71-skill Topia Nexus for Claude Code — discipline rails via native hooks (readiness/guardian/completion-gate/quarantine). 315 synapses + 49 pulses, Maestro…

<img src="assets/banner.svg" alt="Topia — discipline rails for AI coding agents" width="100%">
<strong>Topia — internal skill toolkit for AI coding assistants.</strong><br> 71 skills · 315 synapses · 49 pulses · 10 extension packs · optional persistent memory via agora-code MCP
<strong>Claude Code</strong> (native) · <strong>Cursor</strong> · <strong>Antigravity</strong> · <strong>Codex</strong> · <strong>OpenCode</strong> · <strong>OpenClaw</strong>
AI coding agents are smart but undisciplined. They skip steps, forget context across sessions, and ship code that "looks done." Topia gives them workflow rails the model can't skip:
idea elicits requirements, plan writes phase files, adversary red-teams the plan, all before a line of code.test writes failing tests first (red), fix implements until green. TDD enforced, not suggested.guardian (secrets, OWASP), readiness (logic, regressions), completion-gate (validates agent claims have evidence). Auto-fire on tool use.journal persists ADRs to .topia/, optional agora-code MCP adds SQLite-backed semantic recall across sessions.One source of truth (skills/) compiles to six IDE rule formats — switch IDEs without rewriting your workflow rules.
Full guide: docs/INSTALL.md. Teams: see Team policy below.
/plugin marketplace add linenoize/topia
/plugin install topia@linenoize
Restart Claude Code if /topia:build does not appear.
| Marketplace path | Works? |
|---|---|
linenoize/topia (recommended) | ✓ |
| Git URL to the repo | ✓ |
Direct URL to marketplace.json | ✓ (v3.1.2+) |
| Local clone path | ✓ |
Plugin id is lowercase topia. If install fails, run /plugin marketplace update linenoize first.
After Step 1 you have: all /topia:* skills, plugin hooks (session-start, secrets-scan, quarantine, …), and file-based .topia/ memory — but not machine-wide dispatch hooks, team org.md policy, or agora-code MCP until Step 2.
/topia finalize
Run this once per machine (and again when you want to change hooks or org policy). It enables:
| What finalize adds | Why it matters |
|---|---|
Dispatch hooks (readiness, guardian, completion-gate, dependency-doctor) | Auto-fire on edits and shell commands in every repo, even when the plugin is not loaded |
Team org policy (interview → .topia/org/org.md) | guardian and readiness enforce your review, security, and deploy rules — not the template |
| agora-code MCP (optional) | Cross-session semantic memory |
Project .gitignore (optional) | Keeps .topia/ session state out of git (except org/) |
If you skip Step 2: skills still work when you invoke them; discipline gates only run when you remember to call them or when plugin hooks fire in that session. Cross-repo consistency and team policy injection are the main gaps.
Details: docs/INSTALL-CLAUDE-CODE.md · commands/finalize.md
Run in that project's Claude Code session:
| Step | Command | What you get |
|---|---|---|
| 1 | /topia onboard | CLAUDE.md + .topia/ context so every session starts with codebase knowledge |
| 2 | /topia org-config | Team policy in .topia/org/org.md (also offered during finalize) |
| 3 | /topia doctor | Verify install and nexus health |
Teams: commit .topia/org/ to git so every teammate and agent shares the same gates. See docs/ORG-CONFIG.md.
org.md).topia/org/org.md is where your team defines roles, reviewers, security SLAs, deploy windows, and governance level. Topia compiles it into guardian and readiness hooks — so "we require two reviewers on security files" becomes an actual gate, not a wiki page.
/topia org-config (structured interview) or during /topia finalizeorg.md, then refresh hooks (/topia finalize or topia setup --global)The Claude plugin does not compile Cursor/Codex rules. One-time terminal compile from your project root:
node "<path-to-topia>/compiler/bin/topia.js" init --platform cursor
→ docs/INSTALL-NON-CLAUDE.md (secondary path; Claude install above is primary)
git clone https://github.com/linenoize/topia.git
cd topia && npm install
node compiler/bin/topia.js install
Optional stable location: ~/.claude/skills/topia. topia install registers the plugin, wires hooks, installs agora-code MCP (if Python 3.10+), and runs doctor. Restart Claude Code after install.
node compiler/bin/topia.js install --dry-run # preview every step
node compiler/bin/topia.js install --here # hooks per-project instead of global
node compiler/bin/topia.js install --preset strict # blocking gates (default: gentle)
node compiler/bin/topia.js install --skip-agora # skip Python MCP
node compiler/bin/topia.js install --yes # non-interactive (CI-friendly)
From a Topia clone or plugin cache path:
node compiler/bin/topia.js doctor
# ✓ 71 skills, 315 synapses, 49 pulses — nexus is healthy
Claude-only users can use /topia doctor in chat instead.
Topia ships a vendored copy of agora-code at mcp-servers/agora-code/. Four skills (journal, build, idea, neural-memory) detect it automatically. Without it, skills fall back to file-based .topia/ persistence.
Integration: docs/mcp-integrations/agora-code.md. Do NOT run agora-code install-hooks --claude-code — Topia's hooks are canonical.
topia install handles migration. Manual path:
node compiler/bin/topia.js migrate-from-rune --dry-run
node compiler/bin/topia.js migrate-from-rune
See docs/migration/from-rune.md.
Every skill is invoked as /topia <name>. The router picks the right skill from a natural description of what you want done.
/topia onboard # generate CLAUDE.md + .topia/ context for a new project
/topia build "add JWT auth" # implement a feature (full TDD cycle)
/topia debug "login 401s" # root-cause an issue
/topia rescue # refactor legacy code safely
/topia audit # 8-dimension project health check
/topia sentinel # security scan before commit
/topia incident "503s" # production incident response
/topia retro # engineering retrospective
The 8-step build flow (route → recall → plan → test → implement → gate → verify → persist+commit) is documented on the landing page. Concrete scenarios — "I just inherited this codebase," "production is down," "I want to steal a pattern from a GitHub repo" — live in the Scenarios section.
| Skill | Purpose |
|---|---|
build | L1 orchestrator — full TDD cycle (Understand → Plan → Test → Implement → Verify → Commit). Default route for most code tasks. |
rescue | L1 orchestrator — multi-session legacy refactor with safety nets. |
scaffold | L1 orchestrator — bootstrap a new project from a description. |
team | L1 orchestrator — decompose into parallel workstreams with worktree isolation. |
launch | L1 orchestrator — deploy + verify + announce. |
fix | Apply code changes from diagnosis or review findings. |
debug | Root-cause analysis; hands off to fix. |
test | TDD test writer — red first, green after. |
scout | Fast read-only codebase scanner. |
verification | Run lint + type-check + tests + build. |
db | Migrations, rollbacks, query validation. |
git | Semantic commits, PR bodies, branch naming. |
integrate | Port features from external GitHub repos. |
surgeon | Incremental refactor (Strangler Fig, Branch by Abstraction). |
safeguard | Characterization tests + rollback markers before risky refactors. |
improve-architecture | Find friction; propose deepening opportunities. |
mcp-builder | Generate MCP servers from spec. |
| Skill | Purpose |
|---|---|
guardian | Pre-commit security gate — OWASP, secrets, deps. |
readiness | Pre-commit quality gate — logic, regressions, completeness. |
guardian-env | OS + runtime + tools + ports + env-var check before work starts. |
sast | Static-analysis wrapper (ESLint, Semgrep, Bandit, Clippy). |
adversary | Pre-implementation red-team analysis on high-risk plans. |
logic-guardian | Protects business logic from accidental deletion. |
quarantine | Advisory on untrusted MCP / WebFetch / upload content. |
hallucination-guard | Catch phantom imports, non-existent packages. |
deploy | Multi-platform deploy with health checks. |
watchdog | Post-deploy health monitoring. |
incident | Production incident response (triage → contain → root-cause → postmortem). |
dependency-doctor | Outdated packages + CVE scan + prioritized update plan. |
audit | 8-dimension project health audit. |
perf | Performance regression gate (N+1, sync-in-async, bundle bloat). |
| Skill | Purpose |
|---|---|
research | Web search for technologies + best practices. |
docs | Auto-generate + maintain README, API, architecture docs. |
docs-seeker | Locate API references, changelogs, migration guides. |
documentation | Leadership-ready packages, user stories, Jira CSV. |
onboard | Generate CLAUDE.md + .topia/ context for a new project. |
brainstorm | Generate 2-3 approaches with trade-offs. |
design | Design-system generator (palette, typography, anti-patterns). |
problem-solver | 19 analytical frameworks + 12 bias detectors. |
sequential-thinking | Multi-variable analysis with dependency ordering. |
journal | ADRs, decisions, progress across sessions. |
neural-memory | Cross-session recall via semantic graph (uses agora-code MCP when registered). |
trend-scout | Market intelligence (Product Hunt, GitHub Trending, HN, Reddit). |
autopsy | Health assessment of legacy codebases (rescue RECON). |
doc-processor | Generate / parse PDF, DOCX, XLSX, PPTX, CSV. |
| Skill | Purpose |
|---|---|
plan | Master plan + phase files for multi-phase features. |
idea | Requirements elicitation — 5-question gate, cross-session memory. |
review | Code review with file:line findings. |
review-intake | Process external PR comments / issue triage. |
retro | Engineering retrospective on commit history. |
scope-guard | Detect + quantify scope creep. |
context-pack | Bundle context for sub-agent delegation. |
completion-gate | Validate agent claims against evidence trail. |
| Skill | Purpose |
|---|---|
asset-creator | SVG icons, OG images, social banners. |
marketing | Landing copy, SEO meta, blog posts, video scripts. |
slides | Marp-compatible decks from JSON schema. |
video-creator | Video plans — scripts, storyboards, asset checklists. |
browser-pilot | Playwright automation + a11y audit. |
| Skill | Purpose |
|---|---|
skill-router | L0 — routes every action to the right skill. |
skill-forge | Build + verify new Topia skills. |
context-engine | Context-window management + compaction. |
session-bridge | Cross-session state persistence. |
worktree | Git worktree lifecycle for parallel streams. |
integrity-check | Detect adversarial content in .topia/ files. |
constraint-check | Validate that HARD-GATEs were actually followed. |
Full catalog with invocation markers (👤 user / 🔄 either / 🤖 agent): docs/SKILLS.md.
Install what you need; each pack adds 3–8 domain-specific skills that plug into the core toolkit.
| Pack | Focus |
|---|---|
@Topia/ui | Design systems, accessibility, animation, React patterns |
@Topia/backend | API, auth, DB, middleware |
@Topia/mobile | React Native, Flutter, app store |
@Topia/devops | Docker, CI/CD, SSL, monitoring |
@Topia/security | Pentest, supply chain, API hardening |
@Topia/ecommerce | Shopify, payments, cart, inventory |
@Topia/ai-ml | LLM, RAG, embeddings |
@Topia/content | Blog, CMS, MDX, i18n, SEO |
@Topia/analytics | Tracking, A/B, funnels |
@Topia/chrome-ext | Manifest V3, service workers |
node compiler/bin/topia.js hooks install --preset gentle # advisory (default)
node compiler/bin/topia.js hooks install --preset strict # blocking
node compiler/bin/topia.js hooks status # inspect wiring
node compiler/bin/topia.js hooks uninstall # remove cleanly
| Event | Skill | Fires | |
|---|---|---|---|
| `PreToolUse(Edit\ | Write)` | readiness | Before source-file edits |
PreToolUse(Bash) | guardian | Before shell commands | |
| `PostToolUse(Edit\ | Write)` | dependency-doctor | After manifest edits |
Stop | completion-gate | End of session |
Five layers, each with one responsibility:
| Layer | Role | Count |
|---|---|---|
| L0 Router | Routes every action | 1 |
| L1 Orchestrators | Full lifecycle workflows | 5 (build, team, launch, rescue, scaffold) |
| L2 Workflow Hubs | Cross-hub coordination — the differentiator | ~30 |
| L3 Utilities | Stateless, pure capabilities | 27 |
| L4 Extensions | Domain-specific packs | 10 |
Skills only call downward (with documented L3→L3 exceptions). Connections are declared in ## Calls / ## Called By. Event-driven coordination is declared in emit / listen pulses — full inventory in docs/PULSES.md. Full architecture: docs/ARCHITECTURE.md.
.topia/
├── decisions.md architectural decisions log
├── conventions.md established patterns & style
├── progress.md task progress tracker
├── session-log.md brief session history
├── adr/ individual ADR files (numbered)
├── features/ per-feature requirements + plans
└── org/
└── org.md team / role / policy config — committed
Every new session loads .topia/ automatically.
Only the org/ tree is intended for commit; all other .topia/* (including active-packs.json) stays local per workspace.
topia install and topia setup --here prompt once to append Topia ignore rules (.topia/*, .mcp.json, with an exception for org/). Decline is remembered via .topia/skip-gitignore.flag. Verify anytime with topia doctor.
All @Topia/* packs ship with the plugin. Onboard, topia install, and topia init (non-Claude) run stack detection and write .topia/active-packs.json so each workspace declares which packs to lean on — not a separate install step. Re-run anytime with topia packs detect.
The org/ tree holds stable team and policy configuration. guardian and readiness consume it at compile time and inject an <ORG-POLICY> block into their runtime hooks. See .topia/org/org.md for the template.
| Doc | Contents |
|---|---|
docs/INSTALL.md | Install hub — Claude, Cursor, hybrid |
docs/INSTALL-CLAUDE-CODE.md | Claude Code plugin install |
docs/INSTALL-NON-CLAUDE.md | Cursor, Codex, Windsurf, etc. |
docs/GETTING_STARTED.md | First 5 minutes |
docs/SKILLS.md | Full skill catalog with invocation markers |
docs/SKILL-CATEGORIES.md | Skill taxonomy reference |
docs/ARCHITECTURE.md | 5-layer architecture details |
docs/PULSES.md | Pulse inventory + emit/listen graph |
docs/HOOKS.md | Hook reference per platform |
docs/mcp-integrations/agora-code.md | Persistent-memory MCP integration |
docs/migration/from-rune.md | Migrating from rune-kit |
docs/TROUBLESHOOTING.md | Common issues + fixes |
docs/VISION.md | Strategic positioning + skill-addition filter |
CHANGELOG.md | Release history |
ROADTODO.md | Roadmap + outstanding work |
mcp-servers/agora-code/README.md | Vendored agora-code reference (Apache 2.0) |
Skills: 71 (L0:1 · L1:5 · L2:33 · L3:32)
Extension Packs: 10
Synapses: 315 (4.4 avg/skill)
Pulses: 49 (60 emit/listen edges)
Platforms: Claude Code, Cursor, Codex, Antigravity, OpenCode, OpenClaw, Generic
Tests: 1,035 passing
mcp-servers/agora-code/ for optional persistent memory. See mcp-servers/agora-code/NOTICE-TOPIA.md for attribution + refresh procedure.design + @Topia/ui.MIT — see LICENSE. Vendored agora-code is Apache 2.0 — see mcp-servers/agora-code/LICENSE.
mods/index.tsx 442 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { COMMIT, COMMIT_PUSH, NOT_A_REPO, PUSH, WRITING_TOOLS, bandParts, parseStatus } from './lib/git-band'
5import { projectKey, projectName } from './lib/model-preset'
6import { FAILED, composeText as hostsText, hostsKey, learnHosts, parseSsh } from './lib/remote-hosts'
7import { DEFAULTS, IDS, RULES, composeText as ordersText, ordersKey, ordersStatus } from './lib/standing-orders'
8import { joinStatus } from './lib/status'
9import type { StatusSlot } from './lib/status'
10import { filterHookText, formatUsage } from './lib/topia-quiet'
11import type { GitInfo, Host, HiddenReport, OrderId, Orders, SshTarget, UsageLine } from './types'
12
13// Topia's Claude Code mods, each behind a userConfig switch in
14// .claude-plugin/plugin.json (changing one in /config reloads this module):
15// gitBand changed / unpushed counts above the prompt, Commit and Push buttons
16// standingOrders per-project rules in the system prompt, /orders
17// remoteHosts SSH hosts learned from ssh calls that worked, per project, /hosts (off by default)
18// quietReports Topia's hook reports kept out of context, usage status line, /topia-activity
19// modelPreset the model used last per project, restored at startup, /model-preset
20// A plugin registers each event once without a matcher, so the shared events
21// below run every enabled mod's part in turn. The engine-facing code is all in
22// this file; the logic it calls is in ./lib, where the tests reach it.
23
24const gitInfo = atom({ plugin: 'topia', key: 'gitInfo' } as const, null)
25const gitBandHidden = atom({ plugin: 'topia', key: 'gitBandHidden' } as const, false)
26const standingOrders = atom({ plugin: 'topia', key: 'standingOrders' } as const, null)
27const sshHosts = atom({ plugin: 'topia', key: 'sshHosts' } as const, {})
28const quietHidden = atom({ plugin: 'topia', key: 'quietHidden' } as const, [])
29const quietSavedChars = atom({ plugin: 'topia', key: 'quietSavedChars' } as const, 0)
30const quietUsage = atom({ plugin: 'topia', key: 'quietUsage' } as const, null)
31// Set once the preset has been applied, so a hot reload (which fires
32// session.start again) never switches the model mid-session.
33const modelPresetApplied = atom({ plugin: 'topia', key: 'modelPresetApplied' } as const, false)
34
35const ORDERS_PANE = 'topia-standing-orders'
36const HOSTS_PANE = 'topia-remote-hosts'
37const ACTIVITY_PANE = 'topia-activity'
38
39// Module state: a reload clears it and each mod sets its slot again from
40// session.start.
41const statusSlots: Partial<Record<StatusSlot, string>> = {}
42
43function setStatus($: EngineInterface, slot: StatusSlot, text: string | undefined) {
44 if (text === undefined || text.length === 0) delete statusSlots[slot]
45 else statusSlots[slot] = text
46 $.ui.status(joinStatus(statusSlots))
47}
48
49// ── git band ────────────────────────────────────────────────────────────────
50
51async function refreshGit($: EngineInterface) {
52 const cwd = await $.session.cwd()
53 let next: GitInfo
54 try {
55 const ran = await $.process.run(['git', 'status', '--porcelain=v1', '--branch'], { cwd, timeoutMs: 10_000 })
56 next = ran.exitCode === 0 ? parseStatus(ran.stdout) : NOT_A_REPO
57 } catch {
58 next = NOT_A_REPO
59 }
60 await update($, gitInfo, () => next)
61}
62
63// ── standing orders ─────────────────────────────────────────────────────────
64
65async function loadOrders($: EngineInterface) {
66 const saved = (await $.store.get(ordersKey(await $.session.root()))) as Partial<Orders> | undefined
67 const merged: Orders = { ...DEFAULTS, ...(saved ?? {}) }
68 await update($, standingOrders, () => merged)
69 setStatus($, 'orders', ordersStatus(merged))
70 return merged
71}
72
73async function saveOrders($: EngineInterface, next: Orders) {
74 await $.store.set(ordersKey(await $.session.root()), next)
75 await update($, standingOrders, () => next)
76 setStatus($, 'orders', ordersStatus(next))
77}
78
79// Reads the latest value at press time, never the one a drawing captured.
80async function toggleOrder($: EngineInterface, id: OrderId) {
81 const latest = (await read($, standingOrders)) ?? DEFAULTS
82 await saveOrders($, { ...latest, [id]: !latest[id] })
83}
84
85// ── remote hosts ────────────────────────────────────────────────────────────
86
87async function loadHosts($: EngineInterface) {
88 const saved = ((await $.store.get(hostsKey(await $.session.root()))) ?? {}) as Record<string, Host>
89 await update($, sshHosts, () => saved)
90 return saved
91}
92
93async function forgetHost($: EngineInterface, id: string) {
94 const latest = { ...(await loadHosts($)) }
95 delete latest[id]
96 await $.store.set(hostsKey(await $.session.root()), latest)
97 await update($, sshHosts, () => latest)
98}
99
100async function learnFrom($: EngineInterface, targets: SshTarget[]) {
101 const today = new Date(await $.clock.now()).toISOString().slice(0, 10)
102 const { known, learned } = learnHosts(await loadHosts($), targets, today)
103 await $.store.set(hostsKey(await $.session.root()), known)
104 await update($, sshHosts, () => known)
105 for (const id of learned) {
106 $.ui.toast(`topia: learned ssh host ${id}${known[id]?.key ? ' (key)' : ''}`)
107 }
108}
109
110// ── quiet reports ───────────────────────────────────────────────────────────
111
112async function refreshUsage($: EngineInterface, cachePercent?: number) {
113 const now = await $.session.usage()
114 const previous = await read($, quietUsage)
115 const line: UsageLine = {
116 contextPercent: now.context.percent,
117 cachePercent: cachePercent ?? previous?.cachePercent,
118 usd: now.cost?.usd,
119 fiveHourPercent: now.rateLimits.find(limit => limit.kind === 'five_hour')?.percentUsed,
120 }
121 await update($, quietUsage, () => line)
122 setStatus($, 'usage', formatUsage(line, await read($, quietSavedChars)))
123}
124
125// ── model preset ────────────────────────────────────────────────────────────
126
127async function rememberModel($: EngineInterface) {
128 const root = await $.session.root()
129 const model = await $.session.model()
130 if (model && (await $.store.get(projectKey(root))) !== model) {
131 await $.store.set(projectKey(root), model)
132 }
133}
134
135async function applyModel($: EngineInterface) {
136 if (await read($, modelPresetApplied)) return
137 await update($, modelPresetApplied, () => true)
138
139 const root = await $.session.root()
140 const saved = await $.store.get(projectKey(root))
141 const current = await $.session.model()
142 if (typeof saved !== 'string' || saved === current) return
143
144 await $.command.run({ command: 'model', args: saved })
145 $.ui.toast(`topia: ${projectName(root)} last used ${saved}; switched (was ${current}).`, { timeoutMs: 8000 })
146}
147
148// ── hooks ───────────────────────────────────────────────────────────────────
149
150export const register: Register = (on, options) => {
151 const git = options.gitBand !== false
152 const orders = options.standingOrders !== false
153 const hosts = options.remoteHosts === true
154 const quiet = options.quietReports !== false
155 const preset = options.modelPreset !== false
156
157 on('session.start', async ($, e, next) => {
158 if (git) void refreshGit($)
159 if (orders) {
160 await $.command.register({
161 name: 'orders',
162 description: 'Show or toggle your standing orders for this project',
163 argumentHint: '[fanout|ledger|scripts|commit] [on|off]',
164 })
165 await loadOrders($)
166 }
167 if (hosts) {
168 await $.command.register({
169 name: 'hosts',
170 description: 'Show the SSH hosts the model knows about; forget one',
171 argumentHint: '[forget <user@host>]',
172 })
173 await loadHosts($)
174 }
175 if (quiet) {
176 await $.command.register({
177 name: 'topia-activity',
178 description: "Show the Topia hook reports kept out of the model's context",
179 })
180 void refreshUsage($)
181 }
182 if (preset) {
183 await $.command.register({
184 name: 'model-preset',
185 description: 'Show or forget the model remembered for this project',
186 argumentHint: '[forget]',
187 })
188 }
189
190 const started = await next(e)
191 if (preset && e.isInteractive) void applyModel($)
192
193 return started
194 })
195
196 on('session.end', async ($, e, next) => {
197 if (preset) await rememberModel($)
198
199 return next(e)
200 })
201
202 on('tool.call', async ($, e, next) => {
203 const ran = await next(e)
204 const tool = String(e.tool)
205 if (git && WRITING_TOOLS.has(tool)) void refreshGit($)
206
207 const command = (e as { command?: unknown }).command
208 const succeeded = ran.deny === undefined && ran.isError !== true && !FAILED.test(ran.text ?? '')
209 if (hosts && succeeded && (tool === 'Bash' || tool === 'PowerShell') && typeof command === 'string') {
210 // The remote command may hold `;` or `|` inside quotes, which the segment
211 // split cuts, so sudo is judged on the whole line.
212 const targets = parseSsh(command).map(target => ({ ...target, remote: command }))
213 if (targets.length > 0) await learnFrom($, targets)
214 }
215
216 return ran
217 })
218
219 on('turn.complete', async ($, e, next) => {
220 if (git) void refreshGit($)
221 const result = await next(e)
222 if (e.agentId === undefined) {
223 if (quiet) {
224 const turn = e.usage
225 const input = turn ? turn.input_tokens + turn.cache_read_input_tokens + turn.cache_creation_input_tokens : 0
226 void refreshUsage($, turn && input > 0 ? (turn.cache_read_input_tokens / input) * 100 : undefined)
227 }
228 // The /model picker settles after its command returns, so the choice is
229 // read again once each main-thread turn ends, and when the session ends.
230 if (preset) await rememberModel($)
231 }
232
233 return result
234 })
235
236 on('prompt.compose', async ($, e, next) => {
237 const composed = await next(e)
238 const sections = [...composed.sections]
239
240 const current = orders ? await read($, standingOrders) : null
241 const rules = current === null ? null : ordersText(current)
242 if (rules !== null) sections.push({ id: 'topia:standing-orders', text: rules, scope: 'session' as const })
243
244 const known = hosts ? hostsText(await read($, sshHosts)) : null
245 if (known !== null) sections.push({ id: 'topia:known-hosts', text: known, scope: 'session' as const })
246
247 return sections.length === composed.sections.length ? composed : { sections }
248 })
249
250 on('prompt.attachment', async ($, e, next) => {
251 if (!quiet || e.origin.kind !== 'hook') return next(e)
252
253 const filtered = filterHookText(e.text)
254 if (filtered === null) return next(e)
255
256 const removed = e.text.length - (filtered.text?.length ?? 0)
257 const report: HiddenReport = {
258 event: e.origin.event,
259 kind: filtered.kind,
260 chars: removed,
261 at: await $.clock.now(),
262 text: e.text.slice(0, 4000),
263 }
264 await update($, quietHidden, list => [...list, report].slice(-30))
265 await update($, quietSavedChars, total => total + removed)
266
267 return { text: filtered.text }
268 })
269
270 // ── git band ──
271
272 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
273 if (!git) return next(e)
274 const info = await read($, gitInfo)
275 const isClean = info === null || !info.isRepo || (info.changed === 0 && info.unpushed === 0)
276
277 if (e.props.hasSurvey || isClean || (await read($, gitBandHidden))) {
278 return next(e)
279 }
280
281 const { Box, Button, Text } = $.ui.resolve(e)
282 const submit = (text: string) => () => $.prompt.submit({ text, asUser: true })
283
284 return (
285 <Box flexDirection="row" gap={1}>
286 <Text color={info.changed > 0 ? 'yellow' : 'cyan'}>
287 ⎇ {info.branch} · {bandParts(info).join(' · ')}
288 </Text>
289 {!e.props.isWorking && info.changed > 0 && (
290 <Button key="commit" label="Commit" hotkey="c" onPress={submit(COMMIT)} />
291 )}
292 {!e.props.isWorking && info.changed > 0 && (
293 <Button key="commit-push" label="Commit & push" hotkey="p" variant="primary" onPress={submit(COMMIT_PUSH)} />
294 )}
295 {!e.props.isWorking && info.changed === 0 && info.unpushed > 0 && (
296 <Button key="push" label="Push" hotkey="p" variant="primary" onPress={submit(PUSH)} />
297 )}
298 <Button key="hide" label="Hide" plain onPress={() => update($, gitBandHidden, () => true)} />
299 </Box>
300 )
301 })
302
303 // ── standing orders ──
304
305 on('command.run', { command: 'orders' }, async ($, e, next) => {
306 if (!orders) return next(e)
307 const current = (await read($, standingOrders)) ?? (await loadOrders($))
308 const [id, value] = e.args.trim().toLowerCase().split(/\s+/)
309
310 if (!id) {
311 await $.ui.open({ id: ORDERS_PANE, title: 'Standing orders', focus: true, closeOnEscape: true })
312 return { text: 'Standing orders pane opened.' }
313 }
314 if (!IDS.includes(id as OrderId)) {
315 return { text: `Unknown order "${id}". Orders: ${IDS.join(', ')}.` }
316 }
317
318 const key = id as OrderId
319 const turnOn = value === 'on' ? true : value === 'off' ? false : !current[key]
320 await saveOrders($, { ...current, [key]: turnOn })
321
322 return { text: `Standing order "${key}" is now ${turnOn ? 'on' : 'off'} for this project.` }
323 })
324
325 on('ui.render', { component: 'Pane', requestId: ORDERS_PANE }, async ($, e) => {
326 const { Box, Button, Text } = $.ui.resolve(e)
327 const current = (await read($, standingOrders)) ?? DEFAULTS
328
329 return (
330 <Box flexDirection="column">
331 <Text dimColor>Added to the system prompt for this project. Press to toggle.</Text>
332 {IDS.map((id, index) => (
333 <Box key={`row-${id}`} flexDirection="row" gap={1}>
334 <Button
335 key={id}
336 hotkey={String(index + 1)}
337 label={current[id] ? 'on ' : 'off'}
338 variant={current[id] ? 'primary' : 'secondary'}
339 onPress={() => toggleOrder($, id)}
340 />
341 <Text dimColor={!current[id]}>{RULES[id].label}</Text>
342 </Box>
343 ))}
344 </Box>
345 )
346 })
347
348 // ── remote hosts ──
349
350 on('command.run', { command: 'hosts' }, async ($, e, next) => {
351 if (!hosts) return next(e)
352 const [verb, id] = e.args.trim().split(/\s+/)
353 if (verb === 'forget' && id) {
354 const known = await loadHosts($)
355 if (!known[id]) return { text: `No host "${id}". Known: ${Object.keys(known).join(', ') || 'none'}.` }
356 await forgetHost($, id)
357 return { text: `Forgot ${id}.` }
358 }
359
360 await $.ui.open({ id: HOSTS_PANE, title: 'Known SSH hosts', focus: true, closeOnEscape: true })
361 return { text: 'Known SSH hosts pane opened.' }
362 })
363
364 on('ui.render', { component: 'Pane', requestId: HOSTS_PANE }, async ($, e) => {
365 const { Box, Button, Text } = $.ui.resolve(e)
366 const list = Object.entries(await read($, sshHosts)).sort(([, a], [, b]) => b.lastOk.localeCompare(a.lastOk))
367
368 return (
369 <Box flexDirection="column">
370 {list.length === 0 && <Text dimColor>No hosts yet. They are learned from ssh commands that succeed.</Text>}
371 {list.map(([id, h]) => (
372 <Box key={`row-${id}`} flexDirection="row" gap={1}>
373 <Button key={`forget-${id}`} label="Forget" plain onPress={() => forgetHost($, id)} />
374 <Text bold>{id}</Text>
375 <Text dimColor>
376 {h.key ? 'key' : 'no key'} · {h.sudoOk ? 'sudo ✓' : 'sudo ?'} · {h.uses} uses · last {h.lastOk}
377 </Text>
378 </Box>
379 ))}
380 </Box>
381 )
382 })
383
384 // ── quiet reports ──
385
386 on('command.run', { command: 'topia-activity' }, async ($, e, next) => {
387 if (!quiet) return next(e)
388 await $.ui.open({ id: ACTIVITY_PANE, title: 'Topia activity (hidden from the model)', focus: true, closeOnEscape: true })
389
390 return { text: 'Topia activity pane opened.' }
391 })
392
393 on('ui.render', { component: 'Pane', requestId: ACTIVITY_PANE }, async ($, e) => {
394 const { Box, Markdown, Text } = $.ui.resolve(e)
395 const list = await read($, quietHidden)
396 const saved = await read($, quietSavedChars)
397 const counts = { 'session-report': 0, 'guardian-advisory': 0, 'install-banner': 0 }
398 for (const one of list) counts[one.kind] += 1
399 const lastReport = [...list].reverse().find(one => one.kind === 'session-report')
400
401 return (
402 <Box flexDirection="column">
403 <Text>
404 Kept out of context this session: {(saved / 1000).toFixed(1)}k chars (~{Math.round(saved / 4)} tokens)
405 </Text>
406 <Text dimColor>
407 {counts['session-report']} session reports · {counts['guardian-advisory']} guardian advisories ·{' '}
408 {counts['install-banner']} install/migration banners
409 </Text>
410 {lastReport === undefined
411 ? <Text dimColor>No Topia session report yet this session.</Text>
412 : <Markdown key="report" text={lastReport.text} />}
413 </Box>
414 )
415 })
416
417 // ── model preset ──
418
419 on('command.run', { command: 'model' }, async ($, e, next) => {
420 const ran = await next(e)
421 if (preset && e.args.trim().length > 0) await rememberModel($)
422
423 return ran
424 })
425
426 on('command.run', { command: 'model-preset' }, async ($, e, next) => {
427 if (!preset) return next(e)
428 const root = await $.session.root()
429 if (e.args.trim() === 'forget') {
430 await $.store.delete(projectKey(root))
431 return { text: `Forgot the model for ${projectName(root)}.` }
432 }
433 const saved = await $.store.get(projectKey(root))
434
435 return {
436 text: typeof saved === 'string'
437 ? `${projectName(root)} starts on ${saved}. /model to change it; /model-preset forget to stop.`
438 : `No model remembered for ${projectName(root)} yet; the next one you use is kept.`,
439 }
440 })
441}
442mods/lib/git-band.ts 44 lines1import type { GitInfo } from '../types'
2
3export const WRITING_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash', 'PowerShell'])
4
5export const COMMIT = 'Commit the current changes with a descriptive message.'
6export const COMMIT_PUSH = 'Commit the current changes with a descriptive message, then push to the remote.'
7export const PUSH = 'Push the unpushed commits on this branch to the remote.'
8
9export const NOT_A_REPO: GitInfo = { isRepo: false, branch: '', changed: 0, unpushed: 0, behind: 0, hasUpstream: false }
10
11// `git status --porcelain=v1 --branch` → counts. The first line is the branch
12// header: `## main...origin/main [ahead 2, behind 1]`, `## main` (no upstream),
13// `## No commits yet on main`, or `## HEAD (no branch)`.
14export function parseStatus(stdout: string): GitInfo {
15 const lines = stdout.split(/\r?\n/).filter(line => line.length > 0)
16 const header = lines[0]?.startsWith('## ') ? lines[0].slice(3) : ''
17 const changed = lines.filter(line => !line.startsWith('## ')).length
18
19 let branch = header
20 let hasUpstream = false
21 const noCommits = /^No commits yet on (.+)$/.exec(header)
22 if (noCommits) {
23 branch = noCommits[1] ?? header
24 } else {
25 const split = header.split('...')
26 branch = (split[0] ?? header).split(' ')[0] ?? header
27 hasUpstream = split.length > 1
28 }
29
30 const ahead = Number(/ahead (\d+)/.exec(header)?.[1] ?? 0)
31 const behind = Number(/behind (\d+)/.exec(header)?.[1] ?? 0)
32
33 return { isRepo: true, branch, changed, unpushed: ahead, behind, hasUpstream }
34}
35
36export function bandParts(git: GitInfo): string[] {
37 return [
38 git.changed > 0 ? `${git.changed} changed` : '',
39 git.unpushed > 0 ? `${git.unpushed} unpushed` : '',
40 git.behind > 0 ? `${git.behind} behind` : '',
41 git.hasUpstream ? '' : 'no upstream',
42 ].filter(part => part.length > 0)
43}
44mods/lib/model-preset.ts 8 lines1export function projectKey(root: string) {
2 return `model:${root.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()}`
3}
4
5export function projectName(root: string) {
6 return root.replace(/\\/g, '/').replace(/\/+$/, '').split('/').pop() ?? root
7}
8mods/lib/remote-hosts.ts 105 lines1import type { Host, SshTarget } from '../types'
2
3// ssh options that take a value (`-i key`, or glued: `-p22`, `-oBatchMode=yes`).
4const VALUE_OPTS = new Set('BbcDEeFIiJLlmOoPpQRSWw'.split(''))
5export const FAILED = /Permission denied \(|Could not resolve hostname|Connection (timed out|refused|closed)|Host key verification failed|No route to host/i
6
7function tokenize(segment: string): string[] {
8 const tokens: string[] = []
9 for (const match of segment.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) {
10 tokens.push(match[1] ?? match[2] ?? match[3] ?? '')
11 }
12 return tokens
13}
14
15// Every `ssh … destination [command]` in a shell command line.
16export function parseSsh(command: string): SshTarget[] {
17 const targets: SshTarget[] = []
18 for (const segment of command.split(/\r?\n|;|&&|\|\||(?<![|])\|(?![|])/)) {
19 const tokens = tokenize(segment)
20 const start = tokens.findIndex(token => /(^|[\\/])ssh(\.exe)?$/i.test(token))
21 if (start < 0) continue
22
23 let key: string | undefined
24 let port: string | undefined
25 let user: string | undefined
26 let index = start + 1
27 for (; index < tokens.length; index++) {
28 const token = tokens[index] ?? ''
29 if (!token.startsWith('-') || token === '-') break
30 const flag = token[1] ?? ''
31 if (!VALUE_OPTS.has(flag)) continue
32 const value = token.length > 2 ? token.slice(2) : tokens[++index] ?? ''
33 if (flag === 'i') key = value
34 if (flag === 'p') port = value
35 if (flag === 'l') user = value
36 if (flag === 'o' && /^user=/i.test(value)) user = value.slice(5)
37 if (flag === 'o' && /^port=/i.test(value)) port = value.slice(5)
38 }
39
40 const destination = (tokens[index] ?? '').replace(/^ssh:\/\//, '')
41 if (!destination || /[$`{}<>]/.test(destination)) continue
42 const at = destination.lastIndexOf('@')
43 const hostPort = at >= 0 ? destination.slice(at + 1) : destination
44 if (at >= 0) user = destination.slice(0, at)
45 const [host = '', glued] = hostPort.split(':')
46 if (!/^[A-Za-z0-9][A-Za-z0-9.\-]*$/.test(host)) continue
47 targets.push({ host, user, key, port: glued ?? port, remote: tokens.slice(index + 1).join(' ') })
48 }
49 return targets
50}
51
52// Hosts stay with the project they were learned in: one store key per
53// project root, so another repo never sees them.
54export function hostsKey(root: string) {
55 return `hosts:${root.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()}`
56}
57
58export function hostId(target: { host: string; user?: string }) {
59 return target.user ? `${target.user}@${target.host}` : target.host
60}
61
62export function composeText(known: Record<string, Host>): string | null {
63 const list = Object.entries(known)
64 .sort(([, a], [, b]) => b.lastOk.localeCompare(a.lastOk))
65 .slice(0, 15)
66 if (list.length === 0) return null
67
68 return [
69 '# Known SSH hosts',
70 'Learned from ssh commands that worked in earlier sessions of this project. Connect with these directly; do not ask the user ' +
71 'for connection details or run probe connections first. If one fails, say so and ask before guessing another.',
72 ...list.map(([id, h]) => {
73 const parts = [
74 `ssh${h.key ? ` -i "${h.key}"` : ''}${h.port ? ` -p ${h.port}` : ''} ${id}`,
75 h.sudoOk ? 'sudo worked non-interactively' : 'sudo untested',
76 `last worked ${h.lastOk}`,
77 ]
78 return `- ${parts.join(' — ')}`
79 }),
80 ].join('\n')
81}
82
83// Folds ssh targets that just worked into the known hosts; returns the new
84// map and the ids seen for the first time.
85export function learnHosts(known: Record<string, Host>, targets: SshTarget[], today: string) {
86 const next = { ...known }
87 const learned: string[] = []
88 for (const target of targets) {
89 const id = hostId(target)
90 const before = next[id]
91 if (!before) learned.push(id)
92 next[id] = {
93 host: target.host,
94 user: target.user,
95 key: target.key ?? before?.key,
96 port: target.port ?? before?.port,
97 sudoOk: (before?.sudoOk ?? false) || /\bsudo\b/.test(target.remote),
98 uses: (before?.uses ?? 0) + 1,
99 firstSeen: before?.firstSeen ?? today,
100 lastOk: today,
101 }
102 }
103 return { known: next, learned }
104}
105mods/lib/standing-orders.ts 57 lines1import type { OrderId, Orders } from '../types'
2
3// Off until the user turns one on with /orders: these change how the model works.
4export const DEFAULTS: Orders = { fanout: false, ledger: false, scripts: false, commit: false }
5
6export const RULES: Record<OrderId, { label: string; text: string }> = {
7 fanout: {
8 label: 'Fan out independent work to parallel subagents',
9 text:
10 'When work splits into independent parts (several files, modules, features or investigations), ' +
11 'fan it out to parallel subagents instead of working through it serially. Keep shared decisions ' +
12 'and the final integration in the main thread.',
13 },
14 ledger: {
15 label: 'End tasks with a skipped / deferred ledger',
16 text:
17 'End every task that changes code or docs with a short "Skipped / deferred" list: each item not done, ' +
18 'why, and the file path where it is recorded for later. Record deferred items in the project\'s existing ' +
19 'TODO or plan doc (create docs/TODO.md if there is none). If nothing was skipped, say so in one line.',
20 },
21 scripts: {
22 label: 'Hand-offs are one logged script with yes/no confirms',
23 text:
24 'When the user has to run something themselves (another machine, a server, an install), hand it over ' +
25 'as one script, not steps to copy and paste. The script says up front what it will do, prints clear ' +
26 'progress, writes a timestamped log file and prints its path so the user can hand it back for review, ' +
27 'and asks for an explicit yes/no before anything destructive.',
28 },
29 commit: {
30 label: 'Commit and push when a task is done',
31 text:
32 'When a task is finished and its checks pass, commit the changes with a descriptive message and push ' +
33 'to the current branch\'s remote, unless the user said not to.',
34 },
35}
36
37export const IDS = Object.keys(RULES) as OrderId[]
38
39export function ordersKey(root: string) {
40 return `orders:${root.replace(/\\/g, '/').toLowerCase()}`
41}
42
43export function ordersStatus(current: Orders): string | undefined {
44 const on = IDS.filter(id => current[id])
45 return on.length > 0 ? `orders: ${on.join('·')}` : undefined
46}
47
48export function composeText(current: Orders): string | null {
49 const on = IDS.filter(id => current[id])
50 if (on.length === 0) return null
51 return [
52 '# Standing orders from the user',
53 'The user has asked for these in every session of this project; follow them without being asked again.',
54 ...on.map(id => `- ${RULES[id].text}`),
55 ].join('\n')
56}
57mods/lib/status.ts 10 lines1// A plugin has one status line, so each mod owns a slot in it, joined in a
2// fixed order.
3export const STATUS_SLOTS = ['orders', 'usage'] as const
4export type StatusSlot = (typeof STATUS_SLOTS)[number]
5
6export function joinStatus(current: Partial<Record<StatusSlot, string>>): string | undefined {
7 const text = STATUS_SLOTS.map(slot => current[slot] ?? '').filter(part => part.length > 0).join(' · ')
8 return text.length > 0 ? text : undefined
9}
10mods/lib/topia-quiet.ts 35 lines1import type { HiddenReport, UsageLine } from '../types'
2
3// The blocks Topia's settings hooks print (hooks/session-start/index.cjs,
4// hooks/lib/session-report.cjs, compiler/commands/hook-dispatch.js).
5const RUNE_BLOCK = /\n?=== topia: Rune migration recommended ===[\s\S]*?migrate-from-rune --skip\n*/
6const INSTALL_MENU = /\n?\s*╭─+╮\s*\n\s*│\s*Topia Step 1 done[\s\S]*?auto-checks itself the next session\.\)\n*/
7const SESSION_REPORT = /<summary>Session activity<\/summary>|^Topia · \d+ skills? · /m
8const GUARDIAN = /^\s*Topia-hook: [\w:-]+ \[advisory\] — tool=\S+\s*$/
9
10export type Filtered = { text: string | null; kind: HiddenReport['kind'] } | null
11
12// What the model should read instead of a settings hook's text; null when
13// the text is none of Topia's noise and passes through unchanged.
14export function filterHookText(text: string): Filtered {
15 if (SESSION_REPORT.test(text)) return { text: null, kind: 'session-report' }
16 if (GUARDIAN.test(text)) return { text: null, kind: 'guardian-advisory' }
17
18 const stripped = text.replace(RUNE_BLOCK, '\n').replace(INSTALL_MENU, '\n')
19 if (stripped === text) return null
20
21 const kept = stripped.replace(/\n{3,}/g, '\n\n').trim()
22 return { text: kept.length > 0 ? kept : null, kind: 'install-banner' }
23}
24
25export function formatUsage(line: UsageLine, saved: number): string {
26 const parts = [
27 line.contextPercent !== undefined ? `ctx ${Math.round(line.contextPercent)}%` : '',
28 line.cachePercent !== undefined ? `cache ${Math.round(line.cachePercent)}%` : '',
29 line.usd !== undefined ? `$${line.usd.toFixed(2)}` : '',
30 line.fiveHourPercent !== undefined ? `5h ${Math.round(line.fiveHourPercent)}%` : '',
31 saved > 0 ? `topia −${(saved / 1000).toFixed(1)}k chars` : '',
32 ]
33 return parts.filter(part => part.length > 0).join(' · ')
34}
35mods/types/index.d.ts 58 lines1// State contract for Topia's Claude Code mods (mods/*.tsx). Every value is
2// held under the `topia` plugin, so each key carries its mod's prefix.
3
4export type GitInfo = {
5 isRepo: boolean
6 branch: string
7 changed: number
8 unpushed: number
9 behind: number
10 hasUpstream: boolean
11}
12
13export type OrderId = 'fanout' | 'ledger' | 'scripts' | 'commit'
14export type Orders = Record<OrderId, boolean>
15
16export type Host = {
17 host: string
18 user?: string
19 key?: string
20 port?: string
21 sudoOk: boolean
22 uses: number
23 firstSeen: string
24 lastOk: string
25}
26
27export type SshTarget = { host: string; user?: string; key?: string; port?: string; remote: string }
28
29export type HiddenReport = {
30 event: string
31 kind: 'session-report' | 'guardian-advisory' | 'install-banner'
32 chars: number
33 at: number
34 text: string
35}
36
37export type UsageLine = {
38 contextPercent?: number
39 cachePercent?: number
40 usd?: number
41 fiveHourPercent?: number
42}
43
44declare module 'claude-code' {
45 interface PluginState {
46 topia: {
47 gitInfo: GitInfo | null
48 gitBandHidden: boolean
49 standingOrders: Orders | null
50 sshHosts: Record<string, Host>
51 quietHidden: HiddenReport[]
52 quietSavedChars: number
53 quietUsage: UsageLine | null
54 modelPresetApplied: boolean
55 }
56 }
57}
58