@sleepy_panda_srl/portulan 0.1.2 → 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 +7 -6
- package/cli/README.md +142 -291
- package/cli/advisory.mjs +354 -0
- package/cli/comments.mjs +932 -0
- package/cli/compile.mjs +1443 -1210
- package/cli/context.mjs +883 -0
- package/cli/control-chars.mjs +12 -301
- package/cli/discover.mjs +17 -574
- package/cli/doctor.mjs +225 -1071
- package/cli/drills.mjs +1107 -0
- package/cli/feedback.mjs +16 -237
- package/cli/finish.mjs +437 -0
- package/cli/form.mjs +644 -0
- package/cli/fuzz-shell.mjs +595 -0
- package/cli/gate.mjs +7 -198
- package/cli/goldens.mjs +311 -0
- package/cli/index.mjs +305 -655
- package/cli/init.mjs +244 -564
- 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 +764 -0
- package/cli/new.mjs +12 -243
- package/cli/pack-identity.mjs +6 -68
- package/cli/pack-version.mjs +13 -248
- package/cli/plugin-lint.mjs +42 -658
- package/cli/portulan.mjs +5 -124
- package/cli/recipe-set.mjs +16 -240
- package/cli/release-eval.mjs +748 -0
- package/cli/review-meter.mjs +500 -0
- package/cli/rule-carriers.mjs +12 -175
- package/cli/sessions.mjs +82 -0
- package/cli/skill-goldens.mjs +424 -0
- package/cli/skills-set.mjs +12 -414
- package/cli/stop-gate.mjs +126 -454
- package/cli/symbols.mjs +1132 -0
- package/cli/telemetry.mjs +582 -0
- package/cli/upgrade.mjs +171 -314
- package/cli/vendor.mjs +146 -476
- package/cli/version-carriers.mjs +9 -74
- 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/gate-policy.md +27 -8
- package/core/templates/handoff.md +28 -15
- package/core/templates/memory-entry.md +1 -0
- package/evals/README.md +320 -0
- package/evals/releases/0.1.3.json +134 -0
- package/evals/releases/0.1.3.md +64 -0
- package/evals/releases/0.2.0.json +146 -0
- package/evals/releases/0.2.0.md +67 -0
- package/evals/releases/README.md +32 -0
- package/package.json +9 -2
- package/packs/rituals/checkpoints/README.md +9 -2
- package/packs/rituals/checkpoints/pack.json +1 -1
- package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +32 -2
- 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 +359 -23
- 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,54 +22,22 @@ export class CompileError extends Error {
|
|
|
55
22
|
}
|
|
56
23
|
}
|
|
57
24
|
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
const KNOWN_GATE_POLICY_SPECS = new Set(["2.1", "2.2"]);
|
|
25
|
+
/** The `portulan.spec` versions of `gates.json`: a train separate from the Workspace Definition's, though the numbers overlap. */
|
|
26
|
+
export const KNOWN_GATE_POLICY_SPECS = new Set(["2.1", "2.2"]);
|
|
74
27
|
|
|
75
28
|
const TIERS = new Set(["auto", "propose", "gated", "prohibited"]);
|
|
76
29
|
|
|
77
30
|
const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
78
31
|
|
|
79
|
-
//
|
|
80
|
-
// 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.
|
|
81
33
|
const FILE_PATH = /^[^#?:/]([^#?:]*[^#?:/])?$/;
|
|
82
34
|
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
const READ_TOOLS = ["Read"];
|
|
35
|
+
export const WRITE_TOOLS = ["Edit", "Write", "NotebookEdit"];
|
|
36
|
+
export const READ_TOOLS = ["Read"];
|
|
86
37
|
|
|
87
|
-
//
|
|
88
|
-
// 1. Parse: policy -> validated, normalised rules. NO backend opinion lives here.
|
|
89
|
-
// ===========================================================================================
|
|
38
|
+
// ---------------------------------------------------------------- parse: validated rules, no backend's opinion
|
|
90
39
|
|
|
91
|
-
/**
|
|
92
|
-
* Read a gate policy and hand back `{ rules, floor }` — validated and normalised, with every rule
|
|
93
|
-
* carrying its `kind` and `target` resolved, and nothing yet decided about enforcement.
|
|
94
|
-
*
|
|
95
|
-
* Anything it cannot understand refuses the WHOLE policy rather than dropping one rule — skipping
|
|
96
|
-
* and enforcing are indistinguishable from outside
|
|
97
|
-
* (../.portulan/memory/a-checker-must-refuse-what-it-cannot-check.md).
|
|
98
|
-
*
|
|
99
|
-
* **Why this stage holds no tier partition.** For one session it did: `auto` and `propose` were
|
|
100
|
-
* refused here, before any backend ran, with a sentence saying `propose` "is enforced by the
|
|
101
|
-
* platform floor". That sentence was right and its location was wrong — it names the *other*
|
|
102
|
-
* backend, and when that backend arrived it needed to compile exactly the rules this stage had
|
|
103
|
-
* already thrown away. A partition that is one backend's opinion must live in that backend, or the
|
|
104
|
-
* second one is structurally incapable of disagreeing.
|
|
105
|
-
*/
|
|
40
|
+
/** The policy as `{ rules, floor }`, validated and normalised; anything unreadable throws, since a skipped rule looks enforced. */
|
|
106
41
|
export function parse(policy) {
|
|
107
42
|
if (!policy || typeof policy !== "object" || Array.isArray(policy)) {
|
|
108
43
|
throw new CompileError("the gate policy is not a JSON object");
|
|
@@ -157,32 +92,6 @@ export function parse(policy) {
|
|
|
157
92
|
if (typeof action[kind] !== "string" || action[kind].trim() === "") {
|
|
158
93
|
throw new CompileError(`rule \`${id}\`'s action \`${kind}\` has no value`);
|
|
159
94
|
}
|
|
160
|
-
// ONE REGEX WAS CARRYING TWO DIFFERENT JUSTIFICATIONS, and only one of them reaches every kind.
|
|
161
|
-
//
|
|
162
|
-
// For `shell`, `write` and `read` the value is interpolated into the host's permission DSL —
|
|
163
|
-
// `Bash(prefix:*)`, `Edit(./path)`. Characters structural THERE would emit a rule the host
|
|
164
|
-
// parses differently from what the policy meant, and an ambiguous gate is indistinguishable
|
|
165
|
-
// from an absent one. Refuse rather than escape: a workspace needing a gate on a command
|
|
166
|
-
// containing `(` or `:` needs this compiler extended deliberately, not quietly reinterpreted.
|
|
167
|
-
// Found by review on the pull request.
|
|
168
|
-
//
|
|
169
|
-
// **A `none` value is never interpolated into a permission pattern.** It is PROSE: the sentence
|
|
170
|
-
// the policy gives for why there is no surface, which the compiler reports verbatim rather than
|
|
171
|
-
// inventing its own — see the `kind === "none"` arm of the emitter, which pushes it as `why`,
|
|
172
|
-
// and the `refused` loop in the reporter, which prints it. (An earlier draft of this comment
|
|
173
|
-
// cited `describeRefusal`, a function that does not exist in this file — a name asserted without
|
|
174
|
-
// being looked up, in the same change whose subject is tracing what a value actually reaches.
|
|
175
|
-
// The nearest real thing is `floorRefusal`, which answers a different question.) So the DSL
|
|
176
|
-
// justification above does not reach it, and
|
|
177
|
-
// applying the DSL regex to it forbade **parentheses in an English sentence** — a gate refusing
|
|
178
|
-
// to compile over an aside. #205.
|
|
179
|
-
//
|
|
180
|
-
// What DOES still reach it is narrower and worth stating rather than folding back in: that
|
|
181
|
-
// sentence is printed into a LINE-BASED report — `refused ${id.padEnd(38)} ${why}` — so a
|
|
182
|
-
// newline or a tab in it breaks the column and splits one refusal across two lines. That is a
|
|
183
|
-
// different defect with a different fix, and it is the reason `none` keeps `\n\r\t` while
|
|
184
|
-
// losing `()` and `:`. Two justifications, two scopes, stated separately so the next reader
|
|
185
|
-
// cannot collapse them again.
|
|
186
95
|
const RESERVED = kind === "none" ? /[\n\r\t]/ : kind === "shell" ? /[()\n\r\t:]/ : /[()\n\r\t]/;
|
|
187
96
|
if (RESERVED.test(action[kind])) {
|
|
188
97
|
throw new CompileError(
|
|
@@ -195,15 +104,6 @@ export function parse(policy) {
|
|
|
195
104
|
`differently from what this policy says, which is worse than refusing to compile.`,
|
|
196
105
|
);
|
|
197
106
|
}
|
|
198
|
-
// THE SAME OVER-REACH, NINE LINES BELOW THE SPLIT THAT FIXED IT. This message told every kind
|
|
199
|
-
// that the host "would not match" its value — and nothing about a `none` value is ever matched
|
|
200
|
-
// by the host, for the reason set out directly above. Surrounding whitespace on a `none` value
|
|
201
|
-
// is still worth refusing, but for a reason this comment got wrong once: the report's padding
|
|
202
|
-
// comes from `r.id.padEnd(38)`, not from `why`, so whitespace on the sentence cannot misalign a
|
|
203
|
-
// column. What it does do is shift the sentence out of line with every other refusal, and hide
|
|
204
|
-
// itself in the record. The message says that now — a refusal's stated reason must match its
|
|
205
|
-
// 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
|
|
206
|
-
// reserved-character check on exactly this distinction: `0020` at its shortest range yet.
|
|
207
107
|
if (action[kind] !== action[kind].trim()) {
|
|
208
108
|
throw new CompileError(
|
|
209
109
|
kind === "none"
|
|
@@ -213,24 +113,6 @@ export function parse(policy) {
|
|
|
213
113
|
: `rule \`${id}\`'s ${kind} target has leading or trailing whitespace, which the host would not match`,
|
|
214
114
|
);
|
|
215
115
|
}
|
|
216
|
-
// A path target that leaves the workspace is refused rather than normalised, for the reason
|
|
217
|
-
// directly above. Two spellings, one defect: `pattern()` and `matchesPath()` compare against a
|
|
218
|
-
// workspace-relative TAIL, so neither an absolute target nor one climbing out with `..` means
|
|
219
|
-
// at enforcement time what it says in the policy.
|
|
220
|
-
//
|
|
221
|
-
// "/etc/passwd" -> emits `Edit(./etc/passwd)` and matches any file whose path ends
|
|
222
|
-
// `/etc/passwd`, anywhere on the machine — broader than declared.
|
|
223
|
-
// "../secrets/" -> emits `Edit(./../secrets/**)`, which the host may resolve against the
|
|
224
|
-
// PARENT tree, while `matchesPath` can never match a `/../`-bearing tail
|
|
225
|
-
// against a resolved absolute path — narrower than declared, and the
|
|
226
|
-
// emitter and matcher disagree about which.
|
|
227
|
-
//
|
|
228
|
-
// Broader and narrower are both wrong, and the second is worse: it is a gate that reads as
|
|
229
|
-
// present and holds nothing. A workspace needing to gate a path outside its own tree needs
|
|
230
|
-
// this compiler extended deliberately. Shell targets are exempt: `/usr/bin/git` is a command
|
|
231
|
-
// spelling, not a path this compiler rewrites. Absolute found by review on #31; the `..`
|
|
232
|
-
// sibling by the fresh-context supervisor reviewing that fix, which is the same defect one
|
|
233
|
-
// spelling over.
|
|
234
116
|
if (kind === "write" || kind === "read") {
|
|
235
117
|
const escapes = action[kind].startsWith("/")
|
|
236
118
|
? "is an absolute path"
|
|
@@ -252,21 +134,7 @@ export function parse(policy) {
|
|
|
252
134
|
return { rules, floor: parseFloor(policy.floor) };
|
|
253
135
|
}
|
|
254
136
|
|
|
255
|
-
/**
|
|
256
|
-
* The platform-floor declaration: the facts a floor backend would otherwise have to invent.
|
|
257
|
-
*
|
|
258
|
-
* Optional — a workspace that has declared no floor is a legitimate shape, and it is what every
|
|
259
|
-
* workspace was before this key existed. Present, it is validated whole: a half-declared floor is
|
|
260
|
-
* the one input from which a backend could emit something importable, valid, and weaker than the
|
|
261
|
-
* settings already in force.
|
|
262
|
-
*
|
|
263
|
-
* Four keys, and each is here because it varies per repository and the export would otherwise guess:
|
|
264
|
-
* which ref the floor protects, which status checks must be green, how many approving reviews are
|
|
265
|
-
* required, and whether review threads must be resolved. What is deliberately NOT declarable is
|
|
266
|
-
* `strict` — a pull request may not merge from behind its base
|
|
267
|
-
* (`../.portulan/proposals/0011-no-merge-from-behind-main.md`, applied live on 2026-07-27), so a
|
|
268
|
-
* policy able to declare `strict: false` would be a compiled artifact quietly undoing a ruling.
|
|
269
|
-
*/
|
|
137
|
+
/** The optional `floor`, validated whole, or null; `strict` is not declarable, as no pull request merges from behind its base. */
|
|
270
138
|
function parseFloor(floor) {
|
|
271
139
|
if (floor === undefined || floor === null) return null;
|
|
272
140
|
if (typeof floor !== "object" || Array.isArray(floor)) {
|
|
@@ -279,11 +147,6 @@ function parseFloor(floor) {
|
|
|
279
147
|
`Refusing rather than defaulting to \`main\`: a compiler that invents the ref it gates has stopped compiling policy.`,
|
|
280
148
|
);
|
|
281
149
|
}
|
|
282
|
-
// A branch NAME, never a full ref. The emitter prefixes `refs/heads/` unconditionally, so
|
|
283
|
-
// `refs/heads/main` here produced `refs/heads/refs/heads/main` — a ruleset GitHub accepts and
|
|
284
|
-
// that matches no ref in any repository. Importable, valid, enforcing nothing, in the one field
|
|
285
|
-
// that names what the floor protects. Found by review on the pull request. (`release/2026` is an
|
|
286
|
-
// ordinary branch name and stays legal; only a `refs/` prefix is refused.)
|
|
287
150
|
if (/^refs\//.test(branch)) {
|
|
288
151
|
throw new CompileError(
|
|
289
152
|
`\`floor.branch\` is ${JSON.stringify(branch)} — declare a branch NAME, not a full ref. This compiler emits ` +
|
|
@@ -298,20 +161,13 @@ function parseFloor(floor) {
|
|
|
298
161
|
if (!c || typeof c !== "object" || typeof c.context !== "string" || c.context.trim() === "") {
|
|
299
162
|
throw new CompileError(`\`floor.checks[${i}]\` declares no \`context\` — a required check with no name cannot be required`);
|
|
300
163
|
}
|
|
301
|
-
// Refused rather than normalised, exactly as a rule target is a few lines up: a context
|
|
302
|
-
// stored with its surrounding whitespace is emitted with it, and GitHub then requires a
|
|
303
|
-
// check no job reports. Trimming it silently would hide a policy error in the file a human
|
|
304
|
-
// reviews. Found by review on the pull request.
|
|
305
164
|
if (c.context !== c.context.trim()) {
|
|
306
165
|
throw new CompileError(
|
|
307
166
|
`\`floor.checks[${i}].context\` is ${JSON.stringify(c.context)} — it has leading or trailing whitespace, which no ` +
|
|
308
167
|
`status check will report. Fix the policy rather than having this quietly trim it.`,
|
|
309
168
|
);
|
|
310
169
|
}
|
|
311
|
-
//
|
|
312
|
-
// context with no `integration_id` is satisfiable by ANY app reporting that name. Permitted,
|
|
313
|
-
// because a workspace may legitimately not know its app id, and reported by `doctor` rather
|
|
314
|
-
// 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`.
|
|
315
171
|
const pin = c.integration_id;
|
|
316
172
|
if (pin !== undefined && !Number.isInteger(pin)) {
|
|
317
173
|
throw new CompileError(`\`floor.checks[${i}].integration_id\` is not an integer — an app pin GitHub cannot read is not a pin`);
|
|
@@ -327,44 +183,15 @@ function parseFloor(floor) {
|
|
|
327
183
|
return { branch, checks, reviews: floor.reviews, resolve_conversations: floor.resolve_conversations };
|
|
328
184
|
}
|
|
329
185
|
|
|
330
|
-
//
|
|
331
|
-
// 2. What an action MEANS — shared by the compiler and the runtime gate
|
|
332
|
-
// ===========================================================================================
|
|
333
|
-
//
|
|
334
|
-
// The emitter and the hook runner must agree about what `{"shell": "git push"}` covers, and the
|
|
335
|
-
// only way to guarantee that is one definition. Two implementations of one matcher is the drift
|
|
336
|
-
// this repository keeps finding, and a *matcher* that drifts does not look wrong — it looks like a
|
|
337
|
-
// gate that quietly stopped covering something.
|
|
338
|
-
//
|
|
339
|
-
// `./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
|
|
340
187
|
|
|
341
|
-
/**
|
|
342
|
-
* The spellings of a shell command a gate will match against: the command itself, and the same
|
|
343
|
-
* command with one `sh -c` / `bash -c` / `zsh -c` wrapper peeled off.
|
|
344
|
-
*
|
|
345
|
-
* The permission rule this compiles to sees only the first — `Bash(git push:*)` is a literal
|
|
346
|
-
* prefix match, so `bash -c "git push …"` is invisible to it. Measured, not assumed. The hook
|
|
347
|
-
* runner sees both, which is the one thing it does that the permission layer cannot, and therefore
|
|
348
|
-
* the reason it is emitted at all.
|
|
349
|
-
*
|
|
350
|
-
* **One level, and no more.** This closes the spelling reached for by accident or convenience, not
|
|
351
|
-
* one constructed on purpose: deeper nesting, a heredoc, an interpolated variable, or a command
|
|
352
|
-
* assembled at runtime all still escape. No amount of parsing here would close that, and an
|
|
353
|
-
* ambitious parser would buy false confidence with false reds. What must not happen regardless of
|
|
354
|
-
* spelling belongs on the platform floor — see ../core/operating/autonomy.md, which calls the floor
|
|
355
|
-
* the gate that holds when this layer fails.
|
|
356
|
-
*/
|
|
188
|
+
/** The command and, under one leading `sh -c`-style wrapper, the command inside, which no `Bash(prefix:*)` rule sees; one level only. */
|
|
357
189
|
export function spellings(raw) {
|
|
358
190
|
const command = String(raw ?? "").trim();
|
|
359
191
|
const out = [command];
|
|
360
192
|
const wrapper = /^(?:\/usr\/bin\/env\s+)?(?:ba|z|da)?sh\s+-[a-zA-Z]*c\s+(.*)$/s.exec(command);
|
|
361
193
|
if (wrapper) {
|
|
362
194
|
let inner = wrapper[1].trim();
|
|
363
|
-
// The same `$'…'` / `$"…"` forms `shellWords` strips, at the other site that reads a quote.
|
|
364
|
-
// Two sites, because a wrapped command is unwrapped HERE and a word is tokenised THERE, and
|
|
365
|
-
// fixing one left `bash -c $'git push --force origin main'` still stepping aside while
|
|
366
|
-
// `cp /tmp/x $'docs/vision.md'` had started denying — measured, and the reason this is not
|
|
367
|
-
// one edit. Found by Copilot on #60.
|
|
368
195
|
if (inner[0] === "$" && (inner[1] === "'" || inner[1] === '"')) inner = inner.slice(1);
|
|
369
196
|
const quote = inner[0];
|
|
370
197
|
if ((quote === '"' || quote === "'") && inner.endsWith(quote) && inner.length > 1) {
|
|
@@ -375,75 +202,29 @@ export function spellings(raw) {
|
|
|
375
202
|
return out;
|
|
376
203
|
}
|
|
377
204
|
|
|
378
|
-
|
|
379
|
-
// command — *does it name the protected path in a position where that path is being written* — and
|
|
380
|
-
// these are the two ways it can answer yes without parsing a shell.
|
|
381
|
-
//
|
|
382
|
-
// A table rather than a parser, for the reason `REF_RULES` further down is a table: recognition by
|
|
383
|
-
// exact spelling is a limit a reader can measure, and a matcher clever enough to generalise is clever
|
|
384
|
-
// enough to be wrong quietly.
|
|
385
|
-
|
|
386
|
-
// `git` is deliberately absent, though `git checkout -- <path>` and `git restore` both overwrite a file.
|
|
387
|
-
// The head of those commands is `git`, so admitting it would gate `git diff <path>` and `git log` in the
|
|
388
|
-
// same stroke — and a gate on READING a path is a rule no policy here declares. It is the likeliest
|
|
389
|
-
// uncovered writer in this repository, which is why ../.portulan/gate-map.md names it rather than leaving
|
|
390
|
-
// it inside "any writer outside the table".
|
|
391
|
-
/** 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. */
|
|
392
206
|
export const FILE_WRITERS = new Set(["cp", "mv", "ln", "rm", "tee", "dd", "install", "truncate", "shred", "patch"]);
|
|
393
207
|
|
|
394
|
-
/**
|
|
395
|
-
* Editors that READ their arguments by default and write them only under an in-place flag.
|
|
396
|
-
*
|
|
397
|
-
* Separated from the table above rather than folded into it, because folding them in would gate
|
|
398
|
-
* `sed -n '1,5p' docs/vision.md` — which is a *read*, and which this policy declares Auto. A matcher
|
|
399
|
-
* that contradicts a declared tier is worse than one that admits a gap.
|
|
400
|
-
*/
|
|
208
|
+
/** Editors that write their arguments only under an in-place flag; `sed -n '1,5p' <path>` is a read. */
|
|
401
209
|
export const IN_PLACE_EDITORS = new Set(["sed", "gsed", "perl", "ruby"]);
|
|
402
210
|
|
|
403
|
-
/** Words
|
|
211
|
+
/** Words before the command that runs, recognised and not parsed: `sudo cp …` is seen, `sudo -u someone cp …` is not. */
|
|
404
212
|
const COMMAND_PREFIXES = new Set(["sudo", "env", "command", "builtin", "exec", "nohup", "nice", "time"]);
|
|
405
213
|
|
|
406
214
|
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
407
215
|
|
|
408
|
-
// A NEWLINE is a command separator and belongs in this class, which it did not for one review round.
|
|
409
|
-
// The Bash tool receives multi-line scripts constantly, so
|
|
410
|
-
//
|
|
411
|
-
// git status
|
|
412
|
-
// cp /tmp/x docs/vision.md
|
|
413
|
-
//
|
|
414
|
-
// folded into ONE segment whose head was `git` — not a writer — and the whole table half fell through
|
|
415
|
-
// to false. The most ordinary spelling there is, and it defeated the gate while the redirection half
|
|
416
|
-
// (which reads no head) kept working, so the coverage looked alive. Found by the fresh-context
|
|
417
|
-
// supervisor at the pre-merge checkpoint.
|
|
418
216
|
const OPERATOR = /[|&;<>()\n\r]/;
|
|
419
217
|
|
|
420
|
-
// Words that lead a segment without being the command in it. `{ cp … ; }`, `if …; then cp …`, and
|
|
421
|
-
// `for …; do cp …` all put one of these where the head goes, which hid a writer behind it. Not a
|
|
422
|
-
// grammar — a list of leaders, and `find -exec cp` / `xargs cp` stay uncovered because parsing THEIR
|
|
423
|
-
// flags to find the real command is the ambitious parser this file keeps refusing to become.
|
|
424
218
|
const SEGMENT_LEADERS = new Set(["{", "}", "!", "then", "else", "elif", "do", "done", "fi", "esac"]);
|
|
425
219
|
|
|
426
|
-
/**
|
|
427
|
-
* Drop heredoc BODIES, keeping the line that opens them.
|
|
428
|
-
*
|
|
429
|
-
* A heredoc body is data being written, not commands being run, so reading it as commands is simply
|
|
430
|
-
* wrong — and it became wrong the moment a newline started separating commands, because that is what
|
|
431
|
-
* turned each body line into its own segment. Measured immediately and expensively: this change's own
|
|
432
|
-
* commit, whose message quoted `cp /tmp/x docs/vision.md` as the escape being fixed, was refused by the
|
|
433
|
-
* gate it was adding. A matcher that blocks you from describing an attack is not being cautious.
|
|
434
|
-
*
|
|
435
|
-
* The opening line stays, so `cat > docs/vision.md <<'EOF'` still gates — the redirection is on it.
|
|
436
|
-
* What is dropped is the text between the delimiter and its terminator, which no shell executes.
|
|
437
|
-
*/
|
|
220
|
+
/** The command without its heredoc bodies, which are data; each opening line stays, so a redirection on it still counts. */
|
|
438
221
|
function stripHeredocs(command) {
|
|
439
222
|
const out = [];
|
|
440
223
|
let delimiter = null;
|
|
441
|
-
let held = [];
|
|
224
|
+
let held = [];
|
|
442
225
|
for (const line of command.split("\n")) {
|
|
443
226
|
if (delimiter !== null) {
|
|
444
227
|
if (line.trim() === delimiter) {
|
|
445
|
-
// A real heredoc. Its body is data, not command text, so both the body and the
|
|
446
|
-
// terminator line go.
|
|
447
228
|
delimiter = null;
|
|
448
229
|
held = [];
|
|
449
230
|
continue;
|
|
@@ -455,29 +236,16 @@ function stripHeredocs(command) {
|
|
|
455
236
|
const opened = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/.exec(line);
|
|
456
237
|
if (opened) delimiter = opened[2];
|
|
457
238
|
}
|
|
458
|
-
//
|
|
459
|
-
// inside a quoted string or after a `#` sets `delimiter` on text that opens nothing — and the
|
|
460
|
-
// loop above would then swallow every remaining line looking for a word that never comes.
|
|
461
|
-
//
|
|
462
|
-
// That is a FAIL-OPEN manufactured by a defensive step, which is worse than the parsing gap it
|
|
463
|
-
// was added to close: a gated command on any later line becomes invisible to every matcher
|
|
464
|
-
// downstream. Measured on the runner before this branch existed —
|
|
465
|
-
// `echo "not a heredoc <<EOF"\ngit push --force origin main` stepped aside where the bare
|
|
466
|
-
// command answers `ask`, and `# <<EOF` did the same. Found by Copilot review on #60.
|
|
467
|
-
//
|
|
468
|
-
// Giving the lines back is the fail-CLOSED direction and costs only a false red: a genuine
|
|
469
|
-
// heredoc always has its terminator, so this branch cannot reach one. What it does not close is
|
|
470
|
-
// a mis-detected opener whose delimiter happens to appear later anyway; that is stated in the
|
|
471
|
-
// 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.
|
|
472
240
|
if (delimiter !== null) out.push(...held);
|
|
473
241
|
return out.join("\n");
|
|
474
242
|
}
|
|
475
243
|
|
|
476
244
|
/** A shell command split into words, with unquoted operators kept as words of their own. */
|
|
477
|
-
function shellWords(command) {
|
|
245
|
+
export function shellWords(command) {
|
|
478
246
|
const words = [];
|
|
479
247
|
let text = "";
|
|
480
|
-
let open = false;
|
|
248
|
+
let open = false;
|
|
481
249
|
const flush = () => {
|
|
482
250
|
if (open) words.push({ text, op: false });
|
|
483
251
|
text = "";
|
|
@@ -485,35 +253,10 @@ function shellWords(command) {
|
|
|
485
253
|
};
|
|
486
254
|
for (let i = 0; i < command.length; i += 1) {
|
|
487
255
|
const c = command[i];
|
|
488
|
-
// `$'…'`
|
|
489
|
-
// part of the word. Without this the `$` glued onto the front — `$'docs/vision.md'` tokenised
|
|
490
|
-
// as `$docs/vision.md` — and no target matched. Measured on the runner before this branch:
|
|
491
|
-
// `bash -c $'git push --force origin main'` stepped aside where the plain wrapper answers
|
|
492
|
-
// `ask`, and `cp /tmp/x $'docs/vision.md'` stepped aside where the plain target denies. So
|
|
493
|
-
// this was a live bypass of both a Gated shell target and the constitution's write gate,
|
|
494
|
-
// sitting inside the claim one line down that quoting is honoured. Found by Copilot on #60.
|
|
495
|
-
//
|
|
496
|
-
// **Two forms and no more, because this grammar is closed.** `$(…)` is command substitution
|
|
497
|
-
// and deliberately stays out: its content is a command to run, not a word to read, and
|
|
498
|
-
// reading it as a word is precisely how a matcher becomes clever and wrong quietly. That one
|
|
499
|
-
// 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.
|
|
500
257
|
if (c === "$" && (command[i + 1] === "'" || command[i + 1] === '"')) continue;
|
|
501
258
|
if (c === "'" || c === '"') {
|
|
502
|
-
//
|
|
503
|
-
// must reach the gate, which is what goes red if this branch is removed. (An earlier
|
|
504
|
-
// draft of this comment claimed it was what stops `echo "x > docs/vision.md"` reading as
|
|
505
|
-
// a redirection. Measured at the pre-merge checkpoint: false — without this branch that
|
|
506
|
-
// target tokenises as `docs/vision.md"`, trailing quote and all, and fails to match
|
|
507
|
-
// anyway. Right branch, wrong reason, which `a-stated-enforcer-must-be-the-real-one`
|
|
508
|
-
// counts as the same defect one size down.)
|
|
509
|
-
// Scanned rather than `indexOf`, for the escaped-quote reason above: inside `"…"` a
|
|
510
|
-
// `\"` is a literal quote and must not end the run. The escape is resolved into the word,
|
|
511
|
-
// so `cp /tmp/x "docs/\"v\".md"` compares as the path a shell would pass.
|
|
512
|
-
//
|
|
513
|
-
// `$'…'` reaches here with its `$` already dropped and is read as a single-quoted run, so
|
|
514
|
-
// ANSI-C escape sequences (`\n`, `\t`, `\x41`) are NOT decoded — a stated limit, and the
|
|
515
|
-
// ambitious parser this file refuses to become. A path spelled with them escapes; that is
|
|
516
|
-
// hole 1's "interpolated path" neighbourhood rather than a new one.
|
|
259
|
+
// A backslash escapes only inside `"…"`; `$'…'` reads as `'…'`, its `\x41`-style escapes undecoded.
|
|
517
260
|
let j = i + 1;
|
|
518
261
|
let run = "";
|
|
519
262
|
while (j < command.length) {
|
|
@@ -532,14 +275,8 @@ function shellWords(command) {
|
|
|
532
275
|
continue;
|
|
533
276
|
}
|
|
534
277
|
if (c === "\\" && i + 1 < command.length) {
|
|
535
|
-
// A backslash-newline
|
|
536
|
-
//
|
|
537
|
-
// middle of a path, which no target matches.
|
|
538
|
-
// `\r\n` is consumed as a PAIR. Skipping one character left the `\n` behind, and a
|
|
539
|
-
// newline is an operator here, so a CRLF continuation flushed the word instead of
|
|
540
|
-
// continuing it: `cp /tmp/x \\<CRLF>docs/vision.md` stepped aside where the LF spelling
|
|
541
|
-
// answers `deny` — the constitution, reachable by editing the file on Windows. Measured
|
|
542
|
-
// both ways, 2026-07-28. Found by Copilot review on #60.
|
|
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.
|
|
543
280
|
if (command[i + 1] === "\r" && command[i + 2] === "\n") {
|
|
544
281
|
i += 2;
|
|
545
282
|
continue;
|
|
@@ -553,8 +290,7 @@ function shellWords(command) {
|
|
|
553
290
|
i += 1;
|
|
554
291
|
continue;
|
|
555
292
|
}
|
|
556
|
-
//
|
|
557
|
-
// is the load-bearing one.
|
|
293
|
+
// Before the whitespace test: a newline is both, and must separate.
|
|
558
294
|
if (OPERATOR.test(c)) {
|
|
559
295
|
flush();
|
|
560
296
|
let run = c;
|
|
@@ -576,7 +312,6 @@ function shellWords(command) {
|
|
|
576
312
|
return words;
|
|
577
313
|
}
|
|
578
314
|
|
|
579
|
-
/** Those words folded into `{ head, args, redirects }` per command in a pipeline or list. */
|
|
580
315
|
function shellSegments(command) {
|
|
581
316
|
const segments = [];
|
|
582
317
|
let current = { head: null, args: [], redirects: [] };
|
|
@@ -587,14 +322,6 @@ function shellSegments(command) {
|
|
|
587
322
|
};
|
|
588
323
|
for (const word of shellWords(command)) {
|
|
589
324
|
if (word.op) {
|
|
590
|
-
// `>` `>>` `2>` `&>` — whatever follows is written, whatever the command is. `<` and `<<`
|
|
591
|
-
// introduce something READ, so the word after them is SKIPPED rather than ending the
|
|
592
|
-
// segment: `tee < /tmp/in docs/vision.md` keeps `tee` as the head and its real argument
|
|
593
|
-
// as an argument, which is what goes red if this branch is removed. (An earlier draft
|
|
594
|
-
// justified it with `patch f < d.diff` instead. Measured: that one is safe either way,
|
|
595
|
-
// because without this branch the `<` merely closes the segment and `d.diff` becomes a
|
|
596
|
-
// head rather than an argument. A branch defended by a hazard that cannot happen is a
|
|
597
|
-
// branch nobody can review.)
|
|
598
325
|
if (word.text.includes(">")) pending = "written";
|
|
599
326
|
else if (word.text.includes("<")) pending = "read";
|
|
600
327
|
else {
|
|
@@ -620,23 +347,13 @@ function shellSegments(command) {
|
|
|
620
347
|
return segments;
|
|
621
348
|
}
|
|
622
349
|
|
|
623
|
-
/** Does this segment write the files it names? */
|
|
624
350
|
function writesWhatItNames({ head, args }) {
|
|
625
351
|
if (head === null) return false;
|
|
626
352
|
if (FILE_WRITERS.has(head)) return true;
|
|
627
|
-
// `-i`, `-i.bak`, `-pi`, `--in-place`, `--in-place=.bak`. A long option other than `--in-place`
|
|
628
|
-
// cannot match, because the single-dash form requires a letter run and `-` is not one.
|
|
629
353
|
return IN_PLACE_EDITORS.has(head) && args.some((a) => /^--in-place(=|$)/.test(a) || /^-[a-zA-Z]*i/.test(a));
|
|
630
354
|
}
|
|
631
355
|
|
|
632
|
-
/**
|
|
633
|
-
* A path as typed on a command line, reduced to the spelling the tail comparison can read.
|
|
634
|
-
*
|
|
635
|
-
* `matchesPath` compares tails literally, which is right for the host's own clean absolute paths and
|
|
636
|
-
* wrong for anything a person types: `docs/./vision.md` and `docs//vision.md` are the constitution
|
|
637
|
-
* and neither ends with `/docs/vision.md`. Both were live escapes for one review round. `..` is
|
|
638
|
-
* resolved for the same reason and in the same place — `foo/../docs/vision.md` is the file too.
|
|
639
|
-
*/
|
|
356
|
+
/** A typed path with its `.`, empty and `..` segments resolved, since `matchesPath` compares tails literally. */
|
|
640
357
|
function normalisePath(p) {
|
|
641
358
|
const absolute = p.startsWith("/");
|
|
642
359
|
const out = [];
|
|
@@ -648,88 +365,48 @@ function normalisePath(p) {
|
|
|
648
365
|
return (absolute ? "/" : "") + out.join("/");
|
|
649
366
|
}
|
|
650
367
|
|
|
651
|
-
/**
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
368
|
+
/** True when no host-submitted path can match: the target is not normalised, or keeps a backslash no candidate can hold. */
|
|
369
|
+
export function neverMatches(target) {
|
|
370
|
+
const clean = String(target ?? "").replace(/^\.\//, "").replace(/^\/+/, "");
|
|
371
|
+
// `clean === "/"` and `body === ""` are unreachable, kept to read line for line with `matchesPath`.
|
|
372
|
+
if (clean === "" || clean === "/") return true;
|
|
373
|
+
const body = clean.endsWith("/") ? clean.slice(0, -1) : clean;
|
|
374
|
+
if (body.includes("\\")) return true;
|
|
375
|
+
return body === "" || normalisePath(body) !== body;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The directories above a target, `a/b/c.md` -> `a`, `a/b`, with no trailing `/`, which would match every file under each. */
|
|
659
379
|
function ancestors(target) {
|
|
660
380
|
const parts = normalisePath(target.replace(/\/+$/, "")).split("/").filter(Boolean);
|
|
661
381
|
return parts.slice(0, -1).map((_, i) => parts.slice(0, i + 1).join("/"));
|
|
662
382
|
}
|
|
663
383
|
|
|
664
|
-
/**
|
|
665
|
-
* Does a word name the target — quoted, `./`-prefixed, absolute, messy, or after an `of=`-style `=`?
|
|
666
|
-
*
|
|
667
|
-
* With `orAncestor`, a word naming a DIRECTORY the target lives in counts too. That is what makes
|
|
668
|
-
* `rm -rf docs` and `mv docs docs.bak` reach a rule protecting `docs/vision.md`: destroying the
|
|
669
|
-
* container destroys the file, and a gate that reads `rm -rf docs/` and misses `rm -rf docs` is
|
|
670
|
-
* decided by a trailing slash. Ancestors apply to a writer's arguments only, never to a redirection
|
|
671
|
-
* target — `> docs` writes a file called `docs`, it does not remove a directory.
|
|
672
|
-
*/
|
|
384
|
+
/** Whether a word, or its value after an `of=`-style `=`, names the target; with `orAncestor`, a directory above it counts too. */
|
|
673
385
|
function namesTarget(word, target, orAncestor = false) {
|
|
674
386
|
const eq = word.indexOf("=");
|
|
675
387
|
const candidates = eq > 0 ? [word, word.slice(eq + 1)] : [word];
|
|
676
388
|
return candidates.some((c) => {
|
|
677
389
|
const clean = normalisePath(c);
|
|
678
390
|
if (clean === "") return false;
|
|
679
|
-
// A relative word is given the leading separator the tail comparison needs, rather than a
|
|
680
|
-
// second matcher being written for it.
|
|
681
391
|
const rooted = clean.startsWith("/") ? clean : `/${clean}`;
|
|
682
|
-
//
|
|
683
|
-
// 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/`.
|
|
684
393
|
if (matchesPath(target.endsWith("/") ? `${rooted}/` : rooted, target)) return true;
|
|
685
|
-
// A directory the target lives in, named EXACTLY — `rm -rf docs`, never `cp x docs/plan.md`.
|
|
686
394
|
return orAncestor && ancestors(target).some((a) => matchesPath(rooted, a));
|
|
687
395
|
});
|
|
688
396
|
}
|
|
689
397
|
|
|
690
|
-
/**
|
|
691
|
-
* The commands in a shell line, as SOURCE text, split on unquoted separators.
|
|
692
|
-
*
|
|
693
|
-
* The shell matcher below prefix-matches a command string, which meant a gate held only when its
|
|
694
|
-
* command came FIRST: `ls && git push --force origin main` reached no gate, and neither did
|
|
695
|
-
* `git status; gh pr merge 60` or `cd . && gh repo delete foo`. Every Gated outward action in
|
|
696
|
-
* ../.portulan/gates.json was defeated by putting anything in front of it.
|
|
697
|
-
*
|
|
698
|
-
* That is the same defect as the `write:` one this change began with — a matcher reading only the
|
|
699
|
-
* head of a command string — one action kind over, so it is fixed in the same stroke rather than
|
|
700
|
-
* left as a sibling nobody comes back for. Found by the fresh-context supervisor at the pre-merge
|
|
701
|
-
* checkpoint, which is exactly the class it was asked to hunt.
|
|
702
|
-
*
|
|
703
|
-
* Quoting is respected, so `echo "git push --force"` still splits into one segment that no gate
|
|
704
|
-
* matches; and each segment keeps the whitespace boundary the prefix match relies on, so
|
|
705
|
-
* `--force-with-lease` stays Auto.
|
|
706
|
-
*/
|
|
398
|
+
/** A shell line's commands as source text, split on unquoted separators, without heredoc bodies or leading redirections. */
|
|
707
399
|
function commandSegments(raw) {
|
|
708
|
-
// `String(raw ?? "")`, for the reason `spellings` does the same: `matchesRule` promises never to
|
|
709
|
-
// throw, and a Bash payload carrying no `command` — a malformed host message, a renamed field, a
|
|
710
|
-
// test — would otherwise reach `stripHeredocs` and die on `.split`.
|
|
711
|
-
//
|
|
712
|
-
// What that costs is not an exception some caller reports. ./gate.mjs catches
|
|
713
|
-
// and steps aside, so the throw would SILENTLY remove the shell-write half of the constitution
|
|
714
|
-
// gate — hole 3, the half with no permission rule beneath it. Introduced earlier on this branch
|
|
715
|
-
// by the segment-composition fix, which began passing the raw payload here in place of a spelling
|
|
716
|
-
// that had already been stringified. Found by Copilot review on #60.
|
|
717
400
|
const command = stripHeredocs(String(raw ?? ""));
|
|
718
401
|
const out = [];
|
|
719
402
|
let start = 0;
|
|
720
403
|
let quote = null;
|
|
404
|
+
// The last character a backslash made data: `\>` is a literal, not a redirection operator.
|
|
405
|
+
let escaped = -1;
|
|
721
406
|
for (let i = 0; i < command.length; i += 1) {
|
|
722
407
|
const c = command[i];
|
|
723
408
|
if (quote) {
|
|
724
|
-
//
|
|
725
|
-
// NOT close the run. Without this, `echo "x\""; git push --force …` closed early, the real
|
|
726
|
-
// closing quote opened a new run, and the `;` was swallowed inside it — no split, no
|
|
727
|
-
// segment, no gate. Measured stepping aside where the unescaped spelling answers `ask`,
|
|
728
|
-
// and the same shape reached `cp /tmp/x docs/vision.md`, so it was a false green on the
|
|
729
|
-
// constitution too. Found by Copilot review on #60.
|
|
730
|
-
//
|
|
731
|
-
// Single quotes are deliberately NOT included: POSIX gives `'…'` no escapes at all, so a
|
|
732
|
-
// 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.
|
|
733
410
|
if (quote === '"' && c === "\\" && i + 1 < command.length) {
|
|
734
411
|
i += 1;
|
|
735
412
|
continue;
|
|
@@ -742,77 +419,44 @@ function commandSegments(raw) {
|
|
|
742
419
|
continue;
|
|
743
420
|
}
|
|
744
421
|
if (c === "\\") {
|
|
745
|
-
//
|
|
746
|
-
// here — a continuation split into two segments still leaves a gated command at the head
|
|
747
|
-
// of one — so this is fixed for the reason the session kept re-learning rather than for a
|
|
748
|
-
// failing case: one carrier corrected and its sibling left is how the last three defects
|
|
749
|
-
// on this branch happened.
|
|
422
|
+
// `\r\n` as one pair, in step with `shellWords`.
|
|
750
423
|
i += command[i + 1] === "\r" && command[i + 2] === "\n" ? 2 : 1;
|
|
424
|
+
escaped = i;
|
|
751
425
|
continue;
|
|
752
426
|
}
|
|
753
|
-
// `#` is
|
|
754
|
-
//
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
// wrong runs the other way. A `#` opens a comment only at a word boundary and only outside
|
|
759
|
-
// quotes; a reader that skipped from the first `#` would swallow the rest of
|
|
760
|
-
// `echo "a#b"; git push --force origin main` — a real gated command, measured answering `ask`
|
|
761
|
-
// today — and turn a false red into a false GREEN. The docblock on `shellWrites` states the
|
|
762
|
-
// exchange rate: a false red costs one prompt on a rare spelling, a false green costs the
|
|
763
|
-
// laundering the rule exists to prevent.
|
|
764
|
-
//
|
|
765
|
-
// Both spellings are asserted in the suite so this stays a choice rather than drift.
|
|
766
|
-
// Reported by Copilot review on #60 and declined on those grounds.
|
|
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.
|
|
429
|
+
const opBefore = i - 1 !== escaped && (command[i - 1] === ">" || command[i - 1] === "<");
|
|
430
|
+
if (c === "&" && (opBefore || command[i + 1] === ">")) continue;
|
|
431
|
+
if (c === "|" && i - 1 !== escaped && command[i - 1] === ">") continue;
|
|
767
432
|
if (";|&()\n\r".includes(c)) {
|
|
768
433
|
out.push(command.slice(start, i));
|
|
769
434
|
start = i + 1;
|
|
770
435
|
}
|
|
771
436
|
}
|
|
772
437
|
out.push(command.slice(start));
|
|
773
|
-
return out.map((s) => s.trim()).filter(Boolean);
|
|
438
|
+
return out.map((s) => stripLeadingRedirections(s.trim())).filter(Boolean);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** A redirection target: one shell word, as `shellWords` reads it. Linear: once an operator matches, the match cannot fail. */
|
|
442
|
+
const REDIRECTION_TARGET = String.raw`(?:"(?:\\[\s\S]|[^"\\])*"|'[^']*'|\\[\s\S]|[^\s])+`;
|
|
443
|
+
|
|
444
|
+
const LEADING_REDIRECTION = new RegExp(String.raw`^\d*(?:&>>|&>|>>|>&|>\||<&|<>|<|>)\s*${REDIRECTION_TARGET}\s*`);
|
|
445
|
+
|
|
446
|
+
// The one segment leader stripped: a redirection's grammar is closed, where a table of command prefixes would have no edge.
|
|
447
|
+
function stripLeadingRedirections(segment) {
|
|
448
|
+
let text = segment;
|
|
449
|
+
for (;;) {
|
|
450
|
+
const shorter = text.replace(LEADING_REDIRECTION, "");
|
|
451
|
+
if (shorter === text) return text;
|
|
452
|
+
text = shorter;
|
|
453
|
+
}
|
|
774
454
|
}
|
|
775
455
|
|
|
776
456
|
/**
|
|
777
|
-
*
|
|
778
|
-
*
|
|
779
|
-
*
|
|
780
|
-
* path sits at an arbitrary position in the command, so there is no pattern in that DSL which means
|
|
781
|
-
* "any command writing this file" — which makes this the hook's coverage alone, exactly like the
|
|
782
|
-
* wrapper spelling above, and it fails open on the same terms.
|
|
783
|
-
*
|
|
784
|
-
* **Stated at its real size, because the boundary is the point.** Two recognitions, both by table:
|
|
785
|
-
* a `>`/`>>` redirection into the path, and a file-writing command that names it — or names a
|
|
786
|
-
* directory the path lives in, since removing the container removes the file.
|
|
787
|
-
*
|
|
788
|
-
* A heredoc is NOT on that list, and the omission is worth stating rather than leaving to be read as
|
|
789
|
-
* one: the body is stripped and the command line survives, so `cat > docs/vision.md <<EOF` is reached
|
|
790
|
-
* and gated — asserted in the suite. What escapes is an interpolated TARGET, heredoc or not, which is
|
|
791
|
-
* the first item below. The emitted note said "a heredoc" flatly until 2026-07-28 and overstated the
|
|
792
|
-
* hole; a gate map that overstates a hole is as wrong as one that hides it, and so is a compiler note.
|
|
793
|
-
*
|
|
794
|
-
* What that leaves open, listed because a hole list that is wrong is worse than none: an
|
|
795
|
-
* interpolated path (`> $VISION`), a command assembled at runtime, a language runtime writing the
|
|
796
|
-
* file itself (`python3 -c`), a writer absent from the table (`ex`, and `git checkout` deliberately
|
|
797
|
-
* — see above), a program that INVOKES a writer (`find -exec cp`, `xargs cp`) because parsing their
|
|
798
|
-
* flags to find the real command is the parser this file refuses to become, and two shell wrappers.
|
|
799
|
-
* Quoting is respected only to one level of nesting, so a write-shaped string inside a `node -e`
|
|
800
|
-
* script can produce a false RED — measured on this repository's own tooling.
|
|
801
|
-
*
|
|
802
|
-
* The first version of this shipped a four-item hole list that was missing five holes, including
|
|
803
|
-
* the plainest spelling there is: a newline. A fresh-context supervisor found them by trying to
|
|
804
|
-
* defeat the matcher rather than by reading it, which is the only way that list gets checked.
|
|
805
|
-
*
|
|
806
|
-
* This closes the spelling reached for by accident or convenience; it does not close one
|
|
807
|
-
* constructed on purpose, and no matcher here could. What must not happen regardless of spelling
|
|
808
|
-
* belongs on the platform floor — ../core/operating/autonomy.md.
|
|
809
|
-
*
|
|
810
|
-
* **Every named argument of a writer counts, not only its destination.** `cp docs/vision.md /tmp/x`
|
|
811
|
-
* reads the protected path rather than writing it, and this matches it anyway. Deliberate: argument
|
|
812
|
-
* grammars differ per command (`dd of=`, `tee f1 f2`, `install -t dir src`), so "the last word is
|
|
813
|
-
* the destination" is true of a subset only, and being wrong about it is a false GREEN on a rule
|
|
814
|
-
* whose whole reason is that the file must not change. A false red here costs one prompt on a rare
|
|
815
|
-
* 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.
|
|
816
460
|
*/
|
|
817
461
|
export function shellWrites(command, target) {
|
|
818
462
|
for (const segment of shellSegments(stripHeredocs(String(command ?? "")))) {
|
|
@@ -822,84 +466,30 @@ export function shellWrites(command, target) {
|
|
|
822
466
|
return false;
|
|
823
467
|
}
|
|
824
468
|
|
|
825
|
-
/**
|
|
469
|
+
/** Whether a host-submitted path falls under a policy target, compared by tail: the hook knows no reliable repository root. */
|
|
826
470
|
export function matchesPath(candidate, target) {
|
|
827
471
|
if (typeof candidate !== "string" || candidate === "") return false;
|
|
828
472
|
const clean = String(target ?? "").replace(/^\.\//, "").replace(/^\/+/, "");
|
|
829
473
|
if (clean === "" || clean === "/") return false;
|
|
830
|
-
// Compared against the tail rather than resolved against a root: the host hands over an
|
|
831
|
-
// absolute path, the policy names a repository-relative one, and the hook has no reliable
|
|
832
|
-
// repository root at hook time — its cwd is the session's, which need not be the repo.
|
|
833
474
|
const normalised = candidate.replace(/\\/g, "/");
|
|
834
475
|
return clean.endsWith("/") ? normalised.includes(`/${clean}`) : normalised.endsWith(`/${clean}`);
|
|
835
476
|
}
|
|
836
477
|
|
|
837
|
-
/**
|
|
478
|
+
/** Whether a tool call falls under the rule; never throws, since the gate steps aside on a throw. */
|
|
838
479
|
export function matchesRule(rule, tool, input = {}) {
|
|
839
480
|
const action = rule?.action ?? {};
|
|
840
481
|
if (typeof action.shell === "string" && tool === "Bash") {
|
|
841
|
-
// A
|
|
842
|
-
// already gives a trailing slash for write and read, and the meaning the emitted
|
|
843
|
-
// `Bash(target:*)` rule already has on the host, which prefix-matches the command string.
|
|
844
|
-
// Without this branch the two halves this section exists to keep identical disagreed: for
|
|
845
|
-
// `"./.portulan/verify/"` the emitted rule covers `./.portulan/verify/docs.sh` and this
|
|
846
|
-
// matcher did not, because a path prefix is never followed by a space.
|
|
847
|
-
//
|
|
848
|
-
// **Stated at its real size.** The one target of this shape in ../.portulan/gates.json is
|
|
849
|
-
// `run-a-verify-recipe`, which is `auto` — refused from compilation, and skipped by
|
|
850
|
-
// ./gate.mjs, which reads only `gated` and `prohibited`. So nothing was
|
|
851
|
-
// mis-enforced today; what existed was a divergence between two definitions this file
|
|
852
|
-
// promises are one, waiting for the first gated rule written in the path form. Fixed on that
|
|
853
|
-
// basis rather than on an incident. An ordinary command prefix keeps its whitespace boundary,
|
|
854
|
-
// or `git push` would cover `git pushall`. Found by a Copilot review comment on #31.
|
|
855
|
-
//
|
|
856
|
-
// Each spelling is tested WHOLE and per segment. Whole, because a path-prefix target is
|
|
857
|
-
// matched against the command as written; per segment, because a gated command that is not
|
|
858
|
-
// 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.
|
|
859
483
|
const hit = (s) =>
|
|
860
484
|
action.shell.endsWith("/") ? s.startsWith(action.shell) : s === action.shell || s.startsWith(`${action.shell} `);
|
|
861
|
-
//
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
// origin main"` stepped aside, while both halves of it worked alone — the wrapper spelling is
|
|
865
|
-
// hole 1's "one level, peeled", and mid-line reach is hole 2. Two claims that each held and
|
|
866
|
-
// did not compose, which is how a gap survives a reader checking either one.
|
|
867
|
-
//
|
|
868
|
-
// Found while writing a regression test for the `$'…'` form Copilot reported; the plain-quote
|
|
869
|
-
// spelling turned out to escape the same way, so this is wider than the report.
|
|
870
|
-
//
|
|
871
|
-
// **Both calls read the RAW command, and that is the whole care in this line.** The first
|
|
872
|
-
// draft segmented each *spelling* and unwrapped again — which peeled TWO levels for a nested
|
|
873
|
-
// wrapper and made `bash -c "bash -c 'git push origin HEAD'"` match, contradicting hole 1's
|
|
874
|
-
// documented and asserted limit. The suite caught it, which is what that counterexample test
|
|
875
|
-
// is for. Reading the raw command in both branches keeps the budget at exactly one unwrap.
|
|
876
|
-
const segments = commandSegments(input.command);
|
|
877
|
-
return spellings(input.command).some(hit) || segments.some((seg) => spellings(seg).some(hit));
|
|
485
|
+
// One unwrap, with splitting before and after it: `ls && bash -c "x; git push"` is reached; a second wrapper is not.
|
|
486
|
+
const reach = (s) => hit(s) || commandSegments(s).some(hit);
|
|
487
|
+
return spellings(input.command).some(reach) || commandSegments(input.command).some((seg) => spellings(seg).some(reach));
|
|
878
488
|
}
|
|
879
489
|
if (typeof action.write === "string") {
|
|
880
490
|
if (WRITE_TOOLS.includes(tool)) return matchesPath(input.file_path ?? input.notebook_path, action.write);
|
|
881
|
-
// A `write:` rule names a PATH, not a tool. For one milestone it reached only the three tools
|
|
882
|
-
// that carry a `file_path`, so `echo x >> docs/vision.md` through Bash was gated by neither
|
|
883
|
-
// layer: the permission rule rejects the tool, and this matcher fell through to `false`. The
|
|
884
|
-
// rule's own sentence is what that cost — an agent that can edit the constitution can launder
|
|
885
|
-
// any other change past its own grader — and it was reachable inside a session, with only the
|
|
886
|
-
// platform floor stopping it from landing. See `shellWrites` above for what this covers and,
|
|
887
|
-
// more to the point, for what it does not.
|
|
888
491
|
if (tool === "Bash") {
|
|
889
|
-
// Whole line and per segment, exactly as the `shell` branch above
|
|
890
|
-
// reason, arrived at the hard way. That branch grew segment composition when a wrapper
|
|
891
|
-
// that was not first on the line turned out to escape it. This branch did not, and the
|
|
892
|
-
// gap it left is the worse of the two: `git status; bash -c "echo x >> docs/vision.md"`
|
|
893
|
-
// stepped aside where the same wrapper alone answers `deny`, so the CONSTITUTION was
|
|
894
|
-
// reachable behind any separator plus one wrapper. Measured 2026-07-28.
|
|
895
|
-
//
|
|
896
|
-
// A fix landing in one carrier and not its sibling is the defect class this repository
|
|
897
|
-
// has a standing ruling about; it happened here between two branches of one function,
|
|
898
|
-
// five commits apart. Found by Copilot review on #60, not by the session that wrote the
|
|
899
|
-
// first half.
|
|
900
|
-
//
|
|
901
|
-
// Both calls read the RAW command so the unwrap budget stays at one level, which is what
|
|
902
|
-
// 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.
|
|
903
493
|
const writes = (s) => shellWrites(s, action.write);
|
|
904
494
|
return (
|
|
905
495
|
spellings(input.command).some(writes) ||
|
|
@@ -913,56 +503,20 @@ export function matchesRule(rule, tool, input = {}) {
|
|
|
913
503
|
return false;
|
|
914
504
|
}
|
|
915
505
|
|
|
916
|
-
//
|
|
917
|
-
//
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
// A backend takes the parsed policy and returns one shape, always:
|
|
921
|
-
//
|
|
922
|
-
// { backend, label, compiled: [{id, tier, surface}], refused: [{id, tier, why}],
|
|
923
|
-
// notes: [string], artifact: { path, value, text } | null }
|
|
924
|
-
//
|
|
925
|
-
// `compiled` and `refused` together always equal the input, per backend — asserted by the suite,
|
|
926
|
-
// because the distinctive failure of a compiler that emits gate machinery is a rule that goes in
|
|
927
|
-
// and nothing comes out, leaving a gate map that reads as configured and a machine that enforces
|
|
928
|
-
// nothing. The uniform shape is what lets the matrix and `doctor` read every backend the same way
|
|
929
|
-
// 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.
|
|
930
509
|
|
|
931
|
-
/** A workspace-relative path, as the host spells it. A trailing `/` means the subtree. */
|
|
932
510
|
function pathSpec(target) {
|
|
933
511
|
const clean = target.replace(/^\.\//, "").replace(/^\/+/, "");
|
|
934
512
|
return clean.endsWith("/") ? `./${clean}**` : `./${clean}`;
|
|
935
513
|
}
|
|
936
514
|
|
|
937
|
-
/** A workspace-relative path becomes a host permission pattern. */
|
|
938
515
|
function pattern(tool, target) {
|
|
939
516
|
return `${tool}(${pathSpec(target)})`;
|
|
940
517
|
}
|
|
941
518
|
|
|
942
|
-
//
|
|
943
|
-
// ruling of 2026-07-27: the compiler emits restriction only, never permission. A compiler whose
|
|
944
|
-
// output can only ever ADD a gate cannot loosen an existing check by having a bug.
|
|
945
|
-
//
|
|
946
|
-
// ## What the ruling costs — which this comment used to leave to the reader's imagination
|
|
947
|
-
//
|
|
948
|
-
// Refusing to emit `allow` does not make the Auto tier free. It moves the tier's price off this
|
|
949
|
-
// repository. The host prompts for any command it has not been told about, so every Auto action is
|
|
950
|
-
// answered by hand, and those answers pile up in the host's own settings files, which are
|
|
951
|
-
// per-machine, unreviewable, absent from every diff, and — for a workflow built on throwaway
|
|
952
|
-
// worktrees — lost along with the branch that earned them. Measured on one host, 2026-07-28: 404 hand-added allow
|
|
953
|
-
// entries, of which exactly ONE corresponded to an Auto rule in ../.portulan/gates.json. So the
|
|
954
|
-
// tier is unattended in POLICY and heavily attended in PRACTICE, and those are not the same claim.
|
|
955
|
-
// The refusal string below used to make the first one and read as the second.
|
|
956
|
-
//
|
|
957
|
-
// ## The ruling stands, on a different sentence than before
|
|
958
|
-
//
|
|
959
|
-
// "Emitting `allow` would loosen a check" is not the hazard, or not obviously so: `git push` is
|
|
960
|
-
// Auto and `git push --force` is Gated, and whether a narrower `ask` still outranks a broader
|
|
961
|
-
// `allow` is a host precedence question **this repository has never measured** — so no argument
|
|
962
|
-
// here should rest on it. The hazard that does not depend on that answer: an allow prefix reaches
|
|
963
|
-
// every spelling beneath it, including the ones no rule here names. `git push --mirror` is
|
|
964
|
-
// destructive and appears in no tier of ../.portulan/gates.json. An allow on the prefix would
|
|
965
|
-
// 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.
|
|
966
520
|
const HOST_GATE_TIERS = new Set(["gated", "prohibited"]);
|
|
967
521
|
|
|
968
522
|
const HOST_TIER_NOT_A_GATE = {
|
|
@@ -970,72 +524,30 @@ const HOST_TIER_NOT_A_GATE = {
|
|
|
970
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",
|
|
971
525
|
};
|
|
972
526
|
|
|
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"];
|
|
529
|
+
|
|
973
530
|
/**
|
|
974
|
-
*
|
|
975
|
-
*
|
|
976
|
-
* The mapping, each line of it measured against a running host rather than read from documentation:
|
|
977
|
-
*
|
|
978
|
-
* prohibited -> permissions.deny + a hook returning `deny`
|
|
979
|
-
* gated -> permissions.ask + a hook returning `ask`
|
|
980
|
-
*
|
|
981
|
-
* The hook returns the SAME decision as the permission rule on purpose. A hook returning `deny` for
|
|
982
|
-
* a Gated action would turn a per-action prompt into a hard block, which is the tier above it.
|
|
983
|
-
*
|
|
984
|
-
* One line of that mapping has no permission half. A `write:` rule also gates the SHELL spellings of
|
|
985
|
-
* a write — see `shellWrites` above — and no `Bash(prefix:*)` pattern can express "a command writing
|
|
986
|
-
* this path", so that half is the hook's alone and fails open with it. Emitted anyway, because the
|
|
987
|
-
* alternative was leaving `echo x >> docs/vision.md` gated by nothing at all, and reported in the
|
|
988
|
-
* 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.
|
|
989
533
|
*/
|
|
990
534
|
export function claudeCode(parsed, options = {}) {
|
|
991
|
-
// The header names the policy file that was ACTUALLY read. It was a literal for one round, so a
|
|
992
|
-
// workspace declaring a non-default policy got an artifact claiming it came from somewhere it did
|
|
993
|
-
// not — in the one field whose entire job is telling a reader what generated this. Found by review.
|
|
994
535
|
const source = options.source ?? ".portulan/gates.json";
|
|
995
|
-
// **Where the emitted hook finds its runner, and why this is computed rather than written down.**
|
|
996
|
-
//
|
|
997
|
-
// Both runners used to live at `.portulan/compile/`, which `package.json`'s `files` has never
|
|
998
|
-
// shipped — so every adopter's compiled policy named two files they did not receive, and a missing
|
|
999
|
-
// hook FAILS OPEN. Milestone 7 moves them into `cli/`, which does ship. That fixes what the adopter
|
|
1000
|
-
// has; it does not by itself fix how the hook names it, because `cli/` sits in a different place in
|
|
1001
|
-
// each of the three contexts this must survive:
|
|
1002
|
-
//
|
|
1003
|
-
// 1. this checkout — `<project>/cli/`
|
|
1004
|
-
// 2. a project-local install — `<project>/node_modules/@sleepy_panda_srl/portulan/cli/`
|
|
1005
|
-
// 3. a global or npx-only install — NOT under the project at all
|
|
1006
|
-
//
|
|
1007
|
-
// So the path is derived from where THIS file actually is at compile time and expressed relative to
|
|
1008
|
-
// the project when it lands inside it — covering 1 and 2 with one rule rather than two special
|
|
1009
|
-
// cases. For 3 there is no project-relative spelling that exists, so an absolute path is emitted and
|
|
1010
|
-
// a **note** records that the hook is pinned to this machine — see the `pinned` push below. Naming
|
|
1011
|
-
// that is the point: a hook silently pinned to an absolute path is a policy that stops working when
|
|
1012
|
-
// the package moves, and finding out by having no gate is the worst way to find out.
|
|
1013
|
-
//
|
|
1014
|
-
// This sentence said `refused` for one round, and `refused` carries rule-level compilation refusals
|
|
1015
|
-
// only — nothing ever added the runner path to it. The pre-commit checkpoint found the recording
|
|
1016
|
-
// missing entirely; the fix added it on the `notes` channel and left this line still naming the
|
|
1017
|
-
// wrong one, so a reader following the comment would have looked in a place that never holds it.
|
|
1018
|
-
// Copilot's round caught the remainder. A fix that leaves its own explanation pointing elsewhere is
|
|
1019
|
-
// half a fix.
|
|
1020
536
|
const runnerDir = path.dirname(fileURLToPath(import.meta.url));
|
|
1021
|
-
// The project this policy is being compiled FOR. `claudeCode` is handed a parsed policy and not a
|
|
1022
|
-
// location, so the caller may say; `cwd` is the honest default because `compile` is run from the
|
|
1023
|
-
// repository it compiles.
|
|
1024
537
|
const project = options.root ?? process.cwd();
|
|
1025
538
|
const pinned = [];
|
|
1026
539
|
const spell = (file) => {
|
|
1027
540
|
const abs = path.join(runnerDir, file);
|
|
1028
541
|
const rel = path.relative(project, abs);
|
|
1029
|
-
//
|
|
1030
|
-
// directory literally named `..foo`. Benign in direction (it would fall back to absolute), but
|
|
1031
|
-
// 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.
|
|
1032
543
|
const inside = rel && rel.split(path.sep)[0] !== ".." && !path.isAbsolute(rel);
|
|
1033
544
|
if (inside) return `"\${CLAUDE_PROJECT_DIR}/${rel.split(path.sep).join("/")}"`;
|
|
1034
545
|
pinned.push({ file, abs });
|
|
1035
546
|
return `"${abs}"`;
|
|
1036
547
|
};
|
|
1037
|
-
const runner = options.runner ?? spell(
|
|
1038
|
-
const stopRunner = options.stopRunner ?? spell(
|
|
548
|
+
const runner = options.runner ?? spell(HOOK_RUNNERS[0]);
|
|
549
|
+
const stopRunner = options.stopRunner ?? spell(HOOK_RUNNERS[1]);
|
|
550
|
+
const advisoryRunner = options.advisoryRunner ?? spell(HOOK_RUNNERS[2]);
|
|
1039
551
|
|
|
1040
552
|
const compiled = [];
|
|
1041
553
|
const refused = [];
|
|
@@ -1043,6 +555,7 @@ export function claudeCode(parsed, options = {}) {
|
|
|
1043
555
|
const ask = [];
|
|
1044
556
|
const matchers = new Set();
|
|
1045
557
|
const shellWriteGates = [];
|
|
558
|
+
const editCoveredGates = [];
|
|
1046
559
|
|
|
1047
560
|
for (const rule of parsed.rules) {
|
|
1048
561
|
if (!HOST_GATE_TIERS.has(rule.tier)) {
|
|
@@ -1050,110 +563,109 @@ export function claudeCode(parsed, options = {}) {
|
|
|
1050
563
|
continue;
|
|
1051
564
|
}
|
|
1052
565
|
if (rule.kind === "none") {
|
|
1053
|
-
//
|
|
1054
|
-
// rather than inventing its own — a refusal explained by the tool is a refusal nobody
|
|
1055
|
-
// can review against the policy.
|
|
566
|
+
// A `none` target is the policy's own sentence for why nothing can be gated.
|
|
1056
567
|
refused.push({ id: rule.id, tier: rule.tier, why: rule.target });
|
|
1057
568
|
continue;
|
|
1058
569
|
}
|
|
570
|
+
// A throw, not a `refused` row: a gate declared at an enforcing tier that can never match is a malformed policy.
|
|
571
|
+
if ((rule.kind === "write" || rule.kind === "read") && neverMatches(rule.target)) {
|
|
572
|
+
throw new CompileError(
|
|
573
|
+
`rule \`${rule.id}\` is ${rule.tier} and its ${rule.kind} target ${JSON.stringify(rule.target)} matches no path a host ` +
|
|
574
|
+
`can submit: the matcher compares a tail, and this target's comparison form is one no candidate can carry. ` +
|
|
575
|
+
`This backend would emit ${pattern(rule.kind === "write" ? "Edit" : READ_TOOLS[0], rule.target)} ` +
|
|
576
|
+
`and report the rule compiled, while the runtime matcher answered false for every input: a gate that reads as whole ` +
|
|
577
|
+
`from outside and enforces nothing. Name the path the rule protects in its normalised spelling — no ` +
|
|
578
|
+
`\`.\` or empty segment, no backslash — or drop the rule; and where this ` +
|
|
579
|
+
`rule came from a pack, it is the pack that carries it, not the workspace composing it. ` +
|
|
580
|
+
`(A never-matching target is harmless at \`auto\`, which this backend refuses one step earlier; it is a hollow gate only ` +
|
|
581
|
+
`at the tiers that enforce. See .portulan/gate-map.md, honest holes, entry 8.)`,
|
|
582
|
+
);
|
|
583
|
+
}
|
|
1059
584
|
const into = rule.tier === "prohibited" ? deny : ask;
|
|
1060
|
-
const emitted = []; // permission patterns
|
|
1061
|
-
const hookOnly = [];
|
|
585
|
+
const emitted = []; // permission patterns, the layer that cannot fail open
|
|
586
|
+
const hookOnly = [];
|
|
1062
587
|
if (rule.kind === "shell") {
|
|
1063
588
|
emitted.push(`Bash(${rule.target}:*)`);
|
|
1064
589
|
matchers.add("Bash");
|
|
1065
590
|
} else {
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
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.
|
|
593
|
+
const permissionTools = rule.kind === "write" ? ["Edit"] : READ_TOOLS;
|
|
594
|
+
for (const tool of permissionTools) emitted.push(pattern(tool, rule.target));
|
|
595
|
+
for (const tool of rule.kind === "write" ? WRITE_TOOLS : READ_TOOLS) matchers.add(tool);
|
|
1070
596
|
if (rule.kind === "write") {
|
|
1071
|
-
|
|
1072
|
-
// `
|
|
1073
|
-
// The matcher alone would be inert in any workspace whose policy declares no shell
|
|
1074
|
-
// gate: `Bash` would be absent from the matchers below, the runner would never be
|
|
1075
|
-
// invoked for a Bash call, and the coverage would exist only in a function nothing
|
|
1076
|
-
// calls — the manifest-field-that-validates-and-loads-nothing defect
|
|
1077
|
-
// (../.portulan/memory/a-manifest-field-can-validate-and-load-nothing.md), arriving
|
|
1078
|
-
// this time as a matcher nothing reaches. It changes no artifact in THIS repository,
|
|
1079
|
-
// whose policy already gates shell commands, which is precisely why it would not have
|
|
1080
|
-
// been noticed here.
|
|
1081
|
-
//
|
|
1082
|
-
// No permission pattern joins it, and that is not an omission: `Bash(prefix:*)`
|
|
1083
|
-
// matches a literal command prefix while the path sits at an arbitrary position, so
|
|
1084
|
-
// the DSL has no way to say "any command writing this file". The only patterns that
|
|
1085
|
-
// would fit — `Bash(cp:*)`, `Bash(sed -i:*)` — gate the utility rather than the path,
|
|
1086
|
-
// which is a different and much larger rule than the policy declares.
|
|
597
|
+
editCoveredGates.push(rule.id);
|
|
598
|
+
// Without `Bash` among the matchers, a policy with no shell gate would never run the hook on a shell write.
|
|
1087
599
|
matchers.add("Bash");
|
|
1088
600
|
hookOnly.push(`hook: a Bash command writing ${pathSpec(rule.target)}`);
|
|
1089
601
|
shellWriteGates.push(rule.id);
|
|
1090
602
|
}
|
|
1091
603
|
}
|
|
1092
604
|
into.push(...emitted);
|
|
1093
|
-
// The hook-only clause is carried in the SURFACE and not in `into`. It is not a permission
|
|
1094
|
-
// pattern, and a reader of the matrix has to be able to tell which half of a gate would
|
|
1095
|
-
// survive a broken hook.
|
|
1096
605
|
compiled.push({ id: rule.id, tier: rule.tier, surface: [...emitted, ...hookOnly].join(" · ") });
|
|
1097
606
|
}
|
|
1098
607
|
|
|
1099
|
-
// A policy that declares gates and emits none must not report success. This is the workflow's
|
|
1100
|
-
// "declares no verify recipes — refusing to report green" one level down: the artifact would be
|
|
1101
|
-
// written, the gate map would read as compiled, and nothing would hold.
|
|
1102
608
|
if (parsed.rules.some((r) => HOST_GATE_TIERS.has(r.tier)) && compiled.length === 0) {
|
|
1103
609
|
throw new CompileError(
|
|
1104
610
|
"every gate in this policy refused to compile — refusing to write an artifact that enforces nothing while the policy claims gates",
|
|
1105
611
|
);
|
|
1106
612
|
}
|
|
1107
613
|
|
|
1108
|
-
//
|
|
1109
|
-
// one edit this whole rail exists to catch — a hand-fix that survives until the next compile
|
|
1110
|
-
// silently reverts it.
|
|
1111
|
-
// **What compiled this, recorded beside what generated it** (#264). An unpinned run WOULD read
|
|
1112
|
-
// the host's plugin cache while the rail reads the tree — it now refuses that arrangement outright
|
|
1113
|
-
// (#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
|
|
1114
|
-
// deciding input was a directory outside it.
|
|
1115
|
-
//
|
|
1116
|
-
// **Origin and version, never the ROOT PATH.** The discovered root is an absolute path under
|
|
1117
|
-
// somebody's home directory; recording it would make a tracked artifact machine-dependent and red
|
|
1118
|
-
// `verify/compile.sh` for every developer and CI — trading a silent hazard for a permanent false
|
|
1119
|
-
// one. `recordedOrigin` collapses the resolver's three tags to two for the same reason: the pinned
|
|
1120
|
-
// 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.
|
|
1121
615
|
const packs = (options.packProvenance ?? [])
|
|
1122
616
|
.map((c) => ({ pack: c.pack, origin: c.origin, version: c.version ?? null }))
|
|
1123
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 ");
|
|
1124
634
|
const value = {
|
|
1125
635
|
$portulan: {
|
|
1126
636
|
generated: "cli/compile.mjs",
|
|
1127
637
|
source,
|
|
638
|
+
...(sessionsFrom ? { sessions: sessionsFrom } : {}),
|
|
639
|
+
...(spendFrom ? { spend: spendFrom } : {}),
|
|
1128
640
|
...(packs.length ? { packs } : {}),
|
|
1129
|
-
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.`,
|
|
1130
644
|
},
|
|
1131
645
|
permissions: { deny, ask, allow: [] },
|
|
1132
646
|
hooks: {
|
|
1133
|
-
//
|
|
1134
|
-
// spaces in it, and an unquoted expansion would word-split into a runner nobody can
|
|
1135
|
-
// find. No pipes, redirections or separators — an emitted shell one-liner is where the
|
|
1136
|
-
// 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.
|
|
1137
648
|
PreToolUse: [...matchers].sort().map((matcher) => ({
|
|
1138
649
|
matcher,
|
|
1139
650
|
hooks: [{ type: "command", command: `node ${runner}` }],
|
|
1140
651
|
})),
|
|
1141
|
-
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}` }] }],
|
|
1142
663
|
},
|
|
664
|
+
statusLine: { type: "command", command: `node ${advisoryRunner} status${spendFlags}` },
|
|
665
|
+
...switches,
|
|
1143
666
|
};
|
|
1144
667
|
|
|
1145
|
-
// The shell half of every write gate, reported on every run rather than left for a reader to
|
|
1146
|
-
// infer from the absence of a `Bash` entry in `deny`. It is the one place in this artifact where
|
|
1147
|
-
// a gate is carried by the layer that fails open, so a note that only appeared when something
|
|
1148
|
-
// went wrong would be a note nobody ever reads.
|
|
1149
668
|
const notes = [];
|
|
1150
|
-
// **The promise this comment used to make and the code did not keep.** The fallback above emits an
|
|
1151
|
-
// absolute path when the runner is not under the project — a global or npx-only install — and the
|
|
1152
|
-
// paragraph at the top of this function said `refused` would record that the hook is pinned to this
|
|
1153
|
-
// machine. It recorded nothing: the pre-commit checkpoint compiled from a package outside a project
|
|
1154
|
-
// and got two absolute hooks with **zero output about it**. A hook silently pinned to one machine is
|
|
1155
|
-
// a policy that stops working when the package moves, and finding out by having no gate is the worst
|
|
1156
|
-
// way to find out — so it is said, on the channel that prints every run.
|
|
1157
669
|
if (pinned.length) {
|
|
1158
670
|
notes.push(
|
|
1159
671
|
`${pinned.length} hook(s) are pinned to an ABSOLUTE path on this machine — ` +
|
|
@@ -1163,6 +675,68 @@ export function claudeCode(parsed, options = {}) {
|
|
|
1163
675
|
`project, or pass \`--runner\`/\`--stop-runner\` to name a path you control`,
|
|
1164
676
|
);
|
|
1165
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
|
+
}
|
|
725
|
+
if (editCoveredGates.length) {
|
|
726
|
+
notes.push(
|
|
727
|
+
`${editCoveredGates.length} write gate(s) — ${editCoveredGates.join(", ")} — emit \`Edit(path)\` as their only ` +
|
|
728
|
+
`permission pattern, and that ONE pattern is matched for every file-editing tool. Measured on Claude Code ` +
|
|
729
|
+
`2.1.240: with \`Edit(path)\` alone in the list and \`Write\` allowed, the host refused the write — and a ` +
|
|
730
|
+
`\`Write\` to a DIFFERENT path succeeded in the same session, the control that tells *refused* from ` +
|
|
731
|
+
`*refuses everything*. \`Write(path)\` ` +
|
|
732
|
+
`and \`NotebookEdit(path)\` are NOT emitted because the host discards them — it says so on every start — and a ` +
|
|
733
|
+
`compiler that emitted them would report three rules where one enforces. **Re-measure on a host change in EITHER ` +
|
|
734
|
+
`direction:** this is a fact about one CLI version. If a later host stops treating \`Edit\` as tool-general, ` +
|
|
735
|
+
`this gate narrows to one tool at the permission layer with nothing here going red. And no EARLIER host was ` +
|
|
736
|
+
`re-measured — on one that honoured all three patterns and did not treat \`Edit\` as tool-general, this ` +
|
|
737
|
+
`emission removes two working rules. The hook covers all three tool names on any host.`,
|
|
738
|
+
);
|
|
739
|
+
}
|
|
1166
740
|
if (shellWriteGates.length) {
|
|
1167
741
|
notes.push(
|
|
1168
742
|
`${shellWriteGates.length} write gate(s) — ${shellWriteGates.join(", ")} — also match a Bash command that writes the ` +
|
|
@@ -1187,42 +761,16 @@ export function claudeCode(parsed, options = {}) {
|
|
|
1187
761
|
};
|
|
1188
762
|
}
|
|
1189
763
|
|
|
1190
|
-
//
|
|
1191
|
-
|
|
1192
|
-
//
|
|
1193
|
-
//
|
|
1194
|
-
// The milestone-4 criterion positions this as **the floor backend**: what every host falls back to,
|
|
1195
|
-
// and all that a host with no hook system has. `../core/operating/autonomy.md` says the floor is the
|
|
1196
|
-
// floor "because it holds when everything above it fails", and promises that the enforcement compiler
|
|
1197
|
-
// reads the workspace's gate policy and generates the host's own enforcement; this is that promise,
|
|
1198
|
-
// discharged for the floor half. (Quoted rather than paraphrased-in-quotes, which is the small version
|
|
1199
|
-
// of the defect ../.portulan/memory/a-stated-enforcer-must-be-the-real-one.md is about.)
|
|
1200
|
-
//
|
|
1201
|
-
// It **generates, never applies.** Importing a ruleset is a repository-settings change — outward,
|
|
1202
|
-
// Gated, the maintainer's, per the proposal-0001 precedent — so this emits a file and stops.
|
|
1203
|
-
//
|
|
1204
|
-
// Only the server's *input* fields are emitted: an export carrying an `id` or a `created_at` would be
|
|
1205
|
-
// asserting facts it cannot know. The envelope and that omission list were read from two live rulesets
|
|
1206
|
-
// on 2026-07-27; the `pull_request` and `required_status_checks` parameter blocks were **not** — neither
|
|
1207
|
-
// live ruleset carries those rules — so those come from GitHub's documented schema, and
|
|
1208
|
-
// ../.portulan/compile/README.md marks them as such rather than folding them into "read from live".
|
|
1209
|
-
//
|
|
1210
|
-
// **Recognition is by exact spelling, and that is a limit rather than an oversight.** The action
|
|
1211
|
-
// vocabulary has no `ref` kind — a rule says `{"shell": "git push --force"}` — so this backend
|
|
1212
|
-
// matches command strings against a table and refuses everything else out loud. A parser that
|
|
1213
|
-
// recognised `git push -f` as the same action would be the ambitious parser `../spec/slots.md`
|
|
1214
|
-
// 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.
|
|
1215
767
|
const REF_RULES = new Map([
|
|
1216
768
|
["git push --force", "non_fast_forward"],
|
|
1217
769
|
["git push --delete", "deletion"],
|
|
1218
770
|
]);
|
|
1219
771
|
|
|
1220
|
-
// Which tiers this backend may emit a ref rule for. `prohibited` is included and `auto` is not:
|
|
1221
|
-
// a prohibition on a ref operation is a restriction the floor can carry, while an unattended action
|
|
1222
|
-
// needs no ref gate by definition.
|
|
1223
772
|
const FLOOR_GATE_TIERS = new Set(["gated", "prohibited"]);
|
|
1224
773
|
|
|
1225
|
-
/** Commands a *tag* ruleset would reach. Named individually so the refusal can say which mechanism. */
|
|
1226
774
|
const TAG_SHAPED = new Set(["git tag", "gh release"]);
|
|
1227
775
|
|
|
1228
776
|
export function githubRuleset(parsed, options = {}) {
|
|
@@ -1232,9 +780,6 @@ export function githubRuleset(parsed, options = {}) {
|
|
|
1232
780
|
const refused = [];
|
|
1233
781
|
const notes = [];
|
|
1234
782
|
|
|
1235
|
-
// No floor declared: refuse everything, by name, and write nothing. Defaulting the branch to
|
|
1236
|
-
// `main` was the obvious alternative and is the exact move this compiler must not make — a
|
|
1237
|
-
// compiler that invents the ref it gates has stopped compiling policy and started writing it.
|
|
1238
783
|
if (!floor) {
|
|
1239
784
|
for (const rule of parsed.rules) {
|
|
1240
785
|
refused.push({
|
|
@@ -1247,31 +792,15 @@ export function githubRuleset(parsed, options = {}) {
|
|
|
1247
792
|
}
|
|
1248
793
|
|
|
1249
794
|
const rules = [];
|
|
1250
|
-
// Both halves of the condition are load-bearing, and the second was missing for one round. The
|
|
1251
|
-
// pair is **compiled from `propose` rules**, so a policy with none of them asked for no
|
|
1252
|
-
// pull-request requirement — emitting one because `floor.checks` happens to be declared is the
|
|
1253
|
-
// compiler inventing policy, which is the single thing this backend must not do. It also broke
|
|
1254
|
-
// the accounting silently: those two ruleset rules would have sat in the artifact with no policy
|
|
1255
|
-
// rule credited for compiling them, so the matrix and `doctor` would have described a floor
|
|
1256
|
-
// missing two of its own rules. Found by review, round 3.
|
|
1257
795
|
const declaresPropose = parsed.rules.some((r) => r.tier === "propose");
|
|
1258
796
|
const hasChecks = floor.checks.length > 0;
|
|
1259
797
|
const emitPullRequestPair = hasChecks && declaresPropose;
|
|
1260
798
|
|
|
1261
|
-
// The propose tier and the floor's pull-request pair. Emitted together or not at all: a
|
|
1262
|
-
// `pull_request` rule without `required_status_checks` imports cleanly, reads as a configured
|
|
1263
|
-
// floor, and lets a red pull request merge. Half a mapping is the silent weakening this
|
|
1264
|
-
// repository keeps finding, so the refusal names the missing declaration instead.
|
|
1265
799
|
if (emitPullRequestPair) {
|
|
1266
800
|
rules.push({
|
|
1267
801
|
type: "pull_request",
|
|
1268
802
|
parameters: {
|
|
1269
|
-
//
|
|
1270
|
-
// real optional parameter and was dropped deliberately: nothing in the policy drives
|
|
1271
|
-
// it and the criterion does not name it, so emitting it would widen the part of this
|
|
1272
|
-
// artifact that is invented rather than compiled, to buy nothing. (An earlier draft
|
|
1273
|
-
// also argued it "appears in neither live ruleset" — vacuous, since neither of those
|
|
1274
|
-
// 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.
|
|
1275
804
|
required_approving_review_count: floor.reviews,
|
|
1276
805
|
dismiss_stale_reviews_on_push: true,
|
|
1277
806
|
require_code_owner_review: false,
|
|
@@ -1282,9 +811,7 @@ export function githubRuleset(parsed, options = {}) {
|
|
|
1282
811
|
rules.push({
|
|
1283
812
|
type: "required_status_checks",
|
|
1284
813
|
parameters: {
|
|
1285
|
-
// Not
|
|
1286
|
-
// behind its base (proposal 0011, applied live 2026-07-27). A declarable `strict`
|
|
1287
|
-
// 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.
|
|
1288
815
|
strict_required_status_checks_policy: true,
|
|
1289
816
|
do_not_enforce_on_create: false,
|
|
1290
817
|
required_status_checks: floor.checks,
|
|
@@ -1306,12 +833,6 @@ export function githubRuleset(parsed, options = {}) {
|
|
|
1306
833
|
continue;
|
|
1307
834
|
}
|
|
1308
835
|
|
|
1309
|
-
// The TIER is consulted before the spelling table, and that order is the whole of it. It was
|
|
1310
|
-
// the other way round for one round, so an `auto` rule spelled exactly `git push --force`
|
|
1311
|
-
// compiled into `non_fast_forward` — a ruleset rule emitted for an action the policy declares
|
|
1312
|
-
// unattended, with `floorRefusal`'s own `auto` branch left unreachable for it. The table is
|
|
1313
|
-
// only how this backend spells a gate; whether there is a gate at all is the policy's answer.
|
|
1314
|
-
// Found by review on the pull request.
|
|
1315
836
|
const shell = rule.kind === "shell" && FLOOR_GATE_TIERS.has(rule.tier) ? rule.target : null;
|
|
1316
837
|
const refType = shell ? REF_RULES.get(shell) : undefined;
|
|
1317
838
|
if (refType) {
|
|
@@ -1329,9 +850,6 @@ export function githubRuleset(parsed, options = {}) {
|
|
|
1329
850
|
);
|
|
1330
851
|
}
|
|
1331
852
|
|
|
1332
|
-
// The coarseness, recorded in BOTH directions. A backend that reported only where it is weaker
|
|
1333
|
-
// than the policy would be flattering itself, and the stricter direction is the one an adopter
|
|
1334
|
-
// is surprised by.
|
|
1335
853
|
if (compiled.some((c) => c.surface === "non_fast_forward")) {
|
|
1336
854
|
notes.push(
|
|
1337
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.`,
|
|
@@ -1340,8 +858,6 @@ export function githubRuleset(parsed, options = {}) {
|
|
|
1340
858
|
notes.push(
|
|
1341
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.`,
|
|
1342
860
|
);
|
|
1343
|
-
// A declaration nothing compiles is reported, never dropped in silence — the manifest-field-that-
|
|
1344
|
-
// loads-nothing defect, arriving from the policy side this time.
|
|
1345
861
|
if (hasChecks && !declaresPropose) {
|
|
1346
862
|
notes.push(
|
|
1347
863
|
`this floor declares ${floor.checks.length} status check(s) and the policy carries no \`propose\` rule, so no ` +
|
|
@@ -1354,18 +870,13 @@ export function githubRuleset(parsed, options = {}) {
|
|
|
1354
870
|
);
|
|
1355
871
|
|
|
1356
872
|
const value = {
|
|
1357
|
-
//
|
|
1358
|
-
// only place a warning survives into the settings UI a maintainer actually reads. Same job
|
|
1359
|
-
// 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.
|
|
1360
874
|
name: `portulan floor — ${floor.branch} (generated from ${source})`,
|
|
1361
875
|
target: "branch",
|
|
1362
876
|
enforcement: "active",
|
|
1363
877
|
conditions: { ref_name: { include: [`refs/heads/${floor.branch}`], exclude: [] } },
|
|
1364
878
|
rules,
|
|
1365
|
-
//
|
|
1366
|
-
// the only actor who can act is not a floor. The organisation-level ruleset this repository
|
|
1367
|
-
// sits under does carry an `OrganizationAdmin` always-bypass, which is recorded there as the
|
|
1368
|
-
// unverified layer of the floor — not copied here.
|
|
879
|
+
// No bypass: a floor exempting the only actor who can act is not a floor.
|
|
1369
880
|
bypass_actors: [],
|
|
1370
881
|
};
|
|
1371
882
|
|
|
@@ -1379,22 +890,9 @@ export function githubRuleset(parsed, options = {}) {
|
|
|
1379
890
|
};
|
|
1380
891
|
}
|
|
1381
892
|
|
|
1382
|
-
/**
|
|
1383
|
-
* Why one rule does not reach the floor.
|
|
1384
|
-
*
|
|
1385
|
-
* Every sentence here is scoped to *this export* rather than to GitHub, because
|
|
1386
|
-
* `../.portulan/memory/a-stated-enforcer-must-be-the-real-one.md` binds any sentence containing
|
|
1387
|
-
* "cannot" — and the convenient blanket version ("the platform gates a ref, not a path") is simply
|
|
1388
|
-
* false: CODEOWNERS gates owned paths and is named as part of the floor by
|
|
1389
|
-
* `../core/operating/autonomy.md`, push rulesets gate file paths, and tag rulesets gate `refs/tags/*`.
|
|
1390
|
-
*/
|
|
893
|
+
/** Why a rule misses the floor, said of this export and never of GitHub, which can gate paths and tags. */
|
|
1391
894
|
function floorRefusal(rule, floor) {
|
|
1392
895
|
if (rule.tier === "auto") {
|
|
1393
|
-
// "Unattended by definition" is literally true on THIS backend, and true only here: a branch
|
|
1394
|
-
// ruleset has no prompt to pay, so an Auto rule left out of it costs nobody anything. The
|
|
1395
|
-
// host backend carried the identical phrase and it was false there — see the comment above
|
|
1396
|
-
// `HOST_GATE_TIERS`. Same words, one backend apart, one of them wrong. Do not carry either
|
|
1397
|
-
// version across without re-deriving it for the backend it lands in.
|
|
1398
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.";
|
|
1399
897
|
}
|
|
1400
898
|
if (rule.kind === "none") {
|
|
@@ -1412,38 +910,14 @@ function floorRefusal(rule, floor) {
|
|
|
1412
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.`;
|
|
1413
911
|
}
|
|
1414
912
|
|
|
1415
|
-
/**
|
|
1416
|
-
* Where each backend's artifact lives, whether or not this policy produces one.
|
|
1417
|
-
*
|
|
1418
|
-
* Declared beside the backends rather than read off a result, because `--check` has to look for an
|
|
1419
|
-
* artifact a backend *no longer* owes — and a backend that emits nothing has no result to read a
|
|
1420
|
-
* path from. Keyed by backend id so adding a backend without a path here is a `TypeError` at the
|
|
1421
|
-
* first check rather than a silently unswept file.
|
|
1422
|
-
*/
|
|
913
|
+
/** Each backend's artifact path, owed or not: `--check` looks for an artifact a backend no longer emits. */
|
|
1423
914
|
const ARTIFACT_PATHS = {
|
|
1424
915
|
"claude-code": ".claude/settings.json",
|
|
1425
916
|
"github-ruleset": ".portulan/compile/github-ruleset.json",
|
|
1426
917
|
};
|
|
1427
918
|
|
|
1428
|
-
/**
|
|
1429
|
-
* The same map, for a workspace that does not live at `.portulan`.
|
|
1430
|
-
*
|
|
1431
|
-
* **A feature that dispatches on residence is a parity breach**, and proposal
|
|
1432
|
-
* `../.portulan/proposals/0017-one-repository-one-governing-workspace.md` says it is refusable on that
|
|
1433
|
-
* sentence alone. This one did: the ruleset's path had `.portulan` written into it, so compiling a
|
|
1434
|
-
* feed-side workspace looked for a gate policy at `<feed>/workspaces/.portulan/gates.json` — a
|
|
1435
|
-
* directory that does not exist and never would — and exited 2, *could not run*, on a workspace that
|
|
1436
|
-
* `doctor`, `index` and its own verify recipe all handled identically at both ends.
|
|
1437
|
-
*
|
|
1438
|
-
* **Found by running row 7's fourth demonstration, not by reading this file.** Every other line of the
|
|
1439
|
-
* compiler already keyed to a slot; this one key was location. The default is unchanged, so a caller
|
|
1440
|
-
* that names no workspace directory takes a byte-identical path to the one it took before.
|
|
1441
|
-
*/
|
|
1442
919
|
function artifactPaths(workspaceDir) {
|
|
1443
920
|
if (workspaceDir === ".portulan") return ARTIFACT_PATHS;
|
|
1444
|
-
// A workspace directory that IS the root — the feed-side shape, where the workspace ships as the
|
|
1445
|
-
// plugin and the installed plugin's directory is the workspace root (milestone 6 measured that).
|
|
1446
|
-
// Its compiled artifacts belong beside it, because they ship with it.
|
|
1447
921
|
const prefix = workspaceDir === "." ? "" : `${workspaceDir}/`;
|
|
1448
922
|
return {
|
|
1449
923
|
"claude-code": ARTIFACT_PATHS["claude-code"],
|
|
@@ -1451,23 +925,12 @@ function artifactPaths(workspaceDir) {
|
|
|
1451
925
|
};
|
|
1452
926
|
}
|
|
1453
927
|
|
|
1454
|
-
|
|
928
|
+
export const GENERATED_DIRS = ["compile", ".claude"];
|
|
929
|
+
|
|
1455
930
|
export function backends(parsed, options = {}) {
|
|
1456
|
-
// **Provenance rides in the Claude Code artifact only, and that is an honest limit rather than an
|
|
1457
|
-
// oversight.** `githubRuleset` emits to a fixed external schema that has nowhere to carry a
|
|
1458
|
-
// `$portulan` block; its provenance is the one line it can carry, in `name`. Both backends compile
|
|
1459
|
-
// from ONE root plan per run, so the recorded origins describe the ruleset's inputs too — they are
|
|
1460
|
-
// simply not readable in its file. Stated here and in `../.portulan/compile/README.md` (#264).
|
|
1461
931
|
return [claudeCode(parsed, options), githubRuleset(parsed, options)];
|
|
1462
932
|
}
|
|
1463
933
|
|
|
1464
|
-
/**
|
|
1465
|
-
* The per-host backend matrix: one row per policy rule, one cell per backend.
|
|
1466
|
-
*
|
|
1467
|
-
* Derived from the backends rather than maintained beside them. A matrix written by hand is a claim
|
|
1468
|
-
* about compilers, and this repository has spent two milestones on what claims about machinery are
|
|
1469
|
-
* worth — so this one cannot drift from the compilers, because it *is* their accounting transposed.
|
|
1470
|
-
*/
|
|
1471
934
|
export function matrix(parsed, options = {}) {
|
|
1472
935
|
const columns = backends(parsed, options);
|
|
1473
936
|
return parsed.rules.map((rule) => {
|
|
@@ -1482,209 +945,1177 @@ export function matrix(parsed, options = {}) {
|
|
|
1482
945
|
});
|
|
1483
946
|
}
|
|
1484
947
|
|
|
1485
|
-
//
|
|
1486
|
-
|
|
1487
|
-
|
|
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
|
+
};
|
|
1488
1031
|
|
|
1489
1032
|
/**
|
|
1490
|
-
*
|
|
1491
|
-
*
|
|
1492
|
-
* Read from the manifest's top-level `gates` key, because that is what the key MEANS —
|
|
1493
|
-
* `../spec/slots.md` calls it "a path to a JSON file the enforcement compiler reads", and `doctor`
|
|
1494
|
-
* resolves it. This compiler hard-coded `.portulan/gates.json` for one round, so a workspace naming a
|
|
1495
|
-
* different file would have had `doctor` validate one policy and `compile` compile another, with both
|
|
1496
|
-
* reporting green. A manifest key that validates and is never consumed is this repository's most
|
|
1497
|
-
* expensive recurring defect, and here it was in the very key this milestone added. Found by review on
|
|
1498
|
-
* the pull request.
|
|
1499
|
-
*
|
|
1500
|
-
* The default survives for a workspace with no manifest or no key — that is a legitimate shape, and
|
|
1501
|
-
* refusing it would make the key required, which is a spec change nobody decided.
|
|
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.
|
|
1502
1035
|
*/
|
|
1503
|
-
export function
|
|
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`. */
|
|
1931
|
+
export function policyDeclaration(workspaceRoot, workspaceDir = ".portulan") {
|
|
1504
1932
|
const base = path.join(workspaceRoot, workspaceDir);
|
|
1505
1933
|
const manifest = path.join(base, "workspace.json");
|
|
1934
|
+
const fallback = (reason) => ({ file: path.join(workspaceRoot, workspaceDir, "gates.json"), declared: false, reason });
|
|
1935
|
+
let declared;
|
|
1936
|
+
try {
|
|
1937
|
+
declared = JSON.parse(fs.readFileSync(manifest, "utf8")).gates;
|
|
1938
|
+
} catch {
|
|
1939
|
+
// An unreadable manifest reaches here only from the hook's reader, which must not stop.
|
|
1940
|
+
return fallback("no-manifest");
|
|
1941
|
+
}
|
|
1942
|
+
if (declared === undefined) return fallback("no-key");
|
|
1943
|
+
// Contained after resolution: a `../` chain passes any pattern, and the hook reads this on every tool call.
|
|
1944
|
+
if (typeof declared === "string" && declared.trim() && FILE_PATH.test(declared)) {
|
|
1945
|
+
const resolved = path.resolve(base, declared);
|
|
1946
|
+
if (resolved !== base && isInside(base, resolved)) return { file: resolved, declared: true, reason: "declared" };
|
|
1947
|
+
}
|
|
1948
|
+
return fallback("refused");
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
export function policyPath(workspaceRoot, workspaceDir = ".portulan") {
|
|
1952
|
+
return policyDeclaration(workspaceRoot, workspaceDir).file;
|
|
1953
|
+
}
|
|
1954
|
+
|
|
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;
|
|
1506
1968
|
try {
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
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`);
|
|
1518
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"));
|
|
1519
1997
|
} catch {
|
|
1520
|
-
|
|
1521
|
-
|
|
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;
|
|
1522
2009
|
}
|
|
1523
|
-
|
|
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;
|
|
1524
2018
|
}
|
|
1525
2019
|
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
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) {
|
|
2022
|
+
const manifest = path.join(workspaceRoot, workspaceDir, "workspace.json");
|
|
2023
|
+
const conventional = reason === "refused" && fs.existsSync(policyFile);
|
|
2024
|
+
const opening =
|
|
2025
|
+
reason === "refused"
|
|
2026
|
+
? `\`workspace.json\` names a gate policy this compiler will not read — its top-level ` +
|
|
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.`)
|
|
2031
|
+
: reason === "no-manifest"
|
|
2032
|
+
? `this workspace declares no gate policy — there is no readable \`workspace.json\` at ` +
|
|
2033
|
+
`${manifest}, and there is no \`gates.json\` at ${policyFile}.`
|
|
2034
|
+
: `this workspace declares no gate policy — \`workspace.json\` has no top-level \`gates\` key, ` +
|
|
2035
|
+
`and there is no \`gates.json\` at ${policyFile}.`;
|
|
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}`];
|
|
2043
|
+
let composed = null;
|
|
2044
|
+
try {
|
|
2045
|
+
composed = packContributions(workspaceRoot, workspaceDir, packOptions);
|
|
2046
|
+
} catch {
|
|
2047
|
+
// A pack's refusal would replace this answer; with a policy declared, the next run raises it.
|
|
2048
|
+
}
|
|
2049
|
+
const contributions = composed?.contributions ?? [];
|
|
2050
|
+
const rules = contributions.reduce((n, c) => n + (c.fragments?.length ?? 0), 0);
|
|
2051
|
+
if (rules > 0) {
|
|
2052
|
+
const packs = contributions
|
|
2053
|
+
.filter((c) => (c.fragments?.length ?? 0) > 0)
|
|
2054
|
+
.map((c) => `\`${c.pack}\``)
|
|
2055
|
+
.join(", ");
|
|
2056
|
+
lines.push(
|
|
2057
|
+
`${rules} pack-contributed gate rule(s) from ${packs} are therefore NOT compiled: ` +
|
|
2058
|
+
`a fragment tightens a policy, and ${conventional ? "this run reads none" : "there is none here"} to tighten.`,
|
|
2059
|
+
);
|
|
2060
|
+
}
|
|
2061
|
+
lines.push(
|
|
2062
|
+
reason === "refused"
|
|
2063
|
+
? "Give `gates` a relative path to a file inside the workspace directory, or remove the key " +
|
|
2064
|
+
"and let `gates.json` be found by convention."
|
|
2065
|
+
: "Declare one with `portulan new gate-policy`, or leave it undeclared deliberately — " +
|
|
2066
|
+
"a workspace with no gate policy is a legitimate shape, and this is a state rather than a fault.",
|
|
2067
|
+
);
|
|
2068
|
+
return lines.join("\n ");
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
// ---------------------------------------------------------------- pack-contributed gate fragments
|
|
1540
2072
|
|
|
1541
|
-
/** The tier partial order, weakest to strongest. Tightening moves UP it and never down. */
|
|
1542
2073
|
const TIER_ORDER = ["auto", "propose", "gated", "prohibited"];
|
|
1543
2074
|
|
|
1544
|
-
/** Where a tier sits in the partial order; `-1` for anything that is not a tier. */
|
|
1545
2075
|
export const tierRank = (tier) => TIER_ORDER.indexOf(tier);
|
|
1546
2076
|
|
|
1547
|
-
/**
|
|
1548
|
-
* Resolve one declared pack name — the canonical `category/name` — against a list of roots, in order.
|
|
1549
|
-
*
|
|
1550
|
-
* Root-parameterized rather than deriving one location, because the two shapes that matter are not
|
|
1551
|
-
* the same tree: a workspace composing a pack that ships beside it resolves under its own `packs/`,
|
|
1552
|
-
* while an adopter installing from a feed resolves inside the installed plugin. One resolver, told
|
|
1553
|
-
* where to look, so the second case is the same code path rather than a parallel one discovered later.
|
|
1554
|
-
*
|
|
1555
|
-
* Returns `{ name, category, pack, dir, manifest, root }` with `dir` and `root` null when nothing
|
|
1556
|
-
* matched. `root` is which root answered — the fact a caller states per pack once the set is a union.
|
|
1557
|
-
*/
|
|
2077
|
+
/** The first of `roots` holding the pack; `dir` and `root` are null, with a `why`, where none does or the name is malformed. */
|
|
1558
2078
|
export function resolvePack(rawName, roots = []) {
|
|
1559
|
-
// Coerced once, and the COERCED value is what every return carries. This compiler reads
|
|
1560
|
-
// `workspace.json` without validating it — `doctor` is the tool that judges a manifest, and the
|
|
1561
|
-
// two have no ordering between them — so `packs` may hold a number or an object, and the caller
|
|
1562
|
-
// formats what comes back (`.padEnd()`), which on a non-string throws a TypeError that aborts the
|
|
1563
|
-
// whole compile instead of reporting one unresolvable pack. Found by review.
|
|
1564
2079
|
const name = String(rawName);
|
|
1565
2080
|
const parts = name.split("/");
|
|
1566
2081
|
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
1567
2082
|
return { name, category: null, pack: null, dir: null, manifest: null, root: null, why: "not in `category/name` form" };
|
|
1568
2083
|
}
|
|
1569
2084
|
const [category, pack] = parts;
|
|
1570
|
-
//
|
|
1571
|
-
// root it was supposed to be searched in.
|
|
2085
|
+
// Slugs, so no `..` segment resolves a pack outside the roots.
|
|
1572
2086
|
if (!SLUG.test(category) || !SLUG.test(pack)) {
|
|
1573
2087
|
return { name, category: null, pack: null, dir: null, manifest: null, root: null, why: "`category/name` must both be slugs" };
|
|
1574
2088
|
}
|
|
1575
2089
|
for (const root of roots) {
|
|
1576
2090
|
const dir = path.join(root, category, pack);
|
|
1577
2091
|
const manifest = path.join(dir, "pack.json");
|
|
1578
|
-
// `root` rides along because the resolution set may now be a union, and *which* root answered
|
|
1579
|
-
// is the fact a caller states per pack. Deriving it afterwards by re-testing the roots would
|
|
1580
|
-
// be a second implementation of this loop, and the two would drift on the first-match rule.
|
|
1581
2092
|
if (fs.existsSync(manifest)) return { name, category, pack, dir, manifest, root, why: null };
|
|
1582
2093
|
}
|
|
1583
2094
|
return { name, category, pack, dir: null, manifest: null, root: null, why: "no pack.json under any resolution root" };
|
|
1584
2095
|
}
|
|
1585
2096
|
|
|
1586
|
-
/**
|
|
1587
|
-
* The roots a declared pack name is resolved against for a workspace on disk.
|
|
1588
|
-
*
|
|
1589
|
-
* Derived from the manifest's `tree` rather than declared, because adding a key for it would be a
|
|
1590
|
-
* Workspace Definition change nobody decided — and a slot before its consumer is the mistake that
|
|
1591
|
-
* specification was written to avoid. A workspace with no `tree` gets no roots, and its declared packs
|
|
1592
|
-
* are reported unresolvable rather than failed: the same answer `tree`'s absence already gives every
|
|
1593
|
-
* other claim that needs a tree to check.
|
|
1594
|
-
*/
|
|
1595
2097
|
export function packRoots(workspaceDir, workspace) {
|
|
1596
2098
|
const tree = workspace?.tree;
|
|
1597
2099
|
if (typeof tree === "string" && tree.trim()) return [path.resolve(workspaceDir, tree, "packs")];
|
|
1598
2100
|
return [];
|
|
1599
2101
|
}
|
|
1600
2102
|
|
|
1601
|
-
/**
|
|
1602
|
-
* The `options` object for `packContributions` when roots were named on the command line.
|
|
1603
|
-
*
|
|
1604
|
-
* **Named roots REPLACE the root derived from `tree`; they are not searched ahead of it.** The first
|
|
1605
|
-
* implementation appended the derived root after the named ones here, while `doctor` and `index` — both
|
|
1606
|
-
* of which spell this as `options.packRoots ?? packRoots(…)` — replaced it. Three tools, two semantics,
|
|
1607
|
-
* and every prose carrier described only one of them. The pre-commit checkpoint on the change that added
|
|
1608
|
-
* the flag found it by attacking it: a workspace with the pack sitting in its own tree, given
|
|
1609
|
-
* `--pack-root <an empty directory>`, compiled **green from the local copy** — which is exactly the thing
|
|
1610
|
-
* the flag exists to exclude, since the whole point is that "the pack resolved from the feed" must not be
|
|
1611
|
-
* satisfiable by a copy lying around. Replacement is the semantics that serves that, so replacement is
|
|
1612
|
-
* what all three now do, and `cli/compile.test.mjs` pins the divergent case rather than trusting the
|
|
1613
|
-
* prose to hold the three in line.
|
|
1614
|
-
*
|
|
1615
|
-
* Returns `{}` when nothing was named, so the derived-only path stays byte-identical to what it was
|
|
1616
|
-
* before the flag existed — a caller that passes no roots must not take a different branch, which is the
|
|
1617
|
-
* property that keeps the flag a *surface* on the existing behaviour rather than a second one.
|
|
1618
|
-
*/
|
|
1619
|
-
// **No caller since `--pack-root auto` landed** — the command line now passes `named` into `rootPlan`
|
|
1620
|
-
// and the precedence rule decides. Kept, exported and documented because four files cite it in prose
|
|
1621
|
-
// for the DEFECT it records rather than for the function it is: three tools, two semantics, found by a
|
|
1622
|
-
// checkpoint. A reader arriving from one of those citations should find this note rather than infer
|
|
1623
|
-
// that the code path is live.
|
|
2103
|
+
/** Named roots replace the one derived from `tree` rather than precede it; `{}` where none is named. */
|
|
1624
2104
|
export function namedRootsOption(workspaceRoot, namedRoots) {
|
|
1625
2105
|
if (!namedRoots?.length) return {};
|
|
1626
2106
|
return { packRoots: [...namedRoots] };
|
|
1627
2107
|
}
|
|
1628
2108
|
|
|
1629
|
-
/**
|
|
1630
|
-
* The one place the three tools agree on where a resolution root comes from.
|
|
1631
|
-
*
|
|
1632
|
-
* `compile`, `doctor` and `index` each spelled the derived-versus-named choice for themselves, and two
|
|
1633
|
-
* of the three spelled it differently — the divergence `namedRootsOption` records, found by a
|
|
1634
|
-
* checkpoint rather than by the suite. Discovery adds a third source and a precedence between all
|
|
1635
|
-
* three, which is three more chances to diverge, so the ordering is written once in `discover.mjs`'s
|
|
1636
|
-
* `resolutionRoots` and **called** by each tool rather than described in three places.
|
|
1637
|
-
*/
|
|
1638
2109
|
export function rootPlan(workspaceDir, manifest, { named = [], namedGiven = null, discovery = null, forced = false } = {}) {
|
|
1639
2110
|
return resolutionRoots({ named, namedGiven, derived: packRoots(workspaceDir, manifest), discovery, forced });
|
|
1640
2111
|
}
|
|
1641
2112
|
|
|
1642
|
-
/**
|
|
1643
|
-
* What the packs a workspace declares contribute. Reads each resolved `pack.json` and collects its
|
|
1644
|
-
* gate fragments. It does NOT validate the manifest against the Pack Definition — `doctor` does that,
|
|
1645
|
-
* and this compiler must not depend on `doctor` having been run: the two have no ordering between
|
|
1646
|
-
* them. What it depends on instead is `parse`, which validates every composed rule exactly as it
|
|
1647
|
-
* validates a hand-written one, so a malformed fragment is refused by the same code either way.
|
|
1648
|
-
*/
|
|
1649
|
-
/**
|
|
1650
|
-
* How a resolved pack's ORIGIN is recorded in an emitted artifact — a deliberate collapse of the
|
|
1651
|
-
* resolver's three tags into two, and the collapse is the load-bearing part.
|
|
1652
|
-
*
|
|
1653
|
-
* `resolutionRoots` tags roots `named | derived | discovered`. Recording that raw would be wrong in a
|
|
1654
|
-
* way that turns this whole feature into a liability: the PINNED rail spells its root
|
|
1655
|
-
* (`--pack-root packs`, tagged `named`) while a bare run derives the same directory (tagged
|
|
1656
|
-
* `derived`). Two documented-correct spellings of the same world would emit different bytes, and
|
|
1657
|
-
* `verify/compile.sh` would go red on a tree nothing was wrong with — a per-machine false red, which
|
|
1658
|
-
* is worse than the silent hazard #264 is about.
|
|
1659
|
-
*
|
|
1660
|
-
* So: `discovered` iff the answering root came from discovery — **which is a statement about where the
|
|
1661
|
-
* root was FOUND, not about where it sits.** Discovery reads a record under `CLAUDE_CONFIG_DIR`, and a
|
|
1662
|
-
* hermetic run can point that inside the tree, so a discovered root is not necessarily an external one;
|
|
1663
|
-
* the docblock said "a directory outside this repository" until review caught the conflation, and the
|
|
1664
|
-
* distinction is this field's whole subject rather than a quibble — it is why `outside-tree` exists as
|
|
1665
|
-
* 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
|
|
1666
|
-
* `outside-tree` rather than flattened into `tree`, because calling somebody's `--pack-root /elsewhere`
|
|
1667
|
-
* "the tree" would be this field's first lie.
|
|
1668
|
-
*/
|
|
2113
|
+
/** `discovered` where discovery found the root; else `tree` or `outside-tree` by where it sits, never how it was named. */
|
|
1669
2114
|
export function recordedOrigin(root, plan, workspaceRoot) {
|
|
1670
2115
|
const same = (a, b) => path.resolve(a) === path.resolve(b);
|
|
1671
2116
|
const tagged = (plan?.origins ?? []).find((o) => same(o.root, root));
|
|
1672
2117
|
if (tagged?.origin === "discovered") return "discovered";
|
|
1673
|
-
//
|
|
1674
|
-
// `derived` on its tag alone. It is derived from the manifest's `tree`, which is a string a human
|
|
1675
|
-
// edits: `tree: "../../elsewhere"` resolves a derived root of `/elsewhere/packs` — measured — and
|
|
1676
|
-
// that would have been recorded as `tree`. A tag says where a root CAME FROM; only the path says
|
|
1677
|
-
// whether it is in this repository, and this field's whole job is the second question. Copilot.
|
|
1678
|
-
//
|
|
1679
|
-
// Inside INCLUDES being the repository itself, which the first draft called `outside-tree` on a
|
|
1680
|
-
// `rel` of `""`. By this function's own standard that was the field's first lie.
|
|
1681
|
-
//
|
|
1682
|
-
// **Both sides resolved through `realpathSync`**, because a lexical comparison makes two spellings
|
|
1683
|
-
// of one directory disagree: `--workspace <alias> --pack-root <realpath>` recorded `outside-tree`
|
|
1684
|
-
// for a root plainly inside the tree, which would emit a different artifact for an identical world
|
|
1685
|
-
// — the per-machine false red this whole design refuses. Measured. It is the same trap #220's
|
|
1686
|
-
// second arm repaired in the Stop-gate's divergence sentence, and it is worth stating twice
|
|
1687
|
-
// 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`.
|
|
1688
2119
|
const real = (dir) => {
|
|
1689
2120
|
try {
|
|
1690
2121
|
return fs.realpathSync(path.resolve(dir));
|
|
@@ -1692,28 +2123,10 @@ export function recordedOrigin(root, plan, workspaceRoot) {
|
|
|
1692
2123
|
return path.resolve(dir);
|
|
1693
2124
|
}
|
|
1694
2125
|
};
|
|
1695
|
-
// **`isInside`, not a fourth hand-rolled spelling.** `!rel.startsWith("..")` calls a directory
|
|
1696
|
-
// literally named `..foo` outside the tree — and this repository has already paid for that exact
|
|
1697
|
-
// reasoning once: `./index.mjs`'s docblock records it as the ninth fail-open found in this
|
|
1698
|
-
// scaffolding, and *the first one written by the change that cites the class*. This was the second.
|
|
1699
|
-
// The helper is exported precisely so a third copy cannot drift into it. Copilot.
|
|
1700
2126
|
return isInside(real(workspaceRoot), real(root)) ? "tree" : "outside-tree";
|
|
1701
2127
|
}
|
|
1702
2128
|
|
|
1703
|
-
/**
|
|
1704
|
-
* Two copies of one pack answered, and this says what differs between them.
|
|
1705
|
-
*
|
|
1706
|
-
* **One carrier, because the comparison has already been wrong twice.** `doctor` grew this first and
|
|
1707
|
-
* its history is the argument for not writing a fourth: a first spelling projected `[id, tier, action]`
|
|
1708
|
-
* and so read a copy differing in `reason` as agreeing — while `composeFragments` pushes the WHOLE
|
|
1709
|
-
* fragment, so those differences do reach the compiled policy; a second compared the whole fragment
|
|
1710
|
-
* but claimed *byte-identical*, which `JSON.stringify` of a parsed value cannot promise. This is that
|
|
1711
|
-
* third, correct version, moved here so `doctor` and `compile` cannot drift apart about what a shadow
|
|
1712
|
-
* IS — proposal `0020`'s rule, applied to a function rather than to a sentence.
|
|
1713
|
-
*
|
|
1714
|
-
* Parsed and key-order-normalised is the RIGHT comparison: reformatting a `pack.json` changes nothing
|
|
1715
|
-
* about what the pack contributes, so reporting it would be noise.
|
|
1716
|
-
*/
|
|
2129
|
+
/** Compared parsed, key-sorted and whole: `composeFragments` takes every field of a fragment. */
|
|
1717
2130
|
export function packDifferences(mine, other) {
|
|
1718
2131
|
const canonical = (v) =>
|
|
1719
2132
|
Array.isArray(v)
|
|
@@ -1730,13 +2143,7 @@ export function packDifferences(mine, other) {
|
|
|
1730
2143
|
return differs;
|
|
1731
2144
|
}
|
|
1732
2145
|
|
|
1733
|
-
/**
|
|
1734
|
-
* The non-discovered copy of a pack that a discovered root answered for — or `null` when there is no
|
|
1735
|
-
* shadow. `originOf` maps a root to its tag; pass `plan.origins`' lookup.
|
|
1736
|
-
*
|
|
1737
|
-
* Only a **discovered** answer can be shadowed in the sense that matters: a named root replaces the
|
|
1738
|
-
* derived one, so nothing is behind it, and a derived answer is the tree's own copy already.
|
|
1739
|
-
*/
|
|
2146
|
+
/** The copy behind a discovered answer, or null: only a discovered one can shadow, as a named root replaces the derived. */
|
|
1740
2147
|
export function shadowedCopy(name, answeringOrigin, roots, originOf) {
|
|
1741
2148
|
if (answeringOrigin !== "discovered") return null;
|
|
1742
2149
|
const behind = resolvePack(name, roots.filter((r) => originOf(r) !== "discovered"));
|
|
@@ -1749,21 +2156,12 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
|
|
|
1749
2156
|
try {
|
|
1750
2157
|
manifest = JSON.parse(fs.readFileSync(path.join(base, "workspace.json"), "utf8"));
|
|
1751
2158
|
} catch {
|
|
1752
|
-
// No manifest, or unreadable. `doctor` is the tool that judges a manifest; this one only needs
|
|
1753
|
-
// to know what is composed, and with nothing to go on the answer is nothing.
|
|
1754
2159
|
return { contributions: [], unresolved: [] };
|
|
1755
2160
|
}
|
|
1756
2161
|
const declared = manifest?.packs;
|
|
1757
2162
|
if (!Array.isArray(declared) || declared.length === 0) return { contributions: [], unresolved: [] };
|
|
1758
2163
|
|
|
1759
|
-
// `options.packRoots`
|
|
1760
|
-
// one; a caller passing `[]` means the empty set and still gets it. `options.named` is the
|
|
1761
|
-
// command-line shape. **Both now go through the shared rule**, `namedGiven` carrying the
|
|
1762
|
-
// final-set meaning, because the literal plan this replaces was the sixth site of the defect this
|
|
1763
|
-
// change is about: given `packRoots` AND `forced`, it silently ignored the discovery request, and
|
|
1764
|
-
// it returned a plan with no `origins` and no `refusal` — breaking one file over the uniform shape
|
|
1765
|
-
// `resolutionRoots` exists to guarantee. Found by the pre-commit checkpoint's own sibling sweep,
|
|
1766
|
-
// 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.
|
|
1767
2165
|
const givenPackRoots = options.packRoots !== undefined && options.packRoots !== null;
|
|
1768
2166
|
const plan = rootPlan(base, manifest, {
|
|
1769
2167
|
named: givenPackRoots ? [...options.packRoots] : (options.named ?? []),
|
|
@@ -1783,37 +2181,6 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
|
|
|
1783
2181
|
continue;
|
|
1784
2182
|
}
|
|
1785
2183
|
const packManifest = readJson(found.manifest, `the pack manifest for \`${found.name}\``);
|
|
1786
|
-
// **A shadowed pack is a could-not-run, not a resolution** (#316). Two roots answered for one
|
|
1787
|
-
// declared name and the invocation does not say which the caller meant, so this compiler has
|
|
1788
|
-
// no honest way to pick — and unlike `doctor`, which reports and never grades because an
|
|
1789
|
-
// install state is a fact about a machine rather than a verdict about the repository, this one
|
|
1790
|
-
// does not report: it EMITS. Picking silently wrote the discovered copy's fragments into the
|
|
1791
|
-
// artifact while `verify/compile.sh` read the tree's, which on this project's own host meant a
|
|
1792
|
-
// `git commit --no-verify` matcher that the tree had deliberately removed as false coverage.
|
|
1793
|
-
//
|
|
1794
|
-
// **Refused at RESOLUTION, so `--check` and the write path inherit one rule.** Refusing only
|
|
1795
|
-
// on write would make one tool answer one ambiguity two ways: the check would go on adopting
|
|
1796
|
-
// the discovered world and exit 1 — asserting that the *repository* drifted when it had not.
|
|
1797
|
-
// An exit code is the machine-read API, and a false 1 is a false verdict about the tree.
|
|
1798
|
-
//
|
|
1799
|
-
// **Every unasked shadow refuses, including one whose manifests agree**, which reads as
|
|
1800
|
-
// over-strict until you follow the bytes: `recordedOrigin` tags the answering root into
|
|
1801
|
-
// `$portulan.packs[].origin`, so a discovered answer emits `discovered` where the rail's
|
|
1802
|
-
// artifact says `tree`. Agreement in the manifests is therefore not agreement in the artifact,
|
|
1803
|
-
// and a carve-out for it would ship a compile that still reds the recipe it was meant to
|
|
1804
|
-
// reconcile with. The message distinguishes the two cases; the refusal does not.
|
|
1805
|
-
//
|
|
1806
|
-
// **Only where the caller did not choose.** `--pack-root auto` is discovery ELECTED, and
|
|
1807
|
-
// electing it is the choice this refusal exists to ask for. Both spellings are offered in the
|
|
1808
|
-
// message, because naming only the tree would make the refusal decide the question it claims
|
|
1809
|
-
// to be handing back.
|
|
1810
|
-
//
|
|
1811
|
-
// **The `named` half of this guard is defence in depth, and that was measured rather than
|
|
1812
|
-
// assumed.** Forcing the condition to `true` — refuse unconditionally — leaves the named-root
|
|
1813
|
-
// case still passing: a named root REPLACES the derived one, so the plan carries no discovered
|
|
1814
|
-
// root, `shadowedCopy` finds nothing behind, and there is no shadow to refuse. The protection
|
|
1815
|
-
// is structural. The clause stays because it states the intent at the site where a future
|
|
1816
|
-
// change to `resolutionRoots` could quietly make it load-bearing.
|
|
1817
2184
|
if (!(options.forced ?? false) && plan.source !== "named") {
|
|
1818
2185
|
const behind = shadowedCopy(found.name, recordedOrigin(found.root, plan, workspaceRoot), roots, (r) =>
|
|
1819
2186
|
(plan.origins ?? []).find((o) => path.resolve(o.root) === path.resolve(r))?.origin,
|
|
@@ -1831,24 +2198,6 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
|
|
|
1831
2198
|
);
|
|
1832
2199
|
}
|
|
1833
2200
|
const differs = packDifferences(packManifest, other);
|
|
1834
|
-
// **Both ROOTS by path — the roots, not the pack directories.** `resolvePack` returns
|
|
1835
|
-
// both, and a first cut printed `dir` (`<root>/<category>/<pack>`) while calling it a
|
|
1836
|
-
// root: a diagnostic that mislabels the thing it names, in a message whose own claim is
|
|
1837
|
-
// to name both roots. The root is also the more useful of the two, because it is what a
|
|
1838
|
-
// reader types back into `--pack-root`. A refusal naming only one hands back a choice
|
|
1839
|
-
// while withholding half of what it is between. Printing the absolute path is safe in a
|
|
1840
|
-
// way it is not in `$portulan.packs`: this is stderr, read once by a human, never a
|
|
1841
|
-
// tracked artifact, so #264's origins-never-paths rule (which exists to stop a
|
|
1842
|
-
// machine-dependent path being committed) does not reach it. `doctor` already prints it
|
|
1843
|
-
// in its resolves-from note, so the two tools now name the same two directories.
|
|
1844
|
-
//
|
|
1845
|
-
// **"Discovered on this host", never "outside this repository".** Discovery reads a
|
|
1846
|
-
// record under `CLAUDE_CONFIG_DIR`, which a hermetic run can point INSIDE the tree — so
|
|
1847
|
-
// a discovered root is not necessarily an external one, and the message must not assert
|
|
1848
|
-
// a location it has not tested. `recordedOrigin` keeps `discovered` and `outside-tree`
|
|
1849
|
-
// as separate answers for exactly this reason: where a root came from and where it sits
|
|
1850
|
-
// are different questions, and the path is printed right there for a reader who wants
|
|
1851
|
-
// the second one. Caught by review after the first cut conflated them.
|
|
1852
2201
|
throw new CompileError(
|
|
1853
2202
|
`\`${found.name}\` is SHADOWED — it resolved under ${found.root}, a root discovered on this ` +
|
|
1854
2203
|
`host, while the root ${path.relative(workspaceRoot, behind.root)} also carries it. ` +
|
|
@@ -1861,10 +2210,6 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
|
|
|
1861
2210
|
}
|
|
1862
2211
|
}
|
|
1863
2212
|
const fragments = packManifest?.contributes?.gates;
|
|
1864
|
-
// Checked here rather than left to `composeFragments` iterating it: a manifest is a file a
|
|
1865
|
-
// human edits, `doctor` may not have run (the two tools have no ordering between them), and a
|
|
1866
|
-
// non-array here would surface as a bare TypeError naming neither the pack nor the field.
|
|
1867
|
-
// `?? []` covers absent, not malformed — those are different failures and only one is benign.
|
|
1868
2213
|
if (fragments !== undefined && !Array.isArray(fragments)) {
|
|
1869
2214
|
throw new CompileError(
|
|
1870
2215
|
`the pack manifest for \`${found.name}\` declares \`contributes.gates\` as ` +
|
|
@@ -1876,35 +2221,14 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
|
|
|
1876
2221
|
pack: found.name,
|
|
1877
2222
|
dir: found.dir,
|
|
1878
2223
|
fragments: fragments ?? [],
|
|
1879
|
-
// **Provenance, recorded per pack**, so the artifact this compiles into can say what
|
|
1880
|
-
// compiled it (#264). `version` comes from `portulan.version` — the pack manifest's own
|
|
1881
|
-
// location for it, not a top-level `version` — and its ABSENCE is recorded as such rather
|
|
1882
|
-
// than as a blank, because "this pack declares no version" and "I did not look" are
|
|
1883
|
-
// different facts and only one of them is the pack's.
|
|
1884
2224
|
origin: recordedOrigin(found.root, plan, workspaceRoot),
|
|
1885
2225
|
version: typeof packManifest?.portulan?.version === "string" ? packManifest.portulan.version : null,
|
|
1886
2226
|
});
|
|
1887
2227
|
}
|
|
1888
|
-
// `plan` travels with the result because every consumer prints it: which source won, and the path
|
|
1889
|
-
// it won with, is what tells a feed resolution from a local one.
|
|
1890
2228
|
return { contributions, unresolved, plan };
|
|
1891
2229
|
}
|
|
1892
2230
|
|
|
1893
|
-
/**
|
|
1894
|
-
* Compose pack-contributed fragments onto a workspace policy. **Tighten-only, and it throws.**
|
|
1895
|
-
*
|
|
1896
|
-
* A demotion is refused loudly rather than dropped quietly, because the two are not the same event: a
|
|
1897
|
-
* backend refusing a rule it cannot express is a coverage gap, while a pack moving `gated` to
|
|
1898
|
-
* `propose` is an attempt to disarm a gate, and a build that continues past it has published an
|
|
1899
|
-
* artifact weaker than the policy it claims to compile. Failing closed is right *here* and wrong in
|
|
1900
|
-
* ./gate.mjs for a reason worth keeping straight: this runs at build time against a file you
|
|
1901
|
-
* can edit, while that runs on every tool call and a refusal there makes the session undriveable.
|
|
1902
|
-
*
|
|
1903
|
-
* `auto` is barred by the Pack Definition's tier enum, so a schema-valid pack cannot express a
|
|
1904
|
-
* demotion to unattended at all. It is checked again here anyway — this compiler does not depend on
|
|
1905
|
-
* the schema having been applied, and an unranked tier reaching the comparison would otherwise sort
|
|
1906
|
-
* below everything and read as a tightening of nothing.
|
|
1907
|
-
*/
|
|
2231
|
+
/** Tighten-only: a fragment that would demote a rule, or change what it matches, throws. */
|
|
1908
2232
|
export function composeFragments(policy, contributions) {
|
|
1909
2233
|
const rules = [...(policy?.rules ?? [])];
|
|
1910
2234
|
const at = new Map(rules.map((rule, i) => [rule?.id, i]));
|
|
@@ -1914,22 +2238,7 @@ export function composeFragments(policy, contributions) {
|
|
|
1914
2238
|
for (const { pack, fragments } of contributions) {
|
|
1915
2239
|
for (const fragment of fragments ?? []) {
|
|
1916
2240
|
const id = fragment?.id;
|
|
1917
|
-
//
|
|
1918
|
-
// the point** (#111). Composition deliberately runs BEFORE `parse`, so that a fragment is
|
|
1919
|
-
// graded by exactly the code that grades a hand-written rule — but that ordering means a
|
|
1920
|
-
// malformed id survives composition and surfaces from `parse` with **no pack context at
|
|
1921
|
-
// all**: `rule id undefined is not a slug`, which sends an adopter to audit their own
|
|
1922
|
-
// `gates.json`, the one file that is not at fault. Every other refusal in this loop names
|
|
1923
|
-
// the contributing pack, because naming the dependency is what the merge step is for.
|
|
1924
|
-
//
|
|
1925
|
-
// It fails closed either way — `parse` refuses the composed policy and nothing invalid
|
|
1926
|
-
// compiles — so this is a diagnostic repair, not a correctness one, and it is written down
|
|
1927
|
-
// as such.
|
|
1928
|
-
//
|
|
1929
|
-
// **It also closes a path the issue did not name.** `at` is keyed by `rule?.id`, so with no
|
|
1930
|
-
// check here a SECOND id-less fragment finds `at.has(undefined)` true and is composed onto
|
|
1931
|
-
// the first one — two unrelated fragments from two packs merged because they were equally
|
|
1932
|
-
// 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`.
|
|
1933
2242
|
if (typeof id !== "string" || !SLUG.test(id)) {
|
|
1934
2243
|
throw new CompileError(
|
|
1935
2244
|
`pack \`${pack}\` contributes a fragment whose id is ${JSON.stringify(id)}, which is not a slug. ` +
|
|
@@ -1959,14 +2268,6 @@ export function composeFragments(policy, contributions) {
|
|
|
1959
2268
|
}
|
|
1960
2269
|
const base = rules[at.get(id)];
|
|
1961
2270
|
const baseRank = tierRank(base?.tier);
|
|
1962
|
-
// The base's tier must BE a tier before any comparison means anything. Without this,
|
|
1963
|
-
// `tierRank` returns -1 for a malformed base and every fragment outranks it, so the
|
|
1964
|
-
// refusal below never fires and the fragment REPLACES the malformed rule — a policy
|
|
1965
|
-
// `parse` refuses on its own compiles green once a pack happens to name the same id.
|
|
1966
|
-
// Measured: a base rule at tier `bogus` was refused by `parse` alone, and accepted after
|
|
1967
|
-
// composition, reported as `tightens bogus → gated`. That is a fail-open in gate
|
|
1968
|
-
// machinery healed by a dependency, and the printed provenance claims a tightening from
|
|
1969
|
-
// a tier that is not one. Found by review, in the suppressed channel.
|
|
1970
2271
|
if (baseRank < 0) {
|
|
1971
2272
|
throw new CompileError(
|
|
1972
2273
|
`pack \`${pack}\` contributes a fragment for \`${id}\`, but the policy's own rule \`${id}\` ` +
|
|
@@ -1975,14 +2276,6 @@ export function composeFragments(policy, contributions) {
|
|
|
1975
2276
|
`policy first. A pack must never be able to make an invalid policy compile.`,
|
|
1976
2277
|
);
|
|
1977
2278
|
}
|
|
1978
|
-
// A tier is not the whole rule. Raising the tier while REPLACING the action removes the
|
|
1979
|
-
// matcher and passes every rank comparison — measured on this repository's own policy: a
|
|
1980
|
-
// fragment `{id: force-push-without-a-lease, tier: prohibited, action: {none: …}}` was
|
|
1981
|
-
// reported as `tightens gated → prohibited` and the emitted `Bash(git push --force:*)`
|
|
1982
|
-
// gate DISAPPEARED, leaving the workspace strictly less cautious about the exact action
|
|
1983
|
-
// the rule exists to gate. Ids are greppable by design and ship in core, so knowing one
|
|
1984
|
-
// is not a barrier. A pack may raise a rule's tier; it may not redefine what the rule
|
|
1985
|
-
// matches. Found by the pre-commit supervisor, which is the checkpoint's whole argument.
|
|
1986
2279
|
if (rank <= baseRank) {
|
|
1987
2280
|
throw new CompileError(
|
|
1988
2281
|
`pack \`${pack}\` would move rule \`${id}\` from \`${base?.tier}\` to ` +
|
|
@@ -2031,18 +2324,12 @@ function readJson(file, what) {
|
|
|
2031
2324
|
}
|
|
2032
2325
|
}
|
|
2033
2326
|
|
|
2034
|
-
/** The emitted artifact, as text. One writer, so `--check` compares what `--write` would produce. */
|
|
2035
2327
|
export function render(settings) {
|
|
2036
2328
|
return `${JSON.stringify(settings, null, 2)}\n`;
|
|
2037
2329
|
}
|
|
2038
2330
|
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
*
|
|
2042
|
-
* The criterion asks for the floor backend to be "positioned in the matrix as the floor backend:
|
|
2043
|
-
* what every host falls back to, and all that a host with no hook system has" — so the matrix says
|
|
2044
|
-
* that in words rather than leaving a reader to infer it from a column heading.
|
|
2045
|
-
*/
|
|
2331
|
+
// ---------------------------------------------------------------- the command line
|
|
2332
|
+
|
|
2046
2333
|
function printMatrix(say, parsed, columns, { source }) {
|
|
2047
2334
|
say(`gate policy: ${source} — ${parsed.rules.length} rule(s), ${columns.length} backend(s)`);
|
|
2048
2335
|
say();
|
|
@@ -2064,14 +2351,6 @@ function printMatrix(say, parsed, columns, { source }) {
|
|
|
2064
2351
|
}
|
|
2065
2352
|
say();
|
|
2066
2353
|
|
|
2067
|
-
// The honest degradation signal, and the reason the matrix is worth printing at all: not which
|
|
2068
|
-
// backend is fuller, but what this policy declares that NOTHING enforces.
|
|
2069
|
-
//
|
|
2070
|
-
// Split by tier, because the two halves mean opposite things and printing them together would
|
|
2071
|
-
// pad the alarming number with a harmless one. An `auto` rule compiled by no backend is the
|
|
2072
|
-
// system working — unattended is what that tier means. A `gated` or `prohibited` rule compiled
|
|
2073
|
-
// by no backend is a gate that exists only as a sentence, which is the thing this product is
|
|
2074
|
-
// against. Reporting eight where three are real is how a report gets skimmed.
|
|
2075
2354
|
const uncovered = rows.filter((r) => columns.every((c) => r.backends[c.backend].verdict === "refused"));
|
|
2076
2355
|
const gaps = uncovered.filter((r) => r.tier === "gated" || r.tier === "prohibited");
|
|
2077
2356
|
const unattended = uncovered.filter((r) => r.tier === "auto");
|
|
@@ -2087,30 +2366,26 @@ function printMatrix(say, parsed, columns, { source }) {
|
|
|
2087
2366
|
}
|
|
2088
2367
|
}
|
|
2089
2368
|
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
* absolute path in every run, for no defect. The other two branches derive a path rather than echoing
|
|
2111
|
-
* one, and a derived path has to be absolute to mean anything. Raised as an inconsistency by Copilot
|
|
2112
|
-
* (round 10 on #164) and declined on that ground; the contract is stated here rather than changed.
|
|
2113
|
-
*/
|
|
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. */
|
|
2114
2389
|
export function resolveWorkspace(named) {
|
|
2115
2390
|
const dir = path.resolve(named);
|
|
2116
2391
|
const manifestPath = path.join(dir, "workspace.json");
|
|
@@ -2118,12 +2393,7 @@ export function resolveWorkspace(named) {
|
|
|
2118
2393
|
try {
|
|
2119
2394
|
raw = fs.readFileSync(manifestPath, "utf8");
|
|
2120
2395
|
} catch (cause) {
|
|
2121
|
-
//
|
|
2122
|
-
// all — means the question could not be answered, and falling back to `.portulan` would answer
|
|
2123
|
-
// it *no* and then fail somewhere else with a confusing secondary error about a policy file.
|
|
2124
|
-
// The first cut of this function caught everything, which is the fail-open — "nothing looked"
|
|
2125
|
-
// reported as "nothing wrong" — committed in a change whose own header states the rule three times.
|
|
2126
|
-
// Copilot, round 1 on #164.
|
|
2396
|
+
// As given, not resolved: `compile` prints paths built on it.
|
|
2127
2397
|
if (cause.code === "ENOENT") return { workspaceRoot: named, workspaceDir: ".portulan" };
|
|
2128
2398
|
throw new CompileError(
|
|
2129
2399
|
`${manifestPath} could not be read — ${cause.code ?? cause.message}. Only a MISSING manifest means ` +
|
|
@@ -2142,8 +2412,7 @@ export function resolveWorkspace(named) {
|
|
|
2142
2412
|
if (typeof manifest?.tree === "string" && manifest.tree.trim()) {
|
|
2143
2413
|
const root = path.resolve(dir, manifest.tree);
|
|
2144
2414
|
const inside = path.relative(root, dir);
|
|
2145
|
-
// A `tree` that does not contain its
|
|
2146
|
-
// 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.
|
|
2147
2416
|
if (inside && !inside.startsWith("..") && !path.isAbsolute(inside)) {
|
|
2148
2417
|
return { workspaceRoot: root, workspaceDir: inside.split(path.sep).join("/") };
|
|
2149
2418
|
}
|
|
@@ -2152,19 +2421,16 @@ export function resolveWorkspace(named) {
|
|
|
2152
2421
|
return { workspaceRoot: dir, workspaceDir: "." };
|
|
2153
2422
|
}
|
|
2154
2423
|
|
|
2155
|
-
/**
|
|
2156
|
-
* The help screen — see `./doctor.mjs`'s for the contract and why these three gained one late.
|
|
2157
|
-
* `../.portulan/dod.md` condition 4 binds it: every flag below exists in the parser above.
|
|
2158
|
-
*/
|
|
2159
2424
|
function usage() {
|
|
2160
2425
|
return [
|
|
2161
2426
|
"portulan compile — compile a workspace's gate policy into host enforcement",
|
|
2162
2427
|
"",
|
|
2163
2428
|
" portulan compile [--check] [--matrix] [--workspace <dir>] [--pack-root <dir>|auto]...",
|
|
2164
2429
|
"",
|
|
2165
|
-
" (no flag) compile the policy and write each
|
|
2166
|
-
" --check write nothing; exit 1 if an artifact is out of date against the policy",
|
|
2167
|
-
" --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",
|
|
2168
2434
|
" --workspace the workspace directory to compile; defaults to `.portulan`",
|
|
2169
2435
|
" --pack-root where declared packs are resolved from; `auto` discovers the host's plugin cache.",
|
|
2170
2436
|
" A named root REPLACES every other source. A directory actually named `auto` is `./auto`",
|
|
@@ -2180,9 +2446,7 @@ export function run(argv, options = {}) {
|
|
|
2180
2446
|
const say = (line = "") => {
|
|
2181
2447
|
if (!options.quiet) process.stdout.write(`${line}\n`);
|
|
2182
2448
|
};
|
|
2183
|
-
//
|
|
2184
|
-
// complaint about the rest of the command line. `./portulan.mjs` states the contract: an
|
|
2185
|
-
// 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.
|
|
2186
2450
|
if (argv.includes("--help") || argv.includes("-h")) {
|
|
2187
2451
|
say(usage());
|
|
2188
2452
|
return 0;
|
|
@@ -2191,24 +2455,6 @@ export function run(argv, options = {}) {
|
|
|
2191
2455
|
let named = process.cwd();
|
|
2192
2456
|
let check = false;
|
|
2193
2457
|
let showMatrix = false;
|
|
2194
|
-
// Named pack roots, which REPLACE the root derived from `tree` rather than being searched ahead
|
|
2195
|
-
// of it — see `namedRootsOption` for why that distinction cost a checkpoint finding.
|
|
2196
|
-
// `packContributions` has taken `options.packRoots` since session 0 — shaped so that an adopter
|
|
2197
|
-
// resolving from an installed feed travels the same code path as a workspace whose packs ship
|
|
2198
|
-
// beside it — and nothing ever set it, so the parameter was reachable only from a test.
|
|
2199
|
-
// Discovery of a host's plugin cache landed at milestone 7 for BOTH halves (#123): ./discover.mjs
|
|
2200
|
-
// dereferences a POINTER's `governed_by`, and `--pack-root auto` resolves a pack root from the same
|
|
2201
|
-
// record.
|
|
2202
|
-
//
|
|
2203
|
-
// **Discovery IS the default as of 2026-08-13**, and this comment said the opposite — *"what is
|
|
2204
|
-
// deliberately NOT taken is a DEFAULT … defaulting it would change what every existing run
|
|
2205
|
-
// resolves against"*. It would, and the maintainer ruled the change: row 7's clause makes
|
|
2206
|
-
// `--pack-root` *optional where discovery finds a root*, and it was not. Both of the old
|
|
2207
|
-
// sentence's grounds survive where they were actually load-bearing — a **named** root still
|
|
2208
|
-
// REPLACES the derived one (#117, untouched, the arm above), and a required check's verdict still
|
|
2209
|
-
// cannot move with the machine, because `../.portulan/verify/compile.sh` **names its root** and
|
|
2210
|
-
// `./pinned-roots.live.test.mjs` reds if it stops. What changed is which carrier holds that
|
|
2211
|
-
// second guarantee: the pin, not the absence of a default.
|
|
2212
2458
|
const namedRoots = [];
|
|
2213
2459
|
let forced = false;
|
|
2214
2460
|
for (let i = 0; i < argv.length; i += 1) {
|
|
@@ -2221,30 +2467,18 @@ export function run(argv, options = {}) {
|
|
|
2221
2467
|
} else if (argv[i] === "--pack-root") {
|
|
2222
2468
|
const root = argv[i + 1];
|
|
2223
2469
|
i += 1;
|
|
2224
|
-
// A
|
|
2225
|
-
// consume the flag as a path and fail later as an unreadable one. `doctor` already read
|
|
2226
|
-
// it this way, so this aligns the three rather than inventing a rule. A directory whose
|
|
2227
|
-
// 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`.
|
|
2228
2471
|
if (root === undefined || root.startsWith("-"))
|
|
2229
2472
|
throw new CompileError(
|
|
2230
2473
|
"--pack-root needs a directory, or `auto` to discover one from the host plugin cache. " +
|
|
2231
2474
|
"A directory actually named `auto` is `./auto`",
|
|
2232
2475
|
);
|
|
2233
|
-
//
|
|
2234
|
-
// genuinely named `auto` is still reachable as `./auto`. See ./discover.mjs.
|
|
2476
|
+
// Matched raw, so `./auto` still names a directory.
|
|
2235
2477
|
if (root === AUTO) {
|
|
2236
2478
|
forced = true;
|
|
2237
2479
|
continue;
|
|
2238
2480
|
}
|
|
2239
|
-
// A
|
|
2240
|
-
// resolve — and reporting it as the latter sends an author to the one file that is not
|
|
2241
|
-
// at fault. Same distinction as ../.portulan/memory/verify-preconditions-fail-closed.md.
|
|
2242
|
-
//
|
|
2243
|
-
// A FILE is the sharper case here than in the other two tools, and the reason is what this
|
|
2244
|
-
// one emits: a file-valued root made resolution fail and produced a **misleading green
|
|
2245
|
-
// compile** that had simply ignored the intended root. A green is what a session acts on,
|
|
2246
|
-
// so it is worse than the exit 2 the argument deserves. Third carrier of one rule, found by
|
|
2247
|
-
// 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.
|
|
2248
2482
|
let rootStat = null;
|
|
2249
2483
|
try {
|
|
2250
2484
|
rootStat = fs.statSync(root);
|
|
@@ -2262,66 +2496,81 @@ export function run(argv, options = {}) {
|
|
|
2262
2496
|
} else throw new CompileError(`unknown argument ${JSON.stringify(argv[i])}`);
|
|
2263
2497
|
}
|
|
2264
2498
|
|
|
2265
|
-
//
|
|
2266
|
-
// policy read. It sat below both until Copilot's round 2, where an unrelated workspace or
|
|
2267
|
-
// policy error masked the refusal and this tool disagreed with the four beside it about *when*
|
|
2268
|
-
// the answer is given. The other tools' tests pin exactly that property; this one's did not,
|
|
2269
|
-
// 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.
|
|
2270
2500
|
const bothAsked = namedWithAuto(namedRoots, forced);
|
|
2271
2501
|
if (bothAsked) throw new CompileError(bothAsked);
|
|
2272
2502
|
|
|
2273
|
-
// The workspace may be named as a repository root or as the workspace directory itself, and the
|
|
2274
|
-
// second is how a feed-side workspace is reachable at all — see `resolveWorkspace`.
|
|
2275
2503
|
const { workspaceRoot, workspaceDir } = resolveWorkspace(named);
|
|
2276
|
-
|
|
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) {
|
|
2507
|
+
throw new CompileError(
|
|
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`,
|
|
2512
|
+
);
|
|
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
|
+
}
|
|
2277
2553
|
const policy = readJson(policyFile, "the gate policy");
|
|
2278
|
-
//
|
|
2279
|
-
// is validated by exactly the code that validates a hand-written rule.
|
|
2280
|
-
// The thunk means the host's plugin record is read only on a path where it can win — which is
|
|
2281
|
-
// still true of the **named** and **refused** arms and is no longer what keeps `compile --check`
|
|
2282
|
-
// host-independent. That sentence read *"a required check that names no root"*, and since
|
|
2283
|
-
// 2026-08-13 `../.portulan/verify/compile.sh` runs `--pack-root packs --check`: it names one, and
|
|
2284
|
-
// a named root replaces every other source. The pin is the carrier; the thunk is what keeps an
|
|
2285
|
-
// API caller wiring none hermetic.
|
|
2554
|
+
// A thunk, so the host's plugin record is read only where discovery can win.
|
|
2286
2555
|
const { contributions, unresolved, plan } = packContributions(workspaceRoot, workspaceDir, {
|
|
2287
2556
|
named: namedRoots,
|
|
2288
2557
|
discovery: () => discoverPackRoots(),
|
|
2289
2558
|
forced,
|
|
2290
2559
|
});
|
|
2291
|
-
//
|
|
2292
|
-
// installed, which is why it is normally withheld from a byte-compared run — but a union is the
|
|
2293
|
-
// one arrangement where a tree-derived root joined the search without anyone naming it, and the
|
|
2294
|
-
// whole contract of the union is that this is never silent.
|
|
2295
|
-
//
|
|
2296
|
-
// **`--check` CAN reach it now, and that is the point rather than a leak.** This read *"`--check`
|
|
2297
|
-
// cannot reach it: it names no root and passes no `auto`, so `forced` is false and the plan is
|
|
2298
|
-
// never a union there"* — all three clauses went false on 2026-08-13, when the unasked arm began
|
|
2299
|
-
// consulting discovery. A `--check` run whose resolution set included a discovered root and said
|
|
2300
|
-
// nothing would be the silent substitution `--pack-root auto`'s own justification refuses, so the
|
|
2301
|
-
// line prints. The **required** check reaches neither, because it names a root: `source` is
|
|
2302
|
-
// `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.
|
|
2303
2561
|
if (plan && (showMatrix || plan.source === "union")) say(`packs: resolution root ${plan.source} — ${plan.why}`);
|
|
2304
2562
|
const composed = composeFragments(policy, contributions);
|
|
2305
2563
|
const parsed = parse(composed.policy);
|
|
2306
2564
|
const source = path.relative(workspaceRoot, policyFile).split(path.sep).join("/");
|
|
2307
|
-
// `root` is what makes the emitted runner path correct for the workspace being compiled rather
|
|
2308
|
-
// than for the one this process happens to sit in. The plumbing existed and **the caller never
|
|
2309
|
-
// used it**: `compile --workspace <other-project>` wrote that project's settings naming
|
|
2310
|
-
// `${CLAUDE_PROJECT_DIR}/cli/stop-gate.mjs`, a file the target does not have — and a missing hook
|
|
2311
|
-
// fails open, silently. That is the exact defect class this whole change exists to close,
|
|
2312
|
-
// reintroduced through the cross-compile path. Found by the pre-commit checkpoint, which
|
|
2313
|
-
// demonstrated it rather than reasoning about it.
|
|
2314
2565
|
const columns = backends(parsed, {
|
|
2315
2566
|
source,
|
|
2316
2567
|
root: path.resolve(workspaceRoot),
|
|
2317
2568
|
workspaceDir,
|
|
2318
|
-
// What resolved each pack, carried to the artifact so the file the rail compares says
|
|
2319
|
-
// which world compiled it (#264).
|
|
2320
2569
|
packProvenance: contributions,
|
|
2570
|
+
sessions,
|
|
2571
|
+
spend,
|
|
2321
2572
|
});
|
|
2322
2573
|
|
|
2323
|
-
// Printed before the backends, because a rule's provenance changes how its compiled line reads
|
|
2324
|
-
// — and an unresolvable pack is a declaration the workspace believes it composed.
|
|
2325
2574
|
for (const a of composed.added) say(`pack ${a.pack.padEnd(30)} adds \`${a.id}\` (${a.tier})`);
|
|
2326
2575
|
for (const t of composed.tightened) {
|
|
2327
2576
|
say(`pack ${t.pack.padEnd(30)} tightens \`${t.id}\` ${t.from} → ${t.to}`);
|
|
@@ -2333,14 +2582,16 @@ export function run(argv, options = {}) {
|
|
|
2333
2582
|
|
|
2334
2583
|
if (showMatrix) {
|
|
2335
2584
|
printMatrix(say, parsed, columns, { source });
|
|
2585
|
+
if (guidance) {
|
|
2586
|
+
say();
|
|
2587
|
+
printGuidanceMatrix(say, guidance);
|
|
2588
|
+
}
|
|
2336
2589
|
return 0;
|
|
2337
2590
|
}
|
|
2338
2591
|
|
|
2339
2592
|
for (const column of columns) {
|
|
2340
2593
|
say(`${column.label}: ${column.compiled.length} compiled, ${column.refused.length} refused`);
|
|
2341
2594
|
for (const gate of column.compiled) say(` gate ${gate.id.padEnd(38)} ${gate.surface}`);
|
|
2342
|
-
// Refusals are printed, always. A silent cap reads as "covered everything" when it did
|
|
2343
|
-
// not — and these are the rows the per-host degradation report is built from.
|
|
2344
2595
|
for (const r of column.refused) say(` refused ${r.id.padEnd(38)} ${r.why}`);
|
|
2345
2596
|
for (const n of column.notes) say(` note ${n}`);
|
|
2346
2597
|
say();
|
|
@@ -2349,13 +2600,6 @@ export function run(argv, options = {}) {
|
|
|
2349
2600
|
if (check) {
|
|
2350
2601
|
let drifted = 0;
|
|
2351
2602
|
for (const column of columns) {
|
|
2352
|
-
// A backend with no artifact is owed no file — but *present* and not owed is drift,
|
|
2353
|
-
// and it was the hole here for one checkpoint. Deleting `floor` from a policy left
|
|
2354
|
-
// the ruleset behind: importable, valid, and claiming in its own `name` field to be
|
|
2355
|
-
// generated from a policy that no longer produces it. `--check` reported GREEN,
|
|
2356
|
-
// having compared nothing, which is the shape this recipe exists against. The
|
|
2357
|
-
// eighth fail-open of this repository's series, and again in scaffolding rather than
|
|
2358
|
-
// in a check. Found at the pre-commit checkpoint.
|
|
2359
2603
|
if (!column.artifact) {
|
|
2360
2604
|
const orphan = path.join(workspaceRoot, ...artifactPaths(workspaceDir)[column.backend].split("/"));
|
|
2361
2605
|
if (fs.existsSync(orphan)) {
|
|
@@ -2374,13 +2618,6 @@ export function run(argv, options = {}) {
|
|
|
2374
2618
|
continue;
|
|
2375
2619
|
}
|
|
2376
2620
|
if (current !== column.artifact.text) {
|
|
2377
|
-
// **Name the origin difference AT THE MOMENT OF FAILURE** (#264). A drift caused by
|
|
2378
|
-
// a shadowed pack was previously reported as a diff nobody could reproduce from the
|
|
2379
|
-
// files in front of them, because the deciding input was a directory outside the
|
|
2380
|
-
// repository — and the remedy the sentence prescribed, "Recompile", is the very
|
|
2381
|
-
// unpinned act that caused it. Read DEFENSIVELY: the artifact on disk is a file a
|
|
2382
|
-
// human may have edited, so anything unparseable leaves the plain sentence standing
|
|
2383
|
-
// rather than turning a drift report into a crash.
|
|
2384
2621
|
let why = "";
|
|
2385
2622
|
try {
|
|
2386
2623
|
const onDisk = JSON.parse(current)?.$portulan?.packs;
|
|
@@ -2405,17 +2642,14 @@ export function run(argv, options = {}) {
|
|
|
2405
2642
|
drifted += 1;
|
|
2406
2643
|
}
|
|
2407
2644
|
}
|
|
2645
|
+
drifted += emitGuidance(guidance, guidancePlan, { workspaceRoot, check, say });
|
|
2408
2646
|
if (drifted) return 1;
|
|
2409
|
-
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");
|
|
2410
2648
|
return 0;
|
|
2411
2649
|
}
|
|
2412
2650
|
|
|
2413
2651
|
for (const column of columns) {
|
|
2414
2652
|
if (!column.artifact) {
|
|
2415
|
-
// Remove a stale artifact rather than leaving it for `--check` to complain about
|
|
2416
|
-
// forever: the RED above tells a reader to recompile, so recompiling has to be what
|
|
2417
|
-
// fixes it. Deleting a generated file this compiler wrote is the one deletion it may
|
|
2418
|
-
// do — it is reproducible by definition, and it is already in git if it mattered.
|
|
2419
2653
|
const orphan = path.join(workspaceRoot, ...artifactPaths(workspaceDir)[column.backend].split("/"));
|
|
2420
2654
|
if (fs.existsSync(orphan)) {
|
|
2421
2655
|
fs.rmSync(orphan);
|
|
@@ -2429,11 +2663,10 @@ export function run(argv, options = {}) {
|
|
|
2429
2663
|
fs.writeFileSync(file, column.artifact.text);
|
|
2430
2664
|
say(`wrote ${file}`);
|
|
2431
2665
|
}
|
|
2666
|
+
emitGuidance(guidance, guidancePlan, { workspaceRoot, check, say });
|
|
2432
2667
|
return 0;
|
|
2433
2668
|
} catch (error) {
|
|
2434
|
-
//
|
|
2435
|
-
// itself. Exit 2. Exit 1 would assert that an artifact drifted when nothing was compared,
|
|
2436
|
-
// 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.
|
|
2437
2670
|
if (!options.quiet) {
|
|
2438
2671
|
process.stderr.write(
|
|
2439
2672
|
`compile: ${error instanceof CompileError ? error.message : `unanticipated failure — ${error.stack ?? error}`}\n`,
|