mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* wip(GSTA-131): review depth, fix-check transitions, dispatch counters, carry pregate, autoplan round policy Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * wip(GSTA-131): reviewers stay at full effort; audits light Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(ship): Step 9 dispatches what gstack-review-plan plans; fix-check after below-floor fixes; cap two full passes plus one fix-check Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: review-dispatch-compare has a free lane; review-depth fixtures registered free; resumed max_rounds case off the old default Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(GSTA-131): ship review pass-1 repairs: fix-check binds under its own skill, Step 11.5 accepts a fix-check or a planned adversarial skip, delta carry with a pregate, carry --pregate pinned to the checkout, quiet-after-cap revise regates, skill-dir markdown and rule docs classed code, gate signals from tests class up, plan fails closed on git errors, per-class compare ratios Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(GSTA-131): ship review pass-2 repairs: Step 11 follows the last full-pass plan, Important Rules restored, ship goldens regenerated, true per-class medians, review-policy transitions and once-per-invocation voices, docs/** assets stay docs-class, CLI and fix-check binding tests Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): GSTA-131 review depth, fix-check, delta /ship and autoplan rounds Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: ship-policy cap and counters, review-policy /review cycle cap, unattended passes (docs audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): 400-word findings cap fits the /review size pins (five host pins lowered by 2); CHANGELOG max_rounds wording Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): gate integrity names the size-ledger entry and the lowered /review pins Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: the round-budget case asserts the default through ROUNDS output only (pregate literals heuristic) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: curate ship-receipt-carry-pregate out of the Windows lane like its --pregate siblings Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): end the Windows curation sentence instead of a mid-sentence colon Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): one decision each for the review-loop exits, the planned adversarial skip and Step 16's TODOS rerun (screen judge repair) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): fix-check repairs: pin the renamed Step 11 heading, keep the below-floor exit at the cap, name the plan run a repeat reuses, print the native skip on outside-only plans 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>
158 lines
12 KiB
TypeScript
158 lines
12 KiB
TypeScript
/**
|
|
* autoplan-export — the B0 file set written beside the active plan (plan
|
|
* B2): `plan.md` (the `## URGENT, outside this plan` block above the
|
|
* `## Implementation plan` section; warns above 30 KB), `review-record.md`
|
|
* (the `## Review record` section plus the run log: phases, voices, consensus
|
|
* tables, the gate and the GUARD_NOT_INSTALLED line the validator requires),
|
|
* `findings.jsonl`, `tasks.jsonl`, `decisions.jsonl` (already written by the
|
|
* gate), `timing.json`, and `run.json` with every artifact's path and hash
|
|
* and the counts `gstack-artifact validate` checks against the files, plus
|
|
* the reviewer dispatch counts and their relative input-token estimate. The
|
|
* snapshot bin stays unchanged: this is `gstack-autoplan export --out <dir>`.
|
|
*/
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { PHASES } from './autoplan-prompts';
|
|
import type { ConsensusRecord } from './autoplan-reconcile';
|
|
import { decisionId } from './gate-list';
|
|
import { readDecisions, readJournal, voiceRows, type DispatchCounts, type RunState, type Voice } from './autoplan-state';
|
|
import type { FindingRow, TaskRow } from './headless-artifacts';
|
|
import { readJsonl, renderUrgentBlock, sha256File } from './headless-artifacts';
|
|
|
|
export const PLAN_WARN_BYTES = 30_000;
|
|
|
|
export function buildTasks(state: RunState, findings: FindingRow[]): TaskRow[] {
|
|
const tasks: TaskRow[] = [];
|
|
const covered = new Set<string>();
|
|
let n = 0;
|
|
for (const f of findings as Array<FindingRow & { settled_by?: string }>) {
|
|
if (f.settled_by || covered.has(f.id)) continue;
|
|
if (f.disposition === 'deferred') {
|
|
covered.add(f.id);
|
|
tasks.push({ schema_version: 2, run: state.run, id: `${state.run}-T${++n}`, title: `deferred from ${f.phase}: ${f.title}`, status: 'pending', depends_on: [], findings: [f.id],
|
|
blocked_by: [decisionId(state.run, state.gate_rev ?? 1, 'p1')], acceptance: f.resolution ? f.resolution : `the build resolves: ${f.title}`, priority: f.severity, ...(f.file ? { files: [f.file] } : {}), item: f.phase });
|
|
continue;
|
|
}
|
|
if (!['Critical', 'High'].includes(f.severity) || f.disposition === 'rejected') continue;
|
|
const twin = typeof f.native_counterpart === 'string' ? f.native_counterpart : findings.find(x => x.native_counterpart === f.id)?.id;
|
|
const ids = twin ? [f.id, twin] : [f.id];
|
|
ids.forEach(id => covered.add(id));
|
|
tasks.push({ schema_version: 2, run: state.run, id: `${state.run}-T${++n}`, title: f.title, status: 'blocked_on_decision', depends_on: [], findings: ids,
|
|
blocked_by: [decisionId(state.run, state.gate_rev ?? 1, 'p1')], acceptance: f.resolution ? f.resolution : `reviewer re-verifies: ${f.title}`, priority: f.severity, ...(f.file ? { files: [f.file] } : {}), item: f.phase });
|
|
}
|
|
return tasks;
|
|
}
|
|
|
|
/** Aggregate findings across phases and voices, carrying the reconciliation into each row. */
|
|
export function aggregateFindings(state: RunState): FindingRow[] {
|
|
const out: FindingRow[] = [];
|
|
for (const phase of state.phase_order) {
|
|
const ps = state.phases[phase];
|
|
if (!ps?.closed_at) continue;
|
|
const consensus = JSON.parse(fs.readFileSync(path.join(state.out, `${phase}-consensus.json`), 'utf8')) as ConsensusRecord;
|
|
const byOutside = new Map(consensus.rows.map(r => [r.outside_id, r]));
|
|
// A phase closed accepted-with-findings: its open rows (and the rows it carried from the round last reviewed) are deferred to the build.
|
|
const deferred = (row: FindingRow) => (ps.deferred && !(row as { settled_by?: string }).settled_by ? { disposition: 'deferred' as const } : {});
|
|
for (const row of voiceRows(state, phase, 'native')) out.push({ ...row, native_counterpart: null, ...deferred(row) });
|
|
for (const row of voiceRows(state, phase, 'outside')) {
|
|
const r = byOutside.get(row.id);
|
|
out.push({ ...row, native_counterpart: r?.disposition === 'unverified' ? null : r?.native_id ?? null, ...(r ? { reconciliation: r.disposition, reconciliation_resolution: r.resolution } : {}), ...deferred(row) } as FindingRow);
|
|
}
|
|
const carried = ps.deferred?.carried_file && path.join(state.out, ps.deferred.carried_file);
|
|
if (carried && fs.existsSync(carried)) out.push(...(readJsonl(carried).rows as unknown as FindingRow[]).map(row => ({ ...row, native_counterpart: null, disposition: 'deferred' as const })));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
|
|
export function sections(activePlan: string): { implementation: string; record: string } {
|
|
const text = fs.readFileSync(activePlan, 'utf8');
|
|
const impl = text.indexOf('\n## Implementation plan');
|
|
const rec = text.indexOf('\n## Review record');
|
|
if (impl < 0 || rec < 0 || rec < impl) return { implementation: text, record: '' };
|
|
const implStart = text.indexOf('\n', impl + 1) + 1;
|
|
const recStart = text.indexOf('\n', rec + 1) + 1;
|
|
return { implementation: text.slice(implStart, rec + 1), record: text.slice(recStart) };
|
|
}
|
|
|
|
function runLog(state: RunState, findings: number, tasks: number, decisions: { total: number; pending: number }): string {
|
|
const lines = [
|
|
`### gstack-autoplan run log (run ${state.run}, session_kind ${state.session_kind}, status ${state.status})`,
|
|
'',
|
|
`Host: ${state.host}. Scope: UI ${state.scope.ui ? 'yes' : 'no'} (${state.scope.ui_matches} matches); DX ${state.scope.dx ? 'yes' : 'no'} (${state.scope.dx_matches} term matches${state.scope.developer_tool ? ', developer tool' : ''}${state.scope.agent_primary ? ', agent primary' : ''}). Phase order: ${state.phase_order.join(' → ')}.`,
|
|
...(state.deviations ?? []).map(d => `Deviation: ${d}`),
|
|
'',
|
|
];
|
|
for (const phase of PHASES) {
|
|
const ps = state.phases[phase];
|
|
if (!ps) { lines.push(`#### ${phase}: not reached`, ''); continue; }
|
|
if (ps.skipped) { lines.push(`#### ${phase}: skipped (${ps.skipped})`, ''); continue; }
|
|
const voice = (v: 'native' | 'outside') => { const s = ps[v]; return `${v} ${s?.status ?? 'pending'}${s?.model ? ` (model ${s.model}${s.provider ? `, provider ${s.provider}` : ''}, ${s.findings ?? 0} findings, sha256 ${(s.result_sha256 ?? '').slice(0, 12)})` : ''}`; };
|
|
lines.push(`#### ${phase}: ${ps.closed_at ? `closed ${ps.closed_at}` : ps.snapshot ? 'open' : 'pending'}; snapshot ${ps.snapshot ?? '—'}`, '', `- ${voice('native')}`, `- ${voice('outside')}`, '');
|
|
const consensus = path.join(state.out, `${phase}-consensus.md`);
|
|
if (fs.existsSync(consensus)) lines.push('```', fs.readFileSync(consensus, 'utf8').trimEnd(), '```', '');
|
|
}
|
|
for (const h of state.history) lines.push(`Reopened ${h.phase} at gate_rev ${h.gate_rev}: ${h.reason} (${h.at})`);
|
|
if (state.history.length) lines.push('');
|
|
const budget = [
|
|
...state.phase_order.flatMap(p => { const d = state.phases[p]?.deferred; return d ? [`Deferred ${p} at round ${d.round} (${d.cause}, by ${d.by}): ${d.reason}; ${d.open} open finding(s) carried into tasks.jsonl; owner items: ${d.blockers.join(', ') || 'none'}`] : []; }),
|
|
...(state.capped_deltas ?? []).map(d => `Plan changed after the ${d.phase} cap (revise v${d.k}, round ${d.round} of ${d.cap}): +${d.added} -${d.removed} lines unreviewed; ${d.status}`),
|
|
...[...(state.stops ?? []), ...(state.stop ? [state.stop] : [])].map(s => `Stopped ${s.reason} at ${s.at}: cap ${s.cap_usd ?? 'none'}, spent ${s.spent}, reserved ${s.reserved}, unknown ${s.unknown}${s === state.stop ? '' : ' (lifted)'}`),
|
|
];
|
|
if (budget.length) lines.push(`Budget: max_rounds ${state.max_rounds ?? 'none'}${state.round_grants ? `, grants ${JSON.stringify(state.round_grants)}` : ''}.`, ...budget, '');
|
|
lines.push(`Gate: ${state.gate_rev ? `gate_rev ${state.gate_rev}, ${decisions.total} decisions (${decisions.pending} pending)${state.gate && typeof (state.gate as any).reply === 'string' ? `; answered "${(state.gate as any).reply}"${typeof (state.gate as any).answered_by === 'string' ? ` answered by ${(state.gate as any).answered_by}` : ''}` : ''}` : 'not reached'}. Findings: ${findings}. Tasks: ${tasks}.`);
|
|
return lines.join('\n') + '\n';
|
|
}
|
|
|
|
export interface ExportResult { warnings: string[]; files: string[] }
|
|
|
|
/** Every dispatched attempt in attempts.jsonl, whatever became of it; prompt bytes come from the row (older rows: the prompt file if still on disk). */
|
|
export function dispatchStats(out: string): { dispatches: DispatchCounts; est_input_tokens: number } {
|
|
const dispatches: DispatchCounts = { total: 0, by_phase: {} };
|
|
let bytes = 0;
|
|
for (const r of readJournal(out)) {
|
|
if (r.event !== 'dispatched') continue;
|
|
const by = (dispatches.by_phase[r.phase] ??= {});
|
|
by[r.voice as Voice] = (by[r.voice as Voice] ?? 0) + 1;
|
|
dispatches.total += 1;
|
|
const file = typeof r.prompt === 'string' ? path.resolve(out, r.prompt) : '';
|
|
bytes += typeof r.prompt_bytes === 'number' ? r.prompt_bytes : file && fs.existsSync(file) ? fs.statSync(file).size : 0;
|
|
}
|
|
return { dispatches, est_input_tokens: Math.round(bytes / 4) };
|
|
}
|
|
export const dispatchLine = (state: RunState) => `DISPATCHES: ${state.dispatches?.total ?? 0} est_input_tokens=${state.est_input_tokens ?? 0} (relative estimate)`;
|
|
|
|
/** Write the file set and bind every artifact into run.json (counts and hashes); the caller writes run.json. */
|
|
export function exportRun(state: RunState): ExportResult {
|
|
const warnings: string[] = [];
|
|
const { implementation, record } = sections(state.active_plan);
|
|
const findings = aggregateFindings(state);
|
|
const tasks = buildTasks(state, findings);
|
|
const decisions = readDecisions(state.out);
|
|
const write = (name: string, body: string) => {
|
|
const file = path.join(state.out, name);
|
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
fs.writeFileSync(tmp, body);
|
|
fs.renameSync(tmp, file);
|
|
state.artifacts[name] = { path: name, sha256: sha256File(file) };
|
|
return file;
|
|
};
|
|
const planBody = `${renderUrgentBlock(findings as unknown as Record<string, unknown>[])}\n## Implementation plan\n${implementation}`;
|
|
if (Buffer.byteLength(planBody) > PLAN_WARN_BYTES) warnings.push(`plan.md is ${Buffer.byteLength(planBody)} bytes (> ${PLAN_WARN_BYTES}); reviewers read all of it`);
|
|
const files = [
|
|
write('plan.md', planBody),
|
|
write('review-record.md', `## Review record\n${record.trimEnd()}\n\n${runLog(state, findings.length, tasks.length, { total: decisions.length, pending: decisions.filter(d => d.status === 'pending').length })}`),
|
|
write('findings.jsonl', findings.map(r => JSON.stringify(r)).join('\n') + (findings.length ? '\n' : '')),
|
|
write('tasks.jsonl', tasks.map(r => JSON.stringify(r)).join('\n') + (tasks.length ? '\n' : '')),
|
|
];
|
|
if (fs.existsSync(path.join(state.out, 'decisions.jsonl'))) { state.artifacts['decisions.jsonl'] = { path: 'decisions.jsonl', sha256: sha256File(path.join(state.out, 'decisions.jsonl')) }; files.push(path.join(state.out, 'decisions.jsonl')); }
|
|
if (fs.existsSync(path.join(state.out, 'timing.json'))) { state.artifacts['timing.json'] = { path: 'timing.json', sha256: sha256File(path.join(state.out, 'timing.json')) }; files.push(path.join(state.out, 'timing.json')); }
|
|
if (fs.existsSync(path.join(state.out, 'gate.json'))) { state.artifacts['gate.json'] = { path: 'gate.json', sha256: sha256File(path.join(state.out, 'gate.json')) }; files.push(path.join(state.out, 'gate.json')); }
|
|
for (const phase of state.phase_order) for (const name of [`${phase}-consensus.md`, `${phase}-consensus.json`]) {
|
|
if (fs.existsSync(path.join(state.out, name))) state.artifacts[name] = { path: name, sha256: sha256File(path.join(state.out, name)) };
|
|
}
|
|
Object.assign(state, dispatchStats(state.out));
|
|
state.counts = { decisions: decisions.length, decisions_pending: decisions.filter(d => d.status === 'pending').length, findings: findings.length, tasks: tasks.length };
|
|
return { warnings, files };
|
|
}
|