@sleepy_panda_srl/portulan 0.2.0 → 0.3.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 CHANGED
@@ -20,7 +20,7 @@ workspaces and premium packs delivered as private-marketplace plugins.
20
20
 
21
21
  ## Status
22
22
 
23
- **Current release: `0.2.0`** — recorded in [`CHANGELOG.md`](CHANGELOG.md), which is also the honest
23
+ **Current release: `0.3.0`** — recorded in [`CHANGELOG.md`](CHANGELOG.md), which is also the honest
24
24
  account of what the release does *not* contain.
25
25
 
26
26
  This is pre-release software. The leading `0.` is meant literally: nothing here is stable yet, and
@@ -119,7 +119,7 @@ commit, refusing any packed file that differs from its source.
119
119
  |---|---|
120
120
  | [`init`](cli/init.mjs) | Interview plus codebase scan, producing a drafted workspace |
121
121
  | [`doctor`](cli/doctor.mjs) | Validate a workspace against the Workspace Definition |
122
- | [`compile`](cli/compile.mjs) | Turn gates and verify recipes into host enforcement |
122
+ | [`compile`](cli/compile.mjs) | Turn gates and verify recipes into host enforcement, and what a pack's persona declares about its context into the agent binding it is given, which Claude Code loads only at a plugin root |
123
123
  | [`vendor`](cli/vendor.mjs) | Materialise a workspace where it is needed, in either direction |
124
124
  | [`index`](cli/index.mjs) | Rebuild the declared indexes over memory, handoffs and personas; print changelog fragments for a release |
125
125
  | [`upgrade`](cli/upgrade.mjs) | Migrate a workspace across Workspace Definition versions |
package/cli/README.md CHANGED
@@ -22,7 +22,7 @@ in [`../.portulan/verify/`](../.portulan/verify/), and its operator tooling in
22
22
  |---|---|
23
23
  | [`portulan.mjs`](portulan.mjs) | Portulan — the one entry point the `npx` package exposes. |
24
24
  | [`init.mjs`](init.mjs) | `portulan init`: draft a workspace for a repository that has none. |
25
- | [`doctor.mjs`](doctor.mjs) | `portulan doctor`: validate a workspace against the Workspace Definition. |
25
+ | [`doctor.mjs`](doctor.mjs) | `portulan doctor`: validate a workspace and report branch-owned host tokens beside local receipts; --static skips usage records. |
26
26
  | [`compile.mjs`](compile.mjs) | `portulan compile`: compile a workspace's gate policy into host enforcement. |
27
27
  | [`vendor.mjs`](vendor.mjs) | `portulan vendor`: materialise a workspace where it is needed: into a host, or between residences. |
28
28
  | [`index.mjs`](index.mjs) | `portulan index`: regenerate the memory, handoff and scope indexes. |
@@ -63,6 +63,7 @@ Run as `node cli/<file>.mjs`, or imported by the files above.
63
63
  | [`instructions.mjs`](instructions.mjs) | `instructions` — the sections a team marks in its own instruction file, moved to on-read units and proved. |
64
64
  | [`ledger.mjs`](ledger.mjs) | What a change spends, measured from the host's own usage records. |
65
65
  | [`librarian.mjs`](librarian.mjs) | `librarian` — the scheduled pass over the curated layer: reindex, staleness, nags, demotion drafts. |
66
+ | [`local-routing.mjs`](local-routing.mjs) | Verify the shipped operational local roles against their immutable acquired A/B record. No model. Not shipped. |
66
67
  | [`manifest.mjs`](manifest.mjs) | The two facts every tool here needs out of `package.json`, read once and in a module that imports nothing of ours. |
67
68
  | [`mutants.mjs`](mutants.mjs) | Mutation-test this repository's two gate matchers against the corpus that claims to cover them. |
68
69
  | [`pack-identity.mjs`](pack-identity.mjs) | Every file `npm pack` emits is byte-identical to the tracked blob it came from. |
@@ -71,7 +72,7 @@ Run as `node cli/<file>.mjs`, or imported by the files above.
71
72
  | [`plugin-lint.mjs`](plugin-lint.mjs) | `plugin-lint` — the packaging validator. |
72
73
  | [`recipe-set.mjs`](recipe-set.mjs) | The recipe set: the verify recipes a workspace yields, its own plus each composed pack's, namespaced by pack. |
73
74
  | [`release-eval.mjs`](release-eval.mjs) | Portulan — the eval result a release carries. |
74
- | [`review-meter.mjs`](review-meter.mjs) | The review-loop meter: the figures that bound this repository's review loop, derived from its reviews. |
75
+ | [`review-meter.mjs`](review-meter.mjs) | The review-loop meter: the historical figures of this repository's Copilot review loop, derived from its reviews. |
75
76
  | [`roster.mjs`](roster.mjs) | The cli roster: `cli/README.md`, rendered from the files it lists and never edited by hand. Not shipped. |
76
77
  | [`rule-carriers.mjs`](rule-carriers.mjs) | `rule-carriers` — the rail that keeps a reduced rule reduced. |
77
78
  | [`sessions.mjs`](sessions.mjs) | The session switches' texts: the cache-lifetime offer `init` and `upgrade` print, and the one line `doctor` reports on them. |
@@ -92,6 +93,7 @@ Node's own runner: `node --test "cli/**/*.test.mjs"`, which the `tests` recipe r
92
93
  | [`ab-run.test.mjs`](ab-run.test.mjs) | Tests for `ab-run` — the A/B runner: isolation, the matrix, the journal, the register and the checks behind it. |
93
94
  | [`ab.test.mjs`](ab.test.mjs) | Tests for `ab` — the A/B arm builder: its disposition table, rule-2 matcher, nonces, isolation and stop probe. |
