@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/stop-gate.mjs CHANGED
@@ -1,36 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // The Stop-gate runner — "done" is not a thing an agent may simply declare.
3
3
  //
4
- // Wired by `./compile.mjs` into `.claude/settings.json` as a `Stop` hook. When an agent
5
- // tries to end its turn, this runs the workspace's default verify recipe and blocks the ending if
6
- // the recipe is not green. `../core/operating/verification.md` has specified this contract since
7
- // milestone 1 and named milestone 4 as where the runner arrives; this is that runner.
8
- //
9
- // It also enforces the session-end handoff, which `../core/operating/loop.md` and
10
- // `../memory/every-session-ends-with-a-handoff.md` both promised to this milestone.
11
- //
12
- // ## Two honest limits, stated before the code rather than after
13
- //
14
- // **1. The host's Stop event is not the doctrine's "end of task".** It fires when the agent finishes
15
- // *any* response. So a gate that blocked forever would make a red working copy undriveable —
16
- // including by the session opened to fix the red, which is the failure mode that gets a rail
17
- // switched off rather than fixed. The cap below is the answer, and it is a real weakening: an agent
18
- // that is blocked enough times ends anyway, with the red printed loudly each time. CI still refuses
19
- // the merge, and the platform floor still refuses the push. This gate makes the red *unmissable*;
20
- // the floor is what makes it *binding*.
21
- //
22
- // **The cap counts BLOCKS, not stops, and that distinction is the whole gate.** The first version
23
- // counted every Stop event, which meant an ordinary session spent its budget on turns that were
24
- // perfectly green and then let a genuine red through with a note — a fail-open in the gate written
25
- // to close one, found at the pre-commit checkpoint by a supervisor who spent four green stops and
26
- // then planted a red. A green stop is free. Only a stop this gate actually refuses is charged.
27
- //
28
- // **2. A crashed hook fails open.** Measured, CLI 2.1.220. So every failure path here returns a
29
- // verdict rather than throwing, and the runner has its own timeout — the host's own hook timeout is
30
- // far longer (600s), so a hanging recipe would sit there un-gated until then; this returns first.
31
- // `could not run` blocks exactly as `red` does: the recipes distinguish exit 1 from exit 2 precisely
32
- // so that "nothing looked" is never read as "nothing wrong"
33
- // (../memory/verify-preconditions-fail-closed.md).
4
+ // A crashed hook fails open (Claude Code 2.1.220), so every failure path here returns a verdict instead of throwing.
34
5
 
35
6
  import fs from "node:fs";
36
7
  import os from "node:os";
@@ -38,63 +9,16 @@ import path from "node:path";
38
9
  import { execFileSync } from "node:child_process";
39
10
  import { fileURLToPath, pathToFileURL } from "node:url";
40
11
 
12
+ import { isInside } from "./inside.mjs";
41
13
  import { recipeSet } from "./recipe-set.mjs";
42
14
 
43
- // The project root is TOLD to this runner rather than derived from where this file sits — see the same
44
- // paragraph in ./gate.mjs for why. In short: this file used to live inside the workspace it was reading,
45
- // which is the one layout it could ever work in, and is why it shipped in no artifact an adopter received.
46
- //
47
- // **These stay the TOLD values, and since #220's second arm they are no longer the whole answer.** The
48
- // tree a stop is ABOUT is resolved per invocation by `resolveSessionTree` from the payload's `cwd`,
49
- // because the told root is the repository this hook governs and not necessarily the working tree the
50
- // session used. Three things deliberately keep reading the told values whatever the session's cwd is:
51
- // the refusal COUNTER (a resolution that flips mid-session would rename its file and reset spent
52
- // counts — a cap weakening nobody chose), and both halves of the recipe — `defaultRecipe()` and the
53
- // `cwd` its command runs in. That last one is not a nicety: the gate executes the manifest's `run`
54
- // through `bash -c`, so a recipe read from a workspace found under a payload-supplied path would be
55
- // arbitrary command execution seeded by stdin. The recipe's jurisdiction is the workspace this hook
56
- // was wired into.
57
15
  const PROJECT = process.env.CLAUDE_PROJECT_DIR || process.cwd();
58
16
  const WORKSPACE = path.resolve(PROJECT, process.env.PORTULAN_WORKSPACE || ".portulan");
59
17
  const REPO = PROJECT;
60
18
 
61
19
  /**
62
- * WHICH TREE this stop is about — the session's own, or the one the hook was told.
63
- *
64
- * The told root is what the hook is wired with (`${CLAUDE_PROJECT_DIR}` in `.claude/settings.json`).
65
- * It is not always the tree the session worked in: a session running in a git worktree, or simply
66
- * `cd`-ed elsewhere, has both, and #220's second arm is that the gate answered about the told one.
67
- * **The failure that arm exists for is a SILENT ALLOW** — told tree clean, session tree carrying
68
- * unrecorded work — because `didWork()` reads the clean tree, returns false, and nothing is printed.
69
- * The first arm's naming sentences cannot reach it: they live inside the block path.
70
- *
71
- * `payload.cwd` is the datum. Measured on this host, a real Stop payload carries
72
- * `session_id, transcript_path, cwd, prompt_id, permission_mode, effort, hook_event_name,
73
- * stop_hook_active, last_assistant_message, background_tasks, session_crons` — recorded because the
74
- * record held no measurement of this payload's shape at all, and the whole arm rests on one field.
75
- *
76
- * **THE SAME-REPOSITORY GUARD, which is the difference between a fix and a bypass.** `cwd` is the one
77
- * input the gated agent can steer, so a naive rescope would let a session point the gate at a clean
78
- * unrelated clone and be allowed in silence — the very defect, one remove away. A foreign tree's
79
- * obligations are also unsatisfiable, which is the shape that gets a rail switched off rather than
80
- * fixed. So the session tree answers only when it is the SAME repository, compared by
81
- * `--git-common-dir` and through `realpathSync`: worktrees share their common dir with the origin
82
- * repository, which is exactly the class this arm exists for, while a foreign clone does not.
83
- * Compared resolved, because macOS symlinks `/tmp` to `/private/tmp` and raw strings would disagree
84
- * about identical directories.
85
- *
86
- * **What the same-repository condition does NOT close, said plainly because the sentences around it
87
- * could be read as claiming otherwise.** It stops a foreign tree answering; it does not stop steering
88
- * *within* this repository. A session whose told root is dirty and unrecorded can name a clean,
89
- * recorded sibling worktree in `cwd` and be allowed — measured. That is narrower than the hole this
90
- * arm closes and is not closable from here: the gate is told where the session ENDED, never where it
91
- * worked, and a `cwd` inside this repository is exactly the datum this arm exists to trust.
92
- * **Pinned** — as a recorded gap, not a wanted property — by *THE KNOWN HOLE (#307)* in
93
- * `./stop-gate.test.mjs`, whose comment carries the contract for closing it.
94
- *
95
- * Returns `{ root, workspace, origin, note }`. `note` is a sentence to print when the datum was
96
- * offered and could not be used; it is **null when `cwd` is simply absent**, because a host that
97
- * never offered the field has degraded nothing and deserves today's behaviour byte for byte.
20
+ * The payload's `cwd` is trusted only within this repository, since the gated agent can steer it. A limit: it says
21
+ * where a session ended, not where it worked, so a clean sibling worktree named there is allowed.
98
22
  */
