SLOPSHOPPER

nerviz-cockpit

Draws what ArchHook already decides: the open skill phase and its write territory above the prompt, /nerviz-doctor in a pane, and a guard refusal held behind…

newpanebandguardcommandprocess
★ 3v0.1.0MITupdated 2026-10-16nerviz-ai/nerviz/.claude/mods/nerviz-cockpit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · nerviz-cockpit
│ ┃ nerviz doctor ✕ › fix the failing auth test and add an audit log call │ │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /nerviz-doctor │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<img alt="Nerviz — an agent harness for Spring Boot" src="brand/nerviz-banner-light.png" width="100%">

CI Release License: MIT PRs Welcome Java 21+ Built with Claude Code Spring Boot GitHub stars GitHub issues Last commit

An agent harness for Spring Boot on Claude Code. It generates the project from an architecture declared as data, designs every feature as an approved spec before any code is written, and blocks at write time what that architecture forbids. Generated projects carry the whole harness with them and can update it later.

Documentation: docs/en/ (English) · docs/pt-br/ (português). How it compares with similar projects, with sources: docs/en/09-differentiators.md.

What one /init-project run produced is public: nerviz-ai/bank-app, a clean-architecture-single-module project with a green build. The run took 20 minutes, a figure read from the session transcripts into its GENESIS.md.

The generated project, opened as is: it starts against the compose Postgres, and mvn clean verify passes with tests, Spotless, Checkstyle and JaCoCo.

Then the first feature, inside the generated project. /new-feature takes a one-line request, asks only the questions that change the design (who may call it, duplicates, the age rule, data retention), and has one skill per layer write its part of the spec. The spec is approved, frozen, committed and pushed before a single line of code exists. The run took 10 minutes, about 2 of them waiting for answers.

Everything it wrote is public: the approved spec, its partials, and the run's audit report. That is the first of /new-feature's two flows. The second, /new-feature-implement UC-001-create-customer after /clear, hands the approved spec to the executor agent in a clean session.

At a glance

  • Nine architectures as data, plus your own. A blueprint is a YAML file. Adding one changes no prompt.
  • Spec before code. One use case per run, designed layer by layer by an owner skill for each layer, approved by you, then frozen.
  • Blocked at write time. A forbidden import, a write outside a skill's territory, an edit to an approved spec, or a force push is refused by a hook. The model is not asked to remember the rule.
  • Versions from Spring Initializr, never from the model's memory.
  • Every run measured. Each skill and agent leaves a report with its time, tokens, questions asked and files touched.
  • Self-contained projects. A generated project carries the whole harness and its own CI, and /arch-adopt updates it later.

What this is, and what it is not

The working definition in 2026 is agent = model + harness. Claude Code supplies the model and the tool loop. This repository supplies the rest of the harness for Spring Boot work, in two parts:

  • Guides steer the model before it acts: norms (rules/), procedures and exemplars (skills/), architecture blueprints, and the use-case spec of the feature being built.
  • Sensors check what the model did, deterministically: hooks that refuse a write outside a skill's territory or a forbidden import, the incremental build, ArchUnit, a compose health gate, and a CI that validates the harness itself.

It is not a coding assistant: it brings no model and no editor. It is not an agent framework like LangGraph or Spring AI: those build agents, and this repository constrains one. And it is not a project template: it generates through Spring Initializr at run time, and its job continues after generation, on every feature.

The problem

You start a Java project with a 30-line CLAUDE.md. Six months later it is 400 lines, the model ignores half of it, and the same rule lives in three skills that no longer agree. The AI "forgets" the architecture because nothing tells it cheaply and reliably what is true in this project, and nothing stops it when it gets it wrong.

Every new project also starts from zero. The same conventions get written again, and nothing learned on the previous project survives.

The approach

