@sleepy_panda_srl/portulan 0.1.1 → 0.1.3

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.
package/cli/compile.mjs CHANGED
@@ -45,6 +45,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
45
45
  // Host plugin-cache discovery (#123). The record reader, the config directory and the version refusal
46
46
  // all live there, once — see that file's pack-root section for why this half arrived separately.
47
47
  import { AUTO, discoverPackRoots, namedWithAuto, resolutionRoots } from "./discover.mjs";
48
+ import { isInside } from "./inside.mjs";
48
49
 
49
50
  /** Raised when `compile` cannot run, or cannot compile honestly. Always exit 2, never 1. */
50
51
  export class CompileError extends Error {
@@ -69,7 +70,11 @@ export class CompileError extends Error {
69
70
  // Workspace Definition. A fresh reviewer at milestone 6 session 1 sight-read this as a carrier left
70
71
  // stale by the 2.5 → 2.6 bump and had to measure to clear it. It is correctly untouched by any
71
72
  // 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
+ // Exported so a test can assert MEMBERSHIP against it rather than restate the literal. The
74
+ // scaffold `new gate-policy` emits must declare a spec THIS set contains (#329), and a rail
75
+ // spelling `"2.2"` on its own side would pass while the two drifted apart — which is the
76
+ // shape #329 was.
77
+ export const KNOWN_GATE_POLICY_SPECS = new Set(["2.1", "2.2"]);
73
78
 
74
79
  const TIERS = new Set(["auto", "propose", "gated", "prohibited"]);
75
80
 
@@ -80,8 +85,12 @@ const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
80
85
  const FILE_PATH = /^[^#?:/]([^#?:]*[^#?:/])?$/;
81
86
 
82
87
  /** 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"];
88
+ // Exported since 2026-08-24 so `./goldens.mjs` can DERIVE which branch of `matchesRule` a fixture
89
+ // exercises instead of a corpus author declaring it from memory. A hand-declared path would be a
90
+ // second carrier of what these two tables already decide, and the fixture format asks for the path on
91
+ // every case — which is exactly the volume at which a hand-copied answer goes wrong.
92
+ export const WRITE_TOOLS = ["Edit", "Write", "NotebookEdit"];
93
+ export const READ_TOOLS = ["Read"];
85
94
 
86
95
  // ===========================================================================================
87
96
  // 1. Parse: policy -> validated, normalised rules. NO backend opinion lives here.
@@ -473,7 +482,10 @@ function stripHeredocs(command) {
473
482
  }
474
483
 
475
484
  /** A shell command split into words, with unquoted operators kept as words of their own. */
476
- function shellWords(command) {
485
+ // Exported since 2026-08-24 so the suite can hold `REDIRECTION_TARGET` to THIS definition of a word
486
+ // rather than to a reviewer's patience. Three review findings in a row were the same class — the
487
+ // target reader narrower than a shell word — and each was fixed at the instance quoted.
488
+ export function shellWords(command) {
477
489
  const words = [];
478
490
  let text = "";
479
491
  let open = false; // a word has begun — so an empty `""` argument survives as a word
@@ -534,11 +546,74 @@ function shellWords(command) {
534
546
  // A backslash-newline is a line continuation: both characters vanish, and the word
535
547
  // continues on the next line. Appending the newline instead would have glued it into the
536
548
  // 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.
549
+ // `\r\n` is consumed as a PAIR, and **the reachability that argued for it has been
550
+ // retired.** What stood here from 2026-07-28 until 2026-08-25 was: skipping one character
551
+ // left the `\n` behind, a newline is an operator here, so `cp /tmp/x \\<CRLF>docs/vision.md`
552
+ // stepped aside where the LF spelling answers `deny` — "the constitution, reachable by
553
+ // editing the file on Windows". The first half is a true statement about THIS reader. The
554
+ // second is a claim about a shell, and it did not reproduce on any shell measured.
555
+ //
556
+ // **Measured 2026-08-25 on five shells, with a neutral target path — never the
557
+ // constitution.** `bash 3.2.57(1)` (arm64-apple-darwin25, the maintainer's machine),
558
+ // `bash 5.2.15(1)` (aarch64-unknown-linux-gnu) and `bash 5.2.37(1)` (x86_64-pc-linux-gnu),
559
+ // both in containers; `zsh 5.9`; and **the measured host's `/bin/sh`**, which on that macOS host
560
+ // resolves to bash 3.2.57 in POSIX mode — elsewhere `/bin/sh` is commonly `dash` or `busybox`
561
+ // and NEITHER was measured, so this row is a fifth reading of bash and zsh rather than a claim
562
+ // about `/bin/sh` as such.
563
+ // **All five agree, and none of them joins the pair:** the backslash escapes the `\r`, the
564
+ // newline then ends the command, and the fragment after it is run as its own command. The
565
+ // LF spelling in the same harness DID join and DID write the target, so the harness fires
566
+ // rather than reporting nothing everywhere. For the `cp`-shaped payload the retired
567
+ // sentence names, the target is left byte-for-byte unchanged on all five — so this branch
568
+ // is a **false red** there, fail-closed, and worth one prompt.
569
+ //
570
+ // **It is not a false red everywhere, which is why the branch is still here.** A shell
571
+ // applies a redirection BEFORE it looks the command up, so a clobbering `>` or `1>` on the
572
+ // fragment left by the split still fires and truncates its target to zero bytes — measured
573
+ // on all three bash builds. `>>` appends and does not. The carriers of that split verdict
574
+ // are `./fuzz-shell.mjs`'s `crlf-continuation-in-the-payload` EXPECT cells and
575
+ // `./compile.test.mjs`'s paired CRLF assertions. **Not `../.portulan/gate-map.md`**, which
576
+ // an earlier draft of this sentence named and which carries no CRLF entry at all — a
577
+ // citation to a record that does not exist, in the comment repairing exactly that.
578
+ //
579
+ // **Removing the branch is fail-OPEN on a gate matcher, so it is the maintainer's and not
580
+ // this comment's.** Deleting BOTH carriers in a scratch clone and running the recipes moves
581
+ // **four** surfaces, which is the count an accepting change has to carry:
582
+ // 1. `fuzz-shell` — the `crlf-continuation-in-the-payload|write-named` EXPECT cell, whose
583
+ // recorded divergence CLOSES. Good news, and a red until the record absorbs it.
584
+ // 2. `goldens` — the `a-CRLF-continuation` fixture in
585
+ // `../evals/goldens/gates/edit-the-constitution.json` regresses.
586
+ // 3. `./compile.test.mjs` — **two** direct assertions fail, *a CRLF continuation before
587
+ // the path* and *a CRLF continuation after `>`*.
588
+ // 4. `mutants` — exit **2, could-not-run**, downstream of 2: a census over a red corpus
589
+ // measures nothing, so it refuses until the fixture is repaired.
590
+ // **An earlier draft of this comment said "those two and nothing else" and had counted only
591
+ // the first two** — an exhaustive claim that had not run the suite it was exhaustive about,
592
+ // in the comment repairing an unmeasured claim. Reported by Copilot, round 1 on #342.
593
+ // What does NOT move, **and the reason an earlier draft gave for it was false.** That draft
594
+ // said "the redirection is recognised off the raw segment text rather than off a joined
595
+ // word". There is no raw-text path: `shellWrites` iterates `shellSegments`, which is built
596
+ // from `shellWords`. The real reason is narrower and is a property of WHERE the pair sits.
597
+ // With the branch gone the newline splits, and for the fuzzer's shape — pair after the head,
598
+ // `echo \<CRLF>ok > docs/vision.md` — the `> docs/vision.md` lands in a LATER segment whose
599
+ // `redirects` still name the target, so that cell stays `true`. Put the pair AFTER the `>`
600
+ // and the opposite happens: the operator takes the escaped `\r` as its target and the path
601
+ // becomes an ordinary word in the next segment — which is precisely why surface 3's second
602
+ // assertion fails. **So the true positive survives for this production and NOT for every
603
+ // redirect spelling**, and generalising it was the overclaim. Reported by Copilot, rounds 2
604
+ // and 3 on #342. A real LF continuation is untouched either way. Asked at
605
+ // `../.portulan/proposals/0031-a-continuation-no-shell-joins.md`; until that is ruled, the
606
+ // branch stays and this comment states what was measured rather than what was argued.
607
+ //
608
+ // **The honest limit, and it is the platform the retired sentence named: no Windows-side
609
+ // bash was measured** — not git-bash, MSYS2, Cygwin (whose `igncr` exists precisely for
610
+ // this) or WSL. Nor was any bash 4.x. The standing rail is `./fuzz-shell.ground.test.mjs`,
611
+ // which runs this production under **whatever bash the host running the `tests` recipe
612
+ // has** and reds if that bash joins the pair — so a joining shell is caught wherever the
613
+ // recipe runs, CI included, and is caught nowhere the recipe does not run. That boundary is
614
+ // stated because it is the whole of the enforcement: the container measurements above are a
615
+ // record, not a recipe, since a recipe that pulled an image would be a network call in CI.
616
+ // Found by Copilot review on #60; the claim retired by measurement 2026-08-25.
542
617
  if (command[i + 1] === "\r" && command[i + 2] === "\n") {
543
618
  i += 2;
544
619
  continue;
@@ -647,6 +722,85 @@ function normalisePath(p) {
647
722
  return (absolute ? "/" : "") + out.join("/");
648
723
  }
649
724
 
725
+ /**
726
+ * Does this target match NO path a host actually submits? `true` means none can — the rule matches
727
+ * nothing, whatever it says.
728
+ *
729
+ * _(That first line asked the inverse question until 2026-09-09 — "can any path fall under this target?
730
+ * `false` means none can" — so it described a return value the function does not have. Eight
731
+ * fresh-context gradings read past it, four of them grading this docblock's accuracy: every one checked
732
+ * whether the predicate was right and none checked whether its opening sentence matched its own
733
+ * `return`. Found by Copilot on #408.)_
734
+ *
735
+ * **The predicate is a comparison between two forms of the target, and that is what makes it the
736
+ * class rather than a list.** `matchesPath` compares a *tail* — it strips a leading `./` and any
737
+ * leading `/` and then asks whether the candidate ends with, or contains, what is left. A host is
738
+ * **expected** to hand over a normalised absolute path — no `.` segment, no empty segment, no
739
+ * backslash — and that is an assumption rather than something measured here, argued below. So the
740
+ * target can match only if its comparison form is already normalised. Where the two forms differ, the comparison is against
741
+ * a spelling no candidate can carry, and the rule matches nothing whatever it says.
742
+ *
743
+ * That covers three families at once, which is why it is written as a comparison and not as a list of
744
+ * spellings a reader would have to keep in step:
745
+ *
746
+ * - **The target that reduces to nothing**, which compiles to `Edit(./)`, `Edit(./.)`, or — for one
747
+ * spelling — the real subtree glob `Edit(././**)`.
748
+ * - **The target with an interior `.` or empty segment**, each compiling to a named surface that reads
749
+ * like a real gate on a plausible path, and matches nothing.
750
+ * - **The target carrying a backslash**, and this one is why the argument above needs its second half
751
+ * stated: `matchesPath` normalises the **candidate**'s backslashes to `/` and never the target's, so
752
+ * a backslash in a target is compared against a character no candidate can still contain, **on every
753
+ * platform**. Unlike the first two families this one has no conditional reading at all.
754
+ *
755
+ * **The host assumption, named because it is one.** *No `.` segment, no empty segment, no backslash*
756
+ * describes what a host is expected to send, and this repository does not control that and has not
757
+ * measured an unnormalised `file_path` arriving. The refusal does not rest on it: a rule matching only
758
+ * `/repo/x/.` gates nothing anybody meant to gate whichever way the assumption falls.
759
+ *
760
+ * **Four cuts, three of them incomplete, always in the same direction — and that arc is why this is a
761
+ * comparison rather than a list.** Cut 1 lifted `matchesPath`'s own strip and caught two of the first
762
+ * family's five. Cut 2 asked whether the target normalised to **empty**, catching that family alone
763
+ * while three carriers of its prose claimed the class. Cut 3 added the interior family. Cut 4 added the
764
+ * backslashes. Every miss was found by putting neighbouring spellings through the function at a
765
+ * checkpoint, never by re-reading the sentence. A predicate defined by the instances it was written for
766
+ * is ../.portulan/proposals/0020-a-fix-is-not-done-at-the-site-it-was-found.md's shape, and **this
767
+ * docblock is the one carrier of that arc** — the records cite it rather than recounting it.
768
+ *
769
+ * The thirteen spellings and the controls that must stay matchable are enumerated **and measured** in
770
+ * `./compile.test.mjs`'s `NEVER` and `CONTROLS` — the only carrier that measures them. Four records
771
+ * enumerate them for a reader; this docblock cites the suite rather than joining them.
772
+ *
773
+ * **`matchesPath` is left byte-identical on purpose**, and that is a decision rather than an omission:
774
+ * its answers are asserted by twelve `documented-hole` cases in ../evals/goldens/gates/ and by
775
+ * `mutants` operators anchored on its text, so moving its strip would move two census anchors and exit
776
+ * the census 2 — could-not-run wearing a pass's clothes. This function re-spells that strip rather than
777
+ * importing it, which is one carrier more than ideal and is pinned by a suite case comparing the two on
778
+ * every target below.
779
+ *
780
+ * The subtree marker is **meaning, not spelling**: exactly one trailing `/` says *the subtree* and is
781
+ * removed before the comparison, which is why `docs/` is a real target and `docs//` is not.
782
+ *
783
+ * What this does NOT answer is what `./` should MEAN as a policy target. That is
784
+ * https://github.com/sleepy-panda-srl/portulan/issues/337's reserved question, and this predicate
785
+ * answers only whether a target can *never* match.
786
+ */
787
+ export function neverMatches(target) {
788
+ const clean = String(target ?? "").replace(/^\.\//, "").replace(/^\/+/, "");
789
+ // `clean === "/"` is UNREACHABLE, and so is the `body === ""` arm below — the strip above removes
790
+ // every leading slash, so `"/"` arrives here as `""`. Measured, not assumed. They are kept anyway
791
+ // because `matchesPath` carries the identical pair one screen down and this predicate is meant to be
792
+ // read beside it: deleting them here would make two functions that answer the same question about
793
+ // the same string look different, to save two comparisons nobody runs. Copilot raised it on #408;
794
+ // the answer is that the redundancy is deliberate and now says so, which is the half it was missing.
795
+ if (clean === "" || clean === "/") return true;
796
+ const body = clean.endsWith("/") ? clean.slice(0, -1) : clean;
797
+ // The backslash arm is separate because it is about the CANDIDATE's normalisation rather than the
798
+ // target's: `matchesPath` rewrites `\\` to `/` in what the host hands over, so a target keeping one
799
+ // is compared against a character the comparison has already removed from every candidate.
800
+ if (body.includes("\\")) return true;
801
+ return body === "" || normalisePath(body) !== body;
802
+ }
803
+
650
804
  /**
651
805
  * The directories a target lives under, as exact paths: `a/b/c.md` -> `a`, `a/b`.
652
806
  *
@@ -717,6 +871,8 @@ function commandSegments(raw) {
717
871
  const out = [];
718
872
  let start = 0;
719
873
  let quote = null;
874
+ // The index of the character a backslash most recently turned into data. See the `\\` branch.
875
+ let escaped = -1;
720
876
  for (let i = 0; i < command.length; i += 1) {
721
877
  const c = command[i];
722
878
  if (quote) {
@@ -746,7 +902,28 @@ function commandSegments(raw) {
746
902
  // of one — so this is fixed for the reason the session kept re-learning rather than for a
747
903
  // failing case: one carrier corrected and its sibling left is how the last three defects
748
904
  // on this branch happened.
905
+ // **The pair's two carriers are this line and `shellWords`, and the measurement retiring
906
+ // the reachability claim is written up there once.** No shell measured joins the pair, so
907
+ // consuming it here is unfaithful to a shell in the same direction — **but this carrier
908
+ // records no false red of its own, and an earlier draft of this comment said it did.** On
909
+ // the shell path its `crlf-continuation-in-the-payload|shell` cell answers `false`, the
910
+ // correct negative: nothing gated runs there. On the write path it is an UNWRAPPER feeding
911
+ // `shellWrites`, reached through a short-circuiting `||` after the un-peeled spellings, so
912
+ // the recorded false red — `write-named` — is produced by `shellWords` and not here. **The
913
+ // reason to keep the two readers in step is synchronisation, not a shared verdict**: a
914
+ // carrier corrected while its sibling stands is how the last several defects on this line
915
+ // happened. Reported by Copilot, round 2 on #342. That is why
916
+ // `../.portulan/proposals/0031-a-continuation-no-shell-joins.md` names both carriers rather
917
+ // than the one whose comment argued for it. Reported by Copilot, round 1 on #342.
749
918
  i += command[i + 1] === "\r" && command[i + 2] === "\n" ? 2 : 1;
919
+ // **Remember WHICH character this backslash turned into data.** The redirection-operator
920
+ // check below reads one raw neighbour, and a raw read cannot tell `>` the operator from
921
+ // `\>` the literal. Without this, `echo \>| git push --force origin main` — a REAL pipe,
922
+ // measured in bash delivering bytes downstream — stopped splitting at the `|`, and the
923
+ // force-push after it went invisible to every matcher. `\>&` did the same across a real
924
+ // background separator. Both were caught at HEAD and were narrowed by the #71 fix itself;
925
+ // found by the pre-commit checkpoint, which attacked the change rather than reading it.
926
+ escaped = i;
750
927
  continue;
751
928
  }
752
929
  // `#` is NOT treated as starting a comment, and that is a decision rather than an oversight.
@@ -763,13 +940,91 @@ function commandSegments(raw) {
763
940
  //
764
941
  // Both spellings are asserted in the suite so this stays a choice rather than drift.
765
942
  // Reported by Copilot review on #60 and declined on those grounds.
943
+ // An `&` or `|` bound into a redirection OPERATOR is not a separator, and splitting there is
944
+ // what made the strip below unreachable for three of the four spellings. `2>&1 git push
945
+ // --force …` split at the `&` into `2>` and `1 git push --force …`, whose head is `1` — the
946
+ // redirection was already in pieces by the time anything could strip it, and `>| f git push
947
+ // --force …` broke the same way at the `|`. Both operators are closed forms: `&` preceded by
948
+ // `>`/`<` or followed by `>`, and `|` preceded by `>`. `a && b`, `a & b` and `a | b` are
949
+ // untouched — none of them puts a redirection character next to the separator.
950
+ // `i - 1 !== escaped` on both arms: a `>` or `<` that a backslash turned into DATA is not an
951
+ // operator, and treating it as one un-splits a real separator. The `command[i + 1]` arm needs
952
+ // no such guard — an escaped character is always preceded by its own backslash, so the
953
+ // character after `&` can never be an escaped `>`.
954
+ const opBefore = i - 1 !== escaped && (command[i - 1] === ">" || command[i - 1] === "<");
955
+ if (c === "&" && (opBefore || command[i + 1] === ">")) continue;
956
+ if (c === "|" && i - 1 !== escaped && command[i - 1] === ">") continue;
766
957
  if (";|&()\n\r".includes(c)) {
767
958
  out.push(command.slice(start, i));
768
959
  start = i + 1;
769
960
  }
770
961
  }
771
962
  out.push(command.slice(start));
772
- return out.map((s) => s.trim()).filter(Boolean);
963
+ return out.map((s) => stripLeadingRedirections(s.trim())).filter(Boolean);
964
+ }
965
+
966
+ /**
967
+ * A redirection sitting where the command goes: an optional file descriptor, one of the operators
968
+ * below, and a word.
969
+ *
970
+ * **This is the one leader that is stripped, and the asymmetry is the point** (#71). The other
971
+ * leaders `commandSegments` leaves alone — `env`, `sudo`, `FOO=bar`, `then`, `do`, a brace group —
972
+ * are refused for a reason that does not apply here: a table of command prefixes has no natural edge
973
+ * (`nice`, `time`, `nohup`, `timeout`, `command`, `stdbuf`, `doas`), and one missing entry buys
974
+ * exactly the false confidence a hole list exists to deny. A redirection's grammar is CLOSED, so it
975
+ * can be stripped completely with an edge a reader can check. #60 left it open deliberately, because
976
+ * closing it would have been a matcher change on the same commit that stopped hole 2 overclaiming.
977
+ *
978
+ * **Do not extend this to a named table of command prefixes.** That is the change the paragraph
979
+ * above refuses, and the two look similar enough to be proposed together.
980
+ *
981
+ * Applied repeatedly, because redirections stack: `> /tmp/out 2>&1 git push --force …` carries two.
982
+ */
983
+ /**
984
+ * The redirection TARGET, as one word.
985
+ *
986
+ * `[^\s]+` was wrong and shipped for exactly one review round: a redirection target may contain
987
+ * spaces when it is quoted or escaped, and `> "foo bar" git push --force …` then stripped only
988
+ * `> "foo`, leaving `bar" git push --force …` — head `bar"`, no gate, and the segment text corrupted
989
+ * into the bargain. Measured escaping on five spellings (both quote styles, an escaped space, `2>` and
990
+ * `>|`), and bash confirmed to run the command after each. **A fix that closes a hole by a width its
991
+ * own test never probed**, found by Copilot round 1 — which named the double-quoted case; the other
992
+ * four are its siblings and are closed in the same stroke rather than left for the next round.
993
+ *
994
+ * A word is therefore a run of quoted spans, backslash-escaped characters, and ordinary non-space
995
+ * characters — the same three things `shellWords` recognises, kept to a regex here because this reader
996
+ * needs the target's EXTENT and never its value.
997
+ *
998
+ * **Inside a double-quoted span a backslash escapes the next character, and inside a single-quoted one
999
+ * it does not.** POSIX gives `'…'` no escapes at all, so honouring one there would invent a hole —
1000
+ * which is the same asymmetry `commandSegments`'s own quote loop already carries, and the same one
1001
+ * `shellWords` carries at its quoted-run branch.
1002
+ *
1003
+ * **This is the THIRD review finding of one class, and the class is what the suite now pins.** Round 1
1004
+ * found `[^\s]+` too narrow for a quoted target; round 3 found `"[^"]*"` too narrow for an escaped
1005
+ * quote *inside* one — `> "foo \"bar baz\"" git push --force …`, measured escaping while bash was
1006
+ * measured creating the file and running the command. Both times the fix landed at the spelling that
1007
+ * was quoted. So `compile.test.mjs` now asserts this pattern against `shellWords` itself: every
1008
+ * spelling that reader calls ONE word must be consumed whole here. A fourth sibling cannot be found by
1009
+ * a reviewer first.
1010
+ *
1011
+ * Linear rather than backtracking-prone despite the alternation: the pattern is anchored, the trailing
1012
+ * `\s*` matches empty, and the `+` is greedy over a match that always succeeds once an operator has
1013
+ * been seen — so there is no failure to backtrack into. Probed to 200KB.
1014
+ */
1015
+ const REDIRECTION_TARGET = String.raw`(?:"(?:\\[\s\S]|[^"\\])*"|'[^']*'|\\[\s\S]|[^\s])+`;
1016
+
1017
+ const LEADING_REDIRECTION = new RegExp(String.raw`^\d*(?:&>>|&>|>>|>&|>\||<&|<>|<|>)\s*${REDIRECTION_TARGET}\s*`);
1018
+
1019
+ function stripLeadingRedirections(segment) {
1020
+ let text = segment;
1021
+ for (;;) {
1022
+ const shorter = text.replace(LEADING_REDIRECTION, "");
1023
+ // A segment that is ONLY a redirection strips to the empty string and is dropped by the
1024
+ // caller's `filter(Boolean)`, which is correct: a redirection is not a command.
1025
+ if (shorter === text) return text;
1026
+ text = shorter;
1027
+ }
773
1028
  }
774
1029
 
775
1030
  /**
@@ -872,8 +1127,32 @@ export function matchesRule(rule, tool, input = {}) {
872
1127
  // wrapper and made `bash -c "bash -c 'git push origin HEAD'"` match, contradicting hole 1's
873
1128
  // documented and asserted limit. The suite caught it, which is what that counterexample test
874
1129
  // 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));
1130
+ //
1131
+ // **A spelling is SEGMENTED as well as tested whole, and that half was missing until
1132
+ // 2026-08-25.** The two calls above unwrap the raw command and unwrap each of its segments,
1133
+ // which closes a wrapper before a separator — `ls && bash -c "git push --force origin main"`
1134
+ // — and leaves a separator inside a wrapper wide open: `bash -c "ls; git push --force origin
1135
+ // main"` answered **false**, and so did every other Gated shell action behind one wrapper and
1136
+ // one separator. Bash runs it; the gate did not see it.
1137
+ //
1138
+ // **The write branch below never had this gap, and the reason is why the fix is shaped like
1139
+ // this.** Its `writes` callback is `shellWrites`, which segments AGAIN internally with
1140
+ // `shellSegments` — so the write half got a third segmentation for free while the shell half,
1141
+ // whose `hit` is a plain prefix test, got none. One fix landing in one carrier and not its
1142
+ // sibling, between two branches of one function, for the second time
1143
+ // (`proposals/0020`). Found by ./fuzz-shell.mjs, which
1144
+ // generates the grammar rather than reading it.
1145
+ //
1146
+ // `reach` is used on BOTH arms deliberately: without it on the second,
1147
+ // `ls && bash -c "x; git push --force …"` — a separator outside AND inside — still escaped.
1148
+ // Fixing only the spelling that was quoted is the class this repository keeps meeting.
1149
+ //
1150
+ // **The unwrap budget is still exactly one level**, which is hole 1's documented and asserted
1151
+ // limit. `spellings` is called once on each path here and `commandSegments` peels nothing, so
1152
+ // `bash -c "sh -c 'git push origin HEAD'"` stays false — asserted, because an earlier draft of
1153
+ // the segment composition did peel twice and the suite is what caught it.
1154
+ const reach = (s) => hit(s) || commandSegments(s).some(hit);
1155
+ return spellings(input.command).some(reach) || commandSegments(input.command).some((seg) => spellings(seg).some(reach));
877
1156
  }
878
1157
  if (typeof action.write === "string") {
879
1158
  if (WRITE_TOOLS.includes(tool)) return matchesPath(input.file_path ?? input.notebook_path, action.write);
@@ -969,6 +1248,16 @@ const HOST_TIER_NOT_A_GATE = {
969
1248
  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
1249
  };
971
1250
 
1251
+ /**
1252
+ * The compiled-hook runners, in the order `claudeCode` spells them: the PreToolUse gate, then the Stop
1253
+ * gate. **This is their one carrier.** They are invoked by generated host configuration rather than
1254
+ * imported by anything, so no import graph can find them and every other roster that needs to know
1255
+ * which `cli/` modules are runners has to ask here — `./payload.mjs` does. A third runner added below
1256
+ * without a name added here would classify as unreachable in that rail, which is the direction that
1257
+ * fails loudly rather than the one that ships something unnoticed.
1258
+ */
1259
+ export const HOOK_RUNNERS = ["gate.mjs", "stop-gate.mjs"];
1260
+
972
1261
  /**
973
1262
  * Translate the policy into a Claude Code settings object.
974
1263
  *
@@ -1033,8 +1322,8 @@ export function claudeCode(parsed, options = {}) {
1033
1322
  pinned.push({ file, abs });
1034
1323
  return `"${abs}"`;
1035
1324
  };
1036
- const runner = options.runner ?? spell("gate.mjs");
1037
- const stopRunner = options.stopRunner ?? spell("stop-gate.mjs");
1325
+ const runner = options.runner ?? spell(HOOK_RUNNERS[0]);
1326
+ const stopRunner = options.stopRunner ?? spell(HOOK_RUNNERS[1]);
1038
1327
 
1039
1328
  const compiled = [];
1040
1329
  const refused = [];
@@ -1042,6 +1331,8 @@ export function claudeCode(parsed, options = {}) {
1042
1331
  const ask = [];
1043
1332
  const matchers = new Set();
1044
1333
  const shellWriteGates = [];
1334
+ // Write gates whose single emitted permission pattern is `Edit(path)` — see the note they produce.
1335
+ const editCoveredGates = [];
1045
1336
 
1046
1337
  for (const rule of parsed.rules) {
1047
1338
  if (!HOST_GATE_TIERS.has(rule.tier)) {
@@ -1055,6 +1346,60 @@ export function claudeCode(parsed, options = {}) {
1055
1346
  refused.push({ id: rule.id, tier: rule.tier, why: rule.target });
1056
1347
  continue;
1057
1348
  }
1349
+ // ---- a path target no path can match, refused HERE and not at `parse`
1350
+ //
1351
+ // **Hole 8 of ../.portulan/gate-map.md, closed at the tier that asks the question.** A
1352
+ // `gated` or `prohibited` rule whose path target can never match — see `neverMatches` for the
1353
+ // three families, of which *reduces to nothing* is only the first — compiles to a named
1354
+ // permission surface (`Edit(./)`, `Edit(./.)`, `Edit(././**)`, `Edit(./docs/./vision.md)`,
1355
+ // `Edit(./docs\\vision.md)`) while `matchesRule` answers `false` for every path a host submits. The compiler then reports the rule COMPILED, and
1356
+ // `doctor` counts it as covered. That is a hollow gate that reads from outside exactly like a
1357
+ // whole one: hole 3's failure mode reached by a different road.
1358
+ //
1359
+ // **Why a throw and not a `refused` row.** `refused` is the accounting for a rule a backend
1360
+ // legitimately declines — an `auto` tier, a `none` kind — and it writes the artifact without
1361
+ // the rule and exits 0. Filing this there would leave `doctor` reporting a gate the policy
1362
+ // declares and nothing enforces as ordinary non-coverage, which is precisely the laundering
1363
+ // `parse`'s own target refusals (absolute, `..`, whitespace) already decline to do. A policy
1364
+ // that cannot be enforced as written is malformed, and malformed is could-not-run.
1365
+ //
1366
+ // **Why here and not at `parse`.** That stage holds no tier partition, by a decision recorded
1367
+ // in its own docblock: *"A partition that is one backend's opinion must live in that backend,
1368
+ // or the second one is structurally incapable of disagreeing."* A session once put the
1369
+ // `auto`/`propose` refusals there and the floor backend then needed the rules it had thrown
1370
+ // away. This is that same shape — the hazard exists only for the tiers THIS backend enforces —
1371
+ // so it lives with `HOST_GATE_TIERS` above it, and the two `auto` rules in
1372
+ // ../.portulan/gates.json — `edit-on-a-working-branch` (`write: "./"`) and
1373
+ // `read-anything-in-the-repository` (`read: "./"`), one of each path kind — keep their spelling
1374
+ // and are refused one line earlier, for their tier, exactly as before.
1375
+ //
1376
+ // **One site, and the predicate exported rather than inlined.** The floor backend compiles no
1377
+ // path target at all (see `floorRefusal`), so there is no second site to sweep today and
1378
+ // saying "the same treatment there" would be a claim about code that does not exist.
1379
+ // `neverMatches` is exported for the backend that does compile one, and for the tests.
1380
+ //
1381
+ // **What this does NOT reach, said here rather than left to be discovered.** ./gate.mjs reads
1382
+ // the policy through `parse`, which this change does not touch, so a workspace that has
1383
+ // committed such a rule still loads it at the hook, `matchesRule` answers `false`, and the
1384
+ // hook steps aside because nothing matched. The rule is refused at COMPILE time — which is
1385
+ // when the artifact that gates the host is written — and the residue is recorded in hole 8.
1386
+ //
1387
+ // Found by the milestone-8 gate corpus on its first run (#336) and put as three options in
1388
+ // #337; this is the third, the narrowest, and it deliberately leaves open what `./` should
1389
+ // MEAN as a policy target.
1390
+ if ((rule.kind === "write" || rule.kind === "read") && neverMatches(rule.target)) {
1391
+ throw new CompileError(
1392
+ `rule \`${rule.id}\` is ${rule.tier} and its ${rule.kind} target ${JSON.stringify(rule.target)} matches no path a host ` +
1393
+ `can submit: the matcher compares a tail, and this target's comparison form is one no candidate can carry. ` +
1394
+ `This backend would emit ${pattern(rule.kind === "write" ? "Edit" : READ_TOOLS[0], rule.target)} ` +
1395
+ `and report the rule compiled, while the runtime matcher answered false for every input: a gate that reads as whole ` +
1396
+ `from outside and enforces nothing. Name the path the rule protects in its normalised spelling — no ` +
1397
+ `\`.\` or empty segment, no backslash — or drop the rule; and where this ` +
1398
+ `rule came from a pack, it is the pack that carries it, not the workspace composing it. ` +
1399
+ `(A never-matching target is harmless at \`auto\`, which this backend refuses one step earlier; it is a hollow gate only ` +
1400
+ `at the tiers that enforce. See .portulan/gate-map.md, honest holes, entry 8.)`,
1401
+ );
1402
+ }
1058
1403
  const into = rule.tier === "prohibited" ? deny : ask;
1059
1404
  const emitted = []; // permission patterns — the layer that cannot fail open
1060
1405
  const hookOnly = []; // coverage the permission DSL cannot express, carried by the hook alone
@@ -1062,11 +1407,36 @@ export function claudeCode(parsed, options = {}) {
1062
1407
  emitted.push(`Bash(${rule.target}:*)`);
1063
1408
  matchers.add("Bash");
1064
1409
  } 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
- }
1410
+ // **The PERMISSION pattern narrows to `Edit`; the HOOK matchers keep all three.** These two
1411
+ // lines used to be one loop, and that emitted `Write(path)` and `NotebookEdit(path)` beside
1412
+ // `Edit(path)` — patterns the host **discards**. Measured on Claude Code 2.1.240, printed on
1413
+ // every start of this repository:
1414
+ //
1415
+ // Permission deny rule (.claude/settings.json): Write(./docs/vision.md) is not matched by
1416
+ // file permission checks — only Edit(path) rules are. Use Edit(./docs/vision.md) instead
1417
+ // (Edit rules cover all file-editing tools).
1418
+ //
1419
+ // and the same for the `ask` tier, so this was never a `deny`-only defect. The gate held
1420
+ // throughout — `Edit(path)` is matched for every file-editing tool — so what was wrong was
1421
+ // the compiler's **accounting**: three rules reported compiled where one enforces, plus a
1422
+ // permanent warning, which is `../.portulan/memory/a-stated-enforcer-must-be-the-real-one.md`.
1423
+ //
1424
+ // **`matchers` is a different consumer and must not narrow with it.** It drives the
1425
+ // `PreToolUse` hook, where all three tool names genuinely arrive and `matchesRule` above
1426
+ // answers for each; narrowing it would open a real hole where today there is none. That is
1427
+ // why the repair is not narrowing `WRITE_TOOLS` — two consumers of one constant, one of
1428
+ // which changes. A suite case pins the matcher set for exactly this reason.
1429
+ //
1430
+ // **The dropped patterns are NOT `hookOnly`.** Measured with the pattern alone in `deny` and
1431
+ // `Write` allowed: the host refused the write. So their coverage is carried by the
1432
+ // permission layer, not by the hook, and filing them under a channel documented as *carried
1433
+ // by the hook alone* would be a false claim in the weakening direction. The fact goes on
1434
+ // `notes` instead, where it names its host version.
1435
+ const permissionTools = rule.kind === "write" ? ["Edit"] : READ_TOOLS;
1436
+ for (const tool of permissionTools) emitted.push(pattern(tool, rule.target));
1437
+ for (const tool of rule.kind === "write" ? WRITE_TOOLS : READ_TOOLS) matchers.add(tool);
1069
1438
  if (rule.kind === "write") {
1439
+ editCoveredGates.push(rule.id);
1070
1440
  // The hook is wired for Bash so that a shell spelling of this write reaches
1071
1441
  // `matchesRule`, which now answers for it. **This line is the load-bearing half.**
1072
1442
  // The matcher alone would be inert in any workspace whose policy declares no shell
@@ -1107,10 +1477,24 @@ export function claudeCode(parsed, options = {}) {
1107
1477
  // The generation header. An emitted artifact that does not say what generated it invites the
1108
1478
  // one edit this whole rail exists to catch — a hand-fix that survives until the next compile
1109
1479
  // silently reverts it.
1480
+ // **What compiled this, recorded beside what generated it** (#264). An unpinned run WOULD read
1481
+ // the host's plugin cache while the rail reads the tree — it now refuses that arrangement outright
1482
+ // (#316) — and until this field the artifact said nothing about which answered, so a drift RED named a difference no reader could find in the repository, because the
1483
+ // deciding input was a directory outside it.
1484
+ //
1485
+ // **Origin and version, never the ROOT PATH.** The discovered root is an absolute path under
1486
+ // somebody's home directory; recording it would make a tracked artifact machine-dependent and red
1487
+ // `verify/compile.sh` for every developer and CI — trading a silent hazard for a permanent false
1488
+ // one. `recordedOrigin` collapses the resolver's three tags to two for the same reason: the pinned
1489
+ // rail and a bare run must agree byte for byte about an identical world.
1490
+ const packs = (options.packProvenance ?? [])
1491
+ .map((c) => ({ pack: c.pack, origin: c.origin, version: c.version ?? null }))
1492
+ .sort((a, b) => a.pack.localeCompare(b.pack));
1110
1493
  const value = {
1111
1494
  $portulan: {
1112
1495
  generated: "cli/compile.mjs",
1113
1496
  source,
1497
+ ...(packs.length ? { packs } : {}),
1114
1498
  warning: `Generated file. Edit ${source} and recompile; \`verify/compile.sh\` fails on drift.`,
1115
1499
  },
1116
1500
  permissions: { deny, ask, allow: [] },
@@ -1148,6 +1532,21 @@ export function claudeCode(parsed, options = {}) {
1148
1532
  `project, or pass \`--runner\`/\`--stop-runner\` to name a path you control`,
1149
1533
  );
1150
1534
  }
1535
+ if (editCoveredGates.length) {
1536
+ notes.push(
1537
+ `${editCoveredGates.length} write gate(s) — ${editCoveredGates.join(", ")} — emit \`Edit(path)\` as their only ` +
1538
+ `permission pattern, and that ONE pattern is matched for every file-editing tool. Measured on Claude Code ` +
1539
+ `2.1.240: with \`Edit(path)\` alone in the list and \`Write\` allowed, the host refused the write — and a ` +
1540
+ `\`Write\` to a DIFFERENT path succeeded in the same session, the control that tells *refused* from ` +
1541
+ `*refuses everything*. \`Write(path)\` ` +
1542
+ `and \`NotebookEdit(path)\` are NOT emitted because the host discards them — it says so on every start — and a ` +
1543
+ `compiler that emitted them would report three rules where one enforces. **Re-measure on a host change in EITHER ` +
1544
+ `direction:** this is a fact about one CLI version. If a later host stops treating \`Edit\` as tool-general, ` +
1545
+ `this gate narrows to one tool at the permission layer with nothing here going red. And no EARLIER host was ` +
1546
+ `re-measured — on one that honoured all three patterns and did not treat \`Edit\` as tool-general, this ` +
1547
+ `emission removes two working rules. The hook covers all three tool names on any host.`,
1548
+ );
1549
+ }
1151
1550
  if (shellWriteGates.length) {
1152
1551
  notes.push(
1153
1552
  `${shellWriteGates.length} write gate(s) — ${shellWriteGates.join(", ")} — also match a Bash command that writes the ` +
@@ -1438,6 +1837,11 @@ function artifactPaths(workspaceDir) {
1438
1837
 
1439
1838
  /** Every backend, run against one parsed policy. The order is the order the matrix prints in. */
1440
1839
  export function backends(parsed, options = {}) {
1840
+ // **Provenance rides in the Claude Code artifact only, and that is an honest limit rather than an
1841
+ // oversight.** `githubRuleset` emits to a fixed external schema that has nowhere to carry a
1842
+ // `$portulan` block; its provenance is the one line it can carry, in `name`. Both backends compile
1843
+ // from ONE root plan per run, so the recorded origins describe the ruleset's inputs too — they are
1844
+ // simply not readable in its file. Stated here and in `../.portulan/compile/README.md` (#264).
1441
1845
  return [claudeCode(parsed, options), githubRuleset(parsed, options)];
1442
1846
  }
1443
1847
 
@@ -1467,7 +1871,76 @@ export function matrix(parsed, options = {}) {
1467
1871
  // ===========================================================================================
1468
1872
 
1469
1873
  /**
1470
- * Where a workspace's gate policy lives.
1874
+ * The same answer as `policyPath`, plus WHICH ARM produced it — and, where the manifest named
1875
+ * something unusable, WHY.
1876
+ *
1877
+ * `policyPath` returns a path either way, which is all a reader of the policy needs and is exactly
1878
+ * what made the caller unable to tell two different situations apart: a workspace that NAMES a policy
1879
+ * file which is missing (a genuine error — something is declared and absent), and a workspace that
1880
+ * names none at all (a legitimate shape, per this function's own note below). Both arrived at
1881
+ * `readJson` as `ENOENT` and were reported identically, so the second — documented here as legitimate
1882
+ * — surfaced as `cannot read the gate policy at …/gates.json`, which reads like a corrupt or deleted
1883
+ * file and sends the reader to look for one that was never supposed to exist.
1884
+ *
1885
+ * Found 2026-08-22 while booting the `sleepy-panda` workspace, which declares no `gates` key and
1886
+ * composes a pack contributing two gate rules: the compiler refused with an ENOENT that mentioned
1887
+ * neither fact. Reporting declaredness is what lets the caller say which of the two it is.
1888
+ *
1889
+ * **`declared: false` is four situations, not one, so it carries a `reason`.** The first cut reported
1890
+ * every one of them as "`workspace.json` has no top-level `gates` key", which is false for a manifest
1891
+ * that is absent and false for a `gates` value this function refused — the same two-situations-one-
1892
+ * sentence defect this change exists to fix, reproduced one level down. `reason` is `no-key`,
1893
+ * `no-manifest`, `refused`, or `declared`. Found by Copilot on this pull request.
1894
+ */
1895
+ export function policyDeclaration(workspaceRoot, workspaceDir = ".portulan") {
1896
+ const base = path.join(workspaceRoot, workspaceDir);
1897
+ const manifest = path.join(base, "workspace.json");
1898
+ const fallback = (reason) => ({ file: path.join(workspaceRoot, workspaceDir, "gates.json"), declared: false, reason });
1899
+ let declared;
1900
+ try {
1901
+ declared = JSON.parse(fs.readFileSync(manifest, "utf8")).gates;
1902
+ } catch {
1903
+ // No manifest, or unreadable. `doctor` is the tool that judges a manifest; this one only needs
1904
+ // to know where the policy is, and the default is where it is when nothing says otherwise.
1905
+ return fallback("no-manifest");
1906
+ }
1907
+ if (declared === undefined) return fallback("no-key");
1908
+ // Validated against the schema's `filePath` shape before it is used, and containment is
1909
+ // checked after resolution rather than by pattern alone — a `../` chain passes any regex and
1910
+ // still escapes. Without this, a malformed or hand-edited manifest turns a hook that runs on
1911
+ // EVERY tool call into an arbitrary file read outside the workspace. `doctor` would refuse such
1912
+ // a manifest, but this runner must not depend on `doctor` having been run: the two tools have
1913
+ // no ordering between them, and the schema is the contract, not the sequence. Found by review.
1914
+ //
1915
+ // **`isInside`, not another hand-rolled spelling.** The containment test read
1916
+ // `!path.relative(base, resolved).startsWith("..")` until 2026-08-22 — the spelling
1917
+ // `./inside.mjs` exists to replace, which calls an ordinary directory named `..foo` an escape and
1918
+ // falls back for a policy that was inside all along. This module already imports `isInside` and
1919
+ // already carries the same warning at `recordedOrigin`, so the rule was in scope and unread.
1920
+ // Pre-existing, and surfaced by Copilot on the pull request that touched this line.
1921
+ //
1922
+ // **No count is asserted here.** A first draft called this "the fifth copy"; the pre-commit
1923
+ // checkpoint could not reproduce that figure and a census then found more, including one in THIS
1924
+ // module at `resolveWorkspace`. The census is #331 rather than this commit, because the remaining
1925
+ // sites do not share semantics — they disagree on the empty relative path and on absolute paths —
1926
+ // so each needs its own red and green. An unreproducible number is worse than none.
1927
+ if (typeof declared === "string" && declared.trim() && FILE_PATH.test(declared)) {
1928
+ const resolved = path.resolve(base, declared);
1929
+ if (resolved !== base && isInside(base, resolved)) return { file: resolved, declared: true, reason: "declared" };
1930
+ }
1931
+ // The manifest named something, and nothing it named is usable — a wrong type, an empty string, a
1932
+ // shape the schema refuses, or a path that escapes the workspace. The compiler is on the
1933
+ // conventional path, so the fallback owns the diagnostic; but this is NOT the state of a manifest
1934
+ // that named nothing, and a message conflating the two is the defect this change is about.
1935
+ return fallback("refused");
1936
+ }
1937
+
1938
+ /**
1939
+ * Where a workspace's gate policy lives — the path alone.
1940
+ *
1941
+ * Kept as the narrow answer because most callers want exactly that, and because every existing caller
1942
+ * and test was written against a string. `policyDeclaration` is the one that also says whether the
1943
+ * workspace named it, and why when it did not.
1471
1944
  *
1472
1945
  * Read from the manifest's top-level `gates` key, because that is what the key MEANS —
1473
1946
  * `../spec/slots.md` calls it "a path to a JSON file the enforcement compiler reads", and `doctor`
@@ -1481,26 +1954,73 @@ export function matrix(parsed, options = {}) {
1481
1954
  * refusing it would make the key required, which is a spec change nobody decided.
1482
1955
  */
1483
1956
  export function policyPath(workspaceRoot, workspaceDir = ".portulan") {
1484
- const base = path.join(workspaceRoot, workspaceDir);
1485
- const manifest = path.join(base, "workspace.json");
1957
+ return policyDeclaration(workspaceRoot, workspaceDir).file;
1958
+ }
1959
+
1960
+ /**
1961
+ * What to say when a workspace declares no gate policy and none is there by convention.
1962
+ *
1963
+ * This is a STATE, not a failure to read a file, and the difference is the whole point: `policyPath`'s
1964
+ * note calls an absent `gates` key "a legitimate shape, and refusing it would make the key required,
1965
+ * which is a spec change nobody decided" — and then the reader refused it anyway, with `ENOENT` on a
1966
+ * file the workspace never claimed to have.
1967
+ *
1968
+ * It stays a refusal (exit 2, nothing written), because nothing was compiled and a compiler that
1969
+ * reports success having emitted nothing is the failure this repository keeps writing checks against.
1970
+ * What changes is that the refusal names the actual state and what it costs.
1971
+ *
1972
+ * **The pack count is the load-bearing half.** A workspace in this shape may still compose packs
1973
+ * contributing gate fragments — `sleepy-panda` composes `rituals/checkpoints`, which contributes two
1974
+ * — and those rules silently reach nothing, because a fragment tightens a policy and there is none to
1975
+ * tighten. That is the fact a reader needs and the one the old message could not carry: it threw
1976
+ * before `packContributions` was ever called.
1977
+ */
1978
+ function undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOptions, reason = "no-key") {
1979
+ const manifest = path.join(workspaceRoot, workspaceDir, "workspace.json");
1980
+ // **One opening per arm, because the arm is the reader's first question.** `no-key` is the shape
1981
+ // this whole change defends as legitimate; `no-manifest` is a workspace that has not been
1982
+ // authored yet; `refused` is a manifest that DID name a policy and named one this compiler will
1983
+ // not read, which is neither of the other two and must not be told it has no `gates` key.
1984
+ const opening =
1985
+ reason === "refused"
1986
+ ? `\`workspace.json\` names a gate policy this compiler will not read — its top-level ` +
1987
+ `\`gates\` key is not a relative path to a file inside the workspace — and there is no ` +
1988
+ `\`gates.json\` at ${policyFile} either.`
1989
+ : reason === "no-manifest"
1990
+ ? `this workspace declares no gate policy — there is no readable \`workspace.json\` at ` +
1991
+ `${manifest}, and there is no \`gates.json\` at ${policyFile}.`
1992
+ : `this workspace declares no gate policy — \`workspace.json\` has no top-level \`gates\` key, ` +
1993
+ `and there is no \`gates.json\` at ${policyFile}.`;
1994
+ const lines = [`${opening} Nothing was compiled and nothing was written.`];
1995
+ let composed = null;
1486
1996
  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
- }
1997
+ composed = packContributions(workspaceRoot, workspaceDir, packOptions);
1499
1998
  } 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.
1999
+ // The pack layer has refusals of its own — a shadowed pack, a malformed pack manifest, an
2000
+ // unresolvable root. Swallowed HERE and nowhere else: this message is about the missing policy,
2001
+ // and a pack refusal raised from inside a diagnostic would replace the answer with a different
2002
+ // question. Declare a policy and the very next run surfaces it on its own terms.
1502
2003
  }
1503
- return path.join(workspaceRoot, workspaceDir, "gates.json");
2004
+ const contributions = composed?.contributions ?? [];
2005
+ const rules = contributions.reduce((n, c) => n + (c.fragments?.length ?? 0), 0);
2006
+ if (rules > 0) {
2007
+ const packs = contributions
2008
+ .filter((c) => (c.fragments?.length ?? 0) > 0)
2009
+ .map((c) => `\`${c.pack}\``)
2010
+ .join(", ");
2011
+ lines.push(
2012
+ `${rules} pack-contributed gate rule(s) from ${packs} are therefore NOT compiled: ` +
2013
+ `a fragment tightens a policy, and there is none here to tighten.`,
2014
+ );
2015
+ }
2016
+ lines.push(
2017
+ reason === "refused"
2018
+ ? "Give `gates` a relative path to a file inside the workspace directory, or remove the key " +
2019
+ "and let `gates.json` be found by convention."
2020
+ : "Declare one with `portulan new gate-policy`, or leave it undeclared deliberately — " +
2021
+ "a workspace with no gate policy is a legitimate shape, and this is a state rather than a fault.",
2022
+ );
2023
+ return lines.join("\n ");
1504
2024
  }
1505
2025
 
1506
2026
  // ===========================================================================================
@@ -1626,6 +2146,103 @@ export function rootPlan(workspaceDir, manifest, { named = [], namedGiven = null
1626
2146
  * them. What it depends on instead is `parse`, which validates every composed rule exactly as it
1627
2147
  * validates a hand-written one, so a malformed fragment is refused by the same code either way.
1628
2148
  */
2149
+ /**
2150
+ * How a resolved pack's ORIGIN is recorded in an emitted artifact — a deliberate collapse of the
2151
+ * resolver's three tags into two, and the collapse is the load-bearing part.
2152
+ *
2153
+ * `resolutionRoots` tags roots `named | derived | discovered`. Recording that raw would be wrong in a
2154
+ * way that turns this whole feature into a liability: the PINNED rail spells its root
2155
+ * (`--pack-root packs`, tagged `named`) while a bare run derives the same directory (tagged
2156
+ * `derived`). Two documented-correct spellings of the same world would emit different bytes, and
2157
+ * `verify/compile.sh` would go red on a tree nothing was wrong with — a per-machine false red, which
2158
+ * is worse than the silent hazard #264 is about.
2159
+ *
2160
+ * So: `discovered` iff the answering root came from discovery — **which is a statement about where the
2161
+ * root was FOUND, not about where it sits.** Discovery reads a record under `CLAUDE_CONFIG_DIR`, and a
2162
+ * hermetic run can point that inside the tree, so a discovered root is not necessarily an external one;
2163
+ * the docblock said "a directory outside this repository" until review caught the conflation, and the
2164
+ * distinction is this field's whole subject rather than a quibble — it is why `outside-tree` exists as
2165
+ * a separate answer below. Otherwise `tree`, when the root is one the repository itself yields. A NAMED root that is not under the workspace root is neither, and is recorded as
2166
+ * `outside-tree` rather than flattened into `tree`, because calling somebody's `--pack-root /elsewhere`
2167
+ * "the tree" would be this field's first lie.
2168
+ */
2169
+ export function recordedOrigin(root, plan, workspaceRoot) {
2170
+ const same = (a, b) => path.resolve(a) === path.resolve(b);
2171
+ const tagged = (plan?.origins ?? []).find((o) => same(o.root, root));
2172
+ if (tagged?.origin === "discovered") return "discovered";
2173
+ // **`derived` and `named` take the SAME inside-the-repository test**, and the first draft trusted
2174
+ // `derived` on its tag alone. It is derived from the manifest's `tree`, which is a string a human
2175
+ // edits: `tree: "../../elsewhere"` resolves a derived root of `/elsewhere/packs` — measured — and
2176
+ // that would have been recorded as `tree`. A tag says where a root CAME FROM; only the path says
2177
+ // whether it is in this repository, and this field's whole job is the second question. Copilot.
2178
+ //
2179
+ // Inside INCLUDES being the repository itself, which the first draft called `outside-tree` on a
2180
+ // `rel` of `""`. By this function's own standard that was the field's first lie.
2181
+ //
2182
+ // **Both sides resolved through `realpathSync`**, because a lexical comparison makes two spellings
2183
+ // of one directory disagree: `--workspace <alias> --pack-root <realpath>` recorded `outside-tree`
2184
+ // for a root plainly inside the tree, which would emit a different artifact for an identical world
2185
+ // — the per-machine false red this whole design refuses. Measured. It is the same trap #220's
2186
+ // second arm repaired in the Stop-gate's divergence sentence, and it is worth stating twice
2187
+ // because the two sites were written a day apart and neither inherits the other's guard.
2188
+ const real = (dir) => {
2189
+ try {
2190
+ return fs.realpathSync(path.resolve(dir));
2191
+ } catch {
2192
+ return path.resolve(dir);
2193
+ }
2194
+ };
2195
+ // **`isInside`, not a fourth hand-rolled spelling.** `!rel.startsWith("..")` calls a directory
2196
+ // literally named `..foo` outside the tree — and this repository has already paid for that exact
2197
+ // reasoning once: `./index.mjs`'s docblock records it as the ninth fail-open found in this
2198
+ // scaffolding, and *the first one written by the change that cites the class*. This was the second.
2199
+ // The helper is exported precisely so a third copy cannot drift into it. Copilot.
2200
+ return isInside(real(workspaceRoot), real(root)) ? "tree" : "outside-tree";
2201
+ }
2202
+
2203
+ /**
2204
+ * Two copies of one pack answered, and this says what differs between them.
2205
+ *
2206
+ * **One carrier, because the comparison has already been wrong twice.** `doctor` grew this first and
2207
+ * its history is the argument for not writing a fourth: a first spelling projected `[id, tier, action]`
2208
+ * and so read a copy differing in `reason` as agreeing — while `composeFragments` pushes the WHOLE
2209
+ * fragment, so those differences do reach the compiled policy; a second compared the whole fragment
2210
+ * but claimed *byte-identical*, which `JSON.stringify` of a parsed value cannot promise. This is that
2211
+ * third, correct version, moved here so `doctor` and `compile` cannot drift apart about what a shadow
2212
+ * IS — proposal `0020`'s rule, applied to a function rather than to a sentence.
2213
+ *
2214
+ * Parsed and key-order-normalised is the RIGHT comparison: reformatting a `pack.json` changes nothing
2215
+ * about what the pack contributes, so reporting it would be noise.
2216
+ */
2217
+ export function packDifferences(mine, other) {
2218
+ const canonical = (v) =>
2219
+ Array.isArray(v)
2220
+ ? v.map(canonical)
2221
+ : v && typeof v === "object"
2222
+ ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical(v[k])]))
2223
+ : v;
2224
+ const frag = (m) => JSON.stringify(canonical(m?.contributes?.gates ?? []));
2225
+ const mineV = mine?.portulan?.version ?? "no version";
2226
+ const otherV = other?.portulan?.version ?? "no version";
2227
+ const differs = [];
2228
+ if (mineV !== otherV) differs.push(`version ${mineV} against the tree's ${otherV}`);
2229
+ if (frag(mine) !== frag(other)) differs.push("gate fragments that differ once parsed");
2230
+ return differs;
2231
+ }
2232
+
2233
+ /**
2234
+ * The non-discovered copy of a pack that a discovered root answered for — or `null` when there is no
2235
+ * shadow. `originOf` maps a root to its tag; pass `plan.origins`' lookup.
2236
+ *
2237
+ * Only a **discovered** answer can be shadowed in the sense that matters: a named root replaces the
2238
+ * derived one, so nothing is behind it, and a derived answer is the tree's own copy already.
2239
+ */
2240
+ export function shadowedCopy(name, answeringOrigin, roots, originOf) {
2241
+ if (answeringOrigin !== "discovered") return null;
2242
+ const behind = resolvePack(name, roots.filter((r) => originOf(r) !== "discovered"));
2243
+ return behind.dir ? behind : null;
2244
+ }
2245
+
1629
2246
  export function packContributions(workspaceRoot, workspaceDir = ".portulan", options = {}) {
1630
2247
  const base = path.join(workspaceRoot, workspaceDir);
1631
2248
  let manifest;
@@ -1666,6 +2283,83 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
1666
2283
  continue;
1667
2284
  }
1668
2285
  const packManifest = readJson(found.manifest, `the pack manifest for \`${found.name}\``);
2286
+ // **A shadowed pack is a could-not-run, not a resolution** (#316). Two roots answered for one
2287
+ // declared name and the invocation does not say which the caller meant, so this compiler has
2288
+ // no honest way to pick — and unlike `doctor`, which reports and never grades because an
2289
+ // install state is a fact about a machine rather than a verdict about the repository, this one
2290
+ // does not report: it EMITS. Picking silently wrote the discovered copy's fragments into the
2291
+ // artifact while `verify/compile.sh` read the tree's, which on this project's own host meant a
2292
+ // `git commit --no-verify` matcher that the tree had deliberately removed as false coverage.
2293
+ //
2294
+ // **Refused at RESOLUTION, so `--check` and the write path inherit one rule.** Refusing only
2295
+ // on write would make one tool answer one ambiguity two ways: the check would go on adopting
2296
+ // the discovered world and exit 1 — asserting that the *repository* drifted when it had not.
2297
+ // An exit code is the machine-read API, and a false 1 is a false verdict about the tree.
2298
+ //
2299
+ // **Every unasked shadow refuses, including one whose manifests agree**, which reads as
2300
+ // over-strict until you follow the bytes: `recordedOrigin` tags the answering root into
2301
+ // `$portulan.packs[].origin`, so a discovered answer emits `discovered` where the rail's
2302
+ // artifact says `tree`. Agreement in the manifests is therefore not agreement in the artifact,
2303
+ // and a carve-out for it would ship a compile that still reds the recipe it was meant to
2304
+ // reconcile with. The message distinguishes the two cases; the refusal does not.
2305
+ //
2306
+ // **Only where the caller did not choose.** `--pack-root auto` is discovery ELECTED, and
2307
+ // electing it is the choice this refusal exists to ask for. Both spellings are offered in the
2308
+ // message, because naming only the tree would make the refusal decide the question it claims
2309
+ // to be handing back.
2310
+ //
2311
+ // **The `named` half of this guard is defence in depth, and that was measured rather than
2312
+ // assumed.** Forcing the condition to `true` — refuse unconditionally — leaves the named-root
2313
+ // case still passing: a named root REPLACES the derived one, so the plan carries no discovered
2314
+ // root, `shadowedCopy` finds nothing behind, and there is no shadow to refuse. The protection
2315
+ // is structural. The clause stays because it states the intent at the site where a future
2316
+ // change to `resolutionRoots` could quietly make it load-bearing.
2317
+ if (!(options.forced ?? false) && plan.source !== "named") {
2318
+ const behind = shadowedCopy(found.name, recordedOrigin(found.root, plan, workspaceRoot), roots, (r) =>
2319
+ (plan.origins ?? []).find((o) => path.resolve(o.root) === path.resolve(r))?.origin,
2320
+ );
2321
+ if (behind) {
2322
+ let other = null;
2323
+ try {
2324
+ other = JSON.parse(fs.readFileSync(behind.manifest, "utf8"));
2325
+ } catch (cause) {
2326
+ throw new CompileError(
2327
+ `\`${found.name}\` resolved under the root ${found.root} while the root ` +
2328
+ `${path.relative(workspaceRoot, behind.root)} also carries it, ` +
2329
+ `and that second copy could not be read (${cause.message}) — so which one this would compile from could not be established. ` +
2330
+ "Name the root: `--pack-root packs` compiles from the tree, `--pack-root auto` from the installed copy.",
2331
+ );
2332
+ }
2333
+ const differs = packDifferences(packManifest, other);
2334
+ // **Both ROOTS by path — the roots, not the pack directories.** `resolvePack` returns
2335
+ // both, and a first cut printed `dir` (`<root>/<category>/<pack>`) while calling it a
2336
+ // root: a diagnostic that mislabels the thing it names, in a message whose own claim is
2337
+ // to name both roots. The root is also the more useful of the two, because it is what a
2338
+ // reader types back into `--pack-root`. A refusal naming only one hands back a choice
2339
+ // while withholding half of what it is between. Printing the absolute path is safe in a
2340
+ // way it is not in `$portulan.packs`: this is stderr, read once by a human, never a
2341
+ // tracked artifact, so #264's origins-never-paths rule (which exists to stop a
2342
+ // machine-dependent path being committed) does not reach it. `doctor` already prints it
2343
+ // in its resolves-from note, so the two tools now name the same two directories.
2344
+ //
2345
+ // **"Discovered on this host", never "outside this repository".** Discovery reads a
2346
+ // record under `CLAUDE_CONFIG_DIR`, which a hermetic run can point INSIDE the tree — so
2347
+ // a discovered root is not necessarily an external one, and the message must not assert
2348
+ // a location it has not tested. `recordedOrigin` keeps `discovered` and `outside-tree`
2349
+ // as separate answers for exactly this reason: where a root came from and where it sits
2350
+ // are different questions, and the path is printed right there for a reader who wants
2351
+ // the second one. Caught by review after the first cut conflated them.
2352
+ throw new CompileError(
2353
+ `\`${found.name}\` is SHADOWED — it resolved under ${found.root}, a root discovered on this ` +
2354
+ `host, while the root ${path.relative(workspaceRoot, behind.root)} also carries it. ` +
2355
+ (differs.length
2356
+ ? `They differ by ${differs.join(" and ")}, so the two roots compile to different policies. `
2357
+ : "Their manifests agree, but the emitted artifact still records which root answered, so the two roots compile to different bytes. ") +
2358
+ "Refusing to pick: name the root instead — `--pack-root packs` compiles from the tree, which is what " +
2359
+ "`verify/compile.sh` checks, and `--pack-root auto` compiles from the installed copy.",
2360
+ );
2361
+ }
2362
+ }
1669
2363
  const fragments = packManifest?.contributes?.gates;
1670
2364
  // Checked here rather than left to `composeFragments` iterating it: a manifest is a file a
1671
2365
  // human edits, `doctor` may not have run (the two tools have no ordering between them), and a
@@ -1678,7 +2372,18 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
1678
2372
  `Refusing to compose it — run \`doctor\` to validate the pack against the Pack Definition.`,
1679
2373
  );
1680
2374
  }
1681
- contributions.push({ pack: found.name, dir: found.dir, fragments: fragments ?? [] });
2375
+ contributions.push({
2376
+ pack: found.name,
2377
+ dir: found.dir,
2378
+ fragments: fragments ?? [],
2379
+ // **Provenance, recorded per pack**, so the artifact this compiles into can say what
2380
+ // compiled it (#264). `version` comes from `portulan.version` — the pack manifest's own
2381
+ // location for it, not a top-level `version` — and its ABSENCE is recorded as such rather
2382
+ // than as a blank, because "this pack declares no version" and "I did not look" are
2383
+ // different facts and only one of them is the pack's.
2384
+ origin: recordedOrigin(found.root, plan, workspaceRoot),
2385
+ version: typeof packManifest?.portulan?.version === "string" ? packManifest.portulan.version : null,
2386
+ });
1682
2387
  }
1683
2388
  // `plan` travels with the result because every consumer prints it: which source won, and the path
1684
2389
  // it won with, is what tells a feed resolution from a local one.
@@ -2068,7 +2773,25 @@ export function run(argv, options = {}) {
2068
2773
  // The workspace may be named as a repository root or as the workspace directory itself, and the
2069
2774
  // second is how a feed-side workspace is reachable at all — see `resolveWorkspace`.
2070
2775
  const { workspaceRoot, workspaceDir } = resolveWorkspace(named);
2071
- const policyFile = policyPath(workspaceRoot, workspaceDir);
2776
+ const { file: policyFile, declared: policyDeclared, reason: policyReason } = policyDeclaration(workspaceRoot, workspaceDir);
2777
+ // **Declared-and-missing and never-declared are different answers.** Only the first is a
2778
+ // failure to read something this workspace claimed to have; the second is a shape `policyPath`
2779
+ // documents as legitimate, and reporting it as `ENOENT` sent readers hunting for a deleted
2780
+ // file. Checked here rather than inside the reader because only the caller knows to name what
2781
+ // the absence costs — see `undeclaredPolicyMessage`. The fallback arm is itself FOUR states,
2782
+ // and `policyReason` is what lets the message name the one it is in rather than assert the
2783
+ // commonest: this comment read "two different answers" while the code below had four.
2784
+ if (!policyDeclared && !fs.existsSync(policyFile)) {
2785
+ throw new CompileError(
2786
+ undeclaredPolicyMessage(
2787
+ policyFile,
2788
+ workspaceRoot,
2789
+ workspaceDir,
2790
+ { named: namedRoots, discovery: () => discoverPackRoots(), forced },
2791
+ policyReason,
2792
+ ),
2793
+ );
2794
+ }
2072
2795
  const policy = readJson(policyFile, "the gate policy");
2073
2796
  // The cascade's middle layer, composed before the policy is parsed so that a pack's fragment
2074
2797
  // is validated by exactly the code that validates a hand-written rule.
@@ -2106,7 +2829,14 @@ export function run(argv, options = {}) {
2106
2829
  // fails open, silently. That is the exact defect class this whole change exists to close,
2107
2830
  // reintroduced through the cross-compile path. Found by the pre-commit checkpoint, which
2108
2831
  // demonstrated it rather than reasoning about it.
2109
- const columns = backends(parsed, { source, root: path.resolve(workspaceRoot), workspaceDir });
2832
+ const columns = backends(parsed, {
2833
+ source,
2834
+ root: path.resolve(workspaceRoot),
2835
+ workspaceDir,
2836
+ // What resolved each pack, carried to the artifact so the file the rail compares says
2837
+ // which world compiled it (#264).
2838
+ packProvenance: contributions,
2839
+ });
2110
2840
 
2111
2841
  // Printed before the backends, because a rule's provenance changes how its compiled line reads
2112
2842
  // — and an unresolvable pack is a declaration the workspace believes it composed.
@@ -2162,7 +2892,34 @@ export function run(argv, options = {}) {
2162
2892
  continue;
2163
2893
  }
2164
2894
  if (current !== column.artifact.text) {
2165
- say(`RED — ${file} has drifted from ${policyFile}. Recompile.`);
2895
+ // **Name the origin difference AT THE MOMENT OF FAILURE** (#264). A drift caused by
2896
+ // a shadowed pack was previously reported as a diff nobody could reproduce from the
2897
+ // files in front of them, because the deciding input was a directory outside the
2898
+ // repository — and the remedy the sentence prescribed, "Recompile", is the very
2899
+ // unpinned act that caused it. Read DEFENSIVELY: the artifact on disk is a file a
2900
+ // human may have edited, so anything unparseable leaves the plain sentence standing
2901
+ // rather than turning a drift report into a crash.
2902
+ let why = "";
2903
+ try {
2904
+ const onDisk = JSON.parse(current)?.$portulan?.packs;
2905
+ const mine = JSON.parse(column.artifact.text)?.$portulan?.packs;
2906
+ if (Array.isArray(onDisk) && Array.isArray(mine)) {
2907
+ const key = (p) => `${p.origin ?? "?"} ${p.version ?? "no version"}`;
2908
+ const byName = new Map(mine.map((p) => [p.pack, p]));
2909
+ const moved = onDisk
2910
+ .filter((p) => byName.has(p.pack) && key(byName.get(p.pack)) !== key(p))
2911
+ .map((p) => `\`${p.pack}\` was compiled from the ${key(p)} copy; this check reads the ${key(byName.get(p.pack))} one`);
2912
+ if (moved.length) {
2913
+ why =
2914
+ `\n ${moved.join("\n ")}` +
2915
+ "\n That is a different world, not a stale file: recompile with the root this check uses" +
2916
+ `\n — \`node cli/compile.mjs --workspace . --pack-root packs\` — rather than bare, or the drift returns.`;
2917
+ }
2918
+ }
2919
+ } catch {
2920
+ // Unparseable or hand-edited. The plain sentence below is still true.
2921
+ }
2922
+ say(`RED — ${file} has drifted from ${policyFile}. Recompile.${why}`);
2166
2923
  drifted += 1;
2167
2924
  }
2168
2925
  }