@sleepy_panda_srl/portulan 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +15 -4
  2. package/cli/README.md +55 -3
  3. package/cli/advisory.mjs +167 -53
  4. package/cli/cache-risk.mjs +69 -0
  5. package/cli/comments.mjs +8 -5
  6. package/cli/compile.mjs +492 -146
  7. package/cli/context.mjs +204 -43
  8. package/cli/doctor.mjs +211 -33
  9. package/cli/drills.mjs +32 -7
  10. package/cli/finish.md +34 -0
  11. package/cli/finish.mjs +8 -67
  12. package/cli/form.mjs +34 -18
  13. package/cli/frontmatter.mjs +39 -0
  14. package/cli/gate.mjs +14 -12
  15. package/cli/guidance-files.mjs +335 -0
  16. package/cli/handoff-policy.mjs +76 -0
  17. package/cli/html-entities.mjs +2130 -0
  18. package/cli/import-files.mjs +15 -0
  19. package/cli/index.mjs +77 -24
  20. package/cli/init-preview.mjs +553 -0
  21. package/cli/init.mjs +261 -46
  22. package/cli/instructions-journal.mjs +1185 -0
  23. package/cli/instructions.mjs +332 -57
  24. package/cli/ledger.mjs +123 -37
  25. package/cli/librarian.mjs +103 -19
  26. package/cli/persona-routing.mjs +209 -0
  27. package/cli/plugin-lint.mjs +77 -56
  28. package/cli/recipe-output.mjs +91 -0
  29. package/cli/recipe-runner.md +68 -0
  30. package/cli/recipe-runner.mjs +131 -0
  31. package/cli/recipe-set.mjs +12 -3
  32. package/cli/ritual-contexts.mjs +103 -0
  33. package/cli/sessions.mjs +2 -1
  34. package/cli/spend-analysis.mjs +111 -0
  35. package/cli/spend-offer.md +53 -0
  36. package/cli/spend-offer.mjs +93 -0
  37. package/cli/stop-diagnostics.mjs +114 -0
  38. package/cli/stop-gate.mjs +133 -90
  39. package/cli/stop-policy.mjs +14 -0
  40. package/cli/symbols.mjs +55 -8
  41. package/cli/telemetry.mjs +19 -12
  42. package/cli/upgrade.mjs +62 -44
  43. package/cli/vendor.mjs +207 -125
  44. package/core/operating/context.md +368 -60
  45. package/core/operating/evolution.md +3 -2
  46. package/core/operating/loop.md +9 -4
  47. package/core/operating/sessions.md +6 -15
  48. package/core/personas/README.md +52 -10
  49. package/core/personas/implementer.md +3 -0
  50. package/core/personas/librarian.md +3 -0
  51. package/core/personas/reviewer.md +3 -0
  52. package/core/templates/handoff.md +6 -2
  53. package/evals/ab/personas/README.md +111 -0
  54. package/evals/ab/personas/acquisition.json +330 -0
  55. package/evals/ab/personas/bindings/implementer.txt +43 -0
  56. package/evals/ab/personas/bindings/librarian.txt +40 -0
  57. package/evals/ab/personas/bindings/reviewer.txt +41 -0
  58. package/evals/ab/personas/corpus.json +69 -0
  59. package/evals/ab/personas/grader.mjs +171 -0
  60. package/evals/ab/personas/implementer.json +774 -0
  61. package/evals/ab/personas/implementer.md +22 -0
  62. package/evals/ab/personas/librarian.json +758 -0
  63. package/evals/ab/personas/librarian.md +22 -0
  64. package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
  65. package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
  66. package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
  67. package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
  68. package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
  69. package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
  70. package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
  71. package/evals/ab/personas/reviewer.json +758 -0
  72. package/evals/ab/personas/reviewer.md +22 -0
  73. package/evals/releases/0.4.0.json +154 -0
  74. package/evals/releases/0.4.0.md +69 -0
  75. package/package.json +6 -2
  76. package/packs/rituals/checkpoints/README.md +35 -0
  77. package/packs/rituals/checkpoints/pack.json +3 -2
  78. package/packs/tools/local-llm/README.md +46 -6
  79. package/packs/tools/local-llm/runner/local-llm.mjs +127 -13
  80. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +26 -2
  81. package/spec/README.md +37 -4
  82. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
  83. package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
  84. package/spec/migrations/README.md +6 -0
  85. package/spec/pack.schema.json +12 -3
  86. package/spec/slots.md +262 -117
  87. package/spec/workspace.schema.json +67 -11
package/cli/compile.mjs CHANGED
@@ -6,21 +6,22 @@
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
27
  export const KNOWN_GATE_POLICY_SPECS = new Set(["2.1", "2.2", "2.3"]);
@@ -536,6 +537,8 @@ export const HOOK_RUNNERS = ["gate.mjs", "stop-gate.mjs", "advisory.mjs"];
536
537
  * `ask` for gated, a prompt that blocks headless. Two layers, since on Claude Code 2.1.220 a crashing hook fails open.
537
538
  */
538
539
  export function claudeCode(parsed, options = {}) {
540
+ const hasPolicy = parsed !== null;
541
+ const rules = hasPolicy ? parsed.rules : [];
539
542
  const source = options.source ?? ".portulan/gates.json";
540
543
  const runnerDir = path.dirname(fileURLToPath(import.meta.url));
541
544
  const project = options.root ?? process.cwd();
@@ -549,8 +552,8 @@ export function claudeCode(parsed, options = {}) {
549
552
  pinned.push({ file, abs });
550
553
  return `"${abs}"`;
551
554
  };
552
- const runner = options.runner ?? spell(HOOK_RUNNERS[0]);
553
- 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);
554
557
  const advisoryRunner = options.advisoryRunner ?? spell(HOOK_RUNNERS[2]);
555
558
 
556
559
  const compiled = [];
