@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.
Files changed (81) hide show
  1. package/README.md +7 -6
  2. package/cli/README.md +142 -291
  3. package/cli/advisory.mjs +354 -0
  4. package/cli/comments.mjs +932 -0
  5. package/cli/compile.mjs +1443 -1210
  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 +225 -1071
  10. package/cli/drills.mjs +1107 -0
  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 +595 -0
  15. package/cli/gate.mjs +7 -198
  16. package/cli/goldens.mjs +311 -0
  17. package/cli/index.mjs +305 -655
  18. package/cli/init.mjs +244 -564
  19. package/cli/inside.mjs +3 -28
  20. package/cli/instructions.mjs +829 -0
  21. package/cli/ledger.mjs +778 -0
  22. package/cli/librarian.mjs +135 -599
  23. package/cli/manifest.mjs +2 -39
  24. package/cli/mutants.mjs +764 -0
  25. package/cli/new.mjs +12 -243
  26. package/cli/pack-identity.mjs +6 -68
  27. package/cli/pack-version.mjs +13 -248
  28. package/cli/plugin-lint.mjs +42 -658
  29. package/cli/portulan.mjs +5 -124
  30. package/cli/recipe-set.mjs +16 -240
  31. package/cli/release-eval.mjs +748 -0
  32. package/cli/review-meter.mjs +500 -0
  33. package/cli/rule-carriers.mjs +12 -175
  34. package/cli/sessions.mjs +82 -0
  35. package/cli/skill-goldens.mjs +424 -0
  36. package/cli/skills-set.mjs +12 -414
  37. package/cli/stop-gate.mjs +126 -454
  38. package/cli/symbols.mjs +1132 -0
  39. package/cli/telemetry.mjs +582 -0
  40. package/cli/upgrade.mjs +171 -314
  41. package/cli/vendor.mjs +146 -476
  42. package/cli/version-carriers.mjs +9 -74
  43. package/core/README.md +1 -1
  44. package/core/engine.md +3 -9
  45. package/core/operating/autonomy.md +1 -1
  46. package/core/operating/context.md +183 -0
  47. package/core/operating/evolution.md +3 -3
  48. package/core/operating/loop.md +13 -20
  49. package/core/operating/memory.md +12 -3
  50. package/core/operating/sessions.md +100 -0
  51. package/core/personas/librarian.md +2 -2
  52. package/core/skills/consolidate/SKILL.md +4 -2
  53. package/core/templates/gate-policy.md +27 -8
  54. package/core/templates/handoff.md +28 -15
  55. package/core/templates/memory-entry.md +1 -0
  56. package/evals/README.md +320 -0
  57. package/evals/releases/0.1.3.json +134 -0
  58. package/evals/releases/0.1.3.md +64 -0
  59. package/evals/releases/0.2.0.json +146 -0
  60. package/evals/releases/0.2.0.md +67 -0
  61. package/evals/releases/README.md +32 -0
  62. package/package.json +9 -2
  63. package/packs/rituals/checkpoints/README.md +9 -2
  64. package/packs/rituals/checkpoints/pack.json +1 -1
  65. package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +32 -2
  66. package/packs/tools/github/verify/actions-pinned.sh +10 -55
  67. package/spec/README.md +112 -6
  68. package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
  69. package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
  70. package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
  71. package/spec/migrations/0004-changelog-fragments.mjs +53 -0
  72. package/spec/migrations/0005-session-log-retired.mjs +69 -0
  73. package/spec/migrations/0006-boot-card.mjs +77 -0
  74. package/spec/migrations/0007-guidance-compiled.mjs +31 -0
  75. package/spec/migrations/0008-card-reading.mjs +55 -0
  76. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
  77. package/spec/migrations/0010-comment-rail.mjs +57 -0
  78. package/spec/migrations/README.md +45 -10
  79. package/spec/pack.schema.json +1 -1
  80. package/spec/slots.md +359 -23
  81. package/spec/workspace.schema.json +145 -4
