summaryrefslogtreecommitdiff
path: root/.claudomator-instructions.txt
blob: cb080b86631a436a51f3d2dab47989f5e6cb8c36 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
## Runtime Environment

You are running as a background agent inside Claudomator. You cannot chat with
the user directly, but you have these tools to coordinate:

- **ask_user** — ask the user a question when you genuinely need a decision to
  proceed. After calling it, end your turn immediately; the task pauses and is
  resumed with the user's answer. Prefer making a reasonable decision and noting
  it via report_summary over asking.
- **report_summary** — record a 2-5 sentence summary of what you did. Call this
  before you finish so the user knows the outcome.
- **spawn_subtask** — create a child task to be run separately.
- **record_progress** — leave a short progress note in the task timeline.

---

## Planning Step (do this first)

Before doing any implementation work, estimate whether the task will take more
than ~3 minutes of effort.

- If YES — break it into 3-7 focused pieces with spawn_subtask, then call
  report_summary describing the breakdown and STOP. Do not implement anything.
- If NO — proceed with the task instructions below.

---

## Git Discipline (mandatory when working in a repository)

Every change you make **must be committed before you finish** — the workspace is
rejected if there are uncommitted modifications.

- After completing work: run "git add -A && git commit -m 'concise description'".
- One commit is fine; multiple focused commits are also fine.
- If the task was already done and you made no changes, just exit cleanly.
- Run ALL git commands from your current directory — do NOT use absolute paths
  or "cd <path> && git ...". Your working directory IS the project.

---

## Before Finishing

Call report_summary with a brief paragraph describing what you did and the
outcome. This is shown in the task UI.

---
You are implementing the single task from an already-approved implementation plan in this repo, committed and pushed at `docs/superpowers/plans/2026-07-09-migrate-story-fix-loop-to-current-attempt.md`. This dispatch contains the complete, literal spec inline — every code block below must be transcribed exactly, character for character. Do not paraphrase, relocate, rename, or "improve" anything. If you think something should be different, call ask_user and explain why before changing it.

IMPORTANT: this is the FIFTH dispatch of this exact task. Prior attempts: (1) failed with "agent left uncommitted changes... Instructions must include: git add -A && git commit && git push origin main"; (2) reported READY but the push silently never reached the real remote (verified independently -- main was unchanged); (3) failed almost instantly with a generic container error (credential/infra blip, resynced); (4) failed with the SAME "uncommitted changes" error as attempt 1, despite explicit push instructions -- most likely because the task ran out of time/turns during the heavy test/verification steps before ever reaching the commit, which was sequenced last.

**To fix this, the ordering in this dispatch is different from all prior attempts: you commit and push IMMEDIATELY after making the edits (Step 5), BEFORE running the test suite (Step 6). This way your work is safe even if something goes wrong or you run low on turns during verification.** Do not reorder this back to "test everything, then commit" -- commit first, verify second, and if verification finds a problem, fix it and commit again (Step 7).

Context: this repo's own codebase (claudomator) is the target. `internal/scheduler.StoryOrchestrator.ensureFixAttempt` currently mutates `story.RootTaskID` to re-point it at each new fix attempt. A prior plan built `task.CurrentAttempt` (in `internal/task/currentattempt.go`), a resolution function that walks forward through fix-attempt `DependsOn` chains to find the task currently representing any position. This task migrates `ensureFixAttempt`, `processStory`, and `processRetro` to resolve the story's root through `task.CurrentAttempt` instead, making `story.RootTaskID` a true immutable anchor never written after story creation.

## Step 1: Update `ensureFixAttempt` to resolve via `CurrentAttempt` and stop writing `RootTaskID`

In `internal/scheduler/story_orchestrator.go`, find `ensureFixAttempt` (including its doc comment):

