@hecer/yoke 0.2.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +557 -494
  3. package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/config.yaml +6 -0
  4. package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/context/DECISIONS.md +9 -0
  5. package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/prd.yaml +38 -0
  6. package/bench/.runs/claude-2026-07-09T22-34-01/bench-verify.mjs +15 -0
  7. package/bench/.runs/claude-2026-07-09T22-34-01/package.json +9 -0
  8. package/bench/.runs/claude-2026-07-09T22-34-01/src/index.mjs +48 -0
  9. package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-1.test.mjs +24 -0
  10. package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-2.test.mjs +28 -0
  11. package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-3.test.mjs +25 -0
  12. package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/config.yaml +6 -0
  13. package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/prd.yaml +32 -0
  14. package/bench/.runs/gemini-2026-07-09T22-34-02/bench-verify.mjs +15 -0
  15. package/bench/.runs/gemini-2026-07-09T22-34-02/package.json +9 -0
  16. package/bench/.runs/gemini-2026-07-09T22-34-02/src/index.mjs +3 -0
  17. package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-1.test.mjs +24 -0
  18. package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-2.test.mjs +28 -0
  19. package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-3.test.mjs +25 -0
  20. package/bench/README.md +42 -0
  21. package/bench/RESULTS.md +36 -0
  22. package/bench/fixtures/string-kit/.yoke/config.yaml +6 -0
  23. package/bench/fixtures/string-kit/.yoke/prd.yaml +32 -0
  24. package/bench/fixtures/string-kit/bench-verify.mjs +15 -0
  25. package/bench/fixtures/string-kit/package.json +9 -0
  26. package/bench/fixtures/string-kit/src/index.mjs +3 -0
  27. package/bench/fixtures/string-kit/tests/STORY-1.test.mjs +24 -0
  28. package/bench/fixtures/string-kit/tests/STORY-2.test.mjs +28 -0
  29. package/bench/fixtures/string-kit/tests/STORY-3.test.mjs +25 -0
  30. package/bench/results/claude-2026-07-09T22-34-01.json +40 -0
  31. package/bench/run.mjs +115 -0
  32. package/canon/loop/loop-spec.md +5 -1
  33. package/canon/manifest.yaml +3 -0
  34. package/canon/skills/yoke-retrofit/SKILL.md +1 -0
  35. package/canon/tools/claude-mem.md +15 -0
  36. package/canon/tools/ui-ux-pro-max.md +15 -0
  37. package/dist/cli.js +32 -6
  38. package/dist/loop/cleanup.js +44 -3
  39. package/dist/loop/loop.js +27 -2
  40. package/dist/loop/reporter.js +21 -3
  41. package/dist/loop/run-command.js +19 -6
  42. package/dist/loop/runner.js +130 -13
  43. package/dist/loop/watchdog.js +67 -17
  44. package/dist/retrofit/apply.js +10 -3
  45. package/dist/retrofit/config.js +2 -0
  46. package/dist/retrofit/gitignore.js +2 -0
  47. package/dist/retrofit/planners/claude.js +4 -1
  48. package/dist/retrofit/planners/gemini.js +2 -1
  49. package/dist/retrofit/preserve.js +51 -0
  50. package/dist/update/check.js +86 -0
  51. package/dist/update/refresh.js +28 -0
  52. package/dist/update/upgrade.js +30 -0
  53. package/docs/PUBLISHING.md +41 -0
  54. package/docs/superpowers/plans/2026-06-27-forge-a-canon.md +815 -0
  55. package/docs/superpowers/plans/2026-06-27-forge-b1-retrofit-claude.md +842 -0
  56. package/docs/superpowers/plans/2026-06-27-forge-b2-codex-gemini-tools.md +884 -0
  57. package/docs/superpowers/plans/2026-06-27-forge-c1-loop-engine.md +891 -0
  58. package/docs/superpowers/plans/2026-06-28-baustein-e-context-layer.md +981 -0
  59. package/docs/superpowers/plans/2026-06-28-forge-b3-settings-merge.md +364 -0
  60. package/docs/superpowers/plans/2026-06-28-forge-c2-loop-verify.md +502 -0
  61. package/docs/superpowers/plans/2026-06-28-forge-c3-multi-agent-runners.md +377 -0
  62. package/docs/superpowers/plans/2026-06-28-forge-c4-worktree-isolation.md +413 -0
  63. package/docs/superpowers/plans/2026-06-28-forge-c5-review-iteration.md +419 -0
  64. package/docs/superpowers/plans/2026-06-28-forge-cleanup-dep0190.md +233 -0
  65. package/docs/superpowers/plans/2026-06-28-forge-d-codegraph-choice-minimal-code.md +523 -0
  66. package/docs/superpowers/plans/2026-06-29-baustein-f-routing.md +258 -0
  67. package/docs/superpowers/plans/2026-06-29-baustein-g-loop-observability.md +1006 -0
  68. package/docs/superpowers/plans/2026-06-29-baustein-h-loop-robustness.md +374 -0
  69. package/docs/superpowers/plans/2026-06-30-baustein-i-visual-design-verification.md +450 -0
  70. package/docs/superpowers/plans/2026-07-02-baustein-j-cross-model-review-gstack-compose.md +645 -0
  71. package/docs/superpowers/plans/2026-07-02-baustein-k-zero-to-100-bootstrap.md +1024 -0
  72. package/docs/superpowers/plans/2026-07-02-baustein-m-flow-smoke-proofs.md +574 -0
  73. package/docs/superpowers/specs/2026-06-27-forge-cross-agent-harness-design.md +144 -0
  74. package/docs/superpowers/specs/2026-06-28-baustein-e-context-layer-design.md +146 -0
  75. package/docs/superpowers/specs/2026-06-29-baustein-f-routing-design.md +106 -0
  76. package/docs/superpowers/specs/2026-06-29-baustein-g-loop-observability-design.md +186 -0
  77. package/docs/superpowers/specs/2026-06-29-baustein-h-loop-robustness-design.md +113 -0
  78. package/docs/superpowers/specs/2026-06-30-baustein-i-visual-design-verification-design.md +98 -0
  79. package/docs/superpowers/specs/2026-07-02-baustein-j-cross-model-review-gstack-compose-design.md +137 -0
  80. package/docs/superpowers/specs/2026-07-02-baustein-k-zero-to-100-bootstrap-design.md +200 -0
  81. package/docs/superpowers/specs/2026-07-02-baustein-m-flow-smoke-proofs-design.md +155 -0
  82. package/docs/superpowers/specs/2026-07-10-companion-tools-decision.md +33 -0
  83. package/docs/superpowers/specs/2026-07-10-multi-agent-parallel-loop-design.md +99 -0
  84. package/package.json +8 -2
