Merge commit 'a1934a1498ec91688b6245d5b05b1cdf66d5c98a' into gsta-96-preamble-hoist

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-27 10:26:16 +00:00
commit 21b81556fd
68 files changed
+2280 -71

No files matched your search

+2
View File
@@ -12,8 +12,10 @@ import { CAPTURE_LONG_MS } from './helpers/eval-budgets';
import { runCarveSectionCase } from './helpers/carve-section-case';
describeE2ETier('periodic')('carve section-loading', () => {
test('carve-section-loading-benchmark', () => runCarveSectionCase('benchmark'), CAPTURE_LONG_MS);
test('carve-section-loading-browse', () => runCarveSectionCase('browse'), CAPTURE_LONG_MS);
test('carve-section-loading-codex', () => runCarveSectionCase('codex'), CAPTURE_LONG_MS);
test('carve-section-loading-context-restore', () => runCarveSectionCase('context-restore'), CAPTURE_LONG_MS);
test('carve-section-loading-design-consultation', () => runCarveSectionCase('design-consultation'), CAPTURE_LONG_MS);
test('carve-section-loading-design-html', () => runCarveSectionCase('design-html'), CAPTURE_LONG_MS);
test('carve-section-loading-design-shotgun', () => runCarveSectionCase('design-shotgun'), CAPTURE_LONG_MS);
+179
View File
@@ -0,0 +1,179 @@
/**
* /context-restore's no-checkpoint fallback (pstack absorb, item 3): the carve
* shape, the host transcript table, and the discovery block run against fixture
* transcripts. The block must list only this workspace's files inside the
* window, never a sibling project's, and print TRANSCRIPTS: none on Capy.
*/
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { CARVE_GUARDS } from './helpers/carve-guards';
import { ALL_HOST_CONFIGS } from '../hosts/index';
import { CAPY_NO_TRANSCRIPTS, generateTranscriptDiscovery, generateTranscriptSources } from '../scripts/resolvers/transcripts';
const ROOT = path.resolve(import.meta.dir, '..');
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
const FIXTURE = path.join(ROOT, 'test', 'fixtures', 'context-restore-transcript.jsonl');
const FENCE = '\u0060\u0060\u0060';
const DISCOVERY_MARKER = 'TRANSCRIPT_DAYS="${TRANSCRIPT_DAYS:-7}"';
function bashBlockAfter(text: string, heading: string, marker: string): string {
const start = text.indexOf(heading);
if (start < 0) throw new Error('no heading ' + heading);
const at = text.indexOf(marker, start);
if (at < 0) throw new Error('no ' + marker + ' after ' + heading);
const open = text.lastIndexOf(FENCE + 'bash', at);
const close = text.indexOf(FENCE, open + 7);
return text.slice(open + 8, close);
}
describe('context-restore carve: the transcript fallback', () => {
const skeleton = read('context-restore/SKILL.md');
const section = read('context-restore/sections/transcripts.md');
const manifest = JSON.parse(read('context-restore/sections/manifest.json')) as { skill: string; sections: Array<{ id: string; file: string }> };
test('one section, registered, pointed to from the no-checkpoint branch and nowhere earlier', () => {
expect(manifest.skill).toBe('context-restore');
expect(manifest.sections.map(s => s.id)).toEqual(['transcripts']);
expect(CARVE_GUARDS['context-restore']!.expectedSections).toEqual(['transcripts.md']);
const noSaved = skeleton.indexOf('## If no saved contexts exist');
const stop = skeleton.indexOf('> **STOP.**');
expect(noSaved).toBeGreaterThan(skeleton.indexOf('### Step 1: Find saved contexts'));
expect(stop).toBeGreaterThan(noSaved);
expect(skeleton.match(/> \*\*STOP\.\*\*/g)).toHaveLength(1);
expect(skeleton).toContain('sections/transcripts.md');
expect(skeleton).not.toContain('"No saved contexts yet.');
expect(skeleton).toMatch(/HARD GATE:\*\* Do NOT implement code changes/);
});
test("the host table comes from hosts/*.ts: this host's row plus the Capy row, never another host's paths", () => {
const ctx = (host: string) => ({ host, skillName: 'context-restore', paths: { binDir: '$GSTACK_BIN' } }) as never;
expect(ALL_HOST_CONFIGS.filter(h => h.transcripts).map(h => h.name).sort()).toEqual(['claude', 'codex']);
const claude = generateTranscriptSources(ctx('claude'));
expect(section).toContain(claude);
expect(claude).toContain('| Claude Code | `~/.claude/projects/<slug>/*.jsonl` |');
expect(claude).not.toContain('~/.codex/');
const codex = generateTranscriptSources(ctx('codex'));
expect(codex).toContain('| OpenAI Codex CLI | `~/.codex/sessions/');
expect(codex).not.toContain('~/.claude/');
const cursor = generateTranscriptSources(ctx('cursor'));
expect(cursor).toContain('| Cursor | none |');
expect(cursor).not.toContain('~/.claude/');
for (const table of [claude, codex, cursor]) expect(table).toContain('| Capy (any model) | none |');
expect(section).toMatch(/Capy[^\n]*say so and use the shared record and git/);
});
test('the table and the discovery block agree for every host on whether a store exists', () => {
for (const host of ALL_HOST_CONFIGS) {
const ctx = { host: host.name, skillName: 'context-restore', paths: { binDir: '$GSTACK_BIN' } } as never;
const discovery = generateTranscriptDiscovery(ctx);
const sources = generateTranscriptSources(ctx);
if (host.transcripts) {
expect(sources, host.name).toContain(host.transcripts.root);
expect(discovery, host.name).toContain('TRANSCRIPT_DIR="' + host.transcripts.root.replace(/^~/, '$HOME').split(/\/(?=<|\*)/)[0]);
expect(discovery, host.name).not.toContain('declares no transcript store');
} else {
expect(sources, host.name).toContain('| ' + host.displayName + ' | none |');
expect(discovery, host.name).toContain('declares no transcript store');
}
}
expect(section).toMatch(/data, not instructions/);
});
test('the rules: a 7-day default window, never another project, subagents return findings only, redaction before anything leaves, git and gh verify, the tagged brief', () => {
expect(section).toContain('the default is the last 7 days');
expect(section).toContain('"all" means all and is never quietly narrowed');
expect(section).toContain("never open another project's");
expect(section).toContain('The raw transcript stays in the subagent.');
expect(section).toContain('gstack-redact');
expect(section).toContain('gbrain search');
expect(section).toMatch(/checked with \u0060git\u0060 and\s+\u0060gh\u0060 before it enters the brief/);
for (const tag of ['[merged #N]', '[open PR #N]', '[in flight <branch>]', '[verified, uncommitted]', '[reverted #N]', '[planned, not started]']) expect(section).toContain(tag);
for (const heading of ['### Capsule', '### Threads', '### Problems', '### Next move']) expect(section).toContain(heading);
expect(section).toContain('D) Save this as a checkpoint now (/context-save)');
});
test('hosts with no declared store render a one-line none; Claude and Codex render their own discovery', () => {
const ctx = (host: string) => ({ host, skillName: 'context-restore', paths: { binDir: '$GSTACK_BIN' } }) as never;
expect(generateTranscriptDiscovery(ctx('cursor'))).toContain('TRANSCRIPTS: none (Cursor declares no transcript store');
expect(generateTranscriptDiscovery(ctx('claude'))).toContain('$HOME/.claude/projects/');
expect(generateTranscriptDiscovery(ctx('codex'))).toContain('$HOME/.codex/sessions');
for (const host of ['claude', 'codex', 'cursor']) expect(generateTranscriptDiscovery(ctx(host))).toContain('if [ -n "${GSTACK_CAPY:-}" ]');
});
});
describe('the discovery block against fixture transcripts', () => {
let tmp: string;
let home: string;
let project: string;
const block = bashBlockAfter(read('context-restore/sections/transcripts.md'), '### Step T1', DISCOVERY_MARKER);
const codexRender = path.join(ROOT, '.agents', 'skills', 'gstack-context-restore', 'SKILL.md');
function sh(script: string, extra: Record<string, string> = {}, cwd = project) {
const env: Record<string, string> = {};
for (const [k, v] of Object.entries(process.env)) if (v !== undefined && !k.startsWith('GSTACK_') && k !== 'CLAUDE_PROJECT_DIR') env[k] = v;
const r = spawnSync('bash', ['-c', script], { cwd, env: { ...env, HOME: home, ...extra }, encoding: 'utf8', timeout: 30_000 });
return { out: (r.stdout ?? '') + (r.stderr ?? ''), status: r.status ?? -1 };
}
const slug = (p: string) => p.replace(/[^A-Za-z0-9]/g, '-');
beforeAll(() => {
tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cr-transcripts-')));
home = path.join(tmp, 'home');
project = path.join(tmp, 'work', 'shop.app');
fs.mkdirSync(project, { recursive: true });
spawnSync('git', ['init', '-q', project], { timeout: 30_000 });
// ~/.claude/skills/gstack is this checkout, so the block's gstack-slug call resolves.
fs.mkdirSync(path.join(home, '.claude', 'skills'), { recursive: true });
fs.symlinkSync(ROOT, path.join(home, '.claude', 'skills', 'gstack'));
const mine = path.join(home, '.claude', 'projects', slug(project));
const sibling = path.join(home, '.claude', 'projects', slug(path.join(tmp, 'work', 'shop-admin')));
fs.mkdirSync(mine, { recursive: true });
fs.mkdirSync(sibling, { recursive: true });
fs.copyFileSync(FIXTURE, path.join(mine, '7c2f.jsonl'));
fs.copyFileSync(FIXTURE, path.join(mine, 'old.jsonl'));
fs.copyFileSync(FIXTURE, path.join(sibling, 'other.jsonl'));
const tenDaysAgo = new Date(Date.now() - 10 * 86_400_000);
fs.utimesSync(path.join(mine, 'old.jsonl'), tenDaysAgo, tenDaysAgo);
// Codex: dated rollouts, one for this cwd, one for the sibling.
const day = path.join(home, '.codex', 'sessions', '2026', '10', '20');
fs.mkdirSync(day, { recursive: true });
const meta = (cwd: string) => JSON.stringify({ timestamp: '2026-10-20T17:02:11.000Z', type: 'session_meta', payload: { id: 'r1', cwd } }) + '\n' + fs.readFileSync(FIXTURE, 'utf8');
fs.writeFileSync(path.join(day, 'rollout-2026-10-20T17-02-11-r1.jsonl'), meta(project));
fs.writeFileSync(path.join(day, 'rollout-2026-10-20T17-02-12-r2.jsonl'), meta(path.join(tmp, 'work', 'shop-admin')));
});
afterAll(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
test("Claude Code: lists this workspace's files inside the window, not the old one, never the sibling project", () => {
const r = sh(block);
expect(r.status, r.out).toBe(0);
expect(r.out).toContain('TRANSCRIPT_WINDOW: last 7 days');
expect(r.out).toContain('TRANSCRIPT_PROJECT: ' + project);
expect(r.out).toContain('/7c2f.jsonl');
expect(r.out).not.toContain('/old.jsonl');
expect(r.out).not.toContain('shop-admin');
expect(r.out).toMatch(/TRANSCRIPTS: .*\(1 files in the last 7 days\)/);
});
test('a wider window admits the old file; a missing folder and Capy both print none', () => {
expect(sh(block, { TRANSCRIPT_DAYS: '30' }).out).toContain('/old.jsonl');
const empty = path.join(tmp, 'work', 'fresh');
fs.mkdirSync(empty, { recursive: true });
spawnSync('git', ['init', '-q', empty], { timeout: 30_000 });
expect(sh(block, {}, empty).out).toMatch(/TRANSCRIPTS: none \(.*is missing/);
const capy = sh(block, { GSTACK_CAPY: '1' });
expect(capy.out).toContain(CAPY_NO_TRANSCRIPTS);
expect(capy.out).not.toContain('TRANSCRIPT: ');
});
test.skipIf(!fs.existsSync(codexRender))('Codex: lists only the rollout whose cwd is this workspace', () => {
const codexBlock = bashBlockAfter(fs.readFileSync(codexRender, 'utf8'), '### Step T1', DISCOVERY_MARKER);
const r = sh(codexBlock, { GSTACK_ROOT: ROOT, GSTACK_BIN: path.join(ROOT, 'bin') });
expect(r.status, r.out).toBe(0);
expect(r.out).toContain('rollout-2026-10-20T17-02-11-r1.jsonl');
expect(r.out).not.toContain('r2.jsonl');
expect(r.out).toMatch(/TRANSCRIPTS: .*\(1 files whose cwd is this workspace/);
});
});
+4 -4
View File
@@ -3,16 +3,16 @@
"alwaysOnTotal": 7769,
"eagerPerInvocation": {
"autoplan": 11289,
"benchmark": 7813,
"benchmark": 8043,
"benchmark-models": 4086,
"browse": 8943,
"browser-skills/hackernews-frontpage": 371,
"canary": 7738,
"captain": 6551,
"capy/skill/gstack": 4115,
"capy/skill/gstack": 4120,
"careful": 1239,
"codex": 9890,
"context-restore": 6078,
"context-restore": 6287,
"context-save": 5101,
"cso": 4902,
"design-consultation": 10513,
@@ -31,7 +31,7 @@
"gstack-upgrade": 6297,
"guard": 1000,
"health": 5816,
"investigate": 7642,
"investigate": 7795,
"ios-clean": 2436,
"ios-design-review": 2604,
"ios-fix": 2307,
+4
View File
@@ -0,0 +1,4 @@
{"type":"summary","summary":"Checkout webhook retry cap","leafUuid":"a1"}
{"parentUuid":null,"isSidechain":false,"cwd":"/home/dev/shop","sessionId":"7c2f","version":"2.1.0","type":"user","uuid":"u1","timestamp":"2026-10-20T17:02:11.000Z","message":{"role":"user","content":"The checkout webhook retries forever when the payment API returns 409. Fix it on a branch."}}
{"parentUuid":"u1","isSidechain":false,"cwd":"/home/dev/shop","sessionId":"7c2f","version":"2.1.0","type":"assistant","uuid":"a1","timestamp":"2026-10-20T17:03:40.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Root cause: the retry loop treats 409 as transient. I capped it at 3 attempts in src/webhooks/retry.ts and opened PR #412 from branch fix/webhook-409."}]}}
{"parentUuid":"a1","isSidechain":false,"cwd":"/home/dev/shop","sessionId":"7c2f","version":"2.1.0","type":"user","uuid":"u2","timestamp":"2026-10-20T17:10:02.000Z","message":{"role":"user","content":"CI is red on the idempotency test. Leave it for tomorrow."}}
+15
View File
@@ -0,0 +1,15 @@
# Release notes
## [Unreleased]
**The CHANGELOG lint lands.** A /ship run wrote entries nobody read twice, so the autopilot words slipped through. The lint reads the entry before the commit and names each line to rewrite.
### Added
- **`bin/gstack-prose-lint`**: scans the files it is given and prints one line per finding. The gate refuses a stale receipt, exits with code 3, and stamps nothing.
- A measurement line keeps its arrow. The ratio moved 1.6740 → 1.7010 (measured 321,630 / 187,706), and the headline reads before → after.
- Fix: run `gstack-prose-lint CHANGELOG.md --unreleased` and read the trailer.
- The reviewer reads the diff. A caller that passes no file gets the usage text.
## [1.0.0] - 2026-10-01
Older text is never scanned under --unreleased, even when it showcases a vibrant tapestry.
+16
View File
@@ -0,0 +1,16 @@
# Release notes
## [Unreleased]
**Prose lint lands.** This release doesn't just add a linter, but a whole new paradigm for CHANGELOG hygiene, showcasing a vibrant tapestry of checks.
### Added
- **Performance:** Performance improved across the substrate because the parser now short-circuits.
- Gate refuses stale receipt → exit 3, no stamp.
- The lint is pivotal for teams coming from other tools: instead of reading every entry by hand, they run one command and additionally get a report.
- Measured 1.6740 → 1.7010 (this arrow is a measurement and stays).
- `gstack-prose-lint`: a code span that says `not just X, but Y` is never scanned.
## [1.0.0] - 2026-10-01
An older entry that showcases the vibrant tapestry of the past and is never scanned under --unreleased.
+11 -11
View File
@@ -6,7 +6,7 @@
"agy/gstack-design-review": 123527,
"agy/gstack-plan-design-review": 121890,
"agy/gstack-plan-devex-review": 116514,
"agy/gstack-plan-eng-review": 144370,
"agy/gstack-plan-eng-review": 147266,
"agy/gstack-review": 103816,
"claude/design-review": 115449,
"codex/gstack-autoplan": 108553,
@@ -14,56 +14,56 @@
"codex/gstack-land-and-deploy": 100284,
"codex/gstack-plan-design-review": 122962,
"codex/gstack-plan-devex-review": 118022,
"codex/gstack-plan-eng-review": 145535,
"codex/gstack-plan-eng-review": 148431,
"codex/gstack-review": 103339,
"copilot/gstack-autoplan": 109253,
"copilot/gstack-design-review": 123142,
"copilot/gstack-plan-design-review": 122201,
"copilot/gstack-plan-devex-review": 115976,
"copilot/gstack-plan-eng-review": 144535,
"copilot/gstack-plan-eng-review": 147431,
"copilot/gstack-review": 103397,
"cursor/gstack-autoplan": 108485,
"cursor/gstack-design-review": 122407,
"cursor/gstack-plan-design-review": 120746,
"cursor/gstack-plan-devex-review": 115246,
"cursor/gstack-plan-eng-review": 143125,
"cursor/gstack-plan-eng-review": 146021,
"cursor/gstack-review": 121637,
"factory/gstack-autoplan": 110763,
"factory/gstack-design-review": 124191,
"factory/gstack-land-and-deploy": 100105,
"factory/gstack-plan-design-review": 123521,
"factory/gstack-plan-devex-review": 118609,
"factory/gstack-plan-eng-review": 146233,
"factory/gstack-plan-eng-review": 149129,
"factory/gstack-review": 125410,
"gbrain/gstack-autoplan": 108892,
"gbrain/gstack-design-review": 112612,
"gbrain/gstack-plan-design-review": 109503,
"gbrain/gstack-plan-eng-review": 125727,
"gbrain/gstack-plan-eng-review": 128623,
"hermes/gstack-autoplan": 108982,
"hermes/gstack-design-review": 112719,
"hermes/gstack-plan-design-review": 109612,
"hermes/gstack-plan-eng-review": 125812,
"hermes/gstack-plan-eng-review": 128708,
"kiro/gstack-autoplan": 108259,
"kiro/gstack-design-review": 122233,
"kiro/gstack-plan-design-review": 120598,
"kiro/gstack-plan-devex-review": 115098,
"kiro/gstack-plan-eng-review": 142987,
"kiro/gstack-plan-eng-review": 145883,
"kiro/gstack-review": 121461,
"openclaw/gstack-autoplan": 108715,
"openclaw/gstack-design-review": 111535,
"openclaw/gstack-plan-design-review": 108472,
"openclaw/gstack-plan-eng-review": 124242,
"openclaw/gstack-plan-eng-review": 127138,
"opencode/gstack-autoplan": 109234,
"opencode/gstack-design-review": 122992,
"opencode/gstack-plan-design-review": 122033,
"opencode/gstack-plan-devex-review": 115854,
"opencode/gstack-plan-eng-review": 144394,
"opencode/gstack-plan-eng-review": 147290,
"opencode/gstack-review": 122259,
"slate/gstack-autoplan": 108372,
"slate/gstack-design-review": 122320,
"slate/gstack-plan-design-review": 120672,
"slate/gstack-plan-devex-review": 115172,
"slate/gstack-plan-eng-review": 143056,
"slate/gstack-plan-eng-review": 145952,
"slate/gstack-review": 121549
}
}
+59 -9
View File
@@ -168,7 +168,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// wave's headline capability) grows the union to 1.195x. Deliberate:
// the section is on-demand (loads only for Apple store targets), so
// per-invocation cost for non-iOS ships is one manifest line.
maxSizeRatio: 1.7432, // GSTA-96 hoist merged with v1.91.270.0 (#3487, 2026-10-27): both owner-approved raises together, measured 327,203 / 187,706 = 1.7432. Before: Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 275,422 / 187,706 = 1.4673 (2026-10-06). Combined Oct 6 wave head (B free-text files + D measure loop + A rework): measured 280,363 / 187,706 = 1.4936 (2026-10-06). + gate-once push rule (no second paid gate after a CI push); measured 280,573 / 187,706 = 1.4947 (2026-10-06). + the measurement bar's decision step (exit-code branches for MEETS, MEETS-qualified, needs-classify with the qualifying and fix-round classes, EXTEND once; the measure prose was tightened to offset most of it); measured 281,058 / 187,706 = 1.4973 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 281,438 / 187,706 = 1.4994 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 281,556 / 187,706 = 1.5000 (2026-10-07). + Oct 7 wave B4: /ship publishes through gstack-post (title prepared in a file in the pr-body section, compose block plus gstack-post exit-code branches replacing the two scans); measured 281,894 / 187,706 = 1.5018 (2026-10-07). + B2 early Greptile PR (lazy Step 6.5 section: detect, public-repo consent, push, provisional title file, open; Step 10's bounded wait; the stop-leaves-the-draft rule); measured 286,626 / 187,706 = 1.5270 (2026-10-07). B3, the classifier, B4 and B2 merged: measured 287,128 / 187,706 = 1.5297 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 281,370 / 187,706 = 1.4990 (2026-10-07). Merged with the guard area: measured 287,440 / 187,706 = 1.5313 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 288,783 / 187,706 = 1.5385 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): the shared GATE_SCAN_BLOCK in the core checklist (2.5) and after Step 7's child, the four-option gate question and record fields in 9.4, tests_ran states in Step 5, Gate edits and per-lane counts in the PR body with draft-while-open; measured 300,143 / 187,706 = 1.5990 (2026-10-10). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10). + Oct 10 multi-agent wave PR E2 (ship/sections/handoff.md: the no-PR automation handoff, with its Section-index row and STOP pointer in the skeleton), offset by tightening Steps 1–3, 12, 14–17 and 20–21 prose so the skeleton sta Line truncated
maxSizeRatio: 1.7524, // GSTA-96 hoist merged with v1.91.273.0 (#3501, 2026-10-27): main's 1.7500 plus the hoist's own raise, no trim; measured 328,918 / 187,706 = 1.7523 on the merged tree. Before: Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 275,422 / 187,706 = 1.4673 (2026-10-06). Combined Oct 6 wave head (B free-text files + D measure loop + A rework): measured 280,363 / 187,706 = 1.4936 (2026-10-06). + gate-once push rule (no second paid gate after a CI push); measured 280,573 / 187,706 = 1.4947 (2026-10-06). + the measurement bar's decision step (exit-code branches for MEETS, MEETS-qualified, needs-classify with the qualifying and fix-round classes, EXTEND once; the measure prose was tightened to offset most of it); measured 281,058 / 187,706 = 1.4973 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 281,438 / 187,706 = 1.4994 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 281,556 / 187,706 = 1.5000 (2026-10-07). + Oct 7 wave B4: /ship publishes through gstack-post (title prepared in a file in the pr-body section, compose block plus gstack-post exit-code branches replacing the two scans); measured 281,894 / 187,706 = 1.5018 (2026-10-07). + B2 early Greptile PR (lazy Step 6.5 section: detect, public-repo consent, push, provisional title file, open; Step 10's bounded wait; the stop-leaves-the-draft rule); measured 286,626 / 187,706 = 1.5270 (2026-10-07). B3, the classifier, B4 and B2 merged: measured 287,128 / 187,706 = 1.5297 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 281,370 / 187,706 = 1.4990 (2026-10-07). Merged with the guard area: measured 287,440 / 187,706 = 1.5313 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 288,783 / 187,706 = 1.5385 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): the shared GATE_SCAN_BLOCK in the core checklist (2.5) and after Step 7's child, the four-option gate question and record fields in 9.4, tests_ran states in Step 5, Gate edits and per-lane counts in the PR body with draft-while-open; measured 300,143 / 187,706 = 1.5990 (2026-10-10). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10). + Oct 10 multi-agent wave PR E2 (ship/sections/handoff.md: the no-PR automation handoff, with its Section-index row and STOP pointer in the skeleton), offset by tightening Steps 1–3, 12, 14–17 and Line truncated
},
'plan-ceo-review': {
skill: 'plan-ceo-review',
@@ -201,7 +201,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
'Review the plan in PLAN.md. Accept the current scope. Run the full engineering review (architecture, code quality, tests, performance) and produce the review report.',
staticInvariants: {
mustStayInSkeleton: ['### Step 0: Scope Challenge'],
mustMoveToSection: ['### 1. Architecture review'],
mustMoveToSection: ['### 1. Architecture review', '### Two shapes before one (design-twice)', '**The eight red flags.**'],
gateAfterStop: 'EXIT PLAN MODE GATE',
},
behavioral: 'plan',
@@ -214,7 +214,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// skeleton ~1.5KB (entry resolution emitted once per SKILL.md).
maxSkeletonBytes: 49_659, // GSTA-96 hoist (2026-10-26): lowered from 57_800 by the shared preamble that moved to sections/preamble.md, keeping the pre-hoist headroom of 6,098 bytes; measured 43,561. Before: Scoped reuse entry guidance; measured 57,549 bytes (2026-09-16). Shared rubric remains in the existing section.
minUnionBytes: 99_800, // token-reduction Phases 1-2 (v1.69.x branch); measured union 110,910
mustContain: ['Architecture', 'Code Quality', 'Test', 'Performance'],
mustContain: ['Architecture', 'Code Quality', 'Test', 'Performance', 'interface depth'],
// Cross-cutting preamble growth (v1.57.2.0 AUQ-failure prose fallback + the
// decision-memory nudge + the v1.57.4.0 Boil-the-Ocean rename) plus the
// default-on Codex outside-voice (codexPreflight block + CODEX_MODE branch
@@ -223,7 +223,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// 1.08 → 1.10: the scope-gate exceptions block (+ its adversarial-review
// hardening: host-anchored mode signal, precedence, passing-mention
// guards) and the plan-mode preamble reword land the union at 1.092.
maxSizeRatio: 1.3290, // + clarity rules for saved decisions/setup gates + the Aside probe's failure reason; measured 1.1504. + test value bar and Tests to Retire in the lazy Test review section (~2.6KB); measured 1.168 + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.173 (2026-09-30). + v1.91.12.0 merge of #2999 (headless rule: a disallowed question tool never qualifies) with #3002; measured 1.1741 (2026-10-01). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 146,905 / 124,597 = 1.1790 (2026-10-06). Combined Oct 6 wave head (A and B): measured 147,524 / 124,597 = 1.1840 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 147,693 / 124,597 = 1.1854 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 147,752 / 124,597 = 1.1858 (2026-10-07). + fix wave PR D: the prerequisite offer is skipped in spawned/headless sessions (#1958); measured 147,864 / 124,597 = 1.1867 (2026-10-07). + Oct 10 multi-agent wave PR D1 (reality rows): the Scope Challenge reality-rows block, the deep incident table (18 rows, each tied to the incident that earned it), the Completion summary line and the checklist row; measured 151,423 / 124,597 = 1.2153 (2026-10-10). + #280: the Design Doc Check finds the CEO plan (`gstack-ceo-plan-find`) and names it in the data rule, +325 bytes on main's 151,630; measured 151,955 / 124,597 = 1.2196 (2026-10-12). Fix wave PR 4 (owner-approved raise 1.2170 → 1.2196): #3182's timer-bounded native fallback (net −17 bytes) plus #280's cross-branch rule, offset by tightening the Design Doc Check; measured 151,950 / 124,597 = 1.2195 (2026-10-15). + GSTA-96 shared-preamble hoist (owner-approved raise, Garry 2026-10-25: "do it"): the union now holds sections/preamble.md, so a fresh invocation reads the pointer plus the shared file and its own copies of shared sections twice until their dedupe; measured 165,589 / 124,597 = 1.3290 (2026-10-25). Before: 1.2196.
maxSizeRatio: 1.3532, // GSTA-96 hoist merged with v1.91.273.0 (#3500, 2026-10-27): the union now includes the shared sections/preamble.md (the hoist's owner-approved raise, Garry 2026-10-25 "do it") on top of main's 1.2400; measured 168,601 / 124,597 = 1.3532 on the merged tree. Before: pstack item 9 (Garry 2026-10-26 "do it"): the design-twice pass (two shapes, eight red flags, ~2.4 KB) joins Section 1 of the lazy review section; measured 154,366 / 124,597 = 1.2389 (2026-10-26). Earlier history: + clarity rules for saved decisions/setup gates + the Aside probe's failure reason; measured 1.1504. + test value bar and Tests to Retire in the lazy Test review section (~2.6KB); measured 1.168 + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.173 (2026-09-30). + v1.91.12.0 merge of #2999 (headless rule: a disallowed question tool never qualifies) with #3002; measured 1.1741 (2026-10-01). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 146,905 / 124,597 = 1.1790 (2026-10-06). Combined Oct 6 wave head (A and B): measured 147,524 / 124,597 = 1.1840 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 147,693 / 124,597 = 1.1854 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 147,752 / 124,597 = 1.1858 (2026-10-07). + fix wave PR D: the prerequisite offer is skipped in spawned/headless sessions (#1958); measured 147,864 / 124,597 = 1.1867 (2026-10-07). + Oct 10 multi-agent wave PR D1 (reality rows): the Scope Challenge reality-rows block, the deep incident table (18 rows, each tied to the incident that earned it), the Completion summary line and the checklist row; measured 151,423 / 124,597 = 1.2153 (2026-10-10). + #280: the Design Doc Check finds the CEO plan (`gstack-ceo-plan-find`) and names it in the data rule, +325 bytes on main's 151,630; measured 151,955 / 124,597 = 1.2196 (2026-10-12). Fix wave PR 4 (owner-approved raise 1.2170 → 1.2196): #3182's timer-bounded native fallback (net −17 bytes) plus #280's cross-branch rule, offset by tightening the Design Doc Check; measured 151,950 / 124,597 = 1.2195 (2026-10-15).
},
'plan-design-review': {
skill: 'plan-design-review',
@@ -847,7 +847,7 @@ do not launch the downstream skill or open a browser.`,
},
behavioral: 'external',
externalTest: 'test/skill-e2e-design.test.ts',
maxSkeletonBytes: 39_900, // new skill (GSTA-92 plan item 15, Garry 2026-10-25 "Do it"); measured skeleton 39,836 (2026-10-25)
maxSkeletonBytes: 18_112, // GSTA-96 hoist (2026-10-27): lowered from 39_900 by the shared preamble that moved to sections/preamble.md, keeping main's headroom of 32 bytes; measured 18,080 on the v1.91.273.0 merge. Before: new skill (GSTA-92 plan item 15, Garry 2026-10-25 "Do it"); measured skeleton 39,836 (2026-10-25)
minUnionBytes: 57_500, // measured union 58,666 (2026-10-25)
mustContain: ['window.__start()', 'window.__tick(dt)', 'whole milliseconds averaging 1000/60', 'Content never hides behind animation timing'],
},
@@ -858,7 +858,7 @@ do not launch the downstream skill or open a browser.`,
// Iron Law and the Scope Lock stay always-loaded.
investigate: {
skill: 'investigate',
expectedSections: ['flake.md', 'polluter.md', 'race.md', 'eval-bisect.md', 'backlog.md'],
expectedSections: ['flake.md', 'polluter.md', 'race.md', 'eval-bisect.md', 'backlog.md', 'blast-radius.md'],
requiredReads: ['flake.md'],
scenario:
'Walk /investigate in SIMULATION — do not run bash, edit files or acquire a freeze lock. The symptom: test/widget.test.ts fails in CI about one run in eight ("expected 3 events, got 2") and passes locally every time; the PR under review only touched src/widget.ts. Do NOT use AskUserQuestion. Read each pointed section before doing its step, apply the matching recipe, and report the investigation plan: what you would run on main and on the branch, the forced probe, and the exact `## Flake evidence` section you would require in the PR body. Stop before Phase 4.',
@@ -881,14 +881,64 @@ do not launch the downstream skill or open a browser.`,
'## Backlog mode (`/investigate --backlog`)',
'## Flake evidence',
'ship-measure.ts free --files',
'## Blast radius',
'Look where grep stops',
],
gateAfterStop: undefined, // operational skill, no plan-mode gate
},
behavioral: 'prompt',
maxSkeletonBytes: 30_178, // GSTA-96 hoist (2026-10-26): lowered from 52_000 by the shared preamble that moved to sections/preamble.md, keeping the pre-hoist headroom of 932 bytes; measured 29,246. Before: D3 carve: Arguments table, Section index, Phase 1 call-site and update-index additions, five STOP pointers; measured 51,532 (2026-10-10)
maxSkeletonBytes: 30_210, // GSTA-96 hoist (2026-10-27): lowered from 52_000 by the shared preamble that moved to sections/preamble.md, keeping main's headroom of 377 bytes; measured 29,833 on the v1.91.273.0 merge. Before: D3 carve: Arguments table, Section index, Phase 1 call-site and update-index additions, five STOP pointers; measured 51,532 (2026-10-10). + pstack item 2(b)/8 (Garry 2026-10-26 "do it"): the blast-radius STOP pointer and index row plus the shared-premise red flag; measured 51,623 (2026-10-26), inside the existing cap.
minUnionBytes: 55_000, // D3 carve: skeleton + five recipes; measured union 61,513 (2026-10-10)
mustContain: ['root cause', 'hypothes', 'Flake evidence', 'still_open', 'already_fixed', 'P(fail | regression)'],
maxSizeRatio: 1.12, // the monolith's cap (parity-harness MONOLITH_INVARIANTS before the carve); measured 61,513 / 60,600 = 1.0151 (2026-10-10)
mustContain: ['root cause', 'hypothes', 'Flake evidence', 'still_open', 'already_fixed', 'P(fail | regression)', 'the one fact it is safe because of'],
maxSizeRatio: 1.12, // the monolith's cap (parity-harness MONOLITH_INVARIANTS before the carve); measured 61,513 / 60,600 = 1.0151 (2026-10-10). + the blast-radius section (2.8 KB); measured 66,167 / 60,600 = 1.0919 (2026-10-26).
},
// ── pstack absorb C1 (2026-10-26, Garry "do it"): context-restore's first carve ──
// One lazy section: the no-checkpoint fallback that mines this workspace's
// own transcripts (host stores from hosts/*.ts, a fixed window, never another
// project's folder, cheap subagents return only findings, git and gh verify
// every artifact, the status-tagged brief). Detect, Step 1 and the summary
// stay always-loaded.
'context-restore': {
skill: 'context-restore',
expectedSections: ['transcripts.md'],
requiredReads: ['transcripts.md'],
scenario:
'Walk /context-restore in SIMULATION — do not run bash or read any transcript. Step 1 printed exactly NO_CHECKPOINTS and nothing else (no BELOW_CWD, POINTER or LEGACY_BUCKET line). Do NOT use AskUserQuestion. Read the pointed section and report the plan: which transcript store you would list on this host and how this workspace\'s folder is told apart from another project\'s, the window you would use, how the files would be read and what returns to this conversation, what you would verify with git and gh, and the four headings of the brief with the status tags a thread line may carry.',
staticInvariants: {
mustStayInSkeleton: ['## Detect command', '### Step 1: Find saved contexts', '## If no saved contexts exist', 'RESUMING CONTEXT', '## Important Rules'],
mustPrecedeStop: ['### Step 1: Find saved contexts'],
mustMoveToSection: ["## No saved context: mine this workspace's transcripts", '[verified, uncommitted]', 'TRANSCRIPT_DAYS'],
gateAfterStop: undefined, // operational skill, no plan-mode gate
},
behavioral: 'prompt',
maxSkeletonBytes: 24_514, // GSTA-96 hoist (2026-10-27): lowered from 46_300 by the shared preamble that moved to sections/preamble.md, keeping main's headroom of 452 bytes; measured 24,062 on the v1.91.273.0 merge. Before: first carve: Section index and one STOP pointer on the 45.0k monolith; measured 45,848 (2026-10-26)
minUnionBytes: 50_000, // skeleton + transcripts section; measured union 52,510 (2026-10-26)
mustContain: ['NO_CHECKPOINTS', 'RESUMING CONTEXT', '[merged #N]', "never open another project's", 'gstack-redact', 'last 7 days'],
// The v1.64.1.0 baseline measured the 53.5k monolith; the carved union is below it.
},
// ── pstack absorb C1 (2026-10-26, Garry "do it"): benchmark's first carve ──
// One lazy section: the seven questions that vet a measured speedup or
// regression before Phase 5 reports it. Setup, collection, baseline, budget
// and trend phases stay always-loaded.
benchmark: {
skill: 'benchmark',
expectedSections: ['vet-the-number.md'],
requiredReads: ['vet-the-number.md'],
scenario:
'Walk /benchmark in SIMULATION — do not run bash, aside or a browser. A baseline exists and the current run already measured the dashboard page: LCP 1.6 s on the baseline, 1.1 s now, from one run per side on a laptop that was also building. Do NOT use AskUserQuestion. Before writing the Phase 5 comparison, read the pointed section, then report the verdict you would give for this delta and which of its questions decide it, and what you would run before calling it a speedup.',
staticInvariants: {
mustStayInSkeleton: ['## Arguments', '### Phase 3: Performance Data Collection', '### Phase 5: Comparison', '### Phase 7: Performance Budget', '## Important Rules'],
mustPrecedeStop: ['## Arguments'],
mustMoveToSection: ['## Vet the number', '**Why not double?**', '**Did it even happen?**'],
gateAfterStop: undefined, // operational skill, no plan-mode gate
},
behavioral: 'prompt',
maxSkeletonBytes: 31_300, // first carve: Section index, one STOP pointer and the vet rule on the 29.9k monolith; measured 30,781 (2026-10-26)
minUnionBytes: 33_000, // skeleton + vet-the-number section; measured union 34,527 (2026-10-26)
mustContain: ['performance.getEntries()', 'REGRESSION', 'Name the limiter', 'run-to-run variation', 'inconclusive'],
// The v1.64.1.0 baseline measured the 37.2k monolith; the carved union is below it.
},
};
+2
View File
@@ -32,6 +32,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
'test/fixtures/autoplan-with-result-au.json': ['test/autoplan-phase-observer.test.ts'],
'test/fixtures/browse-judge/**': ['test/workflow-judge-cache.test.ts'],
'test/fixtures/ceo-completion-handoff-m-call.json': ['test/plan-count-completion.test.ts'],
'test/fixtures/context-restore-transcript.jsonl': ['test/context-restore-transcripts.test.ts'],
'test/fixtures/ceo-completion-handoff-o-call.json': ['test/plan-count-completion.test.ts'],
'test/fixtures/ceo-completion-handoff-q-call.json': ['test/plan-count-completion.test.ts'],
'test/fixtures/ceo-count-ad-v2.json': ['test/ceo-count-ad-v2.test.ts'],
@@ -180,6 +181,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
'test/fixtures/plans/autoplan-focus-appearance.md': [],
'test/fixtures/plans/ui-heavy-feature-design.md': [],
'test/fixtures/prosons-neutral-neg-captures.json': ['test/prosons-neutral-posture.test.ts'],
'test/fixtures/prose-lint/**': ['test/prose-lint.test.ts'],
'test/fixtures/pty-idle-turn-end.json': ['test/helpers/claude-pty-runner.runners.unit.test.ts', 'test/helpers/claude-pty-runner.screen.unit.test.ts'],
'test/fixtures/redact-diagram/**': ['test/redact-context-exemptions.test.ts'],
'test/fixtures/remote-identity-vectors.json': ['test/bin-context-windows-slug.test.ts', 'test/gstack-slug-parity.test.ts', 'test/remote-identity-vectors.test.ts'],
+6
View File
@@ -430,6 +430,8 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
'carve-section-loading-design-html': ['design-html/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
'carve-section-loading-design-shotgun': ['design-shotgun/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
'carve-section-loading-document-release': ['document-release/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
'carve-section-loading-benchmark': ['benchmark/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
'carve-section-loading-context-restore': ['context-restore/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
'carve-section-loading-investigate': ['investigate/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
'carve-section-loading-land-and-deploy': ['land-and-deploy/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
'carve-section-loading-plan-design-review': ['plan-design-review/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
@@ -1309,6 +1311,8 @@ export const E2E_TIERS: Record<string, 'gate' | 'periodic' | 'marathon'> = {
'carve-section-loading-design-html': 'periodic',
'carve-section-loading-design-shotgun': 'periodic',
'carve-section-loading-document-release': 'periodic',
'carve-section-loading-benchmark': 'periodic',
'carve-section-loading-context-restore': 'periodic',
'carve-section-loading-investigate': 'periodic',
'carve-section-loading-land-and-deploy': 'periodic',
'carve-section-loading-plan-design-review': 'periodic',
@@ -1776,6 +1780,8 @@ export const E2E_KINDS: Record<string, 'rule' | 'behavior' | 'judge'> = {
'carve-section-loading-design-html': 'rule',
'carve-section-loading-design-shotgun': 'rule',
'carve-section-loading-document-release': 'rule',
'carve-section-loading-benchmark': 'rule',
'carve-section-loading-context-restore': 'rule',
'carve-section-loading-investigate': 'rule',
'carve-section-loading-land-and-deploy': 'rule',
'carve-section-loading-plan-design-review': 'rule',
+18 -3
View File
@@ -13,12 +13,12 @@ import { CARVE_GUARDS } from './helpers/carve-guards';
const ROOT = path.resolve(import.meta.dir, '..');
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
const RECIPES = ['flake', 'polluter', 'race', 'eval-bisect', 'backlog'] as const;
const RECIPES = ['flake', 'polluter', 'race', 'eval-bisect', 'backlog', 'blast-radius'] as const;
describe('investigate carve', () => {
const manifest = JSON.parse(read('investigate/sections/manifest.json')) as { skill: string; sections: Array<{ id: string; file: string; trigger: string }> };
const skeleton = read('investigate/SKILL.md');
test('manifest lists the five recipes and the registry guards them', () => {
test('manifest lists the six recipes and the registry guards them', () => {
expect(manifest.skill).toBe('investigate');
expect(manifest.sections.map(s => s.id)).toEqual([...RECIPES]);
expect(CARVE_GUARDS.investigate!.expectedSections).toEqual(manifest.sections.map(s => s.file));
@@ -95,13 +95,28 @@ describe('recipe tokens', () => {
expect(backlog).toMatch(/no `gh issue close`/);
expect(backlog).not.toMatch(/gh issue close <n>/);
});
test('blast-radius: the one safety fact, the five-rung ladder, look where grep stops, confirmed and cleared risks apart, the Phase 4 pointer and the shared-premise red flag', () => {
const blast = read('investigate/sections/blast-radius.md');
expect(blast).toContain('## Blast radius');
expect(blast).toContain('**Name the one fact it is safe because of.**');
expect(blast).toContain('**Look where grep stops.**');
expect(blast).toMatch(/1\. You said so\.[\s\S]*5\. You reproduced it in the running app\./);
expect(blast).toContain('**Keep confirmed risks and cleared risks apart.**');
expect(blast).toContain('the word "unproven"');
const skeleton = read('investigate/SKILL.md');
const phase4 = skeleton.slice(skeleton.indexOf('## Phase 4: Implementation'), skeleton.indexOf('## Phase 5: Verification & Report'));
expect(phase4).toContain('sections/blast-radius.md');
expect(phase4.indexOf('sections/blast-radius.md')).toBeLessThan(phase4.indexOf('**Fix the root cause, not the symptom.**'));
const phase3 = skeleton.slice(skeleton.indexOf('## Phase 3: Hypothesis Testing'), skeleton.indexOf('## Phase 4: Implementation'));
expect(phase3).toMatch(/Two fixes that shared one premise failed the same gate — write the premise down in one sentence and test it before a third fix/);
});
});
describe('external hosts inline the recipes', () => {
const codex = path.join(ROOT, '.agents', 'skills', 'gstack-investigate', 'SKILL.md');
test.skipIf(!fs.existsSync(codex))('the codex render carries every recipe heading and no section pointer', () => {
const text = fs.readFileSync(codex, 'utf8');
for (const heading of ['## Flake recipe', '## Polluter recipe', '## Race recipe', '## Eval bisect recipe', '## Backlog mode']) expect(text).toContain(heading);
for (const heading of ['## Flake recipe', '## Polluter recipe', '## Race recipe', '## Eval bisect recipe', '## Backlog mode', '## Blast radius']) expect(text).toContain(heading);
expect(text).not.toContain('sections/flake.md');
});
});
+3 -1
View File
@@ -811,7 +811,9 @@ console.log("Ran 1 tests across 1 files. [1ms]"); process.exit(${fail ? 1 : 0});
// The report's after-a-repair command selects exactly one case: --case when it resolves, else that shard's file.
expect(afterRepairCommand('gate', 'plan-mode-no-op', 'test/skill-e2e-plan-mode-no-op.test.ts')).toBe('bun run scripts/test-paid-shards.ts --tier gate --case plan-mode-no-op');
expect(afterRepairCommand('periodic', 'carve-section-loading', 'test/carve-section-loading-qa.test.ts')).toBe('EVALS=1 EVALS_TIER=periodic bun test test/carve-section-loading-qa.test.ts');
});
// caseSelection parses every paid file once per registered id; the loop took 4.97 s
// on a 4-core machine at main (2026-10-26), so the default 5 s budget is not a measurement of anything.
}, 30_000);
test('B4: --list prints the selection; an unknown id exits non-zero before any process starts', () => {
const run = (args: string[]) => spawnSync(process.execPath, [path.join(ROOT, 'scripts/test-paid-shards.ts'), ...args], { encoding: 'utf8', timeout: 60_000, cwd: ROOT });
+357
View File
@@ -0,0 +1,357 @@
/**
* gstack-prose-lint: the AI tells a regular expression can decide, over the
* CHANGELOG entry and PR body /ship writes (absorbed from pstack's unslop,
* rewritten; NOTICE.md). Positive and negative fixtures per rule, the two
* selections (--unreleased, --base), the exit codes, and the template and
* workflow wiring that calls it.
*/
import { describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { addedOnly, lintProse, maskProse, renderProseLint, sectionsOnly, unreleasedBlock } from '../lib/prose-lint';
const ROOT = path.resolve(import.meta.dir, '..');
const FIXTURES = path.join(ROOT, 'test', 'fixtures', 'prose-lint');
const BIN = path.join(ROOT, 'bin', 'gstack-prose-lint');
const run = (args: string[], cwd = ROOT) => {
const r = spawnSync('bun', [BIN, ...args], { cwd, encoding: 'utf8', timeout: 60_000 });
return { code: r.status ?? -1, out: r.stdout ?? '', err: r.stderr ?? '' };
};
const rules = (text: string) => lintProse(text, 'x.md').map(f => f.rule);
describe('lintProse: each rule, positive and negative', () => {
test('vocabulary flags the autopilot words and leaves plain prose alone', () => {
expect(rules('This release showcases a vibrant, pivotal change that underscores our intricate work.')).toEqual(['vocabulary', 'vocabulary', 'vocabulary', 'vocabulary', 'vocabulary']);
expect(rules('The lint reads the entry and names each line to rewrite.')).toEqual([]);
expect(rules('Rotate the photo to landscape orientation; a leading underscore marks a private name.')).toEqual([]);
expect(rules('They mapped the vendor landscape before choosing.')).toEqual(['vocabulary']);
});
test('not-just-but catches the contrast in both spellings', () => {
expect(rules("It isn't just a linter, but a whole new way of writing.")).toEqual(['not-just-but']);
expect(rules('This is not only faster but also smaller.')).toEqual(['not-just-but']);
expect(rules('The lint is fast. It is also small.')).toEqual([]);
});
test('metaphor-noun names the plain word', () => {
const f = lintProse('The queue is the substrate every lane builds on, and its north star is a green train.', 'x.md');
expect(f.map(x => [x.rule, x.match, x.hint])).toEqual([
['metaphor-noun', 'substrate', 'say "base"'],
['metaphor-noun', 'north star', 'say "goal"'],
]);
expect(rules('A smaller API with two exported names is easier to keep stable.')).toEqual([]);
expect(rules('Shrink the API surface before the release.')).toEqual(['metaphor-noun']);
});
test("mid-sentence-colon flags a colon doing a conjunction's job and spares labels, lists and times", () => {
expect(rules('If you come from other tools: instead of reading every entry by hand, run one command.')).toEqual(['mid-sentence-colon']);
expect(rules('- The classifier is conservative: a local lane that cannot prove its base is refused.')).toEqual(['mid-sentence-colon']);
expect(rules('Fix: run the command again.')).toEqual([]);
expect(rules('The gate reads three inputs: the head, the base and the receipt.')).toEqual([]);
expect(rules('The run started at 12:30 and the following rules apply: no retries, no merges.')).toEqual([]);
expect(rules('- **`gstack-post`**: the receipt is written first.')).toEqual([]);
expect(lintProse(['The parser stops at the end of a line here', 'see note: something else happens now'].join('\n'), 'x.md')).toEqual([]);
});
test('inline-header-list flags a bold label that restates its line and spares a label that adds detail', () => {
expect(rules('- **Performance:** Performance improved by 40% on the hot path.')).toEqual(['inline-header-list']);
expect(rules('- **Performance.** The hot path is 40% faster because the parser short-circuits.')).toEqual([]);
expect(rules('- **`bin/gstack-prose-lint`**: `--gate` exits 1 on a finding.')).toEqual([]);
});
test('arrow flags symbol-speak and keeps measurements and the before → after heading', () => {
expect(rules('Bad input → exit 2, no write.')).toEqual(['arrow']);
expect(rules('policy -> warn')).toEqual(['arrow']);
expect(rules('The ratio moved 1.6740 → 1.7010 (measured 323,295 / 187,706) and the pins 250 -> 500.')).toEqual([]);
expect(rules('### Before → after')).toEqual([]);
expect(rules('Rename `old` → `new` in one pass.')).toEqual([]);
});
test('dropped-article flags a bare noun opening a sentence and spares one with its article', () => {
expect(rules('Gate refuses stale receipt and exits.')).toEqual(['dropped-article']);
expect(rules('- Gate refuses the head when the receipt is stale.')).toEqual(['dropped-article']);
expect(rules('The gate refuses a stale receipt and exits.')).toEqual([]);
expect(rules('Garry approves the plan; Bun runs the tests.')).toEqual([]);
expect(rules('Parser errors are rare.')).toEqual([]);
for (const s of ['Test runs now finish faster.', 'Error logs are rotated daily.', 'Tool calls take a while.', 'Merge commits stay.']) expect(rules(s), s).toEqual([]);
});
test('fenced code, inline code, URLs and HTML comments are never scanned, and line numbers hold', () => {
const text = [
'# Title',
'',
'```bash',
'echo "not just X, but Y" → substrate',
'```',
'See https://example.com/vibrant-tapestry and `a pivotal → change`. <!-- showcases -->',
'Gate refuses stale receipt.',
].join('\n');
const f = lintProse(text, 'x.md');
expect(f.map(x => [x.rule, x.line])).toEqual([['dropped-article', 7]]);
const { masked, unclosed } = maskProse(text);
expect(unclosed).toBeNull();
expect(masked.split('\n').length).toBe(text.split('\n').length);
expect(masked).not.toContain('substrate');
expect(masked).not.toContain('vibrant');
});
// Value: protects=someone else's words are never the author's finding (the Capy `## Why` quotes the person's problem verbatim), and a double-backtick code span holding a single backtick is one code span; fails_when=a quoted tell gates the body, or the inner backtick splits the span and leaks its text; why_new=new masking; seam=none; no_claim=a quote spanning lines is read
test('quoted words and multi-backtick code spans are masked', () => {
expect(rules('"Please enhance the landscape view." The fix names the view.')).toEqual([]);
expect(rules('“Please enhance the landscape view.” The fix names the view.')).toEqual([]);
expect(rules('He asked to enhance the view.')).toEqual(['vocabulary']);
expect(rules('Use `` `name` showcases the result `` as the span.')).toEqual([]);
expect(rules('Rename --foo -> --bar in the flags.')).toEqual(['arrow']);
});
// Value: protects=a fence or comment left open is a finding instead of a silent mask of everything after it, and a fence closes only on a bare fence line; fails_when=an unclosed block reads as clean, or a fence line with an info string closes the block early; why_new=the fence test above pinned the fail-open behaviour; seam=none; no_claim=an indented code block is not a fence
test('an unclosed fence or comment is a finding at its opening line, and an info string never closes a fence', () => {
expect(lintProse(['Plain.', '```', 'showcasing a vibrant release'].join('\n'), 'x.md').map(f => [f.rule, f.line])).toEqual([['unclosed-block', 2]]);
expect(lintProse(['Plain. <!-- oops', 'It is crucial and pivotal.'].join('\n'), 'x.md').map(f => [f.rule, f.line])).toEqual([['unclosed-block', 1]]);
expect(lintProse(['```', 'foo', '```js', 'crucial', '```', 'done, and vibrant.'].join('\n'), 'x.md').map(f => [f.rule, f.line])).toEqual([['vocabulary', 6]]);
});
// Value: protects=a longer fence is closed only by a fence at least as long, so a quoted template in a PR body draft is never linted; fails_when=a shorter inner fence or a tilde fence closes the block early, or an unclosed fence stops masking; why_new=the case above uses one three-backtick fence; seam=none; no_claim=other fence syntaxes (indented code blocks) are not masked
test('a longer fence holds a shorter inner fence, a tilde fence masks too, and an unclosed fence masks to the end', () => {
const text = [
'````md',
'```',
'Showcasing a vibrant release.',
'````',
'Gate refuses stale receipt.',
'~~~',
'Showcasing a vibrant release.',
'~~~',
'```',
'Showcasing a vibrant release.',
].join('\n');
expect(lintProse(text, 'x.md').map(f => [f.rule, f.line])).toEqual([['dropped-article', 5], ['unclosed-block', 9]]);
});
test('findings are sorted by line and the render ends in the trailer', () => {
const text = renderProseLint({ findings: lintProse('Showcasing a vibrant release.\n\nParser rejects it.', 'f.md'), scanned: ['f.md'], empty: [] });
expect(text.split('\n').filter(Boolean)).toEqual([
'PROSE: f.md:1 vocabulary "Showcasing" (use the plain word)',
'PROSE: f.md:1 vocabulary "vibrant" (use the plain word)',
'PROSE: f.md:3 dropped-article "Parser rejects" (start with "the" or "a")',
'PROSE_LINT: found=3 files=1',
]);
expect(renderProseLint({ findings: [], scanned: ['a'], empty: [] })).toBe('PROSE_LINT: ok files=1\n');
expect(renderProseLint({ findings: [], scanned: [], empty: ['a'] })).toBe('PROSE_LINT: none files=0 nothing-to-scan=1\n');
});
});
describe('selections', () => {
// Value: protects=the Step 19 compose gate reads only the sections an author writes, so a verbatim tool section (a review line such as "Problem → what you did") can never dead-end /ship; fails_when=a tool section's text is linted, an author section is skipped, heading case is significant, or a body with none of the headings reads as scanned; why_new=new selection; seam=none; no_claim=the compose block's heading list itself is pinned by the wiring test
test('sectionsOnly keeps only the named ## sections, line numbers intact, and is null when none is present', () => {
const body = [
'## Summary', 'The parser showcases a vibrant result.', '',
'## Pre-Landing Review', '[AUTO-FIXED] [x.ts:1] Problem → what you did', '',
'## blast radius', 'Touches the compose block only.', 'Caller -> callee is the only arrow here.',
'## Test plan', '- [x] bun test → 20 pass',
].join('\n');
const picked = sectionsOnly(body, ['Summary', 'Blast radius'])!;
expect(picked.split('\n')).toHaveLength(body.split('\n').length);
expect(lintProse(picked, 'b.md').map(f => [f.rule, f.line])).toEqual([['vocabulary', 2], ['vocabulary', 2], ['arrow', 9]]);
expect(sectionsOnly(body, ['Why'])).toBeNull();
const fenced = ['## Summary', 'Fine.', '', '## Pre-Landing Review', '```codex-review', '## Summary', 'The parser showcases a vibrant result.', '```', '', '## Blast radius', '```', '## Why', 'a fenced heading showcases nothing', '```', 'but this vibrant line is read.'].join('\n');
expect(lintProse(sectionsOnly(fenced, ['Summary', 'Blast radius'])!, 'b.md').map(f => [f.rule, f.line])).toEqual([['vocabulary', 15]]);
const gated = run([path.join(FIXTURES, 'slopped-changelog.md'), '--sections', '[1.0.0] - 2026-10-01', '--gate']);
expect(gated.code).toBe(1);
expect(gated.out).toMatch(/^PROSE: \S+slopped-changelog\.md:16 /m);
expect(gated.out).not.toMatch(/^PROSE: \S+slopped-changelog\.md:(5|8|9|10) /m);
expect(run([path.join(FIXTURES, 'clean-changelog.md'), '--sections', 'Nope']).out).toContain('PROSE_LINT: none');
const lost = run([path.join(FIXTURES, 'clean-changelog.md'), '--sections', 'Nope', '--gate']);
expect(lost.code).toBe(2);
expect(lost.err).toContain('nothing would be gated');
const commented = ['## Summary', 'Fine.', '<!--', '## Test plan', '-->', 'Still the summary, showcasing a vibrant tell.'].join('\n');
expect(lintProse(sectionsOnly(commented, ['Summary'])!, 'b.md').map(f => [f.rule, f.line])).toEqual([['vocabulary', 6], ['vocabulary', 6]]);
});
// Value: protects=an HTML comment that spans lines is never read, as the header promises; fails_when=the second line of a comment, or the text before its closing marker, produces a finding, or prose after the close is skipped; why_new=the mask test above covers one-line comments only; seam=none; no_claim=none beyond protects
test('a multi-line HTML comment is masked through its closing marker and prose after it is read', () => {
const text = ['Plain start. <!-- a vibrant', 'multi-line crucial comment', 'still pivotal --> The parser showcases it.', 'Showcasing a vibrant end.'].join('\n');
expect(lintProse(text, 'x.md').map(f => [f.rule, f.line])).toEqual([['vocabulary', 3], ['vocabulary', 4], ['vocabulary', 4]]);
const quoted = ['Use `<!--` to open a comment, or see https://example.com/<!--', 'The parser showcases a vibrant result.'].join('\n');
expect(lintProse(quoted, 'x.md').map(f => [f.rule, f.line])).toEqual([['vocabulary', 2], ['vocabulary', 2]]);
});
test('unreleasedBlock returns the block with its heading line, or null', () => {
const text = fs.readFileSync(path.join(FIXTURES, 'slopped-changelog.md'), 'utf8');
const block = unreleasedBlock(text)!;
expect(block.line).toBe(3);
expect(block.text).toStartWith('## [Unreleased]');
expect(block.text).not.toContain('[1.0.0]');
expect(unreleasedBlock('# Changelog\n\n## [1.0.0] - 2026-01-01\n- x\n')).toBeNull();
});
// Value: protects=the Step 13 call (--base <ref> --gate): exit 1 on a tell the branch added, none for an older one, exit 2 and no trailer on an unresolvable ref; fails_when=a bad ref or failed git diff reads as ok, --gate stops gating, or a ref with no merge base is not diffed directly; why_new=this test asserted only the none case and addedOnly's return values; seam=none; no_claim=origin/<base> resolution in a real clone (a temp repo with a local main stands in)
test('addedOnly keeps only the lines the working tree added, with their line numbers, and reads an untracked file whole', () => {
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-prose-lint-'));
const git = (...a: string[]) => {
const r = spawnSync('git', a, { cwd: repo, encoding: 'utf8', timeout: 30_000, env: { ...process.env, GIT_CONFIG_GLOBAL: path.join(repo, '.gitconfig'), GIT_CONFIG_NOSYSTEM: '1', GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@x', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@x' } });
if (r.status !== 0) throw new Error(r.stderr);
return r.stdout.trim();
};
try {
git('init', '-q', '-b', 'main');
fs.writeFileSync(path.join(repo, 'CHANGELOG.md'), '# Changelog\n\n## [1.0.0] - 2026-01-01\n\nOld entry showcases a vibrant tapestry.\n');
git('add', '.'); git('commit', '-q', '-m', 'base');
git('checkout', '-q', '-b', 'feature');
fs.writeFileSync(path.join(repo, 'CHANGELOG.md'), '# Changelog\n\n## [1.0.1] - 2026-01-02\n\nGate refuses stale receipt → exit 3.\n\n## [1.0.0] - 2026-01-01\n\nOld entry showcases a vibrant tapestry.\n');
const cwd = process.cwd();
process.chdir(repo);
try {
const added = addedOnly('CHANGELOG.md', 'main')!;
expect([...added.added!]).toEqual([3, 4, 5, 6]);
expect(added.text.split('\n')[8]).toBe('Old entry showcases a vibrant tapestry.');
const gated = run(['CHANGELOG.md', '--base', 'main', '--gate'], repo);
expect(gated.code).toBe(1);
expect(gated.out).toMatch(/^PROSE: CHANGELOG\.md:5 arrow /m);
expect(gated.out).toMatch(/^PROSE: CHANGELOG\.md:5 dropped-article /m);
expect(gated.out).not.toMatch(/^PROSE: CHANGELOG\.md:9 /m);
expect(gated.err).toContain('PROSE_LINT_FOUND');
fs.writeFileSync(path.join(repo, 'CHANGELOG.md'), '# Changelog\n\n## [1.0.0] - 2026-01-01\n\n```\nold line\nit delves into a vibrant example\n```\nOld entry showcases a vibrant tapestry.\n');
git('add', '.'); git('commit', '-q', '-m', 'base with a fence');
git('checkout', '-q', 'main'); git('merge', '-q', '--ff-only', 'feature'); git('checkout', '-q', 'feature');
fs.writeFileSync(path.join(repo, 'CHANGELOG.md'), '# Changelog\n\n## [1.0.0] - 2026-01-01\n\n```\nold line\nit delves into a vibrant example\nadded line that showcases code\n```\nOld entry showcases a vibrant tapestry.\n');
const fenced = run(['CHANGELOG.md', '--base', 'main', '--gate'], repo);
expect(fenced.code).toBe(0);
expect(fenced.out).toBe('PROSE_LINT: ok files=1\n');
git('checkout', '-q', '--', 'CHANGELOG.md');
fs.writeFileSync(path.join(repo, 'CHANGELOG.md'), '# Changelog\n\n## [1.0.1] - 2026-01-02\n\nGate refuses stale receipt → exit 3.\n\n## [1.0.0] - 2026-01-01\n\n```\nold line\nit delves into a vibrant example\n```\nOld entry showcases a vibrant tapestry.\n');
const badRef = run(['CHANGELOG.md', '--base', 'no-such-ref', '--gate'], repo);
expect(badRef.code).toBe(2);
expect(badRef.out).not.toContain('PROSE_LINT:');
git('branch', 'orphan', git('commit-tree', git('write-tree'), '-m', 'unrelated history'));
expect(() => git('merge-base', 'orphan', 'HEAD')).toThrow();
expect([...addedOnly('CHANGELOG.md', 'orphan')!.added!]).toEqual([3, 4, 5, 6]);
fs.writeFileSync(path.join(repo, 'NEW.md'), 'A vibrant note.\n');
expect(addedOnly('NEW.md', 'main')).toEqual({ text: 'A vibrant note.\n', added: null });
git('checkout', '-q', '--', 'CHANGELOG.md');
expect(addedOnly('CHANGELOG.md', 'main')).toBeNull();
} finally { process.chdir(cwd); }
const r = run(['CHANGELOG.md', '--base', 'main'], repo);
expect(r.out.trim()).toBe('PROSE_LINT: none files=0 nothing-to-scan=1');
} finally { fs.rmSync(repo, { recursive: true, force: true }); }
});
});
describe('bin/gstack-prose-lint', () => {
// Value: protects=printed line numbers locate the finding in the file, and one gated call covers several files (the Step 19 body-draft shape); fails_when=the block offset is off by one, or a later file's findings are dropped or misattributed; why_new=the line check here was a 3..12 range and the multi-file call ran only as a report; seam=none; no_claim=none beyond protects
test('a slopped [Unreleased] block gates with exit 1, every rule named, and the result code on stderr', () => {
const r = run([path.join(FIXTURES, 'slopped-changelog.md'), '--unreleased', '--gate']);
expect(r.code).toBe(1);
const found = [...r.out.matchAll(/^PROSE: \S+:(\d+) (\S+) /gm)].map(m => [Number(m[1]), m[2]] as const);
for (const rule of ['vocabulary', 'not-just-but', 'metaphor-noun', 'mid-sentence-colon', 'inline-header-list', 'arrow', 'dropped-article']) expect(found.map(f => f[1])).toContain(rule);
expect(found.every(f => f[0] >= 3 && f[0] <= 12)).toBe(true);
for (const pair of [[5, 'not-just-but'], [8, 'inline-header-list'], [9, 'arrow'], [10, 'mid-sentence-colon']] as const) expect(found).toContainEqual(pair);
const both = run([path.join(FIXTURES, 'clean-changelog.md'), path.join(FIXTURES, 'slopped-changelog.md'), '--unreleased', '--gate']);
expect(both.code).toBe(1);
expect(both.out).toMatch(/^PROSE: \S+slopped-changelog\.md:5 /m);
expect(both.out).not.toMatch(/^PROSE: \S+clean-changelog\.md/m);
expect(both.out).toMatch(/\nPROSE_LINT: found=\d+ files=2\n$/);
expect(r.out).toMatch(/\nPROSE_LINT: found=\d+ files=1\n$/);
expect(r.err).toMatch(/^gstack-prose-lint: .+; fix: .+ \(PROSE_LINT_FOUND\)$/m);
});
test('the clean block is ok, and the same file read whole shows the older entry the selection skipped', () => {
const clean = path.join(FIXTURES, 'clean-changelog.md');
const gated = run([clean, '--unreleased', '--gate']);
expect(gated.code).toBe(0);
expect(gated.out.trim()).toBe('PROSE_LINT: ok files=1');
const whole = run([clean]);
expect(whole.code).toBe(0);
expect(whole.out).toMatch(/^PROSE: \S+clean-changelog\.md:15 vocabulary/m);
expect(whole.out).toMatch(/PROSE_LINT: found=\d+ files=1\n$/);
});
test('no [Unreleased] block is none, exit 0, even with --gate', () => {
const r = run([path.join(ROOT, 'NOTICE.md'), '--unreleased', '--gate']);
expect(r.code).toBe(0);
expect(r.out.trim()).toBe('PROSE_LINT: none files=0 nothing-to-scan=1');
});
// Value: protects=a --base with no ref, a directory and a call with no file exit 2 with their own message, never a clean run; fails_when=a missing ref falls through to a whole-file lint (exit 0) or no file reads as none; why_new=the rows above cover unknown flags, a missing file and --base with --unreleased only; seam=none; no_claim=none beyond protects
test('--json carries the findings; usage errors exit 2', () => {
const j = run([path.join(FIXTURES, 'slopped-changelog.md'), '--unreleased', '--json']);
expect(j.code).toBe(0);
const parsed = JSON.parse(j.out);
expect(parsed.findings.length).toBeGreaterThan(5);
expect(parsed.findings[0]).toMatchObject({ rule: expect.any(String), line: expect.any(Number), match: expect.any(String), hint: expect.any(String) });
expect(run([]).code).toBe(2);
expect(run(['--help']).code).toBe(0);
expect(run(['--help']).out).toContain('--unreleased');
expect(run([path.join(FIXTURES, 'missing.md')]).code).toBe(2);
expect(run([path.join(FIXTURES, 'clean-changelog.md'), '--nope']).code).toBe(2);
expect(run([path.join(FIXTURES, 'clean-changelog.md'), '--unreleased', '--base', 'main']).code).toBe(2);
expect(run([path.join(FIXTURES, 'clean-changelog.md'), '--sections', 'Added', '--base', 'main']).code).toBe(2);
expect(run([path.join(FIXTURES, 'clean-changelog.md'), '--sections', '--gate']).code).toBe(2);
const dir = run([FIXTURES]);
expect(dir.code).toBe(2);
expect(dir.err).toContain('cannot read');
expect(run(['--gate']).code).toBe(2);
const noRef = run([path.join(FIXTURES, 'slopped-changelog.md'), '--base']);
expect(noRef.code).toBe(2);
expect(noRef.err).toContain('--base needs a ref');
});
});
describe('wiring', () => {
const read = (p: string) => fs.readFileSync(path.join(ROOT, p), 'utf8');
test('/ship Step 13 lints the added CHANGELOG lines and Step 19 gates the body drafts before composing', () => {
const changelog = read('ship/sections/changelog.md.tmpl');
expect(changelog).toContain('gstack-prose-lint CHANGELOG.md --base origin/<base> --gate');
const prBody = read('ship/sections/pr-body.md.tmpl');
const compose = prBody.slice(prBody.indexOf(': "${DOCS_SECTION_FILE:?'), prBody.indexOf('echo "PR_BODY_FILE: $PR_BODY_FILE"'));
expect(compose).toContain(`gstack-prose-lint "$BODY_TOP_FILE" --sections 'Why,Summary,Blast radius' --gate`);
expect(compose).not.toContain('What changed,');
expect(compose).not.toContain('"$BODY_REST_FILE" --gate');
expect(compose.indexOf('gstack-prose-lint')).toBeGreaterThan(compose.indexOf('is empty, so the body was never written'));
expect(compose.indexOf('gstack-prose-lint')).toBeLessThan(compose.indexOf('{ awk 1 "$BODY_TOP_FILE"'));
for (const host of ['claude', 'codex', 'factory', 'agy']) expect(fs.existsSync(path.join(ROOT, 'test', 'fixtures', 'golden', host + '-ship-SKILL.md'))).toBe(true);
});
test('the PR body leads with the problem and carries a Blast radius item, on both the standard and the Capy path', () => {
const prBody = read('ship/sections/pr-body.md.tmpl');
const summary = prBody.slice(prBody.indexOf('## Summary'), prBody.indexOf('## Test Coverage'));
expect(summary).toContain("Open with the problem in the user's words");
expect(summary).toContain('Never an inventory or a commit list.');
expect(summary).toMatch(/BAD: ".+" GOOD: "/s);
expect(summary).toContain('## Blast radius');
expect(summary).toContain('the one fact that makes that safe');
expect(summary).toContain('file:line read, walked failure path, script ran, repro');
expect(summary).toContain("the `/investigate` skill's Blast radius section, when present");
expect(summary).not.toMatch(/skills\/gstack\/investigate\/sections\/blast-radius\.md/);
expect(prBody).toContain('names what the user gets, not the mechanism');
const capy = read('ship/sections/capy-pr.md.tmpl');
expect(capy).toContain('`## Why`, `## Blast radius` and `## Approvals`');
expect(capy).toMatch(/\*\*`## Why`:\*\* two or three sentences\. The first states the problem in the person's words/);
expect(capy).toMatch(/BAD "[\s\S]+" GOOD "/);
expect(capy).toContain("**`## Blast radius`:** the body template's item, written once");
expect(capy).toContain("the `/investigate` skill's Blast radius section, when present");
expect(capy).not.toMatch(/skills\/gstack\/investigate\/sections\/blast-radius\.md/);
});
test('the quality gate runs the lint over the [Unreleased] block, and NOTICE.md credits unslop', () => {
const wf = read('.github/workflows/quality-gate.yml');
expect(wf).toContain('bun --no-env-file --config=/dev/null bin/gstack-prose-lint CHANGELOG.md --unreleased --gate');
expect(read('NOTICE.md')).toContain('## pstack (unslop) — Copyright Lauren Tan — MIT License');
expect(read('docs/troubleshooting.md')).toContain('<a id="prose-lint-found"></a>');
});
// Value: protects=report mode (no --gate) exits 0 with a trailer even over a file full of findings, which is what lets the templates and docs keep their em dashes; fails_when=a finding exits nonzero without --gate, or a template run prints no trailer or writes to stderr; why_new=the earlier row accepted ok or found over the templates alone and so proved nothing about findings; seam=none; no_claim=the templates' own findings count is not pinned (it moves with every prose edit, and ok is as valid as found)
test('report mode never gates: the slopped fixture exits 0 without --gate, and the shipped templates get a trailer and no stderr', () => {
const slop = run([path.join(FIXTURES, 'slopped-changelog.md')]);
expect(slop.code).toBe(0);
expect(slop.out).toMatch(/PROSE_LINT: found=\d+ files=1\n$/);
expect(slop.err).toBe('');
const r = run(['ship/sections/changelog.md.tmpl', 'ship/sections/capy-pr.md.tmpl', 'ship/sections/pr-body.md.tmpl']);
expect(r.code).toBe(0);
expect(r.out).toMatch(/PROSE_LINT: (ok|found=\d+) files=3\n$/);
expect(r.err).toBe('');
});
});
+114
View File
@@ -0,0 +1,114 @@
/**
* pstack absorb (GSTA-116 plan, items 6 and 9): benchmark's vet-the-number
* section and plan-eng-review's design-twice pass. Pins the carve shape
* (manifest ids, STOP pointers, where each pointer sits) and the tokens each
* section must carry; prose is not pinned.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { CARVE_GUARDS } from './helpers/carve-guards';
const ROOT = path.resolve(import.meta.dir, '..');
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
describe('benchmark carve: vet the number', () => {
const skeleton = read('benchmark/SKILL.md');
const section = read('benchmark/sections/vet-the-number.md');
const manifest = JSON.parse(read('benchmark/sections/manifest.json')) as { skill: string; sections: Array<{ id: string; file: string }> };
test('one section, registered, pointed to at the top of Phase 5 before any comparison is written', () => {
expect(manifest.skill).toBe('benchmark');
expect(manifest.sections.map(s => s.id)).toEqual(['vet-the-number']);
expect(CARVE_GUARDS.benchmark!.expectedSections).toEqual(['vet-the-number.md']);
const phase5 = skeleton.indexOf('### Phase 5: Comparison');
const stop = skeleton.indexOf('> **STOP.**');
expect(skeleton.indexOf('## Arguments')).toBeLessThan(stop);
expect(stop).toBeGreaterThan(phase5);
expect(stop).toBeLessThan(skeleton.indexOf('If baseline exists, compare current metrics against it'));
expect(skeleton.match(/> \*\*STOP\.\*\*/g)).toHaveLength(1);
expect(skeleton).toContain('sections/vet-the-number.md');
expect(skeleton).toMatch(/\*\*Vet every number before reporting it\.\*\*/);
});
test('the seven questions, the one-run ballpark rule, the 11% arithmetic, alternating runs, and the inconclusive verdict', () => {
expect(section).toContain('## Vet the number');
const questions = ['**Why not double?**', '**Was it tuned?**', '**Did it break a limit?**', '**Did it error?**', '**Does it reproduce?**', '**Does it matter?**', '**Did it even happen?**'];
let last = -1;
for (const q of questions) {
const at = section.indexOf(q);
expect(at, q).toBeGreaterThan(last);
last = at;
}
expect(section).toContain('Name the limiter');
expect(section).toMatch(/one run,\s+still answer questions 4 and 7/);
expect(section).toMatch(/10%[\s\S]*at most about 11% faster/);
expect(section).toMatch(/alternating A, B, A, B/);
expect(section).toContain('A gap smaller than the run-to-run');
expect(section).toMatch(/faster, slower, no measurable difference, or\s+inconclusive/);
expect(section).toMatch(/cannot name the limiter/);
});
});
describe('plan-eng-review: two shapes before one inside Section 1 of the lazy review section', () => {
const skeleton = read('plan-eng-review/SKILL.md');
const section = read('plan-eng-review/sections/review-sections.md');
const manifest = JSON.parse(read('plan-eng-review/sections/manifest.json')) as { sections: Array<{ id: string }> };
test('the pass lives in review-sections.md between Sections 1 and 2, the skeleton and manifest are unchanged, and the checklist maps it', () => {
expect(manifest.sections.map(s => s.id)).toEqual(['review-sections', 'checklist']);
expect(CARVE_GUARDS['plan-eng-review']!.expectedSections).toEqual(['review-sections.md']);
expect(skeleton).not.toContain('design-twice');
expect(skeleton.match(/> \*\*STOP\.\*\*/g)).toHaveLength(1);
const one = section.indexOf('### 1. Architecture review');
const pass = section.indexOf('### Two shapes before one (design-twice)');
const two = section.indexOf('### 2. Code quality review');
expect(one).toBeGreaterThan(0);
expect(pass).toBeGreaterThan(one);
expect(two).toBeGreaterThan(pass);
expect(section.slice(pass, two)).toMatch(/only when the plan introduces a new module, service, package or\s+boundary/);
expect(read('plan-eng-review/sections/checklist.md')).toContain('Two shapes before one (design-twice)');
});
test('a second structurally distinct shape, interface depth, the agent-contributor assumption, and the eight red flags in order', () => {
const pass = section.slice(section.indexOf('### Two shapes before one (design-twice)'), section.indexOf('### 2. Code quality review'));
expect(pass).toContain('**Ask for the second shape.**');
expect(pass).toMatch(/require a second\s+shape that is structurally different/);
expect(pass).toMatch(/A point fix inside\s+the first shape is not a second candidate\./);
expect(pass).toContain('interface depth');
expect(pass).toContain('A deep module is not a deep call chain');
expect(pass).toMatch(/an agent that sees only the files it opened,\s+copies the nearest example and takes the shortest path that compiles/);
const flags = ['**Shallow module.**', '**Information leakage.**', '**Temporal decomposition.**', '**Pass-through method.**', '**Split ownership.**', '**Two ways to do one task.**', '**Importable internals.**', '**Hand-synced list.**'];
let last = pass.indexOf('**The eight red flags.**');
expect(last).toBeGreaterThan(0);
for (const f of flags) {
const at = pass.indexOf(f);
expect(at, f).toBeGreaterThan(last);
last = at;
}
});
});
describe('every pstack-derived section carries its credit line', () => {
const credited = {
'investigate/sections/blast-radius.md.tmpl': 0,
'context-restore/sections/transcripts.md.tmpl': 0,
'benchmark/sections/vet-the-number.md.tmpl': 0,
// The design-twice pass lives inside an existing section; its credit sits on the line above its heading.
'plan-eng-review/sections/review-sections.md.tmpl': '### Two shapes before one (design-twice)',
} as const;
for (const [rel, where] of Object.entries(credited)) {
test(rel, () => {
const lines = read(rel).split('\n');
const at = typeof where === 'number' ? where : lines.indexOf(where) - 1;
expect(at).toBeGreaterThanOrEqual(0);
const credit = lines[at]!;
expect(credit.startsWith('<!--')).toBe(true);
expect(credit).toContain('pstack');
expect(credit).toContain('MIT');
expect(credit).toContain('rewritten');
expect(credit).toContain('See NOTICE.md');
expect(read('NOTICE.md')).toContain(rel.replace('.tmpl', ''));
});
}
});
+1 -1
View File
@@ -6,7 +6,7 @@ const ptyIds = [
'plan-devex-review-plan-mode', 'plan-mode-no-op', 'office-hours-auto-mode',
'auto-decide-preserved', 'plan-ceo-mode-routing', 'plan-design-with-ui-scope', 'plan-eng-finding-floor',
// The two formerly keyless AUQ probes (W1b) import the same SDK capture helper as auq-format-gate, which reaches the PTY runner.
'auq-format-gate', 'auq-consistency', 'auq-verbose-vs-carved-ab', ...['browse', 'codex', 'design-consultation', 'design-html', 'design-shotgun', 'document-release', 'investigate', 'land-and-deploy',
'auq-format-gate', 'auq-consistency', 'auq-verbose-vs-carved-ab', ...['benchmark', 'browse', 'codex', 'context-restore', 'design-consultation', 'design-html', 'design-shotgun', 'document-release', 'investigate', 'land-and-deploy',
'plan-design-review', 'plan-devex-review', 'plan-eng-review', 'qa', 'retro', 'review', 'setup-gbrain', 'spec'].map(skill => `carve-section-loading-${skill}`),
'office-hours-section-loading', 'office-hours-design-draft', 'plan-ceo-section-loading', 'ship-section-loading',
'plan-ceo-finding-floor', 'plan-design-finding-floor', 'plan-devex-finding-floor',
+13 -2
View File
@@ -14,6 +14,16 @@ function writeDrafts(root: string, top = '## Summary\n\n## Documentation', rest
return block => block.replace('<body-top-file-name>', 'pr-body-top.abc123').replace('<body-rest-file-name>', 'pr-body-rest.abc123');
}
const POST = resolve(import.meta.dir, '../bin/gstack-post');
const PROSE_LINT = resolve(import.meta.dir, '../bin/gstack-prose-lint');
/** The two real bins the blocks call, at the installed path under a HOME of `root`. */
function installBins(root: string): void {
const bin = join(root, '.claude/skills/gstack/bin');
mkdirSync(bin, { recursive: true });
for (const [name, target] of [['gstack-post', POST], ['gstack-prose-lint', PROSE_LINT]] as const) {
writeFileSync(join(bin, name), `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(target)} "$@"\n`, { mode: 0o755 });
}
}
const fenceAfter = (marker: string) => {
const at = template.indexOf(marker);
expect(at).toBeGreaterThan(-1);
@@ -26,7 +36,7 @@ const createBlock = fenceAfter('**No open PR/MR:**');
/**
* A repo whose origin is GitHub, a stub gh that records argv and stdin, and
* the real gstack-post at the installed path the blocks call.
* the real gstack-post and gstack-prose-lint at the installed path the blocks call.
*/
function publicationRepo(root: string) {
const log = join(root, 'log');
@@ -38,7 +48,7 @@ function publicationRepo(root: string) {
for (const args of [['init', '-q'], ['remote', 'add', 'origin', 'https://github.com/o/r.git']]) {
Bun.spawnSync(['git', ...args], { cwd: root, env: { ...process.env, ...gitEnv }, timeout: 10_000 });
}
writeFileSync(join(bin, 'gstack-post'), `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(POST)} "$@"\n`, { mode: 0o755 });
installBins(root);
writeFileSync(join(stubs, 'gh'), `#!/usr/bin/env bash
case "$1 $2" in
"repo view") echo PRIVATE; exit 0 ;;
@@ -133,6 +143,7 @@ test('publication composes the saved documentation section unchanged and refuses
const compose = block.slice(block.indexOf('PR_BODY_FILE=$(mktemp'), block.indexOf('echo "PR_BODY_FILE:'));
const root = mkdtempSync(join(tmpdir(), 'ship-compose-'));
try {
installBins(root);
const section = '**Status:** current — no edits.\n\n- Diagram drift: none (no diagrams in any doc).';
writeFileSync(join(root, 'section.md'), section);
const run = (file: string) => Bun.spawnSync(['bash', '-c', `${writeDrafts(root)(compose)}cat -- "$PR_BODY_FILE"`], {
+181
View File
@@ -0,0 +1,181 @@
/**
* Workflow run-step shells (free).
*
* GitHub's default shell for a `run` step is `bash -e {0}` on a Linux runner
* but `sh -e {0}` inside a container job (actions/runner picks sh when the
* step runs in a job container), and the `sh` in gstack's CI image is dash. A
* step that leans on a bash expansion under that default dies before its first
* command: run 40456150409 (evals.yml, train-bisect (1)(2)(3)) failed every
* prefix at "Verify the prefix checkout" with `sh: 6: Bad substitution` on
* `${PREFIX_SHA:0:12}`, so no bisect had ever run a case in CI. The job now
* declares `defaults.run.shell: bash`.
*
* This test computes each run step's effective shell (step `shell` → job
* `defaults.run.shell` → workflow `defaults.run.shell` → GitHub's default for
* the job: sh for a container job, pwsh on Windows, bash otherwise; composite
* actions always name a shell) and fails when a step whose effective shell is
* sh uses a bash-only construct. Static, offline, parses every workflow and
* composite action under .github/.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
const ROOT = path.resolve(__dirname, '..');
const GITHUB_DIR = path.join(ROOT, '.github');
/** Bash-only constructs POSIX sh (dash) rejects or misreads, by name. */
export const BASHISMS: ReadonlyArray<{ name: string; pattern: RegExp }> = [
// ${x:0:12}, ${x: -3}, ${x:$a:$b}, ${@:2}, ${1:0:2}; ${x:-d} ${x:=d} ${x:?m} ${x:+a} are POSIX.
{ name: 'substring expansion ${x:offset:length}', pattern: /\$\{(?:[A-Za-z_][A-Za-z0-9_]*|[0-9]+|[@*]):(?![-+?=])/ },
// ${x/a/b}, ${x//a/b}, ${x^^}, ${x,,}, ${x@Q}, ${!x}; ${x#p} ${x%s} are POSIX.
{ name: 'pattern or case expansion ${x/…} ${x^^} ${x,,} ${x@…}', pattern: /\$\{[A-Za-z_][A-Za-z0-9_]*[/^,@]/ },
{ name: 'indirect expansion ${!x}', pattern: /\$\{![A-Za-z_]/ },
{ name: '[[ … ]] test', pattern: /(?:^|[\s;(&|!])\[\[(?:\s|$)/m },
{ name: 'array assignment name=(…)', pattern: /(?:^|[\s;(])[A-Za-z_][A-Za-z0-9_]*(?:\+)?=\(/m },
{ name: 'array expansion ${x[…]}', pattern: /\$\{[A-Za-z_][A-Za-z0-9_]*\[/ },
{ name: 'declare/local/readonly -a or -A, mapfile, readarray', pattern: /(?:^|[\s;])(?:(?:declare|local|readonly|typeset)\s+-[a-zA-Z]*[aA]|mapfile|readarray)\b/m },
{ name: 'set -o pipefail', pattern: /(?:^|[\s;&|])set\s+(?:-[a-zA-Z]*\s+)*-(?:[a-zA-Z]*o\s+pipefail|o\s+pipefail)\b/m },
{ name: 'PIPESTATUS', pattern: /\bPIPESTATUS\b/ },
{ name: 'here-string <<<', pattern: /<<</ },
{ name: 'process substitution <(…) >(…)', pattern: /(?:^|[\s=])[<>]\(/m },
{ name: "ANSI-C quoting $'…'", pattern: /\$'/ },
{ name: 'source (use .)', pattern: /(?:^|[\s;&|])source\s/m },
{ name: 'function keyword', pattern: /(?:^|[\s;])function\s+[A-Za-z_]/m },
];
/** Shell lines, comment lines dropped so a prose mention of a construct does not count. */
function scriptLines(script: string): string {
return script.split('\n').filter((line) => !/^\s*#/.test(line)).join('\n');
}
/** Names of the bash-only constructs a script uses. */
export function bashisms(script: string): string[] {
const body = scriptLines(script);
return BASHISMS.filter(({ pattern }) => pattern.test(body)).map(({ name }) => name);
}
/** sh, bash, pwsh, python, cmd… from a `shell:` value such as `bash -e {0}` or `/usr/bin/sh`. */
export function shellKind(shell: unknown): string | null {
if (typeof shell !== 'string' || shell.trim() === '') return null;
const command = shell.trim().split(/\s+/)[0]!;
const base = command.slice(command.lastIndexOf('/') + 1).toLowerCase();
return base === 'dash' ? 'sh' : base;
}
/** GitHub's default shell for a job with no `shell` anywhere in its chain. */
export function defaultShellForJob(job: any): string {
if (job?.container !== undefined && job?.container !== null) return 'sh';
const runsOn = JSON.stringify(job?.['runs-on'] ?? '').toLowerCase();
return runsOn.includes('windows') ? 'pwsh' : 'bash';
}
export function effectiveShell(step: any, job: any, workflow: any): string {
return shellKind(step?.shell) ?? shellKind(job?.defaults?.run?.shell) ?? shellKind(workflow?.defaults?.run?.shell) ?? defaultShellForJob(job);
}
type RunStep = { file: string; job: string; step: string; shell: string; run: string };
/** Every run step in the repository's workflows and composite actions with its effective shell. */
export function runSteps(): RunStep[] {
const steps: RunStep[] = [];
const workflows = path.join(GITHUB_DIR, 'workflows');
for (const name of fs.readdirSync(workflows).sort()) {
if (!/\.ya?ml$/.test(name)) continue;
const file = path.join('.github', 'workflows', name);
const workflow = Bun.YAML.parse(fs.readFileSync(path.join(ROOT, file), 'utf8')) as any;
for (const [jobName, job] of Object.entries<any>(workflow?.jobs ?? {})) {
for (const [index, step] of (Array.isArray(job?.steps) ? job.steps : []).entries()) {
if (typeof step?.run !== 'string') continue;
steps.push({ file, job: jobName, step: step.name ?? step.id ?? `step ${index + 1}`, shell: effectiveShell(step, job, workflow), run: step.run });
}
}
}
const actions = path.join(GITHUB_DIR, 'actions');
for (const name of fs.existsSync(actions) ? fs.readdirSync(actions).sort() : []) {
for (const base of ['action.yml', 'action.yaml']) {
const file = path.join('.github', 'actions', name, base);
if (!fs.existsSync(path.join(ROOT, file))) continue;
const action = Bun.YAML.parse(fs.readFileSync(path.join(ROOT, file), 'utf8')) as any;
for (const [index, step] of (Array.isArray(action?.runs?.steps) ? action.runs.steps : []).entries()) {
if (typeof step?.run !== 'string') continue;
steps.push({ file, job: 'runs', step: step.name ?? step.id ?? `step ${index + 1}`, shell: shellKind(step.shell) ?? 'sh', run: step.run });
}
}
}
return steps;
}
describe('workflow run-step shells', () => {
test('the detector names bash-only constructs and accepts POSIX sh', () => {
const cases: Array<[string, string]> = [
['echo "short=${PREFIX_SHA:0:12}" >> "$GITHUB_OUTPUT"', 'substring expansion'],
['x=${name: -3}', 'substring expansion'],
['set -- "${@:2}"', 'substring expansion'],
['echo "${1:0:2}"', 'substring expansion'],
['echo "${path//\\//_}"', 'pattern or case expansion'],
['echo "${name^^}"', 'pattern or case expansion'],
['echo "${!ref}"', 'indirect expansion'],
['if [[ -n "$x" ]]; then echo y; fi', '[[ … ]] test'],
['args=(--one --two)', 'array assignment'],
['echo "${args[@]}"', 'array expansion'],
['declare -a list', 'declare/local/readonly -a'],
['set -euo pipefail', 'set -o pipefail'],
['set -o pipefail', 'set -o pipefail'],
['cmd | tee log; test "${PIPESTATUS[0]}" = 0', 'PIPESTATUS'],
['read -r a <<< "$line"', 'here-string'],
['diff <(sort a) <(sort b)', 'process substitution'],
["printf $'a\\tb'", 'ANSI-C quoting'],
['source ./env.sh', 'source (use .)'],
['function greet { echo hi; }', 'function keyword'],
];
for (const [script, expected] of cases) {
expect(bashisms(script).join('; '), script).toContain(expected);
}
for (const posix of [
'echo "${HOME:-/tmp}" "${X:=d}" "${Y:?missing}" "${Z:+alt}" "${1:-x}" "${@}" "$*"',
'echo "${file#*/}" "${file##*/}" "${file%.ts}" "${file%%.*}" "${#name}"',
'out=$(git rev-parse HEAD); if [ "$out" != "$PREFIX_SHA" ]; then exit 1; fi',
'. ./env.sh && printf "%s\\n" "$out" >> "$GITHUB_OUTPUT"',
'case "$x" in a) echo a ;; *) echo b ;; esac',
'set -eu\nmkdir -p "$dir"',
'echo "$(( 1 + 2 ))"',
'# [[ in a comment, ${x:0:1} too\necho ok',
]) {
expect(bashisms(posix), posix).toEqual([]);
}
});
test('the effective shell follows step, job defaults, workflow defaults, then the runner default', () => {
const container = { 'runs-on': 'ubicloud-standard-8', container: { image: 'ghcr.io/x/ci' } };
const linux = { 'runs-on': 'ubuntu-24.04' };
const windows = { 'runs-on': 'windows-latest' };
expect(effectiveShell({}, container, {})).toBe('sh');
expect(effectiveShell({}, linux, {})).toBe('bash');
expect(effectiveShell({}, windows, {})).toBe('pwsh');
expect(effectiveShell({}, { 'runs-on': '${{ matrix.os }}' }, {})).toBe('bash');
expect(effectiveShell({ shell: 'bash' }, container, {})).toBe('bash');
expect(effectiveShell({}, { ...container, defaults: { run: { shell: 'bash' } } }, {})).toBe('bash');
expect(effectiveShell({}, container, { defaults: { run: { shell: '/usr/bin/bash -e {0}' } } })).toBe('bash');
expect(effectiveShell({ shell: 'sh' }, { ...linux, defaults: { run: { shell: 'bash' } } }, {})).toBe('sh');
expect(effectiveShell({ shell: 'dash -e {0}' }, linux, {})).toBe('sh');
expect(effectiveShell({ shell: 'python' }, container, {})).toBe('python');
});
test('no run step whose effective shell is sh uses a bash-only construct', () => {
const steps = runSteps();
expect(steps.length).toBeGreaterThan(100);
const sh = steps.filter((step) => step.shell === 'sh');
// Container jobs without a shell default are where this bites; a parse that
// found none would pass vacuously.
expect(sh.length, 'expected at least one sh-defaulted container step to lint').toBeGreaterThan(0);
const problems = sh.flatMap((step) => bashisms(step.run).map((name) => `${step.file} ${step.job} / "${step.step}": ${name}`));
expect(problems, 'give the job defaults.run.shell: bash (or the step shell: bash)').toEqual([]);
});
test('evals.yml train-bisect runs its steps under bash (run 40456150409 died at ${PREFIX_SHA:0:12} under sh)', () => {
const steps = runSteps().filter((step) => step.file === path.join('.github', 'workflows', 'evals.yml') && step.job === 'train-bisect');
expect(steps.map((step) => step.step)).toContain('Verify the prefix checkout');
for (const step of steps) expect(step.shell, step.step).toBe('bash');
});
});