@hecer/yoke 0.2.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +583 -494
  3. package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/config.yaml +6 -0
  4. package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/context/DECISIONS.md +9 -0
  5. package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/prd.yaml +38 -0
  6. package/bench/.runs/claude-2026-07-09T22-34-01/bench-verify.mjs +15 -0
  7. package/bench/.runs/claude-2026-07-09T22-34-01/package.json +9 -0
  8. package/bench/.runs/claude-2026-07-09T22-34-01/src/index.mjs +48 -0
  9. package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-1.test.mjs +24 -0
  10. package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-2.test.mjs +28 -0
  11. package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-3.test.mjs +25 -0
  12. package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/config.yaml +6 -0
  13. package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/prd.yaml +32 -0
  14. package/bench/.runs/gemini-2026-07-09T22-34-02/bench-verify.mjs +15 -0
  15. package/bench/.runs/gemini-2026-07-09T22-34-02/package.json +9 -0
  16. package/bench/.runs/gemini-2026-07-09T22-34-02/src/index.mjs +3 -0
  17. package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-1.test.mjs +24 -0
  18. package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-2.test.mjs +28 -0
  19. package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-3.test.mjs +25 -0
  20. package/bench/README.md +42 -0
  21. package/bench/RESULTS.md +36 -0
  22. package/bench/fixtures/string-kit/.yoke/config.yaml +6 -0
  23. package/bench/fixtures/string-kit/.yoke/prd.yaml +32 -0
  24. package/bench/fixtures/string-kit/bench-verify.mjs +15 -0
  25. package/bench/fixtures/string-kit/package.json +9 -0
  26. package/bench/fixtures/string-kit/src/index.mjs +3 -0
  27. package/bench/fixtures/string-kit/tests/STORY-1.test.mjs +24 -0
  28. package/bench/fixtures/string-kit/tests/STORY-2.test.mjs +28 -0
  29. package/bench/fixtures/string-kit/tests/STORY-3.test.mjs +25 -0
  30. package/bench/results/claude-2026-07-09T22-34-01.json +40 -0
  31. package/bench/run.mjs +115 -0
  32. package/canon/loop/loop-spec.md +5 -1
  33. package/canon/manifest.yaml +3 -0
  34. package/canon/skills/yoke-retrofit/SKILL.md +1 -0
  35. package/canon/tools/claude-mem.md +15 -0
  36. package/canon/tools/ui-ux-pro-max.md +15 -0
  37. package/dist/cli.js +37 -6
  38. package/dist/loop/cleanup.js +44 -3
  39. package/dist/loop/loop.js +67 -2
  40. package/dist/loop/reporter.js +88 -4
  41. package/dist/loop/run-command.js +25 -8
  42. package/dist/loop/runner.js +133 -14
  43. package/dist/loop/watchdog.js +67 -17
  44. package/dist/retrofit/apply.js +10 -3
  45. package/dist/retrofit/config.js +9 -1
  46. package/dist/retrofit/gitignore.js +4 -0
  47. package/dist/retrofit/planners/claude.js +4 -1
  48. package/dist/retrofit/planners/gemini.js +2 -1
  49. package/dist/retrofit/preserve.js +51 -0
  50. package/dist/update/check.js +86 -0
  51. package/dist/update/refresh.js +28 -0
  52. package/dist/update/upgrade.js +30 -0
  53. package/docs/PUBLISHING.md +41 -0
  54. package/docs/superpowers/plans/2026-06-27-forge-a-canon.md +815 -0
  55. package/docs/superpowers/plans/2026-06-27-forge-b1-retrofit-claude.md +842 -0
  56. package/docs/superpowers/plans/2026-06-27-forge-b2-codex-gemini-tools.md +884 -0
  57. package/docs/superpowers/plans/2026-06-27-forge-c1-loop-engine.md +891 -0
  58. package/docs/superpowers/plans/2026-06-28-baustein-e-context-layer.md +981 -0
  59. package/docs/superpowers/plans/2026-06-28-forge-b3-settings-merge.md +364 -0
  60. package/docs/superpowers/plans/2026-06-28-forge-c2-loop-verify.md +502 -0
  61. package/docs/superpowers/plans/2026-06-28-forge-c3-multi-agent-runners.md +377 -0
  62. package/docs/superpowers/plans/2026-06-28-forge-c4-worktree-isolation.md +413 -0
  63. package/docs/superpowers/plans/2026-06-28-forge-c5-review-iteration.md +419 -0
  64. package/docs/superpowers/plans/2026-06-28-forge-cleanup-dep0190.md +233 -0
  65. package/docs/superpowers/plans/2026-06-28-forge-d-codegraph-choice-minimal-code.md +523 -0
  66. package/docs/superpowers/plans/2026-06-29-baustein-f-routing.md +258 -0
  67. package/docs/superpowers/plans/2026-06-29-baustein-g-loop-observability.md +1006 -0
  68. package/docs/superpowers/plans/2026-06-29-baustein-h-loop-robustness.md +374 -0
  69. package/docs/superpowers/plans/2026-06-30-baustein-i-visual-design-verification.md +450 -0
  70. package/docs/superpowers/plans/2026-07-02-baustein-j-cross-model-review-gstack-compose.md +645 -0
  71. package/docs/superpowers/plans/2026-07-02-baustein-k-zero-to-100-bootstrap.md +1024 -0
  72. package/docs/superpowers/plans/2026-07-02-baustein-m-flow-smoke-proofs.md +574 -0
  73. package/docs/superpowers/specs/2026-06-27-forge-cross-agent-harness-design.md +144 -0
  74. package/docs/superpowers/specs/2026-06-28-baustein-e-context-layer-design.md +146 -0
  75. package/docs/superpowers/specs/2026-06-29-baustein-f-routing-design.md +106 -0
  76. package/docs/superpowers/specs/2026-06-29-baustein-g-loop-observability-design.md +186 -0
  77. package/docs/superpowers/specs/2026-06-29-baustein-h-loop-robustness-design.md +113 -0
  78. package/docs/superpowers/specs/2026-06-30-baustein-i-visual-design-verification-design.md +98 -0
  79. package/docs/superpowers/specs/2026-07-02-baustein-j-cross-model-review-gstack-compose-design.md +137 -0
  80. package/docs/superpowers/specs/2026-07-02-baustein-k-zero-to-100-bootstrap-design.md +200 -0
  81. package/docs/superpowers/specs/2026-07-02-baustein-m-flow-smoke-proofs-design.md +155 -0
  82. package/docs/superpowers/specs/2026-07-10-companion-tools-decision.md +33 -0
  83. package/docs/superpowers/specs/2026-07-10-multi-agent-parallel-loop-design.md +99 -0
  84. package/package.json +8 -2
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,14 @@ function main(argv) {
123
127
  }
