@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.
Files changed (87) hide show
  1. package/README.md +15 -4
  2. package/cli/README.md +55 -3
  3. package/cli/advisory.mjs +167 -53
  4. package/cli/cache-risk.mjs +69 -0
  5. package/cli/comments.mjs +8 -5
  6. package/cli/compile.mjs +492 -146
  7. package/cli/context.mjs +204 -43
  8. package/cli/doctor.mjs +211 -33
  9. package/cli/drills.mjs +32 -7
  10. package/cli/finish.md +34 -0
  11. package/cli/finish.mjs +8 -67
  12. package/cli/form.mjs +34 -18
  13. package/cli/frontmatter.mjs +39 -0
  14. package/cli/gate.mjs +14 -12
  15. package/cli/guidance-files.mjs +335 -0
  16. package/cli/handoff-policy.mjs +76 -0
  17. package/cli/html-entities.mjs +2130 -0
  18. package/cli/import-files.mjs +15 -0
  19. package/cli/index.mjs +77 -24
  20. package/cli/init-preview.mjs +553 -0
  21. package/cli/init.mjs +261 -46
  22. package/cli/instructions-journal.mjs +1185 -0
  23. package/cli/instructions.mjs +332 -57
  24. package/cli/ledger.mjs +123 -37
  25. package/cli/librarian.mjs +103 -19
  26. package/cli/persona-routing.mjs +209 -0
  27. package/cli/plugin-lint.mjs +77 -56
  28. package/cli/recipe-output.mjs +91 -0
  29. package/cli/recipe-runner.md +68 -0
  30. package/cli/recipe-runner.mjs +131 -0
  31. package/cli/recipe-set.mjs +12 -3
  32. package/cli/ritual-contexts.mjs +103 -0
  33. package/cli/sessions.mjs +2 -1
  34. package/cli/spend-analysis.mjs +111 -0
  35. package/cli/spend-offer.md +53 -0
  36. package/cli/spend-offer.mjs +93 -0
  37. package/cli/stop-diagnostics.mjs +114 -0
  38. package/cli/stop-gate.mjs +133 -90
  39. package/cli/stop-policy.mjs +14 -0
  40. package/cli/symbols.mjs +55 -8
  41. package/cli/telemetry.mjs +19 -12
  42. package/cli/upgrade.mjs +62 -44
  43. package/cli/vendor.mjs +207 -125
  44. package/core/operating/context.md +368 -60
  45. package/core/operating/evolution.md +3 -2
  46. package/core/operating/loop.md +9 -4
  47. package/core/operating/sessions.md +6 -15
  48. package/core/personas/README.md +52 -10
  49. package/core/personas/implementer.md +3 -0
  50. package/core/personas/librarian.md +3 -0
  51. package/core/personas/reviewer.md +3 -0
  52. package/core/templates/handoff.md +6 -2
  53. package/evals/ab/personas/README.md +111 -0
  54. package/evals/ab/personas/acquisition.json +330 -0
  55. package/evals/ab/personas/bindings/implementer.txt +43 -0
  56. package/evals/ab/personas/bindings/librarian.txt +40 -0
  57. package/evals/ab/personas/bindings/reviewer.txt +41 -0
  58. package/evals/ab/personas/corpus.json +69 -0
  59. package/evals/ab/personas/grader.mjs +171 -0
  60. package/evals/ab/personas/implementer.json +774 -0
  61. package/evals/ab/personas/implementer.md +22 -0
  62. package/evals/ab/personas/librarian.json +758 -0
  63. package/evals/ab/personas/librarian.md +22 -0
  64. package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
  65. package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
  66. package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
  67. package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
  68. package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
  69. package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
  70. package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
  71. package/evals/ab/personas/reviewer.json +758 -0
  72. package/evals/ab/personas/reviewer.md +22 -0
  73. package/evals/releases/0.4.0.json +154 -0
  74. package/evals/releases/0.4.0.md +69 -0
  75. package/package.json +6 -2
  76. package/packs/rituals/checkpoints/README.md +35 -0
  77. package/packs/rituals/checkpoints/pack.json +3 -2
  78. package/packs/tools/local-llm/README.md +46 -6
  79. package/packs/tools/local-llm/runner/local-llm.mjs +127 -13
  80. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +26 -2
  81. package/spec/README.md +37 -4
  82. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
  83. package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
  84. package/spec/migrations/README.md +6 -0
  85. package/spec/pack.schema.json +12 -3
  86. package/spec/slots.md +262 -117
  87. package/spec/workspace.schema.json +67 -11
