@sleepy_panda_srl/portulan 0.1.2 → 0.1.3
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 +2 -2
- package/cli/README.md +69 -2
- package/cli/compile.mjs +555 -37
- package/cli/doctor.mjs +5 -1
- package/cli/drills.mjs +1541 -0
- package/cli/fuzz-shell.mjs +880 -0
- package/cli/gate.mjs +3 -2
- package/cli/goldens.mjs +469 -0
- package/cli/init.mjs +4 -1
- package/cli/mutants.mjs +925 -0
- package/cli/pack-identity.mjs +9 -2
- package/cli/pack-version.mjs +7 -0
- package/cli/recipe-set.mjs +6 -0
- package/cli/release-eval.mjs +1068 -0
- package/cli/review-meter.mjs +741 -0
- package/cli/skill-goldens.mjs +590 -0
- package/cli/stop-gate.mjs +107 -4
- package/cli/telemetry.mjs +1047 -0
- package/cli/version-carriers.mjs +13 -0
- package/core/templates/gate-policy.md +27 -8
- package/evals/README.md +910 -0
- package/evals/releases/0.1.3.json +134 -0
- package/evals/releases/0.1.3.md +64 -0
- package/evals/releases/README.md +32 -0
- package/package.json +7 -2
- package/packs/rituals/checkpoints/README.md +8 -1
- package/packs/rituals/checkpoints/pack.json +1 -1
- package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +32 -2
- package/spec/slots.md +26 -2
package/cli/stop-gate.mjs
CHANGED
|
@@ -194,6 +194,28 @@ export const MAX_BLOCKS = 3;
|
|
|
194
194
|
// refusal, or a two-reason session would reach the ceiling in half the attempts.
|
|
195
195
|
export const MAX_TOTAL_BLOCKS = 9;
|
|
196
196
|
|
|
197
|
+
// **Both caps above are keyed to `session_id`, and a host that rotates it per retry defeats them.**
|
|
198
|
+
//
|
|
199
|
+
// Measured 2026-09-02 on Claude Code 2.1.251, inside an A/B arm: the gate refused a stop, the agent
|
|
200
|
+
// retried, and the retry arrived under a NEW session id — so `counterFile()` opened a fresh counter,
|
|
201
|
+
// every consecutive count restarted at zero, and neither cap was ever reached. **85,839 hook
|
|
202
|
+
// invocations and 6,985 distinct counter files from one probe**, none of them ever a fourth consecutive
|
|
203
|
+
// refusal. This module's own comment names the result exactly: *a Stop-gate that cannot stop is not a
|
|
204
|
+
// gate, it is a hang.*
|
|
205
|
+
//
|
|
206
|
+
// The platform already answers this and the answer was documented here and never read. `stop_hook_active`
|
|
207
|
+
// is true precisely when the stop being judged was itself provoked by a hook block — so it identifies
|
|
208
|
+
// the retry CHAIN without reference to any session id. This counter follows it: cleared on a stop that
|
|
209
|
+
// no hook provoked, incremented on one that a hook did, and keyed to the TREE alone, which is the thing
|
|
210
|
+
// that cannot rotate underneath it.
|
|
211
|
+
//
|
|
212
|
+
// **It is a backstop, not a replacement.** The per-reason and per-session caps keep their meaning where
|
|
213
|
+
// the id is stable, which is every ordinary session; this only bounds the case where they cannot see
|
|
214
|
+
// their own history. It is set above `MAX_TOTAL_BLOCKS` deliberately: a session whose id is stable must
|
|
215
|
+
// always meet the specific caps first, so their messages, not this one, are what a person normally
|
|
216
|
+
// reads.
|
|
217
|
+
export const MAX_CHAIN_BLOCKS = 12;
|
|
218
|
+
|
|
197
219
|
const RECIPE_TIMEOUT_MS = 90_000;
|
|
198
220
|
|
|
199
221
|
/**
|
|
@@ -218,6 +240,74 @@ function counterFile(sessionId, dir, root = REPO) {
|
|
|
218
240
|
return path.join(dir, `portulan-stopgate-${readable}-${digest(sessionId)}-${digest(root)}`);
|
|
219
241
|
}
|
|
220
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Where the retry CHAIN's counter lives — keyed by tree, never by session.
|
|
245
|
+
*
|
|
246
|
+
* `counterFile()` is keyed by session id and is the right key for the per-reason caps. This one exists
|
|
247
|
+
* for the case that key cannot survive: a host issuing a new session id per retry. So it is deliberately
|
|
248
|
+
* the one piece of gate state two sessions in a worktree share — the sharing the per-session counters
|
|
249
|
+
* exist to prevent is exactly what makes this able to see a chain the counters cannot.
|
|
250
|
+
*
|
|
251
|
+
* **"The tree" means the tree being JUDGED, and the first cut keyed it to `REPO` instead.** `bumpChain`
|
|
252
|
+
* called this without a root, so every session shared one file whatever `resolveSessionTree(payload.cwd)`
|
|
253
|
+
* had resolved — and this gate answers about another worktree often enough that line ~775 has a branch
|
|
254
|
+
* for it. Two arms, or an arm and this repository, contended on one chain: one tree's retries could
|
|
255
|
+
* spend another tree's budget, or release it early. The docblock above said *keyed to the tree* while
|
|
256
|
+
* the code keyed to a constant. Copilot round 2 on
|
|
257
|
+
* [#406](https://github.com/sleepy-panda-srl/portulan/pull/406).
|
|
258
|
+
*/
|
|
259
|
+
function chainFile(dir, root = REPO) {
|
|
260
|
+
const digest = (v) => {
|
|
261
|
+
let h = 0;
|
|
262
|
+
for (const ch of String(v)) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
|
263
|
+
return h.toString(36);
|
|
264
|
+
};
|
|
265
|
+
return path.join(dir, `portulan-stopgate-chain-${digest(root)}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* How many consecutive hook-provoked stops this tree has seen, and the bump that maintains it.
|
|
270
|
+
*
|
|
271
|
+
* `provoked` is the payload's `stop_hook_active`: false means this stop was not caused by a block, so
|
|
272
|
+
* whatever chain existed has ended. Reading a missing or unreadable file as zero is the stricter
|
|
273
|
+
* direction for the per-session counters and the LOOSER one here, so it is stated rather than inherited:
|
|
274
|
+
* a chain this gate cannot count is a chain it does not charge, and the specific caps still apply.
|
|
275
|
+
*/
|
|
276
|
+
export function bumpChain(provoked, dir = os.tmpdir(), root = REPO) {
|
|
277
|
+
const file = chainFile(dir, root);
|
|
278
|
+
if (!provoked) {
|
|
279
|
+
try {
|
|
280
|
+
fs.rmSync(file, { force: true });
|
|
281
|
+
} catch {
|
|
282
|
+
// A chain counter that cannot be cleared costs a later session part of its budget, which is
|
|
283
|
+
// the safe direction: it can only make this backstop fire sooner, never later.
|
|
284
|
+
}
|
|
285
|
+
return 0;
|
|
286
|
+
}
|
|
287
|
+
let previous = 0;
|
|
288
|
+
try {
|
|
289
|
+
previous = Number(JSON.parse(fs.readFileSync(file, "utf8"))?.chain) || 0;
|
|
290
|
+
} catch {
|
|
291
|
+
previous = 0;
|
|
292
|
+
}
|
|
293
|
+
// **The count advances only if the write does, and the first cut returned the increment either
|
|
294
|
+
// way.** That made the returned value diverge from what the next invocation would read: on an
|
|
295
|
+
// unwritable temp dir the chain appeared to climb within one call and reset on every following one,
|
|
296
|
+
// so the bound could fire on a session that had never accumulated anything, or never fire at all —
|
|
297
|
+
// and the comment below asserted the opposite of what the code did, which is the class this whole
|
|
298
|
+
// change is about. Copilot round 1 on [#406](https://github.com/sleepy-panda-srl/portulan/pull/406).
|
|
299
|
+
//
|
|
300
|
+
// Unwritable state therefore means the chain **cannot grow**: this backstop simply does not fire and
|
|
301
|
+
// the specific caps are unaffected. Silent because it is a fact about the filesystem, not about the
|
|
302
|
+
// session's work.
|
|
303
|
+
try {
|
|
304
|
+
fs.writeFileSync(file, JSON.stringify({ chain: previous + 1 }));
|
|
305
|
+
} catch {
|
|
306
|
+
return previous;
|
|
307
|
+
}
|
|
308
|
+
return previous + 1;
|
|
309
|
+
}
|
|
310
|
+
|
|
221
311
|
/** The stored shape: one consecutive count per reason, plus the non-resetting refusal total. */
|
|
222
312
|
function readCount(file) {
|
|
223
313
|
let stored = {};
|
|
@@ -725,7 +815,7 @@ function collectProblems(tree = { root: REPO, workspace: WORKSPACE, origin: "tol
|
|
|
725
815
|
* block — refuse this stop, and charge it.
|
|
726
816
|
* release — the cap is spent. The session may end, and the record must say RED rather than done.
|
|
727
817
|
*/
|
|
728
|
-
export function verdict({ problems, counts = {}, total = 0, max = MAX_BLOCKS, maxTotal = MAX_TOTAL_BLOCKS }) {
|
|
818
|
+
export function verdict({ problems, counts = {}, total = 0, chain = 0, max = MAX_BLOCKS, maxTotal = MAX_TOTAL_BLOCKS, maxChain = MAX_CHAIN_BLOCKS }) {
|
|
729
819
|
if (problems.length === 0) return { action: "allow" };
|
|
730
820
|
|
|
731
821
|
const text = problems.map((p) => p.text).join("\n\n");
|
|
@@ -734,14 +824,22 @@ export function verdict({ problems, counts = {}, total = 0, max = MAX_BLOCKS, ma
|
|
|
734
824
|
// stop through on the strength of a problem that no longer exists.
|
|
735
825
|
const spent = problems.map((p) => p.reason).filter((reason) => (counts[reason] ?? 0) > max);
|
|
736
826
|
|
|
737
|
-
if (spent.length || total > maxTotal) {
|
|
827
|
+
if (spent.length || total > maxTotal || chain > maxChain) {
|
|
738
828
|
// Name the bound that actually released it. Saying "cap of 3" after nine refusals with a
|
|
739
829
|
// per-reason count of 1 is false, and it is the same defect already fixed once in this very
|
|
740
830
|
// message — a release that misreports why it happened sends a reader to the wrong constant.
|
|
741
831
|
// Per-reason counters add one more way to be wrong: naming the cap without naming whose.
|
|
832
|
+
// The chain bound is named LAST, so the two session-keyed bounds keep reporting themselves
|
|
833
|
+
// wherever they can — a session whose id is stable must always meet those first. Reaching this
|
|
834
|
+
// one is itself a finding about the host: it means the per-session counters could not see their
|
|
835
|
+
// own history, which is what 2.1.251 does inside an isolated arm.
|
|
742
836
|
const bound = spent.length
|
|
743
837
|
? `the cap of ${max} consecutive refusals for \`${spent.join("`, `")}\` was reached`
|
|
744
|
-
:
|
|
838
|
+
: total > maxTotal
|
|
839
|
+
? `the absolute ceiling of ${maxTotal} refusals was reached (no single reason had reached its cap of ${max})`
|
|
840
|
+
: `${chain} consecutive hook-provoked stops were seen in this tree, past the chain bound of ${maxChain} — ` +
|
|
841
|
+
`the per-session caps never fired, which means this host issued a new session id per retry and their ` +
|
|
842
|
+
`counters could not see their own history`;
|
|
745
843
|
return {
|
|
746
844
|
action: "release",
|
|
747
845
|
message:
|
|
@@ -804,7 +902,12 @@ function main() {
|
|
|
804
902
|
const state = problems.length === 0
|
|
805
903
|
? { counts: {}, total: 0 }
|
|
806
904
|
: bumpCount(sessionId, [...new Set(problems.map((p) => p.reason))]);
|
|
807
|
-
|
|
905
|
+
// **The chain follows the platform's own signal, not ours.** `stop_hook_active` is true exactly when
|
|
906
|
+
// the stop being judged was provoked by a hook block, so it identifies the retry chain without any
|
|
907
|
+
// session id. Cleared on a stop nothing provoked — including a green one, which ends a chain as
|
|
908
|
+
// surely as a fixed problem does.
|
|
909
|
+
const chain = bumpChain(problems.length > 0 && payload.stop_hook_active === true, os.tmpdir(), tree.root);
|
|
910
|
+
const result = verdict({ problems, counts: state.counts, total: state.total, chain });
|
|
808
911
|
|
|
809
912
|
if (result.action === "allow") allow();
|
|
810
913
|
if (result.action === "release") {
|