@sleepy_panda_srl/portulan 0.1.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/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +87 -0
- package/cli/README.md +337 -0
- package/cli/compile.mjs +2209 -0
- package/cli/control-chars.mjs +570 -0
- package/cli/discover.mjs +985 -0
- package/cli/doctor.mjs +2803 -0
- package/cli/feedback.mjs +970 -0
- package/cli/gate.mjs +271 -0
- package/cli/index.mjs +1390 -0
- package/cli/init.mjs +1729 -0
- package/cli/librarian.mjs +1384 -0
- package/cli/manifest.mjs +59 -0
- package/cli/new.mjs +803 -0
- package/cli/pack-version.mjs +543 -0
- package/cli/plugin-lint.mjs +1566 -0
- package/cli/portulan.mjs +266 -0
- package/cli/recipe-set.mjs +425 -0
- package/cli/rule-carriers.mjs +489 -0
- package/cli/skills-set.mjs +715 -0
- package/cli/stop-gate.mjs +434 -0
- package/cli/upgrade.mjs +897 -0
- package/cli/vendor.mjs +1504 -0
- package/core/README.md +46 -0
- package/core/engine.md +47 -0
- package/core/operating/autonomy.md +100 -0
- package/core/operating/evolution.md +84 -0
- package/core/operating/loop.md +83 -0
- package/core/operating/memory.md +161 -0
- package/core/operating/safety.md +42 -0
- package/core/operating/verification.md +76 -0
- package/core/personas/README.md +40 -0
- package/core/personas/implementer.md +51 -0
- package/core/personas/librarian.md +73 -0
- package/core/personas/reviewer.md +52 -0
- package/core/skills/README.md +52 -0
- package/core/skills/clarify/SKILL.md +42 -0
- package/core/skills/codify/SKILL.md +65 -0
- package/core/skills/consolidate/SKILL.md +121 -0
- package/core/templates/gate-policy.md +76 -0
- package/core/templates/handoff.md +29 -0
- package/core/templates/memory-entry.md +44 -0
- package/core/templates/pack.md +94 -0
- package/core/templates/persona.md +69 -0
- package/core/templates/proposal.md +27 -0
- package/core/templates/repo-card.md +28 -0
- package/core/templates/skill.md +57 -0
- package/core/templates/task.md +26 -0
- package/core/templates/workspace.md +81 -0
- package/package.json +41 -0
- package/packs/.claude-plugin/plugin.json +22 -0
- package/packs/README.md +61 -0
- package/packs/rituals/README.md +13 -0
- package/packs/rituals/checkpoints/README.md +105 -0
- package/packs/rituals/checkpoints/pack.json +36 -0
- package/packs/rituals/checkpoints/personas/supervisor.md +64 -0
- package/packs/rituals/checkpoints/skills/milestone-close/SKILL.md +75 -0
- package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +75 -0
- package/packs/rituals/checkpoints/skills/session-open/SKILL.md +57 -0
- package/packs/stacks/README.md +8 -0
- package/packs/tools/README.md +17 -0
- package/packs/tools/github/README.md +65 -0
- package/packs/tools/github/pack.json +29 -0
- package/packs/tools/github/verify/README.md +51 -0
- package/packs/tools/github/verify/actions-pinned.sh +132 -0
- package/spec/README.md +399 -0
- package/spec/migrations/0001-repository-declares-its-tree.mjs +100 -0
- package/spec/migrations/0002-bundle-fallback-path.mjs +179 -0
- package/spec/migrations/README.md +95 -0
- package/spec/pack.schema.json +201 -0
- package/spec/slots.md +796 -0
- package/spec/workspace.schema.json +438 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The Stop-gate runner — "done" is not a thing an agent may simply declare.
|
|
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).
|
|
34
|
+
|
|
35
|
+
import fs from "node:fs";
|
|
36
|
+
import os from "node:os";
|
|
37
|
+
import path from "node:path";
|
|
38
|
+
import { execFileSync } from "node:child_process";
|
|
39
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
40
|
+
|
|
41
|
+
import { recipeSet } from "./recipe-set.mjs";
|
|
42
|
+
|
|
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
|
+
const PROJECT = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
47
|
+
const WORKSPACE = path.resolve(PROJECT, process.env.PORTULAN_WORKSPACE || ".portulan");
|
|
48
|
+
const REPO = PROJECT;
|
|
49
|
+
|
|
50
|
+
// The independent reasons this gate refuses a stop for. Each keeps its own consecutive count.
|
|
51
|
+
//
|
|
52
|
+
// Named as data rather than left implicit in the code, because the counters, the reset rule and the
|
|
53
|
+
// release message all have to agree about what a reason *is* — and three implicit lists is how the
|
|
54
|
+
// single-counter version came to reset one reason on another reason's evidence.
|
|
55
|
+
export const REASONS = ["recipe", "handoff"];
|
|
56
|
+
|
|
57
|
+
// How many CONSECUTIVE refusals **for one reason** a session may accumulate before the gate lets it
|
|
58
|
+
// end. Bounded iteration is already doctrine — ../core/operating/loop.md carries the
|
|
59
|
+
// runbook/iteration-cap row — and a Stop-gate that cannot stop is not a gate, it is a hang.
|
|
60
|
+
//
|
|
61
|
+
// **Consecutive, and a reason's count clears only when THAT reason clears** (maintainer's ruling,
|
|
62
|
+
// 2026-07-27, generalised in ../tasks/0007-per-reason-stop-gate-counters.md). The cap is aimed at the
|
|
63
|
+
// futile-retry episode — the same failure the Ralph Wiggum row names — not at long honest sessions. A
|
|
64
|
+
// session that hits three unrelated reds and properly fixes each one should not be taxed for having
|
|
65
|
+
// done the work; accumulating across a whole session would be ceremony that cannot scale down, which
|
|
66
|
+
// is a binding non-goal.
|
|
67
|
+
//
|
|
68
|
+
// Session 0 implemented the reset literally, keyed off the recipe alone, and that made the gate
|
|
69
|
+
// unbounded: a green recipe beside a missing handoff cleared the count on every attempt. It also left
|
|
70
|
+
// an asymmetry nobody chose — a missing five-line handoff rode to nine while a failing suite got
|
|
71
|
+
// three. Per-reason counters remove both by construction.
|
|
72
|
+
export const MAX_BLOCKS = 3;
|
|
73
|
+
|
|
74
|
+
// An absolute ceiling on refusals per session, which does NOT reset. It was introduced to close the
|
|
75
|
+
// hang above and it survives that hang's removal, as the backstop that guarantees the gate can always
|
|
76
|
+
// stop whatever the per-reason counts do — including for a reason added later that nobody remembered
|
|
77
|
+
// to give a clearing condition. Counts REFUSALS, not reasons: a stop refused for two reasons is one
|
|
78
|
+
// refusal, or a two-reason session would reach the ceiling in half the attempts.
|
|
79
|
+
export const MAX_TOTAL_BLOCKS = 9;
|
|
80
|
+
|
|
81
|
+
const RECIPE_TIMEOUT_MS = 90_000;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Where one session's counter lives. Keyed by session id **and** by the working tree, because
|
|
85
|
+
* `worktree-local` is a requirement rather than an accident: several worktrees of this repository are
|
|
86
|
+
* routinely checked out at once, and two sessions sharing a counter would let one disarm the other's
|
|
87
|
+
* gate. Untracked, in the OS temp dir — this is session state, not repository state.
|
|
88
|
+
*/
|
|
89
|
+
function counterFile(sessionId, dir, root = REPO) {
|
|
90
|
+
// A short, stable digest. Not security — just enough that two distinct inputs differ.
|
|
91
|
+
const digest = (v) => {
|
|
92
|
+
let h = 0;
|
|
93
|
+
for (const ch of String(v)) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
|
94
|
+
return h.toString(36);
|
|
95
|
+
};
|
|
96
|
+
// The readable part is for a human debugging a stuck counter; the digest is what makes the name
|
|
97
|
+
// unique. Both are needed. Sanitising ALONE is not enough, and the failure is silent: a session id
|
|
98
|
+
// made entirely of characters outside the class sanitises to the empty string, so every such
|
|
99
|
+
// session shares one counter and they charge each other's cap — or release each other early. The
|
|
100
|
+
// same holds for two ids sharing a 60-character prefix. Found by review on the pull request.
|
|
101
|
+
const readable = String(sessionId).replace(/[^a-zA-Z0-9-]/g, "").slice(0, 40);
|
|
102
|
+
return path.join(dir, `portulan-stopgate-${readable}-${digest(sessionId)}-${digest(root)}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The stored shape: one consecutive count per reason, plus the non-resetting refusal total. */
|
|
106
|
+
function readCount(file) {
|
|
107
|
+
let stored = {};
|
|
108
|
+
try {
|
|
109
|
+
stored = JSON.parse(fs.readFileSync(file, "utf8")) ?? {};
|
|
110
|
+
} catch {
|
|
111
|
+
// No counter yet, or an unreadable one. Either way this session has spent nothing — the
|
|
112
|
+
// stricter direction, since a session that cannot read its budget has not used it.
|
|
113
|
+
}
|
|
114
|
+
// Every stored key is carried forward, THEN the known reasons are defaulted to 0. The order and
|
|
115
|
+
// the carry-forward both matter. Filtering by `REASONS` alone dropped any other key, so a reason
|
|
116
|
+
// this file can emit but the constant does not declare would have reset to 0 on every read —
|
|
117
|
+
// never reaching its own cap, and released only by the ceiling of nine. That is precisely the
|
|
118
|
+
// asymmetry task 0007 removed, reintroduced through a drifted list, and silent in the direction
|
|
119
|
+
// of more patience. The constant exists so the counter, the clearing rule and the message agree
|
|
120
|
+
// about what a reason is; it must not be the thing that also decides what may be *counted*.
|
|
121
|
+
// Found by review, in the suppressed half of a Copilot round. The suite additionally binds every
|
|
122
|
+
// reason this file emits to `REASONS`, so drift is red in CI rather than only survivable here.
|
|
123
|
+
const counts = {};
|
|
124
|
+
for (const [reason, value] of Object.entries(stored.counts ?? {})) counts[reason] = Number(value) || 0;
|
|
125
|
+
for (const reason of REASONS) counts[reason] ??= 0;
|
|
126
|
+
return { counts, total: Number(stored.total) || 0 };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Count one refusal against a session. `reasons` is every reason THIS stop was refused for.
|
|
131
|
+
* Returns `{ counts, total }`.
|
|
132
|
+
*
|
|
133
|
+
* Exported for the suite; `dir` and `root` are injectable for the same reason.
|
|
134
|
+
*
|
|
135
|
+
* When the counter cannot be kept at all, this returns numbers ABOVE every cap — for every reason,
|
|
136
|
+
* not only the ones passed in — so the caller lets the session end. That direction is deliberate: an
|
|
137
|
+
* un-capped gate is the one failure here a human cannot escape from inside the session. (The first
|
|
138
|
+
* version returned exactly `MAX_BLOCKS`, which is not above it — so an unwritable temp dir made a red
|
|
139
|
+
* tree block *forever*, the precise opposite of what its own comment claimed. Found at the pre-commit
|
|
140
|
+
* checkpoint, demonstrated against a read-only TMPDIR. Filling only the named reasons would have
|
|
141
|
+
* reintroduced that trap one reason over.)
|
|
142
|
+
*/
|
|
143
|
+
export function bumpCount(sessionId, reasons, dir = os.tmpdir(), root = REPO) {
|
|
144
|
+
const file = counterFile(sessionId, dir, root);
|
|
145
|
+
const now = readCount(file);
|
|
146
|
+
const counts = { ...now.counts };
|
|
147
|
+
for (const reason of reasons) counts[reason] = (counts[reason] ?? 0) + 1;
|
|
148
|
+
// One refusal, however many reasons named it — see MAX_TOTAL_BLOCKS.
|
|
149
|
+
const next = { counts, total: now.total + 1 };
|
|
150
|
+
try {
|
|
151
|
+
fs.writeFileSync(file, JSON.stringify(next));
|
|
152
|
+
} catch {
|
|
153
|
+
const released = {};
|
|
154
|
+
for (const reason of REASONS) released[reason] = MAX_BLOCKS + 1;
|
|
155
|
+
return { counts: released, total: MAX_TOTAL_BLOCKS + 1 };
|
|
156
|
+
}
|
|
157
|
+
return next;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* One reason's condition cleared: its consecutive count goes to zero, and **only** its own.
|
|
162
|
+
*
|
|
163
|
+
* This is the whole of task 0007. The previous version cleared *the* count on an observed green
|
|
164
|
+
* recipe while the gate refused for two reasons — so a missing handoff's patience was restored by
|
|
165
|
+
* evidence about the recipe, which made the gate unbounded in that case and gave a five-line file
|
|
166
|
+
* three times the tolerance of a failing suite.
|
|
167
|
+
*
|
|
168
|
+
* The running total survives on purpose: it is what guarantees the gate can still stop.
|
|
169
|
+
*/
|
|
170
|
+
export function clearReason(sessionId, reason, dir = os.tmpdir(), root = REPO) {
|
|
171
|
+
const file = counterFile(sessionId, dir, root);
|
|
172
|
+
const now = readCount(file);
|
|
173
|
+
if (!now.counts[reason]) return now;
|
|
174
|
+
const next = { counts: { ...now.counts, [reason]: 0 }, total: now.total };
|
|
175
|
+
try {
|
|
176
|
+
fs.writeFileSync(file, JSON.stringify(next));
|
|
177
|
+
} catch {
|
|
178
|
+
// Failing to clear a counter can only make the gate stricter, never weaker. Leave it.
|
|
179
|
+
return now;
|
|
180
|
+
}
|
|
181
|
+
return next;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Today, in the machine's LOCAL timezone — the same clock that names a handoff file. */
|
|
185
|
+
export function today(now = new Date()) {
|
|
186
|
+
// `toISOString()` is UTC, and handoffs are named by local date. Between midnight and 03:00 in
|
|
187
|
+
// the maintainer's timezone the two disagree, and the gate would demand yesterday's date from a
|
|
188
|
+
// session that had just written today's — a false red, which is the failure that gets a rail
|
|
189
|
+
// switched off rather than fixed. Found at the pre-commit checkpoint.
|
|
190
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
191
|
+
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function allow() {
|
|
195
|
+
process.exit(0);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function block(reason) {
|
|
199
|
+
process.stdout.write(`${JSON.stringify({ decision: "block", reason })}\n`);
|
|
200
|
+
process.exit(0);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The workspace's default verify recipe: `{ id, run }`, or null if the manifest cannot say.
|
|
205
|
+
*
|
|
206
|
+
* Reaches `recipeSet()` rather than enumerating `verify.recipes` here, because that enumeration is one
|
|
207
|
+
* rule and this file was one of four places carrying it — see `recipe-set.mjs`'s header for why they
|
|
208
|
+
* were merged and `recipe-set.test.mjs` for the roster pin that keeps them merged.
|
|
209
|
+
*
|
|
210
|
+
* **It asks for the workspace subset deliberately, by passing no packs**, and that is not composition
|
|
211
|
+
* quietly skipped. `verify.default` is a bare slug in `spec/workspace.schema.json` and a composed id is
|
|
212
|
+
* `<category>/<name>:<id>`, so **no composed recipe can ever be the default** — resolving packs here
|
|
213
|
+
* would cost a pack resolution on every Stop event and could not change the answer. What this call
|
|
214
|
+
* still buys is the one definition of a *valid* recipe: a manifest whose default recipe carries an
|
|
215
|
+
* empty or newline-bearing `run` is refused here exactly as CI refuses it, rather than being handed to
|
|
216
|
+
* a shell by a gate that did its own looser check.
|
|
217
|
+
*/
|
|
218
|
+
function defaultRecipe() {
|
|
219
|
+
try {
|
|
220
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(WORKSPACE, "workspace.json"), "utf8"));
|
|
221
|
+
const set = recipeSet(manifest, { packs: [] });
|
|
222
|
+
if (!set.ok) return null;
|
|
223
|
+
const recipe = set.recipes.find((r) => r.id === set.default);
|
|
224
|
+
return recipe ? { id: recipe.id, run: recipe.run } : null;
|
|
225
|
+
} catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Has this session done work? A read-only session owes no handoff.
|
|
232
|
+
*
|
|
233
|
+
* Three signals, tried in order, because the obvious one is not portable. The first version hard-coded
|
|
234
|
+
* `origin/main..HEAD`, which throws in a repository with no `origin`, a differently-named default
|
|
235
|
+
* branch, or an unfetched ref — and the catch turned that throw into `false`, **silently disabling the
|
|
236
|
+
* handoff gate while local commits sat there unrecorded.** A fail-open in the gate, and a quiet one.
|
|
237
|
+
* Found by review on the pull request.
|
|
238
|
+
*/
|
|
239
|
+
function didWork() {
|
|
240
|
+
const git = (args) => execFileSync("git", args, { cwd: REPO, encoding: "utf8", timeout: 10_000 });
|
|
241
|
+
|
|
242
|
+
// 1. Uncommitted changes. Portable, and the strongest signal there is.
|
|
243
|
+
try {
|
|
244
|
+
if (git(["status", "--porcelain"]).trim() !== "") return true;
|
|
245
|
+
} catch {
|
|
246
|
+
// Not a git repository, or git is unusable. Nothing below will work either.
|
|
247
|
+
process.stderr.write("portulan stop-gate: cannot read git status — the handoff check is not running.\n");
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 2. Commits ahead of this branch's configured upstream, whatever it is called.
|
|
252
|
+
try {
|
|
253
|
+
const upstream = git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]).trim();
|
|
254
|
+
if (upstream) return git(["log", "--oneline", `${upstream}..HEAD`]).trim() !== "";
|
|
255
|
+
} catch {
|
|
256
|
+
// No upstream configured. Ordinary for a branch that has never been pushed — fall through.
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 3. Commits that are on no remote at all. Works with no upstream, and with no remote either —
|
|
260
|
+
// in a repository with no remotes every commit qualifies, which is the correct reading there.
|
|
261
|
+
try {
|
|
262
|
+
return git(["log", "--oneline", "HEAD", "--not", "--remotes"]).trim() !== "";
|
|
263
|
+
} catch {
|
|
264
|
+
// Genuinely cannot tell. Say so rather than inventing an obligation OR silently dropping one:
|
|
265
|
+
// a gate that disables itself without a word is the failure this whole runner exists against.
|
|
266
|
+
process.stderr.write("portulan stop-gate: cannot determine whether this session did work — the handoff check is not running.\n");
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Is there a handoff dated today? The doctrine's checkable form: existence and a date, never length. */
|
|
272
|
+
function handoffToday() {
|
|
273
|
+
const stamp = today();
|
|
274
|
+
try {
|
|
275
|
+
return fs.readdirSync(path.join(WORKSPACE, "handoffs")).some((f) => f.startsWith(stamp) && f.endsWith(".md"));
|
|
276
|
+
} catch {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Everything this gate refuses a stop for, plus whether the governing recipe was observed GREEN.
|
|
283
|
+
* The two are reported separately because the consecutive-cap reset keys off the recipe alone, while
|
|
284
|
+
* whether to block keys off the whole set.
|
|
285
|
+
*/
|
|
286
|
+
function collectProblems() {
|
|
287
|
+
/** Every problem carries the REASON it belongs to — the key its own counter is kept under. */
|
|
288
|
+
const problems = [];
|
|
289
|
+
let recipeGreen = false;
|
|
290
|
+
|
|
291
|
+
const recipe = defaultRecipe();
|
|
292
|
+
if (!recipe) {
|
|
293
|
+
problems.push({
|
|
294
|
+
reason: "recipe",
|
|
295
|
+
text:
|
|
296
|
+
"could not read the workspace's default verify recipe from .portulan/workspace.json, so nothing " +
|
|
297
|
+
"verified this work. That is 'could not run', which blocks exactly as red does.",
|
|
298
|
+
});
|
|
299
|
+
} else {
|
|
300
|
+
// `bash -c` with the command as one string, deliberately: the manifest declares a *command*,
|
|
301
|
+
// not a script path — `spec/slots.md` says so and the CI workflow runs each recipe the same
|
|
302
|
+
// way. Passing argv instead would make the Stop-gate execute recipes differently from CI,
|
|
303
|
+
// which trades a small quoting surface for a much worse property: two runners disagreeing
|
|
304
|
+
// about what a recipe means. The real defect the review found was the exit-code reading
|
|
305
|
+
// below, and that is fixed rather than worked around.
|
|
306
|
+
try {
|
|
307
|
+
execFileSync("bash", ["-c", recipe.run], {
|
|
308
|
+
cwd: REPO,
|
|
309
|
+
encoding: "utf8",
|
|
310
|
+
timeout: RECIPE_TIMEOUT_MS,
|
|
311
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
312
|
+
});
|
|
313
|
+
recipeGreen = true;
|
|
314
|
+
} catch (error) {
|
|
315
|
+
const code = error.status;
|
|
316
|
+
const output = `${error.stdout ?? ""}${error.stderr ?? ""}`.trim().split("\n").slice(-25).join("\n");
|
|
317
|
+
// Exit 2 gets its OWN outcome, not folded into red. The recipes distinguish "ran and
|
|
318
|
+
// failed" from "could not run" precisely so that neither is mistaken for the other, and a
|
|
319
|
+
// runner that flattened them would either fail open (2 read as pass) or manufacture false
|
|
320
|
+
// reds (2 read as a verdict about the tree). Both block; they say different things.
|
|
321
|
+
// Exit 2 gets its OWN outcome, and so do the shell's "cannot execute" codes. 126 is
|
|
322
|
+
// "found but not executable", 127 is "not found", and a spawn failure has no status at
|
|
323
|
+
// all — none of the three is a verdict about the repository, and calling any of them RED
|
|
324
|
+
// would report a judgement nobody reached. That is the same laundering the recipes'
|
|
325
|
+
// three-code contract exists to prevent, one level up, and it was reaching the gate that
|
|
326
|
+
// contract is *for*. Found by review on the pull request.
|
|
327
|
+
const CANNOT_RUN = new Set([2, 126, 127]);
|
|
328
|
+
const outcome = CANNOT_RUN.has(code) || code === undefined || code === null
|
|
329
|
+
? `could not run (exit ${code ?? "no status"}) — the gate could not judge`
|
|
330
|
+
: `RED (exit ${code})`;
|
|
331
|
+
problems.push({ reason: "recipe", text: `verify recipe \`${recipe.id}\` — ${outcome}\n${output}` });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const handoffPresent = handoffToday();
|
|
336
|
+
if (didWork() && !handoffPresent) {
|
|
337
|
+
problems.push({
|
|
338
|
+
reason: "handoff",
|
|
339
|
+
text:
|
|
340
|
+
`no handoff dated ${today()} in .portulan/handoffs/. Every session ends with a dated handoff — ` +
|
|
341
|
+
"five lines is enough, absent is not. The Session log records what landed; the handoff records why, " +
|
|
342
|
+
"and the why is the part the next session cannot reconstruct from the diff.",
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return { problems, recipeGreen, handoffPresent };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The gate's arithmetic, as a pure function — the part that decides whether this gate can be talked
|
|
351
|
+
* past, or can wrongly refuse. Exported because both directions need testing and neither is
|
|
352
|
+
* testable through a hook that calls `process.exit`.
|
|
353
|
+
*
|
|
354
|
+
* Three outcomes, deliberately distinct:
|
|
355
|
+
* allow — nothing is wrong. A green stop is FREE: it must never consume the budget, or an
|
|
356
|
+
* ordinary session spends its cap on good turns and a real red walks through afterwards.
|
|
357
|
+
* block — refuse this stop, and charge it.
|
|
358
|
+
* release — the cap is spent. The session may end, and the record must say RED rather than done.
|
|
359
|
+
*/
|
|
360
|
+
export function verdict({ problems, counts = {}, total = 0, max = MAX_BLOCKS, maxTotal = MAX_TOTAL_BLOCKS }) {
|
|
361
|
+
if (problems.length === 0) return { action: "allow" };
|
|
362
|
+
|
|
363
|
+
const text = problems.map((p) => p.text).join("\n\n");
|
|
364
|
+
// Only a reason that is wrong RIGHT NOW may release the gate. A cleared reason's counter is
|
|
365
|
+
// zeroed, so this never remembers that the recipe was once red four times and lets every later
|
|
366
|
+
// stop through on the strength of a problem that no longer exists.
|
|
367
|
+
const spent = problems.map((p) => p.reason).filter((reason) => (counts[reason] ?? 0) > max);
|
|
368
|
+
|
|
369
|
+
if (spent.length || total > maxTotal) {
|
|
370
|
+
// Name the bound that actually released it. Saying "cap of 3" after nine refusals with a
|
|
371
|
+
// per-reason count of 1 is false, and it is the same defect already fixed once in this very
|
|
372
|
+
// message — a release that misreports why it happened sends a reader to the wrong constant.
|
|
373
|
+
// Per-reason counters add one more way to be wrong: naming the cap without naming whose.
|
|
374
|
+
const bound = spent.length
|
|
375
|
+
? `the cap of ${max} consecutive refusals for \`${spent.join("`, `")}\` was reached`
|
|
376
|
+
: `the absolute ceiling of ${maxTotal} refusals was reached (no single reason had reached its cap of ${max})`;
|
|
377
|
+
return {
|
|
378
|
+
action: "release",
|
|
379
|
+
message:
|
|
380
|
+
`PORTULAN STOP-GATE — ${bound}. This session is ending **RED**, not done.\n` +
|
|
381
|
+
`Nothing below was fixed, and the session ending does not fix it. Say so in the handoff and in any\n` +
|
|
382
|
+
`report of this work; a task that ends at the cap is an unfinished task with a stop attached.\n\n` +
|
|
383
|
+
`${text}\n`,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// The counts shown are per reason, because that is what the caps now are. A single "2/3" would
|
|
388
|
+
// be a number no constant in this file corresponds to the moment two reasons are live.
|
|
389
|
+
const tally = problems.map((p) => `${p.reason} ${counts[p.reason] ?? 0}/${max}`).join(", ");
|
|
390
|
+
return {
|
|
391
|
+
action: "block",
|
|
392
|
+
message:
|
|
393
|
+
`PORTULAN STOP-GATE (${tally}) — this task is not done:\n\n${text}\n\n` +
|
|
394
|
+
"Fix these rather than working around them. If a check is wrong, say so and change it deliberately — " +
|
|
395
|
+
"relaxing a check is the change to scrutinise hardest, because it is the one that makes every future green mean less.",
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function main() {
|
|
400
|
+
let payload = {};
|
|
401
|
+
try {
|
|
402
|
+
payload = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
403
|
+
} catch {
|
|
404
|
+
// No payload means no session id, so the counter cannot be trusted — and a gate that cannot
|
|
405
|
+
// count its own blocks cannot promise to stop. Stepping aside is safer than a possible hang.
|
|
406
|
+
allow();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const sessionId = payload.session_id ?? "unknown";
|
|
410
|
+
const { problems, recipeGreen, handoffPresent } = collectProblems();
|
|
411
|
+
|
|
412
|
+
// A reason's condition clearing ends THAT reason's futile-retry episode, whether or not the stop
|
|
413
|
+
// is allowed, and never any other reason's. Done before the bump so a cleared reason cannot be
|
|
414
|
+
// charged a consecutive refusal on the same turn it cleared.
|
|
415
|
+
if (recipeGreen) clearReason(sessionId, "recipe");
|
|
416
|
+
if (handoffPresent) clearReason(sessionId, "handoff");
|
|
417
|
+
|
|
418
|
+
// Charged only when this gate is actually about to refuse. A green stop costs nothing.
|
|
419
|
+
const state = problems.length === 0
|
|
420
|
+
? { counts: {}, total: 0 }
|
|
421
|
+
: bumpCount(sessionId, [...new Set(problems.map((p) => p.reason))]);
|
|
422
|
+
const result = verdict({ problems, counts: state.counts, total: state.total });
|
|
423
|
+
|
|
424
|
+
if (result.action === "allow") allow();
|
|
425
|
+
if (result.action === "release") {
|
|
426
|
+
process.stderr.write(result.message);
|
|
427
|
+
allow();
|
|
428
|
+
}
|
|
429
|
+
block(result.message);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
433
|
+
main();
|
|
434
|
+
}
|