SLOPSHOPPER

Construct

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

newpaneguardcommandprocessagents
★ 1v8.7.0MITupdated 2026-10-25carsimax/construct/plugins/construct
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · construct
│ ┃ Construct ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ Opus 5.5 ⏺ Read(src/auth.ts) │ ┃ │ ▯▯▯▯ — · 0 req ⎿ Read 6 lines │ ┃ │ ctx ▰▰▰▰▰▱▱▱▱▱ 49% 97k/200k ⏺ Update(src/auth.ts) │ ┃ │ 5h ▰▰▱▱▱ 31% ↻1h00m ⎿ Added 2 lines, removed 1 line │ ┃ ╰─────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ ╭─────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ │ GATE · permissions │ ┃ │ nothing waiting on you ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ⚠ 3 inline writes · …/src/cache.ts │ ┃ ╰─────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /construct │ ┃ ⎿ construct: Construct panel opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Construct
╭──────────────────────────────────────────────────────╮ │ Opus 5.5 ○ ready │ │ ▯▯▯▯ — · 0 req │ │ ctx ▰▰▰▰▰▱▱▱▱▱ 49% 97k/200k │ │ 5h ▰▰▱▱▱ 31% ↻1h00m │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ GATE · permissions all clear │ │ nothing waiting on you │ │ ⚠ 3 inline writes · …/src/cache.ts │ ╰──────────────────────────────────────────────────────╯
README

Construct

Claude Code Plugin version License: MIT

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.

ODD in 30 seconds

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.

What you get

  • ODD workflow. A session contract, injected by hooks, that Claude follows on every request without being asked.
  • Role agents with model routing. 10 agents (explorer, worker, verifier, judges, review lenses). You choose each role's model and effort in /config.
  • Skills. Procedures that load on demand: workflow (sizing included), delegation, debugging, quality bar, migrations, branches, commits and pull requests (delivery), review, memory, and more.
  • Mandatory review. Work whose Risk names a high-risk item is reviewed by lens agents before it can be published. Hooks build the evidence, record the verdicts, and gate git push, gh pr create, and gh release create.
  • Status panel. A docked panel with the model, context, plan limits, active feature, permission gate, agents, and changes. Open it with /construct.
  • Output style. The Principal Engineer output style.
  • Memory. A per-repository memory built in (construct-memory), with no extra plugin, server, or binary. See Memory.

Requirements

RequirementWhy
Claude Code 2.1.304 or later, signed inThe 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 PATHThe review and publication hooks read the repository

Install

One command

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:

  • Adds the construct marketplace and installs construct@construct.
  • Merges into ~/.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.

OptionMeaning
--no-mcpDeprecated: has no effect and prints a warning. It is accepted so earlier install commands keep working, and will be removed in a later release
--forceDeprecated: 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

Manual install

The manual steps the installer runs, for when you want to run them yourself:

  1. Add this marketplace and install the plugin:
   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.

  1. Optional: inside Claude Code, in /config, set each role's model and effort (rows such as Worker model).
  1. Check that it works. Open a session in any git repository:
  • Ask "what does your Construct contract say about Risk lines?". The answer quotes the 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.

First session

