From 4128ba751b13e51b802d2199a38c2fdd49b7a8c7 Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 13 Oct 2026 01:09:04 +0000 Subject: [PATCH] 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=. 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> --- bin/gstack-artifact | 6 +++--- lib/gate-list.ts | 44 ++++++++++++++++++++++++++++++++------- lib/headless-artifacts.ts | 28 ++++++++++++++++++++++--- 3 files changed, 64 insertions(+), 14 deletions(-) diff --git a/bin/gstack-artifact b/bin/gstack-artifact index b5cf4d3f4..88f7077ca 100755 --- a/bin/gstack-artifact +++ b/bin/gstack-artifact @@ -29,7 +29,7 @@ const HELP = `gstack-artifact — the unattended artifact contract (docs/unatten urgent render the URGENT block (\`None.\` when empty) Without --json: one error line per problem on stderr, each ending in (), -then ARTIFACT_VALID: run= status= or ARTIFACT_INVALID: error(s) on stdout. +then ARTIFACT_VALID: run= status=[ attested= when voices bound on attestation] or ARTIFACT_INVALID: error(s) on stdout. ${exitTableHelp()}`; function flag(args: string[], name: string): string | undefined { @@ -70,11 +70,11 @@ function main(argv: string[]): number { } const result = validateRun(file); if (json) { - process.stdout.write(JSON.stringify({ valid: result.valid, run: result.manifest?.run ?? null, status: result.manifest?.status ?? null, run_dir: result.runDir, errors: result.errors }) + '\n'); + process.stdout.write(JSON.stringify({ valid: result.valid, run: result.manifest?.run ?? null, status: result.manifest?.status ?? null, run_dir: result.runDir, attested: result.attested ?? 0, errors: result.errors }) + '\n'); } else { printErrors(result.errors); process.stdout.write(result.valid - ? `ARTIFACT_VALID: run=${result.manifest!.run} status=${result.manifest!.status}\n` + ? `ARTIFACT_VALID: run=${result.manifest!.run} status=${result.manifest!.status}${result.attested ? ` attested=${result.attested}` : ''}\n` : `ARTIFACT_INVALID: ${result.errors.length} error(s)\n`); } return result.valid ? EXIT.ok : EXIT.fail; diff --git a/lib/gate-list.ts b/lib/gate-list.ts index eda1be074..f19772d96 100644 --- a/lib/gate-list.ts +++ b/lib/gate-list.ts @@ -8,18 +8,32 @@ * overrides, so there is no exclusion-vs-override ambiguity). Bare yes/no is * ambiguous across multi-option items and is reported as unparsed, never * guessed. A reply binds to the gate revision it answers (`gate_rev`). + * `all as-is` keeps every direction: each item takes its option that does not + * change the plan (`changes_plan: false`), so agreeing user challenges whose + * recommendation is the change can be declined in one token. Earlier answers + * at the same revision (`prior`) are kept unless a token names the item again: + * only unresolved items default. * Rendered by bin/gstack-gate; rows land in decisions.jsonl (lib/headless-artifacts.ts). */ import type { DecisionRow } from './headless-artifacts'; -export interface GateOption { key: string; text: string } +/** `changes_plan`: choosing this option is an accepted plan change (reopens its phase plus Eng, prints APPLY:). */ +export interface GateOption { key: string; text: string; changes_plan?: boolean } export interface GateItem { id: string; title: string; options: GateOption[]; recommended: string; kind: 'auto' | 'approval'; phase?: string; cost?: string; why?: string; + /** false: an agent decision; no option reopens a phase. */ + reopens?: boolean; + /** Why `recommended` (e.g. `both voices; remedies shown`, `plan direction; voices disagree`). */ + recommended_basis?: string; changes?: string; + /** Stable carry-forward identity (phase, finding ids, phase close, snapshot, option text). */ + origin?: string; + /** Answered at an earlier revision on an unchanged proposal from a phase that did not rerun. */ + carried?: { from: string; chosen: string; gate_rev: number }; } export interface GateList { schema_version: 1; run: string; gate_rev: number; items: GateItem[]; title?: string } -export interface GateChoice { id: string; chosen: string; kind: 'auto' | 'approval'; how: 'recommended' | 'override' | 'auto_default' } +export interface GateChoice { id: string; chosen: string; kind: 'auto' | 'approval'; how: 'recommended' | 'override' | 'auto_default'; prior?: boolean } export interface ParsedReply { valid: boolean; gate_rev: number; reply: string; choices: GateChoice[]; pending: string[]; unparsed: string[]; error?: 'GATE_REV_STALE' | 'GATE_REPLY_UNPARSED' | 'GATE_EMPTY'; @@ -68,14 +82,15 @@ export function renderGate(list: GateList, page = 1, pageSize = PAGE_SIZE): stri lines.push(''); list.items.slice(start, start + pageSize).forEach((item, i) => { const n = start + i + 1; - lines.push(`${n}. [${item.id}] ${item.title} (${item.kind}; recommended: ${item.recommended}${item.phase ? `; from ${item.phase}` : ''})`); + lines.push(`${n}. [${item.id}] ${item.title} (${item.kind}; recommended: ${item.recommended}${item.recommended_basis ? ` (${item.recommended_basis})` : ''}${item.phase ? `; from ${item.phase}` : ''})`); if (item.why) lines.push(` ${item.why}`); + if (item.carried) lines.push(` decided at gate_rev ${item.carried.gate_rev}: ${item.id}${item.carried.chosen} (carried forward; name it again to change it)`); for (const o of item.options) lines.push(` ${item.id}${o.key}) ${o.text}${o.key === item.recommended ? ' (recommended)' : ''}`); if (item.cost) lines.push(` cost: ${item.cost}`); }); lines.push(''); if (pages > 1) lines.push(`Ids are stable across pages; render page N with \`gstack-gate render --page N\`.`); - lines.push('Reply with exactly one of: `all` · `