@@ -0,0 +1,1006 @@
1
+ # Baustein G — Loop Observability Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Make the autonomous loop observable (token-free console + `.yoke/loop-status.json` + `.yoke/loop.log`) and self-limiting (an idle-timeout watchdog that kills only truly silent agents), so a blocked or hung run is visible at a glance instead of stalling invisibly.
6
+
7
+ **Architecture:** A new injectable `LoopReporter` narrates each story phase to three harness-side channels (all Node, zero agent tokens). A standalone `watchdog.ts` wraps the agent process and kills it only after N minutes of *no output* (idle, not total runtime), keeping the loop synchronous. `yoke loop status` reads the status file. Retrofit gains a `.gitignore` manager so the runtime files don't break the clean-tree gate.
8
+
9
+ **Tech Stack:** Node.js + TypeScript (ESM, `.js` import specifiers, `moduleResolution: Bundler`), vitest, zod (existing). Run tests: `npx vitest run <path>`; types: `npx tsc --noEmit`.
10
+
11
+ ---
12
+
13
+ ## File Structure
14
+
15
+ | File | Responsibility |
16
+ |------|----------------|
17
+ | `src/loop/reporter.ts` (create) | `LoopState`/`LoopPhase`/`LoopStatus` types, `writeStatus`/`readStatus` (atomic), `LoopReporter` interface, `makeReporter` (console+file+log), `noopReporter` |
18
+ | `src/loop/watchdog.ts` (create) | `runWatchdog` core (injectable spawn) + CLI entry; idle-timeout wrapper |
19
+ | `src/loop/runner.ts` (modify) | `Invocation.idleTimeoutMs`; route through watchdog; thread into `makeRunner`/`makeReviewRunner` |
20
+ | `src/loop/loop.ts` (modify) | Drive the reporter at each phase; enrich `blocked` reason with leftover hint |
21
+ | `src/loop/run-command.ts` (modify) | Resolve idle timeout; build reporter + runners; upgrade `loopStatus` output |
22
+ | `src/retrofit/config.ts` (modify) | optional `loop.timeoutMinutes` |
23
+ | `src/retrofit/gitignore.ts` (create) | `ensureGitignore(targetDir, block)` — idempotent managed-block append |
24
+ | `src/retrofit/plan.ts` or `cli.ts` (modify) | call `ensureGitignore` during retrofit |
25
+ | `src/cli.ts` (modify) | parse `--timeout=<minutes>` on `yoke loop run` |
26
+ | `README.md` (modify) | document status/log + `--timeout` |
27
+
28
+ **Constants:** `DEFAULT_IDLE_MINUTES = 20` (in `run-command.ts`).
29
+
30
+ ---
31
+
32
+ ### Task 1: reporter — status types + atomic read/write
33
+
34
+ **Files:** Create `src/loop/reporter.ts`; Test `tests/loop/reporter.test.ts`
35
+
36
+ - [ ] **Step 1: Write the failing test**
37
+
38
+ ```typescript
39
+ // tests/loop/reporter.test.ts
40
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
41
+ import { mkdtempSync, writeFileSync, readdirSync, rmSync } from 'node:fs'
42
+ import { join } from 'node:path'
43
+ import { tmpdir } from 'node:os'
44
+ import { writeStatus, readStatus, type LoopStatus } from '../../src/loop/reporter.js'
45
+
46
+ let dir: string
47
+ beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'yoke-rep-')) })
48
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
49
+
50
+ const sample: LoopStatus = {
51
+ state: 'running', phase: 'implementing', story: 'S1', storyTitle: 'First',
52
+ iteration: 1, progress: { passed: 0, total: 2 },
53
+ startedAt: '2026-06-29T10:00:00.000Z', updatedAt: '2026-06-29T10:00:00.000Z',
54
+ }
55
+
56
+ describe('writeStatus / readStatus', () => {
57
+ it('returns null when no status file exists', () => {
58
+ expect(readStatus(dir)).toBeNull()
59
+ })
60
+ it('round-trips a status through .yoke/loop-status.json', () => {
61
+ writeStatus(dir, sample)
62
+ expect(readStatus(dir)).toEqual(sample)
63
+ })
64
+ it('overwrites in place and leaves no temp file behind', () => {
65
+ writeStatus(dir, sample)
66
+ writeStatus(dir, { ...sample, phase: 'verifying' })
67
+ expect(readStatus(dir)?.phase).toBe('verifying')
68
+ const leftovers = readdirSync(join(dir, '.yoke')).filter(f => f.includes('.tmp'))
69
+ expect(leftovers).toEqual([])
70
+ })
71
+ it('returns null on a corrupt status file', () => {
72
+ writeFileSync(join(dir, '.yoke', 'loop-status.json'), '{ not json')
73
+ expect(readStatus(dir)).toBeNull()
74
+ })
75
+ })
76
+ ```
77
+
78
+ - [ ] **Step 2: Run test to verify it fails**
79
+
80
+ Run: `npx vitest run tests/loop/reporter.test.ts`
81
+ Expected: FAIL — `Cannot find module '../../src/loop/reporter.js'`
82
+
83
+ - [ ] **Step 3: Write minimal implementation**
84
+
85
+ ```typescript
86
+ // src/loop/reporter.ts
87
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs'
88
+ import { join } from 'node:path'
89
+
90
+ export type LoopState = 'running' | 'blocked' | 'complete' | 'cap-reached'
91
+ export type LoopPhase = 'implementing' | 'verifying' | 'reviewing' | 'committing'
92
+
93
+ export interface LoopStatus {
94
+ state: LoopState
95
+ phase?: LoopPhase
96
+ story?: string
97
+ storyTitle?: string
98
+ reason?: string
99
+ iteration: number
100
+ progress: { passed: number; total: number }
101
+ startedAt: string
102
+ updatedAt: string
103
+ }
104
+
105
+ function statusPath(dir: string): string {
106
+ return join(dir, '.yoke', 'loop-status.json')
107
+ }
108
+
109
+ export function writeStatus(dir: string, status: LoopStatus): void {
110
+ const file = statusPath(dir)
111
+ mkdirSync(join(dir, '.yoke'), { recursive: true })
112
+ const tmp = `${file}.${process.pid}.tmp`
113
+ writeFileSync(tmp, JSON.stringify(status, null, 2))
114
+ renameSync(tmp, file) // atomic replace
115
+ }
116
+
117
+ export function readStatus(dir: string): LoopStatus | null {
118
+ const file = statusPath(dir)
119
+ if (!existsSync(file)) return null
120
+ try {
121
+ return JSON.parse(readFileSync(file, 'utf8')) as LoopStatus
122
+ } catch {
123
+ return null
124
+ }
125
+ }
126
+ ```
127
+
128
+ - [ ] **Step 4: Run test to verify it passes**
129
+
130
+ Run: `npx vitest run tests/loop/reporter.test.ts`
131
+ Expected: PASS
132
+
133
+ - [ ] **Step 5: Commit**
134
+
135
+ ```bash
136
+ git add src/loop/reporter.ts tests/loop/reporter.test.ts
137
+ git commit -m "feat(loop): LoopStatus types + atomic writeStatus/readStatus"
138
+ ```
139
+
140
+ ---
141
+
142
+ ### Task 2: reporter — LoopReporter (console + status + log)
143
+
144
+ **Files:** Modify `src/loop/reporter.ts`; Test `tests/loop/reporter.test.ts`
145
+
146
+ - [ ] **Step 1: Write the failing test** (append)
147
+
148
+ ```typescript
149
+ import { readFileSync, existsSync } from 'node:fs'
150
+ import { makeReporter, noopReporter, type LoopReporter } from '../../src/loop/reporter.js'
151
+
152
+ const fixedNow = () => new Date('2026-06-29T10:00:00.000Z')
153
+ const prog = { passed: 0, total: 2 }
154
+
155
+ describe('makeReporter', () => {
156
+ it('writes running state + a log line + console line on storyStart', () => {
157
+ const lines: string[] = []
158
+ const r = makeReporter(dir, { log: (s) => lines.push(s) }, fixedNow)
159
+ r.storyStart({ id: 'S1', title: 'First' }, 1, prog)
160
+ const st = readStatus(dir)!
161
+ expect(st.state).toBe('running')
162
+ expect(st.phase).toBe('implementing')
163
+ expect(st.story).toBe('S1')
164
+ expect(lines.join('\n')).toContain('S1')
165
+ const log = readFileSync(join(dir, '.yoke', 'loop.log'), 'utf8')
166
+ expect(log).toContain('implementing')
167
+ expect(log).toContain('S1')
168
+ })
169
+ it('phase() updates phase but keeps story', () => {
170
+ const r = makeReporter(dir, { log: () => {} }, fixedNow)
171
+ r.storyStart({ id: 'S1', title: 'First' }, 1, prog)
172
+ r.phase('verifying')
173
+ expect(readStatus(dir)).toMatchObject({ state: 'running', phase: 'verifying', story: 'S1' })
174
+ })
175
+ it('blocked() records state + reason', () => {
176
+ const r = makeReporter(dir, { log: () => {} }, fixedNow)
177
+ r.storyStart({ id: 'S1', title: 'First' }, 1, prog)
178
+ r.blocked('verify failed')
179
+ expect(readStatus(dir)).toMatchObject({ state: 'blocked', reason: 'verify failed' })
180
+ })
181
+ it('complete() records state + final progress', () => {
182
+ const r = makeReporter(dir, { log: () => {} }, fixedNow)
183
+ r.complete({ passed: 2, total: 2 })
184
+ expect(readStatus(dir)).toMatchObject({ state: 'complete', progress: { passed: 2, total: 2 } })
185
+ })
186
+ it('quiet suppresses the console callback but still writes files', () => {
187
+ const lines: string[] = []
188
+ const r = makeReporter(dir, { log: (s) => lines.push(s), quiet: true }, fixedNow)
189
+ r.storyStart({ id: 'S1', title: 'First' }, 1, prog)
190
+ expect(lines).toEqual([])
191
+ expect(readStatus(dir)?.story).toBe('S1')
192
+ })
193
+ })
194
+
195
+ describe('noopReporter', () => {
196
+ it('does nothing and writes no files', () => {
197
+ noopReporter.storyStart({ id: 'S1', title: 'x' }, 1, prog)
198
+ noopReporter.blocked('x')
199
+ expect(existsSync(join(dir, '.yoke', 'loop-status.json'))).toBe(false)
200
+ })
201
+ })
202
+ ```
203
+
204
+ - [ ] **Step 2: Run test to verify it fails**
205
+
206
+ Run: `npx vitest run tests/loop/reporter.test.ts`
207
+ Expected: FAIL — `makeReporter`/`noopReporter` not exported.
208
+
209
+ - [ ] **Step 3: Write minimal implementation** (append to `src/loop/reporter.ts`)
210
+
211
+ ```typescript
212
+ import { appendFileSync } from 'node:fs'
213
+
214
+ export interface StoryRef { id: string; title: string }
215
+ export interface Progress { passed: number; total: number }
216
+
217
+ export interface LoopReporter {
218
+ storyStart(story: StoryRef, iteration: number, progress: Progress): void
219
+ phase(phase: LoopPhase): void
220
+ blocked(reason: string): void
221
+ complete(progress: Progress): void
222
+ capReached(progress: Progress): void
223
+ }
224
+
225
+ export interface ReporterOpts {
226
+ log?: (line: string) => void // console sink (defaults to process.stdout)
227
+ quiet?: boolean
228
+ }
229
+
230
+ export function makeReporter(
231
+ dir: string,
232
+ opts: ReporterOpts = {},
233
+ now: () => Date = () => new Date(),
234
+ ): LoopReporter {
235
+ const sink = opts.log ?? ((line: string) => process.stdout.write(line + '\n'))
236
+ const emitConsole = (line: string) => { if (!opts.quiet) sink(line) }
237
+ let current: LoopStatus | null = null
238
+
239
+ const persist = (next: LoopStatus, logLabel: string, consoleLine: string) => {
240
+ current = next
241
+ try {
242
+ writeStatus(dir, next)
243
+ mkdirSync(join(dir, '.yoke'), { recursive: true })
244
+ appendFileSync(
245
+ join(dir, '.yoke', 'loop.log'),
246
+ `${next.updatedAt} ${logLabel} ${next.story ?? '-'} ${next.reason ?? ''}`.trimEnd() + '\n',
247
+ )
248
+ } catch { /* observability must never abort the loop */ }
249
+ emitConsole(consoleLine)
250
+ }
251
+
252
+ return {
253
+ storyStart(story, iteration, progress) {
254
+ const ts = now().toISOString()
255
+ persist(
256
+ { state: 'running', phase: 'implementing', story: story.id, storyTitle: story.title,
257
+ iteration, progress, startedAt: ts, updatedAt: ts },
258
+ 'implementing',
259
+ `▶ ${story.id} (${progress.passed}/${progress.total}) — implementing…`,
260
+ )
261
+ },
262
+ phase(phase) {
263
+ if (!current) return
264
+ persist({ ...current, phase, updatedAt: now().toISOString() }, phase, ` · ${phase}…`)
265
+ },
266
+ blocked(reason) {
267
+ const base = current ?? emptyStatus(now().toISOString())
268
+ persist({ ...base, state: 'blocked', reason, updatedAt: now().toISOString() },
269
+ 'blocked', `■ blocked on ${base.story ?? '?'}: ${reason}`)
270
+ },
271
+ complete(progress) {
272
+ persist({ ...(current ?? emptyStatus(now().toISOString())), state: 'complete', phase: undefined,
273
+ progress, reason: undefined, updatedAt: now().toISOString() },
274
+ 'complete', `✔ loop complete — ${progress.passed}/${progress.total}`)
275
+ },
276
+ capReached(progress) {
277
+ persist({ ...(current ?? emptyStatus(now().toISOString())), state: 'cap-reached', phase: undefined,
278
+ progress, updatedAt: now().toISOString() },
279
+ 'cap-reached', `◾ iteration cap reached — ${progress.passed}/${progress.total}`)
280
+ },
281
+ }
282
+ }
283
+
284
+ function emptyStatus(ts: string): LoopStatus {
285
+ return { state: 'running', iteration: 0, progress: { passed: 0, total: 0 }, startedAt: ts, updatedAt: ts }
286
+ }
287
+
288
+ export const noopReporter: LoopReporter = {
289
+ storyStart() {}, phase() {}, blocked() {}, complete() {}, capReached() {},
290
+ }
291
+ ```
292
+
293
+ - [ ] **Step 4: Run test to verify it passes**
294
+
295
+ Run: `npx vitest run tests/loop/reporter.test.ts`
296
+ Expected: PASS
297
+
298
+ - [ ] **Step 5: Commit**
299
+
300
+ ```bash
301
+ git add src/loop/reporter.ts tests/loop/reporter.test.ts
302
+ git commit -m "feat(loop): LoopReporter writing console + loop-status.json + loop.log"
303
+ ```
304
+
305
+ ---
306
+
307
+ ### Task 3: watchdog — idle-timeout wrapper
308
+
309
+ **Files:** Create `src/loop/watchdog.ts`; Test `tests/loop/watchdog.test.ts`
310
+
311
+ The core `runWatchdog` takes an injectable spawn so timing logic is unit-tested with fake timers; the CLI entry wires real spawn + process streams.
312
+
313
+ - [ ] **Step 1: Write the failing test**
314
+
315
+ ```typescript
316
+ // tests/loop/watchdog.test.ts
317
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
318
+ import { EventEmitter } from 'node:events'
319
+ import { runWatchdog, type SpawnLike } from '../../src/loop/watchdog.js'
320
+
321
+ function fakeChild() {
322
+ const child: any = new EventEmitter()
323
+ child.stdout = new EventEmitter()
324
+ child.stderr = new EventEmitter()
325
+ child.stdin = { }
326
+ child.kill = vi.fn()
327
+ child.pid = 4242
328
+ return child
329
+ }
330
+
331
+ beforeEach(() => { vi.useFakeTimers() })
332
+ afterEach(() => { vi.useRealTimers() })
333
+
334
+ describe('runWatchdog', () => {
335
+ it('does NOT kill while the child keeps emitting output, and passes its exit code', async () => {
336
+ const child = fakeChild()
337
+ const spawnFn: SpawnLike = () => child
338
+ const p = runWatchdog({ command: 'x', args: [], idleMs: 100, spawnFn, stdin: new EventEmitter() as any })
339
+ // emit every 50ms for 300ms — never idle for 100ms
340
+ for (let t = 0; t < 300; t += 50) { vi.advanceTimersByTime(50); child.stdout.emit('data', Buffer.from('x')) }
341
+ child.emit('close', 0)
342
+ await expect(p).resolves.toBe(0)
343
+ expect(child.kill).not.toHaveBeenCalled()
344
+ })
345
+
346
+ it('kills a silent child after the idle window and resolves 124', async () => {
347
+ const child = fakeChild()
348
+ const p = runWatchdog({ command: 'x', args: [], idleMs: 100, spawnFn: () => child, stdin: new EventEmitter() as any })
349
+ vi.advanceTimersByTime(150) // no output for >100ms
350
+ expect(child.kill).toHaveBeenCalled()
351
+ child.emit('close', null) // child dies after kill
352
+ await expect(p).resolves.toBe(124)
353
+ })
354
+
355
+ it('with idleMs=0 never starts a timer and passes the exit code through', async () => {
356
+ const child = fakeChild()
357
+ const p = runWatchdog({ command: 'x', args: [], idleMs: 0, spawnFn: () => child, stdin: new EventEmitter() as any })
358
+ vi.advanceTimersByTime(10_000)
359
+ expect(child.kill).not.toHaveBeenCalled()
360
+ child.emit('close', 3)
361
+ await expect(p).resolves.toBe(3)
362
+ })
363
+ })
364
+ ```
365
+
366
+ - [ ] **Step 2: Run test to verify it fails**
367
+
368
+ Run: `npx vitest run tests/loop/watchdog.test.ts`
369
+ Expected: FAIL — module/exports missing.
370
+
371
+ - [ ] **Step 3: Write minimal implementation**
372
+
373
+ ```typescript
374
+ // src/loop/watchdog.ts
375
+ import { spawn } from 'node:child_process'
376
+ import { pathToFileURL } from 'node:url'
377
+ import type { Readable } from 'node:stream'
378
+
379
+ export interface SpawnLike {
380
+ (command: string, args: string[], opts: { shell: boolean }): {
381
+ stdout: { on(ev: 'data', cb: (d: unknown) => void): void }
382
+ stderr: { on(ev: 'data', cb: (d: unknown) => void): void }
383
+ stdin: unknown
384
+ kill(signal?: string): void
385
+ pid?: number
386
+ on(ev: 'close', cb: (code: number | null) => void): void
387
+ on(ev: 'error', cb: (e: Error) => void): void
388
+ }
389
+ }
390
+
391
+ export interface WatchdogOpts {
392
+ command: string
393
+ args: string[]
394
+ idleMs: number
395
+ spawnFn?: SpawnLike
396
+ stdin?: Readable
397
+ out?: (d: unknown) => void
398
+ err?: (d: unknown) => void
399
+ }
400
+
401
+ // Run `command args`, forwarding stdin in and stdout/stderr out. Reset an idle
402
+ // timer on every output chunk; if no output for idleMs, kill the child and
403
+ // resolve 124. idleMs<=0 disables the timer. Resolves the child's exit code.
404
+ export function runWatchdog(opts: WatchdogOpts): Promise<number> {
405
+ const spawnFn = opts.spawnFn ?? (spawn as unknown as SpawnLike)
406
+ const out = opts.out ?? ((d) => process.stdout.write(d as Buffer))
407
+ const err = opts.err ?? ((d) => process.stderr.write(d as Buffer))
408
+ const child = spawnFn(opts.command, opts.args, { shell: process.platform === 'win32' })
409
+
410
+ if (opts.stdin && (child.stdin as { } )) {
411
+ try { (opts.stdin as Readable).pipe(child.stdin as never) } catch { /* no stdin */ }
412
+ }
413
+
414
+ return new Promise<number>((resolve) => {
415
+ let timer: ReturnType<typeof setTimeout> | undefined
416
+ let killedForIdle = false
417
+ const clear = () => { if (timer) { clearTimeout(timer); timer = undefined } }
418
+ const arm = () => {
419
+ if (opts.idleMs <= 0) return
420
+ clear()
421
+ timer = setTimeout(() => {
422
+ killedForIdle = true
423
+ try { child.kill('SIGTERM') } catch { /* already gone */ }
424
+ }, opts.idleMs)
425
+ }
426
+ child.stdout.on('data', (d) => { out(d); arm() })
427
+ child.stderr.on('data', (d) => { err(d); arm() })
428
+ child.on('error', () => { clear(); resolve(127) })
429
+ child.on('close', (code) => { clear(); resolve(killedForIdle ? 124 : (code ?? 0)) })
430
+ arm() // start the clock so a child that emits nothing is still bounded
431
+ })
432
+ }
433
+
434
+ // CLI: node watchdog.js --idle-ms=N -- <command> [args...]
435
+ export function parseWatchdogArgs(argv: string[]): { idleMs: number; command: string; args: string[] } {
436
+ const sep = argv.indexOf('--')
437
+ const flags = sep === -1 ? argv : argv.slice(0, sep)
438
+ const rest = sep === -1 ? [] : argv.slice(sep + 1)
439
+ const idleArg = flags.find((a) => a.startsWith('--idle-ms='))
440
+ const idleMs = idleArg ? Number(idleArg.slice('--idle-ms='.length)) : 0
441
+ const [command, ...args] = rest
442
+ return { idleMs: Number.isFinite(idleMs) ? idleMs : 0, command: command ?? '', args }
443
+ }
444
+
445
+ const isMain = process.argv[1] ? pathToFileURL(process.argv[1]).href === import.meta.url : false
446
+ if (isMain) {
447
+ const { idleMs, command, args } = parseWatchdogArgs(process.argv.slice(2))
448
+ if (!command) { process.stderr.write('watchdog: no command given\n'); process.exit(2) }
449
+ runWatchdog({ command, args, idleMs, stdin: process.stdin }).then((code) => process.exit(code))
450
+ }
451
+ ```
452
+
453
+ - [ ] **Step 4: Run test to verify it passes**
454
+
455
+ Run: `npx vitest run tests/loop/watchdog.test.ts`
456
+ Expected: PASS
457
+
458
+ - [ ] **Step 5: Add a parse unit test + commit**
459
+
460
+ ```typescript
461
+ // append to tests/loop/watchdog.test.ts
462
+ import { parseWatchdogArgs } from '../../src/loop/watchdog.js'
463
+ describe('parseWatchdogArgs', () => {
464
+ it('splits flags from the command after --', () => {
465
+ expect(parseWatchdogArgs(['--idle-ms=500', '--', 'claude', '-p'])).toEqual({ idleMs: 500, command: 'claude', args: ['-p'] })
466
+ })
467
+ it('defaults idleMs to 0 when absent', () => {
468
+ expect(parseWatchdogArgs(['--', 'node']).idleMs).toBe(0)
469
+ })
470
+ })
471
+ ```
472
+ Run: `npx vitest run tests/loop/watchdog.test.ts` → PASS, then:
473
+ ```bash
474
+ git add src/loop/watchdog.ts tests/loop/watchdog.test.ts
475
+ git commit -m "feat(loop): idle-timeout watchdog (kills only on no-output, not total runtime)"
476
+ ```
477
+
478
+ ---
479
+
480
+ ### Task 4: runner — route the agent through the watchdog
481
+
482
+ **Files:** Modify `src/loop/runner.ts`; Test `tests/loop/runner.test.ts`
483
+
484
+ - [ ] **Step 1: Write the failing test** (append)
485
+
486
+ ```typescript
487
+ import { buildWatchdogInvocation } from '../../src/loop/runner.js'
488
+
489
+ describe('idle-timeout wiring', () => {
490
+ it('wraps the agent command in the watchdog when idleTimeoutMs > 0', () => {
491
+ const inv = buildWatchdogInvocation({ command: 'claude', args: ['-p'], input: 'hi', cwd: '.' }, 1200000)
492
+ expect(inv.command).toBe('node')
493
+ expect(inv.args.join(' ')).toContain('watchdog.js')
494
+ expect(inv.args.join(' ')).toContain('--idle-ms=1200000')
495
+ expect(inv.args.join(' ')).toContain('-- claude -p') // original command after --
496
+ expect(inv.input).toBe('hi')
497
+ })
498
+ it('returns the invocation unchanged when idleTimeoutMs is 0', () => {
499
+ const base = { command: 'claude', args: ['-p'], input: 'hi', cwd: '.' }
500
+ expect(buildWatchdogInvocation(base, 0)).toEqual(base)
501
+ })
502
+ })
503
+ ```
504
+
505
+ - [ ] **Step 2: Run test to verify it fails**
506
+
507
+ Run: `npx vitest run tests/loop/runner.test.ts`
508
+ Expected: FAIL — `buildWatchdogInvocation` not exported.
509
+
510
+ - [ ] **Step 3: Write minimal implementation**
511
+
512
+ In `src/loop/runner.ts` add the import and helper (near `agentInvocation`):
513
+ ```typescript
514
+ import { fileURLToPath } from 'node:url'
515
+
516
+ function watchdogPath(): string {
517
+ // runner.js and watchdog.js sit side by side in dist/loop/ (and src/loop/ under tsx)
518
+ return fileURLToPath(new URL('./watchdog.js', import.meta.url))
519
+ }
520
+
521
+ // When idleTimeoutMs > 0, run the agent THROUGH the watchdog so a silent hang is
522
+ // killed after idleTimeoutMs of no output. The prompt still flows via stdin.
523
+ export function buildWatchdogInvocation(inv: Invocation, idleTimeoutMs: number): Invocation {
524
+ if (idleTimeoutMs <= 0) return inv
525
+ return {
526
+ command: 'node',
527
+ args: [watchdogPath(), `--idle-ms=${idleTimeoutMs}`, '--', inv.command, ...inv.args],
528
+ input: inv.input,
529
+ cwd: inv.cwd,
530
+ }
531
+ }
532
+ ```
533
+
534
+ Thread an optional `idleTimeoutMs` into the runner factories. Change `makeRunner` and `makeReviewRunner` signatures:
535
+ ```typescript
536
+ export function makeRunner(agent: Agent, idleTimeoutMs = 0): AgentRunner {
537
+ return (ctx: AgentContext): AgentResult => {
538
+ const base = agentInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir)), ctx.targetDir)
539
+ const inv = buildWatchdogInvocation(base, idleTimeoutMs)
540
+ try {
541
+ runCli(inv)
542
+ return { success: true, summary: `${agent} implemented ${ctx.story.id}` }
543
+ } catch (e) {
544
+ return { success: false, summary: `${agent} failed on ${ctx.story.id}: ${(e as Error).message}` }
545
+ }
546
+ }
547
+ }
548
+
549
+ export function makeReviewRunner(agent: Agent, idleTimeoutMs = 0): AgentRunner {
550
+ return (ctx: AgentContext): AgentResult => {
551
+ const base = agentInvocation(agent, buildReviewPrompt(ctx.story, contextBlockFor(ctx.targetDir)), ctx.targetDir)
552
+ const inv = buildWatchdogInvocation(base, idleTimeoutMs)
553
+ try {
554
+ runCli(inv)
555
+ return { success: true, summary: `${agent} approved ${ctx.story.id}` }
556
+ } catch (e) {
557
+ return { success: false, summary: `${agent} rejected ${ctx.story.id}: ${(e as Error).message}` }
558
+ }
559
+ }
560
+ }
561
+ ```
562
+ `claudeRunner` stays `makeRunner('claude')` (idle 0 → unchanged behavior).
563
+
564
+ - [ ] **Step 4: Run tests to verify they pass**
565
+
566
+ Run: `npx vitest run tests/loop/runner.test.ts`
567
+ Expected: PASS (new + existing runner tests).
568
+
569
+ - [ ] **Step 5: Commit**
570
+
571
+ ```bash
572
+ git add src/loop/runner.ts tests/loop/runner.test.ts
573
+ git commit -m "feat(loop): route agent + reviewer runs through the idle watchdog"
574
+ ```
575
+
576
+ ---
577
+
578
+ ### Task 5: loop — drive the reporter + leftover hint
579
+
580
+ **Files:** Modify `src/loop/loop.ts`; Test `tests/loop/loop.test.ts`
581
+
582
+ - [ ] **Step 1: Write the failing test** (append; reuses existing `dir`, `prd()`, fakes)
583
+
584
+ ```typescript
585
+ import { makeReporter, type LoopReporter } from '../../src/loop/reporter.js'
586
+
587
+ function recordingReporter(): { reporter: LoopReporter; events: string[] } {
588
+ const events: string[] = []
589
+ const reporter: LoopReporter = {
590
+ storyStart: (s) => events.push(`start:${s.id}`),
591
+ phase: (p) => events.push(`phase:${p}`),
592
+ blocked: (r) => events.push(`blocked:${r}`),
593
+ complete: () => events.push('complete'),
594
+ capReached: () => events.push('cap'),
595
+ }
596
+ return { reporter, events }
597
+ }
598
+
599
+ it('drives the reporter through phases on success and reports complete', () => {
600
+ const { reporter, events } = recordingReporter()
601
+ runLoop({ prdPath: prd(), targetDir: dir, runner: alwaysPass, git: cleanGit(), verify: verifyOk, maxIterations: 10, reporter })
602
+ expect(events).toContain('start:S1')
603
+ expect(events).toContain('phase:verifying')
604
+ expect(events).toContain('phase:committing')
605
+ expect(events[events.length - 1]).toBe('complete')
606
+ })
607
+
608
+ it('reports blocked with a leftover hint when the tree is dirty after a block', () => {
609
+ const { reporter, events } = recordingReporter()
610
+ const dirtyAfter: GitOps = { isClean: () => false, commitAll: () => { throw new Error('boom') }, addWorktree: () => {}, removeWorktree: () => {}, integrate: () => {} }
611
+ runLoop({ prdPath: prd(), targetDir: dir, runner: alwaysPass, git: dirtyAfter, verify: verifyOk, maxIterations: 10, reporter })
612
+ const blocked = events.find(e => e.startsWith('blocked:'))!
613
+ expect(blocked).toMatch(/uncommitted changes/i)
614
+ })
615
+ ```
616
+ Note: the dirty-tree block path — the commit throws, the loop reverts and returns blocked; the reporter's `blocked` reason should carry the leftover hint because `git.isClean` is false. (The pre-dispatch gate runs at the top of the NEXT iteration; for this test the first iteration reaches the commit and fails there.) If `isClean:()=>false` also trips the pre-dispatch gate first, adjust the fake so `isClean` returns true for the gate then false after — simplest: keep a counter. Implement the fake as: first call (pre-dispatch) true, later false. Use a closure counter so the gate passes but the post-block leftover check sees dirty.
617
+
618
+ - [ ] **Step 2: Run test to verify it fails**
619
+
620
+ Run: `npx vitest run tests/loop/loop.test.ts`
621
+ Expected: FAIL — `LoopOptions` has no `reporter`; events empty.
622
+
623
+ - [ ] **Step 3: Write minimal implementation**
624
+
625
+ In `src/loop/loop.ts`:
626
+ - Import: `import { noopReporter, type LoopReporter } from './reporter.js'`
627
+ - Add `reporter?: LoopReporter` to `LoopOptions`. At the top of `runLoop`, `const reporter = opts.reporter ?? noopReporter`.
628
+ - Add a helper for the block path:
629
+ ```typescript
630
+ function blockReason(base: string, targetDir: string, git: GitOps): string {
631
+ let dirty = false
632
+ try { dirty = !git.isClean(targetDir) } catch { /* ignore */ }
633
+ return dirty
634
+ ? `${base} (working tree has uncommitted changes from the blocked story — review/clean before re-running)`
635
+ : base
636
+ }
637
+ ```
638
+ - Call the reporter at each boundary. Wherever the loop currently `return { status: 'blocked', ... reason }`, first call `reporter.blocked(blockReason(reason, opts.targetDir, opts.git))` (use the same string in the return `reason`). Wherever it returns `complete`, call `reporter.complete(progress(stories))`; for `cap-reached`, `reporter.capReached(...)`. After selecting a story (both isolate and non-isolate paths), call `reporter.storyStart({ id: story.id, title: story.title }, iterations + 1, progress(stories))` before dispatch; call `reporter.phase('verifying')` before `opts.verify(...)`, `reporter.phase('reviewing')` before the review call, and `reporter.phase('committing')` before `appendDecision`/`commitAll`.
639
+
640
+ Apply to BOTH the isolate and non-isolate branches. Keep the returned `reason` strings identical to what `reporter.blocked` was given (so console and return value agree). Example for the non-isolate verify-fail:
641
+ ```typescript
642
+ const verdict = opts.verify(opts.targetDir)
643
+ if (!verdict.passed) {
644
+ const reason = blockReason(`story ${story.id} did not verify: ${verdict.summary}`, opts.targetDir, opts.git)
645
+ reporter.blocked(reason)
646
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) }
647
+ }
648
+ ```
649
+
650
+ - [ ] **Step 4: Run tests to verify they pass**
651
+
652
+ Run: `npx vitest run tests/loop/loop.test.ts`
653
+ Expected: PASS (new + all existing loop tests — existing ones use no `reporter`, so `noopReporter` keeps them green).
654
+
655
+ - [ ] **Step 5: Commit**
656
+
657
+ ```bash
658
+ git add src/loop/loop.ts tests/loop/loop.test.ts
659
+ git commit -m "feat(loop): narrate phases via LoopReporter + leftover hint on block"
660
+ ```
661
+
662
+ ---
663
+
664
+ ### Task 6: run-command + cli — resolve timeout, wire reporter, upgrade `loop status`
665
+
666
+ **Files:** Modify `src/loop/run-command.ts`, `src/cli.ts`; Test `tests/loop/run-command` coverage via a new `tests/loop/loop-status-render.test.ts`
667
+
668
+ - [ ] **Step 1: Write the failing test**
669
+
670
+ ```typescript
671
+ // tests/loop/loop-status-render.test.ts
672
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
673
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
674
+ import { join } from 'node:path'
675
+ import { tmpdir } from 'node:os'
676
+ import { writeStatus } from '../../src/loop/reporter.js'
677
+ import { loopStatus } from '../../src/loop/run-command.js'
678
+
679
+ let dir: string
680
+ beforeEach(() => {
681
+ dir = mkdtempSync(join(tmpdir(), 'yoke-ls-'))
682
+ mkdirSync(join(dir, '.yoke'), { recursive: true })
683
+ writeFileSync(join(dir, '.yoke', 'config.yaml'), 'canonVersion: "0.1.0"\nagents: [claude]\nloop:\n enabled: true\n')
684
+ writeFileSync(join(dir, '.yoke', 'prd.yaml'), '- { id: S1, title: t, priority: 1, acceptance: ["x"], passes: true }')
685
+ })
686
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
687
+
688
+ describe('loopStatus with a status file', () => {
689
+ it('renders state, story and reason when blocked', () => {
690
+ writeStatus(dir, { state: 'blocked', story: 'S5', storyTitle: 'Schemas', reason: 'verify failed',
691
+ iteration: 19, progress: { passed: 18, total: 45 },
692
+ startedAt: '2026-06-29T10:00:00.000Z', updatedAt: '2026-06-29T10:00:00.000Z' })
693
+ const out = loopStatus(dir)
694
+ expect(out).toMatch(/blocked/i)
695
+ expect(out).toContain('S5')
696
+ expect(out).toContain('verify failed')
697
+ expect(out).toContain('18/45')
698
+ })
699
+ it('falls back to enabled + PRD progress when no status file exists', () => {
700
+ const out = loopStatus(dir)
701
+ expect(out).toMatch(/enabled/i)
702
+ expect(out).toMatch(/1\/1/)
703
+ })
704
+ })
705
+ ```
706
+
707
+ - [ ] **Step 2: Run test to verify it fails**
708
+
709
+ Run: `npx vitest run tests/loop/loop-status-render.test.ts`
710
+ Expected: FAIL — `loopStatus` does not yet read the status file.
711
+
712
+ - [ ] **Step 3: Implement** in `src/loop/run-command.ts`
713
+
714
+ Add imports:
715
+ ```typescript
716
+ import { readStatus, makeReporter } from './reporter.js'
717
+ export const DEFAULT_IDLE_MINUTES = 20
718
+ ```
719
+ Replace `loopStatus` with a version that prefers the status file:
720
+ ```typescript
721
+ export function loopStatus(targetDir: string): string {
722
+ const config = loadConfig(targetDir)
723
+ const enabled = config?.loop.enabled ?? false
724
+ const path = prdPath(targetDir)
725
+ let prog = 'no PRD'
726
+ if (existsSync(path)) {
727
+ const p = progress(loadPrd(path))
728
+ prog = `${p.passed}/${p.total} stories pass`
729
+ }
730
+ const st = readStatus(targetDir)
731
+ if (!st) return `Loop: ${enabled ? 'enabled' : 'disabled'}\nPRD: ${prog}`
732
+ const lines = [
733
+ `Loop: ${st.state.toUpperCase()}${st.story ? ` on ${st.story}${st.storyTitle ? ` "${st.storyTitle}"` : ''}` : ''}`,
734
+ ]
735
+ const meta = [st.phase, `iteration ${st.iteration}`, `${st.progress.passed}/${st.progress.total}`,
736
+ `updated ${st.updatedAt}`].filter(Boolean).join(' · ')
737
+ lines.push(` ${meta}`)
738
+ if (st.reason) lines.push(` reason: ${st.reason}`)
739
+ return lines.join('\n')
740
+ }
741
+ ```
742
+ Add the timeout resolution + reporter/runner wiring in `runLoopCommand`. Extend `RunLoopCommandOptions` with `timeoutMinutes?: number`. After resolving `runnerAgent` and before building runners:
743
+ ```typescript
744
+ const idleMinutes = opts.timeoutMinutes ?? config.loop.timeoutMinutes ?? DEFAULT_IDLE_MINUTES
745
+ const idleMs = idleMinutes > 0 ? idleMinutes * 60_000 : 0
746
+ ```
747
+ Build runners with the idle budget (only when not injected for tests):
748
+ ```typescript
749
+ runner = makeRunner(runnerAgent, idleMs)
750
+ // ...
751
+ review = makeReviewRunner(reviewerAgent, idleMs)
752
+ ```
753
+ Pass a real reporter into `runLoop`:
754
+ ```typescript
755
+ const result = runLoop({
756
+ prdPath: path, targetDir, runner, git: opts.git ?? realGitOps, verify,
757
+ maxIterations: opts.maxIterations, isolate: opts.isolate ?? false, review,
758
+ reporter: opts.reporter ?? makeReporter(targetDir),
759
+ })
760
+ ```
761
+ Add `reporter?: LoopReporter` (import the type) and `timeoutMinutes?: number` to `RunLoopCommandOptions`.
762
+
763
+ - [ ] **Step 4: Wire the CLI flag** in `src/cli.ts` (inside the `loop run` branch, mirror `--max` parsing):
764
+ ```typescript
765
+ const toArg = rest.find(a => a.startsWith('--timeout='))
766
+ let timeoutMinutes: number | undefined
767
+ if (toArg) {
768
+ const v = Number(toArg.slice('--timeout='.length))
769
+ if (!Number.isFinite(v) || v < 0) { console.error(`Invalid --timeout value: ${toArg}`); return 1 }
770
+ timeoutMinutes = v
771
+ }
772
+ ```
773
+ and pass `timeoutMinutes` into the `runLoopCommand(targetDir, { ... })` call. Add `--timeout=<minutes>` to the `loop` usage strings.
774
+
775
+ - [ ] **Step 5: Run tests**
776
+
777
+ Run: `npx vitest run tests/loop/ && npx tsc --noEmit`
778
+ Expected: PASS, clean.
779
+
780
+ - [ ] **Step 6: Commit**
781
+
782
+ ```bash
783
+ git add src/loop/run-command.ts src/cli.ts tests/loop/loop-status-render.test.ts
784
+ git commit -m "feat(loop): resolve idle timeout, wire reporter, upgrade yoke loop status"
785
+ ```
786
+
787
+ ---
788
+
789
+ ### Task 7: config — optional `loop.timeoutMinutes`
790
+
791
+ **Files:** Modify `src/retrofit/config.ts`; Test `tests/retrofit/config.test.ts`
792
+
793
+ - [ ] **Step 1: Write the failing test** (append)
794
+
795
+ ```typescript
796
+ it('accepts an optional loop.timeoutMinutes', () => {
797
+ const cfg = { canonVersion: '0.1.0', agents: ['claude'], loop: { enabled: true, timeoutMinutes: 30 } }
798
+ const parsed = YokeConfigSchema.parse(cfg)
799
+ expect(parsed.loop.timeoutMinutes).toBe(30)
800
+ })
801
+ it('still accepts a loop without timeoutMinutes', () => {
802
+ const parsed = YokeConfigSchema.parse({ canonVersion: '0.1.0', agents: [], loop: { enabled: false } })
803
+ expect(parsed.loop.timeoutMinutes).toBeUndefined()
804
+ })
805
+ ```
806
+ (If `YokeConfigSchema` isn't exported, export it, or assert via `loadConfig` against a temp file — match the file's existing idiom.)
807
+
808
+ - [ ] **Step 2: Run test to verify it fails**
809
+
810
+ Run: `npx vitest run tests/retrofit/config.test.ts`
811
+ Expected: FAIL — `timeoutMinutes` rejected/stripped.
812
+
813
+ - [ ] **Step 3: Implement** — update the schema + interface in `src/retrofit/config.ts`:
814
+ ```typescript
815
+ const YokeConfigSchema = z.object({
816
+ canonVersion: z.string().min(1),
817
+ agents: z.array(AgentSchema),
818
+ loop: z.object({ enabled: z.boolean(), timeoutMinutes: z.number().nonnegative().optional() }),
819
+ verify: z.object({ command: z.string().min(1) }).optional(),
820
+ codeGraph: CodeGraphSchema.optional(),
821
+ })
822
+
823
+ export interface YokeConfig {
824
+ canonVersion: string
825
+ agents: Agent[]
826
+ loop: { enabled: boolean; timeoutMinutes?: number }
827
+ verify?: { command: string }
828
+ codeGraph?: CodeGraph
829
+ }
830
+ ```
831
+
832
+ - [ ] **Step 4: Run tests** — `npx vitest run tests/retrofit/config.test.ts` → PASS.
833
+
834
+ - [ ] **Step 5: Commit**
835
+ ```bash
836
+ git add src/retrofit/config.ts tests/retrofit/config.test.ts
837
+ git commit -m "feat(config): optional loop.timeoutMinutes (idle minutes)"
838
+ ```
839
+
840
+ ---
841
+
842
+ ### Task 8: retrofit — `.gitignore` manager for `.yoke/` runtime files
843
+
844
+ **Files:** Create `src/retrofit/gitignore.ts`; Modify `src/cli.ts` (`runRetrofit`); Test `tests/retrofit/gitignore.test.ts`
845
+
846
+ This closes a real gap: retrofit never managed the target `.gitignore`, so `.yoke/loop-status.json` / `.yoke/loop.log` (and `worktrees`/`backup`) would dirty the tree and break the clean-tree gate.
847
+
848
+ - [ ] **Step 1: Write the failing test**
849
+
850
+ ```typescript
851
+ // tests/retrofit/gitignore.test.ts
852
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
853
+ import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'
854
+ import { join } from 'node:path'
855
+ import { tmpdir } from 'node:os'
856
+ import { ensureGitignore, YOKE_IGNORE_LINES } from '../../src/retrofit/gitignore.js'
857
+
858
+ let dir: string
859
+ beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'yoke-gi-')) })
860
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
861
+ const gi = () => join(dir, '.gitignore')
862
+
863
+ describe('ensureGitignore', () => {
864
+ it('creates .gitignore with the managed block when absent', () => {
865
+ ensureGitignore(dir)
866
+ const text = readFileSync(gi(), 'utf8')
867
+ for (const line of YOKE_IGNORE_LINES) expect(text).toContain(line)
868
+ })
869
+ it('appends the block without disturbing existing content', () => {
870
+ writeFileSync(gi(), 'node_modules/\n')
871
+ ensureGitignore(dir)
872
+ const text = readFileSync(gi(), 'utf8')
873
+ expect(text).toContain('node_modules/')
874
+ expect(text).toContain('.yoke/loop-status.json')
875
+ })
876
+ it('is idempotent — a second run adds nothing', () => {
877
+ ensureGitignore(dir)
878
+ const first = readFileSync(gi(), 'utf8')
879
+ ensureGitignore(dir)
880
+ expect(readFileSync(gi(), 'utf8')).toBe(first)
881
+ })
882
+ it('does not re-add a line that already exists individually', () => {
883
+ writeFileSync(gi(), '.yoke/backup/\n')
884
+ ensureGitignore(dir)
885
+ const text = readFileSync(gi(), 'utf8')
886
+ expect(text.match(/\.yoke\/backup\//g)?.length).toBe(1)
887
+ })
888
+ })
889
+ ```
890
+
891
+ - [ ] **Step 2: Run test to verify it fails**
892
+
893
+ Run: `npx vitest run tests/retrofit/gitignore.test.ts`
894
+ Expected: FAIL — module missing.
895
+
896
+ - [ ] **Step 3: Implement**
897
+
898
+ ```typescript
899
+ // src/retrofit/gitignore.ts
900
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
901
+ import { join } from 'node:path'
902
+
903
+ export const YOKE_IGNORE_LINES = [
904
+ '.yoke/worktrees/',
905
+ '.yoke/backup/',
906
+ '.yoke/loop-status.json',
907
+ '.yoke/loop.log',
908
+ ]
909
+
910
+ const HEADER = '# Yoke runtime artifacts (managed by yoke retrofit)'
911
+
912
+ // Idempotently ensure each Yoke runtime path is gitignored. Appends only the
913
+ // lines that are not already present (matched verbatim, line-wise). Preserves
914
+ // all existing content. Returns true if the file changed.
915
+ export function ensureGitignore(targetDir: string): boolean {
916
+ const file = join(targetDir, '.gitignore')
917
+ const current = existsSync(file) ? readFileSync(file, 'utf8') : ''
918
+ const present = new Set(current.split(/\r?\n/).map((l) => l.trim()))
919
+ const missing = YOKE_IGNORE_LINES.filter((l) => !present.has(l))
920
+ if (missing.length === 0) return false
921
+ const prefix = current === '' ? '' : current.endsWith('\n') ? '' : '\n'
922
+ const block = `${prefix}${current === '' ? '' : '\n'}${HEADER}\n${missing.join('\n')}\n`
923
+ writeFileSync(file, current + block)
924
+ return true
925
+ }
926
+ ```
927
+
928
+ - [ ] **Step 4: Run tests** — `npx vitest run tests/retrofit/gitignore.test.ts` → PASS.
929
+
930
+ - [ ] **Step 5: Wire into retrofit** — in `src/cli.ts` `runRetrofit`, after `applyActions(...)` and before `saveConfig`/report, add:
931
+ ```typescript
932
+ import { ensureGitignore } from './retrofit/gitignore.js'
933
+ // ...
934
+ ensureGitignore(targetDir)
935
+ ```
936
+ (Place the import with the other retrofit imports.)
937
+
938
+ - [ ] **Step 6: Run the full suite + commit**
939
+
940
+ Run: `npx vitest run && npx tsc --noEmit`
941
+ Expected: PASS, clean.
942
+ ```bash
943
+ git add src/retrofit/gitignore.ts src/cli.ts tests/retrofit/gitignore.test.ts
944
+ git commit -m "feat(retrofit): manage .gitignore for .yoke runtime files (status/log/worktrees/backup)"
945
+ ```
946
+
947
+ ---
948
+
949
+ ### Task 9: docs — README loop observability
950
+
951
+ **Files:** Modify `README.md`
952
+
953
+ - [ ] **Step 1: Update the autonomous-loop section** — add after the `yoke loop ...` example block:
954
+ ```markdown
955
+ ### Watching a run
956
+
957
+ Every iteration writes token-free, harness-side feedback:
958
+
959
+ - **Live console** — `▶ S6 (19/45) — implementing… · verifying… ✔ committed`.
960
+ - **`.yoke/loop-status.json`** — the current state; read it with `yoke loop status`:
961
+ ```
962
+ Loop: BLOCKED on S5 "Segment schemas"
963
+ verifying · iteration 19 · 18/45 · updated 2026-06-29T10:00:00Z
964
+ reason: story did not verify (working tree has uncommitted changes — clean before re-running)
965
+ ```
966
+ - **`.yoke/loop.log`** — an append-only timeline of every phase transition.
967
+
968
+ A per-iteration **idle timeout** guards against a genuinely hung agent: if the agent
969
+ produces *no output at all* for `--timeout` minutes (default 20; `0` disables), the loop
970
+ kills it and marks the story blocked. A slow-but-working agent that keeps streaming output
971
+ is never killed — the output stream is the liveness signal. Set a project default with
972
+ `loop.timeoutMinutes` in `.yoke/config.yaml`.
973
+
974
+ `.yoke/loop-status.json` and `.yoke/loop.log` are runtime artifacts; `yoke retrofit`
975
+ gitignores them so they never trip the clean-tree gate.
976
+ ```
977
+
978
+ - [ ] **Step 2: Verify nothing references removed symbols** — `npx vitest run` → PASS.
979
+
980
+ - [ ] **Step 3: Commit**
981
+ ```bash
982
+ git add README.md
983
+ git commit -m "docs: document loop observability (status, log, idle timeout)"
984
+ ```
985
+
986
+ ---
987
+
988
+ ## Self-Review
989
+
990
+ **Spec coverage:**
991
+ - Status types + atomic read/write → Task 1.
992
+ - LoopReporter (console + status + log), zero-token feedback → Task 2.
993
+ - Idle watchdog (kills only on no-output, not total runtime) → Task 3.
994
+ - Runner routes agent + reviewer through the watchdog; `idleTimeoutMs` resolution → Tasks 4 & 6.
995
+ - Loop drives the reporter at each phase + leftover hint → Task 5.
996
+ - `yoke loop status` upgrade with fallback → Task 6.
997
+ - `--timeout` flag + resolution (flag > config > 20) → Tasks 6 & 7.
998
+ - `loop.timeoutMinutes` config → Task 7.
999
+ - gitignore so runtime files don't break the clean-tree gate → Task 8 (also fixes the pre-existing worktrees/backup gap).
1000
+ - Docs → Task 9.
1001
+
1002
+ **Placeholder scan:** No TBD/TODO. Every code step shows complete code. The Task 5 fake-Git note explains the closure-counter for the dirty-after-block case explicitly rather than hand-waving.
1003
+
1004
+ **Type consistency:** `LoopStatus`/`LoopPhase`/`LoopReporter`/`StoryRef`/`Progress`, `makeReporter(dir, opts, now)`, `noopReporter`, `runWatchdog`/`parseWatchdogArgs`/`SpawnLike`, `buildWatchdogInvocation(inv, idleTimeoutMs)`, `makeRunner(agent, idleTimeoutMs)`, `ensureGitignore`/`YOKE_IGNORE_LINES`, `loop.timeoutMinutes`, `DEFAULT_IDLE_MINUTES` are defined once and referenced consistently. The `Invocation` shape (`{command,args,input,cwd}`) is unchanged; the watchdog wrapping produces a new `Invocation` of the same shape.
1005
+
1006
+ **Integration note:** the clean-tree gate concern is covered by Task 8 (gitignore). Existing downstream projects must re-run `yoke retrofit` (or add the four lines manually) to benefit — documented in Task 9.