diff --git a/gsd-core/workflows/ui-phase.md b/gsd-core/workflows/ui-phase.md index 4244aa4b1..a39847af8 100644 --- a/gsd-core/workflows/ui-phase.md +++ b/gsd-core/workflows/ui-phase.md @@ -223,7 +223,7 @@ Agent( ## 8. Handle Checker Return **If `## UI-SPEC VERIFIED`:** -Display dimension results. Proceed to step 10. +Display dimension results. Proceed to step 9.5. **If `## ISSUES FOUND`:** Display blocking issues. Proceed to step 9. @@ -264,6 +264,133 @@ Options: Use AskUserQuestion for the choice. +**On "Force approve":** proceed to step 9.5 (the UI-consideration probe still runs on the accepted UI-SPEC, so state coverage is recorded even when quality FLAGs were accepted), then step 10. **On "Edit manually" / "Abandon":** exit without running the probe. + +## 9.5. UI-Consideration Probe (post-verification) + +Run AFTER the checker approves the UI-SPEC (VERIFIED, or force-approved at step 9) — never inline +during authoring, so a revision-loop researcher rewrite (step 9) cannot clobber the section and the +`## UI Considerations` block is committed with the FINAL UI-SPEC. This is the visual analog of +spec-phase Step 5.5's edge probe, retargeted to the UI element/state axis. Reference: +@~/.claude/gsd-core/references/ui-consideration-probe.md. + +**Skip conditions:** if `--auto` and the UI-SPEC already carries a resolved `## UI Considerations` +section (re-run), the write-back is idempotent (it REPLACES that section, never appends). If the +runtime is non-Claude and the probe engine cannot be resolved, the shim FAILS LOUD (below) — it +never silently no-ops (a silent skip would drop the whole state-coverage axis). + +**Runtime coverage compute — resolve and invoke ui-consideration-probe.cjs:** + +```bash +# Resolve the compiled ui-consideration-probe.cjs against the GSD install dir via RUNTIME_DIR +# (#448) — NOT the consuming project's git root — falling back to git toplevel / $HOME/.claude. +# Mirrors spec-phase.md Step 5.5's edge-probe resolution idiom verbatim (same candidate paths). +_GSD_RT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +UI_PROBE_JS=$(for _c in \ + "$_GSD_RT/gsd-core/bin/lib/ui-consideration-probe.cjs" \ + "$_GSD_RT/bin/lib/ui-consideration-probe.cjs" \ + "$_GSD_RT/.claude/bin/lib/ui-consideration-probe.cjs" \ + "$HOME/.claude/gsd-core/bin/lib/ui-consideration-probe.cjs" \ + "$HOME/.claude/bin/lib/ui-consideration-probe.cjs"; do + [ -f "$_c" ] && { echo "$_c"; break; } +done) + +# Graceful degradation — never a silent skip. Build ONLY when $_GSD_RT is a verified GSD source +# checkout (has tsconfig.build.json + src/ui-consideration-probe.cts), pinned with --prefix so we +# never trigger the CONSUMING project's own build during a ui-phase. Real installs ship the +# compiled .cjs via prepublishOnly, so this path only matters in a GSD dev checkout. +if [ -z "$UI_PROBE_JS" ]; then + if [ -f "$_GSD_RT/tsconfig.build.json" ] && [ -f "$_GSD_RT/src/ui-consideration-probe.cts" ]; then + npm --prefix "$_GSD_RT" run build:lib 2>/dev/null || true + UI_PROBE_JS=$(for _c in \ + "$_GSD_RT/gsd-core/bin/lib/ui-consideration-probe.cjs" \ + "$_GSD_RT/bin/lib/ui-consideration-probe.cjs" \ + "$_GSD_RT/.claude/bin/lib/ui-consideration-probe.cjs" \ + "$HOME/.claude/gsd-core/bin/lib/ui-consideration-probe.cjs" \ + "$HOME/.claude/bin/lib/ui-consideration-probe.cjs"; do + [ -f "$_c" ] && { echo "$_c"; break; } + done) + fi + if [ -z "$UI_PROBE_JS" ]; then + echo "ERROR: ui-consideration-probe.cjs not found — reinstall GSD or run \`npm run build:lib\` in your GSD checkout." >&2 + exit 1 + fi +fi + +# Element extraction: read the researcher-authored UI-SPEC prose (the described surfaces — the +# Design System / Copywriting rows and any element the researcher named) and write ONE object per +# UI element/surface: {"id","text"} where text is the prose describing it. Populate the heredoc from +# the UI-SPEC; the placeholder guard below fails loud on a forgotten substitution (never a no-op). +ELEMENTS_JSON=$(mktemp "${TMPDIR:-/tmp}/ui-probe-elements-XXXXXX") && mv "$ELEMENTS_JSON" "${ELEMENTS_JSON}.json" && ELEMENTS_JSON="${ELEMENTS_JSON}.json" || exit 1 +cat > "$ELEMENTS_JSON" <<'JSON' +[ + { "id": "E1", "text": "" } +] +JSON +if ! node -e 'const a=require(process.argv[1]);if(!Array.isArray(a)||a.length===0)process.exit(1);if(a.some(e=>typeof e.text!=="string"||!e.text.trim()||e.text.includes("/dev/null; then + rm -f "$ELEMENTS_JSON" + echo "ERROR: ui-probe elements JSON is empty/invalid or still holds the placeholder — populate \$ELEMENTS_JSON from the UI-SPEC's described surfaces before this step runs." >&2 + exit 1 +fi +# Invoke the compiled engine and CAPTURE its report. FATAL-INVOKE GUARD: use `if ! COVERAGE=$(…)`, +# NEVER a bare `COVERAGE=$(node …)` — a bare capture swallows the engine's exit 2 (invalid shape / +# bad input) and falls through to prose re-derivation: fail-OPEN at the exact boundary the engine +# validation protects. +if ! COVERAGE=$(node "$UI_PROBE_JS" "$ELEMENTS_JSON"); then + rm -f "$ELEMENTS_JSON" + echo "ERROR: ui-consideration-probe engine failed (invalid shapes or bad input) — fix the element(s) and re-run; never proceed with empty coverage." >&2 + exit 1 +fi +rm -f "$ELEMENTS_JSON" +# Malformed-report guard: exit 0 but garbage. The report must parse as { items[], coverage{} }. +if ! printf '%s' "$COVERAGE" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let r;try{r=JSON.parse(s)}catch{process.exit(1)}if(!r||!Array.isArray(r.items)||typeof r.coverage!=="object"||r.coverage===null)process.exit(1)})'; then + echo "ERROR: ui-consideration-probe produced an unparseable or malformed coverage report — refusing to proceed with the resolution loop." >&2 + exit 1 +fi +# Zero-applicable guard: a report where NO category applied across ANY element is far more likely a +# classification miss (or malformed elements) than a genuinely state-free UI. Surface it loudly. +APPLICABLE=$(printf '%s' "$COVERAGE" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let n=0;try{n=JSON.parse(s).coverage.applicable}catch{n=0}process.stdout.write(String(n))})') +if [ "$APPLICABLE" = "0" ]; then + echo "WARNING: ui-consideration-probe proposed ZERO applicable categories across all elements — likely a classification miss or malformed elements, not a genuinely state-free UI. Do NOT silently write an empty UI Considerations section." >&2 +fi +``` + +If `$APPLICABLE` is `0`, do NOT proceed silently: ask via AskUserQuestion ("The UI probe found no +applicable state considerations — is this genuinely a state-free surface, or should we revisit the +element descriptions?"). Only write an empty section after explicit confirmation. + +**Propose-then-confirm (the partial-cue mitigation — load-bearing).** For each element, the engine +reports the DETECTED element kinds (`classifyElement` over the built `.cjs`). The prose classifier +is heuristic and LOSSY: a surface that is genuinely both a form and a list, but whose prose trips +only the form cue, under-covers — and because SOMETHING classified, no `unclassified` signal fires. +So SURFACE the detected kinds to the user (AskUserQuestion) and ask whether any real element kind +was missed. If the user ADDs a kind, re-run that element with an authored `elements` override +(the union of detected + added) so the missed categories are raised. A single tripped cue is a +SIGNAL, not proof the element is only that kind — the confirm step, not the heuristic, is what makes +coverage sound. + +**Resolution loop** (mirror spec-phase 5.5): resolve each applicable consideration via +AskUserQuestion — **Specify** (→ `covered`, write a concrete truth) / **Dismiss (reason required)** / +**Backstop** (a held-out/visual UI-state test) / **Defer** (→ `unresolved`). An `unclassified` row is +a manual-review nudge, not a hard block. Text mode (`workflow.text_mode` / `--text`) → numbered lists. + +**`--auto` mode (two layers).** The adapter's `autoResolve` is the CODE floor: every applicable +consideration auto-`backstop`s (carrying the taxonomy question as its resolution) and an +`unclassified` candidate stays `unresolved` — it NEVER auto-`dismiss`es and never auto-backstops an +unclassified item (#1110). On top of that floor the workflow MAY upgrade an item to `covered` when a +defensible acceptance criterion can be written (the same judgment spec-phase 5.5 applies in prose). +An auto `--auto` run therefore leaves un-upgraded backstops as `backstop`: at verify time each one +with no wired evidence routes to `insufficient_spec → human_needed` — never a silent pass (#1154). +That surfacing is the intended honest-verifier behavior, not over-flagging. + +**Write-back.** Populate a `## UI Considerations` section in the UI-SPEC from the resolved +considerations, in the format the shipped plan-phase lift reads (plan-phase.md:921): +`covered` → a truth string; `backstop` → a flat scalar `{ statement, verification: backstop }`; +`unresolved` → an explicit `⚠ unresolved — planner must treat as assumption` row. Empty-state and +error-state COPY stays in `## Copywriting Contract` — the considerations section covers shape-rooted +STATE coverage and REFERENCES those rows rather than restating the copy (de-dup). IDEMPOTENT: if a +`## UI Considerations` section already exists, REPLACE it — never append a duplicate. + ## 10. Present Final Status Display: diff --git a/src/ui-consideration-probe.cts b/src/ui-consideration-probe.cts index 3994006e5..fcb30e570 100644 --- a/src/ui-consideration-probe.cts +++ b/src/ui-consideration-probe.cts @@ -237,6 +237,69 @@ export function analyzeCoverage( return coreAnalyzeCoverage(items, resolutions, UI_VALIDATORS); } +/** + * A per-element propose-then-confirm view (WIRE-01, #1867): the detected element `kinds`, the + * `categories` they raise, the proposed `considerations`, and an `unclassified` flag. The ui-phase + * probe step surfaces `kinds` to the user so a human can ADD a kind the heuristic missed — the + * classifier is a SIGNAL, not ground truth (Goodhart). A single tripped cue on a multi-kind surface + * under-covers; the confirm step, not the heuristic, is what makes coverage sound. + */ +export interface ElementProposal { + id: string; + kinds: UIElementKind[]; + categories: string[]; + considerations: UIConsideration[]; + unclassified: boolean; +} + +/** + * Build the propose-then-confirm view for every element (WIRE-01). Deterministic: a pure function + * of the input array (no Date/random/iteration-order surprise), so re-running the probe on an + * unchanged UI-SPEC yields byte-identical rows (the idempotency substrate WIRE-02 relies on). An + * aggregating VIEW over the existing Phase-1 functions — it adds no new classification logic. + * + * `unclassified` is true ONLY when prose classified to zero cues (#1110); an explicit `elements: []` + * opt-out stays silent (`unclassified: false`, empty considerations), matching proposeConsiderations. + */ +export function proposeElements(elements: Element[]): ElementProposal[] { + return elements.map((el): ElementProposal => { + validateRequirement(el); + const considerations = proposeConsiderations(el); + const kinds: UIElementKind[] = Array.isArray(el.elements) + ? el.elements // already validated inside proposeConsiderations + : classifyElement(el.text as string); + const unclassified = !Array.isArray(el.elements) && kinds.length === 0; + const categories = unclassified ? [] : applicableCategories(kinds); + return { id: el.id, kinds, categories, considerations, unclassified }; + }); +} + +/** + * The deterministic `--auto` resolution FLOOR (WIRE-01, SC2). For each proposed consideration: + * - an `unclassified` item stays `unresolved` — NEVER auto-backstopped (a missing cue is not + * evidence a consideration applies, #1110); + * - every applicable item auto-resolves to a conservative `backstop` (carrying the taxonomy + * question as its `resolution` so probe-core's "backstop requires a resolution" check passes). + * - it NEVER emits `dismissed` under any branch — a wrong auto-dismissal is the exact silent + * failure this probe eliminates (the never-dismiss invariant, asserted on the typed return). + * + * This is the CODE floor only. It mirrors spec-phase.md Step 5.5's prose `--auto` rule + * (auto-`covered` where a defensible acceptance criterion can be written, else auto-`backstop`, + * never auto-`dismiss`) but deliberately keeps the covered-vs-backstop JUDGMENT in the ui-phase + * workflow (an LLM MAY upgrade an item to `explicit`/covered when it can write a real acceptance + * criterion). Encoding the never-dismiss FLOOR in code is what makes the invariant unit-testable; + * the covered-upgrade stays prose because "a defensible criterion exists" is not a code predicate. + * Keep the two in sync: if spec-phase's `--auto` policy changes, revisit this floor. + */ +export function autoResolve(items: UIConsideration[]): Resolution[] { + return items.map((item): Resolution => { + if (item.category === UNCLASSIFIED_CATEGORY) { + return { requirement_id: item.requirement_id, category: item.category, status: 'unresolved', verification: null, resolution: null, reason: null }; + } + return { requirement_id: item.requirement_id, category: item.category, status: 'resolved', verification: 'backstop', resolution: item.probe, reason: null }; + }); +} + /* * CLI entry (invokable surface): `ui-consideration-probe.cjs [resolutions.json]`. * The generic I/O plumbing (parse, fail-closed exit 2, pretty-JSON out) lives in probe-core's diff --git a/tests/ui-consideration-probe.test.cjs b/tests/ui-consideration-probe.test.cjs index f4316d2ec..b48d045cf 100644 --- a/tests/ui-consideration-probe.test.cjs +++ b/tests/ui-consideration-probe.test.cjs @@ -201,3 +201,84 @@ describe('ui-consideration-probe LIFT-01: verify-time disposition (never silent assert.equal(d.flagged, false); }); }); + +// ══ WIRE-01 (Phase 2, #1867) — the live ui-phase producer surface ════════════════════════════ +// Two new adapter functions the ui-phase Step 9.5 probe consumes: proposeElements (the +// propose-then-confirm view exposing detected kinds + applicable categories per element) and +// autoResolve (the deterministic `--auto` resolution FLOOR that never dismisses and never +// auto-backstops an unclassified item, #1110). Structured-value assertions only. +const LIST_ELEMENT = { id: 'C1', text: 'A table listing all rows of results' }; +const ZERO_CUE_ELEMENT = { id: 'Z', text: 'xyzzy plugh frobnicate' }; +// A surface that is genuinely BOTH a form and a list, but whose prose trips only the form cue — +// the partial-cue recall gap the confirm step exists to close. +const PARTIAL_CUE_ELEMENT = { id: 'P', text: 'A signup form with input fields and validation' }; + +describe('ui-consideration-probe: proposeElements (WIRE-01 confirm surface, SC1)', () => { + test('a classified element returns one ElementProposal with kinds, applicable categories, considerations, unclassified:false', () => { + const [p] = uc.proposeElements([LIST_ELEMENT]); + assert.equal(p.id, 'C1'); + assert.ok(p.kinds.includes('list-collection')); + assert.deepEqual([...p.categories].sort(), [...uc.applicableCategories(uc.classifyElement(LIST_ELEMENT.text))].sort()); + assert.deepEqual(p.considerations.map((c) => c.category).sort(), [...p.categories].sort()); + assert.equal(p.unclassified, false); + }); + test('a zero-cue element returns kinds:[], categories:[], unclassified:true, and exactly one unclassified consideration (#1110)', () => { + const [p] = uc.proposeElements([ZERO_CUE_ELEMENT]); + assert.deepEqual(p.kinds, []); + assert.deepEqual(p.categories, []); + assert.equal(p.unclassified, true); + assert.equal(p.considerations.length, 1); + assert.equal(p.considerations[0].category, uc.UNCLASSIFIED_CATEGORY); + }); + test('proposeElements is deterministic — two calls on the same element array deepEqual (idempotency substrate for WIRE-02)', () => { + assert.deepEqual(uc.proposeElements([LIST_ELEMENT, ZERO_CUE_ELEMENT]), uc.proposeElements([LIST_ELEMENT, ZERO_CUE_ELEMENT])); + }); + test('an authored elements[] override bypasses prose classification and drives the categories', () => { + const [p] = uc.proposeElements([{ id: 'A', text: 'no cues here at all', elements: ['static-content'] }]); + assert.deepEqual([...p.kinds].sort(), ['static-content']); + assert.deepEqual([...p.categories].sort(), ['long-text', 'overflow']); + assert.equal(p.unclassified, false); + }); +}); + +describe('ui-consideration-probe: autoResolve (WIRE-01 typed --auto never-dismiss, SC2, #1110)', () => { + test('every applicable consideration auto-resolves to a backstop with a non-empty resolution; NONE is dismissed', () => { + const items = uc.proposeConsiderations(LIST_ELEMENT); + const resolutions = uc.autoResolve(items); + assert.equal(resolutions.length, items.length); + for (const r of resolutions) { + assert.notEqual(r.status, 'dismissed'); + assert.equal(r.status, 'resolved'); + assert.equal(r.verification, 'backstop'); + assert.equal(typeof r.resolution, 'string'); + assert.ok(r.resolution.length > 0); + } + }); + test('an unclassified item stays unresolved — never auto-backstopped (a missing cue is not evidence, #1110)', () => { + const items = uc.proposeConsiderations(ZERO_CUE_ELEMENT); // one unclassified item + const [r] = uc.autoResolve(items); + assert.equal(r.status, 'unresolved'); + assert.equal(r.verification, null); + assert.equal(r.resolution, null); + assert.equal(r.reason, null); + }); + test('autoResolve output validates and merges through probe-core: zero dismissed, byVerification.backstop === applicable', () => { + const items = uc.proposeConsiderations(LIST_ELEMENT); + const report = uc.analyzeCoverage([LIST_ELEMENT], uc.autoResolve(items)); + assert.ok(report.items.every((it) => it.status !== 'dismissed')); + assert.equal(report.coverage.resolved, report.coverage.applicable); + assert.equal(report.coverage.byVerification.backstop, report.coverage.applicable); + }); +}); + +describe('ui-consideration-probe: partial-cue recall gap (confirm is load-bearing, not the heuristic — Goodhart)', () => { + test('prose that trips only the form cue under-covers: heuristic categories are a STRICT SUBSET of the confirmed form+list union', () => { + const [heuristic] = uc.proposeElements([PARTIAL_CUE_ELEMENT]); + const [confirmed] = uc.proposeElements([{ ...PARTIAL_CUE_ELEMENT, elements: ['form', 'list-collection'] }]); + assert.deepEqual(heuristic.kinds, ['form']); // prose only tripped 'form' + const hSet = new Set(heuristic.categories); + const cSet = new Set(confirmed.categories); + for (const cat of hSet) assert.ok(cSet.has(cat), `heuristic category ${cat} must be in the confirmed union`); + assert.ok(cSet.size > hSet.size, 'the confirmed union must strictly exceed the heuristic set — proving the confirm step recovers missed coverage'); + }); +});