Files
gstack/scripts/resolvers/autoplan-driver.ts
T
garrytanandcapy-ai[bot] 34c2411cf5 fix(autoplan): checkpoints never touch the live run; generations, newest-valid restore, validated staging (integration items 2-6)
2. checkpointRun canonicalizes the collection and the run directory (symlinks
   resolved through the deepest existing ancestor) and refuses, before
   touching anything, when they are the same or one contains the other
   (CHECKPOINT_OVERLAP); restore applies the same check to source, target and
   staging. Before, <root>/<run> equal to the live run replaced it.
3. Publication writes an immutable generation (generations/g<revision>-…),
   renames it into place complete, then switches current.json with one
   rename; the previous generation is kept, so an interruption at the publish
   boundary (fault hook checkpoint-before-pointer) leaves the last checkpoint
   current. A generation never replaces a newer one (higher run revision):
   "CHECKPOINT: skipped … newer". Publication and restore hold the
   collection's .lock; restore also holds <out>.lock.
4. Restore checks every listed file's real path stays inside the generation
   (an ancestor symlink no longer copies outside files), parses run.json and
   runs validateRun on the staged run before publishing; a refusal leaves
   --out absent. readState reroots plan_input and plan_source for moved runs,
   and a restore with the owner's plan file gone points both at the restored
   copy, so a second restore cycle and a later revise work.
5. A checkpoint failure after a committed command prints
   "CHECKPOINT: failed for <run>: … ; the command's result stands" on stderr
   and keeps the command's exit code.
6. `gstack-autoplan checkpoints --from <dir>` lists generations newest first
   (run, gate_rev, status, revision, time, hash ok|bad, current).
   `resume --from <collection or root>` restores the newest generation that
   verifies, printing each corrupt newer one it skipped; a root holding
   several runs must name one.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-15 01:35:41 +00:00

74 lines
6.4 KiB
TypeScript

