Organic Driven Development orchestration, review lenses, and delivery skills for Claude Code

Construct is a Claude Code plugin that makes every request follow Organic Driven Development (ODD): authorize, explore, size, track, implement test-first, and close with evidence and a Risk line.
Preview. Construct changes quickly, and usage feedback is the most useful contribution right now: open an issue before a pull request. Changes are in CHANGELOG.md; vulnerabilities go through SECURITY.md.
An agent writes code faster than you can check it. Organic Driven Development keeps each step small enough to check: the agent stays read-only until you ask for a change, sizes the task, tracks large work in a feature document (odd/tasks/<feature>.md), writes a failing test before the fix, and ends with what it verified and a Risk line naming anything hard to undo or hard to detect. Risky work is reviewed by independent lens agents before hooks let it be pushed.
/config.delivery), review, memory, and more.git push, gh pr create, and gh release create./construct.Principal Engineer output style.construct-memory), with no extra plugin, server, or binary. See Memory.| Requirement | Why |
|---|---|
| Claude Code 2.1.304 or later, signed in | The panel is a mod, and the panel needs 2.1.304. Tested on 2.1.304 |
| Node.js 22.16 or later (Node 23 is not supported) | The plugin's scripts and hooks, and the memory's node:sqlite with FTS5. Claude Code's native install does not bundle Node.js |
git on the PATH | The review and publication hooks read the repository |
curl -fsSL https://raw.githubusercontent.com/carsimax/construct/v8.7.0/install.sh | bash
The installer checks the requirements above and stops before changing anything if one is missing, or if ~/.claude or ~/.claude/settings.json is not writable, or if settings.json is a directory. Then it runs the manual steps below for you:
construct@construct.~/.claude/settings.json: one permissions.allow rule per non-destructive construct-memory command (search, get, context, timeline, stats, doctor, suggest-topic-key, save, update, summary, review list, review stale, review passive, prompts, prompts --limit, prompts --search, pin, unpin, judge, judge-many, compare, help; delete, prompts delete, consolidate, import, export, and review done still ask), and removes a theme of custom:construct:construct left by earlier versions (any other theme is left alone). Destructive commands run only in their exact form: any extra option or word is refused, which keeps the pre-approved prefixes from reaching them. An empty settings.json counts as {}. Other keys stay as they are, a symlinked file (even a dangling one, whose target is then created) is written through to its target with its mode kept, and the previous file is saved as settings.json.bak-<timestamp>.If engram or codegraph is installed, the installer says Construct no longer needs it and removes nothing. Every step is skipped when it is already done, so running the installer again changes nothing. It never changes other permissions or your permission mode. The output style needs no setting: the plugin forces Principal Engineer, and an outputStyle you already have stays in the file but has no effect while construct is enabled.
| Option | Meaning |
|---|---|
--no-mcp | Deprecated: has no effect and prints a warning. It is accepted so earlier install commands keep working, and will be removed in a later release |
--force | Deprecated: has no effect and prints a warning. It is accepted for now so scripted installs keep working, and will be removed in a later release |
Pass options after bash -s --, for example curl -fsSL <url> | bash -s -- --no-mcp. To read the script before running it:
curl -fsSL https://raw.githubusercontent.com/carsimax/construct/v8.7.0/install.sh -o install.sh
less install.sh
bash install.sh
The manual steps the installer runs, for when you want to run them yourself:
claude plugin marketplace add carsimax/construct
claude plugin install construct@construct
The installer (install.sh) pins this marketplace to the release tag (carsimax/construct#v8.7.0); the command above follows the default branch.
/config, set each role's model and effort (rows such as Worker model).Risk: item N rule./agents lists the construct: agents, such as construct:worker and construct:review-risk./construct opens the panel; /construct doctor prints a setup report.Open Claude Code in a git repository and make a request. Construct applies the same steps each time:
odd/tasks/<feature>.md (specs, tasks, log) before the first source write.Risk: none or Risk: item N (reason)).Commit, push, pull request, and merge stay your decisions. A change whose task in the feature document has a Risk column naming a high-risk item is reviewed before it can be published; see Mandatory review. There is no switch to turn the review off.
To confirm the install works, see step 4 of Manual install.
Construct updates through Claude Code's plugin commands. Running install.sh again does not update them: it skips every step that is already done.
claude plugin marketplace update construct # fetch the latest catalog from carsimax/construct
claude plugin update construct@construct # install the new version
Restart Claude Code afterwards; a plugin update applies to new sessions. claude plugin list shows the installed version.
Options are plugin settings, edited in /config:
| Option | Effect |
|---|---|
| Model and effort per role | See the routing table below. Each option defaults to inherit: use the deprecated key of the same role if it is set, else the agent's own frontmatter |
panel_open_on_start (boolean, default true) | Open the panel at every session start |
inline_edit_max_lines (number, default 150) | Let the main thread edit repository files directly when one Write, Edit, MultiEdit, or NotebookEdit call changes at most this many lines; surface-guard sends bigger changes to construct:worker. 0 restores the old rule: the main thread writes only odd/tasks/ |
slice_lines (number, default 1500) | Authored changed lines on a feature branch after which the commit-slice notice asks you to apply the delivery strategy. A target, not a cap. A value that is not a whole number of 1 or more falls back to the default |
command_guard_asks (boolean, default false) | Let command-guard ask before destructive commands (rm -rf, force push, reset --hard, and the rest). Denies of verifier bypasses always apply |
The plugin ships 10 agents. Each role's model and effort can be changed in /config, where every option appears as a row such as Worker model or Review risk effort.
| Role | Agent | Model | Effort |
|---|---|---|---|
| Explorer | construct:explorer | haiku | medium |
| Worker | construct:worker | sonnet | medium |
| Verifier | construct:verifier | opus | medium |
| Judge A | construct:judge-a | opus | medium |
| Judge B | construct:judge-b | sonnet | medium |
| Review risk | construct:review-risk | opus | medium |
| Review readability | construct:review-readability | sonnet | medium |
| Review reliability | construct:review-reliability | sonnet | medium |
| Review resilience | construct:review-resilience | sonnet | medium |
| General | construct:general and the built-in general-purpose | sonnet | medium |
How the routing works:
hooks/register.js) applies the configured model when a subagent spawns and the configured effort on each of its requests.default keeps the model's own effort.construct:explorer stops after 30 turns, construct:verifier after 50, and each lens after 2. A capped agent returns its output marked partial, and it can be resumed.delegation, review, and workflow skills are model-only (user-invocable: false): the orchestrator loads them, and they do not appear in the / menu. Ask for a review in plain words.model_odd_*, effort_odd_*, model_jd_*, and effort_jd_* are deprecated and still read while the matching model_<role> / effort_<role> is inherit (for example model_odd_worker for model_worker); set the new keys instead.Deep material for people who want to know how each part works.
A plugin cannot ship a CLAUDE.md, so Construct loads its rules in layers:
| Layer | Mechanism | Content |
|---|---|---|
| Session contract | SessionStart hook (startup, resume, clear, compact) | ODD protocol, task size, high-risk list, Risk line, and delegation triggers, under 8,000 characters |
| Subagent contract | SubagentStart hook | Artifact language, edit surfaces, scratch, and the ## Key Learnings closing block. JSON-only reviewers (review-*, judge-*) get nothing |
| Skills | Loaded on demand | workflow, delegation, debugging, constraints |
| Output style | Forced by the plugin (force-for-plugin), in every project | Reply tone, language, response length, verification, and working rules (no AI attribution in commits, one question at a time) |
The contract texts live in plugins/construct/hooks/contract/.
After a compaction or a resume, a second SessionStart hook (matcher compact|resume) re-injects the active feature state. It reads odd/tasks/*.md under the project root and, for up to three documents with unfinished tasks (newest first), emits the path, title, branch line, unfinished task rows, and the last Next step: from the log, in at most 2,500 characters. It is read-only and prints nothing when no document is active. Task-to-document sync hooks were dropped: native tasks have no link to the document's T# rows, so a hook could corrupt the feature document. The orchestrator keeps the document current, and the re-injection tells it where it was.
A PreToolUse hook on Agent (hooks/scripts/delegation-admission.mjs) denies a malformed launch, with a reason that names what is missing and the expected form. It runs next to the review-context hook; an admitted launch prints nothing, so the two never conflict. Other agents are untouched.
construct:worker) need a ## Allowed edit surfaces section with at least one entry per line. An entry that is absolute, starts with ~ or a drive letter, contains .., or starts with a glob (a bare * or ** included) is denied, as is prose inside the section; a path with whitespace goes in backticks, and repeated sections must agree.Spec: odd/tasks/<feature>.md, T<n> naming an existing regular file under odd/tasks/ (a symlink does not count). Without such a document, small work needs no Spec: line.(dashboard)). A segment with brackets, a backslash, or nested or unbalanced braces may match anything at its own position only, while ** and an extglob (@(, !(, +(, *(, ?() overlap everything from their segment on. The hook records each admitted writer (its surfaces and a hash of its prompt) under ${CLAUDE_PLUGIN_DATA}/delegation-admission/<session>/ (same fallback as the gates), and a SubagentStop hook (delegation-admission.mjs stop) removes the record whose prompt hash matches the first user row of agent_transcript_path. A record older than one hour counts as a crashed writer and is dropped. The check and the record run under an mkdir lock beside the records (<session>.lock, a lock older than 10 seconds is broken, a wait over 3 seconds fails open), so two launches in the same parallel batch cannot both pass.worker and verifier are admitted the same with or without the construct: prefix.construct:verifier) needs a line Risk: item N (N 1-6). Risk: none does not admit a verifier.agent() calls never reach the Agent hook, so the same script also runs on PreToolUse for Workflow and admits a script only when every agent() call names a literal agentType that cannot edit files: construct:explorer, the construct:review-* lenses, construct:judge-a, construct:judge-b (prefix optional), Explore, or Plan. A call with no agentType (the default workflow agent can write), a writing or unknown type, a non-literal, computed, quoted, or spread agentType (every agentType key counts), agent passed, aliased, or destructured, any reference to the global object (globalThis, this, self, window, global), an escape outside a string, eval, Function, any constructor reference, and a / right after } (put such a regex in a variable) are denied. The script is read from script, from scriptPath (relative to the cwd; unreadable is denied), or, for name, from a saved workflow in .claude/workflows/ of the project or of the home directory; a bundled workflow name and a bare resumeFromRunId pass. Writes go through construct:worker with the Agent tool. Strings, template text, and comments are not calls; this is a discipline check on the script text, not a sandbox.The hook fails open on unreadable input or any internal error; a well-formed launch that breaks a rule is denied.
Construct ships one MCP server, its own memory server (plugins/construct/.mcp.json, started with node, no dependencies; see Memory), and no third-party server. Structural questions (architecture, call flow, references, impact) use the native LSP tool when a language server is available, and Grep, Glob, and Read otherwise.
The installer no longer registers context7 or codegraph. Earlier registrations can be removed with claude mcp remove --scope user context7 and claude mcp remove --scope user codegraph (the codegraph CLI itself, if you installed it with npm, is npm uninstall -g @colbymchenry/codegraph).
Memory is per repository: one SQLite database at <git common dir>/construct-memory/memory.db, shared by every worktree of the clone and never committed. It needs Node 22.16 or later and is off outside a git repository. Claude reaches it through the plugin's MCP server or the construct-memory command (on the Bash tool's PATH), and the construct:memory skill.
memory server exposes mem_save, mem_search, mem_context, mem_get_observation, mem_update, mem_timeline, mem_session_summary, mem_judge, mem_suggest_topic_key, and mem_relations_pending (Claude Code names them mcp__plugin_construct_memory__<tool>). Each call runs the CLI once, so the CLI stays the single implementation and the fallback; a CLI failure comes back as an error result and a call that takes over 10 seconds as construct-memory timed out. The server works in CLAUDE_PROJECT_DIR when set, else its working directory. install.sh pre-approves these ten tools by exact name. delete, prompts delete, consolidate, import, export, and review done have no tool: they are CLI-only and keep asking. construct:explorer has a fixed tool list without the MCP tools and uses the CLI.save, search, get, context, update, delete, summary --session <id>, review list|done <id>, review stale [--json] (read-only: live memories whose Where: names repository paths that no longer exist), review passive [--days N] [--json] (read-only: live passive memories never read with get and at least N days old, default 30, oldest first, to consolidate or delete), import <file>, export [file], suggest-topic-key (prints a key from a JSON type, title, content on stdin), timeline <id>, pin <id> / unpin <id> (pinned memories load in every session context and rank higher in search), stats, doctor [--json] (read-only health checks, exit 1 when one fails), prompts --search <query>, consolidate <id> <id>... --title T (merge several memories into one: content on stdin, a preview and fingerprint first, then --confirm <fingerprint>), and judge, judge-many, compare (verdicts on related memories), and relations pending [--limit N] [--json] (lists the pending relation candidates). construct-memory help lists the flags. get counts each read of a live memory (accessed: N, columns access_count and last_accessed_at; search, context, and timeline do not count) and a read memory ranks slightly higher in search.construct-memory prints up to 3 similar memories as candidates; judging them is optional; the session context counts pending relations and relations pending lists them; and search and get mark judged conflicts_with and supersedes relations. Export and import carry relations between exported memories.## Key Learnings: section is captured as memories (an item whose words mostly match an earlier learning is skipped), session end marks the session closed, and a one-line reminder appears after 15 minutes without a save. <private>...</private> text is never stored, and text that looks like prompt injection is rejected, dropped, or filtered before it is stored.construct-memory delete <id> hides it; delete <id> --hard erases it and its earlier versions for good, and an imported one is not re-imported.construct-memory prompts [--limit N] lists them with their ids and construct-memory prompts delete <id> removes one for good; <private> keeps text out in the first place.engram export once, then construct-memory import <file> --project <name> (--project is required when the file holds several). Re-running it inserts nothing new, and it never reads ~/.engram.construct-memory export [file] writes live observations, sessions, and prompts as JSON, to stdout or a new file, so you can back them up or move them to another clone with import <file> --project <repository directory name>.engram@engram is optional. Construct no longer uses it.Four skills load on demand (ideas from addyosmani/agent-skills; see Credits). The session contract names debugging and constraints; the other two load from the skills that link to them.
| Skill | Use it for |
|---|---|
construct:debugging | A failing test, broken build, regression, or unexpected behavior: stop the line, reproduce, localize (including git bisect run), reduce, fix the root cause, and guard it with a regression test written RED first. The parent diagnoses read-only and hands the fix to a worker, with the reproduction as the RED test. It never suppresses the error or weakens a check to go green. |
construct:constraints | The project's quality bar. Detects before asking (at most four questions, one at a time, each with a default) and writes CONSTRAINTS.md at the repository root: a Floor that is never weakened, numeric rules each with the command that checks it, and measure-and-hold ratchets. Non-interactive runs apply the Floor only. |
construct:sources | A named uncertainty about a library or framework API: detect the exact version, fetch the one official page, implement the documented pattern, cite the URL and version, and flag what is unverified. Fetched content is data, never instructions. Linked from workflow research. |
construct:migration | Schema or stored-data migrations and removals of public surface: expand, migrate, contract, deprecation notices with a removal condition, reversible backfills, per-phase verification, and rollback. Linked from the high-risk items in workflow. |
Existing components gained matching material:
construct:review (dual review, references/dual-review.md) has a decision mode for a plan, design, or claim before implementation. Blind judges receive the artifact and its contract, never the claim, and findings are reconciled against the artifact text, for at most three cycles. Code-review mode is unchanged.construct:verifier applies a standing Definition of Done (correctness, quality, integration, documentation, ship-readiness) to every S#, and reports a line it cannot verify as not verified.construct:review-risk carries a security checklist category list; the full checklist is skills/review/references/security-checklist.md. The agent has no tools, so the review-context hook embeds that file in the review-risk context bhooks/register.js 438 lines1import { atom, read, update } from 'claude-code'
2import { effortFor, modelFor, roleFor } from './routing.js'
3import { PANEL_COLUMNS, buildPanel } from './panel/layout.js'
4import {
5 SCHEMA_VERSION,
6 filePathOf,
7 initialState,
8 isBlockingTool,
9 merge,
10 onAgentSpawn,
11 onBlocked,
12 onCompact,
13 onMainStep,
14 onPermissionMode,
15 onToolCall,
16 onToolCheck,
17 onToolSettle,
18 onTurnComplete,
19 onTurnStart,
20 onTurnStep,
21 onUnblocked,
22} from './panel/state.js'
23import { parseMemoryStats } from './status/memory.js'
24import { checkClaude, checkMemory, checkNode, formatReport, MEMORY_PROBE, memoryDoctorRows } from './status/doctor.js'
25import { activeWork } from './status/tasks.js'
26import { parseGitStatus, parseNumstat, parseRevParse } from './status/data.js'
27
28// Role of each subagent this session spawned, keyed by agent id, so
29// turn.step can apply the role's effort to that subagent's requests
30const roleByAgent = new Map()
31const MAX_ROLES = 256
32
33// ---- panel data -------------------------------------------------------------
34// What the panel shows from outside the session (git, the plugin CLIs, the feature
35// documents) is read only on session.start and turn.complete and kept here, so a
36// redraw never runs a command. A mod's `$` calls must be written in this file.
37
38const GIT_MS = 3000
39const MAX_UNTRACKED = 20
40const status = { data: null, effort: null, running: false, again: false }
41
42// ---- panel state ------------------------------------------------------------
43// The panel's state lives in one atom. A `ui.render` hook that reads it is drawn
44// again whenever it is written, so the panel redraws on event boundaries only
45// (spawn, step end, tool call and check, turn start and complete, compact), never
46// per token. `merge` resets a stale or mismatched value. Feeding it is observe-only:
47// a failure here is swallowed and never reaches the event.
48
49const PANEL_ID = 'construct'
50const PANEL = atom({ plugin: 'construct', key: 'panel' }, initialState(), { shape: `construct.panel.v${SCHEMA_VERSION}` })
51
52async function feed($, change) {
53 try {
54 await update($, PANEL, (stored) => change(merge(stored)))
55 } catch {
56 // never break the event
57 }
58}
59
60async function nowMs($) {
61 try {
62 return await $.clock.now()
63 } catch {
64 return Date.now()
65 }
66}
67
68// The project root the inline-write detector compares paths with.
69async function projectRoot($) {
70 if (status.data?.root) return status.data.root
71 try {
72 return await $.session.cwd()
73 } catch {
74 return undefined
75 }
76}
77
78// Runs a command by argv; stdout on exit 0, else undefined. Never throws.
79async function runText($, argv, cwd, timeoutMs) {
80 try {
81 const result = await $.process.run(argv, { cwd, timeoutMs })
82 return result.exitCode === 0 ? result.stdout : undefined
83 } catch {
84 return undefined
85 }
86}
87
88// Line count of one untracked file: `git diff --no-index --numstat /dev/null <file>`. It exits 1 when the
89// file differs from /dev/null, which is its normal answer, so 0 and 1 both count. A binary file gives
90// `-` (no count); a failure gives null and the panel shows `new`.
91async function untrackedFile($, cwd, path) {
92 try {
93 const r = await $.process.run(['git', '--no-optional-locks', 'diff', '--no-index', '--numstat', '--', '/dev/null', path], { cwd, timeoutMs: GIT_MS })
94 if (r.exitCode !== 0 && r.exitCode !== 1) return { path, added: null, removed: null, untracked: true }
95 const row = parseNumstat(r.stdout)[0]
96 return { path, added: row?.added ?? null, removed: null, binary: row?.binary === true, untracked: true }
97 } catch {
98 return { path, added: null, removed: null, untracked: true }
99 }
100}
101
102// The files changed against HEAD (staged and unstaged, numstat) and the untracked ones from status.
103// Only the first MAX_UNTRACKED untracked files are counted (one short git call each, in parallel);
104// the rest, and any directory entry (`dir/`), show as `new` without a count, so a tree of untracked
105// files never costs more than a bounded number of processes.
106async function collectChanges($, cwd, git, numstatText) {
107 const tracked = parseNumstat(numstatText).map((f) => ({ ...f, untracked: false }))
108 const paths = git?.untracked ?? []
109 const counted = await Promise.all(
110 paths.map((path, i) => (i < MAX_UNTRACKED && !path.endsWith('/') ? untrackedFile($, cwd, path) : { path, added: null, removed: null, untracked: true })),
111 )
112 return [...tracked, ...counted]
113}
114
115async function readDocs($, root) {
116 const docs = []
117 try {
118 const dir = `${root}/odd/tasks`
119 for (const entry of await $.fs.list(dir)) {
120 if (entry.kind !== 'file' || !entry.name.endsWith('.md')) continue
121 try {
122 docs.push({ name: entry.name, mtimeMs: entry.mtimeMs, text: await $.fs.read(`${dir}/${entry.name}`) })
123 } catch {
124 // unreadable document: skip it
125 }
126 }
127 } catch {
128 // no odd/tasks directory
129 }
130 return docs
131}
132
133// Gathers everything the panel caches. Each source fails on its own: a gap leaves its segment out.
134async function collect($) {
135 // The plugin forces its output style, which overrides settings.outputStyle
136 const data = { git: null, changes: [], root: null, odd: null, memory: null }
137 let cwd
138 try {
139 cwd = await $.session.cwd()
140 } catch {
141 // no directory known
142 }
143 const [gitText, revText, numstatText] = await Promise.all([
144 runText($, ['git', '--no-optional-locks', 'status', '--porcelain=v2', '--branch', '-z'], cwd, GIT_MS),
145 runText($, ['git', 'rev-parse', '--show-toplevel'], cwd, GIT_MS),
146 runText($, ['git', '--no-optional-locks', 'diff', '--numstat', '--no-renames', 'HEAD'], cwd, GIT_MS),
147 ])
148 data.git = parseGitStatus(gitText)
149 data.changes = data.git ? await collectChanges($, cwd, data.git, numstatText) : []
150 const rev = parseRevParse(revText)
151 const root = rev?.root ?? cwd
152 data.root = root ?? null
153 if (root) {
154 data.odd = activeWork(await readDocs($, root))
155 }
156 // Only inside a git repository (the CLI would fail anyway); a gap leaves the card out.
157 data.memory = rev?.root ? await memoryStats($, rev.root) : { off: 'not a git repository' }
158 return data
159}
160
161// Refreshes the cache and asks for a redraw. Runs detached from the hook that
162// starts it, so it never delays a turn; an event that lands meanwhile reruns it once.
163async function refresh($) {
164 if (status.running) {
165 status.again = true
166 return
167 }
168 status.running = true
169 try {
170 do {
171 status.again = false
172 const next = await collect($)
173 status.data = next
174 try {
175 await $.ui.invalidate('ui.render')
176 } catch {
177 // no surface to redraw
178 }
179 } while (status.again)
180 } catch {
181 // keep the last good data
182 } finally {
183 status.running = false
184 }
185}
186
187// The figures read live on each redraw: they are free, and they move during a turn.
188async function readLive($) {
189 const live = { usage: null, model: null, nowMs: Date.now() }
190 try {
191 live.usage = await $.session.usage()
192 } catch {
193 // no usage yet
194 }
195 try {
196 live.model = (await $.session.model()) || null
197 } catch {
198 // no model known
199 }
200 try {
201 live.nowMs = await $.clock.now()
202 } catch {
203 // keep the local clock
204 }
205 return live
206}
207
208// The docked panel body, drawn only for this plugin's pane (its requestId is the pane id).
209async function drawPane($, e, next) {
210 const data = status.data
211 if (e.requestId !== PANEL_ID) return next(e)
212 const live = await readLive($)
213 const panel = merge(await read($, PANEL))
214 const tree = buildPanel({
215 panel,
216 usage: live.usage,
217 model: live.model,
218 effort: status.effort,
219 git: data?.git ?? null,
220 changes: data?.changes ?? [],
221 odd: data?.odd ?? null,
222 memory: data?.memory ?? null,
223 nowMs: live.nowMs,
224 columns: e.props?.bodyColumns,
225 rows: e.props?.scroll?.bodyRows,
226 })
227 return tree ?? next(e)
228}
229
230async function openPane($) {
231 return $.ui.open({ id: PANEL_ID, title: 'Construct', columns: PANEL_COLUMNS })
232}
233
234// Settles with the promise, or with null after `ms`; the timer is cleared once the promise settles.
235async function withTimeout(promise, ms) {
236 let timer
237 try {
238 return await Promise.race([promise, new Promise((resolve) => { timer = setTimeout(resolve, ms, null) })])
239 } finally {
240 clearTimeout(timer)
241 }
242}
243
244// Runs one doctor probe; a failure or a timeout is null, never a throw.
245async function probe(read) {
246 try {
247 return (await withTimeout(read(), GIT_MS)) ?? null
248 } catch {
249 return null
250 }
251}
252
253// stdout of the plugin's own `construct-memory doctor --json`, or null. It exits 1 when a check fails but still
254// prints the list, so the exit code is not read here (runText would drop that output).
255async function memoryDoctor($, cwd) {
256 return probe(async () => (await $.process.run(['node', memoryBin(), 'doctor', '--json'], { cwd, timeoutMs: GIT_MS })).stdout)
257}
258
259// shortcut: pathname breaks on Windows drive-letter paths (/C:/...), switch to fileURLToPath if Windows is supported
260const memoryBin = () => decodeURIComponent(new URL('../bin/construct-memory', import.meta.url).pathname)
261
262// The memory card's data (S20): `construct-memory stats --json` for this session. The exit code and stderr
263// are read (not runText) to tell memory being off from a failed run; null leaves the card out.
264async function memoryStats($, cwd) {
265 const session = await probe(async () => $.session.id())
266 const argv = ['node', memoryBin(), 'stats', '--json', ...(session ? ['--session', session] : [])]
267 return parseMemoryStats(await probe(() => $.process.run(argv, { cwd, timeoutMs: GIT_MS })))
268}
269
270// /construct doctor (S8): every probe runs in parallel with a 3 s timeout (GIT_MS), so the report comes back
271// well inside the hook budget. The checks themselves are pure, in status/doctor.js.
272async function runDoctor($) {
273 const cwd = await probe(() => $.session.cwd())
274 const rev = parseRevParse(await probe(() => runText($, ['git', 'rev-parse', '--show-toplevel'], cwd ?? undefined, GIT_MS)))
275 const [version, node, memory] = await Promise.all([
276 probe(async () => (await $.session.version()).version),
277 probe(() => runText($, ['node', '--version'], cwd ?? undefined, GIT_MS)),
278 probe(() => runText($, ['node', '-e', MEMORY_PROBE], cwd ?? undefined, GIT_MS)),
279 ])
280 const memoryRow = checkMemory(memory)
281 const rows = [checkClaude(version), checkNode(node), memoryRow]
282 if (rev?.root && memoryRow.ok) rows.splice(3, 0, ...memoryDoctorRows(await memoryDoctor($, rev.root)))
283 return formatReport(rows)
284}
285
286// /construct opens the pane, /construct close closes it, /construct doctor reports the setup.
287async function runCommand($, e) {
288 const arg = String(e.args ?? '').trim().toLowerCase()
289 if (arg === 'doctor') return { text: await runDoctor($) }
290 if (arg === 'close') {
291 await $.ui.close({ id: PANEL_ID })
292 return { text: 'Construct panel closed.' }
293 }
294 const opened = await openPane($)
295 return { text: opened?.isPlaced === false ? `Construct panel waiting: ${opened.reason ?? 'no room yet'}` : 'Construct panel opened.' }
296}
297
298// Remembers the effort of the main thread's requests, which the replica shows beside the model.
299function noteEffort(e) {
300 try {
301 if (e.agentId === undefined && e.effort !== undefined) status.effort = String(e.effort)
302 } catch {
303 // never block a step
304 }
305}
306
307export function register(on, options) {
308 // Send each construct agent on the model its role is configured for, unless the call names one.
309 // If this hook fails, the agent keeps its frontmatter model.
310 on('agent.spawn', async ($, e, next) => {
311 const role = roleFor(e.subagentType)
312 const model = e.model ? null : modelFor(role, options)
313 const result = await next(model ? { ...e, model } : e)
314 if (role && result?.agentId) {
315 roleByAgent.set(result.agentId, role)
316 // Never deleted on turn.complete (a resumed subagent needs its role); the oldest go past the cap.
317 if (roleByAgent.size > MAX_ROLES) roleByAgent.delete(roleByAgent.keys().next().value)
318 }
319 await noteSpawn($, e, result, role, options)
320 if (!e.parentAgentId && e.permissionMode) await feed($, (s) => onPermissionMode(s, { mode: e.permissionMode }))
321 return result
322 })
323
324 // Apply the role's effort to every request a routed subagent makes
325 on('turn.step', async function* ($, e, next) {
326 noteEffort(e)
327 const effort = effortFor(roleByAgent.get(e.agentId), options)
328 const result = yield* next(effort ? { ...e, effort } : e)
329 if (e.agentId === undefined) await feed($, (s) => onMainStep(s, { agentId: e.agentId, model: e.model, effort: e.effort }))
330 if (e.agentId !== undefined) await feed($, (s) => onTurnStep(s, { agentId: e.agentId, usage: result?.usage }))
331 return result
332 })
333
334 // Fill the panel's cache when the session starts. Detached, so the start is never held up.
335 on('session.start', async ($, e, next) => {
336 const result = await next(e)
337 void refresh($)
338 void setUpPanel($, options)
339 return result
340 }).catch(($, e, next) => next(e))
341
342 // Refresh it after each main-thread turn, never during one.
343 on('turn.complete', async ($, e, next) => {
344 const result = await next(e)
345 if (e.agentId === undefined) void refresh($)
346 try {
347 const at = await nowMs($)
348 await feed($, (s) => onTurnComplete(onUnblocked(s), { agentId: e.agentId, turnId: e.turnId, nowMs: at }))
349 } catch {
350 // observe only
351 }
352 return result
353 }).catch(($, e, next) => next(e))
354
355 // A turn began: a main turn marks the panel busy, a subagent's runs again.
356 on('turn.start', async ($, e, next) => {
357 const result = await next(e)
358 try {
359 const at = await nowMs($)
360 await feed($, (s) => onTurnStart(onUnblocked(s), { agentId: e.agentId, turnId: e.turnId, nowMs: at }))
361 } catch {
362 // observe only
363 }
364 return result
365 }).catch(($, e, next) => next(e))
366
367 // A tool call: counts edits and errors, and main-thread writes outside odd/tasks. The call stays open
368 // while a question or a plan waits on the human (S8b), so AskUserQuestion and ExitPlanMode block the
369 // panel until it returns; any settled call clears a block.
370 on('tool.call', async ($, e, next) => {
371 if (isBlockingTool(e.tool)) await feed($, onBlocked)
372 const result = await next(e)
373 try {
374 const cwd = await projectRoot($)
375 const at = await nowMs($)
376 const didRun = result?.deny === undefined
377 await feed($, (s) =>
378 onToolCall(onToolSettle(onUnblocked(s), { tool: e.tool, didRun, callId: e.tool_use_id }), { tool: e.tool, path: filePathOf(e), cwd, agentId: e.agentId, isError: result?.isError === true, nowMs: at }),
379 )
380 } catch {
381 // observe only
382 }
383 return result
384 }).catch(($, e, next) => next(e))
385
386 // The permission verdict, whoever decided it. An ask on a real call is the permission prompt (S8b).
387 on('tool.check', async ($, e, next) => {
388 const result = await next(e)
389 const at = await nowMs($)
390 const isQuery = typeof e.tool_use_id !== 'string' || e.tool_use_id === ''
391 await feed($, (s) => {
392 const checked = onToolCheck(s, { tool: e.tool, callId: e.tool_use_id, input: e.input, agentId: e.agentId, decision: result?.decision, nowMs: at, isQuery })
393 // An ask on a real call goes to the person (or the mode's decider): blocked until the call settles.
394 return !isQuery && result?.decision === 'ask' ? onBlocked(checked) : checked
395 })
396 return result
397 }).catch(($, e, next) => next(e))
398
399 on('session.compact', async ($, e, next) => {
400 const result = await next(e)
401 if (e.trigger !== 'precompute' && result && result.skip === undefined) await feed($, (s) => onCompact(s))
402 return result
403 }).catch(($, e, next) => next(e))
404
405 // /construct opens the pane, /construct close closes it.
406 on('command.run', { command: 'construct' }, async ($, e) => runCommand($, e)).catch(($, e, next) => next(e))
407
408 // The docked panel.
409 on('ui.render', { component: 'Pane' }, async ($, e, next) => drawPane($, e, next)).catch(($, e, next) => next(e))
410}
411
412// A subagent started: add its card. Observe only; the routing above has already run.
413async function noteSpawn($, e, result, role, options) {
414 try {
415 const at = await nowMs($)
416 const effort = effortFor(role, options)
417 await feed($, (s) =>
418 onAgentSpawn(s, { agentId: result?.agentId, subagentType: e.subagentType, description: e.description, model: result?.model, effort, nowMs: at }),
419 )
420 } catch {
421 // observe only
422 }
423}
424
425// Registers /construct and, when the user asked for it, opens the pane. Detached from session.start.
426async function setUpPanel($, options) {
427 try {
428 await $.command.register({ name: 'construct', description: 'Open the Construct panel (close: /construct close, setup check: /construct doctor)', argumentHint: '[close|doctor]' })
429 } catch {
430 // the command stays unavailable
431 }
432 try {
433 if (options?.panel_open_on_start === true) await openPane($)
434 } catch {
435 // no surface to draw on
436 }
437}
438hooks/routing.js 34 lines1// Per-role model and effort routing for construct agents (spec S6).
2// A role is the agent name with dashes turned into underscores, and its
3// userConfig options are model_<role> and effort_<role>.
4
5import { BUILTINS, LEGACY, PREFIX as PLUGIN_PREFIX, roleKey } from './scripts/roles.mjs'
6
7export function roleFor(subagentType) {
8 if (typeof subagentType !== 'string') return null
9 if (subagentType.startsWith(PLUGIN_PREFIX)) {
10 return roleKey(subagentType.slice(PLUGIN_PREFIX.length))
11 }
12 return Object.hasOwn(BUILTINS, subagentType) ? roleKey(BUILTINS[subagentType]) : null
13}
14
15// The saved value of an option: the new key, then the legacy key of a renamed role (S7).
16// 'inherit' (the new keys' default) and '' (a key declared without a default) both mean unset.
17function optionFor(prefix, role, options) {
18 const legacy = Object.entries(LEGACY).find(([name]) => roleKey(name) === role)?.[1]
19 const value = options?.[`${prefix}_${role}`]
20 return (value && value !== 'inherit' ? value : undefined) ?? (legacy ? options?.[`${prefix}_${roleKey(legacy)}`] : undefined)
21}
22
23export function modelFor(role, options) {
24 if (!role) return null
25 return optionFor('model', role, options) ?? null
26}
27
28// 'default' keeps the effort the model would use without an override
29export function effortFor(role, options) {
30 if (!role) return null
31 const effort = optionFor('effort', role, options)
32 return effort && effort !== 'default' ? effort : null
33}
34hooks/panel/layout.js 402 lines1// The panel layout: turns the stored panel state and the live data (usage, ODD feature, memory,
2// git) into one column of bordered cards. Each card is built by a small function of its own;
3// buildPanel only decides which cards show and how many rows the changes card may take.
4
5import { kindOf } from '../scripts/roles.mjs'
6import { isNum, text } from '../status/format.js'
7import { PALETTE, card, changeRows, effortMeter, fmtResetIn, gauge, panelBox, stackBar } from './draw.js'
8import { agentsView, initialState, mainBusy, maskSecrets, phaseOf } from './state.js'
9
10export const PANEL_COLUMNS = 66
11
12const MAX_AGENT_ROWS = 6
13const MAX_NAME = 18
14const SEP = ' · '
15// eslint-disable-next-line no-control-regex
16const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g
17
18// ---- text helpers ---------------------------------------------------------
19
20const clean = (s) => (typeof s === 'string' ? s.replace(CONTROL, '') : '')
21const count = (n) => (isNum(n) && n > 0 ? Math.floor(n) : 0)
22// Screen columns, counted by grapheme cluster: a cluster is two wide when its first code point is East Asian
23// wide, fullwidth or emoji-presentation, when it joins an emoji with ZWJ or VS16, or when it holds a skin-tone
24// modifier or a keycap mark (U+20E3); every other cluster is one.
25const WIDE = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE6F\uFF00-\uFF60\uFFE0-\uFFE6\u{20000}-\u{3FFFD}\p{Emoji_Presentation}]/u
26const EMOJI_JOIN = /(?=.*[\u200D\uFE0F])(?=.*\p{Extended_Pictographic})/su
27const EMOJI_MARK = /[\p{Emoji_Modifier}⃣]/u
28const PLAIN =/^[\x20-\x7e]*$/
29const SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
30const clusterWidth = (g) => (WIDE.test(String.fromCodePoint(g.codePointAt(0))) || EMOJI_JOIN.test(g) || EMOJI_MARK.test(g) ? 2 : 1)
31const cols = (s) => (PLAIN.test(s) ? s.length : [...SEGMENTER.segment(s)].reduce((n, { segment }) => n + clusterWidth(segment), 0))
32// The longest prefix of `s` within `max` columns; a cluster is never split.
33function head(s, max) {
34 if (PLAIN.test(s)) return s.slice(0, Math.max(0, max))
35 let out = ''
36 let used = 0
37 for (const { segment } of SEGMENTER.segment(s)) {
38 const w = clusterWidth(segment)
39 if (used + w > max) break
40 out += segment
41 used += w
42 }
43 return out
44}
45const padTo = (s, n) => s + ' '.repeat(Math.max(0, n - cols(s)))
46const cut = (s, max) => (cols(s) <= max ? s : max > 1 ? `${head(s, max - 1)}…` : head(s, max))
47
48// `m:ss`, or `XhYY` from an hour on.
49export function clock(ms) {
50 const s = isNum(ms) ? Math.max(0, Math.floor(ms / 1000)) : 0
51 const pad = (n) => String(n).padStart(2, '0')
52 if (s >= 3600) return `${Math.floor(s / 3600)}h${pad(Math.floor((s % 3600) / 60))}`
53 return `${Math.floor(s / 60)}:${pad(s % 60)}`
54}
55
56// `claude-opus-5-5` gives `Opus 5.5`; a family alone (`opus[1m]`) gives `Opus`; a date suffix is dropped.
57export function prettyModel(model) {
58 if (typeof model !== 'string') return 'Claude'
59 const parts = model.replace(/\[.*$/, '').split('-').filter((p) => p && p !== 'claude')
60 const family = parts.find((p) => /^[a-z]/i.test(p))
61 if (!family) return 'Claude'
62 const version = parts.filter((p) => /^\d{1,2}$/.test(p)).slice(0, 2).join('.')
63 const name = family[0].toUpperCase() + family.slice(1).toLowerCase()
64 return version ? `${name} ${version}` : name
65}
66
67// 666_000 gives `666k`, 1_000_000 gives `1.0M`, below a thousand the integer.
68function tokens(n) {
69 if (n < 1000) return String(Math.round(n))
70 const k = Math.round(n / 1000)
71 return k >= 1000 ? `${(n / 1e6).toFixed(1)}M` : `${k}k`
72}
73
74// "5 minutes ago" as the shortest unit: `<1m`, `5m`, `3h`, `2d`.
75function age(ms) {
76 const m = Math.floor(ms / 60_000)
77 if (m < 1) return '<1m'
78 if (m < 60) return `${m}m`
79 return m < 1440 ? `${Math.floor(m / 60)}h` : `${Math.floor(m / 1440)}d`
80}
81
82// ---- rows: a row is a list of segments `{ t, color?, dim?, bold? }`, clipped to one line -------
83
84const seg = (t, o = {}) => ({ t, ...o })
85const sep = () => seg(SEP, { dim: true })
86const width = (segs) => segs.reduce((n, s) => n + cols(s.t), 0)
87
88// The segments that fit in `max` cells, the cut one ending in `…`.
89function clip(segs, max) {
90 if (width(segs) <= max) return segs
91 const out = []
92 let left = Math.max(0, max - 1)
93 for (const s of segs) {
94 if (left <= 0) break
95 out.push(cols(s.t) <= left ? s : { ...s, t: head(s.t, left) })
96 left -= cols(s.t)
97 }
98 const last = out.pop()
99 out.push(last ? { ...last, t: last.t + '…' } : seg('…')) // a copy: the caller's segment stays as it was
100 return out
101}
102
103function row(segs, max) {
104 const children = clip(segs, max).map((s) => text(s.t, s))
105 return { type: 'Text', props: { wrap: 'truncate' }, children }
106}
107
108const dimRow = (t, max, color) => row([seg(t, color ? { color } : { dim: true })], max)
109
110// gauge cells as two segments; the filled part turns to the warn color from 80%
111function gaugeSegs(pct, cells) {
112 const g = gauge(pct, cells)
113 const on = pct >= 80 ? PALETTE.warn : PALETTE.main
114 return [...(g.on ? [seg(g.on, { color: on })] : []), ...(g.off ? [seg(g.off, { color: PALETTE.faint })] : [])]
115}
116
117function meterSegs(effort, on = PALETTE.main, off = PALETTE.faint) {
118 const m = effortMeter(effort)
119 return [...(m.on ? [seg(m.on, { color: on })] : []), seg(m.off, { color: off })]
120}
121
122// ---- main card ---------------------------------------------------------------
123
124// The phase words of state.js as the voice the main status uses.
125const PHASE_VERB = { implementing: 'building', exploring: 'mapping' }
126
127function mainStatus(panel) {
128 if (panel.blocked) return { text: '◆ waiting on you', color: PALETTE.amber }
129 if (!mainBusy(panel)) return { text: '○ ready', color: PALETTE.dim }
130 const phase = phaseOf(panel)
131 return `● ${phase ? (PHASE_VERB[phase] ?? phase) : 'busy'}`
132}
133
134function branchRow({ panel, git, effort }, max) {
135 const branch = clean(git?.branch)
136 const word = typeof effort === 'string' && effort ? effort : '—'
137 const mode = typeof panel.main?.mode === 'string' && panel.main.mode ? panel.main.mode : ''
138 const join = (parts) => parts.flatMap((p, i) => (i ? [sep(), ...p] : p))
139 const tail = [[...meterSegs(effort, PALETTE.main, PALETTE.main), seg(` ${word}`, { color: PALETTE.main })], [seg(`${count(panel.main?.steps)} req`, { dim: true })]]
140 const build = (b, withMode) =>
141 join([...(b ? [[seg('⎇ ', { dim: true }), seg(b)]] : []), ...tail, ...(withMode && mode ? [[seg(mode, { dim: true })]] : [])])
142 // the branch gives way first (keeping 8 characters), then the mode; effort and requests stay
143 const room = (withMode) => max - width(build('', withMode)) - (branch ? 2 + SEP.length : 0)
144 const fit = (withMode) => {
145 const r = room(withMode)
146 return cols(branch) <= r ? branch : r > 8 ? cut(branch, r) : null
147 }
148 const withMode = fit(true)
149 if (withMode !== null) return row(build(withMode, true), max)
150 return row(build(fit(false) ?? cut(branch, 9), false), max)
151}
152
153function ctxRow(context, panel, max) {
154 const pct = Math.round(context.percent)
155 const segs = [seg('ctx ', { dim: true }), ...gaugeSegs(pct, 10), seg(' '), seg(`${pct}%`, { bold: true })]
156 if (isNum(context.tokens) && isNum(context.window)) segs.push(seg(` ${tokens(context.tokens)}/${tokens(context.window)}`, { dim: true }))
157 if (count(panel.compactions) > 0) segs.push(seg(` ⟲${count(panel.compactions)}`, { color: PALETTE.amber }))
158 return row(segs, max)
159}
160
161function limitsRow(usage, nowMs, max) {
162 const limits = Array.isArray(usage?.rateLimits) ? usage.rateLimits : []
163 const parts = [['five_hour', '5h'], ['seven_day', '7d']].flatMap(([kind, label]) => {
164 const l = limits.find((x) => x?.kind === kind)
165 if (!isNum(l?.percentUsed)) return []
166 const left = fmtResetIn(l.resetsAt, nowMs)
167 return [[seg(`${label} `, { dim: true }), ...gaugeSegs(l.percentUsed, 5), seg(` ${Math.round(l.percentUsed)}%`), ...(left ? [seg(` ↻${left}`, { dim: true })] : [])]]
168 })
169 return parts.length ? row(parts.flatMap((p, i) => (i ? [seg(' '), ...p] : p)), max) : null
170}
171
172function mainCard({ panel, usage, model, effort, git, nowMs }, w) {
173 const inner = w - 4
174 const live = { panel, git, effort: panel.main?.effort ?? effort }
175 const body = [branchRow(live, inner)]
176 if (isNum(usage?.context?.percent)) body.push(ctxRow(usage.context, panel, inner))
177 const limits = limitsRow(usage, nowMs, inner)
178 if (limits) body.push(limits)
179 return card({ title: prettyModel(panel.main?.model ?? model), status: mainStatus(panel), color: PALETTE.main, children: body, width: w })
180}
181
182// ---- ODD card ------------------------------------------------------------------
183
184// One glyph per task joined by `━`; too many keep the latest behind a leading `…`.
185function checkpoints(tasks, max) {
186 const open = tasks.findIndex((t) => !t.done)
187 const glyphs = tasks.map((t, i) => (t.done ? seg('●', { color: PALETTE.odd }) : i === open ? seg('◆', { color: PALETTE.odd, bold: true }) : seg('○', { dim: true })))
188 const fits = Math.floor((max + 1) / 2)
189 const shown = glyphs.length <= fits ? glyphs : glyphs.slice(glyphs.length - Math.floor((max - 1) / 2))
190 const joint = (g, i) => seg('━', { color: PALETTE.faint })
191 const joined = shown.flatMap((g, i) => (i ? [joint(g, i), g] : [g]))
192 return shown === glyphs ? joined : [seg('…━', { dim: true }), ...joined]
193}
194
195function oddCard(odd, w) {
196 const inner = w - 4
197 const status = `${count(odd.open)} open`
198 const body = []
199 if (odd.task) {
200 const id = clean(String(odd.task.id ?? ''))
201 body.push(row([seg(id, { bold: true }), seg(` ${clean(String(odd.task.title ?? ''))}`)], inner))
202 }
203 const tasks = (Array.isArray(odd.tasks) ? odd.tasks : []).filter((t) => t && typeof t === 'object')
204 if (tasks.length) body.push(row(checkpoints(tasks, inner), inner))
205 const title = cut(`ODD · ${clean(String(odd.feature))}`, inner - cols(status) - 1)
206 return card({ title, status: { text: status, dim: true }, color: PALETTE.odd, children: body, width: w })
207}
208
209// ---- GATE card -----------------------------------------------------------------
210
211const sumOf = (o) => Object.values(o ?? {}).reduce((n, v) => n + count(v), 0)
212
213function gateStatus({ ask, deny }, total) {
214 if (ask > 0) return { text: `${ask} waiting on you`, color: PALETTE.amber }
215 return { text: deny === 0 ? 'all clear' : `${total} checks`, dim: true }
216}
217
218function gateBody(panel, v, inner) {
219 const classifier = panel.main?.mode === 'auto' ? 'classifier' : 'you'
220 const items = [
221 ['■', `${v.rule} allowed`, PALETTE.gate],
222 ['■', `${v.cleared} ${classifier}`, PALETTE.cleared],
223 ...(v.ask > 0 ? [['■', `${v.ask} pending`, PALETTE.amber]] : []),
224 ['✗', `${v.deny} denied`, v.deny > 0 ? PALETTE.warn : PALETTE.dim],
225 ]
226 const counts = [v.rule, v.cleared, v.ask, v.deny]
227 const colors = [PALETTE.gate, PALETTE.cleared, PALETTE.amber, PALETTE.warn]
228 const bar = stackBar(counts, inner).flatMap((n, i) => (n ? [seg('█'.repeat(n), { color: colors[i] })] : []))
229 const legend = items.flatMap(([glyph, label, color], i) => [...(i ? [seg(' ')] : []), seg(`${glyph} `, { color }), seg(label, [PALETTE.amber, PALETTE.warn, PALETTE.dim].includes(color) ? { color } : { dim: true })])
230 const buckets = [['f', 'file'], ['s', 'shell'], ['o', 'other']].flatMap(([key, name], i) => {
231 const n = sumOf(panel.gate?.buckets?.[name])
232 return [...(i ? [seg(' ')] : []), seg(`${key}: ${name} ${n}`, n === 0 ? { dim: true } : {})]
233 })
234 return [row(bar, inner), row(legend, inner), row(buckets, inner)]
235}
236
237function inlineTail(path) {
238 const parts = clean(maskSecrets(path)).split('/').filter(Boolean)
239 return parts.length > 2 ? `…/${parts.slice(-2).join('/')}` : parts.join('/')
240}
241
242function gateCard(panel, w) {
243 const inner = w - 4
244 const v = { rule: count(panel.gate?.verdicts?.rule), ask: count(panel.gate?.verdicts?.ask), cleared: count(panel.gate?.verdicts?.cleared), deny: count(panel.gate?.verdicts?.deny) }
245 const total = v.rule + v.ask + v.cleared + v.deny
246 const inline = count(panel.inline?.count)
247 const denied = panel.gate?.lastDenied
248 const hasDenied = denied && typeof denied === 'object'
249 if (total === 0 && inline === 0 && !hasDenied) return null
250 const body = total === 0 ? [dimRow('nothing waiting on you', inner)] : gateBody(panel, v, inner)
251 if (hasDenied) {
252 const detail = cut(clean(maskSecrets(String(denied.detail ?? ''))), 80)
253 body.push(row([seg(`✗ ${clean(String(denied.tool ?? 'tool'))}${detail ? `: ${detail}` : ''}`, { color: PALETTE.warn })], inner))
254 }
255 if (inline > 0) {
256 const tail = typeof panel.inline?.last === 'string' ? inlineTail(panel.inline.last) : ''
257 body.push(row([seg(`⚠ ${inline} inline write${inline === 1 ? '' : 's'}${tail ? ` · ${tail}` : ''}`, { color: PALETTE.warn })], inner))
258 }
259 return card({ title: 'GATE · permissions', status: gateStatus(v, total), color: PALETTE.gate, children: body, width: w })
260}
261
262// ---- MEMORY card ---------------------------------------------------------------
263
264const AMBER_REASON = /newer|read-?only|unable to open|not writable/i
265
266function memoryCard(memory, nowMs, w) {
267 const inner = w - 4
268 const color = PALETTE.cleared
269 if (typeof memory.off === 'string') {
270 const reason = clean(memory.off)
271 return card({ title: 'MEMORY', color, children: [dimRow(reason, inner, AMBER_REASON.test(reason) ? PALETTE.amber : undefined)], width: w })
272 }
273 const n = count(memory.memories)
274 if (n === 0) return card({ title: 'MEMORY', status: { text: '0 memories', dim: true }, color, children: [dimRow('nothing remembered yet', inner)], width: w })
275 const review = count(memory.review)
276 const body = [row([seg(`${review} to review`, review > 0 ? { color: PALETTE.amber } : { dim: true }), seg(` · ${count(memory.pinned)} pinned · ${count(memory.pending)} pending`, { dim: true })], inner)]
277 const saved = Date.parse(memory.lastSaveAt)
278 const facts = [
279 ...(Number.isFinite(saved) ? [`last save ${age(Math.max(0, nowMs - saved))} ago`] : []),
280 ...(isNum(memory.sessionSaves) ? [`+${count(memory.sessionSaves)} this session`] : []),
281 ]
282 if (facts.length) body.push(row([seg(facts.join(SEP), { dim: true })], inner))
283 return card({ title: 'MEMORY', status: `${n} memories`, color, children: body, width: w })
284}
285
286// ---- agents card ---------------------------------------------------------------
287
288// Glyph, running verb, done verb by role kind (roles.mjs); anything else is a plain worker.
289const LOOKS = {
290 reader: ['⌕', 'mapping', 'mapped'],
291 writer: ['⚒', 'building', 'built'],
292 verifier: ['⊨', 'verifying', 'verified'],
293 runner: ['◇', 'working', 'done'],
294 judge: ['⚖', 'judging', 'judged'],
295 lens: ['◎', 'reviewing', 'reviewed'],
296}
297const OTHER_LOOK = ['•', 'working', 'done']
298
299function agentsCard(panel, nowMs, w) {
300 const { cards, rest, running } = agentsView(panel, nowMs, MAX_AGENT_ROWS)
301 if (cards.length === 0) return null
302 const inner = w - 4
303 const shown = [...cards].sort((a, b) => (isNum(a.startedMs) ? a.startedMs : 0) - (isNum(b.startedMs) ? b.startedMs : 0))
304 const looks = shown.map((a) => {
305 const [glyph, now, done] = LOOKS[kindOf(a.name)] ?? OTHER_LOOK
306 return { a: { ...a, name: clean(a.name) }, glyph, verb: a.status === 'running' ? now : done, model: typeof a.model === 'string' && a.model ? prettyModel(a.model) : '—' }
307 })
308 const pad = (key, cap = Infinity) => Math.min(cap, Math.max(...looks.map((l) => cols(key === 'name' ? l.a.name : l[key]))))
309 const [nameW, verbW, modelW] = [pad('name', MAX_NAME), pad('verb'), pad('model')]
310 // glyph, name, verb, model, meter and clock with their spaces; the model goes first when the row does not fit, then the meter
311 const clockW = 1 + Math.max(...shown.map((a) => clock(a.elapsedMs).length))
312 const base = 2 + nameW + 1 + verbW + clockW
313 const showMeter = base + 5 <= inner
314 const showModel = base + 5 + 1 + modelW <= inner
315 const body = looks.map(({ a, glyph, verb, model }) => {
316 const live = a.status === 'running'
317 const tone = live ? { color: PALETTE.agent } : { dim: true }
318 // the clock stays; name and verb are clipped with `…` when even they do not fit
319 const room = inner - 2 - clockW - nameW - 1
320 const cells = cols(verb) <= room ? Math.min(verbW, room) : verbW // pad, never clip, a verb that fits
321 const left = clip([seg(padTo(cut(a.name, MAX_NAME), nameW), tone), seg(' '), seg(padTo(verb, cells), tone)], inner - 2 - clockW)
322 return row(
323 [
324 seg(glyph, live ? tone : { color: PALETTE.gate }),
325 seg(' '),
326 ...left,
327 ...(showModel ? [seg(' '), seg(model.padEnd(modelW), { dim: true })] : []),
328 ...(showMeter ? [seg(' '), ...meterSegs(a.effort, live ? PALETTE.agent : PALETTE.dim, live ? PALETTE.agent : PALETTE.faint)] : []),
329 seg(` ${clock(a.elapsedMs)}`, { color: PALETTE.dim }),
330 ],
331 inner,
332 )
333 })
334 if (rest > 0) body.unshift(row([seg(`+${rest} earlier`, { color: PALETTE.faint })], inner))
335 const total = cards.length + rest
336 const status = { text: running > 0 ? `${running} at work · ${total}` : `all done · ${total}`, dim: true }
337 return card({ title: 'agents', status, color: PALETTE.agent, children: body, width: w })
338}
339
340// ---- changes card --------------------------------------------------------------
341
342// The path shortened to `…/dir/name`, then `…/name`, then clipped, to fit `max` cells.
343function fitPath(path, max) {
344 if (cols(path) <= max) return path
345 const parts = path.split('/').filter(Boolean)
346 for (const keep of [2, 1]) {
347 const short = `…/${parts.slice(-keep).join('/')}`
348 if (cols(short) <= max) return short
349 }
350 return cut(parts.at(-1) ?? path, max)
351}
352
353const lines = (f) => (f.binary ? 0 : count(f.added) + count(f.removed))
354
355function statSegs(f) {
356 if (f.binary) return [seg('bin', { dim: true })]
357 if (f.untracked) return isNum(f.added) ? [seg(`+${count(f.added)}`, { color: PALETTE.gate })] : [seg('new', { dim: true })]
358 return [seg(`+${count(f.added)}`, { color: PALETTE.gate }), seg(' '), seg(`-${count(f.removed)}`, { color: PALETTE.warn })]
359}
360
361function changeRow(f, inner) {
362 const stat = statSegs(f)
363 const path = fitPath(clean(f.path), inner - width(stat) - 1)
364 return row([seg(padTo(path, inner - width(stat))), ...stat], inner)
365}
366
367function changesCard({ git, changes }, budget, w) {
368 const inner = w - 4
369 const files = (Array.isArray(changes) ? changes : []).filter((f) => f && typeof f.path === 'string')
370 const ahead = count(git?.ahead)
371 const behind = count(git?.behind)
372 if (budget < 0 || (files.length === 0 && ahead === 0 && behind === 0)) return null
373 const status = [...(ahead ? [`↑${ahead}`] : []), ...(behind ? [`↓${behind}`] : [])].join(' ')
374 const title = cut(clean(git?.branch) || 'changes', inner - cols(status) - 1)
375 const sorted = [...files].sort((a, b) => lines(b) - lines(a))
376 const fit = sorted.length <= budget ? sorted : sorted.slice(0, Math.max(0, budget - 1))
377 const body = budget === 0 ? [] : fit.map((f) => changeRow(f, inner))
378 if (budget > 0 && fit.length < sorted.length) body.push(dimRow(`+${sorted.length - fit.length} more`, inner, PALETTE.faint))
379 return card({ title, status: status || undefined, color: PALETTE.git, children: body, width: w })
380}
381
382// ---- the panel -----------------------------------------------------------------
383
384// Rows a card takes: its border, then the title row and the body rows.
385const heightOf = (c) => c.children.length + 2
386
387export function buildPanel({ panel: stored, usage, odd, memory, nowMs, columns, rows, model, effort, git, changes }) {
388 const panel = stored ?? initialState()
389 const w = isNum(columns) ? Math.max(40, Math.floor(columns)) : PANEL_COLUMNS
390 const now = isNum(nowMs) ? nowMs : Date.now()
391 const cards = [
392 mainCard({ panel, usage, model, effort, git, nowMs: now }, w),
393 odd?.feature ? oddCard(odd, w) : null,
394 gateCard(panel, w),
395 memory ? memoryCard(memory, now, w) : null,
396 agentsCard(panel, now, w),
397 ].filter(Boolean)
398 const used = cards.reduce((n, c) => n + heightOf(c), 0)
399 const last = changesCard({ git, changes }, changeRows(isNum(rows) && rows > 0 ? rows : Infinity, used), w)
400 return panelBox({ width: w, rows, children: last ? [...cards, last] : cards })
401}
402hooks/panel/state.js 333 lines1// Panel state for the construct mod: pure reducers and selectors over the value persisted as
2// construct.panel.v3. Every reducer takes a state (any value is repaired through merge) and an
3// event object, and returns a new state; nothing here reads the clock or the disk.
4import { roleFor } from '../routing.js'
5import { isNum } from '../status/format.js'
6import { kindOf } from '../scripts/roles.mjs'
7
8export const SCHEMA_VERSION = 3
9
10const MAX_AGENTS = 20
11const MAX_CELLS = 60
12const MAX_PENDING = 100
13const MAX_DETAIL = 80
14
15const isObj = (x) => typeof x === 'object' && x !== null && !Array.isArray(x)
16const str = (x) => (typeof x === 'string' && x !== '' ? x : null)
17const last = (list, max) => (list.length > max ? list.slice(list.length - max) : list)
18
19// ---- state shape ---------------------------------------------------------
20
21const tally = () => ({ rule: 0, ask: 0, cleared: 0, deny: 0 })
22
23export function initialState() {
24 return {
25 v: SCHEMA_VERSION,
26 agents: [],
27 inline: { count: 0, last: null },
28 gate: {
29 cells: [],
30 counts: { allowed: 0, asked: 0, denied: 0, sub: 0 },
31 denied: 0,
32 lastDenied: null,
33 verdicts: tally(),
34 buckets: { file: tally(), shell: tally(), other: tally() },
35 pending: {},
36 },
37 turn: null,
38 compactions: 0,
39 main: { steps: 0, model: null, effort: null, mode: null },
40 blocked: false,
41 }
42}
43
44// A section over its defaults: numeric fields must be numbers, the rest take the stored value when set.
45const fill = (def, v) => {
46 const from = isObj(v) ? v : {}
47 return Object.fromEntries(Object.entries(def).map(([k, d]) => [k, typeof d === 'number' ? (isNum(from[k]) ? from[k] : d) : (from[k] ?? d)]))
48}
49
50// Repairs a stored value: v1 to v3 are read, anything else resets. A section of the wrong type
51// takes its default, so a field dropped from the schema (receipt, log) never comes back.
52export function merge(stored) {
53 const base = initialState()
54 if (!isObj(stored) || ![1, 2, 3].includes(stored.v)) return base
55 const g = isObj(stored.gate) ? stored.gate : {}
56 const gate = {
57 ...fill(base.gate, g),
58 cells: Array.isArray(g.cells) ? g.cells : [],
59 counts: fill(base.gate.counts, g.counts),
60 verdicts: fill(base.gate.verdicts, g.verdicts),
61 buckets: Object.fromEntries(Object.keys(base.gate.buckets).map((k) => [k, fill(base.gate.verdicts, g.buckets?.[k])])),
62 lastDenied: isObj(g.lastDenied) ? g.lastDenied : null,
63 pending: isObj(g.pending) ? g.pending : {},
64 }
65 const agents = (Array.isArray(stored.agents) ? stored.agents : []).filter(isObj).map((a) => ({ ...a, steps: isNum(a.steps) ? a.steps : 0, ctx: isNum(a.ctx) ? a.ctx : 0 }))
66 return {
67 v: SCHEMA_VERSION,
68 agents,
69 inline: fill(base.inline, stored.inline),
70 gate,
71 turn: isObj(stored.turn) ? stored.turn : null,
72 compactions: isNum(stored.compactions) ? stored.compactions : 0,
73 main: fill(base.main, stored.main),
74 blocked: stored.blocked === true,
75 }
76}
77
78// merge is idempotent on a sound value, so every reducer repairs nested damage in a v3 value too
79const open = merge
80
81// ---- secrets and paths ---------------------------------------------------
82
83const SECRET_RULES = [
84 [/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, '$1 ***'],
85 [/(\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:)[^\s@/]+@/gi, '$1***@'],
86 [/(\s-u\s+[^\s:]+:)\S+/g, '$1***'],
87 // NAME=value, NAME: value, --name=value where the name holds a secret word anywhere; found nested after `=` or `-D` too.
88 // The name is a whole run of word characters (the lookbehind starts one scan per run) and the lookahead finds the word, so no rescans.
89 [/((?<![\w.-])(?=[\w.-]*(?:key|secret|password|passwd|pwd|token|credential))[\w.-]*\s*[=:]\s*)("[^"]*"|'[^']*'|[^\s&"']+)/gi, '$1***'],
90 [/(?:(?:sk|pk|rk)[-_][A-Za-z0-9_-]{10,}|(?:npm|whsec)_[A-Za-z0-9]{16,}|SG\.[\w.-]{16,}|gh[pousr]_[A-Za-z0-9]{16,}|github_pat_\w{20,}|glpat-[\w-]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|(?:AKIA|ASIA)[A-Z0-9]{16})/g, '***'],
91 // an AWS-style secret key: 40 chars of base64 with every character class present
92 [/(?<![A-Za-z0-9/+])(?=[A-Za-z0-9/+]*\d)(?=[A-Za-z0-9/+]*[A-Z])(?=[A-Za-z0-9/+]*[a-z])[A-Za-z0-9/+]{40}(?![A-Za-z0-9/+])/g, '***'],
93 // 32+ chars of base64/base64url with a digit (API keys, JWT parts, UUIDs); the lookbehind keeps the scan linear
94 [/(?<![A-Za-z0-9+_-])(?=[A-Za-z0-9+_-]*\d)[A-Za-z0-9+_-]{32,}={0,2}/g, '***'],
95]
96
97export function maskSecrets(text) {
98 if (typeof text !== 'string') return ''
99 let out = text
100 // `-pSECRET` is the mysql form; only mask it where the command is one that takes it
101 if (/\b(?:mysql\w*|mariadb\w*|mongo\w*)\b/.test(out)) out = out.replace(/(\s-p)("[^"]*"|'[^']*'|\S+)/g, '$1***')
102 for (const [re, to] of SECRET_RULES) out = out.replace(re, to)
103 return out
104}
105
106// The path a tool call touches: a top-level file_path on the call, else the one in its input.
107export function filePathOf(e) {
108 const input = isObj(e?.input) ? e.input : {}
109 return str(e?.file_path) ?? str(input.file_path) ?? str(e?.notebook_path) ?? str(input.notebook_path) ?? str(e?.path) ?? str(input.path) ?? undefined
110}
111
112// Lexical normalization against cwd, so `sub/../odd/tasks/x` and `./odd/tasks/x` resolve alike.
113function resolve(path, cwd) {
114 const full = path.startsWith('/') ? path : `${typeof cwd === 'string' ? cwd : ''}/${path}`
115 const parts = []
116 for (const part of full.split('/')) {
117 if (part === '..') parts.pop()
118 else if (part !== '' && part !== '.') parts.push(part)
119 }
120 return parts
121}
122
123export function isOutsideTasks(path, cwd) {
124 if (typeof path !== 'string' || path === '') return false
125 const parts = resolve(path, cwd)
126 // with no cwd, an `odd/tasks` segment pair anywhere in the path counts as inside tasks
127 if (typeof cwd !== 'string') return !parts.some((p, i) => p === 'odd' && parts[i + 1] === 'tasks')
128 const tasks = resolve('odd/tasks', cwd)
129 return !tasks.every((p, i) => parts[i] === p)
130}
131
132// ---- agents --------------------------------------------------------------
133
134const withAgent = (s, id, change) => {
135 if (!s.agents.some((a) => a.id === id)) return s
136 return { ...s, agents: s.agents.map((a) => (a.id === id ? { ...a, ...change(a) } : a)) }
137}
138
139export function onAgentSpawn(state, e) {
140 const s = open(state)
141 const id = str(e?.agentId)
142 if (!id) return s
143 const type = str(e.subagentType)
144 const agent = {
145 id,
146 role: roleFor(type) ?? type,
147 type,
148 description: typeof e.description === 'string' ? e.description : '',
149 model: str(e.model),
150 effort: str(e.effort),
151 ctx: 0,
152 steps: 0,
153 startedMs: isNum(e.nowMs) ? e.nowMs : 0,
154 endedMs: null,
155 status: 'running',
156 }
157 const spawned = { ...s, agents: last([...s.agents.filter((a) => a.id !== id), agent], MAX_AGENTS) }
158 return e.parentAgentId === undefined ? onPermissionMode(spawned, { mode: e.mode }) : spawned
159}
160
161export function onTurnStep(state, e) {
162 const s = open(state)
163 const id = str(e?.agentId)
164 if (!id) return s
165 const u = e.usage
166 const ctx = isObj(u) ? (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0) : null
167 return withAgent(s, id, (a) => ({ steps: a.steps + 1, ctx: isNum(ctx) ? ctx : a.ctx }))
168}
169
170export function onTurnStart(state, e) {
171 const s = open(state)
172 const id = str(e?.agentId)
173 if (id) return withAgent(s, id, () => ({ status: 'running', endedMs: null }))
174 return { ...s, turn: { turnId: str(e?.turnId) } }
175}
176
177export function onTurnComplete(state, e) {
178 const s = open(state)
179 const id = str(e?.agentId)
180 if (id) return withAgent(s, id, () => ({ status: 'done', endedMs: isNum(e.nowMs) ? e.nowMs : 0 }))
181 const turnId = str(e?.turnId)
182 // a different turn id means a stale completion: the open turn stays
183 if (s.turn && turnId && s.turn.turnId && s.turn.turnId !== turnId) return s
184 return s.turn ? { ...s, turn: null } : s
185}
186
187export function onMainStep(state, e) {
188 const s = open(state)
189 if (str(e?.agentId) !== null) return s
190 const effort = e?.effort == null || e.effort === '' ? null : String(e.effort)
191 return { ...s, main: { ...s.main, steps: s.main.steps + 1, model: str(e?.model) ?? s.main.model, effort } }
192}
193
194export function onPermissionMode(state, e) {
195 const s = open(state)
196 const mode = str(e?.mode)
197 return mode ? { ...s, main: { ...s.main, mode } } : s
198}
199
200export const onCompact = (state) => {
201 const s = open(state)
202 return { ...s, compactions: s.compactions + 1 }
203}
204
205// ---- gate ----------------------------------------------------------------
206
207const FILE_TOOLS = ['Read', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'Glob', 'Grep']
208const SHELL_TOOLS = ['Bash', 'PowerShell']
209const WRITE_TOOLS = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit']
210
211export function bucketOf(tool) {
212 if (FILE_TOOLS.includes(tool)) return 'file'
213 return SHELL_TOOLS.includes(tool) ? 'shell' : 'other'
214}
215
216const bump = (counts, key, by = 1) => ({ ...counts, [key]: Math.max(0, counts[key] + by) })
217
218// Moves one verdict from `from` to `to`, in the totals and in the tool's bucket.
219function shift(gate, bucket, from, to) {
220 const buckets = gate.buckets[bucket] ? { ...gate.buckets, [bucket]: bump(bump(gate.buckets[bucket], from, -1), to) } : gate.buckets
221 return { ...gate, verdicts: bump(bump(gate.verdicts, from, -1), to), buckets }
222}
223
224const DETAIL_FIELDS = ['command', 'file_path', 'notebook_path', 'path', 'url', 'pattern']
225const CELL = { allow: 'allowed', ask: 'asked', deny: 'denied' }
226const VERDICT = { allow: 'rule', ask: 'ask', deny: 'deny' }
227
228export function onToolCheck(state, e) {
229 const s = open(state)
230 const decision = e?.decision
231 // a query carries no tool_use_id: it asks what would happen and decides nothing
232 if (e?.isQuery || !CELL[decision]) return s
233 // the same call asked again (a re-check while it waits) is one ask, not two
234 if (decision === 'ask' && str(e.callId) && Object.hasOwn(s.gate.pending ?? {}, e.callId)) return s
235 const bucket = bucketOf(e.tool)
236 const verdict = VERDICT[decision]
237 const sub = str(e.agentId) !== null
238 const cell = sub ? 'sub' : CELL[decision]
239 const g = s.gate
240 let gate = {
241 ...g,
242 cells: last([...g.cells, cell], MAX_CELLS),
243 counts: bump(g.counts, cell),
244 verdicts: bump(g.verdicts, verdict),
245 buckets: { ...g.buckets, [bucket]: bump(g.buckets[bucket], verdict) },
246 }
247 if (decision === 'deny') {
248 const input = isObj(e.input) ? e.input : {}
249 const shown = maskSecrets(DETAIL_FIELDS.map((k) => str(input[k])).find((x) => x) ?? '')
250 const detail = shown.length > MAX_DETAIL ? `${shown.slice(0, MAX_DETAIL - 3)}...` : shown
251 gate = { ...gate, denied: g.denied + 1, lastDenied: { tool: str(e.tool) ?? 'tool', detail } }
252 }
253 if (decision === 'ask') {
254 // asks without a call id still need a unique slot; the running total is one
255 const key = str(e.callId) ?? `anon${Object.values(g.counts).reduce((a, b) => a + b, 0)}`
256 const keys = Object.keys(gate.pending ?? {}).concat(key).slice(-MAX_PENDING)
257 gate = { ...gate, pending: Object.fromEntries(keys.map((k) => [k, k === key ? bucket : gate.pending[k]])) }
258 }
259 return { ...s, gate }
260}
261
262// The ask got its answer: it ran (cleared) or it did not (deny).
263export function onToolSettle(state, e) {
264 const s = open(state)
265 const pending = s.gate.pending ?? {}
266 const id = str(e?.callId)
267 const bucket = bucketOf(e?.tool)
268 const key = id ?? Object.keys(pending).find((k) => pending[k] === bucket)
269 if (key === undefined || !Object.hasOwn(pending, key)) return s
270 const { [key]: settled, ...rest } = pending
271 const gate = shift({ ...s.gate, pending: rest }, settled, 'ask', e.didRun ? 'cleared' : 'deny')
272 return { ...s, gate }
273}
274
275// ---- inline writes, blocked flag ----------------------------------------
276
277export function onToolCall(state, e) {
278 const s = open(state)
279 if (str(e?.agentId) !== null || !WRITE_TOOLS.includes(e?.tool) || !isOutsideTasks(e.path, e.cwd)) return s
280 return { ...s, inline: { count: s.inline.count + 1, last: e.path } }
281}
282
283const BLOCKING = ['AskUserQuestion', 'ExitPlanMode']
284export const isBlockingTool = (tool) => BLOCKING.includes(tool)
285
286// A flag that already matches returns the input object itself, before any repair.
287export function onBlocked(state) {
288 return state?.blocked === true ? state : { ...open(state), blocked: true }
289}
290
291export function onUnblocked(state) {
292 return state?.blocked === false ? state : { ...open(state), blocked: false }
293}
294
295// ---- selectors -----------------------------------------------------------
296
297export const mainBusy = (state) => isObj(state?.turn)
298
299// The agent's short panel name: its role with dashes when that is a construct role, else the role
300// or type as is.
301export function shortName(agent) {
302 const name = typeof agent === 'string' ? agent : (str(agent?.role) ?? str(agent?.type))
303 return name ? name.replace(/^construct:/, '').replaceAll('_', '-') : 'agent'
304}
305
306const PHASES = [
307 ['verifier', 'verifying'],
308 ['judge', 'reviewing'],
309 ['lens', 'reviewing'],
310 ['writer', 'implementing'],
311 ['reader', 'exploring'],
312]
313
314// What the running agents are doing, the most advanced phase winning; null with none running.
315export function phaseOf(state) {
316 const running = (isObj(state) && Array.isArray(state.agents) ? state.agents : []).filter((a) => a?.status === 'running')
317 if (running.length === 0) return null
318 const kinds = running.map((a) => kindOf(shortName(a)))
319 return PHASES.find(([kind]) => kinds.includes(kind))?.[1] ?? 'working'
320}
321
322// Cards for the agents section: running ones first, then the latest finished, finished by most recent end.
323export function agentsView(state, nowMs, max = 3) {
324 const agents = (isObj(state) && Array.isArray(state.agents) ? state.agents : []).filter(isObj)
325 const running = agents.filter((a) => a.status === 'running').sort((a, b) => b.startedMs - a.startedMs)
326 const done = agents.filter((a) => a.status !== 'running').sort((a, b) => (b.endedMs ?? 0) - (a.endedMs ?? 0))
327 const cards = [...running, ...done].slice(0, max).map((a) => {
328 const end = a.status === 'running' ? nowMs : a.endedMs
329 return { ...a, name: shortName(a), elapsedMs: isNum(end) && isNum(a.startedMs) ? Math.max(0, end - a.startedMs) : 0 }
330 })
331 return { cards, rest: agents.length - cards.length, running: running.length }
332}
333hooks/status/memory.js 33 lines1// Pure parser for `construct-memory stats --json` (S20), like the others in data.js.
2
3import { isNum } from './format.js'
4
5const count = (x) => (isNum(x) && x >= 0 ? Math.floor(x) : 0)
6
7// `run` is the process result { exitCode, stdout, stderr }. Gives the card data, `{ off }` with the reason
8// memory is unusable, or null for any other failure (the card is then left out).
9export function parseMemoryStats(run) {
10 if (!run || typeof run !== 'object') return null
11 if (run.exitCode === 0) {
12 try {
13 const s = JSON.parse(run.stdout)
14 if (!s || typeof s !== 'object' || !isNum(s.observations)) return null
15 return {
16 memories: count(s.observations),
17 review: count(s.needing_review),
18 pinned: count(s.pinned),
19 pending: count(s.pending_relations),
20 lastSaveAt: typeof s.last_save_at === 'string' ? s.last_save_at : null,
21 sessionSaves: isNum(s.session_saves) ? count(s.session_saves) : null,
22 }
23 } catch {
24 return null
25 }
26 }
27 const err = String(run.stderr ?? '')
28 if (err.includes('node:sqlite')) return { off: 'memory unavailable (Node 22.16+)' }
29 if (err.includes('not inside a git repository')) return { off: 'not a git repository' }
30 const first = err.split('\n').map((l) => l.trim().replace(/^construct-memory:\s*/, '')).find(Boolean)
31 return first ? { off: first } : null
32}
33hooks/status/doctor.js 64 lines1// Pure checks behind `/construct doctor` (S8). Each takes the raw output of one probe (a string, or null when
2// the probe failed or timed out) and returns a row `{ ok, label, hint }`. No `$` calls here: register.js runs
3// the probes.
4
5export const MIN_CLAUDE = '2.1.304'
6export const MIN_NODE = '22.16'
7
8// The first x.y.z in the text as numbers, or null.
9function triple(text) {
10 const m = /(\d+)\.(\d+)\.(\d+)/.exec(String(text ?? ''))
11 return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
12}
13
14function atLeast(have, want) {
15 for (let i = 0; i < 3; i++) if (have[i] !== want[i]) return have[i] > want[i]
16 return true
17}
18
19// `version` is $.session.version().version (or `claude --version` output): it holds the first x.y.z.
20export function checkClaude(version) {
21 const have = triple(version)
22 const label = `Claude Code ${have ? have.join('.') : 'version unknown'} (needs >= ${MIN_CLAUDE})`
23 const ok = have !== null && atLeast(have, triple(MIN_CLAUDE))
24 return { ok, label, hint: `update Claude Code to ${MIN_CLAUDE} or later (claude update)` }
25}
26
27// `node --version` output, for example `v22.16.0`.
28export function checkNode(text) {
29 const have = triple(text)
30 const label = `Node ${have ? have.join('.') : 'version unknown'} (needs >= ${MIN_NODE})`
31 return { ok: have !== null && atLeast(have, triple(`${MIN_NODE}.0`)), label, hint: `install Node ${MIN_NODE} or later; the hooks need it` }
32}
33
34// Run through `node -e`: prints `ok` when node:sqlite loads and has the FTS5 trigram tokenizer. Node 22.13-22.15 and
35// 23.x load it without fts5, so a version check alone is not enough. install.sh carries the same probe.
36export const MEMORY_PROBE =
37 "const { DatabaseSync } = require('node:sqlite'); new DatabaseSync(':memory:').exec(\"CREATE VIRTUAL TABLE t USING fts5(x, tokenize='trigram')\"); console.log('ok')"
38
39// `out` is the stdout of `node -e MEMORY_PROBE`, or null when it failed or timed out.
40export function checkMemory(out) {
41 return {
42 ok: String(out ?? '').trim() === 'ok',
43 label: 'Memory (node:sqlite with FTS5 trigram)',
44 hint: `use Node 22.16+; node:sqlite with the FTS5 trigram tokenizer must load`,
45 }
46}
47
48// `out` is the stdout of `construct-memory doctor --json` ([{ check, ok, detail }]), or null when the command
49// could not run. Anything that is not a non-empty list of checks is one failed row, never a throw.
50export function memoryDoctorRows(out) {
51 let list = null
52 try {
53 list = JSON.parse(String(out ?? ''))
54 } catch {}
55 if (!Array.isArray(list) || list.length === 0 || !list.every((c) => c && typeof c.check === 'string')) {
56 return [{ ok: false, label: 'memory doctor', hint: 'could not run' }]
57 }
58 return list.map((c) => ({ ok: c.ok === true, label: `memory: ${c.check}`, hint: String(c.detail ?? '') }))
59}
60
61export function formatReport(rows) {
62 return ['Construct doctor', ...rows.map((r) => (r.ok ? `✓ ${r.label}` : `✗ ${r.label} — ${r.hint}`))].join('\n')
63}
64hooks/status/tasks.js 78 lines1// Active ODD feature, by the rule of hooks/scripts/feature-state.mjs: a document
2// counts when its `## Tasks` table is well formed and holds a row whose status
3// is not `done` or `dropped`.
4const FINISHED = new Set(['done', 'dropped'])
5
6function splitRow(line) {
7 return line
8 .trim()
9 .replace(/^\|/, '')
10 .replace(/\|$/, '')
11 .split(/(?<!\\)\|/)
12 .map((cell) => cell.replace(/\\\|/g, '|').trim())
13}
14
15// Finished when the first word is done or dropped (`done (evidence)`, `Done.`).
16const isFinished = (cell) => FINISHED.has(cell.toLowerCase().match(/^[a-z]+/)?.[0] ?? '')
17
18// { open, first } of the Tasks table: the unfinished row count and the first
19// unfinished row's { id, title } (null when none), or null when the table is
20// missing or malformed.
21function scanTasks(markdown) {
22 const lines = String(markdown).split(/\r?\n/)
23 const start = lines.findIndex((l) => /^##\s+Tasks\s*$/.test(l))
24 if (start < 0) return null
25 let i = start + 1
26 while (i < lines.length && !lines[i].trim().startsWith('|')) {
27 if (/^##\s/.test(lines[i])) return null
28 i++
29 }
30 if (i >= lines.length) return null
31 const header = splitRow(lines[i]).map((c) => c.toLowerCase())
32 const status = header.indexOf('status')
33 const idCol = header.indexOf('id')
34 const titleCol = header.indexOf('task')
35 if (status < 0 || idCol < 0 || titleCol < 0) return null
36 if (!/^\|?\s*:?-{3,}/.test(lines[i + 1] ?? '')) return null
37 let open = 0
38 let first = null
39 const tasks = []
40 for (i += 2; i < lines.length && lines[i].trim().startsWith('|'); i++) {
41 const cells = splitRow(lines[i])
42 if (cells.length < header.length) return null
43 // An unescaped `|` in the task cell shifts the columns: join the surplus cells into it.
44 const extra = cells.length - header.length
45 if (extra > 0) cells.splice(titleCol, extra + 1, cells.slice(titleCol, titleCol + extra + 1).join('|'))
46 tasks.push({ id: cells[idCol], done: isFinished(cells[status]) })
47 if (!isFinished(cells[status])) {
48 open++
49 first ??= { id: cells[idCol], title: cells[titleCol] }
50 }
51 }
52 return { open, first, tasks }
53}
54
55function newest(docs) {
56 let best = null
57 for (const doc of docs) {
58 if (typeof doc?.name !== 'string' || !doc.name.endsWith('.md')) continue
59 let scan
60 try {
61 scan = scanTasks(doc.text)
62 } catch {
63 continue
64 }
65 if (!scan?.open) continue
66 if (best === null || doc.mtimeMs > best.mtimeMs) best = { mtimeMs: doc.mtimeMs, feature: doc.name.slice(0, -3), open: scan.open, task: scan.first, tasks: scan.tasks.map(({ id, done }) => ({ id, done })) }
67 }
68 return best
69}
70
71// The newest document (by mtime) that still has unfinished tasks, with its first unfinished
72// task and every task row, as
73// { feature, open, task: { id, title }, tasks: [{ id, done }] }, or null.
74export function activeWork(docs) {
75 const best = newest(docs)
76 return best ? { feature: best.feature, open: best.open, task: best.task, tasks: best.tasks } : null
77}
78hooks/status/data.js 70 lines1// Pure parsers for the command output the band reads.
2
3// A git path as plain text: C-style quotes undone (quotes and backslashes; octal escapes stay as
4// written) and control characters dropped, so a path cannot move the cursor or inject escapes.
5function cleanPath(raw, quoted = true) {
6 let path = raw
7 if (quoted && path.length >= 2 && path.startsWith('"') && path.endsWith('"')) path = path.slice(1, -1).replace(/\\(["\\])/g, '$1')
8 // eslint-disable-next-line no-control-regex
9 return path.replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
10}
11
12// `git status --porcelain=v2 --branch [-z]`: with -z, entries are NUL-separated and paths are never
13// C-quoted (a rename entry `2 ...` is followed by one more field, its origin path); the result is branch, dirty entry count, ahead and behind, and the
14// untracked paths (`? <path>` entries; a directory ends in `/`).
15export function parseGitStatus(text) {
16 if (typeof text !== 'string' || !text.includes('# branch.')) return null
17 let oid = ''
18 let head = ''
19 let ahead = 0
20 let behind = 0
21 let dirty = 0
22 const untracked = []
23 const z = text.includes('\0')
24 const lines = text.split(z ? '\0' : '\n')
25 for (let i = 0; i < lines.length; i++) {
26 const line = lines[i]
27 if (line === '') continue
28 if (!line.startsWith('#')) {
29 dirty++
30 if (z && line.startsWith('2 ')) i++
31 if (line.startsWith('? ')) {
32 const path = cleanPath(line.slice(2), !z)
33 if (path !== '') untracked.push(path)
34 }
35 } else if (line.startsWith('# branch.oid ')) {
36 oid = line.slice(13).trim()
37 } else if (line.startsWith('# branch.head ')) {
38 head = line.slice(14).trim()
39 } else if (line.startsWith('# branch.ab ')) {
40 const m = /\+(\d+) -(\d+)/.exec(line)
41 if (m) [ahead, behind] = [Number(m[1]), Number(m[2])]
42 }
43 }
44 const branch = head === '(detached)' || head === '' ? oid.slice(0, 7) : head
45 return branch ? { branch, dirty, ahead, behind, untracked } : null
46}
47
48// `git diff --numstat`: one `<added>\t<removed>\t<path>` row per file; a binary file is `-\t-`.
49// Rows that do not have that shape are skipped. Use `--no-renames` so every path is one name.
50export function parseNumstat(text) {
51 if (typeof text !== 'string') return []
52 const files = []
53 for (const line of text.split('\n')) {
54 const m = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line)
55 if (!m) continue
56 const path = cleanPath(m[3])
57 if (path === '') continue
58 const binary = m[1] === '-' || m[2] === '-'
59 files.push({ path, added: binary ? null : Number(m[1]), removed: binary ? null : Number(m[2]), binary })
60 }
61 return files
62}
63
64// `git rev-parse --show-toplevel`: the root.
65export function parseRevParse(text) {
66 if (typeof text !== 'string') return null
67 const root = text.split('\n')[0].trim()
68 return root ? { root } : null
69}
70hooks/scripts/roles.mjs 41 lines1// The role registry (S6): the one place that names the construct agents and their kind.
2// Consumers (delegation-admission, contract, review-context, review-capture, the panel
3// phase, routing) derive their sets from it; tests/roles.test.mjs cross-checks it against
4// agents/, plugin.json, hooks.json, and the README.
5// Kinds: reader, writer, verifier, runner (also the role of built-in `general-purpose`),
6// judge, lens.
7export const PREFIX = 'construct:'
8
9export const ROLES = {
10 'explorer': 'reader',
11 'worker': 'writer',
12 'verifier': 'verifier',
13 'general': 'runner',
14 'judge-a': 'judge',
15 'judge-b': 'judge',
16 'review-risk': 'lens',
17 'review-resilience': 'lens',
18 'review-readability': 'lens',
19 'review-reliability': 'lens',
20}
21
22// Built-in subagent types that follow a construct role
23export const BUILTINS = { 'general-purpose': 'general' }
24
25// Legacy agent names of the six renamed roles (S7): saved model_<old>/effort_<old> values keep applying through 6.x
26export const LEGACY = { explorer: 'odd-explorer', worker: 'odd-worker', verifier: 'odd-verify', general: 'odd-general', 'judge-a': 'jd-judge-a', 'judge-b': 'jd-judge-b' }
27
28export const qualified = (name) => `${PREFIX}${name}`
29export const roleKey = (name) => name.replaceAll('-', '_')
30export const namesOf = (kind) => Object.keys(ROLES).filter((name) => ROLES[name] === kind)
31
32// kindOf: the kind of a subagent type with or without the `construct:` prefix; null when it is no construct role.
33export function kindOf(type) {
34 if (typeof type !== 'string') return null
35 const name = type.startsWith(PREFIX) ? type.slice(PREFIX.length) : (BUILTINS[type] ?? type)
36 return Object.hasOwn(ROLES, name) ? ROLES[name] : null
37}
38
39// lensOf: the lens name of a `construct:review-*` subagent type (the prefix is required), else null.
40export const lensOf = (type) => (typeof type === 'string' && type.startsWith(PREFIX) && kindOf(type) === 'lens' ? type.slice(PREFIX.length) : null)
41hooks/status/format.js 14 lines1// Pure formatting shared with the panel: the element-tree text leaf and the
2// review-state colors. No `$` calls live here, so these run (and test) in isolation.
3
4export const isNum = (x) => typeof x === 'number' && Number.isFinite(x)
5
6// A Text leaf; props with no value are left out, since a prop must be plain data.
7export function text(content, { color, dim, bold } = {}) {
8 const props = {}
9 if (color) props.color = color
10 if (dim) props.dimColor = true
11 if (bold) props.weight = 'semibold'
12 return { type: 'Text', props, children: [content] }
13}
14hooks/panel/draw.js 106 lines1// Drawing primitives of the panel: the color palette, gauges and meters, the stacked bar,
2// time formats, and the bordered card. Everything is a pure function over plain data that
3// returns `{ type, props, children }` elements, so layout.js can compose them and the tests
4// can read them without a renderer.
5
6import { isNum, text } from '../status/format.js'
7
8// Roles to theme keys (never raw colors, so the panel follows the user's theme).
9export const PALETTE = {
10 main: 'claude',
11 agent: 'suggestion',
12 gate: 'success',
13 cleared: 'permission',
14 odd: 'merged',
15 git: 'remember',
16 amber: 'warning',
17 warn: 'error',
18 dim: 'inactive',
19 faint: 'subtle',
20}
21
22// A card's frame is its border plus the title row.
23export const CHANGES_FRAME_ROWS = 3
24export const MAX_CHANGE_ROWS = 10
25
26const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n))
27
28// Cells of a fixed-width gauge: `on` filled, `off` empty.
29export function gauge(pct, width) {
30 const w = isNum(width) ? Math.max(0, Math.floor(width)) : 0
31 const filled = isNum(pct) ? clamp(Math.round((pct * w) / 100), 0, w) : 0
32 return { on: '▰'.repeat(filled), off: '▱'.repeat(w - filled) }
33}
34
35const EFFORT_SLOTS = { low: 1, medium: 2, high: 3, xhigh: 4, max: 4 }
36
37// Four slots; an unknown effort fills none.
38export function effortMeter(effort) {
39 const filled = Object.hasOwn(EFFORT_SLOTS, effort) ? EFFORT_SLOTS[effort] : 0
40 return { on: '▮'.repeat(filled), off: '▯'.repeat(4 - filled) }
41}
42
43// Time left until a reset (epoch ms or an ISO string): the two largest units, '' when unknown or past.
44export function fmtResetIn(resetsAt, nowMs) {
45 const at = typeof resetsAt === 'string' ? Date.parse(resetsAt) : resetsAt
46 if (!isNum(at) || !isNum(nowMs) || at <= nowMs) return ''
47 const mins = Math.ceil((at - nowMs) / 60_000)
48 if (mins >= 1440) return `${Math.floor(mins / 1440)}d${Math.floor((mins % 1440) / 60)}h`
49 if (mins >= 60) return `${Math.floor(mins / 60)}h${String(mins % 60).padStart(2, '0')}m`
50 return `${mins}m`
51}
52
53// Rows the changes card may fill: what is left after the other cards and its own frame, at most
54// MAX_CHANGE_ROWS; -1 when not even the frame fits.
55export function changeRows(bodyRows, used) {
56 const left = bodyRows - used - CHANGES_FRAME_ROWS
57 return left < 0 ? -1 : Math.min(MAX_CHANGE_ROWS, left)
58}
59
60// Splits `width` cells over `counts`: every non-zero count keeps one cell, the rest goes by
61// largest remainder. With fewer cells than non-zero counts, the largest counts get one each.
62export function stackBar(counts, width) {
63 const c = counts.map((n) => (isNum(n) ? Math.max(0, n) : 0))
64 const w = isNum(width) ? Math.max(0, Math.floor(width)) : 0
65 const total = c.reduce((a, b) => a + b, 0)
66 const cells = c.map(() => 0)
67 if (total === 0 || w === 0) return cells
68 const nonZero = c.map((n, i) => i).filter((i) => c[i] > 0)
69 if (w <= nonZero.length) {
70 nonZero.sort((a, b) => c[b] - c[a] || a - b).slice(0, w).forEach((i) => (cells[i] = 1))
71 return cells
72 }
73 const spare = w - nonZero.length
74 const quota = c.map((n) => (n / total) * spare)
75 for (const i of nonZero) cells[i] = 1 + Math.floor(quota[i])
76 const left = w - cells.reduce((a, b) => a + b, 0)
77 nonZero
78 .sort((a, b) => (quota[b] % 1) - (quota[a] % 1) || a - b)
79 .slice(0, left)
80 .forEach((i) => cells[i]++)
81 return cells
82}
83
84// The panel root: one column, `minHeight` fills the pane body when its rows are known.
85export function panelBox({ width, rows, children }) {
86 const props = { flexDirection: 'column', width }
87 if (isNum(rows) && rows > 0) props.minHeight = Math.floor(rows)
88 return { type: 'Box', props, children }
89}
90
91// A bordered card: bold title on the left, status on the right. A string status takes the
92// card color; `{ text, color, dim }` styles it on its own.
93export function card({ title, status, color, children = [], width, dim, marginTop }) {
94 const props = { flexDirection: 'column', borderStyle: 'round', borderColor: color, paddingX: 1 }
95 if (width !== undefined) props.width = width
96 if (marginTop) props.marginTop = marginTop
97 const head = [text(title, { color, bold: true, dim })]
98 if (typeof status === 'string') head.push(text(status, { color }))
99 else if (status) head.push(text(status.text, { color: status.color, dim: status.dim }))
100 return {
101 type: 'Box',
102 props,
103 children: [{ type: 'Box', props: { justifyContent: 'space-between' }, children: head }, ...children],
104 }
105}
106types/construct.d.ts 27 lines1// The state contract of the Construct mod: the panel's value (hooks/panel/state.js)
2// kept under a shape tag, so a reload whose schema version differs reads it as absent.
3
4export type ConstructPanel = {
5 v: number
6 agents: readonly Record<string, unknown>[]
7 inline: { count: number; last: string | null }
8 gate: {
9 cells: readonly string[]
10 counts: Record<string, number>
11 denied: number
12 lastDenied: { tool: string; detail: string } | null
13 verdicts: { rule: number; ask: number; cleared: number; deny: number }
14 buckets: Record<'file' | 'shell' | 'other', { rule: number; ask: number; cleared: number; deny: number }>
15 }
16 receipt: Record<string, unknown> | null
17 compactions: number
18 main: { steps: number; model: string | null; effort: string | null; mode: string | null }
19 blocked: boolean
20}
21
22declare module 'claude-code' {
23 interface PluginState {
24 construct: { panel: Shaped<ConstructPanel> }
25 }
26}
27