Merge lane B-aux into the autoplan wave: scope, PLAN_REALITY v2 and DRIVER.md wired into the runner

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 01:19:37 +00:00
commit decde8e6de
24 files changed
+888 -59

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 only when you recommend changing the plan's stated direction AND the change alters its scope, cost or direction; then also add "changes": "scope" | "cost" | "direction". Packaging, ordering, naming and other reversible choices are ordinary findings, not user challenges. 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`: add the missing heading, or rerun the first `gstack-autoplan next` with --scaffold to review a scaffolded copy (your plan file is never edited); duplicate, reversed or empty sections are fixed by hand
- `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`, `all as-is`, `<id><option>` tokens (for example `d3b uc1a`), or `all except <tokens>`; bare yes/no is ambiguous
- `PLAN_AMEND_PENDING`: edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id,...>`; or drop one with `gstack-autoplan revise --out <dir> --withdraw <decision id>`
- `GSTACK_VERSION_CHANGED`: nothing to do for an older run (journaled forward migrations ran); a run written by a newer gstack needs that gstack: upgrade with bin/gstack-upgrade
## 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 -4
View File
@@ -707,14 +707,12 @@ Re-run: copy RESTORE_PATH's bytes to SOURCE_PLAN, then /autoplan.
- Read CLAUDE.md, TODOS.md, git log -30, git diff against the base branch --stat
- Discover design docs: `~/.claude/skills/gstack/bin/gstack-design-doc-find "$SLUG" "$BRANCH"` (prints the doc path, or nothing)
- Detect UI scope: grep the plan for view/rendering terms (component, screen, form,
button, modal, layout, dashboard, sidebar, nav, dialog). Require 2+ matches, excluding
false positives ("page" alone, "UI" in acronyms).
- Use init's full-input `scope`. For changed input or semantic enabling flags, rerun:
```bash
bun "<SNAPSHOT_TOOL>" scope "<ACTIVE_PLAN>"
```
Use returned `dxRequired` (initially `scope.dxRequired`); record its input hash and matched
UI scope is returned `uiRequired`: a rendered file path or a UI term next to a visual
noun (`uiEvidence`); bare UI words never add Design. Use returned `dxRequired` (initially `scope.dxRequired`); record its input hash and matched
terms. The threshold is 2+ term matches (occurrences, not distinct terms). Also enable DX when the
product is a developer tool (developers install, integrate or build on it) or an AI agent is
the primary user: add `--developer-tool` or `--agent-primary`. These flags only enable DX; no
+2 -4
View File
@@ -227,14 +227,12 @@ Re-run: copy RESTORE_PATH's bytes to SOURCE_PLAN, then /autoplan.
- Read CLAUDE.md, TODOS.md, git log -30, git diff against the base branch --stat
- Discover design docs: `~/.claude/skills/gstack/bin/gstack-design-doc-find "$SLUG" "$BRANCH"` (prints the doc path, or nothing)
- Detect UI scope: grep the plan for view/rendering terms (component, screen, form,
button, modal, layout, dashboard, sidebar, nav, dialog). Require 2+ matches, excluding
false positives ("page" alone, "UI" in acronyms).
- Use init's full-input `scope`. For changed input or semantic enabling flags, rerun:
```bash
bun "<SNAPSHOT_TOOL>" scope "<ACTIVE_PLAN>"
```
Use returned `dxRequired` (initially `scope.dxRequired`); record its input hash and matched
UI scope is returned `uiRequired`: a rendered file path or a UI term next to a visual
noun (`uiEvidence`); bare UI words never add Design. Use returned `dxRequired` (initially `scope.dxRequired`); record its input hash and matched
terms. The threshold is 2+ term matches (occurrences, not distinct terms). Also enable DX when the
product is a developer tool (developers install, integrate or build on it) or an AI agent is
the primary user: add `--developer-tool` or `--agent-primary`. These flags only enable DX; no
+4 -3
View File
@@ -32,7 +32,7 @@ import { migrateRun } from '../lib/autoplan-migrate';
import { reviseRun } from '../lib/autoplan-revise';
import { pendingAmendments, type RunState } from '../lib/autoplan-state';
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';
@@ -40,8 +40,9 @@ 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>] [--scaffold] [--ui|--no-ui] [--developer-tool] [--agent-primary]
next --out <dir> [--plan <file>] [--plan-heading <text>] [--scaffold] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--no-dx]
[--no-outside] [--light] [--spend-cap <usd>] [--estimate-usd <n>] [--deadline <min>] [--redispatch <attempt>] [--json]
--scaffold (first next): a plan missing one of its two sections is reviewed from <out>/plan-source.md with the heading added
--light loads each phase's checklist plus the sections its scope needs (the unattended default); interactive kinds load everything
@@ -131,7 +132,7 @@ function doNext(out: string, rest: string[], json: boolean): number {
if (!plan) throw new RunError('RUN_NOT_INITIALIZED', `${out} has no run.json; pass --plan <file> on the first next`, 3);
const ui = rest.includes('--ui') ? true : rest.includes('--no-ui') ? false : undefined;
initRun({ plan, out, cwd, planHeading: flag(rest, '--plan-heading'), outsideEnabled: !rest.includes('--no-outside'), spendCap: num(rest, '--spend-cap') ?? null,
deadlineMin: num(rest, '--deadline'), light: rest.includes('--light'), scaffold: rest.includes('--scaffold'), scope: { ui, developerTool: rest.includes('--developer-tool'), agentPrimary: rest.includes('--agent-primary') } }, initLines);
deadlineMin: num(rest, '--deadline'), light: rest.includes('--light'), scaffold: rest.includes('--scaffold'), scope: { ui, developerTool: rest.includes('--developer-tool'), agentPrimary: rest.includes('--agent-primary'), dx: rest.includes('--no-dx') ? false : undefined } }, initLines);
}
const redispatch = rest.flatMap((a, i) => (a === '--redispatch' && rest[i + 1] ? [rest[i + 1]!] : []));
const r = nextStep({ out, cwd, estimateUsd: num(rest, '--estimate-usd'), redispatch });
+2 -2
View File
@@ -20,13 +20,13 @@ function dxTermsFor(content: string) {
return { threshold: 2, matches, matchCount, dxRequiredByTerms: matchCount >= 2 };
}
/** Byte-bound scope evidence; semantic product/user triggers can only enable DX. */
/** Byte-bound scope evidence; semantic product/user triggers can only enable DX. UI evidence (lib/autoplan-scope.ts) loads only here, so every other command still runs from a lone copy of this file. */
export function detectDxScope(activePlan: string, developerTool = false, agentPrimary = false) {
const source = realpathSync(activePlan);
const content = extractImplementationPlan(readFileSync(source, 'utf8'));
const terms = dxTermsFor(content);
return { activePlan: source, sha256: sha256(content), ...terms, developerTool, agentPrimary,
dxRequired: terms.dxRequiredByTerms || developerTool || agentPrimary };
dxRequired: terms.dxRequiredByTerms || developerTool || agentPrimary, ...(require('../lib/autoplan-scope') as typeof import('../lib/autoplan-scope')).detectUiScope(content) };
}
// The native dispatch payload is assembled from the same immutable bytes as
+6 -2
View File
@@ -20,7 +20,9 @@ const HELP = `gstack-plan-reality — reality rows for plan reviews
Row line: \`${REALITY_LINE_SHAPE}\` (receipt: path:line, path:line-line or a commit sha).
Scopes: ${REALITY_SCOPES.filter(s => s !== 'always').join(', ')}. Session kind unattended → verdict=incomplete; else verdict=repair.
Prints \`PLAN_REALITY: phase=<p> rows=<n>/<m> missing=<ids|none> [unverified=<n>] verdict=complete|incomplete|repair\`.
Prints \`PLAN_REALITY: phase=<p> rows=<n>/<m> missing=<ids|none> [unreceipted=<ids>] [unreceipted_numbers=<n>] [unquoted=<n>] [unverified=<n>] verdict=complete|incomplete|repair\`,
then one \`NUMBER_UNRECEIPTED: <clause>\` per measured number (unit or measured/costs/took) without its own receipt.
A line claiming a decision rule must quote it (\`"..."\`, a fenced or > block after it) or link it (file:line).
${exitTableHelp()}`;
function flag(args: string[], name: string): string | undefined {
@@ -55,10 +57,12 @@ function main(argv: string[]): number {
const sessionKind = flag(rest, '--session-kind') ?? process.env.GSTACK_SESSION_KIND;
const result = checkRealityRows(text, { phase, scopes, sessionKind });
if (json) process.stdout.write(JSON.stringify(result) + '\n');
else process.stdout.write(result.line + '\n');
else process.stdout.write([result.line, ...result.numberLines].join('\n') + '\n');
for (const id of result.unknownRows) process.stderr.write(`gstack-plan-reality: ${file}: REALITY row ${JSON.stringify(id)} is not a known row; it was ignored\n`);
if (result.missing.length) process.stderr.write(resultLine('gstack-plan-reality check', 'REALITY_ROW_MISSING', `phase ${phase}: ${result.missing.join(', ')}`) + '\n');
if (result.unreceipted.length) process.stderr.write(resultLine('gstack-plan-reality check', 'REALITY_RECEIPT_MISSING', `phase ${phase}: ${result.unreceipted.join(', ')}`) + '\n');
if (result.unreceipted_numbers.length) process.stderr.write(resultLine('gstack-plan-reality check', 'REALITY_RECEIPT_MISSING', `phase ${phase}: ${result.unreceipted_numbers.length} measured number(s) share or lack a receipt; give each clause its own file:line, script path or command`) + '\n');
for (const claim of result.unquoted) process.stderr.write(resultLine('gstack-plan-reality check', 'REALITY_RECEIPT_MISSING', `phase ${phase}: a decision rule is claimed without its text; quote it or link its file:line: ${claim}`) + '\n');
return result.verdict === 'complete' ? EXIT.ok : EXIT.fail;
}
return usage(`unknown command ${cmd}`);
+8 -1
View File
@@ -12,9 +12,16 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { decisionId } from './gate-list';
import { resolveSourceRoot } from './source-root';
import { RunError, journal, readDecisions, writeDecisions, type RunState } from './autoplan-state';
export const GSTACK_VERSION = fs.readFileSync(path.join(import.meta.dir, '..', 'VERSION'), 'utf8').trim();
function readVersion(): string {
for (const root of [path.join(import.meta.dir, '..'), resolveSourceRoot(path.join(import.meta.dir, '..'))]) {
try { return fs.readFileSync(path.join(root, 'VERSION'), 'utf8').trim(); } catch { /* try the recorded source checkout */ }
}
return 'unknown';
}
export const GSTACK_VERSION = readVersion();
/** Numeric compare of dotted versions (1.91.95.0); missing parts are 0. */
export function compareVersions(a: string, b: string): number {
+61 -18
View File
@@ -11,39 +11,81 @@
import { spawnSync } from 'node:child_process';
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');
/** The Phase 0 UI trigger from autoplan/SKILL.md.tmpl Step 2: view/rendering terms, 2+ matches, acronyms excluded. */
const UI_TERMS = ['component', 'screen', 'form', 'button', 'modal', 'layout', 'dashboard', 'sidebar', 'nav', 'dialog'];
export function detectUiScope(planText: string): { ui: boolean; matches: Array<{ term: string; count: number }> } {
const matches = UI_TERMS.map(term => ({ term, count: [...planText.matchAll(new RegExp(`\\b${term}s?\\b`, 'gi'))].length })).filter(m => m.count > 0);
return { ui: matches.reduce((s, m) => s + m.count, 0) >= 2, matches };
export { detectUiScope, type ScopeFlags } from './autoplan-scope';
export type ScopeSource = 'flag' | 'marker' | 'detected';
export interface Scope {
ui: boolean; dx: boolean; ui_matches: number; dx_matches: number; developer_tool: boolean; agent_primary: boolean;
sources: { ui: ScopeSource; dx: ScopeSource };
ui_evidence?: UiEvidence; dx_terms?: Array<{ term: string; count: number }>;
/** Flags from the command line (revise re-detects with these) and the plan's marker tokens. */
cli_flags?: string[]; marker?: string[] | null;
/** The marker the author can add to keep CLI flags for the next run on this plan. */
pin?: { marker: string; file: string } | null;
/** Runs created before per-axis provenance. */
source?: 'detected' | 'flags';
}
export interface ScopeFlags { ui?: boolean; developerTool?: boolean; agentPrimary?: boolean; dx?: boolean }
export interface Scope { ui: boolean; dx: boolean; ui_matches: number; dx_matches: number; developer_tool: boolean; agent_primary: boolean; source: 'detected' | 'flags' }
export function snapshotTool(args: string[], cwd: string): any {
const r = spawnSync('bun', [SNAPSHOT_TOOL, ...args], { cwd, encoding: 'utf8', timeout: 120_000, env: process.env });
if (r.status !== 0) throw new Error(`gstack-autoplan-snapshot ${args[0]}: ${(r.stderr || r.stdout).trim()}`);
return JSON.parse(r.stdout);
}
/** UI from the plan text; DX from the snapshot tool's own term count plus the semantic flags (flags only enable). */
export function detectScope(activePlan: string, cwd: string, flags: ScopeFlags): Scope {
const dx = snapshotTool(['scope', activePlan, ...(flags.developerTool ? ['--developer-tool'] : []), ...(flags.agentPrimary ? ['--agent-primary'] : [])], cwd);
const ui = detectUiScope(fs.readFileSync(activePlan, 'utf8'));
/**
* UI and DX evidence from the snapshot tool (both read the Implementation plan only); per axis, CLI flags win over the
* plan's first-line scope marker, which wins over detection. `--developer-tool` and `--agent-primary` only enable DX;
* `--no-dx` turns it off. The source plan is read for its marker and never written.
*/
export function detectScope(activePlan: string, cwd: string, flags: ScopeFlags, sourcePlan: string = activePlan): Scope {
const marker = readScopeMarker(fs.readFileSync(sourcePlan, 'utf8'), sourcePlan);
const m = marker?.flags ?? {};
const developerTool = !!(flags.developerTool || m.developerTool);
const agentPrimary = !!(flags.agentPrimary || m.agentPrimary);
const detected = snapshotTool(['scope', activePlan, ...(developerTool ? ['--developer-tool'] : []), ...(agentPrimary ? ['--agent-primary'] : [])], cwd);
const dxSource: ScopeSource = flags.dx !== undefined ? 'flag' : m.dx !== undefined ? 'marker'
: flags.developerTool || flags.agentPrimary ? 'flag' : m.developerTool || m.agentPrimary ? 'marker' : 'detected';
const cli = scopeTokens(flags);
const merged = scopeTokens({ ui: flags.ui ?? m.ui, dx: flags.dx ?? m.dx, developerTool, agentPrimary });
const pinned = !cli.length || (marker !== null && [...merged].sort().join(' ') === [...marker.tokens].sort().join(' '));
return {
ui: flags.ui ?? ui.ui, dx: flags.dx ?? dx.dxRequired, ui_matches: ui.matches.reduce((s, m) => s + m.count, 0), dx_matches: dx.matchCount,
developer_tool: !!flags.developerTool, agent_primary: !!flags.agentPrimary, source: flags.ui !== undefined || flags.dx !== undefined ? 'flags' : 'detected',
ui: flags.ui ?? m.ui ?? detected.uiRequired, dx: flags.dx ?? m.dx ?? detected.dxRequired,
ui_matches: detected.uiMatches, dx_matches: detected.matchCount, developer_tool: developerTool, agent_primary: agentPrimary,
sources: { ui: flags.ui !== undefined ? 'flag' : m.ui !== undefined ? 'marker' : 'detected', dx: dxSource }, ui_evidence: detected.uiEvidence, dx_terms: detected.matches,
cli_flags: cli, marker: marker?.tokens ?? null, pin: pinned ? null : { marker: scopeMarker(merged), file: sourcePlan },
};
}
/** Per-axis provenance, reading runs created before it existed through their single `source`. */
export function scopeSources(scope: Scope): { ui: ScopeSource; dx: ScopeSource } {
if (scope.sources) return scope.sources;
const s: ScopeSource = scope.source === 'flags' ? 'flag' : 'detected';
return { ui: s, dx: s };
}
export const scopeSourceLabel = (scope: Scope) => { const s = scopeSources(scope); return `ui:${s.ui},dx:${s.dx}`; };
/** What the first `next` prints: the evidence behind each axis, its provenance, and the marker that would keep CLI flags. */
export function scopeLines(scope: Scope): string[] {
const list = (xs: Array<{ term: string; count: number }> | undefined) => (xs?.length ? xs.map(t => `${t.term}×${t.count}`).join(', ') : 'none');
const ev = scope.ui_evidence;
const ui = `ui=${scope.ui ? 'yes' : 'no'} (terms: ${list(ev?.terms)}; visual: ${ev?.visual.length ? ev.visual.join(', ') : 'none'}; files: ${ev?.files.length ? ev.files.join(', ') : 'none'})`;
const dx = `dx=${scope.dx ? 'yes' : 'no'} (detected: ${list(scope.dx_terms)}${scope.developer_tool ? '; developer tool' : ''}${scope.agent_primary ? '; agent primary' : ''})`;
const lines = [`SCOPE: ${ui} ${dx} source=${scopeSourceLabel(scope)}`];
if (scope.pin) lines.push(`SCOPE_PIN: add ${scope.pin.marker} as the first line of ${scope.pin.file} to keep these flags`);
return lines;
}
export function phaseOrder(scope: Scope): Phase[] {
return PHASES.filter(p => (p === 'design' ? scope.ui : p === 'dx' ? scope.dx : true));
}
@@ -91,16 +133,17 @@ export const BOUNDARY = 'IMPORTANT: Do NOT read or execute any SKILL.md files or
export const SECTION_SCOPES = ['always', 'ui', 'dx', 'db', 'perf', 'security', 'incident'] as const;
export type SectionScope = typeof SECTION_SCOPES[number];
export interface SectionLoad { checklist: string | null; loaded: string[]; skipped: string[]; line: string }
/** A scoped section is off only when its scope is off by flags, or detected off with zero supporting matches; anything weaker is uncertain and loads it. */
/** A scoped section is off only when its scope is off by a flag or the plan's marker, or detected off with zero supporting matches; anything weaker is uncertain and loads it. */
export function sectionScopeOff(sectionScope: SectionScope, scope: Scope): boolean {
if (sectionScope === 'ui') return scope.source === 'flags' ? !scope.ui : !scope.ui && scope.ui_matches === 0;
if (sectionScope === 'dx') return scope.source === 'flags' ? !scope.dx : !scope.dx && scope.dx_matches === 0;
const sources = scopeSources(scope);
if (sectionScope === 'ui') return sources.ui !== 'detected' ? !scope.ui : !scope.ui && scope.ui_matches === 0;
if (sectionScope === 'dx') return sources.dx !== 'detected' ? !scope.dx : !scope.dx && scope.dx_matches === 0;
return false;
}
export function sectionLoadFor(phase: Phase, scope: Scope, mode: 'full' | 'light'): SectionLoad {
const dir = path.join(ROOT, REVIEW_SKILL[phase], 'sections');
const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8')) as { sections: Array<{ id: string; file: string; scope?: string; runner_only?: boolean }> };
const detected = `ui=${scope.ui ? 'yes' : 'no'},dx=${scope.dx ? 'yes' : 'no'},source=${scope.source}`;
const detected = `ui=${scope.ui ? 'yes' : 'no'},dx=${scope.dx ? 'yes' : 'no'},source=${scopeSourceLabel(scope)}`;
const loaded: string[] = [], skipped: string[] = [];
let checklist: string | null = null;
for (const s of manifest.sections) {
+3 -2
View File
@@ -18,6 +18,7 @@ import * as path from 'node:path';
import { exportRun } from './autoplan-export';
import { applyRenames, planReopen } from './autoplan-gate';
import { normalizeRevision, reviewHash, scaffold } from './autoplan-plan-text';
import { parseScopeTokens } from './autoplan-scope';
import { PHASES, detectScope, extractHeading, phaseOrder, type Phase, type ScopeFlags } from './autoplan-prompts';
import { FILES, RunError, journal, now, pendingAmendments, readJournal, readState, writeState, type RunState } from './autoplan-state';
@@ -78,8 +79,8 @@ export function reviseRun(o: ReviseOptions): ReviseResult {
fs.writeFileSync(path.join(staging, FILES.active), normalizeRevision(text, state.restore));
fs.writeFileSync(path.join(staging, FILES.source), text);
const flags = (state.scope_flags ?? { ui: state.scope.source === 'flags' ? state.scope.ui : undefined, developerTool: state.scope.developer_tool, agentPrimary: state.scope.agent_primary }) as ScopeFlags;
const detected = detectScope(path.join(staging, FILES.active), o.cwd, flags);
const flags = (state.scope_flags ?? (state.scope.cli_flags ? parseScopeTokens(state.scope.cli_flags, 'run.json') : { ui: state.scope.source === 'flags' ? state.scope.ui : undefined, developerTool: state.scope.developer_tool, agentPrimary: state.scope.agent_primary })) as ScopeFlags;
const detected = detectScope(path.join(staging, FILES.active), o.cwd, flags, file);
const order = PHASES.filter(p => state.phase_order.includes(p) || phaseOrder(detected).includes(p));
const added = order.filter(p => !state.phase_order.includes(p));
const opened = state.phase_order.filter(p => state.phases[p]?.snapshot);
+15 -11
View File
@@ -21,10 +21,10 @@ import { canonicalRows, parseReceipt, parseRecordFindings, parseReviewResult, ty
import { closePhase, finishGate } from './autoplan-gate';
import { GSTACK_VERSION } from './autoplan-migrate';
import { driftLine, reviewHash, scaffold } from './autoplan-plan-text';
import { PHASES, SNAPSHOT_TOOL, detectScope, extractHeading, phaseOrder, reviewSkillFile, 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 { recoverRevise } from './autoplan-revise';
import { FILES, RunError, journal, now, pendingAmendments, readDecisions, readJournal, readState, voiceTerminal, writeState, type AttemptState, type PhaseState, type RunState, type Voice, type VoiceState } from './autoplan-state';
import { checkRealityRows } from './plan-reality';
import { checkPlanReality, checkRealityRows, PLAN_REALITY_CHECKER } from './plan-reality';
import type { RunStatus } from './headless-artifacts';
import { sha256File } from './headless-artifacts';
import { outsideConsent } from './outside-consent';
@@ -91,7 +91,7 @@ export function initRun(o: InitOptions, lines: string[] = []): RunState {
const active = path.join(out, FILES.active);
const restore = path.join(out, FILES.restore);
const init = snapshotTool(['init', source, active, restore], o.cwd);
const scope = detectScope(active, o.cwd, o.scope ?? {});
const scope = detectScope(active, o.cwd, o.scope ?? {}, path.resolve(o.plan));
const order = phaseOrder(scope);
const run = o.runId ?? `${path.basename(out)}-${new Date().toISOString().replace(/[-:T.Z]/g, '').slice(0, 14)}`;
// codex_reviews gates the outside voice here, before any dispatch: a refusal is a terminal not_run outcome per phase.
@@ -172,14 +172,16 @@ export function nextStep(o: NextOptions): NextResult {
}
const phase = currentPhase(state);
if (!phase) { finishGate(state); writeState(state); return result; }
const first = !state.phase_order.some(p => state.phases[p]?.snapshot);
openPhase(state, phase, o.cwd);
const ps = state.phases[phase]!;
result.phase = phase;
lines.push(`PHASE: ${phase} snapshot=${ps.snapshot} order=${state.phase_order.join(',')}`);
const drift = driftLine(state, 'PLAN_CHANGED');
if (drift) lines.push(drift);
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=${state.scope.source}; checklist=${ps.sections.checklist ? 'loaded' : 'none'})`);
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!;
for (const voice of ['native', 'outside'] as const) {
const v = voiceState(state, phase, voice);
@@ -228,15 +230,17 @@ export interface SubmitOptions {
export interface SubmitResult { state: RunState; closed: boolean; findings: number; lines: string[] }
/** Report-only PLAN_REALITY on the active plan, once per Implementation-plan hash and checker (revise clears it). */
export const PLAN_REALITY_CHECKER = 'plan-reality/1';
function recordPlanReality(state: RunState): string[] {
let sha: string;
try { sha = reviewHash(fs.readFileSync(state.active_plan, 'utf8')); } catch { return []; }
let text: string;
try { text = fs.readFileSync(state.active_plan, 'utf8'); } catch { return []; }
const sha = reviewHash(text);
if (state.plan_reality?.plan_sha256 === sha && state.plan_reality.checker === PLAN_REALITY_CHECKER) return [];
const check = checkRealityRows(fs.readFileSync(state.active_plan, 'utf8'), { phase: 'plan', sessionKind: state.session_kind });
state.plan_reality = { plan_sha256: sha, checker: PLAN_REALITY_CHECKER, verdict: check.verdict, line: check.line };
journal(state.out, { event: 'plan_reality', plan_sha256: sha, checker: PLAN_REALITY_CHECKER, verdict: check.verdict });
return [`${check.line} (report only)`];
const rows = checkRealityRows(text, { phase: 'plan', sessionKind: state.session_kind });
const plan = checkPlanReality(text, { sessionKind: state.session_kind });
const verdict = rows.verdict !== 'complete' ? rows.verdict : plan.verdict;
state.plan_reality = { plan_sha256: sha, checker: PLAN_REALITY_CHECKER, verdict, line: `${rows.line}; ${plan.line}` };
journal(state.out, { event: 'plan_reality', plan_sha256: sha, checker: PLAN_REALITY_CHECKER, verdict });
return [`${rows.line} (report only)`, `${plan.line} (report only)`, ...plan.numberLines];
}
function resolveAttempt(v: VoiceState, attempt: string | undefined, phase: string, voice: Voice): AttemptState {
+76
View File
@@ -0,0 +1,76 @@
/**
* autoplan-scope — the scope evidence both /autoplan paths share (field wave
* item 11). The runner (lib/autoplan-prompts.ts `detectScope`) and the
* interactive path (`gstack-autoplan-snapshot.ts scope`, read by
* autoplan/SKILL.md) take UI scope from `detectUiScope` over the
* Implementation plan only. UI needs a rendered surface: a path to a rendered
* file, or a UI term next to a visual noun. Bare UI words are weak evidence:
* they keep the runner's conservative section fallback but do not add the
* Design phase. The scope marker is the author's way to pin flags in the plan
* itself; it is read, never written.
*/
export const UI_TERMS = ['component', 'screen', 'form', 'button', 'modal', 'layout', 'dashboard', 'sidebar', 'nav', 'dialog'] as const;
export const VISUAL_NOUNS = ['page', 'layout', 'style', 'pixel', 'view', 'browser', 'css'] as const;
const VISUAL_WINDOW = 4;
const RENDERED_EXT = /(?:^|\/)[^/.][^/]*\.(?:tsx|jsx|vue|svelte|css|scss|html|erb|hbs)$/i;
const RENDERED_DIR = /(?:^|\/)(?:components|pages|views|styles)\//i;
export interface UiEvidence { terms: Array<{ term: string; count: number }>; visual: string[]; files: string[] }
export interface UiScope { uiRequired: boolean; uiMatches: number; uiEvidence: UiEvidence }
const wordOf = (w: string, list: readonly string[]) => list.find(t => w === t || w === `${t}s` || (t === 'style' && w === 'stylesheet'));
/** UI scope from rendered surfaces: a rendered file path, or a UI term within a few words of a visual noun in the same sentence (paths are not prose). */
export function detectUiScope(implementationPlan: string): UiScope {
const files = [...new Set([...implementationPlan.matchAll(/[\w@~$.\/-]+/g)]
.map(m => m[0].replace(/[.\-]+$/, ''))
.filter(t => t.length > 1 && (RENDERED_EXT.test(t) || RENDERED_DIR.test(t))))];
const counts = new Map<string, number>();
const visual: string[] = [];
for (const sentence of implementationPlan.split(/[.!?;:](?:\s|$)|\n\s*\n/)) {
const words = sentence.replace(/\S*\/\S*|\b[\w-]+\.[a-z]{1,6}\b/gi, ' ').toLowerCase().match(/[a-z]+/g) ?? [];
words.forEach((w, i) => {
const term = wordOf(w, UI_TERMS);
if (!term) return;
counts.set(term, (counts.get(term) ?? 0) + 1);
for (let j = Math.max(0, i - VISUAL_WINDOW); j <= Math.min(words.length - 1, i + VISUAL_WINDOW); j++) {
const noun = j !== i ? wordOf(words[j]!, VISUAL_NOUNS) : undefined;
if (noun && noun !== term) { visual.push(`${term}~${noun}`); return; }
}
});
}
const terms = UI_TERMS.flatMap(term => (counts.has(term) ? [{ term, count: counts.get(term)! }] : []));
return { uiRequired: files.length > 0 || visual.length > 0, uiMatches: terms.reduce((s, t) => s + t.count, 0), uiEvidence: { terms, visual: [...new Set(visual)], files } };
}
export interface ScopeFlags { ui?: boolean; developerTool?: boolean; agentPrimary?: boolean; dx?: boolean }
export const SCOPE_FLAG_TOKENS = ['--ui', '--no-ui', '--no-dx', '--developer-tool', '--agent-primary'] as const;
const MARKER_RE = /^<!--\s*gstack-autoplan-scope:(.*?)-->\s*$/;
export function scopeTokens(f: ScopeFlags): string[] {
return [...(f.ui === true ? ['--ui'] : f.ui === false ? ['--no-ui'] : []), ...(f.dx === false ? ['--no-dx'] : []),
...(f.developerTool ? ['--developer-tool'] : []), ...(f.agentPrimary ? ['--agent-primary'] : [])];
}
export function parseScopeTokens(tokens: readonly string[], where: string): ScopeFlags {
const unknown = tokens.filter(t => !(SCOPE_FLAG_TOKENS as readonly string[]).includes(t));
if (unknown.length) throw new Error(`${where}: unknown scope flag ${unknown.join(', ')} (known: ${SCOPE_FLAG_TOKENS.join(' ')})`);
if (tokens.includes('--ui') && tokens.includes('--no-ui')) throw new Error(`${where}: --ui and --no-ui conflict`);
return {
...(tokens.includes('--ui') ? { ui: true } : tokens.includes('--no-ui') ? { ui: false } : {}),
...(tokens.includes('--no-dx') ? { dx: false } : {}),
...(tokens.includes('--developer-tool') ? { developerTool: true } : {}),
...(tokens.includes('--agent-primary') ? { agentPrimary: true } : {}),
};
}
export const scopeMarker = (tokens: readonly string[]) => `<!-- gstack-autoplan-scope: ${tokens.join(' ')} -->`;
/** The marker on the plan's first line (before any heading), or null. */
export function readScopeMarker(planText: string, file: string): { tokens: string[]; flags: ScopeFlags } | null {
const m = MARKER_RE.exec(planText.split('\n', 1)[0]!.replace(/^\uFEFF/, '').trimEnd());
if (!m) return null;
const tokens = m[1]!.trim().split(/\s+/).filter(Boolean);
return { tokens, flags: parseScopeTokens(tokens, `scope marker in ${file}`) };
}
+87 -5
View File
@@ -156,8 +156,8 @@ export function parseScopes(csv: string | undefined): { scopes: RealityScope[];
return { scopes, unknown };
}
/** `path:line`, `path:line-line` (the path has a letter and no spaces) or a 7–40 hex sha. */
export const RECEIPT_RE = /(?:(?<![\w./-])[A-Za-z~$][\w./~$@-]*:\d+(?:-\d+)?)|(?<![0-9a-f])[0-9a-f]{7,40}(?![0-9a-f])/;
/** `path:line` or `path:line-line` (the path has a letter, may start with a dot, no spaces), or a 7–40 hex sha with a hex letter, or digits-only after `commit ` or in backticks. */
export const RECEIPT_RE = /(?:(?<![\w./-])\.?[A-Za-z~$][\w./~$@-]*:\d+(?:-\d+)?)|(?<![0-9a-f])(?=[0-9]*[a-f])[0-9a-f]{7,40}(?![0-9a-f])|\bcommit [0-9]{7,40}\b|`[0-9]{7,40}`/;
export const REALITY_LINE_RE = /^\s*(?:[-*>|]\s*)?`?REALITY:\s+(?<id>[a-z][a-z0-9-]*)\s+(?<status>pass|finding|n\/a)\b(?<rest>.*)$/;
export interface RealityLine { id: string; status: RealityStatus; rest: string; receipt: string | null; line: number; metrics: Record<string, string> }
@@ -185,6 +185,12 @@ export interface RealityCheck {
missing: string[];
/** Required rows whose line carries no receipt. */
unreceipted: string[];
/** Clauses stating a measured number without a receipt of their own. */
unreceipted_numbers: string[];
/** Lines claiming a decision rule exists without quoting or linking its text. */
unquoted: string[];
/** One `NUMBER_UNRECEIPTED: <clause>` line per unreceipted number. */
numberLines: string[];
/** Lines naming a row id the table does not define. */
unknownRows: string[];
metrics: Record<string, string>;
@@ -192,6 +198,52 @@ export interface RealityCheck {
line: string;
}
/**
* A measured quantity: a number with a unit (s, ms, %, $, USD, tokens, ×, MB, GB) or a number shortly after
* measured/costs/takes/took. Receipts, versions and ids are removed before matching, so they never count.
*/
const MEASURED_RE = /\$\s?\d|\b\d[\d,]*(?:\.\d+)?\s?[kKM]?\s?(?:ms|s|%|USD|tokens?|MB|GB)(?![\w])|\b\d[\d,]*(?:\.\d+)?\s?[×x](?![\w])|\b(?:measured|costs?|takes|take|took)\b\W+(?:[A-Za-z]+\W+){0,3}?\$?\d/i;
const NOT_A_QUANTITY_RE = new RegExp(`${RECEIPT_RE.source}|\\bv?\\d+(?:\\.\\d+){2,}\\b|#\\d+|\\b[A-Za-z]+\\d+[a-z]?\\b`, 'g');
const EXEMPT_TAG_RE = /\[(?:estimated|re-measure before gate)\]/i;
const SCRIPT_RE = /(?:^|[\s(`"'])(?:\.?[\w-]+\/)*[\w.-]+\.(?:ts|js|mjs|cjs|sh|py|rb|go)\b|(?:^|[\s(`"'])(?:bin|scripts)\/[\w.-]+/;
const COMMAND_RE = /`[^`]*\s[^`]*`/;
const RULE_CLAIM_RE = /\b(?:decision rule|rules? (?:was |were )?written first|pre-?registered rule)\b/i;
const QUOTED_RULE_RE = /"[^"]{8,}"|\u201c[^\u201d]{8,}\u201d/;
const LINK_RE = /(?<![\w./-])\.?[A-Za-z~$][\w./~$@-]*:\d+/;
/** Split on `;`, `, ` and sentence ends, never inside brackets, parentheses or backticks. */
export function splitClauses(text: string): string[] {
const out: string[] = [];
let depth = 0, tick = false, start = 0;
for (let i = 0; i < text.length; i++) {
const c = text[i]!;
if (c === '`') tick = !tick;
else if (!tick && '([{'.includes(c)) depth++;
else if (!tick && ')]}'.includes(c)) depth = Math.max(0, depth - 1);
else if (!tick && depth === 0 && (c === ';' || ((c === ',' || c === '.') && /\s/.test(text[i + 1] ?? '')))) {
out.push(text.slice(start, c === '.' ? i + 1 : i)); start = i + 1;
}
}
out.push(text.slice(start));
return out.map(c => c.trim()).filter(Boolean);
}
/** Clauses of one line that state a measured number and carry no receipt of their own. */
export function unreceiptedNumbers(line: string): string[] {
return splitClauses(line).filter(c => MEASURED_RE.test(c.replace(NOT_A_QUANTITY_RE, ' '))
&& !RECEIPT_RE.test(c) && !SCRIPT_RE.test(c) && !COMMAND_RE.test(c) && !EXEMPT_TAG_RE.test(c));
}
/** A rule claim needs the rule: a quoted span, a file:line link on the line, or a fenced or quoted block right after it. */
function ruleUnquoted(lines: string[], i: number): boolean {
const line = lines[i]!;
if (!RULE_CLAIM_RE.test(line) || QUOTED_RULE_RE.test(line) || LINK_RE.test(line)) return false;
const after = lines.slice(i + 1).find(l => l.trim());
return !after || !/^\s*(?:```|~~~|>)/.test(after);
}
const verdictFor = (ok: boolean, sessionKind?: string): RealityVerdict => (ok ? 'complete' : sessionKind === 'unattended' ? 'incomplete' : 'repair');
export interface CheckOptions { phase: string; scopes?: readonly string[]; sessionKind?: string }
/** Deterministic: which required rows the review carries. The verdict word depends on the session kind only. */
@@ -214,14 +266,44 @@ export function checkRealityRows(reviewText: string, o: CheckOptions): RealityCh
const metric = rowById(id)!.metric;
if (metric && l.metrics[metric] !== undefined) metrics[metric] = l.metrics[metric]!;
}
const ok = missing.length === 0 && unreceipted.length === 0;
const verdict: RealityVerdict = ok ? 'complete' : o.sessionKind === 'unattended' ? 'incomplete' : 'repair';
const rows = lines.filter(l => rowById(l.id) && l.status !== 'n/a');
const numbers = rows.flatMap(l => unreceiptedNumbers(l.rest));
const rawLines = reviewText.split('\n');
const unquoted = rows.filter(l => ruleUnquoted(rawLines, l.line - 1)).map(l => rawLines[l.line - 1]!.trim());
const verdict = verdictFor(missing.length === 0 && unreceipted.length === 0 && numbers.length === 0 && unquoted.length === 0, o.sessionKind);
const parts = [`PLAN_REALITY: phase=${o.phase}`, `rows=${present.length}/${required.length}`,
`missing=${missing.length ? missing.join(',') : 'none'}`];
if (unreceipted.length) parts.push(`unreceipted=${unreceipted.join(',')}`);
if (numbers.length) parts.push(`unreceipted_numbers=${numbers.length}`);
if (unquoted.length) parts.push(`unquoted=${unquoted.length}`);
for (const [k, v] of Object.entries(metrics)) parts.push(`${k}=${v}`);
parts.push(`verdict=${verdict}`);
return { phase: o.phase, required, present, missing, unreceipted, unknownRows, metrics, verdict, line: parts.join(' ') };
return { phase: o.phase, required, present, missing, unreceipted, unreceipted_numbers: numbers, unquoted,
numberLines: numbers.map(c => `NUMBER_UNRECEIPTED: ${c}`), unknownRows, metrics, verdict, line: parts.join(' ') };
}
/** Bumped whenever checkPlanReality's rules change, so a cached verdict keyed on the plan hash is recomputed. */
export const PLAN_REALITY_CHECKER = 'plan-reality/2';
export interface PlanRealityCheck { checker: string; unreceipted_numbers: string[]; unquoted: string[]; numberLines: string[]; verdict: RealityVerdict; line: string }
/**
* The same number and rule checks over plan prose (the Implementation plan the caller passes), outside fenced blocks.
* Pure: reads nothing, writes nothing. `gstack-autoplan submit` records it per plan hash; it never refuses a submit.
*/
export function checkPlanReality(planText: string, o: { sessionKind?: string } = {}): PlanRealityCheck {
const lines = planText.split('\n');
const numbers: string[] = [], unquoted: string[] = [];
let fence: string | null = null;
lines.forEach((line, i) => {
const delimiter = /^\s{0,3}(`{3,}|~{3,})/.exec(line)?.[1];
if (delimiter && (!fence || delimiter.startsWith(fence))) { fence = fence ? null : delimiter; return; }
if (fence || /^\s*<!--/.test(line)) return;
numbers.push(...unreceiptedNumbers(line));
if (ruleUnquoted(lines, i)) unquoted.push(line.trim());
});
const verdict = verdictFor(numbers.length === 0 && unquoted.length === 0, o.sessionKind);
return { checker: PLAN_REALITY_CHECKER, unreceipted_numbers: numbers, unquoted, numberLines: numbers.map(c => `NUMBER_UNRECEIPTED: ${c}`), verdict,
line: `PLAN_REALITY: scope=plan unreceipted_numbers=${numbers.length} unquoted=${unquoted.length} verdict=${verdict}` };
}
/** The example line every template quotes; one grammar, stated once. */
+1 -1
View File
@@ -112,7 +112,7 @@ export const RESULT_CODES = {
GATE_REPLY_UNPARSED: {
anchor: 'gate-reply-unparsed',
summary: 'part of the reply matched no gate item or option, so nothing was applied',
fix: 'reply with `all`, `<id><option>` tokens (for example `d3b uc1a`), or `all except <tokens>`; bare yes/no is ambiguous',
fix: 'reply with `all`, `all as-is`, `<id><option>` tokens (for example `d3b uc1a`), or `all except <tokens>`; bare yes/no is ambiguous',
},
// PR A (multi-agent wave): gstack-doctor --check, gstack-capy-install, gstack-browser-ensure.
COMPONENT_MISSING: {
+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,
};
+110
View File
@@ -0,0 +1,110 @@
/**
* 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({ verifiedBun: '1.4.2' });
afterAll(() => host.cleanup());
const hostEnv = () => env({ ...host.env, PATH: host.launchPath, GSTACK_STATE_ROOT: host.stateRoot, GSTACK_HOME: host.stateRoot });
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, 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), 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) 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.copyFileSync(path.join(ROOT, 'bin', 'gstack-autoplan-snapshot.ts'), path.join(root, 'bin', 'gstack-autoplan-snapshot.ts'));
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);
});
+132
View File
@@ -0,0 +1,132 @@
/**
* Field wave item 11: /autoplan scope keys on what the Implementation plan
* changes. UI needs a rendered surface (a rendered file path, or a UI term
* next to a visual noun); bare UI words and Review-record prose never add the
* Design phase. Provenance is per axis (flag, marker, detected), `--no-dx`
* turns DX off, a first-line scope marker in the plan is read (never
* written), and the interactive path reads the same detector through
* `gstack-autoplan-snapshot.ts scope`. Runner-level cases run on the
* simulated Capy host (test/helpers/capy-host.ts).
*/
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 { detectScope, detectUiScope, phaseOrder, sectionScopeOff } from '../lib/autoplan-prompts';
import { capyHost } from './helpers/capy-host';
import { AUTOPLAN, ROOT, env, readRun } from './helpers/autoplan-run-fixture';
const host = capyHost({ verifiedBun: '1.4.2' });
afterAll(() => host.cleanup());
let n = 0;
const planFile = (body: string, record = '', firstLine = '') => {
const dir = path.join(host.base, `plan-${++n}`);
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, 'plan.md');
fs.writeFileSync(file, `${firstLine ? `${firstLine}\n` : ''}# Plan ${n}\n\n## Implementation plan\n\n${body}\n\n## Review record\n\n${record}\n`);
return file;
};
const hostEnv = () => env({ ...host.env, PATH: host.launchPath, GSTACK_SESSION_KIND: 'unattended', GSTACK_STATE_ROOT: host.stateRoot, GSTACK_HOME: host.stateRoot });
const next = (args: string[]) => spawnSync('bun', [AUTOPLAN, 'next', ...args], { cwd: ROOT, env: hostEnv(), encoding: 'utf8', timeout: 120_000 });
const snapshotScope = (file: string) => {
const r = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-autoplan-snapshot.ts'), 'scope', file], { cwd: ROOT, env: hostEnv(), encoding: 'utf8', timeout: 60_000 });
expect(r.status, r.stderr).toBe(0);
return JSON.parse(r.stdout);
};
const CLI_PROSE = 'The sync component reads frontmatter; a malformed form is held. The doctor screen output (terminal) lists each held file with a status line.';
const DX_PROSE = 'Add a CLI flag --since to the sync command and document the flag in the command help.';
describe('UI evidence is a rendered surface in the Implementation plan', () => {
test('non-visual CLI prose with UI words is weak evidence: no Design phase, sections still load', () => {
const r = detectUiScope(CLI_PROSE);
expect(r.uiRequired).toBe(false);
expect(r.uiMatches).toBe(3);
expect(r.uiEvidence).toEqual({ terms: [{ term: 'component', count: 1 }, { term: 'screen', count: 1 }, { term: 'form', count: 1 }], visual: [], files: [] });
const scope = detectScope(planFile(CLI_PROSE), ROOT, {});
expect(scope.ui).toBe(false);
expect(phaseOrder(scope)).not.toContain('design');
expect(sectionScopeOff('ui', scope)).toBe(false);
});
test('UI words only in the Review record give ui=false (Review record prose is not scanned)', () => {
const scope = detectScope(planFile('Retry the webhook worker with backoff in lib/worker.ts.', 'The Settings page layout gets a new button and a modal; the sidebar nav moves.'), ROOT, {});
expect(scope.ui).toBe(false);
expect(scope.ui_matches).toBe(0);
});
test('a rendered file path, quoted or backticked, gives ui=true and is printed as evidence', () => {
expect(detectUiScope('Edit src/components/Settings.tsx to add the toggle.').uiEvidence.files).toEqual(['src/components/Settings.tsx']);
expect(detectUiScope('Render "app/views/home/index.html.erb" with the new partial.').uiRequired).toBe(true);
expect(detectUiScope('Move the colors into `web/theme.scss`.').uiEvidence.files).toEqual(['web/theme.scss']);
expect(detectUiScope('Touch lib/pages.ts and docs/components.md only.').uiRequired).toBe(false);
expect(detectScope(planFile('Edit src/components/Settings.tsx to add a retry toggle.'), ROOT, {}).ui).toBe(true);
});
test('a UI term next to a visual noun is UI; the same term far from one is not', () => {
const near = detectUiScope('Add a retry button to the settings page.');
expect(near.uiRequired).toBe(true);
expect(near.uiEvidence.visual).toEqual(['button~page']);
expect(detectUiScope('The status layout of the JSON report is stable.').uiRequired).toBe(false);
expect(detectUiScope('Add a button. Write a long unrelated paragraph about the queue and its retry budget and the man page.').uiRequired).toBe(false);
});
});
describe('per-axis provenance, --no-dx and the plan marker', () => {
test('--no-ui alone labels a detected DX as detected, not flag-forced', () => {
const scope = detectScope(planFile(DX_PROSE), ROOT, { ui: false });
expect(scope.dx).toBe(true);
expect(scope.sources).toEqual({ ui: 'flag', dx: 'detected' });
});
test('a first-line marker with no CLI flags sets scope from the marker, and the source plan is untouched', () => {
const file = planFile('Edit src/components/Settings.tsx to add a retry toggle.', '', '<!-- gstack-autoplan-scope: --no-ui --developer-tool -->');
const before = fs.readFileSync(file);
const scope = detectScope(file, ROOT, {});
expect(scope).toMatchObject({ ui: false, dx: true, developer_tool: true, sources: { ui: 'marker', dx: 'marker' }, pin: null });
expect(fs.readFileSync(file).equals(before)).toBe(true);
expect(() => detectScope(planFile('x', '', '<!-- gstack-autoplan-scope: --bogus -->'), ROOT, {})).toThrow(/unknown scope flag --bogus/);
});
test('runner: CLI flags print SCOPE evidence and a SCOPE_PIN hint, never write the plan, and persist in run.json', () => {
const file = planFile(`${DX_PROSE} ${CLI_PROSE}`);
const before = fs.readFileSync(file);
const out = path.join(host.base, 'run-pin');
const r = next(['--out', out, '--plan', file, '--no-ui', '--developer-tool']);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toMatch(/^SCOPE: ui=no \(terms: component×1, screen×1, form×1; visual: none; files: none\) dx=yes \(detected: [^)]*flag×2[^)]*\) source=ui:flag,dx:flag$/m);
expect(r.stdout).toContain(`SCOPE_PIN: add <!-- gstack-autoplan-scope: --no-ui --developer-tool --> as the first line of ${file} to keep these flags`);
expect(fs.readFileSync(file).equals(before)).toBe(true);
expect(readRun(out).scope).toMatchObject({ cli_flags: ['--no-ui', '--developer-tool'], marker: null, sources: { ui: 'flag', dx: 'flag' } });
expect(r.stdout).toContain('source=ui:flag,dx:flag; checklist=');
});
test('runner: a marker that already says the CLI flags prints no SCOPE_PIN', () => {
const file = planFile(DX_PROSE, '', '<!-- gstack-autoplan-scope: --developer-tool --no-ui -->');
const r = next(['--out', path.join(host.base, 'run-pinned'), '--plan', file, '--no-ui', '--developer-tool']);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toMatch(/^SCOPE: .* source=ui:flag,dx:flag$/m);
expect(r.stdout).not.toContain('SCOPE_PIN:');
});
test('runner: --no-dx removes the DX phase from a plan whose terms detect it', () => {
const r = next(['--out', path.join(host.base, 'run-nodx'), '--plan', planFile(DX_PROSE), '--no-ui', '--no-dx']);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toMatch(/^PHASE: ceo snapshot=[0-9a-f]{64} order=ceo,eng$/m);
expect(r.stdout).toMatch(/^SCOPE: ui=no .* dx=no .* source=ui:flag,dx:flag$/m);
});
});
describe('interactive parity: one detector', () => {
test('snapshot scope returns uiRequired with its evidence', () => {
expect(snapshotScope(planFile('Edit src/components/Settings.tsx.'))).toMatchObject({ uiRequired: true, uiEvidence: { files: ['src/components/Settings.tsx'] } });
expect(snapshotScope(planFile(CLI_PROSE, 'The Settings page layout gets a new button.'))).toMatchObject({ uiRequired: false, uiMatches: 3 });
});
test('autoplan Step 2 takes UI scope from the returned uiRequired instead of a prose grep', () => {
const step2 = fs.readFileSync(path.join(ROOT, 'autoplan', 'SKILL.md.tmpl'), 'utf8').split('### Step 2: Read context')[1]!.split('### Step 3:')[0]!;
expect(step2).toContain('`uiRequired`');
expect(step2).not.toMatch(/grep the plan/i);
expect(fs.readFileSync(path.join(ROOT, 'autoplan', 'SKILL.md'), 'utf8')).toContain('`uiRequired`');
});
});
+4 -1
View File
@@ -107,7 +107,10 @@
{ "file": "lib/pregate/checks/secrets.ts", "movedFrom": "(new: pre-gate check, C8)" },
{ "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/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/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`,
+79 -1
View File
@@ -13,7 +13,8 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { REALITY_ROWS, REALITY_SCOPES, TIER1_ROW_IDS, checkRealityRows, parseRealityLines, parseScopes, renderIncidentTable, renderRows, requiredRows } from '../lib/plan-reality';
import { REALITY_ROWS, REALITY_SCOPES, TIER1_ROW_IDS, checkPlanReality, checkRealityRows, parseRealityLines, parseScopes, renderIncidentTable, renderRows, requiredRows } from '../lib/plan-reality';
import { extractHeading } from '../lib/autoplan-prompts';
import { RESULT_CODES } from '../lib/result-codes';
import { generatePlanRealityRows } from '../scripts/resolvers/plan-reality';
import type { TemplateContext } from '../scripts/resolvers/types';
@@ -175,6 +176,8 @@ describe('gstack-plan-reality bin', () => {
const parsed = JSON.parse(unreceipted.out);
expect(parsed.unreceipted).toEqual(['premise-table']);
expect(unreceipted.err).toContain('(REALITY_RECEIPT_MISSING)');
const unreceiptedText = run(['check', '--phase', 'ceo', '-'], `${partial}\n${FIXTURE.rows['premise-table']!.decoy}`);
expect(unreceiptedText.out).toMatch(/^PLAN_REALITY: phase=ceo .*\bunreceipted=premise-table\b.* verdict=repair$/m);
expect(run(['check', '--phase', 'ceo', path.join(tmp, 'absent.md')]).code).toBe(EXIT.fail);
fs.rmSync(tmp, { recursive: true, force: true });
});
@@ -233,3 +236,78 @@ describe('rendered templates carry the contract', () => {
}
});
});
describe('field wave item 12: a receipt per measured number, the rule text for a rule claim', () => {
const others = (id: string) => Object.entries(FIXTURE.rows).filter(([k]) => k !== id).map(([, r]) => r.positive).join('\n');
const FIVE = 'REALITY: numbers pass p50 120ms, p95 340ms, 13% faster, 28k tokens, cost $41 [measured: test/bench.ts:10]';
test('five measured numbers sharing one receipt leave four unreceipted, each printed with its clause', () => {
const r = checkRealityRows(`${others('numbers')}\n${FIVE}`, { phase: 'eng', sessionKind: 'unattended' });
expect(r.missing).toEqual([]);
expect(r.unreceipted).toEqual([]);
expect(r.unreceipted_numbers).toEqual(['p50 120ms', 'p95 340ms', '13% faster', '28k tokens']);
expect(r.numberLines).toEqual(['NUMBER_UNRECEIPTED: p50 120ms', 'NUMBER_UNRECEIPTED: p95 340ms', 'NUMBER_UNRECEIPTED: 13% faster', 'NUMBER_UNRECEIPTED: 28k tokens']);
expect(r.verdict).toBe('incomplete');
expect(r.line).toContain(' unreceipted_numbers=4 ');
expect(checkRealityRows(`${others('numbers')}\n${FIVE}`, { phase: 'eng' }).verdict).toBe('repair');
});
test('each clause with its own receipt (file:line, script path or a command in backticks) passes; unmeasured counts, versions and ids need none', () => {
const own = 'REALITY: numbers pass p95 340ms lib/x.ts:4, 13% faster `bun run bench --json`, 28k tokens scripts/count-tokens.ts, recall 0.82 vs 0.79 on gstack 1.91.95.0 per F2 and #3160 [estimated]';
const r = checkRealityRows(`${others('numbers')}\n${own}`, { phase: 'eng' });
expect(r.unreceipted_numbers).toEqual([]);
expect(r.verdict).toBe('complete');
});
test('a rule claim without the rule text is unquoted; the quoted rule or a file:line link to it passes', () => {
const bare = 'REALITY: binding-decisions pass the decision rule was written first 91bbd9e';
const r = checkRealityRows(`${others('binding-decisions')}\n${bare}`, { phase: 'eng', sessionKind: 'unattended' });
expect(r.unreceipted).toEqual([]);
expect(r.unquoted).toEqual([bare]);
expect(r.line).toContain(' unquoted=1 ');
expect(r.verdict).toBe('incomplete');
const quoted = 'REALITY: binding-decisions pass the decision rule was written first: "stop if recall drops below the base on any bucket" 91bbd9e';
expect(checkRealityRows(`${others('binding-decisions')}\n${quoted}`, { phase: 'eng' }).verdict).toBe('complete');
const linked = 'REALITY: binding-decisions pass the decision rule was written first, see docs/plan.md:4';
expect(checkRealityRows(`${others('binding-decisions')}\n${linked}`, { phase: 'eng' }).unquoted).toEqual([]);
});
test('receipts on dot paths count; a sha receipt needs a hex letter or a commit/backtick context', () => {
expect(parseRealityLines('REALITY: pr-count pass one PR .gstack/ship-policy.json:1')[0]!.receipt).toBe('.gstack/ship-policy.json:1');
expect(parseRealityLines('REALITY: pr-count pass ci lane .github/workflows/ci.yml:12-20')[0]!.receipt).toBe('.github/workflows/ci.yml:12-20');
expect(parseRealityLines('REALITY: numbers pass run took 20261012 cycles')[0]!.receipt).toBeNull();
expect(parseRealityLines('REALITY: already-done pass base clean at commit 1234567')[0]!.receipt).toBe('commit 1234567');
expect(parseRealityLines('REALITY: already-done pass base clean at `7654321` today')[0]!.receipt).toBe('`7654321`');
});
test('bin: NUMBER_UNRECEIPTED lines follow the PLAN_REALITY line and the verdict exits 1 with a fix', () => {
const r = run(['check', '--phase', 'eng', '-', '--session-kind', 'unattended'], `${others('numbers')}\n${FIVE}`);
expect(r.code).toBe(EXIT.fail);
expect(r.out.split('\n').filter(Boolean)).toEqual([
expect.stringMatching(/^PLAN_REALITY: phase=eng rows=7\/7 missing=none unreceipted_numbers=4 .*verdict=incomplete$/),
'NUMBER_UNRECEIPTED: p50 120ms', 'NUMBER_UNRECEIPTED: p95 340ms', 'NUMBER_UNRECEIPTED: 13% faster', 'NUMBER_UNRECEIPTED: 28k tokens',
]);
expect(r.err).toContain('(REALITY_RECEIPT_MISSING)');
expect(r.err).toContain('fix:');
});
test('checkPlanReality(planText): plan prose gets the same number and rule checks, pure and session-kind mapped', () => {
const plan = 'Cold install measured 32.5 s; the warm rerun takes 13 s (bin/gstack-capy-install:91).\n```\nlatency 900 ms in a fenced example\n```\nThe decision rule was written first.\nThe preregistered rule is below.\n> stop when recall drops below the base\n';
const r = checkPlanReality(plan, { sessionKind: 'unattended' });
expect(r.unreceipted_numbers).toEqual(['Cold install measured 32.5 s']);
expect(r.unquoted).toEqual(['The decision rule was written first.']);
expect(r.verdict).toBe('incomplete');
expect(r.line).toBe('PLAN_REALITY: scope=plan unreceipted_numbers=1 unquoted=1 verdict=incomplete');
expect(r.numberLines).toEqual(['NUMBER_UNRECEIPTED: Cold install measured 32.5 s']);
expect(checkPlanReality('Add a retry with a 30 s cap (lib/worker.ts:12).\n').line).toBe('PLAN_REALITY: scope=plan unreceipted_numbers=0 unquoted=0 verdict=complete');
expect(checkPlanReality(plan).verdict).toBe('repair');
});
test('false-positive bound: the field-wave plan itself raises only a small, pinned count', () => {
const text = fs.readFileSync(path.join(ROOT, 'docs', 'designs', 'GSTACK_FIELD_WAVE_2026_10_12.md'), 'utf8');
const r = checkPlanReality(extractHeading(text, 'Implementation plan')!);
expect(r.unquoted).toEqual([]);
expect(r.unreceipted_numbers.length).toBeGreaterThan(0);
expect(r.unreceipted_numbers.length).toBeLessThanOrEqual(6);
});
});
+7 -4
View File
@@ -89,20 +89,23 @@ describe('review checklists (plan B4)', () => {
});
test('the runner loads the checklist in light mode, keeps always sections, and falls back to the deep section when scope is uncertain', () => {
const detectedNoUi: Scope = { ui: false, dx: true, ui_matches: 0, dx_matches: 4, developer_tool: false, agent_primary: false, source: 'detected' };
const detectedNoUi: Scope = { ui: false, dx: true, ui_matches: 0, dx_matches: 4, developer_tool: false, agent_primary: false, sources: { ui: 'detected', dx: 'detected' } };
const light = sectionLoadFor('ceo', detectedNoUi, 'light');
expect(light.checklist).toContain('## Checklist (runner profile)');
expect(light.loaded).toEqual(['review-sections']);
expect(light.skipped).toEqual([]);
expect(light.line).toBe('Skipped sections: none (scope: ui=no,dx=yes,source=detected)');
expect(light.line).toBe('Skipped sections: none (scope: ui=no,dx=yes,source=ui:detected,dx:detected)');
const full = sectionLoadFor('ceo', detectedNoUi, 'full');
expect(full.checklist).toBeNull();
expect(full.loaded).toEqual(['review-sections']);
// a scoped section is skipped only when its scope is off by flags or detected with zero matches; weak detection keeps it
expect(sectionScopeOff('ui', detectedNoUi)).toBe(true);
expect(sectionScopeOff('ui', { ...detectedNoUi, ui_matches: 1 })).toBe(false);
expect(sectionScopeOff('ui', { ...detectedNoUi, source: 'flags' })).toBe(true);
expect(sectionScopeOff('ui', { ...detectedNoUi, source: 'flags', ui: true })).toBe(false);
expect(sectionScopeOff('ui', { ...detectedNoUi, sources: { ui: 'flag', dx: 'detected' } })).toBe(true);
expect(sectionScopeOff('ui', { ...detectedNoUi, sources: { ui: 'flag', dx: 'detected' }, ui: true })).toBe(false);
expect(sectionScopeOff('ui', { ...detectedNoUi, ui_matches: 1, sources: { ui: 'marker', dx: 'detected' } })).toBe(true);
expect(sectionScopeOff('dx', { ...detectedNoUi, dx: false, dx_matches: 2, sources: { ui: 'detected', dx: 'flag' } })).toBe(true);
expect(sectionScopeOff('ui', { ...detectedNoUi, sources: undefined as never, source: 'flags', ui_matches: 1 })).toBe(true);
expect(sectionScopeOff('dx', { ...detectedNoUi, dx: false, dx_matches: 0 })).toBe(true);
expect(sectionScopeOff('dx', { ...detectedNoUi, dx: false, dx_matches: 2 })).toBe(false);
for (const s of ['always', 'db', 'perf', 'security', 'incident'] as const) expect(sectionScopeOff(s, { ...detectedNoUi, ui: false, dx: false, dx_matches: 0 })).toBe(false);