@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
@@ -1,4 +1,6 @@
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) {
@@ -12,7 +14,7 @@ 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.', '- Keep your final message to a few short sentences: what changed and what you verified.');
16
18
  return lines.join('\n');
17
19
  }
18
20
  export function buildReviewPrompt(story, context) {
@@ -23,7 +25,7 @@ export function buildReviewPrompt(story, context) {
23
25
  ];
24
26
  if (context)
25
27
  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.');
28
+ 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
29
  return lines.join('\n');
28
30
  }
29
31
  export function buildStandaloneReviewPrompt(scope, focus) {
@@ -34,13 +36,21 @@ export function buildStandaloneReviewPrompt(scope, focus) {
34
36
  ];
35
37
  if (focus)
36
38
  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.');
39
+ 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
40
  return lines.join('\n');
39
41
  }
42
+ // Headless agents must run non-interactively: with plain `-p` the CLI denies
43
+ // every file-write/permission prompt, so the implementer "runs" (exit 0) but
44
+ // produces NOTHING. The loop then sees a clean tree + green pre-existing tests
45
+ // and falsely marks the story done. Granting autonomous permissions makes the
46
+ // implementer actually able to write files and run the verify command.
47
+ // (The loop is opt-in and scoped to the target project dir.)
40
48
  const AGENT_SPECS = {
41
- claude: { command: 'claude', baseArgs: ['-p'] },
42
- codex: { command: 'codex', baseArgs: ['exec'] },
43
- gemini: { command: 'gemini', baseArgs: ['-p'] },
49
+ claude: { command: 'claude', baseArgs: ['-p', '--dangerously-skip-permissions'] },
50
+ codex: { command: 'codex', baseArgs: ['exec', '--dangerously-bypass-approvals-and-sandbox'] },
51
+ // gemini: no `-p` current Gemini CLI (0.33+) requires a value after -p, and
52
+ // piped (non-TTY) stdin already selects headless mode on its own.
53
+ gemini: { command: 'gemini', baseArgs: ['--yolo'] },
44
54
  };
