@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/compile.mjs CHANGED
@@ -6,24 +6,25 @@
6
6
  //
7
7
  // Exit 0 wrote (or, with --check, agrees) · 1 an artifact has drifted · 2 could not run.
8
8
 
9
+ import { readContextCap, contextCaps, contextCapLines, capOnlyPlan, emitCapOnly, CONTEXT_CAP_ENV } from "./ritual-contexts.mjs";
9
10
  import fs from "node:fs";
10
11
  import path from "node:path";
11
12
  import { fileURLToPath, pathToFileURL } from "node:url";
12
13
  import { AUTO, discoverPackRoots, namedWithAuto, resolutionRoots } from "./discover.mjs";
13
14
  import { isInside } from "./inside.mjs";
15
+ import { importTargetIsFile } from "./import-files.mjs";
16
+ export { importTargetIsFile } from "./import-files.mjs";
17
+ import { parseFrontmatter } from "./frontmatter.mjs";
14
18
  import { CannotOutline, sectionOf } from "./symbols.mjs";
15
19
  import { HORIZON, LedgerError, readSpend } from "./ledger.mjs";
20
+ import { readStopPolicy, STOP_HOOK_MARGIN } from "./stop-policy.mjs";
21
+ import { assertSourceJournalReady } from "./instructions-journal.mjs";
16
22
 
17
- /** Raised when `compile` cannot run, or cannot compile honestly. Always exit 2, never 1. */
18
- export class CompileError extends Error {
19
- constructor(message) {
20
- super(message);
21
- this.name = "CompileError";
22
- }
23
- }
23
+ import { CompileError, readOwnedBytes, readOwnedText, sameContent, nestedTarget, nestedMark, validateNestedTargets, workspaceProtection, nestedPlan, applyNestedEdits, preflightNestedEdits, NESTED_REGISTRY, readNestedState, ownedNestedFiles } from "./guidance-files.mjs";
24
+ export { CompileError, readOwnedBytes, readOwnedText, NESTED_REGISTRY, readNestedState, ownedNestedFiles };
24
25
 
