mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
fix(autoplan): open Critical and High findings reach the gate as apply items (GBRA-223 claim 3)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
6ac7e073e5
commit
a2aea43fdf
12 files changed
+128
-45
No files matched your search
@@ -108,11 +108,15 @@ export function reviewRows(state: RunState, prev: PhaseState | undefined): Findi
|
||||
const earlierRows = (state: RunState, phase: string): FindingRow[] =>
|
||||
state.history.filter(h => h.event === 'reopened' && h.phase === phase).flatMap(h => reviewRows(state, h.previous as PhaseState | undefined));
|
||||
|
||||
/** Earlier finding id -> the answered decision whose gate item named it. */
|
||||
/** The origin tag of an open Critical/High finding's gate item: only `a` (its fix applied to the plan) settles it. */
|
||||
export const FINDING_ORIGIN = 'finding';
|
||||
|
||||
/** Earlier finding id -> the answered decision whose gate item named it. A finding carried into the build stays open. */
|
||||
export function settledIds(state: RunState): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
for (const d of readDecisions(state.out)) {
|
||||
if (d.chosen == null || d.status === 'pending' || !d.origin) continue;
|
||||
if (d.origin.endsWith(`|${FINDING_ORIGIN}`) && d.chosen !== 'a') continue;
|
||||
for (const id of (d.origin.split('|')[1] ?? '').split('+')) if (id && id !== 'null') out.set(id, d.id);
|
||||
}
|
||||
return out;
|
||||
|
||||
+21
-5
@@ -3,7 +3,7 @@
|
||||
* (plan B2, B9, B11). `closePhase` reconciles the outside pass against the
|
||||
* native one from canonical ids (lib/autoplan-reconcile.ts), writes the
|
||||
* consensus record and the timing row, and marks the phase closed.
|
||||
* `finishGate` turns disagreements and user challenges into one gate list
|
||||
* `finishGate` turns disagreements, user challenges and open Critical/High findings into one gate list
|
||||
* (lib/gate-list.ts: `d<n>` auto items, `uc<n>` approval items, `p1` the plan
|
||||
* approval), writes decisions.jsonl, tasks.jsonl and the exported files, and
|
||||
* leaves the run `gate_pending`: unattended never approves. `answerGate`
|
||||
@@ -26,9 +26,9 @@ import { FILES, RunError, journal as journalRow, now, readDecisions, voiceRows,
|
||||
import { applyCapChoices, pendingDeltas, releaseUndispatched, roundCap, roundOf } from './autoplan-budget';
|
||||
import { evaluateCarry, gateEvidence, voidPendingCheck } from './autoplan-carry';
|
||||
import { buildEli5, capyRelayLine } from './captain-brief';
|
||||
import { previousReview, reviewRows, settledIds } from './autoplan-confirm';
|
||||
import { FINDING_ORIGIN, previousReview, reviewRows, settledIds } from './autoplan-confirm';
|
||||
import { phaseClose } from './autoplan-timing';
|
||||
import { ANSWERED_BY_RE, applyReply, decisionId, decisionRows, keepDirection, parseReply, pendingHint, recHash, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption, type ParsedReply } from './gate-list';
|
||||
import { ANSWERED_BY_RE, applyReply, autoDefault, decisionId, decisionRows, keepDirection, parseReply, pendingHint, recHash, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption, type ParsedReply } from './gate-list';
|
||||
import type { FindingRow } from './headless-artifacts';
|
||||
import { readJsonl, sha256File } from './headless-artifacts';
|
||||
import { closeDraftDirectionsFile, loadBriefInputs } from './owner-brief';
|
||||
@@ -205,8 +205,9 @@ export function buildGate(state: RunState, gateRev: number): GateList {
|
||||
const byId = new Map([...native, ...outside].map(f => [f.id, f]));
|
||||
const reran = gateRev > 1 && state.history.some(h => h.event === 'reopened' && h.phase === phase);
|
||||
const origin = (ids: string, options: GateOption[]) => [phase, ids, ps.closed_at, ps.snapshot, optionHash(options)].join('|');
|
||||
const push = (item: GateItem, ids: string) => items.push({ ...item, origin: origin(ids, item.options) });
|
||||
const push = (item: GateItem, ids: string, tag?: string) => items.push({ ...item, origin: [origin(ids, item.options), ...(tag ? [tag] : [])].join('|') });
|
||||
const challenged = new Set<string>();
|
||||
const twins = new Map(consensus.rows.filter(r => r.native_id && (r.disposition === 'confirmed' || r.disposition === 'unverified')).map(r => [r.native_id!, r.outside_id]));
|
||||
for (const row of consensus.rows) {
|
||||
const o = byId.get(row.outside_id)! as FindingRow & { user_challenge?: boolean; changes?: string; settled_by?: string };
|
||||
if (o.settled_by) continue;
|
||||
@@ -223,6 +224,7 @@ export function buildGate(state: RunState, gateRev: number): GateList {
|
||||
continue;
|
||||
}
|
||||
if (row.disposition === 'disagree') {
|
||||
challenged.add(o.id); challenged.add(n!.id);
|
||||
push({ id: `d${++d}`, title: `${n!.title} [${row.native_id} vs ${row.outside_id}]`, kind: 'auto', recommended: 'a', phase,
|
||||
why: `voices disagree: ${row.resolution}`, cost: `b reopens ${phase}${phase === 'eng' ? '' : ' and eng'}`,
|
||||
options: [{ key: 'a', text: `native position (${row.severity.native})`, changes_plan: false }, { key: 'b', text: `outside position (${row.severity.outside})`, changes_plan: true }] }, ids);
|
||||
@@ -235,6 +237,20 @@ export function buildGate(state: RunState, gateRev: number): GateList {
|
||||
push({ id: `d${++d}`, title: `${f.title} [${f.id}]`, kind: 'auto', recommended: 'a', phase, why, ...(f.changes ? { changes: f.changes } : {}),
|
||||
cost: `b reopens ${phase}${phase === 'eng' ? '' : ' and eng'}`, options: [KEEP('keep your direction'), { key: 'b', text: 'accept the change', changes_plan: true }] }, f.id);
|
||||
}
|
||||
// An open Critical or High finding the items above do not cover: rec applies its fix to the plan.
|
||||
const pairedOutside = new Set(twins.values());
|
||||
for (const f of [...native, ...outside] as Array<FindingRow & { user_challenge?: boolean; settled_by?: string }>) {
|
||||
if (f.voice === 'outside' && pairedOutside.has(f.id)) continue;
|
||||
const group = [f, ...(twins.has(f.id) ? [byId.get(twins.get(f.id)!)!] : [])] as Array<FindingRow & { user_challenge?: boolean; settled_by?: string }>;
|
||||
if (group.some(g => challenged.has(g.id) || g.user_challenge || g.settled_by)) continue;
|
||||
const severity = group.some(g => g.severity === 'Critical') ? 'Critical' : group.some(g => g.severity === 'High') ? 'High' : null;
|
||||
if (!severity) continue;
|
||||
const ids = group.map(g => g.id);
|
||||
const fixes = [...new Set(group.map(g => g.resolution).filter((r): r is string => !!r))];
|
||||
push({ id: `d${++d}`, title: `${severity}: ${f.title} [${ids.join(' + ')}]`, kind: 'auto', recommended: 'a', phase, finding: ids,
|
||||
why: `an open ${severity} finding (${group.map(g => g.voice).join(' + ')}); rec applies its fix to the plan`, cost: `a reopens ${phase}${phase === 'eng' ? '' : ' and eng'} once the plan is revised`,
|
||||
options: [{ key: 'a', text: `apply the fix to the plan: ${fixes.length ? fixes.join('; ') : `resolve "${f.title}"`}`, changes_plan: true }, { key: 'b', text: 'carry it into the build as a blocking task (no plan change)', changes_plan: false }] }, ids.join('+'), FINDING_ORIGIN);
|
||||
}
|
||||
}
|
||||
items.push(...capItems(state, { d: () => `d${++d}`, uc: () => `uc${++uc}` }));
|
||||
items.push(planApproval(state, items));
|
||||
@@ -277,7 +293,7 @@ export function recommendGate(state: RunState, gateRev: number, itemId: string,
|
||||
writeDecisions(state.out, readDecisions(state.out).map(r => {
|
||||
const i = updated.get(r.id);
|
||||
if (!i) return r;
|
||||
return { ...r, recommended: i.recommended, ...(i.kind === 'auto' ? { default_if_unanswered: i.recommended } : {}), ...(i.recommended_basis ? { recommended_basis: i.recommended_basis } : {}), ...(i.recommended_by ? { recommended_by: i.recommended_by } : {}) };
|
||||
return { ...r, recommended: i.recommended, ...(i.kind === 'auto' ? { default_if_unanswered: autoDefault(i) } : {}), ...(i.recommended_basis ? { recommended_basis: i.recommended_basis } : {}), ...(i.recommended_by ? { recommended_by: i.recommended_by } : {}) };
|
||||
}));
|
||||
const hash = recHash(list);
|
||||
journalRow(state.out, { event: 'recommended', gate_rev: gateRev, item: itemId, option, basis, by, rec_rev: list.rec_rev, rec_hash: hash });
|
||||
|
||||
+11
-3
@@ -2,7 +2,8 @@
|
||||
* gate-list — one gate list instead of four-option sequences (plan B9).
|
||||
* A gate list is numbered items, each with a stable id, a recommended default
|
||||
* and lettered options; `auto` items take their recommendation when
|
||||
* unanswered, `approval` items stay pending until answered. One reply
|
||||
* unanswered (one whose recommendation changes the plan keeps the plan's
|
||||
* direction instead), `approval` items stay pending until answered. One reply
|
||||
* grammar: `rec` (every item takes its recommendation, plan changes
|
||||
* included), `<id><option>` tokens (`d3b uc1a`), or `rec except <tokens>`
|
||||
* (the same operation as listing the overrides). `all`, `approve all`,
|
||||
@@ -39,6 +40,8 @@ export interface GateItem {
|
||||
carried?: { from: string; chosen: string; gate_rev: number };
|
||||
/** A round-cap item (lib/autoplan-budget.ts): `b` grants the phase one more round and reopens it. */
|
||||
cap?: { phase: string; kind: 'deferred' | 'blocker' | 'delta'; ref?: string };
|
||||
/** An open Critical or High finding (its ids, a paired twin included): `a` applies the reviewer's fix to the plan. */
|
||||
finding?: string[];
|
||||
}
|
||||
export interface GateList { schema_version: 1; run: string; gate_rev: number; items: GateItem[]; title?: string;
|
||||
/** Bumped by every `recommend`, so a `rec` relayed before it no longer matches `recHash`. */
|
||||
@@ -203,7 +206,7 @@ export function parseReply(reply: string, list: GateList, gateRev?: number, prio
|
||||
else if (earlier !== undefined && item.options.some(o => o.key === earlier)) choices.push({ id: item.id, chosen: earlier, kind: item.kind, how: how(item, earlier), prior: true });
|
||||
else if (asIs) { const keep = keepDirection(item); choices.push({ id: item.id, chosen: keep, kind: item.kind, how: how(item, keep) }); }
|
||||
else if (rec) choices.push({ id: item.id, chosen: item.recommended, kind: item.kind, how: 'recommended' });
|
||||
else if (item.kind === 'auto') choices.push({ id: item.id, chosen: item.recommended, kind: item.kind, how: 'auto_default' });
|
||||
else if (item.kind === 'auto') choices.push({ id: item.id, chosen: autoDefault(item), kind: item.kind, how: 'auto_default' });
|
||||
else pending.push(item.id);
|
||||
}
|
||||
return { ...base, valid: true, choices, pending, mode: rec ? 'rec' : asIs ? 'as-is' : 'tokens' };
|
||||
@@ -214,6 +217,11 @@ export function keepDirection(item: GateItem): string {
|
||||
return item.options.find(o => o.changes_plan === false)?.key ?? (item.id === 'p1' && item.options.some(o => o.key === 'a') ? 'a' : item.recommended);
|
||||
}
|
||||
|
||||
/** What an unanswered `auto` item resolves to: its recommendation, unless that changes the plan (then the plan's direction). */
|
||||
export function autoDefault(item: GateItem): string {
|
||||
return item.options.find(o => o.key === item.recommended)?.changes_plan ? keepDirection(item) : item.recommended;
|
||||
}
|
||||
|
||||
/** The decisions.jsonl id for a gate item. Item ids restart at every gate revision, so revisions after the first carry `r<rev>-`. */
|
||||
export function decisionId(run: string, gateRev: number, itemId: string): string {
|
||||
return gateRev > 1 ? `${run}-r${gateRev}-${itemId}` : `${run}-${itemId}`;
|
||||
@@ -224,7 +232,7 @@ export function decisionRows(list: GateList, extra: Partial<DecisionRow> = {}):
|
||||
return list.items.map(item => ({
|
||||
schema_version: 1, run: list.run, id: decisionId(list.run, list.gate_rev, item.id), label: item.id.toUpperCase(), title: item.title,
|
||||
options: item.options.map(o => o.key), recommended: item.recommended, kind: item.kind, status: 'pending',
|
||||
default_if_unanswered: item.kind === 'auto' ? item.recommended : null, gate_rev: list.gate_rev,
|
||||
default_if_unanswered: item.kind === 'auto' ? autoDefault(item) : null, gate_rev: list.gate_rev,
|
||||
...(item.phase ? { phase: item.phase } : {}), ...(item.cost ? { cost: item.cost } : {}),
|
||||
...(item.recommended_basis ? { recommended_basis: item.recommended_basis } : {}), ...(item.changes ? { changes: item.changes } : {}),
|
||||
...(item.origin ? { origin: item.origin } : {}), ...(item.reopens === false ? { source: 'agent' } : {}), ...extra,
|
||||
|
||||
Reference in new issue
Block a user