Keep Claude Code on a plan: stages, approval gates and a plan-scoped file budget enforced by hooks, plus model routing, review loops and a decision log.

loopkeeper is a Claude Code plugin, the core of the loopkeeper set, that runs a task loop (plan, validate the plan, execute, verify, QA, ship) and enforces its rules. It adds approval gates, a file budget per turn tied to the approved plan, protected paths, a ship gate, merge and amend guards, model routing by role, bounded review loops and a decision log. The rules are hooks with shared state, not instructions the model may ignore: a call the rules refuse does not run. You agree on a plan once; with the gates on, Claude cannot edit before that plan is approved, change more files than it allows, or commit and push before you say so, and after /lk approve it carries on by itself.
Use it when
docs/decisions/, migrations or .env files.Install: /plugin install loopkeeper@loopkeeper, after /plugin marketplace add connectwithub/loopkeeper (the add-ons are listed in the set's README).
Data handling: it keeps its saved settings (such as the preset, the approval mode and the routes) in its own plugin store, and the loop's state in session state. It reads ~/.claude/loopkeeper.json and <project>/.claude/loopkeeper.json, and writes only the review log, .claude/loopkeeper-review.jsonl, and the project's decision log. It runs read-only git and gh queries for the merge gate and the Files pane; the gh ones reach GitHub through your own gh sign-in. It has no network of its own and makes no model calls, except the decision watch, which is off by default and, when on, makes model calls on your own Claude Code session. It collects no personal data; what it saves stays until you delete it. Details under Footprint.
Commands: /lk (also /loopkeeper), /files, /reviews, /decide and /decisions are listed under Commands, and the five tools it gives the model under Tools.
Requires Claude Code 2.1.294 or later.
claude --plugin-dir /path/to/loopkeeper/plugins/loopkeeper
Then run /lk for the hub, or /lk status. To install it from the loopkeeper marketplace (https://github.com/connectwithub/loopkeeper), see Install in the set's README.
loopkeeper uses its own command names. The built-ins own /loop and /code-review, so the old names are gone (see ADR 0002).
| Before | After | Notes | |||||||
|---|---|---|---|---|---|---|---|---|---|
/pipeline | /lk stage | Opens the Stage pane. /lk alone opens the loopkeeper hub, which links every pane. /loopkeeper is the long form. | |||||||
/pipeline <stage>, next, back | /lk <stage>, /lk next, /lk back | Sub-command words win over stage ids. | |||||||
/pipeline approve, waive, status | /lk approve, /lk waive, /lk status | ||||||||
/pipeline preset <name>, size, small-max, auto | /lk preset <name>, /lk size, /lk small-max, /lk auto | ||||||||
/pipeline reset | /lk reset | Resets the stage machine. | |||||||
| `/pipeline approval auto\ | ask, /gsd approval auto\ | ask` | `/lk approval auto\ | ask` | One mode for the whole plugin (ADR 0005). | ||||
/gsd | /lk routes | Opens the routing pane. | |||||||
| `/gsd on\ | off, /gsd loop on\ | off` | `/lk on\ | off, /lk loop on\ | off` | GSD mode and the loop. | |||
/gsd route <role> <model>, /gsd explain <text> | /lk route <role> <model>, /lk explain <text> | ||||||||
/gsd reset | /lk route reset | /lk reset is the stage reset. | |||||||
/gsd text | /lk text | Plain-text status. | |||||||
/change-control | /lk gates | Opens the gates pane. | |||||||
/change-control text, files | /lk gates text, /lk gates files | /files still opens the Files pane. | |||||||
| `/change-control on\ | off` | `/lk gates on\ | off` | Session switch for every gate. Not the same as `/lk on\ | off`. | ||||
/change-control max <n> | /lk gates max <n> | ||||||||
/change-control plan ok, `plan required on\ | off, plan paths` | /lk gates plan ok, `/lk gates plan required on\ | off, /lk gates plan paths` | ||||||
/change-control ship ok, `ship gate on\ | off` | /lk gates ship ok, `/lk gates ship gate on\ | off` | ||||||
| `/change-control band on\ | off` | `/lk band [stage\ | gates\ | files\ | routing\ | review\ | decisions] on\ | off` | No concern named means all of them. |
/review | /reviews | Same words: text, status, clear, kinds, kind, target, max, ship, reopen, stop, band, context. | |||||||
/decide, /decisions, /files | unchanged | ||||||||
tools set_stage, review_cycle, review_item, review_status, record_decision | mcp__loopkeeper__<name> | Same names and inputs. |
Everything is under /lk (or /loopkeeper), except /reviews, /decide, /decisions and /files. Each word is answered by the concern that owns it; shared words (status, text, reload, band, on, off) are answered by every concern and joined in one reply.
/lk| Form | What it does | |||||||
|---|---|---|---|---|---|---|---|---|
/lk | Opens the loopkeeper hub: the stage, one row per panel with its live status, the other mods and the actions. | |||||||
/lk stage | Opens the Stage pane. | |||||||
/lk help | Lists the keys (below). | |||||||
/lk status, /lk text | Status: stage, routing and gates. text is plain text. /lk status also says Continue after approval: on or off. | |||||||
/lk <stage>, /lk next, /lk back | Moves the stage. <stage> is an id of the current stages. stage and help are read as the words above, so a custom stage with one of those ids is reached with next, back or set_stage. | |||||||
/lk approve | Approves the current stage's gate or, from the stage before a gate, the next one, then asks the model to carry on (see Continue after approval). From the stage before (no gate of its own waiting, no budget waiting) it opens the next stage's gate and moves into it: at Plan in the gsd preset the Validate plan gate ("Approved the Validate plan gate and moved to Validate plan; Claude may start Execute."), at QA the Ship gate. The approval also vouches for the verifier of the stage it leaves and of the gate's stage. It never opens a gate two stages ahead; a gate already approved (after /lk back) is moved into without approving it again. At Validate plan, or opening it from Plan, it approves the planned file budget with the plan. At any other gate a planned budget that still waits comes first: this /lk approve approves the budget, the next one the gate. With the gate already open, or no gate, it approves a waiting budget. | |||||||
/lk waive | Waives the gate for the current task. | |||||||
| `/lk size small\ | large` | Sizes the task. | ||||||
/lk small-max <n> | The small-task limit for this session, 1 to 20. | |||||||
/lk preset <name> | Sets the stage preset for the project. | |||||||
| `/lk approval auto\ | ask` | Plan approval mode for the project. | ||||||
| `/lk auto on\ | off` | Automatic stage moves. | ||||||
/lk reset | Clears size, verdict, hold, waiver, files and the approved budget. | |||||||
| `/lk on\ | off` | GSD mode. | ||||||
| `/lk loop on\ | off` | The loop: the gsd preset and the plan gate with it. | ||||||
/lk reload | Re-reads loopkeeper.json for every concern. | |||||||
| `/lk band [stage\ | gates\ | files\ | routing\ | review\ | decisions] on\ | off` | Shows or hides a band. With no concern named, all of them. | |
/lk routes | Opens the routing pane. | |||||||
| `/lk route <recon\ | execute\ | verify\ | hard\ | default> <haiku\ | sonnet\ | opus\ | model-id>` | Overrides a role's model. Saved in the plugin store for every session on this machine until /lk route reset. |
/lk route reset | Drops every /lk route override. | |||||||
/lk explain <text> | Says which role and model a subagent with this description would get. | |||||||
/lk gates … | The gates, below. |
/lk is the way in. Claude Code 2.1.294 lets a plugin bind keys only inside its own panes and band, so there is no global shortcut. The keys work while a loopkeeper pane has the focus; a pane opened from the hub or the nav row takes the focus when it can (ADR 0014).
| Where | Key | What it does |
|---|---|---|
| Stage, Gates, Files, Routes, Reviews and Decisions panes | 0 to 6 | The nav row on the first line: 0 Hub, 1 Stage, 2 Gates, 3 Files, 4 Routes, 5 Reviews, 6 Decisions. Closes this pane and opens that one. |
| Hub | 1 to 6 | Opens that panel, as above. |
| Hub | m, u, h, t, w, p, s | The add-ons: Agents (/agent-map cards, lk-agent-map), Budget (/budget, lk-token-budget), Handoff (/handoff show, lk-handoff-guard), Attention (/attention, lk-attention), Where (/where, lk-where-am-i), PRs (/pr, lk-pr-watch), Secrets (/secret-guard, lk-secret-guard). Each shows only when that add-on's command is listed. On the desktop a press closes the hub and runs the command, which opens the mod's own pane. On the terminal, or if the run is refused, it fills an empty prompt box with the command and says "Press Enter"; with a draft it keeps the draft and names the command to type. |
| Hub | a | Approves what waits, the way /lk approve does (a planned budget before a gate other than Validate plan); shown only while something waits, or, while a task is in play, when /lk approve can open the next stage's gate. Then the model carries on. |
| Hub | o | Puts /lk on or /lk off in the prompt box. |
| Hub | c | Closes the hub. |
| Prompt box | ctrl+x tab | Focuses the band, or a pane that opened without the focus. On the terminal a toast says so, once per session; the desktop shows no such toast. |
A pane's own buttons keep their letter keys where they have one (b: Back, n: Next and so on); no digit is used by them. The Files pane's name filter takes every key while it has the focus, so digits switch panes there only once the focus leaves it.
Status chips in the panes (Not committed, Reading, ask, deny, asked, p0 to p2 and the like) are rounded Svg pills on the desktop app, VS Code and mobile: white text on a fill dark enough for a contrast ratio of 4.5:1 or more (the kit's CHIP_HEX). The terminal draws a chip as ● text in the tone's colour (ADR 0013).
/lk gatesFrom the gates' own usage line: pane, text, files, max <n>, plan ok, plan required on|off, plan paths, ship ok, ship gate on|off, merge ok, allow <path>, on, off, reload, band on|off.
/lk gates on|off is the session switch for every gate. /lk on|off is GSD mode. The two are different./lk gates max <n> sets a per-turn limit for the session. /lk gates reload is the same as /lk reload./lk gates plan ok allows edits for the session. While the pipeline's plan gate (gsd: Validate plan) is still pending, the reply says that gate is separate: moving past it still needs /lk approve (in auto approval a plan validator's consistent verdict also opens it)./lk gates ship ok opens shipping for the session and, like the Ship approval, lets the next push or merge to the default branch through. /lk gates merge ok lets only that next one through (once)./lk gates allow <path> allows a protected path whose rule is ask for the rest of the session, as Allow for session does in the terminal; the path is relative to the project root, or absolute. allow alone lists the paths allowed so far. A deny rule cannot be allowed this way.ship ok, merge ok and allow <path> count only when the user runs them; from another plugin they are refused.cd does not move them./reviews/reviews [text | status [subject] | clear <subject> | kinds | kind <subject> <kind> | target <n> [subject] |
max <n> [subject] | ship <subject> | reopen <subject> | stop <subject> |
band on|off | context on|off]
With no words it opens the review pane. kind is one of critic, review, plan, qa, copy, design.
/decide, /decisions, /files/decide <decision> -- <why> #tag #tag records a decision. /decide prefs [clear] lists or clears the learned preferences. /decide watch on|off|status and /decide watch model <haiku|sonnet|model id> control the decision watch (off by default)./decisions opens the decision pane. /decisions text, /decisions reload, and /decisions band on|off do the rest./files opens the Files pane: what Claude read and edited this session, and the project tree. It still opens when loopkeeper is off.Claude calls these tools; they are named mcp__loopkeeper__<name>.
| Tool | Inputs | What it does |
|---|---|---|
set_stage | stage (required), note, size (small or large), files (a number, for the plan's budget), verdict (consistent or issues) | Moves the stage, sizes the task, records the file count or a plan verdict. |
review_cycle | subject (required), kind, doneBy (score or items), score, target, maxCycles, cycle, verdict (ship, iterate or stop), items (text, priority p0/p1/p2, done) | Records one pass of a review loop. It is wrapped by the stage, which records a plan pass as the plan verdict when the review is done. |
review_item | subject, text (required), done, proof, priority | Adds an open item, or closes one with a proof (a path, output or commit). |
review_status | subject (optional) | Shows one subject's loop state, or all of them. |
record_decision | decision (required), why, tags | Appends a decision to the project's log. |
The loop is a list of stages. Each stage is a move in set_stage or /lk <stage>. The GSD preset is the full loop; the default preset is four plain stages.
| Stage id | Label | Gate | What it needs | ||||||
|---|---|---|---|---|---|---|---|---|---|
plan | Plan | Claude writes Goal, Steps, Done-when and the files it touches. Given as set_stage {stage:'plan', size:'large', files:n}. | |||||||
validate-plan | Validate plan | ⚑ gate, ⇄ verifier | A subagent whose type or description matches `valid\ | review\ | verif\ | critic\ | audit\ | plan must finish. Then set_stage {stage:'validate-plan', verdict:'consistent'\ | 'issues'}`. Consistent opens the gate in auto approval; in ask approval the user approves it. |
execute | Execute | The work, through subagents. Routing picks their models. | |||||||
validate | Validate | ⇄ verifier | A subagent matching `verif\ | valid\ | review\ | critic\ | audit\ | test\ | qa` must finish during this visit. |
qa | QA | Tests and the build run. | |||||||
ship | Ship | ⚑ gate | Shipping commands are held until the stage opens shipping (/lk approve). |
⚑ means the stage is a gate and waits for approval. ⇄ means a verifier subagent is required before the stage can move on. A verifier counts once per visit to the stage, except that a small task's waiver counts the first time.
default is plan, build, qa, ship, with no gates and no verifier. gsd is the full loop above. /lk preset <name> sets one for the project; a project can define its own presets in the pipeline section./lk on and /lk off switch GSD mode. /lk loop on runs the loop: with GSD mode and the loop both on, the stage uses the gsd preset and the gates require a plan. A change of mode moves the preset only while the loop is on, and /lk loop off goes back to the default preset. A session that starts with both on begins in gsd.set_stage {stage:'plan', size:'small'|'large'} or /lk size small|large. A small task is one clear change in at most pipeline.smallMaxFiles files (default 3). It waives the plan gate and the verifier. If a fourth file is about to be touched, the growth guard refuses it, the task goes back to Plan, and its size is cleared. A large task runs the full loop./lk approval auto (the default) lets a validator's consistent verdict open the plan gate. /lk approval ask keeps the gate closed until the user approves it: /lk approve, the band's Approve plan · n files button, the Stage pane's Approve gate button or the hub's a. /lk approve works from Plan too: it opens the Validate plan gate and moves there (see /lk approve in Commands). The mode is kept per project./lk approve it is sent just after the command answers. During a running turn it waits for that turn's end and is dropped if the stage has moved on by then; a budget-only approval during a turn sends nothing. It is never sent in a non-interactive session, with the kill switch on, or with pipeline.continueOnApprove: false. Non-interactive here means no person at the prompt and no app surface attached, as in a plain -p run; a desktop session counts as interactive even when the desktop app hosts Claude Code through the SDK. Model-side approvals (a validator's verdict, a plan review pass, ExitPlanMode) and /lk gates plan ok send nothing. At Ship, the user's /lk approve is the asking, so the model goes on into the commit or push (ADR 0012).loopkeeper · <Stage> (<n> of <m>) · <next step>, for example loopkeeper · QA (5 of 6) · Next: Ship. Claude moves on when it starts that work, or /lk approve opens Ship now. The next step is decided on the current stage, and the first case that applies wins:/lk approve at Ship, no /lk gates ship ok): "/lk approve when you want Claude to commit, push or open a PR."/lk gates ship ok on.A small task in a preset with a plan gate adds "Small task: the Validate plan gate is waived." The line shows only in an interactive session (see Continue after approval), with the kill switch off and pipeline.nextHint on, and only while a task is in play (sized, or the stage past the first). It does not show after a subagent's turn, a refusal, an interrupted turn or an API error. It is logged with $.ui.log, once per answered main turn and before any continue prompt: the terminal and the desktop draw it as a dim notice, the answer's text is left as it is, and the line is not sent to the model. The same sentence sits under the title of the hub and the Stage pane, and on the Next: line of /lk status.
/lk approve clears the hold./tmp/claude-*), .claude/plans/, docs/plans/, **/*.plan.md and **/*.plan.html are plan material. They are exempt from the plan gate and never count toward the file budget. The list is changeControl.planPaths. Paths resolve against the project root (where the session started, or where /cd or a worktree move took it), so a shell cd into a subfolder does not change what counts. Protected rules still apply to a plan file if its path matches one; the defaults match none.Claude Code's turn is the unit. loopkeeper counts the distinct files a turn edits (Edit, Write, MultiEdit, NotebookEdit and Bash writes it can see), and refuses the edit that would pass the limit unless someone allows it.
changeControl.maxFilesPerTurn is 10 (ADR 0011).set_stage {stage:'plan', size:'large', files:n} records the planned count. Approving the plan approves n + planSlack files (planSlack is 10). The text "13" is read as 13; "12 files", 2.5, 0 and negatives are refused. Approval comes from a validator's consistent verdict in auto approval, a plan review pass with no open p0 or p1 item, leaving Plan in a preset with no plan gate (below), /lk approve, the band's Approve plan · n files button, the Stage pane's Approve gate button, or the hub's a.set_stage or an automatic move, in auto approval with no hold, approves a waiting budget the way a validator's verdict would, up to autoBudgetMax. A move through ExitPlanMode does not (see Plan mode).autoBudgetMax (40) planned files. A larger budget leaves the plan gate open and waits for Approve budget · n files or /lk approve. This also works later, from Execute.ExitPlanMode) moves the stage on and satisfies the gates' plan check. It does not approve the planned file budget. The budget then waits for the band's Approve budget · n files button or /lk approve, and until then the base limit applies./lk gates max <n> override if one is set. Otherwise it is the largest of the base, the approved budget and the session raise.Allow 1 more, Allow rest of turn, Raise to <limit + planSlack> for this session, and Stop. With no plan, the raise reads Raise to 20. The figure is computed once, so the label and the effect match. Stop, a dismissal, or no one to ask (a non-interactive session) denies the edit and ends the turn 250 ms later. The question is put only in the terminal; on the desktop app, mobile or VS Code the edit is denied the same way and the refusal asks the user for /lk gates max <n>. A raise under a max override raises the override too.turn.start resets the turn's count. A subagent's turn does not reset it, and a subagent's edits count only when `changeControl.countSuhooks/register.ts 21 lines1// The only hooks module of loopkeeper. Each concern lives in its own file and is wired here with `on`.
2// The order below is the order of the bands above the prompt and of the lines /lk status prints.
3
4import type { On } from 'claude-code'
5
6import { wireDecisions } from './decisions.ts'
7import { wireFiles } from './files.ts'
8import { wireGates } from './gates.ts'
9import { wireReview } from './review.ts'
10import { wireRouting } from './routing.ts'
11import { wireStage } from './stage.ts'
12
13export function register(on: On): void {
14 wireStage(on)
15 wireGates(on)
16 wireFiles(on)
17 wireRouting(on)
18 wireReview(on)
19 wireDecisions(on)
20}
21hooks/decisions.ts 1107 lines1// Decisions concern: capture decisions into the project's own decision log from inside the session,
2// browse the recent ones, and (when switched on) watch turns for decisions worth keeping.
3//
4// - session.start: finds the log (config override, known files, ADR dir, default), registers /decide,
5// /decisions and the record_decision tool
6// - /decide and record_decision append one entry, never rewriting what is there
7// - /decisions opens a pane of the last 20; /decisions text prints them
8// - the band above the prompt counts this session's decisions, and holds the watch's major decisions
9// with Record and Skip (lib/watch.ts)
10// - /decide prefs and /decide watch … steer the watch, which is OFF until a config file or
11// /decide watch on turns it on
12// - the end of the /lk chain: /lk status adds the decisions line, anything no earlier concern owns
13// is answered here with one "unknown sub-command" line
14// - the hub (/lk) draws a Decisions row (hotkey 6), the last of the hub chain; the pane leads with the
15// nav bar of every loopkeeper pane
16//
17// The pure parts (log formats, watch parsing, dedupe, the band card) are in lib/; the engine side
18// stays in this file because `$` never crosses an import.
19
20import { atom, read, update } from 'claude-code'
21import type { EngineInterface, On } from 'claude-code'
22
23import { mergeConfigText } from './lib/config.ts'
24import {
25 ADR_DIR,
26 ADR_DIR_LOG,
27 CANDIDATES,
28 DECIDE_USAGE,
29 DECISIONS_USAGE,
30 DEFAULT_LOG,
31 absOf,
32 appendEntry,
33 bandText,
34 isAdrFile,
35 lastEntries,
36 parseDecideArgs,
37 parseEntries,
38 parseOverride,
39 stampOf,
40 textListing,
41 toolInputOf,
42 uniqueId,
43} from './lib/decisionFormats.ts'
44import type { DecisionInput, Entry, Kind, Proposed, Target, WatchConfig } from './lib/decisionFormats.ts'
45import { TONES, makeKit } from './lib/kit.ts'
46import type { Elements, Tone } from './lib/kit.ts'
47import { TOOL, TOOL_FULL, projectConfigPath, subOf, userConfigPath } from './lib/names.ts'
48import { HUB_ID, HUB_ROWS, PANES } from './lib/panes.ts'
49import {
50 KEY_BAND,
51 KEY_CALLS,
52 KEY_MODEL,
53 KEY_WATCH,
54 LOG_CONTEXT,
55 MAX_KINDS,
56 MAX_PENDING,
57 MAX_PREFS,
58 MAX_SKIPPED,
59 WATCH_DEFAULTS,
60 WATCH_MAX_TOKENS,
61 WATCH_SYSTEM,
62 WATCH_TIMEOUT_MS,
63 WATCH_USAGE,
64 buildWatchPrompt,
65 byOfJsonl,
66 cut,
67 decisionsLayers,
68 hasCue,
69 isModelId,
70 kindOf,
71 kindsKey,
72 majorCard,
73 makePool,
74 mergePrefs,
75 mergeWatchConfig,
76 minorToast,
77 normText,
78 parseWatchReply,
79 prefsKey,
80 recentCalls,
81 skippedKey,
82 strings,
83} from './lib/watch.ts'
84import type { Proposal } from './lib/watch.ts'
85
86const PANE_ID = 'loopkeeper-decisions'
87const LIST_LIMIT = 20
88const CONCERN_WORDS = ['stage', 'gates', 'files', 'routing', 'review', 'decisions']
89
90const decisionCount = atom({ plugin: 'loopkeeper', key: 'decisionCount' } as const, 0)
91const decisionBand = atom({ plugin: 'loopkeeper', key: 'decisionBand' } as const, true)
92const decisionTarget = atom({ plugin: 'loopkeeper', key: 'decisionTarget' } as const, null as Target | null)
93const decisionEntries = atom({ plugin: 'loopkeeper', key: 'decisionEntries' } as const, [] as Entry[])
94const decisionStats = atom({ plugin: 'loopkeeper', key: 'decisionStats' } as const, { total: 0, today: 0 })
95const decisionCwd = atom({ plugin: 'loopkeeper', key: 'decisionCwd' } as const, '')
96const decisionWatch = atom({ plugin: 'loopkeeper', key: 'decisionWatch' } as const, WATCH_DEFAULTS as WatchConfig)
97const decisionPending = atom({ plugin: 'loopkeeper', key: 'decisionPending' } as const, [] as Proposed[])
98const decisionTurnRecorded = atom({ plugin: 'loopkeeper', key: 'decisionTurnRecorded' } as const, [] as string[])
99const decisionTurnPrompt = atom({ plugin: 'loopkeeper', key: 'decisionTurnPrompt' } as const, '')
100const decisionPrefs = atom({ plugin: 'loopkeeper', key: 'decisionPrefs' } as const, [] as string[])
101const decisionKinds = atom({ plugin: 'loopkeeper', key: 'decisionKinds' } as const, [] as [string, Kind][])
102const decisionLogged = atom({ plugin: 'loopkeeper', key: 'decisionLogged' } as const, [] as string[])
103
104const BADGE: Record<Kind, { label: string; tone: Tone }> = {
105 major: { label: 'major', tone: 'accent' },
106 minor: { label: 'minor', tone: 'muted' },
107 you: { label: 'you', tone: 'info' },
108}
109
110type Recorded = { ok: true; file: string; id: string } | { ok: false; error: string }
111
112const messageOf = (err: unknown) => (err instanceof Error ? err.message : String(err))
113
114/** Logs a dim line in the transcript, never throwing. */
115function note($: EngineInterface, text: string): void {
116 try {
117 const r: unknown = $.ui.log(text)
118 if (r && typeof (r as Promise<unknown>).then === 'function') (r as Promise<unknown>).then(undefined, () => {})
119 } catch {
120 // nothing to do
121 }
122}
123
124/** Shows a toast, never throwing. */
125function toast($: EngineInterface, text: string): void {
126 try {
127 const r: unknown = $.ui.toast(text)
128 if (r && typeof (r as Promise<unknown>).then === 'function') (r as Promise<unknown>).then(undefined, () => {})
129 } catch {
130 // nothing to do
131 }
132}
133
134async function existsSafe($: EngineInterface, path: string): Promise<boolean> {
135 try {
136 return (await $.fs.exists(path)) === true
137 } catch {
138 return false
139 }
140}
141
142async function textOf($: EngineInterface, path: string): Promise<string | null> {
143 try {
144 return (await $.fs.exists(path)) ? await $.fs.read(path) : null
145 } catch {
146 return null
147 }
148}
149
150async function cwdOf($: EngineInterface): Promise<string> {
151 const known = await read($, decisionCwd)
152 if (known) return known
153 try {
154 return String((await $.session.cwd()) ?? '')
155 } catch {
156 return ''
157 }
158}
159
160/** The text of both config files. Local on purpose: $ is never passed across an import. */
161async function configTexts($: EngineInterface): Promise<{ user: string | null; project: string | null }> {
162 let home = ''
163 try {
164 home = String((await $.env.get('HOME')) ?? '')
165 } catch {
166 home = ''
167 }
168 const cwd = await cwdOf($)
169 return {
170 user: home ? await textOf($, userConfigPath(home)) : null,
171 project: cwd ? await textOf($, projectConfigPath(cwd)) : null,
172 }
173}
174
175async function nowMs($: EngineInterface): Promise<number> {
176 try {
177 const ms = Number(await $.clock.now())
178 if (Number.isFinite(ms) && ms > 0) return ms
179 } catch {
180 // fall back to the wall clock
181 }
182 return Date.now()
183}
184
185async function sessionIdOf($: EngineInterface): Promise<string> {
186 try {
187 const id = await $.session.id()
188 return typeof id === 'string' && id ? id : 'unknown'
189 } catch {
190 return 'unknown'
191 }
192}
193
194async function storeGet($: EngineInterface, key: string): Promise<unknown> {
195 try {
196 return await $.store.get(key)
197 } catch {
198 return undefined
199 }
200}
201
202/** Saves a value; false when the store refused it. */
203async function storeSet($: EngineInterface, key: string, value: unknown): Promise<boolean> {
204 try {
205 await $.store.set(key, value as never)
206 return true
207 } catch {
208 return false
209 }
210}
211
212/** Logs a watch problem once per session (per kind), so a failing API does not fill the transcript. */
213async function logWatchProblem($: EngineInterface, kind: string, text: string): Promise<void> {
214 const seen = await read($, decisionLogged)
215 if (seen.includes(kind)) return
216 await update($, decisionLogged, list => [...list, kind])
217 note($, text)
218}
219
220// ---------------------------------------------------------------- the log
221
222/** Copies the saved band setting from $.store into $.state. */
223async function loadBand($: EngineInterface): Promise<void> {
224 try {
225 const saved = await $.store.get(KEY_BAND)
226 await update($, decisionBand, () => saved !== false)
227 } catch {
228 // keep the default: on
229 }
230}
231
232/** Finds the target log and keeps it in $.state. */
233async function detect($: EngineInterface): Promise<Target> {
234 let cwd = ''
235 try {
236 cwd = String((await $.session.cwd()) ?? '')
237 } catch {
238 cwd = ''
239 }
240 await update($, decisionCwd, () => cwd)
241 let found: Target
242 try {
243 found = await findTarget($, cwd)
244 } catch (err) {
245 found = { file: DEFAULT_LOG, abs: absOf(cwd, DEFAULT_LOG), format: 'jsonl', exists: false, source: 'default', note: messageOf(err) }
246 }
247 await update($, decisionTarget, () => found)
248 return found
249}
250
251/** The detection order: config override, known logs, an ADR directory, the default. */
252async function findTarget($: EngineInterface, cwd: string): Promise<Target> {
253 let problem: string | undefined
254 const configPath = projectConfigPath(cwd)
255 if (await existsSafe($, configPath)) {
256 let raw = ''
257 try {
258 raw = await $.fs.read(configPath)
259 } catch (err) {
260 problem = `.claude/loopkeeper.json unreadable: ${messageOf(err)}`
261 }
262 // A config that names no log (other sections, or only watch settings) is not an override
263 const parsed = problem ? null : parseOverride(raw)
264 if (parsed && 'error' in parsed) {
265 problem = parsed.error
266 } else if (parsed) {
267 const abs = absOf(cwd, parsed.file)
268 return { file: parsed.file, abs, format: parsed.format, exists: await existsSafe($, abs), source: 'override' }
269 }
270 }
271 const withNote = (t: Target): Target => (problem ? { ...t, note: problem } : t)
272 for (const candidate of CANDIDATES) {
273 const abs = absOf(cwd, candidate.file)
274 if (await existsSafe($, abs)) return withNote({ ...candidate, abs, exists: true, source: 'found' })
275 }
276 const dir = absOf(cwd, ADR_DIR)
277 if (await existsSafe($, dir)) {
278 let names: string[] = []
279 try {
280 const listed = await $.fs.list(dir)
281 names = Array.isArray(listed) ? listed.map(entry => String(entry?.name ?? '')) : []
282 } catch {
283 names = []
284 }
285 if (names.some(isAdrFile)) {
286 return withNote({ file: ADR_DIR_LOG, abs: absOf(cwd, ADR_DIR_LOG), format: 'jsonl', exists: false, source: 'adr-dir' })
287 }
288 }
289 return withNote({ file: DEFAULT_LOG, abs: absOf(cwd, DEFAULT_LOG), format: 'jsonl', exists: false, source: 'default' })
290}
291
292/** The last entries (with who recorded them, when a jsonl log says so) and the counts, into $.state. */
293async function keepEntries($: EngineInterface, content: string | null, format: Target['format']): Promise<Entry[]> {
294 const by = format === 'jsonl' ? byOfJsonl(content) : new Map<string, 'user' | 'claude'>()
295 const list = lastEntries(content, format, LIST_LIMIT).map(entry => {
296 const who = by.get(normText(entry.decision))
297 return who ? { ...entry, by: who } : entry
298 })
299 const all = parseEntries(content, format)
300 const today = stampOf(await nowMs($)).date
301 await update($, decisionEntries, () => list)
302 await update($, decisionStats, () => ({ total: all.length, today: all.filter(x => x.date === today).length }))
303 return list
304}
305
306async function currentTarget($: EngineInterface): Promise<Target> {
307 const t = (await read($, decisionTarget)) as Target | null
308 return t ?? (await detect($))
309}
310
311/** Reads the target and keeps its last entries in $.state. */
312async function loadEntries($: EngineInterface): Promise<{ target: Target | null; list: Entry[] }> {
313 const t = await currentTarget($)
314 let content: string | null = null
315 let exists = false
316 try {
317 exists = await existsSafe($, t.abs)
318 if (exists) content = await $.fs.read(t.abs)
319 } catch (err) {
320 note($, `could not read ${t.file}: ${messageOf(err)}`)
321 }
322 const now = t.exists === exists ? t : { ...t, exists }
323 if (now !== t) await update($, decisionTarget, () => now)
324 const list = await keepEntries($, content, t.format)
325 return { target: now, list }
326}
327
328/** Every entry in the target log, in file order (the watch reads them to leave out what is recorded). */
329async function allEntries($: EngineInterface): Promise<Entry[]> {
330 const t = await currentTarget($)
331 try {
332 if (!(await existsSafe($, t.abs))) return []
333 return parseEntries(await $.fs.read(t.abs), t.format)
334 } catch {
335 return []
336 }
337}
338
339/** Appends one decision to the target: read, then write the old text plus the entry. */
340async function record($: EngineInterface, input: DecisionInput, by: 'user' | 'claude'): Promise<Recorded> {
341 try {
342 const t = await currentTarget($)
343 const exists = await existsSafe($, t.abs)
344 const existing = exists ? await $.fs.read(t.abs) : null
345 if (exists && typeof existing !== 'string') return { ok: false, error: `could not read ${t.file}` }
346 const stamp = stampOf(await nowMs($))
347 const id = uniqueId(stamp.id, existing)
348 const rec = { id, date: stamp.date, ...input, session: await sessionIdOf($), by }
349 const content = appendEntry(existing, t.format, rec)
350 if (existing !== null && !content.startsWith(existing)) return { ok: false, error: 'refused: the entry would change existing content' }
351 await $.fs.write(t.abs, content)
352 await update($, decisionCount, n => n + 1)
353 const written: Target = { ...t, exists: true }
354 await update($, decisionTarget, () => written)
355 await keepEntries($, content, t.format)
356 return { ok: true, file: t.file, id }
357 } catch (err) {
358 return { ok: false, error: messageOf(err) }
359 }
360}
361
362// ---------------------------------------------------------------- the decision watch
363
364/**
365 * The watch settings: defaults (off), the user file, the project file, then what /decide watch saved
366 * in the store. A bad value in any layer is ignored. Config problems in the decisions section are
367 * logged once per session.
368 */
369async function loadWatchConfig($: EngineInterface): Promise<WatchConfig> {
370 const texts = await configTexts($)
371 const saved = { watch: await storeGet($, KEY_WATCH), watchModel: await storeGet($, KEY_MODEL) }
372 const cfg = mergeWatchConfig([...decisionsLayers(texts.user, texts.project), saved])
373 await update($, decisionWatch, () => cfg)
374 try {
375 const problems = mergeConfigText(texts.user, texts.project).problems.filter(p => /^(?:user|project) decisions[ .:]/.test(p))
376 if (problems.length) await logWatchProblem($, 'config', 'loopkeeper: ' + problems.slice(0, 3).join('; '))
377 } catch {
378 // the settings above stand
379 }
380 return cfg
381}
382
383async function loadPrefs($: EngineInterface): Promise<string[]> {
384 const cwd = await cwdOf($)
385 const list = strings(await storeGet($, prefsKey(cwd))).slice(-MAX_PREFS)
386 await update($, decisionPrefs, () => list)
387 return list
388}
389
390async function loadKinds($: EngineInterface): Promise<void> {
391 const cwd = await cwdOf($)
392 const saved = await storeGet($, kindsKey(cwd))
393 const list: [string, Kind][] = Array.isArray(saved)
394 ? saved.filter(
395 (p): p is [string, Kind] =>
396 Array.isArray(p) && typeof p[0] === 'string' && (p[1] === 'major' || p[1] === 'minor' || p[1] === 'you'),
397 )
398 : []
399 await update($, decisionKinds, () => list.slice(-MAX_KINDS))
400}
401
402/** Loads the config, the preferences and the badges into $.state (session start, /clear, Refresh). */
403async function loadWatch($: EngineInterface): Promise<void> {
404 await loadWatchConfig($)
405 await loadPrefs($)
406 await loadKinds($)
407}
408
409async function markKind($: EngineInterface, text: string, kind: Kind): Promise<void> {
410 const n = normText(text)
411 if (!n) return
412 const cwd = await cwdOf($)
413 const list = [...(await read($, decisionKinds)).filter(p => p[0] !== n), [n, kind] as [string, Kind]].slice(-MAX_KINDS)
414 await update($, decisionKinds, () => list)
415 await storeSet($, kindsKey(cwd), list)
416}
417
418/** Notes a decision just recorded: its badge, and this turn's list unless the user typed it. */
419async function noteRecorded($: EngineInterface, text: string, kind: Kind): Promise<void> {
420 if (kind !== 'you') await update($, decisionTurnRecorded, list => [...list, text])
421 await markKind($, text, kind)
422}
423
424/** Turns the watch on or off and remembers it; off also drops the waiting majors. False when not saved. */
425async function setWatch($: EngineInterface, isOn: boolean): Promise<boolean> {
426 const saved = await storeSet($, KEY_WATCH, isOn)
427 await update($, decisionWatch, c => ({ ...c, watch: isOn }))
428 if (!isOn) await update($, decisionPending, () => [])
429 return saved
430}
431
432/** turn.start: a new turn has recorded nothing yet. */
433async function startTurn($: EngineInterface, prompt: unknown): Promise<void> {
434 await update($, decisionTurnRecorded, () => [])
435 await update($, decisionTurnPrompt, () => (typeof prompt === 'string' ? prompt : ''))
436}
437
438/** The last user message with text, and the last assistant text after it. */
439async function turnTexts($: EngineInterface, fallbackAnswer: unknown): Promise<{ prompt: string; answer: string }> {
440 let msgs: unknown[] = []
441 try {
442 const got: unknown = await $.session.messages()
443 if (Array.isArray(got)) msgs = got
444 } catch {
445 msgs = []
446 }
447 const textOfMsg = (m: unknown) => {
448 const t = (m as { text?: unknown } | null)?.text
449 return typeof t === 'string' ? t.trim() : ''
450 }
451 const roleOf = (m: unknown) => (m as { role?: unknown } | null)?.role
452 let at = -1
453 for (let i = msgs.length - 1; i >= 0; i--) {
454 if (roleOf(msgs[i]) === 'user' && textOfMsg(msgs[i])) {
455 at = i
456 break
457 }
458 }
459 let prompt = at >= 0 ? textOfMsg(msgs[at]) : ''
460 let answer = ''
461 for (let i = msgs.length - 1; i > at; i--) {
462 if (roleOf(msgs[i]) === 'assistant' && textOfMsg(msgs[i])) {
463 answer = textOfMsg(msgs[i])
464 break
465 }
466 }
467 if (!prompt) prompt = (await read($, decisionTurnPrompt)).trim()
468 if (!answer && typeof fallbackAnswer === 'string') answer = fallbackAnswer.trim()
469 return { prompt, answer }
470}
471
472/**
473 * The watch on one main turn: gates (on, length, cue word, hourly cap), one model call, the reply
474 * parsed and deduped. Null when no call was made or the reply was unusable (logged once).
475 * `log` is every entry in the log, `now` the time in ms. With the watch off nothing is called.
476 */
477async function proposeFromTurn($: EngineInterface, answerText: unknown, log: readonly Entry[], now: number): Promise<Proposal | null> {
478 const cfg = await loadWatchConfig($)
479 if (!cfg.watch) return null
480 const { prompt, answer } = await turnTexts($, answerText)
481 if (prompt.length + answer.length < cfg.watchMinChars) return null
482 if (!hasCue(prompt + '\n' + answer)) return null
483
484 const calls = recentCalls(await storeGet($, KEY_CALLS), now)
485 if (calls.length >= cfg.watchMaxPerHour) {
486 await logWatchProblem(
487 $,
488 'cap',
489 `loopkeeper: the watch is paused, ${calls.length} calls in the last hour (watchMaxPerHour ${cfg.watchMaxPerHour})`,
490 )
491 return null
492 }
493 await storeSet($, KEY_CALLS, [...calls, now])
494
495 const turn = await read($, decisionTurnRecorded)
496 const prefs = await loadPrefs($)
497 let reply: unknown
498 try {
499 reply = await $.model.complete({
500 model: cfg.watchModel,
501 system: WATCH_SYSTEM,
502 prompt: buildWatchPrompt({ log: log.slice(-LOG_CONTEXT).map(x => x.decision), turn, prefs, prompt, answer }),
503 maxTokens: WATCH_MAX_TOKENS,
504 timeoutMs: WATCH_TIMEOUT_MS,
505 })
506 } catch (err) {
507 await logWatchProblem($, 'refused', `loopkeeper: the watch call was refused (${messageOf(err)}); ignored`)
508 return null
509 }
510 const r = reply as { isAnswered?: unknown; text?: unknown; reason?: unknown } | null
511 if (!r || r.isAnswered !== true) {
512 const why = typeof r?.reason === 'string' ? r.reason : 'no answer'
513 await logWatchProblem($, 'no-reply', `loopkeeper: the watch got no reply (${why}); ignored`)
514 return null
515 }
516 const parsed = parseWatchReply(r.text)
517 if (!parsed) {
518 await logWatchProblem($, 'not-json', 'loopkeeper: the watch reply was not JSON; ignored')
519 return null
520 }
521
522 const cwd = await cwdOf($)
523 const skipped = strings(await storeGet($, skippedKey(cwd)))
524 const waiting = await read($, decisionPending)
525 const pool = makePool([...log.map(x => x.decision), ...skipped, ...turn, ...waiting.map(p => p.text)])
526 const out: Proposal = { minors: [], majors: [], preferences: parsed.preferences }
527 for (const d of parsed.decisions) {
528 if (pool.has(d.text)) continue
529 pool.add(d.text)
530 const item = { text: d.text, why: d.why, tags: d.tags }
531 if (d.weight === 'minor') out.minors.push(item)
532 else out.majors.push(item)
533 }
534 return out
535}
536
537/** After the minors were recorded: one toast, the majors queued for the band, the preferences merged. */
538async function finishTurn($: EngineInterface, recorded: readonly string[], p: Proposal): Promise<void> {
539 if (recorded.length) toast($, minorToast(recorded))
540 if (p.majors.length) await update($, decisionPending, list => [...list, ...p.majors].slice(-MAX_PENDING))
541 if (p.preferences.length) {
542 const cwd = await cwdOf($)
543 const merged = mergePrefs(await loadPrefs($), p.preferences)
544 await update($, decisionPrefs, () => merged)
545 await storeSet($, prefsKey(cwd), merged)
546 }
547}
548
549/** Runs the watch on a main turn: minors recorded at once, majors queued, preferences merged. */
550async function watchTurn($: EngineInterface, answer: unknown): Promise<void> {
551 const proposal = await proposeFromTurn($, answer, await allEntries($), await nowMs($))
552 if (!proposal) return
553 const recorded: string[] = []
554 for (const m of proposal.minors) {
555 const done = await record($, { decision: m.text, why: m.why, tags: m.tags }, 'claude')
556 if (done.ok) {
557 recorded.push(m.text)
558 await noteRecorded($, m.text, 'minor')
559 } else {
560 await logWatchProblem($, 'write', 'loopkeeper: a minor decision was not recorded: ' + done.error)
561 }
562 }
563 await finishTurn($, recorded, proposal)
564}
565
566/** Takes the oldest waiting major off the queue (Record or Skip pressed). */
567async function takeMajor($: EngineInterface): Promise<Proposed | null> {
568 const m = (await read($, decisionPending))[0]
569 if (!m) return null
570 await update($, decisionPending, l => l.filter(p => p.text !== m.text))
571 return m
572}
573
574/** Puts a major back at the front, after a failed Record. */
575async function returnMajor($: EngineInterface, m: Proposed): Promise<void> {
576 await update($, decisionPending, l => [m, ...l.filter(p => p.text !== m.text)].slice(0, MAX_PENDING))
577}
578
579/** Remembers a skipped major for this project, so the watch never offers it again. */
580async function skipMajor($: EngineInterface, m: Proposed): Promise<void> {
581 const cwd = await cwdOf($)
582 const n = normText(m.text)
583 const skipped = strings(await storeGet($, skippedKey(cwd))).filter(s => normText(s) !== n)
584 await storeSet($, skippedKey(cwd), [...skipped, m.text].slice(-MAX_SKIPPED))
585}
586
587/** Record or Skip on the band's major decision card. */
588async function answerMajor($: EngineInterface, choice: 'record' | 'skip'): Promise<void> {
589 const m = await takeMajor($)
590 if (!m) return
591 if (choice === 'skip') {
592 await skipMajor($, m)
593 return
594 }
595 const done = await record($, { decision: m.text, why: m.why, tags: m.tags }, 'claude')
596 if (done.ok) {
597 await noteRecorded($, m.text, 'major')
598 toast($, 'recorded: ' + cut(m.text, 60))
599 } else {
600 // Back to the front, so Record can be pressed again (or Skip)
601 await returnMajor($, m)
602 toast($, 'loopkeeper: not recorded: ' + done.error)
603 }
604}
605
606async function watchStatus($: EngineInterface, now: number): Promise<string> {
607 const cfg = await loadWatchConfig($)
608 const calls = recentCalls(await storeGet($, KEY_CALLS), now)
609 const waiting = (await read($, decisionPending)).length
610 const prefs = (await loadPrefs($)).length
611 return (
612 `Decision watch ${cfg.watch ? 'on' : 'off'} · model ${cfg.watchModel} · ` +
613 `${calls.length}/${cfg.watchMaxPerHour} calls in the last hour · turns of ${cfg.watchMinChars}+ characters with a cue word · ` +
614 `${waiting} major pending · ${prefs} learned preference${prefs === 1 ? '' : 's'}`
615 )
616}
617
618/** /decide prefs …, /decide watch …; null when the text is a decision to record. */
619async function watchCommand($: EngineInterface, args: unknown, now: number): Promise<{ text: string } | null> {
620 const text = (typeof args === 'string' ? args : '').trim().replace(/\s+/g, ' ')
621 const lower = text.toLowerCase()
622 if (lower === 'prefs' || lower === 'preferences') {
623 const prefs = await loadPrefs($)
624 if (!prefs.length) return { text: 'No learned preferences yet. The decision watch adds them as it reads your turns.' }
625 return { text: [`Learned preferences (${prefs.length}, newest last):`, ...prefs.map(p => '- ' + p)].join('\n') }
626 }
627 if (lower === 'prefs clear' || lower === 'preferences clear') {
628 const cwd = await cwdOf($)
629 const n = (await loadPrefs($)).length
630 await update($, decisionPrefs, () => [])
631 const saved = await storeSet($, prefsKey(cwd), [])
632 return { text: saved ? `Cleared ${n} learned preference${n === 1 ? '' : 's'}.` : 'Cleared for this session; not saved.' }
633 }
634 if (lower === 'watch' || lower === 'watch status') return { text: await watchStatus($, now) }
635 if (lower === 'watch on' || lower === 'watch off') {
636 const isOn = lower === 'watch on'
637 const saved = await setWatch($, isOn)
638 const cfg = await loadWatchConfig($)
639 const what = isOn ? `Decision watch on (model ${cfg.watchModel}).` : 'Decision watch off. No model calls until /decide watch on.'
640 return { text: saved ? what : what + ' Not saved: the store refused it.' }
641 }
642 const model = /^watch model(?: (\S+))?$/i.exec(text)
643 if (model) {
644 const id = model[1] ?? ''
645 if (!isModelId(id)) {
646 const watchConfig = await loadWatchConfig($)
647 return { text: `Watch model is ${watchConfig.watchModel}. ${WATCH_USAGE}` }
648 }
649 const saved = await storeSet($, KEY_MODEL, id)
650 await update($, decisionWatch, c => ({ ...c, watchModel: id }))
651 return { text: saved ? `Watch model: ${id}.` : `Watch model: ${id} for this session; not saved.` }
652 }
653 // A mistyped subcommand ("prefs clr", "watch off now") gets the usage instead of being recorded
654 if (/^(?:prefs|preferences) \S+$/i.test(text) || /^watch (?:on|off|status) \S+$/i.test(text)) return { text: WATCH_USAGE }
655 return null
656}
657
658// ---------------------------------------------------------------- the band switch and /lk
659
660/** Sets the decisions band on or off for the session and in the store (the same key /lk band uses). */
661async function setBand($: EngineInterface, isOn: boolean): Promise<boolean> {
662 await update($, decisionBand, () => isOn)
663 return storeSet($, KEY_BAND, isOn)
664}
665
666async function statusLine($: EngineInterface): Promise<string> {
667 const waiting = (await read($, decisionPending)).length
668 const pending = waiting > 0 ? ` · ${waiting} major pending` : ''
669 const count = await read($, decisionCount)
670 const watching = (await read($, decisionWatch)).watch
671 return `decisions ● ${count} this session · watch ${watching ? 'on' : 'off'}${pending}`
672}
673
674// The end of the /lk chain. status adds this concern's line; reload, band and on/off are shared words
675// that earlier concerns handled first; anything else nobody owned is unknown, once.
676async function lk($: EngineInterface, e: { args: string }): Promise<{ text: string }> {
677 const { sub, rest, text } = subOf(e.args)
678 if (sub === 'status') return { text: await statusLine($) }
679 if (sub === 'reload') {
680 await loadBand($)
681 const found = await detect($)
682 await loadWatch($)
683 const status = await statusLine($)
684 return { text: `decisions ● reloaded: ${found.file} (${found.format}) · ${status}` }
685 }
686 if (sub === 'band') {
687 const words = rest.map(w => w.toLowerCase())
688 const named = words.filter(w => CONCERN_WORDS.includes(w))
689 // Another concern was named: nothing for this one to do
690 if (named.length > 0 && !named.includes('decisions')) return { text: '' }
691 const word = words.find(w => w === 'on' || w === 'off')
692 if (!word) {
693 const shown = await read($, decisionBand)
694 return { text: `decisions ● band ${shown ? 'on' : 'off'}` }
695 }
696 const saved = await setBand($, word === 'on')
697 return { text: `decisions ● band ${word}${saved ? '' : ' (this session only)'}` }
698 }
699 // GSD mode is another concern's switch; the watch stays as it is.
700 if (sub === 'on' || sub === 'off') return { text: '' }
701 return { text: `loopkeeper: unknown sub-command "${text}". Try /lk status, /lk stage (the Stage pane) or /lk help (the keys).` }
702}
703
704// ---------------------------------------------------------------- the hub and the nav bar
705
706// HUB_ID, HUB_ROWS and PANES (every loopkeeper pane in wiring order) come from lib/panes.ts.
707
708// the "ctrl+x tab to focus the panel" toast showed this session (stage.ts's atom, shared by all six concern files)
709const stageFocusHinted = atom({ plugin: 'loopkeeper', key: 'stageFocusHinted' } as const, false)
710
711// Switch panes: close `fromId` first ('' closes nothing), since a focus request is refused while the person holds a
712// pane, then open `toId` with the focus. The composer keeps the focus while it holds a draft: then a toast says how to
713// reach the pane, once a session and only in the terminal (the press's `surface`; with no press, a session whose every
714// surface is the terminal). False when the pane could not be opened.
715async function switchTo($: EngineInterface, fromId: string, toId: string, title: string, surface?: unknown): Promise<boolean> {
716 if (fromId) {
717 try {
718 await $.ui.close({ id: fromId })
719 } catch {
720 // already closed
721 }
722 }
723 try {
724 await $.ui.open({ id: toId, title, focus: true, closeOnEscape: true, ...(toId === HUB_ID ? { rows: HUB_ROWS } : {}) })
725 } catch {
726 return false
727 }
728 try {
729 const pane = (await $.ui.panes()).find(p => p.id === toId)
730 if (pane && !pane.isFocused && !(await read($, stageFocusHinted))) {
731 const terminal = typeof surface === 'string' ? surface === 'terminal' : (await $.session.surfaces()).every(s => s === 'terminal')
732 if (terminal) {
733 await update($, stageFocusHinted, () => true)
734 toast($, 'ctrl+x tab to focus the panel')
735 }
736 }
737 } catch {
738 // no list: no hint
739 }
740 return true
741}
742
743// The nav bar's items: every loopkeeper pane, a press switching from `fromId` to it
744function navItems($: EngineInterface, fromId: string) {
745 return PANES.map(p => ({
746 key: p.key,
747 label: p.label,
748 hotkey: p.hotkey,
749 onPress: async (e?: { surface?: unknown }) => {
750 if (!(await switchTo($, fromId, p.id, p.title, e?.surface))) toast($, 'Could not open the ' + p.label + ' pane.')
751 },
752 }))
753}
754
755/** The hub row's words: the status line without its label (`2 this session · watch off`), and `band off` when hidden. */
756async function hubWords($: EngineInterface): Promise<string> {
757 const words = (await statusLine($)).replace(/^decisions ● /, '')
758 return (await read($, decisionBand)) ? words : words + ' · band off'
759}
760
761// ---------------------------------------------------------------- wiring
762
763export function wireDecisions(on: On): void {
764 on('session.start', { isInteractive: [true, false] }, async ($, e, next) => {
765 try {
766 await loadBand($)
767 await detect($)
768 await loadWatch($)
769 } catch {
770 // Defaults stand.
771 }
772 try {
773 await $.tool.register({
774 name: TOOL.recordDecision,
775 description:
776 "Record a decision in this project's decision log (the file loopkeeper found: a decisions.jsonl, " +
777 'a decisions.md under .claude/brain or a DECISION_JOURNAL.md). Call it once, right after the user and you ' +
778 'settle a design or implementation choice worth remembering later: a library or architecture pick, ' +
779 'a convention, a trade-off, a rejected alternative. Do not call it for routine edits or for choices ' +
780 'still under discussion. Write the decision as one plain sentence, the reason in "why", and a few ' +
781 'short lowercase tags. The entry is appended; nothing already in the log is changed.',
782 inputSchema: {
783 type: 'object',
784 properties: {
785 decision: { type: 'string', description: 'The decision, as one sentence' },
786 why: { type: 'string', description: 'Why it was chosen, briefly' },
787 tags: { type: 'array', items: { type: 'string' }, description: 'Short tags, without #' },
788 },
789 required: ['decision'],
790 },
791 isDeferred: false,
792 })
793 } catch (err) {
794 note($, 'loopkeeper: record_decision not registered: ' + messageOf(err))
795 }
796 try {
797 await $.command.register({
798 name: 'decide',
799 description: "Record a decision in this project's decision log",
800 argumentHint: '<decision> -- <why> #tag | prefs [clear] | watch on|off|status | watch model <id>',
801 })
802 } catch (err) {
803 note($, 'loopkeeper: /decide not registered: ' + messageOf(err))
804 }
805 try {
806 await $.command.register({
807 name: 'decisions',
808 description: 'Browse recent decisions (pane), or: text | reload | band on|off',
809 argumentHint: '[text | reload | band on|off]',
810 immediate: true,
811 })
812 } catch (err) {
813 note($, 'loopkeeper: /decisions not registered: ' + messageOf(err))
814 }
815 return next(e)
816 })
817
818 // /clear, /resume and /branch reset $.state without a new session.start
819 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
820 try {
821 await loadBand($)
822 await detect($)
823 await loadWatch($)
824 } catch {
825 // Defaults stand.
826 }
827 return next(e)
828 }).catch(async (_$, e, next) => next(e))
829
830 on('command.run', { command: 'decide' }, async ($, e) => {
831 const sub = await watchCommand($, e?.args, await nowMs($))
832 if (sub) return sub
833 const input = parseDecideArgs(e?.args)
834 if (!input.decision) return { text: DECIDE_USAGE + '\n' + WATCH_USAGE }
835 const done = await record($, input, 'user')
836 if (!done.ok) return { text: 'Not recorded: ' + done.error }
837 await noteRecorded($, input.decision, 'you')
838 toast($, 'Recorded in ' + done.file)
839 return { text: `Recorded ${done.id} in ${done.file}` }
840 }).catch(async () => ({ text: 'Not recorded: loopkeeper failed while handling /decide.' }))
841
842 on('command.run', { command: 'decisions' }, async ($, e) => {
843 const args = (typeof e?.args === 'string' ? e.args : '').trim().toLowerCase().split(/\s+/).filter(Boolean)
844 const verb = args[0] ?? ''
845 if (verb === '' || verb === 'open' || verb === 'pane') {
846 await loadEntries($)
847 try {
848 await $.ui.open({ id: PANE_ID, title: 'Decisions', focus: true, closeOnEscape: true })
849 } catch (err) {
850 return { text: 'Could not open the pane: ' + messageOf(err) }
851 }
852 return {}
853 }
854 if (verb === 'text') {
855 const loaded = await loadEntries($)
856 return { text: textListing(loaded.target, loaded.list) }
857 }
858 if (verb === 'reload') {
859 const found = await detect($)
860 const loaded = await loadEntries($)
861 const extra = found.note ? ` (${found.note})` : ''
862 return { text: `Target: ${found.file} (${found.format}, ${found.source})${extra}. ${loaded.list.length} recent entries.` }
863 }
864 if (verb === 'band') {
865 const word = args[1] ?? ''
866 if (word !== 'on' && word !== 'off') {
867 const isOn = await read($, decisionBand)
868 return { text: `Band is ${isOn ? 'on' : 'off'}. Use /decisions band on|off.` }
869 }
870 const saved = await setBand($, word === 'on')
871 return { text: saved ? `Band ${word}.` : `Band ${word} for this session; not saved.` }
872 }
873 return { text: DECISIONS_USAGE }
874 }).catch(async () => ({ text: 'loopkeeper: /decisions failed.' }))
875
876 on('command.run', { command: 'lk' }, async ($, e) => lk($, e)).catch(async () => ({ text: 'decisions ● failed' }))
877 on('command.run', { command: 'loopkeeper' }, async ($, e) => lk($, e)).catch(async () => ({ text: 'decisions ● failed' }))
878
879 on('tool.call', { tool: TOOL_FULL.recordDecision }, async ($, e) => {
880 const input = toolInputOf(e)
881 if (!input.decision) return { result: 'Not recorded: record_decision needs a non-empty "decision".' }
882 const done = await record($, input, 'claude')
883 if (!done.ok) return { result: 'Not recorded: ' + done.error }
884 await noteRecorded($, input.decision, 'major')
885 toast($, 'Recorded in ' + done.file)
886 return { result: `Recorded ${done.id} in ${done.file}` }
887 }).catch(async () => ({ result: 'Not recorded: loopkeeper failed while writing the decision.' }))
888
889 // The decision watch (lib/watch.ts): a turn starts with nothing recorded, and a main turn that ended
890 // with an answer is read once by a small model call, when the watch is on.
891 on('turn.start', async ($, e, next) => {
892 await startTurn($, e?.text)
893 return next(e)
894 })
895
896 on('turn.complete', async ($, e, next) => {
897 const out = await next(e)
898 const ended = e as { agentId?: unknown; isAborted?: unknown; reason?: unknown; answer?: unknown } | undefined
899 // A subagent's turn carries agentId; an interrupted or failed turn settled nothing
900 if (ended && !ended.agentId && ended.isAborted !== true && ended.reason !== 'error' && ended.reason !== 'refusal') {
901 try {
902 await watchTurn($, ended.answer)
903 } catch (err) {
904 await logWatchProblem($, 'failed', 'loopkeeper: the watch failed: ' + messageOf(err))
905 }
906 }
907 return out
908 })
909
910 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
911 if (e?.props?.hasSurvey) return next(e)
912 const isOn = await read($, decisionBand)
913 const n = await read($, decisionCount)
914 const waiting = (await read($, decisionPending)).length
915 const showLine = isOn && n > 0
916 if (!showLine && waiting === 0) return next(e)
917 const ui = $.ui.resolve(e)
918 const { Box } = ui
919 const k = makeKit(ui as unknown as Elements, e?.surface ?? 'terminal')
920 const parts: unknown[] = []
921 // A major decision from the watch waits here for Record or Skip, whatever the band setting
922 if (waiting > 0) {
923 parts.push(
924 majorCard(
925 k,
926 await read($, decisionPending),
927 () => answerMajor($, 'record'),
928 () => answerMajor($, 'skip'),
929 ),
930 )
931 }
932 if (showLine) {
933 const watching = (await read($, decisionWatch)).watch === true
934 parts.push(k.line('decisions', [{ text: bandText(n), tone: 'info' }], watching ? 'watch on' : undefined))
935 }
936 // Keep what the concerns after this one draw in the band, under ours.
937 const theirs = await next(e)
938 if (!theirs && parts.length === 1) return parts[0] as never
939 return Box({ flexDirection: 'column', children: (theirs ? [...parts, theirs] : parts) as never[] })
940 })
941
942 // The hub's Decisions row, the last of the hub chain: `6: Decisions` and the status words, quiet, above what the
943 // engine draws beneath (next, often null). It shows with the band off; a failure draws what the chain drew.
944 on('ui.render', { component: 'Pane', requestId: HUB_ID }, async ($, e, next) => {
945 let asked = false
946 let theirs: unknown = null
947 try {
948 const ui = $.ui.resolve(e) as unknown as Elements
949 const k = makeKit(ui, e?.surface ?? 'terminal')
950 const row = k.row({
951 key: 'hub-decisions',
952 left: [
953 ui.Button({
954 key: 'panel-decisions',
955 label: 'Decisions',
956 hotkey: '6',
957 plain: true,
958 onPress: async (press?: { surface?: unknown }) => {
959 if (!(await switchTo($, HUB_ID, PANE_ID, 'Decisions', press?.surface))) toast($, 'Could not open the Decisions pane.')
960 },
961 }),
962 k.text(await hubWords($), { color: TONES.muted.fg, wrap: 'truncate' }),
963 ],
964 })
965 asked = true
966 theirs = await next(e)
967 return (theirs === null || theirs === undefined ? row : k.stack([row, theirs])) as never
968 } catch {
969 return (asked ? theirs : await next(e)) as never
970 }
971 })
972
973 // The pane: the nav bar, the hero and tiles, the target and controls, the major decisions pending, the recent
974 // decisions and the learned preferences.
975 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e, next) => {
976 if (e?.requestId !== PANE_ID) return next(e)
977 const ui = $.ui.resolve(e)
978 const { Box, Text } = ui
979 const k = makeKit(ui as unknown as Elements, e?.surface ?? 'terminal')
980 const t = (await read($, decisionTarget)) as Target | null
981 const list = await read($, decisionEntries)
982 const st = await read($, decisionStats)
983 const cfg = await read($, decisionWatch)
984 // the watch button: on → Pause; paused by the user (the stored value is false) → Resume; off by default or by a
985 // config file → Turn on
986 const watchLabel = cfg.watch ? 'Pause watch' : (await storeGet($, KEY_WATCH)) === false ? 'Resume watch' : 'Turn watch on'
987 const waiting = await read($, decisionPending)
988 const prefs = await read($, decisionPrefs)
989 const kinds = new Map(await read($, decisionKinds))
990 const head = t
991 ? `${t.file} · ${t.format}${t.exists ? '' : ' · created on first record'}`
992 : 'no target yet: run /decisions reload'
993
994 const hero = k.hero({
995 kicker: 'decisions',
996 title: `${st.today} decision${st.today === 1 ? '' : 's'} today`,
997 sub: list[0] ? 'Last: ' + list[0].decision : 'Nothing recorded yet',
998 tone: 'accent',
999 })
1000 const tiles = k.tiles([
1001 { label: 'Total', value: String(st.total) },
1002 { label: 'Today', value: String(st.today), tone: st.today > 0 ? 'info' : undefined },
1003 { label: 'Major pending', value: String(waiting.length), tone: waiting.length > 0 ? 'warn' : undefined },
1004 { label: 'Watch', value: cfg.watch ? 'on' : 'off', tone: cfg.watch ? 'ok' : 'muted' },
1005 ])
1006
1007 const controls = k.buttons([
1008 {
1009 key: 'refresh',
1010 label: 'Refresh',
1011 hotkey: 'r',
1012 plain: true,
1013 autoFocus: true,
1014 onPress: async () => {
1015 await detect($)
1016 await loadEntries($)
1017 await loadWatch($)
1018 },
1019 },
1020 {
1021 key: 'close',
1022 label: 'Close',
1023 hotkey: 'c',
1024 plain: true,
1025 onPress: async () => {
1026 try {
1027 await $.ui.close({ id: PANE_ID })
1028 } catch {
1029 // already closed
1030 }
1031 },
1032 },
1033 {
1034 key: 'watch-toggle',
1035 label: watchLabel,
1036 hotkey: 'w',
1037 plain: true,
1038 onPress: async () => {
1039 await setWatch($, !cfg.watch)
1040 },
1041 },
1042 ])
1043
1044 const rows = list.map((entry, i) => {
1045 const kind = kindOf(entry, kinds)
1046 const lines = [
1047 Box({
1048 flexDirection: 'row',
1049 columnGap: 1,
1050 children: [
1051 ...(kind ? [k.badge(BADGE[kind].label, BADGE[kind].tone)] : []),
1052 Text({ bold: true, children: [entry.date || '----------'] }),
1053 Text({ wrap: 'wrap', children: [entry.decision] }),
1054 ] as never[],
1055 }),
1056 ]
1057 if (entry.why) lines.push(Text({ color: TONES.muted.fg, wrap: 'wrap', children: [' ' + entry.why] }))
1058 if (entry.tags.length) {
1059 lines.push(Text({ color: TONES.info.fg, children: [' ' + entry.tags.map(tag => '#' + tag).join(' ')] }))
1060 }
1061 return Box({ key: 'entry-' + i, flexDirection: 'column', children: lines })
1062 })
1063
1064 const blocks: unknown[] = [
1065 k.nav(navItems($, PANE_ID), 'decisions'),
1066 hero,
1067 tiles,
1068 Box({
1069 flexDirection: 'column',
1070 children: [
1071 Text({ color: TONES.muted.fg, wrap: 'truncate-middle', children: [head] }),
1072 ...(t?.note ? [Text({ color: TONES.warn.fg, children: ['Note: ' + t.note] })] : []),
1073 controls,
1074 ],
1075 }),
1076 ]
1077 if (waiting.length) {
1078 blocks.push(
1079 k.stack([
1080 k.section('Major pending', 'r: Record · s: Skip on the band'),
1081 ...waiting.map((p, i) =>
1082 k.row({ key: 'pending-' + i, left: [k.badge('major', 'accent'), Text({ wrap: 'wrap', children: [p.text] })] }),
1083 ),
1084 ]),
1085 )
1086 }
1087 blocks.push(
1088 k.stack([
1089 k.section('Recent decisions', String(list.length)),
1090 // kit 3.1: one row between entries (list), each entry tight inside
1091 rows.length
1092 ? k.list(rows)
1093 : Text({ color: TONES.muted.fg, children: ['No decisions yet. Record one with /decide <decision> -- <why> #tag'] }),
1094 ]),
1095 )
1096 blocks.push(
1097 k.stack([
1098 k.section('Learned preferences', String(prefs.length)),
1099 ...(prefs.length
1100 ? [...prefs].reverse().map(p => Text({ wrap: 'wrap', children: ['· ' + p] }))
1101 : [Text({ color: TONES.muted.fg, children: ['None yet. The decision watch learns them from your turns.'] })]),
1102 ]),
1103 )
1104 return k.page(blocks) as never
1105 })
1106}
1107hooks/files.ts 599 lines1// Files concern, ported from change-control: the Files pane (/files, /lk gates files) and git status.
2//
3// It watches rather than rules. tool.call hooks on Read, NotebookRead, Glob, Grep and the edit tools set "what Claude
4// is doing right now" before `await next(e)`, clear it after, and record the file when the call was not refused and did
5// not fail; prompt.submit records the files a prompt @-mentions. Bash writes come from the gates: only they parse Bash, so
6// gates.ts records the writes it lets through into the same atoms (filesTouched, filesActivity.last).
7// Every change to filesTouched and filesActivity goes through update (compare-and-set) and never writes a stale copy,
8// so the gates' Bash writes and parallel tool calls all land.
9// The pane's state and pure helpers live in lib/filesModel.ts, its tree in lib/filesPane.ts. The band line
10// (`files ● N touched`, only once something was touched) follows the gates band switch: files has none of its own.
11
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, On } from 'claude-code'
14
15import type { LkAnyNext, LkFilesActivity, LkFilesGit, LkFilesLive, LkFilesView, LkFolderListing, LkNext, LkTouchEntry } from '../types/index.d.ts'
16import {
17 absOf,
18 applyGitStatus,
19 clearActivity,
20 CREATING,
21 emptyFiles,
22 foldersToList,
23 noteEdit,
24 noteRead,
25 noteSearch,
26 parentOf,
27 setActivity,
28 toListing,
29 touchedCount,
30} from './lib/filesModel.ts'
31import type { FilesState } from './lib/filesModel.ts'
32import { FILES_PANE, filesTree } from './lib/filesPane.ts'
33import type { FilesActions } from './lib/filesPane.ts'
34import { editPath, normalizePath } from './lib/gateRules.ts'
35import { TONES, makeKit } from './lib/kit.ts'
36import type { Elements } from './lib/kit.ts'
37import { CMD, DISABLE_ENV, PLUGIN, joinText, subOf } from './lib/names.ts'
38import { HUB_ID, HUB_ROWS, PANES } from './lib/panes.ts'
39import { killSwitchOn } from './lib/stageModel.ts'
40
41// As change-control watched them. Claude Code 2.1.294 lists no Glob, Grep, NotebookRead or MultiEdit among its built-in
42// tools (the typed matcher refuses those names), so the matchers are patterns: where the tools exist they are watched.
43const READ_TOOLS = /^(?:Read|NotebookRead)$/
44const SEARCH_TOOLS = /^(?:Glob|Grep)$/
45const EDIT_TOOLS = /^(?:Edit|Write|MultiEdit|NotebookEdit)$/
46// git runs after each recorded edit and on Refresh: short timeouts keep a hook well under its 10 s
47const GIT_TIMEOUT_MS = 2_000
48// folders listed in one go (open, Refresh) and in one drawing
49const LOAD_FOLDERS_MAX = 40
50const RENDER_LIST_MAX = 10
51// a file the listing gives no time for is stat'ed, at most this many per folder
52const STAT_MAX = 50
53// folder listings kept in $.state; the oldest go first (Refresh drops them all)
54const LISTINGS_KEEP = 80
55
56const filesTouched = atom({ plugin: 'loopkeeper', key: 'filesTouched' } as const, [] as LkTouchEntry[])
57const filesActivity = atom({ plugin: 'loopkeeper', key: 'filesActivity' } as const, { live: [], last: '' } as LkFilesLive)
58const filesGit = atom({ plugin: 'loopkeeper', key: 'filesGit' } as const, null as LkFilesGit | null)
59const filesView = atom({ plugin: 'loopkeeper', key: 'filesView' } as const, { root: '', expanded: [], showSizes: false, showHidden: false, filter: '' } as LkFilesView)
60const filesListing = atom({ plugin: 'loopkeeper', key: 'filesListing' } as const, {} as Record<string, LkFolderListing>)
61// Declared by gates.ts too: the Files band line follows the gates band switch.
62const gateBand = atom({ plugin: 'loopkeeper', key: 'gateBand' } as const, true)
63
64// ---- The module's own bits: the kill switch, the activity ids, the git roots ------------------------------------
65
66let killed: boolean | null = null
67let hydrated = false
68let actSeq = 0
69// Activity ids carry this module's tag, so calls in flight left by a module before a hot reload can be told apart.
70const MODULE_TAG = Math.random().toString(36).slice(2, 8) || 'm'
71const gitRoots = new Map<string, string>()
72
73/** A call to watch; `creating` (a Write of a missing file) ends its id in CREATING, for the hero's plus. */
74type Watch = { verb: LkFilesActivity['verb']; path: string; pattern?: string; tool: string; creating?: boolean }
75type Failure = { error?: { kind?: string; message?: string }; called?: boolean }
76
77/** CLAUDE_MODS_DISABLE names loopkeeper (or all): nothing is tracked and no band line is drawn; /files still opens. */
78async function isKilled($: EngineInterface): Promise<boolean> {
79 if (killed !== null) return killed
80 let raw: unknown = ''
81 try {
82 raw = await $.env.get('CLAUDE_MODS_DISABLE')
83 } catch {
84 raw = ''
85 }
86 killed = killSwitchOn(raw)
87 return killed
88}
89
90async function sessionCwd($: EngineInterface): Promise<string> {
91 try {
92 return (await $.session.cwd()).replace(/\/+$/, '') || '/'
93 } catch {
94 return '/'
95 }
96}
97
98/** Once per module: drop the calls in flight a previous module left (they never clear). */
99async function hydrate($: EngineInterface): Promise<void> {
100 if (hydrated) return
101 hydrated = true
102 const stale = (a: LkFilesActivity) => !String(a.id).startsWith(`${MODULE_TAG}:`)
103 const now = await read($, filesActivity)
104 if (now.live.some(stale)) await update($, filesActivity, a => ({ live: a.live.filter(x => !stale(x)), last: a.last }))
105}
106
107/** A path a tool names, as the pane keys it (relative to the session folder when inside it; '' is the folder itself), or null to leave the call alone. */
108async function trackedPath($: EngineInterface, raw: unknown): Promise<string | null> {
109 if (typeof raw !== 'string' || raw === '') return null
110 try {
111 if (await isKilled($)) return null
112 await hydrate($)
113 const cwd = await sessionCwd($)
114 const p = normalizePath(raw, cwd)
115 return p === cwd ? '' : p
116 } catch {
117 return null
118 }
119}
120
121/** The @path tokens in a prompt (a quoted @"a b.md" too), without a `#L…` line suffix or trailing punctuation; at most 20. */
122function mentionedPaths(text: unknown): string[] {
123 if (typeof text !== 'string' || !text.includes('@')) return []
124 const found: string[] = []
125 for (const m of text.matchAll(/(?:^|\s)@(?:"([^"\n]+)"|(\S+))/g)) {
126 const raw = (m[1] ?? m[2] ?? '').replace(/#L\d.*$/, '').replace(/[.,;:!?)\]}'"]+$/, '')
127 if (raw !== '' && !found.includes(raw)) found.push(raw)
128 if (found.length >= 20) break
129 }
130 return found
131}
132
133/** A tool call's result that counts: not refused (deny) and not failed (isError). */
134function succeeded(r: unknown): boolean {
135 if (typeof r !== 'object' || r === null) return false
136 const o = r as { deny?: unknown; isError?: unknown }
137 return o.deny === undefined && o.isError !== true
138}
139
140/** A change to the touched list, then the hero's "last:" text; each through update, from the value now in $.state. */
141async function noteTouch($: EngineInterface, change: (s: FilesState) => FilesState): Promise<void> {
142 let last = ''
143 await update($, filesTouched, touched => {
144 const s = change({ ...emptyFiles(), touched: [...touched] })
145 last = s.last
146 return s.touched
147 })
148 if (last !== '') await update($, filesActivity, a => ({ live: a.live, last }))
149}
150
151/** A change to the calls in flight, through update. */
152async function changeLive($: EngineInterface, change: (s: FilesState) => FilesState): Promise<void> {
153 await update($, filesActivity, a => {
154 const s = change({ ...emptyFiles(), live: [...a.live], last: a.last })
155 return { live: s.live, last: s.last }
156 })
157}
158
159/** "Claude is <verb> <path>" while the call runs, then the change recorded when the call succeeded. */
160async function watchCall($: EngineInterface, e: unknown, next: LkAnyNext, watch: Watch, change: (s: FilesState) => FilesState): Promise<never> {
161 const { creating, ...act } = watch
162 const id = `${MODULE_TAG}:${++actSeq}${creating ? CREATING : ''}`
163 try {
164 await changeLive($, s => setActivity(s, { ...act, id, at: Date.now() }))
165 } catch {
166 // The call goes ahead; the hero misses it.
167 }
168 let r: unknown
169 try {
170 r = await next(e)
171 } finally {
172 try {
173 await changeLive($, s => clearActivity(s, id))
174 } catch {
175 // Dropped by the next module's hydrate at worst.
176 }
177 }
178 if (succeeded(r)) {
179 try {
180 await noteTouch($, change)
181 } catch {
182 // The result stands; the pane misses one file.
183 }
184 }
185 return r as never
186}
187
188/** Whether a file is there; true when that cannot be told, so a doubtful Write reads as Edited. */
189async function fileExists($: EngineInterface, path: string): Promise<boolean> {
190 try {
191 return await $.fs.exists(absOf(await sessionCwd($), path))
192 } catch {
193 return true
194 }
195}
196
197// ---- git -----------------------------------------------------------------------------------------------------------
198
199/** `git status --porcelain` and the branch, into filesGit; outside a repository (or without git) the git line goes away. */
200async function refreshGit($: EngineInterface): Promise<void> {
201 try {
202 const cwd = await sessionCwd($)
203 const status = await $.process.run(['git', 'status', '--porcelain'], { cwd, timeoutMs: GIT_TIMEOUT_MS }).catch(() => null)
204 if (status === null || status.exitCode !== 0) {
205 if ((await read($, filesGit)) !== null) await update($, filesGit, () => null)
206 return
207 }
208 const branch = await $.process.run(['git', 'branch', '--show-current'], { cwd, timeoutMs: GIT_TIMEOUT_MS }).catch(() => null)
209 const name = branch !== null && branch.exitCode === 0 ? branch.stdout.trim() : ''
210 const root = await gitRoot($, cwd)
211 const git = applyGitStatus(emptyFiles(), status.stdout, name || 'HEAD', { root, cwd }).git
212 await update($, filesGit, () => git)
213 } catch {
214 // Skipped silently.
215 }
216}
217
218/** Porcelain paths are relative to the repository root; asked once per folder, the folder itself when git cannot say. */
219async function gitRoot($: EngineInterface, cwd: string): Promise<string> {
220 const known = gitRoots.get(cwd)
221 if (known !== undefined) return known
222 const r = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd, timeoutMs: GIT_TIMEOUT_MS }).catch(() => null)
223 const root = r !== null && r.exitCode === 0 && r.stdout.trim() !== '' ? r.stdout.trim() : cwd
224 gitRoots.set(cwd, root)
225 return root
226}
227
228// ---- The pane: open, list folders, the buttons ------------------------------------------------------------------
229
230/** Before the pane opens, however it opens (the ui.open hook): list the tree's open folders and read git. */
231async function preloadFiles($: EngineInterface): Promise<void> {
232 try {
233 const cwd = await sessionCwd($)
234 const view = await read($, filesView)
235 await loadFolders($, foldersToList(view.root || cwd, view.expanded), false)
236 await refreshGit($)
237 } catch {
238 // The pane lists what it can when it draws.
239 }
240}
241
242/** /files and /lk gates files: open the pane (the ui.open hook preloads it). */
243async function openFiles($: EngineInterface): Promise<void> {
244 await $.ui.open({ id: FILES_PANE, title: 'Files', focus: true, closeOnEscape: true })
245}
246
247/** Lists folders into filesListing: the ones missing, or (replace) all of them afresh, as Refresh does. */
248async function loadFolders($: EngineInterface, dirs: string[], replace: boolean): Promise<void> {
249 const have = replace ? {} : await read($, filesListing)
250 const todo = [...new Set(dirs)].filter(d => replace || have[d] === undefined).slice(0, LOAD_FOLDERS_MAX)
251 if (todo.length === 0 && !replace) return
252 const listed = await Promise.all(todo.map(d => listFolder($, d)))
253 const loaded: Record<string, LkFolderListing> = {}
254 todo.forEach((d, i) => {
255 loaded[d] = listed[i] as LkFolderListing
256 })
257 await update($, filesListing, l => {
258 const merged: Record<string, LkFolderListing> = replace ? { ...loaded } : { ...l, ...loaded }
259 const keys = Object.keys(merged)
260 for (const k of keys.slice(0, Math.max(0, keys.length - LISTINGS_KEEP))) if (!(k in loaded)) delete merged[k]
261 return merged
262 })
263}
264
265/** One folder from $.fs.list, sorted and capped; a file the listing gives no time for is stat'ed (at most STAT_MAX). */
266async function listFolder($: EngineInterface, abs: string): Promise<LkFolderListing> {
267 try {
268 const listing = toListing(await $.fs.list(abs))
269 const undated = listing.entries.filter(x => x.kind === 'file' && x.mtimeMs <= 0).slice(0, STAT_MAX)
270 await Promise.all(
271 undated.map(async entry => {
272 try {
273 const st = await $.fs.stat(abs === '/' ? `/${entry.name}` : `${abs}/${entry.name}`)
274 if (typeof st?.mtimeMs === 'number' && Number.isFinite(st.mtimeMs)) entry.mtimeMs = st.mtimeMs
275 } catch {
276 // No date for this file.
277 }
278 }),
279 )
280 return listing
281 } catch (err) {
282 const message = String((err as { message?: unknown } | null)?.message ?? err).replace(/\s+/g, ' ')
283 return { entries: [], total: 0, error: message.slice(0, 80) || 'unreadable' }
284 }
285}
286
287function filesActions($: EngineInterface, cwd: string): FilesActions {
288 const setView = (fn: (v: LkFilesView) => LkFilesView) => update($, filesView, fn)
289 return {
290 refresh: async () => {
291 const v = await read($, filesView)
292 await loadFolders($, foldersToList(v.root || cwd, v.expanded), true)
293 await refreshGit($)
294 },
295 collapseAll: () => setView(v => ({ ...v, expanded: [] })),
296 toggleSizes: () => setView(v => ({ ...v, showSizes: !v.showSizes })),
297 toggleHidden: () => setView(v => ({ ...v, showHidden: !v.showHidden })),
298 up: async () => {
299 const v = await read($, filesView)
300 const cur = v.root || cwd
301 const parent = parentOf(cur)
302 if (parent === cur) return
303 await loadFolders($, [parent], false)
304 await setView(x => ({ ...x, root: parent === cwd ? '' : parent }))
305 },
306 back: async () => {
307 await loadFolders($, [cwd], false)
308 await setView(v => ({ ...v, root: '' }))
309 },
310 toggleDir: async (abs: string) => {
311 const v = await read($, filesView)
312 const open = v.expanded.includes(abs)
313 if (!open) await loadFolders($, [abs], false)
314 await setView(x => ({ ...x, expanded: open ? x.expanded.filter(d => d !== abs) : [...x.expanded.filter(d => d !== abs), abs] }))
315 },
316 find: (value: string) => setView(v => ({ ...v, filter: String(value ?? '') })),
317 }
318}
319
320// ---- /files, /lk gates files and the /lk status line ------------------------------------------------------------
321
322async function statusLine($: EngineInterface): Promise<string> {
323 if (await isKilled($)) return `files ● off · ${DISABLE_ENV} names ${PLUGIN}, so nothing is tracked (/files still opens)`
324 const n = touchedCount(await read($, filesTouched))
325 const last = (await read($, filesActivity)).last
326 return `files ● ${n} touched${last ? ` · last: ${last}` : ''}`
327}
328
329const failText = (next: any) =>
330 `${PLUGIN}: the Files pane could not open (${next.error?.kind ?? 'throw'}: ${String(next.error?.message ?? '').slice(0, 120)}).`
331
332const isGatesFiles = (args: unknown) => {
333 const { sub, rest } = subOf(args)
334 return sub === 'gates' && (rest[0] ?? '').toLowerCase() === 'files'
335}
336
337type LkResult = { text?: string }
338
339/** /lk gates files opens the pane (the gates pass that word on); /lk status gets the files line. */
340async function lk($: EngineInterface, e: { args?: unknown }, next: LkNext): Promise<LkResult> {
341 if (isGatesFiles(e?.args)) {
342 await openFiles($)
343 return {}
344 }
345 const { sub, rest } = subOf(e?.args)
346 if (sub === 'status') {
347 const after = await next(e)
348 let line = ''
349 try {
350 line = await statusLine($)
351 } catch {
352 line = `${PLUGIN}: the files line could not be read.`
353 }
354 return { ...(after ?? {}), text: joinText(line, after?.text) }
355 }
356 if (sub === 'band' && rest.some(w => w.toLowerCase() === 'files')) {
357 // files has no band switch of its own: say which one it follows
358 const after = await next(e)
359 return { ...(after ?? {}), text: joinText('The files line follows the gates band: /lk band gates on|off.', after?.text) }
360 }
361 return (await next(e)) ?? {}
362}
363
364async function lkFailed($: EngineInterface, e: { args?: unknown }, next: any): Promise<LkResult> {
365 if (next.called) return (await next(e)) ?? {}
366 if (isGatesFiles(e?.args)) return { text: failText(next) }
367 return (await next(e)) ?? {}
368}
369
370// ---- The hub row and the nav bar -----------------------------------------------------------------------------
371
372/** The hub row's words: the band line without its label (`14 touched · last: read README.md`), or why it is off. */
373async function hubWords($: EngineInterface): Promise<string> {
374 if (await isKilled($)) return `off (${DISABLE_ENV}): nothing is tracked`
375 const n = touchedCount(await read($, filesTouched))
376 const last = (await read($, filesActivity)).last
377 const band = await read($, gateBand)
378 return [`${n} touched`, ...(last ? [`last: ${last}`] : []), ...(band ? [] : ['band off'])].join(' · ')
379}
380
381// HUB_ID, HUB_ROWS and PANES (every loopkeeper pane in wiring order) come from lib/panes.ts.
382
383function toast($: EngineInterface, text: string): void {
384 try {
385 const p = $.ui.toast(text) as unknown
386 if (p && typeof (p as Promise<unknown>).catch === 'function') (p as Promise<unknown>).catch(() => undefined)
387 } catch {
388 // display only
389 }
390}
391
392// the "ctrl+x tab to focus the panel" toast showed this session (stage.ts's atom, shared by all six concern files)
393const stageFocusHinted = atom({ plugin: 'loopkeeper', key: 'stageFocusHinted' } as const, false)
394
395// Switch panes: close `fromId` first ('' closes nothing), since a focus request is refused while the person holds a
396// pane, then open `toId` with the focus. The composer keeps the focus while it holds a draft: then a toast says how to
397// reach the pane, once a session and only in the terminal (the press's `surface`; with no press, a session whose every
398// surface is the terminal). False when the pane could not be opened.
399async function switchTo($: EngineInterface, fromId: string, toId: string, title: string, surface?: unknown): Promise<boolean> {
400 if (fromId) {
401 try {
402 await $.ui.close({ id: fromId })
403 } catch {
404 // already closed
405 }
406 }
407 try {
408 await $.ui.open({ id: toId, title, focus: true, closeOnEscape: true, ...(toId === HUB_ID ? { rows: HUB_ROWS } : {}) })
409 } catch {
410 return false
411 }
412 try {
413 const pane = (await $.ui.panes()).find(p => p.id === toId)
414 if (pane && !pane.isFocused && !(await read($, stageFocusHinted))) {
415 const terminal = typeof surface === 'string' ? surface === 'terminal' : (await $.session.surfaces()).every(s => s === 'terminal')
416 if (terminal) {
417 await update($, stageFocusHinted, () => true)
418 toast($, 'ctrl+x tab to focus the panel')
419 }
420 }
421 } catch {
422 // no list: no hint
423 }
424 return true
425}
426
427// The nav bar's items: every loopkeeper pane, a press switching from `fromId` to it
428function navItems($: EngineInterface, fromId: string) {
429 return PANES.map(p => ({
430 key: p.key,
431 label: p.label,
432 hotkey: p.hotkey,
433 onPress: async (e?: { surface?: unknown }) => {
434 if (!(await switchTo($, fromId, p.id, p.title, e?.surface))) toast($, 'Could not open the ' + p.label + ' pane.')
435 },
436 }))
437}
438
439// ---- Wiring ----------------------------------------------------------------------------------------------------
440
441export function wireFiles(on: On): void {
442 on('session.start', { isInteractive: [true, false] }, async ($, e, next) => {
443 try {
444 await $.command.register({
445 name: CMD.files,
446 description: 'Files: what Claude is reading right now, every file it touched this session, and the project tree',
447 immediate: true,
448 })
449 } catch {
450 // The name is taken; /lk gates files still opens the pane.
451 }
452 return next(e)
453 })
454
455 // /clear, /resume and /branch start a new conversation: the touched list, the hero and git start over.
456 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
457 try {
458 await update($, filesTouched, () => [])
459 await update($, filesActivity, () => ({ live: [], last: '' }))
460 await update($, filesGit, () => null)
461 } catch {
462 // The old list stays until the next change.
463 }
464 return next(e)
465 }).catch(($, e, next) => next(e))
466
467 on('tool.call', { tool: READ_TOOLS }, async ($, e, next) => {
468 const path = await trackedPath($, editPath(e))
469 if (path === null) return next(e)
470 const tool = String(e.tool)
471 return watchCall($, e, next, { verb: 'reading', path, tool }, s => noteRead(s, path, 'read', tool))
472 }).catch(($, e, next) => next(e))
473
474 on('tool.call', { tool: SEARCH_TOOLS }, async ($, e, next) => {
475 const args = e as unknown as Record<string, unknown>
476 const pattern = typeof args.pattern === 'string' ? args.pattern : ''
477 const where = typeof args.path === 'string' && args.path !== '' ? args.path : '.'
478 const dir = pattern === '' ? null : await trackedPath($, where)
479 if (dir === null) return next(e)
480 const tool = String(e.tool)
481 return watchCall($, e, next, { verb: 'searching', path: dir, pattern, tool }, s => noteSearch(s, tool, pattern, dir))
482 }).catch(($, e, next) => next(e))
483
484 on('tool.call', { tool: EDIT_TOOLS }, async ($, e, next) => {
485 const path = await trackedPath($, editPath(e))
486 if (path === null) return next(e)
487 const tool = String(e.tool)
488 const existed = tool === 'Write' ? await fileExists($, path) : true
489 const r = await watchCall($, e, next, { verb: 'editing', path, tool, creating: !existed }, s => noteEdit(s, path, !existed, tool))
490 if (succeeded(r)) await refreshGit($)
491 return r
492 }).catch(($, e, next) => next(e))
493
494 // A file a prompt @-mentions is read for Claude: recorded as opened when it exists. The prompt passes on unchanged.
495 on('prompt.submit', {}, async ($, e, next) => {
496 try {
497 for (const raw of mentionedPaths(e.text)) {
498 const path = await trackedPath($, raw)
499 if (path === null || path === '') continue
500 const there = await $.fs.exists(absOf(await sessionCwd($), path)).catch(() => false)
501 if (there) await noteTouch($, s => noteRead(s, path, 'mention'))
502 }
503 } catch {
504 // The prompt stands; the pane misses a file.
505 }
506 return next(e)
507 }).catch(($, e, next) => next(e))
508
509 on('command.run', { command: 'files' }, async $ => {
510 await openFiles($)
511 return {}
512 }).catch(async ($, e, next) => ({ text: failText(next) }))
513
514 on('command.run', { command: 'lk' }, async ($, e, next) => lk($, e, next)).catch(($, e, next) => lkFailed($, e, next))
515 on('command.run', { command: 'loopkeeper' }, async ($, e, next) => lk($, e, next)).catch(($, e, next) => lkFailed($, e, next))
516
517 // Before the Files pane opens its folders are listed and git read: here for an open from a hook (/files, /lk gates
518 // files); a Button's onPress runs beneath every hook, so its open skips this plugin's own ui.open hooks.
519 on('ui.open', {}, async ($, e, next) => {
520 if ((e as { id?: unknown }).id === FILES_PANE) await preloadFiles($)
521 return next(e)
522 })
523
524 // ... and here for a press that opens it: the hub's Files row, or the Files item of a loopkeeper pane's nav bar.
525 on('ui.press', { plugin: PLUGIN, element: ['panel-files', 'files'] }, async ($, e, next) => {
526 await preloadFiles($)
527 return next(e)
528 })
529
530 // The hub's Files row, above the later concerns' rows (next): `3: Files` and the band's words, quiet. It shows
531 // with the band off or the kill switch on; a failure draws what the chain drew.
532 on('ui.render', { component: 'Pane', requestId: HUB_ID }, async ($, e, next) => {
533 let asked = false
534 let theirs: unknown = null
535 try {
536 const ui = $.ui.resolve(e) as unknown as Elements
537 const kit = makeKit(ui, e.surface)
538 const row = kit.row({
539 key: 'hub-files',
540 left: [
541 ui.Button({
542 key: 'panel-files',
543 label: 'Files',
544 hotkey: '3',
545 plain: true,
546 onPress: async (press?: { surface?: unknown }) => {
547 if (!(await switchTo($, HUB_ID, FILES_PANE, 'Files', press?.surface))) toast($, 'Could not open the Files pane.')
548 },
549 }),
550 kit.text(await hubWords($), { color: TONES.muted.fg, wrap: 'truncate' }),
551 ],
552 })
553 asked = true
554 theirs = await next(e)
555 return (theirs === null || theirs === undefined ? row : kit.stack([row, theirs])) as never
556 } catch {
557 return (asked ? theirs : await next(e)) as never
558 }
559 })
560
561 // The band line: `files ● N touched`, once something was touched, while the gates band is on.
562 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
563 const props = (e.props ?? {}) as { hasSurvey?: boolean }
564 let mine: unknown = null
565 try {
566 if (props.hasSurvey !== true && !(await isKilled($)) && (await read($, gateBand))) {
567 const n = touchedCount(await read($, filesTouched))
568 if (n > 0) mine = makeKit($.ui.resolve(e) as unknown as Elements, e.surface).line('files', [`${n} touched`])
569 }
570 } catch {
571 mine = null
572 }
573 if (mine === null) return next(e)
574 const { Box } = $.ui.resolve(e)
575 const theirs = await next(e)
576 if (theirs === null || theirs === undefined) return mine as ReturnType<typeof Box>
577 return Box({ flexDirection: 'column', children: [mine as ReturnType<typeof Box>, theirs] })
578 })
579
580 // The Files pane, the nav bar first. A drawing may read state, not write it: a folder not loaded yet is listed for
581 // this drawing only.
582 on('ui.render', { component: 'Pane', requestId: FILES_PANE }, async ($, e) => {
583 const cwd = await sessionCwd($)
584 const view = await read($, filesView)
585 const listings = { ...(await read($, filesListing)) }
586 const missing = foldersToList(view.root || cwd, view.expanded).filter(d => listings[d] === undefined).slice(0, RENDER_LIST_MAX)
587 for (const d of missing) listings[d] = await listFolder($, d)
588 const activity = await read($, filesActivity)
589 const files: FilesState = { touched: await read($, filesTouched), live: activity.live, last: activity.last, git: await read($, filesGit) }
590 // HOME draws a folder under it as `~/…`; the body's width sizes the hero's divider.
591 const home = String((await $.env.get('HOME').catch(() => undefined)) ?? '')
592 const columns = Number((e.props as { bodyColumns?: unknown } | undefined)?.bodyColumns) || undefined
593 const ui = $.ui.resolve(e) as unknown as Elements
594 const kit = makeKit(ui, e.surface)
595 const nav = kit.nav(navItems($, FILES_PANE), 'files')
596 return filesTree(kit, ui, { cwd, home, columns, files, view, listings }, filesActions($, cwd), nav) as never
597 })
598}
599hooks/gates.ts 1547 lines1// Gates concern, ported from change-control's register.ts. The rules that hold Claude's hand at the tool call:
2//
3// 1. Bounded blast radius: a files-per-turn limit per main-loop turn. The limit is the `/lk gates max <n>` session
4// override when set, otherwise the largest of the base (changeControl.maxFilesPerTurn), the approved plan's file
5// budget (stageBudget, written by the stage) and the session raise ("Raise to N for this session").
6// 2. Protected paths: globs that are denied, or need the user's yes once per path per session (the terminal asks;
7// elsewhere the user runs `/lk gates allow <path>`).
8// 3. A plan before edits (requirePlan): the stage's stagePlanDone, ExitPlanMode, or `/lk gates plan ok`. Plan
9// material (the scratchpad under /tmp/claude-*, and changeControl.planPaths) is exempt from this gate and rule 1.
10// 4. A ship gate: commits, pushes, PRs, releases and publishes wait for the stage's stageShipOpen or
11// `/lk gates ship ok` (ADR 0004: the stage is always there, so there is no one-question fallback).
12// 5. A merge gate: merges and pushes to the default branch wait for green CI and a yes. The user's Ship approval
13// (or `/lk gates ship ok` or `merge ok`) is that yes for the next one; otherwise the terminal asks. `git commit
14// --amend` on shared branches and (opt-in) `git add -A` / `git add .` are refused.
15//
16// Every verdict is given at tool.check, after the settings rules and the hooks beneath, so a call they already refuse
17// is never asked about twice. A question is put only in the terminal: off it (the desktop app, mobile, VS Code) a
18// dialog from a permission check is never answered, so the refusal names the command that lets the call through. A
19// hook that throws fails closed. CLAUDE_MODS_DISABLE=loopkeeper (or all) turns the gates off; the band says so and
20// /lk gates still answers.
21//
22// The Files pane lives in files.ts. The seam: Bash writes that pass the gates are recorded here into filesTouched and
23// filesActivity.last (the atoms the pane reads), because only the gates parse Bash for the files it writes.
24
25import { atom, read, update } from 'claude-code'
26import type { EngineInterface, On } from 'claude-code'
27
28import {
29 bandModel,
30 bashTargets,
31 checkLayer,
32 contextText,
33 describeRules,
34 editPath,
35 failClosedReason,
36 filesOptions,
37 gitActions,
38 GATES_CMD,
39 isPlanMaterial,
40 limitOf,
41 limitSource,
42 matchProtected,
43 mergeHitsDefault,
44 mergeLayers,
45 MOD,
46 normalizePath,
47 noteBashWrites,
48 parsePorcelain,
49 planPathsText,
50 PROJECT_LABEL,
51 pushHitsDefault,
52 raiseTo,
53 sectionLayer,
54 shipCommands,
55 summarizeChecks,
56 USAGE,
57 USER_LABEL,
58} from './lib/gateRules.ts'
59import type { GitAction, Layer } from './lib/gateRules.ts'
60import { TONES, makeKit, shortPath } from './lib/kit.ts'
61import type { Elements, Kit, Tone } from './lib/kit.ts'
62import { joinText, projectConfigPath, storeKey, subOf, userConfigPath } from './lib/names.ts'
63import { HUB_ID, HUB_ROWS, PANES } from './lib/panes.ts'
64import { bare, cdTargets, prNumber, resolveDir } from './lib/shell.ts'
65import { DEFAULT_STAGES, killSwitchOn, stageForRole } from './lib/stageModel.ts'
66import type { Stage } from './lib/stageModel.ts'
67import type { LkFilesLive, LkGateConfig, LkGateLogRow, LkNext, LkTouchEntry } from '../types/index.d.ts'
68
69const PANE_ID = 'loopkeeper-gates'
70const CONTEXT_BLOCK = 'changeControl'
71const LOG_KEEP = 20
72const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
73const ABORT_DELAY_MS = 250
74const CONCERN_WORDS = ['stage', 'gates', 'files', 'routing', 'review', 'decisions']
75
76const ASK_PROTECTED = ['Allow once', 'Allow for session', 'Deny'] as const
77const ASK_MERGE = ['Merge', 'Cancel'] as const
78
79const BAND_KEY = storeKey('gates', 'band')
80
81// ---- State held by the host (survives a hot reload; read by the band and the pane) ---------------------------
82
83const gateEnabled = atom({ plugin: 'loopkeeper', key: 'gateEnabled' } as const, true)
84const gateBand = atom({ plugin: 'loopkeeper', key: 'gateBand' } as const, true)
85const gateDisabled = atom({ plugin: 'loopkeeper', key: 'gateDisabled' } as const, false)
86const gateConfig = atom({ plugin: 'loopkeeper', key: 'gateConfig' } as const, null as LkGateConfig | null)
87const gateSources = atom({ plugin: 'loopkeeper', key: 'gateSources' } as const, {} as Record<string, string>)
88const gateConfigFiles = atom({ plugin: 'loopkeeper', key: 'gateConfigFiles' } as const, [] as string[])
89const gateConfigErrors = atom({ plugin: 'loopkeeper', key: 'gateConfigErrors' } as const, [] as string[])
90const gateTurnId = atom({ plugin: 'loopkeeper', key: 'gateTurnId' } as const, '')
91const gateTurnFiles = atom({ plugin: 'loopkeeper', key: 'gateTurnFiles' } as const, [] as string[])
92const gateTurnExtra = atom({ plugin: 'loopkeeper', key: 'gateTurnExtra' } as const, 0)
93const gateTurnUnlimited = atom({ plugin: 'loopkeeper', key: 'gateTurnUnlimited' } as const, false)
94const gateTurnFired = atom({ plugin: 'loopkeeper', key: 'gateTurnFired' } as const, false)
95const gateSessionFiles = atom({ plugin: 'loopkeeper', key: 'gateSessionFiles' } as const, [] as string[])
96const gatePlanOk = atom({ plugin: 'loopkeeper', key: 'gatePlanOk' } as const, false)
97const gatePlanBy = atom({ plugin: 'loopkeeper', key: 'gatePlanBy' } as const, '')
98const gateShipOk = atom({ plugin: 'loopkeeper', key: 'gateShipOk' } as const, false)
99const gateGrants = atom({ plugin: 'loopkeeper', key: 'gateGrants' } as const, [] as string[])
100const gateLog = atom({ plugin: 'loopkeeper', key: 'gateLog' } as const, [] as LkGateLogRow[])
101const gateSessionRaise = atom({ plugin: 'loopkeeper', key: 'gateSessionRaise' } as const, 0)
102// The user's yes for the next push or merge to the default branch, spent by it: the Ship approval (stage.ts sets it
103// and clears it on leaving the ship stage), /lk gates ship ok or /lk gates merge ok.
104const gateMergeCredit = atom({ plugin: 'loopkeeper', key: 'gateMergeCredit' } as const, false)
105// Declared by stage.ts too, with the same defaults: one value (ADR 0004).
106const stageCurrent = atom({ plugin: 'loopkeeper', key: 'stageCurrent' } as const, 'plan')
107const stagePlanDone = atom({ plugin: 'loopkeeper', key: 'stagePlanDone' } as const, false)
108const stageShipOpen = atom({ plugin: 'loopkeeper', key: 'stageShipOpen' } as const, true)
109const stageBudget = atom({ plugin: 'loopkeeper', key: 'stageBudget' } as const, 0)
110const stagePlanFiles = atom({ plugin: 'loopkeeper', key: 'stagePlanFiles' } as const, null as number | null)
111// the pipeline's stages: which ones are gated, and their labels for the deny texts
112const stageStages = atom({ plugin: 'loopkeeper', key: 'stageStages' } as const, DEFAULT_STAGES.map(s => ({ ...s })) as Stage[])
113// Declared by routing.ts and stage.ts too: GSD mode and its loop switch.
114const routeMode = atom({ plugin: 'loopkeeper', key: 'routeMode' } as const, false)
115const routeLoop = atom({ plugin: 'loopkeeper', key: 'routeLoop' } as const, true)
116// The Files pane's atoms (files.ts reads them); the gates write Bash writes into them.
117const filesTouched = atom({ plugin: 'loopkeeper', key: 'filesTouched' } as const, [] as LkTouchEntry[])
118const filesActivity = atom({ plugin: 'loopkeeper', key: 'filesActivity' } as const, { live: [], last: '' } as LkFilesLive)
119
120// ---- The module's own copy, so parallel tool.check calls count against one Set -------------------------------
121// Decisions read and change these synchronously; every change is mirrored to $.state.
122
123type Mem = {
124 turnId: string
125 turnFiles: Set<string>
126 turnExtra: number
127 turnUnlimited: boolean
128 turnFired: boolean
129 sessionFiles: Set<string>
130 grants: Set<string>
131 aborting: string
132}
133
134const fresh = (): Mem => ({
135 turnId: '',
136 turnFiles: new Set(),
137 turnExtra: 0,
138 turnUnlimited: false,
139 turnFired: false,
140 sessionFiles: new Set(),
141 grants: new Set(),
142 aborting: '',
143})
144
145let mem: Mem = fresh()
146let hydrated = false
147let killed: boolean | null = null
148
149type CheckResult = { decision: 'allow' | 'ask' | 'deny'; reason?: string; rule?: string }
150
151// ---- Fail closed, and the kill switch (both borrowed from scope-guard) ---------------------------------------
152
153/** The same refusal's reason for an error caught inside a gating hook. */
154function refusedReason(err: unknown): string {
155 const text = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
156 return failClosedReason('throw', text)
157}
158
159/** CLAUDE_MODS_DISABLE names loopkeeper (or all): read once, kept for the module's life and in gateDisabled. */
160async function isKilled($: EngineInterface): Promise<boolean> {
161 if (killed !== null) return killed
162 let raw: unknown = ''
163 try {
164 raw = await $.env.get('CLAUDE_MODS_DISABLE')
165 } catch {
166 raw = ''
167 }
168 killed = killSwitchOn(raw)
169 if (killed) await update($, gateDisabled, () => true).catch(() => undefined)
170 return killed
171}
172
173// ---- Paths and stage names ------------------------------------------------------------------------------------
174
175/**
176 * The project root: where the session started, or where /cd or a worktree move took it. A shell `cd` does not move
177 * it, so the project config, protected globs and plan paths keep matching the same files after one. Falls back to
178 * the session's folder.
179 */
180async function projectRoot($: EngineInterface): Promise<string> {
181 try {
182 const root = await $.session.root()
183 if (typeof root === 'string' && root !== '') return root
184 } catch {
185 // the session's folder below
186 }
187 return $.session.cwd()
188}
189
190/** A path made absolute against the folder a command ran in. */
191function absoluteIn(path: string, dir: string): string {
192 return path.startsWith('/') ? path : `${dir.replace(/\/+$/, '')}/${path}`
193}
194
195async function stagesNow($: EngineInterface): Promise<Stage[]> {
196 const list = await read($, stageStages)
197 return Array.isArray(list) ? list : DEFAULT_STAGES
198}
199
200/** The gated stage of a role and the stage before it, by label; undefined when no stage of that role is gated. */
201function gatedStage(stages: readonly Stage[], role: string): { label: string; before?: string } | undefined {
202 const gate = stageForRole(stages, role)
203 if (!gate || gate.gate !== true) return undefined
204 const i = stages.findIndex(s => s.id === gate.id)
205 const prev = i > 0 ? stages[i - 1] : undefined
206 return { label: gate.label || gate.id, before: prev ? prev.label || prev.id : undefined }
207}
208
209/** Where /lk approve opens a gate: the stage before it or the gated stage itself ("QA or Ship"). */
210function approveFrom(g: { label: string; before?: string }): string {
211 return g.before !== undefined ? `${g.before} or ${g.label}` : g.label
212}
213
214// ---- Config: defaults → ~/.claude/loopkeeper.json → <root>/.claude/loopkeeper.json → this session's overrides -----
215
216async function ensureConfig($: EngineInterface): Promise<LkGateConfig> {
217 const cached = await read($, gateConfig)
218 if (cached !== null) return cached
219 return loadConfig($)
220}
221
222async function loadConfig($: EngineInterface): Promise<LkGateConfig> {
223 const home = ((await $.env.get('HOME').catch(() => undefined)) ?? '').replace(/\/+$/, '')
224 const root = (await projectRoot($)).replace(/\/+$/, '')
225 const candidates: Array<{ path: string; label: string }> = []
226 if (home !== '') candidates.push({ path: userConfigPath(home), label: USER_LABEL })
227 const project = projectConfigPath(root)
228 if (!candidates.some(c => c.path === project)) candidates.push({ path: project, label: PROJECT_LABEL })
229
230 const layers: Array<{ label: string; layer: Layer }> = []
231 const files: string[] = []
232 const errors: string[] = []
233 for (const c of candidates) {
234 const exists = await $.fs.exists(c.path).catch(() => false)
235 if (!exists) continue
236 try {
237 const parsed = sectionLayer(String(await $.fs.read(c.path)))
238 layers.push({ label: c.label, layer: parsed.layer })
239 files.push(c.path)
240 errors.push(...parsed.errors.map(x => `${c.label}: ${x}`))
241 } catch {
242 errors.push(`${c.label} is unreadable or not a JSON object`)
243 }
244 }
245 const override = await readOverride($)
246 if (override !== null) layers.push({ label: 'session', layer: override })
247
248 const { config, sources } = mergeLayers(layers)
249 await update($, gateConfig, () => config)
250 await update($, gateSources, () => sources)
251 await update($, gateConfigFiles, () => files)
252 await update($, gateConfigErrors, () => errors)
253 if (errors.length > 0) $.ui.toast(`config: ${errors.join('; ')}`)
254 $.ui.invalidate('prompt.context')
255 return config
256}
257
258async function sessionKey($: EngineInterface, prefix: string): Promise<string> {
259 const id = await $.session.id().catch(() => '')
260 return storeKey('gates', `${prefix}:${typeof id === 'string' && id !== '' ? id : 'unknown'}`)
261}
262
263async function readOverride($: EngineInterface): Promise<Layer | null> {
264 const raw: unknown = await $.store.get(await sessionKey($, 'overrides')).catch(() => undefined)
265 if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null
266 return checkLayer(raw as Record<string, unknown>).layer
267}
268
269async function setOverride($: EngineInterface, patch: Layer): Promise<void> {
270 const key = await sessionKey($, 'overrides')
271 const prev = (await readOverride($)) ?? {}
272 await $.store.set(key, { ...prev, ...patch })
273}
274
275// ---- The limit in force (ADR 0011) ---------------------------------------------------------------------------
276
277type Limit = { limit: number; source: 'max' | 'budget' | 'raise' | 'base'; base: number; budget: number; raise: number; slack: number }
278
279/** The files-per-turn limit before this turn's extras; a session override (`/lk gates max`) wins over the rest. */
280async function limitNow($: EngineInterface, config: LkGateConfig): Promise<Limit> {
281 const sources = await read($, gateSources)
282 const parts = {
283 max: sources.maxFilesPerTurn === 'session' ? config.maxFilesPerTurn : null,
284 base: config.maxFilesPerTurn,
285 budget: await read($, stageBudget),
286 raise: await read($, gateSessionRaise),
287 }
288 return { limit: limitOf(parts), source: limitSource(parts), base: parts.base, budget: parts.budget, raise: parts.raise, slack: config.planSlack }
289}
290
291// ---- Memory and the log --------------------------------------------------------------------------------------
292
293async function hydrate($: EngineInterface): Promise<void> {
294 if (hydrated) return
295 hydrated = true
296 mem = {
297 turnId: await read($, gateTurnId),
298 turnFiles: new Set(await read($, gateTurnFiles)),
299 turnExtra: await read($, gateTurnExtra),
300 turnUnlimited: await read($, gateTurnUnlimited),
301 turnFired: await read($, gateTurnFired),
302 sessionFiles: new Set(await read($, gateSessionFiles)),
303 grants: new Set(await read($, gateGrants)),
304 aborting: '',
305 }
306}
307
308async function persistTurn($: EngineInterface): Promise<void> {
309 await update($, gateTurnId, () => mem.turnId)
310 await update($, gateTurnFiles, () => [...mem.turnFiles])
311 await update($, gateTurnExtra, () => mem.turnExtra)
312 await update($, gateTurnUnlimited, () => mem.turnUnlimited)
313 await update($, gateTurnFired, () => mem.turnFired)
314}
315
316async function persistSession($: EngineInterface): Promise<void> {
317 await update($, gateSessionFiles, () => [...mem.sessionFiles])
318 await update($, gateGrants, () => [...mem.grants])
319 await update($, gatePlanOk, () => false)
320 await update($, gatePlanBy, () => '')
321 await update($, gateLog, () => [])
322}
323
324async function record($: EngineInterface, row: Omit<LkGateLogRow, 'at'>): Promise<void> {
325 const full: LkGateLogRow = { at: Date.now(), ...row }
326 await update($, gateLog, rows => [...rows, full].slice(-LOG_KEEP))
327}
328
329async function fire($: EngineInterface, row: Omit<LkGateLogRow, 'at'>): Promise<void> {
330 mem.turnFired = true
331 await update($, gateTurnFired, () => true)
332 await record($, row)
333}
334
335// ---- Asking -------------------------------------------------------------------------------------------------
336
337/**
338 * Where a question could reach the user: 'terminal' under the REPL; 'app' when only other surfaces draw the session
339 * (the desktop app, mobile, VS Code), where $.ui.ask from a permission check never resolves to an option; 'none' in a
340 * plain -p run.
341 */
342async function askPlace($: EngineInterface): Promise<'terminal' | 'app' | 'none'> {
343 try {
344 const surfaces = await $.session.surfaces()
345 return surfaces.includes('terminal') ? 'terminal' : surfaces.length > 0 ? 'app' : 'none'
346 } catch {
347 return 'none'
348 }
349}
350
351/** Whether the gates may put a question: only in the terminal. */
352async function isInteractive($: EngineInterface): Promise<boolean> {
353 return (await askPlace($)) === 'terminal'
354}
355
356/** The label picked, or null when nobody could be asked, the question was dismissed, or the answer is not an option. */
357async function ask($: EngineInterface, question: string, options: readonly string[]): Promise<string | null> {
358 if (!(await isInteractive($))) return null
359 try {
360 const answer = await $.ui.ask(question, options)
361 return options.includes(answer) ? answer : null
362 } catch {
363 return null
364 }
365}
366
367// After a deny, Claude tends to try another tool; ending the turn is what makes it stop.
368// The abort waits so the refusal lands in the transcript first (as scope-guard does).
369function scheduleAbort($: EngineInterface): void {
370 const running = mem.turnId
371 if (running === '' || mem.aborting === running) return
372 mem.aborting = running
373 $.clock.after(ABORT_DELAY_MS, () => {
374 void $.turn.abort({ turnId: running }).catch(() => undefined)
375 })
376}
377
378// ---- Edits: plan gate, protected paths, files per turn -------------------------------------------------------
379
380async function planSatisfied($: EngineInterface): Promise<boolean> {
381 if (await read($, gatePlanOk)) return true
382 return (await read($, stagePlanDone)) === true
383}
384
385async function decideEdit($: EngineInterface, paths: string[], tool: string, agentId?: unknown): Promise<string | null> {
386 if (paths.length === 0) return null
387 await hydrate($)
388 const config = await ensureConfig($)
389 // against the project root, so a shell `cd` does not change which rules a file meets
390 const root = await projectRoot($)
391 const rel = [...new Set(paths.map(p => normalizePath(p, root)))]
392 // Plan material (the scratchpad, planPaths): exempt from the plan gate and the per-turn count
393 const plan = new Set(rel.filter(p => isPlanMaterial(p, root, config.planPaths)))
394 const work = rel.filter(p => !plan.has(p))
395
396 // 1. Plan gate
397 const gateClosed = config.requirePlan && !(await planSatisfied($))
398 const planExempt = gateClosed && work.length === 0
399 if (gateClosed && work.length > 0) {
400 await fire($, { rule: 'plan', target: work.join(', '), outcome: 'denied', detail: `${tool} before a plan was approved` })
401 const stages = await stagesNow($)
402 const gate = gatedStage(stages, 'validate-plan')
403 const planStage = stageForRole(stages, 'plan')
404 // true at every stage: /lk approve opens a gated plan stage from that stage or the one before it; with no plan
405 // gate, moving past the plan stage is what opens edits
406 const how = gate
407 ? ` and ask the user to approve it: /lk approve opens the pipeline's ${gate.label} gate (from ${approveFrom(gate)}), ` +
408 `or ${GATES_CMD} plan ok allows edits for this session without it.`
409 : `, then move the pipeline ${planStage ? `past ${planStage.label || planStage.id}` : 'on'} with set_stage, or ask the user for ${GATES_CMD} plan ok.`
410 return `${MOD}: edits are refused until a plan is approved in this session (requirePlan is on). ` +
411 'Do not try another tool to make this change. Present a short plan (Goal, Steps, Done-when, and the files you will touch)' +
412 how
413 }
414
415 // 2. Protected paths
416 for (const p of rel) {
417 const rule = matchProtected(config.protected, p)
418 if (!rule) continue
419 if (rule.action === 'deny') {
420 await fire($, { rule: 'protected', target: p, outcome: 'denied', detail: `matches ${rule.glob} (deny)` })
421 return `${MOD}: ${p} is a protected path (rule "${rule.glob}": deny). Do not edit it or reach it another way. Ask the user to make this change themselves or to change the rule.`
422 }
423 if (mem.grants.has(p)) continue
424 // off the terminal nobody can be asked: the user grants the path with /lk gates allow, as Allow for session does
425 if (!(await isInteractive($))) {
426 await fire($, { rule: 'protected', target: p, outcome: 'denied', detail: `${rule.glob}: needs the user's yes (no terminal to ask)` })
427 return `${MOD}: ${p} is protected (rule "${rule.glob}": ask) and needs the user's yes. Ask the user to run ${GATES_CMD} allow ${p}, which allows it for this session; do not edit it another way.`
428 }
429 const answer = await ask($, `${MOD}: ${p} is protected (rule "${rule.glob}"). Allow this ${tool} edit?`, ASK_PROTECTED)
430 mem.turnFired = true
431 if (answer === 'Allow for session') {
432 mem.grants.add(p)
433 await update($, gateGrants, () => [...mem.grants])
434 await record($, { rule: 'protected', target: p, outcome: 'allowed', detail: `${rule.glob}: allowed for session` })
435 continue
436 }
437 if (answer === 'Allow once') {
438 await record($, { rule: 'protected', target: p, outcome: 'allowed', detail: `${rule.glob}: allowed once` })
439 continue
440 }
441 const why = answer === null ? 'the question was dismissed' : 'the user chose Deny'
442 await fire($, { rule: 'protected', target: p, outcome: 'denied', detail: `${rule.glob}: ${why}` })
443 return `${MOD}: the edit to protected path ${p} (rule "${rule.glob}") was refused: ${why}. Do not work around it; ask the user how to proceed.`
444 }
445
446 if (planExempt) await record($, { rule: 'plan', target: rel.join(', '), outcome: 'allowed', detail: 'plan material' })
447
448 // 3. Files per turn. A subagent's edits are not counted unless countSubagents is on (orchestrated builders write
449 // many files in one main turn); rules 1 and 2 still applied. Plan material never counts.
450 if (work.length === 0 || (typeof agentId === 'string' && agentId !== '' && !config.countSubagents)) {
451 for (const p of rel) mem.sessionFiles.add(p)
452 await update($, gateSessionFiles, () => [...mem.sessionFiles])
453 return null
454 }
455 const lim = await limitNow($, config)
456 // Checked and reserved synchronously so parallel calls count against one Set.
457 let added = work.filter(p => !mem.turnFiles.has(p))
458 const limit = () => (mem.turnUnlimited ? Infinity : lim.limit + mem.turnExtra)
459 if (added.length > 0 && mem.turnFiles.size + added.length > limit()) {
460 const before = [...mem.turnFiles]
461 const n = before.length
462 // the figure is computed once, so the label and what choosing it does match
463 const figure = raiseTo(lim.limit, lim.slack)
464 const options = filesOptions(figure)
465 const place = await askPlace($)
466 const interactive = place === 'terminal'
467 let answer: string | null = null
468 if (interactive) answer = await ask($, `${MOD}: this turn already touched ${n} files. Allow more?`, options)
469 mem.turnFired = true
470 added = work.filter(p => !mem.turnFiles.has(p))
471 if (answer === options[0]) {
472 mem.turnExtra += Math.max(1, mem.turnFiles.size + added.length - limit())
473 await record($, { rule: 'files', target: added.join(', '), outcome: 'allowed', detail: `past ${n} files: allowed 1 more` })
474 } else if (answer === options[1]) {
475 mem.turnUnlimited = true
476 await record($, { rule: 'files', target: added.join(', '), outcome: 'allowed', detail: `past ${n} files: allowed for the rest of the turn` })
477 } else if (answer === options[2]) {
478 await update($, gateSessionRaise, r => Math.max(r, figure))
479 // a session max wins over the raise; raising it too keeps the label true
480 if (lim.source === 'max') {
481 await setOverride($, { maxFilesPerTurn: Math.min(1000, figure) })
482 await loadConfig($)
483 }
484 const over = mem.turnFiles.size + added.length - (figure + mem.turnExtra)
485 if (over > 0) mem.turnExtra += over
486 $.ui.invalidate('prompt.context')
487 await record($, { rule: 'files', target: added.join(', '), outcome: 'allowed', detail: `past ${n} files: raised to ${figure} for this session` })
488 } else if (place === 'app') {
489 // off the terminal nobody can be asked: the user raises the limit for the session instead
490 await fire($, { rule: 'files', target: added.join(', '), outcome: 'denied', detail: `past ${n} files: needs the user's yes (no terminal to ask)` })
491 scheduleAbort($)
492 return [
493 `${MOD} stopped this edit: this turn already touched ${n} files (${before.join(', ')}) and ${added.join(', ')} would exceed the limit of ${lim.limit} files per turn, and more needs the user's yes.`,
494 `Stop editing now. Summarise what changed so far, list the files you still intend to touch and why, and ask the user to raise the limit for this session with ${GATES_CMD} max <n> before continuing.`,
495 ].join('\n')
496 } else {
497 const why = !interactive ? 'no one to ask (non-interactive session)' : answer === 'Stop' ? 'the user chose Stop' : 'the question was dismissed'
498 await fire($, { rule: 'files', target: added.join(', '), outcome: 'denied', detail: `past ${n} files: ${why}` })
499 scheduleAbort($)
500 return [
501 `${MOD} stopped this edit: this turn already touched ${n} files (${before.join(', ')}) and ${added.join(', ')} would exceed the limit of ${lim.limit} files per turn (${why}).`,
502 'Stop editing now. Summarise what changed so far, list the files you still intend to touch and why, and ask the user before continuing.',
503 ].join('\n')
504 }
505 }
506 for (const p of added) mem.turnFiles.add(p)
507 for (const p of rel) mem.sessionFiles.add(p)
508 await persistTurn($)
509 await update($, gateSessionFiles, () => [...mem.sessionFiles])
510 return null
511}
512
513// ---- Bash: the ship gate ---------------------------------------------------------------------------------------
514
515async function shipOk($: EngineInterface): Promise<boolean> {
516 if (await read($, gateShipOk)) return true
517 const saved: unknown = await $.store.get(await sessionKey($, 'shipOk')).catch(() => undefined)
518 if (saved !== true) return false
519 await update($, gateShipOk, () => true)
520 return true
521}
522
523/** ADR 0004: shipping waits for the stage's stageShipOpen or /lk gates ship ok; there is no fallback question. */
524async function shipGate($: EngineInterface, config: LkGateConfig, shell: string): Promise<string | null> {
525 if (!config.shipGate) return null
526 const hits = shipCommands(shell)
527 if (hits.length === 0) return null
528 const what = hits.join(', ')
529 if (await shipOk($)) return null
530 if ((await read($, stageShipOpen)) === true) return null
531 const stage = await read($, stageCurrent)
532 const stages = await stagesNow($)
533 const here = stages.find(s => s.id === stage)
534 const where = here ? here.label || here.id : stage !== '' ? stage : 'an unknown stage'
535 await fire($, { rule: 'ship', target: what, outcome: 'denied', detail: `pipeline at ${where}` })
536 // /lk approve opens a gated ship stage from it or the stage before; with none gated, only ship ok opens shipping
537 const gate = gatedStage(stages, 'ship')
538 const how = gate
539 ? `run /lk approve at ${approveFrom(gate)} (it opens the ${gate.label} gate; a waiting file budget comes first), or ${GATES_CMD} ship ok.`
540 : `run ${GATES_CMD} ship ok.`
541 return `${MOD}: shipping is gated (${what}). The pipeline is at ${where}. Ask the user to review the work and ${how}`
542}
543
544// ---- Bash: merge gate, amend, add-all --------------------------------------------------------------------------
545
546async function gitGate($: EngineInterface, config: LkGateConfig, actions: GitAction[], shell: string, command: string): Promise<string | null> {
547 if (actions.length === 0) return null
548 const dir = await runDir($, command)
549 const branches = await gitBranches($, dir)
550
551 for (const a of actions) {
552 if (a.kind === 'amend' && config.blockAmend) {
553 const shared = branches.defaults.includes(branches.current) || (await headIsPushed($, dir))
554 if (shared) {
555 await fire($, { rule: 'amend', target: branches.current || '(unknown branch)', outcome: 'denied', detail: 'git commit --amend on a shared branch' })
556 return `${MOD}: \`git commit --amend\` is refused on ${branches.current || 'this branch'}: the commit is already shared (default branch, or already pushed). Make a new commit instead.`
557 }
558 }
559 if (a.kind === 'add-all' && config.blockAddAll) {
560 const status = await $.process.run(['git', 'status', '--porcelain'], { cwd: dir, timeoutMs: 15_000 }).catch(() => null)
561 const changed = status !== null && status.exitCode === 0 ? parsePorcelain(status.stdout) : null
562 // porcelain paths are relative to the repository's top level; the session's files to the project root
563 const top = changed !== null && changed.length > 0 ? await repoTop($, dir) : dir
564 const root = await projectRoot($)
565 const untouched = changed === null ? null : changed.filter(p => !mem.sessionFiles.has(normalizePath(`${top}/${p}`, root)))
566 if (untouched === null || untouched.length > 0) {
567 const list = untouched === null ? '(git status could not be read)' : untouched.slice(0, 15).join(', ') + (untouched.length > 15 ? `, and ${untouched.length - 15} more` : '')
568 await fire($, { rule: 'add-all', target: a.args.join(' '), outcome: 'denied', detail: `untouched: ${list}` })
569 return `${MOD}: \`git add ${a.args.join(' ')}\` is refused: it would stage files this session did not touch: ${list}. Stage the files you changed by name.`
570 }
571 }
572 }
573
574 if (!config.mergeGate) return null
575 const merges = actions.filter(
576 a =>
577 a.kind === 'gh-merge' ||
578 (a.kind === 'git-merge' && mergeHitsDefault(a.args, branches.current, branches.defaults)) ||
579 (a.kind === 'git-push' && pushHitsDefault(a.args, branches.current, branches.defaults)),
580 )
581 if (merges.length === 0) return null
582 const what = merges.some(m => m.kind === 'gh-merge')
583 ? `gh pr merge${prNumber(shell) ? ' ' + prNumber(shell) : ''}`
584 : merges[0]?.kind === 'git-push'
585 ? 'push to the default branch'
586 : 'merge with the default branch'
587
588 if (config.requireGreenCi && merges.some(m => m.kind === 'gh-merge')) {
589 const pr = await openPr($, dir, prNumber(shell))
590 if (pr !== null) {
591 const res = await $.process.run(['gh', 'pr', 'checks', String(pr), '--json', 'name,bucket'], { cwd: dir, timeoutMs: 20_000 }).catch(() => null)
592 // gh exits non-zero while checks fail or run; the JSON is still on stdout.
593 const sum = summarizeChecks(res?.stdout?.trim() ? res.stdout : '[]')
594 if (sum.failing.length > 0 || sum.pending.length > 0) {
595 const parts = [
596 sum.failing.length > 0 ? `failing: ${sum.failing.join(', ')}` : '',
597 sum.pending.length > 0 ? `pending: ${sum.pending.join(', ')}` : '',
598 ].filter(x => x !== '')
599 await fire($, { rule: 'merge', target: `PR #${pr}`, outcome: 'denied', detail: parts.join('; ') })
600 return `${MOD}: held the merge of PR #${pr}: CI is not green (${parts.join('; ')}). Wait for the checks or fix them, then ask the user before merging.`
601 }
602 }
603 }
604
605 // the user's yes given ahead: the Ship approval, /lk gates ship ok or /lk gates merge ok covers this one call
606 if (await spendMergeCredit($)) {
607 await record($, { rule: 'merge', target: what, outcome: 'allowed', detail: 'covered by the Ship approval or /lk gates merge ok' })
608 return null
609 }
610 if (!(await isInteractive($))) {
611 await fire($, { rule: 'merge', target: what, outcome: 'denied', detail: "needs the user's yes (no terminal to ask)" })
612 return `${MOD}: the ${what} was held: it needs the user's yes. Ask the user to approve the Ship gate (/lk approve) or to run ${GATES_CMD} merge ok, then run it again. Do not retry it another way.`
613 }
614 const answer = await ask($, `Merge now? (${MOD}: ${what})`, ASK_MERGE)
615 mem.turnFired = true
616 await update($, gateTurnFired, () => true)
617 if (answer === 'Merge') {
618 await record($, { rule: 'merge', target: what, outcome: 'allowed', detail: 'the user chose Merge' })
619 return null
620 }
621 const why = answer === null ? 'the question was dismissed' : 'the user chose Cancel'
622 await fire($, { rule: 'merge', target: what, outcome: 'denied', detail: why })
623 return `${MOD}: the ${what} was held: ${why}. Do not retry it another way; ask the user when to merge.`
624}
625
626/** Spends the merge credit: true when there was one. One update reads and clears it, so two calls in parallel
627 * cannot both pass on it. */
628async function spendMergeCredit($: EngineInterface): Promise<boolean> {
629 let had = false
630 await update($, gateMergeCredit, credit => {
631 had = credit === true
632 return false
633 })
634 return had
635}
636
637async function gitBranches($: EngineInterface, dir: string): Promise<{ current: string; defaults: string[] }> {
638 const cur = await $.process.run(['git', 'branch', '--show-current'], { cwd: dir, timeoutMs: 10_000 }).catch(() => null)
639 const head = await $.process.run(['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], { cwd: dir, timeoutMs: 10_000 }).catch(() => null)
640 const current = cur !== null && cur.exitCode === 0 ? cur.stdout.trim() : ''
641 const defaults = ['main', 'master']
642 const remote = head !== null && head.exitCode === 0 ? head.stdout.trim().replace(/^[^/]+\//, '') : ''
643 if (remote !== '' && !defaults.includes(remote)) defaults.unshift(remote)
644 return { current, defaults }
645}
646
647/** The repository's top level as git sees it from dir; dir itself when git cannot say. */
648async function repoTop($: EngineInterface, dir: string): Promise<string> {
649 const r = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: dir, timeoutMs: 10_000 }).catch(() => null)
650 const top = r !== null && r.exitCode === 0 ? r.stdout.trim() : ''
651 return top.startsWith('/') ? top : dir
652}
653
654async function headIsPushed($: EngineInterface, dir: string): Promise<boolean> {
655 const r = await $.process.run(['git', 'merge-base', '--is-ancestor', 'HEAD', '@{u}'], { cwd: dir, timeoutMs: 10_000 }).catch(() => null)
656 return r !== null && r.exitCode === 0
657}
658
659async function openPr($: EngineInterface, dir: string, number: string | undefined): Promise<number | null> {
660 const args = ['gh', 'pr', 'view', ...(number ? [number] : []), '--json', 'number,state']
661 const r = await $.process.run(args, { cwd: dir, timeoutMs: 20_000 }).catch(() => null)
662 if (r === null || r.exitCode !== 0) return null
663 try {
664 const o = JSON.parse(r.stdout) as { number?: unknown; state?: unknown }
665 return typeof o.number === 'number' && o.state === 'OPEN' ? o.number : null
666 } catch {
667 return null
668 }
669}
670
671// Where the command really runs: the session's folder, moved by any leading `cd` (borrowed; see the notice in lib/shell.ts).
672async function runDir($: EngineInterface, command: string): Promise<string> {
673 let dir = await $.session.cwd()
674 const targets = cdTargets(command)
675 if (targets.length === 0) return dir
676 const home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
677 for (const t of targets) {
678 const nextDir = resolveDir(dir, t, home)
679 if (nextDir === null) break
680 dir = nextDir
681 }
682 return dir
683}
684
685// ---- The Files pane seam: Bash writes the gates let through ------------------------------------------------------
686
687/** Files a Bash command writes (bashTargets), into filesTouched and filesActivity.last, as change-control did. */
688async function recordBashWrites($: EngineInterface, targets: string[], runCwd: string): Promise<void> {
689 try {
690 const cwd = (await $.session.cwd()).replace(/\/+$/, '') || '/'
691 const root = runCwd.replace(/\/+$/, '')
692 const paths = [...new Set(targets.map(t => normalizePath(t.startsWith('/') ? t : `${root}/${t}`, cwd)))]
693 .filter(p => p !== '' && p !== cwd)
694 .slice(0, 20)
695 if (paths.length === 0) return
696 // tool.check runs before the command: a path not there yet is one the command creates
697 const existed = await Promise.all(paths.map(p => $.fs.exists(p.startsWith('/') ? p : `${cwd}/${p}`).catch(() => true)))
698 const at = Date.now()
699 let last = ''
700 await update($, filesTouched, list => {
701 const r = noteBashWrites(list, paths.map((path, i) => ({ path, created: existed[i] !== true })), at)
702 last = r.last
703 return r.touched
704 })
705 if (last !== '') await update($, filesActivity, a => ({ live: a.live, last }))
706 } catch {
707 // The verdict stands; the pane misses these files.
708 }
709}
710
711// ---- The pane, /lk gates text and the /lk status line ------------------------------------------------------------
712
713function clock(at: number): string {
714 try {
715 return new Date(at).toISOString().slice(11, 19)
716 } catch {
717 return '--:--:--'
718 }
719}
720
721function limitWhy(l: Limit, planFiles: number | null): string {
722 if (l.source === 'max') return `${GATES_CMD} max, for this session`
723 if (l.source === 'budget') return `the approved plan's budget: ${planFiles !== null ? `${planFiles} planned + ${l.slack}` : `${l.budget}`}`
724 if (l.source === 'raise') return 'raised for this session'
725 return `the base, maxFilesPerTurn${l.budget > 0 ? `; plan budget ${l.budget}` : ''}${l.raise > 0 ? `; session raise ${l.raise}` : ''}`
726}
727
728async function statusLines($: EngineInterface): Promise<string[]> {
729 const config = await ensureConfig($)
730 const sources = await read($, gateSources)
731 const files = await read($, gateConfigFiles)
732 const errors = await read($, gateConfigErrors)
733 const turn = await read($, gateTurnFiles)
734 const extra = await read($, gateTurnExtra)
735 const unlimited = await read($, gateTurnUnlimited)
736 const planOk = await read($, gatePlanOk)
737 const planBy = await read($, gatePlanBy)
738 const planDone = (await read($, stagePlanDone)) === true
739 const shipOpen = (await read($, stageShipOpen)) === true
740 const stage = await read($, stageCurrent)
741 const okShip = await shipOk($)
742 const log = (await read($, gateLog)).slice().reverse()
743 const enabled = await read($, gateEnabled)
744 const lim = await limitNow($, config)
745 const limit = unlimited ? '∞' : String(lim.limit + extra)
746 const planFilesNow = await read($, stagePlanFiles)
747 return [
748 (await isKilled($))
749 ? `Status: OFF (CLAUDE_MODS_DISABLE names ${MOD}, so no rule is enforced; unset it and start a new session to turn the gates on)`
750 : enabled
751 ? 'Status: ON'
752 : `Status: OFF for this session (${GATES_CMD} on)`,
753 `Config files: ${files.length > 0 ? files.join(', ') : 'none (defaults)'}`,
754 ...errors.map(x => `Ignored: ${x}`),
755 'Active rules [source]:',
756 ...describeRules(config, sources).map(r => ` ${r}`),
757 `Files per turn: ${lim.limit} (${limitWhy(lim, planFilesNow)})`,
758 `Plan: ${planOk ? `approved (${planBy || 'yes'})` : planDone ? 'approved in the pipeline' : config.requirePlan ? 'not yet approved' : 'not required'}`,
759 `Ship: ${
760 !config.shipGate
761 ? 'gate off'
762 : okShip
763 ? `open (${GATES_CMD} ship ok)`
764 : shipOpen
765 ? 'open (the pipeline allows shipping)'
766 : `held (pipeline at ${stage !== '' ? stage : 'an unknown stage'})`
767 }`,
768 `Files touched this turn (${turn.length}/${limit}):`,
769 ...(turn.length > 0 ? turn.map(f => ` ${f}`) : [' none']),
770 `Decisions (last ${LOG_KEEP}, newest first):`,
771 ...(log.length > 0 ? log.map(r => ` ${clock(r.at)} ${r.rule} ${r.outcome} ${r.target} · ${r.detail}`) : [' none yet']),
772 ]
773}
774
775/** The gates' line in /lk status (and the answer to a bare /lk gates beside the pane). */
776async function statusLine($: EngineInterface): Promise<string> {
777 if (await isKilled($)) return `gates ● off · CLAUDE_MODS_DISABLE names ${MOD}, so no rule is enforced`
778 if (!(await read($, gateEnabled))) return `gates ● off for this session · ${GATES_CMD} on to resume`
779 const config = await ensureConfig($)
780 const lim = await limitNow($, config)
781 const count = (await read($, gateTurnFiles)).length
782 const limit = (await read($, gateTurnUnlimited)) ? '∞' : String(lim.limit + (await read($, gateTurnExtra)))
783 const planOk = (await read($, gatePlanOk)) || (await read($, stagePlanDone)) === true
784 const plan = config.requirePlan ? (planOk ? 'plan ✓' : 'plan ✗') : 'plan –'
785 const held = config.shipGate && !(await shipOk($)) && (await read($, stageShipOpen)) !== true
786 const source = lim.source === 'base' ? '' : ` (${lim.source})`
787 return `gates ● on · ${count}/${limit} files${source} · ${plan} · ship ${config.shipGate ? (held ? 'held' : 'open') : 'gate off'} · merge gate ${config.mergeGate ? 'on' : 'off'}`
788}
789
790/** The pane's model: the state statusLines prints, as data, with every path made short for display. */
791type GatesView = {
792 state: 'on' | 'off' | 'killed'
793 title: string
794 sub: string
795 tiles: { label: string; value: string; tone: Tone }[]
796 rules: { key: string; values: string[]; source: string }[]
797 protected: { glob: string; action: 'ask' | 'deny'; source: string }[]
798 configFiles: string[]
799 errors: string[]
800 turn: { name: string; folder: string }[]
801 log: { time: string; outcome: 'allowed' | 'asked' | 'denied'; target: string; detail: string }[]
802}
803
804/** Rules whose allowed rows are the user's answer to a question (Allow once, Allow for session, Merge, …). */
805const ASKED_RULES: ReadonlySet<string> = new Set(['protected', 'files', 'merge'])
806/** Rules whose target is a list of paths. */
807const PATH_RULES: ReadonlySet<string> = new Set(['protected', 'files', 'plan'])
808
809function baseOf(path: string): string {
810 const p = path.replace(/\/+$/, '')
811 return p.slice(p.lastIndexOf('/') + 1) || p
812}
813
814async function gatesView($: EngineInterface): Promise<GatesView> {
815 const config = await ensureConfig($)
816 const sources = await read($, gateSources)
817 // the turn's files are relative to the project root (decideEdit)
818 const root = (await projectRoot($)).replace(/\/+$/, '')
819 const home = String((await $.env.get('HOME').catch(() => undefined)) ?? '')
820 const short = (p: string) => shortPath(p, root, home)
821 const turn = await read($, gateTurnFiles)
822 const extra = await read($, gateTurnExtra)
823 const unlimited = await read($, gateTurnUnlimited)
824 const fired = await read($, gateTurnFired)
825 const stage = await read($, stageCurrent)
826 const lim = await limitNow($, config)
827 const max = lim.limit + extra
828 const limit = unlimited ? '∞' : String(max)
829 const killed = await isKilled($)
830 const enabled = await read($, gateEnabled)
831
832 const from = lim.source === 'base' ? 'the base limit, maxFilesPerTurn' : lim.source === 'max' ? `max override for this session (${GATES_CMD} max)` : limitWhy(lim, await read($, stagePlanFiles))
833 const more = unlimited ? ' · no limit for the rest of this turn' : extra > 0 ? ` · ${extra} more allowed this turn` : ''
834 const near = !unlimited && turn.length >= Math.max(1, max - 1)
835 const planOk = (await read($, gatePlanOk)) || (await read($, stagePlanDone)) === true
836 const shipHeld = !(await shipOk($)) && (await read($, stageShipOpen)) !== true
837
838 // describeRules is the one list of rules; the pane draws protected paths and planPaths its own way
839 const rules: GatesView['rules'] = []
840 for (const row of describeRules(config, sources)) {
841 if (row.startsWith('protected ')) continue
842 const m = /^([^:]+): (.*) {2}\[([^\]]*)\]$/.exec(row)
843 if (!m) continue
844 const values = m[1] === 'planPaths' ? [...config.planPaths, '+ scratchpad'] : [m[2]]
845 rules.push({ key: m[1], values, source: m[3] })
846 }
847
848 return {
849 state: killed ? 'killed' : enabled ? 'on' : 'off',
850 title: killed ? 'Off' : enabled ? `On · ${turn.length}/${limit} files this turn` : 'Off for this session',
851 sub: killed
852 ? `CLAUDE_MODS_DISABLE names ${MOD}, so no rule is enforced; unset it and start a new session to turn the gates on`
853 : enabled
854 ? from + more
855 : `no rule is enforced; ${GATES_CMD} on turns them back on`,
856 tiles: [
857 { label: 'Files per turn', value: limit, tone: fired ? 'danger' : near ? 'warn' : 'ok' },
858 planOk
859 ? { label: 'Plan', value: 'approved', tone: 'ok' }
860 : config.requirePlan
861 ? { label: 'Plan', value: 'needed', tone: 'warn' }
862 : { label: 'Plan', value: 'not required', tone: 'muted' },
863 !config.shipGate
864 ? { label: 'Ship', value: 'gate off', tone: 'muted' }
865 : shipHeld
866 ? { label: 'Ship', value: stage !== '' ? `held at ${stage}` : 'held', tone: 'warn' }
867 : { label: 'Ship', value: 'open', tone: 'ok' },
868 { label: 'Merge gate', value: config.mergeGate ? 'on' : 'off', tone: config.mergeGate ? 'info' : 'muted' },
869 ],
870 rules,
871 protected: config.protected.map(r => ({ glob: r.glob, action: r.action, source: sources[`protected:${r.glob}`] ?? 'default' })),
872 configFiles: (await read($, gateConfigFiles)).map(short),
873 errors: await read($, gateConfigErrors),
874 turn: turn.map(f => {
875 const p = f.replace(/\/+$/, '')
876 const i = p.lastIndexOf('/')
877 return { name: baseOf(p), folder: i < 0 ? '' : short(i === 0 ? '/' : p.slice(0, i)) }
878 }),
879 log: (await read($, gateLog))
880 .slice()
881 .reverse()
882 .map((r): GatesView['log'][number] => ({
883 time: clock(r.at),
884 outcome: r.outcome === 'denied' ? 'denied' : ASKED_RULES.has(r.rule) ? 'asked' : 'allowed',
885 target: PATH_RULES.has(r.rule)
886 ? r.target.split(', ').map(baseOf).join(', ')
887 : r.target.replace(/(^|[\s,])(\/[^\s,]+)/g, (_m, pre: string, p: string) => pre + short(p)),
888 detail: r.detail,
889 })),
890 }
891}
892
893const OUTCOME_TONE: Record<GatesView['log'][number]['outcome'], Tone> = { allowed: 'ok', asked: 'warn', denied: 'danger' }
894
895/**
896 * The pane: the nav bar, a hero (shield, GATES, on or off and the files this turn, where the limit comes from),
897 * tiles, then cards for the rules, the protected paths, this turn's files and the decisions. Long values wrap; only
898 * the labels beside them keep their width.
899 */
900function gatesPane(kit: Kit, ui: Elements, nav: unknown, v: GatesView, buttons: unknown): unknown {
901 const quiet = TONES.muted.fg
902 const box = (props: Record<string, unknown>, children: unknown[]): unknown =>
903 ui.Box({ ...props, children: children.filter(c => c !== null && c !== undefined && c !== '') })
904 const keep = (child: unknown): unknown => box({ flexShrink: 0 }, [child])
905 // `center` lines the right side up with the middle of the row: rows that hold a pill (an Svg chip).
906 const spread = (key: string, left: unknown[], right: unknown[], center = false): unknown => {
907 const align = center ? { alignItems: 'center' } : {}
908 return box({ key, flexDirection: 'row', justifyContent: 'space-between', columnGap: 1, ...align }, [
909 box({ flexDirection: 'column', flexShrink: 1 }, left),
910 box({ flexDirection: 'row', flexShrink: 0, columnGap: 1, ...align }, right),
911 ])
912 }
913 const none = (s: string) => kit.text(s, { color: quiet, wrap: 'wrap' })
914 const enforced = v.state === 'on'
915
916 const hero = kit.card({
917 children: [
918 box({ flexDirection: 'row', columnGap: 1 }, [
919 keep(kit.icon('shield', enforced ? 'ok' : 'danger')),
920 box({ flexDirection: 'column', flexShrink: 1 }, [
921 kit.text('GATES', { bold: true, color: enforced ? TONES.ok.fg : TONES.danger.fg, wrap: 'truncate' }),
922 kit.text(v.title, { bold: true, wrap: 'wrap' }),
923 kit.text(v.sub, { color: quiet, wrap: 'wrap' }),
924 ]),
925 ]),
926 ],
927 })
928
929 const rules = kit.card({
930 children: [
931 kit.header('Rules', 'source'),
932 ...v.rules.map(r =>
933 spread(
934 `rule-${r.key}`,
935 r.values.length === 1
936 ? [ui.Text({ wrap: 'wrap', children: [`${r.key}: `, kit.text(r.values[0], { bold: true })] })]
937 : [kit.text(`${r.key}:`, { wrap: 'wrap' }), ...r.values.map(x => kit.text(` ${x}`, { bold: true, wrap: 'wrap' }))],
938 [kit.text(r.source, { color: quiet })],
939 ),
940 ),
941 kit.text(`Config files: ${v.configFiles.length > 0 ? v.configFiles.join(' · ') : 'none (defaults)'}`, { color: quiet, wrap: 'wrap' }),
942 ...v.errors.map(x => kit.text(`Ignored: ${x}`, { color: TONES.danger.fg, wrap: 'wrap' })),
943 ],
944 })
945
946 const guarded = kit.card({
947 children: [
948 kit.header('Protected paths', String(v.protected.length)),
949 ...(v.protected.length > 0
950 ? v.protected.map(p =>
951 spread(`protected-${p.glob}`, [kit.text(p.glob, { wrap: 'wrap' })], [
952 kit.pill(p.action, p.action === 'deny' ? 'danger' : 'warn'),
953 kit.text(p.source, { color: quiet }),
954 ], true),
955 )
956 : [none('none')]),
957 ],
958 })
959
960 const n = v.turn.length
961 const turn = kit.card({
962 children: [
963 kit.header('This turn', `${n} file${n === 1 ? '' : 's'}`),
964 ...(n > 0
965 ? v.turn.map((f, i) =>
966 box({ key: `turn-${i}`, flexDirection: 'row', columnGap: 1 }, [
967 keep(kit.typeBadge(f.name) ?? kit.icon('file')),
968 box({ flexShrink: 1 }, [
969 ui.Text({
970 wrap: 'wrap',
971 children: [kit.text(f.name, { bold: true }), f.folder ? kit.text(` ${f.folder}`, { color: quiet }) : ''].filter(Boolean),
972 }),
973 ]),
974 ]),
975 )
976 : [none('no files yet')]),
977 ],
978 })
979
980 const decisions = kit.card({
981 children: [
982 kit.header('Decisions', v.log.length > 0 ? `${v.log.length} · newest first` : ''),
983 ...(v.log.length > 0
984 ? v.log.map((r, i) =>
985 box({ key: `decision-${i}`, flexDirection: 'row', alignItems: 'center', columnGap: 1 }, [
986 keep(kit.text(r.time, { color: quiet })),
987 keep(kit.pill(r.outcome, OUTCOME_TONE[r.outcome])),
988 box({ flexShrink: 1 }, [ui.Text({ wrap: 'wrap', children: [kit.text(r.target, { bold: true }), ` · ${r.detail}`] })]),
989 ]),
990 )
991 : [none('none yet')]),
992 ],
993 })
994
995 return kit.page([nav, hero, kit.tiles(v.tiles), rules, guarded, turn, decisions, buttons])
996}
997
998// ---- /lk gates …, and the shared /lk words ---------------------------------------------------------------------
999
1000async function setGatesBand($: EngineInterface, shown: boolean): Promise<boolean> {
1001 await update($, gateBand, () => shown)
1002 try {
1003 await $.store.set(BAND_KEY, shown)
1004 return true
1005 } catch {
1006 return false
1007 }
1008}
1009
1010/**
1011 * /lk gates allow <path>: the user's yes for a protected path whose rule is ask, for the rest of the session (as Allow
1012 * for session is in the terminal). The path resolves against the project root, as decideEdit places it; a deny rule
1013 * cannot be granted. Alone, it lists the session's grants.
1014 */
1015async function allowPath($: EngineInterface, raw: string, fromPerson: boolean): Promise<{ text: string }> {
1016 await hydrate($)
1017 const usage = `usage: ${GATES_CMD} allow <path>`
1018 if (raw === '') {
1019 const grants = [...mem.grants]
1020 return { text: grants.length > 0 ? `Protected paths allowed for this session: ${grants.join(', ')}. ${usage}` : `No protected path is allowed for this session. ${usage}` }
1021 }
1022 if (!fromPerson) return { text: 'Only the user can allow a protected path.' }
1023 const config = await ensureConfig($)
1024 const root = await projectRoot($)
1025 const p = normalizePath(raw, root)
1026 const rule = matchProtected(config.protected, p)
1027 if (rule?.action === 'deny') return { text: `${p} matches rule "${rule.glob}": deny. A deny rule cannot be granted; change the rule to edit it.` }
1028 mem.grants.add(p)
1029 await update($, gateGrants, () => [...mem.grants])
1030 await record($, { rule: 'protected', target: p, outcome: 'allowed', detail: `granted by ${GATES_CMD} allow` })
1031 return { text: rule ? `${p} is allowed for this session (rule "${rule.glob}": ask).` : `${p} is allowed for this session; no protected rule matches it now.` }
1032}
1033
1034/**
1035 * /lk gates <words>: the answer, or null for a word another concern owns (`files`, the Files pane's). `fromPerson`:
1036 * the run came from the person (no origin, or composer, bridge or sdk); only they open shipping, allow a merge or
1037 * grant a protected path.
1038 */
1039async function gatesCommand($: EngineInterface, rest: string[], fromPerson: boolean): Promise<{ text?: string } | null> {
1040 const verb = (rest[0] ?? '').toLowerCase()
1041 const value = (rest[1] ?? '').toLowerCase()
1042 const toggle = (rest[2] ?? '').toLowerCase()
1043
1044 if (verb === '') {
1045 await $.ui.open({ id: PANE_ID, title: 'Gates', focus: true, closeOnEscape: true })
1046 return { text: await statusLine($) }
1047 }
1048 if (verb === 'files') return null
1049 if (verb === 'text') return { text: (await statusLines($)).join('\n') }
1050 if (verb === 'off') {
1051 await update($, gateEnabled, () => false)
1052 $.ui.invalidate('prompt.context')
1053 return { text: `gates off for this session: no rule is enforced. ${GATES_CMD} on to resume.` }
1054 }
1055 if (verb === 'on') {
1056 await update($, gateEnabled, () => true)
1057 $.ui.invalidate('prompt.context')
1058 if (await isKilled($)) return { text: `gates on for this session, but CLAUDE_MODS_DISABLE names ${MOD}, so no rule is enforced until it is unset.` }
1059 return { text: 'gates on: files per turn, protected paths, plan gate, ship gate and merge gate are enforced.' }
1060 }
1061 if (verb === 'reload') {
1062 const c = await loadConfig($)
1063 const files = await read($, gateConfigFiles)
1064 const errors = await read($, gateConfigErrors)
1065 return {
1066 text: [
1067 files.length > 0 ? `reloaded ${files.join(', ')}` : 'reloaded: no config files, defaults only',
1068 ...describeRules(c, await read($, gateSources)),
1069 ...errors.map(x => `ignored: ${x}`),
1070 ].join('\n'),
1071 }
1072 }
1073 if (verb === 'max') {
1074 const n = Number(rest[1])
1075 if (!Number.isInteger(n) || n < 1 || n > 1000) return { text: `usage: ${GATES_CMD} max <n> (a whole number from 1 to 1000)` }
1076 await setOverride($, { maxFilesPerTurn: n })
1077 await loadConfig($)
1078 return { text: `maxFilesPerTurn is ${n} for this session.` }
1079 }
1080 if (verb === 'plan') {
1081 if (value === 'required' && (toggle === 'on' || toggle === 'off')) {
1082 await setOverride($, { requirePlan: toggle === 'on' })
1083 await loadConfig($)
1084 return { text: `requirePlan is ${toggle} for this session${toggle === 'on' ? ': edits wait for an approved plan' : ''}.` }
1085 }
1086 if (value === 'paths') {
1087 const config = await ensureConfig($)
1088 return { text: planPathsText(config.planPaths, (await read($, gateSources)).planPaths ?? 'default') }
1089 }
1090 if (value !== 'ok') return { text: `usage: ${GATES_CMD} plan ok | plan required on|off | plan paths` }
1091 await update($, gatePlanOk, () => true)
1092 await update($, gatePlanBy, () => `${GATES_CMD} plan ok`)
1093 $.ui.invalidate('prompt.context')
1094 // the pipeline's plan gate is a separate approval: say so while it is still pending
1095 const gate = gatedStage(await stagesNow($), 'validate-plan')
1096 const pending = gate !== undefined && (await read($, stagePlanDone)) !== true && (await read($, stageCurrent)) !== ''
1097 const separate = pending && gate
1098 ? ` The pipeline's ${gate.label} gate is separate: moving past it still needs /lk approve ` +
1099 "(in approval mode auto, a plan validator's consistent verdict also opens it)."
1100 : ''
1101 return { text: 'plan approved for this session: edits may go ahead.' + separate }
1102 }
1103 if (verb === 'ship') {
1104 if (value === 'gate' && (toggle === 'on' || toggle === 'off')) {
1105 await setOverride($, { shipGate: toggle === 'on' })
1106 await loadConfig($)
1107 return { text: `shipGate is ${toggle} for this session.` }
1108 }
1109 if (value !== 'ok') return { text: `usage: ${GATES_CMD} ship ok | ship gate on|off` }
1110 if (!fromPerson) return { text: 'Only the user can approve shipping.' }
1111 await $.store.set(await sessionKey($, 'shipOk'), true)
1112 await update($, gateShipOk, () => true)
1113 // the no-pipeline form of the Ship approval: it covers the next push or merge to the default branch too
1114 await update($, gateMergeCredit, () => true)
1115 return { text: 'shipping approved for this session: commits, pushes and publishes may go ahead, and the next push or merge to the default branch is covered.' }
1116 }
1117 if (verb === 'merge') {
1118 if (value !== 'ok') return { text: `usage: ${GATES_CMD} merge ok` }
1119 if (!fromPerson) return { text: 'Only the user can allow a push or merge to the default branch.' }
1120 await update($, gateMergeCredit, () => true)
1121 return { text: 'The next push or merge to the default branch is allowed (once).' }
1122 }
1123 if (verb === 'allow') return allowPath($, rest.slice(1).join(' ').trim(), fromPerson)
1124 if (verb === 'band') {
1125 if (value !== 'on' && value !== 'off') return { text: `usage: ${GATES_CMD} band on|off` }
1126 const saved = await setGatesBand($, value === 'on')
1127 return { text: `gates band ${value}${saved ? ' (saved for future sessions)' : ' (this session only)'}.` }
1128 }
1129 return { text: USAGE }
1130}
1131
1132/** `/lk band [concerns] on|off`: the gates line, or '' when the words name other concerns only. */
1133async function gatesBandLine($: EngineInterface, rest: string[]): Promise<string> {
1134 const words = rest.map(w => w.toLowerCase())
1135 const named = words.filter(w => CONCERN_WORDS.includes(w))
1136 if (named.length > 0 && !named.includes('gates')) return ''
1137 const word = words.find(w => w === 'on' || w === 'off')
1138 if (!word) {
1139 const shown = await read($, gateBand)
1140 return `The gates band is ${shown ? 'on' : 'off'}. Usage: /lk band [gates] on|off`
1141 }
1142 const saved = await setGatesBand($, word === 'on')
1143 return `The gates band is ${word}.${saved ? '' : ' (this session only)'}`
1144}
1145
1146async function reloadShared($: EngineInterface): Promise<string> {
1147 await loadConfig($)
1148 const files = await read($, gateConfigFiles)
1149 const errors = await read($, gateConfigErrors)
1150 return joinText(
1151 files.length > 0 ? `gates: reloaded ${files.join(', ')}` : 'gates: reloaded, no config files, defaults only',
1152 ...errors.map(x => `gates: ignored: ${x}`),
1153 )
1154}
1155
1156/** GSD mode's part in the gates: plan required on while GSD mode and its loop are on, off when either goes. */
1157async function gsdPlan($: EngineInterface, active: boolean): Promise<string> {
1158 await setOverride($, { requirePlan: active })
1159 await loadConfig($)
1160 return `gates: plan required ${active ? 'on' : 'off'} (GSD loop)`
1161}
1162
1163/** At session start: a session that starts with GSD mode and its loop on requires a plan, as /lk on would have set. */
1164async function matchGsd($: EngineInterface): Promise<void> {
1165 if (!(await read($, routeMode)) || !(await read($, routeLoop))) return
1166 if ((await readOverride($))?.requirePlan === true) return
1167 await gsdPlan($, true)
1168}
1169
1170// The gates' line for a /lk word other concerns also answer (status, text, reload, band, on, off, loop)
1171async function gatesSharedLine($: EngineInterface, sub: string, rest: string[], was: unknown): Promise<string> {
1172 if (sub === 'status') {
1173 const status = await statusLine($)
1174 return status
1175 }
1176 if (sub === 'text') {
1177 const status = await statusLine($)
1178 const lines = await statusLines($)
1179 return joinText(status, ...lines.slice(1))
1180 }
1181 if (sub === 'reload') {
1182 const reloaded = await reloadShared($)
1183 return reloaded
1184 }
1185 if (sub === 'band') {
1186 const band = await gatesBandLine($, rest)
1187 return band
1188 }
1189 if (sub === 'on' || sub === 'off') {
1190 const loopOn = await read($, routeLoop)
1191 if (was === (sub === 'on') || !loopOn) return ''
1192 const plan = await gsdPlan($, sub === 'on')
1193 return plan
1194 }
1195 const word = (rest[0] ?? '').toLowerCase()
1196 if (word !== 'on' && word !== 'off') return ''
1197 const modeOn = await read($, routeMode)
1198 if (!modeOn) return ''
1199 const plan = await gsdPlan($, word === 'on')
1200 return planhooks/review.ts 855 lines1// Review concern: bounded review and critique cycles, visible and enforced. Ported from review-loop.
2//
3// - session.start: loads this folder's subjects from $.store (pruning those idle 14 days), reads the
4// config file's review section, registers the three tools and /reviews
5// - review_cycle / review_item / review_status are answered here in tool.call; stage's wrapper around
6// review_cycle reads the reviewSubjects atom afterwards
7// - every cycle is appended to <cwd>/.claude/loopkeeper-review.jsonl (only this concern ever read it)
8// - prompt.context carries a short block with the tool contract and the rule
9// - the band shows the active loop; /reviews opens a pane or prints text; /lk status adds a line
10// - the hub (/lk) draws a Reviews row (hotkey 5); the pane leads with the nav bar of every loopkeeper pane
11
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, On } from 'claude-code'
14
15import { mergeConfigText } from './lib/config.ts'
16import { TONES, makeKit } from './lib/kit.ts'
17import type { Elements, Tone } from './lib/kit.ts'
18import { TOOL_FULL, joinText, projectConfigPath, storeKey, subOf, userConfigPath } from './lib/names.ts'
19import { HUB_ID, HUB_ROWS, PANES } from './lib/panes.ts'
20import {
21 CONTEXT_BLOCK,
22 KIND_NAMES,
23 LOG_FILE,
24 SUBJECTS_PREFIX,
25 USAGE,
26 absOf,
27 activeSubjects,
28 applyCycle,
29 applyItem,
30 bandOf,
31 blocking,
32 contextText,
33 cycleInputOf,
34 cycleReply,
35 cyclesLeft,
36 defaultsLine,
37 doneItems,
38 findSubject,
39 fmt,
40 historyText,
41 itemInputOf,
42 jsonlLine,
43 kindDefaultsFrom,
44 kindOf,
45 kindsText,
46 lastCycle,
47 nameOf,
48 notConverging,
49 notConvergingToast,
50 openItems,
51 parseArgs,
52 progressText,
53 prune,
54 reached,
55 reachedToast,
56 scoresOf,
57 sparkline,
58 statusText,
59 storeKeyOf,
60 subjectText,
61 subjectsFrom,
62 targetOf,
63 unitOf,
64 usedCycles,
65 validMax,
66 validTarget,
67 withKind,
68} from './lib/reviewModel.ts'
69import type { Defaults, Item, KindDefaults, Subject, Subjects } from './lib/reviewModel.ts'
70
71const PANE_ID = 'loopkeeper-review'
72const BAND_KEY = storeKey('review', 'band')
73const CONTEXT_KEY = storeKey('review', 'context')
74const DEFAULTS_KEY = storeKey('review', 'defaults')
75
76// Declared by string literal: the same keys as in types/index.d.ts, and stage reads reviewSubjects.
77const reviewSubjects = atom({ plugin: 'loopkeeper', key: 'reviewSubjects' } as const, {} as Subjects)
78const reviewCwd = atom({ plugin: 'loopkeeper', key: 'reviewCwd' } as const, '')
79const reviewBand = atom({ plugin: 'loopkeeper', key: 'reviewBand' } as const, true)
80const reviewContext = atom({ plugin: 'loopkeeper', key: 'reviewContext' } as const, true)
81// only what the user set with /reviews target and /reviews max; the rest comes from the config file or the kind
82const reviewDefaults = atom({ plugin: 'loopkeeper', key: 'reviewDefaults' } as const, {} as Defaults)
83// The config file's review.kinds, sanitised: per kind target and maxCycles. Plain module state, re-read at
84// every session start, /clear, /resume and /lk reload, so it needs no atom in the state contract.
85const mem: { kinds: KindDefaults } = { kinds: {} }
86
87const messageOf = (err: unknown) => (err instanceof Error ? err.message : String(err))
88
89/** Both config files, merged. Local on purpose: $ is never passed across an import. */
90async function readConfig($: EngineInterface) {
91 const home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
92 const cwd = (await $.session.cwd().catch(() => undefined)) ?? ''
93 const textOf = async (path: string): Promise<string | null> => {
94 try {
95 return (await $.fs.exists(path)) ? await $.fs.read(path) : null
96 } catch {
97 return null
98 }
99 }
100 const user = home ? await textOf(userConfigPath(home)) : null
101 const project = cwd ? await textOf(projectConfigPath(cwd)) : null
102 return mergeConfigText(user, project)
103}
104
105/** The user's store override with the config layer, as the model's functions take them. */
106async function defaultsOf($: EngineInterface): Promise<Defaults> {
107 return { ...(await read($, reviewDefaults)), kinds: mem.kinds }
108}
109
110async function statusLine($: EngineInterface): Promise<string> {
111 const all = Object.values(await read($, reviewSubjects))
112 return `review ● ${all.filter(s => s.status === 'active').length} active of ${all.length} subjects`
113}
114
115const CONCERN_WORDS = ['stage', 'gates', 'files', 'routing', 'review', 'decisions']
116
117/** `/lk band [concerns] on|off`: the review line, or '' when the words name other concerns only. */
118async function bandShared($: EngineInterface, rest: string[]): Promise<string> {
119 const words = rest.map(w => w.toLowerCase())
120 const named = words.filter(w => CONCERN_WORDS.includes(w))
121 if (named.length > 0 && !named.includes('review')) return ''
122 const word = words.find(w => w === 'on' || w === 'off')
123 if (!word) {
124 const shown = await read($, reviewBand)
125 return `The review band is ${shown ? 'on' : 'off'}. Usage: /lk band [review] on|off`
126 }
127 await update($, reviewBand, () => word === 'on')
128 try {
129 await $.store.set(BAND_KEY, word === 'on')
130 } catch {
131 return `The review band is ${word}. (this session only)`
132 }
133 return `The review band is ${word}.`
134}
135
136/** /lk and /loopkeeper: review owns no words of its own there; it adds its line to status, reload and band. */
137async function lk($: EngineInterface, e: { args: string }, next: (e: any) => Promise<{ text?: string } | undefined>) {
138 const { sub, rest } = subOf(e.args)
139 if (sub === 'band') {
140 const after = await next(e)
141 let line: string
142 try {
143 line = await bandShared($, rest)
144 } catch {
145 line = 'loopkeeper: review could not answer "band".'
146 }
147 return { ...(after ?? {}), text: joinText(line, after?.text) }
148 }
149 if (sub === 'status') {
150 const after = await next(e)
151 return { ...(after ?? {}), text: joinText(await statusLine($), after?.text) }
152 }
153 if (sub === 'reload') {
154 const after = await next(e)
155 let line: string
156 try {
157 const { config } = await readConfig($)
158 const kinds = kindDefaultsFrom(config.review.kinds)
159 mem.kinds = kinds
160 const n = Object.keys(kinds).length
161 line = `review ● config reloaded: ${n} kind override${n === 1 ? '' : 's'}`
162 } catch {
163 line = 'review ● config not reloaded'
164 }
165 return { ...(after ?? {}), text: joinText(line, after?.text) }
166 }
167 return next(e)
168}
169
170const TOOLS = [
171 {
172 name: 'review_cycle',
173 description:
174 'Record one pass of a bounded loop (critic pass, code review round, plan validation, test run, copy edit, design review) ' +
175 'for a subject (an asset name, a PR, a plan, a file set) and get its loop state back: cycle n/max, history, whether it is ' +
176 'done, passes left and open items. Call it right after each pass. "kind" (first pass only, default critic) sets the rules: ' +
177 'critic (score, target 9.0, 4 passes), design (score, 9.0, 4), copy (score, 8.5, 3), review (items, 3 rounds), plan ' +
178 '(items, 2 passes), qa (items, 3 runs). Score kinds need "score" out of 10 and are done when it reaches the target; item ' +
179 'kinds ("score" optional) are done when no p0 or p1 item is open. Leave "cycle" out to use the next number. List the ' +
180 'findings in "items"; items carry over between cycles until marked done. kind, target and maxCycles count only on the ' +
181 'first pass of a subject; afterwards only the user changes them. A pass past maxCycles is refused: when the reply says ' +
182 '"not converging", stop iterating and surface to the user.',
183 inputSchema: {
184 type: 'object',
185 properties: {
186 subject: { type: 'string', description: 'What is under review: asset name, PR, or file set' },
187 cycle: { type: 'number', description: 'Cycle number, 1-based; omit to auto-increment' },
188 kind: { type: 'string', enum: ['critic', 'review', 'plan', 'qa', 'copy', 'design'], description: 'What kind of loop this is (default critic); first pass only' },
189 doneBy: { type: 'string', enum: ['score', 'items'], description: 'Override how the kind is done; first pass only' },
190 score: { type: 'number', description: 'Score from 0 to 10; required for score kinds (critic, copy, design), optional for item kinds' },
191 target: { type: 'number', description: 'Score that counts as done (kind default, 9.0 for critic); score kinds, first pass only' },
192 maxCycles: { type: 'number', description: 'Most passes allowed (kind default, 4 for critic); first pass only' },
193 verdict: { type: 'string', enum: ['ship', 'iterate', 'stop'], description: 'The reviewer verdict; consistent, pass, lgtm, approved also mean ship, and issues, needs-work, red, failing mean iterate' },
194 items: {
195 type: 'array',
196 description: 'Prioritized findings or fixes from the pass',
197 items: {
198 type: 'object',
199 properties: {
200 text: { type: 'string' },
201 priority: { type: 'string', enum: ['p0', 'p1', 'p2'] },
202 done: { type: 'boolean' },
203 },
204 required: ['text'],
205 },
206 },
207 notes: { type: 'string', description: 'One or two lines from the critique worth keeping' },
208 },
209 required: ['subject'],
210 },
211 },
212 {
213 name: 'review_item',
214 description:
215 'Add an open item to a review subject, or close one with done: true and a proof (a file path, a command output ' +
216 'excerpt, or a commit). Closing without proof is allowed but is shown as "(no proof)". done: false reopens an item.',
217 inputSchema: {
218 type: 'object',
219 properties: {
220 subject: { type: 'string', description: 'The review subject' },
221 text: { type: 'string', description: 'The item text; when closing, the full text or a unique part of it' },
222 done: { type: 'boolean', description: 'true closes the item, false reopens it' },
223 proof: { type: 'string', description: 'Evidence the fix landed' },
224 priority: { type: 'string', enum: ['p0', 'p1', 'p2'] },
225 },
226 required: ['subject', 'text'],
227 },
228 },
229 {
230 name: 'review_status',
231 description: 'Show the review loop state of one subject, or of every subject in this project when "subject" is left out.',
232 inputSchema: {
233 type: 'object',
234 properties: { subject: { type: 'string', description: 'The review subject; omit for all' } },
235 },
236 },
237] as const
238
239// ---------- the hub and the nav bar
240
241// HUB_ID, HUB_ROWS and PANES (every loopkeeper pane in wiring order) come from lib/panes.ts.
242
243// the "ctrl+x tab to focus the panel" toast showed this session (stage.ts's atom, shared by all six concern files)
244const stageFocusHinted = atom({ plugin: 'loopkeeper', key: 'stageFocusHinted' } as const, false)
245
246// Switch panes: close `fromId` first ('' closes nothing), since a focus request is refused while the person holds a
247// pane, then open `toId` with the focus. The composer keeps the focus while it holds a draft: then a toast says how to
248// reach the pane, once a session and only in the terminal (the press's `surface`; with no press, a session whose every
249// surface is the terminal). False when the pane could not be opened.
250async function switchTo($: EngineInterface, fromId: string, toId: string, title: string, surface?: unknown): Promise<boolean> {
251 if (fromId) {
252 try {
253 await $.ui.close({ id: fromId })
254 } catch {
255 // already closed
256 }
257 }
258 try {
259 await $.ui.open({ id: toId, title, focus: true, closeOnEscape: true, ...(toId === HUB_ID ? { rows: HUB_ROWS } : {}) })
260 } catch {
261 return false
262 }
263 try {
264 const pane = (await $.ui.panes()).find(p => p.id === toId)
265 if (pane && !pane.isFocused && !(await read($, stageFocusHinted))) {
266 const terminal = typeof surface === 'string' ? surface === 'terminal' : (await $.session.surfaces()).every(s => s === 'terminal')
267 if (terminal) {
268 await update($, stageFocusHinted, () => true)
269 toast($, 'ctrl+x tab to focus the panel')
270 }
271 }
272 } catch {
273 // no list: no hint
274 }
275 return true
276}
277
278// The nav bar's items: every loopkeeper pane, a press switching from `fromId` to it
279function navItems($: EngineInterface, fromId: string) {
280 return PANES.map(p => ({
281 key: p.key,
282 label: p.label,
283 hotkey: p.hotkey,
284 onPress: async (e?: { surface?: unknown }) => {
285 if (!(await switchTo($, fromId, p.id, p.title, e?.surface))) toast($, 'Could not open the ' + p.label + ' pane.')
286 },
287 }))
288}
289
290/** The hub row's words: the status line without its label (`1 active of 2 subjects`), and `band off` when hidden. */
291async function hubWords($: EngineInterface): Promise<string> {
292 const all = Object.values(await read($, reviewSubjects))
293 const words = [`${all.filter(s => s.status === 'active').length} active of ${all.length} subjects`]
294 if (!(await read($, reviewBand))) words.push('band off')
295 return words.join(' · ')
296}
297
298export function wireReview(on: On): void {
299 on('session.start', { isInteractive: [true, false] }, async ($, e, next) => {
300 await load($)
301 for (const tool of TOOLS) {
302 try {
303 await $.tool.register({ ...tool, inputSchema: tool.inputSchema as any, isDeferred: false })
304 } catch (err) {
305 note($, `${tool.name} not registered: ${messageOf(err)}`)
306 }
307 }
308 try {
309 await $.command.register({
310 name: 'reviews',
311 description: 'Review loops: pane, or text | clear | kinds | kind | target | max | ship | reopen | stop | band | context',
312 argumentHint: '[text | clear <subject> | kinds | kind <subject> <kind> | target <n> [subject] | max <n> [subject] | band on|off]',
313 immediate: true,
314 })
315 } catch (err) {
316 note($, '/reviews not registered: ' + messageOf(err))
317 }
318 return next(e)
319 })
320
321 // /clear, /resume and /branch reset $.state without a new session.start
322 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
323 await load($)
324 return next(e)
325 }).catch(async () => ({}))
326
327 on('tool.call', { tool: TOOL_FULL.reviewCycle }, async ($, e) => ({ result: await onCycle($, e) })).catch(
328 async () => ({ result: 'Not recorded: loopkeeper review failed while recording the cycle.' }),
329 )
330 on('tool.call', { tool: TOOL_FULL.reviewItem }, async ($, e) => ({ result: await onItem($, e) })).catch(
331 async () => ({ result: 'Not changed: loopkeeper review failed while updating the item.' }),
332 )
333 on('tool.call', { tool: TOOL_FULL.reviewStatus }, async ($, e) => {
334 const name = nameOf((e as { subject?: unknown } | undefined)?.subject)
335 return { result: statusText(await read($, reviewSubjects), name || undefined) }
336 }).catch(async () => ({ result: 'loopkeeper: review could not read its state.' }))
337
338 on('command.run', { command: 'reviews' }, async ($, e) => {
339 try {
340 return await onCommand($, e?.args)
341 } catch (err) {
342 return { text: 'loopkeeper: ' + messageOf(err) }
343 }
344 }).catch(async () => ({ text: 'loopkeeper: review could not run the command.' }))
345 on('command.run', { command: 'lk' }, async ($, e, next) => lk($, e, next)).catch(async () => ({ text: 'loopkeeper: review could not run the command.' }))
346 on('command.run', { command: 'loopkeeper' }, async ($, e, next) => lk($, e, next)).catch(async () => ({ text: 'loopkeeper: review could not run the command.' }))
347
348 on('prompt.context', async ($, e, next) => {
349 const below = await next(e)
350 try {
351 const blocks = Array.isArray(below?.blocks) ? below.blocks.filter(b => b?.name !== CONTEXT_BLOCK) : []
352 if (!(await read($, reviewContext))) return { ...below, blocks }
353 const text = contextText(await defaultsOf($))
354 return { ...below, blocks: [...blocks, { name: CONTEXT_BLOCK, text }] }
355 } catch {
356 return below
357 }
358 })
359
360 // The band: one kit line, `review ● login_page cycle 3/4 ● 2 open 8.4 → 9.0` (bandOf names the tones).
361 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
362 const isOn = await read($, reviewBand)
363 const band = isOn && !e?.props?.hasSurvey ? bandOf(await read($, reviewSubjects)) : null
364 if (!band) return next(e)
365 const ui = $.ui.resolve(e)
366 const { Box } = ui
367 const kit = makeKit(ui as unknown as Elements, e.surface)
368 const line = kit.line('review', band.chips, band.right) as never
369 // Keep what the mods after this one draw in the band, under this line.
370 const theirs = await next(e)
371 if (!theirs) return line
372 return Box({ flexDirection: 'column', children: [line, theirs] })
373 })
374
375 // The hub's Reviews row, above the later concerns' rows (next): `5: Reviews` and the status words, quiet. It
376 // shows with the band off; a failure draws what the chain drew.
377 on('ui.render', { component: 'Pane', requestId: HUB_ID }, async ($, e, next) => {
378 let asked = false
379 let theirs: unknown = null
380 try {
381 const ui = $.ui.resolve(e) as unknown as Elements
382 const kit = makeKit(ui, e.surface)
383 const row = kit.row({
384 key: 'hub-reviews',
385 left: [
386 ui.Button({
387 key: 'panel-reviews',
388 label: 'Reviews',
389 hotkey: '5',
390 plain: true,
391 onPress: async (press?: { surface?: unknown }) => {
392 if (!(await switchTo($, HUB_ID, PANE_ID, 'Review loops', press?.surface))) toast($, 'Could not open the Reviews pane.')
393 },
394 }),
395 kit.text(await hubWords($), { color: TONES.muted.fg, wrap: 'truncate' }),
396 ],
397 })
398 asked = true
399 theirs = await next(e)
400 return (theirs === null || theirs === undefined ? row : kit.stack([row, theirs])) as never
401 } catch {
402 return (asked ? theirs : await next(e)) as never
403 }
404 })
405
406 // The pane, drawn with the kit: the nav bar, a hero for the selected subject (the band's, else the newest),
407 // a score gauge or an items bar, the cycle strip, tiles, the items, the other subjects, buttons.
408 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e, next) => {
409 if (e?.requestId !== PANE_ID) return next(e)
410 const ui = $.ui.resolve(e)
411 const kit = makeKit(ui as unknown as Elements, e.surface)
412 const el = ui as unknown as Elements
413 // kit v3: a piece that keeps its width beside wrapping text, and a full-width Svg bar where the surface draws Svg
414 const keep = (child: unknown) => el.Box({ flexShrink: 0, children: [child] })
415 const svgBars = e.surface !== 'terminal' && typeof el.Svg === 'function'
416 const all = await read($, reviewSubjects)
417 const subjects = Object.values(all).sort((a, b) => b.updatedAt - a.updatedAt)
418 const defaults = await read($, reviewDefaults)
419 const closePane = {
420 key: 'close',
421 label: 'Close pane',
422 hotkey: 'c',
423 plain: true,
424 onPress: async () => {
425 try {
426 await $.ui.close({ id: PANE_ID })
427 } catch {
428 // already closed
429 }
430 },
431 }
432 const subjectButtons = (s: Subject) => [
433 { key: 'ship:' + s.name, label: 'Mark shipped', plain: true, onPress: async () => void (await setStatus($, s.name, 'ship')) },
434 { key: 'reopen:' + s.name, label: 'Reopen', plain: true, onPress: async () => void (await setStatus($, s.name, 'active')) },
435 { key: 'stop:' + s.name, label: 'Close', plain: true, onPress: async () => void (await setStatus($, s.name, 'stop')) },
436 ]
437
438 // the nav bar leads every drawing of the pane
439 const nav = kit.nav(navItems($, PANE_ID), 'reviews')
440
441 const s = activeSubjects(all)[0] ?? subjects[0]
442 if (!s) {
443 return kit.page([
444 nav,
445 kit.hero({ kicker: 'review loop', title: 'No reviews yet', sub: 'Claude records one with review_cycle after each pass.', tone: 'muted' }),
446 kit.text(defaultsLine(defaults), { color: TONES.muted.fg }),
447 kit.buttons([closePane]),
448 ]) as never
449 }
450
451 const last = lastCycle(s)
452 const used = usedCycles(s)
453 const unit = unitOf(s)
454 const open = openItems(s)
455 const p0 = open.filter(i => i.priority === 'p0').length
456 const isDone = reached(s)
457 const state = stateOf(s)
458 const heroTone: Tone = isDone || s.status === 'ship' ? 'ok' : notConverging(s) ? 'danger' : s.status === 'stop' ? 'muted' : 'info'
459 const sub =
460 `${unit} ${used}/${s.maxCycles} · ` +
461 (s.doneBy === 'score' ? `target ${fmt(targetOf(s))}` : `${p0 ? `${p0} p0 · ` : ''}${open.length} open`) +
462 (state === 'active' ? '' : ` · ${state}`)
463
464 // Score kinds: the last score as a gauge out of 10. Item kinds: how many items are closed. On Svg surfaces the
465 // bar is kit.progress, full width, above its figure; the terminal keeps the text bar beside it.
466 const meter = (ratio: number, tone: Tone, figure: string, right: string): unknown =>
467 svgBars
468 ? kit.stack([kit.progress(ratio, { tone }), kit.row({ key: 'gauge', left: [kit.text(figure, { bold: true })], right })])
469 : kit.row({ key: 'gauge', left: [kit.bar(ratio, { width: 20, tone }), kit.text(figure, { bold: true })], right })
470 let gauge: unknown = null
471 if (s.doneBy === 'score') {
472 const score = last?.score
473 gauge =
474 score === undefined
475 ? kit.text('no scores yet', { color: TONES.muted.fg })
476 : meter(score / 10, score >= targetOf(s) ? 'ok' : 'warn', fmt(score), `target ${fmt(targetOf(s))}`)
477 } else if (s.items.length) {
478 const closed = s.items.length - open.length
479 gauge = meter(closed / s.items.length, blocking(s) ? 'warn' : 'ok', `${closed}/${s.items.length} closed`, 'until no open p0/p1')
480 }
481
482 const spark = sparkline(scoresOf(s))
483 const historyTone: Tone | null = isDone ? 'ok' : notConverging(s) ? 'danger' : null
484 const strip = kit.row({
485 key: 'cycles',
486 left: Array.from({ length: Math.max(s.maxCycles, used) }, (_, n) => {
487 const i = n + 1
488 const tone: Tone = i < used ? 'ok' : i === used ? (s.status === 'active' && !isDone ? 'info' : 'ok') : 'muted'
489 return kit.badge(circled(i), tone)
490 }),
491 })
492 const history = kit.row({
493 key: 'history',
494 left: [kit.text(historyText(s), historyTone ? { color: TONES[historyTone].fg, wrap: 'truncate' } : { wrap: 'truncate' })],
495 right: spark ? [kit.text(spark, { color: TONES.info.fg })] : undefined,
496 })
497
498 const verdict = last?.verdict
499 const tiles = kit.tiles([
500 { label: unit === 'round' ? 'Round' : 'Cycle', value: `${used}/${s.maxCycles}`, tone: notConverging(s) ? 'danger' : 'info' },
501 s.doneBy === 'score'
502 ? { label: 'Score', value: last?.score === undefined ? '–' : fmt(last.score), tone: isDone ? 'ok' : 'warn' }
503 : { label: 'Open', value: String(open.length), tone: open.length ? 'warn' : 'ok' },
504 s.doneBy === 'score'
505 ? { label: 'Target', value: fmt(targetOf(s)), tone: 'muted' }
506 : { label: 'P0', value: String(p0), tone: p0 ? 'danger' : 'muted' },
507 { label: 'Verdict', value: verdict ?? '–', tone: verdict === 'ship' ? 'ok' : verdict === 'stop' ? 'danger' : verdict === 'iterate' ? 'warn' : 'muted' },
508 ])
509
510 const items = [...open, ...doneItems(s)]
511 const itemRows: unknown[] = items.length
512 ? items.map((i, n) =>
513 kit.row({
514 key: `item-${n}`,
515 align: 'center',
516 left: [
517 keep(kit.pill(i.priority, PRIORITY_TONE[i.priority])),
518 keep(kit.text(i.done ? '✓' : '○', i.done ? { color: TONES.ok.fg } : { color: TONES.muted.fg })),
519 kit.text(i.text, i.done ? { color: TONES.muted.fg, wrap: 'wrap' } : { wrap: 'wrap' }),
520 ],
521 right: i.done ? i.proof ?? '(no proof)' : undefined,
522 }),
523 )
524 : [kit.text('no items', { color: TONES.muted.fg })]
525
526 const others = subjects.filter(o => o.name !== s.name)
527 const otherRows = others.map(o => {
528 const st = stateOf(o)
529 return kit.stack([
530 kit.row({
531 key: 'subject-' + o.name,
532 left: [kit.text(o.name, { bold: true, wrap: 'truncate' }), kit.text(o.kind, { color: TONES.muted.fg }), kit.badge(st, STATE_TONE[st])],
533 right: progressText(o),
534 }),
535 kit.buttons(subjectButtons(o)),
536 ])
537 })
538
539 // kit 3.1: the other subjects' blocks one row apart (list), each block tight inside
540 return kit.page([
541 nav,
542 kit.hero({ kicker: 'review loop', title: `${s.name} · ${s.kind}`, sub, tone: heroTone }),
543 gauge,
544 strip,
545 history,
546 tiles,
547 kit.stack([kit.section(`Items · ${items.length}`, open.length ? `${open.length} open` : 'none open'), ...itemRows]),
548 kit.buttons(subjectButtons(s)),
549 others.length ? kit.stack([kit.section(`Subjects · ${others.length}`), kit.list(otherRows)]) : null,
550 kit.row({ left: [kit.text(defaultsLine(defaults), { color: TONES.muted.fg })], right: [kit.buttons([closePane])] }),
551 ]) as never
552 })
553}
554
555// ---------- drawing (pure) ----------
556
557const PRIORITY_TONE: Record<Item['priority'], Tone> = { p0: 'danger', p1: 'warn', p2: 'muted' }
558
559type State = 'active' | 'shipped' | 'stopped' | 'not converging'
560const STATE_TONE: Record<State, Tone> = { active: 'info', shipped: 'ok', stopped: 'muted', 'not converging': 'danger' }
561
562/** The word the pane uses for where a subject's loop stands. */
563function stateOf(s: Subject): State {
564 return s.status === 'ship' ? 'shipped' : s.status === 'stop' ? 'stopped' : notConverging(s) ? 'not converging' : 'active'
565}
566
567/** ① … ⑳ for 1 to 20, then (21) and up. */
568function circled(n: number): string {
569 return n >= 1 && n <= 20 ? String.fromCharCode(0x2460 + n - 1) : `(${n})`
570}
571
572// ---------- handlers ----------
573
574async function onCycle($: EngineInterface, e: unknown): Promise<string> {
575 const input = cycleInputOf(e)
576 const now = await nowMs($)
577 const outcome = applyCycle(await read($, reviewSubjects), input, await defaultsOf($), now)
578 if (!outcome.ok) {
579 if (outcome.reply.includes('past the limit')) toast($, `review ${input.subject}: cycle limit reached, surface to the user`)
580 return outcome.reply
581 }
582 const s = outcome.subject
583 await save($, s)
584 await appendLog($, s, outcome.cycle)
585 if (notConverging(s) && s.status === 'active') {
586 toast($, notConvergingToast(s))
587 } else if (reached(s) && s.status === 'active') {
588 toast($, reachedToast(s))
589 }
590 return cycleReply(s, outcome.warnings)
591}
592
593async function onItem($: EngineInterface, e: unknown): Promise<string> {
594 const outcome = applyItem(await read($, reviewSubjects), itemInputOf(e), await defaultsOf($), await nowMs($))
595 if (!outcome.ok) return outcome.reply
596 await save($, outcome.subject)
597 return outcome.reply
598}
599
600async function onCommand($: EngineInterface, args: unknown): Promise<{ text?: string }> {
601 const { verb, rest } = parseArgs(args)
602 if (verb === '' || verb === 'open' || verb === 'pane') {
603 try {
604 await $.ui.open({ id: PANE_ID, title: 'Review loops', focus: true, closeOnEscape: true })
605 } catch (err) {
606 return { text: 'Could not open the pane: ' + messageOf(err) }
607 }
608 return {}
609 }
610 if (verb === 'text' || verb === 'status') {
611 return { text: statusText(await read($, reviewSubjects), nameOf(rest.join(' ')) || undefined) }
612 }
613 if (verb === 'clear') {
614 const name = nameOf(rest.join(' '))
615 if (!name) return { text: 'Usage: /reviews clear <subject>' }
616 const subjects = await read($, reviewSubjects)
617 const s = findSubject(subjects, name)
618 if (!s) return { text: statusText(subjects, name) }
619 await update($, reviewSubjects, all => {
620 const copy = { ...all }
621 delete copy[s.name]
622 return copy
623 })
624 await persist($)
625 return { text: `Cleared ${s.name}.` }
626 }
627 if (verb === 'kinds') return { text: kindsText() }
628 if (verb === 'kind') {
629 const kind = kindOf(rest[rest.length - 1])
630 const name = nameOf(rest.slice(0, -1).join(' '))
631 if (!name || !kind) return { text: 'Usage: /reviews kind <subject> <kind>, where kind is ' + KIND_NAMES.join(', ') + '.' }
632 const subjects = await read($, reviewSubjects)
633 const s = findSubject(subjects, name)
634 if (!s) return { text: statusText(subjects, name) }
635 const changed = withKind(s, kind, await defaultsOf($), await nowMs($))
636 await save($, changed)
637 return { text: `${s.name} is now kind ${kind} (${changed.doneBy === 'score' ? `done by score, target ${fmt(changed.target ?? 0)}` : 'done by items'}, max ${changed.maxCycles}).\n\n${subjectText(changed)}` }
638 }
639 if (verb === 'target' || verb === 'max') {
640 const isTarget = verb === 'target'
641 const value = isTarget ? validTarget(rest[0]) : validMax(rest[0])
642 if (value === undefined) {
643 const d = await read($, reviewDefaults)
644 return {
645 text: isTarget
646 ? `Default target is ${d.target !== undefined ? fmt(d.target) : 'set by kind (critic 9.0, copy 8.5, design 9.0)'}. Use /reviews target <0–10> [subject].`
647 : `Default max is ${d.maxCycles !== undefined ? d.maxCycles : 'set by kind (critic 4, review 3, plan 2, qa 3, copy 3, design 4)'}. Use /reviews max <1–20> [subject].`,
648 }
649 }
650 const name = nameOf(rest.slice(1).join(' '))
651 if (name) {
652 const s = findSubject(await read($, reviewSubjects), name)
653 if (!s) return { text: statusText(await read($, reviewSubjects), name) }
654 if (isTarget && s.doneBy === 'items') return { text: `${s.name} is kind ${s.kind}, done by items: it has no score target. Use /reviews kind ${s.name} critic to score it.` }
655 const changed: Subject = { ...s, ...(isTarget ? { target: value } : { maxCycles: value }), updatedAt: await nowMs($) }
656 await save($, changed)
657 return { text: (isTarget ? `Target for ${s.name} is now ${fmt(value)}.` : `Max cycles for ${s.name} is now ${value}.`) + '\n\n' + subjectText(changed) }
658 }
659 const d = await read($, reviewDefaults)
660 const saved: Defaults = isTarget ? { ...d, target: value } : { ...d, maxCycles: value }
661 await update($, reviewDefaults, () => saved)
662 $.ui.invalidate('prompt.context')
663 try {
664 await $.store.set(DEFAULTS_KEY, saved)
665 } catch (err) {
666 return { text: `Default ${verb} ${isTarget ? fmt(value) : value} for this session; not saved: ${messageOf(err)}` }
667 }
668 return {
669 text: `Default ${verb} is now ${isTarget ? fmt(value) : value} for new ${isTarget ? 'score-kind ' : ''}subjects, over the kind presets. Existing subjects keep theirs; add a subject name to change one.`,
670 }
671 }
672 if (verb === 'ship' || verb === 'reopen' || verb === 'stop') {
673 const name = nameOf(rest.join(' '))
674 if (!name) return { text: `Usage: /reviews ${verb} <subject>` }
675 return { text: await setStatus($, name, verb === 'ship' ? 'ship' : verb === 'stop' ? 'stop' : 'active') }
676 }
677 if (verb === 'band' || verb === 'context') {
678 const isBand = verb === 'band'
679 const label = isBand ? 'Band' : 'Context block'
680 const word = (rest[0] ?? '').toLowerCase()
681 if (word !== 'on' && word !== 'off') {
682 const isOn = isBand ? await read($, reviewBand) : await read($, reviewContext)
683 return { text: `${label} is ${isOn ? 'on' : 'off'}. Use /reviews ${verb} on|off.` }
684 }
685 const isOn = word === 'on'
686 if (isBand) await update($, reviewBand, () => isOn)
687 else {
688 await update($, reviewContext, () => isOn)
689 $.ui.invalidate('prompt.context')
690 }
691 try {
692 await $.store.set(isBand ? BAND_KEY : CONTEXT_KEY, isOn)
693 } catch (err) {
694 return { text: `${label} ${word} for this session; not saved: ${messageOf(err)}` }
695 }
696 return { text: `${label} ${word}.` }
697 }
698 return { text: USAGE }
699}
700
701/** Ship, reopen or stop a subject. Reopening at the cycle limit grants one more cycle. */
702async function setStatus($: EngineInterface, name: string, status: Subject['status']): Promise<string> {
703 const s = findSubject(await read($, reviewSubjects), name)
704 if (!s) return statusText(await read($, reviewSubjects), name)
705 const changed: Subject = { ...s, status, updatedAt: await nowMs($) }
706 let extra = ''
707 if (status === 'active' && cyclesLeft(changed) === 0 && !reached(changed)) {
708 changed.maxCycles = usedCycles(changed) + 1
709 extra = ` with one more cycle (max ${changed.maxCycles})`
710 }
711 await save($, changed)
712 const word = status === 'ship' ? 'Marked shipped' : status === 'stop' ? 'Closed' : 'Reopened'
713 return `${word}: ${s.name}${extra}.`
714}
715
716// ---------- state, store and log ----------
717
718/** Logs a dim line in the transcript, never throwing. */
719function note($: EngineInterface, text: string): void {
720 try {
721 const r: unknown = $.ui.log('loopkeeper: ' + text)
722 if (r && typeof (r as Promise<unknown>).then === 'function') (r as Promise<unknown>).then(undefined, () => {})
723 } catch {
724 // nothing to do
725 }
726}
727
728/** Shows a toast, never throwing. */
729function toast($: EngineInterface, text: string): void {
730 try {
731 const r: unknown = $.ui.toast(text, { timeoutMs: 6000 })
732 if (r && typeof (r as Promise<unknown>).then === 'function') (r as Promise<unknown>).then(undefined, () => {})
733 } catch {
734 // nothing to do
735 }
736}
737
738async function nowMs($: EngineInterface): Promise<number> {
739 try {
740 const ms = Number(await $.clock.now())
741 if (Number.isFinite(ms) && ms > 0) return ms
742 } catch {
743 // fall back to the wall clock
744 }
745 return Date.now()
746}
747
748async function storeGet($: EngineInterface, key: string): Promise<unknown> {
749 try {
750 return await $.store.get(key)
751 } catch {
752 return undefined
753 }
754}
755
756/** Loads settings and this folder's subjects into $.state, pruning old ones in every project. */
757async function load($: EngineInterface): Promise<void> {
758 let cwd = ''
759 try {
760 cwd = String((await $.session.cwd()) ?? '')
761 } catch {
762 cwd = ''
763 }
764 await update($, reviewCwd, () => cwd)
765 const band = await storeGet($, BAND_KEY)
766 await update($, reviewBand, () => band !== false)
767 const context = await storeGet($, CONTEXT_KEY)
768 await update($, reviewContext, () => context !== false)
769 const rawDefaults = (await storeGet($, DEFAULTS_KEY)) as { target?: unknown; maxCycles?: unknown } | undefined
770 const rawTarget = validTarget(rawDefaults?.target)
771 const rawMax = validMax(rawDefaults?.maxCycles)
772 const override: Defaults = { ...(rawTarget !== undefined ? { target: rawTarget } : {}), ...(rawMax !== undefined ? { maxCycles: rawMax } : {}) }
773 await update($, reviewDefaults, () => override)
774 // the config files' per-kind layer; a broken file leaves the presets
775 let kinds: KindDefaults = {}
776 try {
777 kinds = kindDefaultsFrom((await readConfig($)).config.review.kinds)
778 } catch {
779 kinds = {}
780 }
781 mem.kinds = kinds
782 const defaults: Defaults = { ...override, kinds }
783
784 const now = await nowMs($)
785 const mine = storeKeyOf(cwd)
786 let keys: string[] = [mine]
787 try {
788 const all = await $.store.keys()
789 if (Array.isArray(all)) keys = [...new Set([mine, ...all.filter(k => typeof k === 'string' && k.startsWith(SUBJECTS_PREFIX))])]
790 } catch {
791 // only this folder
792 }
793 for (const key of keys) {
794 const raw = await storeGet($, key)
795 if (raw === undefined) continue
796 const { kept, dropped } = prune(subjectsFrom(raw, defaults), now)
797 if (key === mine) await update($, reviewSubjects, () => kept)
798 try {
799 if (!Object.keys(kept).length) await $.store.delete(key)
800 else if (dropped.length) await $.store.set(key, kept)
801 } catch {
802 // pruning the store is best effort
803 }
804 }
805}
806
807/** Puts one subject in $.state and mirrors this folder's subjects to $.store. */
808async function save($: EngineInterface, s: Subject): Promise<void> {
809 await update($, reviewSubjects, all => {
810 const copy = { ...all }
811 const old = findSubject(copy, s.name)
812 if (old && old.name !== s.name) delete copy[old.name]
813 copy[s.name] = s
814 return copy
815 })
816 await persist($)
817}
818
819async function persist($: EngineInterface): Promise<void> {
820 try {
821 const cwd = await read($, reviewCwd)
822 const subjects = await read($, reviewSubjects)
823 if (Object.keys(subjects).length) await $.store.set(storeKeyOf(cwd), subjects)
824 else await $.store.delete(storeKeyOf(cwd))
825 } catch (err) {
826 note($, 'not saved to the store: ' + messageOf(err))
827 }
828}
829
830/** Appends one line to <cwd>/.claude/loopkeeper-review.jsonl; silent when it cannot. */
831async function appendLog($: EngineInterface, s: Subject, c: Subject['cycles'][number]): Promise<void> {
832 try {
833 const cwd = await read($, reviewCwd)
834 if (!cwd) return
835 const path = absOf(cwd, LOG_FILE)
836 let session = 'unknown'
837 try {
838 const id = await $.session.id()
839 if (typeof id === 'string' && id) session = id
840 } catch {
841 // keep unknown
842 }
843 let existing = ''
844 if ((await $.fs.exists(path)) === true) {
845 const text: unknown = await $.fs.read(path)
846 if (typeof text !== 'string') return
847 existing = text
848 }
849 const sep = existing && !existing.endsWith('\n') ? '\n' : ''
850 await $.fs.write(path, existing + sep + jsonlLine(s, c, session) + '\n')
851 } catch {
852 // skip silently: the log is a convenience
853 }
854}
855hooks/routing.ts 621 lines1// Routing concern: the model of each subagent chosen by role (agent.spawn), the loop rules block
2// (prompt.context), GSD mode and its loop, the routing band, the Routing pane (the nav bar first) and its hub row.
3// Ported from gsd-orchestrator.
4// Owns the /lk words routes, loop, route, explain and text; shares status, reload, band, on and off.
5// GSD mode, loop, band and route overrides live in $.store, shared by every session on the machine, as before.
6// ADR 0004: nothing is sent to the other concerns; stage and gates act on /lk on|off and /lk loop themselves.
7
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, On } from 'claude-code'
10
11import type { LkNext, LkRole, LkRouteDecision, LkRoutingConfig } from '../types/index.d.ts'
12import { decide, explain } from './lib/classify.ts'
13import type { SpawnDecision } from './lib/classify.ts'
14import { TONES, makeKit } from './lib/kit.ts'
15import type { Elements, Tone } from './lib/kit.ts'
16import { rulesText, withRules } from './lib/loopRules.ts'
17import { DISABLE_ENV, PLUGIN, joinText, projectConfigPath, storeKey, subOf, userConfigPath } from './lib/names.ts'
18import { HUB_ID, HUB_ROWS, PANES } from './lib/panes.ts'
19import { ROLES, buildConfig, isModelName, isRole, resolveModel, routingLayer, sane } from './lib/routingConfig.ts'
20import {
21 DISABLED_NOTE,
22 USAGE,
23 approvalLine,
24 clockOf,
25 countsLine,
26 loopEffects,
27 loopLine,
28 routingBand,
29 statusLine,
30 statusText,
31 tableRow,
32 tableRows,
33} from './lib/routingFormat.ts'
34import { DEFAULT_LIMITS, killSwitchOn } from './lib/stageModel.ts'
35import type { Approval, Limits } from './lib/stageModel.ts'
36
37const PANE_ID = 'loopkeeper-routes'
38const KEEP_DECISIONS = 50
39// The pane's Recent spawns section shows this many, newest first.
40const SHOWN_SPAWNS = 8
41// A role's badge tone in the pane.
42const ROLE_TONES: Record<LkRole, Tone> = { recon: 'muted', execute: 'info', verify: 'ok', hard: 'accent', default: 'muted' }
43const CONCERN_WORDS = ['stage', 'gates', 'files', 'routing', 'review', 'decisions']
44
45const MODE_KEY = storeKey('routing', 'mode')
46const LOOP_KEY = storeKey('routing', 'loop')
47const BAND_KEY = storeKey('routing', 'band')
48const OVERRIDES_KEY = storeKey('routing', 'overrides')
49
50const routeMode = atom({ plugin: 'loopkeeper', key: 'routeMode' } as const, false)
51const routeLoop = atom({ plugin: 'loopkeeper', key: 'routeLoop' } as const, true)
52const routeBand = atom({ plugin: 'loopkeeper', key: 'routeBand' } as const, true)
53const routeDecisions = atom({ plugin: 'loopkeeper', key: 'routeDecisions' } as const, [] as LkRouteDecision[])
54const routeCounts = atom({ plugin: 'loopkeeper', key: 'routeCounts' } as const, {} as Record<string, number>)
55const routeRouted = atom({ plugin: 'loopkeeper', key: 'routeRouted' } as const, 0)
56const routeConfig = atom({ plugin: 'loopkeeper', key: 'routeConfig' } as const, null as LkRoutingConfig | null)
57const routeDisabled = atom({ plugin: 'loopkeeper', key: 'routeDisabled' } as const, false)
58// Read only, written by the stage: its plan approval mode (ADR 0005) and the file budget numbers, for the rules
59// block and the pane.
60const stageApproval = atom({ plugin: 'loopkeeper', key: 'stageApproval' } as const, 'auto' as Approval)
61const stageLimits = atom({ plugin: 'loopkeeper', key: 'stageLimits' } as const, { ...DEFAULT_LIMITS } as Limits)
62
63const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
64
65// ---- Helpers that take $ (same file, so validate can follow them) ----------------------------------------------
66
67async function textOf($: EngineInterface, path: string): Promise<string | null> {
68 try {
69 return (await $.fs.exists(path)) ? await $.fs.read(path) : null
70 } catch {
71 return null
72 }
73}
74
75async function routingStoreGet($: EngineInterface, key: string): Promise<unknown> {
76 try {
77 return await $.store.get(key)
78 } catch {
79 return undefined
80 }
81}
82
83async function routingStoreSet($: EngineInterface, key: string, value: unknown): Promise<boolean> {
84 try {
85 await $.store.set(key, value)
86 return true
87 } catch {
88 return false
89 }
90}
91
92/** Re-read the routing section of both config files and the stored switches into the atoms. */
93async function loadAll($: EngineInterface): Promise<LkRoutingConfig> {
94 const home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
95 const cwd = (await $.session.cwd().catch(() => undefined)) ?? ''
96 const userPath = home ? userConfigPath(home) : ''
97 const projectPath = cwd ? projectConfigPath(cwd) : ''
98 const userLayer = userPath ? routingLayer(await textOf($, userPath)) : undefined
99 const projectLayer = projectPath && projectPath !== userPath ? routingLayer(await textOf($, projectPath)) : undefined
100 const config = buildConfig(userLayer, projectLayer, await routingStoreGet($, OVERRIDES_KEY))
101 const savedMode = await routingStoreGet($, MODE_KEY)
102 const savedBand = await routingStoreGet($, BAND_KEY)
103 const savedLoop = await routingStoreGet($, LOOP_KEY)
104 await update($, routeConfig, () => config)
105 // an explicit /lk on|off wins over the files' `enabled`
106 await update($, routeMode, () => (typeof savedMode === 'boolean' ? savedMode : config.enabled === true))
107 await update($, routeBand, () => (typeof savedBand === 'boolean' ? savedBand : true))
108 await update($, routeLoop, () => (typeof savedLoop === 'boolean' ? savedLoop : true))
109 return config
110}
111
112/** ADR 0009: CLAUDE_MODS_DISABLE naming loopkeeper (or all) turns spawn routing off; the rules may stay. */
113async function loadKill($: EngineInterface): Promise<boolean> {
114 const off = killSwitchOn(await $.env.get('CLAUDE_MODS_DISABLE').catch(() => undefined))
115 await update($, routeDisabled, () => off)
116 return off
117}
118
119function rerender($: EngineInterface): void {
120 try {
121 $.ui.invalidate('prompt.context')
122 } catch {
123 // The rules follow on the next re-read of the context.
124 }
125}
126
127/** Turn GSD mode on or off, save it, and have the engine re-render the context. */
128async function setMode($: EngineInterface, isOn: boolean): Promise<void> {
129 await update($, routeMode, () => isOn)
130 await routingStoreSet($, MODE_KEY, isOn)
131 rerender($)
132}
133
134/** Turn the loop on or off and save it; the rules change only while the mode is on. */
135async function setLoop($: EngineInterface, isLoop: boolean): Promise<void> {
136 await update($, routeLoop, () => isLoop)
137 await routingStoreSet($, LOOP_KEY, isLoop)
138 if ((await read($, routeMode)) === true) rerender($)
139}
140
141async function nowOf($: EngineInterface): Promise<number> {
142 try {
143 const t = await $.clock.now()
144 return typeof t === 'number' ? t : Date.now()
145 } catch {
146 return Date.now()
147 }
148}
149
150/** Keep one routing decision, the per-model count and the routed count. */
151async function record($: EngineInterface, d: SpawnDecision): Promise<void> {
152 const at = await nowOf($)
153 const { setModel: _set, ...kept } = d
154 const entry: LkRouteDecision = { at, ...kept }
155 await update($, routeDecisions, list => [...(list ?? []), entry].slice(-KEEP_DECISIONS))
156 if (d.source !== 'off') {
157 await update($, routeCounts, c => ({ ...(c ?? {}), [d.model]: ((c ?? {})[d.model] ?? 0) + 1 }))
158 }
159 if (d.setModel !== undefined) await update($, routeRouted, n => (n ?? 0) + 1)
160}
161
162async function currentConfig($: EngineInterface): Promise<LkRoutingConfig> {
163 return sane(await read($, routeConfig))
164}
165
166async function modeText($: EngineInterface, isOn: boolean): Promise<string> {
167 if (!isOn) return 'GSD orchestrator mode off: subagents keep the model they ask for and the rules leave the context.'
168 const isLoop = (await read($, routeLoop)) !== false
169 const disabled = (await read($, routeDisabled)) === true
170 return (
171 'GSD orchestrator mode on: subagents are routed by role and the orchestrator rules join the context.' +
172 (isLoop ? ' Loop on: the stage takes preset gsd and the gates require a plan.' : '') +
173 (disabled ? ` Spawns are not routed while ${DISABLE_ENV} names ${PLUGIN}.` : '')
174 )
175}
176
177async function textAnswer($: EngineInterface): Promise<string> {
178 return statusText({
179 isOn: (await read($, routeMode)) === true,
180 isBand: (await read($, routeBand)) !== false,
181 isLoop: (await read($, routeLoop)) !== false,
182 disabled: (await read($, routeDisabled)) === true,
183 config: await currentConfig($),
184 decisions: (await read($, routeDecisions)) ?? [],
185 counts: (await read($, routeCounts)) ?? {},
186 routed: (await read($, routeRouted)) ?? 0,
187 })
188}
189
190// ---- /lk ---------------------------------------------------------------------------------------------------------
191
192/** The words routing owns. Undefined when the word is not routing's, so the chain moves on. */
193async function ownWord($: EngineInterface, sub: string, rest: string[], text: string): Promise<{ text?: string } | undefined> {
194 if (sub === 'routes') {
195 await $.ui.open({ id: PANE_ID, title: 'Routing', focus: true, closeOnEscape: true })
196 return {}
197 }
198 if (sub === 'loop') {
199 const want = (rest[0] ?? '').toLowerCase()
200 if (want !== 'on' && want !== 'off') return { text: USAGE.loop }
201 await setLoop($, want === 'on')
202 // the stage and the gates, earlier in the chain, add what they switched
203 return { text: `GSD loop ${want}.` + ((await read($, routeMode)) === true ? '' : ' It takes effect when the mode is on.') }
204 }
205 if (sub === 'route') {
206 const role = (rest[0] ?? '').toLowerCase()
207 if (role === 'reset' && rest.length === 1) {
208 await $.store.delete(OVERRIDES_KEY)
209 return { text: 'Overrides dropped. Routing table:\n' + tableRows(await loadAll($)) }
210 }
211 const model = rest[1] ?? ''
212 if (!isRole(role) || !isModelName(model)) return { text: USAGE.route }
213 const saved = await routingStoreGet($, OVERRIDES_KEY)
214 await $.store.set(OVERRIDES_KEY, { ...(isObj(saved) ? saved : {}), [role]: model })
215 const config = await loadAll($)
216 return { text: 'Route set for this and later sessions until /lk route reset:\n ' + tableRow(config, role) }
217 }
218 if (sub === 'explain') {
219 const words = text.split(/\s+/)
220 const what = text.slice((words[0] ?? '').length).trim()
221 if (what === '') return { text: USAGE.explain }
222 return { text: explain(what, await currentConfig($)) }
223 }
224 // /lk text: the stage and the gates, earlier in the chain, put their parts above this one
225 if (sub === 'text') return { text: await textAnswer($) }
226 return undefined
227}
228
229// The routing line for a /lk word other concerns also answer (status, reload, band, on, off)
230// /lk band [concern…] on|off: null when another concern was named, '' when no on/off word was given
231function bandWordOf(rest: string[]): string | null {
232 const words = rest.map(w => w.toLowerCase())
233 const named = words.filter(w => CONCERN_WORDS.includes(w))
234 if (named.length > 0 && !named.includes('routing')) return null
235 return words.find(w => w === 'on' || w === 'off') ?? ''
236}
237
238async function lk($: EngineInterface, e: { args?: unknown }, next: LkNext): Promise<{ text?: string } | undefined> {
239 const { sub, rest, text } = subOf(e?.args)
240 if (sub === 'status' || sub === 'reload' || sub === 'band' || sub === 'on' || sub === 'off') {
241 const r = await next(e)
242 let line = ''
243 try {
244 if (sub === 'status') {
245 const modeOn = (await read($, routeMode)) === true
246 const loopOn = (await read($, routeLoop)) !== false
247 const config = await currentConfig($)
248 const routed = (await read($, routeRouted)) ?? 0
249 const disabled = (await read($, routeDisabled)) === true
250 line = statusLine(modeOn, loopOn, config, routed, disabled)
251 } else if (sub === 'reload') {
252 await loadKill($)
253 const config = await loadAll($)
254 line = 'Routing config re-read. Routing table:\n' + tableRows(config)
255 } else if (sub === 'band') {
256 const want = bandWordOf(rest)
257 if (want === '') {
258 const shown = (await read($, routeBand)) !== false
259 line = `The routing band is ${shown ? 'on' : 'off'}. Usage: /lk band [routing] on|off`
260 } else if (want !== null) {
261 await update($, routeBand, () => want === 'on')
262 let saved = true
263 try {
264 await $.store.set(BAND_KEY, want === 'on')
265 } catch {
266 saved = false
267 }
268 line = `The routing band is ${want}.${saved ? '' : ' (this session only)'}`
269 }
270 } else {
271 await setMode($, sub === 'on')
272 line = await modeText($, sub === 'on')
273 }
274 } catch {
275 line = `${PLUGIN}: routing could not answer "${sub}".`
276 }
277 return { ...(r ?? {}), text: joinText(line, r?.text) }
278 }
279 let own: { text?: string } | undefined
280 try {
281 own = await ownWord($, sub, rest, text)
282 } catch (err) {
283 return { text: `${PLUGIN}: ${err instanceof Error ? err.message : String(err)}` }
284 }
285 return own ?? next(e)
286}
287
288async function lkFailed($: EngineInterface, e: unknown, next: any): Promise<{ text?: string }> {
289 const r = next.called ? ((await next(e)) as { text?: string } | undefined) : undefined
290 const why = `${next.error?.kind ?? 'throw'}: ${String(next.error?.message ?? '').slice(0, 120)}`
291 return { ...(r ?? {}), text: joinText(`${PLUGIN}: the routing command failed (${why}).`, r?.text) }
292}
293
294function toast($: EngineInterface, text: string): void {
295 try {
296 const p: unknown = $.ui.toast(text)
297 if (p && typeof (p as Promise<unknown>).then === 'function') (p as Promise<unknown>).then(undefined, () => undefined)
298 } catch {
299 // Display only.
300 }
301}
302
303/**
304 * A pane button whose switch the stage and the gates must follow. A plugin never sees its own $ calls (so a
305 * button cannot run /lk through the other concerns) and ADR 0004 sends nothing, so the /lk words go in the
306 * prompt box: Enter runs them across every concern, as typing them would.
307 */
308async function offer($: EngineInterface, args: string): Promise<void> {
309 let filled = false
310 try {
311 filled = (await $.prompt.fill({ text: '/lk ' + args, mode: 'replace' })).isFilled === true
312 } catch {
313 filled = false
314 }
315 toast($, filled ? `Press Enter to run /lk ${args}: the stage and the gates switch with routing.` : `Run /lk ${args}: the stage and the gates switch with routing.`)
316}
317
318// ---- The hub and the nav bar -------------------------------------------------------------------------------------
319
320// HUB_ID, HUB_ROWS and PANES (every loopkeeper pane in wiring order) come from lib/panes.ts.
321
322// the "ctrl+x tab to focus the panel" toast showed this session (stage.ts's atom, shared by all six concern files)
323const stageFocusHinted = atom({ plugin: 'loopkeeper', key: 'stageFocusHinted' } as const, false)
324
325// Switch panes: close `fromId` first ('' closes nothing), since a focus request is refused while the person holds a
326// pane, then open `toId` with the focus. The composer keeps the focus while it holds a draft: then a toast says how to
327// reach the pane, once a session and only in the terminal (the press's `surface`; with no press, a session whose every
328// surface is the terminal). False when the pane could not be opened.
329async function switchTo($: EngineInterface, fromId: string, toId: string, title: string, surface?: unknown): Promise<boolean> {
330 if (fromId) {
331 try {
332 await $.ui.close({ id: fromId })
333 } catch {
334 // already closed
335 }
336 }
337 try {
338 await $.ui.open({ id: toId, title, focus: true, closeOnEscape: true, ...(toId === HUB_ID ? { rows: HUB_ROWS } : {}) })
339 } catch {
340 return false
341 }
342 try {
343 const pane = (await $.ui.panes()).find(p => p.id === toId)
344 if (pane && !pane.isFocused && !(await read($, stageFocusHinted))) {
345 const terminal = typeof surface === 'string' ? surface === 'terminal' : (await $.session.surfaces()).every(s => s === 'terminal')
346 if (terminal) {
347 await update($, stageFocusHinted, () => true)
348 toast($, 'ctrl+x tab to focus the panel')
349 }
350 }
351 } catch {
352 // no list: no hint
353 }
354 return true
355}
356
357// The nav bar's items: every loopkeeper pane, a press switching from `fromId` to it
358function navItems($: EngineInterface, fromId: string) {
359 return PANES.map(p => ({
360 key: p.key,
361 label: p.label,
362 hotkey: p.hotkey,
363 onPress: async (e?: { surface?: unknown }) => {
364 if (!(await switchTo($, fromId, p.id, p.title, e?.surface))) toast($, 'Could not open the ' + p.label + ' pane.')
365 },
366 }))
367}
368
369/** The hub row's words: `GSD on · routed 3`, then the kill switch's note and `band off` when they apply. */
370async function hubWords($: EngineInterface): Promise<string> {
371 const modeOn = (await read($, routeMode)) === true
372 const routed = (await read($, routeRouted)) ?? 0
373 const words = [`GSD ${modeOn ? 'on' : 'off'}`, `routed ${routed}`]
374 if ((await read($, routeDisabled)) === true) words.push(DISABLED_NOTE)
375 if ((await read($, routeBand)) === false) words.push('band off')
376 return words.join(' · ')
377}
378
379// ---- Wiring ------------------------------------------------------------------------------------------------------
380
381export function wireRouting(on: On): void {
382 // The stage and the gates read routeMode and routeLoop after this, on their way out of session start.
383 on('session.start', { isInteractive: [true, false] }, async ($, e, next) => {
384 try {
385 await loadKill($)
386 } catch {
387 // Routing stays on.
388 }
389 try {
390 await loadAll($)
391 } catch {
392 // Defaults stand.
393 }
394 return next(e)
395 })
396
397 // /clear, /resume and /branch reset $.state without a session.start.
398 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
399 try {
400 await loadKill($)
401 await loadAll($)
402 } catch {
403 // Defaults stand.
404 }
405 return next(e)
406 }).catch(($, e, next) => next(e))
407
408 // Route each subagent's (and teammate's) model. Fails open: on any error the spawn goes through exactly as asked.
409 on('agent.spawn', {}, async ($, e, next) => {
410 let d: SpawnDecision
411 try {
412 const isOn = (await read($, routeMode)) === true && (await read($, routeDisabled)) !== true
413 d = decide(e, sane(await read($, routeConfig)), isOn)
414 } catch {
415 return next(e)
416 }
417 try {
418 await record($, d)
419 } catch {
420 // Recording is a nicety.
421 }
422 if (d.setModel === undefined) return next(e)
423 return next({ ...e, model: d.setModel })
424 }).catch(async ($, e, next) => {
425 // Fail open: a routing failure never blocks a subagent. If the spawn already went through, report it
426 // rather than start a second one.
427 if (next.called) return { model: typeof e.model === 'string' && e.model !== '' ? e.model : String(e.parentModel ?? '') }
428 return next(e)
429 })
430
431 // The rules join the first message's context the way agents-md joins AGENTS.md: as an instruction file behind
432 // the claudeMd block. They name the stage's approval mode and the plan slack, so they follow both.
433 on('prompt.context', {}, async ($, e, next) => {
434 let text = ''
435 try {
436 if ((await read($, routeMode)) === true) {
437 const limits = await read($, stageLimits)
438 text = rulesText((await read($, routeLoop)) !== false, { slack: limits?.slack, approval: await read($, stageApproval) })
439 }
440 } catch {
441 text = ''
442 }
443 if (text === '') return next(e)
444 let withIt = e
445 try {
446 withIt = withRules(e, $.plugin.root, text)
447 } catch {
448 return next(e)
449 }
450 return next(withIt)
451 })
452
453 on('command.run', { command: 'lk' }, async ($, e, next) => lk($, e, next)).catch(($, e, next) => lkFailed($, e, next))
454 on('command.run', { command: 'loopkeeper' }, async ($, e, next) => lk($, e, next)).catch(($, e, next) => lkFailed($, e, next))
455
456 // The Routing pane (/lk routes), drawn with the kit: hero, tiles, routes, recent spawns, the loop, buttons.
457 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e, next) => {
458 if (e.requestId !== PANE_ID) return next(e)
459 const els = $.ui.resolve(e) as unknown as Elements
460 const k = makeKit(els, e.surface)
461 const isOn = (await read($, routeMode)) === true
462 const isBand = (await read($, routeBand)) !== false
463 const isLoop = (await read($, routeLoop)) !== false
464 const disabled = (await read($, routeDisabled)) === true
465 const config = sane(await read($, routeConfig))
466 const all: LkRouteDecision[] = (await read($, routeDecisions)) ?? []
467 const counts = (await read($, routeCounts)) ?? {}
468 const routed = (await read($, routeRouted)) ?? 0
469 const approval = await read($, stageApproval)
470 const shown = all.slice(-SHOWN_SPAWNS).reverse()
471 const quiet = (text: string) => k.text(text, { color: TONES.muted.fg, wrap: 'truncate' })
472
473 const hero = k.hero({
474 kicker: 'GSD',
475 title: isOn ? 'Orchestrator on' : 'Orchestrator off',
476 icon: isOn ? '●' : '○',
477 tone: isOn ? 'ok' : 'muted',
478 sub: approvalLine(approval) + ` · band ${isBand ? 'on' : 'off'}` + (disabled ? ' · ' + DISABLED_NOTE : ''),
479 })
480
481 const tiles = k.tiles([
482 { label: 'Routes', value: String(ROLES.length), tone: 'info' },
483 { label: 'Spawns', value: all.length >= KEEP_DECISIONS ? KEEP_DECISIONS + '+' : String(all.length) },
484 { label: 'Approval', value: approval === 'ask' ? 'ask' : 'auto', tone: approval === 'ask' ? 'warn' : 'ok' },
485 ])
486
487 const routes = k.stack([
488 k.section('Routes', `override explicit: ${config.override ? 'yes' : 'no'}`),
489 ...ROLES.map(role =>
490 k.row({
491 key: 'route-' + role,
492 left: [k.badge(role, ROLE_TONES[role]), '→ ' + resolveModel(config.roles[role].model, config)],
493 right: config.roles[role].source,
494 }),
495 ),
496 ])
497
498 const spawns = k.stack([
499 k.section('Recent spawns · ' + all.length, `routed ${routed} · ${countsLine(counts)}`),
500 ...(shown.length === 0
501 ? [quiet('none yet')]
502 : shown.map((d, i) =>
503 k.row({
504 key: 'spawn-' + i,
505 left: [
506 quiet(clockOf(d.at)),
507 k.badge(d.role === '' ? '-' : d.role, d.role === '' ? 'muted' : ROLE_TONES[d.role]),
508 k.text(d.label + (d.isTeammate ? ' (teammate)' : ''), { color: d.source === 'off' ? TONES.muted.fg : undefined, wrap: 'truncate' }),
509 ],
510 right: [d.source, '→ ' + d.model],
511 }),
512 )),
513 ])
514
515 const loop = k.stack([
516 k.section('Loop'),
517 k.row({
518 key: 'loop-row',
519 left: [k.badge(isLoop ? 'Loop on' : 'Loop off', isLoop ? 'ok' : 'muted'), 'size → plan → validate plan → execute → verify → QA → ship gate'],
520 }),
521 ...(isLoop
522 ? [quiet('mode on: ' + loopEffects(true).join(' · ')), quiet('mode off: ' + loopEffects(false).join(' · '))]
523 : [quiet(loopLine(false))]),
524 quiet('Toggle mode and Toggle loop put /lk on|off or /lk loop on|off in the prompt box when the stage and the gates must follow'),
525 ])
526
527 const buttons = k.buttons([
528 {
529 key: 'toggle',
530 label: 'Toggle mode',
531 hotkey: 't',
532 plain: true,
533 onPress: async () => {
534 const want = (await read($, routeMode)) !== true
535 // with the loop off only routing changes, so the switch happens here
536 if ((await read($, routeLoop)) === false) await setMode($, want)
537 else await offer($, want ? 'on' : 'off')
538 },
539 },
540 {
541 key: 'loop',
542 label: 'Toggle loop',
543 hotkey: 'l',
544 plain: true,
545 onPress: async () => {
546 const want = (await read($, routeLoop)) === false
547 // with the mode off only routing changes, so the switch happens here
548 if ((await read($, routeMode)) !== true) await setLoop($, want)
549 else await offer($, 'loop ' + (want ? 'on' : 'off'))
550 },
551 },
552 {
553 key: 'close',
554 label: 'Close',
555 hotkey: 'c',
556 plain: true,
557 onPress: async () => {
558 try {
559 await $.ui.close({ id: PANE_ID })
560 } catch {
561 // Already closed.
562 }
563 },
564 },
565 ])
566
567 return k.page([k.nav(navItems($, PANE_ID), 'routes'), hero, tiles, routes, spawns, loop, buttons]) as never
568 })
569
570 // The hub's Routes row, above the later concerns' rows (next): `4: Routes` and the routing words, quiet. It shows
571 // with the band off or the kill switch on; a failure draws what the chain drew.
572 on('ui.render', { component: 'Pane', requestId: HUB_ID }, async ($, e, next) => {
573 let asked = false
574 let theirs: unknown = null
575 try {
576 const els = $.ui.resolve(e) as unknown as Elements
577 const k = makeKit(els, e.surface)
578 const row = k.row({
579 key: 'hub-routes',
580 left: [
581 els.Button({
582 key: 'panel-routes',
583 label: 'Routes',
584 hotkey: '4',
585 plain: true,
586 onPress: async (press?: { surface?: unknown }) => {
587 if (!(await switchTo($, HUB_ID, PANE_ID, 'Routing', press?.surface))) toast($, 'Could not open the Routes pane.')
588 },
589 }),
590 k.text(await hubWords($), { color: TONES.muted.fg, wrap: 'truncate' }),
591 ],
592 })
593 asked = true
594 theirs = await next(e)
595 return (theirs === null || theirs === undefined ? row : k.stack([row, theirs])) as never
596 } catch {
597 return (asked ? theirs : await next(e)) as never
598 }
599 })
600
601 // The band: one kit line above the prompt, `routing ● on ● loop recon→haiku · … · routed n`, over the bands of
602 // the concerns after this one.
603 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
604 const props = (e.props ?? {}) as { hasSurvey?: boolean }
605 if (props.hasSurvey === true || (await read($, routeBand)) === false) return next(e)
606 const els = $.ui.resolve(e) as unknown as Elements
607 const k = makeKit(els, e.surface)
608 const band = routingBand(
609 sane(await read($, routeConfig)),
610 (await read($, routeRouted)) ?? 0,
611 (await read($, routeMode)) === true,
612 (await read($, routeLoop)) !== false,
613 (await read($, routeDisabled)) === true,
614 )
615 const line = k.line('routing', band.chips, band.right)
616 const theirs = await next(e)
617 if (theirs === null || theirs === undefined) return line as never
618 return els.Box({ flexDirection: 'column', children: [line, theirs] }) as never
619 })
620}
621hooks/stage.ts 2025 lines1// Stage concern: the stage machine, ported from pipeline-stage's register.ts onto lib/stageModel.ts.
2// Owns the stage words of /lk (a stage id or label, next, back, approve, waive, reset, preset, size, small-max,
3// approval, auto, stage, which opens the stepper pane, help, and /lk alone, which opens the hub) and its own line of the
4// shared words (status, reload, band, on, off, loop); set_stage; the tool-call heuristics; the growth guard; the hold
5// pattern; the 30 s tick; the file budget approved with the plan; the band's first line; the hub's frame and its
6// Stage row; and the review_cycle wrapper that records a consistent plan verdict. Every $ call lives in this file; lib/stageModel.ts is pure. Atoms use string literal keys, so another
7// concern that declares the same key reads the same value (gates read stagePlanDone, stageShipOpen and stageBudget).
8
9import { atom, read, update } from 'claude-code'
10import type { EngineInterface, On } from 'claude-code'
11
12import { mergeConfig, parseConfigText } from './lib/config.ts'
13import { mergeLayers } from './lib/gateRules.ts'
14import { TONES, makeKit } from './lib/kit.ts'
15import type { Elements, Tone } from './lib/kit.ts'
16import { PLUGIN, TOOL, TOOL_FULL, joinText, projectConfigPath, subOf, userConfigPath } from './lib/names.ts'
17import { HUB_ID, HUB_ROWS, PANES } from './lib/panes.ts'
18import {
19 BAND_KEY,
20 CONFIG_LABEL,
21 DEFAULT_LIMITS,
22 DEFAULT_PLAN_PATHS,
23 DEFAULT_STAGES,
24 DISABLED_NOTE,
25 FILES_CAP,
26 SIZE_REMINDER,
27 SMALL_MAX_DEFAULT,
28 VERIFIER_HINT,
29 agentMatches,
30 agentText,
31 approvalKey,
32 approvalLine,
33 approvalOf,
34 approveAhead,
35 autoTarget,
36 bandButton,
37 budgetFor,
38 budgetLine,
39 budgetSentence,
40 budgetWaitSentence,
41 budgetWaits,
42 capHistory,
43 checkMove,
44 chooseStages,
45 clock,
46 editPath,
47 elapsed,
48 enteredAt,
49 findStage,
50 findSubject,
51 fitBand,
52 gateAhead,
53 indexOf,
54 isPlanMaterial,
55 killSwitchOn,
56 labelOf,
57 largeReply,
58 limitsOf,
59 neighbour,
60 nextStep,
61 normalizePath,
62 outgrowMessage,
63 outgrows,
64 passRecorded,
65 pipelineFromLayers,
66 planDone,
67 planFilesOf,
68 planMaterialLine,
69 planPassApplies,
70 planPassConsistent,
71 planPathsOf,
72 presetKey,
73 presetNames,
74 presetStages,
75 projectKey,
76 roleOf,
77 savedOf,
78 shipOpen,
79 sizeLine,
80 smallHeldReply,
81 smallMaxOf,
82 smallReply,
83 stageForRole,
84 subjectName,
85 validIds,
86 verdictLine,
87 verdictReply,
88 verifiedBy,
89 versionNote,
90 wantsHold,
91} from './lib/stageModel.ts'
92import type { Approval, By, Config, Limits, Size, Stage, Transition, Verdict } from './lib/stageModel.ts'
93import type { LkGateConfig, LkNext, LkSubject } from '../types/index.d.ts'
94
95// ---- State: everything a drawing reads lives in $.state, so it survives a hot reload ---------------------------
96
97const stageStages = atom({ plugin: 'loopkeeper', key: 'stageStages' } as const, DEFAULT_STAGES.map(s => ({ ...s })) as Stage[])
98const stageCurrent = atom({ plugin: 'loopkeeper', key: 'stageCurrent' } as const, 'plan')
99const stageApproved = atom({ plugin: 'loopkeeper', key: 'stageApproved' } as const, [] as string[])
100const stageHistory = atom({ plugin: 'loopkeeper', key: 'stageHistory' } as const, [] as Transition[])
101const stageAuto = atom({ plugin: 'loopkeeper', key: 'stageAuto' } as const, true)
102const stageBand = atom({ plugin: 'loopkeeper', key: 'stageBand' } as const, true)
103const stageNow = atom({ plugin: 'loopkeeper', key: 'stageNow' } as const, 0)
104const stageCwd = atom({ plugin: 'loopkeeper', key: 'stageCwd' } as const, '')
105const stageSource = atom({ plugin: 'loopkeeper', key: 'stageSource' } as const, 'default')
106// stage ids whose requiresAgent a completed matching Agent call met since the stage was last entered
107const stageVerified = atom({ plugin: 'loopkeeper', key: 'stageVerified' } as const, [] as string[])
108const stagePreset = atom({ plugin: 'loopkeeper', key: 'stagePreset' } as const, 'default')
109// Read by the gates: written whenever the stage, the stages or the approvals change
110const stagePlanDone = atom({ plugin: 'loopkeeper', key: 'stagePlanDone' } as const, false)
111const stageShipOpen = atom({ plugin: 'loopkeeper', key: 'stageShipOpen' } as const, true)
112const stageSize = atom({ plugin: 'loopkeeper', key: 'stageSize' } as const, '' as Size)
113const stageVerdict = atom({ plugin: 'loopkeeper', key: 'stageVerdict' } as const, null as Verdict | null)
114const stageApproval = atom({ plugin: 'loopkeeper', key: 'stageApproval' } as const, 'auto' as Approval)
115const stageHoldPlan = atom({ plugin: 'loopkeeper', key: 'stageHoldPlan' } as const, false)
116// true once a held prompt's task was sized: sizing the next task spends the hold
117const stageHoldBound = atom({ plugin: 'loopkeeper', key: 'stageHoldBound' } as const, false)
118// a validator that completed in the stage before a requiresAgent stage: that stage's id, and who it was
119const stageCarry = atom({ plugin: 'loopkeeper', key: 'stageCarry' } as const, '')
120const stageCarryBy = atom({ plugin: 'loopkeeper', key: 'stageCarryBy' } as const, '')
121const stageWaived = atom({ plugin: 'loopkeeper', key: 'stageWaived' } as const, [] as string[])
122const stageFilesTouched = atom({ plugin: 'loopkeeper', key: 'stageFilesTouched' } as const, [] as string[])
123const stageSmallMax = atom({ plugin: 'loopkeeper', key: 'stageSmallMax' } as const, SMALL_MAX_DEFAULT)
124// changeControl.planPaths (one plan-material list for all of loopkeeper); the scratchpad always counts
125const stagePlanPaths = atom({ plugin: 'loopkeeper', key: 'stagePlanPaths' } as const, [...DEFAULT_PLAN_PATHS] as string[])
126// The file budget (ADR 0011): the planned count set_stage gave, and the budget approved with the plan
127const stagePlanFiles = atom({ plugin: 'loopkeeper', key: 'stagePlanFiles' } as const, null as number | null)
128const stageBudget = atom({ plugin: 'loopkeeper', key: 'stageBudget' } as const, 0)
129const stageLimits = atom({ plugin: 'loopkeeper', key: 'stageLimits' } as const, { ...DEFAULT_LIMITS } as Limits)
130// ADR 0007 and ADR 0009: what the band says in warning tone
131const stageVersionNote = atom({ plugin: 'loopkeeper', key: 'stageVersionNote' } as const, '')
132const stageDisabled = atom({ plugin: 'loopkeeper', key: 'stageDisabled' } as const, false)
133// ADR 0012: continue after approval. The config switch, whether a main turn runs, the approval a running turn
134// holds back until it completes, whether a continue prompt is sent or on its way (until the next main turn.start),
135// and whether the session is interactive (session.start)
136const stageContinueOnApprove = atom({ plugin: 'loopkeeper', key: 'stageContinueOnApprove' } as const, true)
137const stageTurnRunning = atom({ plugin: 'loopkeeper', key: 'stageTurnRunning' } as const, false)
138const stageContinuePending = atom(
139 { plugin: 'loopkeeper', key: 'stageContinuePending' } as const,
140 null as { stage: string; text: string } | null,
141)
142type ContinueSent = { seq: number; live: boolean }
143const stageContinueSent = atom({ plugin: 'loopkeeper', key: 'stageContinueSent' } as const, { seq: 0, live: false } as ContinueSent)
144const stageInteractive = atom({ plugin: 'loopkeeper', key: 'stageInteractive' } as const, false)
145// pipeline.nextHint: the next-step line, a notice line in the chat after each answer (dim; Claude does not read it)
146const stageNextHint = atom({ plugin: 'loopkeeper', key: 'stageNextHint' } as const, true)
147// the "ctrl+x tab to focus the panel" toast showed this session (one atom for all six concern files)
148const stageFocusHinted = atom({ plugin: 'loopkeeper', key: 'stageFocusHinted' } as const, false)
149// The other mods' commands the hub links to, read from $.command.list() by /lk (a drawing may not write state)
150const stageHubCommands = atom({ plugin: 'loopkeeper', key: 'stageHubCommands' } as const, [] as { name: string; plugin?: string }[])
151// Written by other concerns, read here: review's subjects (the review_cycle wrapper) and routing's GSD switches
152const reviewSubjects = atom({ plugin: 'loopkeeper', key: 'reviewSubjects' } as const, {} as Record<string, LkSubject>)
153const routeMode = atom({ plugin: 'loopkeeper', key: 'routeMode' } as const, false)
154const routeLoop = atom({ plugin: 'loopkeeper', key: 'routeLoop' } as const, true)
155// the gates' /lk gates ship ok for this session and their merged config (shipGate), read by the next-step sentence
156const gateShipOk = atom({ plugin: 'loopkeeper', key: 'gateShipOk' } as const, false)
157const gateConfig = atom({ plugin: 'loopkeeper', key: 'gateConfig' } as const, null as LkGateConfig | null)
158// Written here, spent by the gates: the user's approval at or into the ship stage covers the next push or merge to
159// the default branch; leaving the ship stage (or a reset) takes back one not used
160const gateMergeCredit = atom({ plugin: 'loopkeeper', key: 'gateMergeCredit' } as const, false)
161
162const PANE_ID = 'loopkeeper-stage'
163const TICK_MS = 30_000
164// How long after /lk approve answered its continue prompt is submitted (ADR 0012)
165const CONTINUE_DELAY_MS = 100
166// The waits before each new try of a refused continue prompt
167const CONTINUE_RETRY_MS = [100, 500, 2_000]
168const GUARDED_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
169const CONCERN_WORDS = ['stage', 'gates', 'files', 'routing', 'review', 'decisions']
170
171// ---- The hub and the nav bar --------------------------------------------------------------------------------------
172// HUB_ID, HUB_ROWS and PANES (every loopkeeper pane in wiring order) come from lib/panes.ts.
173// The other mods the hub links to, each only while $.command.list() reports its command from one of its `plugins`; a
174// press runs the command with `args` (off the terminal) or puts `text` in the composer. `plugins` lists the lk- name
175// first and the old name after it, so the hub still links while either is installed during the switch.
176const HUB_MODS = [
177 { key: 'mod-agents', hotkey: 'm', label: 'Agents', command: 'agent-map', args: 'cards', plugins: ['lk-agent-map', 'agent-map'], text: '/agent-map cards' },
178 { key: 'mod-budget', hotkey: 'u', label: 'Budget', command: 'budget', args: '', plugins: ['lk-token-budget', 'token-budget'], text: '/budget' },
179 { key: 'mod-handoff', hotkey: 'h', label: 'Handoff', command: 'handoff', args: 'show', plugins: ['lk-handoff-guard', 'handoff-guard'], text: '/handoff show' },
180 { key: 'mod-attention', hotkey: 't', label: 'Attention', command: 'attention', args: '', plugins: ['lk-attention', 'attention'], text: '/attention' },
181 { key: 'mod-where', hotkey: 'w', label: 'Where', command: 'where', args: '', plugins: ['lk-where-am-i', 'where-am-i'], text: '/where' },
182 { key: 'mod-prs', hotkey: 'p', label: 'PRs', command: 'pr', args: '', plugins: ['lk-pr-watch', 'pr-watch'], text: '/pr' },
183 { key: 'mod-secrets', hotkey: 's', label: 'Secrets', command: 'secret-guard', args: '', plugins: ['lk-secret-guard', 'secret-guard'], text: '/secret-guard' },
184] as const
185const KEYS_TEXT =
186 'loopkeeper keys: /lk opens the hub, /lk stage the Stage pane. In any loopkeeper pane, 0-6 switch panels: ' +
187 PANES.map(p => p.hotkey + ' ' + p.label).join(', ') +
188 '. In the hub: ' +
189 HUB_MODS.map(m => m.hotkey + ' ' + m.label).join(', ') +
190 ' open the other mods (those installed); a approves while a gate or budget waits, or opens the next stage\'s gate; ' +
191 'o fills /lk on or /lk off; ' +
192 'c closes. ctrl+x tab focuses the band, or a panel that opened without the focus. The Files pane\'s name filter ' +
193 'takes every key while it has the focus, so there digits switch panels only once the focus leaves it.'
194
195// Dedupes the "auto move held at a gate" toast; losing it on reload is harmless
196let lastHeld = ''
197// Growth-guard checks run one at a time, so parallel edits count against one list
198let guardChain: Promise<unknown> = Promise.resolve()
199// The path the growth guard reserved for a call, by tool_use_id, until the call's tool.call hook settles it.
200const reservedFor = new Map<string, string>()
201// The 30 s tick of the band's elapsed counter; a new session start replaces it
202let ticker: { cancel: () => void } | null = null
203
204const TOOL_DESCRIPTION = [
205 "Record which stage of the project's pipeline this session is in (shown to the user above the prompt).",
206 'Call it whenever your work moves between stages: planning (reading, designing, writing a plan),',
207 'building/implementing (editing code), testing/QA/verifying (running tests, validating, reviewing),',
208 'and shipping/committing (git commit, push, opening a PR).',
209 'Pass `stage` as one of the stage ids and a one-line `note` saying what you are about to do.',
210 'Some stages are gates: moving past an unapproved gate is refused, and you must then ask the user',
211 'to review and run `/lk approve` before trying again. Never try to work around a gate.',
212 'Some stages need a verifier (marked ⇄): before moving past one, ' + VERIFIER_HINT + '.',
213 'At the start of each task call it with stage plan and `size`: small (one clear change in at most 3 files; the plan gate',
214 'and the verifier are waived and the pipeline goes straight to execute) or large (the full loop).',
215 'With size large also pass `files`, how many files the plan touches: approving the plan approves a file budget of that',
216 'many files plus a margin, so the gates do not ask on every turn.',
217 'Once a plan validator subagent has checked a large plan, record its result with stage validate-plan and `verdict`',
218 'consistent or issues; consistent opens the plan gate by itself unless the user approves plans.',
219].join(' ')
220
221const USAGE =
222 'Usage: /lk [stage | help | <stage-id> | next | back | approve | status | preset <name> | size small|large | ' +
223 'small-max <n> | approval auto|ask | waive | reset | reload | auto on|off | band [stage] on|off]. With no argument it ' +
224 'opens the loopkeeper hub; /lk stage opens the Stage pane and /lk help lists the keys (a custom stage named stage ' +
225 'or help is reached with next, back or set_stage).'
226
227// changed: the approval approved something new (a gate or a waiting budget); budgetOnly: only a budget was approved;
228// credit: the approval only gave the merge credit (at a Ship stage with no gate, or with its gate already approved)
229type MoveOutcome = { ok: boolean; text: string; reason?: string; changed?: boolean; budgetOnly?: true; credit?: true }
230
231// ---- Small helpers that take $ -----------------------------------------------------------------------------------
232
233async function safeNow($: EngineInterface): Promise<number> {
234 try {
235 return await $.clock.now()
236 } catch {
237 return 0
238 }
239}
240
241function toast($: EngineInterface, text: string): void {
242 try {
243 const p = $.ui.toast(text) as unknown
244 if (p && typeof (p as Promise<unknown>).catch === 'function') (p as Promise<unknown>).catch(() => undefined)
245 } catch {
246 // display only
247 }
248}
249
250// A notice line in the chat (dim; Claude does not read it), never throwing
251function logNotice($: EngineInterface, text: string): void {
252 try {
253 const r: unknown = $.ui.log(text)
254 if (r && typeof (r as Promise<unknown>).then === 'function') (r as Promise<unknown>).then(undefined, () => {})
255 } catch {
256 // display only
257 }
258}
259
260// A person at the prompt, or an app surface attached. The desktop app may host Claude Code through the SDK, where
261// session.start says isInteractive false yet the session draws on the desktop; a plain -p run has no surface.
262async function live($: EngineInterface): Promise<boolean> {
263 try {
264 return (await read($, stageInteractive)) || (await $.session.surfaces()).length > 0
265 } catch {
266 return false
267 }
268}
269
270/** Both config files: the pipeline section as the stage machine reads it, and the changeControl section. Local on
271 * purpose: $ is never passed across an import. A missing or broken file gives the defaults. */
272async function readConfig($: EngineInterface, cwd: string): Promise<{ pipeline: Config; cc: unknown }> {
273 const home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
274 const textOf = async (path: string): Promise<string | null> => {
275 try {
276 return (await $.fs.exists(path)) ? String(await $.fs.read(path)) : null
277 } catch {
278 return null
279 }
280 }
281 const user = parseConfigText(home ? await textOf(userConfigPath(home)) : null, 'user config').value
282 const project = parseConfigText(cwd ? await textOf(projectConfigPath(cwd)) : null, 'project config').value
283 return { pipeline: pipelineFromLayers(user, project), cc: mergeConfig(user, project).config.changeControl }
284}
285
286// What a project keeps between sessions: the task's state, never the history
287async function persist($: EngineInterface): Promise<void> {
288 try {
289 const stage = await read($, stageCurrent)
290 const since = enteredAt(await read($, stageHistory), stage)
291 await $.store.set(projectKey(await read($, stageCwd)), {
292 stage,
293 at: since?.at ?? 0,
294 approved: [...(await read($, stageApproved))],
295 verified: [...(await read($, stageVerified))],
296 size: await read($, stageSize),
297 planVerdict: await read($, stageVerdict),
298 waived: [...(await read($, stageWaived))],
299 filesTouched: [...(await read($, stageFilesTouched))],
300 planFiles: await read($, stagePlanFiles),
301 budget: await read($, stageBudget),
302 })
303 } catch {
304 // the store can be unavailable; the session keeps working from $.state
305 }
306}
307
308// Write the derived atoms the gates read: stagePlanDone and stageShipOpen
309async function derive($: EngineInterface): Promise<void> {
310 const list = await read($, stageStages)
311 const cur = await read($, stageCurrent)
312 const ok = await read($, stageApproved)
313 await update($, stagePlanDone, () => planDone(list, cur, ok))
314 await update($, stageShipOpen, () => shipOpen(list, cur, ok))
315}
316
317// Put a stage list in place, keeping the current stage when it still exists
318async function applyStages($: EngineInterface, list: Stage[], name: string, note?: string): Promise<void> {
319 await update($, stageStages, () => list)
320 await update($, stagePreset, () => name)
321 const ids = list.map(s => s.id)
322 const gates = list.filter(s => s.gate === true).map(s => s.id)
323 await update($, stageApproved, a => a.filter(id => gates.includes(id)))
324 await update($, stageVerified, v => v.filter(id => ids.includes(id)))
325 const first = list[0]?.id ?? 'plan'
326 if (!ids.includes(await read($, stageCurrent))) {
327 await update($, stageCurrent, () => first)
328 // a fallback past moveTo: an unused merge credit goes with the stage it was given at
329 await update($, gateMergeCredit, () => false)
330 if (note) {
331 const at = await safeNow($)
332 await update($, stageHistory, h => capHistory([...h, { stage: first, at, by: 'command' as By, note }]))
333 }
334 }
335 await derive($)
336}
337
338async function stageStoreGet($: EngineInterface, key: string): Promise<unknown> {
339 try {
340 return await $.store.get(key)
341 } catch {
342 return undefined
343 }
344}
345
346async function stageStoreSet($: EngineInterface, key: string, value: unknown): Promise<boolean> {
347 try {
348 await $.store.set(key, value)
349 return true
350 } catch {
351 return false
352 }
353}
354
355async function applyConfig($: EngineInterface, config: Config, cc: unknown): Promise<void> {
356 const dir = await read($, stageCwd)
357 const section = (cc ?? {}) as { planPaths?: unknown; maxFilesPerTurn?: unknown; planSlack?: unknown; autoBudgetMax?: unknown }
358 await update($, stageAuto, () => config.auto)
359 await update($, stageSource, () => config.source)
360 await update($, stageSmallMax, () => config.smallMaxFiles ?? SMALL_MAX_DEFAULT)
361 await update($, stageContinueOnApprove, () => config.continueOnApprove !== false)
362 await update($, stageNextHint, () => config.nextHint !== false)
363 await update($, stagePlanPaths, () => [...(planPathsOf(section.planPaths) ?? DEFAULT_PLAN_PATHS)])
364 await update($, stageLimits, () => limitsOf(section))
365 const mode = approvalOf(await stageStoreGet($, approvalKey(dir)), config)
366 await update($, stageApproval, () => mode)
367 const chosen = chooseStages(config, await stageStoreGet($, presetKey(dir)))
368 await applyStages($, chosen.stages, chosen.preset)
369}
370
371// The per-task state a new task, a reset or a fresh session starts without. The file budget goes with the plan.
372async function clearTask($: EngineInterface): Promise<void> {
373 await update($, stageSize, () => '' as Size)
374 await update($, stageVerdict, () => null)
375 await update($, stageWaived, () => [])
376 await update($, stageFilesTouched, () => [])
377 await clearBudget($)
378}
379
380async function clearBudget($: EngineInterface): Promise<void> {
381 await update($, stagePlanFiles, () => null)
382 await update($, stageBudget, () => 0)
383}
384
385// The session's project root (where it started, or where /cd or a worktree move took it; a shell `cd` does not move
386// it), '' when it cannot be read. Plan paths, the store keys and the config resolve against it.
387async function sessionRoot($: EngineInterface): Promise<string> {
388 try {
389 return String((await $.session.root()) ?? '')
390 } catch {
391 return ''
392 }
393}
394
395// The project root at use, falling back to the one init read. Path decisions (guardEdit, autoTarget) use it; the
396// store keys and the config stay on stageCwd, the root init read, so the pipeline's state is never written under
397// another project's key after a /cd. The next init (session start, /clear, /resume) moves them.
398async function rootDir($: EngineInterface): Promise<string> {
399 return (await sessionRoot($)) || (await read($, stageCwd))
400}
401
402// Read the config and, unless this is a hot reload with state already in place, resume the project's stage
403async function init($: EngineInterface): Promise<void> {
404 let dir = await sessionRoot($)
405 if (!dir) {
406 try {
407 dir = String((await $.session.cwd()) ?? '')
408 } catch {
409 dir = ''
410 }
411 }
412 await update($, stageCwd, () => dir)
413 const { pipeline: config, cc } = await readConfig($, dir)
414 await applyConfig($, config, cc)
415 const at = await safeNow($)
416 await update($, stageNow, () => at)
417 const shown = await stageStoreGet($, BAND_KEY)
418 await update($, stageBand, () => shown !== false)
419 // ADR 0007: an old Claude Code is named on the band; an unknown or dev build is not
420 let note = ''
421 try {
422 note = versionNote((await $.session.version()) as { version?: unknown; base?: unknown })
423 } catch {
424 note = ''
425 }
426 await update($, stageVersionNote, () => note)
427 // ADR 0009: CLAUDE_MODS_DISABLE=loopkeeper turns the growth guard off
428 const disabled = killSwitchOn(await $.env.get('CLAUDE_MODS_DISABLE').catch(() => undefined))
429 await update($, stageDisabled, () => disabled)
430 if ((await read($, stageHistory)).length > 0) {
431 await derive($)
432 return
433 }
434 const saved = savedOf(await stageStoreGet($, projectKey(dir)))
435 const list = await read($, stageStages)
436 if (saved && indexOf(list, saved.stage) >= 0) {
437 const gates = list.filter(s => s.gate === true).map(s => s.id)
438 const ids = list.map(s => s.id)
439 await update($, stageCurrent, () => saved.stage)
440 await update($, stageApproved, () => saved.approved.filter(id => gates.includes(id)))
441 await update($, stageVerified, () => saved.verified.filter(id => ids.includes(id)))
442 await update($, stageSize, () => saved.size)
443 await update($, stageVerdict, () => saved.planVerdict)
444 await update($, stageWaived, () => saved.waived.filter(id => ids.includes(id)))
445 await update($, stageFilesTouched, () => saved.filesTouched)
446 await update($, stagePlanFiles, () => saved.planFiles)
447 await update($, stageBudget, () => saved.budget)
448 await update($, stageHistory, () => [{ stage: saved.stage, at, by: 'resume' as By, note: 'resumed from the last session' }])
449 } else {
450 const first = list[0]?.id ?? 'plan'
451 await update($, stageCurrent, () => first)
452 await update($, stageApproved, () => [])
453 await update($, stageVerified, () => [])
454 await clearTask($)
455 await update($, stageHistory, () => [{ stage: first, at, by: 'auto' as By, note: 'session start' }])
456 }
457 await update($, stageCarry, () => '')
458 await update($, stageCarryBy, () => '')
459 await derive($)
460 if (config.error) {
461 try {
462 $.ui.log(PLUGIN + ': ' + config.error + '; using the default stages')
463 } catch {
464 // nothing to do
465 }
466 }
467}
468
469async function moveTo($: EngineInterface, target: string, by: By, note?: string): Promise<MoveOutcome> {
470 const list = await read($, stageStages)
471 const from = await read($, stageCurrent)
472 const check = checkMove(list, await read($, stageApproved), from, target, await read($, stageVerified))
473 if (!check.ok) return { ok: false, text: check.message, reason: check.reason }
474 const at = await safeNow($)
475 const entry: Transition = { stage: check.stage.id, at, by }
476 const trimmed = typeof note === 'string' ? note.trim().slice(0, 200) : ''
477 if (trimmed) entry.note = trimmed
478 const id = check.stage.id
479 const forwardByOne = indexOf(list, id) === indexOf(list, from) + 1
480 const carried = await read($, stageCarry)
481 const carriedBy = await read($, stageCarryBy)
482 await update($, stageCurrent, () => id)
483 // the merge credit is the ship stage's: leaving it takes back one not used
484 if (roleOf(check.stage) !== 'ship') await update($, gateMergeCredit, () => false)
485 // a verifier counts per visit: entering a stage again needs a fresh one, except a small task's waiver the first time
486 if ((await read($, stageWaived)).includes(id)) await update($, stageWaived, w => w.filter(x => x !== id))
487 else await update($, stageVerified, v => v.filter(x => x !== id))
488 await update($, stageHistory, h => capHistory([...h, entry]))
489 // a validator that completed in the stage just before this one counts here
490 if (carried === id && forwardByOne && check.stage.requiresAgent && !(await read($, stageVerified)).includes(id)) {
491 await update($, stageVerified, v => [...v, id])
492 const by2 = 'verified by ' + (carriedBy || 'an agent')
493 await update($, stageHistory, h => capHistory([...h, { stage: id, at, by: 'auto' as By, note: by2, event: 'verified' as const }]))
494 }
495 await update($, stageCarry, () => '')
496 await update($, stageCarryBy, () => '')
497 // back to Plan: a new plan, so the plan's approval and its file budget go
498 if (roleOf(check.stage) === 'plan') {
499 await update($, stageWaived, () => [])
500 await update($, stageFilesTouched, () => [])
501 await clearBudget($)
502 }
503 await update($, stageNow, () => at)
504 await derive($)
505 await persist($)
506 return { ok: true, text: 'Moved from ' + labelOf(list, from) + ' to ' + check.stage.label + '.' }
507}
508
509// The state the file budget is judged on
510async function budgetState($: EngineInterface) {
511 return {
512 stages: await read($, stageStages),
513 current: await read($, stageCurrent),
514 approved: await read($, stageApproved),
515 size: await read($, stageSize),
516 planFiles: await read($, stagePlanFiles),
517 budget: await read($, stageBudget),
518 }
519}
520
521// The state the next step is decided on
522async function nextState($: EngineInterface) {
523 return {
524 ...(await budgetState($)),
525 approval: await read($, stageApproval),
526 hold: await read($, stageHoldPlan),
527 verified: await read($, stageVerified),
528 waived: await read($, stageWaived),
529 shipOk: await read($, gateShipOk),
530 shipGate: ((await read($, gateConfig)) ?? mergeLayers([]).config).shipGate,
531 }
532}
533
534// The next-step sentence (lib/stageModel.ts nextStep) for the state now
535async function nextSentence($: EngineInterface): Promise<string> {
536 return nextStep(await nextState($))
537}
538
539// The notice line in the chat after a main turn's answer (dim; Claude does not read it): `loopkeeper · <Stage> (<n>
540// of <m>) · ` and the next-step sentence. Only in a live session (see live) with the kill switch off and
541// pipeline.nextHint on, and only while a task is in play (sized, or the stage past the first); '' otherwise.
542async function nextLine($: EngineInterface): Promise<string> {
543 if (!(await read($, stageNextHint)) || (await read($, stageDisabled)) || !(await live($))) return ''
544 const list = await read($, stageStages)
545 const i = Math.max(0, indexOf(list, await read($, stageCurrent)))
546 if ((await read($, stageSize)) === '' && i === 0) return ''
547 return 'loopkeeper · ' + (list[i]?.label ?? '') + ' (' + (i + 1) + ' of ' + list.length + ') · ' + (await nextSentence($))
548}
549
550// Approve the planned file budget with the plan. `who` is 'validator' for a verdict in auto mode, which may approve
551// at most autoBudgetMax planned files; the user approves any count. Returns the sentence the approval states.
552async function approveBudget($: EngineInterface, who: 'validator' | 'user'): Promise<string> {
553 const limits = await read($, stageLimits)
554 const files = await read($, stagePlanFiles)
555 if (files === null) return budgetSentence(null, limits)
556 if (who === 'validator' && files > limits.autoMax) return budgetWaitSentence(files, limits)
557 await update($, stageBudget, () => budgetFor(files, limits))
558 await persist($)
559 return budgetSentence(files, limits)
560}
561
562// The band's "Approve budget" button and /lk approve once the plan gate is open but a large budget still waits
563async function approveWaitingBudget($: EngineInterface): Promise<MoveOutcome> {
564 if (!budgetWaits(await budgetState($))) return { ok: false, text: 'No file budget is waiting for approval.' }
565 return { ok: true, text: 'Approved the file budget. ' + (await approveBudget($, 'user')), changed: true, budgetOnly: true }
566}
567
568// A preset with no plan gate (the default): leaving Plan forward through set_stage or an auto move (never ExitPlanMode)
569// in approval mode auto with no hold approves a waiting budget the way a validator's verdict would, up to
570// autoBudgetMax planned files. Returns the sentence the approval states, '' when nothing waited.
571async function budgetOnLeavingPlan($: EngineInterface, from: string): Promise<string> {
572 const list = await read($, stageStages)
573 if (stageForRole(list, 'validate-plan')?.gate === true) return ''
574 const plan = stageForRole(list, 'plan')
575 if (!plan || from !== plan.id || (await read($, stageCurrent)) === plan.id) return ''
576 if ((await read($, stageApproval)) !== 'auto' || (await read($, stageHoldPlan))) return ''
577 if (!budgetWaits(await budgetState($))) return ''
578 return approveBudget($, 'validator')
579}
580
581// Size the task (Claude through set_stage, or the user through /lk size). It starts a new task in the plan stage: the
582// last task's approvals, verifier marks, verdict and file budget go. Small waives the plan gate and the verifiers and
583// moves on to the build stage; large records the size and, from set_stage, the planned file count.
584async function sizeTask($: EngineInterface, want: 'small' | 'large', by: By, note?: string, files: number | null = null): Promise<MoveOutcome> {
585 const list = await read($, stageStages)
586 const plan = stageForRole(list, 'plan')
587 let head = ''
588 if (plan && (await read($, stageCurrent)) !== plan.id) {
589 const moved = await moveTo($, plan.id, by, note)
590 if (!moved.ok) return moved
591 head = moved.text + ' '
592 }
593 await update($, stageApproved, () => [])
594 await update($, stageVerified, () => [])
595 await clearTask($)
596 // a hold the user's prompt asked for binds to this task; one an earlier task already used is spent
597 if (await read($, stageHoldPlan)) {
598 if (await read($, stageHoldBound)) {
599 await update($, stageHoldPlan, () => false)
600 await update($, stageHoldBound, () => false)
601 } else await update($, stageHoldBound, () => true)
602 }
603 await update($, stageSize, () => want)
604 if (want === 'large') {
605 // re-planning before approval replaces the count
606 await update($, stagePlanFiles, () => files)
607 await derive($)
608 await persist($)
609 return { ok: true, text: head + largeReply(files, await read($, stageLimits), by === 'tool') }
610 }
611 const at = await safeNow($)
612 const gate = stageForRole(list, 'validate-plan')
613 const check = stageForRole(list, 'validate')
614 const build = stageForRole(list, 'build')
615 const hold = await read($, stageHoldPlan)
616 const marks: string[] = []
617 const notes: Transition[] = []
618 if (gate?.requiresAgent) marks.push(gate.id)
619 if (check?.requiresAgent) {
620 marks.push(check.id)
621 notes.push({ stage: check.id, at, by: 'auto', note: 'small task: verifier waived', event: 'verified' })
622 }
623 await update($, stageVerified, v => [...v.filter(x => !marks.includes(x)), ...marks])
624 const opened = gate?.gate === true && !hold
625 if (opened && gate) {
626 await update($, stageApproved, a => (a.includes(gate.id) ? a : [...a, gate.id]))
627 notes.unshift({ stage: gate.id, at, by: 'auto', note: 'small task: plan gate waived', event: 'approved' })
628 }
629 await update($, stageWaived, () => [...new Set([...marks, ...(opened && gate ? [gate.id] : [])])])
630 await update($, stageHistory, h => capHistory([...h, ...notes]))
631 await derive($)
632 await persist($)
633 if (gate?.gate === true && hold) {
634 const moved = await moveTo($, gate.id, by, 'small task')
635 if (!moved.ok) return { ok: false, text: head + moved.text, reason: moved.reason }
636 return { ok: true, text: head + smallHeldReply(gate.label) }
637 }
638 if (build) {
639 const moved = await moveTo($, build.id, by, 'small task')
640 if (!moved.ok) return { ok: false, text: head + moved.text, reason: moved.reason }
641 }
642 const cur = await read($, stageCurrent)
643 return { ok: true, text: head + smallReply(labelOf(list, cur), await read($, stageSmallMax)) }
644}
645
646// Record a plan validator's verdict; consistent opens the plan gate when a validator completed, the mode is auto and
647// the user's prompt did not ask to wait. Opening the gate approves the planned file budget, up to autoBudgetMax.
648async function recordVerdict($: EngineInterface, ok: boolean, by: By, note?: string): Promise<MoveOutcome> {
649 const list = await read($, stageStages)
650 const gate = stageForRole(list, 'validate-plan')
651 if (!gate) return { ok: false, text: 'This pipeline has no validate-plan stage, so there is no plan verdict to record.' }
652 let head = ''
653 if ((await read($, stageCurrent)) !== gate.id) {
654 const moved = await moveTo($, gate.id, by, note)
655 if (!moved.ok) return moved
656 head = moved.text + ' '
657 }
658 const at = await safeNow($)
659 const validated = gate.requiresAgent ? (await read($, stageVerified)).includes(gate.id) : true
660 const who = validated ? verifiedBy(await read($, stageHistory), gate.id) : 'no validator'
661 await update($, stageVerdict, () => ({ ok, by: who, at }))
662 const mode = await read($, stageApproval)
663 const hold = await read($, stageHoldPlan)
664 let opened = false
665 let budget: string | undefined
666 if (ok && validated && mode === 'auto' && !hold && gate.gate === true) {
667 opened = true
668 if (!(await read($, stageApproved)).includes(gate.id)) {
669 await update($, stageApproved, a => [...a, gate.id])
670 const entry: Transition = { stage: gate.id, at, by: 'auto', note: 'plan validated by ' + who + ': gate opened', event: 'approved' }
671 await update($, stageHistory, h => capHistory([...h, entry]))
672 await derive($)
673 toast($, 'Plan validated; gate opened automatically')
674 }
675 budget = await approveBudget($, 'validator')
676 }
677 await persist($)
678 return { ok: true, text: head + verdictReply({ ok, validated, who, approval: mode, hold, opened, budget }) }
679}
680
681// A small task that would touch one file more than it may: refused, and the stage machine goes back to Plan with the
682// waiver undone, so stagePlanDone stays false until a real plan is validated
683async function pushBack($: EngineInterface): Promise<void> {
684 const list = await read($, stageStages)
685 const undo = await read($, stageWaived)
686 await update($, stageApproved, a => a.filter(id => !undo.includes(id)))
687 await update($, stageVerified, v => v.filter(id => !undo.includes(id)))
688 await update($, stageSize, () => '' as Size)
689 const plan = stageForRole(list, 'plan')
690 if (plan && (await read($, stageCurrent)) !== plan.id) await moveTo($, plan.id, 'auto', 'outgrew small')
691 await update($, stageWaived, () => [])
692 await update($, stageFilesTouched, () => [])
693 await clearBudget($)
694 await derive($)
695 await persist($)
696 toast($, PLUGIN + ': the small task outgrew its file limit; back to Plan')
697}
698
699// What the guard decided for one edit: the refusal, or the new path it reserved in the touched list (released again
700// when a hook or rule beneath refuses the edit).
701type Guarded = { held?: { decision: 'deny'; reason: string }; reserved?: string }
702
703async function guardEdit($: EngineInterface, input: unknown): Promise<Guarded> {
704 const raw = editPath(input)
705 if (raw === undefined) return {}
706 const dir = await rootDir($)
707 const path = normalizePath(raw, dir)
708 // plan material (the plan page, notes) is never counted and never refused
709 if (isPlanMaterial(path, dir, await read($, stagePlanPaths))) return {}
710 const touched = await read($, stageFilesTouched)
711 const max = await read($, stageSmallMax)
712 if (outgrows(await read($, stageSize), touched, path, max)) {
713 await pushBack($)
714 return { held: { decision: 'deny', reason: outgrowMessage(path, max) } }
715 }
716 if (!touched.includes(path) && touched.length < FILES_CAP) {
717 await update($, stageFilesTouched, f => (f.includes(path) ? f : [...f, path]))
718 await persist($)
719 return { reserved: path }
720 }
721 return {}
722}
723
724async function releaseEdit($: EngineInterface, path: string): Promise<void> {
725 await update($, stageFilesTouched, f => f.filter(p => p !== path))
726 await persist($)
727}
728
729// Release a reserved path, in turn with the guard's checks, for an edit that did not happen (refused, or its call threw)
730async function releaseReserved($: EngineInterface, path: string): Promise<void> {
731 const undo = guardChain.then(() => releaseEdit($, path))
732 guardChain = undo.catch(() => undefined)
733 try {
734 await undo
735 } catch {
736 // the call's own answer stands either way
737 }
738}
739
740async function step($: EngineInterface, dir: 1 | -1): Promise<MoveOutcome> {
741 const target = neighbour(await read($, stageStages), await read($, stageCurrent), dir)
742 if (!target) return { ok: false, text: dir === 1 ? 'Already at the last stage.' : 'Already at the first stage.' }
743 return moveTo($, target.id, 'command')
744}
745
746// /lk approve: the current stage's gate. Approving the plan gate approves the planned file budget with it (any count:
747// the user approves it). With the gate already open, or at another stage's gate, a large budget still waiting is
748// approved first. From the stage before a gate (no gate of its own waiting), it opens the next stage's gate and moves
749// into it (openGateAhead); never a gate two stages ahead. At the ship stage the user's approval also covers the next
750// push or merge to the default branch (the merge credit), whether it approves the gate, finds it approved or finds none.
751async function approveGate($: EngineInterface): Promise<MoveOutcome> {
752 const list = await read($, stageStages)
753 const stage = list[indexOf(list, await read($, stageCurrent))]
754 if (!stage) return { ok: false, text: 'No current stage.' }
755 const isPlanGate = roleOf(stage) === 'validate-plan'
756 const isShip = roleOf(stage) === 'ship'
757 if (isPlanGate && stage.gate === true) {
758 await update($, stageHoldPlan, () => false)
759 await update($, stageHoldBound, () => false)
760 }
761 if (stage.gate !== true || (await read($, stageApproved)).includes(stage.id)) {
762 if (budgetWaits(await budgetState($))) return approveWaitingBudget($)
763 const ahead = gateAhead(list, stage.id, await read($, stageApproved))
764 if (ahead) return openGateAhead($, stage, ahead)
765 // nothing new is approved, so no continue prompt goes (no `changed`); the answer says what the approval covers
766 if (isShip) {
767 await update($, gateMergeCredit, () => true)
768 const head = stage.gate !== true ? stage.label + ' has no gate to approve' : 'The ' + stage.label + ' gate is already approved'
769 return { ok: true, text: head + '; the next push or merge to the default branch is allowed.', credit: true }
770 }
771 if (stage.gate !== true) return { ok: false, text: stage.label + ' has no gate to approve.' }
772 return { ok: true, text: 'The ' + stage.label + ' gate is already approved.' }
773 }
774 // any other gate waits behind a planned budget, as on the band and the hub's `a`: this approval is the budget's, the
775 // next one the gate's
776 if (!isPlanGate && budgetWaits(await budgetState($))) return approveWaitingBudget($)
777 await update($, stageApproved, a => [...a, stage.id])
778 if (isShip) await update($, gateMergeCredit, () => true)
779 // the user's approval vouches for the stage's verifier too
780 if (stage.requiresAgent && !(await read($, stageVerified)).includes(stage.id)) {
781 await update($, stageVerified, v => [...v, stage.id])
782 }
783 await derive($)
784 await persist($)
785 const text = 'Approved the ' + stage.label + ' gate; the pipeline may now move past it.'
786 return { ok: true, text: isPlanGate ? text + ' ' + (await approveBudget($, 'user')) : text, changed: true }
787}
788
789// /lk approve from the stage before a gate. The user's approval vouches for the current stage's verifier, so the move
790// passes; entering a stage clears its verifier mark, so the next stage's gate and verifier marks go on after the move.
791// A gate already approved (after /lk back) is moved into without approving it again. The plan gate clears the hold
792// and approves the planned file budget, as /lk approve at Validate plan does. Into the ship stage, the approval also
793// covers the next push or merge to the default branch (the merge credit).
794async function openGateAhead($: EngineInterface, stage: Stage, next: Stage): Promise<MoveOutcome> {
795 const list = await read($, stageStages)
796 const already = (await read($, stageApproved)).includes(next.id)
797 const vouch = Boolean(stage.requiresAgent) && !(await read($, stageVerified)).includes(stage.id)
798 if (vouch) await update($, stageVerified, v => [...v, stage.id])
799 const moved = await moveTo($, next.id, 'command', 'approved by the user')
800 if (!moved.ok) {
801 if (vouch) await update($, stageVerified, v => v.filter(id => id !== stage.id))
802 return moved
803 }
804 if (roleOf(next) === 'ship') await update($, gateMergeCredit, () => true)
805 if (!already) await update($, stageApproved, a => (a.includes(next.id) ? a : [...a, next.id]))
806 if (next.requiresAgent) await update($, stageVerified, v => (v.includes(next.id) ? v : [...v, next.id]))
807 const isPlanGate = roleOf(next) === 'validate-plan'
808 if (isPlanGate) {
809 await update($, stageHoldPlan, () => false)
810 await update($, stageHoldBound, () => false)
811 }
812 await derive($)
813 await persist($)
814 const after = neighbour(list, next.id, 1)
815 const opened = 'Approved the ' + next.label + ' gate and moved to ' + next.label
816 const text = already
817 ? 'Moved to ' + next.label + '; its gate was already approved.'
818 : roleOf(next) === 'ship'
819 ? opened + ': Claude may commit, push or open a PR.'
820 : opened + (after ? '; Claude may start ' + after.label + '.' : '.')
821 return { ok: true, text: isPlanGate ? text + ' ' + (await approveBudget($, 'user')) : text, changed: true }
822}
823
824async function resetPipeline($: EngineInterface): Promise<MoveOutcome> {
825 const list = await read($, stageStages)
826 const first = list[0]?.id ?? 'plan'
827 const at = await safeNow($)
828 await update($, stageCurrent, () => first)
829 await update($, gateMergeCredit, () => false)
830 await update($, stageApproved, () => [])
831 await update($, stageVerified, () => [])
832 await clearTask($)
833 await update($, stageHoldPlan, () => false)
834 await update($, stageHoldBound, () => false)
835 await update($, stageCarry, () => '')
836 await update($, stageCarryBy, () => '')
837 await update($, stageHistory, () => [{ stage: first, at, by: 'command' as By, note: 'reset' }])
838 await update($, stageNow, () => at)
839 await derive($)
840 await persist($)
841 return { ok: true, text: 'Pipeline reset to ' + labelOf(list, first) + '; gate approvals cleared.' }
842}
843
844async function reloadConfig($: EngineInterface): Promise<MoveOutcome> {
845 const { pipeline: config, cc } = await readConfig($, await read($, stageCwd))
846 await applyConfig($, config, cc)
847 await persist($)
848 const list = await read($, stageStages)
849 const name = await read($, stagePreset)
850 const where =
851 name !== 'file' && name !== 'default'
852 ? 'preset ' + name
853 : config.source === 'file' && name === 'file'
854 ? CONFIG_LABEL
855 : 'the default stages'
856 const warn = config.error ? ' (' + config.error + ')' : ''
857 return { ok: true, text: 'Loaded ' + list.length + ' stages from ' + where + warn + ': ' + validIds(list) + '.' }
858}
859
860async function choosePreset($: EngineInterface, name: string): Promise<MoveOutcome> {
861 const dir = await read($, stageCwd)
862 const { pipeline: config } = await readConfig($, dir)
863 const known = presetNames(config)
864 if (!name) {
865 return { ok: true, text: 'Preset: ' + (await read($, stagePreset)) + '. Presets: ' + known.join(', ') + (config.hasStages ? ', file' : '') + '.' }
866 }
867 let list: Stage[] | undefined
868 let saved = name
869 if (name === 'file') {
870 if (config.source !== 'file' || config.hasStages === false) return { ok: false, text: CONFIG_LABEL + ' has no stages list to use.' }
871 list = config.stages
872 saved = ''
873 } else {
874 list = presetStages(config, name)
875 }
876 if (!list) return { ok: false, text: 'Unknown preset "' + name + '". Presets: ' + known.join(', ') + '.' }
877 await applyStages($, list, name, 'preset ' + name)
878 await stageStoreSet($, presetKey(dir), saved)
879 await persist($)
880 return { ok: true, text: 'Pipeline preset ' + name + ': ' + list.map(s => s.label).join(' → ') + '. Saved for this project.' }
881}
882
883// The stage's part of /lk status: where the stage machine is and what holds the next move
884async function statusText($: EngineInterface): Promise<string> {
885 const list = await read($, stageStages)
886 const cur = await read($, stageCurrent)
887 const ok = await read($, stageApproved)
888 const done = await read($, stageVerified)
889 const state = await budgetState($)
890 const lines = [
891 'stage ● ' + labelOf(list, cur) + ' (' + (Math.max(0, indexOf(list, cur)) + 1) + ' of ' + list.length + ') · preset ' +
892 (await read($, stagePreset)) + ' · auto ' + ((await read($, stageAuto)) ? 'on' : 'off'),
893 ]
894 const gates = list.filter(s => s.gate === true)
895 lines.push('Gates: ' + (gates.length === 0 ? 'none' : gates.map(g => g.label + (ok.includes(g.id) ? ' approved' : ' pending')).join(', ')))
896 const needs = list.filter(s => s.requiresAgent)
897 if (needs.length > 0) lines.push('Verifier: ' + needs.map(s => s.label + (done.includes(s.id) ? ' satisfied' : ' needed')).join(', '))
898 lines.push(sizeLine(state.size, (await read($, stageFilesTouched)).length, await read($, stageSmallMax)))
899 if (stageForRole(list, 'validate-plan')) lines.push(planMaterialLine(await read($, stagePlanPaths)))
900 lines.push(approvalLine(await read($, stageApproval), await read($, stageHoldPlan)))
901 lines.push('Continue after approval: ' + ((await read($, stageContinueOnApprove)) ? 'on' : 'off'))
902 lines.push(verdictLine(await read($, stageVerdict)))
903 lines.push(budgetLine(state.planFiles, state.budget, budgetWaits(state), await read($, stageLimits)))
904 // the next-step sentence, under a Next: label of its own unless it starts with one
905 const sentence = await nextSentence($)
906 lines.push(sentence.startsWith('Next: ') ? sentence : 'Next: ' + sentence)
907 lines.push('planDone ' + (await read($, stagePlanDone)) + ' · shipOpen ' + (await read($, stageShipOpen)))
908 const version = await read($, stageVersionNote)
909 if (version) lines.push('Warning: loopkeeper ' + version + '.')
910 if (await read($, stageDisabled)) lines.push('Warning: ' + DISABLED_NOTE + '; edits are not counted against a small task.')
911 return lines.join('\n')
912}
913
914// The user vouches for the current stage's verifier requirement
915async function waive($: EngineInterface): Promise<MoveOutcome> {
916 const list = await read($, stageStages)
917 const stage = list[indexOf(list, await read($, stageCurrent))]
918 if (!stage?.requiresAgent) return { ok: false, text: (stage?.label ?? 'This stage') + ' needs no verifier.' }
919 await update($, stageVerified, v => (v.includes(stage.id) ? v : [...v, stage.id]))
920 await persist($)
921 return { ok: true, text: 'Marked the ' + stage.label + ' verifier as satisfied by the user.' }
922}
923
924// A completed Agent call that matches the current stage's requiresAgent satisfies it
925async function noteAgent($: EngineInterface, call: Record<string, unknown>): Promise<void> {
926 const list = await read($, stageStages)
927 const ci = indexOf(list, await read($, stageCurrent))
928 const stage = list[ci]
929 if (!stage) return
930 const who = [call.subagent_type, call.description].filter(v => typeof v === 'string').join(': ').slice(0, 120)
931 if (!agentMatches(stage, call)) {
932 // lenient carry: a validator done in the stage just before a requiresAgent stage counts when it is entered
933 const following = list[ci + 1]
934 if (following && agentMatches(following, call)) {
935 await update($, stageCarry, () => following.id)
936 await update($, stageCarryBy, () => who)
937 }
938 return
939 }
940 if ((await read($, stageVerified)).includes(stage.id)) return
941 await update($, stageVerified, v => [...v, stage.id])
942 const at = await safeNow($)
943 const entry: Transition = { stage: stage.id, at, by: 'auto', note: 'verified by ' + (who || 'an agent'), event: 'verified' }
944 await update($, stageHistory, h => capHistory([...h, entry]))
945 await persist($)
946}
947
948// A press that did nothing says why in a toast, and so does one that only gave the merge credit (the pane shows none)
949function tell($: EngineInterface, outcome: MoveOutcome): void {
950 if (!outcome.ok || outcome.credit) toast($, outcome.text)
951}
952
953// ---- ADR 0012: continue after approval ----------------------------------------------------------------------------
954
955// The prompt goes only from a live session (see live), with the config switch on and the kill switch off
956async function mayContinue($: EngineInterface): Promise<boolean> {
957 return (await read($, stageContinueOnApprove)) && !(await read($, stageDisabled)) && (await live($))
958}
959
960// One continue prompt per main turn: a send claims a ticket (its seq) while none is live, and the next main
961// turn.start ends it. A timer or a retry goes only while its own ticket is still the live one, so a second approval
962// before the next turn, or a retry that a whole turn overtook, sends nothing.
963async function claimContinue($: EngineInterface): Promise<number | null> {
964 let claimed = false
965 const now = await update($, stageContinueSent, cur => {
966 claimed = !cur.live
967 return claimed ? { seq: cur.seq + 1, live: true } : cur
968 })
969 return claimed ? now.seq : null
970}
971
972async function isLive($: EngineInterface, seq: number): Promise<boolean> {
973 const cur = await read($, stageContinueSent)
974 return cur.live && cur.seq === seq
975}
976
977// A ticket given up (or dropped) frees the next turn's prompt, unless a later one took its place
978async function releaseContinue($: EngineInterface, seq: number): Promise<void> {
979 try {
980 await update($, stageContinueSent, cur => (cur.seq === seq ? { seq, live: false } : cur))
981 } catch {
982 // the next turn.start frees it
983 }
984}
985
986// Framed as the plugin's message (no asUser). A refused submit (the host refuses one while a command is still
987// answering) is tried again after 100 ms, 500 ms and 2 s, each time only while the conditions still hold and no main
988// turn has started; then it is given up silently: the approval stands.
989function submitContinue($: EngineInterface, text: string, seq: number, retry = 0): void {
990 const again = (): void => {
991 const ms = CONTINUE_RETRY_MS[retry]
992 if (ms === undefined) return void releaseContinue($, seq)
993 try {
994 $.clock.after(ms, () => void sendIfLive($, text, seq, retry + 1))
995 } catch {
996 void releaseContinue($, seq)
997 }
998 }
999 try {
1000 const p = $.prompt.submit({ text }) as unknown
1001 // a hook that drops the prompt starts no turn, so its ticket is freed here
1002 const dropped = (r: unknown): void => {
1003 if (r !== null && typeof r === 'object' && 'drop' in r) void releaseContinue($, seq)
1004 }
1005 if (p && typeof (p as Promise<unknown>).then === 'function') (p as Promise<unknown>).then(dropped, again)
1006 } catch {
1007 again()
1008 }
1009}
1010
1011// A send from a timer (a retry, or /lk approve's): only while the conditions still hold, no main turn runs and no
1012// main turn started since the ticket was claimed
1013async function sendIfLive($: EngineInterface, text: string, seq: number, retry: number): Promise<void> {
1014 try {
1015 if (!(await isLive($, seq)) || !(await mayContinue($)) || (await read($, stageTurnRunning))) return releaseContinue($, seq)
1016 } catch {
1017 return releaseContinue($, seq)
1018 }
1019 submitContinue($, text, seq, retry)
1020}
1021
1022// After a user's approval that changed something, submit one prompt so the model carries on. Idle, it goes at once,
1023// unless a continue prompt is already on its way; during a main turn it waits for that turn's end (turn.complete),
1024// where a later approval replaces it, and a budget-only approval is not kept.
1025// `fromCommand`: the host refuses a submit from a command.run hook (it would wait on the turn the hook holds), so
1026// /lk approve submits from a timer just after the command has answered.
1027async function continueAfter($: EngineInterface, outcome: MoveOutcome, said: string, fromCommand = false): Promise<void> {
1028 try {
1029 if (!outcome.ok || outcome.changed !== true || !(await mayContinue($))) return
1030 const text = said + ' ' + outcome.text + ' Carry on with the task from where you stopped.'
1031 if (!(await read($, stageTurnRunning))) {
1032 const seq = await claimContinue($)
1033 if (seq === null) return
1034 if (!fromCommand) submitContinue($, text, seq)
1035 else {
1036 try {
1037 $.clock.after(CONTINUE_DELAY_MS, () => void sendIfLive($, text, seq, 0))
1038 } catch {
1039 void releaseContinue($, seq)
1040 }
1041 }
1042 return
1043 }
1044 if (outcome.budgetOnly) return
1045 const stage = await read($, stageCurrent)
1046 await update($, stageContinuePending, () => ({ stage, text }))
1047 } catch {
1048 // the approval stands without the prompt
1049 }
1050}
1051
1052async function closePane($: EngineInterface): Promise<void> {
1053 try {
1054 await $.ui.close({ id: PANE_ID })
1055 } catch {
1056 // already closed
1057 }
1058}
1059
1060// ---- The hub and the nav bar: switching panes ---------------------------------------------------------------------
1061
1062// Switch panes: close `fromId` first ('' closes nothing), since a focus request is refused while the person holds a
1063// pane, then open `toId` with the focus. The composer keeps the focus while it holds a draft: then a toast says how to
1064// reach the pane, once a session and only in the terminal (the press's `surface`; with no press, a session whose every
1065// surface is the terminal). False when the pane could not be opened.
1066async function switchTo($: EngineInterface, fromId: string, toId: string, title: string, surface?: unknown): Promise<boolean> {
1067 if (fromId) {
1068 try {
1069 await $.ui.close({ id: fromId })
1070 } catch {
1071 // already closed
1072 }
1073 }
1074 try {
1075 await $.ui.open({ id: toId, title, focus: true, closeOnEscape: true, ...(toId === HUB_ID ? { rows: HUB_ROWS } : {}) })
1076 } catch {
1077 return false
1078 }
1079 try {
1080 const pane = (await $.ui.panes()).find(p => p.id === toId)
1081 if (pane && !pane.isFocused && !(await read($, stageFocusHinted))) {
1082 const terminal = typeof surface === 'string' ? surface === 'terminal' : (await $.session.surfaces()).every(s => s === 'terminal')
1083 if (terminal) {
1084 await update($, stageFocusHinted, () => true)
1085 toast($, 'ctrl+x tab to focus the panel')
1086 }
1087 }
1088 } catch {
1089 // no list: no hint
1090 }
1091 return true
1092}
1093
1094// The nav bar's items: every loopkeeper pane, a press switching from `fromId` to it
1095function navItems($: EngineInterface, fromId: string) {
1096 return PANES.map(p => ({
1097 key: p.key,
1098 label: p.label,
1099 hotkey: p.hotkey,
1100 onPress: async (e?: { surface?: unknown }) => {
1101 if (!(await switchTo($, fromId, p.id, p.title, e?.surface))) toast($, 'Could not open the ' + p.label + ' pane.')
1102 },
1103 }))
1104}
1105
1106// The other mods' commands $.command.list() reports, matched on name and plugin ('name@marketplace' counts), kept for
1107// the hub to draw with the plugin they came from, the lk- one when both are listed. A list that cannot be read leaves
1108// the last one.
1109async function readHubCommands($: EngineInterface): Promise<void> {
1110 try {
1111 const listed = await $.command.list()
1112 const from = (c: { plugin?: string }, plugin: string) => c.plugin === plugin || (c.plugin ?? '').startsWith(plugin + '@')
1113 const found = HUB_MODS.flatMap(m => {
1114 const plugin = m.plugins.find(p => listed.some(c => c.name === m.command && from(c, p)))
1115 return plugin ? [{ name: m.command, plugin }] : []
1116 })
1117 await update($, stageHubCommands, () => found)
1118 } catch {
1119 // the hub keeps what it drew
1120 }
1121}
1122
1123// An other-mods press: with an empty composer, close the hub and put the command there for Enter; a draft is kept
1124async function offerMod($: EngineInterface, mod: (typeof HUB_MODS)[number]): Promise<void> {
1125 let draft = ''
1126 try {
1127 draft = String((await $.prompt.read()).text ?? '')
1128 } catch {
1129 draft = ''
1130 }
1131 if (draft.trim() !== '') {
1132 toast($, 'Type ' + mod.text + ' to open ' + mod.label)
1133 return
1134 }
1135 try {
1136 await $.ui.close({ id: HUB_ID })
1137 } catch {
1138 // already closed
1139 }
1140 let filled = false
1141 try {
1142 filled = (await $.prompt.fill({ text: mod.text, mode: 'replace' })).isFilled === true
1143 } catch {
1144 filled = false
1145 }
1146 toast($, filled ? 'Press Enter to open ' + mod.label : 'Type ' + mod.text + ' to open ' + mod.label)
1147}
1148
1149// An other-mods press off the terminal (the desktop app): close the hub and run the mod's command, which opens its
1150// own pane. The terminal, or a run that is refused, falls back to offerMod: a pane a queued plugin command opens is
1151// not one the person asked for, and on a narrow terminal it would wait undrawn.
1152async function openMod($: EngineInterface, mod: (typeof HUB_MODS)[number], surface: unknown): Promise<void> {
1153 if (typeof surface === 'string' && surface !== 'terminal') {
1154 try {
1155 await $.ui.close({ id: HUB_ID })
1156 } catch {
1157 // already closed
1158 }
1159 try {
1160 await $.command.run({ command: mod.command, args: mod.args })
1161 return
1162 } catch {
1163 // not run: fill or toast instead
1164 }
1165 }
1166 await offerMod($, mod)
1167}
1168
1169// The hub's o: the /lk words that switch GSD mode go in an empty prompt box, as the routing pane's Toggle mode does;
1170// a draft is kept and the toast says what to type
1171async function offerGsd($: EngineInterface, active: boolean): Promise<void> {
1172 const text = '/lk ' + (active ? 'on' : 'off')
1173 let draft = ''
1174 try {
1175 draft = String((await $.prompt.read()).text ?? '')
1176 } catch {
1177 draft = ''
1178 }
1179 if (draft.trim() !== '') {
1180 toast($, 'Type ' + text + ' to turn GSD mode ' + (active ? 'on' : 'off'))
1181 return
1182 }
1183 let filled = false
1184 try {
1185 filled = (await $.prompt.fill({ text, mode: 'replace' })).isFilled === true
1186 } catch {
1187 filled = false
1188 }
1189 toast($, filled ? 'Press Enter to run ' + text : 'Run ' + text)
1190}
1191
1192// Auto-move from a tool call: main loop only, forward only, never across an unapproved gate
1193async function autoMove($: EngineInterface, call: Record<string, unknown>): Promise<void> {
1194 const list = await read($, stageStages)
1195 const cur = await read($, stageCurrent)
1196 const { stage: target, shipAttempt } = autoTarget(list, call, cur, await rootDir($), await read($, stagePlanPaths))
1197 const tool = typeof call.tool === 'string' ? call.tool : 'tool'
1198 if (shipAttempt) {
1199 // a gated ship stage is entered by a decision, never by a commit; the attempt is noted
1200 const at = await safeNow($)hooks/lib/config.ts 203 lines1// The five config sections, their defaults, and the merge of the two config files.
2// Pure: this file never sees $. Each concern reads the files itself and hands the text here.
3// Precedence: defaults < ~/.claude/loopkeeper.json < <project>/.claude/loopkeeper.json.
4// Session overrides kept in $.store are applied by each concern afterwards.
5
6export const SECTIONS = ['pipeline', 'changeControl', 'routing', 'review', 'decisions'] as const
7
8export const DEFAULT_PLAN_PATHS: readonly string[] = ['.claude/plans/**', 'docs/plans/**', '**/*.plan.md', '**/*.plan.html']
9
10export type StageDef = { id: string; label: string; gate?: boolean; requiresAgent?: string }
11
12export type PipelineConfig = {
13 stages: StageDef[]
14 auto: boolean
15 presets: Record<string, StageDef[]>
16 approval: 'auto' | 'ask'
17 smallMaxFiles: number
18 // after the user approves a gate or a budget, submit one prompt so the model carries on
19 continueOnApprove: boolean
20 // while a task is in play, the next-step line: a notice line in the chat after each answer (dim; Claude does not
21 // read it)
22 nextHint: boolean
23}
24
25export type ProtectedRuleConfig = { glob: string; action: 'ask' | 'deny' }
26
27export type ChangeControlConfig = {
28 maxFilesPerTurn: number
29 planSlack: number
30 autoBudgetMax: number
31 protected: ProtectedRuleConfig[]
32 requirePlan: boolean
33 mergeGate: boolean
34 requireGreenCi: boolean
35 blockAmend: boolean
36 blockAddAll: boolean
37 countSubagents: boolean
38 shipGate: boolean
39 planPaths: string[]
40}
41
42export type RoutingSectionConfig = {
43 override: boolean
44 enabled: boolean | null
45 models: Record<string, string>
46 roles: Record<string, { model?: string; keywords?: string[] }>
47}
48
49export type ReviewConfig = {
50 /** Per kind: target score and cycle limit. A missing kind uses the kind's preset. */
51 kinds: Record<string, { target?: number; maxCycles?: number }>
52}
53
54export type DecisionsConfig = {
55 watch: boolean
56 watchModel: string
57 watchMaxPerHour: number
58 watchMinChars: number
59 /** Log file relative to the project; '' finds one. */
60 file: string
61 /** 'brain-md' is an alias of 'markdown'. */
62 format: 'jsonl' | 'markdown' | 'brain-md' | 'journal-md'
63}
64
65export type LoopkeeperConfig = {
66 pipeline: PipelineConfig
67 changeControl: ChangeControlConfig
68 routing: RoutingSectionConfig
69 review: ReviewConfig
70 decisions: DecisionsConfig
71}
72
73export const DEFAULT_STAGE_LIST: StageDef[] = [
74 { id: 'plan', label: 'Plan' },
75 { id: 'build', label: 'Build' },
76 { id: 'qa', label: 'QA' },
77 { id: 'ship', label: 'Ship' },
78]
79
80export const DEFAULT_ROUTING_MODELS: Record<string, string> = { haiku: 'haiku', sonnet: 'sonnet', opus: 'opus' }
81
82export function defaultConfig(): LoopkeeperConfig {
83 return {
84 pipeline: {
85 stages: DEFAULT_STAGE_LIST.map(s => ({ ...s })),
86 auto: true,
87 presets: {},
88 approval: 'auto',
89 smallMaxFiles: 3,
90 continueOnApprove: true,
91 nextHint: true,
92 },
93 changeControl: {
94 maxFilesPerTurn: 10,
95 planSlack: 10,
96 autoBudgetMax: 40,
97 protected: [
98 { glob: 'docs/decisions/**', action: 'ask' },
99 { glob: '.env*', action: 'deny' },
100 { glob: '**/migrations/**', action: 'ask' },
101 ],
102 requirePlan: false,
103 mergeGate: true,
104 requireGreenCi: true,
105 blockAmend: true,
106 blockAddAll: false,
107 countSubagents: false,
108 shipGate: true,
109 planPaths: [...DEFAULT_PLAN_PATHS],
110 },
111 routing: { override: false, enabled: null, models: { ...DEFAULT_ROUTING_MODELS }, roles: {} },
112 review: { kinds: {} },
113 decisions: {
114 watch: false,
115 watchModel: 'haiku',
116 watchMaxPerHour: 20,
117 watchMinChars: 200,
118 file: '',
119 format: 'jsonl',
120 },
121 }
122}
123
124export type ParseResult = { value: Record<string, unknown>; problems: string[] }
125
126const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
127
128/** Parse one config file's text. Never throws: a broken file gives an empty layer and a problem. */
129export function parseConfigText(text: string | null | undefined, label: string): ParseResult {
130 if (text === null || text === undefined || String(text).trim() === '') return { value: {}, problems: [] }
131 try {
132 const v: unknown = JSON.parse(String(text))
133 if (!isObj(v)) return { value: {}, problems: [`${label}: expected a JSON object`] }
134 return { value: v, problems: [] }
135 } catch (err) {
136 const why = err instanceof Error ? err.message : String(err)
137 return { value: {}, problems: [`${label}: not valid JSON (${why.slice(0, 80)})`] }
138 }
139}
140
141// A value fits a default when its kind matches: same typeof, array for array, object for object.
142function fits(def: unknown, v: unknown): boolean {
143 if (def === null) return v === null || typeof v === 'boolean'
144 if (Array.isArray(def)) return Array.isArray(v)
145 if (isObj(def)) return isObj(v)
146 if (typeof def === 'number') return typeof v === 'number' && Number.isFinite(v)
147 return typeof v === typeof def
148}
149
150function applySection(base: Record<string, unknown>, layer: unknown, name: string, problems: string[]): void {
151 if (layer === undefined) return
152 if (!isObj(layer)) {
153 problems.push(`${name}: expected an object`)
154 return
155 }
156 for (const [k, v] of Object.entries(layer)) {
157 if (!(k in base)) {
158 problems.push(`${name}.${k}: unknown key`)
159 continue
160 }
161 if (!fits(base[k], v)) {
162 problems.push(`${name}.${k}: wrong type, kept ${JSON.stringify(base[k])}`)
163 continue
164 }
165 base[k] = isObj(v) && isObj(base[k]) ? { ...(base[k] as object), ...v } : Array.isArray(v) ? [...v] : v
166 }
167 // A few keys have a closed set of values.
168 const p = base as Record<string, unknown>
169 if (name === 'pipeline' && p.approval !== 'auto' && p.approval !== 'ask') p.approval = 'auto'
170 if (name === 'decisions' && !['jsonl', 'markdown', 'brain-md', 'journal-md'].includes(String(p.format))) p.format = 'jsonl'
171}
172
173export type Merged = { config: LoopkeeperConfig; problems: string[] }
174
175/** defaults < user file < project file. Layers are parsed objects (see parseConfigText). */
176export function mergeConfig(user: unknown, project: unknown): Merged {
177 const config = defaultConfig() as unknown as Record<string, Record<string, unknown>>
178 const problems: string[] = []
179 for (const [label, layer] of [
180 ['user', user],
181 ['project', project],
182 ] as const) {
183 if (layer === undefined || layer === null) continue
184 if (!isObj(layer)) {
185 problems.push(`${label} config: expected an object`)
186 continue
187 }
188 for (const key of Object.keys(layer)) {
189 if (!(SECTIONS as readonly string[]).includes(key)) problems.push(`${label} config: unknown section "${key}"`)
190 }
191 for (const s of SECTIONS) applySection(config[s], layer[s], `${label} ${s}`, problems)
192 }
193 return { config: config as unknown as LoopkeeperConfig, problems }
194}
195
196/** The two files' text in, the merged config and every problem out. */
197export function mergeConfigText(userText: string | null | undefined, projectText: string | null | undefined): Merged {
198 const u = parseConfigText(userText, 'user config')
199 const p = parseConfigText(projectText, 'project config')
200 const m = mergeConfig(u.value, p.value)
201 return { config: m.config, problems: [...u.problems, ...p.problems, ...m.problems] }
202}
203hooks/lib/decisionFormats.ts 407 lines1// Pure helpers for the decision log: parsing /decide, building entries in each
2// log format, parsing entries back out, and keeping paths inside the project.
3// Nothing here touches the mods API, so tests can call it directly.
4
5/** The three log formats. 'markdown' is a dated bullet list; 'brain-md' is accepted on input as its alias. */
6export type Format = 'jsonl' | 'markdown' | 'journal-md'
7
8/** Where decisions go, relative to the session's working directory. */
9export type Target = {
10 /** Path relative to the working directory, with forward slashes */
11 file: string
12 /** Absolute path handed to $.fs */
13 abs: string
14 format: Format
15 /** Whether the file existed when the target was last checked */
16 exists: boolean
17 /** How the target was chosen: "override", "found", "adr-dir" or "default" */
18 source: string
19 /** A problem met while detecting, such as an unreadable override */
20 note?: string
21}
22
23/** One decision as parsed back out of a log. */
24export type Entry = {
25 date: string
26 decision: string
27 why: string
28 tags: string[]
29 id?: string
30 /** Who recorded it, when a jsonl log says so; filled in by decisions.ts for the pane, never by the parser */
31 by?: 'user' | 'claude'
32}
33
34/** The badge a decision gets in the pane: major or minor (Claude or the watch), you (/decide). */
35export type Kind = 'major' | 'minor' | 'you'
36
37/** A decision the watch found in a turn, before it is recorded. */
38export type Proposed = {
39 text: string
40 why: string
41 tags: string[]
42}
43
44/** The watch settings: defaults (watch off), then the two config files, then the session store. */
45export type WatchConfig = {
46 /** Run the watch after each main turn */
47 watch: boolean
48 /** Model alias or id for the watch call, such as 'haiku' or 'sonnet' */
49 watchModel: string
50 /** At most this many watch calls in any hour, across sessions */
51 watchMaxPerHour: number
52 /** Skip turns whose prompt plus answer is shorter than this */
53 watchMinChars: number
54}
55
56export const FORMATS: readonly Format[] = ['jsonl', 'markdown', 'journal-md']
57
58/** Accepts the public names and the 'brain-md' alias; anything else is null. */
59export function normalizeFormat(value: unknown): Format | null {
60 if (value === 'brain-md') return 'markdown'
61 return typeof value === 'string' && (FORMATS as readonly string[]).includes(value) ? (value as Format) : null
62}
63
64/** The project config file, relative to the working directory. The override lives in its "decisions" section. */
65export const OVERRIDE_FILE = '.claude/loopkeeper.json'
66
67/** Existing logs, checked in this order after the override. */
68export const CANDIDATES: readonly { file: string; format: Format }[] = [
69 { file: 'docs/decisions/decisions.jsonl', format: 'jsonl' },
70 { file: '.claude/brain/decisions.md', format: 'markdown' },
71 { file: 'brain/decisions.md', format: 'markdown' },
72 { file: 'DECISION_JOURNAL.md', format: 'journal-md' },
73]
74
75/** Directory whose ADR files mean "start a jsonl log beside them". */
76export const ADR_DIR = 'docs/decisions'
77export const ADR_DIR_LOG = 'docs/decisions/decisions.jsonl'
78
79/** Where decisions go when nothing else is found. */
80export const DEFAULT_LOG = '.claude/decisions.jsonl'
81
82/** One record about to be appended. */
83export type DecisionRecord = {
84 id: string
85 date: string
86 decision: string
87 why: string
88 tags: string[]
89 session: string
90 by: 'user' | 'claude'
91}
92
93export type DecisionInput = { decision: string; why: string; tags: string[] }
94
95const EM = '—'
96
97/** Collapses whitespace, including newlines, to single spaces. */
98export function oneLine(text: unknown): string {
99 return typeof text === 'string' ? text.replace(/\s+/g, ' ').trim() : ''
100}
101
102/** Strips '#', keeps letters, digits, _, - and /, dedupes, drops empties. */
103export function normalizeTags(tags: unknown): string[] {
104 if (!Array.isArray(tags)) return []
105 const out: string[] = []
106 for (const raw of tags) {
107 if (typeof raw !== 'string') continue
108 const tag = raw.trim().replace(/^#+/, '').replace(/[^\p{L}\p{N}_\-/]/gu, '')
109 if (tag && !out.includes(tag)) out.push(tag)
110 }
111 return out
112}
113
114/**
115 * Parses the text after /decide: `<decision> -- <why> #tag #tag`.
116 * `-- why` and the tags are optional; a tag is a `#word` that starts a token.
117 */
118export function parseDecideArgs(args: unknown): DecisionInput {
119 const text = typeof args === 'string' ? args : ''
120 const tags: string[] = []
121 const stripped = text.replace(/(^|\s)#([\p{L}\p{N}_\-/]+)/gu, (_m, lead: string, tag: string) => {
122 tags.push(tag)
123 return lead
124 })
125 const cut = stripped.search(/(^|\s)--(\s|$)/)
126 let decision = stripped
127 let why = ''
128 if (cut >= 0) {
129 decision = stripped.slice(0, cut)
130 why = stripped.slice(cut).replace(/^\s*--/, '')
131 }
132 return { decision: oneLine(decision), why: oneLine(why), tags: normalizeTags(tags) }
133}
134
135/** Reads a record_decision tool call's fields, guarding every one. */
136export function toolInputOf(e: unknown): DecisionInput {
137 const o = (e && typeof e === 'object' ? e : {}) as Record<string, unknown>
138 const tags = Array.isArray(o.tags) ? o.tags : typeof o.tags === 'string' ? o.tags.split(/[\s,]+/) : []
139 return { decision: oneLine(o.decision), why: oneLine(o.why), tags: normalizeTags(tags) }
140}
141
142const pad = (n: number) => String(n).padStart(2, '0')
143
144/** Local date and minute id for a time in milliseconds. */
145export function stampOf(ms: number): { date: string; id: string } {
146 const d = new Date(Number.isFinite(ms) ? ms : 0)
147 const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
148 const id = `D-${date.replace(/-/g, '')}-${pad(d.getHours())}${pad(d.getMinutes())}`
149 return { date, id }
150}
151
152/** Adds -2, -3, ... when the id already appears in the log. */
153export function uniqueId(id: string, existing: string | null): string {
154 if (!existing) return id
155 const taken = (candidate: string) => existing.includes(`"id":"${candidate}"`)
156 if (!taken(id)) return id
157 let n = 2
158 while (taken(`${id}-${n}`)) n += 1
159 return `${id}-${n}`
160}
161
162const noStop = (s: string) => s.replace(/[.\s]+$/, '')
163const tagText = (tags: string[]) => tags.map(t => '#' + t).join(' ')
164
165/** A markdown decision log: `- YYYY-MM-DD — text` bullets. */
166const BRAIN_BULLET = /^- \d{4}-\d{2}-\d{2} [—-] /m
167
168/** The text one record adds to a file, without the separator before it. */
169export function entryText(format: Format, rec: DecisionRecord, existing: string | null): string {
170 if (format === 'jsonl') {
171 return JSON.stringify({
172 id: rec.id,
173 date: rec.date,
174 decision: rec.decision,
175 why: rec.why,
176 tags: rec.tags,
177 session: rec.session,
178 by: rec.by,
179 }) + '\n'
180 }
181 const decision = noStop(rec.decision)
182 const why = noStop(rec.why)
183 const tags = rec.tags.length ? ' ' + tagText(rec.tags) : ''
184 if (format === 'markdown') {
185 const isBrain = !existing || !existing.trim() || BRAIN_BULLET.test(existing) || !/^- /m.test(existing)
186 if (isBrain) {
187 return `- ${rec.date} ${EM} ${decision}.${why ? ` Why: ${why}.` : ''}${tags}\n`
188 }
189 return `- **${rec.date}** ${EM} ${decision}.${why ? ` _Why:_ ${why}.` : ''}${tags}\n`
190 }
191 const lines = [`## ${rec.date} ${EM} ${titleOf(decision)}`, `- **What:** ${decision}.`]
192 if (why) lines.push(`- **Why:** ${why}.`)
193 if (rec.tags.length) lines.push(`- **Tags:** ${tagText(rec.tags)}`)
194 return lines.join('\n') + '\n'
195}
196
197/** A short heading from a decision: its first sentence, cut at 80 characters on a word. */
198export function titleOf(decision: string): string {
199 const first = decision.split(/(?<=[.;:])\s/)[0] ?? decision
200 const text = noStop(first)
201 if (text.length <= 80) return text
202 const cut = text.slice(0, 80)
203 const space = cut.lastIndexOf(' ')
204 return (space > 40 ? cut.slice(0, space) : cut) + '…'
205}
206
207/** The header a new file starts with, by format. */
208function headerOf(format: Format): string {
209 if (format === 'markdown') return '# Decisions\n\n'
210 if (format === 'journal-md') return '# Decision Journal\n\n'
211 return ''
212}
213
214/**
215 * The whole new content: the existing text untouched, a separator so the
216 * entry starts on its own line (a blank line before a journal section), then
217 * the entry. A missing file gets the format's header first.
218 */
219export function appendEntry(existing: string | null, format: Format, rec: DecisionRecord): string {
220 const entry = entryText(format, rec, existing)
221 if (existing === null || existing === '') return headerOf(format) + entry
222 let sep = existing.endsWith('\n') ? '' : '\n'
223 if (format === 'journal-md' && !(existing + sep).endsWith('\n\n')) sep += '\n'
224 return existing + sep + entry
225}
226
227const splitTags = (text: string) => normalizeTags(text.match(/#[\p{L}\p{N}_\-/]+/gu) ?? [])
228
229/** Parses every entry in file order. Unparseable lines are skipped. */
230export function parseEntries(content: unknown, format: Format): Entry[] {
231 if (typeof content !== 'string' || !content) return []
232 if (format === 'jsonl') return parseJsonl(content)
233 if (format === 'journal-md') return parseJournal(content)
234 return parseBrain(content)
235}
236
237function parseJsonl(content: string): Entry[] {
238 const out: Entry[] = []
239 for (const line of content.split('\n')) {
240 if (!line.trim()) continue
241 try {
242 const o = JSON.parse(line) as Record<string, unknown>
243 if (!o || typeof o !== 'object') continue
244 const decision = oneLine(o.title) || oneLine(o.decision)
245 if (!decision) continue
246 out.push({
247 id: typeof o.id === 'string' ? o.id : undefined,
248 date: oneLine(o.date),
249 decision,
250 why: oneLine(o.why) || oneLine(o.context),
251 tags: normalizeTags(o.tags),
252 })
253 } catch {
254 // not JSON: skip the line
255 }
256 }
257 return out
258}
259
260function parseBrain(content: string): Entry[] {
261 const out: Entry[] = []
262 const bullet = /^- (?:\*\*)?(\d{4}-\d{2}-\d{2})(?:\*\*)? [—-]+ (.*)$/
263 for (const line of content.split('\n')) {
264 const m = bullet.exec(line)
265 if (!m) continue
266 let body = m[2] ?? ''
267 const tags: string[] = []
268 const project = /^\[([^\]]+)\]\s*/.exec(body)
269 if (project) {
270 tags.push(project[1] ?? '')
271 body = body.slice(project[0].length)
272 }
273 const trailing = /((?:\s+#[\p{L}\p{N}_\-/]+)+)\s*$/u.exec(body)
274 if (trailing) {
275 tags.push(...splitTags(trailing[1] ?? ''))
276 body = body.slice(0, trailing.index)
277 }
278 const whyAt = body.search(/\s(?:_Why:_|Why:)\s/)
279 const decision = whyAt >= 0 ? body.slice(0, whyAt) : body
280 const why = whyAt >= 0 ? body.slice(whyAt).replace(/^\s*(?:_Why:_|Why:)\s*/, '') : ''
281 out.push({ date: m[1] ?? '', decision: oneLine(decision), why: oneLine(why), tags: normalizeTags(tags) })
282 }
283 return out
284}
285
286function parseJournal(content: string): Entry[] {
287 const out: Entry[] = []
288 const heading = /^## (\d{4}-\d{2}-\d{2})\s*[—-]+\s*(.*)$/
289 let current: Entry | null = null
290 for (const line of content.split('\n')) {
291 const m = heading.exec(line)
292 if (m) {
293 current = { date: m[1] ?? '', decision: oneLine(m[2]), why: '', tags: [] }
294 out.push(current)
295 continue
296 }
297 if (line.startsWith('## ') || line.startsWith('# ')) {
298 current = null
299 continue
300 }
301 if (!current) continue
302 const why = /^- \*\*Why[^*]*\*\*\s*(.*)$/.exec(line)
303 if (why && !current.why) current.why = oneLine(why[1])
304 const tags = /^- \*\*Tags:\*\*\s*(.*)$/.exec(line)
305 if (tags) current.tags = splitTags(tags[1] ?? '')
306 }
307 return out
308}
309
310/** The last n entries, newest first: by date, later in the file first on a tie. */
311export function lastEntries(content: unknown, format: Format, n: number): Entry[] {
312 const all = parseEntries(content, format).map((entry, index) => ({ entry, index }))
313 all.sort((a, b) => (a.entry.date === b.entry.date ? b.index - a.index : a.entry.date < b.entry.date ? 1 : -1))
314 return all.slice(0, Math.max(0, n)).map(x => x.entry)
315}
316
317/**
318 * Normalizes a relative path and refuses one that leaves the project:
319 * absolute paths, drive letters, and `..` above the root return null.
320 */
321export function resolveInside(rel: unknown): string | null {
322 if (typeof rel !== 'string') return null
323 const text = rel.trim()
324 if (!text || text.includes('\0') || /^[\\/]/.test(text) || /^[A-Za-z]:/.test(text) || text.startsWith('~')) return null
325 const parts: string[] = []
326 for (const seg of text.split(/[\\/]+/)) {
327 if (!seg || seg === '.') continue
328 if (seg === '..') {
329 if (parts.length === 0) return null
330 parts.pop()
331 continue
332 }
333 parts.push(seg)
334 }
335 return parts.length ? parts.join('/') : null
336}
337
338/** Joins the working directory and a relative path already checked by resolveInside. */
339export function absOf(cwd: string, rel: string): string {
340 if (!cwd) return rel
341 return cwd.replace(/[\\/]+$/, '') + '/' + rel
342}
343
344/** Where an error about the project config file points: the file and the section. */
345const SECTION = `${OVERRIDE_FILE}, "decisions" section`
346
347/**
348 * The log override in the project config file's "decisions" section: `{ file, format }`.
349 * Returns null when the file names no log (it may hold other sections or only watch settings),
350 * the override when both are valid, and an error text otherwise. Only the project file is read for this.
351 */
352export function parseOverride(raw: unknown): { file: string; format: Format } | { error: string } | null {
353 let o: unknown
354 try {
355 o = JSON.parse(typeof raw === 'string' ? raw : '')
356 } catch {
357 return { error: `${OVERRIDE_FILE} is not valid JSON` }
358 }
359 if (!o || typeof o !== 'object' || Array.isArray(o)) return { error: `${OVERRIDE_FILE} is not an object` }
360 const section = (o as Record<string, unknown>).decisions
361 if (section === undefined) return null
362 if (!section || typeof section !== 'object' || Array.isArray(section)) return { error: `${SECTION}: expected an object` }
363 const rec = section as Record<string, unknown>
364 // Watch settings alone are not an override
365 if (!('file' in rec) && !('format' in rec)) return null
366 // An empty file means "find one", the default
367 if (rec.file === '' && !('format' in rec)) return null
368 const file = resolveInside(rec.file)
369 if (!file) return { error: `${SECTION}: "file" must be a relative path inside the project` }
370 const format = normalizeFormat(rec.format)
371 if (!format) return { error: `${SECTION}: "format" must be one of ${FORMATS.join(', ')}` }
372 return { file, format }
373}
374
375/** Whether a file name in docs/decisions looks like an ADR. */
376export function isAdrFile(name: unknown): boolean {
377 return typeof name === 'string' && /^(?:ADR[-_ ]?\d+|\d{3,4}[-_]).*\.md$/i.test(name)
378}
379
380/** The band's chip: kit.line('decisions', [{ text: bandText(n), tone: 'info' }], 'watch on' while the watch is on). */
381export function bandText(n: number): string {
382 return `${n} this session`
383}
384
385/** Usage for /decide and /decisions. */
386export const DECIDE_USAGE = 'Usage: /decide <decision> -- <why> #tag #tag (the "-- why" and the #tags are optional)'
387export const DECISIONS_USAGE = 'Usage: /decisions [text | reload | band on|off]'
388
389/** The plain-text listing /decisions text prints. */
390export function textListing(target: Target | null, list: readonly Entry[]): string {
391 if (!target) return 'loopkeeper has no decision target yet. Run /decisions reload.'
392 const head = `Decisions · ${target.file} (${target.format})${target.exists ? '' : ' · created on first record'}`
393 const lines = [head]
394 if (target.note) lines.push(`Note: ${target.note}`)
395 if (list.length === 0) {
396 lines.push('No decisions yet. Record one with /decide <decision> -- <why> #tag')
397 return lines.join('\n')
398 }
399 lines.push(`Last ${list.length}, newest first:`)
400 for (const entry of list) {
401 lines.push(`${entry.date || '----------'} ${entry.decision}`)
402 if (entry.why) lines.push(` why: ${entry.why}`)
403 if (entry.tags.length) lines.push(` ${tagText(entry.tags)}`)
404 }
405 return lines.join('\n')
406}
407hooks/lib/kit.ts 955 lines1// kit.ts: the shared UI kit (kit v3).
2//
3// The module has no imports and touches no mods API: every helper takes the elements a hook got
4// from `$.ui.resolve(e)` and the surface (`e.surface`) and returns an element tree.
5//
6// const k = makeKit($.ui.resolve(e) as unknown as Elements, e.surface)
7// return k.stack([k.hero({ kicker: 'right now', title: 'Reading client.test.js' }), k.tiles([...])], 1)
8//
9// What v2 changed on purpose (v1 painted grey fills and dimmed grey text, unreadable on the desktop):
10// - colors are theme keys (suggestion, success, warning, error, claude, inactive) that the host
11// tunes for light and dark; PALETTE below swaps them for named colors in one line;
12// - no dimColor, no backgroundColor, no yellow, white, gray, black or subtle on content: secondary
13// text is `inactive`, state is a bold `● word` chip (glyph and word, so color is never the only cue);
14// - hero and card are one round frame (paddingX 1), tiles are single-border boxes with the label
15// over a bold value; terminal and desktop draw the same structure (the Svg doodle is desktop-only);
16// - kicker, section and tile labels keep the caller's case (v1 upper-cased kicker and section);
17// - additive: line(label, chips, right?) draws one band line.
18//
19// Calibration (claude plugin test, Claude Code 2.1.294): the test harness validates the tree a
20// ui.render hook returns (a Text with a bogus prop makes `$.ui.mount` reject on both surfaces), but
21// not how the desktop paints a theme key. A Button given `plain: false` throws when built (plain and
22// autoFocus take only `true`), so the kit leaves false and undefined props out; a Box inside a Text
23// is refused. doodle() draws its Svg on the desktop only (a pane holding only an Svg opens empty in
24// the terminal).
25//
26// What v3 adds (strictly additive: every v2 export above renders as before). The desktop paints
27// Svg, and its light theme made the v1 fills unreadable, so v3 draws small pictures as Svg wherever
28// the element table has one (desktop, mobile, vscode) and as text on the terminal:
29// - pill(text, tone?): a fully rounded Svg chip (PILL 'svg', the default); PILL 'fill' draws a
30// theme-key backgroundColor under `inverseText` instead (the only fill in the kit); the v2
31// `● text` chip on the terminal;
32// - header(label, right?): an upper-cased `inactive` section label with a right-hand count;
33// - icon(name, tone?), typeBadge(fileName) and progress(ratio, opts?): Svg icons, file-type badges
34// and a rounded bar; a glyph, a bold label and the v2 bar on the terminal;
35// - buttons() items take an optional `variant` ('primary' | 'secondary');
36// - nav(items, currentKey): a pane's nav bar, the current item bold Text, the others plain Buttons;
37// - pure helpers: pillSvg, shortPath and extOf; TONE_HEX, the Svg colors of icons and bars, and
38// CHIP_HEX, the darker pill fills that keep white text at 4.5:1 or more.
39// Svg markup cannot read theme keys, so it uses fixed mid-tone hexes that read on light and dark
40// grounds (never a color word), and each distinct drawing is built once and cached.
41//
42// What v3.1 adds (spacing; additive): on the desktop, blocks stacked with no gap touch border to
43// border and wrapped rows sit line on line. So every wrapping row (buttons, tiles, nav) gets rowGap 1,
44// buttons get columnGap 2, and two new columns space a pane out:
45// - page(children): a pane's top-level column, one row between blocks;
46// - list(children): a column of repeated cards or blocks, one row between them.
47// Rows inside one card or block stay tight (stack with no gap); stack() keeps its props. Bands that
48// draw buttons() are one row, so the new gaps change nothing there. buttons() items also take
49// `autoFocus` (passed only when true).
50
51export const KIT_VERSION = '3.1.0'
52
53/** How pill() draws on a surface with Svg: 'svg' (a fully rounded chip) or 'fill' (a theme-key
54 * background). makeKit's `pill` option overrides it for one kit. */
55export const PILL: 'fill' | 'svg' = 'svg'
56
57/** kit v3: makeKit's options. `pill` overrides PILL for this kit. */
58export type KitOptions = { pill?: 'fill' | 'svg' }
59
60/** 'theme': theme keys that follow the person's light or dark theme. 'named': named colors, for a
61 * host that does not paint theme keys (never yellow: warn is red plus its glyph). One-line switch. */
62export const PALETTE: 'theme' | 'named' = 'theme'
63
64export type Surface = 'terminal' | 'desktop' | (string & {})
65export type Tone = 'info' | 'ok' | 'warn' | 'danger' | 'muted' | 'accent'
66export type Role = 'main' | 'recon' | 'execute' | 'verify' | 'hard' | 'reviewer' | 'other'
67export type DoodleState = 'running' | 'done' | 'failed' | 'waiting' | 'idle'
68/** The tones of pill() and icon(): the six tones plus violet. */
69export type PillTone = Tone | 'violet'
70export type IconName =
71 | 'eye'
72 | 'pencil'
73 | 'plus'
74 | 'branch'
75 | 'search'
76 | 'folder'
77 | 'folder-open'
78 | 'file'
79 | 'shield'
80 | 'flag'
81 | 'check'
82 | 'dot'
83 | 'clock'
84/** What `$.ui.resolve(e)` returns, loosely typed so the kit compiles without the engine's types. */
85export type Elements = Record<string, (props: Record<string, unknown>) => unknown>
86
87export type Kit = {
88 hero(p: { kicker?: string; title: string; sub?: string; icon?: string; tone?: Tone }): unknown
89 card(p: { title?: string; right?: string; tone?: Tone; children: unknown[] }): unknown
90 section(label: string, right?: string): unknown
91 tiles(items: { label: string; value: string; tone?: Tone }[]): unknown
92 badge(text: string, tone?: Tone): unknown
93 legend(items: { tone: Tone; label: string }[]): unknown
94 bar(ratio: number, opts?: { width?: number; tone?: Tone }): unknown
95 /** One row, left side truncating, right side muted. `align: 'center'` (kit v3) centers a row holding a pill. */
96 row(p: { left: unknown[] | string; right?: unknown[] | string; key?: string; align?: 'center' }): unknown
97 kv(label: string, value: string, tone?: Tone): unknown
98 buttons(
99 items: {
100 key: string
101 label: string
102 hotkey?: string
103 onPress: (e?: unknown) => unknown
104 plain?: boolean
105 /** kit v3: the Button's variant, passed only when set. */
106 variant?: 'primary' | 'secondary'
107 /** kit 3.1: focus this Button when the pane opens; passed only when true. */
108 autoFocus?: boolean
109 }[],
110 ): unknown
111 doodle(role: Role, state?: DoodleState): unknown
112 stack(children: unknown[], gap?: number): unknown
113 /** kit 3.1: a pane's top-level column, one row between its blocks; null children are dropped. */
114 page(children: unknown[]): unknown
115 /** kit 3.1: a column of repeated cards or blocks, one row between them; null children are dropped. */
116 list(children: unknown[]): unknown
117 text(s: string, props?: Record<string, unknown>): unknown
118 /**
119 * One band line: `label ● a ● b right · text`. A chip is `{ text, tone? }`, a string (a muted
120 * chip) or an element such as badge(); at most four are drawn. `right` is a string or a list
121 * joined with ` · `, drawn `inactive` and truncated. One row high.
122 */
123 line(label: string, chips: unknown[], right?: string | string[]): unknown
124 /** kit v3: a rounded chip (Svg surfaces) or the v2 `● text` chip (terminal). */
125 pill(text: string, tone?: Tone | 'violet'): unknown
126 /** kit v3: one row, the label upper-cased, bold and `inactive`; `right` upper-cased, right-aligned. */
127 header(label: string, right?: string): unknown
128 /** kit v3: a 16×16 stroke icon (Svg surfaces) or one glyph in the tone (terminal). */
129 icon(name: IconName, tone?: Tone | 'violet'): unknown
130 /** kit v3: a file-type badge ('JS', 'PDF', …) for a file name; null when the name has no extension. */
131 typeBadge(fileName: string): unknown
132 /** kit v3: a rounded bar (Svg surfaces; `width` in CSS pixels, absent: the slot's width) or bar(). */
133 progress(ratio: number, opts?: { width?: number; tone?: Tone }): unknown
134 /**
135 * kit v3: a pane's nav bar, one row keyed 'nav': the item whose key is `currentKey` as bold Text in the accent
136 * tone (not pressable), the others plain Buttons with their hotkeys (`1: Stage`); one without onPress quiet Text.
137 */
138 nav(items: { key: string; label: string; hotkey?: string; onPress?: () => unknown }[], currentKey: string): unknown
139 surface: Surface
140}
141
142/** The quiet color: secondary text, labels, units, timestamps, frame and tile borders. */
143const QUIET = 'inactive'
144
145const THEME_TONES: Record<Tone, string> = {
146 info: 'suggestion',
147 ok: 'success',
148 warn: 'warning',
149 danger: 'error',
150 muted: QUIET,
151 accent: 'claude',
152}
153
154const NAMED_TONES: Record<Tone, string> = {
155 info: 'blue',
156 ok: 'green',
157 warn: 'red',
158 danger: 'red',
159 muted: QUIET,
160 accent: 'magenta',
161}
162
163const TONE_COLORS = PALETTE === 'named' ? NAMED_TONES : THEME_TONES
164
165/** fg: the color of the tone, a theme key (a named color when PALETTE is 'named'). bg equals fg: v2
166 * draws no fills; the field stays so v1 callers compile. */
167export const TONES: Record<Tone, { fg: string; bg: string }> = {
168 info: { fg: TONE_COLORS.info, bg: TONE_COLORS.info },
169 ok: { fg: TONE_COLORS.ok, bg: TONE_COLORS.ok },
170 warn: { fg: TONE_COLORS.warn, bg: TONE_COLORS.warn },
171 danger: { fg: TONE_COLORS.danger, bg: TONE_COLORS.danger },
172 muted: { fg: TONE_COLORS.muted, bg: TONE_COLORS.muted },
173 accent: { fg: TONE_COLORS.accent, bg: TONE_COLORS.accent },
174}
175
176/**
177 * kit v3: the Svg colors of icons and bars, a mid-tone hex per tone that reads on light and dark grounds
178 * (Svg markup cannot read theme keys). warn and accent share the Claude orange: the theme's warning
179 * color is unreadable as a fill. A pill fills with CHIP_HEX instead.
180 *
181 * info #3B82F6 ok #22A35A warn #D97757 danger #E5484D accent #D97757 muted #8A8F98
182 * violet #8B5CF6
183 */
184export const TONE_HEX: Record<PillTone, string> = {
185 info: '#3B82F6',
186 ok: '#22A35A',
187 warn: '#D97757',
188 danger: '#E5484D',
189 accent: '#D97757',
190 muted: '#8A8F98',
191 violet: '#8B5CF6',
192}
193
194/**
195 * kit v3: a pill's fill per tone, darker than TONE_HEX so its white text has a contrast ratio of at
196 * least 4.5:1 (WCAG AA for small text). The ratios against white:
197 *
198 * info #2563EB 5.17 ok #15803D 5.02 warn #C2410C 5.18 danger #B91C1C 6.47 accent #C2410C 5.18
199 * muted #5B6170 6.20 violet #7C3AED 5.70
200 */
201export const CHIP_HEX: Record<PillTone, string> = {
202 info: '#2563EB',
203 ok: '#15803D',
204 warn: '#C2410C',
205 danger: '#B91C1C',
206 accent: '#C2410C',
207 muted: '#5B6170',
208 violet: '#7C3AED',
209}
210
211/** kit v3: the theme key a pill fills with, per tone (warn is 'claude': a 'warning' fill is unreadable). */
212const PILL_FILLS: Record<PillTone, string> = {
213 info: 'suggestion',
214 ok: 'success',
215 warn: 'claude',
216 danger: 'error',
217 accent: 'claude',
218 muted: QUIET,
219 violet: 'permission',
220}
221
222/** kit v3: the stroke of an icon drawn without a tone. */
223const ICON_STROKE = '#6B7280'
224/** kit v3: the track of progress(): the muted hex at quarter opacity. */
225const TRACK_HEX = '#8A8F98'
226/** kit v3: the text on a light fill (the amber JS badge); light text everywhere else. */
227const INK_DARK = '#1A1A1A'
228const INK_LIGHT = '#FFFFFF'
229
230/** kit v3: icon markup on a 16×16 grid, stroked with `currentColor`'s stand-in `S`. */
231const ICONS: Record<IconName, string> = {
232 eye: '<path d="M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z"/><circle cx="8" cy="8" r="2"/>',
233 pencil: '<path d="M11 2.5 13.5 5 5.5 13H3v-2.5z"/><path d="M9.5 4 12 6.5"/>',
234 plus: '<path d="M8 3v10M3 8h10"/>',
235 branch:
236 '<circle cx="4.5" cy="3.5" r="1.5"/><circle cx="4.5" cy="12.5" r="1.5"/><circle cx="11.5" cy="5" r="1.5"/>' +
237 '<path d="M4.5 5v6M11.5 6.5c0 2.5-2 3.5-4 3.8-1.6.2-3 .7-3 .7"/>',
238 search: '<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5 14 14"/>',
239 folder: '<path d="M1.75 3.25h4.5l1.5 1.75h6.5v7.75H1.75z"/>',
240 'folder-open': '<path d="M1.75 12.75v-9.5h4.5l1.5 1.75h5v2"/><path d="M1.75 12.75 3.75 7h10.5l-2 5.75z"/>',
241 file: '<path d="M9 1.75H3v12.5h10v-8.5z"/><path d="M9 1.75v4h4"/>',
242 shield: '<path d="M8 1.75 13 3.75v4c0 3.2-2.2 5.4-5 6.5-2.8-1.1-5-3.3-5-6.5v-4z"/>',
243 flag: '<path d="M3 14.5V2"/><path d="M3 2.5h8.5L9.75 5.5l1.75 3H3"/>',
244 check: '<path d="M3 8.5 6 11.5 13 4.5"/>',
245 dot: '<circle cx="8" cy="8" r="3" fill="S"/>',
246 clock: '<circle cx="8" cy="8" r="6"/><path d="M8 4.5V8l2.5 1.5"/>',
247}
248
249/** kit v3: the terminal glyph of each icon. */
250const ICON_GLYPHS: Record<IconName, string> = {
251 eye: '◉',
252 pencil: '✎',
253 plus: '+',
254 branch: '⎇',
255 search: '⌕',
256 folder: '▸',
257 'folder-open': '▾',
258 file: '·',
259 shield: '⛨',
260 flag: '⚑',
261 check: '✓',
262 dot: '●',
263 clock: '◷',
264}
265
266/** kit v3: file-type badges by extension: the label, its Svg fill and its terminal tone. */
267const FILE_TYPES: { exts: string[]; label: string; fill: string; tone: Tone }[] = [
268 { exts: ['js', 'jsx', 'mjs', 'cjs'], label: 'JS', fill: '#F0C419', tone: 'accent' },
269 { exts: ['ts', 'tsx'], label: 'TS', fill: '#3178C6', tone: 'info' },
270 { exts: ['pdf'], label: 'PDF', fill: '#E5484D', tone: 'danger' },
271 { exts: ['md', 'mdx'], label: 'MD', fill: '#4A7BD0', tone: 'info' },
272 { exts: ['doc', 'docx'], label: 'DOC', fill: '#2B579A', tone: 'info' },
273 { exts: ['csv'], label: 'CSV', fill: '#22A35A', tone: 'ok' },
274 { exts: ['json'], label: 'JSON', fill: '#64748B', tone: 'muted' },
275 { exts: ['yml', 'yaml'], label: 'YML', fill: '#64748B', tone: 'muted' },
276 { exts: ['html'], label: 'HTML', fill: '#E8743B', tone: 'accent' },
277 { exts: ['css'], label: 'CSS', fill: '#4A7BD0', tone: 'info' },
278 { exts: ['py'], label: 'PY', fill: '#3776AB', tone: 'info' },
279 { exts: ['sh'], label: 'SH', fill: '#64748B', tone: 'muted' },
280 { exts: ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'], label: 'IMG', fill: '#8B5CF6', tone: 'accent' },
281 { exts: ['txt'], label: 'TXT', fill: '#64748B', tone: 'muted' },
282]
283const OTHER_FILL = '#64748B'
284
285const GLYPHS: Record<Role, string> = {
286 main: '◉',
287 recon: '◈',
288 execute: '▣',
289 verify: '✓',
290 hard: '◆',
291 reviewer: '◎',
292 other: '●',
293}
294
295const STATE_TONES: Record<DoodleState, Tone> = {
296 running: 'info',
297 done: 'ok',
298 failed: 'danger',
299 waiting: 'warn',
300 idle: 'muted',
301}
302
303const STATE_COLORS: Record<DoodleState, string> = {
304 running: TONES[STATE_TONES.running].fg,
305 done: TONES[STATE_TONES.done].fg,
306 failed: TONES[STATE_TONES.failed].fg,
307 waiting: TONES[STATE_TONES.waiting].fg,
308 idle: TONES[STATE_TONES.idle].fg,
309}
310
311// Desktop doodles (svg fills are not text colors, so they stay hex): 12×12 cells, 3 px each. B = body (by role), A = accent (by state), . = empty.
312const BODY_FILL: Record<Role, string> = {
313 main: '#6c63ff',
314 recon: '#1f9eaa',
315 execute: '#3b7ddd',
316 verify: '#2f9e55',
317 hard: '#b5523b',
318 reviewer: '#c08a1e',
319 other: '#7d8590',
320}
321
322const ACCENT_FILL: Record<DoodleState, string> = {
323 running: '#22d3ee',
324 done: '#4ade80',
325 failed: '#f87171',
326 waiting: '#facc15',
327 idle: '#e5e7eb',
328}
329
330const SPRITES: Record<Role, string[]> = {
331 // a crowned blob
332 main: [
333 '............',
334 '..A..AA..A..',
335 '..AAAAAAAA..',
336 '..BBBBBBBB..',
337 '.BBBBBBBBBB.',
338 '.BBAABBAABB.',
339 '.BBAABBAABB.',
340 '.BBBBBBBBBB.',
341 '.BBBAAAABBB.',
342 '.BBBBBBBBBB.',
343 '..BB....BB..',
344 '..BB....BB..',
345 ],
346 // a one-eyed scout drone
347 recon: [
348 '....BBBB....',
349 '..BBBBBBBB..',
350 '.BBBBBBBBBB.',
351 'BBBBAAAABBBB',
352 'BBBAAAAAABBB',
353 'BBBAA..AABBB',
354 'BBBAAAAAABBB',
355 'BBBBAAAABBBB',
356 '.BBBBBBBBBB.',
357 '..BBBBBBBB..',
358 '...B.BB.B...',
359 '..B..BB..B..',
360 ],
361 // a boxy robot with an antenna
362 execute: [
363 '.....AA.....',
364 '.....BB.....',
365 '.BBBBBBBBBB.',
366 '.BBBBBBBBBB.',
367 '.BBAABBAABB.',
368 '.BBAABBAABB.',
369 '.BBBBBBBBBB.',
370 '.BBAAAAAABB.',
371 '.BBBBBBBBBB.',
372 '...BBBBBB...',
373 '..BBB..BBB..',
374 '..BB....BB..',
375 ],
376 // a shield with a check mark
377 verify: [
378 '.BBBBBBBBBB.',
379 'BBBBBBBBBBBB',
380 'BBBBBBBBBBBB',
381 'BBBBBBBBBAAB',
382 'BBBBBBBBAABB',
383 'BAABBBBAABBB',
384 'BBAABBAABBBB',
385 '.BBAAAABBBB.',
386 '.BBBAABBBBB.',
387 '..BBBBBBBB..',
388 '...BBBBBB...',
389 '.....BB.....',
390 ],
391 // a gem golem
392 hard: [
393 '.....BB.....',
394 '....BBBB....',
395 '...BBBBBB...',
396 '..BBBBBBBB..',
397 '.BBAABBAABB.',
398 'BBBAABBAABBB',
399 'BBBBBBBBBBBB',
400 '.BBBBAABBBB.',
401 '..BBBBBBBB..',
402 '...BBBBBB...',
403 '....BBBB....',
404 '.....BB.....',
405 ],
406 // a round face with glasses
407 reviewer: [
408 '...BBBBBB...',
409 '..BBBBBBBB..',
410 '.BBBBBBBBBB.',
411 'BBBBBBBBBBBB',
412 'BAAAABBAAAAB',
413 'BA..AAAA..AB',
414 'BAAAABBAAAAB',
415 'BBBBBBBBBBBB',
416 'BBBBAAAABBBB',
417 '.BBBBBBBBBB.',
418 '..BBBBBBBB..',
419 '...BBBBBB...',
420 ],
421 // a ghost
422 other: [
423 '....BBBB....',
424 '..BBBBBBBB..',
425 '.BBBBBBBBBB.',
426 '.BBBBBBBBBB.',
427 'BBBAABBAABBB',
428 'BBBAABBAABBB',
429 'BBBBBBBBBBBB',
430 'BBBBBBBBBBBB',
431 'BBBBBBBBBBBB',
432 'BBBBBBBBBBBB',
433 'BBB.BBBB.BBB',
434 'B....BB....B',
435 ],
436}
437
438const CELL = 3
439const GRID = 12
440
441// ---------------------------------------------------------------- formatters
442
443/** '≈$0.08', '≈$1.2', '≈$12'; '$0' for zero, '<$0.01' below a cent, '—' when unknown. */
444export function money(usd: number | null | undefined): string {
445 if (typeof usd !== 'number' || !Number.isFinite(usd)) return '—'
446 const v = Math.max(0, usd)
447 if (v === 0) return '$0'
448 if (v < 0.005) return '<$0.01'
449 const cents = Math.round(v * 100) / 100
450 if (cents < 1) return '≈$' + cents.toFixed(2)
451 const dimes = Math.round(v * 10) / 10
452 if (dimes < 10) return '≈$' + dimes.toFixed(1)
453 return '≈$' + String(Math.round(v))
454}
455
456/** '0', '950', '1.5k', '37k', '1.2M', '15M'. */
457export function tokensShort(n: number): string {
458 if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) return '0'
459 if (n < 1000) return String(Math.round(n))
460 const k = n / 1000
461 if (k < 10) {
462 const r = Math.round(k * 10) / 10
463 if (r < 10) return trimZero(r.toFixed(1)) + 'k'
464 }
465 if (Math.round(k) < 1000) return String(Math.round(k)) + 'k'
466 const m = n / 1_000_000
467 const r = Math.round(m * 10) / 10
468 if (r < 10) return trimZero(r.toFixed(1)) + 'M'
469 return String(Math.round(m)) + 'M'
470}
471
472/** '0:40', '10:05', '1:02:03'. */
473export function clock(ms: number): string {
474 const total = typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? Math.floor(ms / 1000) : 0
475 const h = Math.floor(total / 3600)
476 const m = Math.floor((total % 3600) / 60)
477 const s = total % 60
478 if (h > 0) return `${h}:${pad2(m)}:${pad2(s)}`
479 return `${m}:${pad2(s)}`
480}
481
482/** '4%', '100%'. Not capped at 100 (a budget can run over); negative and NaN read 0%. */
483export function pct(ratio: number): string {
484 if (typeof ratio !== 'number' || !Number.isFinite(ratio) || ratio <= 0) return '0%'
485 return String(Math.round(ratio * 100)) + '%'
486}
487
488/** '━━━━╌╌╌╌╌╌' for 0.4 at width 10; the ratio is clamped to 0..1. */
489export function barText(ratio: number, width: number = 10): string {
490 const w = typeof width === 'number' && Number.isFinite(width) && width >= 1 ? Math.floor(width) : 10
491 const filled = Math.round(clamp01(ratio) * w)
492 return '━'.repeat(filled) + '╌'.repeat(w - filled)
493}
494
495function clamp01(ratio: number): number {
496 if (typeof ratio !== 'number' || !Number.isFinite(ratio)) return 0
497 return Math.min(1, Math.max(0, ratio))
498}
499
500function pad2(n: number): string {
501 return n < 10 ? '0' + n : String(n)
502}
503
504function trimZero(s: string): string {
505 return s.endsWith('.0') ? s.slice(0, -2) : s
506}
507
508function toneOf(tone: unknown, fallback: Tone): Tone {
509 return typeof tone === 'string' && Object.prototype.hasOwnProperty.call(TONES, tone) ? (tone as Tone) : fallback
510}
511
512function roleOf(role: unknown): Role {
513 return typeof role === 'string' && Object.prototype.hasOwnProperty.call(GLYPHS, role) ? (role as Role) : 'other'
514}
515
516function stateOf(state: unknown): DoodleState {
517 return typeof state === 'string' && Object.prototype.hasOwnProperty.call(STATE_COLORS, state)
518 ? (state as DoodleState)
519 : 'idle'
520}
521
522/** Drops undefined and false props: some props (Button plain, autoFocus) take only `true`. */
523function clean(props: Record<string, unknown>): Record<string, unknown> {
524 const out: Record<string, unknown> = {}
525 for (const k of Object.keys(props)) {
526 const v = props[k]
527 if (v === undefined || v === false) continue
528 out[k] = v
529 }
530 return out
531}
532
533function present(children: unknown[]): unknown[] {
534 return children.filter(c => c !== null && c !== undefined && c !== false && c !== '')
535}
536
537/** The SVG document of one doodle: a 12×12 sprite at 3 px per cell, runs merged per row. */
538export function doodleSvg(role: Role, state: DoodleState = 'idle'): string {
539 const r = roleOf(role)
540 const s = stateOf(state)
541 const fills: Record<string, string> = { B: BODY_FILL[r], A: ACCENT_FILL[s] }
542 const size = GRID * CELL
543 let rects = ''
544 SPRITES[r].forEach((line, y) => {
545 let x = 0
546 while (x < GRID) {
547 const ch = line[x] ?? '.'
548 let end = x + 1
549 while (end < GRID && line[end] === ch) end++
550 const fill = fills[ch]
551 if (fill) {
552 rects += `<rect x="${x * CELL}" y="${y * CELL}" width="${(end - x) * CELL}" height="${CELL}" fill="${fill}"/>`
553 }
554 x = end
555 }
556 })
557 return (
558 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" ` +
559 `shape-rendering="crispEdges">${rects}</svg>`
560 )
561}
562
563/** The terminal glyph of a role, as doodle() draws it and as the desktop doodle's alt text. */
564export function glyph(role: Role): string {
565 return GLYPHS[roleOf(role)]
566}
567
568// ---------------------------------------------------------------- kit v3: pure helpers and Svg
569
570/** 'ts' for 'src/a.test.TS', '' for 'Makefile', '.gitignore' or 'notes.'. Lower case, no dot. */
571export function extOf(name: string): string {
572 const base = String(name ?? '').split(/[\\/]/).pop() ?? ''
573 const dot = base.lastIndexOf('.')
574 return dot > 0 && dot < base.length - 1 ? base.slice(dot + 1).toLowerCase() : ''
575}
576
577/**
578 * A path for display: inside `root` it is relative ('.' for the root itself), inside `home` it is
579 * '~/' and the rest, anywhere else '…/' and its last two segments, so no absolute path from outside
580 * the project is drawn. A relative path is already short and comes back as given. Trailing slashes
581 * are dropped.
582 */
583export function shortPath(path: string, root: string, home: string): string {
584 const p = trimSlash(path)
585 const r = trimSlash(root)
586 const h = trimSlash(home)
587 if (!p.startsWith('/')) return p
588 if (r && p === r) return '.'
589 if (r && r !== '/' && p.startsWith(r + '/')) return p.slice(r.length + 1)
590 if (h && p === h) return '~'
591 if (h && h !== '/' && p.startsWith(h + '/')) return '~/' + p.slice(h.length + 1)
592 return '…/' + p.split('/').filter(Boolean).slice(-2).join('/')
593}
594
595function trimSlash(s: string): string {
596 const t = String(s ?? '')
597 return t.length > 1 ? t.replace(/\/+$/, '') || '/' : t
598}
599
600const SVG_CACHE = new Map<string, string>()
601const SVG_CACHE_MAX = 512
602
603/** Builds each distinct drawing once; the cache is cleared when it grows past its bound. */
604function cached(key: string, build: () => string): string {
605 const hit = SVG_CACHE.get(key)
606 if (hit !== undefined) return hit
607 if (SVG_CACHE.size >= SVG_CACHE_MAX) SVG_CACHE.clear()
608 const svg = build()
609 SVG_CACHE.set(key, svg)
610 return svg
611}
612
613function xml(s: string): string {
614 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
615}
616
617/** Dark text on a light fill (relative luminance above one half), light text otherwise. */
618function inkOn(hex: string): string {
619 const n = parseInt(hex.slice(1), 16)
620 const lin = (c: number) => {
621 const v = c / 255
622 return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)
623 }
624 const l = 0.2126 * lin((n >> 16) & 255) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255)
625 return l > 0.5 ? INK_DARK : INK_LIGHT
626}
627
628function pillToneOf(tone: unknown, fallback: PillTone): PillTone {
629 return typeof tone === 'string' && Object.prototype.hasOwnProperty.call(TONE_HEX, tone) ? (tone as PillTone) : fallback
630}
631
632function iconOf(name: unknown): IconName {
633 return typeof name === 'string' && Object.prototype.hasOwnProperty.call(ICONS, name) ? (name as IconName) : 'dot'
634}
635
636const FONT = 'font-family="system-ui,-apple-system,Segoe UI,sans-serif"'
637
638/** A label in a rounded rect: the shape of a pill (height 18) and of a type badge (height 16). */
639function labelSvg(text: string, fill: string, width: number, height: number, rx: number, size: number, ink = inkOn(fill)): string {
640 return (
641 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">` +
642 `<rect width="${width}" height="${height}" rx="${rx}" fill="${fill}"/>` +
643 `<text x="${width / 2}" y="${height / 2}" text-anchor="middle" dominant-baseline="central" ${FONT} ` +
644 `font-size="${size}" font-weight="700" fill="${ink}">${xml(text)}</text></svg>`
645 )
646}
647
648/** A pill's height in CSS pixels; its corners are fully rounded (rx = height / 2). */
649const PILL_HEIGHT = 18
650
651/** A pill's width in CSS pixels: about 6.5 per character of 11 px bold text plus 7 each side. */
652function pillWidth(text: string): number {
653 return Math.max(PILL_HEIGHT, Math.round(text.length * 6.5 + 14))
654}
655
656/** The Svg of a pill: a fully rounded rect in the tone's CHIP_HEX fill, white text. */
657export function pillSvg(text: string, tone: Tone | 'violet' = 'muted'): string {
658 const t = pillToneOf(tone, 'muted')
659 const s = String(text)
660 return cached(`pill|${t}|${s}`, () => labelSvg(s, CHIP_HEX[t], pillWidth(s), PILL_HEIGHT, PILL_HEIGHT / 2, 11, INK_LIGHT))
661}
662
663function iconSvg(name: IconName, hex: string): string {
664 return cached(`icon|${name}|${hex}`, () =>
665 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="none" ` +
666 `stroke="${hex}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">` +
667 `${ICONS[name].replace(/"S"/g, `"${hex}"`)}</svg>`,
668 )
669}
670
671function fileType(fileName: string): { label: string; fill: string; tone: Tone } | null {
672 const ext = extOf(fileName)
673 if (!ext) return null
674 const known = FILE_TYPES.find(t => t.exts.includes(ext))
675 return known ?? { label: ext.slice(0, 3).toUpperCase(), fill: OTHER_FILL, tone: 'muted' }
676}
677
678/** A type badge is 16 high and about 7 px per letter plus 8 wide. */
679function badgeWidth(label: string): number {
680 return Math.max(16, label.length * 7 + 8)
681}
682
683function badgeSvg(label: string, fill: string): string {
684 return cached(`badge|${label}|${fill}`, () => labelSvg(label, fill, badgeWidth(label), 16, 3, 10))
685}
686
687/** A track and a fill, 6 high. Without a width the markup is 100% wide and stretches to the slot. */
688function progressSvg(ratio: number, hex: string, width: number | undefined): string {
689 const full = width ?? 100
690 const fill = Math.round(clamp01(ratio) * full * 10) / 10
691 return cached(`progress|${fill}|${hex}|${width ?? ''}`, () => {
692 const size = width === undefined ? 'width="100%" height="6" preserveAspectRatio="none"' : `width="${width}" height="6"`
693 return (
694 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${full} 6" ${size}>` +
695 `<rect width="${full}" height="6" rx="3" fill="${TRACK_HEX}" opacity="0.25"/>` +
696 (fill > 0 ? `<rect width="${fill}" height="6" rx="3" fill="${hex}"/>` : '') +
697 `</svg>`
698 )
699 })
700}
701
702// ---------------------------------------------------------------- the kit
703
704const MAX_CHIPS = 4
705
706export function makeKit(ui: Elements, surface: Surface, opts: KitOptions = {}): Kit {
707 const desktop = surface === 'desktop'
708 const pillMode = opts.pill ?? PILL
709
710 const el = (type: string, props: Record<string, unknown>, children?: unknown[]): unknown => {
711 const make = ui[type]
712 const p = clean(props)
713 if (children !== undefined) p.children = present(children)
714 return make(p)
715 }
716 const T = (s: string, props: Record<string, unknown> = {}): unknown => el('Text', props, [s])
717 const color = (tone: Tone | undefined): string | undefined => (tone ? TONES[tone].fg : undefined)
718 const pieces = (part: unknown[] | string, right: boolean): unknown[] =>
719 (typeof part === 'string' ? [part] : part).map(c =>
720 typeof c === 'string' || typeof c === 'number'
721 ? T(String(c), { color: right ? QUIET : undefined, wrap: 'truncate' })
722 : c,
723 )
724 const frame = (children: unknown[], tone: Tone | undefined): unknown =>
725 el('Box', { flexDirection: 'column', borderStyle: 'round', borderColor: color(tone) ?? QUIET, paddingX: 1 }, children)
726 const chip = (text: string, tone: Tone): unknown =>
727 T('● ' + text, { color: TONES[tone].fg, bold: tone !== 'muted', wrap: 'truncate' })
728 // kit v3: Svg wherever the table has it (desktop, mobile, vscode); the terminal draws text.
729 const svgOk = surface !== 'terminal' && typeof ui.Svg === 'function'
730 const svg = (source: string, alt: string, width?: number, height?: number): unknown =>
731 el('Svg', { source, alt: alt || ' ', width, height })
732 /** The theme key of a v3 tone on the terminal: the v2 tone's color, violet 'permission'. */
733 const fg = (tone: PillTone): string => (tone === 'violet' ? PILL_FILLS.violet : TONES[tone].fg)
734
735 const kit: Kit = {
736 surface,
737
738 hero(p) {
739 const tone = p.tone ? toneOf(p.tone, 'info') : undefined
740 const title = (p.icon ? p.icon + ' ' : '') + p.title
741 return frame(
742 [
743 p.kicker ? T(p.kicker, { color: QUIET, wrap: 'truncate' }) : null,
744 T(title, { bold: true, color: color(tone), wrap: 'truncate' }),
745 p.sub ? T(p.sub, { color: QUIET, wrap: 'wrap' }) : null,
746 ],
747 tone,
748 )
749 },
750
751 card(p) {
752 const tone = p.tone ? toneOf(p.tone, 'info') : undefined
753 const head =
754 p.title || p.right
755 ? el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
756 p.title ? T(p.title, { bold: true, wrap: 'truncate' }) : null,
757 p.right ? T(p.right, { color: QUIET, wrap: 'truncate' }) : null,
758 ])
759 : null
760 return frame([head, ...(Array.isArray(p.children) ? p.children : [])], tone)
761 },
762
763 section(label, right) {
764 return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
765 T(String(label), { bold: true, wrap: 'truncate' }),
766 right ? T(right, { color: QUIET, wrap: 'truncate' }) : null,
767 ])
768 },
769
770 tiles(items) {
771 return el(
772 'Box',
773 { flexDirection: 'row', flexWrap: 'wrap', columnGap: 1, rowGap: 1 },
774 items.map(it => {
775 const tone = it.tone ? toneOf(it.tone, 'info') : undefined
776 return el(
777 'Box',
778 { flexDirection: 'column', flexGrow: 1, borderStyle: 'single', borderColor: QUIET, paddingX: 1 },
779 [
780 T(it.label, { color: QUIET, wrap: 'truncate' }),
781 T(it.value, { bold: true, color: color(tone), wrap: 'truncate' }),
782 ],
783 )
784 }),
785 )
786 },
787
788 badge(text, tone) {
789 return chip(text, toneOf(tone, 'muted'))
790 },
791
792 legend(items) {
793 const parts: unknown[] = []
794 items.forEach((it, i) => {
795 if (i > 0) parts.push(' ')
796 parts.push(T('●', { color: TONES[toneOf(it.tone, 'muted')].fg }))
797 parts.push(' ' + it.label)
798 })
799 return el('Text', { wrap: 'wrap' }, parts)
800 },
801
802 bar(ratio, opts = {}) {
803 const t = toneOf(opts.tone, 'info')
804 return el('Text', { wrap: 'truncate' }, [
805 T(barText(ratio, opts.width ?? 10), { color: TONES[t].fg }),
806 T(' ' + pct(ratio), { color: QUIET }),
807 ])
808 },
809
810 row(p) {
811 const left = pieces(p.left, false)
812 const right = p.right === undefined || p.right === '' ? [] : pieces(p.right, true)
813 const alignItems = p.align === 'center' ? 'center' : undefined
814 return el('Box', { key: p.key, flexDirection: 'row', justifyContent: 'space-between', alignItems, columnGap: 1 }, [
815 el('Box', { flexDirection: 'row', flexShrink: 1, alignItems, columnGap: 1 }, left),
816 right.length ? el('Box', { flexDirection: 'row', alignItems, columnGap: 1 }, right) : null,
817 ])
818 },
819
820 kv(label, value, tone) {
821 const t = tone ? toneOf(tone, 'info') : undefined
822 return el('Text', { wrap: 'truncate' }, [
823 T(label + ' ', { color: QUIET }),
824 T(value, { bold: true, color: color(t) }),
825 ])
826 },
827
828 buttons(items) {
829 return el(
830 'Box',
831 { flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, rowGap: 1 },
832 items.map(b =>
833 el('Button', {
834 key: b.key,
835 label: b.label,
836 hotkey: b.hotkey,
837 onPress: b.onPress,
838 plain: b.plain === true ? true : undefined,
839 variant: b.variant === 'primary' || b.variant === 'secondary' ? b.variant : undefined,
840 autoFocus: b.autoFocus === true ? true : undefined,
841 }),
842 ),
843 )
844 },
845
846 doodle(role, state = 'idle') {
847 const r = roleOf(role)
848 const s = stateOf(state)
849 if (desktop && typeof ui.Svg === 'function') {
850 return el('Svg', { alt: GLYPHS[r], width: GRID * CELL, height: GRID * CELL, source: doodleSvg(r, s) })
851 }
852 return T(GLYPHS[r], { color: STATE_COLORS[s], bold: s === 'running' })
853 },
854
855 stack(children, gap = 0) {
856 return el('Box', { flexDirection: 'column', rowGap: gap > 0 ? Math.floor(gap) : undefined }, children)
857 },
858
859 page(children) {
860 return el('Box', { flexDirection: 'column', rowGap: 1 }, Array.isArray(children) ? children : [])
861 },
862
863 list(children) {
864 return el('Box', { flexDirection: 'column', rowGap: 1 }, Array.isArray(children) ? children : [])
865 },
866
867 text(s, props = {}) {
868 return el('Text', props, [String(s)])
869 },
870
871 line(label, chips, right) {
872 const drawn = (Array.isArray(chips) ? chips : [])
873 .filter(c => c !== null && c !== undefined && c !== false && c !== '')
874 .slice(0, MAX_CHIPS)
875 .map(c => {
876 if (typeof c === 'string') return chip(c, 'muted')
877 if (isChipSpec(c)) return chip(c.text, toneOf(c.tone, 'muted'))
878 return c
879 })
880 const quiet = (Array.isArray(right) ? right.filter(Boolean).join(' · ') : right ?? '').trim()
881 return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 2 }, [
882 el('Box', { flexDirection: 'row', flexShrink: 1, columnGap: 2 }, [
883 T(String(label), { bold: true, wrap: 'truncate' }),
884 ...drawn,
885 ]),
886 quiet ? T(quiet, { color: QUIET, wrap: 'truncate' }) : null,
887 ])
888 },
889
890 // ------------------------------------------------------------ kit v3
891
892 pill(text, tone) {
893 const t = pillToneOf(tone, 'muted')
894 const s = String(text)
895 if (!svgOk) return t === 'violet' ? T('● ' + s, { color: fg(t), bold: true, wrap: 'truncate' }) : chip(s, t)
896 if (pillMode === 'svg') return svg(pillSvg(s, t), s, pillWidth(s), PILL_HEIGHT)
897 return T(` ${s} `, { backgroundColor: PILL_FILLS[t], color: 'inverseText', bold: true, wrap: 'truncate' })
898 },
899
900 header(label, right) {
901 return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
902 T(String(label).toUpperCase(), { bold: true, color: QUIET, wrap: 'truncate' }),
903 right ? T(String(right).toUpperCase(), { color: QUIET, wrap: 'truncate' }) : null,
904 ])
905 },
906
907 icon(name, tone) {
908 const n = iconOf(name)
909 const t = tone === undefined ? undefined : pillToneOf(tone, 'muted')
910 if (svgOk) return svg(iconSvg(n, t ? TONE_HEX[t] : ICON_STROKE), n, 16, 16)
911 return T(ICON_GLYPHS[n], { color: t ? fg(t) : QUIET })
912 },
913
914 typeBadge(fileName) {
915 const type = fileType(fileName)
916 if (!type) return null
917 if (svgOk) {
918 return svg(badgeSvg(type.label, type.fill), type.label, badgeWidth(type.label), 16)
919 }
920 return T(type.label, { bold: true, color: TONES[type.tone].fg })
921 },
922
923 progress(ratio, opts = {}) {
924 if (!svgOk) return kit.bar(ratio, opts)
925 const t = toneOf(opts.tone, 'info')
926 const width = typeof opts.width === 'number' && Number.isFinite(opts.width) && opts.width >= 1 ? Math.floor(opts.width) : undefined
927 // Without a width the Svg gets no width prop and markup 100% wide: the user's look confirms the
928 // desktop draws it the full width of its slot.
929 return svg(progressSvg(ratio, TONE_HEX[t], width), pct(ratio), width, 6)
930 },
931
932 nav(items, currentKey) {
933 return el(
934 'Box',
935 { key: 'nav', flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, rowGap: 1 },
936 (Array.isArray(items) ? items : []).map(it => {
937 if (it.key === currentKey) return T(String(it.label), { bold: true, color: TONES.accent.fg, wrap: 'truncate' })
938 if (typeof it.onPress !== 'function') return T(String(it.label), { color: QUIET, wrap: 'truncate' })
939 return el('Button', { key: it.key, label: String(it.label), hotkey: it.hotkey, onPress: it.onPress, plain: true })
940 }),
941 )
942 },
943 }
944 return kit
945}
946
947function isChipSpec(c: unknown): c is { text: string; tone?: Tone } {
948 return (
949 typeof c === 'object' &&
950 c !== null &&
951 !('type' in c) &&
952 typeof (c as { text?: unknown }).text === 'string'
953 )
954}
955hooks/lib/names.ts 74 lines1// Names and constants shared by every concern. Pure: this file never sees $.
2
3export const PLUGIN = 'loopkeeper'
4export const MIN_VERSION = '2.1.294'
5export const DISABLE_ENV = 'CLAUDE_MODS_DISABLE'
6
7/** Slash commands this plugin registers. Never `loop` or `review`: both belong to Claude Code. */
8export const CMD = {
9 lk: 'lk',
10 loopkeeper: 'loopkeeper',
11 reviews: 'reviews',
12 decide: 'decide',
13 decisions: 'decisions',
14 files: 'files',
15} as const
16
17/** Tools, by the name registered and by the name Claude sees. */
18export const TOOL = {
19 setStage: 'set_stage',
20 reviewCycle: 'review_cycle',
21 reviewItem: 'review_item',
22 reviewStatus: 'review_status',
23 recordDecision: 'record_decision',
24} as const
25export const toolFull = (name: string): string => `mcp__${PLUGIN}__${name}`
26export const TOOL_FULL = {
27 setStage: toolFull(TOOL.setStage),
28 reviewCycle: toolFull(TOOL.reviewCycle),
29 reviewItem: toolFull(TOOL.reviewItem),
30 reviewStatus: toolFull(TOOL.reviewStatus),
31 recordDecision: toolFull(TOOL.recordDecision),
32} as const
33
34/** The six concerns, in wiring order. This is also the order of the band lines. */
35export const CONCERNS = ['stage', 'gates', 'files', 'routing', 'review', 'decisions'] as const
36export type Concern = (typeof CONCERNS)[number]
37
38/** Store key for a session override: loopkeeper:<concern>:<key>. */
39export const storeKey = (concern: string, key: string): string => `${PLUGIN}:${concern}:${key}`
40
41/** Config files: the user file under the home folder, the project file under the project folder. */
42export const CONFIG_FILE = `.claude/${PLUGIN}.json`
43export const userConfigPath = (home: string): string => `${home.replace(/\/+$/, '')}/${CONFIG_FILE}`
44export const projectConfigPath = (cwd: string): string => `${cwd.replace(/\/+$/, '')}/${CONFIG_FILE}`
45
46/** True when `version` (such as 2.1.294 or 2.1.294-dev) is at least `min`. Unparseable counts as too old. */
47export function versionAtLeast(version: string, min: string = MIN_VERSION): boolean {
48 const parse = (v: string): number[] | null => {
49 const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(v).trim())
50 return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
51 }
52 const a = parse(version)
53 const b = parse(min)
54 if (!a || !b) return false
55 for (let i = 0; i < 3; i++) {
56 if (a[i] !== b[i]) return a[i] > b[i]
57 }
58 return true
59}
60
61// ---- Words of a command line and the text a chain of handlers builds -------------------------
62
63/** The first word of a command's arguments, lower case, and the words after it. */
64export function subOf(args: unknown): { sub: string; rest: string[]; text: string } {
65 const text = String(args ?? '').trim()
66 const words = text.split(/\s+/).filter(Boolean)
67 return { sub: (words[0] ?? '').toLowerCase(), rest: words.slice(1), text }
68}
69
70/** Lines joined by a newline, leaving out empty ones. */
71export function joinText(...parts: Array<string | undefined | null>): string {
72 return parts.filter((p): p is string => typeof p === 'string' && p.trim() !== '').join('\n')
73}
74hooks/lib/panes.ts 22 lines1// panes.ts: the loopkeeper panes the hub and every pane's nav row switch between, in wiring order.
2//
3// Pure data, so every concern file imports the one table (`$` never crosses an import; a table does).
4// Digit hotkeys never clash with the panes' own letter hotkeys.
5
6export const HUB_ID = 'loopkeeper-hub'
7
8/** Rows the hub opens with, so the inline hub is not a third of the screen. */
9export const HUB_ROWS = 22
10
11export const PANES = [
12 { key: 'hub', hotkey: '0', label: 'Hub', id: HUB_ID, title: 'loopkeeper' },
13 { key: 'stage', hotkey: '1', label: 'Stage', id: 'loopkeeper-stage', title: 'Stage' },
14 { key: 'gates', hotkey: '2', label: 'Gates', id: 'loopkeeper-gates', title: 'Gates' },
15 { key: 'files', hotkey: '3', label: 'Files', id: 'files', title: 'Files' },
16 { key: 'routes', hotkey: '4', label: 'Routes', id: 'loopkeeper-routes', title: 'Routing' },
17 { key: 'reviews', hotkey: '5', label: 'Reviews', id: 'loopkeeper-review', title: 'Review loops' },
18 { key: 'decisions', hotkey: '6', label: 'Decisions', id: 'loopkeeper-decisions', title: 'Decisions' },
19] as const
20
21export type PaneKey = (typeof PANES)[number]['key']
22