Scan a project and scaffold a complete Claude Code harness: hooks, skills, agents, rules, settings, strategic MCPs, worktree config and per-domain CLAUDE.md…

Hooks · agents · skills · rules · permissions · MCP · worktrees · per-domain memory — diagnosed, approved, and scaffolded for your codebase.
A good Claude Code setup — the right hooks, rules, agents, permissions, and
CLAUDE.mdfiles — is the difference between an assistant that guesses and one that knows your project. Building it by hand means learning every Claude Code primitive and wiring them together yourself. aia-harness does it for you, safely.
It scans your repository, diagnoses the stack, framework, and architecture, then scaffolds a complete harness-engineering setup: hooks, skills, agents, rules, settings.json, a project-root .mcp.json, env in settings.local.json, worktree config, a language-server strategy, and CLAUDE.md files at the root and per architectural domain. Every change runs through diagnose → approve → apply, with diffs. Nothing touches your repo without your consent. And once configured, each session runs not a lone assistant but a team of specialist agents under an orchestrator — working spec-first: understand, plan, review with you, then implement to your rules, patterns, and stack.
$ cd my-project && claude
> /aia-harness:scan
🔍 Diagnosing my-project …
├─ Language TypeScript (92%) · package manager: pnpm
├─ Frameworks Next.js 15 · React 19 · Prisma · Vitest
├─ Architecture app/ · components/ · lib/ · server/ (4 domains)
├─ Testing ✓ configured (Vitest)
└─ Harness none detected → run /aia-harness:init to scaffold
> /aia-harness:init
📋 Proposed plan — 23 artifacts (≈ 1.2k session-tokens)
◻ CLAUDE.md (root + 4 domains) ◻ rules (ddd, security, react, next, typescript)
◻ settings.json (least-privilege) ◻ hooks (format · verify · secret-scan · large-file)
◻ skills (run-tests · lint-fix · pre-commit-verify) ◻ .mcp.json (context7)
◻ ECC agents (react-reviewer · build-resolver) …
? Select what to apply › [ multi-select ]
✚ Showing diff for settings.json … Apply? (y/N)
✓ Applied. Running harness-reviewer for a safety pass …
| 👥 A team, not one agent | Each session runs an orchestrator that plans with you, then delegates to domain specialists — stack reviewers, build-resolvers, database, devops, QA, security, debugger — each loading only the skills and path-scoped rules its slice needs. Spec-first: plan → review → implement to your architecture. |
| 🎯 Stack-aware, not boilerplate | First-class detection for JS/TS, PHP, Java (Spring Boot vs Quarkus), Python, Go, and more. Every artifact adapts to what it finds — the right rules, the right reviewer agents, the right canonical commands. |
| 🛡️ Safe by design | Diagnose → approve → apply. A diff before every overwrite, secrets only as ${ENV} placeholders, *.local.* gitignored. You sign off on each change; nothing is silent. |
| 🧱 A whole harness, not a snippet | Hooks, agents, skills, rules, least-privilege permissions, strategic MCP servers, worktrees, and layered memory — generated as one coherent system in a single pass. |
| ⚡ Runs anywhere, zero build | Hooks are pure ESM .mjs — no compile step — and resolve a Node runtime even when node isn't on PATH (nvm-friendly). Identical behavior in plain-JS and TypeScript repos. |
| 🔋 Batteries included | Token-economy tools (caveman · ponytail · rtk) that cut token spend on the operations that dominate cost, a code-graph flow (graphify), a GitHub-native PM loop, and curated MCP servers — each one command away. |
| 🧪 Tested & deterministic | The engine is a pure, side-effect-free core backed by 50+ unit tests; detection reads, application writes, and the boundary between them is enforced. |
Once the harness is in place, a session stops behaving like one generalist assistant — it runs like a team.
An orchestrator leads; specialists do the work. A lead agent (orchestrator + project-planner) decomposes the task and delegates each slice to the right domain expert — stack-specific reviewers and build-resolvers, a database-architect, devops-engineer, qa-automation-engineer, security-auditor, debugger, frontend/backend specialists, and more — each pulling in only the skills and path-scoped rules its slice of the work actually needs. Specialist depth on every part of a task, instead of one context juggling all of it.
It works spec-first — not prompt-and-pray. The flow the harness encodes is deliberate:
understand → plan → review WITH you → delegate & implement → verify
The agent first understands the problem and proposes a plan; you review and approve it; only then does the team build — and it builds to the plan, obeying the project's rules, validations, design patterns, architecture, and stack (all encoded by the scaffolded rules and per-domain CLAUDE.md). No silent rewrites, no architecture drift.
Prerequisite: the Claude Code CLI, installed and authenticated. Node ≥ 18 is needed for the engine and hooks (the plugin resolves it automatically when a version manager has placed it on
PATH).
# 1. Add the registry (once — persists across every project)
claude plugin marketplace add leandrosilvaferreira/claude-plugins-registry
# 2. Install the plugin
claude plugin install aia-harness
# 3. Verify
claude plugin list # → aia-harness@leandro-plugins-registry ✔ enabled
Update to the latest version whenever a new release ships:
claude plugin update aia-harness@leandro-plugins-registry
Hand the setup to Claude in chat rather than running the CLI yourself. It's two messages, not one — a freshly installed plugin's commands aren't registered in the session that installed it, and Claude can't restart its own session or invoke /reload-plugins for you.
Message 1 — install the plugin (any project, any session):
Run via Bash:
claude plugin marketplace add leandrosilvaferreira/claude-plugins-registry
claude plugin install aia-harness
Then start a new session in the project you want to harness (or type /reload-plugins yourself — Claude can't trigger it for you).
Message 2 — scaffold the harness (new session, inside the target project):
/aia-harness:init
Option A — --plugin-dir (fastest for iteration; one session only):
cd /path/to/aia_harness && npm install
cd /path/to/target-project
claude --plugin-dir /path/to/aia_harness
# /aia-harness:scan, /aia-harness:init, … are available immediately
Option B — local marketplace (persists across sessions):
cd /path/to/aia_harness && npm install
claude plugin marketplace add /path/to/aia_harness
claude plugin install aia-harness@aia-harness
cd /path/to/target-project && claude
After editing plugin source, sync the installed copy with claude plugin update aia-harness.
Open Claude Code inside the project you want to harness, then:
cd /path/to/your-project
claude
| Step | Command | What happens |
|---|---|---|
| 1️⃣ Diagnose | /aia-harness:scan | Read-only. Prints stack, package manager, frameworks, architectural domains, canonical commands, and any existing harness. Writes nothing. |
| 2️⃣ Scaffold | /aia-harness:init | The full flow: scan → propose a plan → you multi-select what to apply → diffs for anything that would overwrite → apply → enrich root CLAUDE.md → generate rich domain CLAUDE.md files (via revise-claude-md) → safety review. |
| 3️⃣ Maintain | /aia-harness:doctor | Later, after a plugin update or codebase drift: audits the harness, additively adds what's missing, and merges in what's changed — adjudicating any conflict with you, one diff at a time, instead of overwriting. |
That's the loop. scan is always safe to run; init never writes without your approval; doctor keeps an existing harness healthy over time.
All commands are namespaced /aia-harness:<name> and take an optional [path] (default: the current project).
| Command | What it does |
|---|---|
scan [path] | Read-only diagnosis of stack, package manager, frameworks, monorepo layout, canonical commands, architecture, and existing harness. |
init [path] | The headline flow: scan → plan → consent (multi-select) → diffs → apply → enrich root CLAUDE.md → generate rich domain CLAUDE.md files (via revise-claude-md) → harness-reviewer safety pass → optional plugin/tool/MCP install. |
doctor [path] | Audits an existing harness (CLAUDE.md quality, settings safety, hook hygiene, drift after upgrades) and additively applies missing pieces — never mass-overwrites. |
patch [path] | Pick artifact categories (settings, hooks, rules, …) and merge the latest version into those — additive strategies (settings.json, .mcp.json, …) merge with the existing value winning; a CLAUDE.md section that exists and differs, or anything with no merge strategy, is adjudicated with you before it's written. Use when one part of a configured project needs a refresh, including a hand-customized harness. |
| Command | What it does |
|---|---|
add-mcp [path] | Suggests strategic MCP servers and merges chosen ones into .mcp.json — ${ENV} placeholders only, never literal tokens. |
add-plugins [path] | Generates an idempotent installer for the stack's recommended Claude Code plugins (code-review, hookify, feature-dev, per-language LSP, …) and runs it after one confirmation. |
add-tools [path] | Installs token-economy / code-graph tools — caveman & ponytail (global plugins), rtk (hook), graphify, and the worktrees skill — project-scoped. |
add-github-pm [path] | Activates the GitHub PM pillar: a PM skill, ten /pm:* commands, issue/PR templates, and four Projects-v2 sync workflows. |
| Command | What it does |
|---|---|
check-deps [path] | Reports required system dependencies (Node, Python, Go, …) with platform-specific install hints. Read-only. |
revise-claude-md [path] | Generates rich intermediate CLAUDE.md files for strategic subdirectories. Reads .claude/rules/ (recursive — including per-stack subdirs), analyzes real source files, maps rules to domains by relevance, and produces domain-specific files with ## Key patterns (concrete class names, DI tokens, naming conventions), ## Applied rules (condensed summaries + @-references), and ## Local conventions (derived from real code). Two-phase: map → approve → generate with diffs. Runs automatically as step 5.6 of init; run standalone to refresh after project structure or rules change. |
revise-agent-routing [path] | Audits .claude/agents/*.md frontmatter descriptions against the routing-description standard and fixes them in place; cross-references each agent against the project's CLAUDE.md ## Workflow & Agents table (or free-text mentions), fixing stale rows and flagging orphaned agents. Works on any existing project, not just one aia-harness scaffolded. |
check-consistency [path] | Whole-harness consistency audit: a deterministic pass resolves every cross-reference between skills, agents, rules, commands, hooks and every CLAUDE.md (root and nested), flagging references that point at nothing and hook scripts never wired in settings.json; parallel sonnet subagents then judge the ambiguous references and whether each artifact's content still fits the project's real stack. Consolidates one approval plan, applies approved fixes via parallel subagents, re-verifies deterministically. |
condense-harness-prompts [path] | Validates/auto-fixes artifact frontmatter, then semantically condenses your .claude/ markdown via parallel Opus subagents behind a deterministic safety gate. |
audit-prompts [path] | Prompt-fit audit: runs the prompt-audit subcommand of Claude Code's bundled claude-api skill in a subagent, against the models the project pins in .claude/settings.json. Scans CLAUDE.md files, rules, skills, agents and commands for text written for older Claude models (shouting emphasis, step-by-step scripts, scaffolds that an API feature has replaced, stale examples, instruction files that contradict each other), then returns a report plus a diff and writes nothing until you approve. Can take ten minutes or more and uses real tokens. Vendored ECC/ag-kit copies and the graphify skill are skipped. check-consistency, condense-harness-prompts and revise-agent-routing offer it as their last step. Needs the claude-api skill of a recent Claude Code; stops cleanly when it is absent. |
help | Prints the full command reference plus an "I want to…" decision guide mapping intent → command. |
aia-harness is two surfaces over one engine: an interactive Claude Code plugin, and a deterministic CLI the commands shell out to. Data flows through three pure-ish stages:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ SCAN │ │ PLAN │ │ APPLY │
│ (read-only) │ ───▶ │ (pure fn) │ ───▶ │ (writes) │
└──────────────┘ └──────────────┘ └──────────────┘
walks files once, ProjectProfile → dry-run unless --yes,
runs per-concern ordered list of merges by default,
detectors → Artifacts, each never overwrites a
ProjectProfile with contextCost differing file without --force
scan → ProjectProfile — walks the tree once, then runs independent detectors for language, package manager, frameworks, monorepo, commands, architecture, existing harness, VCS, testing gaps, and large files. Read-only.plan → HarnessPlan — turns the profile into an ordered list of Artifacts. Data catalogs decide what applies per detected stack; each artifact carries a contextCost (estimated tokens loaded every session — 0 means lazy/path-scoped) and a defaultSelected flag. Pure and side-effect-free.apply — writes the plan. Safe by default: dry-run unless --yes; settings.json, .mcp.json, and .worktreeinclude always merge additively when they exist and differ, the existing value winning — --force is what bypasses that. Merging is the default for everything else: a file that exists and differs with no mechanical strategy is left untouched and parked in conflicts[] for the user to adjudicate. The merged CLAUDE.md sections replace the whole section in place (or append it when absent) instead of merging key-by-key, so a section that exists and differs is parked in conflicts[] too rather than replaced. Updates .gitignore idempotently under an # aia-harness header.For non-trivial or ambiguous repos, init calls in read-only agents for human-level judgment — architecture-mapper (names the domains so per-domain CLAUDE.md is accurate), stack-analyst (verifies canonical commands against CI when detection is uncertain), and harness-reviewer (adversarially audits the scaffolded result before you trust it).
One init produces a coherent, least-privilege harness — not a pile of defaults. Every artifact is proposed with a diff before it lands.
CLAUDE.md — a concise root memory (stack + canonical commands) plus lazy-loaded per-domain files, enriched from your real source..claude/rules/*.md** — path-scoped rules loaded on demand: a base set (DDD, design patterns, coding principles, code quality, testing, security, subagent dispatch) plus per-stack packs..claude/settings.json — least-privilege permissions + JS hook wiring..claude/settings.local.json — env values, gitignored..mcp.json — strategic MCP servers with ${ENV} placeholders..claude/hooks/ — pure-ESM hooks run through a node-resolver wrapper (formatter, strict verify loop, secret guard, large-file guard, and more)..claude/skills/ — operational skills (run-tests, lint-fix, pre-commit-verify, setup-testing, uncle-bob-craft, …)..worktreeinclude, .lsp.json (opt-in), docs/harness/strategies.md, and an opt-in marketplace install script.Always installed
| Hook | Event | Behavior |
|---|---|---|
format-on-edit | PostToolUse | Runs your formatter on edited files. Fails open — never blocks on a missing formatter. |
verify-on-stop | Stop | Strict mode: blocks on real lint/typecheck failures so the agent self-corrects — but stays fail-open on infra and only fires when lintable code changed. Opt out with --no-strict. |
secret-scan | PreToolUse | Blocks (exit 2) an Edit/Write whose payload looks like a committed secret. |
guard-main-branch | PreToolUse | Intercepts git commit/push on main/master and asks you to confirm. |
large-file-warning | Stop or PostToolUse | Dual-mode (chosen at init): block = refactor files > 350 lines before finishing; advisory = non-blocking refactor suggestion. |
memory-stop | Stop | Complexity-gated nudge to record session learnings (only after meaningful edits). |
sql-idempotent-review | PostToolUse | On any .sql change, asks Claude to make every statement idempotent. |
check-deps-on-start | SessionStart | Warns when required system deps are missing. Fail-open. |
set-files-changed | PostToolUse | Records edited paths so the Stop hooks only fire when code actually changed. |
Worktree-aware (activate only inside .claude/worktrees/<name>): worktree-session-ctx (SessionStart), worktree-prompt-ctx (UserPromptSubmit), worktree-subagent-ctx (SubagentStart), worktree-write-guard (PreToolUse, asks before writing outside the worktree).
Stack-conditional (PHP): phpstan-on-edit (PostToolUse) runs PHPStan on the edited file and feeds findings back so Claude fixes them.
Every hook ships with unit tests asserting its output validates against the matching Claude Code event schema across all 30 hook types in the official SDK's
HOOK_EVENTSset — exit codes,hookSpecificOutput, the lot — cross-checked at compile time against@anthropic-ai/claude-agent-sdk's real TypeScript declarations.
| Skill | Purpose |
|---|---|
run-tests | Run the project's test suite (canonical command from CLAUDE.md) and report real results. |
lint-fix | Lint + format the codebase and fix what's found. |
pre-commit-verify | Full pre-commit/PR gate — typecheck, lint, and tests must all pass. |
setup-testing | Seeds unit tests in a project with none: installs the stack's framework, writes a real test, wires the script, runs to green. |
goal-builder | Generates an optimized /goal command for autonomous / overnight execution. |
uncle-bob-craft | Applies Robert C. Martin's Clean Code/Architecture/Coder/Agile criteria during code review and writing — Dependency Rule, SOLID, code smells. |
adianti-framework | Expert guidance for PHP Adianti Framework 7.x/8.x (TRecord, TForm, TDataGrid, CRUD, master-detail). |
novo-modulo-adianti | Scaffolds a complete Adianti CRUD module by mirroring an existing one. |
github-pm | PM orchestrator for GitHub issues/PRs/Projects v2 (see Power-Ups). |
| Agent | Role |
|---|---|
architecture-mapper | Maps the codebase into named domains/layers so per-domain CLAUDE.md files get accurate responsibilities. |
stack-analyst | Deep-dives ambiguous stacks — verifies canonical lint/test/build commands against CI when deterministic scan isn't enough. |
harness-reviewer | Adversarially audits the freshly scaffolded harness: literal secrets, fail-open hooks, over-broad permissions, bloated CLAUDE.md — reported as file:line findings with fixes. |
Detection drives everything: the rules you get, the reviewer agents assigned, and the canonical commands wired into hooks.
| Tier | Stacks |
|---|---|
| 🥇 First-class | JavaScript / TypeScript (React, Next.js, Vue, Nuxt, SvelteKit, Angular, NestJS, Remix, Astro…) · PHP (Laravel, Symfony, Adianti, and native PHP without Composer) · Java (Maven & Gradle, with Spring Boot vs Quarkus detection) · Python (Django, FastAPI, Flask) · Go (preferring golangci-lint when configured) |
| 🥈 Dedicated rule packs | Rust · Kotlin · C# · C++ · Dart — plus the per-stack packs above |
| 🥉 Structured fallback | Everything else (Ruby, Scala, Elixir, Swift, …) gets a generic-but-structured harness plus ECC/ag-kit assets where available — verify the canonical commands before relying on them. |
Framework-level smarts include Spring Boot vs Quarkus (different run/test/build per framework), Next.js vs plain React, Nuxt vs plain Vue, Laravel vs native PHP, and Django vs FastAPI — each mapped to its own rules, reviewer agents, and test-framework recommendation.
🧪 **Testing-gap detect
hooks/status-band/register.mjs 477 lines1// Status band above the prompt: worktree, branch, open PR and PM shortcut buttons.
2// Engine rules: one `register`, one matcher-less hook per event, `$` never crosses an import
3// (functions that take `$` live here; ./lib/*.mjs stay pure), every hook ends in `.catch(next)`.
4// No recurring timer, no background process: state refreshes on events only. The one exception
5// is a one-shot, cancellable `$.clock.after` that reverts the two-click confirm label.
6import { atom, read, update } from "claude-code";
7
8import { isBandEnabled } from "./lib/marker.mjs";
9import { isHomeDir } from "./lib/home-guard.mjs";
10import { resolveWorktree } from "./lib/worktree.mjs";
11import { createGitSource } from "./lib/git-source.mjs";
12import { availableButtons, buildModel } from "./lib/layout.mjs";
13
14/**
15 * @typedef {import("./types/index.js").StatusBandState} State
16 * @typedef {import("./lib/layout.mjs").ButtonId} ButtonId
17 */
18
19const CONFIRM_MS = 5000;
20
21/** @type {Record<ButtonId, string>} */
22const COMMANDS = {
23 "issue-work-loop": "pm:issue-work-in-loop",
24 "issue-work": "pm:issue-work",
25 "commit-pr": "pm:commit-push-pr",
26 "merge-pr": "pm:pr-merge",
27 "remove-worktree": "pm:worktree-remove",
28};
29/** Buttons that need a second click within CONFIRM_MS. */
30const CONFIRMED = new Set(["merge-pr", "remove-worktree"]);
31
32/** @type {State} */
33const EMPTY = {
34 enabled: false,
35 pmFiles: [],
36 worktree: "",
37 branch: "",
38 pr: null,
39 confirm: null,
40 rev: 0,
41};
42const band = atom({ plugin: "aia-harness", key: "statusBand" }, EMPTY);
43
44/**
45 * What a confirm click acts on: the PR number (merge-pr) or the worktree name (remove-worktree).
46 * @param {{ pr: { number: number } | null, worktree: string }} s
47 * @param {string} id
48 * @returns {string}
49 */
50const targetOf = (s, id) =>
51 id === "merge-pr"
52 ? s.pr
53 ? String(s.pr.number)
54 : ""
55 : id === "remove-worktree"
56 ? s.worktree
57 : "";
58
59/**
60 * Keeps an armed confirm only while its target is still the current one.
61 * @param {State["confirm"]} c @param {{ pr: { number: number } | null, worktree: string }} s
62 * @returns {State["confirm"]}
63 */
64const stillArmed = (c, s) => (c && c.target === targetOf(s, c.id) ? c : null);
65
66/** @param {any} $ @returns {Promise<State>} */
67const load = async ($) => ({ ...EMPTY, ...(await read($, band)) });
68
69/**
70 * Link refuses the whole tree on a bad href, so only a canonical https URL becomes a Link.
71 * @param {string} url
72 */
73function safeHref(url) {
74 try {
75 return /^https:\/\/[\x21-\x7e]+$/.test(url) && !url.includes("@") && new URL(url).href === url;
76 } catch {
77 return false;
78 }
79}
80
81/**
82 * Worktree of a directory. `resolveWorktree` is synchronous, so the `.git` candidates it
83 * asks about are collected first, checked through `$.fs`, then asked again with the answers.
84 * @param {any} $ @param {string} cwd
85 */
86async function worktreeOf($, cwd) {
87 /** @type {string[]} */
88 const asked = [];
89 resolveWorktree(cwd, (p) => (asked.push(p), false));
90 const found = new Set();
91 for (const p of asked) if (await $.fs.exists(p).catch(() => false)) found.add(p);
92 return resolveWorktree(cwd, (p) => found.has(p));
93}
94
95/** @param {import("./lib/git-source.mjs").PullRequest | undefined} pr */
96const prValue = (pr) => (pr ? { number: pr.number, url: pr.url } : null);
97
98// shortcut: module-level state assumes ONE module instance per Claude Code process; if that
99// stops holding, key it by `$.session.id()`.
100/** @type {Map<string, { mtime: number, enabled: boolean, pmFiles: string[] }>} */
101const markers = new Map();
102/** True once a refresh got past the drawing gate for this session; a first render asks for one until then. */
103let refreshed = false;
104/** The one-shot timer that runs the first refresh outside the render hook. */
105/** @type {{ cancel: () => void } | undefined} */
106let firstTimer;
107/** True while any button press runs: taken synchronously at the top of `press`. */
108let busy = false;
109/** The `/aia-bar` command is registered once, after the first refresh that finds the mark. */
110let commandRegistered = false;
111/** Project root of the last refresh. */
112let lastRoot = "";
113/** The pending revert of the confirm label. */
114/** @type {{ cancel: () => void } | undefined} */
115let confirmTimer;
116/** Directory git and gh run in: the session cwd, or the worktree a tool entered or left. */
117let cwd = "";
118/** Clock reading of the last refresh: the git source needs a synchronous `now`. */
119let lastNow = 0;
120/** @type {{ run: (argv: string[], opts: { timeoutMs: number }) => Promise<any>, now: () => Promise<number> } | undefined} */
121let io;
122// Every git/gh call gets the project cwd and its timeout (the git source passes `timeoutMs`).
123const gitSource = createGitSource({
124 run: (argv, opts) => /** @type {NonNullable<typeof io>} */ (io).run(argv, opts),
125 now: () => lastNow,
126});
127
128/**
129 * True while any surface draws. The SDK (VS Code extension) starts with none and gets one later;
130 * a headless -p run never has one. Never rejects.
131 * @param {any} $
132 */
133async function drawing($) {
134 try {
135 return (await $.session.surfaces()).length > 0;
136 } catch {
137 return false;
138 }
139}
140
141function cancelFirstTimer() {
142 firstTimer?.cancel();
143 firstTimer = undefined;
144}
145
146function cancelConfirmTimer() {
147 confirmTimer?.cancel();
148 confirmTimer = undefined;
149}
150
151/**
152 * Arms the confirm and the one-shot timer that clears it after CONFIRM_MS.
153 * @param {any} $ @param {number} until
154 */
155function armConfirmTimer($, until) {
156 cancelConfirmTimer();
157 confirmTimer = $.clock.after(CONFIRM_MS, () => {
158 confirmTimer = undefined;
159 update($, band, (/** @type {State} */ x) => ({
160 ...EMPTY,
161 ...x,
162 confirm: x.confirm && x.confirm.until === until ? null : x.confirm,
163 })).catch(() => {});
164 });
165}
166
167/** @param {any} $ */
168async function dropConfirm($) {
169 cancelConfirmTimer();
170 await update($, band, (/** @type {State} */ x) => ({ ...EMPTY, ...x, confirm: null })).catch(
171 () => {},
172 );
173}
174
175/**
176 * Marker result per project root, valid while settings.json keeps its mtime.
177 * @param {any} $ @param {string} root
178 */
179async function marker($, root) {
180 const file = `${root}/.claude/settings.json`;
181 const mtime = await $.fs.stat(file).then(
182 (/** @type {any} */ st) => st.mtimeMs,
183 () => -1,
184 );
185 const hit = markers.get(root);
186 if (hit && hit.mtime === mtime) return hit;
187 const enabled = mtime >= 0 && isBandEnabled(await $.fs.read(file).catch(() => undefined));
188 const entries = enabled ? await $.fs.list(`${root}/.claude/commands/pm`).catch(() => []) : [];
189 const value = {
190 mtime,
191 enabled,
192 pmFiles: entries
193 .filter((/** @type {any} */ f) => f.kind === "file")
194 .map((/** @type {any} */ f) => f.name),
195 };
196 markers.set(root, value);
197 return value;
198}
199
200/**
201 * Refreshes branch and PR into the atom. Without the mark nothing but the mark check runs.
202 * @param {any} $ @param {string} [dir]
203 */
204async function refresh($, dir) {
205 if (!(await drawing($))) return;
206 refreshed = true;
207 cancelFirstTimer();
208 io = { run: (argv, opts) => $.process.run(argv, { ...opts, cwd }), now: () => $.clock.now() };
209 lastNow = await $.clock.now();
210 cwd = dir || cwd || (await $.session.cwd());
211 const hidden = (await $.store.get("hidden")) === true;
212 const wt = await worktreeOf($, cwd);
213 const root = wt ? wt.root : cwd;
214 lastRoot = root;
215 // The home directory is never a project: its .claude/settings.json is the user's own.
216 const home = {
217 home: await $.env.get("HOME").catch(() => undefined),
218 userProfile: await $.env.get("USERPROFILE").catch(() => undefined),
219 homeDrive: await $.env.get("HOMEDRIVE").catch(() => undefined),
220 homePath: await $.env.get("HOMEPATH").catch(() => undefined),
221 };
222 const m = isHomeDir(root, home) ? { enabled: false, pmFiles: [] } : await marker($, root);
223 if (!m.enabled) {
224 cancelConfirmTimer();
225 if (JSON.stringify(await load($)) !== JSON.stringify(EMPTY)) {
226 await update($, band, () => ({ ...EMPTY }));
227 }
228 return;
229 }
230 if (!commandRegistered) {
231 commandRegistered = true;
232 try {
233 await $.command.register({
234 name: "aia-bar",
235 description: "Show or hide the aia-harness status band",
236 argumentHint: "[on|off]",
237 });
238 } catch {
239 // Registering the command is a convenience.
240 }
241 }
242 // A hidden band does no git/gh work; /aia-bar on refreshes when it comes back.
243 if (hidden) return;
244 const branch = (await gitSource.branch(root)) ?? "";
245 const pr = branch ? await gitSource.pr(root, branch) : undefined;
246 const next = { worktree: wt ? wt.name : "", pr: prValue(pr) };
247 if (!stillArmed((await load($)).confirm, next)) cancelConfirmTimer();
248 await update($, band, (/** @type {State} */ s) => ({
249 ...EMPTY,
250 ...s,
251 enabled: true,
252 pmFiles: m.pmFiles,
253 branch,
254 ...next,
255 confirm: stillArmed(s.confirm, next),
256 }));
257}
258
259/** @param {any} $ @param {string} [dir] */
260async function refreshSafe($, dir) {
261 try {
262 await refresh($, dir);
263 } catch {
264 // The band is a convenience: a failed refresh leaves the last state.
265 }
266}
267
268/** @type {import("claude-code").Register} */
269export const register = (on) => {
270 // No matcher: the extension starts with isInteractive false and no surface. Nothing runs until one draws.
271 on("session.start", async ($, e, next) => {
272 await refreshSafe($, e.cwd);
273 return next(e);
274 }).catch(($, e, next) => next(e));
275
276 on("turn.complete", async ($, e, next) => {
277 // A subagent's turn is not the session's: no refresh.
278 if (e.agentId) return next(e);
279 const r = await next(e);
280 try {
281 await refreshSafe($);
282 } catch {
283 // The band never breaks a turn.
284 }
285 return r;
286 }).catch(($, e, next) => next(e));
287
288 // The tool result holds the new directory; `$.session.cwd()` still answers the old one here.
289 on("tool.call", { tool: ["EnterWorktree", "ExitWorktree"] }, async ($, e, next) => {
290 if (e.agentId) return next(e);
291 const r = await next(e);
292 try {
293 if (r.deny === undefined && !r.isError) {
294 const result = /** @type {any} */ (r.result);
295 // restoredCwd is originalCwd, or the fallback directory when originalCwd is gone.
296 const dir =
297 e.tool === "EnterWorktree"
298 ? result?.worktreePath
299 : (result?.restoredCwd ?? result?.originalCwd);
300 await dropConfirm($);
301 await refreshSafe($, dir);
302 }
303 } catch {
304 // The band never breaks a tool call.
305 }
306 return r;
307 }).catch(($, e, next) => next(e));
308
309 // /clear and /resume start over; `hidden` lives in the store and stays.
310 on("session.end", { reason: ["clear", "resume"] }, async ($, e, next) => {
311 gitSource.reset();
312 cancelConfirmTimer();
313 cancelFirstTimer();
314 refreshed = false;
315 await update($, band, (s) => ({ ...EMPTY, ...s, confirm: null })).catch(() => {});
316 return next(e);
317 }).catch(($, e, next) => next(e));
318
319 on("command.run", { command: "aia-bar" }, async ($, e) => {
320 const arg = String(e.args ?? "")
321 .trim()
322 .toLowerCase();
323 const hidden =
324 arg === "off" ? true : arg === "on" ? false : (await $.store.get("hidden")) !== true;
325 await $.store.set("hidden", hidden);
326 // `rev` changes the atom so the band redraws even when nothing else changed.
327 await update($, band, (s) => ({ ...EMPTY, ...s, rev: s.rev + 1 }));
328 if (!hidden) await refreshSafe($);
329 return {
330 text: hidden ? "Status band hidden. /aia-bar on shows it again." : "Status band shown.",
331 };
332 }).catch(($, e, next) => next(e));
333
334 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
335 // A render hook may not write state: the first render only schedules the refresh, once.
336 if (!refreshed && !firstTimer) {
337 try {
338 firstTimer = $.clock.after(0, () => {
339 firstTimer = undefined;
340 refreshSafe($).catch(() => {});
341 });
342 } catch {
343 // The band is a convenience.
344 }
345 }
346 if (e.props.hasSurvey) return next(e);
347 let s;
348 try {
349 // Read the atom first: a render that never reads it is not redrawn when it changes.
350 s = await load($);
351 if ((await $.store.get("hidden")) === true) return next(e);
352 } catch {
353 return next(e);
354 }
355 if (!s.enabled) return next(e);
356 // Draw ours above whatever other mods put in this slot; the harness rejects when nothing answers below.
357 // next runs exactly once: a failed drawing below must not ask it again.
358 const below = await next(e).catch(() => undefined);
359 try {
360 const now = await $.clock.now();
361 const { Box, Text, Button, Link } = $.ui.resolve(e);
362 const armed =
363 s.confirm && now < s.confirm.until && s.confirm.target === targetOf(s, s.confirm.id)
364 ? /** @type {ButtonId} */ (s.confirm.id)
365 : undefined;
366 const model = buildModel({
367 worktree: s.worktree || undefined,
368 branch: s.branch || undefined,
369 pr: s.pr || undefined,
370 buttons: availableButtons(s.pmFiles, { hasPr: !!s.pr, inWorktree: !!s.worktree }),
371 columns: e.props.bodyColumns,
372 armed,
373 });
374
375 /** @param {ButtonId} id */
376 const press = async (id) => {
377 // Taken before any await: a second click in the same tick sees it.
378 if (busy) return;
379 busy = true;
380 try {
381 const t = await $.clock.now();
382 const until = t + CONFIRM_MS;
383 let fire = false;
384 let armedNow = false;
385 // The updater may run again on a version miss: it recomputes `fire` from `x` each time.
386 await update($, band, (/** @type {State} */ x) => {
387 const target = targetOf(x, id);
388 const c = x.confirm;
389 const confirmed = !!c && c.id === id && c.target === target && t < c.until;
390 fire = !CONFIRMED.has(id) || confirmed;
391 armedNow = !fire && target !== "";
392 return {
393 ...EMPTY,
394 ...x,
395 confirm: fire ? null : armedNow ? { id, target, until } : x.confirm,
396 };
397 });
398 if (!fire) {
399 if (armedNow) armConfirmTimer($, until);
400 return;
401 }
402 cancelConfirmTimer();
403 try {
404 await $.command.run({ command: COMMANDS[id] });
405 } catch {
406 $.ui.toast("aia-harness status band: could not run the command");
407 }
408 // After the command: a failure here must not look like a failed command.
409 try {
410 // A command can open the PR: the cached "no PR" must not hide it for 5 minutes.
411 gitSource.invalidate(lastRoot, (await load($)).branch);
412 await refreshSafe($);
413 } catch {
414 // The band keeps its last state.
415 }
416 } catch {
417 // Arm step failed (state update): nothing ran.
418 $.ui.toast("aia-harness status band: could not run the command");
419 } finally {
420 busy = false;
421 }
422 };
423 const close = async () => {
424 await $.store.set("hidden", true);
425 await update($, band, (x) => ({ ...EMPTY, ...x, rev: x.rev + 1 }));
426 };
427
428 // The close button leads the first row: the first button row, else the first info row.
429 const closeButton = () => Button({ key: "close", plain: true, label: "✕", onPress: close });
430 const rows = model.buttonRows.map((row, r) =>
431 Box({
432 key: `btn-row-${r}`,
433 flexDirection: "row",
434 gap: 1,
435 children: [
436 ...(r === 0 ? [closeButton()] : []),
437 ...row.map((b) =>
438 Button({
439 key: `btn-${b.id}`,
440 plain: true,
441 label: b.label,
442 onPress: () => press(b.id),
443 }),
444 ),
445 ],
446 }),
447 );
448 model.infoRows.forEach((row, r) => {
449 const items = row.map((i) => {
450 if (i.kind === "pr") {
451 return s.pr && safeHref(s.pr.url)
452 ? Link({ href: s.pr.url, children: [Text({ children: i.text })] })
453 : Text({ children: i.text });
454 }
455 return Text({
456 dimColor: true,
457 children: `${i.kind === "worktree" ? "\u{1F333}" : "⎇"} ${i.text}`,
458 });
459 });
460 rows.push(
461 Box({
462 key: `info-row-${r}`,
463 flexDirection: "row",
464 gap: 2,
465 width: e.props.bodyColumns,
466 children: r === 0 && !model.buttonRows.length ? [closeButton(), ...items] : items,
467 }),
468 );
469 });
470 const mine = Box({ flexDirection: "column", children: rows });
471 return below ? Box({ flexDirection: "column", children: [mine, below] }) : mine;
472 } catch {
473 return below;
474 }
475 }).catch(($, e, next) => next(e));
476};
477hooks/status-band/lib/marker.mjs 19 lines1/**
2 * Decides whether the status band is enabled for a project.
3 * @param {string | undefined} settingsText Raw text of `<project>/.claude/settings.json`.
4 * @returns {boolean} True when `enabledPlugins` has an `aia-harness@*` key set to `true`.
5 */
6export function isBandEnabled(settingsText) {
7 if (typeof settingsText !== "string") return false;
8 try {
9 // A BOM makes JSON.parse throw, which disables the band on purpose.
10 const plugins = JSON.parse(settingsText)?.enabledPlugins;
11 if (!plugins || typeof plugins !== "object") return false;
12 return Object.entries(plugins).some(
13 ([key, value]) => key.startsWith("aia-harness@") && value === true,
14 );
15 } catch {
16 return false;
17 }
18}
19hooks/status-band/lib/home-guard.mjs 37 lines1// Pure check: is a project root the user's home directory? No `$`, no Node-only imports.
2
3/**
4 * Canonical form for comparing paths across Linux, macOS, Windows and Git Bash.
5 * @param {string} p
6 * @returns {{ path: string, windows: boolean }}
7 */
8function normalize(p) {
9 let s = p.replace(/\\/g, "/").replace(/\/+/g, "/");
10 // Git Bash / MSYS drive path: /c/Users/x -> C:/Users/x
11 s = s.replace(/^\/([a-zA-Z])(?=\/|$)/, (_, d) => `${d}:`);
12 if (s.length > 1 && s.endsWith("/")) s = s.slice(0, -1);
13 const windows = /^[a-zA-Z]:/.test(s);
14 if (windows) s = s[0].toUpperCase() + s.slice(1);
15 return { path: s, windows };
16}
17
18/**
19 * @param {string} root
20 * @param {{ home?: string, userProfile?: string, homeDrive?: string, homePath?: string }} env
21 * @returns {boolean}
22 */
23export function isHomeDir(root, env) {
24 const candidates = [
25 env.home,
26 env.userProfile,
27 env.homeDrive && env.homePath ? env.homeDrive + env.homePath : undefined,
28 ].filter((c) => typeof c === "string" && c !== "");
29 const r = normalize(root);
30 return candidates.some((c) => {
31 const h = normalize(/** @type {string} */ (c));
32 return r.windows && h.windows
33 ? r.path.toLowerCase() === h.path.toLowerCase()
34 : r.path === h.path;
35 });
36}
37hooks/status-band/lib/worktree.mjs 32 lines1const WT_RE = /[/\\]\.claude[/\\]worktrees[/\\]/;
2
3/**
4 * @typedef {object} WorktreeInfo
5 * @property {string} name Worktree name; multi-segment names keep their `/`.
6 * @property {string} root Worktree root directory.
7 * @property {string} realRoot Main checkout that owns `.claude/worktrees`.
8 */
9
10/**
11 * Resolves the worktree a directory belongs to.
12 * @param {string} cwd Session working directory (`/` or `\` separators).
13 * @param {(p: string) => boolean} exists Reports whether a path exists.
14 * @returns {WorktreeInfo | null} Null outside a worktree.
15 */
16export function resolveWorktree(cwd, exists) {
17 const m = WT_RE.exec(cwd);
18 if (!m) return null;
19 const realRoot = cwd.slice(0, m.index);
20 const sepBefore = m[0].slice(-1);
21 const base = cwd.slice(0, m.index + m[0].length);
22 const segments = cwd.slice(base.length).split(/[/\\]/).filter(Boolean);
23 // Shortest prefix holding a `.git` is the worktree root.
24 for (let i = 1; i <= segments.length; i++) {
25 const root = base + segments.slice(0, i).join(sepBefore);
26 if (exists(root + sepBefore + ".git")) {
27 return { name: segments.slice(0, i).join("/"), root, realRoot };
28 }
29 }
30 return null;
31}
32hooks/status-band/lib/git-source.mjs 107 lines1// git/gh data source for the status band, with strict resource limits.
2// No Node APIs and no timers: the mod injects `run` and `now`, expiry comes from `now()`.
3
4const TIMEOUT_MS = 5000;
5const CACHE_MS = 60_000;
6const BACKOFF_MS = 5 * 60_000;
7
8/**
9 * @typedef {{ exitCode: number, stdout: string }} RunResult
10 * @typedef {{
11 * run(argv: string[], opts: { timeoutMs: number }): Promise<RunResult>,
12 * now(): number
13 * }} GitSourceDeps
14 * @typedef {{ number: number, url: string }} PullRequest
15 * @typedef {{
16 * branch(cwd: string): Promise<string | undefined>,
17 * pr(root: string, branch: string): Promise<PullRequest | undefined>,
18 * invalidate(root: string, branch: string): void,
19 * reset(): void
20 * }} GitSource
21 */
22
23/**
24 * @param {GitSourceDeps} deps
25 * @returns {GitSource}
26 */
27export function createGitSource({ run, now }) {
28 /** @type {Map<string, { value: PullRequest | undefined, expires: number }>} */
29 let cache = new Map();
30 /** @type {Map<string, Promise<PullRequest | undefined>>} */
31 let inflight = new Map();
32 /** @type {Promise<unknown>} */
33 let tail = Promise.resolve();
34 // Bumped by reset() so a query started before it cannot repopulate the cache.
35 let epoch = 0;
36
37 /** @param {string} root @param {string} branch @param {number} startedEpoch Epoch when the caller asked. @returns {Promise<PullRequest | undefined>} */
38 async function query(root, branch, startedEpoch) {
39 /** @type {PullRequest | undefined} */
40 let value;
41 let ttl = CACHE_MS;
42 try {
43 const r = await run(["gh", "pr", "view", branch, "--json", "number,url,state"], {
44 timeoutMs: TIMEOUT_MS,
45 });
46 if (r.exitCode !== 0) throw new Error("gh failed");
47 const j = JSON.parse(r.stdout);
48 if (j && j.state === "OPEN" && typeof j.number === "number" && typeof j.url === "string") {
49 value = { number: j.number, url: j.url };
50 }
51 } catch {
52 value = undefined;
53 ttl = BACKOFF_MS;
54 }
55 if (startedEpoch === epoch) cache.set(`${root}\0${branch}`, { value, expires: now() + ttl });
56 return value;
57 }
58
59 return {
60 async branch(cwd) {
61 try {
62 const r = await run(["git", "-C", cwd, "branch", "--show-current"], {
63 timeoutMs: TIMEOUT_MS,
64 });
65 if (r.exitCode !== 0) return undefined;
66 return r.stdout.trim() || undefined;
67 } catch {
68 return undefined;
69 }
70 },
71
72 pr(root, branch) {
73 const key = `${root}\0${branch}`;
74 const hit = cache.get(key);
75 if (hit && now() < hit.expires) return Promise.resolve(hit.value);
76 const pending = inflight.get(key);
77 if (pending) return pending;
78 // One gh process at a time overall: a query for another key waits its turn.
79 // The map is captured: after reset() the old query must not delete an entry of the new map.
80 const map = inflight;
81 const askedEpoch = epoch;
82 const p = tail
83 .then(() => query(root, branch, askedEpoch))
84 .finally(() => {
85 if (map.get(key) === p) map.delete(key);
86 });
87 tail = p.catch(() => undefined);
88 inflight.set(key, p);
89 return p;
90 },
91
92 invalidate(root, branch) {
93 const key = `${root}\0${branch}`;
94 cache.delete(key);
95 inflight.delete(key);
96 epoch++;
97 },
98
99 reset() {
100 cache = new Map();
101 inflight = new Map();
102 // `tail` stays: a query in flight keeps its place, so two gh never run at once.
103 epoch++;
104 },
105 };
106}
107hooks/status-band/lib/layout.mjs 201 lines1/** @typedef {'issue-work-loop' | 'issue-work' | 'commit-pr' | 'merge-pr' | 'remove-worktree'} ButtonId */
2/** @typedef {{ kind: 'worktree' | 'branch' | 'pr', text: string }} Item */
3/** @typedef {{ id: ButtonId, label: string }} DrawnButton `label` is the drawn (possibly truncated) text. */
4/** @typedef {{ buttonRows: DrawnButton[][], infoRows: Item[][] }} BandModel The close button leads the first row: the first button row, or the first info row when there are no buttons. */
5
6/** Button label per id; the rendered form is `[label]`. */
7export const BUTTON_LABELS = {
8 "issue-work-loop": "Issue work loop",
9 "issue-work": "Issue work",
10 "commit-pr": "Commit+PR",
11 "merge-pr": "Merge PR",
12 "remove-worktree": "Remove worktree",
13};
14
15// Icon cell widths (the tree emoji takes two cells) + one space after the icon.
16/** @type {Record<string, number>} */
17const ICON_WIDTH = { worktree: 3, branch: 2 };
18const GAP = 2;
19const CLOSE_WIDTH = 2; // the "✕" that leads the first row, plus one space
20// On an info row the ✕ is followed by the row gap (2), not one space.
21const CLOSE_INFO_WIDTH = 1 + GAP;
22const MAX_ROWS = 4;
23
24/**
25 * @param {string[]} entries File names in `.claude/commands/pm/`.
26 * @returns {boolean} True when the project has any PM command file.
27 */
28export function hasPmCommands(entries) {
29 return entries.length > 0;
30}
31
32/**
33 * @param {string[]} files File names in `.claude/commands/pm/`.
34 * @param {{ hasPr: boolean, inWorktree: boolean }} ctx
35 * @returns {ButtonId[]} Buttons in the spec's fixed order.
36 */
37export function availableButtons(files, ctx) {
38 const has = (/** @type {string} */ f) => files.includes(f);
39 /** @type {ButtonId[]} */
40 const out = [];
41 if (has("issue-work-in-loop.md")) out.push("issue-work-loop");
42 else if (has("issue-work.md")) out.push("issue-work");
43 if (has("commit-push-pr.md")) out.push("commit-pr");
44 if (ctx.hasPr && has("pr-merge.md")) out.push("merge-pr");
45 if (ctx.inWorktree && has("worktree-remove.md")) out.push("remove-worktree");
46 return out;
47}
48
49/**
50 * Drawn text of a button; an armed one asks for the second click.
51 * @param {ButtonId} id
52 * @param {boolean} armed
53 * @param {number | undefined} prNumber
54 * @returns {string}
55 */
56export function buttonLabel(id, armed, prNumber) {
57 const ask = !armed ? "" : id === "merge-pr" && prNumber !== undefined ? ` #${prNumber}?` : "?";
58 return `[${BUTTON_LABELS[id]}${ask}]`;
59}
60
61/**
62 * Middle truncation: keeps the start and the end around a `…`, so a branch
63 * name stays recognisable (`feat/31-…cash-out`). Width is counted in code points.
64 * Below 4 cells there is no room for two ends: the first `max - 1` chars and `…`.
65 * @param {string} text
66 * @param {number} max Maximum width in cells.
67 * @returns {string}
68 */
69export function truncate(text, max) {
70 const chars = [...text];
71 if (chars.length <= max) return text;
72 if (max <= 0) return "";
73 if (max < 4) return chars.slice(0, max - 1).join("") + "…";
74 const keep = max - 1;
75 const head = Math.ceil(keep / 2);
76 return chars.slice(0, head).join("") + "…" + chars.slice(chars.length - (keep - head)).join("");
77}
78
79/**
80 * @param {Item} item
81 * @returns {number} Display width of the item.
82 */
83function itemWidth(item) {
84 return (ICON_WIDTH[item.kind] ?? 0) + [...item.text].length;
85}
86
87/**
88 * Greedy row packing: an item that does not fit starts a new row.
89 * @param {number[]} widths
90 * @param {number} gap
91 * @param {number} columns
92 * @param {number} lead Cells already taken at the start of the first row.
93 * @returns {number[][]} Item indexes per row.
94 */
95function pack(widths, gap, columns, lead) {
96 /** @type {number[][]} */
97 const rows = [];
98 let used = 0;
99 widths.forEach((w, i) => {
100 const row = rows[rows.length - 1];
101 if (row && used + gap + w <= columns) {
102 row.push(i);
103 used += gap + w;
104 } else {
105 rows.push([i]);
106 used = (rows.length === 1 ? lead : 0) + w;
107 }
108 });
109 return rows;
110}
111
112/**
113 * @param {BandModel} model
114 * @returns {{ buttonRows: number[], infoRows: number[] }} Width of each row; the close button counts on the first row.
115 */
116export function bandWidths(model) {
117 const sum = (/** @type {number[]} */ ws, /** @type {number} */ gap) =>
118 ws.reduce((n, w) => n + w, 0) + gap * Math.max(0, ws.length - 1);
119 const closeOnButtons = model.buttonRows.length > 0;
120 return {
121 buttonRows: model.buttonRows.map(
122 (r, i) =>
123 sum(
124 r.map((b) => [...b.label].length),
125 1,
126 ) + (closeOnButtons && i === 0 ? CLOSE_WIDTH : 0),
127 ),
128 infoRows: model.infoRows.map(
129 (r, i) => sum(r.map(itemWidth), GAP) + (!closeOnButtons && i === 0 ? CLOSE_INFO_WIDTH : 0),
130 ),
131 };
132}
133
134/**
135 * Builds the band model. Nothing is dropped: items are packed into rows of at
136 * most `columns` cells and long texts are truncated with `…`. When more than
137 * MAX_ROWS rows are needed, the longest text (never the PR) shrinks first.
138 * @param {{ worktree?: string, branch?: string, pr?: { number: number, url: string }, buttons: ButtonId[], columns: number, armed?: ButtonId }} input
139 * @returns {BandModel}
140 */
141export function buildModel({ worktree, branch, pr, buttons, columns, armed }) {
142 /** @type {Item[]} */
143 const info = [];
144 if (worktree) info.push({ kind: "worktree", text: worktree });
145 if (branch) info.push({ kind: "branch", text: branch });
146 if (pr) info.push({ kind: "pr", text: `#${pr.number} ↗` });
147 const labels = buttons.map((b) => buttonLabel(b, b === armed, pr?.number));
148
149 // The close button leads the first row, so that row's first item gets less room.
150 const closeOnButtons = buttons.length > 0;
151 const infoCaps = info.map((i, n) =>
152 Math.max(
153 1,
154 columns - (ICON_WIDTH[i.kind] ?? 0) - (!closeOnButtons && n === 0 ? CLOSE_INFO_WIDTH : 0),
155 ),
156 );
157 const buttonCaps = labels.map((_, n) => Math.max(1, columns - (n === 0 ? CLOSE_WIDTH : 0)));
158
159 const build = () => {
160 const shownInfo = info.map((i, n) => ({ ...i, text: truncate(i.text, infoCaps[n]) }));
161 const shownButtons = buttons.map((id, n) => ({
162 id,
163 label: truncate(labels[n], buttonCaps[n]),
164 }));
165 const buttonRows = pack(
166 shownButtons.map((b) => [...b.label].length),
167 1,
168 columns,
169 CLOSE_WIDTH,
170 ).map((r) => r.map((n) => shownButtons[n]));
171 const infoRows = pack(
172 shownInfo.map(itemWidth),
173 GAP,
174 columns,
175 closeOnButtons ? 0 : CLOSE_INFO_WIDTH,
176 ).map((r) => r.map((n) => shownInfo[n]));
177 // Nothing to show: an empty row still hosts the lone close button.
178 if (!buttonRows.length && !infoRows.length) infoRows.push([]);
179 return { buttonRows, infoRows, shownInfo, shownButtons };
180 };
181
182 let built = build();
183 while (built.buttonRows.length + built.infoRows.length > MAX_ROWS) {
184 // Shrink the widest shrinkable text by one cell.
185 /** @type {{ w: number, caps: number[], n: number } | undefined} */
186 let best;
187 for (const [n, i] of built.shownInfo.entries()) {
188 const w = [...i.text].length;
189 if (i.kind !== "pr" && w > 1 && (!best || w > best.w)) best = { w, caps: infoCaps, n };
190 }
191 for (const [n, b] of built.shownButtons.entries()) {
192 const w = [...b.label].length;
193 if (w > 1 && (!best || w > best.w)) best = { w, caps: buttonCaps, n };
194 }
195 if (!best) break;
196 best.caps[best.n] = best.w - 1;
197 built = build();
198 }
199 return { buttonRows: built.buttonRows, infoRows: built.infoRows };
200}
201hooks/status-band/types/index.d.ts 24 lines1// State the status band reads when it draws. Declared for `atom`/`read`/`update`.
2export type StatusBandState = {
3 /** True when the project carries the aia-harness mark. */
4 enabled: boolean;
5 /** File names in `.claude/commands/pm/`. */
6 pmFiles: string[];
7 /** Worktree name, empty outside a worktree. */
8 worktree: string;
9 /** Current git branch, empty when unknown. */
10 branch: string;
11 /** Open pull request of the branch. */
12 pr: { number: number; url: string } | null;
13 /** Armed two-click confirmation: the button, what it acts on (PR number or worktree name) and the time it expires (ms). */
14 confirm: { id: string; target: string; until: number } | null;
15 /** Bumped when only visibility changes, so the band redraws. */
16 rev: number;
17};
18
19declare module "claude-code" {
20 interface PluginState {
21 "aia-harness": { statusBand: StatusBandState };
22 }
23}
24