SLOPSHOPPER

sa-ux

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…

panebandcommandstatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sa-ux
│ ┃ Architecture ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Architecture │ ┃ No facts ledger yet ⏺ Read(src/auth.ts) │ ┃ (.cache/sa-skills/facts.json). Run any ⎿ Read 6 lines │ ┃ sa-skills workflow on this architecture, ⏺ Update(src/auth.ts) │ ┃ then /arch again. ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /arch │ ⎿ sa-ux: No facts ledger yet (.cache/sa-skills/facts.json) — run a │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Architecture
◆ Architecture No facts ledger yet (.cache/sa-skills/facts.json). Run any sa-skills workflow on this architecture, then /arch again.
Pane · Risk radar
◆ Risk radar No architecture here (no ARCHITECTURE.md).
README

Solutions Architect Skills

Architecture documentation that stays true to itself — written with Claude Code, checked by code, delivered to the people who build it.

check Version License: MIT Claude Code plugin PRs welcome

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.


Contents


Showcase

<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.

Why

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:

  • One source, many readers. Business, architects, governance and developers each get the view they need, generated from the same documents — never retyped.
  • Gates, not guidelines. A principle without a trade-off, a diagram edge no integration backs, an OpenAPI operation the docs never mention: each is a finding with a line number, from a script you can run in CI.
  • Versioned like code. Every architecture carries a semantic version and a git tag; downstream artifacts state the version they were cut from, and a release says what changed.
  • Delivered where people work. The handoff kit and an MCP index put the right slice of the architecture in each developer's IDE, or in a build agent that works unattended: interfaces, budgets, security obligations, open questions.
  • Kept by the team that runs it. Once released, a RUN team records its workarounds and small decisions itself (Tier 0). Anything that needs an architect becomes an escalation, so the documents never change without someone seeing it.

What you get

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
StageSkillProduces
Requirementsarchitecture-readinessPRODUCT_OWNER_SPEC.md, scored for checkability and coherence, plus a PDF for review
Capability alignmentarchitecture-capability-alignmentThe PO Spec mapped onto a capability framework — BIAN for banking, a cross-industry map, or your own
Architecturearchitecture-docsARCHITECTURE.md + docs/ (13 sections), C4 and data-flow diagrams, in English or Spanish
Decisions & debtarchitecture-definition-record, technical-debt-recordsADRs and TDRs, including an imported institutional ADR catalog
Releasearchitecture-lifecycleStatus, release gates, changelog, architecture-v<version> tags, delivery increments
Risk & designarchitecture-analysis, architecture-traceabilityTen analyses (SPOF, blast radius, bottlenecks, cost, STRIDE, vendor lock-in, latency, tech debt, coupling, data sensitivity) and PO Spec coverage
Deliveryarchitecture-dev-handoff, architecture-docs-export, architecture-icepanel-sync, architecture-index-export, architecture-ci-setupThe 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
Visualizationarchitecture-command-centerA navigable 3D model with Alia as guide
Operationarchitecture-lifecycle CI · technical-debt-records · architecture-definition-recordTier 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

Automatic mode