124
128
  timeoutMinutes = v;
125
129
  }
126
- return runLoopCommand(targetDir, { maxIterations: rawMax, agent, isolate, reviewer, review, timeoutMinutes });
130
+ const oaArg = rest.find(a => a.startsWith('--on-ambiguity='))?.slice('--on-ambiguity='.length);
131
+ if (oaArg && oaArg !== 'resolve' && oaArg !== 'abort') {
132
+ console.error(`Invalid --on-ambiguity value: ${oaArg} (expected resolve|abort)`);
133
+ return 1;
134
+ }
135
+ return runLoopCommand(targetDir, { maxIterations: rawMax, agent, isolate, reviewer, review, timeoutMinutes, json, onAmbiguity: oaArg });
127
136
  }
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]');
137
+ console.log('usage: yoke loop <on|off|status|cleanup|run [--max=N] [--runner=<claude|codex|gemini>] [--reviewer=<claude|codex|gemini>] [--review] [--isolate] [--timeout=<minutes>] [--on-ambiguity=<resolve|abort>] [--json]> [targetDir]');
129
138
  return 1;
130
139
  }
131
140
  case 'new': {
@@ -233,12 +242,34 @@ function main(argv) {
233
242
  }
234
243
  return runDesignScan(targetDir, { max, report });
235
244
  }
