@sleepy_panda_srl/portulan 0.2.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 (100) hide show
  1. package/README.md +15 -4
  2. package/cli/README.md +61 -6
  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 +781 -130
  7. package/cli/context.mjs +204 -43
  8. package/cli/doctor.mjs +248 -31
  9. package/cli/drills.mjs +56 -9
  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 +230 -40
  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/portulan.mjs +1 -1
  29. package/cli/recipe-output.mjs +91 -0
  30. package/cli/recipe-runner.md +68 -0
  31. package/cli/recipe-runner.mjs +131 -0
  32. package/cli/recipe-set.mjs +12 -3
  33. package/cli/review-meter.mjs +14 -9
  34. package/cli/ritual-contexts.mjs +103 -0
  35. package/cli/sessions.mjs +2 -1
  36. package/cli/spend-analysis.mjs +111 -0
  37. package/cli/spend-offer.md +53 -0
  38. package/cli/spend-offer.mjs +93 -0
  39. package/cli/stop-diagnostics.mjs +114 -0
  40. package/cli/stop-gate.mjs +133 -90
  41. package/cli/stop-policy.mjs +14 -0
  42. package/cli/symbols.mjs +55 -8
  43. package/cli/telemetry.mjs +66 -17
  44. package/cli/upgrade.mjs +62 -44
  45. package/cli/vendor.mjs +207 -125
  46. package/core/operating/context.md +401 -18
  47. package/core/operating/evolution.md +3 -2
  48. package/core/operating/loop.md +9 -4
  49. package/core/operating/sessions.md +6 -15
  50. package/core/personas/README.md +75 -0
  51. package/core/personas/implementer.md +3 -0
  52. package/core/personas/librarian.md +3 -0
  53. package/core/personas/reviewer.md +3 -0
  54. package/core/skills/clarify/SKILL.md +1 -1
  55. package/core/skills/consolidate/SKILL.md +1 -1
  56. package/core/templates/handoff.md +6 -2
  57. package/evals/README.md +23 -7
  58. package/evals/ab/personas/README.md +111 -0
  59. package/evals/ab/personas/acquisition.json +330 -0
  60. package/evals/ab/personas/bindings/implementer.txt +43 -0
  61. package/evals/ab/personas/bindings/librarian.txt +40 -0
  62. package/evals/ab/personas/bindings/reviewer.txt +41 -0
  63. package/evals/ab/personas/corpus.json +69 -0
  64. package/evals/ab/personas/grader.mjs +171 -0
  65. package/evals/ab/personas/implementer.json +774 -0
  66. package/evals/ab/personas/implementer.md +22 -0
  67. package/evals/ab/personas/librarian.json +758 -0
  68. package/evals/ab/personas/librarian.md +22 -0
  69. package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
  70. package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
  71. package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
  72. package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
  73. package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
  74. package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
  75. package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
  76. package/evals/ab/personas/reviewer.json +758 -0
  77. package/evals/ab/personas/reviewer.md +22 -0
  78. package/evals/releases/0.3.0.json +150 -0
  79. package/evals/releases/0.3.0.md +68 -0
  80. package/evals/releases/0.4.0.json +154 -0
  81. package/evals/releases/0.4.0.md +69 -0
  82. package/package.json +7 -2
  83. package/packs/.claude-plugin/plugin.json +2 -1
  84. package/packs/README.md +7 -4
  85. package/packs/rituals/checkpoints/README.md +35 -0
  86. package/packs/rituals/checkpoints/pack.json +3 -2
  87. package/packs/tools/README.md +8 -8
  88. package/packs/tools/local-llm/README.md +176 -0
  89. package/packs/tools/local-llm/pack.json +32 -0
  90. package/packs/tools/local-llm/personas/local-worker.md +44 -0
  91. package/packs/tools/local-llm/runner/local-llm.mjs +1779 -0
  92. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +88 -0
  93. package/packs/tools/local-llm/verify/README.md +18 -0
  94. package/spec/README.md +37 -3
  95. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
  96. package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
  97. package/spec/migrations/README.md +6 -0
  98. package/spec/pack.schema.json +12 -3
  99. package/spec/slots.md +272 -123
  100. package/spec/workspace.schema.json +67 -11