@@ -561,7 +564,7 @@ export function claudeCode(parsed, options = {}) {
561
564
  const shellWriteGates = [];
562
565
  const editCoveredGates = [];
563
566
 
564
- for (const rule of parsed.rules) {
567
+ for (const rule of rules) {
565
568
  if (!HOST_GATE_TIERS.has(rule.tier)) {
566
569
  refused.push({ id: rule.id, tier: rule.tier, why: HOST_TIER_NOT_A_GATE[rule.tier] });
567
570
  continue;
@@ -609,16 +612,17 @@ export function claudeCode(parsed, options = {}) {
609
612
  compiled.push({ id: rule.id, tier: rule.tier, surface: [...emitted, ...hookOnly].join(" · ") });
610
613
  }
611
614
 
612
- 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) {
613
616
  throw new CompileError(
614
617
  "every gate in this policy refused to compile — refusing to write an artifact that enforces nothing while the policy claims gates",
615
618
  );
616
619
  }
617
620
 
618
- // 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.
619
622
  const packs = (options.packProvenance ?? [])
620
623
  .map((c) => ({ pack: c.pack, origin: c.origin, version: c.version ?? null }))
621
624
  .sort((a, b) => a.pack.localeCompare(b.pack));
625
+ const contexts = options.contexts ?? contextCaps([]);
622
626
  const sessions = options.sessions ?? null;
623
627
  const switches = {};
624
628
  if (sessions?.git_instructions !== undefined) switches.includeGitInstructions = sessions.git_instructions;
@@ -628,48 +632,61 @@ export function claudeCode(parsed, options = {}) {
628
632
  const figures = [];
629
633
  if (spend?.multipliers) figures.push("--read", spend.multipliers.read, "--write-5m", spend.multipliers.write["5m"], "--write-1h", spend.multipliers.write["1h"]);
630
634
  if (spend?.horizon) figures.push("--horizon", spend.horizon);
635
+ if (spend?.resume != null) figures.push("--resume-tokens", spend.resume);
631
636
  const spendFlags = figures.map((f) => ` ${f}`).join("");
632
637
  const restartBlock = spend?.restart === "block";
633
638
  const spendFrom = figures.length || restartBlock ? spend.manifest : null;
634
- const declaredIn =
639
+ const figuresIn =
635
640
  sessionsFrom && sessionsFrom === spendFrom
636
641
  ? `\`sessions\` and \`spend\` in ${sessionsFrom}`
637
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 ");
638
651
  const value = {
639
652
  $portulan: {
640
653
  generated: "cli/compile.mjs",
641
654
  source,
642
655
  ...(sessionsFrom ? { sessions: sessionsFrom } : {}),
643
656
  ...(spendFrom ? { spend: spendFrom } : {}),
657
+ ...(stopPolicy ? { verify: stopPolicy.manifest } : {}),
644
658
  ...(packs.length ? { packs } : {}),
659
+ ...(contexts.limit !== null ? { contexts: contexts.sources } : {}),
645
660
  warning: declaredIn
646
661
  ? `Generated file. Edit ${source}, or ${declaredIn}, and recompile; \`verify/compile.sh\` fails on drift.`
647
662
  : `Generated file. Edit ${source} and recompile; \`verify/compile.sh\` fails on drift.`,
648
663
  },
649
- permissions: { deny, ask, allow: [] },
664
+ ...(hasPolicy ? { permissions: { deny, ask, allow: [] } } : {}),
650
665
  hooks: {
651
- // Each command is `node` and a quoted path: no pipe, redirection or separator, where a quoting fail-open would live.
652
- PreToolUse: [...matchers].sort().map((matcher) => ({
653
- matcher,
654
- hooks: [{ type: "command", command: `node ${runner}` }],
655
- })),
656
- Stop: [
657
- {
658
- hooks: [
659
- { type: "command", command: `node ${stopRunner}` },
660
- ...(restartBlock ? [{ type: "command", command: `node ${advisoryRunner} stop${spendFlags}` }] : []),
661
- ],
662
- },
663
- ],
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
+ ] }] } : {}),
664
676
  // No matcher means every tool; a non-blocking `Stop` hook's output would reach only the host's debug log.
665
677
  PostToolUse: [{ hooks: [{ type: "command", command: `node ${advisoryRunner} tool${spendFlags}` }] }],
666
678
  UserPromptSubmit: [{ hooks: [{ type: "command", command: `node ${advisoryRunner} prompt${spendFlags}` }] }],
667
679
  },
668
680
  statusLine: { type: "command", command: `node ${advisoryRunner} status${spendFlags}` },
669
681
  ...switches,
682
+ ...(contexts.limit !== null ? { env: { [CONTEXT_CAP_ENV]: String(contexts.limit) } } : {}),
670
683
  };
671
684
 
