@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/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,114 +70,91 @@ 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
 
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.
88
+ export const MAX_CHAIN_BLOCKS = 12;
89
+
90
+ // Well inside the host's 600 s hook timeout, so a hung recipe meets a verdict here first.
197
91
  const RECIPE_TIMEOUT_MS = 90_000;
198
92
 
199
- /**
200
- * Where one session's counter lives. Keyed by session id **and** by the working tree, because
201
- * `worktree-local` is a requirement rather than an accident: several worktrees of this repository are
202
- * routinely checked out at once, and two sessions sharing a counter would let one disarm the other's
203
- * gate. Untracked, in the OS temp dir — this is session state, not repository state.
204
- */
205
93
  function counterFile(sessionId, dir, root = REPO) {
206
- // A short, stable digest. Not security — just enough that two distinct inputs differ.
207
94
  const digest = (v) => {
208
95
  let h = 0;
209
96
  for (const ch of String(v)) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
210
97
  return h.toString(36);
211
98
  };
212
- // The readable part is for a human debugging a stuck counter; the digest is what makes the name
213
- // unique. Both are needed. Sanitising ALONE is not enough, and the failure is silent: a session id
214
- // made entirely of characters outside the class sanitises to the empty string, so every such
215
- // session shares one counter and they charge each other's cap — or release each other early. The
216
- // 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.
217
100
  const readable = String(sessionId).replace(/[^a-zA-Z0-9-]/g, "").slice(0, 40);
218
101
  return path.join(dir, `portulan-stopgate-${readable}-${digest(sessionId)}-${digest(root)}`);
219
102
  }
220
103
 
221
- /** The stored shape: one consecutive count per reason, plus the non-resetting refusal total. */
104
+ function chainFile(dir, root = REPO) {
105
+ const digest = (v) => {
106
+ let h = 0;
107
+ for (const ch of String(v)) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
108
+ return h.toString(36);
109
+ };
110
+ return path.join(dir, `portulan-stopgate-chain-${digest(root)}`);
111
+ }
112
+
113
+ /** Hook-provoked stops in a row for this tree, this one included, or 0 unless `provoked`; an unwritable count stays put. */
114
+ export function bumpChain(provoked, dir = os.tmpdir(), root = REPO) {
115
+ const file = chainFile(dir, root);
116
+ if (!provoked) {
117
+ try {
118
+ fs.rmSync(file, { force: true });
119
+ } catch {
120
+ // A run left in place can only make the chain bound fire sooner.
121
+ }
122
+ return 0;
123
+ }
124
+ let previous = 0;
125
+ try {
126
+ previous = Number(JSON.parse(fs.readFileSync(file, "utf8"))?.chain) || 0;
127
+ } catch {
128
+ previous = 0;
129
+ }
130
+ try {
131
+ fs.writeFileSync(file, JSON.stringify({ chain: previous + 1 }));
132
+ } catch {
133
+ return previous;
134
+ }
135
+ return previous + 1;
136
+ }
137
+
222
138
  function readCount(file) {
223
139
  let stored = {};
224
140
  try {
225
141
  stored = JSON.parse(fs.readFileSync(file, "utf8")) ?? {};
226
142
  } catch {
227
- // No counter yet, or an unreadable one. Either way this session has spent nothing — the
228
- // stricter direction, since a session that cannot read its budget has not used it.
143
+ // None yet, or unreadable: read as nothing spent.
229
144
  }
230
- // Every stored key is carried forward, THEN the known reasons are defaulted to 0. The order and
231
- // the carry-forward both matter. Filtering by `REASONS` alone dropped any other key, so a reason
232
- // this file can emit but the constant does not declare would have reset to 0 on every read —
233
- // never reaching its own cap, and released only by the ceiling of nine. That is precisely the
234
- // asymmetry task 0007 removed, reintroduced through a drifted list, and silent in the direction
235
- // of more patience. The constant exists so the counter, the clearing rule and the message agree
236
- // about what a reason is; it must not be the thing that also decides what may be *counted*.
237
- // Found by review, in the suppressed half of a Copilot round. The suite additionally binds every
238
- // 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.
239
146
  const counts = {};
240
147
  for (const [reason, value] of Object.entries(stored.counts ?? {})) counts[reason] = Number(value) || 0;
241
148
  for (const reason of REASONS) counts[reason] ??= 0;
242
149
  return { counts, total: Number(stored.total) || 0 };
243
150
  }
244
151
 
