A pane over `ArchHook.jar config`: the settings a project owns in its own .claude/, in the nerviz identity, changed only through `config set` and `config…

<img alt="Nerviz — an agent harness for Spring Boot" src="brand/nerviz-banner-light.png" width="100%">
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.
/arch-adopt updates it later.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:
rules/), procedures and exemplars (skills/), architecture blueprints, and the use-case spec of the feature being built.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.
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.
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:
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.start.spring.io at run time. CI fails if a Spring or Java version appears as a fact./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/.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.CLAUDE.md stays under 200 lines. Norms load through paths only when a file in their territory is touched.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).
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:
.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.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.
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.
A project on a Spring Boot line below this .claude/'s floor is refused by export, and so by /arch-adopt. /platform-upgrade is the way up. Run it inside the project:
/platform-upgrade
@EnableCaching), the renamed types (test slices, @MockBean and @SpyBean), the renamed configuration properties, and a Gradle wrapper the new plugin refuses.ArchHook.jar upgrade-recipe writes from data and from the property renames Spring's own jars publish, run by the Apache-licensed OpenRewrite core through the project's own Maven or Gradle wrapper. What no rename covers is a guided pass, one compile error at a time, until the build is green. The tests run before the bump and after, and ArchHook.jar test-diff tells what the upgrade broke from what was already broken..claude/, or delete, skip or loosen a failing test. It refuses a dirty worktree, and it ends by printing /arch-adopt.See docs/en/15-platform-upgrade.md.
| Symptom | Cause | Check |
|---|---|---|
| Hooks never fire | Developing in the session opened in the clone, or settings.json changed with the session already open | Open claude inside the generated project; after a change, /exit, then claude -c |
| Hooks fail on every OS | java 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 Stop | No HEAD (first git-publish gate declined), or no Maven wrapper | /arch-doctor |
| A pipeline skill behaves like someone else's | A 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 here | superpowers and mattpocock-skills are set to false in enabledPlugins on purpose: their skills compete with the pipeline | Re-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.
/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 sessionOne 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)
▼ prints the next commands: /clear · /git-publish (the approved spec only) · /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
▼ prints the next commands: /clear · /git-publish (feature commit) · /new-feature <next>
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.
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)
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.
/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.
| Piece | Count | Where |
|---|---|---|
| Architecture blueprints | 7 + a template for your own | .claude/blueprints/ |
Norms, loaded on demand through paths | 23 | .claude/rules/ |
| Skills (design, orchestration, build, ops, meta…) | 30 | .claude/skills/ |
| Agents (driver, executor, installer, verifier) | 6 | .claude/agents/ |
| Hook modes, in one Java file | 11 | .claude/hooks/ArchHook.java |
| Decision records — why each piece has its form | 100+ | .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,
hooks/register.ts 253 lines1// nerviz-config — a pane over `ArchHook.jar config`. Nothing here decides anything.
2//
3// /nerviz-config opens a pane with every setting the project owns in its own .claude/
4// (`config list`), in the nerviz identity: the brand blue marks only what
5// is off its default. A change is a button, then a dialog, then
6// `config set` or `config unset` — never a write of this mod's own.
7//
8// Why this is a mod (Form 9 of claude-code-architect-designer): axis 1 — a project owner did
9// not know the settings existed, and one edited a file by hand with a wrong key. The closest
10// rejected form was the `/project-config` skill alone: it spends a turn and a model to list
11// and change what one command already answers, and the pane spends neither. The guarantee
12// beneath is `config` itself — it validates every value before it writes, `guard` refuses a
13// direct write of a registered file, and `doctor` gates a malformed one; this mod does not
14// load in `claude -p`, VS Code, a cloud session or under `--safe-mode`, and every setting is
15// still reachable through the skill and the command. Tested against Claude Code 2.1.293 and 2.1.308.
16//
17// The one write a mod here makes is `config set|unset` through `$.process.run`: it is no file
18// write of the mod's (`$.fs.write` stays forbidden), it is the registry's own writer, and it
19// refuses before it touches anything. `mods.$comment_calls` names this exception.
20//
21// Design: .claude/decisions/0187-project-config-registry-skill-and-mod.md. No hook on a
22// `gating_events` event: the mod holds no refusal, so it needs no `.catch`.
23//
24// The engine reads `on(...)` and `$.noun.method(...)` from source, so they are spelled
25// literally, and helpers that take `$` are top-level functions in this file. The pure parts
26// — what to draw, in which words, in which blue — live beside it and never see `$`.
27
28import type { EngineInterface, Register } from 'claude-code'
29import { changed, effective, paneLines, parseList, plainText, show } from './model.ts'
30import type { Action, ConfigFile, Key, Line, Model, Seg, View } from './model.ts'
31import { brandOf } from './palettes.ts'
32import { langOf, wordsOf } from './words.ts'
33import type { Lang } from './words.ts'
34
35const JAR = '.claude/hooks/ArchHook.jar'
36const PANE = 'nerviz-config'
37const INPUT = 'nerviz-config-input'
38
39let model: Model = { status: 'failed', files: [], detail: '' }
40let view: View = { expanded: [], pages: {}, edit: null, refusal: [] }
41let theme: unknown = null
42let lang: Lang = 'en'
43
44export const register: Register = (on) => {
45 on('session.start', async ($, e, next) => {
46 const result = await next(e)
47 const root = await $.session.root()
48 // No project jar, no command: the mod stays silent where `config` does not exist.
49 if (await $.fs.exists(root + '/' + JAR)) {
50 try {
51 await $.command.register({
52 name: 'nerviz-config',
53 description: 'The project settings in a pane, without spending a turn',
54 immediate: true,
55 })
56 } catch {
57 // The name is taken; /project-config still lists and changes the same settings.
58 }
59 }
60 return result
61 })
62
63 on('command.run', { command: 'nerviz-config' }, async ($, e, next) => {
64 view = { expanded: [], pages: {}, edit: null, refusal: [] }
65 await load($)
66 if ((await $.session.surfaces()).length > 0) {
67 const opened = await $.ui.open({ id: PANE, title: 'nerviz config', focus: true, closeOnEscape: true })
68 if (opened.isPlaced) return {}
69 }
70 return { text: plainText(model) }
71 })
72
73 on('ui.render', { component: 'Pane' }, ($, e, next) => {
74 if (e.requestId !== PANE) return next(e)
75 const els = $.ui.resolve(e)
76 const { Box } = els
77 const lines = paneLines(wordsOf(lang), model, view)
78 return Box({
79 flexDirection: 'column',
80 paddingX: 1,
81 children: lines.map((line, i) => row($, els, line, i)),
82 })
83 })
84}
85
86// ---- What the pane draws ---------------------------------------------------
87
88function row($: EngineInterface, els: any, line: Line, index: number) {
89 const { Box, Text } = els
90 const children = line.length === 0 ? [Text({ children: ' ' })] : line.map((seg, j) => piece($, els, seg, index + '-' + j))
91 return Box({ key: 'l' + index, flexDirection: 'row', children })
92}
93
94function piece($: EngineInterface, els: any, seg: Seg, key: string) {
95 const { Text, Button, Input } = els
96 if (seg.input !== undefined) {
97 const at = seg.input
98 return Input({
99 key: INPUT,
100 value: at.value,
101 submitLabel: wordsOf(lang).save,
102 autoFocus: true,
103 onInput: (value: string) => typing(at.id, at.key, value),
104 onSubmit: (value: string) => submit($, at.id, at.key, value),
105 })
106 }
107 if (seg.act !== undefined) {
108 const act = seg.act
109 return Button({ key: 'b' + key, label: seg.t, plain: true, onPress: () => press($, act) })
110 }
111 return Text({ ...tone(seg), wrap: 'truncate-end', children: seg.t })
112}
113
114function tone(seg: Seg): Record<string, unknown> {
115 switch (seg.tone) {
116 case 'bold':
117 return { bold: true }
118 case 'dim':
119 return { dimColor: true }
120 case 'brand':
121 return { color: brandOf(theme) }
122 case 'error':
123 return { color: 'error' }
124 case 'warning':
125 return { color: 'warning' }
126 case 'inactive':
127 return { color: 'inactive' }
128 default:
129 return {}
130 }
131}
132
133// ---- What `config` says ----------------------------------------------------
134
135async function load($: EngineInterface) {
136 try {
137 const rows = await $.config.list()
138 theme = rows.find((r) => r.key === 'theme')?.value ?? null
139 lang = langOf(rows.find((r) => r.key === 'language')?.value, Intl.DateTimeFormat().resolvedOptions().locale)
140 } catch {
141 theme = null
142 }
143 await refresh($)
144}
145
146async function refresh($: EngineInterface) {
147 try {
148 const run = await $.process.run(['java', '-jar', JAR, 'config', 'list'], {
149 cwd: await $.session.root(),
150 timeoutMs: 20000,
151 })
152 const out = (run.stdout + run.stderr).trim()
153 const files = run.exitCode === 0 ? parseList(run.stdout) : null
154 if (files !== null) model = { status: 'ok', files, detail: '' }
155 else if (out.includes('Unknown mode')) model = { status: 'oldjar', files: [], detail: '' }
156 else model = { status: 'failed', files: [], detail: out.split('\n')[0] ?? '' }
157 } catch {
158 model = { status: 'failed', files: [], detail: 'java did not start' }
159 }
160 $.ui.invalidate('ui.render')
161}
162
163// ---- What a press does -----------------------------------------------------
164
165function find(id: string, key: string): { file: ConfigFile; key: Key } | null {
166 const file = model.files.find((f) => f.id === id)
167 const k = file?.keys.find((x) => x.key === key)
168 return file && k ? { file, key: k } : null
169}
170
171async function press($: EngineInterface, act: Action) {
172 const w = wordsOf(lang)
173 view.refusal = []
174 try {
175 if (act.kind === 'set') {
176 const at = find(act.id, act.key)
177 if (at === null) return
178 const question = w.askSet(at.file.path, at.key.key, show(effective(at.key)), act.value) + '\n' + at.key.effect
179 await change($, question, ['set', act.id, act.key, act.value], w.written(act.id, act.key, act.value))
180 } else if (act.kind === 'unset') {
181 const at = find(act.id, act.key)
182 if (at === null) return
183 const question = w.askUnset(act.key, show(at.key.default)) + '\n' + w.askUnsetHint
184 await change($, question, ['unset', act.id, act.key], w.restored(act.id, act.key))
185 } else if (act.kind === 'edit') {
186 const at = find(act.id, act.key)
187 view.edit = { id: act.id, key: act.key, text: at !== null && changed(at.key) ? show(at.key.value) : '' }
188 } else if (act.kind === 'expand') {
189 view.expanded = view.expanded.includes(act.id) ? view.expanded.filter((x) => x !== act.id) : [...view.expanded, act.id]
190 view.pages[act.id] = 0
191 } else if (act.kind === 'page') {
192 view.pages[act.id] = Math.max(0, (view.pages[act.id] ?? 0) + act.by)
193 } else if (act.kind === 'refresh') {
194 view.edit = null
195 await refresh($)
196 } else if (act.kind === 'doctor') {
197 await $.prompt.fill({ text: '/arch-doctor' })
198 } else if (act.kind === 'adopt') {
199 await $.prompt.fill({ text: '/arch-adopt' })
200 } else if (act.kind === 'close') {
201 await $.ui.close({ id: PANE })
202 return
203 }
204 } catch {
205 // A dialog the person dismissed, or a pane that is already gone: nothing was written.
206 }
207 $.ui.invalidate('ui.render')
208}
209
210function typing(id: string, key: string, text: string) {
211 view.edit = { id, key, text }
212}
213
214async function submit($: EngineInterface, id: string, key: string, raw: string) {
215 const w = wordsOf(lang)
216 const at = find(id, key)
217 if (at === null) return
218 const text = raw.trim()
219 view.refusal = []
220 try {
221 if (text === '') {
222 await change($, w.askUnset(key, show(at.key.default)) + '\n' + w.askUnsetHint, ['unset', id, key], w.restored(id, key))
223 } else {
224 await change($, w.askSet(at.file.path, key, show(effective(at.key)), text) + '\n' + at.key.effect, ['set', id, key, text], w.written(id, key, text))
225 }
226 } catch {
227 // Dismissed dialog.
228 }
229 $.ui.invalidate('ui.render')
230}
231
232// The dialog first, then the one writer. A refusal (exit 2) is shown as `config` printed it.
233async function change($: EngineInterface, question: string, args: string[], done: string) {
234 const w = wordsOf(lang)
235 const answer = await $.ui.ask(question, { header: 'config', options: [w.write, w.cancel] })
236 if (answer !== w.write) return
237 try {
238 const run = await $.process.run(['java', '-jar', JAR, 'config', ...args], {
239 cwd: await $.session.root(),
240 timeoutMs: 20000,
241 })
242 if (run.exitCode === 0) {
243 view.edit = null
244 await refresh($)
245 $.ui.toast(done)
246 } else {
247 view.refusal = (run.stdout + run.stderr).split('\n').filter((l) => l.trim() !== '')
248 }
249 } catch {
250 view.refusal = [w.failed]
251 }
252}
253hooks/model.ts 266 lines1// What `ArchHook.jar config list` prints, as lines the pane draws. Pure: no `$`, no rule. A
2// value is "off default" when the file holds it and it differs from the default `config list`
3// printed; whether a value is valid is `config set`'s answer, never this file's.
4
5import type { Words } from './words.ts'
6
7export type Key = {
8 key: string
9 type: string
10 values?: string[]
11 default: unknown
12 value: unknown
13 effect: string
14 fixed?: boolean
15}
16
17export type ConfigFile = {
18 id: string
19 path: string
20 exists: boolean
21 summary: string
22 doctor_label: string
23 available: boolean
24 in_force: string
25 problems: string[]
26 keys: Key[]
27}
28
29export type Action =
30 | { kind: 'set'; id: string; key: string; value: string }
31 | { kind: 'unset'; id: string; key: string }
32 | { kind: 'edit'; id: string; key: string }
33 | { kind: 'expand'; id: string }
34 | { kind: 'page'; id: string; by: number }
35 | { kind: 'refresh' }
36 | { kind: 'doctor' }
37 | { kind: 'adopt' }
38 | { kind: 'close' }
39
40export type Tone = 'plain' | 'bold' | 'dim' | 'brand' | 'error' | 'warning' | 'inactive'
41
42export type Seg = { t: string; tone?: Tone; act?: Action; input?: { id: string; key: string; value: string } }
43export type Line = Seg[]
44
45export type Status = 'ok' | 'oldjar' | 'failed'
46
47export type Model = { status: Status; files: ConfigFile[]; detail: string }
48
49export type View = {
50 expanded: string[]
51 pages: Record<string, number>
52 edit: { id: string; key: string; text: string } | null
53 refusal: string[]
54}
55
56export const PAGE = 8
57const COLLAPSE_OVER = 8
58
59export function parseList(stdout: string): ConfigFile[] | null {
60 try {
61 const data = JSON.parse(stdout)
62 return Array.isArray(data?.files) ? (data.files as ConfigFile[]) : null
63 } catch {
64 return null
65 }
66}
67
68export function changed(key: Key): boolean {
69 return key.value !== null && key.value !== undefined && key.value !== key.default
70}
71
72export function effective(key: Key): unknown {
73 return key.value === null || key.value === undefined ? key.default : key.value
74}
75
76const dim = (t: string): Seg => ({ t, tone: 'dim' })
77const brand = (t: string): Seg => ({ t, tone: 'brand' })
78const bold = (t: string): Seg => ({ t, tone: 'bold' })
79const plain = (t: string): Seg => ({ t })
80const button = (t: string, act: Action): Seg => ({ t, act })
81
82export function show(value: unknown): string {
83 if (value === null || value === undefined) return '—'
84 return typeof value === 'string' ? value : JSON.stringify(value)
85}
86
87function header(w: Words, sub: string): Line[] {
88 return [
89 [{ t: '█', tone: 'bold' }, brand('╲'), { t: ' █', tone: 'bold' }, bold(' nerviz config')],
90 [{ t: '█ ', tone: 'bold' }, brand('╲'), { t: ' █', tone: 'bold' }, dim(' ' + w.tagline)],
91 [{ t: '█ ', tone: 'bold' }, brand('╲'), { t: '█', tone: 'bold' }, dim(' ' + sub)],
92 [],
93 ]
94}
95
96function seal(w: Words, files: number | null, problems: number): Line[] {
97 return [
98 [],
99 [
100 { t: 'nerviz ', tone: 'bold' },
101 brand('▸ '),
102 bold('config'),
103 ...(files === null ? [] : [dim(' · ' + w.seal(files, problems))]),
104 ],
105 [
106 dim('/nerviz-config '),
107 button(w.refresh, { kind: 'refresh' }),
108 dim(' · '),
109 button('doctor', { kind: 'doctor' }),
110 dim(' '),
111 button(w.close, { kind: 'close' }),
112 ],
113 ]
114}
115
116function enumRow(w: Words, f: ConfigFile, k: Key): Line[] {
117 const now = effective(k)
118 const options = (k.values ?? []).map((v): Seg[] => {
119 if (v === now) return [changed(k) ? brand('● ' + v) : plain('● ' + v)]
120 return [button('○ ' + v, { kind: 'set', id: f.id, key: k.key, value: v })]
121 })
122 const row: Line = [plain(k.key.padEnd(12))]
123 options.forEach((o, i) => row.push(...(i > 0 ? [plain(' ')] : []), ...o))
124 if (changed(k)) row.push(dim(' · '), button(w.reset, { kind: 'unset', id: f.id, key: k.key }))
125 return [row, [dim(' ' + k.effect)]]
126}
127
128function boolRow(w: Words, f: ConfigFile, k: Key): Line {
129 const on = effective(k) === true
130 const mark: Seg = changed(k) ? brand('● ') : dim('○ ')
131 const name = k.key.padEnd(24)
132 if (k.fixed) {
133 return [dim('○ '), { t: name, tone: 'inactive' }, { t: on ? w.on : w.unset, tone: 'inactive' }, { t: ' ' + w.fixed, tone: 'inactive' }]
134 }
135 const value = on ? w.on + ' ' : w.unset
136 const verb = changed(k)
137 ? button(w.reset, { kind: 'unset', id: f.id, key: k.key })
138 : button(on ? w.turnOff : w.turnOn, { kind: 'set', id: f.id, key: k.key, value: String(!on) })
139 const note = changed(k) ? w.classIs(String(k.default)) : w.defaultIs
140 return [mark, plain(name), changed(k) ? brand(value) : dim(value), dim(' ' + note.padEnd(11)), verb]
141}
142
143function numberRow(w: Words, f: ConfigFile, k: Key, view: View): Line[] {
144 const mark: Seg = changed(k) ? brand('● ') : dim('○ ')
145 const name = plain(k.key.padEnd(22))
146 if (view.edit && view.edit.id === f.id && view.edit.key === k.key) {
147 return [
148 [mark, name, { t: '', input: { id: f.id, key: k.key, value: view.edit.text } }],
149 [dim(' ' + ' '.repeat(22) + w.emptyHint)],
150 ]
151 }
152 const row: Line = [mark, name, changed(k) ? brand(show(k.value) + ' ') : dim('— ')]
153 row.push(button(changed(k) ? w.edit : w.set, { kind: 'edit', id: f.id, key: k.key }))
154 if (changed(k)) row.push(dim(' · '), button(w.reset, { kind: 'unset', id: f.id, key: k.key }))
155 return [row]
156}
157
158function keyRows(w: Words, f: ConfigFile, k: Key, view: View): Line[] {
159 if (k.type === 'boolean') return [boolRow(w, f, k)]
160 if (k.type === 'positive_number_or_null') return numberRow(w, f, k, view)
161 if (k.values && k.values.length > 0) return enumRow(w, f, k)
162 return [[dim('○ '), plain(k.key.padEnd(24)), plain(show(effective(k)))]]
163}
164
165function section(w: Words, f: ConfigFile, view: View): { node: Seg; tail: Seg; lines: Line[] } {
166 const off = f.keys.filter(changed).length
167 if (!f.available) {
168 return {
169 node: { t: '○ ', tone: 'inactive' },
170 tail: { t: w.off, tone: 'inactive' },
171 lines: [[{ t: w.trailOff, tone: 'inactive' }], [dim(w.trailOffHint)]],
172 }
173 }
174 const lines: Line[] = []
175 const long = f.keys.length > COLLAPSE_OVER
176 const open = view.expanded.includes(f.id)
177 if (!long || !open) {
178 const shown = long ? f.keys.filter(changed) : f.keys
179 shown.forEach((k) => lines.push(...keyRows(w, f, k, view)))
180 if (long) lines.push([dim(' '), button(w.showAll(f.keys.length), { kind: 'expand', id: f.id })])
181 } else {
182 const page = Math.min(view.pages[f.id] ?? 0, Math.floor((f.keys.length - 1) / PAGE))
183 const slice = f.keys.slice(page * PAGE, page * PAGE + PAGE)
184 slice.forEach((k) => lines.push(...keyRows(w, f, k, view)))
185 const nav: Line = [dim(' ' + w.range(page * PAGE + 1, page * PAGE + slice.length, f.keys.length) + ' ')]
186 if (page > 0) nav.push(button('▲ ' + w.prev, { kind: 'page', id: f.id, by: -1 }), dim(' '))
187 if ((page + 1) * PAGE < f.keys.length) nav.push(button('▼ ' + w.next, { kind: 'page', id: f.id, by: 1 }), dim(' '))
188 nav.push(button(w.onlyChanged, { kind: 'expand', id: f.id }))
189 lines.push(nav)
190 }
191 const invalid = f.problems.length > 0
192 const node: Seg = invalid ? { t: '✗ ', tone: 'error' } : off > 0 ? brand('● ') : dim('○ ')
193 const tail: Seg = invalid
194 ? { t: w.invalid, tone: 'error' }
195 : off > 0
196 ? dim(w.offDefault(off))
197 : dim(f.exists ? w.asDefault : w.notWritten)
198 return { node, tail, lines }
199}
200
201export function paneLines(w: Words, model: Model, view: View): Line[] {
202 if (model.status === 'oldjar') {
203 return [
204 ...header(w, w.noFiles),
205 [{ t: w.oldJar, tone: 'warning' }],
206 [dim(' ' + w.oldJarHint)],
207 [plain(' '), button('/arch-adopt', { kind: 'adopt' })],
208 ...seal(w, null, 0),
209 ]
210 }
211 if (model.status === 'failed') {
212 return [
213 ...header(w, w.noFiles),
214 [{ t: w.failed, tone: 'warning' }],
215 ...(model.detail === '' ? [] : [[dim(' ' + model.detail)] as Line]),
216 ...seal(w, null, 0),
217 ]
218 }
219 const problems = model.files.reduce((n, f) => n + f.problems.length, 0)
220 const off = model.files.reduce((n, f) => n + f.keys.filter(changed).length, 0)
221 const out: Line[] = [...header(w, w.files(model.files.length, off))]
222 if (view.refusal.length > 0) {
223 out.push([{ t: w.refused, tone: 'error' }])
224 view.refusal.forEach((l) => out.push([{ t: ' ' + l, tone: 'error' }]))
225 out.push([])
226 }
227 model.files
228 .filter((f) => f.problems.length > 0)
229 .forEach((f) => {
230 out.push([{ t: w.problems(f.problems.length, f.doctor_label), tone: 'error' }])
231 f.problems.forEach((p) => out.push([{ t: ' ' + p, tone: 'error' }]))
232 out.push([])
233 })
234 model.files.forEach((f, i) => {
235 const last = i === model.files.length - 1
236 const first = i === 0
237 const lead = model.files.length === 1 ? '── ' : first ? '╭─ ' : last ? '╰─ ' : '├─ '
238 const s = section(w, f, view)
239 out.push([dim(lead), s.node, bold(f.doctor_label), dim(' · ' + f.path + ' · '), s.tail])
240 s.lines.forEach((l) => out.push([dim(last ? ' ' : '│ '), ...l]))
241 if (!last) out.push([dim('│')])
242 })
243 out.push(...seal(w, model.files.length, problems))
244 return out
245}
246
247/** The same listing as plain text, for a surface that draws nothing (`claude -p`). */
248export function plainText(model: Model): string {
249 if (model.status !== 'ok') {
250 return 'config list did not answer. ' + (model.status === 'oldjar' ? 'Run /arch-adopt to update this .claude/.' : model.detail)
251 }
252 const out: string[] = []
253 model.files.forEach((f) => {
254 if (!f.available) {
255 out.push(`${f.id} ${f.path} audit trail off`)
256 return
257 }
258 const set = f.keys.filter(changed)
259 out.push(`${f.id} ${f.path} ${f.exists ? (set.length === 0 ? 'default' : set.length + ' off default') : 'not written'}`)
260 set.forEach((k) => out.push(` ${k.key} = ${show(k.value)} (default ${show(k.default)})`))
261 f.problems.forEach((p) => out.push(' ! ' + p))
262 })
263 out.push('Change: java -jar .claude/hooks/ArchHook.jar config set <id> <key> <value>')
264 return out.join('\n')
265}
266hooks/palettes.ts 21 lines1// The one place a hex value may live in this mod (decision 0187, option B of the style round).
2// Everything else the mod draws is a theme key. These are the blue of brand/nerviz-mark*.svg,
3// one per theme the mod knows; any other theme (`auto`, an ANSI or custom one) gets the
4// `suggestion` theme key, so the person's own palette decides.
5
6const DARK = '#8EA2FF'
7const LIGHT = '#2747D9'
8const FALLBACK = 'suggestion'
9
10const BY_THEME: Record<string, string> = {
11 dark: DARK,
12 'dark-daltonized': DARK,
13 light: LIGHT,
14 'light-daltonized': LIGHT,
15}
16
17/** The brand blue for the `theme` row of `$.config.list()`, or a theme key when it is unknown. */
18export function brandOf(theme: unknown): string {
19 return typeof theme === 'string' ? (BY_THEME[theme] ?? FALLBACK) : FALLBACK
20}
21hooks/words.ts 114 lines1// Every string the mod draws, in pt-BR and en. The language is the `/config` Language row when
2// it names one, else the locale of the process.
3
4export type Lang = 'pt' | 'en'
5
6const WORDS = {
7 pt: {
8 tagline: 'o que este projeto configura no próprio .claude/',
9 files: (n: number, off: number) => `${n} arquivos · ${off} fora do padrão`,
10 noFiles: 'config indisponível',
11 seal: (n: number, problems: number) => `${n} arquivos · ${problems} problemas`,
12 offDefault: (n: number) => `${n} fora do padrão`,
13 notWritten: 'não existe',
14 asDefault: 'no padrão',
15 off: 'desligada',
16 invalid: 'inválido',
17 on: 'on',
18 unset: 'off',
19 classIs: (v: string) => `classe: ${v}`,
20 defaultIs: 'padrão',
21 fixed: 'fixo',
22 set: 'definir',
23 edit: 'editar',
24 turnOn: 'ligar',
25 turnOff: 'desligar',
26 reset: 'restaurar',
27 save: 'gravar',
28 showAll: (n: number) => `mostrar as ${n}`,
29 onlyChanged: 'só os fora do padrão',
30 prev: 'anteriores',
31 next: 'próximos',
32 range: (from: number, to: number, n: number) => `${from}–${to} de ${n}`,
33 refresh: 'atualizar',
34 close: 'fechar',
35 trailOff: 'trilha de auditoria desligada',
36 trailOffHint: 'gravar aqui ligaria a trilha: nada a mudar',
37 emptyHint: 'tokens faturáveis da janela; vazio = sem projeção',
38 problems: (n: number, label: string) => `✗ ${n} problema${n > 1 ? 's' : ''} · doctor › ${label} falha o gate`,
39 refused: '✗ config recusou · nada foi gravado',
40 oldJar: '! O ArchHook.jar deste projeto não tem o modo config.',
41 oldJarHint: 'Ele é anterior à decisão 0187. Atualize o .claude/ do projeto:',
42 failed: '! config list não rodou.',
43 write: 'Gravar',
44 cancel: 'Cancelar',
45 askSet: (path: string, key: string, from: string, to: string) => `Gravar ${path}: ${key} ${from} → ${to}?`,
46 askUnset: (key: string, to: string) => `Restaurar ${key} ao padrão (${to})?`,
47 askUnsetHint: 'Remove a chave do arquivo; um arquivo que fica vazio é apagado.',
48 written: (id: string, key: string, to: string) => `${id} · ${key} = ${to} · gravado`,
49 restored: (id: string, key: string) => `${id} · ${key} restaurado`,
50 change: 'Mudar',
51 default: 'padrão',
52 },
53 en: {
54 tagline: 'what this project sets in its own .claude/',
55 files: (n: number, off: number) => `${n} files · ${off} off default`,
56 noFiles: 'config unavailable',
57 seal: (n: number, problems: number) => `${n} files · ${problems} problems`,
58 offDefault: (n: number) => `${n} off default`,
59 notWritten: 'not written',
60 asDefault: 'default',
61 off: 'off',
62 invalid: 'invalid',
63 on: 'on',
64 unset: 'off',
65 classIs: (v: string) => `class: ${v}`,
66 defaultIs: 'default',
67 fixed: 'fixed',
68 set: 'set',
69 edit: 'edit',
70 turnOn: 'turn on',
71 turnOff: 'turn off',
72 reset: 'reset',
73 save: 'save',
74 showAll: (n: number) => `show all ${n}`,
75 onlyChanged: 'only off default',
76 prev: 'previous',
77 next: 'next',
78 range: (from: number, to: number, n: number) => `${from}–${to} of ${n}`,
79 refresh: 'refresh',
80 close: 'close',
81 trailOff: 'audit trail off',
82 trailOffHint: 'a write here would switch the trail on: nothing to change',
83 emptyHint: 'billable tokens of the window; empty = no projection',
84 problems: (n: number, label: string) => `✗ ${n} problem${n > 1 ? 's' : ''} · doctor › ${label} fails the gate`,
85 refused: '✗ config refused · nothing written',
86 oldJar: "! This project's ArchHook.jar has no config mode.",
87 oldJarHint: "It predates decision 0187. Update the project's .claude/:",
88 failed: '! config list did not run.',
89 write: 'Write',
90 cancel: 'Cancel',
91 askSet: (path: string, key: string, from: string, to: string) => `Write ${path}: ${key} ${from} → ${to}?`,
92 askUnset: (key: string, to: string) => `Reset ${key} to its default (${to})?`,
93 askUnsetHint: 'Removes the key from the file; a file left empty is deleted.',
94 written: (id: string, key: string, to: string) => `${id} · ${key} = ${to} · written`,
95 restored: (id: string, key: string) => `${id} · ${key} reset`,
96 change: 'Change',
97 default: 'default',
98 },
99}
100
101export type Words = (typeof WORDS)['pt']
102
103export function wordsOf(lang: Lang): Words {
104 return WORDS[lang]
105}
106
107/** `language` is the `/config` Language row's value (or undefined), `locale` the process's. */
108export function langOf(language: unknown, locale: string): Lang {
109 const named = typeof language === 'string' ? language.toLowerCase() : ''
110 if (named.startsWith('portugu') || named.startsWith('pt')) return 'pt'
111 if (named !== '' && named !== 'default' && named !== 'auto') return 'en'
112 return locale.toLowerCase().startsWith('pt') ? 'pt' : 'en'
113}
114