feat(#1026): §5.6/ui-phase cutover — first gate dispatch (plan:pre step + blocking gate) (#1028)

Replace plan-phase.md §5.6 (a 6-branch inline UI gate) with a capability-driven
loop.render-hooks plan:pre dispatch — the FIRST gate dispatch in any workflow.
A step (ui-phase, when:workflow.ui_phase) + a new blocking gate
(when:workflow.ui_safety_gate). New ui.plan-gate check verb returns
{frontend, hasUiSpec, block}; the dispatch runs it unconditionally then fires
the active step (pipeline) or halts on the active blocking gate (manual). The
gate-handling (run check.query; halt if blocking+block) is the reusable
phase-6 template for blocking-gate cutovers.

Config semantics fixed per #1022 + maintainer call: ui_phase gates plan-time
UI-SPEC generation, ui_safety_gate gates the planning block. Common case + all
ui_phase=false cases are equivalence-preserving; the one intended change is
{ui_phase:true, ui_safety_gate:false} now auto-generating in pipelines.

Review found it broken twice (non-generic dispatch, phase-lookup divergence,
then the step-only check nested in a gate loop) — fixed; final Codex pass
verified all 8 (ui_phase,ui_safety_gate)x{pipeline,manual} cases correct.
gsd-ui-phase skill + autonomous §3a.5 untouched (§3a.5 deferred).
getRoadmapPhaseWithFallback mirrors cmdRoadmapGetPhase for lookup parity.

Closes #1026

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-11 00:58:21 -04:00
committed by GitHub
parent 5f48a42514
commit 9ed8c7d574
10 changed files with 972 additions and 125 deletions

View File

@@ -14,6 +14,10 @@ import core = require('./core.cjs');
const { output, error, ERROR_REASON } = core;
import { parseDecisions } from './decisions.cjs';
import type { Decision } from './decisions.cjs';
import { checkUiPresence } from './ui-safety-gate.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapModule = require('./roadmap.cjs');
const { getRoadmapPhaseWithFallback } = roadmapModule;
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -335,6 +339,129 @@ function cmdDecisionCoverageVerify(projectDir: string, args: string[], raw: bool
}, raw, undefined);
}
// ─── ui-plan-gate ─────────────────────────────────────────────────────────────
/**
* ui-plan-gate: given a phase number, checks whether the phase has frontend
* indicators and whether a *-UI-SPEC.md already exists in the phase directory.
*
* Returns JSON: { frontend: boolean, hasUiSpec: boolean, block: boolean }
* block = frontend && !hasUiSpec (gate fires when UI work is detected but no spec exists)
*
* Invocable as: gsd_run check ui-plan-gate <phase>
*
* Uses checkUiPresence from ui-safety-gate.cjs — does NOT reimplement frontend detection.
* Uses getRoadmapPhaseInternal + findPhaseInternal from core.cjs for phase data.
*/
function findUiSpecInDir(phaseDir: string): string {
if (!phaseDir || !fs.existsSync(phaseDir)) return '';
try {
const files = fs.readdirSync(phaseDir);
const found = files.find((f) => /-UI-SPEC\.md$/.test(f));
return found ? path.join(phaseDir, found) : '';
} catch {
return '';
}
}
/**
* Pure logic for ui-plan-gate — exposed for direct behavioral testing.
*
* Given a projectDir and phase number:
* (a) Reads the phase section from ROADMAP.md via getRoadmapPhaseWithFallback —
* same two-pass lookup (current milestone → full roadmap) as `roadmap.get-phase`
* (cmdRoadmapGetPhase). Cross-milestone / older frontend phases resolve correctly.
* If ROADMAP.md is missing, phaseSection is '' (ROADMAP.md not present = project
* has no roadmap = cannot be frontend). If the phase truly can't be found after
* both passes, phaseSection is '' and phaseLookupFailed is set so callers can
* surface the miss — we do NOT silently degrade to frontend:false if the roadmap
* exists but the phase header is absent.
* (b) Runs checkUiPresence (frontend detection) — no reimplementation.
* (c) Resolves the phase directory via core.findPhaseInternal; checks for *-UI-SPEC.md.
*
* Returns: { frontend, hasUiSpec, block, uiSpecPath, phaseLookupFailed }
* block = frontend && !hasUiSpec
* phaseLookupFailed = ROADMAP.md present but phase header not found (surfaced for
* onError:halt gates so a missing phase doesn't silently bypass)
*/
function computeUiPlanGate(projectDir: string, phase: string): {
frontend: boolean;
hasUiSpec: boolean;
block: boolean;
uiSpecPath: string | null;
phaseLookupFailed?: boolean;
} {
// (a) Read the phase section text using the same two-pass lookup as roadmap.get-phase.
// getRoadmapPhaseWithFallback: current-milestone first, then stripShippedMilestones
// fallback — mirrors cmdRoadmapGetPhase exactly.
let phaseSection = '';
let phaseLookupFailed: boolean | undefined;
try {
const section = getRoadmapPhaseWithFallback(projectDir, phase);
if (section === null) {
// Distinguish: ROADMAP.md missing (no-roadmap project) vs phase not found in ROADMAP.
// core.planningDir(cwd) resolves the .planning/ root for workstream-aware paths.
const planDir: string = typeof (core as unknown as Record<string, unknown>)['planningDir'] === 'function'
? (core as unknown as Record<string, (cwd: string) => string>)['planningDir'](projectDir)
: path.join(projectDir, '.planning');
const roadmapPath = path.join(planDir, 'ROADMAP.md');
if (fs.existsSync(roadmapPath)) {
// ROADMAP.md exists but phase was not found → surface the miss
phaseLookupFailed = true;
}
// phaseSection stays ''
} else {
phaseSection = section;
}
} catch { /* roadmap read failure → treat as empty (non-frontend) */ }
// (b) Run checkUiPresence (frontend detection) — reuse existing helper; no reimplementation
const presenceResult = checkUiPresence(phaseSection);
const frontend = presenceResult.hasUI;
// (c) Resolve phase directory via findPhaseInternal and check for *-UI-SPEC.md
const coreModule = core as unknown as Record<string, unknown>;
let phaseDir = '';
try {
const findPhase = coreModule['findPhaseInternal'] as ((cwd: string, phase: string) => Record<string, unknown> | string | null) | undefined;
if (typeof findPhase === 'function') {
const result = findPhase(projectDir, phase);
if (result && typeof result === 'object') {
// findPhaseInternal returns { directory: '<relative-posix-path>', ... }
// directory is relative to cwd — resolve it to absolute.
const relDir = typeof result['directory'] === 'string' ? result['directory'] : '';
if (relDir) {
phaseDir = path.resolve(projectDir, relDir);
}
} else if (typeof result === 'string') {
phaseDir = result;
}
}
} catch { /* phase dir lookup failure → hasUiSpec=false */ }
const uiSpecPath = findUiSpecInDir(phaseDir);
const hasUiSpec = uiSpecPath !== '';
// block = frontend phase with no UI-SPEC
const block = frontend && !hasUiSpec;
const result: { frontend: boolean; hasUiSpec: boolean; block: boolean; uiSpecPath: string | null; phaseLookupFailed?: boolean } = {
frontend, hasUiSpec, block, uiSpecPath: hasUiSpec ? uiSpecPath : null,
};
if (phaseLookupFailed) result.phaseLookupFailed = true;
return result;
}
function cmdUiPlanGate(projectDir: string, args: string[], raw: boolean): void {
// args[0] = 'check', args[1] = 'ui-plan-gate', args[2] = phase
const phase = args[2] || '';
if (!phase) {
error('ui-plan-gate requires a phase argument: check ui-plan-gate <phase>', ERROR_REASON.SDK_MISSING_ARG);
return;
}
output(computeUiPlanGate(projectDir, phase), raw, undefined);
}
interface RouteCheckCommandOptions {
args: string[];
cwd: string;
@@ -342,7 +469,14 @@ interface RouteCheckCommandOptions {
}
function routeCheckCommand({ args, cwd, raw }: RouteCheckCommandOptions): void {
const subcommand = args[1];
// Normalize dots to hyphens in the subcommand so both forms are accepted.
// This makes `check.query = "ui.plan-gate"` (dotted form in capability.json gates)
// directly runnable as `gsd_run check ui.plan-gate` — the dot is normalized to
// `ui-plan-gate` before routing. The generic gate-dispatch in §5.6 reads
// `check.query` from the active gate hook and runs `gsd_run check ${hook.check.query}`,
// so the declared query must be dispatchable exactly as declared.
const rawSubcommand = args[1];
const subcommand = typeof rawSubcommand === 'string' ? rawSubcommand.replace(/\./g, '-') : rawSubcommand;
if (subcommand === 'auto-mode') {
cmdAutoMode(cwd, raw);
return;
@@ -355,11 +489,16 @@ function routeCheckCommand({ args, cwd, raw }: RouteCheckCommandOptions): void {
cmdDecisionCoverageVerify(cwd, args, raw);
return;
}
error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify', ERROR_REASON.SDK_UNKNOWN_COMMAND);
if (subcommand === 'ui-plan-gate') {
cmdUiPlanGate(cwd, args, raw);
return;
}
error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify, ui-plan-gate', ERROR_REASON.SDK_UNKNOWN_COMMAND);
}
export = {
routeCheckCommand,
decisionMentioned,
extractPlanDesignatedSections,
computeUiPlanGate,
};

View File

@@ -175,6 +175,47 @@ function searchPhaseInContent(content: string, escapedPhase: string, phaseNum: s
};
}
// ─── getRoadmapPhaseWithFallback ──────────────────────────────────────────────
/**
* Two-pass phase lookup that mirrors cmdRoadmapGetPhase's resolution strategy.
*
* Pass 1: current-milestone slice (extractCurrentMilestone).
* Pass 2: full roadmap content (stripShippedMilestones) — covers cross-milestone
* and older frontend phases that are no longer in the current milestone slice.
*
* Returns the phase section string if found, null if ROADMAP.md is missing,
* or throws if ROADMAP.md read fails.
*
* Used by check-command-router (computeUiPlanGate) so ui-plan-gate uses the SAME
* phase resolution as `roadmap.get-phase` — not a milestone-only subset.
*/
function getRoadmapPhaseWithFallback(cwd: string, phaseNum: string): string | null {
const roadmapPath = planningPaths(cwd).roadmap;
if (!fs.existsSync(roadmapPath)) return null;
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
const milestoneContent = extractCurrentMilestone(rawContent, cwd);
const fullContent = stripShippedMilestones(rawContent);
const exactSource = phaseMarkdownRegexSourceExact(phaseNum);
if (exactSource) {
const exactMilestone = searchPhaseInContent(milestoneContent, exactSource, phaseNum);
if (exactMilestone && !exactMilestone.error) return exactMilestone.section ?? null;
const exactFull = searchPhaseInContent(fullContent, exactSource, phaseNum);
if (exactFull && !exactFull.error) return exactFull.section ?? null;
}
const escapedPhase = phaseMarkdownRegexSource(phaseNum);
const milestoneResult = searchPhaseInContent(milestoneContent, escapedPhase, phaseNum);
const result = (milestoneResult && !milestoneResult.error)
? milestoneResult
: searchPhaseInContent(fullContent, escapedPhase, phaseNum) || milestoneResult;
if (!result || result.error) return null;
return result.section ?? null;
}
// ─── cmdRoadmapGetPhase ───────────────────────────────────────────────────────
function cmdRoadmapGetPhase(cwd: string, phaseNum: string, raw: boolean): void {
@@ -710,6 +751,7 @@ function cmdRoadmapAnnotateDependencies(cwd: string, phaseNum: string | null | u
export = {
cmdRoadmapGetPhase,
getRoadmapPhaseWithFallback,
cmdRoadmapAnalyze,
cmdRoadmapUpdatePlanProgress,
cmdRoadmapAnnotateDependencies,