@sleepy_panda_srl/portulan 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +7 -6
  2. package/cli/README.md +145 -358
  3. package/cli/advisory.mjs +354 -0
  4. package/cli/comments.mjs +932 -0
  5. package/cli/compile.mjs +1719 -1699
  6. package/cli/context.mjs +883 -0
  7. package/cli/control-chars.mjs +12 -301
  8. package/cli/discover.mjs +17 -574
  9. package/cli/doctor.mjs +269 -1080
  10. package/cli/drills.mjs +92 -504
  11. package/cli/feedback.mjs +16 -237
  12. package/cli/finish.mjs +437 -0
  13. package/cli/form.mjs +644 -0
  14. package/cli/fuzz-shell.mjs +20 -305
  15. package/cli/gate.mjs +7 -199
  16. package/cli/goldens.mjs +26 -184
  17. package/cli/index.mjs +305 -655
  18. package/cli/init.mjs +243 -566
  19. package/cli/inside.mjs +3 -28
  20. package/cli/instructions.mjs +829 -0
  21. package/cli/ledger.mjs +882 -0
  22. package/cli/librarian.mjs +135 -599
  23. package/cli/manifest.mjs +2 -39
  24. package/cli/mutants.mjs +9 -170
  25. package/cli/new.mjs +12 -243
  26. package/cli/pack-identity.mjs +5 -74
  27. package/cli/pack-version.mjs +13 -255
  28. package/cli/plugin-lint.mjs +42 -658
  29. package/cli/portulan.mjs +6 -125
  30. package/cli/recipe-set.mjs +15 -245
  31. package/cli/release-eval.mjs +28 -348
  32. package/cli/review-meter.mjs +29 -265
  33. package/cli/rule-carriers.mjs +12 -175
  34. package/cli/sessions.mjs +82 -0
  35. package/cli/skill-goldens.mjs +10 -176
  36. package/cli/skills-set.mjs +12 -414
  37. package/cli/stop-gate.mjs +83 -514
  38. package/cli/symbols.mjs +1132 -0
  39. package/cli/telemetry.mjs +82 -505
  40. package/cli/upgrade.mjs +171 -314
  41. package/cli/vendor.mjs +146 -476
  42. package/cli/version-carriers.mjs +8 -86
  43. package/core/README.md +1 -1
  44. package/core/engine.md +3 -9
  45. package/core/operating/autonomy.md +1 -1
  46. package/core/operating/context.md +258 -0
  47. package/core/operating/evolution.md +3 -3
  48. package/core/operating/loop.md +13 -20
  49. package/core/operating/memory.md +12 -3
  50. package/core/operating/sessions.md +100 -0
  51. package/core/personas/README.md +33 -0
  52. package/core/personas/librarian.md +2 -2
  53. package/core/skills/clarify/SKILL.md +1 -1
  54. package/core/skills/consolidate/SKILL.md +5 -3
  55. package/core/templates/handoff.md +28 -15
  56. package/core/templates/memory-entry.md +1 -0
  57. package/evals/README.md +271 -845
  58. package/evals/releases/0.2.0.json +146 -0
  59. package/evals/releases/0.2.0.md +67 -0
  60. package/evals/releases/0.3.0.json +150 -0
  61. package/evals/releases/0.3.0.md +68 -0
  62. package/evals/releases/README.md +3 -3
  63. package/package.json +5 -2
  64. package/packs/.claude-plugin/plugin.json +2 -1
  65. package/packs/README.md +7 -4
  66. package/packs/rituals/checkpoints/README.md +1 -1
  67. package/packs/tools/README.md +8 -8
  68. package/packs/tools/github/verify/actions-pinned.sh +10 -55
  69. package/packs/tools/local-llm/README.md +136 -0
  70. package/packs/tools/local-llm/pack.json +32 -0
  71. package/packs/tools/local-llm/personas/local-worker.md +44 -0
  72. package/packs/tools/local-llm/runner/local-llm.mjs +1665 -0
  73. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +64 -0
  74. package/packs/tools/local-llm/verify/README.md +18 -0
  75. package/spec/README.md +113 -6
  76. package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
  77. package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
  78. package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
  79. package/spec/migrations/0004-changelog-fragments.mjs +53 -0
  80. package/spec/migrations/0005-session-log-retired.mjs +69 -0
  81. package/spec/migrations/0006-boot-card.mjs +77 -0
  82. package/spec/migrations/0007-guidance-compiled.mjs +31 -0
  83. package/spec/migrations/0008-card-reading.mjs +55 -0
  84. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
  85. package/spec/migrations/0010-comment-rail.mjs +57 -0
  86. package/spec/migrations/README.md +45 -10
  87. package/spec/pack.schema.json +1 -1
  88. package/spec/slots.md +343 -27
  89. package/spec/workspace.schema.json +145 -4
package/cli/compile.mjs CHANGED
@@ -1,51 +1,18 @@
1
1
  #!/usr/bin/env node
2
- // `compile` — the enforcement compiler.
2
+ // `compile` — the enforcement compiler: it turns a workspace's gate policy into Claude Code settings and a GitHub
3
+ // ruleset, and its guidance into each host's load tiers.
3
4
  //
4
5
  // node cli/compile.mjs [--workspace <dir>] [--check] [--matrix]
5
6
  //
6
7
  // Exit 0 wrote (or, with --check, agrees) · 1 an artifact has drifted · 2 could not run.
7
- //
8
- // Reads a workspace's gate policy (`gates.json`) and emits enforcement, for two backends: the Claude
9
- // Code host (`permissions` + `hooks`) and the GitHub repository ruleset that is the platform floor.
10
- // `--matrix` prints every rule against both. The tier vocabulary and the action vocabulary are the
11
- // WORKSPACE's, never a host's — a rule says `{"shell": "git push"}`, not `Bash(git push:*)` — which is
12
- // what let the second backend translate the same policy instead of forcing every adopter to rewrite
13
- // theirs. That separation is the whole of "LLM-agnostic by construction" (../docs/vision.md) at this
14
- // layer, and the second backend is where it stopped being a claim.
15
- //
16
- // ## Two layers, and which one is load-bearing
17
- //
18
- // Every gate is emitted TWICE: as a permission rule and as a hook. That is not belt-and-braces for
19
- // its own sake — it was measured. On CLI 2.1.220 a hook that CRASHES fails OPEN: the tool runs
20
- // normally, on the identical wiring that blocked when the hook was healthy. A permission rule does
21
- // not fail open. So the permission rule is the gate and the hook is the sentence a human reads;
22
- // a design resting on hooks alone would be a gate that a syntax error silently removes.
23
- //
24
- // ## Why `ask` rather than `deny` for the Gated tier
25
- //
26
- // ../.portulan/gate-map.md defines Gated as "explicit human approval, per action, before it
27
- // happens" — which is what `ask` is: interactively it prompts; headless, where nobody can approve,
28
- // it blocks. Compiling Gated to `deny` would be the *prohibition* semantics wearing the Gated
29
- // tier's name, and it would make the tier above it — the constitution, which has no approval path
30
- // at all — indistinguishable from an ordinary push.
31
- //
32
- // ## What this file cannot tell you
33
- //
34
- // That the host honours what it emits. A schema-valid settings file the host ignores is
35
- // indistinguishable, from in here, from one it enforces — see
36
- // ../.portulan/memory/a-manifest-field-can-validate-and-load-nothing.md, which cost a milestone.
37
- // The host behaviours above were measured against a running host on 2026-07-27 and are recorded in
38
- // ../.portulan/compile/README.md with their probes. Re-measure on upgrade.
39
- //
40
- // Zero dependencies, no network, no install step — same constraints as ./doctor.mjs.
41
8
 
42
9
  import fs from "node:fs";
43
10
  import path from "node:path";
44
11
  import { fileURLToPath, pathToFileURL } from "node:url";
45
- // Host plugin-cache discovery (#123). The record reader, the config directory and the version refusal
46
- // all live there, once — see that file's pack-root section for why this half arrived separately.
47
12
  import { AUTO, discoverPackRoots, namedWithAuto, resolutionRoots } from "./discover.mjs";
48
13
  import { isInside } from "./inside.mjs";
14
+ import { CannotOutline, sectionOf } from "./symbols.mjs";
15
+ import { HORIZON, LedgerError, readSpend } from "./ledger.mjs";
49
16
 
50
17
  /** Raised when `compile` cannot run, or cannot compile honestly. Always exit 2, never 1. */
51
18
  export class CompileError extends Error {
@@ -55,62 +22,22 @@ export class CompileError extends Error {
55
22
  }
56
23
  }
57
24
 
58
- // The GATE-POLICY spec versions this compiler understands — `gates.json`'s `portulan.spec`, which is a
59
- // different train from the Workspace Definition's despite the overlapping numbers. Checked
60
- // rather than ignored: `doctor` shipped for a day reading no version at all, so a manifest naming a
61
- // spec that had never existed validated green. Same class of hole, closed at birth this time.
62
- //
63
- // 2.1 stays known: the `floor` key 2.2 adds is optional, so a 2.1 policy is still a policy this
64
- // compiler reads correctly — it simply declares no floor, which is a legitimate shape and the one
65
- // every workspace had yesterday.
66
- //
67
- // **These are GATE-POLICY versions, not Workspace Definition versions**, and the name is about to be
68
- // misread by someone: the numbers overlap (`gates.json` is on 2.x and so is the Workspace Definition),
69
- // the constant is spelled the same as ./index.mjs's and ./librarian.mjs's, and those two DO track the
70
- // Workspace Definition. A fresh reviewer at milestone 6 session 1 sight-read this as a carrier left
71
- // stale by the 2.5 → 2.6 bump and had to measure to clear it. It is correctly untouched by any
72
- // Workspace Definition bump; renamed to say so, because the next reader may not measure.
73
- // Exported so a test can assert MEMBERSHIP against it rather than restate the literal. The
74
- // scaffold `new gate-policy` emits must declare a spec THIS set contains (#329), and a rail
75
- // spelling `"2.2"` on its own side would pass while the two drifted apart — which is the
76
- // shape #329 was.
77
- export const KNOWN_GATE_POLICY_SPECS = new Set(["2.1", "2.2"]);
25
+ /** The `portulan.spec` versions of `gates.json`: a train separate from the Workspace Definition's, though the numbers overlap. */
26
+ export const KNOWN_GATE_POLICY_SPECS = new Set(["2.1", "2.2", "2.3"]);
78
27
 
79
28
  const TIERS = new Set(["auto", "propose", "gated", "prohibited"]);
80
29
 
81
30
  const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
82
31
 