94
95
  | [`advisory.test.mjs`](advisory.test.mjs) | Tests for `advisory` — the restart advisory's one line with a tool result or at the prompt, and its figure in the status line. |
96
+ | [`branch-freshness.test.mjs`](branch-freshness.test.mjs) | Tests for this repository's branch freshness check and trusted workflow. |
95
97
  | [`checkout-refs.live.test.mjs`](checkout-refs.live.test.mjs) | A workflow that checks out MORE THAN ONCE names the ref of every checkout after the first. |
96
98
  | [`cli-roster.live.test.mjs`](cli-roster.live.test.mjs) | `cli/README.md` is exactly what ./roster.mjs renders from this directory, and the render keeps its shape. |
97
99
  | [`collisions.test.mjs`](collisions.test.mjs) | The collision contract: `init`, `new` and `vendor` answer each contracted state alike, and no fourth `collisions` exists. |
@@ -100,7 +102,7 @@ Node's own runner: `node --test "cli/**/*.test.mjs"`, which the `tests` recipe r
100
102
  | [`context.test.mjs`](context.test.mjs) | Tests for `context` — what a boot reads and what the host loads into every context, measured. |
101
103
  | [`control-chars.test.mjs`](control-chars.test.mjs) | Tests for `control-chars` — the rail on bytes a reader cannot see. |
102
104
  | [`discover.test.mjs`](discover.test.mjs) | Tests for host plugin-cache discovery. |
103
- | [`doctor.test.mjs`](doctor.test.mjs) | Tests for `doctor` — the Workspace Definition validator. |
105
+ | [`doctor.test.mjs`](doctor.test.mjs) | Tests for `doctor` — workspace validation, branch-owned hybrid usage and static-mode isolation. |
104
106
  | [`drills.test.mjs`](drills.test.mjs) | Tests for `drills` — the forced-red drill harness: its table, perturbation guards, pair oracle, hook rails and tree choice. |
105
107
  | [`eval-bundle.test.mjs`](eval-bundle.test.mjs) | The evaluation-bundle cutter, driven on this repository AND on real fixture repositories. |
106
108
  | [`feedback.live.test.mjs`](feedback.live.test.mjs) | The sender's field map, against the real issue forms rather than against a fixture. |
@@ -121,6 +123,7 @@ Node's own runner: `node --test "cli/**/*.test.mjs"`, which the `tests` recipe r
121
123
  | [`mutants.test.mjs`](mutants.test.mjs) | Tests for `mutants`, the mutation-census rail: its operator table, its imports, its corpus and its refusals. |
122
124
  | [`new.test.mjs`](new.test.mjs) | Tests for `new`: scaffolds that validate, written into the user's own layer and never into `core/`. |
123
125
  | [`pack-identity.test.mjs`](pack-identity.test.mjs) | Tests for the byte-identity rail: green, both findings and could-not-run, each exercised. |
126
+ | [`pack-local-llm.test.mjs`](pack-local-llm.test.mjs) | Tests for the `tools/local-llm` pack's runner — its loopback, input-budget and rollback rails, its eval screen, dedup ranking and A/B record. |
124
127
  | [`pack-version.test.mjs`](pack-version.test.mjs) | The pack-version rail, driven on REAL git repositories rather than an injected history. |
125
128
  | [`payload.test.mjs`](payload.test.mjs) | Tests for the payload-classification rail. |
126
129
  | [`pinned-roots.live.test.mjs`](pinned-roots.live.test.mjs) | Required checks name their resolution root, and every suite whose imports reach the host's plugin record empties it. |
package/cli/compile.mjs CHANGED
@@ -23,7 +23,7 @@ export class CompileError extends Error {
23
23
  }
24
24
 
25
25
  /** The `portulan.spec` versions of `gates.json`: a train separate from the Workspace Definition's, though the numbers overlap. */
26
- export const KNOWN_GATE_POLICY_SPECS = new Set(["2.1", "2.2"]);
26
+ export const KNOWN_GATE_POLICY_SPECS = new Set(["2.1", "2.2", "2.3"]);
27
27
 
28
28
  const TIERS = new Set(["auto", "propose", "gated", "prohibited"]);
29
29
 
@@ -131,11 +131,11 @@ export function parse(policy) {
131
131
  rules.push({ id, tier: rule.tier, kind, target: action[kind], reason: rule.reason, action });
132
132
  }
133
133
 
134
- return { rules, floor: parseFloor(policy.floor) };
134
+ return { rules, floor: parseFloor(policy.floor, String(spec)) };
135
135
  }
136
136
 