45
55
  export function agentInvocation(agent, prompt, cwd) {
46
56
  const spec = AGENT_SPECS[agent];
@@ -49,18 +59,95 @@ export function agentInvocation(agent, prompt, cwd) {
49
59
  export function claudeInvocation(prompt, cwd) {
50
60
  return agentInvocation('claude', prompt, cwd);
51
61
  }
62
+ // Token-reporting variant: stream-json makes claude emit per-message usage on stdout
63
+ // (--verbose is required by the CLI for stream-json in -p mode). Prompt still via stdin.
64
+ // Derived from the base spec so the headless permission-bypass flag rides along.
65
+ export function claudeStreamJsonInvocation(prompt, cwd) {
66
+ return { command: 'claude', args: [...AGENT_SPECS.claude.baseArgs, '--output-format', 'stream-json', '--verbose'], input: prompt, cwd };
67
+ }
68
+ // Pick the runner invocation. Claude ALWAYS runs in stream-json mode: plain `-p`
69
+ // prints nothing until the run finishes, so the idle watchdog saw a healthy
70
+ // long story as a dead process and killed it at exactly the idle window — and
71
+ // the user saw dead air the whole time. stream-json emits per-message output,
72
+ // which doubles as liveness. Token usage rides along for free. Other agents
73
+ // keep their plain invocation (no machine-readable stream to gain).
74
+ export function runnerInvocation(agent, prompt, cwd, _tokenReport = false) {
75
+ if (agent === 'claude')
76
+ return claudeStreamJsonInvocation(prompt, cwd);
77
+ return agentInvocation(agent, prompt, cwd);
78
+ }
79
+ // Parse claude stream-json output into cumulative token usage. Defensive by design:
80
+ // non-JSON lines and unknown message shapes are ignored. The final "result" message
81
+ // carries the run's cumulative usage — prefer it (last one wins); if it is absent
82
+ // (e.g. the process died mid-run), fall back to summing assistant-message usage.
83
+ // Also tracks the model id: the "system"/"init" message and "assistant" messages both
84
+ // carry a model field — the LAST one seen across the stream wins; absent if none did.
85
+ export function parseClaudeStreamUsage(lines) {
86
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
87
+ const usageOf = (v) => {
88
+ if (typeof v !== 'object' || v === null)
89
+ return null;
90
+ const u = v;
91
+ if (u.input_tokens === undefined && u.output_tokens === undefined)
92
+ return null;
93
+ return { input: num(u.input_tokens), output: num(u.output_tokens) };
94
+ };
95
+ const modelOf = (v) => (typeof v === 'string' && v.length > 0 ? v : undefined);
96
+ let assistantIn = 0;
97
+ let assistantOut = 0;
98
+ let result;
99
+ let model;
100
+ for (const line of lines) {
101
+ let msg;
102
+ try {
103
+ msg = JSON.parse(line);
104
+ }
105
+ catch {
106
+ continue;
107
+ }
108
+ if (typeof msg !== 'object' || msg === null || Array.isArray(msg))
109
+ continue;
110
+ const m = msg;
111
+ if (m.type === 'result') {
112
+ const u = usageOf(m.usage);
113
+ if (u)
114
+ result = { inputTokens: u.input, outputTokens: u.output };
115
+ }
116
+ else if (m.type === 'assistant') {
117
+ const message = m.message;
118
+ const u = usageOf(message?.usage);
119
+ if (u) {
120
+ assistantIn += u.input;
121
+ assistantOut += u.output;
122
+ }
123
+ model = modelOf(message?.model) ?? model;
124
+ }
125
+ else if (m.type === 'system' && m.subtype === 'init') {
126
+ model = modelOf(m.model) ?? model;
127
+ }
128
+ }
129
+ const usage = result ?? { inputTokens: assistantIn, outputTokens: assistantOut };
130
+ return model ? { ...usage, model } : usage;
131
+ }
52
132
  function watchdogPath() {
53
133
  // runner.js and watchdog.js sit side by side (dist/loop/ at runtime, src/loop/ under tsx)
54
134
  return fileURLToPath(new URL('./watchdog.js', import.meta.url));
55
135
  }
56
136
  // When idleTimeoutMs > 0, run the agent THROUGH the watchdog so a silent hang is
57
137
  // killed after idleTimeoutMs of no output. The prompt still flows via stdin.
138
+ // If the run dir has a .yoke dir, the watchdog also records its pids in
139
+ // .yoke/runner.pid so `yoke loop cleanup` can reap orphans PROJECT-SCOPED —
140
+ // killing by process-name/command-line pattern takes down other projects'
141
+ // runners too. (Plain repos, e.g. `yoke review` outside a yoke project, get
142
+ // no pid file rather than a littered .yoke dir.)
58
143
  export function buildWatchdogInvocation(inv, idleTimeoutMs) {
59
144
  if (idleTimeoutMs <= 0)
60
145
  return inv;
146
+ const yokeDir = join(inv.cwd, '.yoke');
147
+ const pidArgs = existsSync(yokeDir) ? [`--pid-file=${join(yokeDir, 'runner.pid')}`] : [];
61
148
  return {
62
149
  command: 'node',
63
- args: [watchdogPath(), `--idle-ms=${idleTimeoutMs}`, '--', inv.command, ...inv.args],
150
+ args: [watchdogPath(), `--idle-ms=${idleTimeoutMs}`, ...pidArgs, '--', inv.command, ...inv.args],
64
151
  input: inv.input,
65
152
  cwd: inv.cwd,
66
153
  };
@@ -94,15 +181,27 @@ function runCli(inv) {
94
181
  });
95
182
  }
96
183
  }
184
+ // Like runCli, but with stdout PIPED and returned (stderr stays inherited) — for
185
+ // token reporting, where the agent's stdout is a machine-readable stream-json feed.
186
+ // The watchdog wrapper forwards the child's stdout to its own, so piping still works
187
+ // through it. Throws on a non-zero exit; the error carries the partial stdout.
188
+ function runCliCapture(inv) {
189
+ const opts = { cwd: inv.cwd, input: inv.input, stdio: ['pipe', 'pipe', 'inherit'], encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 };
190
+ return process.platform === 'win32'
191
+ ? execSync(win32CommandString(inv.command, inv.args), opts)
192
+ : execFileSync(inv.command, inv.args, opts);
193
+ }
97
194
  // Probe whether a CLI is on PATH via `<command> --version`. Same win32/other split
98
- // as runCli to stay DEP0190-free. Never throws.
195
+ // as runCli to stay DEP0190-free. Never throws. Timeout is generous because some
196
+ // agent CLIs cold-start slowly (gemini needs ~6s on Windows; 5s misreported it
197
+ // as "not installed").
99
198
  function probeVersion(command) {
100
199
  try {
101
200
  if (process.platform === 'win32') {
102
- execSync(`${command} --version`, { stdio: 'pipe', timeout: 5000 });
201
+ execSync(`${command} --version`, { stdio: 'pipe', timeout: 20000 });
103
202
  }
104
203
  else {
105
- execFileSync(command, ['--version'], { stdio: 'pipe', timeout: 5000 });
204
+ execFileSync(command, ['--version'], { stdio: 'pipe', timeout: 20000 });
106
205
  }
107
206
  return true;
108
207
  }
@@ -121,14 +220,32 @@ export function runAgent(inv) {
121
220
  return { success: false, summary: e.message };
122
221
  }
123
222
  }
