@sleepy_panda_srl/portulan 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +6 -5
  2. package/cli/README.md +142 -358
  3. package/cli/advisory.mjs +354 -0
  4. package/cli/comments.mjs +932 -0
  5. package/cli/compile.mjs +1322 -1607
  6. package/cli/context.mjs +883 -0
  7. package/cli/control-chars.mjs +12 -301
  8. package/cli/discover.mjs +17 -574
  9. package/cli/doctor.mjs +224 -1074
  10. package/cli/drills.mjs +68 -502
  11. package/cli/feedback.mjs +16 -237
  12. package/cli/finish.mjs +437 -0
  13. package/cli/form.mjs +644 -0
  14. package/cli/fuzz-shell.mjs +20 -305
  15. package/cli/gate.mjs +7 -199
  16. package/cli/goldens.mjs +26 -184
  17. package/cli/index.mjs +305 -655
  18. package/cli/init.mjs +243 -566
  19. package/cli/inside.mjs +3 -28
  20. package/cli/instructions.mjs +829 -0
  21. package/cli/ledger.mjs +778 -0
  22. package/cli/librarian.mjs +135 -599
  23. package/cli/manifest.mjs +2 -39
  24. package/cli/mutants.mjs +9 -170
  25. package/cli/new.mjs +12 -243
  26. package/cli/pack-identity.mjs +5 -74
  27. package/cli/pack-version.mjs +13 -255
  28. package/cli/plugin-lint.mjs +42 -658
  29. package/cli/portulan.mjs +5 -124
  30. package/cli/recipe-set.mjs +15 -245
  31. package/cli/release-eval.mjs +28 -348
  32. package/cli/review-meter.mjs +17 -258
  33. package/cli/rule-carriers.mjs +12 -175
  34. package/cli/sessions.mjs +82 -0
  35. package/cli/skill-goldens.mjs +10 -176
  36. package/cli/skills-set.mjs +12 -414
  37. package/cli/stop-gate.mjs +83 -514
  38. package/cli/symbols.mjs +1132 -0
  39. package/cli/telemetry.mjs +27 -492
  40. package/cli/upgrade.mjs +171 -314
  41. package/cli/vendor.mjs +146 -476
  42. package/cli/version-carriers.mjs +8 -86
  43. package/core/README.md +1 -1
  44. package/core/engine.md +3 -9
  45. package/core/operating/autonomy.md +1 -1
  46. package/core/operating/context.md +183 -0
  47. package/core/operating/evolution.md +3 -3
  48. package/core/operating/loop.md +13 -20
  49. package/core/operating/memory.md +12 -3
  50. package/core/operating/sessions.md +100 -0
  51. package/core/personas/librarian.md +2 -2
  52. package/core/skills/consolidate/SKILL.md +4 -2
  53. package/core/templates/handoff.md +28 -15
  54. package/core/templates/memory-entry.md +1 -0
  55. package/evals/README.md +255 -845
  56. package/evals/releases/0.2.0.json +146 -0
  57. package/evals/releases/0.2.0.md +67 -0
  58. package/evals/releases/README.md +3 -3
  59. package/package.json +4 -2
  60. package/packs/rituals/checkpoints/README.md +1 -1
  61. package/packs/tools/github/verify/actions-pinned.sh +10 -55
  62. package/spec/README.md +112 -6
  63. package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
  64. package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
  65. package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
  66. package/spec/migrations/0004-changelog-fragments.mjs +53 -0
  67. package/spec/migrations/0005-session-log-retired.mjs +69 -0
  68. package/spec/migrations/0006-boot-card.mjs +77 -0
  69. package/spec/migrations/0007-guidance-compiled.mjs +31 -0
  70. package/spec/migrations/0008-card-reading.mjs +55 -0
  71. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
  72. package/spec/migrations/0010-comment-rail.mjs +57 -0
  73. package/spec/migrations/README.md +45 -10
  74. package/spec/pack.schema.json +1 -1
  75. package/spec/slots.md +333 -21
  76. package/spec/workspace.schema.json +145 -4
package/cli/gate.mjs CHANGED
@@ -1,185 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
  // The PreToolUse gate runner — the *explanation* half of the enforcement compiler.
3
3
  //
