@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.
- package/CHANGELOG.md +112 -0
- package/README.md +557 -494
- package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/config.yaml +6 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/context/DECISIONS.md +9 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/prd.yaml +38 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/bench-verify.mjs +15 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/package.json +9 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/src/index.mjs +48 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-1.test.mjs +24 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-2.test.mjs +28 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-3.test.mjs +25 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/config.yaml +6 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/prd.yaml +32 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/bench-verify.mjs +15 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/package.json +9 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/src/index.mjs +3 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-1.test.mjs +24 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-2.test.mjs +28 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-3.test.mjs +25 -0
- package/bench/README.md +42 -0
- package/bench/RESULTS.md +36 -0
- package/bench/fixtures/string-kit/.yoke/config.yaml +6 -0
- package/bench/fixtures/string-kit/.yoke/prd.yaml +32 -0
- package/bench/fixtures/string-kit/bench-verify.mjs +15 -0
- package/bench/fixtures/string-kit/package.json +9 -0
- package/bench/fixtures/string-kit/src/index.mjs +3 -0
- package/bench/fixtures/string-kit/tests/STORY-1.test.mjs +24 -0
- package/bench/fixtures/string-kit/tests/STORY-2.test.mjs +28 -0
- package/bench/fixtures/string-kit/tests/STORY-3.test.mjs +25 -0
- package/bench/results/claude-2026-07-09T22-34-01.json +40 -0
- package/bench/run.mjs +115 -0
- package/canon/loop/loop-spec.md +5 -1
- package/canon/manifest.yaml +3 -0
- package/canon/skills/yoke-retrofit/SKILL.md +1 -0
- package/canon/tools/claude-mem.md +15 -0
- package/canon/tools/ui-ux-pro-max.md +15 -0
- package/dist/cli.js +32 -6
- package/dist/loop/cleanup.js +44 -3
- package/dist/loop/loop.js +27 -2
- package/dist/loop/reporter.js +21 -3
- package/dist/loop/run-command.js +19 -6
- package/dist/loop/runner.js +130 -13
- package/dist/loop/watchdog.js +67 -17
- package/dist/retrofit/apply.js +10 -3
- package/dist/retrofit/config.js +2 -0
- package/dist/retrofit/gitignore.js +2 -0
- package/dist/retrofit/planners/claude.js +4 -1
- package/dist/retrofit/planners/gemini.js +2 -1
- package/dist/retrofit/preserve.js +51 -0
- package/dist/update/check.js +86 -0
- package/dist/update/refresh.js +28 -0
- package/dist/update/upgrade.js +30 -0
- package/docs/PUBLISHING.md +41 -0
- package/docs/superpowers/plans/2026-06-27-forge-a-canon.md +815 -0
- package/docs/superpowers/plans/2026-06-27-forge-b1-retrofit-claude.md +842 -0
- package/docs/superpowers/plans/2026-06-27-forge-b2-codex-gemini-tools.md +884 -0
- package/docs/superpowers/plans/2026-06-27-forge-c1-loop-engine.md +891 -0
- package/docs/superpowers/plans/2026-06-28-baustein-e-context-layer.md +981 -0
- package/docs/superpowers/plans/2026-06-28-forge-b3-settings-merge.md +364 -0
- package/docs/superpowers/plans/2026-06-28-forge-c2-loop-verify.md +502 -0
- package/docs/superpowers/plans/2026-06-28-forge-c3-multi-agent-runners.md +377 -0
- package/docs/superpowers/plans/2026-06-28-forge-c4-worktree-isolation.md +413 -0
- package/docs/superpowers/plans/2026-06-28-forge-c5-review-iteration.md +419 -0
- package/docs/superpowers/plans/2026-06-28-forge-cleanup-dep0190.md +233 -0
- package/docs/superpowers/plans/2026-06-28-forge-d-codegraph-choice-minimal-code.md +523 -0
- package/docs/superpowers/plans/2026-06-29-baustein-f-routing.md +258 -0
- package/docs/superpowers/plans/2026-06-29-baustein-g-loop-observability.md +1006 -0
- package/docs/superpowers/plans/2026-06-29-baustein-h-loop-robustness.md +374 -0
- package/docs/superpowers/plans/2026-06-30-baustein-i-visual-design-verification.md +450 -0
- package/docs/superpowers/plans/2026-07-02-baustein-j-cross-model-review-gstack-compose.md +645 -0
- package/docs/superpowers/plans/2026-07-02-baustein-k-zero-to-100-bootstrap.md +1024 -0
- package/docs/superpowers/plans/2026-07-02-baustein-m-flow-smoke-proofs.md +574 -0
- package/docs/superpowers/specs/2026-06-27-forge-cross-agent-harness-design.md +144 -0
- package/docs/superpowers/specs/2026-06-28-baustein-e-context-layer-design.md +146 -0
- package/docs/superpowers/specs/2026-06-29-baustein-f-routing-design.md +106 -0
- package/docs/superpowers/specs/2026-06-29-baustein-g-loop-observability-design.md +186 -0
- package/docs/superpowers/specs/2026-06-29-baustein-h-loop-robustness-design.md +113 -0
- package/docs/superpowers/specs/2026-06-30-baustein-i-visual-design-verification-design.md +98 -0
- package/docs/superpowers/specs/2026-07-02-baustein-j-cross-model-review-gstack-compose-design.md +137 -0
- package/docs/superpowers/specs/2026-07-02-baustein-k-zero-to-100-bootstrap-design.md +200 -0
- package/docs/superpowers/specs/2026-07-02-baustein-m-flow-smoke-proofs-design.md +155 -0
- package/docs/superpowers/specs/2026-07-10-companion-tools-decision.md +33 -0
- package/docs/superpowers/specs/2026-07-10-multi-agent-parallel-loop-design.md +99 -0
- 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.
|