@sleepy_panda_srl/portulan 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +7 -6
  2. package/cli/README.md +145 -358
  3. package/cli/advisory.mjs +354 -0
  4. package/cli/comments.mjs +932 -0
  5. package/cli/compile.mjs +1719 -1699
  6. package/cli/context.mjs +883 -0
  7. package/cli/control-chars.mjs +12 -301
  8. package/cli/discover.mjs +17 -574
  9. package/cli/doctor.mjs +269 -1080
  10. package/cli/drills.mjs +92 -504
  11. package/cli/feedback.mjs +16 -237
  12. package/cli/finish.mjs +437 -0
  13. package/cli/form.mjs +644 -0
  14. package/cli/fuzz-shell.mjs +20 -305
  15. package/cli/gate.mjs +7 -199
  16. package/cli/goldens.mjs +26 -184
  17. package/cli/index.mjs +305 -655
  18. package/cli/init.mjs +243 -566
  19. package/cli/inside.mjs +3 -28
  20. package/cli/instructions.mjs +829 -0
  21. package/cli/ledger.mjs +882 -0
  22. package/cli/librarian.mjs +135 -599
  23. package/cli/manifest.mjs +2 -39
  24. package/cli/mutants.mjs +9 -170
  25. package/cli/new.mjs +12 -243
  26. package/cli/pack-identity.mjs +5 -74
  27. package/cli/pack-version.mjs +13 -255
  28. package/cli/plugin-lint.mjs +42 -658
  29. package/cli/portulan.mjs +6 -125
  30. package/cli/recipe-set.mjs +15 -245
  31. package/cli/release-eval.mjs +28 -348
  32. package/cli/review-meter.mjs +29 -265
  33. package/cli/rule-carriers.mjs +12 -175
  34. package/cli/sessions.mjs +82 -0
  35. package/cli/skill-goldens.mjs +10 -176
  36. package/cli/skills-set.mjs +12 -414
  37. package/cli/stop-gate.mjs +83 -514
  38. package/cli/symbols.mjs +1132 -0
  39. package/cli/telemetry.mjs +82 -505
  40. package/cli/upgrade.mjs +171 -314
  41. package/cli/vendor.mjs +146 -476
  42. package/cli/version-carriers.mjs +8 -86
  43. package/core/README.md +1 -1
  44. package/core/engine.md +3 -9
  45. package/core/operating/autonomy.md +1 -1
  46. package/core/operating/context.md +258 -0
  47. package/core/operating/evolution.md +3 -3
  48. package/core/operating/loop.md +13 -20
  49. package/core/operating/memory.md +12 -3
  50. package/core/operating/sessions.md +100 -0
  51. package/core/personas/README.md +33 -0
  52. package/core/personas/librarian.md +2 -2
  53. package/core/skills/clarify/SKILL.md +1 -1
  54. package/core/skills/consolidate/SKILL.md +5 -3
  55. package/core/templates/handoff.md +28 -15
  56. package/core/templates/memory-entry.md +1 -0
  57. package/evals/README.md +271 -845
  58. package/evals/releases/0.2.0.json +146 -0
  59. package/evals/releases/0.2.0.md +67 -0
  60. package/evals/releases/0.3.0.json +150 -0
  61. package/evals/releases/0.3.0.md +68 -0
  62. package/evals/releases/README.md +3 -3
  63. package/package.json +5 -2
  64. package/packs/.claude-plugin/plugin.json +2 -1
  65. package/packs/README.md +7 -4
  66. package/packs/rituals/checkpoints/README.md +1 -1
  67. package/packs/tools/README.md +8 -8
  68. package/packs/tools/github/verify/actions-pinned.sh +10 -55
  69. package/packs/tools/local-llm/README.md +136 -0
  70. package/packs/tools/local-llm/pack.json +32 -0
  71. package/packs/tools/local-llm/personas/local-worker.md +44 -0
  72. package/packs/tools/local-llm/runner/local-llm.mjs +1665 -0
  73. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +64 -0
  74. package/packs/tools/local-llm/verify/README.md +18 -0
  75. package/spec/README.md +113 -6
  76. package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
  77. package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
  78. package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
  79. package/spec/migrations/0004-changelog-fragments.mjs +53 -0
  80. package/spec/migrations/0005-session-log-retired.mjs +69 -0
  81. package/spec/migrations/0006-boot-card.mjs +77 -0
  82. package/spec/migrations/0007-guidance-compiled.mjs +31 -0
  83. package/spec/migrations/0008-card-reading.mjs +55 -0
  84. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
  85. package/spec/migrations/0010-comment-rail.mjs +57 -0
  86. package/spec/migrations/README.md +45 -10
  87. package/spec/pack.schema.json +1 -1
  88. package/spec/slots.md +343 -27
  89. package/spec/workspace.schema.json +145 -4
package/cli/telemetry.mjs CHANGED
@@ -1,120 +1,10 @@
1
1
  #!/usr/bin/env node
2
- // The OTel emitter — milestone 8's *OTel opt-in config*, and the first thing in this repository that
3
- // can send anything anywhere on purpose.
2
+ // The OTel emitter: the review-loop figures as OTLP/HTTP JSON, sent only when a committed config opts in.
4
3
  //
5
- // The argument for the clause is in `../docs/milestones/m08.md`; what belongs here is what the
6
- // opt-in IS, why it is not the standard environment surface, and — at more length than is
7
- // comfortable — what a silent run means, because for an emitter whose correct default state is
8
- // silence, *did nothing* and *is broken* look identical from outside.
4
+ // node cli/telemetry.mjs --config <file> [--repo-root <dir>] [--render | --check <file> | --write <file> | --export]
5
+ // node cli/telemetry.mjs --audit-recipes [--workspace <dir>] [--repo-root <dir>] [--pack-root <dir>]...
9
6
  //
