@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,419 @@
1
+ # Forge — Baustein C5 (review-iteration with role separation) 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:** After a story's tests pass, optionally run a second, independent agent pass in a **reviewer** role that must approve before the story is committed/marked done — enforcing implementer ≠ reviewer role separation (the last guardrail from safe-agentic-workflow).
6
+
7
+ **Architecture:** A `review` runner is an injectable `AgentRunner` (same type as the implementer runner) invoked with a distinct **review** prompt. `runLoop` gains an optional `review?: AgentRunner`; when present, after `verify` passes and before marking the story done, it runs the reviewer — a non-success verdict blocks the story (no commit, stays open). This applies in both the isolated and non-isolated paths. `run-command` builds a `makeReviewRunner(agent)` from `--review`/`--reviewer=<agent>`, with the readiness gate covering the reviewer agent too. Default: no review (opt-in, backward compatible). Baustein C5 — the final loop guardrail.
8
+
9
+ **Tech Stack:** Node.js (ESM), TypeScript, vitest. Extends C1–C4 `runner.ts`, `loop.ts`, `run-command.ts`, `cli.ts`.
10
+
11
+ **Builds on:** A+B1+B2+C1+C2+B3+C3+C4 on `main`. Modifies: `src/loop/runner.ts`, `src/loop/loop.ts`, `src/loop/run-command.ts`, `src/cli.ts`, `canon/loop/loop-spec.md`.
12
+
13
+ ---
14
+
15
+ ## File Structure
16
+
17
+ ```
18
+ src/loop/
19
+ runner.ts # MODIFY: buildReviewPrompt + makeReviewRunner
20
+ loop.ts # MODIFY: LoopOptions.review?; review step after verify in both paths
21
+ run-command.ts # MODIFY: resolve review runner (--review/--reviewer) + reviewer readiness gate
22
+ cli.ts (src/cli.ts) # MODIFY: loop run parses --review and --reviewer=<agent>
23
+ canon/loop/loop-spec.md # MODIFY: document the review step
24
+ tests/loop/
25
+ runner.test.ts # MODIFY: buildReviewPrompt content; makeReviewRunner is a function
26
+ loop.test.ts # MODIFY: review approve/reject cases (non-isolated + isolated)
27
+ loop-cli.integration.test.ts # MODIFY: review wiring + reviewer readiness refusal
28
+ ```
29
+
30
+ ---
31
+
32
+ ### Task 1: Review prompt + review runner factory
33
+
34
+ **Files:**
35
+ - Modify: `src/loop/runner.ts`
36
+ - Test: `tests/loop/runner.test.ts` (extend)
37
+
38
+ - [ ] **Step 1: Add failing tests**
39
+
40
+ In `tests/loop/runner.test.ts`, extend the import and add a describe block:
41
+ ```ts
42
+ import { buildClaudePrompt, claudeInvocation, agentInvocation, makeRunner, isAgentAvailable, buildReviewPrompt, makeReviewRunner } from '../../src/loop/runner.js'
43
+ ```
44
+ Add:
45
+ ```ts
46
+ describe('buildReviewPrompt', () => {
47
+ it('frames a reviewer role distinct from the implementer and lists acceptance criteria', () => {
48
+ const p = buildReviewPrompt(story)
49
+ expect(p).toMatch(/review/i)
50
+ expect(p).toMatch(/did NOT implement|independent reviewer/i)
51
+ expect(p).toContain('returns 200 for valid creds')
52
+ })
53
+
54
+ it('instructs the reviewer to reject (non-zero exit) on blocking issues and not to modify files', () => {
55
+ const p = buildReviewPrompt(story)
56
+ expect(p).toMatch(/exit non-zero|reject/i)
57
+ expect(p).toMatch(/do not modify|do not commit/i)
58
+ })
59
+ })
60
+
61
+ describe('makeReviewRunner', () => {
62
+ it('returns a callable AgentRunner', () => {
63
+ expect(typeof makeReviewRunner('claude')).toBe('function')
64
+ })
65
+ })
66
+ ```
67
+ (`story` is the existing fixture at the top of the file.)
68
+
69
+ - [ ] **Step 2: Run test to verify it fails**
70
+
71
+ Run: `npm test -- loop/runner`
72
+ Expected: FAIL — `buildReviewPrompt`/`makeReviewRunner` not exported.
73
+
74
+ - [ ] **Step 3: Add to `src/loop/runner.ts`**
75
+
76
+ Add after `buildClaudePrompt`:
77
+ ```ts
78
+ export function buildReviewPrompt(story: Story): string {
79
+ const criteria = story.acceptance.map(a => `- ${a}`).join('\n')
80
+ return [
81
+ 'You are an independent reviewer inside the Forge loop. You did NOT implement this change.',
82
+ 'Review the current uncommitted working-tree changes against the story below.',
83
+ '',
84
+ `Story ${story.id}: ${story.title}`,
85
+ 'Acceptance criteria:',
86
+ criteria,
87
+ '',
88
+ 'Approve by exiting 0 ONLY if every acceptance criterion is met and the change is sound.',
89
+ 'If you find ANY blocking issue (an unmet criterion, a bug, a missing test), exit non-zero to reject.',
90
+ 'Do not modify files. Do not commit.',
91
+ ].join('\n')
92
+ }
93
+ ```
94
+
95
+ Add after `makeRunner`:
96
+ ```ts
97
+ export function makeReviewRunner(agent: Agent): AgentRunner {
98
+ return (ctx: AgentContext): AgentResult => {
99
+ const inv = agentInvocation(agent, buildReviewPrompt(ctx.story), ctx.targetDir)
100
+ try {
101
+ execFileSync(inv.command, inv.args, inv.options)
102
+ return { success: true, summary: `${agent} approved ${ctx.story.id}` }
103
+ } catch (e) {
104
+ return { success: false, summary: `${agent} rejected ${ctx.story.id}: ${(e as Error).message}` }
105
+ }
106
+ }
107
+ }
108
+ ```
109
+
110
+ - [ ] **Step 4: Run test to verify it passes**
111
+
112
+ Run: `npm test -- loop/runner`
113
+ Expected: PASS (existing + 3 new).
114
+
115
+ - [ ] **Step 5: Commit**
116
+
117
+ ```bash
118
+ git add src/loop/runner.ts tests/loop/runner.test.ts
119
+ git commit -m "feat: add reviewer-role prompt and review runner factory"
120
+ ```
121
+
122
+ ---
123
+
124
+ ### Task 2: Review step in runLoop (both paths)
125
+
126
+ **Files:**
127
+ - Modify: `src/loop/loop.ts`
128
+ - Test: `tests/loop/loop.test.ts` (extend)
129
+
130
+ - [ ] **Step 1: Add failing tests**
131
+
132
+ In `tests/loop/loop.test.ts`, add review stubs and cases. Near the other stubs:
133
+ ```ts
134
+ const reviewOk: AgentRunner = () => ({ success: true, summary: 'approved' })
135
+ const reviewReject: AgentRunner = () => ({ success: false, summary: 'rejected: criterion unmet' })
136
+ ```
137
+ Add to the main (non-isolated) `describe('runLoop', ...)`:
138
+ ```ts
139
+ it('blocks when the reviewer rejects after verify passes (no commit, story stays open)', () => {
140
+ const commits: string[] = []
141
+ const git: GitOps = { isClean: () => true, commitAll: (_d, m) => commits.push(m), addWorktree: () => {}, removeWorktree: () => {}, integrate: () => {} }
142
+ const res = runLoop({ prdPath: prd(), targetDir: dir, runner: alwaysPass, git, verify: verifyOk, review: reviewReject, maxIterations: 10 })
143
+ expect(res.status).toBe('blocked')
144
+ expect(res.reason).toMatch(/rejected in review/i)
145
+ expect(loadPrd(prd()).every(s => !s.passes)).toBe(true)
146
+ expect(commits).toHaveLength(0)
147
+ })
148
+
149
+ it('completes when the reviewer approves', () => {
150
+ const res = runLoop({ prdPath: prd(), targetDir: dir, runner: alwaysPass, git: cleanGit(), verify: verifyOk, review: reviewOk, maxIterations: 10 })
151
+ expect(res.status).toBe('complete')
152
+ })
153
+ ```
154
+ And to the `describe('runLoop with isolation', ...)`:
155
+ ```ts
156
+ it('blocks in isolated mode when the reviewer rejects, leaving the main PRD untouched', () => {
157
+ const removed: string[] = []
158
+ const res = runLoop({
159
+ prdPath: isoPrd(), targetDir: isoDir, runner: alwaysPass, git: fsWorktreeGit(isoDir, removed),
160
+ verify: verifyOk, review: reviewReject, isolate: true, maxIterations: 5,
161
+ })
162
+ expect(res.status).toBe('blocked')
163
+ expect(res.reason).toMatch(/rejected in review/i)
164
+ expect(loadPrd(isoPrd())[0].passes).toBe(false)
165
+ expect(removed.length).toBe(1) // worktree still cleaned up
166
+ })
167
+ ```
168
+ (The `cleanGit()` helper must already return the 5-method GitOps from C4; if it does not include the worktree no-ops, this test's `git` object literal above shows the required shape.)
169
+
170
+ - [ ] **Step 2: Run test to verify it fails**
171
+
172
+ Run: `npm test -- loop/loop`
173
+ Expected: FAIL — `review` is not part of `LoopOptions`; no review step.
174
+
175
+ - [ ] **Step 3: Modify `src/loop/loop.ts`**
176
+
177
+ Add `review` to `LoopOptions`:
178
+ ```ts
179
+ export interface LoopOptions {
180
+ prdPath: string
181
+ targetDir: string
182
+ runner: AgentRunner
183
+ git: GitOps
184
+ verify: Verifier
185
+ maxIterations: number
186
+ isolate?: boolean
187
+ review?: AgentRunner
188
+ }
189
+ ```
190
+
191
+ **Isolated path** — after the verify check passes (the block that returns blocked on `!verdict.passed`) and before `const updated = stories.map(...)`, insert:
192
+ ```ts
193
+ if (opts.review) {
194
+ const reviewResult = opts.review({ targetDir: wt, story })
195
+ if (!reviewResult.success) {
196
+ return { status: 'blocked', iterations, reason: `story ${story.id} rejected in review: ${reviewResult.summary}`, finalProgress: progress(stories) }
197
+ }
198
+ }
199
+ ```
200
+
201
+ **Non-isolated path** — after the verify check passes (the block that returns blocked on `!verdict.passed`) and before the `try {` that marks/commits, insert:
202
+ ```ts
203
+ if (opts.review) {
204
+ const reviewResult = opts.review({ targetDir: opts.targetDir, story })
205
+ if (!reviewResult.success) {
206
+ return {
207
+ status: 'blocked',
208
+ iterations,
209
+ reason: `story ${story.id} rejected in review: ${reviewResult.summary}`,
210
+ finalProgress: progress(stories),
211
+ }
212
+ }
213
+ }
214
+ ```
215
+
216
+ - [ ] **Step 4: Run test to verify it passes**
217
+
218
+ Run: `npm test -- loop/loop`
219
+ Expected: PASS (existing + 3 new). Existing tests without `review` are unaffected (review is optional).
220
+
221
+ - [ ] **Step 5: Commit**
222
+
223
+ ```bash
224
+ git add src/loop/loop.ts tests/loop/loop.test.ts
225
+ git commit -m "feat: require reviewer approval before completing a story"
226
+ ```
227
+
228
+ ---
229
+
230
+ ### Task 3: Wire --review / --reviewer through run-command and CLI
231
+
232
+ **Files:**
233
+ - Modify: `src/loop/run-command.ts`, `src/cli.ts`
234
+ - Test: `tests/loop/loop-cli.integration.test.ts` (extend)
235
+
236
+ - [ ] **Step 1: Add failing tests**
237
+
238
+ In `tests/loop/loop-cli.integration.test.ts`, add cases. Add a review stub near the top:
239
+ ```ts
240
+ const reviewReject: AgentRunner = () => ({ success: false, summary: 'nope' })
241
+ ```
242
+ Add:
243
+ ```ts
244
+ it('blocks when an injected review runner rejects', () => {
245
+ saveConfig(dir, { ...cfg(), verify: { command: 'node -e "process.exit(0)"' } })
246
+ const code = runLoopCommand(dir, { maxIterations: 5, runner: passRunner, git: stubGit, verify: verifyOk, reviewRunner: reviewReject })
247
+ expect(code).toBe(1)
248
+ expect(loadPrd(join(dir, '.forge', 'prd.yaml'))[0].passes).toBe(false)
249
+ })
250
+
251
+ it('refuses to run when the reviewer agent CLI is unavailable', () => {
252
+ saveConfig(dir, { ...cfg(), verify: { command: 'node -e "process.exit(0)"' } })
253
+ const code = runLoopCommand(dir, {
254
+ maxIterations: 5, runner: passRunner, git: stubGit, verify: verifyOk,
255
+ reviewer: 'codex', isAvailable: (a) => a !== 'codex',
256
+ })
257
+ expect(code).toBe(2)
258
+ })
259
+ ```
260
+ (`AgentRunner` must be imported in this test file; add it to the existing `../../src/loop/runner.js` import if not present.)
261
+
262
+ - [ ] **Step 2: Run test to verify it fails**
263
+
264
+ Run: `npm test -- loop-cli`
265
+ Expected: FAIL — `RunLoopCommandOptions` has no `reviewRunner`/`reviewer`.
266
+
267
+ - [ ] **Step 3: Modify `src/loop/run-command.ts`**
268
+
269
+ Update the runner import to include `makeReviewRunner`:
270
+ ```ts
271
+ import { makeRunner, makeReviewRunner, isAgentAvailable, type AgentRunner } from './runner.js'
272
+ ```
273
+
274
+ Extend `RunLoopCommandOptions`:
275
+ ```ts
276
+ export interface RunLoopCommandOptions {
277
+ maxIterations: number
278
+ runner?: AgentRunner
279
+ git?: GitOps
280
+ verify?: Verifier
281
+ agent?: Agent
282
+ isAvailable?: (agent: Agent) => boolean
283
+ isolate?: boolean
284
+ reviewRunner?: AgentRunner
285
+ reviewer?: Agent
286
+ review?: boolean
287
+ }
288
+ ```
289
+
290
+ In `runLoopCommand`, compute the resolved runner agent ONCE near the top of the function (after the `config`/PRD/verify checks, replacing the current `if (!runner) { const agent = ... }` shape) so both the runner and the reviewer can reuse it:
291
+ ```ts
292
+ const available = opts.isAvailable ?? isAgentAvailable
293
+ const runnerAgent: Agent = opts.agent ?? config.agents[0] ?? 'claude'
294
+
295
+ let runner = opts.runner
296
+ if (!runner) {
297
+ if (!available(runnerAgent)) {
298
+ console.error(`Agent CLI "${runnerAgent}" was not found on PATH. Install it, or pick another with --runner=<claude|codex|gemini>.`)
299
+ return 2
300
+ }
301
+ runner = makeRunner(runnerAgent)
302
+ }
303
+
304
+ let review = opts.reviewRunner
305
+ if (!review && (opts.review || opts.reviewer)) {
306
+ const reviewerAgent: Agent = opts.reviewer ?? runnerAgent
307
+ if (!available(reviewerAgent)) {
308
+ console.error(`Reviewer agent CLI "${reviewerAgent}" was not found on PATH. Install it, or pick another with --reviewer=<claude|codex|gemini>.`)
309
+ return 2
310
+ }
311
+ review = makeReviewRunner(reviewerAgent)
312
+ }
313
+ ```
314
+ Pass `review` into the `runLoop` call (add the field):
315
+ ```ts
316
+ const result = runLoop({
317
+ prdPath: path,
318
+ targetDir,
319
+ runner,
320
+ git: opts.git ?? realGitOps,
321
+ verify,
322
+ maxIterations: opts.maxIterations,
323
+ isolate: opts.isolate ?? false,
324
+ review,
325
+ })
326
+ ```
327
+
328
+ - [ ] **Step 4: Modify `src/cli.ts` to parse `--review` / `--reviewer=`**
329
+
330
+ In the `loop` `run` branch, after parsing `agent` and `isolate`, add:
331
+ ```ts
332
+ const reviewerArg = rest.find(a => a.startsWith('--reviewer='))?.slice('--reviewer='.length)
333
+ const valid = ['claude', 'codex', 'gemini']
334
+ let reviewer: Agent | undefined
335
+ if (reviewerArg) {
336
+ if (!valid.includes(reviewerArg)) {
337
+ console.error(`Invalid --reviewer value: ${reviewerArg} (expected claude|codex|gemini)`)
338
+ return 1
339
+ }
340
+ reviewer = reviewerArg as Agent
341
+ }
342
+ const review = rest.includes('--review')
343
+ return runLoopCommand(targetDir, { maxIterations: rawMax, agent, isolate, reviewer, review })
344
+ ```
345
+ (Remove the previous `return runLoopCommand(...)` line in that branch so this is the single return.) Update the loop usage string:
346
+ ```ts
347
+ console.log('usage: forge loop <on|off|status|run [--max=N] [--runner=<claude|codex|gemini>] [--reviewer=<claude|codex|gemini>] [--review] [--isolate]> [targetDir]')
348
+ ```
349
+
350
+ - [ ] **Step 5: Run suite + build**
351
+
352
+ Run: `npm test`
353
+ Expected: all pass.
354
+
355
+ Run: `npm run build`
356
+ Expected: tsc 0 errors.
357
+
358
+ - [ ] **Step 6: Commit**
359
+
360
+ ```bash
361
+ git add src/loop/run-command.ts src/cli.ts tests/loop/loop-cli.integration.test.ts
362
+ git commit -m "feat: wire --review/--reviewer with reviewer readiness gate"
363
+ ```
364
+
365
+ ---
366
+
367
+ ### Task 4: Update canon loop-spec
368
+
369
+ **Files:**
370
+ - Modify: `canon/loop/loop-spec.md`
371
+
372
+ - [ ] **Step 1: Document the review step**
373
+
374
+ In `canon/loop/loop-spec.md`, add a review note near the iteration steps:
375
+ ```markdown
376
+ Pass `--review` (or `--reviewer=<claude|codex|gemini>` for a different agent) to add a role-separated review step: after the tests pass, an independent reviewer agent must approve the change before the story is committed and marked done. A rejection blocks the story (no commit). The reviewer is a fresh agent pass — the implementer never reviews its own work.
377
+ ```
378
+ And replace the Limitations section with:
379
+ ```markdown
380
+ ## Limitations
381
+ - The loop verifies via the project's test command and an optional agent review; it has no formal merge-queue or multi-reviewer quorum.
382
+ ```
383
+
384
+ - [ ] **Step 2: Validate the canon**
385
+
386
+ Run: `npm run forge -- validate canon`
387
+ Expected: `✓ canon valid (canon)`.
388
+
389
+ Run: `npm test -- real-canon`
390
+ Expected: PASS.
391
+
392
+ - [ ] **Step 3: Commit**
393
+
394
+ ```bash
395
+ git add canon/loop/loop-spec.md
396
+ git commit -m "docs: canon loop-spec documents the role-separated review step"
397
+ ```
398
+
399
+ ---
400
+
401
+ ## Self-Review
402
+
403
+ **1. Spec coverage (Baustein C5 scope):**
404
+ - Reviewer-role prompt distinct from implementer → Task 1 ✓
405
+ - Review runner (fresh agent pass) → Task 1 (`makeReviewRunner`) ✓
406
+ - Review step after verify, before commit, in BOTH loop paths → Task 2 ✓
407
+ - Reviewer rejection blocks the story (no commit, stays open) → Task 2 ✓
408
+ - Role separation: reviewer can be a different agent (`--reviewer=`) or a fresh pass of the same (`--review`) → Tasks 3 ✓
409
+ - Reviewer agent covered by the readiness gate → Task 3 ✓
410
+ - Opt-in, default off (existing tests unaffected) → Tasks 2, 3 ✓
411
+ - Deterministic tests (injected review runner; readiness via stub) → Tasks 2, 3 ✓
412
+
413
+ **2. Placeholder scan:** No TBD/TODO. Every step has complete code.
414
+
415
+ **3. Type consistency:** `buildReviewPrompt`, `makeReviewRunner`, `LoopOptions.review?: AgentRunner`, `RunLoopCommandOptions.reviewRunner?`/`reviewer?`/`review?`. The review runner reuses `AgentRunner`/`AgentContext`/`AgentResult` and `agentInvocation` from C3. `runnerAgent` is computed once and reused for both runner and reviewer resolution + readiness. The review step is inserted in both the isolated (targetDir = worktree) and non-isolated (targetDir = repo) paths, after verify and before mark/commit; all existing GitOps stubs already have the 5 methods from C4. ✓
416
+
417
+ ## Next Plans (not this document)
418
+
419
+ - (C5 completes the planned loop guardrails. Possible future work: merge-queue / multi-reviewer quorum, a DEP0190 shell-handling cleanup, and a Serena code-graph swap option — all optional.)
@@ -0,0 +1,233 @@
1
+ # Forge — Cleanup: remove DEP0190 (shell:true) warning 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:** Eliminate the Node `DEP0190` deprecation warning emitted whenever an agent CLI is invoked, without losing Windows `.cmd`-shim resolution.
6
+
7
+ **Root cause:** `src/loop/runner.ts` runs agent CLIs with `execFileSync(command, args, { shell: true })` on Windows (so `claude.cmd`/`codex.cmd`/`gemini.cmd` resolve via PATHEXT). Node 22+ deprecates passing an args array together with `shell: true` (DEP0190). Our args are always literal flags (`-p`, `exec`, `--version`) and the prompt is passed via stdin, so there is no injection risk — but the warning fires.
8
+
9
+ **Fix:** On Windows, run the invocation as a single shell command string via `execSync` (no separate args array → no DEP0190); the literal flags are concatenated safely and the prompt is still piped via stdin (`input`). On non-Windows, keep `execFileSync(command, args)` with no shell (already warning-free). Centralize this in one `runCli` helper plus a `probeVersion` helper so both the runner and the availability probe share it.
10
+
11
+ **Tech Stack:** Node.js (ESM), TypeScript, vitest. Touches one source file + its test.
12
+
13
+ **Builds on:** A+B1+B2+B3+C1–C5 on `main`. Modifies: `src/loop/runner.ts`, `tests/loop/runner.test.ts`.
14
+
15
+ ---
16
+
17
+ ## File Structure
18
+
19
+ ```
20
+ src/loop/runner.ts # MODIFY: Invocation shape (drop shell/stdio); runCli + probeVersion; runners + isAgentAvailable use them
21
+ tests/loop/runner.test.ts # MODIFY: agentInvocation assertions match the new shape; add a no-DEP0190 guard
22
+ ```
23
+
24
+ ---
25
+
26
+ ### Task 1: Centralize CLI execution without shell:true args
27
+
28
+ **Files:**
29
+ - Modify: `src/loop/runner.ts`
30
+ - Test: `tests/loop/runner.test.ts`
31
+
32
+ - [ ] **Step 1: Update the failing tests**
33
+
34
+ In `tests/loop/runner.test.ts`, the existing `agentInvocation` / `claudeInvocation` tests assert `inv.options.input` and `inv.options.shell`. Update them to the new flat shape (`inv.input`, no `shell`), and add a guard that running a (failing) probe does not print DEP0190. Replace the `describe('agentInvocation', ...)` and the claude back-compat assertions with:
35
+ ```ts
36
+ describe('agentInvocation', () => {
37
+ it('maps codex to `codex exec` with the prompt as input', () => {
38
+ const inv = agentInvocation('codex', 'P', '/w')
39
+ expect(inv.command).toBe('codex')
40
+ expect(inv.args).toEqual(['exec'])
41
+ expect(inv.input).toBe('P')
42
+ expect(inv.args).not.toContain('P')
43
+ })
44
+
45
+ it('maps gemini to `gemini -p` with the prompt as input', () => {
46
+ const inv = agentInvocation('gemini', 'P', '/w')
47
+ expect(inv.command).toBe('gemini')
48
+ expect(inv.args).toEqual(['-p'])
49
+ expect(inv.input).toBe('P')
50
+ })
51
+
52
+ it('claude back-compat: claudeInvocation equals agentInvocation(claude)', () => {
53
+ expect(claudeInvocation('P', '/w')).toEqual(agentInvocation('claude', 'P', '/w'))
54
+ })
55
+
56
+ it('carries the cwd', () => {
57
+ expect(agentInvocation('claude', 'P', '/w').cwd).toBe('/w')
58
+ })
59
+ })
60
+ ```
61
+ Also update the existing `claudeInvocation` describe block (the one added in C1 that checks `options.input` / `options.shell`) to the flat shape — assert `claudeInvocation('P','/w')` has `.command === 'claude'`, `.args` equal `['-p']`, `.input === 'P'`, and no `.options`/`.shell` property:
62
+ ```ts
63
+ describe('claudeInvocation', () => {
64
+ it('passes the prompt as input, not as a CLI arg', () => {
65
+ const inv = claudeInvocation('PROMPT TEXT', '/work')
66
+ expect(inv.command).toBe('claude')
67
+ expect(inv.args).toEqual(['-p'])
68
+ expect(inv.input).toBe('PROMPT TEXT')
69
+ expect(inv.args).not.toContain('PROMPT TEXT')
70
+ expect((inv as Record<string, unknown>).shell).toBeUndefined()
71
+ })
72
+ })
73
+ ```
74
+ Add a regression guard that the version probe of a non-existent command returns false and emits no DEP0190 on stderr (capture process warnings):
75
+ ```ts
76
+ describe('no DEP0190', () => {
77
+ it('isAgentAvailable does not emit a DEP0190 deprecation warning', () => {
78
+ const warnings: string[] = []
79
+ const onWarn = (w: Error) => warnings.push(String(w))
80
+ process.on('warning', onWarn)
81
+ try {
82
+ // a definitely-absent command — returns false, must not warn
83
+ isAgentAvailable('claude')
84
+ } finally {
85
+ process.off('warning', onWarn)
86
+ }
87
+ expect(warnings.some(w => w.includes('DEP0190'))).toBe(false)
88
+ })
89
+ })
90
+ ```
91
+ (Note: the warning is emitted asynchronously by Node; this guard is best-effort. The primary verification is Step 5's manual check that `npm test` output no longer contains DEP0190.)
92
+
93
+ - [ ] **Step 2: Run test to verify it fails**
94
+
95
+ Run: `npm test -- loop/runner`
96
+ Expected: FAIL — the tests now reference `inv.input`/`inv.cwd` (flat shape) which don't exist yet.
97
+
98
+ - [ ] **Step 3: Rewrite the execution internals in `src/loop/runner.ts`**
99
+
100
+ Change the imports to include `execSync`:
101
+ ```ts
102
+ import { execFileSync, execSync } from 'node:child_process'
103
+ ```
104
+
105
+ Replace the `Invocation` interface with the flat shape (drop `options`):
106
+ ```ts
107
+ export interface Invocation {
108
+ command: string
109
+ args: string[]
110
+ input: string
111
+ cwd: string
112
+ }
113
+ ```
114
+
115
+ Update `agentInvocation` to return the flat shape (no `options`, no `shell`/`stdio`):
116
+ ```ts
117
+ export function agentInvocation(agent: Agent, prompt: string, cwd: string): Invocation {
118
+ const spec = AGENT_SPECS[agent]
119
+ return { command: spec.command, args: spec.baseArgs, input: prompt, cwd }
120
+ }
121
+ ```
122
+ (`claudeInvocation` stays a thin wrapper: `return agentInvocation('claude', prompt, cwd)`.)
123
+
124
+ Add the two execution helpers (place above `makeRunner`):
125
+ ```ts
126
+ // Execute a CLI invocation. On Windows the agent CLIs are `.cmd` shims that
127
+ // execFileSync cannot resolve without a shell; but passing an args array with
128
+ // shell:true triggers DEP0190. So on win32 we run a single command string via
129
+ // execSync (our args are literal flags, never user data — the prompt is piped via
130
+ // stdin), which avoids the warning. On other platforms execFileSync with no shell
131
+ // is already warning-free. Throws on a non-zero exit (caller catches).
132
+ function runCli(inv: Invocation): void {
133
+ if (process.platform === 'win32') {
134
+ execSync([inv.command, ...inv.args].join(' '), {
135
+ cwd: inv.cwd,
136
+ input: inv.input,
137
+ stdio: ['pipe', 'inherit', 'inherit'],
138
+ })
139
+ } else {
140
+ execFileSync(inv.command, inv.args, {
141
+ cwd: inv.cwd,
142
+ input: inv.input,
143
+ stdio: ['pipe', 'inherit', 'inherit'],
144
+ })
145
+ }
146
+ }
147
+
148
+ // Probe whether a CLI is on PATH via `<command> --version`. Same win32/other split
149
+ // as runCli to stay DEP0190-free. Never throws.
150
+ function probeVersion(command: string): boolean {
151
+ try {
152
+ if (process.platform === 'win32') {
153
+ execSync(`${command} --version`, { stdio: 'pipe', timeout: 5000 })
154
+ } else {
155
+ execFileSync(command, ['--version'], { stdio: 'pipe', timeout: 5000 })
156
+ }
157
+ return true
158
+ } catch {
159
+ return false
160
+ }
161
+ }
162
+ ```
163
+
164
+ Update `makeRunner` to use `runCli` (replace its `execFileSync(...)` call):
165
+ ```ts
166
+ export function makeRunner(agent: Agent): AgentRunner {
167
+ return (ctx: AgentContext): AgentResult => {
168
+ const inv = agentInvocation(agent, buildClaudePrompt(ctx.story), ctx.targetDir)
169
+ try {
170
+ // NOTE: the loop trusts the agent's exit code as a proxy for "it ran".
171
+ // Independent verification happens in the loop (Baustein C2), not here.
172
+ runCli(inv)
173
+ return { success: true, summary: `${agent} implemented ${ctx.story.id}` }
174
+ } catch (e) {
175
+ return { success: false, summary: `${agent} failed on ${ctx.story.id}: ${(e as Error).message}` }
176
+ }
177
+ }
178
+ }
179
+ ```
180
+
181
+ Update `makeReviewRunner` the same way (replace its `execFileSync(...)` with `runCli(inv)`):
182
+ ```ts
183
+ export function makeReviewRunner(agent: Agent): AgentRunner {
184
+ return (ctx: AgentContext): AgentResult => {
185
+ const inv = agentInvocation(agent, buildReviewPrompt(ctx.story), ctx.targetDir)
186
+ try {
187
+ runCli(inv)
188
+ return { success: true, summary: `${agent} approved ${ctx.story.id}` }
189
+ } catch (e) {
190
+ return { success: false, summary: `${agent} rejected ${ctx.story.id}: ${(e as Error).message}` }
191
+ }
192
+ }
193
+ }
194
+ ```
195
+
196
+ Update `isAgentAvailable` to delegate to `probeVersion` (replace its body's try/catch with):
197
+ ```ts
198
+ export function isAgentAvailable(agent: Agent): boolean {
199
+ return probeVersion(AGENT_SPECS[agent].command)
200
+ }
201
+ ```
202
+
203
+ - [ ] **Step 4: Run test to verify it passes**
204
+
205
+ Run: `npm test -- loop/runner`
206
+ Expected: PASS (updated assertions + the new guard).
207
+
208
+ - [ ] **Step 5: Verify the warning is gone across the whole suite + build**
209
+
210
+ Run: `npm test 2>&1` and search the output for `DEP0190`.
211
+ Expected: NO `DEP0190` line anywhere in the test output (it previously appeared under `runner.test.ts`). All tests pass.
212
+
213
+ Run: `npm run build`
214
+ Expected: tsc 0 errors.
215
+
216
+ - [ ] **Step 6: Commit**
217
+
218
+ ```bash
219
+ git add src/loop/runner.ts tests/loop/runner.test.ts
220
+ git commit -m "fix: avoid DEP0190 by running win32 CLIs via execSync string"
221
+ ```
222
+
223
+ ---
224
+
225
+ ## Self-Review
226
+
227
+ **1. Coverage:** DEP0190 root cause (args array + shell:true) removed at every call site — `makeRunner`, `makeReviewRunner` (via `runCli`), and `isAgentAvailable` (via `probeVersion`). Windows `.cmd` resolution preserved (execSync uses cmd.exe). Non-Windows path unchanged (execFileSync, no shell). ✓
228
+
229
+ **2. Placeholder scan:** No TBD/TODO; complete code in every step. ✓
230
+
231
+ **3. Type consistency:** `Invocation` flattened to `{command,args,input,cwd}`; `agentInvocation`/`claudeInvocation` return it; `runCli(inv)` and `probeVersion(command)` are the only executors. No consumer outside `runner.ts` reads `Invocation.options` (run-command/loop use `makeRunner`/`makeReviewRunner`/`isAgentAvailable`, not the shape). The C1 back-compat identity `claudeInvocation === agentInvocation('claude')` holds. Tests updated to the flat shape. ✓
232
+
233
+ **Safety note:** `runCli` builds the win32 command string from `inv.command` + `inv.args`, both of which are literal constants from `AGENT_SPECS` (never user/PRD data); the prompt — the only user-influenced input — is passed via stdin (`input`), so no shell-injection surface is introduced.