SLOPSHOPPER

htui-dev

Developing htui: guards that refuse the calls that have cost this repo real losses, a status line (item, phase, branch, disk, Postgres, gate), and /gate…

newguardcommandtoaststatusprocess
v0.0.0-sourceno licenseupdated 2026-10-14mluigi/htui/.claude/skills/htui-dev
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · htui-dev
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /gate ⎿ htui-dev: (gate: no output, exit 0) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ htui-dev: detached · merging
README

htui

htui is a keyboard-driven terminal app for running a backlog of work through AI coding agents. It keeps your work items, their documents, the runs agents performed and full chat transcripts in one Postgres database, and lets you browse and drive all of it from the terminal, across several projects, repositories and machines.

  • One place for everything. Items, documents, notes, runs, requirements, skills and prompt templates live in Postgres. Credentials live in your OS keyring, never in a config file.
  • Works offline. A local copy of what you browse is kept on each machine, so htui opens instantly and stays readable when the server is out of reach.
  • Talks to real agents. Chat with Claude Code or Google's Antigravity (agy) from inside the app, and replay any recorded conversation later.
  • Drives runs step by step. Start a run on an item, approve or reject each step, retry, cancel and close out, all from the Backlog.
  • Never blocks. Network, database and agent work happen in the background, so the UI stays responsive.

It runs on Windows 10+, Linux and macOS.

Contents

Quick start

Try it without a database, using built-in sample data:

cargo run -p htui -- --demo

The demo opens on the Graphics workspace with a few projects, items, runs, notes and documents to look around. Press ? at any time to see the keys for the screen you are on, and q to quit.

Building from source

You need:

  • Rust 1.98.1. The exact version is pinned in rust-toolchain.toml, so rustup downloads it on your first build.
  • On Linux: the D-Bus development files and pkg-config, which the keyring integration builds against (sudo apt install libdbus-1-dev pkg-config on Debian and Ubuntu).
  • Network access on first use of the concepts index. Nothing is downloaded while building. The embedding model (about 134 MB) is downloaded into your user cache directory the first time you index or search, and checked against a pinned hash. An existing download from an earlier htui is reused. On a machine without network access, place onnx/model.onnx (saved as model.onnx) and tokenizer.json from Xenova/bge-small-en-v1.5 at commit ea104dacec62c0de699686887e3f920caeb4f3e3 in <user cache>/htui/model/bge-small-en-v1.5-ea104dac/ (~/.cache on Linux, ~/Library/Caches on macOS, %LOCALAPPDATA% on Windows): files whose sha256 matches the pins in crates/htui-store/src/model.rs are used and nothing is downloaded.
  • git on your PATH, for running work in repositories.

Then build:

cargo build --release

The binary is target/release/htui (target/release/htui.exe on Windows).

To install it onto your PATH (~/.cargo/bin) instead, use cargo install. htui is not published to crates.io, so install from git or from a checkout:

cargo install --git https://git.mluigi.it/htui.git htui --locked
cargo install --path crates/htui --locked

Toolchain

workspace.package.rust-version in Cargo.toml and clippy.toml both say 1.98, matching the pinned toolchain on purpose: with an exact pin, the only minimum version that is both true and checkable is the one everybody actually builds with. Keep all three in step when you move the pin.

Command-line options

OptionWhat it does
--demoStart with the built-in sample data in memory instead of connecting to a database.
--offlineOpen from the local copy only and never try to connect.
--set-dsnRead a Postgres connection string from standard input, save it in the OS keyring and exit.
--clear-dsnRemove the saved connection string from the OS keyring and exit.
--dsn-stdinRead the connection string for this session from standard input; it is never stored. For a TUI over ssh -t on a machine with no keyring.
--keys <PATH>Read key bindings from this file instead of keys.toml. See Changing keys.
--default-keysIgnore keys.toml for this run and use the default keys.
--print-keysPrint every key binding in force as a commented keys.toml and exit. With an invalid file, print its errors and exit with status 2.
--index-itemsUpdate the search index from the database, then exit. See Search.
--search-items <QUERY>Search items, their documents and requirements, print the results and exit. Exits 3 when the database or the search index cannot be reached, 2 on a refusal (for example no index address or connection string saved) and 1 on any other failure.
--project <SLUG>With --index-items or --search-items: only this project. status and waiting take their own --project, after the subcommand.
--decisionsWith --search-items: only decisions, meaning items closed as done, concluded or rejected, and their documents.
--limit <N>With --search-items: how many results to print, from 1 to 1000 (default 10).
--jsonWith --search-items: print one JSON document instead of lines. See Scripting htui.
--cwd <PATH>With --search-items: only the project whose repository, registered on this machine, contains PATH. Cannot be combined with --project.
--log <PATH>Append logs to a file. Also read from HTUI_LOG; HTUI_LOG_FILTER changes the level (default info). Logs never go to the terminal, since the terminal is the app (htui worker, which has none, logs to standard error without it).
worker [--pool-size N] [--dsn-stdin] [--log PATH]Run this machine's runs with no terminal, so they outlive the TUI: htui worker. Needs the box's executor set to worker in Settings › Boxes; no other option may be given beside it, and --log goes after worker. See docs/htui-worker.md.
provision <DESTINATION> [--dsn-stdin] [--replace-credential] [--log PATH]Install this build as the htui-worker service on another Linux machine over ssh, with the DSN encrypted there by systemd-creds, and set that box's executor to worker. See docs/htui-worker.md.
`status [--json] [--project SLUG \--cwd PATH] [--log PATH]`Print the top bar's facts and exit: the workspace, this machine, and how many runs are working and how many things wait on you. Reads only. See Scripting htui.
`waiting [--json] [--project SLUG \--cwd PATH] [--log PATH]`Print what waits on you, as the waiting-on-you list shows it, with how long each has waited, and exit. Reads only. See Scripting htui.
--help, --versionPrint usage or the version and exit.

Connecting to your database

htui needs Postgres 16 or newer.

Run a local server

The repository includes a compose.yaml that starts Postgres 16 on port 5439, so it does not clash with a Postgres you may already have on 5432:

docker compose up -d
echo postgres://postgres:REDACTED@localhost:5439/htui | htui --set-dsn

The same file also starts Qdrant, which is only needed for Search.

Save your connection string

The connection string (DSN) is stored in your OS keyring: Windows Credential Manager, the macOS Keychain, or the Secret Service on Linux. There is no environment variable or config file for it, so it never ends up in your shell history or process list.

You can save it in either of two ways:

  • From inside the app: open Settings › Connection and press e. The input is masked. Press Enter to save, and htui connects straight away without a restart. A machine with no saved connection opens on this screen automatically.
  • From the command line: run htui --set-dsn, paste the DSN and press Enter. You can also pipe it in, as in the example above. htui --clear-dsn removes it again.

TLS follows whatever your DSN asks for, for example ?sslmode=require.

Online and offline

htui opens from its local copy first and connects in the background. The top bar shows where it stands:

Top barMeaning
memoryRunning with --demo. No server and no local copy.
connectingThe first connection attempt has not answered yet.
onlineConnected. The local copy is kept up to date in the background.
offline · 3mNo connection for three minutes. You can browse what was copied locally, but nothing can be changed and no chat or run can start.

While offline, htui retries every 30 seconds and goes back online on its own. Use --offline to look at the local copy on purpose.

Database upgrades

When a new version of htui needs to upgrade your database, it asks first and never upgrades on its own:

3 schema migrations are pending. Apply them now?

y apply · n / Esc stay offline

Press y to upgrade and go online, or n / Esc to leave the database untouched and keep browsing the local copy. If the database is newer than your copy of htui, htui stays offline and says why on the status line; update htui to connect.

Using htui

Everything happens in a workspace. Press w to switch workspaces. If the database has none yet, create one in Settings › Hierarchy with N.

htui has five tabs. Switch with Tab / Shift+Tab or the number keys 1 to 5.

TabWhat it is for
1 BacklogThe workspace's items, grouped by project, with details on the right. This is also where you drive runs.
2 SkillsYour skill library and prompt templates: browse versions, compare them, edit, import, and attach skills to projects and repositories.
3 RequirementsEach project's requirements, grouped by area: what each one says, which items cite it and how it changed over time. The project's owner adds, amends and withdraws them here.
4 SettingsAgents, workspaces and projects, item kinds, prompt settings, the database connection, the search server, this machine's profile and agent personas.
5 ChatA live conversation with an agent, and replays of recorded ones.

Press ? on any screen for its full list of keys. The ones below are the essentials.

Everywhere

KeyAction
qQuit
Tab / Shift+TabNext / previous tab
1 … 5Go to a tab
wSwitch workspace
?Show or hide the key help
EscClose the open pop-up

Most lists use j / k (or the arrow keys) to move, and h / l (or [ / ]) to switch between sub-tabs or sections.

Changing keys

htui reads your key bindings from keys.toml in its configuration directory (see Where htui keeps its files); --keys <PATH> reads another file instead. The file is optional. List only what you change: a list replaces that action's keys, and [] unbinds it. htui --print-keys prints every action with its keys as a commented keys.toml: start from it, and run it again to check your file.

version = 1

[global]
quit = ["ctrl-y"]

[overlay]
close = ["esc", "f2"]

Within one table, your keys win over the defaults you leave alone: if a key you list is the default of another action in the same table, that action gives it up, and is unbound if it had no other key. htui starts anyway and says so on stderr, with the file's path and line, as in [global] quit = "ctrl-q" takes "ctrl-q" from global.queue, which is now unbound; htui --print-keys marks that action's line (unbound by quit). List the action with another key to keep it.

htui refuses to start on an invalid file and names every bad line, as in keys.toml:5: [global] quit = "ctrl-c": ctrl-c always quits and cannot be bound; htui --default-keys starts with the default keys anyway. Two actions you list with the same key are an error. A default gives way to a key from another table in one case only: a [global] key may take the in-pane editor's abort key (ctrl-x) from [editor] abort, which is then unbound, with the same notice. Otherwise a key shared between a pop-up's keys and [global] help is an error in either direction (such as esc for [global] help, or f1 for [overlay] close). [editor] focus must keep a key, and no other action in any table may use it. ctrl-c always quits and cannot be bound. The [editor] rules are in docs/htui-editor.md.

A table named after a Settings section, a Skills view, [requirements], a Backlog sub-tab ([backlog.runs], [backlog.docs], …), [chat] or a pop-up changes a shared action there alone: [settings.boxes] reload = ["f5"] reloads Boxes on F5, and every other section keeps r. [backlog] holds the Backlog's own keys (filter, new item, queue, …), [queue] the queue overlay's (Ctrl+q: move, reorder, pause, dequeue, open) and [picker] the directory picker's (Settings › Hierarchy, b); [backlog.runs] filter = ["/"] moves the filter key in the Runs sub-tab alone. htui --print-keys prints every table that has keys. To see what a table may change, write a name htui does not know in it: the error lists every action that table has and every shared action it may override. A line copied from --print-keys into such a table, like [settings.agents] next_field, replaces that row, so it stops following the shared [form], [common] or [confirm] line; delete it to let it follow again.

A global key must not be a key that Settings, Skills, Requirements, the Backlog, Chat or a pop-up already uses: [global] quit = ["x"] is refused, because x cancels in Settings › Agents and rejects a step in the Backlog's Runs; so is [global] quit = ["f"], because f opens the Backlog filter. A shared table is checked the same way on every screen that uses it: [list] down = ["G"] is refused, because G jumps to the bottom in Chat, and so is [pane] next_subtab = ["tab"], because Tab moves to the next tab. Some Settings forms also move between fields with Down / Up. A [form] next_field line changes them too, and they keep Down; write [settings.agents] next_field = [...] to replace it in that form alone. While a text field takes typing, the keys the field uses itself (letters, arrows, Enter, Esc) stay the field's. In the same way, keys a screen keeps for itself (a choice's value keys, such as Space and h / l on the Backlog filter's rows, or the digits that answer a permission request, the Requirements priority's m / l / Space / ← / →, or the attachment form's activation Space) cannot be given to an action there: [backlog.filter_form] clear = ["space"] is refused. Tab still leaves a Skills editor with the draft kept. In the Backlog's item form and the Notes and Docs editors, [form] save and [form] external_editor replace Ctrl+s and Ctrl+e.

The row above the status line names the keys of what you are in, and follows your keys.toml; ? lists all of them. Every action with its default keys is in docs/htui-keys.md, the output of htui --default-keys --print-keys.

Backlog

Select an item on the left to see it on the right. The detail pane has seven sub-tabs: Body, Runs, Graph (linked items), Docs, Notes, Prompt and Reqs (the requirements the item cites).

KeyAction
j / k, g / GMove, jump to the first / last row
Enter on a projectFold or unfold the project
h / lPrevious / next detail sub-tab
J / K, PageUp / PageDownScroll the detail pane; in Runs, Graph, Docs and Reqs J / K move the pane's cursor, and in Graph PageUp / PageDown move it a page
f / FFilter the list / clear the filter
N / eNew item / edit the item
mOpen the Graph sub-tab
Q / PQueue or dequeue the item / pause or resume this box's queue
?Every key of the pane you are in

The row above the status line names the keys of the focused sub-tab, then the list's when they fit. Ctrl+q opens this box's queue: j / k move, J / K reorder, P pauses or resumes, Q dequeues and Enter opens the item.

Runs. The Runs sub-tab lists an item's runs and their steps. Move between steps with J / K. If an action isn't allowed right now, the status line says why and nothing happens.

KeyAction
RStart a run on the item
a / xApprove / reject the step (rejecting asks for a note)
rRetry the step
sPick this step as the winner of a fan-out
AAccept the step's output
oRead the step's output document
pContinue the step as a chat
iSend a running step's agent a follow-up; it is sent when the current turn ends
EnterReplay the step's conversation in the Chat tab
cCancel the run
TRetry the run's cleanup
uUnblock the item
CClose out the item: pick how it ends with ← / →, press y, then type the item's key to confirm
vSwitch between the list and the flow view
+ / -, =In the flow view: zoom in / out, fit the whole run

The flow view draws the run under the cursor as a graph. Fan-out candidates sit side by side, the judge goes below them, and a retry follows the attempt it replaces. It uses the same cursor as the list, so J / K and every key above work in both views.

When you close out an item, the picker offers only the endings that fit its status. A done item starts on done and can end as done, concluded, rejected, withdrawn, superseded or duplicate. An open, blocked or failed item starts on withdrawn and can also end as rejected, superseded or duplicate.

Runs need a connection to the database.

Graph. The Graph sub-tab draws the items the selected one links to. J / K and PageUp / PageDown move the cursor, + / - show more or fewer links deep, and Enter re-roots the graph on the item under the cursor.

Docs. J / K move between the item's documents, a writes a new document and v a new version of the one under the cursor; Ctrl+s saves.

Notes. a adds a note to the item; Ctrl+s saves it.

Prompt. The Prompt sub-tab shows exactly what an agent would be sent for this item: the assembled text, its token budget, what was trimmed to fit, and which files were picked as context. n / p switch between the project's templates. Looking at a prompt changes nothing and starts nothing.

Reqs. The Reqs sub-tab lists the requirements the item cites, and how: it addresses a requirement, reserves it for later, or is the item that amends or withdraws it. A citation is marked ! suspect when the requirement has changed since the item cited it.

KeyAction
J / KMove between citations
rRe-confirm a suspect citation against the requirement as it reads now
cCite another requirement of the item's project (Enter picks it, then a addresses or v reserves)
uRemove the citation (asks you to confirm with y)

A citation made by amending or withdrawing a requirement is part of that decision and stays. Citing needs a connection to the database.

Requirements