124
- export function makeRunner(agent, idleTimeoutMs = 0) {
223
+ export function makeRunner(agent, idleTimeoutMs = 0, opts = {}) {
224
+ // Claude always streams (see runnerInvocation) — capture the stream so tokens are
225
+ // always reported; other agents keep inherit stdio. opts.tokenReport is now
226
+ // redundant for claude and meaningless elsewhere; kept for caller compatibility.
227
+ const captureTokens = agent === 'claude';
125
228
  return (ctx) => {
126
- const base = agentInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir)), ctx.targetDir);
229
+ const base = runnerInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir)), ctx.targetDir, captureTokens);
127
230
  const inv = buildWatchdogInvocation(base, idleTimeoutMs);
231
+ if (captureTokens) {
232
+ const capture = opts.execCapture ?? runCliCapture;
233
+ try {
234
+ const out = capture(inv);
235
+ return { success: true, summary: `${agent} implemented ${ctx.story.id}`, tokens: parseClaudeStreamUsage(out.split(/\r?\n/)) };
236
+ }
237
+ catch (e) {
238
+ // Salvage usage from whatever the agent streamed before dying — those tokens were spent.
239
+ const partial = e.stdout;
240
+ const tokens = partial == null ? undefined : parseClaudeStreamUsage(String(partial).split(/\r?\n/));
241
+ return { success: false, summary: `${agent} failed on ${ctx.story.id}: ${e.message}`, tokens };
242
+ }
243
+ }
128
244
  try {
129
245
  // NOTE: the loop trusts the agent's exit code as a proxy for "it ran".
130
246
  // Independent verification happens in the loop (Baustein C2), not here.
131
- runCli(inv);
247
+ ;
248
+ (opts.exec ?? runCli)(inv);
132
249
  return { success: true, summary: `${agent} implemented ${ctx.story.id}` };
133
250
  }
134
251
  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
  }
@@ -13,6 +13,8 @@ export const YokeConfigSchema = z.object({
13
13
  verify: z.object({ command: z.string().min(1), retries: z.number().int().nonnegative().optional() }).optional(),
14
14
  codeGraph: CodeGraphSchema.optional(),
15
15
  smoke: SmokeSchema.optional(),
16
+ // Opt-in: upgrade yoke at loop START when a newer version is cached (never mid-run).
17
+ update: z.object({ auto: z.boolean() }).optional(),
16
18
  });
17
19
  export function defaultConfig(canonVersion) {
18
20
  return { canonVersion, agents: [], loop: { enabled: false } };
@@ -6,6 +6,8 @@ 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',
9
11
  '.yoke/proof/',
10
12
  ];
11
13
  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