672
- 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
+ );
673
690
  if (pinned.length) {
674
691
  notes.push(
675
692
  `${pinned.length} hook(s) are pinned to an ABSOLUTE path on this machine — ` +
@@ -719,11 +736,12 @@ export function claudeCode(parsed, options = {}) {
719
736
  `commands carry them, so an edit to \`spend\` is drift until recompiled`,
720
737
  );
721
738
  }
739
+ if (spend?.resume != null) notes.push(`restart resume expense: ${spend.resume} input-equivalent tokens, excluding the fresh-context write and baseline reads`);
722
740
  if (restartBlock) {
723
741
  notes.push(
724
- `the restart advisory also holds a turn's end (\`spend.restart\` "block"): at the first stop at or past the restart ` +
725
- `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 ` +
726
- `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`,
727
745
  );
728
746
  }
729
747
  if (editCoveredGates.length) {
@@ -991,7 +1009,7 @@ export const GUIDANCE_HOSTS = {
991
1009
  "claude-code": {
992
1010
  label: "Claude Code",
993
1011
  always: `an unscoped rule in \`${GUIDANCE_RULES_DIR}/\``,
994
- "on-path": "a rule scoped by `paths:`",
1012
+ "on-path": "a paths-scoped rule or a declared nested CLAUDE.md",
995
1013
  "on-invoke": `a project skill in \`${SKILLS_DIR}/\``,
996
1014
  "on-read": `a line in \`${GUIDANCE_RULES_DIR}/${ON_READ_INDEX}\``,
997
1015
  },
@@ -1032,11 +1050,148 @@ const STRAY_IMPORT = {
1032
1050
  "on-read": "an on-read file is opened as it stands, so the import would load nothing",
1033
1051
  };
1034
1052
 
1035
- /**
1036
- * The `@` imports Claude Code 2.1.281 finds: none in a fence, nor in a code span or HTML comment outside a list
1037
- * item's text, which it reads raw. Misread: a list in a block quote, and a line its lexer keeps out of an item.
1038
- */
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. */
1039
1193
  export function importSpans(text) {
1194
+ importSyntax(text);
1040
1195
  const found = [];
1041
1196
  let fence = null;
1042
1197
  let comment = false;
@@ -1063,7 +1218,7 @@ export function importSpans(text) {
1063
1218
  } else if (item && (listed || /^ {0,3}\S/.test(line))) {
1064
1219
  listed = flowing = true;
1065
1220
  raw = !opensInItem.test(line.slice(item[0].length));
1066
- } else if (listed && (/^\s/.test(line) || (flowing && !OPENS_BLOCK.test(line)))) {
1221
+ } else if (listed && (/^\s/.test(line) || (flowing && !opensBlock(line)))) {
1067
1222
  flowing = true;
1068
1223
  raw = !opensInItem.test(line.trimStart());
1069
1224
  } else {
@@ -1161,8 +1316,13 @@ function checkedImports(text, dir, root, where) {
1161
1316
  return own;
1162
1317
  }
1163
1318
 
1164
- /** An unindented line that ends a list, as CommonMark reads it; a block tag such as `<div>` is read as a lazy line. */
1165
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
+ }
1166
1326
 
1167
1327
  /** Each item's first sentence, byte for byte, from the first list in `file` or under its `#fragment` heading. */
1168
1328
  function leadsOf(file, where, fragment = null, name = path.basename(file)) {
@@ -1200,7 +1360,7 @@ export function leadsOfText(source, name, where) {
1200
1360
  } else if (kind !== null && /^\s+\S/.test(line)) {
1201
1361
  items.at(-1).text += ` ${line.trim()}`;
1202
1362
  } else if (kind !== null && line.trim() !== "") {
1203
- if (under && !OPENS_BLOCK.test(line)) {
1363
+ if (under && !opensBlock(line)) {
1204
1364
  throw new CompileError(
1205
1365
  `${where}: line ${at + 1} of ${name} follows an item of its first list with no blank line and no indent, ` +
1206
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`,
@@ -1252,7 +1412,7 @@ function gatesOf(file, where, packs = []) {
1252
1412
  }
1253
1413
 
1254
1414
  /** The unit's body with each leads, engine and gates line written out; a leads path may end `#<heading>`. */
1255
- function expandedBody(unit, dir, root, packs = []) {
1415
+ export function expandedBody(unit, dir, root, packs = [], { originRoot = root } = {}) {
1256
1416
  unit.leadSources = [];
1257
1417
  unit.written = new Set();
1258
1418
  return unit.body
@@ -1262,11 +1422,13 @@ function expandedBody(unit, dir, root, packs = []) {
1262
1422
  const engine = leads ? null : ENGINE_LINE.exec(line);
1263
1423
  const gates = leads || engine ? null : GATES_LINE.exec(line);
1264
1424
  if (!leads && !engine && !gates) return [line];
1265
- if (engine) return engineLeads(unit, engine[1], root);
1425
+ if (engine) return engineLeads(unit, engine[1], originRoot);
1266
1426
  const [what, spelled] = leads ? ["leads", leads[1]] : ["gates", gates[1]];
1267
1427
  const hash = leads ? spelled.indexOf("#") : -1;
1268
1428
  const [named, fragment] = hash === -1 ? [spelled, null] : [spelled.slice(0, hash), spelled.slice(hash + 1)];
1269
- 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);
1270
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`);
1271
1433
  let real;
1272
1434
  try {
@@ -1307,7 +1469,7 @@ function engineLeads(unit, spelled, root) {
1307
1469
  return leadsOf(real, unit.source, fragment, shown).map((lead) => (own ? lead.replaceAll(PLUGIN_ROOT, "") : lead));
1308
1470
  }
1309
1471
 
1310
- const UNIT_KEYS = new Set(["tier", "paths", "description"]);
1472
+ const UNIT_KEYS = new Set(["tier", "paths", "description", "target"]);
1311
1473
 
1312
1474
  /** A line break, by any spelling a host might honour, or another control character. */
1313
1475
  const CONTROL = /[\u0000-\u001f\u007f\u0085\u2028\u2029]/;
@@ -1340,7 +1502,7 @@ function unitFrontmatter(lines, where) {
1340
1502
  if (!match) throw new CompileError(`${where}: frontmatter line ${i + 2} is not \`key: value\` — ${JSON.stringify(line)}`);
1341
1503
  const [, key, rest = ""] = match;
1342
1504
  if (!UNIT_KEYS.has(key)) {
1343
- 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)`);
1344
1506
  }
1345
1507
  if (Object.hasOwn(fields, key)) throw new CompileError(`${where}: \`${key}\` is declared twice`);
1346
1508
  if (key !== "paths") {
@@ -1389,8 +1551,14 @@ export function parseUnit(name, text, source = `${name}.md`) {
1389
1551
  if (!LOAD_TIERS.includes(tier)) {
1390
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(", ")}`);
1391
1553
  }
1392
- let paths = null;
1393
- 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) {
1394
1562
  if (fields.paths === undefined || fields.paths.length === 0) {
1395
1563
  throw new CompileError(`${where}: an on-path unit names the \`paths\` that load it, and this one names none`);
1396
1564
  }
@@ -1403,7 +1571,7 @@ export function parseUnit(name, text, source = `${name}.md`) {
1403
1571
  }
1404
1572
  }
1405
1573
  paths = fields.paths;
1406
- } else if (fields.paths !== undefined) {
1574
+ } else if (tier !== "on-path" && fields.paths !== undefined) {
1407
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`);
1408
1576
  }
1409
1577
  let description = null;
@@ -1436,12 +1604,13 @@ export function parseUnit(name, text, source = `${name}.md`) {
1436
1604
  } else if (rest[0] === BOOT_CARD_LINE) {
1437
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`);
1438
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`);
1439
1608
  const written = [["leads", LEADS_LINE], ["engine", ENGINE_LINE], ["gates", GATES_LINE]].find(([, line]) => rest.some((text) => line.test(text)));
1440
1609
  if (tier !== "always" && written) {
1441
1610
  const [which] = written;
1442
1611
  throw new CompileError(`${where}: a \`<!-- ${which}: … -->\` line is written out only in an always unit, and this one is \`${tier}\``);
1443
1612
  }
1444
- 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") };
1445
1614
  }
1446
1615
 
1447
1616
  /** The declared `slots.context`, or null where none is declared or there is no manifest; one that will not parse throws. */
@@ -1456,7 +1625,7 @@ export function guidanceDeclaration(workspaceRoot, workspaceDir = ".portulan") {
1456
1625
  }
1457
1626
  let manifest;
1458
1627
  try {
1459
- manifest = JSON.parse(fs.readFileSync(path.join(base, "workspace.json"), "utf8"));
1628
+ manifest = JSON.parse(readWorkspaceText(workspaceRoot, workspaceDir));
1460
1629
  } catch {
1461
1630
  return null;
1462
1631
  }
@@ -1477,11 +1646,12 @@ export function guidanceDeclaration(workspaceRoot, workspaceDir = ".portulan") {
1477
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/\``,
1478
1647
  );
1479
1648
  }
1480
- 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") : [] };
1481
1651
  }
1482
1652
 
1483
1653
  /** Every unit, read and checked, or null where no `slots.context` is declared. */
1484
- export function guidanceUnits(workspaceRoot, workspaceDir = ".portulan") {
1654
+ export function guidanceUnits(workspaceRoot, workspaceDir = ".portulan", { originRoot = workspaceRoot } = {}) {
1485
1655
  const declared = guidanceDeclaration(workspaceRoot, workspaceDir);
1486
1656
  if (declared === null) return null;
1487
1657
  let entries;
@@ -1505,6 +1675,7 @@ export function guidanceUnits(workspaceRoot, workspaceDir = ".portulan") {
1505
1675
  }
1506
1676
  });
1507
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`);
1508
1679
  const names = entries
1509
1680
  .filter((e) => e.name.endsWith(".md") && (e.isFile() || e.isSymbolicLink()))
1510
1681
  .map((e) => e.name)
@@ -1524,20 +1695,22 @@ export function guidanceUnits(workspaceRoot, workspaceDir = ".portulan") {
1524
1695
  }
1525
1696
  let text;
1526
1697
  try {
1527
- 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);
1528
1700
  } catch (cause) {
1529
1701
  throw new CompileError(`${source} could not be read — ${cause.code ?? cause.message}`);
1530
1702
  }
1531
1703
  const unit = parseUnit(path.basename(file, ".md"), text, source);
1704
+ unit.ownedSource = path.relative(fs.realpathSync(workspaceRoot),real).split(path.sep).join("/");
1532
1705
  if (unit.tier === "always") {
1533
1706
  unit.dir = path.dirname(full);
1534
- unit.text = expandedBody(unit, unit.dir, path.resolve(workspaceRoot), declared.packs);
1707
+ unit.text = expandedBody(unit, unit.dir, path.resolve(workspaceRoot), declared.packs, { originRoot });
1535
1708
  unit.imports = checkedImports(unit.text, unit.dir, path.resolve(workspaceRoot), source);
1536
1709
  } else {
1537
1710
  for (const { target } of importSpans(unit.body)) {
1538
1711
  const bare = importPath(target);
1539
1712
  if (bare === null || bare.startsWith("~") || path.isAbsolute(bare)) continue;
1540
- if (fs.statSync(path.resolve(path.dirname(full), bare), { throwIfNoEntry: false })?.isFile()) {
1713
+ if (importTargetIsFile(path.resolve(path.dirname(full), bare))) {
1541
1714
  throw new CompileError(
1542
1715
  `${source}: \`@${target}\` names a file, and only an always unit may import one — ${STRAY_IMPORT[unit.tier]}. ` +
1543
1716
  `Name the file to open instead, in a code span`,
@@ -1547,7 +1720,12 @@ export function guidanceUnits(workspaceRoot, workspaceDir = ".portulan") {
1547
1720
  }
1548
1721
  units.push(unit);
1549
1722
  }
1550
- 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 };
1551
1729
  }
