A solution architect's UX layer for sa-skills: mods that draw the architecture's state inside Claude Code — next steps computed from the project's files…

Architecture documentation that stays true to itself — written with Claude Code, checked by code, delivered to the people who build it.
A suite of Claude Code plugins for solutions architects. It takes an initiative from a Product Owner's requirements to a versioned ARCHITECTURE.md, decision records, risk analyses, a development handoff kit, Word briefings, an interactive 3D model — and an MCP server your developers' IDEs can ask.
The model writes; code holds it to the rules. Every gate — prose, principles, diagrams, integrations, contracts — is a script with tests, so a document either passes or names the line to fix.
<img src="docs/images/command-center/logical.webp" alt="The command center: the synthetic Order Desk architecture in 3D, the Place an order use case lit phase by phase" width="100%">
<table> <tr> <td width="50%" valign="top"><img src="docs/images/command-center/physical.webp" alt="Physical view: regions, the cluster, the managed database and the partner service"><br><b>Physical view</b> — where each container runs.</td> <td width="50%" valign="top"><img src="docs/images/command-center/analysis.webp" alt="Analysis mode: each component in the colour of its worst finding"><br><b>Analysis</b> — SPOFs, blast radius and coupling on the blocks they concern.</td> </tr> <tr> <td width="50%" valign="top"><img src="docs/images/command-center/simulation.webp" alt="Simulation: documented demand against documented capacity, Order DB breaks first at x1.25"><br><b>Simulation</b> — documented load against documented capacity: what breaks first.</td> <td width="50%" valign="top"><img src="docs/images/command-center/mcp-console.webp" alt="Claude Code asking Alia over MCP about the Order Service"><br><b>Alia over MCP</b> — Claude Code asking the architecture from a console.</td> </tr> </table>
All screenshots come from the synthetic Order Desk example — see docs/ALIA.md.
Architecture documents rot. They are written once, drift from the system, contradict themselves across sections, and reach developers as a PDF nobody can query. This project treats the architecture as source:
flowchart LR
PO["Product Owner<br/>PO Spec"] --> CAP["Capability alignment<br/>BIAN · cross-industry · yours"]
CAP --> ARCH["ARCHITECTURE.md + docs/<br/>ADRs · TDRs"]
ARCH --> ANA["Risk analyses<br/>SPOF · STRIDE · latency"]
ARCH --> KIT["Handoff kit<br/>contracts · security"]
ARCH --> CC["Command center<br/>Alia in 3D"]
KIT --> IDX["MCP index<br/>Alia server"]
IDX --> IDE["Developers' IDEs"]
IDX --> AGT["Build agents<br/>unattended"]
IDX --> PIPE["Pipelines<br/>contracts byte for byte"]
IDE -. "Tier 0 · escalations" .-> ARCH
AGT -. "requests a person approves" .-> ARCH
| Stage | Skill | Produces |
|---|---|---|
| Requirements | architecture-readiness | PRODUCT_OWNER_SPEC.md, scored for checkability and coherence, plus a PDF for review |
| Capability alignment | architecture-capability-alignment | The PO Spec mapped onto a capability framework — BIAN for banking, a cross-industry map, or your own |
| Architecture | architecture-docs | ARCHITECTURE.md + docs/ (13 sections), C4 and data-flow diagrams, in English or Spanish |
| Decisions & debt | architecture-definition-record, technical-debt-records | ADRs and TDRs, including an imported institutional ADR catalog |
| Release | architecture-lifecycle | Status, release gates, changelog, architecture-v<version> tags, delivery increments |
| Risk & design | architecture-analysis, architecture-traceability | Ten analyses (SPOF, blast radius, bottlenecks, cost, STRIDE, vendor lock-in, latency, tech debt, coupling, data sensitivity) and PO Spec coverage |
| Delivery | architecture-dev-handoff, architecture-docs-export, architecture-icepanel-sync, architecture-index-export, architecture-ci-setup | The four-part handoff kit with validated OpenAPI / AsyncAPI / GraphQL / Avro / DDL / Protobuf assets, Word briefings, an IcePanel C4 import, the MCP bundle, the CI/CD of the architecture and of its service repositories |
| Visualization | architecture-command-center | A navigable 3D model with Alia as guide |
| Operation | architecture-lifecycle CI · technical-debt-records · architecture-definition-record | Tier 0 writes (a workaround TDR, a TDR move, a Proposed ADR, a doc fix) requested from an IDE or a build agent and written by CI; escalations as records and issues — docs/RUN_TEAMS.md |
Once a project copies the ci/ workflows, a release runs without anyone at the keyboard:
architecture-v<version> and publishes the index: chunks and ledger.what_changed reports what moved.SA_TIER0_AUTOMERGE). Its key, the architecture-tier0 label, is always applied by a person."supervision": "unattended" in the service's .architecture.json). A question becomes a TODO(architecture), a request goes to the architecture repository, and the work ends in a pull request a person reviews.Who consumes the index, and what changes without a person: docs/RUN_TEAMS.md.
Five built-in architecture types — Enterprise (the domain-neutral default), Microservices, 3-Tier, N-Layer and BIAN — and an organization can add its own.
Requirements: Claude Code and Bun ≥ 1.0.
claude plugin marketplace add shadowX4fox/solutions-architect-skills
claude plugin install sa-skills@shadowx4fox-solution-architect-marketplace --scope user
Installing sa-skills brings the other five plugins (sa-records, sa-requirements, sa-analysis, sa-delivery, sa-visualization). In Claude Code run /reload-plugins, then once per project:
/sa-skills:setup
It grants the permissions the skills need, sets up the judge and checks the export toolchain. Word / PDF export and the handoff kit's contract checks also need the plugin's npm packages, installed once:
cd "$(bun ~/.claude/plugins/marketplaces/shadowx4fox-solution-architect-marketplace/scripts/plugin-root.ts --quiet)" && bun install
Then just ask:
Elicit the requirements for a parcel-tracking service → architecture-readiness
Create the architecture → architecture-docs
Run all analyses → architecture-analysis
Generate the dev handoff → architecture-dev-handoff
Open the command center → architecture-command-center
Full guide: docs/INSTALLATION.md · a first architecture, step by step: docs/QUICK_START.md. Optional: sa-ux, a small UX layer drawn inside Claude Code (next-step prompts, an architecture radar) — claude plugin install sa-ux@shadowx4fox-solution-architect-marketplace --scope user.
Alia is the architecture's navigator. She lives in two places.
/sa-visualization:architecture-command-center renders the documented architecture as one self-contained HTML file: a floor per layer, every C4 container a block, every integration a route. Alia walks you through it.
.xlsx.holo, atlas, a neutral corporate, or your organization's own.Read-only by design: it never edits a document; it hands the architect Markdown to paste.
/sa-delivery:architecture-index-export turns a released architecture into an sa-index/1 bundle: every section, flow, integration, ADR and TDR as a searchable chunk, the facts ledger, and a build pack per component — its spec, security obligations, interface contracts both ways, assets byte for byte, budgets and an ordered build plan.
The Alia architecture server (Spring AI MCP, Qdrant, Qwen3-Embedding on Ollama) serves those bundles to any MCP client: VS Code, JetBrains, Claude Code, or a build agent in CI.
| Ask | Tools |
|---|---|
| "What does this component own, and who calls it?" | get_component, list_integrations |
| "Why did we choose Kafka here?" | search_architecture, list_decisions |
| "Walk me through checkout." | list_flows, get_flow |
| "Build this service." | get_build_pack, get_build_instructions, get_build_part |
| "What changed since I built it?" | what_changed |
| "Record this shortcut as technical debt." | preview_tier0, record_tier0 |
| "This blocks me: ask the architect." | preview_escalation, record_escalation |
CI publishes each release (ci/publish-index.sh, with GitHub Actions, Gitea and Azure Pipelines examples). On GitHub, architecture-lifecycle's ci/ adds the rest of the pipeline: release gates on every pull request, and on the merge of a release PR the tag, the index and the handoff kit edited by Claude into a pull request; on a RUN-team project, a Tier 0 workflow writes the requests that arrive as issues (docs/RUN_TEAMS.md). While you write, publish-index.js --up runs a local server in Docker so you can try the draft from your IDE.
The base is generic by construction: it never assumes one industry or one company.
frameworks.js --declare <id|none>).Fifteen skills in six categories. Standalone means the skill starts without an ARCHITECTURE.md. The catalog is scripts/skill-catalog.json; bun run check:catalog holds it to the code.
| Category | Skill | Plugin | Standalone | Needs to start |
|---|---|---|---|---|
| Intake — business input | architecture-readiness | sa-requirements | Yes | Nothing |
architecture-capability-alignment | sa-requirements | Yes | PRODUCT_OWNER_SPEC.md | |
| Records — decisions and debt | architecture-definition-record | sa-records | Yes | Nothing (catalog mode maintains an institutional ADR catalog) |
technical-debt-records | sa-records | Yes | Nothing | |
| Authoring — the architecture | architecture-docs | sa-skills | Yes | PO Spec, the alignment report (when a framework is declared) and an HLD to create |
architecture-lifecycle | sa-skills | No | ARCHITECTURE.md in a git repository | |
architecture-component-guardian | sa-skills | Internal | docs/components/ | |
architecture-explorer-headers | sa-skills | Internal | docs/ | |
| Governance — checks over it | architecture-analysis | sa-analysis | No | ARCHITECTURE.md |
architecture-traceability | sa-analysis | No | PO Spec + ARCHITECTURE.md | |
| Delivery — for other teams | architecture-dev-handoff | sa-delivery | No | ARCHITECTURE.md + docs/05-integration-points.md |
architecture-index-export | sa-delivery | No | ARCHITECTURE.md (a handoff kit for build packs) | |
architecture-ci-setup | sa-delivery | Yes | An architecture repository, or a service repository bound to one | |
architecture-docs-export | sa-delivery | No | ARCHITECTURE.md | |
architecture-icepanel-sync | sa-delivery | No | ARCHITECTURE.md | |
| Visualization | architecture-command-center | sa-visualization | No | ARCHITECTURE.md |
| Guide | For |
|---|---|
| Installation | Install, permissions, upgrades, Bun per OS, context7, VS Code |
| Quick start | A first architecture, end to end |
| Workflow guide | Every phase and what it produces |
| Alia | The 3D command center and the architecture MCP server |
| RUN teams | Operating a released architecture: Tier 0 writes, escalations, unattended build agents |
| Institution packs | Adding an organization's standards, frameworks, types and skins |
| Troubleshooting | Common problems and fixes |
| Design notes | Why the rules are what they are — with the measurements |
| Changelog | What changed, release by release |
Each skill's full reference is its SKILL.md (skills/<name>/ or plugins/<plugin>/skills/<name>/).
This is a community project, and every kind of help counts — a bug report from a real run is worth as much as code.
Before a pull request: bun install && bun run check (typecheck, structure checks, 1,000+ tests). Read CONTRIBUTING.md for the few rules every change follows — above all, synthetic examples only and nothing organization-specific in the base. Security issues: SECURITY.md.
references/bian-v14/).Third-party notices: NOTICE.
MIT © 2025-2026 shadowx4fox
hooks/register.tsx 18 lines1// sa-ux — a solution architect's UX layer over sa-skills. Optional: the skills work without it; it reads what their
2// scripts write and draws it, never writes an architecture document. One hooks module per plugin: each mod lives in its
3// own folder with its drawing hooks, lifecycle.tsx holds the session.start / turn.complete hooks they share, and state.ts
4// the values they keep.
5import type { Register } from 'claude-code'
6
7import { registerExplorer } from './explorer/register'
8import { registerLifecycle } from './lifecycle'
9import { registerNextSteps } from './next-steps/register'
10import { registerRadar } from './radar/register'
11
12export const register: Register = on => {
13 registerLifecycle(on)
14 registerNextSteps(on)
15 registerExplorer(on)
16 registerRadar(on)
17}
18hooks/explorer/register.tsx 151 lines1// sa-ux · explorer — `/arch` opens a pane over the facts ledger: containers, integrations, ADRs, TDRs, and a card per
2// element whose actions fill the prompt (a draft — nothing is sent on its own).
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, On } from 'claude-code'
5
6import type { ExplorerFacts, ExplorerScope, ExplorerTab } from '../../types'
7import { ARCH_PANE } from '../state'
8import type { Io } from '../next-steps/rules'
9import { GLYPH, TONE, cut, pad, rule } from '../ui'
10import { card, inFilter, loadFacts, rows } from './model'
11
12const factsAtom = atom({ plugin: 'sa-ux', key: 'explorerFacts' } as const, null as ExplorerFacts | null)
13const pageAtom = atom({ plugin: 'sa-ux', key: 'explorerPage' } as const, 0)
14const selectedAtom = atom({ plugin: 'sa-ux', key: 'explorerSelected' } as const, null as string | null)
15const tabAtom = atom({ plugin: 'sa-ux', key: 'explorerTab' } as const, 'components' as ExplorerTab)
16const scopeAtom = atom({ plugin: 'sa-ux', key: 'explorerScope' } as const, 'all' as ExplorerScope)
17
18const PANE = ARCH_PANE
19const TABS: { tab: ExplorerTab; key: string; en: string; es: string }[] = [
20 { tab: 'components', key: 'c', en: 'Containers', es: 'Contenedores' },
21 { tab: 'integrations', key: 'i', en: 'Integrations', es: 'Integraciones' },
22 { tab: 'adrs', key: 'a', en: 'ADRs', es: 'ADR' },
23 { tab: 'tdrs', key: 't', en: 'TDRs', es: 'TDR' },
24]
25
26/** Re-read the ledger: on /arch, so the pane shows the project as it stands. */
27async function reload($: EngineInterface) {
28 const io: Io = { exists: (p) => $.fs.exists(p), read: (p) => $.fs.read(p), list: (p) => $.fs.list(p), stat: (p) => $.fs.stat(p) }
29 const facts = await loadFacts(io, await $.session.root())
30 await update($, factsAtom, () => facts)
31}
32
33/** Explorer: `/arch`, a pane over the architecture's containers, integrations, ADRs and TDRs. */
34export function registerExplorer(on: On) {
35 on('command.run', { command: 'arch' }, async $ => {
36 await reload($)
37 const f = await read($, factsAtom)
38 await $.ui.open({ id: PANE, title: f && f.lang === 'es' ? 'Arquitectura' : 'Architecture' })
39 return { text: f ? (f.lang === 'es' ? 'Panel de arquitectura abierto.' : 'Architecture pane opened.') : 'No facts ledger yet (.cache/sa-skills/facts.json) — run any sa-skills workflow on this architecture first.' }
40 })
41
42 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
43 const { Box, Button, Text } = $.ui.resolve(e)
44 const f = await read($, factsAtom)
45 if (!f) return (
46 <Box flexDirection="column">
47 <Text color={TONE.info} bold>{`${GLYPH.brand} Architecture`}</Text>
48 <Text dimColor>No facts ledger yet (.cache/sa-skills/facts.json). Run any sa-skills workflow on this architecture, then /arch again.</Text>
49 </Box>
50 )
51 const es = f.lang === 'es'
52 const tab = await read($, tabAtom), page = await read($, pageAtom), selected = await read($, selectedAtom), scope = await read($, scopeAtom)
53 const width = Math.max(30, e.props.bodyColumns - 2)
54 const total: Record<ExplorerTab, number> = { components: f.components.length, integrations: f.integrations.length, adrs: f.adrs.length, tdrs: f.tdrs.length }
55 const go = (x: ExplorerTab) => async () => { await update($, tabAtom, () => x); await update($, pageAtom, () => 0); await update($, selectedAtom, () => null) }
56 // the tab bar: the open tab is a solid chip, the others are keys
57 const tabs = (
58 <Box flexDirection="column">
59 <Box flexDirection="row" gap={2}>
60 {TABS.map((x) => (x.tab === tab
61 ? <Text key={`on-${x.tab}`} inverse bold>{` ${es ? x.es : x.en} ${total[x.tab]} `}</Text>
62 : <Button key={`tab-${x.tab}`} hotkey={x.key} plain dimColor label={`${es ? x.es : x.en} ${total[x.tab]}`} onPress={go(x.tab)} />))}
63 </Box>
64 <Text dimColor>{rule(width)}</Text>
65 </Box>
66 )
67
68 if (selected) {
69 const c = card(f, tab, selected)
70 if (c) {
71 const labelWidth = Math.max(...c.lines.map(([k]) => k.length), 0)
72 return (
73 <Box flexDirection="column">
74 {tabs}
75 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
76 <Text bold wrap="truncate-end">{c.title}</Text>
77 <Text color={c.badge.tone} dimColor={!c.badge.tone}>{`${c.badge.glyph} ${c.badge.text}`}</Text>
78 {c.lines.map(([k, v]) => (
79 <Box key={`l-${k}`} flexDirection="row" gap={1}>
80 <Text dimColor>{pad(k, labelWidth)}</Text>
81 <Box flexShrink={1}><Text wrap="wrap">{v}</Text></Box>
82 </Box>
83 ))}
84 {c.lists.map((l) => (
85 <Box key={`list-${l.label}`} flexDirection="column" marginTop={1}>
86 <Text dimColor>{`${l.label} ${GLYPH.dot} ${l.items.length}`}</Text>
87 {l.items.map((it, k) => (
88 <Box key={`it-${l.label}-${k}`} flexDirection="row" gap={1}>
89 <Text color={TONE.info}>{it.glyph}</Text>
90 <Text wrap="truncate-end">{it.text}</Text>
91 </Box>
92 ))}
93 </Box>
94 ))}
95 </Box>
96 <Box flexDirection="row" gap={2}>
97 {c.actions.map((a, i) => (
98 <Button key={`act-${i}`} hotkey={String(i + 1)} plain label={a.label} onPress={async () => { await $.prompt.fill({ text: a.prompt }) }} />
99 ))}
100 <Button key="back" hotkey="b" plain dimColor label={es ? 'volver' : 'back'} onPress={() => update($, selectedAtom, () => null)} />
101 </Box>
102 <Text dimColor>{es ? 'las acciones llenan el prompt, no lo envían' : 'actions fill the prompt, never send it'}</Text>
103 </Box>
104 )
105 }
106 }
107
108 // scope: containers and integrations only — `s` cycles all → in scope → out of scope; each state carries its count
109 const scoped = tab === 'components' || tab === 'integrations'
110 const ids = tab === 'components' ? f.components.map((c) => c.slug) : f.integrations.map((i) => i.id)
111 const count = (sc: ExplorerScope) => ids.filter((id) => inFilter(f, tab, id, sc)).length
112 const NEXT: Record<ExplorerScope, ExplorerScope> = { all: 'in', in: 'out', out: 'all' }
113 const word: Record<ExplorerScope, string> = es ? { all: 'todos', in: 'en alcance', out: 'fuera de alcance' } : { all: 'all', in: 'in scope', out: 'out of scope' }
114 const filter = scoped ? (
115 <Box flexDirection="row" gap={1}>
116 <Button key="scope" hotkey="s" plain dimColor label={es ? 'alcance' : 'scope'} onPress={async () => { await update($, scopeAtom, () => NEXT[scope]); await update($, pageAtom, () => 0) }} />
117 {(['all', 'in', 'out'] as ExplorerScope[]).map((sc) => (sc === scope
118 ? <Text key={`sc-${sc}`} color={TONE.info} bold>{`${word[sc]} ${count(sc)}`}</Text>
119 : <Text key={`sc-${sc}`} dimColor>{`${word[sc]} ${count(sc)}`}</Text>))}
120 </Box>
121 ) : null
122 const list = rows(f, tab, scoped ? scope : 'all')
123 const size = Math.max(3, Math.min(9, Math.floor(((e.viewport?.rows ?? 24) - 9) / 2)))
124 const pages = Math.max(1, Math.ceil(list.length / size)), p = Math.min(page, pages - 1)
125 const shown = list.slice(p * size, p * size + size)
126 return (
127 <Box flexDirection="column">
128 {tabs}
129 {filter}
130 {shown.length === 0 ? <Text dimColor>{es ? 'nada en este filtro' : 'nothing under this filter'}</Text> : null}
131 {shown.map((r, i) => (
132 <Box key={`row-${r.id}`} flexDirection="row" gap={1}>
133 <Text color={r.tone} dimColor={!r.tone}>{r.glyph}</Text>
134 <Box flexDirection="column" flexShrink={1}>
135 <Button key={`pick-${r.id}`} hotkey={String(i + 1)} plain dimColor={r.dim} label={cut(r.title, width - 6)} onPress={() => update($, selectedAtom, () => r.id)} />
136 {r.sub ? <Text dimColor>{` ${cut(r.sub, width - 5)}`}</Text> : null}
137 </Box>
138 </Box>
139 ))}
140 <Text dimColor>{rule(width)}</Text>
141 <Box flexDirection="row" gap={2}>
142 <Text dimColor>{es ? `página ${p + 1} de ${pages} ${GLYPH.dot} ${list.length}` : `page ${p + 1} of ${pages} ${GLYPH.dot} ${list.length}`}</Text>
143 {p > 0 ? <Button key="prev" hotkey="p" plain dimColor label={es ? 'anterior' : 'previous'} onPress={() => update($, pageAtom, () => p - 1)} /> : null}
144 {p < pages - 1 ? <Button key="next" hotkey="n" plain dimColor label={es ? 'siguiente' : 'next'} onPress={() => update($, pageAtom, () => p + 1)} /> : null}
145 <Text dimColor>{`ledger ${f.generatedAt.slice(0, 10)}`}</Text>
146 </Box>
147 </Box>
148 )
149 })
150}
151hooks/lifecycle.tsx 104 lines1// sa-ux — the session's lifecycle, once for every mod: a plugin hooks session.start and turn.complete once, so this file
2// registers the commands and refreshes what each mod draws. The logic is the mods' own (pure modules); what needs `$`
3// lives here, at the top of the file, where the engine follows it.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, On } from 'claude-code'
6
7import type { Radar, Step } from '../types'
8
9import { fingerprint, newestMd, nextSteps, type Io } from './next-steps/rules'
10import { buildRadar, type FindingsRow } from './radar/model'
11
12const dismissedAtom = atom({ plugin: 'sa-ux', key: 'nextStepsDismissed' } as const, '')
13const langAtom = atom({ plugin: 'sa-ux', key: 'lang' } as const, 'en' as 'en' | 'es')
14const radarAtom = atom({ plugin: 'sa-ux', key: 'radar' } as const, null as Radar | null)
15const radarKeyAtom = atom({ plugin: 'sa-ux', key: 'radarKey' } as const, '')
16const stepsAtom = atom({ plugin: 'sa-ux', key: 'nextSteps' } as const, [] as Step[])
17
18const MARKETPLACE_ROOT_SCRIPT = '.claude/plugins/marketplaces/shadowx4fox-solution-architect-marketplace/scripts/plugin-root.ts'
19
20/** next-steps: recompute the steps from the project's state; the first is the prompt box's dim suggestion. */
21async function refreshNextSteps($: EngineInterface) {
22 const io: Io = { exists: (p) => $.fs.exists(p), read: (p) => $.fs.read(p), list: (p) => $.fs.list(p), stat: (p) => $.fs.stat(p) }
23 const root = await $.session.root()
24 const { lang, steps } = await nextSteps(io, root, await $.clock.now())
25 await update($, stepsAtom, () => steps)
26 await update($, langAtom, () => lang)
27 const first = steps[0]
28 if (first && (await read($, dismissedAtom)) !== fingerprint(steps)) void $.prompt.suggest({ text: first.prompt })
29}
30
31/** Where sa-skills is: SA_SKILLS_DIR, the checkout this plugin was loaded from, else the marketplace's own resolver. */
32async function saSkillsDir($: EngineInterface): Promise<string | null> {
33 const has = async (d: string) => $.fs.exists(`${d}/scripts/shared/findings.js`)
34 const env = await $.env.get('SA_SKILLS_DIR')
35 if (env && (await has(env))) return env
36 try {
37 const real = (await $.fs.stat($.plugin.root, { resolve: true })).realPath
38 const repo = real ? real.replace(/\/plugins\/sa-ux\/?$/, '') : ''
39 if (repo && repo !== real && (await has(repo))) return repo
40 } catch { /* not a checkout */ }
41 const home = await $.env.get('HOME')
42 if (!home) return null
43 try {
44 const r = await $.process.run(['bun', `${home}/${MARKETPLACE_ROOT_SCRIPT}`])
45 const dir = r.exitCode === 0 ? r.stdout.trim().split('\n').pop() || '' : ''
46 return dir && (await has(dir)) ? dir : null
47 } catch { return null }
48}
49
50/** radar: re-read the risk when its inputs changed — analysis reports and the ledger. */
51async function refreshRadar($: EngineInterface) {
52 const root = await $.session.root()
53 let arch = ''
54 try { arch = await $.fs.read(`${root}/ARCHITECTURE.md`) } catch { arch = '' }
55 if (!arch) { await update($, radarAtom, () => null); $.ui.status(undefined); return }
56 const mtime = async (p: string) => { try { return (await $.fs.stat(p)).mtimeMs } catch { return 0 } }
57 const analysis = `${root}/analysis`, facts = `${root}/.cache/sa-skills/facts.json`
58 let reports: { name: string; mtimeMs: number }[] = []
59 try { reports = (await $.fs.list(analysis)).filter((x) => x.kind === 'file') } catch { reports = [] }
60 const io: Io = { exists: (p) => $.fs.exists(p), read: (p) => $.fs.read(p), list: (p) => $.fs.list(p), stat: (p) => $.fs.stat(p) }
61 const docs = await newestMd(io, `${root}/docs`)
62 const key = [reports.length, Math.max(0, ...reports.map((x) => x.mtimeMs)), await mtime(facts), docs].join(':')
63 if (key === (await read($, radarKeyAtom)) && (await read($, radarAtom))) { const r = await read($, radarAtom); if (r) $.ui.status(r.line); return }
64
65 const lang: 'en' | 'es' = /<!--\s*(?:DOC_)?LANGUAGE:\s*es\b/i.test(arch) ? 'es' : 'en'
66 let findings: FindingsRow[] | null = null, findingsNote: string | null = null
67 if (reports.length) {
68 const sa = await saSkillsDir($)
69 if (!sa) findingsNote = 'sa-skills not found (set SA_SKILLS_DIR) — findings not counted'
70 else {
71 try {
72 const r = await $.process.run(['bun', `${sa}/scripts/shared/findings.js`, analysis, '--json'], { timeoutMs: 20000 })
73 if (r.exitCode === 0) findings = JSON.parse(r.stdout) as FindingsRow[]
74 else findingsNote = 'sa-skills older than 8.116.0 (no findings CLI) — findings not counted'
75 } catch { findingsNote = 'findings parser failed — findings not counted' }
76 }
77 }
78 let tdrs: { status: string }[] = []
79 try { const f = JSON.parse(await $.fs.read(facts)); tdrs = Array.isArray(f.tdrs) ? f.tdrs.map((d: { status?: unknown }) => ({ status: String(d.status || '') })) : [] } catch { tdrs = [] }
80 const radar = buildRadar({ lang, findings, findingsNote, tdrs, docsNewest: docs, now: await $.clock.now() })
81 await update($, radarAtom, () => radar)
82 await update($, radarKeyAtom, () => key)
83 $.ui.status(radar.line)
84}
85
86/** The lifecycle hooks of every mod: commands at session start, refreshes at session start and after each turn. */
87export function registerLifecycle(on: On) {
88 on('session.start', async ($, e, next) => {
89 const r = await next(e)
90 await $.command.register({ name: 'arch', description: 'Browse the architecture: containers, integrations, ADRs and TDRs (sa-ux)' })
91 await $.command.register({ name: 'radar', description: "The architecture's risk: findings by lens and open debt (sa-ux)" })
92 await refreshNextSteps($)
93 await refreshRadar($)
94 return r
95 })
96
97 on('turn.complete', async ($, e, next) => {
98 const r = await next(e)
99 await refreshNextSteps($)
100 await refreshRadar($)
101 return r
102 })
103}
104hooks/next-steps/register.tsx 50 lines1// sa-ux · next steps — the band above the prompt box: up to three prompts lifecycle.tsx computed from the project's
2// state. A click fills the prompt with the step as a draft (nothing is sent); "hide" hides the set until it changes.
3// No hotkeys: a band Button's digit also answers from an empty composer, so a digit typed to answer the model's own
4// numbered options would take a step instead.
5import { atom, read, update } from 'claude-code'
6import type { On } from 'claude-code'
7
8import type { Step } from '../../types'
9import { GLYPH, TONE, cut, pad } from '../ui'
10import { fingerprint } from './rules'
11
12const dismissedAtom = atom({ plugin: 'sa-ux', key: 'nextStepsDismissed' } as const, '')
13const langAtom = atom({ plugin: 'sa-ux', key: 'lang' } as const, 'en' as 'en' | 'es')
14const stepsAtom = atom({ plugin: 'sa-ux', key: 'nextSteps' } as const, [] as Step[])
15
16/** Next steps: the band above the prompt box. */
17export function registerNextSteps(on: On) {
18 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
19 const steps = await read($, stepsAtom)
20 if (e.props.hasSurvey || !steps.length || (await read($, dismissedAtom)) === fingerprint(steps)) return next(e)
21
22 const es = (await read($, langAtom)) === 'es'
23 const { Box, Button, Text } = $.ui.resolve(e)
24 const hide = () => update($, dismissedAtom, () => fingerprint(steps))
25 const take = (s: Step) => async () => {
26 await $.prompt.fill({ text: s.prompt })
27 await hide()
28 }
29
30 // one row per step: the action (a click fills the prompt), then the fact behind it, dimmed and cut to the band
31 const room = Math.max(20, e.props.bodyColumns - 2)
32 const titleWidth = Math.max(...steps.map((s) => s.title.length))
33 return (
34 <Box flexDirection="column">
35 <Box flexDirection="row" gap={1}>
36 <Text color={TONE.info} bold>{`${GLYPH.brand} ${es ? 'Siguientes pasos' : 'Next steps'}`}</Text>
37 <Text dimColor>{es ? `${steps.length} · clic para llenar el prompt, no lo envía` : `${steps.length} · click to fill the prompt, never sends it`}</Text>
38 </Box>
39 {steps.map((s) => (
40 <Box key={`row-${s.id}`} flexDirection="row" gap={1}>
41 <Button key={`step-${s.id}`} plain label={pad(s.title, titleWidth)} onPress={take(s)} />
42 <Text dimColor>{cut(s.detail, Math.max(10, room - titleWidth - 5))}</Text>
43 </Box>
44 ))}
45 <Button key="hide" plain dimColor label={es ? 'ocultar hasta que cambien' : 'hide until they change'} onPress={hide} />
46 </Box>
47 )
48 })
49}
50hooks/radar/register.tsx 89 lines1// sa-ux · radar — the status line carries the summary (lifecycle.tsx writes it); `/radar` opens the detail: each lens's
2// newest report with its critical and high findings and age, the TDRs by status, and a button
3// per stale lens that fills the prompt to rerun it (a draft — nothing is sent).
4import { atom, read } from 'claude-code'
5import type { On } from 'claude-code'
6
7import type { Radar } from '../../types'
8
9import { RADAR_PANE } from '../state'
10import { GLYPH, TONE, bar, pad, padLeft, rule, type Tone } from '../ui'
11
12const radarAtom = atom({ plugin: 'sa-ux', key: 'radar' } as const, null as Radar | null)
13
14/** Radar: `/radar`, the risk detail behind the status line. */
15export function registerRadar(on: On) {
16 on('command.run', { command: 'radar' }, async $ => {
17 const r = await read($, radarAtom)
18 await $.ui.open({ id: RADAR_PANE, title: r && r.lang === 'es' ? 'Radar de riesgo' : 'Risk radar' })
19 return { text: r ? r.line : 'No architecture here (no ARCHITECTURE.md), or the radar has not been read yet.' }
20 })
21
22 on('ui.render', { component: 'Pane', requestId: RADAR_PANE }, async ($, e) => {
23 const { Box, Button, Text } = $.ui.resolve(e)
24 const r = await read($, radarAtom)
25 if (!r) return (
26 <Box flexDirection="column">
27 <Text color={TONE.info} bold>{`${GLYPH.brand} Risk radar`}</Text>
28 <Text dimColor>No architecture here (no ARCHITECTURE.md).</Text>
29 </Box>
30 )
31 const es = r.lang === 'es'
32 const width = Math.max(30, e.props.bodyColumns - 2)
33 const critical = r.lenses.reduce((n, l) => n + l.critical, 0), high = r.lenses.reduce((n, l) => n + l.high, 0)
34 const debt = (r.tdrs['Open'] || 0) + (r.tdrs['Planned'] || 0) + (r.tdrs['In Progress'] || 0)
35 const stale = r.lenses.filter((l) => l.stale)
36 // the meter of a lens: critical counts double, scaled to the heaviest lens
37 const weight = (l: { critical: number; high: number }) => l.critical * 2 + l.high
38 const heaviest = Math.max(1, ...r.lenses.map(weight))
39 const METER = 10
40 const lenses = [...r.lenses].sort((a, b) => weight(b) - weight(a))
41 const tile = (key: string, value: string, label: string, tone: Tone) => (
42 <Box key={key} flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
43 <Text color={tone} bold>{value}</Text>
44 <Text dimColor>{label}</Text>
45 </Box>
46 )
47 return (
48 <Box flexDirection="column">
49 <Box flexDirection="row" gap={1} flexWrap="wrap">
50 {tile('t-crit', String(critical), es ? 'críticos' : 'critical', critical ? TONE.critical : TONE.ok)}
51 {tile('t-high', String(high), es ? 'altos' : 'high', high ? TONE.high : TONE.ok)}
52 {tile('t-debt', String(debt), es ? 'TDR abiertos' : 'open TDRs', debt ? TONE.high : TONE.ok)}
53 </Box>
54 {r.findingsNote ? <Text color={TONE.high}>{`${GLYPH.stale} ${r.findingsNote}`}</Text> : null}
55
56 {lenses.length ? <Text dimColor>{`${pad(es ? 'lente' : 'lens', 17)}${pad('', METER + 1)}${padLeft(es ? 'crít' : 'crit', 5)}${padLeft(es ? 'altos' : 'high', 6)}${padLeft('total', 6)} ${es ? 'reporte' : 'report'}`}</Text> : null}
57 {lenses.map((l) => { const m = bar(weight(l), heaviest, METER); return (
58 <Box key={`lens-${l.lens}`} flexDirection="row">
59 <Text dimColor={!weight(l)}>{pad(l.lens.toLowerCase(), 17)}</Text>
60 <Text color={l.critical ? TONE.critical : TONE.high}>{m.on}</Text>
61 <Text dimColor>{`${m.off} `}</Text>
62 <Text color={l.critical ? TONE.critical : undefined} dimColor={!l.critical}>{padLeft(String(l.critical), 5)}</Text>
63 <Text color={l.high ? TONE.high : undefined} dimColor={!l.high}>{padLeft(String(l.high), 6)}</Text>
64 <Text dimColor>{`${padLeft(String(l.total), 6)} ${l.date}`}</Text>
65 {l.stale ? <Text color={TONE.high}>{` ${GLYPH.stale} ${l.ageDays} d`}</Text> : <Text dimColor>{` ${l.ageDays} d`}</Text>}
66 </Box>
67 ) })}
68 <Text dimColor>{rule(width)}</Text>
69
70 <Box flexDirection="row" gap={1}>
71 <Text dimColor>{pad(es ? 'deuda' : 'debt', 13)}</Text>
72 {Object.keys(r.tdrs).length ? Object.entries(r.tdrs).map(([st, n]) => (
73 <Text key={`tdr-${st}`} color={st === 'Open' ? TONE.critical : /^(Resolved|Won't Fix)$/.test(st) ? TONE.ok : TONE.high}>{`${/^(Resolved|Won't Fix)$/.test(st) ? GLYPH.debtDone : GLYPH.debtOpen} ${st} ${n}`}</Text>
74 )) : <Text dimColor>—</Text>}
75 </Box>
76
77 {stale.length ? <Text dimColor>{rule(width)}</Text> : null}
78 {stale.length ? <Text color={TONE.high}>{es ? `${GLYPH.stale} ${stale.length} análisis más viejos que los docs — volver a correr (llena el prompt):` : `${GLYPH.stale} ${stale.length} analyses older than the docs — rerun (fills the prompt):`}</Text> : null}
79 <Box flexDirection="row" gap={2} flexWrap="wrap">
80 {stale.slice(0, 9).map((l, i) => (
81 <Button key={`rerun-${l.lens}`} hotkey={String(i + 1)} plain label={l.lens.toLowerCase()}
82 onPress={async () => { await $.prompt.fill({ text: `run ${l.lens.toLowerCase().replace(/-/g, ' ')} analysis` }) }} />
83 ))}
84 </Box>
85 </Box>
86 )
87 })
88}
89hooks/state.ts 5 lines1// sa-ux — the ids the mods share. Each file declares the state atoms it reads or writes itself (the engine's scan reads
2// an atom's plugin and key where the file uses it); the keys are the contract in types/index.d.ts.
3export const ARCH_PANE = 'sa-ux-arch'
4export const RADAR_PANE = 'sa-ux-radar'
5hooks/next-steps/rules.ts 132 lines1// The next steps of an sa-skills architecture, computed by code from what the plugin's scripts leave on disk — never
2// proposed by a model. Each rule reads one state and offers one prompt; the first three that hold are shown, most
3// urgent first. A rule whose file is absent or unreadable holds nothing: the project simply has not reached that state.
4import type { Step } from '../../types'
5
6/** The part of `$.fs` the rules read, so a test can hand them a fake project. */
7export type Io = {
8 exists: (path: string) => Promise<boolean>
9 read: (path: string) => Promise<string>
10 list: (path: string) => Promise<{ name: string; kind: 'file' | 'dir' | 'other'; mtimeMs: number }[]>
11 stat: (path: string) => Promise<{ kind: 'file' | 'dir' | 'other'; mtimeMs: number }>
12}
13type Lang = 'en' | 'es'
14
15const WEEK = 7 * 24 * 3600 * 1000
16const MAX = 3
17const day = (ms: number) => new Date(ms).toISOString().slice(0, 10)
18const safe = async <T>(f: () => Promise<T>, d: T): Promise<T> => { try { return await f() } catch { return d } }
19const json = async (io: Io, p: string): Promise<any> => safe(async () => JSON.parse(await io.read(p)), null)
20const t = (lang: Lang, en: string, es: string) => (lang === 'es' ? es : en)
21
22/** The newest modification under a directory (Markdown only), a few levels down. */
23export async function newestMd(io: Io, dir: string, depth = 4): Promise<number> {
24 if (depth < 0) return 0
25 const entries = await safe(() => io.list(dir), [])
26 let max = 0
27 for (const e of entries) {
28 if (e.kind === 'dir') max = Math.max(max, await newestMd(io, `${dir}/${e.name}`, depth - 1))
29 else if (e.kind === 'file' && e.name.endsWith('.md') && !/^CHANGELOG/.test(e.name)) max = Math.max(max, e.mtimeMs)
30 }
31 return max
32}
33
34export async function nextSteps(io: Io, root: string, now: number): Promise<{ lang: Lang; steps: Step[] }> {
35 const arch = await safe(() => io.read(`${root}/ARCHITECTURE.md`), '')
36 if (!arch) return { lang: 'en', steps: [] }
37 const lang: Lang = /<!--\s*(?:DOC_)?LANGUAGE:\s*es\b/i.test(arch) ? 'es' : 'en'
38 const docsNewest = Math.max(await newestMd(io, `${root}/docs`), await safe(async () => (await io.stat(`${root}/ARCHITECTURE.md`)).mtimeMs, 0))
39 const steps: Step[] = []
40
41 // 1 — judge worksheets waiting for answers (written in the last week; an older one was abandoned)
42 const judgeDir = `${root}/.cache/sa-skills/judge`
43 const judge = await safe(() => io.list(judgeDir), [])
44 const pending = judge.filter((w) => w.name.endsWith('.worksheet.md') && now - w.mtimeMs < WEEK).filter((w) => {
45 const a = judge.find((x) => x.name === w.name.replace(/\.worksheet\.md$/, '.answers.json'))
46 return !a || a.mtimeMs < w.mtimeMs
47 })
48 if (pending.length) {
49 const ids = pending.map((w) => w.name.replace(/\.worksheet\.md$/, ''))
50 let items = 0
51 for (const w of pending) items += ((await safe(() => io.read(`${judgeDir}/${w.name}`), '')).match(/^### /gm) || []).length
52 steps.push({ id: 'judge', why: `${ids.length} worksheet(s) without answers newer than themselves`,
53 title: t(lang, 'Answer the judge worksheets', 'Responder las hojas del juez'),
54 detail: t(lang, `${ids.length} pending · ${items} items · ${ids.join(', ')}`, `${ids.length} pendiente(s) · ${items} ítems · ${ids.join(', ')}`),
55 prompt: t(lang, `answer the pending judge worksheets in .cache/sa-skills/judge/ (${ids.join(', ')}) and re-run the checks that wrote them`, `responde las hojas del juez pendientes en .cache/sa-skills/judge/ (${ids.join(', ')}) y vuelve a correr los checks que las escribieron`) })
56 }
57
58 // 2 — a Component Specification older than its component descriptor
59 const manifest = await json(io, `${root}/handoffs/.manifest.json`)
60 const facts = await json(io, `${root}/.cache/sa-skills/facts.json`)
61 if (manifest && manifest.cut_sheets && facts && Array.isArray(facts.components)) {
62 const outdated: string[] = []
63 for (const [slug, e] of Object.entries<any>(manifest.cut_sheets)) {
64 const c = facts.components.find((x: any) => x.slug === slug); if (!c || !c.file || !e.cut_sheet_file) continue
65 const spec = await safe(async () => (await io.stat(`${root}/handoffs/${e.cut_sheet_file}`)).mtimeMs, 0)
66 const desc = await safe(async () => (await io.stat(`${root}/${c.file}`)).mtimeMs, 0)
67 if (spec && desc > spec) outdated.push(c.name)
68 }
69 if (outdated.length) steps.push({ id: 'handoff', why: 'a descriptor changed after its Component Specification',
70 title: t(lang, 'Regenerate the handoff', 'Regenerar el handoff'),
71 detail: t(lang, `${outdated.join(', ')} — descriptor newer than its specification`, `${outdated.join(', ')} — descriptor más nuevo que su especificación`),
72 prompt: t(lang, `regenerate the handoff of ${outdated.join(' and ')}`, `regenera el handoff de ${outdated.join(' y ')}`) })
73 }
74
75 // 3 — the blast-radius analysis no longer matches docs/05. handoff-prepare.js wrote the warning against the report it
76 // read, and names it; the warning holds only while that report is still the newest Blast Radius in analysis/ — a newer
77 // run (the older one archived) answers it, whatever the last handoff left in context-result.json.
78 const ctx = await json(io, `${root}/.cache/sa-skills/handoff-payloads/context-result.json`)
79 const blast = (await safe(() => io.list(`${root}/analysis`), [])).map((e) => e.name).filter((n) => /^BLAST-RADIUS-\d{4}-\d{2}-\d{2}\.(md|html)$/.test(n)).sort().pop()
80 const stale = blast && ctx && Array.isArray(ctx.edge_cross_check_warnings) ? ctx.edge_cross_check_warnings.filter((w: string) => /blast-radius stale/.test(w) && w.includes(`analysis/${blast}`)).length : 0
81 if (stale) steps.push({ id: 'blast-radius', why: `${stale} edge(s) of docs/05 missing from the blast-radius report`,
82 title: t(lang, 'Rerun the blast-radius analysis', 'Volver a correr blast-radius'),
83 detail: t(lang, `${stale} docs/05 edge(s) missing from the report`, `al reporte le faltan ${stale} arista(s) de docs/05`),
84 prompt: t(lang, 'run blast radius analysis', 'corre el análisis blast radius') })
85
86 // 4 — a release is due: the last `lifecycle.js status` (changes since the tag, or a missing tag), else lines in [Unreleased]
87 const changelog = await safe(() => io.read(`${root}/docs/CHANGELOG.md`), '')
88 const unreleased = (changelog.split(/^## \[Unreleased\]\s*$/m)[1] ?? '').split(/^## \[/m)[0] ?? ''
89 const entries = (unreleased.match(/^- /gm) || []).length
90 const version = (arch.match(/<!--\s*ARCHITECTURE_VERSION:\s*([\d.]+)\s*-->/) || [])[1]
91 const life = await json(io, `${root}/.cache/sa-skills/lifecycle/status.json`)
92 if (life && life.version === version && life.state === 'untagged') steps.push({ id: 'release', why: 'Released in the document, tag missing',
93 title: t(lang, 'Recover the architecture tag', 'Recuperar la etiqueta de la arquitectura'),
94 detail: `architecture-v${version}`,
95 prompt: t(lang, 'recover architecture tag', 'recover architecture tag') })
96 else if (life && life.version === version && life.state === 'changed' && life.changes) steps.push({ id: 'release', why: `${life.changes} change(s) since the tag`,
97 title: t(lang, 'Release the architecture', 'Liberar la arquitectura'),
98 detail: t(lang, `${life.changes} change(s) since v${version} · ${life.recommended}`, `${life.changes} cambio(s) desde v${version} · ${life.recommended}`),
99 prompt: t(lang, 'release my architecture', 'release my architecture') })
100 else if (entries) steps.push({ id: 'release', why: `${entries} entr(ies) under [Unreleased]`,
101 title: t(lang, 'Release the architecture', 'Liberar la arquitectura'),
102 detail: t(lang, `${entries} change(s) in [Unreleased] since v${version || '?'}`, `${entries} cambio(s) en [Unreleased] desde v${version || '?'}`),
103 prompt: t(lang, 'release my architecture', 'release my architecture') })
104
105 // 5 — the command center's newest scene is older than the docs
106 const scenes = (await safe(() => io.list(`${root}/visualizations`), [])).filter((e) => e.name.endsWith('.scene.json'))
107 const scene = Math.max(0, ...scenes.map((e) => e.mtimeMs))
108 if (scene && docsNewest > scene) steps.push({ id: 'command-center', why: 'docs newer than the newest scene',
109 title: t(lang, 'Refresh the command center', 'Actualizar el centro de mando'),
110 detail: t(lang, `scene of ${day(scene)}, docs changed since`, `escena del ${day(scene)}, los docs cambiaron después`),
111 prompt: t(lang, 'command center', 'centro de mando') })
112
113 // 6 — the newest Word export is older than the docs
114 const exportsDir = (await safe(() => io.list(`${root}/exports`), [])).filter((e) => /^SA-.*\.docx$/.test(e.name))
115 const exported = Math.max(0, ...exportsDir.map((e) => e.mtimeMs))
116 if (exported && docsNewest > exported) steps.push({ id: 'export', why: 'docs newer than the newest SA-*.docx',
117 title: t(lang, 'Export to Word again', 'Volver a exportar a Word'),
118 detail: t(lang, `last export ${day(exported)}, docs changed since`, `última exportación del ${day(exported)}, los docs cambiaron después`),
119 prompt: t(lang, 'export architecture to Word', 'export architecture to Word') })
120
121 // 7 — not yet under the prose contract
122 if (!/<!--\s*PROSE_STYLE:\s*v1\s*-->/.test(arch)) steps.push({ id: 'restyle', why: 'no PROSE_STYLE: v1 stamp',
123 title: t(lang, 'Apply the prose contract', 'Aplicar el contrato de prosa'),
124 detail: '/restyle-architecture',
125 prompt: '/restyle-architecture' })
126
127 return { lang, steps: steps.slice(0, MAX) }
128}
129
130/** The fingerprint of a step set: the band, once hidden, comes back only when the set changes. */
131export const fingerprint = (steps: Step[]) => steps.map((s) => `${s.id}:${s.detail}`).join('|')
132hooks/ui.ts 21 lines1// sa-ux — the mods' shared visual language: glyphs, tones and small text helpers. Pure (no `$`): each render hook draws
2// with these so the band, the explorer and the radar read as one product. Tones are theme keys, never raw colours, so
3// every theme and surface paints them its own way.
4export const TONE = { critical: 'error', high: 'warning', ok: 'success', info: 'suggestion' } as const
5export type Tone = (typeof TONE)[keyof typeof TONE] | undefined
6
7export const GLYPH = {
8 brand: '◆', step: '▸', inScope: '●', outScope: '○', internal: '⇄', boundary: '⇥', external: '⋯', retired: '✕',
9 accepted: '✓', proposed: '◌', superseded: '✕', debtOpen: '▲', debtDone: '✓', inbound: '←', outbound: '→', stale: '⚠', dot: '·',
10} as const
11
12export const cut = (s: string, n: number) => (s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s)
13export const pad = (s: string, n: number) => (s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length))
14export const padLeft = (s: string, n: number) => (s.length >= n ? s : ' '.repeat(n - s.length) + s)
15export const rule = (n: number) => '─'.repeat(Math.max(0, n))
16/** A meter: `filled` of `width` cells; a non-zero value always shows at least one. */
17export function bar(value: number, max: number, width: number): { on: string; off: string } {
18 const n = max > 0 && value > 0 ? Math.max(1, Math.min(width, Math.round((value / max) * width))) : 0
19 return { on: '█'.repeat(n), off: '░'.repeat(width - n) }
20}
21hooks/explorer/model.ts 105 lines1// sa-ux · explorer — the architecture as a browsable list, from the facts ledger (`.cache/sa-skills/facts.json`, written
2// by arch-facts.js on every workflow): containers, integrations, ADRs and TDRs, and a card per element. Pure: the pane
3// draws what these functions return, and the actions only fill the prompt.
4import type { ExplorerFacts, ExplorerScope, ExplorerTab, IntegrationScope } from '../../types'
5import type { Io } from '../next-steps/rules'
6import { GLYPH, TONE, type Tone } from '../ui'
7
8/** A list row: a glyph in a tone says what kind of thing it is before the words do. */
9export type Row = { id: string; title: string; sub: string; glyph: string; tone: Tone; dim?: boolean }
10/** A card: a badge (the element's state), its scalar fields, its lists (one item a line), and the actions. */
11export type Card = { title: string; badge: { glyph: string; text: string; tone: Tone }; lines: [string, string][]; lists: { label: string; items: { glyph: string; text: string }[] }[]; actions: { label: string; prompt: string }[] }
12
13const SCOPE_GLYPH = { internal: GLYPH.internal, boundary: GLYPH.boundary, external: GLYPH.external } as const
14const adrMark = (status: string): { glyph: string; tone: Tone } => (/^Accepted/i.test(status) ? { glyph: GLYPH.accepted, tone: TONE.ok } : /^Proposed/i.test(status) ? { glyph: GLYPH.proposed, tone: TONE.high } : { glyph: GLYPH.superseded, tone: undefined })
15const tdrMark = (status: string): { glyph: string; tone: Tone } => (/^(Resolved|Won't Fix)$/.test(status) ? { glyph: GLYPH.debtDone, tone: TONE.ok } : status === 'Open' ? { glyph: GLYPH.debtOpen, tone: TONE.critical } : { glyph: GLYPH.debtOpen, tone: TONE.high })
16
17const t = (lang: 'en' | 'es', en: string, es: string) => (lang === 'es' ? es : en)
18const str = (v: unknown) => (v == null ? '' : String(v))
19
20/** The ledger, slimmed to what the pane shows. null when the project has none yet. */
21export async function loadFacts(io: Io, root: string): Promise<ExplorerFacts | null> {
22 let raw: any
23 try { raw = JSON.parse(await io.read(`${root}/.cache/sa-skills/facts.json`)) } catch { return null }
24 const arr = (x: unknown): any[] => (Array.isArray(x) ? x : [])
25 // scope is the ledger's: a system the architecture owns (`owned: true`) is in scope, every other one is outside
26 const systems = new Map(arr(raw.systems).map((s) => [str(s.slug), { name: str(s.name), owned: s.owned === true }]))
27 const components = arr(raw.components).filter((c) => c.level === 'L2').map((c) => {
28 const sys = systems.get(str(c.system))
29 return {
30 slug: str(c.slug), name: str(c.name), index: str(c.index), type: str(c.type), technology: arr(c.technology).map(str),
31 description: str(c.description), internalName: str(c.internal_name), version: str(c.component_version), file: str(c.file),
32 adrs: arr(c.related_adrs).map(str), system: sys ? sys.name : str(c.system), inScope: !!(sys && sys.owned),
33 }
34 })
35 const inside = new Set(components.filter((c) => c.inScope).map((c) => c.name))
36 const scopeOf = (from: string, to: string): IntegrationScope => { const n = (inside.has(from) ? 1 : 0) + (inside.has(to) ? 1 : 0); return n === 2 ? 'internal' : n === 1 ? 'boundary' : 'external' }
37 return {
38 generatedAt: str(raw.generated_at),
39 lang: /^es/i.test(str(raw.architecture && raw.architecture.language)) ? 'es' : 'en',
40 components,
41 integrations: arr(raw.integrations).map((i) => ({ id: str(i.id), from: str(i.from), to: str(i.to), protocol: str(i.protocol), pattern: str(i.pattern), retired: !!i.retired, scope: scopeOf(str(i.from), str(i.to)) })),
42 adrs: arr(raw.adrs).map((a) => ({ id: str(a.id), title: str(a.title), status: str(a.status), scope: str(a.scope), file: str(a.file) })),
43 tdrs: arr(raw.tdrs).map((d) => ({ id: str(d.id), title: str(d.title).replace(/^TDR-\d+:\s*/, ''), status: str(d.status), driver: str(d.driver), file: str(d.file) })),
44 }
45}
46
47const num = (s: string) => s.split('.').map((x) => x.padStart(4, '0')).join('.')
48/** Whether a container or an integration passes the scope filter; ADRs and TDRs are not filtered. */
49export function inFilter(f: ExplorerFacts, tab: ExplorerTab, id: string, scope: ExplorerScope): boolean {
50 if (scope === 'all' || (tab !== 'components' && tab !== 'integrations')) return true
51 if (tab === 'components') { const c = f.components.find((x) => x.slug === id); return !!c && (scope === 'in') === c.inScope }
52 const i = f.integrations.find((x) => x.id === id); return !!i && (scope === 'in') === (i.scope !== 'external')
53}
54const scopeWord = (L: 'en' | 'es', s: IntegrationScope) => (L === 'es' ? { internal: 'interna', boundary: 'frontera', external: 'externa' } : { internal: 'internal', boundary: 'boundary', external: 'external' })[s]
55export function rows(f: ExplorerFacts, tab: ExplorerTab, scope: ExplorerScope = 'all'): Row[] {
56 const L = f.lang
57 if (tab === 'components') return [...f.components].filter((c) => inFilter(f, tab, c.slug, scope)).sort((a, b) => Number(b.inScope) - Number(a.inScope) || num(a.index).localeCompare(num(b.index)))
58 .map((c) => ({ id: c.slug, title: c.name, sub: [c.system, c.type, c.technology.join(', ')].filter(Boolean).join(' · '), glyph: c.inScope ? GLYPH.inScope : GLYPH.outScope, tone: c.inScope ? TONE.ok : undefined, dim: !c.inScope && scope === 'all' }))
59 if (tab === 'integrations') return f.integrations.filter((i) => inFilter(f, tab, i.id, scope))
60 .map((i) => ({ id: i.id, title: `${i.id} ${i.from} → ${i.to}`, sub: [scopeWord(L, i.scope), i.protocol, i.pattern].filter(Boolean).join(' · '), glyph: i.retired ? GLYPH.retired : SCOPE_GLYPH[i.scope], tone: i.retired ? undefined : i.scope === 'internal' ? TONE.ok : i.scope === 'boundary' ? TONE.info : undefined, dim: i.retired || (i.scope === 'external' && scope === 'all') }))
61 if (tab === 'adrs') return f.adrs.map((a) => ({ id: a.id, title: `${a.id} ${a.title}`, sub: [a.status.replace(/\s*\(.*$|\s+—.*$/, ''), a.scope].filter(Boolean).join(' · '), ...adrMark(a.status), dim: /^Superseded|^Deprecated/i.test(a.status) }))
62 return f.tdrs.map((d) => ({ id: d.id, title: `${d.id} ${d.title}`, sub: [d.status, d.driver].filter(Boolean).join(' · '), ...tdrMark(d.status), dim: /^(Resolved|Won't Fix)$/.test(d.status) }))
63}
64
65export function card(f: ExplorerFacts, tab: ExplorerTab, id: string): Card | null {
66 const L = f.lang
67 if (tab === 'components') {
68 const c = f.components.find((x) => x.slug === id); if (!c) return null
69 const live = f.integrations.filter((i) => !i.retired)
70 const inbound = live.filter((i) => i.to === c.name).map((i) => ({ glyph: GLYPH.inbound, text: `${i.id} ${i.from} ${GLYPH.dot} ${i.protocol}` }))
71 const outbound = live.filter((i) => i.from === c.name).map((i) => ({ glyph: GLYPH.outbound, text: `${i.id} ${i.to} ${GLYPH.dot} ${i.protocol}` }))
72 return { title: c.name,
73 badge: c.inScope ? { glyph: GLYPH.inScope, text: t(L, 'in scope', 'en alcance'), tone: TONE.ok } : { glyph: GLYPH.outScope, text: t(L, 'out of scope', 'fuera de alcance'), tone: undefined },
74 lines: ([
75 [t(L, 'System', 'Sistema'), c.system], [t(L, 'Internal name', 'Nombre interno'), c.internalName], [t(L, 'Type', 'Tipo'), c.type],
76 [t(L, 'Technology', 'Tecnología'), c.technology.join(', ')], [t(L, 'Version', 'Versión'), c.version], [t(L, 'Description', 'Descripción'), c.description],
77 ['ADR', c.adrs.join(', ')], [t(L, 'File', 'Archivo'), c.file],
78 ] as [string, string][]).filter(([, v]) => v),
79 lists: [{ label: t(L, 'Inbound', 'Entrantes'), items: inbound }, { label: t(L, 'Outbound', 'Salientes'), items: outbound }].filter((x) => x.items.length),
80 actions: [
81 { label: t(L, 'Ask about it', 'Preguntar'), prompt: t(L, `explain the component ${c.name}: its responsibility, its integrations and the decisions that govern it`, `explica el componente ${c.name}: su responsabilidad, sus integraciones y las decisiones que lo gobiernan`) },
82 { label: t(L, 'Handoff', 'Handoff'), prompt: t(L, `generate the handoff for ${c.name}`, `genera el handoff del ${c.name}`) },
83 ] }
84 }
85 if (tab === 'integrations') {
86 const i = f.integrations.find((x) => x.id === id); if (!i) return null
87 return { title: `${i.id} ${i.from} → ${i.to}`,
88 badge: i.retired ? { glyph: GLYPH.retired, text: t(L, 'retired', 'retirada'), tone: undefined } : { glyph: SCOPE_GLYPH[i.scope], text: scopeWord(L, i.scope), tone: i.scope === 'internal' ? TONE.ok : i.scope === 'boundary' ? TONE.info : undefined },
89 lines: ([[t(L, 'From', 'Desde'), i.from], [t(L, 'To', 'Hacia'), i.to], [t(L, 'Protocol', 'Protocolo'), i.protocol], [t(L, 'Pattern', 'Patrón'), i.pattern]] as [string, string][]).filter(([, v]) => v), lists: [],
90 actions: [{ label: t(L, 'Ask about it', 'Preguntar'), prompt: t(L, `explain integration ${i.id}: what it carries, its failure behaviour and the ADRs behind it`, `explica la integración ${i.id}: qué lleva, cómo falla y qué ADR la gobiernan`) }] }
91 }
92 if (tab === 'adrs') {
93 const a = f.adrs.find((x) => x.id === id); if (!a) return null
94 const proposed = /^Proposed/i.test(a.status)
95 return { title: `${a.id} ${a.title}`, badge: { ...adrMark(a.status), text: a.status.replace(/\s*\(.*$|\s+—.*$/, '') },
96 lines: ([[t(L, 'Status', 'Estado'), a.status], [t(L, 'Scope', 'Alcance'), a.scope], [t(L, 'File', 'Archivo'), a.file]] as [string, string][]).filter(([, v]) => v), lists: [],
97 actions: [{ label: t(L, 'Summarize', 'Resumir'), prompt: t(L, `summarize ${a.id}: the decision, why, and what it constrains`, `resume el ${a.id}: la decisión, por qué y qué restringe`) },
98 ...(proposed ? [{ label: t(L, 'What is missing to accept it', 'Qué falta para aceptarlo'), prompt: t(L, `review the proposed ${a.id} and tell me what is missing to accept it`, `revisa el ${a.id} propuesto y dime qué falta para aceptarlo`) }] : [])] }
99 }
100 const d = f.tdrs.find((x) => x.id === id); if (!d) return null
101 return { title: `${d.id} ${d.title}`, badge: { ...tdrMark(d.status), text: d.status },
102 lines: ([['Driver', d.driver], [t(L, 'File', 'Archivo'), d.file]] as [string, string][]).filter(([, v]) => v), lists: [],
103 actions: [{ label: t(L, 'Summarize', 'Resumir'), prompt: t(L, `summarize ${d.id}: the compromise, its impact and the plan to pay it`, `resume el ${d.id}: el compromiso, su impacto y el plan para pagarlo`) }] }
104}
105hooks/radar/model.ts 33 lines1// sa-ux · radar — the architecture's risk at a glance: the findings of the newest report of each analysis lens (counted
2// by sa-skills' own parser, `scripts/shared/findings.js`, never re-parsed here) and the TDRs by status from the facts
3// ledger. Nothing compliance writes: compliance is isolated, and its health is the command center's (Alia).
4// Pure: lifecycle.tsx gathers the inputs, this shapes them.
5import type { Radar, RadarLens } from '../../types'
6
7/** One row of `bun scripts/shared/findings.js <analysis-dir> --json`. */
8export type FindingsRow = { lens: string; date: string; file: string; total: number; severity: Record<string, number>; residual: Record<string, number>; errors: number }
9
10const DAY = 24 * 3600 * 1000
11const day = (ms: number) => new Date(ms).toISOString().slice(0, 10)
12
13export function buildRadar(input: { lang: 'en' | 'es'; findings: FindingsRow[] | null; findingsNote: string | null; tdrs: { status: string }[]; docsNewest: number; now: number }): Radar {
14 const es = input.lang === 'es'
15 const docsDay = input.docsNewest ? day(input.docsNewest) : ''
16 const lenses: RadarLens[] = (input.findings || []).map((r) => {
17 const ageDays = Math.max(0, Math.floor((input.now - Date.parse(`${r.date}T00:00:00Z`)) / DAY))
18 // stale: older than the newest change to the docs, and at least a week old — a report of yesterday is not stale
19 return { lens: r.lens, date: r.date, ageDays, total: r.total, critical: r.severity.critical || 0, high: r.severity.high || 0, stale: !!docsDay && r.date < docsDay && ageDays >= 7 }
20 })
21 const tdrs: Record<string, number> = {}
22 input.tdrs.forEach((d) => { tdrs[d.status] = (tdrs[d.status] || 0) + 1 })
23 const critical = lenses.reduce((n, l) => n + l.critical, 0), high = lenses.reduce((n, l) => n + l.high, 0)
24 const openDebt = (tdrs['Open'] || 0) + (tdrs['Planned'] || 0) + (tdrs['In Progress'] || 0)
25 const stale = lenses.filter((l) => l.stale).length
26 const parts = [
27 lenses.length ? (es ? `${critical} crít · ${high} altos` : `${critical} critical · ${high} high`) : null,
28 `TDR ${openDebt} ${es ? 'abiertos' : 'open'}`,
29 stale ? (es ? `${stale} análisis viejos` : `${stale} stale analyses`) : null,
30 ].filter(Boolean)
31 return { lang: input.lang, lenses, findingsNote: input.findingsNote, tdrs, line: `${es ? 'riesgo' : 'risk'}: ${parts.join(' · ')}` }
32}
33types/index.d.ts 55 lines1/** next-steps: one suggestion — the action the band names, the fact behind it, the prompt it fills, and why it was offered. */
2export type Step = { id: string; title: string; detail: string; prompt: string; why: string }
3
4/** explorer: the four lists of the pane. */
5export type ExplorerTab = 'components' | 'integrations' | 'adrs' | 'tdrs'
6/** explorer: the scope filter of the containers and integrations — the systems the architecture owns, or the rest. */
7export type ExplorerScope = 'all' | 'in' | 'out'
8/** explorer: an integration against the owned systems — both ends inside, one, or neither. */
9export type IntegrationScope = 'internal' | 'boundary' | 'external'
10/** explorer: the facts ledger, slimmed to what the pane shows. */
11export type ExplorerFacts = {
12 generatedAt: string
13 lang: 'en' | 'es'
14 components: { slug: string; name: string; index: string; type: string; technology: string[]; description: string; internalName: string; version: string; file: string; adrs: string[]; system: string; inScope: boolean }[]
15 integrations: { id: string; from: string; to: string; protocol: string; pattern: string; retired: boolean; scope: IntegrationScope }[]
16 adrs: { id: string; title: string; status: string; scope: string; file: string }[]
17 tdrs: { id: string; title: string; status: string; driver: string; file: string }[]
18}
19
20/** radar: one analysis lens, from the newest report — counted by sa-skills' findings parser. */
21export type RadarLens = { lens: string; date: string; ageDays: number; total: number; critical: number; high: number; stale: boolean }
22/** radar: the architecture's risk at a glance. */
23export type Radar = {
24 lang: 'en' | 'es'
25 lenses: RadarLens[]
26 /** null when sa-skills' findings parser could not be run (not installed, or older than 8.116.0). */
27 findingsNote: string | null
28 tdrs: Record<string, number>
29 line: string
30}
31
32declare module 'claude-code' {
33 interface PluginState {
34 'sa-ux': {
35 /** next-steps: the steps computed at the end of the last turn, at most three, most urgent first. */
36 nextSteps: Step[]
37 /** next-steps: the fingerprint of the step set the person hid or took; hidden until the set changes. */
38 nextStepsDismissed: string
39 /** The architecture's DOC_LANGUAGE, for the mods' own words. */
40 lang: 'en' | 'es'
41 /** explorer: the ledger as last loaded, null before /arch or without one. */
42 explorerFacts: ExplorerFacts | null
43 explorerTab: ExplorerTab
44 explorerScope: ExplorerScope
45 explorerPage: number
46 /** explorer: the id of the element whose card is open, null for the list. */
47 explorerSelected: string | null
48 /** radar: the last reading, null outside an architecture. */
49 radar: Radar | null
50 /** radar: what the last reading was computed from — re-read only when it changes. */
51 radarKey: string
52 }
53 }
54}
55