mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-11 03:00:26 +01:00
Merge remote-tracking branch 'origin/main' into fix/pregate-sealed-push-verify
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> # Conflicts: # CHANGELOG.md # docs/pregate.md
This commit is contained in:
commit
f479f3538e
139 files changed
+5763
-648
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { afterEach, describe, expect, spyOn, test } from 'bun:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
@@ -11,7 +11,8 @@ import { selectTests } from './helpers/test-selection';
|
||||
import { E2E_TOUCHFILES, LLM_JUDGE_TOUCHFILES } from './helpers/touchfiles-data';
|
||||
import { selectPrProfile } from '../scripts/test-pr-profile';
|
||||
import { JUDGE_MS } from './helpers/eval-budgets';
|
||||
import { JudgeRefusalError, DEFAULT_JUDGE_MAX_TOKENS, judgePanel, judgePanelMean, judgePanelMedian, judgePanelReasoning, JUDGE_SCORE_DIMENSIONS, JUDGE_PANEL_SAMPLES } from './helpers/llm-judge';
|
||||
import { Messages } from '@anthropic-ai/sdk/resources/messages';
|
||||
import { callJudge, type CallJudgeOptions, JudgeRefusalError, DEFAULT_JUDGE_MAX_TOKENS, judgePanel, judgePanelMean, judgePanelMedian, judgePanelReasoning, JUDGE_SCORE_DIMENSIONS, JUDGE_PANEL_SAMPLES } from './helpers/llm-judge';
|
||||
import { COOKIE_MANUAL_REVIEW_FILE, getCookieWorkflowManualReview, isManualReviewEntry } from './helpers/cookie-workflow-manual-review';
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
@@ -51,6 +52,7 @@ const passingScore: FixtureScore = { ...COOKIE_WORKFLOW_JUDGE.thresholds, reason
|
||||
|
||||
function actualCookieCallback(root: string, overrides: {
|
||||
judge?: () => Promise<FixtureScore>;
|
||||
callJudge?: (prompt: string, model: string | undefined, options: CallJudgeOptions) => Promise<FixtureScore>;
|
||||
clock?: () => number;
|
||||
budget?: number;
|
||||
setTimer?: typeof setTimeout;
|
||||
@@ -74,7 +76,11 @@ function actualCookieCallback(root: string, overrides: {
|
||||
(_suite: string, names: string[], run: () => void) => { expect(names).toEqual([NAME]); run(); },
|
||||
(name: string, run: () => Promise<void>, budget: number) => { expect(name).toBe(NAME); expect(budget).toBe(JUDGE_MS + 10_000); callback = run; },
|
||||
root, buildCookieWorkflowJudgeInput, (_kind: string, explicit?: string) => explicit ?? 'fixture-model',
|
||||
async (prompt: string, model: string | undefined, options: { signal: AbortSignal }) => { requests.push({ prompt, model, signal: options.signal }); return overrides.judge ? overrides.judge() : passingScore; },
|
||||
async (prompt: string, model: string | undefined, options: CallJudgeOptions & { signal: AbortSignal }) => {
|
||||
requests.push({ prompt, model, signal: options.signal });
|
||||
if (overrides.callJudge) return overrides.callJudge(prompt, model, options);
|
||||
return overrides.judge ? overrides.judge() : passingScore;
|
||||
},
|
||||
COOKIE_WORKFLOW_JUDGE, overrides.budget ?? JUDGE_MS, JUDGE_MS + 10_000, 5_000,
|
||||
{ addTest: (record: EvalTestEntry) => records.push(record) }, expect, { log() {} },
|
||||
readWorkflowJudgeInput, buildWorkflowJudgePrompt,
|
||||
@@ -269,6 +275,46 @@ describe('cookie workflow judge input', () => {
|
||||
expect(selectTests(['README.md'], LLM_JUDGE_TOUCHFILES).selected).not.toContain(NAME);
|
||||
});
|
||||
|
||||
test('the registered cookie judge sends the response schema, so a free-text reply like train 42\'s is not what gets parsed', async () => {
|
||||
const { replies } = JSON.parse(readFileSync(join(ROOT, 'test/fixtures/cookie-judge-train42-replies.json'), 'utf8')) as {
|
||||
replies: Array<{ id: string; model: string; stop_reason: string; text: string }> };
|
||||
expect(replies.map(reply => reply.id)).toEqual(['msg_011CgVnRtVj5vyLvb56m6KJH', 'msg_011CgVne5GStZ6f9pRoCvgWE']);
|
||||
const original = process.env.ANTHROPIC_API_KEY;
|
||||
process.env.ANTHROPIC_API_KEY = 'fake';
|
||||
const create = spyOn(Messages.prototype, 'create');
|
||||
try {
|
||||
for (const reply of replies) {
|
||||
expect(() => JSON.parse(reply.text)).toThrow();
|
||||
const marker = '"reasoning": "';
|
||||
const judgment: FixtureScore = {
|
||||
clarity: Number(/"clarity": (\d)/.exec(reply.text)![1]),
|
||||
completeness: Number(/"completeness": (\d)/.exec(reply.text)![1]),
|
||||
actionability: Number(/"actionability": (\d)/.exec(reply.text)![1]),
|
||||
reasoning: reply.text.slice(reply.text.indexOf(marker) + marker.length, reply.text.lastIndexOf('"}')),
|
||||
};
|
||||
expect(judgment.reasoning).toContain('"one --domain per run"');
|
||||
create.mockReset();
|
||||
create.mockResolvedValue({ id: reply.id, model: reply.model, stop_reason: 'end_turn',
|
||||
content: [{ type: 'text', text: JSON.stringify(judgment) }], usage: { input_tokens: 1, output_tokens: 1 } } as never);
|
||||
const root = fixture();
|
||||
const h = actualCookieCallback(root, { callJudge: (prompt, model, options) => callJudge<FixtureScore>(prompt, model, options) });
|
||||
await h.run();
|
||||
expect(create).toHaveBeenCalledTimes(JUDGE_PANEL_SAMPLES);
|
||||
for (const [request, options] of create.mock.calls as unknown as Array<[Record<string, unknown>, { signal: AbortSignal }]>) {
|
||||
expect(request).toEqual({ model: COOKIE_WORKFLOW_JUDGE.model, max_tokens: DEFAULT_JUDGE_MAX_TOKENS,
|
||||
output_config: { format: { type: 'json_schema', schema: WORKFLOW_JUDGE_RESPONSE_SCHEMA } },
|
||||
messages: [{ role: 'user', content: buildCookieWorkflowJudgeInput(root).prompt }] });
|
||||
expect(options.signal).toBeInstanceOf(AbortSignal);
|
||||
}
|
||||
expect(h.records[0]).toMatchObject({ name: NAME, passed: true, execution: 'executed',
|
||||
judge_scores: { clarity: judgment.clarity, completeness: judgment.completeness, actionability: judgment.actionability } });
|
||||
}
|
||||
} finally {
|
||||
create.mockRestore();
|
||||
if (original === undefined) delete process.env.ANTHROPIC_API_KEY; else process.env.ANTHROPIC_API_KEY = original;
|
||||
}
|
||||
});
|
||||
|
||||
test('the managed callback sends and records exact custom input without borrowing a normal cache identity', async () => {
|
||||
const root = fixture();
|
||||
const input = buildCookieWorkflowJudgeInput(root);
|
||||
|
||||
@@ -367,6 +367,27 @@ describe('uncommitted work is visible (#2299)', () => {
|
||||
expect(vars.SCOPE_BACKEND).toBe('true');
|
||||
});
|
||||
|
||||
// Value: protects=--head scopes one committed range from any checkout, the input the receipt writer and the merge queue
|
||||
// share; fails_when=--head still reads the working tree or untracked files, or an unknown head reads as all-false; seam=none
|
||||
test('--head <sha> scopes only the committed range: working-tree and untracked files are ignored, an unknown head is an error', () => {
|
||||
const dir = createRepo(['lib/auth.ts']);
|
||||
const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: dir, stdio: 'pipe' }).stdout.toString().trim();
|
||||
mkdirSync(join(dir, 'db', 'migrate'), { recursive: true });
|
||||
writeFileSync(join(dir, 'db', 'migrate', '001.rb'), '# untracked\n');
|
||||
writeFileSync(join(dir, 'styles.css'), 'a{}\n');
|
||||
const scope = (args: string[]) => spawnSync('bash', [SCRIPT, 'main', ...args], { cwd: dir, stdio: 'pipe', timeout: 5000 });
|
||||
const ranged = scope(['--head', head]);
|
||||
expect(ranged.status).toBe(0);
|
||||
const out = ranged.stdout.toString();
|
||||
expect(out).toContain('SCOPE_AUTH=true');
|
||||
expect(out).toContain('SCOPE_MIGRATIONS=false');
|
||||
expect(out).toContain('SCOPE_FRONTEND=false');
|
||||
expect(runScopeFull(dir).vars.SCOPE_MIGRATIONS).toBe('true');
|
||||
const unknown = scope(['--head', 'f'.repeat(40)]);
|
||||
expect(unknown.status).toBe(2);
|
||||
expect(unknown.stdout.toString()).toContain('SCOPE_ERROR=no_head');
|
||||
});
|
||||
|
||||
test('non-ASCII path still matches extension globs (NUL-safe file listing, #2526)', () => {
|
||||
const dir = createRepo(['docs/M2 — Notes.md']);
|
||||
const { vars, status } = runScopeFull(dir);
|
||||
|
||||
@@ -163,3 +163,16 @@ test('calibration keeps its future hook but no shipped source enables the absent
|
||||
}
|
||||
expect(mentions).toEqual(['scripts/resolvers/gbrain.ts']);
|
||||
});
|
||||
|
||||
test('the judged Eng bundle carries the shared Voice once and states its em-dash precedence in the Scope gate (paid screen 40634787316)', () => {
|
||||
// Five of six samples (red panel and rerun) named Eng's near-copy of the shared Voice, whose only difference was
|
||||
// its em-dash clause, as a rule the agent must reconcile; the Scope gate's Format precedence already owned it.
|
||||
// fails_when=a second Voice section returns to the judged bundle or the precedence stops naming em dashes.
|
||||
const input = readWorkflowJudgeInput({root: path.join(import.meta.dir, '..'), skillPath: 'plan-eng-review/SKILL.md',
|
||||
startMarker: '# Plan Review Mode', endMarker: null});
|
||||
expect(input.text.match(/^## Voice$/gm)).toHaveLength(1);
|
||||
expect(input.text).not.toContain('Do not add em dashes in prose you compose');
|
||||
expect(scope).toContain('**Format precedence:** Copy required command, output and question formats exactly, em dashes included. Apply Voice to newly composed prose.');
|
||||
const startup = input.files[0]!.content;
|
||||
expect(startup.indexOf('**Format precedence:**')).toBeLessThan(startup.indexOf('## Shared preamble (load first)'));
|
||||
});
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"source": "train 42 gate run 40809051510 (paid-logs-slice-5-a1, rerun-logs-1-a1): llm-judge-response-parse-error events",
|
||||
"replies": [
|
||||
{
|
||||
"id": "msg_011CgVnRtVj5vyLvb56m6KJH",
|
||||
"model": "claude-sonnet-5-5",
|
||||
"stop_reason": "end_turn",
|
||||
"text": "{\"clarity\": 4, \"completeness\": 4, \"actionability\": 4, \"reasoning\": \"The workflow is well ordered: check Aside, collect decisions up front, prepare the browser, import, and report. Consent for storage reset and --all is explicit, and the guidance separates copied cookies from verified sign-in. Error tables, platform limits, and the privacy rules are clear. Weaknesses: (1) the picker-versus-direct choice is underspecified, because the picker is used when browser, profile or domains are open and direct import when they are named, but the target-URL, `--all`, and verify-auth flows under each are not fully mapped. (2) The `--all` handling is thin. SKILL.md does not give direct-import syntax for it and does not say whether `$B goto` is still needed. (3) Verification setup has an ordering wrinkle: the env vars must be set before the daemon starts, while the Aside and `$B status` checks come first, and the instructions only partly say when to stop and restart the daemon. (4) A few edge cases are not defined, such as what to do when the reset or verification fails, how to count partial results, and how to proceed after the failure-reason handoff. (5) The multi-domain instruction is \"one --domain per run\", but it is unclear whether to repeat runs or how to aggregate the receipts. (6) The \"Windows App-Bound Encryption\" recovery depends on a display, with no alternative if none is available. These gaps are minor, and the workflow is mostly executable.\"}"
|
||||
},
|
||||
{
|
||||
"id": "msg_011CgVne5GStZ6f9pRoCvgWE",
|
||||
"model": "claude-sonnet-5-5",
|
||||
"stop_reason": "end_turn",
|
||||
"text": "{\"clarity\": 4, \"completeness\": 4, \"actionability\": 4, \"reasoning\": \"The workflow is well ordered: probe Aside, collect all decisions up front, set up the browser, import, then report three honest lines. Consent boundaries for verification, storage clearing and --all are explicit, the sign-in-versus-cookie-copy distinction is stated repeatedly, and the error and receipt tables give recovery paths. Gaps: (1) Step 2 asks for decisions before `$B` exists, yet the picker-versus-direct choice is tied to browser, profile and domains rather than to the user's wording, and the step 3.1 env vars come before the daemon starts. (2) The conditional step 3.3 (Mode: cdp) and the `$B stop` guidance could be cleaner. (3) The direct-import syntax for `--all` is not shown, and the \"one --domain per run\" rule is stated without saying what to do for several domains (loop, with the tab open on each). (4) The verification-missing return path says to return to step 3 but doesn't say how to handle the already-running daemon beyond `$B stop`. (5) Handling of partial and failed receipts, and the handoff for non-Windows cases, is thin. (6) How the agent learns the profile directory when the user only knows a display name is left to the picker. (7) The root-selection logic with \"never a generated host stub\" is a bit dense. These are minor; the workflow is mostly executable.\"}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"provenance": {
|
||||
"kind": "public native AskUserQuestion calls and answers only (observation.json fingerprints[].nativeCall, byte-exact)",
|
||||
"runId": 40664336141,
|
||||
"artifact": "paid-slice-6-a1",
|
||||
"headSha": "00e713837b71189588268b63a0e860c63c48d51c",
|
||||
"recordedOutcome": "ceiling_reached",
|
||||
"recordedReviewCount": 1,
|
||||
"recordedStep0Count": 3,
|
||||
"recordedPassed": false,
|
||||
"note": "Opus 5.5 put the Step 0D offer in the title ('I rated this plan 4/10 on design completeness. Should I review all 7 areas or focus on a few?') and carried the brief's option letters into the labels ('A) Review all 7 areas (recommended)', 'B) Focus on hierarchy/states/responsive'). The runner reached the review ceiling on the issue-labeled D4 finding, but the focus predicate accepted an all-seven offer only as dimensions/passes in the title or as the leading words of an option label, so the gate failed."
|
||||
},
|
||||
"calls": [
|
||||
{
|
||||
"preReview": true,
|
||||
"observedAtMs": 38216,
|
||||
"call": {
|
||||
"sessionId": "76e83304-e6ca-4412-97cf-2b95950e09d7",
|
||||
"toolUseId": "toolu_01B8NoAdG9DH1cyCwWXnU8Ny",
|
||||
"questions": [
|
||||
{
|
||||
"header": "Routing",
|
||||
"question": "D1: Should I add gstack skill routing rules to CLAUDE.md?\nProject/branch/task: dashboard plan review on main.\nELI10: gstack works better when your project's CLAUDE.md lists which skill handles which kind of request, for example that design plan reviews go to /plan-design-review. Adding that list means editing CLAUDE.md and committing it. Plan mode is on, so I can't make that edit now. If you choose A, I'll do it after you leave plan mode.\nStakes if we pick wrong: Without the rules you call skills by name yourself. With them, CLAUDE.md gets a new section.\nRecommendation: A because it routes later requests to the right skill without you having to name it.\nNote: options differ in kind, not coverage \u2014 no completeness score.\nPros / cons:\nA) Add routing rules (recommended)\n \u2705 Future requests route to the matching gstack skill without you naming it\n \u2705 This is asked once per project and never comes up again\n \u274c Adds a commit to CLAUDE.md, and it has to wait until plan mode ends\nB) No thanks\n \u2705 CLAUDE.md stays as it is, with no new commit in this repo\n \u2705 You keep full manual control over which skill runs and when\n \u274c You have to type the skill name yourself every time you want one\nNet: automatic routing versus leaving CLAUDE.md unchanged.",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{
|
||||
"label": "A) Add routing rules (recommended)",
|
||||
"description": "\u2705 Future requests route to the matching skill without you naming it. \u2705 Asked once per project. \u274c Adds a CLAUDE.md commit, which waits until plan mode ends."
|
||||
},
|
||||
{
|
||||
"label": "B) No thanks",
|
||||
"description": "\u2705 CLAUDE.md stays as it is, with no new commit. \u2705 You keep manual control of skill calls. \u274c You type skill names yourself every time."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"answered": true,
|
||||
"failed": false,
|
||||
"answers": {
|
||||
"D1: Should I add gstack skill routing rules to CLAUDE.md?\nProject/branch/task: dashboard plan review on main.\nELI10: gstack works better when your project's CLAUDE.md lists which skill handles which kind of request, for example that design plan reviews go to /plan-design-review. Adding that list means editing CLAUDE.md and committing it. Plan mode is on, so I can't make that edit now. If you choose A, I'll do it after you leave plan mode.\nStakes if we pick wrong: Without the rules you call skills by name yourself. With them, CLAUDE.md gets a new section.\nRecommendation: A because it routes later requests to the right skill without you having to name it.\nNote: options differ in kind, not coverage \u2014 no completeness score.\nPros / cons:\nA) Add routing rules (recommended)\n \u2705 Future requests route to the matching gstack skill without you naming it\n \u2705 This is asked once per project and never comes up again\n \u274c Adds a commit to CLAUDE.md, and it has to wait until plan mode ends\nB) No thanks\n \u2705 CLAUDE.md stays as it is, with no new commit in this repo\n \u2705 You keep full manual control over which skill runs and when\n \u274c You have to type the skill name yourself every time you want one\nNet: automatic routing versus leaving CLAUDE.md unchanged.": "A) Add routing rules (recommended)"
|
||||
},
|
||||
"unansweredQuestionIndices": [],
|
||||
"answeredAt": "2026-10-28T09:30:34.396Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"preReview": true,
|
||||
"observedAtMs": 87270,
|
||||
"call": {
|
||||
"sessionId": "76e83304-e6ca-4412-97cf-2b95950e09d7",
|
||||
"toolUseId": "toolu_01XQ2nXMdLz3Q4tQU2LTE5ph",
|
||||
"questions": [
|
||||
{
|
||||
"header": "Focus",
|
||||
"question": "D2: I rated this plan 4/10 on design completeness. Should I review all 7 areas or focus on a few?\nProject/branch/task: /dashboard plan review on main.\nELI10: The plan says how the dashboard behaves but not how it looks or what users notice first. The biggest gaps are panel hierarchy, layout at each screen size, empty-state content, and accessibility for the modal and toasts. Next I'll generate mockups, then work through 7 areas: hierarchy, states, user journey, AI-slop risk, design system, responsive/accessibility, and open decisions.\nStakes if we pick wrong: If I narrow the focus, unreviewed areas get built with default guesses, such as a generic three-card grid.\nRecommendation: A because almost every area has a gap and the plan is small enough to cover in full.\nCompleteness: A=10/10, B=6/10\nPros / cons:\nA) Review all 7 areas (recommended)\n \u2705 Every gap is surfaced before implementation, including accessibility and design-system gaps\n \u2705 The final report scores all 7 areas, so you know exactly what is still open\n \u274c More decision questions, roughly one for each gap found across the 7 areas\nB) Focus on hierarchy, states and responsive only\n \u2705 Fewer questions; the gaps that most affect what users see get fixed first\n \u2705 Gets to a plan you can build from faster, with less back-and-forth\n \u274c Accessibility and design-system gaps stay open and become implementer guesses\nNet: full coverage versus fewer questions.",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{
|
||||
"label": "A) Review all 7 areas (recommended)",
|
||||
"description": "\u2705 Every gap surfaced, including accessibility and design system. \u2705 Report scores all 7 areas. \u274c More decision questions."
|
||||
},
|
||||
{
|
||||
"label": "B) Focus on hierarchy/states/responsive",
|
||||
"description": "\u2705 Fewer questions; the most visible gaps first. \u2705 Faster to a plan you can build from. \u274c Accessibility and design-system gaps left to the implementer."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"answered": true,
|
||||
"failed": false,
|
||||
"answers": {
|
||||
"D2: I rated this plan 4/10 on design completeness. Should I review all 7 areas or focus on a few?\nProject/branch/task: /dashboard plan review on main.\nELI10: The plan says how the dashboard behaves but not how it looks or what users notice first. The biggest gaps are panel hierarchy, layout at each screen size, empty-state content, and accessibility for the modal and toasts. Next I'll generate mockups, then work through 7 areas: hierarchy, states, user journey, AI-slop risk, design system, responsive/accessibility, and open decisions.\nStakes if we pick wrong: If I narrow the focus, unreviewed areas get built with default guesses, such as a generic three-card grid.\nRecommendation: A because almost every area has a gap and the plan is small enough to cover in full.\nCompleteness: A=10/10, B=6/10\nPros / cons:\nA) Review all 7 areas (recommended)\n \u2705 Every gap is surfaced before implementation, including accessibility and design-system gaps\n \u2705 The final report scores all 7 areas, so you know exactly what is still open\n \u274c More decision questions, roughly one for each gap found across the 7 areas\nB) Focus on hierarchy, states and responsive only\n \u2705 Fewer questions; the gaps that most affect what users see get fixed first\n \u2705 Gets to a plan you can build from faster, with less back-and-forth\n \u274c Accessibility and design-system gaps stay open and become implementer guesses\nNet: full coverage versus fewer questions.": "A) Review all 7 areas (recommended)"
|
||||
},
|
||||
"unansweredQuestionIndices": [],
|
||||
"answeredAt": "2026-10-28T09:31:23.767Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"preReview": true,
|
||||
"observedAtMs": 116281,
|
||||
"call": {
|
||||
"sessionId": "76e83304-e6ca-4412-97cf-2b95950e09d7",
|
||||
"toolUseId": "toolu_01LxEwUZNTiiwQRydm1C9wCP",
|
||||
"questions": [
|
||||
{
|
||||
"header": "Voices",
|
||||
"question": "Want outside design voices before the detailed review?\nD3. Project/branch/task: /dashboard plan review on main.\nELI10: I can bring in two independent reviewers before the 7 areas: Codex scoring against strict design rules, and a fresh Claude reading for completeness. That's a cross-check on my review. It isn't required.\nStakes if we pick wrong: Without them, all findings come from one reviewer. With them, the review takes a few more minutes.\nRecommendation: No, proceed without, because the plan is small and every gap still gets its own decision from you.\nNote: options differ in kind, not coverage \u2014 no completeness score.\nNet: a cross-model check versus fewer steps.",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{
|
||||
"label": "Yes, run outside design voices",
|
||||
"description": "\u2705 Codex hard-rule + litmus scorecard plus an independent Claude completeness read. \u2705 Disagreements surface with both perspectives in the relevant pass. \u274c Adds a few minutes; Codex may be missing here, in which case only the Claude voice runs and it's tagged single-model."
|
||||
},
|
||||
{
|
||||
"label": "No, proceed without (recommended)",
|
||||
"description": "\u2705 Straight into the 7 passes with the approved mockup as anchor. \u2705 Fewest steps to a fixed plan. \u274c Findings rest on one reviewer; no cross-model check on slop risk or hierarchy."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"answered": true,
|
||||
"failed": false,
|
||||
"answers": {
|
||||
"Want outside design voices before the detailed review?\nD3. Project/branch/task: /dashboard plan review on main.\nELI10: I can bring in two independent reviewers before the 7 areas: Codex scoring against strict design rules, and a fresh Claude reading for completeness. That's a cross-check on my review. It isn't required.\nStakes if we pick wrong: Without them, all findings come from one reviewer. With them, the review takes a few more minutes.\nRecommendation: No, proceed without, because the plan is small and every gap still gets its own decision from you.\nNote: options differ in kind, not coverage \u2014 no completeness score.\nNet: a cross-model check versus fewer steps.": "No, proceed without (recommended)"
|
||||
},
|
||||
"unansweredQuestionIndices": [],
|
||||
"answeredAt": "2026-10-28T09:31:53.125Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"preReview": false,
|
||||
"observedAtMs": 148869,
|
||||
"call": {
|
||||
"sessionId": "76e83304-e6ca-4412-97cf-2b95950e09d7",
|
||||
"toolUseId": "toolu_016u5bq59ktJnbdvhE61syx8",
|
||||
"questions": [
|
||||
{
|
||||
"header": "Hierarchy",
|
||||
"question": "D4 (Issue 1): What should users see first, second and third on /dashboard?\nProject/branch/task: /dashboard plan review on main, Pass 1.\nELI10: Right after login, the user scans the page for one obvious thing to do. The plan doesn't rank the three panels, so they will probably ship as three equal boxes and the user has to work out what matters. I'd make unread notifications the main column, put recent activity in a narrower side column, and show the two quick actions as a compact row under the page title.\nStakes if we pick wrong: Users skim past unread items, or the page becomes a generic card grid.\nRecommendation: 1A because notifications are the only panel that needs the user to act (principle 2: every screen has a hierarchy).\nCompleteness: A=10/10, B=9/10, C=4/10\nPros / cons:\nA) Notifications first, activity second, actions row on top (recommended)\n \u2705 Unread items get the widest column and the eye goes to them first\n \u2705 Quick actions sit at the top as the 'where next' step without competing\n \u274c Activity gets a narrower column, so long activity lines truncate sooner\nB) Activity first, notifications in a right rail\n \u2705 A timeline-led page suits users who mainly come back to check what changed\n \u2705 Activity gets full width, so long descriptions stay readable on one line\n \u274c Unread notifications sit in a narrow rail and are easier to skip\nC) Three equal panels, no ranking\n \u2705 Simplest to build, with one shared panel layout reused three times\n \u2705 No product call needed now about which panel matters more than the others\n \u274c Generic SaaS card grid with no first thing to look at, which fails the slop check\nNet: put the panel that needs action first, or put what changed first.",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{
|
||||
"label": "1A Notifications first (recommended)",
|
||||
"description": "\u2705 Unread items get the widest column and are seen first. \u2705 Quick actions sit up top as 'where next'. \u274c Activity lines truncate sooner."
|
||||
},
|
||||
{
|
||||
"label": "1B Activity first, notif rail",
|
||||
"description": "\u2705 Timeline-led, for users checking what changed. \u2705 Activity gets full width. \u274c Unread items in a narrow rail are easier to skip."
|
||||
},
|
||||
{
|
||||
"label": "1C Three equal panels",
|
||||
"description": "\u2705 Simplest to build, one layout reused. \u2705 No ranking decision needed now. \u274c Generic card grid with no first thing to look at."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"answered": true,
|
||||
"failed": false,
|
||||
"answers": {
|
||||
"D4 (Issue 1): What should users see first, second and third on /dashboard?\nProject/branch/task: /dashboard plan review on main, Pass 1.\nELI10: Right after login, the user scans the page for one obvious thing to do. The plan doesn't rank the three panels, so they will probably ship as three equal boxes and the user has to work out what matters. I'd make unread notifications the main column, put recent activity in a narrower side column, and show the two quick actions as a compact row under the page title.\nStakes if we pick wrong: Users skim past unread items, or the page becomes a generic card grid.\nRecommendation: 1A because notifications are the only panel that needs the user to act (principle 2: every screen has a hierarchy).\nCompleteness: A=10/10, B=9/10, C=4/10\nPros / cons:\nA) Notifications first, activity second, actions row on top (recommended)\n \u2705 Unread items get the widest column and the eye goes to them first\n \u2705 Quick actions sit at the top as the 'where next' step without competing\n \u274c Activity gets a narrower column, so long activity lines truncate sooner\nB) Activity first, notifications in a right rail\n \u2705 A timeline-led page suits users who mainly come back to check what changed\n \u2705 Activity gets full width, so long descriptions stay readable on one line\n \u274c Unread notifications sit in a narrow rail and are easier to skip\nC) Three equal panels, no ranking\n \u2705 Simplest to build, with one shared panel layout reused three times\n \u2705 No product call needed now about which panel matters more than the others\n \u274c Generic SaaS card grid with no first thing to look at, which fails the slop check\nNet: put the panel that needs action first, or put what changed first.": "1A Notifications first (recommended)"
|
||||
},
|
||||
"unansweredQuestionIndices": [],
|
||||
"answeredAt": "2026-10-28T09:32:19.757Z"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+5
-5
@@ -202,11 +202,11 @@ token; `--finish` stamps the binding fields automatically. Never borrow or repla
|
||||
`gstack-wtree` prints a Git tree hash covering tracked and non-ignored untracked files,
|
||||
not a commit ID. Use `git diff <old-tree> <new-tree>` to compare these snapshots.
|
||||
|
||||
**Review policy** (docs/review-policy.md): a finding blocks another review pass
|
||||
only when it would fail a test, break production, or lose data or secrets;
|
||||
everything below that floor ships as a named note in the PR body. Pass 1 reviews
|
||||
the diff; a later pass reads only the range; two full passes plus one fix-check
|
||||
is the cap. `NO FINDINGS` is a complete reviewer result. `ship: review_scope` (Step 3)
|
||||
**Review policy** (docs/review-policy.md): only a finding that would fail a test,
|
||||
break production, or lose data or secrets blocks a pass; the rest is a PR-body
|
||||
note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only
|
||||
raises it). Pass 1 reads the diff, later passes the range; the cap is two full
|
||||
passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3)
|
||||
is the knob; the receipt's `review_loop` records the outcome.
|
||||
|
||||
### Ship control flow
|
||||
|
||||
+5
-5
@@ -202,11 +202,11 @@ token; `--finish` stamps the binding fields automatically. Never borrow or repla
|
||||
`gstack-wtree` prints a Git tree hash covering tracked and non-ignored untracked files,
|
||||
not a commit ID. Use `git diff <old-tree> <new-tree>` to compare these snapshots.
|
||||
|
||||
**Review policy** (docs/review-policy.md): a finding blocks another review pass
|
||||
only when it would fail a test, break production, or lose data or secrets;
|
||||
everything below that floor ships as a named note in the PR body. Pass 1 reviews
|
||||
the diff; a later pass reads only the range; two full passes plus one fix-check
|
||||
is the cap. `NO FINDINGS` is a complete reviewer result. `ship: review_scope` (Step 3)
|
||||
**Review policy** (docs/review-policy.md): only a finding that would fail a test,
|
||||
break production, or lose data or secrets blocks a pass; the rest is a PR-body
|
||||
note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only
|
||||
raises it). Pass 1 reads the diff, later passes the range; the cap is two full
|
||||
passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3)
|
||||
is the knob; the receipt's `review_loop` records the outcome.
|
||||
|
||||
### Ship control flow
|
||||
|
||||
+5
-5
@@ -200,11 +200,11 @@ token; `--finish` stamps the binding fields automatically. Never borrow or repla
|
||||
`gstack-wtree` prints a Git tree hash covering tracked and non-ignored untracked files,
|
||||
not a commit ID. Use `git diff <old-tree> <new-tree>` to compare these snapshots.
|
||||
|
||||
**Review policy** (docs/review-policy.md): a finding blocks another review pass
|
||||
only when it would fail a test, break production, or lose data or secrets;
|
||||
everything below that floor ships as a named note in the PR body. Pass 1 reviews
|
||||
the diff; a later pass reads only the range; two full passes plus one fix-check
|
||||
is the cap. `NO FINDINGS` is a complete reviewer result. `ship: review_scope` (Step 3)
|
||||
**Review policy** (docs/review-policy.md): only a finding that would fail a test,
|
||||
break production, or lose data or secrets blocks a pass; the rest is a PR-body
|
||||
note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only
|
||||
raises it). Pass 1 reads the diff, later passes the range; the cap is two full
|
||||
passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3)
|
||||
is the knob; the receipt's `review_loop` records the outcome.
|
||||
|
||||
### Ship control flow
|
||||
|
||||
+5
-5
@@ -202,11 +202,11 @@ token; `--finish` stamps the binding fields automatically. Never borrow or repla
|
||||
`gstack-wtree` prints a Git tree hash covering tracked and non-ignored untracked files,
|
||||
not a commit ID. Use `git diff <old-tree> <new-tree>` to compare these snapshots.
|
||||
|
||||
**Review policy** (docs/review-policy.md): a finding blocks another review pass
|
||||
only when it would fail a test, break production, or lose data or secrets;
|
||||
everything below that floor ships as a named note in the PR body. Pass 1 reviews
|
||||
the diff; a later pass reads only the range; two full passes plus one fix-check
|
||||
is the cap. `NO FINDINGS` is a complete reviewer result. `ship: review_scope` (Step 3)
|
||||
**Review policy** (docs/review-policy.md): only a finding that would fail a test,
|
||||
break production, or lose data or secrets blocks a pass; the rest is a PR-body
|
||||
note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only
|
||||
raises it). Pass 1 reads the diff, later passes the range; the cap is two full
|
||||
passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3)
|
||||
is the knob; the receipt's `review_loop` records the outcome.
|
||||
|
||||
### Ship control flow
|
||||
|
||||
+5
-1
@@ -51,7 +51,11 @@ reservation; its 14 cases now send `WORKFLOW_JUDGE_RESPONSE_SCHEMA` without the
|
||||
compact-reasoning validator (still only on `ship`). The frontier (`review`) configuration has no corpus and stays on today's request. A $0.12 two-call pilot sized
|
||||
output tokens before the manifest was priced. Total W2 spend: $90.47 of $115.
|
||||
|
||||
`cookie-workflow` (GSTA-91, separately funded) is held and stays on today's request. Its one
|
||||
`cookie-workflow` (GSTA-91, separately funded) was held, and the hold was released on
|
||||
GStack Minder's ruling of 2026-10-29: "GStack Minder, 2026-10-29: release the GSTA-91 hold; a07 is
|
||||
matched by old-b; free-text parse errors cost train 42" (`cookie-workflow/release.json`). The
|
||||
cookie workflow judge now sends `WORKFLOW_JUDGE_RESPONSE_SCHEMA`, with its thresholds, model and
|
||||
samples unchanged; the comparison below stays recorded as held. Its one
|
||||
new false pass, a07, scored 4/4/4 on all three new samples and on all three `old-b` samples;
|
||||
`old-a`'s a07 panel is incomplete because one old sample returned invalid JSON (the defect the
|
||||
schema transport removes), so the comparison counts it against the new request only. Both
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"config": "cookie-workflow",
|
||||
"released": true,
|
||||
"date": "2026-10-29",
|
||||
"by": "GStack Minder",
|
||||
"ruling": "GStack Minder, 2026-10-29: release the GSTA-91 hold; a07 is matched by old-b; free-text parse errors cost train 42",
|
||||
"scope": "cookie workflow judge transport only",
|
||||
"recorded_result": "runs/comparison-1.result.json",
|
||||
"train_red": {
|
||||
"gate_run": "https://github.com/garrytan/gstack/actions/runs/40809051510",
|
||||
"responses": [
|
||||
"msg_011CgVnRtVj5vyLvb56m6KJH",
|
||||
"msg_011CgVne5GStZ6f9pRoCvgWE"
|
||||
]
|
||||
}
|
||||
}
|
||||
+3
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"newModules": [
|
||||
{ "file": "scripts/modules.ts", "movedFrom": "(new: module loadouts catalog and selection, GSTA-140)" },
|
||||
{ "file": "scripts/modules-render.ts", "movedFrom": "(new: module loadout render and lock, GSTA-140)" },
|
||||
{ "file": "scripts/modules-cli.ts", "movedFrom": "(new: bin/gstack-modules CLI, GSTA-140)" },
|
||||
{ "file": "lib/harness-register.ts", "movedFrom": "(new: gstack-harness registrar, GSTA-135)" },
|
||||
{ "file": "lib/harness-qm.ts", "movedFrom": "(new: gstack-harness QM skill-pack target, GSTA-135)" },
|
||||
{ "file": "lib/design-lint/render.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"source": "Paid screen 40737741454 (#3553 head 4cd0777) ship-exploratory-unavailable: paid-slice-11-a1 and paid-rerun-1-a1 native-events.json, every Bash command in order (parent and child). In both trials the review plan dispatched the state-machines specialist, and the parent told it to run git diff with the literal merge-base sha.",
|
||||
"runs": {
|
||||
"40737741454-slice-11": {
|
||||
"root": "/home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs",
|
||||
"mergeBase": "8c9a9c5",
|
||||
"commands": [
|
||||
{
|
||||
"parent": false,
|
||||
"command": "date -u +%Y-%m-%dT%H:%M:%SZ"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "bun /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/host/runtime/bin/gstack-qa-deadline start /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/product/reports/deadline.json 300 2026-10-28T18:00:28.179Z"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "bun /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/product/reports 001 --public --deadline /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/product/reports/deadline.json -- bun scripts/probe.ts 4"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "bun /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/host/runtime/bin/gstack-qa-deadline status /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/product/reports/deadline.json"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "bun /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/product/reports 002 --public --deadline /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/product/reports/deadline.json --after 001 --hypothesis 'Riskiest changed edge: out-of-range 10 must exit 2 with the declared stderr; also confirms whether the missing native-engine prerequisite blocks every CLI path' -- bun scripts/probe.ts 10"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "bun run test"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "/home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/host/runtime/bin/gstack-review-plan --base main"
|
||||
},
|
||||
{
|
||||
"parent": true,
|
||||
"command": "git diff 8c9a9c5"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "bun /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/host/runtime/bin/gstack-qa-evidence materialize /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/product/reports annotations.json"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "git status --short"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "/home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/host/runtime/bin/gstack-review-log '{\"skill\":\"review\",\"timestamp\":\"'\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"'\",\"status\":\"issues_found\",\"issues_found\":0,\"critical\":0,\"informational\":0,\"quality_score\":10,\"specialists\":{\"state-machines\":{\"dispatched\":true,\"findings\":0,\"critical\":0,\"informational\":0},\"red-team\":{\"dispatched\":false,\"reason\":\"scope\"}},\"findings\":[],\"commit\":\"'\"$(git rev-parse --short HEAD)\"'\",\"via\":\"ship\",\"completed\":false,\"converged\":false,\"cycles\":0,\"pass\":1,\"review_scope\":\"frozen\",\"range_base\":null}' --finish bbf15eec-7962-4aee-bcfd-88233b0375fe"
|
||||
}
|
||||
]
|
||||
},
|
||||
"40737741454-rerun-1": {
|
||||
"root": "/home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK",
|
||||
"mergeBase": "0f7b81c",
|
||||
"commands": [
|
||||
{
|
||||
"parent": false,
|
||||
"command": "date -u +%Y-%m-%dT%H:%M:%SZ"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "bun /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/host/runtime/bin/gstack-qa-deadline start /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/product/reports/deadline.json 300 2026-10-28T18:18:29.666Z"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "bun /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/product/reports 001 --public --deadline /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/product/reports/deadline.json -- bun scripts/probe.ts 3"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "bun /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/product/reports 002 --public --deadline /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/product/reports/deadline.json --after 001 --hypothesis 'Success path blocked by missing vendor/native-engine.ts; check whether the riskiest changed edge (out-of-range 10) is equally blocked before reaching scale().' -- bun scripts/probe.ts 10"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "bun run test"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "/home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/host/runtime/bin/gstack-review-plan --base main"
|
||||
},
|
||||
{
|
||||
"parent": true,
|
||||
"command": "git diff 0f7b81c"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "bun /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/host/runtime/bin/gstack-qa-evidence materialize /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/product/reports annotations.json"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "git status --short"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "/home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/host/runtime/bin/gstack-review-log '{\"skill\":\"review\",\"timestamp\":\"'\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"'\",\"status\":\"issues_found\",\"issues_found\":0,\"critical\":0,\"informational\":0,\"quality_score\":10,\"specialists\":{\"state-machines\":{\"dispatched\":true,\"findings\":0,\"critical\":0,\"informational\":0},\"testing\":{\"dispatched\":false,\"reason\":\"scope\"},\"maintainability\":{\"dispatched\":false,\"reason\":\"scope\"},\"red-team\":{\"dispatched\":false,\"reason\":\"scope\"}},\"findings\":[],\"commit\":\"'\"$(git rev-parse --short HEAD)\"'\",\"via\":\"ship\",\"completed\":false,\"converged\":false,\"cycles\":0,\"pass\":1,\"review_scope\":\"frozen\",\"range_base\":null}' --finish 4b1c9f3b-d67b-4919-8237-21634b5b4c0b"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"source": "Paid screen 40707142223 (#3544 head) ship-exploratory-plan-checks: paid-slice-10-a1 and paid-rerun-1-a1 native-events.json, every Bash command in order (parent and child); late-input receipt from train 35 rebuild gate 40664336141 paid-slice-9-a1",
|
||||
"runs": {
|
||||
"40707142223-slice-10": {
|
||||
"root": "/home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE",
|
||||
"commands": [
|
||||
{
|
||||
"parent": false,
|
||||
"command": "date -u +%Y-%m-%dT%H:%M:%SZ"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "bun /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-qa-deadline start /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/product/reports/deadline.json 300 2026-10-28T14:53:05.952Z"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "bun /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/product/reports 001 --public --deadline /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/product/reports/deadline.json -- bun scripts/probe.ts 4"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "bun /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-qa-deadline status /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/product/reports/deadline.json"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "bun /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/product/reports 002 --public --deadline /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/product/reports/deadline.json --after 001 --hypothesis 'Success holds for 4; riskiest changed edge is just past the upper bound, so 10 must exit 2 with the exact stderr message.' -- bun scripts/probe.ts 10"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "bun /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/product/reports 003 --public --timeout-ms 10000 --after 002 --hypothesis 'Rejection of 10 holds; PLAN.md requires the upper boundary check: 9 must exit 0 with stdout 18 and empty stderr.' -- bun scripts/probe.ts 9"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "bun run test"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "bun /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-qa-evidence materialize /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/product/reports annotations.json"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "if GATE_OUT=$(/home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-gate-diff main 2>&1); then\n printf '%s\\n' \"$GATE_OUT\"\nelse\n GATE_EXIT=$?; printf '%s\\n' \"$GATE_OUT\"\n case \"$GATE_EXIT\" in\n 2) echo \"Gate integrity: UNAVAILABLE \u2014 $(printf '%s\\n' \"$GATE_OUT\" | grep -m1 '^GATE_ERROR=')\" ;;\n *) echo \"Gate integrity: UNAVAILABLE \u2014 helper exit $GATE_EXIT (stale install? run /gstack-upgrade)\" ;;\n esac\nfi"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "date -u +%Y-%m-%dT%H:%M:%SZ"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "/home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-review-plan --base main"
|
||||
},
|
||||
{
|
||||
"parent": true,
|
||||
"command": "git diff origin/main"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "git status --short"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "/home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-review-log '{\"skill\":\"review\",\"timestamp\":\"'\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"'\",\"status\":\"clean\",\"issues_found\":0,\"critical\":0,\"informational\":0,\"quality_score\":10.0,\"specialists\":{\"state-machines\":{\"dispatched\":true,\"findings\":0,\"critical\":0,\"informational\":0},\"red-team\":{\"dispatched\":false,\"reason\":\"scope\"}},\"findings\":[],\"commit\":\"'\"$(git rev-parse --short HEAD)\"'\",\"via\":\"ship\",\"completed\":true,\"converged\":true,\"cycles\":0,\"pass\":1,\"review_scope\":\"frozen\",\"range_base\":null}' --finish 5534d8f6-4d67-4b6f-b691-c6e83c659df2"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "date -u +%Y-%m-%dT%H:%M:%SZ"
|
||||
}
|
||||
]
|
||||
},
|
||||
"40707142223-rerun-1": {
|
||||
"root": "/home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP",
|
||||
"commands": [
|
||||
{
|
||||
"parent": false,
|
||||
"command": "date -u +%Y-%m-%dT%H:%M:%SZ"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "bun /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/host/runtime/bin/gstack-qa-deadline start /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/product/reports/deadline.json 300 2026-10-28T15:06:29.695Z"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "bun /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/product/reports 001 --public --deadline /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/product/reports/deadline.json -- bun scripts/probe.ts 9"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "bun /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/product/reports 002 --public --deadline /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/product/reports/deadline.json --after 001 --hypothesis 'Range rule: 10 breaks only the 0..9 bound; changed return path must not alter rejection (exit 2, exact stderr, empty stdout).' -- bun scripts/probe.ts 10"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "bun run test"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "if GATE_OUT=$(/home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/host/runtime/bin/gstack-gate-diff main 2>&1); then\n printf '%s\\n' \"$GATE_OUT\"\nelse\n GATE_EXIT=$?; printf '%s\\n' \"$GATE_OUT\"\n case \"$GATE_EXIT\" in\n 2) echo \"Gate integrity: UNAVAILABLE \u2014 $(printf '%s\\n' \"$GATE_OUT\" | grep -m1 '^GATE_ERROR=')\" ;;\n *) echo \"Gate integrity: UNAVAILABLE \u2014 helper exit $GATE_EXIT (stale install? run /gstack-upgrade)\" ;;\n esac\nfi"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "bun /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/host/runtime/bin/gstack-qa-evidence materialize /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/product/reports annotations.json"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "git status --short"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "date -u +%Y-%m-%dT%H:%M:%SZ"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "/home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/host/runtime/bin/gstack-review-plan --base main"
|
||||
},
|
||||
{
|
||||
"parent": true,
|
||||
"command": "git diff origin/main"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "/home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/host/runtime/bin/gstack-review-log '{\"skill\":\"review\",\"timestamp\":\"'\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"'\",\"status\":\"clean\",\"issues_found\":0,\"critical\":0,\"informational\":0,\"quality_score\":10.0,\"specialists\":{\"state-machines\":{\"dispatched\":true,\"findings\":0,\"critical\":0,\"informational\":0}},\"findings\":[],\"commit\":\"'\"$(git rev-parse --short HEAD)\"'\",\"via\":\"ship\",\"completed\":true,\"converged\":true,\"cycles\":0,\"pass\":1,\"review_scope\":\"frozen\",\"range_base\":null}' --finish 94e80914-bf02-4c55-84a9-a9ea9e1adbac"
|
||||
},
|
||||
{
|
||||
"parent": false,
|
||||
"command": "git status --short"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"lateInputReceipt": {
|
||||
"status": "pass",
|
||||
"probes": [
|
||||
"probe-1cd02598-4151-4346-9556-b245a37e9a44",
|
||||
"probe-11857dac-c540-41de-8e13-ca6181ec88fc",
|
||||
"probe-a8572671-a2ec-4b2d-b5bb-aee043898de1"
|
||||
],
|
||||
"remaining": [
|
||||
"Step 11 native adversarial review (not dispatched)",
|
||||
"review record completed:false"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
{
|
||||
"source": "E2E Evals run 40617200857 slice 8 (paid-slice-8-a1), shared-libs-review-path-eligibility, ignored scenario at tested 39861825: one Bash call returned all of src/retry-worker.ts through cat -n, then a later echo === in the same call failed, so the result carried is_error. Native tool blocks only; sources are the fixture evidence files.",
|
||||
"expected": {
|
||||
"helper": "/__w/gstack/gstack/bin/gstack-review-log",
|
||||
"repo": "/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/repo",
|
||||
"state": "/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/state",
|
||||
"slug": "fixture-shared-libs",
|
||||
"directory": "/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/state/projects/fixture-shared-libs/.review-starts",
|
||||
"branch": "feature/a",
|
||||
"wtree": "96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c",
|
||||
"startedAt": "2026-10-28T01:53:45.785Z",
|
||||
"finding": {
|
||||
"severity": "INFORMATIONAL",
|
||||
"confidence": 9,
|
||||
"advisory": true,
|
||||
"path": "src/retry-worker.ts",
|
||||
"line": 2,
|
||||
"category": "shared-libs",
|
||||
"summary": "Use the established Retry-After contract in the changed worker and the authored route sources.",
|
||||
"fix": "Share the tested retrySeconds contract, preserving runtime and deployment boundaries for each caller.",
|
||||
"evidence_paths": [
|
||||
"src/retry-worker.ts",
|
||||
"runtime-local/retry-route.ts",
|
||||
"lib/retry-after.ts"
|
||||
],
|
||||
"helper_target": {
|
||||
"path": "lib/retry-after.ts",
|
||||
"symbol": "retrySeconds"
|
||||
},
|
||||
"fingerprint": "shared-libs:52c933addcfd80ba554222133fe75e4bd06d9dfb5d1bc4484f0460a1517cf881"
|
||||
},
|
||||
"reusable": false,
|
||||
"coveredPaths": [
|
||||
"src/retry-worker.ts",
|
||||
"lib/retry-after.ts"
|
||||
],
|
||||
"sources": {
|
||||
"src/retry-worker.ts": "// Both callers require Retry-After seconds/date parsing, 3600-second ceiling and caller fallback.\nexport function retrySeconds(value: string | null, now: number, fallback = 5): number {\n if (value == null || value.trim() === '') return fallback;\n const normalized = value.trim();\n if (/^\\d+$/.test(normalized)) {\n const seconds = Number(normalized);\n if (!Number.isSafeInteger(seconds)) return fallback;\n return Math.min(seconds, 3600);\n }\n const deadline = Date.parse(normalized);\n if (!Number.isFinite(deadline)) return fallback;\n const remaining = Math.ceil((deadline - now) / 1000);\n if (remaining < 0) return 0;\n return Math.min(remaining, 3600);\n}\n",
|
||||
"runtime-local/retry-route.ts": "// Both callers require Retry-After seconds/date parsing, 3600-second ceiling and caller fallback.\nexport function retrySeconds(value: string | null, now: number, fallback = 5): number {\n if (value == null || value.trim() === '') return fallback;\n const normalized = value.trim();\n if (/^\\d+$/.test(normalized)) {\n const seconds = Number(normalized);\n if (!Number.isSafeInteger(seconds)) return fallback;\n return Math.min(seconds, 3600);\n }\n const deadline = Date.parse(normalized);\n if (!Number.isFinite(deadline)) return fallback;\n const remaining = Math.ceil((deadline - now) / 1000);\n if (remaining < 0) return 0;\n return Math.min(remaining, 3600);\n}\n\n// Authored caller changed after the prior decision (ignored).\n",
|
||||
"lib/retry-after.ts": "// Proven shared parser already used by the scheduler.\nexport function retrySeconds(value: string | null, now: number, fallback = 5): number {\n if (value == null || value.trim() === '') return fallback;\n const normalized = value.trim();\n if (/^\\d+$/.test(normalized)) {\n const seconds = Number(normalized);\n if (!Number.isSafeInteger(seconds)) return fallback;\n return Math.min(seconds, 3600);\n }\n const deadline = Date.parse(normalized);\n if (!Number.isFinite(deadline)) return fallback;\n const remaining = Math.ceil((deadline - now) / 1000);\n if (remaining < 0) return 0;\n return Math.min(remaining, 3600);\n}\n"
|
||||
}
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01UXXksGCC1ZEq5bcTpXAVpH",
|
||||
"name": "Read",
|
||||
"input": {
|
||||
"file_path": "/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/review-lifecycle.md"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_01UXXksGCC1ZEq5bcTpXAVpH",
|
||||
"type": "tool_result",
|
||||
"content": "1\t---\n2\tname: review\n3\tpreamble-tier: 4\n4\tversion: 1.0.0\n5\tdescription: Pre-landing PR review. (gstack)\n6\tallowed-tools:\n7\t - Bash\n8\t - Read\n9\t - Edit\n10\t - Write\n11\t - Grep\n12\t - Glob\n13\t - Agent\n14\t - AskUserQuestion\n15\t - WebSearch\n16\ttriggers:\n17\t - review this pr\n18\t - code review\n19\t - check my diff\n20\t - pre-landing review\n21\t---\n22\t\n23\t## Step 3: Get the diff\n24\t\n25\tAn invocation is this /review run; a pass reviews one candidate before any fixes.\n26\tOn first entry, initialize one invocation action list and CYCLES=0. Keep both through re-reviews.\n27\t\n28\tEach pass has one direction: collect findings in Steps 3–4.8, approve and apply\n29\tfixes in Step 5, then choose repeat or final persistence in Step 5.8.\n30\tDo not edit reviewed source until Step 5. All readers examine the same candidate.\n31\t\n32\tDiff the working tree against the merge base:\n33\t\n34\t```bash\n35\tDIFF_BASE=$(git merge-base origin/main HEAD)\n36\t/__w/gstack/gstack/bin/gstack-review-log --start review\n37\tgit diff \"$DIFF_BASE\"\n38\t```\n39\t\n40\t1. Save the printed REVIEW_START for this core candidate before reading its diff.\n41\t2. Each re-review captures a new token before reading, never at log time. After an\n42\t edited pass, start the next with `--start review --fix-range`: it prints\n43\t REVIEW_START, then `FIX_BASE=<sha>`. Wherever this pass sets DIFF_BASE outside\n44\t Step 4.8, use FIX_BASE instead; Step 4.8 and every other `<base>` use stay full.\n45\t On refusal, start a full pass.\n46\t3. Native/outside reviewer attempts own separate PASS_START tokens, not REVIEW_START.\n47\t4. Read non-ignored untracked source too (`git ls-files --others --exclude-standard`);\n48\t the captured candidate includes it.\n49\t5. First pass only: emit Step 1.5's final Scope Check; `Diff read` records this read.\n50\t\n51\tKeep the review-record terms separate:\n52\t\n53\t| Value | Purpose and owner |\n54\t|---|---|\n55\t| REVIEW_START / PASS_START | Opaque start receipts from the logger: one for the core pass, one for each other reviewer attempt. |\n56\t| Finding fingerprint | Groups duplicate findings. The installed helper computes shared-code fingerprints; a matching key alone never proves a prior Skip is reusable. |\n57\t| `review_binding` | The logger's proof tying a finished review to its captured candidate, not a finding identifier. |\n58\t| `snapshot_covered_paths` | Supporting advice files the logger proved byte-identical to that candidate. Used by the prior-Skip checker, never supplied by the reviewer. |\n59\t\n60\t## Step 4: Critical pass (core review)\n61\t\n62\t> **STOP.** Before any probe, including plan checks, complete the ordered scope/method Reads in earlier responses. Never batch a probe (capture, guard start, plan check) with a Read.\n63\tStep 4 is read-only: defer charters, setup and probes to Step 4.7.\n64\t\n65\t1. From the installed /review SKILL.md's directory, choose one path:\n66\t- If the caller directory is `review`, Read `../qa/sections/exploratory.md` in full.\n67\t- If the caller directory is prefixed `gstack-review`, use `../gstack-qa/sections/exploratory.md` instead and read it in full.\n68\t- If neither layout applies, report an unresolved QA installation as a setup blocker; do not guess another path.\n69\tUse this host's installation, never the product tree. If missing or unreadable, report a QA setup blocker and its affected probes as blocked; continue other safe probes (independent functional/static checks). Missing/unreadable assets block required QA.\n70\t2. Then Read QA's `sections/scope.md` in full; pick surfaces.\n71\t3. Then Read each listed method in full; the entrypoint alone completes nothing.\n72\t\n73\tQA's `sections/...` and `templates/...` paths resolve from installed QA SKILL.md, not caller/product.\n74\t\n75\tApply both checklist passes in order: CRITICAL, then INFORMATIONAL. Respect its suppressions.\n76\tCRITICAL is the blocking floor (docs/review-policy.md): a finding that would fail a\n77\ttest, break production, or lose data or secrets. Everything else is INFORMATIONAL and\n78\tnever earns another pass. `NO FINDINGS` is a complete result from any reviewer.\n79\t\n80\t**Sideways sweep (every finding; reads code OUTSIDE the diff).** Grep the finding's pattern at every other ingress, command and call site, Read each match and rate severity on the worst sibling; a new enum value, status, tier or type constant is traced through every consumer the same way (checklist: Sideways sweep). Data loss, a trust-boundary bypass or uncapped spend is forced CRITICAL: no lane, specialist, dedup or saved decision downgrades it. Keep findings anchored to changed code.\n81\t\n82\t**Gate Integrity reads Step 3.5's listing.** Carry the `GATE_SUMMARY:` line and every\n83\tread-level hunk (`[<id>] <tag> <path> @<hunk>`) into the checklist's Gate Integrity\n84\tcategory: read each hunk in the diffLine truncated
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01HeRAdTGC3Wwdq9MHgFf4Zk",
|
||||
"name": "Read",
|
||||
"input": {
|
||||
"file_path": "/__w/gstack/gstack/review/checklist.md"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_01HeRAdTGC3Wwdq9MHgFf4Zk",
|
||||
"type": "tool_result",
|
||||
"content": "1\t# Pre-Landing Review Checklist\n2\t\n3\t## Instructions\n4\t\n5\tReview the merge-base diff from the caller, including its selected uncommitted and new source. Use the caller's detected base, not a hardcoded branch. Cite `file:line` and suggest fixes. Only flag real problems.\n6\t\n7\t**Two-pass review:**\n8\t- **Pass 1 (CRITICAL):** Run SQL & Data Safety, Race Conditions, LLM Output Trust Boundary, Shell Injection, Enum Completeness, and Gate Integrity first. Highest severity.\n9\t- **Pass 2 (INFORMATIONAL):** Run remaining categories below. Lower severity but still actioned.\n10\t- **Specialist categories (handled by parallel subagents, NOT this checklist):** Test Gaps, Dead Code, Magic Numbers, Conditional Side Effects, Performance & Bundle Impact, Crypto & Entropy, Simplification (unrequested structure). See `review/specialists/` for these.\n11\t\n12\tCompleteness Gaps and Simplification are orthogonal, not contradictory: Completeness pushes coverage UP (tests, edge cases, error paths), Simplification pushes unrequested structure DOWN (one-implementation abstractions, hand-rolled stdlib, dead flexibility). The same diff can legitimately receive both.\n13\t\n14\tAll findings get action via Fix-First Review: obvious mechanical fixes are applied automatically,\n15\tgenuinely ambiguous issues are batched into a single user question.\n16\t\n17\t**Output format:**\n18\t\n19\t```\n20\tPre-Landing Review: N issues (X critical, Y informational)\n21\t\n22\t**AUTO-FIXED:**\n23\t- [file:line] Problem → fix applied\n24\t\n25\t**NEEDS INPUT:**\n26\t- [file:line] Problem description\n27\t Recommended fix: suggested fix\n28\t```\n29\t\n30\tIf no issues found: `Pre-Landing Review: No issues found.`\n31\t\n32\tBe terse. For each issue: one line describing the problem, one line with the fix. No preamble, no summaries, no \"looks good overall.\"\n33\t\n34\t---\n35\t\n36\t## Review Categories\n37\t\n38\t### Pass 1 — CRITICAL\n39\t\n40\t#### SQL & Data Safety\n41\t- String interpolation in SQL (even if values are `.to_i`/`.to_f` — use parameterized queries (Rails: sanitize_sql_array/Arel; Node: prepared statements; Python: parameterized queries))\n42\t- TOCTOU races: check-then-set patterns that should be atomic `WHERE` + `update_all`\n43\t- Bypassing model validations for direct DB writes (Rails: update_column; Django: QuerySet.update(); Prisma: raw queries)\n44\t- N+1 queries: Missing eager loading (Rails: .includes(); SQLAlchemy: joinedload(); Prisma: include) for associations used in loops/views\n45\t\n46\t#### Race Conditions & Concurrency\n47\t- Read-check-write without uniqueness constraint or catch duplicate key error and retry (e.g., `where(hash:).first` then `save!` without handling concurrent insert)\n48\t- find-or-create without unique DB index — concurrent calls can create duplicates\n49\t- Status transitions that don't use atomic `WHERE old_status = ? UPDATE SET new_status` — concurrent updates can skip or double-apply transitions\n50\t- Unsafe HTML rendering (Rails: .html_safe/raw(); React: dangerouslySetInnerHTML; Vue: v-html; Django: |safe/mark_safe) on user-controlled data (XSS)\n51\t\n52\t#### LLM Output Trust Boundary\n53\t- LLM-generated values (emails, URLs, names) written to DB or passed to mailers without format validation. Add lightweight guards (`EMAIL_REGEXP`, `URI.parse`, `.strip`) before persisting.\n54\t- Structured tool output (arrays, hashes) accepted without type/shape checks before database writes.\n55\t- LLM-generated URLs fetched without allowlist — SSRF risk if URL points to internal network (Python: `urllib.parse.urlparse` → check hostname against blocklist before `requests.get`/`httpx.get`)\n56\t- LLM output stored in knowledge bases or vector DBs without sanitization — stored prompt injection risk\n57\t\n58\t#### Shell Injection (Python-specific)\n59\t- `subprocess.run()` / `subprocess.call()` / `subprocess.Popen()` with `shell=True` AND f-string/`.format()` interpolation in the command string — use argument arrays instead\n60\t- `os.system()` with variable interpolation — replace with `subprocess.run()` using argument arrays\n61\t- `eval()` / `exec()` on LLM-generated code without sandboxing\n62\t\n63\t#### Enum & Value Completeness\n64\tWhen the diff introduces a new enum value, status string, tier name, or type constant, or loosens what an input accepts (a new MIME type, extension or flag, or a widened validator/parser/guard):\n65\t- **Trace it through every consumer.** Read (don't just grep — READ) each file that switches on, filters by, or displays that value. If any consumer doesn't handle the new value, flag it. Common miss: adding a value to the frontend dropdown but the backend model/compute method doesn't persist it.\n66\t- **Check allowlists/filter arrays.** Search for arrays or `%w[]` lists containing sibling values (e.g., if adding \"revise\" to tiers, find every `%w[quick lfg mega]` and verify \"revise\" is included whereLine truncated
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_012nWx4Je2424rCcxZUaaeUg",
|
||||
"name": "Read",
|
||||
"input": {
|
||||
"file_path": "/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/current-advisory.jsonl"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_012nWx4Je2424rCcxZUaaeUg",
|
||||
"type": "tool_result",
|
||||
"content": "1\t{\"severity\":\"INFORMATIONAL\",\"confidence\":9,\"advisory\":true,\"path\":\"src/retry-worker.ts\",\"line\":2,\"category\":\"shared-libs\",\"summary\":\"Use the established Retry-After contract in the changed worker and the authored route sources.\",\"fix\":\"Share the tested retrySeconds contract, preserving runtime and deployment boundaries for each caller.\",\"evidence_paths\":[\"src/retry-worker.ts\",\"runtime-local/retry-route.ts\",\"lib/retry-after.ts\"],\"helper_target\":{\"path\":\"lib/retry-after.ts\",\"symbol\":\"retrySeconds\"},\"fingerprint\":\"shared-libs:52c933addcfd80ba554222133fe75e4bd06d9dfb5d1bc4484f0460a1517cf881\",\"specialist\":\"maintainability\"}\n2\t"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01FiurRTF3YRGrjduMhTi5m8",
|
||||
"name": "Read",
|
||||
"input": {
|
||||
"file_path": "/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/resumed-review-prerequisites.json"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_01FiurRTF3YRGrjduMhTi5m8",
|
||||
"type": "tool_result",
|
||||
"content": "1\t{\n2\t \"kind\": \"synthetic-path-review-prerequisites\",\n3\t \"synthetic\": true,\n4\t \"native_coverage\": false,\n5\t \"binding\": {\n6\t \"root\": \"/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2\",\n7\t \"repo\": \"/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/repo\",\n8\t \"state\": \"/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/state\",\n9\t \"branch\": \"feature/a\",\n10\t \"head\": \"0065f046b3be1008d3788dcaa596fbdadc1b540b\",\n11\t \"base\": \"0065f046b3be1008d3788dcaa596fbdadc1b540b\",\n12\t \"wtree\": \"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\n13\t \"index\": \"H 100644 43ac5a38d8386f6a0743f793d7ccee871df272a5 0\\t.gitignore\\nH 100644 965ef910ba60ca16fe356b96ee4cce5162a01a23 0\\tREADME.md\\nH 100644 00dfe6a156bae81aefc94ff1e9db54e32d774c75 0\\tlib/retry-after.ts\\nH 100644 8ab4b18d9a2bd337433526c96343d251aaee8539 0\\tsrc/retry-route.ts\\nH 100644 8ab4b18d9a2bd337433526c96343d251aaee8539 0\\tsrc/retry-worker.ts\\nH 100644 c362fe9878839740d82db85053758be5b95834cd 0\\tsrc/scheduler.ts\\nH 100644 a470f2c203c43e66c47991c64654dd524f9c5c29 0\\ttest/retry-after.test.ts\",\n14\t \"raw\": {\n15\t \".\": \"dir:16877\",\n16\t \".git/config\": \"33188:2550b8371ff38558f3a8c847e5563ca10271d66c0ce1ea8608cc415a959b5854\",\n17\t \".git/info/exclude\": \"33188:6671fe83b7a07c8932ee89164d1f2793b2318058eb8b98dc5c06ee0a5a3b0ec1\",\n18\t \".gitignore\": \"33188:326b615083eaf2671938ccb4ce4dc28b7c07f5687033fa74dc4caa44f866ccb4\",\n19\t \"README.md\": \"33188:94324728d3116382754324b62b8bcf233cf6046df4d1f4f770ecc68df7b10504\",\n20\t \"lib\": \"dir:16877\",\n21\t \"lib/retry-after.ts\": \"33188:67d7124d11830e41baa5fca93bd0d45ce64fe58d92799c7d14635b912c5e5942\",\n22\t \"runtime-local\": \"dir:16877\",\n23\t \"runtime-local/retry-route.ts\": \"33188:db46a502afb0ba90f6e0001a990728f3ae8849be1c75b8ccd616d3ef25353d33\",\n24\t \"src\": \"dir:16877\",\n25\t \"src/retry-route.ts\": \"33188:300feb02d24ed09ca763371d42249f8307dbe9201fb4cddf7aa634a9e341f887\",\n26\t \"src/retry-worker.ts\": \"33188:a17d12e37ae2b07d38dae292156aef6ec83d11b4ec2e295bb14d01f0e8fc9b2e\",\n27\t \"src/scheduler.ts\": \"33188:8a07bfe628ea4f9e679bcab3cfb68482678eebc1e4031887e996d799a6d1198e\",\n28\t \"test\": \"dir:16877\",\n29\t \"test/retry-after.test.ts\": \"33188:653e4090ae4e0d856f4dbf04fb05449d04c44fef599c24ce146d94969a1040a1\"\n30\t }\n31\t },\n32\t \"qa\": {\n33\t \"settled\": true,\n34\t \"required_probes\": [\n35\t {\n36\t \"id\": \"retry-contract\",\n37\t \"status\": \"passed\",\n38\t \"result\": \"Synthetic fixture input: Retry-After seconds/date parsing, ceiling and fallback probes passed.\"\n39\t }\n40\t ],\n41\t \"findings\": []\n42\t },\n43\t \"native_adversarial\": {\n44\t \"settled\": true,\n45\t \"status\": \"completed\",\n46\t \"findings\": [],\n47\t \"result\": \"Synthetic fixture input: native adversarial review returned no findings.\"\n48\t },\n49\t \"structured_review\": {\n50\t \"required\": false,\n51\t \"reason\": \"Tiny diff; no full-review, structured-review or P1 override requested.\"\n52\t }\n53\t}\n54\t"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01Ux4koaFZbNCDB5y1zsdDTK",
|
||||
"name": "Read",
|
||||
"input": {
|
||||
"file_path": "/__w/gstack/gstack/review/sections/shared-code-reuse.md"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_01Ux4koaFZbNCDB5y1zsdDTK",
|
||||
"type": "tool_result",
|
||||
"content": "1\t<!-- AUTO-GENERATED from shared-code-reuse.md.tmpl — do not edit directly -->\n2\t<!-- Regenerate: bun run gen:skill-docs -->\n3\t**Reuse a skipped shared-code advisory only with complete structural evidence:**\n4\t\n5\t1. **Read the evidence.** Read all supporting callers and the helper destination.\n6\t Establish first-party authored provenance and whether the current extraction\n7\t is worthwhile; the checker cannot decide that. Retain `evidence_paths`/`helper_target`.\n8\t2. **Run the checker.** From the repository root, pass the current finding as\n9\t literal JSON on stdin. Replace REVIEW_START with this pass's captured token\n10\t and the example paths/symbol with actual evidence. Keep the quoted delimiter.\n11\t\n12\t```bash\n13\t\"$HOME/.claude/skills/gstack/bin/gstack-review-log\" --check-shared-libs REVIEW_START <<'GSTACK_SHARED_LIBS_REUSE_JSON'\n14\t{\"advisory\":true,\"severity\":\"INFORMATIONAL\",\"evidence_paths\":[\"src/caller-a.ts\",\"src/caller-b.ts\"],\"helper_target\":{\"path\":\"src/shared.ts\",\"symbol\":\"sharedHelper\"}}\n15\tGSTACK_SHARED_LIBS_REUSE_JSON\n16\t```\n17\t\n18\t3. **Act on its result.** Read the JSON. Only `reusable: true` permits suppression.\n19\t False, command failure or unreadable output requires fresh source review and a\n20\t new decision, never suppression. Do not supply your own snapshot, prior record or coverage.\n21\t4. **Persist through the logger.** The logger recomputes final coverage; never\n22\t supply proof yourself. Real defects retain normal Fix-First handling independently.\n23\t\n24\t**What a reusable result proves (do not reconstruct these checks yourself):**\n25\t- Identity: `sharedLibsFingerprint` plus the actual repo, raw branch and current snapshot.\n26\t The checker reads REVIEW_START without consuming/replacing it. Sanitized branch names are not identity.\n27\t- Prior decision: completed/converged review, verified binding, explicit Skip and\n28\t logger-versioned `snapshot_covered_paths`; older unversioned coverage needs a fresh decision.\n29\t- Source: `canReuseSharedLibsAdvisory` requires every supporting path's raw file\n30\t byte-for-byte with its blob. Exclude assume-unchanged, skip-worktree and sparse index\n31\t entries; symlinks/ancestors, submodules, ignored/outside or unreadable files;\n32\t active/unknown Git filters, encodings and line conversion.\n33\t- Safe inspection: disables fsmonitor and optional locks; never uses external diff/textconv.\n34\t Unknown evidence fails closed.\n35\t"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01HgUDAPUD3QgytJmoHKQMdb",
|
||||
"name": "Bash",
|
||||
"input": {
|
||||
"command": "git fetch origin 2>&1; echo \"MB=$(git merge-base origin/main HEAD)\"; echo \"HEAD=$(git rev-parse HEAD)\"; echo \"BASE=$(git rev-parse origin/main)\"; git branch --show-current; git status --porcelain=v1 --ignored; echo ---ls-files-v; git ls-files -v | head -50; echo ---debug; git ls-files --debug -- src | head -60; echo ---config; git config --local --list; echo ---attrs; cat .gitattributes .git/info/attributes .git/info/exclude 2>&1; cat .gitignore 2>&1; git config --get core.attributesFile; git config --get core.excludesFile"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "Exit code 1\nMB=0065f046b3be1008d3788dcaa596fbdadc1b540b\nHEAD=0065f046b3be1008d3788dcaa596fbdadc1b540b\nBASE=0065f046b3be1008d3788dcaa596fbdadc1b540b\nfeature/a\n M src/retry-worker.ts\n!! runtime-local/\n---ls-files-v\nH .gitignore\nH README.md\nH lib/retry-after.ts\nH src/retry-route.ts\nH src/retry-worker.ts\nH src/scheduler.ts\nH test/retry-after.test.ts\n---debug\nsrc/retry-route.ts\n ctime: 1793152411:639522773\n mtime: 1793152411:639522773\n dev: 45\tino: 9962937\n uid: 1001\tgid: 1001\n size: 51\tflags: 0\nsrc/retry-worker.ts\n ctime: 1793152411:639522773\n mtime: 1793152411:639522773\n dev: 45\tino: 9962936\n uid: 1001\tgid: 1001\n size: 51\tflags: 0\nsrc/scheduler.ts\n ctime: 1793152411:615527724\n mtime: 1793152411:615527724\n dev: 45\tino: 9962940\n uid: 1001\tgid: 1001\n size: 126\tflags: 0\n---config\ncore.repositoryformatversion=0\ncore.filemode=true\ncore.bare=false\ncore.logallrefupdates=true\nuser.name=Shared Libs Fixture\nuser.email=shared-libs@example.invalid\nremote.origin.url=https://github.com/fixture/shared-libs.git\nremote.origin.fetch=+refs/heads/*:refs/remotes/origin/*\n---attrs\ncat: .gitattributes: No such file or directory\ncat: .git/info/attributes: No such file or directory\n# git ls-files --others --exclude-from=.git/info/exclude\n# Lines that start with '#' are comments.\n# For a project mostly in C, the following would be a good set of\n# exclude patterns (uncomment them if you want to use them):\n# *.[oa]\n# *~\n.fixture/\nruntime-local/",
|
||||
"is_error": true,
|
||||
"tool_use_id": "toolu_01HgUDAPUD3QgytJmoHKQMdb"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01XV73n6QkXKEbPiTQbhA5CJ",
|
||||
"name": "Bash",
|
||||
"input": {
|
||||
"command": "'/__w/gstack/gstack/bin/gstack-review-log' --start review"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_01XV73n6QkXKEbPiTQbhA5CJ",
|
||||
"type": "tool_result",
|
||||
"content": "547c0e1f-86d4-40e2-8be6-bf87f439bc75",
|
||||
"is_error": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01A7hwW89RrEkkidKLpKJB41",
|
||||
"name": "Read",
|
||||
"input": {
|
||||
"file_path": "/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/state/projects/fixture-shared-libs/.review-starts/547c0e1f-86d4-40e2-8be6-bf87f439bc75.json"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_01A7hwW89RrEkkidKLpKJB41",
|
||||
"type": "tool_result",
|
||||
"content": "1\t{\"skill\":\"review\",\"repo\":\"/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/repo\",\"branch\":\"feature/a\",\"wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"started_at\":\"2026-10-28T01:53:45.785Z\"}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01JNnuM42rwXQCenGMNg9Uc6",
|
||||
"name": "Bash",
|
||||
"input": {
|
||||
"command": "git diff 0065f046b3be1008d3788dcaa596fbdadc1b540b; echo ---untracked; git ls-files --others --exclude-standard; echo ---ignored; git ls-files --others --ignored --exclude-standard; echo ---checkignore; git check-ignore -v runtime-local/retry-route.ts; echo ---gitignore; cat .gitignore"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_01JNnuM42rwXQCenGMNg9Uc6",
|
||||
"type": "tool_result",
|
||||
"content": "diff --git a/src/retry-worker.ts b/src/retry-worker.ts\nindex 8ab4b18..8f6dd51 100644\n--- a/src/retry-worker.ts\n+++ b/src/retry-worker.ts\n@@ -1 +1,15 @@\n-export { retrySeconds } from '../lib/retry-after';\n+// Both callers require Retry-After seconds/date parsing, 3600-second ceiling and caller fallback.\n+export function retrySeconds(value: string | null, now: number, fallback = 5): number {\n+ if (value == null || value.trim() === '') return fallback;\n+ const normalized = value.trim();\n+ if (/^\\d+$/.test(normalized)) {\n+ const seconds = Number(normalized);\n+ if (!Number.isSafeInteger(seconds)) return fallback;\n+ return Math.min(seconds, 3600);\n+ }\n+ const deadline = Date.parse(normalized);\n+ if (!Number.isFinite(deadline)) return fallback;\n+ const remaining = Math.ceil((deadline - now) / 1000);\n+ if (remaining < 0) return 0;\n+ return Math.min(remaining, 3600);\n+}\n---untracked\n---ignored\nruntime-local/retry-route.ts\n---checkignore\n.gitignore:2:runtime-local/\truntime-local/retry-route.ts\n---gitignore\n.fixture/\nruntime-local/",
|
||||
"is_error": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01SqU5QcTMtXntb5BsZLwEnj",
|
||||
"name": "Bash",
|
||||
"input": {
|
||||
"command": "cat -n src/retry-worker.ts; echo ===; cat -n runtime-local/retry-route.ts; echo ===; cat -n lib/retry-after.ts; echo ===; cat -n src/retry-route.ts; echo ===; cat -n src/scheduler.ts; echo ===; cat -n test/retry-after.test.ts; echo ===; cat -n README.md; echo ===; ls -la runtime-local; git ls-files -s runtime-local; git log --oneline -- runtime-local | head"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "Exit code 1\n 1\t// Both callers require Retry-After seconds/date parsing, 3600-second ceiling and caller fallback.\n 2\texport function retrySeconds(value: string | null, now: number, fallback = 5): number {\n 3\t if (value == null || value.trim() === '') return fallback;\n 4\t const normalized = value.trim();\n 5\t if (/^\\d+$/.test(normalized)) {\n 6\t const seconds = Number(normalized);\n 7\t if (!Number.isSafeInteger(seconds)) return fallback;\n 8\t return Math.min(seconds, 3600);\n 9\t }\n 10\t const deadline = Date.parse(normalized);\n 11\t if (!Number.isFinite(deadline)) return fallback;\n 12\t const remaining = Math.ceil((deadline - now) / 1000);\n 13\t if (remaining < 0) return 0;\n 14\t return Math.min(remaining, 3600);\n 15\t}\n(eval):1: == not found",
|
||||
"is_error": true,
|
||||
"tool_use_id": "toolu_01SqU5QcTMtXntb5BsZLwEnj"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01SG2WWtp5ukb9AXXBzLM52i",
|
||||
"name": "Bash",
|
||||
"input": {
|
||||
"command": "cat -n runtime-local/retry-route.ts; echo '---'; cat -n lib/retry-after.ts; echo '---'; cat -n src/retry-route.ts; echo '---'; cat -n src/scheduler.ts; echo '---'; cat -n test/retry-after.test.ts; echo '---'; cat -n README.md; echo '---'; ls -la runtime-local; git ls-files -s runtime-local; git log --oneline --all -- runtime-local | head"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_01SG2WWtp5ukb9AXXBzLM52i",
|
||||
"type": "tool_result",
|
||||
"content": " 1\t// Both callers require Retry-After seconds/date parsing, 3600-second ceiling and caller fallback.\n 2\texport function retrySeconds(value: string | null, now: number, fallback = 5): number {\n 3\t if (value == null || value.trim() === '') return fallback;\n 4\t const normalized = value.trim();\n 5\t if (/^\\d+$/.test(normalized)) {\n 6\t const seconds = Number(normalized);\n 7\t if (!Number.isSafeInteger(seconds)) return fallback;\n 8\t return Math.min(seconds, 3600);\n 9\t }\n 10\t const deadline = Date.parse(normalized);\n 11\t if (!Number.isFinite(deadline)) return fallback;\n 12\t const remaining = Math.ceil((deadline - now) / 1000);\n 13\t if (remaining < 0) return 0;\n 14\t return Math.min(remaining, 3600);\n 15\t}\n 16\t\n 17\t// Authored caller changed after the prior decision (ignored).\n---\n 1\t// Proven shared parser already used by the scheduler.\n 2\texport function retrySeconds(value: string | null, now: number, fallback = 5): number {\n 3\t if (value == null || value.trim() === '') return fallback;\n 4\t const normalized = value.trim();\n 5\t if (/^\\d+$/.test(normalized)) {\n 6\t const seconds = Number(normalized);\n 7\t if (!Number.isSafeInteger(seconds)) return fallback;\n 8\t return Math.min(seconds, 3600);\n 9\t }\n 10\t const deadline = Date.parse(normalized);\n 11\t if (!Number.isFinite(deadline)) return fallback;\n 12\t const remaining = Math.ceil((deadline - now) / 1000);\n 13\t if (remaining < 0) return 0;\n 14\t return Math.min(remaining, 3600);\n 15\t}\n---\n 1\texport { retrySeconds } from '../lib/retry-after';\n---\n 1\timport { retrySeconds } from '../lib/retry-after';\n 2\texport const nextRun = (value: string) => retrySeconds(value, Date.now());\n---\n 1\timport { expect, test } from 'bun:test';\n 2\timport { retrySeconds } from '../lib/retry-after';\n 3\ttest('retry parser contract', () => {\n 4\t expect(retrySeconds(null, 0)).toBe(5);\n 5\t expect(retrySeconds('invalid', 0, 7)).toBe(7);\n 6\t expect(retrySeconds(' 42 ', 0)).toBe(42);\n 7\t expect(retrySeconds('999999', 0)).toBe(3600);\n 8\t expect(retrySeconds('Thu, 01 Jan 1970 00:00:01 GMT', 0)).toBe(1);\n 9\t});\n---\n 1\t# Fixture application\n 2\t\n 3\tThe application deployment supplies authored optional route adapters under runtime-local/. That established mount is intentionally excluded from this repository; runtime-local/retry-route.ts is first-party application code loaded only after the mount is present.\n---\ntotal 12\ndrwxr-xr-x 2 runner runner 4096 Oct 28 01:53 .\ndrwxr-xr-x 7 runner runner 4096 Oct 28 01:53 ..\n-rw-r--r-- 1 runner runner 737 Oct 28 01:53 retry-route.ts",
|
||||
"is_error": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01CVpwC8ef1niGnBoDL1ppJL",
|
||||
"name": "Bash",
|
||||
"input": {
|
||||
"command": "'/__w/gstack/gstack/bin/gstack-review-log' --check-shared-libs 547c0e1f-86d4-40e2-8be6-bf87f439bc75 <<'GSTACK_REVALIDATION_FINDING'\n{\"severity\":\"INFORMATIONAL\",\"confidence\":9,\"advisory\":true,\"path\":\"src/retry-worker.ts\",\"line\":2,\"category\":\"shared-libs\",\"summary\":\"Use the established Retry-After contract in the changed worker and the authored route sources.\",\"fix\":\"Share the tested retrySeconds contract, preserving runtime and deployment boundaries for each caller.\",\"evidence_paths\":[\"src/retry-worker.ts\",\"runtime-local/retry-route.ts\",\"lib/retry-after.ts\"],\"helper_target\":{\"path\":\"lib/retry-after.ts\",\"symbol\":\"retrySeconds\"},\"fingerprint\":\"shared-libs:52c933addcfd80ba554222133fe75e4bd06d9dfb5d1bc4484f0460a1517cf881\",\"specialist\":\"maintainability\"}\nGSTACK_REVALIDATION_FINDING"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_01CVpwC8ef1niGnBoDL1ppJL",
|
||||
"type": "tool_result",
|
||||
"content": "{\"reusable\":false,\"fingerprint\":\"shared-libs:52c933addcfd80ba554222133fe75e4bd06d9dfb5d1bc4484f0460a1517cf881\",\"review_start\":{\"skill\":\"review\",\"repo\":\"/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/repo\",\"branch\":\"feature/a\",\"wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"started_at\":\"2026-10-28T01:53:45.785Z\"},\"snapshot\":{\"wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"branch_id\":\"951d42dc02dc743167ac3dd9d8decc5eee71860498c4b1707e1d38816df1ed1d\",\"covered_paths\":[\"src/retry-worker.ts\",\"lib/retry-after.ts\"]}}",
|
||||
"is_error": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01CbxQEpFUZwz6eTdFxic3QE",
|
||||
"name": "Bash",
|
||||
"input": {
|
||||
"command": "'/__w/gstack/gstack/bin/gstack-review-read'"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_01CbxQEpFUZwz6eTdFxic3QE",
|
||||
"type": "tool_result",
|
||||
"content": "{\"skill\":\"review\",\"timestamp\":\"2026-10-28T01:53:31.755Z\",\"status\":\"clean\",\"issues_found\":0,\"critical\":0,\"informational\":0,\"quality_score\":10,\"findings\":[{\"severity\":\"INFORMATIONAL\",\"confidence\":9,\"advisory\":true,\"path\":\"src/retry-worker.ts\",\"line\":2,\"category\":\"shared-libs\",\"summary\":\"Use the established Retry-After contract in the changed worker and the authored route sources.\",\"fix\":\"Share the tested retrySeconds contract, preserving runtime and deployment boundaries for each caller.\",\"evidence_paths\":[\"src/retry-worker.ts\",\"runtime-local/retry-route.ts\",\"lib/retry-after.ts\"],\"helper_target\":{\"path\":\"lib/retry-after.ts\",\"symbol\":\"retrySeconds\"},\"fingerprint\":\"shared-libs:52c933addcfd80ba554222133fe75e4bd06d9dfb5d1bc4484f0460a1517cf881\",\"action\":\"skipped\",\"snapshot_covered_paths\":[\"src/retry-worker.ts\",\"lib/retry-after.ts\"]}],\"completed\":true,\"converged\":true,\"cycles\":0,\"status_source\":\"claimed\",\"commit_full\":\"0065f046b3be1008d3788dcaa596fbdadc1b540b\",\"tree\":\"3d257818bedb4ab8770ce806c8b0bfcc45537857\",\"dirty\":true,\"review_binding\":{\"state\":\"verified\",\"start_wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"end_wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"started_at\":\"2026-10-28T01:53:31.753Z\",\"branch_id\":\"951d42dc02dc743167ac3dd9d8decc5eee71860498c4b1707e1d38816df1ed1d\"},\"wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"shared_libs_coverage_version\":1,\"review_freshness\":{\"status\":\"CURRENT\",\"reason\":\"completed clean pass on unchanged content\"}}\n---CONFIG---\nfalse---HEAD---\n0065f04\n---WTREE---\n96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\n---TREE---\n3d257818bedb4ab8770ce806c8b0bfcc45537857\n---DIRTY---\ntrue",
|
||||
"is_error": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_013zyFAS1agf999Po7qTodYm",
|
||||
"name": "Grep",
|
||||
"input": {
|
||||
"pattern": "retrySeconds|3600|Retry-After",
|
||||
"path": "/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/repo",
|
||||
"output_mode": "content"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_013zyFAS1agf999Po7qTodYm",
|
||||
"type": "tool_result",
|
||||
"content": "src/retry-route.ts:1:export { retrySeconds } from '../lib/retry-after';\nsrc/retry-worker.ts:1:// Both callers require Retry-After seconds/date parsing, 3600-second ceiling and caller fallback.\nsrc/retry-worker.ts:2:export function retrySeconds(value: string | null, now: number, fallback = 5): number {\nsrc/retry-worker.ts:8: return Math.min(seconds, 3600);\nsrc/retry-worker.ts:14: return Math.min(remaining, 3600);\nsrc/scheduler.ts:1:import { retrySeconds } from '../lib/retry-after';\nsrc/scheduler.ts:2:export const nextRun = (value: string) => retrySeconds(value, Date.now());\ntest/retry-after.test.ts:2:import { retrySeconds } from '../lib/retry-after';\ntest/retry-after.test.ts:4: expect(retrySeconds(null, 0)).toBe(5);\ntest/retry-after.test.ts:5: expect(retrySeconds('invalid', 0, 7)).toBe(7);\ntest/retry-after.test.ts:6: expect(retrySeconds(' 42 ', 0)).toBe(42);\ntest/retry-after.test.ts:7: expect(retrySeconds('999999', 0)).toBe(3600);\ntest/retry-after.test.ts:8: expect(retrySeconds('Thu, 01 Jan 1970 00:00:01 GMT', 0)).toBe(1);\nlib/retry-after.ts:2:export function retrySeconds(value: string | null, now: number, fallback = 5): number {\nlib/retry-after.ts:8: return Math.min(seconds, 3600);\nlib/retry-after.ts:14: return Math.min(remaining, 3600);"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01Pgoa7ZGB1vUMTZ2QBC3kU2",
|
||||
"name": "AskUserQuestion",
|
||||
"input": {
|
||||
"questions": [
|
||||
{
|
||||
"question": "[ADVISORY] src/retry-worker.ts:2-15 copies lib/retry-after.ts retrySeconds inline, and the copy matches it exactly. The prior Skip can't be reused: the checker returned reusable:false because runtime-local/retry-route.ts is gitignored, isn't covered by the snapshot, and changed after that decision. Proposed fix: put back `export { retrySeconds } from '../lib/retry-after';` in the worker, the same as src/retry-route.ts and src/scheduler.ts. Leave the runtime-local adapter out of the change, since it's a separate deployment mount. Estimated savings: about 15 lines removed, 1 added (net −14). Covered by the existing test/retry-after.test.ts. If the shared helper broke, it would affect the worker, route and scheduler. RECOMMENDATION: A. Note that A edits source, which blocks this no-edit replay, so the review would stay incomplete.",
|
||||
"header": "Retry helper",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{
|
||||
"label": "A) Fix as recommended",
|
||||
"description": "Re-export retrySeconds from lib/retry-after in the worker. Needs a source edit, which blocks this bounded no-edit replay, so the review is reported incomplete."
|
||||
},
|
||||
{
|
||||
"label": "B) Skip",
|
||||
"description": "No code or index change; the Skip is recorded."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "Your questions have been answered: \"[ADVISORY] src/retry-worker.ts:2-15 copies lib/retry-after.ts retrySeconds inline, and the copy matches it exactly. The prior Skip can't be reused: the checker returned reusable:false because runtime-local/retry-route.ts is gitignored, isn't covered by the snapshot, and changed after that decision. Proposed fix: put back `export { retrySeconds } from '../lib/retry-after';` in the worker, the same as src/retry-route.ts and src/scheduler.ts. Leave the runtime-local adapter out of the change, since it's a separate deployment mount. Estimated savings: about 15 lines removed, 1 added (net −14). Covered by the existing test/retry-after.test.ts. If the shared helper broke, it would affect the worker, route and scheduler. RECOMMENDATION: A. Note that A edits source, which blocks this no-edit replay, so the review would stay incomplete.\"=\"B) Skip\". You can now continue with these answers in mind.",
|
||||
"tool_use_id": "toolu_01Pgoa7ZGB1vUMTZ2QBC3kU2"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01MZ3iwhrhCqS2NwyehefiSg",
|
||||
"name": "Bash",
|
||||
"input": {
|
||||
"command": "git rev-parse HEAD origin/main; git branch --show-current; git status --porcelain=v1 --ignored; git ls-files -s; git ls-files -v; sha256sum src/retry-worker.ts lib/retry-after.ts runtime-local/retry-route.ts .git/config .git/info/exclude .gitignore; git config --local --list | md5sum; date -u +%Y-%m-%dT%H:%M:%SZ; git rev-parse --short HEAD"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_01MZ3iwhrhCqS2NwyehefiSg",
|
||||
"type": "tool_result",
|
||||
"content": "0065f046b3be1008d3788dcaa596fbdadc1b540b\n0065f046b3be1008d3788dcaa596fbdadc1b540b\nfeature/a\n M src/retry-worker.ts\n!! runtime-local/\n100644 43ac5a38d8386f6a0743f793d7ccee871df272a5 0\t.gitignore\n100644 965ef910ba60ca16fe356b96ee4cce5162a01a23 0\tREADME.md\n100644 00dfe6a156bae81aefc94ff1e9db54e32d774c75 0\tlib/retry-after.ts\n100644 8ab4b18d9a2bd337433526c96343d251aaee8539 0\tsrc/retry-route.ts\n100644 8ab4b18d9a2bd337433526c96343d251aaee8539 0\tsrc/retry-worker.ts\n100644 c362fe9878839740d82db85053758be5b95834cd 0\tsrc/scheduler.ts\n100644 a470f2c203c43e66c47991c64654dd524f9c5c29 0\ttest/retry-after.test.ts\nH .gitignore\nH README.md\nH lib/retry-after.ts\nH src/retry-route.ts\nH src/retry-worker.ts\nH src/scheduler.ts\nH test/retry-after.test.ts\na17d12e37ae2b07d38dae292156aef6ec83d11b4ec2e295bb14d01f0e8fc9b2e src/retry-worker.ts\n67d7124d11830e41baa5fca93bd0d45ce64fe58d92799c7d14635b912c5e5942 lib/retry-after.ts\ndb46a502afb0ba90f6e0001a990728f3ae8849be1c75b8ccd616d3ef25353d33 runtime-local/retry-route.ts\n2550b8371ff38558f3a8c847e5563ca10271d66c0ce1ea8608cc415a959b5854 .git/config\n6671fe83b7a07c8932ee89164d1f2793b2318058eb8b98dc5c06ee0a5a3b0ec1 .git/info/exclude\n326b615083eaf2671938ccb4ce4dc28b7c07f5687033fa74dc4caa44f866ccb4 .gitignore\nafba8a8c204ce2d12c88f5a21198daad -\n2026-10-28T01:54:34Z\n0065f04",
|
||||
"is_error": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01JqjkEAXGWYdxnP9Mu7fZ92",
|
||||
"name": "Bash",
|
||||
"input": {
|
||||
"command": "bun '/__w/gstack/gstack/test/helpers/shared-libs-path-fixture.ts' --check-review-prerequisites '/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/resumed-review-prerequisites.json'"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_01JqjkEAXGWYdxnP9Mu7fZ92",
|
||||
"type": "tool_result",
|
||||
"content": "{\"synthetic\":true,\"native_coverage\":false,\"settled\":true,\"current\":true,\"input_sha256\":\"b26272f134145cdb6bd79a2bea545d44e9d84d128c6f81852de73bf1876509a1\",\"context\":{\"kind\":\"synthetic-path-review-prerequisites\",\"synthetic\":true,\"native_coverage\":false,\"binding\":{\"root\":\"/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2\",\"repo\":\"/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/repo\",\"state\":\"/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/state\",\"branch\":\"feature/a\",\"head\":\"0065f046b3be1008d3788dcaa596fbdadc1b540b\",\"base\":\"0065f046b3be1008d3788dcaa596fbdadc1b540b\",\"wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"index\":\"H 100644 43ac5a38d8386f6a0743f793d7ccee871df272a5 0\\t.gitignore\\nH 100644 965ef910ba60ca16fe356b96ee4cce5162a01a23 0\\tREADME.md\\nH 100644 00dfe6a156bae81aefc94ff1e9db54e32d774c75 0\\tlib/retry-after.ts\\nH 100644 8ab4b18d9a2bd337433526c96343d251aaee8539 0\\tsrc/retry-route.ts\\nH 100644 8ab4b18d9a2bd337433526c96343d251aaee8539 0\\tsrc/retry-worker.ts\\nH 100644 c362fe9878839740d82db85053758be5b95834cd 0\\tsrc/scheduler.ts\\nH 100644 a470f2c203c43e66c47991c64654dd524f9c5c29 0\\ttest/retry-after.test.ts\",\"raw\":{\".\":\"dir:16877\",\".git/config\":\"33188:2550b8371ff38558f3a8c847e5563ca10271d66c0ce1ea8608cc415a959b5854\",\".git/info/exclude\":\"33188:6671fe83b7a07c8932ee89164d1f2793b2318058eb8b98dc5c06ee0a5a3b0ec1\",\".gitignore\":\"33188:326b615083eaf2671938ccb4ce4dc28b7c07f5687033fa74dc4caa44f866ccb4\",\"README.md\":\"33188:94324728d3116382754324b62b8bcf233cf6046df4d1f4f770ecc68df7b10504\",\"lib\":\"dir:16877\",\"lib/retry-after.ts\":\"33188:67d7124d11830e41baa5fca93bd0d45ce64fe58d92799c7d14635b912c5e5942\",\"runtime-local\":\"dir:16877\",\"runtime-local/retry-route.ts\":\"33188:db46a502afb0ba90f6e0001a990728f3ae8849be1c75b8ccd616d3ef25353d33\",\"src\":\"dir:16877\",\"src/retry-route.ts\":\"33188:300feb02d24ed09ca763371d42249f8307dbe9201fb4cddf7aa634a9e341f887\",\"src/retry-worker.ts\":\"33188:a17d12e37ae2b07d38dae292156aef6ec83d11b4ec2e295bb14d01f0e8fc9b2e\",\"src/scheduler.ts\":\"33188:8a07bfe628ea4f9e679bcab3cfb68482678eebc1e4031887e996d799a6d1198e\",\"test\":\"dir:16877\",\"test/retry-after.test.ts\":\"33188:653e4090ae4e0d856f4dbf04fb05449d04c44fef599c24ce146d94969a1040a1\"}},\"qa\":{\"settled\":true,\"required_probes\":[{\"id\":\"retry-contract\",\"status\":\"passed\",\"result\":\"Synthetic fixture input: Retry-After seconds/date parsing, ceiling and fallback probes passed.\"}],\"findings\":[]},\"native_adversarial\":{\"settled\":true,\"status\":\"completed\",\"findings\":[],\"result\":\"Synthetic fixture input: native adversarial review returned no findings.\"},\"structured_review\":{\"required\":false,\"reason\":\"Tiny diff; no full-review, structured-review or P1 override requested.\"}}}",
|
||||
"is_error": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01JHrJYtsYw8HnZzznyDcNo6",
|
||||
"name": "Bash",
|
||||
"input": {
|
||||
"command": "'/__w/gstack/gstack/bin/gstack-review-log' '{\"skill\":\"review\",\"timestamp\":\"2026-10-28T01:54:34Z\",\"status\":\"clean\",\"issues_found\":0,\"critical\":0,\"informational\":0,\"quality_score\":10.0,\"specialists\":{},\"findings\":[{\"severity\":\"INFORMATIONAL\",\"confidence\":9,\"advisory\":true,\"path\":\"src/retry-worker.ts\",\"line\":2,\"category\":\"shared-libs\",\"summary\":\"Use the established Retry-After contract in the changed worker and the authored route sources.\",\"fix\":\"Share the tested retrySeconds contract, preserving runtime and deployment boundaries for each caller.\",\"evidence_paths\":[\"src/retry-worker.ts\",\"runtime-local/retry-route.ts\",\"lib/retry-after.ts\"],\"helper_target\":{\"path\":\"lib/retry-after.ts\",\"symbol\":\"retrySeconds\"},\"fingerprint\":\"shared-libs:52c933addcfd80ba554222133fe75e4bd06d9dfb5d1bc4484f0460a1517cf881\",\"action\":\"skipped\"}],\"commit\":\"0065f04\",\"completed\":true,\"converged\":true,\"cycles\":0}' --finish 547c0e1f-86d4-40e2-8be6-bf87f439bc75 && '/__w/gstack/gstack/bin/gstack-review-read'"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_01JHrJYtsYw8HnZzznyDcNo6",
|
||||
"type": "tool_result",
|
||||
"content": "{\"skill\":\"review\",\"timestamp\":\"2026-10-28T01:53:31.755Z\",\"status\":\"clean\",\"issues_found\":0,\"critical\":0,\"informational\":0,\"quality_score\":10,\"findings\":[{\"severity\":\"INFORMATIONAL\",\"confidence\":9,\"advisory\":true,\"path\":\"src/retry-worker.ts\",\"line\":2,\"category\":\"shared-libs\",\"summary\":\"Use the established Retry-After contract in the changed worker and the authored route sources.\",\"fix\":\"Share the tested retrySeconds contract, preserving runtime and deployment boundaries for each caller.\",\"evidence_paths\":[\"src/retry-worker.ts\",\"runtime-local/retry-route.ts\",\"lib/retry-after.ts\"],\"helper_target\":{\"path\":\"lib/retry-after.ts\",\"symbol\":\"retrySeconds\"},\"fingerprint\":\"shared-libs:52c933addcfd80ba554222133fe75e4bd06d9dfb5d1bc4484f0460a1517cf881\",\"action\":\"skipped\",\"snapshot_covered_paths\":[\"src/retry-worker.ts\",\"lib/retry-after.ts\"]}],\"completed\":true,\"converged\":true,\"cycles\":0,\"status_source\":\"claimed\",\"commit_full\":\"0065f046b3be1008d3788dcaa596fbdadc1b540b\",\"tree\":\"3d257818bedb4ab8770ce806c8b0bfcc45537857\",\"dirty\":true,\"review_binding\":{\"state\":\"verified\",\"start_wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"end_wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"started_at\":\"2026-10-28T01:53:31.753Z\",\"branch_id\":\"951d42dc02dc743167ac3dd9d8decc5eee71860498c4b1707e1d38816df1ed1d\"},\"wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"shared_libs_coverage_version\":1,\"review_freshness\":{\"status\":\"CURRENT\",\"reason\":\"completed clean pass on unchanged content\"}}\n{\"skill\":\"review\",\"timestamp\":\"2026-10-28T01:54:34Z\",\"status\":\"clean\",\"issues_found\":0,\"critical\":0,\"informational\":0,\"quality_score\":10,\"specialists\":{},\"findings\":[{\"severity\":\"INFORMATIONAL\",\"confidence\":9,\"advisory\":true,\"path\":\"src/retry-worker.ts\",\"line\":2,\"category\":\"shared-libs\",\"summary\":\"Use the established Retry-After contract in the changed worker and the authored route sources.\",\"fix\":\"Share the tested retrySeconds contract, preserving runtime and deployment boundaries for each caller.\",\"evidence_paths\":[\"src/retry-worker.ts\",\"runtime-local/retry-route.ts\",\"lib/retry-after.ts\"],\"helper_target\":{\"path\":\"lib/retry-after.ts\",\"symbol\":\"retrySeconds\"},\"fingerprint\":\"shared-libs:52c933addcfd80ba554222133fe75e4bd06d9dfb5d1bc4484f0460a1517cf881\",\"action\":\"skipped\",\"snapshot_covered_paths\":[\"src/retry-worker.ts\",\"lib/retry-after.ts\"]}],\"commit\":\"0065f04\",\"completed\":true,\"converged\":true,\"cycles\":0,\"status_source\":\"claimed\",\"commit_full\":\"0065f046b3be1008d3788dcaa596fbdadc1b540b\",\"tree\":\"3d257818bedb4ab8770ce806c8b0bfcc45537857\",\"dirty\":true,\"review_binding\":{\"state\":\"verified\",\"start_wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"end_wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"started_at\":\"2026-10-28T01:53:45.785Z\",\"branch_id\":\"951d42dc02dc743167ac3dd9d8decc5eee71860498c4b1707e1d38816df1ed1d\"},\"wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"shared_libs_coverage_version\":1,\"review_freshness\":{\"status\":\"CURRENT\",\"reason\":\"completed clean pass on unchanged content\"}}\n---CONFIG---\nfalse---HEAD---\n0065f04\n---WTREE---\n96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\n---TREE---\n3d257818bedb4ab8770ce806c8b0bfcc45537857\n---DIRTY---\ntrue",
|
||||
"is_error": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+19
-19
@@ -5,50 +5,50 @@
|
||||
"agy/gstack-autoplan": 109810,
|
||||
"agy/gstack-plan-design-review": 110044,
|
||||
"agy/gstack-plan-devex-review": 116496,
|
||||
"agy/gstack-plan-eng-review": 147241,
|
||||
"agy/gstack-review": 103755,
|
||||
"agy/gstack-plan-eng-review": 144907,
|
||||
"agy/gstack-review": 103743,
|
||||
"codex/gstack-autoplan": 108553,
|
||||
"codex/gstack-land-and-deploy": 100284,
|
||||
"codex/gstack-plan-design-review": 111116,
|
||||
"codex/gstack-plan-devex-review": 118004,
|
||||
"codex/gstack-plan-eng-review": 148406,
|
||||
"codex/gstack-review": 103278,
|
||||
"codex/gstack-plan-eng-review": 146072,
|
||||
"codex/gstack-review": 103266,
|
||||
"copilot/gstack-autoplan": 109253,
|
||||
"copilot/gstack-plan-design-review": 110355,
|
||||
"copilot/gstack-plan-devex-review": 115958,
|
||||
"copilot/gstack-plan-eng-review": 147406,
|
||||
"copilot/gstack-review": 103336,
|
||||
"copilot/gstack-plan-eng-review": 145072,
|
||||
"copilot/gstack-review": 103324,
|
||||
"cursor/gstack-autoplan": 108485,
|
||||
"cursor/gstack-plan-design-review": 108900,
|
||||
"cursor/gstack-plan-devex-review": 115228,
|
||||
"cursor/gstack-plan-eng-review": 145996,
|
||||
"cursor/gstack-review": 121626,
|
||||
"cursor/gstack-plan-eng-review": 143662,
|
||||
"cursor/gstack-review": 121614,
|
||||
"factory/gstack-autoplan": 110763,
|
||||
"factory/gstack-land-and-deploy": 100105,
|
||||
"factory/gstack-plan-design-review": 111675,
|
||||
"factory/gstack-plan-devex-review": 118591,
|
||||
"factory/gstack-plan-eng-review": 149104,
|
||||
"factory/gstack-review": 125399,
|
||||
"factory/gstack-plan-eng-review": 146770,
|
||||
"factory/gstack-review": 125387,
|
||||
"gbrain/gstack-autoplan": 108892,
|
||||
"gbrain/gstack-plan-eng-review": 128623,
|
||||
"gbrain/gstack-plan-eng-review": 126289,
|
||||
"hermes/gstack-autoplan": 108982,
|
||||
"hermes/gstack-plan-eng-review": 128708,
|
||||
"hermes/gstack-plan-eng-review": 126374,
|
||||
"kiro/gstack-autoplan": 108259,
|
||||
"kiro/gstack-plan-design-review": 108752,
|
||||
"kiro/gstack-plan-devex-review": 115080,
|
||||
"kiro/gstack-plan-eng-review": 145858,
|
||||
"kiro/gstack-review": 121450,
|
||||
"kiro/gstack-plan-eng-review": 143524,
|
||||
"kiro/gstack-review": 121438,
|
||||
"openclaw/gstack-autoplan": 108715,
|
||||
"openclaw/gstack-plan-eng-review": 127138,
|
||||
"openclaw/gstack-plan-eng-review": 124804,
|
||||
"opencode/gstack-autoplan": 109234,
|
||||
"opencode/gstack-plan-design-review": 110187,
|
||||
"opencode/gstack-plan-devex-review": 115836,
|
||||
"opencode/gstack-plan-eng-review": 147265,
|
||||
"opencode/gstack-review": 122248,
|
||||
"opencode/gstack-plan-eng-review": 144931,
|
||||
"opencode/gstack-review": 122236,
|
||||
"slate/gstack-autoplan": 108372,
|
||||
"slate/gstack-plan-design-review": 108826,
|
||||
"slate/gstack-plan-devex-review": 115154,
|
||||
"slate/gstack-plan-eng-review": 145927,
|
||||
"slate/gstack-review": 121538
|
||||
"slate/gstack-plan-eng-review": 143593,
|
||||
"slate/gstack-review": 121526
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ describe('gen-skill-docs stale-render prune', () => {
|
||||
const source = path.join(out, 'source');
|
||||
fs.mkdirSync(path.join(source, 'scripts'), { recursive: true });
|
||||
fs.copyFileSync(path.join(ROOT, 'scripts', 'gen-skill-docs.ts'), path.join(source, 'scripts', 'gen-skill-docs.ts'));
|
||||
for (const file of ['discover-skills.ts', 'external-skill-names.ts', 'gen-llms-txt.ts', 'gen-agents-digest.ts', 'gen-doctor-components.ts', 'models.ts']) {
|
||||
for (const file of ['discover-skills.ts', 'external-skill-names.ts', 'gen-llms-txt.ts', 'gen-agents-digest.ts', 'gen-doctor-components.ts', 'models.ts', 'modules.ts']) {
|
||||
fs.symlinkSync(path.join(ROOT, 'scripts', file), path.join(source, 'scripts', file), 'file');
|
||||
}
|
||||
fs.symlinkSync(path.join(ROOT, 'scripts', 'resolvers'), path.join(source, 'scripts', 'resolvers'), 'dir');
|
||||
|
||||
@@ -13,6 +13,7 @@ import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { verdictProblem } from '../lib/install-verdict';
|
||||
|
||||
const REPO = path.resolve(import.meta.dir, '..');
|
||||
const BUN_DEFAULT = fs.readFileSync(path.join(REPO, 'bin/gstack-bun-version.sh'), 'utf8').match(/^GSTACK_BUN_DEFAULT="([^"]+)"$/m)![1];
|
||||
@@ -58,7 +59,7 @@ function fixture(opts: { bun?: string; jq?: boolean } = {}) {
|
||||
script(path.join(root, 'setup'), ['echo "setup $*" >> "$CAPY_LOG"', 'echo "gstack ready ($2)"', 'echo "env GSTACK_SKIP_PLAYWRIGHT=${GSTACK_SKIP_PLAYWRIGHT:-} GSTACK_SKIP_FONTS=${GSTACK_SKIP_FONTS:-} bun=$(bun --version)" >> "$CAPY_LOG"', 'exit "${STUB_SETUP_EXIT:-0}"']);
|
||||
script(path.join(root, 'bin/gstack-doctor'), [
|
||||
'echo "doctor $* GSTACK_EPHEMERAL=${GSTACK_EPHEMERAL:-}" >> "$CAPY_LOG"', 'echo "doctor-cwd $(pwd -P)" >> "$CAPY_LOG"', 'echo "doctor-node $(node --version 2>/dev/null)" >> "$CAPY_LOG"',
|
||||
'echo "required: claude codex patch"', 'echo "PASS claude ok"', 'echo "SKIP browser (lazy)"', '[ -n "${STUB_PINS_SKIP:-}" ] && echo "SKIP pins ($STUB_PINS_SKIP)"', '[ -n "${STUB_DOCTOR_EXTRA:-}" ] && echo "$STUB_DOCTOR_EXTRA"',
|
||||
'echo "required: ${STUB_REQUIRED:-claude codex patch}"', 'echo "PASS claude ok"', 'echo "SKIP browser (lazy)"', '[ -n "${STUB_PINS_SKIP:-}" ] && echo "SKIP pins ($STUB_PINS_SKIP)"', '[ -n "${STUB_DOCTOR_EXTRA:-}" ] && echo "$STUB_DOCTOR_EXTRA"',
|
||||
'[ "${STUB_DOCTOR_FAIL:-}" = 1 ] && { echo "FAIL codex — missing; fix: x (COMPONENT_MISSING; d)"; echo "gstack: fail codex"; exit 1; }',
|
||||
'[ -n "${GSTACK_DOCTOR_RUNTIME_DRIFT:-}" ] && { echo "FAIL runtime — bun resolution drifted: $GSTACK_DOCTOR_RUNTIME_DRIFT; fix: $GSTACK_DOCTOR_RUNTIME_DRIFT_FIX (BUN_RESOLUTION_DRIFT; d)"; echo "gstack: fail runtime"; exit 1; }',
|
||||
'echo "gstack: ok $(cat "$(dirname "$0")/../VERSION") for=autoplan"',
|
||||
@@ -351,6 +352,36 @@ describe('gstack-capy-install', () => {
|
||||
expect(run(f, ['--for', 'review-voice', '--project', project]).stdout).not.toMatch(upgrading);
|
||||
});
|
||||
|
||||
// #3561: on a shared machine /ship's docs step runs the installer --for document-release, whose
|
||||
// narrower verdict replaced the --for ship one, so /ship lost admission mid-run.
|
||||
test('--for ship then --for document-release: the verdict keeps both and admits both workflows; another HEAD replaces it', () => {
|
||||
const f = fixture();
|
||||
const env = { HOME: f.home, GSTACK_HOME: path.join(f.home, '.gstack-state') };
|
||||
const admits = (skill: string) => verdictProblem(skill, f.root, env, '', '1.4.2', f.home);
|
||||
const ship = run(f, ['--for', 'ship'], { STUB_REQUIRED: 'claude codex patch runtime pins' });
|
||||
expect(ship.status, ship.stdout + ship.stderr).toBe(0);
|
||||
const file = ship.stdout.match(/GSTACK_INSTALL_VERDICT=(\S+)/)![1]!;
|
||||
expect(admits('ship')).toBeNull();
|
||||
const docs = run(f, ['--for', 'document-release'], { STUB_REQUIRED: 'claude runtime pins' });
|
||||
expect(docs.status, docs.stdout + docs.stderr).toBe(0);
|
||||
expect(docs.stdout).toContain(`gstack-capy-install: the install verdict at ${file} keeps for=ship and adds document-release (for=ship,document-release)`);
|
||||
const v = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
expect(v).toMatchObject({ ok: true, for: 'ship,document-release', pins_planning_only: false });
|
||||
expect([...v.required].sort()).toEqual(['claude', 'codex', 'patch', 'pins', 'runtime']);
|
||||
expect(admits('ship')).toBeNull();
|
||||
expect(admits('document-release')).toBeNull();
|
||||
expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toEqual(v);
|
||||
const again = run(f, ['--check', '--for', 'ship'], { STUB_REQUIRED: 'claude codex patch runtime pins' });
|
||||
expect(again.status, again.stdout + again.stderr).toBe(0);
|
||||
expect(again.stdout).not.toContain('keeps for=');
|
||||
expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toMatchObject({ for: 'ship,document-release' });
|
||||
sh('git -c user.email=t@t -c user.name=t commit -q --allow-empty -m next', f.root);
|
||||
const moved = run(f, ['--for', 'document-release'], { STUB_REQUIRED: 'claude runtime pins' });
|
||||
expect(moved.status, moved.stdout + moved.stderr).toBe(0);
|
||||
expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toMatchObject({ for: 'document-release', required: ['claude', 'runtime', 'pins'] });
|
||||
expect(admits('ship')?.detail).toBe('the install verdict certified for=document-release, which does not cover codex that ship needs');
|
||||
});
|
||||
|
||||
test('--root clones when the directory is absent and refuses a non-gstack directory', () => {
|
||||
const f = fixture();
|
||||
const clone = path.join(f.base, 'clone');
|
||||
|
||||
@@ -9,7 +9,7 @@ import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { missingDependencies, parseRegistry, selectEntries, validateRegistry } from '../lib/regen';
|
||||
import { missingDependencies, parseRegistry, runRegen, selectEntries, validateRegistry } from '../lib/regen';
|
||||
import { BASE_REGISTRY, cleanup, commit, makeFixtureRepo, readFile, runBin, write, type FixtureRepo } from './helpers/pregate-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
@@ -135,6 +135,21 @@ describe('gstack-regen (bin)', () => {
|
||||
expect(parsed.wtree).toMatch(/^[0-9a-f]{40}$/);
|
||||
});
|
||||
|
||||
// Value: protects=the receipt carry's generated class (only outputs a generator recreates) and a generator that reads
|
||||
// its own registered output (gen-skill-docs and qa/templates/functional-report-template.md); fails_when=the proof
|
||||
// deletes outputs before the verdict run, or proves a path the command never wrote; seam=runRegen; no_claim=-
|
||||
test('prove: a writer proves its touched output; a generator that reads its own output stays fresh and proves nothing', () => {
|
||||
const registryOf = (repo: FixtureRepo) => parseRegistry(fs.readFileSync(path.join(repo.work, '.gstack/generated.json'), 'utf8')).registry;
|
||||
const writer = fixture();
|
||||
commit(writer, { 'src/version.js': "module.exports = { VERSION: '1.1.0' };\n", 'generated/digest.md': '# digest v1.1.0\n', 'generated/notes.md': '# hand-written\n' });
|
||||
expect(runRegen({ repoRoot: writer.work, registry: registryOf(writer), mode: 'check', only: ['digest'], prove: ['generated/digest.md', 'generated/notes.md', 'src/version.js'] }).results)
|
||||
.toMatchObject([{ id: 'digest', status: 'fresh', proven: ['generated/digest.md'] }]);
|
||||
const selfRead = fixture({ registry: { entries: [{ id: 'self', command: 'node selfread.js', outputs: ['generated/**'], inputs: ['selfread.js'] }] }, baseFiles: { 'selfread.js': "const fs=require('fs');fs.writeFileSync('generated/digest.md',fs.readFileSync('generated/digest.md','utf8'));\n" } });
|
||||
commit(selfRead, { 'generated/digest.md': '# hand edit\n' });
|
||||
expect(runRegen({ repoRoot: selfRead.work, registry: registryOf(selfRead), mode: 'check', prove: ['generated/digest.md'] }).results)
|
||||
.toMatchObject([{ id: 'self', status: 'fresh', proven: [] }]);
|
||||
});
|
||||
|
||||
test('a failing command is failed (exit 1, REGEN_COMMAND_FAILED, status incomplete); an output outside the declared globs is stale', () => {
|
||||
const repo = fixture({ registry: { entries: [{ id: 'boom', command: 'node -e "process.exit(7)"', outputs: ['generated/**'] }, { id: 'leak', command: 'node -e "require(\'fs\').writeFileSync(\'docs/extra.md\',\'x\')"', outputs: ['generated/**'] }] } });
|
||||
const r = runBin(repo, 'gstack-regen', ['check', '--base', 'main', '--allow-repo-commands']);
|
||||
|
||||
@@ -44,12 +44,14 @@ describe('gstack-ship-receipt write', () => {
|
||||
'--session-kind', 'unattended', '--artifacts-consumed', '2/3', '--run', 'run-1', '--thread', 'jam_1', '--queue-mode', 'stamp-at-merge',
|
||||
'--preregistration', 'none (no preregistration_shas)', '--history', 'squash-ok', '--body', body, '--out', path.join(repo.root, 'receipt.md')]);
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
expect(ok.err.trim()).toBe(`SHIP_RECEIPT: pr=42 head=${head.slice(0, 12)} version=1.2.4.0 base=main gated_tree=${repo.git('rev-parse', 'HEAD^{tree}').slice(0, 12)} tree=same-modulo-stamps gate-reuse=eligible (reusable: tests; rerun: -) predecessor=${'b'.repeat(12)} session_kind=unattended artifacts_consumed=2/3`);
|
||||
expect(ok.err.trim()).toBe(`SHIP_RECEIPT: pr=42 head=${head.slice(0, 12)} version=1.2.4.0 base=main gated_tree=${repo.git('rev-parse', 'HEAD^{tree}').slice(0, 12)} tree=same-modulo-stamps gate-reuse=eligible (reusable: tests; rerun: -) predecessor=${'b'.repeat(12)} session_kind=unattended artifacts_consumed=2/3 depth=light`);
|
||||
const parsed = extractReceipt(ok.out);
|
||||
expect(parsed.found).toBe(true);
|
||||
expect(parsed.errors).toEqual([]);
|
||||
expect(Object.keys(parsed.receipt!)).toEqual(['schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd', 'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history']);
|
||||
expect(Object.keys(parsed.receipt!)).toEqual(['schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd', 'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history', 'head_tree', 'product_patch_id', 'gstack_version', 'review_depth']);
|
||||
expect(parsed.receipt).toMatchObject({ schema_version: 1, pr: 42, head, version: '1.2.4.0', base: 'main', tree: 'same-modulo-stamps', spend_usd: 0.42, ci: { checks: { pass: 3 } } });
|
||||
expect(parsed.receipt!.head_tree).toBe(repo.git('rev-parse', 'HEAD^{tree}'));
|
||||
expect(parsed.receipt!.product_patch_id).toMatch(/^([0-9a-f]{40}|none)$/);
|
||||
expect(parsed.receipt!.policy).toMatch(/^origin\/main@[0-9a-f]{12}:\.gstack\/ship-policy\.json$/);
|
||||
const written = fs.readFileSync(body, 'utf8');
|
||||
expect(written.startsWith('```gstack-ship-receipt\n{\n "schema_version": 1,\n "pr": 42,\n "head": "' + head + '"')).toBe(true);
|
||||
|
||||
@@ -84,6 +84,22 @@ describe('write (FRESH bump)', () => {
|
||||
expect(code).toBe(2);
|
||||
});
|
||||
|
||||
test('--manifest-form full keeps a four-part manifest and its lockfile as written', () => {
|
||||
const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-full-'));
|
||||
fs.writeFileSync(path.join(d2, 'VERSION'), '0.60.482.0\n');
|
||||
fs.writeFileSync(path.join(d2, 'package.json'), JSON.stringify({ name: 'g', version: '0.60.482.0' }, null, 2) + '\n');
|
||||
fs.writeFileSync(path.join(d2, 'package-lock.json'), JSON.stringify({ name: 'g', version: '0.60.482.0', lockfileVersion: 3, packages: { '': { version: '0.60.482.0' } } }, null, 2) + '\n');
|
||||
const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '0.60.483.0', '--manifest-form', 'full'], { cwd: d2, timeout: 30_000 }).toString());
|
||||
expect(out.packageJsonVersion).toBe('0.60.483.0');
|
||||
expect(JSON.parse(fs.readFileSync(path.join(d2, 'package.json'), 'utf-8')).version).toBe('0.60.483.0');
|
||||
const lock = JSON.parse(fs.readFileSync(path.join(d2, 'package-lock.json'), 'utf-8'));
|
||||
expect([lock.version, lock.packages[''].version]).toEqual(['0.60.483.0', '0.60.483.0']);
|
||||
const bad = spawnSync('bun', [BIN, 'write', '--version', '0.60.484.0', '--manifest-form', 'wide'], { cwd: d2, timeout: 30_000 });
|
||||
expect(bad.status).toBe(2);
|
||||
expect(fs.readFileSync(path.join(d2, 'VERSION'), 'utf-8').trim()).toBe('0.60.483.0');
|
||||
fs.rmSync(d2, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('VERSION-only repo (no package.json) writes just VERSION', () => {
|
||||
const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-noPkg-'));
|
||||
fs.writeFileSync(path.join(d2, 'VERSION'), '0.1.0.0\n');
|
||||
|
||||
@@ -62,6 +62,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
|
||||
'test/fixtures/context-bill/**': ['test/context-bill.test.ts', 'test/context-budget-ratchet.test.ts'],
|
||||
'test/fixtures/context-budget.json': ['test/helpers/capture-context-budget.ts', 'test/test-value-bar.test.ts'],
|
||||
'test/fixtures/context-restore-fragment-match-captures.json': ['test/context-restore-fragment-verdict.test.ts'],
|
||||
'test/fixtures/cookie-judge-train42-replies.json': ['test/cookie-workflow-judge-input.test.ts'],
|
||||
'test/fixtures/coverage-audit-cd-chain.json': ['test/coverage-audit-evidence.test.ts'],
|
||||
'test/fixtures/coverage-audit-parallel-column.json': ['test/coverage-audit-evidence.test.ts'],
|
||||
'test/fixtures/coverage-audit-separator-collision.json': ['test/coverage-audit-evidence.test.ts'],
|
||||
@@ -85,6 +86,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
|
||||
'test/fixtures/design-handoff-q-calls.json': ['test/design-completion-handoff-scored.test.ts'],
|
||||
'test/fixtures/design-md-legacy.md': ['test/design-md.test.ts'],
|
||||
'test/fixtures/design-ui-focus-capture-39199692993.json': ['test/plan-design-with-ui-fixture.test.ts'],
|
||||
'test/fixtures/design-ui-focus-capture-40664336141.json': ['test/plan-design-with-ui-fixture.test.ts'],
|
||||
'test/fixtures/design-ui-focus-capture.json': ['test/plan-design-with-ui-fixture.test.ts'],
|
||||
'test/fixtures/detector-corpora/auto-decide-preserved/**': ['test/detector-corpus-auto-decide-preserved.test.ts'],
|
||||
'test/fixtures/detector-corpora/plan-ceo-section-loading/**': ['test/detector-corpus-plan-ceo-section-loading.test.ts'],
|
||||
@@ -178,7 +180,9 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
|
||||
'test/fixtures/plan-floor-edit-wrap-37176835584.json': ['test/plan-floor-edit-wrap.test.ts'],
|
||||
'test/fixtures/plan-format-kind-notes.json': ['test/plan-format-kind-note.test.ts'],
|
||||
'test/fixtures/qa-caller-handoff-order-public.json': ['test/qa-exploratory-callers.test.ts'],
|
||||
'test/fixtures/qa-caller-merge-base-diff-40737741454.json': ['test/qa-caller-review-plan.test.ts'],
|
||||
'test/fixtures/qa-caller-read-probe-mtb1007a.json': ['test/qa-exploratory-callers.test.ts'],
|
||||
'test/fixtures/qa-caller-review-plan-40707142223.json': ['test/qa-caller-review-plan.test.ts'],
|
||||
'test/fixtures/qa-caller-section-skip-ship-small-cli.json': ['test/qa-exploratory-callers.test.ts'],
|
||||
'test/fixtures/plans/autoplan-dashboard.md': ['test/autoplan-init.test.ts', 'test/autoplan-preconfigured-onboarding-ar.test.ts', 'test/autoplan-snapshot.test.ts'],
|
||||
'test/fixtures/plans/autoplan-focus-appearance-design.md': [],
|
||||
@@ -198,6 +202,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
|
||||
'test/fixtures/shared-libs-claude-version-probe.json': ['test/shared-libs-fixture.test.ts'],
|
||||
'test/fixtures/shared-libs-index-flags-loop-read-39173752116.json': ['test/shared-libs-checker-interface-evidence.test.ts'],
|
||||
'test/fixtures/shared-libs-no-change-ci-public.json': ['test/shared-libs-fixture.test.ts'],
|
||||
'test/fixtures/shared-libs-path-ignored-errored-read-40617200857.json': ['test/shared-libs-checker-interface-evidence.test.ts'],
|
||||
'test/fixtures/shared-libs-plan-callers-fixes-behavior-local.json': ['test/shared-libs-plan-actor.test.ts'],
|
||||
'test/fixtures/shared-libs-plan-callers-future-risk-37195203538.json': ['test/shared-libs-plan-actor.test.ts'],
|
||||
'test/fixtures/shared-libs-plan-callers-no-hardening-36633323521.json': ['test/shared-libs-plan-actor.test.ts'],
|
||||
|
||||
+63
-21
@@ -135,7 +135,21 @@ export async function callJudge<T>(
|
||||
// could be exhausted before a frontier judge emitted any JSON.
|
||||
const resolvedModel = resolveEvalModel('judge', model);
|
||||
const maxTokens = opts?.max_tokens ?? DEFAULT_JUDGE_MAX_TOKENS;
|
||||
const client = new Anthropic();
|
||||
// What a cancelled request had received, so a judge deadline names rate-limit
|
||||
// waits, SDK retries, a stream with no events, or a long thinking phase. Counts
|
||||
// only, never content; an observer that throws never changes the judge outcome.
|
||||
const started = performance.now();
|
||||
const elapsedMs = () => Math.round(performance.now() - started);
|
||||
const progress = { httpRequests: 0, httpStatuses: [] as number[], retryWaitsMs: [] as number[], connectedMs: null as number | null,
|
||||
firstEventMs: null as number | null, lastEventMs: null as number | null, block: null as string | null, thinkingChars: 0,
|
||||
textChars: 0, messageId: null as string | null, requestId: null as string | null };
|
||||
const observe = (record: () => void) => { try { record(); } catch {} };
|
||||
const client = new Anthropic({ fetch: async (...args: Parameters<typeof fetch>) => {
|
||||
progress.httpRequests += 1;
|
||||
const response = await globalThis.fetch(...args);
|
||||
progress.httpStatuses.push(response.status);
|
||||
return response;
|
||||
} });
|
||||
|
||||
const request = {
|
||||
model: resolvedModel,
|
||||
@@ -146,34 +160,62 @@ export async function callJudge<T>(
|
||||
...(opts?.effort === undefined ? {} : { effort: opts.effort }) } }),
|
||||
messages: [{ role: 'user' as const, content: prompt }],
|
||||
};
|
||||
const makeRequest = () => opts?.stream
|
||||
? client.messages.stream(request, signal ? { signal } : undefined).finalMessage()
|
||||
: client.messages.create(request, signal ? { signal } : undefined);
|
||||
const makeRequest = () => {
|
||||
if (!opts?.stream) return client.messages.create(request, signal ? { signal } : undefined);
|
||||
const stream = client.messages.stream(request, signal ? { signal } : undefined);
|
||||
stream.on('connect', () => observe(() => {
|
||||
progress.connectedMs ??= elapsedMs();
|
||||
progress.requestId ??= stream.request_id ?? null;
|
||||
}));
|
||||
stream.on('streamEvent', event => observe(() => {
|
||||
progress.firstEventMs ??= elapsedMs();
|
||||
progress.lastEventMs = elapsedMs();
|
||||
progress.requestId ??= stream.request_id ?? null;
|
||||
if (event.type === 'message_start') progress.messageId = event.message.id;
|
||||
if (event.type === 'content_block_start') progress.block = event.content_block.type;
|
||||
if (event.type === 'content_block_delta' && event.delta.type === 'thinking_delta') progress.thinkingChars += event.delta.thinking.length;
|
||||
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') progress.textChars += event.delta.text.length;
|
||||
}));
|
||||
return stream.finalMessage();
|
||||
};
|
||||
|
||||
// 429s under CI concurrency: jittered exponential backoff over 3 retries
|
||||
// (~1s/4s/16s + jitter), honoring the server's retry-after when present.
|
||||
// The old single fixed 1s retry lost races reliably at 40-way concurrency.
|
||||
let response;
|
||||
let attempt = 0;
|
||||
for (;;) {
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
response = await makeRequest();
|
||||
signal?.throwIfAborted();
|
||||
break;
|
||||
} catch (err: any) {
|
||||
signal?.throwIfAborted();
|
||||
if (err?.status !== 429 || attempt >= 3) throw err;
|
||||
const retryAfterSecs = Number(err?.headers?.['retry-after']);
|
||||
const baseMs = Number.isFinite(retryAfterSecs) && retryAfterSecs > 0
|
||||
? retryAfterSecs * 1000
|
||||
: 1000 * 4 ** attempt;
|
||||
await delay(baseMs + Math.random() * 500, undefined, { signal }).catch(error => {
|
||||
try {
|
||||
for (;;) {
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
throw error;
|
||||
});
|
||||
attempt += 1;
|
||||
response = await makeRequest();
|
||||
signal?.throwIfAborted();
|
||||
break;
|
||||
} catch (err: any) {
|
||||
signal?.throwIfAborted();
|
||||
if (err?.status !== 429 || attempt >= 3) throw err;
|
||||
const retryAfterSecs = Number(err?.headers?.['retry-after']);
|
||||
const baseMs = Number.isFinite(retryAfterSecs) && retryAfterSecs > 0
|
||||
? retryAfterSecs * 1000
|
||||
: 1000 * 4 ** attempt;
|
||||
const waitMs = baseMs + Math.random() * 500;
|
||||
progress.retryWaitsMs.push(Math.round(waitMs));
|
||||
await delay(waitMs, undefined, { signal }).catch(error => {
|
||||
signal?.throwIfAborted();
|
||||
throw error;
|
||||
});
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal?.aborted) {
|
||||
console.error(JSON.stringify({
|
||||
type: 'llm-judge-aborted', model: resolvedModel, stream: opts?.stream === true, elapsedMs: elapsedMs(),
|
||||
attempts: attempt + 1, ...progress,
|
||||
reason: (signal.reason instanceof Error ? signal.reason.message : String(signal.reason)).slice(0, 200),
|
||||
}));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
opts?.onResponse?.({
|
||||
|
||||
@@ -140,6 +140,14 @@ const literalCallerListing = new RegExp(`^ls(?: -[la]+)?(?: --)?(?: ${literalCal
|
||||
const callerDiffMode = '(--stat|--numstat|--name-only|--name-status)';
|
||||
const literalCallerDiff = new RegExp(`^git diff(?: ${callerDiffMode})?(?: (origin/main|"\\$DIFF_BASE"))?(?: ${callerDiffMode})?(?: --(?: ${literalCallerArgument})+)?$`);
|
||||
const callerDiffPreface = 'DIFF_BASE=$(git merge-base origin/main HEAD) && ';
|
||||
const literalMergeBaseDiff = new RegExp(`^(?:DIFF_BASE=([0-9a-f]{7,40}) && )?git diff(?: ${callerDiffMode})? ([0-9a-f]{7,40}|"\\$DIFF_BASE")(?: ${callerDiffMode})?(?: --(?: ${literalCallerArgument})+)?$`);
|
||||
|
||||
function mergeBaseDiffAllowed(text: string, context?: CallerDeadlineContext): boolean {
|
||||
const diff = literalMergeBaseDiff.exec(text);
|
||||
if (!diff || !context || (diff[2] && diff[4]) || !!diff[1] !== (diff[3] === '"$DIFF_BASE"')) return false;
|
||||
const base = spawnSync('git', ['merge-base', 'origin/main', 'HEAD'], { cwd: context.fixtureRoot, encoding: 'utf8', timeout: 5000 });
|
||||
return base.status === 0 && base.stdout.trim().startsWith(diff[1] ?? diff[3]);
|
||||
}
|
||||
|
||||
function literalCallerDiffAllowed(text: string): boolean {
|
||||
const recomputedBase = text.startsWith(callerDiffPreface);
|
||||
@@ -217,9 +225,19 @@ function callerDeadlineCommand(command: string, context?: CallerDeadlineContext)
|
||||
return { action, stateFile };
|
||||
}
|
||||
|
||||
const reviewPlanFlags = '(?: --pass \\d+)?(?: --last (?:none|full|fix-check))?(?: --(?:fixed-blocking|fixed-below|blocking-open|touched-gate|critical-found|adversarial-ran))*(?: --reviewed-numbers [\\w:.-]+)?';
|
||||
const reviewPlanForms = new RegExp(`^(?:--base main${reviewPlanFlags}|--fix-check-prompt --fix-list ${literalCallerArgument} --hunks ${literalCallerArgument} --tests ${literalCallerArgument}(?: --callers ${literalCallerArgument})?)$`);
|
||||
|
||||
/** The review-plan helper forms ship Step 9 names, run by the fixture's exact installed path. */
|
||||
function reviewPlanCommandAllowed(text: string, runtime?: string): boolean {
|
||||
const helper = runtime && `${runtime}/bin/gstack-review-plan `;
|
||||
return !!helper && text.startsWith(helper) && reviewPlanForms.test(text.slice(helper.length));
|
||||
}
|
||||
|
||||
export function qaCallerCommandAllowed(command: string, workflowCommands: string[] = [], deadline?: CallerDeadlineContext): boolean {
|
||||
const text = command.trim();
|
||||
if (qaHelperUsageCommand(text)) return true;
|
||||
if (reviewPlanCommandAllowed(text, deadline?.runtime)) return true;
|
||||
if (callerEvidenceCommand(text, deadline)) return true;
|
||||
if (callerDeadlineCommand(text, deadline)) return true;
|
||||
if (/\bgstack-qa-(?:deadline|evidence)\b/.test(text) && !literalCallerCLI.test(text)
|
||||
@@ -227,6 +245,7 @@ export function qaCallerCommandAllowed(command: string, workflowCommands: string
|
||||
if (workflowCommands.includes(text)) return true;
|
||||
if (literalCallerCLI.test(text)) return true;
|
||||
if (literalCallerDiffAllowed(text)) return true;
|
||||
if (mergeBaseDiffAllowed(text, deadline)) return true;
|
||||
if (literalCallerListingAllowed(text)) return true;
|
||||
let normalizedRecord = text;
|
||||
const substitutedFields: string[] = [];
|
||||
@@ -699,7 +718,7 @@ export function callerReviewRecordTemplate(fixture: Pick<QaCallerFixture, 'calle
|
||||
|
||||
export function qaCallerSessionOptions(fixture: QaCallerFixture, runId: string): Parameters<typeof runSkillTest>[0] {
|
||||
return {
|
||||
prompt: `Load gstack's /${fixture.caller} supplied parent phase from caller-${fixture.caller}.md and resume it on the selected working-tree diff against origin/main. This excerpt comes from ${fixture.runtime}/${fixture.caller}/SKILL.md; resolve installed-relative references there, not from the excerpt file or product directory. That path identifies the asset base, not another entrypoint: do not read or invoke the full parent SKILL.md or rerun its preamble. Earlier preamble/branch/base setup is complete; use the existing local origin/main ref without fetch. Earlier-phase asset locators are ${fixture.runtime}/review/checklist.md and ${fixture.runtime}/qa/templates/functional-report-template.md. Read those files directly when referenced; recursive Glob does not follow the installed asset symlinks. Cross-project learnings are configured off in this owned fixture. ${fixture.reviewStart ? `The actual review-start helper already captured REVIEW_START=${fixture.reviewStart} for this unchanged core pass; retain that token. ` : ''} This fixture evaluates only the supplied parent phase, not later publication stages. The fixture owner recorded these read-only observations when this phase began; they are current until an input changes, so use them instead of re-running those commands, and recheck freshness before completion outputs:\n${fixture.startObservations}\n Read README.md for the project contract and commands. Use diagnostic-client commands such as \`bun scripts/probe.ts <literal>\` for exploratory discoveries and their checkpoint evidence. A required \`bun run test\` is separate suite verification: report it as verification, never as a diagnostic observation or checkpoint anchor/target. Use the production evidence helper to publish each diagnostic checkpoint as \`reports/exploration-NNN.json\`, not inside a nested directory; do not transcribe its observed payload. ${fixture.caseId === 'ship-exploratory-plan-checks' ? 'The previously discovered plan is PLAN.md.' : 'No plan file was found.'} There is no remote service and no release publication is authorized. There is no interactive approver; do not invent answers or permission. Keep normal parent decision gates. Handoff freshness check: Read HANDOFF.md and reports/HANDOFF.md if present for any concurrent collaborator update, await those results, and compare evidence with current inputs; rerun any probe whose input changed. Run the check before each of these, and dispatch each only in a later message than the check's Read results: the evidence helper's materialize, which runs once per report root and freezes the verdict; every gstack-review-log completed:true record, adversarial-review included, since a record is a completion, not preliminary bookkeeping; and the completion report. A later handoff read cannot validate an earlier materialize or completion. If a probe's snapshot differs from an earlier probe's, read reports/HANDOFF.md first; it names any collaborator change.\n\nQA fixture bindings: the deadline guard, capture, checkpoint and materialize command forms are in the installed qa/sections/exploratory.md this phase requires you to Read; use those forms verbatim with these bindings substituted literally. DEADLINE_TOOL = ${fixture.runtime}/bin/gstack-qa-deadline. EVIDENCE_TOOL = ${fixture.runtime}/bin/gstack-qa-evidence. PROBE_DIR = ${fixture.cwd}/reports. DEADLINE_FILE = ${fixture.cwd}/reports/deadline.json. SECONDS must be positive and at most 300. EARLIER_UTC is the optional caller absolute deadline: use the section clock's Hard deadline UTC, never its Runner entry UTC, reserve-start time or a clock-read time. The only native COMMAND is the existing diagnostic client, bun scripts/probe.ts, with zero or one literal argument. Resolve these exact helper and state paths; do not use variables, another helper, another state file, nested wrappers, scripts, operators or substitutions. Its diagnostic receipts are declared public/synthetic, so --public is approved. Only an explicit plan check this phase requires may use --timeout-ms 10000 in place of the deadline; every other capture, including a rerun or revalidation of an earlier probe, uses the deadline. This does not reset or bypass the smoke deadline. Only the deadline helper may create or change reports/deadline.json and its .qa-deadline- temporary files; never use Write/Edit/MultiEdit on those paths. Record the full outer command in checkpoints and evidence; keep the unchanged child JSON as observed, separate from prefixed guard diagnostics. A completed expired guard-run is not a probe or a pass: retain its unused checkpoint, report not-run coverage and do not restart the deadline. Keep the 12-probe smoke limit. Required suites and explicit plan checks are outside the bounded smoke budget, not permission to reset it. Quote arguments literally. Sources stay inside reports. Decide to execute the next probe before publishing its checkpoint, then await successful publication and dispatch that exact probe. I
Line truncated
|
||||
prompt: `Load gstack's /${fixture.caller} supplied parent phase from caller-${fixture.caller}.md and resume it on the selected working-tree diff against origin/main. This excerpt comes from ${fixture.runtime}/${fixture.caller}/SKILL.md; resolve installed-relative references there, not from the excerpt file or product directory. That path identifies the asset base, not another entrypoint: do not read or invoke the full parent SKILL.md or rerun its preamble. Earlier preamble/branch/base setup is complete; use the existing local origin/main ref without fetch. Earlier-phase asset locators are ${fixture.runtime}/review/checklist.md and ${fixture.runtime}/qa/templates/functional-report-template.md. Read those files directly when referenced; recursive Glob does not follow the installed asset symlinks. Cross-project learnings are configured off in this owned fixture. ${fixture.reviewStart ? `The actual review-start helper already captured REVIEW_START=${fixture.reviewStart} for this unchanged core pass; retain that token. ` : ''} This fixture evaluates only the supplied parent phase, not later publication stages. The fixture owner recorded these read-only observations when this phase began; they are current until an input changes, so use them instead of re-running those commands, and recheck freshness before completion outputs:\n${fixture.startObservations}\n Read README.md for the project contract and commands. Use diagnostic-client commands such as \`bun scripts/probe.ts <literal>\` for exploratory discoveries and their checkpoint evidence. A required \`bun run test\` is separate suite verification: report it as verification, never as a diagnostic observation or checkpoint anchor/target. Use the production evidence helper to publish each diagnostic checkpoint as \`reports/exploration-NNN.json\`, not inside a nested directory; do not transcribe its observed payload. ${fixture.caseId === 'ship-exploratory-plan-checks' ? 'The previously discovered plan is PLAN.md.' : 'No plan file was found.'} There is no remote service and no release publication is authorized. There is no interactive approver; do not invent answers or permission. Keep normal parent decision gates. Handoff freshness check: Read HANDOFF.md and reports/HANDOFF.md if present for any concurrent collaborator update, await those results, and compare evidence with current inputs; rerun any probe whose input changed. Run the check before each of these, and dispatch each only in a later message than the check's Read results: the evidence helper's materialize, which runs once per report root and freezes the verdict; every gstack-review-log completed:true record, adversarial-review included, since a record is a completion, not preliminary bookkeeping; and the completion report. A later handoff read cannot validate an earlier materialize or completion. If a probe's snapshot differs from an earlier probe's, read reports/HANDOFF.md first; it names any collaborator change.\n\nQA fixture bindings: the deadline guard, capture, checkpoint and materialize command forms are in the installed qa/sections/exploratory.md this phase requires you to Read; use those forms verbatim with these bindings substituted literally. DEADLINE_TOOL = ${fixture.runtime}/bin/gstack-qa-deadline. EVIDENCE_TOOL = ${fixture.runtime}/bin/gstack-qa-evidence. PROBE_DIR = ${fixture.cwd}/reports. DEADLINE_FILE = ${fixture.cwd}/reports/deadline.json. SECONDS must be positive and at most 300. EARLIER_UTC is the optional caller absolute deadline: use the section clock's Hard deadline UTC, never its Runner entry UTC, reserve-start time or a clock-read time. The only native COMMAND is the existing diagnostic client, bun scripts/probe.ts, with zero or one literal argument. Resolve these exact helper and state paths; do not use variables, another helper, another state file, nested wrappers, scripts, operators or substitutions. Its diagnostic receipts are declared public/synthetic, so --public is approved. Only an explicit plan check this phase requires may use --timeout-ms 10000 in place of the deadline; every other capture, including a rerun or revalidation of an earlier probe, uses the deadline. This does not reset or bypass the smoke deadline. Only the deadline helper may create or change reports/deadline.json and its .qa-deadline- temporary files; never use Write/Edit/MultiEdit on those paths. Record the full outer command in checkpoints and evidence; keep the unchanged child JSON as observed, separate from prefixed guard diagnostics. A completed expired guard-run is not a probe or a pass: retain its unused checkpoint, report not-run coverage and do not restart the deadline. Keep the 12-probe smoke limit. Required suites and explicit plan checks are outside the bounded smoke budget, not permission to reset it. Quote arguments literally. Sources stay inside reports. Decide to execute the next probe before publishing its checkpoint, then await successful publication and dispatch that exact probe. I
Line truncated
|
||||
appendSystemPrompt: `Caller execution scheduling (fixture contract):
|
||||
This session has at most 25 assistant turns, including required verification and final artifacts. The command boundary applies to each Bash call, not to the number of independent tool calls in an assistant turn.
|
||||
After required clock and approval prerequisites settle, issue independent source Reads and read-only discovery together as separate native tool calls once their paths and inputs are known. Read-only discovery is Read, Glob, Grep and the declared inventory, Git and review-read forms; a capture, a deadline guard start or run, or a diagnostic-client run is a probe, never discovery. For example, after the first clock read, one response can Read the caller excerpt, README.md, every product file and the referenced checklist and templates. Wait for their results before decisions that depend on them.
|
||||
|
||||
@@ -438,9 +438,9 @@ function inspectsFile(source: string, file: string, text: string, expected: Star
|
||||
}
|
||||
|
||||
/** Inspect native public tool blocks only; narration and instruction contents are not evidence. */
|
||||
function nativeToolPairs(events: unknown[]) {
|
||||
function nativeToolPairs(events: unknown[], includeErrors = false) {
|
||||
const pending = new Map<string, { tool: string; input: any; at: number }>();
|
||||
const pairs: { tool: string; input: any; at: number; returnedAt: number; text: string }[] = [];
|
||||
const pairs: { tool: string; input: any; at: number; returnedAt: number; text: string; error: boolean }[] = [];
|
||||
let position = 0;
|
||||
for (const event of events as any[]) {
|
||||
for (const block of Array.isArray(event?.message?.content) ? event.message.content : []) {
|
||||
@@ -450,11 +450,11 @@ function nativeToolPairs(events: unknown[]) {
|
||||
} else if (event.type === 'user' && block.type === 'tool_result') {
|
||||
const call = pending.get(block.tool_use_id);
|
||||
pending.delete(block.tool_use_id);
|
||||
if (!call || block.is_error === true) continue;
|
||||
if (!call || block.is_error === true && !includeErrors) continue;
|
||||
const text = typeof block.content === 'string' ? block.content
|
||||
: Array.isArray(block.content) ? block.content.filter((part: any) => part.type === 'text'
|
||||
&& typeof part.text === 'string').map((part: any) => part.text).join('\n') : '';
|
||||
pairs.push({ ...call, returnedAt: position, text });
|
||||
pairs.push({ ...call, returnedAt: position, text, error: block.is_error === true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -467,6 +467,8 @@ interface CheckerContext extends StartContext {
|
||||
finding: any;
|
||||
reusable: boolean;
|
||||
coveredPaths: string[];
|
||||
/** Exact evidence sources: an errored Bash read counts only when its output holds the whole source. */
|
||||
sources?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function hasTrustedSharedLibsCheck(events: unknown[], expected: CheckerContext): boolean {
|
||||
@@ -497,8 +499,11 @@ export function hasTrustedSharedLibsCheck(events: unknown[], expected: CheckerCo
|
||||
}).map(call => ({ ...call, last: call === calls.at(-1),
|
||||
words: call.words.map(word => expandVariables(word, call.variables) ?? word) }));
|
||||
};
|
||||
const pairs = nativeToolPairs(events);
|
||||
const bash = pairs.filter(pair => pair.tool === 'Bash' && typeof pair.input?.command === 'string');
|
||||
// Run 40617200857: a later `echo ===` failed in the same call, so the result that
|
||||
// returned the whole first file was marked is_error. Such a read counts only in a
|
||||
// multi-command call whose output holds the exact source. Start, check and finish stay strict.
|
||||
const pairs = nativeToolPairs(events, true);
|
||||
const bash = pairs.filter(pair => !pair.error && pair.tool === 'Bash' && typeof pair.input?.command === 'string');
|
||||
for (const start of bash) {
|
||||
const direct = invocations(start.input.command).some(call => call.last && call.words.length === 3
|
||||
&& call.words[1] === '--start' && call.words[2] === 'review');
|
||||
@@ -546,10 +551,13 @@ export function hasTrustedSharedLibsCheck(events: unknown[], expected: CheckerCo
|
||||
const reads = pairs.filter(pair => pair.at > start.returnedAt && pair.returnedAt < check.at && pair.text.trim());
|
||||
if (!expected.finding.evidence_paths.every((relative: string) => {
|
||||
const file = paths.join(expected.repo, relative);
|
||||
return reads.some(read => read.tool === 'Read' && typeof read.input?.file_path === 'string'
|
||||
const source = expected.sources?.[relative]?.replace(/\n$/, '');
|
||||
return reads.some(read => read.tool === 'Read' && !read.error && typeof read.input?.file_path === 'string'
|
||||
&& literalPath(read.input.file_path, expected.repo, paths) === file
|
||||
|| read.tool === 'Bash' && typeof read.input?.command === 'string'
|
||||
&& inspectsFile(read.input.command, file, read.text, expected));
|
||||
&& inspectsFile(read.input.command, file, read.text, expected)
|
||||
&& (!read.error || !!source && commands(read.input.command).length > 1
|
||||
&& read.text.replace(/^ *\d+\t/gm, '').includes(source)));
|
||||
})) continue;
|
||||
if (bash.some(finish => finish.at > check.returnedAt && invocations(finish.input.command).some(call =>
|
||||
call.words.length === 4 && call.words[2] === '--finish' && call.words[3] === token))) return true;
|
||||
|
||||
@@ -98,10 +98,18 @@ export function ghQueueShim(repo: FixtureRepo, data: { prs: Record<string, unkno
|
||||
return { PATH: `${dir}:${process.env.PATH ?? ''}` };
|
||||
}
|
||||
|
||||
/** A coordinator's separate clone of the fixture origin. */
|
||||
/**
|
||||
* A coordinator's separate clone of the fixture origin, fetched through git's
|
||||
* transport (--no-local) like a real clone of GitHub. A local clone copies
|
||||
* origin.git/objects entry by entry, and every push into the fixture origin
|
||||
* leaves a detached `git maintenance run --auto` there that creates and
|
||||
* deletes objects/maintenance.lock after the push returns: local transport
|
||||
* drops the shard's GIT_CONFIG_COUNT maintenance-off backstop, so it never
|
||||
* reaches that receive-pack.
|
||||
*/
|
||||
export function coordinatorClone(repo: FixtureRepo): string {
|
||||
const dir = path.join(repo.root, 'coordinator');
|
||||
const r = spawnSync('git', ['clone', '-q', repo.origin, dir], { timeout: 60_000, env: { ...process.env, ...repo.env } });
|
||||
const r = spawnSync('git', ['clone', '-q', '--no-local', repo.origin, dir], { timeout: 60_000, env: { ...process.env, ...repo.env } });
|
||||
if (r.status !== 0) throw new Error(`clone failed: ${r.stderr?.toString()}`);
|
||||
return dir;
|
||||
}
|
||||
|
||||
@@ -321,17 +321,21 @@ describe('landed schemas match the recorded calibration', () => {
|
||||
expect(recorded.complete).toBe(true);
|
||||
const cookie = result('cookie-workflow');
|
||||
expect(cookie.complete).toBe(true);
|
||||
expect(cookie.landing.lands).toBe(false);
|
||||
const release = JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures', 'judge-calibration', 'cookie-workflow', 'release.json'), 'utf8'));
|
||||
expect(release).toMatchObject({ config: 'cookie-workflow', released: true, by: 'GStack Minder', scope: 'cookie workflow judge transport only',
|
||||
ruling: 'GStack Minder, 2026-10-29: release the GSTA-91 hold; a07 is matched by old-b; free-text parse errors cost train 42' });
|
||||
const registrations = [...evalSource.matchAll(/await runWorkflowJudge\(\{([\s\S]*?)\n \}\);/g)].map(match => match[1]!);
|
||||
expect(registrations.length).toBeGreaterThan(14);
|
||||
for (const body of registrations) {
|
||||
const testName = /testName: '([^']+)'/.exec(body)![1];
|
||||
const defaultConfig = !/^\s+(?:agentCapability|model|readInput|thresholds|maxTokens|stream|effort):|\.\.\.COOKIE_WORKFLOW_JUDGE/m.test(body);
|
||||
const expected = testName === 'ship/SKILL.md workflow' || (defaultConfig && recorded.landing.lands)
|
||||
|| (testName === 'setup-browser-cookies/SKILL.md workflow' && cookie.landing.lands);
|
||||
|| testName === 'setup-browser-cookies/SKILL.md workflow';
|
||||
expect(body.includes('schemaTransport: true'), testName).toBe(expected);
|
||||
expect(body.includes('compactReasoning: true'), testName).toBe(testName === 'ship/SKILL.md workflow');
|
||||
}
|
||||
expect(registrations.filter(body => body.includes('schemaTransport: true'))).toHaveLength(cookie.landing.lands ? 16 : 15);
|
||||
expect(registrations.filter(body => body.includes('schemaTransport: true'))).toHaveLength(16);
|
||||
const cookieBody = registrations.find(body => body.includes("testName: 'setup-browser-cookies/SKILL.md workflow'"))!;
|
||||
expect(cookieBody).toContain('...COOKIE_WORKFLOW_JUDGE');
|
||||
expect(cookieBody).not.toMatch(/^\s+(?:agentCapability|model|thresholds|maxTokens|stream|effort):/m);
|
||||
|
||||
@@ -85,6 +85,25 @@ describe('callJudge cancellation', () => {
|
||||
expect(create).toHaveBeenCalledTimes(1);
|
||||
}, 3000);
|
||||
|
||||
test('an abort during a 429 wait retains the wait it interrupted', async () => {
|
||||
const diagnostics = spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
create.mockRejectedValue({ status: 429, headers: { 'retry-after': '30' } });
|
||||
const pending = callJudge('Rate-limited request.', 'test-judge-model', { signal: controller.signal });
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
controller.abort(new Error('Expired during rate-limit backoff.'));
|
||||
await expect(pending).rejects.toThrow('Expired during rate-limit backoff.');
|
||||
expect(diagnostics).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(diagnostics.mock.calls[0]![0])).toMatchObject({ type: 'llm-judge-aborted', model: 'test-judge-model',
|
||||
stream: false, attempts: 1, httpRequests: 0, retryWaitsMs: [30000], firstEventMs: null, reason: 'Expired during rate-limit backoff.' });
|
||||
create.mockResolvedValue(response as any);
|
||||
diagnostics.mockClear();
|
||||
expect(await callJudge('Judge this.', undefined, { signal: new AbortController().signal })).toEqual({ passed: true });
|
||||
expect(diagnostics).not.toHaveBeenCalled();
|
||||
} finally { diagnostics.mockRestore(); }
|
||||
}, 3000);
|
||||
|
||||
test('keeps three 429 retries for callers without a signal', async () => {
|
||||
const rateLimit = { status: 429, headers: { 'retry-after': '0.001' } };
|
||||
create.mockRejectedValue(rateLimit);
|
||||
|
||||
@@ -103,3 +103,123 @@ test('streaming retains the caller abort signal instead of extending its deadlin
|
||||
expect(await rejected).toBe(failure);
|
||||
expect(transport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('a judge aborted mid-stream retains what it had received, as counts without content', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const partial = [
|
||||
{ type: 'message_start', message: { id: 'msg_stalled', type: 'message', role: 'assistant', model: 'claude-fable-5-1',
|
||||
content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 99023, output_tokens: 1 } } },
|
||||
{ type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '', signature: '' } },
|
||||
{ type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'PRIVATE_THINKING' } },
|
||||
];
|
||||
let delivered!: () => void;
|
||||
const received = new Promise<void>(resolve => { delivered = resolve; });
|
||||
transport.mockImplementation(async (_url: string, init: RequestInit) => {
|
||||
const chunks = partial.map(event => encoder.encode(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`));
|
||||
return new Response(new ReadableStream({
|
||||
start(stream) {
|
||||
init.signal!.addEventListener('abort', () => stream.error(init.signal!.reason), { once: true });
|
||||
},
|
||||
pull(stream) {
|
||||
const chunk = chunks.shift();
|
||||
if (chunk) stream.enqueue(chunk);
|
||||
else delivered();
|
||||
},
|
||||
}, { highWaterMark: 0 }), { headers: { 'content-type': 'text/event-stream', 'request-id': 'req_stalled' } });
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const rejected = callJudge('score the whole bundle', 'claude-fable-5-1', {
|
||||
max_tokens: 65_536, stream: true, jsonSchema: WORKFLOW_JUDGE_RESPONSE_SCHEMA, signal: controller.signal,
|
||||
}).catch(error => error);
|
||||
await received;
|
||||
const failure = new Error('ship/SKILL.md workflow exceeded its workflow judge work deadline');
|
||||
controller.abort(failure);
|
||||
expect(await rejected).toBe(failure);
|
||||
expect(diagnostics).toHaveBeenCalledTimes(1);
|
||||
const diagnostic = diagnostics.mock.calls[0]![0];
|
||||
expect(diagnostic).not.toContain('PRIVATE_');
|
||||
const event = JSON.parse(diagnostic);
|
||||
expect(event).toMatchObject({ type: 'llm-judge-aborted', model: 'claude-fable-5-1', stream: true, attempts: 1,
|
||||
httpRequests: 1, httpStatuses: [200], retryWaitsMs: [], block: 'thinking', thinkingChars: 'PRIVATE_THINKING'.length, textChars: 0,
|
||||
messageId: 'msg_stalled', requestId: 'req_stalled', reason: failure.message });
|
||||
expect(event.firstEventMs).toBeGreaterThanOrEqual(0);
|
||||
expect(event.lastEventMs).toBeGreaterThanOrEqual(event.firstEventMs); // gstack-timing-ok: ordering of two recorded offsets, not an elapsed-time bound
|
||||
expect(event.elapsedMs).toBeGreaterThanOrEqual(event.lastEventMs); // gstack-timing-ok: ordering of two recorded offsets, not an elapsed-time bound
|
||||
});
|
||||
|
||||
test('a stream that never sent an event is retained as such, and provider errors without an abort are not', async () => {
|
||||
let sent!: () => void;
|
||||
const requested = new Promise<void>(resolve => { sent = resolve; });
|
||||
transport.mockImplementation((_url: string, init: RequestInit) => new Promise((_resolve, reject) => {
|
||||
init.signal!.addEventListener('abort', () => reject(init.signal!.reason), { once: true });
|
||||
sent();
|
||||
}));
|
||||
const controller = new AbortController();
|
||||
const rejected = callJudge('score', 'claude-fable-5-1', { max_tokens: 65_536, stream: true, signal: controller.signal }).catch(error => error);
|
||||
await requested;
|
||||
controller.abort(new Error('deadline'));
|
||||
await rejected;
|
||||
expect(JSON.parse(diagnostics.mock.calls[0]![0])).toMatchObject({ type: 'llm-judge-aborted', httpRequests: 1,
|
||||
httpStatuses: [], connectedMs: null, firstEventMs: null, lastEventMs: null, block: null, thinkingChars: 0, messageId: null, requestId: null, reason: 'deadline' });
|
||||
|
||||
diagnostics.mockClear();
|
||||
transport.mockResolvedValue(new Response(JSON.stringify({ type: 'error', error: { type: 'api_error', message: 'boom' } }),
|
||||
{ status: 500, headers: { 'content-type': 'application/json', 'x-should-retry': 'false' } }));
|
||||
await expect(callJudge('score', 'claude-fable-5-1', { max_tokens: 65_536, stream: true, signal: new AbortController().signal })).rejects.toThrow();
|
||||
expect(diagnostics).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a deadline after headers but before any event retains the connection and its request id', async () => {
|
||||
let reading!: () => void;
|
||||
const connected = new Promise<void>(resolve => { reading = resolve; });
|
||||
transport.mockImplementation(async (_url: string, init: RequestInit) => new Response(new ReadableStream({
|
||||
start(stream) { init.signal!.addEventListener('abort', () => stream.error(init.signal!.reason), { once: true }); },
|
||||
pull() { reading(); return new Promise<void>(() => {}); },
|
||||
}, { highWaterMark: 0 }), { headers: { 'content-type': 'text/event-stream', 'request-id': 'req_quiet' } }));
|
||||
const controller = new AbortController();
|
||||
const rejected = callJudge('score', 'claude-fable-5-1', { max_tokens: 65_536, stream: true, signal: controller.signal }).catch(error => error);
|
||||
await connected;
|
||||
controller.abort(new Error('deadline'));
|
||||
await rejected;
|
||||
const event = JSON.parse(diagnostics.mock.calls[0]![0]);
|
||||
expect(event).toMatchObject({ httpRequests: 1, httpStatuses: [200], requestId: 'req_quiet', firstEventMs: null, block: null });
|
||||
expect(event.connectedMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('SDK-internal 429 retries are counted, so throttling never reads as a silent stream', async () => {
|
||||
let resent!: () => void;
|
||||
const retried = new Promise<void>(resolve => { resent = resolve; });
|
||||
transport.mockResolvedValueOnce(new Response(JSON.stringify({ type: 'error', error: { type: 'rate_limit_error', message: 'slow down' } }),
|
||||
{ status: 429, headers: { 'content-type': 'application/json', 'retry-after-ms': '1' } }));
|
||||
transport.mockImplementationOnce((_url: string, init: RequestInit) => new Promise((_resolve, reject) => {
|
||||
init.signal!.addEventListener('abort', () => reject(init.signal!.reason), { once: true });
|
||||
resent();
|
||||
}));
|
||||
const controller = new AbortController();
|
||||
const rejected = callJudge('score', 'claude-fable-5-1', { max_tokens: 65_536, stream: true, signal: controller.signal }).catch(error => error);
|
||||
await retried;
|
||||
controller.abort(new Error('deadline'));
|
||||
await rejected;
|
||||
expect(JSON.parse(diagnostics.mock.calls[0]![0])).toMatchObject({ attempts: 1, httpRequests: 2, httpStatuses: [429],
|
||||
retryWaitsMs: [], connectedMs: null, firstEventMs: null });
|
||||
});
|
||||
|
||||
test('an event the diagnostic cannot read never changes the judge result', async () => {
|
||||
const unreadable = response();
|
||||
const body = (await unreadable.text()).replace('"thinking":"PRIVATE_THINKING"', '"thinking":null');
|
||||
transport.mockResolvedValue(new Response(body, { headers: { 'content-type': 'text/event-stream' } }));
|
||||
expect(await callJudge('score the whole bundle', 'claude-fable-5-1', {
|
||||
max_tokens: 65_536, stream: true, jsonSchema: WORKFLOW_JUDGE_RESPONSE_SCHEMA,
|
||||
})).toEqual(scores);
|
||||
});
|
||||
|
||||
test('an abort reason is retained at most 200 characters long', async () => {
|
||||
transport.mockImplementation((_url: string, init: RequestInit) => new Promise((_resolve, reject) => {
|
||||
init.signal!.addEventListener('abort', () => reject(init.signal!.reason), { once: true });
|
||||
}));
|
||||
const controller = new AbortController();
|
||||
const rejected = callJudge('score', 'claude-fable-5-1', { max_tokens: 65_536, stream: true, signal: controller.signal }).catch(error => error);
|
||||
controller.abort(new Error('x'.repeat(500)));
|
||||
await rejected;
|
||||
expect(JSON.parse(diagnostics.mock.calls[0]![0]).reason).toBe('x'.repeat(200));
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Module loadouts (GSTA-140, PR 1a): modules/loadouts.json, the derived
|
||||
* catalog, --modules selection, the render filter, and gstack-modules render.
|
||||
* Every rung loads `full` today, so the synthetic ad-hoc arms below are the
|
||||
* only exclusion coverage; committed bytes are covered by Skill Docs Freshness.
|
||||
*/
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { bunIsolationArgs } from '../lib/bun-isolation';
|
||||
import { MODEL_CATALOG } from '../lib/model-catalog';
|
||||
import { runGeneration } from '../scripts/gen-skill-docs';
|
||||
import { ModuleError, excludedSkillDirs, loadLoadouts, moduleCatalog, normalizeModelId, rungFor, selectModules, validateLoadouts, type ModuleErrorCode } from '../scripts/modules';
|
||||
import { LOCK_FILE, renderLoadout, type LoadoutLock } from '../scripts/modules-render';
|
||||
import { applySectionGuards } from '../scripts/resolvers/sections';
|
||||
import { PREAMBLE_MODULE_IDS, preambleSections } from '../scripts/resolvers/preamble';
|
||||
import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const manifest = loadLoadouts();
|
||||
const catalog = moduleCatalog(manifest);
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'loadouts-'));
|
||||
afterAll(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
/** Model ids gstack runs on or GSTA-2 measures (Capy's model list, 2026-10-28). */
|
||||
const CORPUS = [
|
||||
...MODEL_CATALOG.map(e => e.model), 'gpt-6-astra',
|
||||
'anthropic/claude-opus-5-5', 'anthropic/claude-haiku-5-5', 'anthropic/claude-sonnet-5-5', 'anthropic/claude-fable-5-1',
|
||||
'openai/gpt-6.1-sol', 'openai/gpt-6-luna', 'openai/gpt-6.1-terra', 'deepseek/deepseek-v4.1-flash-1017', 'deepseek/deepseek-v4-pro',
|
||||
'zai/glm-5.3', 'zai/glm-5.3-flash', 'google/gemini-4-argon', 'google/gemini-3.8-flash', 'xai/grok-4.7',
|
||||
'moonshotai/kimi-k3.1-pro', 'qwen/qwen3.8-max', 'qwen/qwen3.8-flash', 'minimax/minimax-m3',
|
||||
];
|
||||
|
||||
function codeOf(fn: () => unknown): ModuleErrorCode | 'none' {
|
||||
try { fn(); return 'none'; } catch (error) { if (error instanceof ModuleError) return error.code; throw error; }
|
||||
}
|
||||
|
||||
describe('modules/loadouts.json', () => {
|
||||
test('validates against the derived catalog, with disjoint rungs over the model corpus', () => {
|
||||
expect(validateLoadouts(manifest, catalog, CORPUS)).toEqual([]);
|
||||
});
|
||||
|
||||
test('every rung and unknown loads full until GSTA-2 measures a smaller loadout', () => {
|
||||
expect(Object.values(manifest.default)).toEqual(['full', 'full', 'full', 'full', 'full']);
|
||||
});
|
||||
|
||||
test('the catalog derives every module kind from its source files', () => {
|
||||
const ids = new Set(catalog.map(e => e.id));
|
||||
expect(ids.has('skill:gstack')).toBe(true);
|
||||
expect(ids.has('section:office-hours/second-opinion')).toBe(true);
|
||||
expect(ids.has('overlay:opus-4-8')).toBe(true);
|
||||
for (const id of PREAMBLE_MODULE_IDS) expect(ids.has(id)).toBe(true);
|
||||
for (const entry of catalog) expect(fs.existsSync(path.join(ROOT, entry.source))).toBe(true);
|
||||
expect(catalog.filter(e => e.pinned).map(e => e.id)).toEqual(expect.arrayContaining(['preamble:bash', 'skill:gstack', 'skill:gstack-upgrade', ...manifest.pinned]));
|
||||
});
|
||||
|
||||
test('model ids normalize and map to rungs', () => {
|
||||
expect(normalizeModelId(' Anthropic/Claude-Haiku-5-5[1m] ')).toBe('claude-haiku-5-5');
|
||||
expect(rungFor(manifest, 'anthropic/claude-opus-5-5')).toBe('strong');
|
||||
expect(rungFor(manifest, 'anthropic/claude-haiku-5-5')).toBe('mid');
|
||||
expect(rungFor(manifest, 'deepseek/deepseek-v4.1-flash-1017')).toBe('light');
|
||||
expect(rungFor(manifest, 'zai/glm-5.3-flash')).toBe('light');
|
||||
expect(rungFor(manifest, 'zai/glm-5.3')).toBe('mid');
|
||||
expect(rungFor(manifest, 'haiku')).toBe('mid');
|
||||
expect(rungFor(manifest, 'some-new-model')).toBe('unknown');
|
||||
expect(rungFor(manifest, 'constructor')).toBe('unknown');
|
||||
for (const model of MODEL_CATALOG) expect(rungFor(manifest, model.model)).not.toBe('unknown');
|
||||
});
|
||||
|
||||
test('a broken manifest names each problem', () => {
|
||||
const bad = structuredClone(manifest);
|
||||
bad.loadouts.lean = { base: 'missing', exclude: ['preamble:bash', 'section:nope/none'] };
|
||||
bad.default.mid = 'ghost';
|
||||
bad.default.light = 'toString';
|
||||
bad.loadouts.noship = { base: 'constructor', exclude: ['skill:ship'] };
|
||||
bad.rungs.light.push('claude-opus-5*');
|
||||
const problems = validateLoadouts(bad, catalog, CORPUS).join('\n');
|
||||
expect(problems).toContain('loadouts.lean.base names no loadout');
|
||||
expect(problems).toContain('excludes pinned preamble:bash');
|
||||
expect(problems).toContain('"section:nope/none", which is not a module');
|
||||
expect(problems).toContain('default.mid names no loadout');
|
||||
expect(problems).toContain('default.light names no loadout');
|
||||
expect(problems).toContain('loadouts.noship.base names no loadout');
|
||||
expect(problems).toContain('excludes skill:ship, which holds pinned section:ship/');
|
||||
expect(problems).toContain('matches more than one rung');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectModules', () => {
|
||||
test('full and an empty spec render today\'s bytes (no filter)', () => {
|
||||
expect(selectModules({ loadout: 'full' })).toBeNull();
|
||||
expect(selectModules({})).toBeNull();
|
||||
expect(selectModules({ modules: 'full,-section:office-hours/second-opinion,+section:office-hours/second-opinion' })).toBeNull();
|
||||
});
|
||||
|
||||
test('an ad-hoc spec records its name, normalized spec and hash', () => {
|
||||
const s = selectModules({ modules: ' full , -skill:design-shotgun ' })!;
|
||||
expect(s.name).toBe('adhoc');
|
||||
expect(s.spec).toBe('full,-skill:design-shotgun');
|
||||
expect(s.specSha256).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(excludedSkillDirs(s)).toEqual(['design-shotgun']);
|
||||
});
|
||||
|
||||
test('bad input is refused with a code, never a fallback to full', () => {
|
||||
expect(codeOf(() => selectModules({ loadout: 'nope' }))).toBe('LOADOUT_UNKNOWN');
|
||||
expect(codeOf(() => selectModules({ modules: 'full,skill:qa' }))).toBe('MODULE_SPEC_INVALID');
|
||||
expect(codeOf(() => selectModules({ loadout: 'full', modules: 'full' }))).toBe('MODULE_SPEC_INVALID');
|
||||
expect(codeOf(() => selectModules({ loadout: 'full,-skill:qa' }))).toBe('MODULE_SPEC_INVALID');
|
||||
expect(codeOf(() => selectModules({ modules: 'full,-skill:nope' }))).toBe('MODULE_UNKNOWN');
|
||||
expect(codeOf(() => selectModules({ modules: 'full,-preamble:bash' }))).toBe('MODULE_PINNED');
|
||||
expect(codeOf(() => selectModules({ modules: 'full,-section:ship/adversarial' }))).toBe('MODULE_PINNED');
|
||||
expect(codeOf(() => selectModules({ modules: 'full,-skill:ship' }))).toBe('MODULE_PINNED');
|
||||
expect(codeOf(() => selectModules({ modules: 'full,-skill:autoplan' }))).toBe('MODULE_PINNED');
|
||||
expect(codeOf(() => selectModules({ loadout: 'constructor' }))).toBe('LOADOUT_UNKNOWN');
|
||||
});
|
||||
|
||||
test('every error code has a troubleshooting section', () => {
|
||||
const doc = fs.readFileSync(path.join(ROOT, 'docs/troubleshooting.md'), 'utf-8');
|
||||
const codes: ModuleErrorCode[] = ['LOADOUTS_INVALID', 'LOADOUT_UNKNOWN', 'MODULE_SPEC_INVALID', 'MODULE_UNKNOWN', 'MODULE_PINNED', 'MODULE_EXCLUSION_UNSAFE', 'RENDER_FAILED', 'RENDER_DEST_NOT_OWNED'];
|
||||
for (const code of codes) {
|
||||
const anchor = code.toLowerCase().replaceAll('_', '-');
|
||||
const at = doc.indexOf(`<a id="${anchor}"></a>`);
|
||||
expect(at).toBeGreaterThan(-1);
|
||||
expect(doc.slice(at).split('\n')[1]).toMatch(new RegExp(`^### \`${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('render filter units', () => {
|
||||
const ctx = (extra: Partial<TemplateContext> = {}): TemplateContext => ({
|
||||
skillName: 'office-hours', tmplPath: 'office-hours/SKILL.md.tmpl', host: 'claude', paths: HOST_PATHS.claude, preambleTier: 3, model: 'claude', ...extra,
|
||||
});
|
||||
const filter = (excluded: string[], preambleTierMax?: number) => ({
|
||||
name: 'test', excluded: new Set(excluded), pinned: new Set(catalog.filter(e => e.pinned).map(e => e.id)), preambleTierMax,
|
||||
});
|
||||
|
||||
test('section guards vanish under full and drop their text when the section is excluded', () => {
|
||||
const text = 'a\n{{IF_SECTION:second-opinion}}offer\n{{/IF_SECTION}}b';
|
||||
expect(applySectionGuards(text, ctx())).toBe('a\noffer\nb');
|
||||
expect(applySectionGuards(text, ctx({ modules: filter(['section:office-hours/second-opinion']) }))).toBe('a\nb');
|
||||
expect(() => applySectionGuards('{{IF_SECTION:nope}}x{{/IF_SECTION}}', ctx())).toThrow('no section "nope"');
|
||||
});
|
||||
|
||||
test('a tier cap drops unpinned pieces; pinned pieces follow full', () => {
|
||||
const full = preambleSections(ctx()).join('\n');
|
||||
const capped = preambleSections(ctx({ modules: filter([], 1) })).join('\n');
|
||||
expect(full).toContain('## Writing Style');
|
||||
expect(capped).not.toContain('## Writing Style');
|
||||
expect(capped).toContain('## AskUserQuestion Format');
|
||||
const tier1 = ctx({ preambleTier: 1 });
|
||||
expect(preambleSections({ ...tier1, modules: filter([], 1) })).toEqual(preambleSections(tier1));
|
||||
});
|
||||
|
||||
test('excluding a base overlay drops its text from the overlay that inherits it', () => {
|
||||
const astra = ctx({ model: 'gpt-6-astra' as TemplateContext['model'] });
|
||||
const withBase = preambleSections(astra).join('\n');
|
||||
const withoutBase = preambleSections({ ...astra, modules: filter(['overlay:gpt']) }).join('\n');
|
||||
expect(withBase.length).toBeGreaterThan(withoutBase.length);
|
||||
expect(preambleSections({ ...astra, modules: filter(['overlay:gpt-6-astra']) }).join('\n')).not.toContain('Model-Specific Behavioral Patch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-modules render', () => {
|
||||
const spec = 'full,-section:office-hours/second-opinion,-section:office-hours/visual-design,-preamble:writing-style,-overlay:claude,-skill:design-shotgun';
|
||||
const readLock = (dir: string) => JSON.parse(fs.readFileSync(path.join(dir, LOCK_FILE), 'utf-8')) as LoadoutLock;
|
||||
const ensureArm = async (dir: string) => { if (!fs.existsSync(path.join(dir, LOCK_FILE))) await renderLoadout({ host: 'claude', selection: selectModules({ modules: spec }), outDir: dir }); };
|
||||
|
||||
test('claude: one arm excludes one module of each kind and locks every file', async () => {
|
||||
const out = path.join(tmp, 'claude-arm');
|
||||
const lock = await renderLoadout({ host: 'claude', selection: selectModules({ modules: spec }), outDir: out });
|
||||
const skill = fs.readFileSync(path.join(out, 'office-hours/SKILL.md'), 'utf-8');
|
||||
expect(skill).toContain('<!-- loadout: adhoc -->');
|
||||
expect(skill).not.toContain('Want a second opinion');
|
||||
expect(skill).not.toContain('## Phase 4.25: Visual design');
|
||||
expect(skill).toContain('> Not loaded in the adhoc loadout: skip running the Phase 3.5 second opinion');
|
||||
expect(skill).not.toContain('sections/second-opinion.md');
|
||||
expect(fs.existsSync(path.join(out, 'office-hours/sections/second-opinion.md'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(out, 'office-hours/sections/design-and-handoff.md'))).toBe(true);
|
||||
expect(skill).toContain(`${out}/office-hours/sections/design-and-handoff.md`);
|
||||
const preamble = fs.readFileSync(path.join(out, 'office-hours/sections/preamble.md'), 'utf-8');
|
||||
expect(preamble).not.toContain('## Writing Style');
|
||||
expect(preamble).not.toContain('Model-Specific Behavioral Patch');
|
||||
expect(preamble).toContain('## AskUserQuestion Format');
|
||||
expect(fs.existsSync(path.join(out, 'design-shotgun'))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(out, 'gstack/llms.txt'), 'utf-8')).not.toContain('design-shotgun/SKILL.md');
|
||||
expect(fs.readFileSync(path.join(out, 'SKILL.md'), 'utf-8')).toContain('Disabled on this install (never invoke or suggest them): `/design-shotgun`');
|
||||
expect(lock).toMatchObject({ loadout: 'adhoc', spec, host: 'claude', model: 'claude' });
|
||||
expect(lock.excluded).toEqual(spec.split(',').slice(1).map(t => t.slice(1)).sort());
|
||||
expect(readLock(out).files.length).toBe(lock.files.length);
|
||||
expect(lock.files.find(f => f.path === 'office-hours/SKILL.md')!.bytes).toBe(Buffer.byteLength(skill));
|
||||
expect(fs.readdirSync(tmp).filter(n => n.includes('.stage-') || n.includes('.old-'))).toEqual([]);
|
||||
});
|
||||
|
||||
test('codex: the same arm inlines nothing it excluded', async () => {
|
||||
const out = path.join(tmp, 'codex-arm');
|
||||
await renderLoadout({ host: 'codex', selection: selectModules({ modules: spec }), outDir: out, model: 'gpt-6-astra' });
|
||||
const skill = fs.readFileSync(path.join(out, '.agents/skills/gstack-office-hours/SKILL.md'), 'utf-8');
|
||||
expect(skill).not.toContain('Want a second opinion');
|
||||
expect(skill).toContain('> Not loaded in the adhoc loadout');
|
||||
expect(fs.existsSync(path.join(out, '.agents/skills/gstack-design-shotgun'))).toBe(false);
|
||||
expect(readLock(out).model).toBe('gpt-6-astra');
|
||||
});
|
||||
|
||||
test('a dangling reference is refused and the earlier render survives', async () => {
|
||||
const out = path.join(tmp, 'claude-arm');
|
||||
await ensureArm(out);
|
||||
const before = readLock(out);
|
||||
const unsafe = renderLoadout({ host: 'claude', selection: selectModules({ modules: 'full,-section:plan-design-review/review-sections' }), outDir: out });
|
||||
await expect(unsafe).rejects.toMatchObject({ code: 'MODULE_EXCLUSION_UNSAFE' });
|
||||
await expect(unsafe).rejects.toThrow('referenced by plan-design-review/sections/checklist.md');
|
||||
await expect(renderLoadout({ host: 'claude', selection: selectModules({ modules: 'full,-skill:qa' }), outDir: out })).rejects.toThrow(/skill:qa: referenced by qa-only\//);
|
||||
expect(readLock(out)).toEqual(before);
|
||||
expect(fs.readdirSync(tmp).filter(n => n.includes('.stage-'))).toEqual([]);
|
||||
});
|
||||
|
||||
test('a later render replaces an earlier one; a foreign directory is never replaced', async () => {
|
||||
const out = path.join(tmp, 'claude-arm');
|
||||
await ensureArm(out);
|
||||
expect(readLock(out).loadout).toBe('adhoc');
|
||||
const lock = await renderLoadout({ host: 'claude', selection: selectModules({ modules: 'full,-skill:design-shotgun' }), outDir: out });
|
||||
expect(readLock(out).spec_sha256).toBe(lock.spec_sha256);
|
||||
expect(fs.existsSync(path.join(out, 'office-hours/sections/second-opinion.md'))).toBe(true);
|
||||
const foreign = path.join(tmp, 'foreign');
|
||||
fs.mkdirSync(foreign);
|
||||
fs.writeFileSync(path.join(foreign, 'notes.txt'), 'mine');
|
||||
await expect(renderLoadout({ host: 'claude', selection: null, outDir: foreign })).rejects.toMatchObject({ code: 'RENDER_DEST_NOT_OWNED' });
|
||||
expect(fs.readFileSync(path.join(foreign, 'notes.txt'), 'utf-8')).toBe('mine');
|
||||
});
|
||||
|
||||
test('the full loadout regenerates the committed claude tree byte for byte', async () => {
|
||||
const result = await runGeneration({ host: 'claude', dryRun: true, modules: selectModules({ loadout: 'full' }) });
|
||||
expect(result.diagnostics.filter(d => d.kind === 'stale' || d.kind === 'error')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('modules/loadouts.schema.json', () => {
|
||||
const schema = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/loadouts.schema.json'), 'utf-8'));
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/loadouts.json'), 'utf-8'));
|
||||
|
||||
test('the manifest carries every required key and no key the schema lacks', () => {
|
||||
for (const key of schema.required) expect(raw).toHaveProperty(key);
|
||||
for (const key of Object.keys(raw)) expect(Object.keys(schema.properties)).toContain(key);
|
||||
expect(raw.version).toBe(schema.properties.version.const);
|
||||
});
|
||||
|
||||
test('rung names and patterns agree between the schema and the manifest', () => {
|
||||
const rungs = schema.properties.rungs.required as string[];
|
||||
expect(Object.keys(raw.rungs).sort()).toEqual([...rungs].sort());
|
||||
expect([...schema.properties.aliases.additionalProperties.enum].sort()).toEqual([...rungs].sort());
|
||||
const pattern = new RegExp(schema.properties.rungs.properties.frontier.items.pattern);
|
||||
for (const rung of rungs) for (const glob of raw.rungs[rung]) expect(glob).toMatch(pattern);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bin/gstack-modules', () => {
|
||||
const run = (...args: string[]) => {
|
||||
const result = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(ROOT, 'bin/gstack-modules'), ...args], { cwd: ROOT, encoding: 'utf-8', timeout: 60_000 });
|
||||
return { code: result.status, stdout: result.stdout, stderr: result.stderr };
|
||||
};
|
||||
|
||||
test('validate and list read the committed manifest', () => {
|
||||
expect(run('validate')).toMatchObject({ code: 0, stdout: expect.stringContaining('LOADOUTS: ok') });
|
||||
const listed = JSON.parse(run('list', '--json').stdout);
|
||||
expect(listed.modules.map((m: { id: string }) => m.id)).toEqual(catalog.map(e => e.id));
|
||||
const pinned = run('list', '--pinned').stdout;
|
||||
expect(pinned).toContain('preamble:bash');
|
||||
expect(pinned).not.toContain('skill:design-shotgun');
|
||||
});
|
||||
|
||||
test('refused inputs exit 2 with their code; unknown commands print usage', () => {
|
||||
const out = path.join(tmp, 'cli-refused');
|
||||
expect(run('render', '--host', 'claude', '--modules', 'full,-skill:nope', '--out-dir', out)).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_UNKNOWN') });
|
||||
expect(run('render', '--host', 'claude', '--modules', 'full,-preamble:bash', '--out-dir', out)).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_PINNED') });
|
||||
expect(run('render', '--host', 'claude', '--loadout', 'full', '--out-dir', out, '--plan-mode', 'maybe')).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_SPEC_INVALID') });
|
||||
expect(run('render', '--host', 'claude', '--loadout', 'full', '--out-dir', out, '--model', 'no-such-model')).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_SPEC_INVALID') });
|
||||
expect(run('render', '--host', 'claude', '--out-dir', out)).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_SPEC_INVALID') });
|
||||
expect(fs.existsSync(out)).toBe(false);
|
||||
expect(run('bogus')).toMatchObject({ code: 2, stderr: expect.stringContaining('Usage:') });
|
||||
expect(run('--help')).toMatchObject({ code: 0, stdout: expect.stringContaining('gstack-modules render') });
|
||||
});
|
||||
|
||||
test('gen:skill-docs refuses an exclusion render into the committed tree', () => {
|
||||
const before = spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf-8', timeout: 30_000 }).stdout;
|
||||
for (const extra of [[], ['--out-dir', '.']]) {
|
||||
const result = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(ROOT, 'scripts/gen-skill-docs.ts'), '--host', 'claude', '--modules', 'full,-skill:design-shotgun', ...extra], { cwd: ROOT, encoding: 'utf-8', timeout: 120_000 });
|
||||
expect(result.status).toBe(2);
|
||||
expect(result.stderr).toContain('MODULE_SPEC_INVALID');
|
||||
}
|
||||
expect(spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf-8', timeout: 30_000 }).stdout).toBe(before);
|
||||
});
|
||||
|
||||
test('render prints one RENDERED line and writes the lock', () => {
|
||||
const out = path.join(tmp, 'cli-render');
|
||||
const result = run('render', '--host', 'claude', '--modules', 'full,-skill:design-shotgun', '--out-dir', out);
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toMatch(/^RENDERED: loadout=adhoc host=claude files=\d+ bytes=\d+ excluded=1 lock=/);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(out, LOCK_FILE), 'utf-8')).excluded).toEqual(['skill:design-shotgun']);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import * as path from 'node:path';
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const SUITE = path.join(ROOT, 'test/skill-e2e-office-hours-brain-writeback.test.ts');
|
||||
|
||||
async function probeWritebackEnvironment(option: 'env' | 'extraEnv') {
|
||||
async function probeWritebackEnvironment(option: 'env' | 'extraEnv', put: 'content' | 'stdin' | 'stdin-short' = 'content') {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'office-hours-env-'));
|
||||
const driverBin = path.join(dir, 'bin');
|
||||
const operatorHome = path.join(dir, 'operator-home');
|
||||
@@ -43,7 +43,9 @@ const version = Bun.spawn(['gbrain', '--version'], { env: process.env, stdin: 'i
|
||||
const [versionOut, versionErr, versionCode] = await Promise.all([new Response(version.stdout).text(), new Response(version.stderr).text(), version.exited]);
|
||||
if (versionCode || versionErr || versionOut.trim() !== 'gbrain test-0.41.0') process.exit(44);
|
||||
const payload = '---\\ntitle: Pixel fund\\ntags: [fixture]\\n---\\n' + 'Design detail. '.repeat(25);
|
||||
const put = Bun.spawn(['gbrain', 'put', 'office-hours/pixel-fund', '--content', payload], { env: process.env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
|
||||
const put = ${JSON.stringify(put)} === 'content'
|
||||
? Bun.spawn(['gbrain', 'put', 'office-hours/pixel-fund', '--content', payload], { env: process.env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
|
||||
: Bun.spawn(['gbrain', 'put', 'office-hours/pixel-fund'], { env: process.env, stdin: new Blob([${JSON.stringify(put)} === 'stdin' ? payload : 'not a page']), stdout: 'pipe', stderr: 'pipe' });
|
||||
const [putOut, putErr, putCode] = await Promise.all([new Response(put.stdout).text(), new Response(put.stderr).text(), put.exited]);
|
||||
if (putCode || putOut || putErr) process.exit(45);
|
||||
fs.writeFileSync(${JSON.stringify(receipt)}, JSON.stringify({ ...facts, fakeVersionMatches: true, fakePutSucceeded: true,
|
||||
@@ -108,6 +110,21 @@ describe('office-hours writeback child environment', () => {
|
||||
expect(facts.fakePutSucceeded).toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
// GSTA-134 b0fba22: failing trials piped the page on stdin, as the SAVE_RESULTS template shows.
|
||||
test('a page piped on stdin reaches the payload the original assertions read', async () => {
|
||||
const { code, output, facts } = await probeWritebackEnvironment('env', 'stdin');
|
||||
expect(code, output).toBe(0);
|
||||
expect(output).toContain('1 pass');
|
||||
expect(facts.fakePutSucceeded).toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
test('a stdin body without frontmatter still fails the original payload assertions', async () => {
|
||||
const { code, output, facts } = await probeWritebackEnvironment('env', 'stdin-short');
|
||||
expect(code).toBe(1);
|
||||
expect(output).toContain('Received: "not a page"');
|
||||
expect(facts.fakePutSucceeded).toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
test('restoring extraEnv fails before any fallback gbrain executes', async () => {
|
||||
const { code, output, facts } = await probeWritebackEnvironment('extraEnv');
|
||||
expect(code).toBe(1);
|
||||
|
||||
@@ -95,6 +95,10 @@ const captured39363705692 = JSON.parse(fs.readFileSync(${JSON.stringify(path.joi
|
||||
// "completeness") and offered "All 7 passes (recommended)" against "States + layout only".
|
||||
const captured39852003387 = JSON.parse(fs.readFileSync(${JSON.stringify(path.join(ROOT, 'test/fixtures/design-ui-focus-capture-39852003387.json'))}, 'utf8'))
|
||||
.calls.map(({ preReview, observedAtMs, call }) => nativePlanCallFingerprint(call, observedAtMs, preReview));
|
||||
// Run 40664336141 slice 6 (00e71383): Opus 5.5 asked "I rated this plan 4/10 on design completeness.
|
||||
// Should I review all 7 areas or focus on a few?" with lettered options ("A) Review all 7 areas").
|
||||
const captured40664336141 = JSON.parse(fs.readFileSync(${JSON.stringify(path.join(ROOT, 'test/fixtures/design-ui-focus-capture-40664336141.json'))}, 'utf8'))
|
||||
.calls.map(({ preReview, observedAtMs, call }) => nativePlanCallFingerprint(call, observedAtMs, preReview));
|
||||
const unnumberedFinding = fp('unnumbered-finding', "Which region is the dashboard's primary anchor, and how is the page composed?", false);
|
||||
unnumberedFinding.nativeCall.questions[0].options = nativeFinding.nativeCall.questions[0].options;
|
||||
const finding = fp('finding', 'Which loading feedback should Save show?', false);
|
||||
@@ -223,6 +227,20 @@ mock.module(${JSON.stringify(path.join(ROOT, 'test/helpers/claude-pty-runner.ts'
|
||||
expect(opts.isReviewAUQ(captured39852003387[0])).toBe(false);
|
||||
expect(captured39852003387.slice(1, 3).some(call => opts.isLastStep0AUQ(call) || opts.isReviewAUQ(call))).toBe(false);
|
||||
expect(opts.isReviewAUQ(captured39852003387[3])).toBe(true);
|
||||
expect(opts.isLastStep0AUQ(captured40664336141[1])).toBe(true);
|
||||
expect(opts.isReviewAUQ(captured40664336141[1])).toBe(false);
|
||||
expect([0, 2].some(index => opts.isLastStep0AUQ(captured40664336141[index]))).toBe(false);
|
||||
expect(opts.isReviewAUQ(captured40664336141[3])).toBe(true);
|
||||
const lettered = ['A) Review all 7 areas (recommended)', 'B) Focus on hierarchy/states/responsive'];
|
||||
for (const [label, question, labels] of [
|
||||
['title-offer-unrated', 'D2: Should I review all 7 areas or focus on a few?\\nELI10: I will review every area unless you narrow it.', lettered],
|
||||
['title-offer-out-of-seven', 'D2: I rated this plan 4/7 on design completeness. Should I review all 7 areas or focus on a few?', lettered],
|
||||
['title-offer-without-focus', 'D2: I rated this plan 4/10 on design completeness. Should I review all 7 areas?', lettered],
|
||||
['title-offer-other-count', 'D2: I rated this plan 4/10 on design completeness. Should I review all 4 areas or focus on a few?', lettered],
|
||||
['offer-below-title', 'D2: I rated this plan 4/10 on design completeness. Want me to focus?\\nNext I will review all 7 areas.', lettered],
|
||||
['issue-labeled-title-offer', 'D5 (Issue 3): Keyboard focus across all 7 panels? I rated this plan 4/10 on design completeness.', ['3A Trap focus in the modal', '3B Leave focus order as is']],
|
||||
['single-option-title-offer', 'D2: I rated this plan 4/10 on design completeness. Should I review all 7 areas or focus on a few?', lettered.slice(0, 1)],
|
||||
]) expect(opts.isLastStep0AUQ(labeled(label, question, labels)), label).toBe(false);
|
||||
const areas = ['All 7 areas (recommended)', 'Only specific areas'];
|
||||
for (const body of ['ELI10: I rated this plan 4/10 for design completeness.', 'ELI10: This plan is a 3/10 on design completeness.',
|
||||
'ELI10: Design completeness: 6.5/10. The gaps are layout and states.', 'ELI10: I rated this plan 4/10 on design.']) {
|
||||
@@ -298,6 +316,8 @@ mock.module(${JSON.stringify(path.join(ROOT, 'test/helpers/claude-pty-runner.ts'
|
||||
: mode === 'captured-39363705692-no-focus' ? captured39363705692.filter((_, index) => index !== 0)
|
||||
: mode === 'captured-39852003387' ? captured39852003387
|
||||
: mode === 'captured-39852003387-no-focus' ? captured39852003387.filter((_, index) => index !== 0)
|
||||
: mode === 'captured-40664336141' ? captured40664336141
|
||||
: mode === 'captured-40664336141-no-focus' ? captured40664336141.filter((_, index) => index !== 1)
|
||||
: mode === 'captured-focus-after-finding' ? [...captured.filter((_, index) => index !== 1), captured[1]]
|
||||
: mode.startsWith('target-menu') ? [target, fp('other', 'Which artifact should I inspect?', false)]
|
||||
: mode === 'early-exit' ? [] : mode === 'focus-only' ? [chosenFocus]
|
||||
@@ -342,12 +362,12 @@ await import(${JSON.stringify(path.join(ROOT, 'test/skill-e2e-plan-design-with-u
|
||||
} finally { fs.rmSync(directory, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
test.each(['source', 'paraphrase', 'native-sequence', 'native-unnumbered', 'native-passes', 'captured', 'captured-rated', 'captured-39298269902', 'captured-39363705692', 'captured-39852003387'])('UI gate seeds the exact target and accepts Design progress with an unselected no-UI alternative (%s)', mode => {
|
||||
test.each(['source', 'paraphrase', 'native-sequence', 'native-unnumbered', 'native-passes', 'captured', 'captured-rated', 'captured-39298269902', 'captured-39363705692', 'captured-39852003387', 'captured-40664336141'])('UI gate seeds the exact target and accepts Design progress with an unselected no-UI alternative (%s)', mode => {
|
||||
const result = exercise(mode);
|
||||
expect(result.code, result.output).toBe(0);
|
||||
}, 20_000);
|
||||
|
||||
test.each(['target-menu', 'target-menu-design-system', 'focus-only', 'captured-no-focus', 'captured-focus-after-finding', 'captured-rated-no-focus', 'captured-39298269902-no-focus', 'captured-39298269902-focus-only', 'captured-39363705692-no-focus', 'captured-39852003387-no-focus', 'native-setup-only', 'native-voices-only', 'early-exit', 'timeout', 'exited', 'unanswered-finding', 'failed-finding', 'no-native-finding'])('UI gate rejects %s and removes its fixture', mode => {
|
||||
test.each(['target-menu', 'target-menu-design-system', 'focus-only', 'captured-no-focus', 'captured-focus-after-finding', 'captured-rated-no-focus', 'captured-39298269902-no-focus', 'captured-39298269902-focus-only', 'captured-39363705692-no-focus', 'captured-39852003387-no-focus', 'captured-40664336141-no-focus', 'native-setup-only', 'native-voices-only', 'early-exit', 'timeout', 'exited', 'unanswered-finding', 'failed-finding', 'no-native-finding'])('UI gate rejects %s and removes its fixture', mode => {
|
||||
const result = exercise(mode);
|
||||
expect(result.code, result.output).toBe(1);
|
||||
expect(result.output).toContain('plan-design-review with UI scope FAILED');
|
||||
|
||||
@@ -8,6 +8,7 @@ import { isPreTurnInfraFailure, recordE2E } from './helpers/e2e-helpers';
|
||||
import { EvalCollector, isFinalizedEvalResultFile, listEvalJsonFiles, type EvalTestEntry } from './helpers/eval-store';
|
||||
import { OFFICE_HOURS_BUN_GRACE_MS, runRecordedOfficeHoursAttempt } from './helpers/office-hours-attempt';
|
||||
import { isPaidTestFile } from './helpers/paid-test-set';
|
||||
import { seedHermeticGstackHome } from './helpers/hermetic-env';
|
||||
import { resolveEvalModel } from '../lib/eval-model';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
@@ -21,6 +22,17 @@ const CASE = '/plan-eng-review writes GSTACK REVIEW REPORT to plan file';
|
||||
|
||||
type Scenario = { kind: string; table?: string; exitReason?: string; open?: boolean };
|
||||
|
||||
// Run the copied section's real Outside Voice preflight against a state root.
|
||||
function outsideMode(section: string, home: string, gstackHome?: string): string {
|
||||
const block = section.slice(section.indexOf('**Preflight — decide whether and how the outside voice runs:**')).match(/```bash\n([\s\S]*?)```/)![1];
|
||||
// Git Bash on Windows needs its own spelling of the checkout path.
|
||||
const bin = Bun.spawnSync(['bash', '-c', 'cd "$1" && pwd', '_', path.join(ROOT, 'bin')], { timeout: 10_000 }).stdout.toString().trim();
|
||||
const child = Bun.spawnSync(['bash', '-c', block.replaceAll('"$HOME/.claude/skills/gstack/bin/gstack-config"', '"$GSTACK_CONFIG_BIN"')], {
|
||||
env: { PATH: process.env.PATH ?? '', HOME: home, GSTACK_CONFIG_BIN: `${bin}/gstack-config`,
|
||||
...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), ...(gstackHome ? { GSTACK_HOME: gstackHome } : {}) }, timeout: 10_000 });
|
||||
return child.stdout.toString().trim();
|
||||
}
|
||||
|
||||
async function exercise(scenarios: Scenario[], opts: { deadline?: boolean; sibling?: boolean } = {}) {
|
||||
const owned = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-report-recording-free-'));
|
||||
const evalDir = path.join(owned, 'evals');
|
||||
@@ -46,7 +58,7 @@ async function exercise(scenarios: Scenario[], opts: { deadline?: boolean; sibli
|
||||
for (const declaration of source.matchAll(/^import[\s\S]*?;\n/gm)) executable = executable.replace(declaration[0], '');
|
||||
const js = new Bun.Transpiler({ loader: 'ts', target: 'bun' }).transformSync(executable);
|
||||
const args: Record<string, any> = {
|
||||
expect, ROOT, runId: 'public-report-free', evalsEnabled: true, CAPTURE_LONG_MS, resolveEvalModel,
|
||||
expect, ROOT, runId: 'public-report-free', evalsEnabled: true, CAPTURE_LONG_MS, resolveEvalModel, seedHermeticGstackHome,
|
||||
OFFICE_HOURS_BUN_GRACE_MS, EvalCollector: BoundCollector, recordE2E,
|
||||
createEvalCollector: (tier: string) => new BoundCollector(tier),
|
||||
finalizeEvalCollector: async (collector: EvalCollector | null) => {
|
||||
@@ -82,7 +94,10 @@ async function exercise(scenarios: Scenario[], opts: { deadline?: boolean; sibli
|
||||
runSkillTest: async (options: any) => {
|
||||
const file = path.join(options.workingDirectory, 'plan.md');
|
||||
const seed = fs.readFileSync(file, 'utf8');
|
||||
const section = fs.readFileSync(path.join(options.workingDirectory, 'plan-eng-review/sections/review-sections.md'), 'utf8');
|
||||
calls.push({ ...options, seed,
|
||||
outside: outsideMode(section, options.workingDirectory, options.env?.GSTACK_HOME),
|
||||
unseeded: outsideMode(section, options.workingDirectory, fs.mkdtempSync(path.join(owned, 'unseeded-state-'))),
|
||||
main: fs.readFileSync(path.join(options.workingDirectory, 'plan-eng-review/SKILL.md'), 'utf8'),
|
||||
section: fs.readFileSync(path.join(options.workingDirectory, 'plan-eng-review/sections/review-sections.md'), 'utf8'),
|
||||
});
|
||||
@@ -219,7 +234,12 @@ test('canonical reports preserve both successful exits, terminal forms, metadata
|
||||
expect(call.main).toBe(fs.readFileSync(path.join(ROOT, 'plan-eng-review/SKILL.md'), 'utf8'));
|
||||
expect(call.section).toBe(fs.readFileSync(path.join(ROOT, 'plan-eng-review/sections/review-sections.md'), 'utf8'));
|
||||
expect(call.section).toContain('| Review | Trigger | Why | Runs | Status | Findings |');
|
||||
// Gate 40660626349: both reds spent 4 of 21 turns on the default-on outside voice.
|
||||
expect(call.outside).toBe('CODEX_MODE: disabled');
|
||||
expect(call.unseeded).not.toContain('disabled');
|
||||
expect(call.unseeded).toMatch(/^CODEX_MODE: \w+$/);
|
||||
}
|
||||
expect(actual.fixtures.filter(dir => path.basename(dir).startsWith('skill-e2e-review-report-state-'))).toHaveLength(2);
|
||||
for (const outcome of actual.outcomes) {
|
||||
expect(outcome.entries).toHaveLength(1);
|
||||
expect(outcome.entries[0]).toMatchObject({ name: '/plan-review-report', suite: 'Plan Review Report E2E', tier: 'e2e', passed: true,
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { buildPage, cleanText, diffLines, hunks, pathSides, resolveCommit } from '../lib/pr-shots';
|
||||
import { wtreeFingerprint } from '../lib/script-invocation';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const dirs: string[] = [];
|
||||
@@ -120,6 +121,26 @@ describe('gstack-pr-shots CLI', () => {
|
||||
expect(text).toStartWith('<details><summary>Before/after as text: The record renders as a table</summary>');
|
||||
});
|
||||
|
||||
// #3560: pr-shots starts the browse daemon from the worktree; the daemon's state-dir setup
|
||||
// appended .gstack/ to the tracked .gitignore in a repository that tracks .gstack/ config, so the
|
||||
// tree changed after pregate and the /ship receipt refused with PREGATE_STALE. The stub browse
|
||||
// runs the daemon's real state-dir setup (browse/src/config.ts), then fails like a browser-less host.
|
||||
test.skipIf(process.platform === 'win32')('the working-tree fingerprint is unchanged after a run that starts browse (#3560)', () => {
|
||||
const { dir } = repo({ '.gitignore': 'node_modules/\n.gstack/tmp/\n', '.gstack/pregate.json': '{}\n', 'doc.md': '# Doc\n\nold\n' });
|
||||
fs.writeFileSync(path.join(dir, 'doc.md'), '# Doc\n\nnew\n');
|
||||
const stubDir = tmp('shots-browse-');
|
||||
fs.writeFileSync(path.join(stubDir, 'state-dir.ts'), `import { ensureStateDir, resolveConfig } from ${JSON.stringify(path.join(ROOT, 'browse/src/config.ts'))};\nensureStateDir(resolveConfig());\nprocess.exit(1);\n`);
|
||||
const browse = path.join(stubDir, 'browse');
|
||||
fs.writeFileSync(browse, `#!/usr/bin/env bash\nexec ${JSON.stringify(process.execPath)} --no-env-file --config=/dev/null ${JSON.stringify(path.join(stubDir, 'state-dir.ts'))}\n`, { mode: 0o755 });
|
||||
const before = wtreeFingerprint({ cwd: dir });
|
||||
expect('tree' in before).toBe(true);
|
||||
const r = cli(['--path', 'doc.md', '--caption', 'c'], dir, { GSTACK_STATE_ROOT: tmp('shots-state-'), GSTACK_BROWSE_BIN: browse, BROWSE_STATE_FILE: '' });
|
||||
expect(r.out).toMatch(/^SHOT_PNG: failed /m);
|
||||
expect(fs.existsSync(path.join(dir, '.gstack', '.gitignore'))).toBe(true);
|
||||
expect(fs.readFileSync(path.join(dir, '.gitignore'), 'utf8')).toBe('node_modules/\n.gstack/tmp/\n');
|
||||
expect(wtreeFingerprint({ cwd: dir })).toEqual(before);
|
||||
});
|
||||
|
||||
test('a secret in a capture is refused before anything is written, without echoing it', () => {
|
||||
const { dir } = repo({ 'x.txt': 'x' });
|
||||
const secret = 'ghp_' + 'b'.repeat(36);
|
||||
|
||||
@@ -193,6 +193,12 @@ describe('pregate config: sealed (CD4)', () => {
|
||||
expect(codes({ sealed: { seed_patterns: ['('] } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.seed_patterns[0]']);
|
||||
expect(codes({ sealed: { visibility: 'internal' } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.visibility']);
|
||||
expect(codes({ sealed: { block: true } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.block']);
|
||||
expect(codes({ sealed: { paths: ['**/sealed/**'], allow_paths: ['**/sealed/*/MANIFEST.json', '**/protected/MANIFEST.json'], case_insensitive: true } })).toEqual([]);
|
||||
expect(codes({ sealed: { allow_paths: '**/sealed/*/MANIFEST.json' } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths']);
|
||||
expect(codes({ sealed: { allow_paths: ['../x/MANIFEST.json'] } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths[0]']);
|
||||
expect(codes({ sealed: { allow_paths: Array.from({ length: 65 }, (_, i) => `s${i}/MANIFEST.json`) } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths']);
|
||||
expect(codes({ sealed: { allow_paths: ['**', 'sealed/**', 'sealed/*', '**/*.json', 'sealed/*.*', '**/sealed/**.json', '**/sealed/*/MANIFEST.json', '**/sealed/*/transcript-*.jsonl', '.hidden'] } })).toEqual([0, 1, 2, 3, 4, 5].map(i => `PREGATE_CONFIG_INVALID:sealed.allow_paths[${i}]`));
|
||||
expect(codes({ sealed: { case_insensitive: 'yes' } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.case_insensitive']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* #3527 put Step 9's review plan into ship/sections/review-army.md as prose, so the qa-callers transport
|
||||
* neither admitted the helper nor said where the plan's Step 11 voices run.
|
||||
* - Paid screen 40707142223 (ship-exploratory-plan-checks, slice 10 and its rerun): the only refused call in
|
||||
* both trials was `<runtime>/bin/gstack-review-plan --base main`.
|
||||
* - Train 35 rebuild gate 40664336141 (ship-exploratory-late-input, slice 9): the actor never ran the plan,
|
||||
* read Step 11's native adversarial pass as owed by this Steps 8-9 excerpt, and reported pass with it remaining.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { bunIsolationArgs } from '../lib/bun-isolation';
|
||||
import {
|
||||
createQaCallerFixture, qaCallerCommandAllowed, qaCallerSessionOptions, validateCallerEvidence,
|
||||
type QaCallerCase, type QaCallerFixture,
|
||||
} from './helpers/qa-callers-fixture';
|
||||
|
||||
const captured = JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures/qa-caller-review-plan-40707142223.json'), 'utf8'));
|
||||
const specialistDiff = JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures/qa-caller-merge-base-diff-40737741454.json'), 'utf8'));
|
||||
const SHIP_CASES: QaCallerCase[] = ['ship-exploratory-small-cli', 'ship-exploratory-unavailable', 'ship-exploratory-plan-checks', 'ship-exploratory-late-input'];
|
||||
const fixtures: QaCallerFixture[] = [];
|
||||
afterEach(async () => {
|
||||
for (const fixture of fixtures.splice(0)) { await fixture.close(); fs.rmSync(fixture.root, { recursive: true, force: true }); }
|
||||
});
|
||||
const fixtureFor = (caseId: QaCallerCase, installRuntime = false) => {
|
||||
const fixture = createQaCallerFixture(caseId, { installRuntime });
|
||||
fixtures.push(fixture);
|
||||
return fixture;
|
||||
};
|
||||
const context = (fixture: QaCallerFixture) => ({ runtime: fixture.runtime, fixtureRoot: fixture.cwd });
|
||||
|
||||
describe('ship Step 9 review plan through the qa-callers transport', () => {
|
||||
for (const [run, trial] of Object.entries(captured.runs) as [string, { root: string; commands: { command: string }[] }][]) {
|
||||
test(`captured ${run}: every Bash call the actor made is inside the declared interface`, () => {
|
||||
const fixture = fixtureFor('ship-exploratory-plan-checks', true);
|
||||
const commands = trial.commands.map(({ command }) => command.replaceAll(trial.root, fixture.root));
|
||||
expect(commands.filter(command => command.includes('/bin/gstack-review-plan '))).toEqual([`${fixture.runtime}/bin/gstack-review-plan --base main`]);
|
||||
expect(commands.filter(command => !qaCallerCommandAllowed(command, fixture.workflowCommands, context(fixture)))).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test('the helper forms Step 9 names are admitted at the exact installed path, and nothing else is', () => {
|
||||
const fixture = fixtureFor('ship-exploratory-late-input');
|
||||
const helper = `${fixture.runtime}/bin/gstack-review-plan`;
|
||||
const allowed = (command: string) => qaCallerCommandAllowed(command, fixture.workflowCommands, context(fixture));
|
||||
for (const args of ['--base main', '--base main --pass 1 --last full', '--base main --pass 2 --last fix-check --fixed-below --adversarial-ran',
|
||||
'--base main --pass 1 --last full --blocking-open --touched-gate --critical-found --reviewed-numbers fp:0a1b',
|
||||
'--fix-check-prompt --fix-list reports/fix-list.md --hunks reports/hunks.diff --tests reports/tests.txt',
|
||||
"--fix-check-prompt --fix-list 'reports/fix list.md' --hunks reports/h.diff --tests reports/t.txt --callers reports/c.txt"]) {
|
||||
expect(allowed(`${helper} ${args}`), args).toBe(true);
|
||||
}
|
||||
for (const command of [`${helper} --base origin/main`, `${helper} --base main --json`, `${helper} --base main --force all`,
|
||||
`${helper} --base main --last full --pass 1`, helper, `${helper} --base main | tee reports/plan.txt`, `${helper} --base main; rm -rf reports`,
|
||||
`${helper} --base main && git reset --hard`, `${helper} --base "$(git rev-parse HEAD)"`, `${helper} --fix-check-prompt --fix-list $HOME/x --hunks a --tests b`,
|
||||
`bun ${helper} --base main`, `${helper}x --base main`, `${fixture.runtime}/bin/gstack-review-planner --base main`,
|
||||
'/tmp/elsewhere/bin/gstack-review-plan --base main', '~/.claude/skills/gstack/bin/gstack-review-plan --base main']) {
|
||||
expect(allowed(command), command).toBe(false);
|
||||
}
|
||||
expect(qaCallerCommandAllowed(`${helper} --base main`, fixture.workflowCommands)).toBe(false);
|
||||
});
|
||||
|
||||
test.each(SHIP_CASES)('%s: every reviewer the real plan dispatches has a declared route in the transport', caseId => {
|
||||
const fixture = fixtureFor(caseId, true);
|
||||
const options = qaCallerSessionOptions(fixture, 'free-review-plan');
|
||||
expect(qaCallerCommandAllowed(`${fixture.runtime}/bin/gstack-review-plan --base main`, fixture.workflowCommands, context(fixture))).toBe(true);
|
||||
const plan = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(fixture.runtime, 'bin/gstack-review-plan'), '--base', 'main'],
|
||||
{ cwd: fixture.cwd, env: { ...process.env, ...options.env }, encoding: 'utf8', timeout: 60_000 });
|
||||
expect(plan.status, plan.stderr).toBe(0);
|
||||
const roles = [...plan.stdout.matchAll(/^DISPATCH: (\S+)/gm)].map(match => match[1]);
|
||||
expect(roles).toContain('adversarial-native');
|
||||
const prompt = options.prompt.toLowerCase();
|
||||
for (const role of roles) expect(prompt.includes(role) || prompt.includes(role.replace('-', ' ')), role).toBe(true);
|
||||
expect(options.prompt).toContain("The plan's adversarial-native and outside-voice lines are Step 11's voices, which Step 11 runs in a later stage outside this excerpt; they are not remaining work of this phase.");
|
||||
});
|
||||
|
||||
test('review keeps its own Step 4.8 transport and is not told about ship Step 11', () => {
|
||||
const prompt = qaCallerSessionOptions(fixtureFor('review-exploratory-small-cli'), 'free-review-plan').prompt;
|
||||
expect(prompt).toContain('the native adversarial reviewer (Step 4.8)');
|
||||
expect(prompt).not.toContain('gstack-review-plan');
|
||||
expect(prompt).not.toContain("Step 11's voices");
|
||||
});
|
||||
|
||||
test('captured 40664336141 late-input receipt: pass with remaining work is still a false green', () => {
|
||||
const receipt = captured.lateInputReceipt;
|
||||
expect(receipt.remaining).toContain('Step 11 native adversarial review (not dispatched)');
|
||||
const reportRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'qc-late-'));
|
||||
const validate = (remaining: string[]) => validateCallerEvidence({ caller: 'ship', result: { exitReason: 'success', transcript: [] }, probes: [],
|
||||
receipt: { status: receipt.status, probes: [], remaining }, currentSnapshot: 'none', requiredCharters: [], mutations: [], observerComplete: true,
|
||||
reportRoot, checkpointFiles: {}, reportMarkdown: '' });
|
||||
try {
|
||||
expect(validate(receipt.remaining)).toContain('blocked, failing or incomplete coverage reported green');
|
||||
expect(validate([])).not.toContain('blocked, failing or incomplete coverage reported green');
|
||||
} finally { fs.rmSync(reportRoot, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Paid screen 40737741454 (ship-exploratory-unavailable, slice 11 and its rerun): the plan dispatched the
|
||||
* state-machines specialist, review-army tells the parent to write the merge-base sha into the specialist's
|
||||
* diff command, and the only refused call in both trials was that child's `git diff <merge-base sha>`.
|
||||
*/
|
||||
describe('review-army specialist diff at the literal merge base', () => {
|
||||
const mergeBase = (fixture: QaCallerFixture) => spawnSync('git', ['merge-base', 'origin/main', 'HEAD'], { cwd: fixture.cwd, encoding: 'utf8', timeout: 5000 }).stdout.trim();
|
||||
|
||||
for (const [run, trial] of Object.entries(specialistDiff.runs) as [string, { root: string; mergeBase: string; commands: { command: string }[] }][]) {
|
||||
test(`captured ${run}: every Bash call, the specialist child's included, is inside the declared interface`, () => {
|
||||
const fixture = fixtureFor('ship-exploratory-unavailable', true);
|
||||
const base = mergeBase(fixture).slice(0, trial.mergeBase.length);
|
||||
const commands = trial.commands.map(({ command }) => command.replaceAll(trial.root, fixture.root).replaceAll(trial.mergeBase, base));
|
||||
expect(commands).toContain(`git diff ${base}`);
|
||||
expect(commands.filter(command => !qaCallerCommandAllowed(command, fixture.workflowCommands, context(fixture)))).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test('only the fixture merge base is admitted, bare or in the specialist DIFF_BASE form, and the prompt declares it', () => {
|
||||
const fixture = fixtureFor('ship-exploratory-unavailable');
|
||||
const base = mergeBase(fixture);
|
||||
const allowed = (command: string) => qaCallerCommandAllowed(command, fixture.workflowCommands, context(fixture));
|
||||
for (const command of [`git diff ${base}`, `git diff ${base.slice(0, 7)}`, `git diff --stat ${base.slice(0, 12)}`, `git diff ${base} -- scale.ts`,
|
||||
`DIFF_BASE=${base} && git diff "$DIFF_BASE"`, `DIFF_BASE=${base.slice(0, 9)} && git diff --name-status "$DIFF_BASE"`]) {
|
||||
expect(allowed(command), command).toBe(true);
|
||||
}
|
||||
const other = base.replace(/^./, base[0] === '0' ? '1' : '0');
|
||||
for (const command of [`git diff ${other}`, `git diff ${base.slice(0, 6)}`, `git diff ${base} --output=reports/x`, `git diff --stat ${base} --numstat`,
|
||||
`DIFF_BASE=${other} && git diff "$DIFF_BASE"`, `DIFF_BASE=${base} && git diff ${base}`, `DIFF_BASE=${base}; git diff "$DIFF_BASE"`,
|
||||
`git diff "$DIFF_BASE"`, `DIFF_BASE=${base} && git diff "$DIFF_BASE" && git status --short`, `git diff ${base.toUpperCase()}`]) {
|
||||
expect(allowed(command), command).toBe(false);
|
||||
}
|
||||
expect(qaCallerCommandAllowed(`git diff ${base}`, fixture.workflowCommands)).toBe(false);
|
||||
for (const caseId of ['ship-exploratory-unavailable', 'review-exploratory-small-cli'] as QaCallerCase[]) {
|
||||
expect(qaCallerSessionOptions(fixtureFor(caseId), 'free-review-plan').prompt).toContain(
|
||||
'The merge base itself, the sha git merge-base origin/main HEAD prints (at least 7 characters), may replace origin/main as a literal diff base');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
test.each(['success', 'bash-edit', 'max-turns', 'no-edit', 'no-commit', 'runner', 'retry', 'directory', 'recording', 'late-timeout'])
|
||||
test.each(['success', 'bash-edit', 'max-turns', 'no-edit', 'no-commit', 'runner', 'retry', 'directory', 'recording', 'late-timeout', 'slow-setup'])
|
||||
('QA attempt recording and fixture ownership: %s', scenario => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qa-fix-body-'));
|
||||
const script = path.join(dir, 'body.fixture.test.ts');
|
||||
@@ -21,7 +21,10 @@ const scenario = ${JSON.stringify(scenario)};
|
||||
const attempts = [], records = [], servers = [];
|
||||
const late = scenario === 'late-timeout';
|
||||
const workMs = 1000, drainMs = 80;
|
||||
let finalized = false, outerMs;
|
||||
const realNow = Date.now, slowSetup = scenario === 'slow-setup';
|
||||
let finalized = false, outerMs, frozenAt, clock = realNow();
|
||||
if (late) Date.now = () => frozenAt ?? realNow();
|
||||
else if (slowSetup) Date.now = () => clock;
|
||||
const collector = { addTest: entry => {
|
||||
records.push({ ...entry, afterFinalization: finalized });
|
||||
if (scenario === 'recording') throw new Error('QA recorder failure');
|
||||
@@ -36,7 +39,7 @@ Bun.serve = options => {
|
||||
return server;
|
||||
};
|
||||
mock.module(path.join(root, 'test/helpers/aside-available.ts'), () => ({ asideAvailable: () => false }));
|
||||
if (late) mock.module(path.join(root, 'test/helpers/eval-budgets.ts'), () => ({
|
||||
if (late || slowSetup) mock.module(path.join(root, 'test/helpers/eval-budgets.ts'), () => ({
|
||||
JUDGE_MS: 120_000, CAPTURE_MS: 300_000, CAPTURE_LONG_MS: workMs,
|
||||
}));
|
||||
mock.module(path.join(root, 'test/helpers/e2e-helpers.ts'), () => ({
|
||||
@@ -46,13 +49,13 @@ mock.module(path.join(root, 'test/helpers/e2e-helpers.ts'), () => ({
|
||||
testConcurrentIfSelected: (id, body, budget) => {
|
||||
if (id !== 'qa-fix-loop') return;
|
||||
outerMs = budget;
|
||||
// Keep the actual native outer deadline, scaling only its 5s recording
|
||||
// margin to80ms. The pre-fix equal work/outer deadline stays equal.
|
||||
const nativeBudget = late ? (budget >= workMs + 5000 ? budget - 5000 + 80 : budget) : 5000;
|
||||
test(id, body, nativeBudget);
|
||||
// The late attempt's clock stands still through its real setup, so the
|
||||
// mocked work budget reaches the capture whole; the deadline is the
|
||||
// registered outer budget.
|
||||
test(id, late ? () => { frozenAt = realNow(); return body(); } : body, late ? budget : 5000);
|
||||
},
|
||||
copyDirSync: (source, target) => fs.cpSync(source, target, { recursive: true }),
|
||||
setupBrowseShims: cwd => fs.mkdirSync(path.join(cwd, 'browse/bin'), { recursive: true }),
|
||||
setupBrowseShims: cwd => { fs.mkdirSync(path.join(cwd, 'browse/bin'), { recursive: true }); if (slowSetup) clock += 400; },
|
||||
logCost: () => {}, createEvalCollector: () => collector, finalizeEvalCollector: async () => {},
|
||||
recordE2E: (_collector, name, suite, result, extra) => collector.addTest({
|
||||
name, suite, exit_reason: result.exitReason, model: result.model,
|
||||
@@ -62,6 +65,7 @@ mock.module(path.join(root, 'test/helpers/e2e-helpers.ts'), () => ({
|
||||
mock.module(path.join(root, 'test/helpers/session-runner.ts'), () => ({
|
||||
SESSION_DRAIN_GRACE_MS: late ? drainMs : 5000,
|
||||
runSkillTest: async options => {
|
||||
frozenAt = undefined;
|
||||
const cwd = options.workingDirectory;
|
||||
const git = (...args) => execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 5000 });
|
||||
const initial = fs.readFileSync(path.join(cwd, 'index.html'), 'utf8');
|
||||
@@ -102,6 +106,7 @@ afterAll(async () => {
|
||||
// behind child exit; this reproduces the observed late recording race.
|
||||
if (late) await new Promise(resolve => setTimeout(resolve, 200));
|
||||
fs.writeFileSync(${JSON.stringify(factsPath)}, JSON.stringify({ attempts, records, servers, outerMs }));
|
||||
Date.now = realNow;
|
||||
});
|
||||
await import(path.join(root, 'test/skill-e2e-qa-workflow.test.ts'));
|
||||
`);
|
||||
@@ -152,11 +157,11 @@ await import(path.join(root, 'test/skill-e2e-qa-workflow.test.ts'));
|
||||
}
|
||||
if (scenario === 'late-timeout') {
|
||||
expect(outerMs).toBe(1000 + 80 + 5000);
|
||||
expect(attempts[0].captureBudgetMs).toBeGreaterThan(0);
|
||||
expect(attempts[0].captureBudgetMs).toBeLessThan(1000);
|
||||
expect(attempts[0].captureBudgetMs).toBe(1000);
|
||||
expect(records[0].exit_reason).toBe('timeout');
|
||||
expect(child.stderr).not.toContain('Unhandled error between tests');
|
||||
}
|
||||
if (scenario === 'slow-setup') expect(attempts[0].captureBudgetMs).toBe(1000 - 400);
|
||||
if (scenario === 'directory') {
|
||||
expect(attempts[0].directoryStatus).toBe(404);
|
||||
expect(attempts[0].missingStatus).toBe(404);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* A four-part package.json version survives gstack-restamp in a repo whose
|
||||
* convention is a four-part manifest (gbrain: VERSION 0.60.483.0 and
|
||||
* package.json "0.60.483.0", cross-checked by its CI version gate). The
|
||||
* writer used to store the npm-valid three-part form there ("0.60.483").
|
||||
* gstack's own shape (a three-part manifest outside the policy's stamp files)
|
||||
* and the decision-11 migration of an unlisted four-part manifest are unchanged.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { cleanup, makeFixtureRepo, makePr, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
const fixture = (opts?: Parameters<typeof makeFixtureRepo>[0]) => { const r = makeFixtureRepo(opts); repos.push(r); return r; };
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
|
||||
const GBRAIN_POLICY = {
|
||||
$schema: 'https://github.com/garrytan/gstack/docs/ship-policy.md#schema-v1',
|
||||
bump: 'patch',
|
||||
queue_mode: 'stamp-at-merge',
|
||||
changelog: 'CHANGELOG.md',
|
||||
stamp_files: ['VERSION', 'package.json', 'CHANGELOG.md'],
|
||||
stamp_patterns: ['"version": "{version}"'],
|
||||
};
|
||||
|
||||
const pkgVersion = (repo: FixtureRepo) => JSON.parse(readFile(repo, 'package.json')).version;
|
||||
const setPkgVersion = (repo: FixtureRepo, version: string) => {
|
||||
const pkg = JSON.parse(readFile(repo, 'package.json'));
|
||||
fs.writeFileSync(path.join(repo.work, 'package.json'), JSON.stringify({ ...pkg, version }, null, 2) + '\n');
|
||||
};
|
||||
|
||||
/** Rewrite the fixture's base as gbrain ships it: a four-part manifest mirroring VERSION, and bun, so no npm lockfile. */
|
||||
function fourPartManifest(repo: FixtureRepo, version: string): void {
|
||||
setPkgVersion(repo, version);
|
||||
fs.rmSync(path.join(repo.work, 'package-lock.json'));
|
||||
repo.git('add', '-A');
|
||||
repo.git('commit', '-q', '-m', 'four-part manifest');
|
||||
repo.git('push', '-q', 'origin', 'main');
|
||||
}
|
||||
|
||||
describe('gstack-restamp keeps the repo\'s manifest width', () => {
|
||||
test('gbrain-shaped repo: package.json keeps four parts and agrees with VERSION', () => {
|
||||
const repo = fixture({ policy: GBRAIN_POLICY, version: '0.60.482.0' });
|
||||
fourPartManifest(repo, '0.60.482.0');
|
||||
makePr(repo, 'feat-a');
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(r.out).toContain('STAMP_VERSION: 0.60.483.0');
|
||||
expect(r.out).toContain('stage version: done (wrote 0.60.483.0 (package.json keeps four parts');
|
||||
expect(readFile(repo, 'VERSION').trim()).toBe('0.60.483.0');
|
||||
expect(pkgVersion(repo)).toBe('0.60.483.0');
|
||||
});
|
||||
|
||||
test('gbrain-shaped branch that already carries its stamp: the restamp leaves the four-part manifest as is', () => {
|
||||
const repo = fixture({ policy: GBRAIN_POLICY, version: '0.60.482.0' });
|
||||
fourPartManifest(repo, '0.60.482.0');
|
||||
makePr(repo, 'feat-a');
|
||||
fs.writeFileSync(path.join(repo.work, 'VERSION'), '0.60.483.0\n');
|
||||
setPkgVersion(repo, '0.60.483.0');
|
||||
repo.git('commit', '-q', '-am', 'stamp 0.60.483.0');
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(r.out).toContain('STAMP_VERSION: 0.60.483.0');
|
||||
expect(r.out).not.toContain('diff --git a/package.json');
|
||||
expect(pkgVersion(repo)).toBe('0.60.483.0');
|
||||
});
|
||||
|
||||
test('a branch an older restamp cut to three parts heals: the base\'s four-part manifest sets the form', () => {
|
||||
const repo = fixture({ policy: GBRAIN_POLICY, version: '0.60.482.0' });
|
||||
fourPartManifest(repo, '0.60.482.0');
|
||||
makePr(repo, 'feat-a');
|
||||
setPkgVersion(repo, '0.60.482');
|
||||
repo.git('commit', '-q', '-am', 'old restamp cut the manifest');
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(pkgVersion(repo)).toBe('0.60.483.0');
|
||||
});
|
||||
|
||||
test('a manifest listed through stamp_paths keeps four parts too', () => {
|
||||
const repo = fixture({ policy: { ...GBRAIN_POLICY, stamp_files: [], stamp_paths: ['package.json'] }, version: '0.60.482.0' });
|
||||
fourPartManifest(repo, '0.60.482.0');
|
||||
makePr(repo, 'feat-a');
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(pkgVersion(repo)).toBe('0.60.483.0');
|
||||
});
|
||||
|
||||
test('gstack-shaped repo: the three-part manifest and its lockfile still get the npm-valid form', () => {
|
||||
const repo = fixture();
|
||||
makePr(repo, 'feat-a');
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(r.out).toContain('stage version: done (wrote 1.2.4.0)');
|
||||
expect(readFile(repo, 'VERSION').trim()).toBe('1.2.4.0');
|
||||
expect(pkgVersion(repo)).toBe('1.2.4');
|
||||
const lock = JSON.parse(readFile(repo, 'package-lock.json'));
|
||||
expect([lock.version, lock.packages[''].version]).toEqual(['1.2.4', '1.2.4']);
|
||||
});
|
||||
|
||||
test('a four-part manifest the policy does not list as a stamp file still migrates to the npm-valid form (decision 11)', () => {
|
||||
const repo = fixture({ policy: { ...GBRAIN_POLICY, stamp_files: ['VERSION', 'CHANGELOG.md'] }, version: '0.60.482.0' });
|
||||
fourPartManifest(repo, '0.60.482.0');
|
||||
makePr(repo, 'feat-a');
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(r.out).toContain('stage version: done (wrote 0.60.483.0)');
|
||||
expect(pkgVersion(repo)).toBe('0.60.483');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { afterEach, 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 { collectCommit, collectLive, loadRegistryAt } from '../lib/review-collect';
|
||||
import { depthOfCommitInput } from '../lib/review-depth';
|
||||
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => { while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); });
|
||||
const lines = (n: number, tag = 'v') => Array.from({ length: n }, (_, i) => `export const ${tag}${i} = ${i};`).join('\n') + '\n';
|
||||
const REGISTRY = JSON.stringify({ entries: [{ id: 'docs', command: 'bun run gen', outputs: ['*/SKILL.md'] }, { id: 'manual', command: null, outputs: ['src/hand.ts'] }] });
|
||||
|
||||
function repo(baseFiles: Record<string, string | Buffer>) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'review-collect-'));
|
||||
dirs.push(dir);
|
||||
const git = (...a: string[]) => { const r = spawnSync('git', a, { cwd: dir, encoding: 'utf8', timeout: 10_000 }); if (r.status !== 0) throw new Error(`git ${a.join(' ')}: ${r.stderr}`); return r.stdout.trim(); };
|
||||
const write = (files: Record<string, string | Buffer | null>) => { for (const [p, text] of Object.entries(files)) { const f = path.join(dir, p); if (text === null) { fs.rmSync(f); continue; } fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, text); } };
|
||||
git('init', '-q', '-b', 'main'); git('config', 'user.email', 't@t'); git('config', 'user.name', 't'); git('config', 'commit.gpgsign', 'false');
|
||||
write({ 'README.md': '# x\n', ...baseFiles }); git('add', '-A'); git('commit', '-qm', 'base');
|
||||
const baseSha = git('rev-parse', 'HEAD');
|
||||
git('checkout', '-qb', 'feat');
|
||||
const commit = (files: Record<string, string | Buffer | null>) => { write(files); git('add', '-A'); git('commit', '-qm', 'feat'); return git('rev-parse', 'HEAD'); };
|
||||
return { dir, git, write, commit, baseSha };
|
||||
}
|
||||
|
||||
describe('collectCommit: git objects only', () => {
|
||||
// Value: protects=only a bare version bump in package.json is release metadata; fails_when=a version line that also
|
||||
// carries scripts counts as stamp-only (docs class, light); seam=none
|
||||
test('a package.json version line that also carries scripts is not stamp-only; a bare version bump is', () => {
|
||||
const r = repo({ 'package.json': '{\n "name": "x",\n "version": "1.0.0"\n}\n' });
|
||||
const bare = r.commit({ 'package.json': '{\n "name": "x",\n "version": "1.0.1"\n}\n' });
|
||||
expect(collectCommit(r.dir, r.baseSha, bare).stampOnly).toEqual(['package.json']);
|
||||
const riding = r.commit({ 'package.json': '{\n "name": "x",\n "version": "1.0.2", "scripts": { "postinstall": "node x.js" }\n}\n' });
|
||||
expect(collectCommit(r.dir, r.baseSha, riding).stampOnly).toEqual([]);
|
||||
});
|
||||
test('a registered glob output is excluded, a command:null entry is not, and a .tmpl edit counts once', () => {
|
||||
const r = repo({ '.gstack/generated.json': REGISTRY, 'ship/SKILL.md.tmpl': 'a\n', 'ship/SKILL.md': 'a\n' });
|
||||
const head = r.commit({ 'ship/SKILL.md.tmpl': 'a\nb\nc\n', 'ship/SKILL.md': lines(120), 'src/hand.ts': lines(5) });
|
||||
const c = collectCommit(r.dir, r.baseSha, head);
|
||||
expect(c.errors).toEqual([]);
|
||||
expect(c.registry).toEqual({ sha: r.baseSha, outputs: ['*/SKILL.md'] });
|
||||
const d = depthOfCommitInput({ ...c, gateTagged: 0 });
|
||||
expect([d.depth, d.runtimeLines]).toEqual(['light', 2 + 5]);
|
||||
});
|
||||
test('a head-only registry entry never excludes: the registry is the base commit\'s', () => {
|
||||
const r = repo({ 'src/keep.ts': lines(1) });
|
||||
const head = r.commit({ '.gstack/generated.json': JSON.stringify({ entries: [{ id: 'gen', command: 'gen', outputs: ['src/gen.ts'] }] }), 'src/gen.ts': lines(60) });
|
||||
const c = collectCommit(r.dir, r.baseSha, head);
|
||||
expect(c.registry).toEqual({ sha: r.baseSha, outputs: [] });
|
||||
const d = depthOfCommitInput({ ...c, gateTagged: 0 });
|
||||
expect(d.runtimeLines).toBeGreaterThanOrEqual(60);
|
||||
expect(d.depth).toBe('full');
|
||||
});
|
||||
test('a rename into src/ counts the new path as runtime', () => {
|
||||
const r = repo({ 'notes/a.txt': lines(30) });
|
||||
fs.mkdirSync(path.join(r.dir, 'src'));
|
||||
r.git('mv', 'notes/a.txt', 'src/a.ts');
|
||||
r.git('commit', '-qm', 'mv');
|
||||
const c = collectCommit(r.dir, r.baseSha, r.git('rev-parse', 'HEAD'));
|
||||
expect(c.deleted).toEqual(['notes/a.txt']);
|
||||
expect(c.perPath.find(e => e.path === 'src/a.ts')).toEqual({ path: 'src/a.ts', lines: 30, binary: false });
|
||||
expect(depthOfCommitInput({ ...c, gateTagged: 0 })).toMatchObject({ depth: 'light', runtimeLines: 30 });
|
||||
});
|
||||
test('a binary code-class path is an error (full), never 0 lines; a deleted test is full', () => {
|
||||
const r = repo({ 'test/old.test.ts': 'x\n' });
|
||||
const head = r.commit({ 'lib/x.wasm': Buffer.from([0, 97, 115, 109, 0, 1, 2]) });
|
||||
const d = depthOfCommitInput({ ...collectCommit(r.dir, r.baseSha, head), gateTagged: 0 });
|
||||
expect(d.depth).toBe('full');
|
||||
expect(d.errors).toEqual([expect.objectContaining({ code: 'binary_code_path', input: 'lib/x.wasm' })]);
|
||||
const t = repo({ 'test/old.test.ts': 'x\n', 'test/keep.test.ts': 'x\n' });
|
||||
const del = t.commit({ 'test/old.test.ts': null });
|
||||
const deleted = depthOfCommitInput({ ...collectCommit(t.dir, t.baseSha, del), gateTagged: 0 });
|
||||
expect([deleted.depth, deleted.runtimeLines, deleted.errors]).toEqual(['full', 0, []]);
|
||||
});
|
||||
test('an invalid base registry is an error with its result code; an unresolvable sha is unavailable', () => {
|
||||
const r = repo({ '.gstack/generated.json': '{"entries": [{"id": "x"}]}' });
|
||||
const head = r.commit({ 'src/a.ts': lines(3) });
|
||||
const d = depthOfCommitInput({ ...collectCommit(r.dir, r.baseSha, head), gateTagged: 0 });
|
||||
expect(d.depth).toBe('full');
|
||||
expect(d.codes).toEqual(['REGEN_REGISTRY_INVALID']);
|
||||
expect(d.errors[0]).toMatchObject({ code: 'registry_invalid', result: 'REGEN_REGISTRY_INVALID' });
|
||||
expect(d.errors[0]!.repair).toContain('gstack-regen validate');
|
||||
expect(loadRegistryAt(r.dir, 'f'.repeat(40)).error?.code).toBe('registry_unavailable');
|
||||
const missing = collectCommit(r.dir, 'f'.repeat(40), head);
|
||||
expect(missing.errors.map(e => e.code)).toContain('numstat_failed');
|
||||
expect(depthOfCommitInput({ ...missing, gateTagged: 0 }).depth).toBe('full');
|
||||
});
|
||||
test('from a checkout at another HEAD with a dirty tree it equals the clean result at head', () => {
|
||||
const r = repo({ '.gstack/generated.json': REGISTRY, 'src/a.ts': lines(2), 'ship/SKILL.md': 'a\n', 'autoplan/SKILL.md.tmpl': 'x\n' });
|
||||
const head = r.commit({ 'src/a.ts': lines(40), 'ship/SKILL.md': lines(9), 'docs/b.md': '# b\n12% faster\n', 'autoplan/notes.md': 'n\n', 'package.json': '{"version":"1.0.1"}\n' });
|
||||
const clean = collectCommit(r.dir, r.baseSha, head);
|
||||
r.git('checkout', '-q', r.baseSha);
|
||||
r.write({ 'src/a.ts': lines(500, 'dirty'), 'src/untracked.ts': lines(80), 'autoplan/SKILL.md.tmpl': null });
|
||||
const dirty = collectCommit(r.dir, r.baseSha, head);
|
||||
expect(dirty).toEqual(clean);
|
||||
expect(depthOfCommitInput({ ...dirty, gateTagged: 0 })).toEqual(depthOfCommitInput({ ...clean, gateTagged: 0 }));
|
||||
expect(clean.root).toEqual(['autoplan', 'ship']);
|
||||
expect(clean.added['docs/b.md']).toEqual(['# b', '12% faster']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectLive: the working tree', () => {
|
||||
test('tracked uncommitted edits and untracked files count per path; scratch under .gstack/tmp/ does not', () => {
|
||||
const r = repo({ 'src/a.ts': lines(1) });
|
||||
r.commit({ 'docs/a.md': '# a\n' });
|
||||
r.write({ 'src/a.ts': lines(11), 'src/new.ts': lines(20), '.gstack/tmp/x.ts': lines(500) });
|
||||
const c = collectLive(r.dir, 'main');
|
||||
expect(c.errors).toEqual([]);
|
||||
expect(c.untracked).toEqual(['src/new.ts']);
|
||||
expect(c.perPath.find(e => e.path === 'src/new.ts')).toEqual({ path: 'src/new.ts', lines: 20, binary: false });
|
||||
expect(c.paths).toEqual(['docs/a.md', 'src/a.ts', 'src/new.ts']);
|
||||
expect(depthOfCommitInput({ ...c, gateTagged: 0 })).toMatchObject({ depth: 'light', runtimeLines: 20 + 10 });
|
||||
});
|
||||
test('no merge base is a collector error the CLI refuses on', () => {
|
||||
const r = repo({});
|
||||
expect(collectLive(r.dir, 'no-such-branch').errors.map(e => e.code)).toEqual(['numstat_failed']);
|
||||
});
|
||||
});
|
||||
+126
-4
@@ -1,6 +1,9 @@
|
||||
import { describe, test, expect } 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 { classifyDiff, classifyPath, nextStep, planReview, scanNumbers, FULL_PASS_CAP, type PlanInput } from '../lib/review-depth';
|
||||
import { classifyDiff, classifyPath, depthFor, depthOfCommitInput, diffCommand, nextStep, planReview, scanNumbers, DEPTH_RULES, FULL_PASS_CAP, type DepthError, type PlanInput } from '../lib/review-depth';
|
||||
|
||||
const ROOT = path.join(import.meta.dir, '..');
|
||||
const none = scanNumbers({});
|
||||
@@ -73,9 +76,10 @@ describe('planReview', () => {
|
||||
const p = planReview(base({ paths: ['docs/bench.md'], scope: {}, numbers: scanNumbers({ 'docs/bench.md': ['3x faster'] }) }));
|
||||
expect(roles(p)).toEqual(['outside-voice:outside-voice:host']);
|
||||
});
|
||||
test('tests class: testing specialist only', () => {
|
||||
test('tests class: runtime 0 is light, core only (Behavior changes: no Testing specialist)', () => {
|
||||
const p = planReview(base({ paths: ['test/a.test.ts'], scope: { SCOPE_TESTS: 'true' } }));
|
||||
expect(roles(p)).toEqual(['specialist:testing:full']);
|
||||
expect(p.depth).toBe('light');
|
||||
expect(roles(p)).toEqual([]);
|
||||
});
|
||||
test('code class pass 1: every reviewer at full effort, the outside voice at its own', () => {
|
||||
const p = planReview(base({}));
|
||||
@@ -88,7 +92,7 @@ describe('planReview', () => {
|
||||
});
|
||||
test('code class honours hit-rate gating and small-diff skip', () => {
|
||||
expect(roles(planReview(base({ gated: ['simplification'] })))).not.toContain('specialist:simplification:full');
|
||||
expect(planReview(base({ diffLines: 20 })).dispatches.filter(d => d.role === 'specialist')).toEqual([]);
|
||||
expect(planReview(base({ diffLines: 20 })).dispatches.filter(d => d.role === 'specialist').map(d => d.name)).toEqual(['batched']);
|
||||
});
|
||||
test('pass 2 reruns the army on the range but not the adversarial or outside voice once Step 11 ran', () => {
|
||||
const p = planReview(base({ adversarialRan: true, history: { fullPasses: 1, last: 'full', fixedBlocking: true } }));
|
||||
@@ -167,3 +171,121 @@ describe('GSTA-131 coverage: Red Team and specialist arms', () => {
|
||||
expect(roles(planReview(base({ paths: ['docs/a.md'], force: ['security'] })))).toEqual(['specialist:security']);
|
||||
});
|
||||
});
|
||||
|
||||
const per = (o: Record<string, number>) => Object.entries(o).map(([p, lines]) => ({ path: p, lines, binary: false }));
|
||||
const mixed = (o: Partial<PlanInput> = {}) => base({ paths: Object.keys(o.perPath ? Object.fromEntries(o.perPath.map(e => [e.path, 1])) : {}), ...o });
|
||||
const err = (code: DepthError['code'], result?: DepthError['result']): DepthError => ({ code, detail: 'd', input: 'i', repair: 'r', ...(result ? { result } : {}) });
|
||||
|
||||
describe('review depth: the matrix', () => {
|
||||
test('runtime 0 (tests and docs only) is light: core only, nothing dispatched', () => {
|
||||
const p = planReview(mixed({ perPath: per({ 'test/a.test.ts': 300, 'docs/a.md': 40 }), diffLines: 340 }));
|
||||
expect([p.depth, p.runtimeLines, p.dispatches]).toEqual(['light', 0, []]);
|
||||
});
|
||||
test('runtime 1-50 is light plus one batched specialist: the fixed set, scope picks and the Red Team in one context', () => {
|
||||
const p = planReview(mixed({ perPath: per({ 'src/app.ts': 48, 'test/a.test.ts': 190, 'docs/a.md': 9 }), diffLines: 247 }));
|
||||
expect([p.depth, p.runtimeLines]).toEqual(['light', 48]);
|
||||
expect(p.dispatches).toEqual([
|
||||
{ role: 'specialist', name: 'batched', effort: 'full', exclude: [':(exclude)docs/a.md'], checklists: ['testing', 'maintainability', 'security', 'performance', 'red-team'] },
|
||||
{ role: 'adversarial-native', name: 'adversarial', effort: 'full', exclude: [], reason: 'backtest inconclusive (GSTA-147 D5)' },
|
||||
]);
|
||||
expect(p.skipped.join('; ')).toContain('red-team (light depth');
|
||||
expect(planReview(mixed({ perPath: per({ 'src/app.ts': 48 }), adversarialRan: true, history: { fullPasses: 1, last: 'full', touchedGate: true, fixedBelow: true } })).dispatches.some(d => d.role === 'adversarial-native')).toBe(false);
|
||||
});
|
||||
test('runtime 51 is standard: thresholds read runtime lines, reviewers read their own hunks', () => {
|
||||
const p = planReview(mixed({ perPath: per({ 'src/app.ts': 51, 'test/a.test.ts': 400, 'README.md': 3 }), diffLines: 454 }));
|
||||
expect([p.depth, p.runtimeLines]).toEqual(['standard', 51]);
|
||||
expect(roles(p)).toEqual(['specialist:testing:full', 'specialist:maintainability:full', 'specialist:performance:full', 'adversarial-native:adversarial:full', 'outside-voice:outside-voice:host']);
|
||||
const ex = Object.fromEntries(p.dispatches.map(d => [d.name, d.exclude]));
|
||||
expect(ex.testing).toEqual([':(exclude)README.md']);
|
||||
expect(ex.maintainability).toEqual([':(exclude)README.md', ':(exclude)test/a.test.ts']);
|
||||
expect([ex.adversarial, ex['outside-voice']]).toEqual([[], []]);
|
||||
});
|
||||
test.each([
|
||||
['scope error', { scope: { SCOPE_ERROR: 'boom' } }, 'scope_error', []],
|
||||
['gate scan unavailable', { gateTagged: null }, 'gate_scan_unavailable', []],
|
||||
['base registry invalid', { registry: { sha: 'abc', outputs: [], error: err('registry_invalid', 'REGEN_REGISTRY_INVALID') } }, 'registry_invalid', ['REGEN_REGISTRY_INVALID']],
|
||||
['base registry unavailable', { registry: { sha: null, outputs: [], error: err('registry_unavailable') } }, 'registry_unavailable', []],
|
||||
['numstat failure', { errors: [err('numstat_failed')] }, 'numstat_failed', []],
|
||||
['binary code path', { perPath: [...per({ 'src/app.ts': 5 }), { path: 'lib/x.wasm', lines: 0, binary: true }], paths: ['src/app.ts', 'lib/x.wasm'] }, 'binary_code_path', []],
|
||||
] as const)('%s fails closed to full with its code', (_n, o, code, codes) => {
|
||||
const p = planReview(mixed({ perPath: per({ 'src/app.ts': 5 }), ...(o as Partial<PlanInput>) }));
|
||||
expect(p.depth).toBe('full');
|
||||
expect(p.errors.map(e => e.code)).toContain(code);
|
||||
expect(p.codes).toEqual([...codes]);
|
||||
expect(roles(p)).toContain('red-team:red-team:full');
|
||||
});
|
||||
test.each([
|
||||
['a gate path', { paths: ['src/app.ts', '.github/workflows/ci.yml'] }],
|
||||
['a gate tag', { gateTagged: 1 }],
|
||||
['a deleted test', { deleted: ['test/old.test.ts'] }],
|
||||
['an edit to the registry', { paths: ['.gstack/generated.json'] }],
|
||||
['an edit to SMALL_DIFF_LINES', { paths: ['lib/review-depth.ts'] }],
|
||||
['the collector', { paths: ['lib/review-collect.ts'] }],
|
||||
['the plan CLI', { paths: ['bin/gstack-review-plan'] }],
|
||||
['the queue', { paths: ['lib/ship-queue-train.ts'] }],
|
||||
['the Step 9 template', { paths: ['ship/sections/review-army.md.tmpl'] }],
|
||||
] as const)('%s is full', (_n, o) => {
|
||||
const paths = (o as any).paths ?? ['src/app.ts'];
|
||||
expect(planReview(mixed({ perPath: per(Object.fromEntries(paths.map((p: string) => [p, 2]))), ...(o as Partial<PlanInput>) })).depth).toBe('full');
|
||||
});
|
||||
test('a registered glob output is excluded from runtime; a .tmpl edit plus its generated SKILL.md counts the tmpl only', () => {
|
||||
const p = planReview(mixed({ perPath: per({ 'ship/SKILL.md.tmpl': 30, 'ship/SKILL.md': 200 }), registry: { sha: 'abc', outputs: ['*/SKILL.md'] } }));
|
||||
expect([p.depth, p.runtimeLines]).toEqual(['light', 30]);
|
||||
expect(p.dispatches[0]!.exclude).toEqual([':(exclude)ship/SKILL.md']);
|
||||
});
|
||||
test('a path no base-registry entry names stays runtime', () => {
|
||||
expect(planReview(mixed({ perPath: per({ 'src/gen.ts': 60 }), registry: { sha: 'abc', outputs: ['lib/dom.js'] } })).runtimeLines).toBe(60);
|
||||
});
|
||||
test('numbers in light dispatch the outside voice with an empty exclude', () => {
|
||||
const p = planReview(mixed({ perPath: per({ 'docs/bench.md': 4 }), numbers: scanNumbers({ 'docs/bench.md': ['3x faster'] }) }));
|
||||
expect(p.depth).toBe('light');
|
||||
expect(p.dispatches).toEqual([{ role: 'outside-voice', name: 'outside-voice', effort: 'host', exclude: [] }]);
|
||||
});
|
||||
test('a fixed blocker in light raises the next full step to the standard set, Red Team included', () => {
|
||||
const p = planReview(mixed({ perPath: per({ 'src/app.ts': 30 }), history: { fullPasses: 1, last: 'full', fixedBlocking: true } }));
|
||||
expect([p.depth, p.stepDepth]).toEqual(['light', 'standard']);
|
||||
expect(roles(p)).toEqual(['specialist:testing:full', 'specialist:maintainability:full', 'specialist:performance:full', 'red-team:red-team:full', 'adversarial-native:adversarial:full', 'outside-voice:outside-voice:host']);
|
||||
});
|
||||
test('--depth-floor raises and never lowers', () => {
|
||||
expect(depthFor({ cls: 'code', runtimeLines: 10, errors: [], floor: 'full' })).toBe('full');
|
||||
expect(depthFor({ cls: 'code', runtimeLines: 10, errors: [], floor: 'standard' })).toBe('standard');
|
||||
expect(depthFor({ cls: 'gate', runtimeLines: 10, errors: [], floor: 'standard' })).toBe('full');
|
||||
expect(depthFor({ cls: 'code', runtimeLines: 80, errors: [] })).toBe('standard');
|
||||
const p = planReview(mixed({ perPath: per({ 'src/app.ts': 10 }), floor: 'full' }));
|
||||
expect(p.depth).toBe('full');
|
||||
expect(p.depthReasons).toContain('floor:full');
|
||||
});
|
||||
test('depthOfCommitInput gives the same depth as the plan for the same input', () => {
|
||||
const i = { paths: ['src/app.ts', 'test/a.test.ts'], perPath: per({ 'src/app.ts': 51, 'test/a.test.ts': 9 }), gateTagged: 0 };
|
||||
expect(depthOfCommitInput(i)).toMatchObject({ depth: 'standard', runtimeLines: 51, rules: DEPTH_RULES, errors: [] });
|
||||
expect(depthOfCommitInput({ ...i, gateTagged: null }).depth).toBe('full');
|
||||
});
|
||||
});
|
||||
|
||||
describe('review depth: diff commands and the rules hash', () => {
|
||||
test('DIFF_CMD shell-quotes each pathspec; a glob character makes the pathspec literal', () => {
|
||||
const p = planReview(mixed({ perPath: per({ 'src/app.ts': 10, "docs/my notes*.md": 3, "docs/it's.md": 1 }) }));
|
||||
const ex = p.dispatches[0]!.exclude;
|
||||
expect(ex).toEqual([':(exclude,literal)docs/my notes*.md', ":(exclude)docs/it's.md"]);
|
||||
expect(diffCommand(p.dispatches[0]!)).toBe("git diff <diff-base> -- . ':(exclude,literal)docs/my notes*.md' ':(exclude)docs/it'\\''s.md'");
|
||||
expect(diffCommand({ role: 'red-team', name: 'red-team', effort: 'full', exclude: [] })).toBe('git diff <diff-base>');
|
||||
});
|
||||
test('DEPTH_RULES hashes the deciding constants: a comment-only edit keeps it, a threshold edit changes it', () => {
|
||||
expect(DEPTH_RULES).toMatch(/^[0-9a-f]{16}$/);
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'depth-rules-'));
|
||||
try {
|
||||
const src = fs.readFileSync(path.join(ROOT, 'lib/review-depth.ts'), 'utf8');
|
||||
fs.symlinkSync(path.join(ROOT, 'lib/ship-policy.ts'), path.join(dir, 'ship-policy.ts'));
|
||||
const hashOf = (text: string) => {
|
||||
fs.writeFileSync(path.join(dir, 'review-depth.ts'), text);
|
||||
fs.writeFileSync(path.join(dir, 'print.ts'), "import { DEPTH_RULES } from './review-depth';\nconsole.log(DEPTH_RULES);\n");
|
||||
const r = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(dir, 'print.ts')], { encoding: 'utf8', timeout: 30_000 });
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
return r.stdout.trim();
|
||||
};
|
||||
expect(hashOf('// a comment-only edit\n' + src)).toBe(DEPTH_RULES);
|
||||
expect(hashOf(src.replace('SMALL_DIFF_LINES = 50', 'SMALL_DIFF_LINES = 60'))).not.toBe(DEPTH_RULES);
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('scripts/review-dispatch-compare.ts', () => {
|
||||
const table = run(['--tokens', tok]);
|
||||
expect(table).toContain(`TOKENS: measured (${tok})`);
|
||||
expect(table).toMatch(/^SHIP_TOKEN_RATIO: median=0\.\d+ min=0\.\d+ max=\d\.\d+ \(n=10\)$/m);
|
||||
expect(table).toMatch(/^SHIP_TOKEN_RATIO_BY_CLASS: docs median=0\.\d+ \(n=5\); code median=0\.\d+ \(n=5\)$/m);
|
||||
expect(table).toMatch(/^SHIP_TOKEN_RATIO_BY_CLASS: docs median=0\.\d+ \(n=5\); gate median=0\.\d+ \(n=5\)$/m);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -109,3 +109,51 @@ describe('gstack-review-plan', () => {
|
||||
expect(ran).toContain('SKIP: adversarial-native and outside-voice (run once per invocation)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-review-plan: review depth', () => {
|
||||
const code = (n: number) => Array.from({ length: n }, (_, i) => `export const v${i} = ${i};`).join('\n') + '\n';
|
||||
test('prints REVIEW_DEPTH with runtime lines and the rules hash, and GATE_SCAN bound to the head', () => {
|
||||
const r = repo({ 'src/a.ts': code(10), 'test/a.test.ts': code(200) });
|
||||
const out = run(r, []).stdout;
|
||||
const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: r.dir, encoding: 'utf8', timeout: 10_000 }).stdout.trim();
|
||||
expect(out).toMatch(/^REVIEW_DEPTH: light runtime_lines=10 rules=[0-9a-f]{16} reasons=runtime=10<=50 codes=none$/m);
|
||||
expect(out).toContain(`GATE_SCAN: tagged=0 head=${head}`);
|
||||
expect(out).toContain('DISPATCH: specialist batched effort=full checklists=testing,maintainability,security,performance,state-machines,red-team');
|
||||
expect(out).toContain('DISPATCH: adversarial-native adversarial effort=full reason=backtest inconclusive (GSTA-147 D5)');
|
||||
});
|
||||
test('DIFF_CMD is shell-quoted per dispatch and excludes what the reviewer does not read; DIFF_EXCLUDE: none when empty', () => {
|
||||
const r = repo({ 'src/a.ts': code(80), 'docs/my notes*.md': '# n\n', 'test/a.test.ts': code(3) });
|
||||
const out = run(r, []).stdout;
|
||||
expect(out).toContain("DIFF_CMD: testing git diff <diff-base> -- . ':(exclude,literal)docs/my notes*.md'");
|
||||
expect(out).toContain("DIFF_CMD: maintainability git diff <diff-base> -- . ':(exclude,literal)docs/my notes*.md' ':(exclude)test/a.test.ts'");
|
||||
expect(out).toContain('DIFF_CMD: adversarial git diff <diff-base>\nDIFF_EXCLUDE: adversarial none');
|
||||
const cmd = /^DIFF_CMD: maintainability (.*)$/m.exec(out)![1]!.replace('<diff-base>', 'main');
|
||||
const diff = spawnSync('bash', ['-c', cmd.replace('git diff main', 'git diff --name-only main')], { cwd: r.dir, encoding: 'utf8' });
|
||||
expect(diff.stdout.trim().split('\n')).toEqual(['src/a.ts']);
|
||||
});
|
||||
test('--json carries depth, runtime_lines, rules, errors, gate_scan and every dispatch\'s exclude array', () => {
|
||||
const r = repo({ 'src/a.ts': code(80), 'README.md': '# y\n' });
|
||||
const plan = JSON.parse(run(r, ['--json']).stdout);
|
||||
expect(plan).toMatchObject({ depth: 'standard', step_depth: 'standard', runtime_lines: 80, errors: [], codes: [], gate_scan: { tagged: 0 } });
|
||||
expect(plan.rules).toMatch(/^[0-9a-f]{16}$/);
|
||||
expect(plan.dispatches.find((d: any) => d.name === 'maintainability').exclude).toEqual([':(exclude)README.md']);
|
||||
expect(plan.dispatches.find((d: any) => d.role === 'outside-voice').exclude).toEqual([]);
|
||||
});
|
||||
test('--depth-floor full raises a light diff, never lowers a full one, and an invalid floor is a usage error', () => {
|
||||
const r = repo({ 'src/a.ts': code(5) });
|
||||
expect(run(r, ['--depth-floor', 'full']).stdout).toMatch(/^REVIEW_DEPTH: full runtime_lines=5 .*reasons=runtime=5<=50,floor:full /m);
|
||||
const gate = repo({ 'src/a.ts': code(5), '.github/workflows/ci.yml': 'on: push\n' });
|
||||
expect(run(gate, ['--depth-floor', 'standard']).stdout).toMatch(/^REVIEW_DEPTH: full /m);
|
||||
expect(run(r, ['--depth-floor', 'light']).status).toBe(2);
|
||||
});
|
||||
test('a conservative full plan exits 0 with its errors; a plan that cannot be built exits 1', () => {
|
||||
const r = repo({ 'lib/x.wasm': '\u0000asm\u0000' });
|
||||
const res = run(r, ['--json']);
|
||||
expect(res.status).toBe(0);
|
||||
expect(JSON.parse(res.stdout)).toMatchObject({ depth: 'full', errors: expect.arrayContaining([expect.objectContaining({ code: 'binary_code_path', input: 'lib/x.wasm' })]) });
|
||||
expect(run(r, []).stdout).toContain('DEPTH_ERROR: binary_code_path input=lib/x.wasm');
|
||||
const none = spawnSync(BIN, ['--base', 'no-such-branch', '--gate-tagged', '0'], { cwd: r.dir, encoding: 'utf8', timeout: 60_000, env: { ...process.env, GSTACK_STATE_ROOT: r.state } });
|
||||
expect(none.status).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -684,3 +684,18 @@ test('review suppressions allow a clean result and skip common false positives b
|
||||
expectTokens(suppressions, ['"Function too long"', '"Possible null dereference"', '"N+1 query"', '"Hardcoded value"', '`Math.random()`'], 'suppressions');
|
||||
expectAbsent(suppressions, [/missing input validation/i, /missing await/i], 'suppressions');
|
||||
});
|
||||
|
||||
test('judged repeat-pass QA reruns and unresolved record actions each have one rule (paid screen 40617200857)', () => {
|
||||
// Every sample of the red panel and its rerun named one of these: "post-expiry smoke rechecks are not-run" vs
|
||||
// Step 5.8's rerun of superseded probes, materialize's once-per-PROBE_DIR limit vs reruns into the same report,
|
||||
// and no record action for an unresolved non-gate defect (an unanswered question or an unproven repair).
|
||||
// fails_when=the rerun stops being revalidation, materialize may run per pass, or such a defect has no action.
|
||||
const flat = skill.replace(/\s+/g, ' ');
|
||||
const repeat = flat.slice(flat.indexOf('2. On a repeat, execute Steps 3–5 in order.'), flat.indexOf('3. **Verify completed actions.**'));
|
||||
expect(repeat).toContain('These reruns are revalidation, so they run even after smoke expiry; keep the PROBE_DIR. Earlier provisional sections link checkpoints; only the final pass runs its once-only `materialize`.');
|
||||
expect(repeat.indexOf('rerun each probe the verdict relies on')).toBeLessThan(repeat.indexOf('These reruns are revalidation'));
|
||||
const actions = flat.slice(flat.indexOf('Actions: `auto-fixed` (Step 5b)'), flat.indexOf('Advice is never `auto-fixed`'));
|
||||
expect(actions).toContain('`fixed` (approved **and completed** in Step 5d)');
|
||||
expect(actions).toContain('`skipped` (explicit Skip in Step 5c), `open` (any other defect, e.g. unanswered).');
|
||||
expect(flat).toContain('Do not record an unanswered question as skipped');
|
||||
});
|
||||
@@ -195,6 +195,143 @@ describe('gstack-pregate sealed (the standalone preflight before any push or PR
|
||||
});
|
||||
});
|
||||
|
||||
const MANIFEST = { paths: ['**/sealed/**'], allow_paths: ['**/sealed/*/MANIFEST.json'], answer_markers: ['SEALED ANSWER:'] };
|
||||
function preflight(s: Scratch, args: string[] = []) {
|
||||
const r = spawnSync(PREGATE, ['sealed', '--base', 'main', ...args], { cwd: s.work, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...s.env } });
|
||||
return { code: r.status, out: r.stdout ?? '', err: r.stderr ?? '' };
|
||||
}
|
||||
function landOnMain(s: Scratch, files: Record<string, string>): void {
|
||||
sh(s.work, s.env, ['checkout', '-q', 'main']);
|
||||
write(s.work, files);
|
||||
sh(s.work, s.env, ['add', '-A']); sh(s.work, s.env, ['commit', '-q', '-m', 'base change']); sh(s.work, s.env, ['push', '-q', 'origin', 'main']);
|
||||
sh(s.work, s.env, ['checkout', '-q', 'results']);
|
||||
}
|
||||
|
||||
describe('sealed.allow_paths: base-branch exceptions applied after the deny globs', () => {
|
||||
test('a deny with an allowed exception refuses the sealed file, lists the manifest as exempt, and a manifest-only push passes', () => {
|
||||
const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
|
||||
commit(s, { 'sealed/s1/tasks.json': '{}\n', 'sealed/s1/MANIFEST.json': '{"n":1}\n', 'tasks/x/sealed/s2/MANIFEST.json': '{"n":2}\n' });
|
||||
const r = hook(s);
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.err).toMatch(/^ sealed\.path\s+sealed\/s1\/tasks\.json/m);
|
||||
expect(r.err).not.toMatch(/^ sealed\.path\s+\S*MANIFEST\.json/m);
|
||||
expect(r.err).toMatch(/exempt sealed\.path sealed\/s1\/MANIFEST\.json commit [0-9a-f]{12} \(sealed\.allow_paths \*\*\/sealed\/\*\/MANIFEST\.json\)/);
|
||||
expect(r.err).toContain('exempt sealed.path tasks/x/sealed/s2/MANIFEST.json');
|
||||
const only = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
|
||||
commit(only, { 'sealed/s1/MANIFEST.json': '{"n":1}\n' });
|
||||
expect(hook(only).code).toBe(0);
|
||||
const pre = preflight(only);
|
||||
expect(pre.code).toBe(0);
|
||||
expect(pre.out).toMatch(/^SEALED: clear findings=0 commits=1 visibility=unknown \(counted public\) waived=0 exempt=1 policy=/m);
|
||||
const json = JSON.parse(preflight(only, ['--json']).out);
|
||||
expect(json.exempt).toHaveLength(1);
|
||||
expect(json.exempt[0]).toMatchObject({ rule: 'sealed.path', file: 'sealed/s1/MANIFEST.json', glob: '**/sealed/*/MANIFEST.json' });
|
||||
});
|
||||
|
||||
test('an exception that does not match still refuses: another case and another depth', () => {
|
||||
for (const file of ['sealed/s1/manifest.json', 'sealed/s1/x/MANIFEST.json']) {
|
||||
const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
|
||||
commit(s, { [file]: '{}\n' });
|
||||
const r = hook(s);
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.err).toContain(`sealed.path ${file}`);
|
||||
expect(r.err).not.toContain('exempt sealed.');
|
||||
}
|
||||
});
|
||||
|
||||
test('an exception added on the pushed head is ignored, on top of a base list too; a malformed pushed allow_paths refuses', () => {
|
||||
const fresh = scratch({ '.gstack/pregate.json': policy({ paths: ['**/sealed/**'] }) });
|
||||
commit(fresh, { '.gstack/pregate.json': policy(MANIFEST), 'sealed/s1/MANIFEST.json': '{}\n' });
|
||||
const f = hook(fresh);
|
||||
expect(f.code).toBe(1);
|
||||
expect(f.err).toContain('sealed.path sealed/s1/MANIFEST.json');
|
||||
const extra = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
|
||||
commit(extra, { '.gstack/pregate.json': policy({ ...MANIFEST, allow_paths: [...MANIFEST.allow_paths, '**/sealed/*/notes.md'] }), 'sealed/s1/notes.md': 'n\n' });
|
||||
expect(hook(extra).err).toContain('sealed.path sealed/s1/notes.md');
|
||||
const bad = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
|
||||
commit(bad, { '.gstack/pregate.json': policy({ ...MANIFEST, allow_paths: '**/sealed/*/MANIFEST.json' }), 'docs/x.md': 'x\n' });
|
||||
const b = hook(bad);
|
||||
expect(b.code).toBe(1);
|
||||
expect(b.err).toContain('sealed.allow_paths');
|
||||
});
|
||||
|
||||
test('an allowed manifest that quotes a sealed answer still refuses on the text rule', () => {
|
||||
const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
|
||||
commit(s, { 'sealed/s1/MANIFEST.json': '{"note":"SEALED ANSWER: b"}\n' });
|
||||
const r = hook(s);
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.err).toMatch(/sealed\.answer\s+sealed\/s1\/MANIFEST\.json:1/);
|
||||
expect(r.err).toContain('exempt sealed.path sealed/s1/MANIFEST.json');
|
||||
});
|
||||
|
||||
test('an exception covers sealed.transcript too and is counted before a base sha waiver, never both', () => {
|
||||
const s = scratch({ '.gstack/pregate.json': policy({ paths: ['**/sealed/**'], transcripts: ['**/transcript*.jsonl'], allow_paths: ['**/sealed/*/transcript-public.jsonl'] }) });
|
||||
const sha = commit(s, { 'sealed/s1/transcript-public.jsonl': '{}\n' });
|
||||
landOnMain(s, { '.gstack/sealed-allow.tsv': `sealed.transcript\tsealed/s1/transcript-public.jsonl\t${sha.slice(0, 12)}\tsynthetic fixture\n` });
|
||||
const pre = preflight(s);
|
||||
expect(pre.code).toBe(0);
|
||||
expect(pre.err).toContain('exempt sealed.transcript sealed/s1/transcript-public.jsonl');
|
||||
expect(pre.out).toMatch(/findings=0 .* waived=0 exempt=1 /);
|
||||
});
|
||||
|
||||
test('a preflight error carries the same report shape: exit 1, SEALED: error with exempt=0, no stack trace', () => {
|
||||
const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
|
||||
commit(s, { 'docs/x.md': 'x\n' });
|
||||
const r = preflight(s, ['--range', 'nosuch..HEAD']);
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.out).toMatch(/^SEALED: error findings=0 commits=0 .* waived=0 exempt=0 /m);
|
||||
expect(r.err).not.toMatch(/\n\s+at /);
|
||||
expect(JSON.parse(preflight(s, ['--range', 'nosuch..HEAD', '--json']).out).exempt).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sealed.case_insensitive: deny globs fold case, exceptions never do', () => {
|
||||
test('off (the default), HELDOUT/ passes a lowercase deny glob; on, HELDOUT/, HeldOut/ and a Transcript refuse, and hold-out/ still passes', () => {
|
||||
const off = scratch({ '.gstack/pregate.json': policy({ paths: ['**/heldout/**'] }) });
|
||||
commit(off, { 'HELDOUT/q.json': '{}\n' });
|
||||
expect(hook(off).code).toBe(0);
|
||||
const on = scratch({ '.gstack/pregate.json': policy({ paths: ['**/heldout/**'], transcripts: ['runs/**/transcript*.jsonl'], case_insensitive: true }) });
|
||||
commit(on, { 'HELDOUT/q.json': '{}\n', 'HeldOut/q.json': '{}\n', 'Runs/c1/Transcript.jsonl': '{}\n', 'hold-out/q.json': '{}\n' });
|
||||
const r = hook(on);
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.err).toContain('sealed.path HELDOUT/q.json');
|
||||
expect(r.err).toContain('sealed.path HeldOut/q.json');
|
||||
expect(r.err).toContain('sealed.transcript Runs/c1/Transcript.jsonl');
|
||||
expect(r.err).not.toContain('hold-out/q.json');
|
||||
});
|
||||
|
||||
test('on, allow_paths stay case-sensitive: MANIFEST.json is exempt and manifest.json refuses', () => {
|
||||
const s = scratch({ '.gstack/pregate.json': policy({ ...MANIFEST, case_insensitive: true }) });
|
||||
commit(s, { 'sealed/s1/MANIFEST.json': '{}\n', 'sealed/s1/manifest.json': '{}\n' });
|
||||
const r = hook(s);
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.err).toContain('exempt sealed.path sealed/s1/MANIFEST.json');
|
||||
expect(r.err).toMatch(/^ sealed\.path\s+sealed\/s1\/manifest\.json/m);
|
||||
});
|
||||
|
||||
test('set only on the pushed head, it still tightens (base OR pushed)', () => {
|
||||
const s = scratch({ '.gstack/pregate.json': policy({ paths: ['**/heldout/**'] }) });
|
||||
commit(s, { '.gstack/pregate.json': policy({ paths: ['**/heldout/**'], case_insensitive: true }), 'HELDOUT/q.json': '{}\n' });
|
||||
expect(hook(s).err).toContain('sealed.path HELDOUT/q.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('root-anchoring (the docs/pregate.md example)', () => {
|
||||
test('globs match from the repo root: sealed/** misses a nested sealed/ directory and **/sealed/** catches it at any depth', () => {
|
||||
const docs = fs.readFileSync(path.join(ROOT, 'docs', 'pregate.md'), 'utf8');
|
||||
expect(docs).toContain('`sealed/**` misses `tasks/x/sealed/a.json`');
|
||||
expect(docs).toContain('write `**/sealed/**`');
|
||||
const anchored = scratch({ '.gstack/pregate.json': policy({ paths: ['sealed/**'] }) });
|
||||
commit(anchored, { 'tasks/x/sealed/a.json': '{}\n' });
|
||||
expect(hook(anchored).code).toBe(0);
|
||||
const any = scratch({ '.gstack/pregate.json': policy({ paths: ['**/sealed/**'] }) });
|
||||
commit(any, { 'tasks/x/sealed/a.json': '{}\n', 'sealed/b.json': '{}\n' });
|
||||
const a = hook(any);
|
||||
expect(a.err).toContain('sealed.path tasks/x/sealed/a.json');
|
||||
expect(a.err).toContain('sealed.path sealed/b.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('visibility (cache under the state root; unknown counts as public)', () => {
|
||||
test('a public answer is cached; a cached private answer older than an hour is re-queried before it can downgrade; a failed lookup is unknown', () => {
|
||||
const state = fs.mkdtempSync(path.join(os.tmpdir(), 'sealed-vis-'));
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { CAPTURE_LONG_MS } from './helpers/eval-budgets';
|
||||
import capturedChecker from './fixtures/shared-libs-index-flags-r59-checker-public.json';
|
||||
import loopRead from './fixtures/shared-libs-index-flags-loop-read-39173752116.json';
|
||||
import erroredRead from './fixtures/shared-libs-path-ignored-errored-read-40617200857.json';
|
||||
|
||||
const helper = path.join(SHARED_LIBS_ROOT, 'bin/gstack-review-log');
|
||||
const fixtures: SharedLibsFixture[] = [];
|
||||
@@ -96,7 +97,8 @@ async function capture(change: string, prepared?: PathEligibilityFixture, declar
|
||||
directory: path.join(f.state, 'projects/fixture-shared-libs/.review-starts'),
|
||||
branch: fixtureGit(f, 'symbolic-ref', '--quiet', '--short', 'HEAD'), wtree: fixtureWorkingTree(f),
|
||||
startedAt: receipt.review_start.started_at, finding: current, reusable: change === 'unchanged',
|
||||
coveredPaths: receipt.snapshot.covered_paths };
|
||||
coveredPaths: receipt.snapshot.covered_paths,
|
||||
...(prepared ? { sources: Object.fromEntries(current!.evidence_paths.map((file: string) => [file, fs.readFileSync(path.join(f.repo, file), 'utf8')])) } : {}) };
|
||||
return { f, current, token, receipt, events, checkAt, finishAt, expected, prepared, resumed, prerequisites, ...(declared ? { questions } : {}) };
|
||||
}
|
||||
|
||||
@@ -687,3 +689,37 @@ describe('literal loop reads from the captured skip-worktree review', () => {
|
||||
expect(hasTrustedSharedLibsCheck(events, loopRead.expected)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Gate 40617200857 slice 8: the ignored-path review returned all of src/retry-worker.ts through
|
||||
// cat -n, then a later `echo ===` in the same call failed, so the result carried is_error.
|
||||
describe('errored Bash reads from the captured ignored-path review', () => {
|
||||
const blocks = (events: any[]) => events.flatMap(event => event.message.content);
|
||||
const read = (events: any[]) => blocks(events).find((block: any) => block.type === 'tool_use'
|
||||
&& block.input?.command?.startsWith('cat -n src/retry-worker.ts; echo ===;'));
|
||||
const output = (events: any[], id: string) => blocks(events).find((block: any) => block.type === 'tool_result' && block.tool_use_id === id);
|
||||
|
||||
test('a whole evidence source returned before a later failure counts as reading it', () => {
|
||||
const events = structuredClone(erroredRead.events);
|
||||
expect(output(events, read(events).id).is_error).toBe(true);
|
||||
expect(hasTrustedSharedLibsCheck(events, erroredRead.expected)).toBe(true);
|
||||
});
|
||||
|
||||
test.each(['no expected sources', 'empty expected source', 'last source line missing', 'missing file', 'errored single read', 'errored native Read', 'errored checker'])('%s is still rejected', shape => {
|
||||
const events = structuredClone(erroredRead.events), call = read(events), result = output(events, call.id);
|
||||
const expected: any = structuredClone(erroredRead.expected);
|
||||
if (shape === 'no expected sources') delete expected.sources;
|
||||
if (shape === 'empty expected source') expected.sources['src/retry-worker.ts'] = '';
|
||||
if (shape === 'last source line missing') result.content = result.content.replace(/\n +15\t\}\n/, '\n');
|
||||
if (shape === 'missing file') result.content = 'Exit code 1\ncat: src/retry-worker.ts: No such file or directory';
|
||||
if (shape === 'errored single read') call.input.command = 'cat -n src/retry-worker.ts';
|
||||
if (shape === 'errored native Read') {
|
||||
call.name = 'Read';
|
||||
call.input = { file_path: 'src/retry-worker.ts' };
|
||||
}
|
||||
if (shape === 'errored checker') {
|
||||
const checker = blocks(events).find((block: any) => block.type === 'tool_use' && block.input?.command?.includes('--check-shared-libs'));
|
||||
output(events, checker.id).is_error = true;
|
||||
}
|
||||
expect(hasTrustedSharedLibsCheck(events, expected)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Train gate 40619718717 (798a37d8) slice 14, shared-libs-review-index-flags, assume-unchanged trial: while
|
||||
* gathering evidence the actor ran `git update-index --really-refresh`, which cleared the assume-unchanged flag
|
||||
* the checker reads, so the prerequisite binding went stale. The skill's evidence step now says it is read-only.
|
||||
*/
|
||||
import { afterAll, expect, test } from 'bun:test';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { generateSharedCodeReuse } from '../scripts/resolvers/review-scope';
|
||||
import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types';
|
||||
import { fixtureGit, type SharedLibsFixture } from './helpers/shared-libs-eval-fixture';
|
||||
import { checkPathReviewPrerequisites, preparePathEligibilityFixture } from './helpers/shared-libs-path-fixture';
|
||||
|
||||
const fixtures: SharedLibsFixture[] = [];
|
||||
afterAll(() => { for (const f of fixtures) fs.rmSync(f.root, { recursive: true, force: true }); });
|
||||
|
||||
test.each((['review', 'ship'] as const).flatMap(skill => (Object.keys(HOST_PATHS) as TemplateContext['host'][]).map(host => [skill, host] as const)))('%s on %s: the shared-code evidence step forbids the index refresh the captured actor ran', (skillName, host) => {
|
||||
const section = generateSharedCodeReuse({ skillName, tmplPath: '', host, paths: HOST_PATHS[host] });
|
||||
const step = section.slice(section.indexOf('1. **Read the evidence.**'), section.indexOf('2. **Run the checker.**')).replace(/\s+/g, ' ');
|
||||
expect(step).toContain('read-only: `git update-index`, even `--really-refresh`, clears the index flags the checker reads');
|
||||
});
|
||||
|
||||
test.each([
|
||||
['captured refresh', 'git update-index --really-refresh >/dev/null 2>&1; echo "(no index mutation above intended)"', false],
|
||||
['captured read-only inspection', 'git ls-files -v; git status --porcelain=v1; git hash-object --no-filters -- src/retry-route.ts; git ls-files --debug src/retry-route.ts', true],
|
||||
] as const)('%s against the assume-unchanged fixture: the prerequisite detector sees it (%s)', (_, command, unchanged) => {
|
||||
const prepared = preparePathEligibilityFixture('assume-unchanged');
|
||||
const f = prepared.fixture;
|
||||
fixtures.push(f);
|
||||
const before = checkPathReviewPrerequisites(f, prepared.resumed.input);
|
||||
expect(before.settled).toBe(true);
|
||||
expect(fixtureGit(f, 'ls-files', '-v', 'src/retry-route.ts')).toMatch(/^h /);
|
||||
execFileSync('bash', ['-c', command], { cwd: f.repo, env: { ...process.env, ...f.env }, encoding: 'utf8', timeout: 30_000 });
|
||||
const after = checkPathReviewPrerequisites(f, prepared.resumed.input);
|
||||
if (unchanged) expect(after).toEqual(before);
|
||||
else {
|
||||
expect(fixtureGit(f, 'ls-files', '-v', 'src/retry-route.ts')).toMatch(/^H /);
|
||||
expect({ current: after.current, settled: after.settled }).toEqual({ current: false, settled: false });
|
||||
}
|
||||
});
|
||||
@@ -59,6 +59,15 @@ function publishMain(fx: TrainFixture, prefixCommit: string, version: string, ex
|
||||
return repo.git('rev-parse', 'HEAD');
|
||||
}
|
||||
|
||||
// fails_when=coordinatorClone copies origin.git/objects file by file again, so an entry that appears or vanishes there mid-copy (the detached maintenance.lock every push into the fixture origin leaves) fails the clone, as in two Free Tests reds on 2026-10-28 (GSTA-139 ledger); why_new=no other test clones while origin's objects directory changes; seam=a dangling symlink planted under objects/, which a local copy refuses on every run; no_claim=which process removed the loose object those runs named
|
||||
test('the coordinator clone reads origin through git, never by copying its objects directory (GSTA-139)', () => {
|
||||
const fx = make();
|
||||
fs.symlinkSync(path.join(fx.repo.root, 'gone'), path.join(fx.repo.origin, 'objects', 'maintenance.lock'));
|
||||
const coord = coordinatorClone(fx.repo);
|
||||
expect(spawnSync('git', ['-C', coord, 'rev-parse', 'origin/main'], { encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...fx.repo.env } }).stdout.trim()).toBe(fx.repo.git('rev-parse', 'origin/main'));
|
||||
expect(fs.existsSync(path.join(coord, '.git', 'objects', 'maintenance.lock'))).toBe(false);
|
||||
});
|
||||
|
||||
describe('the train command surface', () => {
|
||||
test('train with no subcommand (flags only) prints the train help and exits usage; --help exits 0; bad arguments name what is missing', () => {
|
||||
const fx = make();
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* gstack-ship-receipt carry across rewritten history and regenerated outputs (GSTA-147 PR 1,
|
||||
* docs/ship-policy.md#receipt-carry): a rebase carries only onto a tree the receipt covers with the
|
||||
* product diff it recorded, and a base merge that changes only a registered output's diff carries when
|
||||
* the pregate for HEAD regenerated that output fresh.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { extractReceipt, planTreeCarry } from '../lib/ship-receipt';
|
||||
import { cleanup, commit, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
const receiptIn = (body: string) => extractReceipt(fs.readFileSync(body, 'utf8')).receipt!;
|
||||
const writeReceipt = (repo: FixtureRepo, body: string, extra: string[] = []) =>
|
||||
runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '7', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body, ...extra]);
|
||||
const carry = (repo: FixtureRepo, body: string, extra: string[] = [], cwd = repo.work) =>
|
||||
runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main', ...extra], {}, cwd);
|
||||
function moveMain(repo: FixtureRepo, files: Record<string, string>, message = 'main moves'): void {
|
||||
repo.git('checkout', '-q', 'main');
|
||||
for (const [rel, text] of Object.entries(files)) write(repo.work, rel, text);
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', message); repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-q', 'feature');
|
||||
}
|
||||
|
||||
describe('carry across a rebase', () => {
|
||||
// Value: protects=a rebase onto the reviewed tree keeps its receipt (Harbor paid ~60M for a byte-identical rebase);
|
||||
// fails_when=carry demands ancestry, or needs the old commit object; seam=none; no_claim=gh pr edit (--body stands in)
|
||||
test('a rebase onto the tree the receipt covers carries, from a fresh clone that never saw the old head', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" });
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
expect(writeReceipt(repo, body).status).toBe(EXIT.ok);
|
||||
const reviewed = receiptIn(body);
|
||||
expect(reviewed.head_tree).toBe(repo.git('rev-parse', 'HEAD^{tree}'));
|
||||
moveMain(repo, { 'docs/README.md': '# docs moved on main\n' });
|
||||
repo.git('merge', '-q', '--no-edit', 'origin/main');
|
||||
const merged = repo.git('rev-parse', 'HEAD');
|
||||
expect(carry(repo, body).status).toBe(EXIT.ok);
|
||||
expect(receiptIn(body)).toMatchObject({ head: merged, head_tree: repo.git('rev-parse', 'HEAD^{tree}'), carry: { ancestry: 'linear', trees: [reviewed.head_tree] } });
|
||||
|
||||
repo.git('checkout', '-q', '-B', 'feature', 'origin/main');
|
||||
repo.git('cherry-pick', reviewed.head);
|
||||
const rebased = repo.git('rev-parse', 'HEAD');
|
||||
expect(repo.git('rev-parse', 'HEAD^{tree}')).toBe(repo.git('rev-parse', `${merged}^{tree}`));
|
||||
repo.git('push', '-q', '-f', 'origin', 'feature');
|
||||
const clone = path.join(repo.root, 'clone');
|
||||
expect(spawnSync('git', ['clone', '-q', '--single-branch', '--branch', 'main', repo.origin, clone], { env: { ...process.env, ...repo.env }, timeout: 30_000 }).status).toBe(0);
|
||||
expect(spawnSync('git', ['fetch', '-q', 'origin', 'feature:feature'], { cwd: clone, env: { ...process.env, ...repo.env }, timeout: 30_000 }).status).toBe(0);
|
||||
expect(spawnSync('git', ['checkout', '-q', 'feature'], { cwd: clone, env: { ...process.env, ...repo.env }, timeout: 30_000 }).status).toBe(0);
|
||||
expect(spawnSync('git', ['cat-file', '-e', `${merged}^{commit}`], { cwd: clone, timeout: 30_000 }).status).not.toBe(0);
|
||||
|
||||
const out = carry(repo, body, [], clone);
|
||||
expect(out.status).toBe(EXIT.ok);
|
||||
expect(out.out).toContain(`CARRY: carried from ${merged.slice(0, 12)} to ${rebased.slice(0, 12)} (rebase onto a reviewed tree)`);
|
||||
expect(receiptIn(body)).toMatchObject({ head: rebased, carry: { from: reviewed.head, reason: 'base merge + rebase onto a reviewed tree', ancestry: 'rewritten', range_diff: 'tree-equal' } });
|
||||
}, 90_000);
|
||||
|
||||
// Value: protects=a rebase that changed the code takes a fresh /ship; fails_when=any non-ancestor head with a receipt
|
||||
// carries; seam=none; no_claim=-
|
||||
test('a rebase onto a different tree is RECEIPT_CARRY_TREE_MISMATCH and leaves the receipt unchanged', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const reviewed = commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" });
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
expect(writeReceipt(repo, body).status).toBe(EXIT.ok);
|
||||
repo.git('checkout', '-q', '-B', 'feature', 'origin/main');
|
||||
const head = commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 1;\n" });
|
||||
const out = carry(repo, body);
|
||||
expect(out.status).toBe(EXIT.refused);
|
||||
expect(out.out).toContain('CARRY: refused (RECEIPT_CARRY_TREE_MISMATCH)');
|
||||
expect(out.err).toContain(`HEAD's tree ${repo.git('rev-parse', 'HEAD^{tree}').slice(0, 12)} is not a tree the receipt covers`);
|
||||
expect(out.err).toContain(`run /ship on ${head.slice(0, 12)}`);
|
||||
expect(receiptIn(body).head).toBe(reviewed);
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=an identical tree on a moved base is not the reviewed diff (a stacked base closed unmerged);
|
||||
// fails_when=tree equality alone carries; seam=planTreeCarry; no_claim=-
|
||||
test('the reviewed tree on a base that moved under it is RECEIPT_CARRY_PRODUCT_CHANGED', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const reviewed = commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" });
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
expect(writeReceipt(repo, body).status).toBe(EXIT.ok);
|
||||
moveMain(repo, { 'lib/retry.js': "exports.MAX_RETRIES = 4;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\n" });
|
||||
repo.git('checkout', '-q', '-B', 'feature', 'origin/main');
|
||||
repo.git('checkout', reviewed, '--', '.');
|
||||
repo.git('commit', '-qam', 'same tree, new base');
|
||||
expect(repo.git('rev-parse', 'HEAD^{tree}')).toBe(receiptIn(body).head_tree ?? '');
|
||||
const out = carry(repo, body);
|
||||
expect(out.status).toBe(EXIT.refused);
|
||||
expect(out.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)');
|
||||
expect(out.err).toContain('has the reviewed tree, but its product diff against the current base differs');
|
||||
expect(receiptIn(body).head).toBe(reviewed);
|
||||
expect(planTreeCarry({ from: 'a'.repeat(40), head: 'b'.repeat(40), headTree: 't1', coveredTrees: ['t1'], productPatchId: { recorded: 'p1', head: 'p1' } })).toMatchObject({ ok: true, carry: { ancestry: 'rewritten' } });
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('carry across a base merge that changes a registered output', () => {
|
||||
// Value: protects=U1's rerun (+28M): a base merge that changes only a generated output's diff carries once the regen
|
||||
// check proves the output fresh; fails_when=outputs carry without regen evidence, or never carry; seam=none; no_claim=gh
|
||||
test('the output is product without a regen-fresh pregate and metadata with one', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const reviewed = commit(repo, { 'src/version.js': "module.exports = { VERSION: '1.1.0' };\n", 'generated/digest.md': '# digest v1.1.0\n' });
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
expect(writeReceipt(repo, body).status).toBe(EXIT.ok);
|
||||
moveMain(repo, {
|
||||
'gen.js': "const fs=require('fs');const {VERSION}=require('./src/version.js');fs.writeFileSync('generated/digest.md',`# Digest v${VERSION}\\n`);\n",
|
||||
'generated/digest.md': '# Digest v1.0.0\n',
|
||||
});
|
||||
spawnSync('git', ['merge', '-q', '--no-edit', 'origin/main'], { cwd: repo.work, env: { ...process.env, ...repo.env }, timeout: 30_000 });
|
||||
write(repo.work, 'generated/digest.md', '# Digest v1.1.0\n');
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'merge main; regenerate digest');
|
||||
|
||||
const bare = carry(repo, body, ['--dry-run']);
|
||||
expect(bare.status).toBe(EXIT.refused);
|
||||
expect(bare.err).toContain('generated/digest.md');
|
||||
expect(bare.out).toContain('RECEIPT_CARRY_PRODUCT_CHANGED');
|
||||
|
||||
expect(runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands']).status).toBe(EXIT.ok);
|
||||
const pregateJson = path.join(repo.work, '.gstack/tmp/pregate.json');
|
||||
const pregate = JSON.parse(fs.readFileSync(pregateJson, 'utf8'));
|
||||
const regen = pregate.checks.find((c: { id: string }) => c.id === 'regen');
|
||||
expect(regen).toMatchObject({ status: 'pass', proven: ['generated/digest.md'] });
|
||||
const tampered = path.join(repo.root, 'pregate-tampered.json');
|
||||
const label = regen.inputs.registry;
|
||||
for (const [registry, proven] of [['head (unreviewed)', ['generated/digest.md']], [label, ['generated/*']]]) {
|
||||
Object.assign(regen, { proven, inputs: { ...regen.inputs, registry } });
|
||||
fs.writeFileSync(tampered, JSON.stringify(pregate));
|
||||
const refused = carry(repo, body, ['--pregate', tampered]);
|
||||
expect(refused.out).toContain('RECEIPT_CARRY_PRODUCT_CHANGED');
|
||||
expect(refused.err).toContain('generated/digest.md');
|
||||
}
|
||||
const out = carry(repo, body, ['--pregate', pregateJson]);
|
||||
expect(out.status).toBe(EXIT.ok);
|
||||
expect(receiptIn(body)).toMatchObject({ head: repo.git('rev-parse', 'HEAD'), carry: { from: reviewed } });
|
||||
}, 120_000);
|
||||
|
||||
// Value: protects=a hand-written file under an output glob is reviewed code (red team, #3564 pass 1); fails_when=a
|
||||
// regen pass that never wrote the path sets it aside as generated; seam=none; no_claim=-
|
||||
test('a file that matches an output glob but that the generator never writes stays product', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const reviewed = commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" });
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
expect(writeReceipt(repo, body).status).toBe(EXIT.ok);
|
||||
commit(repo, { 'generated/extra.md': '# hand-written after review\n' });
|
||||
expect(runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands']).status).toBe(EXIT.ok);
|
||||
const pregateJson = path.join(repo.work, '.gstack/tmp/pregate.json');
|
||||
const regen = JSON.parse(fs.readFileSync(pregateJson, 'utf8')).checks.find((c: { id: string }) => c.id === 'regen');
|
||||
expect(regen.status).toBe('pass');
|
||||
expect(regen.proven ?? []).not.toContain('generated/extra.md');
|
||||
const out = carry(repo, body, ['--pregate', pregateJson]);
|
||||
expect(out.status).toBe(EXIT.refused);
|
||||
expect(out.out).toContain('RECEIPT_CARRY_PRODUCT_CHANGED');
|
||||
expect(out.err).toContain('generated/extra.md');
|
||||
expect(receiptIn(body).head).toBe(reviewed);
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* The /ship receipt's review depth and review-loop exception (GSTA-147 PR 2,
|
||||
* docs/review-policy.md#review-depth, #3559): write records the depth gstack
|
||||
* computes at the checked-out head, the queue recomputes the depth the head
|
||||
* needs and refuses a receipt below it, a carry rebinds the gate scan or
|
||||
* refuses when the head needs more, and the review-loop exception is checked
|
||||
* like the documentation exception.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { extractReceipt, renderReceiptBlock } from '../lib/ship-receipt';
|
||||
import { bindShippedReceipt, receiptDepthVerdict, reviewLoopExceptionLine } from '../lib/ship-receipt-depth';
|
||||
import { cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
const receiptIn = (body: string) => extractReceipt(fs.readFileSync(body, 'utf8')).receipt!;
|
||||
const writeReceipt = (repo: FixtureRepo, body: string, extra: string[] = []) =>
|
||||
runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '7', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body, ...extra]);
|
||||
const lines = (n: number) => Array.from({ length: n }, (_, i) => `exports.f${i} = () => ${i};`).join('\n') + '\n';
|
||||
const rewrite = (body: string, edit: (r: Record<string, any>) => void) => {
|
||||
const text = fs.readFileSync(body, 'utf8');
|
||||
const r = extractReceipt(text).receipt as Record<string, any>;
|
||||
edit(r);
|
||||
fs.writeFileSync(body, text.replace(/```gstack-ship-receipt[\s\S]*?\n```\n/, renderReceiptBlock(r as any)));
|
||||
return fs.readFileSync(body, 'utf8');
|
||||
};
|
||||
|
||||
describe('write records the review depth at the checked-out head', () => {
|
||||
// Value: protects=the receipt names the depth and a gate scan bound to the head it is written for, and never binds a
|
||||
// tree it did not review; fails_when=write records no depth, or --head accepts a commit other than HEAD; seam=none
|
||||
test('a light code diff records depth, runtime lines, the base and the gate scan at HEAD; --head of another commit writes nothing', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const older = commit(repo, { 'lib/util.js': lines(3) });
|
||||
const head = commit(repo, { 'lib/more.js': lines(2) });
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
const ok = writeReceipt(repo, body);
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
expect(ok.err).toContain('depth=light');
|
||||
const r = receiptIn(body);
|
||||
expect(r.gstack_version).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
|
||||
const base = repo.git('rev-parse', 'origin/main');
|
||||
expect(r.review_depth).toMatchObject({ depth: 'light', class: 'code', base, registry: base, gate_scan: { tagged: 0, head } });
|
||||
expect(r.review_depth!.runtime_lines).toBeGreaterThanOrEqual(5);
|
||||
expect(r.review_depth!.runtime_lines).toBeLessThanOrEqual(50);
|
||||
const other = path.join(repo.root, 'other.md');
|
||||
const bad = writeReceipt(repo, other, ['--head', older]);
|
||||
expect(bad.status).toBe(EXIT.fail);
|
||||
expect(bad.err).toContain(`--head ${older.slice(0, 12)} is not the checked-out HEAD ${head.slice(0, 12)}`);
|
||||
expect(bad.err).toContain('(RECEIPT_INVALID)');
|
||||
expect(fs.existsSync(other)).toBe(false);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('the queue refuses a receipt below the depth its head needs', () => {
|
||||
// Value: protects=a receipt reviewed lighter than its head needs never READYs or seats, and the refusal says what to do;
|
||||
// fails_when=the queue trusts the recorded depth, or prints no required depth, reasons and inputs; seam=bindShippedReceipt
|
||||
test('light on a standard head, a missing depth from a current writer and an unbound gate scan refuse; a legacy receipt is accepted', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const head = commit(repo, { 'lib/big.js': lines(80) });
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
expect(writeReceipt(repo, body).status).toBe(EXIT.ok);
|
||||
expect(receiptIn(body).review_depth).toMatchObject({ depth: 'standard', runtime_lines: 80 });
|
||||
const base = repo.git('rev-parse', 'origin/main').slice(0, 12);
|
||||
const bind = (text: string) => bindShippedReceipt(repo.work, 'origin/main', text, '7', [head]);
|
||||
const good = bind(fs.readFileSync(body, 'utf8'));
|
||||
expect(good.ok).toBe(true);
|
||||
expect(good.ok && good.depthLine).toContain('REVIEW_DEPTH: receipt=standard required=standard runtime_lines=80 class=code');
|
||||
|
||||
const low = bind(rewrite(body, r => { r.review_depth.depth = 'light'; }));
|
||||
expect(low.ok).toBe(false);
|
||||
if (low.ok) return;
|
||||
expect(low.code).toBe('QUEUE_RECEIPT_DEPTH_RAISED');
|
||||
expect(low.depthLine).toContain('receipt=light required=standard runtime_lines=80');
|
||||
expect(low.depthLine).toContain(`inputs=base@${base},registry@${base},gate_scan@${head.slice(0, 12)}`);
|
||||
expect(low.message).toContain('runtime=80>50');
|
||||
expect(low.message).toContain(`the receipt is unchanged; run /ship on ${head.slice(0, 12)}`);
|
||||
|
||||
const unbound = bind(rewrite(body, r => { r.review_depth.depth = 'standard'; r.review_depth.gate_scan.head = 'a'.repeat(40); }));
|
||||
expect(unbound.ok).toBe(false);
|
||||
expect(!unbound.ok && unbound.depthLine).toContain('required=full');
|
||||
expect(!unbound.ok && unbound.message).toContain('gate_scan_unavailable');
|
||||
|
||||
const none = bind(rewrite(body, r => { delete r.review_depth; }));
|
||||
expect(!none.ok && none.code).toBe('QUEUE_RECEIPT_DEPTH_RAISED');
|
||||
expect(!none.ok && none.message).toContain('records no review_depth though its writer records one');
|
||||
|
||||
const legacy = rewrite(body, r => { delete r.gstack_version; });
|
||||
const verdict = receiptDepthVerdict(repo.work, 'origin/main', extractReceipt(legacy).receipt!, head);
|
||||
expect(verdict).toEqual({ ok: true, line: 'REVIEW_DEPTH: legacy receipt (written before receipts recorded review_depth); accepted' });
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=the queue's required depth keeps the scope signals the writer used, and a gate scan binds only the full
|
||||
// bound commit; fails_when=the queue recomputes without diff-scope (auth reads light) or binds an empty or short scan head
|
||||
// by prefix; seam=bindShippedReceipt
|
||||
test('an auth path needs full at the queue though the receipt says light; an empty or short gate scan head binds nothing', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const head = commit(repo, { 'lib/auth.js': lines(3) });
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
expect(writeReceipt(repo, body).status).toBe(EXIT.ok);
|
||||
expect(receiptIn(body).review_depth).toMatchObject({ depth: 'full', class: 'gate' });
|
||||
const bind = (text: string) => bindShippedReceipt(repo.work, 'origin/main', text, '7', [head]);
|
||||
const light = bind(rewrite(body, r => { r.review_depth.depth = 'light'; }));
|
||||
expect(!light.ok && light.code).toBe('QUEUE_RECEIPT_DEPTH_RAISED');
|
||||
expect(!light.ok && light.depthLine).toContain('receipt=light required=full');
|
||||
expect(!light.ok && light.depthLine).toContain('class=gate');
|
||||
|
||||
const plain = makeFixtureRepo(); repos.push(plain);
|
||||
const plainHead = commit(plain, { 'lib/util.js': lines(3) });
|
||||
const plainBody = path.join(plain.root, 'body.md');
|
||||
expect(writeReceipt(plain, plainBody).status).toBe(EXIT.ok);
|
||||
for (const scanHead of ['', plainHead.slice(0, 12)]) {
|
||||
const r = bindShippedReceipt(plain.work, 'origin/main', rewrite(plainBody, x => { x.review_depth.gate_scan.head = scanHead; }), '7', [plainHead]);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(!r.ok && r.depthLine).toContain('required=full');
|
||||
expect(!r.ok && r.depthLine).toContain('gate_scan@missing');
|
||||
}
|
||||
}, 90_000);
|
||||
});
|
||||
|
||||
describe('carry and the review depth', () => {
|
||||
// Value: protects=a carried receipt keeps a gate scan bound to the head it now names, and a carry whose new head needs a
|
||||
// deeper review (a skipped test) is refused; fails_when=carry keeps the old scan, or carries a gate-tagged test change; seam=none
|
||||
test('a plain test change carries and rebinds the gate scan; a skipped test raises the depth and refuses the carry', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
commit(repo, { 'lib/util.js': lines(3), 'test/util.test.js': "test('adds', () => { expect(1).toBe(1); });\n" });
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
expect(writeReceipt(repo, body).status).toBe(EXIT.ok);
|
||||
expect(receiptIn(body).review_depth!.depth).toBe('light');
|
||||
const plain = commit(repo, { 'test/util.test.js': "test('adds', () => { expect(1).toBe(1); });\ntest('more', () => { expect(2).toBe(2); });\n" });
|
||||
const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
|
||||
expect(carried.status).toBe(EXIT.ok);
|
||||
expect(receiptIn(body).review_depth).toMatchObject({ depth: 'light', gate_scan: { tagged: 0, head: plain } });
|
||||
const skipped = commit(repo, { 'test/util.test.js': "test.skip('adds', () => { expect(1).toBe(1); });\ntest('more', () => { expect(2).toBe(2); });\n" });
|
||||
const refused = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
|
||||
expect(refused.status).toBe(EXIT.refused);
|
||||
expect(refused.out).toContain('CARRY: refused (RECEIPT_CARRY_REFUSED)');
|
||||
expect(refused.err).toContain(`HEAD ${skipped.slice(0, 12)} needs full review depth`);
|
||||
expect(refused.err).toContain("above the receipt's light");
|
||||
expect(receiptIn(body).head).toBe(plain);
|
||||
}, 90_000);
|
||||
|
||||
// Value: protects=a carry never binds a scan of one checkout to another commit, and never rebinds a receipt the queue would
|
||||
// refuse; fails_when=carry --head from another checkout carries a skipped test at light, or a current receipt without
|
||||
// review_depth carries; seam=none
|
||||
test('carry --head from another checkout refuses; a receipt with gstack_version but no review_depth refuses', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const reviewed = commit(repo, { 'lib/util.js': lines(3), 'test/util.test.js': "test('a', () => {});\n" });
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
expect(writeReceipt(repo, body).status).toBe(EXIT.ok);
|
||||
const skipped = commit(repo, { 'test/util.test.js': "test.skip('a', () => {});\n" });
|
||||
repo.git('checkout', '-q', reviewed);
|
||||
const r = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main', '--head', skipped]);
|
||||
expect(r.status).toBe(EXIT.refused);
|
||||
expect(r.err).toContain(`--head ${skipped.slice(0, 12)} is not the checked-out HEAD ${reviewed.slice(0, 12)}`);
|
||||
expect(receiptIn(body).head).toBe(reviewed);
|
||||
repo.git('checkout', '-q', skipped);
|
||||
rewrite(body, x => { delete x.review_depth; });
|
||||
const noDepth = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
|
||||
expect(noDepth.status).toBe(EXIT.refused);
|
||||
expect(noDepth.err).toContain('records gstack_version but no review_depth');
|
||||
}, 90_000);
|
||||
});
|
||||
|
||||
describe('the review-loop exception (#3559)', () => {
|
||||
// Value: protects=an accepted stop after a fix-check is a structured record the queue checks, not prose under REVIEW_LOOP;
|
||||
// fails_when=write accepts it without a fix-check, with a foreign head or without the body line, or the queue ignores a
|
||||
// body that dropped the line; seam=bindShippedReceipt
|
||||
test('write refuses an exception with no fix-check, a foreign head or no body line, records it otherwise, and the queue checks the line', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const head = commit(repo, { 'lib/util.js': lines(3) });
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
const exception = { accepted_by: 'Garry', date: '2026-10-28', via: 'GSTA-147 relay', reason: 'fix-check edit is a comment; scope frozen', fix_check: { head, findings: 1 } };
|
||||
const arg = (e: unknown) => ['--review-loop-exception', JSON.stringify(e)];
|
||||
const loop = (n: number) => ['--review-loop', JSON.stringify({ passes: 2, blocking_found: 1, deferred: 0, fix_checks: n })];
|
||||
fs.writeFileSync(body, '## What changed\n');
|
||||
for (const [extra, why] of [
|
||||
[[...arg(exception)], 'needs --review-loop with fix_checks >= 1'],
|
||||
[[...arg(exception), ...loop(0)], 'needs --review-loop with fix_checks >= 1'],
|
||||
[[...arg({ ...exception, fix_check: { head: 'a'.repeat(40), findings: 1 } }), ...loop(1)], 'is not ' + head.slice(0, 12) + ' or an ancestor of it'],
|
||||
[[...arg({ ...exception, extra: 1 }), ...loop(1)], 'unknown key "extra"'],
|
||||
[[...arg(exception), ...loop(1)], 'does not carry the exception line verbatim'],
|
||||
] as Array<[string[], string]>) {
|
||||
const out = writeReceipt(repo, body, extra);
|
||||
expect(out.status).toBe(EXIT.fail);
|
||||
expect(out.err).toContain('(REVIEW_LOOP_EXCEPTION_INVALID;');
|
||||
expect(out.err).toContain(why);
|
||||
}
|
||||
expect(extractReceipt(fs.readFileSync(body, 'utf8')).found).toBe(false);
|
||||
const line = reviewLoopExceptionLine(exception);
|
||||
expect(line).toBe(`Review-loop exception: accepted by Garry 2026-10-28 via GSTA-147 relay: fix-check edit is a comment; scope frozen (fix-check at ${head.slice(0, 12)}, 1 finding)`);
|
||||
fs.writeFileSync(body, `## What changed\n\n${line}\n`);
|
||||
expect(writeReceipt(repo, body, [...arg(exception), ...loop(1)]).status).toBe(EXIT.ok);
|
||||
expect(receiptIn(body).review_loop_exception).toEqual(exception);
|
||||
expect(bindShippedReceipt(repo.work, 'origin/main', fs.readFileSync(body, 'utf8'), '7', [head]).ok).toBe(true);
|
||||
const dropped = bindShippedReceipt(repo.work, 'origin/main', fs.readFileSync(body, 'utf8').replace(line, ''), '7', [head]);
|
||||
expect(!dropped.ok && dropped.code).toBe('QUEUE_RECEIPT_EXCEPTION_INVALID');
|
||||
expect(!dropped.ok && dropped.message).toContain('does not carry the review-loop exception line verbatim');
|
||||
const foreign = { ...exception, fix_check: { head: 'f'.repeat(40), findings: 1 } };
|
||||
const forged = rewrite(body, x => { x.review_loop_exception = foreign; }).replace(line, reviewLoopExceptionLine(foreign));
|
||||
const offBranch = bindShippedReceipt(repo.work, 'origin/main', forged, '7', [head]);
|
||||
expect(!offBranch.ok && offBranch.code).toBe('QUEUE_RECEIPT_EXCEPTION_INVALID');
|
||||
expect(!offBranch.ok && offBranch.message).toContain('is not the receipt head or an ancestor of it');
|
||||
}, 90_000);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"bytes": 1008,
|
||||
"why": "GSTA-147 review depth: Step 9 saves REVIEW_DEPTH/GATE_SCAN, passes --depth-floor, records depth fields and reads each reviewer's DIFF_CMD; the batched light specialist; Step 11's light note; the skeleton's Review policy paragraph names the depth and --depth-floor (trimmed to hold the skeleton cap); the pr-body section's --review-loop-exception instruction (#3559)"
|
||||
}
|
||||
@@ -16,8 +16,8 @@
|
||||
* template on demand (the compact block points to it).
|
||||
* 3. Write a fake `gbrain` shell script into workdir/bin/ with robust
|
||||
* argv quoting (printf %q) so heredoc payloads in --content survive
|
||||
* shell-to-shell. The fake logs every invocation + writes payloads
|
||||
* to a per-slug file for inspection.
|
||||
* shell-to-shell. The fake logs every invocation + writes the --content
|
||||
* or stdin page (the template's form) to a per-slug file for inspection.
|
||||
* 4. Run /office-hours via runSkillTest with workdir/bin/ first on PATH.
|
||||
* Feed a deterministic founder pitch + auto-decide instructions.
|
||||
* 5. Assert the argv log contains `gbrain put office-hours/<slug>`, the
|
||||
@@ -180,16 +180,21 @@ case "$1" in
|
||||
get_page) echo "Unknown command: get_page" >&2; exit 1 ;;
|
||||
put)
|
||||
SLUG="$2"
|
||||
PAYLOAD="${payloadDir}/$SLUG.md"
|
||||
shift 2
|
||||
while [ -n "$1" ]; do
|
||||
if [ "$1" = "--content" ]; then
|
||||
PAYLOAD_DIR="${payloadDir}"
|
||||
mkdir -p "$PAYLOAD_DIR/$(dirname "$SLUG")"
|
||||
printf '%s' "$2" > "$PAYLOAD_DIR/$SLUG.md"
|
||||
break
|
||||
mkdir -p "$(dirname "$PAYLOAD")"
|
||||
printf '%s' "$2" > "$PAYLOAD"
|
||||
exit 0
|
||||
fi
|
||||
shift
|
||||
done
|
||||
# The SAVE_RESULTS template pipes the page: gbrain put "<slug>" < "<page-file>".
|
||||
if [ -p /dev/stdin ] || [ -f /dev/stdin ]; then
|
||||
mkdir -p "$(dirname "$PAYLOAD")"
|
||||
cat > "$PAYLOAD"
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
@@ -241,7 +246,7 @@ Read pitch.md — that's a founder pitch coming to office hours. Select Startup
|
||||
|
||||
For the diagnostic, assume the founder confirmed Q1 (strongest evidence = "230 from a single tweet + 51 paying creators in 6 weeks"), Q2 (status quo = "creators write ad-hoc checks or use opaque Patreon-style platforms"), and Q3 (forcing question already asked).
|
||||
|
||||
Generate the design doc per Phase 5. The feature-slug value to substitute into the SAVE_RESULTS template's \`<feature-slug>\` placeholder is exactly 'pixel-fund' (no path prefix — the template already provides the prefix). The \`gbrain\` binary is on PATH at ${workDir}/bin/gbrain. Apply the SAVE_RESULTS template literally: the slug should land at \`<prefix>/pixel-fund\` per the resolver shape, with the actual design doc markdown body in the --content payload. Then enrich entity stubs for any named people or companies mentioned in the pitch.
|
||||
Generate the design doc per Phase 5. The feature-slug value to substitute into the SAVE_RESULTS template's \`<feature-slug>\` placeholder is exactly 'pixel-fund' (no path prefix — the template already provides the prefix). The \`gbrain\` binary is on PATH at ${workDir}/bin/gbrain. Apply the SAVE_RESULTS template literally: the slug should land at \`<prefix>/pixel-fund\` per the resolver shape, with the actual design doc markdown body as the page content. Then enrich entity stubs for any named people or companies mentioned in the pitch.
|
||||
|
||||
This is a test of the brain-writeback path. Do NOT skip the gbrain save step under any circumstance — the runtime guard ("skip if gbrain not on PATH") does NOT apply here because gbrain IS available. Do NOT explore gbrain --help; follow the SAVE_RESULTS template's exact CLI shape. If you encounter any AskUserQuestion, auto-decide recommended.`,
|
||||
workingDirectory: workDir,
|
||||
@@ -302,7 +307,7 @@ This is a test of the brain-writeback path. Do NOT skip the gbrain save step und
|
||||
throw new Error(
|
||||
`Agent called gbrain put but no payload file with 'pixel-fund' ` +
|
||||
`in name was written to ${payloadDir}. Check the fake gbrain ` +
|
||||
`--content parser for argv quoting issues.`,
|
||||
`--content and stdin page capture.`,
|
||||
);
|
||||
}
|
||||
const payload = readFileSync(payloadPath, 'utf-8');
|
||||
|
||||
@@ -42,12 +42,15 @@ const designFocusBoundary = (fp: AskUserQuestionFingerprint): boolean =>
|
||||
// on one line before Project/branch/task:), with the step's own rating in the
|
||||
// body and an option that offers all seven dimensions against a narrower one.
|
||||
// The option leads with all seven, whatever it calls them ("All 7 areas", run 39363705692).
|
||||
// Run 40664336141 put the offer in the rated title instead ("Should I review all 7 areas
|
||||
// or focus on a few?") and lettered its options ("A) Review all 7 areas"). Either place
|
||||
// carries the offer; issue-numbered options are a finding, never Step 0D.
|
||||
const lead = text.split(/\r?\n|\s+(?=Project\/branch\/task:)/, 1)[0]!;
|
||||
return /^I(?:['’]ve| have) rated this plan (?:10(?:\.0+)?|[0-9](?:\.\d+)?)\/10 on design completeness\.[\s\S]*\bWant me to focus on specific areas instead(?: of all (?:7|seven))?\?/i.test(text)
|
||||
|| (/\ball (?:7|seven) (?:design )?(?:dimensions|passes)\b/i.test(title) && /\bfocus\b/i.test(title)
|
||||
&& (/\bdesign\b/i.test(title) || rated(text.slice(title.length))) && /\?\s*$/.test(title))
|
||||
|| (/\bfocus\b/i.test(lead) && rated(text) && options.length >= 2
|
||||
&& options.some(({ label }) => /^\s*all (?:7|seven)\s+[a-z]/i.test(label)));
|
||||
|| (/\bfocus\b/i.test(lead) && rated(text) && options.length >= 2 && !issueLabeled(options)
|
||||
&& (/\ball (?:7|seven)\s+[a-z]/i.test(lead) || options.some(({ label }) => /^\s*all (?:7|seven)\s+[a-z]/i.test(label))));
|
||||
});
|
||||
|
||||
// Require a choice about the supplied UI, not a workflow offer after focus.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { JUDGE_MS, CAPTURE_MS, CAPTURE_LONG_MS, PTY_MS } from './helpers/eval-budgets';
|
||||
import { runSkillTest } from './helpers/session-runner';
|
||||
import { seedHermeticGstackHome } from './helpers/hermetic-env';
|
||||
import { resolveEvalModel } from '../lib/eval-model';
|
||||
import { EvalCollector } from './helpers/eval-store';
|
||||
import { OFFICE_HOURS_BUN_GRACE_MS, runRecordedOfficeHoursAttempt } from './helpers/office-hours-attempt';
|
||||
@@ -380,12 +381,19 @@ Focus on architecture, code quality, tests, and performance sections.`,
|
||||
describeIfSelected('Plan Review Report E2E', ['plan-review-report'], () => {
|
||||
test('/plan-eng-review writes GSTACK REVIEW REPORT to plan file', async () => {
|
||||
let planDir: string | undefined;
|
||||
let stateDir: string | undefined;
|
||||
try {
|
||||
await runRecordedOfficeHoursAttempt({
|
||||
collector: evalCollector, name: '/plan-review-report', suite: 'Plan Review Report E2E',
|
||||
model: resolveEvalModel('capture'), budgetMs: CAPTURE_LONG_MS,
|
||||
run: async signal => {
|
||||
planDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-review-report-'));
|
||||
// This case measures the report write within 20 turns. The default-on outside
|
||||
// voice (timer, Agent, TaskStop) took 4 of the 21 turns in both reds of gate
|
||||
// 40660626349, so the case uses the skill's documented off switch.
|
||||
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-review-report-state-'));
|
||||
seedHermeticGstackHome(stateDir);
|
||||
fs.appendFileSync(path.join(stateDir, 'config.yaml'), 'codex_reviews: disabled\n');
|
||||
const run = (cmd: string, args: string[]) => {
|
||||
const result = spawnSync(cmd, args, { cwd: planDir, stdio: 'pipe', timeout: 5000 });
|
||||
if (result.error || result.status !== 0) {
|
||||
@@ -445,6 +453,7 @@ plan.md is the plan file for this review session; save your review there.`,
|
||||
maxTurns: 20,
|
||||
timeout: CAPTURE_LONG_MS,
|
||||
testName: 'plan-review-report',
|
||||
env: { GSTACK_HOME: stateDir },
|
||||
runId, signal,
|
||||
model: resolveEvalModel('capture'),
|
||||
});
|
||||
@@ -496,6 +505,7 @@ plan.md is the plan file for this review session; save your review there.`,
|
||||
} finally {
|
||||
// Each configured retry owns a pristine plan and finishes cleanup first.
|
||||
if (planDir) try { fs.rmSync(planDir, { recursive: true, force: true }); } catch {}
|
||||
if (stateDir) try { fs.rmSync(stateDir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
}, CAPTURE_LONG_MS + OFFICE_HOURS_BUN_GRACE_MS);
|
||||
});
|
||||
|
||||
@@ -125,6 +125,7 @@ async function exerciseEligibility(testId: string, kinds: PathEligibilityCase[])
|
||||
directory: path.join(f.state, 'projects/fixture-shared-libs/.review-starts'),
|
||||
branch: fixtureGit(f, 'symbolic-ref', '--quiet', '--short', 'HEAD'), wtree: fixtureWorkingTree(f),
|
||||
startedAt: last.review_binding.started_at, finding: prepared.current, reusable: false,
|
||||
sources: Object.fromEntries(sourceBefore),
|
||||
coveredPaths: skipped.find((finding: any) => finding.fingerprint === prepared.current.fingerprint)?.snapshot_covered_paths,
|
||||
})).toBe(true);
|
||||
} else {
|
||||
|
||||
@@ -792,6 +792,7 @@ describeIfSelected('Cookie setup workflow quality', ['setup-browser-cookies/SKIL
|
||||
startMarker: '# Setup Browser Cookies',
|
||||
endMarker: null,
|
||||
...COOKIE_WORKFLOW_JUDGE,
|
||||
schemaTransport: true,
|
||||
readInput: () => buildCookieWorkflowJudgeInput(ROOT),
|
||||
});
|
||||
}, WORKFLOW_JUDGE_TEST_MS);
|
||||
|
||||
@@ -391,7 +391,6 @@ describe('ubi-runner run, verify and quota against a fake API and a fake ssh', (
|
||||
'cmd=${*: -1}\n' +
|
||||
'printf \'%s\\n\' "$cmd" >> "$root/ssh.log"\n' +
|
||||
'case "$cmd" in\n' +
|
||||
' *-O*) exit 0 ;;\n' +
|
||||
' *pkill*) echo "$cmd" >> "$root/cancel.log" ;;\n' +
|
||||
' *captured*) cat > "$root/captured" ;;\n' +
|
||||
' *"cat > ~/.ubirun-env"*) cat > "$root/ubirun-env" ;;\n' +
|
||||
@@ -458,6 +457,18 @@ describe('ubi-runner run, verify and quota against a fake API and a fake ssh', (
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
}, 60_000);
|
||||
|
||||
test('run: the fake ssh dispatches on the remote command, so a checkout whose name holds -O still syncs and runs (GSTA-139)', () => {
|
||||
const f = fakeVm();
|
||||
try {
|
||||
const checkout = join(f.root, 'ubi-run-O1x2y3');
|
||||
mkdirSync(checkout);
|
||||
const r = run(f, ['run', '--src', checkout, '--', 'make test']);
|
||||
expect(r.exitCode, r.stderr.toString()).toBe(0);
|
||||
expect(r.stdout.toString()).toContain('ran: ');
|
||||
expect(f.ssh()).toContain('tar -xzf - -C work/ubi-run-O1x2y3');
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
}, 60_000);
|
||||
|
||||
test('run: --pass forwards a named local variable only; UBICLOUD_* and non-names are refused before any API call', () => {
|
||||
const f = fakeVm();
|
||||
try {
|
||||
|
||||
@@ -410,7 +410,7 @@ test('Ship sends its authorized 64k cap and compact response contract through th
|
||||
expect(WORKFLOW_JUDGE_RESPONSE_SCHEMA.properties.reasoning).not.toHaveProperty('maxLength');
|
||||
expect(source.match(/compactReasoning: true/g)).toHaveLength(1);
|
||||
const f = fixture();
|
||||
const stream = spyOn(Messages.prototype, 'stream').mockReturnValue({ finalMessage: async () => ({
|
||||
const stream = spyOn(Messages.prototype, 'stream').mockReturnValue({ on() { return this; }, finalMessage: async () => ({
|
||||
stop_reason: 'end_turn', content: [{ type: 'text', text: JSON.stringify(scores) }],
|
||||
}) } as any);
|
||||
try {
|
||||
@@ -516,7 +516,7 @@ test('the structured callback rejects incomplete, schema-invalid and below-thres
|
||||
try {
|
||||
for (const response of responses) {
|
||||
const f = fixture();
|
||||
stream.mockReturnValue({ finalMessage: async () => ({ stop_reason: response.stop_reason,
|
||||
stream.mockReturnValue({ on() { return this; }, finalMessage: async () => ({ stop_reason: response.stop_reason,
|
||||
content: [{ type: 'text', text: JSON.stringify(response.value) }] }) } as any);
|
||||
const h = actualCallback(f, { judge: (prompt, model, request) => callJudge<typeof scores>(prompt, model, request) });
|
||||
await expect(h.run({ ...h.options, schemaTransport: true, compactReasoning: true, maxTokens: 65_536, stream: true })).rejects.toThrow();
|
||||
|
||||
Reference in new issue
Block a user