245
+ case 'upgrade':
246
+ return runUpgrade();
236
247
  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>]>');
248
+ 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
249
  return cmd ? 1 : 0;
239
250
  }
240
251
  }
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)));
252
+ // Is this module the process entry point? Node realpaths the ESM entry for
253
+ // import.meta.url but argv[1] keeps the path as typed — a globally npm-installed
254
+ // CLI reaches this file THROUGH the node_modules/yoke symlink, so argv[1] must be
255
+ // realpathed before comparing or the global binary silently exits without running.
256
+ export function isMainEntry(argv1, moduleUrl) {
257
+ if (!argv1)
258
+ return false;
259
+ let resolved = argv1;
260
+ try {
261
+ resolved = realpathSync(argv1);
262
+ }
263
+ catch { /* nonexistent path — compare as given */ }
264
+ return pathToFileURL(resolved).href === moduleUrl;
265
+ }
266
+ if (isMainEntry(process.argv[1], import.meta.url)) {
267
+ const code = await main(process.argv.slice(2));
268
+ // Non-blocking version hint (npm/gh-style): reads a cache, maybe spawns a
269
+ // detached refresher. Never allowed to affect the command's outcome.
270
+ try {
271
+ maybeNotifyUpdate(currentYokeVersion());
272
+ }
273
+ catch { /* never fatal */ }
274
+ process.exit(code);
244
275
  }
