@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,815 @@
1
+ # Forge — Baustein A (Canon + Validator) 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:** Build the harness-agnostic Canon (source-of-truth) and a `forge validate` CLI that lints the Canon for integrity, fully TDD'd.
6
+
7
+ **Architecture:** A Node.js/TypeScript CLI (`forge`) with a `validate` subcommand. It loads `canon/manifest.yaml` (zod-validated), then checks that every referenced skill/policy/loop/tool path exists and that each `SKILL.md` has valid frontmatter. The Canon itself is seeded with minimal-but-real content that passes validation. This is Baustein A of the Forge spec (`docs/superpowers/specs/2026-06-27-forge-cross-agent-harness-design.md`); B (Retrofit-Skill) and C (Loop) get their own plans.
8
+
9
+ **Tech Stack:** Node.js (ESM), TypeScript, vitest (test), `yaml` + `zod` (parsing/schema). Distribution via `npx`/`package.json` `bin`.
10
+
11
+ ---
12
+
13
+ ## File Structure
14
+
15
+ ```
16
+ package.json # project + forge bin + scripts
17
+ tsconfig.json # TS config (ESM, strict)
18
+ vitest.config.ts # test config
19
+ .gitignore # node_modules, dist
20
+ src/
21
+ cli.ts # entry: dispatch subcommands (validate)
22
+ canon/
23
+ manifest.ts # zod schema + loadManifest()
24
+ frontmatter.ts # parseFrontmatter() for SKILL.md
25
+ validate.ts # validateCanon() -> Issue[]
26
+ canon/ # THE CANON (source-of-truth, harness-agnostic)
27
+ AGENTS.md
28
+ manifest.yaml
29
+ skills/tdd/SKILL.md
30
+ skills/eng-review/SKILL.md
31
+ policy/gates.md
32
+ policy/roles.md
33
+ loop/loop-spec.md
34
+ loop/prd.schema.md
35
+ tools/rtk.md
36
+ tools/graphify.md
37
+ tools/playwright-mcp.md
38
+ tests/
39
+ canon/frontmatter.test.ts
40
+ canon/manifest.test.ts
41
+ canon/validate.test.ts
42
+ canon/real-canon.test.ts
43
+ ```
44
+
45
+ Each `src/canon/*.ts` file has one responsibility: schema, frontmatter parsing, validation orchestration. The CLI stays thin so logic is unit-testable without spawning processes.
46
+
47
+ ---
48
+
49
+ ### Task 1: Project scaffold
50
+
51
+ **Files:**
52
+ - Create: `package.json`, `tsconfig.json`, `vitest.config.ts`, `.gitignore`
53
+ - Test: `tests/smoke.test.ts`
54
+
55
+ - [ ] **Step 1: Write the failing smoke test**
56
+
57
+ Create `tests/smoke.test.ts`:
58
+ ```ts
59
+ import { describe, it, expect } from 'vitest'
60
+
61
+ describe('smoke', () => {
62
+ it('runs', () => {
63
+ expect(1 + 1).toBe(2)
64
+ })
65
+ })
66
+ ```
67
+
68
+ - [ ] **Step 2: Create `package.json`**
69
+
70
+ ```json
71
+ {
72
+ "name": "forge",
73
+ "version": "0.1.0",
74
+ "type": "module",
75
+ "bin": { "forge": "./dist/cli.js" },
76
+ "scripts": {
77
+ "build": "tsc",
78
+ "test": "vitest run",
79
+ "forge": "tsx src/cli.ts"
80
+ },
81
+ "dependencies": {
82
+ "yaml": "^2.5.0",
83
+ "zod": "^3.23.8"
84
+ },
85
+ "devDependencies": {
86
+ "@types/node": "^22.7.0",
87
+ "tsx": "^4.19.1",
88
+ "typescript": "^5.6.2",
89
+ "vitest": "^2.1.1"
90
+ }
91
+ }
92
+ ```
93
+
94
+ - [ ] **Step 3: Create `tsconfig.json`**
95
+
96
+ ```json
97
+ {
98
+ "compilerOptions": {
99
+ "target": "ES2022",
100
+ "module": "ESNext",
101
+ "moduleResolution": "Bundler",
102
+ "outDir": "dist",
103
+ "rootDir": "src",
104
+ "strict": true,
105
+ "esModuleInterop": true,
106
+ "skipLibCheck": true,
107
+ "resolveJsonModule": true
108
+ },
109
+ "include": ["src"]
110
+ }
111
+ ```
112
+
113
+ - [ ] **Step 4: Create `vitest.config.ts`**
114
+
115
+ ```ts
116
+ import { defineConfig } from 'vitest/config'
117
+
118
+ export default defineConfig({
119
+ test: { environment: 'node', include: ['tests/**/*.test.ts'] },
120
+ })
121
+ ```
122
+
123
+ - [ ] **Step 5: Create `.gitignore`**
124
+
125
+ ```
126
+ node_modules/
127
+ dist/
128
+ *.log
129
+ ```
130
+
131
+ - [ ] **Step 6: Install and run the test**
132
+
133
+ Run: `npm install && npm test`
134
+ Expected: 1 passing test (`smoke > runs`).
135
+
136
+ - [ ] **Step 7: Commit**
137
+
138
+ ```bash
139
+ git add package.json tsconfig.json vitest.config.ts .gitignore tests/smoke.test.ts
140
+ git commit -m "chore: scaffold forge TS project with vitest"
141
+ ```
142
+
143
+ ---
144
+
145
+ ### Task 2: SKILL.md frontmatter parser
146
+
147
+ **Files:**
148
+ - Create: `src/canon/frontmatter.ts`
149
+ - Test: `tests/canon/frontmatter.test.ts`
150
+
151
+ - [ ] **Step 1: Write the failing test**
152
+
153
+ Create `tests/canon/frontmatter.test.ts`:
154
+ ```ts
155
+ import { describe, it, expect } from 'vitest'
156
+ import { parseFrontmatter } from '../../src/canon/frontmatter.js'
157
+
158
+ describe('parseFrontmatter', () => {
159
+ it('parses name and description from a --- block', () => {
160
+ const md = '---\nname: tdd\ndescription: Test-driven development\n---\n# Body\n'
161
+ expect(parseFrontmatter(md)).toMatchObject({ name: 'tdd', description: 'Test-driven development' })
162
+ })
163
+
164
+ it('tolerates CRLF line endings', () => {
165
+ const md = '---\r\nname: x\r\ndescription: y\r\n---\r\nbody'
166
+ expect(parseFrontmatter(md)).toMatchObject({ name: 'x', description: 'y' })
167
+ })
168
+
169
+ it('returns null when there is no frontmatter', () => {
170
+ expect(parseFrontmatter('# just a heading\n')).toBeNull()
171
+ })
172
+ })
173
+ ```
174
+
175
+ - [ ] **Step 2: Run test to verify it fails**
176
+
177
+ Run: `npm test -- frontmatter`
178
+ Expected: FAIL — cannot find module `src/canon/frontmatter.js`.
179
+
180
+ - [ ] **Step 3: Write minimal implementation**
181
+
182
+ Create `src/canon/frontmatter.ts`:
183
+ ```ts
184
+ import { parse } from 'yaml'
185
+
186
+ export function parseFrontmatter(content: string): Record<string, unknown> | null {
187
+ const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
188
+ if (!m) return null
189
+ const parsed = parse(m[1])
190
+ return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null
191
+ }
192
+ ```
193
+
194
+ - [ ] **Step 4: Run test to verify it passes**
195
+
196
+ Run: `npm test -- frontmatter`
197
+ Expected: PASS (3 tests).
198
+
199
+ - [ ] **Step 5: Commit**
200
+
201
+ ```bash
202
+ git add src/canon/frontmatter.ts tests/canon/frontmatter.test.ts
203
+ git commit -m "feat: add SKILL.md frontmatter parser"
204
+ ```
205
+
206
+ ---
207
+
208
+ ### Task 3: Manifest schema + loader
209
+
210
+ **Files:**
211
+ - Create: `src/canon/manifest.ts`
212
+ - Test: `tests/canon/manifest.test.ts`
213
+
214
+ - [ ] **Step 1: Write the failing test**
215
+
216
+ Create `tests/canon/manifest.test.ts`:
217
+ ```ts
218
+ import { describe, it, expect } from 'vitest'
219
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
220
+ import { join } from 'node:path'
221
+ import { tmpdir } from 'node:os'
222
+ import { loadManifest } from '../../src/canon/manifest.js'
223
+
224
+ function withManifest(yaml: string): string {
225
+ const dir = mkdtempSync(join(tmpdir(), 'forge-mani-'))
226
+ writeFileSync(join(dir, 'manifest.yaml'), yaml)
227
+ return join(dir, 'manifest.yaml')
228
+ }
229
+
230
+ describe('loadManifest', () => {
231
+ it('parses a valid manifest', () => {
232
+ const file = withManifest(`
233
+ name: forge-canon
234
+ version: 0.1.0
235
+ agents: [claude, codex, gemini]
236
+ skills:
237
+ - { id: tdd, path: skills/tdd, kind: methodology }
238
+ policy:
239
+ - { path: policy/gates.md }
240
+ loop: { spec: loop/loop-spec.md, prdSchema: loop/prd.schema.md }
241
+ tools:
242
+ - { id: rtk, path: tools/rtk.md }
243
+ `)
244
+ const m = loadManifest(file)
245
+ expect(m.name).toBe('forge-canon')
246
+ expect(m.agents).toEqual(['claude', 'codex', 'gemini'])
247
+ expect(m.skills[0]).toMatchObject({ id: 'tdd', kind: 'methodology' })
248
+ rmSync(join(file, '..'), { recursive: true, force: true })
249
+ })
250
+
251
+ it('rejects an unknown agent', () => {
252
+ const file = withManifest(`
253
+ name: x
254
+ version: 0.1.0
255
+ agents: [claude, mystery]
256
+ skills: []
257
+ policy: []
258
+ loop: { spec: a, prdSchema: b }
259
+ tools: []
260
+ `)
261
+ expect(() => loadManifest(file)).toThrow()
262
+ rmSync(join(file, '..'), { recursive: true, force: true })
263
+ })
264
+ })
265
+ ```
266
+
267
+ - [ ] **Step 2: Run test to verify it fails**
268
+
269
+ Run: `npm test -- manifest`
270
+ Expected: FAIL — cannot find module `src/canon/manifest.js`.
271
+
272
+ - [ ] **Step 3: Write minimal implementation**
273
+
274
+ Create `src/canon/manifest.ts`:
275
+ ```ts
276
+ import { z } from 'zod'
277
+ import { parse } from 'yaml'
278
+ import { readFileSync } from 'node:fs'
279
+
280
+ export const AgentSchema = z.enum(['claude', 'codex', 'gemini'])
281
+
282
+ export const SkillEntrySchema = z.object({
283
+ id: z.string().min(1),
284
+ path: z.string().min(1),
285
+ kind: z.enum(['methodology', 'role']),
286
+ })
287
+
288
+ export const ToolEntrySchema = z.object({
289
+ id: z.string().min(1),
290
+ path: z.string().min(1),
291
+ })
292
+
293
+ export const ManifestSchema = z.object({
294
+ name: z.string().min(1),
295
+ version: z.string().min(1),
296
+ agents: z.array(AgentSchema).min(1),
297
+ skills: z.array(SkillEntrySchema),
298
+ policy: z.array(z.object({ path: z.string().min(1) })),
299
+ loop: z.object({ spec: z.string().min(1), prdSchema: z.string().min(1) }),
300
+ tools: z.array(ToolEntrySchema),
301
+ })
302
+
303
+ export type Manifest = z.infer<typeof ManifestSchema>
304
+
305
+ export function loadManifest(file: string): Manifest {
306
+ const raw = parse(readFileSync(file, 'utf8'))
307
+ return ManifestSchema.parse(raw)
308
+ }
309
+ ```
310
+
311
+ - [ ] **Step 4: Run test to verify it passes**
312
+
313
+ Run: `npm test -- manifest`
314
+ Expected: PASS (2 tests).
315
+
316
+ - [ ] **Step 5: Commit**
317
+
318
+ ```bash
319
+ git add src/canon/manifest.ts tests/canon/manifest.test.ts
320
+ git commit -m "feat: add canon manifest schema and loader"
321
+ ```
322
+
323
+ ---
324
+
325
+ ### Task 4: Validator core
326
+
327
+ **Files:**
328
+ - Create: `src/canon/validate.ts`
329
+ - Test: `tests/canon/validate.test.ts`
330
+
331
+ - [ ] **Step 1: Write the failing test**
332
+
333
+ Create `tests/canon/validate.test.ts`:
334
+ ```ts
335
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
336
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
337
+ import { join } from 'node:path'
338
+ import { tmpdir } from 'node:os'
339
+ import { validateCanon } from '../../src/canon/validate.js'
340
+
341
+ let dir: string
342
+
343
+ function write(rel: string, content: string) {
344
+ const full = join(dir, rel)
345
+ mkdirSync(join(full, '..'), { recursive: true })
346
+ writeFileSync(full, content)
347
+ }
348
+
349
+ function seedValidCanon() {
350
+ write('manifest.yaml', `
351
+ name: c
352
+ version: 0.1.0
353
+ agents: [claude]
354
+ skills:
355
+ - { id: tdd, path: skills/tdd, kind: methodology }
356
+ policy:
357
+ - { path: policy/gates.md }
358
+ loop: { spec: loop/loop-spec.md, prdSchema: loop/prd.schema.md }
359
+ tools:
360
+ - { id: rtk, path: tools/rtk.md }
361
+ `)
362
+ write('skills/tdd/SKILL.md', '---\nname: tdd\ndescription: d\n---\nbody')
363
+ write('policy/gates.md', 'gates')
364
+ write('loop/loop-spec.md', 'loop')
365
+ write('loop/prd.schema.md', 'prd')
366
+ write('tools/rtk.md', 'rtk')
367
+ }
368
+
369
+ beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'forge-canon-')) })
370
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
371
+
372
+ describe('validateCanon', () => {
373
+ it('returns no errors for a well-formed canon', () => {
374
+ seedValidCanon()
375
+ const errors = validateCanon(dir).filter(i => i.level === 'error')
376
+ expect(errors).toEqual([])
377
+ })
378
+
379
+ it('flags a missing manifest', () => {
380
+ const issues = validateCanon(dir)
381
+ expect(issues.some(i => i.message.includes('manifest.yaml not found'))).toBe(true)
382
+ })
383
+
384
+ it('flags a skill whose SKILL.md is missing', () => {
385
+ seedValidCanon()
386
+ rmSync(join(dir, 'skills/tdd/SKILL.md'))
387
+ expect(validateCanon(dir).some(i => i.message.includes('SKILL.md missing'))).toBe(true)
388
+ })
389
+
390
+ it('flags a skill with no frontmatter name', () => {
391
+ seedValidCanon()
392
+ write('skills/tdd/SKILL.md', '---\ndescription: d\n---\nbody')
393
+ expect(validateCanon(dir).some(i => i.message.includes('missing name'))).toBe(true)
394
+ })
395
+
396
+ it('flags a missing policy file', () => {
397
+ seedValidCanon()
398
+ rmSync(join(dir, 'policy/gates.md'))
399
+ expect(validateCanon(dir).some(i => i.message.includes('policy file not found'))).toBe(true)
400
+ })
401
+
402
+ it('flags duplicate skill ids', () => {
403
+ seedValidCanon()
404
+ write('manifest.yaml', `
405
+ name: c
406
+ version: 0.1.0
407
+ agents: [claude]
408
+ skills:
409
+ - { id: tdd, path: skills/tdd, kind: methodology }
410
+ - { id: tdd, path: skills/tdd, kind: role }
411
+ policy:
412
+ - { path: policy/gates.md }
413
+ loop: { spec: loop/loop-spec.md, prdSchema: loop/prd.schema.md }
414
+ tools:
415
+ - { id: rtk, path: tools/rtk.md }
416
+ `)
417
+ expect(validateCanon(dir).some(i => i.message.includes('duplicate skill id'))).toBe(true)
418
+ })
419
+ })
420
+ ```
421
+
422
+ - [ ] **Step 2: Run test to verify it fails**
423
+
424
+ Run: `npm test -- validate`
425
+ Expected: FAIL — cannot find module `src/canon/validate.js`.
426
+
427
+ - [ ] **Step 3: Write minimal implementation**
428
+
429
+ Create `src/canon/validate.ts`:
430
+ ```ts
431
+ import { existsSync, readFileSync, statSync } from 'node:fs'
432
+ import { join } from 'node:path'
433
+ import { loadManifest } from './manifest.js'
434
+ import { parseFrontmatter } from './frontmatter.js'
435
+
436
+ export interface Issue {
437
+ level: 'error' | 'warn'
438
+ message: string
439
+ }
440
+
441
+ export function validateCanon(canonDir: string): Issue[] {
442
+ const issues: Issue[] = []
443
+ const manifestPath = join(canonDir, 'manifest.yaml')
444
+ if (!existsSync(manifestPath)) {
445
+ return [{ level: 'error', message: `manifest.yaml not found in ${canonDir}` }]
446
+ }
447
+
448
+ let manifest
449
+ try {
450
+ manifest = loadManifest(manifestPath)
451
+ } catch (e) {
452
+ return [{ level: 'error', message: `manifest.yaml invalid: ${(e as Error).message}` }]
453
+ }
454
+
455
+ const seenSkill = new Set<string>()
456
+ for (const s of manifest.skills) {
457
+ if (seenSkill.has(s.id)) issues.push({ level: 'error', message: `duplicate skill id: ${s.id}` })
458
+ seenSkill.add(s.id)
459
+ const dir = join(canonDir, s.path)
460
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) {
461
+ issues.push({ level: 'error', message: `skill ${s.id}: path not found: ${s.path}` })
462
+ continue
463
+ }
464
+ const skillMd = join(dir, 'SKILL.md')
465
+ if (!existsSync(skillMd)) {
466
+ issues.push({ level: 'error', message: `skill ${s.id}: SKILL.md missing` })
467
+ continue
468
+ }
469
+ const fm = parseFrontmatter(readFileSync(skillMd, 'utf8'))
470
+ if (!fm) {
471
+ issues.push({ level: 'error', message: `skill ${s.id}: SKILL.md has no frontmatter` })
472
+ } else {
473
+ if (!fm.name) issues.push({ level: 'error', message: `skill ${s.id}: frontmatter missing name` })
474
+ if (!fm.description) issues.push({ level: 'error', message: `skill ${s.id}: frontmatter missing description` })
475
+ }
476
+ }
477
+
478
+ for (const p of manifest.policy) {
479
+ if (!existsSync(join(canonDir, p.path))) {
480
+ issues.push({ level: 'error', message: `policy file not found: ${p.path}` })
481
+ }
482
+ }
483
+
484
+ const loopChecks: ReadonlyArray<readonly [string, string]> = [
485
+ ['loop.spec', manifest.loop.spec],
486
+ ['loop.prdSchema', manifest.loop.prdSchema],
487
+ ]
488
+ for (const [label, rel] of loopChecks) {
489
+ if (!existsSync(join(canonDir, rel))) {
490
+ issues.push({ level: 'error', message: `${label} not found: ${rel}` })
491
+ }
492
+ }
493
+
494
+ const seenTool = new Set<string>()
495
+ for (const t of manifest.tools) {
496
+ if (seenTool.has(t.id)) issues.push({ level: 'error', message: `duplicate tool id: ${t.id}` })
497
+ seenTool.add(t.id)
498
+ if (!existsSync(join(canonDir, t.path))) {
499
+ issues.push({ level: 'error', message: `tool ${t.id}: path not found: ${t.path}` })
500
+ }
501
+ }
502
+
503
+ return issues
504
+ }
505
+ ```
506
+
507
+ - [ ] **Step 4: Run test to verify it passes**
508
+
509
+ Run: `npm test -- validate`
510
+ Expected: PASS (6 tests).
511
+
512
+ - [ ] **Step 5: Commit**
513
+
514
+ ```bash
515
+ git add src/canon/validate.ts tests/canon/validate.test.ts
516
+ git commit -m "feat: add canon validator"
517
+ ```
518
+
519
+ ---
520
+
521
+ ### Task 5: CLI wiring (`forge validate`)
522
+
523
+ **Files:**
524
+ - Create: `src/cli.ts`
525
+
526
+ - [ ] **Step 1: Write the implementation**
527
+
528
+ Create `src/cli.ts`:
529
+ ```ts
530
+ #!/usr/bin/env node
531
+ import { validateCanon } from './canon/validate.js'
532
+
533
+ export function runValidate(canonDir: string): number {
534
+ const issues = validateCanon(canonDir)
535
+ for (const i of issues) {
536
+ console.log(`${i.level === 'error' ? 'ERROR' : 'warn '} ${i.message}`)
537
+ }
538
+ const errors = issues.filter(i => i.level === 'error')
539
+ if (errors.length === 0) {
540
+ console.log(`✓ canon valid (${canonDir})`)
541
+ return 0
542
+ }
543
+ console.log(`✗ ${errors.length} error(s)`)
544
+ return 1
545
+ }
546
+
547
+ function main(argv: string[]): number {
548
+ const [cmd, ...rest] = argv
549
+ switch (cmd) {
550
+ case 'validate':
551
+ return runValidate(rest[0] ?? 'canon')
552
+ default:
553
+ console.log('usage: forge validate [canonDir]')
554
+ return cmd ? 1 : 0
555
+ }
556
+ }
557
+
558
+ process.exit(main(process.argv.slice(2)))
559
+ ```
560
+
561
+ - [ ] **Step 2: Verify the CLI runs against a temp canon**
562
+
563
+ Run (PowerShell):
564
+ ```
565
+ npm run forge -- validate canon
566
+ ```
567
+ Expected: prints errors (canon not seeded yet) and exits non-zero. This confirms the command is wired; Task 6 seeds the real canon so it passes.
568
+
569
+ - [ ] **Step 3: Commit**
570
+
571
+ ```bash
572
+ git add src/cli.ts
573
+ git commit -m "feat: wire forge validate CLI command"
574
+ ```
575
+
576
+ ---
577
+
578
+ ### Task 6: Seed the real Canon
579
+
580
+ **Files:**
581
+ - Create: `canon/AGENTS.md`, `canon/manifest.yaml`, `canon/skills/tdd/SKILL.md`, `canon/skills/eng-review/SKILL.md`, `canon/policy/gates.md`, `canon/policy/roles.md`, `canon/loop/loop-spec.md`, `canon/loop/prd.schema.md`, `canon/tools/rtk.md`, `canon/tools/graphify.md`, `canon/tools/playwright-mcp.md`
582
+ - Test: `tests/canon/real-canon.test.ts`
583
+
584
+ - [ ] **Step 1: Write the failing integration test**
585
+
586
+ Create `tests/canon/real-canon.test.ts`:
587
+ ```ts
588
+ import { describe, it, expect } from 'vitest'
589
+ import { fileURLToPath } from 'node:url'
590
+ import { join } from 'node:path'
591
+ import { validateCanon } from '../../src/canon/validate.js'
592
+
593
+ const repoRoot = fileURLToPath(new URL('../../', import.meta.url))
594
+
595
+ describe('real canon', () => {
596
+ it('validates with zero errors', () => {
597
+ const errors = validateCanon(join(repoRoot, 'canon')).filter(i => i.level === 'error')
598
+ expect(errors).toEqual([])
599
+ })
600
+ })
601
+ ```
602
+
603
+ - [ ] **Step 2: Run test to verify it fails**
604
+
605
+ Run: `npm test -- real-canon`
606
+ Expected: FAIL — `manifest.yaml not found in .../canon`.
607
+
608
+ - [ ] **Step 3: Create `canon/manifest.yaml`**
609
+
610
+ ```yaml
611
+ name: forge-canon
612
+ version: 0.1.0
613
+ agents: [claude, codex, gemini]
614
+ skills:
615
+ - { id: tdd, path: skills/tdd, kind: methodology }
616
+ - { id: eng-review, path: skills/eng-review, kind: role }
617
+ policy:
618
+ - { path: policy/gates.md }
619
+ - { path: policy/roles.md }
620
+ loop:
621
+ spec: loop/loop-spec.md
622
+ prdSchema: loop/prd.schema.md
623
+ tools:
624
+ - { id: rtk, path: tools/rtk.md }
625
+ - { id: graphify, path: tools/graphify.md }
626
+ - { id: playwright-mcp, path: tools/playwright-mcp.md }
627
+ ```
628
+
629
+ - [ ] **Step 4: Create `canon/AGENTS.md`**
630
+
631
+ ```markdown
632
+ # Forge Harness — Agent Baseline
633
+
634
+ You are operating in a project retrofitted by Forge. Follow these always:
635
+
636
+ - **Quality first:** Test-driven development is the default. No production code without a failing test first. See skill `tdd`.
637
+ - **Stop-the-Line:** Do not start implementation until Definition of Done / Acceptance Criteria are written. See `policy/gates.md`.
638
+ - **Role separation:** The agent that implements does not self-review, self-merge, or self-audit security. See `policy/roles.md`.
639
+ - **Context efficiency:** Prefer the wired tools (rtk for command output, the code-graph for symbol lookup) over reading whole files. See `tools/`.
640
+
641
+ This file is the portable baseline. Agent-specific instructions are generated alongside it (CLAUDE.md, GEMINI.md).
642
+ ```
643
+
644
+ - [ ] **Step 5: Create `canon/skills/tdd/SKILL.md`**
645
+
646
+ ```markdown
647
+ ---
648
+ name: tdd
649
+ description: Use when writing any production code — enforces RED-GREEN-REFACTOR, no production code without a failing test first.
650
+ ---
651
+
652
+ # Test-Driven Development
653
+
654
+ The Iron Law: **No production code without a failing test first.**
655
+
656
+ 1. **RED** — write the smallest failing test for the next behavior. Run it; confirm it fails for the right reason.
657
+ 2. **GREEN** — write the minimal code to make it pass. Run it; confirm it passes.
658
+ 3. **REFACTOR** — clean up without changing behavior. Tests stay green.
659
+ 4. **COMMIT** — one behavior per commit.
660
+
661
+ Never write multiple tests ahead of implementation. Never skip the failing-run step.
662
+ ```
663
+
664
+ - [ ] **Step 6: Create `canon/skills/eng-review/SKILL.md`**
665
+
666
+ ```markdown
667
+ ---
668
+ name: eng-review
669
+ description: Use before merging a change — engineering-manager review of architecture, edge cases, test coverage, and performance.
670
+ ---
671
+
672
+ # Engineering Review
673
+
674
+ Review a change for: architecture fit, data flow, edge cases, test coverage, and performance. Be opinionated. Block on:
675
+
676
+ - Missing or weak tests for the changed behavior.
677
+ - Unhandled error paths or edge cases.
678
+ - Architectural drift from the project's established patterns.
679
+
680
+ Output: a pass/block verdict with specific, actionable findings. A reviewer never reviews their own implementation (see `policy/roles.md`).
681
+ ```
682
+
683
+ - [ ] **Step 7: Create `canon/policy/gates.md`**
684
+
685
+ ```markdown
686
+ # Stop-the-Line Gates
687
+
688
+ These gates are enforced mechanically by the Loop (Baustein C) and expected of every agent run.
689
+
690
+ - **DoD gate:** Implementation may not begin until Definition of Done / Acceptance Criteria for the unit of work exist and are recorded (e.g., in the PRD story).
691
+ - **Green-tests gate:** A unit of work is not complete until its tests pass.
692
+ - **Clean-worktree gate:** No dispatch into a dirty or conflicted git worktree.
693
+ ```
694
+
695
+ - [ ] **Step 8: Create `canon/policy/roles.md`**
696
+
697
+ ```markdown
698
+ # Role Separation
699
+
700
+ The agent that performs a role must not also perform a conflicting one:
701
+
702
+ - **Implementer ≠ Reviewer** — implementation is not self-reviewed.
703
+ - **Implementer ≠ Merger** — the implementer does not merge their own change.
704
+ - **Implementer ≠ Security auditor** — security is not self-audited.
705
+
706
+ In the Loop, these map to separate iterations with fresh context.
707
+ ```
708
+
709
+ - [ ] **Step 9: Create `canon/loop/loop-spec.md`**
710
+
711
+ ```markdown
712
+ # Loop Specification (Ralph + GSD)
713
+
714
+ The autonomous loop is OPTIONAL and toggle-able (`forge loop on|off`). When enabled:
715
+
716
+ 1. Pre-dispatch gates: missing tools / dirty worktree / git conflict → `blocked`.
717
+ 2. Pick the highest-priority unfinished PRD story.
718
+ 3. Stop-the-Line gate: DoD/AC present, else `blocked`.
719
+ 4. Spawn a fresh agent (claude -p | codex exec | gemini) in a git worktree.
720
+ 5. Implement ONE story → tests green → review iteration (different role).
721
+ 6. Update the PRD (`passes: true`) + atomic commit.
722
+ 7. Stop when all stories `passes: true`, or the iteration cap is reached.
723
+
724
+ State lives outside the model context: the PRD file + git. Full driver is built in Baustein C.
725
+ ```
726
+
727
+ - [ ] **Step 10: Create `canon/loop/prd.schema.md`**
728
+
729
+ ```markdown
730
+ # PRD Schema
731
+
732
+ The loop is driven by a versioned PRD file. Each story:
733
+
734
+ ```yaml
735
+ - id: STORY-1
736
+ title: Short imperative description
737
+ priority: 1 # lower = higher priority
738
+ acceptance: # Definition of Done (required before implementation)
739
+ - The endpoint returns 200 for a valid request.
740
+ passes: false # set true only when acceptance is met and tests are green
741
+ ```
742
+
743
+ Stop condition: every story has `passes: true`.
744
+ ```
745
+
746
+ - [ ] **Step 11: Create `canon/tools/rtk.md`**
747
+
748
+ ```markdown
749
+ # Tool: rtk (token compression)
750
+
751
+ Per-agent wiring (generated by Baustein B):
752
+
753
+ - **Claude Code:** PreToolUse hook auto-rewrites commands (`git status` → `rtk git status`). On Windows this needs WSL; otherwise fall back to instruction mode (this file injected into CLAUDE.md telling the agent to prefix commands with `rtk`).
754
+ - **Codex CLI:** inject `RTK.md` / AGENTS.md instruction.
755
+ - **Gemini CLI:** no hook system — register rtk as an MCP tool or inject the GEMINI.md instruction.
756
+ ```
757
+
758
+ - [ ] **Step 12: Create `canon/tools/graphify.md`**
759
+
760
+ ```markdown
761
+ # Tool: graphify (code-graph)
762
+
763
+ MIT, multimodal code/doc graph. Wired as an MCP server for all three agents (stdio). Prefer symbol/graph lookups over reading whole files. Caveat: heuristic edges (INFERRED/AMBIGUOUS) and a static index that can go stale — rebuild on significant changes.
764
+ ```
765
+
766
+ - [ ] **Step 13: Create `canon/tools/playwright-mcp.md`**
767
+
768
+ ```markdown
769
+ # Tool: Playwright MCP (browser / dogfooding)
770
+
771
+ Microsoft Playwright MCP, Apache-2.0. Wired as an MCP server for all three agents — the only browser tool with native MCP parity across Claude/Codex/Gemini. Used for QA, dogfooding user flows, screenshots, and deploy verification.
772
+ ```
773
+
774
+ - [ ] **Step 14: Run the integration test**
775
+
776
+ Run: `npm test -- real-canon`
777
+ Expected: PASS — the real canon validates with zero errors.
778
+
779
+ - [ ] **Step 15: Run the full suite and the CLI**
780
+
781
+ Run: `npm test`
782
+ Expected: all tests pass.
783
+
784
+ Run (PowerShell): `npm run forge -- validate canon`
785
+ Expected: `✓ canon valid (canon)` and exit 0.
786
+
787
+ - [ ] **Step 16: Commit**
788
+
789
+ ```bash
790
+ git add canon tests/canon/real-canon.test.ts
791
+ git commit -m "feat: seed harness-agnostic canon and validate it"
792
+ ```
793
+
794
+ ---
795
+
796
+ ## Self-Review
797
+
798
+ **1. Spec coverage (Baustein A scope):**
799
+ - Canon structure (skills/policy/loop/tools/AGENTS.md/manifest) → Task 6 ✓
800
+ - Manifest as source-of-truth declaration → Tasks 3, 6 ✓
801
+ - Harness-agnostic (no agent paths in canon) → Task 6 content ✓
802
+ - Independently testable software (`forge validate`) → Tasks 2–5 ✓
803
+ - Windows-native, no Make → Node/npm scripts only ✓
804
+ - (Deferred to Plan B: runtime generation/detect/wire. Deferred to Plan C: loop driver. Correct — A is content + validator only.)
805
+
806
+ **2. Placeholder scan:** No TBD/TODO; every file has complete content; every code step shows full code. ✓
807
+
808
+ **3. Type consistency:** `Issue{level,message}`, `Manifest`, `loadManifest()`, `parseFrontmatter()`, `validateCanon()`, `runValidate()` — names used consistently across tasks. Manifest YAML field names (`skills/policy/loop/tools`, `loop.spec`, `loop.prdSchema`, skill `kind`) match between schema (Task 3) and seed (Task 6). ✓
809
+
810
+ ---
811
+
812
+ ## Next Plans (not this document)
813
+
814
+ - **Plan B — Retrofit-Skill:** `forge detect|plan|generate|wire|report`, runtime artifact generation per agent, non-destructive, asks about loop opt-in.
815
+ - **Plan C — Loop-Engine:** the Ralph driver, gates, worktree isolation, `forge loop on|off|status`.