@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
@@ -6,8 +6,9 @@ import { runLoop } from './loop.js';
6
6
  import { realGitOps } from './git.js';
7
7
  import { makeRunner, makeReviewRunner, isAgentAvailable } from './runner.js';
8
8
  import { commandVerifier, retryingVerifier } from './verify.js';
9
- import { readStatus, makeReporter } from './reporter.js';
9
+ import { readStatus, makeReporter, fmtDuration } 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) {
@@ -45,9 +46,13 @@ export function loopStatus(targetDir, now = () => new Date()) {
45
46
  if (!st)
46
47
  return `Loop: ${enabled ? 'enabled' : 'disabled'}\nPRD: ${prog}`;
47
48
  const head = `Loop: ${st.state.toUpperCase()}${st.story ? ` on ${st.story}${st.storyTitle ? ` "${st.storyTitle}"` : ''}` : ''}`;
48
- const meta = [st.phase, `iteration ${st.iteration}`, `${st.progress.passed}/${st.progress.total}`, `updated ${relativeTime(st.updatedAt, now())}`]
49
+ const pct = st.percent !== undefined ? ` (${st.percent}%)` : '';
50
+ const meta = [st.phase, `iteration ${st.iteration}`, `${st.progress.passed}/${st.progress.total}${pct}`, `updated ${relativeTime(st.updatedAt, now())}`]
49
51
  .filter(Boolean).join(' · ');
50
52
  const lines = [head, ` ${meta}`];
53
+ if (st.state === 'running' && st.eta && st.eta.remainingStories > 0) {
54
+ lines.push(` ~${fmtDuration(st.eta.etaMs)} remaining (Ø ${fmtDuration(st.eta.avgStoryMs)}/story)`);
55
+ }
51
56
  if (st.reason)
52
57
  lines.push(` reason: ${st.reason}`);
53
58
  const ageMs = now().getTime() - Date.parse(st.updatedAt);
@@ -80,6 +85,9 @@ export function runLoopCommand(targetDir, opts) {
80
85
  }
81
86
  verify = retryingVerifier(commandVerifier(command), config.verify?.retries ?? 1);
82
87
  }
88
+ // Opt-in self-update, loop START only — this run keeps executing the version
89
+ // it started with; a fetched upgrade applies from the next invocation.
90
+ maybeAutoUpgrade(config.update?.auto);
83
91
  const available = opts.isAvailable ?? isAgentAvailable;
84
92
  const runnerAgent = opts.agent ?? config.agents[0] ?? 'claude';
85
93
  const idleMs = resolveIdleMs(opts.timeoutMinutes, config.loop.timeoutMinutes);
@@ -89,7 +97,9 @@ export function runLoopCommand(targetDir, opts) {
89
97
  console.error(`Agent CLI "${runnerAgent}" was not found on PATH. Install it, or pick another with --runner=<claude|codex|gemini>.`);
90
98
  return 2;
91
99
  }
92
- runner = makeRunner(runnerAgent, idleMs);
100
+ // Token reporting is part of the machine interface: in --json mode a claude
101
+ // runner switches to stream-json so cumulative usage rides on every status.
102
+ runner = makeRunner(runnerAgent, idleMs, { tokenReport: opts.json === true, onAmbiguity: opts.onAmbiguity ?? config.loop.onAmbiguity });
93
103
  }
94
104
  let review = opts.reviewRunner;
95
105
  if (!review && (opts.review || opts.reviewer)) {
@@ -118,15 +128,22 @@ export function runLoopCommand(targetDir, opts) {
118
128
  maxIterations: opts.maxIterations,
119
129
  isolate: opts.isolate ?? false,
120
130
  review,
121
- reporter: opts.reporter ?? makeReporter(targetDir),
131
+ reporter: opts.reporter ?? makeReporter(targetDir, { json: opts.json }),
122
132
  });
123
- console.log(`Loop ${result.status} after ${result.iterations} iteration(s): ${result.finalProgress.passed}/${result.finalProgress.total} stories pass`);
133
+ // In json mode stdout belongs to the NDJSON stream — route the narrative summary to stderr.
134
+ const say = opts.json ? (line) => console.error(line) : (line) => console.log(line);
135
+ say(`Loop ${result.status} after ${result.iterations} iteration(s): ${result.finalProgress.passed}/${result.finalProgress.total} stories pass`);
124
136
  if (result.reason)
125
- console.log(`Reason: ${result.reason}`);
137
+ say(`Reason: ${result.reason}`);
126
138
  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.');
