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