4
- // Wired by `./compile.mjs` into `.claude/settings.json`. It reads the host's hook payload on
5
- // stdin, finds which rule of the policy the workspace **yields** the attempted action matches, and
6
- // returns that rule's own sentence as the decision reason. *Yields*, not *declares*: the rules in
7
- // `.portulan/gates.json` plus the fragments its packs contribute — see "The policy this runner reads"
8
- // below, which is the whole of what `#269` was.
9
- //
10
- // ## This file is not the gate. Read that twice.
11
- //
12
- // The gate is the `permissions` rule the same compiler emits. This runner exists because a bare
13
- // "permission denied" teaches nobody anything, while `gates.json` already holds a sentence written
14
- // for exactly this moment. The split is forced by a measurement, not by taste: on CLI 2.1.220 a
15
- // PreToolUse hook that CRASHES **fails open** — the tool proceeds — on the identical wiring that
16
- // blocks when the hook is healthy. So a hook can never be the load-bearing layer here, and this one
17
- // is written to fail open *deliberately and visibly* rather than pretending otherwise:
18
- //
19
- // any internal error => exit 0, emit nothing => the permission rule governs, unchanged.
20
- //
21
- // Refusing loudly on error *is* available — a hook exiting 2 blocks — and it is still wrong here, for a
22
- // reason worth stating precisely rather than with the hand-wave an earlier draft used. If this runner
23
- // failed closed, a malformed `.portulan/gates.json` would deny EVERY matched tool call until someone fixed it:
24
- // an undriveable session, whose only repair is inside the repository it can no longer edit. What is lost
25
- // by stepping aside is bounded and known — the wrapper coverage and the sentences — because the
26
- // permission layer never reads that file at runtime.
27
- //
28
- // ## Why the decision mirrors the permission rule
29
- //
30
- // `prohibited` => `deny`, `gated` => `ask`. The hook returns the SAME decision the permission rule
31
- // carries. A hook returning `deny` for a Gated action would convert a per-action prompt into a hard
32
- // block — that is the tier above it, and collapsing the two would make the constitution's
33
- // protection indistinguishable from an ordinary push.
34
- //
35
- // ## What this layer is FOR, which is not what its author first assumed
36
- //
37
- // The first version of this runner existed to supply a better sentence than "permission denied".
38
- // Measured, that was wrong: when a permission rule matches, **the host runs this hook and then
39
- // discards its reason** — the permission layer's generic message is what the agent sees. Verified
40
- // with a canary that recorded both the invocation and the command it saw. A hook emitted purely to
41
- // improve a message it cannot reach would be an inert component that reads as an active one, which
42
- // is the defect this repository already has a rule about.
43
- //
44
- // So the split earns its keep somewhere else: **this layer covers what the permission pattern
45
- // cannot.** Two things, and in both the permission layer has nothing to say, so this decision AND
46
- // its sentence are what the agent gets:
47
- //
48
- // 1. The WRAPPER spelling. `Bash(git push:*)` is a prefix match against the literal command, so
49
- // `bash -c "git push …"` is invisible to it — measured, and the reason
50
- // `../core/operating/autonomy.md` calls the platform floor the gate that holds when this
51
- // layer fails. This runner unwraps one level of `sh -c` / `bash -c` / `zsh -c` before matching.
52
- // 2. A SHELL WRITE to a path a `write:` rule protects. `Edit(./docs/vision.md)` denies all three
53
- // file-editing tools — ONE emitted pattern, which the host matches for every one of them — and
54
- // `echo x >> docs/vision.md` is a fourth way to the same bytes. `matchesRule` now
55
- // answers for `Bash` on a write rule, by a table of redirections and file-writing commands
56
- // that `./compile.mjs` states in full.
57
- //
58
- // Two layers, two jobs: the permission rule cannot fail open, and this one covers more ground.
59
- // **The second case above is the uncomfortable one**, and it is named in `../gate-map.md`'s
60
- // honest-holes list rather than left here: it is the only gate whose sole layer is this file, so
61
- // every "fails open" sentence above is, for shell writes to the constitution, the whole story
62
- // rather than a footnote about a lost message.
63
- //
64
- // **One level of unwrapping, and no more.** Deeper nesting, a heredoc, an interpolated variable, a
65
- // command assembled at runtime — all still escape, and no amount of parsing here would close that.
66
- // The same is true one layer over: a write spelled through a runtime (`python3 -c`) or a writer
67
- // outside that table reaches the file. Anything that must not happen regardless of spelling belongs
68
- // on the platform floor, not in a matcher.
4
+ // On Claude Code 2.1.220 a PreToolUse hook that crashes fails open, so the compiled permission rule is the gate.
5
+ // Any internal error exits 0 with no decision, so the permission rule governs unchanged.
6
+ // It covers what a permission pattern cannot: one level of `sh -c` wrapping, and a shell write to a path a `write:` rule protects.
69
7
 
70
8
  import fs from "node:fs";
71
9
  import path from "node:path";
72
10
  import { fileURLToPath } from "node:url";
73
11
 
74
- // The action vocabulary is defined ONCE, in the compiler, and imported here. Two implementations of
75
- // one matcher is the drift this repository keeps finding — and a matcher that drifts does not look
76
- // wrong, it looks like a gate that quietly stopped covering something.
77
- //
78
- // **The same argument reaches one level up, and for a milestone it was applied only to the matcher.**
79
- // Importing `matchesRule` kept the two layers agreeing about what an action *is* while they disagreed
80
- // about which rules *exist*: this file read the workspace's declared policy, and `./compile.mjs`
81
- // enforced that policy plus its packs' contributions. `packContributions` and `composeFragments` are
82
- // imported for the same reason as the matcher — a second composer would drift the same way — and
83
- // `doctor` took this identical repair for this identical noun one session earlier.
84
12
  import { matchesRule, policyPath, packContributions, composeFragments, parse } from "./compile.mjs";
85
13
 