139
+ say('Hint: the agent CLI has no credentials in this environment. Set ANTHROPIC_API_KEY or log the agent in for headless use.');
128
140
  }
129
- return result.status === 'complete' ? 0 : 1;
141
+ // Exit codes: 0 complete · 1 blocked/cap-reached · 2 config error (handled above) · 3 paused (loop.pause consumed at a story boundary)
142
+ if (result.status === 'complete')
143
+ return 0;
144
+ if (result.status === 'paused')
145
+ return 3;
146
+ return 1;
130
147
  }
131
148
  finally {
132
149
  releaseLock(targetDir);
@@ -1,10 +1,12 @@
1
1
  import { execFileSync, execSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
2
4
  import { fileURLToPath } from 'node:url';
3
5
  import { loadContext, formatForPrompt, contextDir } from '../context/context.js';
4
6
  export function contextBlockFor(targetDir) {
5
7
  return formatForPrompt(loadContext(contextDir(targetDir)));
6
8
  }
7
- export function buildClaudePrompt(story, context) {
9
+ export function buildClaudePrompt(story, context, onAmbiguity = 'resolve') {
8
10
  const criteria = story.acceptance.map(a => `- ${a}`).join('\n');
9
11
  const lines = [
10
12
  'You are an autonomous coding agent running inside the Yoke loop.',
@@ -12,7 +14,9 @@ export function buildClaudePrompt(story, context) {
12
14
  ];
13
15
  if (context)
14
16
  lines.push('', context);
15
- lines.push('', `Story ${story.id}: ${story.title}`, 'Acceptance criteria (Definition of Done):', criteria, '', "When done, ensure the project's full test suite passes.", 'Do NOT commit — the loop commits on your behalf after verifying.');
17
+ lines.push('', `Story ${story.id}: ${story.title}`, 'Acceptance criteria (Definition of Done):', criteria, '', "When done, ensure the project's full test suite passes.", 'Do NOT commit — the loop commits on your behalf after verifying.', '', 'Working rules:', '- Add nothing beyond what the story requires: no extra features, abstractions, comments, or defensive code for cases that cannot happen.', '- Do not create summary, plan, or analysis documents — only files the story itself needs.', '- If a check fails, fix the root cause; never bypass it (e.g. --no-verify) or pass by weakening tests.', '- Report the outcome faithfully: if a criterion is unmet or tests fail, say so plainly instead of claiming success.', '- Never ask questions or wait for input — you run unattended and nobody can answer.', onAmbiguity === 'abort'
18
+ ? '- If an acceptance criterion is genuinely undecidable, do NOT guess: write the open question(s) to .yoke/ambiguity.md, change nothing else, and stop.'
19
+ : '- If an acceptance criterion is ambiguous, resolve it yourself in the way most consistent with the other criteria and the existing code, and state your interpretation in your final message.', '- Keep your final message to a few short sentences: what changed and what you verified.');
16
20
  return lines.join('\n');
17
21
  }
18
22
  export function buildReviewPrompt(story, context) {
@@ -23,7 +27,7 @@ export function buildReviewPrompt(story, context) {
23
27
  ];
24
28
  if (context)
25
29
  lines.push('', context);
26
- lines.push('', `Story ${story.id}: ${story.title}`, 'Acceptance criteria:', criteria, '', 'Approve by exiting 0 ONLY if every acceptance criterion is met and the change is sound.', 'If you find ANY blocking issue (an unmet criterion, a bug, a missing test), exit non-zero to reject.', 'Do not modify files. Do not commit.');
30
+ lines.push('', `Story ${story.id}: ${story.title}`, 'Acceptance criteria:', criteria, '', 'Approve by exiting 0 ONLY if every acceptance criterion is met and the change is sound.', 'If you find ANY blocking issue (an unmet criterion, a bug, a missing test), exit non-zero to reject.', 'Base your verdict only on what the diff and test runs actually show — never assume unverified behavior.', 'Do not modify files. Do not commit.', 'Keep your verdict to a few short sentences.');
27
31
  return lines.join('\n');
28
32
  }
29
33
  export function buildStandaloneReviewPrompt(scope, focus) {
@@ -34,13 +38,21 @@ export function buildStandaloneReviewPrompt(scope, focus) {
34
38
  ];
35
39
  if (focus)
36
40
  lines.push(`Pay particular attention to: ${focus}.`);
37
- lines.push('', 'Approve by exiting 0 ONLY if the change is sound and complete.', 'If you find ANY blocking issue, exit non-zero to reject and explain what is wrong.', 'Do not modify files. Do not commit.');
41
+ lines.push('', 'Approve by exiting 0 ONLY if the change is sound and complete.', 'If you find ANY blocking issue, exit non-zero to reject and explain what is wrong.', 'Base your verdict only on what the diff and test runs actually show — never assume unverified behavior.', 'Do not modify files. Do not commit.', 'Keep your verdict to a few short sentences.');
38
42
  return lines.join('\n');
39
43
  }
44
+ // Headless agents must run non-interactively: with plain `-p` the CLI denies
45
+ // every file-write/permission prompt, so the implementer "runs" (exit 0) but
46
+ // produces NOTHING. The loop then sees a clean tree + green pre-existing tests
47
+ // and falsely marks the story done. Granting autonomous permissions makes the
48
+ // implementer actually able to write files and run the verify command.
49
+ // (The loop is opt-in and scoped to the target project dir.)
40
50
  const AGENT_SPECS = {
41
- claude: { command: 'claude', baseArgs: ['-p'] },
42
- codex: { command: 'codex', baseArgs: ['exec'] },
43
- gemini: { command: 'gemini', baseArgs: ['-p'] },
51
+ claude: { command: 'claude', baseArgs: ['-p', '--dangerously-skip-permissions'] },
52
+ codex: { command: 'codex', baseArgs: ['exec', '--dangerously-bypass-approvals-and-sandbox'] },
53
+ // gemini: no `-p` current Gemini CLI (0.33+) requires a value after -p, and
54
+ // piped (non-TTY) stdin already selects headless mode on its own.
55
+ gemini: { command: 'gemini', baseArgs: ['--yolo'] },
44
56
  };
45
57
  export function agentInvocation(agent, prompt, cwd) {
46
58
  const spec = AGENT_SPECS[agent];
@@ -49,18 +61,95 @@ export function agentInvocation(agent, prompt, cwd) {
49
61
  export function claudeInvocation(prompt, cwd) {
50
62
  return agentInvocation('claude', prompt, cwd);
51
63
  }
64
+ // Token-reporting variant: stream-json makes claude emit per-message usage on stdout
65
+ // (--verbose is required by the CLI for stream-json in -p mode). Prompt still via stdin.
66
+ // Derived from the base spec so the headless permission-bypass flag rides along.
67
+ export function claudeStreamJsonInvocation(prompt, cwd) {
68
+ return { command: 'claude', args: [...AGENT_SPECS.claude.baseArgs, '--output-format', 'stream-json', '--verbose'], input: prompt, cwd };
69
+ }
70
+ // Pick the runner invocation. Claude ALWAYS runs in stream-json mode: plain `-p`
71
+ // prints nothing until the run finishes, so the idle watchdog saw a healthy
72
+ // long story as a dead process and killed it at exactly the idle window — and
73
+ // the user saw dead air the whole time. stream-json emits per-message output,
74
+ // which doubles as liveness. Token usage rides along for free. Other agents
75
+ // keep their plain invocation (no machine-readable stream to gain).
76
+ export function runnerInvocation(agent, prompt, cwd, _tokenReport = false) {
77
+ if (agent === 'claude')
78
+ return claudeStreamJsonInvocation(prompt, cwd);
79
+ return agentInvocation(agent, prompt, cwd);
80
+ }
81
+ // Parse claude stream-json output into cumulative token usage. Defensive by design:
82
+ // non-JSON lines and unknown message shapes are ignored. The final "result" message
83
+ // carries the run's cumulative usage — prefer it (last one wins); if it is absent
84
+ // (e.g. the process died mid-run), fall back to summing assistant-message usage.
85
+ // Also tracks the model id: the "system"/"init" message and "assistant" messages both
86
+ // carry a model field — the LAST one seen across the stream wins; absent if none did.
87
+ export function parseClaudeStreamUsage(lines) {
88
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
89
+ const usageOf = (v) => {
90
+ if (typeof v !== 'object' || v === null)
91
+ return null;
92
+ const u = v;
93
+ if (u.input_tokens === undefined && u.output_tokens === undefined)
94
+ return null;
95
+ return { input: num(u.input_tokens), output: num(u.output_tokens) };
96
+ };
97
+ const modelOf = (v) => (typeof v === 'string' && v.length > 0 ? v : undefined);
98
+ let assistantIn = 0;
99
+ let assistantOut = 0;
100
+ let result;
101
+ let model;
102
+ for (const line of lines) {
103
+ let msg;
104
+ try {
105
+ msg = JSON.parse(line);
106
+ }
107
+ catch {
108
+ continue;
109
+ }
110
+ if (typeof msg !== 'object' || msg === null || Array.isArray(msg))
111
+ continue;
112
+ const m = msg;
113
+ if (m.type === 'result') {
114
+ const u = usageOf(m.usage);
115
+ if (u)
116
+ result = { inputTokens: u.input, outputTokens: u.output };
117
+ }
118
+ else if (m.type === 'assistant') {
119
+ const message = m.message;
120
+ const u = usageOf(message?.usage);
121
+ if (u) {
122
+ assistantIn += u.input;
123
+ assistantOut += u.output;
124
+ }
125
+ model = modelOf(message?.model) ?? model;
126
+ }
127
+ else if (m.type === 'system' && m.subtype === 'init') {
128
+ model = modelOf(m.model) ?? model;
129
+ }
130
+ }
131
+ const usage = result ?? { inputTokens: assistantIn, outputTokens: assistantOut };
132
+ return model ? { ...usage, model } : usage;
133
+ }
52
134
  function watchdogPath() {
53
135
  // runner.js and watchdog.js sit side by side (dist/loop/ at runtime, src/loop/ under tsx)
54
136
  return fileURLToPath(new URL('./watchdog.js', import.meta.url));
55
137
  }
56
138
  // When idleTimeoutMs > 0, run the agent THROUGH the watchdog so a silent hang is
57
139
  // killed after idleTimeoutMs of no output. The prompt still flows via stdin.
140
+ // If the run dir has a .yoke dir, the watchdog also records its pids in
141
+ // .yoke/runner.pid so `yoke loop cleanup` can reap orphans PROJECT-SCOPED —
142
+ // killing by process-name/command-line pattern takes down other projects'
143
+ // runners too. (Plain repos, e.g. `yoke review` outside a yoke project, get
144
+ // no pid file rather than a littered .yoke dir.)
58
145
  export function buildWatchdogInvocation(inv, idleTimeoutMs) {
59
146
  if (idleTimeoutMs <= 0)
60
147
  return inv;
148
+ const yokeDir = join(inv.cwd, '.yoke');
149
+ const pidArgs = existsSync(yokeDir) ? [`--pid-file=${join(yokeDir, 'runner.pid')}`] : [];
61
150
  return {
62
151
  command: 'node',
63
- args: [watchdogPath(), `--idle-ms=${idleTimeoutMs}`, '--', inv.command, ...inv.args],
152
+ args: [watchdogPath(), `--idle-ms=${idleTimeoutMs}`, ...pidArgs, '--', inv.command, ...inv.args],
64
153
  input: inv.input,
65
154
  cwd: inv.cwd,
66
155
  };
@@ -94,15 +183,27 @@ function runCli(inv) {
94
183
  });
95
184
  }
96
185
  }
186
+ // Like runCli, but with stdout PIPED and returned (stderr stays inherited) — for
187
+ // token reporting, where the agent's stdout is a machine-readable stream-json feed.
188
+ // The watchdog wrapper forwards the child's stdout to its own, so piping still works
189
+ // through it. Throws on a non-zero exit; the error carries the partial stdout.
190
+ function runCliCapture(inv) {
191
+ const opts = { cwd: inv.cwd, input: inv.input, stdio: ['pipe', 'pipe', 'inherit'], encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 };
192
+ return process.platform === 'win32'
193
+ ? execSync(win32CommandString(inv.command, inv.args), opts)
194
+ : execFileSync(inv.command, inv.args, opts);
195
+ }
97
196
  // Probe whether a CLI is on PATH via `<command> --version`. Same win32/other split
98
- // as runCli to stay DEP0190-free. Never throws.
197
+ // as runCli to stay DEP0190-free. Never throws. Timeout is generous because some
198
+ // agent CLIs cold-start slowly (gemini needs ~6s on Windows; 5s misreported it
199
+ // as "not installed").
99
200
  function probeVersion(command) {
100
201
  try {
101
202
  if (process.platform === 'win32') {
102
- execSync(`${command} --version`, { stdio: 'pipe', timeout: 5000 });
203
+ execSync(`${command} --version`, { stdio: 'pipe', timeout: 20000 });
103
204
  }
104
205
  else {
105
- execFileSync(command, ['--version'], { stdio: 'pipe', timeout: 5000 });
206
+ execFileSync(command, ['--version'], { stdio: 'pipe', timeout: 20000 });
106
207
  }
107
208
  return true;
108
209
  }
@@ -121,14 +222,32 @@ export function runAgent(inv) {
121
222
  return { success: false, summary: e.message };
122
223
  }
123
224
  }
124
- export function makeRunner(agent, idleTimeoutMs = 0) {
225
+ export function makeRunner(agent, idleTimeoutMs = 0, opts = {}) {
226
+ // Claude always streams (see runnerInvocation) — capture the stream so tokens are
227
+ // always reported; other agents keep inherit stdio. opts.tokenReport is now
228
+ // redundant for claude and meaningless elsewhere; kept for caller compatibility.
229
+ const captureTokens = agent === 'claude';
125
230
  return (ctx) => {
126
- const base = agentInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir)), ctx.targetDir);
231
+ const base = runnerInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir), opts.onAmbiguity), ctx.targetDir, captureTokens);
127
232
  const inv = buildWatchdogInvocation(base, idleTimeoutMs);
233
+ if (captureTokens) {
234
+ const capture = opts.execCapture ?? runCliCapture;
235
+ try {
236
+ const out = capture(inv);
237
+ return { success: true, summary: `${agent} implemented ${ctx.story.id}`, tokens: parseClaudeStreamUsage(out.split(/\r?\n/)) };
238
+ }
239
+ catch (e) {
240
+ // Salvage usage from whatever the agent streamed before dying — those tokens were spent.
241
+ const partial = e.stdout;
242
+ const tokens = partial == null ? undefined : parseClaudeStreamUsage(String(partial).split(/\r?\n/));
243
+ return { success: false, summary: `${agent} failed on ${ctx.story.id}: ${e.message}`, tokens };
244
+ }
245
+ }
128
246
  try {
129
247
  // NOTE: the loop trusts the agent's exit code as a proxy for "it ran".
130
248
  // Independent verification happens in the loop (Baustein C2), not here.
131
- runCli(inv);
249
+ ;
250
+ (opts.exec ?? runCli)(inv);
132
251
  return { success: true, summary: `${agent} implemented ${ctx.story.id}` };
133
252
  }
134
253
  catch (e) {
@@ -1,5 +1,31 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { writeFileSync, rmSync } from 'node:fs';
2
3
  import { pathToFileURL } from 'node:url';
4
+ // Kill one recorded process tree, platform-appropriately. Exported for
5
+ // `yoke loop cleanup` (scoped reaping of recorded runner pids).
6
+ export function killProcessTree(pid) {
7
+ if (process.platform === 'win32') {
8
+ try {
9
+ spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
10
+ }
11
+ catch { /* best-effort */ }
12
+ }
13
+ else {
14
+ try {
15
+ process.kill(pid, 'SIGKILL');
16
+ }
17
+ catch { /* already gone */ }
18
+ }
19
+ }
20
+ // win32 default: kill the whole tree. Console apps have no reliable soft-close
21
+ // on Windows, so both phases force (/F) — the grace pass is a retry, not an
22
+ // escalation. Best-effort by design: taskkill is a standard Windows component.
23
+ function taskkillTree(pid, _force) {
24
+ try {
25
+ spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
26
+ }
27
+ catch { /* best-effort */ }
28
+ }
3
29
  export function runWatchdog(opts) {
4
30
  const spawnFn = opts.spawnFn ?? spawn;
5
31
  const out = opts.out ?? ((d) => process.stdout.write(d));
@@ -11,7 +37,36 @@ export function runWatchdog(opts) {
11
37
  }
12
38
  catch { /* no stdin */ }
13
39
  }
40
+ if (opts.pidFile && child.pid !== undefined) {
41
+ try {
42
+ writeFileSync(opts.pidFile, JSON.stringify({ watchdogPid: process.pid, childPid: child.pid, startedAt: new Date().toISOString() }));
43
+ }
44
+ catch { /* best-effort — cleanup falls back to worktree/lock handling */ }
45
+ }
46
+ const removePidFile = () => {
47
+ if (opts.pidFile) {
48
+ try {
49
+ rmSync(opts.pidFile, { force: true });
50
+ }
51
+ catch { /* best-effort */ }
52
+ }
53
+ };
14
54
  const graceMs = opts.graceMs ?? 5000;
55
+ // Explicitly-passed killTree wins (including an explicit undefined, which pins
56
+ // the per-process signal path — tests use this to be platform-independent).
57
+ const killTree = 'killTree' in opts ? opts.killTree : (process.platform === 'win32' ? taskkillTree : undefined);
58
+ // Terminate the child — via the tree-killer when we have one and a pid,
59
+ // otherwise per-process signals (POSIX default; SIGKILL is uncatchable).
60
+ const terminate = (child, force) => {
61
+ if (killTree && child.pid !== undefined) {
62
+ killTree(child.pid, force);
63
+ return;
64
+ }
65
+ try {
66
+ child.kill(force ? 'SIGKILL' : 'SIGTERM');
67
+ }
68
+ catch { /* already gone */ }
69
+ };
15
70
  return new Promise((resolve) => {
16
71
  let timer;
17
72
  let graceTimer;
@@ -41,28 +96,22 @@ export function runWatchdog(opts) {
41
96
  timer = setTimeout(() => {
42
97
  timer = undefined;
43
98
  killedForIdle = true;
44
- try {
45
- child.kill('SIGTERM');
46
- }
47
- catch { /* already gone */ }
48
- // Escalation: a child that catches/ignores SIGTERM would never emit
99
+ terminate(child, false);
100
+ // Escalation: a child that catches/ignores the soft kill would never emit
49
101
  // 'close' and the promise would hang forever — defeating the watchdog.
50
- // SIGKILL is uncatchable, so the child must close and we resolve.
51
- // win32 limitation: with shell:true, SIGKILL on the shell may orphan
52
- // grandchild processes (a resource-cleanup edge, not a hang) — not fixed here.
102
+ // POSIX: SIGKILL is uncatchable. win32: taskkill /T /F again as a retry —
103
+ // and crucially the TREE dies, not just the shell, so no orphaned agent
104
+ // keeps writing to the worktree or burning API tokens.
53
105
  graceTimer = setTimeout(() => {
54
106
  graceTimer = undefined;
55
- try {
56
- child.kill('SIGKILL');
57
- }
58
- catch { /* already gone */ }
107
+ terminate(child, true);
59
108
  }, graceMs);
60
109
  }, opts.idleMs);
61
110
  };
62
111
  child.stdout.on('data', (d) => { out(d); arm(); });
63
112
  child.stderr.on('data', (d) => { err(d); arm(); });
64
- child.on('error', () => { clear(); resolve(127); });
65
- child.on('close', (code) => { clear(); resolve(killedForIdle ? 124 : (code ?? 0)); });
113
+ child.on('error', () => { clear(); removePidFile(); resolve(127); });
114
+ child.on('close', (code) => { clear(); removePidFile(); resolve(killedForIdle ? 124 : (code ?? 0)); });
66
115
  arm();
67
116
  });
68
117
  }
@@ -72,15 +121,16 @@ export function parseWatchdogArgs(argv) {
72
121
  const rest = sep === -1 ? [] : argv.slice(sep + 1);
73
122
  const idleArg = flags.find((a) => a.startsWith('--idle-ms='));
74
123
  const idleMs = idleArg ? Number(idleArg.slice('--idle-ms='.length)) : 0;
124
+ const pidFile = flags.find((a) => a.startsWith('--pid-file='))?.slice('--pid-file='.length);
75
125
  const [command, ...args] = rest;
76
- return { idleMs: Number.isFinite(idleMs) ? idleMs : 0, command: command ?? '', args };
126
+ return { idleMs: Number.isFinite(idleMs) ? idleMs : 0, command: command ?? '', args, ...(pidFile ? { pidFile } : {}) };
77
127
  }
78
128
  const isMain = process.argv[1] ? pathToFileURL(process.argv[1]).href === import.meta.url : false;
79
129
  if (isMain) {
80
- const { idleMs, command, args } = parseWatchdogArgs(process.argv.slice(2));
130
+ const { idleMs, command, args, pidFile } = parseWatchdogArgs(process.argv.slice(2));
81
131
  if (!command) {
82
132
  process.stderr.write('watchdog: no command given\n');
83
133
  process.exit(2);
84
134
  }
85
- runWatchdog({ command, args, idleMs, stdin: process.stdin }).then((code) => process.exit(code));
135
+ runWatchdog({ command, args, idleMs, stdin: process.stdin, pidFile }).then((code) => process.exit(code));
86
136
  }
@@ -1,12 +1,15 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs';
2
2
  import { join, dirname } from 'node:path';
3
3
  import { mergeJson } from './merge-json.js';
4
+ import { carryPreserved } from './preserve.js';
4
5
  export function applyActions(actions, targetDir, opts) {
5
6
  const results = [];
6
7
  for (const action of actions) {
7
8
  const dest = join(targetDir, action.target);
8
9
  let status;
9
10
  let backedUp;
11
+ let content = action.content;
12
+ let reason = action.reason;
10
13
  if (existsSync(dest)) {
11
14
  if (action.ifAbsent) {
12
15
  results.push({ target: action.target, status: 'unchanged', reason: `${action.reason} (exists, left untouched)` });
@@ -34,7 +37,11 @@ export function applyActions(actions, targetDir, opts) {
34
37
  results.push({ target: action.target, status: 'merged', backedUp, reason: action.reason });
35
38
  continue;
36
39
  }
37
- if (current === action.content) {
40
+ // Carry user content marked with yoke preserve markers into the new file.
41
+ content = carryPreserved(current, action.content);
42
+ if (content !== action.content)
43
+ reason = `${action.reason} (preserve block kept)`;
44
+ if (current === content) {
38
45
  results.push({ target: action.target, status: 'unchanged', reason: action.reason });
39
46
  continue;
40
47
  }
@@ -47,8 +54,8 @@ export function applyActions(actions, targetDir, opts) {
47
54
  status = 'created';
48
55
  }
49
56
  mkdirSync(dirname(dest), { recursive: true });
50
- writeFileSync(dest, action.content);
51
- results.push({ target: action.target, status, backedUp, reason: action.reason });
57
+ writeFileSync(dest, content);
58
+ results.push({ target: action.target, status, backedUp, reason });
52
59
  }
53
60
  return results;
54
61
  }
@@ -9,10 +9,18 @@ const SmokeSchema = z.object({ baseUrl: z.string().min(1), flows: z.array(SmokeF
9
9
  export const YokeConfigSchema = z.object({
10
10
  canonVersion: z.string().min(1),
11
11
  agents: z.array(AgentSchema),
12
- loop: z.object({ enabled: z.boolean(), timeoutMinutes: z.number().optional() }),
12
+ loop: z.object({
13
+ enabled: z.boolean(),
14
+ timeoutMinutes: z.number().optional(),
15
+ // Ambiguous acceptance criteria: 'resolve' (default — agent decides and continues)
16
+ // or 'abort' (agent stops the story via .yoke/ambiguity.md for a human decision).
17
+ onAmbiguity: z.enum(['resolve', 'abort']).optional(),
18
+ }),
13
19
  verify: z.object({ command: z.string().min(1), retries: z.number().int().nonnegative().optional() }).optional(),
14
20
  codeGraph: CodeGraphSchema.optional(),
15
21
  smoke: SmokeSchema.optional(),
22
+ // Opt-in: upgrade yoke at loop START when a newer version is cached (never mid-run).
23
+ update: z.object({ auto: z.boolean() }).optional(),
16
24
  });
17
25
  export function defaultConfig(canonVersion) {
18
26
  return { canonVersion, agents: [], loop: { enabled: false } };
@@ -6,6 +6,10 @@ export const YOKE_IGNORE_LINES = [
6
6
  '.yoke/loop-status.json',
7
7
  '.yoke/loop.log',
8
8
  '.yoke/loop.lock',
9
+ '.yoke/loop.pause',
10
+ '.yoke/runner.pid',
11
+ '.yoke/ambiguity.md',
12
+ '.yoke/story-durations.json',
9
13
  '.yoke/proof/',
10
14
  ];
11
15
  const HEADER = '# Yoke runtime artifacts (managed by yoke retrofit)';
@@ -4,6 +4,7 @@ import { loadManifest } from '../../canon/manifest.js';
4
4
  import { mcpServers, rtkInstruction } from '../tools.js';
5
5
  import { hasWsl } from '../wsl.js';
6
6
  import { detectGstack } from '../gstack.js';
7
+ import { PRESERVE_SCAFFOLD } from '../preserve.js';
7
8
  const GSTACK_COMPOSE = `## Composed tools (gstack detected)
8
9
 
9
10
  This project also has [gstack](https://github.com/garrytan/gstack) installed. For capabilities Yoke does not ship, prefer gstack's skills:
@@ -17,7 +18,9 @@ const claudeMd = (rtkNote, composeNote) => `# Project Instructions
17
18
  This project uses the Yoke harness. Baseline instructions:
18
19
 
19
20
  @AGENTS.md
20
- ${rtkNote ? `\n${rtkNote}\n` : ''}${composeNote ? `\n${composeNote}\n` : ''}`;
21
+ ${rtkNote ? `\n${rtkNote}\n` : ''}${composeNote ? `\n${composeNote}\n` : ''}
22
+ ${PRESERVE_SCAFFOLD}
23
+ `;
21
24
  export function planClaude(canonDir, targetDir, wslAvailable = hasWsl(), codeGraph = 'graphify', gstackDetected = detectGstack(targetDir)) {
22
25
  const manifest = loadManifest(join(canonDir, 'manifest.yaml'));
23
26
  const actions = [];
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { loadManifest } from '../../canon/manifest.js';
4
4
  import { parseFrontmatter } from '../../canon/frontmatter.js';
5
5
  import { mcpServers, rtkInstruction } from '../tools.js';
6
+ import { PRESERVE_SCAFFOLD } from '../preserve.js';
6
7
  function tomlString(s) {
7
8
  return '"""\n' + s.replace(/\\/g, '\\\\').replace(/"""/g, '\\"\\"\\"') + '\n"""';
8
9
  }
@@ -14,7 +15,7 @@ export function planGemini(canonDir, _targetDir, codeGraph = 'graphify') {
14
15
  actions.push({
15
16
  kind: 'write',
16
17
  target: 'GEMINI.md',
17
- content: `${baseline}\n${rtkInstruction()}\n`,
18
+ content: `${baseline}\n${rtkInstruction()}\n\n${PRESERVE_SCAFFOLD}\n`,
18
19
  reason: 'baseline + rtk instruction (no hook on Gemini)',
19
20
  });
20
21
  // One TOML slash command per skill.
@@ -0,0 +1,51 @@
1
+ export const PRESERVE_START = '<!-- yoke:preserve:start -->';
2
+ export const PRESERVE_END = '<!-- yoke:preserve:end -->';
3
+ export const PRESERVE_SCAFFOLD = `${PRESERVE_START}
4
+ <!-- Project-specific instructions go here. Yoke keeps this block across retrofits. -->
5
+ ${PRESERVE_END}`;
6
+ /**
7
+ * Extract the inner content of every balanced preserve-marker pair, in order.
8
+ * An unbalanced start marker (no matching end) is ignored from that point on.
9
+ */
10
+ export function extractPreserveBlocks(content) {
11
+ const blocks = [];
12
+ let idx = 0;
13
+ for (;;) {
14
+ const start = content.indexOf(PRESERVE_START, idx);
15
+ if (start === -1)
16
+ break;
17
+ const innerStart = start + PRESERVE_START.length;
18
+ const end = content.indexOf(PRESERVE_END, innerStart);
19
+ if (end === -1)
20
+ break;
21
+ blocks.push(content
22
+ .slice(innerStart, end)
23
+ .replace(/^\r?\n/, '')
24
+ .replace(/\r?\n[ \t]*$/, ''));
25
+ idx = end + PRESERVE_END.length;
26
+ }
27
+ return blocks;
28
+ }
29
+ /**
30
+ * Carry the preserve blocks of `current` (the file on disk) into `incoming`
31
+ * (the freshly generated content).
32
+ *
33
+ * - `current` has no blocks → `incoming` is returned unchanged.
34
+ * - `incoming` has a marker pair (the scaffold) → its inner is replaced with
35
+ * the preserved content (multiple blocks joined by a blank line).
36
+ * - `incoming` has no markers → one preserved block is appended at the end.
37
+ */
38
+ export function carryPreserved(current, incoming) {
39
+ const blocks = extractPreserveBlocks(current);
40
+ if (blocks.length === 0)
41
+ return incoming;
42
+ const inner = blocks.join('\n\n');
43
+ const start = incoming.indexOf(PRESERVE_START);
44
+ const end = start === -1 ? -1 : incoming.indexOf(PRESERVE_END, start + PRESERVE_START.length);
45
+ if (start !== -1 && end !== -1) {
46
+ return (incoming.slice(0, start + PRESERVE_START.length) +
47
+ '\n' + inner + '\n' +
48
+ incoming.slice(end));
49
+ }
50
+ return incoming.replace(/\n*$/, '\n\n') + `${PRESERVE_START}\n${inner}\n${PRESERVE_END}\n`;
51
+ }