10
- // ## The reading was ruled, not derived
11
- //
12
- // *"OTel opt-in config"* reads at least three ways, and `../.portulan/gate-map.md`'s
13
- // *"Session-open runs `clarify` against the milestone row itself"* exists because milestone 4 guessed
14
- // at exactly this shape of ambiguity and cost a session-blocking question. Put to the maintainer on
15
- // 2026-08-28 as three readings: **(i)** a validated config surface emitting nothing; **(ii)** that
16
- // surface plus a real emitter — hand-written OTLP-over-HTTP JSON, no OpenTelemetry SDK — off by
17
- // default and never callable from a verify recipe; **(iii)** an OTLP-shaped local file sink with no
18
- // network path at all. **He ruled (ii).**
19
- //
20
- // Reading (i) is what this file would be if the emitter were deferred, and it fails
21
- // `../.portulan/dod.md` condition 4 in a way the *names the milestone where it arrives* escape does
22
- // not cover: under the row's `1 per clause` budget the clause would be spent with the emitter owned
23
- // by no session — the *rule with no owner* shape the 2026-08-24 amendment exists to end, one
24
- // altitude up from a rule with no checker.
25
- //
26
- // ## Why the opt-in is a committed file and NOT `OTEL_SDK_DISABLED`
27
- //
28
- // This is the one place this tool deliberately departs from the OpenTelemetry environment contract,
29
- // and it is stated out loud rather than left for a reader to discover from the code.
30
- //
31
- // **`OTEL_SDK_DISABLED` defaults to `false`.** Honouring the standard env surface as the gate would
32
- // mean an adopter who already exports `OTEL_EXPORTER_OTLP_ENDPOINT` in CI — for some other
33
- // service — starts emitting from Portulan without having decided to. That is an opt-**out**, and the
34
- // criterion's word is *opt-in*. It would also put a team decision in ambient per-machine state,
35
- // which is the inverse of the cascade's `core < pack < workspace`: the workspace is where a team's
36
- // policy lives.
37
- //
38
- // So: **the committed config file is the only gate**, and the environment supplies transport only —
39
- // the OTLP endpoint and header variables, in both their general and their metrics-specific forms, so
40
- // an adopter's existing collector configuration works unchanged. A secret never enters the committed
41
- // file; that is `../.portulan/dod.md` condition 5 holding by construction rather than by a reviewer
42
- // noticing.
43
- //
44
- // **What of that contract is implemented, stated at its real width rather than as *"exactly as the
45
- // specification defines them"*, which is what this paragraph used to say and was false.** Read:
46
- // `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` (used as given) falling back to `OTEL_EXPORTER_OTLP_ENDPOINT`
47
- // (a base, with `/v1/metrics` appended), and `OTEL_EXPORTER_OTLP_METRICS_HEADERS` **replacing**
48
- // `OTEL_EXPORTER_OTLP_HEADERS` rather than merging with it. **Not** read, and each would be a silent
49
- // no-op for an adopter who set it: `OTEL_EXPORTER_OTLP_PROTOCOL` — this emitter speaks
50
- // `http/json` and nothing else — `_TIMEOUT`, `_COMPRESSION`, `_CERTIFICATE` and the client-auth pair.
51
- // Naming them is the point: a partial implementation of a standard contract is fine, and a partial
52
- // implementation described as a complete one is the overclaim `../.portulan/principles.md` opens with.
53
- //
54
- // _An earlier draft of this clause put the config in the Workspace Definition as a `telemetry` slot
55
- // at spec 2.9. The session-open checkpoint cut it: every slot in that train arrived through a **ruled
56
- // proposal** (`../spec/README.md`'s 2.8 argument cites proposal `0025`; `governed_by` cites `0017`;
57
- // `provenance` cites `0002`), and `../.portulan/gate-map.md` makes an idea that adds a surface a
58
- // proposal rather than an implementation pull request. `../spec/slots.md`'s own `evals` deferral says
59
- // a schema change plus a spec bump plus a migration **"is not a thing to do in passing"** and defers
60
- // to *when milestone 8 closes* — and the slot would have shipped with zero filled instances, since
61
- // this workspace declares telemetry off and `../examples/` stays at 2.4. The slot is filed to ride
62
- // with `evals` at the close, which makes it one bump instead of two. **Milestone 8 closed 2026-09-09,
63
- // so that deferral is now DUE and undrafted** — proposal `0034` commissions it and settles none of the
64
- // shape; the close named it rather than drafting a schema inside itself._
65
- //
66
- // ## Consent — ruled 2026-08-28, and it is a reading of a tier rather than a new one
67
- //
68
- // `../.portulan/gates.json` makes *sending anything outward on the team's behalf* Gated, and
69
- // `../core/operating/autonomy.md` says Gated is **per action**. Proposal `0014` reserves telemetry as
70
- // *"a separate mechanism with separate consent"* — named there, never ruled, until now.
71
- //
72
- // **The maintainer's ruling: the opt-in config IS the standing consent.** A committed config with
73
- // `enabled: true` is the team's decision, and each subsequent export rides on it without a fresh
74
- // per-send approval — the way a compiled gate carries a tier without asking again. What stays his
75
- // alone is **enabling it in this workspace**, which is why the committed config here says `false` and
76
- // why no endpoint is committed anywhere in this tree.
77
- //
78
- // What makes that safe rather than merely decided is the payload, and the payload is closed by
79
- // construction: it is assembled from an explicit allow-list of aggregate figures, and there is no
80
- // path from a snapshot's free-text or path-shaped fields into a request body. See `PRODUCERS` below,
81
- // where the allow-list is the code rather than a promise about it.
82
- //
83
- // ## What a silent run means — the trap this file is most likely to ship
84
- //
85
- // `./review-meter.mjs` shipped a broken entry guard and `--fetch` **printed nothing, exited 0, and
86
- // wrote no snapshot** — the fourth time this repository has met that shape. For every other tool
87
- // here that is a bad day. For an emitter it is worse: **the correct behaviour of an opted-out
88
- // emitter is also to send nothing**, so a tool that never started and a tool working exactly as
89
- // designed produce the same observable. There is no way to tell them apart from outside.
90
- //
91
- // Two consequences, both load-bearing:
92
- //
93
- // * The entry guard is the ONE form `./rule-carriers.mjs` designates, copied rather than
94
- // re-derived, and the suite pins it under a path containing a space.
95
- // * **Every refusal prints a sentence and returns non-zero.** Silence is never this tool's answer
96
- // to anything. A caller may read exit 0 as *the payload went where it was asked to go* and
97
- // nothing else.
98
- //
99
- // And the two non-export states are kept apart, because collapsing them is its own seam defect: a
100
- // config file with a typo that its author meant to be `true` must not report *opted out*.
101
- //
102
- // 0 the thing asked for happened
103
- // 1 a verdict: the golden has drifted, or `--export` was asked of a workspace that is opted out
104
- // 2 could not run: the config or the snapshot is missing, unreadable, or malformed
105
- //
106
- // _`../.portulan/memory/verify-preconditions-fail-closed.md` is deliberately NOT cited for the
107
- // opted-out arm. That record is scoped to verify recipes and its subject is an enumeration that came
108
- // back empty; `--export` against a workspace legitimately off is not a failed precondition — the tool
109
- // looked, and the answer was no. The checkpoint caught this file citing it and it is a
110
- // misapplication, so it is named here as one._
111
- //
112
- // ## Deliberately not a ninth `portulan` subcommand
113
- //
114
- // `../docs/vision.md` names eight and is human-owned; `../.portulan/identity.md` says a ninth is the
115
- // maintainer's call. The precedent is `./eval-bundle.mjs`, `./drills.mjs`, `./review-meter.mjs` and
116
- // `./skill-goldens.mjs`, each of which carries this same sentence. Run it as
117
- // `node cli/telemetry.mjs …`.
7
+ // Exit 0 done · 1 a verdict (golden drift, export while opted out, a recipe reaching the network) · 2 could not run.
118
8
 
119
9
  import fs from "node:fs";
120
10
  import path from "node:path";
@@ -127,131 +17,80 @@ import { recipeSet, resolverFor } from "./recipe-set.mjs";
127
17
  import { isInside } from "./inside.mjs";
128
18
 
129
19
  // ---------------------------------------------------------------------------------------------
130
- // The offline audit — no verify recipe may reach the network, and now something checks
20
+ // The offline audit: recipes reach neither the network nor local usage records
131
21
  // ---------------------------------------------------------------------------------------------
132
22
 
133
- /**
134
- * The network-capable modes in `cli/`, and the flag that turns each one on.
135
- *
136
- * **The subject is the MODE, not the module.** `../.portulan/verify/review-loop.sh` legitimately runs
137
- * `./review-meter.mjs` — in its snapshot-reading mode, which touches nothing. What a recipe may never
138
- * do is reach the mode that opens a socket. A rail that banned the module would forbid an existing
139
- * green recipe; a rail that bans the flag forbids the thing actually prohibited.
140
- *
141
- * **It rails the CLASS rather than this session's own module**, which is the whole reason it is a
142
- * table. Adding `./telemetry.mjs` and railing only it would have left `--fetch` unrailed — one rule
143
- * enforced at the newer of its two sites, which is the defect
144
- * `../.portulan/proposals/0020-a-fix-is-not-done-at-the-site-it-was-found.md` names and the one this
145
- * repository repeats most. A new network mode adds a row here or it is uncovered, and nothing can
146
- * audit that a row was added — the arrears are named rather than pretended away, as
147
- * `../.portulan/rule-carriers.json` does for its own registry.
148
- */
23
+ /** Every network mode in cli/ or a pack (named from `<category>/<name>/`); a new one is unrailed until it has a row here. */
149
24
  export const NETWORK_MODES = Object.freeze([
150
25
  { module: "cli/review-meter.mjs", flag: "--fetch", what: "fetches review data from GitHub" },
151
26
  { module: "cli/telemetry.mjs", flag: "--export", what: "posts an OTLP payload to a collector" },
152
- // **Added at round 10, and its absence is the defect this table's own docblock warns about,
153
- // committed at the table's birth.** `./feedback.mjs` files a GitHub issue through
154
- // `gh issue create`, gated on `--approve` exactly as this module's export is gated on a committed
155
- // config. It was network-capable before either of the rows above existed, and enumerating two of
156
- // three while claiming to rail *the class* is the census-over-its-own-set shape milestone 8's own
157
- // session 4 named: a set drawn by its author, reported as complete.
158
27
  { module: "cli/feedback.mjs", flag: "--approve", what: "files a GitHub issue with `gh issue create`" },
28
+ {
29
+ module: "tools/local-llm/runner/local-llm.mjs",
30
+ unless: "--verify-offline",
31
+ alsoUnless: ["eval", "--verify"],
32
+ what: "calls the model endpoint except offline rails and captured-record verification",
33
+ },
34
+ ]);
35
+
36
+ /** Local usage is machine-specific too; static mode must be explicit on each recipe invocation. */
37
+ export const LOCAL_RECORD_MODES = Object.freeze([
38
+ { module: "cli/doctor.mjs", requiresStatic: "--static", what: "reads local usage records by default" },
39
+ { module: "cli/portulan.mjs", subcommand: "doctor", requiresStatic: "--static", what: "dispatches to the local usage-record reader" },
40
+ { module: "portulan", subcommand: "doctor", requiresStatic: "--static", what: "dispatches to the local usage-record reader" },
159
41
  ]);
