fix: autoplan-regate carries only ELI5 additions; timeout and ref checks fail closed

Step 11 voices (round 2): the autoplan-regate word filter failed open on markup, spelled-out counts and reworded verdicts. A plan-file edit now carries only when it adds lines inside ## ELI5 that hold no number and no spend, cap, arm, model or verdict word; any removed or reworded line goes to the owner, and the carry still exports the reviewed plan. A trailing }, N) counts as a timeout only on a line that opens the test call. --range-base and --base resolve to commits with --end-of-options before git sees them.

Review-Finding: targeted#1 reader=targeted
Review-Finding: outside-voice#1 reader=outside-voice
Review-Finding: outside-voice#2 reader=outside-voice
Review-Finding: outside-voice#3 reader=outside-voice
Review-Finding: adversarial-native#1 reader=adversarial-native
Review-Finding: adversarial-native#6 reader=adversarial-native
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-04 00:51:46 +00:00
1 parent dead4874dc
commit 7fdf69d1f1
10 files changed
+66 -80

No files matched your search

+1 -1
View File
@@ -38,7 +38,7 @@ line yourself; gstack never writes the plan. **Kinds (R65), first `next` only.**
5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export` (`DISPATCHES: <n> est_input_tokens=<k>`, relative).
6. Rounds: one by default; a second only for High or Critical findings; cap 2 (`--max-rounds`). A `revise` whose decisions all resolve below
High prints `APPLIED: <ids> without re-review (below High)`. At the cap with High open, `ESCALATE: evidence sufficient, decision needed`: relay it, never ask for another round.
7. Carry-forward ([docs/unattended.md#carry-forward](../docs/unattended.md#carry-forward); `--no-carry-forward` on the first `next` opts out): a later gate that asks nothing new takes the owner's words from the gate before it (`CARRIED:`, then `APPROVED:`); a plan edit that changes no spend, cap, arm or verdict carries as K1's `autoplan-regate` no-stop (`NO_STOP:`; before /ship run `gstack-no-stop import --from <dir>/no-stop.json`); `CARRY: no (<why>)` leaves it for the owner; `CARRY_CHECK:` means one reviewer from another model family reads the printed delta prompt, then `check --out <dir> --attempt <id> --result <file> --model <id>` (or `--unavailable "<reason>"`).
7. Carry-forward ([docs/unattended.md#carry-forward](../docs/unattended.md#carry-forward); `--no-carry-forward` on the first `next` opts out): a later gate that asks nothing new takes the owner's words from the gate before it (`CARRIED:`, then `APPROVED:`); a plan edit that only adds plain ELI5 lines carries as K1's `autoplan-regate` no-stop (`NO_STOP:`; before /ship run `gstack-no-stop import --from <dir>/no-stop.json`); `CARRY: no (<why>)` leaves it for the owner; `CARRY_CHECK:` means one reviewer from another model family reads the printed delta prompt, then `check --out <dir> --attempt <id> --result <file> --model <id>` (or `--unavailable "<reason>"`).
### On Capy
+1 -1
View File
@@ -35,7 +35,7 @@ line yourself; gstack never writes the plan. **Kinds (R65), first `next` only.**
5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export` (`DISPATCHES: <n> est_input_tokens=<k>`, relative).
6. Rounds: one by default; a second only for High or Critical findings; cap 2 (`--max-rounds`). A `revise` whose decisions all resolve below
High prints `APPLIED: <ids> without re-review (below High)`. At the cap with High open, `ESCALATE: evidence sufficient, decision needed`: relay it, never ask for another round.
7. Carry-forward ([docs/unattended.md#carry-forward](../docs/unattended.md#carry-forward); `--no-carry-forward` on the first `next` opts out): a later gate that asks nothing new takes the owner's words from the gate before it (`CARRIED:`, then `APPROVED:`); a plan edit that changes no spend, cap, arm or verdict carries as K1's `autoplan-regate` no-stop (`NO_STOP:`; before /ship run `gstack-no-stop import --from <dir>/no-stop.json`); `CARRY: no (<why>)` leaves it for the owner; `CARRY_CHECK:` means one reviewer from another model family reads the printed delta prompt, then `check --out <dir> --attempt <id> --result <file> --model <id>` (or `--unavailable "<reason>"`).
7. Carry-forward ([docs/unattended.md#carry-forward](../docs/unattended.md#carry-forward); `--no-carry-forward` on the first `next` opts out): a later gate that asks nothing new takes the owner's words from the gate before it (`CARRIED:`, then `APPROVED:`); a plan edit that only adds plain ELI5 lines carries as K1's `autoplan-regate` no-stop (`NO_STOP:`; before /ship run `gstack-no-stop import --from <dir>/no-stop.json`); `CARRY: no (<why>)` leaves it for the owner; `CARRY_CHECK:` means one reviewer from another model family reads the printed delta prompt, then `check --out <dir> --attempt <id> --result <file> --model <id>` (or `--unavailable "<reason>"`).
### On Capy
+5 -4
View File
@@ -72,15 +72,17 @@ function grant(head: string, detail: string, ledger: string): string | { error:
function record(args: string[], json: boolean): number {
const cls = flag(args, '--class') ?? '';
const rangeBase = flag(args, '--range-base');
const commit = (ref: string | undefined) => { if (!ref) return undefined; const r = git(['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`]); return r.status === 0 ? r.stdout.trim() : null; };
const rangeBase = commit(flag(args, '--range-base'));
const base = cls === 'stamp-only-carry' ? commit(flag(args, '--base')) : undefined;
if (rangeBase === null || base === null) { process.stderr.write(`gstack-no-stop record: --range-base and --base must name commits\n`); return EXIT.usage; }
if (!cls) { process.stderr.write(`gstack-no-stop record: --class is required\n${HELP}\n`); return EXIT.usage; }
if (NEEDS_RANGE.includes(cls) && !rangeBase) { process.stderr.write(`gstack-no-stop record: --class ${cls} needs --range-base <sha>\n`); return EXIT.usage; }
const file = ledgerPath(process.cwd());
if (!file) { process.stderr.write('gstack-no-stop record: not inside a git checkout\n'); return EXIT.fail; }
if (cls === 'scoped-fix-check') {
const pass = lastPassHead();
const base = git(['rev-parse', '--verify', `${rangeBase}^{commit}`]).stdout.trim();
if (!pass || pass !== base) {
if (!pass || pass !== rangeBase) {
process.stderr.write(`NO_STOP: refused ${cls} (--range-base ${rangeBase} is not the commit the last review pass read (${pass ? pass.slice(0, 12) : 'no review-log row'}), so the checked range would not be the range the fix-check covers); stop for the owner\n`);
return EXIT.refused;
}
@@ -92,7 +94,6 @@ function record(args: string[], json: boolean): number {
}
let diff: string | undefined;
if (rangeBase) {
const base = cls === 'stamp-only-carry' ? flag(args, '--base') : undefined;
let from = rangeBase;
if (base) {
const merged = git(['merge-tree', '--write-tree', '-X', 'ours', base, rangeBase]);
+2 -2
View File
@@ -10,9 +10,9 @@
- **`gstack-no-stop import` and `list`** add an autoplan run's records to the branch and print the ledger. /ship's receipt write passes it as `--no-stop @<file>`.
### Changed
- **An autoplan plan edit made after the owner's answer carries their words when it changes no spend, cap, arm or verdict.** Each edited line is compared word by word with the line it replaced. Any changed number counts, and so does any edit to a line that names spend, a budget, a cap, a limit, an arm, a model, a judge or a verdict. An edit with none of those carries as `reason=no-stop autoplan-regate: …` and is written to `<run>/no-stop.json`. Moving "cap 30 s" to "cap 90 s", swapping two arms' caps, or an edit to the reviewed plan itself still goes to the owner. The run keeps a copy of each plan file it hashes, so the edit is diffed against what the owner answered.
- **An autoplan plan edit made after the owner's answer carries their words when it only adds ELI5 lines.** Every added line must sit in the plan's `## ELI5` section and name no number, spend, budget, cap, limit, arm, model, judge or verdict. Such an edit carries as `reason=no-stop autoplan-regate: …` and is written to `<run>/no-stop.json`, and the run still exports the reviewed plan. A removed or reworded line, a line added anywhere else, or an edit to the reviewed plan itself still goes to the owner. The run keeps a copy of each plan file it hashes, so the edit is diffed against what the owner answered.
- docs/review-policy.md#no-stop has the table. docs/ship-policy.md lists the receipt's `no_stop` field, and docs/unattended.md describes the carry-forward change.
### Tests
- `test/no-stop.test.ts`: each class's qualifying and refused cases, the ledger, the grant row, the receipt's `no_stop` validation, stamp-only after a base merge, import, and the template wording.
- `test/autoplan-carry.test.ts`: an ELI5-only edit after the revise now carries and is recorded. A cap move still refuses.
- `test/autoplan-carry.test.ts`: an ELI5-only addition after the revise now carries and is recorded. A cap move still refuses.
+1 -1
View File
@@ -183,7 +183,7 @@ stop goes to the owner as before. Every other stop still stops.
| `test-timeout-fix` | Every changed line is in a test file and only raises a timeout value: the value of a timeout key (`timeout: 5_000`, `setDefaultTimeout(5_000)`, `--timeout=5000`) or bun's and jest's trailing `}, 30_000);` argument. A line with `expect`, `assert` or a comparison never qualifies. | The gate scan's relaxation finding takes the printed `GATE_DISPOSITION: kept` reason instead of `open`. |
| `pregate-flake-rerun` | One rerun of a failed pregate case at the same tree (CREW rule 16). One per branch. | Green counts as flaky. Red again is a real failure. |
| `stamp-only-carry` | A refused carry, where the receipt head merged into the base (base side on conflict) differs from HEAD only in a version stamp: `VERSION`, the `"version"` line of `package.json`, `CHANGELOG.md`, the agents digest or `changelog.d/` fragments. | Take the carry's `RECHECK:` route instead of running Steps 2–16 again. |
| `autoplan-regate` | An edit to the plan file made after the owner's answer, or after the revise that took their changes, while the reviewed plan itself is unchanged. Each edited line is compared with the line it replaced, word by word. The edit changes no number and no word for spend, cost, budget, price, cap, credits, limit, max, arm, model, judge, verdict, threshold, bar, trial, sample or stop rule, and no line that names one of those words is reworded. | `gstack-autoplan` carries the owner's words (`reason=no-stop autoplan-regate: …`) and writes `<run>/no-stop.json`. `gstack-no-stop import` adds it to the branch's ledger. |
| `autoplan-regate` | An edit to the plan file made after the owner's answer, or after the revise that took their changes, while the reviewed plan itself is unchanged. The edit only adds lines, all inside the plan's `## ELI5` section (an added `## ELI5` heading opens one), and no added line holds a number or a word for spend, cost, budget, price, cap, credits, limit, max, arm, model, judge, verdict, threshold, bar, trial, sample, stop rule, paid or eval. A removed or reworded line, or a line added anywhere else, goes to the owner. The carry exports the reviewed plan, so the added explanation is never built. | `gstack-autoplan` carries the owner's words (`reason=no-stop autoplan-regate: …`) and writes `<run>/no-stop.json`. `gstack-no-stop import` adds it to the branch's ledger. |
The ledger is one JSON array per branch under the checkout's git directory
(`NO_STOP_FILE:`). /ship's receipt write passes it as `--no-stop @<file>`.
+4 -4
View File
@@ -618,10 +618,10 @@ then the usual `APPROVED:` lines, and journals `gate_carried`. The carry needs
all of: the earlier gate approved (`p1a` or `p1b`), no item there left
pending, and the owner's words recorded; that gate was not itself carried; every accepted change incorporated
or settled; the plan file's bytes equal those recorded at the gate or at the
`revise` that took the changes, or a later edit changes no spend, cap, arm or
verdict (K1's `autoplan-regate` no-stop: the reason says so, `NO_STOP:` names
`<run>/no-stop.json`, and an edit whose changed words name spend, a cap, an
arm, a verdict or a number beside one refuses; [review-policy.md#no-stop](review-policy.md#no-stop)); no urgent or
`revise` that took the changes, or a later edit only adds ELI5 lines that name
no number, spend, cap, arm or verdict (K1's `autoplan-regate` no-stop: the
reason says so, `NO_STOP:` names `<run>/no-stop.json`, the reviewed plan is
exported, and any other edit refuses; [review-policy.md#no-stop](review-policy.md#no-stop)); no urgent or
outside-plan finding open; and every new gate item one of the cases below.
Anything else prints `CARRY: no (<why>)` (journal `carry_refused`) and the gate
goes to the owner as before. `--no-carry-forward` on the first `next` turns it
+5 -5
View File
@@ -31,7 +31,7 @@ import { FILES, RunError, journal, now, readDecisions, readJournal, readState, w
import { appendTrailRow } from './decision-trail';
import { decisionId, parseReply, type GateItem, type GateList } from './gate-list';
import { readJsonl } from './headless-artifacts';
import { appendLedger, regateTouches } from './no-stop';
import { appendLedger, notEli5Only } from './no-stop';
import { modelFamily } from './outside-voice-runner';
import { release, reserve, settle } from './spend-ledger';
@@ -105,7 +105,7 @@ function deltaSince(state: RunState, rev: number): { diff: string; sha: string }
/** `p1`: an item-token answer's own p1 choice, replayed as given. */
interface Source { id: string; gate_rev: number; words: string; by?: string; p1?: string }
/** noStop: K1's autoplan-regate (lib/no-stop.ts): a plan edit after the owner's answer that changes no spend, cap, arm or verdict. */
/** noStop: K1's autoplan-regate (lib/no-stop.ts): a plan edit after the owner's answer that only adds plain ELI5 lines (notEli5Only). */
interface NoStop { why: string }
type Plan = { source: Source; doIt: GateItem[]; deltas: GateItem[]; noStop?: NoStop } | { reason: string; from?: number };
@@ -149,9 +149,9 @@ function assess(state: RunState, prev: GateList, list: GateList): Plan {
if (!changed) return { source, doIt, deltas };
const before = planFileCopy(state.out, expected);
if (!fs.existsSync(before) || !fs.existsSync(planFile(state))) return { reason: changed, from };
const touched = regateTouches(lineDiff(fs.readFileSync(before, 'utf8'), fs.readFileSync(planFile(state), 'utf8')).filter(l => l.op !== ' ').map(l => `${l.op} ${l.text}`).join('\n'));
if (touched.length) return { reason: `${changed}; it changes spend, a cap, an arm or a verdict (${touched[0]!.slice(2, 82)})`, from };
return { source, doIt, deltas, noStop: { why: `${changed}, with no spend, cap, arm or verdict change` } };
const why = notEli5Only(lineDiff(fs.readFileSync(before, 'utf8'), fs.readFileSync(planFile(state), 'utf8')));
if (why) return { reason: `${changed}; ${why}`, from };
return { source, doIt, deltas, noStop: { why: `${changed}, adding only ELI5 lines (the reviewed plan is exported)` } };
}
/** Called by finishGate once gate_rev N+1 is written (status gate_pending): carry, set up the delta check, or say why not. */
+26 -41
View File
@@ -9,8 +9,8 @@
* - test-timeout-fix: a fix whose every changed line, all in test files, only raises a timeout value.
* - pregate-flake-rerun: one rerun of a failed pregate case at the same tree (CREW rule 16). One per branch.
* - stamp-only-carry: a refused receipt carry whose changes since the receipt head are version stamps only.
* - autoplan-regate: a later autoplan gate revision whose plan delta names no spend, cap, arm or verdict
* (lib/autoplan-carry.ts asks regateTouches before it carries).
* - autoplan-regate: a later autoplan gate revision whose plan-file edit only adds ELI5 lines that name no
* number, spend, cap, arm or verdict (lib/autoplan-carry.ts asks notEli5Only before it carries).
*
* The ledger is a JSON array of {class, reason, evidence} under the checkout's git dir, one file per
* branch, so `gstack-ship-receipt write --no-stop @<ledger>` takes it as it stands.
@@ -33,8 +33,8 @@ const ASSERTION = /\b(?:expect|assert\w*)\b|\s[<>]=?\s/;
const VERSION = /\d+\.\d+\.\d+(?:\.\d+)?/g;
const TEST_PATH = /(?:^|\/)(?:test|tests|__tests__|spec)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
const STAMP_PATHS = ['VERSION', 'package.json', 'CHANGELOG.md', 'agents-digest/gstack-AGENTS.md'];
/** Plan-delta words that mean spend, a cap, an eval arm or a verdict changed: those re-gates go to the owner. */
export const REGATE_WORDS = /\$|\b(?:spend|spending|spent|costs?|budgets?|prices?|caps?|capped|credits?|limits?|max|maximum|arms?|models?|judges?|verdicts?|thresholds?|bars?|pass(?:es)? at|trials?|samples?|stop rules?|kill rules?|paid|evals?|opus|sonnet|haiku|gpt[\w.-]*|gemini|claude)\b|\bn\s*=/i;
/** Words that mean spend, a cap, an eval arm, a model or a verdict: an ELI5 addition naming one goes to the owner. */
const REGATE_WORDS = /\$|\b(?:spend|spending|spent|costs?|budgets?|prices?|caps?|capped|credits?|limits?|max|maximum|arms?|models?|judges?|verdicts?|thresholds?|bars?|pass(?:es)? at|trials?|samples?|stop rules?|kill rules?|paid|evals?|opus|sonnet|haiku|gpt[\w.-]*|gemini|claude)\b|\bn\s*=/i;
/** Unified diff (git diff) to files, each with its one-for-one changed line pairs; a run of unequal - and + counts is unpaired. */
export function parseDiff(diff: string): FileDiff[] {
@@ -66,8 +66,8 @@ export function parseDiff(diff: string): FileDiff[] {
}
const TEST_CALL = /^\s*(?:test|it|describe|bench|beforeAll|beforeEach|afterAll|afterEach)(?:\.\w+)*\(/;
/** A trailing `}, N)` is a timeout only where it closes a test callback: the line opens a test call, or the brace starts the line. */
const isTimeout = (s: string, key: string, at: number, len: number) => !key.startsWith('}') || ((TEST_CALL.test(s) || !s.slice(0, at).trim()) && /\)\s*;?\s*$/.test(s.slice(at + len)));
/** A trailing `}, N)` is a timeout only where the same line opens the test call it closes; a multi-line callback's `}, N);` could close any call. */
const isTimeout = (s: string, key: string, at: number, len: number) => !key.startsWith('}') || (TEST_CALL.test(s) && /\)\s*;?\s*$/.test(s.slice(at + len)));
const timeoutValues = (s: string) => [...s.matchAll(TIMEOUT_VALUE)].filter(m => isTimeout(s, m[1]!, m.index!, m[0].length)).map(m => Number(m[2]!.replace(/_/g, '')));
const maskTimeouts = (s: string) => s.replace(TIMEOUT_VALUE, (_m, key: string, _n: string, at: number) => (isTimeout(s, key, at, _m.length) ? `${key}#` : _m));
@@ -105,45 +105,30 @@ export function notStampOnly(files: FileDiff[]): string | null {
return null;
}
const QUANTITY = /^[$~]?-?\d[\d_,.]*(?:%|x|[a-z]{1,3})?$/i;
const tokens = (line: string) => line.split(/\s+/).map(t => t.replace(/[,.;:!?)]+$/, '')).filter(Boolean);
/** The tokens of a and b outside their longest common subsequence: what the edit changed, in place. */
function changedTokens(a: string[], b: string[]): string[] {
const m = a.map(() => new Array<number>(b.length + 1).fill(0));
m.push(new Array<number>(b.length + 1).fill(0));
for (let i = a.length - 1; i >= 0; i--) for (let j = b.length - 1; j >= 0; j--) m[i]![j] = a[i] === b[j] ? m[i + 1]![j + 1]! + 1 : Math.max(m[i + 1]![j]!, m[i]![j + 1]!);
const out: string[] = [];
let i = 0; let j = 0;
while (i < a.length && j < b.length) {
if (a[i] === b[j]) { i++; j++; } else if (m[i + 1]![j]! >= m[i]![j + 1]!) out.push(a[i++]!); else out.push(b[j++]!);
}
return [...out, ...a.slice(i), ...b.slice(j)];
}
const HEADING = /^#{1,6}\s+(.*)$/;
const ELI5 = /^eli5\b/i;
/**
* The plan-delta lines (lineDiff's "+ " and "- " rows) whose edit changes spend, a cap, an arm, a verdict or any
* number. A run of removed lines pairs in order with the added run after it; the tokens outside the pair's
* longest common subsequence are its change. A pair counts when that change holds one of those words or a number,
* or when the line names one and anything on it changed, so swapping two caps or doubling a budget counts. An
* unpaired line counts when it holds one. An untouched line never counts. An autoplan re-gate with none is a no-stop.
* Why a plan-file edit (lineDiff rows) is not an ELI5-only addition, or null. Every changed line must be added (none
* removed or rewritten), inside the plan's `## ELI5` section (an added `## ELI5` heading opens one), and hold no digit
* and no spend, cap, arm, model or verdict word. Anything else goes to the owner: the carry exports the reviewed
* plan, so the added explanation is never built.
*/
export function regateTouches(delta: string): string[] {
const rows = delta.split('\n').filter(l => /^[+-] /.test(l));
const hits = (ts: string[]) => ts.some(t => REGATE_WORDS.test(t) || QUANTITY.test(t));
const out: string[] = [];
for (let i = 0; i < rows.length;) {
const minus: string[] = []; const plus: string[] = [];
while (i < rows.length && rows[i]![0] === '-') minus.push(rows[i++]!);
while (i < rows.length && rows[i]![0] === '+') plus.push(rows[i++]!);
for (let k = 0; k < Math.max(minus.length, plus.length); k++) {
const a = minus[k]; const b = plus[k];
const changed = a && b ? changedTokens(tokens(a.slice(2)), tokens(b.slice(2))) : tokens((a ?? b)!.slice(2));
const named = [a, b].some(l => l && REGATE_WORDS.test(l.slice(2)));
if (hits(changed) || (named && changed.length)) out.push(...[a, b].filter((l): l is string => !!l).map(l => l.slice(0, 100)));
}
export function notEli5Only(lines: ReadonlyArray<{ op: string; text: string }>): string | null {
let section = '';
let added = 0;
for (const l of lines) {
const heading = l.text.match(HEADING);
if (l.op === '-') return `it removes or rewrites a line: ${l.text.trim().slice(0, 80)}`;
if (l.op !== '+') { if (heading) section = heading[1]!.trim(); continue; }
if (!l.text.trim()) continue;
if (heading && !ELI5.test(heading[1]!.trim())) return `it adds a heading: ${l.text.trim().slice(0, 80)}`;
if (heading) { section = heading[1]!.trim(); continue; }
if (!ELI5.test(section)) return `it adds a line outside ## ELI5: ${l.text.trim().slice(0, 80)}`;
if (/\d/.test(l.text.replace(/\bELI5\b/gi, '')) || REGATE_WORDS.test(l.text)) return `it names a number, spend, a cap, an arm, a model or a verdict: ${l.text.trim().slice(0, 80)}`;
added++;
}
return out;
return added ? null : 'the edit adds no ELI5 line';
}
export interface NoStopInput { cls: string; reason: string; evidence: string; prior: readonly NoStopRecord[]; diff?: string; root?: string }
+4 -4
View File
@@ -140,12 +140,12 @@ describe('carry-forward', () => {
test('an edit to the plan file after the revise (an ELI5-only line) carries as a no-stop re-gate (K1) and is recorded', () => {
const edited = answeredAndRevised();
fs.appendFileSync(edited.v2, '\nAn ELI5 sentence added after the revise.\n');
fs.appendFileSync(edited.v2, '\n## ELI5\n\nAn ELI5 sentence added after the revise.\n');
phaseVia(r, edited.out, 'ceo', NONE);
const e = phaseVia(r, edited.out, 'eng', NONE).outside;
expect(e.stdout).toMatch(/^CARRIED: gate_rev=2 <- gate_rev=1 .*reason=no-stop autoplan-regate: the plan file plan-v2\.md changed after the owner's answer or the revise that took it, with no spend, cap, arm or verdict change$/m);
expect(e.stdout).toMatch(/^CARRIED: gate_rev=2 <- gate_rev=1 .*reason=no-stop autoplan-regate: the plan file plan-v2\.md changed after the owner's answer or the revise that took it, adding only ELI5 lines \(the reviewed plan is exported\)$/m);
expect(e.stdout).toMatch(/^NO_STOP: autoplan-regate recorded /m);
expect(JSON.parse(fs.readFileSync(path.join(edited.out, 'no-stop.json'), 'utf8'))).toEqual([{ class: 'autoplan-regate', reason: expect.stringContaining('with no spend, cap, arm or verdict change'), evidence: `autoplan:${edited.run}@gate_rev=2` }]);
expect(JSON.parse(fs.readFileSync(path.join(edited.out, 'no-stop.json'), 'utf8'))).toEqual([{ class: 'autoplan-regate', reason: expect.stringContaining('adding only ELI5 lines'), evidence: `autoplan:${edited.run}@gate_rev=2` }]);
expect(e.stdout).toMatch(/^APPROVED: gate_rev=2/m);
});
@@ -154,7 +154,7 @@ describe('carry-forward', () => {
fs.writeFileSync(edited.v2, V2.replace('cap 30 s', 'cap 90 s'));
phaseVia(r, edited.out, 'ceo', NONE);
const e = phaseVia(r, edited.out, 'eng', NONE).outside;
expect(e.stdout).toMatch(/^CARRY: no \(the plan file plan-v2\.md changed after the owner's answer or the revise that took it; it changes spend, a cap, an arm or a verdict \(/m);
expect(e.stdout).toMatch(/^CARRY: no \(the plan file plan-v2\.md changed after the owner's answer or the revise that took it; it removes or rewrites a line: /m);
expect(fs.existsSync(path.join(edited.out, 'no-stop.json'))).toBe(false);
expect(readRunJson(edited.out).status).toBe('gate_pending');
});
+17 -17
View File
@@ -10,7 +10,8 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { bunIsolationArgs } from '../lib/bun-isolation';
import { validateReceipt } from '../lib/ship-receipt';
import { checkNoStop, notStampOnly, parseDiff, regateTouches, type NoStopRecord } from '../lib/no-stop';
import { lineDiff } from '../lib/autoplan-confirm';
import { checkNoStop, notEli5Only, notStampOnly, parseDiff, type NoStopRecord } from '../lib/no-stop';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin', 'gstack-no-stop');
@@ -34,7 +35,7 @@ describe('lib/no-stop: what qualifies', () => {
test('test-timeout-fix: only a raised timeout in a test file qualifies', () => {
expect(check('test-timeout-fix', TIMEOUT)).toMatchObject({ ok: true });
expect(check('test-timeout-fix', diff('test/worker.test.ts', ['}, 30_000);'], ['}, 5_000);']))).toMatchObject({ ok: false, reason: expect.stringContaining('does not raise') });
expect(check('test-timeout-fix', diff('test/worker.test.ts', [" test('drains', async () => { await drain(); }, 30_000);"], [" test('drains', async () => { await drain(); }, 5_000);"]))).toMatchObject({ ok: false, reason: expect.stringContaining('does not raise') });
expect(check('test-timeout-fix', diff('test/worker.test.ts', ['expect(n).toBe(3);'], ['expect(n).toBe(4);']))).toMatchObject({ ok: false, reason: expect.stringContaining('more than a timeout') });
expect(check('test-timeout-fix', diff('lib/worker.ts', ['const timeoutMs = 5000;'], ['const timeoutMs = 9000;']))).toMatchObject({ ok: false, reason: expect.stringContaining('not a test file') });
expect(check('test-timeout-fix', diff('test/worker.test.ts', [], [' // a new line, timeout 30_000']))).toMatchObject({ ok: false });
@@ -67,24 +68,23 @@ describe('lib/no-stop: what qualifies', () => {
expect(check('autoplan-regate')).toMatchObject({ ok: false, reason: expect.stringContaining('gstack-autoplan decides') });
});
test('regateTouches: a changed number, an edited line that names a cap, a new arm or a dollar figure counts; other edits do not', () => {
expect(regateTouches('- Retry the webhook worker\n+ Retry the webhook worker behind RETRY_BACKOFF=1')).toEqual([]);
expect(regateTouches('- base 500 ms, cap 30 s\n+ base 500 ms, cap 30 s, behind RETRY_BACKOFF=1')).toHaveLength(2);
expect(regateTouches('+ An ELI5 sentence added after the revise.')).toEqual([]);
expect(regateTouches('- cap 30 s\n+ cap 90 s')).toHaveLength(2);
expect(regateTouches('+ Add a no-gstack arm to the panel.')).toHaveLength(1);
expect(regateTouches('+ Budget up to $40 for the pilot.')).toHaveLength(1);
expect(regateTouches('+ Runs on capy-host machines.')).toEqual([]);
expect(regateTouches('- Arm A cap 30 s; arm B cap 90 s\n+ Arm A cap 90 s; arm B cap 30 s').length).toBeGreaterThan(0);
expect(regateTouches('- Run 40 jobs per sweep\n+ Run 400 jobs per sweep').length).toBeGreaterThan(0);
expect(regateTouches('- Keep the budgets\n+ Double the budgets').length).toBeGreaterThan(0);
test('notEli5Only: only added lines inside ## ELI5 that name no number, spend, cap, arm, model or verdict carry', () => {
const PLAN = '# Plan\n\nRetry the webhook worker, cap 30 s.\n\n## ELI5\n\nRetries wait longer each time.\n';
const edit = (after: string) => notEli5Only(lineDiff(PLAN, after));
expect(edit(PLAN + 'They stop hammering the service.\n')).toBeNull();
expect(edit('# Plan\n\nRetry the webhook worker, cap 30 s.\n\n## ELI5\n\nRetries wait longer each time.\nNothing else changes.\n\n## Notes\n')).toContain('adds a heading');
expect(notEli5Only(lineDiff('# Plan\n\nDo it.\n', '# Plan\n\nDo it.\n\n## ELI5\n\nIt waits longer.\n'))).toBeNull();
expect(edit(PLAN.replace('Retry the webhook worker', 'Retry the webhook worker and the queue'))).toContain('removes or rewrites');
expect(edit(PLAN.replace('cap 30 s.\n', 'cap 30 s.\nAlso delete the users table.\n'))).toContain('outside ## ELI5');
expect(edit(PLAN + 'It runs (30 times) per case.\n')).toContain('names a number');
expect(edit(PLAN + 'Run it on **Opus** instead.\n')).toContain('names a number');
expect(edit(PLAN + 'The budget grows.\n')).toContain('names a number');
expect(edit(PLAN)).toBe('the edit adds no ELI5 line');
});
test('review pass 2 holes: a retry count is not a timeout, and dropping a paid eval or swapping a model is a re-gate', () => {
test('review pass 2 holes: a retry count, or a brace that closes any multi-line call, is not a timeout', () => {
expect(check('test-timeout-fix', diff('test/a.test.ts', [' retry({ fn }, 3);'], [' retry({ fn }, 10);']))).toMatchObject({ ok: false });
expect(check('test-timeout-fix', diff('test/a.test.ts', [' }, 5_000);'], [' }, 30_000);']))).toMatchObject({ ok: true });
expect(regateTouches('- Run the paid eval\n+ Skip the paid eval').length).toBeGreaterThan(0);
expect(regateTouches('- Score it on Opus\n+ Score it on Haiku').length).toBeGreaterThan(0);
expect(check('test-timeout-fix', diff('test/a.test.ts', [' }, 3);'], [' }, 30);']))).toMatchObject({ ok: false });
});
});