Skip to content

Commit 3dcf567

Browse files
committed
Prevent repair from bypassing failed finish gate
1 parent f91f145 commit 3dcf567

6 files changed

Lines changed: 146 additions & 0 deletions

File tree

internal/app/commands.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,10 @@ func statusHyper(fsys fsRoot, args []string) (commandOutput, *hyperError) {
330330
}
331331
state = refreshStateFromPlanForStatus(root, state)
332332
derived := deriveCurrentGoalState(root, state.CurrentGoalID)
333+
if failed, ok := failedFinishGateGoalState(root, state.CurrentGoalID); ok {
334+
derived = failed
335+
state.Status = "active"
336+
}
333337
runs, goals := statusDBCounts(root)
334338
growth := growthStateForStatus(root)
335339
readiness := readinessStateForStatus(root, growth)
@@ -508,6 +512,17 @@ func blockingActiveGoal(root string, state projectState) string {
508512
if strings.TrimSpace(state.CurrentGoalID) == "" {
509513
return ""
510514
}
515+
if failed, ok := failedFinishGateGoalState(root, state.CurrentGoalID); ok {
516+
return strings.Join([]string{
517+
"Current runtime packet has failed the finish gate: " + state.CurrentGoalID,
518+
"Reason: " + failed.Reason,
519+
"",
520+
"Fix the same packet before creating another one:",
521+
" update " + filepath.Join(hyperDir, "goals", state.CurrentGoalID, "evidence.md"),
522+
" update " + filepath.Join(hyperDir, "goals", state.CurrentGoalID, "next.md"),
523+
" hyper complete",
524+
}, "\n")
525+
}
511526
derived := deriveCurrentGoalState(root, state.CurrentGoalID)
512527
if strings.TrimSpace(state.Status) != "" && strings.TrimSpace(derived.State) != "" && state.Status != "active" && state.Status != derived.State {
513528
return strings.Join([]string{

internal/app/finish_gate.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,26 @@ func runFinishGate(root string, state projectState, derived goalState, readiness
6161
return result, nil
6262
}
6363

64+
func failedFinishGateGoalState(root, goalID string) (goalState, bool) {
65+
if strings.TrimSpace(goalID) == "" || finishGateReviewStatus(root, goalID) != "failed" {
66+
return goalState{}, false
67+
}
68+
reviewPath := filepath.Join(hyperDir, "goals", goalID, "review.md")
69+
return goalState{
70+
State: "active",
71+
Reason: "Finish gate failed. Fix " + reviewPath + " findings, then run `hyper complete` again.",
72+
}, true
73+
}
74+
75+
func finishGateReviewStatus(root, goalID string) string {
76+
body := readIfExists(filepath.Join(root, hyperDir, "goals", goalID, "review.md"))
77+
return strings.ToLower(strings.TrimSpace(firstLabelValue(body, "Status")))
78+
}
79+
80+
func isFailedFinishGateReason(reason string) bool {
81+
return strings.Contains(strings.ToLower(strings.TrimSpace(reason)), "finish gate failed")
82+
}
83+
6484
func readinessFinishGateFinding(state projectState, evidenceText string, readiness readinessState) string {
6585
axis := strings.TrimSpace(readiness.NextPressure.Axis)
6686
axisName := strings.TrimSpace(readiness.NextPressure.AxisName)

internal/app/main_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,86 @@ func TestRunBlocksCompletedEvidenceBeforeFinishGate(t *testing.T) {
620620
}
621621
}
622622

623+
func TestRepairDoesNotBypassFailedFinishGate(t *testing.T) {
624+
root := t.TempDir()
625+
mustInitWithPlan(t, root, "Tiny notes", "Build a tiny notes MVP")
626+
mustRun(t, root, "run")
627+
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")
628+
writeFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "next.md"), "# GOAL-0001 Next\n\n## Recommended Next Goal\n\nStart another packet.\n")
629+
630+
if _, err := runCLI(args("complete"), testRoot(root), fakeUpdater{}); err == nil {
631+
t.Fatal("expected finish gate failure")
632+
}
633+
review := readFile(t, filepath.Join(root, ".hyper", "goals", "GOAL-0001", "review.md"))
634+
assertContains(t, review, "Status: failed")
635+
if status := finishGateReviewStatus(root, "GOAL-0001"); status != "failed" {
636+
t.Fatalf("expected failed finish gate review status, got %q", status)
637+
}
638+
if _, ok := failedFinishGateGoalState(root, "GOAL-0001"); !ok {
639+
t.Fatal("expected failed finish gate state to be visible")
640+
}
641+
642+
status, err := runCLI(args("status", "--short"), testRoot(root), fakeUpdater{})
643+
if err != nil {
644+
t.Fatalf("status failed: %v", err)
645+
}
646+
assertContains(t, status.Stdout, "Finish gate failed")
647+
assertNotContains(t, status.Stdout, "Next: hyper repair")
648+
649+
repair, err := runCLI(args("repair"), testRoot(root), fakeUpdater{})
650+
if err != nil {
651+
t.Fatalf("repair failed: %v", err)
652+
}
653+
assertContains(t, repair.Stdout, "State: no repair needed")
654+
assertContains(t, repair.Stdout, "Finish gate failed")
655+
state, hyperErr := readState(filepath.Join(root, ".hyper", "state.json"))
656+
if hyperErr != nil {
657+
t.Fatal(hyperErr)
658+
}
659+
if state.Status != "active" {
660+
t.Fatalf("repair must not mark failed finish gate completed, got %s", state.Status)
661+
}
662+
663+
_, err = runCLI(args("run", "Start another packet"), testRoot(root), fakeUpdater{})
664+
if err == nil {
665+
t.Fatal("expected failed finish gate to block another run")
666+
}
667+
assertContains(t, err.Message, "failed the finish gate")
668+
669+
state.Status = "completed"
670+
if err := writeJSON(filepath.Join(root, ".hyper", "state.json"), state); err != nil {
671+
t.Fatal(err)
672+
}
673+
status, err = runCLI(args("status", "--short"), testRoot(root), fakeUpdater{})
674+
if err != nil {
675+
t.Fatalf("status failed after legacy state write: %v", err)
676+
}
677+
assertContains(t, status.Stdout, "Finish gate failed")
678+
assertNotContains(t, status.Stdout, "Next: hyper repair")
679+
repair, err = runCLI(args("repair"), testRoot(root), fakeUpdater{})
680+
if err != nil {
681+
t.Fatalf("legacy repair failed: %v", err)
682+
}
683+
assertContains(t, repair.Stdout, "State: repaired")
684+
assertContains(t, repair.Stdout, "To: active")
685+
assertContains(t, repair.Stdout, "Next action: hyper complete")
686+
nextPacket := readFile(t, filepath.Join(root, ".hyper", "next-packet.md"))
687+
assertContains(t, nextPacket, "Action: complete-current")
688+
assertContains(t, nextPacket, "Command: hyper complete")
689+
state, hyperErr = readState(filepath.Join(root, ".hyper", "state.json"))
690+
if hyperErr != nil {
691+
t.Fatal(hyperErr)
692+
}
693+
if state.Status != "active" {
694+
t.Fatalf("legacy failed finish gate repair must restore active state, got %s", state.Status)
695+
}
696+
_, err = runCLI(args("run", "Start another packet"), testRoot(root), fakeUpdater{})
697+
if err == nil {
698+
t.Fatal("expected failed finish gate to block another run even when state was marked completed")
699+
}
700+
assertContains(t, err.Message, "failed the finish gate")
701+
}
702+
623703
func TestCompleteLearnsAndRefreshesReadiness(t *testing.T) {
624704
root := t.TempDir()
625705
mustInitWithPlan(t, root, "Tiny notes", "Build a tiny notes MVP")

internal/app/next_packet.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ type plannedNextPacket struct {
1414
}
1515

1616
func buildNextPacketPlan(state projectState, derived goalState, readiness readinessState, growth growthState) plannedNextPacket {
17+
if derived.State == "active" {
18+
return plannedNextPacket{
19+
Action: "complete-current",
20+
Command: "hyper complete",
21+
Reason: statusActionReason(state, derived, readiness, growth),
22+
}
23+
}
1724
if state.AutoContinue && runUntilReached(state, readiness) {
1825
return plannedNextPacket{
1926
Action: "stop",
@@ -89,6 +96,8 @@ func nextPacketGuard(plan plannedNextPacket) string {
8996
switch plan.Action {
9097
case "advance":
9198
return "Do not run `hyper advance` unless the user accepts the stage change."
99+
case "complete-current":
100+
return "Do not create a new runtime packet; fix the current packet evidence, next notes, and review findings before running `hyper complete`."
92101
case "run":
93102
return "Create the next runtime packet only after the current packet has passed the finish gate and completed."
94103
case "stop":

internal/app/repair.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,22 @@ func currentStateConsistency(root string, state projectState) stateConsistency {
3030
}
3131
}
3232
derived := deriveCurrentGoalState(root, goalID)
33+
if failed, ok := failedFinishGateGoalState(root, goalID); ok {
34+
consistent := projectStatus == "" || projectStatus == "active"
35+
reason := failed.Reason
36+
if !consistent {
37+
reason = fmt.Sprintf("state.json says %s, but the finish gate failed; restore %s to active before continuing.", projectStatus, goalID)
38+
}
39+
return stateConsistency{
40+
HasState: true,
41+
HasGoal: true,
42+
ProjectStatus: projectStatus,
43+
Derived: failed,
44+
Consistent: consistent,
45+
Repairable: !consistent,
46+
Reason: reason,
47+
}
48+
}
3349
consistent := projectStatus == "" || projectStatus == derived.State
3450
repairable := !consistent && derived.State != "active"
3551
reason := "state.json matches the current runtime packet."

internal/app/status.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,9 @@ func statusActionReasonWithRefresh(state projectState, derived goalState, readin
304304
return "Project state needs refresh before trusting the next action: " + refresh.Reason
305305
}
306306
if derived.State == "active" {
307+
if isFailedFinishGateReason(derived.Reason) {
308+
return derived.Reason
309+
}
307310
return "The current runtime packet is still open; evidence and next.md decide what the project learns."
308311
}
309312
if strings.TrimSpace(state.Status) != "" && strings.TrimSpace(state.Status) != strings.TrimSpace(derived.State) {
@@ -333,6 +336,9 @@ func statusDoNotDoYetWithRefresh(state projectState, derived goalState, readines
333336
return "Do not advance or start another packet until `hyper migrate` refreshes growth and readiness state."
334337
}
335338
if derived.State == "active" {
339+
if isFailedFinishGateReason(derived.Reason) {
340+
return "Do not start another `hyper run`; fix review.md findings in the same packet and run `hyper complete` again."
341+
}
336342
return "Do not start another `hyper run` until this packet is completed or blocked."
337343
}
338344
if strings.TrimSpace(state.Status) != "" && strings.TrimSpace(state.Status) != strings.TrimSpace(derived.State) {

0 commit comments

Comments
 (0)