mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-13 20:07:08 +01:00
v1.92.64.0 stamp: merge main e657fa0b0d into #3698 (land)
This commit is contained in:
commit
3bf4ee89bc
71 files changed
+2683
-227
No files matched your search
@@ -254,7 +254,12 @@ export function importAutoplan(runDir: string, branch: string, by?: string): App
|
||||
if (!line.trim()) continue;
|
||||
try { rows.push(JSON.parse(line)); } catch { return { code: 'ARTIFACT_MALFORMED_JSONL', message: `decisions.jsonl has a line that is not JSON` }; }
|
||||
}
|
||||
const run = (() => { try { return JSON.parse(fs.readFileSync(path.join(runDir, 'run.json'), 'utf8')); } catch { return {}; } })();
|
||||
const runFile = path.join(runDir, 'run.json');
|
||||
let run: Record<string, any> = {};
|
||||
if (fs.existsSync(runFile)) {
|
||||
try { run = JSON.parse(fs.readFileSync(runFile, 'utf8')); } catch (e: any) { return { code: 'ARTIFACT_INVALID_JSON', message: `run.json in ${runDir} is unreadable (${e?.message ?? e}); an approval names the run it came from` }; }
|
||||
}
|
||||
if (run.ceo_only || run.status === 'reviewed') return { code: 'CEO_ONLY_NO_GATE', message: `${runDir} is a CEO-only run (status ${run.status}): it has no gate and was never approved` };
|
||||
const plan = reviewedPlan(runDir, run);
|
||||
const planTitle = firstTitle(plan.text) ?? 'the plan';
|
||||
const planSha = plan.sha;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* autoplan-ceo-only — the driver's CEO-only run (`gstack-autoplan next
|
||||
* --ceo-only`, GST-537), the twin of `/autoplan --ceo-only` (the skill's
|
||||
* CEO-only section, docs/unattended.md#ceo-only-runs). The flag is a command-line choice for one run:
|
||||
* it conflicts with the flags that add a phase, a plan's scope marker may not
|
||||
* name it, and a run directory keeps the mode it started with. The run opens
|
||||
* only the CEO phase. `finishRun` is the one finish path next, phase close,
|
||||
* resume and close share: a full run writes the gate (`finishGate`); a
|
||||
* CEO-only run writes `ceo-review.md`, records each would-be gate item as a
|
||||
* decision row of kind `auto` and status `auto_kept` (nobody answered: the
|
||||
* plan's direction is kept, and an item only the owner can accept stays
|
||||
* undecided), and ends `reviewed`. It never writes gate.json,
|
||||
* never calls approve() and never edits the plan, so the commands that need a
|
||||
* gate or an approvable run refuse it (CEO_ONLY_NO_GATE); `budget` and
|
||||
* `close --deferred` still recover a run stopped by its spend cap.
|
||||
*/
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { buildGate, finishGate } from './autoplan-gate';
|
||||
import { FINDING_ORIGIN } from './autoplan-confirm';
|
||||
import { carryLines } from './autoplan-carry';
|
||||
import { aggregateFindings, exportRun } from './autoplan-export';
|
||||
import { decisionRows, keepDirection, type GateItem } from './gate-list';
|
||||
import { MARKER_RE } from './autoplan-scope';
|
||||
import { RunError, journal, voiceRows, writeDecisions, type RunState } from './autoplan-state';
|
||||
import type { DecisionRow, FindingRow } from './headless-artifacts';
|
||||
import { sha256File } from './headless-artifacts';
|
||||
|
||||
export const CEO_ONLY_FLAG = '--ceo-only';
|
||||
export const CEO_ONLY_CONFLICTS = ['--ui', '--developer-tool', '--agent-primary'] as const;
|
||||
export const CEO_ONLY_REPORT = 'ceo-review.md';
|
||||
|
||||
/**
|
||||
* The first next's checks: true when this next asks for a CEO-only run. A marker naming the flag refuses whether or not
|
||||
* the flag was passed; the flag refuses beside a phase-adding flag (argument or marker), on a plan at the report path,
|
||||
* or on a directory that holds a full run.
|
||||
*/
|
||||
export function checkCeoOnlyFlags(args: readonly string[], plan: string | undefined, existing: RunState | undefined, out?: string): boolean {
|
||||
const marker = plan && fs.existsSync(plan) ? MARKER_RE.exec(fs.readFileSync(plan, 'utf8').split('\n', 1)[0]!.replace(/^\uFEFF/, '').trimEnd()) : null;
|
||||
const tokens = marker?.[1]!.trim().split(/\s+/) ?? [];
|
||||
if (tokens.includes(CEO_ONLY_FLAG)) throw new RunError('CEO_ONLY_CONFLICT', `the scope marker in ${plan} names ${CEO_ONLY_FLAG}`, 3);
|
||||
if (!args.includes(CEO_ONLY_FLAG)) return false;
|
||||
const conflicts = CEO_ONLY_CONFLICTS.filter(f => args.includes(f) || tokens.includes(f));
|
||||
if (conflicts.length) throw new RunError('CEO_ONLY_CONFLICT', `${CEO_ONLY_FLAG} with ${conflicts.join(', ')}`, 3);
|
||||
const real = (p: string) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
|
||||
if (plan && out && (path.resolve(plan) === path.resolve(out, CEO_ONLY_REPORT) || real(plan) === path.join(real(out), CEO_ONLY_REPORT))) throw new RunError('CEO_ONLY_CONFLICT', `the plan ${plan} is the report path`, 3);
|
||||
if (existing && !existing.ceo_only) throw new RunError('CEO_ONLY_AFTER_INIT', `${existing.out} holds full run ${existing.run} (status ${existing.status})`, 3);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** answer, recommend and revise need a gate or an approvable run: a CEO-only run has neither (budget and close stay: they recover a budget stop). */
|
||||
export function refuseCeoOnly(state: RunState, verb: string): void {
|
||||
if (state.ceo_only) throw new RunError('CEO_ONLY_NO_GATE', `${verb} on CEO-only run ${state.run} (status ${state.status}); report: ${path.join(state.out, CEO_ONLY_REPORT)}`, 3);
|
||||
}
|
||||
|
||||
/** Every phase closed: a full run writes its gate (carry lines follow); a CEO-only run writes its report and ends reviewed. */
|
||||
export function finishRun(state: RunState): string[] {
|
||||
if (!state.ceo_only) { finishGate(state); return carryLines(state); }
|
||||
const report = path.join(state.out, CEO_ONLY_REPORT);
|
||||
const id = (p: string) => fs.statSync(p, { bigint: true });
|
||||
const at = fs.existsSync(report) && !fs.lstatSync(report).isSymbolicLink() ? id(report) : undefined;
|
||||
const plan = at?.nlink === 1n ? [state.plan_source, state.plan_input, state.active_plan].find(p => p && fs.existsSync(p) && id(p).ino === at.ino && id(p).dev === at.dev) : undefined;
|
||||
if (plan) throw new RunError('CEO_ONLY_CONFLICT', `the plan ${plan} is the report path ${report}`, 3);
|
||||
const list = buildGate(state, 1);
|
||||
const items = list.items.filter(i => i.id !== 'p1' && !i.origin?.endsWith(`|${FINDING_ORIGIN}`));
|
||||
const kept = (item: GateItem) => (item.cap && item.kind === 'approval' ? null : keepDirection(item));
|
||||
const rows: DecisionRow[] = decisionRows({ ...list, items }).map((row, i) => ({ ...row, kind: 'auto', status: 'auto_kept', chosen: kept(items[i]!), default_if_unanswered: kept(items[i]!), source: 'ceo-only' }));
|
||||
writeDecisions(state.out, rows);
|
||||
state.status = 'reviewed';
|
||||
exportRun(state);
|
||||
const findings = aggregateFindings(state);
|
||||
const tmp = `${report}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
||||
fs.writeFileSync(tmp, renderReport(state, items, rows, findings), { flag: 'wx' });
|
||||
fs.renameSync(tmp, report);
|
||||
state.artifacts[CEO_ONLY_REPORT] = { path: CEO_ONLY_REPORT, sha256: sha256File(report) };
|
||||
journal(state.out, { event: 'ceo_only_reviewed', decisions: rows.length, findings: findings.length, report: CEO_ONLY_REPORT });
|
||||
return [`REVIEWED: ceo-only run=${state.run} decisions=${rows.length} (auto_kept) findings=${findings.length} report=${report} (not approved; no gate)`];
|
||||
}
|
||||
|
||||
type Challenge = FindingRow & { user_challenge?: boolean };
|
||||
const none = (lines: string[]) => (lines.length ? lines : ['None.']);
|
||||
|
||||
/** The report both paths write: user challenges (direction kept), scope added, conservative choices, findings and the audit trail. */
|
||||
function renderReport(state: RunState, items: GateItem[], rows: DecisionRow[], findings: FindingRow[]): string {
|
||||
const plan = fs.existsSync(state.active_plan) ? fs.readFileSync(state.active_plan, 'utf8') : '';
|
||||
const title = /^#\s+(.+)$/m.exec(plan)?.[1]?.trim() ?? path.basename(state.plan_input ?? state.plan_source);
|
||||
const outside = state.phases.ceo?.outside;
|
||||
const outsideLine = outside?.status === 'completed' ? `completed (${outside.model})` : `${outside?.status ?? 'disabled'}${outside?.reason ? ` (${outside.reason})` : outside?.detail ? ` (${outside.detail})` : ''}`;
|
||||
const challenges = [...voiceRows(state, 'ceo', 'native'), ...voiceRows(state, 'ceo', 'outside')].filter(f => (f as Challenge).user_challenge);
|
||||
const where = (f: FindingRow) => (f.file ? `${f.file}${f.line ? `:${f.line}` : ''}` : 'plan');
|
||||
return [
|
||||
`# CEO-only review: ${title}`, '',
|
||||
`Mode: gstack-autoplan driver, CEO phase only (run ${state.run}). Outside voice: ${outsideLine}. Not approved.`, '',
|
||||
'## User challenges (direction kept)', '',
|
||||
...none(challenges.map(f => `- [${f.severity}] ${f.title} (${f.id}, ${f.voice}): proposal: ${f.resolution ?? 'unstated'}; the plan's direction is kept`)), '',
|
||||
'## Scope added', '', 'None. The driver never edits the plan; scope proposals are listed under Findings.', '',
|
||||
'## Conservative choices', '',
|
||||
...none(rows.map((r, i) => `- ${r.label}: ${r.title}: ${r.chosen === null ? 'left open for the owner (not accepted)' : `kept ${r.chosen} (${items[i]!.options.find(o => o.key === r.chosen)?.text ?? r.chosen})`}; the reviewers recommended ${r.recommended}`)), '',
|
||||
'## Findings', '',
|
||||
...none(findings.map(f => `- [${f.severity}] ${f.title} (${f.id}; ${where(f)}): fix: ${f.resolution ?? 'unstated'}; disposition: ${f.disposition ?? 'open'}${(f as { reconciliation?: string }).reconciliation ? `; voices: ${(f as { reconciliation?: string }).reconciliation}` : ''}`)), '',
|
||||
'## Decision Audit Trail', '',
|
||||
...none(rows.map(r => `- ${r.id}: ${r.kind} ${r.status}, chosen ${r.chosen ?? 'none'}, recommended ${r.recommended}${r.recommended_basis ? ` (${r.recommended_basis})` : ''}`)), '',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* autoplan-checkpoint — an opt-in durable copy of a run directory (next-wave
|
||||
* B12). With GSTACK_AUTOPLAN_CHECKPOINT_DIR set, every mutating command that
|
||||
* leaves the run at rest (gate_pending, amend_pending, complete, incomplete)
|
||||
* leaves the run at rest (gate_pending, amend_pending, complete, reviewed, incomplete)
|
||||
* publishes one immutable generation under `<dir>/<run>/generations/<name>/`:
|
||||
* `checkpoint.json` (every file's sha256), the run files under `run/`, the
|
||||
* owner's plan file under `plan-input/` when it lives outside the run, and
|
||||
@@ -34,7 +34,7 @@ import { readConfigKey } from './state-root';
|
||||
const MANIFEST = 'checkpoint.json';
|
||||
const CURRENT = 'current.json';
|
||||
const GENERATIONS = 'generations';
|
||||
const AT_REST = ['gate_pending', 'amend_pending', 'complete', 'incomplete'];
|
||||
const AT_REST = ['gate_pending', 'amend_pending', 'complete', 'reviewed', 'incomplete'];
|
||||
const FOREIGN_LOCK_STALE_MS = 10 * 60_000;
|
||||
interface CheckpointManifest {
|
||||
schema_version: 1; run: string; status: string; gate_rev: number; revision: number; at: string; host: string; gstack_version?: string;
|
||||
|
||||
@@ -24,21 +24,22 @@ export const PLAN_WARN_BYTES = 30_000;
|
||||
export function buildTasks(state: RunState, findings: FindingRow[]): TaskRow[] {
|
||||
const tasks: TaskRow[] = [];
|
||||
const covered = new Set<string>();
|
||||
const blockedBy = state.ceo_only ? [] : [decisionId(state.run, state.gate_rev ?? 1, 'p1')];
|
||||
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 });
|
||||
blocked_by: blockedBy, 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 });
|
||||
tasks.push({ schema_version: 2, run: state.run, id: `${state.run}-T${++n}`, title: f.title, status: state.ceo_only ? 'pending' : 'blocked_on_decision', depends_on: [], findings: ids,
|
||||
blocked_by: blockedBy, acceptance: f.resolution ? f.resolution : `reviewer re-verifies: ${f.title}`, priority: f.severity, ...(f.file ? { files: [f.file] } : {}), item: f.phase });
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
@@ -100,7 +101,7 @@ function runLog(state: RunState, findings: number, tasks: number, decisions: { t
|
||||
...[...(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}.`);
|
||||
lines.push(`Gate: ${state.ceo_only ? 'none (CEO-only run: ceo-review.md, never approved)' : 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';
|
||||
}
|
||||
|
||||
|
||||
+17
-16
@@ -19,8 +19,8 @@ import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { canonicalRows, parseReceipt, parseRecordFindings, parseReviewResult, type CanonicalFinding } from './autoplan-reconcile';
|
||||
import { amendmentSteps, applyGuidance, closeDeferred, closePhase, finishGate } from './autoplan-gate';
|
||||
import { carryLines } from './autoplan-carry';
|
||||
import { amendmentSteps, applyGuidance, closeDeferred, closePhase } from './autoplan-gate';
|
||||
import { finishRun } from './autoplan-ceo-only';
|
||||
import { GSTACK_VERSION } from './autoplan-migrate';
|
||||
import { driftLine, normalizeFindingFile, reviewHash, scaffold } from './autoplan-plan-text';
|
||||
import { PHASES, SNAPSHOT_TOOL, attemptFile, bindPromptResult, detectScope, driverFile, extractHeading, phaseOrder, reviewSkillFile, scopeLines, scopeSourceLabel, sectionLoadFor, snapshotTool, writePrompts, type Phase, type Scope, type ScopeFlags } from './autoplan-prompts';
|
||||
@@ -72,7 +72,7 @@ export function withRunLock<T>(out: string, fn: () => T, opts: { steal?: boolean
|
||||
// ---------------------------------------------------------------------------
|
||||
// init
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface InitOptions { plan: string; out: string; cwd: string; scope?: ScopeFlags; planHeading?: string; outsideEnabled?: boolean; spendCap?: number | null; sessionKind?: string; deadlineMin?: number; light?: boolean; runId?: string; scaffold?: boolean; maxRounds?: number; carryForward?: boolean; carryCheckCapUsd?: number }
|
||||
export interface InitOptions { plan: string; out: string; cwd: string; scope?: ScopeFlags; ceoOnly?: boolean; planHeading?: string; outsideEnabled?: boolean; spendCap?: number | null; sessionKind?: string; deadlineMin?: number; light?: boolean; runId?: string; scaffold?: boolean; maxRounds?: number; carryForward?: boolean; carryCheckCapUsd?: number }
|
||||
|
||||
/** `lines` receives what init itself reports (SCAFFOLDED:). A plan with one section heading missing refuses, or with `scaffold` is reviewed from a scaffolded copy. */
|
||||
export function initRun(o: InitOptions, lines: string[] = []): RunState {
|
||||
@@ -103,14 +103,14 @@ export function initRun(o: InitOptions, lines: string[] = []): RunState {
|
||||
const restore = path.join(out, FILES.restore);
|
||||
const init = snapshotTool(['init', source, active, restore], o.cwd);
|
||||
const scope = detectScope(active, o.cwd, o.scope ?? {}, path.resolve(o.plan));
|
||||
const order = phaseOrder(scope);
|
||||
const order: Phase[] = o.ceoOnly ? ['ceo'] : phaseOrder(scope);
|
||||
const run = o.runId ?? `${path.basename(out)}-${new Date().toISOString().replace(/[-:T.Z]/g, '').slice(0, 14)}`;
|
||||
// codex_reviews gates the outside voice here, before any dispatch: a refusal is a terminal not_run outcome per phase.
|
||||
const consent = (o.outsideEnabled ?? true) ? outsideConsent({ userInvoked: false }) : undefined;
|
||||
const consentMissing = consent && !consent.allowed ? consent.lines[0] : undefined;
|
||||
const sessionKind = o.sessionKind ?? process.env.GSTACK_SESSION_KIND ?? 'unattended';
|
||||
const phases: Record<string, PhaseState> = {};
|
||||
for (const p of PHASES) if (!order.includes(p)) phases[p] = { skipped: scope.docs_only ? 'docs-only plan' : p === 'design' ? 'no UI scope detected' : 'no developer-facing scope detected', native: { status: 'skipped' }, outside: { status: 'skipped' } };
|
||||
for (const p of PHASES) if (!order.includes(p)) phases[p] = { skipped: o.ceoOnly ? 'ceo-only run' : scope.docs_only ? 'docs-only plan' : p === 'design' ? 'no UI scope detected' : 'no developer-facing scope detected', native: { status: 'skipped' }, outside: { status: 'skipped' } };
|
||||
const state: RunState = {
|
||||
schema_version: 1, run, status: 'running', required_phases: order, phases, artifacts: {}, revision: 0, out,
|
||||
plan_source: init.sourcePlan, active_plan: init.activePlan, restore: init.restorePath, scope, phase_order: order,
|
||||
@@ -120,12 +120,12 @@ export function initRun(o: InitOptions, lines: string[] = []): RunState {
|
||||
deviations: ['autoplan guard: not enforced by this host; publication order is unverified (GUARD_NOT_INSTALLED)'],
|
||||
...(o.deadlineMin ? { deadline_at: new Date(Date.now() + o.deadlineMin * 60_000).toISOString() } : {}),
|
||||
...(o.light ? { light: true } : {}),
|
||||
gstack_version: GSTACK_VERSION, plan_input: input, scope_flags: { ...(o.scope ?? {}) },
|
||||
...(o.ceoOnly ? { ceo_only: true } : {}), gstack_version: GSTACK_VERSION, plan_input: input, scope_flags: { ...(o.scope ?? {}) },
|
||||
...(o.planHeading ? { plan_heading: o.planHeading } : {}), ...(scaffolded ? { scaffolded: true } : {}),
|
||||
...(o.carryForward === false ? { carry_forward: false } : {}), ...(o.carryCheckCapUsd !== undefined ? { carry_check_cap_usd: o.carryCheckCapUsd } : {}),
|
||||
};
|
||||
fs.writeFileSync(path.join(out, 'timing.json'), JSON.stringify({ schema_version: 1, run, phases: [], started_at: now(), session_kind: state.session_kind }, null, 2) + '\n');
|
||||
journal(out, { event: 'init', run, plan_source: source, scope, phase_order: order, ...(consentMissing ? { outside: 'not_run', reason: 'consent_missing' } : {}) });
|
||||
journal(out, { event: 'init', run, plan_source: source, scope, phase_order: order, ...(o.ceoOnly ? { ceo_only: true } : {}), ...(consentMissing ? { outside: 'not_run', reason: 'consent_missing' } : {}) });
|
||||
writeState(state);
|
||||
return state;
|
||||
}
|
||||
@@ -198,7 +198,7 @@ export function nextStep(o: NextOptions): NextResult {
|
||||
const lines: string[] = [];
|
||||
const result: NextResult = { state, attempts: [], lines };
|
||||
if (state.status === 'incomplete' && state.stop) { lines.push(...stopLines(state)); return result; }
|
||||
if (state.status === 'gate_pending' || state.status === 'complete' || state.status === 'incomplete' || state.status === 'refused') return result;
|
||||
if (['gate_pending', 'complete', 'reviewed', 'incomplete', 'refused'].includes(state.status)) return result;
|
||||
if (state.deadline_at && Date.parse(state.deadline_at) < Date.now()) { state.status = 'interrupted'; writeState(state); lines.push(`DEADLINE: passed at ${state.deadline_at}`); return result; }
|
||||
const pending = pendingAmendments(state).map(a => a.decision);
|
||||
if (pending.length) {
|
||||
@@ -210,7 +210,7 @@ export function nextStep(o: NextOptions): NextResult {
|
||||
lines.push(...closeDeferred(state, phase, { reason: `round cap: round ${roundOf(state, phase)} would pass max ${roundCap(state, phase)}`, by: 'runner', cause: 'cap' }).lines);
|
||||
phase = currentPhase(state);
|
||||
}
|
||||
if (!phase) { finishGate(state); lines.push(...carryLines(state)); writeState(state); return result; }
|
||||
if (!phase) { lines.push(...finishRun(state)); writeState(state); return result; }
|
||||
const first = !state.phase_order.some(p => state.phases[p]?.snapshot);
|
||||
openPhase(state, phase, o.cwd);
|
||||
const ps = state.phases[phase]!;
|
||||
@@ -394,8 +394,9 @@ export function submitResult(o: SubmitOptions): SubmitResult {
|
||||
function closeAndAdvance(state: RunState, phase: Phase, reconcile?: string): string[] {
|
||||
const close = closePhase(state, phase, { overridesFile: reconcile });
|
||||
const gated = !currentPhase(state);
|
||||
if (gated) finishGate(state); else state.status = 'running';
|
||||
return [`PHASE_CLOSED: ${phase} confirmed=${close.consensus.confirmed}${close.consensus.unverified ? ` unverified=${close.consensus.unverified}` : ''} disagree=${close.consensus.disagree} new=${close.consensus.new} native_only=${close.consensus.native_only} coverage=${close.consensus.coverage}`, ...(gated ? carryLines(state) : [])];
|
||||
const finished = gated ? finishRun(state) : [];
|
||||
if (!gated) state.status = 'running';
|
||||
return [`PHASE_CLOSED: ${phase} confirmed=${close.consensus.confirmed}${close.consensus.unverified ? ` unverified=${close.consensus.unverified}` : ''} disagree=${close.consensus.disagree} new=${close.consensus.new} native_only=${close.consensus.native_only} coverage=${close.consensus.coverage}`, ...finished];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -514,15 +515,15 @@ export function resumeRun(out: string): ResumeResult {
|
||||
if (ps.native?.status === 'completed' && voiceTerminal(ps.outside)) closePhase(state, phase, {});
|
||||
}
|
||||
const stopped = resumeStop(state);
|
||||
const gated = !currentPhase(state) && !['gate_pending', 'amend_pending', 'complete', 'incomplete', 'refused'].includes(state.status);
|
||||
if (gated) finishGate(state);
|
||||
else if (state.status === 'interrupted' || state.status === 'running' || state.status === 'awaiting_result') {
|
||||
const gated = !currentPhase(state) && !['gate_pending', 'amend_pending', 'complete', 'reviewed', 'incomplete', 'refused'].includes(state.status);
|
||||
const finished = gated ? finishRun(state) : [];
|
||||
if (!gated && (state.status === 'interrupted' || state.status === 'running' || state.status === 'awaiting_result')) {
|
||||
const anyOpen = state.phase_order.some(p => (['native', 'outside'] as const).some(v => openAttempt(voiceState(state, p, v))));
|
||||
state.status = anyOpen ? 'awaiting_result' : 'running';
|
||||
}
|
||||
journal(out, { event: 'resumed', unknown, rebound, status: state.status });
|
||||
writeState(state);
|
||||
const lines = [...recovered, ...stopped, ...(gated ? carryLines(state) : []), `RESUMED: run=${state.run} status=${state.status} execution_unknown=${unknown.length} rebound=${rebound.length}`];
|
||||
const lines = [...recovered, ...stopped, ...finished, `RESUMED: run=${state.run} status=${state.status} execution_unknown=${unknown.length} rebound=${rebound.length}`];
|
||||
for (const a of unknown) lines.push(`EXECUTION_UNKNOWN: ${a}`);
|
||||
return { state, unknown, rebound, lines };
|
||||
}
|
||||
@@ -546,6 +547,6 @@ export function statusLines(state: RunState): string[] {
|
||||
for (const d of state.capped_deltas ?? []) lines.push(`CAPPED_DELTA: v${d.k} ${d.phase} +${d.added} -${d.removed} ${d.status}`);
|
||||
return lines;
|
||||
}
|
||||
export const TERMINAL: readonly RunStatus[] = ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'refused', 'interrupted'];
|
||||
export const TERMINAL: readonly RunStatus[] = ['complete', 'reviewed', 'gate_pending', 'amend_pending', 'incomplete', 'refused', 'interrupted'];
|
||||
export { SNAPSHOT_TOOL, RunError, FILES, readState, writeState, readJournal, journal };
|
||||
export type { RunState, Voice, VoiceState, AttemptState };
|
||||
@@ -65,7 +65,7 @@ export function detectDocsOnly(implementationPlan: string, root: string): DocsOn
|
||||
|
||||
export interface ScopeFlags { ui?: boolean; developerTool?: boolean; agentPrimary?: boolean; dx?: boolean; docsOnly?: boolean }
|
||||
export const SCOPE_FLAG_TOKENS = ['--ui', '--no-ui', '--no-dx', '--developer-tool', '--agent-primary', '--docs-only'] as const;
|
||||
const MARKER_RE = /^<!--\s*gstack-autoplan-scope:(.*?)-->\s*$/;
|
||||
export const MARKER_RE = /^<!--\s*gstack-autoplan-scope:(.*?)-->\s*$/;
|
||||
|
||||
export function scopeTokens(f: ScopeFlags): string[] {
|
||||
return [...(f.ui === true ? ['--ui'] : f.ui === false ? ['--no-ui'] : []), ...(f.dx === false ? ['--no-dx'] : []),
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface RunState extends RunManifest {
|
||||
dispatches?: DispatchCounts; est_input_tokens?: number;
|
||||
/** Carry-forward (lib/autoplan-carry.ts): false with --no-carry-forward; the per-gate cap on the delta check; the latest carry decision. */
|
||||
carry_forward?: boolean; carry_check_cap_usd?: number; carry?: import('./autoplan-carry').CarryRecord;
|
||||
/** `next --ceo-only` (lib/autoplan-ceo-only.ts): the CEO phase alone, no gate, ends `reviewed`; never set by a scope marker. */
|
||||
ceo_only?: boolean;
|
||||
}
|
||||
|
||||
export const TERMINAL_VOICE_STATUSES = ['completed', 'disabled', 'unavailable', 'failed', 'skipped'] as const;
|
||||
|
||||
+31
-13
@@ -25,7 +25,8 @@
|
||||
* flaky events flake-records-a<n> artifacts of gate runs (paid, one record per case
|
||||
* that failed once and passed its one rerun) and flake-ledger-* artifacts
|
||||
* of <free workflow> runs on <base> (free-suite serial retries); the same
|
||||
* two sources the daily flaky pass reads
|
||||
* two sources the daily flaky pass reads; a main push that reused its PR's
|
||||
* run (no artifacts, `reuse` job green; reuseChecker) is left out
|
||||
* issues search/issues created / closed inside the window
|
||||
* releases releases published inside the window (drafts excluded)
|
||||
*
|
||||
@@ -36,13 +37,13 @@ import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
GH_CLIENT, createdFilter, downloadRunArtifacts, filesUnder, historyFetcher, listWorkflowRuns, readFlakeLedgerDir, unavailableReason,
|
||||
GH_CLIENT, createdFilter, downloadRunArtifacts, filesUnder, historyFetcher, listWorkflowRuns, readFlakeLedgerDir, reuseChecker, unavailableReason,
|
||||
type GhClient,
|
||||
} from '../scripts/lib/ci-history';
|
||||
|
||||
export interface ScorecardSource {
|
||||
getJson(apiPath: string): unknown;
|
||||
/** Downloaded, unzipped artifact directories of one run whose names match, keyed by artifact name; one listing per call. */
|
||||
/** Downloaded, unzipped artifact directories of one run whose names match, keyed by artifact name; one listing per call, and `match` sees every name in it. */
|
||||
artifactDirs(runId: number, match: (name: string) => boolean): Map<string, string>;
|
||||
}
|
||||
|
||||
@@ -93,6 +94,8 @@ export interface FreeRetryRun { id: number; conclusion: string | null; retries:
|
||||
export interface Scorecard {
|
||||
repo: string; base: string; windowStart: string; windowEnd: string;
|
||||
prs: MergedPr[]; gates: GateRun[]; freeRuns: FreeRetryRun[]; artifactRunsRead: number; passCap: number;
|
||||
/** Reused main pushes left out of freeRuns (reuseChecker), and the note naming runs it could not check. */
|
||||
freeRunsSkipped: number; freeRunsNote: string | null;
|
||||
/** Gate runs whose artifacts could not be read; they count without cost or flake data. */
|
||||
artifactErrors: number;
|
||||
issuesOpened: number | null; issuesClosed: number | null; releases: Array<{ tag: string; publishedAt: string }>;
|
||||
@@ -308,14 +311,29 @@ export function readGateRuns(source: ScorecardSource, o: ScorecardOptions, w: Sc
|
||||
return { gates, artifactRunsRead, artifactErrors };
|
||||
}
|
||||
|
||||
/** Free-suite runs on base in the window and the flaky-pass entries their flake-ledger-* artifacts hold. */
|
||||
export function readFreeRetries(source: ScorecardSource, o: ScorecardOptions, w: ScoreWindow): FreeRetryRun[] {
|
||||
/**
|
||||
* Free-suite runs on base in the window and the flaky-pass entries their
|
||||
* flake-ledger-* artifacts hold. A read run that reused its pull request's run
|
||||
* (reuseChecker) is left out and counted in freeRunsSkipped; an unreadable run
|
||||
* and a run past maxArtifactRuns are kept, as before.
|
||||
*/
|
||||
export function readFreeRetries(source: ScorecardSource, o: ScorecardOptions, w: ScoreWindow): Pick<Scorecard, 'freeRuns' | 'freeRunsSkipped' | 'freeRunsNote'> {
|
||||
const client: GhClient = { getJson: p => source.getJson(p), downloadZip: () => { throw new Error('not used'); } };
|
||||
const runs = listWorkflowRuns(client, o.repo, o.freeWorkflow, { since: w.start, until: w.end, branch: o.base });
|
||||
const retries = (runId: number): number | null => {
|
||||
try { return [...source.artifactDirs(runId, name => name.startsWith('flake-ledger-')).values()].flatMap(readFlakeLedgerDir).length; } catch { return null; }
|
||||
};
|
||||
return runs.map((run, index) => ({ id: run.id, conclusion: run.conclusion, retries: index < o.maxArtifactRuns ? retries(run.id) : 0 }));
|
||||
const reuse = reuseChecker(client, o.repo);
|
||||
const freeRuns: FreeRetryRun[] = [];
|
||||
let freeRunsSkipped = 0;
|
||||
runs.forEach((run, index) => {
|
||||
if (index >= o.maxArtifactRuns) { freeRuns.push({ id: run.id, conclusion: run.conclusion, retries: 0 }); return; }
|
||||
const names: string[] = [];
|
||||
let retries: number | null;
|
||||
try {
|
||||
retries = [...source.artifactDirs(run.id, name => { names.push(name); return name.startsWith('flake-ledger-'); }).values()].flatMap(readFlakeLedgerDir).length;
|
||||
} catch { retries = null; }
|
||||
if (retries !== null && reuse.reused(run, names)) { freeRunsSkipped++; return; }
|
||||
freeRuns.push({ id: run.id, conclusion: run.conclusion, retries });
|
||||
});
|
||||
return { freeRuns, freeRunsSkipped, freeRunsNote: reuse.note() ?? null };
|
||||
}
|
||||
|
||||
function searchCount(source: ScorecardSource, q: string): number | null {
|
||||
@@ -340,14 +358,14 @@ export function readReleases(source: ScorecardSource, repo: string, w: ScoreWind
|
||||
export function collectScorecard(source: ScorecardSource, o: ScorecardOptions): Scorecard {
|
||||
const w = scoreWindow(o);
|
||||
const card: Scorecard = { repo: o.repo, base: o.base, windowStart: w.start, windowEnd: w.end,
|
||||
prs: [], gates: [], freeRuns: [], artifactRunsRead: 0, passCap: o.passCap, artifactErrors: 0, issuesOpened: null, issuesClosed: null, releases: [], unavailable: [] };
|
||||
prs: [], gates: [], freeRuns: [], freeRunsSkipped: 0, freeRunsNote: null, artifactRunsRead: 0, passCap: o.passCap, artifactErrors: 0, issuesOpened: null, issuesClosed: null, releases: [], unavailable: [] };
|
||||
const guard = (what: string, fn: () => void) => { try { fn(); } catch (e) { card.unavailable.push(`${what}: ${unavailableReason(e)}`); } };
|
||||
guard('merged PRs', () => { card.prs = readMergedPrs(source, o, w); });
|
||||
guard('gate runs', () => {
|
||||
const prCost = new Map(card.prs.filter(p => p.commentCostUsd !== null).map(p => [p.number, p.commentCostUsd!] as const));
|
||||
Object.assign(card, readGateRuns(source, o, w, prCost));
|
||||
});
|
||||
guard('free retries', () => { card.freeRuns = readFreeRetries(source, o, w); });
|
||||
guard('free retries', () => { Object.assign(card, readFreeRetries(source, o, w)); });
|
||||
guard('issues', () => {
|
||||
card.issuesOpened = searchCount(source, `repo:${o.repo} is:issue created:${searchRange(w)}`);
|
||||
card.issuesClosed = searchCount(source, `repo:${o.repo} is:issue closed:${searchRange(w)}`);
|
||||
@@ -412,7 +430,7 @@ export function scorecardRows(card: Scorecard): ScorecardRow[] {
|
||||
return { id, metric, value: failed ? unavailableText(failed) : value(), benchmark: BENCHMARKS[id]!, rule };
|
||||
};
|
||||
const paidFlakes = () => `${flakes} paid case(s) passed on their one rerun (rerun spend ${usd(flakeUsd)})${card.artifactErrors ? `; artifacts unreadable on ${card.artifactErrors} run(s)` : ''}`;
|
||||
const freeRetries = () => `${retries} free-suite retr${retries === 1 ? 'y' : 'ies'} in ${retryRuns} of ${card.freeRuns.length} runs${unreadableFree ? ` (${unreadableFree} unreadable)` : ''}`;
|
||||
const freeRetries = () => `${retries} free-suite retr${retries === 1 ? 'y' : 'ies'} in ${retryRuns} of ${card.freeRuns.length} runs${unreadableFree ? ` (${unreadableFree} unreadable)` : ''}${card.freeRunsSkipped ? `; ${card.freeRunsSkipped} reused main push(es) left out` : ''}${card.freeRunsNote ? `; ${card.freeRunsNote}` : ''}`;
|
||||
return [
|
||||
row('merged', 'Merged PRs', 'merged PRs', () => `${card.prs.length} (crew ${crewPrs.length}, outside ${card.prs.length - crewPrs.length})`,
|
||||
`merged_at inside the window, base ${card.base}; crew by login list or author_association OWNER/MEMBER/COLLABORATOR`),
|
||||
@@ -429,7 +447,7 @@ export function scorecardRows(card: Scorecard): ScorecardRow[] {
|
||||
row('red_train', 'Red-train rate', 'gate runs', () => `${pct(trains.filter(t => t.outcome === 'red').length, trains.length)} (${trains.filter(t => t.outcome === 'red').length} red of ${trains.length} trains; ${by('red', 'backstop')} red backstop, ${by('red', 'screen')} red screens)`,
|
||||
`runs whose head branch starts with the train prefix, cancelled runs excluded; red means the run's conclusion was not success`),
|
||||
row('flaky', 'Flaky events', [], () => `${unavailable('gate runs') ? unavailableText('gate runs') : paidFlakes()}; ${unavailable('free retries') ? unavailableText('free retries') : freeRetries()}`,
|
||||
`flake-records artifacts of the newest ${card.artifactRunsRead} gate runs and flake-ledger artifacts of free runs on the base branch`),
|
||||
`flake-records artifacts of the newest ${card.artifactRunsRead} gate runs and flake-ledger artifacts of free runs on the base branch, reused main pushes left out`),
|
||||
row('issues', 'Issues opened / closed', 'issues', () => `${card.issuesOpened ?? 'n/a'} / ${card.issuesClosed ?? 'n/a'}`, 'GitHub search: created, closed inside the window'),
|
||||
row('releases', 'Releases cut', 'releases', () => `${card.releases.length}${card.releases.length ? ` (${card.releases.map(r => r.tag).join(', ')})` : ''}`, 'releases published inside the window, drafts excluded'),
|
||||
];
|
||||
|
||||
@@ -20,7 +20,7 @@ export type Severity = 'Critical' | 'High' | 'Medium' | 'Low' | 'Informational';
|
||||
export type Disposition = 'open' | 'accepted' | 'partially_accepted' | 'rejected' | 'deferred' | 'fixed';
|
||||
export type Voice = 'native' | 'outside';
|
||||
export type DecisionKind = 'auto' | 'approval' | 'user_challenge';
|
||||
export type DecisionStatus = 'pending' | 'approved' | 'overridden' | 'rejected';
|
||||
export type DecisionStatus = 'pending' | 'approved' | 'overridden' | 'rejected' | 'auto_kept';
|
||||
export type TaskStatus = 'pending' | 'ready' | 'blocked_on_dependency' | 'blocked_on_decision' | 'in_progress' | 'done';
|
||||
export type VoiceStatus = 'completed' | 'unavailable' | 'disabled' | 'skipped' | 'failed' | 'pending' | 'running';
|
||||
export type ResultStatus = 'complete' | 'reviewed' | 'gate_pending' | 'amend_pending' | 'incomplete' | 'interrupted' | 'refused';
|
||||
@@ -122,7 +122,7 @@ export interface ShipReceipt {
|
||||
}
|
||||
|
||||
export const RESULT_STATUSES: readonly ResultStatus[] = ['complete', 'reviewed', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused'];
|
||||
const TERMINAL_RUN: readonly string[] = ['complete', 'gate_pending', 'amend_pending', 'refused', 'incomplete'];
|
||||
const TERMINAL_RUN: readonly string[] = ['complete', 'reviewed', 'gate_pending', 'amend_pending', 'refused', 'incomplete'];
|
||||
const TERMINAL_VOICE: readonly string[] = ['completed', 'unavailable', 'disabled', 'skipped', 'failed'];
|
||||
const RESOLVED: readonly string[] = ['accepted', 'partially_accepted', 'rejected', 'deferred', 'fixed'];
|
||||
|
||||
@@ -176,7 +176,7 @@ export const ARTIFACT_SCHEMAS = {
|
||||
required: ['schema_version', 'run', 'id', 'title', 'options', 'recommended', 'kind', 'status', 'gate_rev'],
|
||||
properties: {
|
||||
schema_version: { const: 1 }, run: str, id: str, label: str, title: str, options: strList, recommended: nullableStr,
|
||||
kind: { enum: ['auto', 'approval', 'user_challenge'] }, status: { enum: ['pending', 'approved', 'overridden', 'rejected'] },
|
||||
kind: { enum: ['auto', 'approval', 'user_challenge'] }, status: { enum: ['pending', 'approved', 'overridden', 'rejected', 'auto_kept'] },
|
||||
default_if_unanswered: nullableStr, gate_rev: { type: 'integer', minimum: 1 }, phase: str, chosen: nullableStr,
|
||||
answered_at: str, reply: str, source: str, cost: str,
|
||||
recommended_basis: str, changes: str, origin: str, carried_from: str, chosen_text: str,
|
||||
@@ -207,7 +207,7 @@ export const ARTIFACT_SCHEMAS = {
|
||||
required: ['schema_version', 'run', 'status', 'required_phases', 'phases', 'artifacts'],
|
||||
properties: {
|
||||
schema_version: { const: 1 }, run: str,
|
||||
status: { enum: ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused', 'awaiting_result', 'running'] },
|
||||
status: { enum: ['complete', 'reviewed', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused', 'awaiting_result', 'running'] },
|
||||
base: str, host: str, session_kind: str, required_phases: strList,
|
||||
phases: { type: 'object', description: 'phase id → { snapshot, native, outside }', properties: {} },
|
||||
counts: { type: 'object', description: 'decisions, decisions_pending, findings, tasks: each must equal the rows in its file' },
|
||||
|
||||
+16
-2
@@ -27,7 +27,7 @@ import { NON_DOWNGRADABLE, PREGATE_CONFIG_PATH, userPolicyAbsent } from './confi
|
||||
import { sha256, withFingerprint } from './context';
|
||||
import { baseBranchOf, baseMismatchFix, describeSubstitution, substituteStampOnlyTree } from './remote-receipt';
|
||||
import { resolveBaseBranch } from '../ship-policy';
|
||||
import { parseRunReceipt, parseRunRef, runChanged, runReceiptFields, uncoveredFiles, verifyRun, type RemoteGateRuns, type RunDeps, type RunEvidence, type RunProfile } from '../run-evidence';
|
||||
import { parseRunReceipt, parseRunRef, runChanged, runReceiptFields, uncoveredFiles, verifyRun, type ApiRun, type RemoteGateRuns, type RunDeps, type RunEvidence, type RunProfile } from '../run-evidence';
|
||||
import type { CheckDef, CheckResult, PregateContext, Stage } from './types';
|
||||
|
||||
export const CHECKS: readonly CheckDef[] = [regenCheck, secretsCheck, straysCheck, literalsCheck, timingCheck, guardsCheck, lanesCheck];
|
||||
@@ -217,7 +217,10 @@ export type ClearDecision = { receipt: string; attested: boolean; substitution?:
|
||||
* tree and the substitution recorded; when any differing path is not a stamp,
|
||||
* the tree mismatch refuses as before. A `test_backend/<backend>` obligation
|
||||
* has no machine-checkable receipt, so its run URL is recorded as attested.
|
||||
* Platform lanes take a run URL or an evidence-bundle lane label.
|
||||
* Platform lanes take a run URL or an evidence-bundle lane label. When the run
|
||||
* evidence is readable, a run URL must name a run of this repository whose
|
||||
* workflow file is the lane's own; a run of another workflow, an unreadable
|
||||
* run or a URL that is not an Actions run refuses (GSTA-203).
|
||||
*
|
||||
* `--run` (and `--run-url` on a lane the base branch profiles) goes to
|
||||
* clearByRuns, which accepts only verified Actions runs; a profiled lane
|
||||
@@ -253,6 +256,17 @@ export function clearDecision(file: PregateFile, lane: string, input: ClearInput
|
||||
if (!input.runUrl) return { refused: 'usage', message: `${lane} clears with --run-url <url>` };
|
||||
return { receipt: `attested:${input.runUrl}`, attested: true };
|
||||
}
|
||||
if (input.runUrl && input.evidence) {
|
||||
const repo = input.evidence.repo;
|
||||
const invalid = (why: string, fix: string): ClearDecision => ({ refused: 'PREGATE_RECEIPT_INVALID', message: `REUSE: refused reason=${why}; ${lane} stays uncleared`, fix });
|
||||
const id = parseRunRef(input.runUrl.replace(/[?#].*$/, ''), repo);
|
||||
if (id === null) return invalid(`run_ref_invalid — ${JSON.stringify(input.runUrl)} is not a run id or a run URL on ${repo}`, `pass the run's numeric id or https://github.com/${repo}/actions/runs/<id>`);
|
||||
let run: ApiRun | null;
|
||||
try { run = input.evidence.deps.run(id); }
|
||||
catch (e: any) { return invalid(`api_error — reading run ${id} failed: ${String(e?.message ?? e).slice(0, 300)}`, `check gh auth status and that gh can read ${repo}'s Actions runs, then rerun the same clear`); }
|
||||
const want = `.github/workflows/${target.workflow}`;
|
||||
if (run?.path !== want) return invalid(`workflow_mismatch — run ${id} attempt ${run?.run_attempt ?? '?'} ran ${run?.path ?? 'an unknown workflow'}, not ${want}`, `clear ${lane} with a run of its own workflow: gh workflow run ${target.workflow} --ref <branch>, then gstack-pregate clear --remote ${lane} --run-url <that run's URL>`);
|
||||
}
|
||||
const receipt = input.runUrl ?? (input.bundleLane ? `bundle-lane:${input.bundleLane}` : undefined);
|
||||
if (!receipt) return { refused: 'usage', message: `${lane} clears with --run-url <url> or --bundle-lane <label>` };
|
||||
return { receipt, attested: false };
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Result codes for the CEO-only driver run (`gstack-autoplan next --ceo-only`,
|
||||
* lib/autoplan-ceo-only.ts). Kept beside lib/result-codes.ts, which spreads
|
||||
* this table into RESULT_CODES; the same rules apply: one stable
|
||||
* docs/troubleshooting.md anchor, a summary and a fix clause each. The run is
|
||||
* documented in docs/unattended.md#ceo-only-runs.
|
||||
*/
|
||||
import type { ResultCode } from './result-codes';
|
||||
|
||||
export const AUTOPLAN_RESULT_CODES = {
|
||||
CEO_ONLY_CONFLICT: {
|
||||
anchor: 'ceo-only-conflict',
|
||||
summary: '--ceo-only was combined with a flag that adds a phase (--ui, --developer-tool, --agent-primary), or a plan\'s scope marker names it',
|
||||
fix: 'drop the conflicting flag, or run without --ceo-only for the full review; --ceo-only goes on the command line of the first next only, never in a gstack-autoplan-scope marker',
|
||||
},
|
||||
CEO_ONLY_AFTER_INIT: {
|
||||
anchor: 'ceo-only-after-init',
|
||||
summary: 'next --ceo-only named a run directory that already holds a full review run',
|
||||
fix: 'a run keeps the mode it started with: pass --ceo-only on the first next with a new --out directory',
|
||||
},
|
||||
CEO_ONLY_NO_GATE: {
|
||||
anchor: 'ceo-only-no-gate',
|
||||
summary: 'answer, recommend, revise and approvals import need a gate or an approvable run, and a CEO-only run has neither: it ends status=reviewed and is never approved',
|
||||
fix: 'read <run dir>/ceo-review.md; to get a gate and an approval, start a full review with gstack-autoplan next --out <new dir> --plan <file> (no --ceo-only)',
|
||||
},
|
||||
} as const satisfies Record<string, ResultCode>;
|
||||
@@ -15,6 +15,7 @@ import { CAPY_RESULT_CODES } from './result-codes-capy';
|
||||
import { BUDGET_RESULT_CODES } from './result-codes-budget';
|
||||
import { EVAL_RESULT_CODES } from './result-codes-eval';
|
||||
import { PLAN_RESULT_CODES } from './result-codes-plan';
|
||||
import { AUTOPLAN_RESULT_CODES } from './result-codes-autoplan';
|
||||
|
||||
export interface ResultCode {
|
||||
/** Stable `<a id>` in docs/troubleshooting.md. */
|
||||
@@ -770,6 +771,7 @@ export const RESULT_CODES = {
|
||||
...BUDGET_RESULT_CODES,
|
||||
...EVAL_RESULT_CODES,
|
||||
...PLAN_RESULT_CODES,
|
||||
...AUTOPLAN_RESULT_CODES,
|
||||
} as const satisfies Record<string, ResultCode>;
|
||||
|
||||
export type ResultCodeName = keyof typeof RESULT_CODES;
|
||||
|
||||
+10
-7
@@ -20,6 +20,8 @@ export interface Collected extends ClassInput {
|
||||
errors: DepthError[];
|
||||
deleted: string[];
|
||||
stampOnly: string[];
|
||||
/** Paths the diff creates (untracked files included): their added lines are the whole file. */
|
||||
created: string[];
|
||||
/** Added lines per path, for scanNumbers. */
|
||||
added: Record<string, string[]>;
|
||||
/** Deleted lines per path, for the targeted classes (lib/review-targeted.ts). */
|
||||
@@ -54,11 +56,11 @@ export function loadRegistryAt(root: string, sha: string): RegistryInfo {
|
||||
}
|
||||
|
||||
/** Numstat, deletions and the -U0 patch for `range` (`[mergeBase, head]` or `[mergeBase]` for the working tree). */
|
||||
function readDiff(root: string, range: string[], errors: DepthError[]): Pick<Collected, 'perPath' | 'deleted' | 'stampOnly' | 'added' | 'removed'> {
|
||||
function readDiff(root: string, range: string[], errors: DepthError[]): Pick<Collected, 'perPath' | 'deleted' | 'stampOnly' | 'created' | 'added' | 'removed'> {
|
||||
const numstat = git(root, ['diff', '--numstat', '-z', '--no-renames', ...range]);
|
||||
const status = git(root, ['diff', '--name-status', '-z', '--no-renames', '--diff-filter=D', ...range]);
|
||||
const patch = git(root, ['diff', '-U0', '--no-color', '--no-ext-diff', '--no-renames', ...range]);
|
||||
const empty = { perPath: [], deleted: [], stampOnly: [], added: {}, removed: {} };
|
||||
const empty = { perPath: [], deleted: [], stampOnly: [], created: [], added: {}, removed: {} };
|
||||
for (const [r, what] of [[numstat, '--numstat'], [status, '--name-status'], [patch, '-U0']] as const) {
|
||||
if (!r.ok) { errors.push({ code: 'numstat_failed', detail: oneLine(r.err || 'git diff failed'), input: `git diff ${what} ${range.join(' ')}`, repair: 'check that both commits exist locally (git fetch origin <base>) and rerun' }); return empty; }
|
||||
}
|
||||
@@ -73,6 +75,7 @@ function readDiff(root: string, range: string[], errors: DepthError[]): Pick<Col
|
||||
const added: Record<string, string[]> = {};
|
||||
const removed: Record<string, string[]> = {};
|
||||
const changed: Record<string, string[]> = {};
|
||||
const created: string[] = [];
|
||||
let cur = '';
|
||||
let minus = '';
|
||||
let inHunk = false;
|
||||
@@ -80,7 +83,7 @@ function readDiff(root: string, range: string[], errors: DepthError[]): Pick<Col
|
||||
if (line.startsWith('diff --git ')) { inHunk = false; cur = ''; minus = ''; continue; }
|
||||
if (!inHunk) {
|
||||
if (line.startsWith('--- ')) minus = line === '--- /dev/null' ? '' : line.slice(6).replace(/\t$/, '');
|
||||
else if (line.startsWith('+++ ')) cur = line === '+++ /dev/null' ? minus : line.slice(6).replace(/\t$/, '');
|
||||
else if (line.startsWith('+++ ')) { cur = line === '+++ /dev/null' ? minus : line.slice(6).replace(/\t$/, ''); if (!minus && cur && !scratch(cur)) created.push(cur); }
|
||||
else if (line.startsWith('@@')) inHunk = true;
|
||||
continue;
|
||||
}
|
||||
@@ -90,7 +93,7 @@ function readDiff(root: string, range: string[], errors: DepthError[]): Pick<Col
|
||||
if (line[0] === '+' || line[0] === '-') (changed[cur] ??= []).push(line);
|
||||
}
|
||||
const stampOnly = perPath.map(e => e.path).filter(p => p.endsWith('package.json') && !!changed[p]?.length && changed[p]!.every(l => /^[+-]\s*"version"\s*:\s*"[^"]*"\s*,?\s*$/.test(l)));
|
||||
return { perPath, deleted, stampOnly, added, removed };
|
||||
return { perPath, deleted, stampOnly, created, added, removed };
|
||||
}
|
||||
|
||||
/** More data candidates than this keep every data file code: the consumer scan is not run. */
|
||||
@@ -158,7 +161,7 @@ export function collectCommit(root: string, baseSha: string, headSha: string): C
|
||||
const mb = git(root, ['merge-base', baseSha, headSha]);
|
||||
if (!mb.ok) {
|
||||
errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || 'no merge base'), input: `git merge-base ${baseSha} ${headSha}`, repair: 'check that both commits exist locally (git fetch origin <base>) and rerun' });
|
||||
return result({ perPath: [], deleted: [], stampOnly: [], added: {}, removed: {} }, { root: [], registry, errors, mergeBase: null, untracked: [], consumed: 'all' });
|
||||
return result({ perPath: [], deleted: [], stampOnly: [], created: [], added: {}, removed: {} }, { root: [], registry, errors, mergeBase: null, untracked: [], consumed: 'all' });
|
||||
}
|
||||
const mergeBase = mb.out.trim();
|
||||
const diff = readDiff(root, [mergeBase, headSha], errors);
|
||||
@@ -176,7 +179,7 @@ export function collectLive(root: string, base: string): Collected {
|
||||
const mb = git(root, ['merge-base', ref, 'HEAD']);
|
||||
if (!mb.ok) {
|
||||
errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || `no merge base with ${ref}`), input: `git merge-base ${ref} HEAD`, repair: `fetch the base first (git fetch origin ${base})` });
|
||||
return result({ perPath: [], deleted: [], stampOnly: [], added: {}, removed: {} }, { root, registry, errors, mergeBase: null, untracked: [], consumed: 'all' });
|
||||
return result({ perPath: [], deleted: [], stampOnly: [], created: [], added: {}, removed: {} }, { root, registry, errors, mergeBase: null, untracked: [], consumed: 'all' });
|
||||
}
|
||||
const mergeBase = mb.out.trim();
|
||||
const diff = readDiff(root, [mergeBase], errors);
|
||||
@@ -190,7 +193,7 @@ export function collectLive(root: string, base: string): Collected {
|
||||
const text = binary ? '' : buf.toString('utf8');
|
||||
const lines = text ? text.split('\n').length - (text.endsWith('\n') ? 1 : 0) : 0;
|
||||
diff.perPath.push({ path: p, lines, binary });
|
||||
if (!binary) diff.added[p] = text.split('\n');
|
||||
if (!binary) { diff.added[p] = text.split('\n'); diff.created.push(p); }
|
||||
}
|
||||
const consumed = scanConsumers(root, [...diff.perPath.map(e => e.path), ...diff.deleted], [mergeBase], true, root, errors);
|
||||
return result(diff, { root, registry, errors, mergeBase, untracked, consumed });
|
||||
|
||||
+3
-1
@@ -131,6 +131,8 @@ export interface ClassInput {
|
||||
* (lib/review-collect.ts). A candidate not listed counts as docs; 'all' or no scan keeps every data file code.
|
||||
*/
|
||||
consumed?: readonly string[] | 'all';
|
||||
/** Paths the diff creates (untracked files included): their added lines are the whole file. */
|
||||
created?: readonly string[];
|
||||
/** Added and deleted lines per path (lib/review-collect.ts): the risk classes read them; without them no line signal applies. */
|
||||
added?: Record<string, readonly string[]>;
|
||||
removed?: Record<string, readonly string[]>;
|
||||
@@ -395,7 +397,7 @@ export function planReview(i: PlanInput): ReviewPlan {
|
||||
const skipped: string[] = [];
|
||||
const dispatches: Dispatch[] = [];
|
||||
const add = (role: Role, name: string, effort: Effort, exclude: string[] = [], extra: Partial<Dispatch> = {}) => dispatches.push({ role, name, effort, exclude, ...extra });
|
||||
const docsAndOutputs = i.paths.filter(p => pathClass(i, p) === 'docs' || isOutput(i, p)).map(excludeSpec);
|
||||
const docsAndOutputs = i.paths.filter(p => (pathClass(i, p) === 'docs' && !(i.numbers.publishes && DOC_PATH.test(p))) || isOutput(i, p)).map(excludeSpec);
|
||||
const numbersChanged = i.numbers.publishes && i.reviewedNumbers !== undefined && i.reviewedNumbers !== i.numbers.fingerprint;
|
||||
const firstVoices = h.fullPasses === 0 || !i.adversarialRan;
|
||||
const specialist: Effort = i.tiers === false ? 'host' : ROLE_EFFORT.specialist;
|
||||
|
||||
+31
-10
@@ -4,16 +4,23 @@
|
||||
* and deleted lines, so it imports neither the depth nor the targeted module.
|
||||
*
|
||||
* money, auth and secret are the targeted pass's matchers (GST-257). security is a trust-boundary path or line
|
||||
* (a redaction, sanitizer, consent or trust module; a shell, eval or raw HTML sink); data is user data (a path
|
||||
* naming personal or stored data, or a line that drops or bulk-deletes it). Path and identifier signals are
|
||||
* token-bounded: costume never names cost.
|
||||
* (a redaction, sanitizer, consent or trust module; a shell, eval or raw HTML sink; outbound URL handling; archive
|
||||
* extraction and path traversal; any line naming the child_process module); data is user data (a path naming personal or
|
||||
* stored data, or a line that drops or bulk-deletes it). Path and identifier signals are token-bounded: costume never
|
||||
* names cost, and a CLI's usage text never names money.
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export const RISK_CLASSES = ['money', 'auth', 'secret', 'security', 'data'] as const;
|
||||
export type RiskClass = (typeof RISK_CLASSES)[number];
|
||||
|
||||
export const MONEY_PATH_TOKENS = ['cost', 'costs', 'spend', 'spending', 'budget', 'budgets', 'ledger', 'billing', 'price', 'prices', 'pricing', 'fee', 'fees', 'invoice', 'invoices'];
|
||||
/** Metering and charge words (GST-850: gbrain-evals #289 changed a metering proxy and billing clients with none of the older tokens); they also count on a line. */
|
||||
export const MONEY_LINE_TOKENS = ['meter', 'meters', 'metering', 'metered', 'charge', 'charges', 'charged', 'billable', 'billed', 'credits'];
|
||||
export const MONEY_PATH_TOKENS = ['cost', 'costs', 'spend', 'spending', 'budget', 'budgets', 'ledger', 'billing', 'price', 'prices', 'pricing', 'fee', 'fees', 'invoice', 'invoices', ...MONEY_LINE_TOKENS];
|
||||
/** Words that name money only beside another in the same path or line: token usage, a billing proxy, a cost or charge client. */
|
||||
export const MONEY_PAIRS: readonly (readonly [string, readonly string[]])[] = [
|
||||
['usage', ['token', 'tokens', 'cost', 'costs', 'spend', 'billing', 'price', 'pricing', 'budget']], ['proxy', ['billing', 'bill']], ['client', ['cost', 'costs', 'charge', 'charges', 'billing', 'price', 'pricing', 'spend']],
|
||||
];
|
||||
/** Identifiers ending in _usd, Usd or cap_usd, any spend_cap spelling, and paid-provider call sites (the providers lib/pricing.ts prices). */
|
||||
export const MONEY_LINE = [
|
||||
/\b[A-Za-z0-9_]*_usd\b/i, /\b[a-z][A-Za-z0-9]*Usd\b/, /\bspend[_-]?cap\b/i,
|
||||
@@ -33,22 +40,36 @@ export const SECRET_LINE = [
|
||||
];
|
||||
|
||||
const SECURITY_PATH_TOKENS = ['trust', 'redact', 'redaction', 'redactor', 'sanitize', 'sanitizer', 'neutralize', 'neutralizer', 'consent', 'escape'];
|
||||
const SECURITY_LINE = [
|
||||
/\bshell\s*:\s*true\b/, /\bshell\s*=\s*True\b/, /(?<![.\w])execSync\s*\(/, /\bchild_process\.exec(?:Sync)?\s*\(/, /\bos\.system\s*\(/,
|
||||
/** Coarse, linear-time rules (GST-850 B+): any changed line naming the child_process module, any outbound HTTP client call; quantifiers bounded. */
|
||||
export const SECURITY_LINE = [
|
||||
/\bshell\s*:\s*true\b/, /\bshell\s*=\s*True\b/, /(?<![.\w$])exec(?:Sync)?\s*\(/, /\b(?:child_process|childProcess|cp)\.exec(?:Sync)?\s*\(/, /\bos\.system\s*\(/,
|
||||
/['"`](?:node:)?child_process['"`]/,
|
||||
/(?:(?<![.\w$])|\b(?:globalThis|window|self)\.)fetch\s{0,20}\(/, /\bhttps?\.(?:request|get)\s{0,20}\(/, /\baxios\b/, /(?<![.\w$])got(?:\.[a-z]{1,10})?\s{0,20}\(/, /\bundici\b/,
|
||||
/\b(?:requests|httpx)\.[a-z]{1,10}\s{0,20}\(/, /\burllib(?:3|\.request)?\b|\burlopen\s{0,20}\(/, /\bnew\s{1,20}URL\s{0,20}\(\s{0,20}(?!['"][^'"\\]{0,2000}['"]\s{0,20}\)|import\.meta\.url\s{0,20}\))/, /\b(?:urljoin|url\.resolve)\s{0,20}\(/,
|
||||
/\bredirect\s*:\s*['"](?:follow|manual)['"]|\b(?:maxRedirects|followRedirects?|follow_redirects|allow_redirects)\s*[:=]/,
|
||||
/\b(?:HTTPS?_PROXY|NO_PROXY|ALL_PROXY|https?_proxy|no_proxy)\b|\b(?:Https?)?ProxyAgent\b|\bproxy\s*(?::|=(?![=>]))/, /\b(?:allowed|trusted|blocked|denied)[_-]?(?:hosts?|domains?|origins?|urls?)|\b(?:hosts?|domains?|origins?|urls?)[_-]?(?:allow|deny|block)[_-]?list/i,
|
||||
/\btar\.(?:x|extract)\s*\(|\.(?:extractall|extractAllTo|extractEntryTo)\s*\(|\b(?:tar|tf|zf|zip|archive)\.extract\s*\(|\bshutil\.unpack_archive\s*\(|\b(?:adm-zip|AdmZip|yauzl|extract-zip|unzipper|node-stream-zip)\b/,
|
||||
/\btar\s+(?:-[A-Za-z]*x|x[A-Za-z]*\s|--extract)|\bunzip\s+[-\w"'$]/,
|
||||
/\bpath\.(?:join|resolve)\s*\([^)]{0,300}?(?:\b(?:req|request|params|query|argv|headers?|filename|fileName|entryName|member|upload|untrusted|userInput)\b|\bentry\.(?:path|name|fileName)\b)/,
|
||||
/\b(?:includes|startsWith|endsWith|indexOf)\s*\(\s*['"`]\.\.[/\\]{0,2}['"`]|[!=]==?\s*['"`]\.\.['"`]|['"`]\.\.['"`]\s*[!=]==?|\\\.\\\./,
|
||||
/(?<![.\w])eval\s*\(/, /(?:^|[;&|])\s*eval\s+["'$]/, /\bnew\s+Function\s*\(/,
|
||||
/\b(?:spawn|spawnSync|execFile|execFileSync)\s*\(\s*['"](?:\/bin\/)?(?:ba|z)?sh['"]/, /\bBun\.spawn(?:Sync)?\s*\(\s*\[\s*['"](?:\/bin\/)?(?:ba|z)?sh['"]/,
|
||||
/\brmSync\s*\([^)]*recursive\s*:\s*true/, /\brm\s+-(?:[a-z]*r[a-z]*f|[a-z]*f[a-z]*r)\b/,
|
||||
/\brmSync\s*\([^)]{0,500}recursive\s*:\s*true/, /\brm\s+-(?:[a-z]*r[a-z]*f|[a-z]*f[a-z]*r)\b/,
|
||||
/\b(?:innerHTML|outerHTML)\s*\+?=/, /\bdangerouslySetInnerHTML\b/, /\bv-html\b/, /\{@html\b/, /\binsertAdjacentHTML\s*\(/, /\bdocument\.write\s*\(/,
|
||||
];
|
||||
const DATA_PATH_TOKENS = ['pii', 'gdpr', 'ccpa', 'privacy', 'personal', 'account', 'accounts', 'customer', 'customers', 'cookie', 'cookies', 'persistence', 'storage', 'database'];
|
||||
const DATA_LINE = [
|
||||
export const DATA_LINE = [
|
||||
/\bDELETE\s+FROM\b/, /\b(?:drop\s+(?:table|column|database|schema)|truncate\s+table)\b/i, /\bdelete\s+from\s+[\w."`]+\s*(?:where\b|;|$|["'`)])/i,
|
||||
/\.(?:deleteMany|destroyAll|bulkDelete)\s*\(/, /\b(?:destroy_all|delete_all)\b/,
|
||||
];
|
||||
|
||||
/** Whether a path or line's tokens name money: a metering or charge word (a cost word too, for a path), or a pair. */
|
||||
const namesMoney = (t: readonly string[], words: readonly string[]) => t.some(x => words.includes(x)) || MONEY_PAIRS.some(([w, beside]) => t.includes(w) && t.some(x => beside.includes(x)));
|
||||
|
||||
/** A hash of the patterns that decide the risk classes, not of this file's bytes; lib/review-depth.ts folds it into DEPTH_RULES. */
|
||||
export const RISK_RULES = createHash('sha256').update(JSON.stringify({
|
||||
RISK_CLASSES, MONEY_PATH_TOKENS, AUTH_PATH_TOKENS, AUTH_PATH_PREFIXES, KEY_NAME, SECURITY_PATH_TOKENS, DATA_PATH_TOKENS,
|
||||
MONEY_LINE_TOKENS, MONEY_PAIRS,
|
||||
patterns: Object.entries({ MONEY_LINE, SECRET_LINE, SECRET_PATH: [SECRET_PATH], SECURITY_LINE, DATA_LINE }).map(([k, v]) => [k, v.map(r => '/' + r.source + '/' + r.flags)]),
|
||||
})).digest('hex').slice(0, 16);
|
||||
|
||||
@@ -71,11 +92,11 @@ export function matchRisk(i: RiskInput): Map<RiskClass, string> {
|
||||
const t = tokens(p);
|
||||
if (i.scopeAuth && t.some(x => AUTH_PATH_TOKENS.includes(x) || AUTH_PATH_PREFIXES.some(a => x.startsWith(a)))) hit('auth', 'auth path: ' + p);
|
||||
if (SECRET_PATH.test(p)) hit('secret', 'secret path: ' + p);
|
||||
if (t.some(x => MONEY_PATH_TOKENS.includes(x))) hit('money', 'money path: ' + p);
|
||||
if (namesMoney(t, MONEY_PATH_TOKENS)) hit('money', 'money path: ' + p);
|
||||
if (t.some(x => SECURITY_PATH_TOKENS.includes(x))) hit('security', 'security path: ' + p);
|
||||
if (t.some(x => DATA_PATH_TOKENS.includes(x))) hit('data', 'data path: ' + p);
|
||||
for (const line of [...(i.added?.[p] ?? []), ...(i.removed?.[p] ?? [])]) {
|
||||
if (!why.has('money') && MONEY_LINE.some(r => r.test(line))) hit('money', 'money line: ' + p);
|
||||
if (!why.has('money') && (MONEY_LINE.some(r => r.test(line)) || namesMoney(tokens(line), MONEY_LINE_TOKENS))) hit('money', 'money line: ' + p);
|
||||
if (!why.has('secret') && SECRET_LINE.some(r => r.test(line))) hit('secret', 'secret line: ' + p);
|
||||
if (!why.has('security') && SECURITY_LINE.some(r => r.test(line))) hit('security', 'security line: ' + p);
|
||||
if (!why.has('data') && DATA_LINE.some(r => r.test(line))) hit('data', 'data line: ' + p);
|
||||
|
||||
@@ -21,14 +21,14 @@ import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { classifyPath } from './review-depth';
|
||||
import { AUTH_PATH_PREFIXES, AUTH_PATH_TOKENS, KEY_NAME, MONEY_LINE, MONEY_PATH_TOKENS, SECRET_LINE, SECRET_PATH, matchRisk } from './review-risk';
|
||||
import { AUTH_PATH_PREFIXES, AUTH_PATH_TOKENS, KEY_NAME, MONEY_LINE, MONEY_LINE_TOKENS, MONEY_PAIRS, MONEY_PATH_TOKENS, SECRET_LINE, SECRET_PATH, matchRisk } from './review-risk';
|
||||
|
||||
export const TARGETED_CLASSES = ['money', 'gate', 'auth', 'secret'] as const;
|
||||
export type TargetedClass = (typeof TARGETED_CLASSES)[number];
|
||||
|
||||
/** A hash of the patterns that decide the classes, not of this file's bytes: a comment edit keeps it, a pattern edit changes it. */
|
||||
export const TARGETED_RULES = createHash('sha256').update(JSON.stringify({
|
||||
TARGETED_CLASSES, MONEY_PATH_TOKENS, AUTH_PATH_TOKENS, AUTH_PATH_PREFIXES, KEY_NAME,
|
||||
TARGETED_CLASSES, MONEY_PATH_TOKENS, MONEY_LINE_TOKENS, MONEY_PAIRS, AUTH_PATH_TOKENS, AUTH_PATH_PREFIXES, KEY_NAME,
|
||||
patterns: Object.entries({ MONEY_LINE, SECRET_LINE, SECRET_PATH: [SECRET_PATH] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]),
|
||||
})).digest('hex').slice(0, 16);
|
||||
|
||||
|
||||
+1
-1
@@ -175,7 +175,7 @@ function conflictHint(mergeable: unknown, ci: { green: boolean; none: boolean },
|
||||
if (mergeable !== 'CONFLICTING') return null;
|
||||
const ref = branch && /^[\w.][\w./-]*$/.test(branch) ? `--ref ${branch}` : '--ref <branch>';
|
||||
if (ci.none && fork) return `ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${head.slice(0, 12)}) and its head is on a fork, so the free workflows cannot be dispatched here; the coordinator decides (advisory)`;
|
||||
if (ci.none) return `ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${head.slice(0, 12)}); dispatch the free workflows on the branch (gh workflow run free-tests.yml ${ref} && gh workflow run windows-smoke.yml ${ref}), clear the pregate lanes with their run URLs (gstack-pregate clear --remote <lane> --run-url <run>), then rerun ready (advisory)`;
|
||||
if (ci.none) return `ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${head.slice(0, 12)}); dispatch the free workflows on the branch (gh workflow run free-tests.yml ${ref} && gh workflow run windows-smoke.yml ${ref}), clear each lane pregate names with a run of that lane's own workflow (gstack-pregate clear --remote <workflow.yml/job> --run-url <that workflow's run>; a run of another workflow is refused), then rerun ready (advisory)`;
|
||||
if (ci.green) return `ci-hint: PR conflicts with the base (no merge ref); the runs at ${head.slice(0, 12)} were not pull_request runs (dispatched on the branch, or push-triggered), and they count (advisory)`;
|
||||
return null;
|
||||
}
|
||||
|
||||
+26
-2
@@ -11,6 +11,8 @@
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { mergeChangelogs, normalizeChangelog } from './changelog-check';
|
||||
import { versionAtRef } from './restamp';
|
||||
import { classifyPath } from './review-depth';
|
||||
import { comparePath, contextPacket, gateEditedPaths, isTestCode, PACKET_SECTION_CAP, readScopedReview, touchedPaths, type PatchSides } from './ship-patch';
|
||||
import { parseReceiptDocs, type ReceiptDocs } from './ship-receipt-docs';
|
||||
@@ -217,7 +219,12 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: string
|
||||
return by;
|
||||
}
|
||||
|
||||
/** Step 16 stage 3. Test code, paths only the base moved, and --docs-requested fixes the audit's own result asked for do not stale the audit (GST-391). */
|
||||
/**
|
||||
* Step 16 stage 3. Test code, paths only the base or a stamp moved, a slot restamp after the audit (the CHANGELOG's heading
|
||||
* move or re-heading after the restamp writer's own merge of the base's CHANGELOG into the audited one, the version file's
|
||||
* version, each against the audited bytes; GST-906), and --docs-requested fixes the audit's own result asked for do not
|
||||
* stale the audit (GST-391).
|
||||
*/
|
||||
function docsFresh(cwd: string, base: string, postFile: string, moved: readonly string[], requested: string[], resultFile: string | undefined, r: Report): boolean {
|
||||
const cmp = bin(cwd, 'gstack-docs-candidate', ['compare', postFile], 120_000);
|
||||
let stale: string[] | null = null;
|
||||
@@ -253,7 +260,24 @@ function docsFresh(cwd: string, base: string, postFile: string, moved: readonly
|
||||
if (!atAudit || (nowDiff && nowDiff.status !== 0)) { r.stop(`git could not list the paths the branch touched against ${baseRef}; documentation freshness is unknown (stage 3)`); return false; }
|
||||
const branchAtAudit = new Set([...atAudit, ...record.staged, ...record.unstaged, ...record.untracked]);
|
||||
const branchNow = new Set([...(nowDiff ? nul(nowDiff.stdout) : []), ...nul(git(cwd, ['ls-files', '-z', '--others', '--exclude-standard']).stdout)]);
|
||||
const carried = { tests: stale.filter(isTestCode), base: stale.filter(p => !isTestCode(p) && (moved.includes(p) || (!!auditBase && !!nowBase && !branchAtAudit.has(p) && !branchNow.has(p)))), requested: [] as string[] };
|
||||
const changelog = shipPolicy(cwd, base)?.changelog;
|
||||
const versionFile = versionAtRef(cwd, 'HEAD');
|
||||
const restamped = stale.filter(p => {
|
||||
const isChangelog = p === changelog;
|
||||
if (!isChangelog && !('rel' in versionFile && p === versionFile.rel)) return false;
|
||||
const audited = typeof record.hashes?.[p] === 'string' ? git(cwd, ['cat-file', 'blob', record.hashes[p]]) : null;
|
||||
if (audited?.status !== 0) return false;
|
||||
let now: string;
|
||||
try { now = fs.readFileSync(path.join(cwd, p), 'utf8'); } catch { return false; }
|
||||
if (!isChangelog) return !!parseVersion(audited.stdout.trim()) && !!parseVersion(now.trim());
|
||||
const [atAuditBase, atNowBase] = [auditBase, nowBase].map(b => b ? git(cwd, ['show', `${b}:${p}`]) : null);
|
||||
if (atAuditBase?.status !== 0 || atNowBase?.status !== 0) return false;
|
||||
if (atAuditBase.stdout === atNowBase.stdout) return normalizeChangelog(audited.stdout) === normalizeChangelog(now);
|
||||
const kept = mergeChangelogs(audited.stdout, atAuditBase.stdout, atAuditBase.stdout);
|
||||
const merged = mergeChangelogs(audited.stdout, atNowBase.stdout, atAuditBase.stdout);
|
||||
return kept !== null && merged !== null && normalizeChangelog(kept) === normalizeChangelog(audited.stdout) && normalizeChangelog(merged) === normalizeChangelog(now);
|
||||
});
|
||||
const carried = { tests: stale.filter(isTestCode), base: stale.filter(p => !isTestCode(p) && (moved.includes(p) || restamped.includes(p) || (!!auditBase && !!nowBase && !branchAtAudit.has(p) && !branchNow.has(p)))), requested: [] as string[] };
|
||||
let audit: any = null;
|
||||
try { audit = resultFile ? JSON.parse(fs.readFileSync(resultFile, 'utf8')) : null; } catch { audit = null; }
|
||||
for (const p of requested.filter(q => stale!.includes(q) && !carried.tests.includes(q) && !carried.base.includes(q))) {
|
||||
|
||||
Reference in new issue
Block a user