Open Claude Code in a git repository and make a request. Construct applies the same steps each time:

  1. Authorize. Questions, reviews, and planning stay read-only. Files change only when you explicitly ask for a change.
  2. Explore. Claude reads the existing code first, in proportion to the task.
  3. Size. A task is small or large. Small work goes to one worker agent. Large work is tracked in odd/tasks/<feature>.md (specs, tasks, log) before the first source write.
  4. Implement test-first. A worker writes a failing test, makes it pass, then refactors, inside the edit surfaces it was given.
  5. Close with evidence. One report: what was verified, what failed or was skipped, the next step, and a Risk line (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.

Update

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.

Configuration

Options are plugin settings, edited in /config:

OptionEffect
Model and effort per roleSee 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

Agents and model routing

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.

RoleAgentModelEffort
Explorerconstruct:explorerhaikumedium
Workerconstruct:workersonnetmedium
Verifierconstruct:verifieropusmedium
Judge Aconstruct:judge-aopusmedium
Judge Bconstruct:judge-bsonnetmedium
Review riskconstruct:review-riskopusmedium
Review readabilityconstruct:review-readabilitysonnetmedium
Review reliabilityconstruct:review-reliabilitysonnetmedium
Review resilienceconstruct:review-resiliencesonnetmedium
Generalconstruct:general and the built-in general-purposesonnetmedium

How the routing works:

  • The plugin's mod (hooks/register.js) applies the configured model when a subagent spawns and the configured effort on each of its requests.
  • A model passed explicitly on the Agent call wins; the configured model applies only when the call names none.
  • An effort of default keeps the model's own effort.
  • Turn budgets: 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.
  • The 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.
  • The 5.x options 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.
  • If the mod cannot run, each agent keeps the model and effort in its own frontmatter.

Reference

Deep material for people who want to know how each part works.

How the ODD contract loads

A plugin cannot ship a CLAUDE.md, so Construct loads its rules in layers:

LayerMechanismContent
Session contractSessionStart hook (startup, resume, clear, compact)ODD protocol, task size, high-risk list, Risk line, and delegation triggers, under 8,000 characters
Subagent contractSubagentStart hookArtifact language, edit surfaces, scratch, and the ## Key Learnings closing block. JSON-only reviewers (review-*, judge-*) get nothing
SkillsLoaded on demandworkflow, delegation, debugging, constraints
Output styleForced by the plugin (force-for-plugin), in every projectReply 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.

Delegation admission

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.

  • Writers (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 line. When an unfinished feature document exists (the same rule the compact/resume re-injection uses), a writer prompt also needs a line 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.
  • Overlap. A writer whose surfaces overlap those of a writer still open is denied. A glob entry covers what it matches and everything under it; entries are compared segment by segment, and two different literal segments never overlap whatever follows. Parentheses are literal (Next.js route groups such as (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.
  • Prefix. worker and verifier are admitted the same with or without the construct: prefix.
  • Verifier (construct:verifier) needs a line Risk: item N (N 1-6). Risk: none does not admit a verifier.
  • Workflows. A workflow's 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.

MCP servers

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

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.

  • MCP tools. The 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.
  • Commands. 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.
  • Related memories. After a save, 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.
  • Hooks. At session start the recent memory is injected: sessions that have a summary or memories, recent prompts, pinned memories, the 20 newest agent saves, and the 3 newest passive learnings apart (after a compaction, with an instruction to save a summary first). Search ranks passive learnings below agent saves of equal relevance. Each prompt is stored (harness notices such as subagent and background-job completions are skipped), a subagent's ## 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.
  • Erasing a memory. 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.
  • Stored prompts. 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.
  • Import from engram. Run 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.
  • Export. 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>.
  • Uninstalling engram@engram is optional. Construct no longer uses it.

Quality and recovery skills

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.

SkillUse it for
construct:debuggingA 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:constraintsThe 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:sourcesA 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:migrationSchema 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 b
Source 12 files
hooks/register.js 438 lines
1import { 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}
438
hooks/routing.js 34 lines
1// 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}
34
hooks/panel/layout.js 402 lines
1// 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}
402
hooks/panel/state.js 333 lines
1// 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}
333
hooks/status/memory.js 33 lines
1// 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}
33
hooks/status/doctor.js 64 lines
1// 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}
64
hooks/status/tasks.js 78 lines
1// 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}
78
hooks/status/data.js 70 lines
1// 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}
70
hooks/scripts/roles.mjs 41 lines
1// 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)
41
hooks/status/format.js 14 lines
1// 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}
14
hooks/panel/draw.js 106 lines
1// 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}
106
types/construct.d.ts 27 lines
1// 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