86
- // **The project root is TOLD to this runner, never derived from where this file sits.**
87
- //
88
- // It used to be derived: this file lived at `.portulan/compile/gate.mjs`, so `HERE/../..` was the
89
- // repository and `basename(HERE/..)` was the workspace directory. That worked for exactly one layout —
90
- // the author's — and it is why the file could not ship. Milestone 7 moves both runners into `cli/` so an
91
- // adopter actually receives them (`package.json`'s `files` never carried `.portulan/`, so every compiled
92
- // policy named a file the adopter did not have, and a missing hook fails open). From `cli/`, and even
93
- // more so from `node_modules/@sleepy_panda_srl/portulan/cli/`, this file has no idea where the
94
- // adopter's workspace is — and inferring one would be #131's class, paths resolved against the author's
95
- // layout, in the tool with the most to lose from it.
96
- //
97
- // `CLAUDE_PROJECT_DIR` is what the host sets and what the emitted hook already interpolates; `cwd` is the
98
- // honest fallback, because a hook runs from the project. `||` rather than `??` on purpose: an env var set
99
- // to the empty string must fall through to `cwd`, not resolve every path against `""`.
100
- //
101
- // **What happens when no workspace is found differs between the two runners, and the first draft of this
102
- // paragraph claimed the stricter behaviour for both.** THIS file steps aside silently — `main()` catches
103
- // and returns without a decision — which is its own defended design: a PreToolUse hook that cannot read
104
- // the policy must not block every tool call, and the permission rules compiled beside it still hold, so
105
- // the layer degrades rather than disappearing. `./stop-gate.mjs` does the opposite and blocks loudly,
106
- // because a Stop-gate that cannot read the workspace has nothing beneath it. Stated per file rather than
107
- // as one sentence covering both, since they genuinely differ and the difference is the interesting part.
14
+ // `||` so an empty CLAUDE_PROJECT_DIR falls back to cwd, the directory the host runs a hook from.
108
15
  const PROJECT = process.env.CLAUDE_PROJECT_DIR || process.cwd();
109
16
  const WORKSPACE_DIR = process.env.PORTULAN_WORKSPACE || ".portulan";
110
17
  const POLICY = policyPath(PROJECT, WORKSPACE_DIR);
111
18
 
112
- /** Exit without a decision. The permission rule still holds; only the sentence is lost. */
113
19
  function stepAside() {
114
20
  process.exit(0);
115
21
  }
116
22
 