137
- /** The optional `floor`, validated whole, or null; `strict` is not declarable, as no pull request merges from behind its base. */
138
- function parseFloor(floor) {
137
+ /** The optional platform floor, with strict freshness unless the policy explicitly chooses otherwise. */
138
+ function parseFloor(floor, spec) {
139
139
  if (floor === undefined || floor === null) return null;
140
140
  if (typeof floor !== "object" || Array.isArray(floor)) {
141
141
  throw new CompileError("`floor` is present but is not a JSON object");
@@ -180,7 +180,11 @@ function parseFloor(floor) {
180
180
  if (typeof floor.resolve_conversations !== "boolean") {
181
181
  throw new CompileError("`floor.resolve_conversations` must be a boolean — omitting it would export a floor weaker than the one in force");
182
182
  }
183
- return { branch, checks, reviews: floor.reviews, resolve_conversations: floor.resolve_conversations };
183
+ if (Object.hasOwn(floor, "strict")) {
184
+ if (spec !== "2.3") throw new CompileError("`floor.strict` requires gate-policy 2.3");
185
+ if (typeof floor.strict !== "boolean") throw new CompileError("`floor.strict` must be a boolean");
186
+ }
187
+ return { branch, checks, reviews: floor.reviews, resolve_conversations: floor.resolve_conversations, strict: floor.strict ?? true };
184
188
  }
185
189
 
186
190
  // ---------------------------------------------------------------- what an action means, to the compiler and the gate alike
@@ -811,8 +815,7 @@ export function githubRuleset(parsed, options = {}) {
811
815
  rules.push({
812
816
  type: "required_status_checks",
813
817
  parameters: {
814
- // Not declarable: a pull request may never merge from behind its base.
815
- strict_required_status_checks_policy: true,
818
+ strict_required_status_checks_policy: floor.strict,
816
819
  do_not_enforce_on_create: false,
817
820
  required_status_checks: floor.checks,
818
821
  },
@@ -2217,10 +2220,18 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
2217
2220
  `Refusing to compose it — run \`doctor\` to validate the pack against the Pack Definition.`,
2218
2221
  );
2219
2222
  }
2223
+ const personas = packManifest?.contributes?.personas;
2224
+ if (personas !== undefined && !Array.isArray(personas)) {
2225
+ throw new CompileError(
2226
+ `the pack manifest for \`${found.name}\` declares \`contributes.personas\` as ${typeof personas} rather than an array. ` +
2227
+ `Refusing to compose it — run \`doctor\` to validate the pack against the Pack Definition.`,
2228
+ );
2229
+ }
2220
2230
  contributions.push({
2221
2231
  pack: found.name,
2222
2232
  dir: found.dir,
2223
2233
  fragments: fragments ?? [],
2234
+ personas: personas ?? [],
2224
2235
  origin: recordedOrigin(found.root, plan, workspaceRoot),
2225
2236
  version: typeof packManifest?.portulan?.version === "string" ? packManifest.portulan.version : null,
2226
2237
  });
@@ -2328,6 +2339,291 @@ export function render(settings) {
2328
2339
  return `${JSON.stringify(settings, null, 2)}\n`;
2329
2340
  }
2330
2341
 
2342
+ // ---------------------------------------------------------------- personas: what a role declares about its context
2343
+
2344
+ /** The fields a persona may declare about the context it runs in, in the order a binding carries them. */
2345
+ export const PERSONA_FIELDS = ["model", "effort", "omitClaudeMd", "cacheLifetime"];
2346
+
2347
+ export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
2348
+
2349
+ const MODEL_NAME = /^[A-Za-z0-9][A-Za-z0-9._:@/[\]-]{0,199}$/;
2350
+
2351
+ const readBoolean = (raw) => raw === "true" ? true : raw === "false" ? false : undefined;
2352
+
2353
+ const PERSONA_VALUES = {
2354
+ model: { read: (raw) => (MODEL_NAME.test(raw) ? raw : undefined), takes: "one model name or alias, written as a single token" },
2355
+ effort: { read: (raw) => (EFFORT_LEVELS.includes(raw) ? raw : undefined), takes: `one of ${EFFORT_LEVELS.join(", ")}` },
2356
+ omitClaudeMd: { read: readBoolean, takes: "`true` or `false`, unquoted" },
2357
+ cacheLifetime: { read: (raw) => (CACHE_LIFETIMES.includes(raw) ? raw : undefined), takes: `one of ${CACHE_LIFETIMES.join(", ")}` },
2358
+ };
2359
+
2360
+ /** Claude Code's frontmatter key for each field, a dotted one nested under its first word. */
2361
+ const CLAUDE_CODE_KEYS = { model: "model", effort: "effort", omitClaudeMd: "omitClaudeMd", cacheLifetime: "experimental.cacheTtl" };
2362
+
2363
+ /** `null` is a field the host cannot express; `why` is the sentence the degradation report gives for it. */
2364
+ export const PERSONA_HOSTS = {
2365
+ "claude-code": {
2366
+ label: "Claude Code",
2367
+ ...Object.fromEntries(PERSONA_FIELDS.map((field) => [field, `\`${CLAUDE_CODE_KEYS[field]}\``])),
2368
+ },
2369
+ "agents-md": {
2370
+ label: "AGENTS.md, vendored",
2371
+ model: null,
2372
+ effort: null,
2373
+ omitClaudeMd: null,
2374
+ cacheLifetime: null,
2375
+ why: "AGENTS.md defines no agent to carry it, so the role runs with the session's own",
2376
+ },
2377
+ };
2378
+
2379
+ /** Where a Claude Code binding sits in a tree, as `AGENT_DIR` in ./plugin-lint.mjs, which a test holds it to. */
2380
+ export const BINDING_DIR = "agents";
2381
+
2382
+ const PLUGIN_MANIFEST = ".claude-plugin/plugin.json";
2383
+
2384
+ const unquoted = (raw) => (raw.length >= 2 && `"'`.includes(raw[0]) && raw.at(-1) === raw[0] ? raw.slice(1, -1) : raw);
2385
+
2386
+ /** What a persona's top-level frontmatter lines declare: `{ name, declared, problems }`, a malformed value a problem and never a default. */
2387
+ export function personaDeclarations(text) {
2388
+ const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
2389
+ const close = lines[0]?.trim() === "---" ? lines.indexOf("---", 1) : -1;
2390
+ const declared = {};
2391
+ const problems = [];
2392
+ const seen = new Set();
2393
+ let name = null;
2394
+ for (const line of close === -1 ? [] : lines.slice(1, close)) {
2395
+ const match = /^([A-Za-z_][\w-]*)\s*:(.*)$/.exec(line);
2396
+ if (match?.[1] === "name") name = unquoted(match[2].trim()).trim() || null;
2397
+ if (!match || !Object.hasOwn(PERSONA_VALUES, match[1])) continue;
2398
+ const [, key, rest] = match;
2399
+ if (seen.has(key)) {
2400
+ problems.push(`declares \`${key}\` twice`);
2401
+ continue;
2402
+ }
2403
+ seen.add(key);
2404
+ const raw = key === "omitClaudeMd" ? rest.trim() : unquoted(rest.trim());
2405
+ const value = PERSONA_VALUES[key].read(raw);
2406
+ if (value === undefined) problems.push(`sets \`${key}\` to ${JSON.stringify(rest.trim())}, and \`${key}\` takes ${PERSONA_VALUES[key].takes}`);
2407
+ else declared[key] = value;
2408
+ }
2409
+ return { name, declared, problems };
2410
+ }
2411
+
2412
+ const PERSONA_MARK = "# compiled by `portulan compile` from ";
2413
+ const PERSONA_MARK_TAIL = "; edit that file, then recompile";
2414
+ const BINDING_KEY = /^([A-Za-z_][\w-]*)\s*:(.*)$/;
2415
+ const topKey = (field) => CLAUDE_CODE_KEYS[field].split(".")[0];
2416
+
2417
+ function bindingLines(declared) {
2418
+ const lines = [];
2419
+ for (const field of PERSONA_FIELDS) {
2420
+ if (declared[field] === undefined) continue;
2421
+ const value = field === "model" ? quoted(declared[field]) : String(declared[field]);
2422
+ const [top, nested] = CLAUDE_CODE_KEYS[field].split(".");
2423
+ lines.push(...(nested ? [`${top}:`, ` ${nested}: ${value}`] : [`${top}: ${value}`]));
2424
+ }
2425
+ return lines;
2426
+ }
2427
+
2428
+ /** Read recognized compiled fields strictly; the rewritten lines must still match byte for byte. */
2429
+ function blockFields(lines, where) {
2430
+ const read = {};
2431
+ let under = null;
2432
+ for (const line of lines) {
2433
+ const nested = /^\s+([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line);
2434
+ const match = nested ? null : BINDING_KEY.exec(line);
2435
+ const key = nested ? `${under}.${nested[1]}` : match?.[1];
2436
+ const raw = (nested?.[2] ?? match?.[2] ?? "").trim();
2437
+ if (!nested) under = key;
2438
+ const field = PERSONA_FIELDS.find((f) => CLAUDE_CODE_KEYS[f] === key);
2439
+ if (!field) continue;
2440
+ let input = raw;
2441
+ if (field === "model") {
2442
+ try { input = JSON.parse(raw); } catch { /* The byte comparison rejects noncanonical quoting. */ }
2443
+ }
2444
+ const value = typeof input === "string" ? PERSONA_VALUES[field].read(input) : undefined;
2445
+ if (value === undefined) throw new CompileError(`${where}: the compiled block holds \`${line}\`, which this compiler did not write — ${PERSONA_VALUES[field].takes}`);
2446
+ read[field] = value;
2447
+ }
2448
+ return read;
2449
+ }
2450
+
2451
+ /** A Claude Code binding's text with its compiled block rewritten in place from `declared`; every other byte is the binding's own. */
2452
+ export function boundAgent(text, declared, source, where) {
2453
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
2454
+ if (eol === "\r\n" && text.replaceAll("\r\n", "").includes("\n")) {
2455
+ throw new CompileError(`${where} mixes CRLF and LF line endings, so which lines are its own cannot be told apart. Give it one ending, then compile again`);
2456
+ }
2457
+ const lines = text.split(eol);
2458
+ const close = lines[0] === "---" ? lines.indexOf("---", 1) : -1;
2459
+ if (close === -1) {
2460
+ throw new CompileError(`${where} has no frontmatter block to compile ${source}'s fields into — a host reads an agent's \`name\`, \`description\` and \`tools\` from one`);
2461
+ }
2462
+ const owned = new Set(PERSONA_FIELDS.map(topKey));
2463
+ const front = [];
2464
+ let at = null;
2465
+ for (let i = 1; i < close; i += 1) {
2466
+ if (!lines[i].startsWith(PERSONA_MARK)) {
2467
+ front.push(lines[i]);
2468
+ continue;
2469
+ }
2470
+ if (at !== null) throw new CompileError(`${where} carries two compiled blocks. Delete one, then compile again`);
2471
+ at = front.length;
2472
+ const block = [];
2473
+ while (i + 1 < close && (/^\s/.test(lines[i + 1]) || owned.has(BINDING_KEY.exec(lines[i + 1])?.[1]))) block.push(lines[(i += 1)]);
2474
+ const written = bindingLines(blockFields(block, where));
2475
+ const stray = block.find((line) => !written.includes(line)) ?? block.find((line, n) => line !== written[n]);
2476
+ if (stray !== undefined) {
2477
+ throw new CompileError(
2478
+ `${where}: the block compiled from ${source} holds \`${stray}\`, which this compiler did not write. Move the line above the ` +
2479
+ "mark or delete it, then compile again: removing it unasked would lose a line somebody wrote",
2480
+ );
2481
+ }
2482
+ }
2483
+ const writes = new Set(PERSONA_FIELDS.filter((f) => declared[f] !== undefined).map(topKey));
2484
+ for (const line of front) {
2485
+ const key = BINDING_KEY.exec(line)?.[1];
2486
+ if (writes.has(key)) {
2487
+ throw new CompileError(
2488
+ `${where} sets \`${key}\` by hand, and ${source} declares it. Remove the line from the binding: the persona's value is the one compiled, and two would leave the host to pick`,
2489
+ );
2490
+ }
2491
+ if (writes.size && owned.has(key)) {
2492
+ throw new CompileError(
2493
+ `${where} sets \`${key}\` by hand, which ${source} does not declare. Declare it in the persona or remove the line: in a binding ` +
2494
+ "it compiles into, `compile` owns every key a persona may declare",
2495
+ );
2496
+ }
2497
+ }
2498
+ const block = writes.size ? [`${PERSONA_MARK}${source}${PERSONA_MARK_TAIL}`, ...bindingLines(declared)] : [];
2499
+ const place = at ?? front.length;
2500
+ return [lines[0], ...front.slice(0, place), ...block, ...front.slice(place), ...lines.slice(close)].join(eol);
2501
+ }
2502
+
2503
+ const listed = (names) => (names.length < 2 ? names.join("") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`);
2504
+
2505
+ /** Each composed persona against its host binding: the bindings to rewrite, the lines to say, and what each host cannot carry. */
2506
+ export function planPersonas(contributions, workspaceRoot, workspaceDir = ".portulan") {
2507
+ const base = path.join(workspaceRoot, workspaceDir);
2508
+ let tree = null;
2509
+ try {
2510
+ tree = JSON.parse(fs.readFileSync(path.join(base, "workspace.json"), "utf8"))?.tree;
2511
+ } catch {
2512
+ // `run` refuses an unreadable manifest before this is reached; an absent one declares no tree.
2513
+ }
2514
+ const treeRoot = typeof tree === "string" && tree.trim() ? path.resolve(base, tree) : null;
2515
+ const files = [];
2516
+ const lines = [];
2517
+ const degraded = [];
2518
+ const owners = new Map();
2519
+ let compared = 0;
2520
+ for (const contribution of contributions) {
2521
+ for (const rel of contribution.personas ?? []) {
2522
+ const where = `\`${contribution.pack}\`'s persona \`${rel}\``;
2523
+ const file = path.resolve(contribution.dir, rel);
2524
+ let text;
2525
+ try {
2526
+ if (!isInside(fs.realpathSync(contribution.dir), fs.realpathSync(file))) throw new CompileError(`${where} resolves outside its pack`);
2527
+ text = fs.readFileSync(file, "utf8");
2528
+ } catch (cause) {
2529
+ if (cause instanceof CompileError) throw cause;
2530
+ throw new CompileError(`${where} could not be read — ${cause.code ?? cause.message}, so what it declares is unknown. Run \`doctor\` to validate the pack`);
2531
+ }
2532
+ const { name: declaredName, declared, problems } = personaDeclarations(text);
2533
+ if (problems.length) {
2534
+ throw new CompileError(`${where} ${problems.join("; it ")}. core/personas/README.md gives the fields a persona may declare`);
2535
+ }
2536
+ const fields = PERSONA_FIELDS.filter((f) => declared[f] !== undefined);
2537
+ const name = declaredName ?? path.basename(rel, ".md");
2538
+ if (fields.length && !SLUG.test(name)) {
2539
+ throw new CompileError(`${where} is named \`${name}\`, which is not a slug, so no binding file can be named for it`);
2540
+ }
2541
+ const source = `${contribution.pack}'s ${rel}`;
2542
+ for (const field of fields) {
2543
+ for (const [id, host] of Object.entries(PERSONA_HOSTS)) {
2544
+ if (host[field] === null) degraded.push({ persona: name, field, host: id, line: `degraded ${name.padEnd(22)} ${field.padEnd(14)} ${id} (${host.label}): ${host.why}` });
2545
+ }
2546
+ }
2547
+ if (!SLUG.test(name)) continue;
2548
+ const bindingRel = `${BINDING_DIR}/${name}.md`;
2549
+ if (owners.has(bindingRel)) {
2550
+ const sources = [owners.get(bindingRel), source].sort();
2551
+ throw new CompileError(`${bindingRel} has multiple persona owners: ${sources.join(" and ")}. Each binding must have one owner`);
2552
+ }
2553
+ owners.set(bindingRel, source);
2554
+ if (!treeRoot) {
2555
+ if (fields.length) lines.push(`persona ${name.padEnd(22)} UNBOUND — declares ${listed(fields)}, and this workspace declares no \`tree\` to hold its binding`);
2556
+ continue;
2557
+ }
2558
+ const binding = path.join(treeRoot, BINDING_DIR, `${name}.md`);
2559
+ const shown = path.relative(workspaceRoot, binding).split(path.sep).join("/");
2560
+ let stat = null;
2561
+ try {
2562
+ stat = fs.lstatSync(binding);
2563
+ } catch (cause) {
2564
+ if (cause.code !== "ENOENT") throw new CompileError(`${shown} could not be examined — ${cause.code ?? cause.message}`);
2565
+ }
2566
+ if (stat === null) {
2567
+ if (fields.length) lines.push(`persona ${name.padEnd(22)} UNBOUND — declares ${listed(fields)}, and no host binding at \`${bindingRel}\` carries them`);
2568
+ continue;
2569
+ }
2570
+ if (!stat.isFile()) {
2571
+ throw new CompileError(`${shown} binds ${where} and is not a regular file, so nothing is compiled through it`);
2572
+ }
2573
+ try {
2574
+ if (!isInside(fs.realpathSync(treeRoot), fs.realpathSync(binding))) {
2575
+ throw new CompileError(`${shown} resolves outside the declared tree, so nothing is compiled through it`);
2576
+ }
2577
+ } catch (cause) {
2578
+ if (cause instanceof CompileError) throw cause;
2579
+ throw new CompileError(`${shown} could not be resolved against the declared tree — ${cause.code ?? cause.message}`);
2580
+ }
2581
+ const current = fs.readFileSync(binding, "utf8");
2582
+ const next = boundAgent(current, declared, source, shown);
2583
+ if (fields.length) lines.push(`persona ${name.padEnd(22)} ${listed(fields)} → ${shown} (${PERSONA_HOSTS["claude-code"].label})`);
2584
+ if (fields.length && !fs.existsSync(path.join(treeRoot, ...PLUGIN_MANIFEST.split("/")))) {
2585
+ degraded.push({
2586
+ persona: name,
2587
+ field: null,
2588
+ host: "claude-code",
2589
+ line:
2590
+ `degraded ${name.padEnd(22)} ${listed(fields)} claude-code (Claude Code): ${shown} loads only from a plugin root, and this tree ` +
2591
+ `has no ${PLUGIN_MANIFEST}; compile writes no .claude/agents/, so nothing here loads the compiled fields`,
2592
+ });
2593
+ }
2594
+ if (fields.length || next !== current) compared += 1;
2595
+ if (next !== current) files.push({ path: shown, file: binding, text: next, source });
2596
+ }
2597
+ }
2598
+ return { files, lines, degraded, compared };
2599
+ }
2600
+
2601
+ const unresolvedLine = (u) => `pack ${u.name.padEnd(30)} UNRESOLVED — ${u.why}; it contributes nothing`;
2602
+
2603
+ function printPersonaReport(say, personas) {
2604
+ if (!personas.lines.length && !personas.degraded.length) return;
2605
+ say();
2606
+ for (const line of [...personas.lines, ...personas.degraded.map((d) => d.line)]) say(` ${line}`);
2607
+ }
2608
+
2609
+ function emitPersonas(personas, { check, say }) {
2610
+ if (!personas) return 0;
2611
+ for (const line of personas.lines) say(line);
2612
+ for (const d of personas.degraded) say(d.line);
2613
+ let drifted = 0;
2614
+ for (const f of personas.files) {
2615
+ if (check) {
2616
+ say(`RED — ${f.path} has drifted from ${f.source}. Recompile.`);
2617
+ drifted += 1;
2618
+ } else {
2619
+ fs.writeFileSync(f.file, f.text);
2620
+ say(`wrote ${f.file}`);
2621
+ }
2622
+ }
2623
+ if (personas.lines.length || personas.degraded.length || personas.files.length) say();
2624
+ return drifted;
2625
+ }
2626
+
2331
2627
  // ---------------------------------------------------------------- the command line
2332
2628
 
2333
2629
  function printMatrix(say, parsed, columns, { source }) {
@@ -2540,14 +2836,19 @@ export function run(argv, options = {}) {
2540
2836
  );
2541
2837
  }
2542
2838
  say();
2839
+ const packs = packContributions(workspaceRoot, workspaceDir, packOptions);
2840
+ for (const u of packs.unresolved) say(unresolvedLine(u));
2841
+ if (packs.unresolved.length) say();
2842
+ const personas = planPersonas(packs.contributions, workspaceRoot, workspaceDir);
2543
2843
  if (showMatrix) {
2544
2844
  printGuidanceMatrix(say, guidance);
2845
+ printPersonaReport(say, personas);
2545
2846
  return 0;
2546
2847
  }
2547
- const drifted = emitGuidance(guidance, guidancePlan, { workspaceRoot, check, say });
2848
+ const drifted = emitPersonas(personas, { check, say }) + emitGuidance(guidance, guidancePlan, { workspaceRoot, check, say });
2548
2849
  if (!check) return 0;
2549
2850
  if (drifted) return 1;
2550
- say("GREEN — every compiled guidance file matches its unit");
2851
+ say(`GREEN — every compiled guidance file matches its unit${personas.compared ? ", and every persona binding its persona" : ""}`);
2551
2852
  return 0;
2552
2853
  }
2553
2854
  const policy = readJson(policyFile, "the gate policy");
@@ -2557,6 +2858,7 @@ export function run(argv, options = {}) {
2557
2858
  discovery: () => discoverPackRoots(),
2558
2859
  forced,
2559
2860
  });
2861
+ const personas = planPersonas(contributions, workspaceRoot, workspaceDir);
2560
2862
  // Only under `--matrix`, as it moves with what is installed; but a union is never silent.
2561
2863
  if (plan && (showMatrix || plan.source === "union")) say(`packs: resolution root ${plan.source} — ${plan.why}`);
2562
2864
  const composed = composeFragments(policy, contributions);
@@ -2575,9 +2877,7 @@ export function run(argv, options = {}) {
2575
2877
  for (const t of composed.tightened) {
2576
2878
  say(`pack ${t.pack.padEnd(30)} tightens \`${t.id}\` ${t.from} → ${t.to}`);
2577
2879
  }
2578
- for (const u of unresolved) {
2579
- say(`pack ${u.name.padEnd(30)} UNRESOLVED — ${u.why}; it contributes nothing`);
2580
- }
2880
+ for (const u of unresolved) say(unresolvedLine(u));
2581
2881
  if (composed.added.length || composed.tightened.length || unresolved.length) say();
2582
2882
 
2583
2883
  if (showMatrix) {
@@ -2586,6 +2886,7 @@ export function run(argv, options = {}) {
2586
2886
  say();
2587
2887
  printGuidanceMatrix(say, guidance);
2588
2888
  }
2889
+ printPersonaReport(say, personas);
2589
2890
  return 0;
2590
2891
  }
2591
2892
 
@@ -2642,9 +2943,12 @@ export function run(argv, options = {}) {
2642
2943
  drifted += 1;
2643
2944
  }
2644
2945
  }
2946
+ drifted += emitPersonas(personas, { check, say });
2645
2947
  drifted += emitGuidance(guidance, guidancePlan, { workspaceRoot, check, say });
2646
2948
  if (drifted) return 1;
2647
- say(guidance ? "GREEN — every emitted artifact matches the policy, and every guidance file its unit" : "GREEN — every emitted artifact matches the policy");
2949
+ const bindings = personas.compared ? ", and every persona binding its persona" : "";
2950
+ const guided = bindings ? `, every guidance file its unit${bindings}` : ", and every guidance file its unit";
2951
+ say(`GREEN — every emitted artifact matches the policy${guidance ? guided : bindings}`);
2648
2952
  return 0;
2649
2953
  }