@@ -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, readFileSync } 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,34 @@ 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
+ }
22
+ // Abort channel for an agent that hits genuinely undecidable acceptance criteria
23
+ // (only instructed to use it under loop.onAmbiguity: abort). Honoured whenever
24
+ // present: without this check, an agent that stopped without changes would sail
25
+ // through verify on pre-existing green tests and be falsely marked done.
26
+ export function ambiguityFilePath(dir) {
27
+ return join(dir, '.yoke', 'ambiguity.md');
28
+ }
29
+ function consumeAmbiguity(dir) {
30
+ const file = ambiguityFilePath(dir);
31
+ if (!existsSync(file))
32
+ return null;
33
+ let content = '';
34
+ try {
35
+ content = readFileSync(file, 'utf8');
36
+ }
37
+ catch { /* the signal alone still blocks */ }
38
+ try {
39
+ unlinkSync(file);
40
+ }
41
+ catch { /* best-effort consume */ }
42
+ const compact = content.replace(/\s+/g, ' ').trim().slice(0, 500);
43
+ return compact || 'agent reported ambiguous acceptance criteria without details';
44
+ }
16
45
  export function runLoop(opts) {
17
46
  let iterations = 0;
18
47
  const reporter = opts.reporter ?? noopReporter;
@@ -31,6 +60,17 @@ export function runLoop(opts) {
31
60
  reporter.capReached(progress(stories));
32
61
  return { status: 'cap-reached', iterations, finalProgress: progress(stories) };
33
62
  }
63
+ // Story boundary: honour a pause signal before selecting the next story.
64
+ // complete/cap-reached above still win — pausing an already-finished loop is meaningless.
65
+ const pauseFile = pauseFilePath(opts.targetDir);
66
+ if (existsSync(pauseFile)) {
67
+ try {
68
+ unlinkSync(pauseFile);
69
+ }
70
+ catch { /* consumed best-effort — pausing still wins */ }
71
+ reporter.paused(progress(stories));
72
+ return { status: 'paused', iterations, finalProgress: progress(stories) };
73
+ }
34
74
  const pre = preDispatchGate(opts.targetDir, opts.git);
35
75
  if (!pre.ok) {
36
76
  reporter.blocked(pre.reason ?? 'pre-dispatch gate failed');
@@ -50,10 +90,19 @@ export function runLoop(opts) {
50
90
  if (opts.isolate) {
51
91
  const wt = join(opts.targetDir, '.yoke', 'worktrees', story.id);
52
92
  const wtPrd = join(wt, relative(opts.targetDir, opts.prdPath));
93
+ let landed = null;
53
94
  try {
54
95
  opts.git.addWorktree(opts.targetDir, wt);
55
96
  const result = opts.runner({ targetDir: wt, story });
56
97
  iterations++;
98
+ if (result.tokens)
99
+ reporter.addTokens(result.tokens);
100
+ const ambiguity = consumeAmbiguity(wt);
101
+ if (ambiguity) {
102
+ const reason = `story ${story.id} stopped: ambiguous acceptance criteria — ${ambiguity}`;
103
+ reporter.blocked(reason);
104
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
105
+ }
57
106
  // Verify is the source of truth — NOT the runner's exit code. A spurious non-zero
58
107
  // exit (e.g. a Windows .cmd wrapper ghost) must not block a story whose tests are green.
59
108
  reporter.phase('verifying');
@@ -102,6 +151,7 @@ export function runLoop(opts) {
102
151
  savePrd(wtPrd, updated);
103
152
  opts.git.commitAll(wt, `yoke: complete ${story.id} ${story.title}`);
104
153
  opts.git.integrate(opts.targetDir, wt);
154
+ landed = progress(updated);
105
155
  }
106
156
  catch (e) {
107
157
  const reason = blockReason(`isolated iteration failed for ${story.id}: ${e.message}`, opts.targetDir, opts.git);
@@ -114,10 +164,20 @@ export function runLoop(opts) {
114
164
  }
115
165
  catch { /* cleanup is best-effort */ }
116
166
  }
167
+ if (landed)
168
+ reporter.storyDone({ id: story.id, title: story.title }, landed);
117
169
  continue;
118
170
  }
119
171
  const result = opts.runner({ targetDir: opts.targetDir, story });
120
172
  iterations++;
173
+ if (result.tokens)
174
+ reporter.addTokens(result.tokens);
175
+ const ambiguity = consumeAmbiguity(opts.targetDir);
176
+ if (ambiguity) {
177
+ const reason = `story ${story.id} stopped: ambiguous acceptance criteria — ${ambiguity}`;
178
+ reporter.blocked(reason);
179
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
180
+ }
121
181
  // Verify is the source of truth — NOT the runner's exit code. A spurious non-zero
122
182
  // exit (e.g. a Windows .cmd wrapper ghost) must not block a story whose tests are green.
123
183
  reporter.phase('verifying');
@@ -169,13 +229,17 @@ export function runLoop(opts) {
169
229
  title: story.title,
170
230
  summary,
171
231
  });
172
- const updated = stories.map(s => (s.id === story.id ? { ...s, passes: true } : s));
232
+ // Re-read the PRD from disk before persisting passes:true a story injected
233
+ // mid-iteration (hot-reload) must survive this save, not be clobbered by the
234
+ // stale top-of-iteration copy.
235
+ const onDisk = loadPrd(opts.prdPath);
236
+ const updated = onDisk.map(s => (s.id === story.id ? { ...s, passes: true } : s));
173
237
  savePrd(opts.prdPath, updated);
174
238
  try {
175
239
  opts.git.commitAll(opts.targetDir, `yoke: complete ${story.id} ${story.title}`);
176
240
  }
177
241
  catch (e) {
178
- savePrd(opts.prdPath, stories); // revert — never persist passes:true without a commit
242
+ savePrd(opts.prdPath, onDisk); // revert — never persist passes:true without a commit
179
243
  dec.rollback(); // and never leave an orphan decision
180
244
  const reason = blockReason(`commit failed for ${story.id}: ${e.message}`, opts.targetDir, opts.git);
181
245
  reporter.blocked(reason);
@@ -186,5 +250,6 @@ export function runLoop(opts) {
186
250
  finalProgress: progress(stories),
187
251
  };
188
252
  }
253
+ reporter.storyDone({ id: story.id, title: story.title }, progress(updated));
189
254
  }
190
255
  }
@@ -23,6 +23,39 @@ export function appendLog(dir, line, capBytes = LOG_CAP_BYTES) {
23
23
  const trimmed = nl >= 0 ? tail.slice(nl + 1) : tail;
24
24
  writeFileSync(file, `# … loop.log truncated …\n${trimmed}`);
25
25
  }
26
+ export function fmtDuration(ms) {
27
+ const s = Math.max(0, Math.round(ms / 1000));
28
+ if (s < 60)
29
+ return `${s}s`;
30
+ const m = Math.floor(s / 60);
31
+ if (m < 60)
32
+ return s % 60 > 0 ? `${m}m${s % 60}s` : `${m}m`;
33
+ const h = Math.floor(m / 60);
34
+ return m % 60 > 0 ? `${h}h${m % 60}m` : `${h}h`;
35
+ }
36
+ export const DURATION_HISTORY_CAP = 50;
37
+ function durationsPath(dir) {
38
+ return join(dir, '.yoke', 'story-durations.json');
39
+ }
40
+ export function readDurations(dir) {
41
+ try {
42
+ const arr = JSON.parse(readFileSync(durationsPath(dir), 'utf8'));
43
+ if (!Array.isArray(arr))
44
+ return [];
45
+ return arr.filter((d) => typeof d?.ms === 'number' && d.ms > 0);
46
+ }
47
+ catch {
48
+ return [];
49
+ }
50
+ }
51
+ function appendDuration(dir, d) {
52
+ const all = [...readDurations(dir), d].slice(-DURATION_HISTORY_CAP);
53
+ try {
54
+ mkdirSync(join(dir, '.yoke'), { recursive: true });
55
+ writeFileSync(durationsPath(dir), JSON.stringify(all));
56
+ }
57
+ catch { /* observability must never abort the loop */ }
58
+ }
26
59
  function statusPath(dir) {
27
60
  return join(dir, '.yoke', 'loop-status.json');
28
61
  }
@@ -49,20 +82,59 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
49
82
  const emitConsole = (line) => { if (!opts.quiet)
50
83
  sink(line); };
51
84
  let current = null;
52
- const persist = (next, logLabel, consoleLine) => {
85
+ let tokens;
86
+ // ETA source: durations of stories completed in THIS run beat the persisted
87
+ // history of earlier runs (current velocity over old experience).
88
+ const history = readDurations(dir).map(h => h.ms);
89
+ const runDurations = [];
90
+ let storyStartedAt = null;
91
+ const percentOf = (p) => (p.total > 0 ? Math.round((p.passed / p.total) * 100) : 0);
92
+ const etaFor = (p) => {
93
+ const pool = runDurations.length > 0 ? runDurations : history;
94
+ if (pool.length === 0)
95
+ return undefined;
96
+ const avg = pool.reduce((a, b) => a + b, 0) / pool.length;
97
+ const remainingStories = Math.max(0, p.total - p.passed);
98
+ return { avgStoryMs: Math.round(avg), remainingStories, etaMs: Math.round(avg * remainingStories) };
99
+ };
100
+ const persist = (status, logLabel, consoleLine) => {
101
+ const withPercent = { ...status, percent: percentOf(status.progress) };
102
+ const next = tokens ? { ...withPercent, tokens: { ...tokens } } : withPercent;
53
103
  current = next;
54
104
  try {
55
105
  writeStatus(dir, next);
56
106
  appendLog(dir, `${next.updatedAt} ${logLabel} ${next.story ?? '-'} ${next.reason ?? ''}`.trimEnd());
57
107
  }
58
108
  catch { /* observability must never abort the loop */ }
59
- emitConsole(consoleLine);
109
+ // json mode owns stdout: one machine-readable line per status write, no narrative.
110
+ if (opts.json)
111
+ sink(JSON.stringify({ type: 'status', ...next }));
112
+ else
113
+ emitConsole(consoleLine);
60
114
  };
61
115
  return {
62
116
  storyStart(story, iteration, progress) {
63
117
  const ts = now().toISOString();
118
+ storyStartedAt = now().getTime();
119
+ const eta = etaFor(progress);
120
+ const hint = eta ? ` · ~${fmtDuration(eta.etaMs)} left (Ø ${fmtDuration(eta.avgStoryMs)}/story)` : '';
64
121
  persist({ state: 'running', phase: 'implementing', story: story.id, storyTitle: story.title,
65
- iteration, progress, startedAt: ts, updatedAt: ts }, 'implementing', `▶ ${story.id} (${progress.passed}/${progress.total}) — implementing…`);
122
+ iteration, progress, ...(eta ? { eta } : {}), startedAt: ts, updatedAt: ts }, 'implementing', `▶ ${story.id} (${progress.passed}/${progress.total} · ${percentOf(progress)}%) — implementing…${hint}`);
123
+ },
124
+ storyDone(story, progress) {
125
+ const t = now().getTime();
126
+ const ms = storyStartedAt !== null ? Math.max(0, t - storyStartedAt) : undefined;
127
+ storyStartedAt = null;
128
+ if (ms !== undefined) {
129
+ runDurations.push(ms);
130
+ appendDuration(dir, { storyId: story.id, ms });
131
+ }
132
+ const eta = etaFor(progress);
133
+ const base = current ?? emptyStatus(now().toISOString());
134
+ const took = ms !== undefined ? ` in ${fmtDuration(ms)}` : '';
135
+ const hint = eta && eta.remainingStories > 0 ? ` · ~${fmtDuration(eta.etaMs)} left` : '';
136
+ persist({ ...base, state: 'running', phase: undefined, story: story.id, storyTitle: story.title,
137
+ progress, ...(eta ? { eta } : {}), updatedAt: now().toISOString() }, 'story-done', `✓ ${story.id} done${took} — ${progress.passed}/${progress.total} (${percentOf(progress)}%)${hint}`);
66
138
  },
67
139
  phase(phase) {
68
140
  if (!current)
@@ -81,11 +153,23 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
81
153
  persist({ ...(current ?? emptyStatus(now().toISOString())), state: 'cap-reached', phase: undefined,
82
154
  progress, updatedAt: now().toISOString() }, 'cap-reached', `◾ iteration cap reached — ${progress.passed}/${progress.total}`);
83
155
  },
156
+ paused(progress) {
157
+ persist({ ...(current ?? emptyStatus(now().toISOString())), state: 'paused', phase: undefined,
158
+ progress, updatedAt: now().toISOString() }, 'paused', `⏸ loop paused — ${progress.passed}/${progress.total}`);
159
+ },
160
+ addTokens(usage) {
161
+ const model = usage.model ?? tokens?.model;
162
+ tokens = {
163
+ inputTokens: (tokens?.inputTokens ?? 0) + usage.inputTokens,
164
+ outputTokens: (tokens?.outputTokens ?? 0) + usage.outputTokens,
165
+ ...(model ? { model } : {}),
166
+ };
167
+ },
84
168
  };
85
169
  }
86
170
  function emptyStatus(ts) {
87
171
  return { state: 'running', iteration: 0, progress: { passed: 0, total: 0 }, startedAt: ts, updatedAt: ts };
88
172
  }
89
173
  export const noopReporter = {
90
- storyStart() { }, phase() { }, blocked() { }, complete() { }, capReached() { },
174
+ storyStart() { }, storyDone() { }, phase() { }, blocked() { }, complete() { }, capReached() { }, paused() { }, addTokens() { },
91
175
  };