mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* feat(ship): GSTA-167 turn levers on the restored reviewers: ship preflight/release bins, own-jam meter, dispatch-pair metering, ship/DRIVER.md
Ports the non-reviewer parts of #3633 (gsta-167-pr34) and #3637 (gsta-167-pr5 @2914400)
onto main ae3d58c (v1.91.331.0). Reviewer briefs, checklists, the fix-check prompt and
the depth rules are byte-identical to main and now pinned in
test/review-packets-removed.test.ts.
Left out: gstack-review-packet and its libs, parallel/batched fanout, --like, collect,
the audit prompt split, the docs-audit packet, review-army/adversarial/outside-voice
edits, the gate-scan summary-only block and the review-depth scope-error change.
Packet-bytes reader pricing is dropped from gstack-ship-budget; handoff READ ranges
send each section whole; the driver runs the audit, review, adversarial and
documentation sections as written.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(context-budget): raise the capy/skill/gstack eager ceiling to its captured value after the main merge (own-jam meter, driver and reviewer-batch prose, GSTA-167)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(ship-driver): name ship/DRIVER.md.tmpl so the pregate maps the template to its test lane (GSTA-167)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(ship-driver): keep the skill generator off lib/ship-budget so a minimal setup checkout still renders (GSTA-167)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(windows): keep test/ship-release.test.ts off the Windows subset; it launches the shebang bin and sh stubs directly (GSTA-167)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(ship): variant E orchestration: one program per review pass and Step 14.5 audit, the review child starts at Step 9, a metered dispatch:docs role, and the driver's record headings (GSTA-167)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(ship): variant G orchestration: one call per driver phase (9 for a clean pass, at most 12), gstack-ship-preflight --audit for single reads and deterministic audits, an observe-mode ledger with note and credits, 240 s wait pings, and small-model audit readers (GSTA-167)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(ship-budget): a Capy handoff child needs no jam id: verify-handoff with no reading makes it parent-metered (METER: parent), it never delegates on an unknown charge, and reconcile charges the parent's counter delta (GSTA-167 replay 7e)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(ship): a poll settles a reader only at done, failed or a final summary, never a fresh idle; return settles the child's open step reservation (GSTA-167 replay 7e)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(ship-budget): return releases an open step reservation under the estimate meter instead of booking its projection; measured meters still settle it (GSTA-167 replay 7e)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(review): a clean fix-check after below-floor fixes is done; Step 11 and fix-check below-floor findings are notes at pass 1
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(ship): verbatim reviewer briefs and loss-free collection (gstack-review-dispatch render/sent/collect/judge/verify)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes
- verify requires each output's brief rendered and sent verbatim, reruns judge
against the recorded judged.jsonl hash, and flags outputs collected after judge
- judge drops self-folds, fold cycles and chains
- prose findings parse a leading label or a label heading, not any severity word
- releasePaths: manifests count as release metadata only for version-only edits,
lockfiles never; TODOS.md stays out of lane-evidence exemptions
- push stops when the sealed-output guard did not run
- ship-audit reads the test tree once instead of one git grep per file
- drop the uncalled --delegate measured mode; doc/help step-number fixes
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes (pass 2)
- collect keeps a finding wherever its label stands (headings, table rows,
mid-line, Informational and upper-case labels); only top-level list items
inherit a label heading, and any heading or bold-only line ends it
- judge treats a merged file missing any collected id as a drop; verify flags
merged_ids_missing and a sent reader never collected (NOT_COLLECTED)
- finish computes release paths after --build, so a build that rewrites
package.json is a build-input change
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(review-dispatch): verify per render, voided superseded mismatches
- verify treats each render as one dispatch of its reader, so a two-pass
ledger compares pass-1 sends with pass-1 briefs (later renders print as
<reader>@2); outputs bind to the latest send of their own render
- a mismatched send re-sent byte-exact before anything was collected is void
and counted (voided=N on the verdict line); one that produced an output
stays MISMATCH
- an indented bold sub-line no longer ends a label section (fix-check)
- docs: a failed reader is collected from its failure text
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(review-dispatch): only the newest render of a path checks its file; absolute ledger paths
- a later render into the same briefs directory overwrites the earlier brief by
design, so verify checks a brief file only for the newest render of that path
- collect and judge record absolute output, merged and judged paths, so verify
reads them from any directory
- docs: a failed reader's failure text is parsed like any output
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* chore: changelog and TODOS
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(ship-release): the changelog_fragments fragment is release metadata
Fix-check 4 (CRITICAL) after the main merge: releasePaths omitted
<changelog_fragments.dir>/<branch-slug>.md, so finish stopped on the
fragment Step 13 writes under gstack's policy, or routed it to re-review.
version now names the fragment in its Step 13 NEXT line instead of
[Unreleased], DRIVER.md Step 13 writes and lints where NEXT says, and the
preflight POLICY line says Steps 12-16.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* chore: changelog and TODOS
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>
105 lines
6.7 KiB
TypeScript
105 lines
6.7 KiB
TypeScript
/**
|
|
* ship-audit — gstack-ship-preflight --audit (GSTA-167 variant G): the parent's single
|
|
* reads and the deterministic part of /ship's non-blocking audits in one call. It writes
|
|
* the branch diff once to <out>/diff.patch, the commits to <out>/commits.txt and the
|
|
* TODOS.md items that name a touched path to <out>/todos.md, maps each changed runtime
|
|
* file to the tests that name it (Step 7), lists the plan candidates Step 8 offers, and
|
|
* prints which audit still needs a reader. The parent reads these small files and never
|
|
* TODOS.md or the diff whole; the audits' briefs stay their sections' own.
|
|
*/
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { classifyPath } from './review-depth';
|
|
import { resolveStateRoot } from './state-root';
|
|
import { bin, gh, git, hostedOrigin, Report, short } from './ship-bin-common';
|
|
|
|
const DATA_PATH = /\.(json|jsonl|ndjson|ya?ml|toml|csv|tsv|svg|png|jpe?g|gif|webp|ico|pdf)$/i;
|
|
const TEST_PATH = /(^|\/)(test|tests|__tests__|spec)\/|\.(test|spec)\.[a-z0-9]+$/i;
|
|
const TEST_SPECS = ['test', 'tests', '__tests__', 'spec', '*.test.*', '*.spec.*'];
|
|
const TODOS_HITS_MAX = 120;
|
|
|
|
const stem = (p: string) => path.basename(p).replace(/\.[^.]+$/, '');
|
|
const readOr = (file: string) => { try { return fs.readFileSync(file, 'utf8'); } catch { return ''; } };
|
|
|
|
/** TODOS.md lines naming a touched path or its file stem (a stem under 4 characters only by full path), under their nearest heading. */
|
|
export function todosHits(text: string, touched: readonly string[]): string[] {
|
|
const keys = [...new Set(touched.flatMap(p => [p, ...(stem(p).length >= 4 ? [stem(p)] : [])]))];
|
|
const out: string[] = [];
|
|
let heading = '', printed = '';
|
|
for (const line of text.split('\n')) {
|
|
if (/^#{1,6} /.test(line)) { heading = line; continue; }
|
|
if (!keys.some(k => line.includes(k))) continue;
|
|
if (heading && heading !== printed) { out.push(heading); printed = heading; }
|
|
out.push(line);
|
|
if (out.length >= TODOS_HITS_MAX) break;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Step 8's discovery: the PR body's Plan: binding, docs/designs/ files this branch changed or that name it, then personal plan files naming it. */
|
|
function planCandidates(cwd: string, fork: string, branch: string): string[] {
|
|
const found: string[] = [];
|
|
if (hostedOrigin(cwd)) {
|
|
const bound = /^\s*Plan:\s*(\S+)/m.exec(gh(cwd, ['pr', 'view', '--json', 'body', '-q', '.body']).stdout.replace(/[\r`]/g, ''))?.[1];
|
|
if (bound) found.push(`PLAN_BINDING: ${bound}`);
|
|
}
|
|
const designs = new Set([
|
|
...git(cwd, ['diff', '--name-only', '--diff-filter=AM', fork, '--', 'docs/designs/*.md']).stdout.split('\n'),
|
|
...(branch ? git(cwd, ['grep', '-l', '-F', '-e', branch, '--', 'docs/designs/*.md']).stdout.split('\n') : []),
|
|
].filter(Boolean));
|
|
for (const d of designs) found.push(`PLAN_CANDIDATE: ${d}`);
|
|
if (!branch) return found;
|
|
const slug = /^SLUG=(\S+)/m.exec(bin(cwd, 'gstack-slug', [], 30_000).stdout)?.[1] ?? path.basename(cwd).replace(/[^a-zA-Z0-9._-]/g, '');
|
|
const home = process.env.HOME ?? '';
|
|
for (const dir of [path.join(resolveStateRoot(process.env), 'projects', slug), path.join(home, '.claude/plans'), path.join(home, '.codex/plans'), path.join(cwd, '.gstack/plans')]) {
|
|
let names: string[];
|
|
try { names = fs.readdirSync(dir).filter(n => n.endsWith('.md')); } catch { continue; }
|
|
for (const n of names) if (readOr(path.join(dir, n)).includes(branch)) found.push(`PLAN_CANDIDATE: ${path.join(dir, n)}`);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
export function audit(cwd: string, base: string, out: string): Report {
|
|
const r = new Report();
|
|
const fork = git(cwd, ['merge-base', `origin/${base}`, 'HEAD']).stdout.trim() || `origin/${base}`;
|
|
fs.mkdirSync(out, { recursive: true, mode: 0o700 });
|
|
const diff = git(cwd, ['diff', fork]).stdout;
|
|
const files = git(cwd, ['diff', '--name-only', fork]).stdout.split('\n').filter(Boolean);
|
|
const commits = git(cwd, ['log', '--oneline', `origin/${base}..HEAD`]).stdout;
|
|
fs.writeFileSync(path.join(out, 'diff.patch'), diff, { mode: 0o600 });
|
|
fs.writeFileSync(path.join(out, 'commits.txt'), commits, { mode: 0o600 });
|
|
r.add(`CONTEXT: diff=${path.join(out, 'diff.patch')} bytes=${Buffer.byteLength(diff)} files=${files.length} commits=${commits.split('\n').filter(Boolean).length} log=${path.join(out, 'commits.txt')}`);
|
|
|
|
if (!fs.existsSync(path.join(cwd, 'TODOS.md'))) r.add('TODOS: none (no TODOS.md)');
|
|
else {
|
|
const text = readOr(path.join(cwd, 'TODOS.md'));
|
|
const hits = todosHits(text, files);
|
|
fs.writeFileSync(path.join(out, 'todos.md'), hits.join('\n') + (hits.length ? '\n' : ''), { mode: 0o600 });
|
|
r.add(`TODOS: bytes=${Buffer.byteLength(text)} hits=${hits.filter(l => !/^#{1,6} /.test(l)).length} file=${path.join(out, 'todos.md')} (Steps 8.2 and 14 read this file, never TODOS.md whole)`);
|
|
}
|
|
|
|
const tests = files.filter(p => TEST_PATH.test(p)).map(p => ({ p, text: readOr(path.join(cwd, p)) }));
|
|
const runtime = files.filter(p => !TEST_PATH.test(p) && !DATA_PATH.test(p) && fs.existsSync(path.join(cwd, p)) && classifyPath(p, cwd) === 'code');
|
|
// One read of the tracked test tree for every changed file, instead of one full-tree git grep per file.
|
|
const testTree = runtime.length ? git(cwd, ['ls-files', '-z', '--', ...TEST_SPECS]).stdout.split('\0').filter(Boolean).map(f => readOr(path.join(cwd, f))) : [];
|
|
const map = runtime.map(p => ({
|
|
p,
|
|
changed: tests.filter(t => t.p.includes(stem(p)) || t.text.includes(stem(p))).length,
|
|
referencing: testTree.filter(text => text.includes(stem(p))).length,
|
|
}));
|
|
const mapFile = path.join(out, 'coverage.txt');
|
|
fs.writeFileSync(mapFile, map.map(m => `${m.p} changed_tests=${m.changed} referencing_tests=${m.referencing}\n`).join(''), { mode: 0o600 });
|
|
for (const m of map.slice(0, 8)) r.add(`COVERAGE_MAP: ${m.p} changed_tests=${m.changed} referencing_tests=${m.referencing}`);
|
|
const untested = map.filter(m => m.changed === 0).map(m => m.p);
|
|
r.add(!runtime.length ? 'Coverage: skipped (no runtime file changed; no audit reader)'
|
|
: untested.length ? `Coverage: ${untested.length} of ${runtime.length} changed runtime files have no changed test naming them (${short(untested, 4)}); map=${mapFile}`
|
|
: `Coverage: deterministic (each of ${runtime.length} changed runtime files has a changed test naming it; no audit reader); map=${mapFile}`);
|
|
|
|
const branch = git(cwd, ['branch', '--show-current']).stdout.trim().replace(/\//g, '-').replace(/[^a-zA-Z0-9._-]/g, '');
|
|
const plans = planCandidates(cwd, fork, branch);
|
|
for (const line of plans.slice(0, 5)) r.add(line);
|
|
if (!plans.length) r.add('Plan completion audit: not run (no plan is bound to this branch and no docs/designs/ file matches). Fix: add "Plan: <path>" to the PR body, or run /autoplan.');
|
|
r.add(`READER: coverage=${untested.length ? 'needed' : 'none'} plan=${plans.length ? 'needed' : 'none'}`);
|
|
return r;
|
|
}
|