```go
// ensureFixAttempt handles a story sitting at NEEDS_FIX: it spawns one new
// top-level builder-role task depending on the rejected root (purely for
// structural discoverability/audit trail — the rejected root is already
// COMPLETED, so this dependency is immediately satisfied), whose
// instructions carry the original story spec/acceptance criteria plus the
// arbitration's rejection reasoning, then re-points st.RootTaskID at the new
// task and resets st.Status to IN_PROGRESS. The very next tick re-enters
// processStory's normal Builder->Evaluators->Arbitration flow against the
// new root, completely unchanged — no new pipeline, the existing one
// re-entered.
//
// Idempotency is structural, mirroring every other stage in this file: it
// looks for an existing builder-role dependent of the rejected root before
// spawning a new one, so calling this repeatedly (or after a restart between
// "task spawned" and "story updated") never spawns duplicates. The
// maxFixAttempts cap is only checked in the "spawn a new one" branch — a
// dependent that was already committed to being spawned still gets
// re-pointed to, regardless of the cap, since refusing to do so would leave
// a task dangling with nothing tracking it.
func (o *StoryOrchestrator) ensureFixAttempt(ctx context.Context, st *story.Story) {
	oldRoot, err := o.Store.GetTask(st.RootTaskID)
	if err != nil {
		o.logf("story orchestrator: fix attempt: get rejected root task", "storyID", st.ID, "rootTaskID", st.RootTaskID, "error", err)
		return
	}

	dependents, err := o.Store.ListDependents(oldRoot.ID)
	if err != nil {
		o.logf("story orchestrator: fix attempt: list root dependents", "storyID", st.ID, "error", err)
		return
	}
	var fix *task.Task
	for _, d := range dependents {
		if d.Agent.Role == "builder" {
			fix = d
			break
		}
	}

	if fix == nil {
		if depth := o.fixAttemptDepth(oldRoot); depth >= maxFixAttempts {
			o.logf("story orchestrator: fix attempt: max fix attempts reached, leaving story at NEEDS_FIX for a human", "storyID", st.ID, "depth", depth)
			return
		}
		reasoning := o.rejectionReasoning(st, oldRoot)
		instructions := fmt.Sprintf(
			"A previous attempt at story %q (task %s) was rejected by arbitration. Fix the issues and resubmit.\n\n"+
				"Story spec:\n%s\n\nAcceptance criteria:\n%s\n\nArbitration's rejection reasoning:\n%s",
			st.Name, oldRoot.ID, st.Spec, formatAcceptanceCriteria(st.AcceptanceCriteria), reasoning)
		nt, err := o.spawnRoleTask(ctx, "Fix attempt: "+st.Name, "builder", []string{oldRoot.ID}, oldRoot, instructions)
		if err != nil {
			o.logf("story orchestrator: fix attempt: spawn", "storyID", st.ID, "error", err)
			return
		}
		fix = nt
	}

	st.RootTaskID = fix.ID
	st.Status = "IN_PROGRESS"
	if err := o.Store.UpdateStory(st); err != nil {
		o.logf("story orchestrator: fix attempt: repoint story root", "storyID", st.ID, "error", err)
	}
}
```

Replace it with:

```go
// ensureFixAttempt handles a story sitting at NEEDS_FIX: it spawns one new
// top-level builder-role task depending on the rejected root (purely for
// structural discoverability/audit trail — the rejected root is already
// COMPLETED, so this dependency is immediately satisfied), whose
// instructions carry the original story spec/acceptance criteria plus the
// arbitration's rejection reasoning, then resets st.Status to IN_PROGRESS.
// st.RootTaskID is never written here — it is an immutable anchor set once
// at story creation; every caller that needs "the task currently
// representing the story's root" resolves it via task.CurrentAttempt, which
// walks forward through however many fix-attempt links exist. The very next
// tick re-enters processStory's normal Builder->Evaluators->Arbitration flow
// against whatever CurrentAttempt now resolves to — completely unchanged, no
// new pipeline, the existing one re-entered.
//
// Idempotency is structural, mirroring every other stage in this file: it
// looks for an existing builder-role dependent of the rejected root before
// spawning a new one, so calling this repeatedly (or after a restart between
// "task spawned" and "story status reset") never spawns duplicates. The
// maxFixAttempts cap is only checked in the "spawn a new one" branch — a
// dependent that was already committed to being spawned still gets used,
// regardless of the cap, since refusing to do so would leave a task
// dangling with nothing tracking it.
func (o *StoryOrchestrator) ensureFixAttempt(ctx context.Context, st *story.Story) {
	oldRoot, err := task.CurrentAttempt(o.Store, st.RootTaskID)
	if err != nil {
		o.logf("story orchestrator: fix attempt: resolve current root attempt", "storyID", st.ID, "rootTaskID", st.RootTaskID, "error", err)
		return
	}

	dependents, err := o.Store.ListDependents(oldRoot.ID)
	if err != nil {
		o.logf("story orchestrator: fix attempt: list root dependents", "storyID", st.ID, "error", err)
		return
	}
	var fix *task.Task
	for _, d := range dependents {
		if d.Agent.Role == "builder" {
			fix = d
			break
		}
	}

	if fix == nil {
		if depth := o.fixAttemptDepth(oldRoot); depth >= maxFixAttempts {
			o.logf("story orchestrator: fix attempt: max fix attempts reached, leaving story at NEEDS_FIX for a human", "storyID", st.ID, "depth", depth)
			return
		}
		reasoning := o.rejectionReasoning(st, oldRoot)
		instructions := fmt.Sprintf(
			"A previous attempt at story %q (task %s) was rejected by arbitration. Fix the issues and resubmit.\n\n"+
				"Story spec:\n%s\n\nAcceptance criteria:\n%s\n\nArbitration's rejection reasoning:\n%s",
			st.Name, oldRoot.ID, st.Spec, formatAcceptanceCriteria(st.AcceptanceCriteria), reasoning)
		nt, err := o.spawnRoleTask(ctx, "Fix attempt: "+st.Name, "builder", []string{oldRoot.ID}, oldRoot, instructions)
		if err != nil {
			o.logf("story orchestrator: fix attempt: spawn", "storyID", st.ID, "error", err)
			return
		}
		fix = nt
	}

	st.Status = "IN_PROGRESS"
	if err := o.Store.UpdateStory(st); err != nil {
		o.logf("story orchestrator: fix attempt: reset story status", "storyID", st.ID, "error", err)
	}
}
```

