SLOPSHOPPER

Topia

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

newpanebandguardcommandtoast
★ 1v3.8.0MITupdated 2026-10-05linenoize/topia
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · topia
│ ┃ Standing orders ✕ › fix the failing auth test and add an audit log call │ ┃ Added to the system prompt for this project. │ ┃ Press to toggle. ⏺ Read(src/auth.ts) │ ┃ [ off ] Fan out independent work to parallel ⎿ Read 6 lines │ ┃ subagents ⏺ Update(src/auth.ts) │ ┃ [ off ] End tasks with a skipped / deferred ⎿ Added 2 lines, removed 1 line │ ┃ [ off ] Hand-offs are one logged script with ⏺ Bash(bun test) │ ┃ confirms ⎿ 3 pass, 1 fail │ ┃ [ off ] Commit and push when a task is done │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /orders │ ⎿ topia: Standing orders pane opened. │ │ ⎇ · 2 changed · no upstream [ Commit ] [ Commit & push ] Hide ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ topia: ctx 49% · cache 93% · $0.42 · 5h 31%

Draws

Band
⎇ · 2 changed · no upstream [ Commit ] [ Commit & push ] Hide
Pane · Standing orders
Added to the system prompt for this project. Press to toggle. [ off ] Fan out independent work to parallel subagents [ off ] End tasks with a skipped / deferred ledger [ off ] Hand-offs are one logged script with yes/no confirms [ off ] Commit and push when a task is done
Pane · Topia activity (hidden from the model)
Kept out of context this session: 0.0k chars (~0 tokens) 0 session reports · 0 guardian advisories · 0 install/migration banners No Topia session report yet this session.
Pane · topia-remote-hosts
No hosts yet. They are learned from ssh commands that succeed.
README

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


What Topia is

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:

  • Plan before code — idea elicits requirements, plan writes phase files, adversary red-teams the plan, all before a line of code.
  • Tests before commits — test writes failing tests first (red), fix implements until green. TDD enforced, not suggested.
  • Hooks block bad work — guardian (secrets, OWASP), readiness (logic, regressions), completion-gate (validates agent claims have evidence). Auto-fire on tool use.
  • Memory survives sessions — 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.


Install (Claude Code)

Full guide: docs/INSTALL.md. Teams: see Team policy below.

Step 1 — Install the plugin (in chat, no terminal)

/plugin marketplace add linenoize/topia
/plugin install topia@linenoize

Restart Claude Code if /topia:build does not appear.

Marketplace pathWorks?
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.

Step 2 — Finalize (in chat, recommended)

/topia finalize

Run this once per machine (and again when you want to change hooks or org policy). It enables:

What finalize addsWhy 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

Per project (each repo you work in)

Run in that project's Claude Code session:

StepCommandWhat you get
1/topia onboardCLAUDE.md + .topia/ context so every session starts with codebase knowledge
2/topia org-configTeam policy in .topia/org/org.md (also offered during finalize)
3/topia doctorVerify install and nexus health

Teams: commit .topia/org/ to git so every teammate and agent shares the same gates. See docs/ORG-CONFIG.md.

Team policy (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.

  • Set up in chat: /topia org-config (structured interview) or during /topia finalize
  • Edit later: change org.md, then refresh hooks (/topia finalize or topia setup --global)

Other IDEs (Cursor, Codex, Windsurf, …)

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)

Contributors / terminal install

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)

Verify (terminal)

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.

About agora-code MCP

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.

Coming from rune-kit?

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.


Use

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 catalog

Core development & workflow

SkillPurpose
buildL1 orchestrator — full TDD cycle (Understand → Plan → Test → Implement → Verify → Commit). Default route for most code tasks.
rescueL1 orchestrator — multi-session legacy refactor with safety nets.
scaffoldL1 orchestrator — bootstrap a new project from a description.
teamL1 orchestrator — decompose into parallel workstreams with worktree isolation.
launchL1 orchestrator — deploy + verify + announce.
fixApply code changes from diagnosis or review findings.
debugRoot-cause analysis; hands off to fix.
testTDD test writer — red first, green after.
scoutFast read-only codebase scanner.
verificationRun lint + type-check + tests + build.
dbMigrations, rollbacks, query validation.
gitSemantic commits, PR bodies, branch naming.
integratePort features from external GitHub repos.
surgeonIncremental refactor (Strangler Fig, Branch by Abstraction).
safeguardCharacterization tests + rollback markers before risky refactors.
improve-architectureFind friction; propose deepening opportunities.
mcp-builderGenerate MCP servers from spec.

Security, governance, infrastructure

SkillPurpose
guardianPre-commit security gate — OWASP, secrets, deps.
readinessPre-commit quality gate — logic, regressions, completeness.
guardian-envOS + runtime + tools + ports + env-var check before work starts.
sastStatic-analysis wrapper (ESLint, Semgrep, Bandit, Clippy).
adversaryPre-implementation red-team analysis on high-risk plans.
logic-guardianProtects business logic from accidental deletion.
quarantineAdvisory on untrusted MCP / WebFetch / upload content.
hallucination-guardCatch phantom imports, non-existent packages.
deployMulti-platform deploy with health checks.
watchdogPost-deploy health monitoring.
incidentProduction incident response (triage → contain → root-cause → postmortem).
dependency-doctorOutdated packages + CVE scan + prioritized update plan.
audit8-dimension project health audit.
perfPerformance regression gate (N+1, sync-in-async, bundle bloat).

Knowledge, research, strategy

