Skip to content

Commit 4d3f425

Browse files
committed
Add plan review gate
1 parent 8007ad8 commit 4d3f425

6 files changed

Lines changed: 248 additions & 20 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Decision: Direct supervisor edit allowed for this fix because Agents loop could not make p
2+
3+
Status: proposed
4+
By: smart
5+
6+
Direct supervisor edit allowed for this fix because Agents loop could not make progress on the open finding: repeated implementer runs short-circuited or died while the plan gate itself routed the task away from the implementer.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Add plan review gate
2+
3+
status: archived
4+
previous_status: active
5+
archived_at: 2026-06-12T21:22:55Z
6+
7+
Summary:
8+
- decisions: 1
9+
- handoffs: 8
10+
- verification records: 2
11+
- run activity records: 9
12+
- findings open: 0
13+
- findings fixed: 0
14+
- findings verified: 1
15+
16+
## Scope
17+
18+
Recent handoffs:
19+
- Please verify the fixed stale plan-review evidence finding and review the direct supervisor edit. Focus on plan Updated timestamp parsing, planReviewCurrent, the generic verification branch, and regression coverage. Tests passed locally with ...
20+
- Fix the open medium finding about stale plan-review evidence. Add regression coverage and preserve normal implementation verification behavior.
21+
22+
Decisions:
23+
- Direct supervisor edit allowed for this fix because Agents loop could not make progress on the open finding: repeated implementer runs short-circuited or died while the plan gate itself routed the task ...
24+
25+
Verification:
26+
- Reviewed plan review gate routing, plan Updated timestamp parsing, generic verification approval blocking, docs, and regression coverage. No unresolved issues found. Checks passed with isolated GOCACHE: go test ./internal/app and go test ...
27+
- Reviewed plan review gate fix: plan Updated metadata is tracked, planReviewCurrent requires verification newer than the current plan revision, generic verification no longer bypasses draft/stale plan approval states, and regression coverage verifies ...
28+
29+
## Final Status
30+
31+
Implemented plan review gate routing, reviewer prompt guidance, docs, and focused regression tests; go test ./... passes with isolated cache.
32+
33+
## Reviewer Verification
34+
35+
- Reviewed plan review gate routing, plan Updated timestamp parsing, generic verification approval blocking, docs, and regression coverage. No unresolved issues found. Checks passed with isolated GOCACHE: go test ./internal/app and go test ...
36+
- Reviewed plan review gate fix: plan Updated metadata is tracked, planReviewCurrent requires verification newer than the current plan revision, generic verification no longer bypasses draft/stale plan approval states, and regression coverage verifies ...
37+
38+
Detailed transient chunks remain available in git history.

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -199,11 +199,11 @@ agents plan clear
199199

200200
The canonical plan lives at `.agents/tasks/<task>/plan.md`. Runs may mention
201201
plan updates, but durable plan content belongs to the task. A draft or stale
202-
plan routes the loop to the coordinator for approval or refinement before Agents
203-
runs the implementer. Reviewers inspect the current task plan as part of review
204-
and should record findings when the implementation does not match the plan, or
205-
when the plan is stale, missing, shallow, or incorrect for the work under
206-
review.
202+
plan routes the loop to the reviewer for a plan review gate before Agents runs
203+
the implementer. Reviewers should record findings when the plan is stale,
204+
missing, shallow, incorrect, or missing enough evidence, validation, risks, or
205+
constraints for the work. Passing plan review still stops at the human approval
206+
boundary; `agents plan approve` is what allows implementation to proceed.
207207

208208
`agents plan refine` accepts refinement feedback only when it can be handled
209209
honestly. Because current plans are free-form Markdown, the command refuses

