SLOPSHOPPER

aia-harness

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

newbandguardcommandtoastprocess
A shopper browsing a rack in a slop shop
README

🪐 aia-harness

Point it at any repo. Get a complete, stack-aware Claude Code setup in minutes

Hooks · agents · skills · rules · permissions · MCP · worktrees · per-domain memory — diagnosed, approved, and scaffolded for your codebase.

Tests Version Node Claude Code License: MIT PRs welcome


A good Claude Code setup — the right hooks, rules, agents, permissions, and CLAUDE.md files — 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 …

📋 Table of Contents


✨ Why aia-harness

👥 A team, not one agentEach 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 boilerplateFirst-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 designDiagnose → 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 snippetHooks, 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 buildHooks 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 includedToken-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 & deterministicThe engine is a pure, side-effect-free core backed by 50+ unit tests; detection reads, application writes, and the boundary between them is enforced.

👥 A Team of Specialists, Working Spec-First

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.


📦 Installation

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

💬 Prefer to paste a prompt instead of typing commands?

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.


🚀 Quick Start

Open Claude Code inside the project you want to harness, then:

cd /path/to/your-project
claude
StepCommandWhat happens
1️⃣ Diagnose/aia-harness:scanRead-only. Prints stack, package manager, frameworks, architectural domains, canonical commands, and any existing harness. Writes nothing.
2️⃣ Scaffold/aia-harness:initThe 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:doctorLater, 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.


🧩 Command Reference

All commands are namespaced /aia-harness:<name> and take an optional [path] (default: the current project).

🔧 Core lifecycle

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

🔌 Extensions

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

🩺 Utility

CommandWhat 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.
helpPrints the full command reference plus an "I want to…" decision guide mapping intent → command.

🔄 How It Works

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
  1. 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.
  2. 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.
  3. 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).


🎁 What Gets Scaffolded

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.
  • 🧩 Stack-matched agents, skills, and rules vendored from ECC and ag-kit (see Credits).

Always installed

HookEventBehavior
format-on-editPostToolUseRuns your formatter on edited files. Fails open — never blocks on a missing formatter.
verify-on-stopStopStrict 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-scanPreToolUseBlocks (exit 2) an Edit/Write whose payload looks like a committed secret.
guard-main-branchPreToolUseIntercepts git commit/push on main/master and asks you to confirm.
large-file-warningStop or PostToolUseDual-mode (chosen at init): block = refactor files > 350 lines before finishing; advisory = non-blocking refactor suggestion.
memory-stopStopComplexity-gated nudge to record session learnings (only after meaningful edits).
sql-idempotent-reviewPostToolUseOn any .sql change, asks Claude to make every statement idempotent.
check-deps-on-startSessionStartWarns when required system deps are missing. Fail-open.
set-files-changedPostToolUseRecords 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_EVENTS set — exit codes, hookSpecificOutput, the lot — cross-checked at compile time against @anthropic-ai/claude-agent-sdk's real TypeScript declarations.

SkillPurpose
run-testsRun the project's test suite (canonical command from CLAUDE.md) and report real results.
lint-fixLint + format the codebase and fix what's found.
pre-commit-verifyFull pre-commit/PR gate — typecheck, lint, and tests must all pass.
setup-testingSeeds unit tests in a project with none: installs the stack's framework, writes a real test, wires the script, runs to green.
goal-builderGenerates an optimized /goal command for autonomous / overnight execution.
uncle-bob-craftApplies Robert C. Martin's Clean Code/Architecture/Coder/Agile criteria during code review and writing — Dependency Rule, SOLID, code smells.
adianti-frameworkExpert guidance for PHP Adianti Framework 7.x/8.x (TRecord, TForm, TDataGrid, CRUD, master-detail).
novo-modulo-adiantiScaffolds a complete Adianti CRUD module by mirroring an existing one.
github-pmPM orchestrator for GitHub issues/PRs/Projects v2 (see Power-Ups).
AgentRole
architecture-mapperMaps the codebase into named domains/layers so per-domain CLAUDE.md files get accurate responsibilities.
stack-analystDeep-dives ambiguous stacks — verifies canonical lint/test/build commands against CI when deterministic scan isn't enough.
harness-reviewerAdversarially audits the freshly scaffolded harness: literal secrets, fail-open hooks, over-broad permissions, bloated CLAUDE.md — reported as file:line findings with fixes.

🧠 Stack Awareness

Detection drives everything: the rules you get, the reviewer agents assigned, and the canonical commands wired into hooks.

TierStacks
🥇 First-classJavaScript / 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 packsRust · Kotlin · C# · C++ · Dart — plus the per-stack packs above
🥉 Structured fallbackEverything 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

Source 7 files
hooks/status-band/register.mjs 477 lines
1// 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};
477
hooks/status-band/lib/marker.mjs 19 lines
1/**
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}
19
hooks/status-band/lib/home-guard.mjs 37 lines
1// 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}
37
hooks/status-band/lib/worktree.mjs 32 lines
1const 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}
32
hooks/status-band/lib/git-source.mjs 107 lines
1// 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}
107
hooks/status-band/lib/layout.mjs 201 lines
1/** @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}
201
hooks/status-band/types/index.d.ts 24 lines
1// 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