117
- // ## The policy this runner reads
118
- //
119
- // **Declared plus composed, and until #269 it was declared alone.** A pack contributes gate fragments
120
- // (`contributes.gates` in its `pack.json`); `./compile.mjs` composes them onto the workspace's own rules
121
- // before it emits anything, so the permission rules sitting beside this hook are compiled from the
122
- // composed set. This file read `.portulan/gates.json` and nothing else, so a rule a pack contributed was
123
- // enforced by the layer that cannot say why and invisible to the layer whose whole job is the sentence —
124
- // and for a `prohibited` fragment with a matcher, the two layers returned different decisions.
125
- //
126
- // **What this cost on THIS repository is nothing, and saying so is the point.** Its one gate-contributing
127
- // pack (`rituals/checkpoints`) contributes two fragments and both carry `action: {none: …}`, which no
128
- // matcher can reach — `compile --matrix` lists them among the gates no backend compiles. So the defect
129
- // here was latent rather than live, and it is fixed on the shape rather than on an incident. The demonstration
130
- // in `./gate.test.mjs` uses a fixture whose pack contributes a *matchable* fragment, because manufacturing
131
- // a live case would have meant giving a `none` rule a matcher — reversing the ruling recorded in
132
- // `../.portulan/proposals/0029-a-constraint-names-a-category-not-a-list.md` Q3, which removed one.
133
- //
134
- // **Three limits, named rather than left to be measured.** _(This said "Two" while enumerating three:
135
- // the third arrived with the round-1 fix below and the count above it did not move. Exactly the
136
- // undercount-by-enumeration `../.portulan/gate-map.md`'s own `action none` row was corrected for on
137
- // 2026-08-14, repeated here within the week. Found by Copilot on #272, round 2.)_
138
- //
139
- // The three share one principle, and stating it is what keeps a later reader from deriving the wrong
140
- // rule from the pair: **this runner degrades toward whatever intact policy remains — composed to
141
- // declared, declared to nothing — and never toward a block.** It is never the load-bearing layer, so
142
- // every arm below hands the decision back rather than taking it.
143
- //
144
- // 1. **No discovery.** `packContributions` is called with no `discovery` thunk, so `resolutionRoots`'
145
- // unasked arm never consults the host's plugin cache and roots come from the workspace manifest's
146
- // `tree` alone. Deliberate twice over: this runs on every tool call, and a gate whose answer moved
147
- // with what is installed on the machine would be a gate nobody could review. A pack resolving ONLY
148
- // from the host cache therefore contributes to what a bare `compile` yields and not to what this
149
- // hook composes — the residual gap, stated in `../.portulan/gate-map.md` and owned by `#264`.
150
- // 2. **A composition failure falls back to the DECLARED policy**, and does not step aside. Composition
151
- // only ever adds a rule or raises a tier — `composeFragments` throws on a demotion rather than
152
- // applying it — so the composed policy is never weaker than the declared one, and falling back can
153
- // only lose coverage this file already had rather than drop below it. Stepping aside instead would
154
- // let a malformed `pack.json` switch off gates the workspace declares in its own file, which hands a
155
- // dependency a lever on the layer above it. `./compile.mjs` refuses to build in the same case, and
156
- // the divergence is the intended one: it fails closed at build time against a file you can edit,
157
- // while this runs on every tool call.
158
- // 3. **The composed policy is VALIDATED, by the same `parse` the compiler validates with.** Composition
159
- // checks a fragment's *tier* and, when tightening, that the action shape is unchanged; it does not
160
- // check that an ADDED fragment is a well-formed rule. So a pack contributing
161
- // `{id, tier: prohibited, action: {shell: curl}}` and no `reason` composed cleanly and this hook
162
- // denied with the sentence `— undefined`, where `compile` refuses the same input at exit 2 with
163
- // *"rule `no-reason` carries no reason — a gate with no sentence to show a human is not finished"*.
164
- // Measured, on the runner. The two layers disagreeing about whether a policy is admissible is this
165
- // file's own defect one step further in. Raised by Copilot on #272, round 1, in the suppressed
166
- // channel. **The limit, since the fix is narrower than it reads:** this covers the COMPOSED arm. A
167
- // rule missing its `reason` in the *declared* policy still denies with `— undefined`, byte-identically
168
- // with every predecessor — because the fallback for an inadmissible declared policy is that same
169
- // policy, and stepping aside there would drop a live prohibition over a missing string. `compile`
170
- // refuses that shape at build time, which is where it belongs. Pinned in `./gate.test.mjs` so the
171
- // arm cannot be quietly converted to a step-aside.
172
- //
173
- // **Validation only — the decision is still made on the composed policy, not on `parse`'s return.**
174
- // Not because the parsed shape would not match: measured, `parse` returns rules carrying the SAME
175
- // `action` object beside the added `kind`/`target`, and `matchesRule` answers correctly on them —
176
- // a draft of this comment claimed otherwise and was wrong. The two real grounds: `parse` **refuses
177
- // rather than normalises**, so once it passes, the composed and parsed forms are identical for
178
- // matching and deciding on the parsed one buys nothing; and the fallback path has no parsed form to
179
- // offer — an inadmissible policy produces none — so deciding on `parse`'s return would hand
180
- // `decide()` two different shapes on its two paths, importing a representational seam rather than
181
- // closing one. **If `parse` ever stops refusing and starts normalising, that change must sweep this
182
- // hook too.** The check costs 0.003 ms against a ~30 ms invocation, 2000 iterations, so it is free.
23
+ // No `discovery` option: packs resolve from the manifest's `tree` alone, never from the host's plugin cache.
24
+ // Falls back to the declared policy when composing or `parse` fails: a broken pack cannot switch off the workspace's gates.
183
25
  function yielded(declared) {
184
26
  try {
185
27
  const { contributions } = packContributions(PROJECT, WORKSPACE_DIR);
@@ -191,41 +33,7 @@ function yielded(declared) {
191
33
  }
192
34
  }
193
35
 
194
- /**
195
- * The rule this call answers to: the STRONGEST gate it falls under, not the first one listed.
196
- *
197
- * This returned the first match for one milestone, which is not the same rule whenever two of different
198
- * tiers match one call: the weaker one wins if it is listed higher. The shape is ordinary rather than
199
- * exotic — one dangerous spelling prohibited beneath a prefix the workspace already gates broadly,
200
- * `git push` at `gated` and `git push --mirror` at `prohibited` — and it lands on `ask` for an action the
201
- * policy prohibits.
202
- *
203
- * **Composition makes that systematic rather than causing it, and a draft of this paragraph had it the
204
- * other way round** — *"indistinguishable from the strongest for as long as the policy came from one
205
- * file"*. Measured on a workspace composing nothing, with those two rules in that order: the old scan
206
- * answered `ask`, this one answers `deny`. What composition adds is that a contributed rule can never
207
- * win the tie-break, because `composeFragments` APPENDS added rules after the workspace's own — so a
208
- * pack-contributed `prohibited` rule is always the later one and always the one first-match discards.
209
- * That is #269's case reached through the ordering rather than through the missing rules, and closing
210
- * only the missing-rules half would have left it.
211
- *
212
- * Grounded in the policy's own semantics rather than in host precedence between two matching patterns,
213
- * which this repository has never measured and which nothing here needs: `prohibited` means the action
214
- * must not happen, so answering `ask` on a call the policy prohibits is wrong on the policy's terms
215
- * whatever the host does with the compiled pair.
216
- *
217
- * **It only ever moves a decision UP, and that is the honest form of the guarantee.** `prohibited` is the
218
- * top of the tier order and `gated` the only other tier considered, so "strongest, ties to the first" is
219
- * exactly *the first `prohibited` match if there is one, otherwise the first `gated` match* — which is
220
- * what the loop below is. Output is byte-identical wherever no two matching rules sit at different tiers,
221
- * and `ask` becomes `deny` where they do. **Not** "byte-identical for any workspace that composes
222
- * nothing" — that is the claim the counterexample above kills, and this repository is not evidence for it
223
- * either: its own policy is unchanged only because `edit-the-constitution`, the one `prohibited` rule it
224
- * declares, is listed FIRST, so first-match already returned it. Measured, on a command matching both it
225
- * and a gated shell rule: `deny` from either runner. Move that rule down the file and the two disagree.
226
- * The early return on `prohibited` also keeps the denied path's short-circuit: nothing later can outrank
227
- * the top of the order.
228
- */
36
+ /** The strongest gate the call matches, not the first listed: a `prohibited` match outranks any `gated` one. */
229
37
  function decide(payload, policy) {
230
38
  const tool = payload.tool_name;
231
39
  const input = payload.tool_input ?? {};
package/cli/goldens.mjs CHANGED
@@ -1,65 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // Grade this workspace's compiled gates against a corpus of adversarial fixtures.
3
3
  //
4
- // Milestone 8, clause (a): *golden tasks reach the gates as well as the skills, so a matcher ships
5
- // with the attack cases that prove its coverage instead of prose describing it.*
6
- //
7
- // ## What this is FOR, at its real size
8
- //
9
- // `../.portulan/gate-map.md` carries an honest-holes list. That list was wrong when first published —
10
- // four items, five missing, the plainest of them a newline — and it was corrected by a supervisor who
11
- // tried to DEFEAT the matcher rather than read it. Fourteen review rounds then found three more live
12
- // bypasses the attack pass itself had not tried. Eight in all, every one found after the gate was
13
- // called done.
14
- //
15
- // A hole list is a claim like any other, and the only thing that checks it is somebody attacking it.
16
- // This runner is where those attacks live so they are re-run on every commit instead of on the day
17
- // somebody happens to look.
18
- //
19
- // ## The two things it rails, and the one it does NOT
20
- //
21
- // 1. **Coverage is measured, not named.** A rule that compiles to a matcher and carries no fixture
22
- // is RED. So a new gate cannot ship with prose standing in for attack cases — the failure
23
- // `../.portulan/memory/a-checkers-coverage-is-measured-not-named.md` names, turned into a check.
24
- // 2. **A documented hole stays documented in BOTH directions.** A `documented-hole` case asserts the
25
- // matcher's CURRENT behaviour and names the hole record it belongs to. If the hole silently
26
- // closes, that case goes red too — because a hole list that still lists a closed hole is as wrong
27
- // as one that hides an open one, which is a sentence the gate map has had to write about itself.
28
- //
29
- // **What it does not rail: adequacy.** This is a PRESENCE floor. One trivial happy-path fixture per
30
- // rule satisfies clause 1 while proving nothing adversarial, and no check here can tell the
31
- // difference — judging whether a corpus is a real attack is exactly the judgement a reviewer is for.
32
- // Written down rather than left to be discovered, because a rail whose limit is unstated gets read as
33
- // the guarantee it is not. What the floor buys is narrower and still worth having: a gate cannot reach
34
- // the compiled policy with NO adversarial thought recorded against it at all.
35
- //
36
- // ## How a case is graded — and what is deliberately never done to it
37
- //
38
- // Every case is answered by `./compile.mjs`'s own exported `matchesRule`, the same function
39
- // `./gate.mjs` calls at hook time. Never a re-implementation: "two implementations of one matcher is
40
- // the drift this repository keeps finding, and a *matcher* that drifts does not look wrong — it looks
41
- // like a matcher" (`./compile.mjs`).
42
- //
43
- // **A fixture's command string is DATA and is never executed.** The corpus contains
44
- // `git push --force`, `rm -rf docs` and constitution-write spellings by design — running one would be
45
- // somewhere between tripping the gate under test and destroying the tree it runs in. There is no code
46
- // path from a fixture to a subprocess, and `./goldens.test.mjs` asserts that this module spawns
47
- // nothing at all.
48
- //
49
- // ## Which policy it grades
50
- //
51
- // The one the workspace YIELDS — its own `gates.json` plus the fragments its composed packs
52
- // contribute — never the declared file alone. A rail scoped to the declared list would let a composed
53
- // gate ship with no fixtures while this check stayed green, which is the class `../.portulan/dod.md`
54
- // condition 1 names in so many words and `./gate.mjs` already had repaired.
55
- //
56
- // Resolution is HERMETIC: no host plugin-cache discovery, ever. `compile --matrix` without a pinned
57
- // root refuses on this maintainer's machine as SHADOWED — an installed cache copy at one version
58
- // against the tree's at another — so a runner that consulted the host would grade the machine rather
59
- // than the tree, and its verdict would move with what happens to be installed. The recipe names its
60
- // root as well, on the pin discipline `../.portulan/verify/compile.sh` and `./pinned-roots.live.test.mjs`
61
- // already hold the compiler to.
62
- //
63
4
  // Exit 0 green · 1 red · 2 could not run.
64
5
 
65
6
  import fs from "node:fs";
@@ -76,60 +17,24 @@ import {
76
17
  parse,
77
18
  policyDeclaration,
78
19
  resolveWorkspace,
20
+ unreadableManifest,
79
21
  } from "./compile.mjs";
80
22
 
81
- /** Where the corpus lives, relative to the repository root. */
82
23
  export const CORPUS_DIR = "evals/goldens/gates";
83
24
 
84
- /** The action kinds `matchesRule` can answer for. A rule shaped otherwise has no matcher to attack. */
85
25
  export const MATCHABLE = ["shell", "write", "read"];
86
26
 
87
- /**
88
- * The two case classes, and why there are exactly two.
89
- *
90
- * `holds` is a regression pin: the matcher catches this today and must keep catching it. Every one of
91
- * the eight bypasses found on #60 is one of these — they are CLOSED holes, and the corpus exists so
92
- * they cannot quietly re-open.
93
- *
94
- * `documented-hole` is the other direction: the matcher does NOT catch this, the gate map says so, and
95
- * the case exists to keep that admission true. A third class for "we hope this gets fixed" was
96
- * considered and refused — it would be a wish, and a wish that goes green when someone fixes it is
97
- * indistinguishable from a hole that closed without its record being updated.
98
- */
27
+ /** `holds`: the matcher catches the case and must keep catching it; `documented-hole`: it does not, as the gate map records. */
99
28
  export const CLASSES = ["holds", "documented-hole"];
100
29
 
101
- /**
102
- * Which branch of `matchesRule` a case exercises — the session-open checkpoint's adjustment 3.
103
- *
104
- * **Why a case must say this.** One rule id can be answered by two different segmenters with two
105
- * different answers, and the corpus contains a live example: a `then`/`do`/brace-group leader is
106
- * CAUGHT on the write path (`shellSegments` knows `SEGMENT_LEADERS`) and ESCAPES on the shell path
107
- * (`commandSegments` does not). A reader meeting those two cases without this field would take the
108
- * pair for a contradiction rather than for the asymmetry it is.
109
- *
110
- * **And why it is DERIVED rather than declared.** `matcherPath` computes it from the rule's action
111
- * kind and the case's tool, and `grade` refuses a case whose declared path disagrees. A field written
112
- * from memory on 212 cases is a second carrier of what `WRITE_TOOLS`, `READ_TOOLS` and the action kind
113
- * already decide — and this repository's standing finding is that the second carrier is the one that
114
- * goes wrong. Writing it down keeps it readable; deriving it keeps it true.
115
- */
30
+ /** The branch of `matchesRule` a case takes, since two branches segment a command differently. */
116
31
  export const PATHS = ["matchesPath", "shell-write", "shell-prefix", "no-branch"];
117
32
 
118
- /**
119
- * The branch `matchesRule` will take for this rule kind and tool.
120
- *
121
- * `no-branch` is a real answer and not a gap: it names a combination the matcher has no code for, and
122
- * a case declaring it is asserting exactly that — `Bash` against a `read:` rule, or a write tool
123
- * against a `shell:` one. Those cases matter because the asymmetry between action kinds is the sort of
124
- * thing a reader assumes away.
125
- */
33
+ /** `no-branch`: a kind and tool the matcher has no code for, such as `Bash` against a `read:` rule. */
126
34
  export function matcherPath(kind, tool) {
127
35
  if (kind === "shell") return tool === "Bash" ? "shell-prefix" : "no-branch";
128
36
  if (kind === "write") {
129
37
  if (WRITE_TOOLS.includes(tool)) return "matchesPath";
130
- // Bash reaches a `write:` rule through `commandSegments` × `spellings` and then `shellWrites`,
131
- // which segments AGAIN with `shellSegments`. Named for the entry, since that is what a reader
132
- // needs to find the code; the docblock above carries the two-segmenter consequence.
133
38
  return tool === "Bash" ? "shell-write" : "no-branch";
134
39
  }
135
40
  if (kind === "read") return READ_TOOLS.includes(tool) ? "matchesPath" : "no-branch";
@@ -138,7 +43,6 @@ export function matcherPath(kind, tool) {
138
43
 
139
44
  export class CouldNotRun extends Error {}
140
45
 
141
- /** Read one JSON file, or say which file and why not. */
142
46
  function readJson(file, what) {
143
47
  let text;
144
48
  try {
@@ -153,37 +57,33 @@ function readJson(file, what) {
153
57
  }
154
58
  }
155
59
 
156
- /**
157
- * The rules this workspace yields, parsed.
158
- *
159
- * Composition runs BEFORE `parse`, exactly as `compile` does it, so a pack's fragment is graded by the
160
- * same code that grades a hand-written rule. Following the compiler's order rather than inventing one
161
- * is the whole reason this function is four lines of plumbing and no judgement.
162
- */
60
+ /** Composed before `parse`, as `compile` does, so a pack's fragment is graded like a hand-written rule. */
163
61
  export function yieldedRules(named, { packRoots = null } = {}) {
164
62
  const { workspaceRoot, workspaceDir } = resolveWorkspace(named);
63
+ const unreadable = unreadableManifest(workspaceRoot, workspaceDir);
64
+ if (unreadable !== null) {
65
+ throw new CouldNotRun(
66
+ `${unreadable.file} is not a manifest this tool can read: ${unreadable.why}. Read as one declaring nothing, ` +
67
+ `it would have fixtures graded against a \`gates.json\` found by convention, which it may not name. ` +
68
+ `There is nothing to grade fixtures against`,
69
+ );
70
+ }
165
71
  const { file: policyFile, declared, reason } = policyDeclaration(workspaceRoot, workspaceDir);
166
- if (!declared && !fs.existsSync(policyFile)) {
167
- // **The fallback is THREE states and the message names which one.** This said
168
- // `${workspaceDir}/workspace.json declares none` for all of them, which is a sentence that is
169
- // false twice over: when the manifest does not exist there is nothing to have declared
170
- // anything, and when it named a policy this compiler refused, it declared one and was
171
- // overruled. Both send a reader to audit the one file that is not at fault — the failure
172
- // `../.portulan/memory/verify-preconditions-fail-closed.md` names, and the failure
173
- // `compile.mjs` had already learned at `undeclaredPolicyMessage`, whose own comment says
174
- // "only the caller knows to name what the absence costs". I copied that call site's SHAPE
175
- // and not its discipline. Reported as a suppressed note by Copilot, round 8 on #336.
72
+ if (reason === "refused" || (!declared && !fs.existsSync(policyFile))) {
176
73
  const why =
177
74
  reason === "no-manifest"
178
75
  ? `there is no ${workspaceDir}/workspace.json, so nothing here has been authored as a workspace yet`
179
76
  : reason === "refused"
180
77
  ? `${workspaceDir}/workspace.json DOES name a gate policy, and it was refused — the manifest is not at fault, the path it names is`
181
78
  : `${workspaceDir}/workspace.json declares no \`gates\` key, which is a legitimate shape and means the policy is expected at the default path`;
182
- throw new CouldNotRun(`no gate policy at ${policyFile}: ${why}. There is nothing to grade fixtures against`);
79
+ const head =
80
+ reason === "refused" && fs.existsSync(policyFile)
81
+ ? `the \`gates.json\` at ${policyFile} is not the gate policy the manifest names`
82
+ : `no gate policy at ${policyFile}`;
83
+ throw new CouldNotRun(`${head}: ${why}. There is nothing to grade fixtures against`);
183
84
  }
184
85
  const policy = readJson(policyFile, "the gate policy");
185
- // `discovery: null` and `forced: false`, always. See the header: a rail that consults the host
186
- // grades the host.
86
+ // Never the host's plugin cache: a rail that consulted it would grade the machine, not the tree.
187
87
  const { contributions, unresolved } = packContributions(workspaceRoot, workspaceDir, {
188
88
  ...(packRoots === null ? { named: [] } : { packRoots }),
189
89
  discovery: null,
@@ -193,19 +93,6 @@ export function yieldedRules(named, { packRoots = null } = {}) {
193
93
  return { workspaceRoot, rules: parse(composed.policy).rules, unresolved };
194
94
  }
195
95
 
196
- /**
197
- * Split the yielded rules into the ones a fixture can attack and the ones it cannot.
198
- *
199
- * A rule whose action is `none` has no tool-level surface at all — `spend-money-or-register-a-domain`
200
- * and `send-something-outside-this-repository` are the standing examples, and both say so in their own
201
- * `action.none` prose. There is no input to hand `matchesRule` for them, so requiring fixtures would be
202
- * requiring a test of nothing.
203
- *
204
- * **They are named in the output on every run regardless**, and that is the load-bearing half. The
205
- * exemption is the obvious way to dodge this rail — write the next gate `none`-shaped and it needs no
206
- * attack cases — so the census prints them the way `compile --matrix` prints its own refused rules,
207
- * rather than letting the exemption be silent.
208
- */
209
96
  export function partition(rules) {
210
97
  const matchable = [];
211
98
  const exempt = [];
@@ -217,7 +104,6 @@ export function partition(rules) {
217
104
  return { matchable, exempt };
218
105
  }
219
106
 
220
- /** Every fixture file in the corpus, read and shape-checked. */
221
107
  export function readCorpus(repoRoot, dir = CORPUS_DIR) {
222
108
  const base = path.join(repoRoot, dir);
223
109
  let names;
@@ -234,13 +120,6 @@ export function readCorpus(repoRoot, dir = CORPUS_DIR) {
234
120
  if (typeof doc.rule !== "string" || doc.rule === "") {
235
121
  throw new CouldNotRun(`${where} names no \`rule\` — a fixture file attacks exactly one rule and must say which`);
236
122
  }
237
- // **One fixture file per rule, named for it — checked, because this file already SAYS it.**
238
- // The red for a missing fixture prints `add evals/goldens/gates/<rule-id>.json`, `evals/README.md`
239
- // documents the layout, and nothing enforced either: a renamed or misfiled file validated
240
- // cleanly and still graded, so the convention held by everyone remembering it. That is the
241
- // shape `../.portulan/memory/a-mandate-nothing-checks-is-already-broken.md` names, and this
242
- // module was carrying one of its own while its whole subject is mandates that need checkers.
243
- // Reported as a suppressed note by Copilot, round 5 on #336.
244
123
  if (name !== `${doc.rule}.json`) {
245
124
  throw new CouldNotRun(
246
125
  `${where} declares rule \`${doc.rule}\` — one fixture file per rule, named for it, so rename it to ` +
@@ -277,12 +156,6 @@ export function readCorpus(repoRoot, dir = CORPUS_DIR) {
277
156
  return files;
278
157
  }
279
158
 
280
- /**
281
- * Grade the corpus against the rules. Returns findings; the caller decides what they cost.
282
- *
283
- * Every finding names the file, the case and the repair. A red that does not say what to do about it
284
- * costs a reader the same time twice.
285
- */
286
159
  export function grade(rules, corpus) {
287
160
  const { matchable, exempt } = partition(rules);
288
161
  const byId = new Map(matchable.map((r) => [r.id, r]));
@@ -306,9 +179,6 @@ export function grade(rules, corpus) {
306
179
  covered.add(rule.id);
307
180
  for (const c of doc.cases) {
308
181
  cases += 1;
309
- // The declared path is checked against the DERIVED one before the case is graded. A case
310
- // that mislabels its own branch is a case a reader will draw the wrong conclusion from,
311
- // and the label is the half no green would otherwise test.
312
182
  const derived = matcherPath(rule.kind, c.tool);
313
183
  byPath.set(derived, (byPath.get(derived) ?? 0) + 1);
314
184
  if (c.path !== derived) {
@@ -321,7 +191,6 @@ export function grade(rules, corpus) {
321
191
  });
322
192
  continue;
323
193
  }
324
- // The one call that matters, and it is the compiler's own. Never re-implemented here.
325
194
  const actual = matchesRule(rule, c.tool, c.input);
326
195
  if (actual === c.expect) continue;
327
196
  findings.push({
@@ -363,8 +232,7 @@ function usage() {
363
232
 
364
233
  export function run(argv = [], { stdout = process.stdout, stderr = process.stderr, cwd = process.cwd() } = {}) {
365
234
  const say = (line = "") => stdout.write(`${line}\n`);
366
- // Before every other argument decision, so asking for help cannot be outranked by a complaint
367
- // about the rest of the command line — the contract `./portulan.mjs` states.
235
+ // First, so `--help` is answered whatever else the command line holds.
368
236
  if (argv.includes("--help") || argv.includes("-h")) {
369
237
  say(usage());
370
238
  return 0;
@@ -381,9 +249,7 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
381
249
  } else if (argv[i] === "--pack-root") {
382
250
  const root = argv[i + 1];
383
251
  i += 1;
384
- // A single leading `-` is refused too, the way `compile` and `doctor` refuse it:
385
- // `--pack-root -h` would otherwise be consumed as a path and fail later as an
386
- // unreadable one. A directory genuinely named `-name` is `./-name`.
252
+ // A value starting with `-` is a flag, not a path; a directory so named is `./-name`.
387
253
  if (root === undefined || root.startsWith("-")) throw new CouldNotRun("--pack-root needs a directory");
388
254
  let stat = null;
389
255
  try {
@@ -400,16 +266,11 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
400
266
  const corpus = readCorpus(workspaceRoot);
401
267
  const { findings, cases, matchable, exempt, byPath } = grade(rules, corpus);
402
268
 
403
- // An unresolved pack is a declaration the workspace believes it composed, so its fragments are
404
- // absent from the denominator and this run's coverage claim is narrower than it looks. Printed
405
- // before the verdict, for the reason `compile` prints it there.
406
269
  for (const u of unresolved) say(`pack ${u.name} UNRESOLVED — ${u.why}; its gate fragments are not in this census`);
407
270
 
408
271
  say(`goldens: ${cases} case(s) over ${matchable.length} matchable rule(s) in ${corpus.length} fixture file(s)`);
409
- // Per matcher branch, and printed even at zero. A corpus can carry 200 cases and exercise one
410
- // branch of three; the total says nothing about that and this line says it plainly.
411
272
  say(`goldens: by matcher path — ${PATHS.map((p) => `${p} ${byPath.get(p) ?? 0}`).join(" · ")}`);
412
- // The exemption, named on every run — see `partition`.
273
+ // Printed on every run, so the exemption cannot become a quiet way out of fixtures.
413
274
  if (exempt.length) {
414
275
  say(`goldens: ${exempt.length} rule(s) declare no matchable action and are exempt from fixtures:`);
415
276
  for (const e of exempt) say(` ${e.id} (${e.tier}, ${e.why})`);
@@ -421,8 +282,6 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
421
282
  return 1;
422
283
  }
423
284
  say("GREEN — every matchable gate carries fixtures, and every case answers as recorded");
424
- // Said out loud rather than implied by the green, because the limit is the half a reader
425
- // otherwise supplies wrongly. See the header.
426
285
  say("goldens: this is a PRESENCE floor — whether a corpus is a real attack is a reviewer's judgement, not this rail's");
427
286
  return 0;
428
287
  } catch (error) {
@@ -430,28 +289,13 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
430
289
  stderr.write(`goldens: ${error.message}\n`);
431
290
  return 2;
432
291
  }
433
- // **An unexpected throw is COULD-NOT-RUN, never a red**, and this arm exists because the first
434
- // draft rethrew: a `ReferenceError` from a typo crashed the process, node exited 1, and
435
- // `../.portulan/verify/goldens.sh` faithfully translated that into "RED — verify recipe failed"
436
- // about a corpus nothing had finished grading. A crash that reads as a verdict is the shape
437
- // this repository has now met in three tools — it is why `pack-version.sh` and
438
- // `eval-bundle.sh` grew `[ -f ]` preconditions. The stack rides along, because a
439
- // could-not-run nobody can debug is only half an answer.
292
+ // An unexpected throw exits 2, never 1: a crash is not a verdict on the corpus.
440
293
  stderr.write(`goldens: could not finish grading — ${error?.stack ?? error}\n`);
441
294
  return 2;
442
295
  }
443
296
  }
444
297
 
445
- // The entry guard, in the ONE form `./rule-carriers.mjs` designates after this repository got it wrong
446
- // twice. `file://${argv[1]}` is NOT that form: `import.meta.url` percent-encodes, this working copy
447
- // lives under a path with spaces, and the comparison fails — so the tool exits 0 having run nothing.
448
- // **A green that is the tool never starting.** This file shipped that spelling for exactly one
449
- // measurement: the first run of `node cli/goldens.mjs` printed nothing and exited 0, on a corpus that
450
- // did not exist yet and against a policy nothing had read. The third time this repository has met it,
451
- // and the reason the designated form is copied rather than re-derived.
452
- //
453
- // The realpath fallback covers the symlink case an npm `bin` produces, in a `try` because a missing
454
- // path must answer no rather than throw.
298
+ // URLs on both sides, since `import.meta.url` percent-encodes; the realpath covers a symlinked `bin`.
455
299
  function isMain() {
456
300
  const invoked = process.argv[1];
457
301
  if (!invoked) return false;
@@ -463,7 +307,5 @@ function isMain() {
463
307
  }
464
308
  }
465
309
 
466
- // `process.exitCode` rather than `process.exit`, which `./control-chars.mjs` settled for this
467
- // repository: exiting outright can truncate a pipe that has not drained — and a truncated line IS exit
468
- // 0 with no output, the precise shape of the false green the guard above was fixed for.
310
+ // `exitCode`, not `exit()`: exiting outright can cut off output a pipe has not drained.
469
311
  if (isMain()) process.exitCode = run(process.argv.slice(2));