From 0e59e9f05b61463b76dbf3897b8ab4dc6346aa69 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 12 Jul 2026 15:10:54 -0400 Subject: [PATCH] fix(#2150): treat the UI hint yes/no line as authoritative in checkUiPresence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkUiPresence ran UI_TOKENS (which includes the bare token 'UI') over the raw phase-section text, so GSD's own '**UI hint**: no' metadata line matched the 'UI' token and reported hasUI=true — blocking non-frontend phases that explicitly declare themselves non-frontend via the documented convention (the plan-phase UI-SPEC gate fired under the default workflow.ui_safety_gate=true). - An explicit '**UI hint**: yes|no' line is now authoritative (mirrors how progress.md / new-project.md already parse it via 'UI hint.*yes'). hint:no -> hasUI=false; hint:yes -> hasUI=true. - Any '**UI hint**:' line is stripped before token-sniffing, so a hint without a recognised yes/no cannot false-positive on the bare 'UI' token. - With no hint line, behaviour is unchanged (token-sniffing on the rest). Closes #2150 --- src/ui-safety-gate.cts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/ui-safety-gate.cts b/src/ui-safety-gate.cts index d2815d113..1aaf89b35 100644 --- a/src/ui-safety-gate.cts +++ b/src/ui-safety-gate.cts @@ -77,8 +77,24 @@ export function checkUiPresence(text: string): UiPresenceResult { // Normalise CRLF so the pattern sees consistent line boundaries. const normalised = text.replace(/\r\n/g, '\n'); + // #2150: an explicit `**UI hint**: yes|no` metadata line is the author's + // authoritative declaration of whether the phase has a UI surface — progress.md + // and new-project.md already parse this line (`UI hint.*yes`). The bare token + // `UI` in the line itself must not count as a UI indicator, and the declaration + // overrides token-sniffing. + const hintMatch = normalised.match(/\*\*UI hint\*\*\s*:\s*(yes|no)/i); + const hint = hintMatch ? hintMatch[1].toLowerCase() : null; + + // Strip ANY `**UI hint**:` line before token-sniffing so a hint without a + // recognised yes/no (or one we did not short-circuit on) cannot false-positive + // on the bare `UI` token. + const sniffable = normalised + .split('\n') + .filter((line) => !/^\s*\*\*UI hint\*\*\s*:/i.test(line)) + .join('\n'); + const found = new Set(); - for (const line of normalised.split('\n')) { + for (const line of sniffable.split('\n')) { // Reset lastIndex before each line so the global pattern restarts from 0. UI_GATE_PATTERN_GLOBAL.lastIndex = 0; for (const m of line.matchAll(UI_GATE_PATTERN_GLOBAL)) { @@ -86,6 +102,13 @@ export function checkUiPresence(text: string): UiPresenceResult { } } + if (hint === 'no') { + return { hasUI: false, tokens: [] }; + } + if (hint === 'yes') { + return { hasUI: true, tokens: [...found] }; + } + return { hasUI: found.size > 0, tokens: [...found] }; }