@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,574 @@
1
+ # Baustein M — Flow-Smoke with Proofs Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** `yoke flow-smoke` — a built-in browser gate that loads configured flows, asserts landmark + zero console errors, always saves screenshots to `.yoke/proof/<label>/`, keeps video only on failure; the loop labels proofs per story via `YOKE_STORY`.
6
+
7
+ **Architecture:** One async command module (`src/smoke/command.ts`) with a structural `SmokeBrowser` interface; the real path adapts Playwright resolved **from the target project** (never a Yoke dependency); tests inject a filesystem-level fake. Config gains an optional `smoke` section. `main()` in cli.ts becomes awaitable.
8
+
9
+ **Tech Stack:** Node 20 ESM TypeScript (`.js` import specifiers!), vitest, zod, yaml. No new dependencies (Playwright is a *target-project* dependency).
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-07-02-baustein-m-flow-smoke-proofs-design.md`
12
+
13
+ **Pitfalls for the implementer:**
14
+ - rtk proxy mangles grep output — verify test results ONLY via `npx vitest run --reporter=json --outputFile=.yoke-test.json` + node parse; delete the file before committing. Conda `pydantic_core` stderr banner = harmless noise.
15
+ - `npx tsc --noEmit` before every commit. ESM `.js` import specifiers.
16
+ - **Planned deviation from the spec:** the spec's `opts.browser?: () => Promise<SmokeBrowser>` seam is generalized to `opts.launch?: (targetDir: string) => Promise<SmokeBrowser | null>` (null = "playwright unresolvable"). This makes the exit-2 path deterministically testable without depending on ambient playwright resolution on the dev machine. Record this in your final report.
17
+
18
+ ---
19
+
20
+ ### Task 1: `smoke` config section
21
+
22
+ **Files:**
23
+ - Modify: `src/retrofit/config.ts`
24
+ - Test: `tests/retrofit/config.test.ts` (extend; if that exact file doesn't exist, extend the existing test file that covers `loadConfig`/`saveConfig` — find it with Glob `tests/retrofit/*config*`)
25
+
26
+ - [ ] **Step 1: Write the failing tests** (append to the config test file, reusing its temp-dir helpers):
27
+
28
+ ```ts
29
+ it('round-trips a smoke section', () => {
30
+ const config = { ...defaultConfig('1.0.0'), smoke: { baseUrl: 'http://localhost:3000', flows: [{ name: 'home', path: '/', landmark: 'main h1' }, { name: 'login', path: '/login' }] } }
31
+ saveConfig(dir, config)
32
+ expect(loadConfig(dir)?.smoke?.flows).toHaveLength(2)
33
+ expect(loadConfig(dir)?.smoke?.flows[0].landmark).toBe('main h1')
34
+ })
35
+
36
+ it('config without smoke stays valid', () => {
37
+ saveConfig(dir, defaultConfig('1.0.0'))
38
+ expect(loadConfig(dir)?.smoke).toBeUndefined()
39
+ })
40
+
41
+ it('rejects a smoke section with empty flows', () => {
42
+ writeFileSync(join(dir, '.yoke', 'config.yaml'), 'canonVersion: "1"\nagents: []\nloop:\n enabled: false\nsmoke:\n baseUrl: http://x\n flows: []\n')
43
+ expect(() => loadConfig(dir)).toThrow()
44
+ })
45
+ ```
46
+
47
+ - [ ] **Step 2: Run to verify failure.**
48
+
49
+ - [ ] **Step 3: Implement** in `src/retrofit/config.ts` — above `YokeConfigSchema`:
50
+
51
+ ```ts
52
+ const SmokeFlowSchema = z.object({ name: z.string().min(1), path: z.string().min(1), landmark: z.string().optional() })
53
+ const SmokeSchema = z.object({ baseUrl: z.string().min(1), flows: z.array(SmokeFlowSchema).min(1) })
54
+ ```
55
+
56
+ Add `smoke: SmokeSchema.optional(),` to `YokeConfigSchema` and the interfaces:
57
+
58
+ ```ts
59
+ export interface SmokeFlow { name: string; path: string; landmark?: string }
60
+ export interface SmokeConfig { baseUrl: string; flows: SmokeFlow[] }
61
+ ```
62
+
63
+ plus `smoke?: SmokeConfig` on `YokeConfig`.
64
+
65
+ - [ ] **Step 4: Run** — config tests PASS, `npx tsc --noEmit` clean.
66
+
67
+ - [ ] **Step 5: Commit** — `feat(config): optional smoke section (baseUrl + flows)`
68
+
69
+ ---
70
+
71
+ ### Task 2: `runFlowSmoke` core with fake browser
72
+
73
+ **Files:**
74
+ - Create: `src/smoke/command.ts`
75
+ - Test: `tests/smoke/command.test.ts`
76
+
77
+ - [ ] **Step 1: Write the failing tests:**
78
+
79
+ ```ts
80
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
81
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readdirSync } from 'node:fs'
82
+ import { tmpdir } from 'node:os'
83
+ import { dirname, join } from 'node:path'
84
+ import { runFlowSmoke, type SmokeBrowser, type SmokePage } from '../../src/smoke/command.js'
85
+ import { saveConfig, defaultConfig, type SmokeConfig } from '../../src/retrofit/config.js'
86
+
87
+ let dir: string
88
+ beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'yoke-smoke-')) })
89
+ afterEach(() => {
90
+ rmSync(dir, { recursive: true, force: true })
91
+ delete process.env.YOKE_STORY
92
+ })
93
+
94
+ function withSmoke(smoke: SmokeConfig) {
95
+ saveConfig(dir, { ...defaultConfig('1.0.0'), smoke })
96
+ }
97
+
98
+ interface FakeBehavior {
99
+ status?: number // default 200
100
+ landmarkFound?: boolean // default true
101
+ consoleErrors?: string[] // default []
102
+ gotoThrows?: string
103
+ }
104
+
105
+ let vid = 0
106
+ function fakeLaunch(behavior: FakeBehavior): (targetDir: string) => Promise<SmokeBrowser | null> {
107
+ return async () => ({
108
+ async newContext(opts?: { recordVideo?: { dir: string } }) {
109
+ const videoDir = opts?.recordVideo?.dir
110
+ let videoPath: string | null = null
111
+ if (videoDir) {
112
+ mkdirSync(videoDir, { recursive: true })
113
+ videoPath = join(videoDir, `v${vid++}.webm`)
114
+ writeFileSync(videoPath, 'vid')
115
+ }
116
+ const handlers: Record<string, ((a: unknown) => void)[]> = { console: [], pageerror: [] }
117
+ const page: SmokePage = {
118
+ async goto() {
119
+ if (behavior.gotoThrows) throw new Error(behavior.gotoThrows)
120
+ for (const e of behavior.consoleErrors ?? []) {
121
+ for (const h of handlers.console) h({ type: () => 'error', text: () => e })
122
+ }
123
+ const status = behavior.status ?? 200
124
+ return { ok: () => status >= 200 && status < 300, status: () => status }
125
+ },
126
+ async waitForSelector() {
127
+ if (behavior.landmarkFound === false) throw new Error('timeout')
128
+ return {}
129
+ },
130
+ async screenshot({ path }: { path: string }) {
131
+ mkdirSync(dirname(path), { recursive: true })
132
+ writeFileSync(path, 'png')
133
+ },
134
+ on(event: 'console' | 'pageerror', handler: (a: unknown) => void) { handlers[event].push(handler) },
135
+ video: () => (videoPath ? { path: async () => videoPath as string } : null),
136
+ }
137
+ return { newPage: async () => page, close: async () => {} }
138
+ },
139
+ async close() {},
140
+ })
141
+ }
142
+
143
+ const HOME = { name: 'home', path: '/', landmark: 'main h1' }
144
+
145
+ describe('runFlowSmoke', () => {
146
+ it('exits 2 with guidance when there is no smoke config', async () => {
147
+ saveConfig(dir, defaultConfig('1.0.0'))
148
+ expect(await runFlowSmoke(dir, { launch: fakeLaunch({}) })).toBe(2)
149
+ })
150
+
151
+ it('exits 2 when playwright cannot be resolved (launch returns null)', async () => {
152
+ withSmoke({ baseUrl: 'http://x', flows: [HOME] })
153
+ expect(await runFlowSmoke(dir, { launch: async () => null })).toBe(2)
154
+ })
155
+
156
+ it('green flow: exit 0, screenshot saved, video deleted', async () => {
157
+ withSmoke({ baseUrl: 'http://x', flows: [HOME] })
158
+ const code = await runFlowSmoke(dir, { launch: fakeLaunch({}) })
159
+ expect(code).toBe(0)
160
+ const proof = join(dir, '.yoke', 'proof', 'latest')
161
+ expect(existsSync(join(proof, 'home.png'))).toBe(true)
162
+ expect(readdirSync(proof).some(f => f.endsWith('.webm'))).toBe(false)
163
+ expect(existsSync(join(proof, '.video-tmp'))).toBe(false)
164
+ })
165
+
166
+ it('landmark timeout: exit 1, screenshot still saved, video kept', async () => {
167
+ withSmoke({ baseUrl: 'http://x', flows: [HOME] })
168
+ const code = await runFlowSmoke(dir, { launch: fakeLaunch({ landmarkFound: false }) })
169
+ expect(code).toBe(1)
170
+ const proof = join(dir, '.yoke', 'proof', 'latest')
171
+ expect(existsSync(join(proof, 'home.png'))).toBe(true)
172
+ expect(existsSync(join(proof, 'home.webm'))).toBe(true)
173
+ })
174
+
175
+ it('console errors fail the flow', async () => {
176
+ withSmoke({ baseUrl: 'http://x', flows: [HOME] })
177
+ expect(await runFlowSmoke(dir, { launch: fakeLaunch({ consoleErrors: ['boom'] }) })).toBe(1)
178
+ })
179
+
180
+ it('a non-OK response fails the flow', async () => {
181
+ withSmoke({ baseUrl: 'http://x', flows: [HOME] })
182
+ expect(await runFlowSmoke(dir, { launch: fakeLaunch({ status: 500 }) })).toBe(1)
183
+ })
184
+
185
+ it('a goto crash fails the flow but still screenshots', async () => {
186
+ withSmoke({ baseUrl: 'http://x', flows: [HOME] })
187
+ const code = await runFlowSmoke(dir, { launch: fakeLaunch({ gotoThrows: 'net::ERR_CONNECTION_REFUSED' }) })
188
+ expect(code).toBe(1)
189
+ expect(existsSync(join(dir, '.yoke', 'proof', 'latest', 'home.png'))).toBe(true)
190
+ })
191
+
192
+ it('label resolution: --label beats YOKE_STORY beats latest', async () => {
193
+ withSmoke({ baseUrl: 'http://x', flows: [{ name: 'home', path: '/' }] })
194
+ process.env.YOKE_STORY = 'S7'
195
+ await runFlowSmoke(dir, { launch: fakeLaunch({}) })
196
+ expect(existsSync(join(dir, '.yoke', 'proof', 'S7', 'home.png'))).toBe(true)
197
+ await runFlowSmoke(dir, { launch: fakeLaunch({}), label: 'manual' })
198
+ expect(existsSync(join(dir, '.yoke', 'proof', 'manual', 'home.png'))).toBe(true)
199
+ })
200
+
201
+ it('wipes the label dir before a run', async () => {
202
+ withSmoke({ baseUrl: 'http://x', flows: [{ name: 'home', path: '/' }] })
203
+ const proof = join(dir, '.yoke', 'proof', 'latest')
204
+ mkdirSync(proof, { recursive: true })
205
+ writeFileSync(join(proof, 'stale.png'), 'old')
206
+ await runFlowSmoke(dir, { launch: fakeLaunch({}) })
207
+ expect(existsSync(join(proof, 'stale.png'))).toBe(false)
208
+ expect(existsSync(join(proof, 'home.png'))).toBe(true)
209
+ })
210
+
211
+ it('a failing flow does not stop later flows', async () => {
212
+ withSmoke({ baseUrl: 'http://x', flows: [HOME, { name: 'about', path: '/about' }] })
213
+ // landmarkFound:false only affects flows WITH a landmark — about has none and passes
214
+ const code = await runFlowSmoke(dir, { launch: fakeLaunch({ landmarkFound: false }) })
215
+ expect(code).toBe(1)
216
+ const proof = join(dir, '.yoke', 'proof', 'latest')
217
+ expect(existsSync(join(proof, 'home.png'))).toBe(true)
218
+ expect(existsSync(join(proof, 'about.png'))).toBe(true)
219
+ })
220
+
221
+ it('--url overrides baseUrl (fake records the target url)', async () => {
222
+ withSmoke({ baseUrl: 'http://x', flows: [{ name: 'home', path: '/p' }] })
223
+ const seen: string[] = []
224
+ const launch = fakeLaunch({})
225
+ const spying: typeof launch = async (t) => {
226
+ const b = await launch(t)
227
+ if (!b) return null
228
+ const orig = b.newContext.bind(b)
229
+ b.newContext = async (o?: object) => {
230
+ const ctx = await orig(o)
231
+ const origPage = ctx.newPage.bind(ctx)
232
+ ctx.newPage = async () => {
233
+ const p = await origPage()
234
+ const g = p.goto.bind(p)
235
+ p.goto = async (url: string, o2?: object) => { seen.push(url); return g(url, o2) }
236
+ return p
237
+ }
238
+ return ctx
239
+ }
240
+ return b
241
+ }
242
+ await runFlowSmoke(dir, { launch: spying, url: 'http://override:9999' })
243
+ expect(seen[0]).toBe('http://override:9999/p')
244
+ })
245
+ })
246
+ ```
247
+
248
+ - [ ] **Step 2: Run to verify failure** — module not found.
249
+
250
+ - [ ] **Step 3: Implement `src/smoke/command.ts`:**
251
+
252
+ ```ts
253
+ import { existsSync, mkdirSync, rmSync, renameSync } from 'node:fs'
254
+ import { join } from 'node:path'
255
+ import { createRequire } from 'node:module'
256
+ import { pathToFileURL } from 'node:url'
257
+ import { loadConfig } from '../retrofit/config.js'
258
+
259
+ // Structural browser interface: the real path adapts Playwright's chromium,
260
+ // tests inject a filesystem-level fake. Playwright is a TARGET-project
261
+ // dependency, never Yoke's.
262
+ export interface SmokePage {
263
+ goto(url: string, opts?: object): Promise<{ ok(): boolean; status(): number } | null>
264
+ waitForSelector(sel: string, opts?: object): Promise<unknown>
265
+ screenshot(opts: { path: string; fullPage?: boolean }): Promise<unknown>
266
+ on(event: 'console' | 'pageerror', handler: (arg: unknown) => void): void
267
+ video(): { path(): Promise<string> } | null
268
+ }
269
+ export interface SmokeContext { newPage(): Promise<SmokePage>; close(): Promise<void> }
270
+ export interface SmokeBrowser {
271
+ newContext(opts?: object): Promise<SmokeContext>
272
+ close(): Promise<void>
273
+ }
274
+
275
+ export interface FlowSmokeOptions {
276
+ url?: string
277
+ label?: string
278
+ // null = playwright unresolvable in the target project (exit 2)
279
+ launch?: (targetDir: string) => Promise<SmokeBrowser | null>
280
+ }
281
+
282
+ const CONFIG_GUIDANCE = [
283
+ 'No smoke flows configured. Add a smoke section to .yoke/config.yaml, e.g.:',
284
+ '',
285
+ 'smoke:',
286
+ ' baseUrl: http://localhost:3000',
287
+ ' flows:',
288
+ ' - name: home',
289
+ ' path: /',
290
+ ' landmark: "main h1"',
291
+ ].join('\n')
292
+
293
+ async function launchPlaywright(targetDir: string): Promise<SmokeBrowser | null> {
294
+ try {
295
+ const req = createRequire(join(targetDir, 'package.json'))
296
+ const resolved = req.resolve('playwright')
297
+ const pw = await import(pathToFileURL(resolved).href) as { chromium?: { launch(o: object): Promise<SmokeBrowser> }; default?: { chromium: { launch(o: object): Promise<SmokeBrowser> } } }
298
+ const chromium = pw.chromium ?? pw.default?.chromium
299
+ if (!chromium) return null
300
+ return await chromium.launch({ headless: true })
301
+ } catch {
302
+ return null
303
+ }
304
+ }
305
+
306
+ // Flow names come from user config and become filenames — keep them safe.
307
+ function safeName(name: string): string {
308
+ return name.replace(/[^\w.-]+/g, '-')
309
+ }
310
+
311
+ export async function runFlowSmoke(targetDir: string, opts: FlowSmokeOptions = {}): Promise<number> {
312
+ const config = loadConfig(targetDir)
313
+ const smoke = config?.smoke
314
+ if (!smoke) {
315
+ console.error(CONFIG_GUIDANCE)
316
+ return 2
317
+ }
318
+ const baseUrl = opts.url ?? smoke.baseUrl
319
+ const label = opts.label ?? process.env.YOKE_STORY ?? 'latest'
320
+ const proofRel = join('.yoke', 'proof', label)
321
+ const proofDir = join(targetDir, proofRel)
322
+ rmSync(proofDir, { recursive: true, force: true }) // fresh evidence per run
323
+ mkdirSync(proofDir, { recursive: true })
324
+ const videoTmp = join(proofDir, '.video-tmp')
325
+
326
+ const launch = opts.launch ?? launchPlaywright
327
+ const browser = await launch(targetDir)
328
+ if (!browser) {
329
+ console.error(`Playwright not found in ${targetDir}. Install it: npm i -D playwright && npx playwright install chromium`)
330
+ return 2
331
+ }
332
+
333
+ let green = 0
334
+ try {
335
+ for (const flow of smoke.flows) {
336
+ const context = await browser.newContext({ recordVideo: { dir: videoTmp } })
337
+ const page = await context.newPage()
338
+ const errors: string[] = []
339
+ page.on('console', (msg) => {
340
+ const m = msg as { type?: () => string; text?: () => string }
341
+ if (m.type?.() === 'error') errors.push(String(m.text?.() ?? msg))
342
+ })
343
+ page.on('pageerror', (err) => {
344
+ errors.push(String((err as Error)?.message ?? err))
345
+ })
346
+ let reason: string | undefined
347
+ try {
348
+ const resp = await page.goto(baseUrl + flow.path, { waitUntil: 'load', timeout: 30_000 })
349
+ if (resp && !resp.ok()) reason = `HTTP ${resp.status()}`
350
+ if (!reason && flow.landmark) {
351
+ try {
352
+ await page.waitForSelector(flow.landmark, { timeout: 10_000 })
353
+ } catch {
354
+ reason = `landmark "${flow.landmark}" not found`
355
+ }
356
+ }
357
+ if (!reason && errors.length > 0) {
358
+ reason = `${errors.length} console error(s): ${errors[0].slice(0, 200)}`
359
+ }
360
+ } catch (e) {
361
+ reason = (e as Error).message.split('\n')[0]
362
+ }
363
+ // The screenshot IS the evidence — taken on success AND failure; a crashed
364
+ // page must not mask the original failure.
365
+ const shotName = `${safeName(flow.name)}.png`
366
+ try {
367
+ await page.screenshot({ path: join(proofDir, shotName), fullPage: true })
368
+ } catch { /* keep the original reason */ }
369
+ const video = page.video()
370
+ await context.close()
371
+ if (video) {
372
+ try {
373
+ const vpath = await video.path()
374
+ if (reason) renameSync(vpath, join(proofDir, `${safeName(flow.name)}.webm`))
375
+ else rmSync(vpath, { force: true })
376
+ } catch { /* video is best-effort evidence */ }
377
+ }
378
+ if (reason) {
379
+ console.log(`✘ ${flow.name} — ${reason} (screenshot + video saved under ${proofRel})`)
380
+ } else {
381
+ green++
382
+ console.log(`✔ ${flow.name} (screenshot: ${join(proofRel, shotName)})`)
383
+ }
384
+ }
385
+ } finally {
386
+ await browser.close()
387
+ rmSync(videoTmp, { recursive: true, force: true })
388
+ }
389
+ console.log(`Flow-smoke: ${green}/${smoke.flows.length} flows green — proof: ${proofRel}`)
390
+ return green === smoke.flows.length ? 0 : 1
391
+ }
392
+ ```
393
+
394
+ - [ ] **Step 4: Run** — `npx vitest run tests/smoke` → PASS. `npx tsc --noEmit` clean.
395
+
396
+ - [ ] **Step 5: Commit** — `feat(smoke): yoke flow-smoke core — screenshots always, video on failure`
397
+
398
+ ---
399
+
400
+ ### Task 3: Loop linkage (`YOKE_STORY`) + gitignore `.yoke/proof/`
401
+
402
+ **Files:**
403
+ - Modify: `src/loop/loop.ts` (both verify call sites, near lines 88 and 138)
404
+ - Modify: `src/retrofit/gitignore.ts`
405
+ - Test: the existing loop test file that exercises `runLoop` with a fake verifier (find with Glob `tests/loop/loop*.test.ts`); `tests/retrofit/gitignore.test.ts`
406
+
407
+ - [ ] **Step 1: Write the failing tests.** In the loop test file, using its existing fake-runner/fake-git helpers:
408
+
409
+ ```ts
410
+ it('exposes the story id to the verifier via YOKE_STORY and unsets it after', () => {
411
+ let seen: string | undefined
412
+ const verify = (dir: string) => { seen = process.env.YOKE_STORY; return { passed: true, summary: 'ok' } }
413
+ // arrange a single-story PRD exactly like the existing happy-path test, then runLoop with this verifier
414
+ // ... existing setup ...
415
+ expect(seen).toBe('S1')
416
+ expect(process.env.YOKE_STORY).toBeUndefined()
417
+ })
418
+ ```
419
+
420
+ In `tests/retrofit/gitignore.test.ts`: extend the explicit-lines assertion so `.yoke/proof/` is one of the ensured lines (same pattern as the `.yoke/loop.lock` case from Baustein K).
421
+
422
+ - [ ] **Step 2: Run to verify failure.**
423
+
424
+ - [ ] **Step 3: Implement.** In `src/retrofit/gitignore.ts`, append `'.yoke/proof/'` to `YOKE_IGNORE_LINES`. In `src/loop/loop.ts`, wrap BOTH verify call sites:
425
+
426
+ ```ts
427
+ reporter.phase('verifying')
428
+ process.env.YOKE_STORY = story.id
429
+ let verdict
430
+ try {
431
+ verdict = opts.verify(wt) // second site: opts.verify(opts.targetDir)
432
+ } finally {
433
+ delete process.env.YOKE_STORY
434
+ }
435
+ ```
436
+
437
+ (Keep everything downstream of `verdict` unchanged.)
438
+
439
+ - [ ] **Step 4: Run** — `npx vitest run tests/loop tests/retrofit` → PASS.
440
+
441
+ - [ ] **Step 5: Commit** — `feat(loop): label flow-smoke proofs per story via YOKE_STORY; gitignore .yoke/proof/`
442
+
443
+ ---
444
+
445
+ ### Task 4: CLI wiring (async main) + usage
446
+
447
+ **Files:**
448
+ - Modify: `src/cli.ts`
449
+
450
+ - [ ] **Step 1: Make `main` awaitable.** Change the signature to `function main(argv: string[]): number | Promise<number>` and the isMain guard to:
451
+
452
+ ```ts
453
+ const isMain = process.argv[1] ? pathToFileURL(process.argv[1]).href === import.meta.url : false
454
+ if (isMain) {
455
+ process.exit(await main(process.argv.slice(2)))
456
+ }
457
+ ```
458
+
459
+ (Top-level await is valid here — ESM.) Add the import and the case:
460
+
461
+ ```ts
462
+ import { runFlowSmoke } from './smoke/command.js'
463
+ ```
464
+
465
+ ```ts
466
+ case 'flow-smoke': {
467
+ const targetDir = rest.find(a => !a.startsWith('-')) ?? '.'
468
+ const url = rest.find(a => a.startsWith('--url='))?.slice('--url='.length)
469
+ const label = rest.find(a => a.startsWith('--label='))?.slice('--label='.length)
470
+ return runFlowSmoke(targetDir, { url, label })
471
+ }
472
+ ```
473
+
474
+ Extend the `default` usage string with `flow-smoke [dir] [--url=<baseUrl>] [--label=<name>]`.
475
+
476
+ - [ ] **Step 2: Verify** — `npx tsc --noEmit` clean; `npm run build` succeeds; `node dist/cli.js flow-smoke` in the yoke repo itself prints the config guidance and exits 2 (yoke has no smoke section — expected).
477
+
478
+ - [ ] **Step 3: Commit** — `feat(cli): wire yoke flow-smoke (async main)`
479
+
480
+ ---
481
+
482
+ ### Task 5: Canon skill update + attribution
483
+
484
+ **Files:**
485
+ - Modify: `canon/skills/visual-verification/SKILL.md`
486
+ - Modify: `canon/skills/ATTRIBUTION.md`
487
+
488
+ - [ ] **Step 1: Rewrite sections 2 and 3 of the skill** (keep section 1 and the frontmatter's spirit; update the description to mention the built-in command). New content for the body after section 1:
489
+
490
+ ```markdown
491
+ ## 2. Flow-smoke with the built-in gate
492
+
493
+ Configure the key user flows once in `.yoke/config.yaml`:
494
+
495
+ ```yaml
496
+ smoke:
497
+ baseUrl: http://localhost:3000
498
+ flows:
499
+ - name: home
500
+ path: /
501
+ landmark: "main h1"
502
+ - name: login
503
+ path: /login
504
+ landmark: "form"
505
+ ```
506
+
507
+ Then chain the built-in gate in `verify.command`:
508
+
509
+ ```
510
+ <typecheck> && <unit tests> && yoke design-scan . && yoke flow-smoke .
511
+ ```
512
+
513
+ `yoke flow-smoke` loads each route against the running dev server, waits for the landmark,
514
+ fails on any console error, and **always** saves a screenshot to `.yoke/proof/<story>/`
515
+ (the loop labels the folder with the current story id via `YOKE_STORY`; standalone runs use
516
+ `latest`, or pass `--label=`). Requires Playwright in the project:
517
+ `npm i -D playwright && npx playwright install chromium`. Start the dev server before verify
518
+ (e.g. via `start-server-and-test`).
519
+
520
+ ## 3. Video only when necessary
521
+
522
+ `yoke flow-smoke` records video per flow and keeps it **only on failure**
523
+ (`.yoke/proof/<story>/<flow>.webm`). When a flow goes red: watch that clip first, then use the
524
+ wired Playwright MCP to reproduce interactively. Never record every run manually — the gate
525
+ already handles the failure case.
526
+
527
+ ## Rule
528
+
529
+ Green pipeline = types + units + no design-slop over budget + every flow renders without
530
+ console errors, with a screenshot to prove it. Only then is the story actually done.
531
+ ```
532
+
533
+ Update the frontmatter `description` to: `Use for any UI/web project — make the verify gate cover more than unit tests by composing a pipeline (types → unit → design-scan → flow-smoke) and running the built-in yoke flow-smoke gate (landmark + zero console errors + screenshot proof to .yoke/proof/<story>/, video kept on failure). Catches the unwired-page / runtime-crash / AI-slop bugs unit tests miss.`
534
+
535
+ - [ ] **Step 2: Extend the gstack entry in `canon/skills/ATTRIBUTION.md`** — add to the existing gstack bullet (do not create a second entry): browser-QA-as-gate idea (`/qa`) natively re-implemented as `yoke flow-smoke` with a proof-artifact contract; no code copied.
536
+
537
+ - [ ] **Step 3: Run** — `npx vitest run tests/canon` → PASS (real-canon validates the skill file).
538
+
539
+ - [ ] **Step 4: Commit** — `docs(canon): visual-verification uses built-in flow-smoke; attribution`
540
+
541
+ ---
542
+
543
+ ### Task 6: README + final verification
544
+
545
+ **Files:**
546
+ - Modify: `README.md`
547
+
548
+ - [ ] **Step 1: README updates** (user rule: README moves with every change):
549
+ - New section **`yoke flow-smoke`** next to the design-scan section: config example, proof contract (screenshots always → `.yoke/proof/<label>/`, video kept on failure, loop labels per story via `YOKE_STORY`), exit codes 0/1/2, Playwright-in-target-project requirement, `--url`/`--label` flags.
550
+ - Extend the verify-pipeline example(s) to `... && yoke design-scan . && yoke flow-smoke .`.
551
+ - Highlights list: add a line about story-level visual proofs ("done, with a photo").
552
+ - Update the test count badge + mentions to the real number from Step 2.
553
+
554
+ - [ ] **Step 2: Full verification:**
555
+
556
+ ```
557
+ npx tsc --noEmit
558
+ npx vitest run --reporter=json --outputFile=.yoke-test.json
559
+ node -e "const r=require('./.yoke-test.json'); console.log('success', r.success, 'total', r.numTotalTests, 'failed', r.numFailedTests)"
560
+ npm run build
561
+ node dist/cli.js validate canon
562
+ ```
563
+
564
+ Delete `.yoke-test.json`. All green, canon valid.
565
+
566
+ - [ ] **Step 3: Commit** — `docs(readme): flow-smoke gate + proof contract; test count`
567
+
568
+ ---
569
+
570
+ ## Self-review (done at plan time)
571
+
572
+ - Spec coverage: Part 1→Task 1, Part 2→Task 2, Part 3→Task 3, Part 4→Task 4, Part 5→Task 5, README→Task 6. Spec's `browser` seam intentionally generalized to `launch` (declared deviation, testable exit-2).
573
+ - Placeholder scan: all steps carry complete code or exact edit locations; the two "reuse existing helpers" test steps name the discovery mechanism (Glob patterns) and the exact assertions.
574
+ - Type consistency: `SmokeBrowser`/`SmokeContext`/`SmokePage`/`FlowSmokeOptions.launch` identical across Tasks 2 and the tests; `SmokeConfig`/`SmokeFlow` from Task 1 consumed in Task 2 via `loadConfig`.