25
26
  /** 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"]);
27
+ export const KNOWN_GATE_POLICY_SPECS = new Set(["2.1", "2.2", "2.3"]);
27
28
 
28
29
  const TIERS = new Set(["auto", "propose", "gated", "prohibited"]);
29
30
 
@@ -131,11 +132,11 @@ export function parse(policy) {
131
132
  rules.push({ id, tier: rule.tier, kind, target: action[kind], reason: rule.reason, action });
132
133
  }
133
134
 
134
- return { rules, floor: parseFloor(policy.floor) };
135
+ return { rules, floor: parseFloor(policy.floor, String(spec)) };
135
136
  }
136
137
 
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) {
138
+ /** The optional platform floor, with strict freshness unless the policy explicitly chooses otherwise. */
139
+ function parseFloor(floor, spec) {
139
140
  if (floor === undefined || floor === null) return null;
140
141
  if (typeof floor !== "object" || Array.isArray(floor)) {
141
142
  throw new CompileError("`floor` is present but is not a JSON object");
@@ -180,7 +181,11 @@ function parseFloor(floor) {
180
181
  if (typeof floor.resolve_conversations !== "boolean") {
181
182
  throw new CompileError("`floor.resolve_conversations` must be a boolean — omitting it would export a floor weaker than the one in force");
182
183
  }
183
- return { branch, checks, reviews: floor.reviews, resolve_conversations: floor.resolve_conversations };
184
+ if (Object.hasOwn(floor, "strict")) {
185
+ if (spec !== "2.3") throw new CompileError("`floor.strict` requires gate-policy 2.3");
186
+ if (typeof floor.strict !== "boolean") throw new CompileError("`floor.strict` must be a boolean");
187
+ }
188
+ return { branch, checks, reviews: floor.reviews, resolve_conversations: floor.resolve_conversations, strict: floor.strict ?? true };
184
189
  }
185
190
 
186
191
  // ---------------------------------------------------------------- what an action means, to the compiler and the gate alike
@@ -532,6 +537,8 @@ export const HOOK_RUNNERS = ["gate.mjs", "stop-gate.mjs", "advisory.mjs"];
532
537
  * `ask` for gated, a prompt that blocks headless. Two layers, since on Claude Code 2.1.220 a crashing hook fails open.
533
538
  */
534
539
  export function claudeCode(parsed, options = {}) {
540
+ const hasPolicy = parsed !== null;
541
+ const rules = hasPolicy ? parsed.rules : [];
535
542
  const source = options.source ?? ".portulan/gates.json";
536
543
  const runnerDir = path.dirname(fileURLToPath(import.meta.url));
537
544
  const project = options.root ?? process.cwd();
@@ -545,8 +552,8 @@ export function claudeCode(parsed, options = {}) {
545
552
  pinned.push({ file, abs });
546
553
  return `"${abs}"`;
547
554
  };
548
- const runner = options.runner ?? spell(HOOK_RUNNERS[0]);
549
- const stopRunner = options.stopRunner ?? spell(HOOK_RUNNERS[1]);
555
+ const runner = options.runner ?? (hasPolicy ? spell(HOOK_RUNNERS[0]) : null);
556
+ const stopRunner = options.stopRunner ?? (hasPolicy ? spell(HOOK_RUNNERS[1]) : null);
550
557
  const advisoryRunner = options.advisoryRunner ?? spell(HOOK_RUNNERS[2]);
551
558
 
552
559
  const compiled = [];
@@ -557,7 +564,7 @@ export function claudeCode(parsed, options = {}) {
557
564
  const shellWriteGates = [];
558
565
  const editCoveredGates = [];
559
566
 
560
- for (const rule of parsed.rules) {
567
+ for (const rule of rules) {
561
568
  if (!HOST_GATE_TIERS.has(rule.tier)) {
562
569
  refused.push({ id: rule.id, tier: rule.tier, why: HOST_TIER_NOT_A_GATE[rule.tier] });
563
570
  continue;
@@ -605,16 +612,17 @@ export function claudeCode(parsed, options = {}) {
605
612
  compiled.push({ id: rule.id, tier: rule.tier, surface: [...emitted, ...hookOnly].join(" · ") });
606
613
  }
607
614
 
608
- if (parsed.rules.some((r) => HOST_GATE_TIERS.has(r.tier)) && compiled.length === 0) {
615
+ if (rules.some((r) => HOST_GATE_TIERS.has(r.tier)) && compiled.length === 0) {
609
616
  throw new CompileError(
610
617
  "every gate in this policy refused to compile — refusing to write an artifact that enforces nothing while the policy claims gates",
611
618
  );
612
619
  }
613
620
 
614
- // Each pack's origin and version, never its root: an absolute path would make the tracked artifact differ per machine.
621
+ // Portable provenance excludes absolute roots.
615
622
  const packs = (options.packProvenance ?? [])
616
623
  .map((c) => ({ pack: c.pack, origin: c.origin, version: c.version ?? null }))
617
624
  .sort((a, b) => a.pack.localeCompare(b.pack));
625
+ const contexts = options.contexts ?? contextCaps([]);
618
626
  const sessions = options.sessions ?? null;
619
627
  const switches = {};
620
628
  if (sessions?.git_instructions !== undefined) switches.includeGitInstructions = sessions.git_instructions;
@@ -624,48 +632,61 @@ export function claudeCode(parsed, options = {}) {
624
632
  const figures = [];
625
633
  if (spend?.multipliers) figures.push("--read", spend.multipliers.read, "--write-5m", spend.multipliers.write["5m"], "--write-1h", spend.multipliers.write["1h"]);
626
634
  if (spend?.horizon) figures.push("--horizon", spend.horizon);
635
+ if (spend?.resume != null) figures.push("--resume-tokens", spend.resume);
627
636
  const spendFlags = figures.map((f) => ` ${f}`).join("");
628
637
  const restartBlock = spend?.restart === "block";
629
638
  const spendFrom = figures.length || restartBlock ? spend.manifest : null;
630
- const declaredIn =
639
+ const figuresIn =
631
640
  sessionsFrom && sessionsFrom === spendFrom
632
641
  ? `\`sessions\` and \`spend\` in ${sessionsFrom}`
633
642
  : [sessionsFrom && `\`sessions\` in ${sessionsFrom}`, spendFrom && `\`spend\` in ${spendFrom}`].filter(Boolean).join(", or ");
643
+ const stopPolicy = hasPolicy ? options.stopPolicy ?? null : null;
644
+ if (stopPolicy !== null) {
645
+ try {
646
+ const seconds = typeof stopPolicy === "object" && !Array.isArray(stopPolicy) ? stopPolicy.seconds : null;
647
+ readStopPolicy({ portulan: { spec: "2.14" }, verify: { stopTimeout: seconds ?? null } });
648
+ } catch (error) { throw new CompileError(error.message); }
649
+ }
650
+ const declaredIn = [figuresIn, contexts.limit !== null && "ritual contexts in composed pack manifests", stopPolicy && `\`verify.stopTimeout\` in ${stopPolicy.manifest}`].filter(Boolean).join(", or ");
634
651
  const value = {
635
652
  $portulan: {
636
653
  generated: "cli/compile.mjs",
637
654
  source,
638
655
  ...(sessionsFrom ? { sessions: sessionsFrom } : {}),
639
656
  ...(spendFrom ? { spend: spendFrom } : {}),
657
+ ...(stopPolicy ? { verify: stopPolicy.manifest } : {}),
640
658
  ...(packs.length ? { packs } : {}),
659
+ ...(contexts.limit !== null ? { contexts: contexts.sources } : {}),
641
660
  warning: declaredIn
642
661
  ? `Generated file. Edit ${source}, or ${declaredIn}, and recompile; \`verify/compile.sh\` fails on drift.`
643
662
  : `Generated file. Edit ${source} and recompile; \`verify/compile.sh\` fails on drift.`,
644
663
  },
645
- permissions: { deny, ask, allow: [] },
664
+ ...(hasPolicy ? { permissions: { deny, ask, allow: [] } } : {}),
646
665
  hooks: {
647
- // Each command is `node` and a quoted path: no pipe, redirection or separator, where a quoting fail-open would live.
648
- PreToolUse: [...matchers].sort().map((matcher) => ({
649
- matcher,
650
- hooks: [{ type: "command", command: `node ${runner}` }],
651
- })),
652
- Stop: [
653
- {
654
- hooks: [
655
- { type: "command", command: `node ${stopRunner}` },
656
- ...(restartBlock ? [{ type: "command", command: `node ${advisoryRunner} stop${spendFlags}` }] : []),
657
- ],
658
- },
659
- ],
666
+ ...(hasPolicy ? {
667
+ PreToolUse: [...matchers].sort().map((matcher) => ({
668
+ matcher,
669
+ hooks: [{ type: "command", command: `node ${runner}` }],
670
+ })),
671
+ } : {}),
672
+ ...(hasPolicy || restartBlock ? { Stop: [{ hooks: [
673
+ ...(hasPolicy ? [{ type: "command", command: `node ${stopRunner}`, ...(stopPolicy ? { timeout: stopPolicy.seconds + STOP_HOOK_MARGIN } : {}) }] : []),
674
+ ...(restartBlock ? [{ type: "command", command: `node ${advisoryRunner} stop${spendFlags}` }] : []),
675
+ ] }] } : {}),
660
676
  // No matcher means every tool; a non-blocking `Stop` hook's output would reach only the host's debug log.
661
677
  PostToolUse: [{ hooks: [{ type: "command", command: `node ${advisoryRunner} tool${spendFlags}` }] }],
662
678
  UserPromptSubmit: [{ hooks: [{ type: "command", command: `node ${advisoryRunner} prompt${spendFlags}` }] }],
663
679
  },
664
680
  statusLine: { type: "command", command: `node ${advisoryRunner} status${spendFlags}` },
665
681
  ...switches,
682
+ ...(contexts.limit !== null ? { env: { [CONTEXT_CAP_ENV]: String(contexts.limit) } } : {}),
666
683
  };
667
684
 
668
- const notes = [];
685
+ const notes = contextCapLines(contexts);
686
+ if (!hasPolicy) notes.push(
687
+ "Claude Code: prompt/tool restart advisory and status line compile without a gate policy; declared session switches and ritual caps compose here. GitHub repository ruleset and AGENTS guidance: advisory/session settings not compiled by these adapters.",
688
+ `Undeclared prices use general read 0.1× and writes 1.25×/2×; horizon ${spend?.horizon ? `${spend.horizon} declared` : `${HORIZON} general`}; resume expense ${spend?.resume != null ? `${spend.resume} declared` : "unknown"}. Unknown resume expense or a general horizon permits conditional advice, never a Stop hold.`,
689
+ );
669
690
  if (pinned.length) {
670
691
  notes.push(
671
692
  `${pinned.length} hook(s) are pinned to an ABSOLUTE path on this machine — ` +
@@ -715,11 +736,12 @@ export function claudeCode(parsed, options = {}) {
715
736
  `commands carry them, so an edit to \`spend\` is drift until recompiled`,
716
737
  );
717
738
  }
739
+ if (spend?.resume != null) notes.push(`restart resume expense: ${spend.resume} input-equivalent tokens, excluding the fresh-context write and baseline reads`);
718
740
  if (restartBlock) {
719
741
  notes.push(
720
- `the restart advisory also holds a turn's end (\`spend.restart\` "block"): at the first stop at or past the restart ` +
721
- `threshold that no block provoked, once in a session and again after each compaction, with the line as the reason, beside the Stop-gate. Nothing ends the ` +
722
- `session, and the line still comes with a tool result or at the prompt`,
742
+ `the restart advisory's Stop command (\`spend.restart\` "block") holds once only with an explicit horizon, an explicit resume expense ` +
743
+ `and finite positive estimated savings; equality never holds. Without both estimates it reports conditionally without holding. ` +
744
+ `Retry protection and once per compaction still apply; nothing ends the session`,
723
745
  );
724
746
  }
725
747
  if (editCoveredGates.length) {
@@ -811,8 +833,7 @@ export function githubRuleset(parsed, options = {}) {
811
833
  rules.push({
812
834
  type: "required_status_checks",
813
835
  parameters: {
814
- // Not declarable: a pull request may never merge from behind its base.
815
- strict_required_status_checks_policy: true,
836
+ strict_required_status_checks_policy: floor.strict,
816
837
  do_not_enforce_on_create: false,
817
838
  required_status_checks: floor.checks,
818
839
  },
@@ -988,7 +1009,7 @@ export const GUIDANCE_HOSTS = {
988
1009
  "claude-code": {
989
1010
  label: "Claude Code",
990
1011
  always: `an unscoped rule in \`${GUIDANCE_RULES_DIR}/\``,
991
- "on-path": "a rule scoped by `paths:`",
1012
+ "on-path": "a paths-scoped rule or a declared nested CLAUDE.md",
992
1013
  "on-invoke": `a project skill in \`${SKILLS_DIR}/\``,
993
1014
  "on-read": `a line in \`${GUIDANCE_RULES_DIR}/${ON_READ_INDEX}\``,
994
1015
  },
@@ -1029,11 +1050,148 @@ const STRAY_IMPORT = {
1029
1050
  "on-read": "an on-read file is opened as it stands, so the import would load nothing",
1030
1051
  };
1031
1052
 
1032
- /**
1033
- * The `@` imports Claude Code 2.1.281 finds: none in a fence, nor in a code span or HTML comment outside a list
1034
- * item's text, which it reads raw. Misread: a list in a block quote, and a line its lexer keeps out of an item.
1035
- */
1053
+ function importCandidate(text) {
1054
+ return [...text.matchAll(/@((?:[^\s\\]|\\ )+)/g)].some(match =>
1055
+ !/[\p{L}\p{N}.+-]/u.test(text[match.index - 1] ?? " ") && importPath(match[1]) !== null);
1056
+ }
1057
+
1058
+ function importSyntax(text) {
1059
+ const rows = text.split("\n");
1060
+ const starts = [];
1061
+ let offset = 0;
1062
+ for (const row of rows) { starts.push(offset); offset += row.length + 1; }
1063
+ const width = (prefix) => [...prefix].reduce((col, char) => col + (char === "\t" ? 4 - col % 4 : 1), 0);
1064
+ const refuse = (name, region, i) => {
1065
+ if (importCandidate(region)) throw new CompileError(`unsupported import syntax: ${name} at line ${i + 1}; put the import alone in a supported paragraph`);
1066
+ };
1067
+ let fence = null;
1068
+ let comment = false;
1069
+ let quotedList = null;
1070
+ let list = null;
1071
+ let flowing = false;
1072
+ for (let i = 0; i < rows.length; i++) {
1073
+ let line = rows[i].replace(/\r$/, "");
1074
+ let shift = 0;
1075
+ if (fence) {
1076
+ if (new RegExp(`^ {0,3}${fence[0]}{${fence.length},}[ \\t]*$`).test(line)) fence = null;
1077
+ continue;
1078
+ }
1079
+ if (comment) {
1080
+ const end = line.indexOf("-->");
1081
+ if (end === -1) continue;
1082
+ comment = false;
1083
+ shift = end + 3;
1084
+ line = line.slice(shift);
1085
+ }
1086
+ const trim = line.trimStart();
1087
+ const indent = width(/^[ \t]*/.exec(line)[0]);
1088
+ const regionThrough = (stop) => [line, ...rows.slice(i + 1, stop)].join("\n");
1089
+ const opening = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
1090
+ if (opening && !(opening[1][0] === "`" && opening[2].includes("`"))) {
1091
+ fence = opening[1];
1092
+ quotedList = null;
1093
+ flowing = false;
1094
+ continue;
1095
+ }
1096
+ let quoted = line;
1097
+ let outerItem;
1098
+ while ((outerItem = /^ {0,3}(?:[-+*]|\d{1,9}[.)])(?:[ \t]+|$)/.exec(quoted))) quoted = quoted.slice(outerItem[0].length);
1099
+ let depth = 0;
1100
+ let prefix;
1101
+ while ((prefix = /^ {0,3}>[ \t]?/.exec(quoted))) { depth++; quoted = quoted.slice(prefix[0].length); }
1102
+ const quotedItem = /^ {0,3}(?:[-+*]|\d{1,9}[.)])(?:[ \t]+|$)/.exec(quoted);
1103
+ if (depth && quotedItem) {
1104
+ quotedList = { depth, indent: width(quotedItem[0]), flowing: !opensBlock(quoted.slice(quotedItem[0].length)) };
1105
+ refuse("blockquoted list", quoted, i);
1106
+ } else if (quotedList) {
1107
+ const contained = depth > quotedList.depth || (depth === quotedList.depth && width(/^[ \t]*/.exec(quoted)[0]) >= quotedList.indent);
1108
+ if (quoted.trim() === "") {
1109
+ if (depth < quotedList.depth) quotedList = null;
1110
+ else quotedList.flowing = false;
1111
+ }
1112
+ else if (contained || (quotedList.flowing && !opensBlock(quoted))) {
1113
+ refuse("blockquoted list", quoted, i);
1114
+ quotedList.flowing = !opensBlock(quoted.trimStart());
1115
+ } else quotedList = null;
1116
+ }
1117
+ const item = /^([ \t]*)([-+*]|\d{1,9}[.)])([ \t]+|$)/.exec(line);
1118
+ if (item && (list || indent <= 3)) {
1119
+ if (list && indent >= list.indent) list.reason = "list after nested list";
1120
+ else list = { indent: width(item[0]), reason: null };
1121
+ flowing = true;
1122
+ } else if (line.trim() === "") {
1123
+ flowing = false;
1124
+ } else if (list && (indent >= list.indent || (flowing && !opensBlock(line)))) {
1125
+ flowing = true;
1126
+ } else {
1127
+ list = null;
1128
+ flowing = false;
1129
+ }
1130
+ if (list && flowing) {
1131
+ const content = item ? line.slice(item[0].length) : trim;
1132
+ if (OPENS_HTML_BLOCK.test(content) || /^<(?:\?|!\[CDATA\[|![A-Z])/.test(content)) list.reason = "list after HTML block";
1133
+ else if ((item || indent >= list.indent) && /^<\/?[A-Za-z]/.test(content)) list.reason = "list after tag";
1134
+ else if (!item && indent >= list.indent + 4) list.reason = "list after indented code";
1135
+ if (list.reason && !item) refuse(list.reason, line, i);
1136
+ continue;
1137
+ }
1138
+ const deepFence = /^(?: {4,}|[ \t]*\t[ \t]*)(`{3,}|~{3,})/.exec(line);
1139
+ if (deepFence) {
1140
+ let end = i + 1;
1141
+ while (end < rows.length && !new RegExp(`^[ \\t]*${deepFence[1][0]}{${deepFence[1].length},}[ \\t]*$`).test(rows[end])) end++;
1142
+ refuse("indented fence", regionThrough(Math.min(end + 1, rows.length)), i);
1143
+ i = Math.min(end, rows.length - 1);
1144
+ continue;
1145
+ }
1146
+ if (indent >= 4) { refuse("indented code", line, i); continue; }
1147
+ let htmlEnd = null;
1148
+ const raw = /^<(pre|script|style|textarea)(?=[ \t>]|$)/i.exec(trim);
1149
+ if (raw) htmlEnd = new RegExp(`</${raw[1]}[ \\t]*>`, "i");
1150
+ else if (/^<\?/.test(trim)) htmlEnd = /\?>/;
1151
+ else if (/^<!\[CDATA\[/.test(trim)) htmlEnd = /\]\]>/;
1152
+ else if (/^<![A-Z]/.test(trim)) htmlEnd = />/;
1153
+ const html = htmlEnd || OPENS_HTML_BLOCK.test(trim) ||
1154
+ ((i === 0 || rows[i - 1].trim() === "") && /^<\/?[A-Za-z][^<>]*>[ \t]*$/.test(trim));
1155
+ if (html) {
1156
+ let end = i;
1157
+ while (end < rows.length && (htmlEnd ? !htmlEnd.test(end === i ? line : rows[end]) : (end === i ? line : rows[end]).trim() !== "")) end++;
1158
+ const stop = htmlEnd ? Math.min(end + 1, rows.length) : end;
1159
+ refuse("HTML block", regionThrough(stop), i);
1160
+ i = Math.max(i, stop - 1);
1161
+ continue;
1162
+ }
1163
+ let masked = line;
1164
+ const ticks = [...line.matchAll(/`+/g)];
1165
+ for (let k = 0; k < ticks.length; k++) {
1166
+ const tick = ticks[k];
1167
+ const absolute = starts[i] + shift + tick.index;
1168
+ const rest = text.slice(absolute + tick[0].length).split(/\n[ \t]*\n/, 1)[0];
1169
+ const close = [...rest.matchAll(/`+/g)].find(next => next[0] === tick[0]);
1170
+ if (!close) continue;
1171
+ const region = rest.slice(0, close.index);
1172
+ if (region.includes("\n")) refuse("multiline code span", region, i);
1173
+ else {
1174
+ if (line.includes("<!--")) refuse("comment-opening line", region, i);
1175
+ const end = tick.index + tick[0].length * 2 + close.index;
1176
+ masked = masked.slice(0, tick.index) + " ".repeat(end - tick.index) + masked.slice(end);
1177
+ while (k + 1 < ticks.length && ticks[k + 1].index < end) k++;
1178
+ }
1179
+ }
1180
+ masked = masked.replace(/<!--.*?(?:-->|$)/g, match => " ".repeat(match.length));
1181
+ for (const [name, pattern] of [
1182
+ ["emphasis adjacency", /[*_]@((?:[^\s\\]|\\ )+)/g],
1183
+ ["escape adjacency", /\\[!-/:-@[-`{-~]@((?:[^\s\\]|\\ )+)/g],
1184
+ ["tag adjacency", />@((?:[^\s\\]|\\ )+)/g],
1185
+ ]) for (const match of masked.matchAll(pattern)) if (importPath(match[1]) !== null) {
1186
+ throw new CompileError(`unsupported import syntax: ${name} at line ${i + 1}; put the import alone in a supported paragraph`);
1187
+ }
1188
+ if (line.includes("<!--") && !line.slice(line.indexOf("<!--") + 4).includes("-->")) comment = true;
1189
+ }
1190
+ }
1191
+
1192
+ /** Proven import forms; ambiguous Markdown regions fail closed before offsets are read. */
1036
1193
  export function importSpans(text) {
1194
+ importSyntax(text);
1037
1195
  const found = [];
1038
1196
  let fence = null;
1039
1197
  let comment = false;
@@ -1060,7 +1218,7 @@ export function importSpans(text) {
1060
1218
  } else if (item && (listed || /^ {0,3}\S/.test(line))) {
1061
1219
  listed = flowing = true;
1062
1220
  raw = !opensInItem.test(line.slice(item[0].length));
1063
- } else if (listed && (/^\s/.test(line) || (flowing && !OPENS_BLOCK.test(line)))) {
1221
+ } else if (listed && (/^\s/.test(line) || (flowing && !opensBlock(line)))) {
1064
1222
  flowing = true;
1065
1223
  raw = !opensInItem.test(line.trimStart());
1066
1224
  } else {
@@ -1158,8 +1316,13 @@ function checkedImports(text, dir, root, where) {
1158
1316
  return own;
1159
1317
  }
1160
1318
 
1161
- /** An unindented line that ends a list, as CommonMark reads it; a block tag such as `<div>` is read as a lazy line. */
1162
1319
  const OPENS_BLOCK = /^(?:#{1,6}(?:[ \t]|$)|>|([-*_])(?:[ \t]*\1){2,}[ \t]*$|(?:[-+*]|\d{1,9}[.)])(?:[ \t]|$)|<(?:!--|\?|![A-Za-z]|!\[CDATA\[))/;
1320
+ const OPENS_HTML_BLOCK = /^<(?:(?:pre|script|style|textarea)(?=[ \t>]|$)|\/?(?:address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)(?=[ \t>]|\/>|$))/i;
1321
+
1322
+ /** An unindented line that ends a list, as CommonMark reads it. */
1323
+ function opensBlock(line) {
1324
+ return OPENS_BLOCK.test(line) || OPENS_HTML_BLOCK.test(line);
1325
+ }
1163
1326
 
1164
1327
  /** Each item's first sentence, byte for byte, from the first list in `file` or under its `#fragment` heading. */
1165
1328
  function leadsOf(file, where, fragment = null, name = path.basename(file)) {
@@ -1197,7 +1360,7 @@ export function leadsOfText(source, name, where) {
1197
1360
  } else if (kind !== null && /^\s+\S/.test(line)) {
1198
1361
  items.at(-1).text += ` ${line.trim()}`;
1199
1362
  } else if (kind !== null && line.trim() !== "") {
1200
- if (under && !OPENS_BLOCK.test(line)) {
1363
+ if (under && !opensBlock(line)) {
1201
1364
  throw new CompileError(
1202
1365
  `${where}: line ${at + 1} of ${name} follows an item of its first list with no blank line and no indent, ` +
1203
1366
  `where CommonMark reads a line of text as more of that item — indent it under the item, or end the list with a blank line`,
@@ -1249,7 +1412,7 @@ function gatesOf(file, where, packs = []) {
1249
1412
  }
1250
1413
 
1251
1414
  /** The unit's body with each leads, engine and gates line written out; a leads path may end `#<heading>`. */
1252
- function expandedBody(unit, dir, root, packs = []) {
1415
+ export function expandedBody(unit, dir, root, packs = [], { originRoot = root } = {}) {
1253
1416
  unit.leadSources = [];
1254
1417
  unit.written = new Set();
1255
1418
  return unit.body
@@ -1259,11 +1422,13 @@ function expandedBody(unit, dir, root, packs = []) {
1259
1422
  const engine = leads ? null : ENGINE_LINE.exec(line);
1260
1423
  const gates = leads || engine ? null : GATES_LINE.exec(line);
1261
1424
  if (!leads && !engine && !gates) return [line];
1262
- if (engine) return engineLeads(unit, engine[1], root);
1425
+ if (engine) return engineLeads(unit, engine[1], originRoot);
1263
1426
  const [what, spelled] = leads ? ["leads", leads[1]] : ["gates", gates[1]];
1264
1427
  const hash = leads ? spelled.indexOf("#") : -1;
1265
1428
  const [named, fragment] = hash === -1 ? [spelled, null] : [spelled.slice(0, hash), spelled.slice(hash + 1)];
1266
- const file = path.resolve(dir, named);
1429
+ const file = path.isAbsolute(named) && originRoot !== root && isInside(originRoot, named)
1430
+ ? path.resolve(root, path.relative(originRoot, named))
1431
+ : path.resolve(dir, named);
1267
1432
  if (!isInside(root, file)) throw new CompileError(`${unit.source}: the ${what} of ${named} were asked for, and it lies outside the tree compiled here`);
1268
1433
  let real;
1269
1434
  try {
@@ -1304,7 +1469,7 @@ function engineLeads(unit, spelled, root) {
1304
1469
  return leadsOf(real, unit.source, fragment, shown).map((lead) => (own ? lead.replaceAll(PLUGIN_ROOT, "") : lead));
1305
1470
  }
1306
1471
 
1307
- const UNIT_KEYS = new Set(["tier", "paths", "description"]);
1472
+ const UNIT_KEYS = new Set(["tier", "paths", "description", "target"]);
1308
1473
 
1309
1474
  /** A line break, by any spelling a host might honour, or another control character. */
1310
1475
  const CONTROL = /[\u0000-\u001f\u007f\u0085\u2028\u2029]/;
@@ -1337,7 +1502,7 @@ function unitFrontmatter(lines, where) {
1337
1502
  if (!match) throw new CompileError(`${where}: frontmatter line ${i + 2} is not \`key: value\` — ${JSON.stringify(line)}`);
1338
1503
  const [, key, rest = ""] = match;
1339
1504
  if (!UNIT_KEYS.has(key)) {
1340
- throw new CompileError(`${where}: \`${key}\` is not a key a unit takes — only \`tier\`, \`paths\` and \`description\` (spec/slots.md)`);
1505
+ throw new CompileError(`${where}: \`${key}\` is not a key a unit takes — only \`tier\`, \`paths\`, \`target\` and \`description\` (spec/slots.md)`);
1341
1506
  }
1342
1507
  if (Object.hasOwn(fields, key)) throw new CompileError(`${where}: \`${key}\` is declared twice`);
1343
1508
  if (key !== "paths") {
@@ -1386,8 +1551,14 @@ export function parseUnit(name, text, source = `${name}.md`) {
1386
1551
  if (!LOAD_TIERS.includes(tier)) {
1387
1552
  throw new CompileError(`${where}: \`tier\` is ${tier === undefined ? "missing" : JSON.stringify(tier)}, and it must be one of ${LOAD_TIERS.map((t) => `\`${t}\``).join(", ")}`);
1388
1553
  }
1389
- let paths = null;
1390
- if (tier === "on-path") {
1554
+ let paths = null, target = null;
1555
+ if (fields.target !== undefined) {
1556
+ if (tier !== "on-path") throw new CompileError(`${where}: target is valid only on-path`);
1557
+ if (fields.paths !== undefined) throw new CompileError(`${where}: select one of paths or target, never both`);
1558
+ target = nestedTarget(fields.target,where);
1559
+ paths = [`${path.posix.dirname(target)}/**`];
1560
+ }
1561
+ if (tier === "on-path" && target === null) {
1391
1562
  if (fields.paths === undefined || fields.paths.length === 0) {
1392
1563
  throw new CompileError(`${where}: an on-path unit names the \`paths\` that load it, and this one names none`);
1393
1564
  }
@@ -1400,7 +1571,7 @@ export function parseUnit(name, text, source = `${name}.md`) {
1400
1571
  }
1401
1572
  }
1402
1573
  paths = fields.paths;
1403
- } else if (fields.paths !== undefined) {
1574
+ } else if (tier !== "on-path" && fields.paths !== undefined) {
1404
1575
  throw new CompileError(`${where}: \`paths\` scopes an on-path unit, and this one is \`${tier}\` — a key that loads nothing is one its author believes is in force`);
1405
1576
  }
1406
1577
  let description = null;
@@ -1433,12 +1604,13 @@ export function parseUnit(name, text, source = `${name}.md`) {
1433
1604
  } else if (rest[0] === BOOT_CARD_LINE) {
1434
1605
  throw new CompileError(`${where}: \`${BOOT_CARD_LINE}\` opens the boot card, and only the unit named \`${BOOT_CARD_UNIT}\` is one — the boot skill would take this unit for the card`);
1435
1606
  }
1607
+ if (target !== null && importSpans(rest.join("\n")).some(({target: spelled}) => importPath(spelled) !== null)) throw new CompileError(`${where}: nested instruction imports are not supported; put literal examples in code`);
1436
1608
  const written = [["leads", LEADS_LINE], ["engine", ENGINE_LINE], ["gates", GATES_LINE]].find(([, line]) => rest.some((text) => line.test(text)));
1437
1609
  if (tier !== "always" && written) {
1438
1610
  const [which] = written;
1439
1611
  throw new CompileError(`${where}: a \`<!-- ${which}: … -->\` line is written out only in an always unit, and this one is \`${tier}\``);
1440
1612
  }
1441
- return { name, tier, paths, description, body: `${rest.join("\n")}\n`, source, bytes: Buffer.byteLength(text, "utf8") };
1613
+ return { name, tier, paths, ...(target === null ? {} : {target}), description, body: `${rest.join("\n")}\n`, source, bytes: Buffer.byteLength(text, "utf8") };
1442
1614
  }
1443
1615
 
1444
1616
  /** The declared `slots.context`, or null where none is declared or there is no manifest; one that will not parse throws. */
@@ -1453,7 +1625,7 @@ export function guidanceDeclaration(workspaceRoot, workspaceDir = ".portulan") {
1453
1625
  }
1454
1626
  let manifest;
1455
1627
  try {
1456
- manifest = JSON.parse(fs.readFileSync(path.join(base, "workspace.json"), "utf8"));
1628
+ manifest = JSON.parse(readWorkspaceText(workspaceRoot, workspaceDir));
1457
1629
  } catch {
1458
1630
  return null;
1459
1631
  }
@@ -1474,11 +1646,12 @@ export function guidanceDeclaration(workspaceRoot, workspaceDir = ".portulan") {
1474
1646
  `a unit there would be overwritten by what it compiles to, and \`vendor\` carries neither directory. Keep guidance in a directory of its own, such as \`context/\``,
1475
1647
  );
1476
1648
  }
1477
- return { dir, rel: `${rel}/`, packs: Array.isArray(manifest.packs) ? manifest.packs.filter((p) => typeof p === "string") : [] };
1649
+ const workspace = path.relative(fs.realpathSync(workspaceRoot),fs.realpathSync(base)).split(path.sep).join("/") || ".";
1650
+ return { dir, rel: `${rel}/`, workspace, packs: Array.isArray(manifest.packs) ? manifest.packs.filter((p) => typeof p === "string") : [] };
1478
1651
  }
1479
1652
 
1480
1653
  /** Every unit, read and checked, or null where no `slots.context` is declared. */
1481
- export function guidanceUnits(workspaceRoot, workspaceDir = ".portulan") {
1654
+ export function guidanceUnits(workspaceRoot, workspaceDir = ".portulan", { originRoot = workspaceRoot } = {}) {
1482
1655
  const declared = guidanceDeclaration(workspaceRoot, workspaceDir);
1483
1656
  if (declared === null) return null;
1484
1657
  let entries;
@@ -1502,6 +1675,7 @@ export function guidanceUnits(workspaceRoot, workspaceDir = ".portulan") {
1502
1675
  }
1503
1676
  });
1504
1677
  const units = [];
1678
+ for(const entry of entries) if(entry.name.endsWith(".md") && !entry.isDirectory() && !entry.isFile() && !entry.isSymbolicLink()) throw new CompileError(`${declared.rel}${entry.name} is not a regular guidance source`);
1505
1679
  const names = entries
1506
1680
  .filter((e) => e.name.endsWith(".md") && (e.isFile() || e.isSymbolicLink()))
1507
1681
  .map((e) => e.name)
@@ -1521,20 +1695,22 @@ export function guidanceUnits(workspaceRoot, workspaceDir = ".portulan") {
1521
1695
  }
1522
1696
  let text;
1523
1697
  try {
1524
- text = fs.readFileSync(full, "utf8");
1698
+ if (!fs.statSync(full).isFile()) throw new CompileError(`${source} is not a regular guidance unit`);
1699
+ text = readOwnedText(full,realBase);
1525
1700
  } catch (cause) {
1526
1701
  throw new CompileError(`${source} could not be read — ${cause.code ?? cause.message}`);
1527
1702
  }
1528
1703
  const unit = parseUnit(path.basename(file, ".md"), text, source);
1704
+ unit.ownedSource = path.relative(fs.realpathSync(workspaceRoot),real).split(path.sep).join("/");
1529
1705
  if (unit.tier === "always") {
1530
1706
  unit.dir = path.dirname(full);
1531
- unit.text = expandedBody(unit, unit.dir, path.resolve(workspaceRoot), declared.packs);
1707
+ unit.text = expandedBody(unit, unit.dir, path.resolve(workspaceRoot), declared.packs, { originRoot });
1532
1708
  unit.imports = checkedImports(unit.text, unit.dir, path.resolve(workspaceRoot), source);
1533
1709
  } else {
1534
1710
  for (const { target } of importSpans(unit.body)) {
1535
1711
  const bare = importPath(target);
1536
1712
  if (bare === null || bare.startsWith("~") || path.isAbsolute(bare)) continue;
1537
- if (fs.statSync(path.resolve(path.dirname(full), bare), { throwIfNoEntry: false })?.isFile()) {
1713
+ if (importTargetIsFile(path.resolve(path.dirname(full), bare))) {
1538
1714
  throw new CompileError(
1539
1715
  `${source}: \`@${target}\` names a file, and only an always unit may import one — ${STRAY_IMPORT[unit.tier]}. ` +
1540
1716
  `Name the file to open instead, in a code span`,
@@ -1544,7 +1720,12 @@ export function guidanceUnits(workspaceRoot, workspaceDir = ".portulan") {
1544
1720
  }
1545
1721
  units.push(unit);
1546
1722
  }
1547
- return { source: declared.rel, units, root: path.resolve(workspaceRoot) };
1723
+ validateNestedTargets(units.filter(unit=>unit.target).map(unit=>unit.target));
1724
+ for (const unit of units.filter(unit=>unit.target)) {
1725
+ const at=path.resolve(workspaceRoot,unit.target);
1726
+ if (units.some(other=>path.resolve(workspaceRoot,other.ownedSource)===at)) throw new CompileError(`${unit.target} would overwrite a guidance source`);
1727
+ }
1728
+ return { source: declared.rel, units, root: path.resolve(workspaceRoot), workspace:declared.workspace, protected:units.some(unit=>unit.target) ? workspaceProtection(workspaceRoot,workspaceDir) : undefined };
1548
1729
  }
1549
1730
 
1550
1731
  function rebasedText(unit, root, to) {
@@ -1573,6 +1754,8 @@ export function claudeCodeGuidance(guidance) {
1573
1754
  if (unit.tier === "always") {
1574
1755
  const at = `${GUIDANCE_RULES_DIR}/${unit.name}.md`;
1575
1756
  files.push({ unit, path: at, text: unit.text === undefined ? unit.body : rebasedText(unit, guidance.root, at) });
1757
+ } else if (unit.tier === "on-path" && unit.target) {
1758
+ files.push({unit,path:unit.target,text:`${nestedMark(guidance.workspace ?? ".",unit.ownedSource ?? unit.source,unit.target)}\n${unit.body}`,nested:true});
1576
1759
  } else if (unit.tier === "on-path") {
1577
1760
  const header = ["---", "paths:", ...unit.paths.map((glob) => ` - ${quoted(glob)}`), "---", ""].join("\n");
1578
1761
  files.push({ unit, path: `${GUIDANCE_RULES_DIR}/${unit.name}.md`, text: `${header}\n${unit.body}` });
@@ -1659,12 +1842,15 @@ function listReal(workspaceRoot, rel) {
1659
1842
  }
1660
1843
 
1661
1844
  /** Judged before either half writes, so a refusal leaves no gate artifact written beside it. */
1662
- function planGuidance(guidance, workspaceRoot) {
1845
+ export function planGuidance(guidance, workspaceRoot, workspaceDir = ".portulan") {
1846
+ assertSourceJournalReady(workspaceRoot,workspaceDir);
1663
1847
  const files = guidance ? claudeCodeGuidance(guidance).files : [];
1848
+ const nested = nestedPlan(workspaceRoot,guidance,files,guidance?.workspace ?? workspaceDir);
1664
1849
  const rules = files.filter((f) => f.path.startsWith(`${GUIDANCE_RULES_DIR}/`));
1665
1850
  const owedFiles = rules.length
1666
1851
  ? [{ unit: null, marker: true, path: `${GUIDANCE_RULES_DIR}/${RULES_MARKER}`, text: markerText(rules.map((f) => path.posix.basename(f.path))) }, ...files]
1667
1852
  : files;
1853
+ if (nested.control) owedFiles.push(nested.control);
1668
1854
  const owed = new Set(owedFiles.map((f) => f.path));
1669
1855
 
1670
1856
  for (const file of owedFiles) {
@@ -1706,11 +1892,11 @@ function planGuidance(guidance, workspaceRoot) {
1706
1892
  // A run stopped before its marker leaves exactly its unit's output; anything else was written by hand.
1707
1893
  let current;
1708
1894
  try {
1709
- current = fs.readFileSync(path.join(rulesDir, name), "utf8");
1895
+ current = fs.readFileSync(path.join(rulesDir, name));
1710
1896
  } catch (cause) {
1711
1897
  throw new CompileError(`${file.path} could not be read — ${cause.code ?? cause.message}`);
1712
1898
  }
1713
- if (current !== file.text) {
1899
+ if (!sameContent(current,file.text)) {
1714
1900
  throw new CompileError(`${file.path} exists and was not compiled here — ${file.unit ? file.unit.source : `the on-read units in ${guidance.source}`} would replace a rule written by hand. Rename the unit or the rule`);
1715
1901
  }
1716
1902
  }
@@ -1739,7 +1925,7 @@ function planGuidance(guidance, workspaceRoot) {
1739
1925
  if (listed instanceof Set || rules.length) {
1740
1926
  for (const entry of entries) {
1741
1927
  const rel = `${GUIDANCE_RULES_DIR}/${entry.name}`;
1742
- if (owed.has(rel)) continue;
1928
+ if (owed.has(rel) || nested.claimed.has(rel)) continue;
1743
1929
  const ours = listed instanceof Set && entry.isFile() && (listed.has(entry.name) || entry.name === RULES_MARKER);
1744
1930
  stray.push({ path: rel, removable: ours });
1745
1931
  }
@@ -1763,7 +1949,20 @@ function planGuidance(guidance, workspaceRoot) {
1763
1949
  const kept = listed instanceof Set || rules.length
1764
1950
  ? rules.map((f) => path.posix.basename(f.path)).filter((name) => listed instanceof Set && listed.has(name) && present.has(name))
1765
1951
  : null;
1766
- return { owedFiles, stray, kept };
1952
+ stray.push(...nested.stale.map(edit=>({path:edit.file,removable:true,nested:true})));
1953
+ return { owedFiles, stray, kept, nested };
1954
+ }
1955
+
1956
+ function preflightGuidanceWrites(plan, workspaceRoot) {
1957
+ preflightNestedEdits(workspaceRoot, plan.nested.edits);
1958
+ const outputs = new Set(plan.owedFiles.filter((file) => !file.nested && !file.control).map((file) => file.path));
1959
+ if (plan.kept !== null) outputs.add(`${GUIDANCE_RULES_DIR}/${RULES_MARKER}`);
1960
+ for (const rel of outputs) {
1961
+ const target = path.join(workspaceRoot, ...rel.split("/"));
1962
+ const at = fs.lstatSync(target, { throwIfNoEntry: false });
1963
+ if (at?.isFile() && at.nlink !== 1)
1964
+ throw new CompileError(`${target} is a multiply linked guidance output; refusing publication`);
1965
+ }
1767
1966
  }
1768
1967
 
1769
1968
  function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
@@ -1771,7 +1970,7 @@ function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
1771
1970
  if (guidance === null && stray.length === 0) return 0;
1772
1971
 
1773
1972
  if (guidance) {
1774
- const files = owedFiles.filter((f) => !f.marker);
1973
+ const files = owedFiles.filter((f) => !f.marker && !f.control);
1775
1974
  const counts = LOAD_TIERS.map((tier) => `${guidance.units.filter((u) => u.tier === tier).length} ${tier}`).join(", ");
1776
1975
  say(`guidance: ${guidance.units.length} unit(s) in ${guidance.source} — ${counts}`);
1777
1976
  for (const [id, host] of Object.entries(GUIDANCE_HOSTS)) {
@@ -1799,17 +1998,19 @@ function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
1799
1998
  const target = path.join(workspaceRoot, ...file.path.split("/"));
1800
1999
  let current = null;
1801
2000
  try {
1802
- current = fs.readFileSync(target, "utf8");
2001
+ current = fs.readFileSync(target);
1803
2002
  } catch {
1804
2003
  say(file.marker
1805
2004
  ? `RED — ${target} does not exist; it is the marker that lists the rules this compiler wrote. Recompile to write it.`
2005
+ : file.control ? `RED — ${target} is missing its nested instruction ownership registry. Recompile to write it.`
1806
2006
  : `RED — ${target} does not exist; ${file.unit ? file.unit.source : "an on-read unit"} compiles to it`);
1807
2007
  drifted += 1;
1808
2008
  continue;
1809
2009
  }
1810
- if (current !== file.text) {
2010
+ if (!sameContent(current,file.text)) {
1811
2011
  say(file.marker
1812
2012
  ? `RED — ${target} does not list the rules the units compile to now. Recompile to rewrite it.`
2013
+ : file.control ? `RED — ${target} has drifted from nested instruction ownership. Recompile to rewrite it.`
1813
2014
  : file.unit?.leadSources?.length
1814
2015
  ? `RED — ${target} has drifted from ${file.unit.source}, whose ${[...file.unit.written].sort().join(" and ")} are written from ${file.unit.leadSources.join(" and ")}. Edit the unit or those files, then recompile.`
1815
2016
  : `RED — ${target} has drifted from ${file.unit ? file.unit.source : `the on-read units in ${guidance.source}`}. Edit the unit, then recompile.`);
@@ -1826,6 +2027,8 @@ function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
1826
2027
  return drifted;
1827
2028
  }
1828
2029
 
2030
+ preflightGuidanceWrites(plan, workspaceRoot);
2031
+ applyNestedEdits(workspaceRoot,plan.nested.edits);
1829
2032
  // Listed only once written, unlisted before removal: an interrupted run never lists a file it did not write.
1830
2033
  const markerPath = path.join(workspaceRoot, ...GUIDANCE_RULES_DIR.split("/"), RULES_MARKER);
1831
2034
  if (kept !== null) {
@@ -1834,7 +2037,7 @@ function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
1834
2037
  }
1835
2038
  for (const s of stray) {
1836
2039
  const target = path.join(workspaceRoot, ...s.path.split("/"));
1837
- if (target === markerPath) continue;
2040
+ if (target === markerPath || s.nested) continue;
1838
2041
  if (!s.removable) {
1839
2042
  say(`left ${target} — it is not a file this compiler wrote, so it is not this compiler's to remove`);
1840
2043
  continue;
@@ -1850,7 +2053,7 @@ function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
1850
2053
  }
1851
2054
  }
1852
2055
  for (const file of owedFiles) {
1853
- if (file.marker) continue;
2056
+ if (file.marker || file.nested || file.control) continue;
1854
2057
  const target = path.join(workspaceRoot, ...file.path.split("/"));
1855
2058
  fs.mkdirSync(path.dirname(target), { recursive: true });
1856
2059
  fs.writeFileSync(target, file.text);
@@ -1875,10 +2078,11 @@ function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
1875
2078
  }
1876
2079
 
1877
2080
  /** The guidance half alone: the gate policy is not read, so host settings stay the output of a `compile` a human runs. */
1878
- export function compileGuidance(named, { check = false, say = () => {} } = {}) {
2081
+ export function compileGuidance(named, { check = false, say = () => {}, originRoot } = {}) {
1879
2082
  const { workspaceRoot, workspaceDir } = resolveWorkspace(named);
1880
- const guidance = guidanceUnits(workspaceRoot, workspaceDir);
1881
- const plan = planGuidance(guidance, workspaceRoot);
2083
+ const guidance = guidanceUnits(workspaceRoot, workspaceDir, { originRoot });
2084
+ const plan = planGuidance(guidance, workspaceRoot, workspaceDir);
2085
+ if (!check) preflightGuidanceWrites(plan, workspaceRoot);
1882
2086
  return { declared: guidance !== null, drifted: emitGuidance(guidance, plan, { workspaceRoot, check, say }) };
1883
2087
  }
1884
2088
 
@@ -1886,25 +2090,37 @@ export function compileGuidance(named, { check = false, say = () => {} } = {}) {
1886
2090
  export function guidanceEdits(named) {
1887
2091
  const { workspaceRoot, workspaceDir } = resolveWorkspace(named);
1888
2092
  const guidance = guidanceUnits(workspaceRoot, workspaceDir);
1889
- const { owedFiles, stray, kept } = planGuidance(guidance, workspaceRoot);
2093
+ const { owedFiles, stray, kept, nested } = planGuidance(guidance, workspaceRoot, workspaceDir);
1890
2094
  const markerRel = `${GUIDANCE_RULES_DIR}/${RULES_MARKER}`;
1891
2095
  const current = (rel) => {
1892
2096
  try {
1893
- return fs.readFileSync(path.join(workspaceRoot, ...rel.split("/")), "utf8");
2097
+ return fs.readFileSync(path.join(workspaceRoot, ...rel.split("/")));
1894
2098
  } catch (cause) {
1895
2099
  if (cause.code === "ENOENT") return null;
1896
2100
  throw new CompileError(`${rel} could not be read — ${cause.code ?? cause.message}`);
1897
2101
  }
1898
2102
  };
1899
- const edits = [];
1900
- for (const s of stray) if (s.removable && s.path !== markerRel) edits.push({ file: s.path, next: null });
1901
- for (const file of owedFiles) if (!file.marker && current(file.path) !== file.text) edits.push({ file: file.path, next: file.text });
2103
+ const edits = [...nested.edits];
2104
+ for (const s of stray) if (s.removable && !s.nested && s.path !== markerRel) edits.push({ file: s.path, next: null });
2105
+ for (const file of owedFiles) if (!file.marker && !file.nested && !file.control && !sameContent(current(file.path),file.text)) edits.push({ file: file.path, next: file.text });
1902
2106
  const marker = owedFiles.find((f) => f.marker);
1903
- if (marker && current(markerRel) !== marker.text) edits.push({ file: markerRel, next: marker.text });
2107
+ if (marker && !sameContent(current(markerRel),marker.text)) edits.push({ file: markerRel, next: marker.text });
1904
2108
  else if (!marker && kept !== null && current(markerRel) !== null) edits.push({ file: markerRel, next: null });
1905
2109
  return { root: path.resolve(workspaceRoot), edits, left: stray.filter((s) => !s.removable).map((s) => s.path) };
1906
2110
  }
1907
2111
 
2112
+ export function personaEdits(named, options = {}) {
2113
+ const { workspaceRoot, workspaceDir } = resolveWorkspace(named);
2114
+ const packs = packContributions(workspaceRoot, workspaceDir, { discovery: () => discoverPackRoots(), ...options });
2115
+ if (packs.unresolved.length) throw new CompileError("persona binding sources are unresolved; refusing a partial refresh");
2116
+ const planned = planPersonas(packs.contributions, workspaceRoot, workspaceDir);
2117
+ return {
2118
+ root: path.resolve(workspaceRoot), compared: planned.compared,
2119
+ edits: planned.files.map(file => ({ file: file.path, next: file.text })),
2120
+ notes: [...planned.lines, ...planned.degraded.map(value => value.line)],
2121
+ };
2122
+ }
2123
+
1908
2124
  // ---------------------------------------------------------------- the manifest's declarations
1909
2125
 
1910
2126
  /** `{ file, why }` for a manifest that is there and will not parse as an object; null when it reads or is absent. */
@@ -1912,7 +2128,7 @@ export function unreadableManifest(workspaceRoot, workspaceDir = ".portulan") {
1912
2128
  const file = path.join(workspaceRoot, workspaceDir, "workspace.json");
1913
2129
  let raw;
1914
2130
  try {
1915
- raw = fs.readFileSync(file, "utf8");
2131
+ raw = readWorkspaceText(workspaceRoot, workspaceDir);
1916
2132
  } catch (cause) {
1917
2133
  if (cause.code === "ENOENT") return null;
1918
2134
  return { file, why: `it could not be read — ${cause.code ?? cause.message}` };
@@ -1934,7 +2150,7 @@ export function policyDeclaration(workspaceRoot, workspaceDir = ".portulan") {
1934
2150
  const fallback = (reason) => ({ file: path.join(workspaceRoot, workspaceDir, "gates.json"), declared: false, reason });
1935
2151
  let declared;
1936
2152
  try {
1937
- declared = JSON.parse(fs.readFileSync(manifest, "utf8")).gates;
2153
+ declared = JSON.parse(readWorkspaceText(workspaceRoot, workspaceDir)).gates;
1938
2154
  } catch {
1939
2155
  // An unreadable manifest reaches here only from the hook's reader, which must not stop.
1940
2156
  return fallback("no-manifest");
@@ -1966,7 +2182,7 @@ export function sessionsDeclaration(workspaceRoot, workspaceDir = ".portulan") {
1966
2182
  const manifest = path.join(workspaceRoot, workspaceDir, "workspace.json");
1967
2183
  let declared;
1968
2184
  try {
1969
- declared = JSON.parse(fs.readFileSync(manifest, "utf8")).sessions;
2185
+ declared = JSON.parse(readWorkspaceText(workspaceRoot, workspaceDir)).sessions;
1970
2186
  } catch {
1971
2187
  return null;
1972
2188
  }
@@ -1993,7 +2209,7 @@ export function spendDeclaration(workspaceRoot, workspaceDir = ".portulan") {
1993
2209
  const manifest = path.join(workspaceRoot, workspaceDir, "workspace.json");
1994
2210
  let parsed;
1995
2211
  try {
1996
- parsed = JSON.parse(fs.readFileSync(manifest, "utf8"));
2212
+ parsed = JSON.parse(readWorkspaceText(workspaceRoot, workspaceDir));
1997
2213
  } catch {
1998
2214
  return null;
1999
2215
  }
@@ -2007,16 +2223,43 @@ export function spendDeclaration(workspaceRoot, workspaceDir = ".portulan") {
2007
2223
  if (error instanceof LedgerError) throw new CompileError(error.message);
2008
2224
  throw error;
2009
2225
  }
2010
- const version = /^([0-9]+)\.([0-9]+)$/.exec(parsed.portulan?.spec ?? "");
2011
- if (spend.restart !== null && version && Number(version[1]) === 2 && Number(version[2]) < 13) {
2012
- throw new CompileError(
2013
- `\`spend.restart\` in ${where} is Workspace Definition 2.13's, and this manifest declares ${version[0]}, whose validator ` +
2014
- "refuses it as an unknown key. Declare 2.13, or remove the key",
2015
- );
2226
+ if (spend.restart !== null) {
2227
+ const spec = parsed.portulan?.spec;
2228
+ const version = typeof spec === "string" ? /^([0-9]+)\.([0-9]+)$/.exec(spec) : null;
2229
+ if (!version || Number(version[1]) !== 2) {
2230
+ throw new CompileError(
2231
+ `\`spend.restart\` in ${where} requires \`portulan.spec\` to be a string MAJOR.MINOR on supported major 2; ` +
2232
+ `got ${JSON.stringify(spec)}. Declare 2.13 or a later minor, or remove the key`,
2233
+ );
2234
+ }
2235
+ if (Number(version[2]) < 13) {
2236
+ throw new CompileError(
2237
+ `\`spend.restart\` in ${where} is Workspace Definition 2.13's, and this manifest declares ${version[0]}, whose validator ` +
2238
+ "refuses it as an unknown key. Declare 2.13, or remove the key",
2239
+ );
2240
+ }
2241
+ }
2242
+ if (spend.resume !== null) {
2243
+ const version = typeof parsed.portulan?.spec === "string" ? /^2\.([0-9]+)$/.exec(parsed.portulan.spec) : null;
2244
+ const schema = JSON.parse(fs.readFileSync(new URL("../spec/workspace.schema.json", import.meta.url), "utf8"));
2245
+ const supported = /\/spec\/2\.([0-9]+)\//.exec(schema.$id ?? "");
2246
+ if (!version || !supported || Number(version[1]) < 16 || Number(version[1]) > Number(supported[1])) {
2247
+ throw new CompileError(`\`spend.resume\` in ${where} requires Workspace Definition 2.16 through the shipped schema's supported minor`);
2248
+ }
2016
2249
  }
2017
2250
  return spend;
2018
2251
  }
2019
2252
 
2253
+ function stopDeclaration(workspaceRoot, workspaceDir) {
2254
+ const file = path.join(workspaceRoot, workspaceDir, "workspace.json");
2255
+ let manifest;
2256
+ try { manifest = JSON.parse(readWorkspaceText(workspaceRoot, workspaceDir)); } catch { return null; }
2257
+ try {
2258
+ const policy = readStopPolicy(manifest);
2259
+ return policy.declared ? { ...policy, manifest: path.relative(workspaceRoot, file).split(path.sep).join("/") } : null;
2260
+ } catch (error) { throw new CompileError(error.message); }
2261
+ }
2262
+
2020
2263
  /** `guidanceOnly` is true where guidance still compiles, and `"leftover"` where only an earlier run's is removed. */
2021
2264
  function undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOptions, reason = "no-key", guidanceOnly = false) {
2022
2265
  const manifest = path.join(workspaceRoot, workspaceDir, "workspace.json");
@@ -2034,8 +2277,14 @@ function undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOp
2034
2277
  : `this workspace declares no gate policy — \`workspace.json\` has no top-level \`gates\` key, ` +
2035
2278
  `and there is no \`gates.json\` at ${policyFile}.`;
2036
2279
  const outcome =
2037
- guidanceOnly === "leftover"
2280
+ guidanceOnly === "advisory"
2281
+ ? "No gate policy is compiled; restart advice, declared session settings, ritual admission settings and guidance/persona bindings compile independently."
2282
+ : guidanceOnly === "contexts"
2283
+ ? "No gate policy is compiled; ritual admission settings and any declared guidance/persona bindings compile independently."
2284
+ : guidanceOnly === "leftover"
2038
2285
  ? "No enforcement is compiled, and no guidance is declared: what an earlier run compiled from guidance is this compiler's to remove, and is named below."
2286
+ : guidanceOnly === "personas"
2287
+ ? "No enforcement or guidance is compiled; existing persona bindings still carry their declared fields."
2039
2288
  : guidanceOnly
2040
2289
  ? "No enforcement is compiled; the workspace's guidance still is."
2041
2290
  : "Nothing was compiled and nothing was written.";
@@ -2089,7 +2338,15 @@ export function resolvePack(rawName, roots = []) {
2089
2338
  for (const root of roots) {
2090
2339
  const dir = path.join(root, category, pack);
2091
2340
  const manifest = path.join(dir, "pack.json");
2092
- if (fs.existsSync(manifest)) return { name, category, pack, dir, manifest, root, why: null };
2341
+ try {
2342
+ const realRoot = fs.realpathSync(root), realDir = fs.realpathSync(dir);
2343
+ if (!isInside(realRoot, realDir)) throw new CompileError(`${dir} resolves outside its resolution root ${root}`);
2344
+ readOwnedText(manifest, dir);
2345
+ return { name, category, pack, dir, manifest, root, why: null };
2346
+ } catch (error) {
2347
+ if (error.code === "ENOENT") continue;
2348
+ throw new CompileError(`cannot resolve \`${name}\` under ${root} — ${error.code ?? error.message}`);
2349
+ }
2093
2350
  }
2094
2351
  return { name, category, pack, dir: null, manifest: null, root: null, why: "no pack.json under any resolution root" };
2095
2352
  }
@@ -2140,6 +2397,7 @@ export function packDifferences(mine, other) {
2140
2397
  const differs = [];
2141
2398
  if (mineV !== otherV) differs.push(`version ${mineV} against the tree's ${otherV}`);
2142
2399
  if (frag(mine) !== frag(other)) differs.push("gate fragments that differ once parsed");
2400
+ if (JSON.stringify(canonical(mine?.contributes?.contexts ?? null)) !== JSON.stringify(canonical(other?.contributes?.contexts ?? null))) differs.push("context caps that differ once parsed");
2143
2401
  return differs;
2144
2402
  }
2145
2403
 
@@ -2154,9 +2412,10 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
2154
2412
  const base = path.join(workspaceRoot, workspaceDir);
2155
2413
  let manifest;
2156
2414
  try {
2157
- manifest = JSON.parse(fs.readFileSync(path.join(base, "workspace.json"), "utf8"));
2158
- } catch {
2159
- return { contributions: [], unresolved: [] };
2415
+ manifest = JSON.parse(readWorkspaceText(workspaceRoot, workspaceDir));
2416
+ } catch (error) {
2417
+ if (error.code === "ENOENT") return { contributions: [], unresolved: [] };
2418
+ throw new CompileError(`the workspace manifest could not be read — ${error.code ?? error.message}`);
2160
2419
  }
2161
2420
  const declared = manifest?.packs;
2162
2421
  if (!Array.isArray(declared) || declared.length === 0) return { contributions: [], unresolved: [] };
@@ -2180,7 +2439,7 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
2180
2439
  unresolved.push(found);
2181
2440
  continue;
2182
2441
  }
2183
- const packManifest = readJson(found.manifest, `the pack manifest for \`${found.name}\``);
2442
+ const packManifest = readJson(found.manifest, `the pack manifest for \`${found.name}\``, found.dir);
2184
2443
  if (!(options.forced ?? false) && plan.source !== "named") {
2185
2444
  const behind = shadowedCopy(found.name, recordedOrigin(found.root, plan, workspaceRoot), roots, (r) =>
2186
2445
  (plan.origins ?? []).find((o) => path.resolve(o.root) === path.resolve(r))?.origin,
@@ -2188,7 +2447,7 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
2188
2447
  if (behind) {
2189
2448
  let other = null;
2190
2449
  try {
2191
- other = JSON.parse(fs.readFileSync(behind.manifest, "utf8"));
2450
+ other = JSON.parse(readOwnedText(behind.manifest, behind.dir));
2192
2451
  } catch (cause) {
2193
2452
  throw new CompileError(
2194
2453
  `\`${found.name}\` resolved under the root ${found.root} while the root ` +
@@ -2217,10 +2476,19 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
2217
2476
  `Refusing to compose it — run \`doctor\` to validate the pack against the Pack Definition.`,
2218
2477
  );
2219
2478
  }
2479
+ const personas = packManifest?.contributes?.personas;
2480
+ if (personas !== undefined && !Array.isArray(personas)) {
2481
+ throw new CompileError(
2482
+ `the pack manifest for \`${found.name}\` declares \`contributes.personas\` as ${typeof personas} rather than an array. ` +
2483
+ `Refusing to compose it — run \`doctor\` to validate the pack against the Pack Definition.`,
2484
+ );
2485
+ }
2220
2486
  contributions.push({
2221
2487
  pack: found.name,
2222
2488
  dir: found.dir,
2489
+ contextCap: readContextCap(packManifest, found.name),
2223
2490
  fragments: fragments ?? [],
2491
+ personas: personas ?? [],
2224
2492
  origin: recordedOrigin(found.root, plan, workspaceRoot),
2225
2493
  version: typeof packManifest?.portulan?.version === "string" ? packManifest.portulan.version : null,
2226
2494
  });
@@ -2310,10 +2578,18 @@ export function composeFragments(policy, contributions) {
2310
2578
  return { policy: { ...policy, rules }, added, tightened };
2311
2579
  }
2312
2580
 
2313
- function readJson(file, what) {
2581
+ function readWorkspaceText(workspaceRoot, workspaceDir) {
2582
+ const base = path.join(workspaceRoot, workspaceDir);
2583
+ fs.lstatSync(path.join(base, "workspace.json"));
2584
+ const root = fs.realpathSync(workspaceRoot), owner = fs.realpathSync(base);
2585
+ if (!isInside(root, owner)) throw new CompileError(`${base} resolves outside its nominated workspace root`);
2586
+ return readOwnedText(path.join(base, "workspace.json"), base);
2587
+ }
2588
+
2589
+ function readJson(file, what, scope = path.dirname(file)) {
2314
2590
  let raw;
2315
2591
  try {
2316
- raw = fs.readFileSync(file, "utf8");
2592
+ raw = readOwnedText(file, scope);
2317
2593
  } catch (error) {
2318
2594
  throw new CompileError(`cannot read ${what} at ${file} — ${error.code ?? error.message}`);
2319
2595
  }
@@ -2328,6 +2604,360 @@ export function render(settings) {
2328
2604
  return `${JSON.stringify(settings, null, 2)}\n`;
2329
2605
  }
2330
2606
 
2607
+ // ---------------------------------------------------------------- personas: what a role declares about its context
2608
+
2609
+ /** The fields a persona may declare about the context it runs in, in the order a binding carries them. */
2610
+ export const PERSONA_FIELDS = ["model", "effort", "omitClaudeMd", "cacheLifetime"];
2611
+
2612
+ export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
2613
+
2614
+ const MODEL_NAME = /^[A-Za-z0-9][A-Za-z0-9._:@/[\]-]{0,199}$/;
2615
+
2616
+ const readBoolean = (raw) => raw === "true" ? true : raw === "false" ? false : undefined;
2617
+
2618
+ const PERSONA_VALUES = {
2619
+ model: { read: (raw) => (MODEL_NAME.test(raw) ? raw : undefined), takes: "one model name or alias, written as a single token" },
2620
+ effort: { read: (raw) => (EFFORT_LEVELS.includes(raw) ? raw : undefined), takes: `one of ${EFFORT_LEVELS.join(", ")}` },
2621
+ omitClaudeMd: { read: readBoolean, takes: "`true` or `false`, unquoted" },
2622
+ cacheLifetime: { read: (raw) => (CACHE_LIFETIMES.includes(raw) ? raw : undefined), takes: `one of ${CACHE_LIFETIMES.join(", ")}` },
2623
+ };
2624
+
2625
+ /** Claude Code's frontmatter key for each field, a dotted one nested under its first word. */
2626
+ const CLAUDE_CODE_KEYS = { model: "model", effort: "effort", omitClaudeMd: "omitClaudeMd", cacheLifetime: "experimental.cacheTtl" };
2627
+
2628
+ /** `null` is a field the host cannot express; `why` is the sentence the degradation report gives for it. */
2629
+ export const PERSONA_HOSTS = {
2630
+ "claude-code": {
2631
+ label: "Claude Code",
2632
+ ...Object.fromEntries(PERSONA_FIELDS.map((field) => [field, `\`${CLAUDE_CODE_KEYS[field]}\``])),
2633
+ },
2634
+ "agents-md": {
2635
+ label: "AGENTS.md, vendored",
2636
+ model: null,
2637
+ effort: null,
2638
+ omitClaudeMd: null,
2639
+ cacheLifetime: null,
2640
+ why: "AGENTS.md defines no agent to carry it, so the role runs with the session's own",
2641
+ },
2642
+ };
2643
+
2644
+ /** The plugin location; ordinary projects use `.claude/agents/`. */
2645
+ export const BINDING_DIR = "agents";
2646
+
2647
+ const PLUGIN_MANIFEST = ".claude-plugin/plugin.json";
2648
+
2649
+ export function bindingDirectory(treeRoot) {
2650
+ const unread = (where, cause) => {
2651
+ const error = new CompileError(`${where} could not be examined — ${cause.code ?? cause.message}; binding location is unread, not absent`);
2652
+ error.unread = true;
2653
+ return error;
2654
+ };
2655
+ let realRoot;
2656
+ try { realRoot = fs.realpathSync(treeRoot); }
2657
+ catch (cause) { throw unread(treeRoot, cause); }
2658
+ const directory = rel => {
2659
+ let current = treeRoot;
2660
+ for (const part of rel.split("/")) {
2661
+ current = path.join(current, part);
2662
+ try { fs.lstatSync(current); }
2663
+ catch (cause) { if (cause.code === "ENOENT") return false; throw unread(current, cause); }
2664
+ let real, target;
2665
+ try { real = fs.realpathSync(current); target = fs.statSync(real); }
2666
+ catch (cause) { throw unread(current, cause); }
2667
+ if (!isInside(realRoot, real)) throw new CompileError(`${current} resolves outside the declared tree`);
2668
+ if (!target.isDirectory()) throw new CompileError(`${current} is not a directory`);
2669
+ }
2670
+ return true;
2671
+ };
2672
+ let selected = ".claude/agents";
2673
+ if (directory(".claude-plugin")) {
2674
+ const marker = path.join(treeRoot, PLUGIN_MANIFEST);
2675
+ let stat = null;
2676
+ try { stat = fs.lstatSync(marker); }
2677
+ catch (cause) { if (cause.code !== "ENOENT") throw unread(marker, cause); }
2678
+ if (stat !== null) {
2679
+ if (!stat.isFile()) throw new CompileError(`${marker} is not a regular plugin marker`);
2680
+ let raw;
2681
+ try { raw = fs.readFileSync(marker); }
2682
+ catch (cause) { throw unread(marker, cause); }
2683
+ let value;
2684
+ try { value = JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(raw)); }
2685
+ catch (cause) { throw new CompileError(`${marker} is not a readable UTF-8 JSON plugin marker — ${cause.message}`); }
2686
+ if (value === null || typeof value !== "object" || Array.isArray(value) || typeof value.name !== "string" || !SLUG.test(value.name)) throw new CompileError(`${marker} is not a named plugin object`);
2687
+ selected = BINDING_DIR;
2688
+ }
2689
+ }
2690
+ directory(selected);
2691
+ return selected;
2692
+ }
2693
+
2694
+ const unquoted = (raw) => (raw.length >= 2 && `"'`.includes(raw[0]) && raw.at(-1) === raw[0] ? raw.slice(1, -1) : raw);
2695
+
2696
+ /** What a persona's top-level frontmatter lines declare: `{ name, declared, problems }`, a malformed value a problem and never a default. */
2697
+ export function personaDeclarations(text) {
2698
+ const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
2699
+ const close = lines[0]?.trim() === "---" ? lines.indexOf("---", 1) : -1;
2700
+ if (close === -1) {
2701
+ const why = lines[0]?.trim() === "---" ? "the block is never closed" : "the first line must be `---`";
2702
+ return { name: null, declared: {}, problems: [`has no usable frontmatter — ${why}`] };
2703
+ }
2704
+ const declared = {};
2705
+ const problems = [];
2706
+ const seen = new Set();
2707
+ let name = null;
2708
+ for (const line of lines.slice(1, close)) {
2709
+ const match = /^([A-Za-z_][\w-]*)\s*:(.*)$/.exec(line);
2710
+ if (match?.[1] === "name") name = unquoted(match[2].trim()).trim() || null;
2711
+ if (!match || !Object.hasOwn(PERSONA_VALUES, match[1])) continue;
2712
+ const [, key, rest] = match;
2713
+ if (seen.has(key)) {
2714
+ problems.push(`declares \`${key}\` twice`);
2715
+ continue;
2716
+ }
2717
+ seen.add(key);
2718
+ const raw = key === "omitClaudeMd" ? rest.trim() : unquoted(rest.trim());
2719
+ const value = PERSONA_VALUES[key].read(raw);
2720
+ if (value === undefined) problems.push(`sets \`${key}\` to ${JSON.stringify(rest.trim())}, and \`${key}\` takes ${PERSONA_VALUES[key].takes}`);
2721
+ else declared[key] = value;
2722
+ }
2723
+ return { name, declared, problems };
2724
+ }
2725
+
2726
+ const PERSONA_MARK = "# compiled by `portulan compile` from ";
2727
+ const PERSONA_MARK_TAIL = "; edit that file, then recompile";
2728
+ const BINDING_KEY = /^([A-Za-z_][\w-]*)\s*:(.*)$/;
2729
+ const topKey = (field) => CLAUDE_CODE_KEYS[field].split(".")[0];
2730
+
2731
+ function bindingLines(declared) {
2732
+ const lines = [];
2733
+ for (const field of PERSONA_FIELDS) {
2734
+ if (declared[field] === undefined) continue;
2735
+ const value = field === "model" ? quoted(declared[field]) : String(declared[field]);
2736
+ const [top, nested] = CLAUDE_CODE_KEYS[field].split(".");
2737
+ lines.push(...(nested ? [`${top}:`, ` ${nested}: ${value}`] : [`${top}: ${value}`]));
2738
+ }
2739
+ return lines;
2740
+ }
2741
+
2742
+ /** Read recognized compiled fields strictly; the rewritten lines must still match byte for byte. */
2743
+ function blockFields(lines, where) {
2744
+ const read = {};
2745
+ let under = null;
2746
+ for (const line of lines) {
2747
+ const nested = /^\s+([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line);
2748
+ const match = nested ? null : BINDING_KEY.exec(line);
2749
+ const key = nested ? `${under}.${nested[1]}` : match?.[1];
2750
+ const raw = (nested?.[2] ?? match?.[2] ?? "").trim();
2751
+ if (!nested) under = key;
2752
+ const field = PERSONA_FIELDS.find((f) => CLAUDE_CODE_KEYS[f] === key);
2753
+ if (!field) continue;
2754
+ let input = raw;
2755
+ if (field === "model") {
2756
+ try { input = JSON.parse(raw); } catch { /* The byte comparison rejects noncanonical quoting. */ }
2757
+ }
2758
+ const value = typeof input === "string" ? PERSONA_VALUES[field].read(input) : undefined;
2759
+ if (value === undefined) throw new CompileError(`${where}: the compiled block holds \`${line}\`, which this compiler did not write — ${PERSONA_VALUES[field].takes}`);
2760
+ read[field] = value;
2761
+ }
2762
+ return read;
2763
+ }
2764
+
2765
+ /** A Claude Code binding's text with its compiled block rewritten in place from `declared`; every other byte is the binding's own. */
2766
+ export function boundAgent(text, declared, source, where) {
2767
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
2768
+ if (eol === "\r\n" && text.replaceAll("\r\n", "").includes("\n")) {
2769
+ 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`);
2770
+ }
2771
+ const lines = text.split(eol);
2772
+ const close = lines[0] === "---" ? lines.indexOf("---", 1) : -1;
2773
+ if (close === -1) {
2774
+ 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`);
2775
+ }
2776
+ const owned = new Set(PERSONA_FIELDS.map(topKey));
2777
+ const front = [];
2778
+ let at = null;
2779
+ for (let i = 1; i < close; i += 1) {
2780
+ if (!lines[i].startsWith(PERSONA_MARK)) {
2781
+ front.push(lines[i]);
2782
+ continue;
2783
+ }
2784
+ if (at !== null) throw new CompileError(`${where} carries two compiled blocks. Delete one, then compile again`);
2785
+ at = front.length;
2786
+ const block = [];
2787
+ while (i + 1 < close && (/^\s/.test(lines[i + 1]) || owned.has(BINDING_KEY.exec(lines[i + 1])?.[1]))) block.push(lines[(i += 1)]);
2788
+ const written = bindingLines(blockFields(block, where));
2789
+ const stray = block.find((line) => !written.includes(line)) ?? block.find((line, n) => line !== written[n]);
2790
+ if (stray !== undefined) {
2791
+ throw new CompileError(
2792
+ `${where}: the block compiled from ${source} holds \`${stray}\`, which this compiler did not write. Move the line above the ` +
2793
+ "mark or delete it, then compile again: removing it unasked would lose a line somebody wrote",
2794
+ );
2795
+ }
2796
+ }
2797
+ const writes = new Set(PERSONA_FIELDS.filter((f) => declared[f] !== undefined).map(topKey));
2798
+ for (const line of front) {
2799
+ const key = BINDING_KEY.exec(line)?.[1];
2800
+ if (writes.has(key)) {
2801
+ throw new CompileError(
2802
+ `${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`,
2803
+ );
2804
+ }
2805
+ if (writes.size && owned.has(key)) {
2806
+ throw new CompileError(
2807
+ `${where} sets \`${key}\` by hand, which ${source} does not declare. Declare it in the persona or remove the line: in a binding ` +
2808
+ "it compiles into, `compile` owns every key a persona may declare",
2809
+ );
2810
+ }
2811
+ }
2812
+ const block = writes.size ? [`${PERSONA_MARK}${source}${PERSONA_MARK_TAIL}`, ...bindingLines(declared)] : [];
2813
+ const place = at ?? front.length;
2814
+ return [lines[0], ...front.slice(0, place), ...block, ...front.slice(place), ...lines.slice(close)].join(eol);
2815
+ }
2816
+
2817
+ const listed = (names) => (names.length < 2 ? names.join("") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`);
2818
+
2819
+ /** Declared personas against existing bindings, including core sources when compiling this engine's own tree. */
2820
+ export function planPersonas(contributions, workspaceRoot, workspaceDir = ".portulan") {
2821
+ const base = path.join(workspaceRoot, workspaceDir);
2822
+ let tree = null;
2823
+ try {
2824
+ tree = JSON.parse(readWorkspaceText(workspaceRoot, workspaceDir))?.tree;
2825
+ } catch {
2826
+ // `run` refuses an unreadable manifest before this is reached; an absent one declares no tree.
2827
+ }
2828
+ const treeRoot = typeof tree === "string" && tree.trim() ? path.resolve(base, tree) : null;
2829
+ let ownTree = false;
2830
+ if (treeRoot) {
2831
+ try {
2832
+ ownTree = fs.realpathSync(treeRoot) === fs.realpathSync(ENGINE_ROOT);
2833
+ } catch (cause) {
2834
+ if (cause.code !== "ENOENT") throw new CompileError(`the declared tree could not be resolved — ${cause.code ?? cause.message}`);
2835
+ }
2836
+ }
2837
+ const sources = [...contributions];
2838
+ if (ownTree) {
2839
+ let personas;
2840
+ try {
2841
+ personas = fs.readdirSync(path.join(ENGINE_ROOT, "core/personas"))
2842
+ .filter((entry) => entry.endsWith(".md") && entry !== "README.md")
2843
+ .sort().map((entry) => `core/personas/${entry}`);
2844
+ } catch (cause) {
2845
+ throw new CompileError(`core/personas/ could not be read — ${cause.code ?? cause.message}, so its declarations are unknown`);
2846
+ }
2847
+ sources.unshift({ core: true, dir: ENGINE_ROOT, personas });
2848
+ }
2849
+ const files = [];
2850
+ const lines = [];
2851
+ const degraded = [];
2852
+ const owners = new Map();
2853
+ let compared = 0;
2854
+ const bindingDir = treeRoot && sources.some(source => source.personas?.length) ? bindingDirectory(treeRoot) : BINDING_DIR;
2855
+ for (const contribution of sources) {
2856
+ for (const rel of contribution.personas ?? []) {
2857
+ const where = contribution.core ? `core persona \`${rel}\`` : `\`${contribution.pack}\`'s persona \`${rel}\``;
2858
+ const file = path.resolve(contribution.dir, rel);
2859
+ let text;
2860
+ try {
2861
+ if (!isInside(fs.realpathSync(contribution.dir), fs.realpathSync(file))) throw new CompileError(`${where} resolves outside its ${contribution.core ? "engine" : "pack"}`);
2862
+ if (!fs.statSync(file).isFile()) throw new CompileError(`${where} is not a regular persona file`);
2863
+ text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(file));
2864
+ } catch (cause) {
2865
+ if (cause instanceof CompileError) throw cause;
2866
+ throw new CompileError(`${where} could not be read — ${cause.code ?? cause.message}, so what it declares is unknown.${contribution.core ? " Restore the core source" : " Run `doctor` to validate the pack"}`);
2867
+ }
2868
+ const { name: declaredName, declared, problems } = personaDeclarations(text);
2869
+ if (problems.length) {
2870
+ throw new CompileError(`${where} ${problems.join("; it ")}. core/personas/README.md gives the fields a persona may declare`);
2871
+ }
2872
+ const fields = PERSONA_FIELDS.filter((f) => declared[f] !== undefined);
2873
+ const name = declaredName ?? path.basename(rel, ".md");
2874
+ if (contribution.core && name !== path.basename(rel, ".md")) {
2875
+ throw new CompileError(`${where} declares name \`${name}\`, which differs from its filename and binding identity`);
2876
+ }
2877
+ if (!SLUG.test(name)) {
2878
+ throw new CompileError(`${where} is named \`${name}\`, which is not a slug, so no binding file can be named for it`);
2879
+ }
2880
+ const source = contribution.core ? rel : `${contribution.pack}'s ${rel}`;
2881
+ for (const field of fields) {
2882
+ for (const [id, host] of Object.entries(PERSONA_HOSTS)) {
2883
+ if (host[field] === null) degraded.push({ persona: name, field, host: id, line: `degraded ${name.padEnd(22)} ${field.padEnd(14)} ${id} (${host.label}): ${host.why}` });
2884
+ }
2885
+ }
2886
+ const bindingRel = `${bindingDir}/${name}.md`;
2887
+ if (owners.has(bindingRel)) {
2888
+ const sources = [owners.get(bindingRel), source].sort();
2889
+ throw new CompileError(`${bindingRel} has multiple persona owners: ${sources.join(" and ")}. Each binding must have one owner`);
2890
+ }
2891
+ owners.set(bindingRel, source);
2892
+ if (!treeRoot) {
2893
+ if (fields.length) lines.push(`persona ${name.padEnd(22)} UNBOUND — declares ${listed(fields)}, and this workspace declares no \`tree\` to hold its binding`);
2894
+ continue;
2895
+ }
2896
+ const binding = path.join(treeRoot, bindingDir, `${name}.md`);
2897
+ const shown = path.relative(workspaceRoot, binding).split(path.sep).join("/");
2898
+ let stat = null;
2899
+ try {
2900
+ stat = fs.lstatSync(binding);
2901
+ } catch (cause) {
2902
+ if (cause.code !== "ENOENT") throw new CompileError(`${shown} could not be examined — ${cause.code ?? cause.message}`);
2903
+ }
2904
+ if (stat === null) {
2905
+ if (fields.length) lines.push(`persona ${name.padEnd(22)} UNBOUND — declares ${listed(fields)}, and no host binding at \`${bindingRel}\` carries them`);
2906
+ continue;
2907
+ }
2908
+ if (!stat.isFile()) {
2909
+ throw new CompileError(`${shown} binds ${where} and is not a regular file, so nothing is compiled through it`);
2910
+ }
2911
+ try {
2912
+ if (!isInside(fs.realpathSync(treeRoot), fs.realpathSync(binding))) {
2913
+ throw new CompileError(`${shown} resolves outside the declared tree, so nothing is compiled through it`);
2914
+ }
2915
+ } catch (cause) {
2916
+ if (cause instanceof CompileError) throw cause;
2917
+ throw new CompileError(`${shown} could not be resolved against the declared tree — ${cause.code ?? cause.message}`);
2918
+ }
2919
+ let current;
2920
+ try { current = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(binding)); }
2921
+ catch (cause) { throw new CompileError(`${shown} could not be read as UTF-8 — ${cause.code ?? cause.message}`); }
2922
+ const next = boundAgent(current, declared, source, shown);
2923
+ const { fields: bindingFields, error } = parseFrontmatter(current);
2924
+ if (error || bindingFields?.name?.trim() !== name || !bindingFields?.tools?.trim()) {
2925
+ throw new CompileError(`${shown} needs matching name: ${name} and a tools allow-list in usable frontmatter before compilation`);
2926
+ }
2927
+ if (fields.length) lines.push(`persona ${name.padEnd(22)} ${listed(fields)} → ${shown} (${PERSONA_HOSTS["claude-code"].label})`);
2928
+ if (fields.length || next !== current) compared += 1;
2929
+ if (next !== current) files.push({ path: shown, file: binding, text: next, source });
2930
+ }
2931
+ }
2932
+ return { files, lines, degraded, compared };
2933
+ }
2934
+
2935
+ const unresolvedLine = (u) => `pack ${u.name.padEnd(30)} UNRESOLVED — ${u.why}; it contributes nothing`;
2936
+
2937
+ function printPersonaReport(say, personas) {
2938
+ if (!personas.lines.length && !personas.degraded.length) return;
2939
+ say();
2940
+ for (const line of [...personas.lines, ...personas.degraded.map((d) => d.line)]) say(` ${line}`);
2941
+ }
2942
+
2943
+ function emitPersonas(personas, { check, say }) {
2944
+ if (!personas) return 0;
2945
+ for (const line of personas.lines) say(line);
2946
+ for (const d of personas.degraded) say(d.line);
2947
+ let drifted = 0;
2948
+ for (const f of personas.files) {
2949
+ if (check) {
2950
+ say(`RED — ${f.path} has drifted from ${f.source}. Recompile.`);
2951
+ drifted += 1;
2952
+ } else {
2953
+ fs.writeFileSync(f.file, f.text);
2954
+ say(`wrote ${f.file}`);
2955
+ }
2956
+ }
2957
+ if (personas.lines.length || personas.degraded.length || personas.files.length) say();
2958
+ return drifted;
2959
+ }
2960
+
2331
2961
  // ---------------------------------------------------------------- the command line
2332
2962
 
2333
2963
  function printMatrix(say, parsed, columns, { source }) {
@@ -2391,7 +3021,7 @@ export function resolveWorkspace(named) {
2391
3021
  const manifestPath = path.join(dir, "workspace.json");
2392
3022
  let raw;
2393
3023
  try {
2394
- raw = fs.readFileSync(manifestPath, "utf8");
3024
+ raw = readOwnedText(manifestPath, dir);
2395
3025
  } catch (cause) {
2396
3026
  // As given, not resolved: `compile` prints paths built on it.
2397
3027
  if (cause.code === "ENOENT") return { workspaceRoot: named, workspaceDir: ".portulan" };
@@ -2412,6 +3042,7 @@ export function resolveWorkspace(named) {
2412
3042
  if (typeof manifest?.tree === "string" && manifest.tree.trim()) {
2413
3043
  const root = path.resolve(dir, manifest.tree);
2414
3044
  const inside = path.relative(root, dir);
3045
+ if(inside === "") return {workspaceRoot:root,workspaceDir:"."};
2415
3046
  // A `tree` that does not contain its workspace cannot be trusted, so the answer is the one that changes nothing.
2416
3047
  if (inside && !inside.startsWith("..") && !path.isAbsolute(inside)) {
2417
3048
  return { workspaceRoot: root, workspaceDir: inside.split(path.sep).join("/") };
@@ -2423,19 +3054,19 @@ export function resolveWorkspace(named) {
2423
3054
 
2424
3055
  function usage() {
2425
3056
  return [
2426
- "portulan compile — compile a workspace's gate policy into host enforcement",
3057
+ "portulan compile — compile workspace advice, guidance, bindings and declared gates",
2427
3058
  "",
2428
3059
  " portulan compile [--check] [--matrix] [--workspace <dir>] [--pack-root <dir>|auto]...",
2429
3060
  "",
2430
- " (no flag) compile the policy and the guidance, and write each artifact",
2431
- " --check write nothing; exit 1 if an artifact is out of date against the policy or the guidance",
3061
+ " (no flag) compile native advice/settings, guidance, bindings and declared gates",
3062
+ " --check write nothing; exit 1 if an artifact differs from its declarations",
2432
3063
  " --matrix print every rule against every backend, the gates neither compiles, and every",
2433
- " guidance unit against every host",
3064
+ " guidance unit against every host; report native settings without a policy",
2434
3065
  " --workspace the workspace directory to compile; defaults to `.portulan`",
2435
3066
  " --pack-root where declared packs are resolved from; `auto` discovers the host's plugin cache.",
2436
3067
  " A named root REPLACES every other source. A directory actually named `auto` is `./auto`",
2437
3068
  "",
2438
- "The compiler emits RESTRICTION only: `auto` and `propose` compile to nothing in the Claude Code",
3069
+ "Gate compilation emits RESTRICTION only: `auto` and `propose` compile to nothing in the Claude Code",
2439
3070
  "backend by design, and the partition inverts in the repository ruleset, which is the floor.",
2440
3071
  "",
2441
3072
  "Exit codes: 0 succeeded · 1 a red verdict · 2 could not run.",
@@ -2512,51 +3143,65 @@ export function run(argv, options = {}) {
2512
3143
  );
2513
3144
  }
2514
3145
  const guidance = guidanceUnits(workspaceRoot, workspaceDir);
2515
- const guidancePlan = showMatrix ? null : planGuidance(guidance, workspaceRoot);
3146
+ const guidancePlan = showMatrix ? null : planGuidance(guidance, workspaceRoot, workspaceDir);
3147
+ if (!check && guidancePlan) preflightGuidanceWrites(guidancePlan, workspaceRoot);
2516
3148
  const sessions = sessionsDeclaration(workspaceRoot, workspaceDir);
2517
3149
  const spend = spendDeclaration(workspaceRoot, workspaceDir);
3150
+ const stopPolicy = stopDeclaration(workspaceRoot, workspaceDir);
2518
3151
  const { file: policyFile, declared: policyDeclared, reason: policyReason } = policyDeclaration(workspaceRoot, workspaceDir);
2519
3152
  const packOptions = { named: namedRoots, discovery: () => discoverPackRoots(), forced };
2520
3153
  if (policyReason === "refused") {
2521
3154
  throw new CompileError(undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOptions, policyReason));
2522
3155
  }
2523
3156
  if (!policyDeclared && !fs.existsSync(policyFile)) {
2524
- // Only a manifest read and naming no policy clears leftovers: one that is missing has not been authored.
3157
+ // Only an authored manifest clears stray guidance.
2525
3158
  const leftover = guidance === null && policyReason === "no-key" && guidancePlan !== null && guidancePlan.stray.length > 0;
2526
- if (guidance === null && !leftover) {
2527
- throw new CompileError(undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOptions, policyReason));
2528
- }
2529
- say(`note ${undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOptions, policyReason, leftover ? "leftover" : true)}`);
2530
- if (sessions !== null) {
2531
- say(
2532
- `note \`sessions\` in ${sessions.manifest} compiled nothing: its host switches ride the settings a gate ` +
2533
- `policy compiles to, and this workspace has none`,
2534
- );
3159
+ let packs;
3160
+ try { packs = packContributions(workspaceRoot, workspaceDir, packOptions); }
3161
+ catch (cause) {
3162
+ if (guidance === null && !leftover) throw new CompileError(`${undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOptions, policyReason)}\n Persona planning was unavailable: ${cause.message}`);
3163
+ throw cause;
2535
3164
  }
2536
- if (spend !== null) {
2537
- say(
2538
- `note \`spend\` in ${spend.manifest} compiled nothing: what it declares rides the restart advisory's commands ` +
2539
- `in the settings a gate policy compiles to, and this workspace has none`,
2540
- );
3165
+ const contexts = contextCaps(packs.contributions, packs.unresolved);
3166
+ const authored = policyReason === "no-key" && ["repository", "demo", "portfolio"].includes(JSON.parse(readWorkspaceText(workspaceRoot, workspaceDir)).kind);
3167
+ const advice = authored ? claudeCode(null, {
3168
+ source: path.join(workspaceDir, "workspace.json").split(path.sep).join("/"),
3169
+ root: path.resolve(workspaceRoot), sessions, spend, contexts, packProvenance: packs.contributions,
3170
+ }) : null;
3171
+ const capPlan = capOnlyPlan(workspaceRoot, contexts, advice?.artifact.value ?? null);
3172
+ const personas = planPersonas(packs.contributions, workspaceRoot, workspaceDir);
3173
+ if (guidance === null && !leftover && personas.compared === 0 && !capPlan) {
3174
+ printPersonaReport(say, personas);
3175
+ for (const line of contextCapLines(contexts)) say(`note ${line}`);
3176
+ throw new CompileError(undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOptions, policyReason));
2541
3177
  }
3178
+ say(`note ${undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOptions, policyReason, advice ? "advisory" : capPlan ? "contexts" : leftover ? "leftover" : guidance === null ? "personas" : true)}`);
3179
+ if (advice) for (const line of advice.notes) say(`note ${line}`);
3180
+ else if (sessions !== null || spend !== null) say("note session and spend settings require an authored governing workspace or gate policy");
3181
+ if (stopPolicy !== null) say("note verify.stopTimeout compiled no outer hook timeout: this workspace has no gate policy");
2542
3182
  say();
3183
+ for (const u of packs.unresolved) say(unresolvedLine(u));
3184
+ if (packs.unresolved.length) say();
3185
+ if (!advice) for (const line of contextCapLines(contexts)) say(`note ${line}`);
2543
3186
  if (showMatrix) {
2544
- printGuidanceMatrix(say, guidance);
3187
+ if (guidance) printGuidanceMatrix(say, guidance);
3188
+ printPersonaReport(say, personas);
2545
3189
  return 0;
2546
3190
  }
2547
- const drifted = emitGuidance(guidance, guidancePlan, { workspaceRoot, check, say });
3191
+ const drifted = emitCapOnly(capPlan, { check, say }) + emitPersonas(personas, { check, say }) + emitGuidance(guidance, guidancePlan, { workspaceRoot, check, say });
2548
3192
  if (!check) return 0;
2549
3193
  if (drifted) return 1;
2550
- say("GREEN — every compiled guidance file matches its unit");
3194
+ say(`GREEN — every compiled guidance file matches its unit${personas.compared ? ", and every persona binding its persona" : ""}${capPlan ? ", and native settings match their declarations" : ""}`);
2551
3195
  return 0;
2552
3196
  }
2553
- const policy = readJson(policyFile, "the gate policy");
3197
+ const policy = readJson(policyFile, "the gate policy", path.join(workspaceRoot, workspaceDir));
2554
3198
  // A thunk, so the host's plugin record is read only where discovery can win.
2555
3199
  const { contributions, unresolved, plan } = packContributions(workspaceRoot, workspaceDir, {
2556
3200
  named: namedRoots,
2557
3201
  discovery: () => discoverPackRoots(),
2558
3202
  forced,
2559
3203
  });
3204
+ const personas = planPersonas(contributions, workspaceRoot, workspaceDir);
2560
3205
  // Only under `--matrix`, as it moves with what is installed; but a union is never silent.
2561
3206
  if (plan && (showMatrix || plan.source === "union")) say(`packs: resolution root ${plan.source} — ${plan.why}`);
2562
3207
  const composed = composeFragments(policy, contributions);
@@ -2569,23 +3214,25 @@ export function run(argv, options = {}) {
2569
3214
  packProvenance: contributions,
2570
3215
  sessions,
2571
3216
  spend,
3217
+ stopPolicy,
3218
+ contexts: contextCaps(contributions, unresolved),
2572
3219
  });
2573
3220
 
2574
3221
  for (const a of composed.added) say(`pack ${a.pack.padEnd(30)} adds \`${a.id}\` (${a.tier})`);
2575
3222
  for (const t of composed.tightened) {
2576
3223
  say(`pack ${t.pack.padEnd(30)} tightens \`${t.id}\` ${t.from} → ${t.to}`);
2577
3224
  }
2578
- for (const u of unresolved) {
2579
- say(`pack ${u.name.padEnd(30)} UNRESOLVED — ${u.why}; it contributes nothing`);
2580
- }
3225
+ for (const u of unresolved) say(unresolvedLine(u));
2581
3226
  if (composed.added.length || composed.tightened.length || unresolved.length) say();
2582
3227
 
2583
3228
  if (showMatrix) {
2584
3229
  printMatrix(say, parsed, columns, { source });
3230
+ for (const line of contextCapLines(contextCaps(contributions, unresolved))) say(`note ${line}`);
2585
3231
  if (guidance) {
2586
3232
  say();
2587
3233
  printGuidanceMatrix(say, guidance);
2588
3234
  }
3235
+ printPersonaReport(say, personas);
2589
3236
  return 0;
2590
3237
  }
2591
3238
 
@@ -2642,9 +3289,12 @@ export function run(argv, options = {}) {
2642
3289
  drifted += 1;
2643
3290
  }
2644
3291
  }
3292
+ drifted += emitPersonas(personas, { check, say });
2645
3293
  drifted += emitGuidance(guidance, guidancePlan, { workspaceRoot, check, say });
2646
3294
  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");
3295
+ const bindings = personas.compared ? ", and every persona binding its persona" : "";
3296
+ const guided = bindings ? `, every guidance file its unit${bindings}` : ", and every guidance file its unit";
3297
+ say(`GREEN — every emitted artifact matches the policy${guidance ? guided : bindings}`);
2648
3298
  return 0;
2649
3299
  }
2650
3300
 
@@ -2663,6 +3313,7 @@ export function run(argv, options = {}) {
2663
3313
  fs.writeFileSync(file, column.artifact.text);
2664
3314
  say(`wrote ${file}`);
2665
3315
  }
3316
+ emitPersonas(personas, { check, say });
2666
3317
  emitGuidance(guidance, guidancePlan, { workspaceRoot, check, say });
2667
3318
  return 0;
2668
3319
  } catch (error) {