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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-13 00:52:52 +00:00
1 parent 2f65e8a810
commit 17f69f4dae
12 files changed
+410 -4

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
<!-- AUTO-GENERATED from autoplan/DRIVER.md.tmpl — do not edit directly -->
<!-- Regenerate: bun run gen:skill-docs -->
# /autoplan driver: run the review loop from a parent agent
Read this file instead of `autoplan/SKILL.md` when you (a parent agent on any host) drive an
unattended /autoplan run through `gstack-autoplan`: the reviewers are subagents you dispatch,
and the runner keeps the state. The commands live in `<root>/bin/`, where `<root>` is the
folder that holds this `autoplan/` directory; `gstack-autoplan --help` and the first `next`
print `DRIVER: <root>/autoplan/DRIVER.md`. Run every command with the `GSTACK_LAUNCH`
assignments your installer printed. Read printed lines and files, never prose: every workflow
command ends with `GSTACK_RESULT: skill=autoplan status=<s> run=<dir>`, and every error line
ends with `(<CODE>)`.
## Phases and scope
Order: ceo → design → dx → eng. Design runs only with UI scope, DX only with DX scope, Eng always and last.
Scope flags on the first `next`: `--ui`, `--no-ui`, `--no-dx`, `--developer-tool`, `--agent-primary`. `--developer-tool` and `--agent-primary` only enable DX.
The first `next` prints `SCOPE: ui=… (terms; visual; files) dx=… (detected) source=ui:<s>,dx:<s>`
(each axis is `flag`, `marker` or `detected`). UI needs a rendered file path or a UI term next
to a visual noun in the Implementation plan. To keep flags for later runs on the same plan, add
the `SCOPE_PIN:` marker it prints (`<!-- gstack-autoplan-scope: … -->`) as the plan's first
line yourself; gstack never writes the plan.
## The loop
1. `next` prints one `ATTEMPT: <id> phase=<p> voice=native|outside prompt=<file> result=<file>`
per voice. Dispatch the native prompt to a subagent on your model and the outside prompt to
a subagent on another model family (or `gstack-outside-voice run`). Each writes `result=`.
2. Submit each voice exactly once (`BOUND:`). Both voices in closes the phase (`PHASE_CLOSED:`).
3. Repeat `next` until `status=gate_pending` (`GATE: gate_rev=<n>`); render it with
`gstack-gate render <dir>/gate.json`. You never approve the gate for the owner: relay it.
4. `answer` with the owner's reply. `APPLY: <id> <title> -> <change>` lines are accepted plan
changes: edit the plan, then `revise` (the printed `NEXT:` names it). Until then `next`
refuses with `PLAN_AMEND_PENDING` and `export` will not mark the run complete.
5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export`.
### Commands
- `gstack-autoplan next --out <dir> --plan <plan.md> [scope flags] [--light] [--spend-cap <usd>] [--scaffold]`
first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice.
- `gstack-autoplan next --out <dir> [--redispatch <attempt>]`
later calls: open the next phase or reprint open attempts; never redispatches on its own.
- `gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --record <file.md> --model <id> [--attempt <id>]`
bind a free-form review record (canonical fence, one findings table or list, or `No findings.`).
- `gstack-autoplan submit ... --record <file.md> --attest "<reason>" --attempt <id>`
bind a record with no INPUT: line as receipt: attested (a wrong INPUT: always refuses).
- `gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --result <file> --model <id> --attempt <id>`
bind a canonical result (the RESULT FORMAT below).
- `gstack-autoplan submit --out <dir> --phase <p> --voice outside --unavailable|--failed "<reason>"`
close the phase native-only when no outside voice can run.
- `gstack-autoplan answer --out <dir> --gate-rev <n> --reply "<reply>"`
answer the gate with the reply grammar below.
- `gstack-autoplan revise --out <dir> --plan <plan v2> --incorporates <decision ids>`
bind the amended plan; reopens every phase whose reviewed plan changed, Eng last.
- `gstack-autoplan revise --out <dir> --withdraw <decision id>`
drop a pending amendment instead of applying it.
- `gstack-autoplan export --out <dir>`
write the artifact set; then `gstack-artifact validate <dir>/run.json`.
- `gstack-autoplan status|resume --out <dir>`
read the manifest; take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts).
## Reviewer result format
Every prompt ends with this contract; `submit --result` enforces it, `submit --record` accepts
the looser record shapes above.
RESULT FORMAT (required by gstack-autoplan; the parent binds your file by this contract):
1. The first line of your result is exactly: INPUT: <phase> <snapshot sha256>
2. Write the complete review as prose, then END with one fenced block of canonical findings, one JSON object per line:
```gstack-findings
{"id":"F1","severity":"High","title":"<one line>","file":"<path or omit>","line":12,"fix":"<the fix>"}
```
severity is Critical | High | Medium | Low | Informational. Add "user_challenge": true when both the plan's stated direction should change (merge, split, add, remove) and you recommend it. Add "urgent": true and "suggested_owner": "<who>" for a security or data-loss finding this plan does not own. An empty fence means no findings; a missing fence is refused.
3. Write the whole result to this file: <result file>
## Gate replies
A reply binds to the `gate_rev` you read (`--gate-rev`). Bare `yes`/`no` is unparsed, never guessed.
- `all`: every item takes its recommendation (an agreed change prints APPLY: and needs revise).
- `all as-is`: keep every direction; no plan change.
- `d3b uc1a`: one <id><option> token per item you set; unanswered auto items take their default.
- `all except d3b`: recommendations, with the listed overrides.
## Run directory
Runner state: `run.json`, `attempts.jsonl`, `run.lock`, `spend.json`, `plan-active.md`, `restore.md`, `plan-source.md`. Reviewer files: `<phase>-native-prompt.md`,
`<phase>-outside-prompt.md`, `<phase>-native.md`, `<phase>-outside.md`, `<phase>-consensus.{json,md}`,
`gate.json`, `decisions.jsonl`. `export` writes `plan.md`, `review-record.md`, `findings.jsonl`,
`tasks.jsonl`, `timing.json` and rehashes `run.json`; `gstack-artifact validate <dir>/run.json`
is the completion check. Copy the whole directory, never single files.
## Result codes
- `RUN_NOT_INITIALIZED`: start with `gstack-autoplan next --plan <file> --out <dir>` (the first call initializes the run)
- `PLAN_SECTIONS_INVALID`: the plan needs one `## Implementation plan` and one `## Review record`; rerun `next --scaffold` to wrap a plan that has neither
- `RUN_LOCKED`: wait for it to exit, or if its pid is gone run `gstack-autoplan resume --out <dir>` to take ownership
- `EXECUTION_UNKNOWN`: submit the result file if the reviewer finished (`gstack-autoplan submit ... --attempt <id>`), or redispatch explicitly with `gstack-autoplan next --redispatch <id>`; nothing is redispatched automatically
- `SPEND_CAP_EXCEEDED`: raise `--spend-cap`, settle or release unsettled attempts, or stop; unknown charges count as their reservation
- `PHASE_NOT_AWAITING`: run `gstack-autoplan status --out <dir>` and submit for the attempt `next` printed
- `ATTEMPT_MISMATCH`: pass the attempt id `next` printed for this voice; a result is bound exactly once
- `RESULT_RECEIPT_MISSING`: the reviewer must read the prompt file through EOF and begin its result with the INPUT line the prompt names; a result for another snapshot is refused
- `RESULT_FINDINGS_MISSING`: end the review with one ```gstack-findings fence, one JSON object per line with at least severity and title; `[]` rows are not findings, an empty fence means no findings
- `MODEL_FAMILY_CONFLICT`: pass `--model <id>` of a different family (anthropic, openai, google, xai, deepseek, qwen, zai, moonshot, meta, mistral); a same-family result is not an outside voice
- `CONSENSUS_MISSING`: resume the run so the phase closes through reconciliation; the Eng prompt is never sent without every prior phase summary
- `GATE_REV_STALE`: render the current gate list, read it, and reply with its gate_rev
- `GATE_REPLY_UNPARSED`: reply with `all`, `<id><option>` tokens (for example `d3b uc1a`), or `all except <tokens>`; bare yes/no is ambiguous
- `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
## Coordinator contract (inherited by every lane)
Merge conditions. A lane's PR merges only when: the required gate lanes are green on the stamped tree or the tree receipt says `gate-reuse: eligible`; `gstack-lane-check` reports `clear` for its planned files against every other open lane; the PR body opens with the `gstack-ship-receipt` block; and the coordinator, not the lane, says merge. A lane never merges, rebases another lane, or force-pushes.
Behavior-change notices. Every user-visible behavior change outside the plan's own items is listed in the PR body under `## Behavior changes` with the file, the old behavior and the new one, before the review starts. An omitted change found in review is a CRITICAL finding, whatever its size.
Spend caps. Each lane runs under the `--spend-cap` the coordinator names; a lane that reaches it stops with `status=budget_exhausted` and reports spent, reserved and unknown charges. Paid evals are never retried to change a verdict; a rerun needs a named repair. Host-subagent spend is reported as `unknown`, never zero.
Handoff format. A lane reports in this order, identifiers first: PR URL (or `no PR` with branch and SHA), `GSTACK_RESULT` line, gate summary (lanes passed/failed/skipped), behavior changes (or `none`), spend (spent/reserved/unknown), then what it could not verify. Full thread ids (`jam_…`), never short codes, in every cross-thread message. Prose after the identifiers may be truncated; nothing before them may be.
+63
View File
@@ -0,0 +1,63 @@
# /autoplan driver: run the review loop from a parent agent
Read this file instead of `autoplan/SKILL.md` when you (a parent agent on any host) drive an
unattended /autoplan run through `gstack-autoplan`: the reviewers are subagents you dispatch,
and the runner keeps the state. The commands live in `<root>/bin/`, where `<root>` is the
folder that holds this `autoplan/` directory; `gstack-autoplan --help` and the first `next`
print `DRIVER: <root>/autoplan/DRIVER.md`. Run every command with the `GSTACK_LAUNCH`
assignments your installer printed. Read printed lines and files, never prose: every workflow
command ends with `GSTACK_RESULT: skill=autoplan status=<s> run=<dir>`, and every error line
ends with `(<CODE>)`.
## Phases and scope
{{AUTOPLAN_DRIVER_PHASES}}
The first `next` prints `SCOPE: ui=… (terms; visual; files) dx=… (detected) source=ui:<s>,dx:<s>`
(each axis is `flag`, `marker` or `detected`). UI needs a rendered file path or a UI term next
to a visual noun in the Implementation plan. To keep flags for later runs on the same plan, add
the `SCOPE_PIN:` marker it prints (`<!-- gstack-autoplan-scope: … -->`) as the plan's first
line yourself; gstack never writes the plan.
## The loop
1. `next` prints one `ATTEMPT: <id> phase=<p> voice=native|outside prompt=<file> result=<file>`
per voice. Dispatch the native prompt to a subagent on your model and the outside prompt to
a subagent on another model family (or `gstack-outside-voice run`). Each writes `result=`.
2. Submit each voice exactly once (`BOUND:`). Both voices in closes the phase (`PHASE_CLOSED:`).
3. Repeat `next` until `status=gate_pending` (`GATE: gate_rev=<n>`); render it with
`gstack-gate render <dir>/gate.json`. You never approve the gate for the owner: relay it.
4. `answer` with the owner's reply. `APPLY: <id> <title> -> <change>` lines are accepted plan
changes: edit the plan, then `revise` (the printed `NEXT:` names it). Until then `next`
refuses with `PLAN_AMEND_PENDING` and `export` will not mark the run complete.
5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export`.
### Commands
{{AUTOPLAN_DRIVER_COMMANDS}}
## Reviewer result format
Every prompt ends with this contract; `submit --result` enforces it, `submit --record` accepts
the looser record shapes above.
{{AUTOPLAN_DRIVER_RESULT_FORMAT}}
## Gate replies
A reply binds to the `gate_rev` you read (`--gate-rev`). Bare `yes`/`no` is unparsed, never guessed.
{{AUTOPLAN_DRIVER_GATE}}
## Run directory
{{AUTOPLAN_DRIVER_FILES}} Reviewer files: `<phase>-native-prompt.md`,
`<phase>-outside-prompt.md`, `<phase>-native.md`, `<phase>-outside.md`, `<phase>-consensus.{json,md}`,
`gate.json`, `decisions.jsonl`. `export` writes `plan.md`, `review-record.md`, `findings.jsonl`,
`tasks.jsonl`, `timing.json` and rehashes `run.json`; `gstack-artifact validate <dir>/run.json`
is the completion check. Copy the whole directory, never single files.
## Result codes
{{AUTOPLAN_DRIVER_CODES}}
{{COORDINATOR_CONTRACT}}
+2 -1
View File
@@ -24,7 +24,7 @@ import * as path from 'node:path';
import { answerGate } from '../lib/autoplan-gate';
import { exportRun } from '../lib/autoplan-export';
import { FILES, RunError, initRun, markOutside, nextStep, readState, resumeRun, statusLines, submitResult, withRunLock, writeState } from '../lib/autoplan-run';
import type { Phase } from '../lib/autoplan-prompts';
import { driverFile, type Phase } from '../lib/autoplan-prompts';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { createRunner, familyConflict, type RunnerId } from '../lib/outside-voice-runner';
import { resultLine } from '../lib/result-codes';
@@ -32,6 +32,7 @@ import { release } from '../lib/spend-ledger';
import { COORDINATOR_CONTRACT } from '../lib/coordinator-contract';
const HELP = `gstack-autoplan — unattended /autoplan: next/submit loop, answer, resume (docs/unattended.md)
DRIVER: ${driverFile()}
next --out <dir> [--plan <file>] [--plan-heading <text>] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--no-dx]
[--no-outside] [--light] [--spend-cap <usd>] [--estimate-usd <n>] [--deadline <min>] [--redispatch <attempt>] [--json]
+3
View File
@@ -13,12 +13,15 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { readScopeMarker, scopeMarker, scopeTokens, type ScopeFlags, type UiEvidence } from './autoplan-scope';
import { COORDINATOR_CONTRACT } from './coordinator-contract';
import { resolveSourceRoot } from './source-root';
export const PHASES = ['ceo', 'design', 'dx', 'eng'] as const;
export type Phase = typeof PHASES[number];
export const REVIEW_SKILL: Record<Phase, string> = { ceo: 'plan-ceo-review', design: 'plan-design-review', dx: 'plan-devex-review', eng: 'plan-eng-review' };
const ROOT = path.resolve(import.meta.dir, '..');
export const SNAPSHOT_TOOL = path.join(ROOT, 'bin', 'gstack-autoplan-snapshot.ts');
/** The parent's driver doc, in this checkout or the one an install root records (lib/source-root.ts). */
export const driverFile = () => path.join(resolveSourceRoot(ROOT), 'autoplan', 'DRIVER.md');
export { detectUiScope, type ScopeFlags } from './autoplan-scope';
+2 -2
View File
@@ -19,7 +19,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { canonicalRows, parseReviewResult } from './autoplan-reconcile';
import { closePhase, finishGate } from './autoplan-gate';
import { PHASES, SNAPSHOT_TOOL, detectScope, extractHeading, phaseOrder, reviewSkillFile, scopeLines, scopeSourceLabel, sectionLoadFor, snapshotTool, writePrompts, type Phase, type Scope, type ScopeFlags } from './autoplan-prompts';
import { PHASES, SNAPSHOT_TOOL, detectScope, driverFile, extractHeading, phaseOrder, reviewSkillFile, scopeLines, scopeSourceLabel, sectionLoadFor, snapshotTool, writePrompts, type Phase, type Scope, type ScopeFlags } from './autoplan-prompts';
import { FILES, RunError, journal, now, readJournal, readState, writeState, type AttemptState, type PhaseState, type RunState, type Voice, type VoiceState } from './autoplan-state';
import type { RunStatus } from './headless-artifacts';
import { sha256File } from './headless-artifacts';
@@ -151,7 +151,7 @@ export function nextStep(o: NextOptions): NextResult {
const ps = state.phases[phase]!;
result.phase = phase;
lines.push(`PHASE: ${phase} snapshot=${ps.snapshot} order=${state.phase_order.join(',')}`);
if (first) lines.push(...scopeLines(state.scope));
if (first) lines.push(...scopeLines(state.scope), `DRIVER: ${driverFile()}`);
if (ps.outside?.reason === 'consent_missing') lines.push(`OUTSIDE: ${phase} not_run reason=consent_missing`, String(ps.outside.detail));
if (ps.sections) lines.push(`Skipped sections: ${ps.sections.skipped.length ? ps.sections.skipped.join(',') : 'none'} (scope: ui=${state.scope.ui ? 'yes' : 'no'},dx=${state.scope.dx ? 'yes' : 'no'},source=${scopeSourceLabel(state.scope)}; checklist=${ps.sections.checklist ? 'loaded' : 'none'})`);
const prompts = ps.prompts!;
+17
View File
@@ -0,0 +1,17 @@
/**
* source-root — the gstack checkout behind an install root. setup records the
* source checkout in `<install root>/.source-path` (`_link_runtime_dists`,
* Windows copy roots and the Copilot root); files that live only in the
* checkout (autoplan/DRIVER.md) resolve through it. A root without the file,
* or whose recorded path no longer exists, is its own source.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
export function resolveSourceRoot(root: string): string {
try {
const recorded = fs.readFileSync(path.join(root, '.source-path'), 'utf8').split('\n', 1)[0]!.trim();
if (recorded && fs.statSync(recorded).isDirectory()) return path.resolve(recorded);
} catch { /* no record, or a stale one */ }
return root;
}
+12
View File
@@ -878,6 +878,15 @@ function processTemplate(tmplPath: string, host: Host, options: RenderOptions):
return { outputPath, content, symlinkLoop, metadata };
}
/** autoplan/DRIVER.md (field wave item 13): one host-neutral render of autoplan/DRIVER.md.tmpl through the same resolvers. */
export const AUTOPLAN_DRIVER_RELPATH = 'autoplan/DRIVER.md';
export function renderAutoplanDriver(options: RenderOptions): string {
const tmplPath = path.join(ROOT, `${AUTOPLAN_DRIVER_RELPATH}.tmpl`);
const tmpl = fs.readFileSync(tmplPath, 'utf-8');
const ctx = buildContext(tmpl, tmplPath, 'claude', options, 'autoplan');
return GENERATED_HEADER.replace('{{SOURCE}}', `${AUTOPLAN_DRIVER_RELPATH}.tmpl`) + resolvePlaceholders(tmpl, ctx, getHostConfig('claude'), `${AUTOPLAN_DRIVER_RELPATH}.tmpl`, options);
}
/**
* Generate one on-demand section file (`<skill>/sections/<name>.md.tmpl` →
* `<name>.md`). Sections are BODY FRAGMENTS — no frontmatter, no catalog trim,
@@ -1061,6 +1070,9 @@ export async function runGeneration(settings: GenerationOptions = {}): Promise<G
generateDesignChecklistMd(), 'asset', host);
emit(path.join(options.outputRoot, DOM_DUMP_FILE), DOM_DUMP_SCRIPT + '\n', 'asset', host);
}
if (host === 'claude' && includesSkill(hostConfig, 'autoplan')) {
emit(path.join(options.outputRoot, AUTOPLAN_DRIVER_RELPATH), renderAutoplanDriver(options), 'asset', host);
}
if (host === 'openclaw') {
for (const variant of ['lite', 'full', 'plan'] as const) {
+70
View File
@@ -0,0 +1,70 @@
/**
* 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>"', does: 'answer the gate with the reply grammar below' },
{ 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> --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; take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts)' },
];
/** The reply grammar `gstack-gate` parses, one form per line. */
export const GATE_REPLY_FORMS: Array<{ reply: string; means: string }> = [
{ reply: 'all', means: 'every item takes its recommendation (an agreed change prints APPLY: and needs revise)' },
{ reply: 'all 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: 'all 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', 'PLAN_AMEND_PENDING', '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');
+7
View File
@@ -52,6 +52,7 @@ import { generateImplementerDispatchRules } from './dispatch-rules';
import { generatePlanRealityRows } from './plan-reality';
import { generateSidewaysSweep, generateFlakeFixNumber } from './sideways';
import { generateCoordinatorContract } from './coordinator-contract';
import { generateAutoplanDriverCodes, generateAutoplanDriverCommands, generateAutoplanDriverFiles, generateAutoplanDriverGate, generateAutoplanDriverPhases, generateAutoplanDriverResultFormat } from './autoplan-driver';
import { generateQAScope, generateQAExploratory, generateQAFunctional, generateQAResource, generateQAReview, generateQAReviewPreflight, generateQAMethodReads } from './qa';
export const RESOLVERS: Record<string, ResolverFn> = {
@@ -176,6 +177,12 @@ export const RESOLVERS: Record<string, ResolverFn> = {
TASKS_SECTION_EMIT: generateTasksSectionEmit,
TASKS_SECTION_AGGREGATE: generateTasksSectionAggregate,
COORDINATOR_CONTRACT: generateCoordinatorContract,
AUTOPLAN_DRIVER_PHASES: generateAutoplanDriverPhases,
AUTOPLAN_DRIVER_COMMANDS: generateAutoplanDriverCommands,
AUTOPLAN_DRIVER_RESULT_FORMAT: generateAutoplanDriverResultFormat,
AUTOPLAN_DRIVER_GATE: generateAutoplanDriverGate,
AUTOPLAN_DRIVER_FILES: generateAutoplanDriverFiles,
AUTOPLAN_DRIVER_CODES: generateAutoplanDriverCodes,
SECTION,
SECTION_INDEX,
};
+111
View File
@@ -0,0 +1,111 @@
/**
* Field wave item 13: autoplan/DRIVER.md is the compact, host-neutral entry a
* parent agent reads to drive /autoplan through `gstack-autoplan`. It is
* generated from autoplan/DRIVER.md.tmpl by resolvers that read the code
* (phase order, scope flags, run files, RESULT FORMAT, result codes, the
* coordinator contract), stays under 160 lines with no harness vocabulary,
* is in the generator's normal, out-dir and dry-run inventories, and is
* found from an installed root through its recorded `.source-path`.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { FILES } from '../lib/autoplan-state';
import { PHASES, RESULT_FORMAT } from '../lib/autoplan-prompts';
import { SCOPE_FLAG_TOKENS } from '../lib/autoplan-scope';
import { COORDINATOR_CONTRACT } from '../lib/coordinator-contract';
import { parseReply, type GateList } from '../lib/gate-list';
import { resolveSourceRoot } from '../lib/source-root';
import { AUTOPLAN_DRIVER_COMMANDS, DRIVER_CODES, GATE_REPLY_FORMS } from '../scripts/resolvers/autoplan-driver';
import { runGeneration } from '../scripts/gen-skill-docs';
import { capyHost } from './helpers/capy-host';
import { AUTOPLAN, ROOT, SMALL_PLAN, env } from './helpers/autoplan-run-fixture';
const DRIVER = path.join(ROOT, 'autoplan', 'DRIVER.md');
const doc = fs.readFileSync(DRIVER, 'utf8');
const host = capyHost();
afterAll(() => host.cleanup());
const hostEnv = () => env({ ...host.env, GSTACK_STATE_ROOT: host.stateRoot, GSTACK_HOME: host.stateRoot });
/** Flags the field-wave plan specifies for lane B-core's commands (submit --record, revise, next --scaffold); they reach --help when PR B integrates. */
const PLAN_SPECIFIED_FLAGS = new Set(['--record', '--attest', '--unavailable', '--failed', '--scaffold', '--incorporates', '--withdraw']);
describe('autoplan/DRIVER.md content comes from the code', () => {
test('phase order, scope flags, run files, RESULT FORMAT, result codes and the coordinator contract are rendered', () => {
expect(doc).toContain(`Order: ${PHASES.join(' → ')}.`);
for (const flag of SCOPE_FLAG_TOKENS) expect(doc).toContain(`\`${flag}\``);
for (const file of Object.values(FILES)) expect(doc).toContain(`\`${file}\``);
expect(doc).toContain(RESULT_FORMAT('<phase>', '<snapshot sha256>', '<result file>').replace(/^\n---\n/, '').trimEnd());
for (const code of DRIVER_CODES) expect(doc).toMatch(new RegExp(`^- \`${code}\`: \\S`, 'm'));
expect(doc).toContain(COORDINATOR_CONTRACT);
for (const c of AUTOPLAN_DRIVER_COMMANDS) expect(doc).toContain(`- \`${c.cmd}\``);
for (const f of GATE_REPLY_FORMS) expect(doc).toContain(`- \`${f.reply}\`: `);
});
test('the plan-specified commands and the amendment interlock are present', () => {
for (const token of ['submit --out <dir> --phase <p> --voice native|outside --record', '--attest "<reason>"', '--unavailable|--failed',
'revise --out <dir> --plan <plan v2> --incorporates <decision ids>', '--withdraw <decision id>', '[--scaffold]', '`--no-dx`', '`all as-is`', 'PLAN_AMEND_PENDING', 'APPLY:']) {
expect(doc).toContain(token);
}
});
test('at most 160 lines and no harness vocabulary', () => {
expect(doc.split('\n').length).toBeLessThanOrEqual(160);
for (const term of ['PreToolUse', 'PostToolUse', 'SessionStart', 'AskUserQuestion', 'Skill tool']) expect(doc).not.toContain(term);
});
test('every flag it names is in gstack-autoplan --help or is a plan-specified B-core flag, and the gate forms parse', () => {
const help = spawnSync('bun', [AUTOPLAN, '--help'], { cwd: ROOT, env: hostEnv(), encoding: 'utf8', timeout: 30_000 }).stdout;
const commands = AUTOPLAN_DRIVER_COMMANDS.map(c => c.cmd).join(' ');
for (const flag of new Set(commands.match(/--[a-z][a-z-]*/g)!)) {
expect(help.includes(flag) || PLAN_SPECIFIED_FLAGS.has(flag), flag).toBe(true);
}
const gate: GateList = { schema_version: 1, run: 'r', gate_rev: 1, items: ['d3', 'uc1'].map(id => ({ id, title: id, recommended: 'a', kind: 'auto' as const, options: [{ key: 'a', text: 'keep' }, { key: 'b', text: 'change' }] })) };
for (const f of GATE_REPLY_FORMS.filter(f => f.reply !== 'all as-is')) expect(parseReply(f.reply, gate, 1).valid, f.reply).toBe(true);
expect(parseReply('yes', gate, 1).valid).toBe(false);
});
});
describe('where a parent finds it', () => {
test('gstack-autoplan --help prints DRIVER: <root>/autoplan/DRIVER.md', () => {
const r = spawnSync('bun', [AUTOPLAN, '--help'], { cwd: ROOT, env: hostEnv(), encoding: 'utf8', timeout: 30_000 });
expect(r.stdout.split('\n')).toContain(`DRIVER: ${DRIVER}`);
});
test('the first next prints the DRIVER line once', () => {
const dir = path.join(host.base, 'first-next');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'plan.md'), SMALL_PLAN);
const out = path.join(dir, 'run');
const first = spawnSync('bun', [AUTOPLAN, 'next', '--out', out, '--plan', path.join(dir, 'plan.md'), '--no-ui'], { cwd: ROOT, env: hostEnv(), encoding: 'utf8', timeout: 120_000 });
expect(first.status, first.stderr).toBe(0);
expect(first.stdout.split('\n').filter(l => l.startsWith('DRIVER: '))).toEqual([`DRIVER: ${DRIVER}`]);
const again = spawnSync('bun', [AUTOPLAN, 'next', '--out', out], { cwd: ROOT, env: hostEnv(), encoding: 'utf8', timeout: 120_000 });
expect(again.stdout).not.toContain('DRIVER: ');
});
test('an installed copy root resolves DRIVER.md through its recorded .source-path; a stale record falls back to the root', () => {
const root = path.join(host.base, 'install-root');
fs.mkdirSync(path.join(root, 'bin'), { recursive: true });
fs.cpSync(path.join(ROOT, 'lib'), path.join(root, 'lib'), { recursive: true });
fs.copyFileSync(AUTOPLAN, path.join(root, 'bin', 'gstack-autoplan'));
fs.writeFileSync(path.join(root, '.source-path'), `${ROOT}\n`);
const help = spawnSync('bun', [path.join(root, 'bin', 'gstack-autoplan'), '--help'], { cwd: host.base, env: hostEnv(), encoding: 'utf8', timeout: 30_000 });
expect(help.stdout.split('\n')).toContain(`DRIVER: ${DRIVER}`);
expect(resolveSourceRoot(root)).toBe(ROOT);
fs.writeFileSync(path.join(root, '.source-path'), `${path.join(host.base, 'gone')}\n`);
expect(resolveSourceRoot(root)).toBe(root);
});
});
describe('generator inventory', () => {
test('normal, out-dir and dry-run generation all carry autoplan/DRIVER.md, and the committed file is fresh', async () => {
const outDir = path.join(host.base, 'render');
const written = await runGeneration({ host: 'claude', outputRoot: outDir, contentLinkRoot: null });
expect(written.artifacts).toContainEqual({ relativePath: 'autoplan/DRIVER.md', kind: 'asset', host: 'claude' });
expect(fs.readFileSync(path.join(outDir, 'autoplan', 'DRIVER.md'), 'utf8')).toBe(doc);
const dry = await runGeneration({ host: 'claude', dryRun: true });
expect(dry.artifacts).toContainEqual({ relativePath: 'autoplan/DRIVER.md', kind: 'asset', host: 'claude' });
expect(dry.diagnostics.filter(d => d.relativePath === 'autoplan/DRIVER.md')).toEqual([]);
}, 120_000);
});
+3 -1
View File
@@ -104,7 +104,9 @@
{ "file": "lib/pregate/checks/strays.ts", "movedFrom": "(new: pre-gate check, C8)" },
{ "file": "lib/pregate/checks/literals.ts", "movedFrom": "(new: pre-gate check, C8)" },
{ "file": "lib/pregate/checks/lanes.ts", "movedFrom": "(new: pre-gate check with the pipefail runner and requires-remote obligations, C8)" },
{ "file": "lib/autoplan-scope.ts", "movedFrom": "(new: UI evidence from rendered surfaces and the plan scope marker, shared by the runner and the snapshot tool, field wave B2c)" }
{ "file": "lib/autoplan-scope.ts", "movedFrom": "(new: UI evidence from rendered surfaces and the plan scope marker, shared by the runner and the snapshot tool, field wave B2c)" },
{ "file": "lib/source-root.ts", "movedFrom": "(new: the source checkout behind an install root, from its recorded .source-path, field wave B2e)" },
{ "file": "scripts/resolvers/autoplan-driver.ts", "movedFrom": "(new: renders autoplan/DRIVER.md from the runner's constants, field wave B2e)" }
],
"residualFiles": [
{ "file": "scripts/test-free-shards.ts", "maxLines": 2361, "reason": "free lane policy after the shard engine took spawn, sandbox, logs, seeds and flags" },
+1
View File
@@ -46,6 +46,7 @@ describe('generator artifact and dry-run contract', () => {
expect(generated.artifacts.filter(a => a.kind === 'asset').sort((a, b) => a.relativePath.localeCompare(b.relativePath))).toEqual([
{ relativePath: 'review/design-checklist.md', kind: 'asset', host: 'claude' },
{ relativePath: 'lib/dom-dump.js', kind: 'asset', host: 'claude' },
{ relativePath: 'autoplan/DRIVER.md', kind: 'asset', host: 'claude' },
...ALL_HOST_NAMES.filter(host => includesSkill(getHostConfig(host), 'qa')).map(host => ({
relativePath: host === 'claude' ? 'qa/templates/functional-report-template.md'
: `${getHostConfig(host).hostSubdir}/skills/gstack-qa/templates/functional-report-template.md`,