@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
package/bench/run.mjs ADDED
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ // Yoke benchmark harness.
3
+ //
4
+ // node bench/run.mjs --runner=claude [--max=6] [--timeout=10] [--label=note]
5
+ //
6
+ // Copies the fixture into bench/.runs/<runner>-<stamp>, git-inits it, then drives
7
+ // `yoke loop run --json` and measures from the OUTSIDE (the loop itself records no
8
+ // durations): per-story wall-clock from NDJSON event timestamps, tokens/model from
9
+ // the loop's token hook (claude runner only), and quality as the fixture's own
10
+ // pre-written tests — run per story AFTER the loop finishes, on the final tree.
11
+ import { spawn, spawnSync } from 'node:child_process'
12
+ import { cpSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs'
13
+ import { join, dirname } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+
16
+ const benchDir = dirname(fileURLToPath(import.meta.url))
17
+ const repoRoot = dirname(benchDir)
18
+ const cli = join(repoRoot, 'dist', 'cli.js')
19
+
20
+ const args = Object.fromEntries(
21
+ process.argv.slice(2).filter(a => a.startsWith('--')).map(a => {
22
+ const [k, v] = a.slice(2).split('=')
23
+ return [k, v ?? true]
24
+ }),
25
+ )
26
+ const runner = args.runner
27
+ if (!['claude', 'codex', 'gemini'].includes(runner)) {
28
+ console.error('usage: node bench/run.mjs --runner=<claude|codex|gemini> [--max=6] [--timeout=10] [--label=note]')
29
+ process.exit(2)
30
+ }
31
+ const max = Number(args.max ?? 6)
32
+ const timeout = Number(args.timeout ?? 10)
33
+
34
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
35
+ const runDir = join(benchDir, '.runs', `${runner}-${stamp}`)
36
+ mkdirSync(runDir, { recursive: true })
37
+ cpSync(join(benchDir, 'fixtures', 'string-kit'), runDir, { recursive: true })
38
+
39
+ const git = (...a) => {
40
+ const r = spawnSync('git', ['-C', runDir, ...a], { encoding: 'utf8' })
41
+ if (r.status !== 0) throw new Error(`git ${a.join(' ')} failed: ${r.stderr}`)
42
+ }
43
+ git('init', '-q')
44
+ git('-c', 'user.name=bench', '-c', 'user.email=bench@yoke', 'add', '-A')
45
+ git('-c', 'user.name=bench', '-c', 'user.email=bench@yoke', 'commit', '-q', '-m', 'bench: fixture baseline')
46
+
47
+ // A nested Claude Code session refuses some operations; scrub session markers.
48
+ const env = { ...process.env }
49
+ for (const k of Object.keys(env)) if (k.startsWith('CLAUDE_CODE') || k === 'CLAUDECODE') delete env[k]
50
+
51
+ console.error(`[bench] ${runner} → ${runDir}`)
52
+ const t0 = Date.now()
53
+ const events = []
54
+ const child = spawn(process.execPath, [cli, 'loop', 'run', runDir, '--json', `--runner=${runner}`, `--max=${max}`, `--timeout=${timeout}`], {
55
+ env, stdio: ['ignore', 'pipe', 'inherit'],
56
+ })
57
+ let buf = ''
58
+ child.stdout.on('data', d => {
59
+ buf += d
60
+ let i
61
+ while ((i = buf.indexOf('\n')) >= 0) {
62
+ const line = buf.slice(0, i).trim()
63
+ buf = buf.slice(i + 1)
64
+ if (!line) continue
65
+ try { events.push({ at: Date.now(), ...JSON.parse(line) }) } catch { /* non-JSON noise */ }
66
+ }
67
+ })
68
+ const exitCode = await new Promise(res => child.on('close', res))
69
+ const wallClockMs = Date.now() - t0
70
+
71
+ // Per-story duration: first event mentioning the story -> first event mentioning the next story (or end).
72
+ const storyIds = ['STORY-1', 'STORY-2', 'STORY-3']
73
+ const firstSeen = {}
74
+ for (const e of events) if (e.story && !(e.story in firstSeen)) firstSeen[e.story] = e.at
75
+ const stories = storyIds.map((id, idx) => {
76
+ const start = firstSeen[id]
77
+ const next = storyIds.slice(idx + 1).map(n => firstSeen[n]).find(v => v !== undefined)
78
+ const durationMs = start === undefined ? null : (next ?? t0 + wallClockMs) - start
79
+ const iterations = new Set(events.filter(e => e.story === id).map(e => e.iteration)).size
80
+ // Quality: the fixture's own tests for this story, on the final tree.
81
+ const q = spawnSync(process.execPath, ['--test', `tests/${id}.test.mjs`], { cwd: runDir, encoding: 'utf8' })
82
+ return { id, durationMs, iterations, finalTestsPass: q.status === 0 }
83
+ })
84
+
85
+ const last = events[events.length - 1] ?? {}
86
+ let status = {}
87
+ try { status = JSON.parse(readFileSync(join(runDir, '.yoke', 'loop-status.json'), 'utf8')) } catch { /* loop may have refused before writing status */ }
88
+
89
+ // Source size (LOC in src/) as a code-economy proxy.
90
+ const loc = (dir) => readdirSync(dir).reduce((n, f) => {
91
+ const p = join(dir, f)
92
+ if (statSync(p).isDirectory()) return n + loc(p)
93
+ return n + readFileSync(p, 'utf8').split('\n').filter(l => l.trim() !== '').length
94
+ }, 0)
95
+
96
+ const result = {
97
+ runner,
98
+ label: args.label ?? null,
99
+ yokeVersion: JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')).version,
100
+ fixture: 'string-kit',
101
+ startedAt: new Date(t0).toISOString(),
102
+ wallClockMs,
103
+ exitCode,
104
+ finalState: last.state ?? null,
105
+ progress: last.progress ?? null,
106
+ tokens: status.tokens ?? null, // claude runner only; gemini/codex report none (documented gap)
107
+ stories,
108
+ srcLoc: loc(join(runDir, 'src')),
109
+ }
110
+
111
+ mkdirSync(join(benchDir, 'results'), { recursive: true })
112
+ const out = join(benchDir, 'results', `${runner}-${stamp}.json`)
113
+ writeFileSync(out, JSON.stringify(result, null, 2) + '\n')
114
+ console.error(`[bench] done: ${out}`)
115
+ console.log(JSON.stringify(result, null, 2))
@@ -10,6 +10,8 @@ Pass `--isolate` to run each iteration in a fresh git worktree: the agent works
10
10
 
11
11
  Pass `--review` (or `--reviewer=<claude|codex|gemini>` for a different agent) to add a role-separated review step: after the tests pass, an independent reviewer agent must approve the change before the story is committed and marked done. A rejection blocks the story (no commit). The reviewer is a fresh agent pass — the implementer never reviews its own work.
12
12
 
13
+ Pass `--json` for machine mode: each status transition is emitted as one NDJSON line on stdout (the `.yoke/loop-status.json` shape, tagged `"type":"status"`) instead of the human narrative, so a supervisor can consume the stream instead of polling the file.
14
+
13
15
  When enabled and run, each iteration:
14
16
 
15
17
  1. Pre-dispatch gate: the git worktree must be clean, else `blocked`.
@@ -24,7 +26,9 @@ When enabled and run, each iteration:
24
26
  `passes: true`, committed atomically, and a decision logged. If verify fails: `blocked`.
25
27
  6. Stop when all stories `passes: true` (`complete`), or the iteration cap is reached (`cap-reached`).
26
28
 
27
- State lives outside the model context: the PRD file + git. The agent runner is pluggable.
29
+ A supervisor can pause the loop by creating `.yoke/loop.pause`: at the next story boundary (before the next story is selected — the running story always finishes) the loop consumes the file, records `paused` in the status file and log, and exits with code `3`. Running `yoke loop run` again resumes.
30
+
31
+ State lives outside the model context: the PRD file + git. The agent runner is pluggable. The PRD is re-read from disk at every story boundary, so stories appended to `.yoke/prd.yaml` mid-run are picked up at the next iteration without a restart.
28
32
 
29
33
  ## Limitations
30
34
  - The loop verifies via the project's test command and an optional agent review; it has no formal merge-queue or multi-reviewer quorum.
@@ -45,3 +45,6 @@ tools:
45
45
  - { id: graphify, path: tools/graphify.md }
46
46
  - { id: playwright-mcp, path: tools/playwright-mcp.md }
47
47
  - { id: serena, path: tools/serena.md }
48
+ # optional companions (external installers; documented wiring only)
49
+ - { id: claude-mem, path: tools/claude-mem.md }
50
+ - { id: ui-ux-pro-max, path: tools/ui-ux-pro-max.md }
@@ -14,5 +14,6 @@ Set up (or update) the Yoke harness in the current project.
14
14
  2. Run `yoke retrofit . --agent=all --code-graph=<choice>` (or a subset of agents). Non-destructive — existing files are backed up under `.yoke/backup/` before any overwrite; `.claude/settings.json` is merged, not replaced. Generated per agent: Claude (`.claude/skills/`, `AGENTS.md`, `CLAUDE.md`, `.mcp.json`, rtk hook when WSL is available); Codex (`AGENTS.md`, `.codex/config.toml`, `RTK.md`); Gemini (`GEMINI.md`, `.gemini/commands/*.toml`, `.gemini/settings.json`).
15
15
  3. **Ask whether to enable the autonomous Loop** (default off). If yes, add `--loop`. Toggle any time with `yoke loop on|off`.
16
16
  4. Show the printed report (created/overwritten/unchanged/merged + detected agents) and where backups went. Note that the generated MCP launch commands may need adjusting to the user's local tool installs.
17
+ 5. **Preserve project content.** If the pre-retrofit `CLAUDE.md`/`GEMINI.md` had project-specific instructions (tech stack, workflow, `@`-includes), move them from the backup into the preserve block the generated file ships (`<!-- yoke:preserve:start -->` … `<!-- yoke:preserve:end -->`). Everything inside these markers survives every future `yoke retrofit` — in any yoke-written file; content outside them is replaced (but backed up).
17
18
 
18
19
  The harness includes a `minimal-code` skill (YAGNI / lazy-senior-dev) that nudges every agent to write the least code that solves the task — saving tokens and reducing maintenance.
@@ -0,0 +1,15 @@
1
+ # Tool: claude-mem (persistent cross-session memory) — optional companion
2
+
3
+ External install (not bundled): `npx claude-mem install` — https://github.com/thedotmack/claude-mem (Apache-2.0).
4
+ Captures tool usage via lifecycle hooks, compresses it into SQLite + vector search, and injects
5
+ relevant context into future sessions.
6
+
7
+ Per-agent wiring:
8
+
9
+ - **Claude Code:** first-class support — its installer registers the lifecycle hooks itself. Yoke does not duplicate that wiring; run the installer once per machine.
10
+ - **Codex CLI / Gemini CLI:** supported by claude-mem's own multi-agent adapters; follow its docs.
11
+
12
+ Interaction with the Yoke loop (deliberate boundary):
13
+
14
+ - The loop's memory is **explicit and versioned**: `context/PROJECT.md`, `KNOWLEDGE.md`, `DECISIONS.md` plus the PRD. Every loop iteration starts from fresh context on purpose — reproducible, reviewable, in git.
15
+ - claude-mem's automatic injection is **for interactive sessions**, not for loop runs. Do not enable its hooks inside a loop runner profile: injected memory is non-deterministic and bypasses the context files the gates rely on.
@@ -0,0 +1,15 @@
1
+ # Tool: ui-ux-pro-max (design intelligence) — optional companion
2
+
3
+ External install (not bundled): `npm i -g ui-ux-pro-max-cli && uipro init --ai claude` —
4
+ https://github.com/nextlevelbuilder/ui-ux-pro-max-skill (MIT).
5
+ A data-driven design skill: UI styles, industry reasoning rules, palettes, font pairings,
6
+ chart types, per-stack guidance. Its own installer targets Claude Code, Codex CLI, and
7
+ Gemini CLI natively — run `uipro init` once per agent you use.
8
+
9
+ Why it pairs with Yoke instead of being vendored:
10
+
11
+ - **Division of labour:** ui-ux-pro-max makes the *generation* side better (what to build);
12
+ Yoke's `unslop-ui` skill, `yoke design-scan`, and `visual-verification` are the *verification*
13
+ side (prove it doesn't look AI-generated, prove it renders). Generate with pro-max, gate with Yoke.
14
+ - **Not vendored on purpose:** it ships its own installer and update cadence; a copy frozen into
15
+ the canon would go stale. Install it directly and let `uipro update` keep it current.
package/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { pathToFileURL } from 'node:url';
3
+ import { realpathSync } from 'node:fs';
3
4
  import { validateCanon } from './canon/validate.js';
4
5
  import { runRetrofit } from './retrofit/command.js';
5
6
  import { setLoopEnabled, loopStatus, runLoopCommand } from './loop/run-command.js';
@@ -10,6 +11,8 @@ import { runNew } from './new/command.js';
10
11
  import { runPrdDraft, runPrdCheck } from './prd/command.js';
11
12
  import { runLoopCleanup } from './loop/cleanup.js';
12
13
  import { runFlowSmoke } from './smoke/command.js';
14
+ import { maybeNotifyUpdate, currentYokeVersion } from './update/check.js';
15
+ import { runUpgrade } from './update/upgrade.js';
13
16
  export { runRetrofit } from './retrofit/command.js';
14
17
  export function runValidate(canonDir) {
15
18
  const issues = validateCanon(canonDir);
@@ -113,6 +116,7 @@ function main(argv) {
113
116
  reviewer = reviewerArg;
114
117
  }
115
118
  const review = rest.includes('--review');
119
+ const json = rest.includes('--json');
116
120
  const toArg = rest.find(a => a.startsWith('--timeout='));
117
121
  let timeoutMinutes;
118
122
  if (toArg) {
@@ -123,9 +127,9 @@ function main(argv) {
123
127
  }
124
128
  timeoutMinutes = v;
125
129
  }
126
- return runLoopCommand(targetDir, { maxIterations: rawMax, agent, isolate, reviewer, review, timeoutMinutes });
130
+ return runLoopCommand(targetDir, { maxIterations: rawMax, agent, isolate, reviewer, review, timeoutMinutes, json });
127
131
  }
128
- console.log('usage: yoke loop <on|off|status|cleanup|run [--max=N] [--runner=<claude|codex|gemini>] [--reviewer=<claude|codex|gemini>] [--review] [--isolate] [--timeout=<minutes>]> [targetDir]');
132
+ console.log('usage: yoke loop <on|off|status|cleanup|run [--max=N] [--runner=<claude|codex|gemini>] [--reviewer=<claude|codex|gemini>] [--review] [--isolate] [--timeout=<minutes>] [--json]> [targetDir]');
129
133
  return 1;
130
134
  }
131
135
  case 'new': {
@@ -233,12 +237,34 @@ function main(argv) {
233
237
  }
234
238
  return runDesignScan(targetDir, { max, report });
235
239
  }
240
+ case 'upgrade':
241
+ return runUpgrade();
236
242
  default:
237
- console.log('usage: yoke <new <dir> [--idea="..."] | validate [canonDir] | retrofit [targetDir] [--agent=claude,codex,gemini|all] [--code-graph=graphify|serena] [--loop] | prd <draft|check> [dir] | loop <on|off|status|run|cleanup> | context <init|status> | review [dir] [--reviewer=<claude|codex|gemini>] [--base=<ref>] [--focus="..."] | design-scan [dir] [--max=N] [--report] | flow-smoke [dir] [--url=<baseUrl>] [--label=<name>]>');
243
+ console.log('usage: yoke <new <dir> [--idea="..."] | validate [canonDir] | retrofit [targetDir] [--agent=claude,codex,gemini|all] [--code-graph=graphify|serena] [--loop] | prd <draft|check> [dir] | loop <on|off|status|run|cleanup> | context <init|status> | review [dir] [--reviewer=<claude|codex|gemini>] [--base=<ref>] [--focus="..."] | design-scan [dir] [--max=N] [--report] | flow-smoke [dir] [--url=<baseUrl>] [--label=<name>] | upgrade>');
238
244
  return cmd ? 1 : 0;
239
245
  }
