# design-insufficiency — a stack-agnostic Claude Code skill: find where a feature is badly designed for the human using it (Nielsen smell scan + handicapped-persona walkthrough + archetype completeness…

> Source: <https://gist.github.com/paulmorrishill/4bc1a845c289f164f4397d5d616ef885>
> Published: 2026-08-17 08:10:38+00:00

|
// design-insufficiency pipeline: three tracks -> Skeptic -> Referee -> cross-link. |
|
// Track A (static smell / heuristic evaluation): one Scanner per Nielsen heuristic cluster (detectors.md). |
|
// Track B (role-play / cognitive walkthrough): one Walker per handicapped persona cluster (personas.md). |
|
// Track C (capability completeness): classify the archetype, diff expected capabilities vs code (archetypes.md). |
|
// All tracks feed the same Skeptic -> Referee verdict pipeline, then findings are cross-linked. |
|
// |
|
// Written for a multi-agent orchestration runtime that provides `agent(prompt, opts)`, `parallel(thunks)` |
|
// and an injected `args` global (e.g. Claude Code's Workflow tool). If your harness has no such runtime, |
|
// treat this file as the spec: dispatch one read-only subagent per HEURISTICS/PERSONAS entry with the |
|
// same prompts, then one Skeptic + one Referee per finding, and apply the same scope-fence and |
|
// cross-link logic by hand. |
|
// |
|
// TEMPLATE — before running: |
|
// 1. Fill SCOPE_FILES + ROUTE_CONTRACT_MAP from SKILL.md Step 1 (routes, components, request/error contracts). |
|
// 2. Set STACK_CONTEXT to your stack (framework, design-system component set + prefix, token source, |
|
// where the typed API contracts live). |
|
// 3. Trim HEURISTICS / PERSONAS to what applies (drop e.g. bulk personas if it's not a list screen). |
|
// 4. Paste the relevant detectors.md rows into DETECTOR_GUIDANCE and the persona rows into PERSONA_GUIDANCE. |
|
|
|
export const meta = { |
|
name: 'design-insufficiency', |
|
description: 'Static usability-smell scan + handicapped-persona walkthrough + archetype completeness diff -> Skeptic -> Referee -> cross-link, over one feature scope', |
|
phases: [ |
|
{ title: 'Scan', detail: 'static usability-smell scanner per Nielsen heuristic cluster' }, |
|
{ title: 'Walk', detail: 'cognitive walkthrough per handicapped persona cluster' }, |
|
{ title: 'Complete', detail: 'classify archetype + diff expected capabilities (research-backed) vs code' }, |
|
{ title: 'Skeptic', detail: 'try to disprove each smell/wall/gap (find the path/affordance/capability missed)' }, |
|
{ title: 'Referee', detail: 'binding REAL_GAP / HAS_PATH / MANUAL_REVIEW verdict' }, |
|
], |
|
} |
|
|
|
// ---- ARGS NORMALISATION (do not skip) ---- |
|
// If the caller passes `args` as a JSON *string* (easy mistake), `args?.files` reads undefined and EVERY |
|
// field silently falls back to its default — an EMPTY SCOPE_FILES, which makes every scanner roam the |
|
// whole repo and audit the WRONG feature (this actually happened: a 163-agent run scanned an unrelated |
|
// module while the target was an import wizard). Coerce string->object so args.files/args.map are read. |
|
const A = typeof args === 'string' ? JSON.parse(args) : (args || {}) |
|
|
|
// ---- FILL THESE IN (or supply via args) ---- |
|
const SCOPE_FILES = A.files || [ |
|
// 'src/app/orders/order-form/order-form.component.ts', |
|
// 'src/api/generated/CreateOrder.ts', |
|
] |
|
// FAIL LOUD, never scan nothing: an empty scope is always a caller error, not a valid "scan everything". |
|
// A silent empty-scope run wastes a full fan-out on the wrong code and looks like it worked. |
|
if (!Array.isArray(SCOPE_FILES) || SCOPE_FILES.length === 0) { |
|
throw new Error('design-insufficiency: SCOPE_FILES is empty. Pass `args` as a JSON OBJECT (not a stringified JSON) with a non-empty `files` array from SKILL Step 1. An empty scope makes every scanner roam the whole repo and audit the wrong feature.') |
|
} |
|
// The route/contract map from SKILL Step 1 — routes, nav depth, request-field TYPES, error outcomes. |
|
const ROUTE_CONTRACT_MAP = A.map || '<paste the route list + contract summary here>' |
|
if (ROUTE_CONTRACT_MAP.startsWith('<paste')) { |
|
throw new Error('design-insufficiency: ROUTE_CONTRACT_MAP is the unfilled placeholder. Supply args.map (the route + request/error contract summary from SKILL Step 1) — without it the tracks are ungrounded and hallucinate screens.') |
|
} |
|
// Describe YOUR stack: framework, design-system component set + prefix, token source, contract location. |
|
const STACK_CONTEXT = A.stack || |
|
'A component-based web UI calling a typed API. Design-system components live in <DS_COMPONENTS>; colour/spacing tokens in <DS_TOKENS>; typed request/error contracts in <CONTRACTS>.' |
|
|
|
// Track A — Nielsen heuristic clusters (detectors.md). Drop any that can't apply to the scope. |
|
const HEURISTICS = A.heuristics || [ |
|
'H2-real-world (raw ID/JSON/enum-as-text/jargon, hardcoded or unlocalised terminology)', |
|
'H3-user-control (view-no-edit, no-undo, destructive-no-confirm)', |
|
'H5-error-prevention (text-for-number/date, rich-text-as-textarea, no-bounds)', |
|
'H1+H9-status+errors (async-button-no-spinner, long-op-bare-spinner-not-progress, background-job-appears-idle, no-cancel/timeout, no loading/empty/success state, unhandled error branch)', |
|
'H4-consistency (raw primitive where a DS component exists, off-token colour, unstyled screen)', |
|
'H6+H10-recognition+wayfinding (placeholder-as-label, breadcrumb, orphan route)', |
|
'H8-layout (too-many-columns, missing/inconsistent padding, cramped)', |
|
'H8-scalability (per-item control overflow at high N, fixed-slot long text, unbounded list no scroll, seed-data-only layout)', |
|
] |
|
// Track B — handicapped persona clusters (personas.md). |
|
const PERSONAS = A.personas || [ |
|
'field-worker-mobile', 'new-hire-day1', 'back-office-bulk', |
|
'finance-auditor', 'the-corrector', 'the-returner', 'wrong-order-user', 'the-handoff', |
|
] |
|
// Track C — archetype(s) this feature is. Leave null to let the agent classify from the code. |
|
const ARCHETYPES = A.archetypes || null // e.g. ['outbound-email', 'rich-text-editor', 'audience-selector'] |
|
const ARCHETYPE_GUIDANCE = A.archetypeGuidance || '' // pasted archetypes.md checklist rows for the classified kinds |
|
const ALLOW_RESEARCH = A.allowResearch !== false // Track C may search the web for archetype checklists |
|
const DETECTOR_GUIDANCE = A.detectorGuidance || {} // heuristic cluster -> pasted detectors.md rows |
|
const PERSONA_GUIDANCE = A.personaGuidance || {} // persona -> pasted personas.md row + intents |
|
// ----------------------- |
|
|
|
// Set of scope files, for the hard scope-fence: findings anchored outside these are discarded. |
|
const SCOPE_SET = new Set(SCOPE_FILES) |
|
// The one instruction every track shares — subagents have Grep/Glob/Read and WILL roam the whole repo |
|
// unless told not to. This is why earlier runs audited an unrelated module. |
|
const SCOPE_FENCE = ` |
|
HARD SCOPE FENCE (non-negotiable): The ONLY files you may open, scan, grep, or report on are the scope |
|
files listed above. Do NOT Glob/Grep the wider repo, do NOT wander into neighbouring features, do NOT |
|
report a finding whose file is not one of the scope files. You may read a file named in the route/contract |
|
map for context, but every FINDING you output must anchor to a scope file. If you find nothing in scope, |
|
return an empty findings array — an empty result is correct, roaming out of scope is not.` |
|
|
|
const FINDINGS_SCHEMA = { |
|
type: 'object', required: ['findings'], |
|
properties: { |
|
findings: { |
|
type: 'array', |
|
items: { |
|
type: 'object', |
|
required: ['track', 'file', 'line', 'heuristic', 'problem', 'expected', 'offered', 'severity'], |
|
properties: { |
|
track: { enum: ['smell', 'role-play', 'completeness'] }, |
|
persona: { type: 'string', description: 'role-play only: who hit the wall' }, |
|
intent: { type: 'string', description: 'role-play only: what they wanted to do' }, |
|
file: { type: 'string' }, |
|
line: { type: 'integer' }, |
|
heuristic: { type: 'string', description: 'Nielsen H# / wall_type / archetype' }, |
|
problem: { type: 'string', description: 'the smell or the wall, concretely' }, |
|
expected: { type: 'string', description: 'what a reasonable person expects' }, |
|
offered: { type: 'string', description: 'what the design actually offers (cite the real control/route)' }, |
|
suggested_fix: { type: 'string' }, |
|
confidence: { enum: ['A-deterministic', 'B-heuristic'] }, |
|
severity: { enum: ['critical', 'high', 'medium', 'low'] }, |
|
}, |
|
}, |
|
}, |
|
}, |
|
} |
|
const SKEPTIC_SCHEMA = { |
|
type: 'object', required: ['disproven', 'counter_evidence'], |
|
properties: { disproven: { type: 'boolean' }, counter_evidence: { type: 'string' } }, |
|
} |
|
const VERDICT_SCHEMA = { |
|
type: 'object', required: ['verdict', 'justification'], |
|
properties: { verdict: { enum: ['REAL_GAP', 'HAS_PATH', 'MANUAL_REVIEW'] }, justification: { type: 'string' } }, |
|
} |
|
|
|
const fileList = SCOPE_FILES.map(f => `- ${f}`).join('\n') |
|
|
|
function scannerPrompt(cluster) { |
|
const extra = DETECTOR_GUIDANCE[cluster] ? `\nDetectors to run (from detectors.md):\n${DETECTOR_GUIDANCE[cluster]}\n` : '' |
|
return `You are a static USABILITY-SMELL SCANNER. Scan ONLY for this heuristic cluster: ${cluster}. |
|
Scope files: |
|
${fileList} |
|
Route/contract map (join controls against these TYPES — a control's data type decides the right control): |
|
${ROUTE_CONTRACT_MAP} |
|
Stack: ${STACK_CONTEXT}.${extra}${SCOPE_FENCE} |
|
For EACH smell output: track="smell", file, line, heuristic (H#), problem, expected, offered (cite the |
|
real control/route), suggested_fix (the design-system component / picker / dropdown / edit route it |
|
should be), confidence (A-deterministic for type<->control mismatches & unhandled error branches & |
|
orphan update-operations; else B-heuristic), severity. |
|
RULES: |
|
- Cite a REAL file:line IN A SCOPE FILE. No anchor, or an anchor outside scope => discard. |
|
- Contract-join first: compare each control against the request field TYPE and the operation's error outcomes. |
|
- Only this cluster. Return the findings array (empty if none).` |
|
} |
|
|
|
function walkerPrompt(persona) { |
|
const extra = PERSONA_GUIDANCE[persona] ? `\nPersona + intents (from personas.md):\n${PERSONA_GUIDANCE[persona]}\n` : '' |
|
return `You are ROLE-PLAYING the "${persona}" user. You are NOT trying to complete the task cleverly — |
|
you are trying to find WHERE YOU WOULD GIVE UP, given your handicap. |
|
Scope files: |
|
${fileList} |
|
REAL route/contract map (do NOT invent screens; only walk what's here): |
|
${ROUTE_CONTRACT_MAP} |
|
Stack: ${STACK_CONTEXT}.${extra}${SCOPE_FENCE} |
|
Walk your intents step by step against the real routes/controls. Mark each step |
|
available|hidden|missing|requires-workaround. Stop at the first WALL. |
|
For EACH wall output: track="role-play", persona, intent, file, line (of the missing/broken affordance), |
|
heuristic (wall_type: missing-affordance|dead-end|hidden-path|forced-workaround|unhandled-reverse|context-assumed), |
|
problem, expected, offered, suggested_fix, confidence="B-heuristic", severity. |
|
RULES: |
|
- Real file:line IN A SCOPE FILE only. "Feels clunky" with no anchor, or an anchor outside scope => discard. |
|
- If a path MIGHT exist off-map, say so in offered ("verify: possible path via X") and let the Skeptic check — do not assume missing. |
|
- Report where you'd GIVE UP, not a completion. Return the findings array (empty if none).` |
|
} |
|
|
|
function completenessPrompt() { |
|
const kinds = ARCHETYPES ? `This feature's archetype(s): ${ARCHETYPES.join(', ')}.` : |
|
`First CLASSIFY this feature into its archetype(s) from the operations / entity / request shape / routes.` |
|
const research = ALLOW_RESEARCH |
|
? `You MAY search the web for "<archetype> feature checklist / best practices" and the 2-3 leading products' feature lists — especially for unusual/domain-specific archetypes. Tag each researched capability with its source URL in suggested_fix.` |
|
: `Use only the baked archetype library guidance below (no web search).` |
|
return `You are a CAPABILITY-COMPLETENESS analyst. Find whole capabilities a feature of THIS KIND |
|
normally has but this one is MISSING (e.g. an outbound-email screen with no Subject; an import with no |
|
error report). ${kinds} |
|
Scope files: |
|
${fileList} |
|
Route/contract map (operations, request fields, routes): |
|
${ROUTE_CONTRACT_MAP} |
|
Stack: ${STACK_CONTEXT}. |
|
Archetype checklist guidance (baked library): |
|
${ARCHETYPE_GUIDANCE || '(none supplied — derive from the archetype + research)'} |
|
${research}${SCOPE_FENCE} |
|
(This track may consult the WEB and read a map-named contract for context, but every reported gap must |
|
still anchor to a SCOPE FILE — where the missing capability should live.) |
|
Method: classify -> build expected-capability checklist (banded table-stakes/expected/maturity) -> |
|
DIFF each expected capability against the code (present as a request field / operation / route / control, |
|
or absent?) -> report each ABSENT, plausibly-expected capability. |
|
For EACH gap output: track="completeness", file (where it SHOULD live — the request type/operation/route/component, |
|
even though absent), line (best anchor, or the screen/contract file), heuristic=the archetype, |
|
problem (the missing capability), expected (why users of this archetype assume it), offered (what exists |
|
instead / nothing), suggested_fix (+ source URL if researched), confidence="B-heuristic", |
|
severity (table-stakes=critical/high, expected=medium, maturity=low). |
|
RULES: |
|
- Only report capabilities genuinely EXPECTED for this archetype in THIS context. Do NOT inflate maturity |
|
features to critical. An internal password-reset mail does not need A/B testing. |
|
- Ground every gap: name where it should live. If it might already exist off-scope, say "verify: possibly via X". |
|
Return the findings array (empty if the feature is complete).` |
|
} |
|
|
|
const skepticPrompt = (f) => `You are a SKEPTIC. Try to DISPROVE this design-insufficiency claim. |
|
Read defensively: find the picker sibling, the link from another screen, the edit route reachable |
|
elsewhere, the global error interceptor or error boundary, the parent padding class, the confirm dialog |
|
that already exists. |
|
Claim: ${JSON.stringify(f)} |
|
Scope: ${fileList} |
|
Map: ${ROUTE_CONTRACT_MAP} |
|
Set disproven=true only if you genuinely find the path/affordance; cite it (file:line + why) in counter_evidence.` |
|
|
|
const refereePrompt = (f, s) => `You are the REFEREE. Independent binding verdict. Re-check the cited code + map yourself. |
|
Finding: ${JSON.stringify(f)} |
|
Skeptic: ${JSON.stringify(s)} |
|
Scope: ${fileList} |
|
Verdict REAL_GAP | HAS_PATH | MANUAL_REVIEW + one-line justification. |
|
MANUAL_REVIEW only when it needs a human eye on rendered pixels to judge.` |
|
|
|
const verdictOf = (findingsPromise) => |
|
findingsPromise.then(res => parallel((res?.findings || []).map(f => () => |
|
agent(skepticPrompt(f), { label: `skeptic:${f.file}:${f.line}`, phase: 'Skeptic', schema: SKEPTIC_SCHEMA }) |
|
.then(sk => agent(refereePrompt(f, sk), { label: `referee:${f.file}:${f.line}`, phase: 'Referee', schema: VERDICT_SCHEMA }) |
|
.then(v => ({ ...f, skeptic: sk, verdict: v.verdict, justification: v.justification })))))) |
|
|
|
// All three tracks run in parallel, and each finding flows to Skeptic+Referee as soon as it lands. |
|
const smellRuns = HEURISTICS.map(h => () => |
|
verdictOf(agent(scannerPrompt(h), { label: `scan:${h}`, phase: 'Scan', schema: FINDINGS_SCHEMA }))) |
|
const walkRuns = PERSONAS.map(p => () => |
|
verdictOf(agent(walkerPrompt(p), { label: `walk:${p}`, phase: 'Walk', schema: FINDINGS_SCHEMA }))) |
|
const completenessRun = () => |
|
verdictOf(agent(completenessPrompt(), { label: 'complete:archetype', phase: 'Complete', schema: FINDINGS_SCHEMA })) |
|
|
|
const results = await parallel([...smellRuns, ...walkRuns, completenessRun]) |
|
const rawAll = results.flat().filter(Boolean) |
|
// Code-level scope-fence backstop: drop any finding whose anchor is NOT a scope file, so a subagent that |
|
// ignored the prompt fence and roamed cannot leak off-scope findings into the report. Surfaced as a count |
|
// (dropped_out_of_scope) rather than silently — a non-zero value means a scanner wandered. |
|
const all = rawAll.filter(f => SCOPE_SET.has(f.file)) |
|
const droppedOutOfScope = rawAll.length - all.length |
|
const surviving = all.filter(f => f.verdict === 'REAL_GAP' || f.verdict === 'MANUAL_REVIEW') |
|
|
|
// Cross-link: a smell and a wall at the same file (± a few lines) = two methods agreeing -> promote. |
|
const promote = { critical: 'critical', high: 'critical', medium: 'high', low: 'medium' } |
|
const crossLinked = [] |
|
for (const f of surviving) { |
|
const match = surviving.find(g => g !== f && g.track !== f.track && g.file === f.file && Math.abs((g.line || 0) - (f.line || 0)) <= 8) |
|
crossLinked.push(match ? { ...f, track: 'both', cross_linked_with: `${match.track}:${match.file}:${match.line}`, severity: promote[f.severity] || f.severity } : f) |
|
} |
|
|
|
const sevRank = { critical: 0, high: 1, medium: 2, low: 3 } |
|
const tierRank = { 'A-deterministic': 0, 'B-heuristic': 1 } |
|
const rank = (a, b) => (tierRank[a.confidence] ?? 9) - (tierRank[b.confidence] ?? 9) || (sevRank[a.severity] ?? 9) - (sevRank[b.severity] ?? 9) |
|
|
|
return { |
|
heuristics_scanned: HEURISTICS, |
|
personas_walked: PERSONAS, |
|
archetypes: ARCHETYPES || 'auto-classified by agent', |
|
scope_file_count: SCOPE_FILES.length, |
|
scope_files: SCOPE_FILES, |
|
dropped_out_of_scope: droppedOutOfScope, // >0 means a subagent roamed past the scope fence — investigate |
|
real_gaps: crossLinked.filter(f => f.verdict === 'REAL_GAP').sort(rank), |
|
manual_review: crossLinked.filter(f => f.verdict === 'MANUAL_REVIEW').sort(rank), |
|
false_positives_killed: all.filter(f => f.verdict === 'HAS_PATH').length, |
|
} |