internal/app/app.go

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2587,6 +2587,7 @@ type activeTask struct {
25872587
type taskStats struct {
25882588
Next string
25892589
PlanState string
2590+
PlanUpdatedStamp string
25902591
Decisions int
25912592
Handoffs int
25922593
LatestHandoffTo string
@@ -2861,10 +2862,14 @@ func readTaskStats(cwd, activePath string) (taskStats, error) {
28612862
if err != nil {
28622863
return taskStats{}, err
28632864
}
2864-
planState, err := planStateForStats(cwd, activePath, latestHandoffName)
2865+
plan, err := readTaskPlan(cwd, activePath)
28652866
if err != nil {
28662867
return taskStats{}, err
28672868
}
2869+
planState := plan.State
2870+
if planState == planStateApproved && planStale(cwd, activePath, latestHandoffName) {
2871+
planState = planStateStale
2872+
}
28682873
verificationPass, verificationFail, latestVerificationName, err := readVerificationStats(filepath.Join(taskDir, "verification"))
28692874
if err != nil {
28702875
return taskStats{}, err
@@ -2880,6 +2885,7 @@ func readTaskStats(cwd, activePath string) (taskStats, error) {
28802885
return taskStats{
28812886
Next: next,
28822887
PlanState: planState,
2888+
PlanUpdatedStamp: plan.UpdatedStamp,
28832889
Decisions: decisions,
28842890
Handoffs: handoffs,
28852891
LatestHandoffTo: latestHandoffTo,
@@ -4951,7 +4957,9 @@ func dispatchPacket(e env, role string) (dispatchInfo, error) {
49514957
info.Action = "implement the next task step, run relevant checks, then write a handoff"
49524958
}
49534959
case "reviewer":
4954-
if state.Stats.LatestHandoffTo != "reviewer" {
4960+
if state.NextAction == "review plan before implementation" {
4961+
info.Action = "review the draft or stale plan; add findings or record plan review evidence"
4962+
} else if state.Stats.LatestHandoffTo != "reviewer" {
49554963
info.Action = "wait for an implementer handoff before reviewing"
49564964
info.Boundary = "handoff required"
49574965
} else {
@@ -4980,7 +4988,11 @@ func analyzeLoopState(e env) (loopState, error) {
49804988
case stats.FindingsFixed > 0:
49814989
state.NextRole = "reviewer"
49824990
state.NextAction = "verify fixed findings independently"
4983-
case verificationCurrent(stats):
4991+
case planRequiresApproval(stats.PlanState) && planReviewCurrent(stats):
4992+
state.NextRole = "human"
4993+
state.NextAction = "approve reviewed plan before implementation"
4994+
state.Boundary = "plan approval required"
4995+
case verificationCurrent(stats) && !planRequiresApproval(stats.PlanState) && !(stats.PlanState == planStateApproved && stats.LatestHandoffTo == "implementer"):
49844996
state.NextRole = "human"
49854997
state.NextAction = "approve archive, commit, or next task"
49864998
state.Boundary = "human approval required"
@@ -4998,8 +5010,8 @@ func analyzeLoopState(e env) (loopState, error) {
49985010
state.NextAction = "start or continue implementation"
49995011
}
50005012
if state.NextRole == "implementer" && planRequiresApproval(stats.PlanState) {
5001-
state.NextRole = "coordinator"
5002-
state.NextAction = "approve or refine plan before implementation"
5013+
state.NextRole = "reviewer"
5014+
state.NextAction = "review plan before implementation"
50035015
state.Boundary = "plan approval required"
50045016
}
50055017
return state, nil
@@ -5030,6 +5042,16 @@ func verificationCurrent(stats taskStats) bool {
50305042
return chunkStamp(stats.LatestVerificationName) >= chunkStamp(stats.LatestHandoffName)
50315043
}
50325044

5045+
func planReviewCurrent(stats taskStats) bool {
5046+
if !verificationCurrent(stats) {
5047+
return false
5048+
}
5049+
if stats.PlanUpdatedStamp == "" {
5050+
return false
5051+
}
5052+
return chunkStamp(stats.LatestVerificationName) >= stats.PlanUpdatedStamp
5053+
}
5054+
50335055
// chunkStamp returns the leading UTC timestamp of a chunk filename
50345056
// (e.g. "2026-06-10T112546Z"), or the whole name if it is shorter.
50355057
func chunkStamp(name string) string {
@@ -6114,6 +6136,11 @@ func buildRolePrompt(e env, state loopState) (string, error) {
61146136
appendPromptDir(&b, e.cwd, state.Task.Path, "findings", "Findings")
61156137
appendPromptDir(&b, e.cwd, state.Task.Path, "verification", "Verification")
61166138
fmt.Fprintln(&b, "Working agreement:")
6139+
if state.NextAction == "review plan before implementation" {
6140+
fmt.Fprintln(&b, "- This is a plan review gate before implementation. Review `plan.md` for readiness; do not review or request implementation changes unless they are needed to make the plan actionable.")
6141+
fmt.Fprintln(&b, "- If the plan is shallow, stale, missing evidence, incorrect, or omits validation or constraints, record findings against the plan so the implementer can fix it before code changes.")
6142+
fmt.Fprintln(&b, "- If the plan is ready, record review evidence. The human must still approve the plan before implementation proceeds.")
6143+
}
61176144
fmt.Fprintln(&b, "- Review the working tree, tests, and task chunks; verify claims against the code, not the handoff text.")
61186145
fmt.Fprintln(&b, "- Review the current task plan in `plan.md` when present. Check for substantive objective, repo-relative evidence, assumptions or open questions, implementation shape, validation, risks, and constraints; do not require exact section headings.")
61196146
fmt.Fprintln(&b, "- Check whether the implementation and review scope match the approved or draft plan.")

internal/app/app_test.go

Lines changed: 137 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3429,7 +3429,7 @@ func TestRolePromptBoundsLongPlanContent(t *testing.T) {
34293429
}
34303430
}
34313431

3432-
func TestDraftPlanRoutesImplementerHandoffToCoordinator(t *testing.T) {
3432+
func TestDraftPlanRoutesImplementerHandoffToReviewerPlanReview(t *testing.T) {
34333433
var out, errOut bytes.Buffer
34343434
code := withTempProcessEnv(t, func(home, cwd string) int {
34353435
e := env{cwd: cwd, home: home, trustPath: filepath.Join(home, ".agents", "trust.json")}
@@ -3454,14 +3454,149 @@ func TestDraftPlanRoutesImplementerHandoffToCoordinator(t *testing.T) {
34543454
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
34553455
t.Fatalf("run JSON should decode: %v\n%s", err, out.String())
34563456
}
3457-
if got.NextRole != "coordinator" || got.NextAction != "approve or refine plan before implementation" || got.Boundary != "plan approval required" {
3457+
if got.NextRole != "reviewer" || got.NextAction != "review plan before implementation" || got.Boundary != "plan approval required" {
34583458
t.Fatalf("unexpected loop state: %+v", got)
34593459
}
34603460
if got.Task == nil || got.Task.Plan != "draft" {
34613461
t.Fatalf("expected draft plan in loop JSON, got %+v", got.Task)
34623462
}
34633463
}
34643464

3465+
func TestStalePlanRoutesImplementationToReviewerPlanReview(t *testing.T) {
3466+
code := withTempProcessEnv(t, func(home, cwd string) int {
3467+
e := env{cwd: cwd, home: home, trustPath: filepath.Join(home, ".agents", "trust.json")}
3468+
if _, err := openTask(e, []string{"Stale Plan Boundary"}, time.Date(2026, 6, 5, 10, 0, 0, 0, time.UTC)); err != nil {
3469+
t.Fatal(err)
3470+
}
3471+
if _, err := addHandoff(e, []string{"--to", "implementer", "Build it."}, time.Date(2026, 6, 5, 10, 1, 0, 0, time.UTC)); err != nil {
3472+
t.Fatal(err)
3473+
}
3474+
if _, err := writeActiveTaskPlan(e, []string{"Stale plan that needs reviewer attention."}, planStateStale); err != nil {
3475+
t.Fatal(err)
3476+
}
3477+
state, err := analyzeLoopState(e)
3478+
if err != nil {
3479+
t.Fatal(err)
3480+
}
3481+
if state.NextRole != "reviewer" || state.NextAction != "review plan before implementation" || state.Boundary != "plan approval required" {
3482+
t.Fatalf("unexpected loop state: %+v", state)
3483+
}
3484+
return 0
3485+
})
3486+
if code != 0 {
3487+
t.Fatalf("withTempProcessEnv code=%d", code)
3488+
}
3489+
}
3490+
3491+
func TestReviewerPromptExplainsPlanReviewGate(t *testing.T) {
3492+
code := withTempProcessEnv(t, func(home, cwd string) int {
3493+
e := env{cwd: cwd, home: home, trustPath: filepath.Join(home, ".agents", "trust.json")}
3494+
task, err := openTask(e, []string{"Plan Review Prompt"}, time.Date(2026, 6, 5, 10, 0, 0, 0, time.UTC))
3495+
if err != nil {
3496+
t.Fatal(err)
3497+
}
3498+
if _, err := writeActiveTaskPlan(e, []string{"Review this plan before implementation."}, planStateDraft); err != nil {
3499+
t.Fatal(err)
3500+
}
3501+
prompt, err := buildRolePrompt(e, loopState{
3502+
Task: task,
3503+
NextRole: "reviewer",
3504+
NextAction: "review plan before implementation",
3505+
})
3506+
if err != nil {
3507+
t.Fatal(err)
3508+
}
3509+
for _, want := range []string{
3510+
"This is a plan review gate before implementation",
3511+
"record findings against the plan",
3512+
"The human must still approve the plan before implementation proceeds",
3513+
} {
3514+
if !strings.Contains(prompt, want) {
3515+
t.Fatalf("reviewer prompt missing %q:\n%s", want, prompt)
3516+
}
3517+
}
3518+
return 0
3519+
})
3520+
if code != 0 {
3521+
t.Fatalf("withTempProcessEnv code=%d", code)
3522+
}
3523+
}
3524+
3525+
func writeTestPlanAt(t *testing.T, cwd, activePath, state, text string, updated time.Time) {
3526+
t.Helper()
3527+
taskDir := filepath.Join(cwd, stateDir, activePath)
3528+
body := fmt.Sprintf("# Plan\n\nState: %s\nUpdated: %s\n\n%s\n", state, updated.UTC().Format(time.RFC3339), text)
3529+
if err := os.WriteFile(filepath.Join(taskDir, "plan.md"), []byte(body), 0o644); err != nil {
3530+
t.Fatal(err)
3531+
}
3532+
}
3533+
3534+
func TestPassingPlanReviewStopsForHumanPlanApproval(t *testing.T) {
3535+
code := withTempProcessEnv(t, func(home, cwd string) int {
3536+
e := env{cwd: cwd, home: home, trustPath: filepath.Join(home, ".agents", "trust.json")}
3537+
task, err := openTask(e, []string{"Plan Review Approval"}, time.Date(2026, 6, 5, 10, 0, 0, 0, time.UTC))
3538+
if err != nil {
3539+
t.Fatal(err)
3540+
}
3541+
if _, err := addHandoff(e, []string{"--to", "implementer", "Build it after the plan is ready."}, time.Date(2026, 6, 5, 10, 1, 0, 0, time.UTC)); err != nil {
3542+
t.Fatal(err)
3543+
}
3544+
writeTestPlanAt(t, cwd, task.Path, planStateDraft, "Plan reviewed before implementation.", time.Date(2026, 6, 5, 10, 1, 30, 0, time.UTC))
3545+
if _, _, err := addVerification(e, []string{"reviewer checked plan readiness", "--by", "reviewer"}, time.Date(2026, 6, 5, 10, 2, 0, 0, time.UTC)); err != nil {
3546+
t.Fatal(err)
3547+
}
3548+
state, err := analyzeLoopState(e)
3549+
if err != nil {
3550+
t.Fatal(err)
3551+
}
3552+
if state.NextRole != "human" || state.NextAction != "approve reviewed plan before implementation" || state.Boundary != "plan approval required" {
3553+
t.Fatalf("unexpected loop state before approval: %+v", state)
3554+
}
3555+
if _, err := approveActiveTaskPlan(e); err != nil {
3556+
t.Fatal(err)
3557+
}
3558+
state, err = analyzeLoopState(e)
3559+
if err != nil {
3560+
t.Fatal(err)
3561+
}
3562+
if state.NextRole != "implementer" || state.NextAction != "act on latest handoff" {
3563+
t.Fatalf("approved reviewed plan should return to implementation, got %+v", state)
3564+
}
3565+
return 0
3566+
})
3567+
if code != 0 {
3568+
t.Fatalf("withTempProcessEnv code=%d", code)
3569+
}
3570+
}
3571+
3572+
func TestPlanReviewEvidenceMustBeNewerThanPlanRevision(t *testing.T) {
3573+
code := withTempProcessEnv(t, func(home, cwd string) int {
3574+
e := env{cwd: cwd, home: home, trustPath: filepath.Join(home, ".agents", "trust.json")}
3575+
task, err := openTask(e, []string{"Plan Review Freshness"}, time.Date(2026, 6, 5, 10, 0, 0, 0, time.UTC))
3576+
if err != nil {
3577+
t.Fatal(err)
3578+
}
3579+
if _, err := addHandoff(e, []string{"--to", "implementer", "Build it after plan review."}, time.Date(2026, 6, 5, 10, 1, 0, 0, time.UTC)); err != nil {
3580+
t.Fatal(err)
3581+
}
3582+
if _, _, err := addVerification(e, []string{"reviewed the earlier plan", "--by", "reviewer"}, time.Date(2026, 6, 5, 10, 2, 0, 0, time.UTC)); err != nil {
3583+
t.Fatal(err)
3584+
}
3585+
writeTestPlanAt(t, cwd, task.Path, planStateDraft, "Newer plan that has not been reviewed.", time.Date(2026, 6, 5, 10, 3, 0, 0, time.UTC))
3586+
state, err := analyzeLoopState(e)
3587+
if err != nil {
3588+
t.Fatal(err)
3589+
}
3590+
if state.NextRole != "reviewer" || state.NextAction != "review plan before implementation" || state.Boundary != "plan approval required" {
3591+
t.Fatalf("stale plan-review evidence must route back to reviewer, got %+v", state)
3592+
}
3593+
return 0
3594+
})
3595+
if code != 0 {
3596+
t.Fatalf("withTempProcessEnv code=%d", code)
3597+
}
3598+
}
3599+
34653600
func TestRunRoutesHandoffToCoordinatorToCoordinator(t *testing.T) {
34663601
var out, errOut bytes.Buffer
34673602
code := withTempProcessEnv(t, func(home, cwd string) int {

internal/app/plan.go

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ const (
1717
)
1818

1919
type taskPlan struct {
20-
State string
21-
Text string
22-
Path string
20+
State string
21+
Text string
22+
Path string
23+
UpdatedStamp string
2324
}
2425

2526
func runPlanCommand(args []string, out, errOut io.Writer, e env) int {
@@ -106,12 +107,22 @@ func readTaskPlan(cwd, activePath string) (taskPlan, error) {
106107
return taskPlan{}, err
107108
}
108109
state := planStateDraft
110+
updatedStamp := ""
109111
body := planBodyWithoutState(stripFirstMarkdownHeading(string(data)))
110112
for _, line := range strings.Split(string(data), "\n") {
111113
trimmed := strings.TrimSpace(line)
112-
if strings.HasPrefix(strings.ToLower(trimmed), "state:") {
114+
lower := strings.ToLower(trimmed)
115+
if strings.HasPrefix(lower, "state:") {
113116
state = strings.ToLower(strings.TrimSpace(trimmed[len("state:"):]))
114-
break
117+
continue
118+
}
119+
if strings.HasPrefix(lower, "updated:") {
120+
updatedStamp = planUpdatedStamp(strings.TrimSpace(trimmed[len("updated:"):]))
121+
}
122+
}
123+
if updatedStamp == "" {
124+
if info, err := os.Stat(path); err == nil {
125+
updatedStamp = info.ModTime().UTC().Format("2006-01-02T150405Z")
115126
}
116127
}
117128
switch state {
@@ -120,9 +131,10 @@ func readTaskPlan(cwd, activePath string) (taskPlan, error) {
120131
state = planStateDraft
121132
}
122133
return taskPlan{
123-
State: state,
124-
Text: strings.TrimSpace(body),
125-
Path: filepath.Join(activePath, "plan.md"),
134+
State: state,
135+
Text: strings.TrimSpace(body),
136+
Path: filepath.Join(activePath, "plan.md"),
137+
UpdatedStamp: updatedStamp,
126138
}, nil
127139
}
128140

@@ -241,6 +253,16 @@ func planBodyWithoutState(text string) string {
241253
return strings.TrimSpace(strings.Join(lines, "\n"))
242254
}
243255

256+
func planUpdatedStamp(updated string) string {
257+
if updated == "" {
258+
return ""
259+
}
260+
if t, err := time.Parse(time.RFC3339, updated); err == nil {
261+
return t.UTC().Format("2006-01-02T150405Z")
262+
}
263+
return chunkStamp(updated)
264+
}
265+
244266
func planStateForStats(cwd, activePath, latestHandoffName string) (string, error) {
245267
plan, err := readTaskPlan(cwd, activePath)
246268
if err != nil {

0 commit comments

Comments
 (0)