@@ -0,0 +1,22 @@
1
+ # librarian — acquired routing comparison
2
+
3
+ Host: 2.1.321 (Claude Code). Concrete model: claude-sonnet-5-5.
4
+
5
+ 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.
6
+
7
+ | Arm | Semantic passes | Observed cost index | Cold-normalized cost index |
8
+ |---|---:|---:|---:|
9
+ | A | 3/3 | 189830.90 | 454310.90 |
10
+ | B | 3/3 | 175537.25 | 451854.20 |
11
+
12
+ Medium wins this observed corpus: **yes**. The shipped policy remains inherited model, undeclared effort, instruction floor retained and five-minute cache lifetime.
13
+
14
+ 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.
15
+
16
+ | Case | A | B |
17
+ |---|---|---|
18
+ | librarian-duplicate | PASS | PASS |
19
+ | librarian-conflict | PASS | PASS |
20
+ | librarian-sealed | PASS | PASS |
21
+
22
+ 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.
@@ -0,0 +1,284 @@
1
+ #!/usr/bin/env node
2
+ // Acquire the frozen persona comparisons through native children; never run by a recipe.
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { createHash, randomUUID } from "node:crypto";
6
+ import { spawnSync, execFileSync } from "node:child_process";
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
8
+ import { cloneTree, childEnv, transcriptOf } from "./warm.mjs";
9
+ import { readTranscript } from "./ledger.mjs";
10
+ import { CORPUS, grade } from "../evals/ab/personas/grader.mjs";
11
+
12
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
13
+ const PARENT_MODEL = "claude-sonnet-5-5";
14
+ const ROLES = ["implementer", "reviewer", "librarian"];
15
+ const FROZEN = ["evals/ab/personas/corpus.json", "evals/ab/personas/grader.mjs", "cli/persona-routing.test.mjs"];
16
+ const digest = bytes => createHash("sha256").update(bytes).digest("hex");
17
+ const sha = file => digest(fs.readFileSync(file));
18
+ const json = file => JSON.parse(fs.readFileSync(file, "utf8"));
19
+ const save = (file, value) => fs.writeFileSync(file, JSON.stringify(value, null, 2) + "\n");
20
+ const git = (...args) => execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim();
21
+ const records = file => fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map(line => JSON.parse(line));
22
+
23
+ export function parentPrompt(role, taskFile, effort) {
24
+ const input = {
25
+ subagent_type: `portulan:${role}`, description: "Frozen persona artifact probe",
26
+ prompt: `Read ${taskFile} and follow its task exactly. This is an artifact proposal, not a completed repository change.`,
27
+ run_in_background: false,
28
+ ...(effort === null ? {} : { effort }),
29
+ };
30
+ return "This is a frozen evaluation. Invoke the Agent tool exactly once with these input values: " + JSON.stringify(input) +
31
+ ". Do not supply a child model or change the effort. Wait for that foreground child to finish. Do not answer its task yourself or invoke another child. After completion reply only DONE.";
32
+ }
33
+
34
+ function response(file) {
35
+ const raw = fs.readFileSync(file, "utf8").trim();
36
+ let value;
37
+ try { value = JSON.parse(raw); } catch { throw Error("the CLI returned no single JSON result"); }
38
+ if (value.type !== "result" || value.is_error || value.subtype !== "success" || typeof value.session_id !== "string") throw Error("the CLI did not complete successfully");
39
+ return value;
40
+ }
41
+
42
+ function answerOf(rows) {
43
+ const messages = rows.filter(r => r.type === "assistant" && r.message?.model !== "<synthetic>" && Array.isArray(r.message?.content));
44
+ const last = messages.at(-1)?.message.id;
45
+ if (!last) throw Error("child has no final assistant message");
46
+ const blocks = messages.filter(r => r.message.id === last).flatMap(r => r.message.content).filter(b => b.type === "text");
47
+ return [...new Set(blocks.map(b => b.text))].join("\n").trim();
48
+ }
49
+
50
+ export function nativeInvocation(rows, role, effort) {
51
+ const calls = rows.filter(r => r.type === "assistant").flatMap(r => r.message?.content ?? []).filter(b => b.type === "tool_use" && ["Agent", "Task"].includes(b.name));
52
+ const unique = [...new Map(calls.map(call => [call.id, call])).values()];
53
+ if (unique.length !== 1) throw Error("a parent must invoke exactly one native child");
54
+ const input = unique[0].input;
55
+ if (input?.subagent_type !== `portulan:${role}` || Object.hasOwn(input, "model") || (input.effort ?? null) !== effort) throw Error("the parent did not select the frozen role/inherited model/effort");
56
+ if (input.run_in_background === true) throw Error("a background child is not a completed foreground comparison");
57
+ return { type: input.subagent_type, effort: input.effort ?? null, prompt: input.prompt };
58
+ }
59
+
60
+ function collectCall(entry, dir, tree, env) {
61
+ if (entry.exit !== 0 || entry.timedOut) throw Error("a parent invocation failed or timed out");
62
+ const result = response(path.join(dir, entry.stdout));
63
+ if (result.session_id !== entry.sessionId) throw Error("the result belongs to a different parent session");
64
+ const transcript = transcriptOf(result.session_id, tree, env);
65
+ if (transcript === null) throw Error("the completed parent transcript is unavailable");
66
+ const rows = records(transcript);
67
+ const invocation = nativeInvocation(rows, entry.role, entry.effort);
68
+ if (invocation.prompt !== entry.childPrompt) throw Error("the child did not receive the frozen prompt verbatim");
69
+ const agentDir = path.join(path.dirname(transcript), result.session_id, "subagents");
70
+ const children = fs.readdirSync(agentDir).filter(name => /^agent-.*\.jsonl$/.test(name));
71
+ if (children.length !== 1) throw Error("the parent has other than one child transcript");
72
+ const child = path.join(agentDir, children[0]);
73
+ const childRows = records(child);
74
+ const parentUsage = readTranscript(transcript), childUsage = readTranscript(child);
75
+ if (parentUsage.malformed || childUsage.malformed) throw Error("a transcript has malformed records");
76
+ const parent = parentUsage.requests.filter(r => !r.synthetic && !r.sidechain);
77
+ const requests = childUsage.requests.filter(r => !r.synthetic);
78
+ if (!parent.length || !requests.length) throw Error("parent or child usage is missing");
79
+ const models = [...new Set([...parent, ...requests].map(r => r.model))];
80
+ if (models.length !== 1 || !models[0]) throw Error("inherited parent/child concrete models disagree or are unknown");
81
+ const efforts = [...new Set(requests.map(r => r.effort))];
82
+ if (entry.effort !== null && (efforts.length !== 1 || efforts[0] !== entry.effort)) throw Error("host usage does not establish the requested child effort");
83
+ const target = path.join(dir, "originals", entry.key);
84
+ fs.mkdirSync(target, { recursive: true });
85
+ fs.copyFileSync(transcript, path.join(target, "parent.jsonl"));
86
+ fs.copyFileSync(child, path.join(target, "child.jsonl"));
87
+ return {
88
+ invocation: { type: invocation.type, effort: invocation.effort, promptSha256: digest(invocation.prompt) },
89
+ model: models[0], efforts, parentEfforts: [...new Set(parent.map(r => r.effort))],
90
+ answer: answerOf(childRows), usage: { parent, child: requests },
91
+ raw: { result: sha(path.join(dir, entry.stdout)), parent: sha(path.join(target, "parent.jsonl")), child: sha(path.join(target, "child.jsonl")) },
92
+ transcriptDuplicates: { parent: parentUsage.duplicates, child: childUsage.duplicates },
93
+ };
94
+ }
95
+
96
+ function partialUsage(entry, dir, tree, env) {
97
+ const transcript = transcriptOf(entry.sessionId, tree, env);
98
+ if (transcript === null) return { parent: [], children: [], unavailable: true };
99
+ const target = path.join(dir, "originals", entry.key);
100
+ fs.mkdirSync(target, { recursive: true });
101
+ fs.copyFileSync(transcript, path.join(target, "parent.jsonl"));
102
+ const agentDir = path.join(path.dirname(transcript), entry.sessionId, "subagents");
103
+ const children = fs.existsSync(agentDir) ? fs.readdirSync(agentDir).filter(name => /^agent-.*\.jsonl$/.test(name)).map(name => {
104
+ const file = path.join(agentDir, name);
105
+ fs.copyFileSync(file, path.join(target, name));
106
+ return { usage: readTranscript(file).requests, sha256: sha(file) };
107
+ }) : [];
108
+ return { parent: readTranscript(transcript).requests, parentSha256: sha(transcript), children, unavailable: false };
109
+ }
110
+
111
+ function initialize(dir) {
112
+ if (fs.existsSync(dir)) throw Error("acquisition directory already exists; no automatic retry or resampling");
113
+ fs.mkdirSync(dir, { recursive: true });
114
+ const frozen = Object.fromEntries(FROZEN.map(rel => [rel, sha(path.join(ROOT, rel))]));
115
+ const tree = cloneTree(ROOT, path.join(dir, "tree"));
116
+ const version = execFileSync("claude", ["--version"], { encoding: "utf8" }).trim();
117
+ const journal = { version: 1, phase: "frozen", started: new Date().toISOString(), source: git("rev-parse", "HEAD"), host: version, frozen,
118
+ parentPolicy: { model: PARENT_MODEL, effort: "high", configuredEstimateCeilingUsd: 1, tools: "Agent,Read" }, instrumentSha256: sha(fileURLToPath(import.meta.url)), calls: [] };
119
+ fs.mkdirSync(path.join(dir, "frozen"));
120
+ for (const rel of FROZEN) fs.copyFileSync(path.join(ROOT, rel), path.join(dir, "frozen", path.basename(rel)));
121
+ fs.copyFileSync(fileURLToPath(import.meta.url), path.join(dir, "frozen", "acquire.mjs"));
122
+ fs.mkdirSync(path.join(dir, "tasks"));
123
+ for (const task of CORPUS.cases) save(path.join(dir, "tasks", task.id + ".json"), { role: task.role, task: task.task });
124
+ save(path.join(dir, "journal.json"), journal);
125
+ return { journal, tree };
126
+ }
127
+
128
+ function checkFrozen(journal, dir, tree) {
129
+ if (!journal.qualityFrozen) throw Error("quality inputs were not frozen");
130
+ for (const [rel, expected] of Object.entries(journal.qualityFrozen.semantic)) if (sha(path.join(ROOT, rel)) !== expected) throw Error("frozen semantic source changed");
131
+ for (const [id, expected] of Object.entries(journal.qualityFrozen.tasks)) if (sha(path.join(dir, "tasks", id + ".json")) !== expected) throw Error("frozen task bytes changed");
132
+ if (sha(fileURLToPath(import.meta.url)) !== journal.qualityFrozen.instrument) throw Error("frozen acquisition instrument changed");
133
+ if (journal.decodeRepair) {
134
+ const archive = path.join(dir, "aborted-before-decode-repair.json");
135
+ if (sha(archive) !== journal.decodeRepair.abortedJournalSha256) throw Error("the archived stop changed");
136
+ const retained = JSON.parse(JSON.stringify(journal.calls.slice(0, 2)));
137
+ delete retained[1]?.regraded;
138
+ if (JSON.stringify(retained) !== JSON.stringify(json(archive).calls)) throw Error("the retained original call prefix changed");
139
+ }
140
+ if (execFileSync("git", ["rev-parse", "HEAD"], { cwd: tree, encoding: "utf8" }).trim() !== journal.source || execFileSync("git", ["status", "--porcelain"], { cwd: tree, encoding: "utf8" }).trim()) throw Error("frozen plugin checkout changed");
141
+ }
142
+
143
+ export function pendingQuality(journal) {
144
+ const order = journal.qualityFrozen?.order;
145
+ const expected = CORPUS.cases.flatMap((task, i) => (i % 2 === 0 ? ["a", "b"] : ["b", "a"]).map(arm => `${task.id}-${arm}`));
146
+ if (JSON.stringify(order) !== JSON.stringify(expected)) throw Error("quality order is not the frozen matrix");
147
+ const probe = journal.calls?.[0];
148
+ if (probe?.key !== "mechanism-medium" || probe.task !== "mechanism" || probe.state !== "complete" || probe.effort !== "medium") throw Error("the original completed mechanism is not first");
149
+ const quality = journal.calls.filter(entry => entry.task !== "mechanism");
150
+ if (journal.phase === "quality-frozen" && quality.length === 0 && journal.calls.length === 1) return order;
151
+ const first = journal.calls[1];
152
+ if (journal.phase === "quality-frozen-repaired" && quality.length === 1 && journal.calls.length === 2 &&
153
+ journal.decodeRepair?.retainedKey === order[0] && first.key === order[0] && first.state === "complete" &&
154
+ first.task === CORPUS.cases[0].id && first.role === CORPUS.cases[0].role && first.effort === null &&
155
+ first.model === journal.parentPolicy?.model && first.collected?.model === journal.model &&
156
+ first.collected?.invocation?.type === `portulan:${CORPUS.cases[0].role}` && first.collected.invocation.effort === null &&
157
+ first.grade?.ok === false && first.regraded?.ok === true && journal.decodeRepair.approvedBy) return order.slice(1);
158
+ throw Error("no untouched matrix or approved one-answer decoding repair; refusing retry or resampling");
159
+ }
160
+
161
+ function decodingRepair(journal, dir) {
162
+ if (journal.phase !== "aborted" || journal.calls.length !== 2 || journal.decodeRepair) throw Error("decoding repair requires exactly one retained outcome");
163
+ const previous = journal.qualityFrozen;
164
+ const first = journal.calls[1];
165
+ if (first.key !== previous.order[0] || first.state !== "complete" || first.grade?.ok !== false) throw Error("the retained outcome is not the stopped first control");
166
+ const approval = json(path.join(dir, "decode-repair-approval.json"));
167
+ if (approval.verdict !== "PASS" || !approval.by || approval.previousGrader !== previous.semantic["evals/ab/personas/grader.mjs"] ||
168
+ approval.grader !== sha(path.join(ROOT, "evals/ab/personas/grader.mjs")) || approval.instrument !== sha(fileURLToPath(import.meta.url))) throw Error("decoding repair approval does not cover these exact sources");
169
+ if (sha(path.join(ROOT, "evals/ab/personas/corpus.json")) !== previous.semantic["evals/ab/personas/corpus.json"]) throw Error("decoding repair changed the task corpus");
170
+ for (const [id, expected] of Object.entries(previous.tasks)) if (sha(path.join(dir, "tasks", id + ".json")) !== expected) throw Error("decoding repair changed a task");
171
+ const before = fs.readFileSync(path.join(dir, "frozen-quality", "grader.mjs"), "utf8");
172
+ const after = fs.readFileSync(path.join(ROOT, "evals/ab/personas/grader.mjs"), "utf8");
173
+ if (before.slice(before.indexOf("function codeCases")) !== after.slice(after.indexOf("function codeCases"))) throw Error("decoding repair changed semantic predicates or pricing");
174
+ const original = json(path.join(dir, "aborted-before-decode-repair.json"));
175
+ if (JSON.stringify(original.calls) !== JSON.stringify(journal.calls)) throw Error("the retained calls changed after the stop");
176
+ const repaired = grade(CORPUS.cases[0], first.collected.answer);
177
+ if (!repaired.ok) throw Error("the original retained answer does not pass the repaired decoder");
178
+ fs.mkdirSync(path.join(dir, "frozen-quality-repair"));
179
+ for (const rel of FROZEN) fs.copyFileSync(path.join(ROOT, rel), path.join(dir, "frozen-quality-repair", path.basename(rel)));
180
+ fs.copyFileSync(fileURLToPath(import.meta.url), path.join(dir, "frozen-quality-repair", "acquire.mjs"));
181
+ journal.priorQualityFrozen = previous;
182
+ journal.qualityFrozen = { ...previous, revision: 2, at: new Date().toISOString(),
183
+ semantic: Object.fromEntries(FROZEN.map(rel => [rel, sha(path.join(ROOT, rel))])), instrument: sha(fileURLToPath(import.meta.url)) };
184
+ journal.decodeRepair = { retainedKey: first.key, approvedBy: approval.by, approvalSha256: sha(path.join(dir, "decode-repair-approval.json")),
185
+ abortedJournalSha256: sha(path.join(dir, "aborted-before-decode-repair.json")), previousGrader: approval.previousGrader, grader: approval.grader, observedOutcomes: 1 };
186
+ first.regraded = repaired;
187
+ journal.phase = "quality-frozen-repaired";
188
+ save(path.join(dir, "journal.json"), journal);
189
+ }
190
+
191
+ function call(journal, dir, tree, { key, role, effort, task, model = PARENT_MODEL }) {
192
+ if (task !== "mechanism") checkFrozen(journal, dir, tree);
193
+ const taskFile = path.join(dir, "tasks", task + ".json");
194
+ const env = childEnv(process.env, { cache_lifetime: "5m" }, { local: true });
195
+ const childPrompt = `Read ${taskFile} and follow its task exactly. This is an artifact proposal, not a completed repository change.`;
196
+ const sessionId = randomUUID();
197
+ const args = ["-p", parentPrompt(role, taskFile, effort), "--output-format", "json", "--session-id", sessionId, "--effort", "high", "--plugin-dir", tree,
198
+ "--tools", "Agent,Read", "--allowedTools", `Agent(portulan:${role}),Read`, "--add-dir", path.join(dir, "tasks"), "--max-budget-usd", "1", ...(model ? ["--model", model] : [])];
199
+ const entry = { key, role, effort, task, childPrompt, model, sessionId, argv: args, instrumentSha256: sha(fileURLToPath(import.meta.url)), started: new Date().toISOString(), state: "started", stdout: key + ".stdout.json", stderr: key + ".stderr.txt" };
200
+ journal.calls.push(entry);
201
+ save(path.join(dir, "journal.json"), journal);
202
+ const started = Date.now();
203
+ const run = spawnSync("claude", args, { cwd: tree, env, encoding: "utf8", timeout: 180000, maxBuffer: 4 * 1024 * 1024 });
204
+ fs.writeFileSync(path.join(dir, entry.stdout), run.stdout ?? "");
205
+ fs.writeFileSync(path.join(dir, entry.stderr), run.stderr ?? "");
206
+ Object.assign(entry, { exit: run.status, timedOut: run.error?.code === "ETIMEDOUT", durationMs: Date.now() - started, ended: new Date().toISOString() });
207
+ try {
208
+ entry.collected = collectCall(entry, dir, tree, env);
209
+ entry.state = "complete";
210
+ if (task !== "mechanism") entry.grade = grade(CORPUS.cases.find(c => c.id === task), entry.collected.answer);
211
+ } catch (error) {
212
+ entry.state = "invalid";
213
+ entry.fault = error.message;
214
+ try { entry.partial = partialUsage(entry, dir, tree, env); } catch { entry.partial = { unavailable: true }; }
215
+ journal.phase = "aborted";
216
+ }
217
+ save(path.join(dir, "journal.json"), journal);
218
+ process.stdout.write(`${key}: ${entry.state}${entry.grade ? `, semantic ${entry.grade.ok ? "PASS" : "FAIL"}` : ""}\n`);
219
+ if (entry.state !== "complete") throw Error(entry.fault);
220
+ return entry;
221
+ }
222
+
223
+ export function run(argv = process.argv.slice(2)) {
224
+ const [mode, named] = argv;
225
+ if (!["probe", "freeze", "repair", "run"].includes(mode) || !named || argv.length !== 2) throw Error("usage: node cli/acquire-personas.mjs probe|freeze|repair|run <private-directory>");
226
+ const dir = path.resolve(named);
227
+ if (mode === "probe") {
228
+ const { journal, tree } = initialize(dir);
229
+ save(path.join(dir, "tasks", "mechanism.json"), { role: "implementer", task: "Read your role charter, then reply exactly MECHANISM_OK. This tests native effort selection only; do not edit files or call other tools." });
230
+ const entry = call(journal, dir, tree, { key: "mechanism-medium", role: "implementer", effort: "medium", task: "mechanism" });
231
+ if (entry.collected.answer !== "MECHANISM_OK") throw Error("mechanism child did not finish its fixed task");
232
+ journal.model = entry.collected.model;
233
+ journal.phase = "mechanism-demonstrated";
234
+ save(path.join(dir, "journal.json"), journal);
235
+ return 0;
236
+ }
237
+ const journal = json(path.join(dir, "journal.json"));
238
+ const tree = path.join(dir, "tree");
239
+ if (mode === "repair") { decodingRepair(journal, dir); checkFrozen(journal, dir, tree); return 0; }
240
+ if (mode === "freeze") {
241
+ if (journal.phase !== "mechanism-demonstrated" || journal.calls.length !== 1 || journal.qualityFrozen) throw Error("quality freeze requires one valid probe and no outcomes or prior freeze");
242
+ fs.mkdirSync(path.join(dir, "frozen-quality"));
243
+ for (const rel of FROZEN) fs.copyFileSync(path.join(ROOT, rel), path.join(dir, "frozen-quality", path.basename(rel)));
244
+ fs.copyFileSync(fileURLToPath(import.meta.url), path.join(dir, "frozen-quality", "acquire.mjs"));
245
+ for (const task of CORPUS.cases) save(path.join(dir, "tasks", task.id + ".json"), { role: task.role, task: task.task });
246
+ journal.qualityFrozen = {
247
+ at: new Date().toISOString(), semantic: Object.fromEntries(FROZEN.map(rel => [rel, sha(path.join(ROOT, rel))])),
248
+ instrument: sha(fileURLToPath(import.meta.url)), tasks: Object.fromEntries(CORPUS.cases.map(task => [task.id, sha(path.join(dir, "tasks", task.id + ".json"))])),
249
+ parentPolicy: journal.parentPolicy,
250
+ order: CORPUS.cases.flatMap((task, i) => (i % 2 === 0 ? ["a", "b"] : ["b", "a"]).map(arm => `${task.id}-${arm}`)),
251
+ roles: Object.fromEntries(ROLES.map(role => [role, { persona: sha(path.join(tree, "core/personas", role + ".md")), binding: sha(path.join(tree, "agents", role + ".md")) }])),
252
+ };
253
+ journal.phase = "quality-frozen";
254
+ save(path.join(dir, "journal.json"), journal);
255
+ checkFrozen(journal, dir, tree);
256
+ return 0;
257
+ }
258
+ const pending = new Set(pendingQuality(journal));
259
+ checkFrozen(journal, dir, tree);
260
+ journal.instrument = sha(fileURLToPath(import.meta.url));
261
+ journal.phase = "acquiring";
262
+ save(path.join(dir, "journal.json"), journal);
263
+ try {
264
+ for (const [i, task] of CORPUS.cases.entries()) {
265
+ for (const arm of i % 2 === 0 ? ["a", "b"] : ["b", "a"]) {
266
+ if (!pending.has(`${task.id}-${arm}`)) continue;
267
+ const entry = call(journal, dir, tree, { key: `${task.id}-${arm}`, role: task.role, effort: arm === "a" ? null : "medium", task: task.id });
268
+ if (entry.collected.model !== journal.model) throw Error("the frozen concrete model changed");
269
+ if (arm === "a" && !entry.grade.ok) { journal.phase = "aborted"; save(path.join(dir, "journal.json"), journal); throw Error("a control failed: no routing is earned and no resampling is allowed"); }
270
+ }
271
+ }
272
+ } catch (error) {
273
+ journal.phase = "aborted";
274
+ journal.fault = error.message;
275
+ save(path.join(dir, "journal.json"), journal);
276
+ throw error;
277
+ }
278
+ journal.phase = "complete";
279
+ save(path.join(dir, "journal.json"), journal);
280
+ return 0;
281
+ }
282
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
283
+ try { process.exitCode = run(); } catch (error) { process.stderr.write(`persona acquisition: ${error.message}\n`); process.exitCode = 2; }
284
+ }
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env node
2
+ // Acquire the frozen persona comparisons through native children; never run by a recipe.
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { createHash, randomUUID } from "node:crypto";
6
+ import { spawnSync, execFileSync } from "node:child_process";
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
8
+ import { cloneTree, childEnv, transcriptOf } from "./warm.mjs";
9
+ import { readTranscript } from "./ledger.mjs";
10
+ import { CORPUS, grade } from "../evals/ab/personas/grader.mjs";
11
+
12
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
13
+ const PARENT_MODEL = "claude-sonnet-5-5";
14
+ const ROLES = ["implementer", "reviewer", "librarian"];
15
+ const FROZEN = ["evals/ab/personas/corpus.json", "evals/ab/personas/grader.mjs", "cli/persona-routing.test.mjs"];
16
+ const digest = bytes => createHash("sha256").update(bytes).digest("hex");
17
+ const sha = file => digest(fs.readFileSync(file));
18
+ const json = file => JSON.parse(fs.readFileSync(file, "utf8"));
19
+ const save = (file, value) => fs.writeFileSync(file, JSON.stringify(value, null, 2) + "\n");
20
+ const git = (...args) => execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim();
21
+ const records = file => fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map(line => JSON.parse(line));
22
+
23
+ export function parentPrompt(role, taskFile, effort) {
24
+ const input = {
25
+ subagent_type: `portulan:${role}`, description: "Frozen persona artifact probe",
26
+ prompt: `Read ${taskFile} and follow its task exactly. This is an artifact proposal, not a completed repository change.`,
27
+ run_in_background: false,
28
+ ...(effort === null ? {} : { effort }),
29
+ };
30
+ return "This is a frozen evaluation. Invoke the Agent tool exactly once with these input values: " + JSON.stringify(input) +
31
+ ". Do not supply a child model or change the effort. Wait for that foreground child to finish. Do not answer its task yourself or invoke another child. After completion reply only DONE.";
32
+ }
33
+
34
+ function response(file) {
35
+ const raw = fs.readFileSync(file, "utf8").trim();
36
+ let value;
37
+ try { value = JSON.parse(raw); } catch { throw Error("the CLI returned no single JSON result"); }
38
+ if (value.type !== "result" || value.is_error || value.subtype !== "success" || typeof value.session_id !== "string") throw Error("the CLI did not complete successfully");
39
+ return value;
40
+ }
41
+
42
+ function answerOf(rows) {
43
+ const messages = rows.filter(r => r.type === "assistant" && r.message?.model !== "<synthetic>" && Array.isArray(r.message?.content));
44
+ const last = messages.at(-1)?.message.id;
45
+ if (!last) throw Error("child has no final assistant message");
46
+ const blocks = messages.filter(r => r.message.id === last).flatMap(r => r.message.content).filter(b => b.type === "text");
47
+ return [...new Set(blocks.map(b => b.text))].join("\n").trim();
48
+ }
49
+
50
+ export function nativeInvocation(rows, role, effort) {
51
+ const calls = rows.filter(r => r.type === "assistant").flatMap(r => r.message?.content ?? []).filter(b => b.type === "tool_use" && ["Agent", "Task"].includes(b.name));
52
+ const unique = [...new Map(calls.map(call => [call.id, call])).values()];
53
+ if (unique.length !== 1) throw Error("a parent must invoke exactly one native child");
54
+ const input = unique[0].input;
55
+ if (input?.subagent_type !== `portulan:${role}` || Object.hasOwn(input, "model") || (input.effort ?? null) !== effort) throw Error("the parent did not select the frozen role/inherited model/effort");
56
+ if (input.run_in_background === true) throw Error("a background child is not a completed foreground comparison");
57
+ return { type: input.subagent_type, effort: input.effort ?? null, prompt: input.prompt };
58
+ }
59
+
60
+ function collectCall(entry, dir, tree, env) {
61
+ if (entry.exit !== 0 || entry.timedOut) throw Error("a parent invocation failed or timed out");
62
+ const result = response(path.join(dir, entry.stdout));
63
+ if (result.session_id !== entry.sessionId) throw Error("the result belongs to a different parent session");
64
+ const transcript = transcriptOf(result.session_id, tree, env);
65
+ if (transcript === null) throw Error("the completed parent transcript is unavailable");
66
+ const rows = records(transcript);
67
+ const invocation = nativeInvocation(rows, entry.role, entry.effort);
68
+ if (invocation.prompt !== entry.childPrompt) throw Error("the child did not receive the frozen prompt verbatim");
69
+ const agentDir = path.join(path.dirname(transcript), result.session_id, "subagents");
70
+ const children = fs.readdirSync(agentDir).filter(name => /^agent-.*\.jsonl$/.test(name));
71
+ if (children.length !== 1) throw Error("the parent has other than one child transcript");
72
+ const child = path.join(agentDir, children[0]);
73
+ const childRows = records(child);
74
+ const parentUsage = readTranscript(transcript), childUsage = readTranscript(child);
75
+ if (parentUsage.malformed || childUsage.malformed) throw Error("a transcript has malformed records");
76
+ const parent = parentUsage.requests.filter(r => !r.synthetic && !r.sidechain);
77
+ const requests = childUsage.requests.filter(r => !r.synthetic);
78
+ if (!parent.length || !requests.length) throw Error("parent or child usage is missing");
79
+ const models = [...new Set([...parent, ...requests].map(r => r.model))];
80
+ if (models.length !== 1 || !models[0]) throw Error("inherited parent/child concrete models disagree or are unknown");
81
+ const efforts = [...new Set(requests.map(r => r.effort))];
82
+ if (entry.effort !== null && (efforts.length !== 1 || efforts[0] !== entry.effort)) throw Error("host usage does not establish the requested child effort");
83
+ const target = path.join(dir, "originals", entry.key);
84
+ fs.mkdirSync(target, { recursive: true });
85
+ fs.copyFileSync(transcript, path.join(target, "parent.jsonl"));
86
+ fs.copyFileSync(child, path.join(target, "child.jsonl"));
87
+ return {
88
+ invocation: { type: invocation.type, effort: invocation.effort, promptSha256: digest(invocation.prompt) },
89
+ model: models[0], efforts, parentEfforts: [...new Set(parent.map(r => r.effort))],
90
+ answer: answerOf(childRows), usage: { parent, child: requests },
91
+ raw: { result: sha(path.join(dir, entry.stdout)), parent: sha(path.join(target, "parent.jsonl")), child: sha(path.join(target, "child.jsonl")) },
92
+ transcriptDuplicates: { parent: parentUsage.duplicates, child: childUsage.duplicates },
93
+ };
94
+ }
95
+
96
+ function partialUsage(entry, dir, tree, env) {
97
+ const transcript = transcriptOf(entry.sessionId, tree, env);
98
+ if (transcript === null) return { parent: [], children: [], unavailable: true };
99
+ const target = path.join(dir, "originals", entry.key);
100
+ fs.mkdirSync(target, { recursive: true });
101
+ fs.copyFileSync(transcript, path.join(target, "parent.jsonl"));
102
+ const agentDir = path.join(path.dirname(transcript), entry.sessionId, "subagents");
103
+ const children = fs.existsSync(agentDir) ? fs.readdirSync(agentDir).filter(name => /^agent-.*\.jsonl$/.test(name)).map(name => {
104
+ const file = path.join(agentDir, name);
105
+ fs.copyFileSync(file, path.join(target, name));
106
+ return { usage: readTranscript(file).requests, sha256: sha(file) };
107
+ }) : [];
108
+ return { parent: readTranscript(transcript).requests, parentSha256: sha(transcript), children, unavailable: false };
109
+ }
110
+
111
+ function initialize(dir) {
112
+ if (fs.existsSync(dir)) throw Error("acquisition directory already exists; no automatic retry or resampling");
113
+ fs.mkdirSync(dir, { recursive: true });
114
+ const frozen = Object.fromEntries(FROZEN.map(rel => [rel, sha(path.join(ROOT, rel))]));
115
+ const tree = cloneTree(ROOT, path.join(dir, "tree"));
116
+ const version = execFileSync("claude", ["--version"], { encoding: "utf8" }).trim();
117
+ const journal = { version: 1, phase: "frozen", started: new Date().toISOString(), source: git("rev-parse", "HEAD"), host: version, frozen,
118
+ parentPolicy: { model: PARENT_MODEL, effort: "high", configuredEstimateCeilingUsd: 1, tools: "Agent,Read" }, instrumentSha256: sha(fileURLToPath(import.meta.url)), calls: [] };
119
+ fs.mkdirSync(path.join(dir, "frozen"));
120
+ for (const rel of FROZEN) fs.copyFileSync(path.join(ROOT, rel), path.join(dir, "frozen", path.basename(rel)));
121
+ fs.copyFileSync(fileURLToPath(import.meta.url), path.join(dir, "frozen", "acquire.mjs"));
122
+ fs.mkdirSync(path.join(dir, "tasks"));
123
+ for (const task of CORPUS.cases) save(path.join(dir, "tasks", task.id + ".json"), { role: task.role, task: task.task });
124
+ save(path.join(dir, "journal.json"), journal);
125
+ return { journal, tree };
126
+ }
127
+
128
+ function checkFrozen(journal, dir, tree) {
129
+ if (!journal.qualityFrozen) throw Error("quality inputs were not frozen");
130
+ for (const [rel, expected] of Object.entries(journal.qualityFrozen.semantic)) if (sha(path.join(ROOT, rel)) !== expected) throw Error("frozen semantic source changed");
131
+ for (const [id, expected] of Object.entries(journal.qualityFrozen.tasks)) if (sha(path.join(dir, "tasks", id + ".json")) !== expected) throw Error("frozen task bytes changed");
132
+ if (sha(fileURLToPath(import.meta.url)) !== journal.qualityFrozen.instrument) throw Error("frozen acquisition instrument changed");
133
+ if (execFileSync("git", ["rev-parse", "HEAD"], { cwd: tree, encoding: "utf8" }).trim() !== journal.source || execFileSync("git", ["status", "--porcelain"], { cwd: tree, encoding: "utf8" }).trim()) throw Error("frozen plugin checkout changed");
134
+ }
135
+
136
+ function call(journal, dir, tree, { key, role, effort, task, model = PARENT_MODEL }) {
137
+ if (task !== "mechanism") checkFrozen(journal, dir, tree);
138
+ const taskFile = path.join(dir, "tasks", task + ".json");
139
+ const env = childEnv(process.env, { cache_lifetime: "5m" }, { local: true });
140
+ const childPrompt = `Read ${taskFile} and follow its task exactly. This is an artifact proposal, not a completed repository change.`;
141
+ const sessionId = randomUUID();
142
+ const args = ["-p", parentPrompt(role, taskFile, effort), "--output-format", "json", "--session-id", sessionId, "--effort", "high", "--plugin-dir", tree,
143
+ "--tools", "Agent,Read", "--allowedTools", `Agent(portulan:${role}),Read`, "--add-dir", path.join(dir, "tasks"), "--max-budget-usd", "1", ...(model ? ["--model", model] : [])];
144
+ const entry = { key, role, effort, task, childPrompt, model, sessionId, argv: args, instrumentSha256: sha(fileURLToPath(import.meta.url)), started: new Date().toISOString(), state: "started", stdout: key + ".stdout.json", stderr: key + ".stderr.txt" };
145
+ journal.calls.push(entry);
146
+ save(path.join(dir, "journal.json"), journal);
147
+ const started = Date.now();
148
+ const run = spawnSync("claude", args, { cwd: tree, env, encoding: "utf8", timeout: 180000, maxBuffer: 4 * 1024 * 1024 });
149
+ fs.writeFileSync(path.join(dir, entry.stdout), run.stdout ?? "");
150
+ fs.writeFileSync(path.join(dir, entry.stderr), run.stderr ?? "");
151
+ Object.assign(entry, { exit: run.status, timedOut: run.error?.code === "ETIMEDOUT", durationMs: Date.now() - started, ended: new Date().toISOString() });
152
+ try {
153
+ entry.collected = collectCall(entry, dir, tree, env);
154
+ entry.state = "complete";
155
+ if (task !== "mechanism") entry.grade = grade(CORPUS.cases.find(c => c.id === task), entry.collected.answer);
156
+ } catch (error) {
157
+ entry.state = "invalid";
158
+ entry.fault = error.message;
159
+ try { entry.partial = partialUsage(entry, dir, tree, env); } catch { entry.partial = { unavailable: true }; }
160
+ journal.phase = "aborted";
161
+ }
162
+ save(path.join(dir, "journal.json"), journal);
163
+ process.stdout.write(`${key}: ${entry.state}${entry.grade ? `, semantic ${entry.grade.ok ? "PASS" : "FAIL"}` : ""}\n`);
164
+ if (entry.state !== "complete") throw Error(entry.fault);
165
+ return entry;
166
+ }
167
+
168
+ export function run(argv = process.argv.slice(2)) {
169
+ const [mode, named] = argv;
170
+ if (!["probe", "freeze", "run"].includes(mode) || !named || argv.length !== 2) throw Error("usage: node cli/acquire-personas.mjs probe|freeze|run <private-directory>");
171
+ const dir = path.resolve(named);
172
+ if (mode === "probe") {
173
+ const { journal, tree } = initialize(dir);
174
+ save(path.join(dir, "tasks", "mechanism.json"), { role: "implementer", task: "Read your role charter, then reply exactly MECHANISM_OK. This tests native effort selection only; do not edit files or call other tools." });
175
+ const entry = call(journal, dir, tree, { key: "mechanism-medium", role: "implementer", effort: "medium", task: "mechanism" });
176
+ if (entry.collected.answer !== "MECHANISM_OK") throw Error("mechanism child did not finish its fixed task");
177
+ journal.model = entry.collected.model;
178
+ journal.phase = "mechanism-demonstrated";
179
+ save(path.join(dir, "journal.json"), journal);
180
+ return 0;
181
+ }
182
+ const journal = json(path.join(dir, "journal.json"));
183
+ const tree = path.join(dir, "tree");
184
+ if (mode === "freeze") {
185
+ if (journal.phase !== "mechanism-demonstrated" || journal.calls.length !== 1 || journal.qualityFrozen) throw Error("quality freeze requires one valid probe and no outcomes or prior freeze");
186
+ fs.mkdirSync(path.join(dir, "frozen-quality"));
187
+ for (const rel of FROZEN) fs.copyFileSync(path.join(ROOT, rel), path.join(dir, "frozen-quality", path.basename(rel)));
188
+ fs.copyFileSync(fileURLToPath(import.meta.url), path.join(dir, "frozen-quality", "acquire.mjs"));
189
+ for (const task of CORPUS.cases) save(path.join(dir, "tasks", task.id + ".json"), { role: task.role, task: task.task });
190
+ journal.qualityFrozen = {
191
+ at: new Date().toISOString(), semantic: Object.fromEntries(FROZEN.map(rel => [rel, sha(path.join(ROOT, rel))])),
192
+ instrument: sha(fileURLToPath(import.meta.url)), tasks: Object.fromEntries(CORPUS.cases.map(task => [task.id, sha(path.join(dir, "tasks", task.id + ".json"))])),
193
+ parentPolicy: journal.parentPolicy,
194
+ order: CORPUS.cases.flatMap((task, i) => (i % 2 === 0 ? ["a", "b"] : ["b", "a"]).map(arm => `${task.id}-${arm}`)),
195
+ roles: Object.fromEntries(ROLES.map(role => [role, { persona: sha(path.join(tree, "core/personas", role + ".md")), binding: sha(path.join(tree, "agents", role + ".md")) }])),
196
+ };
197
+ journal.phase = "quality-frozen";
198
+ save(path.join(dir, "journal.json"), journal);
199
+ checkFrozen(journal, dir, tree);
200
+ return 0;
201
+ }
202
+ if (journal.phase !== "quality-frozen" || journal.calls.length !== 1) throw Error("no untouched quality freeze; refusing retry or resampling");
203
+ checkFrozen(journal, dir, tree);
204
+ journal.instrument = sha(fileURLToPath(import.meta.url));
205
+ journal.phase = "acquiring";
206
+ save(path.join(dir, "journal.json"), journal);
207
+ try {
208
+ for (const [i, task] of CORPUS.cases.entries()) {
209
+ for (const arm of i % 2 === 0 ? ["a", "b"] : ["b", "a"]) {
210
+ const entry = call(journal, dir, tree, { key: `${task.id}-${arm}`, role: task.role, effort: arm === "a" ? null : "medium", task: task.id });
211
+ if (entry.collected.model !== journal.model) throw Error("the frozen concrete model changed");
212
+ if (arm === "a" && !entry.grade.ok) { journal.phase = "aborted"; save(path.join(dir, "journal.json"), journal); throw Error("a control failed: no routing is earned and no resampling is allowed"); }
213
+ }
214
+ }
215
+ } catch (error) {
216
+ journal.phase = "aborted";
217
+ journal.fault = error.message;
218
+ save(path.join(dir, "journal.json"), journal);
219
+ throw error;
220
+ }
221
+ journal.phase = "complete";
222
+ save(path.join(dir, "journal.json"), journal);
223
+ return 0;
224
+ }
225
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
226
+ try { process.exitCode = run(); } catch (error) { process.stderr.write(`persona acquisition: ${error.message}\n`); process.exitCode = 2; }
227
+ }