Treat the AI files as production code: an explicit architecture, dependencies in one direction, one owning file per rule, and enforcement by a program rather than by the model's memory. Concretely:

  • Architecture is data. Nine blueprints (hexagonal, hexagonal-single-module, clean-architecture-multi-module, clean-architecture-single-module, layered, onion, onion-single-module, vertical-slice, modular-monolith) plus your own, each a YAML file. One forbidden_imports declaration feeds the module's CLAUDE.md, the hook that blocks the write, and the POM graph. A new architecture touches no prompt, and CI checks that.
  • Versions are never written from memory. The base project comes from start.spring.io at run time. CI fails if a Spring or Java version appears as a fact.
  • One use case per run, designed before it is built. /new-feature passes the request through one owner skill per layer and consolidates an UC-NNN-spec.md. Only after approval does a restricted executor agent write code under src/.
  • Territory is enforced, not promised. Every skill and agent has a class in schemas/extensions.json. The class says which paths it may write, and the guard hook refuses everything else, through file tools and shell commands alike. An approved spec folder is frozen.
  • Context stays cheap. The root CLAUDE.md stays under 200 lines. Norms load through paths only when a file in their territory is touched.
  • Every run is measured. In a generated project, each skill or agent invocation leaves a report with tokens and the model per piece, the call chain, files touched, and failures.

Getting started

