mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 07:18:03 +01:00
v1.93.40.0 fix(ship): Step 9.2.1 QA reports go to a git-excluded run dir, not the product tree (GST-969) (#3898)
* fix(ship): Step 9.2.1 QA writes reports under a git-excluded run dir, not the product tree (GST-969) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: harden report-dir (linked .gstack, .gitignore negation, error cause) per Step 11 voices Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: CHANGELOG fragment for GST-969 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: move the QA report-dir fallback into the shared exploratory section (review-army.md stays at its no-loss pin); classify qa-reports for gstack-clean Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
d4bef0a6a3
commit
e4e9913398
9 files changed
+153
-6
No files matched your search
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## [1.93.40.0] - 2026-11-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- **/ship and /review exploratory QA no longer write reports into your project (GST-969).** /ship Step 9.2.1 and /review Step 4.7 used to put its QA reports, deadline file and captured evidence in `.gstack/qa-reports/` inside the repository under review. In a project that doesn't ignore `.gstack/`, those files showed up as untracked changes, so they could land in the PR and change the tree the review certified. The new `gstack-qa-evidence report-dir` command creates a fresh run directory under `.gstack/tmp/qa-reports/` and adds `/.gstack/tmp/` to the repository's git exclude file, and both QA steps now use it. It refuses a symlinked `.gstack`, a `.gitignore` rule that un-ignores `.gstack/tmp`, and a directory outside a git repository. /qa keeps its `.gstack/qa-reports` output directory.
|
||||
|
||||
## [1.93.39.0] - 2026-11-03
|
||||
|
||||
**/ship on Capy stops an outside voice that hangs and records it as missing, never as converged or as an unavailable Codex CLI (GST-1104).** TRUT-7's /ship run had two outside voices end with no result (gpt-6.1-sol stopped after 20 minutes; grok-4.7 used 16.9M tokens before it was stopped), and its Step 11 row read `outside_provider: codex`, `outside_status: unavailable`, `converged: true`.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.93.39.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.93.40.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
+37
-1
@@ -388,7 +388,37 @@ function materialize(root: string, source: string) {
|
||||
next: `Include every reportLinks entry in the Markdown report, and report the overall status as ${verdict.status}${verdict.open.length ? ` (open: ${verdict.open.join('; ')})` : ''}; this verdict is final. Next, write the Markdown report beside evidence.json.` };
|
||||
}
|
||||
|
||||
const QA_EVIDENCE_USAGE = 'capture ROOT ID [--public] --deadline FILE|--timeout-ms MS [--after PREVIOUS_CAPTURE --hypothesis TEXT] -- COMMAND ARGS (--after publishes checkpoint ID linking PREVIOUS_CAPTURE to this probe; required after the first complete capture unless a checkpoint was published) | checkpoint ROOT ID CAPTURE OBSERVATION_COMMAND HYPOTHESIS NEXT_COMMAND | checkpoint ROOT ID INTENT_FILE | materialize ROOT ANNOTATIONS (annotations: {evidence: [{capture, command, contract, expected, classification: pass|superseded|product-defect|fail|setup-blocked|blocked|inconclusive}], limits: [..]}; revision, runtime, cwd and learning are filled in)';
|
||||
/** A fresh report root for caller QA (/review and /ship) under the repository's git-excluded .gstack/tmp, so probes never change the reviewed tree. */
|
||||
function reportDir(): Record<string, any> {
|
||||
const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 5000 });
|
||||
if (top.status !== 0 || !top.stdout.trim()) throw new QaEvidenceError('report-dir needs a git work tree');
|
||||
const repoRoot = fs.realpathSync(top.stdout.trim());
|
||||
const exclude = spawnSync('git', ['rev-parse', '--git-path', 'info/exclude'], { encoding: 'utf8', timeout: 5000 });
|
||||
if (exclude.status !== 0 || !exclude.stdout.trim()) throw new QaEvidenceError('report-dir cannot locate the git exclude file');
|
||||
const excludeFile = path.resolve(exclude.stdout.trim());
|
||||
fs.mkdirSync(path.dirname(excludeFile), { recursive: true });
|
||||
const excluded = fs.existsSync(excludeFile) ? fs.readFileSync(excludeFile, 'utf8') : '';
|
||||
if (!excluded.split(/\r?\n/).includes('/.gstack/tmp/')) fs.appendFileSync(excludeFile, `${excluded && !excluded.endsWith('\n') ? '\n' : ''}/.gstack/tmp/\n`);
|
||||
const parent = path.join(repoRoot, '.gstack', 'tmp', 'qa-reports');
|
||||
for (const dir of [path.join(repoRoot, '.gstack'), path.dirname(parent), parent]) {
|
||||
try {
|
||||
if (fs.lstatSync(dir).isSymbolicLink()) throw new QaEvidenceError(`report-dir refuses the linked ${path.relative(repoRoot, dir)}`);
|
||||
} catch (error: any) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
fs.mkdirSync(dir, { mode: 0o700 });
|
||||
}
|
||||
}
|
||||
const ignored = spawnSync('git', ['check-ignore', '-q', path.join(parent, 'report.md')], { cwd: repoRoot, encoding: 'utf8', timeout: 5000 });
|
||||
if (ignored.status !== 0) throw new QaEvidenceError('report-dir: .gstack/tmp is not git-ignored here (a .gitignore rule overrides info/exclude)');
|
||||
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
||||
for (let n = 1; ; n++) {
|
||||
const dir = path.join(parent, `run-${stamp}${n > 1 ? `-${n}` : ''}`);
|
||||
try { fs.mkdirSync(dir, { mode: 0o700 }); } catch (error: any) { if (error?.code === 'EEXIST') continue; throw error; }
|
||||
return { action: 'report-dir', status: 'complete', reportDir: qaEvidenceRoot(dir), exitCode: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
const QA_EVIDENCE_USAGE = 'report-dir (prints a fresh git-excluded report root for /review and /ship QA) | capture ROOT ID [--public] --deadline FILE|--timeout-ms MS [--after PREVIOUS_CAPTURE --hypothesis TEXT] -- COMMAND ARGS (--after publishes checkpoint ID linking PREVIOUS_CAPTURE to this probe; required after the first complete capture unless a checkpoint was published) | checkpoint ROOT ID CAPTURE OBSERVATION_COMMAND HYPOTHESIS NEXT_COMMAND | checkpoint ROOT ID INTENT_FILE | materialize ROOT ANNOTATIONS (annotations: {evidence: [{capture, command, contract, expected, classification: pass|superseded|product-defect|fail|setup-blocked|blocked|inconclusive}], limits: [..]}; revision, runtime, cwd and learning are filled in)';
|
||||
|
||||
export async function qaEvidenceMain(args: string[]): Promise<number> {
|
||||
return withQaReceiptOutput(false, 'qa-evidence-receipt', value => value.event === 'observation'
|
||||
@@ -400,6 +430,12 @@ export async function qaEvidenceMain(args: string[]): Promise<number> {
|
||||
emit('stdout', { action: 'help', status: 'complete', usage: QA_EVIDENCE_USAGE, exitCode: 0 });
|
||||
return 0;
|
||||
}
|
||||
if (action === 'report-dir' && args.length === 1) {
|
||||
let created: Record<string, any>;
|
||||
try { created = reportDir(); } catch (error: any) { throw error instanceof QaEvidenceError ? error : new QaEvidenceError(`report-dir failed: ${error?.code ?? 'unknown error'}`); }
|
||||
emit('stdout', created);
|
||||
return 0;
|
||||
}
|
||||
const root = qaEvidenceRoot(reportRoot);
|
||||
let receipt: Record<string, any>;
|
||||
const publicOutput = action === 'capture' && rest[1] === '--public';
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.93.39",
|
||||
"version": "1.93.40",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -19,7 +19,7 @@ Missing or unreadable assets, prerequisites or permission block affected probes,
|
||||
|
||||
## 1. Charter and preflight
|
||||
|
||||
Reuse resolved REPORT_DIR; otherwise own a fresh `.gstack/qa-reports` subdirectory.
|
||||
Reuse resolved REPORT_DIR; otherwise use the `reportDir` `EVIDENCE_TOOL report-dir` prints.
|
||||
Write a **charter** per behavior: contract, risk, entrypoint, isolation, exit condition, source, commands and inputs. Save charters as Markdown in the report.
|
||||
|
||||
For /review and /ship, no plan/server is required.
|
||||
|
||||
@@ -66,7 +66,7 @@ full contents. If either required Read is missing, complete it now before Charte
|
||||
|
||||
## 1. Charter and preflight
|
||||
|
||||
Reuse resolved REPORT_DIR; otherwise own a fresh \`.gstack/qa-reports\` subdirectory.
|
||||
${reportOnly ? 'Reuse resolved REPORT_DIR; otherwise own a fresh \`.gstack/qa-reports\` subdirectory.' : 'Reuse resolved REPORT_DIR; otherwise use the \`reportDir\` \`EVIDENCE_TOOL report-dir\` prints.'}
|
||||
Write a **charter** per behavior: contract, risk, entrypoint, isolation, exit condition, source, commands and inputs. Save charters as Markdown in the report.
|
||||
|
||||
${reportOnly ? '' : `For /review and /ship, no plan/server is required.
|
||||
|
||||
@@ -42,6 +42,7 @@ const KEPT: Record<string, string> = {
|
||||
'review-*': '/ship review loop records',
|
||||
'fix-check-*': '/ship fix-check inputs',
|
||||
'tasks-*': 'gstack-tasks rows files (bin/gstack-tasks)',
|
||||
'qa-reports': 'caller QA report runs from gstack-qa-evidence report-dir (lib/qa-evidence.ts)',
|
||||
'autoplan': 'autoplan phase artifacts (autoplan/SKILL.md.tmpl)',
|
||||
'eval-bisect': '/investigate eval bisect cache',
|
||||
'price-gate': 'gstack-eval-plan price receipts',
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { afterAll, 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 { RESOLVERS } from '../scripts/resolvers';
|
||||
import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types';
|
||||
|
||||
const BIN = path.resolve(import.meta.dir, '../bin');
|
||||
const ROOT = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'qa-report-dir-'));
|
||||
afterAll(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
|
||||
const IDENTITY = { GIT_AUTHOR_NAME: 'fixture', GIT_AUTHOR_EMAIL: 'fixture@example.com', GIT_COMMITTER_NAME: 'fixture', GIT_COMMITTER_EMAIL: 'fixture@example.com' };
|
||||
|
||||
function product() {
|
||||
const root = fs.mkdtempSync(path.join(ROOT, 'product-'));
|
||||
const git = (...args: string[]) => {
|
||||
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 10_000, env: { ...process.env, ...IDENTITY } });
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
return r.stdout;
|
||||
};
|
||||
git('init', '-q');
|
||||
fs.writeFileSync(path.join(root, 'app.ts'), 'export const x = 1;\n');
|
||||
git('add', 'app.ts');
|
||||
git('commit', '-q', '-m', 'seed');
|
||||
const tool = (name: string, ...args: string[]) => {
|
||||
const r = spawnSync(process.execPath, [path.join(BIN, name), ...args], { cwd: root, encoding: 'utf8', timeout: 15_000 });
|
||||
expect(r.error, r.error?.message).toBeUndefined();
|
||||
return r;
|
||||
};
|
||||
const tree = () => git('status', '--porcelain', '--untracked-files=all', '--ignored=no') + git('ls-files', '--others', '--exclude-standard');
|
||||
return { root, tool, tree };
|
||||
}
|
||||
|
||||
function reportDir(f: ReturnType<typeof product>): string {
|
||||
const r = f.tool('gstack-qa-evidence', 'report-dir');
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
return JSON.parse(r.stdout.trim().slice('QA_EVIDENCE '.length)).reportDir;
|
||||
}
|
||||
|
||||
test('the /ship QA report root leaves a product tree without a .gstack ignore rule unchanged', () => {
|
||||
const f = product();
|
||||
expect(f.tree()).toBe('');
|
||||
const dir = reportDir(f);
|
||||
expect(path.relative(f.root, dir).split(path.sep).slice(0, 3)).toEqual(['.gstack', 'tmp', 'qa-reports']);
|
||||
const deadline = path.join(dir, 'deadline.json');
|
||||
expect(f.tool('gstack-qa-deadline', 'start', deadline, '300').status).toBe(0);
|
||||
const capture = f.tool('gstack-qa-evidence', 'capture', dir, '001', '--public', '--deadline', deadline, '--', process.execPath, '-e', 'console.log(JSON.stringify({ ok: true }))');
|
||||
expect(capture.status, capture.stderr).toBe(0);
|
||||
fs.writeFileSync(path.join(dir, 'report.md'), '## Exploratory QA\n');
|
||||
expect(f.tree()).toBe('');
|
||||
});
|
||||
|
||||
test('each report-dir call owns a fresh run so a second QA run never overwrites the first', () => {
|
||||
const f = product();
|
||||
const first = reportDir(f);
|
||||
const second = reportDir(f);
|
||||
expect(second).not.toBe(first);
|
||||
expect(fs.readdirSync(first)).toEqual([]);
|
||||
expect(fs.readdirSync(second)).toEqual([]);
|
||||
});
|
||||
|
||||
test('report-dir refuses a linked .gstack instead of creating directories through it', () => {
|
||||
const f = product();
|
||||
const elsewhere = fs.mkdtempSync(path.join(ROOT, 'elsewhere-'));
|
||||
fs.symlinkSync(elsewhere, path.join(f.root, '.gstack'));
|
||||
const r = f.tool('gstack-qa-evidence', 'report-dir');
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('report-dir refuses the linked .gstack');
|
||||
expect(fs.readdirSync(elsewhere)).toEqual([]);
|
||||
});
|
||||
|
||||
test('report-dir refuses when a product .gitignore un-ignores .gstack/tmp', () => {
|
||||
const f = product();
|
||||
fs.writeFileSync(path.join(f.root, '.gitignore'), '!/.gstack/tmp/\n!/.gstack/tmp/**\n');
|
||||
const r = f.tool('gstack-qa-evidence', 'report-dir');
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('.gstack/tmp is not git-ignored here');
|
||||
});
|
||||
|
||||
test('report-dir names the filesystem cause when .gstack is not a directory', () => {
|
||||
const f = product();
|
||||
fs.writeFileSync(path.join(f.root, '.gstack'), 'not a directory\n');
|
||||
const r = f.tool('gstack-qa-evidence', 'report-dir');
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('report-dir failed: ENOTDIR');
|
||||
});
|
||||
|
||||
test('report-dir refuses outside a git work tree instead of guessing a location', () => {
|
||||
const outside = fs.mkdtempSync(path.join(ROOT, 'plain-'));
|
||||
const r = spawnSync(process.execPath, [path.join(BIN, 'gstack-qa-evidence'), 'report-dir'], { cwd: outside, encoding: 'utf8', timeout: 15_000, env: { ...process.env, GIT_CEILING_DIRECTORIES: ROOT } });
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('report-dir needs a git work tree');
|
||||
expect(fs.readdirSync(outside)).toEqual([]);
|
||||
});
|
||||
|
||||
test('caller QA without a resolved REPORT_DIR (/review, /ship) falls back to report-dir, not the product tree', () => {
|
||||
const line = 'Reuse resolved REPORT_DIR; otherwise use the `reportDir` `EVIDENCE_TOOL report-dir` prints.';
|
||||
const ctx: TemplateContext = { host: 'claude', skillName: 'qa', tmplPath: '', paths: HOST_PATHS.claude };
|
||||
expect(RESOLVERS.QA_EXPLORATORY(ctx)).toContain(line);
|
||||
expect(fs.readFileSync(path.resolve(import.meta.dir, '../qa/sections/exploratory.md'), 'utf8')).toContain(line);
|
||||
expect(fs.readFileSync(path.resolve(import.meta.dir, '../qa/SKILL.md.tmpl'), 'utf8')).toContain('Use the directory as `REPORT_DIR`');
|
||||
});
|
||||
Reference in new issue
Block a user