160
42
 
161
- /**
162
- * Strip shell comments so a recipe may **explain** a network mode without being accused of running
163
- * one. `../.portulan/verify/review-loop.sh` says *"Refreshing is `node cli/review-meter.mjs --fetch`,
164
- * run by a person"* in a comment, and a matcher that could not tell prose from a command would red on
165
- * the sentence documenting the very rule it enforces — the failure shape
166
- * `./version-carriers.mjs` records for its own record-layer exclusion.
167
- *
168
- * Whole-line comments only. A trailing `#` cannot be stripped without knowing whether it is inside a
169
- * quote, and a half-right stripper fails OPEN — it would delete a real command that happened to carry
170
- * a `#`. Named as a limit rather than approximated.
171
- */
43
+ export const modeSpelling = (m) => m.requiresStatic ? `${m.module}${m.subcommand ? ` ${m.subcommand}` : ""} without an immediate ${m.requiresStatic}` : (m.flag ? `${m.module} ${m.flag}` : `${m.module} with anything but ${m.unless} alone${m.alsoUnless ? ` or ${m.alsoUnless.join(" ")} alone` : ""}`);
44
+
45
+ /** Whole-line comments only: a trailing `#` may sit inside a quote. */
172
46
  export const stripShellComments = (source) =>
173
47
  source
174
48
  .split("\n")
