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:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user