2650
2954
 
@@ -2663,6 +2967,7 @@ export function run(argv, options = {}) {
2663
2967
  fs.writeFileSync(file, column.artifact.text);
2664
2968
  say(`wrote ${file}`);
2665
2969
  }
2970
+ emitPersonas(personas, { check, say });
2666
2971
  emitGuidance(guidance, guidancePlan, { workspaceRoot, check, say });
2667
2972
  return 0;
2668
2973
  } catch (error) {
package/cli/doctor.mjs CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  // `doctor` — the Workspace Definition validator.
3
3
  //
4
- // node cli/doctor.mjs [--pack-root <dir>|auto]... [--repo-root <dir>]... <workspace-dir>...
4
+ // node cli/doctor.mjs [--static] [--pack-root <dir>|auto]... [--repo-root <dir>]... <workspace-dir>...
5
5
  //
6
6
  // Exit 0 every workspace validates · 1 at least one does not · 2 could not run.
7
7
 
8
8
  import fs from "node:fs";
9
+ import { execFileSync } from "node:child_process";
9
10
  import path from "node:path";
10
11
  import { fileURLToPath, pathToFileURL } from "node:url";
11
12
 
@@ -18,6 +19,7 @@ import {
18
19
  composeFragments,
19
20
  packDifferences,
20
21
  shadowedCopy,
22
+ personaDeclarations,
21
23
  } from "./compile.mjs";
22
24
  import { isInside, recordType } from "./index.mjs";
23
25
  import { parseFrontmatter, AGENT_DIR } from "./plugin-lint.mjs";
@@ -26,6 +28,7 @@ import { composedId } from "./recipe-set.mjs";
26
28
  import { alwaysLine } from "./context.mjs";
27
29
  import { formLine } from "./form.mjs";
28
30
  import { sessionsLine } from "./sessions.mjs";
31
+ import { hostPaths, ledger, print, readSpend, worktrees, linesChanged } from "./ledger.mjs";
29
32
 
30
33
  const HERE = path.dirname(fileURLToPath(import.meta.url));
31
34
  const DEFAULT_SCHEMA = path.resolve(HERE, "..", "spec", "workspace.schema.json");
@@ -506,6 +509,9 @@ export function validatePersona(text, { fail, pack, rel }) {
506
509
  fail("packs", `\`${pack}\`'s persona \`${rel}\` is missing ${part} — the five-part contract core/personas/README.md fixes`);
507
510
  }
508
511
  }
