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