@hecer/yoke 0.2.0 → 0.8.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 +130 -0
  2. package/README.md +583 -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 +37 -6
  38. package/dist/loop/cleanup.js +44 -3
  39. package/dist/loop/loop.js +67 -2
  40. package/dist/loop/reporter.js +88 -4
  41. package/dist/loop/run-command.js +25 -8
  42. package/dist/loop/runner.js +133 -14
  43. package/dist/loop/watchdog.js +67 -17
  44. package/dist/retrofit/apply.js +10 -3
  45. package/dist/retrofit/config.js +9 -1
  46. package/dist/retrofit/gitignore.js +4 -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,1024 @@
1
+ # Baustein K — Zero-to-100 Bootstrap 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:** `yoke new` (greenfield bootstrap: scaffold + retrofit + context + PRD template, optional idea→PRD draft), `yoke prd draft|check`, `yoke loop cleanup`, and a single-flight loop lock.
6
+
7
+ **Architecture:** Four small command modules following the existing seam pattern (`src/review/command.ts` is the model: injectable `isAvailable`/`run`/`git` functions, exit-code contracts). `runRetrofit` moves out of `cli.ts` into `src/retrofit/command.ts` to break the import cycle `cli → new → cli`. One new canon skill (`authoring-prd`).
8
+
9
+ **Tech Stack:** Node 20 ESM TypeScript (`.js` import specifiers!), vitest, zod, yaml. No new dependencies.
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-07-02-baustein-k-zero-to-100-bootstrap-design.md`
12
+
13
+ **Pitfalls for the implementer:**
14
+ - ESM: every relative import ends in `.js` even though sources are `.ts`.
15
+ - The rtk proxy mangles `grep`/`grep -c` output — verify test results with `npx vitest run --reporter=json --outputFile=.yoke-test.json` and parse with node, or read the vitest summary directly. A conda `pydantic_core` stderr banner appears on every shell command; it is harmless noise.
16
+ - Run `npx tsc --noEmit` before each commit.
17
+ - Deviation from spec §Testing: `tests/canon/real-canon.test.ts` has **no** skill-count assertion; Task 8 adds a registration assertion for `authoring-prd` instead.
18
+
19
+ ---
20
+
21
+ ### Task 1: Move `runRetrofit` to `src/retrofit/command.ts` (cycle-breaking refactor, no behavior change)
22
+
23
+ **Files:**
24
+ - Create: `src/retrofit/command.ts`
25
+ - Modify: `src/cli.ts`
26
+
27
+ - [ ] **Step 1: Create `src/retrofit/command.ts`** — move the entire `runRetrofit` function plus its imports out of `src/cli.ts` verbatim:
28
+
29
+ ```ts
30
+ import { join } from 'node:path'
31
+ import { resolveCanonDir } from './canon-dir.js'
32
+ import { planRetrofit } from './plan.js'
33
+ import { applyActions } from './apply.js'
34
+ import { formatReport } from './report.js'
35
+ import { detectProject } from './detect.js'
36
+ import { ensureGitignore } from './gitignore.js'
37
+ import { loadConfig, saveConfig, defaultConfig, type Agent, type YokeConfig, type CodeGraph } from './config.js'
38
+ import { loadManifest } from '../canon/manifest.js'
39
+
40
+ export function runRetrofit(targetDir: string, opts: { loop: boolean; agents?: Agent[]; codeGraph?: CodeGraph }): number {
41
+ // ... body copied 1:1 from src/cli.ts (lines 35-68) ...
42
+ }
43
+ ```
44
+
45
+ Copy the body exactly as it is in `src/cli.ts:35-68` — do not "improve" it.
46
+
47
+ - [ ] **Step 2: Update `src/cli.ts`** — delete the moved function and its now-unused imports, and re-export for the existing tests:
48
+
49
+ ```ts
50
+ export { runRetrofit } from './retrofit/command.js'
51
+ ```
52
+
53
+ (`tests/retrofit/retrofit.integration.test.ts` imports `runRetrofit` from `../../src/cli.js`; the re-export keeps that import working.)
54
+
55
+ - [ ] **Step 3: Verify** — `npx tsc --noEmit` clean, then `npx vitest run tests/retrofit` → all pass.
56
+
57
+ - [ ] **Step 4: Commit** — `refactor(retrofit): extract runRetrofit into retrofit/command (break cli cycle)`
58
+
59
+ ---
60
+
61
+ ### Task 2: Loop lock module
62
+
63
+ **Files:**
64
+ - Create: `src/loop/lock.ts`
65
+ - Test: `tests/loop/lock.test.ts`
66
+
67
+ - [ ] **Step 1: Write the failing tests** (`tests/loop/lock.test.ts`):
68
+
69
+ ```ts
70
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
71
+ import { mkdtempSync, rmSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
72
+ import { tmpdir } from 'node:os'
73
+ import { join } from 'node:path'
74
+ import { acquireLock, releaseLock, readLock, isPidAlive, lockPath } from '../../src/loop/lock.js'
75
+
76
+ let dir: string
77
+ beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'yoke-lock-')) })
78
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
79
+
80
+ describe('loop lock', () => {
81
+ it('acquires when no lock exists (creates .yoke if needed)', () => {
82
+ const r = acquireLock(dir, process.pid)
83
+ expect(r.acquired).toBe(true)
84
+ expect(readLock(dir)?.pid).toBe(process.pid)
85
+ })
86
+
87
+ it('refuses when the holder pid is alive', () => {
88
+ mkdirSync(join(dir, '.yoke'), { recursive: true })
89
+ writeFileSync(lockPath(dir), JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }))
90
+ const r = acquireLock(dir, 999999)
91
+ expect(r.acquired).toBe(false)
92
+ expect(r.holderPid).toBe(process.pid)
93
+ })
94
+
95
+ it('takes over a stale lock (dead pid) and reports it', () => {
96
+ mkdirSync(join(dir, '.yoke'), { recursive: true })
97
+ // 2^22 exceeds the default pid_max on every platform we target — treat as dead
98
+ writeFileSync(lockPath(dir), JSON.stringify({ pid: 4194304 + 12345, startedAt: new Date().toISOString() }))
99
+ const r = acquireLock(dir, process.pid)
100
+ expect(r.acquired).toBe(true)
101
+ expect(r.stalePid).toBe(4194304 + 12345)
102
+ expect(readLock(dir)?.pid).toBe(process.pid)
103
+ })
104
+
105
+ it('takes over a corrupt lock file', () => {
106
+ mkdirSync(join(dir, '.yoke'), { recursive: true })
107
+ writeFileSync(lockPath(dir), 'not json')
108
+ const r = acquireLock(dir, process.pid)
109
+ expect(r.acquired).toBe(true)
110
+ })
111
+
112
+ it('releaseLock removes the file and never throws', () => {
113
+ acquireLock(dir, process.pid)
114
+ releaseLock(dir)
115
+ expect(existsSync(lockPath(dir))).toBe(false)
116
+ releaseLock(dir) // second call: no throw
117
+ })
118
+
119
+ it('isPidAlive: own pid true, absurd pid false, non-positive false', () => {
120
+ expect(isPidAlive(process.pid)).toBe(true)
121
+ expect(isPidAlive(4194304 + 54321)).toBe(false)
122
+ expect(isPidAlive(0)).toBe(false)
123
+ expect(isPidAlive(-1)).toBe(false)
124
+ })
125
+ })
126
+ ```
127
+
128
+ - [ ] **Step 2: Run to verify failure** — `npx vitest run tests/loop/lock.test.ts` → FAIL (module not found).
129
+
130
+ - [ ] **Step 3: Implement `src/loop/lock.ts`:**
131
+
132
+ ```ts
133
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'
134
+ import { join, dirname } from 'node:path'
135
+
136
+ export interface LockInfo { pid: number; startedAt: string }
137
+ export interface LockResult { acquired: boolean; holderPid?: number; stalePid?: number }
138
+
139
+ export function lockPath(targetDir: string): string {
140
+ return join(targetDir, '.yoke', 'loop.lock')
141
+ }
142
+
143
+ // Liveness probe via signal 0. EPERM means "exists but not ours" — alive.
144
+ export function isPidAlive(pid: number): boolean {
145
+ if (!Number.isInteger(pid) || pid <= 0) return false
146
+ try {
147
+ process.kill(pid, 0)
148
+ return true
149
+ } catch (e) {
150
+ return (e as NodeJS.ErrnoException).code === 'EPERM'
151
+ }
152
+ }
153
+
154
+ export function readLock(targetDir: string): LockInfo | null {
155
+ const file = lockPath(targetDir)
156
+ if (!existsSync(file)) return null
157
+ try {
158
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as LockInfo
159
+ return typeof parsed?.pid === 'number' ? parsed : null
160
+ } catch {
161
+ return null
162
+ }
163
+ }
164
+
165
+ export function acquireLock(targetDir: string, pid: number = process.pid): LockResult {
166
+ const file = lockPath(targetDir)
167
+ const holder = readLock(targetDir)
168
+ if (holder && isPidAlive(holder.pid)) return { acquired: false, holderPid: holder.pid }
169
+ mkdirSync(dirname(file), { recursive: true })
170
+ writeFileSync(file, JSON.stringify({ pid, startedAt: new Date().toISOString() }))
171
+ return holder ? { acquired: true, stalePid: holder.pid } : { acquired: true }
172
+ }
173
+
174
+ // Best-effort: a missing file or an unwritable disk must never crash loop teardown.
175
+ export function releaseLock(targetDir: string): void {
176
+ try { rmSync(lockPath(targetDir), { force: true }) } catch { /* best-effort */ }
177
+ }
178
+ ```
179
+
180
+ - [ ] **Step 4: Run** — `npx vitest run tests/loop/lock.test.ts` → PASS. `npx tsc --noEmit` clean.
181
+
182
+ - [ ] **Step 5: Commit** — `feat(loop): single-flight lock module`
183
+
184
+ ---
185
+
186
+ ### Task 3: Wire the lock into `runLoopCommand` + gitignore line
187
+
188
+ **Files:**
189
+ - Modify: `src/loop/run-command.ts` (function `runLoopCommand`, currently lines 81-142)
190
+ - Modify: `src/retrofit/gitignore.ts` (const `YOKE_IGNORE_LINES`)
191
+ - Test: `tests/loop/run-command.test.ts` (add one case), `tests/retrofit/gitignore.test.ts` (extend)
192
+
193
+ - [ ] **Step 1: Write the failing tests.** In `tests/loop/run-command.test.ts`, add (reuse the file's existing helpers for a runnable temp project — config with loop enabled, PRD, injected runner/git/verify):
194
+
195
+ ```ts
196
+ it('returns 2 when another loop holds the lock', () => {
197
+ // arrange a fully runnable loop project exactly like the existing happy-path test,
198
+ // then plant a live lock before calling runLoopCommand:
199
+ mkdirSync(join(dir, '.yoke'), { recursive: true })
200
+ writeFileSync(join(dir, '.yoke', 'loop.lock'), JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }))
201
+ const code = runLoopCommand(dir, { maxIterations: 1, runner, git, verify, reporter })
202
+ expect(code).toBe(2)
203
+ })
204
+
205
+ it('releases the lock after a run', () => {
206
+ // run the existing happy-path setup to completion, then:
207
+ expect(existsSync(join(dir, '.yoke', 'loop.lock'))).toBe(false)
208
+ })
209
+ ```
210
+
211
+ In `tests/retrofit/gitignore.test.ts`, extend the existing expectations so `.yoke/loop.lock` is one of the ensured lines.
212
+
213
+ - [ ] **Step 2: Run to verify failure** — the lock case fails (returns 0/1, not 2), gitignore case fails.
214
+
215
+ - [ ] **Step 3: Implement.** In `src/retrofit/gitignore.ts` add the line:
216
+
217
+ ```ts
218
+ export const YOKE_IGNORE_LINES = [
219
+ '.yoke/worktrees/',
220
+ '.yoke/backup/',
221
+ '.yoke/loop-status.json',
222
+ '.yoke/loop.log',
223
+ '.yoke/loop.lock',
224
+ ]
225
+ ```
226
+
227
+ In `src/loop/run-command.ts`: `import { acquireLock, releaseLock } from './lock.js'`, then in `runLoopCommand`, AFTER all existing pre-checks (loop enabled, PRD exists, verify resolved, runner/review available) and immediately BEFORE the `runLoop(...)` call, insert:
228
+
229
+ ```ts
230
+ const lock = acquireLock(targetDir)
231
+ if (!lock.acquired) {
232
+ console.error(`Another loop is already running here (pid ${lock.holderPid}). If that is wrong, run: yoke loop cleanup`)
233
+ return 2
234
+ }
235
+ if (lock.stalePid !== undefined) {
236
+ console.warn(`Took over a stale loop lock (pid ${lock.stalePid} is gone).`)
237
+ }
238
+ try {
239
+ const result = runLoop({ /* unchanged existing args */ })
240
+ /* unchanged existing console.log lines + return */
241
+ } finally {
242
+ releaseLock(targetDir)
243
+ }
244
+ ```
245
+
246
+ (The existing `result`/logging/return block moves inside the `try`.)
247
+
248
+ - [ ] **Step 4: Check the integration test.** `tests/loop/loop-cli.integration.test.ts` drives a real repo — if it writes its own `.gitignore` instead of calling retrofit/`ensureGitignore`, the untracked lock file cannot break its clean-tree gate because the lock is released before gates run per iteration and only exists during the run; but the pre-dispatch gate runs DURING the run while the lock exists. If that test's project lacks the ignore line, add `.yoke/loop.lock` to whatever `.gitignore` the test writes. Run the loop test suite: `npx vitest run tests/loop` → all pass.
249
+
250
+ - [ ] **Step 5: Commit** — `feat(loop): refuse concurrent runs via loop.lock; gitignore it`
251
+
252
+ ---
253
+
254
+ ### Task 4: PRD template, draft prompt, `runPrdDraft`
255
+
256
+ **Files:**
257
+ - Create: `src/prd/command.ts`
258
+ - Test: `tests/prd/command.test.ts`
259
+
260
+ - [ ] **Step 1: Write the failing tests** (`tests/prd/command.test.ts`):
261
+
262
+ ```ts
263
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
264
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs'
265
+ import { tmpdir } from 'node:os'
266
+ import { join } from 'node:path'
267
+ import { runPrdDraft, buildPrdDraftPrompt, PRD_TEMPLATE } from '../../src/prd/command.js'
268
+ import type { Invocation } from '../../src/loop/runner.js'
269
+
270
+ let dir: string
271
+ beforeEach(() => {
272
+ dir = mkdtempSync(join(tmpdir(), 'yoke-prd-'))
273
+ mkdirSync(join(dir, '.yoke'), { recursive: true })
274
+ })
275
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
276
+
277
+ const VALID_PRD = `- id: STORY-1\n title: scaffold project\n priority: 1\n acceptance:\n - "verify command exits 0"\n passes: false\n`
278
+
279
+ function writingRun(content: string, calls: Invocation[] = []) {
280
+ return (inv: Invocation) => {
281
+ calls.push(inv)
282
+ writeFileSync(join(dir, '.yoke', 'prd.yaml'), content)
283
+ return { success: true, summary: 'exited 0' }
284
+ }
285
+ }
286
+
287
+ describe('buildPrdDraftPrompt', () => {
288
+ it('contains the idea, the story band, the scaffold rule and the write-only rule', () => {
289
+ const p = buildPrdDraftPrompt('a todo cli')
290
+ expect(p).toContain('a todo cli')
291
+ expect(p).toContain('5-12')
292
+ expect(p).toContain('STORY-1')
293
+ expect(p).toContain('.yoke/prd.yaml')
294
+ expect(p).toContain('Do not commit')
295
+ })
296
+ })
297
+
298
+ describe('PRD_TEMPLATE', () => {
299
+ it('parses to an empty story array', () => {
300
+ writeFileSync(join(dir, '.yoke', 'prd.yaml'), PRD_TEMPLATE)
301
+ // loadPrd via runPrdCheck is Task 5; here just assert the YAML payload
302
+ expect(PRD_TEMPLATE.trim().endsWith('[]')).toBe(true)
303
+ })
304
+ })
305
+
306
+ describe('runPrdDraft', () => {
307
+ it('invokes the resolved agent with the draft prompt and validates the result', () => {
308
+ const calls: Invocation[] = []
309
+ const code = runPrdDraft(dir, { idea: 'a todo cli', runner: 'codex', isAvailable: () => true, run: writingRun(VALID_PRD, calls) })
310
+ expect(code).toBe(0)
311
+ expect(calls[0].command).toBe('codex')
312
+ expect(calls[0].input).toContain('a todo cli')
313
+ })
314
+
315
+ it('requires an idea', () => {
316
+ expect(runPrdDraft(dir, { idea: ' ', isAvailable: () => true, run: writingRun(VALID_PRD) })).toBe(1)
317
+ })
318
+
319
+ it('returns 2 when the requested agent is unavailable', () => {
320
+ expect(runPrdDraft(dir, { idea: 'x', runner: 'gemini', isAvailable: () => false, run: writingRun(VALID_PRD) })).toBe(2)
321
+ })
322
+
323
+ it('refuses to overwrite a PRD with stories unless --force', () => {
324
+ writeFileSync(join(dir, '.yoke', 'prd.yaml'), VALID_PRD)
325
+ expect(runPrdDraft(dir, { idea: 'x', isAvailable: () => true, run: writingRun(VALID_PRD) })).toBe(1)
326
+ expect(runPrdDraft(dir, { idea: 'x', force: true, isAvailable: () => true, run: writingRun(VALID_PRD) })).toBe(0)
327
+ })
328
+
329
+ it('the template (zero stories) does not trigger the overwrite guard', () => {
330
+ writeFileSync(join(dir, '.yoke', 'prd.yaml'), PRD_TEMPLATE)
331
+ expect(runPrdDraft(dir, { idea: 'x', isAvailable: () => true, run: writingRun(VALID_PRD) })).toBe(0)
332
+ })
333
+
334
+ it('fails when the agent output is invalid YAML/schema', () => {
335
+ expect(runPrdDraft(dir, { idea: 'x', isAvailable: () => true, run: writingRun('- id: 1\n nope: true\n') })).toBe(1)
336
+ })
337
+
338
+ it('fails when the agent produces an empty PRD', () => {
339
+ expect(runPrdDraft(dir, { idea: 'x', isAvailable: () => true, run: writingRun('[]') })).toBe(1)
340
+ })
341
+
342
+ it('fails when the agent run fails', () => {
343
+ const run = (_: Invocation) => ({ success: false, summary: 'boom' })
344
+ expect(runPrdDraft(dir, { idea: 'x', isAvailable: () => true, run })).toBe(1)
345
+ })
346
+ })
347
+ ```
348
+
349
+ - [ ] **Step 2: Run to verify failure** — module not found.
350
+
351
+ - [ ] **Step 3: Implement `src/prd/command.ts`:**
352
+
353
+ ```ts
354
+ import { existsSync } from 'node:fs'
355
+ import { join } from 'node:path'
356
+ import type { Agent } from '../retrofit/config.js'
357
+ import { loadConfig } from '../retrofit/config.js'
358
+ import { loadPrd } from '../loop/prd.js'
359
+ import {
360
+ agentInvocation,
361
+ buildWatchdogInvocation,
362
+ runAgent,
363
+ isAgentAvailable,
364
+ type Invocation,
365
+ type AgentResult,
366
+ } from '../loop/runner.js'
367
+ import { resolveIdleMs } from '../loop/run-command.js'
368
+
369
+ export const PRD_TEMPLATE = `# Yoke PRD — the loop picks the lowest-priority open story each iteration.
370
+ # Story format (see canon/loop/prd.schema.md):
371
+ # - id: STORY-1
372
+ # title: scaffold the project with a runnable test suite
373
+ # priority: 1
374
+ # acceptance:
375
+ # - "the verify command exits 0"
376
+ # - "a placeholder test exists and passes"
377
+ # passes: false
378
+ []
379
+ `
380
+
381
+ export function buildPrdDraftPrompt(idea: string): string {
382
+ return [
383
+ 'You are drafting a PRD for the Yoke autonomous loop.',
384
+ '',
385
+ `Product idea: ${idea}`,
386
+ '',
387
+ 'Break the idea into 5-12 small, independently shippable stories; each must fit one loop iteration.',
388
+ 'Each story needs:',
389
+ '- id: STORY-1, STORY-2, ... (unique)',
390
+ '- title: one imperative sentence',
391
+ '- priority: dense integers from 1 (lower = built first)',
392
+ '- acceptance: 2-5 testable, behavioral criteria (observable outcomes, never implementation steps)',
393
+ '- passes: false',
394
+ '',
395
+ 'If the project has no source code yet, STORY-1 must scaffold the project skeleton with a runnable',
396
+ 'test suite, and its acceptance must include that the verify command (verify.command in',
397
+ '.yoke/config.yaml) exits 0.',
398
+ '',
399
+ 'Write ONLY the file .yoke/prd.yaml as a YAML array of stories in exactly that shape.',
400
+ 'Do not modify any other file. Do not commit.',
401
+ ].join('\n')
402
+ }
403
+
404
+ export interface PrdDraftOptions {
405
+ idea: string
406
+ runner?: Agent
407
+ force?: boolean
408
+ timeoutMinutes?: number
409
+ isAvailable?: (a: Agent) => boolean
410
+ run?: (inv: Invocation) => AgentResult
411
+ }
412
+
413
+ export function prdFile(targetDir: string): string {
414
+ return join(targetDir, '.yoke', 'prd.yaml')
415
+ }
416
+
417
+ export function runPrdDraft(targetDir: string, opts: PrdDraftOptions): number {
418
+ const idea = opts.idea?.trim()
419
+ if (!idea) {
420
+ console.error('yoke prd draft requires --idea="..."')
421
+ return 1
422
+ }
423
+ const path = prdFile(targetDir)
424
+ if (existsSync(path) && !opts.force) {
425
+ try {
426
+ const existing = loadPrd(path)
427
+ if (existing.length > 0) {
428
+ console.error(`PRD already has ${existing.length} stories — use --force to overwrite.`)
429
+ return 1
430
+ }
431
+ } catch {
432
+ // unparseable existing file: the draft will replace it
433
+ }
434
+ }
435
+ const available = opts.isAvailable ?? isAgentAvailable
436
+ const config = loadConfig(targetDir)
437
+ const agent: Agent = opts.runner ?? config?.agents[0] ?? 'claude'
438
+ if (!available(agent)) {
439
+ console.error(`Agent CLI "${agent}" was not found on PATH. Install it, or pick another with --runner=<claude|codex|gemini>.`)
440
+ return 2
441
+ }
442
+ const idleMs = resolveIdleMs(opts.timeoutMinutes, undefined)
443
+ const inv = agentInvocation(agent, buildPrdDraftPrompt(idea), targetDir)
444
+ console.log(`Drafting PRD with ${agent}...`)
445
+ const run = opts.run ?? ((i: Invocation) => runAgent(buildWatchdogInvocation(i, idleMs)))
446
+ const result = run(inv)
447
+ if (!result.success) {
448
+ console.error(`PRD draft failed: ${result.summary}`)
449
+ return 1
450
+ }
451
+ let count: number
452
+ try {
453
+ count = loadPrd(path).length
454
+ } catch (e) {
455
+ console.error(`PRD draft produced an invalid PRD: ${(e as Error).message}`)
456
+ return 1
457
+ }
458
+ if (count === 0) {
459
+ console.error('PRD draft failed: agent produced an empty PRD.')
460
+ return 1
461
+ }
462
+ console.log(`Drafted ${count} stories → ${path}`)
463
+ return 0
464
+ }
465
+ ```
466
+
467
+ - [ ] **Step 4: Run** — `npx vitest run tests/prd` → PASS. `npx tsc --noEmit` clean.
468
+
469
+ - [ ] **Step 5: Commit** — `feat(prd): PRD template + idea→PRD draft via agent`
470
+
471
+ ---
472
+
473
+ ### Task 5: `runPrdCheck`
474
+
475
+ **Files:**
476
+ - Modify: `src/prd/command.ts`
477
+ - Test: `tests/prd/command.test.ts` (extend)
478
+
479
+ - [ ] **Step 1: Write the failing tests** (append to `tests/prd/command.test.ts`; import `runPrdCheck`):
480
+
481
+ ```ts
482
+ describe('runPrdCheck', () => {
483
+ const write = (content: string) => writeFileSync(join(dir, '.yoke', 'prd.yaml'), content)
484
+
485
+ it('passes a valid PRD', () => {
486
+ write(VALID_PRD)
487
+ expect(runPrdCheck(dir)).toBe(0)
488
+ })
489
+
490
+ it('fails when the file is missing', () => {
491
+ expect(runPrdCheck(dir)).toBe(1)
492
+ })
493
+
494
+ it('fails on schema violations', () => {
495
+ write('- id: STORY-1\n')
496
+ expect(runPrdCheck(dir)).toBe(1)
497
+ })
498
+
499
+ it('fails on duplicate ids', () => {
500
+ write(VALID_PRD + VALID_PRD.replace('scaffold project', 'again'))
501
+ expect(runPrdCheck(dir)).toBe(1)
502
+ })
503
+
504
+ it('fails on empty acceptance', () => {
505
+ write('- id: STORY-1\n title: t\n priority: 1\n acceptance: []\n passes: false\n')
506
+ expect(runPrdCheck(dir)).toBe(1)
507
+ })
508
+
509
+ it('fails on zero stories (the untouched template)', () => {
510
+ write(PRD_TEMPLATE)
511
+ expect(runPrdCheck(dir)).toBe(1)
512
+ })
513
+ })
514
+ ```
515
+
516
+ - [ ] **Step 2: Run to verify failure.**
517
+
518
+ - [ ] **Step 3: Implement** (append to `src/prd/command.ts`; add imports `progress` from `../loop/prd.js` and `Story` type):
519
+
520
+ ```ts
521
+ export function runPrdCheck(targetDir: string): number {
522
+ const path = prdFile(targetDir)
523
+ if (!existsSync(path)) {
524
+ console.error(`No PRD at ${path} — create one with yoke prd draft or yoke new.`)
525
+ return 1
526
+ }
527
+ let stories: Story[]
528
+ try {
529
+ stories = loadPrd(path)
530
+ } catch (e) {
531
+ console.error(`Invalid PRD: ${(e as Error).message}`)
532
+ return 1
533
+ }
534
+ const errors: string[] = []
535
+ if (stories.length === 0) errors.push('PRD has no stories')
536
+ const seen = new Set<string>()
537
+ for (const s of stories) {
538
+ if (seen.has(s.id)) errors.push(`duplicate story id: ${s.id}`)
539
+ seen.add(s.id)
540
+ // the schema allows [], but the loop's stop-the-line gate blocks it — fail fast here
541
+ if (s.acceptance.length === 0) errors.push(`story ${s.id} has no acceptance criteria`)
542
+ }
543
+ if (errors.length > 0) {
544
+ for (const e of errors) console.error(`ERROR ${e}`)
545
+ return 1
546
+ }
547
+ const p = progress(stories)
548
+ console.log(`✓ PRD valid — ${p.total} stories, ${p.passed} pass`)
549
+ return 0
550
+ }
551
+ ```
552
+
553
+ (`import { loadPrd, progress, type Story } from '../loop/prd.js'` — merge with the existing loadPrd import.)
554
+
555
+ - [ ] **Step 4: Run** — `npx vitest run tests/prd` → PASS.
556
+
557
+ - [ ] **Step 5: Commit** — `feat(prd): yoke prd check lint gate`
558
+
559
+ ---
560
+
561
+ ### Task 6: `yoke loop cleanup`
562
+
563
+ **Files:**
564
+ - Create: `src/loop/cleanup.ts`
565
+ - Test: `tests/loop/cleanup.test.ts`
566
+
567
+ - [ ] **Step 1: Write the failing tests:**
568
+
569
+ ```ts
570
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
571
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from 'node:fs'
572
+ import { tmpdir } from 'node:os'
573
+ import { join } from 'node:path'
574
+ import { runLoopCleanup } from '../../src/loop/cleanup.js'
575
+ import { lockPath } from '../../src/loop/lock.js'
576
+
577
+ let dir: string
578
+ beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'yoke-clean-')) })
579
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
580
+
581
+ describe('runLoopCleanup', () => {
582
+ it('reports nothing to clean on a fresh dir', () => {
583
+ expect(runLoopCleanup(dir, { git: () => { throw new Error('must not be called') } })).toBe(0)
584
+ })
585
+
586
+ it('removes every yoke worktree via git and prunes', () => {
587
+ mkdirSync(join(dir, '.yoke', 'worktrees', 'STORY-1'), { recursive: true })
588
+ mkdirSync(join(dir, '.yoke', 'worktrees', 'STORY-2'), { recursive: true })
589
+ const calls: string[][] = []
590
+ const code = runLoopCleanup(dir, { git: (args) => { calls.push(args) } })
591
+ expect(code).toBe(0)
592
+ const removes = calls.filter(a => a[1] === 'remove')
593
+ expect(removes).toHaveLength(2)
594
+ expect(calls.some(a => a[1] === 'prune')).toBe(true)
595
+ })
596
+
597
+ it('returns 1 when a removal fails but continues with the rest', () => {
598
+ mkdirSync(join(dir, '.yoke', 'worktrees', 'A'), { recursive: true })
599
+ mkdirSync(join(dir, '.yoke', 'worktrees', 'B'), { recursive: true })
600
+ let n = 0
601
+ const code = runLoopCleanup(dir, { git: (args) => { if (args[1] === 'remove' && n++ === 0) throw new Error('boom') } })
602
+ expect(code).toBe(1)
603
+ expect(n).toBeGreaterThan(0)
604
+ })
605
+
606
+ it('removes a stale lock but keeps a live one', () => {
607
+ mkdirSync(join(dir, '.yoke'), { recursive: true })
608
+ writeFileSync(lockPath(dir), JSON.stringify({ pid: 4194304 + 999, startedAt: 'x' }))
609
+ runLoopCleanup(dir, { git: () => {} })
610
+ expect(existsSync(lockPath(dir))).toBe(false)
611
+
612
+ writeFileSync(lockPath(dir), JSON.stringify({ pid: process.pid, startedAt: 'x' }))
613
+ runLoopCleanup(dir, { git: () => {} })
614
+ expect(existsSync(lockPath(dir))).toBe(true)
615
+ })
616
+ })
617
+ ```
618
+
619
+ - [ ] **Step 2: Run to verify failure.**
620
+
621
+ - [ ] **Step 3: Implement `src/loop/cleanup.ts`:**
622
+
623
+ ```ts
624
+ import { existsSync, readdirSync, rmSync } from 'node:fs'
625
+ import { join } from 'node:path'
626
+ import { execFileSync } from 'node:child_process'
627
+ import { lockPath, readLock, isPidAlive } from './lock.js'
628
+
629
+ export interface CleanupOptions {
630
+ git?: (args: string[], cwd: string) => void
631
+ }
632
+
633
+ // Cleans ONLY yoke-created runtime artifacts: .yoke/worktrees/* and a stale loop.lock.
634
+ // Never touches user-created worktrees or a lock whose holder is alive.
635
+ export function runLoopCleanup(targetDir: string, opts: CleanupOptions = {}): number {
636
+ const git = opts.git ?? ((args: string[], cwd: string) => { execFileSync('git', args, { cwd, stdio: 'pipe' }) })
637
+ const wtDir = join(targetDir, '.yoke', 'worktrees')
638
+ let removed = 0
639
+ let failed = 0
640
+ if (existsSync(wtDir)) {
641
+ for (const name of readdirSync(wtDir)) {
642
+ const path = join(wtDir, name)
643
+ try {
644
+ git(['worktree', 'remove', '--force', path], targetDir)
645
+ removed++
646
+ } catch (e) {
647
+ console.error(`Failed to remove worktree ${path}: ${(e as Error).message}`)
648
+ failed++
649
+ }
650
+ }
651
+ try { git(['worktree', 'prune'], targetDir) } catch { /* best-effort */ }
652
+ }
653
+ const lockFile = lockPath(targetDir)
654
+ if (existsSync(lockFile)) {
655
+ const holder = readLock(targetDir)
656
+ if (holder && isPidAlive(holder.pid)) {
657
+ console.log(`Loop lock held by a live process (pid ${holder.pid}) — left in place.`)
658
+ } else {
659
+ rmSync(lockFile, { force: true })
660
+ console.log('Removed stale loop lock.')
661
+ }
662
+ }
663
+ console.log(removed === 0 && failed === 0 ? 'Nothing to clean.' : `Removed ${removed} worktree(s)${failed > 0 ? `, ${failed} failed` : ''}.`)
664
+ return failed === 0 ? 0 : 1
665
+ }
666
+ ```
667
+
668
+ - [ ] **Step 4: Run** — `npx vitest run tests/loop/cleanup.test.ts` → PASS.
669
+
670
+ - [ ] **Step 5: Commit** — `feat(loop): yoke loop cleanup for orphaned worktrees + stale lock`
671
+
672
+ ---
673
+
674
+ ### Task 7: `yoke new`
675
+
676
+ **Files:**
677
+ - Create: `src/new/command.ts`
678
+ - Test: `tests/new/command.test.ts`
679
+
680
+ - [ ] **Step 1: Write the failing tests:**
681
+
682
+ ```ts
683
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
684
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs'
685
+ import { tmpdir } from 'node:os'
686
+ import { join } from 'node:path'
687
+ import { runNew } from '../../src/new/command.js'
688
+ import type { Invocation } from '../../src/loop/runner.js'
689
+
690
+ const VALID_PRD = `- id: STORY-1\n title: scaffold project\n priority: 1\n acceptance:\n - "verify command exits 0"\n passes: false\n`
691
+
692
+ let parent: string
693
+ beforeEach(() => { parent = mkdtempSync(join(tmpdir(), 'yoke-new-')) })
694
+ afterEach(() => { rmSync(parent, { recursive: true, force: true }) })
695
+
696
+ describe('runNew', () => {
697
+ const noGit = { git: (_args: string[], _cwd: string) => {} }
698
+
699
+ it('refuses a non-empty existing directory', () => {
700
+ const dir = join(parent, 'app')
701
+ mkdirSync(dir)
702
+ writeFileSync(join(dir, 'x.txt'), 'x')
703
+ expect(runNew(dir, { ...noGit })).toBe(1)
704
+ })
705
+
706
+ it('scaffolds README, .gitignore, retrofit artifacts, context and the PRD template', () => {
707
+ const dir = join(parent, 'app')
708
+ const gitCalls: string[][] = []
709
+ const code = runNew(dir, { git: (args) => { gitCalls.push(args) } })
710
+ expect(code).toBe(0)
711
+ expect(readFileSync(join(dir, 'README.md'), 'utf8')).toContain('# app')
712
+ expect(readFileSync(join(dir, '.gitignore'), 'utf8')).toContain('node_modules/')
713
+ expect(existsSync(join(dir, 'CLAUDE.md'))).toBe(true) // retrofit ran
714
+ expect(existsSync(join(dir, '.yoke', 'context', 'PROJECT.md'))).toBe(true) // context init ran
715
+ expect(readFileSync(join(dir, '.yoke', 'prd.yaml'), 'utf8').trim().endsWith('[]')).toBe(true)
716
+ expect(gitCalls[0]).toEqual(['init'])
717
+ expect(gitCalls.some(a => a[0] === '-c' && a[2] === 'commit')).toBe(true) // initial commit
718
+ })
719
+
720
+ it('seeds PROJECT.md with the idea', () => {
721
+ const dir = join(parent, 'app')
722
+ runNew(dir, { ...noGit, idea: 'a todo cli', isAvailable: () => true, run: (inv: Invocation) => { writeFileSync(join(dir, '.yoke', 'prd.yaml'), VALID_PRD); return { success: true, summary: 'ok' } } })
723
+ expect(readFileSync(join(dir, '.yoke', 'context', 'PROJECT.md'), 'utf8')).toContain('a todo cli')
724
+ })
725
+
726
+ it('with --idea drafts the PRD via the injected runner and commits twice', () => {
727
+ const dir = join(parent, 'app')
728
+ const gitCalls: string[][] = []
729
+ const code = runNew(dir, {
730
+ idea: 'a todo cli',
731
+ git: (args) => { gitCalls.push(args) },
732
+ isAvailable: () => true,
733
+ run: (_inv: Invocation) => { writeFileSync(join(dir, '.yoke', 'prd.yaml'), VALID_PRD); return { success: true, summary: 'ok' } },
734
+ })
735
+ expect(code).toBe(0)
736
+ const commits = gitCalls.filter(a => a.includes('commit'))
737
+ expect(commits).toHaveLength(2)
738
+ })
739
+
740
+ it('keeps the template and returns non-zero when the draft fails', () => {
741
+ const dir = join(parent, 'app')
742
+ const code = runNew(dir, {
743
+ idea: 'a todo cli',
744
+ ...noGit,
745
+ isAvailable: () => true,
746
+ run: (_inv: Invocation) => ({ success: false, summary: 'boom' }),
747
+ })
748
+ expect(code).toBe(1)
749
+ expect(readFileSync(join(dir, '.yoke', 'prd.yaml'), 'utf8').trim().endsWith('[]')).toBe(true)
750
+ })
751
+ })
752
+ ```
753
+
754
+ - [ ] **Step 2: Run to verify failure.**
755
+
756
+ - [ ] **Step 3: Implement `src/new/command.ts`:**
757
+
758
+ ```ts
759
+ import { existsSync, mkdirSync, readdirSync, writeFileSync, appendFileSync } from 'node:fs'
760
+ import { join, basename, resolve } from 'node:path'
761
+ import { execFileSync } from 'node:child_process'
762
+ import type { Agent } from '../retrofit/config.js'
763
+ import { runRetrofit } from '../retrofit/command.js'
764
+ import { runContextInit } from '../context/command.js'
765
+ import { runPrdDraft, PRD_TEMPLATE, type PrdDraftOptions } from '../prd/command.js'
766
+
767
+ export interface RunNewOptions {
768
+ idea?: string
769
+ agents?: Agent[]
770
+ runner?: Agent
771
+ loop?: boolean
772
+ timeoutMinutes?: number
773
+ git?: (args: string[], cwd: string) => void
774
+ isAvailable?: PrdDraftOptions['isAvailable']
775
+ run?: PrdDraftOptions['run']
776
+ }
777
+
778
+ export function runNew(dir: string, opts: RunNewOptions = {}): number {
779
+ const git = opts.git ?? ((args: string[], cwd: string) => { execFileSync('git', args, { cwd, stdio: 'pipe' }) })
780
+ const target = resolve(dir)
781
+ if (existsSync(target) && readdirSync(target).length > 0) {
782
+ console.error(`${dir} already exists and is not empty — yoke new is greenfield-only (use yoke retrofit for existing projects).`)
783
+ return 1
784
+ }
785
+ mkdirSync(target, { recursive: true })
786
+ git(['init'], target)
787
+ const name = basename(target)
788
+ writeFileSync(join(target, 'README.md'), `# ${name}\n${opts.idea ? `\n${opts.idea}\n` : ''}`)
789
+ writeFileSync(join(target, '.gitignore'), 'node_modules/\ndist/\n.env\n')
790
+ runRetrofit(target, { loop: opts.loop ?? false, agents: opts.agents })
791
+ runContextInit(target)
792
+ if (opts.idea) {
793
+ appendFileSync(join(target, '.yoke', 'context', 'PROJECT.md'), `\n## Idea\n\n${opts.idea}\n`)
794
+ }
795
+ writeFileSync(join(target, '.yoke', 'prd.yaml'), PRD_TEMPLATE)
796
+ git(['add', '-A'], target)
797
+ git(['-c', 'commit.gpgsign=false', 'commit', '-m', `chore: bootstrap ${name} with yoke`], target)
798
+
799
+ let code = 0
800
+ if (opts.idea) {
801
+ const draft = runPrdDraft(target, {
802
+ idea: opts.idea,
803
+ runner: opts.runner,
804
+ timeoutMinutes: opts.timeoutMinutes,
805
+ isAvailable: opts.isAvailable,
806
+ run: opts.run,
807
+ })
808
+ if (draft === 0) {
809
+ git(['add', '-A'], target)
810
+ git(['-c', 'commit.gpgsign=false', 'commit', '-m', 'docs: draft PRD from idea'], target)
811
+ } else {
812
+ console.error(`PRD draft did not succeed. The project is ready anyway; retry with: yoke prd draft ${dir} --idea="..."`)
813
+ code = draft
814
+ }
815
+ }
816
+
817
+ console.log([
818
+ `✓ ${name} bootstrapped.`,
819
+ 'Next steps:',
820
+ ' 1. Review .yoke/prd.yaml (or draft it: yoke prd draft --idea="...")',
821
+ ' 2. Set verify.command in .yoke/config.yaml (e.g. "npm test")',
822
+ ` 3. yoke loop on ${dir} && yoke loop run ${dir} --isolate`,
823
+ ].join('\n'))
824
+ return code
825
+ }
826
+ ```
827
+
828
+ - [ ] **Step 4: Run** — `npx vitest run tests/new` → PASS. Note: retrofit inside the test resolves the real bundled canon via `resolveCanonDir()` — the same mechanism `tests/retrofit/retrofit.integration.test.ts` relies on.
829
+
830
+ - [ ] **Step 5: Commit** — `feat(new): yoke new — greenfield bootstrap to loop-ready project`
831
+
832
+ ---
833
+
834
+ ### Task 8: Canon skill `authoring-prd`
835
+
836
+ **Files:**
837
+ - Create: `canon/skills/authoring-prd/SKILL.md`
838
+ - Modify: `canon/manifest.yaml`
839
+ - Test: `tests/canon/real-canon.test.ts` (add one assertion)
840
+
841
+ - [ ] **Step 1: Write the failing test** (append to `tests/canon/real-canon.test.ts`):
842
+
843
+ ```ts
844
+ it('registers the authoring-prd skill', () => {
845
+ const manifest = loadManifest(join(repoRoot, 'canon', 'manifest.yaml'))
846
+ expect(manifest.skills.some(s => s.id === 'authoring-prd')).toBe(true)
847
+ })
848
+ ```
849
+
850
+ - [ ] **Step 2: Run to verify failure.**
851
+
852
+ - [ ] **Step 3: Create `canon/skills/authoring-prd/SKILL.md`:**
853
+
854
+ ```markdown
855
+ ---
856
+ name: authoring-prd
857
+ description: Use when turning a product idea into a loop-ready .yoke/prd.yaml — slice the idea into small, independently shippable stories with testable behavioral acceptance criteria; greenfield STORY-1 scaffolds the project and wires verify.command.
858
+ ---
859
+
860
+ # Authoring a PRD
861
+
862
+ The Yoke loop is only as good as its stories. Bad stories ("build the app") stall it;
863
+ good stories (small, testable, ordered) let it run overnight.
864
+
865
+ ## Story rules
866
+
867
+ 1. **One iteration per story.** If you can't imagine an agent finishing it in one sitting,
868
+ split it. Prefer 5-12 stories over 3 epics.
869
+ 2. **Independently shippable.** After any story, the project builds and tests pass.
870
+ 3. **Acceptance = observable behavior**, never implementation:
871
+ - Good: "GET /health returns 200", "the CLI prints the sum of its arguments"
872
+ - Bad: "create a HealthController class", "use express"
873
+ 2-5 criteria per story. Each must be checkable by a test or a command.
874
+ 4. **Dense priorities from 1**; lower runs first. Order by dependency, then by risk.
875
+ 5. **Greenfield: STORY-1 scaffolds.** Project skeleton + runnable test suite + a criterion
876
+ that the verify command (`verify.command` in `.yoke/config.yaml`) exits 0. Every later
877
+ story stands on a green pipeline.
878
+
879
+ ## Format (`.yoke/prd.yaml`)
880
+
881
+ ```yaml
882
+ - id: STORY-1
883
+ title: scaffold a TypeScript CLI with vitest
884
+ priority: 1
885
+ acceptance:
886
+ - "npm test exits 0 with at least one passing test"
887
+ - "verify.command is set in .yoke/config.yaml"
888
+ passes: false
889
+ - id: STORY-2
890
+ title: add the sum command
891
+ priority: 2
892
+ acceptance:
893
+ - "cli sum 1 2 prints 3"
894
+ - "non-numeric input exits 1 with an error message"
895
+ passes: false
896
+ ```
897
+
898
+ `passes` is owned by the loop — always start `false`. Validate with `yoke prd check`.
899
+ ```
900
+
901
+ - [ ] **Step 4: Register in `canon/manifest.yaml`** — append under the existing skills list:
902
+
903
+ ```yaml
904
+ # zero-to-100 bootstrap (kind: methodology)
905
+ - { id: authoring-prd, path: skills/authoring-prd, kind: methodology }
906
+ ```
907
+
908
+ - [ ] **Step 5: Run** — `npx vitest run tests/canon` → PASS (validateCanon must report zero errors — the frontmatter above matches the canon rules: `name` + one-line `description` starting with "Use when").
909
+
910
+ - [ ] **Step 6: Commit** — `feat(canon): authoring-prd skill (idea → loop-ready stories)`
911
+
912
+ ---
913
+
914
+ ### Task 9: CLI wiring + usage + README
915
+
916
+ **Files:**
917
+ - Modify: `src/cli.ts`
918
+ - Modify: `README.md`
919
+ - Test: existing suites (CLI dispatch is thin; behavior is covered by the module tests)
920
+
921
+ - [ ] **Step 1: Wire the commands in `src/cli.ts`.** Imports:
922
+
923
+ ```ts
924
+ import { runNew } from './new/command.js'
925
+ import { runPrdDraft, runPrdCheck } from './prd/command.js'
926
+ import { runLoopCleanup } from './loop/cleanup.js'
927
+ ```
928
+
929
+ New cases in `main()` (before `default`), plus one line in the existing `loop` case:
930
+
931
+ ```ts
932
+ case 'new': {
933
+ const dir = rest.find(a => !a.startsWith('-'))
934
+ if (!dir) {
935
+ console.error('usage: yoke new <dir> [--idea="..."] [--agent=claude,codex,gemini|all] [--runner=<claude|codex|gemini>] [--loop]')
936
+ return 1
937
+ }
938
+ const idea = rest.find(a => a.startsWith('--idea='))?.slice('--idea='.length)
939
+ const loop = rest.includes('--loop')
940
+ const agentArg = rest.find(a => a.startsWith('--agent='))?.slice('--agent='.length)
941
+ const all: Agent[] = ['claude', 'codex', 'gemini']
942
+ const agents = !agentArg || agentArg === 'all'
943
+ ? (agentArg === 'all' ? all : undefined)
944
+ : agentArg.split(',').filter((a): a is Agent => (all as string[]).includes(a))
945
+ const runnerArg = rest.find(a => a.startsWith('--runner='))?.slice('--runner='.length)
946
+ if (runnerArg && !(all as string[]).includes(runnerArg)) {
947
+ console.error(`Invalid --runner value: ${runnerArg} (expected claude|codex|gemini)`)
948
+ return 1
949
+ }
950
+ return runNew(dir, { idea, agents, runner: runnerArg as Agent | undefined, loop })
951
+ }
952
+ case 'prd': {
953
+ const sub = rest[0]
954
+ const targetDir = rest.slice(1).find(a => !a.startsWith('-')) ?? '.'
955
+ if (sub === 'draft') {
956
+ const idea = rest.find(a => a.startsWith('--idea='))?.slice('--idea='.length)
957
+ if (!idea) {
958
+ console.error('usage: yoke prd draft [dir] --idea="..." [--runner=<claude|codex|gemini>] [--force] [--timeout=<minutes>]')
959
+ return 1
960
+ }
961
+ const valid = ['claude', 'codex', 'gemini']
962
+ const runnerArg = rest.find(a => a.startsWith('--runner='))?.slice('--runner='.length)
963
+ if (runnerArg && !valid.includes(runnerArg)) {
964
+ console.error(`Invalid --runner value: ${runnerArg} (expected claude|codex|gemini)`)
965
+ return 1
966
+ }
967
+ const force = rest.includes('--force')
968
+ const toArg = rest.find(a => a.startsWith('--timeout='))
969
+ let timeoutMinutes: number | undefined
970
+ if (toArg) {
971
+ const v = Number(toArg.slice('--timeout='.length))
972
+ if (!Number.isFinite(v) || v < 0) { console.error(`Invalid --timeout value: ${toArg}`); return 1 }
973
+ timeoutMinutes = v
974
+ }
975
+ return runPrdDraft(targetDir, { idea, runner: runnerArg as Agent | undefined, force, timeoutMinutes })
976
+ }
977
+ if (sub === 'check') return runPrdCheck(targetDir)
978
+ console.log('usage: yoke prd <draft|check> [dir] [--idea="..."] [--runner=<claude|codex|gemini>] [--force] [--timeout=<minutes>]')
979
+ return 1
980
+ }
981
+ ```
982
+
983
+ In the `loop` case, next to the `on|off|status` one-liners:
984
+
985
+ ```ts
986
+ if (sub === 'cleanup') return runLoopCleanup(targetDir)
987
+ ```
988
+
989
+ …and add `cleanup` to the loop usage string. Update the `default` usage line to include
990
+ `new <dir> [--idea="..."]` and `prd <draft|check>`.
991
+
992
+ - [ ] **Step 2: README** (user rule: README moves with every change). Update:
993
+ - Quickstart gains the greenfield path as the FIRST entry:
994
+
995
+ ```
996
+ # Greenfield: idea → loop-ready project in one command
997
+ yoke new my-app --idea="a CLI that tracks reading lists"
998
+ yoke loop on my-app && yoke loop run my-app --isolate
999
+ ```
1000
+ - New section **`yoke new`** (scaffold, retrofit, context, PRD template/draft, two commits, exit codes) and **`yoke prd draft|check`** (overwrite guard, lint rules) — mirror the style of the existing `yoke review` section.
1001
+ - Loop section: document `yoke loop cleanup` and the `loop.lock` single-flight guard (`exit 2` + hint when another loop runs).
1002
+ - Skills table/count: add `authoring-prd` (26 → 27 skills wherever the count appears).
1003
+ - Update the test count badge/mentions to the real number after the full run (Step 3).
1004
+
1005
+ - [ ] **Step 3: Full verification** —
1006
+
1007
+ ```
1008
+ npx tsc --noEmit
1009
+ npx vitest run --reporter=json --outputFile=.yoke-test.json
1010
+ node -e "const r=require('./.yoke-test.json'); console.log('success', r.success, 'total', r.numTotalTests, 'failed', r.numFailedTests)"
1011
+ node dist/cli.js validate canon # after npm run build — zero errors
1012
+ ```
1013
+
1014
+ Delete `.yoke-test.json` afterwards. Put the real total into the README test count.
1015
+
1016
+ - [ ] **Step 4: Commit** — `feat(cli): wire new/prd/loop-cleanup; docs(readme): zero-to-100 quickstart`
1017
+
1018
+ ---
1019
+
1020
+ ## Self-review (done at plan time)
1021
+
1022
+ - Spec coverage: Part 1→Task 7, Part 2/2a→Task 4, Part 3→Task 5, Part 4→Task 6, Part 5→Tasks 2-3, Part 6→Task 8, CLI/README→Task 9. Cycle-breaker (Task 1) is plan-level necessity discovered against the spec.
1023
+ - Deviation noted: real-canon has no count assertion; Task 8 adds a registration assertion (spec §Testing amended by this plan).
1024
+ - Type consistency: `LockResult {acquired, holderPid?, stalePid?}` used identically in Tasks 2/3/6; `PrdDraftOptions` seams reused by `RunNewOptions` in Task 7; `prdFile` only used within `src/prd/command.ts`.