@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,374 @@
1
+ # Baustein H — Loop Robustness 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 trust **verify** rather than the agent's exit code, and tolerate transient test flakes — closing two real block causes (SE5, T4) seen in production.
6
+
7
+ **Architecture:** A `retryingVerifier` wrapper in `src/loop/verify.ts`; `runLoop` runs verify regardless of the implementer runner's exit and blocks only on verify failure (the reviewer keeps exit-as-verdict); `verify.retries` config wired in `run-command.ts`. No new modules.
8
+
9
+ **Tech Stack:** Node.js + TypeScript (ESM, `.js` specifiers, strict), vitest. `npx vitest run`, `npx tsc --noEmit`.
10
+
11
+ ---
12
+
13
+ ## File Structure
14
+
15
+ | File | Change |
16
+ |------|--------|
17
+ | `src/loop/verify.ts` | Add `retryingVerifier(inner, retries)` |
18
+ | `src/loop/loop.ts` | Verify-as-truth in both isolate + non-isolate paths |
19
+ | `src/retrofit/config.ts` | `verify.retries?: number` |
20
+ | `src/loop/run-command.ts` | Wrap the command verifier with the resolved retry count |
21
+ | `canon/loop/loop-spec.md`, `README.md` | Document verify-as-truth + `verify.retries` |
22
+
23
+ ---
24
+
25
+ ### Task 1: `retryingVerifier`
26
+
27
+ **Files:** Modify `src/loop/verify.ts`; Test `tests/loop/verify.test.ts`
28
+
29
+ - [ ] **Step 1: Write the failing test** (append to `tests/loop/verify.test.ts`; if the file doesn't exist, create it with the vitest imports):
30
+
31
+ ```typescript
32
+ import { describe, it, expect } from 'vitest'
33
+ import { retryingVerifier, type Verifier, type VerifyResult } from '../../src/loop/verify.js'
34
+
35
+ function stub(results: VerifyResult[]): Verifier {
36
+ let i = 0
37
+ return () => results[Math.min(i++, results.length - 1)]
38
+ }
39
+ const ok: VerifyResult = { passed: true, summary: 'green' }
40
+ const bad: VerifyResult = { passed: false, summary: 'red' }
41
+
42
+ describe('retryingVerifier', () => {
43
+ it('passes immediately without retrying when the inner verifier passes', () => {
44
+ let calls = 0
45
+ const inner: Verifier = () => { calls++; return ok }
46
+ expect(retryingVerifier(inner, 2)('/d').passed).toBe(true)
47
+ expect(calls).toBe(1)
48
+ })
49
+ it('passes on a retry when the inner fails then passes', () => {
50
+ const r = retryingVerifier(stub([bad, ok]), 2)('/d')
51
+ expect(r.passed).toBe(true)
52
+ expect(r.summary).toMatch(/retry 1/i)
53
+ })
54
+ it('fails after exhausting the retries', () => {
55
+ let calls = 0
56
+ const inner: Verifier = () => { calls++; return bad }
57
+ const r = retryingVerifier(inner, 2)('/d')
58
+ expect(r.passed).toBe(false)
59
+ expect(calls).toBe(3) // 1 initial + 2 retries
60
+ expect(r.summary).toMatch(/after 2 retr/i)
61
+ })
62
+ it('retries:0 is a single shot', () => {
63
+ let calls = 0
64
+ const inner: Verifier = () => { calls++; return bad }
65
+ expect(retryingVerifier(inner, 0)('/d').passed).toBe(false)
66
+ expect(calls).toBe(1)
67
+ })
68
+ })
69
+ ```
70
+
71
+ - [ ] **Step 2: Run test to verify it fails**
72
+
73
+ Run: `npx vitest run tests/loop/verify.test.ts`
74
+ Expected: FAIL — `retryingVerifier` not exported.
75
+
76
+ - [ ] **Step 3: Implement** — append to `src/loop/verify.ts`:
77
+
78
+ ```typescript
79
+ // Re-run a failing verifier up to `retries` times; the first pass wins. Lets a
80
+ // transient flake (e.g. a load-induced async timeout) self-heal while a real
81
+ // failure still fails (it stays red across every attempt).
82
+ export function retryingVerifier(inner: Verifier, retries: number): Verifier {
83
+ return (targetDir: string): VerifyResult => {
84
+ let last = inner(targetDir)
85
+ let attempt = 0
86
+ while (!last.passed && attempt < retries) {
87
+ attempt++
88
+ last = inner(targetDir)
89
+ }
90
+ if (last.passed && attempt > 0) {
91
+ return { passed: true, summary: `${last.summary} (passed on retry ${attempt})` }
92
+ }
93
+ if (!last.passed && attempt > 0) {
94
+ return { passed: false, summary: `${last.summary} (still failing after ${attempt} retr${attempt === 1 ? 'y' : 'ies'})` }
95
+ }
96
+ return last
97
+ }
98
+ }
99
+ ```
100
+
101
+ - [ ] **Step 4: Run test to verify it passes**
102
+
103
+ Run: `npx vitest run tests/loop/verify.test.ts`
104
+ Expected: PASS
105
+
106
+ - [ ] **Step 5: Commit**
107
+
108
+ ```bash
109
+ git add src/loop/verify.ts tests/loop/verify.test.ts
110
+ git commit -m "feat(loop): retryingVerifier — tolerate transient verify flakes"
111
+ ```
112
+
113
+ ---
114
+
115
+ ### Task 2: verify is the source of truth in `runLoop`
116
+
117
+ **Files:** Modify `src/loop/loop.ts`; Test `tests/loop/loop.test.ts`
118
+
119
+ - [ ] **Step 1: Write the failing tests** (append to `tests/loop/loop.test.ts`; reuse existing `dir`, `prd()`, `cleanGit`, `verifyOk`, and add a failing runner):
120
+
121
+ ```typescript
122
+ it('commits a story when the runner reports failure but verify is GREEN (exit-code ghost)', () => {
123
+ const commits: string[] = []
124
+ const git: GitOps = { isClean: () => true, commitAll: (_d, m) => commits.push(m), addWorktree: () => {}, removeWorktree: () => {}, integrate: () => {} }
125
+ const runnerGhost: AgentRunner = () => ({ success: false, summary: 'exit 127 from claude.cmd' })
126
+ const res = runLoop({ prdPath: prd(), targetDir: dir, runner: runnerGhost, git, verify: verifyOk, maxIterations: 10 })
127
+ expect(res.status).toBe('complete')
128
+ expect(commits.length).toBe(2) // both stories committed despite runner "failure"
129
+ expect(loadPrd(prd()).every(s => s.passes)).toBe(true)
130
+ })
131
+
132
+ it('blocks when the runner fails AND verify is red, naming both', () => {
133
+ const runnerBad: AgentRunner = () => ({ success: false, summary: 'agent boom' })
134
+ const verifyBad: Verifier = () => ({ passed: false, summary: 'tests red' })
135
+ const res = runLoop({ prdPath: prd(), targetDir: dir, runner: runnerBad, git: cleanGit(), verify: verifyBad, maxIterations: 10 })
136
+ expect(res.status).toBe('blocked')
137
+ expect(res.reason).toMatch(/agent boom/)
138
+ expect(res.reason).toMatch(/tests red/)
139
+ })
140
+ ```
141
+ (The existing "blocks when the runner fails a story" test asserted a runner failure blocks; it used a passing verify implicitly? Check it — if it relied on runner-failure-blocks-before-verify with a passing verify, it now changes meaning. UPDATE that existing test: a runner failure with a GREEN verify should now COMPLETE, not block. Find the test "blocks when the runner fails a story" and either repurpose it to the ghost case above or pair the failing runner with a failing verify so it still blocks. Make the existing test internally consistent with verify-as-truth — do NOT just delete coverage.)
142
+
143
+ - [ ] **Step 2: Run tests to verify they fail**
144
+
145
+ Run: `npx vitest run tests/loop/loop.test.ts`
146
+ Expected: FAIL — the ghost case currently blocks (runner failure is checked before verify).
147
+
148
+ - [ ] **Step 3: Implement — non-isolate path.** In `src/loop/loop.ts`, find the non-isolate block:
149
+ ```typescript
150
+ const result = opts.runner({ targetDir: opts.targetDir, story })
151
+ iterations++
152
+
153
+ if (!result.success) {
154
+ const reason = blockReason(`story ${story.id} failed: ${result.summary}`, opts.targetDir, opts.git)
155
+ reporter.blocked(reason)
156
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) }
157
+ }
158
+
159
+ reporter.phase('verifying')
160
+ const verdict = opts.verify(opts.targetDir)
161
+ if (!verdict.passed) {
162
+ const reason = blockReason(`story ${story.id} did not verify: ${verdict.summary}`, opts.targetDir, opts.git)
163
+ reporter.blocked(reason)
164
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) }
165
+ }
166
+ ```
167
+ Replace it with (delete the early `if (!result.success)` block; verify decides):
168
+ ```typescript
169
+ const result = opts.runner({ targetDir: opts.targetDir, story })
170
+ iterations++
171
+
172
+ // Verify is the source of truth — NOT the runner's exit code. A spurious non-zero
173
+ // exit (e.g. a Windows .cmd wrapper ghost) must not block a story whose tests are green.
174
+ reporter.phase('verifying')
175
+ const verdict = opts.verify(opts.targetDir)
176
+ if (!verdict.passed) {
177
+ const base = result.success
178
+ ? `story ${story.id} did not verify: ${verdict.summary}`
179
+ : `story ${story.id} runner failed (${result.summary}) and verify is red: ${verdict.summary}`
180
+ const reason = blockReason(base, opts.targetDir, opts.git)
181
+ reporter.blocked(reason)
182
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) }
183
+ }
184
+ const summary = result.success
185
+ ? result.summary
186
+ : `${result.summary} (runner exited non-zero but verify is green)`
187
+ ```
188
+ Then change the non-isolate `appendDecision(...)` call's `summary:` field from `result.summary` to the new `summary` variable:
189
+ ```typescript
190
+ const dec = appendDecision(contextDir(opts.targetDir), {
191
+ storyId: story.id,
192
+ title: story.title,
193
+ summary,
194
+ })
195
+ ```
196
+ Leave the `if (opts.review) { ... }` block and the commit/rollback logic exactly as-is.
197
+
198
+ - [ ] **Step 4: Implement — isolate path.** In the `if (opts.isolate)` block, apply the same transformation. Replace:
199
+ ```typescript
200
+ const result = opts.runner({ targetDir: wt, story })
201
+ iterations++
202
+ if (!result.success) {
203
+ const reason = blockReason(`story ${story.id} failed: ${result.summary}`, opts.targetDir, opts.git)
204
+ reporter.blocked(reason)
205
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) }
206
+ }
207
+ reporter.phase('verifying')
208
+ const verdict = opts.verify(wt)
209
+ if (!verdict.passed) {
210
+ const reason = blockReason(`story ${story.id} did not verify: ${verdict.summary}`, opts.targetDir, opts.git)
211
+ reporter.blocked(reason)
212
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) }
213
+ }
214
+ ```
215
+ with:
216
+ ```typescript
217
+ const result = opts.runner({ targetDir: wt, story })
218
+ iterations++
219
+ reporter.phase('verifying')
220
+ const verdict = opts.verify(wt)
221
+ if (!verdict.passed) {
222
+ const base = result.success
223
+ ? `story ${story.id} did not verify: ${verdict.summary}`
224
+ : `story ${story.id} runner failed (${result.summary}) and verify is red: ${verdict.summary}`
225
+ const reason = blockReason(base, opts.targetDir, opts.git)
226
+ reporter.blocked(reason)
227
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) }
228
+ }
229
+ const summary = result.success
230
+ ? result.summary
231
+ : `${result.summary} (runner exited non-zero but verify is green)`
232
+ ```
233
+ and change the isolate `appendDecision(contextDir(wt), { ... summary: result.summary })` to use `summary`.
234
+
235
+ - [ ] **Step 5: Run tests to verify they pass**
236
+
237
+ Run: `npx vitest run tests/loop/loop.test.ts`
238
+ Expected: PASS — ghost case completes; runner-fail+verify-fail blocks naming both; runner-pass+verify-fail still blocks; reviewer-reject still blocks; the commit-revert invariant + leftover-hint tests stay green.
239
+
240
+ - [ ] **Step 6: Run the full suite + types**
241
+
242
+ Run: `npx vitest run && npx tsc --noEmit`
243
+ Expected: PASS, clean.
244
+
245
+ - [ ] **Step 7: Commit**
246
+
247
+ ```bash
248
+ git add src/loop/loop.ts tests/loop/loop.test.ts
249
+ git commit -m "feat(loop): verify is the source of truth (runner exit code is advisory)"
250
+ ```
251
+
252
+ ---
253
+
254
+ ### Task 3: `verify.retries` config + wiring
255
+
256
+ **Files:** Modify `src/retrofit/config.ts`, `src/loop/run-command.ts`; Test `tests/retrofit/config.test.ts`, `tests/loop/loop-status-render.test.ts` (or the run-command test file)
257
+
258
+ - [ ] **Step 1: Write the failing config test** (append to `tests/retrofit/config.test.ts`):
259
+
260
+ ```typescript
261
+ it('accepts an optional verify.retries', () => {
262
+ const parsed = YokeConfigSchema.parse({ canonVersion: '0.1.0', agents: ['claude'], loop: { enabled: true }, verify: { command: 'npm test', retries: 2 } })
263
+ expect(parsed.verify?.retries).toBe(2)
264
+ })
265
+ it('accepts verify without retries', () => {
266
+ const parsed = YokeConfigSchema.parse({ canonVersion: '0.1.0', agents: [], loop: { enabled: false }, verify: { command: 'npm test' } })
267
+ expect(parsed.verify?.retries).toBeUndefined()
268
+ })
269
+ ```
270
+ (Match the file's idiom — if it tests via `loadConfig`/`saveConfig` round-trip rather than `YokeConfigSchema` directly, use that instead.)
271
+
272
+ - [ ] **Step 2: Run it to verify it fails**
273
+
274
+ Run: `npx vitest run tests/retrofit/config.test.ts`
275
+ Expected: FAIL — `retries` rejected/stripped.
276
+
277
+ - [ ] **Step 3: Implement the config field.** In `src/retrofit/config.ts`, extend the verify schema + interface:
278
+ ```typescript
279
+ verify: z.object({ command: z.string().min(1), retries: z.number().int().nonnegative().optional() }).optional(),
280
+ ```
281
+ ```typescript
282
+ verify?: { command: string; retries?: number }
283
+ ```
284
+
285
+ - [ ] **Step 4: Wire the retry into `run-command.ts`.** In `src/loop/run-command.ts`, import `retryingVerifier` and wrap the command verifier. Find:
286
+ ```typescript
287
+ let verify = opts.verify
288
+ if (!verify) {
289
+ const command = resolveVerifyCommand(targetDir, config)
290
+ if (!command) {
291
+ console.error('No verify command configured. Set verify.command in .yoke/config.yaml ...')
292
+ return 2
293
+ }
294
+ verify = commandVerifier(command)
295
+ }
296
+ ```
297
+ Change the construction to wrap with the resolved retry count:
298
+ ```typescript
299
+ let verify = opts.verify
300
+ if (!verify) {
301
+ const command = resolveVerifyCommand(targetDir, config)
302
+ if (!command) {
303
+ console.error('No verify command configured. Set verify.command in .yoke/config.yaml ...')
304
+ return 2
305
+ }
306
+ verify = retryingVerifier(commandVerifier(command), config.verify?.retries ?? 1)
307
+ }
308
+ ```
309
+ Add the import: `import { commandVerifier, retryingVerifier, type Verifier } from './verify.js'` (extend the existing import line — it already imports `commandVerifier`/`Verifier`).
310
+
311
+ - [ ] **Step 5: Run tests**
312
+
313
+ Run: `npx vitest run tests/retrofit/config.test.ts && npx vitest run` then `npx tsc --noEmit`
314
+ Expected: PASS, clean. (The injected-`opts.verify` test path in `loop-cli.integration.test.ts` is unchanged — wrapping only happens when no verifier is injected.)
315
+
316
+ - [ ] **Step 6: Commit**
317
+
318
+ ```bash
319
+ git add src/retrofit/config.ts src/loop/run-command.ts tests/retrofit/config.test.ts
320
+ git commit -m "feat(loop): verify.retries config (default 1) wired into the loop"
321
+ ```
322
+
323
+ ---
324
+
325
+ ### Task 4: Document verify-as-truth + retries
326
+
327
+ **Files:** Modify `canon/loop/loop-spec.md`, `README.md`
328
+
329
+ - [ ] **Step 1: Update `canon/loop/loop-spec.md`.** Find step 5 (the verify step, "On success: run the project's verify command ... Only if it passes, mark the story passes: true"). Replace its body to reflect verify-as-truth + retries:
330
+ ```markdown
331
+ 5. Run the project's verify command (config `verify.command`, or detected `npm test`).
332
+ **Verify is the source of truth** — the agent's exit code is advisory, so a spurious
333
+ non-zero exit (e.g. a Windows `.cmd` wrapper) cannot block a story whose tests are green.
334
+ A failing verify is retried up to `verify.retries` times (default 1) so a transient flake
335
+ self-heals; a real failure still fails. Only if verify passes is the story marked
336
+ `passes: true`, committed atomically, and a decision logged. If verify fails: `blocked`.
337
+ ```
338
+
339
+ - [ ] **Step 2: Update `README.md`** — in the autonomous-loop section (near the `--timeout` / idle paragraph or the PRD/verify description), add:
340
+ ```markdown
341
+ The loop trusts **verify**, not the agent's exit code: a story whose tests are green is
342
+ committed even if the agent process exited non-zero (a common Windows `.cmd`-wrapper ghost).
343
+ A failing verify is retried up to `verify.retries` times (default 1) so a transient flake
344
+ self-heals while a real failure still blocks.
345
+ ```
346
+
347
+ - [ ] **Step 3: Verify the suite still passes** (docs change):
348
+
349
+ Run: `npx vitest run`
350
+ Expected: PASS.
351
+
352
+ - [ ] **Step 4: Commit**
353
+
354
+ ```bash
355
+ git add canon/loop/loop-spec.md README.md
356
+ git commit -m "docs: verify is the source of truth + verify.retries"
357
+ ```
358
+
359
+ ---
360
+
361
+ ## Self-Review
362
+
363
+ **Spec coverage:**
364
+ - `retryingVerifier` → Task 1.
365
+ - Verify-as-truth in both paths (runner exit advisory; combined reason on dual failure; ghost noted in the decision summary) → Task 2.
366
+ - `verify.retries` config + run-command wiring (default 1) → Task 3.
367
+ - Reviewer unchanged (its exit is the verdict) → Task 2 leaves the review block untouched.
368
+ - Docs (loop-spec + README) → Task 4.
369
+
370
+ **Placeholder scan:** No TBD/TODO. Every code step shows the exact before/after. Task 2 step 1 explicitly flags the existing "blocks when the runner fails a story" test for repurposing rather than silent deletion.
371
+
372
+ **Type consistency:** `retryingVerifier(inner: Verifier, retries: number): Verifier`, `VerifyResult`, `verify?: { command; retries? }`, the `summary` local in both loop paths, and `config.verify?.retries ?? 1` are consistent. The `appendDecision` summary field changes from `result.summary` to `summary` in both paths.
373
+
374
+ **Invariant note:** The Baustein-E commit-integrity invariant and the Baustein-G reporter/leftover-hint are preserved — Task 2 only moves the implementer-failure decision from "before verify" to "verify decides"; the commit/rollback/reporter calls are untouched.