/**
* autoplan/DRIVER.md resolvers (field wave item 13): the compact entry a parent
* agent on any host reads to drive an unattended /autoplan run through
* `gstack-autoplan`, instead of autoplan/SKILL.md. Every list comes from code:
* the phase order (PHASES), the scope flags (SCOPE_FLAG_TOKENS), the run
* directory files (FILES), the reviewer RESULT FORMAT, the result codes
* (RESULT_CODES). The command lines and the gate reply forms are stated once
* here; test/autoplan-driver.test.ts checks them against `gstack-autoplan
* --help` and the gate parser.
*/
import { FILES } from '../../lib/autoplan-state';
import { PHASES, RESULT_FORMAT } from '../../lib/autoplan-prompts';
import { SCOPE_FLAG_TOKENS } from '../../lib/autoplan-scope';
import { RESULT_CODES } from '../../lib/result-codes';
import type { ResolverFn } from './types';
/** One entry per command line the loop runs, as the field-wave plan specifies them. */
export const AUTOPLAN_DRIVER_COMMANDS: Array<{ cmd: string; does: string }> = [
{ cmd: 'gstack-autoplan next --out <dir> --plan <plan.md> [scope flags] [--light] [--spend-cap <usd>] [--scaffold]', does: 'first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice' },
{ cmd: 'gstack-autoplan next --out <dir> [--redispatch <attempt>]', does: 'later calls: open the next phase or reprint open attempts; never redispatches on its own' },
{ cmd: 'gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --record <file.md> --model <id> [--attempt <id>]', does: 'bind a free-form review record (canonical fence, one findings table or list, or `No findings.`)' },
{ cmd: 'gstack-autoplan submit ... --record <file.md> --attest "<reason>" --attempt <id>', does: 'bind a record with no INPUT: line as receipt: attested (a wrong INPUT: always refuses)' },
{ cmd: 'gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --result <file> --model <id> --attempt <id>', does: 'bind a canonical result (the RESULT FORMAT below)' },
{ cmd: 'gstack-autoplan submit --out <dir> --phase <p> --voice outside --unavailable|--failed "<reason>"', does: 'close the phase native-only when no outside voice can run' },
{ cmd: 'gstack-autoplan answer --out <dir> --gate-rev <n> --reply "<reply>" --by human|agent:<id> [--owner-reply <text>|-] [--rec-hash <h>]', does: 'answer the gate with the reply grammar below; an agent answer stores the owner\'s words verbatim' },
{ cmd: 'gstack-autoplan recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>"', does: 'change one recommendation before relay; then render and relay again (a `rec` needs the new `--rec-hash`)' },
{ cmd: 'gstack-autoplan revise --out <dir> --plan <plan v2> --incorporates <decision ids>', does: 'bind the amended plan; reopens every phase whose reviewed plan changed, Eng last' },
{ cmd: 'gstack-autoplan revise --out <dir> --settle <decision id|d4>', does: 'discharge a packaging or ordering change with no plan edit; reopens nothing' },
{ cmd: 'gstack-autoplan revise --out <dir> --withdraw <decision id>', does: 'drop a pending amendment instead of applying it' },
{ cmd: 'gstack-autoplan export --out <dir>', does: 'write the artifact set; then `gstack-artifact validate <dir>/run.json`' },
{ cmd: 'gstack-autoplan status|resume --out <dir>', does: 'read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts)' },
{ cmd: 'gstack-autoplan resume --out <new dir> --from <checkpoint>', does: 'restore the newest valid checkpoint generation (GSTACK_AUTOPLAN_CHECKPOINT_DIR) into an empty directory and take ownership; `checkpoints --from <dir>` lists them' },
];
/** The reply grammar `gstack-gate` parses, one form per line. */
export const GATE_REPLY_FORMS: Array<{ reply: string; means: string }> = [
{ reply: 'rec', means: 'every item takes its recommendation, plan changes included (each prints APPLY:); `all` is an alias' },
{ reply: 'as-is', means: 'keep every direction; no plan change' },
{ reply: 'd3b uc1a', means: 'one <id><option> token per item you set; unanswered auto items take their default' },
{ reply: 'rec except d3b', means: 'recommendations, with the listed overrides' },
];
/** Result codes the loop can print, in the order a run meets them. */
export const DRIVER_CODES = ['RUN_NOT_INITIALIZED', 'PLAN_SECTIONS_INVALID', 'RUN_LOCKED', 'EXECUTION_UNKNOWN', 'SPEND_CAP_EXCEEDED', 'PHASE_NOT_AWAITING', 'ATTEMPT_MISMATCH',
'RESULT_RECEIPT_MISSING', 'RESULT_FINDINGS_MISSING', 'MODEL_FAMILY_CONFLICT', 'CONSENSUS_MISSING', 'GATE_REV_STALE', 'GATE_REPLY_UNPARSED', 'GATE_REC_STALE', 'OWNER_REPLY_REQUIRED', 'GATE_OWNER_REPLY_MISMATCH', 'PLAN_AMEND_PENDING', 'AMENDMENT_NOT_SETTLEABLE', 'CHECKPOINT_INVALID', 'GSTACK_VERSION_CHANGED'] as const;
/** Fix text for codes the plan specifies and lane B-core adds to lib/result-codes.ts; the table's own fix wins once it exists. */
const PENDING_FIX: Record<string, string> = {
PLAN_SECTIONS_INVALID: 'the plan needs one `## Implementation plan` and one `## Review record`; rerun `next --scaffold` to wrap a plan that has neither',
PLAN_AMEND_PENDING: 'edit the plan for each APPLY: line and run `revise --incorporates <ids>`, or `revise --withdraw <id>`',
GSTACK_VERSION_CHANGED: 'notice only: the run was started on another gstack; forward migrations ran and were journaled',
};
export const generateAutoplanDriverPhases: ResolverFn = () => [
`Order: ${PHASES.join(' → ')}. Design runs only with UI scope, DX only with DX scope, Eng always and last.`,
`Scope flags on the first \`next\`: ${SCOPE_FLAG_TOKENS.map(t => `\`${t}\``).join(', ')}. \`--developer-tool\` and \`--agent-primary\` only enable DX.`,
].join('\n');
export const generateAutoplanDriverCommands: ResolverFn = () =>
AUTOPLAN_DRIVER_COMMANDS.map(c => `- \`${c.cmd}\`\n ${c.does}.`).join('\n');
export const generateAutoplanDriverGate: ResolverFn = () =>
GATE_REPLY_FORMS.map(f => `- \`${f.reply}\`: ${f.means}.`).join('\n');
export const generateAutoplanDriverFiles: ResolverFn = () =>
`Runner state: ${Object.values(FILES).map(f => `\`${f}\``).join(', ')}.`;
export const generateAutoplanDriverResultFormat: ResolverFn = () =>
RESULT_FORMAT('<phase>', '<snapshot sha256>', '<result file>').replace(/^\n---\n/, '').trimEnd();
export const generateAutoplanDriverCodes: ResolverFn = () => DRIVER_CODES.map(code => {
const row = (RESULT_CODES as Record<string, { fix: string }>)[code];
return `- \`${code}\`: ${row?.fix ?? PENDING_FIX[code]}`;
}).join('\n');