1552
1730
 
1553
1731
  function rebasedText(unit, root, to) {
@@ -1576,6 +1754,8 @@ export function claudeCodeGuidance(guidance) {
1576
1754
  if (unit.tier === "always") {
1577
1755
  const at = `${GUIDANCE_RULES_DIR}/${unit.name}.md`;
1578
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});
1579
1759
  } else if (unit.tier === "on-path") {
1580
1760
  const header = ["---", "paths:", ...unit.paths.map((glob) => ` - ${quoted(glob)}`), "---", ""].join("\n");
1581
1761
  files.push({ unit, path: `${GUIDANCE_RULES_DIR}/${unit.name}.md`, text: `${header}\n${unit.body}` });
@@ -1662,12 +1842,15 @@ function listReal(workspaceRoot, rel) {
1662
1842
  }
1663
1843
 
1664
1844
  /** Judged before either half writes, so a refusal leaves no gate artifact written beside it. */
1665
- function planGuidance(guidance, workspaceRoot) {
1845
+ export function planGuidance(guidance, workspaceRoot, workspaceDir = ".portulan") {
1846
+ assertSourceJournalReady(workspaceRoot,workspaceDir);
1666
1847
  const files = guidance ? claudeCodeGuidance(guidance).files : [];
1848
+ const nested = nestedPlan(workspaceRoot,guidance,files,guidance?.workspace ?? workspaceDir);
1667
1849
  const rules = files.filter((f) => f.path.startsWith(`${GUIDANCE_RULES_DIR}/`));
1668
1850
  const owedFiles = rules.length
1669
1851
  ? [{ unit: null, marker: true, path: `${GUIDANCE_RULES_DIR}/${RULES_MARKER}`, text: markerText(rules.map((f) => path.posix.basename(f.path))) }, ...files]
1670
1852
  : files;
1853
+ if (nested.control) owedFiles.push(nested.control);
1671
1854
  const owed = new Set(owedFiles.map((f) => f.path));
1672
1855
 
1673
1856
  for (const file of owedFiles) {
@@ -1709,11 +1892,11 @@ function planGuidance(guidance, workspaceRoot) {
1709
1892
  // A run stopped before its marker leaves exactly its unit's output; anything else was written by hand.
1710
1893
  let current;
1711
1894
  try {
1712
- current = fs.readFileSync(path.join(rulesDir, name), "utf8");
1895
+ current = fs.readFileSync(path.join(rulesDir, name));
1713
1896
  } catch (cause) {
1714
1897
  throw new CompileError(`${file.path} could not be read — ${cause.code ?? cause.message}`);
1715
1898
  }
1716
- if (current !== file.text) {
1899
+ if (!sameContent(current,file.text)) {
1717
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`);
1718
1901
  }
1719
1902
  }
@@ -1742,7 +1925,7 @@ function planGuidance(guidance, workspaceRoot) {
1742
1925
  if (listed instanceof Set || rules.length) {
1743
1926
  for (const entry of entries) {
1744
1927
  const rel = `${GUIDANCE_RULES_DIR}/${entry.name}`;
1745
- if (owed.has(rel)) continue;
1928
+ if (owed.has(rel) || nested.claimed.has(rel)) continue;
1746
1929
  const ours = listed instanceof Set && entry.isFile() && (listed.has(entry.name) || entry.name === RULES_MARKER);
1747
1930
  stray.push({ path: rel, removable: ours });
1748
1931
  }
@@ -1766,7 +1949,20 @@ function planGuidance(guidance, workspaceRoot) {
1766
1949
  const kept = listed instanceof Set || rules.length
1767
1950
  ? rules.map((f) => path.posix.basename(f.path)).filter((name) => listed instanceof Set && listed.has(name) && present.has(name))
1768
1951
  : null;
1769
- 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
+ }
1770
1966
  }
1771
1967
 
1772
1968
  function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
@@ -1774,7 +1970,7 @@ function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
1774
1970
  if (guidance === null && stray.length === 0) return 0;
1775
1971
 
1776
1972
  if (guidance) {
1777
- const files = owedFiles.filter((f) => !f.marker);
1973
+ const files = owedFiles.filter((f) => !f.marker && !f.control);
1778
1974
  const counts = LOAD_TIERS.map((tier) => `${guidance.units.filter((u) => u.tier === tier).length} ${tier}`).join(", ");
1779
1975
  say(`guidance: ${guidance.units.length} unit(s) in ${guidance.source} — ${counts}`);
1780
1976
  for (const [id, host] of Object.entries(GUIDANCE_HOSTS)) {
@@ -1802,17 +1998,19 @@ function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
1802
1998
  const target = path.join(workspaceRoot, ...file.path.split("/"));
1803
1999
  let current = null;
1804
2000
  try {
1805
- current = fs.readFileSync(target, "utf8");
2001
+ current = fs.readFileSync(target);
1806
2002
  } catch {
1807
2003
  say(file.marker
1808
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.`
1809
2006
  : `RED — ${target} does not exist; ${file.unit ? file.unit.source : "an on-read unit"} compiles to it`);
1810
2007
  drifted += 1;
1811
2008
  continue;
1812
2009
  }
1813
- if (current !== file.text) {
2010
+ if (!sameContent(current,file.text)) {
1814
2011
  say(file.marker
1815
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.`
1816
2014
  : file.unit?.leadSources?.length
1817
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.`
1818
2016
  : `RED — ${target} has drifted from ${file.unit ? file.unit.source : `the on-read units in ${guidance.source}`}. Edit the unit, then recompile.`);
@@ -1829,6 +2027,8 @@ function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
1829
2027
  return drifted;
1830
2028
  }
1831
2029
 
2030
+ preflightGuidanceWrites(plan, workspaceRoot);
2031
+ applyNestedEdits(workspaceRoot,plan.nested.edits);
1832
2032
  // Listed only once written, unlisted before removal: an interrupted run never lists a file it did not write.
1833
2033
  const markerPath = path.join(workspaceRoot, ...GUIDANCE_RULES_DIR.split("/"), RULES_MARKER);
1834
2034
  if (kept !== null) {
@@ -1837,7 +2037,7 @@ function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
1837
2037
  }
1838
2038
  for (const s of stray) {
1839
2039
  const target = path.join(workspaceRoot, ...s.path.split("/"));
1840
- if (target === markerPath) continue;
2040
+ if (target === markerPath || s.nested) continue;
1841
2041
  if (!s.removable) {
1842
2042
  say(`left ${target} — it is not a file this compiler wrote, so it is not this compiler's to remove`);
1843
2043
  continue;
@@ -1853,7 +2053,7 @@ function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
1853
2053
  }
1854
2054
  }
1855
2055
  for (const file of owedFiles) {
1856
- if (file.marker) continue;
2056
+ if (file.marker || file.nested || file.control) continue;
1857
2057
  const target = path.join(workspaceRoot, ...file.path.split("/"));
1858
2058
  fs.mkdirSync(path.dirname(target), { recursive: true });
1859
2059
  fs.writeFileSync(target, file.text);
@@ -1878,10 +2078,11 @@ function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
1878
2078
  }
1879
2079
 
1880
2080
  /** The guidance half alone: the gate policy is not read, so host settings stay the output of a `compile` a human runs. */
1881
- export function compileGuidance(named, { check = false, say = () => {} } = {}) {
2081
+ export function compileGuidance(named, { check = false, say = () => {}, originRoot } = {}) {
1882
2082
  const { workspaceRoot, workspaceDir } = resolveWorkspace(named);
1883
- const guidance = guidanceUnits(workspaceRoot, workspaceDir);
1884
- 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);
1885
2086
  return { declared: guidance !== null, drifted: emitGuidance(guidance, plan, { workspaceRoot, check, say }) };
1886
2087
  }
1887
2088
 
@@ -1889,25 +2090,37 @@ export function compileGuidance(named, { check = false, say = () => {} } = {}) {
1889
2090
  export function guidanceEdits(named) {
1890
2091
  const { workspaceRoot, workspaceDir } = resolveWorkspace(named);
1891
2092
  const guidance = guidanceUnits(workspaceRoot, workspaceDir);
1892
- const { owedFiles, stray, kept } = planGuidance(guidance, workspaceRoot);
2093
+ const { owedFiles, stray, kept, nested } = planGuidance(guidance, workspaceRoot, workspaceDir);
1893
2094
  const markerRel = `${GUIDANCE_RULES_DIR}/${RULES_MARKER}`;
1894
2095
  const current = (rel) => {
1895
2096
  try {
1896
- return fs.readFileSync(path.join(workspaceRoot, ...rel.split("/")), "utf8");
2097
+ return fs.readFileSync(path.join(workspaceRoot, ...rel.split("/")));
1897
2098
  } catch (cause) {
1898
2099
  if (cause.code === "ENOENT") return null;
1899
2100
  throw new CompileError(`${rel} could not be read — ${cause.code ?? cause.message}`);
1900
2101
  }
1901
2102
  };
1902
- const edits = [];
1903
- for (const s of stray) if (s.removable && s.path !== markerRel) edits.push({ file: s.path, next: null });
1904
- 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 });
1905
2106
  const marker = owedFiles.find((f) => f.marker);
1906
- 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 });
1907
2108
  else if (!marker && kept !== null && current(markerRel) !== null) edits.push({ file: markerRel, next: null });
1908
2109
  return { root: path.resolve(workspaceRoot), edits, left: stray.filter((s) => !s.removable).map((s) => s.path) };
1909
2110
  }
1910
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
+
1911
2124
  // ---------------------------------------------------------------- the manifest's declarations
1912
2125
 
1913
2126
  /** `{ file, why }` for a manifest that is there and will not parse as an object; null when it reads or is absent. */
@@ -1915,7 +2128,7 @@ export function unreadableManifest(workspaceRoot, workspaceDir = ".portulan") {
1915
2128
  const file = path.join(workspaceRoot, workspaceDir, "workspace.json");
1916
2129
  let raw;
1917
2130
  try {
1918
- raw = fs.readFileSync(file, "utf8");
2131
+ raw = readWorkspaceText(workspaceRoot, workspaceDir);
1919
2132
  } catch (cause) {
1920
2133
  if (cause.code === "ENOENT") return null;
1921
2134
  return { file, why: `it could not be read — ${cause.code ?? cause.message}` };
@@ -1937,7 +2150,7 @@ export function policyDeclaration(workspaceRoot, workspaceDir = ".portulan") {
1937
2150
  const fallback = (reason) => ({ file: path.join(workspaceRoot, workspaceDir, "gates.json"), declared: false, reason });
1938
2151
  let declared;
1939
2152
  try {
1940
- declared = JSON.parse(fs.readFileSync(manifest, "utf8")).gates;
2153
+ declared = JSON.parse(readWorkspaceText(workspaceRoot, workspaceDir)).gates;
1941
2154
  } catch {
1942
2155
  // An unreadable manifest reaches here only from the hook's reader, which must not stop.
1943
2156
  return fallback("no-manifest");
@@ -1969,7 +2182,7 @@ export function sessionsDeclaration(workspaceRoot, workspaceDir = ".portulan") {
1969
2182
  const manifest = path.join(workspaceRoot, workspaceDir, "workspace.json");
1970
2183
  let declared;
1971
2184
  try {
1972
- declared = JSON.parse(fs.readFileSync(manifest, "utf8")).sessions;
2185
+ declared = JSON.parse(readWorkspaceText(workspaceRoot, workspaceDir)).sessions;
1973
2186
  } catch {
1974
2187
  return null;
1975
2188
  }
@@ -1996,7 +2209,7 @@ export function spendDeclaration(workspaceRoot, workspaceDir = ".portulan") {
1996
2209
  const manifest = path.join(workspaceRoot, workspaceDir, "workspace.json");
1997
2210
  let parsed;
1998
2211
  try {
1999
- parsed = JSON.parse(fs.readFileSync(manifest, "utf8"));
2212
+ parsed = JSON.parse(readWorkspaceText(workspaceRoot, workspaceDir));
2000
2213
  } catch {
2001
2214
  return null;
2002
2215
  }
@@ -2010,16 +2223,43 @@ export function spendDeclaration(workspaceRoot, workspaceDir = ".portulan") {
2010
2223
  if (error instanceof LedgerError) throw new CompileError(error.message);
2011
2224
  throw error;
2012
2225
  }
2013
- const version = /^([0-9]+)\.([0-9]+)$/.exec(parsed.portulan?.spec ?? "");
2014
- if (spend.restart !== null && version && Number(version[1]) === 2 && Number(version[2]) < 13) {
2015
- throw new CompileError(
2016
- `\`spend.restart\` in ${where} is Workspace Definition 2.13's, and this manifest declares ${version[0]}, whose validator ` +
2017
- "refuses it as an unknown key. Declare 2.13, or remove the key",
2018
- );
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
+ }
2019
2249
  }
2020
2250
  return spend;
2021
2251
  }
2022
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
+
2023
2263
  /** `guidanceOnly` is true where guidance still compiles, and `"leftover"` where only an earlier run's is removed. */
2024
2264
  function undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOptions, reason = "no-key", guidanceOnly = false) {
2025
2265
  const manifest = path.join(workspaceRoot, workspaceDir, "workspace.json");
@@ -2037,8 +2277,14 @@ function undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOp
2037
2277
  : `this workspace declares no gate policy — \`workspace.json\` has no top-level \`gates\` key, ` +
2038
2278
  `and there is no \`gates.json\` at ${policyFile}.`;
2039
2279
  const outcome =
2040
- 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"
2041
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."
2042
2288
  : guidanceOnly
2043
2289
  ? "No enforcement is compiled; the workspace's guidance still is."
2044
2290
  : "Nothing was compiled and nothing was written.";
@@ -2092,7 +2338,15 @@ export function resolvePack(rawName, roots = []) {
2092
2338
  for (const root of roots) {
2093
2339
  const dir = path.join(root, category, pack);
2094
2340
  const manifest = path.join(dir, "pack.json");
2095
- 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
+ }
2096
2350
  }
2097
2351
  return { name, category, pack, dir: null, manifest: null, root: null, why: "no pack.json under any resolution root" };
2098
2352
  }
@@ -2143,6 +2397,7 @@ export function packDifferences(mine, other) {
2143
2397
  const differs = [];
2144
2398
  if (mineV !== otherV) differs.push(`version ${mineV} against the tree's ${otherV}`);
2145
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");
2146
2401
  return differs;
2147
2402
  }
2148
2403
 
@@ -2157,9 +2412,10 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
2157
2412
  const base = path.join(workspaceRoot, workspaceDir);
2158
2413
  let manifest;
2159
2414
  try {
2160
- manifest = JSON.parse(fs.readFileSync(path.join(base, "workspace.json"), "utf8"));
2161
- } catch {
2162
- 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}`);
2163
2419
  }
2164
2420
  const declared = manifest?.packs;
2165
2421
  if (!Array.isArray(declared) || declared.length === 0) return { contributions: [], unresolved: [] };
@@ -2183,7 +2439,7 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
2183
2439
  unresolved.push(found);
2184
2440
  continue;
2185
2441
  }
2186
- 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);
2187
2443
  if (!(options.forced ?? false) && plan.source !== "named") {
2188
2444
  const behind = shadowedCopy(found.name, recordedOrigin(found.root, plan, workspaceRoot), roots, (r) =>
2189
2445
  (plan.origins ?? []).find((o) => path.resolve(o.root) === path.resolve(r))?.origin,
@@ -2191,7 +2447,7 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
2191
2447
  if (behind) {
2192
2448
  let other = null;
2193
2449
  try {
2194
- other = JSON.parse(fs.readFileSync(behind.manifest, "utf8"));
2450
+ other = JSON.parse(readOwnedText(behind.manifest, behind.dir));
2195
2451
  } catch (cause) {
2196
2452
  throw new CompileError(
2197
2453
  `\`${found.name}\` resolved under the root ${found.root} while the root ` +
@@ -2230,6 +2486,7 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
2230
2486
  contributions.push({
2231
2487
  pack: found.name,
2232
2488
  dir: found.dir,
2489
+ contextCap: readContextCap(packManifest, found.name),
2233
2490
  fragments: fragments ?? [],
2234
2491
  personas: personas ?? [],
2235
2492
  origin: recordedOrigin(found.root, plan, workspaceRoot),
@@ -2321,10 +2578,18 @@ export function composeFragments(policy, contributions) {
2321
2578
  return { policy: { ...policy, rules }, added, tightened };
2322
2579
  }
2323
2580
 
2324
- 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)) {
2325
2590
  let raw;
2326
2591
  try {
2327
- raw = fs.readFileSync(file, "utf8");
2592
+ raw = readOwnedText(file, scope);
2328
2593
  } catch (error) {
2329
2594
  throw new CompileError(`cannot read ${what} at ${file} — ${error.code ?? error.message}`);
2330
2595
  }
@@ -2376,22 +2641,71 @@ export const PERSONA_HOSTS = {
2376
2641
  },
2377
2642
  };
2378
2643
 
2379
- /** Where a Claude Code binding sits in a tree, as `AGENT_DIR` in ./plugin-lint.mjs, which a test holds it to. */
2644
+ /** The plugin location; ordinary projects use `.claude/agents/`. */
2380
2645
  export const BINDING_DIR = "agents";
2381
2646
 
2382
2647
  const PLUGIN_MANIFEST = ".claude-plugin/plugin.json";
2383
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
+
2384
2694
  const unquoted = (raw) => (raw.length >= 2 && `"'`.includes(raw[0]) && raw.at(-1) === raw[0] ? raw.slice(1, -1) : raw);
2385
2695
 
2386
2696
  /** What a persona's top-level frontmatter lines declare: `{ name, declared, problems }`, a malformed value a problem and never a default. */
2387
2697
  export function personaDeclarations(text) {
2388
2698
  const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
2389
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
+ }
2390
2704
  const declared = {};
2391
2705
  const problems = [];
2392
2706
  const seen = new Set();
2393
2707
  let name = null;
2394
- for (const line of close === -1 ? [] : lines.slice(1, close)) {
2708
+ for (const line of lines.slice(1, close)) {
2395
2709
  const match = /^([A-Za-z_][\w-]*)\s*:(.*)$/.exec(line);
2396
2710
  if (match?.[1] === "name") name = unquoted(match[2].trim()).trim() || null;
2397
2711
  if (!match || !Object.hasOwn(PERSONA_VALUES, match[1])) continue;
@@ -2502,32 +2816,54 @@ export function boundAgent(text, declared, source, where) {
2502
2816
 
2503
2817
  const listed = (names) => (names.length < 2 ? names.join("") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`);
2504
2818
 
2505
- /** Each composed persona against its host binding: the bindings to rewrite, the lines to say, and what each host cannot carry. */
2819
+ /** Declared personas against existing bindings, including core sources when compiling this engine's own tree. */
2506
2820
  export function planPersonas(contributions, workspaceRoot, workspaceDir = ".portulan") {
2507
2821
  const base = path.join(workspaceRoot, workspaceDir);
2508
2822
  let tree = null;
2509
2823
  try {
2510
- tree = JSON.parse(fs.readFileSync(path.join(base, "workspace.json"), "utf8"))?.tree;
2824
+ tree = JSON.parse(readWorkspaceText(workspaceRoot, workspaceDir))?.tree;
2511
2825
  } catch {
2512
2826
  // `run` refuses an unreadable manifest before this is reached; an absent one declares no tree.
2513
2827
  }
2514
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
+ }
2515
2849
  const files = [];
2516
2850
  const lines = [];
2517
2851
  const degraded = [];
2518
2852
  const owners = new Map();
2519
2853
  let compared = 0;
2520
- for (const contribution of contributions) {
2854
+ const bindingDir = treeRoot && sources.some(source => source.personas?.length) ? bindingDirectory(treeRoot) : BINDING_DIR;
2855
+ for (const contribution of sources) {
2521
2856
  for (const rel of contribution.personas ?? []) {
2522
- const where = `\`${contribution.pack}\`'s persona \`${rel}\``;
2857
+ const where = contribution.core ? `core persona \`${rel}\`` : `\`${contribution.pack}\`'s persona \`${rel}\``;
2523
2858
  const file = path.resolve(contribution.dir, rel);
2524
2859
  let text;
2525
2860
  try {
2526
- if (!isInside(fs.realpathSync(contribution.dir), fs.realpathSync(file))) throw new CompileError(`${where} resolves outside its pack`);
2527
- text = fs.readFileSync(file, "utf8");
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));
2528
2864
  } catch (cause) {
2529
2865
  if (cause instanceof CompileError) throw cause;
2530
- throw new CompileError(`${where} could not be read — ${cause.code ?? cause.message}, so what it declares is unknown. Run \`doctor\` to validate the pack`);
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"}`);
2531
2867
  }
2532
2868
  const { name: declaredName, declared, problems } = personaDeclarations(text);
2533
2869
  if (problems.length) {
@@ -2535,17 +2871,19 @@ export function planPersonas(contributions, workspaceRoot, workspaceDir = ".port
2535
2871
  }
2536
2872
  const fields = PERSONA_FIELDS.filter((f) => declared[f] !== undefined);
2537
2873
  const name = declaredName ?? path.basename(rel, ".md");
2538
- if (fields.length && !SLUG.test(name)) {
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)) {
2539
2878
  throw new CompileError(`${where} is named \`${name}\`, which is not a slug, so no binding file can be named for it`);
2540
2879
  }
2541
- const source = `${contribution.pack}'s ${rel}`;
2880
+ const source = contribution.core ? rel : `${contribution.pack}'s ${rel}`;
2542
2881
  for (const field of fields) {
2543
2882
  for (const [id, host] of Object.entries(PERSONA_HOSTS)) {
2544
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}` });
2545
2884
  }
2546
2885
  }
2547
- if (!SLUG.test(name)) continue;
2548
- const bindingRel = `${BINDING_DIR}/${name}.md`;
2886
+ const bindingRel = `${bindingDir}/${name}.md`;
2549
2887
  if (owners.has(bindingRel)) {
2550
2888
  const sources = [owners.get(bindingRel), source].sort();
2551
2889
  throw new CompileError(`${bindingRel} has multiple persona owners: ${sources.join(" and ")}. Each binding must have one owner`);
@@ -2555,7 +2893,7 @@ export function planPersonas(contributions, workspaceRoot, workspaceDir = ".port
2555
2893
  if (fields.length) lines.push(`persona ${name.padEnd(22)} UNBOUND — declares ${listed(fields)}, and this workspace declares no \`tree\` to hold its binding`);
2556
2894
  continue;
2557
2895
  }
2558
- const binding = path.join(treeRoot, BINDING_DIR, `${name}.md`);
2896
+ const binding = path.join(treeRoot, bindingDir, `${name}.md`);
2559
2897
  const shown = path.relative(workspaceRoot, binding).split(path.sep).join("/");
2560
2898
  let stat = null;
2561
2899
  try {
@@ -2578,19 +2916,15 @@ export function planPersonas(contributions, workspaceRoot, workspaceDir = ".port
2578
2916
  if (cause instanceof CompileError) throw cause;
2579
2917
  throw new CompileError(`${shown} could not be resolved against the declared tree — ${cause.code ?? cause.message}`);
2580
2918
  }
2581
- const current = fs.readFileSync(binding, "utf8");
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}`); }
2582
2922
  const next = boundAgent(current, declared, source, shown);
2583
- if (fields.length) lines.push(`persona ${name.padEnd(22)} ${listed(fields)} → ${shown} (${PERSONA_HOSTS["claude-code"].label})`);
2584
- if (fields.length && !fs.existsSync(path.join(treeRoot, ...PLUGIN_MANIFEST.split("/")))) {
2585
- degraded.push({
2586
- persona: name,
2587
- field: null,
2588
- host: "claude-code",
2589
- line:
2590
- `degraded ${name.padEnd(22)} ${listed(fields)} claude-code (Claude Code): ${shown} loads only from a plugin root, and this tree ` +
2591
- `has no ${PLUGIN_MANIFEST}; compile writes no .claude/agents/, so nothing here loads the compiled fields`,
2592
- });
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`);
2593
2926
  }
2927
+ if (fields.length) lines.push(`persona ${name.padEnd(22)} ${listed(fields)} → ${shown} (${PERSONA_HOSTS["claude-code"].label})`);
2594
2928
  if (fields.length || next !== current) compared += 1;
2595
2929
  if (next !== current) files.push({ path: shown, file: binding, text: next, source });
2596
2930
  }
@@ -2687,7 +3021,7 @@ export function resolveWorkspace(named) {
2687
3021
  const manifestPath = path.join(dir, "workspace.json");
2688
3022
  let raw;
2689
3023
  try {
2690
- raw = fs.readFileSync(manifestPath, "utf8");
3024
+ raw = readOwnedText(manifestPath, dir);
2691
3025
  } catch (cause) {
2692
3026
  // As given, not resolved: `compile` prints paths built on it.
2693
3027
  if (cause.code === "ENOENT") return { workspaceRoot: named, workspaceDir: ".portulan" };
@@ -2708,6 +3042,7 @@ export function resolveWorkspace(named) {
2708
3042
  if (typeof manifest?.tree === "string" && manifest.tree.trim()) {
2709
3043
  const root = path.resolve(dir, manifest.tree);
2710
3044
  const inside = path.relative(root, dir);
3045
+ if(inside === "") return {workspaceRoot:root,workspaceDir:"."};
2711
3046
  // A `tree` that does not contain its workspace cannot be trusted, so the answer is the one that changes nothing.
2712
3047
  if (inside && !inside.startsWith("..") && !path.isAbsolute(inside)) {
2713
3048
  return { workspaceRoot: root, workspaceDir: inside.split(path.sep).join("/") };
@@ -2719,19 +3054,19 @@ export function resolveWorkspace(named) {
2719
3054
 
2720
3055
  function usage() {
2721
3056
  return [
2722
- "portulan compile — compile a workspace's gate policy into host enforcement",
3057
+ "portulan compile — compile workspace advice, guidance, bindings and declared gates",
2723
3058
  "",
2724
3059
  " portulan compile [--check] [--matrix] [--workspace <dir>] [--pack-root <dir>|auto]...",
2725
3060
  "",
2726
- " (no flag) compile the policy and the guidance, and write each artifact",
2727
- " --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",
2728
3063
  " --matrix print every rule against every backend, the gates neither compiles, and every",
2729
- " guidance unit against every host",
3064
+ " guidance unit against every host; report native settings without a policy",
2730
3065
  " --workspace the workspace directory to compile; defaults to `.portulan`",
2731
3066
  " --pack-root where declared packs are resolved from; `auto` discovers the host's plugin cache.",
2732
3067
  " A named root REPLACES every other source. A directory actually named `auto` is `./auto`",
2733
3068
  "",
2734
- "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",
2735
3070
  "backend by design, and the partition inverts in the repository ruleset, which is the floor.",
2736
3071
  "",
2737
3072
  "Exit codes: 0 succeeded · 1 a red verdict · 2 could not run.",
@@ -2808,50 +3143,58 @@ export function run(argv, options = {}) {
2808
3143
  );
2809
3144
  }
2810
3145
  const guidance = guidanceUnits(workspaceRoot, workspaceDir);
2811
- const guidancePlan = showMatrix ? null : planGuidance(guidance, workspaceRoot);
3146
+ const guidancePlan = showMatrix ? null : planGuidance(guidance, workspaceRoot, workspaceDir);
3147
+ if (!check && guidancePlan) preflightGuidanceWrites(guidancePlan, workspaceRoot);
2812
3148
  const sessions = sessionsDeclaration(workspaceRoot, workspaceDir);
2813
3149
  const spend = spendDeclaration(workspaceRoot, workspaceDir);
3150
+ const stopPolicy = stopDeclaration(workspaceRoot, workspaceDir);
2814
3151
  const { file: policyFile, declared: policyDeclared, reason: policyReason } = policyDeclaration(workspaceRoot, workspaceDir);
2815
3152
  const packOptions = { named: namedRoots, discovery: () => discoverPackRoots(), forced };
2816
3153
  if (policyReason === "refused") {
2817
3154
  throw new CompileError(undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOptions, policyReason));
2818
3155
  }
2819
3156
  if (!policyDeclared && !fs.existsSync(policyFile)) {
2820
- // 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.
2821
3158
  const leftover = guidance === null && policyReason === "no-key" && guidancePlan !== null && guidancePlan.stray.length > 0;
2822
- if (guidance === null && !leftover) {
2823
- throw new CompileError(undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOptions, policyReason));
2824
- }
2825
- say(`note ${undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOptions, policyReason, leftover ? "leftover" : true)}`);
2826
- if (sessions !== null) {
2827
- say(
2828
- `note \`sessions\` in ${sessions.manifest} compiled nothing: its host switches ride the settings a gate ` +
2829
- `policy compiles to, and this workspace has none`,
2830
- );
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;
2831
3164
  }
2832
- if (spend !== null) {
2833
- say(
2834
- `note \`spend\` in ${spend.manifest} compiled nothing: what it declares rides the restart advisory's commands ` +
2835
- `in the settings a gate policy compiles to, and this workspace has none`,
2836
- );
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));
2837
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");
2838
3182
  say();
2839
- const packs = packContributions(workspaceRoot, workspaceDir, packOptions);
2840
3183
  for (const u of packs.unresolved) say(unresolvedLine(u));
2841
3184
  if (packs.unresolved.length) say();
2842
- const personas = planPersonas(packs.contributions, workspaceRoot, workspaceDir);
3185
+ if (!advice) for (const line of contextCapLines(contexts)) say(`note ${line}`);
2843
3186
  if (showMatrix) {
2844
- printGuidanceMatrix(say, guidance);
3187
+ if (guidance) printGuidanceMatrix(say, guidance);
2845
3188
  printPersonaReport(say, personas);
2846
3189
  return 0;
2847
3190
  }
2848
- const drifted = emitPersonas(personas, { check, say }) + emitGuidance(guidance, guidancePlan, { workspaceRoot, check, say });
3191
+ const drifted = emitCapOnly(capPlan, { check, say }) + emitPersonas(personas, { check, say }) + emitGuidance(guidance, guidancePlan, { workspaceRoot, check, say });
2849
3192
  if (!check) return 0;
2850
3193
  if (drifted) return 1;
2851
- say(`GREEN — every compiled guidance file matches its unit${personas.compared ? ", and every persona binding its persona" : ""}`);
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" : ""}`);
2852
3195
  return 0;
2853
3196
  }
2854
- const policy = readJson(policyFile, "the gate policy");
3197
+ const policy = readJson(policyFile, "the gate policy", path.join(workspaceRoot, workspaceDir));
2855
3198
  // A thunk, so the host's plugin record is read only where discovery can win.
2856
3199
  const { contributions, unresolved, plan } = packContributions(workspaceRoot, workspaceDir, {
2857
3200
  named: namedRoots,
@@ -2871,6 +3214,8 @@ export function run(argv, options = {}) {
2871
3214
  packProvenance: contributions,
2872
3215
  sessions,
2873
3216
  spend,
3217
+ stopPolicy,
3218
+ contexts: contextCaps(contributions, unresolved),
2874
3219
  });
2875
3220
 
2876
3221
  for (const a of composed.added) say(`pack ${a.pack.padEnd(30)} adds \`${a.id}\` (${a.tier})`);
@@ -2882,6 +3227,7 @@ export function run(argv, options = {}) {
2882
3227
 
2883
3228
  if (showMatrix) {
2884
3229
  printMatrix(say, parsed, columns, { source });
3230
+ for (const line of contextCapLines(contextCaps(contributions, unresolved))) say(`note ${line}`);
2885
3231
  if (guidance) {
2886
3232
  say();
2887
3233
  printGuidanceMatrix(say, guidance);