SkillPurpose
researchWeb search for technologies + best practices.
docsAuto-generate + maintain README, API, architecture docs.
docs-seekerLocate API references, changelogs, migration guides.
documentationLeadership-ready packages, user stories, Jira CSV.
onboardGenerate CLAUDE.md + .topia/ context for a new project.
brainstormGenerate 2-3 approaches with trade-offs.
designDesign-system generator (palette, typography, anti-patterns).
problem-solver19 analytical frameworks + 12 bias detectors.
sequential-thinkingMulti-variable analysis with dependency ordering.
journalADRs, decisions, progress across sessions.
neural-memoryCross-session recall via semantic graph (uses agora-code MCP when registered).
trend-scoutMarket intelligence (Product Hunt, GitHub Trending, HN, Reddit).
autopsyHealth assessment of legacy codebases (rescue RECON).
doc-processorGenerate / parse PDF, DOCX, XLSX, PPTX, CSV.

Planning & management

SkillPurpose
planMaster plan + phase files for multi-phase features.
ideaRequirements elicitation — 5-question gate, cross-session memory.
reviewCode review with file:line findings.
review-intakeProcess external PR comments / issue triage.
retroEngineering retrospective on commit history.
scope-guardDetect + quantify scope creep.
context-packBundle context for sub-agent delegation.
completion-gateValidate agent claims against evidence trail.

Creative & specialized

SkillPurpose
asset-creatorSVG icons, OG images, social banners.
marketingLanding copy, SEO meta, blog posts, video scripts.
slidesMarp-compatible decks from JSON schema.
video-creatorVideo plans — scripts, storyboards, asset checklists.
browser-pilotPlaywright automation + a11y audit.

Internal toolkit primitives

SkillPurpose
skill-routerL0 — routes every action to the right skill.
skill-forgeBuild + verify new Topia skills.
context-engineContext-window management + compaction.
session-bridgeCross-session state persistence.
worktreeGit worktree lifecycle for parallel streams.
integrity-checkDetect adversarial content in .topia/ files.
constraint-checkValidate that HARD-GATEs were actually followed.

Full catalog with invocation markers (👤 user / 🔄 either / 🤖 agent): docs/SKILLS.md.


Extension packs

Install what you need; each pack adds 3–8 domain-specific skills that plug into the core toolkit.

PackFocus
@Topia/uiDesign systems, accessibility, animation, React patterns
@Topia/backendAPI, auth, DB, middleware
@Topia/mobileReact Native, Flutter, app store
@Topia/devopsDocker, CI/CD, SSL, monitoring
@Topia/securityPentest, supply chain, API hardening
@Topia/ecommerceShopify, payments, cart, inventory
@Topia/ai-mlLLM, RAG, embeddings
@Topia/contentBlog, CMS, MDX, i18n, SEO
@Topia/analyticsTracking, A/B, funnels
@Topia/chrome-extManifest V3, service workers

Auto-discipline hooks

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
EventSkillFires
`PreToolUse(Edit\Write)`readinessBefore source-file edits
PreToolUse(Bash)guardianBefore shell commands
`PostToolUse(Edit\Write)`dependency-doctorAfter manifest edits
Stopcompletion-gateEnd of session

Architecture

Five layers, each with one responsibility:

LayerRoleCount
L0 RouterRoutes every action1
L1 OrchestratorsFull lifecycle workflows5 (build, team, launch, rescue, scaffold)
L2 Workflow HubsCross-hub coordination — the differentiator~30
L3 UtilitiesStateless, pure capabilities27
L4 ExtensionsDomain-specific packs10

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.


Cross-session state

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

Project .gitignore

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.

L4 packs: shipped vs activated

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.


Reference

DocContents
docs/INSTALL.mdInstall hub — Claude, Cursor, hybrid
docs/INSTALL-CLAUDE-CODE.mdClaude Code plugin install
docs/INSTALL-NON-CLAUDE.mdCursor, Codex, Windsurf, etc.
docs/GETTING_STARTED.mdFirst 5 minutes
docs/SKILLS.mdFull skill catalog with invocation markers
docs/SKILL-CATEGORIES.mdSkill taxonomy reference
docs/ARCHITECTURE.md5-layer architecture details
docs/PULSES.mdPulse inventory + emit/listen graph
docs/HOOKS.mdHook reference per platform
docs/mcp-integrations/agora-code.mdPersistent-memory MCP integration
docs/migration/from-rune.mdMigrating from rune-kit
docs/TROUBLESHOOTING.mdCommon issues + fixes
docs/VISION.mdStrategic positioning + skill-addition filter
CHANGELOG.mdRelease history
ROADTODO.mdRoadmap + outstanding work
mcp-servers/agora-code/README.mdVendored agora-code reference (Apache 2.0)

Numbers

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

Acknowledgments

  • agora-code (Apache 2.0) — vendored at mcp-servers/agora-code/ for optional persistent memory. See mcp-servers/agora-code/NOTICE-TOPIA.md for attribution + refresh procedure.
  • UI/UX Pro Max (MIT) — design-intelligence DB powering design + @Topia/ui.
  • biome (MIT) (Apache 2.0) - Installed for app use.
  • rune-kit (MIT) — Workflow, hooks, skill, methodology process grafted (integrated) into the topia operation.

License

MIT — see LICENSE. Vendored agora-code is Apache 2.0 — see mcp-servers/agora-code/LICENSE.

Source 8 files
mods/index.tsx 442 lines
1import { 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}
442
mods/lib/git-band.ts 44 lines
1import 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}
44
mods/lib/model-preset.ts 8 lines
1export 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}
8
mods/lib/remote-hosts.ts 105 lines
1import 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}
105
mods/lib/standing-orders.ts 57 lines
1import 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}
57
mods/lib/status.ts 10 lines
1// 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}
10
mods/lib/topia-quiet.ts 35 lines
1import 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}
35
mods/types/index.d.ts 58 lines
1// 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