245
- /**
246
- * Count one refusal against a session. `reasons` is every reason THIS stop was refused for.
247
- * Returns `{ counts, total }`.
248
- *
249
- * Exported for the suite; `dir` and `root` are injectable for the same reason.
250
- *
251
- * When the counter cannot be kept at all, this returns numbers ABOVE every cap — for every reason,
252
- * not only the ones passed in — so the caller lets the session end. That direction is deliberate: an
253
- * un-capped gate is the one failure here a human cannot escape from inside the session. (The first
254
- * version returned exactly `MAX_BLOCKS`, which is not above it — so an unwritable temp dir made a red
255
- * tree block *forever*, the precise opposite of what its own comment claimed. Found at the pre-commit
256
- * checkpoint, demonstrated against a read-only TMPDIR. Filling only the named reasons would have
257
- * reintroduced that trap one reason over.)
258
- */
152
+ /** Charges one refusal to `reasons`; where it cannot be written, every count comes back past its cap, releasing the session. */
259
153
  export function bumpCount(sessionId, reasons, dir = os.tmpdir(), root = REPO) {
260
154
  const file = counterFile(sessionId, dir, root);
261
155
  const now = readCount(file);
262
156
  const counts = { ...now.counts };
263
157
  for (const reason of reasons) counts[reason] = (counts[reason] ?? 0) + 1;
264
- // One refusal, however many reasons named it — see MAX_TOTAL_BLOCKS.
265
158
  const next = { counts, total: now.total + 1 };
266
159
  try {
267
160
  fs.writeFileSync(file, JSON.stringify(next));
@@ -273,16 +166,6 @@ export function bumpCount(sessionId, reasons, dir = os.tmpdir(), root = REPO) {
273
166
  return next;
274
167
  }
275
168
 
276
- /**
277
- * One reason's condition cleared: its consecutive count goes to zero, and **only** its own.
278
- *
279
- * This is the whole of task 0007. The previous version cleared *the* count on an observed green
280
- * recipe while the gate refused for two reasons — so a missing handoff's patience was restored by
281
- * evidence about the recipe, which made the gate unbounded in that case and gave a five-line file
282
- * three times the tolerance of a failing suite.
283
- *
284
- * The running total survives on purpose: it is what guarantees the gate can still stop.
285
- */
286
169
  export function clearReason(sessionId, reason, dir = os.tmpdir(), root = REPO) {
287
170
  const file = counterFile(sessionId, dir, root);
288
171
  const now = readCount(file);
@@ -291,18 +174,13 @@ export function clearReason(sessionId, reason, dir = os.tmpdir(), root = REPO) {
291
174
  try {
292
175
  fs.writeFileSync(file, JSON.stringify(next));
293
176
  } catch {
294
- // Failing to clear a counter can only make the gate stricter, never weaker. Leave it.
295
177
  return now;
296
178
  }
297
179
  return next;
298
180
  }
299
181
 
300
- /** 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. */
301
183
  export function today(now = new Date()) {
302
- // `toISOString()` is UTC, and handoffs are named by local date. Between midnight and 03:00 in
303
- // the maintainer's timezone the two disagree, and the gate would demand yesterday's date from a
304
- // session that had just written today's — a false red, which is the failure that gets a rail
305
- // switched off rather than fixed. Found at the pre-commit checkpoint.
306
184
  const pad = (n) => String(n).padStart(2, "0");
307
185
  return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
308
186
  }
@@ -316,24 +194,10 @@ function block(reason) {
316
194
  process.exit(0);
317
195
  }
318
196
 
319
- /**
320
- * The workspace's default verify recipe: `{ id, run }`, or null if the manifest cannot say.
321
- *
322
- * Reaches `recipeSet()` rather than enumerating `verify.recipes` here, because that enumeration is one
323
- * rule and this file was one of four places carrying it — see `recipe-set.mjs`'s header for why they
324
- * were merged and `recipe-set.test.mjs` for the roster pin that keeps them merged.
325
- *
326
- * **It asks for the workspace subset deliberately, by passing no packs**, and that is not composition
327
- * quietly skipped. `verify.default` is a bare slug in `spec/workspace.schema.json` and a composed id is
328
- * `<category>/<name>:<id>`, so **no composed recipe can ever be the default** — resolving packs here
329
- * would cost a pack resolution on every Stop event and could not change the answer. What this call
330
- * still buys is the one definition of a *valid* recipe: a manifest whose default recipe carries an
331
- * empty or newline-bearing `run` is refused here exactly as CI refuses it, rather than being handed to
332
- * a shell by a gate that did its own looser check.
333
- */
334
197
  function defaultRecipe() {
335
198
  try {
336
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.
337
201
  const set = recipeSet(manifest, { packs: [] });
338
202
  if (!set.ok) return null;
339
203
  const recipe = set.recipes.find((r) => r.id === set.default);
@@ -343,95 +207,32 @@ function defaultRecipe() {
343
207
  }
344
208
  }
345
209
 
346
- /**
347
- * Has this session done work? A read-only session owes no handoff.
348
- *
349
- * Three signals, tried in order, because the obvious one is not portable. The first version hard-coded
350
- * `origin/main..HEAD`, which throws in a repository with no `origin`, a differently-named default
351
- * branch, or an unfetched ref — and the catch turned that throw into `false`, **silently disabling the
352
- * handoff gate while local commits sat there unrecorded.** A fail-open in the gate, and a quiet one.
353
- * Found by review on the pull request.
354
- */
355
210
  function didWork(root = REPO) {
356
- // `maxBuffer` raised from node's 1 MiB default **for `git cherry`, which cannot be bounded the way
357
- // the emptiness probe below can**: it must print a line per compared commit to be read at all,
358
- // where the probe only has to answer yes-or-no and so is asked with `--max-count=1`. An overflow
359
- // here throws into the outer catch and reads as *cannot tell* on precisely the branches that carry
360
- // the most commits. This raise moves that cliff rather than removing it — every buffer is finite —
361
- // which is why the probe is bounded instead of merely given more room.
362
211
  const git = (args) => execFileSync("git", args, { cwd: root, encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024 * 1024 });
363
212
 
364
- // 1. Uncommitted changes. Portable, and the strongest signal there is.
365
213
  try {
366
214
  if (git(["status", "--porcelain"]).trim() !== "") return true;
367
215
  } catch {
368
- // Not a git repository, or git is unusable. Nothing below will work either.
369
216
  process.stderr.write("portulan stop-gate: cannot read git status — the handoff check is not running.\n");
370
217
  return false;
371
218
  }
372
219
 
373
- // 2. Commits ahead of this branch's configured upstream, whatever it is called.
374
220
  try {
375
221
  const upstream = git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]).trim();
376
222
  if (upstream) return git(["log", "--oneline", `${upstream}..HEAD`]).trim() !== "";
377
223
  } catch {
378
- // No upstream configured. Ordinary for a branch that has never been pushed — fall through.
224
+ // No upstream, as on a branch never pushed.
379
225
  }
380
226
 
381
- // 3. Commits whose PATCHES are on no remote — compared by patch-id, never by reachability.
382
- //
383
- // **This repository rebase-merges, and that makes reachability the wrong question** (#220). A
384
- // rebase-merge rewrites commits, so a merged branch's originals appear on no remote; once the
385
- // remote branch is deleted on merge they never will. `HEAD --not --remotes` therefore answers
386
- // *yes, there is unrecorded work* **permanently**, for every commit of every merged-and-deleted
387
- // branch — and any checkout left on such a branch demanded a handoff from a session that had
388
- // done nothing. Measured 2026-08-10: the gate blocked twice over a handoff that was already on
389
- // `main`, on the strength of four commits `git cherry` showed as fully upstream.
390
- //
391
- // The class is named in this repository for a different tool —
392
- // `../.portulan/memory/a-branch-syncs-with-main-before-it-merges.md`, *"a rebase-merge leaves the
393
- // branch tip an ancestor of nothing"* — and the sanctioned test there is `git cherry`:
394
- // comparison by patch-id. This is that record applied at the site that asked the same wrong
395
- // question.
227
+ // By patch-id, not reachability: a rebase-merge rewrites commits, so a merged branch's originals are on no remote.
396
228
  try {
397
- // The coarse reading first, because it is cheap and it BOUNDS the expensive one: nothing on
398
- // no remote by reachability means nothing on no remote by patch either, since a rewritten
399
- // commit is still a commit that reachability would have found.
400
- //
401
- // **`rev-list --max-count=1`, not `log --oneline`, and the difference is a fail-open.** This
402
- // asks only whether the set is EMPTY, but `log --oneline` answers by printing one line per
403
- // commit — and a long-lived checkout on a rebase-merged branch is exactly where that set gets
404
- // large, which is the situation this whole signal exists for. Past whatever `maxBuffer` is in
405
- // force, `execFileSync` throws `ENOBUFS` (measured against node's 1 MiB default, which is what
406
- // this ran on when the defect was found), the outer catch below turns that into `return false`,
407
- // and the handoff check disables itself on the repositories most likely to need it.
408
- //
409
- // **The raised `maxBuffer` above does not make this redundant, and the order matters.** That
410
- // raise moves the cliff; it does not remove one, because every buffer is finite and the set
411
- // this probe asks about has no bound. Bounding the QUESTION is what removes the failure: one
412
- // hash cannot outgrow any buffer. The raise is defence in depth for `git cherry`, which must
413
- // print a line per compared commit and so cannot be bounded the same way. Copilot, round 3;
414
- // 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.
415
230
  if (git(["rev-list", "--max-count=1", "HEAD", "--not", "--remotes"]).trim() === "") return false;
416
231
 
417
- // No remote at all: every commit qualifies, which is the correct reading there and is what
418
- // this signal has always said. Documented rather than degraded, so it reports nothing.
419
232
  const remotes = git(["remote"]).trim();
420
233
  if (remotes === "") return true;
421
234
 
422
- // A base to compare patches against: the remote's own recorded default head, never a branch
423
- // picked by name. `./init.mjs`'s `delete-a-remote-branch` reason settles that — *"a wrong base
424
- // answers no-unique-work about a question nobody asked"* — and `main` is not a name this file
425
- // may assume, for the same reason signal 2 above stopped hard-coding `origin/main`.
426
- // **ONE ordering, used by both the base selection and the sentence that reports its failure.**
427
- // `git remote` lists alphabetically, so a repository carrying `backup` beside `origin` offered
428
- // `backup/HEAD` first — and this loop takes the first that resolves, which means comparing
429
- // patch-ids against a non-canonical remote and reaching a wrong allow/block, not merely a
430
- // clumsy message. `origin` is the conventional canonical remote and is preferred where it
431
- // exists. Written as one list rather than two matching literals because the first version of
432
- // this preferred `origin` in the DEGRADED SENTENCE only, leaving the verdict itself on the
433
- // alphabetical remote — a fix that repaired the report of a defect and not the defect.
434
- // Copilot, round 3.
235
+ // A remote's recorded default head, never a branch picked by name; `origin` first, as `git remote` sorts by name.
435
236
  let base = null;
436
237
  const listed = remotes.split("\n").filter(Boolean);
437
238
  const named = listed.includes("origin") ? ["origin", ...listed.filter((r) => r !== "origin")] : listed;
@@ -440,25 +241,11 @@ function didWork(root = REPO) {
440
241
  base = git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", `${remote}/HEAD`]).trim() || null;
441
242
  if (base) break;
442
243
  } catch {
443
- // This remote records no default head. Try the next one.
244
+ // This remote records no default head.
444
245
  }
445
246
  }
446
247
 
447
- // **The refinement failed, so the coarse reading stands — and it stands as TRUE.**
448
- //
449
- // This is the fail-CLOSED direction and it is chosen rather than defaulted into. Commits on
450
- // no remote is an established fact by the time we are here; only the attempt to excuse them
451
- // as already-upstream could not be made. Reading that as *no work* would newly convert a
452
- // case that blocks today into a pass — a relaxation, and this gate's own refusal message
453
- // says relaxing a check is the change to scrutinise hardest. The asymmetry decides it: a
454
- // wrong block is capped at MAX_BLOCKS and speaks, a wrong pass is unbounded and silent.
455
- // `../.portulan/memory/verify-preconditions-fail-closed.md` is cited in this file's header
456
- // for exactly this direction.
457
- // The advice NAMES A COMMAND THE READER CAN RUN. The first draft always said
458
- // `git cherry <remote>/HEAD HEAD` — but the commonest way into this branch is that
459
- // `<remote>/HEAD` does not resolve, so the suggested check failed for the very reason the
460
- // refinement did. Telling someone to re-run the thing that just broke is a diagnosis-shaped
461
- // 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.
462
249
  const degraded = (why, check) => {
463
250
  process.stderr.write(
464
251
  `portulan stop-gate: could not compare by patch-id (${why}), so commits on no remote are ` +
@@ -468,12 +255,6 @@ function didWork(root = REPO) {
468
255
  return true;
469
256
  };
470
257
  if (!base) {
471
- // **The remotes are NAMED, because this runner already knows them** — it just tried every
472
- // one. A `<remote>` placeholder in a multi-remote repository invites the reader to run the
473
- // repair against whichever they think of first, which is how a diagnosis sends someone to
474
- // the wrong ref. Copilot, round 3. `named` is already ordered `origin`-first above, so the
475
- // remote this repairs is the same one the loop would have compared against — the two
476
- // cannot disagree, which is why the ordering is computed once.
477
258
  const which = named[0];
478
259
  return degraded(
479
260
  `no remote records a default head (tried \`${named.join("`, `")}\`)`,
@@ -482,11 +263,7 @@ function didWork(root = REPO) {
482
263
  );
483
264
  }
484
265
 
485
- // **Exit 0 AND zero `+` lines, both required.** `git cherry` against an unknown ref exits 128
486
- // and prints NOTHING, so counting `+` alone reads a failed command as *nothing unmerged* —
487
- // measured, and already recorded in `../.portulan/gate-map.md` and `./init.mjs`'s
488
- // `delete-a-remote-branch` reason as a fail-open closed once before. The exit half is carried
489
- // 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.
490
267
  let cherry;
491
268
  try {
492
269
  cherry = git(["cherry", base, "HEAD"]);
@@ -495,52 +272,51 @@ function didWork(root = REPO) {
495
272
  }
496
273
  return cherry.split("\n").some((line) => line.startsWith("+"));
497
274
  } catch {
498
- // Genuinely cannot tell. Say so rather than inventing an obligation OR silently dropping one:
499
- // a gate that disables itself without a word is the failure this whole runner exists against.
500
275
  process.stderr.write("portulan stop-gate: cannot determine whether this session did work — the handoff check is not running.\n");
501
276
  return false;
502
277
  }
503
278
  }
504
279
 
505
- /** Is there a handoff dated today? The doctrine's checkable form: existence and a date, never length. */
506
- 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;
507
283
  try {
508
- 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"));
509
285
  } catch {
510
- 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);
511
310
  }
311
+ return { own, recorded };
512
312
  }
513
313
 
514
- /**
515
- * WHICH TREE this gate just answered about — the working copy and the branch it is on.
516
- *
517
- * `handoffToday()` reads the directory under `WORKSPACE`, which is derived from `PROJECT`, which is
518
- * whatever the host told this hook or whatever the cwd happens to be. That is deliberate — this file
519
- * is TOLD its root rather than deriving one, for the reason its header gives — but it means the gate
520
- * can ask a question about one tree and answer it from another. Measured 2026-08-10: a session whose
521
- * worktree had been removed had its cwd fall back to the origin repository, parked on an unrelated
522
- * branch 84 commits behind, and was told *"no handoff dated 2026-08-10"* while five sat on `main`.
523
- *
524
- * **Since #220's second arm the gate DOES rescope, and this names whichever tree answered.** An
525
- * earlier version of this paragraph argued that naming was sufficient because a removed worktree
526
- * cannot be read. That held for the one stop after the removal and failed as an argument about the
527
- * arm: for the whole of that session's working phase both trees existed and differed, so every stop
528
- * before the removal was already answering about the wrong one. The removal exposed the divergence
529
- * rather than creating it. `resolveSessionTree` above now picks the session's tree from the Stop
530
- * payload's `cwd` when it belongs to this repository, and falls back here — told root, named — only
531
- * where it cannot.
532
- */
533
314
  function treeIdentity(root = REPO) {
534
315
  try {
535
316
  const run = (args) => execFileSync("git", args, {
536
317
  cwd: root, encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"],
537
318
  }).trim();
538
319
  const branch = run(["rev-parse", "--abbrev-ref", "HEAD"]);
539
- // **A DETACHED HEAD is the common case here, not the exotic one.** `--abbrev-ref` answers the
540
- // literal string `HEAD` when no branch is checked out, and this repository routinely has several
541
- // detached worktrees at once — measured: four, on the machine this was written on. Printing
542
- // *"on `HEAD`"* would name a branch that does not exist, in the very sentence added so a reader
543
- // could identify the tree. The commit is what identifies a detached one. Copilot, round 3.
544
320
  if (branch === "HEAD") return `${root} (detached at \`${run(["rev-parse", "--short", "HEAD"])}\`)`;
545
321
  return `${root} (on \`${branch}\`)`;
546
322
  } catch {
@@ -548,62 +324,30 @@ function treeIdentity(root = REPO) {
548
324
  }
549
325
  }
550
326
 
551
- /**
552
- * A handoff dated today somewhere in this repository's history — every ref already on disk — or null.
553
- *
554
- * **No network, deliberately.** A Stop hook runs on every attempt to end a turn, and a fetch would
555
- * put a remote round trip — and an offline host's timeout — inside the gate. So this reads refs that
556
- * are already on disk — `--all` is every ref this repository carries, local branches included, not only
557
- * remote-tracking ones. The sentence it feeds must not overclaim: *absent from this working tree* is
558
- * always true when it fires; *present at `<ref>`* is claimed only where some ref on disk shows one.
559
- */
560
- function handoffInHistory(stamp, tree = { root: REPO, workspace: WORKSPACE }) {
561
- // **Root and workspace arrive as ONE pair, never mixed.** A told root beside a session workspace
562
- // makes the relative path below start with `..`, the guard returns null, and the elsewhere arm
563
- // 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 = []) {
564
329
  try {
565
- // **Separators normalised to `/`, because this becomes a git PATHSPEC.** `path.relative` yields
566
- // `\` on Windows and git would match nothing — and the failure is silent: this returns null,
567
- // the refusal loses the half that tells a reader their handoff is already recorded, and nothing
568
- // says why. The same spelling is used wherever this repository hands git a derived path
569
- // (`./librarian.mjs`, `./compile.mjs`). Copilot, round 3.
570
- const dir = path.relative(tree.root, path.join(tree.workspace, "handoffs")).split(path.sep).join("/");
571
- // A workspace outside the repository is not a question git can answer about this history.
572
- 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;
573
333
  const git = (args) => execFileSync("git", args, { cwd: tree.root, encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"] });
574
- // `--all` is every ref already on disk, local and remote-tracking. `-1` because existence is
575
- // the whole question — this is the doctrine's checkable form, not a census.
576
- // **`:(glob)` magic, because a bare `*` is not reliably a glob.** With `GIT_NOGLOB_PATHSPECS`
577
- // set, git reads `*` literally, this matches nothing, and the function returns null — silently
578
- // reintroducing the exact "a handoff exists elsewhere and the gate does not say so" gap this
579
- // arm exists to close. Measured on git 2.50.1: bare pattern matches 1 normally and **0** under
580
- // `GIT_NOGLOB_PATHSPECS=1`; with `:(glob)` it matches 1 under both. _(The neighbouring lever
581
- // `core.globPathspec=false` does NOT reproduce it — measured — so the magic is what makes this
582
- // deterministic, not a config default.)_ Explicit pathspec magic is the same discipline
583
- // `../.portulan/verify/plugin.sh` documents. Copilot, round 3.
584
- 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();
585
337
  if (!commit) return null;
586
338
  const refs = git(["branch", "--all", "--contains", commit, "--format=%(refname:short)"])
587
339
  .split("\n").map((r) => r.trim()).filter(Boolean);
588
340
  return { commit: commit.slice(0, 7), ref: refs[0] ?? null };
589
341
  } catch {
590
- // Cannot look. Say nothing rather than claim an absence — the refusal below is already
591
- // correct without this sentence, and a false "nowhere in history either" would be worse
592
- // than silence.
593
342
  return null;
594
343
  }
595
344
  }
596
345
 
597
- /**
598
- * Everything this gate refuses a stop for, plus whether the governing recipe was observed GREEN.
599
- * The two are reported separately because the consecutive-cap reset keys off the recipe alone, while
600
- * whether to block keys off the whole set.
601
- */
602
346
  function collectProblems(tree = { root: REPO, workspace: WORKSPACE, origin: "told", note: null }) {
603
- /** Every problem carries the REASON it belongs to — the key its own counter is kept under. */
604
347
  const problems = [];
605
348
  let recipeGreen = false;
606
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.
607
351
  const recipe = defaultRecipe();
608
352
  if (!recipe) {
609
353
  problems.push({
@@ -613,12 +357,6 @@ function collectProblems(tree = { root: REPO, workspace: WORKSPACE, origin: "tol
613
357
  "verified this work. That is 'could not run', which blocks exactly as red does.",
614
358
  });
615
359
  } else {
616
- // `bash -c` with the command as one string, deliberately: the manifest declares a *command*,
617
- // not a script path — `spec/slots.md` says so and the CI workflow runs each recipe the same
618
- // way. Passing argv instead would make the Stop-gate execute recipes differently from CI,
619
- // which trades a small quoting surface for a much worse property: two runners disagreeing
620
- // about what a recipe means. The real defect the review found was the exit-code reading
621
- // below, and that is fixed rather than worked around.
622
360
  try {
623
361
  execFileSync("bash", ["-c", recipe.run], {
624
362
  cwd: REPO,
@@ -630,24 +368,10 @@ function collectProblems(tree = { root: REPO, workspace: WORKSPACE, origin: "tol
630
368
  } catch (error) {
631
369
  const code = error.status;
632
370
  const output = `${error.stdout ?? ""}${error.stderr ?? ""}`.trim().split("\n").slice(-25).join("\n");
633
- // Exit 2 gets its OWN outcome, not folded into red. The recipes distinguish "ran and
634
- // failed" from "could not run" precisely so that neither is mistaken for the other, and a
635
- // runner that flattened them would either fail open (2 read as pass) or manufacture false
636
- // reds (2 read as a verdict about the tree). Both block; they say different things.
637
- // Exit 2 gets its OWN outcome, and so do the shell's "cannot execute" codes. 126 is
638
- // "found but not executable", 127 is "not found", and a spawn failure has no status at
639
- // all — none of the three is a verdict about the repository, and calling any of them RED
640
- // would report a judgement nobody reached. That is the same laundering the recipes'
641
- // three-code contract exists to prevent, one level up, and it was reaching the gate that
642
- // contract is *for*. Found by review on the pull request.
643
371
  const CANNOT_RUN = new Set([2, 126, 127]);
644
372
  const outcome = CANNOT_RUN.has(code) || code === undefined || code === null
645
373
  ? `could not run (exit ${code ?? "no status"}) — the gate could not judge`
646
374
  : `RED (exit ${code})`;
647
- // The tree is named here too, now that one verdict can span two: the recipe is always run
648
- // in the TOLD root (see the header) while the handoff question may have been answered
649
- // about the session's. Without this, two problems in one refusal would read as though
650
- // both concerned the same tree. Raised as optional at the session-open checkpoint; taken.
651
375
  problems.push({
652
376
  reason: "recipe",
653
377
  text: `verify recipe \`${recipe.id}\` in ${REPO} — ${outcome}\n${output}`,
@@ -655,34 +379,11 @@ function collectProblems(tree = { root: REPO, workspace: WORKSPACE, origin: "tol
655
379
  }
656
380
  }
657
381
 
658
- // **ONE date per Stop event, captured once and threaded through every use.** There were four
659
- // independent `today()` calls on this path — the tree check, the history query, and both halves of
660
- // the sentence. A stop spanning local midnight (or a host clock that jumps) could therefore CHECK
661
- // one date and REPORT another, and worse, search history for a date nobody checked the tree for.
662
- // `today()`'s own header records that this file has already produced one false red from a date
663
- // disagreement; two dates inside a single verdict is the same class. Copilot, round 3.
664
382
  const stamp = today();
665
- const handoffPresent = handoffToday(stamp, tree.workspace);
666
- // **`!handoffPresent` first, and the order is load-bearing rather than stylistic.** `didWork()`
667
- // shells out to git several times and can print the could-not-compare sentence; ordered the other
668
- // way, a session that HAS written its handoff still paid that cost on every Stop event and could
669
- // 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.
670
386
  if (!handoffPresent && didWork(tree.root)) {
671
- // The tree is NAMED, because the sentence was once true about a tree the reader was not
672
- // thinking of and read as a verdict about their session (#220).
673
- //
674
- // **A REFUSAL names both trees where they differ**, because a reader looking at the hook's
675
- // configured root would otherwise not find the tree this verdict came from. Only on a refusal:
676
- // told ≠ session is the routine worktree case here, so a divergence line on every green stop
677
- // would be a metronome, and this gate's sentences are worth reading precisely because they are
678
- // rare (#220 second arm).
679
- // **Both sides resolved through `realpathSync`, or this sentence invents a divergence.**
680
- // `resolveSessionTree` returns a realpathed root, so comparing it against a merely
681
- // `path.resolve`d `REPO` makes a symlinked told root — `/tmp` on macOS, or any convenience
682
- // link — read as a second tree, and the refusal then names one directory twice as though the
683
- // session had worked somewhere else. Measured. It is the same trap the same-repository guard
684
- // above already defends against, left unapplied one comparison away, which is why the guard's
685
- // own reasoning is repeated here rather than assumed to carry.
686
387
  const real = (dir) => {
687
388
  try {
688
389
  return fs.realpathSync(dir);
@@ -693,55 +394,45 @@ function collectProblems(tree = { root: REPO, workspace: WORKSPACE, origin: "tol
693
394
  const answeredElsewhere = tree.origin === "session" && real(tree.root) !== real(REPO)
694
395
  ? ` — the tree this session worked in, not the one this hook was told (${REPO})`
695
396
  : "";
696
- const elsewhere = handoffInHistory(stamp, tree);
397
+ const { recorded } = handoffs;
398
+ const elsewhere = handoffInHistory(stamp, tree, recorded);
697
399
  const found = elsewhere
698
400
  ? ` One dated ${stamp} does exist elsewhere in this repository's refs, at ` +
699
401
  `${elsewhere.commit}${elsewhere.ref ? ` on \`${elsewhere.ref}\`` : ""} — so this working tree may not be ` +
700
- "the tree that did the work. Check before writing a second one: a duplicate handoff reds `docs.sh`'s " +
701
- "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. `
702
407
  : "";
703
408
  problems.push({
704
409
  reason: "handoff",
705
410
  text:
706
- `no handoff dated ${stamp} in ${path.join(tree.workspace, "handoffs")}, read from ` +
707
- `${treeIdentity(tree.root)}${answeredElsewhere}. ` +
708
- "Every session ends with a dated handoff — five lines is enough, absent is not. The Session log " +
709
- "records what landed; the handoff records why, and the why is the part the next session cannot " +
710
- `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}`,
711
416
  });
712
417
  }
713
418
 
714
- return { problems, recipeGreen, handoffPresent };
419
+ return { problems, recipeGreen };
715
420
  }
716
421
 
717
- /**
718
- * The gate's arithmetic, as a pure function — the part that decides whether this gate can be talked
719
- * past, or can wrongly refuse. Exported because both directions need testing and neither is
720
- * testable through a hook that calls `process.exit`.
721
- *
722
- * Three outcomes, deliberately distinct:
723
- * allow — nothing is wrong. A green stop is FREE: it must never consume the budget, or an
724
- * ordinary session spends its cap on good turns and a real red walks through afterwards.
725
- * block — refuse this stop, and charge it.
726
- * release — the cap is spent. The session may end, and the record must say RED rather than done.
727
- */
728
- export function verdict({ problems, counts = {}, total = 0, max = MAX_BLOCKS, maxTotal = MAX_TOTAL_BLOCKS }) {
422
+ export function verdict({ problems, counts = {}, total = 0, chain = 0, max = MAX_BLOCKS, maxTotal = MAX_TOTAL_BLOCKS, maxChain = MAX_CHAIN_BLOCKS }) {
729
423
  if (problems.length === 0) return { action: "allow" };
730
424
 
731
425
  const text = problems.map((p) => p.text).join("\n\n");
732
- // Only a reason that is wrong RIGHT NOW may release the gate. A cleared reason's counter is
733
- // zeroed, so this never remembers that the recipe was once red four times and lets every later
734
- // stop through on the strength of a problem that no longer exists.
735
426
  const spent = problems.map((p) => p.reason).filter((reason) => (counts[reason] ?? 0) > max);
736
427
 
737
- if (spent.length || total > maxTotal) {
738
- // Name the bound that actually released it. Saying "cap of 3" after nine refusals with a
739
- // per-reason count of 1 is false, and it is the same defect already fixed once in this very
740
- // message — a release that misreports why it happened sends a reader to the wrong constant.
741
- // Per-reason counters add one more way to be wrong: naming the cap without naming whose.
428
+ if (spent.length || total > maxTotal || chain > maxChain) {
742
429
  const bound = spent.length
743
430
  ? `the cap of ${max} consecutive refusals for \`${spent.join("`, `")}\` was reached`
744
- : `the absolute ceiling of ${maxTotal} refusals was reached (no single reason had reached its cap of ${max})`;
431
+ : total > maxTotal
432
+ ? `the absolute ceiling of ${maxTotal} refusals was reached (no single reason had reached its cap of ${max})`
433
+ : `${chain} consecutive hook-provoked stops were seen in this tree, past the chain bound of ${maxChain} — ` +
434
+ `the per-session caps never fired, which means this host issued a new session id per retry and their ` +
435
+ `counters could not see their own history`;
745
436
  return {
746
437
  action: "release",
747
438
  message:
@@ -752,8 +443,6 @@ export function verdict({ problems, counts = {}, total = 0, max = MAX_BLOCKS, ma
752
443
  };
753
444
  }
754
445
 
755
- // The counts shown are per reason, because that is what the caps now are. A single "2/3" would
756
- // be a number no constant in this file corresponds to the moment two reasons are live.
757
446
  const tally = problems.map((p) => `${p.reason} ${counts[p.reason] ?? 0}/${max}`).join(", ");
758
447
  return {
759
448
  action: "block",
@@ -769,42 +458,25 @@ function main() {
769
458
  try {
770
459
  payload = JSON.parse(fs.readFileSync(0, "utf8"));
771
460
  } catch {
772
- // No payload means no session id, so the counter cannot be trusted — and a gate that cannot
773
- // 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.
774
462
  allow();
775
463
  }
776
464
 
777
465
  const sessionId = payload.session_id ?? "unknown";
778
-
779
- // **Resolved ONCE, here, and threaded down** — so every question this stop asks is about the same
780
- // tree, and the sentence that reports the answer names that tree rather than another one.
781
466
  const tree = resolveSessionTree(payload.cwd);
782
-
783
- // **Degradation is spoken when the datum was offered and could not be used, and silent when it was
784
- // never offered.** A host that sends no `cwd` has degraded nothing: it gets today's behaviour byte
785
- // for byte, which is also what makes every pre-existing case a control for this change. A host that
786
- // sent one this gate could not use is a different fact, and one a reader needs — printed whatever
787
- // the verdict turns out to be, because it explains an allow just as much as a refusal.
788
- //
789
- // **The honest residue: on this path the silent-allow gap of #220 REMAINS, by construction.** The
790
- // told root answers, and if the session's real tree carries unrecorded work this gate cannot see
791
- // it. Forcing a block instead would manufacture an obligation from no evidence and leave the
792
- // removed-worktree case permanently blocking — the false red the first arm was built to remove.
793
467
  if (tree.note) process.stderr.write(`portulan stop-gate: ${tree.note}; answering about ${REPO} instead.\n`);
794
468
 
795
- const { problems, recipeGreen, handoffPresent } = collectProblems(tree);
469
+ const { problems, recipeGreen } = collectProblems(tree);
796
470
 
797
- // A reason's condition clearing ends THAT reason's futile-retry episode, whether or not the stop
798
- // is allowed, and never any other reason's. Done before the bump so a cleared reason cannot be
799
- // 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.
800
472
  if (recipeGreen) clearReason(sessionId, "recipe");
801
- if (handoffPresent) clearReason(sessionId, "handoff");
473
+ if (!problems.some((p) => p.reason === "handoff")) clearReason(sessionId, "handoff");
802
474
 
803
- // Charged only when this gate is actually about to refuse. A green stop costs nothing.
804
475
  const state = problems.length === 0
805
476
  ? { counts: {}, total: 0 }
806
477
  : bumpCount(sessionId, [...new Set(problems.map((p) => p.reason))]);
807
- const result = verdict({ problems, counts: state.counts, total: state.total });
478
+ const chain = bumpChain(problems.length > 0 && payload.stop_hook_active === true, os.tmpdir(), tree.root);
479
+ const result = verdict({ problems, counts: state.counts, total: state.total, chain });
808
480
 
809
481
  if (result.action === "allow") allow();
810
482
  if (result.action === "release") {