fix(autoplan): scope keys on rendered surfaces, per-axis provenance, --no-dx and a read-only plan marker

UI scope reads the Implementation plan only and needs a rendered file path or a UI
term next to a visual noun; bare UI words stay weak evidence (sections load, no
Design phase). Scope records sources {ui,dx} (flag|marker|detected), --no-dx turns
DX off, a first-line <!-- gstack-autoplan-scope: ... --> marker is read and never
written, and the first next prints SCOPE: evidence plus a SCOPE_PIN hint. The
snapshot tool's scope output carries uiRequired from the same detector and
autoplan Step 2 uses it instead of a prose grep (field wave item 11).

Intentional pin change: test/review-checklists.test.ts Scope literal and the
source= line shape (source=ui:detected,dx:detected).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-13 00:44:48 +00:00
1 parent b4481089ef
commit b743b7280c
10 files changed
+288 -38

No files matched your search

+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
+2 -2
View File
@@ -33,7 +33,7 @@ import { COORDINATOR_CONTRACT } from '../lib/coordinator-contract';
const HELP = `gstack-autoplan — unattended /autoplan: next/submit loop, answer, resume (docs/unattended.md)
next --out <dir> [--plan <file>] [--plan-heading <text>] [--ui|--no-ui] [--developer-tool] [--agent-primary]
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]
--light loads each phase's checklist plus the sections its scope needs (the unattended default); interactive kinds load everything
prepare the current phase; one ATTEMPT: line per voice; ends awaiting_result
@@ -81,7 +81,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'), scope: { ui, developerTool: rest.includes('--developer-tool'), agentPrimary: rest.includes('--agent-primary') } });
deadlineMin: num(rest, '--deadline'), light: rest.includes('--light'), scope: { ui, developerTool: rest.includes('--developer-tool'), agentPrimary: rest.includes('--agent-primary'), dx: rest.includes('--no-dx') ? false : undefined } });
}
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
+58 -18
View File
@@ -11,6 +11,7 @@
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';
export const PHASES = ['ceo', 'design', 'dx', 'eng'] as const;
@@ -19,15 +20,20 @@ export const REVIEW_SKILL: Record<Phase, string> = { ceo: 'plan-ceo-review', des
const ROOT = path.resolve(import.meta.dir, '..');
export const SNAPSHOT_TOOL = path.join(ROOT, 'bin', 'gstack-autoplan-snapshot.ts');
/** 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 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 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 function snapshotTool(args: string[], cwd: string): any {
const r = spawnSync('bun', [SNAPSHOT_TOOL, ...args], { cwd, encoding: 'utf8', timeout: 120_000, env: process.env });
@@ -35,15 +41,48 @@ export function snapshotTool(args: string[], cwd: string): any {
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 +130,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) {
+5 -3
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, sectionLoadFor, snapshotTool, writePrompts, type Phase, type Scope, type ScopeFlags } from './autoplan-prompts';
import { PHASES, SNAPSHOT_TOOL, detectScope, 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';
@@ -73,7 +73,7 @@ export function initRun(o: InitOptions): 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.
@@ -146,12 +146,14 @@ export function nextStep(o: NextOptions): NextResult {
if (state.deadline_at && Date.parse(state.deadline_at) < Date.now()) { state.status = 'interrupted'; writeState(state); lines.push(`DEADLINE: passed at ${state.deadline_at}`); return result; }
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(',')}`);
if (first) lines.push(...scopeLines(state.scope));
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);
+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}`) };
}
+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();
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, 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`');
});
});
+2 -1
View File
@@ -103,7 +103,8 @@
{ "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)" }
],
"residualFiles": [
{ "file": "scripts/test-free-shards.ts", "maxLines": 2361, "reason": "free lane policy after the shard engine took spawn, sandbox, logs, seeds and flags" },
+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);