@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.
- package/CHANGELOG.md +130 -0
- package/README.md +583 -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 +37 -6
- package/dist/loop/cleanup.js +44 -3
- package/dist/loop/loop.js +67 -2
- package/dist/loop/reporter.js +88 -4
- package/dist/loop/run-command.js +25 -8
- package/dist/loop/runner.js +133 -14
- package/dist/loop/watchdog.js +67 -17
- package/dist/retrofit/apply.js +10 -3
- package/dist/retrofit/config.js +9 -1
- package/dist/retrofit/gitignore.js +4 -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/run-command.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
+
say(`Reason: ${result.reason}`);
|
|
126
138
|
if (result.reason && /api key|please run \/login|not logged in/i.test(result.reason)) {
|
|
127
|
-
|
|
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
|
-
|
|
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);
|
package/dist/loop/runner.js
CHANGED
|
@@ -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:
|
|
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:
|
|
203
|
+
execSync(`${command} --version`, { stdio: 'pipe', timeout: 20000 });
|
|
103
204
|
}
|
|
104
205
|
else {
|
|
105
|
-
execFileSync(command, ['--version'], { stdio: 'pipe', timeout:
|
|
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 =
|
|
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
|
-
|
|
249
|
+
;
|
|
250
|
+
(opts.exec ?? runCli)(inv);
|
|
132
251
|
return { success: true, summary: `${agent} implemented ${ctx.story.id}` };
|
|
133
252
|
}
|
|
134
253
|
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
|
@@ -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({
|
|
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
|
+
}
|