feat(#1867): wire ui-consideration probe into ui-phase (WIRE-01)

Add the live ui-phase producer path for the UI-consideration probe (Phase 2,
WIRE-01). Two small exports on the Phase-1 adapter — proposeElements (the
propose-then-confirm view of detected kinds + applicable categories) and
autoResolve (the deterministic --auto floor that never dismisses and never
auto-backstops an unclassified item, #1110) — plus a post-verification
'## 9.5 UI-Consideration Probe' step in ui-phase.md mirroring spec-phase 5.5's
RUNTIME_DIR shim + fatal-invoke/malformed-report/zero-applicable fail-closed
guards, propose-then-confirm (the partial-cue recall mitigation), and the
'## UI Considerations' write-back in the shipped plan-phase.md:921 lift format.

autoResolve is the CODE floor; the covered-upgrade stays workflow prose (the
two-layer --auto). Un-upgraded backstops route to insufficient_spec ->
human_needed at verify, never a silent pass (#1154).

Tests: +8 typed (proposeElements shape/determinism, autoResolve never-dismiss,
partial-cue strict-subset) — structured-value only. ui-phase.md size baseline
ratcheted 15477->24447 (under DEFAULT cap). The plan-phase.md PRE_PHASE6 ceiling
stays RED pending #1852 (unchanged from Phase 1).

Claude-Session: https://claude.ai/code/session_01BKt4hgNZwXSeJYJtYAQUSS
This commit is contained in:
Dave
2026-07-02 23:47:40 -04:00
parent 6d7450c465
commit bea7196c4c
3 changed files with 272 additions and 1 deletions

View File

@@ -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": "<replace: element/surface description from the UI-SPEC prose>" }
]
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("<replace:")))process.exit(1)' "$ELEMENTS_JSON" 2>/dev/null; then
rm -f "$ELEMENTS_JSON"
echo "ERROR: ui-probe elements JSON is empty/invalid or still holds the <replace: …> 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:

View File

@@ -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<UIVerification>[] {
return items.map((item): Resolution<UIVerification> => {
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 <elements.json> [resolutions.json]`.
* The generic I/O plumbing (parse, fail-closed exit 2, pretty-JSON out) lives in probe-core's

View File

@@ -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');
});
});