package/cli/librarian.mjs CHANGED
@@ -22,6 +22,8 @@ import { pathToFileURL } from "node:url";
22
22
 
23
23
  import { inspect as inspectIndex, IndexError, recordType, dateOf, isInside } from "./index.mjs";
24
24
  import { parseProvenance } from "./doctor.mjs";
25
+ import { alwaysTier, declaredContext, ESTIMATED_BYTES_PER_TOKEN, tokensOf, TOP_CONTRIBUTORS, treeOf } from "./context.mjs";
26
+ import { BOOT_CARD_UNIT, ENGINE_LINE, GATES_LINE, guidanceDeclaration, guidanceEdits, GUIDANCE_RULES_DIR, LEADS_LINE, parseUnit, resolveWorkspace, RULES_MARKER } from "./compile.mjs";
25
27
 
26
28
  /** Raised when the pass cannot run, or cannot report honestly. Always exit 2. */
27
29
  export class LibrarianError extends Error {
@@ -31,7 +33,7 @@ export class LibrarianError extends Error {
31
33
  }
32
34
  }
33
35
 
34
- const KNOWN_SPECS = new Set(["2.0", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6", "2.7", "2.8", "2.9", "2.10", "2.11", "2.12", "2.13"]);
36
+ const KNOWN_SPECS = new Set(["2.0", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6", "2.7", "2.8", "2.9", "2.10", "2.11", "2.12", "2.13", "2.14", "2.15", "2.16"]);
35
37
 
36
38
  const NOT_A_RECORD = new Set(["README.md"]);
37
39
 
@@ -89,22 +91,32 @@ export function retireWhen(source) {
89
91
  .trim();
90
92
  }
91
93
 
92
- /** Whether a proposal still waits on the human gate: anything short of a stated verdict does, as this feeds a nag, never a gate. */
94
+ /** Explicit leading outcomes only; this classifies a report, never grants consent. */
93
95
  export function proposalPending(source) {
94
- const lines = source.split("\n");
95
- const parts = [];
96
- for (let i = 0; i < lines.length; i += 1) {
97
- if (!/^\s*\*\*(decision|status)\b/i.test(lines[i])) continue;
98
- let end = i;
99
- while (end + 1 < lines.length && lines[end + 1].trim() !== "") end += 1;
100
- parts.push(lines.slice(i, end + 1).join(" "));
101
- i = end;
102
- }
103
- if (!parts.length) return true;
104
- const text = parts.join(" ").replace(/[*_`]/g, "");
105
- if (/\bpending\b/i.test(text)) return true;
106
- if (/[{}]|\|/.test(text)) return true; // an unfilled template line is not a decision
107
- return !/\b(accepted|rejected|revised|applied|withdrawn|superseded)\b/i.test(text);
96
+ const words = "accepted|rejected|revised|applied|withdrawn|superseded|ruled";
97
+ const outcome = new RegExp(`^(${words})\\b`, "i");
98
+ const outcomeWord = new RegExp(`\\b(${words}|pending)\\b`, "i");
99
+ let settled = false;
100
+ for (const line of source.split("\n")) {
101
+ const marker = line.match(/^\s*\*\*(decision|status)[.:]/i);
102
+ if (!marker) continue;
103
+ const declaration = line.replace(/[*_]/g, "").replace(/^\s*(decision|status)[.:]\s*/i, "").split(/\bbecause\b/i)[0].trim();
104
+ const ownerForm = marker[1].toLowerCase() === "decision" ? declaration.match(/^[^—{}|<>\n]{1,160}—\s*(.*)$/) : null;
105
+ const leading = outcome.test(declaration) ? declaration : ownerForm?.[1] ?? declaration;
106
+ const match = leading.match(outcome);
107
+ if (!match) {
108
+ if (outcomeWord.test(declaration) || /[{}|<>]/.test(declaration)) return true;
109
+ if (ownerForm && !/^\d{4}-\d{2}-\d{2}\b/.test(leading)) return true;
110
+ continue;
111
+ }
112
+ const header = leading.split(/—|\bbecause\b/i)[0];
113
+ if (/[{}|<>?]/.test(header)) return true;
114
+ const rest = header.slice(match[0].length);
115
+ const alternative = rest.match(/\/|\bor\b|\bversus\b|\bvs\b|\b(?:and|but)\s+not\b/i);
116
+ if (alternative && outcomeWord.test(rest.slice(alternative.index))) return true;
117
+ settled = true;
118
+ }
119
+ return !settled;
108
120
  }
109
121
 
110
122
  // ---------------------------------------------------------------- Reading history
@@ -173,6 +185,58 @@ const listMarkdown = (dir) => {
173
185
  return names.filter((n) => n.endsWith(".md") && !NOT_A_RECORD.has(n)).sort();
174
186
  };
175
187
 
188
+ function contextHeadroom(dir, workspace) {
189
+ try {
190
+ const declared = declaredContext(workspace);
191
+ const tree = treeOf(dir, workspace);
192
+ const base = { budget: declared.budget, ratio: declared.ratio ?? ESTIMATED_BYTES_PER_TOKEN, calibratedBy: declared.calibratedBy, drafts: [] };
193
+ if (tree === null) return { ...base, state: "unmeasured", bytes: null, tokens: null, remaining: null, reason: "no tree is declared" };
194
+ const root = fs.realpathSync(tree);
195
+ const measured = alwaysTier(root);
196
+ const bytes = measured.entries.reduce((sum, entry) => sum + entry.bytes, 0);
197
+ const tokens = tokensOf(bytes, base.ratio);
198
+ const result = { ...base, state: "measured", bytes, tokens, remaining: base.budget === null ? null : base.budget - tokens,
199
+ exclusions: { missing: measured.missing, outside: measured.outside, tooDeep: measured.tooDeep, scoped: measured.scoped, unlisted: measured.unlisted } };
200
+ if (base.budget === null) return result;
201
+ const realDir = fs.realpathSync(dir);
202
+ const declaration = guidanceDeclaration(root, path.relative(root, realDir) || ".");
203
+ if (declaration === null) return result;
204
+ const realContext = fs.realpathSync(declaration.dir);
205
+ if (!isInside(realDir, realContext) || realContext === realDir) throw new LibrarianError("slots.context resolves outside its workspace");
206
+ const loaded = new Map(measured.entries.filter(entry => entry.label === "rule").map(entry => [path.resolve(entry.file), entry]));
207
+ for (const file of listMarkdown(declaration.dir)) {
208
+ const source = path.join(declaration.dir, file);
209
+ if (fs.realpathSync(source) !== path.resolve(source)) continue;
210
+ if (measured.entries.some(entry => fs.realpathSync(entry.file) === source)) continue;
211
+ const unit = parseUnit(path.basename(file, ".md"), fs.readFileSync(source, "utf8"), file);
212
+ if (unit.tier !== "always" || unit.name === BOOT_CARD_UNIT || unit.body.includes("@")) continue;
213
+ if (unit.body.split("\n").some(line => [ENGINE_LINE, GATES_LINE, LEADS_LINE].some(pattern => pattern.test(line)))) continue;
214
+ const target = path.join(root, GUIDANCE_RULES_DIR, file);
215
+ const entry = loaded.get(target);
216
+ if (!entry || fs.realpathSync(target) !== path.resolve(target)) continue;
217
+ result.drafts.push({ source: path.relative(realDir, source).split(path.sep).join("/"), target: path.relative(root, target).split(path.sep).join("/"),
218
+ from: "always", to: "on-read", bytes: entry.bytes, tokens: tokensOf(entry.bytes, base.ratio) });
219
+ }
220
+ if (result.drafts.length) {
221
+ const plan = guidanceEdits(realDir);
222
+ if (fs.realpathSync(plan.root) !== root) throw new LibrarianError("the compiler ownership plan does not cover the measured tree; no demotions can be drafted");
223
+ const resolved = resolveWorkspace(realDir);
224
+ if (fs.realpathSync(path.resolve(resolved.workspaceRoot, resolved.workspaceDir)) !== realDir) throw new LibrarianError("the compiler ownership plan does not cover the measured workspace; no demotions can be drafted");
225
+ const repairs = new Set([...plan.edits.map(edit => edit.file), ...plan.left]);
226
+ if (repairs.has(`${GUIDANCE_RULES_DIR}/${RULES_MARKER}`)) {
227
+ result.drafts = [];
228
+ result.draftNote = "Compiler ownership needs regeneration; recompile before drafting demotions.";
229
+ } else result.drafts = result.drafts.filter(draft => !repairs.has(draft.target));
230
+ }
231
+ result.drafts.sort((a, b) => b.bytes - a.bytes || (a.source < b.source ? -1 : a.source > b.source ? 1 : 0));
232
+ result.drafts = result.drafts.slice(0, TOP_CONTRIBUTORS);
233
+ return result;
234
+ } catch (cause) {
235
+ if (cause instanceof LibrarianError) throw cause;
236
+ throw new LibrarianError(`the always tier could not be measured or drafted — ${cause.message}`);
237
+ }
238
+ }
239
+
176
240
  /** One workspace's pass, which never writes: `run` does, after it. */
177
241
  export function passWorkspace(dir, { asOf, reviews, since } = {}) {
178
242
  parseDate(asOf, "--as-of");
@@ -385,6 +449,7 @@ export function passWorkspace(dir, { asOf, reviews, since } = {}) {
385
449
  handoffs: series,
386
450
  mining,
387
451
  consolidation,
452
+ context: contextHeadroom(dir, workspace),
388
453
  };
389
454
  }
390
455
 
@@ -509,8 +574,8 @@ export function renderReport(results, { asOf }) {
509
574
  "",
510
575
  `**Date:** ${asOf} · Filed by \`cli/librarian.mjs\` on a cron.`,
511
576
  "",
512
- "**State.** Every figure below is **as of " + asOf + "**, read from git history rather than from the",
513
- "filesystem, and nothing here is a decision. This pass drafts; the maintainer disposes.",
577
+ `**State.** Ages are read from git history as of ${asOf}; sizes and context headroom describe the tree on arrival, before any index regeneration.`,
578
+ "Nothing here is a decision. This pass drafts; the maintainer disposes.",
514
579
  "",
515
580
  ];
516
581
 
@@ -521,6 +586,25 @@ export function renderReport(results, { asOf }) {
521
586
  }
522
587
  const t = r.thresholds;
523
588
  out.push(`## ${r.name}`, "");
589
+ const context = r.context;
590
+ if (context) {
591
+ const cap = context.budget === null ? "budget undeclared; headroom unjudged" : `budget ${context.budget} tokens`;
592
+ if (context.state === "unmeasured") out.push(`**Always tier — Claude Code project.** Not measured: ${context.reason}; ${cap}.`, "");
593
+ else {
594
+ const headroom = context.remaining === null ? cap : `${cap}; ~${context.remaining} tokens headroom`;
595
+ out.push(`**Always tier — Claude Code project.** ${context.bytes} B, ~${context.tokens} tokens at ${context.ratio} bytes per token (${context.calibratedBy ?? "unconfigured estimate"}); ${headroom}.`, "");
596
+ const e = context.exclusions;
597
+ out.push(`**Measurement limits.** ${e.missing.length} missing imports; ${e.outside.length} outside imports or links; ${e.tooDeep.length} depth-limited imports; ${e.scoped} path-scoped rules; ${e.unlisted} descriptions unavailable. Personal/parent instructions, auto memory, system/tool prompts and plugin descriptions are outside this project measurement.`, "");
598
+ for (const [label, values] of [["Missing", e.missing], ["Outside", e.outside], ["Depth-limited", e.tooDeep]]) {
599
+ if (values.length) out.push(`${label}: ${values.map(value => `\`${value}\``).join(", ")}.`, "");
600
+ }
601
+ }
602
+ out.push(`**Context demotion drafts.** ${context.drafts.length} conditional candidate(s); nothing applied. Pure, loaded, compiler-owned, matching always units only; boot cards, at-sign text, imported sources, expansions, links and stale targets are not drafted.`, "");
603
+ if (context.draftNote) out.push(context.draftNote, "");
604
+ for (const draft of context.drafts) {
605
+ out.push(`- **Draft: \`${draft.source}\`, \`${draft.from}\` → \`${draft.to}\`.** \`${draft.target}\` currently contributes ${draft.bytes} B (~${draft.tokens} tokens). If the maintainer decides this guidance can wait for an explicit read, change its tier, add a one-line \`description\`, preserve its body, recompile, and record before/after measurements. This is not a measured savings claim.`, "");
606
+ }
607
+ }
524
608
  out.push(
525
609
  `**Store.** ${plural(r.counts.records, "record")}, ${plural(r.counts.rules, "rule")}, ` +
526
610
  `${(r.counts.bytes / 1024).toFixed(1)} KB` +
@@ -543,7 +627,7 @@ export function renderReport(results, { asOf }) {
543
627
  `${(r.handoffs.bytes / 1024).toFixed(1)} KB, oldest ${r.handoffs.oldest ? `\`${r.handoffs.oldest.file}\` at ${plural(r.handoffs.oldest.days, "day")}` : "none — the series is empty"}. ` +
544
628
  "Index: " +
545
629
  indexState(r.index.series.handoffs) +
546
- ". No threshold reaches this series and no demotion is drafted against it: it is " +
630
+ ". No aggregate size or staleness threshold reaches this series and no staleness demotion is drafted against it: it is " +
547
631
  "append-only, so the only repair a staleness draft could recommend is deleting the " +
548
632
  "record the series exists to keep.",
549
633
  "",
@@ -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();
@@ -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("..") || path.isAbsolute(inside);
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 fs.readFileSync(file, "utf8");
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(fs.readFileSync(path.join(root, GOVERNING), "utf8"));
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 = fs.readFileSync(path.join(dir, "pack.json"), "utf8");
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}/, the only directory the host ` +
620
- "loads agents from — it will not load",
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
- if (personaFiles && !agentsExamined) {
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 ${personaFiles.length} persona(s) with nothing bound to them`,
741
+ `question nobody answered rather than ${owners.size} persona(s) with nothing bound to them`,
720
742
  );
721
- } else if (personaFiles) {
722
- for (const persona of personaFiles) {
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
- `core/personas/${persona}.md has no binding at ./${AGENT_DIR}/${persona}.md — a persona this plugin ships and does not bind ` +
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 \`core/personas/${persona}.md\` by its filename while declaring \`name: ${binding.name}\` — the host keys ` +
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 (!personaNames.has(name)) {
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 ` +
package/cli/portulan.mjs CHANGED
@@ -23,7 +23,7 @@ export const SUBCOMMANDS = [
23
23
  {
24
24
  name: "doctor",
25
25
  module: "doctor.mjs",
26
- summary: "validate a workspace against the Workspace Definition",
26
+ summary: "validate a workspace and report branch-owned host tokens beside local receipts; --static skips usage records",
27
27
  },
28
28
  {
29
29
  name: "compile",