The left pane lists the workspace's projects, their areas and each area's requirements, with their priority (must or later). Select a requirement to see its full text and rationale, which items cite it (a ! suspect marker means the item cited an older version) and its revision history. Withdrawn requirements stay in the list, dimmed and marked ✕.

KeyAction
j / k, g / GMove, jump to the first / last row
Enter on a project or areaFold or unfold it
J / K, PageUp / PageDownScroll the detail pane
/Filter the list as you type (Enter keeps the filter, Esc clears it)
aAdd an area to the project
nWrite a new requirement in the selected area
eAmend the requirement
WWithdraw the requirement (asks you to type its key to confirm)
rRead the requirements again

In the forms, Tab moves between fields and Ctrl+s saves. An amend or a withdraw names the item that decided it, and that item then cites the requirement.

A project's requirements belong to whoever first added an area or a requirement to it. Anyone can read them, but only the owner can add, amend or withdraw; for everyone else, and while offline, the write keys are greyed out and the status line says why. If someone else changed a requirement while you were amending it, your text is kept and Ctrl+s saves over the new version.

Chat

KeyAction
i / EnterWrite a message (Enter sends, Esc stops writing)
aPick another agent (before the first message)
1 … 9Answer the agent's permission request
Esc EscEnd the conversation
tShow or hide the agent's thinking
j / k, g / GScroll

The header shows the agent, the model and the project. If the agent can't do something, such as ask for permission or propose edits, a banner says so before you start typing.

Chats need a connection to the database. When you quit, htui stops every running agent and waits for it to exit.

Replays open read-only over the Chat tab. Esc closes the replay and brings back your live conversation exactly as it was.

Settings

SectionWhat you do there
AgentsSee which agents work on this machine (r checks again), install an agent's adapter (i), and log in (a).
HierarchyCreate and edit workspaces (N), projects and repositories (n), and tell htui where each repository lives on this machine (b picks a directory, or i detects them).
KindsItem kinds, their phases and step graphs; a phase's persona field binds a persona to it.
PromptPrompt settings such as the token budget.
ConnectionThe database connection string (e edit, c clear) and rebuilding the local copy (R).
QdrantThe search server's address and API key (e edit, c clear).
BoxesThis machine's profile: tags (t), quirks (e), who runs its runs, the TUI or htui worker (w), and a fresh check of its tools (p).
PersonasAgent personas: create (n), edit the fields (e), the body (b) and the permission rules (r), delete (d), and import .md persona files (I). See docs/personas.md.

Deleting in Hierarchy shows what would be removed and asks you to type a confirmation, because it cannot be undone.

Setting up agents

htui comes with three agents:

AgentWhat it is
claudeClaude Code over ACP, the Agent Client Protocol. The recommended choice.
claude-cliClaude Code through its command-line JSON stream. A fallback for when the ACP adapter can't be installed.
agyGoogle Antigravity, through Google's ACP server.

Check Settings › Agents to see which ones work on this machine. n adds an agent and e edits the selected one.

Installing an adapter

Some agents need a separate adapter program. For agy, that is Google's agy_acp_server, which the agy command-line tool does not install. In Settings › Agents, select the agent and press i. Before anything is downloaded, htui shows what it will install: the version, where it comes from, its size, whe