175
49
  .filter((line) => !/^\s*#/.test(line))
176
50
  .join("\n");
177
51
 
178
- /**
179
- * Does this recipe's script reach a network mode?
180
- *
181
- * Deliberately whole-body rather than same-line: a recipe invokes a tool across several continued
182
- * lines (`review-loop.sh` spreads one command over four), so a same-line rule would miss exactly the
183
- * spelling a real invocation uses.
184
- */
185
- /**
186
- * Split a shell body into the tokens a command is actually made of, stripping the quoting and
187
- * bracketing a path picks up in a script.
188
- */
189
52
  const shellTokens = (body) => body.split(/\s+/).map((t) => t.replace(/^[("'`]+/, "").replace(/[)"'`;]+$/, "")).filter(Boolean);
190
53
 
191
- /**
192
- * Does this token name that module, however it is spelled?
193
- *
194
- * **A matcher that knew one spelling was a bypass, not a rail.** The first version compared the body
195
- * against the literal `cli/telemetry.mjs`, so `node ./cli/telemetry.mjs --export` — the same file, the
196
- * same effect, one prefix different — walked straight past an enforcement check. That is precisely the
197
- * class milestone 8's clauses (a) and (b) exist for: a matcher's coverage is measured, never described,
198
- * and seven of the eight bypasses that produced those clauses were path and grammar spellings.
199
- * Copilot round 3 on #362, and https://github.com/sleepy-panda-srl/portulan/issues/337 already names
200
- * `./` as a spelling that matches nothing at runtime.
201
- *
202
- * So the test is a **suffix** on a path-separator boundary: `cli/telemetry.mjs`, `./cli/telemetry.mjs`
203
- * and `/abs/where/ever/cli/telemetry.mjs` all name it. The cost is stated rather than hidden — some
204
- * *other* tree's `vendor/cli/telemetry.mjs` would match too, which is a **false red**, the safe
205
- * direction, and a recipe reaching into another tree is worth a human looking either way.
206
- */
207
54
  const tokenNamesModule = (token, module) => token === module || token.endsWith(`/${module}`);
208
55
 
209
- /** A flag token, in the two spellings a shell writes it: bare, or `--flag=value`. */
210
56
  const tokenIsFlag = (token, flag) => token === flag || token.startsWith(`${flag}=`);
211
57
 
58
+ /** An `unless` row passes only where every invocation is the module and its one safe flag, alone on its command. */
59
+ const onlySafeInvocations = (source, m) =>
60
+ stripShellComments(source)
61
+ .replace(/\\\n/g, " ")
62
+ .split(/\n|;|&&?|\|\|?|\$\(|<\(|>\(|`/)
63
+ .every((segment) => {
64
+ const tokens = shellTokens(segment);
65
+ if (tokens[0] === "for" || ["[", "test"].includes(tokens[0]) || (tokens[0] === "if" && ["[", "test"].includes(tokens[1]))) return true;
66
+ return tokens.every((t, i) => !tokenNamesModule(t, m.module) || ((tokens.length === i + 2 && tokens[i + 1] === m.unless) || (m.alsoUnless && JSON.stringify(tokens.slice(i + 1)) === JSON.stringify(m.alsoUnless))));
67
+ });
68
+
69
+ const onlyStaticInvocations = (source, mode) => stripShellComments(source)
70
+ .replace(/\\\n/g, " ")
71
+ .split(/\n|;|&&?|\|\|?|\$\(|<\(|>\(|`/)
72
+ .every((segment) => {
73
+ const words = shellTokens(segment);
74
+ const first = words[0];
75
+ // A path in a file test or dependency list is data, not a CLI invocation.
76
+ if (["for", "printf", "echo", "[", "test"].includes(first) || (first === "if" && words.slice(1, 3).some((w) => ["[", "test"].includes(w)))) return true;
77
+ return words.every((word, i) => !tokenNamesModule(word, mode.module) || (mode.subcommand && words[i + 1] !== mode.subcommand) || words[i + (mode.subcommand ? 2 : 1)] === mode.requiresStatic);
78
+ });
79
+
212
80
  export function auditRecipeSource(source) {
213
81
  const tokens = shellTokens(stripShellComments(source));
214
- return NETWORK_MODES.filter((m) => tokens.some((t) => tokenNamesModule(t, m.module)) && tokens.some((t) => tokenIsFlag(t, m.flag)));
82
+ const network = NETWORK_MODES.filter((m) =>
83
+ m.flag ? tokens.some((t) => tokenNamesModule(t, m.module)) && tokens.some((t) => tokenIsFlag(t, m.flag)) : !onlySafeInvocations(source, m),
84
+ );
85
+ const local = LOCAL_RECORD_MODES.filter((m) => !onlyStaticInvocations(source, m));
86
+ return [...network, ...local];
215
87
  }
216
88
 
217
- /**
218
- * Sweep the recipe set the workspace **yields**, at a pinned root.
219
- *
220
- * Three properties, each from a defect this repository has already paid for:
221
- *
222
- * * **It reads the yielded set, not the directory.** `../.portulan/dod.md` condition 1 is about what
223
- * the manifest yields, which since milestone 7's composition amendment includes a composed pack's
224
- * recipes — `tools/github:actions-pinned` lives under `packs/`. A directory sweep of
225
- * `.portulan/verify/` would exempt precisely the recipes hardest to see.
226
- * * **An empty or unobtainable set is could-not-run, never green.** *No recipe reaches the network*
227
- * is satisfied vacuously by no recipes, and a rail whose green can be produced by an enumeration
228
- * coming back empty is the fail-open
229
- * `../.portulan/memory/verify-preconditions-fail-closed.md` was written about — and the vacuity
230
- * `./skill-goldens.mjs` refuses one clause over.
231
- * * **The root is pinned**, so the answer is about this tree rather than about the machine.
232
- */
233
89
  export function auditRecipes({ workspaceDir, repoRoot, packRoots = [] }) {
234
- // **The workspace directory is pinned by `--repo-root` like every other path here.** It was
235
- // resolved against the CURRENT WORKING DIRECTORY, so a run from outside the tree that named
236
- // `--repo-root` correctly would still look for the manifest beside wherever the caller happened to
237
- // stand — the same root/cwd split https://github.com/sleepy-panda-srl/portulan/issues/347 names for
238
- // `--pack-root`, arriving in a new tool. `path.resolve` leaves an absolute `--workspace` alone, so
239
- // naming one explicitly still wins. Copilot round 1 on #362.
240
90
  const workspace = path.resolve(repoRoot, workspaceDir);
241
- // **And it must be INSIDE the pinned root.** Resolving against `repoRoot` does not keep it there:
242
- // `--workspace ../../elsewhere`, or an absolute path anywhere, would have this audit grade a
243
- // manifest that is not part of the tree it claims to be answering about. Round 3 closed exactly
244
- // this for a recipe's *script* and left the *manifest* open — the same fix owed at two sites and
245
- // taken at one, which is now the fourth instance of that shape in this file. Copilot round 10.
246
91
  if (!isInside(path.resolve(repoRoot), workspace)) {
247
92
  return { ok: false, why: `the workspace at ${workspace} resolves outside the repository at ${repoRoot}; this audit grades the pinned tree and will not read past it` };
248
93
  }
249
- // **And the pack roots, which the first repair of this defect left behind.** Round 1 pinned
250
- // `workspaceDir` against `repoRoot` and passed `packRoots` through untouched, so a run from outside
251
- // the tree still resolved them against the caller's cwd — and the yielded recipe SET, hence this
252
- // audit's whole answer, moved with where the tool was invoked from. That is `0020`'s class exactly:
253
- // a fix applied at the site it was found and not at its sibling, inside the change that made it.
254
- // Copilot round 6 on #362.
255
94
  const roots = packRoots.map((r) => path.resolve(repoRoot, r));
256
95
  let manifest;
257
96
  try {
@@ -274,21 +113,14 @@ export function auditRecipes({ workspaceDir, repoRoot, packRoots = [] }) {
274
113
  const findings = [];
275
114
  const read = [];
276
115
  for (const r of recipes) {
277
- // The script is the `.sh` token in the run line — `./.portulan/verify/docs.sh` for a
278
- // workspace recipe, `bash packs/.../actions-pinned.sh` for a composed one.
279
- const script = String(r.run ?? "")
280
- .split(/\s+/)
281
- .find((t) => t.endsWith(".sh"));
116
+ const words = String(r.run ?? "").split(/\s+/).filter(Boolean);
117
+ // A run line that is itself a `node` invocation is the whole shell text the recipe runs, so it is graded as read.
118
+ const direct = words[0] === "node" && !words.some((t) => t.endsWith(".sh"));
119
+ const script = direct ? words.find((t) => /\.[cm]?js$/.test(t)) : words.find((t) => t.endsWith(".sh"));
282
120
  if (!script) {
283
121
  return { ok: false, why: `recipe ${JSON.stringify(r.id)} has no readable script in its run line (${JSON.stringify(r.run ?? null)}); this audit cannot grade what it cannot read` };
284
122
  }
285
123
  const file = path.resolve(repoRoot, script);
286
- // **A script outside the tree is could-not-run, never a pass.** `path.resolve` happily follows
287
- // `bash ../../somewhere/outside.sh` or an absolute path out of the repository, and this audit
288
- // would then read and grade a file the pinned root does not cover — ending the property the
289
- // pinned root exists to buy, and offering a bypass to anyone who wanted one. `isInside` was
290
- // already imported into this module for the consent check and this call site did not use it.
291
- // Copilot round 3 on #362.
292
124
  if (!isInside(path.resolve(repoRoot), file)) {
293
125
  return { ok: false, why: `recipe ${JSON.stringify(r.id)} names ${script}, which resolves outside the repository at ${repoRoot}; this audit grades the tree and will not read past it` };
294
126
  }
@@ -299,7 +131,7 @@ export function auditRecipes({ workspaceDir, repoRoot, packRoots = [] }) {
299
131
  return { ok: false, why: `recipe ${JSON.stringify(r.id)} names ${script}, which could not be read — ${cause.message}` };
300
132
  }
301
133
  read.push(r.id);
302
- for (const hit of auditRecipeSource(source)) findings.push({ recipe: r.id, script, ...hit });
134
+ for (const hit of auditRecipeSource(direct ? r.run : source)) findings.push({ recipe: r.id, script, ...hit });
303
135
  }
304
136
  return { ok: true, examined: read, findings };
305
137
  }
@@ -311,13 +143,7 @@ export function auditRecipes({ workspaceDir, repoRoot, packRoots = [] }) {
311
143
  /** The config document's own version, so a future shape change is a refusal rather than a misread. */
312
144
  export const CONFIG_SPEC = "1";
313
145
 
314
- /**
315
- * Validate an opt-in config. Returns an array of problems; empty means usable.
316
- *
317
- * **Every problem is a refusal, never a default.** A config that half-parses is the state in which
318
- * an author believes they opted in and did not — so there is no "assume off and carry on" arm here.
319
- * Off is a value this file requires somebody to have written down.
320
- */
146
+ /** The config's problems; empty means usable. */
321
147
  export function validateConfig(config) {
322
148
  const problems = [];
323
149
  if (config === null || typeof config !== "object" || Array.isArray(config)) {
@@ -327,8 +153,6 @@ export function validateConfig(config) {
327
153
  if (spec !== CONFIG_SPEC) {
328
154
  problems.push(`portulan.telemetry must be ${JSON.stringify(CONFIG_SPEC)}, not ${JSON.stringify(spec ?? null)}`);
329
155
  }
330
- // Absent is refused rather than read as false. An opt-in whose off state can be produced by
331
- // FORGETTING a key is an opt-in that can be turned on by forgetting a different one.
332
156
  if (typeof config.enabled !== "boolean") {
333
157
  problems.push(`enabled must be a boolean — absent is not false, because a gate you can reach by omission is not a gate (got ${JSON.stringify(config.enabled ?? null)})`);
334
158
  }
@@ -347,8 +171,6 @@ export function validateConfig(config) {
347
171
  }
348
172
  }
349
173
  }
350
- // The seam, as a refusal rather than as a review note. A committed file may name where a secret
351
- // is read from; it may never carry one. `../.portulan/dod.md` condition 5.
352
174
  for (const banned of ["headers", "endpoint", "token", "apiKey", "api_key"]) {
353
175
  if (config[banned] !== undefined || config.exporter?.[banned] !== undefined) {
354
176
  problems.push(
@@ -364,114 +186,37 @@ export function validateConfig(config) {
364
186
  // The producer registry — the closed payload, as code
365
187
  // ---------------------------------------------------------------------------------------------
366
188
 
367
- /**
368
- * What each signal contributes, and nothing else reaches a request body.
369
- *
370
- * **This registry IS the allow-list.** A producer returns metric rows built from named scalars; no
371
- * producer is handed a whole snapshot to spread into a payload, so there is no path from a
372
- * free-text or path-shaped field to the network. That is the seam holding by construction rather
373
- * than by a reviewer noticing — `../.portulan/dod.md` condition 5.
374
- *
375
- * **Ruled 2026-08-28: the review-loop figures only.** `../.portulan/verify/review-loop.sh` says the
376
- * OTel clause *"owes an emission path for these figures"*, and that debt is this one signal. The
377
- * shape below is the seam a second rail joins through — a producer is one entry returning rows —
378
- * rather than a promise that one day it might be.
379
- *
380
- * Each row: `{name, unit, description, value}`. `value` is a finite number or `null`; a null row is
381
- * DROPPED rather than emitted as zero, because an unmeasured window is not a window measuring zero
382
- * and `./review-meter.mjs` is careful about exactly that distinction.
383
- */
189
+ /** The payload's allow-list: a producer builds its rows from named figures and never spreads a snapshot into them. */
384
190
  export const PRODUCERS = {
385
191
  "review-loop": {
386
- /** Where this producer's input is read from, relative to the repository root. */
387
192
  input: "evals/review-loop/snapshot.json",
388
193
  scope: "portulan/review-loop",
389
- /**
390
- * The instant these figures are ABOUT, which is not the instant they are sent.
391
- *
392
- * OTLP defines `timeUnixNano` as the time the measurement is associated with, so a snapshot's
393
- * own `captured` stamp is the correct value and not a workaround for determinism — though it
394
- * is also what makes the committed golden byte-stable, since a clock read would make every
395
- * render differ from the last.
396
- */
194
+ /** OTLP's timeUnixNano is when a figure applies, so the capture stamp, which also keeps the golden byte-stable. */
397
195
  capturedAt: (snapshot) => snapshot.captured ?? null,
398
- /**
399
- * **The input is validated before it is metered, and this was missing.**
400
- *
401
- * `meter()` treats a non-array `pullRequests` as `[]` and this producer defaults `repository`
402
- * and `window.merged`, so a malformed snapshot rendered a payload carrying *wrong* window and
403
- * repository metadata rather than refusing — a fail-open in the one direction that matters,
404
- * since the payload is the thing that leaves the machine. `./review-meter.mjs` already exports
405
- * the validator its own recipe uses; reusing it keeps one carrier of what a snapshot must be,
406
- * rather than a second opinion here that could drift from the producer's. Copilot round 1 on
407
- * #362, through the suppressed channel and promoted to a gating thread by this repository's own
408
- * step.
409
- */
410
196
  validate: (snapshot) => validateSnapshot(snapshot),
411
197
  rows(snapshot) {
412
198
  const m = meter(snapshot);
413
199
  return [
414
200
  { name: "portulan.review.pull_requests", unit: "{pull_request}", description: "Merged pull requests in the metered window.", value: m.pullRequests },
415
201
  { name: "portulan.review.submissions", unit: "{submission}", description: "Reviews the reviewer submitted across the window. Submission units, never fix-rounds.", value: m.submissions },
416
- { name: "portulan.review.submissions_per_pull_request", unit: "1", description: "The figure a-review-loop-needs-a-bound.md bounds the loop on.", value: m.submissionsPerPullRequest },
202
+ { name: "portulan.review.submissions_per_pull_request", unit: "1", description: "The figure the retired a-review-loop-needs-a-bound.md bounded the loop on.", value: m.submissionsPerPullRequest },
417
203
  { name: "portulan.review.submissions_no_inline", unit: "{submission}", description: "Submissions raising no inline comment.", value: m.noInline },
418
204
  { name: "portulan.review.no_inline_rate", unit: "1", description: "An UPPER BOUND on the found-nothing rate; a submission carrying only suppressed notes counts in it.", value: m.noInlineRate },
419
205
  { name: "portulan.review.pushes", unit: "{push}", description: "Distinct heads the reviewer saw — a floor on pushes, not a count of them.", value: m.pushes },
420
206
  { name: "portulan.review.pushes_per_submission", unit: "1", description: "The criterion's literal pushes-per-round figure, in submission units.", value: m.pushesPerSubmission },
421
207
  ];
422
208
  },
423
- /**
424
- * Attributes every row of this signal carries.
425
- *
426
- * `window.captured` is here because of a defect one rail over: the review-loop snapshot **does
427
- * not refresh itself** (https://github.com/sleepy-panda-srl/portulan/issues/356), so a stale
428
- * capture and a current one are the same green. Stamping the capture date onto the exported
429
- * data means a stale export is LABELLED stale at the far end rather than arriving as though it
430
- * were fresh — which is the most this emitter can do about a silence it does not own.
431
- */
432
209
  attributes: (snapshot) => ({
433
210
  "portulan.window.merged": snapshot.window?.merged ?? 0,
434
211
  "portulan.window.captured": snapshot.captured ?? "",
435
212
  "portulan.units": "submission",
436
213
  }),
437
- /**
438
- * The one identifier the payload carries, and it is the maintainer's widening rather than an
439
- * implementer's.
440
- *
441
- * The ruled payload vocabulary was *figures, rule ids, recipe ids* — no identity at all. The
442
- * supervisor's objection was that an export with no resource identity is indistinguishable at
443
- * any real collector, so somebody adds one later, and a closed list widened by an implementer
444
- * is the failure the list exists to prevent. Put to him 2026-08-28; **he widened it to one
445
- * named resource attribute**, which is this.
446
- *
447
- * A **resource** attribute rather than a data-point one, because it identifies the source and
448
- * not the measurement — and it rides once per payload instead of once per point.
449
- *
450
- * `login` stays excluded and is the field this deliberately is not. This snapshot's only login
451
- * is a bot's, but an adopter's would carry human reviewer names, and an identifier about a
452
- * PERSON is outside anything ruled here.
453
- */
214
+ /** The payload's one identifier, the repository's; nothing naming a person, such as a login, may join it. */
454
215
  resource: (snapshot) => ({ "portulan.repository": snapshot.repository ?? "" }),
455
216
  },
456
217
  };
457
218
 
458
- /**
459
- * Every attribute key an emission **may** carry, pinned so the closed payload is a rail and not a
460
- * reminder.
461
- *
462
- * **An unfamiliar key in a rendered payload is a red.** Without this the "closed list" is a promise
463
- * about the code rather than a property of it — and a producer added later could widen the vocabulary
464
- * silently, which is precisely the widening the maintainer reserved to himself.
465
- *
466
- * **This is an ALLOW-LIST, and `REQUIRED_ATTRIBUTE_KEYS` below is the floor — one list was doing both
467
- * jobs and they are not the same job.** `service.namespace` is optional in the config *and* optional
468
- * in OpenTelemetry's own semantic conventions, so a workspace that declares none emits a payload
469
- * without it — legitimately. A single list checked "in both directions" made that legal config look
470
- * like a broken invariant, and the only reason nothing failed is that THIS workspace happens to
471
- * declare a namespace: the pin was green by coincidence of one instance. Requiring the key instead
472
- * would have been this repository inventing a constraint the specification does not have. Copilot
473
- * round 2 on #362.
474
- */
219
+ /** Every attribute key a payload may carry; the suite fails a rendered key outside it. */
475
220
  export const EMITTED_ATTRIBUTE_KEYS = Object.freeze([
476
221
  "service.name",
477
222
  "service.namespace",
@@ -484,38 +229,16 @@ export const EMITTED_ATTRIBUTE_KEYS = Object.freeze([
484
229
  "portulan.units",
485
230
  ]);
486
231
 
487
- /**
488
- * The keys every emission **must** carry, whatever the config says.
489
- *
490
- * The floor rather than the ceiling: a payload missing one of these is not identifiable at a
491
- * collector, which is the thing the maintainer's one widening of the vocabulary was for. Everything in
492
- * `EMITTED_ATTRIBUTE_KEYS` and not here is legitimately conditional — today that is `service.namespace`
493
- * alone, and it is conditional on the config declaring one.
494
- */
495
232
  export const REQUIRED_ATTRIBUTE_KEYS = Object.freeze(EMITTED_ATTRIBUTE_KEYS.filter((k) => k !== "service.namespace"));
496
233
 
497
234
  // ---------------------------------------------------------------------------------------------
498
235
  // OTLP/HTTP JSON — the wire shape, written by hand
499
236
  // ---------------------------------------------------------------------------------------------
500
237
 
501
- /**
502
- * One OTLP `AnyValue`. Exported so the suite asserts against THIS map rather than re-spelling it —
503
- * a second copy of a wire encoding is a second thing to get wrong, and `./inside.mjs`'s docblock is
504
- * this repository's standing argument about that.
505
- */
506
238
  export function anyValue(v) {
507
239
  if (typeof v === "string") return { stringValue: v };
508
240
  if (typeof v === "boolean") return { boolValue: v };
509
- // **`isSafeInteger`, not `isInteger`.** OTLP's `intValue` is an int64 and a JS number stops being an
510
- // exact integer past 2^53 — `Number.isInteger(2**53 + 1)` is `true` and the value is already the
511
- // wrong one, so stringifying it emits a precise-looking integer that is not the number anybody
512
- // meant. Beyond that range `asDouble` is the honest encoding: approximate, and *labelled*
513
- // approximate, which is the same distinction between could-not-run and a verdict that this module
514
- // spends a docblock on. Copilot round 11 on #362, through the suppressed channel.
515
- //
516
- // **Not reachable with today's producer** — the review-loop figures are pull-request and submission
517
- // counts — and it is fixed anyway because `anyValue` is exported, and the producer seam exists to
518
- // invite rows this module did not write.
241
+ // Past 2^53 a number may not be the integer meant, so it goes out as a double rather than as a wrong int64.
519
242
  if (Number.isSafeInteger(v)) return { intValue: String(v) };
520
243
  if (typeof v === "number" && Number.isFinite(v)) return { doubleValue: v };
521
244
  throw new Error(`no OTLP encoding for ${JSON.stringify(v)}`);
@@ -523,21 +246,10 @@ export function anyValue(v) {
523
246
 
524
247
  const kv = (attrs) => Object.entries(attrs).map(([key, value]) => ({ key, value: anyValue(value) }));
525
248
 
526
- /**
527
- * Render the OTLP/HTTP JSON metrics payload.
528
- *
529
- * **Gauge, and the point type is a decision rather than a default.** These are snapshot statistics
530
- * over a window of merged pull requests — not monotonic counters. A `Sum` would owe an
531
- * `aggregationTemporality` and a `startTimeUnixNano`, and the snapshot records no window START (only
532
- * its size and the per-pull-request merge stamps), so any start instant would be derived — which is
533
- * where double counting comes from. A Gauge needs neither and says what these figures are.
534
- */
249
+ /** Gauges, not Sums: a Sum owes a start time, and the snapshot records no window start. */
535
250
  export function renderPayload({ config, signals, version = VERSION }) {
536
251
  const resource = { "service.name": config.service.name, "telemetry.sdk.name": "portulan", "telemetry.sdk.language": "nodejs", "telemetry.sdk.version": version };
537
252
  if (config.service.namespace !== undefined) resource["service.namespace"] = config.service.namespace;
538
- // The maintainer's one widening of the closed vocabulary, contributed by the signal rather than
539
- // invented here. Signals agreeing on a key is fine; a signal inventing one the pin does not know
540
- // is what `EMITTED_ATTRIBUTE_KEYS` and the suite exist to catch.
541
253
  for (const s of signals) Object.assign(resource, s.resource ?? {});
542
254
 
543
255
  const scopeMetrics = [];
@@ -545,9 +257,6 @@ export function renderPayload({ config, signals, version = VERSION }) {
545
257
  const timeUnixNano = String(BigInt(Date.parse(s.capturedAt)) * 1000000n);
546
258
  const attributes = kv(s.attributes);
547
259
  const metrics = s.rows
548
- // A null figure is DROPPED, never sent as zero. `./review-meter.mjs` returns null for an
549
- // unmeasured ratio precisely so nobody reads an empty corpus as a measured zero, and
550
- // encoding it as 0 here would undo that one layer down.
551
260
  .filter((r) => r.value !== null && Number.isFinite(r.value))
552
261
  .map((r) => ({
553
262
  name: r.name,
@@ -567,26 +276,7 @@ export const serialize = (payload) => `${JSON.stringify(payload, null, 2)}\n`;
567
276
  // Transport — the ONE mode that reaches the network
568
277
  // ---------------------------------------------------------------------------------------------
569
278
 
570
- /**
571
- * Read transport from the OpenTelemetry standard environment. Nothing here is committed anywhere.
572
- *
573
- * `OTEL_EXPORTER_OTLP_HEADERS` is the specification's `key=value,key=value` list; it is parsed but
574
- * **never printed**, because a header list is where a bearer token lives. Neither is the endpoint,
575
- * beyond its origin and path — see `safeEndpoint`, and round 14 of this file's own review for why
576
- * withholding one while printing the other was not enough.
577
- */
578
- /**
579
- * A URL safe to print: no userinfo, no query, no fragment.
580
- *
581
- * **The headers were withheld and the URL was not, which is the same reasoning applied at one site and
582
- * not its sibling.** A collector endpoint legally carries credentials — `https://user:pass@host/…` —
583
- * and tokens are routinely passed as query parameters, so printing the endpoint on success leaked
584
- * exactly what withholding the header list was protecting. Into CI logs, which are long-lived and, in
585
- * this repository, world-readable.
586
- *
587
- * Origin and path only. Enough for a human to tell *where it went*, which is the whole reason the
588
- * endpoint is printed at all. Copilot round 14 on #362.
589
- */
279
+ /** A URL safe to print: origin and path, since userinfo and a query may carry credentials. */
590
280
  export function safeEndpoint(href) {
591
281
  try {
592
282
  const u = new URL(href);
@@ -596,21 +286,12 @@ export function safeEndpoint(href) {
596
286
  u.hash = "";
597
287
  return u.href;
598
288
  } catch {
599
- // Unparsable, so nothing can be redacted from it with any confidence — and an unparsable value
600
- // is exactly the one most likely to be a mistyped secret. Never echoed.
601
289
  return "<withheld: not a parsable URL>";
602
290
  }
603
291
  }
604
292
 
293
+ /** Endpoint and headers only: the OTLP protocol, timeout, compression, certificate and client-key variables go unread. */
605
294
  export function transportFromEnv(env) {
606
- // **The signal-specific variables win, and they are used AS GIVEN.** The OpenTelemetry protocol
607
- // specification draws a distinction this function ignored: `OTEL_EXPORTER_OTLP_ENDPOINT` is a BASE
608
- // to which the signal's path is appended, while `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` is the full
609
- // URL and must be used untouched. Appending to both produced `…/v1/metrics/v1/metrics` for anyone
610
- // configured the signal-specific way, and left them no escape hatch — while this file's own
611
- // docblock claimed the variables are read "exactly as the OpenTelemetry specification defines
612
- // them". A conformance claim is a claim like any other, and this one was false. Copilot round 5 on
613
- // #362, through the suppressed channel.
614
295
  const specific = env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT;
615
296
  const base = env.OTEL_EXPORTER_OTLP_ENDPOINT;
616
297
  const named = typeof specific === "string" && specific.length > 0 ? "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT" : "OTEL_EXPORTER_OTLP_ENDPOINT";
@@ -622,14 +303,8 @@ export function transportFromEnv(env) {
622
303
  try {
623
304
  url = new URL(named === "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT" ? raw : raw.endsWith("/") ? `${raw}v1/metrics` : `${raw}/v1/metrics`);
624
305
  } catch {
625
- // The raw value is NOT echoed. It comes from the environment, it may carry credentials or a
626
- // query token, and a value that failed to parse is the likeliest of all to be a mistyped
627
- // secret. The variable name is what a reader needs; the value is what they must not be given.
628
306
  return { ok: false, why: `${named} is not a parsable URL — its value is withheld, since an endpoint may carry credentials` };
629
307
  }
630
- // Headers follow the same rule: the signal-specific list REPLACES the general one rather than
631
- // merging with it, which is what the specification says and is the behaviour a reader configuring
632
- // one tenant's credentials for metrics alone is relying on.
633
308
  const headerSource = env.OTEL_EXPORTER_OTLP_METRICS_HEADERS ?? env.OTEL_EXPORTER_OTLP_HEADERS ?? "";
634
309
  const headers = { "content-type": "application/json" };
635
310
  for (const pair of headerSource.split(",")) {
@@ -639,76 +314,24 @@ export function transportFromEnv(env) {
639
314
  return { ok: true, url: url.href, headers, from: named };
640
315
  }
641
316
 
642
- /**
643
- * The real send. **Injected everywhere it is exercised**, and that is a rule rather than a
644
- * convenience: `../.portulan/verify/tests.sh` runs the suite, so a test opening a real socket would
645
- * be a network call inside a verify recipe — which `../spec/slots.md` and
646
- * `../.portulan/verify/README.md` both prohibit outright. The suite tests this module against an
647
- * injected transport; the real socket is exercised in the session's recorded demonstration, outside
648
- * the recipe set.
649
- */
317
+ /** The real send; the suite injects its own, since it runs inside a verify recipe, which may not reach the network. */
650
318
  export async function postJson(url, headers, body) {
651
319
  const res = await fetch(url, { method: "POST", headers, body });
652
320
  return { status: res.status, ok: res.ok, text: await res.text().catch(() => "") };
653
321
  }
654
322
 
655
- /**
656
- * Is the consent COMMITTED, or merely present in somebody's working copy?
657
- *
658
- * **The ruling is that the committed config is the standing consent — so an uncommitted config is not
659
- * consent at all.** Without this check an agent could write `enabled: true` into a working copy and
660
- * export on nobody's decision, and the maintainer's Gated act would have been performed by the thing
661
- * it gates. That is a mandate nothing checks, which this repository holds to be another way of
662
- * spelling broken (`../.portulan/memory/a-mandate-nothing-checks-is-already-broken.md`), arriving
663
- * inside the change that states the mandate.
664
- *
665
- * The discipline is `./drills.mjs`'s, one file wide: it **reports on a commit and says which one**.
666
- * Tracked, and byte-identical to `HEAD`, or `--export` refuses.
667
- *
668
- * **This is the ONLY path in this module that spawns anything**, and it is on the `--export` arm
669
- * alone — `--render` and `--check` are what a verify recipe may run, and they touch neither a
670
- * subprocess nor a socket.
671
- */
323
+ /** Refuses a config that is untracked, staged and never committed, or changed since HEAD. */
672
324
  export function consentIsCommitted(configPath, repoRoot, spawn = spawnSync) {
673
325
  const parent = path.resolve(repoRoot);
674
- // **Pinned by `repoRoot`, like every other path this tool takes.** `path.resolve(configPath)` alone
675
- // reads a relative `--config` against the CALLER'S cwd, so an in-repo config was classed *outside
676
- // the repository* whenever the tool ran from anywhere else. Third instance of the root/cwd split in
677
- // this one file — `workspaceDir` in round 1, `packRoots` in round 6, this in round 7 — which is why
678
- // it is fixed here as a rule rather than a site: every path this function touches derives from
679
- // `child`, and nothing below calls `path.resolve` on the raw argument again.
680
326
  const child = path.resolve(parent, configPath);
681
327
  if (!isInside(parent, child)) {
682
328
  return { ok: false, why: `${configPath} is outside the repository at ${repoRoot}, so nothing can establish that it is committed` };
683
329
  }
684
- // **Git wants POSIX separators, whatever the platform gives `path.relative`.** On Windows this
685
- // returns `evals\\telemetry\\config.json`, and git's pathspecs and `HEAD:<path>` syntax both want
686
- // `/` — so a config that IS committed would be refused as untracked, or as missing at HEAD. The
687
- // consent gate would fail closed rather than open, but it would fail for every Windows adopter and
688
- // the message would name the wrong cause.
689
- //
690
- // Seven sites in `cli/` already normalise this way, `./librarian.mjs` on exactly this
691
- // git-relative case; this was the eighth that needed it and did not have it. Unlike `isInside`,
692
- // which was extracted to one carrier, this convention is still copied — filed rather than reduced
693
- // here, since collapsing seven call sites is a change of its own shape.
694
- // Copilot round 9 on #362.
330
+ // Git pathspecs and HEAD:<path> want forward slashes, whatever separator path.relative gives.
695
331
  const rel = path.relative(parent, child).split(path.sep).join("/");
696
332
  const git = (args) => spawn("git", ["-C", parent, ...args], { encoding: "utf8" });
697
333
 
698
- // **Is this a repository at all? Asked ONCE, up front, and that is the whole design of what
699
- // follows.**
700
- //
701
- // The first repair of this function distinguished *git failed* from *the file is untracked* by
702
- // treating exit **128** as "not a repository". That is true of `ls-files` and **false of `show`**:
703
- // `git show HEAD:<path>` also exits 128 for a path that is tracked in the index and not in `HEAD` —
704
- // *"fatal: path 'x' exists on disk, but not in 'HEAD'"*, measured. So the repair made the
705
- // *does not exist at HEAD* branch unreachable and reported *not a repository* for a file somebody
706
- // had **staged and never committed** — which is precisely the case this gate exists to catch, since
707
- // staging is how an agent would try to manufacture consent one step short of a commit.
708
- //
709
- // Establishing repository-ness once removes the ambiguity structurally instead of by matching on
710
- // git's prose, which is localised and version-dependent. After this line, a non-zero status is that
711
- // command's own domain answer and nothing else. Copilot round 7 on #362.
334
+ // Asked first, since `git show HEAD:<path>` also exits 128 for a path staged but never committed.
712
335
  const repo = git(["rev-parse", "--git-dir"]);
713
336
  if (repo.error) return { ok: false, why: `git could not be run to establish whether the consent is committed — ${repo.error.message}` };
714
337
  if (repo.status !== 0) {
@@ -724,18 +347,10 @@ export function consentIsCommitted(configPath, repoRoot, spawn = spawnSync) {
724
347
  const head = git(["show", `HEAD:${rel}`]);
725
348
  if (head.error) return { ok: false, why: `git could not read ${rel} at HEAD — ${head.error.message}` };
726
349
  if (head.status !== 0) {
727
- // Tracked but not in `HEAD` — staged, or committed on no branch yet. Either way the consent has
728
- // never been committed, which is the answer, not a malfunction.
729
350
  return { ok: false, why: `${rel} is tracked but does not exist at HEAD, so the consent it states has been staged and never committed` };
730
351
  }
731
352
 
732
- // **Git's own notion of "differs", not a byte compare.** Reading the working copy and comparing it
733
- // to `git show HEAD:<path>` looks equivalent and is not: with `core.autocrlf=true` — the Windows
734
- // default, and this repository ships no `.gitattributes` — a committed file is checked out with
735
- // CRLF while the blob at HEAD stays LF. The bytes differ, `git diff` is clean, and the gate would
736
- // refuse a consent that IS committed. Fail-closed, and wrong for every Windows adopter: the same
737
- // platform class as the separator defect one round earlier, and equally invisible to CI here,
738
- // which runs `ubuntu-latest` only. Copilot round 16 on #362.
353
+ // `git diff`, not a byte compare: under core.autocrlf a committed file checks out as CRLF over an LF blob.
739
354
  const diff = git(["diff", "--quiet", "HEAD", "--", rel]);
740
355
  if (diff.error) return { ok: false, why: `git could not compare ${rel} against HEAD — ${diff.error.message}` };
741
356
  if (diff.status === 1) {
@@ -763,35 +378,16 @@ const USAGE = [
763
378
  " first: OTEL_EXPORTER_OTLP_METRICS_ENDPOINT (used as given) else",
764
379
  " OTEL_EXPORTER_OTLP_ENDPOINT (a base, +/v1/metrics); and",
765
380
  " OTEL_EXPORTER_OTLP_METRICS_HEADERS replacing OTEL_EXPORTER_OTLP_HEADERS",
766
- " --audit-recipes assert that no recipe the workspace YIELDS can reach a network mode;",
381
+ " --audit-recipes assert that no recipe the workspace YIELDS can reach a network mode or read local usage records;",
767
382
  " takes --workspace and --pack-root, and needs no --config",
768
383
  "",
769
384
  "exit 0 the thing asked for happened · 1 a verdict (golden drift, or export while opted out)",
770
385
  " · 2 could not run (config or snapshot missing, unreadable, or malformed)",
771
386
  ].join("\n");
772
387
 
773
- /**
774
- * Every option whose value is a path, and therefore must be pinned by `--repo-root`.
775
- *
776
- * **This exists because the same defect was fixed five times, one site at a time.** Across this pull
777
- * request's review: `--workspace` resolved against the caller's cwd (round 1), `--pack-root` left
778
- * behind by that very fix (round 6), `--config` and its re-read (round 7), `--workspace` resolved but
779
- * not contained (round 10), and `--check`/`--write` (round 12). Each repair was correct and each left
780
- * a sibling, which is
781
- * `../.portulan/proposals/0020-a-fix-is-not-done-at-the-site-it-was-found.md`'s class five times over.
782
- *
783
- * So the pinning is **one operation over a declared list** rather than a call at each use. A new path
784
- * option is pinned by adding it here, and the suite asserts this list against the parser's own keys —
785
- * so an option that takes a path and is not named here reddens instead of becoming the sixth instance.
786
- */
787
388
  export const PATH_OPTIONS = Object.freeze(["config", "check", "write", "workspace"]);
788
389
 
789
- /**
790
- * Resolve every path option against the pinned root, once, before anything reads one.
791
- *
792
- * `path.resolve` leaves an absolute value alone, so naming an absolute path explicitly still wins —
793
- * which is the property `--pack-root` needed and the reason this is `resolve` rather than `join`.
794
- */
390
+ /** Resolves each path option against the repository root; an absolute one stays as given. */
795
391
  export function pinPaths(opts) {
796
392
  const root = path.resolve(opts.repoRoot);
797
393
  const pinned = { ...opts, repoRoot: root, packRoots: opts.packRoots.map((r) => path.resolve(root, r)) };
@@ -841,11 +437,7 @@ export async function run(argv = process.argv.slice(2), io = console, { env = pr
841
437
  io.log(USAGE);
842
438
  return 0;
843
439
  }
844
- // Pinned ONCE, here, before anything reads a path. See `PATH_OPTIONS`.
845
440
  opts = pinPaths(opts);
846
- // The audit is about the RECIPES, not about a payload, so it runs before the config is read and
847
- // needs no --config. A workspace that has not opted in still owes the property that none of its
848
- // recipes can reach the network.
849
441
  if (opts.audit) {
850
442
  const res = auditRecipes({ workspaceDir: opts.workspace, repoRoot: opts.repoRoot, packRoots: opts.packRoots });
851
443
  if (!res.ok) {
@@ -853,16 +445,16 @@ export async function run(argv = process.argv.slice(2), io = console, { env = pr
853
445
  return 2;
854
446
  }
855
447
  if (res.findings.length > 0) {
856
- io.error(`telemetry: ${res.findings.length} verify recipe(s) can reach the network:`);
857
- for (const f of res.findings) io.error(` - ${f.recipe} (${f.script}) invokes ${f.module} ${f.flag}, which ${f.what}`);
858
- io.error("A verify recipe may not make a network call: spec/slots.md and .portulan/verify/README.md");
448
+ io.error(`telemetry: ${res.findings.length} verify recipe(s) can reach the network or local usage records:`);
449
+ for (const f of res.findings) io.error(` - ${f.recipe} (${f.script}) invokes ${modeSpelling(f)}, which ${f.what}`);
450
+ io.error("A verify recipe may not make a network call or read local usage records: spec/slots.md and .portulan/verify/README.md");
859
451
  io.error("both prohibit it, because a rail that moves with the network goes red about the world");
860
452
  io.error("rather than about the tree.");
861
453
  return 1;
862
454
  }
863
- io.log(`telemetry: ${res.examined.length} yielded recipe(s) examined; none reaches a network mode`);
864
- io.log(` Modes railed: ${NETWORK_MODES.map((m) => `${m.module} ${m.flag}`).join(", ")}.`);
865
- io.log(" A network mode with no row here is UNRAILED, and nothing can audit that a row was added.");
455
+ io.log(`telemetry: ${res.examined.length} yielded recipe(s) examined; none reaches a network mode or reads local usage records`);
456
+ io.log(` Modes railed: ${[...NETWORK_MODES, ...LOCAL_RECORD_MODES].map(modeSpelling).join(", ")}.`);
457
+ io.log(" A network or local-record mode with no row here is UNRAILED, and nothing can audit that a row was added.");
866
458
  return 0;
867
459
  }
868
460
 
@@ -877,9 +469,7 @@ export async function run(argv = process.argv.slice(2), io = console, { env = pr
877
469
  return 2;
878
470
  }
879
471
 
880
- // ---- the config. Unreadable and opted-out are DIFFERENT ANSWERS and get different codes.
881
- // Pinned against `--repo-root`, the same rule `consentIsCommitted` and the signal inputs follow —
882
- // a relative `--config` must mean the same file wherever the tool is invoked from.
472
+ // ---- the config
883
473
  const configPath = opts.config;
884
474
  let config;
885
475
  try {
@@ -965,8 +555,7 @@ export async function run(argv = process.argv.slice(2), io = console, { env = pr
965
555
 
966
556
  // ---- --export, the one mode that reaches the network
967
557
  if (opts.export) {
968
- // The gate, spelled ONCE and here. Non-zero and a sentence: for this tool silence is never an
969
- // answer, because an opted-out emitter and a broken one are both silent.
558
+ // Never silent: an opted-out emitter and one that never started would look alike.
970
559
  if (config.enabled !== true) {
971
560
  io.error(`telemetry: ${configPath} says enabled: false, so nothing was sent.`);
972
561
  io.error("This is a verdict, not a failure: the config was read and it opts out. Emission is");
@@ -974,7 +563,6 @@ export async function run(argv = process.argv.slice(2), io = console, { env = pr
974
563
  io.error("workspace has not given it. Use --render to see exactly what WOULD be sent.");
975
564
  return 1;
976
565
  }
977
- // The consent must be COMMITTED, not merely written. See `consentIsCommitted`.
978
566
  const committed = consentIsCommitted(configPath, opts.repoRoot);
979
567
  if (!committed.ok) {
980
568
  io.error(`telemetry: --export cannot run — ${committed.why}.`);
@@ -1001,14 +589,11 @@ export async function run(argv = process.argv.slice(2), io = console, { env = pr
1001
589
  io.error(`telemetry: the collector answered ${res.status}${res.text ? ` — ${res.text.slice(0, 200)}` : ""}`);
1002
590
  return 1;
1003
591
  }
1004
- // **Origin and path only, and the headers never.** A header list is where a bearer token lives —
1005
- // and so is a URL, which may carry `user:pass@` or a query token. Withholding one and printing
1006
- // the other was the same rule applied at one site and not its sibling.
1007
592
  io.log(`telemetry: exported ${emitted} metric(s) to ${safeEndpoint(t.url)} — ${res.status}`);
1008
593
  return 0;
1009
594
  }
1010
595
 
1011
- // ---- --render, the default. No socket is opened on this path.
596
+ // ---- --render, the default
1012
597
  io.log(body);
1013
598
  io.log(`telemetry: rendered ${emitted} metric(s) over ${signals.length} signal(s); nothing was sent.`);
1014
599
  io.log(` This workspace's config says enabled: ${config.enabled}. --export is the only mode that`);
@@ -1018,14 +603,7 @@ export async function run(argv = process.argv.slice(2), io = console, { env = pr
1018
603
  return 0;
1019
604
  }
1020
605
 
1021
- // The entry guard, in the ONE form `./rule-carriers.mjs` designates — copied rather than re-derived,
1022
- // as `./drills.mjs` and `./fuzz-shell.mjs` both are. `file://${argv[1]}` is NOT that form:
1023
- // `import.meta.url` percent-encodes and a working copy under a path with spaces then never matches,
1024
- // so the tool exits 0 having never started. Four files here have shipped that defect.
1025
- //
1026
- // **It matters more in this file than in any of them.** An emitter that never starts sends nothing,
1027
- // and an emitter that is correctly opted out also sends nothing. The two are indistinguishable from
1028
- // outside, so the guard is the difference between a working gate and a tool that has silently stopped.
606
+ // As file URLs, since `import.meta.url` percent-encodes a space; through realpath too, for an npm `bin` symlink.
1029
607
  function isMain() {
1030
608
  const invoked = process.argv[1];
1031
609
  if (!invoked) return false;
@@ -1037,8 +615,7 @@ function isMain() {
1037
615
  }
1038
616
  }
1039
617
 
1040
- // `process.exitCode` rather than `process.exit`, which `./control-chars.mjs` settled here: exiting
1041
- // outright can truncate a pipe that has not drained, and a truncated line IS exit 0 with no output.
618
+ // `process.exitCode` rather than `process.exit`, so a pipe that has not drained is not cut short.
1042
619
  if (isMain()) {
1043
620
  run(process.argv.slice(2)).then((code) => {
1044
621
  process.exitCode = code;