package/cli/drills.mjs ADDED
@@ -0,0 +1,1107 @@
1
+ #!/usr/bin/env node
2
+ // The forced-red drill harness — every rail broken on purpose, and required to fire.
3
+
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import crypto from "node:crypto";
8
+ import { spawnSync } from "node:child_process";
9
+ import { pathToFileURL } from "node:url";
10
+
11
+ import { CouldNotRun } from "./goldens.mjs";
12
+ import { isInside } from "./inside.mjs";
13
+ import { recipeSet, resolverFor } from "./recipe-set.mjs";
14
+
15
+ const RAIL_TIMEOUT_MS = 10 * 60 * 1000;
16
+
17
+ export const DRILL_SESSION_PREFIX = "portulan-drill";
18
+
19
+ /** Hooks answer in their stdout JSON and exit 0 either way, so their drills judge by a tell read from stdout. */
20
+ export const NON_RECIPE_RAILS = [
21
+ {
22
+ id: "stop-gate",
23
+ what: "the Stop-gate — `./stop-gate.mjs`, wired by the compiler as a `Stop` hook. It runs the workspace's default recipe and blocks the turn ending when it is not green.",
24
+ argv: ["cli/stop-gate.mjs"],
25
+ tellStream: "stdout",
26
+ },
27
+ {
28
+ id: "gate",
29
+ what: "the PreToolUse gate runner — `./gate.mjs`, which finds the rule of the yielded policy an attempted action matches and returns that rule's own sentence.",
30
+ argv: ["cli/gate.mjs"],
31
+ tellStream: "stdout",
32
+ },
33
+ ];
34
+
35
+ export const NOT_DRILLED = [
36
+ {
37
+ rail: "the platform floor — branch protection, the required checks, `enforce_admins`",
38
+ why: "forcing it red means a direct push to `main` or a merge of a red pull request: outward, Gated, and the maintainer's. `../.portulan/gate-map/platform-floor.md` records what `enforce_admins` holds and why the test that would force it past is deliberately not run.",
39
+ },
40
+ {
41
+ rail: "the `permissions` layer the compiler emits",
42
+ why: "that gate is the host's, not this repository's. `compile` byte-checks what is emitted and `goldens`/`mutants` grade the matcher behind it; forcing the host to refuse is a measurement of Claude Code, taken by hand and recorded in `../.portulan/gate-map.md`.",
43
+ },
44
+ {
45
+ rail: "the CI seam — a non-zero recipe becoming a failed check becoming `BLOCKED`",
46
+ why: "drill 1 of 2026-07-30 established it in both directions on #118, and re-drilling it needs a pull request opened for the purpose. Covered once, deliberately not recurring: the seam is shared by every recipe in one job, so it does not need covering once per rail.",
47
+ },
48
+ {
49
+ rail: "the pre-commit seam scan (`../.portulan/dod.md` condition 5)",
50
+ why: "its term list lives outside this repository by design, so no scheduled job here can run it. It is drilled once per session instead, by the planted-term control every session's attestation records.",
51
+ },
52
+ {
53
+ rail: "`claude plugin validate --strict`",
54
+ why: "deliberately not a verify recipe — `../.portulan/identity.md` argues why: CI installs nothing, so declaring it would make a recipe that exits 2 on every run. It is checked at the supervised checkpoints and before a release, which is a rail on no calendar, and that cost is already stated in `../.portulan/verify/README.md`.",
55
+ },
56
+ {
57
+ rail: "Dependabot, the `copilot auto-review` ruleset, and `pr-labels`",
58
+ why: "platform watchers whose triggering condition is an event this repository cannot manufacture on a schedule. `0007`'s own honest limit applies — not every watcher can be forced red safely — and each carries its own recorded observation instead.",
59
+ },
60
+ {
61
+ rail: "the librarian's scheduled pass",
62
+ why: "not a rail in this sense: it files work for a human rather than refusing an action, so there is no red to force. Its own failure is a failed workflow run, and `0007` covers it as a watcher.",
63
+ },
64
+ ];
65
+
66
+ /** A rail that reads only the index misses a created file unless the drill sets `stage`. */
67
+ export const DRILLS = [
68
+ // ------------------------------------------------------------------------- the yielded recipes
69
+ {
70
+ rail: "docs",
71
+ perturb: {
72
+ file: "CONTRIBUTING.md",
73
+ find: "- [`docs/plan.md`](docs/plan.md) — the milestones. A change's record is its commit message.",
74
+ replace: "- [`docs/plan.md`](docs/plan-the-drill-broke-this.md) — the milestones. A change's record is its commit message.",
75
+ },
76
+ exit: 1,
77
+ tell: "link(s) that do not resolve in the repository",
78
+ why: "The `links` check is the one that has fired for real here, twice on 2026-07-30, over a path that existed in a working copy and not in a clean checkout. This keeps it firing.",
79
+ },
80
+ {
81
+ rail: "json",
82
+ perturb: {
83
+ file: ".portulan/labels.json",
84
+ find: '"policy": "at-least-one",',
85
+ replace: '"policy": "at-least-one",,',
86
+ },
87
+ exit: 1,
88
+ tell: "Expected double-quoted property name",
89
+ why: "A manifest that does not parse gates nothing. This rail is the floor every JSON-reading rail above it stands on, so its silence would be the widest.",
90
+ },
91
+ {
92
+ rail: "doctor",
93
+ perturb: {
94
+ file: ".portulan/workspace.json",
95
+ find: '"identity": "identity.md",',
96
+ replace: '"identity": "identity-the-drill-removed.md",',
97
+ },
98
+ exit: 1,
99
+ tell: "slots.identity points at",
100
+ why: "`doctor` resolving a slot path is the check the Workspace Definition rests on: a manifest naming a file that is not there must never validate.",
101
+ },
102
+ {
103
+ rail: "doctor",
104
+ perturb: {
105
+ file: ".portulan/gates.json",
106
+ find: '"id": "edit-on-a-working-branch",\n "tier": "auto",',
107
+ replace: '"id": "edit-on-a-working-branch",\n "tier": "gated",',
108
+ },
109
+ exit: 1,
110
+ tell: "matches no path a host can submit",
111
+ why: "A gate whose target can never match is the one defect this repository cannot see by reading a policy: the compiler reports it COMPILED and `doctor` counts it covered, so the gate map, the artifact and the report all agree about a gate that enforces nothing. Hole 8 of the gate map, closed by #337's option 3 — and the only rail that can watch the closure is the one whose exit is a verdict rather than a refusal.",
112
+ },
113
+ {
114
+ rail: "tests",
115
+ perturb: {
116
+ create: "cli/drill.test.mjs",
117
+ content: [
118
+ "// A forced-red drill fixture, written by `cli/drills.mjs` into a throwaway worktree and never",
119
+ "// committed. If you are reading this file inside the repository, a drill did not clean up after",
120
+ "// itself and that is the bug.",
121
+ 'import { test } from "node:test";',
122
+ 'import assert from "node:assert/strict";',
123
+ "",
124
+ 'test("forced-red drill: the tests recipe reports a failing assertion", () => {',
125
+ " assert.equal(1, 2);",
126
+ "});",
127
+ "",
128
+ ].join("\n"),
129
+ },
130
+ exit: 1,
131
+ tell: "forced-red drill: the tests recipe reports a failing assertion",
132
+ why: "The shape drill 1 used on #118 on 2026-07-30, kept as the recurring form of the only rail this repository has ever observed firing on purpose in CI.",
133
+ },
134
+ {
135
+ rail: "plugin",
136
+ perturb: {
137
+ file: ".claude-plugin/plugin.json",
138
+ find: '"./plugin/skills/",',
139
+ replace: '"./plugin/skills-the-drill-moved/",',
140
+ },
141
+ exit: 1,
142
+ tell: "plugin/skills-the-drill-moved",
143
+ why: "A declared component path that resolves nowhere is how a plugin ships registering nothing — the defect `skills-set` exists beside this check to make impossible.",
144
+ },
145
+ {
146
+ rail: "compile",
147
+ perturb: {
148
+ file: ".claude/settings.json",
149
+ find: '"Bash(gh pr merge:*)",',
150
+ replace: '"Bash(gh pr merge:*)",\n "Bash(the-drill-added-this:*)",',
151
+ },
152
+ exit: 1,
153
+ tell: "has drifted from",
154
+ why: "A committed generated file invites the hand-fix that works until the next compile silently reverts it. This is the drift rail for the enforcement itself, so its firing is the difference between a compiled gate and a decorative one.",
155
+ },
156
+ {
157
+ rail: "workflow-filters",
158
+ perturb: {
159
+ file: ".github/workflows/pr-labels.yml",
160
+ find: "if ! declared=$(jq -er '.labels[].name' \"$POLICY\"); then",
161
+ // Appends, so the fixture's anchor still finds the program and the rail exits 1 rather than 2.
162
+ replace: "if ! declared=$(jq -er '.labels[].name + \"-drilled\"' \"$POLICY\"); then",
163
+ },
164
+ exit: 1,
165
+ tell: "fixture(s) failed",
166
+ why: "The jq programs the merge gates branch on are executed rather than described. A filter that silently changed shape would take the label gate down with it.",
167
+ },
168
+ {
169
+ rail: "index",
170
+ perturb: {
171
+ file: ".portulan/memory-index.md",
172
+ find: "- [A branch syncs with main before it merges](memory/a-branch-syncs-with-main-before-it-merges.md) — rule",
173
+ replace: "- [A branch syncs with main before it merges](memory/a-branch-syncs-with-main-before-it-merges.md) — rule (edited by hand)",
174
+ },
175
+ exit: 1,
176
+ tell: "is out of date against the store",
177
+ why: "The index is generated and byte-compared, which is the only thing that makes `core/operating/memory.md`'s *generated, never hand-maintained* a fact rather than a preference.",
178
+ },
179
+ {
180
+ rail: "context",
181
+ perturb: {
182
+ file: "core/engine.md",
183
+ find: "# Portulan engine",
184
+ // 408 bytes: over the engine rail's headroom, under the boot rails', so only the engine rail fires.
185
+ replace: `# Portulan engine${" (moved back from an on-read file)".repeat(12)}`,
186
+ },
187
+ exit: 1,
188
+ tell: "over its rail of",
189
+ why: "Every boot in every adopting repository reads the kernel and the boot skill in full, so a demotion undone there costs every context everywhere. The rail is the only thing that turns that growth from a quiet diff into a red.",
190
+ },
191
+ {
192
+ rail: "ledger",
193
+ perturb: {
194
+ file: "cli/ledger.mjs",
195
+ find: "const prior = request.id === null ? undefined : byId.get(request.id);",
196
+ replace: "const prior = undefined;",
197
+ },
198
+ exit: 1,
199
+ tell: "does not reproduce",
200
+ why: "The host writes one usage record per content block, so a reader that stops deduplicating by message id multiplies every figure by the blocks per request and still prints plausible numbers. The fixture's known totals are the only thing that tells those numbers from the true ones.",
201
+ },
202
+ {
203
+ rail: "control-chars",
204
+ perturb: {
205
+ file: "CONTRIBUTING.md",
206
+ find: "# Contributing",
207
+ // A real NUL, written as an escape so this file carries no control byte of its own.
208
+ replace: "# Contributing\u0000",
209
+ },
210
+ exit: 1,
211
+ tell: "control character(s) — first at line",
212
+ why: "A raw NUL shipped here once inside a template literal, and the tool most likely to have shown it — `grep` — is the tool the byte silences. Only a byte reader can fire on this, so only a byte reader's silence would hide it.",
213
+ },
214
+ {
215
+ rail: "comments",
216
+ perturb: {
217
+ file: "cli/comments.mjs",
218
+ find: 'const figure = (n) => n.toLocaleString("en-US");',
219
+ replace: 'const figure = (n) => n.toLocaleString("en-US"); // Added 2026-09-24.',
220
+ },
221
+ exit: 1,
222
+ tell: "is over the limit of",
223
+ why: "A comment carrying a change's history is read again by every session that opens its file, and a model matching the surrounding code copies it. Only a reader of the comments themselves can tell one from the code around it.",
224
+ },
225
+ {
226
+ rail: "rule-carriers",
227
+ perturb: {
228
+ file: ".portulan/README.md",
229
+ find: "# `.portulan/` — Portulan's own workspace",
230
+ replace: "# `.portulan/` — Portulan's own workspace\n\nCI runs every recipe the manifest declares.",
231
+ },
232
+ exit: 1,
233
+ tell: "recipe the manifest declares",
234
+ why: "A rule an incident reduced to one carrier must stay reduced. The registered spelling reappearing uncited is exactly the drift proposal 0027 was written for.",
235
+ },
236
+ {
237
+ rail: "pack-version",
238
+ perturb: {
239
+ file: "packs/tools/github/pack.json",
240
+ find: ' "tr"\n ],',
241
+ replace: ' "tr",\n "awk"\n ],',
242
+ },
243
+ exit: 1,
244
+ tell: "without moving `portulan.version`",
245
+ why: "A pack whose contributions moved without its version moving is a pin that no longer names what it pins — the whole basis on which an adopter runs third-party code in their CI.",
246
+ },
247
+ {
248
+ rail: "pack-identity",
249
+ perturb: {
250
+ file: "README.md",
251
+ find: "**Current release: `0.2.0`**",
252
+ replace: "**Current release: `0.1.3`** <!-- the drill edited this and did not stage it -->",
253
+ },
254
+ stage: false,
255
+ exit: 1,
256
+ tell: "the package would not install the tree's bytes",
257
+ why: "`.portulan/identity.md` claims the `npx` path installs the same bytes as the tree. This rail is that claim's only continuous check; an unstaged edit is precisely the drift it owns.",
258
+ },
259
+ {
260
+ rail: "payload",
261
+ perturb: {
262
+ create: "cli/a-module-the-drill-left-unclassified.mjs",
263
+ content: [
264
+ "// A forced-red drill fixture, written by `cli/drills.mjs` into a throwaway worktree and never",
265
+ "// committed. If you are reading this file inside the repository, a drill did not clean up after",
266
+ "// itself and that is the bug.",
267
+ "//",
268
+ "// It is here because `package.json`'s `files` sweeps `cli/`, so ANY module landing here joins the",
269
+ "// npm payload. `cli/pack-identity.mjs` would report green over it — it checks the bytes of what is",
270
+ "// packed and never the roster — which is how `ab.mjs`, `ab-run.mjs` and `ab-grade.mjs` shipped for",
271
+ "// three sessions before #382 removed them.",
272
+ "",
273
+ "export const theDrillPlantedThis = true;",
274
+ "",
275
+ ].join("\n"),
276
+ },
277
+ // Not staged: `npm pack` reads the working tree, so an untracked module ships too.
278
+ stage: false,
279
+ exit: 1,
280
+ tell: "SHIPS and is classified by nothing",
281
+ why:
282
+ "`package.json`'s `files` array governs the payload and nothing derived its membership until #383 — " +
283
+ "`pack-identity` holds the packed BYTES and is silent on which files are packed. The drill proves the " +
284
+ "rail sees a real arrival in the tree rather than only a synthetic report handed to `findings()`, " +
285
+ "which is the half a unit test cannot reach.",
286
+ },
287
+ {
288
+ rail: "eval-bundle",
289
+ perturb: {
290
+ create: "drill-top-level-path/README.md",
291
+ content: "A top-level path the eval-bundle roster has never seen.\n",
292
+ },
293
+ stage: true,
294
+ exit: 1,
295
+ tell: "drill-top-level-path",
296
+ why: "The bundle's pinned top-level roster is what stops the next licensed cut silently thinning or mislicensing. It reads the index, so this drill stages — a creation it cannot see is a rail that reads as not firing.",
297
+ },
298
+ {
299
+ rail: "goldens",
300
+ perturb: {
301
+ file: "evals/goldens/gates/force-push-without-a-lease.json",
302
+ find: ' "command": "git push --force origin main"\n },\n "expect": true,',
303
+ replace: ' "command": "git push --force origin main"\n },\n "expect": false,',
304
+ },
305
+ exit: 1,
306
+ tell: "the-bare-spelling",
307
+ why: "The corpus is only worth its green if a case that stops answering as recorded reds it. This flips the control every other case in that file is measured against.",
308
+ },
309
+ {
310
+ rail: "mutants",
311
+ perturb: {
312
+ file: "cli/mutants.mjs",
313
+ find: ' replace: "s === action.shell || s.startsWith(action.shell)",\n outcome: "killed",',
314
+ replace: ' replace: "s === action.shell || s.startsWith(action.shell)",\n outcome: "survives",',
315
+ },
316
+ exit: 1,
317
+ tell: "is recorded SURVIVES and was KILLED",
318
+ why: "A recorded outcome that stops being true must red in BOTH directions — a `killed` operator that survives means the kill-set weakened, and a `survives` one that is killed is good news the record has to absorb. This drills the second direction, which is the one a reader is least likely to expect a rail to hold. _(The first draft renamed an operator id instead, on the assumption that the census pins its own roster. Measured: it does not — the census stayed GREEN. The drill was rewritten around what the rail actually does rather than around what its author assumed.)_",
319
+ },
320
+ {
321
+ rail: "fuzz-shell",
322
+ perturb: {
323
+ file: "cli/compile.mjs",
324
+ find: 'const REDIRECTION_TARGET = String.raw`(?:"(?:\\\\[\\s\\S]|[^"\\\\])*"|\'[^\']*\'|\\\\[\\s\\S]|[^\\s])+`;',
325
+ replace: "const REDIRECTION_TARGET = String.raw`[^\\s]+`;",
326
+ },
327
+ exit: 1,
328
+ tell: "disagree with the recorded grammar",
329
+ why: "Session 1's own drill, kept: this is the exact #336 defect — a redirection target reader narrower than a shell word — and the fuzzer is what turns that class from three review rounds into a generated red.",
330
+ },
331
+ {
332
+ rail: "version-carriers",
333
+ perturb: {
334
+ file: "README.md",
335
+ find: "**Current release: `0.2.0`**",
336
+ replace: "**Current release: `0.1.3`**",
337
+ },
338
+ // Staged: this rail reads the index (`git show :<path>`), so an unstaged edit is invisible to it.
339
+ stage: true,
340
+ exit: 1,
341
+ tell: "but package.json declares",
342
+ why: "This repository shipped that exact defect twice, and `README.md` is in `package.json`'s `files` — npm freezes a README per version, so a wrong sentence that reaches a publish needs another release to correct.",
343
+ },
344
+ {
345
+ rail: "skill-goldens",
346
+ perturb: {
347
+ file: "core/skills/clarify/SKILL.md",
348
+ find: "## Why it earns its tokens",
349
+ replace: "5. **A step the drill added** — bound to nothing, answered by no case.\n\n## Why it earns its tokens",
350
+ },
351
+ exit: 1,
352
+ tell: "is in the skill's pass and in no case",
353
+ why: "A core skill states its mandates in prose, and prose is what this repository ships. Before this rail a step could be added, reworded or deleted with every check still green. The derived denominator is what makes the corpus a rail rather than a self-graded exam, so it is the half worth drilling.",
354
+ },
355
+ {
356
+ rail: "review-loop",
357
+ perturb: {
358
+ file: "evals/review-loop/register.md",
359
+ find: "- **Repository:** `sleepy-panda-srl/portulan`",
360
+ replace: "- **Repository:** `sleepy-panda-srl/portulan` (edited by hand)",
361
+ },
362
+ exit: 1,
363
+ tell: "is out of date against the snapshot",
364
+ why: "The register is the review loop's published figures, and this clause exists because the figures it replaces were hand-maintained. A register that could drift from its own snapshot would be that hand-maintained tally wearing a generated file's clothes.",
365
+ },
366
+ {
367
+ rail: "telemetry",
368
+ perturb: {
369
+ file: ".portulan/verify/review-loop.sh",
370
+ find: " --snapshot evals/review-loop/snapshot.json \\",
371
+ replace: " --fetch \\\n --snapshot evals/review-loop/snapshot.json \\",
372
+ },
373
+ exit: 1,
374
+ tell: "invokes cli/review-meter.mjs --fetch",
375
+ why: "A verify recipe that can reach the network goes red about the world rather than about the tree, and this repository prohibits it in three carriers while nothing checked it. The drill proves the checker sees a real invocation — spread across continued lines, the spelling a recipe actually uses — rather than only the literal string a test would hand it.",
376
+ },
377
+ {
378
+ rail: "ab",
379
+ perturb: {
380
+ create: ".portulan/a-file-the-drill-left-unclassified.md",
381
+ content: [
382
+ "A forced-red drill fixture, written by `cli/drills.mjs` into a throwaway worktree and never",
383
+ "committed. If you are reading this file inside the repository, a drill did not clean up after",
384
+ "itself and that is the bug.",
385
+ "",
386
+ "It is here because `cli/ab.mjs`'s disposition table has to be TOTAL over `.portulan/`: anything",
387
+ "it does not classify is carried into the A/B treatment arm by `cli/vendor.mjs`, which walks the",
388
+ "whole workspace directory.",
389
+ "",
390
+ ].join("\n"),
391
+ },
392
+ exit: 1,
393
+ tell: "classified by no disposition",
394
+ why:
395
+ "The A/B treatment arm is built by removing things from customer zero's workspace, and `cli/vendor.mjs` carries " +
396
+ "every ordinary file under it — so a specification that names what to remove leaks whatever it forgot, silently, " +
397
+ "into the arm an experiment is about to measure. That leak has already happened once and `doctor` was green on it. " +
398
+ "This is the rail that turns the next one into a red on the commit that adds the file.",
399
+ },
400
+ {
401
+ rail: "ab-grade",
402
+ perturb: {
403
+ file: "cli/ab-grade.mjs",
404
+ find: ' const scenario = "curated-layer";\n const anchor = anchored(root, scenario, nonce);',
405
+ replace:
406
+ ' const scenario = "curated-layer";\n' +
407
+ ' if (fs.existsSync(path.join(root, "AGENTS.md"))) return verdict(scenario, "proposals", "a drill-planted constant about the BASE", []);\n' +
408
+ " const anchor = anchored(root, scenario, nonce);",
409
+ },
410
+ exit: 1,
411
+ tell: "did not invert the figures",
412
+ why:
413
+ "A grader that has stopped reading the arm produces a baseline about file copying, and it does so silently: the " +
414
+ "prototype this instrument was built around passed level-1 discrimination and was still a constant. Only inversion " +
415
+ "caught it. This drill plants that constant back and requires the rail to fire — on the level-2 tell specifically, " +
416
+ "because a fix that restored level 1 alone would leave the arm-blindness in place.",
417
+ },
418
+ {
419
+ rail: "release-eval",
420
+ perturb: {
421
+ file: "cli/release-eval.mjs",
422
+ find: 'export const FIRST_GOVERNED_VERSION = "0.1.3";',
423
+ replace: 'export const FIRST_GOVERNED_VERSION = "0.1.2";',
424
+ },
425
+ exit: 1,
426
+ tell: "is a release from milestone 8 onward and there is no",
427
+ why:
428
+ "Milestone 8's ninth clause is that a release carries an eval result, and the check that carries it is *a governed " +
429
+ "release with no record reds*. Every governed release this repository has cut carries its record, so that arm cannot " +
430
+ "be exercised from the committed tree at all — the rail's green grades a non-empty set and finds every member " +
431
+ "complete, which is a measurement whose red arm nothing in the tree can reach. This drill moves the boundary so " +
432
+ "the arm becomes reachable and requires it to fire, so the " +
433
+ "clause's central check is watched working rather than assumed from a green over an empty set.",
434
+ },
435
+ {
436
+ rail: "ab-run",
437
+ perturb: {
438
+ file: "evals/ab/baseline.json",
439
+ find: '"operatorEnv": "isolated"',
440
+ replace: '"operatorEnv": "inherit"',
441
+ },
442
+ exit: 1,
443
+ tell: "no baseline may be recorded under an unisolated arm",
444
+ why:
445
+ "A baseline is the only record in this repository derived from events rather than from the tree, so it cannot be " +
446
+ "re-derived and a reader has nothing but the capture beside it to trust. This rail holds the published document to " +
447
+ "that capture, and refuses a baseline claiming an arm that is not the ruled one — the departure `evals/ab/corpus.md` " +
448
+ "forbids in terms, and the one a later session is likeliest to reach for when a credential is inconvenient.",
449
+ },
450
+ {
451
+ rail: "tools/github:actions-pinned",
452
+ perturb: {
453
+ file: ".github/workflows/verify.yml",
454
+ find: " - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1",
455
+ replace: " - uses: actions/checkout@v7",
456
+ },
457
+ exit: 1,
458
+ tell: "is a tag or branch, not a commit",
459
+ why: "The composed pack's own rail, drilled from the adopting workspace exactly as a workspace-owned one is — which is the property milestone 7's composition amendment bought and nothing else here re-checks.",
460
+ },
461
+
462
+ // ------------------------------------------------------- the coverage rail, which is itself a rail
463
+ {
464
+ rail: "drills",
465
+ perturb: {
466
+ file: "cli/drills.mjs",
467
+ // Split so this line is not its own anchor; additive so every other anchor still places.
468
+ find: "export const DRILL" + "S = [",
469
+ replace:
470
+ "export const DRILL" +
471
+ 'S = [\n { rail: "a-rail-this-workspace-does-not-yield", perturb: null, stdinControl: {}, exit: 1, tell: "an inserted drill", why: "inserted by the drill for the `drills` rail" },',
472
+ },
473
+ exit: 1,
474
+ tell: "a-rail-this-workspace-does-not-yield",
475
+ why: "The allow-list is what stops a misspelled recipe id being silently reclassified as a rail of its own, leaving the real recipe undrilled while the coverage check reports green. A drill naming a rail nothing yields is the shortest path to that state.",
476
+ },
477
+
478
+ // ------------------------------------------------------------------------- the two non-recipe rails
479
+ {
480
+ rail: "stop-gate",
481
+ perturb: {
482
+ file: "CONTRIBUTING.md",
483
+ find: "- [`docs/plan.md`](docs/plan.md) — the milestones. A change's record is its commit message.",
484
+ replace: "- [`docs/plan.md`](docs/plan-the-drill-broke-this.md) — the milestones. A change's record is its commit message.",
485
+ },
486
+ stdin: {
487
+ hook_event_name: "Stop",
488
+ session_id: DRILL_SESSION_PREFIX,
489
+ },
490
+ exit: 0,
491
+ // The recipe reason only: whether this hook also blocks for a missing handoff depends on the date.
492
+ tell: "PORTULAN STOP-GATE (recipe",
493
+ why: "This gate is what makes a red unmissable rather than merely recorded. It fails open on a crash by design, so a crashed Stop-gate and a green one are indistinguishable from outside — and only a drill that requires the refusal can tell them apart.",
494
+ },
495
+ {
496
+ rail: "gate",
497
+ perturb: null,
498
+ stdinControl: { tool_name: "Bash", tool_input: { command: "git status --short" } },
499
+ stdin: { tool_name: "Bash", tool_input: { command: "git push --force origin main" } },
500
+ exit: 0,
501
+ tell: "PORTULAN GATE `force-push-without-a-lease`",
502
+ why: "The wrapper spellings and the shell writes are the ground the permission pattern cannot reach, so for those this hook is the only layer. A hook that had stopped emitting would leave that ground uncovered and say nothing.",
503
+ },
504
+ ];
505
+
506
+ const digest = (buffer) => crypto.createHash("sha256").update(buffer).digest("hex");
507
+
508
+ /** Throws `CouldNotRun`, never skips, when the anchor is absent or ambiguous or the bytes do not move. */
509
+ export function perturb(worktree, drill) {
510
+ if (!drill.perturb) return null;
511
+
512
+ const contained = (rel) => {
513
+ let root;
514
+ try {
515
+ root = fs.realpathSync(worktree);
516
+ } catch (cause) {
517
+ throw new CouldNotRun(
518
+ `the drill worktree ${worktree} cannot be resolved — ${cause.code ?? cause.message}. ` +
519
+ "Nothing is perturbed against a tree that is not there",
520
+ );
521
+ }
522
+ const target = path.resolve(worktree, rel);
523
+
524
+ // The realpath loop below skips past a dangling link, so a link at the target is refused here.
525
+ let leaf = null;
526
+ try {
527
+ leaf = fs.lstatSync(target);
528
+ } catch {
529
+ /* Not there at all, which is the ordinary case for a `create`. */
530
+ }
531
+ if (leaf?.isSymbolicLink()) {
532
+ throw new CouldNotRun(
533
+ `drill \`${drill.rail}\` names ${JSON.stringify(rel)}, which is a symlink. A write follows it, so it can ` +
534
+ "leave the drill worktree even when the link is dangling — refused whatever it points at",
535
+ );
536
+ }
537
+
538
+ let probe = target;
539
+ for (;;) {
540
+ try {
541
+ probe = fs.realpathSync(probe);
542
+ break;
543
+ } catch {
544
+ const parent = path.dirname(probe);
545
+ if (parent === probe) break;
546
+ probe = parent;
547
+ }
548
+ }
549
+ // The first stops a symlink hop, the second a `..` that never touches an existing directory.
550
+ if (!isInside(root, probe) || !isInside(path.resolve(worktree), target)) {
551
+ throw new CouldNotRun(
552
+ `drill \`${drill.rail}\` names ${JSON.stringify(rel)}, which resolves outside the drill worktree. ` +
553
+ "A perturbation may only touch the throwaway tree — the isolation is the reason a drill is safe to run at all",
554
+ );
555
+ }
556
+ return target;
557
+ };
558
+
559
+ if (drill.perturb.create !== undefined) {
560
+ const target = contained(drill.perturb.create);
561
+ if (fs.existsSync(target)) {
562
+ throw new CouldNotRun(
563
+ `drill \`${drill.rail}\` would create ${drill.perturb.create}, which already exists in the tree — ` +
564
+ `a creation that overwrites is an edit wearing the wrong shape. Re-declare it as {file, find, replace} or pick another path`,
565
+ );
566
+ }
567
+ fs.mkdirSync(path.dirname(target), { recursive: true });
568
+ fs.writeFileSync(target, drill.perturb.content);
569
+ return { path: drill.perturb.create, before: null, after: digest(Buffer.from(drill.perturb.content)) };
570
+ }
571
+
572
+ const rel = drill.perturb.file;
573
+ const target = contained(rel);
574
+ let source;
575
+ try {
576
+ source = fs.readFileSync(target, "utf8");
577
+ } catch (cause) {
578
+ throw new CouldNotRun(
579
+ `drill \`${drill.rail}\` names ${rel}, which cannot be read in the drilled tree — ${cause.code ?? cause.message}. ` +
580
+ `The file moved and this drill did not: re-anchor it or retire it, but do not let it be skipped`,
581
+ );
582
+ }
583
+
584
+ let count = 0;
585
+ let at = 0;
586
+ for (;;) {
587
+ const i = source.indexOf(drill.perturb.find, at);
588
+ if (i === -1) break;
589
+ count += 1;
590
+ at = i + 1;
591
+ }
592
+ if (count === 0) {
593
+ throw new CouldNotRun(
594
+ `drill \`${drill.rail}\` does not place: its anchor is absent from ${rel}. The subject moved and this drill ` +
595
+ `did not — re-anchor it or retire it, but do not let it be skipped`,
596
+ );
597
+ }
598
+ if (count > 1) {
599
+ throw new CouldNotRun(
600
+ `drill \`${drill.rail}\` places ${count} times in ${rel} and a perturbation must be exactly one edit. ` +
601
+ `Lengthen its anchor until it is unique`,
602
+ );
603
+ }
604
+
605
+ const before = digest(Buffer.from(source, "utf8"));
606
+ const next = source.replace(drill.perturb.find, () => drill.perturb.replace);
607
+ fs.writeFileSync(target, next);
608
+ const after = digest(fs.readFileSync(target));
609
+ if (before === after) {
610
+ throw new CouldNotRun(
611
+ `drill \`${drill.rail}\` placed in ${rel} and the bytes did not move — the replacement is what it replaced. ` +
612
+ `A drill that does not fire reports on nothing`,
613
+ );
614
+ }
615
+ return { path: rel, before, after };
616
+ }
617
+
618
+ /** Never consults the host for packs, so the set cannot move with what happens to be installed. */
619
+ export function yieldedRecipes({ workspaceDir, repoRoot, packRoots }) {
620
+ let manifest;
621
+ try {
622
+ manifest = JSON.parse(fs.readFileSync(path.join(workspaceDir, "workspace.json"), "utf8"));
623
+ } catch (cause) {
624
+ throw new CouldNotRun(`the workspace manifest at ${workspaceDir} could not be read — ${cause.message}`);
625
+ }
626
+ let resolve;
627
+ try {
628
+ resolve = resolverFor({ workspaceDir, manifest, repoRoot, named: packRoots, discovery: null, forced: false });
629
+ } catch (cause) {
630
+ throw new CouldNotRun(cause.message);
631
+ }
632
+ const set = recipeSet(manifest, { resolve });
633
+ if (!set.ok) throw new CouldNotRun(set.reason);
634
+ return set.recipes;
635
+ }
636
+
637
+ /** Returns findings; throws `CouldNotRun` for an anchor that does not place exactly once. */
638
+ export function check({ recipes, repoRoot, drills = DRILLS }) {
639
+ const findings = [];
640
+ const recipeIds = new Set(recipes.map((r) => r.id));
641
+ const nonRecipeIds = new Set(NON_RECIPE_RAILS.map((r) => r.id));
642
+ const drilled = new Set();
643
+
644
+ for (const rail of NON_RECIPE_RAILS) {
645
+ if (recipeIds.has(rail.id)) {
646
+ findings.push({
647
+ where: `rail \`${rail.id}\``,
648
+ what:
649
+ "is both a recipe this workspace yields and a declared non-recipe rail. One id cannot be two rails: " +
650
+ "the hook would shadow the recipe, and the recipe would go undrilled behind a green",
651
+ });
652
+ }
653
+ }
654
+
655
+ for (const drill of drills) {
656
+ const where = `drill \`${drill.rail}\``;
657
+ if (!recipeIds.has(drill.rail) && !nonRecipeIds.has(drill.rail)) {
658
+ findings.push({
659
+ where,
660
+ what:
661
+ `names a rail that is neither a recipe the workspace yields nor a declared non-recipe rail. ` +
662
+ `A misspelled recipe id looks exactly like this, and it would leave the real recipe undrilled`,
663
+ });
664
+ continue;
665
+ }
666
+ drilled.add(drill.rail);
667
+
668
+ if (nonRecipeIds.has(drill.rail) && drill.stdin?.session_id !== undefined && drill.stdin.session_id !== DRILL_SESSION_PREFIX) {
669
+ findings.push({
670
+ where,
671
+ what:
672
+ `declares its own \`session_id\` (${JSON.stringify(drill.stdin.session_id)}). A hook drill's id must be ` +
673
+ `\`${DRILL_SESSION_PREFIX}\`, which \`runRail\` completes per run and \`drillOne\` retires — an id outside it ` +
674
+ `is shared between runs and its counter file is never cleaned`,
675
+ });
676
+ }
677
+
678
+ if (typeof drill.tell !== "string" || drill.tell.length === 0) {
679
+ findings.push({ where, what: "declares no `tell`, so a red for the wrong reason would count as a fire" });
680
+ }
681
+ if (typeof drill.why !== "string" || drill.why.length === 0) {
682
+ findings.push({ where, what: "declares no `why`. A drill with no stated claim is a perturbation nobody can review" });
683
+ }
684
+ if (drill.exit !== 0 && drill.exit !== 1) {
685
+ findings.push({
686
+ where,
687
+ what:
688
+ `declares \`exit: ${JSON.stringify(drill.exit)}\`. A fire is 0 (a hook, which answers in its stdout) or ` +
689
+ "1 (a red); 2 is could-not-run everywhere here, and counting it as a fire would read a refusal as a verdict",
690
+ });
691
+ }
692
+ // A null `stdinControl` falls back to `stdin` in `drillOne`, so it does not count as differing input.
693
+ const inputCanDiffer =
694
+ nonRecipeIds.has(drill.rail) &&
695
+ drill.stdinControl !== undefined &&
696
+ drill.stdinControl !== null &&
697
+ JSON.stringify(drill.stdinControl) !== JSON.stringify(drill.stdin);
698
+ if (!drill.perturb && !inputCanDiffer) {
699
+ findings.push({
700
+ where,
701
+ what:
702
+ nonRecipeIds.has(drill.rail)
703
+ ? "has no perturbation and no control input that differs from its fire input, so its control and its fire are the same run"
704
+ : "has no perturbation, and a recipe rail is handed no stdin at all — so its control and its fire are the same run",
705
+ });
706
+ }
707
+
708
+ if (drill.perturb?.file !== undefined) {
709
+ const target = path.join(repoRoot, drill.perturb.file);
710
+ let source;
711
+ try {
712
+ source = fs.readFileSync(target, "utf8");
713
+ } catch (cause) {
714
+ throw new CouldNotRun(
715
+ `${where} names ${drill.perturb.file}, which cannot be read — ${cause.code ?? cause.message}`,
716
+ );
717
+ }
718
+ let count = 0;
719
+ let at = 0;
720
+ for (;;) {
721
+ const i = source.indexOf(drill.perturb.find, at);
722
+ if (i === -1) break;
723
+ count += 1;
724
+ at = i + 1;
725
+ }
726
+ if (count !== 1) {
727
+ throw new CouldNotRun(
728
+ `${where} places ${count} time(s) in ${drill.perturb.file} and a perturbation must be exactly one edit. ` +
729
+ (count === 0
730
+ ? "The subject moved and this drill did not — re-anchor it or retire it, but do not let it be skipped"
731
+ : "Lengthen its anchor until it is unique"),
732
+ );
733
+ }
734
+ if (drill.perturb.find === drill.perturb.replace) {
735
+ findings.push({ where, what: "replaces its anchor with itself, so the perturbation cannot move a byte" });
736
+ }
737
+ }
738
+ if (drill.perturb?.create !== undefined && fs.existsSync(path.join(repoRoot, drill.perturb.create))) {
739
+ findings.push({
740
+ where,
741
+ what: `would create ${drill.perturb.create}, which is already in the tree — a creation that overwrites is an edit wearing the wrong shape`,
742
+ });
743
+ }
744
+ }
745
+
746
+ for (const recipe of recipes) {
747
+ if (!drilled.has(recipe.id)) {
748
+ findings.push({
749
+ where: `rail \`${recipe.id}\``,
750
+ what: "is yielded by this workspace and has no drill. Nothing here has ever watched it fire",
751
+ });
752
+ }
753
+ }
754
+ for (const rail of NON_RECIPE_RAILS) {
755
+ if (!drilled.has(rail.id)) {
756
+ findings.push({ where: `rail \`${rail.id}\``, what: "is a declared non-recipe rail and has no drill" });
757
+ }
758
+ }
759
+ return findings;
760
+ }
761
+
762
+ function git(args, { cwd }) {
763
+ const result = spawnSync("git", args, { cwd, encoding: "utf8" });
764
+ if (result.error) throw new CouldNotRun(`git ${args[0]} could not run — ${result.error.message}`);
765
+ if (result.status !== 0) {
766
+ throw new CouldNotRun(`git ${args.join(" ")} exited ${result.status} — ${(result.stderr || "").trim()}`);
767
+ }
768
+ return result.stdout;
769
+ }
770
+
771
+ export function treeToDrill({ repoRoot, workingCopy }) {
772
+ const head = git(["rev-parse", "HEAD"], { cwd: repoRoot }).trim();
773
+ const dirty = git(["status", "--porcelain"], { cwd: repoRoot }).trim();
774
+
775
+ if (!workingCopy) {
776
+ if (dirty) {
777
+ throw new CouldNotRun(
778
+ `the working tree is not clean, and the sweep drills a COMMIT. Reporting on ${head.slice(0, 7)} while you are ` +
779
+ `looking at uncommitted work would be a green about a different tree. Pass --working-copy to drill the ` +
780
+ `working copy through a synthesized commit, or commit first`,
781
+ );
782
+ }
783
+ return { sha: head, kind: "HEAD" };
784
+ }
785
+
786
+ if (!dirty) return { sha: head, kind: "HEAD (the working tree is clean, so --working-copy has nothing to add)" };
787
+
788
+ const untracked = git(["ls-files", "--others", "--exclude-standard"], { cwd: repoRoot }).trim();
789
+ if (untracked) {
790
+ throw new CouldNotRun(
791
+ "`git stash create` does not carry untracked files, so a synthesized commit would be missing these and the " +
792
+ `drill would report on a tree that is not the one under review — stage them (\`git add\`) or remove them:\n` +
793
+ untracked
794
+ .split("\n")
795
+ .map((f) => ` ${f}`)
796
+ .join("\n"),
797
+ );
798
+ }
799
+ const synthesized = git(["stash", "create"], { cwd: repoRoot }).trim();
800
+ if (!synthesized) {
801
+ throw new CouldNotRun(
802
+ "`git stash create` produced no commit over a tree `git status` calls dirty — refusing to guess which tree to drill",
803
+ );
804
+ }
805
+ return { sha: synthesized, kind: "a commit synthesized from the working copy" };
806
+ }
807
+
808
+ function runRail({ rail, worktree, stdin, workspaceRel }) {
809
+ if (rail.argv) {
810
+ const payload =
811
+ stdin?.session_id === DRILL_SESSION_PREFIX
812
+ ? { ...stdin, session_id: `${DRILL_SESSION_PREFIX}-${path.basename(worktree)}` }
813
+ : stdin;
814
+ const result = spawnSync(process.execPath, rail.argv, {
815
+ cwd: worktree,
816
+ encoding: "utf8",
817
+ // After the spread, so a drill's own `cwd` cannot point the hook at another tree.
818
+ input: `${JSON.stringify({ ...payload, cwd: worktree })}\n`,
819
+ timeout: RAIL_TIMEOUT_MS,
820
+ // Set, never inherited: the hooks read their tree and workspace from these two.
821
+ env: { ...process.env, CLAUDE_PROJECT_DIR: worktree, PORTULAN_WORKSPACE: workspaceRel },
822
+ });
823
+ if (result.error) throw new CouldNotRun(`rail \`${rail.id}\` could not run — ${result.error.message}`);
824
+ return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
825
+ }
826
+ // `bash -c`, as CI and the Stop-gate run a recipe; no drill declaration may reach this command line.
827
+ const result = spawnSync("bash", ["-c", rail.run], {
828
+ cwd: worktree,
829
+ encoding: "utf8",
830
+ timeout: RAIL_TIMEOUT_MS,
831
+ });
832
+ if (result.error) throw new CouldNotRun(`rail \`${rail.id}\` could not run — ${result.error.message}`);
833
+ return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
834
+ }
835
+
836
+ const tellText = (rail, out) => (rail.tellStream === "stdout" ? out.stdout : `${out.stdout}${out.stderr}`);
837
+
838
+ const salient = (text, lines = 12) => {
839
+ const all = text.split("\n").filter((l) => l.trim() !== "");
840
+ const findings = all.filter((l) => /^\s*(FAIL|RED|✗|✖|not ok|UNPINNED)\b/.test(l) || /^RED —/.test(l));
841
+ const chosen = findings.length ? [...findings.slice(0, lines), ...all.slice(-2)] : all.slice(-lines);
842
+ return [...new Set(chosen)].map((l) => ` | ${l}`).join("\n");
843
+ };
844
+
845
+ /** A finding, or null when the rail fired as recorded; throws `CouldNotRun` when no verdict can be formed. */
846
+ export function drillOne({ drill, rail, repoRoot, sha, say, workspaceRel = ".portulan" }) {
847
+ const worktree = fs.mkdtempSync(path.join(os.tmpdir(), "portulan-drill-"));
848
+ // `git worktree add` creates the directory itself; `mkdtemp` only reserved a unique name for it.
849
+ fs.rmSync(worktree, { recursive: true, force: true });
850
+ try {
851
+ git(["worktree", "add", "--detach", worktree, sha], { cwd: repoRoot });
852
+
853
+ const control = runRail({ rail, worktree, stdin: drill.stdinControl ?? drill.stdin, workspaceRel });
854
+ if (control.status !== 0) {
855
+ throw new CouldNotRun(
856
+ `rail \`${drill.rail}\` is not green on the drilled tree, so nothing it does next is attributable to the ` +
857
+ `perturbation. Its control exited ${control.status}:\n${salient(`${control.stdout}${control.stderr}`)}`,
858
+ );
859
+ }
860
+ if (tellText(rail, control).includes(drill.tell)) {
861
+ throw new CouldNotRun(
862
+ `rail \`${drill.rail}\` already says ${JSON.stringify(drill.tell)} before it is perturbed, so that tell ` +
863
+ `cannot show the drill fired. Narrow it to something only the finding says`,
864
+ );
865
+ }
866
+
867
+ const moved = perturb(worktree, drill);
868
+ if (drill.stage) git(["add", "-A"], { cwd: worktree });
869
+
870
+ const fire = runRail({ rail, worktree, stdin: drill.stdin, workspaceRel });
871
+ const text = tellText(rail, fire);
872
+ if (fire.status === null) {
873
+ throw new CouldNotRun(
874
+ `rail \`${drill.rail}\` was killed by a signal rather than exiting, so no verdict was formed` +
875
+ `${moved ? ` after ${moved.path} was perturbed` : ""}. Output:\n${salient(`${fire.stdout}${fire.stderr}`)}`,
876
+ );
877
+ }
878
+ if (fire.status === 2 && drill.exit !== 2) {
879
+ throw new CouldNotRun(
880
+ `rail \`${drill.rail}\` exited 2 — could not run — so it formed no verdict about the perturbation` +
881
+ `${moved ? ` in ${moved.path}` : ""}. Output:\n${salient(`${fire.stdout}${fire.stderr}`)}`,
882
+ );
883
+ }
884
+ if (fire.status !== drill.exit) {
885
+ return {
886
+ where: `rail \`${drill.rail}\``,
887
+ what:
888
+ `did not fire as recorded: the drill expects exit ${drill.exit} and it exited ${fire.status}` +
889
+ `${moved ? ` after ${moved.path} was perturbed` : ""}. Output:\n${salient(`${fire.stdout}${fire.stderr}`)}`,
890
+ };
891
+ }
892
+ if (!text.includes(drill.tell)) {
893
+ return {
894
+ where: `rail \`${drill.rail}\``,
895
+ what:
896
+ `exited ${fire.status} as recorded and never said ${JSON.stringify(drill.tell)}, so it may have fired for ` +
897
+ `another reason entirely. Output:\n${salient(text)}`,
898
+ };
899
+ }
900
+ say(` fired ${drill.rail.padEnd(28)} exit ${fire.status} · said ${JSON.stringify(drill.tell)}`);
901
+ return null;
902
+ } finally {
903
+ // Only this harness's prefix: a wider match would delete a live session's counter and disarm its cap.
904
+ try {
905
+ const dir = os.tmpdir();
906
+ const mine = `portulan-stopgate-${DRILL_SESSION_PREFIX}-${path.basename(worktree)}`;
907
+ for (const entry of fs.readdirSync(dir)) {
908
+ if (entry.startsWith(mine)) fs.rmSync(path.join(dir, entry), { force: true });
909
+ }
910
+ } catch {
911
+ /* A counter file this run cannot retire is litter in a temp directory, never a wrong verdict. */
912
+ }
913
+ try {
914
+ git(["worktree", "remove", "--force", worktree], { cwd: repoRoot });
915
+ } catch {
916
+ fs.rmSync(worktree, { recursive: true, force: true });
917
+ try {
918
+ git(["worktree", "prune"], { cwd: repoRoot });
919
+ } catch {
920
+ /* A worktree this run cannot retire is noise in `git worktree list`, never a wrong verdict. */
921
+ }
922
+ }
923
+ }
924
+ }
925
+
926
+ const USAGE = [
927
+ "usage: node cli/drills.mjs [--check] [--only <rail>] [--working-copy] [--repo-root <dir>] [--workspace <dir>] [--pack-root <dir>]",
928
+ "",
929
+ " Forces every rail red and requires it to fire. Milestone 8, clause (d).",
930
+ "",
931
+ " --check the correspondence pass only: every yielded rail has a drill, every drill names a",
932
+ " declared rail, every anchor still places exactly once. Runs no rail. This is what",
933
+ " the `drills` verify recipe runs, because the sweep reports on a COMMIT and a recipe",
934
+ " must answer about the working copy.",
935
+ " --only <rail> drill one rail. Narrows what RUNS, never what must be well formed.",
936
+ " --working-copy drill the working copy through a commit synthesized with `git stash create`,",
937
+ " instead of refusing a dirty tree. The synthesized sha is printed.",
938
+ "",
939
+ " Exit 0 every drill fired · 1 a rail did not fire, or a rail has no drill · 2 could not run.",
940
+ ].join("\n");
941
+
942
+ export async function run(argv = [], { stdout = process.stdout, stderr = process.stderr, cwd = process.cwd() } = {}) {
943
+ const say = (line) => stdout.write(`${line}\n`);
944
+ try {
945
+ let checkOnly = false;
946
+ let workingCopy = false;
947
+ let only = null;
948
+ let repoRoot = cwd;
949
+ let workspaceDir = null;
950
+ const packRoots = [];
951
+
952
+ for (let i = 0; i < argv.length; i += 1) {
953
+ const value = argv[i + 1];
954
+ const needs = (flag) => {
955
+ if (value === undefined || value.startsWith("-") || value.trim() === "") {
956
+ throw new CouldNotRun(`${flag} needs a value`);
957
+ }
958
+ i += 1;
959
+ return value;
960
+ };
961
+ if (argv[i] === "--check") checkOnly = true;
962
+ else if (argv[i] === "--working-copy") workingCopy = true;
963
+ else if (argv[i] === "--help" || argv[i] === "-h") {
964
+ say(USAGE);
965
+ return 0;
966
+ } else if (argv[i] === "--only") only = needs("--only");
967
+ else if (argv[i] === "--repo-root") repoRoot = path.resolve(needs("--repo-root"));
968
+ else if (argv[i] === "--workspace") workspaceDir = path.resolve(needs("--workspace"));
969
+ else if (argv[i] === "--pack-root") {
970
+ const root = needs("--pack-root");
971
+ let stat = null;
972
+ try {
973
+ stat = fs.statSync(root);
974
+ } catch (cause) {
975
+ throw new CouldNotRun(`--pack-root ${JSON.stringify(root)} cannot be read — ${cause.code ?? cause.message}`);
976
+ }
977
+ if (!stat.isDirectory()) throw new CouldNotRun(`--pack-root ${JSON.stringify(root)} is not a directory`);
978
+ packRoots.push(path.resolve(root));
979
+ } else throw new CouldNotRun(`unknown argument ${JSON.stringify(argv[i])}`);
980
+ }
981
+ workspaceDir ??= path.join(repoRoot, ".portulan");
982
+
983
+ const recipes = yieldedRecipes({ workspaceDir, repoRoot, packRoots });
984
+
985
+ // The whole table in every mode: `--only` narrows what runs, not what must be well formed.
986
+ const findings = check({ recipes, repoRoot });
987
+
988
+ if (checkOnly) {
989
+ say(
990
+ `drills: ${DRILLS.length} drill(s) over ${recipes.length} yielded recipe(s) plus ` +
991
+ `${NON_RECIPE_RAILS.length} declared non-recipe rail(s); anchors checked against the working tree`,
992
+ );
993
+ for (const excluded of NOT_DRILLED) say(` not drilled ${excluded.rail}`);
994
+ if (findings.length) {
995
+ for (const f of findings) stderr.write(`drills: ${f.where}\n ${f.what}\n`);
996
+ stderr.write(`RED — ${findings.length} finding(s) in the drill roster\n`);
997
+ return 1;
998
+ }
999
+ say("GREEN — every yielded rail has a drill and every drill's anchor places exactly once");
1000
+ say("drills: --check runs no rail. Whether each one still FIRES is the sweep's answer, on the calendar");
1001
+ return 0;
1002
+ }
1003
+
1004
+ // Before `treeToDrill`, so a mistyped `--only` on a dirty tree is not reported as the dirty tree.
1005
+ const selected = only === null ? DRILLS : DRILLS.filter((d) => d.rail === only);
1006
+ if (only !== null && selected.length === 0) {
1007
+ throw new CouldNotRun(
1008
+ `--only ${JSON.stringify(only)} names no drill. The declared rails are: ` +
1009
+ `${DRILLS.map((d) => d.rail).join(", ")}`,
1010
+ );
1011
+ }
1012
+
1013
+ // `realpathSync` on both sides: on macOS `/var` is `/private/var`, one directory under two names.
1014
+ const real = (dir) => {
1015
+ try {
1016
+ return fs.realpathSync(dir);
1017
+ } catch {
1018
+ return path.resolve(dir);
1019
+ }
1020
+ };
1021
+ const repoReal = real(repoRoot);
1022
+ for (const root of packRoots) {
1023
+ if (!isInside(repoReal, real(root))) {
1024
+ throw new CouldNotRun(
1025
+ `--pack-root ${root} is outside ${repoRoot}, and the sweep runs each rail from a throwaway worktree. ` +
1026
+ "A composed recipe's `${PACK_ROOT}` is relativised against the repository, so from a worktree it " +
1027
+ "would point at neither copy. Pass a root inside the repository, or use --check, which runs no rail",
1028
+ );
1029
+ }
1030
+ }
1031
+ if (!isInside(repoReal, real(workspaceDir))) {
1032
+ throw new CouldNotRun(
1033
+ `--workspace ${workspaceDir} is outside ${repoRoot}, and the hook rails are handed their workspace as a ` +
1034
+ "path inside the tree being drilled. Pass a workspace inside the repository, or use --check",
1035
+ );
1036
+ }
1037
+ // `.`, not "": the hooks' `PORTULAN_WORKSPACE || ".portulan"` reads an empty value as unset.
1038
+ const workspaceRel = path.relative(repoRoot, workspaceDir) || ".";
1039
+
1040
+ if (findings.length) {
1041
+ for (const f of findings) stderr.write(`drills: ${f.where}\n ${f.what}\n`);
1042
+ stderr.write(`RED — ${findings.length} finding(s) in the drill roster; no rail was drilled\n`);
1043
+ return 1;
1044
+ }
1045
+
1046
+ const tree = treeToDrill({ repoRoot, workingCopy });
1047
+ const byId = new Map([
1048
+ ...recipes.map((r) => [r.id, { id: r.id, run: r.run }]),
1049
+ ...NON_RECIPE_RAILS.map((r) => [r.id, r]),
1050
+ ]);
1051
+
1052
+ say(`drills: forcing every rail red on ${tree.sha.slice(0, 7)} — ${tree.kind}`);
1053
+ say(`drills: ${selected.length} of ${DRILLS.length} drill(s), one throwaway git worktree each`);
1054
+ for (const excluded of NOT_DRILLED) say(` not drilled ${excluded.rail}`);
1055
+
1056
+ const unjudged = [];
1057
+ for (const drill of selected) {
1058
+ const rail = byId.get(drill.rail);
1059
+ if (!rail) throw new CouldNotRun(`rail \`${drill.rail}\` is not in the yielded set nor declared`);
1060
+ try {
1061
+ const finding = drillOne({ drill, rail, repoRoot, sha: tree.sha, say, workspaceRel });
1062
+ if (finding) findings.push(finding);
1063
+ } catch (error) {
1064
+ if (!(error instanceof CouldNotRun)) throw error;
1065
+ unjudged.push({ where: `rail \`${drill.rail}\``, what: error.message });
1066
+ say(` UNJUDGED ${drill.rail}`);
1067
+ }
1068
+ }
1069
+
1070
+ for (const f of [...findings, ...unjudged]) stderr.write(`drills: ${f.where}\n ${f.what}\n`);
1071
+ if (unjudged.length) {
1072
+ stderr.write(
1073
+ `COULD NOT RUN — ${unjudged.length} rail(s) could not be judged` +
1074
+ `${findings.length ? ` and ${findings.length} did not fire` : ""}. A set that was not fully judged has not been judged\n`,
1075
+ );
1076
+ return 2;
1077
+ }
1078
+ if (findings.length) {
1079
+ stderr.write(`RED — ${findings.length} rail(s) did not fire as recorded\n`);
1080
+ return 1;
1081
+ }
1082
+ say(`GREEN — every drilled rail was forced red and fired, on ${tree.sha.slice(0, 7)}`);
1083
+ say("drills: a rail fires here on ONE known-bad input. That it fires is not that it catches everything");
1084
+ return 0;
1085
+ } catch (error) {
1086
+ if (error instanceof CouldNotRun) {
1087
+ stderr.write(`drills: ${error.message}\n`);
1088
+ return 2;
1089
+ }
1090
+ stderr.write(`drills: could not finish the sweep — ${error?.stack ?? error}\n`);
1091
+ return 2;
1092
+ }
1093
+ }
1094
+
1095
+ // Not `file://${argv[1]}`: `import.meta.url` percent-encodes, so a path with a space would never match.
1096
+ function isMain() {
1097
+ const invoked = process.argv[1];
1098
+ if (!invoked) return false;
1099
+ if (import.meta.url === pathToFileURL(invoked).href) return true;
1100
+ try {
1101
+ return import.meta.url === pathToFileURL(fs.realpathSync(invoked)).href;
1102
+ } catch {
1103
+ return false;
1104
+ }
1105
+ }
1106
+
1107
+ if (isMain()) process.exitCode = await run(process.argv.slice(2));