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…

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.
htui opens instantly and stays readable when the server is out of reach.agy) from inside the app, and replay any recorded conversation later.It runs on Windows 10+, Linux and macOS.
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.
You need:
rust-toolchain.toml, so rustup downloads it on your first build.pkg-config, which the keyring integration builds against (sudo apt install libdbus-1-dev pkg-config on Debian and Ubuntu).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
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.
| Option | What it does | |
|---|---|---|
--demo | Start with the built-in sample data in memory instead of connecting to a database. | |
--offline | Open from the local copy only and never try to connect. | |
--set-dsn | Read a Postgres connection string from standard input, save it in the OS keyring and exit. | |
--clear-dsn | Remove the saved connection string from the OS keyring and exit. | |
--dsn-stdin | Read 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-keys | Ignore keys.toml for this run and use the default keys. | |
--print-keys | Print 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-items | Update 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. | |
--decisions | With --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). | |
--json | With --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, --version | Print usage or the version and exit. |
htui needs Postgres 16 or newer.
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.
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:
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.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.
htui opens from its local copy first and connects in the background. The top bar shows where it stands:
| Top bar | Meaning |
|---|---|
memory | Running with --demo. No server and no local copy. |
connecting | The first connection attempt has not answered yet. |
online | Connected. The local copy is kept up to date in the background. |
offline · 3m | No 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.
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.
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.
| Tab | What it is for |
|---|---|
| 1 Backlog | The workspace's items, grouped by project, with details on the right. This is also where you drive runs. |
| 2 Skills | Your skill library and prompt templates: browse versions, compare them, edit, import, and attach skills to projects and repositories. |
| 3 Requirements | Each 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 Settings | Agents, workspaces and projects, item kinds, prompt settings, the database connection, the search server, this machine's profile and agent personas. |
| 5 Chat | A 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.
| Key | Action |
|---|---|
q | Quit |
Tab / Shift+Tab | Next / previous tab |
1 … 5 | Go to a tab |
w | Switch workspace |
? | Show or hide the key help |
Esc | Close 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.
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.
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).
| Key | Action |
|---|---|
j / k, g / G | Move, jump to the first / last row |
Enter on a project | Fold or unfold the project |
h / l | Previous / next detail sub-tab |
J / K, PageUp / PageDown | Scroll 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 / F | Filter the list / clear the filter |
N / e | New item / edit the item |
m | Open the Graph sub-tab |
Q / P | Queue 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.
| Key | Action |
|---|---|
R | Start a run on the item |
a / x | Approve / reject the step (rejecting asks for a note) |
r | Retry the step |
s | Pick this step as the winner of a fan-out |
A | Accept the step's output |
o | Read the step's output document |
p | Continue the step as a chat |
i | Send a running step's agent a follow-up; it is sent when the current turn ends |
Enter | Replay the step's conversation in the Chat tab |
c | Cancel the run |
T | Retry the run's cleanup |
u | Unblock the item |
C | Close out the item: pick how it ends with ← / →, press y, then type the item's key to confirm |
v | Switch 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.
| Key | Action |
|---|---|
J / K | Move between citations |
r | Re-confirm a suspect citation against the requirement as it reads now |
c | Cite another requirement of the item's project (Enter picks it, then a addresses or v reserves) |
u | Remove 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.
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 ✕.
| Key | Action |
|---|---|
j / k, g / G | Move, jump to the first / last row |
Enter on a project or area | Fold or unfold it |
J / K, PageUp / PageDown | Scroll the detail pane |
/ | Filter the list as you type (Enter keeps the filter, Esc clears it) |
a | Add an area to the project |
n | Write a new requirement in the selected area |
e | Amend the requirement |
W | Withdraw the requirement (asks you to type its key to confirm) |
r | Read 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.
| Key | Action |
|---|---|
i / Enter | Write a message (Enter sends, Esc stops writing) |
a | Pick another agent (before the first message) |
1 … 9 | Answer the agent's permission request |
Esc Esc | End the conversation |
t | Show or hide the agent's thinking |
j / k, g / G | Scroll |
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.
| Section | What you do there |
|---|---|
| Agents | See which agents work on this machine (r checks again), install an agent's adapter (i), and log in (a). |
| Hierarchy | Create 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). |
| Kinds | Item kinds, their phases and step graphs; a phase's persona field binds a persona to it. |
| Prompt | Prompt settings such as the token budget. |
| Connection | The database connection string (e edit, c clear) and rebuilding the local copy (R). |
| Qdrant | The search server's address and API key (e edit, c clear). |
| Boxes | This 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). |
| Personas | Agent 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.
htui comes with three agents:
| Agent | What it is |
|---|---|
claude | Claude Code over ACP, the Agent Client Protocol. The recommended choice. |
claude-cli | Claude Code through its command-line JSON stream. A fallback for when the ACP adapter can't be installed. |
agy | Google 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.
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
hooks/register.ts 508 lines1// 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}
508hooks/commands.ts 80 lines1// 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}
80hooks/status.ts 293 lines1// 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}
293hooks/guards.ts 1017 lines1// 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}
1017hooks/shell.ts 489 lines1// 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}
489types/index.d.ts 24 lines1/** 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