SLOPSHOPPER

loopkeeper

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.

newpanebandguardcommandtoast
★ 1v0.1.1MITupdated 2026-10-14connectwithub/loopkeeper/plugins/loopkeeper
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · loopkeeper
│ ┃ loopkeeper ✕ › fix the failing auth test and add╭───────────────────────────────╮ │ ┃ ╭──────────────────────────────────────────╮ │ loopkeeper │ │ ┃ │ LOOPKEEPER │ ● loopkeeper: loopkeeper · Ship (4 │ ctrl+x tab to focus the panel │ │ ┃ │ ⚑ Ship · 4 of 4 │ ⏺ Read(src/auth.ts) ╰───────────────────────────────╯ │ ┃ │ preset default · approval auto · GSD off │ ⎿ Read 6 lines │ ┃ │ · needs Claude Code 2.1.294+, found │ ⏺ Update(src/auth.ts) │ ┃ │ 2.1.289 │ ⎿ Added 2 lines, removed 1 line │ ┃ ╰──────────────────────────────────────────╯ ⏺ Bash(bun test) │ ┃ Last stage: ask Claude to commit, push or ⎿ 3 pass, 1 fail │ ┃ open a PR; /lk reset starts the next task. │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ PANELS │ ┃ 1: Stage ✓ Plan ─ ✓ Build ─ ✓ QA ─ ● Ship · ✻ Worked for 42s · done 4:20 PM │ ┃ 2: Gates on · 0/10 files · merge gate · ship │ ┃ 3: Files 3 touched · last: created cache.ts › /lk │ ┃ 4: Routes GSD off · routed 0 │ ┃ 5: Reviews 0 active of 0 subjects │ ┃ 6: Decisions 0 this session · watch off │ ┃ ⟨Claude Code's own drawing⟩ │ ┃ │ ┃ o: GSD on c: Close │ stage ✓ Plan ─ ✓ Build ─ ✓ QA ─ ● Ship · in Ship <1m · needs Claude Code 2.1.294+, found 2.1.289 gates ● 0/10 files ● merge gate ● ship open files ● 3 touched routing ● off ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
stage ✓ Plan ─ ✓ Build ─ ✓ QA ─ ● Ship · in Ship <1m · needs Claude Code 2.1.294+, found 2.1.289 gates ● 0/10 files ● merge gate ● ship open files ● 3 touched routing ● off ⟨Claude Code's own drawing⟩
Pane · loopkeeper
╭──────────────────────────────────────────────────────────╮ │ LOOPKEEPER │ │ ⚑ Ship · 4 of 4 │ │ preset default · approval auto · GSD off · needs Claude │ │ Code 2.1.294+, found 2.1.289 │ ╰──────────────────────────────────────────────────────────╯ Last stage: ask Claude to commit, push or open a PR; /lk reset starts the next task. PANELS 1: Stage ✓ Plan ─ ✓ Build ─ ✓ QA ─ ● Ship · in Ship <1m 2: Gates on · 0/10 files · merge gate · ship open 3: Files 3 touched · last: created cache.ts 4: Routes GSD off · routed 0 5: Reviews 0 active of 0 subjects 6: Decisions 0 this session · watch off ⟨Claude Code's own drawing⟩ o: GSD on c: Close
Pane · Files
0: Hub 1: Stage 2: Gates Files 4: Routes 5: Reviews 6: ╭──────────────────────────────────────────────────────────╮ │ ◷ RIGHT NOW │ │ Claude is idle │ │ last: created cache.ts │ │ ──────────────────────────────────────────────────── │ │ ⎇ feat/auth-refresh 2 changed │ │ ● Claude read ● Claude edited ● Not committed │ ╰──────────────────────────────────────────────────────────╯ ⌕ Find a file: part of a name or path ⏎ find [ Refresh ] [ Collapse all ] [ Show sizes ] [ Up a folder ╭──────────────────────────────────────────────────────────╮ │ CLAUDE TOUCHED 3 FILES │ │ TS cache.ts src ● Created │ │ TS audit.ts src ● Created │ │ TS auth.ts src ● Edited │ │ ⌕ refresh\( · src ● Searched │ ╰──────────────────────────────────────────────────────────╯ ALL FILES APP JSON package.json 09 Oct 2025 MD README.md 09 Oct 2025 · src
Pane · Review loops
0: Hub 1: Stage 2: Gates 3: Files 4: Routes Reviews 6: ╭──────────────────────────────────────────────────────────╮ │ review loop │ │ No reviews yet │ │ Claude records one with review_cycle after each pass. │ ╰──────────────────────────────────────────────────────────╯ defaults: target by kind · max by kind c: Close pane
Pane · Decisions
0: Hub 1: Stage 2: Gates 3: Files 4: Routes 5: Reviews ╭──────────────────────────────────────────────────────────╮ │ decisions │ │ 0 decisions today │ │ Nothing recorded yet │ ╰──────────────────────────────────────────────────────────╯ ┌──────────────────────────────────────────────────────────┐ │ Total │ │ 0 │ └──────────────────────────────────────────────────────────┘ .claude/decisions.jsonl · jsonl · created on first record r: Refresh c: Close w: Turn watch on Recent decisions 0 No decisions yet. Record one with /decide <decision> -- <why> #tag Learned preferences 0 None yet. The decision watch learns them from your turns.
Pane · loopkeeper-stage
0: Hub Stage 2: Gates 3: Files 4: Routes 5: Reviews 6: ╭──────────────────────────────────────────────────────────╮ │ stage │ │ Ship │ │ preset default · auto on · from Bash · needs Claude Code │ │ 2.1.294+, found 2.1.289 │ ╰──────────────────────────────────────────────────────────╯ Last stage: ask Claude to commit, push or open a PR; /lk reset starts the next task. ✓ Plan ─ ✓ Build ─ ✓ QA ─ ● Ship ━━━━━━━━━━━━━━━╌╌╌╌╌ 75% in Ship for <1m ┌──────────────────────────────────────────────────────────┐ │ Stage │ │ 4 of 4 │ └──────────────────────────────────────────────────────────┘ Gates No gates History · 4 08:53 ● moved Ship · auto · from Bash 08:53 ● moved QA · auto · from Bash 08:53 ● moved Build · auto · from Edit 08:53 ● moved Plan · auto · session start b: Back n: Next a: Approve gate r: Reset c: Close
Pane · loopkeeper-gates
0: Hub 1: Stage Gates 3: Files 4: Routes 5: Reviews 6: ╭──────────────────────────────────────────────────────────╮ │ ⛨ GATES │ │ On · 0/10 files this turn │ │ the base limit, maxFilesPerTurn │ ╰──────────────────────────────────────────────────────────╯ ┌──────────────────────────────────────────────────────────┐ │ Files per turn │ │ 10 │ └──────────────────────────────────────────────────────────┘ ╭──────────────────────────────────────────────────────────╮ │ RULES SOURCE │ │ maxFilesPerTurn: 10 default │ │ planSlack: 10 default │ │ autoBudgetMax: 40 default │ │ planPaths: default │ │ .claude/plans/** │ │ docs/plans/** │ │ **/*.plan.md │ │ **/*.plan.html │ │ + scratchpad │ │ requirePlan: off default │ │ mergeGate: on default │ │ requireGreenCi: on default │ │ blockAmend: on default │ │ blockAddAll: off default │ │ countSubagents: off default │ │ shipGate: on default │ │ Config files: none (defaults) │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ PROTECTED PATHS 3 │ │ docs/decisions/** ● ask default │ │ .env* ● deny default │ │ **/migrations/** ● ask default │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ THIS TURN 0 FILES │ │ no files yet │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ DECISIONS │ │ none yet │ ╰──────────────────────────────────────────────────────────╯ r: Reset turn c: Close
Pane · loopkeeper-routes
0: Hub 1: Stage 2: Gates 3: Files Routes 5: Reviews 6: ╭──────────────────────────────────────────────────────────╮ │ GSD │ │ ○ Orchestrator off │ │ Plan approval: auto (/lk approval auto|ask) · band on │ ╰──────────────────────────────────────────────────────────╯ ┌──────────────────────────────────────────────────────────┐ │ Routes │ │ 5 │ └──────────────────────────────────────────────────────────┘ Routes override explicit: no ● recon → haiku default ● execute → sonnet default ● verify → sonnet default ● hard → opus default ● default → sonnet default Recent spawns · 0 routed 0 · none yet none yet Loop ● Loop on size → plan → validate plan → execute → verify → Q mode on: stage preset gsd · gates plan required on mode off: stage preset default · gates plan required off Toggle mode and Toggle loop put /lk on|off or /lk loop on|o… t: Toggle mode l: Toggle loop c: Close
README

loopkeeper

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

  • A large change should follow a plan you approved, with Claude held at the gate instead of asked to wait.
  • Parts of the repo are off limits or need your OK first, such as docs/decisions/, migrations or .env files.
  • You want commits, pushes and merges held until you approve them, and merges to the default branch held until CI is green.

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.

Try it

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).

BeforeAfterNotes
/pipeline/lk stageOpens 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 backSub-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 resetResets 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 routesOpens 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 textPlain-text status.
/change-control/lk gatesOpens 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/reviewsSame words: text, status, clear, kinds, kind, target, max, ship, reopen, stop, band, context.
/decide, /decisions, /filesunchanged
tools set_stage, review_cycle, review_item, review_status, record_decisionmcp__loopkeeper__<name>Same names and inputs.

Commands

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

FormWhat it does
/lkOpens the loopkeeper hub: the stage, one row per panel with its live status, the other mods and the actions.
/lk stageOpens the Stage pane.
/lk helpLists the keys (below).
/lk status, /lk textStatus: stage, routing and gates. text is plain text. /lk status also says Continue after approval: on or off.
/lk <stage>, /lk next, /lk backMoves 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 approveApproves 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 waiveWaives 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 resetClears 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 reloadRe-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 routesOpens 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 resetDrops every /lk route override.
/lk explain <text>Says which role and model a subagent with this description would get.
/lk gates …The gates, below.

Keys

/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).

WhereKeyWhat it does
Stage, Gates, Files, Routes, Reviews and Decisions panes0 to 6The 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.
Hub1 to 6Opens that panel, as above.
Hubm, u, h, t, w, p, sThe 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.
HubaApproves 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.
HuboPuts /lk on or /lk off in the prompt box.
HubcCloses the hub.
Prompt boxctrl+x tabFocuses 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 gates

From 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.
  • The config is read from the project root, and the plan and protected-path checks resolve paths against it, so a shell 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.

Tools

Claude calls these tools; they are named mcp__loopkeeper__<name>.

ToolInputsWhat it does
set_stagestage (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_cyclesubject (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_itemsubject, text (required), done, proof, priorityAdds an open item, or closes one with a proof (a path, output or commit).
review_statussubject (optional)Shows one subject's loop state, or all of them.
record_decisiondecision (required), why, tagsAppends a decision to the project's log.

The loop

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 idLabelGateWhat it needs
planPlanClaude writes Goal, Steps, Done-when and the files it touches. Given as set_stage {stage:'plan', size:'large', files:n}.
validate-planValidate plan⚑ gate, ⇄ verifierA 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.
executeExecuteThe work, through subagents. Routing picks their models.
validateValidate⇄ verifierA subagent matching `verif\valid\review\critic\audit\test\qa` must finish during this visit.
qaQATests and the build run.
shipShip⚑ gateShipping 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.

  • Presets. 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.
  • GSD mode. /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.
  • Sizing. Every task is sized first, with 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.
  • Approval. /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.
  • Continue after approval. When a user approval changes something (a gate or a waiting budget newly approved, or a move into the next stage's gate from the stage before it), loopkeeper submits one prompt so the model carries on: "The user ran /lk approve. <outcome> Carry on with the task from where you stopped." (or "The user pressed <button label>."). The prompt is framed as the plugin's message, not as the user's. From /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).
  • Next step after every answer. After each answered main turn, a notice line in the chat (dim; Claude does not read it) says where the task stands and what moves it on: 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:
  • The band shows an Approve button (the plan gate waits for the user in ask approval or under a hold, or a planned file budget waits): "Waiting for you: /lk approve (or a in /lk)."
  • Validate plan's gate is not approved, approval is auto and nothing holds it: "A plan validator's consistent verdict opens the gate, or /lk approve."
  • Any other stage's gate is not approved (custom presets can gate any stage), except Ship and the last stage: "Waiting for you: /lk approve."
  • A verifier subagent must finish before the move: "A verifier subagent checks the work first."
  • Ship, with the ship gate on and shipping not open (no /lk approve at Ship, no /lk gates ship ok): "/lk approve when you want Claude to commit, push or open a PR."
  • The last stage, whatever its id: "Last stage: ask Claude to commit, push or open a PR; /lk reset starts the next task."
  • Otherwise: "Next: <next stage>. Claude moves on when it starts that work, or /lk next." When the next stage has a gate not yet approved, the sentence ends "or /lk approve opens <next stage> now." instead, except for a Ship gate with the ship gate off or /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.

  • Hold phrase. A prompt the person sends (typed, through the bridge, or from the SDK host) that asks to wait holds the gate even when the verdict is consistent: phrases such as "wait for approval", "ask me first", "check with me", "let me approve the plan", "I want to review the plan", "review the plan first", "plan only" or "just the plan". A mention such as "once I approve the plan" does not hold. A prompt from anywhere else (a plugin, a peer session, a task notification, a channel) never holds. /lk approve clears the hold.
  • Plan material. Files in the scratchpad (/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.

The file budget

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.

  • Base. changeControl.maxFilesPerTurn is 10 (ADR 0011).
  • Plan budget. 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.
  • No plan gate. In a preset whose plan stage has no gate (the default preset), leaving Plan forward through 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).
  • Auto approval limit. Auto approval covers budgets up to 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.
  • Plan mode. Approving a plan through Claude Code's plan mode (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.
  • Limit in force. The limit is the /lk gates max <n> override if one is set. Otherwise it is the largest of the base, the approved budget and the session raise.
  • The limit question. Past the limit, the user is asked with four options: 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.
  • Lifetimes. turn.start resets the turn's count. A subagent's turn does not reset it, and a subagent's edits count only when `changeControl.countSu
Source 24 files
hooks/register.ts 21 lines
1// 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}
21
hooks/decisions.ts 1107 lines
1// 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}
1107
hooks/files.ts 599 lines
1// 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}
599
hooks/gates.ts 1547 lines
1// 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 plan
hooks/review.ts 855 lines
1// 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}
855
hooks/routing.ts 621 lines
1// 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}
621
hooks/stage.ts 2025 lines
1// 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 lines
1// 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}
203
hooks/lib/decisionFormats.ts 407 lines
1// 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}
407
hooks/lib/kit.ts 955 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
955
hooks/lib/names.ts 74 lines
1// 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}
74
hooks/lib/panes.ts 22 lines
1// 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