99
23
  function resolveSessionTree(cwd, told = { root: REPO, workspace: WORKSPACE }) {
100
24
  const fell = (note) => ({ ...told, origin: "told", note });
@@ -108,19 +32,11 @@ function resolveSessionTree(cwd, told = { root: REPO, workspace: WORKSPACE }) {
108
32
  try {
109
33
  root = git(cwd, ["rev-parse", "--show-toplevel"]);
110
34
  } catch {
111
- // Gone, or not a repository. Both exit 128; neither is a tree this gate can ask about.
112
35
  return fell(`the session's directory (${cwd}) is not inside a git repository`);
113
36
  }
114
37
  if (!root) return fell(`the session's directory (${cwd}) yielded no repository root`);
115
38
 
116
- // Same repository? Worktrees share a common dir with their origin; a foreign clone does not.
117
- // **`--path-format` needs git 2.31, so it is TRIED, not required.** Where it is missing the flag
118
- // is fatal, and without a fallback this comparison would throw on every stop — sending a correct
119
- // worktree down the degraded path with a stderr sentence, on an old git, forever. The arm would
120
- // read as shipped and do nothing, which is the shape this repository has been bitten by before.
121
- // The bare spelling answers too, but RELATIVELY at a repository root (measured on git 2.50.1:
122
- // `.git` there, absolute from a subdirectory or a linked worktree), so it is resolved against the
123
- // directory that was asked. Copilot, round 2.
39
+ // `--path-format` needs git 2.31; the bare form prints `.git`, relative, at a repository root (git 2.50.1).
124
40
  const commonDir = (dir) => {
125
41
  let raw;
126
42
  try {
@@ -154,108 +70,37 @@ function resolveSessionTree(cwd, told = { root: REPO, workspace: WORKSPACE }) {
154
70
  } catch { /* the toplevel git just printed; keep it */ }
155
71
  return {
156
72
  root: resolved,
157
- // Recomputed against the session tree, never reused from the told root, or the handoff
158
- // question is asked correctly in the wrong directory. `path.resolve` leaves an ABSOLUTE
159
- // `PORTULAN_WORKSPACE` absolute, so a workspace named absolutely deliberately does not travel.
160
73
  workspace: path.resolve(resolved, process.env.PORTULAN_WORKSPACE || ".portulan"),
161
74
  origin: "session",
162
75
  note: null,
163
76
  };
164
77
  }
165
78
 
166
- // The independent reasons this gate refuses a stop for. Each keeps its own consecutive count.
167
- //
168
- // Named as data rather than left implicit in the code, because the counters, the reset rule and the
169
- // release message all have to agree about what a reason *is* — and three implicit lists is how the
170
- // single-counter version came to reset one reason on another reason's evidence.
171
79
  export const REASONS = ["recipe", "handoff"];
172
80
 
173
- // How many CONSECUTIVE refusals **for one reason** a session may accumulate before the gate lets it
174
- // end. Bounded iteration is already doctrine — ../core/operating/loop.md carries the
175
- // runbook/iteration-cap row — and a Stop-gate that cannot stop is not a gate, it is a hang.
176
- //
177
- // **Consecutive, and a reason's count clears only when THAT reason clears** (maintainer's ruling,
178
- // 2026-07-27, generalised in ../tasks/0007-per-reason-stop-gate-counters.md). The cap is aimed at the
179
- // futile-retry episode — the same failure the Ralph Wiggum row names — not at long honest sessions. A
180
- // session that hits three unrelated reds and properly fixes each one should not be taxed for having
181
- // done the work; accumulating across a whole session would be ceremony that cannot scale down, which
182
- // is a binding non-goal.
183
- //
184
- // Session 0 implemented the reset literally, keyed off the recipe alone, and that made the gate
185
- // unbounded: a green recipe beside a missing handoff cleared the count on every attempt. It also left
186
- // an asymmetry nobody chose — a missing five-line handoff rode to nine while a failing suite got
187
- // three. Per-reason counters remove both by construction.
81
+ // Stop fires after every response, so a red session is released at a bound rather than held forever. A reason's
82
+ // count is consecutive and clears only with that reason; the total never resets.
188
83
  export const MAX_BLOCKS = 3;
189
-
190
- // An absolute ceiling on refusals per session, which does NOT reset. It was introduced to close the
191
- // hang above and it survives that hang's removal, as the backstop that guarantees the gate can always
192
- // stop whatever the per-reason counts do — including for a reason added later that nobody remembered
193
- // to give a clearing condition. Counts REFUSALS, not reasons: a stop refused for two reasons is one
194
- // refusal, or a two-reason session would reach the ceiling in half the attempts.
195
84
  export const MAX_TOTAL_BLOCKS = 9;
196
85
 
197
- // **Both caps above are keyed to `session_id`, and a host that rotates it per retry defeats them.**
198
- //
199
- // Measured 2026-09-02 on Claude Code 2.1.251, inside an A/B arm: the gate refused a stop, the agent
200
- // retried, and the retry arrived under a NEW session id — so `counterFile()` opened a fresh counter,
201
- // every consecutive count restarted at zero, and neither cap was ever reached. **85,839 hook
202
- // invocations and 6,985 distinct counter files from one probe**, none of them ever a fourth consecutive
203
- // refusal. This module's own comment names the result exactly: *a Stop-gate that cannot stop is not a
204
- // gate, it is a hang.*
205
- //
206
- // The platform already answers this and the answer was documented here and never read. `stop_hook_active`
207
- // is true precisely when the stop being judged was itself provoked by a hook block — so it identifies
208
- // the retry CHAIN without reference to any session id. This counter follows it: cleared on a stop that
209
- // no hook provoked, incremented on one that a hook did, and keyed to the TREE alone, which is the thing
210
- // that cannot rotate underneath it.
211
- //
212
- // **It is a backstop, not a replacement.** The per-reason and per-session caps keep their meaning where
213
- // the id is stable, which is every ordinary session; this only bounds the case where they cannot see
214
- // their own history. It is set above `MAX_TOTAL_BLOCKS` deliberately: a session whose id is stable must
215
- // always meet the specific caps first, so their messages, not this one, are what a person normally
216
- // reads.
86
+ // Counted per tree from `stop_hook_active`, since in an A/B arm Claude Code 2.1.251 gave each retry a new session id,
87
+ // restarting both caps. Set above MAX_TOTAL_BLOCKS, so a session whose id holds meets the other bounds first.
217
88
  export const MAX_CHAIN_BLOCKS = 12;
218
89
 
90
+ // Well inside the host's 600 s hook timeout, so a hung recipe meets a verdict here first.
219
91
  const RECIPE_TIMEOUT_MS = 90_000;
220
92
 
221
- /**
222
- * Where one session's counter lives. Keyed by session id **and** by the working tree, because
223
- * `worktree-local` is a requirement rather than an accident: several worktrees of this repository are
224
- * routinely checked out at once, and two sessions sharing a counter would let one disarm the other's
225
- * gate. Untracked, in the OS temp dir — this is session state, not repository state.
226
- */
227
93
  function counterFile(sessionId, dir, root = REPO) {
228
- // A short, stable digest. Not security — just enough that two distinct inputs differ.
229
94
  const digest = (v) => {
230
95
  let h = 0;
231
96
  for (const ch of String(v)) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
232
97
  return h.toString(36);
233
98
  };
234
- // The readable part is for a human debugging a stuck counter; the digest is what makes the name
235
- // unique. Both are needed. Sanitising ALONE is not enough, and the failure is silent: a session id
236
- // made entirely of characters outside the class sanitises to the empty string, so every such
237
- // session shares one counter and they charge each other's cap — or release each other early. The
238
- // same holds for two ids sharing a 60-character prefix. Found by review on the pull request.
99
+ // Readable for a person; unique by the digest, since distinct ids can sanitise alike.
239
100
  const readable = String(sessionId).replace(/[^a-zA-Z0-9-]/g, "").slice(0, 40);
240
101
  return path.join(dir, `portulan-stopgate-${readable}-${digest(sessionId)}-${digest(root)}`);
241
102
  }
242
103
 
243
- /**
244
- * Where the retry CHAIN's counter lives — keyed by tree, never by session.
245
- *
246
- * `counterFile()` is keyed by session id and is the right key for the per-reason caps. This one exists
247
- * for the case that key cannot survive: a host issuing a new session id per retry. So it is deliberately
248
- * the one piece of gate state two sessions in a worktree share — the sharing the per-session counters
249
- * exist to prevent is exactly what makes this able to see a chain the counters cannot.
250
- *
251
- * **"The tree" means the tree being JUDGED, and the first cut keyed it to `REPO` instead.** `bumpChain`
252
- * called this without a root, so every session shared one file whatever `resolveSessionTree(payload.cwd)`
253
- * had resolved — and this gate answers about another worktree often enough that line ~775 has a branch
254
- * for it. Two arms, or an arm and this repository, contended on one chain: one tree's retries could
255
- * spend another tree's budget, or release it early. The docblock above said *keyed to the tree* while
256
- * the code keyed to a constant. Copilot round 2 on
257
- * [#406](https://github.com/sleepy-panda-srl/portulan/pull/406).
258
- */
259
104
  function chainFile(dir, root = REPO) {
260
105
  const digest = (v) => {
261
106
  let h = 0;
@@ -265,22 +110,14 @@ function chainFile(dir, root = REPO) {
265
110
  return path.join(dir, `portulan-stopgate-chain-${digest(root)}`);
266
111
  }
267
112
 
268
- /**
269
- * How many consecutive hook-provoked stops this tree has seen, and the bump that maintains it.
270
- *
271
- * `provoked` is the payload's `stop_hook_active`: false means this stop was not caused by a block, so
272
- * whatever chain existed has ended. Reading a missing or unreadable file as zero is the stricter
273
- * direction for the per-session counters and the LOOSER one here, so it is stated rather than inherited:
274
- * a chain this gate cannot count is a chain it does not charge, and the specific caps still apply.
275
- */
113
+ /** Hook-provoked stops in a row for this tree, this one included, or 0 unless `provoked`; an unwritable count stays put. */
276
114
  export function bumpChain(provoked, dir = os.tmpdir(), root = REPO) {
277
115
  const file = chainFile(dir, root);
278
116
  if (!provoked) {
279
117
  try {
280
118
  fs.rmSync(file, { force: true });
281
119
  } catch {
282
- // A chain counter that cannot be cleared costs a later session part of its budget, which is
283
- // the safe direction: it can only make this backstop fire sooner, never later.
120
+ // A run left in place can only make the chain bound fire sooner.
284
121
  }
285
122
  return 0;
286
123
  }
@@ -290,16 +127,6 @@ export function bumpChain(provoked, dir = os.tmpdir(), root = REPO) {
290
127
  } catch {
291
128
  previous = 0;
292
129
  }
293
- // **The count advances only if the write does, and the first cut returned the increment either
294
- // way.** That made the returned value diverge from what the next invocation would read: on an
295
- // unwritable temp dir the chain appeared to climb within one call and reset on every following one,
296
- // so the bound could fire on a session that had never accumulated anything, or never fire at all —
297
- // and the comment below asserted the opposite of what the code did, which is the class this whole
298
- // change is about. Copilot round 1 on [#406](https://github.com/sleepy-panda-srl/portulan/pull/406).
299
- //
300
- // Unwritable state therefore means the chain **cannot grow**: this backstop simply does not fire and
301
- // the specific caps are unaffected. Silent because it is a fact about the filesystem, not about the
302
- // session's work.
303
130
  try {
304
131
  fs.writeFileSync(file, JSON.stringify({ chain: previous + 1 }));
305
132
  } catch {
@@ -308,50 +135,26 @@ export function bumpChain(provoked, dir = os.tmpdir(), root = REPO) {
308
135
  return previous + 1;
309
136
  }
310
137
 
311
- /** The stored shape: one consecutive count per reason, plus the non-resetting refusal total. */
312
138
  function readCount(file) {
313
139
  let stored = {};
314
140
  try {
315
141
  stored = JSON.parse(fs.readFileSync(file, "utf8")) ?? {};
316
142
  } catch {
317
- // No counter yet, or an unreadable one. Either way this session has spent nothing — the
318
- // stricter direction, since a session that cannot read its budget has not used it.
143
+ // None yet, or unreadable: read as nothing spent.
319
144
  }
320
- // Every stored key is carried forward, THEN the known reasons are defaulted to 0. The order and
321
- // the carry-forward both matter. Filtering by `REASONS` alone dropped any other key, so a reason
322
- // this file can emit but the constant does not declare would have reset to 0 on every read —
323
- // never reaching its own cap, and released only by the ceiling of nine. That is precisely the
324
- // asymmetry task 0007 removed, reintroduced through a drifted list, and silent in the direction
325
- // of more patience. The constant exists so the counter, the clearing rule and the message agree
326
- // about what a reason is; it must not be the thing that also decides what may be *counted*.
327
- // Found by review, in the suppressed half of a Copilot round. The suite additionally binds every
328
- // reason this file emits to `REASONS`, so drift is red in CI rather than only survivable here.
145
+ // Every stored reason is kept, not only REASONS: one missing there would reset on each read and never reach its cap.
329
146
  const counts = {};
330
147
  for (const [reason, value] of Object.entries(stored.counts ?? {})) counts[reason] = Number(value) || 0;
331
148
  for (const reason of REASONS) counts[reason] ??= 0;
332
149
  return { counts, total: Number(stored.total) || 0 };
333
150
  }
334
151
 
335
- /**
336
- * Count one refusal against a session. `reasons` is every reason THIS stop was refused for.
337
- * Returns `{ counts, total }`.
338
- *
339
- * Exported for the suite; `dir` and `root` are injectable for the same reason.
340
- *
341
- * When the counter cannot be kept at all, this returns numbers ABOVE every cap — for every reason,
342
- * not only the ones passed in — so the caller lets the session end. That direction is deliberate: an
343
- * un-capped gate is the one failure here a human cannot escape from inside the session. (The first
344
- * version returned exactly `MAX_BLOCKS`, which is not above it — so an unwritable temp dir made a red
345
- * tree block *forever*, the precise opposite of what its own comment claimed. Found at the pre-commit
346
- * checkpoint, demonstrated against a read-only TMPDIR. Filling only the named reasons would have
347
- * reintroduced that trap one reason over.)
348
- */
152
+ /** Charges one refusal to `reasons`; where it cannot be written, every count comes back past its cap, releasing the session. */
349
153
  export function bumpCount(sessionId, reasons, dir = os.tmpdir(), root = REPO) {
350
154
  const file = counterFile(sessionId, dir, root);
351
155
  const now = readCount(file);
352
156
  const counts = { ...now.counts };
353
157
  for (const reason of reasons) counts[reason] = (counts[reason] ?? 0) + 1;
354
- // One refusal, however many reasons named it — see MAX_TOTAL_BLOCKS.
355
158
  const next = { counts, total: now.total + 1 };
356
159
  try {
357
160
  fs.writeFileSync(file, JSON.stringify(next));
@@ -363,16 +166,6 @@ export function bumpCount(sessionId, reasons, dir = os.tmpdir(), root = REPO) {
363
166
  return next;
364
167
  }
365
168
 
366
- /**
367
- * One reason's condition cleared: its consecutive count goes to zero, and **only** its own.
368
- *
369
- * This is the whole of task 0007. The previous version cleared *the* count on an observed green
370
- * recipe while the gate refused for two reasons — so a missing handoff's patience was restored by
371
- * evidence about the recipe, which made the gate unbounded in that case and gave a five-line file
372
- * three times the tolerance of a failing suite.
373
- *
374
- * The running total survives on purpose: it is what guarantees the gate can still stop.
375
- */
376
169
  export function clearReason(sessionId, reason, dir = os.tmpdir(), root = REPO) {
377
170
  const file = counterFile(sessionId, dir, root);
378
171
  const now = readCount(file);
@@ -381,18 +174,13 @@ export function clearReason(sessionId, reason, dir = os.tmpdir(), root = REPO) {
381
174
  try {
382
175
  fs.writeFileSync(file, JSON.stringify(next));
383
176
  } catch {
384
- // Failing to clear a counter can only make the gate stricter, never weaker. Leave it.
385
177
  return now;
386
178
  }
387
179
  return next;
388
180
  }
389
181
 
390
- /** Today, in the machine's LOCAL timezone — the same clock that names a handoff file. */
182
+ /** Today in local time, as a handoff file is named; `toISOString()` is UTC and disagrees near midnight. */
391
183
  export function today(now = new Date()) {
392
- // `toISOString()` is UTC, and handoffs are named by local date. Between midnight and 03:00 in
393
- // the maintainer's timezone the two disagree, and the gate would demand yesterday's date from a
394
- // session that had just written today's — a false red, which is the failure that gets a rail
395
- // switched off rather than fixed. Found at the pre-commit checkpoint.
396
184
  const pad = (n) => String(n).padStart(2, "0");
397
185
  return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
398
186
  }
@@ -406,24 +194,10 @@ function block(reason) {
406
194
  process.exit(0);
407
195
  }
408
196
 
409
- /**
410
- * The workspace's default verify recipe: `{ id, run }`, or null if the manifest cannot say.
411
- *
412
- * Reaches `recipeSet()` rather than enumerating `verify.recipes` here, because that enumeration is one
413
- * rule and this file was one of four places carrying it — see `recipe-set.mjs`'s header for why they
414
- * were merged and `recipe-set.test.mjs` for the roster pin that keeps them merged.
415
- *
416
- * **It asks for the workspace subset deliberately, by passing no packs**, and that is not composition
417
- * quietly skipped. `verify.default` is a bare slug in `spec/workspace.schema.json` and a composed id is
418
- * `<category>/<name>:<id>`, so **no composed recipe can ever be the default** — resolving packs here
419
- * would cost a pack resolution on every Stop event and could not change the answer. What this call
420
- * still buys is the one definition of a *valid* recipe: a manifest whose default recipe carries an
421
- * empty or newline-bearing `run` is refused here exactly as CI refuses it, rather than being handed to
422
- * a shell by a gate that did its own looser check.
423
- */
424
197
  function defaultRecipe() {
425
198
  try {
426
199
  const manifest = JSON.parse(fs.readFileSync(path.join(WORKSPACE, "workspace.json"), "utf8"));
200
+ // No pack: `verify.default` is a bare slug, which no composed id is.
427
201
  const set = recipeSet(manifest, { packs: [] });
428
202
  if (!set.ok) return null;
429
203
  const recipe = set.recipes.find((r) => r.id === set.default);
@@ -433,95 +207,32 @@ function defaultRecipe() {
433
207
  }
434
208
  }
435
209
 
436
- /**
437
- * Has this session done work? A read-only session owes no handoff.
438
- *
439
- * Three signals, tried in order, because the obvious one is not portable. The first version hard-coded
440
- * `origin/main..HEAD`, which throws in a repository with no `origin`, a differently-named default
441
- * branch, or an unfetched ref — and the catch turned that throw into `false`, **silently disabling the
442
- * handoff gate while local commits sat there unrecorded.** A fail-open in the gate, and a quiet one.
443
- * Found by review on the pull request.
444
- */
445
210
  function didWork(root = REPO) {
446
- // `maxBuffer` raised from node's 1 MiB default **for `git cherry`, which cannot be bounded the way
447
- // the emptiness probe below can**: it must print a line per compared commit to be read at all,
448
- // where the probe only has to answer yes-or-no and so is asked with `--max-count=1`. An overflow
449
- // here throws into the outer catch and reads as *cannot tell* on precisely the branches that carry
450
- // the most commits. This raise moves that cliff rather than removing it — every buffer is finite —
451
- // which is why the probe is bounded instead of merely given more room.
452
211
  const git = (args) => execFileSync("git", args, { cwd: root, encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024 * 1024 });
453
212
 
454
- // 1. Uncommitted changes. Portable, and the strongest signal there is.
455
213
  try {
456
214
  if (git(["status", "--porcelain"]).trim() !== "") return true;
457
215
  } catch {
458
- // Not a git repository, or git is unusable. Nothing below will work either.
459
216
  process.stderr.write("portulan stop-gate: cannot read git status — the handoff check is not running.\n");
460
217
  return false;
461
218
  }
462
219
 
463
- // 2. Commits ahead of this branch's configured upstream, whatever it is called.
464
220
  try {
465
221
  const upstream = git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]).trim();
466
222
  if (upstream) return git(["log", "--oneline", `${upstream}..HEAD`]).trim() !== "";
467
223
  } catch {
468
- // No upstream configured. Ordinary for a branch that has never been pushed — fall through.
224
+ // No upstream, as on a branch never pushed.
469
225
  }
470
226
 
471
- // 3. Commits whose PATCHES are on no remote — compared by patch-id, never by reachability.
472
- //
473
- // **This repository rebase-merges, and that makes reachability the wrong question** (#220). A
474
- // rebase-merge rewrites commits, so a merged branch's originals appear on no remote; once the
475
- // remote branch is deleted on merge they never will. `HEAD --not --remotes` therefore answers
476
- // *yes, there is unrecorded work* **permanently**, for every commit of every merged-and-deleted
477
- // branch — and any checkout left on such a branch demanded a handoff from a session that had
478
- // done nothing. Measured 2026-08-10: the gate blocked twice over a handoff that was already on
479
- // `main`, on the strength of four commits `git cherry` showed as fully upstream.
480
- //
481
- // The class is named in this repository for a different tool —
482
- // `../.portulan/memory/a-branch-syncs-with-main-before-it-merges.md`, *"a rebase-merge leaves the
483
- // branch tip an ancestor of nothing"* — and the sanctioned test there is `git cherry`:
484
- // comparison by patch-id. This is that record applied at the site that asked the same wrong
485
- // question.
227
+ // By patch-id, not reachability: a rebase-merge rewrites commits, so a merged branch's originals are on no remote.
486
228
  try {
487
- // The coarse reading first, because it is cheap and it BOUNDS the expensive one: nothing on
488
- // no remote by reachability means nothing on no remote by patch either, since a rewritten
489
- // commit is still a commit that reachability would have found.
490
- //
491
- // **`rev-list --max-count=1`, not `log --oneline`, and the difference is a fail-open.** This
492
- // asks only whether the set is EMPTY, but `log --oneline` answers by printing one line per
493
- // commit — and a long-lived checkout on a rebase-merged branch is exactly where that set gets
494
- // large, which is the situation this whole signal exists for. Past whatever `maxBuffer` is in
495
- // force, `execFileSync` throws `ENOBUFS` (measured against node's 1 MiB default, which is what
496
- // this ran on when the defect was found), the outer catch below turns that into `return false`,
497
- // and the handoff check disables itself on the repositories most likely to need it.
498
- //
499
- // **The raised `maxBuffer` above does not make this redundant, and the order matters.** That
500
- // raise moves the cliff; it does not remove one, because every buffer is finite and the set
501
- // this probe asks about has no bound. Bounding the QUESTION is what removes the failure: one
502
- // hash cannot outgrow any buffer. The raise is defence in depth for `git cherry`, which must
503
- // print a line per compared commit and so cannot be bounded the same way. Copilot, round 3;
504
- // the wording corrected in the same round after the raise made the original rationale stale.
229
+ // Nothing off the remotes means nothing to compare; `--max-count=1` asks only that, so no set overflows the buffer.
505
230
  if (git(["rev-list", "--max-count=1", "HEAD", "--not", "--remotes"]).trim() === "") return false;
506
231
 
507
- // No remote at all: every commit qualifies, which is the correct reading there and is what
508
- // this signal has always said. Documented rather than degraded, so it reports nothing.
509
232
  const remotes = git(["remote"]).trim();
510
233
  if (remotes === "") return true;
511
234
 
512
- // A base to compare patches against: the remote's own recorded default head, never a branch
513
- // picked by name. `./init.mjs`'s `delete-a-remote-branch` reason settles that — *"a wrong base
514
- // answers no-unique-work about a question nobody asked"* — and `main` is not a name this file
515
- // may assume, for the same reason signal 2 above stopped hard-coding `origin/main`.
516
- // **ONE ordering, used by both the base selection and the sentence that reports its failure.**
517
- // `git remote` lists alphabetically, so a repository carrying `backup` beside `origin` offered
518
- // `backup/HEAD` first — and this loop takes the first that resolves, which means comparing
519
- // patch-ids against a non-canonical remote and reaching a wrong allow/block, not merely a
520
- // clumsy message. `origin` is the conventional canonical remote and is preferred where it
521
- // exists. Written as one list rather than two matching literals because the first version of
522
- // this preferred `origin` in the DEGRADED SENTENCE only, leaving the verdict itself on the
523
- // alphabetical remote — a fix that repaired the report of a defect and not the defect.
524
- // Copilot, round 3.
235
+ // A remote's recorded default head, never a branch picked by name; `origin` first, as `git remote` sorts by name.
525
236
  let base = null;
526
237
  const listed = remotes.split("\n").filter(Boolean);
527
238
  const named = listed.includes("origin") ? ["origin", ...listed.filter((r) => r !== "origin")] : listed;
@@ -530,25 +241,11 @@ function didWork(root = REPO) {
530
241
  base = git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", `${remote}/HEAD`]).trim() || null;
531
242
  if (base) break;
532
243
  } catch {
533
- // This remote records no default head. Try the next one.
244
+ // This remote records no default head.
534
245
  }
535
246
  }
536
247
 
537
- // **The refinement failed, so the coarse reading stands — and it stands as TRUE.**
538
- //
539
- // This is the fail-CLOSED direction and it is chosen rather than defaulted into. Commits on
540
- // no remote is an established fact by the time we are here; only the attempt to excuse them
541
- // as already-upstream could not be made. Reading that as *no work* would newly convert a
542
- // case that blocks today into a pass — a relaxation, and this gate's own refusal message
543
- // says relaxing a check is the change to scrutinise hardest. The asymmetry decides it: a
544
- // wrong block is capped at MAX_BLOCKS and speaks, a wrong pass is unbounded and silent.
545
- // `../.portulan/memory/verify-preconditions-fail-closed.md` is cited in this file's header
546
- // for exactly this direction.
547
- // The advice NAMES A COMMAND THE READER CAN RUN. The first draft always said
548
- // `git cherry <remote>/HEAD HEAD` — but the commonest way into this branch is that
549
- // `<remote>/HEAD` does not resolve, so the suggested check failed for the very reason the
550
- // refinement did. Telling someone to re-run the thing that just broke is a diagnosis-shaped
551
- // sentence carrying no diagnosis. Copilot, round 1.
248
+ // Where patch-ids cannot be compared, commits on no remote count as work: a wrong block is capped and speaks, a wrong pass is silent.
552
249
  const degraded = (why, check) => {
553
250
  process.stderr.write(
554
251
  `portulan stop-gate: could not compare by patch-id (${why}), so commits on no remote are ` +
@@ -558,12 +255,6 @@ function didWork(root = REPO) {
558
255
  return true;
559
256
  };
560
257
  if (!base) {
561
- // **The remotes are NAMED, because this runner already knows them** — it just tried every
562
- // one. A `<remote>` placeholder in a multi-remote repository invites the reader to run the
563
- // repair against whichever they think of first, which is how a diagnosis sends someone to
564
- // the wrong ref. Copilot, round 3. `named` is already ordered `origin`-first above, so the
565
- // remote this repairs is the same one the loop would have compared against — the two
566
- // cannot disagree, which is why the ordering is computed once.
567
258
  const which = named[0];
568
259
  return degraded(
569
260
  `no remote records a default head (tried \`${named.join("`, `")}\`)`,
@@ -572,11 +263,7 @@ function didWork(root = REPO) {
572
263
  );
573
264
  }
574
265
 
575
- // **Exit 0 AND zero `+` lines, both required.** `git cherry` against an unknown ref exits 128
576
- // and prints NOTHING, so counting `+` alone reads a failed command as *nothing unmerged* —
577
- // measured, and already recorded in `../.portulan/gate-map.md` and `./init.mjs`'s
578
- // `delete-a-remote-branch` reason as a fail-open closed once before. The exit half is carried
579
- // by `git()` itself, which throws on non-zero; the catch below is that half, not decoration.
266
+ // `git cherry` against an unknown ref exits 128 and prints nothing, so its failure is the throw, not an empty output.
580
267
  let cherry;
581
268
  try {
582
269
  cherry = git(["cherry", base, "HEAD"]);
@@ -585,52 +272,51 @@ function didWork(root = REPO) {
585
272
  }
586
273
  return cherry.split("\n").some((line) => line.startsWith("+"));
587
274
  } catch {
588
- // Genuinely cannot tell. Say so rather than inventing an obligation OR silently dropping one:
589
- // a gate that disables itself without a word is the failure this whole runner exists against.
590
275
  process.stderr.write("portulan stop-gate: cannot determine whether this session did work — the handoff check is not running.\n");
591
276
  return false;
592
277
  }
593
278
  }
594
279
 
595
- /** Is there a handoff dated today? The doctrine's checkable form: existence and a date, never length. */
596
- function handoffToday(stamp, workspace = WORKSPACE) {
280
+ /** Today's handoffs as `{ own, recorded }`, `recorded` being clean and held by a remote at that path. */
281
+ function handoffToday(stamp, tree = { root: REPO, workspace: WORKSPACE }) {
282
+ let dated;
597
283
  try {
598
- return fs.readdirSync(path.join(workspace, "handoffs")).some((f) => f.startsWith(stamp) && f.endsWith(".md"));
284
+ dated = fs.readdirSync(path.join(tree.workspace, "handoffs")).filter((f) => f.startsWith(stamp) && f.endsWith(".md"));
599
285
  } catch {
600
- return false;
286
+ return { own: [], recorded: [] };
287
+ }
288
+ const handoffsDir = path.join(tree.workspace, "handoffs");
289
+ const dir = path.relative(tree.root, handoffsDir).split(path.sep).join("/");
290
+ if (dir === "" || !isInside(tree.root, handoffsDir)) return { own: dated, recorded: [] };
291
+ const git = (args) => execFileSync("git", args, { cwd: tree.root, encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"] });
292
+ const own = [];
293
+ const recorded = [];
294
+ for (const name of dated) {
295
+ const file = `:(literal)${dir}/${name}`;
296
+ let unrecorded;
297
+ try {
298
+ unrecorded = git(["status", "--porcelain", "--untracked-files=all", "--ignored", "--", file]).trim() !== "";
299
+ if (!unrecorded) {
300
+ // By content, not commit: a rebase or squash merge pushes a handoff in a commit of its own.
301
+ // `--root`, or `log.showRoot=false` skips a root commit.
302
+ const blob = git(["rev-parse", `HEAD:./${dir}/${name}`]).trim();
303
+ unrecorded = git(["log", "-1", "--root", "--format=%h", "--remotes", `--find-object=${blob}`, "--", file]).trim() === "";
304
+ }
305
+ } catch {
306
+ process.stderr.write(`portulan stop-gate: could not tell whether ${name} is committed and pushed, so it counts as this tree's own.\n`);
307
+ unrecorded = true;
308
+ }
309
+ (unrecorded ? own : recorded).push(name);
601
310
  }
311
+ return { own, recorded };
602
312
  }
603
313
 
604
- /**
605
- * WHICH TREE this gate just answered about — the working copy and the branch it is on.
606
- *
607
- * `handoffToday()` reads the directory under `WORKSPACE`, which is derived from `PROJECT`, which is
608
- * whatever the host told this hook or whatever the cwd happens to be. That is deliberate — this file
609
- * is TOLD its root rather than deriving one, for the reason its header gives — but it means the gate
610
- * can ask a question about one tree and answer it from another. Measured 2026-08-10: a session whose
611
- * worktree had been removed had its cwd fall back to the origin repository, parked on an unrelated
612
- * branch 84 commits behind, and was told *"no handoff dated 2026-08-10"* while five sat on `main`.
613
- *
614
- * **Since #220's second arm the gate DOES rescope, and this names whichever tree answered.** An
615
- * earlier version of this paragraph argued that naming was sufficient because a removed worktree
616
- * cannot be read. That held for the one stop after the removal and failed as an argument about the
617
- * arm: for the whole of that session's working phase both trees existed and differed, so every stop
618
- * before the removal was already answering about the wrong one. The removal exposed the divergence
619
- * rather than creating it. `resolveSessionTree` above now picks the session's tree from the Stop
620
- * payload's `cwd` when it belongs to this repository, and falls back here — told root, named — only
621
- * where it cannot.
622
- */
623
314
  function treeIdentity(root = REPO) {
624
315
  try {
625
316
  const run = (args) => execFileSync("git", args, {
626
317
  cwd: root, encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"],
627
318
  }).trim();
628
319
  const branch = run(["rev-parse", "--abbrev-ref", "HEAD"]);
629
- // **A DETACHED HEAD is the common case here, not the exotic one.** `--abbrev-ref` answers the
630
- // literal string `HEAD` when no branch is checked out, and this repository routinely has several
631
- // detached worktrees at once — measured: four, on the machine this was written on. Printing
632
- // *"on `HEAD`"* would name a branch that does not exist, in the very sentence added so a reader
633
- // could identify the tree. The commit is what identifies a detached one. Copilot, round 3.
634
320
  if (branch === "HEAD") return `${root} (detached at \`${run(["rev-parse", "--short", "HEAD"])}\`)`;
635
321
  return `${root} (on \`${branch}\`)`;
636
322
  } catch {
@@ -638,62 +324,30 @@ function treeIdentity(root = REPO) {
638
324
  }
639
325
  }
640
326
 
641
- /**
642
- * A handoff dated today somewhere in this repository's history — every ref already on disk — or null.
643
- *
644
- * **No network, deliberately.** A Stop hook runs on every attempt to end a turn, and a fetch would
645
- * put a remote round trip — and an offline host's timeout — inside the gate. So this reads refs that
646
- * are already on disk — `--all` is every ref this repository carries, local branches included, not only
647
- * remote-tracking ones. The sentence it feeds must not overclaim: *absent from this working tree* is
648
- * always true when it fires; *present at `<ref>`* is claimed only where some ref on disk shows one.
649
- */
650
- function handoffInHistory(stamp, tree = { root: REPO, workspace: WORKSPACE }) {
651
- // **Root and workspace arrive as ONE pair, never mixed.** A told root beside a session workspace
652
- // makes the relative path below start with `..`, the guard returns null, and the elsewhere arm
653
- // dies in silence — the same both-must-move-together rule the caller obeys, inside this function.
327
+ // Refs on disk only: a fetch would put a round trip, or an offline host's timeout, inside every Stop.
328
+ function handoffInHistory(stamp, tree = { root: REPO, workspace: WORKSPACE }, held = []) {
654
329
  try {
655
- // **Separators normalised to `/`, because this becomes a git PATHSPEC.** `path.relative` yields
656
- // `\` on Windows and git would match nothing — and the failure is silent: this returns null,
657
- // the refusal loses the half that tells a reader their handoff is already recorded, and nothing
658
- // says why. The same spelling is used wherever this repository hands git a derived path
659
- // (`./librarian.mjs`, `./compile.mjs`). Copilot, round 3.
660
- const dir = path.relative(tree.root, path.join(tree.workspace, "handoffs")).split(path.sep).join("/");
661
- // A workspace outside the repository is not a question git can answer about this history.
662
- if (dir === "" || dir.startsWith("..") || path.isAbsolute(dir)) return null;
330
+ const handoffsDir = path.join(tree.workspace, "handoffs");
331
+ const dir = path.relative(tree.root, handoffsDir).split(path.sep).join("/");
332
+ if (dir === "" || !isInside(tree.root, handoffsDir)) return null;
663
333
  const git = (args) => execFileSync("git", args, { cwd: tree.root, encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"] });
664
- // `--all` is every ref already on disk, local and remote-tracking. `-1` because existence is
665
- // the whole question — this is the doctrine's checkable form, not a census.
666
- // **`:(glob)` magic, because a bare `*` is not reliably a glob.** With `GIT_NOGLOB_PATHSPECS`
667
- // set, git reads `*` literally, this matches nothing, and the function returns null — silently
668
- // reintroducing the exact "a handoff exists elsewhere and the gate does not say so" gap this
669
- // arm exists to close. Measured on git 2.50.1: bare pattern matches 1 normally and **0** under
670
- // `GIT_NOGLOB_PATHSPECS=1`; with `:(glob)` it matches 1 under both. _(The neighbouring lever
671
- // `core.globPathspec=false` does NOT reproduce it — measured — so the magic is what makes this
672
- // deterministic, not a config default.)_ Explicit pathspec magic is the same discipline
673
- // `../.portulan/verify/plugin.sh` documents. Copilot, round 3.
674
- const commit = git(["log", "--all", "-1", "--format=%H", "--", `:(glob)${dir}/${stamp}*`]).trim();
334
+ // `:(glob)`, since git reads a bare `*` literally under `GIT_NOGLOB_PATHSPECS=1` (git 2.50.1).
335
+ const commit = git(["log", "-1", "--format=%H", "--all", "--not", "HEAD", "--", `:(glob)${dir}/${stamp}*`,
336
+ ...held.map((name) => `:(exclude,literal)${dir}/${name}`)]).trim();
675
337
  if (!commit) return null;
676
338
  const refs = git(["branch", "--all", "--contains", commit, "--format=%(refname:short)"])
677
339
  .split("\n").map((r) => r.trim()).filter(Boolean);
678
340
  return { commit: commit.slice(0, 7), ref: refs[0] ?? null };
679
341
  } catch {
680
- // Cannot look. Say nothing rather than claim an absence — the refusal below is already
681
- // correct without this sentence, and a false "nowhere in history either" would be worse
682
- // than silence.
683
342
  return null;
684
343
  }
685
344
  }
686
345
 
687
- /**
688
- * Everything this gate refuses a stop for, plus whether the governing recipe was observed GREEN.
689
- * The two are reported separately because the consecutive-cap reset keys off the recipe alone, while
690
- * whether to block keys off the whole set.
691
- */
692
346
  function collectProblems(tree = { root: REPO, workspace: WORKSPACE, origin: "told", note: null }) {
693
- /** Every problem carries the REASON it belongs to — the key its own counter is kept under. */
694
347
  const problems = [];
695
348
  let recipeGreen = false;
696
349
 
350
+ // Only the told workspace's recipe, run in the told root: one read from a tree the payload names would run what stdin chose.
697
351
  const recipe = defaultRecipe();
698
352
  if (!recipe) {
699
353
  problems.push({
@@ -703,12 +357,6 @@ function collectProblems(tree = { root: REPO, workspace: WORKSPACE, origin: "tol
703
357
  "verified this work. That is 'could not run', which blocks exactly as red does.",
704
358
  });
705
359
  } else {
706
- // `bash -c` with the command as one string, deliberately: the manifest declares a *command*,
707
- // not a script path — `spec/slots.md` says so and the CI workflow runs each recipe the same
708
- // way. Passing argv instead would make the Stop-gate execute recipes differently from CI,
709
- // which trades a small quoting surface for a much worse property: two runners disagreeing
710
- // about what a recipe means. The real defect the review found was the exit-code reading
711
- // below, and that is fixed rather than worked around.
712
360
  try {
713
361
  execFileSync("bash", ["-c", recipe.run], {
714
362
  cwd: REPO,
@@ -720,24 +368,10 @@ function collectProblems(tree = { root: REPO, workspace: WORKSPACE, origin: "tol
720
368
  } catch (error) {
721
369
  const code = error.status;
722
370
  const output = `${error.stdout ?? ""}${error.stderr ?? ""}`.trim().split("\n").slice(-25).join("\n");
723
- // Exit 2 gets its OWN outcome, not folded into red. The recipes distinguish "ran and
724
- // failed" from "could not run" precisely so that neither is mistaken for the other, and a
725
- // runner that flattened them would either fail open (2 read as pass) or manufacture false
726
- // reds (2 read as a verdict about the tree). Both block; they say different things.
727
- // Exit 2 gets its OWN outcome, and so do the shell's "cannot execute" codes. 126 is
728
- // "found but not executable", 127 is "not found", and a spawn failure has no status at
729
- // all — none of the three is a verdict about the repository, and calling any of them RED
730
- // would report a judgement nobody reached. That is the same laundering the recipes'
731
- // three-code contract exists to prevent, one level up, and it was reaching the gate that
732
- // contract is *for*. Found by review on the pull request.
733
371
  const CANNOT_RUN = new Set([2, 126, 127]);
734
372
  const outcome = CANNOT_RUN.has(code) || code === undefined || code === null
735
373
  ? `could not run (exit ${code ?? "no status"}) — the gate could not judge`
736
374
  : `RED (exit ${code})`;
737
- // The tree is named here too, now that one verdict can span two: the recipe is always run
738
- // in the TOLD root (see the header) while the handoff question may have been answered
739
- // about the session's. Without this, two problems in one refusal would read as though
740
- // both concerned the same tree. Raised as optional at the session-open checkpoint; taken.
741
375
  problems.push({
742
376
  reason: "recipe",
743
377
  text: `verify recipe \`${recipe.id}\` in ${REPO} — ${outcome}\n${output}`,
@@ -745,34 +379,11 @@ function collectProblems(tree = { root: REPO, workspace: WORKSPACE, origin: "tol
745
379
  }
746
380
  }
747
381
 
748
- // **ONE date per Stop event, captured once and threaded through every use.** There were four
749
- // independent `today()` calls on this path — the tree check, the history query, and both halves of
750
- // the sentence. A stop spanning local midnight (or a host clock that jumps) could therefore CHECK
751
- // one date and REPORT another, and worse, search history for a date nobody checked the tree for.
752
- // `today()`'s own header records that this file has already produced one false red from a date
753
- // disagreement; two dates inside a single verdict is the same class. Copilot, round 3.
754
382
  const stamp = today();
755
- const handoffPresent = handoffToday(stamp, tree.workspace);
756
- // **`!handoffPresent` first, and the order is load-bearing rather than stylistic.** `didWork()`
757
- // shells out to git several times and can print the could-not-compare sentence; ordered the other
758
- // way, a session that HAS written its handoff still paid that cost on every Stop event and could
759
- // be handed a degradation warning about an obligation it does not owe. Copilot, round 1.
383
+ const handoffs = handoffToday(stamp, tree);
384
+ const handoffPresent = handoffs.own.length > 0;
385
+ // `handoffPresent` first: `didWork()` runs git several times and may warn about a handoff that is not owed.
760
386
  if (!handoffPresent && didWork(tree.root)) {
761
- // The tree is NAMED, because the sentence was once true about a tree the reader was not
762
- // thinking of and read as a verdict about their session (#220).
763
- //
764
- // **A REFUSAL names both trees where they differ**, because a reader looking at the hook's
765
- // configured root would otherwise not find the tree this verdict came from. Only on a refusal:
766
- // told ≠ session is the routine worktree case here, so a divergence line on every green stop
767
- // would be a metronome, and this gate's sentences are worth reading precisely because they are
768
- // rare (#220 second arm).
769
- // **Both sides resolved through `realpathSync`, or this sentence invents a divergence.**
770
- // `resolveSessionTree` returns a realpathed root, so comparing it against a merely
771
- // `path.resolve`d `REPO` makes a symlinked told root — `/tmp` on macOS, or any convenience
772
- // link — read as a second tree, and the refusal then names one directory twice as though the
773
- // session had worked somewhere else. Measured. It is the same trap the same-repository guard
774
- // above already defends against, left unapplied one comparison away, which is why the guard's
775
- // own reasoning is repeated here rather than assumed to carry.
776
387
  const real = (dir) => {
777
388
  try {
778
389
  return fs.realpathSync(dir);
@@ -783,56 +394,38 @@ function collectProblems(tree = { root: REPO, workspace: WORKSPACE, origin: "tol
783
394
  const answeredElsewhere = tree.origin === "session" && real(tree.root) !== real(REPO)
784
395
  ? ` — the tree this session worked in, not the one this hook was told (${REPO})`
785
396
  : "";
786
- const elsewhere = handoffInHistory(stamp, tree);
397
+ const { recorded } = handoffs;
398
+ const elsewhere = handoffInHistory(stamp, tree, recorded);
787
399
  const found = elsewhere
788
400
  ? ` One dated ${stamp} does exist elsewhere in this repository's refs, at ` +
789
401
  `${elsewhere.commit}${elsewhere.ref ? ` on \`${elsewhere.ref}\`` : ""} — so this working tree may not be ` +
790
- "the tree that did the work. Check before writing a second one: a duplicate handoff reds `docs.sh`'s " +
791
- "record check, which is the trap the bare sentence used to set."
402
+ "the tree that did the work. Check before writing a second one."
403
+ : "";
404
+ const notCounted = recorded.length
405
+ ? `${recorded.map((f) => `\`${f}\``).join(", ")} ${recorded.length === 1 ? "is" : "are"} committed and pushed already, ` +
406
+ `so ${recorded.length === 1 ? "it records" : "they record"} work already recorded. `
792
407
  : "";
793
408
  problems.push({
794
409
  reason: "handoff",
795
410
  text:
796
- `no handoff dated ${stamp} in ${path.join(tree.workspace, "handoffs")}, read from ` +
797
- `${treeIdentity(tree.root)}${answeredElsewhere}. ` +
798
- "Every session ends with a dated handoff — five lines is enough, absent is not. The Session log " +
799
- "records what landed; the handoff records why, and the why is the part the next session cannot " +
800
- `reconstruct from the diff.${found}`,
411
+ `no handoff dated ${stamp}${recorded.length ? " of this tree's own" : ""} in ${path.join(tree.workspace, "handoffs")}, read from ` +
412
+ `${treeIdentity(tree.root)}${answeredElsewhere}. ${notCounted}` +
413
+ "This tree holds work that is not committed and pushed. Commit and push it, its why in the commit " +
414
+ "message, or end with a dated handoff naming what is open: where things stand, the open questions " +
415
+ `and the next action. Five lines is enough; absent is not.${found}`,
801
416
  });
802
417
  }
803
418
 
804
- return { problems, recipeGreen, handoffPresent };
419
+ return { problems, recipeGreen };
805
420
  }
806
421
 
807
- /**
808
- * The gate's arithmetic, as a pure function — the part that decides whether this gate can be talked
809
- * past, or can wrongly refuse. Exported because both directions need testing and neither is
810
- * testable through a hook that calls `process.exit`.
811
- *
812
- * Three outcomes, deliberately distinct:
813
- * allow — nothing is wrong. A green stop is FREE: it must never consume the budget, or an
814
- * ordinary session spends its cap on good turns and a real red walks through afterwards.
815
- * block — refuse this stop, and charge it.
816
- * release — the cap is spent. The session may end, and the record must say RED rather than done.
817
- */
818
422
  export function verdict({ problems, counts = {}, total = 0, chain = 0, max = MAX_BLOCKS, maxTotal = MAX_TOTAL_BLOCKS, maxChain = MAX_CHAIN_BLOCKS }) {
819
423
  if (problems.length === 0) return { action: "allow" };
820
424
 
821
425
  const text = problems.map((p) => p.text).join("\n\n");
822
- // Only a reason that is wrong RIGHT NOW may release the gate. A cleared reason's counter is
823
- // zeroed, so this never remembers that the recipe was once red four times and lets every later
824
- // stop through on the strength of a problem that no longer exists.
825
426
  const spent = problems.map((p) => p.reason).filter((reason) => (counts[reason] ?? 0) > max);
826
427
 
827
428
  if (spent.length || total > maxTotal || chain > maxChain) {
828
- // Name the bound that actually released it. Saying "cap of 3" after nine refusals with a
829
- // per-reason count of 1 is false, and it is the same defect already fixed once in this very
830
- // message — a release that misreports why it happened sends a reader to the wrong constant.
831
- // Per-reason counters add one more way to be wrong: naming the cap without naming whose.
832
- // The chain bound is named LAST, so the two session-keyed bounds keep reporting themselves
833
- // wherever they can — a session whose id is stable must always meet those first. Reaching this
834
- // one is itself a finding about the host: it means the per-session counters could not see their
835
- // own history, which is what 2.1.251 does inside an isolated arm.
836
429
  const bound = spent.length
837
430
  ? `the cap of ${max} consecutive refusals for \`${spent.join("`, `")}\` was reached`
838
431
  : total > maxTotal
@@ -850,8 +443,6 @@ export function verdict({ problems, counts = {}, total = 0, chain = 0, max = MAX
850
443
  };
851
444
  }
852
445
 
853
- // The counts shown are per reason, because that is what the caps now are. A single "2/3" would
854
- // be a number no constant in this file corresponds to the moment two reasons are live.
855
446
  const tally = problems.map((p) => `${p.reason} ${counts[p.reason] ?? 0}/${max}`).join(", ");
856
447
  return {
857
448
  action: "block",
@@ -867,45 +458,23 @@ function main() {
867
458
  try {
868
459
  payload = JSON.parse(fs.readFileSync(0, "utf8"));
869
460
  } catch {
870
- // No payload means no session id, so the counter cannot be trusted — and a gate that cannot
871
- // count its own blocks cannot promise to stop. Stepping aside is safer than a possible hang.
461
+ // Without a session id no count can be kept, and a gate that cannot count cannot promise to stop.
872
462
  allow();
873
463
  }
874
464
 
875
465
  const sessionId = payload.session_id ?? "unknown";
876
-
877
- // **Resolved ONCE, here, and threaded down** — so every question this stop asks is about the same
878
- // tree, and the sentence that reports the answer names that tree rather than another one.
879
466
  const tree = resolveSessionTree(payload.cwd);
880
-
881
- // **Degradation is spoken when the datum was offered and could not be used, and silent when it was
882
- // never offered.** A host that sends no `cwd` has degraded nothing: it gets today's behaviour byte
883
- // for byte, which is also what makes every pre-existing case a control for this change. A host that
884
- // sent one this gate could not use is a different fact, and one a reader needs — printed whatever
885
- // the verdict turns out to be, because it explains an allow just as much as a refusal.
886
- //
887
- // **The honest residue: on this path the silent-allow gap of #220 REMAINS, by construction.** The
888
- // told root answers, and if the session's real tree carries unrecorded work this gate cannot see
889
- // it. Forcing a block instead would manufacture an obligation from no evidence and leave the
890
- // removed-worktree case permanently blocking — the false red the first arm was built to remove.
891
467
  if (tree.note) process.stderr.write(`portulan stop-gate: ${tree.note}; answering about ${REPO} instead.\n`);
892
468
 
893
- const { problems, recipeGreen, handoffPresent } = collectProblems(tree);
469
+ const { problems, recipeGreen } = collectProblems(tree);
894
470
 
895
- // A reason's condition clearing ends THAT reason's futile-retry episode, whether or not the stop
896
- // is allowed, and never any other reason's. Done before the bump so a cleared reason cannot be
897
- // charged a consecutive refusal on the same turn it cleared.
471
+ // The session's counts stay on the told root, so a `cwd` that moves mid-session cannot restart them.
898
472
  if (recipeGreen) clearReason(sessionId, "recipe");
899
- if (handoffPresent) clearReason(sessionId, "handoff");
473
+ if (!problems.some((p) => p.reason === "handoff")) clearReason(sessionId, "handoff");
900
474
 
901
- // Charged only when this gate is actually about to refuse. A green stop costs nothing.
902
475
  const state = problems.length === 0
903
476
  ? { counts: {}, total: 0 }
904
477
  : bumpCount(sessionId, [...new Set(problems.map((p) => p.reason))]);
905
- // **The chain follows the platform's own signal, not ours.** `stop_hook_active` is true exactly when
906
- // the stop being judged was provoked by a hook block, so it identifies the retry chain without any
907
- // session id. Cleared on a stop nothing provoked — including a green one, which ends a chain as
908
- // surely as a fixed problem does.
909
478
  const chain = bumpChain(problems.length > 0 && payload.stop_hook_active === true, os.tmpdir(), tree.root);
910
479
  const result = verdict({ problems, counts: state.counts, total: state.total, chain });
911
480