@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.
- package/CHANGELOG.md +112 -0
- package/README.md +557 -494
- package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/config.yaml +6 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/context/DECISIONS.md +9 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/prd.yaml +38 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/bench-verify.mjs +15 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/package.json +9 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/src/index.mjs +48 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-1.test.mjs +24 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-2.test.mjs +28 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-3.test.mjs +25 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/config.yaml +6 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/prd.yaml +32 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/bench-verify.mjs +15 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/package.json +9 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/src/index.mjs +3 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-1.test.mjs +24 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-2.test.mjs +28 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-3.test.mjs +25 -0
- package/bench/README.md +42 -0
- package/bench/RESULTS.md +36 -0
- package/bench/fixtures/string-kit/.yoke/config.yaml +6 -0
- package/bench/fixtures/string-kit/.yoke/prd.yaml +32 -0
- package/bench/fixtures/string-kit/bench-verify.mjs +15 -0
- package/bench/fixtures/string-kit/package.json +9 -0
- package/bench/fixtures/string-kit/src/index.mjs +3 -0
- package/bench/fixtures/string-kit/tests/STORY-1.test.mjs +24 -0
- package/bench/fixtures/string-kit/tests/STORY-2.test.mjs +28 -0
- package/bench/fixtures/string-kit/tests/STORY-3.test.mjs +25 -0
- package/bench/results/claude-2026-07-09T22-34-01.json +40 -0
- package/bench/run.mjs +115 -0
- package/canon/loop/loop-spec.md +5 -1
- package/canon/manifest.yaml +3 -0
- package/canon/skills/yoke-retrofit/SKILL.md +1 -0
- package/canon/tools/claude-mem.md +15 -0
- package/canon/tools/ui-ux-pro-max.md +15 -0
- package/dist/cli.js +32 -6
- package/dist/loop/cleanup.js +44 -3
- package/dist/loop/loop.js +27 -2
- package/dist/loop/reporter.js +21 -3
- package/dist/loop/run-command.js +19 -6
- package/dist/loop/runner.js +130 -13
- package/dist/loop/watchdog.js +67 -17
- package/dist/retrofit/apply.js +10 -3
- package/dist/retrofit/config.js +2 -0
- package/dist/retrofit/gitignore.js +2 -0
- package/dist/retrofit/planners/claude.js +4 -1
- package/dist/retrofit/planners/gemini.js +2 -1
- package/dist/retrofit/preserve.js +51 -0
- package/dist/update/check.js +86 -0
- package/dist/update/refresh.js +28 -0
- package/dist/update/upgrade.js +30 -0
- package/docs/PUBLISHING.md +41 -0
- package/docs/superpowers/plans/2026-06-27-forge-a-canon.md +815 -0
- package/docs/superpowers/plans/2026-06-27-forge-b1-retrofit-claude.md +842 -0
- package/docs/superpowers/plans/2026-06-27-forge-b2-codex-gemini-tools.md +884 -0
- package/docs/superpowers/plans/2026-06-27-forge-c1-loop-engine.md +891 -0
- package/docs/superpowers/plans/2026-06-28-baustein-e-context-layer.md +981 -0
- package/docs/superpowers/plans/2026-06-28-forge-b3-settings-merge.md +364 -0
- package/docs/superpowers/plans/2026-06-28-forge-c2-loop-verify.md +502 -0
- package/docs/superpowers/plans/2026-06-28-forge-c3-multi-agent-runners.md +377 -0
- package/docs/superpowers/plans/2026-06-28-forge-c4-worktree-isolation.md +413 -0
- package/docs/superpowers/plans/2026-06-28-forge-c5-review-iteration.md +419 -0
- package/docs/superpowers/plans/2026-06-28-forge-cleanup-dep0190.md +233 -0
- package/docs/superpowers/plans/2026-06-28-forge-d-codegraph-choice-minimal-code.md +523 -0
- package/docs/superpowers/plans/2026-06-29-baustein-f-routing.md +258 -0
- package/docs/superpowers/plans/2026-06-29-baustein-g-loop-observability.md +1006 -0
- package/docs/superpowers/plans/2026-06-29-baustein-h-loop-robustness.md +374 -0
- package/docs/superpowers/plans/2026-06-30-baustein-i-visual-design-verification.md +450 -0
- package/docs/superpowers/plans/2026-07-02-baustein-j-cross-model-review-gstack-compose.md +645 -0
- package/docs/superpowers/plans/2026-07-02-baustein-k-zero-to-100-bootstrap.md +1024 -0
- package/docs/superpowers/plans/2026-07-02-baustein-m-flow-smoke-proofs.md +574 -0
- package/docs/superpowers/specs/2026-06-27-forge-cross-agent-harness-design.md +144 -0
- package/docs/superpowers/specs/2026-06-28-baustein-e-context-layer-design.md +146 -0
- package/docs/superpowers/specs/2026-06-29-baustein-f-routing-design.md +106 -0
- package/docs/superpowers/specs/2026-06-29-baustein-g-loop-observability-design.md +186 -0
- package/docs/superpowers/specs/2026-06-29-baustein-h-loop-robustness-design.md +113 -0
- package/docs/superpowers/specs/2026-06-30-baustein-i-visual-design-verification-design.md +98 -0
- package/docs/superpowers/specs/2026-07-02-baustein-j-cross-model-review-gstack-compose-design.md +137 -0
- package/docs/superpowers/specs/2026-07-02-baustein-k-zero-to-100-bootstrap-design.md +200 -0
- package/docs/superpowers/specs/2026-07-02-baustein-m-flow-smoke-proofs-design.md +155 -0
- package/docs/superpowers/specs/2026-07-10-companion-tools-decision.md +33 -0
- package/docs/superpowers/specs/2026-07-10-multi-agent-parallel-loop-design.md +99 -0
- package/package.json +8 -2
package/dist/loop/runner.js
CHANGED
|
@@ -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:
|
|
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:
|
|
201
|
+
execSync(`${command} --version`, { stdio: 'pipe', timeout: 20000 });
|
|
103
202
|
}
|
|
104
203
|
else {
|
|
105
|
-
execFileSync(command, ['--version'], { stdio: 'pipe', timeout:
|
|
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 =
|
|
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
|
-
|
|
247
|
+
;
|
|
248
|
+
(opts.exec ?? runCli)(inv);
|
|
132
249
|
return { success: true, summary: `${agent} implemented ${ctx.story.id}` };
|
|
133
250
|
}
|
|
134
251
|
catch (e) {
|
package/dist/loop/watchdog.js
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
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
|
|
51
|
-
//
|
|
52
|
-
//
|
|
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
|
-
|
|
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
|
}
|
package/dist/retrofit/apply.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
51
|
-
results.push({ target: action.target, status, backedUp, reason
|
|
57
|
+
writeFileSync(dest, content);
|
|
58
|
+
results.push({ target: action.target, status, backedUp, reason });
|
|
52
59
|
}
|
|
53
60
|
return results;
|
|
54
61
|
}
|
package/dist/retrofit/config.js
CHANGED
|
@@ -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 } };
|
|
@@ -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
|
+
}
|