@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.
- package/README.md +15 -4
- package/cli/README.md +61 -6
- package/cli/advisory.mjs +167 -53
- package/cli/cache-risk.mjs +69 -0
- package/cli/comments.mjs +8 -5
- package/cli/compile.mjs +781 -130
- package/cli/context.mjs +204 -43
- package/cli/doctor.mjs +248 -31
- package/cli/drills.mjs +56 -9
- package/cli/finish.md +34 -0
- package/cli/finish.mjs +8 -67
- package/cli/form.mjs +34 -18
- package/cli/frontmatter.mjs +39 -0
- package/cli/gate.mjs +14 -12
- package/cli/guidance-files.mjs +335 -0
- package/cli/handoff-policy.mjs +76 -0
- package/cli/html-entities.mjs +2130 -0
- package/cli/import-files.mjs +15 -0
- package/cli/index.mjs +77 -24
- package/cli/init-preview.mjs +553 -0
- package/cli/init.mjs +261 -46
- package/cli/instructions-journal.mjs +1185 -0
- package/cli/instructions.mjs +332 -57
- package/cli/ledger.mjs +230 -40
- package/cli/librarian.mjs +103 -19
- package/cli/persona-routing.mjs +209 -0
- package/cli/plugin-lint.mjs +77 -56
- package/cli/portulan.mjs +1 -1
- package/cli/recipe-output.mjs +91 -0
- package/cli/recipe-runner.md +68 -0
- package/cli/recipe-runner.mjs +131 -0
- package/cli/recipe-set.mjs +12 -3
- package/cli/review-meter.mjs +14 -9
- package/cli/ritual-contexts.mjs +103 -0
- package/cli/sessions.mjs +2 -1
- package/cli/spend-analysis.mjs +111 -0
- package/cli/spend-offer.md +53 -0
- package/cli/spend-offer.mjs +93 -0
- package/cli/stop-diagnostics.mjs +114 -0
- package/cli/stop-gate.mjs +133 -90
- package/cli/stop-policy.mjs +14 -0
- package/cli/symbols.mjs +55 -8
- package/cli/telemetry.mjs +66 -17
- package/cli/upgrade.mjs +62 -44
- package/cli/vendor.mjs +207 -125
- package/core/operating/context.md +401 -18
- package/core/operating/evolution.md +3 -2
- package/core/operating/loop.md +9 -4
- package/core/operating/sessions.md +6 -15
- package/core/personas/README.md +75 -0
- package/core/personas/implementer.md +3 -0
- package/core/personas/librarian.md +3 -0
- package/core/personas/reviewer.md +3 -0
- package/core/skills/clarify/SKILL.md +1 -1
- package/core/skills/consolidate/SKILL.md +1 -1
- package/core/templates/handoff.md +6 -2
- package/evals/README.md +23 -7
- package/evals/ab/personas/README.md +111 -0
- package/evals/ab/personas/acquisition.json +330 -0
- package/evals/ab/personas/bindings/implementer.txt +43 -0
- package/evals/ab/personas/bindings/librarian.txt +40 -0
- package/evals/ab/personas/bindings/reviewer.txt +41 -0
- package/evals/ab/personas/corpus.json +69 -0
- package/evals/ab/personas/grader.mjs +171 -0
- package/evals/ab/personas/implementer.json +774 -0
- package/evals/ab/personas/implementer.md +22 -0
- package/evals/ab/personas/librarian.json +758 -0
- package/evals/ab/personas/librarian.md +22 -0
- package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
- package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
- package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
- package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
- package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
- package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
- package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
- package/evals/ab/personas/reviewer.json +758 -0
- package/evals/ab/personas/reviewer.md +22 -0
- package/evals/releases/0.3.0.json +150 -0
- package/evals/releases/0.3.0.md +68 -0
- package/evals/releases/0.4.0.json +154 -0
- package/evals/releases/0.4.0.md +69 -0
- package/package.json +7 -2
- package/packs/.claude-plugin/plugin.json +2 -1
- package/packs/README.md +7 -4
- package/packs/rituals/checkpoints/README.md +35 -0
- package/packs/rituals/checkpoints/pack.json +3 -2
- package/packs/tools/README.md +8 -8
- package/packs/tools/local-llm/README.md +176 -0
- package/packs/tools/local-llm/pack.json +32 -0
- package/packs/tools/local-llm/personas/local-worker.md +44 -0
- package/packs/tools/local-llm/runner/local-llm.mjs +1779 -0
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +88 -0
- package/packs/tools/local-llm/verify/README.md +18 -0
- package/spec/README.md +37 -3
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
- package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
- package/spec/migrations/README.md +6 -0
- package/spec/pack.schema.json +12 -3
- package/spec/slots.md +272 -123
- 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
|
-
/**
|
|
94
|
+
/** Explicit leading outcomes only; this classifies a report, never grants consent. */
|
|
93
95
|
export function proposalPending(source) {
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
513
|
-
"
|
|
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();
|
package/cli/plugin-lint.mjs
CHANGED
|
@@ -11,6 +11,9 @@ import path from "node:path";
|
|
|
11
11
|
import { pathToFileURL } from "node:url";
|
|
12
12
|
|
|
13
13
|
import { HOST_SKILL_DEPTH, skillsSet, canonical } from "./skills-set.mjs";
|
|
14
|
+
import { parseFrontmatter } from "./frontmatter.mjs";
|
|
15
|
+
export { parseFrontmatter };
|
|
16
|
+
import { packContributions, personaDeclarations } from "./compile.mjs";
|
|
14
17
|
|
|
15
18
|
/** Raised when `plugin-lint` cannot run at all. Always exit 2, never 1. */
|
|
16
19
|
export class PluginLintError extends Error {
|
|
@@ -25,7 +28,6 @@ const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
|
25
28
|
// Kept by hand with docs/plan.md's repo topology: visibility is a GitHub setting no file can read.
|
|
26
29
|
const PRIVATE_FEEDS = ["sleepy-panda-srl/portulan-internal"];
|
|
27
30
|
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
28
|
-
const BLOCK_SCALAR = /^[|>][+-]?$/;
|
|
29
31
|
|
|
30
32
|
const PATH_FIELDS = [
|
|
31
33
|
"skills",
|
|
@@ -44,46 +46,6 @@ const SKIP_DIRS = new Set([".git", "node_modules", ".claude-plugin"]);
|
|
|
44
46
|
export const AGENT_DIR = "agents";
|
|
45
47
|
const MAX_WALK_DEPTH = 6;
|
|
46
48
|
|
|
47
|
-
// ===========================================================================================
|
|
48
|
-
// Frontmatter
|
|
49
|
-
// ===========================================================================================
|
|
50
|
-
|
|
51
|
-
/** A flat-YAML reader: `fields` is null without a usable block, and `error` is set when that is a defect. */
|
|
52
|
-
export function parseFrontmatter(text) {
|
|
53
|
-
const lines = text.split(/\r?\n/);
|
|
54
|
-
if (lines[0]?.trim() !== "---") return { fields: null };
|
|
55
|
-
|
|
56
|
-
const close = lines.indexOf("---", 1);
|
|
57
|
-
if (close === -1) {
|
|
58
|
-
return { fields: null, error: "the frontmatter block is never closed" };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const fields = {};
|
|
62
|
-
const body = lines.slice(1, close);
|
|
63
|
-
for (let i = 0; i < body.length; i += 1) {
|
|
64
|
-
const match = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(body[i]);
|
|
65
|
-
if (!match) continue;
|
|
66
|
-
const key = match[1];
|
|
67
|
-
let value = match[2].trim();
|
|
68
|
-
|
|
69
|
-
if (BLOCK_SCALAR.test(value)) {
|
|
70
|
-
const collected = [];
|
|
71
|
-
while (i + 1 < body.length && (body[i + 1].trim() === "" || /^\s+/.test(body[i + 1]))) {
|
|
72
|
-
i += 1;
|
|
73
|
-
collected.push(body[i].trim());
|
|
74
|
-
}
|
|
75
|
-
value = collected.join(" ").trim();
|
|
76
|
-
} else if (
|
|
77
|
-
(value.startsWith('"') && value.endsWith('"') && value.length >= 2) ||
|
|
78
|
-
(value.startsWith("'") && value.endsWith("'") && value.length >= 2)
|
|
79
|
-
) {
|
|
80
|
-
value = value.slice(1, -1);
|
|
81
|
-
}
|
|
82
|
-
fields[key] = value;
|
|
83
|
-
}
|
|
84
|
-
return { fields };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
49
|
// ===========================================================================================
|
|
88
50
|
// Inspection
|
|
89
51
|
// ===========================================================================================
|
|
@@ -91,7 +53,7 @@ export function parseFrontmatter(text) {
|
|
|
91
53
|
/** Lexical: a symlink inside `root` can still lead out of it. */
|
|
92
54
|
function escapes(root, target) {
|
|
93
55
|
const inside = path.relative(root, target);
|
|
94
|
-
return inside.startsWith(
|
|
56
|
+
return inside === ".." || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside);
|
|
95
57
|
}
|
|
96
58
|
|
|
97
59
|
/** Throws PluginLintError only when the root is not a readable directory; `payload` excuses an absent marketplace.json. */
|
|
@@ -119,10 +81,21 @@ export function inspect(rawRoot, { payload = false } = {}) {
|
|
|
119
81
|
const fail = (check, message) => findings.push({ severity: "fail", check, message });
|
|
120
82
|
const note = (check, message) => findings.push({ severity: "note", check, message });
|
|
121
83
|
|
|
84
|
+
const ownedText = (file, scope = root) => {
|
|
85
|
+
const realScope = fs.realpathSync(scope), real = fs.realpathSync(file);
|
|
86
|
+
if (escapes(root, realScope) || escapes(realScope, real)) throw Error(`${file} resolves outside its bundled owner`);
|
|
87
|
+
if (!fs.statSync(real).isFile()) throw Error(`${file} is not a regular source file`);
|
|
88
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(real));
|
|
89
|
+
};
|
|
90
|
+
const ownedJson = (file, scope = root) => {
|
|
91
|
+
const value = JSON.parse(ownedText(file, scope));
|
|
92
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw Error(`${file} is not a JSON object`);
|
|
93
|
+
return value;
|
|
94
|
+
};
|
|
122
95
|
/** Never throws: a failed read becomes a finding and returns null. */
|
|
123
96
|
const read = (file, check, label) => {
|
|
124
97
|
try {
|
|
125
|
-
return
|
|
98
|
+
return ownedText(file);
|
|
126
99
|
} catch (error) {
|
|
127
100
|
fail(check, `${label} could not be read — ${error.code ?? error.message}`);
|
|
128
101
|
return null;
|
|
@@ -420,7 +393,7 @@ export function inspect(rawRoot, { payload = false } = {}) {
|
|
|
420
393
|
let composition;
|
|
421
394
|
let composable = true;
|
|
422
395
|
try {
|
|
423
|
-
composition = JSON.parse(
|
|
396
|
+
composition = JSON.parse(ownedText(path.join(root, GOVERNING), path.join(root, ".portulan")));
|
|
424
397
|
} catch (error) {
|
|
425
398
|
if (error.code === "ENOENT") composable = false;
|
|
426
399
|
else {
|
|
@@ -550,7 +523,7 @@ export function inspect(rawRoot, { payload = false } = {}) {
|
|
|
550
523
|
for (const { name, dir } of composed) {
|
|
551
524
|
let text;
|
|
552
525
|
try {
|
|
553
|
-
text =
|
|
526
|
+
text = ownedText(path.join(dir, "pack.json"), dir);
|
|
554
527
|
} catch (error) {
|
|
555
528
|
if (error.code === "ENOENT") continue;
|
|
556
529
|
fail(
|
|
@@ -616,8 +589,8 @@ export function inspect(rawRoot, { payload = false } = {}) {
|
|
|
616
589
|
for (const stranded of walkForStrandedAgents(root)) {
|
|
617
590
|
note(
|
|
618
591
|
"agents",
|
|
619
|
-
`${path.relative(root, stranded)} is not in ./${AGENT_DIR}/,
|
|
620
|
-
"
|
|
592
|
+
`${path.relative(root, stranded)} is not in ./${AGENT_DIR}/, this plugin's agent directory — ` +
|
|
593
|
+
"it will not load as a plugin binding",
|
|
621
594
|
);
|
|
622
595
|
}
|
|
623
596
|
|
|
@@ -703,28 +676,77 @@ export function inspect(rawRoot, { payload = false } = {}) {
|
|
|
703
676
|
// Fails where doctor only reports: this bundle targets one host, and an unbound persona is inert there.
|
|
704
677
|
const personaDir = path.join(root, "core", "personas");
|
|
705
678
|
let personaFiles = null;
|
|
679
|
+
let corePresent = false;
|
|
706
680
|
try {
|
|
681
|
+
fs.lstatSync(personaDir);
|
|
682
|
+
corePresent = true;
|
|
683
|
+
if (escapes(root, fs.realpathSync(personaDir))) throw Error("core/personas/ resolves outside the plugin");
|
|
707
684
|
personaFiles = fs
|
|
708
685
|
.readdirSync(personaDir)
|
|
709
686
|
.filter((entry) => entry.endsWith(".md") && entry !== "README.md")
|
|
710
687
|
.map((entry) => path.basename(entry, ".md"))
|
|
711
688
|
.sort();
|
|
712
689
|
} catch (error) {
|
|
713
|
-
if (error.code !== "ENOENT") fail("agents", `core/personas/ could not be read — ${error.code ?? error.message}, so the binding correspondence went unchecked`);
|
|
690
|
+
if (error.code !== "ENOENT" || corePresent) fail("agents", `core/personas/ could not be read — ${error.code ?? error.message}, so the binding correspondence went unchecked`);
|
|
691
|
+
}
|
|
692
|
+
const owners = new Map();
|
|
693
|
+
for (const name of personaFiles ?? []) {
|
|
694
|
+
const source = `core/personas/${name}.md`;
|
|
695
|
+
try { ownedText(path.join(root, source)); owners.set(name, source); }
|
|
696
|
+
catch (error) { fail("agents", `${source} could not be read — ${error.message}`); }
|
|
697
|
+
}
|
|
698
|
+
let composedDeclared = false;
|
|
699
|
+
const workspaceFile = path.join(root, ".portulan/workspace.json");
|
|
700
|
+
let workspacePresent = false;
|
|
701
|
+
try {
|
|
702
|
+
fs.lstatSync(workspaceFile);
|
|
703
|
+
workspacePresent = true;
|
|
704
|
+
const workspace = ownedJson(workspaceFile, path.dirname(workspaceFile));
|
|
705
|
+
if (workspace?.packs !== undefined && !Array.isArray(workspace.packs)) throw Error("workspace packs is not an array");
|
|
706
|
+
composedDeclared = (workspace?.packs?.length ?? 0) > 0;
|
|
707
|
+
if (composedDeclared) {
|
|
708
|
+
for (const ref of workspace.packs) {
|
|
709
|
+
if (typeof ref !== "string" || !/^[a-z0-9-]+\/[a-z0-9-]+$/.test(ref)) throw Error("a composed pack reference is malformed");
|
|
710
|
+
const packsRoot = fs.realpathSync(path.join(root, "packs"));
|
|
711
|
+
const packDir = fs.realpathSync(path.join(root, "packs", ref));
|
|
712
|
+
if (escapes(root, packsRoot) || escapes(packsRoot, packDir)) throw Error(`${ref} resolves outside its bundled packs root`);
|
|
713
|
+
const pack = ownedJson(path.join(packDir, "pack.json"), packDir);
|
|
714
|
+
if (pack.contributes !== undefined && (pack.contributes === null || typeof pack.contributes !== "object" || Array.isArray(pack.contributes))) throw Error(`${ref} contributes is not a mapping`);
|
|
715
|
+
}
|
|
716
|
+
const composed = packContributions(root, ".portulan", { packRoots: [path.join(root, "packs")] });
|
|
717
|
+
if (composed.unresolved.length) throw Error(`unresolved persona owners: ${composed.unresolved.map(pack => pack.name).join(", ")}`);
|
|
718
|
+
for (const contribution of composed.contributions) {
|
|
719
|
+
for (const rel of contribution.personas) {
|
|
720
|
+
if (typeof rel !== "string" || !rel.trim()) throw Error("a composed persona path is not a nonempty string");
|
|
721
|
+
const file = path.resolve(contribution.dir, rel);
|
|
722
|
+
if (escapes(contribution.dir, file)) throw Error(`${contribution.pack}'s ${rel} resolves outside its bundled pack`);
|
|
723
|
+
const source = `${contribution.pack}'s ${rel}`;
|
|
724
|
+
const parsed = personaDeclarations(ownedText(file, contribution.dir));
|
|
725
|
+
if (parsed.problems.length) throw Error(`${source}: ${parsed.problems.join("; ")}`);
|
|
726
|
+
const name = parsed.name ?? path.basename(rel, ".md");
|
|
727
|
+
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) throw Error(`${source} has no usable binding name`);
|
|
728
|
+
if (owners.has(name)) throw Error(`${name} has multiple persona owners: ${owners.get(name)} and ${source}`);
|
|
729
|
+
owners.set(name, source);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
} catch (error) {
|
|
734
|
+
if (error.code !== "ENOENT" || workspacePresent) fail("agents", `composed persona ownership could not be read — ${error.code ?? error.message}`);
|
|
714
735
|
}
|
|
715
|
-
|
|
736
|
+
const correspondence = personaFiles !== null || composedDeclared;
|
|
737
|
+
if (correspondence && !agentsExamined) {
|
|
716
738
|
note(
|
|
717
739
|
"agents",
|
|
718
740
|
`the persona↔binding correspondence went unchecked — ./${AGENT_DIR}/ could not be examined, so an empty binding set is a ` +
|
|
719
|
-
`question nobody answered rather than ${
|
|
741
|
+
`question nobody answered rather than ${owners.size} persona(s) with nothing bound to them`,
|
|
720
742
|
);
|
|
721
|
-
} else if (
|
|
722
|
-
for (const persona of
|
|
743
|
+
} else if (correspondence) {
|
|
744
|
+
for (const [persona, source] of owners) {
|
|
723
745
|
const binding = bindings.get(persona);
|
|
724
746
|
if (!binding) {
|
|
725
747
|
fail(
|
|
726
748
|
"agents",
|
|
727
|
-
|
|
749
|
+
`${source} has no binding at ./${AGENT_DIR}/${persona}.md — a persona this plugin ships and does not bind ` +
|
|
728
750
|
`is doctrine the host never registers, which looks from the outside exactly like a role that is available`,
|
|
729
751
|
);
|
|
730
752
|
continue;
|
|
@@ -732,14 +754,13 @@ export function inspect(rawRoot, { payload = false } = {}) {
|
|
|
732
754
|
if (binding.name !== undefined && binding.name !== persona) {
|
|
733
755
|
fail(
|
|
734
756
|
"agents",
|
|
735
|
-
`${binding.rel} binds
|
|
757
|
+
`${binding.rel} binds \`${source}\` by its filename while declaring \`name: ${binding.name}\` — the host keys ` +
|
|
736
758
|
`on the field, so the two disagree about which role this file registers`,
|
|
737
759
|
);
|
|
738
760
|
}
|
|
739
761
|
}
|
|
740
|
-
const personaNames = new Set(personaFiles);
|
|
741
762
|
for (const [name, binding] of bindings) {
|
|
742
|
-
if (!
|
|
763
|
+
if (!owners.has(name)) {
|
|
743
764
|
fail(
|
|
744
765
|
"agents",
|
|
745
766
|
`${binding.rel} binds no persona — there is no \`core/personas/${name}.md\`. A host file that outlived the doctrine it was ` +
|
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
|
|
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",
|