## Step 2: Update `processStory`'s root lookup

In the same file, find the start of `processStory`:

```go
func (o *StoryOrchestrator) processStory(ctx context.Context, st *story.Story) {
	if st.Status == "NEEDS_FIX" {
		o.ensureFixAttempt(ctx, st)
		return
	}

	root, err := o.Store.GetTask(st.RootTaskID)
	if err != nil {
		o.logf("story orchestrator: get root task", "storyID", st.ID, "rootTaskID", st.RootTaskID, "error", err)
		return
	}
```

Replace it with:

```go
func (o *StoryOrchestrator) processStory(ctx context.Context, st *story.Story) {
	if st.Status == "NEEDS_FIX" {
		o.ensureFixAttempt(ctx, st)
		return
	}

	root, err := task.CurrentAttempt(o.Store, st.RootTaskID)
	if err != nil {
		o.logf("story orchestrator: resolve current root attempt", "storyID", st.ID, "rootTaskID", st.RootTaskID, "error", err)
		return
	}
```

## Step 3: Update `processRetro`'s root lookup

In the same file, find the start of `processRetro`:

```go
func (o *StoryOrchestrator) processRetro(ctx context.Context, st *story.Story) {
	root, err := o.Store.GetTask(st.RootTaskID)
	if err != nil {
		o.logf("story orchestrator: retro: get root task", "storyID", st.ID, "rootTaskID", st.RootTaskID, "error", err)
		return
	}
```

Replace it with:

```go
func (o *StoryOrchestrator) processRetro(ctx context.Context, st *story.Story) {
	root, err := task.CurrentAttempt(o.Store, st.RootTaskID)
	if err != nil {
		o.logf("story orchestrator: retro: resolve current root attempt", "storyID", st.ID, "rootTaskID", st.RootTaskID, "error", err)
		return
	}
```

## Step 4: Update the three tests whose assertions describe the old re-pointing behavior

In `internal/scheduler/story_orchestrator_test.go`, find `TestStoryOrchestrator_NeedsFix_SpawnsFixAttemptAndRepointsRoot` in full:

```go
func TestStoryOrchestrator_NeedsFix_SpawnsFixAttemptAndRepointsRoot(t *testing.T) {
	store, st, oldRoot, _, _ := seedNeedsFixStory(t)

	pool := &fakePool{}
	orch := &StoryOrchestrator{Store: store, Pool: pool}
	orch.Tick(context.Background())

	fixAttempts := store.dependentsWithRole(oldRoot.ID, "builder")
	if len(fixAttempts) != 1 {
		t.Fatalf("expected exactly 1 fix-attempt task depending on the rejected root, got %d", len(fixAttempts))
	}
	fix := fixAttempts[0]
	if fix.State != task.StateQueued {
		t.Errorf("fix attempt State = %v, want QUEUED", fix.State)
	}
	if fix.ParentTaskID != "" {
		t.Errorf("fix attempt ParentTaskID = %q, want empty (top-level, DAG sibling not delegated subtask)", fix.ParentTaskID)
	}

	stories, _ := store.ListStories(storage.StoryFilter{})
	var got *story.Story
	for _, s := range stories {
		if s.ID == st.ID {
			got = s
		}
	}
	if got == nil {
		t.Fatal("story not found")
	}
	if got.RootTaskID != fix.ID {
		t.Errorf("story RootTaskID = %q, want the new fix-attempt task %q", got.RootTaskID, fix.ID)
	}
	if got.Status != "IN_PROGRESS" {
		t.Errorf("story Status = %q, want IN_PROGRESS", got.Status)
	}
}
```