Requirements: JDK 21+, git, curl. Nothing else: no Python, no Node, no shell scripts, no mvn on the PATH (the wrapper comes inside the Initializr's starter.tgz).

New project

A project is generated from a copy of this repository, not by copying .claude/ into an empty directory. /init-project and the blueprint catalog stay in that copy; the project receives only what it needs to develop, written by the export mode. The copy is a clone, below, or the tree the plugin fetches without one:

claude plugin marketplace add nerviz-ai/marketplace
claude plugin install nerviz@nerviz
cd ~/dev && claude        # the directory that will hold the projects

Then /nerviz:new-project: it fetches the latest release into ~/dev/nerviz-<tag>, records which tag and commit it fetched, and prints the session to open there for /init-project. The generation is the same one a clone runs. With a clone instead:

# 1. clone next to where the projects will live
cd ~/dev
git clone --depth 1 https://github.com/nerviz-ai/nerviz

# 2. confirm the tools (nothing to install); gh is only for the optional push
java --version && git --version && curl --version | head -1
gh auth status

# 3. open the session in the clone
cd nerviz
claude

Inside that session:

/arch-doctor                 # confirms enforcement is active on this machine
/init-project --blueprint hexagonal --build maven --name my-api

Claude Code asks before writing outside the directory the session started in. To approve the generation once, run mkdir ../my-api and /add-dir ../my-api before /init-project; an empty directory is accepted.

The project is born next to the clone, at ../<name> (~/dev/my-api above). At the end, git-publish asks twice: once to run git init and make the first commit, once to create the GitHub repository and push. One clone generates as many projects as you want: /clear between them.

Then open a new session inside the generated project to develop:

cd ../my-api
claude
/new-feature <what the first use case should do>

Why this order:

  • The generated project carries its own .claude/. The export mode writes the rules, the development skills, the executor agents, the hook, and the active blueprint. It leaves out init-project, project-bootstrap, and the blueprint catalog, so a project cannot regenerate itself on top of live code. It also refuses to write into this repository.
  • .claude/settings.json is read once, at session startup. The session opened in the clone runs with this repository's hooks, not the project's. Developing in it means none of the project's hooks fire, and everything looks like it works.
  • Accept the first git-publish gate. The tests hook uses git diff HEAD. In a repository without commits, HEAD does not exist and the hook exits without running a single test.

The project's own CLAUDE.md is generated during bootstrap, with the resolved versions and the real module list. This repository's root CLAUDE.md describes the meta-repository and does not travel. The full generation sequence is in docs/en/02-init-project.md.

Existing project

From inside a session in that project, /arch-adopt installs this .claude/ into a project that was never generated here, or pulls a newer version into one that is behind. It refuses a dirty worktree and writes through the deterministic export mode. It also stamps .claude/.arch-provenance.json, so the next update names every file you edited by hand before overwriting it. On an update, it prints the migrations notes for convention changes the project has not seen yet; it never runs them. See docs/en/10-arch-adopt.md.

Silent failures to know about

SymptomCauseCheck
Hooks never fireDeveloping in the session opened in the clone, or settings.json changed with the session already openOpen claude inside the generated project; after a change, /exit, then claude -c
Hooks fail on every OSjava not on PATH/arch-doctor
A forbidden import is not blocked.claude/forbidden-imports.txt does not exist yet (it is generated during init)/arch-doctor shows the number of active rules
Tests never run on StopNo HEAD (first git-publish gate declined), or no Maven wrapper/arch-doctor
A pipeline skill behaves like someone else'sA personal skill in ~/.claude/skills/ has the same name, and personal wins over project/arch-doctor, the Skill shadowing line
A plugin you installed does nothing heresuperpowers and mattpocock-skills are set to false in enabledPlugins on purpose: their skills compete with the pipelineRe-enable one for yourself in .claude/settings.local.json

The hooks exit 0 when they cannot verify something. That is safe, but it can give false greens, which is why /arch-doctor exists. More traps, runtime and repository alike: docs/en/11-pitfalls.md.


Flows

/init-project — creating the project

/init-project
     │
     ├─ 1. INTERVIEW, in the main session — blueprint · coordinates · build · features · TLS · Sonar
     │
     ▼  agent: project-initializer (isolated context, model: sonnet, asks nothing)
     │
     ├─ 2. VALIDATES blueprint — 6-rule checklist            [fails fast]
     ├─ 3. BASE via Spring Initializr (curl)                 [GA versions, nothing hardcoded]
     ├─ 4. RESTRUCTURES into modules per the blueprint       [exemplars give the shape]
     ├─ 5. GENERATES forbidden-imports.txt + root and module CLAUDE.md/AGENTS.md, CI, Checkstyle,
     │      lombok.config, logback, Dockerfile + docker-compose
     ├─ 6. EXPORTS norms, development skills, executor agents — the project is self-contained
     ├─ 7. INSTALLS hooks (ArchHook.jar + extensions.json + settings.json), opens the audit trail
     ├─ 8. VERIFIES build + smoke + tested boundary block; writes README + GENESIS.md
     ├─ 9. REPORT in the fixed output contract
     ├─ 10. GENESIS.md figures from the session transcripts — Started, Finished, tokens
     └─ if the build is green: OFFERS git-publish — two independent confirmations

No business code is generated: no ExampleController to delete. The first feature comes from a real spec.

/new-feature — one use case, spec first, code in a clean session

One command, two flows. The argument decides which, and the two never share a session: the design leaves the main thread at 250–370k tokens of context, and implementing on top of it would pay for that on every turn.

Flow 1 — create the spec. /new-feature <feature description>

/new-feature <feature description>     (or UC-NNN-slug to resume a draft, or empty to list)
     │
     use-case-design → domain-modeling → rest-api-architect (unless GraphQL only)
        → graphql-api-architect (conditional)
        → security-architect (conditional) → http-client-architect (conditional)
        → ai-architect (conditional) → search-architect (conditional)
        → messaging-architect (conditional) → jobs-architect (conditional)
        → persistence-architect → cache-architect (conditional) → test-architect
     │  one use case per run; a split goes to docs/use-cases/BACKLOG.md
     │  design skills write only docs/ — never src/, never docker-compose.yml, never git
     │  a missing DB or broker is asked about and recorded: add it, runs elsewhere, or by hand
     │  each one decides the design patterns of its own layer, in its partial
     │
     ▼  consolidates into UC-NNN-spec.md (status: draft)
     ▼  asks approval → status: approved (the guard hook freezes the folder from here)
     ▼  git-publish (the approved spec only)
     ▼  prints the next two commands:  /clear  ·  /new-feature-implement UC-NNN-slug

Flow 2 — implement the spec. /new-feature-implement UC-NNN-slug over an approved spec, after /clear

/new-feature-implement UC-NNN-slug     (status: approved — the command is the request)
     │
     │  pre-flight, once per project (ArchUnit, logging/masking aspects)
     │  agent: java-spring-boot-developer, pattern catalog injected at start,
     │         in three chained groups, each starting again from the spec and the disk:
     │
     │    domain (blocks 1–2) → adapters (H, 3, S, M, J) → tests (block 4, ./mvnw verify)
     │
     │  each group ticks its checklist steps when it ends green;
     │  a failed group stops the chain, and the same command resumes there
     │
     ▼  status: implemented (or implemented-blocked)
     ▼  agent: compose-service-installer → docker-architect, for each service the spec
     │         marked materialize — the container lands in the same commit as the code
     ▼  git-publish (feature commit)

Each step's output contract is literally the next step's input contract. A free-prose handoff degrades by the third hop. Details: docs/en/03-new-feature.md.

What the consolidation produces: one UC-NNN-spec.md that keeps a single decision per fact, links back to the partial that owns each one, and ends in an implementation order the executor follows. An example is bank-app's UC-001-spec.md. Conditional layers the use case does not need, such as security, outbound HTTP, messaging and jobs, are recorded as not applicable instead of being left out silently.

Edit cycle — what runs on every write in a generated project

prompt (UserPromptSubmit)
     └─ guard prompt        baseline of the working tree for this turn's sweep

Write / Edit
     ├─ guard               path outside the active skill's or agent's territory → blocked
     │                      approved spec folder → blocked
     │                      Skill(build class) during a design run → blocked
     ├─ schema              frontmatter + body of .claude/**/*.md, .mcp.json   (blocks)
     ├─ format              spotless on the touched module                    (never blocks)
     ├─ check               forbidden imports + incremental test-compile      (blocks)
     └─ audit               file touched → appended to the run's log          (never blocks)

Bash
     └─ guard bash          force push → refused; a redirect, sed -i, tee… outside
                            the territory or into a frozen folder → blocked

SubagentStart
     └─ context subagent    design-pattern catalog → agents whose class asks for it

Stop
     ├─ guard sweep         what git sees changed this turn, re-checked       (blocks)
     ├─ compose gate        a service down, or published and unreachable      (blocks)
     ├─ audit flush         renders the run's report + ledger line            (never blocks)
     └─ tests               tests of the changed modules                      (blocks)

Audit trail — what every run spent

The report of the bank-app /new-feature run described above, as it lands in the project: 2026-10-07T08-52-11--new-feature.md. It shows the duration with the time spent waiting for the user taken out, the chain of skills with each one's share, tokens per piece, tool calls and peak context per piece, the most expensive turns, the files touched, and every question asked with the answer given.

The trail is a hook, not a skill. It survives the model forgetting and the session dying, and it costs zero tokens to produce. Prompts are redacted before they land in git. /audit-usage aggregates the ledgers across runs. Details: docs/en/08-audit-usage.md.

Issues — filed from a project, verified here

/report-issue, run inside a generated project, files an issue on this repository. It refuses a report with no evidence, warns when the project is behind the latest release, and strips the project's own code and names from the body. Here, /triage-issue <N> has a read-only agent check every claim against HEAD, including the reporter's diagnosis and proposed fix, before anything is commented or designed. Details: docs/en/12-issues.md.


What is in the box

PieceCountWhere
Architecture blueprints7 + a template for your own.claude/blueprints/
Norms, loaded on demand through paths23.claude/rules/
Skills (design, orchestration, build, ops, meta…)30.claude/skills/
Agents (driver, executor, installer, verifier)6.claude/agents/
Hook modes, in one Java file11.claude/hooks/ArchHook.java
Decision records — why each piece has its form100+.claude/decisions/

Design skills of the /new-feature pipeline: use-case-design, domain-modeling, rest-api-architect, graphql-api-architect (the inbound GraphQL adapter, schema-first on the servlet stack: root fields, userErrors, connections, @BatchMapping, field-level access — only when the use case's trigger names it), security-architect (Spring Security on the servlet stack: who may call each endpoint, JWT, opaque token, API key, 401/403, CORS), http-client-architect (outbound HTTP client and engine, timeouts, retry, circuit breaker, outbound authentication), ai-architect (a language model or vector store behind a port — structured output, semantic search, prompts as resources, what the answer may change), search-architect (an Elasticsearch or OpenSearch index kept as a rebuildable projection of a source of truth: strict mapping, alias, indexer, rebuild, lag — only when the use case names a search index), messaging-architect (Kafka producer and consumer, retry, DLQ, the AsyncAPI contract), jobs-architect (@Scheduled, ShedLock, Quartz, Spring Batch, db-scheduler, JobRunr; the outbox relay), persistence-architect (relational with JPA or document with MongoDB, as the use case's Storage row states; stamps or an Envers history when its Audit row asks), cache-architect (Caffeine or Redis behind a decorator of the outbound port, keys, expiry, eviction on every write — only when the use case states how old an answer may be), test-architect.

Cross-cutting concerns that arrive solved: Idempotency-Key through AOP, logging with sensitive-data masking, an OTLP collector with Jaeger or Grafana + Tempo + Prometheus, transport security decided once per project through transport-security-setup (edge, direct, or mutual TLS, HSTS with exactly one writer), SonarQube or SonarCloud setup, compose port-collision and reachability checks, and permissions.deny on secrets files.

What travels into a generated project: the norms, the development skills, the executor agents, the hook and its schema, and the active blueprint. The manifest of what travels is data, the export block of schemas/extensions.json, and the schema hook fails if a skill or agent on disk is missing from it. Creation-only pieces stay here: project-bootstrap, init-project, new-project, claude-code-architect-designer, triage-issue, project-initializer, issue-verifier, and the blueprint catalog. arch-adopt is the one creation skill that travels, because it is how the project updates itself once this repository is out of the picture.


Blueprints

IDLayoutChoose ifCost
layeredsingle-moduleCRUD, familiar controller/service/repository vocabularyArchUnit is the only boundary, not the compiler
clean-architecture-multi-modulemulti-moduleLong-lived system, medium or large teamMore ceremony, many mappings
clean-architecture-single-modulesingle-moduleClean Architecture without the module countArchUnit is the only boundary
hexagonalmulti-moduleSeveral entry and exit channelsMore modules than layered
hexagonal-single-modulesingle-moduleSeveral entry and exit channels, without one POM per adapterArchUnit and the boundary map are the only boundary, not the compiler
onionmulti-modulePalermo's terminology; persistence and presentation as independent peersLarger domain compilation unit
onion-single-modulesingle-modulePalermo's terminology without the module countArchUnit and the boundary map are the only boundary

| vertical-slice | single-module | Loosely coupl

Source 1 files
hooks/register.ts 203 lines
1// nerviz-cockpit — draws what ArchHook already decides. Nothing here decides anything.
2//
3//   band     AbovePrompt: the open skill phase, its class and the paths it may write,
4//            from `java -jar .claude/hooks/ArchHook.jar guard status`
5//   doctor   /nerviz-doctor: runs `ArchHook.jar doctor` and shows it in a pane, no turn spent
6//   dialog   tool.call on Bash: when `guard` refused the command, holds the refusal behind two
7//            buttons — let Claude adapt, or stop the turn. The refusal stands either way
8//
9// Why this is a mod (Form 9 of claude-code-architect-designer): axis 1 — the open phase and
10// its write territory were invisible until `guard` refused a write, and no settings hook can
11// draw. The closest rejected form was a `UserPromptSubmit` hook adding the phase as context:
12// it reaches the model, not the person, and costs context on every prompt. Every rule stays
13// in ArchHook.java (invariant 2) and every refusal stays a settings hook (invariant 6) — this
14// mod does not load in `claude -p`, VS Code, a cloud session or under `--safe-mode`, and
15// nothing breaks when it is absent. Allowed events, calls and programs: the `mods` block of
16// .claude/schemas/extensions.json. Tested against Claude Code 2.1.293.
17//
18// Design: .claude/decisions/0131-mods-in-architect-designer.md. The spinner suffix, which showed
19// the turn's cost in USD, was removed by .claude/decisions/0134-audit-tokens-only-no-usd-pricing.md.
20//
21// Every hook that can gate an event ends in `.catch(($, e, next) => next(e))`: a hook that
22// throws then replays what the guards beneath already decided, or runs them once — never a
23// call let through past them, never a refusal of this mod's own.
24//
25// The engine reads `on(...)` and `$.noun.method(...)` from source, so they are spelled
26// literally, and helpers that take `$` are top-level functions.
27
28import type { EngineInterface, Register } from 'claude-code'
29
30const JAR = '.claude/hooks/ArchHook.jar'
31const PANE = 'nerviz-doctor'
32const LET_ADAPT = 'Let Claude adapt'
33const STOP_TURN = 'Stop the turn'
34const TERRITORY_SHOWN = 3
35
36type Phase = { skills: string[]; cls: string | null; writeAllow: string[]; markers: string[] }
37
38const NO_PHASE: Phase = { skills: [], cls: null, writeAllow: [], markers: [] }
39
40let phase: Phase = NO_PHASE
41let turnId: string | null = null
42let doctor: string[] | null = null
43
44export const register: Register = (on) => {
45  on('session.start', async ($, e, next) => {
46    const result = await next(e)
47    try {
48      await $.command.register({
49        name: 'nerviz-doctor',
50        description: 'ArchHook doctor in a pane, without spending a turn',
51        immediate: true,
52      })
53    } catch {
54      // The name is taken; /arch-doctor still reports the same lines.
55    }
56    await readPhase($)
57    return result
58  })
59
60  // `guard prompt` opens or closes the phase in the settings hooks beneath this one.
61  on('classic.UserPromptSubmit', async ($, e, next) => {
62    const result = await next(e)
63    await readPhase($)
64    return result
65  }).catch(($, e, next) => next(e))
66
67  on('turn.start', async ($, e, next) => {
68    turnId = e.turnId
69    return next(e)
70  })
71
72  on('turn.complete', async ($, e, next) => {
73    const result = await next(e)
74    if (!e.agentId) turnId = null
75    return result
76  })
77
78  // `guard call` joins a skill to the phase in the PreToolUse hook beneath this one.
79  on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
80    const result = await next(e)
81    await readPhase($)
82    return result
83  }).catch(($, e, next) => next(e))
84
85  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
86    const result = await next(e)
87    if (typeof result.deny !== 'string' || !isGuardRefusal(result.deny)) return result
88    if ((await $.session.surfaces()).length === 0) return result
89    let answer = LET_ADAPT
90    try {
91      answer = await $.ui.ask(question(result.deny), { header: 'guard', options: [LET_ADAPT, STOP_TURN] })
92    } catch {
93      return result
94    }
95    if (answer === STOP_TURN && turnId !== null) {
96      try {
97        await $.turn.abort({ turnId })
98      } catch {
99        // The turn already ended.
100      }
101    }
102    return result
103  }).catch(($, e, next) => next(e))
104
105  on('command.run', { command: 'nerviz-doctor' }, async ($, e, next) => {
106    const root = await $.session.root()
107    try {
108      const run = await $.process.run(['java', '-jar', JAR, 'doctor'], { cwd: root, timeoutMs: 120000 })
109      doctor = (run.stdout + run.stderr).split('\n').filter((line) => line.trim() !== '')
110    } catch {
111      doctor = ['ArchHook doctor did not run: no java on PATH, or ' + JAR + ' is missing.']
112    }
113    if ((await $.session.surfaces()).length > 0) {
114      const opened = await $.ui.open({ id: PANE, title: 'nerviz doctor', focus: true, closeOnEscape: true })
115      if (opened.isPlaced) return {}
116    }
117    return { text: doctor.join('\n') }
118  })
119
120  on('ui.render', { component: 'Pane' }, ($, e, next) => {
121    if (e.requestId !== PANE || doctor === null) return next(e)
122    const { Box, Text } = $.ui.resolve(e)
123    return Box({
124      flexDirection: 'column',
125      paddingX: 1,
126      children: doctor.map((line) => Text({ color: lineColor(line), wrap: 'truncate-end', children: line })),
127    })
128  })
129
130  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
131    if (e.props.hasSurvey || phase.skills.length === 0) return next(e)
132    const { Box, Text } = $.ui.resolve(e)
133    const theirs = await next(e)
134    const mine = Box({
135      key: 'nerviz-phase',
136      flexDirection: 'row',
137      paddingX: 1,
138      children: [
139        Text({ color: 'cyan', bold: true, children: '▍ ' + (phase.cls ?? 'no class') }),
140        Text({ children: '  ' + phase.skills.join(' + ') }),
141        Text({ dimColor: true, wrap: 'truncate-end', children: '  writes ' + territory() }),
142      ],
143    })
144    return theirs ? Box({ flexDirection: 'column', children: [mine, theirs] }) : mine
145  })
146}
147
148// ---- What the guard says ---------------------------------------------------
149
150async function readPhase($: EngineInterface) {
151  try {
152    const root = await $.session.root()
153    const run = await $.process.run(['java', '-jar', JAR, 'guard', 'status'], {
154      cwd: root,
155      stdin: JSON.stringify({ session_id: await $.session.id() }),
156      timeoutMs: 15000,
157    })
158    phase = run.exitCode === 0 ? parsePhase(run.stdout) : NO_PHASE
159  } catch {
160    phase = NO_PHASE
161  }
162  $.ui.invalidate('ui.render')
163}
164
165function parsePhase(stdout: string): Phase {
166  try {
167    const status = JSON.parse(stdout)
168    return {
169      skills: Array.isArray(status.phase) ? status.phase : [],
170      cls: typeof status.class === 'string' ? status.class : null,
171      writeAllow: Array.isArray(status.write_allow) ? status.write_allow : [],
172      markers: Array.isArray(status.deny_markers) ? status.deny_markers : [],
173    }
174  } catch {
175    return NO_PHASE
176  }
177}
178
179function isGuardRefusal(deny: string): boolean {
180  return phase.markers.some((marker) => deny.includes(marker))
181}
182
183function question(deny: string): string {
184  const first = deny.split('\n').find((line) => line.trim() !== '') ?? deny
185  return 'guard refused this command: ' + first.trim() + '\nThe refusal stands. What should Claude do next?'
186}
187
188// ---- What the band draws -----------------------------------------------------
189
190function territory(): string {
191  if (phase.writeAllow.length === 0) return 'nothing'
192  const shown = phase.writeAllow.slice(0, TERRITORY_SHOWN).join(', ')
193  const rest = phase.writeAllow.length - TERRITORY_SHOWN
194  return rest > 0 ? shown + ' +' + rest : shown
195}
196
197function lineColor(line: string): string | undefined {
198  if (line.includes('❌')) return 'red'
199  if (line.includes('✅')) return 'green'
200  if (line.includes('⚠')) return 'yellow'
201  return undefined
202}
203