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