@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,842 @@
1
+ # Forge — Baustein B1 (Retrofit-Skill, Claude Code) Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Add a `forge retrofit` command that, run in a target project, detects it, plans a **non-destructive** retrofit, generates **Claude Code** artifacts from the Canon (`.claude/skills/`, `AGENTS.md`, `CLAUDE.md`), records the loop opt-in in `.forge/config.yaml`, and reports what changed.
6
+
7
+ **Architecture:** Pure planning (`planClaudeRetrofit` reads the Canon → list of `Action`s) is separated from effectful application (`applyActions` does backup-then-write, idempotent). Detection and config IO are isolated modules. The CLI wires them. This is Baustein B1 of the Forge spec (`docs/superpowers/specs/2026-06-27-forge-cross-agent-harness-design.md`) — Claude only; Codex/Gemini + tool-MCP wiring are Baustein B2.
8
+
9
+ **Tech Stack:** Node.js (ESM), TypeScript, vitest, `yaml`. Reuses Baustein A modules (`loadManifest`, `Manifest`). Distribution via `npx`.
10
+
11
+ **Builds on:** Baustein A (already on `main`): `src/canon/manifest.ts` (`loadManifest`, `Manifest`), `src/canon/validate.ts`, `src/cli.ts` (`main` dispatch, import-safe via `isMain` guard).
12
+
13
+ ---
14
+
15
+ ## File Structure
16
+
17
+ ```
18
+ src/
19
+ retrofit/
20
+ config.ts # ForgeConfig type + loadConfig/saveConfig (.forge/config.yaml)
21
+ detect.ts # detectProject() -> Detection
22
+ plan.ts # Action type + planClaudeRetrofit(canonDir, targetDir) -> Action[]
23
+ apply.ts # AppliedAction type + applyActions() (non-destructive, idempotent)
24
+ report.ts # formatReport(applied, config) -> string
25
+ canon-dir.ts # resolveCanonDir() — find the bundled canon next to package.json
26
+ cli.ts # MODIFY: add `retrofit` subcommand
27
+ tests/
28
+ retrofit/
29
+ config.test.ts
30
+ detect.test.ts
31
+ plan.test.ts
32
+ apply.test.ts
33
+ report.test.ts
34
+ retrofit.integration.test.ts
35
+ ```
36
+
37
+ Each module has one responsibility: config IO, detection, pure planning, effectful apply, reporting, canon resolution. The pure/effectful split keeps planning unit-testable without a filesystem and makes apply’s backup logic independently verifiable.
38
+
39
+ ---
40
+
41
+ ### Task 1: Forge config IO
42
+
43
+ **Files:**
44
+ - Create: `src/retrofit/config.ts`
45
+ - Test: `tests/retrofit/config.test.ts`
46
+
47
+ - [ ] **Step 1: Write the failing test**
48
+
49
+ Create `tests/retrofit/config.test.ts`:
50
+ ```ts
51
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
52
+ import { mkdtempSync, rmSync, existsSync } from 'node:fs'
53
+ import { join } from 'node:path'
54
+ import { tmpdir } from 'node:os'
55
+ import { loadConfig, saveConfig, defaultConfig } from '../../src/retrofit/config.js'
56
+
57
+ let dir: string
58
+ beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'forge-cfg-')) })
59
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
60
+
61
+ describe('forge config', () => {
62
+ it('returns null when no config exists', () => {
63
+ expect(loadConfig(dir)).toBeNull()
64
+ })
65
+
66
+ it('saves and reloads a config round-trip', () => {
67
+ const cfg = { canonVersion: '0.1.0', agents: ['claude'] as const, loop: { enabled: true } }
68
+ saveConfig(dir, cfg)
69
+ expect(existsSync(join(dir, '.forge', 'config.yaml'))).toBe(true)
70
+ expect(loadConfig(dir)).toEqual(cfg)
71
+ })
72
+
73
+ it('defaultConfig has loop disabled', () => {
74
+ expect(defaultConfig('0.1.0').loop.enabled).toBe(false)
75
+ })
76
+ })
77
+ ```
78
+
79
+ - [ ] **Step 2: Run test to verify it fails**
80
+
81
+ Run: `npm test -- retrofit/config`
82
+ Expected: FAIL — cannot find module `src/retrofit/config.js`.
83
+
84
+ - [ ] **Step 3: Write minimal implementation**
85
+
86
+ Create `src/retrofit/config.ts`:
87
+ ```ts
88
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
89
+ import { join, dirname } from 'node:path'
90
+ import { parse, stringify } from 'yaml'
91
+
92
+ export type Agent = 'claude' | 'codex' | 'gemini'
93
+
94
+ export interface ForgeConfig {
95
+ canonVersion: string
96
+ agents: Agent[]
97
+ loop: { enabled: boolean }
98
+ }
99
+
100
+ export function defaultConfig(canonVersion: string): ForgeConfig {
101
+ return { canonVersion, agents: [], loop: { enabled: false } }
102
+ }
103
+
104
+ export function configPath(targetDir: string): string {
105
+ return join(targetDir, '.forge', 'config.yaml')
106
+ }
107
+
108
+ export function loadConfig(targetDir: string): ForgeConfig | null {
109
+ const file = configPath(targetDir)
110
+ if (!existsSync(file)) return null
111
+ return parse(readFileSync(file, 'utf8')) as ForgeConfig
112
+ }
113
+
114
+ export function saveConfig(targetDir: string, config: ForgeConfig): void {
115
+ const file = configPath(targetDir)
116
+ mkdirSync(dirname(file), { recursive: true })
117
+ writeFileSync(file, stringify(config))
118
+ }
119
+ ```
120
+
121
+ - [ ] **Step 4: Run test to verify it passes**
122
+
123
+ Run: `npm test -- retrofit/config`
124
+ Expected: PASS (3 tests).
125
+
126
+ - [ ] **Step 5: Commit**
127
+
128
+ ```bash
129
+ git add src/retrofit/config.ts tests/retrofit/config.test.ts
130
+ git commit -m "feat: add forge .forge/config.yaml IO"
131
+ ```
132
+
133
+ ---
134
+
135
+ ### Task 2: Project detection
136
+
137
+ **Files:**
138
+ - Create: `src/retrofit/detect.ts`
139
+ - Test: `tests/retrofit/detect.test.ts`
140
+
141
+ - [ ] **Step 1: Write the failing test**
142
+
143
+ Create `tests/retrofit/detect.test.ts`:
144
+ ```ts
145
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
146
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
147
+ import { join } from 'node:path'
148
+ import { tmpdir } from 'node:os'
149
+ import { detectProject } from '../../src/retrofit/detect.js'
150
+
151
+ let dir: string
152
+ beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'forge-detect-')) })
153
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
154
+
155
+ describe('detectProject', () => {
156
+ it('reports no agents in an empty project', () => {
157
+ const d = detectProject(dir)
158
+ expect(d.agents).toEqual([])
159
+ expect(d.hasAgentsMd).toBe(false)
160
+ expect(d.hasForgeConfig).toBe(false)
161
+ })
162
+
163
+ it('detects claude via .claude/ and a CLAUDE.md', () => {
164
+ mkdirSync(join(dir, '.claude'), { recursive: true })
165
+ writeFileSync(join(dir, 'CLAUDE.md'), '# project')
166
+ expect(detectProject(dir).agents).toContain('claude')
167
+ })
168
+
169
+ it('detects codex and gemini directories', () => {
170
+ mkdirSync(join(dir, '.codex'), { recursive: true })
171
+ mkdirSync(join(dir, '.gemini'), { recursive: true })
172
+ const d = detectProject(dir)
173
+ expect(d.agents).toContain('codex')
174
+ expect(d.agents).toContain('gemini')
175
+ })
176
+
177
+ it('flags an existing AGENTS.md and .forge config', () => {
178
+ writeFileSync(join(dir, 'AGENTS.md'), 'x')
179
+ mkdirSync(join(dir, '.forge'), { recursive: true })
180
+ writeFileSync(join(dir, '.forge', 'config.yaml'), 'x')
181
+ const d = detectProject(dir)
182
+ expect(d.hasAgentsMd).toBe(true)
183
+ expect(d.hasForgeConfig).toBe(true)
184
+ })
185
+ })
186
+ ```
187
+
188
+ - [ ] **Step 2: Run test to verify it fails**
189
+
190
+ Run: `npm test -- retrofit/detect`
191
+ Expected: FAIL — cannot find module `src/retrofit/detect.js`.
192
+
193
+ - [ ] **Step 3: Write minimal implementation**
194
+
195
+ Create `src/retrofit/detect.ts`:
196
+ ```ts
197
+ import { existsSync } from 'node:fs'
198
+ import { join } from 'node:path'
199
+ import type { Agent } from './config.js'
200
+
201
+ export interface Detection {
202
+ agents: Agent[]
203
+ hasAgentsMd: boolean
204
+ hasForgeConfig: boolean
205
+ }
206
+
207
+ export function detectProject(targetDir: string): Detection {
208
+ const has = (...parts: string[]) => existsSync(join(targetDir, ...parts))
209
+ const agents: Agent[] = []
210
+ if (has('.claude') || has('CLAUDE.md')) agents.push('claude')
211
+ if (has('.codex') || has('AGENTS.md')) agents.push('codex')
212
+ if (has('.gemini') || has('GEMINI.md')) agents.push('gemini')
213
+ return {
214
+ agents,
215
+ hasAgentsMd: has('AGENTS.md'),
216
+ hasForgeConfig: has('.forge', 'config.yaml'),
217
+ }
218
+ }
219
+ ```
220
+
221
+ Note: AGENTS.md implies a Codex-style project because Codex reads AGENTS.md natively; that is acceptable detection noise for B1 (we only generate Claude artifacts here). Refined per-agent gating lands in B2.
222
+
223
+ - [ ] **Step 4: Run test to verify it passes**
224
+
225
+ Run: `npm test -- retrofit/detect`
226
+ Expected: PASS (4 tests).
227
+
228
+ - [ ] **Step 5: Commit**
229
+
230
+ ```bash
231
+ git add src/retrofit/detect.ts tests/retrofit/detect.test.ts
232
+ git commit -m "feat: add project/harness detection"
233
+ ```
234
+
235
+ ---
236
+
237
+ ### Task 3: Plan Claude retrofit (pure)
238
+
239
+ **Files:**
240
+ - Create: `src/retrofit/plan.ts`
241
+ - Test: `tests/retrofit/plan.test.ts`
242
+
243
+ - [ ] **Step 1: Write the failing test**
244
+
245
+ Create `tests/retrofit/plan.test.ts`:
246
+ ```ts
247
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
248
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
249
+ import { join } from 'node:path'
250
+ import { tmpdir } from 'node:os'
251
+ import { planClaudeRetrofit } from '../../src/retrofit/plan.js'
252
+
253
+ let canon: string
254
+ beforeEach(() => {
255
+ canon = mkdtempSync(join(tmpdir(), 'forge-canon-'))
256
+ const w = (rel: string, c: string) => {
257
+ mkdirSync(join(canon, rel, '..'), { recursive: true })
258
+ writeFileSync(join(canon, rel), c)
259
+ }
260
+ w('manifest.yaml', `
261
+ name: forge-canon
262
+ version: 0.1.0
263
+ agents: [claude]
264
+ skills:
265
+ - { id: tdd, path: skills/tdd, kind: methodology }
266
+ policy: []
267
+ loop: { spec: loop/loop-spec.md, prdSchema: loop/prd.schema.md }
268
+ tools: []
269
+ `)
270
+ w('AGENTS.md', '# Forge Harness Baseline\n')
271
+ w('skills/tdd/SKILL.md', '---\nname: tdd\ndescription: d\n---\nbody')
272
+ })
273
+ afterEach(() => { rmSync(canon, { recursive: true, force: true }) })
274
+
275
+ describe('planClaudeRetrofit', () => {
276
+ it('plans a skill, AGENTS.md, and CLAUDE.md', () => {
277
+ const actions = planClaudeRetrofit(canon, '/target')
278
+ const targets = actions.map(a => a.target)
279
+ expect(targets).toContain('.claude/skills/tdd/SKILL.md')
280
+ expect(targets).toContain('AGENTS.md')
281
+ expect(targets).toContain('CLAUDE.md')
282
+ })
283
+
284
+ it('copies the canon skill content verbatim', () => {
285
+ const action = planClaudeRetrofit(canon, '/target').find(a => a.target === '.claude/skills/tdd/SKILL.md')!
286
+ expect(action.content).toContain('name: tdd')
287
+ })
288
+
289
+ it('CLAUDE.md imports AGENTS.md', () => {
290
+ const action = planClaudeRetrofit(canon, '/target').find(a => a.target === 'CLAUDE.md')!
291
+ expect(action.content).toContain('@AGENTS.md')
292
+ })
293
+ })
294
+ ```
295
+
296
+ - [ ] **Step 2: Run test to verify it fails**
297
+
298
+ Run: `npm test -- retrofit/plan`
299
+ Expected: FAIL — cannot find module `src/retrofit/plan.js`.
300
+
301
+ - [ ] **Step 3: Write minimal implementation**
302
+
303
+ Create `src/retrofit/plan.ts`:
304
+ ```ts
305
+ import { readFileSync } from 'node:fs'
306
+ import { join } from 'node:path'
307
+ import { loadManifest } from '../canon/manifest.js'
308
+
309
+ export interface Action {
310
+ kind: 'write'
311
+ target: string
312
+ content: string
313
+ reason: string
314
+ }
315
+
316
+ const CLAUDE_MD = `# Project Instructions
317
+
318
+ This project uses the Forge harness. Baseline instructions:
319
+
320
+ @AGENTS.md
321
+ `
322
+
323
+ export function planClaudeRetrofit(canonDir: string, _targetDir: string): Action[] {
324
+ const manifest = loadManifest(join(canonDir, 'manifest.yaml'))
325
+ const actions: Action[] = []
326
+
327
+ for (const skill of manifest.skills) {
328
+ const content = readFileSync(join(canonDir, skill.path, 'SKILL.md'), 'utf8')
329
+ actions.push({
330
+ kind: 'write',
331
+ target: `.claude/skills/${skill.id}/SKILL.md`,
332
+ content,
333
+ reason: `skill: ${skill.id}`,
334
+ })
335
+ }
336
+
337
+ actions.push({
338
+ kind: 'write',
339
+ target: 'AGENTS.md',
340
+ content: readFileSync(join(canonDir, 'AGENTS.md'), 'utf8'),
341
+ reason: 'baseline instructions',
342
+ })
343
+
344
+ actions.push({
345
+ kind: 'write',
346
+ target: 'CLAUDE.md',
347
+ content: CLAUDE_MD,
348
+ reason: 'Claude entry importing AGENTS.md',
349
+ })
350
+
351
+ return actions
352
+ }
353
+ ```
354
+
355
+ - [ ] **Step 4: Run test to verify it passes**
356
+
357
+ Run: `npm test -- retrofit/plan`
358
+ Expected: PASS (3 tests).
359
+
360
+ - [ ] **Step 5: Commit**
361
+
362
+ ```bash
363
+ git add src/retrofit/plan.ts tests/retrofit/plan.test.ts
364
+ git commit -m "feat: plan Claude retrofit actions from the canon"
365
+ ```
366
+
367
+ ---
368
+
369
+ ### Task 4: Apply actions (non-destructive, idempotent)
370
+
371
+ **Files:**
372
+ - Create: `src/retrofit/apply.ts`
373
+ - Test: `tests/retrofit/apply.test.ts`
374
+
375
+ - [ ] **Step 1: Write the failing test**
376
+
377
+ Create `tests/retrofit/apply.test.ts`:
378
+ ```ts
379
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
380
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs'
381
+ import { join } from 'node:path'
382
+ import { tmpdir } from 'node:os'
383
+ import { applyActions } from '../../src/retrofit/apply.js'
384
+ import type { Action } from '../../src/retrofit/plan.js'
385
+
386
+ let target: string
387
+ const backupDir = () => join(target, '.forge', 'backup', 'test')
388
+
389
+ const actions: Action[] = [
390
+ { kind: 'write', target: 'AGENTS.md', content: 'NEW', reason: 'baseline' },
391
+ { kind: 'write', target: '.claude/skills/tdd/SKILL.md', content: 'SKILL', reason: 'skill: tdd' },
392
+ ]
393
+
394
+ beforeEach(() => { target = mkdtempSync(join(tmpdir(), 'forge-apply-')) })
395
+ afterEach(() => { rmSync(target, { recursive: true, force: true }) })
396
+
397
+ describe('applyActions', () => {
398
+ it('creates new files', () => {
399
+ const res = applyActions(actions, target, { backupDir: backupDir() })
400
+ expect(res.every(r => r.status === 'created')).toBe(true)
401
+ expect(readFileSync(join(target, 'AGENTS.md'), 'utf8')).toBe('NEW')
402
+ expect(existsSync(join(target, '.claude/skills/tdd/SKILL.md'))).toBe(true)
403
+ })
404
+
405
+ it('is idempotent — second run reports unchanged and writes no backup', () => {
406
+ applyActions(actions, target, { backupDir: backupDir() })
407
+ const res = applyActions(actions, target, { backupDir: backupDir() })
408
+ expect(res.every(r => r.status === 'unchanged')).toBe(true)
409
+ expect(existsSync(backupDir())).toBe(false)
410
+ })
411
+
412
+ it('backs up an existing file before overwriting with different content', () => {
413
+ writeFileSync(join(target, 'AGENTS.md'), 'OLD')
414
+ const res = applyActions(actions, target, { backupDir: backupDir() })
415
+ const agents = res.find(r => r.target === 'AGENTS.md')!
416
+ expect(agents.status).toBe('overwritten')
417
+ expect(agents.backedUp).toBeDefined()
418
+ expect(readFileSync(agents.backedUp!, 'utf8')).toBe('OLD')
419
+ expect(readFileSync(join(target, 'AGENTS.md'), 'utf8')).toBe('NEW')
420
+ })
421
+ })
422
+ ```
423
+
424
+ - [ ] **Step 2: Run test to verify it fails**
425
+
426
+ Run: `npm test -- retrofit/apply`
427
+ Expected: FAIL — cannot find module `src/retrofit/apply.js`.
428
+
429
+ - [ ] **Step 3: Write minimal implementation**
430
+
431
+ Create `src/retrofit/apply.ts`:
432
+ ```ts
433
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs'
434
+ import { join, dirname } from 'node:path'
435
+ import type { Action } from './plan.js'
436
+
437
+ export interface AppliedAction {
438
+ target: string
439
+ status: 'created' | 'overwritten' | 'unchanged'
440
+ backedUp?: string
441
+ reason: string
442
+ }
443
+
444
+ export interface ApplyOptions {
445
+ backupDir: string
446
+ }
447
+
448
+ export function applyActions(actions: Action[], targetDir: string, opts: ApplyOptions): AppliedAction[] {
449
+ const results: AppliedAction[] = []
450
+
451
+ for (const action of actions) {
452
+ const dest = join(targetDir, action.target)
453
+ let status: AppliedAction['status']
454
+ let backedUp: string | undefined
455
+
456
+ if (existsSync(dest)) {
457
+ const current = readFileSync(dest, 'utf8')
458
+ if (current === action.content) {
459
+ results.push({ target: action.target, status: 'unchanged', reason: action.reason })
460
+ continue
461
+ }
462
+ backedUp = join(opts.backupDir, action.target)
463
+ mkdirSync(dirname(backedUp), { recursive: true })
464
+ copyFileSync(dest, backedUp)
465
+ status = 'overwritten'
466
+ } else {
467
+ status = 'created'
468
+ }
469
+
470
+ mkdirSync(dirname(dest), { recursive: true })
471
+ writeFileSync(dest, action.content)
472
+ results.push({ target: action.target, status, backedUp, reason: action.reason })
473
+ }
474
+
475
+ return results
476
+ }
477
+ ```
478
+
479
+ - [ ] **Step 4: Run test to verify it passes**
480
+
481
+ Run: `npm test -- retrofit/apply`
482
+ Expected: PASS (3 tests).
483
+
484
+ - [ ] **Step 5: Commit**
485
+
486
+ ```bash
487
+ git add src/retrofit/apply.ts tests/retrofit/apply.test.ts
488
+ git commit -m "feat: apply retrofit actions non-destructively with backups"
489
+ ```
490
+
491
+ ---
492
+
493
+ ### Task 5: Report formatting
494
+
495
+ **Files:**
496
+ - Create: `src/retrofit/report.ts`
497
+ - Test: `tests/retrofit/report.test.ts`
498
+
499
+ - [ ] **Step 1: Write the failing test**
500
+
501
+ Create `tests/retrofit/report.test.ts`:
502
+ ```ts
503
+ import { describe, it, expect } from 'vitest'
504
+ import { formatReport } from '../../src/retrofit/report.js'
505
+ import type { AppliedAction } from '../../src/retrofit/apply.js'
506
+
507
+ const applied: AppliedAction[] = [
508
+ { target: 'AGENTS.md', status: 'created', reason: 'baseline' },
509
+ { target: 'CLAUDE.md', status: 'overwritten', backedUp: '/b/CLAUDE.md', reason: 'entry' },
510
+ { target: '.claude/skills/tdd/SKILL.md', status: 'unchanged', reason: 'skill: tdd' },
511
+ ]
512
+
513
+ describe('formatReport', () => {
514
+ it('summarizes counts and loop state', () => {
515
+ const out = formatReport(applied, { loopEnabled: false })
516
+ expect(out).toContain('1 created')
517
+ expect(out).toContain('1 overwritten')
518
+ expect(out).toContain('1 unchanged')
519
+ expect(out).toContain('Loop: disabled')
520
+ })
521
+
522
+ it('lists each target with its status', () => {
523
+ const out = formatReport(applied, { loopEnabled: true })
524
+ expect(out).toContain('AGENTS.md')
525
+ expect(out).toContain('Loop: enabled')
526
+ })
527
+ })
528
+ ```
529
+
530
+ - [ ] **Step 2: Run test to verify it fails**
531
+
532
+ Run: `npm test -- retrofit/report`
533
+ Expected: FAIL — cannot find module `src/retrofit/report.js`.
534
+
535
+ - [ ] **Step 3: Write minimal implementation**
536
+
537
+ Create `src/retrofit/report.ts`:
538
+ ```ts
539
+ import type { AppliedAction } from './apply.js'
540
+
541
+ export interface ReportMeta {
542
+ loopEnabled: boolean
543
+ }
544
+
545
+ export function formatReport(applied: AppliedAction[], meta: ReportMeta): string {
546
+ const count = (s: AppliedAction['status']) => applied.filter(a => a.status === s).length
547
+ const lines: string[] = []
548
+ lines.push('Forge retrofit (Claude Code):')
549
+ for (const a of applied) {
550
+ const note = a.backedUp ? ` (backup: ${a.backedUp})` : ''
551
+ lines.push(` ${a.status.padEnd(11)} ${a.target}${note}`)
552
+ }
553
+ lines.push('')
554
+ lines.push(`Summary: ${count('created')} created, ${count('overwritten')} overwritten, ${count('unchanged')} unchanged`)
555
+ lines.push(`Loop: ${meta.loopEnabled ? 'enabled' : 'disabled'}`)
556
+ return lines.join('\n')
557
+ }
558
+ ```
559
+
560
+ - [ ] **Step 4: Run test to verify it passes**
561
+
562
+ Run: `npm test -- retrofit/report`
563
+ Expected: PASS (2 tests).
564
+
565
+ - [ ] **Step 5: Commit**
566
+
567
+ ```bash
568
+ git add src/retrofit/report.ts tests/retrofit/report.test.ts
569
+ git commit -m "feat: format retrofit report"
570
+ ```
571
+
572
+ ---
573
+
574
+ ### Task 6: Canon directory resolution
575
+
576
+ **Files:**
577
+ - Create: `src/retrofit/canon-dir.ts`
578
+ - Test: `tests/retrofit/canon-dir.test.ts`
579
+
580
+ - [ ] **Step 1: Write the failing test**
581
+
582
+ Create `tests/retrofit/canon-dir.test.ts`:
583
+ ```ts
584
+ import { describe, it, expect } from 'vitest'
585
+ import { existsSync } from 'node:fs'
586
+ import { join } from 'node:path'
587
+ import { resolveCanonDir } from '../../src/retrofit/canon-dir.js'
588
+
589
+ describe('resolveCanonDir', () => {
590
+ it('finds the bundled canon (contains manifest.yaml)', () => {
591
+ const dir = resolveCanonDir()
592
+ expect(existsSync(join(dir, 'manifest.yaml'))).toBe(true)
593
+ })
594
+ })
595
+ ```
596
+
597
+ - [ ] **Step 2: Run test to verify it fails**
598
+
599
+ Run: `npm test -- retrofit/canon-dir`
600
+ Expected: FAIL — cannot find module `src/retrofit/canon-dir.js`.
601
+
602
+ - [ ] **Step 3: Write minimal implementation**
603
+
604
+ Create `src/retrofit/canon-dir.ts`:
605
+ ```ts
606
+ import { existsSync } from 'node:fs'
607
+ import { dirname, join } from 'node:path'
608
+ import { fileURLToPath } from 'node:url'
609
+
610
+ // Walk up from this module to the package root (the dir containing package.json),
611
+ // then return its `canon/` directory. Works under both tsx (src/) and built dist/.
612
+ export function resolveCanonDir(): string {
613
+ let dir = dirname(fileURLToPath(import.meta.url))
614
+ for (let i = 0; i < 10; i++) {
615
+ if (existsSync(join(dir, 'package.json'))) {
616
+ return join(dir, 'canon')
617
+ }
618
+ const parent = dirname(dir)
619
+ if (parent === dir) break
620
+ dir = parent
621
+ }
622
+ throw new Error('could not locate package root to resolve canon/')
623
+ }
624
+ ```
625
+
626
+ - [ ] **Step 4: Run test to verify it passes**
627
+
628
+ Run: `npm test -- retrofit/canon-dir`
629
+ Expected: PASS (1 test).
630
+
631
+ - [ ] **Step 5: Commit**
632
+
633
+ ```bash
634
+ git add src/retrofit/canon-dir.ts tests/retrofit/canon-dir.test.ts
635
+ git commit -m "feat: resolve bundled canon directory"
636
+ ```
637
+
638
+ ---
639
+
640
+ ### Task 7: Wire `forge retrofit` CLI + integration test
641
+
642
+ **Files:**
643
+ - Modify: `src/cli.ts`
644
+ - Test: `tests/retrofit/retrofit.integration.test.ts`
645
+
646
+ - [ ] **Step 1: Write the failing integration test**
647
+
648
+ Create `tests/retrofit/retrofit.integration.test.ts`:
649
+ ```ts
650
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
651
+ import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs'
652
+ import { join } from 'node:path'
653
+ import { tmpdir } from 'node:os'
654
+ import { runRetrofit } from '../../src/cli.js'
655
+ import { loadConfig } from '../../src/retrofit/config.js'
656
+
657
+ let target: string
658
+ beforeEach(() => { target = mkdtempSync(join(tmpdir(), 'forge-retro-')) })
659
+ afterEach(() => { rmSync(target, { recursive: true, force: true }) })
660
+
661
+ describe('forge retrofit (integration, Claude)', () => {
662
+ it('generates Claude artifacts and writes config with loop disabled by default', () => {
663
+ const code = runRetrofit(target, { loop: false })
664
+ expect(code).toBe(0)
665
+ expect(existsSync(join(target, 'AGENTS.md'))).toBe(true)
666
+ expect(existsSync(join(target, 'CLAUDE.md'))).toBe(true)
667
+ expect(existsSync(join(target, '.claude/skills/tdd/SKILL.md'))).toBe(true)
668
+ expect(existsSync(join(target, '.claude/skills/eng-review/SKILL.md'))).toBe(true)
669
+ const cfg = loadConfig(target)!
670
+ expect(cfg.agents).toContain('claude')
671
+ expect(cfg.loop.enabled).toBe(false)
672
+ })
673
+
674
+ it('records loop enabled when --loop is passed', () => {
675
+ runRetrofit(target, { loop: true })
676
+ expect(loadConfig(target)!.loop.enabled).toBe(true)
677
+ })
678
+
679
+ it('is idempotent on a second run', () => {
680
+ runRetrofit(target, { loop: false })
681
+ const agentsBefore = readFileSync(join(target, 'AGENTS.md'), 'utf8')
682
+ const code = runRetrofit(target, { loop: false })
683
+ expect(code).toBe(0)
684
+ expect(readFileSync(join(target, 'AGENTS.md'), 'utf8')).toBe(agentsBefore)
685
+ })
686
+ })
687
+ ```
688
+
689
+ - [ ] **Step 2: Run test to verify it fails**
690
+
691
+ Run: `npm test -- retrofit.integration`
692
+ Expected: FAIL — `runRetrofit` is not exported from `src/cli.js`.
693
+
694
+ - [ ] **Step 3: Modify `src/cli.ts` to add `runRetrofit` and the subcommand**
695
+
696
+ Add these imports at the top of `src/cli.ts` (below the existing import):
697
+ ```ts
698
+ import { resolveCanonDir } from './retrofit/canon-dir.js'
699
+ import { planClaudeRetrofit } from './retrofit/plan.js'
700
+ import { applyActions } from './retrofit/apply.js'
701
+ import { formatReport } from './retrofit/report.js'
702
+ import { loadConfig, saveConfig, defaultConfig, type ForgeConfig } from './retrofit/config.js'
703
+ import { loadManifest } from './canon/manifest.js'
704
+ import { join } from 'node:path'
705
+ ```
706
+
707
+ Add the `runRetrofit` function (after `runValidate`):
708
+ ```ts
709
+ export function runRetrofit(targetDir: string, opts: { loop: boolean }): number {
710
+ const canonDir = resolveCanonDir()
711
+ const canonVersion = loadManifest(join(canonDir, 'manifest.yaml')).version
712
+
713
+ const actions = planClaudeRetrofit(canonDir, targetDir)
714
+ const backupDir = join(targetDir, '.forge', 'backup', String(Date.now()))
715
+ const applied = applyActions(actions, targetDir, { backupDir })
716
+
717
+ const existing = loadConfig(targetDir)
718
+ const config: ForgeConfig = {
719
+ ...(existing ?? defaultConfig(canonVersion)),
720
+ canonVersion,
721
+ agents: ['claude'],
722
+ loop: { enabled: opts.loop },
723
+ }
724
+ saveConfig(targetDir, config)
725
+
726
+ console.log(formatReport(applied, { loopEnabled: config.loop.enabled }))
727
+ return 0
728
+ }
729
+ ```
730
+
731
+ Extend the `switch` in `main` with a `retrofit` case:
732
+ ```ts
733
+ case 'retrofit': {
734
+ const targetDir = rest.find(a => !a.startsWith('-')) ?? '.'
735
+ const loop = rest.includes('--loop')
736
+ return runRetrofit(targetDir, { loop })
737
+ }
738
+ ```
739
+
740
+ And update the default usage line:
741
+ ```ts
742
+ default:
743
+ console.log('usage: forge <validate [canonDir] | retrofit [targetDir] [--loop]>')
744
+ return cmd ? 1 : 0
745
+ ```
746
+
747
+ - [ ] **Step 4: Run test to verify it passes**
748
+
749
+ Run: `npm test -- retrofit.integration`
750
+ Expected: PASS (3 tests).
751
+
752
+ - [ ] **Step 5: Run the full suite and a manual smoke**
753
+
754
+ Run: `npm test`
755
+ Expected: all tests pass (Baustein A's 17 + B1's new tests).
756
+
757
+ Run (PowerShell), retrofitting a throwaway dir:
758
+ ```
759
+ mkdir ../forge-smoke 2>$null; npm run forge -- retrofit ../forge-smoke
760
+ ```
761
+ Expected: prints the retrofit report; `../forge-smoke/.claude/skills/tdd/SKILL.md`, `AGENTS.md`, `CLAUDE.md`, and `.forge/config.yaml` exist. Then delete it: `Remove-Item -Recurse -Force ../forge-smoke`.
762
+
763
+ - [ ] **Step 6: Commit**
764
+
765
+ ```bash
766
+ git add src/cli.ts tests/retrofit/retrofit.integration.test.ts
767
+ git commit -m "feat: wire forge retrofit command for Claude Code"
768
+ ```
769
+
770
+ ---
771
+
772
+ ### Task 8: Retrofit skill wrapper (agent-facing)
773
+
774
+ **Files:**
775
+ - Create: `canon/skills/forge-retrofit/SKILL.md`
776
+ - Test: (validated by Baustein A's `real-canon` test — the new skill must be added to `canon/manifest.yaml`)
777
+
778
+ - [ ] **Step 1: Add the skill to `canon/manifest.yaml`**
779
+
780
+ Add under `skills:`:
781
+ ```yaml
782
+ - { id: forge-retrofit, path: skills/forge-retrofit, kind: methodology }
783
+ ```
784
+
785
+ - [ ] **Step 2: Create `canon/skills/forge-retrofit/SKILL.md`**
786
+
787
+ ```markdown
788
+ ---
789
+ name: forge-retrofit
790
+ description: Use when asked to "retrofit", "forge this project", or set up the Forge harness in a project — runs forge retrofit and asks whether to enable the autonomous loop.
791
+ ---
792
+
793
+ # Forge Retrofit
794
+
795
+ Set up (or update) the Forge harness in the current project.
796
+
797
+ 1. Run `forge retrofit .` to generate the Claude Code artifacts (`.claude/skills/`, `AGENTS.md`, `CLAUDE.md`). This is non-destructive — existing files are backed up under `.forge/backup/` before any overwrite.
798
+ 2. **Ask the user whether to enable the autonomous Loop** (default: off). If yes, re-run with `forge retrofit . --loop`. The choice is recorded in `.forge/config.yaml` and can be changed any time with `forge loop on|off` (Baustein C).
799
+ 3. Show the user the printed report (what was created/overwritten/unchanged) and where backups went.
800
+
801
+ Never overwrite the user's existing instructions without surfacing the backup location in your summary.
802
+ ```
803
+
804
+ - [ ] **Step 3: Run the canon validator and the real-canon test**
805
+
806
+ Run: `npm test -- real-canon`
807
+ Expected: PASS — the canon (now with `forge-retrofit`) still validates.
808
+
809
+ Run: `npm run forge -- validate canon`
810
+ Expected: `✓ canon valid (canon)`.
811
+
812
+ - [ ] **Step 4: Commit**
813
+
814
+ ```bash
815
+ git add canon/manifest.yaml canon/skills/forge-retrofit/SKILL.md
816
+ git commit -m "feat: add agent-facing forge-retrofit skill to the canon"
817
+ ```
818
+
819
+ ---
820
+
821
+ ## Self-Review
822
+
823
+ **1. Spec coverage (Baustein B1 scope):**
824
+ - detect → Task 2 ✓
825
+ - plan (non-destructive, additive) → Tasks 3, 4 (backup-before-overwrite) ✓
826
+ - generate Claude artifacts (.claude/skills, AGENTS.md, CLAUDE.md) → Tasks 3, 7 ✓
827
+ - report → Tasks 5, 7 ✓
828
+ - loop opt-in recorded in `.forge/config.yaml`, default off → Tasks 1, 7 ✓
829
+ - agent-facing skill that asks about the loop → Task 8 ✓
830
+ - idempotent + reversible (backups) → Tasks 4, 7 ✓
831
+ - (Deferred to B2: Codex/Gemini generation, rtk/graphify/playwright MCP wiring, rtk WSL detection. Correct — B1 is Claude-only.)
832
+
833
+ **2. Placeholder scan:** No TBD/TODO; every step has complete code/content. ✓
834
+
835
+ **3. Type consistency:** `Agent`, `ForgeConfig`, `Detection`, `Action{kind,target,content,reason}`, `AppliedAction{target,status,backedUp?,reason}`, `ReportMeta{loopEnabled}`, `planClaudeRetrofit`, `applyActions`, `formatReport`, `resolveCanonDir`, `runRetrofit`, `loadConfig/saveConfig/defaultConfig` — names and signatures consistent across tasks. `loadManifest`/`Manifest` reused from Baustein A unchanged. ✓
836
+
837
+ ---
838
+
839
+ ## Next Plans (not this document)
840
+
841
+ - **Plan B2 — Retrofit for Codex + Gemini + tool wiring:** generate `AGENTS.md`/`config.toml`/`RTK.md` (Codex) and `.gemini/commands/*.toml`/`GEMINI.md`/`settings.json` (Gemini); wire rtk (hook/instruction with WSL detection), graphify + Playwright MCP servers per agent.
842
+ - **Plan C — Loop-Engine:** Ralph driver, gates, worktree isolation, `forge loop on|off|status`.