Replace it with:

```go
// TestStoryOrchestrator_NeedsFix_SpawnsFixAttempt proves the core mechanism:
// a story at NEEDS_FIX gets a new builder-role fix-attempt task spawned
// (depending on the rejected root), and the story's Status resets to
// IN_PROGRESS so the very next tick re-enters the normal
// Builder->Evaluators->Arbitration flow against the new attempt --
// discovered via task.CurrentAttempt resolving forward from st.RootTaskID,
// which is never mutated (it's an immutable anchor set once at story
// creation).
func TestStoryOrchestrator_NeedsFix_SpawnsFixAttempt(t *testing.T) {
	store, st, oldRoot, _, _ := seedNeedsFixStory(t)

	pool := &fakePool{}
	orch := &StoryOrchestrator{Store: store, Pool: pool}
	orch.Tick(context.Background())

	fixAttempts := store.dependentsWithRole(oldRoot.ID, "builder")
	if len(fixAttempts) != 1 {
		t.Fatalf("expected exactly 1 fix-attempt task depending on the rejected root, got %d", len(fixAttempts))
	}
	fix := fixAttempts[0]
	if fix.State != task.StateQueued {
		t.Errorf("fix attempt State = %v, want QUEUED", fix.State)
	}
	if fix.ParentTaskID != "" {
		t.Errorf("fix attempt ParentTaskID = %q, want empty (top-level, DAG sibling not delegated subtask)", fix.ParentTaskID)
	}

	stories, _ := store.ListStories(storage.StoryFilter{})
	var got *story.Story
	for _, s := range stories {
		if s.ID == st.ID {
			got = s
		}
	}
	if got == nil {
		t.Fatal("story not found")
	}
	if got.RootTaskID != oldRoot.ID {
		t.Errorf("story RootTaskID = %q, want unchanged (still %q -- it's an immutable anchor now, resolved via task.CurrentAttempt)", got.RootTaskID, oldRoot.ID)
	}
	if got.Status != "IN_PROGRESS" {
		t.Errorf("story Status = %q, want IN_PROGRESS", got.Status)
	}
}
```

Find `TestStoryOrchestrator_EnsureFixAttempt_FindsExistingSpawnedAttempt` in full:

```go
// TestStoryOrchestrator_EnsureFixAttempt_FindsExistingSpawnedAttempt proves
// ensureFixAttempt's own structural idempotency: simulating a restart
// between "fix task spawned" and "story RootTaskID/Status updated to point
// at it" -- calling ensureFixAttempt again must find the already-spawned
// builder-role dependent rather than spawning a second one, and still
// complete the re-pointing.
func TestStoryOrchestrator_EnsureFixAttempt_FindsExistingSpawnedAttempt(t *testing.T) {
	store, st, oldRoot, _, _ := seedNeedsFixStory(t)

	pool := &fakePool{}
	orch := &StoryOrchestrator{Store: store, Pool: pool}

	preexisting, err := orch.spawnRoleTask(context.Background(), "Fix attempt: pre-existing", "builder", []string{oldRoot.ID}, oldRoot, "fix it")
	if err != nil {
		t.Fatalf("seed pre-existing fix attempt: %v", err)
	}

	orch.ensureFixAttempt(context.Background(), st)

	fixAttempts := store.dependentsWithRole(oldRoot.ID, "builder")
	if len(fixAttempts) != 1 {
		t.Fatalf("expected exactly 1 fix-attempt task (the pre-existing one, not a new one), got %d", len(fixAttempts))
	}
	if fixAttempts[0].ID != preexisting.ID {
		t.Errorf("expected ensureFixAttempt to reuse the pre-existing task %s, got a different one %s", preexisting.ID, fixAttempts[0].ID)
	}
	if st.RootTaskID != preexisting.ID {
		t.Errorf("story RootTaskID = %q, want the pre-existing fix attempt %q", st.RootTaskID, preexisting.ID)
	}
	if st.Status != "IN_PROGRESS" {
		t.Errorf("story Status = %q, want IN_PROGRESS", st.Status)
	}
}
```