Source 6 files
hooks/register.ts 508 lines
1// htui-dev: developing htui. Guards that each refuse, with the fix in its reason, a kind of
2// call that has cost this repo real work (docs/htui-dev.md):
3//   G1  cargo test that builds feature-gated integration tests without their feature (0 tests, green)
4//   G2  cargo test with the other crate's test feature name (testkit vs test-support)
5//   G3  the literal extensibility.rs forbids, written into crates/**/*.{rs,json} by an edit tool
6//   G4  file writes, and the git writes docs/htui-dev.md lists, from an agent started read-only
7//   G5  a subagent asked to run on Fable
8//   G6  git commit --no-verify (or -n), which skips the repo's commit hooks
9// Every guard is a `{ deny }`, never a rewrite, and judges before `next`. A guard that throws
10// lets the call through and says so on the status line (ANA-29 §6.3, "fail open"): the guards
11// are a safety net, not a sandbox. Beside them (M2): one status line, refreshed by a 60 s timer in
12// interactive sessions (item and HANDOFF phase, merge/ahead/dirty, disk, Postgres, last gate), a
13// toast when a gate finishes, and the turnless /gate, /next and /validate. The mod runs git and
14// the repo's scripts, reads HANDOFF.md and target/gate/last.json, writes no file and reaches no
15// network. Its values in `$.state` (read-only agents' ids, the gate last toasted, whether the
16// session runs the timer) survive a reload; the timer does not, and a tool call re-arms it.
17import { atom, read, update } from 'claude-code'
18import type { EngineInterface, Register, Timer } from 'claude-code'
19import type { HtuiDevGateSeen, HtuiDevReadOnly, HtuiDevReadOnlyCalls } from '../types'
20import { gateCommand, scriptArgv, scriptCommand, type Answer } from './commands'
21import {
22  composeAmbient,
23  composeLine,
24  gateToast,
25  gather,
26  join,
27  parseGate,
28  type GateRead,
29  type Host,
30  type Ran,
31  type Reader,
32  type Runner,
33} from './status'
34import {
35  cargoTest,
36  coarseGitWrite,
37  type Cwd,
38  declaresReadOnly,
39  fableModel,
40  forbiddenLiteral,
41  gitWrite,
42  isFable,
43  movedTo,
44  noVerify,
45  readOnlyEdit,
46  readOnlyGit,
47  writesNothing,
48} from './guards'
49import { simpleCommands, type Words } from './shell'
50
51const readOnly = atom({ plugin: 'htui-dev', key: 'readOnly' } as const, [] as HtuiDevReadOnly)
52const readOnlyCalls = atom({ plugin: 'htui-dev', key: 'readOnlyCalls' } as const, [] as HtuiDevReadOnlyCalls)
53const gateSeen = atom({ plugin: 'htui-dev', key: 'gateSeen' } as const, null as HtuiDevGateSeen | null)
54const ambient = atom({ plugin: 'htui-dev', key: 'ambient' } as const, false as boolean)
55
56/** The status timer's period (ANA-29 §6.1). */
57const PERIOD_MS = 60_000
58/** How long a guard warning leads the line, from the first tick that showed it. */
59const WARNING_MS = 10 * 60_000
60
61/** `status_line` as `register` read it; off stops the timer. */
62let statusLine = true
63/** The status timer while one runs; a module reload drops it. */
64let timer: Timer | null = null
65/** Set while a tick runs, so a slow tick is never overlapped by the next. */
66let polling = false
67/** Whether a tool call already asked `ambient` since this module loaded. */
68let rearmChecked = false
69/** The last guard warning, and the tick time it was first shown at (null until a tick stamps it). */
70let warning: { text: string; at: number | null } | null = null
71/** The last ambient line a tick composed. */
72let ambientLine: string | null = null
73/** The last line put on the status line, so an unchanged tick sends nothing. */
74let shown: string | undefined
75/** The checkout, found once per module load. */
76let hostMemo: Host | null = null
77
78/** Puts the composed line on the plugin's status line (`force`: even when unchanged); never throws. */
79function show($: EngineInterface, force = false): void {
80  const line = composeLine(warning?.text ?? null, ambientLine)
81  if (!force && line === shown) return
82  shown = line
83  try {
84    // Fire and forget: the call answers nothing, so only a throw (a re-entry) is seen.
85    $.ui.status(line)
86  } catch {
87    // The next tick sends the line again.
88    shown = undefined
89  }
90}
91
92/**
93 * Puts `text` on the plugin's status line, ahead of the ambient line while there is one; never
94 * throws and calls nothing on `$` but the status line (it runs where every other `$` call rejects).
95 */
96function warn($: EngineInterface, text: string): void {
97  warning = { text, at: null }
98  show($, true)
99}
100
101/** Runs one guard; a throw is a status warning and no refusal (ANA-29 §6.3). */
102function judge($: EngineInterface, guard: string, what: 'call' | 'spawn', decide: () => string | null): string | null {
103  try {
104    return decide()
105  } catch {
106    warn($, `htui-dev: ${guard} guard failed, ${what} allowed`)
107    return null
108  }
109}
110
111/**
112 * Whether the list `load` reads holds `id`; an undefined or empty `id` reads nothing. A read that
113 * fails, or a value that is no list, warns with `failed` and answers false.
114 */
115async function holds($: EngineInterface, load: () => Promise<unknown>, id: string | undefined, failed: string): Promise<boolean> {
116  if (id === undefined || id === '') return false
117  try {
118    const ids = await load()
119    if (!Array.isArray(ids)) throw new TypeError('the state holds no list')
120    return ids.includes(id)
121  } catch {
122    warn($, failed)
123    return false
124  }
125}
126
127/** Whether `agentId` was tagged read-only. The main loop (no `agentId`) never reads the state. */
128const isTagged = ($: EngineInterface, agentId: string | undefined, failed: string): Promise<boolean> =>
129  holds($, () => read($, readOnly), agentId, failed)
130
131/** Whether a read-only agent made the Workflow or Agent call `toolUseId`. */
132const isReadOnlyCall = ($: EngineInterface, toolUseId: string | undefined, failed: string): Promise<boolean> =>
133  holds($, () => read($, readOnlyCalls), toolUseId, failed)
134
135/** The list with `id` added, once. */
136const adding =
137  (id: string) =>
138  (ids: readonly string[] | undefined): readonly string[] =>
139    (ids ?? []).includes(id) ? ids! : [...(ids ?? []), id]
140
141/** Runs a state write; one that fails warns with `failed`. */
142async function store($: EngineInterface, write: () => Promise<unknown>, failed: string): Promise<void> {
143  try {
144    await write()
145  } catch {
146    warn($, failed)
147  }
148}
149
150/**
151 * `agent.spawn`'s backstop (a throw, an overrun budget, a wrong answer shape): a spawn is never
152 * refused here. A settled `next` is replayed, with a warning that the agent may have started before
153 * its read-only tag was written; otherwise the spawn goes ahead, with a warning.
154 */
155export function spawnBackstop<E, R>($: EngineInterface, e: E, next: ((e: E) => R) & { readonly called: boolean }): R {
156  if (next.called) {
157    warn($, 'htui-dev: agent.spawn guard failed after the spawn, read-only tag may be missing')
158    return next(e)
159  }
160  warn($, 'htui-dev: agent.spawn guard failed, spawn allowed')
161  return next(e)
162}
163
164// ---- M2: the status line ----
165
166/** The clock's time; 0 when it cannot be read. */
167async function nowOf($: EngineInterface): Promise<number> {
168  try {
169    return await $.clock.now()
170  } catch {
171    return 0
172  }
173}
174
175/** `$.process.run` as a Runner: a run that cannot start, or outlasts its bound, is `{ failed }`. */
176const runnerOf =
177  ($: EngineInterface): Runner =>
178  async (argv, init) => {
179    const started = await nowOf($)
180    try {
181      const r = await $.process.run(argv, init)
182      return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr } satisfies Ran
183    } catch {
184      const elapsed = (await nowOf($)) - started
185      return { failed: elapsed >= init.timeoutMs ? 'timed out' : 'not found' }
186    }
187  }
188
189/** `$.fs.read` as a Reader: a missing or unreadable file is null. */
190const readerOf =
191  ($: EngineInterface): Reader =>
192  async path => {
193    try {
194      const text = await $.fs.read(path)
195      return typeof text === 'string' ? text : null
196    } catch {
197      return null
198    }
199  }
200
201/**
202 * The checkout this copy of the mod sits in: `git rev-parse --show-toplevel` from the plugin
203 * folder, and whether this is Windows. Kept once found; a failure is tried again next time.
204 */
205async function hostOf($: EngineInterface): Promise<Host | null> {
206  if (hostMemo !== null) return hostMemo
207  const r = await runnerOf($)(['git', 'rev-parse', '--show-toplevel'], { cwd: $.plugin.root, timeoutMs: 10_000 })
208  if ('failed' in r || r.exitCode !== 0 || r.stdout.trim() === '') return null
209  let windows = false
210  try {
211    windows = (await $.env.get('OS')) === 'Windows_NT'
212  } catch {
213    // Not Windows, as far as anyone can tell.
214  }
215  hostMemo = { root: r.stdout.trim(), windows }
216  return hostMemo
217}
218
219/** Toasts a gate that finished since the last one seen (C2); the first one seen only seeds. */
220async function toastGate($: EngineInterface, doc: GateRead): Promise<void> {
221  if (doc === null || doc === 'unreadable' || doc.state === 'running' || doc.finished === null) return
222  const seen = await read($, gateSeen)
223  if (seen !== null && seen.finished === doc.finished) return
224  const finished = doc.finished
225  const text = gateToast(doc)
226  if (seen !== null && text !== null) $.ui.toast(text)
227  await update($, gateSeen, () => ({ finished }))
228}
229
230/** Seeds `gateSeen` from the gate result there is now, so a gate that ended before the session never toasts. */
231async function seedGate($: EngineInterface): Promise<void> {
232  const host = await hostOf($)
233  // No root, no seed: the first finished gate a tick sees then seeds instead of toasting.
234  if (host === null) return
235  const doc = parseGate(await readerOf($)(join(host.root, 'target', 'gate', 'last.json')))
236  const finished = doc !== null && doc !== 'unreadable' && doc.state !== 'running' ? doc.finished : null
237  await update($, gateSeen, prev => prev ?? { finished })
238}
239
240/** One period: gather the facts, compose the line, toast a finished gate, age the warning out. */
241async function tick($: EngineInterface): Promise<void> {
242  if (polling) return
243  polling = true
244  try {
245    const now = await nowOf($)
246    const host = await hostOf($)
247    if (host === null) {
248      ambientLine = 'htui-dev: no repo root (git rev-parse failed)'
249    } else {
250      let hrItem: string | undefined
251      try {
252        hrItem = await $.env.get('HR_ITEM')
253      } catch {
254        hrItem = undefined
255      }
256      const snap = await gather(host, hrItem, runnerOf($), readerOf($), scriptArgv(host, 'health'))
257      ambientLine = composeAmbient(snap, now)
258      try {
259        await toastGate($, snap.gate)
260      } catch {
261        // The state store failed: no toast this time; the line still updates.
262      }
263    }
264    if (warning !== null) {
265      warning.at ??= now
266      if (now - warning.at >= WARNING_MS) warning = null
267    }
268    show($)
269  } catch {
270    // The module unloaded mid-tick (its `$` is gone), or a state write failed: the next tick retries.
271  } finally {
272    polling = false
273  }
274}
275
276/** Starts the status timer unless one runs, with one tick now. */
277function arm($: EngineInterface): void {
278  if (timer !== null) return
279  timer = $.clock.every(PERIOD_MS, () => void tick($))
280  void (async () => {
281    try {
282      await seedGate($)
283    } catch {
284      // Unseeded: the first finished gate seen then seeds instead of toasting.
285    }
286    await tick($)
287  })()
288}
289
290/**
291 * A reload drops the timer. Live (2.1.296) a reload raises `session.start` again, which re-arms
292 * it; should one not, the first main-loop tool call after it asks `ambient` whether this session
293 * ran the timer and re-arms it (or, with `status_line` now off, takes the ambient part off the
294 * line). Never awaited: it adds nothing to a guard's path.
295 */
296function kick($: EngineInterface, agentId: string | undefined): void {
297  // Only the main loop's calls: a subagent's `$` is not known to outlive its dispatch.
298  if (agentId !== undefined || rearmChecked || timer !== null) return
299  rearmChecked = true
300  void (async () => {
301    let fit: boolean
302    try {
303      fit = await read($, ambient)
304    } catch {
305      rearmChecked = false
306      return
307    }
308    if (fit !== true) return
309    if (statusLine) {
310      arm($)
311    } else {
312      ambientLine = null
313      show($, true)
314    }
315  })().catch(() => {})
316}
317
318// ---- M2: the commands ----
319
320const NO_ROOT: Answer = { text: 'htui-dev: cannot find the repo root (git rev-parse --show-toplevel failed)', exitCode: 1 }
321
322/** Runs a command's body against the checkout; never throws (a throw is the command's answer). */
323async function answer($: EngineInterface, name: string, body: (h: Host) => Promise<Answer>): Promise<Answer> {
324  try {
325    const h = await hostOf($)
326    return h === null ? NO_ROOT : await body(h)
327  } catch (err) {
328    return { text: `htui-dev: /${name} failed: ${err instanceof Error ? err.message : String(err)}`, exitCode: 1 }
329  }
330}
331
332export const register: Register = (on, options) => {
333  statusLine = (options as { status_line?: unknown }).status_line !== false
334  timer = null
335  polling = false
336  rearmChecked = false
337  warning = null
338  ambientLine = null
339  shown = undefined
340  hostMemo = null
341
342  on('session.start', async ($, e, next) => {
343    // Every session gets the commands (they serve `claude -p "/validate"` too); each is registered
344    // by a literal name, so `claude plugin validate` reads its hook as answering its own command.
345    try {
346      await $.command.register({
347        name: 'gate',
348        description: 'Run the htui gate detached (scripts/gate.sh); status: its last result',
349        argumentHint: '[step|status|force]',
350      })
351      await $.command.register({ name: 'next', description: 'Rank the open HANDOFF items (next-item script)' })
352      await $.command.register({ name: 'validate', description: 'Check HANDOFF.md and DECISIONS.md (workflow validator)' })
353    } catch {
354      warn($, 'htui-dev: /gate, /next or /validate not registered')
355    }
356    // The timer runs where a person watches the line, and never in a session htui drives.
357    try {
358      const fit = e.isInteractive === true && (await $.env.get('HTUI_SESSION')) === undefined
359      await update($, ambient, () => fit)
360      if (fit && statusLine) arm($)
361    } catch {
362      // No timer; the commands still work.
363    }
364    return next(e)
365  })
366
367  on('command.run', { command: 'gate' }, async ($, e) =>
368    answer($, 'gate', async h => {
369      const run = runnerOf($)
370      const head = await run(['git', 'rev-parse', 'HEAD'], { cwd: h.root, timeoutMs: 10_000 })
371      const sha = 'failed' in head || head.exitCode !== 0 ? null : head.stdout.trim()
372      return gateCommand(e.args, h, run, readerOf($), await nowOf($), sha)
373    }),
374  )
375  on('command.run', { command: 'next' }, async $ => answer($, 'next', h => scriptCommand('next', h, runnerOf($))))
376  on('command.run', { command: 'validate' }, async $ => answer($, 'validate', h => scriptCommand('validate', h, runnerOf($))))
377
378  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
379    kick($, e.agentId)
380    let cmds: Words[] | null = null
381    try {
382      cmds = simpleCommands(e.command)
383    } catch {
384      warn($, 'htui-dev: shell parser failed, call allowed')
385    }
386    const tagged = await isTagged($, e.agentId, 'htui-dev: read-only guard failed, call allowed')
387    if (cmds === null) {
388      // A line the parser cannot follow is allowed, with no status: it is no failure of the guard.
389      // A read-only agent's still meets a coarse check for git writes.
390      const reason = tagged
391        ? judge($, 'read-only', 'call', () => {
392            const verb = coarseGitWrite(e.command)
393            return verb === null ? null : readOnlyGit(verb, e.agentId!)
394          })
395        : null
396      return reason !== null ? { deny: reason } : next(e)
397    }
398    // A `cd crates/<member>` earlier in the line makes a bare `cargo test` select that member.
399    let cwd: Cwd = []
400    for (const words of cmds) {
401      const reason =
402        (tagged
403          ? judge($, 'read-only', 'call', () => {
404              const verb = gitWrite(words)
405              return verb === null ? null : readOnlyGit(verb, e.agentId!)
406            })
407          : null) ??
408        judge($, 'no-verify', 'call', () => noVerify(words)) ??
409        judge($, 'cargo test', 'call', () => cargoTest(words, cwd))
410      if (reason !== null) return { deny: reason }
411      try {
412        cwd = movedTo(words, cwd)
413      } catch {
414        cwd = []
415      }
416    }
417    return next(e)
418  }).catch(($, e, next) => {
419    // A backstop (overrun budget, wrong answer shape, re-entry): it replays a settled `next`, and
420    // otherwise lets the call through with a warning. It never refuses.
421    if (next.called) return next(e)
422    warn($, 'htui-dev: tool.call guard failed, call allowed')
423    return next(e)
424  })
425
426  // The tools that write a file. Of Gortex's MCP tools (Q3): `edit_file`, `write_file`,
427  // `edit_symbol`, `rename_symbol` and `batch_edit` by their schemas; `edit` and `refactor`, the
428  // compact names, read the same way. Serena's write tools are configured machine-wide, so they
429  // reach this repo too; its optional JetBrains ones are `jet_brains_*` in Serena 2.0.0 (the
430  // shorter `jetbrains_*` is matched as well). `execute_shell_command` is left out: Serena's
431  // claude-code context excludes it, and it is no file write. Either server may also come from a plugin (`mcp__plugin_<name>_<server>__`).
432  // The patterns are written in the matcher, so that `claude plugin validate` lists them.
433  on(
434    'tool.call',
435    {
436      tool: [
437        'Edit',
438        'Write',
439        'NotebookEdit',
440        /^mcp__(plugin_[a-z0-9_-]+_)?gortex__(edit|edit_file|edit_symbol|batch_edit|rename_symbol|refactor|write_file)$/,
441        /^mcp__(plugin_[a-z0-9_-]+_)?serena__(create_text_file|replace_content|replace_in_files|replace_symbol_body|insert_after_symbol|insert_before_symbol|rename_symbol|safe_delete_symbol|replace_lines|insert_at_line|delete_lines|jet_?brains_(?:rename|move|safe_delete|inline_symbol))$/,
442      ],
443    },
444    async ($, e, next) => {
445      kick($, e.agentId)
446      const input = e as unknown as Readonly<Record<string, unknown>>
447      if (writesNothing(e.tool, input)) return next(e)
448      // G4 first: a read-only agent writes nothing, whatever the text.
449      if (await isTagged($, e.agentId, 'htui-dev: read-only guard failed, call allowed')) {
450        return { deny: readOnlyEdit(e.tool, e.agentId!) }
451      }
452      const reason = judge($, 'forbidden literal', 'call', () => forbiddenLiteral(e.tool, input))
453      return reason !== null ? { deny: reason } : next(e)
454    },
455  ).catch(($, e, next) => {
456    // A backstop (overrun budget, wrong answer shape, re-entry): it replays a settled `next`, and
457    // otherwise lets the call through with a warning. It never refuses.
458    if (next.called) return next(e)
459    warn($, 'htui-dev: tool.call guard failed, call allowed')
460    return next(e)
461  })
462
463  // G4 for workflows: a workflow agent's spawn names no parent, only the Workflow call that
464  // started its run (its `tool_use_id`). A read-only agent's Workflow and Agent calls are noted
465  // before they run, so the spawns they cause can be tagged. Never refuses.
466  on('tool.call', { tool: ['Workflow', 'Agent'] }, async ($, e, next) => {
467    kick($, e.agentId)
468    if (await isTagged($, e.agentId, 'htui-dev: read-only guard failed, call allowed')) {
469      const id = e.tool_use_id
470      await store($, () => update($, readOnlyCalls, adding(id)), `htui-dev: read-only tag failed, call ${id} unrecorded`)
471    }
472    return next(e)
473  }).catch(($, e, next) => {
474    if (next.called) return next(e)
475    warn($, 'htui-dev: tool.call guard failed, call allowed')
476    return next(e)
477  })
478
479  on('agent.spawn', async ($, e, next) => {
480    // Every refusal comes before `next`: a deny after it fails the hook (AgentSpawnResult).
481    const fable = judge($, 'Fable', 'spawn', () => fableModel(e.model))
482    if (fable !== null) return { deny: fable }
483    let tag = false
484    try {
485      tag = declaresReadOnly(e)
486    } catch {
487      warn($, 'htui-dev: read-only guard failed, spawn allowed untagged')
488    }
489    if (!tag) tag = await isTagged($, e.parentAgentId, 'htui-dev: read-only guard failed, spawn allowed untagged')
490    // A main-loop spawn that is no workflow agent reads no state.
491    if (!tag && (e.workflow !== undefined || e.parentAgentId !== undefined)) {
492      tag = await isReadOnlyCall($, e.tool_use_id, 'htui-dev: read-only guard failed, spawn allowed untagged')
493    }
494    const got = await next(e)
495    if (got.deny !== undefined) return got
496    if (tag && got.agentId !== undefined) {
497      const id = got.agentId
498      await store($, () => update($, readOnly, adding(id)), `htui-dev: read-only tag failed, agent ${id} untagged`)
499    }
500    // G5's gap: a model left unset resolves from the agent definition or the parent, after the
501    // hook could refuse. Too late to deny (it fails the hook); say so instead.
502    if (typeof got.model === 'string' && isFable(got.model)) {
503      warn($, `htui-dev: subagent ${got.agentId ?? '(no id)'} resolved to Fable (${got.model}) — inherited or pinned in its agent definition`)
504    }
505    return got
506  }).catch(spawnBackstop)
507}
508
hooks/commands.ts 80 lines
1// The turnless commands (TOOL-8 M2, docs/htui-dev.md): `/gate` starts TOOL-9's gate detached,
2// `/next` and `/validate` run the workflow scripts. Each runs the `.ps1` through pwsh on Windows
3// and the `.sh` through bash elsewhere (ANA-29 §6.3), and answers the script's own text and exit
4// code. The runner and reader are injected, so the kit tests drive these without an engine.
5import { age, gateStatusText, isFreshRun, join, parseGate, type Host, type Ran, type Reader, type Runner } from './status'
6
7/** gate.sh's STEPS (and gate.ps1's ValidateSet), in lockstep. */
8export const GATE_STEPS = ['fmt', 'clippy-all', 'clippy-bare', 'test', 'abort-grep', 'migrations', 'workflow-docs', 'plugin'] as const
9
10/** What a command answers: its transcript text, and the exit code a `claude -p "/cmd"` run takes. */
11export type Answer = { text: string; exitCode: number }
12
13export type Script = 'next' | 'validate' | 'health'
14
15/** Each script's path from the repo root, without its `.sh`/`.ps1`. */
16const SCRIPTS: Record<Script, string> = {
17  next: '.claude/skills/handoff-run/scripts/next-item',
18  validate: '.claude/skills/handoff-run/scripts/validate-workflow-docs',
19  health: 'scripts/dev-health',
20}
21
22/** How a script is started on this platform: `bash x.sh`, or `pwsh -NoProfile -File x.ps1`. */
23const launch = (h: Host, path: string): string[] =>
24  h.windows ? ['pwsh', '-NoProfile', '-File', join(h.root, `${path}.ps1`)] : ['bash', join(h.root, `${path}.sh`)]
25
26/** A workflow script's argv; `next` and `validate` are told the repo root. */
27export function scriptArgv(h: Host, s: Script): string[] {
28  const root = s === 'health' ? [] : h.windows ? ['-RepoRoot', h.root] : ['--repo-root', h.root]
29  return [...launch(h, SCRIPTS[s]), ...root]
30}
31
32/** The detached gate's argv, for one step or all. */
33export function gateArgv(h: Host, step: string | null): string[] {
34  const only = step === null ? [] : h.windows ? ['-Only', step] : ['--only', step]
35  return [...launch(h, 'scripts/gate'), h.windows ? '-Detach' : '--detach', ...only]
36}
37
38/** A run as a command's answer: stdout, with stderr after it on a failure, and the exit code. */
39export function answerOf(ran: Ran, what: string): Answer {
40  if ('failed' in ran) return { text: `htui-dev: ${what} could not run: ${ran.failed}`, exitCode: 1 }
41  let text = ran.stdout.trimEnd()
42  if (ran.exitCode !== 0 && ran.stderr.trim() !== '') text = text === '' ? ran.stderr.trimEnd() : `${text}\n${ran.stderr.trimEnd()}`
43  if (text === '') text = `(${what}: no output, exit ${ran.exitCode})`
44  return { text, exitCode: exitCodeOf(ran.exitCode) }
45}
46
47/** An exit code `command.run` takes (a whole number 0-255; any other fails the hook): else 1. */
48const exitCodeOf = (c: number): number => (Number.isInteger(c) && c >= 0 && c <= 255 ? c : 1)
49
50/** gate.sh --detach returns at once; the bound only covers a launcher that hangs. */
51const GATE_LAUNCH_MS = 30_000
52/** next-item and the validator finish in about a second here. */
53const SCRIPT_MS = 60_000
54
55/**
56 * `/gate [step|status|force]`. Bare or with a step: refuses while a gate started under 4 h ago is
57 * running, else starts it detached. `force` starts the whole gate regardless. `status` prints the
58 * last result. Exit codes follow gate.sh: 1 refused or no result, 2 usage.
59 */
60export async function gateCommand(args: string, h: Host, run: Runner, read: Reader, now: number, head: string | null): Promise<Answer> {
61  const verb = args.trim().split(/\s+/)[0] ?? ''
62  const doc = parseGate(await read(join(h.root, 'target', 'gate', 'last.json')))
63  if (verb === 'status') return { text: gateStatusText(doc, head, now), exitCode: doc === null || doc === 'unreadable' ? 1 : 0 }
64  const step = verb === '' || verb === 'force' ? null : verb
65  if (step !== null && !(GATE_STEPS as readonly string[]).includes(step)) {
66    return { text: `unknown step "${step}" - /gate [step|status|force]; steps: ${GATE_STEPS.join(', ')}`, exitCode: 2 }
67  }
68  if (verb !== 'force' && isFreshRun(doc, now) && doc !== null && doc !== 'unreadable') {
69    const t = Date.parse(doc.started)
70    const ago = Number.isNaN(t) ? '' : ` (started ${age(Math.max(0, Math.floor((now - t) / 1000)))} ago)`
71    return { text: `gate already running${ago}: /gate status for progress, /gate force to start another`, exitCode: 1 }
72  }
73  return answerOf(await run(gateArgv(h, step), { cwd: h.root, timeoutMs: GATE_LAUNCH_MS }), 'gate')
74}
75
76/** `/next` and `/validate`: the script's text and exit code. */
77export async function scriptCommand(s: 'next' | 'validate', h: Host, run: Runner): Promise<Answer> {
78  return answerOf(await run(scriptArgv(h, s), { cwd: h.root, timeoutMs: SCRIPT_MS }), s === 'next' ? 'next-item' : 'validate-workflow-docs')
79}
80
hooks/status.ts 293 lines
1// The status line's facts (TOOL-8 M2, docs/htui-dev.md): which item and HANDOFF phase, the
2// branch's merge/ahead/dirty state, the repo disk and the dev Postgres, the last gate result.
3// Pure functions over text, and `gather`, which reads the facts through an injected runner and
4// reader so the kit tests drive it without an engine. Nothing here throws on bad input: a fact
5// that cannot be read is left out of the line.
6
7/** One process run: how it exited, or why it never did (not found, timed out). */
8export type Ran = { exitCode: number; stdout: string; stderr: string } | { failed: string }
9/** Runs `argv` in `cwd`; never throws. */
10export type Runner = (argv: readonly string[], init: { cwd: string; timeoutMs: number }) => Promise<Ran>
11/** A file's text, or null when it is absent or unreadable; never throws. */
12export type Reader = (path: string) => Promise<string | null>
13/** The checkout the mod serves: its root (as git prints it) and whether this is Windows. */
14export type Host = { root: string; windows: boolean }
15
16/** `root` and `parts` joined with `/`, which Windows accepts too; a trailing separator on `root` is dropped. */
17export const join = (root: string, ...parts: string[]): string => [root.replace(/[\\/]+$/, ''), ...parts].join('/')
18
19/** Whether a run exited, and with 0. */
20const succeeded = (r: Ran): r is { exitCode: number; stdout: string; stderr: string } => !('failed' in r) && r.exitCode === 0
21
22/** A JSON object: not null, not a list. */
23const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
24
25/** `42s`, `5m`, `3h`, `2d`: the largest whole unit, as the htui plugin's own lines. */
26export const age = (s: number): string =>
27  s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : s < 86400 ? `${Math.floor(s / 3600)}h` : `${Math.floor(s / 86400)}d`
28
29// ---- S1: the item ----
30
31/**
32 * The item the session works on: `HR_ITEM` when set, else the first `PREFIX-N` in the branch,
33 * upper-cased, else the branch itself (`detached` for none). `key` is the HANDOFF ID, when there is one.
34 */
35export function itemOf(hrItem: string | undefined, branch: string | null): { label: string; key: string | null } {
36  if (hrItem !== undefined && hrItem !== '') return { label: hrItem, key: hrItem }
37  const m = branch === null ? null : /([A-Za-z]+-\d+)/.exec(branch)
38  if (m !== null) {
39    const key = m[1]!.toUpperCase()
40    return { label: key, key }
41  }
42  return { label: branch ?? 'detached', key: null }
43}
44
45// ---- S2: the HANDOFF phase ----
46
47/** What `phaseOf` reads: `<X> landed` from the last phase note, `open`, or `not open`. */
48export type Phase = string
49
50const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
51
52/** A checklist line of any item, open or closed: the end of the item before it. */
53const ITEM_LINE = /^\s*[-*+]\s*\[[^\]]*\]\s*\*\*[A-Z]+-\d+/
54const HEADING = /^#{1,6}\s/
55
56/**
57 * `key`'s phase from HANDOFF.md: the last `**Phase <X> landed` in its open checklist line and the
58 * lines under it, up to the next item line or heading, as `<X> landed`; `open` with no phase note;
59 * `not open` when the item has no open line.
60 */
61export function phaseOf(handoff: string, key: string): Phase {
62  const lines = handoff.split(/\r?\n/)
63  const open = new RegExp(`^- \\[ \\] \\*\\*${escapeRegExp(key)}(?![0-9])\\s*[-–—]`)
64  const start = lines.findIndex(l => open.test(l))
65  if (start === -1) return 'not open'
66  let end = start + 1
67  while (end < lines.length && !ITEM_LINE.test(lines[end]!) && !HEADING.test(lines[end]!)) end++
68  let phase: string | null = null
69  for (const m of lines.slice(start, end).join('\n').matchAll(/\*\*Phase\s+(\S+?)\s+landed\b/g)) phase = m[1]!
70  return phase === null ? 'open' : `${phase} landed`
71}
72
73// ---- S4: the branch ----
74
75export type Porcelain = { oid: string | null; head: string | null; ahead: number | null; dirty: number }
76
77/**
78 * `git status --porcelain=v2 --branch`: the commit, the branch (null when detached), the commits
79 * ahead of the upstream (null with no upstream), and the entries that are changed, renamed,
80 * unmerged or untracked.
81 */
82export function parsePorcelainV2(stdout: string): Porcelain {
83  const p: Porcelain = { oid: null, head: null, ahead: null, dirty: 0 }
84  for (const line of stdout.split(/\r?\n/)) {
85    let m: RegExpExecArray | null
86    if ((m = /^# branch\.oid (\S+)/.exec(line)) !== null) p.oid = m[1] === '(initial)' ? null : m[1]!
87    else if ((m = /^# branch\.head (.+)$/.exec(line)) !== null) p.head = m[1] === '(detached)' ? null : m[1]!
88    else if ((m = /^# branch\.ab \+(\d+) -\d+/.exec(line)) !== null) p.ahead = Number(m[1])
89    else if (/^[12u?] /.test(line)) p.dirty++
90  }
91  return p
92}
93
94// ---- S5/S6: dev-health ----
95
96export type PgState = 'up' | 'recovering' | 'down' | 'unknown'
97export type Health = { usedPct: number | null; pg: PgState | null }
98
99const PG_STATES: readonly string[] = ['up', 'recovering', 'down', 'unknown']
100
101/** scripts/dev-health's JSON line; anything it cannot read is null. */
102export function parseHealth(stdout: string): Health {
103  let doc: unknown
104  try {
105    doc = JSON.parse(stdout)
106  } catch {
107    return { usedPct: null, pg: null }
108  }
109  if (!isObject(doc)) return { usedPct: null, pg: null }
110  const pct = isObject(doc.disk) ? doc.disk.used_pct : undefined
111  const state = isObject(doc.postgres) ? doc.postgres.state : undefined
112  return {
113    usedPct: typeof pct === 'number' && Number.isFinite(pct) ? pct : null,
114    pg: typeof state === 'string' && PG_STATES.includes(state) ? (state as PgState) : null,
115  }
116}
117
118// ---- S7: the gate ----
119
120export type GateStep = { name: string; status: string; seconds: number }
121export type GateDoc = {
122  head: string
123  started: string
124  finished: string | null
125  state: 'running' | 'passed' | 'failed'
126  steps: GateStep[]
127}
128/** A parsed `last.json`: the document, null when there is none, or `unreadable`. */
129export type GateRead = GateDoc | null | 'unreadable'
130
131/** `target/gate/last.json` as gate.sh/.ps1 writes it; null text is no file. */
132export function parseGate(text: string | null): GateRead {
133  if (text === null) return null
134  let doc: unknown
135  try {
136    doc = JSON.parse(text)
137  } catch {
138    return 'unreadable'
139  }
140  if (!isObject(doc)) return 'unreadable'
141  const { head, started, finished, state, steps } = doc
142  if (state !== 'running' && state !== 'passed' && state !== 'failed') return 'unreadable'
143  if (!Array.isArray(steps)) return 'unreadable'
144  return {
145    head: typeof head === 'string' ? head : '',
146    started: typeof started === 'string' ? started : '',
147    finished: typeof finished === 'string' ? finished : null,
148    state,
149    steps: steps.filter(isObject).map(s => ({
150      name: String(s.name ?? ''),
151      status: String(s.status ?? ''),
152      seconds: Math.max(0, Math.floor(Number(s.seconds) || 0)),
153    })),
154  }
155}
156
157/** ` 4m` for the seconds from `isoTime` to `now`, or nothing when the time does not parse. */
158const since = (isoTime: string | null, now: number): string => {
159  const t = isoTime === null ? NaN : Date.parse(isoTime)
160  return Number.isNaN(t) ? '' : ` ${age(Math.max(0, Math.floor((now - t) / 1000)))}`
161}
162
163const failedSteps = (doc: GateDoc): string[] => doc.steps.filter(s => s.status === 'failed').map(s => s.name)
164
165/**
166 * The outcome alone: `gate passed`, `gate passed (2 skipped)`, `gate failed: test, abort-grep`, or
167 * `gate running`. A finished pass of one step (`--only`) names it: `gate plugin passed` (or
168 * `skipped`) is not the gate passing. Skips are named as gate.sh names them, so a green cannot
169 * hide a missing check.
170 */
171const outcome = (doc: GateDoc): string => {
172  if (doc.state === 'passed' && doc.steps.length === 1) return `gate ${doc.steps[0]!.name} ${doc.steps[0]!.status}`
173  if (doc.state === 'passed') {
174    const skipped = doc.steps.filter(s => s.status === 'skipped').length
175    return skipped === 0 ? 'gate passed' : `gate passed (${skipped} skipped)`
176  }
177  if (doc.state !== 'failed') return `gate ${doc.state}`
178  const failed = failedSteps(doc)
179  return failed.length === 0 ? 'gate failed' : `gate failed: ${failed.join(', ')}`
180}
181
182/**
183 * The gate's part of the line: `gate running 4m` (from its start), `gate passed 2h` or `gate
184 * failed: test 40m` (from its end), `(older HEAD)` when it ran on another commit; null with no file.
185 */
186export function gateSummary(doc: GateRead, head: string | null, now: number): string | null {
187  if (doc === null) return null
188  if (doc === 'unreadable') return 'gate: last.json unreadable'
189  const when = since(doc.state === 'running' ? doc.started : doc.finished, now)
190  const older = head !== null && doc.head !== '' && doc.head !== head ? ' (older HEAD)' : ''
191  return `${outcome(doc)}${when}${older}`
192}
193
194/** The toast for a finished gate; null while it runs. */
195export const gateToast = (doc: GateDoc): string | null => (doc.state === 'running' ? null : outcome(doc))
196
197/** How long a `running` result blocks `/gate`: a gate killed mid-run leaves one behind. */
198export const FRESH_MS = 4 * 3600_000
199
200/** Whether a gate is running and started under FRESH_MS ago. */
201export function isFreshRun(doc: GateRead, now: number): boolean {
202  if (doc === null || doc === 'unreadable' || doc.state !== 'running') return false
203  // A start that does not parse cannot be aged: a damaged file never blocks /gate.
204  const t = Date.parse(doc.started)
205  return !Number.isNaN(t) && now - t < FRESH_MS
206}
207
208/** `/gate status`: the summary, then one line per step. */
209export function gateStatusText(doc: GateRead, head: string | null, now: number): string {
210  const summary = gateSummary(doc, head, now)
211  if (summary === null) return 'no gate result yet'
212  if (doc === null || doc === 'unreadable') return summary
213  return [summary, ...doc.steps.map(s => `${s.name} ${s.status} ${age(s.seconds)}`)].join('\n')
214}
215
216// ---- the line ----
217
218/** Every fact the line shows; null where it could not be read. */
219export type Snapshot = {
220  item: { label: string; key: string | null }
221  phase: Phase | null
222  merging: boolean
223  ahead: number | null
224  dirty: number | null
225  health: Health
226  gate: GateRead
227  head: string | null
228}
229
230/** The disk use above which the line shows it (ANA-29 D6). */
231const DISK_WARN_PCT = 90
232
233/**
234 * The ambient line, quiet parts dropped: `TOOL-8 M1 landed · merging · 2 ahead · 1 dirty ·
235 * disk 93% · pg down · gate passed 2h`.
236 */
237export function composeAmbient(s: Snapshot, now: number): string {
238  const parts = [s.phase === null ? s.item.label : `${s.item.label} ${s.phase}`]
239  if (s.merging) parts.push('merging')
240  if (s.ahead !== null && s.ahead > 0) parts.push(`${s.ahead} ahead`)
241  if (s.dirty !== null && s.dirty > 0) parts.push(`${s.dirty} dirty`)
242  if (s.health.usedPct !== null && s.health.usedPct > DISK_WARN_PCT) parts.push(`disk ${s.health.usedPct}%`)
243  if (s.health.pg === 'recovering' || s.health.pg === 'down') parts.push(`pg ${s.health.pg}`)
244  const gate = gateSummary(s.gate, s.head, now)
245  if (gate !== null) parts.push(gate)
246  return parts.join(' · ')
247}
248
249/** The plugin's one status line: a guard warning leads the ambient line; neither clears it. */
250export function composeLine(warning: string | null, ambient: string | null): string | undefined {
251  if (warning !== null && ambient !== null) return `⚠ ${warning} · ${ambient}`
252  return warning ?? ambient ?? undefined
253}
254
255// ---- gathering ----
256
257const GIT_MS = 10_000
258const HEALTH_MS = 20_000
259
260/**
261 * Reads every fact at once. `healthArgv` runs scripts/dev-health for the platform. A piece that
262 * fails is null; the item falls back to `?` when git could not say which branch this is.
263 */
264export async function gather(host: Host, hrItem: string | undefined, run: Runner, read: Reader, healthArgv: readonly string[]): Promise<Snapshot> {
265  const init = { cwd: host.root, timeoutMs: GIT_MS }
266  const [status, merge, health, handoff, gateText] = await Promise.all([
267    run(['git', '--no-optional-locks', 'status', '--porcelain=v2', '--branch'], init),
268    run(['git', 'rev-parse', '-q', '--verify', 'MERGE_HEAD'], init),
269    run(healthArgv, { cwd: host.root, timeoutMs: HEALTH_MS }),
270    read(join(host.root, 'HANDOFF.md')),
271    read(join(host.root, 'target', 'gate', 'last.json')),
272  ])
273  const porcelain = succeeded(status) ? parsePorcelainV2(status.stdout) : null
274  let ahead = porcelain?.ahead ?? null
275  if (porcelain !== null && ahead === null && porcelain.head !== null && porcelain.head !== 'main') {
276    const count = await run(['git', 'rev-list', '--count', 'main..HEAD'], init)
277    const n = succeeded(count) ? Number(count.stdout.trim()) : NaN
278    ahead = Number.isSafeInteger(n) ? n : null
279  }
280  const item =
281    porcelain === null && (hrItem === undefined || hrItem === '') ? { label: '?', key: null } : itemOf(hrItem, porcelain?.head ?? null)
282  return {
283    item,
284    phase: item.key !== null && handoff !== null ? phaseOf(handoff, item.key) : null,
285    merging: succeeded(merge),
286    ahead,
287    dirty: porcelain?.dirty ?? null,
288    health: succeeded(health) ? parseHealth(health.stdout) : { usedPct: null, pg: null },
289    gate: parseGate(gateText),
290    head: porcelain?.oid ?? null,
291  }
292}
293
hooks/guards.ts 1017 lines
1// The guards' decisions, pure: each reads plain words or a plain record and answers the deny
2// reason (`htui-dev: <what> — <the fix>`), or null to let the call through. Nothing here calls
3// `$`; register.ts wires them onto the events and catches what they throw.
4import { quoteWords, type Words } from './shell'
5
6// --- G1 and G2: cargo test -------------------------------------------------------------------
7
8// The three tables below are copied from the manifests; a drift is TOOL-9's gate to catch
9// (`cargo tree -e features` shows what cargo really turns on). Every feature is `<crate>/<name>`.
10
11/**
12 * The features each member declares, and what each forwards to: the `[features]` tables of
13 * `crates/*\/Cargo.toml`. Only the forwards that reach a test feature are listed (`dep:` ones are not).
14 */
15const FEATURES: Readonly<Record<string, Readonly<Record<string, readonly string[]>>>> = {
16  htui: { testkit: ['htui-store/test-support'] },
17  'htui-agent': { 'test-support': ['htui-core/test-support'] },
18  'htui-core': { demo: [], 'test-support': ['htui-core/demo'], sqlx: [] },
19  'htui-mcp': {},
20  'htui-orch': { 'test-support': ['htui-core/test-support', 'htui-agent/test-support'] },
21  'htui-secrets': {},
22  'htui-store': { demo: ['htui-core/demo'], 'test-support': ['htui-store/demo'], 'local-embed': [] },
23  'htui-worker': { 'test-support': [] },
24}
25
26/**
27 * What selecting a member for `cargo test` turns on by itself: the `features = [...]` of its
28 * `[dependencies]` and, since a test build uses them, its `[dev-dependencies]` (only `demo` and
29 * `test-support` listed). Cargo unifies them over every selected crate, so `-p htui-agent
30 * -p htui-orch` builds the agent's own tests with `test-support`, and `-p htui-store` its own.
31 */
32const SELECTING: Readonly<Record<string, readonly string[]>> = {
33  htui: [
34    'htui-core/demo',
35    'htui-store/demo',
36    'htui-agent/test-support',
37    'htui-orch/test-support',
38    'htui-worker/test-support',
39    'htui-store/test-support',
40  ],
41  'htui-orch': ['htui-core/test-support', 'htui-agent/test-support'],
42  'htui-worker': [
43    'htui-core/test-support',
44    'htui-orch/test-support',
45    'htui-agent/test-support',
46    'htui-store/demo',
47    'htui-store/test-support',
48  ],
49  'htui-mcp': [
50    'htui-core/test-support',
51    'htui-agent/test-support',
52    'htui-orch/test-support',
53    'htui-store/demo',
54    'htui-store/test-support',
55  ],
56  'htui-store': ['htui-core/test-support', 'htui-store/test-support'],
57}
58
59/**
60 * The features a member's integration tests are gated on (`#![cfg(feature = …)]` over
61 * `crates/*\/tests`: htui 43 files on `testkit`; agent 4 and core 8 on `test-support`; store 3 on
62 * `test-support` and 5 on `demo`). The first is the one a refusal suggests.
63 */
64const GATES: Readonly<Record<string, readonly string[]>> = {
65  htui: ['testkit'],
66  'htui-agent': ['test-support'],
67  'htui-core': ['test-support'],
68  'htui-store': ['test-support', 'demo'],
69}
70
71/** Every member's test feature, `testkit` or `test-support`, gated tests or not: G2 checks the spelling. */
72const TEST_FEATURE: Readonly<Record<string, string>> = Object.fromEntries(
73  Object.entries(FEATURES).flatMap(([crate, own]) =>
74    ['testkit', 'test-support'].filter(f => own[f] !== undefined).map(f => [crate, f]),
75  ),
76)
77
78/** The virtual root's members (`Cargo.toml` `[workspace]`): a run with no package selection tests them all. */
79const MEMBERS = [
80  'htui',
81  'htui-agent',
82  'htui-core',
83  'htui-mcp',
84  'htui-orch',
85  'htui-secrets',
86  'htui-store',
87  'htui-worker',
88] as const
89
90/** The two test feature names; a third spelling is no business of G2's. */
91const FEATURE_NAMES = new Set(['testkit', 'test-support'])
92
93/** cargo's own options before the subcommand that take a value as the next word. */
94const CARGO_GLOBAL_VALUE = new Set(['--color', '--config', '-Z', '-C', '--explain'])
95
96/** `cargo test` options that take a value as the next word (beside the ones read below). */
97const TEST_VALUE = new Set([
98  '--target',
99  '--target-dir',
100  '-j',
101  '--jobs',
102  '--profile',
103  '--color',
104  '--config',
105  '-Z',
106  '--message-format',
107  '--lockfile-path',
108])
109
110/** Target selectors that leave the integration tests out of the run. */
111const OUT_OF_SCOPE = new Set(['--lib', '--bin', '--bins', '--doc', '--example', '--examples', '--bench', '--benches'])
112
113/** Target selectors that bring them in. */
114const IN_SCOPE = new Set(['--test', '--tests', '--all-targets'])
115
116/** One `--features` value as written: the word it sits in and the text before it there (`--features=`, `-F`, or none). */
117type FeatureWord = { index: number; prefix: string }
118
119/** What a `cargo test` command asks for, read from its words. */
120type CargoTest = {
121  words: Words
122  /** Selected packages (`-p`, `--manifest-path`), or null for the whole workspace. */
123  packages: string[] | null
124  excluded: string[]
125  features: string[]
126  featureWords: FeatureWord[]
127  allFeatures: boolean
128  /** Whether the run builds the integration tests (`--test`, `--tests`, `--all-targets`, or no selector). */
129  inScope: boolean
130  /** Where the test binary's own arguments start (`--`), or the words' length. */
131  dashDash: number
132  /** Whether `--workspace`/`--all` or `--manifest-path` says where to test, whatever the working directory. */
133  located: boolean
134}
135
136/**
137 * The package a `--manifest-path` names: its folder's name when that folder sits in `crates/` (or
138 * `../`), or is a bare `<member>/Cargo.toml`; otherwise null, the workspace root, whatever its
139 * folder is called (`/r/.claude/worktrees/tool-8/Cargo.toml`, `/home/me/htui/Cargo.toml`).
140 */
141function manifestPackage(path: string): string | null {
142  const parts = path.replace(/\\/g, '/').split('/').filter(p => p !== '' && p !== '.')
143  const name = parts[parts.length - 2]
144  if (name === undefined) return null
145  const above = parts[parts.length - 3]
146  if (above === 'crates' || above === '..') return name
147  const bare = above === undefined && !path.startsWith('/') && (MEMBERS as readonly string[]).includes(name)
148  return bare ? name : null
149}
150
151/** `cargo test`'s short options that take a value, attached (`-phtui`) or as the next word. */
152const SHORT_VALUE = new Set(['p', 'F', 'j', 'Z'])
153
154/** The members a package spec names: `@version` dropped, a glob (`htui-*`) matched against the members, anything else as written. */
155function packagesOf(spec: string): string[] {
156  const name = spec.replace(/@.*$/, '')
157  if (!/[*?[]/.test(name)) return [name]
158  const pattern = name.replace(/[.+^${}()|\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')
159  return MEMBERS.filter(m => new RegExp(`^${pattern}$`).test(m))
160}
161
162/** Reads `words` as a `cargo test` (or `cargo t`) command, or null when it is none. */
163function parseCargoTest(words: Words): CargoTest | null {
164  if ((words[0] ?? '').split('/').pop() !== 'cargo') return null
165  let at = 1
166  while (at < words.length) {
167    const w = words[at]!
168    if (w.startsWith('+')) at++
169    else if (CARGO_GLOBAL_VALUE.has(w)) at += 2
170    else if (w.startsWith('-')) at++
171    else break
172  }
173  if (words[at] !== 'test' && words[at] !== 't') return null
174
175  const got: CargoTest = {
176    words,
177    packages: null,
178    excluded: [],
179    features: [],
180    featureWords: [],
181    allFeatures: false,
182    inScope: true,
183    dashDash: words.length,
184    located: false,
185  }
186  let workspace = false
187  let selectors = 0
188  let inScopeSelectors = 0
189  const packages: string[] = []
190  const addFeatures = (index: number, prefix: string) => {
191    got.featureWords.push({ index, prefix })
192    got.features.push(...words[index]!.slice(prefix.length).split(/[\s,]+/).filter(f => f !== ''))
193  }
194  /** The value of option `name` at `i`: `name=v`, `-xv` (short), or the next word; `[value, words used]`. */
195  const valueOf = (i: number, name: string): [string | undefined, number] => {
196    const w = words[i]!
197    if (w === name) return [words[i + 1], 2]
198    return [w.startsWith('--') ? w.slice(name.length + 1) : w.slice(name.length), 1]
199  }
200  /** Whether `w` is option `name`, alone or with its value attached. */
201  const isOpt = (w: string, name: string) =>
202    w === name || (name.startsWith('--') ? w.startsWith(name + '=') : w.startsWith(name))
203
204  for (let i = at + 1; i < words.length; ) {
205    const w = words[i]!
206    if (w === '--') {
207      got.dashDash = i
208      break
209    }
210    if (/^-[A-Za-z]/.test(w)) {
211      // A short cluster (`-qp htui`, `-vvF testkit`): flags until one takes a value, which is the
212      // rest of the cluster or the next word.
213      let used = 1
214      for (let j = 1; j < w.length; j++) {
215        const letter = w[j]!
216        if (!SHORT_VALUE.has(letter)) continue
217        const attached = w.slice(j + 1)
218        const value = attached !== '' ? attached : words[i + 1]
219        if (attached === '') used = 2
220        if (letter === 'p' && value !== undefined) packages.push(...packagesOf(value))
221        if (letter === 'F' && value !== undefined) {
222          if (attached !== '') addFeatures(i, w.slice(0, j + 1))
223          else addFeatures(i + 1, '')
224        }
225        break
226      }
227      i += used
228    } else if (isOpt(w, '--package')) {
229      const [v, n] = valueOf(i, '--package')
230      if (v !== undefined) packages.push(...packagesOf(v))
231      i += n
232    } else if (isOpt(w, '--manifest-path')) {
233      const [v, n] = valueOf(i, '--manifest-path')
234      const pkg = v === undefined ? null : manifestPackage(v)
235      if (pkg !== null) packages.push(pkg)
236      got.located = true
237      i += n
238    } else if (w === '--workspace' || w === '--all') {
239      workspace = true
240      got.located = true
241      i++
242    } else if (isOpt(w, '--exclude')) {
243      const [v, n] = valueOf(i, '--exclude')
244      if (v !== undefined) got.excluded.push(...packagesOf(v))
245      i += n
246    } else if (w === '--all-features') {
247      got.allFeatures = true
248      i++
249    } else if (isOpt(w, '--features')) {
250      if (w === '--features') {
251        if (i + 1 < words.length) addFeatures(i + 1, '')
252        i += 2
253      } else {
254        addFeatures(i, '--features=')
255        i++
256      }
257    } else if (OUT_OF_SCOPE.has(w.split('=')[0]!) || IN_SCOPE.has(w.split('=')[0]!)) {
258      const name = w.split('=')[0]!
259      selectors++
260      if (IN_SCOPE.has(name)) inScopeSelectors++
261      // `--test <name>`, `--bin <name>`, ...: the name is the next word unless attached with `=`.
262      const takesName = !w.includes('=') && ['--test', '--bin', '--example', '--bench'].includes(name)
263      i += takesName && i + 1 < words.length && !words[i + 1]!.startsWith('-') ? 2 : 1
264    } else if (TEST_VALUE.has(w)) {
265      i += 2
266    } else {
267      i++
268    }
269  }
270  got.packages = workspace || packages.length === 0 ? null : packages
271  got.inScope = selectors === 0 || inScopeSelectors > 0
272  return got
273}
274
275/**
276 * The features (`<crate>/<name>`) a run of `t` over the crates `selected` turns on: the command
277 * line's (`--all-features`; a plain name on each selected crate that has it; `<crate>/<name>`), what
278 * selecting each crate turns on, and everything those forward to.
279 */
280function enabledFeatures(t: CargoTest, selected: readonly string[]): Set<string> {
281  const queue: string[] = []
282  for (const crate of selected) {
283    const own = FEATURES[crate] ?? {}
284    if (t.allFeatures) queue.push(...Object.keys(own).map(f => `${crate}/${f}`))
285    for (const term of t.features) if (!term.includes('/') && own[term] !== undefined) queue.push(`${crate}/${term}`)
286    queue.push(...(SELECTING[crate] ?? []))
287  }
288  queue.push(...t.features.filter(term => term.includes('/')))
289  const on = new Set<string>()
290  while (queue.length > 0) {
291    const feature = queue.pop()!
292    if (on.has(feature)) continue
293    on.add(feature)
294    const [crate, name] = feature.split('/') as [string, string]
295    queue.push(...(FEATURES[crate]?.[name] ?? []))
296  }
297  return on
298}
299
300/** `a`, `a and b`, `a, b and c`. */
301const listed = (items: readonly string[]): string =>
302  items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`
303
304/** `t`'s words with `add` put before `--`, or at the end. */
305const withWords = (t: CargoTest, add: readonly string[]): Words => [
306  ...t.words.slice(0, t.dashDash),
307  ...add,
308  ...t.words.slice(t.dashDash),
309]
310
311/** G2: a test feature named for a crate that spells it the other way, with the command corrected. */
312function wrongFeature(t: CargoTest): string | null {
313  const selected = t.packages ?? [...MEMBERS]
314  for (const term of t.features) {
315    const slash = term.indexOf('/')
316    const crate = slash === -1 ? null : term.slice(0, slash)
317    const name = slash === -1 ? term : term.slice(slash + 1)
318    if (!FEATURE_NAMES.has(name)) continue
319    let owner: string | undefined
320    if (crate !== null) {
321      if (TEST_FEATURE[crate] !== undefined && TEST_FEATURE[crate] !== name) owner = crate
322    } else if (!selected.some(c => TEST_FEATURE[c] === name)) {
323      owner = selected.find(c => TEST_FEATURE[c] !== undefined)
324    }
325    if (owner === undefined) continue
326    const right = TEST_FEATURE[owner]!
327    const fixed = t.words.map((w, i) => {
328      const fw = t.featureWords.find(f => f.index === i)
329      if (fw === undefined) return w
330      const swapped = w
331        .slice(fw.prefix.length)
332        .split(/([\s,]+)/)
333        .map(part => (part === term ? (crate === null ? right : `${crate}/${right}`) : part))
334        .join('')
335      return fw.prefix + swapped
336    })
337    return `htui-dev: ${owner} has no \`${name}\` feature (cargo will refuse it) — it is \`${right}\`: ${quoteWords(fixed)}`
338  }
339  return null
340}
341
342/** G1: integration tests built without the feature they are gated on, so they run 0 tests and pass. */
343function vacuousRun(t: CargoTest): string | null {
344  if (!t.inScope) return null
345  const selected = [...new Set(t.packages ?? MEMBERS.filter(c => !t.excluded.includes(c)))]
346  const on = enabledFeatures(t, selected)
347  const missing = selected.filter(c => (GATES[c] ?? []).some(f => !on.has(`${c}/${f}`)))
348  if (missing.length === 0) return null
349  // A plain name turns the feature on in every selected crate that has it, so one word fixes all.
350  const features = [...new Set(missing.map(c => GATES[c]![0]!))]
351  const tests = missing.length === 1 ? `${missing[0]}'s integration tests` : `the integration tests of ${listed(missing)}`
352  const fix = quoteWords(withWords(t, ['--features', features.join(',')]))
353  const single = t.packages !== null && selected.length === 1
354  return `htui-dev: \`${quoteWords(t.words)}\` builds ${tests} without ${listed(features.map(f => `\`${f}\``))}, so they run 0 tests and pass — run: ${fix}${single ? '' : ' (or --all-features)'}`
355}
356
357/**
358 * G1 and G2 over one simple command run in `cwd` (see `movedTo`): G2 first (its text is the more
359 * precise), then G1. In a member's folder a `cargo test` that names no package, workspace or
360 * manifest tests that member, as cargo finds its manifest first.
361 */
362export function cargoTest(words: Words, cwd: Cwd = []): string | null {
363  const t = parseCargoTest(words)
364  if (t === null) return null
365  const member = memberAt(cwd)
366  if (member !== null && t.packages === null && !t.located) t.packages = [member]
367  return wrongFeature(t) ?? vacuousRun(t)
368}
369
370// --- The working directory a line moves to (G1) ------------------------------------------------
371
372/**
373 * The working directory, as far as the Bash line has told: its folders, relative to where the line
374 * started (or from `/` after an absolute `cd`); [] for where it started, and for a place the line does
375 * not tell. The session's own directory is not known, and read as no member's.
376 */
377export type Cwd = readonly string[]
378
379/** The member whose folder `cwd` is in: the folder after a `crates` folder, when it names a member (cargo looks for the manifest from any folder below it). */
380function memberAt(cwd: Cwd): string | null {
381  for (let i = cwd.length - 2; i >= 0; i--) {
382    if (cwd[i] === 'crates' && (MEMBERS as readonly string[]).includes(cwd[i + 1]!)) return cwd[i + 1]!
383  }
384  return null
385}
386
387/**
388 * The working directory after the simple command `words`, run in `cwd`: a `cd` or `pushd` to a path
389 * joins it onto `cwd` (an absolute one replaces it), `.` and `..` resolved. `cd` alone, `cd -`, `~`,
390 * a `$VAR`, `pushd` with no folder or with `+N`/`-N`, and `popd` go where the line does not tell: [].
391 * `pushd -n` and any other command leave `cwd` as it is.
392 */
393export function movedTo(words: Words, cwd: Cwd): Cwd {
394  const program = words[0]
395  if (program === 'popd') return []
396  if (program !== 'cd' && program !== 'pushd') return cwd
397  let at = 1
398  for (; at < words.length && /^-[LPe@n]+$/.test(words[at]!); at++) {
399    if (program === 'pushd' && words[at]!.includes('n')) return cwd
400  }
401  if (words[at] === '--') at++
402  const dir = words[at]
403  if (dir === undefined || /^[-+~]/.test(dir) || /[$`]/.test(dir)) return []
404  return segmentsOf(dir.startsWith('/') ? dir : [...cwd, dir].join('/'))
405}
406
407// --- G6 and G4's Bash half: git ---------------------------------------------------------------
408
409/** git's own options before the subcommand that take a value as the next word (`-C dir`, `--git-dir x`). */
410const GIT_GLOBAL_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--config-env'])
411
412/** `git commit`'s short options whose value is the rest of the cluster or the next word. */
413const COMMIT_SHORT_VALUE = new Set(['m', 'F', 'C', 'c', 't'])
414
415/** `git commit`'s short options whose value, if any, is only the rest of the cluster (`-S<keyid>`, `-u<mode>`). */
416const COMMIT_SHORT_OPTIONAL = new Set(['S', 'u'])
417
418/** `git commit`'s long options that take the next word as their value when not given with `=`. */
419const COMMIT_LONG_VALUE = new Set([
420  '--message',
421  '--file',
422  '--reuse-message',
423  '--reedit-message',
424  '--template',
425  '--author',
426  '--date',
427  '--fixup',
428  '--squash',
429  '--cleanup',
430  '--trailer',
431  '--pathspec-from-file',
432])
433
434/** `git commit`'s long options that take no value (2.43): an abbreviation of one is no value-taking option. */
435const COMMIT_LONG_FLAG = [
436  '--all',
437  '--patch',
438  '--interactive',
439  '--amend',
440  '--allow-empty',
441  '--allow-empty-message',
442  '--branch',
443  '--dry-run',
444  '--edit',
445  '--include',
446  '--long',
447  '--no-edit',
448  '--no-post-rewrite',
449  '--no-verify',
450  '--null',
451  '--only',
452  '--porcelain',
453  '--quiet',
454  '--reset-author',
455  '--short',
456  '--signoff',
457  '--no-signoff',
458  '--status',
459  '--no-status',
460  '--verbose',
461  '--verify',
462  '--gpg-sign',
463  '--no-gpg-sign',
464  '--untracked-files',
465  '--pathspec-file-nul',
466  '--ahead-behind',
467  '--no-ahead-behind',
468]
469
470/** Whether long option `w` (no `=`) takes the next word: a value-taking option, or an abbreviation git reads as one alone. */
471const takesValue = (w: string): boolean =>
472  COMMIT_LONG_VALUE.has(w) ||
473  (w.length > 3 && [...COMMIT_LONG_VALUE].some(o => o.startsWith(w)) && !COMMIT_LONG_FLAG.some(o => o.startsWith(w)))
474
475/** The subcommand of a `git` command and where its arguments start, or null when `words` run no git. */
476function gitSubcommand(words: Words): { sub: string; at: number } | null {
477  if ((words[0] ?? '').split('/').pop() !== 'git') return null
478  let at = 1
479  while (at < words.length) {
480    const w = words[at]!
481    if (GIT_GLOBAL_VALUE.has(w)) at += 2
482    else if (w.startsWith('-')) at++
483    else return { sub: w, at: at + 1 }
484  }
485  return null
486}
487
488/** Whether a long option `w` is `full` or a prefix of it git would take (at least `min` characters). */
489const abbreviates = (w: string, full: string, min: number): boolean => w.length >= min && full.startsWith(w)
490
491/**
492 * G6: `git commit` with `--no-verify` (or a prefix git takes) or `-n`, also inside a short-flag
493 * cluster, unless a later `--verify` cancels it. The deny shows the commit without the flag.
494 */
495export function noVerify(words: Words): string | null {
496  const git = gitSubcommand(words)
497  if (git === null || git.sub !== 'commit') return null
498  let skips = false
499  const fixed: string[] = [...words.slice(0, git.at)]
500  for (let i = git.at; i < words.length; i++) {
501    const w = words[i]!
502    if (w === '--') {
503      fixed.push(...words.slice(i))
504      break
505    }
506    if (w.startsWith('--')) {
507      const name = w.split('=')[0]!
508      if (abbreviates(name, '--no-verify', '--no-ve'.length) && !w.includes('=')) {
509        skips = true
510        continue
511      }
512      if (abbreviates(name, '--verify', '--veri'.length) && !w.includes('=')) skips = false
513      fixed.push(w)
514      if (!w.includes('=') && takesValue(w) && i + 1 < words.length) fixed.push(words[++i]!)
515      continue
516    }
517    if (!w.startsWith('-') || w === '-') {
518      fixed.push(w)
519      continue
520    }
521    // A short cluster: letters until one takes a value, which is the rest or the next word.
522    let kept = '-'
523    let takesNext = false
524    for (let j = 1; j < w.length; j++) {
525      const letter = w[j]!
526      if (COMMIT_SHORT_VALUE.has(letter)) {
527        kept += w.slice(j)
528        takesNext = j === w.length - 1
529        break
530      }
531      if (COMMIT_SHORT_OPTIONAL.has(letter)) {
532        kept += w.slice(j)
533        break
534      }
535      if (letter === 'n') skips = true
536      else kept += letter
537    }
538    if (kept !== '-') fixed.push(kept)
539    if (takesNext && i + 1 < words.length) fixed.push(words[++i]!)
540  }
541  if (!skips) return null
542  return `htui-dev: git commit --no-verify skips the repo's commit hooks — commit without it: ${quoteWords(fixed)}`
543}
544
545/** git subcommands that change the working tree, the index, refs, history or the remote in every form (G4). */
546const GIT_WRITES = new Set([
547  'commit',
548  'reset',
549  'restore',
550  'merge',
551  'rebase',
552  'cherry-pick',
553  'revert',
554  'am',
555  'pull',
556  'push',
557  'checkout-index',
558  'read-tree',
559  'update-ref',
560  'update-index',
561  'gc',
562  'filter-branch',
563])
564
565/**
566 * git subcommands that write only through some of their own subcommands: the action is the first
567 * word that is no option (past the value of an option `skip` names), and `writes` says whether
568 * that action (or none, `undefined`) writes. The label names the action when there is one to name.
569 */
570const GIT_ACTIONS: Readonly<Record<string, { skip: readonly string[]; writes: (action: string | undefined) => boolean; named: boolean }>> = {
571  bisect: { skip: [], writes: a => !['log', 'view', 'visualize'].includes(a ?? ''), named: false },
572  worktree: { skip: [], writes: a => ['add', 'remove', 'move', 'prune'].includes(a ?? ''), named: true },
573  submodule: { skip: [], writes: a => ['update', 'deinit', 'add', 'sync'].includes(a ?? ''), named: true },
574  'sparse-checkout': { skip: [], writes: a => a !== 'list', named: false },
575  reflog: { skip: [], writes: a => a === 'expire' || a === 'delete', named: true },
576  notes: { skip: ['--ref'], writes: a => ['add', 'remove', 'edit', 'append', 'copy', 'prune'].includes(a ?? ''), named: true },
577}
578
579/** The first word of `args` that is no option, past the value of an option in `skip`. */
580function actionOf(args: Words, skip: readonly string[]): string | undefined {
581  for (let i = 0; i < args.length; i++) {
582    const w = args[i]!
583    if (skip.includes(w)) i++
584    else if (!w.startsWith('-')) return w
585  }
586  return undefined
587}
588
589/**
590 * The long option `w` (its name before `=`) stands for among `options`: itself, or the one option
591 * it is a prefix of, as git reads an abbreviation. Null for a `--no-` form, an unknown option, or an
592 * ambiguous prefix (git refuses that one itself).
593 */
594function longOption(w: string, options: readonly string[]): string | null {
595  const name = w.split('=')[0]!
596  if (name.startsWith('--no-')) return null
597  if (options.includes(name)) return name
598  const hits = options.filter(o => o.startsWith(name))
599  return hits.length === 1 ? hits[0]! : null
600}
601
602/** `git checkout`'s long options (2.43), for reading an abbreviation. */
603const CHECKOUT_LONG = [
604  '--guess',
605  '--overlay',
606  '--quiet',
607  '--recurse-submodules',
608  '--progress',
609  '--merge',
610  '--conflict',
611  '--detach',
612  '--track',
613  '--force',
614  '--orphan',
615  '--overwrite-ignore',
616  '--ignore-other-worktrees',
617  '--ours',
618  '--theirs',
619  '--patch',
620  '--ignore-skip-worktree-bits',
621  '--pathspec-from-file',
622  '--pathspec-file-nul',
623]
624
625/** `git switch`'s long options (2.43), for reading an abbreviation. */
626const SWITCH_LONG = [
627  '--create',
628  '--force-create',
629  '--guess',
630  '--discard-changes',
631  '--quiet',
632  '--recurse-submodules',
633  '--progress',
634  '--merge',
635  '--conflict',
636  '--detach',
637  '--track',
638  '--force',
639  '--orphan',
640  '--overwrite-ignore',
641  '--ignore-other-worktrees',
642]
643
644/** Long options of `checkout` and `switch` whose value is the next word when not given with `=`. */
645const BRANCH_LONG_VALUE = ['--create', '--force-create', '--orphan', '--conflict', '--pathspec-from-file']
646
647/**
648 * What a `git checkout` or `git switch` writes, or null when it only switches (or creates) a branch.
649 * `long` maps the long options that write to their label, `short` the short letters that do;
650 * `shortValue` are the letters whose value is the rest of the cluster or the next word. For
651 * checkout, a second positional or `.` is a pathspec.
652 */
653function branchWrite(
654  sub: 'checkout' | 'switch',
655  args: Words,
656  options: readonly string[],
657  long: Readonly<Record<string, string>>,
658  short: Readonly<Record<string, string>>,
659  shortValue: string,
660): string | null {
661  let positionals = 0
662  for (let i = 0; i < args.length; i++) {
663    const w = args[i]!
664    if (w === '--') return sub === 'checkout' ? 'git checkout <paths>' : null
665    if (w.startsWith('--')) {
666      const o = longOption(w, options)
667      if (o === null) continue
668      if (long[o] !== undefined) return `git ${sub} ${long[o]}`
669      if (BRANCH_LONG_VALUE.includes(o) && !w.includes('=')) i++
670      continue
671    }
672    if (/^-[A-Za-z0-9]/.test(w)) {
673      // A short cluster: flags until one takes a value, which is the rest or the next word.
674      for (let j = 1; j < w.length; j++) {
675        const letter = w[j]!
676        if (short[letter] !== undefined) return `git ${sub} ${short[letter]}`
677        if (shortValue.includes(letter)) {
678          if (j === w.length - 1) i++
679          break
680        }
681      }
682      continue
683    }
684    if (sub === 'checkout' && (w === '.' || ++positionals >= 2)) return 'git checkout <paths>'
685  }
686  return null
687}
688
689/** What `git checkout` writes, or null when it only switches branches: paths (also `--ours`/`--theirs`, `-2`/`-3`), `--force` or `--patch`. */
690const checkoutWrite = (args: Words): string | null =>
691  branchWrite(
692    'checkout',
693    args,
694    CHECKOUT_LONG,
695    { '--force': '--force', '--patch': '--patch', '--ours': '<paths>', '--theirs': '<paths>', '--pathspec-from-file': '<paths>' },
696    { f: '--force', p: '--patch', '2': '<paths>', '3': '<paths>' },
697    'bB',
698  )
699
700/** What `git switch` writes, or null when it only switches or creates a branch: `--force`/`--discard-changes`, `-C`, `--orphan`. */
701const switchWrite = (args: Words): string | null =>
702  branchWrite(
703    'switch',
704    args,
705    SWITCH_LONG,
706    { '--force': '--force', '--discard-changes': '--discard-changes', '--force-create': '--force-create', '--orphan': '--orphan' },
707    { f: '--force', C: '--force-create' },
708    'c',
709  )
710
711/** `git clean`'s long options (2.43), for reading an abbreviation. */
712const CLEAN_LONG = ['--quiet', '--dry-run', '--force', '--interactive', '--exclude']
713
714/**
715 * Whether a `git clean` is a dry run: `-n` (also in a short cluster, before an `-e` whose value is
716 * the rest of it) or `--dry-run` (or a prefix git takes), before `--` and not as an exclude pattern.
717 */
718function cleanDryRun(args: Words): boolean {
719  for (let i = 0; i < args.length; i++) {
720    const w = args[i]!
721    if (w === '--') return false
722    if (w.startsWith('--')) {
723      const o = longOption(w, CLEAN_LONG)
724      if (o === '--dry-run') return true
725      if (o === '--exclude' && !w.includes('=')) i++
726    } else if (/^-[A-Za-z]/.test(w)) {
727      for (let j = 1; j < w.length; j++) {
728        if (w[j] === 'n') return true
729        if (w[j] === 'e') {
730          if (j === w.length - 1) i++
731          break
732        }
733      }
734    }
735  }
736  return false
737}
738
739/** The `git apply` options that turn applying off: they only read the patch. */
740const APPLY_READS = ['--check', '--stat', '--numstat', '--summary']
741
742/** Whether a `git apply` only reads: at least one of `APPLY_READS`, and no other option. */
743function applyReadsOnly(args: Words): boolean {
744  let reads = false
745  for (const w of args) {
746    if (w === '--') break
747    if (APPLY_READS.includes(w)) reads = true
748    else if (w.startsWith('-') && w !== '-') return false
749  }
750  return reads
751}
752
753/** Whether a `git add`, `mv` or `rm` is a dry run: `-n` (none of their short options takes a value) or a prefix of `--dry-run`, before `--`. */
754function dryRun(args: Words): boolean {
755  for (const w of args) {
756    if (w === '--') return false
757    if (w.startsWith('--') ? w.length >= 3 && '--dry-run'.startsWith(w) : /^-[A-Za-z]*n/.test(w)) return true
758  }
759  return false
760}
761
762/**
763 * Whether a `git branch` or `git tag` command only lists: no write option (`writeLong`, or a short
764 * cluster with a letter of `writeShort`), and a name given only to a list form (`--list`, `-l`, ...),
765 * as a pattern, or as the value of a filter (`--contains <commit>`).
766 */
767function listsOnly(args: Words, writeLong: readonly string[], writeShort: string, listing: readonly string[]): boolean {
768  const filters = ['--contains', '--no-contains', '--merged', '--no-merged', '--points-at', '--sort', '--format']
769  let list = false
770  let names = false
771  for (let i = 0; i < args.length; i++) {
772    const w = args[i]!
773    const name = w.split('=')[0]!
774    if (w === '--') {
775      names ||= i + 1 < args.length
776      break
777    }
778    if (writeLong.includes(name)) return false
779    if (listing.includes(name)) list = true
780    else if (filters.includes(w) && i + 1 < args.length && !args[i + 1]!.startsWith('-')) i++
781    else if (/^-[A-Za-z]/.test(w) && !w.startsWith('--')) {
782      const letters = w.slice(1).replace(/\d+$/, '')
783      if ([...letters].some(l => writeShort.includes(l))) return false
784      if ([...letters].some(l => listing.includes(`-${l}`))) list = true
785    } else if (!w.startsWith('-')) names = true
786  }
787  return list || !names
788}
789
790/**
791 * G4's Bash half: the git command, of those listed in docs/htui-dev.md, that changes the working
792 * tree, the index, refs, history or the remote, as a label for the refusal, or null. Reading stays
793 * allowed: status, diff, log, show, blame, fetch, `stash list|show`, `checkout <branch>`, `clean -n`,
794 * the list forms of `branch`, `tag`, `replace`, `reflog`, `notes`, `worktree`, `submodule`,
795 * `sparse-checkout`, and `bisect log|view|visualize`. A git command not listed passes (Known gaps).
796 */
797export function gitWrite(words: Words): string | null {
798  const git = gitSubcommand(words)
799  if (git === null) return null
800  const args = words.slice(git.at)
801  if (GIT_WRITES.has(git.sub)) return `git ${git.sub}`
802  const actions = GIT_ACTIONS[git.sub]
803  if (actions !== undefined) {
804    const action = actionOf(args, actions.skip)
805    if (!actions.writes(action)) return null
806    return actions.named ? `git ${git.sub} ${action}` : `git ${git.sub}`
807  }
808  switch (git.sub) {
809    case 'replace':
810      // `git replace` alone, or with `-l`/`--list`, lists the replacements.
811      return args.length === 0 || args[0] === '-l' || args[0] === '--list' || args[0]!.startsWith('--format') ? null : 'git replace'
812    case 'stash':
813      return args[0] === 'list' || args[0] === 'show' ? null : 'git stash'
814    case 'checkout':
815      return checkoutWrite(args)
816    case 'switch':
817      return switchWrite(args)
818    case 'clean':
819      return cleanDryRun(args) ? null : 'git clean'
820    case 'apply':
821      return applyReadsOnly(args) ? null : 'git apply'
822    case 'add':
823    case 'mv':
824    case 'rm':
825      return dryRun(args) ? null : `git ${git.sub}`
826    case 'branch':
827      return listsOnly(
828        args,
829        ['--delete', '--move', '--copy', '--force', '--set-upstream-to', '--unset-upstream', '--edit-description', '--track', '--no-track', '--create-reflog'],
830        'dDmMcCfut',
831        ['--list', '-l', '-a', '--all', '-r', '--remotes', '--show-current'],
832      )
833        ? null
834        : 'git branch'
835    case 'tag':
836      return listsOnly(
837        args,
838        ['--annotate', '--sign', '--no-sign', '--local-user', '--force', '--delete', '--message', '--file', '--edit', '--create-reflog', '--trailer'],
839        'asudmFfe',
840        ['--list', '-l', '-n', '--verify', '-v'],
841      )
842        ? null
843        : 'git tag'
844    default:
845      return null
846  }
847}
848
849/**
850 * A `git` word followed, past its global options, by a subcommand that writes in (nearly) every
851 * form, or by a writing action of one that does not. Only for a line the parser cannot follow;
852 * coarse on purpose.
853 */
854const COARSE_GIT_WRITE =
855  /(?:^|[\s;&|()`'"])git(?:\s+(?:-C|-c|--git-dir|--work-tree|--namespace)\s+\S+|\s+-\S+)*\s+(commit|reset|restore|merge|rebase|cherry-pick|revert|am|apply|add|rm|mv|pull|clean|push|checkout-index|read-tree|update-ref|update-index|gc|filter-branch|stash(?!\s+(?:list|show)\b)|bisect(?!\s+(?:log|view|visualize)\b)|worktree\s+(?:add|remove|move|prune)|submodule\s+(?:update|deinit|add|sync)|sparse-checkout(?!\s+list\b)|reflog\s+(?:expire|delete)|notes\s+(?:add|remove|edit|append|copy|prune))(?![\w-])/
856
857/**
858 * G4's Bash half for a line the parser cannot follow (an unbalanced quote, a heredoc with no
859 * end): the git write it seems to run, by a coarse pattern, or null. For a read-only agent only;
860 * any other caller's unreadable line stays allowed.
861 */
862export function coarseGitWrite(command: string): string | null {
863  const m = COARSE_GIT_WRITE.exec(command)
864  return m === null ? null : `git ${m[1]!.replace(/\s+/g, ' ')}`
865}
866
867// --- G3: the forbidden literal --------------------------------------------------------------
868
869/**
870 * The name `crates/htui-agent/tests/extensibility.rs` forbids anywhere in `crates/**\/*.{rs,json}`
871 * (case-sensitive), built so that this file never spells it.
872 */
873export const LITERAL = 'ze' + 'ta'
874
875/**
876 * The fields that carry the text a write puts into a file (Gortex: `new_string`, `new_source`,
877 * `content`, `new_name`; Serena: `body`, `repl`, `content`, `new_name`); the old text is never read
878 * (removing the literal is the fix).
879 */
880const NEW_TEXT = ['new_string', 'new_source', 'content', 'new_name', 'body', 'repl'] as const
881
882/** Serena's one write tool that reaches many files: `relative_path` is a file, a folder, or empty for the whole project. */
883const SERENA_MANY = /^mcp__(plugin_[a-z0-9_-]+_)?serena__replace_in_files$/
884
885/** A path from a Gortex symbol id: what stands before `::` (`htui/crates/x.rs::Sym`). */
886const idPath = (v: unknown): string | null => (typeof v === 'string' && v.includes('::') ? v.slice(0, v.indexOf('::')) : null)
887
888/** The file a node of a tool's input names: `file_path`, `path`, `notebook_path`, `relative_path` (Serena), `target.file`, or a symbol id's file. */
889function pathOf(node: Readonly<Record<string, unknown>>): string | null {
890  for (const key of ['file_path', 'path', 'notebook_path', 'relative_path']) {
891    if (typeof node[key] === 'string') return node[key] as string
892  }
893  const target = node.target
894  if (target !== null && typeof target === 'object' && !Array.isArray(target)) {
895    const t = target as Record<string, unknown>
896    if (typeof t.file === 'string') return t.file
897    const p = idPath(t.symbol) ?? idPath(t.id)
898    if (p !== null) return p
899  }
900  return idPath(node.id)
901}
902
903/** A path's folders and file, `\\` read as `/`, with `.` dropped and `..` taken back. */
904function segmentsOf(path: string): string[] {
905  const out: string[] = []
906  for (const part of path.replace(/\\/g, '/').split('/')) {
907    if (part === '' || part === '.') continue
908    if (part === '..' && out.length > 0 && out[out.length - 1] !== '..') out.pop()
909    else out.push(part)
910  }
911  return out
912}
913
914/**
915 * Whether the literal scan reads `path`: a `.rs` or `.json` below the first `crates` folder, with
916 * no `target` folder below it, and not the scan's own file `crates/htui-agent/tests/extensibility.rs`.
917 */
918function scanned(path: string): boolean {
919  const parts = segmentsOf(path)
920  const at = parts.indexOf('crates')
921  if (at === -1 || at >= parts.length - 1) return false
922  if (!/\.(rs|json)$/.test(parts[parts.length - 1]!)) return false
923  if (parts.slice(at).includes('target')) return false
924  return parts.slice(-4).join('/') !== 'crates/htui-agent/tests/extensibility.rs'
925}
926
927/** The first path, walking `node` with the nearest path passed down, whose new text holds the literal. */
928function offender(node: unknown, inherited: string | null, depth: number): string | null {
929  if (depth > 16 || node === null || typeof node !== 'object') return null
930  if (Array.isArray(node)) {
931    for (const item of node) {
932      const hit = offender(item, inherited, depth + 1)
933      if (hit !== null) return hit
934    }
935    return null
936  }
937  const record = node as Readonly<Record<string, unknown>>
938  const path = pathOf(record) ?? inherited
939  if (path !== null && scanned(path)) {
940    for (const key of NEW_TEXT) {
941      const v = record[key]
942      if (typeof v === 'string' && v.includes(LITERAL)) return path
943    }
944  }
945  for (const [key, v] of Object.entries(record)) {
946    if (key === 'target') continue
947    const hit = offender(v, path, depth + 1)
948    if (hit !== null) return hit
949  }
950  return null
951}
952
953/** Whether a `replace_in_files` over `path` (a file, a folder, or empty for the whole project) can reach a file the scan reads. */
954function reachesScanned(path: string): boolean {
955  const parts = segmentsOf(path)
956  if (parts.length === 0) return true
957  if (/\.[A-Za-z0-9]+$/.test(parts[parts.length - 1]!)) return scanned(path)
958  const at = parts.indexOf('crates')
959  return at !== -1 && !parts.slice(at).includes('target')
960}
961
962/** A call that writes nothing although its tool can: Serena's `replace_in_files` as a dry run. */
963export const writesNothing = (tool: string, input: Readonly<Record<string, unknown>>): boolean =>
964  SERENA_MANY.test(tool) && input.dry_run === true
965
966/** G3: an edit by `tool` that would write the literal into a file the scan reads; every matched tool's input is read the same way. */
967export function forbiddenLiteral(tool: string, input: Readonly<Record<string, unknown>>): string | null {
968  if (writesNothing(tool, input)) return null
969  if (SERENA_MANY.test(tool)) {
970    const where = typeof input.relative_path === 'string' ? input.relative_path : ''
971    if (typeof input.repl !== 'string' || !input.repl.includes(LITERAL) || !reachesScanned(where)) return null
972    const across = where.replace(/^\.?\/?$/, '') === '' ? 'the whole project' : where
973    return `htui-dev: replacing across ${across} would write "${LITERAL}" into crates/**/*.{rs,json}, which crates/htui-agent/tests/extensibility.rs forbids — use another name, or narrow relative_path to files the scan does not read`
974  }
975  const path = offender(input, null, 0)
976  if (path === null) return null
977  return `htui-dev: ${path} would contain "${LITERAL}", which crates/htui-agent/tests/extensibility.rs forbids in crates/**/*.{rs,json} — use another name; only extensibility.rs may spell it`
978}
979
980// --- G4 and G5: subagents ---------------------------------------------------------------------
981
982/** Agent types that review or design and never change the tree (Q2). A plugin's `<plugin>:<name>` counts by its name. */
983const READ_ONLY_TYPES = new Set(['rust-reviewer', 'code-architect'])
984
985/**
986 * G4's marker (Q2): a prompt holding `READ-ONLY` (case-sensitive: a lowercase "read-only host tree"
987 * is ordinary text) or "strictly no edits" (any case), or a read-only agent type. A tagged parent
988 * is register.ts's to check: it lives in `$.state`.
989 */
990export function declaresReadOnly(spawn: { prompt: string; subagentType: string }): boolean {
991  return (
992    spawn.prompt.includes('READ-ONLY') ||
993    /strictly no edits/i.test(spawn.prompt) ||
994    READ_ONLY_TYPES.has(spawn.subagentType.split(':').pop()!)
995  )
996}
997
998/** G4's deny for a file-writing tool called by the read-only agent `agentId`. */
999export const readOnlyEdit = (tool: string, agentId: string): string =>
1000  `htui-dev: agent ${agentId} was started read-only and may not change files (${tool}) — report the change in your answer instead`
1001
1002/** G4's deny for a git write (`gitWrite`'s label) run by the read-only agent `agentId`. */
1003export const readOnlyGit = (verb: string, agentId: string): string =>
1004  `htui-dev: agent ${agentId} was started read-only and may not run ${verb} — report what to change instead`
1005
1006/** Whether a model name, alias or id, is a Fable model. */
1007export const isFable = (model: string): boolean => /fable/i.test(model)
1008
1009/**
1010 * G5: a subagent asked to run on Fable. It reads the Agent tool's `model` parameter only: a model
1011 * left unset is resolved after the hook, from the agent definition or the parent (see register.ts).
1012 */
1013export function fableModel(model: string | undefined): string | null {
1014  if (model === undefined || !isFable(model)) return null
1015  return `htui-dev: subagents never run on Fable in this repo (model "${model}") — leave model unset so the agent inherits the session's model`
1016}
1017
hooks/shell.ts 489 lines
1// Reads a Bash line as the simple commands it runs, enough for the guards to see
2// `cargo test …` and `git commit …` wherever they stand. It is no shell: what it
3// cannot follow (an unbalanced quote, a heredoc with no end) answers null, and the
4// guards let that call through (ANA-29 §6.3, "fail open").
5
6/** One simple command's words: quotes removed, leading VAR=value and the wrappers env (and its -S string)/command/time/nice/timeout/nohup/exec stripped, redirections dropped. */
7export type Words = readonly string[]
8
9/**
10 * What the tokenizer hands on: a word, a command separator, a redirection that does or does not
11 * take the next word, or the text of a `$( … )` inside double quotes, whose commands run first.
12 */
13type Token =
14  | { kind: 'word'; text: string }
15  | { kind: 'sep' }
16  | { kind: 'redir'; takesWord: boolean }
17  | { kind: 'sub'; text: string }
18
19/** A heredoc whose body starts at the next newline: its delimiter, and whether `<<-` lets the end line lead with tabs. */
20type Heredoc = { delimiter: string; stripTabs: boolean }
21
22/** Thrown inside the parser for a line it cannot follow; `simpleCommands` turns it into null. */
23class Unfollowable extends Error {}
24
25/** Characters that end an unquoted word. */
26const BREAK = new Set([' ', '\t', '\n', ';', '&', '|', '(', ')', '<', '>', '`'])
27
28/** Words that open or close a compound command; at the head of a command they are not the program. */
29const KEYWORDS = new Set(['!', '{', '}', 'if', 'then', 'elif', 'else', 'fi', 'do', 'done', 'while', 'until'])
30
31/** `NAME=value` or `NAME+=value`, a shell assignment. */
32const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*\+?=/
33
34/** The shells whose `-c` string is followed. */
35const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh'])
36
37/** The index of the `)` closing the `(` at `open`, counting parentheses only (for `$(( … ))`). Throws Unfollowable. */
38function parenEnd(line: string, open: number): number {
39  let depth = 0
40  for (let i = open; i < line.length; i++) {
41    if (line[i] === '(') depth++
42    else if (line[i] === ')' && --depth === 0) return i
43  }
44  throw new Unfollowable('unbalanced $((')
45}
46
47/** Where a `$(` (or `$((`) at `dollar` ends: the index of its last `)`. Throws Unfollowable. */
48const dollarParenEnd = (line: string, dollar: number): number =>
49  line[dollar + 2] === '(' ? parenEnd(line, dollar + 1) : substitutionEnd(line, dollar + 2)
50
51/** The index of the `"` closing a double-quoted string whose text starts at `from`. Throws Unfollowable. */
52function doubleQuoteEnd(line: string, from: number): number {
53  for (let i = from; i < line.length; ) {
54    const d = line[i]!
55    if (d === '"') return i
56    if (d === '\\') i += 2
57    else if (d === '$' && line[i + 1] === '(') i = dollarParenEnd(line, i) + 1
58    else i++
59  }
60  throw new Unfollowable('unbalanced double quote')
61}
62
63/**
64 * The index of the `)` closing a `$(` whose text starts at `from`. Inside, the shell starts afresh:
65 * quotes, nested parentheses, comments and heredoc bodies are stepped over, so a `)` or `"` in them
66 * closes nothing. Throws Unfollowable.
67 */
68function substitutionEnd(line: string, from: number): number {
69  let depth = 0
70  let wordStart = true
71  const pending: Heredoc[] = []
72  let i = from
73  while (i < line.length) {
74    const c = line[i]!
75    if (c === '\\') {
76      i += 2
77      wordStart = false
78      continue
79    }
80    if (c === "'") {
81      const close = line.indexOf("'", i + 1)
82      if (close === -1) throw new Unfollowable('unbalanced single quote')
83      i = close + 1
84      wordStart = false
85      continue
86    }
87    if (c === '"') {
88      i = doubleQuoteEnd(line, i + 1) + 1
89      wordStart = false
90      continue
91    }
92    if (c === '$' && line[i + 1] === '(') {
93      i = dollarParenEnd(line, i) + 1
94      wordStart = false
95      continue
96    }
97    if (c === '#' && wordStart) {
98      const eol = line.indexOf('\n', i)
99      i = eol === -1 ? line.length : eol
100      continue
101    }
102    if (line.startsWith('<<<', i)) {
103      // A here-string: its word follows, no heredoc body.
104      i += 3
105      wordStart = true
106      continue
107    }
108    if (c === '<' && line[i + 1] === '<') {
109      i += 2
110      const stripTabs = line[i] === '-'
111      if (stripTabs) i++
112      while (line[i] === ' ' || line[i] === '\t') i++
113      let delimiter = ''
114      while (i < line.length && !' \t\n;&|<>()'.includes(line[i]!)) {
115        const q = line[i]!
116        if (q === "'" || q === '"') {
117          const close = line.indexOf(q, i + 1)
118          if (close === -1) throw new Unfollowable('unbalanced quote in a heredoc delimiter')
119          delimiter += line.slice(i + 1, close)
120          i = close + 1
121        } else if (q === '\\') {
122          delimiter += line[i + 1] ?? ''
123          i += 2
124        } else {
125          delimiter += q
126          i++
127        }
128      }
129      if (delimiter === '') throw new Unfollowable('heredoc without a delimiter')
130      pending.push({ delimiter, stripTabs })
131      wordStart = false
132      continue
133    }
134    if (c === '\n') {
135      i++
136      for (const doc of pending.splice(0)) {
137        for (;;) {
138          if (i >= line.length) throw new Unfollowable('heredoc without an end')
139          const eol = line.indexOf('\n', i)
140          const end = eol === -1 ? line.length : eol
141          const text = line.slice(i, end)
142          i = end + 1
143          if ((doc.stripTabs ? text.replace(/^\t+/, '') : text) === doc.delimiter) break
144        }
145      }
146      wordStart = true
147      continue
148    }
149    if (c === '(') depth++
150    else if (c === ')') {
151      if (depth === 0) return i
152      depth--
153    }
154    wordStart = ' \t;&|()<>'.includes(c)
155    i++
156  }
157  throw new Unfollowable('unbalanced $(')
158}
159
160/** Splits `line` into tokens, skipping comments and heredoc bodies. Throws Unfollowable. */
161function tokenize(line: string): Token[] {
162  const tokens: Token[] = []
163  const heredocs: Heredoc[] = []
164  /** Set once a redirection `<<` has read its operator: the next word is a heredoc delimiter. */
165  let wantsDelimiter: Heredoc | null = null
166  let word = ''
167  let inWord = false
168  let quoted = false
169  let i = 0
170
171  const endWord = () => {
172    if (!inWord) return
173    if (wantsDelimiter !== null) {
174      heredocs.push({ ...wantsDelimiter, delimiter: word })
175      wantsDelimiter = null
176    } else {
177      tokens.push({ kind: 'word', text: word })
178    }
179    word = ''
180    inWord = false
181    quoted = false
182  }
183
184  /** At a newline: skips the body of every heredoc opened on the line just ended. */
185  const skipBodies = () => {
186    for (const doc of heredocs.splice(0)) {
187      for (;;) {
188        if (i >= line.length) throw new Unfollowable('heredoc without an end')
189        const eol = line.indexOf('\n', i)
190        const end = eol === -1 ? line.length : eol
191        const text = line.slice(i, end)
192        i = end + 1
193        if ((doc.stripTabs ? text.replace(/^\t+/, '') : text) === doc.delimiter) break
194      }
195    }
196  }
197
198  while (i < line.length) {
199    const c = line[i]!
200    if (c === '\\') {
201      if (i + 1 >= line.length) throw new Unfollowable('trailing backslash')
202      if (line[i + 1] === '\n') {
203        i += 2
204        continue
205      }
206      word += line[i + 1]
207      inWord = true
208      i += 2
209      continue
210    }
211    if (c === "'") {
212      const close = line.indexOf("'", i + 1)
213      if (close === -1) throw new Unfollowable('unbalanced single quote')
214      word += line.slice(i + 1, close)
215      inWord = quoted = true
216      i = close + 1
217      continue
218    }
219    if (c === '"') {
220      i++
221      for (;;) {
222        if (i >= line.length) throw new Unfollowable('unbalanced double quote')
223        const d = line[i]!
224        if (d === '"') break
225        if (d === '\\' && i + 1 < line.length && '$`"\\\n'.includes(line[i + 1]!)) {
226          if (line[i + 1] !== '\n') word += line[i + 1]
227          i += 2
228          continue
229        }
230        if (d === '$' && line[i + 1] === '(') {
231          // A substitution: its text stays in the word, and its commands are handed on to run first.
232          const end = dollarParenEnd(line, i)
233          if (line[i + 2] !== '(') tokens.push({ kind: 'sub', text: line.slice(i + 2, end) })
234          word += line.slice(i, end + 1)
235          i = end + 1
236          continue
237        }
238        word += d
239        i++
240      }
241      inWord = quoted = true
242      i++
243      continue
244    }
245    if (c === '#' && !inWord) {
246      const eol = line.indexOf('\n', i)
247      i = eol === -1 ? line.length : eol
248      continue
249    }
250    if (c === '$' && line[i + 1] === '(' && line[i + 2] === '(') {
251      // `$(( … ))`: arithmetic, part of the word, no command (and its `<<` no heredoc).
252      const end = parenEnd(line, i + 1)
253      word += line.slice(i, end + 1)
254      inWord = true
255      i = end + 1
256      continue
257    }
258    if (c === '$' && line[i + 1] === '(') {
259      // `$(`: the substitution is a command of its own, opened by the `(` below.
260      endWord()
261      i++
262      continue
263    }
264    if (!BREAK.has(c)) {
265      word += c
266      inWord = true
267      i++
268      continue
269    }
270    if (c === '(' && inWord && !quoted && /^[A-Za-z_][A-Za-z0-9_]*\+?=$/.test(word)) {
271      // `arr=( … )`: an array assignment, one word, no subshell.
272      const end = substitutionEnd(line, i + 1)
273      word += line.slice(i, end + 1)
274      i = end + 1
275      continue
276    }
277    if (c === '<' || c === '>') {
278      // A run of digits right before the operator names a file descriptor, not a word.
279      if (inWord && !quoted && /^\d+$/.test(word)) {
280        word = ''
281        inWord = false
282      }
283      endWord()
284      if (line.startsWith('<<<', i)) {
285        tokens.push({ kind: 'redir', takesWord: true })
286        i += 3
287      } else if (line.startsWith('<<', i)) {
288        const stripTabs = line[i + 2] === '-'
289        i += stripTabs ? 3 : 2
290        wantsDelimiter = { delimiter: '', stripTabs }
291      } else {
292        const two = line.slice(i, i + 2)
293        const op = two === '>>' || two === '>&' || two === '<&' || two === '>|' || two === '<>' ? two : c
294        i += op.length
295        // `2>&1`, `>&-`: the operator names its target itself.
296        const fd = op.endsWith('&') ? /^(\d+|-)/.exec(line.slice(i)) : null
297        if (fd !== null) i += fd[0].length
298        tokens.push({ kind: 'redir', takesWord: fd === null })
299      }
300      continue
301    }
302    if (c === '&' && line[i + 1] === '>') {
303      endWord()
304      i += line[i + 2] === '>' ? 3 : 2
305      tokens.push({ kind: 'redir', takesWord: true })
306      continue
307    }
308    endWord()
309    if (wantsDelimiter !== null && c !== ' ' && c !== '\t') throw new Unfollowable('heredoc without a delimiter')
310    if (c === ' ' || c === '\t') {
311      i++
312      continue
313    }
314    tokens.push({ kind: 'sep' })
315    i += (c === '&' && line[i + 1] === '&') || (c === '|' && (line[i + 1] === '|' || line[i + 1] === '&')) ? 2 : 1
316    if (c === '\n') skipBodies()
317  }
318  endWord()
319  if (wantsDelimiter !== null) throw new Unfollowable('heredoc without a delimiter')
320  if (heredocs.length > 0) skipBodies()
321  return tokens
322}
323
324/**
325 * The index past a wrapper's options from `at`: `withValue` and `longWithValue` name the options
326 * whose value is the next word (`-n 10`); `-n10` and `--long=v` are one word, as is any other option.
327 */
328function skipOptions(words: string[], at: number, withValue: readonly string[], longWithValue: readonly string[]): number {
329  while (at < words.length) {
330    const w = words[at]!
331    if (w === '--') return at + 1
332    if (!w.startsWith('-') || w === '-') return at
333    if (withValue.includes(w) || longWithValue.includes(w)) at += 2
334    else at += 1
335  }
336  return at
337}
338
339/**
340 * Where `env`'s options starting at `at` end, and the string of its `-S` (`--split-string`), whose
341 * words stand in for the option and run as the command, or null without one.
342 */
343function envOptions(words: readonly string[], at: number): { at: number; split: string | null } {
344  while (at < words.length) {
345    const o = words[at]!
346    if (o === '--') return { at: at + 1, split: null }
347    if (!o.startsWith('-') || o === '-') return { at, split: null }
348    if (o === '--split-string') return { at: at + 2, split: words[at + 1] ?? '' }
349    if (o.startsWith('--split-string=')) return { at: at + 1, split: o.slice('--split-string='.length) }
350    if (o === '--unset' || o === '--chdir') {
351      at += 2
352      continue
353    }
354    if (o.startsWith('--')) {
355      at++
356      continue
357    }
358    // A short cluster: `-u NAME` and `-C DIR` take a value, `-S STRING` the split string.
359    let used = 1
360    for (let j = 1; j < o.length; j++) {
361      const letter = o[j]!
362      if (letter !== 'u' && letter !== 'C' && letter !== 'S') continue
363      const rest = o.slice(j + 1)
364      if (letter === 'S') return rest !== '' ? { at: at + 1, split: rest } : { at: at + 2, split: words[at + 1] ?? '' }
365      if (rest === '') used = 2
366      break
367    }
368    at += used
369  }
370  return { at, split: null }
371}
372
373/** The words of `text` as the shell splits them: quotes removed, nothing run. Throws Unfollowable. */
374const wordsOf = (text: string): string[] =>
375  tokenize(text).flatMap(t => (t.kind === 'word' ? [t.text] : []))
376
377/** The words with leading assignments, keywords and wrappers stripped, the program first. */
378function unwrap(given: string[]): string[] {
379  let words = given
380  let at = 0
381  for (;;) {
382    const start = at
383    while (at < words.length && (ASSIGNMENT.test(words[at]!) || KEYWORDS.has(words[at]!))) at++
384    const w = words[at]
385    if (w === 'env') {
386      const env = envOptions(words, at + 1)
387      if (env.split !== null) {
388        // `env -S "cargo test"`: its string, split, is the command, followed by the words after it.
389        words = [...wordsOf(env.split), ...words.slice(env.at)]
390        at = 0
391        continue
392      }
393      at = env.at
394    } else if (w === 'exec') {
395      at = skipOptions(words, at + 1, ['-a'], [])
396    } else if (w === 'command' || w === 'nohup') {
397      at = skipOptions(words, at + 1, [], [])
398    } else if (w === 'time') {
399      at = skipOptions(words, at + 1, ['-f', '-o'], ['--format', '--output'])
400    } else if (w === 'nice') {
401      at = skipOptions(words, at + 1, ['-n'], ['--adjustment'])
402    } else if (w === 'timeout') {
403      at = skipOptions(words, at + 1, ['-s', '-k'], ['--signal', '--kill-after'])
404      if (at < words.length) at++ // the duration
405    }
406    if (at === start) return words.slice(at)
407  }
408}
409
410/** The string a `bash|sh|zsh|dash|ksh -c` command runs, or null when the words are no such command. */
411function dashC(words: readonly string[]): string | null {
412  if (!SHELLS.has((words[0] ?? '').split('/').pop()!)) return null
413  let hasC = false
414  let at = 1
415  while (at < words.length) {
416    const w = words[at]!
417    if (w === '-o' || w === '+o' || w === '-O' || w === '+O' || w === '--rcfile' || w === '--init-file') {
418      at += 2
419      continue
420    }
421    if (w === '--') {
422      at++
423      break
424    }
425    if (w.startsWith('--')) {
426      at++
427      continue
428    }
429    if (/^[-+][A-Za-z]+$/.test(w)) {
430      if (w.startsWith('-') && w.includes('c')) hasC = true
431      at++
432      continue
433    }
434    break
435  }
436  return hasC && at < words.length ? words[at]! : null
437}
438
439/** The simple commands of `line`, `bash -c` strings followed. Throws Unfollowable. */
440function commandsOf(line: string, depth: number): Words[] {
441  if (depth > 8) throw new Unfollowable('bash -c nested too deep')
442  const out: Words[] = []
443  let words: string[] = []
444  /** The commands of the substitutions in the command being read: they run before it. */
445  let subs: Words[] = []
446  let dropNext = false
447  const flush = () => {
448    out.push(...subs)
449    subs = []
450    const unwrapped = unwrap(words)
451    words = []
452    if (unwrapped.length === 0) return
453    const inner = dashC(unwrapped)
454    if (inner === null) out.push(unwrapped)
455    else out.push(...commandsOf(inner, depth + 1))
456  }
457  for (const t of tokenize(line)) {
458    if (t.kind === 'sub') {
459      subs.push(...commandsOf(t.text, depth + 1))
460    } else if (t.kind === 'sep') {
461      dropNext = false
462      flush()
463    } else if (t.kind === 'redir') {
464      dropNext = t.takesWord
465    } else if (dropNext) {
466      dropNext = false
467    } else {
468      words.push(t.text)
469    }
470  }
471  flush()
472  return out
473}
474
475/** The simple commands of a Bash line, recursing into `bash|sh|zsh|dash|ksh -c` (also `-lc`, `-ec`) and `$( … )` inside double quotes; null when the parser cannot follow it (unbalanced quote, trailing backslash, unterminated heredoc, nesting deep enough to overflow the stack). */
476export function simpleCommands(command: string): Words[] | null {
477  try {
478    return commandsOf(command, 0)
479  } catch (err) {
480    if (err instanceof Unfollowable || err instanceof RangeError) return null
481    throw err
482  }
483}
484
485/** Words back to one shell line, single-quoting a word that needs it (for the corrected command in a deny). */
486export function quoteWords(words: Words): string {
487  return words.map(w => (/^[A-Za-z0-9_\-./=:,+@%^]+$/.test(w) ? w : `'${w.replace(/'/g, `'\\''`)}'`)).join(' ')
488}
489
types/index.d.ts 24 lines
1/** agentIds started read-only this session (G4); each is the agentId its loop's `tool.call` carries. */
2export type HtuiDevReadOnly = readonly string[]
3
4/** The `tool_use_id`s of the Workflow and Agent calls read-only agents made (G4): a spawn under one starts read-only. */
5export type HtuiDevReadOnlyCalls = readonly string[]
6
7/** The `finished` of the last gate result the status timer has seen (null: none had finished when it was seeded). */
8export type HtuiDevGateSeen = { readonly finished: string | null }
9
10declare module 'claude-code' {
11  interface PluginState {
12    'htui-dev': {
13      /** Grows as read-only agents start; never shrinks within a session. */
14      readOnly: HtuiDevReadOnly
15      /** Grows as read-only agents call Workflow or Agent; never shrinks within a session. */
16      readOnlyCalls: HtuiDevReadOnlyCalls
17      /** Seeded when the status timer arms; a tick toasts when last.json's `finished` moves past it. */
18      gateSeen: HtuiDevGateSeen | null
19      /** session.start found the session fit for the status timer (interactive, not driven by htui); the tool.call hooks re-arm the timer from it after a reload. */
20      ambient: boolean
21    }
22  }
23}
24