@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,172 @@
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 } 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 ROLES = ["implementer", "reviewer", "librarian"];
14
+ const FROZEN = ["evals/ab/personas/corpus.json", "evals/ab/personas/grader.mjs", "cli/persona-routing.test.mjs"];
15
+ const digest = bytes => createHash("sha256").update(bytes).digest("hex");
16
+ const sha = file => digest(fs.readFileSync(file));
17
+ const json = file => JSON.parse(fs.readFileSync(file, "utf8"));
18
+ const save = (file, value) => fs.writeFileSync(file, JSON.stringify(value, null, 2) + "\n");
19
+ const git = (...args) => execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim();
20
+ const records = file => fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map(line => JSON.parse(line));
21
+
22
+ export function parentPrompt(role, taskFile, effort) {
23
+ const input = {
24
+ subagent_type: `portulan:${role}`, description: "Frozen persona artifact probe",
25
+ prompt: `Read ${taskFile} and follow its task exactly. This is an artifact proposal, not a completed repository change.`,
26
+ run_in_background: false,
27
+ ...(effort === null ? {} : { effort }),
28
+ };
29
+ return "This is a frozen evaluation. Invoke the Agent tool exactly once with these input values: " + JSON.stringify(input) +
30
+ ". 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.";
31
+ }
32
+
33
+ function response(file) {
34
+ const raw = fs.readFileSync(file, "utf8").trim();
35
+ let value;
36
+ try { value = JSON.parse(raw); } catch { throw Error("the CLI returned no single JSON result"); }
37
+ if (value.type !== "result" || value.is_error || value.subtype !== "success" || typeof value.session_id !== "string") throw Error("the CLI did not complete successfully");
38
+ return value;
39
+ }
40
+
41
+ function answerOf(rows) {
42
+ const messages = rows.filter(r => r.type === "assistant" && r.message?.model !== "<synthetic>" && Array.isArray(r.message?.content));
43
+ const last = messages.at(-1)?.message.id;
44
+ if (!last) throw Error("child has no final assistant message");
45
+ const blocks = messages.filter(r => r.message.id === last).flatMap(r => r.message.content).filter(b => b.type === "text");
46
+ return [...new Set(blocks.map(b => b.text))].join("\n").trim();
47
+ }
48
+
49
+ export function nativeInvocation(rows, role, effort) {
50
+ const calls = rows.filter(r => r.type === "assistant").flatMap(r => r.message?.content ?? []).filter(b => b.type === "tool_use" && ["Agent", "Task"].includes(b.name));
51
+ const unique = [...new Map(calls.map(call => [call.id, call])).values()];
52
+ if (unique.length !== 1) throw Error("a parent must invoke exactly one native child");
53
+ const input = unique[0].input;
54
+ 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");
55
+ if (input.run_in_background === true) throw Error("a background child is not a completed foreground comparison");
56
+ return { type: input.subagent_type, effort: input.effort ?? null, prompt: input.prompt };
57
+ }
58
+
59
+ function collectCall(entry, dir, tree, env) {
60
+ if (entry.exit !== 0 || entry.timedOut) throw Error("a parent invocation failed or timed out");
61
+ const result = response(path.join(dir, entry.stdout));
62
+ const transcript = transcriptOf(result.session_id, tree, env);
63
+ if (transcript === null) throw Error("the completed parent transcript is unavailable");
64
+ const rows = records(transcript);
65
+ const invocation = nativeInvocation(rows, entry.role, entry.effort);
66
+ if (invocation.prompt !== entry.childPrompt) throw Error("the child did not receive the frozen prompt verbatim");
67
+ const agentDir = path.join(path.dirname(transcript), result.session_id, "subagents");
68
+ const children = fs.readdirSync(agentDir).filter(name => /^agent-.*\.jsonl$/.test(name));
69
+ if (children.length !== 1) throw Error("the parent has other than one child transcript");
70
+ const child = path.join(agentDir, children[0]);
71
+ const childRows = records(child);
72
+ const parentUsage = readTranscript(transcript), childUsage = readTranscript(child);
73
+ if (parentUsage.malformed || childUsage.malformed) throw Error("a transcript has malformed records");
74
+ const parent = parentUsage.requests.filter(r => !r.synthetic && !r.sidechain);
75
+ const requests = childUsage.requests.filter(r => !r.synthetic);
76
+ if (!parent.length || !requests.length) throw Error("parent or child usage is missing");
77
+ const models = [...new Set([...parent, ...requests].map(r => r.model))];
78
+ if (models.length !== 1 || !models[0]) throw Error("inherited parent/child concrete models disagree or are unknown");
79
+ const efforts = [...new Set(requests.map(r => r.effort))];
80
+ if (entry.effort !== null && (efforts.length !== 1 || efforts[0] !== entry.effort)) throw Error("host usage does not establish the requested child effort");
81
+ const target = path.join(dir, "originals", entry.key);
82
+ fs.mkdirSync(target, { recursive: true });
83
+ fs.copyFileSync(transcript, path.join(target, "parent.jsonl"));
84
+ fs.copyFileSync(child, path.join(target, "child.jsonl"));
85
+ return {
86
+ invocation: { type: invocation.type, effort: invocation.effort, promptSha256: digest(invocation.prompt) },
87
+ model: models[0], efforts, parentEfforts: [...new Set(parent.map(r => r.effort))],
88
+ answer: answerOf(childRows), usage: { parent, child: requests },
89
+ raw: { result: sha(path.join(dir, entry.stdout)), parent: sha(path.join(target, "parent.jsonl")), child: sha(path.join(target, "child.jsonl")) },
90
+ transcriptDuplicates: { parent: parentUsage.duplicates, child: childUsage.duplicates },
91
+ };
92
+ }
93
+
94
+ function initialize(dir) {
95
+ if (fs.existsSync(dir)) throw Error("acquisition directory already exists; no automatic retry or resampling");
96
+ fs.mkdirSync(dir, { recursive: true });
97
+ const frozen = Object.fromEntries(FROZEN.map(rel => [rel, sha(path.join(ROOT, rel))]));
98
+ const tree = cloneTree(ROOT, path.join(dir, "tree"));
99
+ const version = execFileSync("claude", ["--version"], { encoding: "utf8" }).trim();
100
+ const journal = { version: 1, phase: "frozen", started: new Date().toISOString(), source: git("rev-parse", "HEAD"), host: version, frozen, calls: [] };
101
+ fs.mkdirSync(path.join(dir, "frozen"));
102
+ for (const rel of FROZEN) fs.copyFileSync(path.join(ROOT, rel), path.join(dir, "frozen", path.basename(rel)));
103
+ fs.mkdirSync(path.join(dir, "tasks"));
104
+ for (const task of CORPUS.cases) save(path.join(dir, "tasks", task.id + ".json"), { role: task.role, task: task.task });
105
+ save(path.join(dir, "journal.json"), journal);
106
+ return { journal, tree };
107
+ }
108
+
109
+ function call(journal, dir, tree, { key, role, effort, task, model = null }) {
110
+ const taskFile = path.join(dir, "tasks", task + ".json");
111
+ const env = childEnv(process.env, { cache_lifetime: "5m" }, { local: true });
112
+ const childPrompt = `Read ${taskFile} and follow its task exactly. This is an artifact proposal, not a completed repository change.`;
113
+ const args = ["-p", parentPrompt(role, taskFile, effort), "--output-format", "json", "--effort", "high", "--plugin-dir", tree,
114
+ "--tools", "Agent,Read", "--allowedTools", `Agent(portulan:${role}),Read`, "--add-dir", path.join(dir, "tasks"), "--max-budget-usd", "1", ...(model ? ["--model", model] : [])];
115
+ const entry = { key, role, effort, task, childPrompt, model, started: new Date().toISOString(), state: "started", stdout: key + ".stdout.json", stderr: key + ".stderr.txt" };
116
+ journal.calls.push(entry);
117
+ save(path.join(dir, "journal.json"), journal);
118
+ const started = Date.now();
119
+ const run = spawnSync("claude", args, { cwd: tree, env, encoding: "utf8", timeout: 180000, maxBuffer: 4 * 1024 * 1024 });
120
+ fs.writeFileSync(path.join(dir, entry.stdout), run.stdout ?? "");
121
+ fs.writeFileSync(path.join(dir, entry.stderr), run.stderr ?? "");
122
+ Object.assign(entry, { exit: run.status, timedOut: run.error?.code === "ETIMEDOUT", durationMs: Date.now() - started, ended: new Date().toISOString() });
123
+ try {
124
+ entry.collected = collectCall(entry, dir, tree, env);
125
+ entry.state = "complete";
126
+ if (task !== "mechanism") entry.grade = grade(CORPUS.cases.find(c => c.id === task), entry.collected.answer);
127
+ } catch (error) {
128
+ entry.state = "invalid";
129
+ entry.fault = error.message;
130
+ journal.phase = "aborted";
131
+ }
132
+ save(path.join(dir, "journal.json"), journal);
133
+ process.stdout.write(`${key}: ${entry.state}${entry.grade ? `, semantic ${entry.grade.ok ? "PASS" : "FAIL"}` : ""}\n`);
134
+ if (entry.state !== "complete") throw Error(entry.fault);
135
+ return entry;
136
+ }
137
+
138
+ export function run(argv = process.argv.slice(2)) {
139
+ const [mode, named] = argv;
140
+ if (!["probe", "run"].includes(mode) || !named || argv.length !== 2) throw Error("usage: node cli/acquire-personas.mjs probe|run <private-directory>");
141
+ const dir = path.resolve(named);
142
+ if (mode === "probe") {
143
+ const { journal, tree } = initialize(dir);
144
+ 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." });
145
+ const entry = call(journal, dir, tree, { key: "mechanism-medium", role: "implementer", effort: "medium", task: "mechanism" });
146
+ if (entry.collected.answer !== "MECHANISM_OK") throw Error("mechanism child did not finish its fixed task");
147
+ journal.model = entry.collected.model;
148
+ journal.phase = "mechanism-demonstrated";
149
+ save(path.join(dir, "journal.json"), journal);
150
+ return 0;
151
+ }
152
+ const journal = json(path.join(dir, "journal.json"));
153
+ if (journal.phase !== "mechanism-demonstrated" || journal.calls.length !== 1) throw Error("no valid untouched mechanism probe; refusing retry or resampling");
154
+ for (const rel of FROZEN) if (sha(path.join(ROOT, rel)) !== journal.frozen[rel]) throw Error("frozen semantic source changed");
155
+ journal.instrument = sha(fileURLToPath(import.meta.url));
156
+ journal.phase = "acquiring";
157
+ save(path.join(dir, "journal.json"), journal);
158
+ const tree = path.join(dir, "tree");
159
+ for (const [i, task] of CORPUS.cases.entries()) {
160
+ for (const arm of i % 2 === 0 ? ["a", "b"] : ["b", "a"]) {
161
+ const entry = call(journal, dir, tree, { key: `${task.id}-${arm}`, role: task.role, effort: arm === "a" ? null : "medium", task: task.id, model: journal.model });
162
+ if (entry.collected.model !== journal.model) throw Error("the frozen concrete model changed");
163
+ 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"); }
164
+ }
165
+ }
166
+ journal.phase = "complete";
167
+ save(path.join(dir, "journal.json"), journal);
168
+ return 0;
169
+ }
170
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
171
+ try { process.exitCode = run(); } catch (error) { process.stderr.write(`persona acquisition: ${error.message}\n`); process.exitCode = 2; }
172
+ }
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ // Publish whitelisted numeric metadata and generic answers from the completed private persona acquisition.
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 { parentPrompt } from "./acquire-personas.mjs";
8
+ import { CORPUS } from "../evals/ab/personas/grader.mjs";
9
+ import { ROLES, ROUTING, summarize, register, verifyData } from "./persona-routing.mjs";
10
+
11
+ const sha = bytes => createHash("sha256").update(bytes).digest("hex");
12
+ const read = file => JSON.parse(fs.readFileSync(file, "utf8"));
13
+ const digest = file => sha(fs.readFileSync(file));
14
+ const save = (file, value) => fs.writeFileSync(file, JSON.stringify(value, null, 2) + "\n");
15
+ const counters = ["uncached", "written5m", "written1h", "writtenUnknown", "read", "output"];
16
+ const usage = requests => requests.map(request => Object.fromEntries([
17
+ ["id", "request-" + sha(request.id)], ...["model", "effort", "at", ...counters].map(key => [key, request[key]]),
18
+ ]));
19
+
20
+ function normalizedArgs(args, directory, sessionId) {
21
+ return args.map(value => value.replaceAll(path.join(directory, "tree"), "<plugin>").replaceAll(directory, "<acquisition>")
22
+ .replaceAll(sessionId ?? "NO_SESSION_ID_WAS_RECORDED", "<parent-context>"));
23
+ }
24
+
25
+ function normalize(call, directory, firstProbe = false) {
26
+ const partial = firstProbe ? call.partial : null;
27
+ const child = firstProbe ? partial.children.flatMap(child => child.usage) : call.collected.usage.child;
28
+ const parent = firstProbe ? partial.parent : call.collected.usage.parent;
29
+ const raw = firstProbe ? { parent: partial.parentSha256, child: partial.children[0].sha256, result: digest(path.join(directory, call.stdout)) } : call.collected.raw;
30
+ const args = firstProbe ? ["-p", parentPrompt(call.role, path.join(directory, "tasks/mechanism.json"), call.effort), "--output-format", "json", "--effort", "high", "--plugin-dir", path.join(directory, "tree"),
31
+ "--tools", "Agent,Read", "--allowedTools", `Agent(portulan:${call.role}),Read`, "--add-dir", path.join(directory, "tasks"), "--max-budget-usd", "1"] : call.argv;
32
+ const result = {
33
+ key: call.key, task: call.task, role: call.role, effort: call.effort, requestedModel: call.model,
34
+ model: firstProbe ? parent[0].model : call.collected.model, parentEffort: "high", fresh: true,
35
+ context: "context-" + sha(call.sessionId ?? raw.parent), childContext: "child-" + sha(raw.child),
36
+ state: call.state, exit: call.exit, timedOut: call.timedOut, started: call.started, ended: call.ended,
37
+ argv: normalizedArgs(args, directory, call.sessionId), argvProvenance: firstProbe ? "reconstructed-after-execution" : "recorded-before-execution",
38
+ instrumentSha256: firstProbe ? partial.postProbeSourceSha256 : call.instrumentSha256,
39
+ usage: { parent: usage(parent), child: usage(child) }, raw,
40
+ hostEstimateUsd: read(path.join(directory, call.stdout)).total_cost_usd,
41
+ };
42
+ if (firstProbe) Object.assign(result, { reason: "error_max_budget_usd", markerSeen: partial.children[0].markerSeen === true,
43
+ sourceSnapshotTiming: "after-execution", freshContextEvidence: "one parent transcript and one child transcript; no resume option" });
44
+ else {
45
+ const answer = normalizedArgs([call.collected.answer], directory)[0];
46
+ Object.assign(result, { invocation: { ...call.collected.invocation, model: null, background: false }, answer,
47
+ answerRawSha256: sha(call.collected.answer), answerPresentationSha256: sha(answer), answerNormalized: answer !== call.collected.answer,
48
+ transcriptDuplicates: call.collected.transcriptDuplicates });
49
+ }
50
+ if (call.grade) result.grade = call.grade;
51
+ if (call.regraded) result.regraded = call.regraded;
52
+ if (call.task === "mechanism" && !firstProbe) result.markerSeen = call.collected.answer === "MECHANISM_OK";
53
+ else if (call.task !== "mechanism") result.arm = call.key.endsWith("-a") ? "a" : "b";
54
+ return result;
55
+ }
56
+
57
+ export function publish(root, firstDirectory, directory) {
58
+ root = path.resolve(root); firstDirectory = path.resolve(firstDirectory); directory = path.resolve(directory);
59
+ const first = read(path.join(firstDirectory, "journal.json")), journal = read(path.join(directory, "journal.json"));
60
+ if (journal.phase !== "complete" || journal.calls.length !== 19 || first.calls.length !== 1) throw Error("only the complete, unrepeated acquisition may be published");
61
+ const target = path.join(root, "evals/ab/personas");
62
+ const sourceHashes = Object.fromEntries(["evals/ab/personas/corpus.json", "evals/ab/personas/grader.mjs"].map(file => [file, journal.qualityFrozen.semantic[file]]));
63
+ for (const role of ROLES) {
64
+ const file = `core/personas/${role}.md`, expected = journal.qualityFrozen.roles[role].persona;
65
+ if (digest(path.join(directory, "tree", file)) !== expected) throw Error("the acquired charter differs from its frozen hash");
66
+ sourceHashes[file] = expected;
67
+ }
68
+ for (const [file, expected] of Object.entries(sourceHashes)) if (digest(path.join(root, file)) !== expected) throw Error("current source differs from its acquired frozen hash: " + file);
69
+ const records = {}, turns = journal.calls.slice(1).map(call => normalize(call, directory));
70
+ for (const role of ROLES) {
71
+ const record = { version: 1, role, host: journal.host, model: journal.model, turns: turns.filter(turn => turn.role === role) };
72
+ record.summary = summarize(record); records[role] = record;
73
+ }
74
+ const old = fs.readFileSync(path.join(directory, "frozen-quality/grader.mjs"), "utf8");
75
+ const current = fs.readFileSync(path.join(root, "evals/ab/personas/grader.mjs"), "utf8");
76
+ const approval = read(path.join(directory, "decode-repair-approval.json"));
77
+ const preflights = [normalize(first.calls[0], firstDirectory, true), normalize(journal.calls[0], directory)];
78
+ const capture = {
79
+ version: 1, phase: journal.phase, sourceCommit: journal.source, host: journal.host, model: journal.model,
80
+ seed: CORPUS.seed, prices: CORPUS.prices, routing: ROUTING,
81
+ publication: { answerTransformation: "replace only known plugin-root and acquisition-root prefixes with <plugin> and <acquisition>",
82
+ normalizedAnswers: turns.filter(turn => turn.answerNormalized).map(turn => turn.key),
83
+ note: "Original wording otherwise retained; original answers stay private with raw-answer digests. This presentation normalization changes no semantic artifact or score." },
84
+ qualityFrozenAt: journal.priorQualityFrozen.at, continuationFrozenAt: journal.qualityFrozen.at,
85
+ order: journal.qualityFrozen.order, taskSha256: journal.qualityFrozen.tasks,
86
+ preflights, decodeRepair: { ...journal.decodeRepair, approval: approval.verdict, reruns: 0,
87
+ semanticSuffixSha256: sha(current.slice(current.indexOf("function codeCases"))),
88
+ explanation: "After one outcome, the original decoder rejected one JSON fence followed by prose. The same retained answer passes the revised decoder; all semantic predicates, cases and price code are byte-identical. The original false score remains. Independent approval covered exact decoder and continuation instrument hashes before the remaining seventeen calls." },
89
+ accounting: { calls: 20, parentContexts: 20, qualityChildren: 18,
90
+ requests: [...preflights, ...turns].flatMap(call => [...call.usage.parent, ...call.usage.child]).length,
91
+ note: "Both probes, every parent and every child are included. Host USD values are lifecycle estimates, not subscription bills, and are not added to the declared-vector indexes." },
92
+ sources: sourceHashes,
93
+ bindings: Object.fromEntries(ROLES.map(role => [role, journal.qualityFrozen.roles[role].binding])),
94
+ instruments: { firstProbe: { sha256: first.calls[0].partial.postProbeSourceSha256, timing: "snapshotted-after-execution" },
95
+ secondProbe: { sha256: journal.instrumentSha256, timing: "frozen-before-execution" },
96
+ firstOutcome: { sha256: journal.priorQualityFrozen.instrument, timing: "frozen-before-execution" },
97
+ continuation: { sha256: journal.qualityFrozen.instrument, timing: "approved-and-frozen-before-seventeen-untouched-cells" },
98
+ publisher: { sha256: digest(new URL(import.meta.url)), timing: "after-acquisition; whitelisted normalization only" } }, files: {},
99
+ };
100
+ verifyData(capture, records);
101
+ fs.mkdirSync(path.join(target, "bindings"), { recursive: true });
102
+ fs.mkdirSync(path.join(target, "protocol"), { recursive: true });
103
+ for (const role of ROLES) {
104
+ save(path.join(target, `${role}.json`), records[role]);
105
+ fs.writeFileSync(path.join(target, `${role}.md`), register(records[role]));
106
+ fs.copyFileSync(path.join(directory, "tree/agents", `${role}.md`), path.join(target, "bindings", `${role}.txt`));
107
+ capture.files[`${role}.json`] = digest(path.join(target, `${role}.json`));
108
+ }
109
+ const snapshots = [
110
+ [path.join(firstDirectory, "frozen/acquire-v1-after-probe.mjs"), "first-probe.mjs.txt"],
111
+ [path.join(directory, "frozen/acquire.mjs"), "second-probe.mjs.txt"],
112
+ [path.join(directory, "frozen-quality/acquire.mjs"), "first-outcome.mjs.txt"],
113
+ [path.join(directory, "frozen-quality-repair/acquire.mjs"), "continuation.mjs.txt"],
114
+ [new URL(import.meta.url), "publisher.mjs.txt"],
115
+ ];
116
+ for (const [source, name] of snapshots) fs.copyFileSync(source, path.join(target, "protocol", name));
117
+ fs.writeFileSync(path.join(target, "repairs/grader-before-decode-repair.mjs.txt"), old);
118
+ save(path.join(target, "acquisition.json"), capture);
119
+ return digest(path.join(target, "acquisition.json"));
120
+ }
121
+ if (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href) {
122
+ try {
123
+ if (process.argv.length !== 5) throw Error("usage: node cli/normalize-personas.mjs <repo-root> <failed-probe-directory> <completed-directory>");
124
+ console.log(publish(...process.argv.slice(2)));
125
+ } catch (error) { console.error(error.message); process.exitCode = 2; }
126
+ }
@@ -0,0 +1,193 @@
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 call(journal, dir, tree, { key, role, effort, task, model = PARENT_MODEL }) {
129
+ const taskFile = path.join(dir, "tasks", task + ".json");
130
+ const env = childEnv(process.env, { cache_lifetime: "5m" }, { local: true });
131
+ const childPrompt = `Read ${taskFile} and follow its task exactly. This is an artifact proposal, not a completed repository change.`;
132
+ const sessionId = randomUUID();
133
+ const args = ["-p", parentPrompt(role, taskFile, effort), "--output-format", "json", "--session-id", sessionId, "--effort", "high", "--plugin-dir", tree,
134
+ "--tools", "Agent,Read", "--allowedTools", `Agent(portulan:${role}),Read`, "--add-dir", path.join(dir, "tasks"), "--max-budget-usd", "1", ...(model ? ["--model", model] : [])];
135
+ 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" };
136
+ journal.calls.push(entry);
137
+ save(path.join(dir, "journal.json"), journal);
138
+ const started = Date.now();
139
+ const run = spawnSync("claude", args, { cwd: tree, env, encoding: "utf8", timeout: 180000, maxBuffer: 4 * 1024 * 1024 });
140
+ fs.writeFileSync(path.join(dir, entry.stdout), run.stdout ?? "");
141
+ fs.writeFileSync(path.join(dir, entry.stderr), run.stderr ?? "");
142
+ Object.assign(entry, { exit: run.status, timedOut: run.error?.code === "ETIMEDOUT", durationMs: Date.now() - started, ended: new Date().toISOString() });
143
+ try {
144
+ entry.collected = collectCall(entry, dir, tree, env);
145
+ entry.state = "complete";
146
+ if (task !== "mechanism") entry.grade = grade(CORPUS.cases.find(c => c.id === task), entry.collected.answer);
147
+ } catch (error) {
148
+ entry.state = "invalid";
149
+ entry.fault = error.message;
150
+ try { entry.partial = partialUsage(entry, dir, tree, env); } catch { entry.partial = { unavailable: true }; }
151
+ journal.phase = "aborted";
152
+ }
153
+ save(path.join(dir, "journal.json"), journal);
154
+ process.stdout.write(`${key}: ${entry.state}${entry.grade ? `, semantic ${entry.grade.ok ? "PASS" : "FAIL"}` : ""}\n`);
155
+ if (entry.state !== "complete") throw Error(entry.fault);
156
+ return entry;
157
+ }
158
+
159
+ export function run(argv = process.argv.slice(2)) {
160
+ const [mode, named] = argv;
161
+ if (!["probe", "run"].includes(mode) || !named || argv.length !== 2) throw Error("usage: node cli/acquire-personas.mjs probe|run <private-directory>");
162
+ const dir = path.resolve(named);
163
+ if (mode === "probe") {
164
+ const { journal, tree } = initialize(dir);
165
+ 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." });
166
+ const entry = call(journal, dir, tree, { key: "mechanism-medium", role: "implementer", effort: "medium", task: "mechanism" });
167
+ if (entry.collected.answer !== "MECHANISM_OK") throw Error("mechanism child did not finish its fixed task");
168
+ journal.model = entry.collected.model;
169
+ journal.phase = "mechanism-demonstrated";
170
+ save(path.join(dir, "journal.json"), journal);
171
+ return 0;
172
+ }
173
+ const journal = json(path.join(dir, "journal.json"));
174
+ if (journal.phase !== "mechanism-demonstrated" || journal.calls.length !== 1) throw Error("no valid untouched mechanism probe; refusing retry or resampling");
175
+ for (const rel of FROZEN) if (sha(path.join(ROOT, rel)) !== journal.frozen[rel]) throw Error("frozen semantic source changed");
176
+ journal.instrument = sha(fileURLToPath(import.meta.url));
177
+ journal.phase = "acquiring";
178
+ save(path.join(dir, "journal.json"), journal);
179
+ const tree = path.join(dir, "tree");
180
+ for (const [i, task] of CORPUS.cases.entries()) {
181
+ for (const arm of i % 2 === 0 ? ["a", "b"] : ["b", "a"]) {
182
+ const entry = call(journal, dir, tree, { key: `${task.id}-${arm}`, role: task.role, effort: arm === "a" ? null : "medium", task: task.id });
183
+ if (entry.collected.model !== journal.model) throw Error("the frozen concrete model changed");
184
+ 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"); }
185
+ }
186
+ }
187
+ journal.phase = "complete";
188
+ save(path.join(dir, "journal.json"), journal);
189
+ return 0;
190
+ }
191
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
192
+ try { process.exitCode = run(); } catch (error) { process.stderr.write(`persona acquisition: ${error.message}\n`); process.exitCode = 2; }
193
+ }
@@ -0,0 +1,23 @@
1
+ ```json
2
+ {
3
+ "code": "function lowerBound(sorted, value) {\n let lo = 0;\n let hi = sorted.length;\n while (lo < hi) {\n const mid = lo + Math.floor((hi - lo) / 2);\n if (sorted[mid] < value) {\n lo = mid + 1;\n } else {\n hi = mid;\n }\n }\n return lo;\n}"
4
+ }
5
+ ```
6
+
7
+ This is an artifact proposal only. I changed no repository files and ran nothing, since the task said no tools beyond reading the charter were needed.
8
+
9
+ The search range is the half-open interval `[lo, hi)`, with `hi` starting at `sorted.length`. This replaces the old `sorted.length - 1` start, which could never return `sorted.length` and so gave the wrong answer when every entry is below `value`.
10
+
11
+ - **Empty array:** `lo` and `hi` are both 0, so the loop never runs and it returns 0, which equals `sorted.length`.
12
+ - **Duplicates:** when `sorted[mid] >= value`, `hi` moves to `mid`, so the search keeps narrowing toward the leftmost qualifying index.
13
+ - **Value below the first entry:** every probe takes the `hi = mid` branch, so it returns 0.
14
+ - **Value above the last entry:** every probe takes the `lo = mid + 1` branch, so it returns `sorted.length`.
15
+ - **No mutation:** it only reads `sorted` and `value`.
16
+
17
+ Hand-traced on `[1,2,2,3]`:
18
+
19
+ | `value` | Result |
20
+ |---|---|
21
+ | 2 | 1 |
22
+ | 0 | 0 |
23
+ | 4 | 4 |