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