83
- // The `filePath` shape from ../spec/workspace.schema.json, carried here rather than imported because
84
- // this file has no dependencies by design. Relative, no fragment/query/colon, not a directory.
32
+ // spec/workspace.schema.json's `filePath`, copied: relative, no `#`, `?` or `:`, and not a directory.
85
33
  const FILE_PATH = /^[^#?:/]([^#?:]*[^#?:/])?$/;
86
34
 
87
- /** The tools that can write a path, and the tools that can read one. */
88
- // Exported since 2026-08-24 so `./goldens.mjs` can DERIVE which branch of `matchesRule` a fixture
89
- // exercises instead of a corpus author declaring it from memory. A hand-declared path would be a
90
- // second carrier of what these two tables already decide, and the fixture format asks for the path on
91
- // every case — which is exactly the volume at which a hand-copied answer goes wrong.
92
35
  export const WRITE_TOOLS = ["Edit", "Write", "NotebookEdit"];
93
36
  export const READ_TOOLS = ["Read"];
94
37
 
95
- // ===========================================================================================
96
- // 1. Parse: policy -> validated, normalised rules. NO backend opinion lives here.
97
- // ===========================================================================================
38
+ // ---------------------------------------------------------------- parse: validated rules, no backend's opinion
98
39
 
99
- /**
100
- * Read a gate policy and hand back `{ rules, floor }` — validated and normalised, with every rule
101
- * carrying its `kind` and `target` resolved, and nothing yet decided about enforcement.
102
- *
103
- * Anything it cannot understand refuses the WHOLE policy rather than dropping one rule — skipping
104
- * and enforcing are indistinguishable from outside
105
- * (../.portulan/memory/a-checker-must-refuse-what-it-cannot-check.md).
106
- *
107
- * **Why this stage holds no tier partition.** For one session it did: `auto` and `propose` were
108
- * refused here, before any backend ran, with a sentence saying `propose` "is enforced by the
109
- * platform floor". That sentence was right and its location was wrong — it names the *other*
110
- * backend, and when that backend arrived it needed to compile exactly the rules this stage had
111
- * already thrown away. A partition that is one backend's opinion must live in that backend, or the
112
- * second one is structurally incapable of disagreeing.
113
- */
40
+ /** The policy as `{ rules, floor }`, validated and normalised; anything unreadable throws, since a skipped rule looks enforced. */
114
41
  export function parse(policy) {
115
42
  if (!policy || typeof policy !== "object" || Array.isArray(policy)) {
116
43
  throw new CompileError("the gate policy is not a JSON object");
@@ -165,32 +92,6 @@ export function parse(policy) {
165
92
  if (typeof action[kind] !== "string" || action[kind].trim() === "") {
166
93
  throw new CompileError(`rule \`${id}\`'s action \`${kind}\` has no value`);
167
94
  }
168
- // ONE REGEX WAS CARRYING TWO DIFFERENT JUSTIFICATIONS, and only one of them reaches every kind.
169
- //
170
- // For `shell`, `write` and `read` the value is interpolated into the host's permission DSL —
171
- // `Bash(prefix:*)`, `Edit(./path)`. Characters structural THERE would emit a rule the host
172
- // parses differently from what the policy meant, and an ambiguous gate is indistinguishable
173
- // from an absent one. Refuse rather than escape: a workspace needing a gate on a command
174
- // containing `(` or `:` needs this compiler extended deliberately, not quietly reinterpreted.
175
- // Found by review on the pull request.
176
- //
177
- // **A `none` value is never interpolated into a permission pattern.** It is PROSE: the sentence
178
- // the policy gives for why there is no surface, which the compiler reports verbatim rather than
179
- // inventing its own — see the `kind === "none"` arm of the emitter, which pushes it as `why`,
180
- // and the `refused` loop in the reporter, which prints it. (An earlier draft of this comment
181
- // cited `describeRefusal`, a function that does not exist in this file — a name asserted without
182
- // being looked up, in the same change whose subject is tracing what a value actually reaches.
183
- // The nearest real thing is `floorRefusal`, which answers a different question.) So the DSL
184
- // justification above does not reach it, and
185
- // applying the DSL regex to it forbade **parentheses in an English sentence** — a gate refusing
186
- // to compile over an aside. #205.
187
- //
188
- // What DOES still reach it is narrower and worth stating rather than folding back in: that
189
- // sentence is printed into a LINE-BASED report — `refused ${id.padEnd(38)} ${why}` — so a
190
- // newline or a tab in it breaks the column and splits one refusal across two lines. That is a
191
- // different defect with a different fix, and it is the reason `none` keeps `\n\r\t` while
192
- // losing `()` and `:`. Two justifications, two scopes, stated separately so the next reader
193
- // cannot collapse them again.
194
95
  const RESERVED = kind === "none" ? /[\n\r\t]/ : kind === "shell" ? /[()\n\r\t:]/ : /[()\n\r\t]/;
195
96
  if (RESERVED.test(action[kind])) {
196
97
  throw new CompileError(
@@ -203,15 +104,6 @@ export function parse(policy) {
203
104
  `differently from what this policy says, which is worse than refusing to compile.`,
204
105
  );
205
106
  }
206
- // THE SAME OVER-REACH, NINE LINES BELOW THE SPLIT THAT FIXED IT. This message told every kind
207
- // that the host "would not match" its value — and nothing about a `none` value is ever matched
208
- // by the host, for the reason set out directly above. Surrounding whitespace on a `none` value
209
- // is still worth refusing, but for a reason this comment got wrong once: the report's padding
210
- // comes from `r.id.padEnd(38)`, not from `why`, so whitespace on the sentence cannot misalign a
211
- // column. What it does do is shift the sentence out of line with every other refusal, and hide
212
- // itself in the record. The message says that now — a refusal's stated reason must match its
213
- // actual mechanism, which is this change's own subject, missed nine lines below its own fix. Found by Copilot on #256 round 1, in the change that had just split the
214
- // reserved-character check on exactly this distinction: `0020` at its shortest range yet.
215
107
  if (action[kind] !== action[kind].trim()) {
216
108
  throw new CompileError(
217
109
  kind === "none"
@@ -221,24 +113,6 @@ export function parse(policy) {
221
113
  : `rule \`${id}\`'s ${kind} target has leading or trailing whitespace, which the host would not match`,
222
114
  );
223
115
  }
224
- // A path target that leaves the workspace is refused rather than normalised, for the reason
225
- // directly above. Two spellings, one defect: `pattern()` and `matchesPath()` compare against a
226
- // workspace-relative TAIL, so neither an absolute target nor one climbing out with `..` means
227
- // at enforcement time what it says in the policy.
228
- //
229
- // "/etc/passwd" -> emits `Edit(./etc/passwd)` and matches any file whose path ends
230
- // `/etc/passwd`, anywhere on the machine — broader than declared.
231
- // "../secrets/" -> emits `Edit(./../secrets/**)`, which the host may resolve against the
232
- // PARENT tree, while `matchesPath` can never match a `/../`-bearing tail
233
- // against a resolved absolute path — narrower than declared, and the
234
- // emitter and matcher disagree about which.
235
- //
236
- // Broader and narrower are both wrong, and the second is worse: it is a gate that reads as
237
- // present and holds nothing. A workspace needing to gate a path outside its own tree needs
238
- // this compiler extended deliberately. Shell targets are exempt: `/usr/bin/git` is a command
239
- // spelling, not a path this compiler rewrites. Absolute found by review on #31; the `..`
240
- // sibling by the fresh-context supervisor reviewing that fix, which is the same defect one
241
- // spelling over.
242
116
  if (kind === "write" || kind === "read") {
243
117
  const escapes = action[kind].startsWith("/")
244
118
  ? "is an absolute path"
@@ -257,25 +131,11 @@ export function parse(policy) {
257
131
  rules.push({ id, tier: rule.tier, kind, target: action[kind], reason: rule.reason, action });
258
132
  }
259
133
 
260
- return { rules, floor: parseFloor(policy.floor) };
134
+ return { rules, floor: parseFloor(policy.floor, String(spec)) };
261
135
  }
262
136
 
263
- /**
264
- * The platform-floor declaration: the facts a floor backend would otherwise have to invent.
265
- *
266
- * Optional — a workspace that has declared no floor is a legitimate shape, and it is what every
267
- * workspace was before this key existed. Present, it is validated whole: a half-declared floor is
268
- * the one input from which a backend could emit something importable, valid, and weaker than the
269
- * settings already in force.
270
- *
271
- * Four keys, and each is here because it varies per repository and the export would otherwise guess:
272
- * which ref the floor protects, which status checks must be green, how many approving reviews are
273
- * required, and whether review threads must be resolved. What is deliberately NOT declarable is
274
- * `strict` — a pull request may not merge from behind its base
275
- * (`../.portulan/proposals/0011-no-merge-from-behind-main.md`, applied live on 2026-07-27), so a
276
- * policy able to declare `strict: false` would be a compiled artifact quietly undoing a ruling.
277
- */
278
- function parseFloor(floor) {
137
+ /** The optional platform floor, with strict freshness unless the policy explicitly chooses otherwise. */
138
+ function parseFloor(floor, spec) {
279
139
  if (floor === undefined || floor === null) return null;
280
140
  if (typeof floor !== "object" || Array.isArray(floor)) {
281
141
  throw new CompileError("`floor` is present but is not a JSON object");
@@ -287,11 +147,6 @@ function parseFloor(floor) {
287
147
  `Refusing rather than defaulting to \`main\`: a compiler that invents the ref it gates has stopped compiling policy.`,
288
148
  );
289
149
  }
290
- // A branch NAME, never a full ref. The emitter prefixes `refs/heads/` unconditionally, so
291
- // `refs/heads/main` here produced `refs/heads/refs/heads/main` — a ruleset GitHub accepts and
292
- // that matches no ref in any repository. Importable, valid, enforcing nothing, in the one field
293
- // that names what the floor protects. Found by review on the pull request. (`release/2026` is an
294
- // ordinary branch name and stays legal; only a `refs/` prefix is refused.)
295
150
  if (/^refs\//.test(branch)) {
296
151
  throw new CompileError(
297
152
  `\`floor.branch\` is ${JSON.stringify(branch)} — declare a branch NAME, not a full ref. This compiler emits ` +
@@ -306,20 +161,13 @@ function parseFloor(floor) {
306
161
  if (!c || typeof c !== "object" || typeof c.context !== "string" || c.context.trim() === "") {
307
162
  throw new CompileError(`\`floor.checks[${i}]\` declares no \`context\` — a required check with no name cannot be required`);
308
163
  }
309
- // Refused rather than normalised, exactly as a rule target is a few lines up: a context
310
- // stored with its surrounding whitespace is emitted with it, and GitHub then requires a
311
- // check no job reports. Trimming it silently would hide a policy error in the file a human
312
- // reviews. Found by review on the pull request.
313
164
  if (c.context !== c.context.trim()) {
314
165
  throw new CompileError(
315
166
  `\`floor.checks[${i}].context\` is ${JSON.stringify(c.context)} — it has leading or trailing whitespace, which no ` +
316
167
  `status check will report. Fix the policy rather than having this quietly trim it.`,
317
168
  );
318
169
  }
319
- // The app pin is optional and its absence is a real weakening rather than a style choice: a
320
- // context with no `integration_id` is satisfiable by ANY app reporting that name. Permitted,
321
- // because a workspace may legitimately not know its app id, and reported by `doctor` rather
322
- // than refused here. Recorded in ../.portulan/gate-map.md, which learned it the same way.
170
+ // Without `integration_id`, any app reporting the name satisfies the check: allowed, and reported by `doctor`.
323
171
  const pin = c.integration_id;
324
172
  if (pin !== undefined && !Number.isInteger(pin)) {
325
173
  throw new CompileError(`\`floor.checks[${i}].integration_id\` is not an integer — an app pin GitHub cannot read is not a pin`);
@@ -332,47 +180,22 @@ function parseFloor(floor) {
332
180
  if (typeof floor.resolve_conversations !== "boolean") {
333
181
  throw new CompileError("`floor.resolve_conversations` must be a boolean — omitting it would export a floor weaker than the one in force");
334
182
  }
335
- return { branch, checks, reviews: floor.reviews, resolve_conversations: floor.resolve_conversations };
183
+ if (Object.hasOwn(floor, "strict")) {
184
+ if (spec !== "2.3") throw new CompileError("`floor.strict` requires gate-policy 2.3");
185
+ if (typeof floor.strict !== "boolean") throw new CompileError("`floor.strict` must be a boolean");
186
+ }
187
+ return { branch, checks, reviews: floor.reviews, resolve_conversations: floor.resolve_conversations, strict: floor.strict ?? true };
336
188
  }
337
189
 
338
- // ===========================================================================================
339
- // 2. What an action MEANS — shared by the compiler and the runtime gate
340
- // ===========================================================================================
341
- //
342
- // The emitter and the hook runner must agree about what `{"shell": "git push"}` covers, and the
343
- // only way to guarantee that is one definition. Two implementations of one matcher is the drift
344
- // this repository keeps finding, and a *matcher* that drifts does not look wrong — it looks like a
345
- // gate that quietly stopped covering something.
346
- //
347
- // `./gate.mjs` imports these. It is the only thing outside this file that may.
190
+ // ---------------------------------------------------------------- what an action means, to the compiler and the gate alike
348
191
 
349
- /**
350
- * The spellings of a shell command a gate will match against: the command itself, and the same
351
- * command with one `sh -c` / `bash -c` / `zsh -c` wrapper peeled off.
352
- *
353
- * The permission rule this compiles to sees only the first — `Bash(git push:*)` is a literal
354
- * prefix match, so `bash -c "git push …"` is invisible to it. Measured, not assumed. The hook
355
- * runner sees both, which is the one thing it does that the permission layer cannot, and therefore
356
- * the reason it is emitted at all.
357
- *
358
- * **One level, and no more.** This closes the spelling reached for by accident or convenience, not
359
- * one constructed on purpose: deeper nesting, a heredoc, an interpolated variable, or a command
360
- * assembled at runtime all still escape. No amount of parsing here would close that, and an
361
- * ambitious parser would buy false confidence with false reds. What must not happen regardless of
362
- * spelling belongs on the platform floor — see ../core/operating/autonomy.md, which calls the floor
363
- * the gate that holds when this layer fails.
364
- */
192
+ /** The command and, under one leading `sh -c`-style wrapper, the command inside, which no `Bash(prefix:*)` rule sees; one level only. */
365
193
  export function spellings(raw) {
366
194
  const command = String(raw ?? "").trim();
367
195
  const out = [command];
368
196
  const wrapper = /^(?:\/usr\/bin\/env\s+)?(?:ba|z|da)?sh\s+-[a-zA-Z]*c\s+(.*)$/s.exec(command);
369
197
  if (wrapper) {
370
198
  let inner = wrapper[1].trim();
371
- // The same `$'…'` / `$"…"` forms `shellWords` strips, at the other site that reads a quote.
372
- // Two sites, because a wrapped command is unwrapped HERE and a word is tokenised THERE, and
373
- // fixing one left `bash -c $'git push --force origin main'` still stepping aside while
374
- // `cp /tmp/x $'docs/vision.md'` had started denying — measured, and the reason this is not
375
- // one edit. Found by Copilot on #60.
376
199
  if (inner[0] === "$" && (inner[1] === "'" || inner[1] === '"')) inner = inner.slice(1);
377
200
  const quote = inner[0];
378
201
  if ((quote === '"' || quote === "'") && inner.endsWith(quote) && inner.length > 1) {
@@ -383,75 +206,29 @@ export function spellings(raw) {
383
206
  return out;
384
207
  }
385
208
 
386
- // The shell spellings of a write, recognised by table. Below, `shellWrites` asks one question of a
387
- // command — *does it name the protected path in a position where that path is being written* — and
388
- // these are the two ways it can answer yes without parsing a shell.
389
- //
390
- // A table rather than a parser, for the reason `REF_RULES` further down is a table: recognition by
391
- // exact spelling is a limit a reader can measure, and a matcher clever enough to generalise is clever
392
- // enough to be wrong quietly.
393
-
394
- // `git` is deliberately absent, though `git checkout -- <path>` and `git restore` both overwrite a file.
395
- // The head of those commands is `git`, so admitting it would gate `git diff <path>` and `git log` in the
396
- // same stroke — and a gate on READING a path is a rule no policy here declares. It is the likeliest
397
- // uncovered writer in this repository, which is why ../.portulan/gate-map.md names it rather than leaving
398
- // it inside "any writer outside the table".
399
- /** Commands whose job is to write, replace or remove a file they NAME on their own command line. */
209
+ /** Commands that write, replace or remove a file named on their command line; not `git`, which would gate `git diff <path>` too. */
400
210
  export const FILE_WRITERS = new Set(["cp", "mv", "ln", "rm", "tee", "dd", "install", "truncate", "shred", "patch"]);
401
211
 
402
- /**
403
- * Editors that READ their arguments by default and write them only under an in-place flag.
404
- *
405
- * Separated from the table above rather than folded into it, because folding them in would gate
406
- * `sed -n '1,5p' docs/vision.md` — which is a *read*, and which this policy declares Auto. A matcher
407
- * that contradicts a declared tier is worse than one that admits a gap.
408
- */
212
+ /** Editors that write their arguments only under an in-place flag; `sed -n '1,5p' <path>` is a read. */
409
213
  export const IN_PLACE_EDITORS = new Set(["sed", "gsed", "perl", "ruby"]);
410
214
 
411
- /** Words that stand in front of the command that actually runs. Recognised, not parsed: `sudo cp …` is seen, `sudo -u someone cp …` is not. */
215
+ /** Words before the command that runs, recognised and not parsed: `sudo cp …` is seen, `sudo -u someone cp …` is not. */
412
216
  const COMMAND_PREFIXES = new Set(["sudo", "env", "command", "builtin", "exec", "nohup", "nice", "time"]);
413
217
 
414
218
  const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
415
219
 
416
- // A NEWLINE is a command separator and belongs in this class, which it did not for one review round.
417
- // The Bash tool receives multi-line scripts constantly, so
418
- //
419
- // git status
420
- // cp /tmp/x docs/vision.md
421
- //
422
- // folded into ONE segment whose head was `git` — not a writer — and the whole table half fell through
423
- // to false. The most ordinary spelling there is, and it defeated the gate while the redirection half
424
- // (which reads no head) kept working, so the coverage looked alive. Found by the fresh-context
425
- // supervisor at the pre-merge checkpoint.
426
220
  const OPERATOR = /[|&;<>()\n\r]/;
427
221
 
428
- // Words that lead a segment without being the command in it. `{ cp … ; }`, `if …; then cp …`, and
429
- // `for …; do cp …` all put one of these where the head goes, which hid a writer behind it. Not a
430
- // grammar — a list of leaders, and `find -exec cp` / `xargs cp` stay uncovered because parsing THEIR
431
- // flags to find the real command is the ambitious parser this file keeps refusing to become.
432
222
  const SEGMENT_LEADERS = new Set(["{", "}", "!", "then", "else", "elif", "do", "done", "fi", "esac"]);
433
223
 
434
- /**
435
- * Drop heredoc BODIES, keeping the line that opens them.
436
- *
437
- * A heredoc body is data being written, not commands being run, so reading it as commands is simply
438
- * wrong — and it became wrong the moment a newline started separating commands, because that is what
439
- * turned each body line into its own segment. Measured immediately and expensively: this change's own
440
- * commit, whose message quoted `cp /tmp/x docs/vision.md` as the escape being fixed, was refused by the
441
- * gate it was adding. A matcher that blocks you from describing an attack is not being cautious.
442
- *
443
- * The opening line stays, so `cat > docs/vision.md <<'EOF'` still gates — the redirection is on it.
444
- * What is dropped is the text between the delimiter and its terminator, which no shell executes.
445
- */
224
+ /** The command without its heredoc bodies, which are data; each opening line stays, so a redirection on it still counts. */
446
225
  function stripHeredocs(command) {
447
226
  const out = [];
448
227
  let delimiter = null;
449
- let held = []; // lines after an opener whose terminator has not been seen yet
228
+ let held = [];
450
229
  for (const line of command.split("\n")) {
451
230
  if (delimiter !== null) {
452
231
  if (line.trim() === delimiter) {
453
- // A real heredoc. Its body is data, not command text, so both the body and the
454
- // terminator line go.
455
232
  delimiter = null;
456
233
  held = [];
457
234
  continue;
@@ -463,32 +240,16 @@ function stripHeredocs(command) {
463
240
  const opened = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/.exec(line);
464
241
  if (opened) delimiter = opened[2];
465
242
  }
466
- // **An opener with no terminator was not an opener.** The regex reads a raw line, so `<<EOF`
467
- // inside a quoted string or after a `#` sets `delimiter` on text that opens nothing — and the
468
- // loop above would then swallow every remaining line looking for a word that never comes.
469
- //
470
- // That is a FAIL-OPEN manufactured by a defensive step, which is worse than the parsing gap it
471
- // was added to close: a gated command on any later line becomes invisible to every matcher
472
- // downstream. Measured on the runner before this branch existed —
473
- // `echo "not a heredoc <<EOF"\ngit push --force origin main` stepped aside where the bare
474
- // command answers `ask`, and `# <<EOF` did the same. Found by Copilot review on #60.
475
- //
476
- // Giving the lines back is the fail-CLOSED direction and costs only a false red: a genuine
477
- // heredoc always has its terminator, so this branch cannot reach one. What it does not close is
478
- // a mis-detected opener whose delimiter happens to appear later anyway; that is stated in the
479
- // gate map rather than chased with a quote-aware parser this file refuses to grow.
243
+ // Never terminated, so no heredoc (`<<EOF` in quotes, or after `#`): its lines come back rather than hide a gated command.
480
244
  if (delimiter !== null) out.push(...held);
481
245
  return out.join("\n");
482
246
  }
483
247
 
484
248
  /** A shell command split into words, with unquoted operators kept as words of their own. */
485
- // Exported since 2026-08-24 so the suite can hold `REDIRECTION_TARGET` to THIS definition of a word
486
- // rather than to a reviewer's patience. Three review findings in a row were the same class — the
487
- // target reader narrower than a shell word — and each was fixed at the instance quoted.
488
249
  export function shellWords(command) {
489
250
  const words = [];
490
251
  let text = "";
491
- let open = false; // a word has begun — so an empty `""` argument survives as a word
252
+ let open = false;
492
253
  const flush = () => {
493
254
  if (open) words.push({ text, op: false });
494
255
  text = "";
@@ -496,35 +257,10 @@ export function shellWords(command) {
496
257
  };
497
258
  for (let i = 0; i < command.length; i += 1) {
498
259
  const c = command[i];
499
- // `$'…'` (ANSI-C quoting) and `$"…"` (locale translation) are quoting forms whose `$` is not
500
- // part of the word. Without this the `$` glued onto the front — `$'docs/vision.md'` tokenised
501
- // as `$docs/vision.md` — and no target matched. Measured on the runner before this branch:
502
- // `bash -c $'git push --force origin main'` stepped aside where the plain wrapper answers
503
- // `ask`, and `cp /tmp/x $'docs/vision.md'` stepped aside where the plain target denies. So
504
- // this was a live bypass of both a Gated shell target and the constitution's write gate,
505
- // sitting inside the claim one line down that quoting is honoured. Found by Copilot on #60.
506
- //
507
- // **Two forms and no more, because this grammar is closed.** `$(…)` is command substitution
508
- // and deliberately stays out: its content is a command to run, not a word to read, and
509
- // reading it as a word is precisely how a matcher becomes clever and wrong quietly. That one
510
- // is hole 1's "command assembled at runtime", where it belongs.
260
+ // `$'…'` and `$"…"` quote, and their `$` is not part of the word; `$(…)` runs a command and is deliberately not read.
511
261
  if (c === "$" && (command[i + 1] === "'" || command[i + 1] === '"')) continue;
512
262
  if (c === "'" || c === '"') {
513
- // Quoted runs are taken whole, and the quotes are stripped: `cp /tmp/x 'docs/vision.md'`
514
- // must reach the gate, which is what goes red if this branch is removed. (An earlier
515
- // draft of this comment claimed it was what stops `echo "x > docs/vision.md"` reading as
516
- // a redirection. Measured at the pre-merge checkpoint: false — without this branch that
517
- // target tokenises as `docs/vision.md"`, trailing quote and all, and fails to match
518
- // anyway. Right branch, wrong reason, which `a-stated-enforcer-must-be-the-real-one`
519
- // counts as the same defect one size down.)
520
- // Scanned rather than `indexOf`, for the escaped-quote reason above: inside `"…"` a
521
- // `\"` is a literal quote and must not end the run. The escape is resolved into the word,
522
- // so `cp /tmp/x "docs/\"v\".md"` compares as the path a shell would pass.
523
- //
524
- // `$'…'` reaches here with its `$` already dropped and is read as a single-quoted run, so
525
- // ANSI-C escape sequences (`\n`, `\t`, `\x41`) are NOT decoded — a stated limit, and the
526
- // ambitious parser this file refuses to become. A path spelled with them escapes; that is
527
- // hole 1's "interpolated path" neighbourhood rather than a new one.
263
+ // A backslash escapes only inside `"…"`; `$'…'` reads as `'…'`, its `\x41`-style escapes undecoded.
528
264
  let j = i + 1;
529
265
  let run = "";
530
266
  while (j < command.length) {
@@ -543,77 +279,8 @@ export function shellWords(command) {
543
279
  continue;
544
280
  }
545
281
  if (c === "\\" && i + 1 < command.length) {
546
- // A backslash-newline is a line continuation: both characters vanish, and the word
547
- // continues on the next line. Appending the newline instead would have glued it into the
548
- // middle of a path, which no target matches.
549
- // `\r\n` is consumed as a PAIR, and **the reachability that argued for it has been
550
- // retired.** What stood here from 2026-07-28 until 2026-08-25 was: skipping one character
551
- // left the `\n` behind, a newline is an operator here, so `cp /tmp/x \\<CRLF>docs/vision.md`
552
- // stepped aside where the LF spelling answers `deny` — "the constitution, reachable by
553
- // editing the file on Windows". The first half is a true statement about THIS reader. The
554
- // second is a claim about a shell, and it did not reproduce on any shell measured.
555
- //
556
- // **Measured 2026-08-25 on five shells, with a neutral target path — never the
557
- // constitution.** `bash 3.2.57(1)` (arm64-apple-darwin25, the maintainer's machine),
558
- // `bash 5.2.15(1)` (aarch64-unknown-linux-gnu) and `bash 5.2.37(1)` (x86_64-pc-linux-gnu),
559
- // both in containers; `zsh 5.9`; and **the measured host's `/bin/sh`**, which on that macOS host
560
- // resolves to bash 3.2.57 in POSIX mode — elsewhere `/bin/sh` is commonly `dash` or `busybox`
561
- // and NEITHER was measured, so this row is a fifth reading of bash and zsh rather than a claim
562
- // about `/bin/sh` as such.
563
- // **All five agree, and none of them joins the pair:** the backslash escapes the `\r`, the
564
- // newline then ends the command, and the fragment after it is run as its own command. The
565
- // LF spelling in the same harness DID join and DID write the target, so the harness fires
566
- // rather than reporting nothing everywhere. For the `cp`-shaped payload the retired
567
- // sentence names, the target is left byte-for-byte unchanged on all five — so this branch
568
- // is a **false red** there, fail-closed, and worth one prompt.
569
- //
570
- // **It is not a false red everywhere, which is why the branch is still here.** A shell
571
- // applies a redirection BEFORE it looks the command up, so a clobbering `>` or `1>` on the
572
- // fragment left by the split still fires and truncates its target to zero bytes — measured
573
- // on all three bash builds. `>>` appends and does not. The carriers of that split verdict
574
- // are `./fuzz-shell.mjs`'s `crlf-continuation-in-the-payload` EXPECT cells and
575
- // `./compile.test.mjs`'s paired CRLF assertions. **Not `../.portulan/gate-map.md`**, which
576
- // an earlier draft of this sentence named and which carries no CRLF entry at all — a
577
- // citation to a record that does not exist, in the comment repairing exactly that.
578
- //
579
- // **Removing the branch is fail-OPEN on a gate matcher, so it is the maintainer's and not
580
- // this comment's.** Deleting BOTH carriers in a scratch clone and running the recipes moves
581
- // **four** surfaces, which is the count an accepting change has to carry:
582
- // 1. `fuzz-shell` — the `crlf-continuation-in-the-payload|write-named` EXPECT cell, whose
583
- // recorded divergence CLOSES. Good news, and a red until the record absorbs it.
584
- // 2. `goldens` — the `a-CRLF-continuation` fixture in
585
- // `../evals/goldens/gates/edit-the-constitution.json` regresses.
586
- // 3. `./compile.test.mjs` — **two** direct assertions fail, *a CRLF continuation before
587
- // the path* and *a CRLF continuation after `>`*.
588
- // 4. `mutants` — exit **2, could-not-run**, downstream of 2: a census over a red corpus
589
- // measures nothing, so it refuses until the fixture is repaired.
590
- // **An earlier draft of this comment said "those two and nothing else" and had counted only
591
- // the first two** — an exhaustive claim that had not run the suite it was exhaustive about,
592
- // in the comment repairing an unmeasured claim. Reported by Copilot, round 1 on #342.
593
- // What does NOT move, **and the reason an earlier draft gave for it was false.** That draft
594
- // said "the redirection is recognised off the raw segment text rather than off a joined
595
- // word". There is no raw-text path: `shellWrites` iterates `shellSegments`, which is built
596
- // from `shellWords`. The real reason is narrower and is a property of WHERE the pair sits.
597
- // With the branch gone the newline splits, and for the fuzzer's shape — pair after the head,
598
- // `echo \<CRLF>ok > docs/vision.md` — the `> docs/vision.md` lands in a LATER segment whose
599
- // `redirects` still name the target, so that cell stays `true`. Put the pair AFTER the `>`
600
- // and the opposite happens: the operator takes the escaped `\r` as its target and the path
601
- // becomes an ordinary word in the next segment — which is precisely why surface 3's second
602
- // assertion fails. **So the true positive survives for this production and NOT for every
603
- // redirect spelling**, and generalising it was the overclaim. Reported by Copilot, rounds 2
604
- // and 3 on #342. A real LF continuation is untouched either way. Asked at
605
- // `../.portulan/proposals/0031-a-continuation-no-shell-joins.md`; until that is ruled, the
606
- // branch stays and this comment states what was measured rather than what was argued.
607
- //
608
- // **The honest limit, and it is the platform the retired sentence named: no Windows-side
609
- // bash was measured** — not git-bash, MSYS2, Cygwin (whose `igncr` exists precisely for
610
- // this) or WSL. Nor was any bash 4.x. The standing rail is `./fuzz-shell.ground.test.mjs`,
611
- // which runs this production under **whatever bash the host running the `tests` recipe
612
- // has** and reds if that bash joins the pair — so a joining shell is caught wherever the
613
- // recipe runs, CI included, and is caught nowhere the recipe does not run. That boundary is
614
- // stated because it is the whole of the enforcement: the container measurements above are a
615
- // record, not a recipe, since a recipe that pulled an image would be a network call in CI.
616
- // Found by Copilot review on #60; the claim retired by measurement 2026-08-25.
282
+ // A backslash-newline continues the word. `\r\n` is one pair here, though bash 3.2 and 5.2 and zsh 5.9 end the
283
+ // command at its `\n` (no Windows bash measured): that fails closed, and dropping the pair fails open.
617
284
  if (command[i + 1] === "\r" && command[i + 2] === "\n") {
618
285
  i += 2;
619
286
  continue;
@@ -627,8 +294,7 @@ export function shellWords(command) {
627
294
  i += 1;
628
295
  continue;
629
296
  }
630
- // Operators are tested BEFORE whitespace, because a newline is both and the separator reading
631
- // is the load-bearing one.
297
+ // Before the whitespace test: a newline is both, and must separate.
632
298
  if (OPERATOR.test(c)) {
633
299
  flush();
634
300
  let run = c;
@@ -650,7 +316,6 @@ export function shellWords(command) {
650
316
  return words;
651
317
  }
652
318
 
653
- /** Those words folded into `{ head, args, redirects }` per command in a pipeline or list. */
654
319
  function shellSegments(command) {
655
320
  const segments = [];
656
321
  let current = { head: null, args: [], redirects: [] };
@@ -661,14 +326,6 @@ function shellSegments(command) {
661
326
  };
662
327
  for (const word of shellWords(command)) {
663
328
  if (word.op) {
664
- // `>` `>>` `2>` `&>` — whatever follows is written, whatever the command is. `<` and `<<`
665
- // introduce something READ, so the word after them is SKIPPED rather than ending the
666
- // segment: `tee < /tmp/in docs/vision.md` keeps `tee` as the head and its real argument
667
- // as an argument, which is what goes red if this branch is removed. (An earlier draft
668
- // justified it with `patch f < d.diff` instead. Measured: that one is safe either way,
669
- // because without this branch the `<` merely closes the segment and `d.diff` becomes a
670
- // head rather than an argument. A branch defended by a hazard that cannot happen is a
671
- // branch nobody can review.)
672
329
  if (word.text.includes(">")) pending = "written";
673
330
  else if (word.text.includes("<")) pending = "read";
674
331
  else {
@@ -694,23 +351,13 @@ function shellSegments(command) {
694
351
  return segments;
695
352
  }
696
353
 
697
- /** Does this segment write the files it names? */
698
354
  function writesWhatItNames({ head, args }) {
699
355
  if (head === null) return false;
700
356
  if (FILE_WRITERS.has(head)) return true;
701
- // `-i`, `-i.bak`, `-pi`, `--in-place`, `--in-place=.bak`. A long option other than `--in-place`
702
- // cannot match, because the single-dash form requires a letter run and `-` is not one.
703
357
  return IN_PLACE_EDITORS.has(head) && args.some((a) => /^--in-place(=|$)/.test(a) || /^-[a-zA-Z]*i/.test(a));
704
358
  }
705
359
 
706
- /**
707
- * A path as typed on a command line, reduced to the spelling the tail comparison can read.
708
- *
709
- * `matchesPath` compares tails literally, which is right for the host's own clean absolute paths and
710
- * wrong for anything a person types: `docs/./vision.md` and `docs//vision.md` are the constitution
711
- * and neither ends with `/docs/vision.md`. Both were live escapes for one review round. `..` is
712
- * resolved for the same reason and in the same place — `foo/../docs/vision.md` is the file too.
713
- */
360
+ /** A typed path with its `.`, empty and `..` segments resolved, since `matchesPath` compares tails literally. */
714
361
  function normalisePath(p) {
715
362
  const absolute = p.startsWith("/");
716
363
  const out = [];
@@ -722,169 +369,48 @@ function normalisePath(p) {
722
369
  return (absolute ? "/" : "") + out.join("/");
723
370
  }
724
371
 
725
- /**
726
- * Does this target match NO path a host actually submits? `true` means none can — the rule matches
727
- * nothing, whatever it says.
728
- *
729
- * _(That first line asked the inverse question until 2026-09-09 — "can any path fall under this target?
730
- * `false` means none can" — so it described a return value the function does not have. Eight
731
- * fresh-context gradings read past it, four of them grading this docblock's accuracy: every one checked
732
- * whether the predicate was right and none checked whether its opening sentence matched its own
733
- * `return`. Found by Copilot on #408.)_
734
- *
735
- * **The predicate is a comparison between two forms of the target, and that is what makes it the
736
- * class rather than a list.** `matchesPath` compares a *tail* — it strips a leading `./` and any
737
- * leading `/` and then asks whether the candidate ends with, or contains, what is left. A host is
738
- * **expected** to hand over a normalised absolute path — no `.` segment, no empty segment, no
739
- * backslash — and that is an assumption rather than something measured here, argued below. So the
740
- * target can match only if its comparison form is already normalised. Where the two forms differ, the comparison is against
741
- * a spelling no candidate can carry, and the rule matches nothing whatever it says.
742
- *
743
- * That covers three families at once, which is why it is written as a comparison and not as a list of
744
- * spellings a reader would have to keep in step:
745
- *
746
- * - **The target that reduces to nothing**, which compiles to `Edit(./)`, `Edit(./.)`, or — for one
747
- * spelling — the real subtree glob `Edit(././**)`.
748
- * - **The target with an interior `.` or empty segment**, each compiling to a named surface that reads
749
- * like a real gate on a plausible path, and matches nothing.
750
- * - **The target carrying a backslash**, and this one is why the argument above needs its second half
751
- * stated: `matchesPath` normalises the **candidate**'s backslashes to `/` and never the target's, so
752
- * a backslash in a target is compared against a character no candidate can still contain, **on every
753
- * platform**. Unlike the first two families this one has no conditional reading at all.
754
- *
755
- * **The host assumption, named because it is one.** *No `.` segment, no empty segment, no backslash*
756
- * describes what a host is expected to send, and this repository does not control that and has not
757
- * measured an unnormalised `file_path` arriving. The refusal does not rest on it: a rule matching only
758
- * `/repo/x/.` gates nothing anybody meant to gate whichever way the assumption falls.
759
- *
760
- * **Four cuts, three of them incomplete, always in the same direction — and that arc is why this is a
761
- * comparison rather than a list.** Cut 1 lifted `matchesPath`'s own strip and caught two of the first
762
- * family's five. Cut 2 asked whether the target normalised to **empty**, catching that family alone
763
- * while three carriers of its prose claimed the class. Cut 3 added the interior family. Cut 4 added the
764
- * backslashes. Every miss was found by putting neighbouring spellings through the function at a
765
- * checkpoint, never by re-reading the sentence. A predicate defined by the instances it was written for
766
- * is ../.portulan/proposals/0020-a-fix-is-not-done-at-the-site-it-was-found.md's shape, and **this
767
- * docblock is the one carrier of that arc** — the records cite it rather than recounting it.
768
- *
769
- * The thirteen spellings and the controls that must stay matchable are enumerated **and measured** in
770
- * `./compile.test.mjs`'s `NEVER` and `CONTROLS` — the only carrier that measures them. Four records
771
- * enumerate them for a reader; this docblock cites the suite rather than joining them.
772
- *
773
- * **`matchesPath` is left byte-identical on purpose**, and that is a decision rather than an omission:
774
- * its answers are asserted by twelve `documented-hole` cases in ../evals/goldens/gates/ and by
775
- * `mutants` operators anchored on its text, so moving its strip would move two census anchors and exit
776
- * the census 2 — could-not-run wearing a pass's clothes. This function re-spells that strip rather than
777
- * importing it, which is one carrier more than ideal and is pinned by a suite case comparing the two on
778
- * every target below.
779
- *
780
- * The subtree marker is **meaning, not spelling**: exactly one trailing `/` says *the subtree* and is
781
- * removed before the comparison, which is why `docs/` is a real target and `docs//` is not.
782
- *
783
- * What this does NOT answer is what `./` should MEAN as a policy target. That is
784
- * https://github.com/sleepy-panda-srl/portulan/issues/337's reserved question, and this predicate
785
- * answers only whether a target can *never* match.
786
- */
372
+ /** True when no host-submitted path can match: the target is not normalised, or keeps a backslash no candidate can hold. */
787
373
  export function neverMatches(target) {
788
374
  const clean = String(target ?? "").replace(/^\.\//, "").replace(/^\/+/, "");
789
- // `clean === "/"` is UNREACHABLE, and so is the `body === ""` arm below — the strip above removes
790
- // every leading slash, so `"/"` arrives here as `""`. Measured, not assumed. They are kept anyway
791
- // because `matchesPath` carries the identical pair one screen down and this predicate is meant to be
792
- // read beside it: deleting them here would make two functions that answer the same question about
793
- // the same string look different, to save two comparisons nobody runs. Copilot raised it on #408;
794
- // the answer is that the redundancy is deliberate and now says so, which is the half it was missing.
375
+ // `clean === "/"` and `body === ""` are unreachable, kept to read line for line with `matchesPath`.
795
376
  if (clean === "" || clean === "/") return true;
796
377
  const body = clean.endsWith("/") ? clean.slice(0, -1) : clean;
797
- // The backslash arm is separate because it is about the CANDIDATE's normalisation rather than the
798
- // target's: `matchesPath` rewrites `\\` to `/` in what the host hands over, so a target keeping one
799
- // is compared against a character the comparison has already removed from every candidate.
800
378
  if (body.includes("\\")) return true;
801
379
  return body === "" || normalisePath(body) !== body;
802
380
  }
803
381
 
804
- /**
805
- * The directories a target lives under, as exact paths: `a/b/c.md` -> `a`, `a/b`.
806
- *
807
- * No trailing slash, and that is the whole correctness of it. Spelled `a/` these would be SUBTREE
808
- * patterns, and `matchesPath` would then report every file under the directory as a hit — so
809
- * `cp foo docs/plan.md` matched a rule protecting `docs/vision.md`, which is a false red on an
810
- * ordinary edit to an unrelated file. Caught by this change's own probe before it left the worktree.
811
- */
382
+ /** The directories above a target, `a/b/c.md` -> `a`, `a/b`, with no trailing `/`, which would match every file under each. */
812
383
  function ancestors(target) {
813
384
  const parts = normalisePath(target.replace(/\/+$/, "")).split("/").filter(Boolean);
814
385
  return parts.slice(0, -1).map((_, i) => parts.slice(0, i + 1).join("/"));
815
386
  }
816
387
 
817
- /**
818
- * Does a word name the target — quoted, `./`-prefixed, absolute, messy, or after an `of=`-style `=`?
819
- *
820
- * With `orAncestor`, a word naming a DIRECTORY the target lives in counts too. That is what makes
821
- * `rm -rf docs` and `mv docs docs.bak` reach a rule protecting `docs/vision.md`: destroying the
822
- * container destroys the file, and a gate that reads `rm -rf docs/` and misses `rm -rf docs` is
823
- * decided by a trailing slash. Ancestors apply to a writer's arguments only, never to a redirection
824
- * target — `> docs` writes a file called `docs`, it does not remove a directory.
825
- */
388
+ /** Whether a word, or its value after an `of=`-style `=`, names the target; with `orAncestor`, a directory above it counts too. */
826
389
  function namesTarget(word, target, orAncestor = false) {
827
390
  const eq = word.indexOf("=");
828
391
  const candidates = eq > 0 ? [word, word.slice(eq + 1)] : [word];
829
392
  return candidates.some((c) => {
830
393
  const clean = normalisePath(c);
831
394
  if (clean === "") return false;
832
- // A relative word is given the leading separator the tail comparison needs, rather than a
833
- // second matcher being written for it.
834
395
  const rooted = clean.startsWith("/") ? clean : `/${clean}`;
835
- // The target itself. A subtree target wants the candidate to look like a directory, which is
836
- // what lets `rm -rf .portulan` reach a rule written `.portulan/`.
396
+ // A subtree target needs the word to read as a directory: `rm -rf .portulan` reaches `.portulan/`.
837
397
  if (matchesPath(target.endsWith("/") ? `${rooted}/` : rooted, target)) return true;
838
- // A directory the target lives in, named EXACTLY — `rm -rf docs`, never `cp x docs/plan.md`.
839
398
  return orAncestor && ancestors(target).some((a) => matchesPath(rooted, a));
840
399
  });
841
400
  }
842
401
 
843
- /**
844
- * The commands in a shell line, as SOURCE text, split on unquoted separators.
845
- *
846
- * The shell matcher below prefix-matches a command string, which meant a gate held only when its
847
- * command came FIRST: `ls && git push --force origin main` reached no gate, and neither did
848
- * `git status; gh pr merge 60` or `cd . && gh repo delete foo`. Every Gated outward action in
849
- * ../.portulan/gates.json was defeated by putting anything in front of it.
850
- *
851
- * That is the same defect as the `write:` one this change began with — a matcher reading only the
852
- * head of a command string — one action kind over, so it is fixed in the same stroke rather than
853
- * left as a sibling nobody comes back for. Found by the fresh-context supervisor at the pre-merge
854
- * checkpoint, which is exactly the class it was asked to hunt.
855
- *
856
- * Quoting is respected, so `echo "git push --force"` still splits into one segment that no gate
857
- * matches; and each segment keeps the whitespace boundary the prefix match relies on, so
858
- * `--force-with-lease` stays Auto.
859
- */
402
+ /** A shell line's commands as source text, split on unquoted separators, without heredoc bodies or leading redirections. */
860
403
  function commandSegments(raw) {
861
- // `String(raw ?? "")`, for the reason `spellings` does the same: `matchesRule` promises never to
862
- // throw, and a Bash payload carrying no `command` — a malformed host message, a renamed field, a
863
- // test — would otherwise reach `stripHeredocs` and die on `.split`.
864
- //
865
- // What that costs is not an exception some caller reports. ./gate.mjs catches
866
- // and steps aside, so the throw would SILENTLY remove the shell-write half of the constitution
867
- // gate — hole 3, the half with no permission rule beneath it. Introduced earlier on this branch
868
- // by the segment-composition fix, which began passing the raw payload here in place of a spelling
869
- // that had already been stringified. Found by Copilot review on #60.
870
404
  const command = stripHeredocs(String(raw ?? ""));
871
405
  const out = [];
872
406
  let start = 0;
873
407
  let quote = null;
874
- // The index of the character a backslash most recently turned into data. See the `\\` branch.
408
+ // The last character a backslash made data: `\>` is a literal, not a redirection operator.
875
409
  let escaped = -1;
876
410
  for (let i = 0; i < command.length; i += 1) {
877
411
  const c = command[i];
878
412
  if (quote) {
879
- // Inside `"…"` a backslash escapes the next character, so `\"` is a literal quote and does
880
- // NOT close the run. Without this, `echo "x\""; git push --force …` closed early, the real
881
- // closing quote opened a new run, and the `;` was swallowed inside it — no split, no
882
- // segment, no gate. Measured stepping aside where the unescaped spelling answers `ask`,
883
- // and the same shape reached `cp /tmp/x docs/vision.md`, so it was a false green on the
884
- // constitution too. Found by Copilot review on #60.
885
- //
886
- // Single quotes are deliberately NOT included: POSIX gives `'…'` no escapes at all, so a
887
- // backslash there is a literal backslash and honouring it would invent a hole.
413
+ // A backslash escapes inside `"…"` only: POSIX gives `'…'` no escapes, and honouring one there would open a hole.
888
414
  if (quote === '"' && c === "\\" && i + 1 < command.length) {
889
415
  i += 1;
890
416
  continue;
@@ -897,60 +423,13 @@ function commandSegments(raw) {
897
423
  continue;
898
424
  }
899
425
  if (c === "\\") {
900
- // The same CRLF pair, in the other reader. No spelling was measured behaving differently
901
- // here — a continuation split into two segments still leaves a gated command at the head
902
- // of one — so this is fixed for the reason the session kept re-learning rather than for a
903
- // failing case: one carrier corrected and its sibling left is how the last three defects
904
- // on this branch happened.
905
- // **The pair's two carriers are this line and `shellWords`, and the measurement retiring
906
- // the reachability claim is written up there once.** No shell measured joins the pair, so
907
- // consuming it here is unfaithful to a shell in the same direction — **but this carrier
908
- // records no false red of its own, and an earlier draft of this comment said it did.** On
909
- // the shell path its `crlf-continuation-in-the-payload|shell` cell answers `false`, the
910
- // correct negative: nothing gated runs there. On the write path it is an UNWRAPPER feeding
911
- // `shellWrites`, reached through a short-circuiting `||` after the un-peeled spellings, so
912
- // the recorded false red — `write-named` — is produced by `shellWords` and not here. **The
913
- // reason to keep the two readers in step is synchronisation, not a shared verdict**: a
914
- // carrier corrected while its sibling stands is how the last several defects on this line
915
- // happened. Reported by Copilot, round 2 on #342. That is why
916
- // `../.portulan/proposals/0031-a-continuation-no-shell-joins.md` names both carriers rather
917
- // than the one whose comment argued for it. Reported by Copilot, round 1 on #342.
426
+ // `\r\n` as one pair, in step with `shellWords`.
918
427
  i += command[i + 1] === "\r" && command[i + 2] === "\n" ? 2 : 1;
919
- // **Remember WHICH character this backslash turned into data.** The redirection-operator
920
- // check below reads one raw neighbour, and a raw read cannot tell `>` the operator from
921
- // `\>` the literal. Without this, `echo \>| git push --force origin main` — a REAL pipe,
922
- // measured in bash delivering bytes downstream — stopped splitting at the `|`, and the
923
- // force-push after it went invisible to every matcher. `\>&` did the same across a real
924
- // background separator. Both were caught at HEAD and were narrowed by the #71 fix itself;
925
- // found by the pre-commit checkpoint, which attacked the change rather than reading it.
926
428
  escaped = i;
927
429
  continue;
928
430
  }
929
- // `#` is NOT treated as starting a comment, and that is a decision rather than an oversight.
930
- // `echo ok #; git push --force origin main` therefore segments as though the comment were
931
- // code and answers `ask`, which a real shell would not — a false RED, measured 2026-07-28.
932
- //
933
- // Taking it would mean deciding where a comment begins, and the failure mode of getting that
934
- // wrong runs the other way. A `#` opens a comment only at a word boundary and only outside
935
- // quotes; a reader that skipped from the first `#` would swallow the rest of
936
- // `echo "a#b"; git push --force origin main` — a real gated command, measured answering `ask`
937
- // today — and turn a false red into a false GREEN. The docblock on `shellWrites` states the
938
- // exchange rate: a false red costs one prompt on a rare spelling, a false green costs the
939
- // laundering the rule exists to prevent.
940
- //
941
- // Both spellings are asserted in the suite so this stays a choice rather than drift.
942
- // Reported by Copilot review on #60 and declined on those grounds.
943
- // An `&` or `|` bound into a redirection OPERATOR is not a separator, and splitting there is
944
- // what made the strip below unreachable for three of the four spellings. `2>&1 git push
945
- // --force …` split at the `&` into `2>` and `1 git push --force …`, whose head is `1` — the
946
- // redirection was already in pieces by the time anything could strip it, and `>| f git push
947
- // --force …` broke the same way at the `|`. Both operators are closed forms: `&` preceded by
948
- // `>`/`<` or followed by `>`, and `|` preceded by `>`. `a && b`, `a & b` and `a | b` are
949
- // untouched — none of them puts a redirection character next to the separator.
950
- // `i - 1 !== escaped` on both arms: a `>` or `<` that a backslash turned into DATA is not an
951
- // operator, and treating it as one un-splits a real separator. The `command[i + 1]` arm needs
952
- // no such guard — an escaped character is always preceded by its own backslash, so the
953
- // character after `&` can never be an escaped `>`.
431
+ // `#` is not read as a comment: a false red on `echo ok #; git push`, where misplacing one would be a false green.
432
+ // `&` and `|` inside a redirection operator (`2>&1`, `&>`, `>|`) do not separate, unless the `>` or `<` was escaped.
954
433
  const opBefore = i - 1 !== escaped && (command[i - 1] === ">" || command[i - 1] === "<");
955
434
  if (c === "&" && (opBefore || command[i + 1] === ">")) continue;
956
435
  if (c === "|" && i - 1 !== escaped && command[i - 1] === ">") continue;
@@ -963,110 +442,25 @@ function commandSegments(raw) {
963
442
  return out.map((s) => stripLeadingRedirections(s.trim())).filter(Boolean);
964
443
  }
965
444
 
966
- /**
967
- * A redirection sitting where the command goes: an optional file descriptor, one of the operators
968
- * below, and a word.
969
- *
970
- * **This is the one leader that is stripped, and the asymmetry is the point** (#71). The other
971
- * leaders `commandSegments` leaves alone — `env`, `sudo`, `FOO=bar`, `then`, `do`, a brace group —
972
- * are refused for a reason that does not apply here: a table of command prefixes has no natural edge
973
- * (`nice`, `time`, `nohup`, `timeout`, `command`, `stdbuf`, `doas`), and one missing entry buys
974
- * exactly the false confidence a hole list exists to deny. A redirection's grammar is CLOSED, so it
975
- * can be stripped completely with an edge a reader can check. #60 left it open deliberately, because
976
- * closing it would have been a matcher change on the same commit that stopped hole 2 overclaiming.
977
- *
978
- * **Do not extend this to a named table of command prefixes.** That is the change the paragraph
979
- * above refuses, and the two look similar enough to be proposed together.
980
- *
981
- * Applied repeatedly, because redirections stack: `> /tmp/out 2>&1 git push --force …` carries two.
982
- */
983
- /**
984
- * The redirection TARGET, as one word.
985
- *
986
- * `[^\s]+` was wrong and shipped for exactly one review round: a redirection target may contain
987
- * spaces when it is quoted or escaped, and `> "foo bar" git push --force …` then stripped only
988
- * `> "foo`, leaving `bar" git push --force …` — head `bar"`, no gate, and the segment text corrupted
989
- * into the bargain. Measured escaping on five spellings (both quote styles, an escaped space, `2>` and
990
- * `>|`), and bash confirmed to run the command after each. **A fix that closes a hole by a width its
991
- * own test never probed**, found by Copilot round 1 — which named the double-quoted case; the other
992
- * four are its siblings and are closed in the same stroke rather than left for the next round.
993
- *
994
- * A word is therefore a run of quoted spans, backslash-escaped characters, and ordinary non-space
995
- * characters — the same three things `shellWords` recognises, kept to a regex here because this reader
996
- * needs the target's EXTENT and never its value.
997
- *
998
- * **Inside a double-quoted span a backslash escapes the next character, and inside a single-quoted one
999
- * it does not.** POSIX gives `'…'` no escapes at all, so honouring one there would invent a hole —
1000
- * which is the same asymmetry `commandSegments`'s own quote loop already carries, and the same one
1001
- * `shellWords` carries at its quoted-run branch.
1002
- *
1003
- * **This is the THIRD review finding of one class, and the class is what the suite now pins.** Round 1
1004
- * found `[^\s]+` too narrow for a quoted target; round 3 found `"[^"]*"` too narrow for an escaped
1005
- * quote *inside* one — `> "foo \"bar baz\"" git push --force …`, measured escaping while bash was
1006
- * measured creating the file and running the command. Both times the fix landed at the spelling that
1007
- * was quoted. So `compile.test.mjs` now asserts this pattern against `shellWords` itself: every
1008
- * spelling that reader calls ONE word must be consumed whole here. A fourth sibling cannot be found by
1009
- * a reviewer first.
1010
- *
1011
- * Linear rather than backtracking-prone despite the alternation: the pattern is anchored, the trailing
1012
- * `\s*` matches empty, and the `+` is greedy over a match that always succeeds once an operator has
1013
- * been seen — so there is no failure to backtrack into. Probed to 200KB.
1014
- */
445
+ /** A redirection target: one shell word, as `shellWords` reads it. Linear: once an operator matches, the match cannot fail. */
1015
446
  const REDIRECTION_TARGET = String.raw`(?:"(?:\\[\s\S]|[^"\\])*"|'[^']*'|\\[\s\S]|[^\s])+`;
1016
447
 
1017
448
  const LEADING_REDIRECTION = new RegExp(String.raw`^\d*(?:&>>|&>|>>|>&|>\||<&|<>|<|>)\s*${REDIRECTION_TARGET}\s*`);
1018
449
 
450
+ // The one segment leader stripped: a redirection's grammar is closed, where a table of command prefixes would have no edge.
1019
451
  function stripLeadingRedirections(segment) {
1020
452
  let text = segment;
1021
453
  for (;;) {
1022
454
  const shorter = text.replace(LEADING_REDIRECTION, "");
1023
- // A segment that is ONLY a redirection strips to the empty string and is dropped by the
1024
- // caller's `filter(Boolean)`, which is correct: a redirection is not a command.
1025
455
  if (shorter === text) return text;
1026
456
  text = shorter;
1027
457
  }
1028
458
  }
1029
459
 
1030
460
  /**
1031
- * Does this shell command write a path a `write:` rule protects?
1032
- *
1033
- * The permission layer cannot ask this. `Bash(prefix:*)` matches a literal command PREFIX and the
1034
- * path sits at an arbitrary position in the command, so there is no pattern in that DSL which means
1035
- * "any command writing this file" — which makes this the hook's coverage alone, exactly like the
1036
- * wrapper spelling above, and it fails open on the same terms.
1037
- *
1038
- * **Stated at its real size, because the boundary is the point.** Two recognitions, both by table:
1039
- * a `>`/`>>` redirection into the path, and a file-writing command that names it — or names a
1040
- * directory the path lives in, since removing the container removes the file.
1041
- *
1042
- * A heredoc is NOT on that list, and the omission is worth stating rather than leaving to be read as
1043
- * one: the body is stripped and the command line survives, so `cat > docs/vision.md <<EOF` is reached
1044
- * and gated — asserted in the suite. What escapes is an interpolated TARGET, heredoc or not, which is
1045
- * the first item below. The emitted note said "a heredoc" flatly until 2026-07-28 and overstated the
1046
- * hole; a gate map that overstates a hole is as wrong as one that hides it, and so is a compiler note.
1047
- *
1048
- * What that leaves open, listed because a hole list that is wrong is worse than none: an
1049
- * interpolated path (`> $VISION`), a command assembled at runtime, a language runtime writing the
1050
- * file itself (`python3 -c`), a writer absent from the table (`ex`, and `git checkout` deliberately
1051
- * — see above), a program that INVOKES a writer (`find -exec cp`, `xargs cp`) because parsing their
1052
- * flags to find the real command is the parser this file refuses to become, and two shell wrappers.
1053
- * Quoting is respected only to one level of nesting, so a write-shaped string inside a `node -e`
1054
- * script can produce a false RED — measured on this repository's own tooling.
1055
- *
1056
- * The first version of this shipped a four-item hole list that was missing five holes, including
1057
- * the plainest spelling there is: a newline. A fresh-context supervisor found them by trying to
1058
- * defeat the matcher rather than by reading it, which is the only way that list gets checked.
1059
- *
1060
- * This closes the spelling reached for by accident or convenience; it does not close one
1061
- * constructed on purpose, and no matcher here could. What must not happen regardless of spelling
1062
- * belongs on the platform floor — ../core/operating/autonomy.md.
1063
- *
1064
- * **Every named argument of a writer counts, not only its destination.** `cp docs/vision.md /tmp/x`
1065
- * reads the protected path rather than writing it, and this matches it anyway. Deliberate: argument
1066
- * grammars differ per command (`dd of=`, `tee f1 f2`, `install -t dir src`), so "the last word is
1067
- * the destination" is true of a subset only, and being wrong about it is a false GREEN on a rule
1068
- * whose whole reason is that the file must not change. A false red here costs one prompt on a rare
1069
- * operation; a false green costs the laundering the rule exists to prevent.
461
+ * Whether a shell command writes the target: a `>` or `>>` into it, or a writer from the tables naming it or a directory
462
+ * above it in any argument, its source too. Missed: an interpolated path, a command built at runtime, a runtime writing
463
+ * the file (`python3 -c`), a writer outside the tables (`ex`, `git checkout`), `find -exec` or `xargs`, a second wrapper.
1070
464
  */
1071
465
  export function shellWrites(command, target) {
1072
466
  for (const segment of shellSegments(stripHeredocs(String(command ?? "")))) {
@@ -1076,108 +470,30 @@ export function shellWrites(command, target) {
1076
470
  return false;
1077
471
  }
1078
472
 
1079
- /** Does a path handed over by the host fall under a policy target? */
473
+ /** Whether a host-submitted path falls under a policy target, compared by tail: the hook knows no reliable repository root. */
1080
474
  export function matchesPath(candidate, target) {
1081
475
  if (typeof candidate !== "string" || candidate === "") return false;
1082
476
  const clean = String(target ?? "").replace(/^\.\//, "").replace(/^\/+/, "");
1083
477
  if (clean === "" || clean === "/") return false;
1084
- // Compared against the tail rather than resolved against a root: the host hands over an
1085
- // absolute path, the policy names a repository-relative one, and the hook has no reliable
1086
- // repository root at hook time — its cwd is the session's, which need not be the repo.
1087
478
  const normalised = candidate.replace(/\\/g, "/");
1088
479
  return clean.endsWith("/") ? normalised.includes(`/${clean}`) : normalised.endsWith(`/${clean}`);
1089
480
  }
1090
481
 
1091
- /** Does an attempted tool call fall under this rule? Returns true/false; never throws. */
482
+ /** Whether a tool call falls under the rule; never throws, since the gate steps aside on a throw. */
1092
483
  export function matchesRule(rule, tool, input = {}) {
1093
484
  const action = rule?.action ?? {};
1094
485
  if (typeof action.shell === "string" && tool === "Bash") {
1095
- // A shell target ending in `/` is a PATH PREFIX covering the subtree — the meaning `pattern()`
1096
- // already gives a trailing slash for write and read, and the meaning the emitted
1097
- // `Bash(target:*)` rule already has on the host, which prefix-matches the command string.
1098
- // Without this branch the two halves this section exists to keep identical disagreed: for
1099
- // `"./.portulan/verify/"` the emitted rule covers `./.portulan/verify/docs.sh` and this
1100
- // matcher did not, because a path prefix is never followed by a space.
1101
- //
1102
- // **Stated at its real size.** The one target of this shape in ../.portulan/gates.json is
1103
- // `run-a-verify-recipe`, which is `auto` — refused from compilation, and skipped by
1104
- // ./gate.mjs, which reads only `gated` and `prohibited`. So nothing was
1105
- // mis-enforced today; what existed was a divergence between two definitions this file
1106
- // promises are one, waiting for the first gated rule written in the path form. Fixed on that
1107
- // basis rather than on an incident. An ordinary command prefix keeps its whitespace boundary,
1108
- // or `git push` would cover `git pushall`. Found by a Copilot review comment on #31.
1109
- //
1110
- // Each spelling is tested WHOLE and per segment. Whole, because a path-prefix target is
1111
- // matched against the command as written; per segment, because a gated command that is not
1112
- // the first thing on the line is still that command — see `commandSegments`.
486
+ // A target ending in `/` is a path prefix, as the host's `Bash(target:*)` reads it; any other ends at a word boundary.
1113
487
  const hit = (s) =>
1114
488
  action.shell.endsWith("/") ? s.startsWith(action.shell) : s === action.shell || s.startsWith(`${action.shell} `);
1115
- // `spellings` is applied to each SEGMENT as well as to the whole line, and the second call is
1116
- // not redundant. Unwrapping was anchored at the start of the command, so a wrapper that was
1117
- // not the first thing on the line never got unwrapped: `ls && bash -c "git push --force
1118
- // origin main"` stepped aside, while both halves of it worked alone — the wrapper spelling is
1119
- // hole 1's "one level, peeled", and mid-line reach is hole 2. Two claims that each held and
1120
- // did not compose, which is how a gap survives a reader checking either one.
1121
- //
1122
- // Found while writing a regression test for the `$'…'` form Copilot reported; the plain-quote
1123
- // spelling turned out to escape the same way, so this is wider than the report.
1124
- //
1125
- // **Both calls read the RAW command, and that is the whole care in this line.** The first
1126
- // draft segmented each *spelling* and unwrapped again — which peeled TWO levels for a nested
1127
- // wrapper and made `bash -c "bash -c 'git push origin HEAD'"` match, contradicting hole 1's
1128
- // documented and asserted limit. The suite caught it, which is what that counterexample test
1129
- // is for. Reading the raw command in both branches keeps the budget at exactly one unwrap.
1130
- //
1131
- // **A spelling is SEGMENTED as well as tested whole, and that half was missing until
1132
- // 2026-08-25.** The two calls above unwrap the raw command and unwrap each of its segments,
1133
- // which closes a wrapper before a separator — `ls && bash -c "git push --force origin main"`
1134
- // — and leaves a separator inside a wrapper wide open: `bash -c "ls; git push --force origin
1135
- // main"` answered **false**, and so did every other Gated shell action behind one wrapper and
1136
- // one separator. Bash runs it; the gate did not see it.
1137
- //
1138
- // **The write branch below never had this gap, and the reason is why the fix is shaped like
1139
- // this.** Its `writes` callback is `shellWrites`, which segments AGAIN internally with
1140
- // `shellSegments` — so the write half got a third segmentation for free while the shell half,
1141
- // whose `hit` is a plain prefix test, got none. One fix landing in one carrier and not its
1142
- // sibling, between two branches of one function, for the second time
1143
- // (`proposals/0020`). Found by ./fuzz-shell.mjs, which
1144
- // generates the grammar rather than reading it.
1145
- //
1146
- // `reach` is used on BOTH arms deliberately: without it on the second,
1147
- // `ls && bash -c "x; git push --force …"` — a separator outside AND inside — still escaped.
1148
- // Fixing only the spelling that was quoted is the class this repository keeps meeting.
1149
- //
1150
- // **The unwrap budget is still exactly one level**, which is hole 1's documented and asserted
1151
- // limit. `spellings` is called once on each path here and `commandSegments` peels nothing, so
1152
- // `bash -c "sh -c 'git push origin HEAD'"` stays false — asserted, because an earlier draft of
1153
- // the segment composition did peel twice and the suite is what caught it.
489
+ // One unwrap, with splitting before and after it: `ls && bash -c "x; git push"` is reached; a second wrapper is not.
1154
490
  const reach = (s) => hit(s) || commandSegments(s).some(hit);
1155
491
  return spellings(input.command).some(reach) || commandSegments(input.command).some((seg) => spellings(seg).some(reach));
1156
492
  }
1157
493
  if (typeof action.write === "string") {
1158
494
  if (WRITE_TOOLS.includes(tool)) return matchesPath(input.file_path ?? input.notebook_path, action.write);
1159
- // A `write:` rule names a PATH, not a tool. For one milestone it reached only the three tools
1160
- // that carry a `file_path`, so `echo x >> docs/vision.md` through Bash was gated by neither
1161
- // layer: the permission rule rejects the tool, and this matcher fell through to `false`. The
1162
- // rule's own sentence is what that cost — an agent that can edit the constitution can launder
1163
- // any other change past its own grader — and it was reachable inside a session, with only the
1164
- // platform floor stopping it from landing. See `shellWrites` above for what this covers and,
1165
- // more to the point, for what it does not.
1166
495
  if (tool === "Bash") {
1167
- // Whole line and per segment, exactly as the `shell` branch above — and for the same
1168
- // reason, arrived at the hard way. That branch grew segment composition when a wrapper
1169
- // that was not first on the line turned out to escape it. This branch did not, and the
1170
- // gap it left is the worse of the two: `git status; bash -c "echo x >> docs/vision.md"`
1171
- // stepped aside where the same wrapper alone answers `deny`, so the CONSTITUTION was
1172
- // reachable behind any separator plus one wrapper. Measured 2026-07-28.
1173
- //
1174
- // A fix landing in one carrier and not its sibling is the defect class this repository
1175
- // has a standing ruling about; it happened here between two branches of one function,
1176
- // five commits apart. Found by Copilot review on #60, not by the session that wrote the
1177
- // first half.
1178
- //
1179
- // Both calls read the RAW command so the unwrap budget stays at one level, which is what
1180
- // keeps hole 1's two-wrapper counterexample true on this side too.
496
+ // Whole line and per segment, exactly as the `shell` branch above; the two change together.
1181
497
  const writes = (s) => shellWrites(s, action.write);
1182
498
  return (
1183
499
  spellings(input.command).some(writes) ||
@@ -1191,56 +507,20 @@ export function matchesRule(rule, tool, input = {}) {
1191
507
  return false;
1192
508
  }
1193
509
 
1194
- // ===========================================================================================
1195
- // 3. The backends
1196
- // ===========================================================================================
1197
- //
1198
- // A backend takes the parsed policy and returns one shape, always:
1199
- //
1200
- // { backend, label, compiled: [{id, tier, surface}], refused: [{id, tier, why}],
1201
- // notes: [string], artifact: { path, value, text } | null }
1202
- //
1203
- // `compiled` and `refused` together always equal the input, per backend — asserted by the suite,
1204
- // because the distinctive failure of a compiler that emits gate machinery is a rule that goes in
1205
- // and nothing comes out, leaving a gate map that reads as configured and a machine that enforces
1206
- // nothing. The uniform shape is what lets the matrix and `doctor` read every backend the same way
1207
- // without either of them knowing what a backend does.
510
+ // ---------------------------------------------------------------- the backends
511
+ // Each returns `{ backend, label, compiled: [{ id, tier, surface }], refused: [{ id, tier, why }], notes,
512
+ // artifact: { path, value, text } | null }`, and `compiled` with `refused` holds every rule.
1208
513
 
1209
- /** A workspace-relative path, as the host spells it. A trailing `/` means the subtree. */
1210
514
  function pathSpec(target) {
1211
515
  const clean = target.replace(/^\.\//, "").replace(/^\/+/, "");
1212
516
  return clean.endsWith("/") ? `./${clean}**` : `./${clean}`;
1213
517
  }
1214
518
 
1215
- /** A workspace-relative path becomes a host permission pattern. */
1216
519
  function pattern(tool, target) {
1217
520
  return `${tool}(${pathSpec(target)})`;
1218
521
  }
1219
522
 
1220
- // Which tiers this backend gates. `auto` and `propose` are refused wholesale, on the maintainer's
1221
- // ruling of 2026-07-27: the compiler emits restriction only, never permission. A compiler whose
1222
- // output can only ever ADD a gate cannot loosen an existing check by having a bug.
1223
- //
1224
- // ## What the ruling costs — which this comment used to leave to the reader's imagination
1225
- //
1226
- // Refusing to emit `allow` does not make the Auto tier free. It moves the tier's price off this
1227
- // repository. The host prompts for any command it has not been told about, so every Auto action is
1228
- // answered by hand, and those answers pile up in the host's own settings files, which are
1229
- // per-machine, unreviewable, absent from every diff, and — for a workflow built on throwaway
1230
- // worktrees — lost along with the branch that earned them. Measured on one host, 2026-07-28: 404 hand-added allow
1231
- // entries, of which exactly ONE corresponded to an Auto rule in ../.portulan/gates.json. So the
1232
- // tier is unattended in POLICY and heavily attended in PRACTICE, and those are not the same claim.
1233
- // The refusal string below used to make the first one and read as the second.
1234
- //
1235
- // ## The ruling stands, on a different sentence than before
1236
- //
1237
- // "Emitting `allow` would loosen a check" is not the hazard, or not obviously so: `git push` is
1238
- // Auto and `git push --force` is Gated, and whether a narrower `ask` still outranks a broader
1239
- // `allow` is a host precedence question **this repository has never measured** — so no argument
1240
- // here should rest on it. The hazard that does not depend on that answer: an allow prefix reaches
1241
- // every spelling beneath it, including the ones no rule here names. `git push --mirror` is
1242
- // destructive and appears in no tier of ../.portulan/gates.json. An allow on the prefix would
1243
- // clear it silently, and nothing in this policy would notice.
523
+ // Restriction only, never `allow`: an allow prefix would clear every spelling beneath it, `git push --mirror` included.
1244
524
  const HOST_GATE_TIERS = new Set(["gated", "prohibited"]);
1245
525
 
1246
526
  const HOST_TIER_NOT_A_GATE = {
@@ -1248,75 +528,22 @@ const HOST_TIER_NOT_A_GATE = {
1248
528
  propose: "tier `propose` is enforced by the platform floor — pull requests, required checks, review — not by a tool-level permission rule on this machine",
1249
529
  };
1250
530
 
1251
- /**
1252
- * The compiled-hook runners, in the order `claudeCode` spells them: the PreToolUse gate, then the Stop
1253
- * gate. **This is their one carrier.** They are invoked by generated host configuration rather than
1254
- * imported by anything, so no import graph can find them and every other roster that needs to know
1255
- * which `cli/` modules are runners has to ask here — `./payload.mjs` does. A third runner added below
1256
- * without a name added here would classify as unreachable in that rail, which is the direction that
1257
- * fails loudly rather than the one that ships something unnoticed.
1258
- */
1259
- export const HOOK_RUNNERS = ["gate.mjs", "stop-gate.mjs"];
531
+ /** The hook runners, in the order `claudeCode` indexes them; generated settings invoke them, so no import graph finds them. */
532
+ export const HOOK_RUNNERS = ["gate.mjs", "stop-gate.mjs", "advisory.mjs"];
1260
533
 
1261
534
  /**
1262
- * Translate the policy into a Claude Code settings object.
1263
- *
1264
- * The mapping, each line of it measured against a running host rather than read from documentation:
1265
- *
1266
- * prohibited -> permissions.deny + a hook returning `deny`
1267
- * gated -> permissions.ask + a hook returning `ask`
1268
- *
1269
- * The hook returns the SAME decision as the permission rule on purpose. A hook returning `deny` for
1270
- * a Gated action would turn a per-action prompt into a hard block, which is the tier above it.
1271
- *
1272
- * One line of that mapping has no permission half. A `write:` rule also gates the SHELL spellings of
1273
- * a write — see `shellWrites` above — and no `Bash(prefix:*)` pattern can express "a command writing
1274
- * this path", so that half is the hook's alone and fails open with it. Emitted anyway, because the
1275
- * alternative was leaving `echo x >> docs/vision.md` gated by nothing at all, and reported in the
1276
- * backend's `notes` on every run so the weaker layer is never inferred from silence.
535
+ * The policy as Claude Code settings: each gate a permission rule and a hook with one answer, `deny` for prohibited and
536
+ * `ask` for gated, a prompt that blocks headless. Two layers, since on Claude Code 2.1.220 a crashing hook fails open.
1277
537
  */
1278
538
  export function claudeCode(parsed, options = {}) {
1279
- // The header names the policy file that was ACTUALLY read. It was a literal for one round, so a
1280
- // workspace declaring a non-default policy got an artifact claiming it came from somewhere it did
1281
- // not — in the one field whose entire job is telling a reader what generated this. Found by review.
1282
539
  const source = options.source ?? ".portulan/gates.json";
1283
- // **Where the emitted hook finds its runner, and why this is computed rather than written down.**
1284
- //
1285
- // Both runners used to live at `.portulan/compile/`, which `package.json`'s `files` has never
1286
- // shipped — so every adopter's compiled policy named two files they did not receive, and a missing
1287
- // hook FAILS OPEN. Milestone 7 moves them into `cli/`, which does ship. That fixes what the adopter
1288
- // has; it does not by itself fix how the hook names it, because `cli/` sits in a different place in
1289
- // each of the three contexts this must survive:
1290
- //
1291
- // 1. this checkout — `<project>/cli/`
1292
- // 2. a project-local install — `<project>/node_modules/@sleepy_panda_srl/portulan/cli/`
1293
- // 3. a global or npx-only install — NOT under the project at all
1294
- //
1295
- // So the path is derived from where THIS file actually is at compile time and expressed relative to
1296
- // the project when it lands inside it — covering 1 and 2 with one rule rather than two special
1297
- // cases. For 3 there is no project-relative spelling that exists, so an absolute path is emitted and
1298
- // a **note** records that the hook is pinned to this machine — see the `pinned` push below. Naming
1299
- // that is the point: a hook silently pinned to an absolute path is a policy that stops working when
1300
- // the package moves, and finding out by having no gate is the worst way to find out.
1301
- //
1302
- // This sentence said `refused` for one round, and `refused` carries rule-level compilation refusals
1303
- // only — nothing ever added the runner path to it. The pre-commit checkpoint found the recording
1304
- // missing entirely; the fix added it on the `notes` channel and left this line still naming the
1305
- // wrong one, so a reader following the comment would have looked in a place that never holds it.
1306
- // Copilot's round caught the remainder. A fix that leaves its own explanation pointing elsewhere is
1307
- // half a fix.
1308
540
  const runnerDir = path.dirname(fileURLToPath(import.meta.url));
1309
- // The project this policy is being compiled FOR. `claudeCode` is handed a parsed policy and not a
1310
- // location, so the caller may say; `cwd` is the honest default because `compile` is run from the
1311
- // repository it compiles.
1312
541
  const project = options.root ?? process.cwd();
1313
542
  const pinned = [];
1314
543
  const spell = (file) => {
1315
544
  const abs = path.join(runnerDir, file);
1316
545
  const rel = path.relative(project, abs);
1317
- // `rel.split(sep)[0] !== ".."` rather than `!startsWith("..")` — the latter also rejects a
1318
- // directory literally named `..foo`. Benign in direction (it would fall back to absolute), but
1319
- // the exact spelling costs nothing and the loose one is copied from here.
546
+ // Not `startsWith("..")`, which would take a directory named `..foo` for the parent.
1320
547
  const inside = rel && rel.split(path.sep)[0] !== ".." && !path.isAbsolute(rel);
1321
548
  if (inside) return `"\${CLAUDE_PROJECT_DIR}/${rel.split(path.sep).join("/")}"`;
1322
549
  pinned.push({ file, abs });
@@ -1324,6 +551,7 @@ export function claudeCode(parsed, options = {}) {
1324
551
  };
1325
552
  const runner = options.runner ?? spell(HOOK_RUNNERS[0]);
1326
553
  const stopRunner = options.stopRunner ?? spell(HOOK_RUNNERS[1]);
554
+ const advisoryRunner = options.advisoryRunner ?? spell(HOOK_RUNNERS[2]);
1327
555
 
1328
556
  const compiled = [];
1329
557
  const refused = [];
@@ -1331,7 +559,6 @@ export function claudeCode(parsed, options = {}) {
1331
559
  const ask = [];
1332
560
  const matchers = new Set();
1333
561
  const shellWriteGates = [];
1334
- // Write gates whose single emitted permission pattern is `Edit(path)` — see the note they produce.
1335
562
  const editCoveredGates = [];
1336
563
 
1337
564
  for (const rule of parsed.rules) {
@@ -1340,53 +567,11 @@ export function claudeCode(parsed, options = {}) {
1340
567
  continue;
1341
568
  }
1342
569
  if (rule.kind === "none") {
1343
- // The policy itself states why there is no surface. The compiler reports those words
1344
- // rather than inventing its own — a refusal explained by the tool is a refusal nobody
1345
- // can review against the policy.
570
+ // A `none` target is the policy's own sentence for why nothing can be gated.
1346
571
  refused.push({ id: rule.id, tier: rule.tier, why: rule.target });
1347
572
  continue;
1348
573
  }
1349
- // ---- a path target no path can match, refused HERE and not at `parse`
1350
- //
1351
- // **Hole 8 of ../.portulan/gate-map.md, closed at the tier that asks the question.** A
1352
- // `gated` or `prohibited` rule whose path target can never match — see `neverMatches` for the
1353
- // three families, of which *reduces to nothing* is only the first — compiles to a named
1354
- // permission surface (`Edit(./)`, `Edit(./.)`, `Edit(././**)`, `Edit(./docs/./vision.md)`,
1355
- // `Edit(./docs\\vision.md)`) while `matchesRule` answers `false` for every path a host submits. The compiler then reports the rule COMPILED, and
1356
- // `doctor` counts it as covered. That is a hollow gate that reads from outside exactly like a
1357
- // whole one: hole 3's failure mode reached by a different road.
1358
- //
1359
- // **Why a throw and not a `refused` row.** `refused` is the accounting for a rule a backend
1360
- // legitimately declines — an `auto` tier, a `none` kind — and it writes the artifact without
1361
- // the rule and exits 0. Filing this there would leave `doctor` reporting a gate the policy
1362
- // declares and nothing enforces as ordinary non-coverage, which is precisely the laundering
1363
- // `parse`'s own target refusals (absolute, `..`, whitespace) already decline to do. A policy
1364
- // that cannot be enforced as written is malformed, and malformed is could-not-run.
1365
- //
1366
- // **Why here and not at `parse`.** That stage holds no tier partition, by a decision recorded
1367
- // in its own docblock: *"A partition that is one backend's opinion must live in that backend,
1368
- // or the second one is structurally incapable of disagreeing."* A session once put the
1369
- // `auto`/`propose` refusals there and the floor backend then needed the rules it had thrown
1370
- // away. This is that same shape — the hazard exists only for the tiers THIS backend enforces —
1371
- // so it lives with `HOST_GATE_TIERS` above it, and the two `auto` rules in
1372
- // ../.portulan/gates.json — `edit-on-a-working-branch` (`write: "./"`) and
1373
- // `read-anything-in-the-repository` (`read: "./"`), one of each path kind — keep their spelling
1374
- // and are refused one line earlier, for their tier, exactly as before.
1375
- //
1376
- // **One site, and the predicate exported rather than inlined.** The floor backend compiles no
1377
- // path target at all (see `floorRefusal`), so there is no second site to sweep today and
1378
- // saying "the same treatment there" would be a claim about code that does not exist.
1379
- // `neverMatches` is exported for the backend that does compile one, and for the tests.
1380
- //
1381
- // **What this does NOT reach, said here rather than left to be discovered.** ./gate.mjs reads
1382
- // the policy through `parse`, which this change does not touch, so a workspace that has
1383
- // committed such a rule still loads it at the hook, `matchesRule` answers `false`, and the
1384
- // hook steps aside because nothing matched. The rule is refused at COMPILE time — which is
1385
- // when the artifact that gates the host is written — and the residue is recorded in hole 8.
1386
- //
1387
- // Found by the milestone-8 gate corpus on its first run (#336) and put as three options in
1388
- // #337; this is the third, the narrowest, and it deliberately leaves open what `./` should
1389
- // MEAN as a policy target.
574
+ // A throw, not a `refused` row: a gate declared at an enforcing tier that can never match is a malformed policy.
1390
575
  if ((rule.kind === "write" || rule.kind === "read") && neverMatches(rule.target)) {
1391
576
  throw new CompileError(
1392
577
  `rule \`${rule.id}\` is ${rule.tier} and its ${rule.kind} target ${JSON.stringify(rule.target)} matches no path a host ` +
@@ -1401,128 +586,90 @@ export function claudeCode(parsed, options = {}) {
1401
586
  );
1402
587
  }
1403
588
  const into = rule.tier === "prohibited" ? deny : ask;
1404
- const emitted = []; // permission patterns — the layer that cannot fail open
1405
- const hookOnly = []; // coverage the permission DSL cannot express, carried by the hook alone
589
+ const emitted = []; // permission patterns, the layer that cannot fail open
590
+ const hookOnly = [];
1406
591
  if (rule.kind === "shell") {
1407
592
  emitted.push(`Bash(${rule.target}:*)`);
1408
593
  matchers.add("Bash");
1409
594
  } else {
1410
- // **The PERMISSION pattern narrows to `Edit`; the HOOK matchers keep all three.** These two
1411
- // lines used to be one loop, and that emitted `Write(path)` and `NotebookEdit(path)` beside
1412
- // `Edit(path)` — patterns the host **discards**. Measured on Claude Code 2.1.240, printed on
1413
- // every start of this repository:
1414
- //
1415
- // Permission deny rule (.claude/settings.json): Write(./docs/vision.md) is not matched by
1416
- // file permission checks — only Edit(path) rules are. Use Edit(./docs/vision.md) instead
1417
- // (Edit rules cover all file-editing tools).
1418
- //
1419
- // and the same for the `ask` tier, so this was never a `deny`-only defect. The gate held
1420
- // throughout — `Edit(path)` is matched for every file-editing tool — so what was wrong was
1421
- // the compiler's **accounting**: three rules reported compiled where one enforces, plus a
1422
- // permanent warning, which is `../.portulan/memory/a-stated-enforcer-must-be-the-real-one.md`.
1423
- //
1424
- // **`matchers` is a different consumer and must not narrow with it.** It drives the
1425
- // `PreToolUse` hook, where all three tool names genuinely arrive and `matchesRule` above
1426
- // answers for each; narrowing it would open a real hole where today there is none. That is
1427
- // why the repair is not narrowing `WRITE_TOOLS` — two consumers of one constant, one of
1428
- // which changes. A suite case pins the matcher set for exactly this reason.
1429
- //
1430
- // **The dropped patterns are NOT `hookOnly`.** Measured with the pattern alone in `deny` and
1431
- // `Write` allowed: the host refused the write. So their coverage is carried by the
1432
- // permission layer, not by the hook, and filing them under a channel documented as *carried
1433
- // by the hook alone* would be a false claim in the weakening direction. The fact goes on
1434
- // `notes` instead, where it names its host version.
595
+ // Claude Code 2.1.240 discards `Write(path)` and `NotebookEdit(path)` and applies `Edit(path)` to every editing
596
+ // tool; the hook still matches all three names, each of which reaches `PreToolUse` on its own.
1435
597
  const permissionTools = rule.kind === "write" ? ["Edit"] : READ_TOOLS;
1436
598
  for (const tool of permissionTools) emitted.push(pattern(tool, rule.target));
1437
599
  for (const tool of rule.kind === "write" ? WRITE_TOOLS : READ_TOOLS) matchers.add(tool);
1438
600
  if (rule.kind === "write") {
1439
601
  editCoveredGates.push(rule.id);
1440
- // The hook is wired for Bash so that a shell spelling of this write reaches
1441
- // `matchesRule`, which now answers for it. **This line is the load-bearing half.**
1442
- // The matcher alone would be inert in any workspace whose policy declares no shell
1443
- // gate: `Bash` would be absent from the matchers below, the runner would never be
1444
- // invoked for a Bash call, and the coverage would exist only in a function nothing
1445
- // calls — the manifest-field-that-validates-and-loads-nothing defect
1446
- // (../.portulan/memory/a-manifest-field-can-validate-and-load-nothing.md), arriving
1447
- // this time as a matcher nothing reaches. It changes no artifact in THIS repository,
1448
- // whose policy already gates shell commands, which is precisely why it would not have
1449
- // been noticed here.
1450
- //
1451
- // No permission pattern joins it, and that is not an omission: `Bash(prefix:*)`
1452
- // matches a literal command prefix while the path sits at an arbitrary position, so
1453
- // the DSL has no way to say "any command writing this file". The only patterns that
1454
- // would fit — `Bash(cp:*)`, `Bash(sed -i:*)` — gate the utility rather than the path,
1455
- // which is a different and much larger rule than the policy declares.
602
+ // Without `Bash` among the matchers, a policy with no shell gate would never run the hook on a shell write.
1456
603
  matchers.add("Bash");
1457
604
  hookOnly.push(`hook: a Bash command writing ${pathSpec(rule.target)}`);
1458
605
  shellWriteGates.push(rule.id);
1459
606
  }
1460
607
  }
1461
608
  into.push(...emitted);
1462
- // The hook-only clause is carried in the SURFACE and not in `into`. It is not a permission
1463
- // pattern, and a reader of the matrix has to be able to tell which half of a gate would
1464
- // survive a broken hook.
1465
609
  compiled.push({ id: rule.id, tier: rule.tier, surface: [...emitted, ...hookOnly].join(" · ") });
1466
610
  }
1467
611
 
1468
- // A policy that declares gates and emits none must not report success. This is the workflow's
1469
- // "declares no verify recipes — refusing to report green" one level down: the artifact would be
1470
- // written, the gate map would read as compiled, and nothing would hold.
1471
612
  if (parsed.rules.some((r) => HOST_GATE_TIERS.has(r.tier)) && compiled.length === 0) {
1472
613
  throw new CompileError(
1473
614
  "every gate in this policy refused to compile — refusing to write an artifact that enforces nothing while the policy claims gates",
1474
615
  );
1475
616
  }
1476
617
 
1477
- // The generation header. An emitted artifact that does not say what generated it invites the
1478
- // one edit this whole rail exists to catch — a hand-fix that survives until the next compile
1479
- // silently reverts it.
1480
- // **What compiled this, recorded beside what generated it** (#264). An unpinned run WOULD read
1481
- // the host's plugin cache while the rail reads the tree — it now refuses that arrangement outright
1482
- // (#316) — and until this field the artifact said nothing about which answered, so a drift RED named a difference no reader could find in the repository, because the
1483
- // deciding input was a directory outside it.
1484
- //
1485
- // **Origin and version, never the ROOT PATH.** The discovered root is an absolute path under
1486
- // somebody's home directory; recording it would make a tracked artifact machine-dependent and red
1487
- // `verify/compile.sh` for every developer and CI — trading a silent hazard for a permanent false
1488
- // one. `recordedOrigin` collapses the resolver's three tags to two for the same reason: the pinned
1489
- // rail and a bare run must agree byte for byte about an identical world.
618
+ // Each pack's origin and version, never its root: an absolute path would make the tracked artifact differ per machine.
1490
619
  const packs = (options.packProvenance ?? [])
1491
620
  .map((c) => ({ pack: c.pack, origin: c.origin, version: c.version ?? null }))
1492
621
  .sort((a, b) => a.pack.localeCompare(b.pack));
622
+ const sessions = options.sessions ?? null;
623
+ const switches = {};
624
+ if (sessions?.git_instructions !== undefined) switches.includeGitInstructions = sessions.git_instructions;
625
+ if (sessions?.cache_lifetime !== undefined) switches.promptCacheTtl = sessions.cache_lifetime;
626
+ const sessionsFrom = Object.keys(switches).length ? sessions.manifest : null;
627
+ const spend = options.spend ?? null;
628
+ const figures = [];
629
+ if (spend?.multipliers) figures.push("--read", spend.multipliers.read, "--write-5m", spend.multipliers.write["5m"], "--write-1h", spend.multipliers.write["1h"]);
630
+ if (spend?.horizon) figures.push("--horizon", spend.horizon);
631
+ const spendFlags = figures.map((f) => ` ${f}`).join("");
632
+ const restartBlock = spend?.restart === "block";
633
+ const spendFrom = figures.length || restartBlock ? spend.manifest : null;
634
+ const declaredIn =
635
+ sessionsFrom && sessionsFrom === spendFrom
636
+ ? `\`sessions\` and \`spend\` in ${sessionsFrom}`
637
+ : [sessionsFrom && `\`sessions\` in ${sessionsFrom}`, spendFrom && `\`spend\` in ${spendFrom}`].filter(Boolean).join(", or ");
1493
638
  const value = {
1494
639
  $portulan: {
1495
640
  generated: "cli/compile.mjs",
1496
641
  source,
642
+ ...(sessionsFrom ? { sessions: sessionsFrom } : {}),
643
+ ...(spendFrom ? { spend: spendFrom } : {}),
1497
644
  ...(packs.length ? { packs } : {}),
1498
- warning: `Generated file. Edit ${source} and recompile; \`verify/compile.sh\` fails on drift.`,
645
+ warning: declaredIn
646
+ ? `Generated file. Edit ${source}, or ${declaredIn}, and recompile; \`verify/compile.sh\` fails on drift.`
647
+ : `Generated file. Edit ${source} and recompile; \`verify/compile.sh\` fails on drift.`,
1499
648
  },
1500
649
  permissions: { deny, ask, allow: [] },
1501
650
  hooks: {
1502
- // Quoted deliberately: this repository's own working copy lives under a path with
1503
- // spaces in it, and an unquoted expansion would word-split into a runner nobody can
1504
- // find. No pipes, redirections or separators — an emitted shell one-liner is where the
1505
- // next quoting fail-open would live (../.portulan/tasks/0004).
651
+ // Each command is `node` and a quoted path: no pipe, redirection or separator, where a quoting fail-open would live.
1506
652
  PreToolUse: [...matchers].sort().map((matcher) => ({
1507
653
  matcher,
1508
654
  hooks: [{ type: "command", command: `node ${runner}` }],
1509
655
  })),
1510
- Stop: [{ hooks: [{ type: "command", command: `node ${stopRunner}` }] }],
656
+ Stop: [
657
+ {
658
+ hooks: [
659
+ { type: "command", command: `node ${stopRunner}` },
660
+ ...(restartBlock ? [{ type: "command", command: `node ${advisoryRunner} stop${spendFlags}` }] : []),
661
+ ],
662
+ },
663
+ ],
664
+ // No matcher means every tool; a non-blocking `Stop` hook's output would reach only the host's debug log.
665
+ PostToolUse: [{ hooks: [{ type: "command", command: `node ${advisoryRunner} tool${spendFlags}` }] }],
666
+ UserPromptSubmit: [{ hooks: [{ type: "command", command: `node ${advisoryRunner} prompt${spendFlags}` }] }],
1511
667
  },
668
+ statusLine: { type: "command", command: `node ${advisoryRunner} status${spendFlags}` },
669
+ ...switches,
1512
670
  };
1513
671
 
1514
- // The shell half of every write gate, reported on every run rather than left for a reader to
1515
- // infer from the absence of a `Bash` entry in `deny`. It is the one place in this artifact where
1516
- // a gate is carried by the layer that fails open, so a note that only appeared when something
1517
- // went wrong would be a note nobody ever reads.
1518
672
  const notes = [];
1519
- // **The promise this comment used to make and the code did not keep.** The fallback above emits an
1520
- // absolute path when the runner is not under the project — a global or npx-only install — and the
1521
- // paragraph at the top of this function said `refused` would record that the hook is pinned to this
1522
- // machine. It recorded nothing: the pre-commit checkpoint compiled from a package outside a project
1523
- // and got two absolute hooks with **zero output about it**. A hook silently pinned to one machine is
1524
- // a policy that stops working when the package moves, and finding out by having no gate is the worst
1525
- // way to find out — so it is said, on the channel that prints every run.
1526
673
  if (pinned.length) {
1527
674
  notes.push(
1528
675
  `${pinned.length} hook(s) are pinned to an ABSOLUTE path on this machine — ` +
@@ -1532,6 +679,53 @@ export function claudeCode(parsed, options = {}) {
1532
679
  `project, or pass \`--runner\`/\`--stop-runner\` to name a path you control`,
1533
680
  );
1534
681
  }
682
+ notes.push(
683
+ `the status line is compiled: it shows the restart threshold (proposal 0038) and takes the place of a status line ` +
684
+ `set in user settings, in this repository only. To keep your own here, set \`statusLine\` in \`.claude/settings.local.json\`, which ` +
685
+ `outranks this file; the restart advisory with a tool result and at the prompt is unaffected`,
686
+ );
687
+ if (switches.includeGitInstructions === false) {
688
+ notes.push(
689
+ `the git instructions are compiled off (\`sessions.git_instructions\`): every session here starts without the host's ` +
690
+ `startup git snapshot and its commit and pull-request instructions. A session that needs them starts with ` +
691
+ `\`CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS=0\`, which outranks this file (read in Claude Code 2.1.281's program text)`,
692
+ );
693
+ }
694
+ if (switches.includeGitInstructions === true) {
695
+ notes.push(
696
+ `the git instructions are compiled on (\`sessions.git_instructions\`): every session here starts with the host's ` +
697
+ `startup git snapshot and its commit and pull-request instructions, whatever a user's own settings say. A session ` +
698
+ `that must go without them starts with \`CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS=1\`, which outranks this file`,
699
+ );
700
+ }
701
+ if (switches.promptCacheTtl !== undefined) {
702
+ notes.push(
703
+ `the main conversation's cache lifetime is compiled as ${switches.promptCacheTtl} (\`sessions.cache_lifetime\`); ` +
704
+ `\`CLAUDE_CODE_PROMPT_CACHE_TTL\` outranks it for one session, and subagents keep a lifetime of their own`,
705
+ );
706
+ }
707
+ if (sessions?.headless !== undefined) {
708
+ notes.push(
709
+ `\`sessions.headless\` is not compiled: it is what the runners that start sessions apply, today \`cli/warm.mjs\`, ` +
710
+ `and the dynamic-sections exclusion it can carry is no host setting`,
711
+ );
712
+ }
713
+ if (figures.length) {
714
+ const m = spend.multipliers;
715
+ const priced = m ? `read ${m.read}×, write ${m.write["5m"]}× for five minutes and ${m.write["1h"]}× for an hour, whichever lifetime the host records` : "the general multipliers";
716
+ notes.push(
717
+ `the restart advisory and the status line compute the threshold at the declared figures (\`spend\`): ${priced}, and ` +
718
+ `${spend.horizon ? `a horizon of ${spend.horizon} requests` : `the general horizon of ${HORIZON} requests`}. The compiled ` +
719
+ `commands carry them, so an edit to \`spend\` is drift until recompiled`,
720
+ );
721
+ }
722
+ if (restartBlock) {
723
+ 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`,
727
+ );
728
+ }
1535
729
  if (editCoveredGates.length) {
1536
730
  notes.push(
1537
731
  `${editCoveredGates.length} write gate(s) — ${editCoveredGates.join(", ")} — emit \`Edit(path)\` as their only ` +
@@ -1571,42 +765,16 @@ export function claudeCode(parsed, options = {}) {
1571
765
  };
1572
766
  }
1573
767
 
1574
- // ===========================================================================================
1575
- // 3b. The floor backend — a GitHub repository ruleset
1576
- // ===========================================================================================
1577
- //
1578
- // The milestone-4 criterion positions this as **the floor backend**: what every host falls back to,
1579
- // and all that a host with no hook system has. `../core/operating/autonomy.md` says the floor is the
1580
- // floor "because it holds when everything above it fails", and promises that the enforcement compiler
1581
- // reads the workspace's gate policy and generates the host's own enforcement; this is that promise,
1582
- // discharged for the floor half. (Quoted rather than paraphrased-in-quotes, which is the small version
1583
- // of the defect ../.portulan/memory/a-stated-enforcer-must-be-the-real-one.md is about.)
1584
- //
1585
- // It **generates, never applies.** Importing a ruleset is a repository-settings change — outward,
1586
- // Gated, the maintainer's, per the proposal-0001 precedent — so this emits a file and stops.
1587
- //
1588
- // Only the server's *input* fields are emitted: an export carrying an `id` or a `created_at` would be
1589
- // asserting facts it cannot know. The envelope and that omission list were read from two live rulesets
1590
- // on 2026-07-27; the `pull_request` and `required_status_checks` parameter blocks were **not** — neither
1591
- // live ruleset carries those rules — so those come from GitHub's documented schema, and
1592
- // ../.portulan/compile/README.md marks them as such rather than folding them into "read from live".
1593
- //
1594
- // **Recognition is by exact spelling, and that is a limit rather than an oversight.** The action
1595
- // vocabulary has no `ref` kind — a rule says `{"shell": "git push --force"}` — so this backend
1596
- // matches command strings against a table and refuses everything else out loud. A parser that
1597
- // recognised `git push -f` as the same action would be the ambitious parser `../spec/slots.md`
1598
- // warns against, and it would buy confidence with false reds.
768
+ // ---------------------------------------------------------------- the floor backend: a GitHub repository ruleset
769
+
770
+ // Exact spellings, never parsed: a rule spelled `git push -f` is refused rather than recognised.
1599
771
  const REF_RULES = new Map([
1600
772
  ["git push --force", "non_fast_forward"],
1601
773
  ["git push --delete", "deletion"],
1602
774
  ]);
1603
775
 
1604
- // Which tiers this backend may emit a ref rule for. `prohibited` is included and `auto` is not:
1605
- // a prohibition on a ref operation is a restriction the floor can carry, while an unattended action
1606
- // needs no ref gate by definition.
1607
776
  const FLOOR_GATE_TIERS = new Set(["gated", "prohibited"]);
1608
777
 
1609
- /** Commands a *tag* ruleset would reach. Named individually so the refusal can say which mechanism. */
1610
778
  const TAG_SHAPED = new Set(["git tag", "gh release"]);
1611
779
 
1612
780
  export function githubRuleset(parsed, options = {}) {
@@ -1616,9 +784,6 @@ export function githubRuleset(parsed, options = {}) {
1616
784
  const refused = [];
1617
785
  const notes = [];
1618
786
 
1619
- // No floor declared: refuse everything, by name, and write nothing. Defaulting the branch to
1620
- // `main` was the obvious alternative and is the exact move this compiler must not make — a
1621
- // compiler that invents the ref it gates has stopped compiling policy and started writing it.
1622
787
  if (!floor) {
1623
788
  for (const rule of parsed.rules) {
1624
789
  refused.push({
@@ -1631,31 +796,15 @@ export function githubRuleset(parsed, options = {}) {
1631
796
  }
1632
797
 
1633
798
  const rules = [];
1634
- // Both halves of the condition are load-bearing, and the second was missing for one round. The
1635
- // pair is **compiled from `propose` rules**, so a policy with none of them asked for no
1636
- // pull-request requirement — emitting one because `floor.checks` happens to be declared is the
1637
- // compiler inventing policy, which is the single thing this backend must not do. It also broke
1638
- // the accounting silently: those two ruleset rules would have sat in the artifact with no policy
1639
- // rule credited for compiling them, so the matrix and `doctor` would have described a floor
1640
- // missing two of its own rules. Found by review, round 3.
1641
799
  const declaresPropose = parsed.rules.some((r) => r.tier === "propose");
1642
800
  const hasChecks = floor.checks.length > 0;
1643
801
  const emitPullRequestPair = hasChecks && declaresPropose;
1644
802
 
1645
- // The propose tier and the floor's pull-request pair. Emitted together or not at all: a
1646
- // `pull_request` rule without `required_status_checks` imports cleanly, reads as a configured
1647
- // floor, and lets a red pull request merge. Half a mapping is the silent weakening this
1648
- // repository keeps finding, so the refusal names the missing declaration instead.
1649
803
  if (emitPullRequestPair) {
1650
804
  rules.push({
1651
805
  type: "pull_request",
1652
806
  parameters: {
1653
- // The five GitHub requires on this rule, and no sixth. `allowed_merge_methods` is a
1654
- // real optional parameter and was dropped deliberately: nothing in the policy drives
1655
- // it and the criterion does not name it, so emitting it would widen the part of this
1656
- // artifact that is invented rather than compiled, to buy nothing. (An earlier draft
1657
- // also argued it "appears in neither live ruleset" — vacuous, since neither of those
1658
- // carries a `pull_request` rule at all. Removed rather than kept as decoration.)
807
+ // GitHub requires all five; the optional `allowed_merge_methods` is left out, as no policy drives it.
1659
808
  required_approving_review_count: floor.reviews,
1660
809
  dismiss_stale_reviews_on_push: true,
1661
810
  require_code_owner_review: false,
@@ -1666,10 +815,7 @@ export function githubRuleset(parsed, options = {}) {
1666
815
  rules.push({
1667
816
  type: "required_status_checks",
1668
817
  parameters: {
1669
- // Not read from the policy, and deliberately: a pull request may not merge from
1670
- // behind its base (proposal 0011, applied live 2026-07-27). A declarable `strict`
1671
- // would let a later policy edit undo a ruling with no diff anyone would read as one.
1672
- strict_required_status_checks_policy: true,
818
+ strict_required_status_checks_policy: floor.strict,
1673
819
  do_not_enforce_on_create: false,
1674
820
  required_status_checks: floor.checks,
1675
821
  },
@@ -1690,12 +836,6 @@ export function githubRuleset(parsed, options = {}) {
1690
836
  continue;
1691
837
  }
1692
838
 
1693
- // The TIER is consulted before the spelling table, and that order is the whole of it. It was
1694
- // the other way round for one round, so an `auto` rule spelled exactly `git push --force`
1695
- // compiled into `non_fast_forward` — a ruleset rule emitted for an action the policy declares
1696
- // unattended, with `floorRefusal`'s own `auto` branch left unreachable for it. The table is
1697
- // only how this backend spells a gate; whether there is a gate at all is the policy's answer.
1698
- // Found by review on the pull request.
1699
839
  const shell = rule.kind === "shell" && FLOOR_GATE_TIERS.has(rule.tier) ? rule.target : null;
1700
840
  const refType = shell ? REF_RULES.get(shell) : undefined;
1701
841
  if (refType) {
@@ -1713,9 +853,6 @@ export function githubRuleset(parsed, options = {}) {
1713
853
  );
1714
854
  }
1715
855
 
1716
- // The coarseness, recorded in BOTH directions. A backend that reported only where it is weaker
1717
- // than the policy would be flattering itself, and the stricter direction is the one an adopter
1718
- // is surprised by.
1719
856
  if (compiled.some((c) => c.surface === "non_fast_forward")) {
1720
857
  notes.push(
1721
858
  `on \`refs/heads/${floor.branch}\` the \`non_fast_forward\` rule is STRICTER than this policy: it blocks every force-push, including \`git push --force-with-lease\`, which the policy classifies Auto. The floor gates a ref and cannot read a command's flags.`,
@@ -1724,8 +861,6 @@ export function githubRuleset(parsed, options = {}) {
1724
861
  notes.push(
1725
862
  `every rule here applies to one declared ref, \`refs/heads/${floor.branch}\`, and to nothing else. A policy rule naming an action on any other branch is not covered by this export even where it compiled.`,
1726
863
  );
1727
- // A declaration nothing compiles is reported, never dropped in silence — the manifest-field-that-
1728
- // loads-nothing defect, arriving from the policy side this time.
1729
864
  if (hasChecks && !declaresPropose) {
1730
865
  notes.push(
1731
866
  `this floor declares ${floor.checks.length} status check(s) and the policy carries no \`propose\` rule, so no ` +
@@ -1738,18 +873,13 @@ export function githubRuleset(parsed, options = {}) {
1738
873
  );
1739
874
 
1740
875
  const value = {
1741
- // JSON has no comments and a GitHub ruleset has no description field, so the name is the
1742
- // only place a warning survives into the settings UI a maintainer actually reads. Same job
1743
- // as the `$portulan` header in the other artifact, done in the one field available.
876
+ // A ruleset has no description field, so its name is where provenance reaches GitHub's settings UI.
1744
877
  name: `portulan floor — ${floor.branch} (generated from ${source})`,
1745
878
  target: "branch",
1746
879
  enforcement: "active",
1747
880
  conditions: { ref_name: { include: [`refs/heads/${floor.branch}`], exclude: [] } },
1748
881
  rules,
1749
- // Unconditional, and the gate map's own sentence is why: a floor carrying an exemption for
1750
- // the only actor who can act is not a floor. The organisation-level ruleset this repository
1751
- // sits under does carry an `OrganizationAdmin` always-bypass, which is recorded there as the
1752
- // unverified layer of the floor — not copied here.
882
+ // No bypass: a floor exempting the only actor who can act is not a floor.
1753
883
  bypass_actors: [],
1754
884
  };
1755
885
 
@@ -1763,22 +893,9 @@ export function githubRuleset(parsed, options = {}) {
1763
893
  };
1764
894
  }
1765
895
 
1766
- /**
1767
- * Why one rule does not reach the floor.
1768
- *
1769
- * Every sentence here is scoped to *this export* rather than to GitHub, because
1770
- * `../.portulan/memory/a-stated-enforcer-must-be-the-real-one.md` binds any sentence containing
1771
- * "cannot" — and the convenient blanket version ("the platform gates a ref, not a path") is simply
1772
- * false: CODEOWNERS gates owned paths and is named as part of the floor by
1773
- * `../core/operating/autonomy.md`, push rulesets gate file paths, and tag rulesets gate `refs/tags/*`.
1774
- */
896
+ /** Why a rule misses the floor, said of this export and never of GitHub, which can gate paths and tags. */
1775
897
  function floorRefusal(rule, floor) {
1776
898
  if (rule.tier === "auto") {
1777
- // "Unattended by definition" is literally true on THIS backend, and true only here: a branch
1778
- // ruleset has no prompt to pay, so an Auto rule left out of it costs nobody anything. The
1779
- // host backend carried the identical phrase and it was false there — see the comment above
1780
- // `HOST_GATE_TIERS`. Same words, one backend apart, one of them wrong. Do not carry either
1781
- // version across without re-deriving it for the backend it lands in.
1782
899
  return "tier `auto` is unattended by definition — the floor exists to refuse what an agent may not do alone, and an action needing nobody's approval needs no ref gate.";
1783
900
  }
1784
901
  if (rule.kind === "none") {
@@ -1796,38 +913,14 @@ function floorRefusal(rule, floor) {
1796
913
  return `no branch-ruleset rule corresponds to \`${rule.target}\`, and recognition here is by EXACT command spelling (\`${[...REF_RULES.keys()].join("`, `")}\`) rather than by parsing — a matcher clever enough to generalise would be clever enough to be wrong quietly.`;
1797
914
  }
1798
915
 
1799
- /**
1800
- * Where each backend's artifact lives, whether or not this policy produces one.
1801
- *
1802
- * Declared beside the backends rather than read off a result, because `--check` has to look for an
1803
- * artifact a backend *no longer* owes — and a backend that emits nothing has no result to read a
1804
- * path from. Keyed by backend id so adding a backend without a path here is a `TypeError` at the
1805
- * first check rather than a silently unswept file.
1806
- */
916
+ /** Each backend's artifact path, owed or not: `--check` looks for an artifact a backend no longer emits. */
1807
917
  const ARTIFACT_PATHS = {
1808
918
  "claude-code": ".claude/settings.json",
1809
919
  "github-ruleset": ".portulan/compile/github-ruleset.json",
1810
920
  };
1811
921
 
1812
- /**
1813
- * The same map, for a workspace that does not live at `.portulan`.
1814
- *
1815
- * **A feature that dispatches on residence is a parity breach**, and proposal
1816
- * `../.portulan/proposals/0017-one-repository-one-governing-workspace.md` says it is refusable on that
1817
- * sentence alone. This one did: the ruleset's path had `.portulan` written into it, so compiling a
1818
- * feed-side workspace looked for a gate policy at `<feed>/workspaces/.portulan/gates.json` — a
1819
- * directory that does not exist and never would — and exited 2, *could not run*, on a workspace that
1820
- * `doctor`, `index` and its own verify recipe all handled identically at both ends.
1821
- *
1822
- * **Found by running row 7's fourth demonstration, not by reading this file.** Every other line of the
1823
- * compiler already keyed to a slot; this one key was location. The default is unchanged, so a caller
1824
- * that names no workspace directory takes a byte-identical path to the one it took before.
1825
- */
1826
922
  function artifactPaths(workspaceDir) {
1827
923
  if (workspaceDir === ".portulan") return ARTIFACT_PATHS;
1828
- // A workspace directory that IS the root — the feed-side shape, where the workspace ships as the
1829
- // plugin and the installed plugin's directory is the workspace root (milestone 6 measured that).
1830
- // Its compiled artifacts belong beside it, because they ship with it.
1831
924
  const prefix = workspaceDir === "." ? "" : `${workspaceDir}/`;
1832
925
  return {
1833
926
  "claude-code": ARTIFACT_PATHS["claude-code"],
@@ -1835,23 +928,12 @@ function artifactPaths(workspaceDir) {
1835
928
  };
1836
929
  }
1837
930
 
1838
- /** Every backend, run against one parsed policy. The order is the order the matrix prints in. */
931
+ export const GENERATED_DIRS = ["compile", ".claude"];
932
+
1839
933
  export function backends(parsed, options = {}) {
1840
- // **Provenance rides in the Claude Code artifact only, and that is an honest limit rather than an
1841
- // oversight.** `githubRuleset` emits to a fixed external schema that has nowhere to carry a
1842
- // `$portulan` block; its provenance is the one line it can carry, in `name`. Both backends compile
1843
- // from ONE root plan per run, so the recorded origins describe the ruleset's inputs too — they are
1844
- // simply not readable in its file. Stated here and in `../.portulan/compile/README.md` (#264).
1845
934
  return [claudeCode(parsed, options), githubRuleset(parsed, options)];
1846
935
  }
1847
936
 
1848
- /**
1849
- * The per-host backend matrix: one row per policy rule, one cell per backend.
1850
- *
1851
- * Derived from the backends rather than maintained beside them. A matrix written by hand is a claim
1852
- * about compilers, and this repository has spent two milestones on what claims about machinery are
1853
- * worth — so this one cannot drift from the compilers, because it *is* their accounting transposed.
1854
- */
1855
937
  export function matrix(parsed, options = {}) {
1856
938
  const columns = backends(parsed, options);
1857
939
  return parsed.rules.map((rule) => {
@@ -1866,354 +948,1188 @@ export function matrix(parsed, options = {}) {
1866
948
  });
1867
949
  }
1868
950
 
1869
- // ===========================================================================================
1870
- // 3. The command line
1871
- // ===========================================================================================
951
+ // ---------------------------------------------------------------- guidance: slots.context compiled per host
1872
952
 
1873
- /**
1874
- * The same answer as `policyPath`, plus WHICH ARM produced it — and, where the manifest named
1875
- * something unusable, WHY.
1876
- *
1877
- * `policyPath` returns a path either way, which is all a reader of the policy needs and is exactly
1878
- * what made the caller unable to tell two different situations apart: a workspace that NAMES a policy
1879
- * file which is missing (a genuine error — something is declared and absent), and a workspace that
1880
- * names none at all (a legitimate shape, per this function's own note below). Both arrived at
1881
- * `readJson` as `ENOENT` and were reported identically, so the second — documented here as legitimate
1882
- * — surfaced as `cannot read the gate policy at …/gates.json`, which reads like a corrupt or deleted
1883
- * file and sends the reader to look for one that was never supposed to exist.
1884
- *
1885
- * Found 2026-08-22 while booting the `sleepy-panda` workspace, which declares no `gates` key and
1886
- * composes a pack contributing two gate rules: the compiler refused with an ENOENT that mentioned
1887
- * neither fact. Reporting declaredness is what lets the caller say which of the two it is.
1888
- *
1889
- * **`declared: false` is four situations, not one, so it carries a `reason`.** The first cut reported
1890
- * every one of them as "`workspace.json` has no top-level `gates` key", which is false for a manifest
1891
- * that is absent and false for a `gates` value this function refused — the same two-situations-one-
1892
- * sentence defect this change exists to fix, reproduced one level down. `reason` is `no-key`,
1893
- * `no-manifest`, `refused`, or `declared`. Found by Copilot on this pull request.
1894
- */
1895
- export function policyDeclaration(workspaceRoot, workspaceDir = ".portulan") {
1896
- const base = path.join(workspaceRoot, workspaceDir);
1897
- const manifest = path.join(base, "workspace.json");
1898
- const fallback = (reason) => ({ file: path.join(workspaceRoot, workspaceDir, "gates.json"), declared: false, reason });
1899
- let declared;
1900
- try {
1901
- declared = JSON.parse(fs.readFileSync(manifest, "utf8")).gates;
1902
- } catch {
1903
- // No manifest, or unreadable. `doctor` is the tool that judges a manifest; this one only needs
1904
- // to know where the policy is, and the default is where it is when nothing says otherwise.
1905
- return fallback("no-manifest");
1906
- }
1907
- if (declared === undefined) return fallback("no-key");
1908
- // Validated against the schema's `filePath` shape before it is used, and containment is
1909
- // checked after resolution rather than by pattern alone — a `../` chain passes any regex and
1910
- // still escapes. Without this, a malformed or hand-edited manifest turns a hook that runs on
1911
- // EVERY tool call into an arbitrary file read outside the workspace. `doctor` would refuse such
1912
- // a manifest, but this runner must not depend on `doctor` having been run: the two tools have
1913
- // no ordering between them, and the schema is the contract, not the sequence. Found by review.
1914
- //
1915
- // **`isInside`, not another hand-rolled spelling.** The containment test read
1916
- // `!path.relative(base, resolved).startsWith("..")` until 2026-08-22 — the spelling
1917
- // `./inside.mjs` exists to replace, which calls an ordinary directory named `..foo` an escape and
1918
- // falls back for a policy that was inside all along. This module already imports `isInside` and
1919
- // already carries the same warning at `recordedOrigin`, so the rule was in scope and unread.
1920
- // Pre-existing, and surfaced by Copilot on the pull request that touched this line.
1921
- //
1922
- // **No count is asserted here.** A first draft called this "the fifth copy"; the pre-commit
1923
- // checkpoint could not reproduce that figure and a census then found more, including one in THIS
1924
- // module at `resolveWorkspace`. The census is #331 rather than this commit, because the remaining
1925
- // sites do not share semantics — they disagree on the empty relative path and on absolute paths —
1926
- // so each needs its own red and green. An unreproducible number is worse than none.
1927
- if (typeof declared === "string" && declared.trim() && FILE_PATH.test(declared)) {
1928
- const resolved = path.resolve(base, declared);
1929
- if (resolved !== base && isInside(base, resolved)) return { file: resolved, declared: true, reason: "declared" };
1930
- }
1931
- // The manifest named something, and nothing it named is usable — a wrong type, an empty string, a
1932
- // shape the schema refuses, or a path that escapes the workspace. The compiler is on the
1933
- // conventional path, so the fallback owns the diagnostic; but this is NOT the state of a manifest
1934
- // that named nothing, and a message conflating the two is the defect this change is about.
1935
- return fallback("refused");
953
+ export const LOAD_TIERS = ["always", "on-path", "on-invoke", "on-read"];
954
+
955
+ /** Its rules are claimed by `RULES_MARKER`, not a mark in each: every context pays for an always rule's bytes. */
956
+ export const GUIDANCE_RULES_DIR = ".claude/rules/portulan";
957
+
958
+ /** Not a `.md` file, so it costs no context: Claude Code loads only `.md` files as rules. */
959
+ export const RULES_MARKER = ".compiled";
960
+ const RULES_MARKER_HEAD = "`portulan compile` wrote the rules listed below from slots.context; it rewrites and removes only these.";
961
+
962
+ const markerText = (names) => `${RULES_MARKER_HEAD}\n${names.map((name) => `${name}\n`).join("")}`;
963
+
964
+ /** The rule files a marker lists, or null when its text is not one this compiler writes. */
965
+ function markedRules(text) {
966
+ const lines = text.split("\n");
967
+ if (lines[0] !== RULES_MARKER_HEAD || lines.at(-1) !== "") return null;
968
+ const names = lines.slice(1, -1);
969
+ if (!names.every((name) => SLUG.test(name.replace(/\.md$/, "")) && name.endsWith(".md"))) return null;
970
+ return new Set(names).size === names.length ? new Set(names) : null;
1936
971
  }
1937
972
 
1938
- /**
1939
- * Where a workspace's gate policy lives — the path alone.
1940
- *
1941
- * Kept as the narrow answer because most callers want exactly that, and because every existing caller
1942
- * and test was written against a string. `policyDeclaration` is the one that also says whether the
1943
- * workspace named it, and why when it did not.
1944
- *
1945
- * Read from the manifest's top-level `gates` key, because that is what the key MEANS —
1946
- * `../spec/slots.md` calls it "a path to a JSON file the enforcement compiler reads", and `doctor`
1947
- * resolves it. This compiler hard-coded `.portulan/gates.json` for one round, so a workspace naming a
1948
- * different file would have had `doctor` validate one policy and `compile` compile another, with both
1949
- * reporting green. A manifest key that validates and is never consumed is this repository's most
1950
- * expensive recurring defect, and here it was in the very key this milestone added. Found by review on
1951
- * the pull request.
1952
- *
1953
- * The default survives for a workspace with no manifest or no key — that is a legitimate shape, and
1954
- * refusing it would make the key required, which is a spec change nobody decided.
1955
- */
1956
- export function policyPath(workspaceRoot, workspaceDir = ".portulan") {
1957
- return policyDeclaration(workspaceRoot, workspaceDir).file;
973
+ export const ON_READ_INDEX = "on-read.md";
974
+
975
+ /** Shared with hand-written skills, so a compiled skill carries a mark naming its unit. */
976
+ export const SKILLS_DIR = ".claude/skills";
977
+ const SKILL_MARK = "<!-- compiled by `portulan compile` from ";
978
+ const SKILL_MARK_TAIL = "; edit that file, then recompile -->";
979
+
980
+ /** The unit a skill's mark names, or null when the line after its frontmatter is not a mark this compiler writes. */
981
+ function skillSource(text) {
982
+ const lines = text.split("\n");
983
+ const close = lines[0] === "---" ? lines.indexOf("---", 1) : -1;
984
+ const line = close === -1 || lines[close + 1] !== "" ? "" : lines[close + 2] ?? "";
985
+ if (!line.startsWith(SKILL_MARK) || !line.endsWith(SKILL_MARK_TAIL)) return null;
986
+ return line.slice(SKILL_MARK.length, -SKILL_MARK_TAIL.length) || null;
1958
987
  }
1959
988
 
989
+ /** `null` is a tier the host cannot express, whose units degrade to an on-read pointer. */
990
+ export const GUIDANCE_HOSTS = {
991
+ "claude-code": {
992
+ label: "Claude Code",
993
+ always: `an unscoped rule in \`${GUIDANCE_RULES_DIR}/\``,
994
+ "on-path": "a rule scoped by `paths:`",
995
+ "on-invoke": `a project skill in \`${SKILLS_DIR}/\``,
996
+ "on-read": `a line in \`${GUIDANCE_RULES_DIR}/${ON_READ_INDEX}\``,
997
+ },
998
+ "agents-md": {
999
+ label: "AGENTS.md, vendored",
1000
+ always: "inline in `AGENTS.md`",
1001
+ "on-path": null,
1002
+ "on-invoke": null,
1003
+ "on-read": "a line in `AGENTS.md`",
1004
+ },
1005
+ };
1006
+
1007
+ /** Both reserved: the boot skill knows the card is in its context by this first line. */
1008
+ export const BOOT_CARD_UNIT = "boot";
1009
+ export const BOOT_CARD_LINE = "# Portulan boot card";
1010
+
1011
+ /** Claude Code 2.1.281 resolves an import from the importing file, inside the project only, and loads nothing this deep. */
1012
+ export const IMPORT_DEPTH = 5;
1013
+
1014
+ export const LEADS_LINE = /^<!-- leads: (\S+) -->$/;
1015
+
1016
+ export const ENGINE_LINE = /^<!-- engine: (\S+) -->$/;
1017
+ const ENGINE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
1018
+ const PLUGIN_ROOT = "<plugin root>/";
1019
+
1020
+ export const GATES_LINE = /^<!-- gates: (\S+) -->$/;
1021
+
1022
+ const TIER_GLOSS = {
1023
+ auto: "**Auto**, unattended",
1024
+ propose: "**Propose**, a human decides",
1025
+ gated: "**Gated**, a human's approval for each action, never inferred and never standing",
1026
+ prohibited: "**Prohibited**, where no approval exists",
1027
+ };
1028
+
1029
+ const STRAY_IMPORT = {
1030
+ "on-path": "the host loads a path-scoped rule's imports into every context, so the file would not wait for the path",
1031
+ "on-invoke": "the unit compiles to a skill in another directory, from which the import names another file",
1032
+ "on-read": "an on-read file is opened as it stands, so the import would load nothing",
1033
+ };
1034
+
1960
1035
  /**
1961
- * What to say when a workspace declares no gate policy and none is there by convention.
1962
- *
1963
- * This is a STATE, not a failure to read a file, and the difference is the whole point: `policyPath`'s
1964
- * note calls an absent `gates` key "a legitimate shape, and refusing it would make the key required,
1965
- * which is a spec change nobody decided" — and then the reader refused it anyway, with `ENOENT` on a
1966
- * file the workspace never claimed to have.
1967
- *
1968
- * It stays a refusal (exit 2, nothing written), because nothing was compiled and a compiler that
1969
- * reports success having emitted nothing is the failure this repository keeps writing checks against.
1970
- * What changes is that the refusal names the actual state and what it costs.
1971
- *
1972
- * **The pack count is the load-bearing half.** A workspace in this shape may still compose packs
1973
- * contributing gate fragments — `sleepy-panda` composes `rituals/checkpoints`, which contributes two
1974
- * — and those rules silently reach nothing, because a fragment tightens a policy and there is none to
1975
- * tighten. That is the fact a reader needs and the one the old message could not carry: it threw
1976
- * before `packContributions` was ever called.
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.
1977
1038
  */
1978
- function undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOptions, reason = "no-key") {
1979
- const manifest = path.join(workspaceRoot, workspaceDir, "workspace.json");
1980
- // **One opening per arm, because the arm is the reader's first question.** `no-key` is the shape
1981
- // this whole change defends as legitimate; `no-manifest` is a workspace that has not been
1982
- // authored yet; `refused` is a manifest that DID name a policy and named one this compiler will
1983
- // not read, which is neither of the other two and must not be told it has no `gates` key.
1984
- const opening =
1985
- reason === "refused"
1986
- ? `\`workspace.json\` names a gate policy this compiler will not read — its top-level ` +
1987
- `\`gates\` key is not a relative path to a file inside the workspace — and there is no ` +
1988
- `\`gates.json\` at ${policyFile} either.`
1989
- : reason === "no-manifest"
1990
- ? `this workspace declares no gate policy — there is no readable \`workspace.json\` at ` +
1991
- `${manifest}, and there is no \`gates.json\` at ${policyFile}.`
1992
- : `this workspace declares no gate policy — \`workspace.json\` has no top-level \`gates\` key, ` +
1993
- `and there is no \`gates.json\` at ${policyFile}.`;
1994
- const lines = [`${opening} Nothing was compiled and nothing was written.`];
1995
- let composed = null;
1996
- try {
1997
- composed = packContributions(workspaceRoot, workspaceDir, packOptions);
1998
- } catch {
1999
- // The pack layer has refusals of its own — a shadowed pack, a malformed pack manifest, an
2000
- // unresolvable root. Swallowed HERE and nowhere else: this message is about the missing policy,
2001
- // and a pack refusal raised from inside a diagnostic would replace the answer with a different
2002
- // question. Declare a policy and the very next run surfaces it on its own terms.
2003
- }
2004
- const contributions = composed?.contributions ?? [];
2005
- const rules = contributions.reduce((n, c) => n + (c.fragments?.length ?? 0), 0);
2006
- if (rules > 0) {
2007
- const packs = contributions
2008
- .filter((c) => (c.fragments?.length ?? 0) > 0)
2009
- .map((c) => `\`${c.pack}\``)
2010
- .join(", ");
2011
- lines.push(
2012
- `${rules} pack-contributed gate rule(s) from ${packs} are therefore NOT compiled: ` +
2013
- `a fragment tightens a policy, and there is none here to tighten.`,
2014
- );
1039
+ export function importSpans(text) {
1040
+ const found = [];
1041
+ let fence = null;
1042
+ let comment = false;
1043
+ let listed = false; // in a list, which a blank line alone does not end
1044
+ let flowing = false; // the line above is list text, which an unindented line may continue
1045
+ const opensInItem = /^(?:#{1,6}(?:[ \t]|$)|>|<!--)/;
1046
+ let offset = 0;
1047
+ for (const line of text.split("\n")) {
1048
+ const start = offset;
1049
+ offset += line.length + 1;
1050
+ let raw = false;
1051
+ if (!comment) {
1052
+ const marker = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
1053
+ if (marker) {
1054
+ if (fence === null) fence = marker[1];
1055
+ else if (marker[1][0] === fence[0] && marker[1].length >= fence.length && /^\s{0,3}[`~]+\s*$/.test(line)) fence = null;
1056
+ flowing = false;
1057
+ continue;
1058
+ }
1059
+ if (fence !== null) continue;
1060
+ const item = /^\s*(?:[-+*]|\d{1,9}[.)])(?:[ \t]+|$)/.exec(line);
1061
+ if (line.trim() === "") {
1062
+ flowing = false;
1063
+ } else if (item && (listed || /^ {0,3}\S/.test(line))) {
1064
+ listed = flowing = true;
1065
+ raw = !opensInItem.test(line.slice(item[0].length));
1066
+ } else if (listed && (/^\s/.test(line) || (flowing && !OPENS_BLOCK.test(line)))) {
1067
+ flowing = true;
1068
+ raw = !opensInItem.test(line.trimStart());
1069
+ } else {
1070
+ listed = flowing = false;
1071
+ }
1072
+ }
1073
+ // Code spans and comments are blanked, not cut, so every offset stays put; an item's text is read whole.
1074
+ let seen = raw ? line : "";
1075
+ let at = raw ? line.length : 0;
1076
+ while (at < line.length) {
1077
+ if (comment) {
1078
+ const end = line.indexOf("-->", at);
1079
+ const to = end === -1 ? line.length : end + 3;
1080
+ seen += " ".repeat(to - at);
1081
+ at = to;
1082
+ comment = end === -1;
1083
+ continue;
1084
+ }
1085
+ const next = /`+|<!--/g;
1086
+ next.lastIndex = at;
1087
+ const hit = next.exec(line);
1088
+ if (hit === null) {
1089
+ seen += line.slice(at);
1090
+ break;
1091
+ }
1092
+ seen += line.slice(at, hit.index);
1093
+ if (hit[0] === "<!--") {
1094
+ comment = true;
1095
+ at = hit.index;
1096
+ continue;
1097
+ }
1098
+ const close = line.indexOf(hit[0], hit.index + hit[0].length);
1099
+ const to = close === -1 ? hit.index + hit[0].length : close + hit[0].length;
1100
+ seen += close === -1 ? hit[0] : " ".repeat(to - hit.index);
1101
+ at = to;
1102
+ }
1103
+ for (const match of seen.matchAll(/(?:^|\s)@((?:[^\s\\]|\\ )+)/g)) {
1104
+ found.push({ target: match[1], index: start + match.index + match[0].length - match[1].length, line });
1105
+ }
2015
1106
  }
2016
- lines.push(
2017
- reason === "refused"
2018
- ? "Give `gates` a relative path to a file inside the workspace directory, or remove the key " +
2019
- "and let `gates.json` be found by convention."
2020
- : "Declare one with `portulan new gate-policy`, or leave it undeclared deliberately — " +
2021
- "a workspace with no gate policy is a legitimate shape, and this is a state rather than a fault.",
2022
- );
2023
- return lines.join("\n ");
1107
+ return found;
2024
1108
  }
2025
1109
 
2026
- // ===========================================================================================
2027
- // Pack-contributed gate fragments — the cascade's middle layer
2028
- // ===========================================================================================
2029
- //
2030
- // The cascade is `core < pack < workspace`, and until now no pack contributed to the gate policy —
2031
- // ../packs/tools/README.md called that "the cascade's missing middle". The floor was ruled
2032
- // before any of it was built (../.portulan/proposals/0010-prohibited-as-a-fourth-universal-tier.md,
2033
- // agreed 2026-07-27): **packs may only tighten.** A pack may raise a tier or add a prohibition; it may
2034
- // never demote another layer's classification, because a composed-in third-party artifact able to
2035
- // demote `push` to Auto would be a dependency with the power to disarm the gate containing it — and
2036
- // the demotion would look exactly like configuration.
2037
- //
2038
- // That ruling is why the policy was modelled as a list of id-addressed rules with no dependence on
2039
- // being the only source. This is the merge step it was shaped for: an addition, not a redesign.
1110
+ /** The path Claude Code 2.1.281 reads from an import token, or null where it reads no import. */
1111
+ export const importPath = (target) => {
1112
+ const bare = target.split("#")[0].replaceAll("\\ ", " ");
1113
+ return bare !== "" && /^(?:\.\/|~\/|\/.|[A-Za-z0-9._-])/.test(bare) ? bare : null;
1114
+ };
2040
1115
 
2041
- /** The tier partial order, weakest to strongest. Tightening moves UP it and never down. */
2042
- const TIER_ORDER = ["auto", "propose", "gated", "prohibited"];
1116
+ function checkedImports(text, dir, root, where) {
1117
+ const own = [];
1118
+ const shown = path.relative(process.cwd(), root) || ".";
1119
+ const queue = [{ text, dir, depth: 0, from: where, top: null, file: null }];
1120
+ const seen = new Set();
1121
+ while (queue.length) {
1122
+ const { text: body, dir: base, depth, from, top, file: parent } = queue.shift();
1123
+ for (const { target, line } of importSpans(body)) {
1124
+ const bare = importPath(target);
1125
+ if (bare === null) continue;
1126
+ const spelled = `\`@${target}\` (in ${from})`;
1127
+ if (depth === 0 && line.trim() !== `@${target}`) {
1128
+ throw new CompileError(`${spelled} shares its line with other text — an import stands alone on its line, so compile can spell it again from the file it compiles to; text that is not an import goes in a fenced block, or in a code span outside a list, since the host reads a list item's code spans for imports too`);
1129
+ }
1130
+ if (bare.startsWith("~") || path.isAbsolute(bare)) {
1131
+ throw new CompileError(`${spelled} is a home or an absolute path — an import here is relative to the file that makes it, and stays inside ${shown}`);
1132
+ }
1133
+ const file = path.resolve(base, bare);
1134
+ if (!isInside(root, file)) throw new CompileError(`${spelled} leaves ${shown}, the tree compiled here — the host follows no import out of the project, and a vendored copy carries only its workspace`);
1135
+ let real;
1136
+ try {
1137
+ real = fs.realpathSync(file);
1138
+ } catch {
1139
+ throw new CompileError(`${spelled} names no file, so the host would load nothing — put text that is not an import in a fenced block, or in a code span outside a list`);
1140
+ }
1141
+ if (!isInside(fs.realpathSync(root), real)) throw new CompileError(`${spelled} is a link out of ${shown}, the tree compiled here`);
1142
+ if (!fs.statSync(real).isFile()) throw new CompileError(`${spelled} names no file, so the host would load nothing`);
1143
+ if (depth + 1 >= IMPORT_DEPTH) {
1144
+ throw new CompileError(`${spelled} sits ${depth + 1} imports below the rule, and the host loads nothing ${IMPORT_DEPTH} deep — import the file from nearer the rule`);
1145
+ }
1146
+ // Breadth-first, so a file both imported directly and through another is named once, as the text's own.
1147
+ if (depth === 0) own.push({ target, file, nested: [] });
1148
+ if (seen.has(real)) continue;
1149
+ seen.add(real);
1150
+ if (depth > 0) own[top].nested.push({ file, from: parent });
1151
+ queue.push({
1152
+ text: fs.readFileSync(real, "utf8"),
1153
+ dir: path.dirname(file),
1154
+ depth: depth + 1,
1155
+ from: path.relative(root, file).split(path.sep).join("/"),
1156
+ top: depth === 0 ? own.length - 1 : top,
1157
+ file,
1158
+ });
1159
+ }
1160
+ }
1161
+ return own;
1162
+ }
2043
1163
 
2044
- /** Where a tier sits in the partial order; `-1` for anything that is not a tier. */
2045
- export const tierRank = (tier) => TIER_ORDER.indexOf(tier);
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
+ const OPENS_BLOCK = /^(?:#{1,6}(?:[ \t]|$)|>|([-*_])(?:[ \t]*\1){2,}[ \t]*$|(?:[-+*]|\d{1,9}[.)])(?:[ \t]|$)|<(?:!--|\?|![A-Za-z]|!\[CDATA\[))/;
2046
1166
 
2047
- /**
2048
- * Resolve one declared pack name — the canonical `category/name` — against a list of roots, in order.
2049
- *
2050
- * Root-parameterized rather than deriving one location, because the two shapes that matter are not
2051
- * the same tree: a workspace composing a pack that ships beside it resolves under its own `packs/`,
2052
- * while an adopter installing from a feed resolves inside the installed plugin. One resolver, told
2053
- * where to look, so the second case is the same code path rather than a parallel one discovered later.
2054
- *
2055
- * Returns `{ name, category, pack, dir, manifest, root }` with `dir` and `root` null when nothing
2056
- * matched. `root` is which root answered — the fact a caller states per pack once the set is a union.
2057
- */
2058
- export function resolvePack(rawName, roots = []) {
2059
- // Coerced once, and the COERCED value is what every return carries. This compiler reads
2060
- // `workspace.json` without validating it — `doctor` is the tool that judges a manifest, and the
2061
- // two have no ordering between them — so `packs` may hold a number or an object, and the caller
2062
- // formats what comes back (`.padEnd()`), which on a non-string throws a TypeError that aborts the
2063
- // whole compile instead of reporting one unresolvable pack. Found by review.
2064
- const name = String(rawName);
2065
- const parts = name.split("/");
2066
- if (parts.length !== 2 || !parts[0] || !parts[1]) {
2067
- return { name, category: null, pack: null, dir: null, manifest: null, root: null, why: "not in `category/name` form" };
2068
- }
2069
- const [category, pack] = parts;
2070
- // A name is a name, never a path: a `..` segment here would resolve a declared pack outside every
2071
- // root it was supposed to be searched in.
2072
- if (!SLUG.test(category) || !SLUG.test(pack)) {
2073
- return { name, category: null, pack: null, dir: null, manifest: null, root: null, why: "`category/name` must both be slugs" };
1167
+ /** Each item's first sentence, byte for byte, from the first list in `file` or under its `#fragment` heading. */
1168
+ function leadsOf(file, where, fragment = null, name = path.basename(file)) {
1169
+ let text = fs.readFileSync(file, "utf8");
1170
+ if (fragment !== null) {
1171
+ try {
1172
+ text = sectionOf(text, fragment).text;
1173
+ } catch (error) {
1174
+ if (!(error instanceof CannotOutline)) throw error;
1175
+ throw new CompileError(`${where}: the leads of ${name}#${fragment} were asked for, and ${error.message}`);
1176
+ }
2074
1177
  }
2075
- for (const root of roots) {
2076
- const dir = path.join(root, category, pack);
2077
- const manifest = path.join(dir, "pack.json");
2078
- // `root` rides along because the resolution set may now be a union, and *which* root answered
2079
- // is the fact a caller states per pack. Deriving it afterwards by re-testing the roots would
2080
- // be a second implementation of this loop, and the two would drift on the first-match rule.
2081
- if (fs.existsSync(manifest)) return { name, category, pack, dir, manifest, root, why: null };
1178
+ return leadsOfText(text, fragment === null ? name : `${name}#${fragment}`, where);
1179
+ }
1180
+
1181
+ /** `leadsOf` over a text already read; `name` is how the refusals name its file. */
1182
+ export function leadsOfText(source, name, where) {
1183
+ const lines = source.split(/\r?\n/);
1184
+ const items = [];
1185
+ let kind = null;
1186
+ let fence = null;
1187
+ let under = false;
1188
+ for (const [at, line] of lines.entries()) {
1189
+ const marker = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
1190
+ if (marker) {
1191
+ if (kind !== null) break;
1192
+ fence = fence === null ? marker[1] : null;
1193
+ continue;
1194
+ }
1195
+ if (fence !== null) continue;
1196
+ const item = /^(\d+)\. (.*)$/.exec(line) ?? /^(-) (.*)$/.exec(line);
1197
+ if (item && (kind === null || (item[1] === "-") === (kind === "-"))) {
1198
+ kind = item[1] === "-" ? "-" : "1";
1199
+ items.push({ marker: item[1] === "-" ? "-" : `${item[1]}.`, text: item[2].trim() });
1200
+ } else if (kind !== null && /^\s+\S/.test(line)) {
1201
+ items.at(-1).text += ` ${line.trim()}`;
1202
+ } else if (kind !== null && line.trim() !== "") {
1203
+ if (under && !OPENS_BLOCK.test(line)) {
1204
+ throw new CompileError(
1205
+ `${where}: line ${at + 1} of ${name} follows an item of its first list with no blank line and no indent, ` +
1206
+ `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`,
1207
+ );
1208
+ }
1209
+ break;
1210
+ }
1211
+ under = kind !== null && line.trim() !== "";
2082
1212
  }
2083
- return { name, category, pack, dir: null, manifest: null, root: null, why: "no pack.json under any resolution root" };
1213
+ if (items.length === 0) throw new CompileError(`${where}: the leads of ${name} were asked for, and it holds no list`);
1214
+ return items.map(({ marker, text }) => {
1215
+ if (!text.startsWith("**")) throw new CompileError(`${where}: an item of ${name}'s first list opens without a bold lead — ${JSON.stringify(text.slice(0, 60))}`);
1216
+ const code = [...text.matchAll(/(`+)[\s\S]*?\1/g)].map((m) => [m.index, m.index + m[0].length]);
1217
+ let end = text.length;
1218
+ for (const stop of text.matchAll(/[.!?]/g)) {
1219
+ if (code.some(([from, to]) => stop.index >= from && stop.index < to)) continue;
1220
+ const after = text.slice(stop.index + 1).replace(/^[*_)"'”]+/, "");
1221
+ if (after === "" || /^\s/.test(after)) {
1222
+ end = text.length - after.length;
1223
+ break;
1224
+ }
1225
+ }
1226
+ const lead = text.slice(0, end);
1227
+ if (/\]\(/.test(lead)) throw new CompileError(`${where}: the lead ${JSON.stringify(lead.slice(0, 60))} of ${name} carries a link, which would not resolve from the compiled file`);
1228
+ return `${marker} ${lead}`;
1229
+ });
2084
1230
  }
2085
1231
 
2086
- /**
2087
- * The roots a declared pack name is resolved against for a workspace on disk.
2088
- *
2089
- * Derived from the manifest's `tree` rather than declared, because adding a key for it would be a
2090
- * Workspace Definition change nobody decided — and a slot before its consumer is the mistake that
2091
- * specification was written to avoid. A workspace with no `tree` gets no roots, and its declared packs
2092
- * are reported unresolvable rather than failed: the same answer `tree`'s absence already gives every
2093
- * other claim that needs a tree to check.
2094
- */
2095
- export function packRoots(workspaceDir, workspace) {
2096
- const tree = workspace?.tree;
2097
- if (typeof tree === "string" && tree.trim()) return [path.resolve(workspaceDir, tree, "packs")];
2098
- return [];
1232
+ function gatesOf(file, where, packs = []) {
1233
+ let policy;
1234
+ try {
1235
+ policy = JSON.parse(fs.readFileSync(file, "utf8"));
1236
+ } catch (cause) {
1237
+ throw new CompileError(`${where}: the gates of ${path.basename(file)} were asked for, and it is not a readable JSON policy — ${cause.message}`);
1238
+ }
1239
+ let rules;
1240
+ try {
1241
+ ({ rules } = parse(policy));
1242
+ } catch (cause) {
1243
+ if (!(cause instanceof CompileError)) throw cause;
1244
+ throw new CompileError(`${where}: the gates of ${path.basename(file)} were asked for, and ${cause.message}`);
1245
+ }
1246
+ const lines = TIER_ORDER.map((tier) => {
1247
+ const ids = rules.filter((r) => r.tier === tier).map((r) => `\`${r.id}\``);
1248
+ return `- ${TIER_GLOSS[tier]}: ${ids.length ? ids.join(", ") : "none"}.`;
1249
+ });
1250
+ if (packs.length) lines.push(`- **Packs** add gates of their own, which \`portulan compile --matrix\` lists: ${packs.map((p) => `\`${p}\``).join(", ")}.`);
1251
+ return lines;
2099
1252
  }
2100
1253
 
2101
- /**
2102
- * The `options` object for `packContributions` when roots were named on the command line.
2103
- *
2104
- * **Named roots REPLACE the root derived from `tree`; they are not searched ahead of it.** The first
2105
- * implementation appended the derived root after the named ones here, while `doctor` and `index` — both
2106
- * of which spell this as `options.packRoots ?? packRoots(…)` — replaced it. Three tools, two semantics,
2107
- * and every prose carrier described only one of them. The pre-commit checkpoint on the change that added
2108
- * the flag found it by attacking it: a workspace with the pack sitting in its own tree, given
2109
- * `--pack-root <an empty directory>`, compiled **green from the local copy** — which is exactly the thing
2110
- * the flag exists to exclude, since the whole point is that "the pack resolved from the feed" must not be
2111
- * satisfiable by a copy lying around. Replacement is the semantics that serves that, so replacement is
2112
- * what all three now do, and `cli/compile.test.mjs` pins the divergent case rather than trusting the
2113
- * prose to hold the three in line.
2114
- *
2115
- * Returns `{}` when nothing was named, so the derived-only path stays byte-identical to what it was
2116
- * before the flag existed — a caller that passes no roots must not take a different branch, which is the
2117
- * property that keeps the flag a *surface* on the existing behaviour rather than a second one.
2118
- */
2119
- // **No caller since `--pack-root auto` landed** — the command line now passes `named` into `rootPlan`
2120
- // and the precedence rule decides. Kept, exported and documented because four files cite it in prose
2121
- // for the DEFECT it records rather than for the function it is: three tools, two semantics, found by a
2122
- // checkpoint. A reader arriving from one of those citations should find this note rather than infer
2123
- // that the code path is live.
2124
- export function namedRootsOption(workspaceRoot, namedRoots) {
2125
- if (!namedRoots?.length) return {};
2126
- return { packRoots: [...namedRoots] };
1254
+ /** 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 = []) {
1256
+ unit.leadSources = [];
1257
+ unit.written = new Set();
1258
+ return unit.body
1259
+ .split("\n")
1260
+ .flatMap((line) => {
1261
+ const leads = LEADS_LINE.exec(line);
1262
+ const engine = leads ? null : ENGINE_LINE.exec(line);
1263
+ const gates = leads || engine ? null : GATES_LINE.exec(line);
1264
+ if (!leads && !engine && !gates) return [line];
1265
+ if (engine) return engineLeads(unit, engine[1], root);
1266
+ const [what, spelled] = leads ? ["leads", leads[1]] : ["gates", gates[1]];
1267
+ const hash = leads ? spelled.indexOf("#") : -1;
1268
+ const [named, fragment] = hash === -1 ? [spelled, null] : [spelled.slice(0, hash), spelled.slice(hash + 1)];
1269
+ const file = path.resolve(dir, named);
1270
+ 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
+ let real;
1272
+ try {
1273
+ real = fs.realpathSync(file);
1274
+ } catch {
1275
+ throw new CompileError(`${unit.source}: the ${what} of ${named} were asked for, and it names no file`);
1276
+ }
1277
+ if (!isInside(fs.realpathSync(root), real) || !fs.statSync(real).isFile()) {
1278
+ throw new CompileError(`${unit.source}: the ${what} of ${named} were asked for, and it is not a file inside the tree compiled here`);
1279
+ }
1280
+ const rel = path.relative(root, file).split(path.sep).join("/");
1281
+ if (!unit.leadSources.includes(rel)) unit.leadSources.push(rel);
1282
+ unit.written.add(what);
1283
+ return leads ? leadsOf(real, unit.source, fragment, path.basename(named)) : gatesOf(real, unit.source, packs);
1284
+ })
1285
+ .join("\n");
2127
1286
  }
2128
1287
 
2129
- /**
2130
- * The one place the three tools agree on where a resolution root comes from.
2131
- *
2132
- * `compile`, `doctor` and `index` each spelled the derived-versus-named choice for themselves, and two
2133
- * of the three spelled it differently — the divergence `namedRootsOption` records, found by a
2134
- * checkpoint rather than by the suite. Discovery adds a third source and a precedence between all
2135
- * three, which is three more chances to diverge, so the ordering is written once in `discover.mjs`'s
2136
- * `resolutionRoots` and **called** by each tool rather than described in three places.
2137
- */
2138
- export function rootPlan(workspaceDir, manifest, { named = [], namedGiven = null, discovery = null, forced = false } = {}) {
2139
- return resolutionRoots({ named, namedGiven, derived: packRoots(workspaceDir, manifest), discovery, forced });
1288
+ function engineLeads(unit, spelled, root) {
1289
+ const hash = spelled.indexOf("#");
1290
+ const [named, fragment] = hash === -1 ? [spelled, null] : [spelled.slice(0, hash), spelled.slice(hash + 1)];
1291
+ const core = path.join(ENGINE_ROOT, "core");
1292
+ const file = path.resolve(core, named);
1293
+ let real = null;
1294
+ try {
1295
+ if (isInside(core, file)) real = fs.realpathSync(file);
1296
+ } catch {
1297
+ real = null;
1298
+ }
1299
+ if (real === null || !isInside(fs.realpathSync(core), real) || !fs.statSync(real).isFile()) {
1300
+ throw new CompileError(`${unit.source}: the engine's leads of ${spelled} were asked for, and it names no file in the engine's core/`);
1301
+ }
1302
+ const shown = `core/${path.relative(core, file).split(path.sep).join("/")}`;
1303
+ const source = `the engine's ${shown}`;
1304
+ if (!unit.leadSources.includes(source)) unit.leadSources.push(source);
1305
+ unit.written.add("leads");
1306
+ const own = fs.realpathSync(ENGINE_ROOT) === fs.realpathSync(root);
1307
+ return leadsOf(real, unit.source, fragment, shown).map((lead) => (own ? lead.replaceAll(PLUGIN_ROOT, "") : lead));
2140
1308
  }
2141
1309
 
2142
- /**
2143
- * What the packs a workspace declares contribute. Reads each resolved `pack.json` and collects its
2144
- * gate fragments. It does NOT validate the manifest against the Pack Definition — `doctor` does that,
2145
- * and this compiler must not depend on `doctor` having been run: the two have no ordering between
2146
- * them. What it depends on instead is `parse`, which validates every composed rule exactly as it
2147
- * validates a hand-written one, so a malformed fragment is refused by the same code either way.
2148
- */
2149
- /**
2150
- * How a resolved pack's ORIGIN is recorded in an emitted artifact — a deliberate collapse of the
2151
- * resolver's three tags into two, and the collapse is the load-bearing part.
2152
- *
2153
- * `resolutionRoots` tags roots `named | derived | discovered`. Recording that raw would be wrong in a
2154
- * way that turns this whole feature into a liability: the PINNED rail spells its root
2155
- * (`--pack-root packs`, tagged `named`) while a bare run derives the same directory (tagged
2156
- * `derived`). Two documented-correct spellings of the same world would emit different bytes, and
2157
- * `verify/compile.sh` would go red on a tree nothing was wrong with — a per-machine false red, which
2158
- * is worse than the silent hazard #264 is about.
2159
- *
2160
- * So: `discovered` iff the answering root came from discovery — **which is a statement about where the
2161
- * root was FOUND, not about where it sits.** Discovery reads a record under `CLAUDE_CONFIG_DIR`, and a
2162
- * hermetic run can point that inside the tree, so a discovered root is not necessarily an external one;
2163
- * the docblock said "a directory outside this repository" until review caught the conflation, and the
2164
- * distinction is this field's whole subject rather than a quibble — it is why `outside-tree` exists as
2165
- * a separate answer below. Otherwise `tree`, when the root is one the repository itself yields. A NAMED root that is not under the workspace root is neither, and is recorded as
2166
- * `outside-tree` rather than flattened into `tree`, because calling somebody's `--pack-root /elsewhere`
2167
- * "the tree" would be this field's first lie.
2168
- */
2169
- export function recordedOrigin(root, plan, workspaceRoot) {
2170
- const same = (a, b) => path.resolve(a) === path.resolve(b);
2171
- const tagged = (plan?.origins ?? []).find((o) => same(o.root, root));
2172
- if (tagged?.origin === "discovered") return "discovered";
2173
- // **`derived` and `named` take the SAME inside-the-repository test**, and the first draft trusted
2174
- // `derived` on its tag alone. It is derived from the manifest's `tree`, which is a string a human
2175
- // edits: `tree: "../../elsewhere"` resolves a derived root of `/elsewhere/packs` — measured — and
2176
- // that would have been recorded as `tree`. A tag says where a root CAME FROM; only the path says
2177
- // whether it is in this repository, and this field's whole job is the second question. Copilot.
2178
- //
2179
- // Inside INCLUDES being the repository itself, which the first draft called `outside-tree` on a
2180
- // `rel` of `""`. By this function's own standard that was the field's first lie.
2181
- //
2182
- // **Both sides resolved through `realpathSync`**, because a lexical comparison makes two spellings
2183
- // of one directory disagree: `--workspace <alias> --pack-root <realpath>` recorded `outside-tree`
2184
- // for a root plainly inside the tree, which would emit a different artifact for an identical world
2185
- // — the per-machine false red this whole design refuses. Measured. It is the same trap #220's
2186
- // second arm repaired in the Stop-gate's divergence sentence, and it is worth stating twice
2187
- // because the two sites were written a day apart and neither inherits the other's guard.
2188
- const real = (dir) => {
1310
+ const UNIT_KEYS = new Set(["tier", "paths", "description"]);
1311
+
1312
+ /** A line break, by any spelling a host might honour, or another control character. */
1313
+ const CONTROL = /[\u0000-\u001f\u007f\u0085\u2028\u2029]/;
1314
+
1315
+ function scalar(raw, where) {
1316
+ const value = raw.trim();
1317
+ if (value.startsWith('"')) {
2189
1318
  try {
2190
- return fs.realpathSync(path.resolve(dir));
1319
+ const parsed = JSON.parse(value);
1320
+ if (typeof parsed === "string") return parsed;
2191
1321
  } catch {
2192
- return path.resolve(dir);
1322
+ // Refused below.
2193
1323
  }
2194
- };
2195
- // **`isInside`, not a fourth hand-rolled spelling.** `!rel.startsWith("..")` calls a directory
2196
- // literally named `..foo` outside the tree — and this repository has already paid for that exact
2197
- // reasoning once: `./index.mjs`'s docblock records it as the ninth fail-open found in this
2198
- // scaffolding, and *the first one written by the change that cites the class*. This was the second.
2199
- // The helper is exported precisely so a third copy cannot drift into it. Copilot.
2200
- return isInside(real(workspaceRoot), real(root)) ? "tree" : "outside-tree";
1324
+ throw new CompileError(`${where} is not a string this reader can parse — write it plain, or in double quotes with JSON's escapes`);
1325
+ }
1326
+ if (value.startsWith("'")) {
1327
+ const inner = value.length >= 2 && value.endsWith("'") ? value.slice(1, -1) : null;
1328
+ if (inner !== null && /^(?:[^']|'')*$/.test(inner)) return inner.replaceAll("''", "'");
1329
+ throw new CompileError(`${where} is single-quoted and either not closed or holding a \`'\` not written \`''\``);
1330
+ }
1331
+ return value;
2201
1332
  }
2202
1333
 
2203
- /**
2204
- * Two copies of one pack answered, and this says what differs between them.
2205
- *
2206
- * **One carrier, because the comparison has already been wrong twice.** `doctor` grew this first and
2207
- * its history is the argument for not writing a fourth: a first spelling projected `[id, tier, action]`
2208
- * and so read a copy differing in `reason` as agreeing — while `composeFragments` pushes the WHOLE
2209
- * fragment, so those differences do reach the compiled policy; a second compared the whole fragment
2210
- * but claimed *byte-identical*, which `JSON.stringify` of a parsed value cannot promise. This is that
2211
- * third, correct version, moved here so `doctor` and `compile` cannot drift apart about what a shadow
2212
- * IS — proposal `0020`'s rule, applied to a function rather than to a sentence.
2213
- *
2214
- * Parsed and key-order-normalised is the RIGHT comparison: reformatting a `pack.json` changes nothing
2215
- * about what the pack contributes, so reporting it would be noise.
2216
- */
1334
+ function unitFrontmatter(lines, where) {
1335
+ const fields = {};
1336
+ for (let i = 0; i < lines.length; i += 1) {
1337
+ const line = lines[i];
1338
+ if (line.trim() === "") continue;
1339
+ const match = /^([A-Za-z_][\w-]*):(?:\s+(.*))?$/.exec(line);
1340
+ if (!match) throw new CompileError(`${where}: frontmatter line ${i + 2} is not \`key: value\` — ${JSON.stringify(line)}`);
1341
+ const [, key, rest = ""] = match;
1342
+ 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)`);
1344
+ }
1345
+ if (Object.hasOwn(fields, key)) throw new CompileError(`${where}: \`${key}\` is declared twice`);
1346
+ if (key !== "paths") {
1347
+ fields[key] = scalar(rest, `${where}: \`${key}\``);
1348
+ continue;
1349
+ }
1350
+ if (rest.trim() !== "") {
1351
+ let list;
1352
+ try {
1353
+ list = JSON.parse(rest.trim());
1354
+ } catch {
1355
+ list = null;
1356
+ }
1357
+ if (!Array.isArray(list)) {
1358
+ throw new CompileError(`${where}: \`paths\` is not a list — write it \`["a/**", "b/*.md"]\`, each glob in double quotes, or as a block of \`- \` items`);
1359
+ }
1360
+ fields.paths = list;
1361
+ continue;
1362
+ }
1363
+ const items = [];
1364
+ while (i + 1 < lines.length && /^\s*-(\s|$)/.test(lines[i + 1])) {
1365
+ i += 1;
1366
+ items.push(scalar(lines[i].replace(/^\s*-\s*/, ""), `${where}: an item of \`paths\``));
1367
+ }
1368
+ fields.paths = items;
1369
+ }
1370
+ return fields;
1371
+ }
1372
+
1373
+ export function parseUnit(name, text, source = `${name}.md`) {
1374
+ const where = source;
1375
+ if (!SLUG.test(name)) {
1376
+ throw new CompileError(`${where}: a unit's name is its file's name less \`.md\`, and it must be a slug — lowercase letters, digits and single hyphens — because a host names a skill by it`);
1377
+ }
1378
+ if (name === path.basename(ON_READ_INDEX, ".md")) {
1379
+ throw new CompileError(`${where}: \`${name}\` is the name of the index this compiler writes beside the rules, so no unit may take it`);
1380
+ }
1381
+ // A byte-order mark is an editor's, not the author's: it is dropped, as the host drops it.
1382
+ const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
1383
+ if (lines[0] !== "---") throw new CompileError(`${where}: a unit opens with frontmatter naming its tier — the first line must be \`---\``);
1384
+ const close = lines.indexOf("---", 1);
1385
+ if (close === -1) throw new CompileError(`${where}: the frontmatter is never closed with a \`---\` line`);
1386
+ const fields = unitFrontmatter(lines.slice(1, close), where);
1387
+
1388
+ const tier = fields.tier;
1389
+ if (!LOAD_TIERS.includes(tier)) {
1390
+ 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
+ }
1392
+ let paths = null;
1393
+ if (tier === "on-path") {
1394
+ if (fields.paths === undefined || fields.paths.length === 0) {
1395
+ throw new CompileError(`${where}: an on-path unit names the \`paths\` that load it, and this one names none`);
1396
+ }
1397
+ for (const glob of fields.paths) {
1398
+ if (typeof glob !== "string" || glob.trim() === "" || glob !== glob.trim() || CONTROL.test(glob)) {
1399
+ throw new CompileError(`${where}: every item of \`paths\` is a glob on one line with no surrounding space, and ${JSON.stringify(glob)} is not`);
1400
+ }
1401
+ if (glob.startsWith("/") || /^[A-Za-z]:/.test(glob) || glob.split("/").includes("..") || glob.includes("\\")) {
1402
+ throw new CompileError(`${where}: \`${glob}\` is not a glob relative to the repository and inside it — no leading \`/\`, no \`..\`, and \`/\` as the separator`);
1403
+ }
1404
+ }
1405
+ paths = fields.paths;
1406
+ } else if (fields.paths !== undefined) {
1407
+ 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
+ }
1409
+ let description = null;
1410
+ if (tier === "always") {
1411
+ if (fields.description !== undefined) {
1412
+ throw new CompileError(`${where}: an always unit is loaded whole, so nothing reads a description of it — remove \`description\`, or move the unit to a later tier`);
1413
+ }
1414
+ } else {
1415
+ description = fields.description;
1416
+ if (typeof description !== "string" || description.trim() === "") {
1417
+ throw new CompileError(`${where}: an ${tier} unit needs a one-line \`description\`: it is what an agent reads to decide whether to open the unit`);
1418
+ }
1419
+ // A quoted scalar can spell a line break as an escape, and would then add an index line nobody declared.
1420
+ if (CONTROL.test(description)) {
1421
+ throw new CompileError(`${where}: the \`description\` holds a line break or another control character, and it is written as one line of an index`);
1422
+ }
1423
+ description = description.trim();
1424
+ }
1425
+ const rest = lines.slice(close + 1);
1426
+ while (rest.length && rest[0].trim() === "") rest.shift();
1427
+ while (rest.length && rest[rest.length - 1].trim() === "") rest.pop();
1428
+ if (rest.length === 0) throw new CompileError(`${where}: the unit carries no guidance below its frontmatter`);
1429
+ if (name === BOOT_CARD_UNIT) {
1430
+ if (tier !== "always") {
1431
+ throw new CompileError(`${where}: \`${BOOT_CARD_UNIT}\` is the name of a workspace's boot card, which every context loads — it is an \`always\` unit, and this one is \`${tier}\``);
1432
+ }
1433
+ if (rest[0] !== BOOT_CARD_LINE) {
1434
+ throw new CompileError(`${where}: a boot card opens with the line \`${BOOT_CARD_LINE}\`, which is how the boot skill knows it is loaded, and this one opens ${JSON.stringify(rest[0])}`);
1435
+ }
1436
+ } else if (rest[0] === BOOT_CARD_LINE) {
1437
+ 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
+ }
1439
+ const written = [["leads", LEADS_LINE], ["engine", ENGINE_LINE], ["gates", GATES_LINE]].find(([, line]) => rest.some((text) => line.test(text)));
1440
+ if (tier !== "always" && written) {
1441
+ const [which] = written;
1442
+ throw new CompileError(`${where}: a \`<!-- ${which}: … -->\` line is written out only in an always unit, and this one is \`${tier}\``);
1443
+ }
1444
+ return { name, tier, paths, description, body: `${rest.join("\n")}\n`, source, bytes: Buffer.byteLength(text, "utf8") };
1445
+ }
1446
+
1447
+ /** The declared `slots.context`, or null where none is declared or there is no manifest; one that will not parse throws. */
1448
+ export function guidanceDeclaration(workspaceRoot, workspaceDir = ".portulan") {
1449
+ const base = path.join(workspaceRoot, workspaceDir);
1450
+ const unreadable = unreadableManifest(workspaceRoot, workspaceDir);
1451
+ if (unreadable !== null) {
1452
+ throw new CompileError(
1453
+ `${unreadable.file} is not a manifest this compiler can read: ${unreadable.why}. Read as one declaring no ` +
1454
+ `guidance, it would have every rule and skill an earlier run compiled removed, so nothing was written or removed`,
1455
+ );
1456
+ }
1457
+ let manifest;
1458
+ try {
1459
+ manifest = JSON.parse(fs.readFileSync(path.join(base, "workspace.json"), "utf8"));
1460
+ } catch {
1461
+ return null;
1462
+ }
1463
+ const declared = manifest?.slots?.context;
1464
+ if (declared === undefined) return null;
1465
+ if (typeof declared !== "string" || !/^[^#?:/][^#?:]*\/$/.test(declared) || CONTROL.test(declared)) {
1466
+ throw new CompileError(`\`slots.context\` is ${JSON.stringify(declared)}, which is not a relative path to a directory ending in \`/\` — the schema's \`dirPath\``);
1467
+ }
1468
+ const dir = path.resolve(base, declared);
1469
+ if (dir === path.resolve(base) || !isInside(path.resolve(base), dir)) {
1470
+ throw new CompileError(`\`slots.context\` (${declared}) resolves to the workspace directory itself or outside it — guidance is read from a directory of its own inside it, never from elsewhere`);
1471
+ }
1472
+ const rel = path.relative(path.resolve(workspaceRoot), dir).split(path.sep).join("/");
1473
+ const top = path.relative(path.resolve(base), dir).split(path.sep)[0];
1474
+ if (rel.split("/")[0] === ".claude" || GENERATED_DIRS.includes(top)) {
1475
+ throw new CompileError(
1476
+ `\`slots.context\` (${declared}) lies in a directory \`compile\` writes into, \`.claude/\` or the workspace's \`compile/\` — ` +
1477
+ `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
+ );
1479
+ }
1480
+ return { dir, rel: `${rel}/`, packs: Array.isArray(manifest.packs) ? manifest.packs.filter((p) => typeof p === "string") : [] };
1481
+ }
1482
+
1483
+ /** Every unit, read and checked, or null where no `slots.context` is declared. */
1484
+ export function guidanceUnits(workspaceRoot, workspaceDir = ".portulan") {
1485
+ const declared = guidanceDeclaration(workspaceRoot, workspaceDir);
1486
+ if (declared === null) return null;
1487
+ let entries;
1488
+ try {
1489
+ entries = fs.readdirSync(declared.dir, { withFileTypes: true });
1490
+ } catch (cause) {
1491
+ throw new CompileError(`\`slots.context\` names ${declared.rel}, which could not be listed — ${cause.code ?? cause.message}`);
1492
+ }
1493
+ let realBase;
1494
+ try {
1495
+ realBase = fs.realpathSync(path.join(workspaceRoot, workspaceDir));
1496
+ } catch (cause) {
1497
+ throw new CompileError(`the workspace directory could not be resolved — ${cause.code ?? cause.message}`);
1498
+ }
1499
+ const written = [path.join(workspaceRoot, ".claude"), ...GENERATED_DIRS.map((g) => path.join(workspaceRoot, workspaceDir, g))].flatMap((at) => {
1500
+ try {
1501
+ return [fs.realpathSync(at)];
1502
+ } catch (cause) {
1503
+ if (cause.code === "ENOENT" || cause.code === "ENOTDIR") return [];
1504
+ throw new CompileError(`${at} could not be resolved — ${cause.code ?? cause.message}`);
1505
+ }
1506
+ });
1507
+ const units = [];
1508
+ const names = entries
1509
+ .filter((e) => e.name.endsWith(".md") && (e.isFile() || e.isSymbolicLink()))
1510
+ .map((e) => e.name)
1511
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1512
+ for (const file of names) {
1513
+ const full = path.join(declared.dir, file);
1514
+ const source = `${declared.rel}${file}`;
1515
+ let real;
1516
+ try {
1517
+ real = fs.realpathSync(full);
1518
+ } catch (cause) {
1519
+ throw new CompileError(`${source} could not be resolved — ${cause.code ?? cause.message}`);
1520
+ }
1521
+ if (!isInside(realBase, real)) throw new CompileError(`${source} is a link out of the workspace — a unit is read from inside it, never from elsewhere`);
1522
+ if (written.some((at) => isInside(at, real))) {
1523
+ throw new CompileError(`${source} is a link into a directory \`compile\` writes into — a unit there would be overwritten by what it compiles to`);
1524
+ }
1525
+ let text;
1526
+ try {
1527
+ text = fs.readFileSync(full, "utf8");
1528
+ } catch (cause) {
1529
+ throw new CompileError(`${source} could not be read — ${cause.code ?? cause.message}`);
1530
+ }
1531
+ const unit = parseUnit(path.basename(file, ".md"), text, source);
1532
+ if (unit.tier === "always") {
1533
+ unit.dir = path.dirname(full);
1534
+ unit.text = expandedBody(unit, unit.dir, path.resolve(workspaceRoot), declared.packs);
1535
+ unit.imports = checkedImports(unit.text, unit.dir, path.resolve(workspaceRoot), source);
1536
+ } else {
1537
+ for (const { target } of importSpans(unit.body)) {
1538
+ const bare = importPath(target);
1539
+ if (bare === null || bare.startsWith("~") || path.isAbsolute(bare)) continue;
1540
+ if (fs.statSync(path.resolve(path.dirname(full), bare), { throwIfNoEntry: false })?.isFile()) {
1541
+ throw new CompileError(
1542
+ `${source}: \`@${target}\` names a file, and only an always unit may import one — ${STRAY_IMPORT[unit.tier]}. ` +
1543
+ `Name the file to open instead, in a code span`,
1544
+ );
1545
+ }
1546
+ }
1547
+ }
1548
+ units.push(unit);
1549
+ }
1550
+ return { source: declared.rel, units, root: path.resolve(workspaceRoot) };
1551
+ }
1552
+
1553
+ function rebasedText(unit, root, to) {
1554
+ if (!unit.imports.length) return unit.text;
1555
+ const byLine = new Map();
1556
+ for (const { target, file } of unit.imports) {
1557
+ const spelled = path.relative(path.join(root, path.dirname(to)), file).split(path.sep).join("/").replaceAll(" ", "\\ ");
1558
+ const fragment = target.includes("#") ? target.slice(target.indexOf("#")) : "";
1559
+ byLine.set(`@${target}`, `@${spelled}${fragment}`);
1560
+ }
1561
+ return unit.text
1562
+ .split("\n")
1563
+ .map((line) => byLine.get(line.trim()) ?? line)
1564
+ .join("\n");
1565
+ }
1566
+
1567
+ /** Double-quoted YAML: JSON's escapes are a subset of YAML's. */
1568
+ const quoted = (s) => JSON.stringify(s);
1569
+
1570
+ const kilobytes = (bytes) => (bytes < 1024 ? "<1 KB" : `~${Math.round(bytes / 1024).toLocaleString("en-US")} KB`);
1571
+
1572
+ export function claudeCodeGuidance(guidance) {
1573
+ const files = [];
1574
+ const pointers = [];
1575
+ for (const unit of guidance.units) {
1576
+ if (unit.tier === "always") {
1577
+ const at = `${GUIDANCE_RULES_DIR}/${unit.name}.md`;
1578
+ files.push({ unit, path: at, text: unit.text === undefined ? unit.body : rebasedText(unit, guidance.root, at) });
1579
+ } else if (unit.tier === "on-path") {
1580
+ const header = ["---", "paths:", ...unit.paths.map((glob) => ` - ${quoted(glob)}`), "---", ""].join("\n");
1581
+ files.push({ unit, path: `${GUIDANCE_RULES_DIR}/${unit.name}.md`, text: `${header}\n${unit.body}` });
1582
+ } else if (unit.tier === "on-invoke") {
1583
+ const header = ["---", `name: ${unit.name}`, `description: ${quoted(unit.description)}`, "---", ""].join("\n");
1584
+ const mark = `${SKILL_MARK}${unit.source}${SKILL_MARK_TAIL}\n`;
1585
+ files.push({ unit, path: `${SKILLS_DIR}/${unit.name}/SKILL.md`, text: `${header}\n${mark}\n${unit.body}` });
1586
+ } else {
1587
+ pointers.push(unit);
1588
+ }
1589
+ }
1590
+ if (pointers.length) {
1591
+ files.push({
1592
+ unit: null,
1593
+ path: `${GUIDANCE_RULES_DIR}/${ON_READ_INDEX}`,
1594
+ text: pointers.map((u) => `- \`${u.source}\` (${kilobytes(u.bytes)}): ${u.description}\n`).join(""),
1595
+ });
1596
+ }
1597
+ return { backend: "claude-code", label: GUIDANCE_HOSTS["claude-code"].label, files };
1598
+ }
1599
+
1600
+ /** `dir` is where the units sit in the vendored tree, as typed from beside `AGENTS.md`. */
1601
+ export function agentsMdGuidance(guidance, dir) {
1602
+ const always = guidance.units.filter((u) => u.tier === "always");
1603
+ const pointed = guidance.units.filter((u) => u.tier !== "always");
1604
+ // AGENTS.md's hosts follow no import, so each becomes a pointer, as does each file it imports in turn.
1605
+ const inline = (u) => {
1606
+ if (u.text === undefined) return u.body;
1607
+ const vendored = (file) => `\`${path.posix.normalize(path.posix.join(dir, path.relative(u.dir, file).split(path.sep).join("/")))}\``;
1608
+ const byLine = new Map(
1609
+ u.imports.map(({ target, file, nested = [] }) => [
1610
+ `@${target}`,
1611
+ [
1612
+ `- ${vendored(file)}: read it in full — a host that follows imports loads it here.`,
1613
+ ...nested.map((n) => `- ${vendored(n.file)}: read it in full too — ${vendored(n.from)} imports it.`),
1614
+ ].join("\n"),
1615
+ ]),
1616
+ );
1617
+ return u.text
1618
+ .split("\n")
1619
+ .map((line) => byLine.get(line.trim()) ?? line)
1620
+ .join("\n");
1621
+ };
1622
+ const pointer = (u) => {
1623
+ const where = `\`${dir}${u.name}.md\``;
1624
+ if (u.tier === "on-path") return `- ${where}: when you work on ${u.paths.map((g) => `\`${g}\``).join(", ")}. ${u.description}`;
1625
+ return `- ${where}: ${u.description}`;
1626
+ };
1627
+ return { inline: always.map(inline), pointers: pointed.map(pointer) };
1628
+ }
1629
+
1630
+ /** Why `rel` cannot be written as a real `kind`, or null; a link stops it anywhere, even one inside the repository. */
1631
+ function landing(workspaceRoot, rel, kind = "file") {
1632
+ const parts = rel.split("/");
1633
+ let at = workspaceRoot;
1634
+ for (const [i, part] of parts.entries()) {
1635
+ at = path.join(at, part);
1636
+ let stat;
1637
+ try {
1638
+ stat = fs.lstatSync(at);
1639
+ } catch (cause) {
1640
+ if (cause.code === "ENOENT") return null;
1641
+ throw new CompileError(`${rel} could not be examined — ${cause.code ?? cause.message}`);
1642
+ }
1643
+ const last = i === parts.length - 1;
1644
+ const here = parts.slice(0, i + 1).join("/");
1645
+ if (stat.isSymbolicLink()) {
1646
+ return last ? "is a link, and writing it would change whatever the link points at" : `lies through a link, ${here}, and writing through it would change whatever the link points at`;
1647
+ }
1648
+ const want = last ? kind : "directory";
1649
+ if (want === "file" ? !stat.isFile() : !stat.isDirectory()) return last ? `is not a ${kind}` : `cannot be created, because ${here} is not a directory`;
1650
+ }
1651
+ return null;
1652
+ }
1653
+
1654
+ function listReal(workspaceRoot, rel) {
1655
+ const dir = path.join(workspaceRoot, ...rel.split("/"));
1656
+ if (landing(workspaceRoot, rel, "directory") !== null || !fs.existsSync(dir)) return [];
1657
+ try {
1658
+ return fs.readdirSync(dir, { withFileTypes: true });
1659
+ } catch (cause) {
1660
+ throw new CompileError(`${rel} could not be listed — ${cause.code ?? cause.message}`);
1661
+ }
1662
+ }
1663
+
1664
+ /** Judged before either half writes, so a refusal leaves no gate artifact written beside it. */
1665
+ function planGuidance(guidance, workspaceRoot) {
1666
+ const files = guidance ? claudeCodeGuidance(guidance).files : [];
1667
+ const rules = files.filter((f) => f.path.startsWith(`${GUIDANCE_RULES_DIR}/`));
1668
+ const owedFiles = rules.length
1669
+ ? [{ unit: null, marker: true, path: `${GUIDANCE_RULES_DIR}/${RULES_MARKER}`, text: markerText(rules.map((f) => path.posix.basename(f.path))) }, ...files]
1670
+ : files;
1671
+ const owed = new Set(owedFiles.map((f) => f.path));
1672
+
1673
+ for (const file of owedFiles) {
1674
+ const stop = landing(workspaceRoot, file.path);
1675
+ if (stop) throw new CompileError(`${file.path} ${stop} — this compiler writes only real files, and nothing through a link`);
1676
+ }
1677
+
1678
+ const rulesDir = path.join(workspaceRoot, ...GUIDANCE_RULES_DIR.split("/"));
1679
+ const entries = listReal(workspaceRoot, GUIDANCE_RULES_DIR);
1680
+ const markerEntry = entries.find((e) => e.name === RULES_MARKER);
1681
+ let listed = null;
1682
+ if (markerEntry) {
1683
+ let text = null;
1684
+ if (markerEntry.isFile()) {
1685
+ try {
1686
+ text = fs.readFileSync(path.join(rulesDir, RULES_MARKER), "utf8");
1687
+ } catch (cause) {
1688
+ throw new CompileError(`${GUIDANCE_RULES_DIR}/${RULES_MARKER} could not be read — ${cause.code ?? cause.message}`);
1689
+ }
1690
+ }
1691
+ listed = text === null ? undefined : markedRules(text) ?? undefined;
1692
+ }
1693
+ if (rules.length) {
1694
+ if (listed === undefined) {
1695
+ throw new CompileError(`${GUIDANCE_RULES_DIR}/${RULES_MARKER} is not a marker this compiler wrote, so nothing shows which rules beside it are its own. Move the directory's files out, then compile again`);
1696
+ }
1697
+ if (listed === null) {
1698
+ const markdown = entries.filter((e) => e.name.endsWith(".md")).map((e) => e.name).sort();
1699
+ if (markdown.length) {
1700
+ const them = markdown.length === 1 ? "it" : "them";
1701
+ throw new CompileError(
1702
+ `${GUIDANCE_RULES_DIR}/ holds ${markdown.join(", ")} and no \`${RULES_MARKER}\` marker, so nothing shows this compiler wrote ${them}, and it will not overwrite or remove ${them}. Move ${them} out of the directory, then compile again`,
1703
+ );
1704
+ }
1705
+ } else {
1706
+ for (const file of rules) {
1707
+ const name = path.posix.basename(file.path);
1708
+ if (!entries.some((e) => e.name === name) || listed.has(name)) continue;
1709
+ // A run stopped before its marker leaves exactly its unit's output; anything else was written by hand.
1710
+ let current;
1711
+ try {
1712
+ current = fs.readFileSync(path.join(rulesDir, name), "utf8");
1713
+ } catch (cause) {
1714
+ throw new CompileError(`${file.path} could not be read — ${cause.code ?? cause.message}`);
1715
+ }
1716
+ if (current !== file.text) {
1717
+ 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
+ }
1719
+ }
1720
+ }
1721
+ }
1722
+ for (const file of files) {
1723
+ if (!file.path.startsWith(`${SKILLS_DIR}/`)) continue;
1724
+ let current;
1725
+ try {
1726
+ current = fs.readFileSync(path.join(workspaceRoot, ...file.path.split("/")), "utf8");
1727
+ } catch {
1728
+ continue;
1729
+ }
1730
+ const source = skillSource(current);
1731
+ if (source === null) {
1732
+ throw new CompileError(`${file.path} exists and was not compiled here — ${file.unit.source} would replace a skill written by hand. Rename the unit or the skill`);
1733
+ }
1734
+ if (source !== file.unit.source) {
1735
+ throw new CompileError(
1736
+ `${file.path} was compiled from ${source}, and ${file.unit.source} would replace it: two units compile to one skill. Rename one of them, or remove the skill by hand if its unit is gone, then compile again`,
1737
+ );
1738
+ }
1739
+ }
1740
+
1741
+ const stray = [];
1742
+ if (listed instanceof Set || rules.length) {
1743
+ for (const entry of entries) {
1744
+ const rel = `${GUIDANCE_RULES_DIR}/${entry.name}`;
1745
+ if (owed.has(rel)) continue;
1746
+ const ours = listed instanceof Set && entry.isFile() && (listed.has(entry.name) || entry.name === RULES_MARKER);
1747
+ stray.push({ path: rel, removable: ours });
1748
+ }
1749
+ }
1750
+ const skillsDir = path.join(workspaceRoot, ...SKILLS_DIR.split("/"));
1751
+ for (const entry of listReal(workspaceRoot, SKILLS_DIR)) {
1752
+ if (!entry.isDirectory()) continue;
1753
+ const rel = `${SKILLS_DIR}/${entry.name}/SKILL.md`;
1754
+ const file = path.join(skillsDir, entry.name, "SKILL.md");
1755
+ let text;
1756
+ try {
1757
+ if (!fs.lstatSync(file).isFile()) continue;
1758
+ text = fs.readFileSync(file, "utf8");
1759
+ } catch {
1760
+ continue;
1761
+ }
1762
+ if (skillSource(text) !== null && !owed.has(rel)) stray.push({ path: rel, removable: true });
1763
+ }
1764
+ // What the marker may list mid-write: the rules it lists now that are still there and owed; null with no marker.
1765
+ const present = new Set(entries.filter((e) => e.isFile()).map((e) => e.name));
1766
+ const kept = listed instanceof Set || rules.length
1767
+ ? rules.map((f) => path.posix.basename(f.path)).filter((name) => listed instanceof Set && listed.has(name) && present.has(name))
1768
+ : null;
1769
+ return { owedFiles, stray, kept };
1770
+ }
1771
+
1772
+ function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
1773
+ const { owedFiles, stray, kept } = plan;
1774
+ if (guidance === null && stray.length === 0) return 0;
1775
+
1776
+ if (guidance) {
1777
+ const files = owedFiles.filter((f) => !f.marker);
1778
+ const counts = LOAD_TIERS.map((tier) => `${guidance.units.filter((u) => u.tier === tier).length} ${tier}`).join(", ");
1779
+ say(`guidance: ${guidance.units.length} unit(s) in ${guidance.source} — ${counts}`);
1780
+ for (const [id, host] of Object.entries(GUIDANCE_HOSTS)) {
1781
+ const cannot = LOAD_TIERS.filter((tier) => host[tier] === null);
1782
+ const degraded = guidance.units.filter((u) => cannot.includes(u.tier));
1783
+ if (id === "claude-code") {
1784
+ say(` ${host.label}: expresses every tier — ${files.length} file(s)`);
1785
+ for (const unit of guidance.units) {
1786
+ const target = unit.tier === "on-read" ? `${GUIDANCE_RULES_DIR}/${ON_READ_INDEX}, a pointer` : files.find((f) => f.unit === unit).path;
1787
+ say(` unit ${unit.name.padEnd(30)} ${unit.tier.padEnd(9)} → ${target}`);
1788
+ }
1789
+ } else {
1790
+ say(
1791
+ ` ${host.label}: expresses ${LOAD_TIERS.filter((t) => host[t] !== null).join(" and ")}; ${cannot.join(" and ")} degrade to an on-read pointer` +
1792
+ ` — written by \`portulan vendor --host\`, not here`,
1793
+ );
1794
+ for (const unit of degraded) say(` degrade ${unit.name.padEnd(30)} ${unit.tier.padEnd(9)} → a pointer in AGENTS.md`);
1795
+ }
1796
+ }
1797
+ }
1798
+
1799
+ if (check) {
1800
+ let drifted = 0;
1801
+ for (const file of owedFiles) {
1802
+ const target = path.join(workspaceRoot, ...file.path.split("/"));
1803
+ let current = null;
1804
+ try {
1805
+ current = fs.readFileSync(target, "utf8");
1806
+ } catch {
1807
+ say(file.marker
1808
+ ? `RED — ${target} does not exist; it is the marker that lists the rules this compiler wrote. Recompile to write it.`
1809
+ : `RED — ${target} does not exist; ${file.unit ? file.unit.source : "an on-read unit"} compiles to it`);
1810
+ drifted += 1;
1811
+ continue;
1812
+ }
1813
+ if (current !== file.text) {
1814
+ say(file.marker
1815
+ ? `RED — ${target} does not list the rules the units compile to now. Recompile to rewrite it.`
1816
+ : file.unit?.leadSources?.length
1817
+ ? `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
+ : `RED — ${target} has drifted from ${file.unit ? file.unit.source : `the on-read units in ${guidance.source}`}. Edit the unit, then recompile.`);
1819
+ drifted += 1;
1820
+ }
1821
+ }
1822
+ for (const s of stray) {
1823
+ const where = path.join(workspaceRoot, ...s.path.split("/"));
1824
+ say(s.removable
1825
+ ? `RED — ${where} is where this compiler writes guidance, and no unit compiles to it. Recompile to remove it.`
1826
+ : `RED — ${where} is in the directory this compiler writes its rules to, and is not a file it wrote, so a recompile leaves it. Move it out by hand.`);
1827
+ drifted += 1;
1828
+ }
1829
+ return drifted;
1830
+ }
1831
+
1832
+ // Listed only once written, unlisted before removal: an interrupted run never lists a file it did not write.
1833
+ const markerPath = path.join(workspaceRoot, ...GUIDANCE_RULES_DIR.split("/"), RULES_MARKER);
1834
+ if (kept !== null) {
1835
+ fs.mkdirSync(path.dirname(markerPath), { recursive: true });
1836
+ fs.writeFileSync(markerPath, markerText(kept));
1837
+ }
1838
+ for (const s of stray) {
1839
+ const target = path.join(workspaceRoot, ...s.path.split("/"));
1840
+ if (target === markerPath) continue;
1841
+ if (!s.removable) {
1842
+ say(`left ${target} — it is not a file this compiler wrote, so it is not this compiler's to remove`);
1843
+ continue;
1844
+ }
1845
+ fs.rmSync(target);
1846
+ say(`removed ${target} — no unit compiles to it`);
1847
+ if (s.path.startsWith(`${SKILLS_DIR}/`)) {
1848
+ try {
1849
+ fs.rmdirSync(path.dirname(target));
1850
+ } catch {
1851
+ // Not empty: whatever else sits beside it was not written here.
1852
+ }
1853
+ }
1854
+ }
1855
+ for (const file of owedFiles) {
1856
+ if (file.marker) continue;
1857
+ const target = path.join(workspaceRoot, ...file.path.split("/"));
1858
+ fs.mkdirSync(path.dirname(target), { recursive: true });
1859
+ fs.writeFileSync(target, file.text);
1860
+ say(`wrote ${target}`);
1861
+ }
1862
+ const marker = owedFiles.find((f) => f.marker);
1863
+ if (marker) {
1864
+ fs.writeFileSync(markerPath, marker.text);
1865
+ say(`wrote ${markerPath}`);
1866
+ } else if (kept !== null) {
1867
+ fs.rmSync(markerPath);
1868
+ say(`removed ${markerPath} — no unit compiles to a rule`);
1869
+ }
1870
+ if (landing(workspaceRoot, GUIDANCE_RULES_DIR, "directory") === null) {
1871
+ try {
1872
+ fs.rmdirSync(path.join(workspaceRoot, ...GUIDANCE_RULES_DIR.split("/")));
1873
+ } catch {
1874
+ // Absent, or holding files: either way there is nothing to tidy.
1875
+ }
1876
+ }
1877
+ return 0;
1878
+ }
1879
+
1880
+ /** 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 = () => {} } = {}) {
1882
+ const { workspaceRoot, workspaceDir } = resolveWorkspace(named);
1883
+ const guidance = guidanceUnits(workspaceRoot, workspaceDir);
1884
+ const plan = planGuidance(guidance, workspaceRoot);
1885
+ return { declared: guidance !== null, drifted: emitGuidance(guidance, plan, { workspaceRoot, check, say }) };
1886
+ }
1887
+
1888
+ /** What compiling the guidance would change, writing nothing: `next` null removes a file, and `left` is what a compile leaves. */
1889
+ export function guidanceEdits(named) {
1890
+ const { workspaceRoot, workspaceDir } = resolveWorkspace(named);
1891
+ const guidance = guidanceUnits(workspaceRoot, workspaceDir);
1892
+ const { owedFiles, stray, kept } = planGuidance(guidance, workspaceRoot);
1893
+ const markerRel = `${GUIDANCE_RULES_DIR}/${RULES_MARKER}`;
1894
+ const current = (rel) => {
1895
+ try {
1896
+ return fs.readFileSync(path.join(workspaceRoot, ...rel.split("/")), "utf8");
1897
+ } catch (cause) {
1898
+ if (cause.code === "ENOENT") return null;
1899
+ throw new CompileError(`${rel} could not be read — ${cause.code ?? cause.message}`);
1900
+ }
1901
+ };
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 });
1905
+ const marker = owedFiles.find((f) => f.marker);
1906
+ if (marker && current(markerRel) !== marker.text) edits.push({ file: markerRel, next: marker.text });
1907
+ else if (!marker && kept !== null && current(markerRel) !== null) edits.push({ file: markerRel, next: null });
1908
+ return { root: path.resolve(workspaceRoot), edits, left: stray.filter((s) => !s.removable).map((s) => s.path) };
1909
+ }
1910
+
1911
+ // ---------------------------------------------------------------- the manifest's declarations
1912
+
1913
+ /** `{ file, why }` for a manifest that is there and will not parse as an object; null when it reads or is absent. */
1914
+ export function unreadableManifest(workspaceRoot, workspaceDir = ".portulan") {
1915
+ const file = path.join(workspaceRoot, workspaceDir, "workspace.json");
1916
+ let raw;
1917
+ try {
1918
+ raw = fs.readFileSync(file, "utf8");
1919
+ } catch (cause) {
1920
+ if (cause.code === "ENOENT") return null;
1921
+ return { file, why: `it could not be read — ${cause.code ?? cause.message}` };
1922
+ }
1923
+ let manifest;
1924
+ try {
1925
+ manifest = JSON.parse(raw);
1926
+ } catch (cause) {
1927
+ return { file, why: `it is not valid JSON — ${cause.message}` };
1928
+ }
1929
+ if (manifest === null || typeof manifest !== "object" || Array.isArray(manifest)) return { file, why: "it is not a JSON object" };
1930
+ return null;
1931
+ }
1932
+
1933
+ /** `{ file, declared, reason }`, `reason` one of `declared`, `no-key`, `no-manifest` or `refused`. */
1934
+ export function policyDeclaration(workspaceRoot, workspaceDir = ".portulan") {
1935
+ const base = path.join(workspaceRoot, workspaceDir);
1936
+ const manifest = path.join(base, "workspace.json");
1937
+ const fallback = (reason) => ({ file: path.join(workspaceRoot, workspaceDir, "gates.json"), declared: false, reason });
1938
+ let declared;
1939
+ try {
1940
+ declared = JSON.parse(fs.readFileSync(manifest, "utf8")).gates;
1941
+ } catch {
1942
+ // An unreadable manifest reaches here only from the hook's reader, which must not stop.
1943
+ return fallback("no-manifest");
1944
+ }
1945
+ if (declared === undefined) return fallback("no-key");
1946
+ // Contained after resolution: a `../` chain passes any pattern, and the hook reads this on every tool call.
1947
+ if (typeof declared === "string" && declared.trim() && FILE_PATH.test(declared)) {
1948
+ const resolved = path.resolve(base, declared);
1949
+ if (resolved !== base && isInside(base, resolved)) return { file: resolved, declared: true, reason: "declared" };
1950
+ }
1951
+ return fallback("refused");
1952
+ }
1953
+
1954
+ export function policyPath(workspaceRoot, workspaceDir = ".portulan") {
1955
+ return policyDeclaration(workspaceRoot, workspaceDir).file;
1956
+ }
1957
+
1958
+ /** The prompt-cache lifetimes Claude Code takes. */
1959
+ export const CACHE_LIFETIMES = ["5m", "1h"];
1960
+
1961
+ const SESSION_SWITCHES = {
1962
+ git_instructions: (v) => typeof v === "boolean",
1963
+ cache_lifetime: (v) => CACHE_LIFETIMES.includes(v),
1964
+ };
1965
+ const HEADLESS_SWITCHES = { ...SESSION_SWITCHES, exclude_dynamic_sections: (v) => typeof v === "boolean" };
1966
+
1967
+ /** The manifest's `sessions`, or null where it declares none or cannot be read; a shape the schema refuses throws. */
1968
+ export function sessionsDeclaration(workspaceRoot, workspaceDir = ".portulan") {
1969
+ const manifest = path.join(workspaceRoot, workspaceDir, "workspace.json");
1970
+ let declared;
1971
+ try {
1972
+ declared = JSON.parse(fs.readFileSync(manifest, "utf8")).sessions;
1973
+ } catch {
1974
+ return null;
1975
+ }
1976
+ if (declared === undefined) return null;
1977
+ const where = path.relative(workspaceRoot, manifest).split(path.sep).join("/");
1978
+ const refuse = (what) =>
1979
+ new CompileError(`\`sessions\` in ${where} ${what}; ../spec/slots.md gives its shape, and \`doctor\` names every finding`);
1980
+ const plain = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
1981
+ const check = (value, allowed, at) => {
1982
+ if (!plain(value)) throw refuse(`${at}is not an object`);
1983
+ for (const [key, v] of Object.entries(value)) {
1984
+ if (key === "headless" && at === "") continue;
1985
+ if (!Object.hasOwn(allowed, key)) throw refuse(`${at}names \`${key}\`, which is no session switch`);
1986
+ if (!allowed[key](v)) throw refuse(`${at}sets \`${key}\` to ${JSON.stringify(v)}, which it does not take`);
1987
+ }
1988
+ };
1989
+ check(declared, SESSION_SWITCHES, "");
1990
+ if (declared.headless !== undefined) check(declared.headless, HEADLESS_SWITCHES, "at `headless` ");
1991
+ return { manifest: where, ...declared };
1992
+ }
1993
+
1994
+ /** The manifest's `spend` as the ledger reads it, or null where it declares none or cannot be read; a refused one throws. */
1995
+ export function spendDeclaration(workspaceRoot, workspaceDir = ".portulan") {
1996
+ const manifest = path.join(workspaceRoot, workspaceDir, "workspace.json");
1997
+ let parsed;
1998
+ try {
1999
+ parsed = JSON.parse(fs.readFileSync(manifest, "utf8"));
2000
+ } catch {
2001
+ return null;
2002
+ }
2003
+ const declared = parsed?.spend;
2004
+ if (declared === undefined) return null;
2005
+ const where = path.relative(workspaceRoot, manifest).split(path.sep).join("/");
2006
+ let spend;
2007
+ try {
2008
+ spend = { manifest: where, ...readSpend(declared, where) };
2009
+ } catch (error) {
2010
+ if (error instanceof LedgerError) throw new CompileError(error.message);
2011
+ throw error;
2012
+ }
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
+ );
2019
+ }
2020
+ return spend;
2021
+ }
2022
+
2023
+ /** `guidanceOnly` is true where guidance still compiles, and `"leftover"` where only an earlier run's is removed. */
2024
+ function undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOptions, reason = "no-key", guidanceOnly = false) {
2025
+ const manifest = path.join(workspaceRoot, workspaceDir, "workspace.json");
2026
+ const conventional = reason === "refused" && fs.existsSync(policyFile);
2027
+ const opening =
2028
+ reason === "refused"
2029
+ ? `\`workspace.json\` names a gate policy this compiler will not read — its top-level ` +
2030
+ `\`gates\` key is not a relative path to a file inside the workspace — and ` +
2031
+ (conventional
2032
+ ? `the \`gates.json\` at ${policyFile} is not the policy it names, so it is not compiled in its place.`
2033
+ : `there is no \`gates.json\` at ${policyFile} either.`)
2034
+ : reason === "no-manifest"
2035
+ ? `this workspace declares no gate policy — there is no readable \`workspace.json\` at ` +
2036
+ `${manifest}, and there is no \`gates.json\` at ${policyFile}.`
2037
+ : `this workspace declares no gate policy — \`workspace.json\` has no top-level \`gates\` key, ` +
2038
+ `and there is no \`gates.json\` at ${policyFile}.`;
2039
+ const outcome =
2040
+ guidanceOnly === "leftover"
2041
+ ? "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."
2042
+ : guidanceOnly
2043
+ ? "No enforcement is compiled; the workspace's guidance still is."
2044
+ : "Nothing was compiled and nothing was written.";
2045
+ const lines = [`${opening} ${outcome}`];
2046
+ let composed = null;
2047
+ try {
2048
+ composed = packContributions(workspaceRoot, workspaceDir, packOptions);
2049
+ } catch {
2050
+ // A pack's refusal would replace this answer; with a policy declared, the next run raises it.
2051
+ }
2052
+ const contributions = composed?.contributions ?? [];
2053
+ const rules = contributions.reduce((n, c) => n + (c.fragments?.length ?? 0), 0);
2054
+ if (rules > 0) {
2055
+ const packs = contributions
2056
+ .filter((c) => (c.fragments?.length ?? 0) > 0)
2057
+ .map((c) => `\`${c.pack}\``)
2058
+ .join(", ");
2059
+ lines.push(
2060
+ `${rules} pack-contributed gate rule(s) from ${packs} are therefore NOT compiled: ` +
2061
+ `a fragment tightens a policy, and ${conventional ? "this run reads none" : "there is none here"} to tighten.`,
2062
+ );
2063
+ }
2064
+ lines.push(
2065
+ reason === "refused"
2066
+ ? "Give `gates` a relative path to a file inside the workspace directory, or remove the key " +
2067
+ "and let `gates.json` be found by convention."
2068
+ : "Declare one with `portulan new gate-policy`, or leave it undeclared deliberately — " +
2069
+ "a workspace with no gate policy is a legitimate shape, and this is a state rather than a fault.",
2070
+ );
2071
+ return lines.join("\n ");
2072
+ }
2073
+
2074
+ // ---------------------------------------------------------------- pack-contributed gate fragments
2075
+
2076
+ const TIER_ORDER = ["auto", "propose", "gated", "prohibited"];
2077
+
2078
+ export const tierRank = (tier) => TIER_ORDER.indexOf(tier);
2079
+
2080
+ /** The first of `roots` holding the pack; `dir` and `root` are null, with a `why`, where none does or the name is malformed. */
2081
+ export function resolvePack(rawName, roots = []) {
2082
+ const name = String(rawName);
2083
+ const parts = name.split("/");
2084
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
2085
+ return { name, category: null, pack: null, dir: null, manifest: null, root: null, why: "not in `category/name` form" };
2086
+ }
2087
+ const [category, pack] = parts;
2088
+ // Slugs, so no `..` segment resolves a pack outside the roots.
2089
+ if (!SLUG.test(category) || !SLUG.test(pack)) {
2090
+ return { name, category: null, pack: null, dir: null, manifest: null, root: null, why: "`category/name` must both be slugs" };
2091
+ }
2092
+ for (const root of roots) {
2093
+ const dir = path.join(root, category, pack);
2094
+ const manifest = path.join(dir, "pack.json");
2095
+ if (fs.existsSync(manifest)) return { name, category, pack, dir, manifest, root, why: null };
2096
+ }
2097
+ return { name, category, pack, dir: null, manifest: null, root: null, why: "no pack.json under any resolution root" };
2098
+ }
2099
+
2100
+ export function packRoots(workspaceDir, workspace) {
2101
+ const tree = workspace?.tree;
2102
+ if (typeof tree === "string" && tree.trim()) return [path.resolve(workspaceDir, tree, "packs")];
2103
+ return [];
2104
+ }
2105
+
2106
+ /** Named roots replace the one derived from `tree` rather than precede it; `{}` where none is named. */
2107
+ export function namedRootsOption(workspaceRoot, namedRoots) {
2108
+ if (!namedRoots?.length) return {};
2109
+ return { packRoots: [...namedRoots] };
2110
+ }
2111
+
2112
+ export function rootPlan(workspaceDir, manifest, { named = [], namedGiven = null, discovery = null, forced = false } = {}) {
2113
+ return resolutionRoots({ named, namedGiven, derived: packRoots(workspaceDir, manifest), discovery, forced });
2114
+ }
2115
+
2116
+ /** `discovered` where discovery found the root; else `tree` or `outside-tree` by where it sits, never how it was named. */
2117
+ export function recordedOrigin(root, plan, workspaceRoot) {
2118
+ const same = (a, b) => path.resolve(a) === path.resolve(b);
2119
+ const tagged = (plan?.origins ?? []).find((o) => same(o.root, root));
2120
+ if (tagged?.origin === "discovered") return "discovered";
2121
+ // Real paths on both sides: compared lexically, an alias of the tree would record `outside-tree`.
2122
+ const real = (dir) => {
2123
+ try {
2124
+ return fs.realpathSync(path.resolve(dir));
2125
+ } catch {
2126
+ return path.resolve(dir);
2127
+ }
2128
+ };
2129
+ return isInside(real(workspaceRoot), real(root)) ? "tree" : "outside-tree";
2130
+ }
2131
+
2132
+ /** Compared parsed, key-sorted and whole: `composeFragments` takes every field of a fragment. */
2217
2133
  export function packDifferences(mine, other) {
2218
2134
  const canonical = (v) =>
2219
2135
  Array.isArray(v)
@@ -2230,13 +2146,7 @@ export function packDifferences(mine, other) {
2230
2146
  return differs;
2231
2147
  }
2232
2148
 
2233
- /**
2234
- * The non-discovered copy of a pack that a discovered root answered for — or `null` when there is no
2235
- * shadow. `originOf` maps a root to its tag; pass `plan.origins`' lookup.
2236
- *
2237
- * Only a **discovered** answer can be shadowed in the sense that matters: a named root replaces the
2238
- * derived one, so nothing is behind it, and a derived answer is the tree's own copy already.
2239
- */
2149
+ /** The copy behind a discovered answer, or null: only a discovered one can shadow, as a named root replaces the derived. */
2240
2150
  export function shadowedCopy(name, answeringOrigin, roots, originOf) {
2241
2151
  if (answeringOrigin !== "discovered") return null;
2242
2152
  const behind = resolvePack(name, roots.filter((r) => originOf(r) !== "discovered"));
@@ -2249,21 +2159,12 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
2249
2159
  try {
2250
2160
  manifest = JSON.parse(fs.readFileSync(path.join(base, "workspace.json"), "utf8"));
2251
2161
  } catch {
2252
- // No manifest, or unreadable. `doctor` is the tool that judges a manifest; this one only needs
2253
- // to know what is composed, and with nothing to go on the answer is nothing.
2254
2162
  return { contributions: [], unresolved: [] };
2255
2163
  }
2256
2164
  const declared = manifest?.packs;
2257
2165
  if (!Array.isArray(declared) || declared.length === 0) return { contributions: [], unresolved: [] };
2258
2166
 
2259
- // `options.packRoots` keeps its pre-discovery meaning — the FINAL root set, replacing the derived
2260
- // one; a caller passing `[]` means the empty set and still gets it. `options.named` is the
2261
- // command-line shape. **Both now go through the shared rule**, `namedGiven` carrying the
2262
- // final-set meaning, because the literal plan this replaces was the sixth site of the defect this
2263
- // change is about: given `packRoots` AND `forced`, it silently ignored the discovery request, and
2264
- // it returned a plan with no `origins` and no `refusal` — breaking one file over the uniform shape
2265
- // `resolutionRoots` exists to guarantee. Found by the pre-commit checkpoint's own sibling sweep,
2266
- // after the same class had already been found once in `index` inside this change.
2167
+ // `options.packRoots` is the final root set, `[]` included; `options.named` is the command line's.
2267
2168
  const givenPackRoots = options.packRoots !== undefined && options.packRoots !== null;
2268
2169
  const plan = rootPlan(base, manifest, {
2269
2170
  named: givenPackRoots ? [...options.packRoots] : (options.named ?? []),
@@ -2283,37 +2184,6 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
2283
2184
  continue;
2284
2185
  }
2285
2186
  const packManifest = readJson(found.manifest, `the pack manifest for \`${found.name}\``);
2286
- // **A shadowed pack is a could-not-run, not a resolution** (#316). Two roots answered for one
2287
- // declared name and the invocation does not say which the caller meant, so this compiler has
2288
- // no honest way to pick — and unlike `doctor`, which reports and never grades because an
2289
- // install state is a fact about a machine rather than a verdict about the repository, this one
2290
- // does not report: it EMITS. Picking silently wrote the discovered copy's fragments into the
2291
- // artifact while `verify/compile.sh` read the tree's, which on this project's own host meant a
2292
- // `git commit --no-verify` matcher that the tree had deliberately removed as false coverage.
2293
- //
2294
- // **Refused at RESOLUTION, so `--check` and the write path inherit one rule.** Refusing only
2295
- // on write would make one tool answer one ambiguity two ways: the check would go on adopting
2296
- // the discovered world and exit 1 — asserting that the *repository* drifted when it had not.
2297
- // An exit code is the machine-read API, and a false 1 is a false verdict about the tree.
2298
- //
2299
- // **Every unasked shadow refuses, including one whose manifests agree**, which reads as
2300
- // over-strict until you follow the bytes: `recordedOrigin` tags the answering root into
2301
- // `$portulan.packs[].origin`, so a discovered answer emits `discovered` where the rail's
2302
- // artifact says `tree`. Agreement in the manifests is therefore not agreement in the artifact,
2303
- // and a carve-out for it would ship a compile that still reds the recipe it was meant to
2304
- // reconcile with. The message distinguishes the two cases; the refusal does not.
2305
- //
2306
- // **Only where the caller did not choose.** `--pack-root auto` is discovery ELECTED, and
2307
- // electing it is the choice this refusal exists to ask for. Both spellings are offered in the
2308
- // message, because naming only the tree would make the refusal decide the question it claims
2309
- // to be handing back.
2310
- //
2311
- // **The `named` half of this guard is defence in depth, and that was measured rather than
2312
- // assumed.** Forcing the condition to `true` — refuse unconditionally — leaves the named-root
2313
- // case still passing: a named root REPLACES the derived one, so the plan carries no discovered
2314
- // root, `shadowedCopy` finds nothing behind, and there is no shadow to refuse. The protection
2315
- // is structural. The clause stays because it states the intent at the site where a future
2316
- // change to `resolutionRoots` could quietly make it load-bearing.
2317
2187
  if (!(options.forced ?? false) && plan.source !== "named") {
2318
2188
  const behind = shadowedCopy(found.name, recordedOrigin(found.root, plan, workspaceRoot), roots, (r) =>
2319
2189
  (plan.origins ?? []).find((o) => path.resolve(o.root) === path.resolve(r))?.origin,
@@ -2331,24 +2201,6 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
2331
2201
  );
2332
2202
  }
2333
2203
  const differs = packDifferences(packManifest, other);
2334
- // **Both ROOTS by path — the roots, not the pack directories.** `resolvePack` returns
2335
- // both, and a first cut printed `dir` (`<root>/<category>/<pack>`) while calling it a
2336
- // root: a diagnostic that mislabels the thing it names, in a message whose own claim is
2337
- // to name both roots. The root is also the more useful of the two, because it is what a
2338
- // reader types back into `--pack-root`. A refusal naming only one hands back a choice
2339
- // while withholding half of what it is between. Printing the absolute path is safe in a
2340
- // way it is not in `$portulan.packs`: this is stderr, read once by a human, never a
2341
- // tracked artifact, so #264's origins-never-paths rule (which exists to stop a
2342
- // machine-dependent path being committed) does not reach it. `doctor` already prints it
2343
- // in its resolves-from note, so the two tools now name the same two directories.
2344
- //
2345
- // **"Discovered on this host", never "outside this repository".** Discovery reads a
2346
- // record under `CLAUDE_CONFIG_DIR`, which a hermetic run can point INSIDE the tree — so
2347
- // a discovered root is not necessarily an external one, and the message must not assert
2348
- // a location it has not tested. `recordedOrigin` keeps `discovered` and `outside-tree`
2349
- // as separate answers for exactly this reason: where a root came from and where it sits
2350
- // are different questions, and the path is printed right there for a reader who wants
2351
- // the second one. Caught by review after the first cut conflated them.
2352
2204
  throw new CompileError(
2353
2205
  `\`${found.name}\` is SHADOWED — it resolved under ${found.root}, a root discovered on this ` +
2354
2206
  `host, while the root ${path.relative(workspaceRoot, behind.root)} also carries it. ` +
@@ -2361,10 +2213,6 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
2361
2213
  }
2362
2214
  }
2363
2215
  const fragments = packManifest?.contributes?.gates;
2364
- // Checked here rather than left to `composeFragments` iterating it: a manifest is a file a
2365
- // human edits, `doctor` may not have run (the two tools have no ordering between them), and a
2366
- // non-array here would surface as a bare TypeError naming neither the pack nor the field.
2367
- // `?? []` covers absent, not malformed — those are different failures and only one is benign.
2368
2216
  if (fragments !== undefined && !Array.isArray(fragments)) {
2369
2217
  throw new CompileError(
2370
2218
  `the pack manifest for \`${found.name}\` declares \`contributes.gates\` as ` +
@@ -2372,39 +2220,26 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
2372
2220
  `Refusing to compose it — run \`doctor\` to validate the pack against the Pack Definition.`,
2373
2221
  );
2374
2222
  }
2223
+ const personas = packManifest?.contributes?.personas;
2224
+ if (personas !== undefined && !Array.isArray(personas)) {
2225
+ throw new CompileError(
2226
+ `the pack manifest for \`${found.name}\` declares \`contributes.personas\` as ${typeof personas} rather than an array. ` +
2227
+ `Refusing to compose it — run \`doctor\` to validate the pack against the Pack Definition.`,
2228
+ );
2229
+ }
2375
2230
  contributions.push({
2376
2231
  pack: found.name,
2377
2232
  dir: found.dir,
2378
2233
  fragments: fragments ?? [],
2379
- // **Provenance, recorded per pack**, so the artifact this compiles into can say what
2380
- // compiled it (#264). `version` comes from `portulan.version` — the pack manifest's own
2381
- // location for it, not a top-level `version` — and its ABSENCE is recorded as such rather
2382
- // than as a blank, because "this pack declares no version" and "I did not look" are
2383
- // different facts and only one of them is the pack's.
2234
+ personas: personas ?? [],
2384
2235
  origin: recordedOrigin(found.root, plan, workspaceRoot),
2385
2236
  version: typeof packManifest?.portulan?.version === "string" ? packManifest.portulan.version : null,
2386
2237
  });
2387
2238
  }
2388
- // `plan` travels with the result because every consumer prints it: which source won, and the path
2389
- // it won with, is what tells a feed resolution from a local one.
2390
2239
  return { contributions, unresolved, plan };
2391
2240
  }
2392
2241
 
2393
- /**
2394
- * Compose pack-contributed fragments onto a workspace policy. **Tighten-only, and it throws.**
2395
- *
2396
- * A demotion is refused loudly rather than dropped quietly, because the two are not the same event: a
2397
- * backend refusing a rule it cannot express is a coverage gap, while a pack moving `gated` to
2398
- * `propose` is an attempt to disarm a gate, and a build that continues past it has published an
2399
- * artifact weaker than the policy it claims to compile. Failing closed is right *here* and wrong in
2400
- * ./gate.mjs for a reason worth keeping straight: this runs at build time against a file you
2401
- * can edit, while that runs on every tool call and a refusal there makes the session undriveable.
2402
- *
2403
- * `auto` is barred by the Pack Definition's tier enum, so a schema-valid pack cannot express a
2404
- * demotion to unattended at all. It is checked again here anyway — this compiler does not depend on
2405
- * the schema having been applied, and an unranked tier reaching the comparison would otherwise sort
2406
- * below everything and read as a tightening of nothing.
2407
- */
2242
+ /** Tighten-only: a fragment that would demote a rule, or change what it matches, throws. */
2408
2243
  export function composeFragments(policy, contributions) {
2409
2244
  const rules = [...(policy?.rules ?? [])];
2410
2245
  const at = new Map(rules.map((rule, i) => [rule?.id, i]));
@@ -2414,22 +2249,7 @@ export function composeFragments(policy, contributions) {
2414
2249
  for (const { pack, fragments } of contributions) {
2415
2250
  for (const fragment of fragments ?? []) {
2416
2251
  const id = fragment?.id;
2417
- // **The id is validated HERE even though `parse` validates it too, and the duplication is
2418
- // the point** (#111). Composition deliberately runs BEFORE `parse`, so that a fragment is
2419
- // graded by exactly the code that grades a hand-written rule — but that ordering means a
2420
- // malformed id survives composition and surfaces from `parse` with **no pack context at
2421
- // all**: `rule id undefined is not a slug`, which sends an adopter to audit their own
2422
- // `gates.json`, the one file that is not at fault. Every other refusal in this loop names
2423
- // the contributing pack, because naming the dependency is what the merge step is for.
2424
- //
2425
- // It fails closed either way — `parse` refuses the composed policy and nothing invalid
2426
- // compiles — so this is a diagnostic repair, not a correctness one, and it is written down
2427
- // as such.
2428
- //
2429
- // **It also closes a path the issue did not name.** `at` is keyed by `rule?.id`, so with no
2430
- // check here a SECOND id-less fragment finds `at.has(undefined)` true and is composed onto
2431
- // the first one — two unrelated fragments from two packs merged because they were equally
2432
- // malformed, reported as a tightening of a rule that does not exist.
2252
+ // Checked before `parse` so the refusal names the pack, and so two id-less fragments cannot merge on `undefined`.
2433
2253
  if (typeof id !== "string" || !SLUG.test(id)) {
2434
2254
  throw new CompileError(
2435
2255
  `pack \`${pack}\` contributes a fragment whose id is ${JSON.stringify(id)}, which is not a slug. ` +
@@ -2459,14 +2279,6 @@ export function composeFragments(policy, contributions) {
2459
2279
  }
2460
2280
  const base = rules[at.get(id)];
2461
2281
  const baseRank = tierRank(base?.tier);
2462
- // The base's tier must BE a tier before any comparison means anything. Without this,
2463
- // `tierRank` returns -1 for a malformed base and every fragment outranks it, so the
2464
- // refusal below never fires and the fragment REPLACES the malformed rule — a policy
2465
- // `parse` refuses on its own compiles green once a pack happens to name the same id.
2466
- // Measured: a base rule at tier `bogus` was refused by `parse` alone, and accepted after
2467
- // composition, reported as `tightens bogus → gated`. That is a fail-open in gate
2468
- // machinery healed by a dependency, and the printed provenance claims a tightening from
2469
- // a tier that is not one. Found by review, in the suppressed channel.
2470
2282
  if (baseRank < 0) {
2471
2283
  throw new CompileError(
2472
2284
  `pack \`${pack}\` contributes a fragment for \`${id}\`, but the policy's own rule \`${id}\` ` +
@@ -2475,14 +2287,6 @@ export function composeFragments(policy, contributions) {
2475
2287
  `policy first. A pack must never be able to make an invalid policy compile.`,
2476
2288
  );
2477
2289
  }
2478
- // A tier is not the whole rule. Raising the tier while REPLACING the action removes the
2479
- // matcher and passes every rank comparison — measured on this repository's own policy: a
2480
- // fragment `{id: force-push-without-a-lease, tier: prohibited, action: {none: …}}` was
2481
- // reported as `tightens gated → prohibited` and the emitted `Bash(git push --force:*)`
2482
- // gate DISAPPEARED, leaving the workspace strictly less cautious about the exact action
2483
- // the rule exists to gate. Ids are greppable by design and ship in core, so knowing one
2484
- // is not a barrier. A pack may raise a rule's tier; it may not redefine what the rule
2485
- // matches. Found by the pre-commit supervisor, which is the checkpoint's whole argument.
2486
2290
  if (rank <= baseRank) {
2487
2291
  throw new CompileError(
2488
2292
  `pack \`${pack}\` would move rule \`${id}\` from \`${base?.tier}\` to ` +
@@ -2531,18 +2335,297 @@ function readJson(file, what) {
2531
2335
  }
2532
2336
  }
2533
2337
 
2534
- /** The emitted artifact, as text. One writer, so `--check` compares what `--write` would produce. */
2535
2338
  export function render(settings) {
2536
2339
  return `${JSON.stringify(settings, null, 2)}\n`;
2537
2340
  }
2538
2341
 
2539
- /**
2540
- * Print the per-host backend matrix.
2541
- *
2542
- * The criterion asks for the floor backend to be "positioned in the matrix as the floor backend:
2543
- * what every host falls back to, and all that a host with no hook system has" — so the matrix says
2544
- * that in words rather than leaving a reader to infer it from a column heading.
2545
- */
2342
+ // ---------------------------------------------------------------- personas: what a role declares about its context
2343
+
2344
+ /** The fields a persona may declare about the context it runs in, in the order a binding carries them. */
2345
+ export const PERSONA_FIELDS = ["model", "effort", "omitClaudeMd", "cacheLifetime"];
2346
+
2347
+ export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
2348
+
2349
+ const MODEL_NAME = /^[A-Za-z0-9][A-Za-z0-9._:@/[\]-]{0,199}$/;
2350
+
2351
+ const readBoolean = (raw) => raw === "true" ? true : raw === "false" ? false : undefined;
2352
+
2353
+ const PERSONA_VALUES = {
2354
+ model: { read: (raw) => (MODEL_NAME.test(raw) ? raw : undefined), takes: "one model name or alias, written as a single token" },
2355
+ effort: { read: (raw) => (EFFORT_LEVELS.includes(raw) ? raw : undefined), takes: `one of ${EFFORT_LEVELS.join(", ")}` },
2356
+ omitClaudeMd: { read: readBoolean, takes: "`true` or `false`, unquoted" },
2357
+ cacheLifetime: { read: (raw) => (CACHE_LIFETIMES.includes(raw) ? raw : undefined), takes: `one of ${CACHE_LIFETIMES.join(", ")}` },
2358
+ };
2359
+
2360
+ /** Claude Code's frontmatter key for each field, a dotted one nested under its first word. */
2361
+ const CLAUDE_CODE_KEYS = { model: "model", effort: "effort", omitClaudeMd: "omitClaudeMd", cacheLifetime: "experimental.cacheTtl" };
2362
+
2363
+ /** `null` is a field the host cannot express; `why` is the sentence the degradation report gives for it. */
2364
+ export const PERSONA_HOSTS = {
2365
+ "claude-code": {
2366
+ label: "Claude Code",
2367
+ ...Object.fromEntries(PERSONA_FIELDS.map((field) => [field, `\`${CLAUDE_CODE_KEYS[field]}\``])),
2368
+ },
2369
+ "agents-md": {
2370
+ label: "AGENTS.md, vendored",
2371
+ model: null,
2372
+ effort: null,
2373
+ omitClaudeMd: null,
2374
+ cacheLifetime: null,
2375
+ why: "AGENTS.md defines no agent to carry it, so the role runs with the session's own",
2376
+ },
2377
+ };
2378
+
2379
+ /** Where a Claude Code binding sits in a tree, as `AGENT_DIR` in ./plugin-lint.mjs, which a test holds it to. */
2380
+ export const BINDING_DIR = "agents";
2381
+
2382
+ const PLUGIN_MANIFEST = ".claude-plugin/plugin.json";
2383
+
2384
+ const unquoted = (raw) => (raw.length >= 2 && `"'`.includes(raw[0]) && raw.at(-1) === raw[0] ? raw.slice(1, -1) : raw);
2385
+
2386
+ /** What a persona's top-level frontmatter lines declare: `{ name, declared, problems }`, a malformed value a problem and never a default. */
2387
+ export function personaDeclarations(text) {
2388
+ const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
2389
+ const close = lines[0]?.trim() === "---" ? lines.indexOf("---", 1) : -1;
2390
+ const declared = {};
2391
+ const problems = [];
2392
+ const seen = new Set();
2393
+ let name = null;
2394
+ for (const line of close === -1 ? [] : lines.slice(1, close)) {
2395
+ const match = /^([A-Za-z_][\w-]*)\s*:(.*)$/.exec(line);
2396
+ if (match?.[1] === "name") name = unquoted(match[2].trim()).trim() || null;
2397
+ if (!match || !Object.hasOwn(PERSONA_VALUES, match[1])) continue;
2398
+ const [, key, rest] = match;
2399
+ if (seen.has(key)) {
2400
+ problems.push(`declares \`${key}\` twice`);
2401
+ continue;
2402
+ }
2403
+ seen.add(key);
2404
+ const raw = key === "omitClaudeMd" ? rest.trim() : unquoted(rest.trim());
2405
+ const value = PERSONA_VALUES[key].read(raw);
2406
+ if (value === undefined) problems.push(`sets \`${key}\` to ${JSON.stringify(rest.trim())}, and \`${key}\` takes ${PERSONA_VALUES[key].takes}`);
2407
+ else declared[key] = value;
2408
+ }
2409
+ return { name, declared, problems };
2410
+ }
2411
+
2412
+ const PERSONA_MARK = "# compiled by `portulan compile` from ";
2413
+ const PERSONA_MARK_TAIL = "; edit that file, then recompile";
2414
+ const BINDING_KEY = /^([A-Za-z_][\w-]*)\s*:(.*)$/;
2415
+ const topKey = (field) => CLAUDE_CODE_KEYS[field].split(".")[0];
2416
+
2417
+ function bindingLines(declared) {
2418
+ const lines = [];
2419
+ for (const field of PERSONA_FIELDS) {
2420
+ if (declared[field] === undefined) continue;
2421
+ const value = field === "model" ? quoted(declared[field]) : String(declared[field]);
2422
+ const [top, nested] = CLAUDE_CODE_KEYS[field].split(".");
2423
+ lines.push(...(nested ? [`${top}:`, ` ${nested}: ${value}`] : [`${top}: ${value}`]));
2424
+ }
2425
+ return lines;
2426
+ }
2427
+
2428
+ /** Read recognized compiled fields strictly; the rewritten lines must still match byte for byte. */
2429
+ function blockFields(lines, where) {
2430
+ const read = {};
2431
+ let under = null;
2432
+ for (const line of lines) {
2433
+ const nested = /^\s+([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line);
2434
+ const match = nested ? null : BINDING_KEY.exec(line);
2435
+ const key = nested ? `${under}.${nested[1]}` : match?.[1];
2436
+ const raw = (nested?.[2] ?? match?.[2] ?? "").trim();
2437
+ if (!nested) under = key;
2438
+ const field = PERSONA_FIELDS.find((f) => CLAUDE_CODE_KEYS[f] === key);
2439
+ if (!field) continue;
2440
+ let input = raw;
2441
+ if (field === "model") {
2442
+ try { input = JSON.parse(raw); } catch { /* The byte comparison rejects noncanonical quoting. */ }
2443
+ }
2444
+ const value = typeof input === "string" ? PERSONA_VALUES[field].read(input) : undefined;
2445
+ if (value === undefined) throw new CompileError(`${where}: the compiled block holds \`${line}\`, which this compiler did not write — ${PERSONA_VALUES[field].takes}`);
2446
+ read[field] = value;
2447
+ }
2448
+ return read;
2449
+ }
2450
+
2451
+ /** A Claude Code binding's text with its compiled block rewritten in place from `declared`; every other byte is the binding's own. */
2452
+ export function boundAgent(text, declared, source, where) {
2453
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
2454
+ if (eol === "\r\n" && text.replaceAll("\r\n", "").includes("\n")) {
2455
+ throw new CompileError(`${where} mixes CRLF and LF line endings, so which lines are its own cannot be told apart. Give it one ending, then compile again`);
2456
+ }
2457
+ const lines = text.split(eol);
2458
+ const close = lines[0] === "---" ? lines.indexOf("---", 1) : -1;
2459
+ if (close === -1) {
2460
+ throw new CompileError(`${where} has no frontmatter block to compile ${source}'s fields into — a host reads an agent's \`name\`, \`description\` and \`tools\` from one`);
2461
+ }
2462
+ const owned = new Set(PERSONA_FIELDS.map(topKey));
2463
+ const front = [];
2464
+ let at = null;
2465
+ for (let i = 1; i < close; i += 1) {
2466
+ if (!lines[i].startsWith(PERSONA_MARK)) {
2467
+ front.push(lines[i]);
2468
+ continue;
2469
+ }
2470
+ if (at !== null) throw new CompileError(`${where} carries two compiled blocks. Delete one, then compile again`);
2471
+ at = front.length;
2472
+ const block = [];
2473
+ while (i + 1 < close && (/^\s/.test(lines[i + 1]) || owned.has(BINDING_KEY.exec(lines[i + 1])?.[1]))) block.push(lines[(i += 1)]);
2474
+ const written = bindingLines(blockFields(block, where));
2475
+ const stray = block.find((line) => !written.includes(line)) ?? block.find((line, n) => line !== written[n]);
2476
+ if (stray !== undefined) {
2477
+ throw new CompileError(
2478
+ `${where}: the block compiled from ${source} holds \`${stray}\`, which this compiler did not write. Move the line above the ` +
2479
+ "mark or delete it, then compile again: removing it unasked would lose a line somebody wrote",
2480
+ );
2481
+ }
2482
+ }
2483
+ const writes = new Set(PERSONA_FIELDS.filter((f) => declared[f] !== undefined).map(topKey));
2484
+ for (const line of front) {
2485
+ const key = BINDING_KEY.exec(line)?.[1];
2486
+ if (writes.has(key)) {
2487
+ throw new CompileError(
2488
+ `${where} sets \`${key}\` by hand, and ${source} declares it. Remove the line from the binding: the persona's value is the one compiled, and two would leave the host to pick`,
2489
+ );
2490
+ }
2491
+ if (writes.size && owned.has(key)) {
2492
+ throw new CompileError(
2493
+ `${where} sets \`${key}\` by hand, which ${source} does not declare. Declare it in the persona or remove the line: in a binding ` +
2494
+ "it compiles into, `compile` owns every key a persona may declare",
2495
+ );
2496
+ }
2497
+ }
2498
+ const block = writes.size ? [`${PERSONA_MARK}${source}${PERSONA_MARK_TAIL}`, ...bindingLines(declared)] : [];
2499
+ const place = at ?? front.length;
2500
+ return [lines[0], ...front.slice(0, place), ...block, ...front.slice(place), ...lines.slice(close)].join(eol);
2501
+ }
2502
+
2503
+ const listed = (names) => (names.length < 2 ? names.join("") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`);
2504
+
2505
+ /** Each composed persona against its host binding: the bindings to rewrite, the lines to say, and what each host cannot carry. */
2506
+ export function planPersonas(contributions, workspaceRoot, workspaceDir = ".portulan") {
2507
+ const base = path.join(workspaceRoot, workspaceDir);
2508
+ let tree = null;
2509
+ try {
2510
+ tree = JSON.parse(fs.readFileSync(path.join(base, "workspace.json"), "utf8"))?.tree;
2511
+ } catch {
2512
+ // `run` refuses an unreadable manifest before this is reached; an absent one declares no tree.
2513
+ }
2514
+ const treeRoot = typeof tree === "string" && tree.trim() ? path.resolve(base, tree) : null;
2515
+ const files = [];
2516
+ const lines = [];
2517
+ const degraded = [];
2518
+ const owners = new Map();
2519
+ let compared = 0;
2520
+ for (const contribution of contributions) {
2521
+ for (const rel of contribution.personas ?? []) {
2522
+ const where = `\`${contribution.pack}\`'s persona \`${rel}\``;
2523
+ const file = path.resolve(contribution.dir, rel);
2524
+ let text;
2525
+ try {
2526
+ if (!isInside(fs.realpathSync(contribution.dir), fs.realpathSync(file))) throw new CompileError(`${where} resolves outside its pack`);
2527
+ text = fs.readFileSync(file, "utf8");
2528
+ } catch (cause) {
2529
+ if (cause instanceof CompileError) throw cause;
2530
+ throw new CompileError(`${where} could not be read — ${cause.code ?? cause.message}, so what it declares is unknown. Run \`doctor\` to validate the pack`);
2531
+ }
2532
+ const { name: declaredName, declared, problems } = personaDeclarations(text);
2533
+ if (problems.length) {
2534
+ throw new CompileError(`${where} ${problems.join("; it ")}. core/personas/README.md gives the fields a persona may declare`);
2535
+ }
2536
+ const fields = PERSONA_FIELDS.filter((f) => declared[f] !== undefined);
2537
+ const name = declaredName ?? path.basename(rel, ".md");
2538
+ if (fields.length && !SLUG.test(name)) {
2539
+ throw new CompileError(`${where} is named \`${name}\`, which is not a slug, so no binding file can be named for it`);
2540
+ }
2541
+ const source = `${contribution.pack}'s ${rel}`;
2542
+ for (const field of fields) {
2543
+ for (const [id, host] of Object.entries(PERSONA_HOSTS)) {
2544
+ if (host[field] === null) degraded.push({ persona: name, field, host: id, line: `degraded ${name.padEnd(22)} ${field.padEnd(14)} ${id} (${host.label}): ${host.why}` });
2545
+ }
2546
+ }
2547
+ if (!SLUG.test(name)) continue;
2548
+ const bindingRel = `${BINDING_DIR}/${name}.md`;
2549
+ if (owners.has(bindingRel)) {
2550
+ const sources = [owners.get(bindingRel), source].sort();
2551
+ throw new CompileError(`${bindingRel} has multiple persona owners: ${sources.join(" and ")}. Each binding must have one owner`);
2552
+ }
2553
+ owners.set(bindingRel, source);
2554
+ if (!treeRoot) {
2555
+ if (fields.length) lines.push(`persona ${name.padEnd(22)} UNBOUND — declares ${listed(fields)}, and this workspace declares no \`tree\` to hold its binding`);
2556
+ continue;
2557
+ }
2558
+ const binding = path.join(treeRoot, BINDING_DIR, `${name}.md`);
2559
+ const shown = path.relative(workspaceRoot, binding).split(path.sep).join("/");
2560
+ let stat = null;
2561
+ try {
2562
+ stat = fs.lstatSync(binding);
2563
+ } catch (cause) {
2564
+ if (cause.code !== "ENOENT") throw new CompileError(`${shown} could not be examined — ${cause.code ?? cause.message}`);
2565
+ }
2566
+ if (stat === null) {
2567
+ if (fields.length) lines.push(`persona ${name.padEnd(22)} UNBOUND — declares ${listed(fields)}, and no host binding at \`${bindingRel}\` carries them`);
2568
+ continue;
2569
+ }
2570
+ if (!stat.isFile()) {
2571
+ throw new CompileError(`${shown} binds ${where} and is not a regular file, so nothing is compiled through it`);
2572
+ }
2573
+ try {
2574
+ if (!isInside(fs.realpathSync(treeRoot), fs.realpathSync(binding))) {
2575
+ throw new CompileError(`${shown} resolves outside the declared tree, so nothing is compiled through it`);
2576
+ }
2577
+ } catch (cause) {
2578
+ if (cause instanceof CompileError) throw cause;
2579
+ throw new CompileError(`${shown} could not be resolved against the declared tree — ${cause.code ?? cause.message}`);
2580
+ }
2581
+ const current = fs.readFileSync(binding, "utf8");
2582
+ const next = boundAgent(current, declared, source, shown);
2583
+ if (fields.length) lines.push(`persona ${name.padEnd(22)} ${listed(fields)} → ${shown} (${PERSONA_HOSTS["claude-code"].label})`);
2584
+ if (fields.length && !fs.existsSync(path.join(treeRoot, ...PLUGIN_MANIFEST.split("/")))) {
2585
+ degraded.push({
2586
+ persona: name,
2587
+ field: null,
2588
+ host: "claude-code",
2589
+ line:
2590
+ `degraded ${name.padEnd(22)} ${listed(fields)} claude-code (Claude Code): ${shown} loads only from a plugin root, and this tree ` +
2591
+ `has no ${PLUGIN_MANIFEST}; compile writes no .claude/agents/, so nothing here loads the compiled fields`,
2592
+ });
2593
+ }
2594
+ if (fields.length || next !== current) compared += 1;
2595
+ if (next !== current) files.push({ path: shown, file: binding, text: next, source });
2596
+ }
2597
+ }
2598
+ return { files, lines, degraded, compared };
2599
+ }
2600
+
2601
+ const unresolvedLine = (u) => `pack ${u.name.padEnd(30)} UNRESOLVED — ${u.why}; it contributes nothing`;
2602
+
2603
+ function printPersonaReport(say, personas) {
2604
+ if (!personas.lines.length && !personas.degraded.length) return;
2605
+ say();
2606
+ for (const line of [...personas.lines, ...personas.degraded.map((d) => d.line)]) say(` ${line}`);
2607
+ }
2608
+
2609
+ function emitPersonas(personas, { check, say }) {
2610
+ if (!personas) return 0;
2611
+ for (const line of personas.lines) say(line);
2612
+ for (const d of personas.degraded) say(d.line);
2613
+ let drifted = 0;
2614
+ for (const f of personas.files) {
2615
+ if (check) {
2616
+ say(`RED — ${f.path} has drifted from ${f.source}. Recompile.`);
2617
+ drifted += 1;
2618
+ } else {
2619
+ fs.writeFileSync(f.file, f.text);
2620
+ say(`wrote ${f.file}`);
2621
+ }
2622
+ }
2623
+ if (personas.lines.length || personas.degraded.length || personas.files.length) say();
2624
+ return drifted;
2625
+ }
2626
+
2627
+ // ---------------------------------------------------------------- the command line
2628
+
2546
2629
  function printMatrix(say, parsed, columns, { source }) {
2547
2630
  say(`gate policy: ${source} — ${parsed.rules.length} rule(s), ${columns.length} backend(s)`);
2548
2631
  say();
@@ -2564,14 +2647,6 @@ function printMatrix(say, parsed, columns, { source }) {
2564
2647
  }
2565
2648
  say();
2566
2649
 
2567
- // The honest degradation signal, and the reason the matrix is worth printing at all: not which
2568
- // backend is fuller, but what this policy declares that NOTHING enforces.
2569
- //
2570
- // Split by tier, because the two halves mean opposite things and printing them together would
2571
- // pad the alarming number with a harmless one. An `auto` rule compiled by no backend is the
2572
- // system working — unattended is what that tier means. A `gated` or `prohibited` rule compiled
2573
- // by no backend is a gate that exists only as a sentence, which is the thing this product is
2574
- // against. Reporting eight where three are real is how a report gets skimmed.
2575
2650
  const uncovered = rows.filter((r) => columns.every((c) => r.backends[c.backend].verdict === "refused"));
2576
2651
  const gaps = uncovered.filter((r) => r.tier === "gated" || r.tier === "prohibited");
2577
2652
  const unattended = uncovered.filter((r) => r.tier === "auto");
@@ -2587,30 +2662,26 @@ function printMatrix(say, parsed, columns, { source }) {
2587
2662
  }
2588
2663
  }
2589
2664
 
2590
- /**
2591
- * What `--workspace <dir>` names, and where the workspace inside it lives.
2592
- *
2593
- * Two shapes, told apart by **`tree`** — the one thing proposal 0017 says is keyed to location, and the
2594
- * same key `cli/vendor.mjs` reads to decide a residence:
2595
- *
2596
- * - `<dir>` is a **repository root** — no manifest here, so the workspace is at `.portulan` and this is
2597
- * byte-identical to what this tool did before the distinction existed.
2598
- * - `<dir>` is a **workspace directory** — it holds `workspace.json`. A manifest declaring `tree`
2599
- * resolves back to the repository it governs; one declaring none is feed-side, and *is* its own root,
2600
- * because an installed plugin's directory is the workspace root.
2601
- *
2602
- * Nothing here validates the manifest. `doctor` judges manifests and these two tools have no ordering
2603
- * between them — this one only needs to know where the policy is.
2604
- *
2605
- * **`workspaceRoot` is returned as given on the repository-root branch and resolved on the other two,
2606
- * and that asymmetry is deliberate rather than an oversight.** The repository-root branch is the path
2607
- * every existing caller takes, including `.portulan/verify/compile.sh`'s `--workspace .`; returning the
2608
- * argument verbatim is what keeps this change byte-identical for them, output included — `compile` joins
2609
- * this value into the paths it PRINTS, so resolving it here would turn `.claude/settings.json` into an
2610
- * absolute path in every run, for no defect. The other two branches derive a path rather than echoing
2611
- * one, and a derived path has to be absolute to mean anything. Raised as an inconsistency by Copilot
2612
- * (round 10 on #164) and declined on that ground; the contract is stated here rather than changed.
2613
- */
2665
+ function printGuidanceMatrix(say, guidance) {
2666
+ const hosts = Object.entries(GUIDANCE_HOSTS);
2667
+ say(`guidance: ${guidance.source} — ${guidance.units.length} unit(s), ${hosts.length} host(s)`);
2668
+ say();
2669
+ for (const [id, host] of hosts) {
2670
+ const cannot = LOAD_TIERS.filter((tier) => host[tier] === null);
2671
+ say(` ${id.padEnd(16)} ${cannot.length ? `expresses ${LOAD_TIERS.filter((t) => host[t] !== null).join(" and ")}; ${cannot.join(" and ")} degrade to an on-read pointer` : "expresses every tier"}`);
2672
+ }
2673
+ say();
2674
+ const width = Math.max(4, ...guidance.units.map((u) => u.name.length));
2675
+ say(` ${"unit".padEnd(width)} ${"tier".padEnd(9)} ${hosts.map(([id]) => id.padEnd(16)).join(" ")}`);
2676
+ for (const unit of guidance.units) {
2677
+ const cells = hosts.map(([, host]) => (host[unit.tier] === null ? "pointer" : "expressed").padEnd(16));
2678
+ say(` ${unit.name.padEnd(width)} ${unit.tier.padEnd(9)} ${cells.join(" ")}`);
2679
+ }
2680
+ say();
2681
+ say(" A unit a host cannot express in its tier is carried as a one-line pointer to its file: late, never lost.");
2682
+ }
2683
+
2684
+ /** A directory with `workspace.json` is a workspace directory, its own root without a `tree`; any other, a repository root. */
2614
2685
  export function resolveWorkspace(named) {
2615
2686
  const dir = path.resolve(named);
2616
2687
  const manifestPath = path.join(dir, "workspace.json");
@@ -2618,12 +2689,7 @@ export function resolveWorkspace(named) {
2618
2689
  try {
2619
2690
  raw = fs.readFileSync(manifestPath, "utf8");
2620
2691
  } catch (cause) {
2621
- // **Only `ENOENT` means "this is not a workspace directory".** Anything else — `EACCES` above
2622
- // all — means the question could not be answered, and falling back to `.portulan` would answer
2623
- // it *no* and then fail somewhere else with a confusing secondary error about a policy file.
2624
- // The first cut of this function caught everything, which is the fail-open — "nothing looked"
2625
- // reported as "nothing wrong" — committed in a change whose own header states the rule three times.
2626
- // Copilot, round 1 on #164.
2692
+ // As given, not resolved: `compile` prints paths built on it.
2627
2693
  if (cause.code === "ENOENT") return { workspaceRoot: named, workspaceDir: ".portulan" };
2628
2694
  throw new CompileError(
2629
2695
  `${manifestPath} could not be read — ${cause.code ?? cause.message}. Only a MISSING manifest means ` +
@@ -2642,8 +2708,7 @@ export function resolveWorkspace(named) {
2642
2708
  if (typeof manifest?.tree === "string" && manifest.tree.trim()) {
2643
2709
  const root = path.resolve(dir, manifest.tree);
2644
2710
  const inside = path.relative(root, dir);
2645
- // A `tree` that does not contain its own workspace is a manifest `doctor` refuses; here it just
2646
- // means the derivation cannot be trusted, so the safe answer is the one that changes nothing.
2711
+ // A `tree` that does not contain its workspace cannot be trusted, so the answer is the one that changes nothing.
2647
2712
  if (inside && !inside.startsWith("..") && !path.isAbsolute(inside)) {
2648
2713
  return { workspaceRoot: root, workspaceDir: inside.split(path.sep).join("/") };
2649
2714
  }
@@ -2652,19 +2717,16 @@ export function resolveWorkspace(named) {
2652
2717
  return { workspaceRoot: dir, workspaceDir: "." };
2653
2718
  }
2654
2719
 
2655
- /**
2656
- * The help screen — see `./doctor.mjs`'s for the contract and why these three gained one late.
2657
- * `../.portulan/dod.md` condition 4 binds it: every flag below exists in the parser above.
2658
- */
2659
2720
  function usage() {
2660
2721
  return [
2661
2722
  "portulan compile — compile a workspace's gate policy into host enforcement",
2662
2723
  "",
2663
2724
  " portulan compile [--check] [--matrix] [--workspace <dir>] [--pack-root <dir>|auto]...",
2664
2725
  "",
2665
- " (no flag) compile the policy and write each backend's artifact",
2666
- " --check write nothing; exit 1 if an artifact is out of date against the policy",
2667
- " --matrix print every rule against every backend, and the gates neither compiles",
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",
2728
+ " --matrix print every rule against every backend, the gates neither compiles, and every",
2729
+ " guidance unit against every host",
2668
2730
  " --workspace the workspace directory to compile; defaults to `.portulan`",
2669
2731
  " --pack-root where declared packs are resolved from; `auto` discovers the host's plugin cache.",
2670
2732
  " A named root REPLACES every other source. A directory actually named `auto` is `./auto`",
@@ -2680,9 +2742,7 @@ export function run(argv, options = {}) {
2680
2742
  const say = (line = "") => {
2681
2743
  if (!options.quiet) process.stdout.write(`${line}\n`);
2682
2744
  };
2683
- // **Before every other argument decision**, so asking for help cannot be outranked by a
2684
- // complaint about the rest of the command line. `./portulan.mjs` states the contract: an
2685
- // explicit `--help` exits 0, because asking for help is a request and it succeeded.
2745
+ // First, so `--help` is answered whatever else the command line holds.
2686
2746
  if (argv.includes("--help") || argv.includes("-h")) {
2687
2747
  say(usage());
2688
2748
  return 0;
@@ -2691,24 +2751,6 @@ export function run(argv, options = {}) {
2691
2751
  let named = process.cwd();
2692
2752
  let check = false;
2693
2753
  let showMatrix = false;
2694
- // Named pack roots, which REPLACE the root derived from `tree` rather than being searched ahead
2695
- // of it — see `namedRootsOption` for why that distinction cost a checkpoint finding.
2696
- // `packContributions` has taken `options.packRoots` since session 0 — shaped so that an adopter
2697
- // resolving from an installed feed travels the same code path as a workspace whose packs ship
2698
- // beside it — and nothing ever set it, so the parameter was reachable only from a test.
2699
- // Discovery of a host's plugin cache landed at milestone 7 for BOTH halves (#123): ./discover.mjs
2700
- // dereferences a POINTER's `governed_by`, and `--pack-root auto` resolves a pack root from the same
2701
- // record.
2702
- //
2703
- // **Discovery IS the default as of 2026-08-13**, and this comment said the opposite — *"what is
2704
- // deliberately NOT taken is a DEFAULT … defaulting it would change what every existing run
2705
- // resolves against"*. It would, and the maintainer ruled the change: row 7's clause makes
2706
- // `--pack-root` *optional where discovery finds a root*, and it was not. Both of the old
2707
- // sentence's grounds survive where they were actually load-bearing — a **named** root still
2708
- // REPLACES the derived one (#117, untouched, the arm above), and a required check's verdict still
2709
- // cannot move with the machine, because `../.portulan/verify/compile.sh` **names its root** and
2710
- // `./pinned-roots.live.test.mjs` reds if it stops. What changed is which carrier holds that
2711
- // second guarantee: the pin, not the absence of a default.
2712
2754
  const namedRoots = [];
2713
2755
  let forced = false;
2714
2756
  for (let i = 0; i < argv.length; i += 1) {
@@ -2721,30 +2763,18 @@ export function run(argv, options = {}) {
2721
2763
  } else if (argv[i] === "--pack-root") {
2722
2764
  const root = argv[i + 1];
2723
2765
  i += 1;
2724
- // A SINGLE leading `-` is refused too, not just `--`: `--pack-root -h` would otherwise
2725
- // consume the flag as a path and fail later as an unreadable one. `doctor` already read
2726
- // it this way, so this aligns the three rather than inventing a rule. A directory whose
2727
- // name really begins with `-` is `./-name`, the same escape `auto` takes.
2766
+ // A value starting with `-` is a flag, not a path; a directory so named is `./-name`.
2728
2767
  if (root === undefined || root.startsWith("-"))
2729
2768
  throw new CompileError(
2730
2769
  "--pack-root needs a directory, or `auto` to discover one from the host plugin cache. " +
2731
2770
  "A directory actually named `auto` is `./auto`",
2732
2771
  );
2733
- // The keyword, matched on the RAW argument before `path.resolve`, so a directory
2734
- // genuinely named `auto` is still reachable as `./auto`. See ./discover.mjs.
2772
+ // Matched raw, so `./auto` still names a directory.
2735
2773
  if (root === AUTO) {
2736
2774
  forced = true;
2737
2775
  continue;
2738
2776
  }
2739
- // A root that is not there is a fact about the filesystem, not a pack that failed to
2740
- // resolve — and reporting it as the latter sends an author to the one file that is not
2741
- // at fault. Same distinction as ../.portulan/memory/verify-preconditions-fail-closed.md.
2742
- //
2743
- // A FILE is the sharper case here than in the other two tools, and the reason is what this
2744
- // one emits: a file-valued root made resolution fail and produced a **misleading green
2745
- // compile** that had simply ignored the intended root. A green is what a session acts on,
2746
- // so it is worse than the exit 2 the argument deserves. Third carrier of one rule, found by
2747
- // Copilot on #117, round 7 — the sibling class the 2026-07-27 ruling names.
2777
+ // A missing or file-valued root would compile green with every pack unresolved.
2748
2778
  let rootStat = null;
2749
2779
  try {
2750
2780
  rootStat = fs.statSync(root);
@@ -2762,103 +2792,107 @@ export function run(argv, options = {}) {
2762
2792
  } else throw new CompileError(`unknown argument ${JSON.stringify(argv[i])}`);
2763
2793
  }
2764
2794
 
2765
- // Refused HERE — the last statement of the argument parse, before a workspace is resolved or a
2766
- // policy read. It sat below both until Copilot's round 2, where an unrelated workspace or
2767
- // policy error masked the refusal and this tool disagreed with the four beside it about *when*
2768
- // the answer is given. The other tools' tests pin exactly that property; this one's did not,
2769
- // which is why the placement could drift here and nowhere else.
2795
+ // Before the workspace is resolved, so no workspace or policy error masks this refusal.
2770
2796
  const bothAsked = namedWithAuto(namedRoots, forced);
2771
2797
  if (bothAsked) throw new CompileError(bothAsked);
2772
2798
 
2773
- // The workspace may be named as a repository root or as the workspace directory itself, and the
2774
- // second is how a feed-side workspace is reachable at all — see `resolveWorkspace`.
2775
2799
  const { workspaceRoot, workspaceDir } = resolveWorkspace(named);
2776
- const { file: policyFile, declared: policyDeclared, reason: policyReason } = policyDeclaration(workspaceRoot, workspaceDir);
2777
- // **Declared-and-missing and never-declared are different answers.** Only the first is a
2778
- // failure to read something this workspace claimed to have; the second is a shape `policyPath`
2779
- // documents as legitimate, and reporting it as `ENOENT` sent readers hunting for a deleted
2780
- // file. Checked here rather than inside the reader because only the caller knows to name what
2781
- // the absence costs — see `undeclaredPolicyMessage`. The fallback arm is itself FOUR states,
2782
- // and `policyReason` is what lets the message name the one it is in rather than assert the
2783
- // commonest: this comment read "two different answers" while the code below had four.
2784
- if (!policyDeclared && !fs.existsSync(policyFile)) {
2800
+ // First: every reader below takes a manifest that will not parse for one declaring nothing.
2801
+ const unreadable = unreadableManifest(workspaceRoot, workspaceDir);
2802
+ if (unreadable !== null) {
2785
2803
  throw new CompileError(
2786
- undeclaredPolicyMessage(
2787
- policyFile,
2788
- workspaceRoot,
2789
- workspaceDir,
2790
- { named: namedRoots, discovery: () => discoverPackRoots(), forced },
2791
- policyReason,
2792
- ),
2804
+ `${unreadable.file} is not a manifest this compiler can read: ${unreadable.why}. Read as one declaring ` +
2805
+ `nothing, it would remove every rule and skill an earlier run compiled from its guidance and compile a ` +
2806
+ `\`gates.json\` found by convention in its place, so nothing was compiled, written or removed. Fix the ` +
2807
+ `manifest, then compile again`,
2793
2808
  );
2794
2809
  }
2810
+ const guidance = guidanceUnits(workspaceRoot, workspaceDir);
2811
+ const guidancePlan = showMatrix ? null : planGuidance(guidance, workspaceRoot);
2812
+ const sessions = sessionsDeclaration(workspaceRoot, workspaceDir);
2813
+ const spend = spendDeclaration(workspaceRoot, workspaceDir);
2814
+ const { file: policyFile, declared: policyDeclared, reason: policyReason } = policyDeclaration(workspaceRoot, workspaceDir);
2815
+ const packOptions = { named: namedRoots, discovery: () => discoverPackRoots(), forced };
2816
+ if (policyReason === "refused") {
2817
+ throw new CompileError(undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOptions, policyReason));
2818
+ }
2819
+ if (!policyDeclared && !fs.existsSync(policyFile)) {
2820
+ // Only a manifest read and naming no policy clears leftovers: one that is missing has not been authored.
2821
+ 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
+ );
2831
+ }
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
+ );
2837
+ }
2838
+ say();
2839
+ const packs = packContributions(workspaceRoot, workspaceDir, packOptions);
2840
+ for (const u of packs.unresolved) say(unresolvedLine(u));
2841
+ if (packs.unresolved.length) say();
2842
+ const personas = planPersonas(packs.contributions, workspaceRoot, workspaceDir);
2843
+ if (showMatrix) {
2844
+ printGuidanceMatrix(say, guidance);
2845
+ printPersonaReport(say, personas);
2846
+ return 0;
2847
+ }
2848
+ const drifted = emitPersonas(personas, { check, say }) + emitGuidance(guidance, guidancePlan, { workspaceRoot, check, say });
2849
+ if (!check) return 0;
2850
+ if (drifted) return 1;
2851
+ say(`GREEN — every compiled guidance file matches its unit${personas.compared ? ", and every persona binding its persona" : ""}`);
2852
+ return 0;
2853
+ }
2795
2854
  const policy = readJson(policyFile, "the gate policy");
2796
- // The cascade's middle layer, composed before the policy is parsed so that a pack's fragment
2797
- // is validated by exactly the code that validates a hand-written rule.
2798
- // The thunk means the host's plugin record is read only on a path where it can win — which is
2799
- // still true of the **named** and **refused** arms and is no longer what keeps `compile --check`
2800
- // host-independent. That sentence read *"a required check that names no root"*, and since
2801
- // 2026-08-13 `../.portulan/verify/compile.sh` runs `--pack-root packs --check`: it names one, and
2802
- // a named root replaces every other source. The pin is the carrier; the thunk is what keeps an
2803
- // API caller wiring none hermetic.
2855
+ // A thunk, so the host's plugin record is read only where discovery can win.
2804
2856
  const { contributions, unresolved, plan } = packContributions(workspaceRoot, workspaceDir, {
2805
2857
  named: namedRoots,
2806
2858
  discovery: () => discoverPackRoots(),
2807
2859
  forced,
2808
2860
  });
2809
- // Under `--matrix` only, EXCEPT when the set is a union: that line moves with what is
2810
- // installed, which is why it is normally withheld from a byte-compared run — but a union is the
2811
- // one arrangement where a tree-derived root joined the search without anyone naming it, and the
2812
- // whole contract of the union is that this is never silent.
2813
- //
2814
- // **`--check` CAN reach it now, and that is the point rather than a leak.** This read *"`--check`
2815
- // cannot reach it: it names no root and passes no `auto`, so `forced` is false and the plan is
2816
- // never a union there"* — all three clauses went false on 2026-08-13, when the unasked arm began
2817
- // consulting discovery. A `--check` run whose resolution set included a discovered root and said
2818
- // nothing would be the silent substitution `--pack-root auto`'s own justification refuses, so the
2819
- // line prints. The **required** check reaches neither, because it names a root: `source` is
2820
- // `named` there and this condition is false for the right reason.
2861
+ const personas = planPersonas(contributions, workspaceRoot, workspaceDir);
2862
+ // Only under `--matrix`, as it moves with what is installed; but a union is never silent.
2821
2863
  if (plan && (showMatrix || plan.source === "union")) say(`packs: resolution root ${plan.source} — ${plan.why}`);
2822
2864
  const composed = composeFragments(policy, contributions);
2823
2865
  const parsed = parse(composed.policy);
2824
2866
  const source = path.relative(workspaceRoot, policyFile).split(path.sep).join("/");
2825
- // `root` is what makes the emitted runner path correct for the workspace being compiled rather
2826
- // than for the one this process happens to sit in. The plumbing existed and **the caller never
2827
- // used it**: `compile --workspace <other-project>` wrote that project's settings naming
2828
- // `${CLAUDE_PROJECT_DIR}/cli/stop-gate.mjs`, a file the target does not have — and a missing hook
2829
- // fails open, silently. That is the exact defect class this whole change exists to close,
2830
- // reintroduced through the cross-compile path. Found by the pre-commit checkpoint, which
2831
- // demonstrated it rather than reasoning about it.
2832
2867
  const columns = backends(parsed, {
2833
2868
  source,
2834
2869
  root: path.resolve(workspaceRoot),
2835
2870
  workspaceDir,
2836
- // What resolved each pack, carried to the artifact so the file the rail compares says
2837
- // which world compiled it (#264).
2838
2871
  packProvenance: contributions,
2872
+ sessions,
2873
+ spend,
2839
2874
  });
2840
2875
 
2841
- // Printed before the backends, because a rule's provenance changes how its compiled line reads
2842
- // — and an unresolvable pack is a declaration the workspace believes it composed.
2843
2876
  for (const a of composed.added) say(`pack ${a.pack.padEnd(30)} adds \`${a.id}\` (${a.tier})`);
2844
2877
  for (const t of composed.tightened) {
2845
2878
  say(`pack ${t.pack.padEnd(30)} tightens \`${t.id}\` ${t.from} → ${t.to}`);
2846
2879
  }
2847
- for (const u of unresolved) {
2848
- say(`pack ${u.name.padEnd(30)} UNRESOLVED — ${u.why}; it contributes nothing`);
2849
- }
2880
+ for (const u of unresolved) say(unresolvedLine(u));
2850
2881
  if (composed.added.length || composed.tightened.length || unresolved.length) say();
2851
2882
 
2852
2883
  if (showMatrix) {
2853
2884
  printMatrix(say, parsed, columns, { source });
2885
+ if (guidance) {
2886
+ say();
2887
+ printGuidanceMatrix(say, guidance);
2888
+ }
2889
+ printPersonaReport(say, personas);
2854
2890
  return 0;
2855
2891
  }
2856
2892
 
2857
2893
  for (const column of columns) {
2858
2894
  say(`${column.label}: ${column.compiled.length} compiled, ${column.refused.length} refused`);
2859
2895
  for (const gate of column.compiled) say(` gate ${gate.id.padEnd(38)} ${gate.surface}`);
2860
- // Refusals are printed, always. A silent cap reads as "covered everything" when it did
2861
- // not — and these are the rows the per-host degradation report is built from.
2862
2896
  for (const r of column.refused) say(` refused ${r.id.padEnd(38)} ${r.why}`);
2863
2897
  for (const n of column.notes) say(` note ${n}`);
2864
2898
  say();
@@ -2867,13 +2901,6 @@ export function run(argv, options = {}) {
2867
2901
  if (check) {
2868
2902
  let drifted = 0;
2869
2903
  for (const column of columns) {
2870
- // A backend with no artifact is owed no file — but *present* and not owed is drift,
2871
- // and it was the hole here for one checkpoint. Deleting `floor` from a policy left
2872
- // the ruleset behind: importable, valid, and claiming in its own `name` field to be
2873
- // generated from a policy that no longer produces it. `--check` reported GREEN,
2874
- // having compared nothing, which is the shape this recipe exists against. The
2875
- // eighth fail-open of this repository's series, and again in scaffolding rather than
2876
- // in a check. Found at the pre-commit checkpoint.
2877
2904
  if (!column.artifact) {
2878
2905
  const orphan = path.join(workspaceRoot, ...artifactPaths(workspaceDir)[column.backend].split("/"));
2879
2906
  if (fs.existsSync(orphan)) {
@@ -2892,13 +2919,6 @@ export function run(argv, options = {}) {
2892
2919
  continue;
2893
2920
  }
2894
2921
  if (current !== column.artifact.text) {
2895
- // **Name the origin difference AT THE MOMENT OF FAILURE** (#264). A drift caused by
2896
- // a shadowed pack was previously reported as a diff nobody could reproduce from the
2897
- // files in front of them, because the deciding input was a directory outside the
2898
- // repository — and the remedy the sentence prescribed, "Recompile", is the very
2899
- // unpinned act that caused it. Read DEFENSIVELY: the artifact on disk is a file a
2900
- // human may have edited, so anything unparseable leaves the plain sentence standing
2901
- // rather than turning a drift report into a crash.
2902
2922
  let why = "";
2903
2923
  try {
2904
2924
  const onDisk = JSON.parse(current)?.$portulan?.packs;
@@ -2923,17 +2943,17 @@ export function run(argv, options = {}) {
2923
2943
  drifted += 1;
2924
2944
  }
2925
2945
  }
2946
+ drifted += emitPersonas(personas, { check, say });
2947
+ drifted += emitGuidance(guidance, guidancePlan, { workspaceRoot, check, say });
2926
2948
  if (drifted) return 1;
2927
- say("GREEN — every emitted artifact matches the policy");
2949
+ const bindings = personas.compared ? ", and every persona binding its persona" : "";
2950
+ const guided = bindings ? `, every guidance file its unit${bindings}` : ", and every guidance file its unit";
2951
+ say(`GREEN — every emitted artifact matches the policy${guidance ? guided : bindings}`);
2928
2952
  return 0;
2929
2953
  }
2930
2954
 
2931
2955
  for (const column of columns) {
2932
2956
  if (!column.artifact) {
2933
- // Remove a stale artifact rather than leaving it for `--check` to complain about
2934
- // forever: the RED above tells a reader to recompile, so recompiling has to be what
2935
- // fixes it. Deleting a generated file this compiler wrote is the one deletion it may
2936
- // do — it is reproducible by definition, and it is already in git if it mattered.
2937
2957
  const orphan = path.join(workspaceRoot, ...artifactPaths(workspaceDir)[column.backend].split("/"));
2938
2958
  if (fs.existsSync(orphan)) {
2939
2959
  fs.rmSync(orphan);
@@ -2947,11 +2967,11 @@ export function run(argv, options = {}) {
2947
2967
  fs.writeFileSync(file, column.artifact.text);
2948
2968
  say(`wrote ${file}`);
2949
2969
  }
2970
+ emitPersonas(personas, { check, say });
2971
+ emitGuidance(guidance, guidancePlan, { workspaceRoot, check, say });
2950
2972
  return 0;
2951
2973
  } catch (error) {
2952
- // Anything reaching here means compile could not judge — including a defect in compile
2953
- // itself. Exit 2. Exit 1 would assert that an artifact drifted when nothing was compared,
2954
- // and exit 0 would be the fail-open this tool exists against.
2974
+ // Any throw exits 2, never 1: nothing was compared, so there is no verdict.
2955
2975
  if (!options.quiet) {
2956
2976
  process.stderr.write(
2957
2977
  `compile: ${error instanceof CompileError ? error.message : `unanticipated failure — ${error.stack ?? error}`}\n`,