@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,645 @@
1
+ # Baustein J — Cross-Model Review + gstack Compose 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:** Add a standalone `yoke review` command that has a *second* model review the current diff as a pass/fail gate, and make `yoke retrofit` compose with gstack (Claude-only routing note) when it is installed.
6
+
7
+ **Architecture:** Reuse the loop's existing agent-invocation + watchdog plumbing (`src/loop/runner.ts`) behind a new exported `runAgent`, and a new story-less prompt builder. A new `src/review/command.ts` (mirroring `src/context/command.ts`) resolves the reviewer, builds the scope + prompt, invokes, and maps the result to an exit code. gstack detection is a small pure module threaded into the Claude planner only.
8
+
9
+ **Tech Stack:** Node.js/TypeScript (ESM, `.js` import specifiers), vitest, existing `agentInvocation`/`buildWatchdogInvocation`/`resolveIdleMs` helpers.
10
+
11
+ ---
12
+
13
+ ## File Structure
14
+
15
+ - Create: `src/retrofit/gstack.ts` — `detectGstack(targetDir, homedir?)` pure detection.
16
+ - Create: `src/review/command.ts` — `runReview(targetDir, opts): number` (reviewer resolution, scope, invoke, exit code).
17
+ - Create: `tests/retrofit/gstack.test.ts`, `tests/review/command.test.ts`.
18
+ - Modify: `src/loop/runner.ts` — add `buildStandaloneReviewPrompt`, export `runAgent`.
19
+ - Modify: `src/retrofit/planners/claude.ts` — append a "Composed tools" section to CLAUDE.md when gstack detected.
20
+ - Modify: `src/cli.ts` — add `case 'review'` + usage string.
21
+ - Modify: `canon/skills/review/SKILL.md`, `canon/skills/ATTRIBUTION.md`, `README.md`.
22
+
23
+ ---
24
+
25
+ ### Task 1: `buildStandaloneReviewPrompt` + `runAgent` export
26
+
27
+ **Files:**
28
+ - Modify: `src/loop/runner.ts` (add after `buildReviewPrompt`, and after `runCli`)
29
+ - Test: `tests/loop/standalone-review.test.ts` (create)
30
+
31
+ - [ ] **Step 1: Write the failing test**
32
+
33
+ Create `tests/loop/standalone-review.test.ts`:
34
+
35
+ ```ts
36
+ import { describe, it, expect } from 'vitest'
37
+ import { buildStandaloneReviewPrompt } from '../../src/loop/runner.js'
38
+
39
+ describe('buildStandaloneReviewPrompt', () => {
40
+ it('names the scope and forbids file changes', () => {
41
+ const p = buildStandaloneReviewPrompt('the uncommitted working-tree changes')
42
+ expect(p).toContain('the uncommitted working-tree changes')
43
+ expect(p).toMatch(/independent reviewer/i)
44
+ expect(p).toMatch(/exit(ing)? 0/i)
45
+ expect(p).toMatch(/do not modify files/i)
46
+ })
47
+ it('injects an optional focus line', () => {
48
+ const p = buildStandaloneReviewPrompt('the diff main..HEAD', 'authentication and access control')
49
+ expect(p).toContain('the diff main..HEAD')
50
+ expect(p).toContain('authentication and access control')
51
+ })
52
+ it('omits the focus line when no focus is given', () => {
53
+ expect(buildStandaloneReviewPrompt('x')).not.toMatch(/Pay particular attention/i)
54
+ })
55
+ })
56
+ ```
57
+
58
+ - [ ] **Step 2: Run test to verify it fails**
59
+
60
+ Run: `npx vitest run tests/loop/standalone-review.test.ts`
61
+ Expected: FAIL — `buildStandaloneReviewPrompt` is not exported.
62
+
63
+ - [ ] **Step 3: Implement in `src/loop/runner.ts`**
64
+
65
+ Add after `buildReviewPrompt` (around line 60):
66
+
67
+ ```ts
68
+ export function buildStandaloneReviewPrompt(scope: string, focus?: string): string {
69
+ const lines = [
70
+ 'You are an independent reviewer. You did NOT write this change.',
71
+ `Review ${scope}. Run git yourself to see the diff (e.g. \`git diff\`, or \`git diff <base>..HEAD\`).`,
72
+ 'Judge it for correctness, unmet intent, missing tests, and obvious bug or security risks.',
73
+ ]
74
+ if (focus) lines.push(`Pay particular attention to: ${focus}.`)
75
+ lines.push(
76
+ '',
77
+ 'Approve by exiting 0 ONLY if the change is sound and complete.',
78
+ 'If you find ANY blocking issue, exit non-zero to reject and explain what is wrong.',
79
+ 'Do not modify files. Do not commit.',
80
+ )
81
+ return lines.join('\n')
82
+ }
83
+ ```
84
+
85
+ Add after `runCli` (around line 129), exporting a reusable runner:
86
+
87
+ ```ts
88
+ // Reusable one-shot invocation runner for callers outside the loop (e.g. `yoke review`).
89
+ // Mirrors makeRunner's try/catch: success=true when the CLI exits 0, false when it throws.
90
+ export function runAgent(inv: Invocation): AgentResult {
91
+ try {
92
+ runCli(inv)
93
+ return { success: true, summary: 'exited 0' }
94
+ } catch (e) {
95
+ return { success: false, summary: (e as Error).message }
96
+ }
97
+ }
98
+ ```
99
+
100
+ - [ ] **Step 4: Run test to verify it passes**
101
+
102
+ Run: `npx vitest run tests/loop/standalone-review.test.ts`
103
+ Expected: PASS (3 tests).
104
+
105
+ - [ ] **Step 5: Commit**
106
+
107
+ ```bash
108
+ git add src/loop/runner.ts tests/loop/standalone-review.test.ts
109
+ git commit -m "feat(review): standalone review prompt + reusable runAgent"
110
+ ```
111
+
112
+ ---
113
+
114
+ ### Task 2: `detectGstack`
115
+
116
+ **Files:**
117
+ - Create: `src/retrofit/gstack.ts`
118
+ - Test: `tests/retrofit/gstack.test.ts`
119
+
120
+ - [ ] **Step 1: Write the failing test**
121
+
122
+ Create `tests/retrofit/gstack.test.ts`:
123
+
124
+ ```ts
125
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
126
+ import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'
127
+ import { join } from 'node:path'
128
+ import { tmpdir } from 'node:os'
129
+ import { detectGstack } from '../../src/retrofit/gstack.js'
130
+
131
+ describe('detectGstack', () => {
132
+ let target: string
133
+ let home: string
134
+ beforeEach(() => {
135
+ target = mkdtempSync(join(tmpdir(), 'yoke-gs-t-'))
136
+ home = mkdtempSync(join(tmpdir(), 'yoke-gs-h-'))
137
+ })
138
+ afterEach(() => {
139
+ rmSync(target, { recursive: true, force: true })
140
+ rmSync(home, { recursive: true, force: true })
141
+ })
142
+
143
+ it('is false when gstack is not installed anywhere', () => {
144
+ expect(detectGstack(target, home)).toBe(false)
145
+ })
146
+ it('detects a repo-local gstack skill dir', () => {
147
+ mkdirSync(join(target, '.claude', 'skills', 'gstack'), { recursive: true })
148
+ expect(detectGstack(target, home)).toBe(true)
149
+ })
150
+ it('detects a global (home) gstack skill dir', () => {
151
+ mkdirSync(join(home, '.claude', 'skills', 'gstack'), { recursive: true })
152
+ expect(detectGstack(target, home)).toBe(true)
153
+ })
154
+ })
155
+ ```
156
+
157
+ - [ ] **Step 2: Run test to verify it fails**
158
+
159
+ Run: `npx vitest run tests/retrofit/gstack.test.ts`
160
+ Expected: FAIL — module `src/retrofit/gstack.ts` not found.
161
+
162
+ - [ ] **Step 3: Implement `src/retrofit/gstack.ts`**
163
+
164
+ ```ts
165
+ import { existsSync } from 'node:fs'
166
+ import { join } from 'node:path'
167
+ import { homedir } from 'node:os'
168
+
169
+ // Best-effort, non-fatal detection of a gstack install (garrytan/gstack).
170
+ // gstack lives at ~/.claude/skills/gstack (global) or .claude/skills/gstack (repo-local).
171
+ export function detectGstack(targetDir: string, home: string = homedir()): boolean {
172
+ const candidates = [
173
+ join(targetDir, '.claude', 'skills', 'gstack'),
174
+ join(home, '.claude', 'skills', 'gstack'),
175
+ ]
176
+ return candidates.some(p => {
177
+ try { return existsSync(p) } catch { return false }
178
+ })
179
+ }
180
+ ```
181
+
182
+ - [ ] **Step 4: Run test to verify it passes**
183
+
184
+ Run: `npx vitest run tests/retrofit/gstack.test.ts`
185
+ Expected: PASS (3 tests).
186
+
187
+ - [ ] **Step 5: Commit**
188
+
189
+ ```bash
190
+ git add src/retrofit/gstack.ts tests/retrofit/gstack.test.ts
191
+ git commit -m "feat(retrofit): detectGstack (best-effort, repo-local + global)"
192
+ ```
193
+
194
+ ---
195
+
196
+ ### Task 3: `runReview` command
197
+
198
+ **Files:**
199
+ - Create: `src/review/command.ts`
200
+ - Test: `tests/review/command.test.ts`
201
+
202
+ - [ ] **Step 1: Write the failing test**
203
+
204
+ Create `tests/review/command.test.ts`:
205
+
206
+ ```ts
207
+ import { describe, it, expect } from 'vitest'
208
+ import { runReview } from '../../src/review/command.js'
209
+ import type { Invocation } from '../../src/loop/runner.js'
210
+ import type { Agent } from '../../src/retrofit/config.js'
211
+
212
+ function harness(overrides: {
213
+ available?: Agent[]
214
+ succeed?: boolean
215
+ } = {}) {
216
+ const available = new Set(overrides.available ?? ['codex', 'gemini', 'claude'])
217
+ const calls: Invocation[] = []
218
+ const run = (inv: Invocation) => {
219
+ calls.push(inv)
220
+ return { success: overrides.succeed ?? true, summary: overrides.succeed === false ? 'nope' : 'exited 0' }
221
+ }
222
+ const isAvailable = (a: Agent) => available.has(a)
223
+ return { calls, run, isAvailable }
224
+ }
225
+
226
+ describe('runReview', () => {
227
+ it('prefers a second model (codex) and approves with exit 0', () => {
228
+ const h = harness()
229
+ const code = runReview('.', { run: h.run, isAvailable: h.isAvailable })
230
+ expect(code).toBe(0)
231
+ expect(h.calls[0].command).toBe('codex')
232
+ expect(h.calls[0].input).toContain('uncommitted working-tree changes')
233
+ })
234
+ it('falls through codex -> gemini when codex is absent', () => {
235
+ const h = harness({ available: ['gemini', 'claude'] })
236
+ runReview('.', { run: h.run, isAvailable: h.isAvailable })
237
+ expect(h.calls[0].command).toBe('gemini')
238
+ })
239
+ it('falls back to claude self-review when it is the only agent', () => {
240
+ const h = harness({ available: ['claude'] })
241
+ const code = runReview('.', { run: h.run, isAvailable: h.isAvailable })
242
+ expect(code).toBe(0)
243
+ expect(h.calls[0].command).toBe('claude')
244
+ })
245
+ it('errors (exit 2) when no agent CLI is available', () => {
246
+ const h = harness({ available: [] })
247
+ expect(runReview('.', { run: h.run, isAvailable: h.isAvailable })).toBe(2)
248
+ expect(h.calls).toHaveLength(0)
249
+ })
250
+ it('honours an explicit --reviewer', () => {
251
+ const h = harness()
252
+ runReview('.', { reviewer: 'gemini', run: h.run, isAvailable: h.isAvailable })
253
+ expect(h.calls[0].command).toBe('gemini')
254
+ })
255
+ it('errors (exit 2) when the explicit reviewer is unavailable', () => {
256
+ const h = harness({ available: ['claude'] })
257
+ expect(runReview('.', { reviewer: 'codex', run: h.run, isAvailable: h.isAvailable })).toBe(2)
258
+ })
259
+ it('rejects with exit 1 when the reviewer finds issues', () => {
260
+ const h = harness({ succeed: false })
261
+ expect(runReview('.', { run: h.run, isAvailable: h.isAvailable })).toBe(1)
262
+ })
263
+ it('builds a base-range scope with --base', () => {
264
+ const h = harness()
265
+ runReview('.', { base: 'main', run: h.run, isAvailable: h.isAvailable })
266
+ expect(h.calls[0].input).toContain('main..HEAD')
267
+ })
268
+ it('injects --focus into the prompt', () => {
269
+ const h = harness()
270
+ runReview('.', { focus: 'the auth layer', run: h.run, isAvailable: h.isAvailable })
271
+ expect(h.calls[0].input).toContain('the auth layer')
272
+ })
273
+ })
274
+ ```
275
+
276
+ - [ ] **Step 2: Run test to verify it fails**
277
+
278
+ Run: `npx vitest run tests/review/command.test.ts`
279
+ Expected: FAIL — module `src/review/command.ts` not found.
280
+
281
+ - [ ] **Step 3: Implement `src/review/command.ts`**
282
+
283
+ ```ts
284
+ import type { Agent } from '../retrofit/config.js'
285
+ import {
286
+ agentInvocation,
287
+ buildStandaloneReviewPrompt,
288
+ buildWatchdogInvocation,
289
+ runAgent,
290
+ isAgentAvailable,
291
+ type Invocation,
292
+ type AgentResult,
293
+ } from '../loop/runner.js'
294
+ import { resolveIdleMs } from '../loop/run-command.js'
295
+
296
+ export interface RunReviewOptions {
297
+ reviewer?: Agent
298
+ base?: string
299
+ focus?: string
300
+ timeoutMinutes?: number
301
+ isAvailable?: (a: Agent) => boolean
302
+ run?: (inv: Invocation) => AgentResult
303
+ }
304
+
305
+ // Resolve to the first available agent, preferring a *second* model so the review
306
+ // is genuinely cross-model. claude last => a Claude-only box degrades to self-review.
307
+ const RESOLUTION_ORDER: Agent[] = ['codex', 'gemini', 'claude']
308
+
309
+ export function runReview(targetDir: string, opts: RunReviewOptions = {}): number {
310
+ const available = opts.isAvailable ?? isAgentAvailable
311
+ let reviewer = opts.reviewer
312
+ if (reviewer) {
313
+ if (!available(reviewer)) {
314
+ console.error(`Reviewer agent CLI "${reviewer}" was not found on PATH. Install it, or pick another with --reviewer=<claude|codex|gemini>.`)
315
+ return 2
316
+ }
317
+ } else {
318
+ reviewer = RESOLUTION_ORDER.find(a => available(a))
319
+ if (!reviewer) {
320
+ console.error('No agent CLI (claude|codex|gemini) found on PATH. Install one to run a review.')
321
+ return 2
322
+ }
323
+ if (reviewer === 'claude') {
324
+ console.log('Note: only Claude is available — this is a self-review, not cross-model.')
325
+ }
326
+ }
327
+
328
+ const scope = opts.base
329
+ ? `the diff ${opts.base}..HEAD`
330
+ : 'the uncommitted working-tree changes (working tree + staged)'
331
+ const prompt = buildStandaloneReviewPrompt(scope, opts.focus)
332
+ const idleMs = resolveIdleMs(opts.timeoutMinutes, undefined)
333
+ const inv = buildWatchdogInvocation(agentInvocation(reviewer, prompt, targetDir), idleMs)
334
+
335
+ console.log(`Reviewing ${scope} with ${reviewer}...`)
336
+ const run = opts.run ?? runAgent
337
+ const result = run(inv)
338
+ if (result.success) {
339
+ console.log(`✓ ${reviewer} approved`)
340
+ return 0
341
+ }
342
+ console.log(`✗ ${reviewer} found issues (${result.summary})`)
343
+ return 1
344
+ }
345
+ ```
346
+
347
+ - [ ] **Step 4: Run test to verify it passes**
348
+
349
+ Run: `npx vitest run tests/review/command.test.ts`
350
+ Expected: PASS (9 tests).
351
+
352
+ - [ ] **Step 5: Commit**
353
+
354
+ ```bash
355
+ git add src/review/command.ts tests/review/command.test.ts
356
+ git commit -m "feat(review): yoke review command (cross-model gate, reviewer resolution)"
357
+ ```
358
+
359
+ ---
360
+
361
+ ### Task 4: CLI wiring for `yoke review`
362
+
363
+ **Files:**
364
+ - Modify: `src/cli.ts` (import + `case 'review'` + usage string)
365
+
366
+ - [ ] **Step 1: Add the import**
367
+
368
+ At the top of `src/cli.ts`, after the `runContextInit` import:
369
+
370
+ ```ts
371
+ import { runReview } from './review/command.js'
372
+ ```
373
+
374
+ - [ ] **Step 2: Add the `case 'review'` block**
375
+
376
+ Insert before `case 'design-scan':` in `main()`:
377
+
378
+ ```ts
379
+ case 'review': {
380
+ const targetDir = rest.find(a => !a.startsWith('-')) ?? '.'
381
+ const valid = ['claude', 'codex', 'gemini']
382
+ const reviewerArg = rest.find(a => a.startsWith('--reviewer='))?.slice('--reviewer='.length)
383
+ if (reviewerArg && !valid.includes(reviewerArg)) {
384
+ console.error(`Invalid --reviewer value: ${reviewerArg} (expected claude|codex|gemini)`)
385
+ return 1
386
+ }
387
+ const base = rest.find(a => a.startsWith('--base='))?.slice('--base='.length)
388
+ const focus = rest.find(a => a.startsWith('--focus='))?.slice('--focus='.length)
389
+ const toArg = rest.find(a => a.startsWith('--timeout='))
390
+ let timeoutMinutes: number | undefined
391
+ if (toArg) {
392
+ const v = Number(toArg.slice('--timeout='.length))
393
+ if (!Number.isFinite(v) || v < 0) { console.error(`Invalid --timeout value: ${toArg}`); return 1 }
394
+ timeoutMinutes = v
395
+ }
396
+ return runReview(targetDir, { reviewer: reviewerArg as any, base, focus, timeoutMinutes })
397
+ }
398
+ ```
399
+
400
+ - [ ] **Step 3: Update the default usage string**
401
+
402
+ Replace the `default:` usage line to include review:
403
+
404
+ ```ts
405
+ console.log('usage: yoke <validate [canonDir] | retrofit [targetDir] [--agent=claude,codex,gemini|all] [--code-graph=graphify|serena] [--loop] | loop <on|off|status|run> | context <init|status> | review [dir] [--reviewer=<claude|codex|gemini>] [--base=<ref>] [--focus="..."] | design-scan [dir] [--max=N] [--report]>')
406
+ ```
407
+
408
+ - [ ] **Step 4: Build + smoke test**
409
+
410
+ Run: `npx tsc --noEmit`
411
+ Expected: no errors.
412
+
413
+ Run: `npx tsx src/cli.ts review --reviewer=bogus`
414
+ Expected: prints `Invalid --reviewer value: bogus ...` and exits 1.
415
+
416
+ - [ ] **Step 5: Commit**
417
+
418
+ ```bash
419
+ git add src/cli.ts
420
+ git commit -m "feat(cli): wire yoke review"
421
+ ```
422
+
423
+ ---
424
+
425
+ ### Task 5: gstack compose section in the Claude planner
426
+
427
+ **Files:**
428
+ - Modify: `src/retrofit/planners/claude.ts`
429
+ - Test: `tests/retrofit/gstack-compose.test.ts` (create)
430
+
431
+ - [ ] **Step 1: Write the failing test**
432
+
433
+ Create `tests/retrofit/gstack-compose.test.ts`:
434
+
435
+ ```ts
436
+ import { describe, it, expect } from 'vitest'
437
+ import { fileURLToPath } from 'node:url'
438
+ import { join } from 'node:path'
439
+ import { planClaude } from '../../src/retrofit/planners/claude.js'
440
+ import { planCodex } from '../../src/retrofit/planners/codex.js'
441
+ import { planGemini } from '../../src/retrofit/planners/gemini.js'
442
+
443
+ const canonDir = fileURLToPath(new URL('../../canon', import.meta.url))
444
+ const claudeMd = (actions: { target: string; content: string }[]) =>
445
+ actions.find(a => a.target === 'CLAUDE.md')?.content ?? ''
446
+
447
+ describe('gstack compose in Claude planner', () => {
448
+ it('adds a Composed tools section to CLAUDE.md when gstack is detected', () => {
449
+ const md = claudeMd(planClaude(canonDir, '.', false, 'graphify', true))
450
+ expect(md).toMatch(/Composed tools/i)
451
+ expect(md).toContain('/qa')
452
+ expect(md).toContain('/cso')
453
+ expect(md).toContain('/ship')
454
+ })
455
+ it('omits the section when gstack is not detected', () => {
456
+ const md = claudeMd(planClaude(canonDir, '.', false, 'graphify', false))
457
+ expect(md).not.toMatch(/Composed tools/i)
458
+ })
459
+ it('never adds the section to Codex or Gemini artifacts', () => {
460
+ for (const content of [...planCodex(canonDir, '.', 'graphify'), ...planGemini(canonDir, '.', 'graphify')].map(a => a.content)) {
461
+ expect(content).not.toMatch(/Composed tools/i)
462
+ }
463
+ })
464
+ })
465
+ ```
466
+
467
+ - [ ] **Step 2: Run test to verify it fails**
468
+
469
+ Run: `npx vitest run tests/retrofit/gstack-compose.test.ts`
470
+ Expected: FAIL — `planClaude` ignores the 5th arg; CLAUDE.md has no "Composed tools".
471
+
472
+ - [ ] **Step 3: Implement in `src/retrofit/planners/claude.ts`**
473
+
474
+ Add the import at the top:
475
+
476
+ ```ts
477
+ import { detectGstack } from '../gstack.js'
478
+ ```
479
+
480
+ Replace the `claudeMd` template and `planClaude` signature. New `claudeMd`:
481
+
482
+ ```ts
483
+ const GSTACK_COMPOSE = `## Composed tools (gstack detected)
484
+
485
+ This project also has [gstack](https://github.com/garrytan/gstack) installed. For capabilities Yoke does not ship, prefer gstack's skills:
486
+
487
+ - Live-browser QA → \`/qa\`
488
+ - Security audit → \`/cso\`
489
+ - Ship / deploy → \`/ship\`, \`/land-and-deploy\`
490
+ `
491
+
492
+ const claudeMd = (rtkNote: string, composeNote: string) => `# Project Instructions
493
+
494
+ This project uses the Yoke harness. Baseline instructions:
495
+
496
+ @AGENTS.md
497
+ ${rtkNote ? `\n${rtkNote}\n` : ''}${composeNote ? `\n${composeNote}\n` : ''}`
498
+ ```
499
+
500
+ Update the signature and the CLAUDE.md action:
501
+
502
+ ```ts
503
+ export function planClaude(
504
+ canonDir: string,
505
+ targetDir: string,
506
+ wslAvailable: boolean = hasWsl(),
507
+ codeGraph: CodeGraph = 'graphify',
508
+ gstackDetected: boolean = detectGstack(targetDir),
509
+ ): Action[] {
510
+ ```
511
+
512
+ And in the CLAUDE.md write action, pass the compose note:
513
+
514
+ ```ts
515
+ actions.push({
516
+ kind: 'write',
517
+ target: 'CLAUDE.md',
518
+ content: claudeMd(rtkHookable ? '' : rtkInstruction(), gstackDetected ? GSTACK_COMPOSE : ''),
519
+ reason: 'Claude entry importing AGENTS.md',
520
+ })
521
+ ```
522
+
523
+ Note: `planClaude`'s `targetDir` param is currently named `_targetDir` and unused — rename it to `targetDir` since detection now uses it.
524
+
525
+ - [ ] **Step 4: Run test to verify it passes**
526
+
527
+ Run: `npx vitest run tests/retrofit/gstack-compose.test.ts`
528
+ Expected: PASS (3 tests).
529
+
530
+ - [ ] **Step 5: Commit**
531
+
532
+ ```bash
533
+ git add src/retrofit/planners/claude.ts tests/retrofit/gstack-compose.test.ts
534
+ git commit -m "feat(retrofit): compose with gstack via Claude-only CLAUDE.md note"
535
+ ```
536
+
537
+ ---
538
+
539
+ ### Task 6: Canon `review` skill mention + gstack attribution
540
+
541
+ **Files:**
542
+ - Modify: `canon/skills/review/SKILL.md` (add a short note about `yoke review`)
543
+ - Modify: `canon/skills/ATTRIBUTION.md` (credit gstack `/codex` idea)
544
+
545
+ - [ ] **Step 1: Add the `yoke review` note to the review skill**
546
+
547
+ Append to `canon/skills/review/SKILL.md` a short section (keep the skill's existing voice):
548
+
549
+ ```markdown
550
+
551
+ ## Interactive cross-model review
552
+
553
+ Outside the loop, run `yoke review` to have a *second* model review your current diff
554
+ before you commit or push. It resolves to the first available of codex → gemini → claude
555
+ (preferring a model other than the one you are driving), reviews the uncommitted working
556
+ tree by default (or `--base=<ref>` for a branch range), and exits non-zero if it finds a
557
+ blocking issue — so it chains as a gate (`... && yoke review`) or a pre-push hook.
558
+ This is the interactive counterpart to the loop's `--review`/`--reviewer`.
559
+ ```
560
+
561
+ - [ ] **Step 2: Add the attribution entry**
562
+
563
+ In `canon/skills/ATTRIBUTION.md`, before the MIT License block, add:
564
+
565
+ ```markdown
566
+ ### gstack (cross-model review, idea credit)
567
+
568
+ The interactive `yoke review` command is inspired by gstack's `/codex` skill
569
+ (https://github.com/garrytan/gstack, MIT © Garry Tan) — an independent second-model
570
+ review with a pass/fail gate. Yoke's implementation is native and cross-agent; no code
571
+ or data was copied.
572
+ ```
573
+
574
+ - [ ] **Step 3: Verify canon still validates**
575
+
576
+ Run: `npx vitest run tests/canon/real-canon.test.ts`
577
+ Expected: PASS (canon validates with zero errors; existing assertions unaffected).
578
+
579
+ - [ ] **Step 4: Commit**
580
+
581
+ ```bash
582
+ git add canon/skills/review/SKILL.md canon/skills/ATTRIBUTION.md
583
+ git commit -m "docs(canon): yoke review note in review skill + gstack attribution"
584
+ ```
585
+
586
+ ---
587
+
588
+ ### Task 7: README + full green + tsc
589
+
590
+ **Files:**
591
+ - Modify: `README.md`
592
+
593
+ - [ ] **Step 1: Add a `yoke review` section + gstack-compose note to README**
594
+
595
+ Add a section documenting `yoke review` (usage, reviewer resolution, `--base`/`--focus`,
596
+ exit-code gate) near the loop/verify docs, and a short "Composes with gstack" note under the
597
+ cross-agent/retrofit section explaining that if gstack is installed, retrofit routes Claude to
598
+ it for QA/security/deploy — with no bundling or dependency. Keep the README's existing tone.
599
+
600
+ - [ ] **Step 2: Bump the test-count badge**
601
+
602
+ Run the full suite first to get the exact number:
603
+
604
+ Run: `npx vitest run`
605
+ Expected: all tests PASS. Note the total count.
606
+
607
+ Update the test-count badge/references in `README.md` to the new total (previous: 244;
608
+ new total = 244 + tests added here: standalone-review 3 + gstack 3 + review command 9 +
609
+ gstack-compose 3 = **262**, unless the runner reports a different number — use the actual
610
+ number vitest prints).
611
+
612
+ - [ ] **Step 3: Typecheck**
613
+
614
+ Run: `npx tsc --noEmit`
615
+ Expected: no errors.
616
+
617
+ - [ ] **Step 4: Commit**
618
+
619
+ ```bash
620
+ git add README.md
621
+ git commit -m "docs(readme): yoke review + composes-with-gstack; test count -> <N>"
622
+ ```
623
+
624
+ ---
625
+
626
+ ## Self-Review
627
+
628
+ **Spec coverage:**
629
+ - Part 1 `yoke review` (scope default + `--base`, reviewer resolution codex→gemini→claude
630
+ + self-review fallback + no-agent error, story-less prompt, `--focus`, exit-code gate,
631
+ watchdog) → Tasks 1, 3, 4. ✓
632
+ - Part 2 gstack detect + Claude-only compose note, never on Codex/Gemini → Tasks 2, 5. ✓
633
+ - Part 3 tests + README + review-skill mention + attribution → Tasks 1–7. ✓
634
+
635
+ **Type consistency:** `Invocation`, `AgentResult`, `agentInvocation`, `buildWatchdogInvocation`,
636
+ `resolveIdleMs`, `Agent` are all imported from their existing modules with the same names used
637
+ where defined. `runReview(targetDir, opts)` signature matches its test and CLI caller.
638
+ `buildStandaloneReviewPrompt(scope, focus?)` matches its test and the review command caller.
639
+ `planClaude(canonDir, targetDir, wslAvailable, codeGraph, gstackDetected)` — the 5th param is
640
+ additive and defaulted, so `PLANNERS.claude = (c,t,cg) => planClaude(c,t,undefined,cg)` still
641
+ compiles (gstackDetected defaults to `detectGstack(targetDir)`).
642
+
643
+ **Placeholder scan:** README Task 7 Step 1 is descriptive prose (doc content, not code) — the
644
+ exact wording is left to the author's judgement, which is acceptable for a docs task; every
645
+ code step shows complete code. Test-count math is shown but flagged to use vitest's actual output.