diff --git a/README.md b/README.md index 1ddbf3a..007c05a 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: @@ -311,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 @@ -397,6 +415,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/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/advance.go b/internal/app/advance.go index da7c9c9..1448017 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: "+displayRelPath(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/app.go b/internal/app/app.go index 2a75d93..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) @@ -113,7 +158,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.", @@ -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/commands.go b/internal/app/commands.go index 697e05b..5b55502 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: " + displayRelPath(hyperDir, "next-packet.md"), + "", + "No runtime packet created.", + "", + "Next:", + " " + nextPlan.Command, + "", + }, "\n")), nil + } + } runID, err := nextID(db, "runs", "RUN") if err != nil { @@ -301,13 +330,18 @@ 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 := readGrowthStateIfExists(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 } @@ -351,7 +385,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 { @@ -442,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:", @@ -478,17 +512,49 @@ func blockingActiveGoal(root string, state projectState) string { if strings.TrimSpace(state.CurrentGoalID) == "" { return "" } - if state.Status != "" && state.Status != "active" { - 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 " + displayRelPath(hyperDir, "goals", state.CurrentGoalID, "evidence.md"), + " update " + displayRelPath(hyperDir, "goals", state.CurrentGoalID, "next.md"), + " hyper complete", + }, "\n") } 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, @@ -501,6 +567,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/concepts.go b/internal/app/concepts.go index 1a3c3a0..d71d74f 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: @@ -93,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 d97624c..9282b10 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)...) @@ -136,6 +137,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 +154,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"} } @@ -164,23 +168,90 @@ 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 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"} + } + 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", displayRelPath(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 } 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..b205597 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 := displayRelPath(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) @@ -68,27 +88,229 @@ 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." + } + 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 "" } } - 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 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": + 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 { - validators, err := activeValidatorCapabilities(root) - if err != nil || len(validators) == 0 { + capabilities, err := activeCapabilities(root) + if err != nil || len(capabilities) == 0 { return "" } - if hasNonPendingSection(evidenceText, "Active Capability Evidence") { + lines := usefulSectionLines(evidenceText, "Active Capability Evidence") + missing := []string{} + for _, capability := range capabilities { + if activeCapabilityEvidenceCovers(capability, lines) { + continue + } + if activeValidatorValidationCovers(capability, evidenceText) { + continue + } + missing = append(missing, capability.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(capability activeCapability, lines []string) bool { + if len(lines) == 0 { + return false + } + name := normalizeSentence(capability.Name) + command := normalizeSentence(inferredCommandForSignal(capability.Signal)) + for _, line := range lines { + normalized := normalizeSentence(line) + if !credibleActiveCapabilityEvidence(normalized) { + continue + } + if name != "" && strings.Contains(normalized, name) { + return true + } + if command != "" && strings.Contains(normalized, command) { + return true + } + } + return false +} + +func activeValidatorValidationCovers(capability activeCapability, evidenceText string) bool { + if capability.Kind != "validator" { + return false + } + command := normalizeSentence(inferredCommandForSignal(capability.Signal)) + if command == "" { + return false } - return "Record active capability evidence for: " + strings.Join(names, ", ") + 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 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 { + return hasAny(normalized, + "passed", + "success", + "succeeded", + "verified", + "checked", + "covered", + "proved", + "proven", + "built", + " ok ", + "ok ./", + ) +} + +func credibleActiveCapabilityEvidence(normalized string) bool { + if normalized == "" || isPlaceholder(normalized) { + return false + } + 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", + "tbd", + "not run", + "not executed", + "not checked", + "not verified", + "not validated", + "not yet", + "missing", + ) { + return false + } + 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 { diff --git a/internal/app/goal_state.go b/internal/app/goal_state.go index 68fd0ed..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,23 +315,65 @@ 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 validationCommandBoundary(trimmed) { + if nextCommand := firstBacktickCommand(trimmed); nextCommand != "" { + command = nextCommand + commandEmitted = false + } + } if usefulValidationSignal(trimmed) { - return trimmed + memory := trimmed + if firstBacktickCommand(trimmed) == "" && command != "" { + 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) + } + } else if command == "" { + command = firstBacktickCommand(trimmed) } } - return "" + return memories } -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 - } +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." } - return "" } func weakLearnSignal(kind, text string, confidence float64) bool { @@ -365,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 } @@ -375,6 +417,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 +427,25 @@ 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", + "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", + "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", + ) +} + func isPassiveNoChangeText(normalized string) bool { return hasAny(normalized, "not changed in this episode", @@ -401,15 +465,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 10c1c46..d325e44 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 { @@ -118,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, @@ -129,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 @@ -158,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 { @@ -178,21 +256,110 @@ 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 } + if activeCapabilityExecutionSignal(signal) { + return false + } if quality == "weak" { return usefulValidationSignal(signal) || hasAny(normalizeSentence(signal), "before every", "before each", "repeatable") } return true } -func findPressureAccumulator(accs []*pressureAccumulator, pressureType, canonical string) *pressureAccumulator { +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:" + 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, 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 } @@ -200,6 +367,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 } @@ -226,6 +428,8 @@ func memorySignal(text string) string { } prefixes := []string{ "decisions:", + "pressure signal:", + "pressure signals:", "readiness evidence:", "reusable patterns:", "learn decision:", @@ -258,9 +462,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) { @@ -269,6 +499,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 @@ -362,6 +595,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) { @@ -376,11 +612,70 @@ 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", + "remains incomplete", + "remain incomplete", + "remains minimal", + "remain minimal", + "remains thin", + "remain thin", + "remains for the next stage", + ) +} + func isValidationPattern(signal string) bool { normalized := strings.ToLower(signal) + 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 { + 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") && @@ -402,21 +697,41 @@ 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) + 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 { @@ -427,12 +742,50 @@ 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" + } + 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 +} + +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) 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 @@ -449,21 +802,24 @@ func growthBehaviorWithActiveCapabilities(root string, pressures []growthPressur return behavior, nil } -type activeValidatorCapability struct { - Name string - Signal string +type activeCapability struct { + Kind string + Name string + Signal string + Path string + Managed bool } -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 @@ -475,6 +831,8 @@ func activeValidatorCapabilities(root string) ([]activeValidatorCapability, *hyp } validator, ok := parseActiveValidatorCapability(entry.Name(), string(body)) if ok { + validator.Path = path + validator.Managed = managedCapabilityFile(string(body)) validators = append(validators, validator) } } @@ -487,10 +845,62 @@ 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 { + capability.Path = path + capability.Managed = managedCapabilityFile(string(body)) + 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 +909,13 @@ 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 managedCapabilityFile(body string) bool { + return capabilityField(body, "Pressure type") != "" || capabilityField(body, "Evidence count") != "" } func markdownTitle(body string) string { @@ -538,7 +952,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 @@ -550,45 +964,30 @@ 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 { + return nil, activeErr + } + candidates = mergeActiveCapabilityCandidates(growthState{Candidates: candidates}, active).Candidates retired, err := retiredGrowthCandidates(root, previous, candidates) if err != nil { return nil, err @@ -597,6 +996,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" @@ -633,11 +1077,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{ @@ -668,7 +1123,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", @@ -680,13 +1135,29 @@ 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, } } +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: @@ -700,11 +1171,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" @@ -759,6 +1230,8 @@ func retiredGrowthCandidates(root string, previous growthState, current []growth func harnessPressureReady(pressures []growthPressure) bool { stable := 0 hasValidation := false + hasImplementation := false + hasWorkBoundary := false for _, pressure := range pressures { if pressure.GoalCount < growthRepeatedSignalGoals { continue @@ -766,11 +1239,17 @@ func harnessPressureReady(pressures []growthPressure) bool { if pressure.Effect == "validation" { hasValidation = true } + 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 && stable >= growthHarnessStablePressures + return hasValidation && hasImplementation && hasWorkBoundary && stable >= growthHarnessStablePressures } func aggregateHarnessPressure(pressures []growthPressure) growthPressure { @@ -791,7 +1270,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), @@ -859,12 +1338,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 } @@ -934,15 +1407,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 } } @@ -972,13 +1446,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 3d728ce..3f48063 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") @@ -38,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 { @@ -49,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{}) @@ -117,6 +163,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") @@ -232,6 +280,71 @@ 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 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", + 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") @@ -363,20 +476,116 @@ 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") + assertNotContains(t, short, "Gap:") +} + 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"}, Advancement: stageAdvancementPolicy{Candidate: true}}, + NextPressure: readinessPressure{Axis: "stage_advancement", AxisName: "Stage advancement", Status: "candidate", Reason: "Tiny MVP gate is ready."}, + } + + 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 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.", }, - StageGate: readinessStageGate{CurrentStage: "Tiny MVP", NextStage: "Usable MVP", Status: "ready", RequiredAxes: []string{"product_completeness", "core_ux", "validation_coverage"}}, } - out := strings.Join(readinessDashboardLines(readiness), "\n") - assertNotContains(t, out, "Reference benchmark: missing") + 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) { @@ -392,6 +601,105 @@ 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 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") @@ -442,7 +750,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") @@ -451,13 +759,125 @@ 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") 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", "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") + + _, 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:") + 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 { + t.Fatalf("complete should accept named active capability evidence: %v", err) + } +} + +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`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{}) + 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 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\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 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 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") + 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") + 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") @@ -479,26 +899,197 @@ 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") } -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" { - t.Fatalf("expected no-remaining blocker text to complete, got %+v", state) +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 TestStatusDerivesReadinessForLegacyState(t *testing.T) { +func TestRunAutoUntilDoesNotCreatePacketAfterTargetReached(t *testing.T) { root := t.TempDir() - mustInitWithPlan(t, root, "Tiny notes", "Build a tiny notes MVP") - mustRun(t, root, "run") - if err := os.Remove(filepath.Join(root, ".hyper", "readiness", "state.json")); err != nil { + 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 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", + 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" { + t.Fatalf("expected no-remaining blocker text to complete, got %+v", state) + } +} + +func TestStatusDerivesReadinessForLegacyState(t *testing.T) { + root := t.TempDir() + mustInitWithPlan(t, root, "Tiny notes", "Build a tiny notes MVP") + mustRun(t, root, "run") + if err := os.Remove(filepath.Join(root, ".hyper", "readiness", "state.json")); err != nil { t.Fatalf("remove readiness state failed: %v", err) } @@ -553,6 +1144,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 { @@ -648,25 +1297,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 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") if _, err := runCLI(args("run", "Add note editing polish"), testRoot(root), fakeUpdater{}); err != nil { t.Fatalf("second run failed: %v", err) @@ -688,14 +1339,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) @@ -722,6 +1375,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", @@ -814,6 +1479,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") @@ -854,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 { @@ -883,6 +1611,134 @@ 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"}, + {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) + } + 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) + } + 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) { + 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 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 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"}, + {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 { + 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 { @@ -930,6 +1786,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) @@ -951,64 +1810,448 @@ func TestGrowthClustersSignalsAndPromotesLifecycle(t *testing.T) { assertNotContains(t, readFile(t, filepath.Join(root, ".hyper", "growth", "state.json")), "Required active validator validator-go-test") } -func TestActiveValidatorBecomesRequiredValidationSignal(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-run-go-test.md"), "# validator-run-go-test\n\nStatus: active\nKind: validator\n\n## Pressure\n\n- Signal: Run go test ./... before handoff.\n") - writeFile(t, filepath.Join(root, ".hyper", "capabilities", "candidates", "validator", "validator-candidate-only.md"), "# validator-candidate-only\n\nStatus: promotable\nKind: validator\n\n## Pressure\n\n- Signal: Run candidate-only smoke check.\n") - - if _, err := runCLI(args("run", "Add CLI persistence"), testRoot(root), fakeUpdater{}); err != nil { - t.Fatalf("run failed: %v", err) +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) } - - goal := readFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "goal.md")) - assertContains(t, goal, "## Active Capabilities") - assertContains(t, goal, "Required active validator validator-run-go-test") - assertContains(t, goal, "Required active validator validator-run-go-test: Run go test ./... before handoff.") - assertNotContains(t, goal, "candidate-only smoke check") - evidence := readFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md")) - assertContains(t, evidence, "## Active Capability Evidence") - assertContains(t, readFile(t, filepath.Join(root, ".hyper", "growth", "state.json")), "Required active validator validator-run-go-test") } -func TestReadinessPressureSelectsStageGateGap(t *testing.T) { - root := t.TempDir() - mustRun(t, root, "init") - writeFile(t, filepath.Join(root, "plan.md"), "# Product Plan\n\n## Product\n\nTiny CRM\n\n## Target Users\n\nSolo sellers\n\n## MVP\n\nAdd and revisit customer notes.\n\n## Current Stage\n\nUsable MVP\n\n## Build Style\n\nWeb app\n\n## Non-goals\n\nTeam collaboration\n\n## Constraints\n\nLocal first\n\n## Success Criteria\n\nPrimary customer notes flow works without manual data edits.\n\n## Current Focus\n\nImprove customer notes.\n") - - if _, err := runCLI(args("run"), testRoot(root), fakeUpdater{}); err != nil { - t.Fatalf("run failed: %v", err) +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") } - - 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"`) - 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") } -func TestReadinessEvidenceProgressesSelectedAxis(t *testing.T) { - root := t.TempDir() - mustRun(t, root, "init") - writeFile(t, filepath.Join(root, "plan.md"), "# Product Plan\n\n## Product\n\nTiny CRM\n\n## Target Users\n\nSolo sellers\n\n## MVP\n\nAdd and revisit customer notes.\n\n## Current Stage\n\nUsable MVP\n\n## Build Style\n\nWeb app\n\n## Non-goals\n\nTeam collaboration\n\n## Constraints\n\nLocal first\n\n## Success Criteria\n\nPrimary customer notes flow works without manual data edits.\n\n## Current Focus\n\nImprove customer notes.\n") +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") + } +} - if _, err := runCLI(args("run"), testRoot(root), fakeUpdater{}); err != nil { - t.Fatalf("first run failed: %v", err) +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) } - 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", "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") + assertContains(t, behavior.ValidationSignals[0], "./check.sh") +} - if _, err := runCLI(args("run"), testRoot(root), fakeUpdater{}); err != nil { - t.Fatalf("second run failed: %v", err) +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") +} - state := readReadinessStateIfExists(root) - if got := readinessDimensionMap(state.Dimensions)["persistence"].Status; got != "covered" { - t.Fatalf("expected persistence covered, got %s", got) +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 { + 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 { + 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"}}, + {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 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" { + t.Fatalf("expected code-symbol pattern to remain implementation pressure, got %s/%s", pressureType, effect) + } +} + +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") + writeFile(t, filepath.Join(root, ".hyper", "capabilities", "active", "validator", "validator-run-go-test.md"), "# validator-run-go-test\n\nStatus: active\nKind: validator\n\n## Pressure\n\n- Signal: Run go test ./... before handoff.\n") + writeFile(t, filepath.Join(root, ".hyper", "capabilities", "candidates", "validator", "validator-candidate-only.md"), "# validator-candidate-only\n\nStatus: promotable\nKind: validator\n\n## Pressure\n\n- Signal: Run candidate-only smoke check.\n") + + if _, err := runCLI(args("run", "Add CLI persistence"), 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, "## Active Capabilities") + assertContains(t, goal, "Required active validator validator-run-go-test") + assertContains(t, goal, "Required active validator validator-run-go-test: Run go test ./... before handoff.") + assertNotContains(t, goal, "candidate-only smoke check") + evidence := readFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "evidence.md")) + assertContains(t, evidence, "## Active Capability Evidence") + 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 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") + writeFile(t, filepath.Join(root, "plan.md"), "# Product Plan\n\n## Product\n\nTiny CRM\n\n## Target Users\n\nSolo sellers\n\n## MVP\n\nAdd and revisit customer notes.\n\n## Current Stage\n\nUsable MVP\n\n## Build Style\n\nWeb app\n\n## Non-goals\n\nTeam collaboration\n\n## Constraints\n\nLocal first\n\n## Success Criteria\n\nPrimary customer notes flow works without manual data edits.\n\n## Current Focus\n\nImprove customer notes.\n") + + if _, err := runCLI(args("run"), testRoot(root), fakeUpdater{}); err != nil { + t.Fatalf("run failed: %v", err) + } + + 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": "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: 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) { + root := t.TempDir() + mustRun(t, root, "init") + writeFile(t, filepath.Join(root, "plan.md"), "# Product Plan\n\n## Product\n\nTiny CRM\n\n## Target Users\n\nSolo sellers\n\n## MVP\n\nAdd and revisit customer notes.\n\n## Current Stage\n\nUsable MVP\n\n## Build Style\n\nWeb app\n\n## Non-goals\n\nTeam collaboration\n\n## Constraints\n\nLocal first\n\n## Success Criteria\n\nPrimary customer notes flow works without manual data edits.\n\n## Current Focus\n\nImprove customer notes.\n") + + 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\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) + } + + state := readReadinessStateIfExists(root) + if got := readinessDimensionMap(state.Dimensions)["persistence"].Status; got != "covered" { + t.Fatalf("expected persistence covered, got %s", got) } if state.NextPressure.Axis != "error_handling" { t.Fatalf("expected next pressure to move to error_handling, got %+v", state.NextPressure) @@ -1035,6 +2278,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") @@ -1049,6 +2299,62 @@ 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) + } + 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") + } + 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) + } + 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) + } + 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") + } + 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) @@ -1075,6 +2381,20 @@ 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) + } + 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") @@ -1096,6 +2416,20 @@ 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) + } + 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") @@ -1103,6 +2437,20 @@ 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) + } + 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") @@ -1112,6 +2460,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{ @@ -1191,6 +2666,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) @@ -1211,6 +2687,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 { @@ -1234,24 +2758,186 @@ func TestRepeatedSurfaceProofCreatesVisualSmokeCandidate(t *testing.T) { if len(state.Pressures) == 0 || state.Pressures[0].PressureType != "surface_validation" { t.Fatalf("expected surface validation pressure, got %+v", state.Pressures) } - if len(state.Candidates) == 0 || !strings.HasPrefix(state.Candidates[0].Name, "validator-visual-smoke-") { - t.Fatalf("expected visual smoke validator candidate, got %+v", state.Candidates) + if len(state.Candidates) == 0 || !strings.HasPrefix(state.Candidates[0].Name, "validator-visual-smoke-") { + t.Fatalf("expected visual smoke validator candidate, got %+v", state.Candidates) + } + assertContains(t, readFile(t, filepath.Join(root, ".hyper", "growth", "state.json")), `"pressure_type": "surface_validation"`) +} + +func TestReadinessEvidenceDoesNotDowngradeCompletePlan(t *testing.T) { + plan := map[string]string{ + "Product": "Tiny pet widget", + "MVP": "A draggable pet with one care loop.", + "Success Criteria": "A user can run it and complete one care action.", + "Current Stage": "Tiny MVP", + } + weakRecord := readinessEvidenceRecordForAxis("GOAL-0001", "product_completeness", "proof - visible canvas pet and care panel exist.") + state := deriveReadinessState(plan, growthState{}, []readinessEvidenceRecord{weakRecord}) + dim := readinessDimensionMap(state.Dimensions)["product_completeness"] + if dim.Status != "covered" { + t.Fatalf("complete plan should stay covered despite weak runtime evidence, got %+v", dim) + } +} + +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 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 + +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["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) + } + 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 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 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) } - assertContains(t, readFile(t, filepath.Join(root, ".hyper", "growth", "state.json")), `"pressure_type": "surface_validation"`) + 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 TestReadinessEvidenceDoesNotDowngradeCompletePlan(t *testing.T) { - plan := map[string]string{ - "Product": "Tiny pet widget", - "MVP": "A draggable pet with one care loop.", - "Success Criteria": "A user can run it and complete one care action.", - "Current Stage": "Tiny MVP", +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"]) } - weakRecord := readinessEvidenceRecordForAxis("GOAL-0001", "product_completeness", "proof - visible canvas pet and care panel exist.") - state := deriveReadinessState(plan, growthState{}, []readinessEvidenceRecord{weakRecord}) - dim := readinessDimensionMap(state.Dimensions)["product_completeness"] - if dim.Status != "covered" { - t.Fatalf("complete plan should stay covered despite weak runtime evidence, got %+v", dim) + 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"]) } } @@ -1268,12 +2954,41 @@ 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") + + 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") 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) @@ -1303,9 +3018,191 @@ 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 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) { @@ -1392,6 +3289,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") @@ -1432,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) @@ -1444,6 +3361,58 @@ 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) + } + 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) { + 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 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) { @@ -1512,6 +3481,36 @@ 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", + "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) + } + 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) { @@ -1577,7 +3576,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 { @@ -1594,25 +3593,242 @@ 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") + + 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" { + 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) { + 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 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", + "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{}) + 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") + 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{}) + 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{}) 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, + 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, 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") + checklist := doneChecklistDoc("Sustained Service Quality", readiness, growthState{}) + 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") @@ -1646,6 +3862,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 +3934,64 @@ 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) + } + + 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) { + 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 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 { @@ -1709,6 +4010,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/migrate.go b/internal/app/migrate.go index 8a613cf..8e8231f 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 { @@ -37,25 +46,39 @@ 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 = displayRelPath(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{ "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)), "Readiness gate: " + readinessGateSummary(readiness), "State consistency: " + stateMessage, + "Next packet plan: " + nextPacketMessage, "", "Next:", " hyper doctor", @@ -95,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/next_packet.go b/internal/app/next_packet.go index 8e6908c..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" ) @@ -14,11 +13,18 @@ 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", 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, } } @@ -30,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) } @@ -89,6 +95,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": @@ -104,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, " ") } @@ -128,11 +136,13 @@ func stageRank(stage string) int { return 3 case "Service Quality": return 4 + case "Sustained Service Quality": + return 5 default: return 0 } } func quoteCommandArg(value string) string { - return strconv.Quote(value) + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" } diff --git a/internal/app/plan.go b/internal/app/plan.go index e9a3fb6..2a30abf 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" @@ -83,6 +87,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,14 +100,107 @@ 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) + } + 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, "#") + 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{ "product": "Product", + "productbrief": "Product", + "brief": "Product", "productdefinition": "Product", "service": "Product", "servicedefinition": "Product", + "project": "Product", "projectname": "Product", + "name": "Product", "oneliner": "Product", "제품": "Product", "제품정의": "Product", @@ -150,9 +248,15 @@ func canonicalPlanKey(heading string) string { "법적운영리스크": "Constraints", "successcriteria": "Success Criteria", "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", @@ -231,6 +335,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" @@ -261,16 +368,63 @@ 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) 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")) @@ -278,13 +432,13 @@ 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{ 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), } @@ -340,8 +494,33 @@ func runtimeObjective(focus string, plan map[string]string, stage, product strin func broadRuntimeFocus(focus string) bool { normalized := normalizeLabel(focus) - return hasAny(normalized, - "service", "production", "quality", "harden", "upgrade", "improve", "polish", "complete", "finish", "better", + 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 && !explicitQualityTarget { + return false + } + serviceAction := hasAny(normalized, "production", "quality", "harden", "upgrade", "improve", "polish", "complete", "finish", "better") + if strings.Contains(normalized, "service") && !explicitQualityTarget && !serviceAction { + return false + } + return explicitQualityTarget || hasAny(normalized, + "production", "quality", "harden", "upgrade", "improve", "polish", "complete", "finish", "better", "실서비스", "서비스", "품질", "고도화", "업그레이드", "완성", "개선", "베타", "프로덕션", ) } @@ -395,12 +574,17 @@ 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" || 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" { @@ -426,10 +610,21 @@ 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") || + 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") @@ -449,6 +644,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 := "" @@ -561,6 +757,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") { @@ -582,6 +792,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.", @@ -605,6 +823,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." } @@ -622,6 +843,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." } @@ -747,7 +971,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 { @@ -770,12 +994,12 @@ 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.") } - 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") @@ -822,7 +1046,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") @@ -831,10 +1055,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 { @@ -848,6 +1081,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() { @@ -878,7 +1122,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") } @@ -903,8 +1147,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\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 { + 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 { @@ -931,6 +1195,9 @@ func serviceQualityStage(stage string) bool { } func referenceBenchmarkRequired(stage string, readiness readinessState) bool { + if readiness.Version != 0 { + return readiness.NextPressure.Axis == "reference_benchmark" + } 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 970f698..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), } } @@ -114,16 +114,33 @@ 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: "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: "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", "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."}, + {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."}, } } 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 corpusMentionsReadinessAxis(corpus, def) { + 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) @@ -153,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 @@ -256,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 @@ -270,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 { @@ -297,6 +331,36 @@ 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 { + 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 && + userActionProof + if screenProof { + return true + } + 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 + return apiOrCLIProof +} + func usefulReadinessEvidence(text string) bool { normalized := strings.ToLower(strings.TrimSpace(text)) if normalized == "" || isPlaceholder(normalized) { @@ -358,6 +422,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", @@ -374,6 +444,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 @@ -414,29 +488,26 @@ 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") && - hasAny(normalized, "sqlite", "mysql", "postgres", "postgresql", "database", " db ", "db check", "sql", "localstorage", "local storage", "storage"), - "MySQL, SQLite, DB, reload, restart, storage, or database evidence" + 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"), + 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": 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 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" @@ -444,10 +515,13 @@ 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": + return sustainedQualityEvidenceCovered(normalized), + "active validator, active harness, or active capability evidence" default: return len(strings.Fields(normalized)) >= 4, "specific evidence for this readiness axis" } @@ -478,37 +552,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 +698,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 @@ -578,28 +735,74 @@ 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 { 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") } +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 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) { + active := []string{} + for _, candidate := range growth.Candidates { + if candidate.Status != "active" { + continue + } + if candidate.Kind == "validator" || candidate.Kind == "harness" { + 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 + } + } + 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", + "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", ) deploymentProof := hasAny(normalized, "passed", "available", "hosted", "deployed", "built", "released", "verified", "validated", "proved", "proven", - "created", "served", "extracted", "smoke", "parity", + "verifies", "validates", "creates", "creation", "created", "served", "extracted", "smoke", "parity", "ran", ) return deploymentTarget && deploymentProof } @@ -610,12 +813,15 @@ 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 } 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 @@ -627,7 +833,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." } } @@ -636,6 +843,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...) @@ -643,11 +853,50 @@ 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...) } } +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"} { @@ -660,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) @@ -673,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" @@ -690,6 +942,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, @@ -708,15 +968,26 @@ 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"}, + []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") { @@ -734,18 +1005,41 @@ 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) - 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 pressure, ok := latestOpenFailurePressure(growth); ok { + return readinessPressureForOpenFailure(plan, pressure, 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: 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.", } } for _, dim := range dimensions { @@ -756,6 +1050,79 @@ func selectReadinessPressure(plan map[string]string, stage string, dimensions [] return readinessPressureForDimension(plan, stage, dims["maintainability"], gate) } +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."} @@ -777,6 +1144,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, @@ -813,15 +1183,21 @@ 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) } } +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) != "" { - if len(strings.Fields(before)) <= 4 { + if len(strings.Fields(before)) <= 6 { return strings.TrimSpace(before) } } 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/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/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() diff --git a/internal/app/status.go b/internal/app/status.go index a2ae035..158e136 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, "") @@ -142,29 +156,71 @@ 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 } return "" } -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`" +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 "" } + 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 } +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" @@ -175,17 +231,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 { @@ -199,10 +272,13 @@ 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 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 @@ -211,21 +287,34 @@ 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" { + 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) { 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 } @@ -239,7 +328,17 @@ 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" { + 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) { @@ -325,7 +424,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") } @@ -404,6 +503,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 +522,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" } @@ -449,19 +547,25 @@ 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" } - if state.AutoContinue && runUntilReached(state, readiness) { - return "hyper status --short" - } if strings.TrimSpace(state.CurrentGoalID) == "" { + if state.AutoContinue && runUntilReached(state, readiness) { + return "hyper status --short" + } 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" } @@ -476,3 +580,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 +} 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 } 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 {