Replace it with:

```go
// TestStoryOrchestrator_EnsureFixAttempt_FindsExistingSpawnedAttempt proves
// ensureFixAttempt's own structural idempotency: simulating a restart
// between "fix task spawned" and "story Status reset" -- calling
// ensureFixAttempt again must find the already-spawned builder-role
// dependent rather than spawning a second one, and still reset Status.
func TestStoryOrchestrator_EnsureFixAttempt_FindsExistingSpawnedAttempt(t *testing.T) {
	store, st, oldRoot, _, _ := seedNeedsFixStory(t)

	pool := &fakePool{}
	orch := &StoryOrchestrator{Store: store, Pool: pool}

	preexisting, err := orch.spawnRoleTask(context.Background(), "Fix attempt: pre-existing", "builder", []string{oldRoot.ID}, oldRoot, "fix it")
	if err != nil {
		t.Fatalf("seed pre-existing fix attempt: %v", err)
	}

	orch.ensureFixAttempt(context.Background(), st)

	fixAttempts := store.dependentsWithRole(oldRoot.ID, "builder")
	if len(fixAttempts) != 1 {
		t.Fatalf("expected exactly 1 fix-attempt task (the pre-existing one, not a new one), got %d", len(fixAttempts))
	}
	if fixAttempts[0].ID != preexisting.ID {
		t.Errorf("expected ensureFixAttempt to reuse the pre-existing task %s, got a different one %s", preexisting.ID, fixAttempts[0].ID)
	}
	if st.RootTaskID != oldRoot.ID {
		t.Errorf("story RootTaskID = %q, want unchanged (still %q -- it's an immutable anchor now)", st.RootTaskID, oldRoot.ID)
	}
	if st.Status != "IN_PROGRESS" {
		t.Errorf("story Status = %q, want IN_PROGRESS", st.Status)
	}
}
```

Find `TestStoryOrchestrator_NeedsFix_CapsAtMaxFixAttempts` in full:

```go
// TestStoryOrchestrator_NeedsFix_CapsAtMaxFixAttempts proves the safety net:
// once a chain of maxFixAttempts consecutive fix attempts already exists,
// ensureFixAttempt stops spawning new ones and leaves the story at
// NEEDS_FIX, exactly like today's fully-manual behavior.
func TestStoryOrchestrator_NeedsFix_CapsAtMaxFixAttempts(t *testing.T) {
	store, st, oldRoot, _, _ := seedNeedsFixStory(t)

	pool := &fakePool{}
	orch := &StoryOrchestrator{Store: store, Pool: pool}

	// Build a chain of maxFixAttempts prior fix-attempt tasks, each
	// depending on the one before it, ending at oldRoot -- simulating a
	// story that has already exhausted its automatic fix attempts.
	prev := oldRoot
	for i := 0; i < maxFixAttempts; i++ {
		nt, err := orch.spawnRoleTask(context.Background(), fmt.Sprintf("Fix attempt %d", i), "builder", []string{prev.ID}, oldRoot, "fix it")
		if err != nil {
			t.Fatalf("seed fix-attempt chain: %v", err)
		}
		prev = nt
	}
	st.RootTaskID = prev.ID
	if err := store.UpdateStory(st); err != nil {
		t.Fatalf("persist chained root: %v", err)
	}

	orch.Tick(context.Background())

	fixAttempts := store.dependentsWithRole(prev.ID, "builder")
	if len(fixAttempts) != 0 {
		t.Fatalf("expected no new fix-attempt task once maxFixAttempts is reached, got %d", len(fixAttempts))
	}

	stories, _ := store.ListStories(storage.StoryFilter{})
	var got *story.Story
	for _, s := range stories {
		if s.ID == st.ID {
			got = s
		}
	}
	if got.Status != "NEEDS_FIX" {
		t.Errorf("story Status = %q, want NEEDS_FIX (cap reached, no further automation)", got.Status)
	}
}
```

Replace it with:

```go
// TestStoryOrchestrator_NeedsFix_CapsAtMaxFixAttempts proves the safety net:
// once a chain of maxFixAttempts consecutive fix attempts already exists,
// ensureFixAttempt stops spawning new ones and leaves the story at
// NEEDS_FIX, exactly like today's fully-manual behavior. st.RootTaskID stays
// at oldRoot.ID throughout (it's an immutable anchor); ensureFixAttempt
// resolves the chain's tip itself via task.CurrentAttempt.
func TestStoryOrchestrator_NeedsFix_CapsAtMaxFixAttempts(t *testing.T) {
	store, st, oldRoot, _, _ := seedNeedsFixStory(t)

	pool := &fakePool{}
	orch := &StoryOrchestrator{Store: store, Pool: pool}

	// Build a chain of maxFixAttempts prior fix-attempt tasks, each
	// depending on the one before it, ending at oldRoot -- simulating a
	// story that has already exhausted its automatic fix attempts.
	prev := oldRoot
	for i := 0; i < maxFixAttempts; i++ {
		nt, err := orch.spawnRoleTask(context.Background(), fmt.Sprintf("Fix attempt %d", i), "builder", []string{prev.ID}, oldRoot, "fix it")
		if err != nil {
			t.Fatalf("seed fix-attempt chain: %v", err)
		}
		prev = nt
	}

	orch.Tick(context.Background())

	fixAttempts := store.dependentsWithRole(prev.ID, "builder")
	if len(fixAttempts) != 0 {
		t.Fatalf("expected no new fix-attempt task once maxFixAttempts is reached, got %d", len(fixAttempts))
	}

	stories, _ := store.ListStories(storage.StoryFilter{})
	var got *story.Story
	for _, s := range stories {
		if s.ID == st.ID {
			got = s
		}
	}
	if got.Status != "NEEDS_FIX" {
		t.Errorf("story Status = %q, want NEEDS_FIX (cap reached, no further automation)", got.Status)
	}
}
```

## Step 5: Commit and push IMMEDIATELY -- do this before running any tests

Run these exact commands, in order, and confirm each one succeeds before moving to the next:

```bash
git remote -v
gofmt -l internal/scheduler/story_orchestrator.go internal/scheduler/story_orchestrator_test.go
git add -A
git commit -m "refactor(scheduler): migrate story fix loop off RootTaskID re-pointing onto CurrentAttempt resolution"
git push origin main
git rev-parse HEAD
git ls-remote origin main
```

If `gofmt -l` printed any file paths, run `gofmt -w` on them before `git add -A` (only if that file wasn't already gofmt-dirty before your changes -- check with `git show HEAD:<path> | gofmt -l -` if unsure). The SHA printed by `git rev-parse HEAD` and the SHA printed by `git ls-remote origin main` MUST match. If they don't, the push did not really land -- do not proceed to Step 6 until they match; diagnose why (non-fast-forward rejection, wrong remote, auth failure) and retry the push. Paste all of this output.

## Step 6: Now run the test suite to verify correctness

Run: `go test ./internal/scheduler/ -run 'TestStoryOrchestrator_(NeedsFix|EnsureFixAttempt|Arbitration|Retro)' -v`
Expected: PASS, all of them — the 3 updated tests, plus `TestStoryOrchestrator_NeedsFix_FixAttemptInstructionsIncludeRejectionReasoning` and `TestStoryOrchestrator_NeedsFix_IdempotentAcrossTicks` (unmodified — they don't assert on `RootTaskID` at all), plus the `TestStoryOrchestrator_Arbitration*` and `TestStoryOrchestrator_Retro_*` clusters (unmodified — the retro tests' `seedDoneStory` fixture has no fix-attempt chain, so `task.CurrentAttempt` resolves to the exact same task a plain `GetTask` would have). Paste the actual output.

Then run: `go test ./...` (the entire repo). This must also pass — paste the actual output. If `internal/api` flakes under the race detector on a one-off "sql: database is closed" teardown error unrelated to this task's actual changes, rerun once before treating it as real. If it fails a second time in a way connected to your actual changes, proceed to Step 7 to fix it.

## Step 7: If Step 6 found any real problem, fix it and commit+push again

If everything in Step 6 passed, skip this step. If something genuinely failed because of your changes, fix it, then repeat the exact same commit sequence as Step 5 (`git add -A && git commit -m "fix: ..." && git push origin main`, then verify `git rev-parse HEAD` matches `git ls-remote origin main` again) before ending your turn.

## Mandatory verification disclosure

When you call report_summary, paste the actual terminal output of every command above — literal pass/fail counts and command output, not a claim of success, including: the `git remote -v` output, the Step 5 `git rev-parse HEAD` / `git ls-remote origin main` pair (must match), the full-repo `go test ./...` run, and (if Step 7 ran) its own push verification too. If anything here is ambiguous or conflicts with what you find in the actual repo, call ask_user and describe the specific conflict — don't guess or silently decide.