mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-04 15:57:33 +01:00
* docs(designs): gstack field wave plan (2026-10-12) and the shared simulated Capy host fixture
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(autoplan): scope keys on rendered surfaces, per-axis provenance, --no-dx and a read-only plan marker
UI scope reads the Implementation plan only and needs a rendered file path or a UI
term next to a visual noun; bare UI words stay weak evidence (sections load, no
Design phase). Scope records sources {ui,dx} (flag|marker|detected), --no-dx turns
DX off, a first-line <!-- gstack-autoplan-scope: ... --> marker is read and never
written, and the first next prints SCOPE: evidence plus a SCOPE_PIN hint. The
snapshot tool's scope output carries uiRequired from the same detector and
autoplan Step 2 uses it instead of a prose grep (field wave item 11).
Intentional pin change: test/review-checklists.test.ts Scope literal and the
source= line shape (source=ui:detected,dx:detected).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(plan-reality): a receipt per measured number, the rule text for a rule claim, dot-path receipts
Each clause stating a measured quantity (a unit, or a number after measured/costs/
takes/took) needs its own receipt; misses print unreceipted_numbers=<n> and one
NUMBER_UNRECEIPTED: <clause> line each. A line claiming a decision rule must quote
it or link its file:line (unquoted=<n>). Either miss maps to the existing verdict
(incomplete unattended, repair otherwise). RECEIPT_RE accepts dot paths and needs
a hex letter or a commit/backtick context for a sha. New pure checkPlanReality(planText)
runs the same checks over plan prose for gstack-autoplan submit (field wave item 12).
The unreceipted= text token is now pinned as well as the JSON.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(autoplan): generated DRIVER.md, the compact entry for a parent driving gstack-autoplan
autoplan/DRIVER.md (from autoplan/DRIVER.md.tmpl) is what a parent agent on any host
reads instead of autoplan/SKILL.md: phase order and scope flags, the next/submit/
answer/revise/export loop with the plan-specified submit --record/--attest/
--unavailable, revise --incorporates/--withdraw and next --scaffold, the reviewer
RESULT FORMAT, the gate grammar including all as-is, the run files, the result codes
and the coordinator contract, every list rendered from code (117 lines). gen:skill-docs
emits it as a Claude asset, so normal, out-dir and dry-run inventories and the
freshness check cover it. gstack-autoplan --help and the first next print
DRIVER: <root>/autoplan/DRIVER.md, resolved through an install root's recorded
.source-path (new lib/source-root.ts) (field wave item 13).
Intentional pin change: test/gen-skill-docs-checks.test.ts asset inventory gains
autoplan/DRIVER.md.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(doctor): --min-revision, a launch Bun found off PATH, planning-only pins SKIP
gstack-doctor --check gains --min-revision <v>|unknown: below it the revision
row FAILs with REVISION_BELOW_MINIMUM naming the Capy wrapper or
bin/gstack-upgrade; unknown says so and never claims the minimum. The runtime
and pins rows name a supported Bun found in $BUN_INSTALL/bin (not on PATH)
with the export/GSTACK_LAUNCH fix (gstack_bun_launch_dir, one helper in
bin/gstack-bun-version.sh). --for a planning-only workflow (autoplan, the plan
reviews, office-hours) reports a repo test-pin mismatch as SKIP pins
(planning-only; ...). New result codes UPGRADE_REFUSED, UPGRADE_FETCH_FAILED,
REVISION_BELOW_MINIMUM, CAPY_WRAPPER_STALE, INSTALL_UNVERIFIED with
troubleshooting anchors; REVISION_UNMET's fix names bin/gstack-upgrade.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(install): install verdict and install admission at skill start
bin/gstack-install-verdict.sh (Bash) and lib/install-verdict.ts (TypeScript)
are the one reader of the install verdict gstack-capy-install writes to
<state root>/install-verdicts/<sha256(checkout realpath)>.json (checkout,
HEAD, workflow and required components, planning-only pins, trailer). The
source checkout and capy/support.env resolve through gstack_source_root /
sourceRoot, which follow an installed root's .source-path.
gstack-skill-start, after SESSION_KIND: an unattended session (any host)
refuses on a PATH Bun below the supported minimum; with GSTACK_CAPY=1 an
unattended or explicit spawned session also refuses without a passing
verdict for this checkout, HEAD and workflow (INSTALL_UNVERIFIED). A refusal
prints the INSTALL: row, an install-refused instruction block bound to its
SESSION_ID, GSTACK_RESULT ... status=refused run=- and exits 3. Ambient
spawned sessions warn; GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 admits and says so.
capy/support.env declares GSTACK_MIN_REVISION and GSTACK_CAPY_MIN_WRAPPER.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(capy-install): launch PATH, Bun reuse and CI pins, setup skip on HEAD, verdict, --check
The GSTACK_LAUNCH line carries PATH=<dir>:$PATH when the certified Bun is not
first on PATH, plus GSTACK_CAPY=1 and GSTACK_INSTALL_VERDICT, and
GSTACK_LAUNCH_PATH: <dir>; the doctor runs with that PATH. A supported Bun in
$BUN_INSTALL/bin is reused instead of downloaded again. Project pins are read
with bin/gstack-runtime-pins.ts (CI lanes included): when every exact pin is
gstack's verified version the archive is installed alongside a different PATH
Bun; any other exact pin is RUNTIME_PIN_CONFLICT. Both ./setup runs are
skipped when this HEAD was set up with the same hosts, model and browser flag
and the check passes (never after a fast-forward); a failed setup ends with
gstack: fail setup and is retried next run. --check certifies only;
GSTACK_CAPY_LOCK_HELD=1 accepts the bootstrapper's held lock; result anchors
come from the generated components table; REV_NOTE names bin/gstack-upgrade.
Intentional pin change: test/gstack-capy-install.test.ts --revision trailer
now names <root>/bin/gstack-upgrade (the old text named a command that never
existed).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* result codes: PLAN_SECTIONS_INVALID, PLAN_AMEND_PENDING, GSTACK_VERSION_CHANGED, INSTALL_VERDICT_FAILED, ARTIFACT_GATE_UNSETTLED
Troubleshooting anchors for each and the regenerated doctor components table (field wave lane B-core).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* artifact contract: changes, recommended_basis, carried decisions, attested count, settled-gate check
Findings and decisions carry the user challenge's `changes` value; decisions record recommended_basis, origin,
carried_from and chosen_text; validate refuses a complete run with an unsettled gate (ARTIFACT_GATE_UNSETTLED) and
prints attested=<n>. The gate reply grammar gains `all as-is` and resolves partial replies against prior answers
(field wave B2a/B2b).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* autoplan: run-state fields, plan-text hashing, version migration and admission modules
RunState gains gstack_version, plan_input/heading, scope_flags, plan_revision, amendments and plan_reality; voices
record receipt kind and reviewed hash. New pure modules: Implementation-plan review hash, scaffold and drift lines
(B1c); gstack_version stamp and legacy decision-id migration (B1a); install-verdict and Bun-minimum admission
with a local verdict shim until lane A's lib/install-verdict.ts lands.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
bin/gstack-autoplan-snapshot.ts exports its section scanner and review projection, and initializes only a plan with
no section headings by wrapping it.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* autoplan: closable runs - submit --record/--attest/--unavailable, revise, amendment interlock, agreeing-UC defaults, admission
B1a: an older run directory prints GSTACK_VERSION_CHANGED once and migrates legacy decision ids; a newer one refuses.
B1b: submit binds hand-run records (fence, table, severity list, No findings.), attested binds need --attempt,
--attempt is optional with one open attempt, --unavailable/--failed close an outside voice native_only, and submit
caches the plan-reality report by Implementation-plan hash.
B1c: revise reviews a revised plan in the same run as one recoverable transaction (reopen on hash drift plus Eng,
add newly in-scope phases, .r<rev>/.v<k> archives, gate_rev+1 at the gate); PLAN_SECTIONS_INVALID and next --scaffold;
PLAN_CHANGED / PLAN_SOURCE_CHANGED drift hints.
B2a/B2b: user challenges carry changes; agreeing voices with the same changes on a confirmed finding recommend b with
both remedies shown (or the reconcile proposal); accepted changes print APPLY and become pending amendments that next
and export refuse past until revise --incorporates names them or --withdraw drops one; all as-is; partial replies,
DECIDED block, carry-forward and rerun demotion.
Mutating verbs pass admission (GSTACK_CAPY install verdict; unattended Bun minimum; override recorded as a deviation).
Intentional pin rewrite: test/autoplan-answer-invalidation.test.ts reopen tests now revise --incorporates before rerun.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(upgrade): bin/gstack-upgrade, the unattended fast-forward every fix line names
--check fetches and prints current|behind <installed> -> <remote> (<n>
commits); the default fast-forwards to the remote default branch tip and
reinstalls (bin/gstack-capy-install on a Capy install, else ./setup
--refresh-registered), completing a pending reinstall on the next run. It
refuses (UPGRADE_REFUSED, exit 3, nothing changed) a dirty tracked byte, a
diverged commit, a pinned checkout, the incoming Bun floor and a held
.setup-lock; never stashes or resets; an unreachable remote is
UPGRADE_FETCH_FAILED. Fetches are receipted (fail-open). The unattended
skill-end UPGRADE_AVAILABLE line is followed by UPGRADE_COMMAND naming it.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(capy): vendored Capy skill, frozen bootstrapper and full wrapper bin/gstack-capy-session
capy/skill/gstack/ is the Drive skill's source of truth. scripts/install.sh
is a frozen bootstrapper: it prints gstack-capy-skill: wrapper <n> (<path>),
clones or fetches under .gstack-capy-install.lock, reads the remote
capy/support.env (FETCH_HEAD), fast-forwards a clean unpinned checkout below
GSTACK_MIN_REVISION (refusing dirty, diverged or pinned checkouts with the
exact commands; GSTACK_CAPY_AUTO_UPGRADE=0 disables), and execs the
checkout's own bin/gstack-capy-session with GSTACK_CAPY_LOCK_HELD=1 so the
held lock is never taken twice. Offline it says the remote minimum is
unknown. A Drive copy older than GSTACK_CAPY_MIN_WRAPPER prints an owner
action (CAPY_WRAPPER_STALE). SKILL.md: check at session start, install
lazily before the first workflow, the GSTACK_LAUNCH line, the trailer rule,
the subagent first-report line, gstack-pregate sealed before any push, and
the autoplan/DRIVER.md pointer. The context-budget fixture gains the skill's
entry.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: Capy install layers, lazy install, snapshot bake, forward-only recovery, raising the minimum
docs/capy-install.md describes the three layers (Drive bootstrapper, full
wrapper, installer), the lazy install, the GSTACK_LAUNCH line, install
admission, baking gstack into a Capy snapshot, and that recovery after an
interrupted fast-forward completes or refuses the pending install stages
(checking out an older commit is not a rollback). docs/CHANGELOG_STYLE.md
(the release doc; there is no docs/RELEASING.md) says when
GSTACK_MIN_REVISION may be raised.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* refactor(capy): the stale-copy owner action moves from the frozen bootstrapper to bin/gstack-capy-session
The checkout-owned full wrapper reads GSTACK_CAPY_MIN_WRAPPER through
gstack_support_env_get(gstack_source_root) and compares it with the
bootstrapper revision it was handed, so the rule can change without
recopying the Drive skill. The bootstrapper keeps only what a stale checkout
cannot do: clone, fetch, read the remote minimum, fast-forward, exec.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* v1.91.96.0: honest install and self-upgrade on Capy; supported minimum 1.91.96.0
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* v1.91.97.0: closable unattended autoplan runs on Capy
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: drop the helper-only Capy host fixture test (test-of-test ratchet); lane tests exercise the helper
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(autoplan): revise naming a non-pending decision refuses with AMENDMENT_UNKNOWN, not PLAN_AMEND_PENDING
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: bound the new sync spawns in the install-verdict and wrapper tests (spawnSync tripwire)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(autoplan): pin CLI refusals to the result-code registry's fix text
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(gen-skill-docs): emit autoplan/DRIVER.md only when the autoplan template is in the tree (setup fixtures with a skill subset)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(autoplan): run.lock is linked into place with its owner written; a fresh unreadable lock is held, not stolen
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* ci(paid-pr-profile): the vendored Capy skill is free-only; free-only files are not prompts needing paid coverage
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* chore(harness): non-behavioral HARNESS_VERSION decision for the PR-profile free-only rule
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(doctor): lane A runtime and pins cases are platform-neutral (bash paths; a lane-free .bun-version pin)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(designs): field wave plan with amendments and the gate rev 2-3 review record (ARTIFACT_VALID, 3 gate revisions)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(capy-install): follow a project's exact Bun pin above the minimum when gstack has its verified archive (adds Bun 1.4.3)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: the installer follows a verified exact project Bun pin (capy-install, troubleshooting, changelog)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(capy-install): the install reason names the project's pin and gstack's real minimum
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* ci: run the main-targeted checks after retargeting #3165 to main
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(autoplan-admission): the PATH-Bun refusal case needs the POSIX Capy host stub; skip it on Windows
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>
333 lines
22 KiB
TypeScript
333 lines
22 KiB
TypeScript
/**
|
|
* autoplan-gate — phase close and the final gate of the unattended runner
|
|
* (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
|
|
* (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`
|
|
* applies one reply atomically (a stale or conflicting reply writes nothing;
|
|
* a partial one records its answers and keeps the gate); a chosen option that
|
|
* changes the plan (`changes_plan`) reopens the dependent phases with Eng last
|
|
* and is recorded as a pending amendment, and only a reply that changes
|
|
* nothing reviewed finalizes `run.json.status`. Rerun gates turn challenges
|
|
* that name no scope, cost or direction into agent decisions, and answers to
|
|
* unchanged proposals from phases that did not rerun carry forward.
|
|
*/
|
|
import { createHash } from 'node:crypto';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { exportRun } from './autoplan-export';
|
|
import { driftLine } from './autoplan-plan-text';
|
|
import { consensusRecord, reconcile, renderConsensus, type ConsensusRecord, type ReconciliationOverride, type ReconciliationRow } from './autoplan-reconcile';
|
|
import type { Phase } from './autoplan-prompts';
|
|
import { FILES, RunError, journal as journalRow, now, readDecisions, voiceRows, writeDecisions, type RunState, type VoiceState } from './autoplan-state';
|
|
import { phaseClose } from './autoplan-timing';
|
|
import { applyReply, decisionId, decisionRows, keepDirection, parseReply, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption } from './gate-list';
|
|
import type { FindingRow } from './headless-artifacts';
|
|
import { readJsonl } from './headless-artifacts';
|
|
import { closeDraftDirectionsFile, loadBriefInputs } from './owner-brief';
|
|
import { settle } from './spend-ledger';
|
|
|
|
export function loadOverrides(file: string | undefined): ReconciliationOverride[] {
|
|
if (!file) return [];
|
|
const read = readJsonl(file);
|
|
if (read.errors.length) throw new RunError('ARTIFACT_MALFORMED_JSONL', read.errors.map(e => e.message).join('; '));
|
|
return read.rows as unknown as ReconciliationOverride[];
|
|
}
|
|
|
|
function seconds(v: VoiceState | undefined): number | undefined {
|
|
const a = v?.attempts?.find(x => x.state === 'submitted');
|
|
if (!a?.terminal_at) return undefined;
|
|
return Math.max(0, Math.round((Date.parse(a.terminal_at) - Date.parse(a.dispatched_at)) / 1000));
|
|
}
|
|
|
|
/** Reconcile, write `<phase>-consensus.{json,md}`, record timing, mark closed. Override errors throw before any write. */
|
|
export function closePhase(state: RunState, phase: Phase, o: { overridesFile?: string }): { consensus: ConsensusRecord } {
|
|
const ps = state.phases[phase]!;
|
|
const native = voiceRows(state, phase, 'native');
|
|
const outside = voiceRows(state, phase, 'outside');
|
|
const rec = reconcile(native, outside, loadOverrides(o.overridesFile));
|
|
if (rec.errors.length) throw new RunError('ARTIFACT_DANGLING_REF', rec.errors.join('; '));
|
|
const consensus = consensusRecord(phase,
|
|
ps.native?.status === 'completed' ? { rows: native, model: ps.native.model } : null,
|
|
ps.outside?.status === 'completed' ? { rows: outside, model: ps.outside.model, provider: ps.outside.provider } : null, rec);
|
|
const md = renderConsensus(consensus, [...native, ...outside]);
|
|
fs.writeFileSync(path.join(state.out, `${phase}-consensus.json`), JSON.stringify(consensus, null, 2) + '\n');
|
|
fs.writeFileSync(path.join(state.out, `${phase}-consensus.md`), md);
|
|
ps.consensus = { confirmed: consensus.confirmed, disagree: consensus.disagree, new: consensus.new, native_only: consensus.native_only, coverage: consensus.coverage };
|
|
ps.closed_at = now();
|
|
const closed = phaseClose(path.join(state.out, 'timing.json'), state.run, phase, { outside_s: seconds(ps.outside), native_s: seconds(ps.native), session_kind: state.session_kind });
|
|
journalRow(state.out, { event: 'phase_closed', phase, consensus: ps.consensus, wall_s: closed.entry.wall_s, analytics: closed.analytics });
|
|
return { consensus };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// the gate list
|
|
// ---------------------------------------------------------------------------
|
|
const CONTENT_SCOPE: Record<string, string> = { ceo: 'scope', design: 'design', dx: 'dx', eng: 'arch' };
|
|
const optionHash = (options: GateOption[]) => createHash('sha256').update(JSON.stringify(options.map(o => [o.key, o.text]))).digest('hex').slice(0, 16);
|
|
const KEEP = (text: string): GateOption => ({ key: 'a', text, changes_plan: false });
|
|
|
|
/**
|
|
* A user challenge both voices raised recommends the change only when the
|
|
* finding reconciled `confirmed`, both name the same `changes` and the
|
|
* remedies are on the record (both voices', or the parent's one shared
|
|
* proposal from a reconcile override); option b shows them verbatim. Anything
|
|
* weaker keeps the plan's direction, and the basis says why.
|
|
*/
|
|
function challengeDefault(row: ReconciliationRow, n: FindingRow | undefined, o: FindingRow): { recommended: 'a' | 'b'; basis: string; text: string } {
|
|
const nf = n as (FindingRow & { changes?: string }) | undefined, of = o as FindingRow & { changes?: string };
|
|
const remedies = row.proposal ? ` (shared proposal: ${row.proposal})`
|
|
: nf?.resolution || of.resolution ? ` (native: ${nf?.resolution ?? 'no remedy stated'}; outside: ${of.resolution ?? 'no remedy stated'})` : '';
|
|
const text = `accept the change both reviewers recommend${remedies}`;
|
|
const keep = (basis: string) => ({ recommended: 'a' as const, basis: `plan direction; ${basis}`, text });
|
|
if (row.disposition !== 'confirmed') return keep('voices disagree');
|
|
if (!nf?.changes || !of.changes) return keep('no shared changes value');
|
|
if (nf.changes !== of.changes) return keep('voices name different changes');
|
|
if (row.proposal) return { recommended: 'b', basis: 'both voices; shared proposal', text };
|
|
if (!nf.resolution || !of.resolution) return keep('a remedy is missing');
|
|
return { recommended: 'b', basis: 'both voices; remedies shown', text };
|
|
}
|
|
|
|
/** A rerun challenge naming no scope, cost or direction: the agent's own decision, recorded, reopening nothing. */
|
|
function agentDecision(id: string, title: string, phase: string, why: string): GateItem {
|
|
return { id, title, kind: 'auto', recommended: 'a', phase, reopens: false, why: `${why}; it names no scope, cost or direction change, so on a rerun it is the agent's decision`,
|
|
cost: 'agent decision; b is recorded, no phase reruns', options: [KEEP('keep your direction'), { key: 'b', text: 'accept the change (agent decision; no phase reruns)', changes_plan: false }] };
|
|
}
|
|
|
|
export function buildGate(state: RunState, gateRev: number): GateList {
|
|
const items: GateItem[] = [];
|
|
let d = 0, uc = 0;
|
|
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 native = voiceRows(state, phase, 'native');
|
|
const outside = voiceRows(state, phase, 'outside');
|
|
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 challenged = new Set<string>();
|
|
for (const row of consensus.rows) {
|
|
const o = byId.get(row.outside_id)! as FindingRow & { user_challenge?: boolean; changes?: string };
|
|
const n = (row.native_id ? byId.get(row.native_id) : undefined) as (FindingRow & { user_challenge?: boolean; changes?: string }) | undefined;
|
|
const ids = `${row.native_id}+${row.outside_id}`;
|
|
if (o.user_challenge && n?.user_challenge && row.disposition !== 'new') {
|
|
challenged.add(o.id); challenged.add(n.id);
|
|
const title = `${n.title} [${row.native_id} + ${row.outside_id}]`;
|
|
if (reran && !n.changes && !o.changes) { push(agentDecision(`d${++d}`, title, phase, 'both voices recommend changing your stated direction'), ids); continue; }
|
|
const def = challengeDefault(row, n, o);
|
|
push({ id: `uc${++uc}`, title, kind: 'approval', recommended: def.recommended, recommended_basis: def.basis, phase, ...(n.changes ?? o.changes ? { changes: n.changes ?? o.changes } : {}),
|
|
why: `both voices recommend changing your stated direction; blind spot: ${o.title}; cost if wrong: ${o.resolution ?? n.resolution ?? 'unstated'}`,
|
|
options: [KEEP('keep your direction (the plan as reviewed)'), { key: 'b', text: def.text, changes_plan: true }] }, ids);
|
|
continue;
|
|
}
|
|
if (row.disposition === 'disagree') {
|
|
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);
|
|
}
|
|
}
|
|
for (const f of [...native, ...outside] as Array<FindingRow & { user_challenge?: boolean; changes?: string }>) {
|
|
if (!f.user_challenge || challenged.has(f.id)) continue;
|
|
const why = `one voice (${f.voice}) recommends changing your stated direction`;
|
|
if (reran && !f.changes) { push(agentDecision(`d${++d}`, `${f.title} [${f.id}]`, phase, why), f.id); continue; }
|
|
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);
|
|
}
|
|
}
|
|
const changes = items.filter(i => i.options.find(o => o.key === i.recommended)?.changes_plan).map(i => `${i.id}${i.recommended}`);
|
|
items.push({ id: 'p1', title: 'Plan approval', kind: 'approval', recommended: changes.length ? 'b' : 'a',
|
|
options: [{ key: 'a', text: 'approve as-is' }, { key: 'b', text: changes.length ? `approve with the recommended changes: ${changes.join(', ')}` : 'approve with the overrides above' },
|
|
{ key: 'c', text: 'interrogate (ask questions; stays pending)' },
|
|
{ key: 'd', text: `rerun every phase on the current plan, Eng last (to review an edited plan instead: gstack-autoplan revise --out ${state.out} --plan <file>)` }, { key: 'e', text: 'reject' }] });
|
|
const list: GateList = { schema_version: 1, run: state.run, gate_rev: gateRev, items, title: '/autoplan final approval gate' };
|
|
const problems = validateGateList(list);
|
|
if (problems.length) throw new Error(`gate list invalid: ${problems.join('; ')}`);
|
|
return list;
|
|
}
|
|
|
|
/** Every phase closed: write the gate list, decisions, tasks and the exported files; the run ends gate_pending. Unchanged answered proposals carry forward. */
|
|
export function finishGate(state: RunState): GateList {
|
|
const gateRev = (state.gate_rev ?? 0) + 1;
|
|
const list = buildGate(state, gateRev);
|
|
const kept = readDecisions(state.out).filter(r => r.gate_rev !== gateRev);
|
|
const answered = new Map(kept.filter(r => r.origin && r.chosen != null && r.status !== 'pending').map(r => [r.origin!, r]));
|
|
for (const item of list.items) {
|
|
const prev = item.origin ? answered.get(item.origin) : undefined;
|
|
if (prev && item.options.some(o => o.key === prev.chosen)) item.carried = { from: prev.carried_from ?? prev.id, chosen: prev.chosen!, gate_rev: prev.gate_rev };
|
|
}
|
|
fs.writeFileSync(path.join(state.out, 'gate.json'), JSON.stringify(list, null, 2) + '\n');
|
|
const rows = decisionRows(list).map(row => {
|
|
const item = list.items.find(i => decisionId(list.run, gateRev, i.id) === row.id)!;
|
|
if (!item.carried) return row;
|
|
const prev = answered.get(item.origin!)!;
|
|
return { ...row, chosen: item.carried.chosen, status: item.carried.chosen === item.recommended ? 'approved' as const : 'overridden' as const,
|
|
answered_at: prev.answered_at, reply: prev.reply, carried_from: item.carried.from, ...(prev.chosen_text ? { chosen_text: prev.chosen_text } : {}) };
|
|
});
|
|
writeDecisions(state.out, [...kept, ...rows]);
|
|
state.gate_rev = gateRev;
|
|
state.status = 'gate_pending';
|
|
state.gate = { rev: gateRev, items: list.items.length, written_at: now() };
|
|
exportRun(state);
|
|
journalRow(state.out, { event: 'gate_written', gate_rev: gateRev, items: list.items.length, carried: list.items.filter(i => i.carried).map(i => i.id) });
|
|
return list;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// answer
|
|
// ---------------------------------------------------------------------------
|
|
export interface AnswerResult { state: RunState; applied: boolean; pending: string[]; reopened: Phase[]; outcome: 'complete' | 'gate_pending' | 'reopened' | 'rejected'; lines: string[] }
|
|
|
|
/**
|
|
* Apply one reply. Earlier answers at this revision stand unless named again;
|
|
* the gate stays pending until every approval settles. A chosen option with
|
|
* `changes_plan` is an accepted plan change: it reopens its phase plus Eng,
|
|
* prints `APPLY:` and becomes a pending amendment (`next` refuses until
|
|
* `revise --incorporates` or `--withdraw`).
|
|
*/
|
|
export function answerGate(state: RunState, gateRev: number, reply: string): AnswerResult {
|
|
const gateFile = path.join(state.out, 'gate.json');
|
|
if (state.status !== 'gate_pending' || !fs.existsSync(gateFile)) throw new RunError('PHASE_NOT_AWAITING', `run is ${state.status}, not gate_pending`, 3);
|
|
const list = JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList;
|
|
const rows = readDecisions(state.out);
|
|
const prior = Object.fromEntries(rows.filter(r => r.gate_rev === list.gate_rev && r.chosen != null && r.status !== 'pending' && r.label).map(r => [r.label!.toLowerCase(), r.chosen!]));
|
|
const parsed = parseReply(reply, list, gateRev, prior);
|
|
if (parsed.error === 'GATE_REV_STALE') throw new RunError('GATE_REV_STALE', `reply names gate_rev=${gateRev}, current gate_rev=${list.gate_rev}`, 3);
|
|
if (!parsed.valid) throw new RunError('GATE_REPLY_UNPARSED', parsed.error === 'GATE_EMPTY' ? 'empty reply' : `unparsed: ${parsed.unparsed.join(', ')}`);
|
|
const itemOf = (id: string) => list.items.find(i => i.id === id)!;
|
|
const optionOf = (c: GateChoice) => itemOf(c.id).options.find(o => o.key === c.chosen)!;
|
|
const plan = parsed.choices.find(c => c.id === 'p1');
|
|
const lines: string[] = [];
|
|
const answeredAt = now();
|
|
const record = () => writeDecisions(state.out, applyReply(rows, parsed, answeredAt).map(r => {
|
|
const c = r.gate_rev === list.gate_rev && r.chosen && !r.chosen_text ? itemOf((r.label ?? '').toLowerCase())?.options.find(o => o.key === r.chosen) : undefined;
|
|
return c ? { ...r, chosen_text: c.text } : r;
|
|
}));
|
|
if (!plan || parsed.pending.length) {
|
|
// Approvals unanswered: record what was answered, keep the gate pending; nothing reviewed changes.
|
|
record();
|
|
const pending = parsed.pending.length ? parsed.pending : ['p1'];
|
|
state.gate = { ...(state.gate ?? {}), last_reply: reply, pending };
|
|
lines.push(`GATE_PENDING: ${pending.join(', ')} unanswered (reply with p1a..p1e or all)`);
|
|
return { state, applied: true, pending, reopened: [], outcome: 'gate_pending', lines };
|
|
}
|
|
const proceeds = plan.chosen !== 'c' && plan.chosen !== 'e';
|
|
const carried = (c: GateChoice) => itemOf(c.id).carried?.chosen === c.chosen;
|
|
const changing = proceeds ? parsed.choices.filter(c => c.id !== 'p1' && !carried(c) && itemOf(c.id).reopens !== false && (optionOf(c).changes_plan ?? c.how === 'override')) : [];
|
|
const tokens = (cs: GateChoice[]) => cs.map(c => `${c.id}${c.chosen}`);
|
|
const overrides = parsed.choices.filter(c => c.how === 'override' && c.id !== 'p1');
|
|
const reopenSet = new Set<Phase>();
|
|
if (plan.chosen === 'd') state.phase_order.forEach(p => reopenSet.add(p));
|
|
for (const c of changing) {
|
|
const phase = itemOf(c.id).phase as Phase | undefined;
|
|
if (phase) { reopenSet.add(phase); if (state.phase_order.includes('eng')) reopenSet.add('eng'); }
|
|
}
|
|
record();
|
|
state.gate = { ...(state.gate ?? {}), answered_at: answeredAt, reply, gate_rev: gateRev, plan: plan.chosen, overrides: tokens(overrides) };
|
|
journalRow(state.out, { event: 'answered', gate_rev: gateRev, reply, plan: plan.chosen, overrides: tokens(overrides), changes: tokens(changing), reopen: [...reopenSet] });
|
|
if (plan.chosen === 'c') {
|
|
lines.push(`GATE_PENDING: interrogate recorded; the gate stays at gate_rev=${gateRev}`);
|
|
return { state, applied: true, pending: ['p1'], reopened: [], outcome: 'gate_pending', lines };
|
|
}
|
|
if (plan.chosen === 'e') {
|
|
state.status = 'incomplete';
|
|
(state.deviations ??= []).push(`plan rejected by the owner at gate_rev ${gateRev}`);
|
|
exportRun(state);
|
|
lines.push('REJECTED: run.json.status=incomplete');
|
|
return { state, applied: true, pending: [], reopened: [], outcome: 'rejected', lines };
|
|
}
|
|
for (const c of changing) {
|
|
const decision = decisionId(state.run, gateRev, c.id);
|
|
(state.amendments ??= []).push({ decision, title: itemOf(c.id).title, chosen: c.chosen, text: optionOf(c).text, gate_rev: gateRev, status: 'pending', at: answeredAt });
|
|
journalRow(state.out, { event: 'amendment_pending', decision, gate_rev: gateRev });
|
|
lines.push(`APPLY: ${decision} ${itemOf(c.id).title} -> ${optionOf(c).text}`);
|
|
}
|
|
if (reopenSet.size === 0) {
|
|
state.status = 'complete';
|
|
lines.push(...closeDraftDirections(state, answeredAt.slice(0, 10)));
|
|
exportRun(state);
|
|
const notes = overrides.filter(o => o.chosen !== keepDirection(itemOf(o.id)));
|
|
lines.push(`APPROVED: gate_rev=${gateRev} ${notes.length ? `overrides=${tokens(notes).join(',')} (none change reviewed content)` : 'as-is'}`);
|
|
return { state, applied: true, pending: [], reopened: [], outcome: 'complete', lines };
|
|
}
|
|
const reopened = state.phase_order.filter(p => reopenSet.has(p));
|
|
const reason = plan.chosen === 'd' ? 'revise' : tokens(changing).join(',');
|
|
reopenPhases(state, reopened, gateRev, `gate_rev ${gateRev}: ${reason}`);
|
|
lines.push(`REOPENED: ${reopened.join(',')} (Eng last) reason=${reason}`);
|
|
const drift = driftLine(state, 'PLAN_SOURCE_CHANGED');
|
|
if (drift) lines.push(drift);
|
|
return { state, applied: true, pending: [], reopened, outcome: 'reopened', lines };
|
|
}
|
|
|
|
/** E3 phase-close operation: every `draft direction stands until the owner decides` line in the active plan becomes the decided option and date. */
|
|
function closeDraftDirections(state: RunState, date: string): string[] {
|
|
const loaded = loadBriefInputs(state.out);
|
|
if ('error' in loaded) return [];
|
|
const r = closeDraftDirectionsFile(state.active_plan, loaded.inputs, date);
|
|
if (!r.rewrites.length) return [];
|
|
journalRow(state.out, { event: 'draft_directions_closed', rewritten: r.rewrites.length - r.unresolved.length, unresolved: r.unresolved.map(u => u.line) });
|
|
return [`DRAFT_DIRECTIONS: rewritten=${r.rewrites.length - r.unresolved.length} unresolved=${r.unresolved.length}`];
|
|
}
|
|
|
|
/** `<stem>.r<rev><ext>`, or `<stem>.r<rev>.v<k><ext>` when that name is taken: an archive is never overwritten. */
|
|
export function archiveName(out: string, name: string, gateRev: number, taken: Set<string>): string {
|
|
const ext = path.extname(name);
|
|
const stem = name.slice(0, -ext.length || undefined);
|
|
for (let k = 1; ; k++) {
|
|
const candidate = `${stem}.r${gateRev}${k > 1 ? `.v${k}` : ''}${ext}`;
|
|
if (!taken.has(candidate) && !fs.existsSync(path.join(out, candidate))) { taken.add(candidate); return candidate; }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset the reopened phases in `state` (history, superseded open attempts,
|
|
* artifacts) and return the archive renames still to perform, so a caller can
|
|
* journal them before touching the directory (revise's transaction).
|
|
*/
|
|
export function planReopen(state: RunState, phases: Phase[], gateRev: number, reason: string): Array<[string, string]> {
|
|
const taken = new Set<string>();
|
|
const renames: Array<[string, string]> = [];
|
|
const archive = (name: string) => { if (fs.existsSync(path.join(state.out, name))) renames.push([name, archiveName(state.out, name, gateRev, taken)]); };
|
|
for (const phase of phases) {
|
|
const ps = state.phases[phase] ?? {};
|
|
state.history.push({ event: 'reopened', phase, gate_rev: gateRev, reason, at: now(), previous: ps });
|
|
for (const voice of ['native', 'outside'] as const) {
|
|
for (const a of ps[voice]?.attempts ?? []) {
|
|
if (a.state !== 'dispatched' && a.state !== 'execution_unknown') continue;
|
|
a.state = 'superseded'; a.terminal_at = now();
|
|
settle(path.join(state.out, FILES.spend), a.attempt, 'unknown');
|
|
journalRow(state.out, { event: 'superseded', attempt: a.attempt, phase, voice, reason });
|
|
}
|
|
const output = ps[voice]?.output;
|
|
if (output) { archive(`${output}.findings.jsonl`); archive(output); delete state.artifacts[output]; }
|
|
archive(`${phase}-${voice}-prompt.md`);
|
|
}
|
|
archive(`${phase}-consensus.json`); archive(`${phase}-consensus.md`);
|
|
delete state.artifacts[`${phase}-consensus.json`]; delete state.artifacts[`${phase}-consensus.md`];
|
|
state.phases[phase] = {};
|
|
journalRow(state.out, { event: 'reopened', phase, gate_rev: gateRev, reason });
|
|
}
|
|
archive('gate.json');
|
|
delete state.artifacts['gate.json'];
|
|
return renames;
|
|
}
|
|
|
|
/** Perform archive renames; a rename already done (source gone, target present) is skipped, so a recovery can replay the list. */
|
|
export function applyRenames(out: string, renames: Array<[string, string]>): void {
|
|
for (const [from, to] of renames) {
|
|
if (fs.existsSync(path.join(out, to))) continue;
|
|
if (fs.existsSync(path.join(out, from))) fs.renameSync(path.join(out, from), path.join(out, to));
|
|
}
|
|
}
|
|
|
|
/** Archive the reopened phases' bound files under their gate revision and reset them; the gate itself is archived too. */
|
|
export function reopenPhases(state: RunState, phases: Phase[], gateRev: number, reason: string): void {
|
|
applyRenames(state.out, planReopen(state, phases, gateRev, reason));
|
|
state.status = 'running';
|
|
exportRun(state);
|
|
}
|