@sleepy_panda_srl/portulan 0.3.0 → 0.4.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/README.md +15 -4
- package/cli/README.md +55 -3
- package/cli/advisory.mjs +167 -53
- package/cli/cache-risk.mjs +69 -0
- package/cli/comments.mjs +8 -5
- package/cli/compile.mjs +492 -146
- package/cli/context.mjs +204 -43
- package/cli/doctor.mjs +211 -33
- package/cli/drills.mjs +32 -7
- package/cli/finish.md +34 -0
- package/cli/finish.mjs +8 -67
- package/cli/form.mjs +34 -18
- package/cli/frontmatter.mjs +39 -0
- package/cli/gate.mjs +14 -12
- package/cli/guidance-files.mjs +335 -0
- package/cli/handoff-policy.mjs +76 -0
- package/cli/html-entities.mjs +2130 -0
- package/cli/import-files.mjs +15 -0
- package/cli/index.mjs +77 -24
- package/cli/init-preview.mjs +553 -0
- package/cli/init.mjs +261 -46
- package/cli/instructions-journal.mjs +1185 -0
- package/cli/instructions.mjs +332 -57
- package/cli/ledger.mjs +123 -37
- package/cli/librarian.mjs +103 -19
- package/cli/persona-routing.mjs +209 -0
- package/cli/plugin-lint.mjs +77 -56
- package/cli/recipe-output.mjs +91 -0
- package/cli/recipe-runner.md +68 -0
- package/cli/recipe-runner.mjs +131 -0
- package/cli/recipe-set.mjs +12 -3
- package/cli/ritual-contexts.mjs +103 -0
- package/cli/sessions.mjs +2 -1
- package/cli/spend-analysis.mjs +111 -0
- package/cli/spend-offer.md +53 -0
- package/cli/spend-offer.mjs +93 -0
- package/cli/stop-diagnostics.mjs +114 -0
- package/cli/stop-gate.mjs +133 -90
- package/cli/stop-policy.mjs +14 -0
- package/cli/symbols.mjs +55 -8
- package/cli/telemetry.mjs +19 -12
- package/cli/upgrade.mjs +62 -44
- package/cli/vendor.mjs +207 -125
- package/core/operating/context.md +368 -60
- package/core/operating/evolution.md +3 -2
- package/core/operating/loop.md +9 -4
- package/core/operating/sessions.md +6 -15
- package/core/personas/README.md +52 -10
- package/core/personas/implementer.md +3 -0
- package/core/personas/librarian.md +3 -0
- package/core/personas/reviewer.md +3 -0
- package/core/templates/handoff.md +6 -2
- package/evals/ab/personas/README.md +111 -0
- package/evals/ab/personas/acquisition.json +330 -0
- package/evals/ab/personas/bindings/implementer.txt +43 -0
- package/evals/ab/personas/bindings/librarian.txt +40 -0
- package/evals/ab/personas/bindings/reviewer.txt +41 -0
- package/evals/ab/personas/corpus.json +69 -0
- package/evals/ab/personas/grader.mjs +171 -0
- package/evals/ab/personas/implementer.json +774 -0
- package/evals/ab/personas/implementer.md +22 -0
- package/evals/ab/personas/librarian.json +758 -0
- package/evals/ab/personas/librarian.md +22 -0
- package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
- package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
- package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
- package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
- package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
- package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
- package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
- package/evals/ab/personas/reviewer.json +758 -0
- package/evals/ab/personas/reviewer.md +22 -0
- package/evals/releases/0.4.0.json +154 -0
- package/evals/releases/0.4.0.md +69 -0
- package/package.json +6 -2
- package/packs/rituals/checkpoints/README.md +35 -0
- package/packs/rituals/checkpoints/pack.json +3 -2
- package/packs/tools/local-llm/README.md +46 -6
- package/packs/tools/local-llm/runner/local-llm.mjs +127 -13
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +26 -2
- package/spec/README.md +37 -4
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
- package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
- package/spec/migrations/README.md +6 -0
- package/spec/pack.schema.json +12 -3
- package/spec/slots.md +262 -117
- package/spec/workspace.schema.json +67 -11
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Regrade the acquired core-persona records and check their shipped routing without a model.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { CORPUS, grade, price } from "../evals/ab/personas/grader.mjs";
|
|
8
|
+
|
|
9
|
+
export const CAPTURE = "evals/ab/personas/acquisition.json";
|
|
10
|
+
export const CAPTURE_SHA256 = "ff3688658bc20af6b78e834a5149ea6bd52bc88027b0340ec98cb52430c6d641";
|
|
11
|
+
export const ROLES = ["implementer", "reviewer", "librarian"];
|
|
12
|
+
export const ROUTING = { model: "inherit", effort: null, omitClaudeMd: false, cacheLifetime: "5m" };
|
|
13
|
+
const CLASSES = ["uncached", "written5m", "written1h", "writtenUnknown", "read", "output"];
|
|
14
|
+
const BAD_DESCRIPTION = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
|
|
15
|
+
const sha = bytes => createHash("sha256").update(bytes).digest("hex");
|
|
16
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
17
|
+
const hash = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
18
|
+
const demand = (ok, why) => { if (!ok) throw new Finding(why); };
|
|
19
|
+
export class Finding extends Error {}
|
|
20
|
+
|
|
21
|
+
export function bindingContract(text) {
|
|
22
|
+
demand(typeof text === "string" && text.startsWith("---\n") && !text.includes("\uFFFD"), "binding frontmatter is unusable");
|
|
23
|
+
const close = text.indexOf("\n---\n", 4);
|
|
24
|
+
demand(close !== -1, "binding frontmatter is unclosed");
|
|
25
|
+
const lines = text.slice(4, close).split("\n");
|
|
26
|
+
const descriptions = lines.filter(line => /^\s*description\s*:/.test(line));
|
|
27
|
+
demand(descriptions.length === 1 && /^description: \S/.test(descriptions[0]), "binding needs one nonempty single-line description");
|
|
28
|
+
for (const line of lines.slice(lines.indexOf(descriptions[0]) + 1)) {
|
|
29
|
+
if (/^[A-Za-z_][\w-]*\s*:/.test(line)) break;
|
|
30
|
+
demand(!line.trim() || /^\s*#/.test(line), "binding description has a multiline continuation");
|
|
31
|
+
}
|
|
32
|
+
const raw = descriptions[0].slice("description: ".length);
|
|
33
|
+
demand(!BAD_DESCRIPTION.test(raw), "binding description contains a control or line separator");
|
|
34
|
+
let value;
|
|
35
|
+
if (raw.startsWith('"')) {
|
|
36
|
+
try { value = JSON.parse(raw); } catch {}
|
|
37
|
+
} else if (raw.startsWith("'")) {
|
|
38
|
+
if (/^'(?:[^']|'')*'$/.test(raw)) value = raw.slice(1, -1).replaceAll("''", "'");
|
|
39
|
+
} else if (/^[\p{L}_][^:#{}\[\]\r\n]*$/u.test(raw) && !/^(true|false|null)$/i.test(raw.trim())) value = raw;
|
|
40
|
+
demand(typeof value === "string" && value.trim() && !BAD_DESCRIPTION.test(value), "binding description is not a usable single-line scalar");
|
|
41
|
+
return text.slice(0, 4) + lines.map(line => line === descriptions[0] ? "description: <description>" : line).join("\n") + text.slice(close);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function costOf(call) {
|
|
45
|
+
const parent = price(call.usage.parent), child = price(call.usage.child);
|
|
46
|
+
return { parent, child, observed: parent.observed + child.observed, normalized: parent.normalized + child.normalized };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function summarize(record) {
|
|
50
|
+
const arms = Object.fromEntries(["a", "b"].map(arm => {
|
|
51
|
+
const turns = record.turns.filter(turn => turn.arm === arm);
|
|
52
|
+
const costs = turns.map(costOf);
|
|
53
|
+
return [arm, { passed: turns.filter(turn => grade(CORPUS.cases.find(task => task.id === turn.task), turn.answer).ok).length,
|
|
54
|
+
cases: turns.length, observed: costs.reduce((sum, value) => sum + value.observed, 0), normalized: costs.reduce((sum, value) => sum + value.normalized, 0) }];
|
|
55
|
+
}));
|
|
56
|
+
return { arms, mediumWinsObservedCorpus: arms.b.passed === 3 && arms.b.normalized < arms.a.normalized,
|
|
57
|
+
shipped: ROUTING, scope: "retained policy; one observed model and three cases; no global medium default earned" };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function register(record) {
|
|
61
|
+
const result = summarize(record);
|
|
62
|
+
const lines = [`# ${record.role} — acquired routing comparison`, "", `Host: ${record.host}. Concrete model: ${record.model}.`, "",
|
|
63
|
+
"A uses the shipped inherited model and undeclared child effort (observed high). B requests medium through the native child tool. Each case has one attempt per arm.", "",
|
|
64
|
+
"| Arm | Semantic passes | Observed cost index | Cold-normalized cost index |", "|---|---:|---:|---:|"];
|
|
65
|
+
for (const arm of ["a", "b"]) {
|
|
66
|
+
const value = result.arms[arm];
|
|
67
|
+
lines.push(`| ${arm.toUpperCase()} | ${value.passed}/${value.cases} | ${value.observed.toFixed(2)} | ${value.normalized.toFixed(2)} |`);
|
|
68
|
+
}
|
|
69
|
+
lines.push("", `Medium wins this observed corpus: **${result.mediumWinsObservedCorpus ? "yes" : "no"}**. The shipped policy remains inherited model, undeclared effort, instruction floor retained and five-minute cache lifetime.`, "",
|
|
70
|
+
"These are three observations, not a reliability estimate. A result on this concrete model does not establish medium effort for other inherited models. Cost indexes use the frozen declared vector, include parent and child requests, and are not subscription bills. First-request input is normalized separately in each fresh parent and child context.", "",
|
|
71
|
+
"| Case | A | B |", "|---|---|---|");
|
|
72
|
+
for (const task of CORPUS.cases.filter(task => task.role === record.role)) {
|
|
73
|
+
const verdict = arm => grade(task, record.turns.find(turn => turn.task === task.id && turn.arm === arm).answer).ok ? "PASS" : "FAIL";
|
|
74
|
+
lines.push(`| ${task.id} | ${verdict("a")} | ${verdict("b")} |`);
|
|
75
|
+
}
|
|
76
|
+
lines.push("", "The common acquisition record accounts for both mechanism probes and discloses the one retained-answer decoder repair after the first outcome. The original failed decode score and the repaired score are both retained; that child was never rerun.", "");
|
|
77
|
+
return lines.join("\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function checkCall(call, model, ids, contexts, quality = false) {
|
|
81
|
+
demand(call && ["complete", "invalid"].includes(call.state), "call completion state missing");
|
|
82
|
+
demand(typeof call.context === "string" && !contexts.has(call.context), "a parent context was reused");
|
|
83
|
+
contexts.add(call.context);
|
|
84
|
+
demand(typeof call.childContext === "string" && !contexts.has(call.childContext), "a child context was reused");
|
|
85
|
+
contexts.add(call.childContext);
|
|
86
|
+
demand(hash(call.raw?.parent) && hash(call.raw?.result), "raw parent/result digest missing");
|
|
87
|
+
demand(Date.parse(call.started) <= Date.parse(call.ended), "call timing is invalid");
|
|
88
|
+
for (const side of ["parent", "child"]) {
|
|
89
|
+
const requests = call.usage?.[side];
|
|
90
|
+
demand(Array.isArray(requests) && requests.length > 0, `${side} usage missing`);
|
|
91
|
+
for (const [index, request] of requests.entries()) {
|
|
92
|
+
demand(typeof request.id === "string" && !ids.has(request.id), "duplicate request identity");
|
|
93
|
+
ids.add(request.id);
|
|
94
|
+
demand(request.model === model, "concrete model changed");
|
|
95
|
+
demand(request.effort === (side === "parent" ? "high" : call.effort ?? "high"), "effective effort differs from frozen arm");
|
|
96
|
+
demand(Number.isFinite(request.at) && request.at >= Date.parse(call.started) && request.at <= Date.parse(call.ended), "request outside its call");
|
|
97
|
+
demand(index === 0 || request.at >= requests[index - 1].at, "usage requests are out of chronological order");
|
|
98
|
+
for (const key of CLASSES) demand(Number.isSafeInteger(request[key]) && request[key] >= 0, `invalid ${key} count`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
costOf(call);
|
|
102
|
+
if (!quality) return;
|
|
103
|
+
demand(call.state === "complete" && call.exit === 0 && call.timedOut === false, "quality call did not complete");
|
|
104
|
+
demand(hash(call.raw.child) && hash(call.invocation?.promptSha256), "raw child/prompt digest missing");
|
|
105
|
+
demand(call.invocation.type === `portulan:${call.role}` && call.invocation.effort === call.effort && call.invocation.model === null && call.invocation.background === false, "native child selection changed");
|
|
106
|
+
demand(call.model === model && call.requestedModel === model, "quality model changed");
|
|
107
|
+
demand(call.parentEffort === "high" && call.fresh === true, "quality parent was not fresh/high");
|
|
108
|
+
demand(hash(call.answerRawSha256) && sha(call.answer) === call.answerPresentationSha256 && typeof call.answerNormalized === "boolean", "answer presentation provenance missing");
|
|
109
|
+
demand(call.answerNormalized === (call.answerRawSha256 !== call.answerPresentationSha256), "answer normalization flag differs");
|
|
110
|
+
demand(!/\/Users\/|\/private\/var\/folders\//.test(call.answer), "an original local path remains in a public answer");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function verifyData(capture, records) {
|
|
114
|
+
demand(capture.version === 1 && capture.phase === "complete", "acquisition is incomplete");
|
|
115
|
+
demand(capture.model === "claude-sonnet-5-5" && same(capture.prices, CORPUS.prices), "frozen model or price vector changed");
|
|
116
|
+
demand(capture.seed === CORPUS.seed && /^[a-f0-9]{40}$/.test(capture.sourceCommit), "corpus seed or source commit missing");
|
|
117
|
+
demand(same(Object.keys(capture.taskSha256 ?? {}).sort(), CORPUS.cases.map(task => task.id).sort()), "frozen task roster changed");
|
|
118
|
+
for (const task of CORPUS.cases) demand(sha(JSON.stringify({ role: task.role, task: task.task }, null, 2) + "\n") === capture.taskSha256[task.id], "frozen task bytes differ from the public corpus");
|
|
119
|
+
demand(same(capture.routing, ROUTING), "routing differs from retained policy");
|
|
120
|
+
demand(capture.preflights?.length === 2 && capture.preflights[0].state === "invalid" && capture.preflights[1].state === "complete", "both mechanism probes must be accounted");
|
|
121
|
+
demand(capture.preflights[0].reason === "error_max_budget_usd" && capture.preflights[0].argvProvenance === "reconstructed-after-execution", "failed first probe provenance missing");
|
|
122
|
+
demand(capture.preflights[1].markerSeen === true, "native medium mechanism was not demonstrated");
|
|
123
|
+
demand(capture.preflights[0].instrumentSha256 === capture.instruments.firstProbe.sha256 && capture.preflights[1].instrumentSha256 === capture.instruments.secondProbe.sha256, "probe instrument attribution differs");
|
|
124
|
+
const repair = capture.decodeRepair;
|
|
125
|
+
demand(repair?.observedOutcomes === 1 && repair.retainedKey === "implementer-lower-bound-a" && repair.reruns === 0 && repair.approval === "PASS", "one-answer repair disclosure missing");
|
|
126
|
+
for (const key of ["previousGrader", "grader", "abortedJournalSha256", "approvalSha256", "semanticSuffixSha256"]) demand(hash(repair[key]), `repair ${key} missing`);
|
|
127
|
+
const ids = new Set(), contexts = new Set();
|
|
128
|
+
checkCall(capture.preflights[0], "claude-fable-5-2", ids, contexts);
|
|
129
|
+
checkCall(capture.preflights[1], capture.model, ids, contexts, true);
|
|
130
|
+
demand(capture.preflights[1].effort === "medium" && capture.preflights[1].answer === "MECHANISM_OK", "medium probe answer/effort differs");
|
|
131
|
+
const all = [];
|
|
132
|
+
for (const role of ROLES) {
|
|
133
|
+
const record = records[role];
|
|
134
|
+
demand(record?.version === 1 && record.role === role && record.model === capture.model && record.host === capture.host, "role record identity changed");
|
|
135
|
+
demand(record.turns?.length === 6, "role has other than six acquired turns");
|
|
136
|
+
const expected = CORPUS.cases.filter(task => task.role === role).flatMap(task => ["a", "b"].map(arm => `${task.id}-${arm}`));
|
|
137
|
+
demand(same(record.turns.map(turn => turn.key).sort(), expected.sort()), "role case/arm coverage differs");
|
|
138
|
+
for (const turn of record.turns) {
|
|
139
|
+
const task = CORPUS.cases.find(task => task.id === turn.task);
|
|
140
|
+
demand(turn.role === role && task.role === role && turn.key === `${turn.task}-${turn.arm}` && turn.effort === (turn.arm === "a" ? null : "medium"), "role/arm changed");
|
|
141
|
+
checkCall(turn, capture.model, ids, contexts, true);
|
|
142
|
+
demand(turn.instrumentSha256 === capture.instruments[turn.key === repair.retainedKey ? "firstOutcome" : "continuation"].sha256, "quality instrument attribution differs");
|
|
143
|
+
const actual = grade(task, turn.answer);
|
|
144
|
+
demand(same(actual, turn.regraded ?? turn.grade), "stored semantic grade differs");
|
|
145
|
+
if (turn.key === repair.retainedKey) demand(turn.grade.ok === false && turn.regraded?.ok === true, "original failed decode score was overwritten");
|
|
146
|
+
else demand(!Object.hasOwn(turn, "regraded"), "an unapproved outcome was regraded");
|
|
147
|
+
if (turn.arm === "a") demand(actual.ok, "a failed control earns no routing");
|
|
148
|
+
all.push(turn);
|
|
149
|
+
}
|
|
150
|
+
demand(same(record.summary, summarize(record)), "stored role summary differs");
|
|
151
|
+
}
|
|
152
|
+
const expectedOrder = CORPUS.cases.flatMap((task, index) => (index % 2 === 0 ? ["a", "b"] : ["b", "a"]).map(arm => `${task.id}-${arm}`));
|
|
153
|
+
demand(same(capture.order, expectedOrder), "frozen acquisition order changed");
|
|
154
|
+
const chronological = all.sort((a, b) => Date.parse(a.started) - Date.parse(b.started));
|
|
155
|
+
demand(same(capture.publication?.normalizedAnswers, chronological.filter(turn => turn.answerNormalized).map(turn => turn.key)), "normalized answer disclosure differs");
|
|
156
|
+
demand(same(chronological.map(turn => turn.key), expectedOrder), "turns did not run in frozen order");
|
|
157
|
+
demand(Date.parse(capture.qualityFrozenAt) <= Date.parse(chronological[0].started) && Date.parse(capture.continuationFrozenAt) >= Date.parse(chronological[0].ended) && Date.parse(capture.continuationFrozenAt) <= Date.parse(chronological[1].started), "freeze/repair timing differs from the disclosed sequence");
|
|
158
|
+
const calls = [...capture.preflights, ...chronological];
|
|
159
|
+
demand(calls.every((call, index) => index === 0 || Date.parse(call.started) >= Date.parse(calls[index - 1].ended)), "fresh calls overlap or probes were reordered");
|
|
160
|
+
const requests = capture.preflights.flatMap(call => [...call.usage.parent, ...call.usage.child]).length + all.flatMap(call => [...call.usage.parent, ...call.usage.child]).length;
|
|
161
|
+
demand(capture.accounting.requests === requests && capture.accounting.calls === 20 && capture.accounting.qualityChildren === 18 && capture.accounting.parentContexts === 20, "usage accounting is incomplete");
|
|
162
|
+
return { roles: ROLES.length, qualityChildren: all.length, requests, overhead: capture.preflights.map(costOf) };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function verifyCapture(root, expectedDigest = CAPTURE_SHA256) {
|
|
166
|
+
const bytes = fs.readFileSync(path.join(root, CAPTURE));
|
|
167
|
+
demand(sha(bytes) === expectedDigest, "acquisition digest changed");
|
|
168
|
+
const capture = JSON.parse(bytes), directory = path.dirname(path.join(root, CAPTURE));
|
|
169
|
+
const records = {};
|
|
170
|
+
for (const role of ROLES) {
|
|
171
|
+
const file = `${role}.json`, data = fs.readFileSync(path.join(directory, file));
|
|
172
|
+
demand(sha(data) === capture.files[file], `${role} record digest changed`);
|
|
173
|
+
records[role] = JSON.parse(data);
|
|
174
|
+
demand(fs.readFileSync(path.join(directory, `${role}.md`), "utf8") === register(records[role]), `${role} register differs`);
|
|
175
|
+
}
|
|
176
|
+
const sources = ["evals/ab/personas/corpus.json", "evals/ab/personas/grader.mjs", ...ROLES.map(role => `core/personas/${role}.md`)];
|
|
177
|
+
demand(same(Object.keys(capture.sources ?? {}).sort(), sources.sort()), "required source roster differs");
|
|
178
|
+
for (const [file, expected] of Object.entries(capture.sources)) demand(sha(fs.readFileSync(path.join(root, file))) === expected, `source differs: ${file}`);
|
|
179
|
+
const old = fs.readFileSync(path.join(directory, "repairs/grader-before-decode-repair.mjs.txt"), "utf8");
|
|
180
|
+
const current = fs.readFileSync(path.join(directory, "grader.mjs"), "utf8");
|
|
181
|
+
demand(sha(old) === capture.decodeRepair.previousGrader && sha(current) === capture.decodeRepair.grader, "decoder versions differ");
|
|
182
|
+
const suffix = text => text.slice(text.indexOf("function codeCases"));
|
|
183
|
+
demand(suffix(old) === suffix(current) && sha(suffix(current)) === capture.decodeRepair.semanticSuffixSha256, "repair changed semantic predicates or pricing");
|
|
184
|
+
const first = records.implementer.turns.find(turn => turn.key === capture.decodeRepair.retainedKey);
|
|
185
|
+
demand(fs.readFileSync(path.join(directory, "repairs/first-control-answer.txt"), "utf8") === first.answer + "\n", "retained first answer differs");
|
|
186
|
+
for (const [key, name] of Object.entries({ firstProbe: "first-probe", secondProbe: "second-probe", firstOutcome: "first-outcome", continuation: "continuation", publisher: "publisher" })) {
|
|
187
|
+
demand(sha(fs.readFileSync(path.join(directory, `protocol/${name}.mjs.txt`))) === capture.instruments[key].sha256, "instrument snapshot differs");
|
|
188
|
+
}
|
|
189
|
+
for (const role of ROLES) {
|
|
190
|
+
const source = fs.readFileSync(path.join(root, `core/personas/${role}.md`), "utf8");
|
|
191
|
+
const header = source.split("---")[1];
|
|
192
|
+
demand(/^model: inherit$/m.test(header) && !/^effort:/m.test(header) && /^omitClaudeMd: false$/m.test(header) && /^cacheLifetime: 5m$/m.test(header), "shipped source routing differs");
|
|
193
|
+
const binding = fs.readFileSync(path.join(directory, `bindings/${role}.txt`));
|
|
194
|
+
demand(sha(binding) === capture.bindings[role], "captured binding differs");
|
|
195
|
+
const installed = path.join(root, `agents/${role}.md`);
|
|
196
|
+
if (fs.existsSync(installed)) demand(bindingContract(fs.readFileSync(installed, "utf8")) === bindingContract(binding.toString("utf8")), "shipped plugin binding differs outside its description");
|
|
197
|
+
}
|
|
198
|
+
return verifyData(capture, records);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function run(argv = process.argv.slice(2)) {
|
|
202
|
+
try {
|
|
203
|
+
if (argv.length && !(argv.length === 2 && argv[0] === "--repo-root")) throw Error("usage: node cli/persona-routing.mjs [--repo-root <dir>]");
|
|
204
|
+
const result = verifyCapture(path.resolve(argv[1] ?? "."));
|
|
205
|
+
console.log(`persona-routing: ${result.roles} acquired role records, ${result.qualityChildren} quality children and ${result.requests} accounted requests; shipped policy matches`);
|
|
206
|
+
return 0;
|
|
207
|
+
} catch (error) { console.error(`persona-routing: ${error.message}`); return error instanceof Finding ? 1 : 2; }
|
|
208
|
+
}
|
|
209
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href) process.exitCode = run();
|
package/cli/plugin-lint.mjs
CHANGED
|
@@ -11,6 +11,9 @@ import path from "node:path";
|
|
|
11
11
|
import { pathToFileURL } from "node:url";
|
|
12
12
|
|
|
13
13
|
import { HOST_SKILL_DEPTH, skillsSet, canonical } from "./skills-set.mjs";
|
|
14
|
+
import { parseFrontmatter } from "./frontmatter.mjs";
|
|
15
|
+
export { parseFrontmatter };
|
|
16
|
+
import { packContributions, personaDeclarations } from "./compile.mjs";
|
|
14
17
|
|
|
15
18
|
/** Raised when `plugin-lint` cannot run at all. Always exit 2, never 1. */
|
|
16
19
|
export class PluginLintError extends Error {
|
|
@@ -25,7 +28,6 @@ const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
|
25
28
|
// Kept by hand with docs/plan.md's repo topology: visibility is a GitHub setting no file can read.
|
|
26
29
|
const PRIVATE_FEEDS = ["sleepy-panda-srl/portulan-internal"];
|
|
27
30
|
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
28
|
-
const BLOCK_SCALAR = /^[|>][+-]?$/;
|
|
29
31
|
|
|
30
32
|
const PATH_FIELDS = [
|
|
31
33
|
"skills",
|
|
@@ -44,46 +46,6 @@ const SKIP_DIRS = new Set([".git", "node_modules", ".claude-plugin"]);
|
|
|
44
46
|
export const AGENT_DIR = "agents";
|
|
45
47
|
const MAX_WALK_DEPTH = 6;
|
|
46
48
|
|
|
47
|
-
// ===========================================================================================
|
|
48
|
-
// Frontmatter
|
|
49
|
-
// ===========================================================================================
|
|
50
|
-
|
|
51
|
-
/** A flat-YAML reader: `fields` is null without a usable block, and `error` is set when that is a defect. */
|
|
52
|
-
export function parseFrontmatter(text) {
|
|
53
|
-
const lines = text.split(/\r?\n/);
|
|
54
|
-
if (lines[0]?.trim() !== "---") return { fields: null };
|
|
55
|
-
|
|
56
|
-
const close = lines.indexOf("---", 1);
|
|
57
|
-
if (close === -1) {
|
|
58
|
-
return { fields: null, error: "the frontmatter block is never closed" };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const fields = {};
|
|
62
|
-
const body = lines.slice(1, close);
|
|
63
|
-
for (let i = 0; i < body.length; i += 1) {
|
|
64
|
-
const match = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(body[i]);
|
|
65
|
-
if (!match) continue;
|
|
66
|
-
const key = match[1];
|
|
67
|
-
let value = match[2].trim();
|
|
68
|
-
|
|
69
|
-
if (BLOCK_SCALAR.test(value)) {
|
|
70
|
-
const collected = [];
|
|
71
|
-
while (i + 1 < body.length && (body[i + 1].trim() === "" || /^\s+/.test(body[i + 1]))) {
|
|
72
|
-
i += 1;
|
|
73
|
-
collected.push(body[i].trim());
|
|
74
|
-
}
|
|
75
|
-
value = collected.join(" ").trim();
|
|
76
|
-
} else if (
|
|
77
|
-
(value.startsWith('"') && value.endsWith('"') && value.length >= 2) ||
|
|
78
|
-
(value.startsWith("'") && value.endsWith("'") && value.length >= 2)
|
|
79
|
-
) {
|
|
80
|
-
value = value.slice(1, -1);
|
|
81
|
-
}
|
|
82
|
-
fields[key] = value;
|
|
83
|
-
}
|
|
84
|
-
return { fields };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
49
|
// ===========================================================================================
|
|
88
50
|
// Inspection
|
|
89
51
|
// ===========================================================================================
|
|
@@ -91,7 +53,7 @@ export function parseFrontmatter(text) {
|
|
|
91
53
|
/** Lexical: a symlink inside `root` can still lead out of it. */
|
|
92
54
|
function escapes(root, target) {
|
|
93
55
|
const inside = path.relative(root, target);
|
|
94
|
-
return inside.startsWith(
|
|
56
|
+
return inside === ".." || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside);
|
|
95
57
|
}
|
|
96
58
|
|
|
97
59
|
/** Throws PluginLintError only when the root is not a readable directory; `payload` excuses an absent marketplace.json. */
|
|
@@ -119,10 +81,21 @@ export function inspect(rawRoot, { payload = false } = {}) {
|
|
|
119
81
|
const fail = (check, message) => findings.push({ severity: "fail", check, message });
|
|
120
82
|
const note = (check, message) => findings.push({ severity: "note", check, message });
|
|
121
83
|
|
|
84
|
+
const ownedText = (file, scope = root) => {
|
|
85
|
+
const realScope = fs.realpathSync(scope), real = fs.realpathSync(file);
|
|
86
|
+
if (escapes(root, realScope) || escapes(realScope, real)) throw Error(`${file} resolves outside its bundled owner`);
|
|
87
|
+
if (!fs.statSync(real).isFile()) throw Error(`${file} is not a regular source file`);
|
|
88
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(real));
|
|
89
|
+
};
|
|
90
|
+
const ownedJson = (file, scope = root) => {
|
|
91
|
+
const value = JSON.parse(ownedText(file, scope));
|
|
92
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw Error(`${file} is not a JSON object`);
|
|
93
|
+
return value;
|
|
94
|
+
};
|
|
122
95
|
/** Never throws: a failed read becomes a finding and returns null. */
|
|
123
96
|
const read = (file, check, label) => {
|
|
124
97
|
try {
|
|
125
|
-
return
|
|
98
|
+
return ownedText(file);
|
|
126
99
|
} catch (error) {
|
|
127
100
|
fail(check, `${label} could not be read — ${error.code ?? error.message}`);
|
|
128
101
|
return null;
|
|
@@ -420,7 +393,7 @@ export function inspect(rawRoot, { payload = false } = {}) {
|
|
|
420
393
|
let composition;
|
|
421
394
|
let composable = true;
|
|
422
395
|
try {
|
|
423
|
-
composition = JSON.parse(
|
|
396
|
+
composition = JSON.parse(ownedText(path.join(root, GOVERNING), path.join(root, ".portulan")));
|
|
424
397
|
} catch (error) {
|
|
425
398
|
if (error.code === "ENOENT") composable = false;
|
|
426
399
|
else {
|
|
@@ -550,7 +523,7 @@ export function inspect(rawRoot, { payload = false } = {}) {
|
|
|
550
523
|
for (const { name, dir } of composed) {
|
|
551
524
|
let text;
|
|
552
525
|
try {
|
|
553
|
-
text =
|
|
526
|
+
text = ownedText(path.join(dir, "pack.json"), dir);
|
|
554
527
|
} catch (error) {
|
|
555
528
|
if (error.code === "ENOENT") continue;
|
|
556
529
|
fail(
|
|
@@ -616,8 +589,8 @@ export function inspect(rawRoot, { payload = false } = {}) {
|
|
|
616
589
|
for (const stranded of walkForStrandedAgents(root)) {
|
|
617
590
|
note(
|
|
618
591
|
"agents",
|
|
619
|
-
`${path.relative(root, stranded)} is not in ./${AGENT_DIR}/,
|
|
620
|
-
"
|
|
592
|
+
`${path.relative(root, stranded)} is not in ./${AGENT_DIR}/, this plugin's agent directory — ` +
|
|
593
|
+
"it will not load as a plugin binding",
|
|
621
594
|
);
|
|
622
595
|
}
|
|
623
596
|
|
|
@@ -703,28 +676,77 @@ export function inspect(rawRoot, { payload = false } = {}) {
|
|
|
703
676
|
// Fails where doctor only reports: this bundle targets one host, and an unbound persona is inert there.
|
|
704
677
|
const personaDir = path.join(root, "core", "personas");
|
|
705
678
|
let personaFiles = null;
|
|
679
|
+
let corePresent = false;
|
|
706
680
|
try {
|
|
681
|
+
fs.lstatSync(personaDir);
|
|
682
|
+
corePresent = true;
|
|
683
|
+
if (escapes(root, fs.realpathSync(personaDir))) throw Error("core/personas/ resolves outside the plugin");
|
|
707
684
|
personaFiles = fs
|
|
708
685
|
.readdirSync(personaDir)
|
|
709
686
|
.filter((entry) => entry.endsWith(".md") && entry !== "README.md")
|
|
710
687
|
.map((entry) => path.basename(entry, ".md"))
|
|
711
688
|
.sort();
|
|
712
689
|
} catch (error) {
|
|
713
|
-
if (error.code !== "ENOENT") fail("agents", `core/personas/ could not be read — ${error.code ?? error.message}, so the binding correspondence went unchecked`);
|
|
690
|
+
if (error.code !== "ENOENT" || corePresent) fail("agents", `core/personas/ could not be read — ${error.code ?? error.message}, so the binding correspondence went unchecked`);
|
|
691
|
+
}
|
|
692
|
+
const owners = new Map();
|
|
693
|
+
for (const name of personaFiles ?? []) {
|
|
694
|
+
const source = `core/personas/${name}.md`;
|
|
695
|
+
try { ownedText(path.join(root, source)); owners.set(name, source); }
|
|
696
|
+
catch (error) { fail("agents", `${source} could not be read — ${error.message}`); }
|
|
697
|
+
}
|
|
698
|
+
let composedDeclared = false;
|
|
699
|
+
const workspaceFile = path.join(root, ".portulan/workspace.json");
|
|
700
|
+
let workspacePresent = false;
|
|
701
|
+
try {
|
|
702
|
+
fs.lstatSync(workspaceFile);
|
|
703
|
+
workspacePresent = true;
|
|
704
|
+
const workspace = ownedJson(workspaceFile, path.dirname(workspaceFile));
|
|
705
|
+
if (workspace?.packs !== undefined && !Array.isArray(workspace.packs)) throw Error("workspace packs is not an array");
|
|
706
|
+
composedDeclared = (workspace?.packs?.length ?? 0) > 0;
|
|
707
|
+
if (composedDeclared) {
|
|
708
|
+
for (const ref of workspace.packs) {
|
|
709
|
+
if (typeof ref !== "string" || !/^[a-z0-9-]+\/[a-z0-9-]+$/.test(ref)) throw Error("a composed pack reference is malformed");
|
|
710
|
+
const packsRoot = fs.realpathSync(path.join(root, "packs"));
|
|
711
|
+
const packDir = fs.realpathSync(path.join(root, "packs", ref));
|
|
712
|
+
if (escapes(root, packsRoot) || escapes(packsRoot, packDir)) throw Error(`${ref} resolves outside its bundled packs root`);
|
|
713
|
+
const pack = ownedJson(path.join(packDir, "pack.json"), packDir);
|
|
714
|
+
if (pack.contributes !== undefined && (pack.contributes === null || typeof pack.contributes !== "object" || Array.isArray(pack.contributes))) throw Error(`${ref} contributes is not a mapping`);
|
|
715
|
+
}
|
|
716
|
+
const composed = packContributions(root, ".portulan", { packRoots: [path.join(root, "packs")] });
|
|
717
|
+
if (composed.unresolved.length) throw Error(`unresolved persona owners: ${composed.unresolved.map(pack => pack.name).join(", ")}`);
|
|
718
|
+
for (const contribution of composed.contributions) {
|
|
719
|
+
for (const rel of contribution.personas) {
|
|
720
|
+
if (typeof rel !== "string" || !rel.trim()) throw Error("a composed persona path is not a nonempty string");
|
|
721
|
+
const file = path.resolve(contribution.dir, rel);
|
|
722
|
+
if (escapes(contribution.dir, file)) throw Error(`${contribution.pack}'s ${rel} resolves outside its bundled pack`);
|
|
723
|
+
const source = `${contribution.pack}'s ${rel}`;
|
|
724
|
+
const parsed = personaDeclarations(ownedText(file, contribution.dir));
|
|
725
|
+
if (parsed.problems.length) throw Error(`${source}: ${parsed.problems.join("; ")}`);
|
|
726
|
+
const name = parsed.name ?? path.basename(rel, ".md");
|
|
727
|
+
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) throw Error(`${source} has no usable binding name`);
|
|
728
|
+
if (owners.has(name)) throw Error(`${name} has multiple persona owners: ${owners.get(name)} and ${source}`);
|
|
729
|
+
owners.set(name, source);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
} catch (error) {
|
|
734
|
+
if (error.code !== "ENOENT" || workspacePresent) fail("agents", `composed persona ownership could not be read — ${error.code ?? error.message}`);
|
|
714
735
|
}
|
|
715
|
-
|
|
736
|
+
const correspondence = personaFiles !== null || composedDeclared;
|
|
737
|
+
if (correspondence && !agentsExamined) {
|
|
716
738
|
note(
|
|
717
739
|
"agents",
|
|
718
740
|
`the persona↔binding correspondence went unchecked — ./${AGENT_DIR}/ could not be examined, so an empty binding set is a ` +
|
|
719
|
-
`question nobody answered rather than ${
|
|
741
|
+
`question nobody answered rather than ${owners.size} persona(s) with nothing bound to them`,
|
|
720
742
|
);
|
|
721
|
-
} else if (
|
|
722
|
-
for (const persona of
|
|
743
|
+
} else if (correspondence) {
|
|
744
|
+
for (const [persona, source] of owners) {
|
|
723
745
|
const binding = bindings.get(persona);
|
|
724
746
|
if (!binding) {
|
|
725
747
|
fail(
|
|
726
748
|
"agents",
|
|
727
|
-
|
|
749
|
+
`${source} has no binding at ./${AGENT_DIR}/${persona}.md — a persona this plugin ships and does not bind ` +
|
|
728
750
|
`is doctrine the host never registers, which looks from the outside exactly like a role that is available`,
|
|
729
751
|
);
|
|
730
752
|
continue;
|
|
@@ -732,14 +754,13 @@ export function inspect(rawRoot, { payload = false } = {}) {
|
|
|
732
754
|
if (binding.name !== undefined && binding.name !== persona) {
|
|
733
755
|
fail(
|
|
734
756
|
"agents",
|
|
735
|
-
`${binding.rel} binds
|
|
757
|
+
`${binding.rel} binds \`${source}\` by its filename while declaring \`name: ${binding.name}\` — the host keys ` +
|
|
736
758
|
`on the field, so the two disagree about which role this file registers`,
|
|
737
759
|
);
|
|
738
760
|
}
|
|
739
761
|
}
|
|
740
|
-
const personaNames = new Set(personaFiles);
|
|
741
762
|
for (const [name, binding] of bindings) {
|
|
742
|
-
if (!
|
|
763
|
+
if (!owners.has(name)) {
|
|
743
764
|
fail(
|
|
744
765
|
"agents",
|
|
745
766
|
`${binding.rel} binds no persona — there is no \`core/personas/${name}.md\`. A host file that outlived the doctrine it was ` +
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Agent output for any recipe: one green count, complete failure streams.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { Readable } from "node:stream";
|
|
7
|
+
import { pipeline } from "node:stream/promises";
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { runRecipe } from "./recipe-runner.mjs";
|
|
10
|
+
|
|
11
|
+
function spools(tmpdir) {
|
|
12
|
+
const dir = fs.mkdtempSync(path.join(tmpdir, "portulan-recipe-output-"));
|
|
13
|
+
const descriptors = [];
|
|
14
|
+
try {
|
|
15
|
+
for (const name of ["stdout", "stderr"]) {
|
|
16
|
+
const file = path.join(dir, name);
|
|
17
|
+
descriptors.push(fs.openSync(file, "wx+", 0o600));
|
|
18
|
+
fs.unlinkSync(file);
|
|
19
|
+
}
|
|
20
|
+
fs.rmdirSync(dir);
|
|
21
|
+
return descriptors;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
for (const fd of descriptors) { try { fs.closeSync(fd); } catch {} }
|
|
24
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function replay(fd, destination) {
|
|
30
|
+
function* chunks() {
|
|
31
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
32
|
+
let offset = 0;
|
|
33
|
+
for (;;) {
|
|
34
|
+
const bytes = fs.readSync(fd, buffer, 0, buffer.length, offset);
|
|
35
|
+
if (bytes === 0) break;
|
|
36
|
+
offset += bytes;
|
|
37
|
+
yield Buffer.from(buffer.subarray(0, bytes));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
await pipeline(Readable.from(chunks()), destination, { end: false });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function execute(command, { root = process.cwd(), env = process.env, tmpdir = os.tmpdir(), stdout = process.stdout, stderr = process.stderr, timeout = 600_000 } = {}) {
|
|
44
|
+
let descriptors = [];
|
|
45
|
+
try {
|
|
46
|
+
if (typeof command !== "string" || !command.trim()) throw new Error("one nonempty recipe command is required");
|
|
47
|
+
descriptors = spools(tmpdir);
|
|
48
|
+
const inherited = env.PORTULAN_RECIPE_MANAGED === "1";
|
|
49
|
+
const groupId = Number(env.PORTULAN_RECIPE_GROUP);
|
|
50
|
+
if (inherited && process.platform !== "win32") {
|
|
51
|
+
const actual = Number(execFileSync("ps", ["-o", "pgid=", "-p", String(process.pid)], { encoding: "utf8", timeout: 1000, stdio: ["ignore", "pipe", "pipe"] }).trim());
|
|
52
|
+
if (!Number.isInteger(groupId) || groupId <= 1 || actual !== groupId) throw new Error("inherited recipe group does not own this executor");
|
|
53
|
+
}
|
|
54
|
+
const result = await runRecipe({ id: "recipe", run: command }, {
|
|
55
|
+
root, env, timeout: inherited ? 0 : timeout, mergeOutput: false, inheritGroup: inherited, groupId,
|
|
56
|
+
onOutput: (name, chunk) => {
|
|
57
|
+
const fd = descriptors[name === "stdout" ? 0 : 1];
|
|
58
|
+
let offset = 0;
|
|
59
|
+
while (offset < chunk.length) {
|
|
60
|
+
let wrote;
|
|
61
|
+
try { wrote = fs.writeSync(fd, chunk, offset, chunk.length - offset); }
|
|
62
|
+
catch (error) { stderr.write(`recipe-output: could not run — ${error.message}\n`); throw error; }
|
|
63
|
+
if (wrote <= 0) throw new Error("output spool made no write progress");
|
|
64
|
+
offset += wrote;
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
if (result.outcome !== "green") {
|
|
69
|
+
await replay(descriptors[0], stdout);
|
|
70
|
+
await replay(descriptors[1], stderr);
|
|
71
|
+
if (result.diagnostics?.length) stderr.write(`recipe-output: could not run — ${result.diagnostics.join("; ")}\n`);
|
|
72
|
+
}
|
|
73
|
+
for (const fd of descriptors) fs.closeSync(fd);
|
|
74
|
+
descriptors = [];
|
|
75
|
+
if (result.outcome === "green") { await pipeline(Readable.from(["GREEN — 1 recipe passed\n"]), stdout, { end: false }); return 0; }
|
|
76
|
+
return result.code ?? 2;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
stderr.write(`recipe-output: could not run — ${error.message}\n`);
|
|
79
|
+
return 2;
|
|
80
|
+
} finally {
|
|
81
|
+
for (const fd of descriptors) { try { fs.closeSync(fd); } catch {} }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
86
|
+
const args = process.argv.slice(2);
|
|
87
|
+
if (args.length !== 2 || args[0] !== "--") {
|
|
88
|
+
process.stderr.write("usage: node cli/recipe-output.mjs -- <recipe-command>\n");
|
|
89
|
+
process.exitCode = 2;
|
|
90
|
+
} else process.exitCode = await execute(args[1]);
|
|
91
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Recipe execution and cleanup
|
|
2
|
+
|
|
3
|
+
`finish` and the Stop-gate share `recipe-runner.mjs`. Finish allows ten minutes.
|
|
4
|
+
Stop allows ninety seconds unless its trusted workspace declares
|
|
5
|
+
`verify.stopTimeout` in seconds at spec 2.14 or later. Recompile after declaring it:
|
|
6
|
+
the compiled Stop hook allows that deadline plus sixty seconds for other checks
|
|
7
|
+
and cleanup. An invalid declaration prevents execution. Output is drained as it arrives; only the last
|
|
8
|
+
64 KiB and at most one incoming chunk are retained.
|
|
9
|
+
|
|
10
|
+
Stop reads both command and deadline from the hook-told workspace and runs in the
|
|
11
|
+
hook-told root. The payload's session worktree selects its record/work checks,
|
|
12
|
+
and cannot choose executable policy. Verification of that other worktree remains
|
|
13
|
+
the separate #311 trust question; a green here covers the told tree.
|
|
14
|
+
|
|
15
|
+
After three actual recipe refusals in a session, further Stops skip that recipe
|
|
16
|
+
and release explicitly unverified/RED. Repairing files then does not resume
|
|
17
|
+
automatic verification in that session: run it explicitly before claiming done,
|
|
18
|
+
or use a new session. A still-pending handoff at its cap, nine total refusals, or
|
|
19
|
+
the thirteenth consecutive hook-provoked Stop also suppresses recipe execution.
|
|
20
|
+
Skipped runs never charge a recipe failure or become green evidence. A handoff
|
|
21
|
+
that clears before the other bounds allows execution again; ordinary Stops reset
|
|
22
|
+
the chain, and a measured green before the recipe cap clears its streak.
|
|
23
|
+
|
|
24
|
+
The deadline tests inject a long selection and exercise a short live timeout;
|
|
25
|
+
they demonstrate configuration and cleanup, not a real five-minute recipe or a
|
|
26
|
+
live host's outer-timeout enforcement. Counter-file failure/concurrent-write
|
|
27
|
+
limits remain; this is not a cross-session scheduler or a cache of green trees.
|
|
28
|
+
|
|
29
|
+
On POSIX, Bash starts in a detached process group. A timeout sends TERM to the group;
|
|
30
|
+
SIGINT, SIGTERM and SIGHUP received by the active runner are forwarded to it. After
|
|
31
|
+
250 ms the runner sends KILL to that group, even if Bash has already exited or closed
|
|
32
|
+
its pipes. Repeated interruptions do not restart that grace period. Cleanup allows
|
|
33
|
+
one further second for closure and reports failures as could-not-run. Finish retains
|
|
34
|
+
an observed interruption, stops its recipe loop, rolls back its provisional commit
|
|
35
|
+
and pushes nothing.
|
|
36
|
+
|
|
37
|
+
On Windows, the runner checks an absolute system `taskkill.exe` path before starting
|
|
38
|
+
Bash. Cleanup invokes `/PID <owned pid> /T /F` with a one-second command timeout;
|
|
39
|
+
this is forced tree termination rather than POSIX signal forwarding. Missing tools
|
|
40
|
+
and termination errors produce could-not-run diagnostics. Command construction and
|
|
41
|
+
error paths have unit controls here; native Windows execution has not been tested.
|
|
42
|
+
|
|
43
|
+
A normally exiting recipe keeps the existing behavior: inherited pipes are drained
|
|
44
|
+
for at most one second, and background processes can continue. Descendants that
|
|
45
|
+
intentionally escape the POSIX group are outside its cleanup. OS signal denial,
|
|
46
|
+
runner SIGKILL and failures outside active recipe execution cannot promise cleanup
|
|
47
|
+
or commit rollback. This runner does not impose a deadline on finish's other Git
|
|
48
|
+
operations.
|
|
49
|
+
|
|
50
|
+
`recipe-set --agent` wraps each command in `recipe-output.mjs`. That executor
|
|
51
|
+
spools stdout and stderr separately to private descriptors, unlinks their names
|
|
52
|
+
before starting the child, and replays all bytes on failure. Green is one counted
|
|
53
|
+
line. The runner's ordinary tail remains bounded; the executor's failure stream
|
|
54
|
+
is complete at its own boundary. Output capture, replay or setup failures return
|
|
55
|
+
could-not-run, never green.
|
|
56
|
+
|
|
57
|
+
The runner marks the environment of its owned child. A nested executor shares
|
|
58
|
+
that process group and delegates its deadline to the outer runner. On POSIX it
|
|
59
|
+
checks its actual group with `ps` before inheriting ownership, so the outer
|
|
60
|
+
timeout or interruption reaches every descendant even if the executor is killed.
|
|
61
|
+
An inner capture failure also terminates that owned group. Anonymous descriptors
|
|
62
|
+
leave no named temporary logs behind on a hard kill. Standalone executors own
|
|
63
|
+
their own recipe group and a ten-minute deadline. Native Windows execution of
|
|
64
|
+
this output mode remains untested, as does its forced-termination filesystem
|
|
65
|
+
behavior; inability to create anonymous spools refuses execution.
|
|
66
|
+
|
|
67
|
+
The platform mechanisms follow [Node's detached-process documentation](https://nodejs.org/api/child_process.html#optionsdetached)
|
|
68
|
+
and [Microsoft's taskkill reference](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/taskkill).
|