From 8f83ee295ac71c92c20ab7a0697afa0844d7fbcf Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 17:08:05 +0900 Subject: [PATCH 01/52] Stabilize readiness status signals --- internal/app/main_test.go | 135 +++++++++++++++++++++++++++++++++- internal/app/plan.go | 17 ++++- internal/app/readiness.go | 149 +++++++++++++++++++++++++++++++------- internal/app/status.go | 25 ++++--- 4 files changed, 285 insertions(+), 41 deletions(-) diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 3d728ce..d70c5f5 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -15,6 +15,7 @@ func TestInitCreatesProjectStateAndRules(t *testing.T) { if err != nil { t.Fatalf("init failed: %v", err) } + assertContains(t, out.Stdout, "Project: Unknown project") assertContains(t, out.Stdout, "Status: initialized") assertContains(t, out.Stdout, "$hyper run") assertContains(t, out.Stdout, "Fill in plan.md") @@ -117,6 +118,8 @@ func TestRunCreatesGoalAfterInit(t *testing.T) { assertContains(t, goal, "Growth loop: Execution -> Evidence -> Pressure Ledger -> Candidate -> Structure when proven.") assertContains(t, goal, "No structure before pressure.") assertContains(t, goal, "## Stage Gate") + assertContains(t, goal, "Gate requirement:") + assertNotContains(t, goal, "Gate evidence:") assertContains(t, goal, "## Stage Runtime Behavior") assertContains(t, goal, "## Active Capabilities") assertContains(t, goal, "## Proof Contract") @@ -364,19 +367,56 @@ func TestStatusHighlightsReferenceBenchmarkWhenRequired(t *testing.T) { } func TestStatusDoesNotShowFutureReferenceBenchmarkBeforeRequired(t *testing.T) { + state := projectState{Project: "Tiny Pet", Stage: "Tiny MVP", Status: "completed", ActiveRunID: "RUN-0013", CurrentGoalID: "GOAL-0013", CurrentGoalPath: ".hyper/goals/GOAL-0013/goal.md", UpdatedAt: "now"} + derived := goalState{State: "completed", Reason: "done"} readiness := readinessState{ Version: 1, Stage: "Tiny MVP", Dimensions: []readinessDimension{ {ID: "core_ux", Name: "Core UX", Status: "covered", Evidence: "Browser smoke covered the primary flow."}, {ID: "validation_coverage", Name: "Validation coverage", Status: "covered", Evidence: "`go test ./...` passed."}, - {ID: "reference_benchmark", Name: "Reference benchmark", Status: "missing", Gap: "Reference comparison has not proven category baseline and differentiating strength."}, + {ID: "reference_benchmark", Name: "Reference benchmark", Status: "emerging", Evidence: "GOAL-0013 readiness evidence needs stronger proof for reference benchmark needs category, 3-5 named references."}, }, - StageGate: readinessStageGate{CurrentStage: "Tiny MVP", NextStage: "Usable MVP", Status: "ready", RequiredAxes: []string{"product_completeness", "core_ux", "validation_coverage"}}, + StageGate: readinessStageGate{CurrentStage: "Tiny MVP", NextStage: "Usable MVP", Status: "ready", RequiredAxes: []string{"product_completeness", "core_ux", "validation_coverage"}, Advancement: stageAdvancementPolicy{Candidate: true}}, + NextPressure: readinessPressure{Axis: "stage_advancement", AxisName: "Stage advancement", Status: "candidate", Reason: "Tiny MVP gate is ready."}, } - out := strings.Join(readinessDashboardLines(readiness), "\n") - assertNotContains(t, out, "Reference benchmark: missing") + dashboard := strings.Join(readinessDashboardLines(readiness), "\n") + assertNotContains(t, dashboard, "Reference benchmark") + assertNotContains(t, dashboard, "Benchmark:") + assertNotContains(t, dashboard, "Emerging axes: Reference benchmark") + + short := strings.Join(statusShortLines(state, derived, readiness, growthState{}), "\n") + assertContains(t, short, "Proof: functional covered, surface covered, operational covered") + assertNotContains(t, short, "benchmark emerging") + assertNotContains(t, short, "Benchmark:") +} + +func TestStatusShortPrioritizesActivePacketGuard(t *testing.T) { + state := projectState{Project: "LLog", Stage: "Beta", Status: "active", ActiveRunID: "RUN-0012", CurrentGoalID: "GOAL-0012", CurrentGoalPath: ".hyper/goals/GOAL-0012/goal.md", UpdatedAt: "now"} + derived := goalState{State: "active", Reason: "Runtime packet evidence is still pending."} + readiness := readinessState{ + Version: 1, + Stage: "Beta", + Dimensions: []readinessDimension{ + {ID: "core_ux", Name: "Core UX", Status: "covered", Evidence: "Browser smoke covered the primary flow."}, + {ID: "validation_coverage", Name: "Validation coverage", Status: "covered", Evidence: "`go test ./...` passed."}, + {ID: "reference_benchmark", Name: "Reference benchmark", Status: "covered", Evidence: "GOAL-0011 readiness evidence: benchmark covered."}, + }, + StageGate: readinessStageGate{ + CurrentStage: "Beta", + NextStage: "Service Quality", + Status: "ready", + RequiredAxes: []string{"validation_coverage", "security_baseline", "deployment_readiness", "operations_docs", "reference_benchmark"}, + Advancement: stageAdvancementPolicy{Candidate: true, Recommendation: "Beta gate is ready."}, + }, + NextPressure: readinessPressure{Axis: "stage_advancement", AxisName: "Stage advancement", Status: "candidate", Reason: "Beta gate is ready."}, + } + + short := strings.Join(statusShortLines(state, derived, readiness, growthState{}), "\n") + assertContains(t, short, "Next: update .hyper/goals/GOAL-0012/evidence.md and next.md, then run `hyper complete`") + assertContains(t, short, "Guard: Do not start another `hyper run` until this packet is completed or blocked.") + assertNotContains(t, short, "Guard: accept the stage change before running `hyper advance`") } func TestRunBlocksPendingActiveGoal(t *testing.T) { @@ -487,6 +527,55 @@ func TestRunAutoUntilPlansNextPacketAfterComplete(t *testing.T) { assertContains(t, readFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "review.md")), "Status: passed") } +func TestStatusAutoTargetReachedExplainsPause(t *testing.T) { + state := projectState{ + Project: "Local Clip Shelf", + Stage: "Service Quality", + Status: "completed", + ActiveRunID: "RUN-0001", + CurrentGoalID: "GOAL-0001", + CurrentGoalPath: ".hyper/goals/GOAL-0001/goal.md", + AutoContinue: true, + RunUntil: "Service Quality", + } + derived := goalState{State: "completed", Reason: "done"} + readiness := readinessState{ + Version: 1, + Stage: "Service Quality", + StageGate: readinessStageGate{ + CurrentStage: "Service Quality", + NextStage: "Sustained Service Quality", + Status: "not_ready", + BlockingGaps: []string{"Maintainability: The codebase has not accumulated enough maintainability evidence."}, + }, + NextPressure: readinessPressure{Axis: "maintainability", AxisName: "Maintainability", Status: "emerging", Reason: "Maintainability is emerging for the Service Quality -> Sustained Service Quality gate."}, + } + + short := strings.Join(statusShortLines(state, derived, readiness, growthState{}), "\n") + assertContains(t, short, "Next: hyper status --short") + assertContains(t, short, "Why: Auto target Service Quality is reached; review status before choosing a new target or manual next run.") + assertNotContains(t, short, "Why: Maintainability is emerging") +} + +func TestStatusAutoTargetReachedDoesNotHideActivePacket(t *testing.T) { + state := projectState{ + Project: "Local Clip Shelf", + Stage: "Service Quality", + Status: "active", + ActiveRunID: "RUN-0002", + CurrentGoalID: "GOAL-0002", + CurrentGoalPath: ".hyper/goals/GOAL-0002/goal.md", + AutoContinue: true, + RunUntil: "Service Quality", + } + derived := goalState{State: "active", Reason: "Runtime packet evidence is still pending."} + readiness := readinessState{Version: 1, Stage: "Service Quality"} + + short := strings.Join(statusShortLines(state, derived, readiness, growthState{}), "\n") + assertContains(t, short, "Next: update .hyper/goals/GOAL-0002/evidence.md and next.md, then run `hyper complete`") + assertContains(t, short, "Why: The current runtime packet is still open") +} + func TestGoalStateTreatsNoRemainingBlockerAsCompleted(t *testing.T) { state := deriveGoalState("## Validation\n\nSmoke passed.\n\n## Blocker\n\nNo remaining blocker for this packet. Final art still needs a designer asset.\n", "## Recommended Next Goal\n\nContinue.\n") if state.State != "completed" { @@ -1646,6 +1735,33 @@ func TestReferenceBenchmarkEvidenceSectionFeedsReadiness(t *testing.T) { } } +func TestReferenceBenchmarkNestedReferencesCountAsNamedReferences(t *testing.T) { + evidence := strings.Join([]string{ + "## Reference Benchmark Evidence", + "", + "- Category: Location-based social chat with map markers.", + "- References: 3-5 named references selected, 5 total: Google Maps; KakaoMap; Snap Map; Pokemon GO; Duolingo.", + "- Named references: Google Maps, KakaoMap, Snap Map, Pokemon GO, Duolingo.", + "- Baseline expectations: Pins stay readable at small sizes; the map remains the primary surface; motion adds life without interrupting map use.", + "- Category baseline: Keep the map readable, keep markers legible at small size, make social presence feel alive.", + "- Current comparison: below baseline = none; meets baseline = pin readability and timed bubble behavior; above baseline = timed jelly pin chat for social presence.", + "- No critical below-baseline gap: No critical below-baseline gap and no critical category-baseline gap were found.", + "- Above-baseline strength: Pickachat has one concrete above-baseline strength: location chat feels alive through jelly mascot pins.", + "- Decision: Service Quality reference benchmark is covered for the current desktop proof.", + "- References:", + " - Google Maps: map markers must be readable and not obscure the map.", + " - KakaoMap: Korean users expect familiar map controls and clear nearby context.", + " - Snap Map: social map presence should feel alive instead of static.", + " - Pokemon GO: map presence should feel playful and legible while preserving location context.", + " - Duolingo: mascot expression should be friendly with a low number of parts.", + }, "\n") + + record := referenceBenchmarkRecordFromExample(t, evidence) + if record.Status != "covered" { + t.Fatalf("expected nested reference benchmark evidence to be covered, got %+v", record) + } +} + func TestReferenceBenchmarkExampleDocsMatchParser(t *testing.T) { body := readFile(t, filepath.Join("..", "..", "docs", "examples", "reference-benchmark.md")) covered := markdownCodeBlockAfterHeading(t, body, "Covered Example") @@ -1691,6 +1807,17 @@ func TestReadinessEvidenceRequiresAxisLabelAndCoversMySQLPersistence(t *testing. } } +func TestReadinessEvidenceCoversPrivacyBoundaryAsSecurityBaseline(t *testing.T) { + defs := readinessDimensionDefs() + record, ok := parseReadinessEvidenceLine("GOAL-0001", "Privacy boundary: clipboard content stays local in SQLite, no cloud sync or telemetry, and sensitive text can be deleted locally.", defs) + if !ok { + t.Fatal("expected privacy boundary evidence to parse") + } + if record.Axis != "security_baseline" || record.Status != "covered" { + t.Fatalf("expected covered security baseline evidence from privacy boundary, got %+v", record) + } +} + func TestGrowthIgnoresNoIssueAndNoChangeSignals(t *testing.T) { root := t.TempDir() if err := ensureProjectLayout(root); err != nil { diff --git a/internal/app/plan.go b/internal/app/plan.go index e9a3fb6..7fb5e45 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -265,12 +265,25 @@ func firstMarkdownHeading(body, prefix string) string { for _, line := range strings.Split(body, "\n") { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, prefix) && !strings.HasPrefix(trimmed, prefix+"#") { - return strings.TrimSpace(strings.TrimPrefix(trimmed, prefix)) + heading := strings.TrimSpace(strings.TrimPrefix(trimmed, prefix)) + if genericPlanTitle(heading) { + continue + } + return heading } } return "" } +func genericPlanTitle(value string) bool { + switch compactPlanHeading(value) { + case "plan", "productplan", "projectplan", "serviceplan", "기획서", "제품기획서", "프로젝트기획서", "서비스기획서": + return true + default: + return false + } +} + func compileGoalEpisode(goalID, focus, planBody string, similar []similarContext, growth growthState, readiness readinessState) episode { plan := parsePlan(planBody) stage := normalizeRuntimeStage(firstRuntimeValue(plan["Current Stage"], "Tiny MVP")) @@ -878,7 +891,7 @@ func buildStageGateDoc(readiness readinessState) string { } } for _, evidence := range readiness.StageGate.RequiredEvidence { - lines = append(lines, "- Gate evidence: "+compactText(evidence, 160)) + lines = append(lines, "- Gate requirement: "+compactText(evidence, 160)) } return strings.Join(lines, "\n") } diff --git a/internal/app/readiness.go b/internal/app/readiness.go index 970f698..d96278e 100644 --- a/internal/app/readiness.go +++ b/internal/app/readiness.go @@ -114,7 +114,7 @@ func readinessDimensionDefs() []readinessDimensionDef { {ID: "persistence", Name: "Data persistence", Keywords: []string{"persist", "persistence", "storage", "database", "sqlite", "mysql", "postgres", "postgresql", "db", "sql", "localstorage", "reload", "save"}, Gap: "User data durability has not been proven."}, {ID: "error_handling", Name: "Error handling", Keywords: []string{"error", "empty", "loading", "failure", "fallback", "blocked", "edge case"}, Gap: "Failure, empty, or edge states are not yet handled."}, {ID: "validation_coverage", Name: "Validation coverage", Keywords: []string{"test", "smoke", "validation", "validate", "playwright", "go test", "npm run", "pytest"}, Gap: "The primary behavior does not have repeatable validation evidence."}, - {ID: "security_baseline", Name: "Security baseline", Keywords: []string{"security", "permission", "rate limit", "secret", "session", "token"}, Gap: "Basic security and misuse boundaries are not yet explicit."}, + {ID: "security_baseline", Name: "Security baseline", Keywords: []string{"security", "privacy", "permission", "rate limit", "secret", "session", "token", "telemetry", "misuse", "data handling"}, Gap: "Basic security, privacy, and misuse boundaries are not yet explicit."}, {ID: "deployment_readiness", Name: "Deployment readiness", Keywords: []string{"deploy", "release", "production", "server", "docker", "ci", "hosted"}, Gap: "The project is not yet proven runnable outside the local development path."}, {ID: "operations_docs", Name: "Operations and docs", Keywords: []string{"readme", "docs", "runbook", "rollback", "logs", "monitor", "environment"}, Gap: "Operational notes, setup, rollback, or handoff docs are not sufficient."}, {ID: "maintainability", Name: "Maintainability", Keywords: []string{"refactor", "cleanup", "component", "module", "architecture", "helper", "table-driven"}, Gap: "The codebase has not accumulated enough maintainability evidence."}, @@ -358,6 +358,12 @@ func readinessAxisForLabel(label string, defs []readinessDimensionDef) string { "test": "validation_coverage", "security": "security_baseline", "securitybaseline": "security_baseline", + "privacy": "security_baseline", + "privacyboundary": "security_baseline", + "dataprivacy": "security_baseline", + "datahandling": "security_baseline", + "misuse": "security_baseline", + "misuseboundary": "security_baseline", "deployment": "deployment_readiness", "deploymentreadiness": "deployment_readiness", "deploy": "deployment_readiness", @@ -434,9 +440,8 @@ func readinessEvidenceQuality(axis, text string) (bool, string) { hasAny(normalized, "passed", "repeatable", "covered", "verified"), "repeatable command, build, test, smoke, or coverage evidence" case "security_baseline": - return hasAny(normalized, "security", "permission", "rate limit", "secret", "session", "token", "auth", "abuse") && - hasAny(normalized, "documented", "verified", "implemented", "checked", "covered"), - "security, permission, token, session, rate-limit, or abuse-boundary evidence" + return securityBaselineEvidenceCovered(normalized), + "security, privacy, permission, token, session, telemetry, data-handling, or misuse-boundary evidence" case "deployment_readiness": return deploymentEvidenceCovered(normalized), "deploy, hosted URL, release, build, artifact, zip, file smoke, Docker, or CI evidence" @@ -478,37 +483,111 @@ func referenceBenchmarkEvidenceQuality(text string) (bool, string) { func parseReferenceBenchmarkEvidence(text string) referenceBenchmarkEvidence { fields := referenceBenchmarkEvidence{} + currentField := "" for _, chunk := range referenceBenchmarkChunks(text) { label, value, ok := strings.Cut(chunk, ":") if !ok { + appendReferenceBenchmarkContinuation(&fields, currentField, chunk) continue } value = strings.TrimSpace(value) compactLabel := compactReadinessLabel(label) + field := referenceBenchmarkFieldForLabel(compactLabel) if value == "" || (isPlaceholder(value) && compactLabel != "belowbaselinegaps" && compactLabel != "belowbaselinegap" && compactLabel != "gaps") { + if field != "" { + currentField = field + } continue } - switch compactLabel { - case "category": - fields.Category = value - case "reference", "references": - fields.References = value - case "baseline", "baselineexpectations", "expectations": - fields.BaselineExpectations = value - case "currentcomparison", "comparison": - fields.CurrentComparison = value - case "belowbaselinegaps", "belowbaselinegap", "gaps": - fields.BelowBaselineGaps = value - case "abovebaselinestrength", "abovebaselinestrengths", "strength": - fields.AboveBaselineStrength = value - case "decision": - fields.Decision = value + if field == "" { + appendReferenceBenchmarkUnknownLabel(&fields, currentField, label, value) + continue } + currentField = field + setReferenceBenchmarkField(&fields, field, value) } fields.ReferenceCount = countReferenceItems(fields.References) return fields } +func referenceBenchmarkFieldForLabel(compactLabel string) string { + switch compactLabel { + case "category": + return "category" + case "reference", "references", "namedreference", "namedreferences": + return "references" + case "baseline", "baselineexpectations", "expectations", "categorybaseline": + return "baseline" + case "currentcomparison", "comparison": + return "comparison" + case "belowbaselinegaps", "belowbaselinegap", "gaps", "nocriticalbelowbaselinegap", "nocorecategorybaselinegap": + return "below_gaps" + case "abovebaselinestrength", "abovebaselinestrengths", "strength", "differentiatingstrength": + return "above_strength" + case "decision": + return "decision" + default: + return "" + } +} + +func setReferenceBenchmarkField(fields *referenceBenchmarkEvidence, field, value string) { + switch field { + case "category": + fields.Category = value + case "references": + fields.References = appendBenchmarkValue(fields.References, value) + case "baseline": + fields.BaselineExpectations = appendBenchmarkValue(fields.BaselineExpectations, value) + case "comparison": + fields.CurrentComparison = appendBenchmarkValue(fields.CurrentComparison, value) + case "below_gaps": + fields.BelowBaselineGaps = appendBenchmarkValue(fields.BelowBaselineGaps, value) + case "above_strength": + fields.AboveBaselineStrength = appendBenchmarkValue(fields.AboveBaselineStrength, value) + case "decision": + fields.Decision = appendBenchmarkValue(fields.Decision, value) + } +} + +func appendReferenceBenchmarkContinuation(fields *referenceBenchmarkEvidence, currentField, value string) { + value = strings.TrimSpace(value) + if value == "" { + return + } + setReferenceBenchmarkField(fields, currentField, value) +} + +func appendReferenceBenchmarkUnknownLabel(fields *referenceBenchmarkEvidence, currentField, label, value string) { + label = strings.TrimSpace(label) + value = strings.TrimSpace(value) + if currentField == "references" { + if referenceCountPrefix(label) { + fields.References = appendBenchmarkValue(fields.References, value) + return + } + fields.References = appendBenchmarkValue(fields.References, label) + return + } + setReferenceBenchmarkField(fields, currentField, strings.TrimSpace(label+": "+value)) +} + +func appendBenchmarkValue(existing, value string) string { + value = strings.TrimSpace(value) + if value == "" { + return existing + } + if strings.TrimSpace(existing) == "" { + return value + } + return existing + "; " + value +} + +func referenceCountPrefix(label string) bool { + normalized := normalizeSentence(label) + return hasAny(normalized, "total", "selected") || strings.ContainsAny(normalized, "0123456789") +} + func referenceBenchmarkChunks(text string) []string { raw := strings.FieldsFunc(text, func(r rune) bool { return r == '\n' || r == ';' @@ -550,26 +629,35 @@ func referenceBenchmarkMissingRequirements(fields referenceBenchmarkEvidence) [] } func countReferenceItems(value string) int { - count := 0 + seen := map[string]bool{} for _, item := range splitReferenceItems(value) { - normalized := normalizeSentence(item) + normalized := normalizeReferenceItem(item) if normalized == "" || isPlaceholder(normalized) { continue } - if hasAny(normalized, "3-5", "three to five", "comparable products", "comparable tools", "comparable apps", "tool a", "tool b", "tool c") { + if hasAny(normalized, "3-5", "three to five", "named references", "comparable products", "comparable tools", "comparable apps", "tool a", "tool b", "tool c") { continue } - count++ + seen[normalized] = true } - return count + return len(seen) } func splitReferenceItems(value string) []string { - replacer := strings.NewReplacer("\n", ",", "|", ",", " / ", ",", " and ", ",") + replacer := strings.NewReplacer("\n", ",", ";", ",", "|", ",", " / ", ",", " and ", ",") normalized := replacer.Replace(value) return strings.Split(normalized, ",") } +func normalizeReferenceItem(item string) string { + item = strings.TrimSpace(item) + if label, value, ok := strings.Cut(item, ":"); ok && referenceCountPrefix(label) { + item = value + } + item = strings.TrimSpace(strings.Trim(item, ".")) + return normalizeSentence(item) +} + func specificBenchmarkField(value string) bool { normalized := normalizeSentence(value) return normalized != "" && !isPlaceholder(normalized) && !hasAny(normalized, "pending", "todo") && len(strings.Fields(normalized)) >= 3 @@ -592,6 +680,17 @@ func noCriticalBelowBaselineGap(value string) bool { return hasAny(normalized, "none", "no critical", "no core", "no below baseline", "no below-baseline", "not blocked", "none blocking") } +func securityBaselineEvidenceCovered(normalized string) bool { + hasSecurityBoundary := hasAny(normalized, + "security", "privacy", "permission", "rate limit", "secret", "session", "token", "auth", "abuse", "misuse", + "telemetry", "data handling", "data boundary", "local only", "local-only", "no cloud", "cloud sync", "sensitive", + ) + hasProof := hasAny(normalized, + "documented", "verified", "implemented", "checked", "covered", "explicit", "no cloud", "no telemetry", "deleted", "delete path", + ) + return hasSecurityBoundary && hasProof +} + func deploymentEvidenceCovered(normalized string) bool { deploymentTarget := hasAny(normalized, "deploy", "deployed", "deployment", "url", "https://", "http://", "build", "release", "hosted", "docker", "ci", diff --git a/internal/app/status.go b/internal/app/status.go index a2ae035..5e0b9b2 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -155,13 +155,16 @@ func statusShortGap(readiness readinessState) string { } func statusShortGuard(state projectState, derived goalState, readiness readinessState, growth growthState) string { - if readiness.StageGate.Advancement.Candidate { - return "accept the stage change before running `hyper advance`" - } warning := statusDoNotDoYet(state, derived, readiness, growth) if strings.HasPrefix(warning, "Do not add broad structure") { return "" } + if derived.State == "active" || (strings.TrimSpace(state.Status) != "" && strings.TrimSpace(state.Status) != strings.TrimSpace(derived.State)) { + return warning + } + if readiness.StageGate.Advancement.Candidate { + return "accept the stage change before running `hyper advance`" + } return warning } @@ -226,6 +229,9 @@ func statusActionReason(state projectState, derived goalState, readiness readine if strings.TrimSpace(state.Status) != "" && strings.TrimSpace(state.Status) != strings.TrimSpace(derived.State) { return "The packet evidence says " + derived.State + " while state.json still says " + state.Status + "; repair before trusting automation." } + if state.AutoContinue && runUntilReached(state, readiness) { + return "Auto target " + state.RunUntil + " is reached; review status before choosing a new target or manual next run." + } if readiness.StageGate.Advancement.Candidate { return readiness.StageGate.Advancement.Recommendation } @@ -404,6 +410,9 @@ func visibleReadinessDimensions(readiness readinessState) []readinessDimension { } visible := []readinessDimension{} for _, dim := range readiness.Dimensions { + if dim.ID == "reference_benchmark" && !referenceBenchmarkRelevant(readiness) { + continue + } if dim.Status != "missing" || required[dim.ID] || dim.ID == readiness.NextPressure.Axis { visible = append(visible, dim) } @@ -420,10 +429,6 @@ func readinessRequiredAxisMap(readiness readinessState) map[string]bool { } func referenceBenchmarkRelevant(readiness readinessState) bool { - dim, ok := readinessDimensionMap(readiness.Dimensions)["reference_benchmark"] - if ok && dim.Status != "" && dim.Status != "missing" { - return true - } return readinessRequiredAxisMap(readiness)["reference_benchmark"] || readiness.NextPressure.Axis == "reference_benchmark" } @@ -453,15 +458,15 @@ func statusNextCommand(state projectState, derived goalState, readiness readines if strings.TrimSpace(state.Status) != "" && strings.TrimSpace(derived.State) != "" && strings.TrimSpace(state.Status) != strings.TrimSpace(derived.State) { return "hyper repair" } - if state.AutoContinue && runUntilReached(state, readiness) { - return "hyper status --short" - } if strings.TrimSpace(state.CurrentGoalID) == "" { return "hyper run [focus]" } if derived.State == "active" { return "update " + strings.TrimSuffix(state.CurrentGoalPath, "goal.md") + "evidence.md and next.md, then run `hyper complete`" } + if state.AutoContinue && runUntilReached(state, readiness) { + return "hyper status --short" + } if readiness.NextPressure.Axis == "stage_advancement" || readiness.StageGate.Advancement.Candidate { return "hyper advance" } From 78fd1474e4dcce83571cc76e7fcf01cf221c66eb Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 17:18:23 +0900 Subject: [PATCH 02/52] Refresh next packet after stage advance --- internal/app/advance.go | 16 +++++-- internal/app/main_test.go | 94 +++++++++++++++++++++++++++++++++++++ internal/app/next_packet.go | 2 +- 3 files changed, 106 insertions(+), 6 deletions(-) diff --git a/internal/app/advance.go b/internal/app/advance.go index da7c9c9..30227d1 100644 --- a/internal/app/advance.go +++ b/internal/app/advance.go @@ -113,6 +113,11 @@ func advanceHyper(fsys fsRoot) (commandOutput, *hyperError) { } } + nextPlan, nextErr := writeNextPacketPlan(root, state, consistency.Derived, updatedReadiness, growth) + if nextErr != nil { + return commandOutput{}, nextErr + } + lines := []string{ "Hyper Run Stage Advance", "", @@ -125,14 +130,15 @@ func advanceHyper(fsys fsRoot) (commandOutput, *hyperError) { lines = append(lines, "Readiness gate: "+readinessGateSummary(updatedReadiness), "Readiness pressure: "+readinessPressureSummary(updatedReadiness), + "Next action: "+nextPlan.Command, + "Why: "+nextPlan.Reason, + "Next packet plan: "+filepath.Join(hyperDir, "next-packet.md"), "", "Next:", - " hyper status", + " "+nextPlan.Command, ) - if updatedReadiness.NextPressure.RecommendedGoal != "" { - lines = append(lines, " hyper run \""+compactText(updatedReadiness.NextPressure.RecommendedGoal, 120)+"\"") - } else { - lines = append(lines, " hyper run [next focus]") + if nextPlan.Command != "hyper status --short" { + lines = append(lines, " hyper status --short") } lines = append(lines, "") return stdout(strings.Join(lines, "\n")), nil diff --git a/internal/app/main_test.go b/internal/app/main_test.go index d70c5f5..01bf314 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -1392,9 +1392,103 @@ func TestAdvanceUpdatesPlanWhenGateReady(t *testing.T) { assertContains(t, out.Stdout, "Stage advanced: Tiny MVP -> Usable MVP") assertContains(t, out.Stdout, "Updated: plan.md Current Stage -> Usable MVP") assertContains(t, out.Stdout, "Readiness gate: Usable MVP -> Beta") + assertContains(t, out.Stdout, "Next packet plan: .hyper/next-packet.md") assertContains(t, readFile(t, filepath.Join(root, "plan.md")), "## Current Stage\n\nUsable MVP") assertContains(t, readFile(t, filepath.Join(root, ".hyper", "state.json")), `"stage": "Usable MVP"`) assertContains(t, readFile(t, filepath.Join(root, ".hyper", "logs", "project.jsonl")), `"stage_advanced"`) + nextPlan := readFile(t, filepath.Join(root, ".hyper", "next-packet.md")) + assertContains(t, nextPlan, "Action: run") + assertNotContains(t, nextPlan, "Command: hyper advance") +} + +func TestAdvanceStopsAutoPlanWhenRunUntilTargetReached(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "plan.md"), strings.Join([]string{ + "# Product Plan", + "", + "## Product", + "", + "Local Clip Shelf", + "", + "## Target Users", + "", + "Developers and operators", + "", + "## MVP", + "", + "Save, search, pin, and restart clipboard snippets.", + "", + "## Current Stage", + "", + "Beta", + "", + "## Build Style", + "", + "Native desktop helper with local SQLite storage.", + "", + "## Success Criteria", + "", + "Primary flow validates with realistic data and local privacy boundaries.", + }, "\n")) + + out, err := runCLI(args("run", "--auto", "--until", "service-quality", "Prepare service quality"), testRoot(root), fakeUpdater{}) + if err != nil { + t.Fatalf("auto run failed: %v", err) + } + assertContains(t, out.Stdout, "Run mode: auto until Service Quality") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), strings.Join([]string{ + "# GOAL-0001 Evidence", + "", + "## Validation", + "", + "`clip-shelf smoke` passed for save, search, pin, and restart using realistic command text.", + "", + "## Readiness Evidence", + "", + "Validation coverage: `clip-shelf smoke` passed and is repeatable for save, search, pin, and restart.", + "Security baseline: Privacy boundary verified: clipboard content stays local in SQLite, no cloud sync or telemetry, and sensitive text can be deleted locally.", + "Deployment readiness: Packaged helper binary smoke passed outside the development command path.", + "Operations and docs: README documents setup, local data path, delete path, rollback, and smoke command.", + "", + "## Reference Benchmark Evidence", + "", + "- Category: Local clipboard history helper.", + "- References: Raycast Clipboard History, Alfred Clipboard History, Maccy.", + "- Baseline expectations: Save recent text, search quickly, pin snippets, and keep local privacy boundaries clear.", + "- Current comparison: below baseline = none for command-helper path; meets baseline = save/search/pin/restart and privacy proof; above baseline = operator command-snippet smoke.", + "- Below-baseline gaps: No critical below-baseline gap for the command-helper path.", + "- Above-baseline strength: Restart persistence and privacy proof are explicit.", + "- Decision: Service Quality is allowed for the helper command path.", + "", + "## Changed Files", + "", + "Prototype helper behavior and docs.", + "", + "## Blocker", + "", + "None blocking.", + }, "\n")) + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nReview stage advancement.\n") + if _, err := runCLI(args("complete"), testRoot(root), fakeUpdater{}); err != nil { + t.Fatalf("complete failed: %v", err) + } + + advance, err := runCLI(args("advance"), testRoot(root), fakeUpdater{}) + if err != nil { + t.Fatalf("advance failed: %v", err) + } + assertContains(t, advance.Stdout, "Stage advanced: Beta -> Service Quality") + assertContains(t, advance.Stdout, "Next action: hyper status --short") + assertContains(t, advance.Stdout, "Why: Auto target Service Quality is reached; review status before choosing a new target or manual next run.") + if strings.Count(advance.Stdout, " hyper status --short") != 1 { + t.Fatalf("expected one status next command, got:\n%s", advance.Stdout) + } + nextPlan := readFile(t, filepath.Join(root, ".hyper", "next-packet.md")) + assertContains(t, nextPlan, "Mode: auto until Service Quality") + assertContains(t, nextPlan, "Action: stop") + assertContains(t, nextPlan, "Command: hyper status --short") + assertContains(t, nextPlan, "Reason: Auto target Service Quality is reached; review status before choosing a new target or manual next run.") + assertNotContains(t, nextPlan, "Command: hyper advance") } func TestAdvanceRejectsWhenGateNotReady(t *testing.T) { diff --git a/internal/app/next_packet.go b/internal/app/next_packet.go index 8e6908c..9ac3461 100644 --- a/internal/app/next_packet.go +++ b/internal/app/next_packet.go @@ -18,7 +18,7 @@ func buildNextPacketPlan(state projectState, derived goalState, readiness readin return plannedNextPacket{ Action: "stop", Command: "hyper status --short", - Reason: "Run-until target reached: " + state.RunUntil, + Reason: firstNonBlank(statusActionReason(state, derived, readiness, growth), "Run-until target reached: "+state.RunUntil), Terminal: true, } } From 0a32728a45f94771f6374d124d8cd5b5f87c664d Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 17:46:35 +0900 Subject: [PATCH 03/52] Require sustained quality evidence --- internal/app/doctor.go | 32 ++++++++-- internal/app/finish_gate.go | 40 ++++++++++-- internal/app/growth.go | 10 --- internal/app/main_test.go | 121 +++++++++++++++++++++++++++++++++++- internal/app/readiness.go | 51 ++++++++++++++- 5 files changed, 232 insertions(+), 22 deletions(-) diff --git a/internal/app/doctor.go b/internal/app/doctor.go index d97624c..fd24423 100644 --- a/internal/app/doctor.go +++ b/internal/app/doctor.go @@ -170,17 +170,39 @@ func sameReadinessForDoctor(a, b readinessState) bool { } aDims := readinessDimensionMap(a.Dimensions) bDims := readinessDimensionMap(b.Dimensions) - if len(aDims) != len(bDims) { - return false - } - for id, aDim := range aDims { - if bDims[id].Status != aDim.Status { + for _, id := range doctorRelevantReadinessAxes(a, b) { + aDim := aDims[id] + bDim := bDims[id] + if aDim.ID == "" && bDim.ID == "" { + continue + } + if bDim.Status != aDim.Status { return false } } return true } +func doctorRelevantReadinessAxes(states ...readinessState) []string { + seen := map[string]bool{} + axes := []string{} + add := func(axis string) { + axis = strings.TrimSpace(axis) + if axis == "" || seen[axis] { + return + } + seen[axis] = true + axes = append(axes, axis) + } + for _, state := range states { + for _, axis := range state.StageGate.RequiredAxes { + add(axis) + } + add(state.NextPressure.Axis) + } + return axes +} + func readinessDoctorSummary(readiness readinessState) string { return readinessGateSummary(readiness) + " / pressure " + firstNonBlank(readiness.NextPressure.Axis, "none") } diff --git a/internal/app/finish_gate.go b/internal/app/finish_gate.go index 7293ffd..38b797b 100644 --- a/internal/app/finish_gate.go +++ b/internal/app/finish_gate.go @@ -68,6 +68,14 @@ func readinessFinishGateFinding(state projectState, evidenceText string, readine return "" } records := readinessEvidenceRecordsFromGoalText(state.CurrentGoalID, evidenceText) + if axis == "sustained_quality" { + for _, record := range records { + if record.Axis == axis { + return "" + } + } + return "Add sustained quality evidence that records repeated runtime proof or a real blocker." + } for _, record := range records { if record.Axis == axis && record.Status == "covered" { return "" @@ -81,14 +89,36 @@ func activeCapabilityFinishGateFinding(root, evidenceText string) string { if err != nil || len(validators) == 0 { return "" } - if hasNonPendingSection(evidenceText, "Active Capability Evidence") { + lines := usefulSectionLines(evidenceText, "Active Capability Evidence") + missing := []string{} + for _, validator := range validators { + if activeCapabilityEvidenceCovers(validator, lines) { + continue + } + missing = append(missing, validator.Name) + } + if len(missing) == 0 { return "" } - names := []string{} - for _, validator := range validators { - names = append(names, validator.Name) + return "Record active capability evidence for: " + strings.Join(missing, ", ") +} + +func activeCapabilityEvidenceCovers(validator activeValidatorCapability, lines []string) bool { + if len(lines) == 0 { + return false + } + name := normalizeSentence(validator.Name) + command := normalizeSentence(inferredCommandForSignal(validator.Signal)) + for _, line := range lines { + normalized := normalizeSentence(line) + if name != "" && strings.Contains(normalized, name) { + return true + } + if command != "" && strings.Contains(normalized, command) { + return true + } } - return "Record active capability evidence for: " + strings.Join(names, ", ") + return false } func readinessEvidenceRecordsFromGoalText(goalID, evidenceText string) []readinessEvidenceRecord { diff --git a/internal/app/growth.go b/internal/app/growth.go index 10c1c46..5bab6b7 100644 --- a/internal/app/growth.go +++ b/internal/app/growth.go @@ -859,12 +859,6 @@ func writeGrowthCandidate(root string, candidate growthCandidate, pressure growt if err := writeText(filepath.Join(root, candidate.LifecyclePath), body); err != nil { return err } - if candidate.Status != "retired" { - candidatePath := filepath.Join(root, hyperDir, "capabilities", "candidates", candidate.Kind, candidate.Name+".md") - if candidatePath != filepath.Join(root, candidate.LifecyclePath) { - return writeText(candidatePath, body) - } - } return nil } @@ -972,13 +966,9 @@ func firstBacktickCommand(value string) string { func removeConflictingLifecycleCopies(root string, candidate growthCandidate) *hyperError { lifecyclePath := filepath.Join(root, candidate.LifecyclePath) - candidatePath := filepath.Join(root, hyperDir, "capabilities", "candidates", candidate.Kind, candidate.Name+".md") for _, bucket := range []string{"candidates", "active", "retired"} { path := filepath.Join(root, hyperDir, "capabilities", bucket, candidate.Kind, candidate.Name+".md") keep := path == lifecyclePath - if candidate.Status != "retired" && path == candidatePath { - keep = true - } if keep { continue } diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 01bf314..dc203f5 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -235,6 +235,29 @@ func TestDoctorWarnsWhenStoredReadinessIsStale(t *testing.T) { assertContains(t, out.Stdout, "Run `hyper migrate`") } +func TestDoctorReadinessComparisonIgnoresIrrelevantFutureAxes(t *testing.T) { + stored := readinessState{ + Stage: "Tiny MVP", + StageGate: readinessStageGate{ + CurrentStage: "Tiny MVP", + NextStage: "Usable MVP", + Status: "ready", + RequiredAxes: []string{"product_completeness", "core_ux", "validation_coverage"}, + }, + NextPressure: readinessPressure{Axis: "stage_advancement"}, + Dimensions: []readinessDimension{ + {ID: "product_completeness", Status: "covered"}, + {ID: "core_ux", Status: "covered"}, + {ID: "validation_coverage", Status: "covered"}, + }, + } + current := stored + current.Dimensions = append(current.Dimensions, readinessDimension{ID: "sustained_quality", Status: "missing"}) + if !sameReadinessForDoctor(stored, current) { + t.Fatal("doctor should not warn when only an irrelevant future-stage axis was added") + } +} + func TestRepairReconcilesStaleProjectState(t *testing.T) { root := t.TempDir() mustInitWithPlan(t, root, "Tiny notes", "Build a tiny notes MVP") @@ -498,6 +521,40 @@ func TestCompleteRunsFinishGateBeforeLearning(t *testing.T) { assertNotContains(t, readFile(t, filepath.Join(root, ".hyper", "state.json")), `"status": "completed"`) } +func TestCompleteRequiresSpecificActiveCapabilityEvidence(t *testing.T) { + root := t.TempDir() + mustInitWithPlan(t, root, "Tiny CLI", "Build a tiny CLI MVP") + mustRun(t, root, "run") + writeFile(t, filepath.Join(root, ".hyper", "capabilities", "active", "validator", "validator-go-test.md"), "# validator-go-test\n\nStatus: active\nKind: validator\nSignal: Run go test ./... before completing packets.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\n`go test ./...` passed.\n\n## Readiness Evidence\n\nCore UX: CLI smoke verified create and complete flow.\nValidation coverage: `go test ./...` passed and primary CLI smoke is repeatable.\n\n## Active Capability Evidence\n\nNone active.\n\n## Blocker\n\nNone blocking.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nReview stage advancement.\n") + + _, err := runCLI(args("complete"), testRoot(root), fakeUpdater{}) + if err == nil { + t.Fatal("expected active capability evidence to name or prove the validator") + } + assertContains(t, err.Message, "Record active capability evidence for: validator-go-test") + + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\n`go test ./...` passed.\n\n## Readiness Evidence\n\nCore UX: CLI smoke verified create and complete flow.\nValidation coverage: `go test ./...` passed and primary CLI smoke is repeatable.\n\n## Active Capability Evidence\n\nvalidator-go-test: `go test ./...` passed.\n\n## Blocker\n\nNone blocking.\n") + if _, err := runCLI(args("complete"), testRoot(root), fakeUpdater{}); err != nil { + t.Fatalf("complete should accept named active capability evidence: %v", err) + } +} + +func TestCompleteAllowsEmergingSustainedQualityEvidence(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "plan.md"), "# Product Plan\n\n## Product\n\nLocal Build Relay\n\n## Target Users\n\nDevelopers\n\n## MVP\n\nRun one handoff command.\n\n## Current Stage\n\nService Quality\n\n## Build Style\n\nGo CLI\n\n## Success Criteria\n\nEvery packet proves the handoff command.\n") + if _, err := runCLI(args("run", "Repeat handoff validation"), testRoot(root), fakeUpdater{}); err != nil { + t.Fatalf("run failed: %v", err) + } + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\n`go test ./...` passed.\n\n## Readiness Evidence\n\nSustained quality: Repeated runtime evidence exists for the same handoff validation pattern, but it is not active required behavior yet.\n\n## Blocker\n\nNone blocking.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nRepeat validation again.\n") + + if _, err := runCLI(args("complete"), testRoot(root), fakeUpdater{}); err != nil { + t.Fatalf("emerging sustained quality evidence should allow packet closure: %v", err) + } +} + func TestRunAutoUntilPlansNextPacketAfterComplete(t *testing.T) { root := t.TempDir() mustRun(t, root, "init") @@ -1019,6 +1076,9 @@ func TestGrowthClustersSignalsAndPromotesLifecycle(t *testing.T) { t.Fatalf("expected active candidate, got %+v", state.Candidates[0]) } assertContains(t, readFile(t, filepath.Join(root, ".hyper", "capabilities", "active", "validator", "validator-go-test.md")), "Status: active") + if exists(filepath.Join(root, ".hyper", "capabilities", "candidates", "validator", "validator-go-test.md")) { + t.Fatal("active validator should move out of candidates") + } if _, err := db.Exec(`update memories set stale_at = ? where kind = ?`, nowISO(), "pattern"); err != nil { t.Fatalf("stale update failed: %v", err) @@ -1760,7 +1820,7 @@ func TestServiceQualityStageDefinesOperationalStandard(t *testing.T) { } _, _, axes, evidence := readinessGateDefinition("Service Quality") - for _, axis := range []string{"validation_coverage", "security_baseline", "deployment_readiness", "operations_docs", "maintainability", "reference_benchmark"} { + for _, axis := range []string{"validation_coverage", "security_baseline", "deployment_readiness", "operations_docs", "maintainability", "reference_benchmark", "sustained_quality"} { found := false for _, got := range axes { if got == axis { @@ -1777,6 +1837,46 @@ func TestServiceQualityStageDefinesOperationalStandard(t *testing.T) { assertContains(t, joinedEvidence, "rollback") assertContains(t, joinedEvidence, "hidden context") assertContains(t, joinedEvidence, "Reference benchmark evidence") + assertContains(t, joinedEvidence, "Repeated runtime evidence") +} + +func TestServiceQualityGateRequiresSustainedGrowthEvidence(t *testing.T) { + plan := map[string]string{ + "Product": "Local Build Relay", + "Current Stage": "Service Quality", + "Success Criteria": "Every packet proves the handoff command.", + } + evidence := []readinessEvidenceRecord{ + readinessEvidenceRecordForAxis("GOAL-0001", "validation_coverage", "`go test ./...` passed and is repeatable."), + readinessEvidenceRecordForAxis("GOAL-0001", "security_baseline", "Security baseline: Privacy boundary verified, no cloud sync, no telemetry, and no secrets."), + readinessEvidenceRecordForAxis("GOAL-0001", "deployment_readiness", "Deployment readiness: Packaged CLI smoke passed outside the development command."), + readinessEvidenceRecordForAxis("GOAL-0001", "operations_docs", "Operations and docs: README documents setup, rollback, and smoke command."), + readinessEvidenceRecordForAxis("GOAL-0001", "maintainability", "Maintainability: Test helper keeps command validation repeatable without hidden local context."), + readinessEvidenceRecordForAxis("GOAL-0001", "reference_benchmark", strings.Join([]string{ + "Category: Local developer handoff CLI.", + "References: GitHub CLI, Taskfile, Make.", + "Baseline expectations: documented command, repeatable output, rollback, no hidden credentials.", + "Current comparison: below baseline = none; meets baseline = command/test/docs/rollback; above baseline = packet evidence loop.", + "Below-baseline gaps: No critical below-baseline gap.", + "Above-baseline strength: packet evidence loop.", + "Decision: Service Quality proof can continue.", + }, "; ")), + } + + state := deriveReadinessState(plan, growthState{}, evidence) + if state.StageGate.Status != "not_ready" { + t.Fatalf("single service-quality packet must not unlock sustained quality, got %+v", state.StageGate) + } + if state.NextPressure.Axis != "sustained_quality" { + t.Fatalf("expected sustained quality pressure, got %+v", state.NextPressure) + } + assertContains(t, strings.Join(state.StageGate.BlockingGaps, "\n"), "Sustained quality") + + growth := growthState{Candidates: []growthCandidate{{Kind: "validator", Name: "validator-go-test", Status: "active"}}} + state = deriveReadinessState(plan, growth, evidence) + if state.StageGate.Status != "ready" { + t.Fatalf("active validator should unlock sustained quality gate, got %+v", state.StageGate) + } } func TestReferenceBenchmarkEvidenceTemplateForBetaAndServiceQuality(t *testing.T) { @@ -1912,6 +2012,25 @@ func TestReadinessEvidenceCoversPrivacyBoundaryAsSecurityBaseline(t *testing.T) } } +func TestSustainedQualityEvidenceDoesNotTreatNotActiveAsCovered(t *testing.T) { + defs := readinessDimensionDefs() + record, ok := parseReadinessEvidenceLine("GOAL-0002", "Sustained quality: Repeated runtime evidence exists for the same handoff validation pattern, but it is not active required behavior yet.", defs) + if !ok { + t.Fatal("expected sustained quality evidence to parse") + } + if record.Axis != "sustained_quality" || record.Status != "emerging" { + t.Fatalf("expected emerging sustained quality evidence, got %+v", record) + } + + covered, ok := parseReadinessEvidenceLine("GOAL-0004", "Sustained quality: Active validator validator-go-test is required and verified before every packet handoff.", defs) + if !ok { + t.Fatal("expected active sustained quality evidence to parse") + } + if covered.Axis != "sustained_quality" || covered.Status != "covered" { + t.Fatalf("expected covered sustained quality evidence, got %+v", covered) + } +} + func TestGrowthIgnoresNoIssueAndNoChangeSignals(t *testing.T) { root := t.TempDir() if err := ensureProjectLayout(root); err != nil { diff --git a/internal/app/readiness.go b/internal/app/readiness.go index d96278e..dcb8c06 100644 --- a/internal/app/readiness.go +++ b/internal/app/readiness.go @@ -119,6 +119,7 @@ func readinessDimensionDefs() []readinessDimensionDef { {ID: "operations_docs", Name: "Operations and docs", Keywords: []string{"readme", "docs", "runbook", "rollback", "logs", "monitor", "environment"}, Gap: "Operational notes, setup, rollback, or handoff docs are not sufficient."}, {ID: "maintainability", Name: "Maintainability", Keywords: []string{"refactor", "cleanup", "component", "module", "architecture", "helper", "table-driven"}, Gap: "The codebase has not accumulated enough maintainability evidence."}, {ID: "reference_benchmark", Name: "Reference benchmark", Keywords: []string{"reference", "benchmark", "baseline", "category", "comparison", "comparable", "above baseline", "below baseline"}, Gap: "Reference comparison has not proven category baseline and differentiating strength."}, + {ID: "sustained_quality", Name: "Sustained quality", Keywords: []string{"sustained", "repeated evidence", "active validator", "active harness", "repeated pressure"}, Gap: "Sustained quality needs repeated runtime evidence and an active validator or equivalent reusable quality structure."}, } } @@ -380,6 +381,10 @@ func readinessAxisForLabel(label string, defs []readinessDimensionDef) string { "referencebenchmark": "reference_benchmark", "baseline": "reference_benchmark", "comparison": "reference_benchmark", + "sustainedquality": "sustained_quality", + "sustained": "sustained_quality", + "activevalidator": "sustained_quality", + "activeharness": "sustained_quality", } if axis := aliases[compact]; axis != "" { return axis @@ -453,6 +458,9 @@ func readinessEvidenceQuality(axis, text string) (bool, string) { "refactor, modularity, test, helper, cleanup, or architecture evidence" case "reference_benchmark": return referenceBenchmarkEvidenceQuality(normalized) + case "sustained_quality": + return sustainedQualityEvidenceCovered(normalized), + "active validator, active harness, or active capability evidence" default: return len(strings.Fields(normalized)) >= 4, "specific evidence for this readiness axis" } @@ -691,6 +699,36 @@ func securityBaselineEvidenceCovered(normalized string) bool { return hasSecurityBoundary && hasProof } +func sustainedQualityEvidenceCovered(normalized string) bool { + if hasAny(normalized, "not active", "not yet active", "not required behavior yet", "not active required behavior") { + return false + } + return hasAny(normalized, "active validator", "active harness", "active capability") && + hasAny(normalized, "promoted", "required", "covered", "verified", "proved", "proven", "active") +} + +func sustainedQualityGrowthEvidence(growth growthState) (bool, bool, string) { + for _, candidate := range growth.Candidates { + if candidate.Status != "active" { + continue + } + if candidate.Kind == "validator" || candidate.Kind == "harness" { + return true, true, "Active " + candidate.Kind + " " + candidate.Name + " proves repeated quality pressure became required behavior." + } + } + for _, candidate := range growth.Candidates { + if candidate.Status == "promotable" || candidate.Status == "repeated" { + return false, true, "Repeated quality pressure exists but has not become active required behavior yet: " + candidate.Name + } + } + for _, pressure := range growth.Pressures { + if pressure.GoalCount >= growthRepeatedSignalGoals && (pressure.Effect == "validation" || pressure.Effect == "harness") { + return false, true, "Repeated quality pressure exists but has not crossed the active threshold yet: " + pressure.Signal + } + } + return false, false, "" +} + func deploymentEvidenceCovered(normalized string) bool { deploymentTarget := hasAny(normalized, "deploy", "deployed", "deployment", "url", "https://", "http://", "build", "release", "hosted", "docker", "ci", @@ -715,6 +753,9 @@ func operationsDocsEvidenceCovered(normalized string) bool { } func growthEvidenceForDimension(growth growthState, def readinessDimensionDef) (bool, bool, string) { + if def.ID == "sustained_quality" { + return sustainedQualityGrowthEvidence(growth) + } for _, pressure := range growth.Pressures { if !pressureMatchesReadiness(pressure, def) { continue @@ -742,6 +783,8 @@ func pressureMatchesReadiness(pressure growthPressure, def readinessDimensionDef return pressure.Effect == "stop_condition" || hasAny(signal, def.Keywords...) case "maintainability": return pressure.Effect == "implementation" || hasAny(signal, def.Keywords...) + case "sustained_quality": + return pressure.GoalCount >= growthRepeatedSignalGoals && (pressure.Effect == "validation" || pressure.Effect == "harness") default: return hasAny(signal, def.Keywords...) } @@ -809,13 +852,14 @@ func readinessGateDefinition(stage string) (string, string, []string, []string) normalized := normalizeLabel(stage) if strings.Contains(normalized, "service") || strings.Contains(normalized, "production") { return "Service Quality", "Sustained Service Quality", - []string{"validation_coverage", "security_baseline", "deployment_readiness", "operations_docs", "maintainability", "reference_benchmark"}, + []string{"validation_coverage", "security_baseline", "deployment_readiness", "operations_docs", "maintainability", "reference_benchmark", "sustained_quality"}, []string{ "Required validation or documented manual checks are repeatable.", "Security, privacy, and misuse boundaries are explicit and verified.", "Setup, release or run, rollback, and recovery paths are documented and checked.", "Maintainability evidence shows the next operator can continue without hidden context.", "Reference benchmark evidence shows no core category-baseline gap and at least one above-baseline strength.", + "Repeated runtime evidence has promoted an active validator, active harness, or equivalent reusable quality structure.", } } if strings.Contains(normalized, "beta") { @@ -876,6 +920,9 @@ func readinessPressureForDimension(plan map[string]string, stage string, dim rea if dim.ID == "reference_benchmark" { workBoundary = "Compare the current result against 3-5 named category references before adding feature breadth; close only the strongest critical below-baseline gap if one is found." validationSignal = "Fill Reference Benchmark Evidence with named references, baseline expectations, current comparison, below-baseline gaps, above-baseline strength, and decision." + } else if dim.ID == "sustained_quality" { + workBoundary = "Do not claim sustained quality from one good packet. Repeat the highest-value validation or operational proof until it becomes active required behavior." + validationSignal = "Record repeated packet evidence and the active validator, active harness, or equivalent reusable quality structure that now protects the service." } return readinessPressure{ Axis: dim.ID, @@ -912,6 +959,8 @@ func readinessRecommendedGoal(plan map[string]string, stage, axis string) string return fmt.Sprintf("Reduce the highest-friction code path so %s can keep growing.", product) case "reference_benchmark": return fmt.Sprintf("Compare %s against 3-5 named category references, define the baseline, and close the strongest critical below-baseline gap if one exists.", product) + case "sustained_quality": + return fmt.Sprintf("Repeat the highest-value %s quality proof until active validation or an equivalent reusable quality structure is justified.", product) default: return fmt.Sprintf("Advance %s toward %s readiness.", product, stage) } From bf3142c5b23c1ed1a41d9c8f74dafc9c403b45d8 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 17:54:17 +0900 Subject: [PATCH 04/52] Enforce active capability evidence --- internal/app/finish_gate.go | 16 ++++----- internal/app/growth.go | 67 ++++++++++++++++++++++++++++++++----- internal/app/main_test.go | 7 ++-- internal/app/plan.go | 2 +- 4 files changed, 73 insertions(+), 19 deletions(-) diff --git a/internal/app/finish_gate.go b/internal/app/finish_gate.go index 38b797b..38911ca 100644 --- a/internal/app/finish_gate.go +++ b/internal/app/finish_gate.go @@ -85,17 +85,17 @@ func readinessFinishGateFinding(state projectState, evidenceText string, readine } func activeCapabilityFinishGateFinding(root, evidenceText string) string { - validators, err := activeValidatorCapabilities(root) - if err != nil || len(validators) == 0 { + capabilities, err := activeCapabilities(root) + if err != nil || len(capabilities) == 0 { return "" } lines := usefulSectionLines(evidenceText, "Active Capability Evidence") missing := []string{} - for _, validator := range validators { - if activeCapabilityEvidenceCovers(validator, lines) { + for _, capability := range capabilities { + if activeCapabilityEvidenceCovers(capability, lines) { continue } - missing = append(missing, validator.Name) + missing = append(missing, capability.Name) } if len(missing) == 0 { return "" @@ -103,12 +103,12 @@ func activeCapabilityFinishGateFinding(root, evidenceText string) string { return "Record active capability evidence for: " + strings.Join(missing, ", ") } -func activeCapabilityEvidenceCovers(validator activeValidatorCapability, lines []string) bool { +func activeCapabilityEvidenceCovers(capability activeCapability, lines []string) bool { if len(lines) == 0 { return false } - name := normalizeSentence(validator.Name) - command := normalizeSentence(inferredCommandForSignal(validator.Signal)) + name := normalizeSentence(capability.Name) + command := normalizeSentence(inferredCommandForSignal(capability.Signal)) for _, line := range lines { normalized := normalizeSentence(line) if name != "" && strings.Contains(normalized, name) { diff --git a/internal/app/growth.go b/internal/app/growth.go index 5bab6b7..70de883 100644 --- a/internal/app/growth.go +++ b/internal/app/growth.go @@ -449,21 +449,22 @@ func growthBehaviorWithActiveCapabilities(root string, pressures []growthPressur return behavior, nil } -type activeValidatorCapability struct { +type activeCapability struct { + Kind string Name string Signal string } -func activeValidatorCapabilities(root string) ([]activeValidatorCapability, *hyperError) { +func activeValidatorCapabilities(root string) ([]activeCapability, *hyperError) { dir := filepath.Join(root, hyperDir, "capabilities", "active", "validator") entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { - return []activeValidatorCapability{}, nil + return []activeCapability{}, nil } return nil, ioError(err) } - validators := []activeValidatorCapability{} + validators := []activeCapability{} for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { continue @@ -487,10 +488,60 @@ func activeValidatorCapabilities(root string) ([]activeValidatorCapability, *hyp return validators, nil } -func parseActiveValidatorCapability(filename, body string) (activeValidatorCapability, bool) { +func activeCapabilities(root string) ([]activeCapability, *hyperError) { + dir := filepath.Join(root, hyperDir, "capabilities", "active") + kindEntries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return []activeCapability{}, nil + } + return nil, ioError(err) + } + capabilities := []activeCapability{} + for _, kindEntry := range kindEntries { + if !kindEntry.IsDir() { + continue + } + kind := kindEntry.Name() + entries, readErr := os.ReadDir(filepath.Join(dir, kind)) + if readErr != nil { + return nil, ioError(readErr) + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { + continue + } + path := filepath.Join(dir, kind, entry.Name()) + body, bodyErr := os.ReadFile(path) + if bodyErr != nil { + return nil, ioError(bodyErr) + } + capability, ok := parseActiveCapability(kind, entry.Name(), string(body)) + if ok { + capabilities = append(capabilities, capability) + } + } + } + sort.Slice(capabilities, func(i, j int) bool { + if capabilities[i].Kind == capabilities[j].Kind { + if capabilities[i].Name == capabilities[j].Name { + return capabilities[i].Signal < capabilities[j].Signal + } + return capabilities[i].Name < capabilities[j].Name + } + return capabilities[i].Kind < capabilities[j].Kind + }) + return capabilities, nil +} + +func parseActiveValidatorCapability(filename, body string) (activeCapability, bool) { + return parseActiveCapability("validator", filename, body) +} + +func parseActiveCapability(kind, filename, body string) (activeCapability, bool) { status := capabilityField(body, "Status") if status != "" && normalizeLabel(status) != "active" { - return activeValidatorCapability{}, false + return activeCapability{}, false } name := firstNonBlank(markdownTitle(body), strings.TrimSuffix(filename, filepath.Ext(filename))) signal := firstNonBlank( @@ -499,9 +550,9 @@ func parseActiveValidatorCapability(filename, body string) (activeValidatorCapab firstSectionLine(body, "Validation"), ) if name == "" || signal == "" { - return activeValidatorCapability{}, false + return activeCapability{}, false } - return activeValidatorCapability{Name: name, Signal: oneLine(signal)}, true + return activeCapability{Kind: kind, Name: name, Signal: oneLine(signal)}, true } func markdownTitle(body string) string { diff --git a/internal/app/main_test.go b/internal/app/main_test.go index dc203f5..6e23620 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -526,6 +526,7 @@ func TestCompleteRequiresSpecificActiveCapabilityEvidence(t *testing.T) { mustInitWithPlan(t, root, "Tiny CLI", "Build a tiny CLI MVP") mustRun(t, root, "run") writeFile(t, filepath.Join(root, ".hyper", "capabilities", "active", "validator", "validator-go-test.md"), "# validator-go-test\n\nStatus: active\nKind: validator\nSignal: Run go test ./... before completing packets.\n") + writeFile(t, filepath.Join(root, ".hyper", "capabilities", "active", "harness", "harness-growth-candidate.md"), "# harness-growth-candidate\n\nStatus: active\nKind: harness\n\n## Required Behavior\n\nRun the project-specific handoff harness before completing packets.\n") writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\n`go test ./...` passed.\n\n## Readiness Evidence\n\nCore UX: CLI smoke verified create and complete flow.\nValidation coverage: `go test ./...` passed and primary CLI smoke is repeatable.\n\n## Active Capability Evidence\n\nNone active.\n\n## Blocker\n\nNone blocking.\n") writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nReview stage advancement.\n") @@ -533,9 +534,11 @@ func TestCompleteRequiresSpecificActiveCapabilityEvidence(t *testing.T) { if err == nil { t.Fatal("expected active capability evidence to name or prove the validator") } - assertContains(t, err.Message, "Record active capability evidence for: validator-go-test") + assertContains(t, err.Message, "Record active capability evidence for:") + assertContains(t, err.Message, "validator-go-test") + assertContains(t, err.Message, "harness-growth-candidate") - writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\n`go test ./...` passed.\n\n## Readiness Evidence\n\nCore UX: CLI smoke verified create and complete flow.\nValidation coverage: `go test ./...` passed and primary CLI smoke is repeatable.\n\n## Active Capability Evidence\n\nvalidator-go-test: `go test ./...` passed.\n\n## Blocker\n\nNone blocking.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\n`go test ./...` passed.\n\n## Readiness Evidence\n\nCore UX: CLI smoke verified create and complete flow.\nValidation coverage: `go test ./...` passed and primary CLI smoke is repeatable.\n\n## Active Capability Evidence\n\nvalidator-go-test: `go test ./...` passed.\nharness-growth-candidate: project-specific handoff harness passed.\n\n## Blocker\n\nNone blocking.\n") if _, err := runCLI(args("complete"), testRoot(root), fakeUpdater{}); err != nil { t.Fatalf("complete should accept named active capability evidence: %v", err) } diff --git a/internal/app/plan.go b/internal/app/plan.go index 7fb5e45..2489c7f 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -783,7 +783,7 @@ func doneChecklistDoc(stage string, readiness readinessState, growth growthState lines = append(lines, "- Readiness Evidence includes concrete proof for "+readiness.NextPressure.AxisName+".") } if activeStructureCount(growth.Candidates) > 0 { - lines = append(lines, "- Active Capability Evidence shows each active validator ran or why it was blocked.") + lines = append(lines, "- Active Capability Evidence shows each active validator, skill, or harness ran or why it was blocked.") } if strings.Contains(normalizeLabel(stage), "beta") || strings.Contains(normalizeLabel(stage), "service") { lines = append(lines, "- Stop conditions cover failure, regression, and missing credential cases found during this packet.") From e0f0f81e38a2a4268cbcee184ebb412afaeede08 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 18:12:09 +0900 Subject: [PATCH 05/52] Stabilize sustained quality flow --- README.md | 3 + internal/app/app.go | 2 +- internal/app/concepts.go | 2 + internal/app/main_test.go | 158 ++++++++++++++++++++++++++++++++++++ internal/app/next_packet.go | 2 + internal/app/plan.go | 15 ++++ internal/app/readiness.go | 63 ++++++++++++-- internal/app/run_options.go | 6 +- internal/app/storage.go | 10 +++ 9 files changed, 250 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 1ddbf3a..b3afd06 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,8 @@ For longer Codex Desktop sessions, start with an auto target: hyper run --auto --until service-quality "Keep upgrading this service" ``` +Use `--until sustained-service-quality` when the goal is to keep planning packets after Service Quality and focus on repeatable validators, operational handoff, and friction reduction. + Auto mode does not skip proof or silently advance stages. It keeps the next packet command planned in `.hyper/next-packet.md`; stage changes still require explicit acceptance with `hyper advance`. In Codex Desktop you can use the same idea as a project command: @@ -397,6 +399,7 @@ That updates `plan.md` from the current stage to the next stage, refreshes readi hyper init # install Hyper Run files in this project hyper run [focus] # create the next runtime packet hyper run --auto --until service-quality [focus] +hyper run --auto --until sustained-service-quality [focus] hyper complete # run the finish gate, close the packet, and learn hyper advance # apply an accepted stage change when the gate is ready hyper status # show current stage, gaps, and readiness diff --git a/internal/app/app.go b/internal/app/app.go index 2a75d93..96553d8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -113,7 +113,7 @@ func usage() string { "Primary flow:", " Run `hyper init` once in a project to install Hyper Run settings.", " Edit plan.md, then use `hyper run [focus]` to create the next runtime packet.", - " Use `hyper run --auto --until service-quality [focus]` when Codex should keep planning the next packet until the target stage.", + " Use `hyper run --auto --until service-quality [focus]` or `--until sustained-service-quality` when Codex should keep planning packets toward a target stage.", " After updating evidence.md and next.md, use `hyper complete` to turn evidence into pressure, candidates, and readiness.", " `hyper complete` runs the finish gate first; fix review.md findings in the same packet before continuing.", " When `hyper status` says the stage gate is ready, use `hyper advance` to apply the accepted stage change.", diff --git a/internal/app/concepts.go b/internal/app/concepts.go index 1a3c3a0..6fdc0f5 100644 --- a/internal/app/concepts.go +++ b/internal/app/concepts.go @@ -33,6 +33,8 @@ func stageGrowthContract(stage string) string { return "Usability proof: make the primary flow usable end-to-end for a real user." case strings.Contains(normalized, "beta"): return "Repeatability proof: prove reliability around realistic data, failures, validation, docs, and release readiness." + case strings.Contains(normalized, "sustained"): + return "Sustained operation proof: keep service quality protected by repeated evidence, active capabilities, and focused friction reduction." case strings.Contains(normalized, "service") || strings.Contains(normalized, "production"): return "Operability proof: treat security, deployment, operations, rollback, and repeatable validation as required product behavior." default: diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 6e23620..2004d19 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -39,6 +39,34 @@ func TestInitCreatesProjectStateAndRules(t *testing.T) { assertContains(t, readFile(t, filepath.Join(root, ".hyper", "readiness", "state.json")), `"version": 1`) } +func TestOpenDBConfiguresBusyTimeout(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, hyperDir), 0755); err != nil { + t.Fatal(err) + } + db, herr := openDB(root) + if herr != nil { + t.Fatalf("openDB failed: %v", herr) + } + defer db.Close() + + var timeout int + if err := db.QueryRow("pragma busy_timeout").Scan(&timeout); err != nil { + t.Fatalf("busy timeout pragma failed: %v", err) + } + if timeout < 5000 { + t.Fatalf("expected busy timeout >= 5000ms, got %d", timeout) + } + + var journalMode string + if err := db.QueryRow("pragma journal_mode").Scan(&journalMode); err != nil { + t.Fatalf("journal mode pragma failed: %v", err) + } + if strings.ToLower(journalMode) != "wal" { + t.Fatalf("expected wal journal mode, got %s", journalMode) + } +} + func TestVersionShowsBuildAndExecutable(t *testing.T) { out, err := runCLI(args("version"), testRoot(t.TempDir()), fakeUpdater{}) if err != nil { @@ -1554,6 +1582,94 @@ func TestAdvanceStopsAutoPlanWhenRunUntilTargetReached(t *testing.T) { assertNotContains(t, nextPlan, "Command: hyper advance") } +func TestAdvanceToSustainedServiceQualityDoesNotLoop(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "plan.md"), strings.Join([]string{ + "# Product Plan", + "", + "## Product", + "", + "Local Build Relay", + "", + "## Target Users", + "", + "Developers", + "", + "## MVP", + "", + "Run one handoff command.", + "", + "## Current Stage", + "", + "Service Quality", + "", + "## Build Style", + "", + "Go CLI", + "", + "## Success Criteria", + "", + "Every packet proves the handoff command.", + }, "\n")) + if _, err := runCLI(args("run", "Prepare sustained quality"), testRoot(root), fakeUpdater{}); err != nil { + t.Fatalf("run failed: %v", err) + } + writeFile(t, filepath.Join(root, ".hyper", "capabilities", "active", "validator", "validator-go-test.md"), "# validator-go-test\n\nStatus: active\nKind: validator\nSignal: Run go test ./... before completing packets.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), strings.Join([]string{ + "# GOAL-0001 Evidence", + "", + "## Validation", + "", + "`go test ./...` passed.", + "", + "## Readiness Evidence", + "", + "Validation coverage: `go test ./...` passed and is repeatable.", + "Security baseline: Security boundary verified, no cloud sync, no telemetry, and no secrets.", + "Deployment readiness: Packaged CLI smoke passed outside the development command.", + "Operations and docs: README documents setup, rollback, and smoke command.", + "Maintainability: Test helper keeps command validation repeatable without hidden local context.", + "Sustained quality: Active validator validator-go-test is required and verified before every packet handoff.", + "", + "## Reference Benchmark Evidence", + "", + "- Category: Local developer handoff CLI.", + "- References: GitHub CLI, Taskfile, Make.", + "- Baseline expectations: documented command, repeatable output, rollback, no hidden credentials.", + "- Current comparison: below baseline = none; meets baseline = command/test/docs/rollback; above baseline = packet evidence loop.", + "- Below-baseline gaps: No critical below-baseline gap.", + "- Above-baseline strength: packet evidence loop.", + "- Decision: Service Quality proof can continue.", + "", + "## Active Capability Evidence", + "", + "validator-go-test: `go test ./...` passed.", + "", + "## Blocker", + "", + "None blocking.", + }, "\n")) + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nReview sustained quality advancement.\n") + if _, err := runCLI(args("complete"), testRoot(root), fakeUpdater{}); err != nil { + t.Fatalf("complete failed: %v", err) + } + advance, err := runCLI(args("advance"), testRoot(root), fakeUpdater{}) + if err != nil { + t.Fatalf("advance failed: %v", err) + } + assertContains(t, advance.Stdout, "Stage advanced: Service Quality -> Sustained Service Quality") + assertNotContains(t, advance.Stdout, "Next action: hyper advance") + + status, err := runCLI(args("status", "--short"), testRoot(root), fakeUpdater{}) + if err != nil { + t.Fatalf("status failed: %v", err) + } + assertContains(t, status.Stdout, "Stage: Sustained Service Quality") + assertContains(t, status.Stdout, "Gate: Sustained Service Quality -> Sustained Service Quality") + assertNotContains(t, status.Stdout, "Next: hyper advance") + assertContains(t, readFile(t, filepath.Join(root, "plan.md")), "## Current Stage\n\nSustained Service Quality") +} + func TestAdvanceRejectsWhenGateNotReady(t *testing.T) { root := t.TempDir() mustInitWithPlan(t, root, "Tiny tasks", "Build a tiny task list MVP") @@ -1758,6 +1874,27 @@ func TestStageNormalizationUsesFirstNamedStage(t *testing.T) { } goal := readinessRecommendedGoal(map[string]string{"Product": "Pickachat is a location-pinned chat web app."}, "Tiny MVP", "persistence") assertContains(t, goal, "primary Pickachat flow") + + sustained := deriveReadinessState(map[string]string{ + "Current Stage": "Sustained Service Quality", + "Product": "Local Build Relay", + }, growthState{Candidates: []growthCandidate{{Kind: "validator", Name: "validator-go-test", Status: "active"}}}, []readinessEvidenceRecord{ + readinessEvidenceRecordForAxis("GOAL-0001", "validation_coverage", "`go test ./...` passed and is repeatable."), + readinessEvidenceRecordForAxis("GOAL-0001", "operations_docs", "Operations and docs: README documents setup, rollback, and smoke command."), + readinessEvidenceRecordForAxis("GOAL-0001", "maintainability", "Maintainability: Test helper keeps command validation repeatable without hidden local context."), + }) + if sustained.Stage != "Sustained Service Quality" { + t.Fatalf("expected Sustained Service Quality, got %s", sustained.Stage) + } + if sustained.StageGate.CurrentStage != "Sustained Service Quality" || sustained.StageGate.NextStage != "Sustained Service Quality" { + t.Fatalf("expected terminal sustained gate, got %+v", sustained.StageGate) + } + if sustained.StageGate.Advancement.Candidate { + t.Fatalf("sustained stage must not create another stage advancement candidate: %+v", sustained.StageGate.Advancement) + } + if sustained.NextPressure.Axis == "stage_advancement" { + t.Fatalf("sustained stage should continue quality work, got %+v", sustained.NextPressure) + } } func TestReferenceBenchmarkPressureShapesRuntimePacket(t *testing.T) { @@ -1882,6 +2019,27 @@ func TestServiceQualityGateRequiresSustainedGrowthEvidence(t *testing.T) { } } +func TestServiceQualityPressureFollowsGateOrderOverPlanMentions(t *testing.T) { + plan := map[string]string{ + "Product": "Tiny Release Ledger", + "Current Stage": "Service Quality", + "MVP": "Append one release note and one validation result.", + "Constraints": "No secrets, no telemetry, deterministic smoke command.", + "Success Criteria": strings.Join([]string{ + "Validation, security, deployment, docs, rollback, and maintainability are all required before handoff.", + "Reference comparison should prove the category baseline.", + }, " "), + } + + state := deriveReadinessState(plan, growthState{}, nil) + if state.NextPressure.Axis != "validation_coverage" { + t.Fatalf("expected service-quality pressure to start at validation coverage, got %+v", state.NextPressure) + } + if state.NextPressure.Status != "emerging" { + t.Fatalf("expected mentioned validation to remain emerging until evidence exists, got %+v", state.NextPressure) + } +} + func TestReferenceBenchmarkEvidenceTemplateForBetaAndServiceQuality(t *testing.T) { betaEvidence := buildEvidenceDoc("GOAL-0001", "Beta", readinessState{}) assertContains(t, betaEvidence, "## Reference Benchmark Evidence") diff --git a/internal/app/next_packet.go b/internal/app/next_packet.go index 9ac3461..189eb2d 100644 --- a/internal/app/next_packet.go +++ b/internal/app/next_packet.go @@ -128,6 +128,8 @@ func stageRank(stage string) int { return 3 case "Service Quality": return 4 + case "Sustained Service Quality": + return 5 default: return 0 } diff --git a/internal/app/plan.go b/internal/app/plan.go index 2489c7f..84be9f2 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -462,6 +462,7 @@ func normalizeRuntimeStage(stage string) string { {name: "Tiny MVP", patterns: []string{"tiny mvp"}}, {name: "Usable MVP", patterns: []string{"usable mvp"}}, {name: "Beta", patterns: []string{"beta"}}, + {name: "Sustained Service Quality", patterns: []string{"sustained service quality", "sustained quality"}}, {name: "Service Quality", patterns: []string{"service quality", "production"}}, } bestName := "" @@ -595,6 +596,14 @@ func stageDoneCondition(stage string) string { if strings.Contains(normalized, "beta") { return "- Primary flows are validated against realistic data.\n- Known blockers are documented with owner or next action.\n- Release or demo readiness evidence is captured." } + if strings.Contains(normalized, "sustained") { + return strings.Join([]string{ + "- Active validators, harnesses, or equivalent reusable quality structures continue to pass or have explicit blockers.", + "- Repeated failures or friction are converted into the next focused quality packet.", + "- Operational, validation, and maintainability evidence stays current without broad feature expansion.", + "- next.md identifies the next sustained-service improvement, not another stage advancement.", + }, "\n") + } if strings.Contains(normalized, "service") || strings.Contains(normalized, "production") { return strings.Join([]string{ "- Required validation, security, deployment, operations, and maintainability evidence is captured.", @@ -618,6 +627,9 @@ func stageRuntimeBoundary(stage string) string { if strings.Contains(normalized, "beta") { return "Prioritize realistic data, reliability, security, deployment, and documentation gaps over new feature breadth." } + if strings.Contains(normalized, "sustained") { + return "Keep the service healthy through repeated quality evidence, active validators or harnesses, and friction reduction before adding breadth." + } if strings.Contains(normalized, "service") || strings.Contains(normalized, "production") { return "Close operational and reference acceptance criteria first: repeatable validation, security/privacy boundaries, release and rollback proof, operator docs, maintainability evidence, and category-baseline comparison before feature breadth." } @@ -635,6 +647,9 @@ func stageValidationSignal(stage string) string { if strings.Contains(normalized, "beta") { return "Beta validation should use realistic data and capture security, deployment, or docs evidence when those axes are touched." } + if strings.Contains(normalized, "sustained") { + return "Sustained Service Quality validation should run active validators or harnesses, record any blocker, and convert repeated failure into the next focused quality packet." + } if strings.Contains(normalized, "service") || strings.Contains(normalized, "production") { return "Service Quality validation should prove the service can be set up, checked, released or run, rolled back, handed off, and compared against category references from documented commands, artifacts, or benchmark notes; run active validators or record why each required check is blocked." } diff --git a/internal/app/readiness.go b/internal/app/readiness.go index dcb8c06..37d6f30 100644 --- a/internal/app/readiness.go +++ b/internal/app/readiness.go @@ -832,6 +832,14 @@ func readinessGateForStage(stage string, dimensions []readinessDimension) readin } func readinessStageAdvancement(current, next, status string, evidence []string) stageAdvancementPolicy { + if current == next { + return stageAdvancementPolicy{ + Candidate: false, + Recommendation: current + " is the current operating stage. Continue with the next focused quality packet instead of advancing stage.", + PlanChange: "", + RequiredEvidence: evidence, + } + } if status != "ready" { return stageAdvancementPolicy{ Candidate: false, @@ -850,6 +858,16 @@ func readinessStageAdvancement(current, next, status string, evidence []string) func readinessGateDefinition(stage string) (string, string, []string, []string) { normalized := normalizeLabel(stage) + if strings.Contains(normalized, "sustained") { + return "Sustained Service Quality", "Sustained Service Quality", + []string{"validation_coverage", "operations_docs", "maintainability", "sustained_quality"}, + []string{ + "Active validators, harnesses, or equivalent reusable quality structures continue to pass or have explicit blockers.", + "Operational handoff, rollback, and recovery notes stay current.", + "Maintainability evidence shows repeated friction is reduced before feature breadth.", + "Sustained quality remains protected by repeated runtime evidence and active required behavior.", + } + } if strings.Contains(normalized, "service") || strings.Contains(normalized, "production") { return "Service Quality", "Sustained Service Quality", []string{"validation_coverage", "security_baseline", "deployment_readiness", "operations_docs", "maintainability", "reference_benchmark", "sustained_quality"}, @@ -879,16 +897,36 @@ func readinessGateDefinition(stage string) (string, string, []string, []string) func selectReadinessPressure(plan map[string]string, stage string, dimensions []readinessDimension, gate readinessStageGate) readinessPressure { dims := readinessDimensionMap(dimensions) - for _, axis := range gate.RequiredAxes { - dim := dims[axis] - if dim.ID != "" && dim.Status == "missing" { - return readinessPressureForDimension(plan, stage, dim, gate) + if readinessPressureShouldFollowGateOrder(gate) { + for _, axis := range gate.RequiredAxes { + dim := dims[axis] + if dim.ID != "" && dim.Status != "covered" { + return readinessPressureForDimension(plan, stage, dim, gate) + } + } + } else { + for _, axis := range gate.RequiredAxes { + dim := dims[axis] + if dim.ID != "" && dim.Status == "missing" { + return readinessPressureForDimension(plan, stage, dim, gate) + } + } + for _, axis := range gate.RequiredAxes { + dim := dims[axis] + if dim.ID != "" && dim.Status != "covered" { + return readinessPressureForDimension(plan, stage, dim, gate) + } } } - for _, axis := range gate.RequiredAxes { - dim := dims[axis] - if dim.ID != "" && dim.Status != "covered" { - return readinessPressureForDimension(plan, stage, dim, gate) + if gate.CurrentStage == gate.NextStage { + return readinessPressure{ + Axis: "sustained_quality", + AxisName: "Sustained quality", + Status: "ongoing", + Reason: gate.CurrentStage + " is active; continue the next focused quality improvement instead of advancing stage.", + RecommendedGoal: readinessRecommendedGoal(plan, stage, "sustained_quality"), + WorkBoundary: "Stay in sustained operation: reduce one repeated validation, operational, or maintainability friction without broad feature expansion.", + ValidationSignal: "Run active validators, active harnesses, or the safest equivalent quality check and record the result.", } } for _, dim := range dimensions { @@ -899,6 +937,15 @@ func selectReadinessPressure(plan map[string]string, stage string, dimensions [] return readinessPressureForDimension(plan, stage, dims["maintainability"], gate) } +func readinessPressureShouldFollowGateOrder(gate readinessStageGate) bool { + switch gate.CurrentStage { + case "Beta", "Service Quality", "Sustained Service Quality": + return true + default: + return false + } +} + func readinessPressureForDimension(plan map[string]string, stage string, dim readinessDimension, gate readinessStageGate) readinessPressure { if dim.ID == "" { dim = readinessDimension{ID: "maintainability", Name: "Maintainability", Status: "covered", Gap: "Keep the codebase easy to continue."} diff --git a/internal/app/run_options.go b/internal/app/run_options.go index f0b1127..6df5284 100644 --- a/internal/app/run_options.go +++ b/internal/app/run_options.go @@ -49,7 +49,7 @@ func normalizeRunUntilTarget(value string) (string, *hyperError) { normalized = strings.Join(strings.Fields(normalized), " ") switch normalized { case "", "none": - return "", newError("Missing value for --until.\n\nUse one of: tiny-mvp, usable-mvp, beta, service-quality.", 2) + return "", newError("Missing value for --until.\n\nUse one of: tiny-mvp, usable-mvp, beta, service-quality, sustained-service-quality.", 2) case "tiny", "tiny mvp": return "Tiny MVP", nil case "usable", "usable mvp": @@ -58,8 +58,10 @@ func normalizeRunUntilTarget(value string) (string, *hyperError) { return "Beta", nil case "service", "service quality", "production", "production quality": return "Service Quality", nil + case "sustained", "sustained quality", "sustained service", "sustained service quality": + return "Sustained Service Quality", nil default: - return "", newError("Unknown --until stage: "+value+"\n\nUse one of: tiny-mvp, usable-mvp, beta, service-quality.", 2) + return "", newError("Unknown --until stage: "+value+"\n\nUse one of: tiny-mvp, usable-mvp, beta, service-quality, sustained-service-quality.", 2) } } diff --git a/internal/app/storage.go b/internal/app/storage.go index 1a93cb4..15670b1 100644 --- a/internal/app/storage.go +++ b/internal/app/storage.go @@ -15,6 +15,16 @@ func openDB(root string) (*sql.DB, *hyperError) { if err != nil { return nil, dbError(err) } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + if _, err := db.Exec("pragma busy_timeout = 5000"); err != nil { + _ = db.Close() + return nil, dbError(err) + } + if _, err := db.Exec("pragma journal_mode = wal"); err != nil { + _ = db.Close() + return nil, dbError(err) + } return db, nil } From 4c5a258b97dc11e7d4c3ce699778ebac7c03d5a9 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 18:20:26 +0900 Subject: [PATCH 06/52] Polish service quality status evidence --- internal/app/main_test.go | 40 +++++++++++++++++++++++++++++++++++++++ internal/app/readiness.go | 5 +++-- internal/app/status.go | 31 +++++++++++++++++++++++------- 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 2004d19..e1d04b9 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -417,6 +417,32 @@ func TestStatusHighlightsReferenceBenchmarkWhenRequired(t *testing.T) { assertContains(t, short, "Gap: Reference benchmark: Reference comparison has not proven category baseline") } +func TestStatusDoesNotReportSurfaceGapWhenCoreUXIsNotRequired(t *testing.T) { + state := projectState{Project: "Local Build Relay", Stage: "Sustained Service Quality", Status: "completed", ActiveRunID: "RUN-0001", CurrentGoalID: "GOAL-0001", CurrentGoalPath: ".hyper/goals/GOAL-0001/goal.md", AutoContinue: true, RunUntil: "Sustained Service Quality"} + derived := goalState{State: "completed", Reason: "done"} + readiness := readinessState{ + Version: 1, + Stage: "Sustained Service Quality", + Dimensions: []readinessDimension{ + {ID: "core_ux", Name: "Core UX", Status: "emerging", Evidence: "CLI command surface exists."}, + {ID: "validation_coverage", Name: "Validation coverage", Status: "covered", Evidence: "`go test ./...` passed."}, + {ID: "sustained_quality", Name: "Sustained quality", Status: "covered", Evidence: "Active validator is required."}, + }, + StageGate: readinessStageGate{ + CurrentStage: "Sustained Service Quality", + NextStage: "Sustained Service Quality", + Status: "ready", + RequiredAxes: []string{"validation_coverage", "operations_docs", "maintainability", "sustained_quality"}, + }, + NextPressure: readinessPressure{Axis: "sustained_quality", AxisName: "Sustained quality", Status: "ongoing", Reason: "Continue focused quality work."}, + } + + short := strings.Join(statusShortLines(state, derived, readiness, growthState{}), "\n") + assertContains(t, short, "Proof: functional covered, operational covered") + assertNotContains(t, short, "surface emerging") + assertNotContains(t, short, "surface proof for the primary user flow") +} + func TestStatusDoesNotShowFutureReferenceBenchmarkBeforeRequired(t *testing.T) { state := projectState{Project: "Tiny Pet", Stage: "Tiny MVP", Status: "completed", ActiveRunID: "RUN-0013", CurrentGoalID: "GOAL-0013", CurrentGoalPath: ".hyper/goals/GOAL-0013/goal.md", UpdatedAt: "now"} derived := goalState{State: "completed", Reason: "done"} @@ -1255,6 +1281,13 @@ func TestReadinessEvidenceQualityRules(t *testing.T) { if strongDeploy.Status != "covered" { t.Fatalf("expected strong deployment evidence to be covered, got %+v", strongDeploy) } + cliDeploy, ok := parseReadinessEvidenceLine("GOAL-0001", "Deployment readiness: Built the CLI binary and ran the smoke command outside the development command.", defs) + if !ok { + t.Fatal("expected CLI deployment evidence to parse") + } + if cliDeploy.Status != "covered" { + t.Fatalf("expected CLI deployment evidence to be covered, got %+v", cliDeploy) + } weakReference, ok := parseReadinessEvidenceLine("GOAL-0001", "Reference benchmark: Compared against three comparable project-growth CLIs; category baseline is fine and above-baseline strength exists.", defs) if !ok { t.Fatal("expected weak reference benchmark evidence to parse") @@ -1276,6 +1309,13 @@ func TestReadinessEvidenceQualityRules(t *testing.T) { if opsDocs.Status != "covered" { t.Fatalf("expected operations docs evidence to be covered, got %+v", opsDocs) } + opsNotes, ok := parseReadinessEvidenceLine("GOAL-0001", "Operations and docs: README handoff notes cover setup, rollback, recovery, and the smoke command.", defs) + if !ok { + t.Fatal("expected operations notes evidence to parse") + } + if opsNotes.Status != "covered" { + t.Fatalf("expected operations notes evidence to be covered, got %+v", opsNotes) + } referenceBenchmark, ok := parseReadinessEvidenceLine("GOAL-0001", "Reference benchmark: Category: Developer CLI; References: namba-ai, pi.dev, Claude Code; Baseline expectations: install is clear and one command creates useful work context; Current comparison: setup meets baseline and evidence loop is above baseline; Below-baseline gaps: None; no critical user or operator baseline gap remains; Above-baseline strength: project-local evidence pressure; Decision: Service Quality is allowed from the benchmark perspective.", defs) if !ok { t.Fatal("expected reference benchmark evidence to parse") diff --git a/internal/app/readiness.go b/internal/app/readiness.go index 37d6f30..33c0370 100644 --- a/internal/app/readiness.go +++ b/internal/app/readiness.go @@ -733,10 +733,11 @@ func deploymentEvidenceCovered(normalized string) bool { deploymentTarget := hasAny(normalized, "deploy", "deployed", "deployment", "url", "https://", "http://", "build", "release", "hosted", "docker", "ci", "artifact", "zip", "dist/", "file://", "static server", "server check", "packaged", "package", "parity", + "binary", "executable", "outside the development", "outside development", "smoke command", ) deploymentProof := hasAny(normalized, "passed", "available", "hosted", "deployed", "built", "released", "verified", "validated", "proved", "proven", - "created", "served", "extracted", "smoke", "parity", + "created", "served", "extracted", "smoke", "parity", "ran", ) return deploymentTarget && deploymentProof } @@ -747,7 +748,7 @@ func operationsDocsEvidenceCovered(normalized string) bool { "handoff", "tester", "smoke path", "run command", "package command", "stop condition", "stop conditions", ) docsProof := hasAny(normalized, - "documented", "documents", "updated", "verified", "covered", "written", "records", "defines", "includes", + "documented", "documents", "updated", "verified", "covered", "cover", "covers", "written", "records", "defines", "includes", ) return docsTarget && docsProof } diff --git a/internal/app/status.go b/internal/app/status.go index 5e0b9b2..817cc7e 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -178,17 +178,34 @@ func proofStatusSummary(derived goalState, readiness readinessState) string { } else if derived.State == "blocked" { functional = "blocked" } - summary := fmt.Sprintf("functional %s, surface %s, operational %s", - functional, - proofAxisStatus(readiness, "core_ux"), - proofAxisStatus(readiness, "validation_coverage"), - ) + parts := []string{"functional " + functional} + if proofAxisVisible(readiness, "core_ux") { + parts = append(parts, "surface "+proofAxisStatus(readiness, "core_ux")) + } + if proofAxisVisible(readiness, "validation_coverage") { + parts = append(parts, "operational "+proofAxisStatus(readiness, "validation_coverage")) + } + summary := strings.Join(parts, ", ") if referenceBenchmarkRelevant(readiness) { summary += ", benchmark " + proofAxisStatus(readiness, "reference_benchmark") } return summary } +func proofAxisVisible(readiness readinessState, axis string) bool { + status := proofAxisStatus(readiness, axis) + return readinessAxisRequired(readiness, axis) || status == "covered" || readiness.NextPressure.Axis == axis +} + +func readinessAxisRequired(readiness readinessState, axis string) bool { + for _, required := range readiness.StageGate.RequiredAxes { + if required == axis { + return true + } + } + return false +} + func proofAxisStatus(readiness readinessState, axis string) string { for _, dim := range readiness.Dimensions { if dim.ID == axis { @@ -203,9 +220,9 @@ func nextProofGap(readiness readinessState) string { return "not selected" } switch { - case proofAxisStatus(readiness, "core_ux") != "covered": + case readinessAxisRequired(readiness, "core_ux") && proofAxisStatus(readiness, "core_ux") != "covered": return "surface proof for the primary user flow" - case proofAxisStatus(readiness, "validation_coverage") != "covered": + case readinessAxisRequired(readiness, "validation_coverage") && proofAxisStatus(readiness, "validation_coverage") != "covered": return "repeatable validation proof" case readiness.NextPressure.AxisName != "": return readiness.NextPressure.AxisName From 25ce48f81ae5f06efbdd3d103c5109521f84e9b8 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 18:32:45 +0900 Subject: [PATCH 07/52] Require real active sustained capability --- internal/app/growth.go | 47 +++++++++++++++++++++-- internal/app/main_test.go | 79 +++++++++++++++++++++++++++++++++++++++ internal/app/readiness.go | 16 ++++++++ 3 files changed, 139 insertions(+), 3 deletions(-) diff --git a/internal/app/growth.go b/internal/app/growth.go index 70de883..725b265 100644 --- a/internal/app/growth.go +++ b/internal/app/growth.go @@ -450,9 +450,11 @@ func growthBehaviorWithActiveCapabilities(root string, pressures []growthPressur } type activeCapability struct { - Kind string - Name string - Signal string + Kind string + Name string + Signal string + Path string + Managed bool } func activeValidatorCapabilities(root string) ([]activeCapability, *hyperError) { @@ -476,6 +478,8 @@ func activeValidatorCapabilities(root string) ([]activeCapability, *hyperError) } validator, ok := parseActiveValidatorCapability(entry.Name(), string(body)) if ok { + validator.Path = path + validator.Managed = managedCapabilityFile(string(body)) validators = append(validators, validator) } } @@ -518,6 +522,8 @@ func activeCapabilities(root string) ([]activeCapability, *hyperError) { } capability, ok := parseActiveCapability(kind, entry.Name(), string(body)) if ok { + capability.Path = path + capability.Managed = managedCapabilityFile(string(body)) capabilities = append(capabilities, capability) } } @@ -555,6 +561,10 @@ func parseActiveCapability(kind, filename, body string) (activeCapability, bool) return activeCapability{Kind: kind, Name: name, Signal: oneLine(signal)}, true } +func managedCapabilityFile(body string) bool { + return capabilityField(body, "Pressure type") != "" || capabilityField(body, "Evidence count") != "" +} + func markdownTitle(body string) string { for _, line := range strings.Split(body, "\n") { trimmed := strings.TrimSpace(line) @@ -640,6 +650,21 @@ func materializeGrowthCandidates(root string, pressures []growthPressure, previo seen[candidate.LifecyclePath] = true } } + active, activeErr := activeCapabilities(root) + if activeErr != nil { + return nil, activeErr + } + for _, capability := range active { + if capability.Managed { + continue + } + candidate := growthCandidateForActiveCapability(capability) + seenKey := firstNonBlank(candidate.LifecyclePath, candidate.Kind+"\x00"+candidate.Name) + if !seen[seenKey] { + candidates = append(candidates, candidate) + seen[seenKey] = true + } + } retired, err := retiredGrowthCandidates(root, previous, candidates) if err != nil { return nil, err @@ -738,6 +763,22 @@ func harnessCandidateForPressure(pressure growthPressure) growthCandidate { } } +func growthCandidateForActiveCapability(capability activeCapability) growthCandidate { + return growthCandidate{ + Kind: capability.Kind, + Name: capability.Name, + Status: "active", + LifecyclePath: capability.Path, + Reason: "Active capability file is installed in the project.", + Signal: capability.Signal, + PressureType: "active_capability", + EvidenceCount: growthActiveSignalGoals, + RepeatedThreshold: growthRepeatedSignalGoals, + PromotionThreshold: growthPromotableSignalGoals, + ActivationThreshold: growthActiveSignalGoals, + } +} + func capabilityStatusForEvidence(goalCount int) string { switch { case goalCount >= growthActiveSignalGoals: diff --git a/internal/app/main_test.go b/internal/app/main_test.go index e1d04b9..dcfeea3 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -1177,6 +1177,31 @@ func TestActiveValidatorBecomesRequiredValidationSignal(t *testing.T) { assertContains(t, readFile(t, filepath.Join(root, ".hyper", "growth", "state.json")), "Required active validator validator-run-go-test") } +func TestActiveCapabilityFilesBecomeGrowthCandidates(t *testing.T) { + root := t.TempDir() + mustInitWithPlan(t, root, "Tiny CLI", "Build a tiny CLI MVP") + writeFile(t, filepath.Join(root, ".hyper", "capabilities", "active", "validator", "validator-go-test.md"), "# validator-go-test\n\nStatus: active\nKind: validator\nSignal: Run go test ./... before completing packets.\n") + writeFile(t, filepath.Join(root, ".hyper", "capabilities", "active", "harness", "harness-growth-candidate.md"), "# harness-growth-candidate\n\nStatus: active\nKind: harness\n\n## Required Behavior\n\nRun the project-specific handoff harness before completing packets.\n") + db, err := openDB(root) + if err != nil { + t.Fatalf("db open failed: %v", err) + } + defer db.Close() + if err := ensureSchema(db); err != nil { + t.Fatalf("schema failed: %v", err) + } + + state, hyperErr := updateGrowthState(root, db) + if hyperErr != nil { + t.Fatalf("growth failed: %v", hyperErr) + } + if activeStructureCount(state.Candidates) != 2 { + t.Fatalf("expected two active structures from active capability files, got %+v", state.Candidates) + } + assertContains(t, readFile(t, filepath.Join(root, ".hyper", "growth", "state.json")), `"name": "validator-go-test"`) + assertContains(t, readFile(t, filepath.Join(root, ".hyper", "growth", "state.json")), `"name": "harness-growth-candidate"`) +} + func TestReadinessPressureSelectsStageGateGap(t *testing.T) { root := t.TempDir() mustRun(t, root, "init") @@ -2052,6 +2077,16 @@ func TestServiceQualityGateRequiresSustainedGrowthEvidence(t *testing.T) { } assertContains(t, strings.Join(state.StageGate.BlockingGaps, "\n"), "Sustained quality") + fakeActiveEvidence := append([]readinessEvidenceRecord{}, evidence...) + fakeActiveEvidence = append(fakeActiveEvidence, readinessEvidenceRecordForAxis("GOAL-0002", "sustained_quality", "Sustained quality: Active validator validator-go-test is required and verified before every packet handoff.")) + state = deriveReadinessState(plan, growthState{}, fakeActiveEvidence) + if state.StageGate.Status != "not_ready" { + t.Fatalf("text-only active validator evidence must not unlock sustained quality, got %+v", state.StageGate) + } + if state.NextPressure.Axis != "sustained_quality" { + t.Fatalf("expected sustained quality pressure without actual active capability, got %+v", state.NextPressure) + } + growth := growthState{Candidates: []growthCandidate{{Kind: "validator", Name: "validator-go-test", Status: "active"}}} state = deriveReadinessState(plan, growth, evidence) if state.StageGate.Status != "ready" { @@ -2080,6 +2115,50 @@ func TestServiceQualityPressureFollowsGateOrderOverPlanMentions(t *testing.T) { } } +func TestServiceQualityPressureWalksRequiredAxesInOrder(t *testing.T) { + plan := map[string]string{ + "Product": "Axis Walk CLI", + "Current Stage": "Service Quality", + "MVP": "Create one handoff entry, validate it, and show the latest handoff state.", + "Constraints": "No secrets, no telemetry, no network dependency during normal use.", + } + evidence := []readinessEvidenceRecord{} + assertNext := func(want string, growth growthState) { + t.Helper() + state := deriveReadinessState(plan, growth, evidence) + if state.NextPressure.Axis != want { + t.Fatalf("expected next pressure %s, got %+v", want, state.NextPressure) + } + } + + assertNext("validation_coverage", growthState{}) + evidence = append(evidence, readinessEvidenceRecordForAxis("GOAL-0001", "validation_coverage", "Validation coverage: `go test ./...` passed and the handoff smoke command is repeatable.")) + assertNext("security_baseline", growthState{}) + evidence = append(evidence, readinessEvidenceRecordForAxis("GOAL-0002", "security_baseline", "Security baseline: Privacy boundary verified, no cloud sync, no telemetry, no token storage, no secrets, and local-only data handling is explicit.")) + assertNext("deployment_readiness", growthState{}) + evidence = append(evidence, readinessEvidenceRecordForAxis("GOAL-0003", "deployment_readiness", "Deployment readiness: Built the CLI binary and ran the smoke command outside the development command.")) + assertNext("operations_docs", growthState{}) + evidence = append(evidence, readinessEvidenceRecordForAxis("GOAL-0004", "operations_docs", "Operations and docs: README handoff notes cover setup, rollback, recovery, and the smoke command.")) + assertNext("maintainability", growthState{}) + evidence = append(evidence, readinessEvidenceRecordForAxis("GOAL-0005", "maintainability", "Maintainability: Table-driven validation helper keeps command checks repeatable without hidden local context.")) + assertNext("reference_benchmark", growthState{}) + evidence = append(evidence, readinessEvidenceRecordForAxis("GOAL-0006", "reference_benchmark", strings.Join([]string{ + "Category: Local developer handoff CLI.", + "References: GitHub CLI, Taskfile, Make.", + "Baseline expectations: documented command, repeatable output, rollback notes, no hidden credentials.", + "Current comparison: below baseline = none; meets baseline = command/test/docs/rollback; above baseline = packet evidence loop.", + "Below-baseline gaps: No critical below-baseline gap.", + "Above-baseline strength: packet evidence loop.", + "Decision: Service Quality proof can continue.", + }, "; "))) + assertNext("sustained_quality", growthState{}) + + state := deriveReadinessState(plan, growthState{Candidates: []growthCandidate{{Kind: "validator", Name: "validator-go-test", Status: "active"}}}, evidence) + if state.NextPressure.Axis != "stage_advancement" || state.StageGate.Status != "ready" { + t.Fatalf("expected ready stage advancement after active capability, got %+v / %+v", state.NextPressure, state.StageGate) + } +} + func TestReferenceBenchmarkEvidenceTemplateForBetaAndServiceQuality(t *testing.T) { betaEvidence := buildEvidenceDoc("GOAL-0001", "Beta", readinessState{}) assertContains(t, betaEvidence, "## Reference Benchmark Evidence") diff --git a/internal/app/readiness.go b/internal/app/readiness.go index 33c0370..deb98d3 100644 --- a/internal/app/readiness.go +++ b/internal/app/readiness.go @@ -125,6 +125,22 @@ func readinessDimensionDefs() []readinessDimensionDef { func readinessDimensionStatus(def readinessDimensionDef, plan map[string]string, growth growthState, evidenceRecords []readinessEvidenceRecord, corpus string) (string, int, string) { record, hasRecord := readinessEvidenceForAxis(evidenceRecords, def.ID) + if def.ID == "sustained_quality" { + covered, emerging, evidence := sustainedQualityGrowthEvidence(growth) + if covered { + return "covered", 2, evidence + } + if hasRecord { + return "emerging", 1, fmt.Sprintf("%s readiness evidence needs an actual active validator, active harness, or equivalent active capability: %s", record.GoalID, record.Text) + } + if emerging { + return "emerging", 1, evidence + } + if hasAny(corpus, def.Keywords...) { + return "emerging", 1, "plan.md or learned context mentions this readiness axis." + } + return "missing", 0, def.Gap + } if hasRecord { if record.Status == "covered" { return "covered", 2, fmt.Sprintf("%s readiness evidence: %s", record.GoalID, record.Text) From e823967e943c7132a025e275ae5bfeb3ed03be47 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 18:36:49 +0900 Subject: [PATCH 08/52] Hide terminal sustained gap --- internal/app/main_test.go | 1 + internal/app/status.go | 3 +++ 2 files changed, 4 insertions(+) diff --git a/internal/app/main_test.go b/internal/app/main_test.go index dcfeea3..3c682dd 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -441,6 +441,7 @@ func TestStatusDoesNotReportSurfaceGapWhenCoreUXIsNotRequired(t *testing.T) { assertContains(t, short, "Proof: functional covered, operational covered") assertNotContains(t, short, "surface emerging") assertNotContains(t, short, "surface proof for the primary user flow") + assertNotContains(t, short, "Gap:") } func TestStatusDoesNotShowFutureReferenceBenchmarkBeforeRequired(t *testing.T) { diff --git a/internal/app/status.go b/internal/app/status.go index 817cc7e..de5a61b 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -219,6 +219,9 @@ func nextProofGap(readiness readinessState) string { if readiness.Version == 0 { return "not selected" } + if readiness.StageGate.CurrentStage == readiness.StageGate.NextStage && readiness.StageGate.Status == "ready" { + return "none" + } switch { case readinessAxisRequired(readiness, "core_ux") && proofAxisStatus(readiness, "core_ux") != "covered": return "surface proof for the primary user flow" From 699bc7dae13fb13e6e4c5b74392926c80056ebaa Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 18:43:15 +0900 Subject: [PATCH 09/52] Align short status gap with next pressure --- internal/app/main_test.go | 32 ++++++++++++++++++++++++++++++++ internal/app/status.go | 14 ++++++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 3c682dd..ebf835f 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -497,6 +497,38 @@ func TestStatusShortPrioritizesActivePacketGuard(t *testing.T) { assertNotContains(t, short, "Guard: accept the stage change before running `hyper advance`") } +func TestStatusShortGapMatchesNextReadinessPressure(t *testing.T) { + state := projectState{Project: "Active Guard CLI", Stage: "Tiny MVP", Status: "active", ActiveRunID: "RUN-0001", CurrentGoalID: "GOAL-0001", CurrentGoalPath: ".hyper/goals/GOAL-0001/goal.md", AutoContinue: true, RunUntil: "Service Quality"} + derived := goalState{State: "active", Reason: "Runtime packet evidence is still pending."} + readiness := readinessState{ + Version: 1, + Stage: "Tiny MVP", + Dimensions: []readinessDimension{ + {ID: "validation_coverage", Name: "Validation coverage", Status: "missing", Gap: "The primary behavior does not have repeatable validation evidence."}, + }, + StageGate: readinessStageGate{ + CurrentStage: "Tiny MVP", + NextStage: "Usable MVP", + Status: "not_ready", + RequiredAxes: []string{"product_completeness", "core_ux", "validation_coverage"}, + BlockingGaps: []string{ + "Core UX: The primary user flow is not yet proven usable.", + "Validation coverage: The primary behavior does not have repeatable validation evidence.", + }, + }, + NextPressure: readinessPressure{ + Axis: "validation_coverage", + AxisName: "Validation coverage", + Status: "missing", + Reason: "Validation coverage is missing for the Tiny MVP -> Usable MVP gate.", + }, + } + + short := strings.Join(statusShortLines(state, derived, readiness, growthState{}), "\n") + assertContains(t, short, "Gap: Validation coverage: The primary behavior does not have repeatable validation evidence.") + assertNotContains(t, short, "Gap: Core UX") +} + func TestRunBlocksPendingActiveGoal(t *testing.T) { root := t.TempDir() mustInitWithPlan(t, root, "Tiny notes", "Build a tiny notes MVP") diff --git a/internal/app/status.go b/internal/app/status.go index de5a61b..00c786f 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -142,12 +142,22 @@ func statusShortGap(readiness readinessState) string { if readiness.Version == 0 { return "" } - if len(readiness.StageGate.BlockingGaps) > 0 { - return compactText(readiness.StageGate.BlockingGaps[0], 120) + if readiness.StageGate.CurrentStage == readiness.StageGate.NextStage && readiness.StageGate.Status == "ready" { + return "" } if readiness.StageGate.Advancement.Candidate { return "none; stage advancement is ready" } + if readiness.NextPressure.Axis != "" && readiness.NextPressure.Axis != "stage_advancement" { + dim := readinessDimensionMap(readiness.Dimensions)[readiness.NextPressure.Axis] + if dim.ID != "" { + return compactText(readiness.NextPressure.AxisName+": "+firstNonBlank(dim.Gap, dim.Evidence, readiness.NextPressure.Reason), 120) + } + return compactText(readinessPressureSummary(readiness), 120) + } + if len(readiness.StageGate.BlockingGaps) > 0 { + return compactText(readiness.StageGate.BlockingGaps[0], 120) + } if gap := nextProofGap(readiness); gap != "" && gap != "none" { return gap } From f8f5dd8d8656b676b23f89c44902d4a5f05891b3 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 18:48:57 +0900 Subject: [PATCH 10/52] Follow stage gate pressure order --- internal/app/main_test.go | 27 ++++++++++++++++++++++----- internal/app/readiness.go | 7 +------ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/internal/app/main_test.go b/internal/app/main_test.go index ebf835f..69f05aa 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -1247,12 +1247,12 @@ func TestReadinessPressureSelectsStageGateGap(t *testing.T) { readiness := readFile(t, filepath.Join(root, ".hyper", "readiness", "state.json")) assertContains(t, readiness, `"current_stage": "Usable MVP"`) assertContains(t, readiness, `"next_stage": "Beta"`) - assertContains(t, readiness, `"axis": "persistence"`) + assertContains(t, readiness, `"axis": "core_ux"`) goal := readFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "goal.md")) assertContains(t, goal, "Current gate: Usable MVP -> Beta") - assertContains(t, goal, "Next readiness pressure: Data persistence") - assertContains(t, goal, "Make the primary Tiny CRM flow persist real user data") - assertContains(t, goal, "Capture readiness evidence for Data persistence") + assertContains(t, goal, "Next readiness pressure: Core UX") + assertContains(t, goal, "Implement the smallest usable Tiny CRM core flow") + assertContains(t, goal, "Capture readiness evidence for Core UX") } func TestReadinessEvidenceProgressesSelectedAxis(t *testing.T) { @@ -1263,7 +1263,7 @@ func TestReadinessEvidenceProgressesSelectedAxis(t *testing.T) { if _, err := runCLI(args("run"), testRoot(root), fakeUpdater{}); err != nil { t.Fatalf("first run failed: %v", err) } - writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\nBrowser smoke passed.\n\n## Readiness Evidence\n\nData persistence: Customer notes persist across reload using local storage.\n\n## Changed Files\n\nsrc/App.tsx\n\n## Decisions\n\nKeep storage local-first.\n\n## Reusable Patterns\n\nPending.\n\n## Blocker\n\nPending.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\nBrowser smoke passed.\n\n## Readiness Evidence\n\nCore UX: Browser smoke verified add and revisit customer notes flow.\nData persistence: Customer notes persist across reload using local storage.\n\n## Changed Files\n\nsrc/App.tsx\n\n## Decisions\n\nKeep storage local-first.\n\n## Reusable Patterns\n\nPending.\n\n## Blocker\n\nPending.\n") writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nHandle empty and failure states.\n\n## Learn Notes\n\n- Pattern: Record readiness evidence with an axis label.\n") if _, err := runCLI(args("run"), testRoot(root), fakeUpdater{}); err != nil { @@ -2148,6 +2148,23 @@ func TestServiceQualityPressureFollowsGateOrderOverPlanMentions(t *testing.T) { } } +func TestTinyMVPPressureFollowsGateOrderOverPlanMentions(t *testing.T) { + plan := map[string]string{ + "Product": "Active Guard CLI", + "Current Stage": "Tiny MVP", + "MVP": "Create one handoff packet and require evidence before the next one.", + "Success Criteria": "A second run is blocked until the active packet is completed.", + } + + state := deriveReadinessState(plan, growthState{}, nil) + if state.NextPressure.Axis != "core_ux" { + t.Fatalf("expected Tiny MVP pressure to prove the useful flow before validation, got %+v", state.NextPressure) + } + if state.NextPressure.Status != "emerging" { + t.Fatalf("expected mentioned core flow to remain emerging until evidence exists, got %+v", state.NextPressure) + } +} + func TestServiceQualityPressureWalksRequiredAxesInOrder(t *testing.T) { plan := map[string]string{ "Product": "Axis Walk CLI", diff --git a/internal/app/readiness.go b/internal/app/readiness.go index deb98d3..388847d 100644 --- a/internal/app/readiness.go +++ b/internal/app/readiness.go @@ -955,12 +955,7 @@ func selectReadinessPressure(plan map[string]string, stage string, dimensions [] } func readinessPressureShouldFollowGateOrder(gate readinessStageGate) bool { - switch gate.CurrentStage { - case "Beta", "Service Quality", "Sustained Service Quality": - return true - default: - return false - } + return len(gate.RequiredAxes) > 0 } func readinessPressureForDimension(plan map[string]string, stage string, dim readinessDimension, gate readinessStageGate) readinessPressure { From af8654364722f8d734fab6d5fe88fa72f4ec3628 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 19:05:51 +0900 Subject: [PATCH 11/52] Stabilize growth status signals --- internal/app/commands.go | 4 +- internal/app/concepts.go | 8 +- internal/app/doctor.go | 5 +- internal/app/goal_state.go | 14 ++++ internal/app/growth.go | 89 ++++++++++++++++++--- internal/app/main_test.go | 155 +++++++++++++++++++++++++++++++++++++ internal/app/similarity.go | 3 + 7 files changed, 263 insertions(+), 15 deletions(-) diff --git a/internal/app/commands.go b/internal/app/commands.go index 697e05b..7a6c7b7 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -302,7 +302,7 @@ func statusHyper(fsys fsRoot, args []string) (commandOutput, *hyperError) { state = refreshStateFromPlanForStatus(root, state) derived := deriveCurrentGoalState(root, state.CurrentGoalID) runs, goals := statusDBCounts(root) - growth := readGrowthStateIfExists(root) + growth := growthStateForStatus(root) readiness := readinessStateForStatus(root, growth) if short { return stdout(strings.Join(statusShortLines(state, derived, readiness, growth), "\n")), nil @@ -351,7 +351,7 @@ func completeHyper(fsys fsRoot) (commandOutput, *hyperError) { } readinessForGate := readReadinessStateIfExists(root) if readinessForGate.Version == 0 { - readinessForGate = readinessStateForStatus(root, readGrowthStateIfExists(root)) + readinessForGate = readinessStateForStatus(root, growthStateForStatus(root)) } finishGate, finishErr := runFinishGate(root, state, derived, readinessForGate) if finishErr != nil { diff --git a/internal/app/concepts.go b/internal/app/concepts.go index 6fdc0f5..d71d74f 100644 --- a/internal/app/concepts.go +++ b/internal/app/concepts.go @@ -95,10 +95,16 @@ func activeStructureCount(candidates []growthCandidate) int { func growthLoopStateSummary(growth growthState) string { pressureCount := visibleGrowthPressureCount(growth.Pressures) candidateCount := visibleGrowthCandidateCount(growth.Candidates) + activeCount := activeStructureCount(growth.Candidates) if pressureCount == 0 { + if activeCount > 0 { + return fmt.Sprintf("0 pressure(s), %d candidate(s), %d active structure(s).", candidateCount, activeCount) + } + if candidateCount > 0 { + return fmt.Sprintf("0 pressure(s), %d candidate(s); structure stays candidate until repeated evidence proves it.", candidateCount) + } return "Pressure Ledger is empty; the next run starts from plan.md and repository state." } - activeCount := activeStructureCount(growth.Candidates) if activeCount > 0 { return fmt.Sprintf("%d pressure(s), %d candidate(s), %d active structure(s).", pressureCount, candidateCount, activeCount) } diff --git a/internal/app/doctor.go b/internal/app/doctor.go index fd24423..a87c380 100644 --- a/internal/app/doctor.go +++ b/internal/app/doctor.go @@ -136,6 +136,9 @@ func doctorGrowthMigrationCheck(root string) doctorCheck { if growth.Version == 0 { return doctorCheck{"Growth migration", "OK", "no growth state yet"} } + if growthHasUnstoredManualActiveCapability(root, growth) { + return doctorCheck{"Growth migration", "WARN", "active capability files are not reflected in stored growth state; run `hyper migrate`"} + } if growthMigrationNeeded(growth) { return doctorCheck{"Growth migration", "WARN", "legacy or noisy growth entries found; run `hyper migrate`"} } @@ -150,7 +153,7 @@ func doctorReadinessStateCheck(root string) doctorCheck { if !exists(filepath.Join(root, planFile)) { return doctorCheck{"Readiness state", "WARN", "plan.md missing; cannot refresh readiness"} } - current := readinessStateForStatus(root, readGrowthStateIfExists(root)) + current := readinessStateForStatus(root, growthStateForStatus(root)) if current.Version == 0 { return doctorCheck{"Readiness state", "OK", "readiness state is present"} } diff --git a/internal/app/goal_state.go b/internal/app/goal_state.go index 68fd0ed..86769b2 100644 --- a/internal/app/goal_state.go +++ b/internal/app/goal_state.go @@ -375,6 +375,9 @@ func noisyMemoryText(text string) bool { if normalized == "" { return true } + if isHyperProtocolNoiseText(normalized) { + return true + } return hasAny(normalized, "hyper run created", "`hyper run` created", "created goal-", "created `goal-", "runtime packet created", "created runtime packet", "screenshot saved", "screenshot path", "pending.", "no learnable signal", @@ -382,6 +385,17 @@ func noisyMemoryText(text string) bool { ) || isNoIssueText(normalized) || isPassiveNoChangeText(normalized) } +func isHyperProtocolNoiseText(normalized string) bool { + return hasAny(normalized, + "stage advancement remains a recommendation pending user acceptance", + "do not edit `plan.md current stage` until the user accepts stage advancement", + "do not edit plan.md current stage until the user accepts stage advancement", + "do not run `hyper advance` unless the user accepts the stage advancement", + "recommend updating plan.md current stage", + "review readiness evidence, then run `hyper advance`", + ) +} + func isPassiveNoChangeText(normalized string) bool { return hasAny(normalized, "not changed in this episode", diff --git a/internal/app/growth.go b/internal/app/growth.go index 725b265..5ac4923 100644 --- a/internal/app/growth.go +++ b/internal/app/growth.go @@ -85,6 +85,80 @@ func readGrowthStateIfExists(root string) growthState { return state } +func growthStateForStatus(root string) growthState { + return growthStateWithActiveCapabilityOverlay(root, readGrowthStateIfExists(root)) +} + +func growthStateWithActiveCapabilityOverlay(root string, growth growthState) growthState { + active, err := activeCapabilities(root) + if err != nil { + return growth + } + return mergeActiveCapabilityCandidates(growth, active) +} + +func growthHasUnstoredManualActiveCapability(root string, growth growthState) bool { + active, err := activeCapabilities(root) + if err != nil { + return false + } + if len(active) == 0 { + return false + } + seen := map[string]growthCandidate{} + for _, candidate := range growth.Candidates { + seen[growthCandidateIdentity(candidate)] = candidate + } + for _, capability := range active { + if capability.Managed { + continue + } + candidate, ok := seen[growthCandidateIdentity(growthCandidateForActiveCapability(capability))] + if !ok || candidate.Status != "active" { + return true + } + } + return false +} + +func mergeActiveCapabilityCandidates(growth growthState, active []activeCapability) growthState { + if len(active) == 0 { + return growth + } + candidates := append([]growthCandidate{}, growth.Candidates...) + seen := map[string]int{} + for index, candidate := range candidates { + seen[growthCandidateIdentity(candidate)] = index + } + for _, capability := range active { + if capability.Managed { + continue + } + candidate := growthCandidateForActiveCapability(capability) + key := growthCandidateIdentity(candidate) + if index, ok := seen[key]; ok { + if candidates[index].Status != "active" { + candidates[index] = candidate + } + continue + } + seen[key] = len(candidates) + candidates = append(candidates, candidate) + } + growth.Candidates = candidates + growth.PressureLedger = pressureLedgerFor(growth.Pressures, growth.Candidates) + return growth +} + +func growthCandidateIdentity(candidate growthCandidate) string { + kind := strings.ToLower(strings.TrimSpace(candidate.Kind)) + name := strings.ToLower(strings.TrimSpace(candidate.Name)) + if kind != "" || name != "" { + return kind + "\x00" + name + } + return strings.TrimSpace(candidate.LifecyclePath) +} + func loadMemoryRecords(db *sql.DB) ([]memoryRecord, *hyperError) { rows, err := db.Query(`select id, kind, text, coalesce(confidence, 0), coalesce(quality, '') from memories where stale_at is null order by created_at asc, id asc`) if err != nil { @@ -269,6 +343,9 @@ func isNoisyGrowthSignal(signal string) bool { if isNoIssueText(normalized) || isPassiveNoChangeText(normalized) { return true } + if isHyperProtocolNoiseText(normalized) { + return true + } tokens := pressureTokens(signal) if len(tokens) < 2 { return true @@ -654,17 +731,7 @@ func materializeGrowthCandidates(root string, pressures []growthPressure, previo if activeErr != nil { return nil, activeErr } - for _, capability := range active { - if capability.Managed { - continue - } - candidate := growthCandidateForActiveCapability(capability) - seenKey := firstNonBlank(candidate.LifecyclePath, candidate.Kind+"\x00"+candidate.Name) - if !seen[seenKey] { - candidates = append(candidates, candidate) - seen[seenKey] = true - } - } + candidates = mergeActiveCapabilityCandidates(growthState{Candidates: candidates}, active).Candidates retired, err := retiredGrowthCandidates(root, previous, candidates) if err != nil { return nil, err diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 69f05aa..258e0c6 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -1119,6 +1119,44 @@ func TestGrowthIgnoresPassiveReadinessProofAsSkillCandidate(t *testing.T) { } } +func TestGrowthIgnoresStageAdvancementProtocolNoise(t *testing.T) { + pressures := deriveGrowthPressures([]memoryRecord{ + {Kind: "decision", Text: "GOAL-0001 learn decision: Preserve current stage in `plan.md`; stage advancement remains a recommendation pending user acceptance.", Confidence: 0.75, Quality: "durable"}, + {Kind: "constraint", Text: "GOAL-0002 learn constraint: Do not edit `plan.md Current Stage` until the user accepts stage advancement.", Confidence: 0.75, Quality: "durable"}, + }) + if len(pressures) != 0 { + t.Fatalf("expected stage advancement protocol notes to stay out of growth pressure, got %+v", pressures) + } + memories := appendMemoryIfUseful(nil, "decision", "GOAL-0001 decisions: Preserve current stage in `plan.md`; stage advancement remains a recommendation pending user acceptance.", 0.75) + if len(memories) != 0 { + t.Fatalf("expected protocol note to stay out of memory, got %+v", memories) + } +} + +func TestSimilarContextIgnoresProtocolNoiseMemories(t *testing.T) { + root := t.TempDir() + if err := ensureProjectLayout(root); err != nil { + t.Fatalf("layout failed: %v", err) + } + db, err := openDB(root) + if err != nil { + t.Fatalf("db open failed: %v", err) + } + defer db.Close() + if err := ensureSchema(db); err != nil { + t.Fatalf("schema failed: %v", err) + } + insertRawTestMemory(t, db, "decision", "GOAL-0001 learn decision: Preserve current stage in `plan.md`; stage advancement remains a recommendation pending user acceptance.", "durable") + + similar, hyperErr := findSimilarContext(db, "stage advancement plan current stage", 5) + if hyperErr != nil { + t.Fatalf("similar context failed: %v", hyperErr) + } + if len(similar) != 0 { + t.Fatalf("expected protocol noise memory to stay out of similar context, got %+v", similar) + } +} + func TestGrowthClustersSignalsAndPromotesLifecycle(t *testing.T) { root := t.TempDir() if err := ensureProjectLayout(root); err != nil { @@ -1235,6 +1273,123 @@ func TestActiveCapabilityFilesBecomeGrowthCandidates(t *testing.T) { assertContains(t, readFile(t, filepath.Join(root, ".hyper", "growth", "state.json")), `"name": "harness-growth-candidate"`) } +func TestGrowthStatusOverlayPromotesManualActiveCapabilityWithoutDuplicate(t *testing.T) { + root := t.TempDir() + if err := ensureProjectLayout(root); err != nil { + t.Fatalf("layout failed: %v", err) + } + writeFile(t, filepath.Join(root, ".hyper", "capabilities", "active", "validator", "validator-go-test.md"), "# validator-go-test\n\nStatus: active\nKind: validator\nSignal: Run go test ./... before completing packets.\n") + growth := growthState{ + Pressures: []growthPressure{{State: "repeated", PressureType: "repeated_validation", Effect: "validation", Signal: "Run go test before handoff.", GoalCount: 2}}, + Candidates: []growthCandidate{ + {Kind: "validator", Name: "validator-go-test", Status: "repeated", Signal: "Run go test before handoff.", LifecyclePath: filepath.Join(hyperDir, "capabilities", "candidates", "validator", "validator-go-test.md")}, + }, + } + + overlaid := growthStateWithActiveCapabilityOverlay(root, growth) + if len(overlaid.Candidates) != 1 { + t.Fatalf("expected one candidate after overlay, got %+v", overlaid.Candidates) + } + if overlaid.Candidates[0].Status != "active" { + t.Fatalf("expected active candidate after overlay, got %+v", overlaid.Candidates[0]) + } + if activeStructureCount(overlaid.Candidates) != 1 || overlaid.PressureLedger.ActiveStructures != 1 { + t.Fatalf("expected active counts to refresh, got %+v", overlaid.PressureLedger) + } +} + +func TestStatusReflectsManualActiveCapabilityBeforeMigrate(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "plan.md"), strings.Join([]string{ + "# Product Plan", + "", + "## Product", + "", + "Local Build Relay", + "", + "## Target Users", + "", + "Developers", + "", + "## MVP", + "", + "Run one repeatable handoff command.", + "", + "## Current Stage", + "", + "Service Quality", + "", + "## Build Style", + "", + "Go CLI", + "", + "## Success Criteria", + "", + "Every packet proves validation, release, docs, maintainability, and benchmark baseline.", + }, "\n")) + if _, err := runCLI(args("run", "Prepare sustained quality"), testRoot(root), fakeUpdater{}); err != nil { + t.Fatalf("run failed: %v", err) + } + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), strings.Join([]string{ + "# GOAL-0001 Evidence", + "", + "## Validation", + "", + "`go test ./...` passed and the CLI smoke command is repeatable.", + "", + "## Readiness Evidence", + "", + "Validation coverage: `go test ./...` passed and the CLI smoke command is repeatable.", + "Security baseline: Privacy boundary verified, no cloud sync, no telemetry, no token storage, no secrets, and local-only data handling is explicit.", + "Deployment readiness: Built the CLI binary and ran the smoke command outside the development command.", + "Operations and docs: README handoff notes cover setup, rollback, recovery, and the smoke command.", + "Maintainability: Table-driven validation helper keeps command checks repeatable without hidden local context.", + "", + "## Reference Benchmark Evidence", + "", + "- Category: Local developer handoff CLI.", + "- References: GitHub CLI, Taskfile, Make.", + "- Baseline expectations: documented command, repeatable output, rollback notes, no hidden credentials.", + "- Current comparison: below baseline = none; meets baseline = command/test/docs/rollback; above baseline = packet evidence loop.", + "- Below-baseline gaps: No critical below-baseline gap.", + "- Above-baseline strength: packet evidence loop.", + "- Decision: Service Quality proof can continue.", + "", + "## Blocker", + "", + "None blocking.", + }, "\n")) + if activeStructureCount(readGrowthStateIfExists(root).Candidates) != 0 { + t.Fatal("stored growth should not know about the manual active capability yet") + } + writeFile(t, filepath.Join(root, ".hyper", "capabilities", "active", "validator", "validator-go-test.md"), "# validator-go-test\n\nStatus: active\nKind: validator\nSignal: Run go test ./... before completing packets.\n") + + short, err := runCLI(args("status", "--short"), testRoot(root), fakeUpdater{}) + if err != nil { + t.Fatalf("status failed: %v", err) + } + assertContains(t, short.Stdout, "Gate: Service Quality -> Sustained Service Quality (ready)") + assertContains(t, short.Stdout, "Next: update .hyper/goals/GOAL-0001/evidence.md and next.md, then run `hyper complete`") + + full, err := runCLI(args("status"), testRoot(root), fakeUpdater{}) + if err != nil { + t.Fatalf("status failed: %v", err) + } + assertContains(t, full.Stdout, "Pressure ledger: 0 pressure(s), 1 candidate(s), 1 active structure(s).") + assertContains(t, full.Stdout, "Covered axes: Product completeness, Validation coverage, Security baseline, Deployment readiness, Operations and docs, Maintainability, Reference benchmark, Sustained quality") + if activeStructureCount(readGrowthStateIfExists(root).Candidates) != 0 { + t.Fatal("status overlay should not mutate stored growth state") + } + + doctor, err := runCLI(args("doctor"), testRoot(root), fakeUpdater{}) + if err != nil { + t.Fatalf("doctor failed: %v", err) + } + assertContains(t, doctor.Stdout, "[WARN] Growth migration: active capability files are not reflected in stored growth state; run `hyper migrate`") + assertContains(t, doctor.Stdout, "[WARN] Readiness state:") + assertContains(t, doctor.Stdout, "Run `hyper migrate`.") +} + func TestReadinessPressureSelectsStageGateGap(t *testing.T) { root := t.TempDir() mustRun(t, root, "init") diff --git a/internal/app/similarity.go b/internal/app/similarity.go index bd4ca60..e6e4113 100644 --- a/internal/app/similarity.go +++ b/internal/app/similarity.go @@ -64,6 +64,9 @@ func findSimilarContext(db *sql.DB, query string, limit int) ([]similarContext, if memoryQualityIsIgnored(quality) { continue } + if noisyMemoryText(text) { + continue + } candidates = append(candidates, similarContext{Source: "memory", ID: strconv.FormatInt(id, 10), Kind: firstNonBlank(quality, kind), Text: text}) } memRows.Close() From 35a8c2d036fc0e4b0fd3b91fcbcd84628153e457 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 19:19:09 +0900 Subject: [PATCH 12/52] Improve file persistence and gap pressure --- internal/app/growth.go | 15 +++++++++++++++ internal/app/main_test.go | 26 ++++++++++++++++++++++++++ internal/app/readiness.go | 4 ++-- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/internal/app/growth.go b/internal/app/growth.go index 5ac4923..5cc84ca 100644 --- a/internal/app/growth.go +++ b/internal/app/growth.go @@ -439,6 +439,9 @@ func growthClassification(kind, signal string) (string, string) { case "constraint": return "recurring_constraint", "work_boundary" case "failure": + if isKnownImplementationGap(signal) { + return "implementation_gap", "implementation" + } return "recurring_failure", "stop_condition" case "pattern": if isSurfaceValidationPattern(signal) { @@ -453,6 +456,18 @@ func growthClassification(kind, signal string) (string, string) { } } +func isKnownImplementationGap(signal string) bool { + normalized := normalizeSentence(signal) + return hasAny(normalized, + "not handled yet", + "not implemented yet", + "not covered yet", + "not built yet", + "needs implementation", + "needs recovery", + ) +} + func isValidationPattern(signal string) bool { normalized := strings.ToLower(signal) return hasAny(normalized, "test", "build", "smoke", "validate", "validation", "playwright", "browser", "go test", "npm run", "pytest") diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 258e0c6..edb0d9a 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -1133,6 +1133,22 @@ func TestGrowthIgnoresStageAdvancementProtocolNoise(t *testing.T) { } } +func TestGrowthTreatsKnownGapFailureAsImplementationPressure(t *testing.T) { + pressures := deriveGrowthPressures([]memoryRecord{ + {Kind: "failure", Text: "GOAL-0003 learn failure: Malformed `.release_notes.json` recovery is not handled yet.", Confidence: 0.8, Quality: "durable"}, + }) + if len(pressures) != 1 { + t.Fatalf("expected one implementation gap pressure, got %+v", pressures) + } + if pressures[0].PressureType != "implementation_gap" || pressures[0].Effect != "implementation" { + t.Fatalf("expected implementation gap pressure, got %+v", pressures[0]) + } + behavior := growthBehaviorFromPressures(pressures) + if len(behavior.StopConditions) != 0 { + t.Fatalf("known implementation gap should not become a stop condition, got %+v", behavior.StopConditions) + } +} + func TestSimilarContextIgnoresProtocolNoiseMemories(t *testing.T) { root := t.TempDir() if err := ensureProjectLayout(root); err != nil { @@ -2486,6 +2502,16 @@ func TestReadinessEvidenceRequiresAxisLabelAndCoversMySQLPersistence(t *testing. } } +func TestReadinessEvidenceCoversFileBackedPersistence(t *testing.T) { + record, ok := parseReadinessEvidenceLine("GOAL-0002", "Data persistence: `.release_notes.json` stored the release note and a separate `go run . list` command re-read it after the add command exited.", readinessDimensionDefs()) + if !ok { + t.Fatal("expected labeled file persistence evidence to parse") + } + if record.Axis != "persistence" || record.Status != "covered" { + t.Fatalf("expected covered persistence evidence, got %+v", record) + } +} + func TestReadinessEvidenceCoversPrivacyBoundaryAsSecurityBaseline(t *testing.T) { defs := readinessDimensionDefs() record, ok := parseReadinessEvidenceLine("GOAL-0001", "Privacy boundary: clipboard content stays local in SQLite, no cloud sync or telemetry, and sensitive text can be deleted locally.", defs) diff --git a/internal/app/readiness.go b/internal/app/readiness.go index 388847d..c65fda5 100644 --- a/internal/app/readiness.go +++ b/internal/app/readiness.go @@ -450,8 +450,8 @@ func readinessEvidenceQuality(axis, text string) (bool, string) { "browser, screenshot, smoke, or verified primary-flow evidence" case "persistence": return hasAny(normalized, "persist", "reload", "restart", "saved", "survive", "stored", "created", "re-read", "reread", "confirmed", "row") && - hasAny(normalized, "sqlite", "mysql", "postgres", "postgresql", "database", " db ", "db check", "sql", "localstorage", "local storage", "storage"), - "MySQL, SQLite, DB, reload, restart, storage, or database evidence" + hasAny(normalized, "sqlite", "mysql", "postgres", "postgresql", "database", " db ", "db check", "sql", "localstorage", "local storage", "storage", "json", ".json", "file", "disk", "filesystem"), + "MySQL, SQLite, DB, file, JSON, reload, restart, storage, or database evidence" case "error_handling": return hasAny(normalized, "empty", "error", "loading", "fallback", "failure", "edge") && hasAny(normalized, "handled", "covered", "verified", "tested", "implemented", "works"), From 4bd9c84436ef319c8255625632ed691cedf306ec Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 19:31:05 +0900 Subject: [PATCH 13/52] Refine sustained quality flow --- internal/app/growth.go | 2 +- internal/app/main_test.go | 42 +++++++++++++++++++++++++++++++++++++++ internal/app/plan.go | 12 ++++++++++- internal/app/readiness.go | 6 +++++- 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/internal/app/growth.go b/internal/app/growth.go index 5cc84ca..1c8262b 100644 --- a/internal/app/growth.go +++ b/internal/app/growth.go @@ -838,7 +838,7 @@ func harnessCandidateForPressure(pressure growthPressure) growthCandidate { Signal: pressure.Signal, PressureType: pressure.PressureType, Sources: pressure.Sources, - EvidenceCount: pressure.GoalCount, + EvidenceCount: pressure.MemoryCount, RepeatedThreshold: growthHarnessStablePressures, PromotionThreshold: growthHarnessPromotableSignals, ActivationThreshold: growthHarnessActiveSignals, diff --git a/internal/app/main_test.go b/internal/app/main_test.go index edb0d9a..35b66e5 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -1244,6 +1244,21 @@ func TestGrowthClustersSignalsAndPromotesLifecycle(t *testing.T) { assertNotContains(t, readFile(t, filepath.Join(root, ".hyper", "growth", "state.json")), "Required active validator validator-go-test") } +func TestHarnessCandidateEvidenceCountUsesStablePressureCount(t *testing.T) { + pressure := aggregateHarnessPressure([]growthPressure{ + {Effect: "validation", GoalCount: 2, Sources: []string{"GOAL-0001", "GOAL-0002"}}, + {Effect: "implementation", GoalCount: 2, Sources: []string{"GOAL-0003", "GOAL-0004"}}, + {Effect: "work_boundary", GoalCount: 2, Sources: []string{"GOAL-0005", "GOAL-0006"}}, + }) + candidate := harnessCandidateForPressure(pressure) + if candidate.Status != "repeated" { + t.Fatalf("expected repeated harness candidate, got %+v", candidate) + } + if candidate.EvidenceCount != 3 { + t.Fatalf("expected harness evidence count to use stable pressure count, got %+v", candidate) + } +} + func TestActiveValidatorBecomesRequiredValidationSignal(t *testing.T) { root := t.TempDir() mustInitWithPlan(t, root, "Tiny CLI", "Build a tiny CLI MVP") @@ -2164,6 +2179,8 @@ func TestStageNormalizationUsesFirstNamedStage(t *testing.T) { if sustained.NextPressure.Axis == "stage_advancement" { t.Fatalf("sustained stage should continue quality work, got %+v", sustained.NextPressure) } + assertContains(t, sustained.NextPressure.RecommendedGoal, "Run active quality checks") + assertNotContains(t, sustained.NextPressure.RecommendedGoal, "until active validation") } func TestReferenceBenchmarkPressureShapesRuntimePacket(t *testing.T) { @@ -2397,6 +2414,31 @@ func TestReferenceBenchmarkEvidenceTemplateForBetaAndServiceQuality(t *testing.T assertContains(t, tasks, "Fill Reference Benchmark Evidence") } +func TestReferenceBenchmarkEvidenceNotRepeatedAfterCovered(t *testing.T) { + readiness := readinessState{ + Version: 1, + Stage: "Sustained Service Quality", + Dimensions: []readinessDimension{ + {ID: "reference_benchmark", Name: "Reference benchmark", Status: "covered"}, + {ID: "sustained_quality", Name: "Sustained quality", Status: "covered"}, + }, + StageGate: readinessStageGate{ + Status: "ready", + CurrentStage: "Sustained Service Quality", + NextStage: "Sustained Service Quality", + RequiredAxes: []string{"validation_coverage", "operations_docs", "maintainability", "sustained_quality", "reference_benchmark"}, + }, + NextPressure: readinessPressure{Axis: "sustained_quality", AxisName: "Sustained quality", Status: "ongoing"}, + } + + evidence := buildEvidenceDoc("GOAL-0009", "Sustained Service Quality", readiness) + assertNotContains(t, evidence, "## Reference Benchmark Evidence") + tasks := buildTasksDoc("GOAL-0009", "Go CLI", "Sustained Service Quality", readiness, growthState{}) + assertNotContains(t, tasks, "Fill Reference Benchmark Evidence") + checklist := doneChecklistDoc("Sustained Service Quality", readiness, growthState{}) + assertNotContains(t, checklist, "Reference Benchmark Evidence lists") +} + func TestReferenceBenchmarkEvidenceSectionFeedsReadiness(t *testing.T) { root := t.TempDir() goalDir := filepath.Join(root, ".hyper", "goals", "GOAL-0001") diff --git a/internal/app/plan.go b/internal/app/plan.go index 84be9f2..c85f79c 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -803,7 +803,7 @@ func doneChecklistDoc(stage string, readiness readinessState, growth growthState if strings.Contains(normalizeLabel(stage), "beta") || strings.Contains(normalizeLabel(stage), "service") { lines = append(lines, "- Stop conditions cover failure, regression, and missing credential cases found during this packet.") } - if serviceQualityStage(stage) { + if referenceBenchmarkRequired(stage, readiness) { lines = append(lines, "- Reference Benchmark Evidence lists 3-5 references, baseline expectations, current comparison, below-baseline gaps, above-baseline strength, and the next pressure.") } return strings.Join(lines, "\n") @@ -959,6 +959,16 @@ func serviceQualityStage(stage string) bool { } func referenceBenchmarkRequired(stage string, readiness readinessState) bool { + if readiness.Version != 0 { + if readiness.NextPressure.Axis == "reference_benchmark" { + return true + } + if readinessAxisRequired(readiness, "reference_benchmark") { + dim := readinessDimensionMap(readiness.Dimensions)["reference_benchmark"] + return dim.Status != "covered" + } + return false + } normalized := normalizeLabel(stage) if strings.Contains(normalized, "beta") || serviceQualityStage(stage) { return true diff --git a/internal/app/readiness.go b/internal/app/readiness.go index c65fda5..625917d 100644 --- a/internal/app/readiness.go +++ b/internal/app/readiness.go @@ -941,7 +941,7 @@ func selectReadinessPressure(plan map[string]string, stage string, dimensions [] AxisName: "Sustained quality", Status: "ongoing", Reason: gate.CurrentStage + " is active; continue the next focused quality improvement instead of advancing stage.", - RecommendedGoal: readinessRecommendedGoal(plan, stage, "sustained_quality"), + RecommendedGoal: readinessSustainedOngoingGoal(plan), WorkBoundary: "Stay in sustained operation: reduce one repeated validation, operational, or maintainability friction without broad feature expansion.", ValidationSignal: "Run active validators, active harnesses, or the safest equivalent quality check and record the result.", } @@ -1025,6 +1025,10 @@ func readinessRecommendedGoal(plan map[string]string, stage, axis string) string } } +func readinessSustainedOngoingGoal(plan map[string]string) string { + return fmt.Sprintf("Run active quality checks and reduce one small operational, validation, or maintainability friction for %s.", readinessProductName(plan)) +} + func readinessProductName(plan map[string]string) string { product := firstRuntimeValue(plan["Product"], "the product") if before, after, ok := strings.Cut(product, " is "); ok && strings.TrimSpace(before) != "" && strings.TrimSpace(after) != "" { From 29a74ce45f0d476442cb5097261cd0943044450d Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 19:41:09 +0900 Subject: [PATCH 14/52] Surface migration before next action --- internal/app/commands.go | 5 +- internal/app/goal_state.go | 2 + internal/app/main_test.go | 59 +++++++++++++++++++++++ internal/app/status.go | 95 +++++++++++++++++++++++++++++++++----- 4 files changed, 148 insertions(+), 13 deletions(-) diff --git a/internal/app/commands.go b/internal/app/commands.go index 7a6c7b7..0c0a8e2 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -304,10 +304,11 @@ func statusHyper(fsys fsRoot, args []string) (commandOutput, *hyperError) { runs, goals := statusDBCounts(root) growth := growthStateForStatus(root) readiness := readinessStateForStatus(root, growth) + refresh := statusRefreshFor(root) if short { - return stdout(strings.Join(statusShortLines(state, derived, readiness, growth), "\n")), nil + return stdout(strings.Join(statusShortLinesWithRefresh(state, derived, readiness, growth, refresh), "\n")), nil } - lines := statusDashboardLines(state, derived, readiness, growth, runs, goals) + lines := statusDashboardLinesWithRefresh(state, derived, readiness, growth, runs, goals, refresh) return stdout(strings.Join(lines, "\n")), nil } diff --git a/internal/app/goal_state.go b/internal/app/goal_state.go index 86769b2..d3b02bd 100644 --- a/internal/app/goal_state.go +++ b/internal/app/goal_state.go @@ -388,6 +388,8 @@ func noisyMemoryText(text string) bool { func isHyperProtocolNoiseText(normalized string) bool { return hasAny(normalized, "stage advancement remains a recommendation pending user acceptance", + "stage advancement is a recommendation pending user acceptance", + "stage advancement recommendation pending user acceptance", "do not edit `plan.md current stage` until the user accepts stage advancement", "do not edit plan.md current stage until the user accepts stage advancement", "do not run `hyper advance` unless the user accepts the stage advancement", diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 35b66e5..647c53a 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -789,6 +789,64 @@ func TestStatusShortShowsOnlyDecisionSurface(t *testing.T) { assertNotContains(t, status.Stdout, "Readiness:") } +func TestStatusSuggestsMigrateBeforeNextActionWhenGrowthIsStale(t *testing.T) { + root := t.TempDir() + mustInitWithPlan(t, root, "Tiny tasks", "Build a tiny task list MVP") + mustRun(t, root, "run") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\n`npm run build` passed and browser smoke passed.\n\n## Readiness Evidence\n\nCore UX: Browser smoke passed for create and complete flow.\nValidation coverage: `npm run build` passed and primary browser smoke is repeatable.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nReview stage advancement.\n") + mustRun(t, root, "complete") + stale := growthState{ + Version: 1, + Pressures: []growthPressure{ + {State: "repeated", PressureType: "recurring_failure", Effect: "stop_condition", Signal: "None in this run.", GoalCount: 2}, + }, + } + if err := writeJSON(filepath.Join(root, ".hyper", "growth", "state.json"), stale); err != nil { + t.Fatalf("write stale growth failed: %v", err) + } + + short, err := runCLI(args("status", "--short"), testRoot(root), fakeUpdater{}) + if err != nil { + t.Fatalf("status --short failed: %v", err) + } + assertContains(t, short.Stdout, "Next: hyper migrate") + assertContains(t, short.Stdout, "Refresh: legacy or noisy growth entries found; run `hyper migrate`") + assertContains(t, short.Stdout, "Guard: run `hyper migrate` before advancing or starting another packet") + assertNotContains(t, short.Stdout, "Next: hyper advance") + + full, err := runCLI(args("status"), testRoot(root), fakeUpdater{}) + if err != nil { + t.Fatalf("status failed: %v", err) + } + assertContains(t, full.Stdout, "State refresh: needed - legacy or noisy growth entries found; run `hyper migrate`") + assertContains(t, full.Stdout, "Next action: hyper migrate") + assertContains(t, full.Stdout, "Do not advance or start another packet until `hyper migrate` refreshes growth and readiness state.") +} + +func TestStatusDoesNotPutMigrateBeforeActivePacketCompletion(t *testing.T) { + root := t.TempDir() + mustInitWithPlan(t, root, "Tiny tasks", "Build a tiny task list MVP") + mustRun(t, root, "run") + stale := growthState{ + Version: 1, + Pressures: []growthPressure{ + {State: "repeated", PressureType: "recurring_failure", Effect: "stop_condition", Signal: "None in this run.", GoalCount: 2}, + }, + } + if err := writeJSON(filepath.Join(root, ".hyper", "growth", "state.json"), stale); err != nil { + t.Fatalf("write stale growth failed: %v", err) + } + + short, err := runCLI(args("status", "--short"), testRoot(root), fakeUpdater{}) + if err != nil { + t.Fatalf("status --short failed: %v", err) + } + assertContains(t, short.Stdout, "Next: update .hyper/goals/GOAL-0001/evidence.md and next.md, then run `hyper complete`") + assertContains(t, short.Stdout, "Refresh: legacy or noisy growth entries found; run `hyper migrate`") + assertNotContains(t, short.Stdout, "Next: hyper migrate") +} + func TestStatusShortRejectsUnknownOption(t *testing.T) { _, err := runCLI(args("status", "--json"), testRoot(t.TempDir()), fakeUpdater{}) if err == nil { @@ -1123,6 +1181,7 @@ func TestGrowthIgnoresStageAdvancementProtocolNoise(t *testing.T) { pressures := deriveGrowthPressures([]memoryRecord{ {Kind: "decision", Text: "GOAL-0001 learn decision: Preserve current stage in `plan.md`; stage advancement remains a recommendation pending user acceptance.", Confidence: 0.75, Quality: "durable"}, {Kind: "constraint", Text: "GOAL-0002 learn constraint: Do not edit `plan.md Current Stage` until the user accepts stage advancement.", Confidence: 0.75, Quality: "durable"}, + {Kind: "decision", Text: "GOAL-0003 decisions: Do not edit `plan.md Current Stage` in this packet; stage advancement is a recommendation pending user acceptance.", Confidence: 0.75, Quality: "durable"}, }) if len(pressures) != 0 { t.Fatalf("expected stage advancement protocol notes to stay out of growth pressure, got %+v", pressures) diff --git a/internal/app/status.go b/internal/app/status.go index 00c786f..b058384 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -41,6 +41,10 @@ func staleProjectName(project string) bool { } func statusDashboardLines(state projectState, derived goalState, readiness readinessState, growth growthState, runs, goals int) []string { + return statusDashboardLinesWithRefresh(state, derived, readiness, growth, runs, goals, statusRefresh{}) +} + +func statusDashboardLinesWithRefresh(state projectState, derived goalState, readiness readinessState, growth growthState, runs, goals int, refresh statusRefresh) []string { project := compactText(firstNonBlank(state.Project, "Unknown project"), 120) stage := normalizeRuntimeStage(firstNonBlank(state.Stage, readiness.Stage, "Unknown stage")) lines := []string{ @@ -60,6 +64,9 @@ func statusDashboardLines(state projectState, derived goalState, readiness readi "Runtime packet state: " + derived.State, "Runtime packet reason: " + derived.Reason, } + if refresh.Needed { + lines = append(lines, "State refresh: needed - "+refresh.Reason) + } runLabel := "Last run" packetLabel := "Last runtime packet" if state.Status == "active" { @@ -72,7 +79,7 @@ func statusDashboardLines(state projectState, derived goalState, readiness readi "Runtime packet file: "+state.CurrentGoalPath, "", ) - lines = append(lines, statusActionLines(state, derived, readiness, growth)...) + lines = append(lines, statusActionLinesWithRefresh(state, derived, readiness, growth, refresh)...) lines = append(lines, "") lines = append(lines, pressureDashboardLines(growth)...) lines = append(lines, "") @@ -80,7 +87,7 @@ func statusDashboardLines(state projectState, derived goalState, readiness readi lines = append(lines, "", "Next:", - " "+statusNextCommand(state, derived, readiness), + " "+statusNextCommandWithRefresh(state, derived, readiness, refresh), "", fmt.Sprintf("Runs recorded: %d", runs), fmt.Sprintf("Runtime packets recorded: %d", goals), @@ -93,9 +100,13 @@ func statusDashboardLines(state projectState, derived goalState, readiness readi } func statusShortLines(state projectState, derived goalState, readiness readinessState, growth growthState) []string { + return statusShortLinesWithRefresh(state, derived, readiness, growth, statusRefresh{}) +} + +func statusShortLinesWithRefresh(state projectState, derived goalState, readiness readinessState, growth growthState, refresh statusRefresh) []string { project := compactText(firstNonBlank(state.Project, "Unknown project"), 80) stage := normalizeRuntimeStage(firstNonBlank(state.Stage, readiness.Stage, "Unknown stage")) - next := statusNextCommand(state, derived, readiness) + next := statusNextCommandWithRefresh(state, derived, readiness, refresh) lines := []string{ "Hyper Run Status", "Project: " + project, @@ -105,7 +116,10 @@ func statusShortLines(state projectState, derived goalState, readiness readiness "Proof: " + proofStatusSummary(derived, readiness), "Packet: " + shortPacketSummary(state, derived), "Next: " + next, - "Why: " + statusActionReason(state, derived, readiness, growth), + "Why: " + statusActionReasonWithRefresh(state, derived, readiness, growth, refresh), + } + if refresh.Needed { + lines = append(lines, "Refresh: "+refresh.Reason) } if benchmark := referenceBenchmarkShortStatus(readiness); benchmark != "" { lines = append(lines, "Benchmark: "+benchmark) @@ -113,7 +127,7 @@ func statusShortLines(state projectState, derived goalState, readiness readiness if gap := statusShortGap(readiness); gap != "" { lines = append(lines, "Gap: "+gap) } - if guard := statusShortGuard(state, derived, readiness, growth); guard != "" { + if guard := statusShortGuardWithRefresh(state, derived, readiness, growth, refresh); guard != "" { lines = append(lines, "Guard: "+guard) } lines = append(lines, "") @@ -164,7 +178,10 @@ func statusShortGap(readiness readinessState) string { return "" } -func statusShortGuard(state projectState, derived goalState, readiness readinessState, growth growthState) string { +func statusShortGuardWithRefresh(state projectState, derived goalState, readiness readinessState, growth growthState, refresh statusRefresh) string { + if statusRefreshActionable(state, derived, refresh) { + return "run `hyper migrate` before advancing or starting another packet" + } warning := statusDoNotDoYet(state, derived, readiness, growth) if strings.HasPrefix(warning, "Do not add broad structure") { return "" @@ -178,6 +195,32 @@ func statusShortGuard(state projectState, derived goalState, readiness readiness return warning } +type statusRefresh struct { + Needed bool + Reason string +} + +func statusRefreshFor(root string) statusRefresh { + growth := readGrowthStateIfExists(root) + if growth.Version != 0 { + if growthHasUnstoredManualActiveCapability(root, growth) { + return statusRefresh{Needed: true, Reason: "active capability files are not reflected in stored growth state; run `hyper migrate`"} + } + if growthMigrationNeeded(growth) { + return statusRefresh{Needed: true, Reason: "legacy or noisy growth entries found; run `hyper migrate`"} + } + } + stored := readReadinessStateIfExists(root) + if stored.Version == 0 || !exists(filepath.Join(root, planFile)) { + return statusRefresh{} + } + current := readinessStateForStatus(root, growthStateForStatus(root)) + if current.Version != 0 && !sameReadinessForDoctor(stored, current) { + return statusRefresh{Needed: true, Reason: "stored readiness differs from current evidence; run `hyper migrate`"} + } + return statusRefresh{} +} + func proofStatusSummary(derived goalState, readiness readinessState) string { if readiness.Version == 0 { return "not recorded" @@ -244,15 +287,22 @@ func nextProofGap(readiness readinessState) string { } } -func statusActionLines(state projectState, derived goalState, readiness readinessState, growth growthState) []string { +func statusActionLinesWithRefresh(state projectState, derived goalState, readiness readinessState, growth growthState, refresh statusRefresh) []string { lines := []string{"Action:"} - lines = append(lines, " Next action: "+statusNextCommand(state, derived, readiness)) - lines = append(lines, " Why now: "+statusActionReason(state, derived, readiness, growth)) - lines = append(lines, " Do not do yet: "+statusDoNotDoYet(state, derived, readiness, growth)) + lines = append(lines, " Next action: "+statusNextCommandWithRefresh(state, derived, readiness, refresh)) + lines = append(lines, " Why now: "+statusActionReasonWithRefresh(state, derived, readiness, growth, refresh)) + lines = append(lines, " Do not do yet: "+statusDoNotDoYetWithRefresh(state, derived, readiness, growth, refresh)) return lines } func statusActionReason(state projectState, derived goalState, readiness readinessState, growth growthState) string { + return statusActionReasonWithRefresh(state, derived, readiness, growth, statusRefresh{}) +} + +func statusActionReasonWithRefresh(state projectState, derived goalState, readiness readinessState, growth growthState, refresh statusRefresh) string { + if statusRefreshActionable(state, derived, refresh) { + return "Project state needs refresh before trusting the next action: " + refresh.Reason + } if derived.State == "active" { return "The current runtime packet is still open; evidence and next.md decide what the project learns." } @@ -275,6 +325,13 @@ func statusActionReason(state projectState, derived goalState, readiness readine } func statusDoNotDoYet(state projectState, derived goalState, readiness readinessState, growth growthState) string { + return statusDoNotDoYetWithRefresh(state, derived, readiness, growth, statusRefresh{}) +} + +func statusDoNotDoYetWithRefresh(state projectState, derived goalState, readiness readinessState, growth growthState, refresh statusRefresh) string { + if statusRefreshActionable(state, derived, refresh) { + return "Do not advance or start another packet until `hyper migrate` refreshes growth and readiness state." + } if derived.State == "active" { return "Do not start another `hyper run` until this packet is completed or blocked." } @@ -484,7 +541,10 @@ func referenceBenchmarkDashboardStatus(readiness readinessState) string { return "Reference benchmark: " + dim.Status + " - " + compactText(firstNonBlank(dim.Evidence, dim.Gap), 140) } -func statusNextCommand(state projectState, derived goalState, readiness readinessState) string { +func statusNextCommandWithRefresh(state projectState, derived goalState, readiness readinessState, refresh statusRefresh) string { + if statusRefreshActionable(state, derived, refresh) { + return "hyper migrate" + } if strings.TrimSpace(state.Status) != "" && strings.TrimSpace(derived.State) != "" && strings.TrimSpace(state.Status) != strings.TrimSpace(derived.State) { return "hyper repair" } @@ -511,3 +571,16 @@ func statusNextCommand(state projectState, derived goalState, readiness readines } return "hyper run [next focus]" } + +func statusRefreshActionable(state projectState, derived goalState, refresh statusRefresh) bool { + if !refresh.Needed { + return false + } + if derived.State == "active" { + return false + } + if strings.TrimSpace(state.Status) != "" && strings.TrimSpace(derived.State) != "" && strings.TrimSpace(state.Status) != strings.TrimSpace(derived.State) { + return false + } + return true +} From 5e4f64d7425728c1fba64c0203dea6106fa85b23 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 19:51:15 +0900 Subject: [PATCH 15/52] Tighten readiness evidence matching --- internal/app/main_test.go | 55 ++++++++++++++++++++++++++ internal/app/plan.go | 4 ++ internal/app/readiness.go | 83 ++++++++++++++++++++++++++++++++++----- 3 files changed, 133 insertions(+), 9 deletions(-) diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 647c53a..03f6cd1 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -1558,6 +1558,20 @@ func TestReadinessEvidenceQualityRules(t *testing.T) { if strongUX.Status != "covered" { t.Fatalf("expected strong UX evidence to be covered, got %+v", strongUX) } + apiUX, ok := parseReadinessEvidenceLine("GOAL-0001", "Core UX: HTTP API test passed for create and list endpoints.", defs) + if !ok { + t.Fatal("expected API UX evidence to parse") + } + if apiUX.Status != "covered" { + t.Fatalf("expected API UX evidence to be covered, got %+v", apiUX) + } + apiProduct, ok := parseReadinessEvidenceLine("GOAL-0001", "Product completeness: A tiny notes API now has a measurable create-and-list flow: `POST /notes` creates one note and `GET /notes` returns it.", defs) + if !ok { + t.Fatal("expected API product evidence to parse") + } + if apiProduct.Status != "covered" { + t.Fatalf("expected API product evidence to be covered, got %+v", apiProduct) + } inferred := inferReadinessEvidenceFromValidationLine("GOAL-0001", "`npm run check` passed.") if len(inferred) != 1 || inferred[0].Axis != "validation_coverage" || inferred[0].Status != "covered" { t.Fatalf("expected validation command to infer covered validation evidence, got %+v", inferred) @@ -1778,6 +1792,47 @@ func TestReadinessEvidenceDoesNotDowngradeCompletePlan(t *testing.T) { } } +func TestPlanAliasesAcceptBriefAndSuccessSignals(t *testing.T) { + plan := parsePlan("# Service Probe\n\n## Product Brief\n\nA tiny notes API.\n\n## Success Signals\n\nCreate and list one note.\n") + if got := plan["Product"]; got != "A tiny notes API." { + t.Fatalf("Product alias = %q", got) + } + if got := plan["Success Criteria"]; got != "Create and list one note." { + t.Fatalf("Success Criteria alias = %q", got) + } +} + +func TestReadinessIgnoresDeferredStructureSignals(t *testing.T) { + plan := map[string]string{"Current Stage": "Tiny MVP"} + growth := growthState{Pressures: []growthPressure{ + { + PressureType: "repeated_validation", + Signal: "For tiny API MVPs, prove the primary flow with `httptest` before adding persistence or UI.", + Effect: "validation", + State: "observed", + GoalCount: 1, + }, + { + PressureType: "stable_decision", + Signal: "Keep the Tiny MVP local and in-memory.", + Effect: "work_boundary", + State: "observed", + GoalCount: 1, + }, + }} + state := deriveReadinessState(plan, growth, nil) + dims := readinessDimensionMap(state.Dimensions) + if got := dims["persistence"].Status; got != "missing" { + t.Fatalf("deferred persistence should stay missing, got %+v", dims["persistence"]) + } + if got := dims["core_ux"].Status; got != "missing" { + t.Fatalf("deferred UI should not create Core UX pressure, got %+v", dims["core_ux"]) + } + if got := dims["deployment_readiness"].Status; got != "missing" { + t.Fatalf("local in-memory decision should not create deployment pressure, got %+v", dims["deployment_readiness"]) + } +} + func TestBroadFocusIsRewrittenThroughReadinessPressure(t *testing.T) { root := t.TempDir() mustInitWithPlan(t, root, "Tiny tasks", "Build a tiny task list MVP") diff --git a/internal/app/plan.go b/internal/app/plan.go index c85f79c..89ec518 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -99,6 +99,8 @@ func canonicalPlanKey(heading string) string { normalized := compactPlanHeading(heading) aliases := map[string]string{ "product": "Product", + "productbrief": "Product", + "brief": "Product", "productdefinition": "Product", "service": "Product", "servicedefinition": "Product", @@ -150,6 +152,8 @@ func canonicalPlanKey(heading string) string { "법적운영리스크": "Constraints", "successcriteria": "Success Criteria", "successmetrics": "Success Criteria", + "successsignals": "Success Criteria", + "successsignal": "Success Criteria", "성공지표": "Success Criteria", "성공기준": "Success Criteria", "완료기준": "Success Criteria", diff --git a/internal/app/readiness.go b/internal/app/readiness.go index 625917d..d779382 100644 --- a/internal/app/readiness.go +++ b/internal/app/readiness.go @@ -115,7 +115,7 @@ func readinessDimensionDefs() []readinessDimensionDef { {ID: "error_handling", Name: "Error handling", Keywords: []string{"error", "empty", "loading", "failure", "fallback", "blocked", "edge case"}, Gap: "Failure, empty, or edge states are not yet handled."}, {ID: "validation_coverage", Name: "Validation coverage", Keywords: []string{"test", "smoke", "validation", "validate", "playwright", "go test", "npm run", "pytest"}, Gap: "The primary behavior does not have repeatable validation evidence."}, {ID: "security_baseline", Name: "Security baseline", Keywords: []string{"security", "privacy", "permission", "rate limit", "secret", "session", "token", "telemetry", "misuse", "data handling"}, Gap: "Basic security, privacy, and misuse boundaries are not yet explicit."}, - {ID: "deployment_readiness", Name: "Deployment readiness", Keywords: []string{"deploy", "release", "production", "server", "docker", "ci", "hosted"}, Gap: "The project is not yet proven runnable outside the local development path."}, + {ID: "deployment_readiness", Name: "Deployment readiness", Keywords: []string{"deploy", "release", "production", "server", "docker", "github actions", "continuous integration", "hosted"}, Gap: "The project is not yet proven runnable outside the local development path."}, {ID: "operations_docs", Name: "Operations and docs", Keywords: []string{"readme", "docs", "runbook", "rollback", "logs", "monitor", "environment"}, Gap: "Operational notes, setup, rollback, or handoff docs are not sufficient."}, {ID: "maintainability", Name: "Maintainability", Keywords: []string{"refactor", "cleanup", "component", "module", "architecture", "helper", "table-driven"}, Gap: "The codebase has not accumulated enough maintainability evidence."}, {ID: "reference_benchmark", Name: "Reference benchmark", Keywords: []string{"reference", "benchmark", "baseline", "category", "comparison", "comparable", "above baseline", "below baseline"}, Gap: "Reference comparison has not proven category baseline and differentiating strength."}, @@ -136,7 +136,7 @@ func readinessDimensionStatus(def readinessDimensionDef, plan map[string]string, if emerging { return "emerging", 1, evidence } - if hasAny(corpus, def.Keywords...) { + if corpusMentionsReadinessAxis(corpus, def) { return "emerging", 1, "plan.md or learned context mentions this readiness axis." } return "missing", 0, def.Gap @@ -170,7 +170,7 @@ func readinessDimensionStatus(def readinessDimensionDef, plan map[string]string, if emerging { return "emerging", 1, evidence } - if hasAny(corpus, def.Keywords...) { + if corpusMentionsReadinessAxis(corpus, def) { return "emerging", 1, "plan.md or learned context mentions this readiness axis." } return "missing", 0, def.Gap @@ -314,6 +314,31 @@ func readinessEvidenceRecordForAxis(goalID, axis, text string) readinessEvidence return readinessEvidenceRecord{Axis: axis, GoalID: goalID, Text: text, Status: status, Quality: quality} } +func productCompletenessEvidenceCovered(normalized string) bool { + productSurface := hasAny(normalized, + "product", "mvp", "slice", "flow", "api", "endpoint", "command", "feature", "behavior", "primary", "user can", + ) + measurableProof := hasAny(normalized, + "success", "criteria", "target", "measurable", "defined", "proved", "proven", "verified", "works", "creates", "returns", "lists", + ) + concreteBehavior := hasAny(normalized, + "create", "list", "send", "open", "read", "write", "complete", "delete", "login", "sign up", "note", "chat", "pin", "task", + ) + return productSurface && measurableProof && concreteBehavior +} + +func coreUXEvidenceCovered(normalized string) bool { + screenProof := hasAny(normalized, "smoke", "screenshot", "browser", "verified", "passed") && + hasAny(normalized, "flow", "click", "create", "add", "edit", "complete", "delete", "send", "navigate", "reload", "primary action", "surface", "screen", "route", "state") + if screenProof { + return true + } + apiOrCLIProof := hasAny(normalized, "api", "endpoint", "cli", "command", "http", "route") && + hasAny(normalized, "create", "list", "send", "complete", "read", "write", "post", "get", "primary flow") && + hasAny(normalized, "verified", "passed", "proved", "proven", "works", "test", "httptest", "smoke") + return apiOrCLIProof +} + func usefulReadinessEvidence(text string) bool { normalized := strings.ToLower(strings.TrimSpace(text)) if normalized == "" || isPlaceholder(normalized) { @@ -441,12 +466,10 @@ func readinessEvidenceQuality(axis, text string) (bool, string) { normalized := strings.ToLower(text) switch axis { case "product_completeness": - return hasAny(normalized, "product", "mvp", "slice") && - hasAny(normalized, "success", "criteria", "target", "measurable", "defined"), + return productCompletenessEvidenceCovered(normalized), "measurable product, MVP, target, or success criteria" case "core_ux": - return hasAny(normalized, "smoke", "screenshot", "browser", "verified", "passed") && - hasAny(normalized, "flow", "click", "create", "add", "edit", "complete", "delete", "send", "navigate", "reload", "primary action", "surface", "screen", "route", "state"), + return coreUXEvidenceCovered(normalized), "browser, screenshot, smoke, or verified primary-flow evidence" case "persistence": return hasAny(normalized, "persist", "reload", "restart", "saved", "survive", "stored", "created", "re-read", "reread", "confirmed", "row") && @@ -747,7 +770,8 @@ func sustainedQualityGrowthEvidence(growth growthState) (bool, bool, string) { func deploymentEvidenceCovered(normalized string) bool { deploymentTarget := hasAny(normalized, - "deploy", "deployed", "deployment", "url", "https://", "http://", "build", "release", "hosted", "docker", "ci", + "deploy", "deployed", "deployment", "url", "https://", "http://", "build", "release", "hosted", "docker", + "github actions", "continuous integration", "ci pipeline", "ci check", "ci passed", "artifact", "zip", "dist/", "file://", "static server", "server check", "packaged", "package", "parity", "binary", "executable", "outside the development", "outside development", "smoke command", ) @@ -784,7 +808,8 @@ func growthEvidenceForDimension(growth growthState, def readinessDimensionDef) ( return false, true, evidence } for _, signal := range growth.RuntimeBehavior.ValidationSignals { - if hasAny(strings.ToLower(signal), def.Keywords...) { + normalized := strings.ToLower(signal) + if !readinessSignalDefersAxis(normalized, def.ID) && hasAny(normalized, def.Keywords...) { return true, true, "Active runtime behavior references this readiness axis." } } @@ -793,6 +818,9 @@ func growthEvidenceForDimension(growth growthState, def readinessDimensionDef) ( func pressureMatchesReadiness(pressure growthPressure, def readinessDimensionDef) bool { signal := strings.ToLower(pressure.Signal + " " + pressure.PressureType + " " + pressure.Effect) + if readinessSignalDefersAxis(signal, def.ID) { + return false + } switch def.ID { case "validation_coverage": return pressure.Effect == "validation" || hasAny(signal, def.Keywords...) @@ -807,6 +835,43 @@ func pressureMatchesReadiness(pressure growthPressure, def readinessDimensionDef } } +func corpusMentionsReadinessAxis(corpus string, def readinessDimensionDef) bool { + if readinessSignalDefersAxis(corpus, def.ID) { + return false + } + return hasAny(corpus, def.Keywords...) +} + +func readinessSignalDefersAxis(normalized, axis string) bool { + switch axis { + case "core_ux": + return hasAny(normalized, "before adding ui", "before adding persistence or ui", "without ui", "no ui", "not adding ui") + case "persistence": + return hasAny(normalized, + "before adding persistence", + "without persistence", + "no persistence", + "not persisted", + "not persistent", + "in-memory", + "in memory", + ) + case "deployment_readiness": + return hasAny(normalized, + "local only", + "local-only", + "local and in-memory", + "local and in memory", + "outside deployment scope", + "not deployed", + "no deployment", + "without deployment", + ) + default: + return false + } +} + func readinessCorpus(plan map[string]string, growth growthState) string { parts := []string{} for _, key := range []string{"Product", "Target Users", "MVP", "Build Style", "Non-goals", "Constraints", "Success Criteria", "Current Focus"} { From 9063caafbd897539a8b54e96a3c433c9abb8b201 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 19:56:28 +0900 Subject: [PATCH 16/52] Refresh next packet during migration --- internal/app/main_test.go | 21 +++++++++++++++++++++ internal/app/migrate.go | 13 +++++++++++++ 2 files changed, 34 insertions(+) diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 03f6cd1..ba1e656 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -1108,6 +1108,27 @@ func TestMigrateRetiresLegacyNoIssueGrowthCandidates(t *testing.T) { } } +func TestMigrateRefreshesNextPacketPlan(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "plan.md"), "# Service Probe\n\n## Product Brief\n\nA tiny notes API.\n\n## Current Stage\n\nTiny MVP\n\n## Success Signals\n\nCreate and list one note.\n") + mustRun(t, root, "init") + mustRun(t, root, "run") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\n`go test ./...` passed.\n\n## Readiness Evidence\n\nProduct completeness: A tiny notes API now has a measurable create-and-list flow: `POST /notes` creates one note and `GET /notes` returns it.\nValidation coverage: `go test ./...` passed and the primary HTTP API flow is repeatable.\n\n## Blocker\n\nNone blocking.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nDocument the API command surface.\n\n## Learn Notes\n\n- pattern: API MVPs should prove create/list with HTTP tests.\n") + mustRun(t, root, "complete") + writeFile(t, filepath.Join(root, ".hyper", "next-packet.md"), "# Next Packet Plan\n\nAction: advance\nCommand: hyper advance\n") + + out, err := runCLI(args("migrate"), testRoot(root), fakeUpdater{}) + if err != nil { + t.Fatalf("migrate failed: %v", err) + } + assertContains(t, out.Stdout, "Next packet plan: .hyper/next-packet.md (run)") + nextPacket := readFile(t, filepath.Join(root, ".hyper", "next-packet.md")) + assertContains(t, nextPacket, "Action: run") + assertContains(t, nextPacket, "Command: hyper run \"Implement the smallest usable A tiny notes API core flow: the primary user flow\"") + assertNotContains(t, nextPacket, "Command: hyper advance") +} + func TestMigrateRefreshesLegacyMemoryQualityFixture(t *testing.T) { root := t.TempDir() mustInitWithPlan(t, root, "Tiny CLI", "Build a tiny CLI MVP") diff --git a/internal/app/migrate.go b/internal/app/migrate.go index 8a613cf..3add70b 100644 --- a/internal/app/migrate.go +++ b/internal/app/migrate.go @@ -37,14 +37,26 @@ func migrateHyper(fsys fsRoot) (commandOutput, *hyperError) { } } stateMessage := "not checked" + nextPacketMessage := "not updated; no completed runtime packet state found" if state, stateErr := readState(filepath.Join(root, hyperDir, "state.json")); stateErr == nil { consistency := currentStateConsistency(root, state) if consistency.Consistent { stateMessage = "state.json is consistent" + if consistency.Derived.State == "active" { + nextPacketMessage = "unchanged while the current runtime packet is active" + } else { + nextPlan, nextErr := writeNextPacketPlan(root, state, consistency.Derived, readiness, growth) + if nextErr != nil { + return commandOutput{}, nextErr + } + nextPacketMessage = filepath.Join(hyperDir, "next-packet.md") + " (" + nextPlan.Action + ")" + } } else if consistency.Repairable { stateMessage = "state.json needs repair; run `hyper repair`" + nextPacketMessage = "not updated; run `hyper repair` first" } else { stateMessage = "state.json mismatch is not repairable while packet is active" + nextPacketMessage = "not updated while packet state is inconsistent" } } return stdout(strings.Join([]string{ @@ -56,6 +68,7 @@ func migrateHyper(fsys fsRoot) (commandOutput, *hyperError) { fmt.Sprintf("Visible candidates: %d -> %d", visibleGrowthCandidateCount(before.Candidates), visibleGrowthCandidateCount(growth.Candidates)), "Readiness gate: " + readinessGateSummary(readiness), "State consistency: " + stateMessage, + "Next packet plan: " + nextPacketMessage, "", "Next:", " hyper doctor", From 00ba4fe8798ec896702b98e295c7ade94bf553d8 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 20:02:35 +0900 Subject: [PATCH 17/52] Check next packet consistency in doctor --- internal/app/doctor.go | 43 +++++++++++++++++++++++++++++++++++++++ internal/app/main_test.go | 17 ++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/internal/app/doctor.go b/internal/app/doctor.go index a87c380..a9dc50b 100644 --- a/internal/app/doctor.go +++ b/internal/app/doctor.go @@ -65,6 +65,7 @@ func doctorHyper(fsys fsRoot) (commandOutput, *hyperError) { checks = append(checks, doctorStateChecks(root)...) checks = append(checks, doctorGrowthMigrationCheck(root)) checks = append(checks, doctorReadinessStateCheck(root)) + checks = append(checks, doctorNextPacketPlanCheck(root)) checks = append(checks, doctorSignatureCheck()) checks = append(checks, doctorDBCheck(root)) checks = append(checks, doctorCodexChecks(root)...) @@ -167,6 +168,48 @@ func doctorReadinessStateCheck(root string) doctorCheck { return doctorCheck{"Readiness state", "OK", "readiness state is current"} } +func doctorNextPacketPlanCheck(root string) doctorCheck { + path := filepath.Join(root, hyperDir, "next-packet.md") + statePath := filepath.Join(root, hyperDir, "state.json") + if !exists(statePath) { + return doctorCheck{"Next packet plan", "OK", "no runtime state yet"} + } + state, err := readState(statePath) + if err != nil { + return doctorCheck{"Next packet plan", "WARN", "cannot inspect state.json: " + err.Message} + } + consistency := currentStateConsistency(root, state) + if !consistency.Consistent { + return doctorCheck{"Next packet plan", "WARN", "cannot verify until state.json is repaired"} + } + if consistency.Derived.State == "active" { + return doctorCheck{"Next packet plan", "OK", "not required while the current runtime packet is active"} + } + if !exists(path) { + return doctorCheck{"Next packet plan", "WARN", "missing; run `hyper migrate` or complete the current packet again"} + } + growth := growthStateForStatus(root) + readiness := readinessStateForStatus(root, growth) + expected := buildNextPacketPlan(state, consistency.Derived, readiness, growth) + actual := nextPacketPlanCommand(readIfExists(path)) + if actual == "" { + return doctorCheck{"Next packet plan", "WARN", "missing Command; run `hyper migrate`"} + } + if actual != expected.Command { + return doctorCheck{"Next packet plan", "WARN", "expected `" + expected.Command + "`, found `" + actual + "`; run `hyper migrate`"} + } + return doctorCheck{"Next packet plan", "OK", filepath.Join(hyperDir, "next-packet.md") + " matches current state"} +} + +func nextPacketPlanCommand(body string) string { + for _, line := range strings.Split(body, "\n") { + if _, value, ok := strings.Cut(strings.TrimSpace(line), "Command:"); ok { + return strings.TrimSpace(value) + } + } + return "" +} + func sameReadinessForDoctor(a, b readinessState) bool { if a.Stage != b.Stage || a.StageGate.Status != b.StageGate.Status || a.NextPressure.Axis != b.NextPressure.Axis { return false diff --git a/internal/app/main_test.go b/internal/app/main_test.go index ba1e656..991df8a 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -263,6 +263,23 @@ func TestDoctorWarnsWhenStoredReadinessIsStale(t *testing.T) { assertContains(t, out.Stdout, "Run `hyper migrate`") } +func TestDoctorWarnsWhenNextPacketPlanIsStale(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "plan.md"), "# Service Probe\n\n## Product Brief\n\nA tiny notes API.\n\n## Current Stage\n\nTiny MVP\n\n## Success Signals\n\nCreate and list one note.\n") + mustRun(t, root, "init") + mustRun(t, root, "run") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\n`go test ./...` passed.\n\n## Readiness Evidence\n\nProduct completeness: A tiny notes API now has a measurable create-and-list flow: `POST /notes` creates one note and `GET /notes` returns it.\nValidation coverage: `go test ./...` passed and the primary HTTP API flow is repeatable.\n\n## Blocker\n\nNone blocking.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nDocument the API command surface.\n\n## Learn Notes\n\n- pattern: API MVPs should prove create/list with HTTP tests.\n") + mustRun(t, root, "complete") + writeFile(t, filepath.Join(root, ".hyper", "next-packet.md"), "# Next Packet Plan\n\nAction: advance\nCommand: hyper advance\n") + + out, err := runCLI(args("doctor"), testRoot(root), fakeUpdater{}) + if err != nil { + t.Fatalf("doctor failed: %v", err) + } + assertContains(t, out.Stdout, "[WARN] Next packet plan: expected `hyper run \"Implement the smallest usable A tiny notes API core flow: the primary user flow\"`, found `hyper advance`; run `hyper migrate`") +} + func TestDoctorReadinessComparisonIgnoresIrrelevantFutureAxes(t *testing.T) { stored := readinessState{ Stage: "Tiny MVP", From 9c3409306c6ccd8411510fce601f5f4399084011 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 20:09:31 +0900 Subject: [PATCH 18/52] Stop auto run when target is reached --- internal/app/commands.go | 50 +++++++++++++++++++++++++++++++++++ internal/app/main_test.go | 55 +++++++++++++++++++++++++++++++++++++++ internal/app/status.go | 3 +++ 3 files changed, 108 insertions(+) diff --git a/internal/app/commands.go b/internal/app/commands.go index 0c0a8e2..7c42dd5 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -167,6 +167,35 @@ func runHyper(fsys fsRoot, opts runOptions) (commandOutput, *hyperError) { if err != nil { return commandOutput{}, err } + if opts.AutoContinue && strings.TrimSpace(opts.RunUntil) != "" { + stopState := runUntilStopState(previous, opts, planResult.Body, readiness) + if runUntilReached(stopState, readiness) { + if err := writeJSON(filepath.Join(root, hyperDir, "state.json"), stopState); err != nil { + return commandOutput{}, err + } + nextPlan, err := writeNextPacketPlan(root, stopState, runUntilStopDerived(stopState), readiness, growth) + if err != nil { + return commandOutput{}, err + } + return stdout(strings.Join([]string{ + "Run-until target already reached: " + opts.RunUntil, + "Stage: " + normalizeRuntimeStage(firstNonBlank(readiness.Stage, stopState.Stage)), + "Run mode: " + formatRunMode(opts), + "Auto learn: " + formatAutoLearn(autoLearn), + "Readiness gate: " + readinessGateSummary(readiness), + "Readiness pressure: " + readinessPressureSummary(readiness), + "Next action: " + nextPlan.Command, + "Why: " + nextPlan.Reason, + "Next packet plan: " + filepath.Join(hyperDir, "next-packet.md"), + "", + "No runtime packet created.", + "", + "Next:", + " " + nextPlan.Command, + "", + }, "\n")), nil + } + } runID, err := nextID(db, "runs", "RUN") if err != nil { @@ -502,6 +531,27 @@ func blockingActiveGoal(root string, state projectState) string { }, "\n") } +func runUntilStopState(previous projectState, opts runOptions, planBody string, readiness readinessState) projectState { + plan := parsePlan(planBody) + state := previous + state.Project = firstNonBlank(state.Project, readinessProductName(plan), "Unknown project") + state.Stage = normalizeRuntimeStage(firstNonBlank(readiness.Stage, state.Stage)) + state.Status = firstNonBlank(state.Status, "completed") + state.PlanPath = planFile + state.PlanHash = hashText(planBody) + state.AutoContinue = true + state.RunUntil = opts.RunUntil + state.UpdatedAt = nowISO() + return state +} + +func runUntilStopDerived(state projectState) goalState { + return goalState{ + State: firstNonBlank(state.Status, "completed"), + Reason: "Run-until target is already reached.", + } +} + func learnCurrentGoal(fsys fsRoot) (commandOutput, *hyperError) { root := fsys.root() statePath := filepath.Join(root, hyperDir, "state.json") diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 991df8a..212edae 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -721,6 +721,61 @@ func TestStatusAutoTargetReachedExplainsPause(t *testing.T) { assertNotContains(t, short, "Why: Maintainability is emerging") } +func TestRunAutoUntilDoesNotCreatePacketAfterTargetReached(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "plan.md"), "# Product Plan\n\n## Product\n\nTiny Bookmark CLI\n\n## Target Users\n\nDevelopers\n\n## MVP\n\nAdd and list one bookmark.\n\n## Current Stage\n\nTiny MVP\n\n## Build Style\n\nGo CLI\n\n## Success Criteria\n\nCommand-surface add/list flow is repeatable.\n") + mustRun(t, root, "init") + if _, err := runCLI(args("run", "--auto", "--until", "usable-mvp", "Build the bookmark CLI"), testRoot(root), fakeUpdater{}); err != nil { + t.Fatalf("auto run failed: %v", err) + } + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\n`go test ./...` passed.\n\n## Readiness Evidence\n\nProduct completeness: Tiny Bookmark CLI has a measurable add/list command flow.\nCore UX: CLI command test passed for add and list behavior, proving the primary bookmark flow works from the command surface.\nValidation coverage: `go test ./...` passed and the primary CLI add/list flow is repeatable.\n\n## Blocker\n\nNone blocking.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nReview Tiny MVP evidence.\n\n## Learn Notes\n\n- pattern: CLI MVPs can use command-surface proof for Core UX.\n") + if _, err := runCLI(args("complete"), testRoot(root), fakeUpdater{}); err != nil { + t.Fatalf("complete failed: %v", err) + } + if _, err := runCLI(args("advance"), testRoot(root), fakeUpdater{}); err != nil { + t.Fatalf("advance failed: %v", err) + } + + out, err := runCLI(args("run", "--auto", "--until", "usable-mvp", "Should not continue"), testRoot(root), fakeUpdater{}) + if err != nil { + t.Fatalf("run at reached target should stop cleanly: %v", err) + } + assertContains(t, out.Stdout, "Run-until target already reached: Usable MVP") + assertContains(t, out.Stdout, "No runtime packet created.") + assertContains(t, out.Stdout, "Next action: hyper status --short") + if exists(filepath.Join(root, ".hyper", "goals", "GOAL-0002")) { + t.Fatal("auto run should not create another packet after the target stage is reached") + } + assertContains(t, readFile(t, filepath.Join(root, ".hyper", "next-packet.md")), "Action: stop") +} + +func TestRunAutoUntilReachedBeforeFirstPacketKeepsHandoffConsistent(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "plan.md"), "# Product Plan\n\n## Product\n\nAuto Target Guard\n\n## Target Users\n\nDevelopers\n\n## MVP\n\nOne command flow is already usable.\n\n## Current Stage\n\nUsable MVP\n\n## Build Style\n\nGo CLI\n\n## Success Criteria\n\nAuto run-until does not create work after the target stage is reached.\n") + mustRun(t, root, "init") + + out, err := runCLI(args("run", "--auto", "--until", "usable-mvp", "Should not start"), testRoot(root), fakeUpdater{}) + if err != nil { + t.Fatalf("run at reached target should stop cleanly: %v", err) + } + assertContains(t, out.Stdout, "No runtime packet created.") + if exists(filepath.Join(root, ".hyper", "goals", "GOAL-0001")) { + t.Fatal("auto run should not create a first packet when the target stage is already reached") + } + status, err := runCLI(args("status", "--short"), testRoot(root), fakeUpdater{}) + if err != nil { + t.Fatalf("status failed: %v", err) + } + assertContains(t, status.Stdout, "Mode: auto until Usable MVP") + assertContains(t, status.Stdout, "Next: hyper status --short") + doctor, err := runCLI(args("doctor"), testRoot(root), fakeUpdater{}) + if err != nil { + t.Fatalf("doctor failed: %v", err) + } + assertContains(t, doctor.Stdout, "[OK] Next packet plan: .hyper/next-packet.md matches current state") +} + func TestStatusAutoTargetReachedDoesNotHideActivePacket(t *testing.T) { state := projectState{ Project: "Local Clip Shelf", diff --git a/internal/app/status.go b/internal/app/status.go index b058384..ed2095d 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -549,6 +549,9 @@ func statusNextCommandWithRefresh(state projectState, derived goalState, readine return "hyper repair" } if strings.TrimSpace(state.CurrentGoalID) == "" { + if state.AutoContinue && runUntilReached(state, readiness) { + return "hyper status --short" + } return "hyper run [focus]" } if derived.State == "active" { From 4c4550fbd56f192e0c58566e6a8e58c310612813 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 20:13:50 +0900 Subject: [PATCH 19/52] Warn on next packet during refresh --- internal/app/doctor.go | 3 +++ internal/app/main_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/internal/app/doctor.go b/internal/app/doctor.go index a9dc50b..f5f23fa 100644 --- a/internal/app/doctor.go +++ b/internal/app/doctor.go @@ -185,6 +185,9 @@ func doctorNextPacketPlanCheck(root string) doctorCheck { if consistency.Derived.State == "active" { return doctorCheck{"Next packet plan", "OK", "not required while the current runtime packet is active"} } + if refresh := statusRefreshFor(root); statusRefreshActionable(state, consistency.Derived, refresh) { + return doctorCheck{"Next packet plan", "WARN", "cannot trust next-packet until refresh completes: " + refresh.Reason} + } if !exists(path) { return doctorCheck{"Next packet plan", "WARN", "missing; run `hyper migrate` or complete the current packet again"} } diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 212edae..d9bfd5e 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -280,6 +280,31 @@ func TestDoctorWarnsWhenNextPacketPlanIsStale(t *testing.T) { assertContains(t, out.Stdout, "[WARN] Next packet plan: expected `hyper run \"Implement the smallest usable A tiny notes API core flow: the primary user flow\"`, found `hyper advance`; run `hyper migrate`") } +func TestDoctorDoesNotTrustNextPacketWhenRefreshIsNeeded(t *testing.T) { + root := t.TempDir() + mustInitWithPlan(t, root, "Tiny tasks", "Build a tiny task list MVP") + mustRun(t, root, "run") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\n`npm run build` passed and browser smoke passed.\n\n## Readiness Evidence\n\nCore UX: Browser smoke passed for create and complete flow.\nValidation coverage: `npm run build` passed and primary browser smoke is repeatable.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nReview stage advancement.\n") + mustRun(t, root, "complete") + stale := growthState{ + Version: 1, + Pressures: []growthPressure{ + {State: "repeated", PressureType: "recurring_failure", Effect: "stop_condition", Signal: "None in this run.", GoalCount: 2}, + }, + } + if err := writeJSON(filepath.Join(root, ".hyper", "growth", "state.json"), stale); err != nil { + t.Fatalf("write stale growth failed: %v", err) + } + + out, err := runCLI(args("doctor"), testRoot(root), fakeUpdater{}) + if err != nil { + t.Fatalf("doctor failed: %v", err) + } + assertContains(t, out.Stdout, "[WARN] Growth migration: legacy or noisy growth entries found; run `hyper migrate`") + assertContains(t, out.Stdout, "[WARN] Next packet plan: cannot trust next-packet until refresh completes: legacy or noisy growth entries found; run `hyper migrate`") +} + func TestDoctorReadinessComparisonIgnoresIrrelevantFutureAxes(t *testing.T) { stored := readinessState{ Stage: "Tiny MVP", From 9e1c5cb9a00b66b59ea10b07065313846b8ee5a2 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 20:24:05 +0900 Subject: [PATCH 20/52] Cover sustained auto quality chain --- internal/app/main_test.go | 67 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/internal/app/main_test.go b/internal/app/main_test.go index d9bfd5e..0da41fb 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -801,6 +801,73 @@ func TestRunAutoUntilReachedBeforeFirstPacketKeepsHandoffConsistent(t *testing.T assertContains(t, doctor.Stdout, "[OK] Next packet plan: .hyper/next-packet.md matches current state") } +func TestRunAutoUntilSustainedQualityPromotesActiveValidator(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "plan.md"), "# Product Plan\n\n## Product\n\nService Quality Chain\n\n## Target Users\n\nDevelopers\n\n## MVP\n\nAdd one release note and list it back.\n\n## Current Stage\n\nTiny MVP\n\n## Build Style\n\nLocal CLI\n\n## Success Criteria\n\nReach sustained quality only after repeated validation becomes active required behavior.\n") + mustRun(t, root, "init") + if _, err := runCLI(args("run", "--auto", "--until", "sustained-service-quality", "Drive to sustained quality"), testRoot(root), fakeUpdater{}); err != nil { + t.Fatalf("initial auto run failed: %v", err) + } + validation := "`./check.sh` passed with output: `release-note add/list/error smoke passed`." + writeEvidence := func(goalID, readiness string) { + writeFile(t, filepath.Join(root, ".hyper", "goals", goalID, "evidence.md"), "# "+goalID+" Evidence\n\n## Validation\n\n"+validation+"\n\n## Readiness Evidence\n\n"+readiness+"\n\n## Active Capability Evidence\n\nNo active project capability required yet.\n\n## Changed Files\n\nfixture\n\n## Decisions\n\nKeep the local CLI boundary.\n\n## Reusable Patterns\n\nUse `./check.sh` as the repeated validation path.\n\n## Blockers\n\nNone blocking.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", goalID, "next.md"), "# "+goalID+" Next\n\n## Recommended Next Goal\n\nContinue toward sustained quality.\n\n## Learn Notes\n\n- pattern: Use `./check.sh` as the repeated validation path.\n") + } + complete := func(goalID string) string { + out, err := runCLI(args("complete"), testRoot(root), fakeUpdater{}) + if err != nil { + t.Fatalf("complete %s failed: %v", goalID, err) + } + assertContains(t, out.Stdout, "Finish gate: passed") + return out.Stdout + } + advance := func() { + if _, err := runCLI(args("advance"), testRoot(root), fakeUpdater{}); err != nil { + t.Fatalf("advance failed: %v", err) + } + } + nextRun := func(focus string) { + if _, err := runCLI(args("run", "--auto", "--until", "sustained-service-quality", focus), testRoot(root), fakeUpdater{}); err != nil { + t.Fatalf("auto run failed: %v", err) + } + } + + writeEvidence("GOAL-0001", "Product completeness: Service Quality Chain has a measurable add/list CLI slice.\nCore UX: CLI smoke passed for add and list behavior from the command surface.\nValidation coverage: "+validation) + complete("GOAL-0001") + advance() + nextRun("Prove persistence and error handling") + + writeEvidence("GOAL-0002", "Data persistence: Text file storage saved a release note and a separate list command re-read it from disk.\nError handling: Missing argument and unknown command states are handled and verified.\nValidation coverage: "+validation) + complete("GOAL-0002") + advance() + nextRun("Prove beta service quality axes") + + writeEvidence("GOAL-0003", strings.Join([]string{ + "Validation coverage: " + validation, + "Security baseline: Local-only security and privacy boundary is documented and verified: no cloud sync, no telemetry, no secrets, no tokens, and no sessions.", + "Deployment readiness: Release artifacts are created in `dist/` and the packaged smoke command passed outside the source command path.", + "Operations and docs: README documents setup, run command, smoke command, rollback, recovery, and stop condition.", + "Reference benchmark: Category: Local CLI release-note tracker; References: git-chglog, standard-version, release-it; Baseline expectations: A useful local release-note CLI should add entries, list entries, keep data local, expose a repeatable smoke command, and document rollback or recovery; Current comparison: Service Quality Chain meets baseline for local add/list, file-backed persistence, repeatable smoke validation, local-only security boundary, and rollback docs; Below baseline gaps: none critical for the local-only CLI category; Above baseline strength: Hyper Run evidence ties validation, security, release artifact, docs, and benchmark proof to stage advancement; Decision: Service Quality advancement is acceptable because no core category-baseline gap remains.", + }, "\n")) + complete("GOAL-0003") + advance() + nextRun("Promote repeated validation to active required behavior") + + writeEvidence("GOAL-0004", "Maintainability: Documented validation helper coverage in `DEVELOPMENT.md`; the maintained `./check.sh` helper keeps command validation repeatable without hidden local context and reduces future operator handoff friction.\nValidation coverage: "+validation+"\nOperations and docs: DEVELOPMENT and README documents setup, validation, rollback, recovery, and handoff constraints for the next operator.") + out := complete("GOAL-0004") + assertContains(t, out, "1 active structure") + assertContains(t, readFile(t, filepath.Join(root, ".hyper", "capabilities", "active", "validator", "validator-check-sh.md")), "Status: active") + advance() + + status, err := runCLI(args("status", "--short"), testRoot(root), fakeUpdater{}) + if err != nil { + t.Fatalf("status failed: %v", err) + } + assertContains(t, status.Stdout, "Stage: Sustained Service Quality") + assertContains(t, status.Stdout, "Next: hyper status --short") + assertContains(t, readFile(t, filepath.Join(root, ".hyper", "next-packet.md")), "Action: stop") +} + func TestStatusAutoTargetReachedDoesNotHideActivePacket(t *testing.T) { state := projectState{ Project: "Local Clip Shelf", From 6d480da8fa7e611fb2e71c32d971e87c38a61a2e Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 20:31:28 +0900 Subject: [PATCH 21/52] Name active capabilities in evidence template --- internal/app/main_test.go | 25 +++++++++++++++++++++---- internal/app/plan.go | 26 +++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 0da41fb..d802a29 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -2697,16 +2697,16 @@ func TestServiceQualityPressureWalksRequiredAxesInOrder(t *testing.T) { } func TestReferenceBenchmarkEvidenceTemplateForBetaAndServiceQuality(t *testing.T) { - betaEvidence := buildEvidenceDoc("GOAL-0001", "Beta", readinessState{}) + betaEvidence := buildEvidenceDoc("GOAL-0001", "Beta", readinessState{}, growthState{}) assertContains(t, betaEvidence, "## Reference Benchmark Evidence") assertContains(t, betaEvidence, "References: Pending") assertContains(t, betaEvidence, "Below-baseline gaps") assertContains(t, betaEvidence, "Above-baseline strength") - serviceEvidence := buildEvidenceDoc("GOAL-0001", "Service Quality", readinessState{}) + serviceEvidence := buildEvidenceDoc("GOAL-0001", "Service Quality", readinessState{}, growthState{}) assertContains(t, serviceEvidence, "## Reference Benchmark Evidence") - tinyEvidence := buildEvidenceDoc("GOAL-0001", "Tiny MVP", readinessState{}) + tinyEvidence := buildEvidenceDoc("GOAL-0001", "Tiny MVP", readinessState{}, growthState{}) assertNotContains(t, tinyEvidence, "## Reference Benchmark Evidence") tasks := buildTasksDoc("GOAL-0001", "Web app", "Service Quality", readinessState{}, growthState{}) @@ -2730,7 +2730,7 @@ func TestReferenceBenchmarkEvidenceNotRepeatedAfterCovered(t *testing.T) { NextPressure: readinessPressure{Axis: "sustained_quality", AxisName: "Sustained quality", Status: "ongoing"}, } - evidence := buildEvidenceDoc("GOAL-0009", "Sustained Service Quality", readiness) + evidence := buildEvidenceDoc("GOAL-0009", "Sustained Service Quality", readiness, growthState{}) assertNotContains(t, evidence, "## Reference Benchmark Evidence") tasks := buildTasksDoc("GOAL-0009", "Go CLI", "Sustained Service Quality", readiness, growthState{}) assertNotContains(t, tasks, "Fill Reference Benchmark Evidence") @@ -2738,6 +2738,23 @@ func TestReferenceBenchmarkEvidenceNotRepeatedAfterCovered(t *testing.T) { assertNotContains(t, checklist, "Reference Benchmark Evidence lists") } +func TestEvidenceTemplateNamesActiveCapabilities(t *testing.T) { + growth := growthState{ + Candidates: []growthCandidate{ + { + Kind: "validator", + Name: "validator-check-sh", + Status: "active", + Signal: "validation pattern: `./check.sh` passed with output: `release-note add/list/error smoke passed`.", + }, + }, + } + evidence := buildEvidenceDoc("GOAL-0010", "Sustained Service Quality", readinessState{}, growth) + assertContains(t, evidence, "## Active Capability Evidence") + assertContains(t, evidence, "- validator-check-sh: Pending. Required behavior: validation pattern: `./check.sh` passed") + assertNotContains(t, evidence, "## Active Capability Evidence\n\nPending.") +} + func TestReferenceBenchmarkEvidenceSectionFeedsReadiness(t *testing.T) { root := t.TempDir() goalDir := filepath.Join(root, ".hyper", "goals", "GOAL-0001") diff --git a/internal/app/plan.go b/internal/app/plan.go index 89ec518..3f42b66 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -301,7 +301,7 @@ func compileGoalEpisode(goalID, focus, planBody string, similar []similarContext docs := episodeDocs{ Goal: buildGoalDoc(goalID, objective, focus, plan, stage, buildStyle, scope, validation, stopCondition, similar, growth, readiness), Tasks: buildTasksDoc(goalID, buildStyle, stage, readiness, growth), - Evidence: buildEvidenceDoc(goalID, stage, readiness), + Evidence: buildEvidenceDoc(goalID, stage, readiness, growth), Review: fmt.Sprintf("# %s Review\n\n## Result\n\nPending.\n\n## Issues\n\nPending.\n", goalID), Next: buildNextDoc(goalID, readiness), } @@ -935,8 +935,28 @@ func buildTasksDoc(goalID, buildStyle, stage string, readiness readinessState, g return fmt.Sprintf("# %s Tasks\n\n- [ ] Read plan.md and this runtime packet\n- [ ] Inspect current project structure and recent Hyper evidence\n- [ ] Confirm the stage behavior for `%s`\n- [ ] Implement the smallest coherent step toward the current episode\n- [ ] Run validation or record why validation is blocked\n%s%s%s%s- [ ] Update evidence.md with validation, readiness evidence, active capability evidence, pressure signals, changed files, decisions, reusable patterns, and blockers\n- [ ] Write next.md with exactly one recommended next runtime episode and durable Learn Notes only\n- [ ] Run `hyper complete`; if the finish gate fails, fix this same packet using review.md\n", goalID, stage, browserTask, referenceTask, readinessTask, activeTask) } -func buildEvidenceDoc(goalID, stage string, readiness readinessState) string { - return fmt.Sprintf("# %s Evidence\n\n## Validation\n\nPending.\n\n## Readiness Evidence\n\n%s\n\n## Surface Proof Evidence\n\n- Target surface: Pending.\n- Primary user action: Pending.\n- States checked: Pending.\n- Viewports: Pending.\n- Evidence: Pending.\n- Surface risks or gaps: Pending.\n\n%s## Active Capability Evidence\n\nPending.\n\n## Pressure Signals\n\nPending.\n\n## Changed Files\n\nPending.\n\n## Decisions\n\nPending.\n\n## Reusable Patterns\n\nPending.\n\n## Learn Quality Gate\n\n- Keep as memory only if it should change future work boundary, validation, stop conditions, readiness, or capability candidates.\n- Do not record one-off progress, file lists, generic summaries, or \"none\" statements as Learn signals.\n\n## Blocker\n\nPending.\n\n## Notes\n\nPending.\n", goalID, readinessEvidenceTemplate(readiness), referenceBenchmarkEvidenceTemplate(stage, readiness)) +func buildEvidenceDoc(goalID, stage string, readiness readinessState, growth growthState) string { + return fmt.Sprintf("# %s Evidence\n\n## Validation\n\nPending.\n\n## Readiness Evidence\n\n%s\n\n## Surface Proof Evidence\n\n- Target surface: Pending.\n- Primary user action: Pending.\n- States checked: Pending.\n- Viewports: Pending.\n- Evidence: Pending.\n- Surface risks or gaps: Pending.\n\n%s## Active Capability Evidence\n\n%s\n\n## Pressure Signals\n\nPending.\n\n## Changed Files\n\nPending.\n\n## Decisions\n\nPending.\n\n## Reusable Patterns\n\nPending.\n\n## Learn Quality Gate\n\n- Keep as memory only if it should change future work boundary, validation, stop conditions, readiness, or capability candidates.\n- Do not record one-off progress, file lists, generic summaries, or \"none\" statements as Learn signals.\n\n## Blocker\n\nPending.\n\n## Notes\n\nPending.\n", goalID, readinessEvidenceTemplate(readiness), referenceBenchmarkEvidenceTemplate(stage, readiness), activeCapabilityEvidenceTemplate(growth)) +} + +func activeCapabilityEvidenceTemplate(growth growthState) string { + lines := []string{} + for _, candidate := range visibleGrowthCandidates(growth.Candidates) { + if candidate.Status != "active" { + continue + } + name := displayGrowthCandidateName(candidate) + signal := compactText(firstNonBlank(candidate.Signal, candidate.Reason), 160) + if signal == "" { + lines = append(lines, "- "+name+": Pending. Run or explicitly block this active "+candidate.Kind+".") + continue + } + lines = append(lines, "- "+name+": Pending. Required behavior: "+signal) + } + if len(lines) == 0 { + return "Pending." + } + return strings.Join(lines, "\n") } func referenceBenchmarkEvidenceTemplate(stage string, readiness readinessState) string { From dd5cf3e113d03a638c22bcba61110b73cf309605 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 20:36:05 +0900 Subject: [PATCH 22/52] Reject pending active capability evidence --- internal/app/finish_gate.go | 24 ++++++++++++++++++++++++ internal/app/main_test.go | 15 +++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/internal/app/finish_gate.go b/internal/app/finish_gate.go index 38911ca..d751795 100644 --- a/internal/app/finish_gate.go +++ b/internal/app/finish_gate.go @@ -111,6 +111,9 @@ func activeCapabilityEvidenceCovers(capability activeCapability, lines []string) command := normalizeSentence(inferredCommandForSignal(capability.Signal)) for _, line := range lines { normalized := normalizeSentence(line) + if !credibleActiveCapabilityEvidence(normalized) { + continue + } if name != "" && strings.Contains(normalized, name) { return true } @@ -121,6 +124,27 @@ func activeCapabilityEvidenceCovers(capability activeCapability, lines []string) return false } +func credibleActiveCapabilityEvidence(normalized string) bool { + if normalized == "" || isPlaceholder(normalized) { + return false + } + if hasAny(normalized, + "pending", + "todo", + "tbd", + "not run", + "not executed", + "not checked", + "not verified", + "not validated", + "not yet", + "missing", + ) { + return false + } + return true +} + func readinessEvidenceRecordsFromGoalText(goalID, evidenceText string) []readinessEvidenceRecord { defs := readinessDimensionDefs() records := []readinessEvidenceRecord{} diff --git a/internal/app/main_test.go b/internal/app/main_test.go index d802a29..84ee20f 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -673,6 +673,21 @@ func TestCompleteRequiresSpecificActiveCapabilityEvidence(t *testing.T) { } } +func TestCompleteRejectsPendingActiveCapabilityTemplate(t *testing.T) { + root := t.TempDir() + mustInitWithPlan(t, root, "Tiny CLI", "Build a tiny CLI MVP") + mustRun(t, root, "run") + writeFile(t, filepath.Join(root, ".hyper", "capabilities", "active", "validator", "validator-go-test.md"), "# validator-go-test\n\nStatus: active\nKind: validator\nSignal: Run go test ./... before completing packets.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\n`go test ./...` passed.\n\n## Readiness Evidence\n\nCore UX: CLI smoke verified create and complete flow.\nValidation coverage: `go test ./...` passed and primary CLI smoke is repeatable.\n\n## Active Capability Evidence\n\nvalidator-go-test: Pending. Required behavior: Run go test ./... before completing packets.\n\n## Blocker\n\nNone blocking.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nReview stage advancement.\n") + + _, err := runCLI(args("complete"), testRoot(root), fakeUpdater{}) + if err == nil { + t.Fatal("expected pending active capability template to fail finish gate") + } + assertContains(t, err.Message, "Record active capability evidence for: validator-go-test") +} + func TestCompleteAllowsEmergingSustainedQualityEvidence(t *testing.T) { root := t.TempDir() writeFile(t, filepath.Join(root, "plan.md"), "# Product Plan\n\n## Product\n\nLocal Build Relay\n\n## Target Users\n\nDevelopers\n\n## MVP\n\nRun one handoff command.\n\n## Current Stage\n\nService Quality\n\n## Build Style\n\nGo CLI\n\n## Success Criteria\n\nEvery packet proves the handoff command.\n") From be68ef6b726d5faf4fd29c58d3e18480f9030a41 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 20:43:16 +0900 Subject: [PATCH 23/52] Allow explicit active capability blockers --- internal/app/finish_gate.go | 20 ++++++++++++++++++++ internal/app/main_test.go | 15 +++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/internal/app/finish_gate.go b/internal/app/finish_gate.go index d751795..b91b857 100644 --- a/internal/app/finish_gate.go +++ b/internal/app/finish_gate.go @@ -128,6 +128,9 @@ func credibleActiveCapabilityEvidence(normalized string) bool { if normalized == "" || isPlaceholder(normalized) { return false } + if explicitActiveCapabilityBlocker(normalized) { + return true + } if hasAny(normalized, "pending", "todo", @@ -145,6 +148,23 @@ func credibleActiveCapabilityEvidence(normalized string) bool { return true } +func explicitActiveCapabilityBlocker(normalized string) bool { + return hasAny(normalized, + "blocked because", + "blocked by", + "cannot run because", + "could not run because", + "unable to run because", + "missing credential", + "missing credentials", + "missing token", + "missing secret", + "permission denied", + "network unavailable", + "command unavailable", + ) +} + func readinessEvidenceRecordsFromGoalText(goalID, evidenceText string) []readinessEvidenceRecord { defs := readinessDimensionDefs() records := []readinessEvidenceRecord{} diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 84ee20f..768095b 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -688,6 +688,21 @@ func TestCompleteRejectsPendingActiveCapabilityTemplate(t *testing.T) { assertContains(t, err.Message, "Record active capability evidence for: validator-go-test") } +func TestCompleteAcceptsExplicitActiveCapabilityBlocker(t *testing.T) { + root := t.TempDir() + mustInitWithPlan(t, root, "Tiny CLI", "Build a tiny CLI MVP") + mustRun(t, root, "run") + writeFile(t, filepath.Join(root, ".hyper", "capabilities", "active", "validator", "validator-go-test.md"), "# validator-go-test\n\nStatus: active\nKind: validator\nSignal: Run go test ./... before completing packets.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\n`go test ./...` passed.\n\n## Readiness Evidence\n\nCore UX: CLI smoke verified create and complete flow.\nValidation coverage: `go test ./...` passed and primary CLI smoke is repeatable.\n\n## Active Capability Evidence\n\nvalidator-go-test: blocked because missing credentials for the private module registry.\n\n## Blocker\n\nNone blocking.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nReview stage advancement.\n") + + out, err := runCLI(args("complete"), testRoot(root), fakeUpdater{}) + if err != nil { + t.Fatalf("explicit active capability blocker should satisfy finish gate: %v", err) + } + assertContains(t, out.Stdout, "Finish gate: passed") +} + func TestCompleteAllowsEmergingSustainedQualityEvidence(t *testing.T) { root := t.TempDir() writeFile(t, filepath.Join(root, "plan.md"), "# Product Plan\n\n## Product\n\nLocal Build Relay\n\n## Target Users\n\nDevelopers\n\n## MVP\n\nRun one handoff command.\n\n## Current Stage\n\nService Quality\n\n## Build Style\n\nGo CLI\n\n## Success Criteria\n\nEvery packet proves the handoff command.\n") From 144e5d5ccad762ed42b4c8750c0eecabca1b0b66 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 20:56:44 +0900 Subject: [PATCH 24/52] Improve first-run plan parsing --- README.md | 16 +++++ README_ko.md | 16 +++++ internal/app/app.go | 127 ++++++++++++++++++++++++++++++++++++++ internal/app/main_test.go | 52 ++++++++++++++++ internal/app/plan.go | 83 +++++++++++++++++++++++++ internal/app/readiness.go | 2 +- 6 files changed, 295 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b3afd06..007c05a 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,22 @@ How do we know this stage is done? What should the next run improve? ``` +Short form also works: + +```markdown +# Plan + +Project: Service Desk Lite +Current Stage: Tiny MVP +Build Style: Thin vertical slice first. + +Product brief: +A teammate can create one support request, see it in a list, and mark it handled. + +Validation: +One smoke command proves the create/list/handle flow. +``` + If `plan.md` is sparse, Hyper Run may create `.hyper/plan-candidates.md` from README or docs so you can copy useful product context into `plan.md`. ## What `hyper run` Does diff --git a/README_ko.md b/README_ko.md index ae88e68..9069ed0 100644 --- a/README_ko.md +++ b/README_ko.md @@ -311,6 +311,22 @@ Web app 다음 run에서 무엇을 개선해야 하나요? ``` +짧게 써도 됩니다. + +```markdown +# Plan + +Project: Service Desk Lite +Current Stage: Tiny MVP +Build Style: Thin vertical slice first. + +Product brief: +팀원이 지원 요청 하나를 만들고, 목록에서 보고, 처리 완료로 바꿀 수 있습니다. + +Validation: +하나의 smoke command로 create/list/handle flow를 증명합니다. +``` + `plan.md`가 너무 비어 있으면 Hyper Run이 README나 docs를 읽고 `.hyper/plan-candidates.md`를 만들 수 있습니다. 거기서 쓸 만한 제품 문맥을 `plan.md`로 옮기면 됩니다. ## `hyper run`이 하는 일 diff --git a/internal/app/app.go b/internal/app/app.go index 96553d8..b982be7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -42,33 +42,66 @@ func runCLI(args []string, fsys fsRoot, updater updater) (commandOutput, *hyperE case "", "help", "--help", "-h": return stdout(usage()), nil case "init": + if helpRequested(rest) { + return stdout(commandUsage("init")), nil + } if len(rest) > 0 { return commandOutput{}, newError("hyper init does not take an objective.\n\nRun `hyper init`, fill in plan.md, then use `hyper run [focus]`.", 2) } return initHyper(fsys) case "run": + if helpRequested(rest) { + return stdout(commandUsage("run")), nil + } opts, err := parseRunOptions(rest) if err != nil { return commandOutput{}, err } return runHyper(fsys, opts) case "status": + if helpRequested(rest) { + return stdout(commandUsage("status")), nil + } return statusHyper(fsys, rest) case "doctor": + if helpRequested(rest) { + return stdout(commandUsage("doctor")), nil + } return doctorHyper(fsys) case "repair": + if helpRequested(rest) { + return stdout(commandUsage("repair")), nil + } return repairHyper(fsys) case "migrate": + if helpRequested(rest) { + return stdout(commandUsage("migrate")), nil + } return migrateHyper(fsys) case "resume": + if helpRequested(rest) { + return stdout(commandUsage("resume")), nil + } return resumeHyper(fsys) case "complete": + if helpRequested(rest) { + return stdout(commandUsage("complete")), nil + } return completeHyper(fsys) case "advance": + if helpRequested(rest) { + return stdout(commandUsage("advance")), nil + } return advanceHyper(fsys) case "version": + if helpRequested(rest) { + return stdout(commandUsage("version")), nil + } return versionHyper() case "update": + if helpRequested(rest) { + return stdout(commandUsage("update")), nil + } source := "" if len(rest) > 0 { source = rest[0] @@ -81,6 +114,18 @@ func runCLI(args []string, fsys fsRoot, updater updater) (commandOutput, *hyperE } } +func helpRequested(args []string) bool { + for i, arg := range args { + switch strings.TrimSpace(arg) { + case "--help", "-h": + return true + case "help": + return i == 0 && len(args) == 1 + } + } + return false +} + func runInternal(args []string, fsys fsRoot) (commandOutput, *hyperError) { if len(args) > 0 && args[0] == "learn" { return learnCurrentGoal(fsys) @@ -132,6 +177,88 @@ func usage() string { }, "\n") } +func commandUsage(command string) string { + lines := map[string][]string{ + "init": { + "Usage:", + " hyper init", + "", + "Creates `plan.md`, `.hyper/`, and Codex Desktop routing files in the current project.", + "Use `hyper run [focus]` for the current work objective; do not pass the objective to `hyper init`.", + }, + "run": { + "Usage:", + " hyper run [--auto] [--until stage] [focus]", + "", + "Creates the next runtime packet from `plan.md`, prior evidence, pressure, and readiness.", + "Options:", + " --auto Continue packet-by-packet through the generated next-packet plan.", + " --until Plan auto continuation toward tiny-mvp, usable-mvp, beta, service-quality, or sustained-service-quality.", + }, + "status": { + "Usage:", + " hyper status", + " hyper status --short", + "", + "Shows current stage, gate, proof, pressure, next action, and blocking gaps.", + }, + "doctor": { + "Usage:", + " hyper doctor", + "", + "Checks install path, version, project state, SQLite, migration freshness, and Codex Desktop routing.", + }, + "repair": { + "Usage:", + " hyper repair", + "", + "Refreshes generated project state when files are missing or stale.", + }, + "migrate": { + "Usage:", + " hyper migrate", + "", + "Refreshes project state, growth rules, readiness, and next-packet planning after a CLI update.", + }, + "resume": { + "Usage:", + " hyper resume", + "", + "Prints the current runtime packet handoff if an active packet exists.", + }, + "complete": { + "Usage:", + " hyper complete", + "", + "Runs the finish gate, learns from `evidence.md` and `next.md`, then refreshes growth and readiness.", + }, + "advance": { + "Usage:", + " hyper advance", + "", + "Updates `plan.md` to the next stage only when the readiness gate is ready and the user accepts the change.", + }, + "version": { + "Usage:", + " hyper version", + "", + "Shows build version, commit, build date, platform, executable path, and update source.", + }, + "update": { + "Usage:", + " hyper update [source]", + "", + "Installs the latest Hyper Run binary from the configured GitHub release or provided source.", + }, + } + body := lines[command] + if len(body) == 0 { + return usage() + } + out := append([]string{"Hyper Run " + command, ""}, body...) + return strings.Join(out, "\n") +} + type fsRoot interface { root() string } diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 768095b..563f69c 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -78,6 +78,23 @@ func TestVersionShowsBuildAndExecutable(t *testing.T) { assertContains(t, out.Stdout, "Update source: github:KoreanCode/orange-hyper-run") } +func TestSubcommandHelpDoesNotError(t *testing.T) { + for _, tc := range []struct { + args []string + want string + }{ + {args("run", "--help"), "Usage:\n hyper run [--auto] [--until stage] [focus]"}, + {args("status", "--help"), "Usage:\n hyper status\n hyper status --short"}, + {args("update", "--help"), "Usage:\n hyper update [source]"}, + } { + out, err := runCLI(tc.args, testRoot(t.TempDir()), fakeUpdater{}) + if err != nil { + t.Fatalf("%v failed: %v", tc.args, err) + } + assertContains(t, out.Stdout, tc.want) + } +} + func TestInitRejectsObjectiveArgument(t *testing.T) { root := t.TempDir() _, err := runCLI(args("init", "Build a tiny CRM MVP"), testRoot(root), fakeUpdater{}) @@ -1759,6 +1776,13 @@ func TestReadinessEvidenceQualityRules(t *testing.T) { if strong.Status != "covered" { t.Fatalf("expected strong validation evidence to be covered, got %+v", strong) } + shellSmoke, ok := parseReadinessEvidenceLine("GOAL-0001", "Validation coverage: The shell smoke command proved the add/list/handle flow end to end with real command output.", defs) + if !ok { + t.Fatal("expected shell smoke validation evidence to parse") + } + if shellSmoke.Status != "covered" { + t.Fatalf("expected shell smoke validation evidence to be covered, got %+v", shellSmoke) + } weakUX, ok := parseReadinessEvidenceLine("GOAL-0001", "Core UX: flow exists.", defs) if !ok { t.Fatal("expected weak UX evidence to parse") @@ -2017,6 +2041,34 @@ func TestPlanAliasesAcceptBriefAndSuccessSignals(t *testing.T) { } } +func TestPlanAliasesAcceptInlineFields(t *testing.T) { + plan := parsePlan(`# Plan + +Project: Service Desk Lite +Current Stage: Tiny MVP + +Product brief: +A tiny internal support queue where a teammate can create one request, see it in a list, and mark it handled. + +Build Style: Thin vertical slice first. + +Validation: +Use the smallest command or smoke check that proves the useful flow still works. +`) + if got := plan["Product"]; got != "Service Desk Lite" { + t.Fatalf("Product inline field = %q", got) + } + if got := plan["Current Stage"]; got != "Tiny MVP" { + t.Fatalf("Current Stage inline field = %q", got) + } + if got := plan["Build Style"]; got != "Thin vertical slice first." { + t.Fatalf("Build Style inline field = %q", got) + } + if got := plan["Success Criteria"]; got != "Use the smallest command or smoke check that proves the useful flow still works." { + t.Fatalf("Validation inline field = %q", got) + } +} + func TestReadinessIgnoresDeferredStructureSignals(t *testing.T) { plan := map[string]string{"Current Stage": "Tiny MVP"} growth := growthState{Pressures: []growthPressure{ diff --git a/internal/app/plan.go b/internal/app/plan.go index 3f42b66..0ab96ef 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -83,6 +83,7 @@ func parsePlan(body string) map[string]string { } func augmentPlanAliases(plan map[string]string, body string) { + augmentInlinePlanAliases(plan, body) for heading, value := range plan { canonical := canonicalPlanKey(heading) if canonical == "" { @@ -95,6 +96,82 @@ func augmentPlanAliases(plan map[string]string, body string) { } } +func augmentInlinePlanAliases(plan map[string]string, body string) { + lines := strings.Split(body, "\n") + inFence := false + for i := 0; i < len(lines); i++ { + line := strings.TrimSpace(lines[i]) + if strings.HasPrefix(line, "```") || strings.HasPrefix(line, "~~~") { + inFence = !inFence + continue + } + if inFence || line == "" { + continue + } + label, value, ok := splitInlinePlanField(line) + if !ok { + continue + } + canonical := canonicalPlanKey(label) + if canonical == "" { + continue + } + if strings.TrimSpace(value) == "" { + value = followingInlinePlanValue(lines, i+1) + } + setPlanAliasIfMissing(plan, canonical, value) + } +} + +func splitInlinePlanField(line string) (string, string, bool) { + line = strings.TrimSpace(line) + line = strings.TrimLeft(line, "#") + line = strings.TrimSpace(line) + line = strings.TrimLeft(line, "-*") + line = strings.TrimSpace(line) + index := strings.Index(line, ":") + if index <= 0 { + return "", "", false + } + label := strings.TrimSpace(line[:index]) + if label == "" || len([]rune(label)) > 48 { + return "", "", false + } + return label, strings.TrimSpace(line[index+1:]), true +} + +func followingInlinePlanValue(lines []string, start int) string { + values := []string{} + inFence := false + for i := start; i < len(lines); i++ { + line := strings.TrimSpace(lines[i]) + if strings.HasPrefix(line, "```") || strings.HasPrefix(line, "~~~") { + inFence = !inFence + if len(values) == 0 { + continue + } + } + if inFence { + values = append(values, line) + continue + } + if line == "" { + if len(values) == 0 { + continue + } + break + } + if strings.HasPrefix(line, "#") { + break + } + if label, _, ok := splitInlinePlanField(line); ok && canonicalPlanKey(label) != "" { + break + } + values = append(values, line) + } + return strings.Join(values, "\n") +} + func canonicalPlanKey(heading string) string { normalized := compactPlanHeading(heading) aliases := map[string]string{ @@ -104,7 +181,9 @@ func canonicalPlanKey(heading string) string { "productdefinition": "Product", "service": "Product", "servicedefinition": "Product", + "project": "Product", "projectname": "Product", + "name": "Product", "oneliner": "Product", "제품": "Product", "제품정의": "Product", @@ -154,9 +233,13 @@ func canonicalPlanKey(heading string) string { "successmetrics": "Success Criteria", "successsignals": "Success Criteria", "successsignal": "Success Criteria", + "validation": "Success Criteria", + "validationplan": "Success Criteria", "성공지표": "Success Criteria", "성공기준": "Success Criteria", "완료기준": "Success Criteria", + "검증": "Success Criteria", + "검증방법": "Success Criteria", "currentfocus": "Current Focus", "priority": "Current Focus", "priorities": "Current Focus", diff --git a/internal/app/readiness.go b/internal/app/readiness.go index d779382..93d5cdc 100644 --- a/internal/app/readiness.go +++ b/internal/app/readiness.go @@ -481,7 +481,7 @@ func readinessEvidenceQuality(axis, text string) (bool, string) { "empty, loading, error, failure, fallback, or edge-state evidence" case "validation_coverage": return hasAny(normalized, "smoke", "playwright", "go test", "npm run", "pytest", "build", "command", "validation", "browser", "screenshot", "`") && - hasAny(normalized, "passed", "repeatable", "covered", "verified"), + hasAny(normalized, "passed", "repeatable", "covered", "verified", "proved", "proven", "works"), "repeatable command, build, test, smoke, or coverage evidence" case "security_baseline": return securityBaselineEvidenceCovered(normalized), From 84309d6eb1fffc71602c59a41e4b465293f9aace Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 21:03:25 +0900 Subject: [PATCH 25/52] Keep inline plan stages consistent --- internal/app/main_test.go | 28 ++++++++++++++++++++++ internal/app/plan.go | 50 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 563f69c..185ca6d 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -2058,6 +2058,9 @@ Use the smallest command or smoke check that proves the useful flow still works. if got := plan["Product"]; got != "Service Desk Lite" { t.Fatalf("Product inline field = %q", got) } + if got := plan["MVP"]; got != "A tiny internal support queue where a teammate can create one request, see it in a list, and mark it handled." { + t.Fatalf("Product brief inline field should fill MVP boundary, got %q", got) + } if got := plan["Current Stage"]; got != "Tiny MVP" { t.Fatalf("Current Stage inline field = %q", got) } @@ -2069,6 +2072,31 @@ Use the smallest command or smoke check that proves the useful flow still works. } } +func TestUpdatePlanCurrentStageUpdatesInlineField(t *testing.T) { + body := strings.Join([]string{ + "# Plan", + "", + "Project: Inline Stage Probe", + "Current Stage: Tiny MVP", + "Build Style: Local CLI", + "", + "Product brief:", + "A developer can add one item and list it back locally.", + "", + }, "\n") + updated, changed := updatePlanCurrentStage(body, "Usable MVP") + if !changed { + t.Fatal("expected inline Current Stage to change") + } + assertContains(t, updated, "Current Stage: Usable MVP") + assertNotContains(t, updated, "Current Stage: Tiny MVP") + assertNotContains(t, updated, "## Current Stage") + plan := parsePlan(updated) + if got := plan["Current Stage"]; got != "Usable MVP" { + t.Fatalf("expected updated inline stage to parse, got %q", got) + } +} + func TestReadinessIgnoresDeferredStructureSignals(t *testing.T) { plan := map[string]string{"Current Stage": "Tiny MVP"} growth := growthState{Pressures: []growthPressure{ diff --git a/internal/app/plan.go b/internal/app/plan.go index 0ab96ef..008c90c 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -119,10 +119,23 @@ func augmentInlinePlanAliases(plan map[string]string, body string) { if strings.TrimSpace(value) == "" { value = followingInlinePlanValue(lines, i+1) } + if inlineProductBriefCanFillMVP(label, plan) { + setPlanAliasIfMissing(plan, "MVP", value) + continue + } setPlanAliasIfMissing(plan, canonical, value) } } +func inlineProductBriefCanFillMVP(label string, plan map[string]string) bool { + switch compactPlanHeading(label) { + case "productbrief", "brief", "productdefinition", "servicedefinition": + return firstRuntimeValue(plan["Product"]) != "" && firstRuntimeValue(plan["MVP"]) == "" + default: + return false + } +} + func splitInlinePlanField(line string) (string, string, bool) { line = strings.TrimSpace(line) line = strings.TrimLeft(line, "#") @@ -318,6 +331,9 @@ func updatePlanCurrentStage(body, nextStage string) (string, bool) { } } if headingIndex == -1 { + if updated, changed, found := updateInlinePlanCurrentStage(body, nextStage); found { + return updated, changed + } trimmed := strings.TrimRight(body, "\n") if trimmed != "" { trimmed += "\n\n" @@ -348,6 +364,40 @@ func updatePlanCurrentStage(body, nextStage string) (string, bool) { return out, true } +func updateInlinePlanCurrentStage(body, nextStage string) (string, bool, bool) { + lines := strings.Split(body, "\n") + inFence := false + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") { + inFence = !inFence + continue + } + if inFence { + continue + } + label, value, ok := splitInlinePlanField(line) + if !ok || canonicalPlanKey(label) != "Current Stage" { + continue + } + current := strings.TrimSpace(value) + if strings.EqualFold(current, nextStage) || normalizeRuntimeStage(current) == nextStage { + return body, false, true + } + index := strings.Index(line, ":") + if index < 0 { + return body, false, true + } + lines[i] = strings.TrimRight(line[:index+1], " ") + " " + nextStage + out := strings.Join(lines, "\n") + if !strings.HasSuffix(out, "\n") { + out += "\n" + } + return out, true, true + } + return body, false, false +} + func firstMarkdownHeading(body, prefix string) string { for _, line := range strings.Split(body, "\n") { trimmed := strings.TrimSpace(line) From 89b097111ef6645d3455e81fee727f0f0c3d1b08 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 21:08:14 +0900 Subject: [PATCH 26/52] Preserve stage stop conditions --- internal/app/main_test.go | 29 +++++++++++++++++++++++++++++ internal/app/plan.go | 16 +++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 185ca6d..24dd3d1 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -2097,6 +2097,35 @@ func TestUpdatePlanCurrentStageUpdatesInlineField(t *testing.T) { } } +func TestRuntimePacketCombinesPlanAndStageStopConditions(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "plan.md"), strings.Join([]string{ + "# Plan", + "", + "Project: Inline Stage Probe", + "Current Stage: Usable MVP", + "Build Style: Local CLI", + "", + "Product brief:", + "A developer can add one item and list it back locally.", + "", + "Validation:", + "A smoke command proves add/list works.", + "", + }, "\n")) + if _, err := runCLI(args("init"), testRoot(root), fakeUpdater{}); err != nil { + t.Fatalf("init failed: %v", err) + } + if _, err := runCLI(args("run", "Make the flow persistent"), testRoot(root), fakeUpdater{}); err != nil { + t.Fatalf("run failed: %v", err) + } + goal := readFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "goal.md")) + assertContains(t, goal, "## Stop When") + assertContains(t, goal, "- Plan success criteria: A smoke command proves add/list works.") + assertContains(t, goal, "- Core flow is usable without manual data edits.") + assertContains(t, goal, "- Empty, loading, and error states are handled for the primary path.") +} + func TestReadinessIgnoresDeferredStructureSignals(t *testing.T) { plan := map[string]string{"Current Stage": "Tiny MVP"} growth := growthState{Pressures: []growthPressure{ diff --git a/internal/app/plan.go b/internal/app/plan.go index 008c90c..8970970 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -428,7 +428,7 @@ func compileGoalEpisode(goalID, focus, planBody string, similar []similarContext product := readinessProductName(plan) objective := runtimeObjective(focus, plan, stage, product, readiness) validation := applyReadinessValidation(applyGrowthValidation(applyStageValidation(validationForBuildStyle(buildStyle), stage), growth), readiness) - stopCondition := applyReadinessStopConditions(applyGrowthStopConditions(firstRuntimeValue(plan["Success Criteria"], stageDoneCondition(stage)), growth), readiness) + stopCondition := runtimeStopCondition(plan, stage, growth, readiness) scope := runtimeWorkBoundary(objective, stage, plan, growth, readiness) nonGoals := firstRuntimeValue(plan["Non-goals"], "No explicit non-goals recorded in plan.md.") docs := episodeDocs{ @@ -712,6 +712,20 @@ func applyReadinessStopConditions(base string, readiness readinessState) string return base } +func runtimeStopCondition(plan map[string]string, stage string, growth growthState, readiness readinessState) string { + base := stageDoneCondition(stage) + if criteria := firstRuntimeValue(plan["Success Criteria"]); criteria != "" && !sameStopCondition(criteria, base) { + base = "- Plan success criteria: " + compactText(criteria, 240) + "\n" + base + } + return applyReadinessStopConditions(applyGrowthStopConditions(base, growth), readiness) +} + +func sameStopCondition(criteria, base string) bool { + criteria = normalizeSentence(criteria) + base = normalizeSentence(base) + return criteria == "" || criteria == base || strings.Contains(base, criteria) +} + func stageDoneCondition(stage string) string { normalized := normalizeLabel(stage) if strings.Contains(normalized, "tiny") && strings.Contains(normalized, "mvp") { From 42ab4ea10c0a1c9b0885b4d6a8e0101daebbf393 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 21:13:47 +0900 Subject: [PATCH 27/52] Defer benchmark evidence until pressure --- internal/app/main_test.go | 32 ++++++++++++++++++++++++++++++++ internal/app/plan.go | 9 +-------- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 24dd3d1..42879a8 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -2852,6 +2852,38 @@ func TestReferenceBenchmarkEvidenceTemplateForBetaAndServiceQuality(t *testing.T assertContains(t, tasks, "Fill Reference Benchmark Evidence") } +func TestReferenceBenchmarkTemplateWaitsForPressure(t *testing.T) { + readiness := readinessState{ + Version: 1, + Stage: "Beta", + Dimensions: []readinessDimension{ + {ID: "security_baseline", Name: "Security baseline", Status: "missing"}, + {ID: "reference_benchmark", Name: "Reference benchmark", Status: "missing"}, + }, + StageGate: readinessStageGate{ + Status: "not_ready", + CurrentStage: "Beta", + NextStage: "Service Quality", + RequiredAxes: []string{"validation_coverage", "security_baseline", "deployment_readiness", "operations_docs", "reference_benchmark"}, + }, + NextPressure: readinessPressure{Axis: "security_baseline", AxisName: "Security baseline", Status: "missing"}, + } + + evidence := buildEvidenceDoc("GOAL-0001", "Beta", readiness, growthState{}) + assertContains(t, evidence, "Reference benchmark: Pending.") + assertNotContains(t, evidence, "## Reference Benchmark Evidence") + tasks := buildTasksDoc("GOAL-0001", "Local CLI", "Beta", readiness, growthState{}) + assertNotContains(t, tasks, "Fill Reference Benchmark Evidence") + checklist := doneChecklistDoc("Beta", readiness, growthState{}) + assertNotContains(t, checklist, "Reference Benchmark Evidence lists") + + readiness.NextPressure = readinessPressure{Axis: "reference_benchmark", AxisName: "Reference benchmark", Status: "missing"} + evidence = buildEvidenceDoc("GOAL-0002", "Beta", readiness, growthState{}) + assertContains(t, evidence, "## Reference Benchmark Evidence") + tasks = buildTasksDoc("GOAL-0002", "Local CLI", "Beta", readiness, growthState{}) + assertContains(t, tasks, "Fill Reference Benchmark Evidence") +} + func TestReferenceBenchmarkEvidenceNotRepeatedAfterCovered(t *testing.T) { readiness := readinessState{ Version: 1, diff --git a/internal/app/plan.go b/internal/app/plan.go index 8970970..e880abd 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -1131,14 +1131,7 @@ func serviceQualityStage(stage string) bool { func referenceBenchmarkRequired(stage string, readiness readinessState) bool { if readiness.Version != 0 { - if readiness.NextPressure.Axis == "reference_benchmark" { - return true - } - if readinessAxisRequired(readiness, "reference_benchmark") { - dim := readinessDimensionMap(readiness.Dimensions)["reference_benchmark"] - return dim.Status != "covered" - } - return false + return readiness.NextPressure.Axis == "reference_benchmark" } normalized := normalizeLabel(stage) if strings.Contains(normalized, "beta") || serviceQualityStage(stage) { From ff3e339ff84efbc390d6425d2155050e64639e78 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 21:44:08 +0900 Subject: [PATCH 28/52] Tighten sustained quality promotion --- internal/app/goal_state.go | 25 ++++++++++ internal/app/growth.go | 30 ++++++++++-- internal/app/main_test.go | 99 ++++++++++++++++++++++++++++++++++++++ internal/app/readiness.go | 9 ++-- 4 files changed, 154 insertions(+), 9 deletions(-) diff --git a/internal/app/goal_state.go b/internal/app/goal_state.go index d3b02bd..5e00dd7 100644 --- a/internal/app/goal_state.go +++ b/internal/app/goal_state.go @@ -315,15 +315,40 @@ func memoryQualityIsIgnored(quality string) bool { } func firstUsefulValidationMemory(text string) string { + command := "" for _, line := range strings.Split(text, "\n") { trimmed := strings.TrimSpace(strings.TrimLeft(line, "-*0123456789. ")) + if command == "" { + command = firstBacktickCommand(trimmed) + } if usefulValidationSignal(trimmed) { + if firstBacktickCommand(trimmed) == "" && command != "" { + return commandValidationMemory(command, trimmed) + } return trimmed } } return "" } +func commandValidationMemory(command, outcome string) string { + command = strings.TrimSpace(command) + if command == "" { + return strings.TrimSpace(outcome) + } + normalized := normalizeSentence(outcome) + switch { + case hasAny(normalized, "failed", "failure", "error"): + return "`" + command + "` failed." + case hasAny(normalized, "blocked"): + return "`" + command + "` blocked." + case hasAny(normalized, "warning", "warn"): + return "`" + command + "` completed with warning." + default: + return "`" + command + "` passed." + } +} + func firstNonPendingLine(text string) string { for _, line := range strings.Split(text, "\n") { trimmed := strings.TrimSpace(strings.TrimLeft(line, "-*0123456789. ")) diff --git a/internal/app/growth.go b/internal/app/growth.go index 1c8262b..9ac6544 100644 --- a/internal/app/growth.go +++ b/internal/app/growth.go @@ -252,6 +252,9 @@ func growthRecordAllowed(record memoryRecord) bool { if memoryQualityIsIgnored(quality) { return false } + if !readinessEvidenceContributesToGrowth(record.Text) { + return false + } signal := memorySignal(record.Text) if signal == "" || isNoisyGrowthSignal(signal) { return false @@ -262,6 +265,17 @@ func growthRecordAllowed(record memoryRecord) bool { return true } +func readinessEvidenceContributesToGrowth(text string) bool { + normalized := normalizeSentence(text) + prefix := "readiness evidence:" + index := strings.Index(normalized, prefix) + if index == -1 { + return true + } + rest := strings.TrimSpace(normalized[index+len(prefix):]) + return strings.HasPrefix(rest, "validation coverage:") +} + func findPressureAccumulator(accs []*pressureAccumulator, pressureType, canonical string) *pressureAccumulator { for _, acc := range accs { if acc.pressureType != pressureType { @@ -470,6 +484,12 @@ func isKnownImplementationGap(signal string) bool { func isValidationPattern(signal string) bool { normalized := strings.ToLower(signal) + if strings.Contains(normalized, "readiness evidence:") && !strings.Contains(normalized, "validation coverage:") { + return false + } + if command := firstBacktickCommand(signal); command != "" && hasAny(normalized, "run", "check", "smoke", "validation", "handoff", "before every", "before each", "passed", "repeatable") { + return true + } return hasAny(normalized, "test", "build", "smoke", "validate", "validation", "playwright", "browser", "go test", "npm run", "pytest") } @@ -826,7 +846,7 @@ func cleanCandidateSignal(signal string) string { } func harnessCandidateForPressure(pressure growthPressure) growthCandidate { - status := harnessStatusForPressure(pressure.MemoryCount) + status := harnessStatusForPressure(pressure.GoalCount, pressure.MemoryCount) name := "harness-growth-candidate" return growthCandidate{ Kind: "harness", @@ -874,11 +894,11 @@ func capabilityStatusForEvidence(goalCount int) string { } } -func harnessStatusForPressure(stablePressureCount int) string { +func harnessStatusForPressure(sourceGoalCount, stablePressureCount int) string { switch { - case stablePressureCount >= growthHarnessActiveSignals: + case sourceGoalCount >= growthHarnessActiveSignals && stablePressureCount >= growthHarnessActiveSignals: return "active" - case stablePressureCount >= growthHarnessPromotableSignals: + case sourceGoalCount >= growthHarnessPromotableSignals && stablePressureCount >= growthHarnessPromotableSignals: return "promotable" default: return "repeated" @@ -965,7 +985,7 @@ func aggregateHarnessPressure(pressures []growthPressure) growthPressure { Signal: "Promote repeated decisions, validation patterns, and constraints into a project-specific harness candidate.", CanonicalSignal: "harness emergence", Effect: "harness", - State: harnessStatusForPressure(stablePressureCount), + State: harnessStatusForPressure(len(sources), stablePressureCount), GoalCount: len(sources), MemoryCount: stablePressureCount, Score: growthScore(len(sources), stablePressureCount), diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 42879a8..a68bf67 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -1550,6 +1550,70 @@ func TestHarnessCandidateEvidenceCountUsesStablePressureCount(t *testing.T) { } } +func TestHarnessCandidateRequiresEnoughSourceGoalsForActivation(t *testing.T) { + twoGoalPressure := aggregateHarnessPressure([]growthPressure{ + {Effect: "validation", GoalCount: 2, Sources: []string{"GOAL-0003", "GOAL-0004"}}, + {Effect: "validation", GoalCount: 2, Sources: []string{"GOAL-0003", "GOAL-0004"}}, + {Effect: "validation", GoalCount: 2, Sources: []string{"GOAL-0003", "GOAL-0004"}}, + {Effect: "implementation", GoalCount: 2, Sources: []string{"GOAL-0003", "GOAL-0004"}}, + {Effect: "work_boundary", GoalCount: 2, Sources: []string{"GOAL-0003", "GOAL-0004"}}, + }) + candidate := harnessCandidateForPressure(twoGoalPressure) + if candidate.Status != "repeated" { + t.Fatalf("harness must not become active from many pressures in only two packets, got %+v", candidate) + } + assertContains(t, candidate.LifecyclePath, filepath.Join(".hyper", "capabilities", "candidates", "harness")) + + fiveGoalPressure := aggregateHarnessPressure([]growthPressure{ + {Effect: "validation", GoalCount: 5, Sources: []string{"GOAL-0001", "GOAL-0002", "GOAL-0003", "GOAL-0004", "GOAL-0005"}}, + {Effect: "validation", GoalCount: 5, Sources: []string{"GOAL-0001", "GOAL-0002", "GOAL-0003", "GOAL-0004", "GOAL-0005"}}, + {Effect: "implementation", GoalCount: 5, Sources: []string{"GOAL-0001", "GOAL-0002", "GOAL-0003", "GOAL-0004", "GOAL-0005"}}, + {Effect: "work_boundary", GoalCount: 5, Sources: []string{"GOAL-0001", "GOAL-0002", "GOAL-0003", "GOAL-0004", "GOAL-0005"}}, + {Effect: "work_boundary", GoalCount: 5, Sources: []string{"GOAL-0001", "GOAL-0002", "GOAL-0003", "GOAL-0004", "GOAL-0005"}}, + }) + candidate = harnessCandidateForPressure(fiveGoalPressure) + if candidate.Status != "active" { + t.Fatalf("expected active harness only after enough stable pressures and source goals, got %+v", candidate) + } +} + +func TestReadinessEvidenceDoesNotBecomeValidatorExceptValidationCoverage(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, hyperDir), 0755); err != nil { + t.Fatal(err) + } + db, hyperErr := openDB(root) + if hyperErr != nil { + t.Fatalf("open db failed: %v", hyperErr) + } + defer db.Close() + if hyperErr := ensureSchema(db); hyperErr != nil { + t.Fatalf("schema failed: %v", hyperErr) + } + for _, goal := range []string{"GOAL-0001", "GOAL-0002"} { + insertTestMemory(t, db, "pattern", goal+" readiness evidence: Security baseline: Local-only file storage is explicit, no network or telemetry exists, and sensitive words are rejected by the CLI smoke command.") + insertTestMemory(t, db, "pattern", goal+" readiness evidence: Reference benchmark: Category: Local file-backed utility CLI; References: Git, SQLite CLI, Taskfile, Make; Baseline expectations: local commands are documented and repeatable command output exists.") + insertTestMemory(t, db, "pattern", goal+" readiness evidence: Validation coverage: `./check.sh` passed and is repeatable.") + } + state, hyperErr := updateGrowthState(root, db) + if hyperErr != nil { + t.Fatalf("growth failed: %v", hyperErr) + } + for _, candidate := range state.Candidates { + if strings.Contains(candidate.Name, "security-baseline") || strings.Contains(candidate.Name, "reference-benchmark") { + t.Fatalf("readiness evidence for %s should not become a validator candidate: %+v", candidate.Name, state.Candidates) + } + } + assertContains(t, readFile(t, filepath.Join(root, ".hyper", "capabilities", "candidates", "validator", "validator-check-sh.md")), "Status: repeated") +} + +func TestCommandHandoffPatternClassifiesAsValidation(t *testing.T) { + pressureType, effect := growthClassification("pattern", "Pattern: Run `./check.sh` before every service-quality handoff.") + if pressureType != "repeated_validation" || effect != "validation" { + t.Fatalf("expected command handoff pattern to be validation pressure, got %s/%s", pressureType, effect) + } +} + func TestActiveValidatorBecomesRequiredValidationSignal(t *testing.T) { root := t.TempDir() mustInitWithPlan(t, root, "Tiny CLI", "Build a tiny CLI MVP") @@ -1844,6 +1908,13 @@ func TestReadinessEvidenceQualityRules(t *testing.T) { if cliDeploy.Status != "covered" { t.Fatalf("expected CLI deployment evidence to be covered, got %+v", cliDeploy) } + exportDeploy, ok := parseReadinessEvidenceLine("GOAL-0001", "Deployment readiness: `./check.sh` verifies export artifact creation outside the normal add/list path.", defs) + if !ok { + t.Fatal("expected export deployment evidence to parse") + } + if exportDeploy.Status != "covered" { + t.Fatalf("expected export deployment evidence to be covered, got %+v", exportDeploy) + } weakReference, ok := parseReadinessEvidenceLine("GOAL-0001", "Reference benchmark: Compared against three comparable project-growth CLIs; category baseline is fine and above-baseline strength exists.", defs) if !ok { t.Fatal("expected weak reference benchmark evidence to parse") @@ -1872,6 +1943,13 @@ func TestReadinessEvidenceQualityRules(t *testing.T) { if opsNotes.Status != "covered" { t.Fatalf("expected operations notes evidence to be covered, got %+v", opsNotes) } + maintainabilityHandoff, ok := parseReadinessEvidenceLine("GOAL-0001", "Maintainability: `DEVELOPMENT.md` documents the required `./check.sh` service-quality smoke, what it proves, and the files that must stay synchronized when command behavior changes.", defs) + if !ok { + t.Fatal("expected maintainability handoff evidence to parse") + } + if maintainabilityHandoff.Status != "covered" { + t.Fatalf("expected maintainability handoff evidence to be covered, got %+v", maintainabilityHandoff) + } referenceBenchmark, ok := parseReadinessEvidenceLine("GOAL-0001", "Reference benchmark: Category: Developer CLI; References: namba-ai, pi.dev, Claude Code; Baseline expectations: install is clear and one command creates useful work context; Current comparison: setup meets baseline and evidence loop is above baseline; Below-baseline gaps: None; no critical user or operator baseline gap remains; Above-baseline strength: project-local evidence pressure; Decision: Service Quality is allowed from the benchmark perspective.", defs) if !ok { t.Fatal("expected reference benchmark evidence to parse") @@ -1879,6 +1957,13 @@ func TestReadinessEvidenceQualityRules(t *testing.T) { if referenceBenchmark.Status != "covered" { t.Fatalf("expected reference benchmark evidence to be covered, got %+v", referenceBenchmark) } + naturalReferenceBenchmark, ok := parseReadinessEvidenceLine("GOAL-0001", "Reference benchmark: Category: Local file-backed utility CLI; References: Git, SQLite CLI, Taskfile, Make; Baseline expectations: local commands are documented and repeatable command output exists; Current comparison: this sample meets the repeatable local CLI baseline; Below-baseline gaps: None for this smoke path; Above-baseline strength: evidence is captured before learning; Decision: Service Quality can continue from this benchmark.", defs) + if !ok { + t.Fatal("expected natural reference benchmark evidence to parse") + } + if naturalReferenceBenchmark.Status != "covered" { + t.Fatalf("expected natural reference benchmark evidence to be covered, got %+v", naturalReferenceBenchmark) + } errorHandling, ok := parseReadinessEvidenceLine("GOAL-0001", "Error handling: Covered. Empty, loading, error, fallback, and recovery states are handled for the primary path: missing profile fields, future birth date, incomplete daily log, empty report, and storage-disabled browser fallback. Playwright verified each state at 390x844.", defs) if !ok { t.Fatal("expected error handling evidence with missing input text to parse") @@ -2530,6 +2615,20 @@ func TestGoalStateIgnoresNoIssueBlockerAndFailureNotes(t *testing.T) { } } +func TestValidationMemoryPrefersCommandOverOutputLine(t *testing.T) { + validation := strings.Join([]string{ + "Command: `./check.sh`", + "Output:", + "```text", + "no items", + "service-quality smoke passed", + "```", + }, "\n") + if got := firstUsefulValidationMemory(validation); got != "`./check.sh` passed." { + t.Fatalf("expected command-centered validation memory, got %q", got) + } +} + func TestStatusDerivesCompletedForNoOpBlocker(t *testing.T) { root := t.TempDir() mustInitWithPlan(t, root, "Tiny tasks", "Build a tiny task list MVP") diff --git a/internal/app/readiness.go b/internal/app/readiness.go index 93d5cdc..9a0f8a9 100644 --- a/internal/app/readiness.go +++ b/internal/app/readiness.go @@ -493,8 +493,8 @@ func readinessEvidenceQuality(axis, text string) (bool, string) { return operationsDocsEvidenceCovered(normalized), "README, docs, setup, runbook, rollback, smoke path, stop conditions, or environment evidence" case "maintainability": - return hasAny(normalized, "refactor", "cleanup", "component", "module", "architecture", "helper", "table-driven", "test", "extracted", "reduced", "documented"), - "refactor, modularity, test, helper, cleanup, or architecture evidence" + return hasAny(normalized, "refactor", "cleanup", "component", "module", "architecture", "helper", "table-driven", "test", "extracted", "reduced", "documented", "document", "documents", "handoff", "maintenance", "synchronized", "sync"), + "refactor, modularity, test, helper, cleanup, documentation, handoff, or maintenance evidence" case "reference_benchmark": return referenceBenchmarkEvidenceQuality(normalized) case "sustained_quality": @@ -713,7 +713,8 @@ func specificBenchmarkField(value string) bool { func currentComparisonCovered(value string) bool { normalized := normalizeSentence(value) return specificBenchmarkField(value) && - hasAny(normalized, "below baseline", "below-baseline", "meets baseline", "meet baseline", "above baseline", "above-baseline") + (hasAny(normalized, "below baseline", "below-baseline", "meets baseline", "meet baseline", "above baseline", "above-baseline") || + (strings.Contains(normalized, "baseline") && hasAny(normalized, "below", "meets", "meet", "above"))) } func noCriticalBelowBaselineGap(value string) bool { @@ -777,7 +778,7 @@ func deploymentEvidenceCovered(normalized string) bool { ) deploymentProof := hasAny(normalized, "passed", "available", "hosted", "deployed", "built", "released", "verified", "validated", "proved", "proven", - "created", "served", "extracted", "smoke", "parity", "ran", + "verifies", "validates", "creates", "creation", "created", "served", "extracted", "smoke", "parity", "ran", ) return deploymentTarget && deploymentProof } From 63815f357d7c14148b1544df5316d50657dbb6d7 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 21:46:05 +0900 Subject: [PATCH 29/52] Guard command pattern classification --- internal/app/growth.go | 19 ++++++++++++++++++- internal/app/main_test.go | 7 +++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/internal/app/growth.go b/internal/app/growth.go index 9ac6544..a1b476d 100644 --- a/internal/app/growth.go +++ b/internal/app/growth.go @@ -487,12 +487,29 @@ func isValidationPattern(signal string) bool { if strings.Contains(normalized, "readiness evidence:") && !strings.Contains(normalized, "validation coverage:") { return false } - if command := firstBacktickCommand(signal); command != "" && hasAny(normalized, "run", "check", "smoke", "validation", "handoff", "before every", "before each", "passed", "repeatable") { + if command := firstBacktickCommand(signal); looksLikeRuntimeCommand(command) && hasAny(normalized, "run", "check", "smoke", "validation", "handoff", "before every", "before each", "passed", "repeatable") { return true } return hasAny(normalized, "test", "build", "smoke", "validate", "validation", "playwright", "browser", "go test", "npm run", "pytest") } +func looksLikeRuntimeCommand(command string) bool { + fields := strings.Fields(strings.TrimSpace(command)) + if len(fields) == 0 { + return false + } + executable := fields[0] + if strings.HasPrefix(executable, "./") || strings.HasPrefix(executable, "../") || strings.HasPrefix(executable, "/") { + return true + } + switch executable { + case "bun", "cargo", "deno", "docker", "go", "just", "make", "node", "npm", "pnpm", "python", "python3", "pytest", "uv", "yarn": + return true + default: + return false + } +} + func isSurfaceValidationPattern(signal string) bool { normalized := strings.ToLower(signal) return hasAny(normalized, "surface", "screen", "route", "viewport", "mobile", "desktop", "screenshot", "browser", "visual", "responsive", "accessibility", "focus", "keyboard") && diff --git a/internal/app/main_test.go b/internal/app/main_test.go index a68bf67..35f8799 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -1614,6 +1614,13 @@ func TestCommandHandoffPatternClassifiesAsValidation(t *testing.T) { } } +func TestBacktickCodeSymbolDoesNotClassifyAsValidationCommand(t *testing.T) { + pressureType, effect := growthClassification("pattern", "Pattern: Check `loadState()` fallback before rendering.") + if pressureType != "implementation_pattern" || effect != "implementation" { + t.Fatalf("expected code-symbol pattern to remain implementation pressure, got %s/%s", pressureType, effect) + } +} + func TestActiveValidatorBecomesRequiredValidationSignal(t *testing.T) { root := t.TempDir() mustInitWithPlan(t, root, "Tiny CLI", "Build a tiny CLI MVP") From 4e7487ab1826df6062928a5d346802f0e4ac746b Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 21:53:35 +0900 Subject: [PATCH 30/52] Require finish gate before next packet --- internal/app/commands.go | 29 +++++++++++++++++++++++++---- internal/app/growth.go | 6 +++++- internal/app/main_test.go | 38 ++++++++++++++++++++++++++++++++------ 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/internal/app/commands.go b/internal/app/commands.go index 7c42dd5..735b62e 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -508,17 +508,38 @@ func blockingActiveGoal(root string, state projectState) string { if strings.TrimSpace(state.CurrentGoalID) == "" { return "" } - if state.Status != "" && state.Status != "active" { - return "" - } derived := deriveCurrentGoalState(root, state.CurrentGoalID) - if derived.State != "active" { + if strings.TrimSpace(state.Status) != "" && strings.TrimSpace(derived.State) != "" && state.Status != "active" && state.Status != derived.State { + return strings.Join([]string{ + "Current runtime packet state is inconsistent: " + state.CurrentGoalID, + "State file: " + state.Status, + "Evidence state: " + derived.State, + "", + "Repair it before creating another packet:", + " hyper status --short", + " hyper repair", + " hyper complete", + }, "\n") + } + if state.Status != "" && state.Status != "active" { return "" } path := state.CurrentGoalPath if strings.TrimSpace(path) == "" { path = fmt.Sprintf(".hyper/goals/%s/goal.md", state.CurrentGoalID) } + if derived.State != "active" { + return strings.Join([]string{ + "Current runtime packet has not passed the finish gate yet: " + state.CurrentGoalID, + "Evidence state: " + derived.State, + "Reason: " + derived.Reason, + "", + "Finish it before creating another packet:", + " hyper complete", + " if the finish gate fails, fix " + strings.TrimSuffix(path, "goal.md") + "review.md", + " then run hyper complete again", + }, "\n") + } return strings.Join([]string{ "Current runtime packet is still active: " + state.CurrentGoalID, "Reason: " + derived.Reason, diff --git a/internal/app/growth.go b/internal/app/growth.go index a1b476d..316e145 100644 --- a/internal/app/growth.go +++ b/internal/app/growth.go @@ -970,6 +970,7 @@ func retiredGrowthCandidates(root string, previous growthState, current []growth func harnessPressureReady(pressures []growthPressure) bool { stable := 0 hasValidation := false + hasNonValidationStructure := false for _, pressure := range pressures { if pressure.GoalCount < growthRepeatedSignalGoals { continue @@ -977,11 +978,14 @@ func harnessPressureReady(pressures []growthPressure) bool { if pressure.Effect == "validation" { hasValidation = true } + if pressure.Effect == "implementation" || pressure.Effect == "work_boundary" { + hasNonValidationStructure = true + } if pressure.Effect == "validation" || pressure.Effect == "implementation" || pressure.Effect == "work_boundary" { stable++ } } - return hasValidation && stable >= growthHarnessStablePressures + return hasValidation && hasNonValidationStructure && stable >= growthHarnessStablePressures } func aggregateHarnessPressure(pressures []growthPressure) growthPressure { diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 35f8799..884011f 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -601,6 +601,25 @@ func TestRunBlocksPendingActiveGoal(t *testing.T) { assertContains(t, err.Message, "hyper complete") } +func TestRunBlocksCompletedEvidenceBeforeFinishGate(t *testing.T) { + root := t.TempDir() + mustInitWithPlan(t, root, "Tiny notes", "Build a tiny notes MVP") + mustRun(t, root, "run") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\n`go test ./...` passed.\n\n## Readiness Evidence\n\nValidation coverage: `go test ./...` passed and is repeatable.\n\n## Blocker\n\nNone blocking.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nAdd the primary notes flow.\n\n## Learn Notes\n\n- Pattern: Run go test before handoff.\n") + + _, err := runCLI(args("run", "Start another packet"), testRoot(root), fakeUpdater{}) + if err == nil { + t.Fatal("expected finish gate guard to block next run") + } + assertContains(t, err.Message, "has not passed the finish gate yet") + assertContains(t, err.Message, "hyper complete") + assertContains(t, err.Message, "review.md") + if exists(filepath.Join(root, ".hyper", "goals", "GOAL-0002")) { + t.Fatal("new runtime packet should not be created before the finish gate passes") + } +} + func TestCompleteLearnsAndRefreshesReadiness(t *testing.T) { root := t.TempDir() mustInitWithPlan(t, root, "Tiny notes", "Build a tiny notes MVP") @@ -1153,25 +1172,27 @@ func TestAutoLearnFeedsNextGoalContext(t *testing.T) { root := t.TempDir() mustInitWithPlan(t, root, "Tiny CRM", "Build a tiny CRM MVP") mustRun(t, root, "run") - writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\nCustomer records persisted in SQLite. go test passed.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\nCustomer records persisted in SQLite. go test passed.\n\n## Readiness Evidence\n\nProduct completeness: Tiny CRM has a measurable create-and-list customer record flow.\nCore UX: CLI smoke verified create and list customer records from the command surface.\nValidation coverage: go test passed and the customer persistence smoke is repeatable.\n\n## Blocker\n\nNone blocking.\n") writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nAdd persisted customer records.\n") + mustRun(t, root, "complete") out, err := runCLI(args("run", "Add persisted customer records"), testRoot(root), fakeUpdater{}) if err != nil { t.Fatalf("second run failed: %v", err) } - assertContains(t, out.Stdout, "Auto learn: completed, inserted 1") + assertContains(t, out.Stdout, "Auto learn: completed, inserted 0") assertContains(t, out.Stdout, "Similar context: ") assertContains(t, strings.ToLower(readFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0002", "goal.md"))), "customer records persisted") - assertContains(t, readFile(t, filepath.Join(root, ".hyper", "logs", "RUN-0001.jsonl")), "auto_learn_completed") + assertContains(t, readFile(t, filepath.Join(root, ".hyper", "logs", "RUN-0001.jsonl")), "runtime_packet_completed") } func TestGrowthStateChangesNextRuntimePacket(t *testing.T) { root := t.TempDir() mustInitWithPlan(t, root, "Tiny notes", "Build a local-first notes MVP") mustRun(t, root, "run") - writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\ngo test ./... passed.\n\n## Changed Files\n\ncmd/notes.go\n\n## Decisions\n\nKeep local-first storage.\n\n## Reusable Patterns\n\nRun go test before every runtime packet handoff.\n\n## Blocker\n\nPending.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\ngo test ./... passed.\n\n## Readiness Evidence\n\nProduct completeness: Tiny notes has a measurable local note command slice.\nCore UX: CLI smoke verified the primary note command surface.\nValidation coverage: go test ./... passed and is repeatable.\n\n## Changed Files\n\ncmd/notes.go\n\n## Decisions\n\nKeep local-first storage.\n\n## Reusable Patterns\n\nRun go test before every runtime packet handoff.\n\n## Blocker\n\nNone blocking.\n") writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nAdd note editing polish.\n\n## Learn Notes\n\n- Pattern: Run go test before every runtime packet handoff.\n- Constraint: Do not add external services without credentials.\n") + mustRun(t, root, "complete") if _, err := runCLI(args("run", "Add note editing polish"), testRoot(root), fakeUpdater{}); err != nil { t.Fatalf("second run failed: %v", err) @@ -1193,14 +1214,16 @@ func TestGrowthGeneratesValidatorCandidateAfterRepeatedPressure(t *testing.T) { root := t.TempDir() mustInitWithPlan(t, root, "Tiny CLI", "Build a tiny CLI MVP") mustRun(t, root, "run") - writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\ngo test ./... passed.\n\n## Changed Files\n\ncmd/app.go\n\n## Decisions\n\nPending.\n\n## Reusable Patterns\n\nRun go test before every runtime packet handoff.\n\n## Blocker\n\nPending.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\ngo test ./... passed.\n\n## Readiness Evidence\n\nProduct completeness: Tiny CLI has a measurable command flow.\nCore UX: CLI smoke verified the primary command surface.\nValidation coverage: go test ./... passed and is repeatable.\n\n## Changed Files\n\ncmd/app.go\n\n## Decisions\n\nPending.\n\n## Reusable Patterns\n\nRun go test before every runtime packet handoff.\n\n## Blocker\n\nNone blocking.\n") writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nAdd CLI persistence.\n\n## Learn Notes\n\n- Pattern: Run go test before every runtime packet handoff.\n") + mustRun(t, root, "complete") if _, err := runCLI(args("run", "Add CLI persistence"), testRoot(root), fakeUpdater{}); err != nil { t.Fatalf("second run failed: %v", err) } - writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0002", "evidence.md"), "# GOAL-0002 Evidence\n\n## Validation\n\ngo test ./... passed.\n\n## Changed Files\n\ncmd/storage.go\n\n## Decisions\n\nPending.\n\n## Reusable Patterns\n\nRun go test before every runtime packet handoff.\n\n## Blocker\n\nPending.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0002", "evidence.md"), "# GOAL-0002 Evidence\n\n## Validation\n\ngo test ./... passed.\n\n## Readiness Evidence\n\nProduct completeness: Tiny CLI persistence keeps the measurable command flow intact.\nCore UX: CLI smoke verifies the primary command surface.\nValidation coverage: go test ./... passed and is repeatable.\n\n## Changed Files\n\ncmd/storage.go\n\n## Decisions\n\nPending.\n\n## Reusable Patterns\n\nRun go test before every runtime packet handoff.\n\n## Blocker\n\nNone blocking.\n") writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0002", "next.md"), "# GOAL-0002 Next\n\n## Recommended Next Goal\n\nPolish CLI output.\n\n## Learn Notes\n\n- Pattern: Run go test before every runtime packet handoff.\n") + mustRun(t, root, "complete") if _, err := runCLI(args("run", "Polish CLI output"), testRoot(root), fakeUpdater{}); err != nil { t.Fatalf("third run failed: %v", err) @@ -1813,6 +1836,7 @@ func TestReadinessEvidenceProgressesSelectedAxis(t *testing.T) { } writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\nBrowser smoke passed.\n\n## Readiness Evidence\n\nCore UX: Browser smoke verified add and revisit customer notes flow.\nData persistence: Customer notes persist across reload using local storage.\n\n## Changed Files\n\nsrc/App.tsx\n\n## Decisions\n\nKeep storage local-first.\n\n## Reusable Patterns\n\nPending.\n\n## Blocker\n\nPending.\n") writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nHandle empty and failure states.\n\n## Learn Notes\n\n- Pattern: Record readiness evidence with an axis label.\n") + mustRun(t, root, "complete") if _, err := runCLI(args("run"), testRoot(root), fakeUpdater{}); err != nil { t.Fatalf("second run failed: %v", err) @@ -2059,6 +2083,7 @@ func TestSurfaceProofEvidenceProgressesReadiness(t *testing.T) { } writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\n`npm run build` passed.\n\n## Surface Proof Evidence\n\n- Evidence: Browser smoke verified the primary action create customer note flow at mobile 390x844 and desktop 1440x900; screenshots captured and passed.\n\n## Changed Files\n\nsrc/App.tsx\n\n## Decisions\n\nPending.\n\n## Reusable Patterns\n\nPending.\n\n## Blocker\n\nPending.\n") writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nReview stage readiness.\n") + mustRun(t, root, "complete") if _, err := runCLI(args("run"), testRoot(root), fakeUpdater{}); err != nil { t.Fatalf("second run failed: %v", err) @@ -2268,6 +2293,7 @@ func TestStageAdvancementCandidateWhenGateReady(t *testing.T) { mustRun(t, root, "run") writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\n`npm run build` passed.\n\n## Readiness Evidence\n\nCore UX: Browser smoke verified create, complete, and delete flow.\nValidation coverage: `npm run build` passed and primary flow smoke test passed.\n\n## Changed Files\n\nsrc/App.tsx\n\n## Decisions\n\nKeep local-first storage.\n\n## Reusable Patterns\n\nPending.\n\n## Blocker\n\nPending.\n") writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nReview stage advancement.\n\n## Learn Notes\n\n- Pattern: Record axis-labeled readiness evidence.\n") + mustRun(t, root, "complete") if _, err := runCLI(args("run"), testRoot(root), fakeUpdater{}); err != nil { t.Fatalf("second run failed: %v", err) From 40109c37806ab2375a94b2e795c3b73665a7c640 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 22:03:18 +0900 Subject: [PATCH 31/52] Polish stage flow evidence handling --- internal/app/growth.go | 28 ++++++++++++++++++++++++++++ internal/app/main_test.go | 30 ++++++++++++++++++++++++++++++ internal/app/readiness.go | 6 +++--- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/internal/app/growth.go b/internal/app/growth.go index 316e145..d24a350 100644 --- a/internal/app/growth.go +++ b/internal/app/growth.go @@ -314,6 +314,8 @@ func memorySignal(text string) string { } prefixes := []string{ "decisions:", + "pressure signal:", + "pressure signals:", "readiness evidence:", "reusable patterns:", "learn decision:", @@ -346,9 +348,35 @@ func memorySignal(text string) string { if isPlaceholder(signal) { return "" } + signal = stripPressureSignalLabel(signal) return signal } +func stripPressureSignalLabel(signal string) string { + labels := []string{ + "repeated_validation:", + "service_quality_boundary:", + "implementation_pattern:", + "work_boundary:", + "known_failure:", + "recurring_failure:", + } + for { + normalized := strings.ToLower(strings.TrimSpace(signal)) + changed := false + for _, label := range labels { + if strings.HasPrefix(normalized, label) { + signal = strings.TrimSpace(signal[len(label):]) + changed = true + break + } + } + if !changed { + return strings.TrimSpace(signal) + } + } +} + func isNoisyGrowthSignal(signal string) bool { normalized := normalizeSentence(signal) if isPlaceholder(normalized) { diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 884011f..95e3d36 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -1637,6 +1637,18 @@ func TestCommandHandoffPatternClassifiesAsValidation(t *testing.T) { } } +func TestMemorySignalStripsPressureSignalLabels(t *testing.T) { + got := memorySignal("GOAL-0002 pressure signals: repeated_validation: `./check.sh` passed again as the handoff smoke.") + if got != "`./check.sh` passed again as the handoff smoke." { + t.Fatalf("expected clean pressure signal, got %q", got) + } + + got = memorySignal("GOAL-0003 pressure signals: service_quality_boundary: Keep security rejection and export proof in the handoff.") + if got != "Keep security rejection and export proof in the handoff." { + t.Fatalf("expected clean service boundary signal, got %q", got) + } +} + func TestBacktickCodeSymbolDoesNotClassifyAsValidationCommand(t *testing.T) { pressureType, effect := growthClassification("pattern", "Pattern: Check `loadState()` fallback before rendering.") if pressureType != "implementation_pattern" || effect != "implementation" { @@ -3171,6 +3183,24 @@ func TestReadinessEvidenceCoversFileBackedPersistence(t *testing.T) { if record.Axis != "persistence" || record.Status != "covered" { t.Fatalf("expected covered persistence evidence, got %+v", record) } + + textFileRecord, ok := parseReadinessEvidenceLine("GOAL-0003", "Data persistence: `notes.txt` stores the added note and `./check.sh` reads it back through a separate list command before export.", readinessDimensionDefs()) + if !ok { + t.Fatal("expected labeled txt persistence evidence to parse") + } + if textFileRecord.Axis != "persistence" || textFileRecord.Status != "covered" { + t.Fatalf("expected covered txt persistence evidence, got %+v", textFileRecord) + } +} + +func TestReadinessEvidenceCoversRejectedInputErrorHandling(t *testing.T) { + record, ok := parseReadinessEvidenceLine("GOAL-0001", "Error handling: Empty list returns `no notes`, unsafe input containing a secret-like value is rejected, and the smoke command proves both paths.", readinessDimensionDefs()) + if !ok { + t.Fatal("expected rejected input error handling evidence to parse") + } + if record.Axis != "error_handling" || record.Status != "covered" { + t.Fatalf("expected covered rejected input error evidence, got %+v", record) + } } func TestReadinessEvidenceCoversPrivacyBoundaryAsSecurityBaseline(t *testing.T) { diff --git a/internal/app/readiness.go b/internal/app/readiness.go index 9a0f8a9..d661d38 100644 --- a/internal/app/readiness.go +++ b/internal/app/readiness.go @@ -472,12 +472,12 @@ func readinessEvidenceQuality(axis, text string) (bool, string) { return coreUXEvidenceCovered(normalized), "browser, screenshot, smoke, or verified primary-flow evidence" case "persistence": - return hasAny(normalized, "persist", "reload", "restart", "saved", "survive", "stored", "created", "re-read", "reread", "confirmed", "row") && - hasAny(normalized, "sqlite", "mysql", "postgres", "postgresql", "database", " db ", "db check", "sql", "localstorage", "local storage", "storage", "json", ".json", "file", "disk", "filesystem"), + return hasAny(normalized, "persist", "reload", "restart", "saved", "save", "survive", "stored", "stores", "created", "re-read", "reread", "read back", "reads it back", "confirmed", "row") && + hasAny(normalized, "sqlite", "mysql", "postgres", "postgresql", "database", " db ", "db check", "sql", "localstorage", "local storage", "storage", "json", ".json", ".txt", "file", "disk", "filesystem"), "MySQL, SQLite, DB, file, JSON, reload, restart, storage, or database evidence" case "error_handling": return hasAny(normalized, "empty", "error", "loading", "fallback", "failure", "edge") && - hasAny(normalized, "handled", "covered", "verified", "tested", "implemented", "works"), + hasAny(normalized, "handled", "covered", "verified", "tested", "implemented", "works", "rejected", "proves", "proved", "passed"), "empty, loading, error, failure, fallback, or edge-state evidence" case "validation_coverage": return hasAny(normalized, "smoke", "playwright", "go test", "npm run", "pytest", "build", "command", "validation", "browser", "screenshot", "`") && From 3b18598d4fff546460b354f2286fd33725f49200 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 22:12:57 +0900 Subject: [PATCH 32/52] Tighten harness candidate threshold --- internal/app/growth.go | 6 +++--- internal/app/main_test.go | 12 ++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/internal/app/growth.go b/internal/app/growth.go index d24a350..e659259 100644 --- a/internal/app/growth.go +++ b/internal/app/growth.go @@ -998,7 +998,7 @@ func retiredGrowthCandidates(root string, previous growthState, current []growth func harnessPressureReady(pressures []growthPressure) bool { stable := 0 hasValidation := false - hasNonValidationStructure := false + nonValidationStructures := 0 for _, pressure := range pressures { if pressure.GoalCount < growthRepeatedSignalGoals { continue @@ -1007,13 +1007,13 @@ func harnessPressureReady(pressures []growthPressure) bool { hasValidation = true } if pressure.Effect == "implementation" || pressure.Effect == "work_boundary" { - hasNonValidationStructure = true + nonValidationStructures++ } if pressure.Effect == "validation" || pressure.Effect == "implementation" || pressure.Effect == "work_boundary" { stable++ } } - return hasValidation && hasNonValidationStructure && stable >= growthHarnessStablePressures + return hasValidation && nonValidationStructures >= 2 && stable >= growthHarnessStablePressures } func aggregateHarnessPressure(pressures []growthPressure) growthPressure { diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 95e3d36..0235748 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -1573,6 +1573,18 @@ func TestHarnessCandidateEvidenceCountUsesStablePressureCount(t *testing.T) { } } +func TestHarnessCandidateNeedsMultipleNonValidationStructures(t *testing.T) { + pressures := []growthPressure{ + {Effect: "validation", GoalCount: 2, Sources: []string{"GOAL-0001", "GOAL-0002"}}, + {Effect: "validation", GoalCount: 2, Sources: []string{"GOAL-0001", "GOAL-0002"}}, + {Effect: "validation", GoalCount: 2, Sources: []string{"GOAL-0001", "GOAL-0002"}}, + {Effect: "work_boundary", GoalCount: 2, Sources: []string{"GOAL-0001", "GOAL-0002"}}, + } + if harnessPressureReady(pressures) { + t.Fatal("single repeated decision plus repeated validation must not create a harness candidate") + } +} + func TestHarnessCandidateRequiresEnoughSourceGoalsForActivation(t *testing.T) { twoGoalPressure := aggregateHarnessPressure([]growthPressure{ {Effect: "validation", GoalCount: 2, Sources: []string{"GOAL-0003", "GOAL-0004"}}, From 5fc0b7c29f51dc3e631759c159d9885098d784f2 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 22:19:19 +0900 Subject: [PATCH 33/52] Accept validation output for active validators --- internal/app/finish_gate.go | 18 ++++++++++++++++++ internal/app/main_test.go | 23 +++++++++++++++++++---- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/internal/app/finish_gate.go b/internal/app/finish_gate.go index b91b857..e03b1df 100644 --- a/internal/app/finish_gate.go +++ b/internal/app/finish_gate.go @@ -95,6 +95,9 @@ func activeCapabilityFinishGateFinding(root, evidenceText string) string { if activeCapabilityEvidenceCovers(capability, lines) { continue } + if activeValidatorValidationCovers(capability, evidenceText) { + continue + } missing = append(missing, capability.Name) } if len(missing) == 0 { @@ -124,6 +127,21 @@ func activeCapabilityEvidenceCovers(capability activeCapability, lines []string) return false } +func activeValidatorValidationCovers(capability activeCapability, evidenceText string) bool { + if capability.Kind != "validator" { + return false + } + command := normalizeSentence(inferredCommandForSignal(capability.Signal)) + if command == "" { + return false + } + validation := normalizeSentence(firstUsefulValidationMemory(sectionBody(evidenceText, "Validation"))) + if !credibleActiveCapabilityEvidence(validation) { + return false + } + return strings.Contains(validation, command) +} + func credibleActiveCapabilityEvidence(normalized string) bool { if normalized == "" || isPlaceholder(normalized) { return false diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 0235748..96dfec8 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -670,7 +670,7 @@ func TestCompleteRunsFinishGateBeforeLearning(t *testing.T) { root := t.TempDir() mustInitWithPlan(t, root, "Tiny notes", "Build a tiny notes MVP") mustRun(t, root, "run") - writeFile(t, filepath.Join(root, ".hyper", "capabilities", "active", "validator", "validator-go-test.md"), "# validator-go-test\n\nStatus: active\nKind: validator\nSignal: Run go test ./... before completing packets.\n") + writeFile(t, filepath.Join(root, ".hyper", "capabilities", "active", "validator", "validator-smoke.md"), "# validator-smoke\n\nStatus: active\nKind: validator\nSignal: Run npm run smoke before completing packets.\n") writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\n`go test ./...` passed.\n\n## Readiness Evidence\n\nCore UX: CLI smoke passed for create and complete flow.\nValidation coverage: `go test ./...` passed and is repeatable.\n") writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nAdd the next slice.\n") @@ -679,7 +679,7 @@ func TestCompleteRunsFinishGateBeforeLearning(t *testing.T) { t.Fatal("expected finish gate to reject missing active capability evidence") } assertContains(t, err.Message, "Finish gate failed for GOAL-0001") - assertContains(t, err.Message, "Record active capability evidence for: validator-go-test") + assertContains(t, err.Message, "Record active capability evidence for: validator-smoke") review := readFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "review.md")) assertContains(t, review, "Status: failed") assertContains(t, review, "Stay in the same runtime packet") @@ -700,8 +700,8 @@ func TestCompleteRequiresSpecificActiveCapabilityEvidence(t *testing.T) { t.Fatal("expected active capability evidence to name or prove the validator") } assertContains(t, err.Message, "Record active capability evidence for:") - assertContains(t, err.Message, "validator-go-test") assertContains(t, err.Message, "harness-growth-candidate") + assertNotContains(t, err.Message, "validator-go-test") writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\n`go test ./...` passed.\n\n## Readiness Evidence\n\nCore UX: CLI smoke verified create and complete flow.\nValidation coverage: `go test ./...` passed and primary CLI smoke is repeatable.\n\n## Active Capability Evidence\n\nvalidator-go-test: `go test ./...` passed.\nharness-growth-candidate: project-specific handoff harness passed.\n\n## Blocker\n\nNone blocking.\n") if _, err := runCLI(args("complete"), testRoot(root), fakeUpdater{}); err != nil { @@ -714,7 +714,7 @@ func TestCompleteRejectsPendingActiveCapabilityTemplate(t *testing.T) { mustInitWithPlan(t, root, "Tiny CLI", "Build a tiny CLI MVP") mustRun(t, root, "run") writeFile(t, filepath.Join(root, ".hyper", "capabilities", "active", "validator", "validator-go-test.md"), "# validator-go-test\n\nStatus: active\nKind: validator\nSignal: Run go test ./... before completing packets.\n") - writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\n`go test ./...` passed.\n\n## Readiness Evidence\n\nCore UX: CLI smoke verified create and complete flow.\nValidation coverage: `go test ./...` passed and primary CLI smoke is repeatable.\n\n## Active Capability Evidence\n\nvalidator-go-test: Pending. Required behavior: Run go test ./... before completing packets.\n\n## Blocker\n\nNone blocking.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\n`npm run build` passed.\n\n## Readiness Evidence\n\nCore UX: CLI smoke verified create and complete flow.\nValidation coverage: `npm run build` passed and primary CLI smoke is repeatable.\n\n## Active Capability Evidence\n\nvalidator-go-test: Pending. Required behavior: Run go test ./... before completing packets.\n\n## Blocker\n\nNone blocking.\n") writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nReview stage advancement.\n") _, err := runCLI(args("complete"), testRoot(root), fakeUpdater{}) @@ -724,6 +724,21 @@ func TestCompleteRejectsPendingActiveCapabilityTemplate(t *testing.T) { assertContains(t, err.Message, "Record active capability evidence for: validator-go-test") } +func TestCompleteAcceptsValidationOutputForActiveValidator(t *testing.T) { + root := t.TempDir() + mustInitWithPlan(t, root, "Tiny CLI", "Build a tiny CLI MVP") + mustRun(t, root, "run") + writeFile(t, filepath.Join(root, ".hyper", "capabilities", "active", "validator", "validator-go-test.md"), "# validator-go-test\n\nStatus: active\nKind: validator\nSignal: Run go test ./... before completing packets.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\nCommand: `go test ./...`\n\nOutput:\n\n```text\nok ./...\n```\n\ngo test ./... passed.\n\n## Readiness Evidence\n\nCore UX: CLI smoke verified create and complete flow.\nValidation coverage: `go test ./...` passed and primary CLI smoke is repeatable.\n\n## Active Capability Evidence\n\nvalidator-go-test: Pending. Required behavior: Run go test ./... before completing packets.\n\n## Blocker\n\nNone blocking.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nReview stage advancement.\n") + + out, err := runCLI(args("complete"), testRoot(root), fakeUpdater{}) + if err != nil { + t.Fatalf("validation output should satisfy active validator proof: %v", err) + } + assertContains(t, out.Stdout, "Finish gate: passed") +} + func TestCompleteAcceptsExplicitActiveCapabilityBlocker(t *testing.T) { root := t.TempDir() mustInitWithPlan(t, root, "Tiny CLI", "Build a tiny CLI MVP") From 51156ca7916854c7deefb92a795919cf9eb538eb Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 22:31:11 +0900 Subject: [PATCH 34/52] Keep active validators from being downgraded --- internal/app/finish_gate.go | 26 +++++++++-- internal/app/growth.go | 87 ++++++++++++++++++++++++------------- internal/app/main_test.go | 73 ++++++++++++++++++++++++++++++- 3 files changed, 153 insertions(+), 33 deletions(-) diff --git a/internal/app/finish_gate.go b/internal/app/finish_gate.go index e03b1df..ea50dfe 100644 --- a/internal/app/finish_gate.go +++ b/internal/app/finish_gate.go @@ -135,11 +135,27 @@ func activeValidatorValidationCovers(capability activeCapability, evidenceText s if command == "" { return false } - validation := normalizeSentence(firstUsefulValidationMemory(sectionBody(evidenceText, "Validation"))) - if !credibleActiveCapabilityEvidence(validation) { + validation := normalizeSentence(sectionBody(evidenceText, "Validation")) + if !strings.Contains(validation, command) || !credibleActiveCapabilityEvidence(validation) { return false } - return strings.Contains(validation, command) + return successfulValidationEvidence(validation) +} + +func successfulValidationEvidence(normalized string) bool { + return hasAny(normalized, + "passed", + "success", + "succeeded", + "verified", + "checked", + "covered", + "proved", + "proven", + "built", + " ok ", + "ok ./", + ) } func credibleActiveCapabilityEvidence(normalized string) bool { @@ -149,6 +165,10 @@ func credibleActiveCapabilityEvidence(normalized string) bool { if explicitActiveCapabilityBlocker(normalized) { return true } + if hasAny(normalized, "failed", "failure", "blocked", "warning", "warn") && + !hasAny(normalized, "passed", "success", "succeeded", "verified", "checked", "covered", "handled", "proved", "proven", "recovered") { + return false + } if hasAny(normalized, "pending", "todo", diff --git a/internal/app/growth.go b/internal/app/growth.go index e659259..c710800 100644 --- a/internal/app/growth.go +++ b/internal/app/growth.go @@ -756,7 +756,7 @@ func growthLine(verb string, pressure growthPressure, label string) string { func materializeGrowthCandidates(root string, pressures []growthPressure, previous growthState) ([]growthCandidate, *hyperError) { candidates := []growthCandidate{} - seen := map[string]bool{} + seen := map[string]int{} for _, pressure := range pressures { if pressure.GoalCount < growthRepeatedSignalGoals { continue @@ -768,44 +768,24 @@ func materializeGrowthCandidates(root string, pressures []growthPressure, previo if pressure.PressureType == "surface_validation" { reason = "Repeated surface proof pressure crossed the validator threshold." } - candidate := growthCandidateForPressure("validator", prefix, "validators", reason, pressure) - if err := writeGrowthCandidate(root, candidate, pressure); err != nil { + if err := addGrowthCandidate(root, &candidates, seen, growthCandidateForPressure("validator", prefix, "validators", reason, pressure), pressure); err != nil { return nil, err } - if !seen[candidate.LifecyclePath] { - candidates = append(candidates, candidate) - seen[candidate.LifecyclePath] = true - } case "implementation": - candidate := growthCandidateForPressure("skill", "skill", "skills", "Repeated implementation pressure crossed the skill threshold.", pressure) - if err := writeGrowthCandidate(root, candidate, pressure); err != nil { + if err := addGrowthCandidate(root, &candidates, seen, growthCandidateForPressure("skill", "skill", "skills", "Repeated implementation pressure crossed the skill threshold.", pressure), pressure); err != nil { return nil, err } - if !seen[candidate.LifecyclePath] { - candidates = append(candidates, candidate) - seen[candidate.LifecyclePath] = true - } case "stop_condition": - candidate := growthCandidateForPressure("validator", "preflight", "validators", "Repeated failure pressure crossed the preflight threshold.", pressure) - if err := writeGrowthCandidate(root, candidate, pressure); err != nil { + if err := addGrowthCandidate(root, &candidates, seen, growthCandidateForPressure("validator", "preflight", "validators", "Repeated failure pressure crossed the preflight threshold.", pressure), pressure); err != nil { return nil, err } - if !seen[candidate.LifecyclePath] { - candidates = append(candidates, candidate) - seen[candidate.LifecyclePath] = true - } } } if harnessPressureReady(pressures) { pressure := aggregateHarnessPressure(pressures) - candidate := harnessCandidateForPressure(pressure) - if err := writeGrowthCandidate(root, candidate, pressure); err != nil { + if err := addGrowthCandidate(root, &candidates, seen, harnessCandidateForPressure(pressure), pressure); err != nil { return nil, err } - if !seen[candidate.LifecyclePath] { - candidates = append(candidates, candidate) - seen[candidate.LifecyclePath] = true - } } active, activeErr := activeCapabilities(root) if activeErr != nil { @@ -820,6 +800,51 @@ func materializeGrowthCandidates(root string, pressures []growthPressure, previo return candidates, nil } +func addGrowthCandidate(root string, candidates *[]growthCandidate, seen map[string]int, candidate growthCandidate, pressure growthPressure) *hyperError { + key := growthCandidateIdentity(candidate) + if index, ok := seen[key]; ok { + existing := (*candidates)[index] + if strongerOrEqualGrowthCandidate(existing, candidate) { + return nil + } + if err := writeGrowthCandidate(root, candidate, pressure); err != nil { + return err + } + (*candidates)[index] = candidate + return nil + } + if err := writeGrowthCandidate(root, candidate, pressure); err != nil { + return err + } + seen[key] = len(*candidates) + *candidates = append(*candidates, candidate) + return nil +} + +func strongerOrEqualGrowthCandidate(existing, candidate growthCandidate) bool { + existingRank := growthCandidateStatusRank(existing.Status) + candidateRank := growthCandidateStatusRank(candidate.Status) + if existingRank != candidateRank { + return existingRank > candidateRank + } + return existing.EvidenceCount >= candidate.EvidenceCount +} + +func growthCandidateStatusRank(status string) int { + switch status { + case "active": + return 4 + case "promotable": + return 3 + case "repeated": + return 2 + case "observed", "candidate": + return 1 + default: + return 0 + } +} + func validatorCandidatePrefix(pressure growthPressure) string { if pressure.PressureType != "surface_validation" { return "validator" @@ -998,7 +1023,8 @@ func retiredGrowthCandidates(root string, previous growthState, current []growth func harnessPressureReady(pressures []growthPressure) bool { stable := 0 hasValidation := false - nonValidationStructures := 0 + hasImplementation := false + hasWorkBoundary := false for _, pressure := range pressures { if pressure.GoalCount < growthRepeatedSignalGoals { continue @@ -1006,14 +1032,17 @@ func harnessPressureReady(pressures []growthPressure) bool { if pressure.Effect == "validation" { hasValidation = true } - if pressure.Effect == "implementation" || pressure.Effect == "work_boundary" { - nonValidationStructures++ + if pressure.Effect == "implementation" { + hasImplementation = true + } + if pressure.Effect == "work_boundary" { + hasWorkBoundary = true } if pressure.Effect == "validation" || pressure.Effect == "implementation" || pressure.Effect == "work_boundary" { stable++ } } - return hasValidation && nonValidationStructures >= 2 && stable >= growthHarnessStablePressures + return hasValidation && hasImplementation && hasWorkBoundary && stable >= growthHarnessStablePressures } func aggregateHarnessPressure(pressures []growthPressure) growthPressure { diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 96dfec8..58f8c77 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -729,7 +729,7 @@ func TestCompleteAcceptsValidationOutputForActiveValidator(t *testing.T) { mustInitWithPlan(t, root, "Tiny CLI", "Build a tiny CLI MVP") mustRun(t, root, "run") writeFile(t, filepath.Join(root, ".hyper", "capabilities", "active", "validator", "validator-go-test.md"), "# validator-go-test\n\nStatus: active\nKind: validator\nSignal: Run go test ./... before completing packets.\n") - writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\nCommand: `go test ./...`\n\nOutput:\n\n```text\nok ./...\n```\n\ngo test ./... passed.\n\n## Readiness Evidence\n\nCore UX: CLI smoke verified create and complete flow.\nValidation coverage: `go test ./...` passed and primary CLI smoke is repeatable.\n\n## Active Capability Evidence\n\nvalidator-go-test: Pending. Required behavior: Run go test ./... before completing packets.\n\n## Blocker\n\nNone blocking.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\nCommand: `go test ./...`\n\nOutput:\n\n```text\nok ./...\n```\n\n## Readiness Evidence\n\nCore UX: CLI smoke verified create and complete flow.\nValidation coverage: `go test ./...` passed and primary CLI smoke is repeatable.\n\n## Active Capability Evidence\n\nvalidator-go-test: Pending. Required behavior: Run go test ./... before completing packets.\n\n## Blocker\n\nNone blocking.\n") writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nReview stage advancement.\n") out, err := runCLI(args("complete"), testRoot(root), fakeUpdater{}) @@ -739,6 +739,21 @@ func TestCompleteAcceptsValidationOutputForActiveValidator(t *testing.T) { assertContains(t, out.Stdout, "Finish gate: passed") } +func TestCompleteRejectsFailedValidationOutputForActiveValidator(t *testing.T) { + root := t.TempDir() + mustInitWithPlan(t, root, "Tiny CLI", "Build a tiny CLI MVP") + mustRun(t, root, "run") + writeFile(t, filepath.Join(root, ".hyper", "capabilities", "active", "validator", "validator-go-test.md"), "# validator-go-test\n\nStatus: active\nKind: validator\nSignal: Run go test ./... before completing packets.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\nCommand: `go test ./...`\n\nOutput:\n\n```text\nFAIL ./...\n```\n\ngo test ./... failed.\n\n## Readiness Evidence\n\nCore UX: CLI smoke verified create and complete flow.\nValidation coverage: `go test ./...` failed and needs repair.\n\n## Active Capability Evidence\n\nvalidator-go-test: Pending. Required behavior: Run go test ./... before completing packets.\n\n## Blocker\n\nNone blocking.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nRepair the failing validation.\n") + + _, err := runCLI(args("complete"), testRoot(root), fakeUpdater{}) + if err == nil { + t.Fatal("failed validator output must not satisfy active validator proof") + } + assertContains(t, err.Message, "Record active capability evidence for: validator-go-test") +} + func TestCompleteAcceptsExplicitActiveCapabilityBlocker(t *testing.T) { root := t.TempDir() mustInitWithPlan(t, root, "Tiny CLI", "Build a tiny CLI MVP") @@ -1600,6 +1615,62 @@ func TestHarnessCandidateNeedsMultipleNonValidationStructures(t *testing.T) { } } +func TestHarnessCandidateNeedsImplementationAndBoundaryPressure(t *testing.T) { + pressures := []growthPressure{ + {Effect: "validation", GoalCount: 4, Sources: []string{"GOAL-0001", "GOAL-0002", "GOAL-0003", "GOAL-0004"}}, + {Effect: "work_boundary", GoalCount: 4, Sources: []string{"GOAL-0001", "GOAL-0002", "GOAL-0003", "GOAL-0004"}}, + {Effect: "work_boundary", GoalCount: 4, Sources: []string{"GOAL-0001", "GOAL-0002", "GOAL-0003", "GOAL-0004"}}, + } + if harnessPressureReady(pressures) { + t.Fatal("repeated decisions plus validation must not create a harness without implementation pressure") + } +} + +func TestDuplicateCommandCandidatesKeepStrongestLifecycle(t *testing.T) { + root := t.TempDir() + if err := ensureProjectLayout(root); err != nil { + t.Fatalf("layout failed: %v", err) + } + pressures := []growthPressure{ + { + Kind: "pattern", + PressureType: "repeated_validation", + Signal: "validation pattern: `./check.sh` passed.", + Effect: "validation", + State: "repeated", + GoalCount: 4, + MemoryCount: 4, + Sources: []string{"GOAL-0001", "GOAL-0002", "GOAL-0003", "GOAL-0004"}, + }, + { + Kind: "pattern", + PressureType: "repeated_validation", + Signal: "`./check.sh` passed as active validator smoke.", + Effect: "validation", + State: "repeated", + GoalCount: 2, + MemoryCount: 2, + Sources: []string{"GOAL-0005", "GOAL-0006"}, + }, + } + candidates, hyperErr := materializeGrowthCandidates(root, pressures, growthState{}) + if hyperErr != nil { + t.Fatalf("materialize candidates failed: %v", hyperErr) + } + if len(candidates) != 1 { + t.Fatalf("expected one deduped validator candidate, got %+v", candidates) + } + if candidates[0].Status != "active" { + t.Fatalf("expected strongest active validator to win, got %+v", candidates[0]) + } + if !exists(filepath.Join(root, ".hyper", "capabilities", "active", "validator", "validator-check-sh.md")) { + t.Fatal("active validator file should exist") + } + if exists(filepath.Join(root, ".hyper", "capabilities", "candidates", "validator", "validator-check-sh.md")) { + t.Fatal("weaker duplicate validator candidate should not overwrite active validator") + } +} + func TestHarnessCandidateRequiresEnoughSourceGoalsForActivation(t *testing.T) { twoGoalPressure := aggregateHarnessPressure([]growthPressure{ {Effect: "validation", GoalCount: 2, Sources: []string{"GOAL-0003", "GOAL-0004"}}, From 9112792f7ad80960b5670e145151dcfd68226ff5 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 22:37:34 +0900 Subject: [PATCH 35/52] Require command-specific validator proof --- internal/app/finish_gate.go | 59 ++++++++++++++++++++++++++++++++++--- internal/app/main_test.go | 15 ++++++++++ 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/internal/app/finish_gate.go b/internal/app/finish_gate.go index ea50dfe..410c5f1 100644 --- a/internal/app/finish_gate.go +++ b/internal/app/finish_gate.go @@ -135,11 +135,62 @@ func activeValidatorValidationCovers(capability activeCapability, evidenceText s if command == "" { return false } - validation := normalizeSentence(sectionBody(evidenceText, "Validation")) - if !strings.Contains(validation, command) || !credibleActiveCapabilityEvidence(validation) { - return false + for _, fragment := range validationCommandEvidenceFragments(sectionBody(evidenceText, "Validation"), command) { + validation := normalizeSentence(fragment) + if !strings.Contains(validation, command) || !credibleActiveCapabilityEvidence(validation) { + continue + } + if successfulValidationEvidence(validation) { + return true + } } - return successfulValidationEvidence(validation) + return false +} + +func validationCommandEvidenceFragments(body, command string) []string { + lines := strings.Split(body, "\n") + fragments := []string{} + current := []string{} + sawCommandBoundary := false + for _, line := range lines { + if validationCommandBoundary(line) && len(current) > 0 { + fragments = append(fragments, strings.Join(current, "\n")) + current = nil + } + if strings.TrimSpace(line) != "" || len(current) > 0 { + current = append(current, line) + } + if validationCommandBoundary(line) { + sawCommandBoundary = true + } + } + if len(current) > 0 { + fragments = append(fragments, strings.Join(current, "\n")) + } + if sawCommandBoundary { + return fragments + } + lineFragments := []string{} + for _, line := range strings.Split(body, "\n") { + trimmed := strings.TrimSpace(strings.TrimLeft(line, "-*0123456789. ")) + if trimmed == "" || isPlaceholder(trimmed) { + continue + } + if strings.Contains(normalizeSentence(trimmed), command) { + lineFragments = append(lineFragments, trimmed) + } + } + return lineFragments +} + +func validationCommandBoundary(line string) bool { + trimmed := strings.TrimSpace(strings.TrimLeft(line, "-*0123456789. ")) + normalized := strings.ToLower(trimmed) + return strings.HasPrefix(normalized, "command:") || + strings.HasPrefix(normalized, "$ ") || + strings.HasPrefix(normalized, "> ") || + strings.HasPrefix(normalized, "run:") || + strings.HasPrefix(normalized, "check:") } func successfulValidationEvidence(normalized string) bool { diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 58f8c77..47d798a 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -754,6 +754,21 @@ func TestCompleteRejectsFailedValidationOutputForActiveValidator(t *testing.T) { assertContains(t, err.Message, "Record active capability evidence for: validator-go-test") } +func TestCompleteRejectsFailedActiveValidatorWhenAnotherValidationPassed(t *testing.T) { + root := t.TempDir() + mustInitWithPlan(t, root, "Tiny CLI", "Build a tiny CLI MVP") + mustRun(t, root, "run") + writeFile(t, filepath.Join(root, ".hyper", "capabilities", "active", "validator", "validator-go-test.md"), "# validator-go-test\n\nStatus: active\nKind: validator\nSignal: Run go test ./... before completing packets.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\nCommand: `go test ./...`\n\nOutput:\n\n```text\nFAIL ./...\n```\n\ngo test ./... failed.\n\nCommand: `npm run build`\n\nOutput:\n\n```text\nbuilt in 120ms\n```\n\nnpm run build passed.\n\n## Readiness Evidence\n\nCore UX: CLI smoke verified create and complete flow.\nValidation coverage: `npm run build` passed, but `go test ./...` failed.\n\n## Active Capability Evidence\n\nvalidator-go-test: Pending. Required behavior: Run go test ./... before completing packets.\n\n## Blocker\n\nNone blocking.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nRepair the failing active validator.\n") + + _, err := runCLI(args("complete"), testRoot(root), fakeUpdater{}) + if err == nil { + t.Fatal("a different passing validation command must not satisfy a failed active validator") + } + assertContains(t, err.Message, "Record active capability evidence for: validator-go-test") +} + func TestCompleteAcceptsExplicitActiveCapabilityBlocker(t *testing.T) { root := t.TempDir() mustInitWithPlan(t, root, "Tiny CLI", "Build a tiny CLI MVP") From 73d12482507916ddcb6294b8b0bb3650a7634709 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 22:51:52 +0900 Subject: [PATCH 36/52] Group repeated validation by command --- internal/app/goal_state.go | 75 ++++++++++++++++++++++++++++++++++++-- internal/app/growth.go | 64 +++++++++++++++++++++++++++++++- internal/app/main_test.go | 58 +++++++++++++++++++++++++++++ internal/app/readiness.go | 2 +- 4 files changed, 192 insertions(+), 7 deletions(-) diff --git a/internal/app/goal_state.go b/internal/app/goal_state.go index 5e00dd7..1e4814b 100644 --- a/internal/app/goal_state.go +++ b/internal/app/goal_state.go @@ -442,15 +442,82 @@ func isPassiveNoChangeText(normalized string) bool { } func dedupeMemories(memories []memory) []memory { - seen := map[string]bool{} deduped := []memory{} for _, mem := range memories { - key := mem.Kind + "\x00" + mem.Text - if seen[key] { + duplicateIndex := -1 + for i, existing := range deduped { + if memoriesOverlap(existing, mem) { + duplicateIndex = i + break + } + } + if duplicateIndex >= 0 { + if memoryPreferred(mem, deduped[duplicateIndex]) { + deduped[duplicateIndex] = mem + } continue } - seen[key] = true deduped = append(deduped, mem) } return deduped } + +func memoriesOverlap(left, right memory) bool { + if !strings.EqualFold(strings.TrimSpace(left.Kind), strings.TrimSpace(right.Kind)) { + return false + } + leftSignal := memorySignal(left.Text) + rightSignal := memorySignal(right.Text) + if leftSignal == "" || rightSignal == "" { + return normalizeSentence(left.Text) == normalizeSentence(right.Text) + } + leftTokens := tokenSet(pressureTokens(leftSignal)) + rightTokens := tokenSet(pressureTokens(rightSignal)) + if len(leftTokens) == 0 || len(rightTokens) == 0 { + return normalizeSentence(leftSignal) == normalizeSentence(rightSignal) + } + if tokenJaccard(leftTokens, rightTokens) >= 0.82 { + return true + } + intersection := 0 + for token := range leftTokens { + if rightTokens[token] { + intersection++ + } + } + smaller := len(leftTokens) + if len(rightTokens) < smaller { + smaller = len(rightTokens) + } + return smaller > 0 && float64(intersection)/float64(smaller) >= 0.86 +} + +func memoryPreferred(candidate, existing memory) bool { + candidateRank := memoryQualityRank(candidate.Quality) + existingRank := memoryQualityRank(existing.Quality) + if candidateRank != existingRank { + return candidateRank > existingRank + } + if candidate.Confidence > existing.Confidence+0.01 { + return true + } + if existing.Confidence > candidate.Confidence+0.01 { + return false + } + return len(memorySignal(candidate.Text)) < len(memorySignal(existing.Text)) +} + +func memoryQualityRank(quality string) int { + switch strings.ToLower(strings.TrimSpace(quality)) { + case "durable": + return 3 + case "weak": + return 2 + case "one_off": + return 1 + case "passive": + return 0 + default: + return 1 + } +} diff --git a/internal/app/growth.go b/internal/app/growth.go index c710800..b0eddf3 100644 --- a/internal/app/growth.go +++ b/internal/app/growth.go @@ -192,7 +192,7 @@ func deriveGrowthPressures(records []memoryRecord) []growthPressure { kind := strings.ToLower(strings.TrimSpace(record.Kind)) pressureType, effect := growthClassification(kind, signal) canonical := canonicalPressureSignal(signal) - acc := findPressureAccumulator(accs, pressureType, canonical) + acc := findPressureAccumulator(accs, pressureType, canonical, signal) if acc == nil { acc = &pressureAccumulator{ kind: kind, @@ -203,6 +203,9 @@ func deriveGrowthPressures(records []memoryRecord) []growthPressure { goals: map[string]bool{}, } accs = append(accs, acc) + } else if growthSignalPreferred(signal, acc.signal) { + acc.signal = signal + acc.canonicalSignal = canonical } acc.memoryCount++ acc.goals[memoryGoalID(record.Text)] = true @@ -276,11 +279,18 @@ func readinessEvidenceContributesToGrowth(text string) bool { return strings.HasPrefix(rest, "validation coverage:") } -func findPressureAccumulator(accs []*pressureAccumulator, pressureType, canonical string) *pressureAccumulator { +func findPressureAccumulator(accs []*pressureAccumulator, pressureType, canonical, signal string) *pressureAccumulator { + command := "" + if pressureType == "repeated_validation" || pressureType == "surface_validation" { + command = normalizeSentence(inferredCommandForSignal(signal)) + } for _, acc := range accs { if acc.pressureType != pressureType { continue } + if command != "" && command == normalizeSentence(inferredCommandForSignal(acc.signal)) { + return acc + } if tokenJaccardString(acc.canonicalSignal, canonical) >= 0.72 { return acc } @@ -288,6 +298,41 @@ func findPressureAccumulator(accs []*pressureAccumulator, pressureType, canonica return nil } +func growthSignalPreferred(candidate, existing string) bool { + candidate = strings.TrimSpace(candidate) + existing = strings.TrimSpace(existing) + if candidate == "" { + return false + } + if existing == "" { + return true + } + candidateNormalized := normalizeSentence(candidate) + existingNormalized := normalizeSentence(existing) + candidateCoverage := strings.HasPrefix(candidateNormalized, "validation coverage:") + existingCoverage := strings.HasPrefix(existingNormalized, "validation coverage:") + if existingCoverage != candidateCoverage { + return existingCoverage && !candidateCoverage + } + candidateActionable := actionableGrowthSignal(candidateNormalized) + existingActionable := actionableGrowthSignal(existingNormalized) + if candidateActionable != existingActionable { + return candidateActionable + } + return len(candidate) < len(existing) +} + +func actionableGrowthSignal(normalized string) bool { + return hasAny(normalized, + "before every", + "before each", + "repeated validation path", + "smoke command", + "run ", + "use ", + ) +} + func growthScore(goalCount, memoryCount int) float64 { return float64(goalCount) + float64(memoryCount-goalCount)*0.25 } @@ -559,6 +604,7 @@ func growthBehaviorFromPressures(pressures []growthPressure) growthBehavior { ValidationSignals: []string{}, StopConditions: []string{}, } + seenValidation := map[string]bool{} for _, pressure := range pressures { switch pressure.Effect { case "work_boundary": @@ -572,8 +618,15 @@ func growthBehaviorFromPressures(pressures []growthPressure) growthBehavior { behavior.WorkBoundary = append(behavior.WorkBoundary, growthLine("Respect", pressure, "learned constraint")) } case "validation": + key := growthBehaviorValidationKey(pressure) + if key != "" && seenValidation[key] { + continue + } if len(behavior.ValidationSignals) < 3 { behavior.ValidationSignals = append(behavior.ValidationSignals, growthLine("Reuse", pressure, "validation pattern")) + if key != "" { + seenValidation[key] = true + } } case "stop_condition": if len(behavior.StopConditions) < 3 { @@ -584,6 +637,13 @@ func growthBehaviorFromPressures(pressures []growthPressure) growthBehavior { return behavior } +func growthBehaviorValidationKey(pressure growthPressure) string { + if command := normalizeSentence(inferredCommandForSignal(pressure.Signal)); command != "" { + return "command:" + command + } + return "signal:" + pressure.CanonicalSignal +} + func growthBehaviorWithActiveCapabilities(root string, pressures []growthPressure) (growthBehavior, *hyperError) { behavior := growthBehaviorFromPressures(pressures) validators, err := activeValidatorCapabilities(root) diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 47d798a..a261668 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -1508,6 +1508,29 @@ func TestGrowthTreatsKnownGapFailureAsImplementationPressure(t *testing.T) { } } +func TestGrowthGroupsRepeatedValidationByCommand(t *testing.T) { + pressures := deriveGrowthPressures([]memoryRecord{ + {Kind: "pattern", Text: "GOAL-0001 reusable patterns: Use `./check.sh` as the narrow local smoke command for the add/list flow.", Confidence: 0.75, Quality: "durable"}, + {Kind: "pattern", Text: "GOAL-0002 reusable patterns: Use `./check.sh` as the repeated validation path for add/list plus CLI edge states.", Confidence: 0.75, Quality: "durable"}, + }) + if len(pressures) != 1 { + t.Fatalf("expected same-command validation pressure to merge, got %+v", pressures) + } + if pressures[0].State != "repeated" || pressures[0].GoalCount != 2 { + t.Fatalf("expected repeated validation pressure across two goals, got %+v", pressures[0]) + } + if pressures[0].PressureType != "repeated_validation" { + t.Fatalf("expected repeated validation pressure, got %+v", pressures[0]) + } +} + +func TestErrorHandlingEvidenceAcceptsCLIInvalidCommandStates(t *testing.T) { + covered, _ := readinessEvidenceQuality("error_handling", "Missing argument and unknown command states are handled and verified.") + if !covered { + t.Fatal("CLI missing-argument and unknown-command evidence should cover error handling") + } +} + func TestSimilarContextIgnoresProtocolNoiseMemories(t *testing.T) { root := t.TempDir() if err := ensureProjectLayout(root); err != nil { @@ -1641,6 +1664,25 @@ func TestHarnessCandidateNeedsImplementationAndBoundaryPressure(t *testing.T) { } } +func TestGrowthBehaviorDedupesValidationSignalsByCommand(t *testing.T) { + behavior := growthBehaviorFromPressures([]growthPressure{ + { + Effect: "validation", + Signal: "Use `./check.sh` as the narrow local smoke command for the add/list flow.", + CanonicalSignal: "add check command flow list local narrow sh smoke use", + }, + { + Effect: "validation", + Signal: "Validation coverage: `./check.sh` passed and covers add, list, read-back, and `go test ./...`.", + CanonicalSignal: "add back check coverage covers go list passed read sh test validation", + }, + }) + if len(behavior.ValidationSignals) != 1 { + t.Fatalf("expected same-command validation signals to dedupe, got %+v", behavior.ValidationSignals) + } + assertContains(t, behavior.ValidationSignals[0], "./check.sh") +} + func TestDuplicateCommandCandidatesKeepStrongestLifecycle(t *testing.T) { root := t.TempDir() if err := ensureProjectLayout(root); err != nil { @@ -2719,6 +2761,22 @@ func TestLearnExtractsDurableSignals(t *testing.T) { assertNotContains(t, readFile(t, filepath.Join(root, ".hyper", "memories", "decisions.md")), "Changed Files") } +func TestLearnDedupesOverlappingSectionAndLearnSignals(t *testing.T) { + evidence := "# GOAL-0001 Evidence\n\n## Validation\n\n`./check.sh` passed.\n\n## Decisions\n\nKeep the first slice as a local CLI with file-backed persistence; avoid generated harnesses until repeated need appears.\n\n## Blocker\n\nNone blocking.\n" + next := "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nHandle empty state.\n\n## Learn Notes\n\n- Decision: Keep the first slice as a local CLI with file-backed persistence.\n- Constraint: Do not create harnesses until repeated evidence shows the project needs one.\n" + memories := memoriesForDerivedState(goalState{State: "completed", Reason: "done"}, "GOAL-0001", evidence, next) + + overlappingDecisions := 0 + for _, memory := range memories { + if memory.Kind == "decision" && strings.Contains(memory.Text, "first slice") { + overlappingDecisions++ + } + } + if overlappingDecisions != 1 { + t.Fatalf("expected one deduped overlapping decision, got %d in %+v", overlappingDecisions, memories) + } +} + func TestLearnIgnoresHyperRunMetaProgress(t *testing.T) { root := t.TempDir() mustInitWithPlan(t, root, "Tiny chat", "Build a tiny chat MVP") diff --git a/internal/app/readiness.go b/internal/app/readiness.go index d661d38..4e6a3ee 100644 --- a/internal/app/readiness.go +++ b/internal/app/readiness.go @@ -476,7 +476,7 @@ func readinessEvidenceQuality(axis, text string) (bool, string) { hasAny(normalized, "sqlite", "mysql", "postgres", "postgresql", "database", " db ", "db check", "sql", "localstorage", "local storage", "storage", "json", ".json", ".txt", "file", "disk", "filesystem"), "MySQL, SQLite, DB, file, JSON, reload, restart, storage, or database evidence" case "error_handling": - return hasAny(normalized, "empty", "error", "loading", "fallback", "failure", "edge") && + return hasAny(normalized, "empty", "error", "loading", "fallback", "failure", "edge", "missing argument", "missing input", "invalid input", "invalid command", "unknown command", "required field", "required input") && hasAny(normalized, "handled", "covered", "verified", "tested", "implemented", "works", "rejected", "proves", "proved", "passed"), "empty, loading, error, failure, fallback, or edge-state evidence" case "validation_coverage": From b9d3a6cc38e0780a9f5d58224631c6c8ea61fedb Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 23:10:13 +0900 Subject: [PATCH 37/52] Reduce runtime packet growth noise --- internal/app/growth.go | 65 ++++++++++++++++++++++++++++++++-- internal/app/main_test.go | 73 +++++++++++++++++++++++++++++++++++++++ internal/app/plan.go | 27 +++++++++++++++ internal/app/readiness.go | 5 +-- 4 files changed, 164 insertions(+), 6 deletions(-) diff --git a/internal/app/growth.go b/internal/app/growth.go index b0eddf3..6f73f5b 100644 --- a/internal/app/growth.go +++ b/internal/app/growth.go @@ -552,6 +552,13 @@ func isKnownImplementationGap(signal string) bool { "not built yet", "needs implementation", "needs recovery", + "remains incomplete", + "remain incomplete", + "remains minimal", + "remain minimal", + "remains thin", + "remain thin", + "remains for the next stage", ) } @@ -560,12 +567,29 @@ func isValidationPattern(signal string) bool { if strings.Contains(normalized, "readiness evidence:") && !strings.Contains(normalized, "validation coverage:") { return false } + if documentationPatternSignal(normalized) || referenceBenchmarkPatternSignal(normalized) { + return false + } if command := firstBacktickCommand(signal); looksLikeRuntimeCommand(command) && hasAny(normalized, "run", "check", "smoke", "validation", "handoff", "before every", "before each", "passed", "repeatable") { return true } return hasAny(normalized, "test", "build", "smoke", "validate", "validation", "playwright", "browser", "go test", "npm run", "pytest") } +func documentationPatternSignal(normalized string) bool { + if !hasAny(normalized, "readme", "docs", "documentation", "runbook", "operator handoff", "rollback") { + return false + } + return !hasAny(normalized, "passed", "validated", "verified", "check.sh", "go test", "npm run", "`./", "playwright") +} + +func referenceBenchmarkPatternSignal(normalized string) bool { + if !hasAny(normalized, "reference benchmark", "category baseline", "baseline gap", "stage advancement") { + return false + } + return !hasAny(normalized, "passed", "validated", "verified", "check.sh", "go test", "npm run", "`./", "playwright") +} + func looksLikeRuntimeCommand(command string) bool { fields := strings.Fields(strings.TrimSpace(command)) if len(fields) == 0 { @@ -604,18 +628,30 @@ func growthBehaviorFromPressures(pressures []growthPressure) growthBehavior { ValidationSignals: []string{}, StopConditions: []string{}, } + seenBoundary := map[string]bool{} seenValidation := map[string]bool{} for _, pressure := range pressures { switch pressure.Effect { case "work_boundary": + key := growthBehaviorBoundaryKey(pressure) + if key != "" && seenBoundary[key] { + continue + } if len(behavior.WorkBoundary) >= 4 { continue } + line := "" switch pressure.Kind { case "decision": - behavior.WorkBoundary = append(behavior.WorkBoundary, growthLine("Carry forward", pressure, "learned decision")) + line = growthLine("Carry forward", pressure, "learned decision") case "constraint": - behavior.WorkBoundary = append(behavior.WorkBoundary, growthLine("Respect", pressure, "learned constraint")) + line = growthLine("Respect", pressure, "learned constraint") + } + if line != "" { + behavior.WorkBoundary = append(behavior.WorkBoundary, line) + if key != "" { + seenBoundary[key] = true + } } case "validation": key := growthBehaviorValidationKey(pressure) @@ -637,6 +673,14 @@ func growthBehaviorFromPressures(pressures []growthPressure) growthBehavior { return behavior } +func growthBehaviorBoundaryKey(pressure growthPressure) string { + normalized := normalizeSentence(pressure.Signal) + if hasAny(normalized, "harness") && hasAny(normalized, "do not", "not create", "not add", "avoid", "without", "until repeated", "while one", "instead of adding") { + return "no-harness" + } + return pressure.Kind + ":" + pressure.CanonicalSignal +} + func growthBehaviorValidationKey(pressure growthPressure) string { if command := normalizeSentence(inferredCommandForSignal(pressure.Signal)); command != "" { return "command:" + command @@ -650,6 +694,23 @@ func growthBehaviorWithActiveCapabilities(root string, pressures []growthPressur if err != nil { return behavior, err } + activeCommands := map[string]bool{} + for _, validator := range validators { + if command := normalizeSentence(inferredCommandForSignal(validator.Signal)); command != "" { + activeCommands[command] = true + } + } + if len(activeCommands) > 0 { + filtered := behavior.ValidationSignals[:0] + for _, signal := range behavior.ValidationSignals { + command := normalizeSentence(inferredCommandForSignal(signal)) + if command != "" && activeCommands[command] { + continue + } + filtered = append(filtered, signal) + } + behavior.ValidationSignals = filtered + } seen := map[string]bool{} for _, signal := range behavior.ValidationSignals { seen[normalizeLabel(signal)] = true diff --git a/internal/app/main_test.go b/internal/app/main_test.go index a261668..432bc92 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -1508,6 +1508,22 @@ func TestGrowthTreatsKnownGapFailureAsImplementationPressure(t *testing.T) { } } +func TestGrowthTreatsRemainingGapFailureAsImplementationPressure(t *testing.T) { + pressures := deriveGrowthPressures([]memoryRecord{ + {Kind: "failure", Text: "GOAL-0003 learn failure: Operations docs and reference benchmark remain incomplete.", Confidence: 0.8, Quality: "durable"}, + }) + if len(pressures) != 1 { + t.Fatalf("expected one implementation pressure, got %+v", pressures) + } + if pressures[0].PressureType != "implementation_gap" || pressures[0].Effect != "implementation" { + t.Fatalf("expected remaining gap to become implementation pressure, got %+v", pressures[0]) + } + behavior := growthBehaviorFromPressures(pressures) + if len(behavior.StopConditions) != 0 { + t.Fatalf("remaining gap should not become a stop condition, got %+v", behavior.StopConditions) + } +} + func TestGrowthGroupsRepeatedValidationByCommand(t *testing.T) { pressures := deriveGrowthPressures([]memoryRecord{ {Kind: "pattern", Text: "GOAL-0001 reusable patterns: Use `./check.sh` as the narrow local smoke command for the add/list flow.", Confidence: 0.75, Quality: "durable"}, @@ -1683,6 +1699,37 @@ func TestGrowthBehaviorDedupesValidationSignalsByCommand(t *testing.T) { assertContains(t, behavior.ValidationSignals[0], "./check.sh") } +func TestGrowthBehaviorDedupesNoHarnessBoundaryPressure(t *testing.T) { + behavior := growthBehaviorFromPressures([]growthPressure{ + {Kind: "constraint", Effect: "work_boundary", Signal: "Do not create harnesses until repeated evidence shows the project needs one.", CanonicalSignal: "create do evidence harnesses needs not one project repeated shows until"}, + {Kind: "constraint", Effect: "work_boundary", Signal: "Do not add a harness while active validator promotion can cover the repeated smoke command.", CanonicalSignal: "active add command cover harness not promotion repeated smoke validator while"}, + {Kind: "constraint", Effect: "work_boundary", Signal: "Do not create a harness while one local smoke command still covers the required proof.", CanonicalSignal: "command covers create do harness local not one proof required smoke still while"}, + {Kind: "decision", Effect: "work_boundary", Signal: "Keep edge-state checks inside the same narrow smoke command instead of adding a separate harness.", CanonicalSignal: "adding checks command edge harness inside instead keep narrow same separate smoke state"}, + }) + if len(behavior.WorkBoundary) != 1 { + t.Fatalf("expected overlapping no-harness constraints to dedupe, got %+v", behavior.WorkBoundary) + } + assertContains(t, behavior.WorkBoundary[0], "harness") +} + +func TestActiveValidatorReplacesSameCommandValidationSignal(t *testing.T) { + root := t.TempDir() + if err := ensureProjectLayout(root); err != nil { + t.Fatalf("layout failed: %v", err) + } + writeFile(t, filepath.Join(root, ".hyper", "capabilities", "active", "validator", "validator-check-sh.md"), "# validator-check-sh\n\nStatus: active\nKind: validator\nSignal: Use `./check.sh` as the repeated validation path.\n") + behavior, hyperErr := growthBehaviorWithActiveCapabilities(root, []growthPressure{ + {Effect: "validation", Signal: "Use `./check.sh` as the narrow local smoke command.", CanonicalSignal: "check command local narrow sh smoke use"}, + }) + if hyperErr != nil { + t.Fatalf("growth behavior failed: %v", hyperErr) + } + if len(behavior.ValidationSignals) != 1 { + t.Fatalf("expected active validator to replace same-command reuse signal, got %+v", behavior.ValidationSignals) + } + assertContains(t, behavior.ValidationSignals[0], "Required active validator validator-check-sh") +} + func TestDuplicateCommandCandidatesKeepStrongestLifecycle(t *testing.T) { root := t.TempDir() if err := ensureProjectLayout(root); err != nil { @@ -1811,6 +1858,20 @@ func TestBacktickCodeSymbolDoesNotClassifyAsValidationCommand(t *testing.T) { } } +func TestDocumentationPatternDoesNotBecomeValidationSignal(t *testing.T) { + pressureType, effect := growthClassification("pattern", "Use README setup/build/rollback sections as the operator handoff for this local CLI.") + if pressureType != "implementation_pattern" || effect != "implementation" { + t.Fatalf("expected documentation handoff pattern to remain implementation pressure, got %s/%s", pressureType, effect) + } +} + +func TestReferenceBenchmarkPatternDoesNotBecomeValidationSignal(t *testing.T) { + pressureType, effect := growthClassification("pattern", "Use reference benchmark evidence to prevent stage advancement on validation alone.") + if pressureType != "implementation_pattern" || effect != "implementation" { + t.Fatalf("expected reference benchmark pattern to remain implementation pressure, got %s/%s", pressureType, effect) + } +} + func TestActiveValidatorBecomesRequiredValidationSignal(t *testing.T) { root := t.TempDir() mustInitWithPlan(t, root, "Tiny CLI", "Build a tiny CLI MVP") @@ -2162,6 +2223,13 @@ func TestReadinessEvidenceQualityRules(t *testing.T) { if naturalReferenceBenchmark.Status != "covered" { t.Fatalf("expected natural reference benchmark evidence to be covered, got %+v", naturalReferenceBenchmark) } + noneCriticalReferenceBenchmark, ok := parseReadinessEvidenceLine("GOAL-0001", "Reference benchmark: Category: Local CLI release-note tracker; References: git-chglog, standard-version, release-it; Baseline expectations: local entries, local data, repeatable smoke, setup docs, and rollback docs; Current comparison: this CLI meets baseline for local add/list, file persistence, validation, setup, and rollback; Below baseline gaps: none critical for the local-only CLI category; Above baseline strength: active validator promotion is evidence-driven; Decision: Service Quality is allowed because no core category-baseline gap remains.", defs) + if !ok { + t.Fatal("expected none-critical reference benchmark evidence to parse") + } + if noneCriticalReferenceBenchmark.Status != "covered" { + t.Fatalf("expected none-critical reference benchmark evidence to be covered, got %+v", noneCriticalReferenceBenchmark) + } errorHandling, ok := parseReadinessEvidenceLine("GOAL-0001", "Error handling: Covered. Empty, loading, error, fallback, and recovery states are handled for the primary path: missing profile fields, future birth date, incomplete daily log, empty report, and storage-disabled browser fallback. Playwright verified each state at 390x844.", defs) if !ok { t.Fatal("expected error handling evidence with missing input text to parse") @@ -2829,6 +2897,10 @@ func TestGoalStateIgnoresNoIssueBlockerAndFailureNotes(t *testing.T) { if kind != "" || value != "" { t.Fatalf("expected no-op failure learn note for this run to be ignored, got %q %q", kind, value) } + kind, value = parseLearnNote("- Failure: None critical for the local-only CLI category.") + if kind != "" || value != "" { + t.Fatalf("expected no-critical failure learn note to be ignored, got %q %q", kind, value) + } } func TestValidationMemoryPrefersCommandOverOutputLine(t *testing.T) { @@ -3422,6 +3494,7 @@ func TestGrowthIgnoresNoIssueAndNoChangeSignals(t *testing.T) { insertTestMemory(t, db, "failure", "GOAL-0003 learn failure: None in this episode.") insertRawTestMemory(t, db, "failure", "GOAL-0004 learn failure: None in this run.", "durable") insertRawTestMemory(t, db, "failure", "GOAL-0005 blocked: Clear: implementation and validation completed for this packet.", "durable") + insertRawTestMemory(t, db, "failure", "GOAL-0006 learn failure: None critical for the local-only CLI category.", "durable") state, hyperErr := updateGrowthState(root, db) if hyperErr != nil { diff --git a/internal/app/plan.go b/internal/app/plan.go index e880abd..6535473 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -545,6 +545,9 @@ func isNoIssueText(normalized string) bool { normalized == "no blockers remain" || normalized == "no failure" || normalized == "no failures" || + normalized == "none critical" || + normalized == "no critical gap" || + normalized == "no critical gaps" || normalized == "no failure in this episode" || normalized == "no failures in this episode" || normalized == "no failure in this run" || @@ -580,6 +583,10 @@ func isNoIssueText(normalized string) bool { strings.HasPrefix(normalized, "no failures for this episode") || strings.HasPrefix(normalized, "no failure in this run") || strings.HasPrefix(normalized, "no failures in this run") || + strings.HasPrefix(normalized, "none critical for") || + strings.HasPrefix(normalized, "no critical gap for") || + strings.HasPrefix(normalized, "no critical gaps for") || + strings.HasPrefix(normalized, "no core category-baseline gap") || strings.HasPrefix(normalized, "clear: implementation and validation completed") || strings.HasPrefix(normalized, "clear implementation and validation completed") || strings.HasPrefix(normalized, "implementation and validation completed for this packet") @@ -1010,10 +1017,19 @@ func stageRuntimeBehaviorDoc(stage, buildStyle string, readiness readinessState) func activeCapabilitiesDoc(growth growthState) string { candidates := visibleGrowthCandidates(growth.Candidates) lines := []string{} + requiredActiveValidators := map[string]bool{} + for _, signal := range growth.RuntimeBehavior.ValidationSignals { + if name := requiredActiveValidatorName(signal); name != "" { + requiredActiveValidators[name] = true + } + } for _, candidate := range candidates { if candidate.Status != "active" { continue } + if candidate.Kind == "validator" && requiredActiveValidators[candidate.Name] { + continue + } lines = append(lines, fmt.Sprintf("- Active %s %s: %s", candidate.Kind, displayGrowthCandidateName(candidate), compactText(candidate.Signal, 180))) } for _, signal := range growth.RuntimeBehavior.ValidationSignals { @@ -1027,6 +1043,17 @@ func activeCapabilitiesDoc(growth growthState) string { return strings.Join(lines, "\n") } +func requiredActiveValidatorName(signal string) string { + fields := strings.Fields(strings.TrimPrefix(strings.TrimSpace(signal), "- ")) + if len(fields) < 4 { + return "" + } + if fields[0] == "Required" && fields[1] == "active" && fields[2] == "validator" { + return strings.TrimSuffix(fields[3], ":") + } + return "" +} + func formatGrowthPrinciples() string { lines := []string{} for _, principle := range growthPrinciples() { diff --git a/internal/app/readiness.go b/internal/app/readiness.go index 4e6a3ee..2f41b33 100644 --- a/internal/app/readiness.go +++ b/internal/app/readiness.go @@ -719,11 +719,8 @@ func currentComparisonCovered(value string) bool { func noCriticalBelowBaselineGap(value string) bool { normalized := normalizeSentence(value) - if normalized == "none" { - return true - } if normalized == "" || isPlaceholder(normalized) { - return false + return normalized == "none" || strings.HasPrefix(normalized, "none critical") } return hasAny(normalized, "none", "no critical", "no core", "no below baseline", "no below-baseline", "not blocked", "none blocking") } From 844e5320f094625aed450717f157946778b6f7a9 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 23:21:50 +0900 Subject: [PATCH 38/52] Polish sustained runtime packet guidance --- internal/app/goal_state.go | 6 ++++++ internal/app/growth.go | 6 ++++++ internal/app/main_test.go | 25 +++++++++++++++++++++++++ internal/app/plan.go | 4 ++-- 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/internal/app/goal_state.go b/internal/app/goal_state.go index 1e4814b..efa85fe 100644 --- a/internal/app/goal_state.go +++ b/internal/app/goal_state.go @@ -420,6 +420,12 @@ func isHyperProtocolNoiseText(normalized string) bool { "do not run `hyper advance` unless the user accepts the stage advancement", "recommend updating plan.md current stage", "review readiness evidence, then run `hyper advance`", + "stage advancement is acceptable", + "stage advancement is allowed", + "allow stage advancement", + "service quality advancement is acceptable", + "service quality advancement is allowed", + "allow service quality advancement", ) } diff --git a/internal/app/growth.go b/internal/app/growth.go index 6f73f5b..c7d5899 100644 --- a/internal/app/growth.go +++ b/internal/app/growth.go @@ -678,6 +678,12 @@ func growthBehaviorBoundaryKey(pressure growthPressure) string { if hasAny(normalized, "harness") && hasAny(normalized, "do not", "not create", "not add", "avoid", "without", "until repeated", "while one", "instead of adding") { return "no-harness" } + if hasAny(normalized, "active validator", "validator") && hasAny(normalized, "keep", "until", "broader", "release check", "repeatedly proven") { + if command := normalizeSentence(inferredCommandForSignal(pressure.Signal)); command != "" { + return "active-validator:" + command + } + return "active-validator" + } return pressure.Kind + ":" + pressure.CanonicalSignal } diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 432bc92..305c082 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -1482,6 +1482,8 @@ func TestGrowthIgnoresStageAdvancementProtocolNoise(t *testing.T) { {Kind: "decision", Text: "GOAL-0001 learn decision: Preserve current stage in `plan.md`; stage advancement remains a recommendation pending user acceptance.", Confidence: 0.75, Quality: "durable"}, {Kind: "constraint", Text: "GOAL-0002 learn constraint: Do not edit `plan.md Current Stage` until the user accepts stage advancement.", Confidence: 0.75, Quality: "durable"}, {Kind: "decision", Text: "GOAL-0003 decisions: Do not edit `plan.md Current Stage` in this packet; stage advancement is a recommendation pending user acceptance.", Confidence: 0.75, Quality: "durable"}, + {Kind: "decision", Text: "GOAL-0004 learn decision: Service Quality advancement is allowed because no core category-baseline gap remains.", Confidence: 0.75, Quality: "durable"}, + {Kind: "decision", Text: "GOAL-0005 learn decision: Allow Service Quality advancement only because the local CLI has no critical category-baseline gap.", Confidence: 0.75, Quality: "durable"}, }) if len(pressures) != 0 { t.Fatalf("expected stage advancement protocol notes to stay out of growth pressure, got %+v", pressures) @@ -1490,6 +1492,14 @@ func TestGrowthIgnoresStageAdvancementProtocolNoise(t *testing.T) { if len(memories) != 0 { t.Fatalf("expected protocol note to stay out of memory, got %+v", memories) } + memories = appendMemoryIfUseful(nil, "decision", "GOAL-0004 learn decision: Service Quality advancement is allowed because no core category-baseline gap remains.", 0.75) + if len(memories) != 0 { + t.Fatalf("expected stage advancement allowed note to stay out of memory, got %+v", memories) + } + memories = appendMemoryIfUseful(nil, "decision", "GOAL-0005 learn decision: Allow Service Quality advancement only because the local CLI has no critical category-baseline gap.", 0.75) + if len(memories) != 0 { + t.Fatalf("expected allow stage advancement note to stay out of memory, got %+v", memories) + } } func TestGrowthTreatsKnownGapFailureAsImplementationPressure(t *testing.T) { @@ -1712,6 +1722,17 @@ func TestGrowthBehaviorDedupesNoHarnessBoundaryPressure(t *testing.T) { assertContains(t, behavior.WorkBoundary[0], "harness") } +func TestGrowthBehaviorDedupesActiveValidatorBoundaryPressure(t *testing.T) { + behavior := growthBehaviorFromPressures([]growthPressure{ + {Kind: "constraint", Effect: "work_boundary", Signal: "Keep `./check.sh` as the active validator until a broader release check is repeatedly proven.", CanonicalSignal: "active broader check keep proven release repeatedly sh until validator"}, + {Kind: "decision", Effect: "work_boundary", Signal: "Keep `./check.sh` as the only active validator until a broader release check is repeatedly proven.", CanonicalSignal: "active broader check keep only proven release repeatedly sh until validator"}, + }) + if len(behavior.WorkBoundary) != 1 { + t.Fatalf("expected overlapping active-validator boundaries to dedupe, got %+v", behavior.WorkBoundary) + } + assertContains(t, behavior.WorkBoundary[0], "active validator") +} + func TestActiveValidatorReplacesSameCommandValidationSignal(t *testing.T) { root := t.TempDir() if err := ensureProjectLayout(root); err != nil { @@ -3006,6 +3027,10 @@ func TestStageNormalizationUsesFirstNamedStage(t *testing.T) { } assertContains(t, sustained.NextPressure.RecommendedGoal, "Run active quality checks") assertNotContains(t, sustained.NextPressure.RecommendedGoal, "until active validation") + stageBehavior := stageRuntimeBehaviorDoc("Sustained Service Quality", "Go CLI", sustained) + assertNotContains(t, stageBehavior, "only recommend stage advancement") + executionContract := executionContractDoc("Sustained Service Quality", sustained, growthState{}) + assertNotContains(t, executionContract, "hyper advance") } func TestReferenceBenchmarkPressureShapesRuntimePacket(t *testing.T) { diff --git a/internal/app/plan.go b/internal/app/plan.go index 6535473..e842054 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -933,7 +933,7 @@ func executionContractDoc(stage string, readiness readinessState, growth growthS "- If validation fails twice for the same reason, stop and record the failure instead of broadening scope.", "- Close the packet with evidence.md, next.md, and `hyper complete`; do not create the next packet first.", } - if readiness.StageGate.Status == "ready" { + if readiness.StageGate.Advancement.Candidate { lines = append(lines, "- Gate-ready packets may recommend `hyper advance`, but must not silently change plan.md stage.") } if activeStructureCount(growth.Candidates) > 0 { @@ -1008,7 +1008,7 @@ func stageRuntimeBehaviorDoc(stage, buildStyle string, readiness readinessState) if readiness.NextPressure.AxisName != "" { lines = append(lines, "- This packet should move readiness pressure: "+readiness.NextPressure.AxisName) } - if readiness.StageGate.Status == "ready" { + if readiness.StageGate.Advancement.Candidate { lines = append(lines, "- Gate is ready; only recommend stage advancement, do not silently edit plan.md.") } return strings.Join(lines, "\n") From 03fe767655ce4056e5e418402ed71e5220bb15b8 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 23:41:28 +0900 Subject: [PATCH 39/52] Tighten service-quality packet evidence --- internal/app/finish_gate.go | 25 ++++++++++++++++++++++++- internal/app/main_test.go | 29 +++++++++++++++++++++++++++++ internal/app/plan.go | 12 ++++++++++-- internal/app/readiness.go | 8 +++++--- 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/internal/app/finish_gate.go b/internal/app/finish_gate.go index 410c5f1..243ef20 100644 --- a/internal/app/finish_gate.go +++ b/internal/app/finish_gate.go @@ -81,7 +81,30 @@ func readinessFinishGateFinding(state projectState, evidenceText string, readine return "" } } - return "Add covered readiness evidence for `" + axisName + "` or record a real blocker." + return "Add covered readiness evidence for `" + axisName + "`" + readinessFinishGateHint(axis) + " or record a real blocker." +} + +func readinessFinishGateHint(axis string) string { + switch axis { + case "core_ux": + return " (for CLI work, use evidence like `Core UX: CLI smoke passed for the primary run command and verified the expected output.`)" + case "validation_coverage": + return " (include the exact command and a passed, verified, or repeatable result)" + case "error_handling": + return " (name the empty, error, fallback, or edge state and how it was verified)" + case "security_baseline": + return " (name the security/privacy boundary and whether it was documented, verified, or implemented)" + case "deployment_readiness": + return " (name the build, artifact, URL, release, or isolated run path that was verified)" + case "operations_docs": + return " (name the README, runbook, setup, rollback, or smoke path that was documented)" + case "reference_benchmark": + return " (include category, 3-5 references, current comparison, baseline gaps, and decision)" + case "sustained_quality": + return " (name the active validator, active harness, or equivalent reusable quality structure)" + default: + return "" + } } func activeCapabilityFinishGateFinding(root, evidenceText string) string { diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 305c082..fa52c7a 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -2148,6 +2148,13 @@ func TestReadinessEvidenceQualityRules(t *testing.T) { if apiUX.Status != "covered" { t.Fatalf("expected API UX evidence to be covered, got %+v", apiUX) } + commandUX, ok := parseReadinessEvidenceLine("GOAL-0001", "Core UX: CLI smoke passed for the primary run command and verified the expected output.", defs) + if !ok { + t.Fatal("expected command UX evidence to parse") + } + if commandUX.Status != "covered" { + t.Fatalf("expected command UX evidence to be covered, got %+v", commandUX) + } apiProduct, ok := parseReadinessEvidenceLine("GOAL-0001", "Product completeness: A tiny notes API now has a measurable create-and-list flow: `POST /notes` creates one note and `GET /notes` returns it.", defs) if !ok { t.Fatal("expected API product evidence to parse") @@ -2155,6 +2162,13 @@ func TestReadinessEvidenceQualityRules(t *testing.T) { if apiProduct.Status != "covered" { t.Fatalf("expected API product evidence to be covered, got %+v", apiProduct) } + missingState, ok := parseReadinessEvidenceLine("GOAL-0001", "Error handling: Missing state is handled by creating the state file and the recovery command passed.", defs) + if !ok { + t.Fatal("expected missing-state error evidence to parse") + } + if missingState.Status != "covered" { + t.Fatalf("expected missing-state evidence to be covered, got %+v", missingState) + } inferred := inferReadinessEvidenceFromValidationLine("GOAL-0001", "`npm run check` passed.") if len(inferred) != 1 || inferred[0].Axis != "validation_coverage" || inferred[0].Status != "covered" { t.Fatalf("expected validation command to infer covered validation evidence, got %+v", inferred) @@ -2543,6 +2557,20 @@ func TestBroadFocusIsRewrittenThroughReadinessPressure(t *testing.T) { assertContains(t, goal, "- Current focus: 실서비스 수준으로 업그레이드") } +func TestSpecificServiceFocusIsNotOverRewritten(t *testing.T) { + root := t.TempDir() + mustInitWithPlan(t, root, "Tiny tasks", "Build a tiny task list MVP") + + focus := "Reduce service handoff friction without adding a harness" + if _, err := runCLI(args("run", focus), testRoot(root), fakeUpdater{}); err != nil { + t.Fatalf("run failed: %v", err) + } + + goal := readFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "goal.md")) + assertContains(t, goal, "## Current Episode\n\n"+focus) + assertNotContains(t, goal, "Translate `"+focus+"`") +} + func TestStageAdvancementCandidateWhenGateReady(t *testing.T) { root := t.TempDir() mustInitWithPlan(t, root, "Tiny tasks", "Build a tiny task list MVP") @@ -3253,6 +3281,7 @@ func TestReferenceBenchmarkEvidenceTemplateForBetaAndServiceQuality(t *testing.T assertContains(t, betaEvidence, "References: Pending") assertContains(t, betaEvidence, "Below-baseline gaps") assertContains(t, betaEvidence, "Above-baseline strength") + assertContains(t, betaEvidence, "- Decision: Pending. State whether Service Quality is allowed or blocked, and what the next pressure should be.\n\n## Active Capability Evidence") serviceEvidence := buildEvidenceDoc("GOAL-0001", "Service Quality", readinessState{}, growthState{}) assertContains(t, serviceEvidence, "## Reference Benchmark Evidence") diff --git a/internal/app/plan.go b/internal/app/plan.go index e842054..843c5aa 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -490,8 +490,16 @@ func runtimeObjective(focus string, plan map[string]string, stage, product strin func broadRuntimeFocus(focus string) bool { normalized := normalizeLabel(focus) + fields := strings.Fields(normalized) + if len(fields) > 5 && !hasAny(normalized, "실서비스", "서비스화") { + return false + } + serviceAction := hasAny(normalized, "production", "quality", "harden", "upgrade", "improve", "polish", "complete", "finish", "better") + if strings.Contains(normalized, "service") && !hasAny(normalized, "service quality", "service-level", "service ready", "service-ready", "production service") && !serviceAction { + return false + } return hasAny(normalized, - "service", "production", "quality", "harden", "upgrade", "improve", "polish", "complete", "finish", "better", + "service quality", "service-level", "service ready", "service-ready", "production service", "production", "quality", "harden", "upgrade", "improve", "polish", "complete", "finish", "better", "실서비스", "서비스", "품질", "고도화", "업그레이드", "완성", "개선", "베타", "프로덕션", ) } @@ -1110,7 +1118,7 @@ func buildTasksDoc(goalID, buildStyle, stage string, readiness readinessState, g } func buildEvidenceDoc(goalID, stage string, readiness readinessState, growth growthState) string { - return fmt.Sprintf("# %s Evidence\n\n## Validation\n\nPending.\n\n## Readiness Evidence\n\n%s\n\n## Surface Proof Evidence\n\n- Target surface: Pending.\n- Primary user action: Pending.\n- States checked: Pending.\n- Viewports: Pending.\n- Evidence: Pending.\n- Surface risks or gaps: Pending.\n\n%s## Active Capability Evidence\n\n%s\n\n## Pressure Signals\n\nPending.\n\n## Changed Files\n\nPending.\n\n## Decisions\n\nPending.\n\n## Reusable Patterns\n\nPending.\n\n## Learn Quality Gate\n\n- Keep as memory only if it should change future work boundary, validation, stop conditions, readiness, or capability candidates.\n- Do not record one-off progress, file lists, generic summaries, or \"none\" statements as Learn signals.\n\n## Blocker\n\nPending.\n\n## Notes\n\nPending.\n", goalID, readinessEvidenceTemplate(readiness), referenceBenchmarkEvidenceTemplate(stage, readiness), activeCapabilityEvidenceTemplate(growth)) + return fmt.Sprintf("# %s Evidence\n\n## Validation\n\nPending.\n\n## Readiness Evidence\n\n%s\n\n## Surface Proof Evidence\n\n- Target surface: Pending.\n- Primary user action: Pending.\n- States checked: Pending.\n- Viewports: Pending.\n- Evidence: Pending.\n- Surface risks or gaps: Pending.\n\n%s\n## Active Capability Evidence\n\n%s\n\n## Pressure Signals\n\nPending.\n\n## Changed Files\n\nPending.\n\n## Decisions\n\nPending.\n\n## Reusable Patterns\n\nPending.\n\n## Learn Quality Gate\n\n- Keep as memory only if it should change future work boundary, validation, stop conditions, readiness, or capability candidates.\n- Do not record one-off progress, file lists, generic summaries, or \"none\" statements as Learn signals.\n\n## Blocker\n\nPending.\n\n## Notes\n\nPending.\n", goalID, readinessEvidenceTemplate(readiness), referenceBenchmarkEvidenceTemplate(stage, readiness), activeCapabilityEvidenceTemplate(growth)) } func activeCapabilityEvidenceTemplate(growth growthState) string { diff --git a/internal/app/readiness.go b/internal/app/readiness.go index 2f41b33..e8bb9cf 100644 --- a/internal/app/readiness.go +++ b/internal/app/readiness.go @@ -333,9 +333,11 @@ func coreUXEvidenceCovered(normalized string) bool { if screenProof { return true } + actionProof := hasAny(normalized, "create", "list", "send", "complete", "read", "write", "post", "get", "run", "execute", "start", "invoke", "primary flow", "primary command", "run command") + resultProof := hasAny(normalized, "verified", "passed", "proved", "proven", "works", "test", "httptest", "smoke", "exit code 0", "output matched") apiOrCLIProof := hasAny(normalized, "api", "endpoint", "cli", "command", "http", "route") && - hasAny(normalized, "create", "list", "send", "complete", "read", "write", "post", "get", "primary flow") && - hasAny(normalized, "verified", "passed", "proved", "proven", "works", "test", "httptest", "smoke") + actionProof && + resultProof return apiOrCLIProof } @@ -476,7 +478,7 @@ func readinessEvidenceQuality(axis, text string) (bool, string) { hasAny(normalized, "sqlite", "mysql", "postgres", "postgresql", "database", " db ", "db check", "sql", "localstorage", "local storage", "storage", "json", ".json", ".txt", "file", "disk", "filesystem"), "MySQL, SQLite, DB, file, JSON, reload, restart, storage, or database evidence" case "error_handling": - return hasAny(normalized, "empty", "error", "loading", "fallback", "failure", "edge", "missing argument", "missing input", "invalid input", "invalid command", "unknown command", "required field", "required input") && + return hasAny(normalized, "empty", "error", "loading", "fallback", "failure", "edge", "missing argument", "missing input", "missing state", "missing file", "missing data", "corrupt", "corrupted", "invalid input", "invalid command", "unknown command", "required field", "required input") && hasAny(normalized, "handled", "covered", "verified", "tested", "implemented", "works", "rejected", "proves", "proved", "passed"), "empty, loading, error, failure, fallback, or edge-state evidence" case "validation_coverage": From 295cc25e914565a811fb5608290c6b17727cd447 Mon Sep 17 00:00:00 2001 From: jsPark Date: Tue, 26 May 2026 23:54:26 +0900 Subject: [PATCH 40/52] Clean up visual smoke candidate naming --- internal/app/growth.go | 20 ++++++++++++++++---- internal/app/main_test.go | 12 ++++++++++++ internal/app/status.go | 2 +- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/internal/app/growth.go b/internal/app/growth.go index c7d5899..5ce276e 100644 --- a/internal/app/growth.go +++ b/internal/app/growth.go @@ -1008,11 +1008,22 @@ func growthCandidateForPressure(kind, prefix, generatedDir, reason string, press func growthCandidateName(prefix string, pressure growthPressure) string { if command := inferredCommandForSignal(pressure.Signal); command != "" { - return prefix + "-" + slugify(command) + return growthCandidateNameForCommand(prefix, command) } return prefix + "-" + slugify(cleanCandidateSignal(pressure.Signal)) } +func growthCandidateNameForCommand(prefix, command string) string { + commandSlug := slugify(command) + if commandSlug == "" { + return prefix + } + if strings.HasSuffix(prefix, "-smoke") && commandSlug == "smoke-sh" { + return prefix + } + return prefix + "-" + commandSlug +} + func cleanCandidateSignal(signal string) string { cleaned := oneLine(signal) prefixes := []string{ @@ -1327,15 +1338,16 @@ func candidateEvidenceRequired(candidate growthCandidate, pressure growthPressur } func candidateRequiredBehavior(candidate growthCandidate, pressure growthPressure) string { + signal := compactText(cleanCandidateSignal(pressure.Signal), 160) switch candidate.Kind { case "validator": - return "Before `hyper complete`, prove this behavior or record why it is blocked: " + compactText(pressure.Signal, 160) + return "Before `hyper complete`, prove this behavior or record why it is blocked: " + signal case "skill": - return "Keep this implementation guidance in mind when the same pressure appears: " + compactText(pressure.Signal, 160) + return "Keep this implementation guidance in mind when the same pressure appears: " + signal case "harness": return "Only consolidate repeated validators, skills, and constraints after the project has enough evidence that the structure will be reused." default: - return compactText(pressure.Signal, 160) + return signal } } diff --git a/internal/app/main_test.go b/internal/app/main_test.go index fa52c7a..6e01156 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -1295,6 +1295,18 @@ func TestGrowthUsesShortCommandCandidateName(t *testing.T) { if name != "validator-npm-run-build" { t.Fatalf("expected short command candidate name, got %s", name) } + visualSmoke := growthCandidateName("validator-visual-smoke", growthPressure{Signal: "Pattern: For web packets, pair `./smoke.sh` with one browser viewport proof."}) + if visualSmoke != "validator-visual-smoke" { + t.Fatalf("expected visual smoke command name to avoid duplicate smoke suffix, got %s", visualSmoke) + } + candidate := growthCandidateForPressure("validator", "validator-visual-smoke", "validators", "Repeated surface proof pressure crossed the validator threshold.", growthPressure{ + Signal: "Pattern: For web packets, pair `./smoke.sh` with one browser viewport proof.", + PressureType: "surface_validation", + GoalCount: 2, + }) + behavior := candidateRequiredBehavior(candidate, growthPressure{Signal: "Pattern: For web packets, pair `./smoke.sh` with one browser viewport proof."}) + assertContains(t, behavior, "For web packets") + assertNotContains(t, behavior, "Pattern:") display := displayGrowthCandidateName(growthCandidate{ Name: "validator-visual-smoke-npm-run-check", Kind: "validator", diff --git a/internal/app/status.go b/internal/app/status.go index ed2095d..e544295 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -418,7 +418,7 @@ func displayGrowthCandidateName(candidate growthCandidate) string { name := strings.TrimSpace(candidate.Name) prefix := candidateDisplayPrefix(candidate) if command := inferredCommandForSignal(candidate.Signal); command != "" && prefix != "" { - return prefix + "-" + slugify(command) + return growthCandidateNameForCommand(prefix, command) } return firstNonBlank(name, candidate.Kind, "candidate") } From f91f14579d78f59c34e062498d0f295be812c4c2 Mon Sep 17 00:00:00 2001 From: jsPark Date: Wed, 27 May 2026 00:04:37 +0900 Subject: [PATCH 41/52] Refine auto mode broad focus routing --- internal/app/main_test.go | 17 +++++++++++++++++ internal/app/plan.go | 25 +++++++++++++++++++++---- internal/app/readiness.go | 2 +- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 6e01156..eb75bbc 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -2569,6 +2569,20 @@ func TestBroadFocusIsRewrittenThroughReadinessPressure(t *testing.T) { assertContains(t, goal, "- Current focus: 실서비스 수준으로 업그레이드") } +func TestLongServiceQualityFocusIsRewrittenThroughReadinessPressure(t *testing.T) { + root := t.TempDir() + mustInitWithPlan(t, root, "Tiny notes", "Build a tiny note CLI MVP") + + focus := "Keep upgrading this note CLI toward service quality" + if _, err := runCLI(args("run", "--auto", "--until", "service-quality", focus), testRoot(root), fakeUpdater{}); err != nil { + t.Fatalf("run failed: %v", err) + } + + goal := readFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "goal.md")) + assertContains(t, goal, "Translate `"+focus+"` into the smallest Tiny MVP step") + assertContains(t, goal, "- Current focus: "+focus) +} + func TestSpecificServiceFocusIsNotOverRewritten(t *testing.T) { root := t.TempDir() mustInitWithPlan(t, root, "Tiny tasks", "Build a tiny task list MVP") @@ -3044,6 +3058,9 @@ func TestStageNormalizationUsesFirstNamedStage(t *testing.T) { } goal := readinessRecommendedGoal(map[string]string{"Product": "Pickachat is a location-pinned chat web app."}, "Tiny MVP", "persistence") assertContains(t, goal, "primary Pickachat flow") + goal = readinessRecommendedGoal(map[string]string{"Product": "Hyper Auto Audit Sample 2 is a tiny local note CLI."}, "Tiny MVP", "core_ux") + assertContains(t, goal, "Hyper Auto Audit Sample 2 core flow") + assertNotContains(t, goal, "tiny local note CLI") sustained := deriveReadinessState(map[string]string{ "Current Stage": "Sustained Service Quality", diff --git a/internal/app/plan.go b/internal/app/plan.go index 843c5aa..3a76098 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -490,16 +490,33 @@ func runtimeObjective(focus string, plan map[string]string, stage, product strin func broadRuntimeFocus(focus string) bool { normalized := normalizeLabel(focus) + explicitQualityTarget := hasAny(normalized, + "service quality", + "service-level", + "service ready", + "service-ready", + "production service", + "production quality", + "production ready", + "production-ready", + "sustained service quality", + "toward service quality", + "to service quality", + "until service quality", + "실서비스", + "서비스화", + "서비스 수준", + ) fields := strings.Fields(normalized) - if len(fields) > 5 && !hasAny(normalized, "실서비스", "서비스화") { + if len(fields) > 5 && !explicitQualityTarget { return false } serviceAction := hasAny(normalized, "production", "quality", "harden", "upgrade", "improve", "polish", "complete", "finish", "better") - if strings.Contains(normalized, "service") && !hasAny(normalized, "service quality", "service-level", "service ready", "service-ready", "production service") && !serviceAction { + if strings.Contains(normalized, "service") && !explicitQualityTarget && !serviceAction { return false } - return hasAny(normalized, - "service quality", "service-level", "service ready", "service-ready", "production service", "production", "quality", "harden", "upgrade", "improve", "polish", "complete", "finish", "better", + return explicitQualityTarget || hasAny(normalized, + "production", "quality", "harden", "upgrade", "improve", "polish", "complete", "finish", "better", "실서비스", "서비스", "품질", "고도화", "업그레이드", "완성", "개선", "베타", "프로덕션", ) } diff --git a/internal/app/readiness.go b/internal/app/readiness.go index e8bb9cf..9e0801c 100644 --- a/internal/app/readiness.go +++ b/internal/app/readiness.go @@ -1097,7 +1097,7 @@ func readinessSustainedOngoingGoal(plan map[string]string) string { func readinessProductName(plan map[string]string) string { product := firstRuntimeValue(plan["Product"], "the product") if before, after, ok := strings.Cut(product, " is "); ok && strings.TrimSpace(before) != "" && strings.TrimSpace(after) != "" { - if len(strings.Fields(before)) <= 4 { + if len(strings.Fields(before)) <= 6 { return strings.TrimSpace(before) } } From 3dcf5673dbfc8599ddcdf5cdfcc90c0637f2c87c Mon Sep 17 00:00:00 2001 From: jsPark Date: Wed, 27 May 2026 00:23:59 +0900 Subject: [PATCH 42/52] Prevent repair from bypassing failed finish gate --- internal/app/commands.go | 15 +++++++ internal/app/finish_gate.go | 20 ++++++++++ internal/app/main_test.go | 80 +++++++++++++++++++++++++++++++++++++ internal/app/next_packet.go | 9 +++++ internal/app/repair.go | 16 ++++++++ internal/app/status.go | 6 +++ 6 files changed, 146 insertions(+) diff --git a/internal/app/commands.go b/internal/app/commands.go index 735b62e..03c95cb 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -330,6 +330,10 @@ func statusHyper(fsys fsRoot, args []string) (commandOutput, *hyperError) { } state = refreshStateFromPlanForStatus(root, state) derived := deriveCurrentGoalState(root, state.CurrentGoalID) + if failed, ok := failedFinishGateGoalState(root, state.CurrentGoalID); ok { + derived = failed + state.Status = "active" + } runs, goals := statusDBCounts(root) growth := growthStateForStatus(root) readiness := readinessStateForStatus(root, growth) @@ -508,6 +512,17 @@ func blockingActiveGoal(root string, state projectState) string { if strings.TrimSpace(state.CurrentGoalID) == "" { return "" } + if failed, ok := failedFinishGateGoalState(root, state.CurrentGoalID); ok { + return strings.Join([]string{ + "Current runtime packet has failed the finish gate: " + state.CurrentGoalID, + "Reason: " + failed.Reason, + "", + "Fix the same packet before creating another one:", + " update " + filepath.Join(hyperDir, "goals", state.CurrentGoalID, "evidence.md"), + " update " + filepath.Join(hyperDir, "goals", state.CurrentGoalID, "next.md"), + " hyper complete", + }, "\n") + } derived := deriveCurrentGoalState(root, state.CurrentGoalID) if strings.TrimSpace(state.Status) != "" && strings.TrimSpace(derived.State) != "" && state.Status != "active" && state.Status != derived.State { return strings.Join([]string{ diff --git a/internal/app/finish_gate.go b/internal/app/finish_gate.go index 243ef20..26f11a5 100644 --- a/internal/app/finish_gate.go +++ b/internal/app/finish_gate.go @@ -61,6 +61,26 @@ func runFinishGate(root string, state projectState, derived goalState, readiness return result, nil } +func failedFinishGateGoalState(root, goalID string) (goalState, bool) { + if strings.TrimSpace(goalID) == "" || finishGateReviewStatus(root, goalID) != "failed" { + return goalState{}, false + } + reviewPath := filepath.Join(hyperDir, "goals", goalID, "review.md") + return goalState{ + State: "active", + Reason: "Finish gate failed. Fix " + reviewPath + " findings, then run `hyper complete` again.", + }, true +} + +func finishGateReviewStatus(root, goalID string) string { + body := readIfExists(filepath.Join(root, hyperDir, "goals", goalID, "review.md")) + return strings.ToLower(strings.TrimSpace(firstLabelValue(body, "Status"))) +} + +func isFailedFinishGateReason(reason string) bool { + return strings.Contains(strings.ToLower(strings.TrimSpace(reason)), "finish gate failed") +} + func readinessFinishGateFinding(state projectState, evidenceText string, readiness readinessState) string { axis := strings.TrimSpace(readiness.NextPressure.Axis) axisName := strings.TrimSpace(readiness.NextPressure.AxisName) diff --git a/internal/app/main_test.go b/internal/app/main_test.go index eb75bbc..09ff6f7 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -620,6 +620,86 @@ func TestRunBlocksCompletedEvidenceBeforeFinishGate(t *testing.T) { } } +func TestRepairDoesNotBypassFailedFinishGate(t *testing.T) { + root := t.TempDir() + mustInitWithPlan(t, root, "Tiny notes", "Build a tiny notes MVP") + mustRun(t, root, "run") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\n`go test ./...` passed.\n\n## Readiness Evidence\n\nCore UX: flow exists.\nValidation coverage: `go test ./...` passed and is repeatable.\n\n## Blocker\n\nNone blocking.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nStart another packet.\n") + + if _, err := runCLI(args("complete"), testRoot(root), fakeUpdater{}); err == nil { + t.Fatal("expected finish gate failure") + } + review := readFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "review.md")) + assertContains(t, review, "Status: failed") + if status := finishGateReviewStatus(root, "GOAL-0001"); status != "failed" { + t.Fatalf("expected failed finish gate review status, got %q", status) + } + if _, ok := failedFinishGateGoalState(root, "GOAL-0001"); !ok { + t.Fatal("expected failed finish gate state to be visible") + } + + status, err := runCLI(args("status", "--short"), testRoot(root), fakeUpdater{}) + if err != nil { + t.Fatalf("status failed: %v", err) + } + assertContains(t, status.Stdout, "Finish gate failed") + assertNotContains(t, status.Stdout, "Next: hyper repair") + + repair, err := runCLI(args("repair"), testRoot(root), fakeUpdater{}) + if err != nil { + t.Fatalf("repair failed: %v", err) + } + assertContains(t, repair.Stdout, "State: no repair needed") + assertContains(t, repair.Stdout, "Finish gate failed") + state, hyperErr := readState(filepath.Join(root, ".hyper", "state.json")) + if hyperErr != nil { + t.Fatal(hyperErr) + } + if state.Status != "active" { + t.Fatalf("repair must not mark failed finish gate completed, got %s", state.Status) + } + + _, err = runCLI(args("run", "Start another packet"), testRoot(root), fakeUpdater{}) + if err == nil { + t.Fatal("expected failed finish gate to block another run") + } + assertContains(t, err.Message, "failed the finish gate") + + state.Status = "completed" + if err := writeJSON(filepath.Join(root, ".hyper", "state.json"), state); err != nil { + t.Fatal(err) + } + status, err = runCLI(args("status", "--short"), testRoot(root), fakeUpdater{}) + if err != nil { + t.Fatalf("status failed after legacy state write: %v", err) + } + assertContains(t, status.Stdout, "Finish gate failed") + assertNotContains(t, status.Stdout, "Next: hyper repair") + repair, err = runCLI(args("repair"), testRoot(root), fakeUpdater{}) + if err != nil { + t.Fatalf("legacy repair failed: %v", err) + } + assertContains(t, repair.Stdout, "State: repaired") + assertContains(t, repair.Stdout, "To: active") + assertContains(t, repair.Stdout, "Next action: hyper complete") + nextPacket := readFile(t, filepath.Join(root, ".hyper", "next-packet.md")) + assertContains(t, nextPacket, "Action: complete-current") + assertContains(t, nextPacket, "Command: hyper complete") + state, hyperErr = readState(filepath.Join(root, ".hyper", "state.json")) + if hyperErr != nil { + t.Fatal(hyperErr) + } + if state.Status != "active" { + t.Fatalf("legacy failed finish gate repair must restore active state, got %s", state.Status) + } + _, err = runCLI(args("run", "Start another packet"), testRoot(root), fakeUpdater{}) + if err == nil { + t.Fatal("expected failed finish gate to block another run even when state was marked completed") + } + assertContains(t, err.Message, "failed the finish gate") +} + func TestCompleteLearnsAndRefreshesReadiness(t *testing.T) { root := t.TempDir() mustInitWithPlan(t, root, "Tiny notes", "Build a tiny notes MVP") diff --git a/internal/app/next_packet.go b/internal/app/next_packet.go index 189eb2d..3180fb6 100644 --- a/internal/app/next_packet.go +++ b/internal/app/next_packet.go @@ -14,6 +14,13 @@ type plannedNextPacket struct { } func buildNextPacketPlan(state projectState, derived goalState, readiness readinessState, growth growthState) plannedNextPacket { + if derived.State == "active" { + return plannedNextPacket{ + Action: "complete-current", + Command: "hyper complete", + Reason: statusActionReason(state, derived, readiness, growth), + } + } if state.AutoContinue && runUntilReached(state, readiness) { return plannedNextPacket{ Action: "stop", @@ -89,6 +96,8 @@ func nextPacketGuard(plan plannedNextPacket) string { switch plan.Action { case "advance": return "Do not run `hyper advance` unless the user accepts the stage change." + case "complete-current": + return "Do not create a new runtime packet; fix the current packet evidence, next notes, and review findings before running `hyper complete`." case "run": return "Create the next runtime packet only after the current packet has passed the finish gate and completed." case "stop": diff --git a/internal/app/repair.go b/internal/app/repair.go index 2cbaa34..cde240a 100644 --- a/internal/app/repair.go +++ b/internal/app/repair.go @@ -30,6 +30,22 @@ func currentStateConsistency(root string, state projectState) stateConsistency { } } derived := deriveCurrentGoalState(root, goalID) + if failed, ok := failedFinishGateGoalState(root, goalID); ok { + consistent := projectStatus == "" || projectStatus == "active" + reason := failed.Reason + if !consistent { + reason = fmt.Sprintf("state.json says %s, but the finish gate failed; restore %s to active before continuing.", projectStatus, goalID) + } + return stateConsistency{ + HasState: true, + HasGoal: true, + ProjectStatus: projectStatus, + Derived: failed, + Consistent: consistent, + Repairable: !consistent, + Reason: reason, + } + } consistent := projectStatus == "" || projectStatus == derived.State repairable := !consistent && derived.State != "active" reason := "state.json matches the current runtime packet." diff --git a/internal/app/status.go b/internal/app/status.go index e544295..158e136 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -304,6 +304,9 @@ func statusActionReasonWithRefresh(state projectState, derived goalState, readin return "Project state needs refresh before trusting the next action: " + refresh.Reason } if derived.State == "active" { + if isFailedFinishGateReason(derived.Reason) { + return derived.Reason + } return "The current runtime packet is still open; evidence and next.md decide what the project learns." } if strings.TrimSpace(state.Status) != "" && strings.TrimSpace(state.Status) != strings.TrimSpace(derived.State) { @@ -333,6 +336,9 @@ func statusDoNotDoYetWithRefresh(state projectState, derived goalState, readines return "Do not advance or start another packet until `hyper migrate` refreshes growth and readiness state." } if derived.State == "active" { + if isFailedFinishGateReason(derived.Reason) { + return "Do not start another `hyper run`; fix review.md findings in the same packet and run `hyper complete` again." + } return "Do not start another `hyper run` until this packet is completed or blocked." } if strings.TrimSpace(state.Status) != "" && strings.TrimSpace(state.Status) != strings.TrimSpace(derived.State) { From 66385bb0476c5c21e7f9ffdbd95cec45e5b21e47 Mon Sep 17 00:00:00 2001 From: jsPark Date: Wed, 27 May 2026 00:27:03 +0900 Subject: [PATCH 43/52] Accept named CLI output smoke as core UX proof --- internal/app/main_test.go | 7 +++++++ internal/app/readiness.go | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 09ff6f7..6954cd0 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -2247,6 +2247,13 @@ func TestReadinessEvidenceQualityRules(t *testing.T) { if commandUX.Status != "covered" { t.Fatalf("expected command UX evidence to be covered, got %+v", commandUX) } + namedCommandUX, ok := parseReadinessEvidenceLine("GOAL-0001", "Core UX: CLI smoke passed for the primary greet command and verified the expected `Hello, Ada` output.", defs) + if !ok { + t.Fatal("expected named command UX evidence to parse") + } + if namedCommandUX.Status != "covered" { + t.Fatalf("expected named command UX evidence to be covered, got %+v", namedCommandUX) + } apiProduct, ok := parseReadinessEvidenceLine("GOAL-0001", "Product completeness: A tiny notes API now has a measurable create-and-list flow: `POST /notes` creates one note and `GET /notes` returns it.", defs) if !ok { t.Fatal("expected API product evidence to parse") diff --git a/internal/app/readiness.go b/internal/app/readiness.go index 9e0801c..c16aeb1 100644 --- a/internal/app/readiness.go +++ b/internal/app/readiness.go @@ -333,8 +333,8 @@ func coreUXEvidenceCovered(normalized string) bool { if screenProof { return true } - actionProof := hasAny(normalized, "create", "list", "send", "complete", "read", "write", "post", "get", "run", "execute", "start", "invoke", "primary flow", "primary command", "run command") - resultProof := hasAny(normalized, "verified", "passed", "proved", "proven", "works", "test", "httptest", "smoke", "exit code 0", "output matched") + actionProof := hasAny(normalized, "create", "list", "send", "complete", "read", "write", "post", "get", "run", "execute", "start", "invoke", "return", "returns", "returned", "print", "prints", "printed", "output", "primary flow", "primary command", "run command") + resultProof := hasAny(normalized, "verified", "passed", "proved", "proven", "works", "test", "httptest", "smoke", "exit code 0", "output matched", "expected output", "returned") apiOrCLIProof := hasAny(normalized, "api", "endpoint", "cli", "command", "http", "route") && actionProof && resultProof From a3cbd4343659fc6d448a03267337e27302ab75bc Mon Sep 17 00:00:00 2001 From: jsPark Date: Wed, 27 May 2026 00:39:21 +0900 Subject: [PATCH 44/52] Harden plan parsing and CLI error evidence --- internal/app/main_test.go | 43 +++++++++++++++++++++++++++++++++++++++ internal/app/plan.go | 8 ++++++-- internal/app/readiness.go | 2 +- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 6954cd0..01fdc59 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -2254,6 +2254,13 @@ func TestReadinessEvidenceQualityRules(t *testing.T) { if namedCommandUX.Status != "covered" { t.Fatalf("expected named command UX evidence to be covered, got %+v", namedCommandUX) } + missingNameError, ok := parseReadinessEvidenceLine("GOAL-0001", "Error handling: Missing name input is rejected with `missing name` and exit status 2, verified by CLI smoke.", defs) + if !ok { + t.Fatal("expected missing-name error evidence to parse") + } + if missingNameError.Status != "covered" { + t.Fatalf("expected missing-name error evidence to be covered, got %+v", missingNameError) + } apiProduct, ok := parseReadinessEvidenceLine("GOAL-0001", "Product completeness: A tiny notes API now has a measurable create-and-list flow: `POST /notes` creates one note and `GET /notes` returns it.", defs) if !ok { t.Fatal("expected API product evidence to parse") @@ -2527,6 +2534,42 @@ func TestPlanAliasesAcceptBriefAndSuccessSignals(t *testing.T) { } } +func TestParsePlanDoesNotLetBlankDuplicateTemplateOverrideContent(t *testing.T) { + plan := parsePlan(strings.Join([]string{ + "# Product Plan", + "", + "## Product", + "", + "TinyFlow CLI", + "", + "## Current Stage", + "", + "Usable MVP", + "", + "## Success Criteria", + "", + "`go test ./...` passes.", + "", + "## Product", + "", + "## Current Stage", + "", + "Tiny MVP", + "", + "## Success Criteria", + "", + }, "\n")) + if got := firstRuntimeValue(plan["Product"]); got != "TinyFlow CLI" { + t.Fatalf("expected first non-empty Product to survive duplicate blank heading, got %q", got) + } + if got := firstRuntimeValue(plan["Current Stage"]); got != "Usable MVP" { + t.Fatalf("expected first non-empty Current Stage to survive duplicate template heading, got %q", got) + } + if got := firstRuntimeValue(plan["Success Criteria"]); got != "`go test ./...` passes." { + t.Fatalf("expected Success Criteria to survive duplicate blank heading, got %q", got) + } +} + func TestPlanAliasesAcceptInlineFields(t *testing.T) { plan := parsePlan(`# Plan diff --git a/internal/app/plan.go b/internal/app/plan.go index 3a76098..fa199ea 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -64,13 +64,17 @@ Tiny MVP func parsePlan(body string) map[string]string { result := map[string]string{} current := "" + currentWritable := false for _, line := range strings.Split(body, "\n") { if strings.HasPrefix(line, "## ") { current = strings.TrimSpace(strings.TrimPrefix(line, "## ")) - result[current] = "" + if _, ok := result[current]; !ok { + result[current] = "" + } + currentWritable = firstRuntimeValue(result[current]) == "" continue } - if current != "" { + if current != "" && currentWritable { existing := result[current] if existing != "" { existing += "\n" diff --git a/internal/app/readiness.go b/internal/app/readiness.go index c16aeb1..a03828a 100644 --- a/internal/app/readiness.go +++ b/internal/app/readiness.go @@ -478,7 +478,7 @@ func readinessEvidenceQuality(axis, text string) (bool, string) { hasAny(normalized, "sqlite", "mysql", "postgres", "postgresql", "database", " db ", "db check", "sql", "localstorage", "local storage", "storage", "json", ".json", ".txt", "file", "disk", "filesystem"), "MySQL, SQLite, DB, file, JSON, reload, restart, storage, or database evidence" case "error_handling": - return hasAny(normalized, "empty", "error", "loading", "fallback", "failure", "edge", "missing argument", "missing input", "missing state", "missing file", "missing data", "corrupt", "corrupted", "invalid input", "invalid command", "unknown command", "required field", "required input") && + return hasAny(normalized, "empty", "error", "loading", "fallback", "failure", "edge", "missing argument", "missing input", "missing name", "missing required", "missing state", "missing file", "missing data", "corrupt", "corrupted", "invalid input", "invalid command", "unknown command", "required field", "required input") && hasAny(normalized, "handled", "covered", "verified", "tested", "implemented", "works", "rejected", "proves", "proved", "passed"), "empty, loading, error, failure, fallback, or edge-state evidence" case "validation_coverage": From 7f8db35632f379d114def79893de640d8d4862ea Mon Sep 17 00:00:00 2001 From: jsPark Date: Wed, 27 May 2026 00:54:02 +0900 Subject: [PATCH 45/52] Learn all repeated validation commands --- internal/app/goal_state.go | 53 +++++++++++++++++++++++++------------- internal/app/main_test.go | 30 +++++++++++++++++++++ 2 files changed, 65 insertions(+), 18 deletions(-) diff --git a/internal/app/goal_state.go b/internal/app/goal_state.go index efa85fe..5b7fe1e 100644 --- a/internal/app/goal_state.go +++ b/internal/app/goal_state.go @@ -45,7 +45,7 @@ func memoriesForDerivedState(state goalState, goalID, evidenceText, nextText str case "blocked": memories = appendMemoryIfUseful(memories, "failure", fmt.Sprintf("%s blocked: %s", goalID, state.Reason), 0.8) case "completed": - if validation := firstUsefulValidationMemory(sectionBody(evidenceText, "Validation")); validation != "" { + for _, validation := range usefulValidationMemories(sectionBody(evidenceText, "Validation")) { memories = appendMemoryIfUseful(memories, "pattern", fmt.Sprintf("%s validation pattern: %s", goalID, validation), 0.65) } case "waiting_user": @@ -79,7 +79,7 @@ func rejectedMemoryQualityCounts(goalID, evidenceText, nextText string) map[stri } countRejectedMemoryQuality(rejected, kind, fmt.Sprintf("%s learn %s: %s", goalID, kind, value), 0.7) } - if validation := firstNonPendingLine(sectionBody(evidenceText, "Validation")); validation != "" { + for _, validation := range usefulValidationMemories(sectionBody(evidenceText, "Validation")) { countRejectedMemoryQuality(rejected, "pattern", fmt.Sprintf("%s validation pattern: %s", goalID, validation), 0.65) } return rejected @@ -315,20 +315,47 @@ func memoryQualityIsIgnored(quality string) bool { } func firstUsefulValidationMemory(text string) string { + memories := usefulValidationMemories(text) + if len(memories) == 0 { + return "" + } + return memories[0] +} + +func usefulValidationMemories(text string) []string { command := "" + commandEmitted := false + seen := map[string]bool{} + memories := []string{} for _, line := range strings.Split(text, "\n") { trimmed := strings.TrimSpace(strings.TrimLeft(line, "-*0123456789. ")) - if command == "" { - command = firstBacktickCommand(trimmed) + if validationCommandBoundary(trimmed) { + if nextCommand := firstBacktickCommand(trimmed); nextCommand != "" { + command = nextCommand + commandEmitted = false + } } if usefulValidationSignal(trimmed) { + memory := trimmed if firstBacktickCommand(trimmed) == "" && command != "" { - return commandValidationMemory(command, trimmed) + if commandEmitted { + continue + } + memory = commandValidationMemory(command, trimmed) + commandEmitted = true + } else if firstBacktickCommand(trimmed) != "" { + command = firstBacktickCommand(trimmed) + commandEmitted = true + } + if !seen[memory] { + seen[memory] = true + memories = append(memories, memory) } - return trimmed + } else if command == "" { + command = firstBacktickCommand(trimmed) } } - return "" + return memories } func commandValidationMemory(command, outcome string) string { @@ -349,16 +376,6 @@ func commandValidationMemory(command, outcome string) string { } } -func firstNonPendingLine(text string) string { - for _, line := range strings.Split(text, "\n") { - trimmed := strings.TrimSpace(strings.TrimLeft(line, "-*0123456789. ")) - if trimmed != "" && !isPlaceholder(trimmed) { - return trimmed - } - } - return "" -} - func weakLearnSignal(kind, text string, confidence float64) bool { normalized := normalizeSentence(text) if normalized == "" { @@ -390,7 +407,7 @@ func usefulValidationSignal(text string) bool { ) hasOutcome := hasAny(normalized, "passed", "pass", "succeeded", "success", "verified", "checked", "captured", "built", - "failed", "blocked", "warning", "warn", + "created", "failed", "blocked", "warning", "warn", ) return hasTool && hasOutcome } diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 01fdc59..4ae58e1 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -3122,6 +3122,36 @@ func TestValidationMemoryPrefersCommandOverOutputLine(t *testing.T) { } } +func TestValidationMemoriesCaptureMultipleCommandBlocks(t *testing.T) { + validation := strings.Join([]string{ + "Command: `npm test`", + "", + "Output:", + "", + "```text", + "tiny-panel smoke passed", + "```", + "", + "Command: `npm run build`", + "", + "Output:", + "", + "```text", + "dist build created", + "```", + }, "\n") + memories := usefulValidationMemories(validation) + if len(memories) != 2 { + t.Fatalf("expected two validation memories, got %+v", memories) + } + if memories[0] != "`npm test` passed." { + t.Fatalf("expected npm test memory, got %+v", memories) + } + if memories[1] != "`npm run build` passed." { + t.Fatalf("expected npm run build memory, got %+v", memories) + } +} + func TestStatusDerivesCompletedForNoOpBlocker(t *testing.T) { root := t.TempDir() mustInitWithPlan(t, root, "Tiny tasks", "Build a tiny task list MVP") From d68647c82f542c5521b876a0b180f2e9bc2fed80 Mon Sep 17 00:00:00 2001 From: jsPark Date: Wed, 27 May 2026 01:02:18 +0900 Subject: [PATCH 46/52] Show all active quality structures in readiness --- internal/app/main_test.go | 10 ++++++++++ internal/app/readiness.go | 7 ++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 4ae58e1..ec3b656 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -3380,6 +3380,16 @@ func TestServiceQualityGateRequiresSustainedGrowthEvidence(t *testing.T) { if state.StageGate.Status != "ready" { t.Fatalf("active validator should unlock sustained quality gate, got %+v", state.StageGate) } + assertContains(t, readinessDimensionMap(state.Dimensions)["sustained_quality"].Evidence, "validator-go-test") + + growth = growthState{Candidates: []growthCandidate{ + {Kind: "validator", Name: "validator-npm-test", Status: "active"}, + {Kind: "validator", Name: "validator-npm-run-build", Status: "active"}, + }} + state = deriveReadinessState(plan, growth, evidence) + sustainedEvidence := readinessDimensionMap(state.Dimensions)["sustained_quality"].Evidence + assertContains(t, sustainedEvidence, "validator-npm-test") + assertContains(t, sustainedEvidence, "validator-npm-run-build") } func TestServiceQualityPressureFollowsGateOrderOverPlanMentions(t *testing.T) { diff --git a/internal/app/readiness.go b/internal/app/readiness.go index a03828a..1f71cfe 100644 --- a/internal/app/readiness.go +++ b/internal/app/readiness.go @@ -747,14 +747,19 @@ func sustainedQualityEvidenceCovered(normalized string) bool { } func sustainedQualityGrowthEvidence(growth growthState) (bool, bool, string) { + active := []string{} for _, candidate := range growth.Candidates { if candidate.Status != "active" { continue } if candidate.Kind == "validator" || candidate.Kind == "harness" { - return true, true, "Active " + candidate.Kind + " " + candidate.Name + " proves repeated quality pressure became required behavior." + active = append(active, candidate.Kind+" "+candidate.Name) } } + if len(active) > 0 { + sort.Strings(active) + return true, true, "Active quality structures prove repeated quality pressure became required behavior: " + strings.Join(active, ", ") + "." + } for _, candidate := range growth.Candidates { if candidate.Status == "promotable" || candidate.Status == "repeated" { return false, true, "Repeated quality pressure exists but has not become active required behavior yet: " + candidate.Name From 91588e910ce0d0e430b96a42700bfedfab501ca4 Mon Sep 17 00:00:00 2001 From: jsPark Date: Wed, 27 May 2026 01:13:37 +0900 Subject: [PATCH 47/52] Tighten Core UX readiness evidence --- internal/app/main_test.go | 57 ++++++++++++++++++++++++++++++++++++++- internal/app/readiness.go | 24 +++++++++++++++-- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/internal/app/main_test.go b/internal/app/main_test.go index ec3b656..afe1b8b 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -1315,7 +1315,7 @@ func TestGrowthStateChangesNextRuntimePacket(t *testing.T) { root := t.TempDir() mustInitWithPlan(t, root, "Tiny notes", "Build a local-first notes MVP") mustRun(t, root, "run") - writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\ngo test ./... passed.\n\n## Readiness Evidence\n\nProduct completeness: Tiny notes has a measurable local note command slice.\nCore UX: CLI smoke verified the primary note command surface.\nValidation coverage: go test ./... passed and is repeatable.\n\n## Changed Files\n\ncmd/notes.go\n\n## Decisions\n\nKeep local-first storage.\n\n## Reusable Patterns\n\nRun go test before every runtime packet handoff.\n\n## Blocker\n\nNone blocking.\n") + writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md"), "# GOAL-0001 Evidence\n\n## Validation\n\ngo test ./... passed.\n\n## Readiness Evidence\n\nProduct completeness: Tiny notes has a measurable local note command slice.\nCore UX: CLI smoke passed for the primary add/list note command and verified expected output.\nValidation coverage: go test ./... passed and is repeatable.\n\n## Changed Files\n\ncmd/notes.go\n\n## Decisions\n\nKeep local-first storage.\n\n## Reusable Patterns\n\nRun go test before every runtime packet handoff.\n\n## Blocker\n\nNone blocking.\n") writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nAdd note editing polish.\n\n## Learn Notes\n\n- Pattern: Run go test before every runtime packet handoff.\n- Constraint: Do not add external services without credentials.\n") mustRun(t, root, "complete") @@ -2233,6 +2233,13 @@ func TestReadinessEvidenceQualityRules(t *testing.T) { if strongUX.Status != "covered" { t.Fatalf("expected strong UX evidence to be covered, got %+v", strongUX) } + genericBuildUX, ok := parseReadinessEvidenceLine("GOAL-0001", "Core UX: Node smoke passed and build artifact was created.", defs) + if !ok { + t.Fatal("expected generic build UX evidence to parse") + } + if genericBuildUX.Status != "emerging" { + t.Fatalf("expected generic build evidence not to cover Core UX, got %+v", genericBuildUX) + } apiUX, ok := parseReadinessEvidenceLine("GOAL-0001", "Core UX: HTTP API test passed for create and list endpoints.", defs) if !ok { t.Fatal("expected API UX evidence to parse") @@ -2480,6 +2487,54 @@ func TestSurfaceProofEvidenceProgressesReadiness(t *testing.T) { assertContains(t, status.Stdout, "Proof: functional pending, surface covered, operational covered") } +func TestSurfaceRiskLinesDoNotBecomeReadinessEvidence(t *testing.T) { + riskLabels := []string{ + "- Surface risks or gaps: No pixel screenshot yet; visual harness remains a candidate only if visual regressions repeat.", + "- Surface risk: No pixel screenshot yet; visual harness remains a candidate only if visual regressions repeat.", + "- Surface gaps: No pixel screenshot yet; visual harness remains a candidate only if visual regressions repeat.", + } + for _, line := range riskLabels { + if records := inferReadinessEvidenceFromSurfaceLine("GOAL-0001", line); len(records) != 0 { + t.Fatalf("surface risk/gap line must not infer readiness evidence for %q, got %+v", line, records) + } + } + + records := inferReadinessEvidenceFromSurfaceLine("GOAL-0001", "- Evidence: Browser smoke verified the primary panel state at mobile and desktop viewports; screenshots captured and passed.") + axes := map[string]string{} + for _, record := range records { + axes[record.Axis] = record.Status + } + if axes["core_ux"] != "covered" { + t.Fatalf("expected positive surface evidence to cover Core UX, got %+v", records) + } + + records = inferReadinessEvidenceFromSurfaceLine("GOAL-0001", "- Evidence: Node smoke passed and build artifact was created.") + for _, record := range records { + if record.Axis == "core_ux" && record.Status == "covered" { + t.Fatalf("generic build smoke must not cover Core UX, got %+v", records) + } + } +} + +func TestGenericStaticBuildSurfaceEvidenceDoesNotCoverCoreUX(t *testing.T) { + root := t.TempDir() + goalDir := filepath.Join(root, ".hyper", "goals", "GOAL-0003") + if err := os.MkdirAll(goalDir, 0o755); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + writeFile(t, filepath.Join(goalDir, "evidence.md"), "# GOAL-0003 Evidence\n\n## Validation\n\nCommand: `npm test`\n\nOutput:\n\n```text\ntiny-panel smoke passed\n```\n\nCommand: `npm run build`\n\nOutput:\n\n```text\ndist build created\n```\n\n## Readiness Evidence\n\nValidation coverage: `npm test`, `npm run build`, and security/docs search passed.\n\n## Surface Proof Evidence\n\n- Target surface: static `dist/index.html` artifact.\n- Primary user action: Open panel, mark complete, and preserve status locally.\n- States checked: ready, saved, completed, storage fallback, docs, build artifact.\n- Evidence: Node smoke passed and build artifact was created.\n- Surface risks or gaps: No pixel screenshot yet; visual harness remains a candidate only if visual regressions repeat.\n") + + records, err := loadReadinessEvidence(root, readinessDimensionDefs()) + if err != nil { + t.Fatalf("load readiness failed: %v", err) + } + for _, record := range records { + if record.GoalID == "GOAL-0003" && record.Axis == "core_ux" && record.Status == "covered" { + t.Fatalf("generic static build evidence must not cover Core UX, got %+v", records) + } + } +} + func TestRepeatedSurfaceProofCreatesVisualSmokeCandidate(t *testing.T) { root := t.TempDir() if err := ensureProjectLayout(root); err != nil { diff --git a/internal/app/readiness.go b/internal/app/readiness.go index 1f71cfe..20d1545 100644 --- a/internal/app/readiness.go +++ b/internal/app/readiness.go @@ -273,6 +273,9 @@ func inferReadinessEvidenceFromValidationLine(goalID, line string) []readinessEv } func inferReadinessEvidenceFromSurfaceLine(goalID, line string) []readinessEvidenceRecord { + if !surfaceProofReadinessInferenceAllowed(line) { + return nil + } text := surfaceProofValue(line) if !usefulReadinessEvidence(text) || !looksLikeSurfaceProof(text) { return nil @@ -287,6 +290,20 @@ func inferReadinessEvidenceFromSurfaceLine(goalID, line string) []readinessEvide return records } +func surfaceProofReadinessInferenceAllowed(line string) bool { + text := oneLine(line) + label, _, ok := strings.Cut(text, ":") + if !ok { + return true + } + switch compactReadinessLabel(label) { + case "surfacerisksorgaps", "surfacerisk", "surfacegaps": + return false + default: + return true + } +} + func surfaceProofValue(line string) string { text := oneLine(line) if label, value, ok := strings.Cut(text, ":"); ok { @@ -328,8 +345,11 @@ func productCompletenessEvidenceCovered(normalized string) bool { } func coreUXEvidenceCovered(normalized string) bool { - screenProof := hasAny(normalized, "smoke", "screenshot", "browser", "verified", "passed") && - hasAny(normalized, "flow", "click", "create", "add", "edit", "complete", "delete", "send", "navigate", "reload", "primary action", "surface", "screen", "route", "state") + visualSurfaceProof := hasAny(normalized, "browser", "screenshot", "viewport", "mobile", "desktop", "screen", "surface", "user interface", "page", "form", "button", "panel", "route") + userActionProof := hasAny(normalized, "flow", "click", "create", "add", "edit", "complete", "delete", "send", "navigate", "reload", "primary action", "state") + screenProof := hasAny(normalized, "smoke", "screenshot", "browser", "verified", "passed", "checked", "captured") && + visualSurfaceProof && + userActionProof if screenProof { return true } From d89177b39d606f42fde6d895489db6beeeb92baa Mon Sep 17 00:00:00 2001 From: jsPark Date: Wed, 27 May 2026 01:15:28 +0900 Subject: [PATCH 48/52] Avoid substring Core UX proof matches --- internal/app/main_test.go | 7 +++++++ internal/app/readiness.go | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/app/main_test.go b/internal/app/main_test.go index afe1b8b..7b357d1 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -2240,6 +2240,13 @@ func TestReadinessEvidenceQualityRules(t *testing.T) { if genericBuildUX.Status != "emerging" { t.Fatalf("expected generic build evidence not to cover Core UX, got %+v", genericBuildUX) } + performanceBuildUX, ok := parseReadinessEvidenceLine("GOAL-0001", "Core UX: Performance smoke passed and build artifact was created.", defs) + if !ok { + t.Fatal("expected performance build UX evidence to parse") + } + if performanceBuildUX.Status != "emerging" { + t.Fatalf("expected performance build evidence not to cover Core UX, got %+v", performanceBuildUX) + } apiUX, ok := parseReadinessEvidenceLine("GOAL-0001", "Core UX: HTTP API test passed for create and list endpoints.", defs) if !ok { t.Fatal("expected API UX evidence to parse") diff --git a/internal/app/readiness.go b/internal/app/readiness.go index 20d1545..264a38e 100644 --- a/internal/app/readiness.go +++ b/internal/app/readiness.go @@ -345,7 +345,7 @@ func productCompletenessEvidenceCovered(normalized string) bool { } func coreUXEvidenceCovered(normalized string) bool { - visualSurfaceProof := hasAny(normalized, "browser", "screenshot", "viewport", "mobile", "desktop", "screen", "surface", "user interface", "page", "form", "button", "panel", "route") + visualSurfaceProof := hasAny(normalized, "browser", "screenshot", "viewport", "mobile", "desktop", "screen", "surface", "user interface", "page", "button", "panel", "route") userActionProof := hasAny(normalized, "flow", "click", "create", "add", "edit", "complete", "delete", "send", "navigate", "reload", "primary action", "state") screenProof := hasAny(normalized, "smoke", "screenshot", "browser", "verified", "passed", "checked", "captured") && visualSurfaceProof && From a343ae632154d84950b5a981c0b09179e7b89d4a Mon Sep 17 00:00:00 2001 From: jsPark Date: Wed, 27 May 2026 01:28:48 +0900 Subject: [PATCH 49/52] Block advancement on latest failure pressure --- internal/app/finish_gate.go | 16 +++++ internal/app/main_test.go | 135 ++++++++++++++++++++++++++++++++++-- internal/app/next_packet.go | 7 +- internal/app/readiness.go | 83 ++++++++++++++++++++-- 4 files changed, 229 insertions(+), 12 deletions(-) diff --git a/internal/app/finish_gate.go b/internal/app/finish_gate.go index 26f11a5..ea99b67 100644 --- a/internal/app/finish_gate.go +++ b/internal/app/finish_gate.go @@ -96,6 +96,12 @@ func readinessFinishGateFinding(state projectState, evidenceText string, readine } return "Add sustained quality evidence that records repeated runtime proof or a real blocker." } + if axis == "open_failure" { + if openFailureFinishGateCovered(evidenceText) { + return "" + } + return "Record validation that closes the latest failure pressure, or record a real blocker." + } for _, record := range records { if record.Axis == axis && record.Status == "covered" { return "" @@ -104,6 +110,16 @@ func readinessFinishGateFinding(state projectState, evidenceText string, readine return "Add covered readiness evidence for `" + axisName + "`" + readinessFinishGateHint(axis) + " or record a real blocker." } +func openFailureFinishGateCovered(evidenceText string) bool { + normalized := strings.ToLower(evidenceText) + if !hasNonPendingSection(evidenceText, "Validation") { + return false + } + hasFailureContext := hasAny(normalized, "failure", "failures", "failed write", "write error", "error handling", "rollback", "rolled back") + hasClosureProof := hasAny(normalized, "fixed", "closed", "resolved", "returned", "returns", "handled", "verified", "passed", "covered") + return hasFailureContext && hasClosureProof +} + func readinessFinishGateHint(axis string) string { switch axis { case "core_ux": diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 7b357d1..52e3f3c 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -294,7 +294,7 @@ func TestDoctorWarnsWhenNextPacketPlanIsStale(t *testing.T) { if err != nil { t.Fatalf("doctor failed: %v", err) } - assertContains(t, out.Stdout, "[WARN] Next packet plan: expected `hyper run \"Implement the smallest usable A tiny notes API core flow: the primary user flow\"`, found `hyper advance`; run `hyper migrate`") + assertContains(t, out.Stdout, "[WARN] Next packet plan: expected `hyper run 'Implement the smallest usable A tiny notes API core flow: the primary user flow'`, found `hyper advance`; run `hyper migrate`") } func TestDoctorDoesNotTrustNextPacketWhenRefreshIsNeeded(t *testing.T) { @@ -899,11 +899,11 @@ func TestRunAutoUntilPlansNextPacketAfterComplete(t *testing.T) { t.Fatalf("complete failed: %v", err) } assertContains(t, complete.Stdout, "Finish gate: passed") - assertContains(t, complete.Stdout, "Next action: hyper run --auto --until \"Service Quality\" \"Handle empty, failure, and edge states for the primary Tiny CRM flow.\"") + assertContains(t, complete.Stdout, "Next action: hyper run --auto --until 'Service Quality' 'Handle empty, failure, and edge states for the primary Tiny CRM flow.'") nextPlan := readFile(t, filepath.Join(root, ".hyper", "next-packet.md")) assertContains(t, nextPlan, "Mode: auto until Service Quality") assertContains(t, nextPlan, "Action: run") - assertContains(t, nextPlan, "Command: hyper run --auto --until \"Service Quality\"") + assertContains(t, nextPlan, "Command: hyper run --auto --until 'Service Quality'") assertContains(t, readFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "review.md")), "Status: passed") } @@ -1496,7 +1496,7 @@ func TestMigrateRefreshesNextPacketPlan(t *testing.T) { assertContains(t, out.Stdout, "Next packet plan: .hyper/next-packet.md (run)") nextPacket := readFile(t, filepath.Join(root, ".hyper", "next-packet.md")) assertContains(t, nextPacket, "Action: run") - assertContains(t, nextPacket, "Command: hyper run \"Implement the smallest usable A tiny notes API core flow: the primary user flow\"") + assertContains(t, nextPacket, "Command: hyper run 'Implement the smallest usable A tiny notes API core flow: the primary user flow'") assertNotContains(t, nextPacket, "Command: hyper advance") } @@ -2394,6 +2394,133 @@ func TestReadinessEvidenceQualityRules(t *testing.T) { } } +func TestLatestFailurePressureBlocksStageAdvancement(t *testing.T) { + plan := map[string]string{ + "Product": "Mini Notes API", + "MVP": "Create and list notes through HTTP endpoints.", + "Current Stage": "Usable MVP", + } + evidence := []readinessEvidenceRecord{ + readinessEvidenceRecordForAxis("GOAL-0001", "core_ux", "HTTP API test passed for create and list endpoints, proving the primary developer-facing request/response flow works."), + readinessEvidenceRecordForAxis("GOAL-0002", "persistence", "`notes.json` stores created notes and a fresh store re-read the note after reload."), + readinessEvidenceRecordForAxis("GOAL-0002", "error_handling", "Empty note input is rejected with HTTP 400 and verified by API smoke."), + readinessEvidenceRecordForAxis("GOAL-0002", "validation_coverage", "`go test ./...` passed and covers create/list, empty-note rejection, and file-backed reload."), + } + growth := growthState{Pressures: []growthPressure{ + { + Kind: "failure", + PressureType: "recurring_failure", + Signal: "File write errors are currently swallowed in `Store.Add`; future error handling should return persistence failures.", + Effect: "stop_condition", + State: "observed", + Sources: []string{"GOAL-0002"}, + }, + }} + + state := deriveReadinessState(plan, growth, evidence) + if state.StageGate.Status != "not_ready" { + t.Fatalf("expected latest failure pressure to block stage advancement, got %+v", state.StageGate) + } + if state.StageGate.Advancement.Candidate { + t.Fatalf("stage advancement must not be candidate with latest failure pressure: %+v", state.StageGate.Advancement) + } + if state.NextPressure.Axis != "open_failure" { + t.Fatalf("expected open failure pressure, got %+v", state.NextPressure) + } + if !strings.Contains(state.NextPressure.RecommendedGoal, "File write errors") { + t.Fatalf("expected next goal to name the failure, got %+v", state.NextPressure) + } +} + +func TestNextPacketRunCommandKeepsFullRecommendedGoal(t *testing.T) { + focus := "Fix or explicitly close the latest Mini Notes API failure: File write errors are currently swallowed in `Store.Add`; future error handling should return persistence failures." + state := projectState{ + Status: "completed", + Stage: "Usable MVP", + AutoContinue: true, + RunUntil: "Service Quality", + } + readiness := readinessState{ + Stage: "Usable MVP", + StageGate: readinessStageGate{ + CurrentStage: "Usable MVP", + NextStage: "Beta", + Status: "not_ready", + }, + NextPressure: readinessPressure{ + Axis: "open_failure", + Reason: "Latest evidence recorded an unresolved failure.", + RecommendedGoal: focus, + }, + } + + plan := buildNextPacketPlan(state, goalState{State: "completed"}, readiness, growthState{}) + if plan.Action != "run" { + t.Fatalf("expected run action, got %+v", plan) + } + if strings.Contains(plan.Command, "...") { + t.Fatalf("next-packet command must be executable and not ellipsized, got %q", plan.Command) + } + if !strings.Contains(plan.Command, "'Fix or explicitly close") || strings.Contains(plan.Command, "\"Fix or explicitly close") { + t.Fatalf("next-packet command should shell-quote focus with single quotes, got %q", plan.Command) + } + assertContains(t, plan.Command, "`Store.Add`") + assertContains(t, plan.Command, "future error handling should return persistence failures") +} + +func TestOpenFailureFinishGateAcceptsClosureEvidence(t *testing.T) { + evidence := "# GOAL-0003 Evidence\n\n## Validation\n\n`go test ./...` passed and covers file write failure handling.\n\n## Readiness Evidence\n\nError handling: File write failures are returned from `Store.Add`, failed writes are rolled back from memory, and API save failures return HTTP 500.\n\n## Blocker\n\nNone blocking.\n" + readiness := readinessState{NextPressure: readinessPressure{Axis: "open_failure", AxisName: "Open failure"}} + if finding := readinessFinishGateFinding(projectState{CurrentGoalID: "GOAL-0003"}, evidence, readiness); finding != "" { + t.Fatalf("expected open failure closure evidence to pass, got %q", finding) + } + + weak := "# GOAL-0003 Evidence\n\n## Validation\n\n`go test ./...` passed.\n\n## Readiness Evidence\n\nValidation coverage: tests passed.\n\n## Blocker\n\nNone blocking.\n" + if finding := readinessFinishGateFinding(projectState{CurrentGoalID: "GOAL-0003"}, weak, readiness); finding == "" { + t.Fatal("expected weak open failure closure evidence to fail") + } +} + +func TestStaleFailurePressureDoesNotBlockLaterCleanEvidence(t *testing.T) { + plan := map[string]string{ + "Product": "Mini Notes API", + "MVP": "Create and list notes through HTTP endpoints.", + "Current Stage": "Usable MVP", + } + evidence := []readinessEvidenceRecord{ + readinessEvidenceRecordForAxis("GOAL-0001", "core_ux", "HTTP API test passed for create and list endpoints, proving the primary developer-facing request/response flow works."), + readinessEvidenceRecordForAxis("GOAL-0002", "persistence", "`notes.json` stores created notes and a fresh store re-read the note after reload."), + readinessEvidenceRecordForAxis("GOAL-0003", "error_handling", "File write failures are returned as HTTP 500 and verified by API smoke."), + readinessEvidenceRecordForAxis("GOAL-0003", "validation_coverage", "`go test ./...` passed and covers create/list, persistence reload, and write failure handling."), + } + growth := growthState{Pressures: []growthPressure{ + { + Kind: "failure", + PressureType: "recurring_failure", + Signal: "File write errors are currently swallowed in `Store.Add`; future error handling should return persistence failures.", + Effect: "stop_condition", + State: "observed", + Sources: []string{"GOAL-0002"}, + }, + { + Kind: "pattern", + PressureType: "repeated_validation", + Signal: "Run `go test ./...` before every packet handoff.", + Effect: "validation", + State: "repeated", + Sources: []string{"GOAL-0003"}, + }, + }} + + state := deriveReadinessState(plan, growth, evidence) + if state.StageGate.Status != "ready" { + t.Fatalf("stale failure should not block after later clean evidence, got %+v", state.StageGate) + } + if !state.StageGate.Advancement.Candidate { + t.Fatalf("expected stage advancement candidate after later clean evidence, got %+v", state.StageGate.Advancement) + } +} + func TestBetaGateAcceptsStaticDeploymentAndRunbookEvidence(t *testing.T) { defs := readinessDimensionDefs() lines := []string{ diff --git a/internal/app/next_packet.go b/internal/app/next_packet.go index 3180fb6..fc9ddd4 100644 --- a/internal/app/next_packet.go +++ b/internal/app/next_packet.go @@ -2,7 +2,6 @@ package app import ( "path/filepath" - "strconv" "strings" ) @@ -37,7 +36,7 @@ func buildNextPacketPlan(state projectState, derived goalState, readiness readin } } if readiness.NextPressure.RecommendedGoal != "" { - command := "hyper run " + quoteCommandArg(compactText(readiness.NextPressure.RecommendedGoal, 160)) + command := "hyper run " + quoteCommandArg(readiness.NextPressure.RecommendedGoal) if state.AutoContinue { command = autoRunCommand(state, readiness.NextPressure.RecommendedGoal) } @@ -113,7 +112,7 @@ func autoRunCommand(state projectState, focus string) string { parts = append(parts, "--until", quoteCommandArg(state.RunUntil)) } if strings.TrimSpace(focus) != "" { - parts = append(parts, quoteCommandArg(compactText(focus, 160))) + parts = append(parts, quoteCommandArg(focus)) } return strings.Join(parts, " ") } @@ -145,5 +144,5 @@ func stageRank(stage string) int { } func quoteCommandArg(value string) string { - return strconv.Quote(value) + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" } diff --git a/internal/app/readiness.go b/internal/app/readiness.go index 264a38e..329c5e4 100644 --- a/internal/app/readiness.go +++ b/internal/app/readiness.go @@ -80,13 +80,13 @@ func readinessStateForStatus(root string, growth growthState) readinessState { func deriveReadinessState(plan map[string]string, growth growthState, evidence []readinessEvidenceRecord) readinessState { stage := normalizeRuntimeStage(firstRuntimeValue(plan["Current Stage"], "Tiny MVP")) dimensions := readinessDimensions(plan, growth, evidence) - gate := readinessGateForStage(stage, dimensions) + gate := readinessGateForStage(stage, dimensions, growth) return readinessState{ Version: readinessStateVersion, Stage: stage, Dimensions: dimensions, StageGate: gate, - NextPressure: selectReadinessPressure(plan, stage, dimensions, gate), + NextPressure: selectReadinessPressure(plan, stage, dimensions, gate, growth), } } @@ -909,7 +909,7 @@ func readinessCorpus(plan map[string]string, growth growthState) string { return strings.ToLower(strings.Join(parts, "\n")) } -func readinessGateForStage(stage string, dimensions []readinessDimension) readinessStageGate { +func readinessGateForStage(stage string, dimensions []readinessDimension, growth growthState) readinessStageGate { current, next, axes, evidence := readinessGateDefinition(stage) blocking := []string{} dims := readinessDimensionMap(dimensions) @@ -922,6 +922,9 @@ func readinessGateForStage(stage string, dimensions []readinessDimension) readin blocking = append(blocking, fmt.Sprintf("%s: %s", dim.Name, dim.Gap)) } } + if pressure, ok := latestOpenFailurePressure(growth); ok { + blocking = append(blocking, "Open failure pressure: "+pressure.Signal) + } status := "ready" if len(blocking) > 0 { status = "not_ready" @@ -1002,7 +1005,7 @@ func readinessGateDefinition(stage string) (string, string, []string, []string) []string{"Product and MVP slice are measurable.", "One core user flow works locally.", "Minimal validation evidence exists."} } -func selectReadinessPressure(plan map[string]string, stage string, dimensions []readinessDimension, gate readinessStageGate) readinessPressure { +func selectReadinessPressure(plan map[string]string, stage string, dimensions []readinessDimension, gate readinessStageGate, growth growthState) readinessPressure { dims := readinessDimensionMap(dimensions) if readinessPressureShouldFollowGateOrder(gate) { for _, axis := range gate.RequiredAxes { @@ -1025,6 +1028,9 @@ func selectReadinessPressure(plan map[string]string, stage string, dimensions [] } } } + if pressure, ok := latestOpenFailurePressure(growth); ok { + return readinessPressureForOpenFailure(plan, pressure, gate) + } if gate.CurrentStage == gate.NextStage { return readinessPressure{ Axis: "sustained_quality", @@ -1048,6 +1054,75 @@ func readinessPressureShouldFollowGateOrder(gate readinessStageGate) bool { return len(gate.RequiredAxes) > 0 } +func latestOpenFailurePressure(growth growthState) (growthPressure, bool) { + latest := latestGrowthSourceGoal(growth) + if latest == "" { + return growthPressure{}, false + } + for _, pressure := range growth.Pressures { + if pressureOpenFailure(pressure) && pressureHasSource(pressure, latest) { + return pressure, true + } + } + return growthPressure{}, false +} + +func latestGrowthSourceGoal(growth growthState) string { + latest := "" + for _, pressure := range growth.Pressures { + for _, source := range pressure.Sources { + if compareGoalID(source, latest) > 0 { + latest = source + } + } + } + return latest +} + +func compareGoalID(a, b string) int { + a = strings.TrimSpace(a) + b = strings.TrimSpace(b) + if a == b { + return 0 + } + if b == "" { + return 1 + } + if a == "" { + return -1 + } + if a > b { + return 1 + } + return -1 +} + +func pressureOpenFailure(pressure growthPressure) bool { + return pressure.Kind == "failure" || pressure.Effect == "stop_condition" || pressure.PressureType == "recurring_failure" +} + +func pressureHasSource(pressure growthPressure, source string) bool { + for _, candidate := range pressure.Sources { + if candidate == source { + return true + } + } + return false +} + +func readinessPressureForOpenFailure(plan map[string]string, pressure growthPressure, gate readinessStageGate) readinessPressure { + product := readinessProductName(plan) + return readinessPressure{ + Axis: "open_failure", + AxisName: "Open failure", + Status: firstRuntimeValue(pressure.State, "observed"), + Reason: "Latest evidence recorded an unresolved failure before the " + gate.CurrentStage + " -> " + gate.NextStage + " gate: " + pressure.Signal, + RecommendedGoal: "Fix or explicitly close the latest " + product + " failure: " + pressure.Signal, + WorkBoundary: "Do not advance stage while the latest packet left an unresolved failure pressure. Fix it, prove it, or record a real blocker.", + ValidationSignal: "Record validation that the failure is fixed, or record the blocker that prevents closure.", + } +} + func readinessPressureForDimension(plan map[string]string, stage string, dim readinessDimension, gate readinessStageGate) readinessPressure { if dim.ID == "" { dim = readinessDimension{ID: "maintainability", Name: "Maintainability", Status: "covered", Gap: "Keep the codebase easy to continue."} From 27e3d883d2823feaa2bae653c49fa2c7908e3012 Mon Sep 17 00:00:00 2001 From: jsPark Date: Wed, 27 May 2026 01:40:26 +0900 Subject: [PATCH 50/52] Clean up resolved growth pressures --- internal/app/growth.go | 69 +++++++++++++++++++++++++++++++++++++++ internal/app/main_test.go | 24 ++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/internal/app/growth.go b/internal/app/growth.go index 5ce276e..d325e44 100644 --- a/internal/app/growth.go +++ b/internal/app/growth.go @@ -235,6 +235,7 @@ func deriveGrowthPressures(records []memoryRecord) []growthPressure { Sources: sources, }) } + pressures = suppressResolvedFailurePressures(pressures, records) sort.Slice(pressures, func(i, j int) bool { if pressures[i].Score == pressures[j].Score { if pressures[i].Kind == pressures[j].Kind { @@ -262,12 +263,80 @@ func growthRecordAllowed(record memoryRecord) bool { if signal == "" || isNoisyGrowthSignal(signal) { return false } + if activeCapabilityExecutionSignal(signal) { + return false + } if quality == "weak" { return usefulValidationSignal(signal) || hasAny(normalizeSentence(signal), "before every", "before each", "repeatable") } return true } +func activeCapabilityExecutionSignal(signal string) bool { + normalized := normalizeSentence(signal) + return hasAny(normalized, "active validator", "active harness", "active capability") && + hasAny(normalized, "passed before packet handoff", "passed for goal", "ran for goal", "blocked because", "record active capability") +} + +func suppressResolvedFailurePressures(pressures []growthPressure, records []memoryRecord) []growthPressure { + filtered := make([]growthPressure, 0, len(pressures)) + for _, pressure := range pressures { + if pressureOpenFailure(pressure) && pressureResolvedByLaterMemory(pressure, records) { + continue + } + filtered = append(filtered, pressure) + } + return filtered +} + +func pressureResolvedByLaterMemory(pressure growthPressure, records []memoryRecord) bool { + latest := latestSourceGoal(pressure.Sources) + if latest == "" { + return false + } + for _, record := range records { + goalID := memoryGoalID(record.Text) + if compareGoalID(goalID, latest) <= 0 { + continue + } + signal := memorySignal(record.Text) + if failureClosureSignal(signal) && failureClosureOverlaps(pressure.Signal, signal) { + return true + } + } + return false +} + +func latestSourceGoal(sources []string) string { + latest := "" + for _, source := range sources { + if compareGoalID(source, latest) > 0 { + latest = source + } + } + return latest +} + +func failureClosureSignal(signal string) bool { + normalized := normalizeSentence(signal) + if hasAny(normalized, "not fixed", "not resolved", "not closed", "still open") { + return false + } + return hasAny(normalized, "closed by", "is closed", "now closed", "resolved", "fixed", "no longer", "now handled", "now configurable") +} + +func failureClosureOverlaps(failure, closure string) bool { + failureTokens := tokenSet(pressureTokens(failure)) + closureTokens := tokenSet(pressureTokens(closure)) + overlap := 0 + for token := range failureTokens { + if closureTokens[token] { + overlap++ + } + } + return overlap >= 2 || tokenJaccard(failureTokens, closureTokens) >= 0.35 +} + func readinessEvidenceContributesToGrowth(text string) bool { normalized := normalizeSentence(text) prefix := "readiness evidence:" diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 52e3f3c..ee7d27e 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -1626,6 +1626,30 @@ func TestGrowthTreatsRemainingGapFailureAsImplementationPressure(t *testing.T) { } } +func TestGrowthIgnoresActiveValidatorPassAsNewValidationPressure(t *testing.T) { + pressures := deriveGrowthPressures([]memoryRecord{ + {Kind: "pattern", Text: "GOAL-0005 pressure signals: Active validator `validator-go-test` passed before packet handoff.", Confidence: 0.7, Quality: "weak"}, + {Kind: "pattern", Text: "GOAL-0006 pressure signals: Active validator `validator-go-test` passed before packet handoff.", Confidence: 0.7, Quality: "weak"}, + {Kind: "pattern", Text: "GOAL-0007 pressure signals: Active validator `validator-go-test` passed before packet handoff.", Confidence: 0.7, Quality: "weak"}, + }) + if len(pressures) != 0 { + t.Fatalf("active validator execution evidence should not create a new validator pressure, got %+v", pressures) + } +} + +func TestGrowthSuppressesFailurePressureClosedByLaterEvidence(t *testing.T) { + pressures := deriveGrowthPressures([]memoryRecord{ + {Kind: "failure", Text: "GOAL-0005 learn failure: Fixed port `:8080` is a deployment friction for future operations.", Confidence: 0.8, Quality: "durable"}, + {Kind: "failure", Text: "GOAL-0006 learn failure: Fixed port `:8080` remains deployment/operations friction.", Confidence: 0.8, Quality: "durable"}, + {Kind: "pattern", Text: "GOAL-0007 pressure signals: Fixed port deployment friction is closed by `MINIAPI_ADDR`.", Confidence: 0.75, Quality: "durable"}, + }) + for _, pressure := range pressures { + if pressureOpenFailure(pressure) && strings.Contains(pressure.Signal, "Fixed port") { + t.Fatalf("resolved fixed-port failure should not remain as open pressure, got %+v", pressures) + } + } +} + func TestGrowthGroupsRepeatedValidationByCommand(t *testing.T) { pressures := deriveGrowthPressures([]memoryRecord{ {Kind: "pattern", Text: "GOAL-0001 reusable patterns: Use `./check.sh` as the narrow local smoke command for the add/list flow.", Confidence: 0.75, Quality: "durable"}, From 31c020619b068ddf34b3fed81ec2c26063113826 Mon Sep 17 00:00:00 2001 From: jsPark Date: Wed, 27 May 2026 02:06:14 +0900 Subject: [PATCH 51/52] Ignore no-op blocker memories --- internal/app/main_test.go | 50 +++++++++++++++++ internal/app/migrate.go | 112 ++++++++++++++++++++++++++++++++++++++ internal/app/plan.go | 9 +++ 3 files changed, 171 insertions(+) diff --git a/internal/app/main_test.go b/internal/app/main_test.go index ee7d27e..3f48063 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -1540,6 +1540,48 @@ func TestMigrateRefreshesLegacyMemoryQualityFixture(t *testing.T) { } } +func TestMigrateStalesNoOpMemoriesAndRewritesMarkdown(t *testing.T) { + root := t.TempDir() + mustInitWithPlan(t, root, "Tiny Ledger", "Build a tiny ledger CLI") + db, err := openDB(root) + if err != nil { + t.Fatalf("db open failed: %v", err) + } + defer db.Close() + if err := ensureSchema(db); err != nil { + t.Fatalf("schema failed: %v", err) + } + insertRawTestMemory(t, db, "failure", "GOAL-0001 learn failure: No new failure; previous distribution pressure is closed by the wrapper.", "durable") + insertRawTestMemory(t, db, "failure", "GOAL-0002 blocked: None for this packet. The command-style wrapper closes the previous distribution pressure inside the current MVP boundary.", "durable") + insertRawTestMemory(t, db, "failure", "GOAL-0003 learn failure: Missing API key blocks release smoke.", "durable") + writeFile(t, filepath.Join(root, ".hyper", "memories", "failures.md"), strings.Join([]string{ + "# Failures", + "", + "- [durable] GOAL-0001 learn failure: No new failure; previous distribution pressure is closed by the wrapper.", + "- [durable] GOAL-0002 blocked: None for this packet. The command-style wrapper closes the previous distribution pressure inside the current MVP boundary.", + "- [durable] GOAL-0003 learn failure: Missing API key blocks release smoke.", + "", + }, "\n")) + + out, err := runCLI(args("migrate"), testRoot(root), fakeUpdater{}) + if err != nil { + t.Fatalf("migrate failed: %v", err) + } + assertContains(t, out.Stdout, "Learn quality gate: staled 2 noisy memory record(s)") + failures := readFile(t, filepath.Join(root, ".hyper", "memories", "failures.md")) + assertNotContains(t, failures, "No new failure") + assertNotContains(t, failures, "None for this packet") + assertContains(t, failures, "Missing API key blocks release smoke") + + var activeNoop int + if err := db.QueryRow(`select count(*) from memories where stale_at is null and (text like '%No new failure%' or text like '%None for this packet%')`).Scan(&activeNoop); err != nil { + t.Fatalf("count active no-op memories failed: %v", err) + } + if activeNoop != 0 { + t.Fatalf("expected no active no-op memory records, got %d", activeNoop) + } +} + func TestGrowthIgnoresPassiveReadinessProofAsSkillCandidate(t *testing.T) { root := t.TempDir() if err := ensureProjectLayout(root); err != nil { @@ -3303,6 +3345,10 @@ func TestGoalStateIgnoresNoIssueBlockerAndFailureNotes(t *testing.T) { if completed.State != "completed" { t.Fatalf("expected user-deferred stage note with evidence to complete, got %+v", completed) } + completed = deriveGoalState("## Validation\n\nWrapper smoke passed.\n\n## Blocker\n\nNone for this packet. The command-style wrapper closes the previous distribution pressure inside the current MVP boundary.\n", "## Recommended Next Goal\n\nReview stage advancement.\n") + if completed.State != "completed" { + t.Fatalf("expected none-for-this-packet blocker text to complete, got %+v", completed) + } waiting := deriveGoalState("## Blocker\n\nWaiting for user approval before stage advancement.\n", "") if waiting.State != "waiting_user" { t.Fatalf("expected user decision blocker to wait for user, got %+v", waiting) @@ -3319,6 +3365,10 @@ func TestGoalStateIgnoresNoIssueBlockerAndFailureNotes(t *testing.T) { if kind != "" || value != "" { t.Fatalf("expected no-critical failure learn note to be ignored, got %q %q", kind, value) } + kind, value = parseLearnNote("- Failure: No new failure; previous distribution pressure is closed by the wrapper.") + if kind != "" || value != "" { + t.Fatalf("expected no-new-failure learn note to be ignored, got %q %q", kind, value) + } } func TestValidationMemoryPrefersCommandOverOutputLine(t *testing.T) { diff --git a/internal/app/migrate.go b/internal/app/migrate.go index 3add70b..364a01a 100644 --- a/internal/app/migrate.go +++ b/internal/app/migrate.go @@ -24,6 +24,15 @@ func migrateHyper(fsys fsRoot) (commandOutput, *hyperError) { if err != nil { return commandOutput{}, err } + staledMemories, err := staleNoisyMemoryRecords(db) + if err != nil { + return commandOutput{}, err + } + if staledMemories > 0 { + if err := rewriteMemoryMarkdownFiles(root, db); err != nil { + return commandOutput{}, err + } + } before := readGrowthStateIfExists(root) growth, err := updateGrowthState(root, db) if err != nil { @@ -63,6 +72,7 @@ func migrateHyper(fsys fsRoot) (commandOutput, *hyperError) { "Hyper Run Migration", "", fmt.Sprintf("Learn quality gate: refreshed %d legacy memory quality value(s)", refreshedMemories), + fmt.Sprintf("Learn quality gate: staled %d noisy memory record(s)", staledMemories), "Growth state: refreshed", fmt.Sprintf("Visible pressures: %d -> %d", visibleGrowthPressureCount(before.Pressures), visibleGrowthPressureCount(growth.Pressures)), fmt.Sprintf("Visible candidates: %d -> %d", visibleGrowthCandidateCount(before.Candidates), visibleGrowthCandidateCount(growth.Candidates)), @@ -108,6 +118,108 @@ func refreshLegacyMemoryQuality(db *sql.DB) (int, *hyperError) { return len(updates), nil } +func staleNoisyMemoryRecords(db *sql.DB) (int, *hyperError) { + rows, err := db.Query(`select id, kind, text, coalesce(confidence, 0), coalesce(quality, '') from memories where stale_at is null order by created_at asc, id asc`) + if err != nil { + return 0, dbError(err) + } + defer rows.Close() + ids := []int64{} + for rows.Next() { + var record memoryRecord + if err := rows.Scan(&record.ID, &record.Kind, &record.Text, &record.Confidence, &record.Quality); err != nil { + return 0, dbError(err) + } + if noisyPersistedMemoryRecord(record) { + ids = append(ids, record.ID) + } + } + if err := rows.Err(); err != nil { + return 0, dbError(err) + } + for _, id := range ids { + if _, err := db.Exec(`update memories set stale_at = ? where id = ?`, nowISO(), id); err != nil { + return 0, dbError(err) + } + } + return len(ids), nil +} + +func noisyPersistedMemoryRecord(record memoryRecord) bool { + signal := memorySignal(record.Text) + normalized := normalizeSentence(signal) + if normalized == "" { + return true + } + return isNoIssueText(normalized) || isPassiveNoChangeText(normalized) || isHyperProtocolNoiseText(normalized) +} + +func rewriteMemoryMarkdownFiles(root string, db *sql.DB) *hyperError { + rows, err := db.Query(`select id, kind, text, coalesce(confidence, 0), coalesce(quality, '') from memories where stale_at is null order by created_at asc, id asc`) + if err != nil { + return dbError(err) + } + defer rows.Close() + type markdownMemory struct { + kind string + text string + quality string + } + memories := []markdownMemory{} + for rows.Next() { + var record memoryRecord + if err := rows.Scan(&record.ID, &record.Kind, &record.Text, &record.Confidence, &record.Quality); err != nil { + return dbError(err) + } + if noisyPersistedMemoryRecord(record) { + continue + } + quality := firstNonBlank(record.Quality, memoryQuality(record.Kind, record.Text, firstNonZeroFloat(record.Confidence, 0.7)), "weak") + memories = append(memories, markdownMemory{kind: record.Kind, text: record.Text, quality: quality}) + } + if err := rows.Err(); err != nil { + return dbError(err) + } + files := map[string]struct { + title string + lines []string + }{ + "decision": {title: "Decisions"}, + "pattern": {title: "Patterns"}, + "failure": {title: "Failures"}, + "constraint": {title: "Constraints"}, + } + for _, mem := range memories { + entry, ok := files[mem.kind] + if !ok { + continue + } + entry.lines = append(entry.lines, "- ["+mem.quality+"] "+mem.text) + files[mem.kind] = entry + } + for kind, entry := range files { + rel := "" + switch kind { + case "decision": + rel = ".hyper/memories/decisions.md" + case "pattern": + rel = ".hyper/memories/patterns.md" + case "failure": + rel = ".hyper/memories/failures.md" + case "constraint": + rel = ".hyper/memories/constraints.md" + } + body := "# " + entry.title + "\n\n" + if len(entry.lines) > 0 { + body += strings.Join(entry.lines, "\n") + "\n" + } + if err := writeText(filepath.Join(root, rel), body); err != nil { + return err + } + } + return nil +} + func growthMigrationNeeded(growth growthState) bool { for _, pressure := range growth.Pressures { if !visibleGrowthPressure(pressure) { diff --git a/internal/app/plan.go b/internal/app/plan.go index fa199ea..2a30abf 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -583,6 +583,8 @@ func isNoIssueText(normalized string) bool { normalized == "no failures in this run" || normalized == "no failure this run" || normalized == "no failures this run" || + normalized == "no new failure" || + normalized == "no new failures" || normalized == "clear: implementation and validation completed for this packet" || normalized == "clear implementation and validation completed for this packet" || normalized == "implementation and validation completed for this packet" { @@ -608,10 +610,17 @@ func isNoIssueText(normalized string) bool { strings.HasPrefix(normalized, "no remaining blockers for this packet") || strings.HasPrefix(normalized, "no blocker remains for this packet") || strings.HasPrefix(normalized, "no blockers remain for this packet") || + strings.HasPrefix(normalized, "none for this episode") || + strings.HasPrefix(normalized, "none for this packet") || + strings.HasPrefix(normalized, "none for this run") || strings.HasPrefix(normalized, "no failure for this episode") || + strings.HasPrefix(normalized, "no failure for this packet") || strings.HasPrefix(normalized, "no failures for this episode") || + strings.HasPrefix(normalized, "no failures for this packet") || strings.HasPrefix(normalized, "no failure in this run") || strings.HasPrefix(normalized, "no failures in this run") || + strings.HasPrefix(normalized, "no new failure") || + strings.HasPrefix(normalized, "no new failures") || strings.HasPrefix(normalized, "none critical for") || strings.HasPrefix(normalized, "no critical gap for") || strings.HasPrefix(normalized, "no critical gaps for") || From 8ef62d10357e754dece54731014072f53253fded Mon Sep 17 00:00:00 2001 From: jsPark Date: Wed, 27 May 2026 09:12:48 +0900 Subject: [PATCH 52/52] Normalize displayed Hyper paths --- internal/app/advance.go | 2 +- internal/app/commands.go | 8 ++++---- internal/app/doctor.go | 2 +- internal/app/finish_gate.go | 2 +- internal/app/migrate.go | 2 +- internal/app/util.go | 4 ++++ 6 files changed, 12 insertions(+), 8 deletions(-) diff --git a/internal/app/advance.go b/internal/app/advance.go index 30227d1..1448017 100644 --- a/internal/app/advance.go +++ b/internal/app/advance.go @@ -132,7 +132,7 @@ func advanceHyper(fsys fsRoot) (commandOutput, *hyperError) { "Readiness pressure: "+readinessPressureSummary(updatedReadiness), "Next action: "+nextPlan.Command, "Why: "+nextPlan.Reason, - "Next packet plan: "+filepath.Join(hyperDir, "next-packet.md"), + "Next packet plan: "+displayRelPath(hyperDir, "next-packet.md"), "", "Next:", " "+nextPlan.Command, diff --git a/internal/app/commands.go b/internal/app/commands.go index 03c95cb..5b55502 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -186,7 +186,7 @@ func runHyper(fsys fsRoot, opts runOptions) (commandOutput, *hyperError) { "Readiness pressure: " + readinessPressureSummary(readiness), "Next action: " + nextPlan.Command, "Why: " + nextPlan.Reason, - "Next packet plan: " + filepath.Join(hyperDir, "next-packet.md"), + "Next packet plan: " + displayRelPath(hyperDir, "next-packet.md"), "", "No runtime packet created.", "", @@ -476,7 +476,7 @@ func completeHyper(fsys fsRoot) (commandOutput, *hyperError) { "Readiness pressure: " + readinessPressureSummary(readiness), "Next action: " + nextCommand, "Why: " + nextReason, - "Next packet plan: " + filepath.Join(hyperDir, "next-packet.md"), + "Next packet plan: " + displayRelPath(hyperDir, "next-packet.md"), line, "", "Next:", @@ -518,8 +518,8 @@ func blockingActiveGoal(root string, state projectState) string { "Reason: " + failed.Reason, "", "Fix the same packet before creating another one:", - " update " + filepath.Join(hyperDir, "goals", state.CurrentGoalID, "evidence.md"), - " update " + filepath.Join(hyperDir, "goals", state.CurrentGoalID, "next.md"), + " update " + displayRelPath(hyperDir, "goals", state.CurrentGoalID, "evidence.md"), + " update " + displayRelPath(hyperDir, "goals", state.CurrentGoalID, "next.md"), " hyper complete", }, "\n") } diff --git a/internal/app/doctor.go b/internal/app/doctor.go index f5f23fa..9282b10 100644 --- a/internal/app/doctor.go +++ b/internal/app/doctor.go @@ -201,7 +201,7 @@ func doctorNextPacketPlanCheck(root string) doctorCheck { if actual != expected.Command { return doctorCheck{"Next packet plan", "WARN", "expected `" + expected.Command + "`, found `" + actual + "`; run `hyper migrate`"} } - return doctorCheck{"Next packet plan", "OK", filepath.Join(hyperDir, "next-packet.md") + " matches current state"} + return doctorCheck{"Next packet plan", "OK", displayRelPath(hyperDir, "next-packet.md") + " matches current state"} } func nextPacketPlanCommand(body string) string { diff --git a/internal/app/finish_gate.go b/internal/app/finish_gate.go index ea99b67..b205597 100644 --- a/internal/app/finish_gate.go +++ b/internal/app/finish_gate.go @@ -65,7 +65,7 @@ func failedFinishGateGoalState(root, goalID string) (goalState, bool) { if strings.TrimSpace(goalID) == "" || finishGateReviewStatus(root, goalID) != "failed" { return goalState{}, false } - reviewPath := filepath.Join(hyperDir, "goals", goalID, "review.md") + reviewPath := displayRelPath(hyperDir, "goals", goalID, "review.md") return goalState{ State: "active", Reason: "Finish gate failed. Fix " + reviewPath + " findings, then run `hyper complete` again.", diff --git a/internal/app/migrate.go b/internal/app/migrate.go index 364a01a..8e8231f 100644 --- a/internal/app/migrate.go +++ b/internal/app/migrate.go @@ -58,7 +58,7 @@ func migrateHyper(fsys fsRoot) (commandOutput, *hyperError) { if nextErr != nil { return commandOutput{}, nextErr } - nextPacketMessage = filepath.Join(hyperDir, "next-packet.md") + " (" + nextPlan.Action + ")" + nextPacketMessage = displayRelPath(hyperDir, "next-packet.md") + " (" + nextPlan.Action + ")" } } else if consistency.Repairable { stateMessage = "state.json needs repair; run `hyper repair`" diff --git a/internal/app/util.go b/internal/app/util.go index 341ae73..434003b 100644 --- a/internal/app/util.go +++ b/internal/app/util.go @@ -112,6 +112,10 @@ func oneLine(value string) string { return strings.Join(strings.Fields(value), " ") } +func displayRelPath(parts ...string) string { + return filepath.ToSlash(filepath.Join(parts...)) +} + func compactText(value string, limit int) string { value = oneLine(value) if limit <= 0 || len([]rune(value)) <= limit {