512
+ for (const problem of personaDeclarations(text).problems) {
513
+ fail("packs", `\`${pack}\`'s persona \`${rel}\` ${problem} — core/personas/README.md gives the fields a persona may declare, and none is defaulted`);
514
+ }
509
515
  const reach = text.split(/^##\s+Autonomy reach\b.*$/im)[1]?.split(/^##\s/m)[0];
510
516
  if (reach && claimsProhibited(reach)) {
511
517
  fail(
@@ -1499,7 +1505,7 @@ export async function inspect(workspaceDir, options = {}) {
1499
1505
  report(
1500
1506
  "bindings",
1501
1507
  `${where} has no host binding at \`${rel}\`${keyed} — reported, not failed: a persona without one is unbound rather than wrong, ` +
1502
- "and this is the one location a Claude Code host loads agents from",
1508
+ "and this is where a Claude Code plugin's agents load from — a repository that is not a plugin loads its agents from `.claude/agents/`",
1503
1509
  );
1504
1510
  } else {
1505
1511
  report("bindings", `${where}'s binding at \`${rel}\` could not be read — ${cause.code ?? cause.message}. Unread, not absent`);
@@ -1850,7 +1856,7 @@ export async function inspect(workspaceDir, options = {}) {
1850
1856
  (stats.unassessed
1851
1857
  ? `; ${stats.unassessed} unreadable and never assessed`
1852
1858
  : "") +
1853
- ". Size and count only: ages live in git, which doctor does not read, so staleness belongs to `cli/librarian.mjs` — the scheduled pass, which may ask git and does"
1859
+ ". Size and count only: memory ages are not measured here, so staleness belongs to `cli/librarian.mjs` — the scheduled pass, which may ask git and does"
1854
1860
  : "no memory records — nothing measured, nothing awaiting retirement",
1855
1861
  );
1856
1862
 
@@ -1889,6 +1895,32 @@ export async function inspect(workspaceDir, options = {}) {
1889
1895
  return { dir, workspace, findings, stats };
1890
1896
  }
1891
1897
 
1898
+ /** Optional usage accounting: a data failure never changes the workspace-validation verdict. */
1899
+ export async function workspaceUsageReport(workspaceDir, workspace, options = {}) {
1900
+ const unmeasured = (why) => [` note spend unmeasured — ${why}`];
1901
+ if (typeof workspace?.tree !== "string" || workspace.tree.trim() === "") return unmeasured("the workspace declares no tree to attribute usage to");
1902
+ try {
1903
+ const repo = path.resolve(workspaceDir, workspace.tree);
1904
+ const roots = worktrees(repo);
1905
+ if (roots === null || roots.length === 0) return unmeasured("the declared tree has no Git worktree ownership");
1906
+ const branch = execFileSync("git", ["-C", repo, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10_000 }).trim();
1907
+ if (!branch || branch === "HEAD") return unmeasured("the declared tree has no current named Git branch");
1908
+ const host = hostPaths(options.env ?? process.env, options.home);
1909
+ if (host.why !== undefined) return unmeasured("the configured usage-record location is ambiguous");
1910
+ const report = ledger({ ...host, roots, branch, lines: linesChanged(repo, branch), spend: readSpend(workspace.spend, "this workspace") });
1911
+ const lines = [];
1912
+ if (report.collected.malformed) lines.push(` note spend partial data — ${report.collected.malformed} malformed usage line(s) were ignored`);
1913
+ if (report.figures.main.requests + report.figures.subagents.requests === 0 && report.local.receipts === 0) {
1914
+ return [...lines, ...unmeasured("no host requests or local receipts on this branch are recorded here")];
1915
+ }
1916
+ print(report, (line) => lines.push(line));
1917
+ return lines;
1918
+ } catch {
1919
+ // Parser messages can quote a private record. Never print them or a raw usage line.
1920
+ return unmeasured("optional local usage data could not be read or parsed; no totals were reported");
1921
+ }
1922
+ }
1923
+
1892
1924
  // ---------------------------------------------------------------- the command
1893
1925
 
1894
1926
  const ICON = { fail: "FAIL ", report: "note " };
@@ -1903,8 +1935,9 @@ function usage() {
1903
1935
  return [
1904
1936
  "portulan doctor — validate a workspace against the Workspace Definition",
1905
1937
  "",
1906
- " portulan doctor [--pack-root <dir>|auto]... [--repo-root <dir>]... <workspace-dir> [<workspace-dir> ...]",
1938
+ " portulan doctor [--static] [--pack-root <dir>|auto]... [--repo-root <dir>]... <workspace-dir> [<workspace-dir> ...]",
1907
1939
  "",
1940
+ " --static skip local usage records; validation and plugin discovery remain unchanged.",
1908
1941
  " --pack-root where declared packs are resolved from; `auto` discovers the host's plugin cache.",
1909
1942
  " A named root REPLACES every other source. A directory actually named `auto` is `./auto`",
1910
1943
  " --repo-root where the repositories this workspace's cards NAME are checked out, so their",
@@ -1927,6 +1960,7 @@ export async function run(argv, options = {}) {
1927
1960
  return 0;
1928
1961
  }
1929
1962
  try {
1963
+ let staticMode = false;
1930
1964
  const namedRoots = [];
1931
1965
  let discoverPacks = false;
1932
1966
  const repoRoots = [];
@@ -1953,7 +1987,9 @@ export async function run(argv, options = {}) {
1953
1987
  };
1954
1988
  const REPO_ROOT = { unresolvable: "a repository ungoverned", holds: "repositories" };
1955
1989
  for (let i = 0; i < argv.length; i += 1) {
1956
- if (argv[i] === "--repo-root") {
1990
+ if (argv[i] === "--static") {
1991
+ staticMode = true;
1992
+ } else if (argv[i] === "--repo-root") {
1957
1993
  repoRoots.push(directoryRoot("--repo-root", argv[i + 1], REPO_ROOT));
1958
1994
  i += 1;
1959
1995
  } else if (argv[i] === "--pack-root") {
@@ -1971,7 +2007,7 @@ export async function run(argv, options = {}) {
1971
2007
  if (bothAsked) throw new DoctorError(bothAsked);
1972
2008
  if (dirs.length === 0) {
1973
2009
  if (!options.quiet) {
1974
- process.stderr.write("usage: node cli/doctor.mjs [--pack-root <dir>|auto]... [--repo-root <dir>]... <workspace-dir> [<workspace-dir> ...]\n");
2010
+ process.stderr.write("usage: node cli/doctor.mjs [--static] [--pack-root <dir>|auto]... [--repo-root <dir>]... <workspace-dir> [<workspace-dir> ...]\n");
1975
2011
  }
1976
2012
  return 2;
1977
2013
  }
@@ -1983,7 +2019,7 @@ export async function run(argv, options = {}) {
1983
2019
  ...(discoverPacks ? { discoverPacks } : {}),
1984
2020
  ...(repoRoots.length ? { repoRoots } : {}),
1985
2021
  };
1986
- const { findings, stats } = await inspect(dir, { ...options, ...roots });
2022
+ const { findings, stats, workspace } = await inspect(dir, { ...options, ...roots });
1987
2023
  const bad = findings.filter((f) => f.severity === "fail");
1988
2024
  say(display(dir));
1989
2025
  for (const f of findings) say(` ${ICON[f.severity]} ${f.check.padEnd(10)} ${f.message}`);
@@ -1992,6 +2028,9 @@ export async function run(argv, options = {}) {
1992
2028
  `${findings.length - bad.length} note(s), ${stats.claims} claim(s) checked` +
1993
2029
  (stats.unverifiable ? `, ${stats.unverifiable} unverifiable` : ""),
1994
2030
  );
2031
+ if (!staticMode && !options.quiet && !bad.length && workspace) {
2032
+ for (const line of await workspaceUsageReport(path.resolve(dir), workspace, options)) say(line);
2033
+ }
1995
2034
  say();
1996
2035
  if (bad.length) failed += 1;
1997
2036
  }