@sleepy_panda_srl/portulan 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- package/cli/README.md +142 -291
- package/cli/advisory.mjs +354 -0
- package/cli/comments.mjs +932 -0
- package/cli/compile.mjs +1443 -1210
- package/cli/context.mjs +883 -0
- package/cli/control-chars.mjs +12 -301
- package/cli/discover.mjs +17 -574
- package/cli/doctor.mjs +225 -1071
- package/cli/drills.mjs +1107 -0
- package/cli/feedback.mjs +16 -237
- package/cli/finish.mjs +437 -0
- package/cli/form.mjs +644 -0
- package/cli/fuzz-shell.mjs +595 -0
- package/cli/gate.mjs +7 -198
- package/cli/goldens.mjs +311 -0
- package/cli/index.mjs +305 -655
- package/cli/init.mjs +244 -564
- package/cli/inside.mjs +3 -28
- package/cli/instructions.mjs +829 -0
- package/cli/ledger.mjs +778 -0
- package/cli/librarian.mjs +135 -599
- package/cli/manifest.mjs +2 -39
- package/cli/mutants.mjs +764 -0
- package/cli/new.mjs +12 -243
- package/cli/pack-identity.mjs +6 -68
- package/cli/pack-version.mjs +13 -248
- package/cli/plugin-lint.mjs +42 -658
- package/cli/portulan.mjs +5 -124
- package/cli/recipe-set.mjs +16 -240
- package/cli/release-eval.mjs +748 -0
- package/cli/review-meter.mjs +500 -0
- package/cli/rule-carriers.mjs +12 -175
- package/cli/sessions.mjs +82 -0
- package/cli/skill-goldens.mjs +424 -0
- package/cli/skills-set.mjs +12 -414
- package/cli/stop-gate.mjs +126 -454
- package/cli/symbols.mjs +1132 -0
- package/cli/telemetry.mjs +582 -0
- package/cli/upgrade.mjs +171 -314
- package/cli/vendor.mjs +146 -476
- package/cli/version-carriers.mjs +9 -74
- package/core/README.md +1 -1
- package/core/engine.md +3 -9
- package/core/operating/autonomy.md +1 -1
- package/core/operating/context.md +183 -0
- package/core/operating/evolution.md +3 -3
- package/core/operating/loop.md +13 -20
- package/core/operating/memory.md +12 -3
- package/core/operating/sessions.md +100 -0
- package/core/personas/librarian.md +2 -2
- package/core/skills/consolidate/SKILL.md +4 -2
- package/core/templates/gate-policy.md +27 -8
- package/core/templates/handoff.md +28 -15
- package/core/templates/memory-entry.md +1 -0
- package/evals/README.md +320 -0
- package/evals/releases/0.1.3.json +134 -0
- package/evals/releases/0.1.3.md +64 -0
- package/evals/releases/0.2.0.json +146 -0
- package/evals/releases/0.2.0.md +67 -0
- package/evals/releases/README.md +32 -0
- package/package.json +9 -2
- package/packs/rituals/checkpoints/README.md +9 -2
- package/packs/rituals/checkpoints/pack.json +1 -1
- package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +32 -2
- package/packs/tools/github/verify/actions-pinned.sh +10 -55
- package/spec/README.md +112 -6
- package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
- package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
- package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
- package/spec/migrations/0004-changelog-fragments.mjs +53 -0
- package/spec/migrations/0005-session-log-retired.mjs +69 -0
- package/spec/migrations/0006-boot-card.mjs +77 -0
- package/spec/migrations/0007-guidance-compiled.mjs +31 -0
- package/spec/migrations/0008-card-reading.mjs +55 -0
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
- package/spec/migrations/0010-comment-rail.mjs +57 -0
- package/spec/migrations/README.md +45 -10
- package/spec/pack.schema.json +1 -1
- package/spec/slots.md +359 -23
- package/spec/workspace.schema.json +145 -4
package/cli/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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
174
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
224
|
+
// No upstream, as on a branch never pushed.
|
|
379
225
|
}
|
|
380
226
|
|
|
381
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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.
|
|
244
|
+
// This remote records no default head.
|
|
444
245
|
}
|
|
445
246
|
}
|
|
446
247
|
|
|
447
|
-
//
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
506
|
-
function handoffToday(stamp,
|
|
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
|
-
|
|
284
|
+
dated = fs.readdirSync(path.join(tree.workspace, "handoffs")).filter((f) => f.startsWith(stamp) && f.endsWith(".md"));
|
|
509
285
|
} catch {
|
|
510
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
//
|
|
575
|
-
|
|
576
|
-
|
|
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
|
|
666
|
-
|
|
667
|
-
//
|
|
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
|
|
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
|
|
701
|
-
|
|
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
|
-
"
|
|
709
|
-
"
|
|
710
|
-
`
|
|
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
|
|
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
|
-
:
|
|
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
|
-
//
|
|
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
|
|
469
|
+
const { problems, recipeGreen } = collectProblems(tree);
|
|
796
470
|
|
|
797
|
-
//
|
|
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 (
|
|
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
|
|
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") {
|