mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-11 11:10:13 +01:00
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:
commit
decde8e6de
24 files changed
+888
-59
No files matched your search
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user