+ }
@@ -0,0 +1,86 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { spawn } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
6
+ export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
7
+ // Machine-global (not per-project): one cache under the user's home dir.
8
+ export function cachePath() {
9
+ return join(homedir(), '.yoke', 'update-check.json');
10
+ }
11
+ export function readCache(file = cachePath()) {
12
+ if (!existsSync(file))
13
+ return null;
14
+ try {
15
+ const raw = JSON.parse(readFileSync(file, 'utf8'));
16
+ if (typeof raw.checkedAt === 'string' && typeof raw.latest === 'string') {
17
+ return { checkedAt: raw.checkedAt, latest: raw.latest };
18
+ }
19
+ }
20
+ catch { /* malformed — treat as absent */ }
21
+ return null;
22
+ }
23
+ export function writeCache(cache, file = cachePath()) {
24
+ mkdirSync(dirname(file), { recursive: true });
25
+ writeFileSync(file, JSON.stringify(cache));
26
+ }
27
+ // Strict numeric triple compare; prerelease suffixes are stripped (a prerelease
28
+ // of the same triple is not "newer"). Malformed input is never newer.
29
+ export function isNewer(candidate, current) {
30
+ const triple = (v) => {
31
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
32
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
33
+ };
34
+ const a = triple(candidate);
35
+ const b = triple(current);
36
+ if (!a || !b)
37
+ return false;
38
+ for (let i = 0; i < 3; i++) {
39
+ if (a[i] !== b[i])
40
+ return a[i] > b[i];
41
+ }
42
+ return false;
43
+ }
44
+ export function isStale(cache, now) {
45
+ if (!cache)
46
+ return true;
47
+ const t = Date.parse(cache.checkedAt);
48
+ if (!Number.isFinite(t))
49
+ return true;
50
+ return now.getTime() - t > CHECK_INTERVAL_MS;
51
+ }
52
+ export function currentYokeVersion() {
53
+ // src/update/ and dist/update/ both sit two levels below the package root.
54
+ const pkg = JSON.parse(readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8'));
55
+ return pkg.version;
56
+ }
57
+ function spawnDetachedRefresh() {
58
+ const refresh = fileURLToPath(new URL('./refresh.js', import.meta.url));
59
+ try {
60
+ spawn(process.execPath, [refresh], { detached: true, stdio: 'ignore' }).unref();
61
+ }
62
+ catch { /* best-effort — next invocation retries */ }
63
+ }
64
+ // Print (to stderr) a one-line hint when a newer version is cached; kick off a
65
+ // detached cache refresh when stale. Silent in CI, --json runs, non-TTY pipes,
66
+ // and under YOKE_NO_UPDATE_CHECK=1. Never throws, never blocks.
67
+ export function maybeNotifyUpdate(currentVersion, opts = {}) {
68
+ const env = opts.env ?? process.env;
69
+ if (env.YOKE_NO_UPDATE_CHECK || env.CI)
70
+ return;
71
+ const argv = opts.argv ?? process.argv;
72
+ if (argv.includes('--json'))
73
+ return;
74
+ const tty = opts.tty ?? process.stderr.isTTY === true;
75
+ if (!tty)
76
+ return;
77
+ const cache = readCache(opts.cacheFile);
78
+ if (isStale(cache, opts.now ?? new Date())) {
79
+ (opts.spawnRefresh ?? spawnDetachedRefresh)();
80
+ return;
81
+ }
82
+ if (cache && isNewer(cache.latest, currentVersion)) {
83
+ const print = opts.print ?? ((m) => console.error(m));
84
+ print(`yoke ${currentVersion} → ${cache.latest} available — run: yoke upgrade (disable this check: YOKE_NO_UPDATE_CHECK=1)`);
85
+ }
86
+ }
@@ -0,0 +1,28 @@
1
+ import { pathToFileURL } from 'node:url';
2
+ import { writeCache } from './check.js';
3
+ // Detached background refresher (spawned by maybeNotifyUpdate when the cache
4
+ // is stale): one small registry request, write the cache, exit. Failures are
5
+ // silent by design — the notifier simply retries on a later invocation.
6
+ const REGISTRY_LATEST = 'https://registry.npmjs.org/@hecer%2fyoke/latest';
7
+ export async function refreshOnce(opts = {}) {
8
+ const fetchFn = opts.fetchFn ?? fetch;
9
+ const ctrl = new AbortController();
10
+ const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? 5000);
11
+ try {
12
+ const res = await fetchFn(REGISTRY_LATEST, { signal: ctrl.signal });
13
+ if (!res.ok)
14
+ return;
15
+ const body = await res.json();
16
+ if (typeof body.version === 'string') {
17
+ writeCache({ checkedAt: (opts.now ?? new Date()).toISOString(), latest: body.version }, opts.cacheFile);
18
+ }
19
+ }
20
+ catch { /* offline / blocked / registry down — silent */ }
21
+ finally {
22
+ clearTimeout(timer);
23
+ }
24
+ }
25
+ const isMain = process.argv[1] ? pathToFileURL(process.argv[1]).href === import.meta.url : false;
26
+ if (isMain) {
27
+ refreshOnce().then(() => process.exit(0));
28
+ }
@@ -0,0 +1,30 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { readCache, isNewer, currentYokeVersion } from './check.js';
3
+ // `yoke upgrade` — the explicit path to the latest release.
4
+ export function runUpgrade(opts = {}) {
5
+ const exec = opts.exec ?? ((cmd) => { execSync(cmd, { stdio: 'inherit' }); });
6
+ console.log('Upgrading @hecer/yoke to latest...');
7
+ try {
8
+ exec('npm install -g @hecer/yoke@latest');
9
+ }
10
+ catch (e) {
11
+ console.error(`Upgrade failed: ${e.message}`);
12
+ console.error('Running from a git clone? Upgrade with: git pull && npm install && npm run build');
13
+ return 1;
14
+ }
15
+ console.log('✓ upgraded — the new version is active on the next yoke invocation.');
16
+ return 0;
17
+ }
18
+ // Opt-in auto-upgrade (`update.auto: true` in .yoke/config.yaml), evaluated at
19
+ // loop START only — never mid-run: the current process keeps executing the
20
+ // version it started with; the upgrade takes effect on the next invocation.
21
+ export function maybeAutoUpgrade(auto, opts = {}) {
22
+ if (auto !== true)
23
+ return;
24
+ const cache = readCache(opts.cacheFile);
25
+ const current = currentYokeVersion();
26
+ if (cache && isNewer(cache.latest, current)) {
27
+ console.error(`update.auto: upgrading yoke ${current} → ${cache.latest} (this run continues on ${current}; the upgrade applies from the next run)`);
28
+ runUpgrade(opts);
29
+ }
30
+ }