240
246
  }
241
- const isMain = process.argv[1] ? pathToFileURL(process.argv[1]).href === import.meta.url : false;
242
- if (isMain) {
243
- process.exit(await main(process.argv.slice(2)));
247
+ // Is this module the process entry point? Node realpaths the ESM entry for
248
+ // import.meta.url but argv[1] keeps the path as typed — a globally npm-installed
249
+ // CLI reaches this file THROUGH the node_modules/yoke symlink, so argv[1] must be
250
+ // realpathed before comparing or the global binary silently exits without running.
251
+ export function isMainEntry(argv1, moduleUrl) {
252
+ if (!argv1)
253
+ return false;
254
+ let resolved = argv1;
255
+ try {
256
+ resolved = realpathSync(argv1);
257
+ }
258
+ catch { /* nonexistent path — compare as given */ }
259
+ return pathToFileURL(resolved).href === moduleUrl;
260
+ }
261
+ if (isMainEntry(process.argv[1], import.meta.url)) {
262
+ const code = await main(process.argv.slice(2));
263
+ // Non-blocking version hint (npm/gh-style): reads a cache, maybe spawns a
264
+ // detached refresher. Never allowed to affect the command's outcome.
265
+ try {
266
+ maybeNotifyUpdate(currentYokeVersion());
267
+ }
268
+ catch { /* never fatal */ }
269
+ process.exit(code);
244
270
  }
@@ -1,12 +1,53 @@
1
- import { existsSync, readdirSync, rmSync } from 'node:fs';
1
+ import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { execFileSync } from 'node:child_process';
4
4
  import { lockPath, readLock, isPidAlive } from './lock.js';
5
- // Cleans ONLY yoke-created runtime artifacts: .yoke/worktrees/* and a stale loop.lock.
6
- // Never touches user-created worktrees or a lock whose holder is alive.
5
+ import { killProcessTree } from './watchdog.js';
6
+ // Reap orphaned runners PROJECT-SCOPED: kill only pids recorded in this project's
7
+ // .yoke/runner.pid files (main dir + each worktree). Never by process-name or
8
+ // command-line pattern — that takes down runners belonging to OTHER projects,
9
+ // which then stall mid-story. Skipped entirely while the loop lock holder is
10
+ // alive: a live loop's runner is healthy, not an orphan.
11
+ function reapRecordedRunners(targetDir, wtDir, isAlive, killTree) {
12
+ const pidFiles = [join(targetDir, '.yoke', 'runner.pid')];
13
+ if (existsSync(wtDir)) {
14
+ for (const name of readdirSync(wtDir))
15
+ pidFiles.push(join(wtDir, name, '.yoke', 'runner.pid'));
16
+ }
17
+ let killed = 0;
18
+ for (const file of pidFiles) {
19
+ if (!existsSync(file))
20
+ continue;
21
+ try {
22
+ const rec = JSON.parse(readFileSync(file, 'utf8'));
23
+ // Child (agent tree) first, then the watchdog wrapper.
24
+ for (const pid of [rec.childPid, rec.watchdogPid]) {
25
+ if (typeof pid === 'number' && Number.isInteger(pid) && pid > 0 && isAlive(pid)) {
26
+ killTree(pid);
27
+ killed++;
28
+ }
29
+ }
30
+ }
31
+ catch { /* malformed record — still consume the file below */ }
32
+ rmSync(file, { force: true });
33
+ }
34
+ return killed;
35
+ }
36
+ // Cleans ONLY yoke-created runtime artifacts: recorded orphan runners,
37
+ // .yoke/worktrees/*, and a stale loop.lock. Never touches user-created
38
+ // worktrees, a lock whose holder is alive, or any process it did not record.
7
39
  export function runLoopCleanup(targetDir, opts = {}) {
8
40
  const git = opts.git ?? ((args, cwd) => { execFileSync('git', args, { cwd, stdio: 'pipe' }); });
41
+ const isAlive = opts.isAlive ?? isPidAlive;
42
+ const killTree = opts.killTree ?? killProcessTree;
9
43
  const wtDir = join(targetDir, '.yoke', 'worktrees');
44
+ const holder = readLock(targetDir);
45
+ const lockHeld = holder !== null && isPidAlive(holder.pid);
46
+ if (!lockHeld) {
47
+ const killed = reapRecordedRunners(targetDir, wtDir, isAlive, killTree);
48
+ if (killed > 0)
49
+ console.log(`Killed ${killed} orphaned runner process tree(s) recorded in runner.pid files.`);
50
+ }
10
51
  let removed = 0;
11
52
  let failed = 0;
12
53
  if (existsSync(wtDir)) {
package/dist/loop/loop.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { existsSync, unlinkSync } from 'node:fs';
1
2
  import { join, relative } from 'node:path';
2
3
  import { loadPrd, savePrd, selectNextStory, allPass, progress } from './prd.js';
3
4
  import { stopTheLineGate, preDispatchGate } from './gates.js';
@@ -13,6 +14,11 @@ function blockReason(base, targetDir, git) {
13
14
  ? `${base} (working tree has uncommitted changes from the blocked story — review/clean before re-running)`
14
15
  : base;
15
16
  }
17
+ // Control file a supervisor drops to pause the loop at the next story boundary.
18
+ // The loop consumes (deletes) it and stops with state 'paused' — never mid-story.
19
+ export function pauseFilePath(targetDir) {
20
+ return join(targetDir, '.yoke', 'loop.pause');
21
+ }
16
22
  export function runLoop(opts) {
17
23
  let iterations = 0;
18
24
  const reporter = opts.reporter ?? noopReporter;
@@ -31,6 +37,17 @@ export function runLoop(opts) {
31
37
  reporter.capReached(progress(stories));
32
38
  return { status: 'cap-reached', iterations, finalProgress: progress(stories) };
33
39
  }
40
+ // Story boundary: honour a pause signal before selecting the next story.
41
+ // complete/cap-reached above still win — pausing an already-finished loop is meaningless.
42
+ const pauseFile = pauseFilePath(opts.targetDir);
43
+ if (existsSync(pauseFile)) {
44
+ try {
45
+ unlinkSync(pauseFile);
46
+ }
47
+ catch { /* consumed best-effort — pausing still wins */ }
48
+ reporter.paused(progress(stories));
49
+ return { status: 'paused', iterations, finalProgress: progress(stories) };
50
+ }
34
51
  const pre = preDispatchGate(opts.targetDir, opts.git);
35
52
  if (!pre.ok) {
36
53
  reporter.blocked(pre.reason ?? 'pre-dispatch gate failed');
@@ -54,6 +71,8 @@ export function runLoop(opts) {
54
71
  opts.git.addWorktree(opts.targetDir, wt);
55
72
  const result = opts.runner({ targetDir: wt, story });
56
73
  iterations++;
74
+ if (result.tokens)
75
+ reporter.addTokens(result.tokens);
57
76
  // Verify is the source of truth — NOT the runner's exit code. A spurious non-zero
58
77
  // exit (e.g. a Windows .cmd wrapper ghost) must not block a story whose tests are green.
59
78
  reporter.phase('verifying');
@@ -118,6 +137,8 @@ export function runLoop(opts) {
118
137
  }
119
138
  const result = opts.runner({ targetDir: opts.targetDir, story });
120
139
  iterations++;
140
+ if (result.tokens)
141
+ reporter.addTokens(result.tokens);
121
142
  // Verify is the source of truth — NOT the runner's exit code. A spurious non-zero
122
143
  // exit (e.g. a Windows .cmd wrapper ghost) must not block a story whose tests are green.
123
144
  reporter.phase('verifying');
@@ -169,13 +190,17 @@ export function runLoop(opts) {
169
190
  title: story.title,
170
191
  summary,
171
192
  });
172
- const updated = stories.map(s => (s.id === story.id ? { ...s, passes: true } : s));
193
+ // Re-read the PRD from disk before persisting passes:true a story injected
194
+ // mid-iteration (hot-reload) must survive this save, not be clobbered by the
195
+ // stale top-of-iteration copy.
196
+ const onDisk = loadPrd(opts.prdPath);
197
+ const updated = onDisk.map(s => (s.id === story.id ? { ...s, passes: true } : s));
173
198
  savePrd(opts.prdPath, updated);
174
199
  try {
175
200
  opts.git.commitAll(opts.targetDir, `yoke: complete ${story.id} ${story.title}`);
176
201
  }
177
202
  catch (e) {
178
- savePrd(opts.prdPath, stories); // revert — never persist passes:true without a commit
203
+ savePrd(opts.prdPath, onDisk); // revert — never persist passes:true without a commit
179
204
  dec.rollback(); // and never leave an orphan decision
180
205
  const reason = blockReason(`commit failed for ${story.id}: ${e.message}`, opts.targetDir, opts.git);
181
206
  reporter.blocked(reason);
@@ -49,14 +49,20 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
49
49
  const emitConsole = (line) => { if (!opts.quiet)
50
50
  sink(line); };
51
51
  let current = null;
52
- const persist = (next, logLabel, consoleLine) => {
52
+ let tokens;
53
+ const persist = (status, logLabel, consoleLine) => {
54
+ const next = tokens ? { ...status, tokens: { ...tokens } } : status;
53
55
  current = next;
54
56
  try {
55
57
  writeStatus(dir, next);
56
58
  appendLog(dir, `${next.updatedAt} ${logLabel} ${next.story ?? '-'} ${next.reason ?? ''}`.trimEnd());
57
59
  }
58
60
  catch { /* observability must never abort the loop */ }
59
- emitConsole(consoleLine);
61
+ // json mode owns stdout: one machine-readable line per status write, no narrative.
62
+ if (opts.json)
63
+ sink(JSON.stringify({ type: 'status', ...next }));
64
+ else
65
+ emitConsole(consoleLine);
60
66
  };
61
67
  return {
62
68
  storyStart(story, iteration, progress) {
@@ -81,11 +87,23 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
81
87
  persist({ ...(current ?? emptyStatus(now().toISOString())), state: 'cap-reached', phase: undefined,
82
88
  progress, updatedAt: now().toISOString() }, 'cap-reached', `◾ iteration cap reached — ${progress.passed}/${progress.total}`);
83
89
  },
90
+ paused(progress) {
91
+ persist({ ...(current ?? emptyStatus(now().toISOString())), state: 'paused', phase: undefined,
92
+ progress, updatedAt: now().toISOString() }, 'paused', `⏸ loop paused — ${progress.passed}/${progress.total}`);
93
+ },
94
+ addTokens(usage) {
95
+ const model = usage.model ?? tokens?.model;
96
+ tokens = {
97
+ inputTokens: (tokens?.inputTokens ?? 0) + usage.inputTokens,
98
+ outputTokens: (tokens?.outputTokens ?? 0) + usage.outputTokens,
99
+ ...(model ? { model } : {}),
100
+ };
101
+ },
84
102
  };
85
103
  }
86
104
  function emptyStatus(ts) {
87
105
  return { state: 'running', iteration: 0, progress: { passed: 0, total: 0 }, startedAt: ts, updatedAt: ts };
88
106
  }
89
107
  export const noopReporter = {
90
- storyStart() { }, phase() { }, blocked() { }, complete() { }, capReached() { },
108
+ storyStart() { }, phase() { }, blocked() { }, complete() { }, capReached() { }, paused() { }, addTokens() { },
91
109
  };
@@ -8,6 +8,7 @@ import { makeRunner, makeReviewRunner, isAgentAvailable } from './runner.js';
8
8
  import { commandVerifier, retryingVerifier } from './verify.js';
9
9
  import { readStatus, makeReporter } from './reporter.js';
10
10
  import { acquireLock, releaseLock } from './lock.js';
11
+ import { maybeAutoUpgrade } from '../update/upgrade.js';
11
12
  export const DEFAULT_IDLE_MINUTES = 20;
12
13
  const STALE_MINUTES = 20; // a running status older than this likely means the loop died
13
14
  export function relativeTime(fromIso, now) {
@@ -80,6 +81,9 @@ export function runLoopCommand(targetDir, opts) {
80
81
  }
81
82
  verify = retryingVerifier(commandVerifier(command), config.verify?.retries ?? 1);
82
83
  }
84
+ // Opt-in self-update, loop START only — this run keeps executing the version
85
+ // it started with; a fetched upgrade applies from the next invocation.
86
+ maybeAutoUpgrade(config.update?.auto);
83
87
  const available = opts.isAvailable ?? isAgentAvailable;
84
88
  const runnerAgent = opts.agent ?? config.agents[0] ?? 'claude';
85
89
  const idleMs = resolveIdleMs(opts.timeoutMinutes, config.loop.timeoutMinutes);
@@ -89,7 +93,9 @@ export function runLoopCommand(targetDir, opts) {
89
93
  console.error(`Agent CLI "${runnerAgent}" was not found on PATH. Install it, or pick another with --runner=<claude|codex|gemini>.`);
90
94
  return 2;
91
95
  }
92
- runner = makeRunner(runnerAgent, idleMs);
96
+ // Token reporting is part of the machine interface: in --json mode a claude
97
+ // runner switches to stream-json so cumulative usage rides on every status.
98
+ runner = makeRunner(runnerAgent, idleMs, { tokenReport: opts.json === true });
93
99
  }
94
100
  let review = opts.reviewRunner;
95
101
  if (!review && (opts.review || opts.reviewer)) {
@@ -118,15 +124,22 @@ export function runLoopCommand(targetDir, opts) {
118
124
  maxIterations: opts.maxIterations,
119
125
  isolate: opts.isolate ?? false,
120
126
  review,
121
- reporter: opts.reporter ?? makeReporter(targetDir),
127
+ reporter: opts.reporter ?? makeReporter(targetDir, { json: opts.json }),
122
128
  });
123
- console.log(`Loop ${result.status} after ${result.iterations} iteration(s): ${result.finalProgress.passed}/${result.finalProgress.total} stories pass`);
129
+ // In json mode stdout belongs to the NDJSON stream — route the narrative summary to stderr.
130
+ const say = opts.json ? (line) => console.error(line) : (line) => console.log(line);
131
+ say(`Loop ${result.status} after ${result.iterations} iteration(s): ${result.finalProgress.passed}/${result.finalProgress.total} stories pass`);
124
132
  if (result.reason)
125
- console.log(`Reason: ${result.reason}`);
133
+ say(`Reason: ${result.reason}`);
126
134
  if (result.reason && /api key|please run \/login|not logged in/i.test(result.reason)) {
127
- console.log('Hint: the agent CLI has no credentials in this environment. Set ANTHROPIC_API_KEY or log the agent in for headless use.');
135
+ say('Hint: the agent CLI has no credentials in this environment. Set ANTHROPIC_API_KEY or log the agent in for headless use.');
128
136
  }
129
- return result.status === 'complete' ? 0 : 1;
137
+ // Exit codes: 0 complete · 1 blocked/cap-reached · 2 config error (handled above) · 3 paused (loop.pause consumed at a story boundary)
138
+ if (result.status === 'complete')
139
+ return 0;
140
+ if (result.status === 'paused')
141
+ return 3;
142
+ return 1;
130
143
  }
131
144
  finally {
132
145
  releaseLock(targetDir);