Once a project copies the ci/ workflows, a release runs without anyone at the keyboard:

  1. The release PR is gated. Its merge tags architecture-v<version> and publishes the index: chunks and ledger.
  2. The handoff kit is regenerated into a pull request. It is edited in place: only the parts whose sources moved go to the model, the rest keep their text and their version row moves by code, and each component's technical debt is written into its specification by code.
  3. Merging that PR publishes the build packs, and every consumer's what_changed reports what moved.
  4. A Tier 0 request is written, verified and gated, then merged without an architect (SA_TIER0_AUTOMERGE). Its key, the architecture-tier0 label, is always applied by a person.
  5. Build agents consume the packs unattended ("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.

Quick start

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.

Meet Alia

Alia is the architecture's navigator. She lives in two places.

In the command center — a 3D model of your architecture

/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.

  • Logical and Physical views — the C4 model, and where each container runs (zones, nodes, replicas).
  • Use-case walkthroughs — each flow played phase by phase, with its latency budget.
  • Analysis on the stage — every SPOF, threat or bottleneck lit on the block it concerns, ranked by residual severity.
  • Load simulation — documented capacity against documented demand; find the first thing that breaks, save scenarios, export them to .xlsx.
  • Ask Alia — she answers from a dossier of the architecture baked into the page: sections, ADRs, TDRs, findings.
  • Skins — 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.

In the IDE and in unattended builds — the architecture as an MCP server

/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.

AskTools
"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.

Any industry, any organization

The base is generic by construction: it never assumes one industry or one company.

  • Capability frameworks. Before design, the PO Spec is aligned to the framework your project declares: BIAN (banking), the bundled cross-industry capability map (Turbo EA Capabilities, CC BY 4.0), a framework your organization adds — or none (frameworks.js --declare <id|none>).
  • Institution packs. What belongs to one organization — its design language standard, a licensed reference model, brand skins, compliance contracts, internal forms, its own architecture types and frameworks — ships as a separate (usually private) plugin the base finds on its own. The base never names a pack. Contract: docs/INSTITUTION_PACKS.md.
  • Languages. Documents are written in English or Spanish; every structural heading carries an English anchor, so the checks work in both.

Skill Categories

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.

CategorySkillPluginStandaloneNeeds to start
Intake — business inputarchitecture-readinesssa-requirementsYesNothing
architecture-capability-alignmentsa-requirementsYesPRODUCT_OWNER_SPEC.md
Records — decisions and debtarchitecture-definition-recordsa-recordsYesNothing (catalog mode maintains an institutional ADR catalog)
technical-debt-recordssa-recordsYesNothing
Authoring — the architecturearchitecture-docssa-skillsYesPO Spec, the alignment report (when a framework is declared) and an HLD to create
architecture-lifecyclesa-skillsNoARCHITECTURE.md in a git repository
architecture-component-guardiansa-skillsInternaldocs/components/
architecture-explorer-headerssa-skillsInternaldocs/
Governance — checks over itarchitecture-analysissa-analysisNoARCHITECTURE.md
architecture-traceabilitysa-analysisNoPO Spec + ARCHITECTURE.md
Delivery — for other teamsarchitecture-dev-handoffsa-deliveryNoARCHITECTURE.md + docs/05-integration-points.md
architecture-index-exportsa-deliveryNoARCHITECTURE.md (a handoff kit for build packs)
architecture-ci-setupsa-deliveryYesAn architecture repository, or a service repository bound to one
architecture-docs-exportsa-deliveryNoARCHITECTURE.md
architecture-icepanel-syncsa-deliveryNoARCHITECTURE.md
Visualizationarchitecture-command-centersa-visualizationNoARCHITECTURE.md

Documentation

GuideFor
InstallationInstall, permissions, upgrades, Bun per OS, context7, VS Code
Quick startA first architecture, end to end
Workflow guideEvery phase and what it produces
AliaThe 3D command center and the architecture MCP server
RUN teamsOperating a released architecture: Tier 0 writes, escalations, unattended build agents
Institution packsAdding an organization's standards, frameworks, types and skins
TroubleshootingCommon problems and fixes
Design notesWhy the rules are what they are — with the measurements
ChangelogWhat changed, release by release

Each skill's full reference is its SKILL.md (skills/<name>/ or plugins/<plugin>/skills/<name>/).

Get involved

This is a community project, and every kind of help counts — a bug report from a real run is worth as much as code.

  • Ask, share, propose. Questions, ideas and what you built go to Discussions.
  • Report a bug. Open an issue with what you ran, what you expected and what happened.
  • Bring your industry. A capability framework for telecom, insurance, health or the public sector — as a pack, or bundled when its licence allows it (docs/INSTITUTION_PACKS.md).
  • Add an architecture type, a handoff asset format, an analysis lens, a command-center skin.
  • Add a language. The documents speak English and Spanish today; the anchor scheme is ready for more.
  • Improve the docs. If something confused you, it will confuse the next person.

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.

Acknowledgments

Third-party notices: NOTICE.

License

MIT © 2025-2026 shadowx4fox

Source 11 files
hooks/register.tsx 18 lines
1// 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}
18
hooks/explorer/register.tsx 151 lines
1// 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}
151
hooks/lifecycle.tsx 104 lines
1// 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}
104
hooks/next-steps/register.tsx 50 lines
1// 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}
50
hooks/radar/register.tsx 89 lines
1// 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}
89
hooks/state.ts 5 lines
1// 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'
5
hooks/next-steps/rules.ts 132 lines
1// 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('|')
132
hooks/ui.ts 21 lines
1// 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}
21
hooks/explorer/model.ts 105 lines
1// 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}
105
hooks/radar/model.ts 33 lines
1// 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}
33
types/index.d.ts 55 lines
1/** 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