@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,981 @@
1
+ # Baustein E — Context Layer 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:** Give every Yoke agent (loop + interactive) a durable `.yoke/context/` it reads before implementing and auto-logs decisions back to, closing the spec-drift hole.
6
+
7
+ **Architecture:** A new pure `src/context/` module (load/format/append). The loop runner injects bounded context into the agent + reviewer prompts (read path); `loop.ts` appends a `DECISIONS.md` entry that rides the story's atomic commit (write path). Retrofit scaffolds the three files non-destructively via a new `ifAbsent` action; a `maintaining-context` skill propagates the convention to all three agents.
8
+
9
+ **Tech Stack:** Node.js + TypeScript, vitest, zod (existing), `yaml` (existing). ESM with `.js` import specifiers.
10
+
11
+ ---
12
+
13
+ ## File Structure
14
+
15
+ | File | Responsibility |
16
+ |------|----------------|
17
+ | `src/context/context.ts` (create) | Pure: `ProjectContext`, `loadContext`, `formatForPrompt`, `appendDecision`, `contextDir` |
18
+ | `tests/context/context.test.ts` (create) | Unit tests for the above |
19
+ | `canon/context/{PROJECT,DECISIONS,KNOWLEDGE}.md` (create) | Scaffold templates |
20
+ | `canon/skills/maintaining-context/SKILL.md` (create) | Agent-facing convention |
21
+ | `canon/manifest.yaml` (modify) | Register the new skill |
22
+ | `src/retrofit/plan.ts` (modify) | `Action.ifAbsent`; prepend base context actions |
23
+ | `src/retrofit/apply.ts` (modify) | Honour `ifAbsent` (skip existing, no backup) |
24
+ | `src/retrofit/context-actions.ts` (create) | `baseContextActions(canonDir)` shared by retrofit + CLI |
25
+ | `src/loop/runner.ts` (modify) | `buildClaudePrompt`/`buildReviewPrompt` context param; `contextBlockFor`; wire into `makeRunner`/`makeReviewRunner` |
26
+ | `src/loop/loop.ts` (modify) | Append decision before the atomic commit; rollback on failure; isolate path |
27
+ | `src/cli.ts` (modify) | `yoke context init|status` |
28
+ | `tests/...` (create/modify) | Coverage per task |
29
+ | `README.md` (modify) | Document the Context layer |
30
+
31
+ **Constants:** `MAX_CONTEXT_CHARS = 2000` (per file, in `context.ts`).
32
+
33
+ **Bounding rule:** `PROJECT.md` and `KNOWLEDGE.md` are curated (important content at the top) → **head**-bound. `DECISIONS.md` is an append-only log (recent at the bottom) → **tail**-bound. Truncation inserts a `… (truncated)` marker.
34
+
35
+ ---
36
+
37
+ ### Task 1: context module — types, loadContext, formatForPrompt
38
+
39
+ **Files:**
40
+ - Create: `src/context/context.ts`
41
+ - Test: `tests/context/context.test.ts`
42
+
43
+ - [ ] **Step 1: Write the failing test**
44
+
45
+ ```typescript
46
+ // tests/context/context.test.ts
47
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
48
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
49
+ import { join } from 'node:path'
50
+ import { tmpdir } from 'node:os'
51
+ import { loadContext, formatForPrompt, contextDir } from '../../src/context/context.js'
52
+
53
+ let dir: string
54
+ beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'yoke-ctx-')) })
55
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
56
+
57
+ describe('contextDir', () => {
58
+ it('resolves under .yoke/context', () => {
59
+ expect(contextDir('/proj')).toBe(join('/proj', '.yoke', 'context'))
60
+ })
61
+ })
62
+
63
+ describe('loadContext', () => {
64
+ it('returns empty strings when no files exist', () => {
65
+ expect(loadContext(dir)).toEqual({ project: '', decisions: '', knowledge: '' })
66
+ })
67
+
68
+ it('reads each file that is present', () => {
69
+ writeFileSync(join(dir, 'PROJECT.md'), 'goal')
70
+ writeFileSync(join(dir, 'KNOWLEDGE.md'), 'gotcha')
71
+ const ctx = loadContext(dir)
72
+ expect(ctx.project).toBe('goal')
73
+ expect(ctx.knowledge).toBe('gotcha')
74
+ expect(ctx.decisions).toBe('')
75
+ })
76
+ })
77
+
78
+ describe('formatForPrompt', () => {
79
+ it('returns empty string when all files are empty', () => {
80
+ expect(formatForPrompt({ project: '', decisions: '', knowledge: '' })).toBe('')
81
+ })
82
+
83
+ it('includes a header and only the non-empty sections', () => {
84
+ const out = formatForPrompt({ project: 'GOAL', decisions: '', knowledge: 'GOTCHA' })
85
+ expect(out).toContain('Project context')
86
+ expect(out).toContain('GOAL')
87
+ expect(out).toContain('GOTCHA')
88
+ expect(out).not.toContain('Recent decisions')
89
+ })
90
+
91
+ it('head-bounds PROJECT and tail-bounds DECISIONS', () => {
92
+ const project = 'P'.repeat(50) + 'TAILP'
93
+ const decisions = 'HEADD' + 'D'.repeat(50)
94
+ const out = formatForPrompt({ project, decisions, knowledge: '' }, 10)
95
+ expect(out).toContain('PPPPPPPPPP') // head of PROJECT kept
96
+ expect(out).not.toContain('TAILP') // tail of PROJECT dropped
97
+ expect(out).toContain('DDDDDDDDDD') // tail of DECISIONS kept
98
+ expect(out).not.toContain('HEADD') // head of DECISIONS dropped
99
+ expect(out).toContain('truncated')
100
+ })
101
+ })
102
+ ```
103
+
104
+ - [ ] **Step 2: Run test to verify it fails**
105
+
106
+ Run: `npx vitest run tests/context/context.test.ts`
107
+ Expected: FAIL — `Cannot find module '../../src/context/context.js'`
108
+
109
+ - [ ] **Step 3: Write minimal implementation**
110
+
111
+ ```typescript
112
+ // src/context/context.ts
113
+ import { existsSync, readFileSync } from 'node:fs'
114
+ import { join } from 'node:path'
115
+
116
+ export const MAX_CONTEXT_CHARS = 2000
117
+
118
+ export interface ProjectContext {
119
+ project: string
120
+ decisions: string
121
+ knowledge: string
122
+ }
123
+
124
+ export function contextDir(targetDir: string): string {
125
+ return join(targetDir, '.yoke', 'context')
126
+ }
127
+
128
+ function readIf(file: string): string {
129
+ return existsSync(file) ? readFileSync(file, 'utf8') : ''
130
+ }
131
+
132
+ export function loadContext(dir: string): ProjectContext {
133
+ return {
134
+ project: readIf(join(dir, 'PROJECT.md')),
135
+ decisions: readIf(join(dir, 'DECISIONS.md')),
136
+ knowledge: readIf(join(dir, 'KNOWLEDGE.md')),
137
+ }
138
+ }
139
+
140
+ function boundHead(s: string, max: number): string {
141
+ return s.length <= max ? s : s.slice(0, max) + '\n… (truncated)'
142
+ }
143
+
144
+ function boundTail(s: string, max: number): string {
145
+ return s.length <= max ? s : '… (truncated)\n' + s.slice(s.length - max)
146
+ }
147
+
148
+ export function formatForPrompt(ctx: ProjectContext, max: number = MAX_CONTEXT_CHARS): string {
149
+ const parts: string[] = []
150
+ if (ctx.project.trim()) parts.push(`### North star (PROJECT.md)\n${boundHead(ctx.project.trim(), max)}`)
151
+ if (ctx.knowledge.trim()) parts.push(`### Known gotchas (KNOWLEDGE.md)\n${boundHead(ctx.knowledge.trim(), max)}`)
152
+ if (ctx.decisions.trim()) parts.push(`### Recent decisions (DECISIONS.md)\n${boundTail(ctx.decisions.trim(), max)}`)
153
+ if (parts.length === 0) return ''
154
+ return ['## Project context (from .yoke/context — read before implementing)', ...parts].join('\n\n')
155
+ }
156
+ ```
157
+
158
+ - [ ] **Step 4: Run test to verify it passes**
159
+
160
+ Run: `npx vitest run tests/context/context.test.ts`
161
+ Expected: PASS
162
+
163
+ - [ ] **Step 5: Commit**
164
+
165
+ ```bash
166
+ git add src/context/context.ts tests/context/context.test.ts
167
+ git commit -m "feat(context): loadContext + formatForPrompt with head/tail bounding"
168
+ ```
169
+
170
+ ---
171
+
172
+ ### Task 2: context module — appendDecision + rollback
173
+
174
+ **Files:**
175
+ - Modify: `src/context/context.ts`
176
+ - Test: `tests/context/context.test.ts` (add a describe block)
177
+
178
+ - [ ] **Step 1: Write the failing test**
179
+
180
+ ```typescript
181
+ // add to tests/context/context.test.ts
182
+ import { readFileSync, existsSync } from 'node:fs'
183
+ import { appendDecision } from '../../src/context/context.js'
184
+
185
+ describe('appendDecision', () => {
186
+ const entry = { storyId: 'S1', title: 'First story', summary: 'did the thing' }
187
+ const fixedDate = new Date('2026-06-28T12:00:00Z')
188
+
189
+ it('creates DECISIONS.md when absent and writes a dated heading', () => {
190
+ appendDecision(dir, entry, fixedDate)
191
+ const text = readFileSync(join(dir, 'DECISIONS.md'), 'utf8')
192
+ expect(text).toContain('## 2026-06-28 — S1: First story')
193
+ expect(text).toContain('did the thing')
194
+ })
195
+
196
+ it('appends to existing content without clobbering it', () => {
197
+ writeFileSync(join(dir, 'DECISIONS.md'), '# Decisions\n\nold entry\n')
198
+ appendDecision(dir, entry, fixedDate)
199
+ const text = readFileSync(join(dir, 'DECISIONS.md'), 'utf8')
200
+ expect(text).toContain('old entry')
201
+ expect(text.indexOf('old entry')).toBeLessThan(text.indexOf('S1'))
202
+ })
203
+
204
+ it('rollback restores prior content', () => {
205
+ writeFileSync(join(dir, 'DECISIONS.md'), 'PRIOR\n')
206
+ const { rollback } = appendDecision(dir, entry, fixedDate)
207
+ rollback()
208
+ expect(readFileSync(join(dir, 'DECISIONS.md'), 'utf8')).toBe('PRIOR\n')
209
+ })
210
+
211
+ it('rollback removes the file when it did not exist before', () => {
212
+ const { rollback } = appendDecision(dir, entry, fixedDate)
213
+ rollback()
214
+ expect(existsSync(join(dir, 'DECISIONS.md'))).toBe(false)
215
+ })
216
+ })
217
+ ```
218
+
219
+ - [ ] **Step 2: Run test to verify it fails**
220
+
221
+ Run: `npx vitest run tests/context/context.test.ts`
222
+ Expected: FAIL — `appendDecision is not exported`
223
+
224
+ - [ ] **Step 3: Write minimal implementation**
225
+
226
+ Add to `src/context/context.ts` (update the `node:fs` import to include `mkdirSync`, `writeFileSync`, `rmSync`, and `node:path` to include `dirname`):
227
+
228
+ ```typescript
229
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
230
+ import { join, dirname } from 'node:path'
231
+
232
+ export interface DecisionEntry {
233
+ storyId: string
234
+ title: string
235
+ summary: string
236
+ }
237
+
238
+ export function appendDecision(
239
+ dir: string,
240
+ entry: DecisionEntry,
241
+ now: Date = new Date(),
242
+ ): { rollback: () => void } {
243
+ const file = join(dir, 'DECISIONS.md')
244
+ const existed = existsSync(file)
245
+ const prior = existed ? readFileSync(file, 'utf8') : ''
246
+ const date = now.toISOString().slice(0, 10)
247
+ const block = `\n## ${date} — ${entry.storyId}: ${entry.title}\n${entry.summary}\n`
248
+ mkdirSync(dirname(file), { recursive: true })
249
+ writeFileSync(file, prior + block)
250
+ return {
251
+ rollback: () => {
252
+ if (existed) writeFileSync(file, prior)
253
+ else { try { rmSync(file) } catch { /* best-effort cleanup */ } }
254
+ },
255
+ }
256
+ }
257
+ ```
258
+
259
+ - [ ] **Step 4: Run test to verify it passes**
260
+
261
+ Run: `npx vitest run tests/context/context.test.ts`
262
+ Expected: PASS
263
+
264
+ - [ ] **Step 5: Commit**
265
+
266
+ ```bash
267
+ git add src/context/context.ts tests/context/context.test.ts
268
+ git commit -m "feat(context): appendDecision with atomic rollback"
269
+ ```
270
+
271
+ ---
272
+
273
+ ### Task 3: canon templates + maintaining-context skill + manifest
274
+
275
+ **Files:**
276
+ - Create: `canon/context/PROJECT.md`, `canon/context/DECISIONS.md`, `canon/context/KNOWLEDGE.md`
277
+ - Create: `canon/skills/maintaining-context/SKILL.md`
278
+ - Modify: `canon/manifest.yaml`
279
+ - Test: `tests/canon/real-canon.test.ts` (add an assertion) — verify the skill is registered and canon validates
280
+
281
+ - [ ] **Step 1: Write the failing test**
282
+
283
+ ```typescript
284
+ // add to tests/canon/real-canon.test.ts (it already imports loadManifest + validateCanon against the real canon dir)
285
+ import { join } from 'node:path'
286
+ import { loadManifest } from '../../src/canon/manifest.js'
287
+
288
+ it('registers the maintaining-context skill', () => {
289
+ const manifest = loadManifest(join('canon', 'manifest.yaml'))
290
+ expect(manifest.skills.some(s => s.id === 'maintaining-context')).toBe(true)
291
+ })
292
+ ```
293
+
294
+ > NOTE: `tests/canon/real-canon.test.ts` already runs `validateCanon('canon')` and asserts zero errors. Adding the skill must keep that green — that is the second half of this task's coverage.
295
+
296
+ - [ ] **Step 2: Run test to verify it fails**
297
+
298
+ Run: `npx vitest run tests/canon/real-canon.test.ts`
299
+ Expected: FAIL — `maintaining-context` not found in manifest.
300
+
301
+ - [ ] **Step 3: Create the templates and skill, then register it**
302
+
303
+ `canon/context/PROJECT.md`:
304
+ ```markdown
305
+ # Project — North Star
306
+
307
+ > Edit this file. It is the durable goal every Yoke agent reads before implementing.
308
+
309
+ ## Goal
310
+ <!-- One paragraph: what this project is and the outcome it must produce. -->
311
+
312
+ ## Constraints
313
+ <!-- Hard limits: stack, platforms, performance, compliance. -->
314
+
315
+ ## Non-goals
316
+ <!-- Explicitly out of scope. The most valuable section for preventing drift. -->
317
+
318
+ ## Success criteria
319
+ <!-- How we know it works. -->
320
+ ```
321
+
322
+ `canon/context/DECISIONS.md`:
323
+ ```markdown
324
+ # Decisions
325
+
326
+ > Append-only ledger. The Yoke loop adds an entry per completed story; agents add
327
+ > entries for non-obvious calls made during interactive work. Newest at the bottom.
328
+ ```
329
+
330
+ `canon/context/KNOWLEDGE.md`:
331
+ ```markdown
332
+ # Knowledge
333
+
334
+ > Reusable gotchas, conventions, and learnings. Add a bullet whenever you discover
335
+ > something a future agent would waste time rediscovering.
336
+ ```
337
+
338
+ `canon/skills/maintaining-context/SKILL.md`:
339
+ ```markdown
340
+ ---
341
+ name: maintaining-context
342
+ description: Use at the start of any substantial task and whenever you make a non-obvious decision or learn a reusable fact — keeps .yoke/context/ (PROJECT, DECISIONS, KNOWLEDGE) the durable source of truth so fresh-context work never drifts.
343
+ ---
344
+
345
+ # Maintaining Project Context
346
+
347
+ Yoke keeps durable, cross-session context in `.yoke/context/`. These files are the
348
+ project's memory — they survive between sessions and loop iterations, so an agent
349
+ starting with a fresh context window is never blind.
350
+
351
+ ## Before substantial work
352
+
353
+ 1. Read `.yoke/context/PROJECT.md` — the north star (goal, constraints, **non-goals**, success criteria). Align your work to it. If the task contradicts a non-goal, stop and flag it.
354
+ 2. Skim `.yoke/context/KNOWLEDGE.md` for gotchas that affect what you are about to do.
355
+
356
+ ## While working
357
+
358
+ - When you make a **non-obvious decision** (a trade-off, an architectural choice, a rejected alternative), append it to `.yoke/context/DECISIONS.md`:
359
+
360
+ ```
361
+ ## <YYYY-MM-DD> — <short title>
362
+ What you chose and why, in one or two lines.
363
+ ```
364
+
365
+ - When you learn a **reusable fact or gotcha** (a non-obvious build step, an API quirk, a convention), append a bullet to `.yoke/context/KNOWLEDGE.md`.
366
+
367
+ ## Rules
368
+
369
+ - Never rewrite history in `DECISIONS.md` — only append.
370
+ - Keep `PROJECT.md` curated and short; it is read into every loop prompt.
371
+ - Inside the autonomous loop, the loop appends a decision per completed story for you — you still add the *why* and any learnings.
372
+ ```
373
+
374
+ Then add to `canon/manifest.yaml`, in the skills list (after the `minimal-code` entry):
375
+ ```yaml
376
+ - { id: maintaining-context, path: skills/maintaining-context, kind: methodology }
377
+ ```
378
+
379
+ - [ ] **Step 4: Run tests to verify they pass**
380
+
381
+ Run: `npx vitest run tests/canon/`
382
+ Expected: PASS — skill registered AND `validateCanon('canon')` still reports zero errors.
383
+
384
+ - [ ] **Step 5: Commit**
385
+
386
+ ```bash
387
+ git add canon/context canon/skills/maintaining-context canon/manifest.yaml tests/canon/real-canon.test.ts
388
+ git commit -m "feat(canon): context templates + maintaining-context skill"
389
+ ```
390
+
391
+ ---
392
+
393
+ ### Task 4: retrofit scaffolding — ifAbsent action + base context actions
394
+
395
+ **Files:**
396
+ - Modify: `src/retrofit/plan.ts` (add `ifAbsent?` to `Action`)
397
+ - Modify: `src/retrofit/apply.ts` (honour `ifAbsent`)
398
+ - Create: `src/retrofit/context-actions.ts`
399
+ - Modify: `src/retrofit/plan.ts` (`planRetrofit` prepends base context actions)
400
+ - Test: `tests/retrofit/apply.test.ts`, `tests/retrofit/plan-dispatch.test.ts`
401
+
402
+ - [ ] **Step 1: Write the failing test (apply honours ifAbsent)**
403
+
404
+ ```typescript
405
+ // add to tests/retrofit/apply.test.ts (mirror its existing temp-dir + applyActions setup)
406
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
407
+ import { join } from 'node:path'
408
+ import { tmpdir } from 'node:os'
409
+ import { applyActions } from '../../src/retrofit/apply.js'
410
+
411
+ it('ifAbsent action creates the file when missing', () => {
412
+ const d = mkdtempSync(join(tmpdir(), 'yoke-apply-'))
413
+ const res = applyActions(
414
+ [{ kind: 'write', target: '.yoke/context/PROJECT.md', content: 'TEMPLATE', reason: 'scaffold', ifAbsent: true }],
415
+ d, { backupDir: join(d, '.yoke', 'backup', 'x') },
416
+ )
417
+ expect(res[0].status).toBe('created')
418
+ expect(readFileSync(join(d, '.yoke/context/PROJECT.md'), 'utf8')).toBe('TEMPLATE')
419
+ rmSync(d, { recursive: true, force: true })
420
+ })
421
+
422
+ it('ifAbsent action leaves an existing file untouched and takes no backup', () => {
423
+ const d = mkdtempSync(join(tmpdir(), 'yoke-apply-'))
424
+ const dest = join(d, '.yoke/context/PROJECT.md')
425
+ mkdtempSync // ensure import used
426
+ writeFileSync(join(d, 'placeholder'), '') // keep tmp non-empty
427
+ const { mkdirSync } = require('node:fs')
428
+ mkdirSync(join(d, '.yoke/context'), { recursive: true })
429
+ writeFileSync(dest, 'USER CONTENT')
430
+ const res = applyActions(
431
+ [{ kind: 'write', target: '.yoke/context/PROJECT.md', content: 'TEMPLATE', reason: 'scaffold', ifAbsent: true }],
432
+ d, { backupDir: join(d, '.yoke', 'backup', 'x') },
433
+ )
434
+ expect(res[0].status).toBe('unchanged')
435
+ expect(res[0].backedUp).toBeUndefined()
436
+ expect(readFileSync(dest, 'utf8')).toBe('USER CONTENT')
437
+ rmSync(d, { recursive: true, force: true })
438
+ })
439
+ ```
440
+
441
+ > NOTE: prefer ESM `import { mkdirSync } from 'node:fs'` at the top of the test file rather than `require`; the inline `require` above is only to keep the snippet self-contained. Use the file's existing import style.
442
+
443
+ - [ ] **Step 2: Run test to verify it fails**
444
+
445
+ Run: `npx vitest run tests/retrofit/apply.test.ts`
446
+ Expected: FAIL — `ifAbsent` not in `Action` type / not honoured (existing file gets overwritten).
447
+
448
+ - [ ] **Step 3: Implement ifAbsent**
449
+
450
+ In `src/retrofit/plan.ts`, extend the `Action` interface:
451
+ ```typescript
452
+ export interface Action {
453
+ kind: 'write'
454
+ target: string
455
+ content: string
456
+ reason: string
457
+ merge?: boolean
458
+ ifAbsent?: boolean
459
+ }
460
+ ```
461
+
462
+ In `src/retrofit/apply.ts`, at the **top** of the `if (existsSync(dest))` branch (before the `action.merge` check), add:
463
+ ```typescript
464
+ if (existsSync(dest)) {
465
+ if (action.ifAbsent) {
466
+ results.push({ target: action.target, status: 'unchanged', reason: `${action.reason} (exists, left untouched)` })
467
+ continue
468
+ }
469
+ const current = readFileSync(dest, 'utf8')
470
+ // ... existing merge / overwrite logic unchanged ...
471
+ ```
472
+
473
+ - [ ] **Step 4: Run test to verify it passes**
474
+
475
+ Run: `npx vitest run tests/retrofit/apply.test.ts`
476
+ Expected: PASS
477
+
478
+ - [ ] **Step 5: Write the failing test (base context actions are planned once)**
479
+
480
+ ```typescript
481
+ // add to tests/retrofit/plan-dispatch.test.ts (it already calls planRetrofit against the real canon dir)
482
+ import { baseContextActions } from '../../src/retrofit/context-actions.js'
483
+
484
+ it('scaffolds the three context files exactly once for --agent=all', () => {
485
+ const actions = planRetrofit('canon', '.', ['claude', 'codex', 'gemini'])
486
+ const ctxTargets = actions.filter(a => a.target.startsWith('.yoke/context/')).map(a => a.target)
487
+ expect(ctxTargets.sort()).toEqual([
488
+ '.yoke/context/DECISIONS.md',
489
+ '.yoke/context/KNOWLEDGE.md',
490
+ '.yoke/context/PROJECT.md',
491
+ ])
492
+ expect(actions.filter(a => a.target === '.yoke/context/PROJECT.md')).toHaveLength(1)
493
+ })
494
+
495
+ it('baseContextActions are all ifAbsent', () => {
496
+ expect(baseContextActions('canon').every(a => a.ifAbsent)).toBe(true)
497
+ })
498
+ ```
499
+
500
+ - [ ] **Step 6: Run test to verify it fails**
501
+
502
+ Run: `npx vitest run tests/retrofit/plan-dispatch.test.ts`
503
+ Expected: FAIL — `baseContextActions` not exported; context targets absent from plan.
504
+
505
+ - [ ] **Step 7: Implement baseContextActions and wire into planRetrofit**
506
+
507
+ Create `src/retrofit/context-actions.ts`:
508
+ ```typescript
509
+ import { readFileSync } from 'node:fs'
510
+ import { join } from 'node:path'
511
+ import type { Action } from './plan.js'
512
+
513
+ const CONTEXT_FILES = ['PROJECT.md', 'DECISIONS.md', 'KNOWLEDGE.md'] as const
514
+
515
+ export function baseContextActions(canonDir: string): Action[] {
516
+ return CONTEXT_FILES.map(name => ({
517
+ kind: 'write',
518
+ target: `.yoke/context/${name}`,
519
+ content: readFileSync(join(canonDir, 'context', name), 'utf8'),
520
+ reason: `context scaffold: ${name}`,
521
+ ifAbsent: true,
522
+ }))
523
+ }
524
+ ```
525
+
526
+ In `src/retrofit/plan.ts`, import it and seed `planRetrofit` with the base actions before the agent loop:
527
+ ```typescript
528
+ import { baseContextActions } from './context-actions.js'
529
+
530
+ export function planRetrofit(canonDir: string, targetDir: string, agents: Agent[], codeGraph: CodeGraph = 'graphify'): Action[] {
531
+ const seen = new Set<string>()
532
+ const merged: Action[] = []
533
+ for (const action of baseContextActions(canonDir)) {
534
+ seen.add(action.target)
535
+ merged.push(action)
536
+ }
537
+ for (const agent of agents) {
538
+ for (const action of PLANNERS[agent](canonDir, targetDir, codeGraph)) {
539
+ if (seen.has(action.target)) continue
540
+ seen.add(action.target)
541
+ merged.push(action)
542
+ }
543
+ }
544
+ return merged
545
+ }
546
+ ```
547
+
548
+ - [ ] **Step 8: Run tests to verify they pass**
549
+
550
+ Run: `npx vitest run tests/retrofit/`
551
+ Expected: PASS (plan-dispatch + apply + existing retrofit tests all green).
552
+
553
+ - [ ] **Step 9: Commit**
554
+
555
+ ```bash
556
+ git add src/retrofit/plan.ts src/retrofit/apply.ts src/retrofit/context-actions.ts tests/retrofit/apply.test.ts tests/retrofit/plan-dispatch.test.ts
557
+ git commit -m "feat(retrofit): scaffold .yoke/context via non-destructive ifAbsent actions"
558
+ ```
559
+
560
+ ---
561
+
562
+ ### Task 5: loop read path — inject context into agent + reviewer prompts
563
+
564
+ **Files:**
565
+ - Modify: `src/loop/runner.ts`
566
+ - Test: `tests/loop/runner.test.ts`
567
+
568
+ - [ ] **Step 1: Write the failing test**
569
+
570
+ ```typescript
571
+ // add to tests/loop/runner.test.ts
572
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
573
+ import { join } from 'node:path'
574
+ import { tmpdir } from 'node:os'
575
+ import { buildClaudePrompt, buildReviewPrompt, contextBlockFor } from '../../src/loop/runner.js'
576
+
577
+ const story = { id: 'S1', title: 'First', priority: 1, acceptance: ['x'], passes: false }
578
+
579
+ describe('prompt context injection', () => {
580
+ it('buildClaudePrompt omits the context section when no context is given', () => {
581
+ const p = buildClaudePrompt(story)
582
+ expect(p).not.toContain('Project context')
583
+ expect(p).toContain('Story S1: First')
584
+ })
585
+
586
+ it('buildClaudePrompt includes the context block when provided', () => {
587
+ const p = buildClaudePrompt(story, '## Project context\nGOAL')
588
+ expect(p).toContain('## Project context')
589
+ expect(p).toContain('GOAL')
590
+ expect(p.indexOf('GOAL')).toBeLessThan(p.indexOf('Story S1'))
591
+ })
592
+
593
+ it('buildReviewPrompt includes the context block when provided', () => {
594
+ expect(buildReviewPrompt(story, '## Project context\nGOAL')).toContain('GOAL')
595
+ })
596
+
597
+ it('contextBlockFor reads .yoke/context under the target dir', () => {
598
+ const d = mkdtempSync(join(tmpdir(), 'yoke-cbf-'))
599
+ mkdirSync(join(d, '.yoke', 'context'), { recursive: true })
600
+ writeFileSync(join(d, '.yoke', 'context', 'PROJECT.md'), 'NORTHSTAR')
601
+ expect(contextBlockFor(d)).toContain('NORTHSTAR')
602
+ expect(contextBlockFor(mkdtempSync(join(tmpdir(), 'yoke-empty-')))).toBe('')
603
+ rmSync(d, { recursive: true, force: true })
604
+ })
605
+ })
606
+ ```
607
+
608
+ - [ ] **Step 2: Run test to verify it fails**
609
+
610
+ Run: `npx vitest run tests/loop/runner.test.ts`
611
+ Expected: FAIL — `contextBlockFor` not exported; `buildClaudePrompt` takes no second arg.
612
+
613
+ - [ ] **Step 3: Implement context-aware prompts + wiring**
614
+
615
+ In `src/loop/runner.ts`, add the import and helper near the top:
616
+ ```typescript
617
+ import { loadContext, formatForPrompt, contextDir } from '../context/context.js'
618
+
619
+ export function contextBlockFor(targetDir: string): string {
620
+ return formatForPrompt(loadContext(contextDir(targetDir)))
621
+ }
622
+ ```
623
+
624
+ Replace `buildClaudePrompt` and `buildReviewPrompt` with context-aware versions:
625
+ ```typescript
626
+ export function buildClaudePrompt(story: Story, context = ''): string {
627
+ const criteria = story.acceptance.map(a => `- ${a}`).join('\n')
628
+ const lines = [
629
+ 'You are an autonomous coding agent running inside the Yoke loop.',
630
+ 'Implement ONLY this story and nothing else. Follow test-driven development.',
631
+ ]
632
+ if (context) lines.push('', context)
633
+ lines.push(
634
+ '',
635
+ `Story ${story.id}: ${story.title}`,
636
+ 'Acceptance criteria (Definition of Done):',
637
+ criteria,
638
+ '',
639
+ "When done, ensure the project's full test suite passes.",
640
+ 'Do NOT commit — the loop commits on your behalf after verifying.',
641
+ )
642
+ return lines.join('\n')
643
+ }
644
+
645
+ export function buildReviewPrompt(story: Story, context = ''): string {
646
+ const criteria = story.acceptance.map(a => `- ${a}`).join('\n')
647
+ const lines = [
648
+ 'You are an independent reviewer inside the Yoke loop. You did NOT implement this change.',
649
+ 'Review the current uncommitted working-tree changes against the story below.',
650
+ ]
651
+ if (context) lines.push('', context)
652
+ lines.push(
653
+ '',
654
+ `Story ${story.id}: ${story.title}`,
655
+ 'Acceptance criteria:',
656
+ criteria,
657
+ '',
658
+ 'Approve by exiting 0 ONLY if every acceptance criterion is met and the change is sound.',
659
+ 'If you find ANY blocking issue (an unmet criterion, a bug, a missing test), exit non-zero to reject.',
660
+ 'Do not modify files. Do not commit.',
661
+ )
662
+ return lines.join('\n')
663
+ }
664
+ ```
665
+
666
+ In `makeRunner`, pass the context block:
667
+ ```typescript
668
+ const inv = agentInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir)), ctx.targetDir)
669
+ ```
670
+
671
+ In `makeReviewRunner`, likewise:
672
+ ```typescript
673
+ const inv = agentInvocation(agent, buildReviewPrompt(ctx.story, contextBlockFor(ctx.targetDir)), ctx.targetDir)
674
+ ```
675
+
676
+ - [ ] **Step 4: Run tests to verify they pass**
677
+
678
+ Run: `npx vitest run tests/loop/runner.test.ts`
679
+ Expected: PASS
680
+
681
+ - [ ] **Step 5: Commit**
682
+
683
+ ```bash
684
+ git add src/loop/runner.ts tests/loop/runner.test.ts
685
+ git commit -m "feat(loop): inject .yoke/context into agent + reviewer prompts"
686
+ ```
687
+
688
+ ---
689
+
690
+ ### Task 6: loop write path — append decision on the atomic commit
691
+
692
+ **Files:**
693
+ - Modify: `src/loop/loop.ts`
694
+ - Test: `tests/loop/loop.test.ts`
695
+
696
+ - [ ] **Step 1: Write the failing test**
697
+
698
+ ```typescript
699
+ // add to tests/loop/loop.test.ts (reuses the existing dir / prd() / fakes)
700
+ import { readFileSync, existsSync } from 'node:fs'
701
+ import { join } from 'node:path'
702
+ import { contextDir } from '../../src/context/context.js'
703
+
704
+ const decisionsFile = () => join(contextDir(dir), 'DECISIONS.md')
705
+
706
+ it('appends a decision per completed story, in the commit', () => {
707
+ const commits: string[] = []
708
+ const git: GitOps = { isClean: () => true, commitAll: (_d, m) => commits.push(m), addWorktree: () => {}, removeWorktree: () => {}, integrate: () => {} }
709
+ runLoop({ prdPath: prd(), targetDir: dir, runner: alwaysPass, git, verify: verifyOk, maxIterations: 10 })
710
+ const text = readFileSync(decisionsFile(), 'utf8')
711
+ expect(text).toContain('S1: First')
712
+ expect(text).toContain('S2: Second')
713
+ })
714
+
715
+ it('does not append a decision when the story is blocked at verify', () => {
716
+ const verifyFail: Verifier = () => ({ passed: false, summary: 'red' })
717
+ runLoop({ prdPath: prd(), targetDir: dir, runner: alwaysPass, git: cleanGit(), verify: verifyFail, maxIterations: 10 })
718
+ expect(existsSync(decisionsFile())).toBe(false)
719
+ })
720
+
721
+ it('reverts the decision append when the commit fails', () => {
722
+ const failingGit: GitOps = { isClean: () => true, commitAll: () => { throw new Error('commit boom') }, addWorktree: () => {}, removeWorktree: () => {}, integrate: () => {} }
723
+ const res = runLoop({ prdPath: prd(), targetDir: dir, runner: alwaysPass, git: failingGit, verify: verifyOk, maxIterations: 10 })
724
+ expect(res.status).toBe('blocked')
725
+ expect(existsSync(decisionsFile())).toBe(false) // rolled back
726
+ expect(loadPrd(prd()).every(s => !s.passes)).toBe(true) // PRD reverted too
727
+ })
728
+ ```
729
+
730
+ - [ ] **Step 2: Run test to verify it fails**
731
+
732
+ Run: `npx vitest run tests/loop/loop.test.ts`
733
+ Expected: FAIL — no DECISIONS.md is written.
734
+
735
+ - [ ] **Step 3: Implement the write path**
736
+
737
+ In `src/loop/loop.ts`, add the import:
738
+ ```typescript
739
+ import { appendDecision, contextDir } from '../context/context.js'
740
+ ```
741
+
742
+ Replace the **non-isolate** commit block (the final `try { ... } catch { ... }` around `savePrd` + `commitAll`) with:
743
+ ```typescript
744
+ const dec = appendDecision(contextDir(opts.targetDir), {
745
+ storyId: story.id,
746
+ title: story.title,
747
+ summary: result.summary,
748
+ })
749
+ const updated = stories.map(s => (s.id === story.id ? { ...s, passes: true } : s))
750
+ savePrd(opts.prdPath, updated)
751
+ try {
752
+ opts.git.commitAll(opts.targetDir, `yoke: complete ${story.id} ${story.title}`)
753
+ } catch (e) {
754
+ savePrd(opts.prdPath, stories) // revert — never persist passes:true without a commit
755
+ dec.rollback() // and never leave an orphan decision
756
+ return {
757
+ status: 'blocked',
758
+ iterations,
759
+ reason: `commit failed for ${story.id}: ${(e as Error).message}`,
760
+ finalProgress: progress(stories),
761
+ }
762
+ }
763
+ ```
764
+
765
+ In the **isolate** block, append the decision in the worktree just before `savePrd(wtPrd, ...)`:
766
+ ```typescript
767
+ appendDecision(contextDir(wt), {
768
+ storyId: story.id,
769
+ title: story.title,
770
+ summary: result.summary,
771
+ })
772
+ const updated = stories.map(s => (s.id === story.id ? { ...s, passes: true } : s))
773
+ savePrd(wtPrd, updated)
774
+ opts.git.commitAll(wt, `yoke: complete ${story.id} ${story.title}`)
775
+ opts.git.integrate(opts.targetDir, wt)
776
+ ```
777
+ (No explicit rollback needed in isolate: a failed iteration's worktree is discarded by the existing `finally { removeWorktree }`, so the decision never reaches the main tree.)
778
+
779
+ - [ ] **Step 4: Run tests to verify they pass**
780
+
781
+ Run: `npx vitest run tests/loop/loop.test.ts`
782
+ Expected: PASS — including the existing "blocks and reverts the PRD when the commit fails" test (now also asserting no orphan decision).
783
+
784
+ - [ ] **Step 5: Commit**
785
+
786
+ ```bash
787
+ git add src/loop/loop.ts tests/loop/loop.test.ts
788
+ git commit -m "feat(loop): auto-log a decision per completed story on the atomic commit"
789
+ ```
790
+
791
+ ---
792
+
793
+ ### Task 7: CLI — `yoke context init|status`
794
+
795
+ **Files:**
796
+ - Modify: `src/cli.ts`
797
+ - Create: `src/context/command.ts` (testable command bodies)
798
+ - Test: `tests/context/command.test.ts`
799
+
800
+ - [ ] **Step 1: Write the failing test**
801
+
802
+ ```typescript
803
+ // tests/context/command.test.ts
804
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
805
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
806
+ import { join } from 'node:path'
807
+ import { tmpdir } from 'node:os'
808
+ import { runContextInit, runContextStatus } from '../../src/context/command.js'
809
+
810
+ let dir: string
811
+ beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'yoke-ctxcmd-')) })
812
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
813
+
814
+ describe('runContextInit', () => {
815
+ it('scaffolds the three files and is idempotent + non-destructive', () => {
816
+ expect(runContextInit(dir)).toBe(0)
817
+ const project = join(dir, '.yoke', 'context', 'PROJECT.md')
818
+ writeFileSync(project, 'USER EDIT')
819
+ expect(runContextInit(dir)).toBe(0) // second run
820
+ expect(readFileSync(project, 'utf8')).toBe('USER EDIT') // not clobbered
821
+ })
822
+ })
823
+
824
+ describe('runContextStatus', () => {
825
+ it('reports absence then presence', () => {
826
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {})
827
+ runContextStatus(dir)
828
+ expect(log.mock.calls.flat().join('\n')).toMatch(/not initialised|no .yoke\/context/i)
829
+ runContextInit(dir)
830
+ log.mockClear()
831
+ runContextStatus(dir)
832
+ expect(log.mock.calls.flat().join('\n')).toContain('PROJECT.md')
833
+ log.mockRestore()
834
+ })
835
+ })
836
+ ```
837
+
838
+ - [ ] **Step 2: Run test to verify it fails**
839
+
840
+ Run: `npx vitest run tests/context/command.test.ts`
841
+ Expected: FAIL — `src/context/command.js` does not exist.
842
+
843
+ - [ ] **Step 3: Implement the command bodies**
844
+
845
+ Create `src/context/command.ts`:
846
+ ```typescript
847
+ import { existsSync, readFileSync, statSync } from 'node:fs'
848
+ import { join } from 'node:path'
849
+ import { resolveCanonDir } from '../retrofit/canon-dir.js'
850
+ import { baseContextActions } from '../retrofit/context-actions.js'
851
+ import { applyActions } from '../retrofit/apply.js'
852
+ import { contextDir } from './context.js'
853
+
854
+ export function runContextInit(targetDir: string): number {
855
+ const canonDir = resolveCanonDir()
856
+ const actions = baseContextActions(canonDir)
857
+ const applied = applyActions(actions, targetDir, { backupDir: join(targetDir, '.yoke', 'backup', 'context') })
858
+ for (const a of applied) console.log(` ${a.status.padEnd(11)} ${a.target}`)
859
+ return 0
860
+ }
861
+
862
+ export function runContextStatus(targetDir: string): number {
863
+ const dir = contextDir(targetDir)
864
+ const files = ['PROJECT.md', 'DECISIONS.md', 'KNOWLEDGE.md']
865
+ if (!files.some(f => existsSync(join(dir, f)))) {
866
+ console.log('Context not initialised (no .yoke/context). Run: yoke context init')
867
+ return 0
868
+ }
869
+ for (const f of files) {
870
+ const p = join(dir, f)
871
+ console.log(existsSync(p) ? ` ${f.padEnd(13)} ${statSync(p).size} bytes` : ` ${f.padEnd(13)} (missing)`)
872
+ }
873
+ const decisions = join(dir, 'DECISIONS.md')
874
+ if (existsSync(decisions)) {
875
+ const last = readFileSync(decisions, 'utf8').split('\n').filter(l => l.startsWith('## ')).pop()
876
+ if (last) console.log(` last decision: ${last.slice(3)}`)
877
+ }
878
+ return 0
879
+ }
880
+ ```
881
+
882
+ - [ ] **Step 4: Run tests to verify they pass**
883
+
884
+ Run: `npx vitest run tests/context/command.test.ts`
885
+ Expected: PASS
886
+
887
+ - [ ] **Step 5: Wire into the CLI**
888
+
889
+ In `src/cli.ts`, add the import:
890
+ ```typescript
891
+ import { runContextInit, runContextStatus } from './context/command.js'
892
+ ```
893
+
894
+ Add a `case` in `main`'s switch (before `default`):
895
+ ```typescript
896
+ case 'context': {
897
+ const sub = rest[0]
898
+ const targetDir = rest.slice(1).find(a => !a.startsWith('-')) ?? '.'
899
+ if (sub === 'init') return runContextInit(targetDir)
900
+ if (sub === 'status') return runContextStatus(targetDir)
901
+ console.log('usage: yoke context <init|status> [targetDir]')
902
+ return 1
903
+ }
904
+ ```
905
+
906
+ Update the top-level `default` usage string to include `context`:
907
+ ```typescript
908
+ console.log('usage: yoke <validate [canonDir] | retrofit [...] | loop <...> | context <init|status>>')
909
+ ```
910
+
911
+ - [ ] **Step 6: Run the full suite**
912
+
913
+ Run: `npx vitest run`
914
+ Expected: PASS — all tests green.
915
+
916
+ - [ ] **Step 7: Commit**
917
+
918
+ ```bash
919
+ git add src/cli.ts src/context/command.ts tests/context/command.test.ts
920
+ git commit -m "feat(cli): yoke context init|status"
921
+ ```
922
+
923
+ ---
924
+
925
+ ### Task 8: Documentation — README Context layer section
926
+
927
+ **Files:**
928
+ - Modify: `README.md`
929
+ - Modify: `src/loop/run-command.ts` (only if a stale comment references prompt contents — otherwise skip)
930
+
931
+ - [ ] **Step 1: Add a "Context layer" section to README.md**
932
+
933
+ Insert after the loop section. Use this content:
934
+ ```markdown
935
+ ## Context layer (`.yoke/context/`)
936
+
937
+ Yoke keeps durable, cross-session context so a fresh-context agent is never blind:
938
+
939
+ - `PROJECT.md` — the north star (goal, constraints, non-goals, success criteria).
940
+ - `DECISIONS.md` — an append-only ledger. The loop adds an entry per completed story; you and agents add the *why*.
941
+ - `KNOWLEDGE.md` — reusable gotchas and conventions.
942
+
943
+ `yoke retrofit` scaffolds these files (non-destructively — your edits are never overwritten).
944
+ The loop reads them into every agent + reviewer prompt and logs decisions back on each story's
945
+ commit. Manage them directly with `yoke context init` and `yoke context status`. The
946
+ `maintaining-context` skill teaches agents to honour the same files during interactive work.
947
+ ```
948
+
949
+ - [ ] **Step 2: Verify the docs build / links**
950
+
951
+ Run: `npx vitest run` (ensure nothing references removed symbols)
952
+ Expected: PASS
953
+
954
+ - [ ] **Step 3: Commit**
955
+
956
+ ```bash
957
+ git add README.md
958
+ git commit -m "docs: document the Context layer"
959
+ ```
960
+
961
+ ---
962
+
963
+ ## Self-Review
964
+
965
+ **Spec coverage:**
966
+ - Three files `.yoke/context/{PROJECT,DECISIONS,KNOWLEDGE}.md` → Task 3 (templates) + Task 4 (scaffold).
967
+ - `src/context/context.ts` (`loadContext`, `formatForPrompt`, `appendDecision`) → Tasks 1–2.
968
+ - Loop read (bounded injection into both prompts) → Task 5.
969
+ - Loop write (decision on the same atomic commit, revert both on failure, isolate-safe) → Task 6.
970
+ - Retrofit scaffolding (non-destructive, idempotent, once for all agents) → Task 4.
971
+ - `maintaining-context` skill via manifest → Task 3.
972
+ - `yoke context init|status` → Task 7.
973
+ - Backwards-compat (no `.yoke/context/` → prompt unchanged) → Task 5 step 1 first test.
974
+ - No new config keys; bound is a constant → Task 1 (`MAX_CONTEXT_CHARS`).
975
+ - Docs → Task 8.
976
+
977
+ **Placeholder scan:** No TBD/TODO. The one inline `require` in Task 4 step 1 carries an explicit NOTE to use the file's ESM import style. All code steps show complete code.
978
+
979
+ **Type consistency:** `ProjectContext`, `DecisionEntry`, `Action.ifAbsent`, `contextDir`, `baseContextActions`, `contextBlockFor`, `runContextInit`, `runContextStatus` are defined once and referenced consistently. `appendDecision` signature `(dir, entry, now?)` matches all call sites (loop omits `now` → defaults to `new Date()`; tests pass a fixed date).
980
+
981
+ **Bounding note:** `formatForPrompt(ctx, max)` defaults to `MAX_CONTEXT_CHARS`; tests pass `10` to exercise truncation deterministically.