SLOPSHOPPER

session-panel

Session Panel: hides tool calls and shows a calm step checklist above the prompt, with a Hide/Show details button.

newbandrowsguardcommandtoast
★ 2v0.3.63no licenseupdated 2026-10-15herman925/925-cc-plugins/session-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-panel
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ session-panel │ ⏺ Read(src/auth.ts) │ Session Panel is off: details are showing. │ ⎿ Read 6 lines ╰────────────────────────────────────────────╯ ⏺ Update(src/auth.ts) ╭───────────────╮ ⎿ Added 2 lines, removed 1 line │ session-panel │ ⏺ Bash(bun test) │ Bar view │ ⎿ 3 pass, 1 fail ╰───────────────╯ ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /askchoices ⎿ session-panel: ask_choices is on. ⟨Claude Code's own drawing⟩ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Session Panel · off [ ⚙ Settings ▾ ] │ │ [ Enhance ][ Undo ][ Send ] │ │ ==== ASSUMPTIONS (1 open) ==== │ │ Tool calls are showing. Open Settings to hide them again. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Session Panel · off [ ⚙ Settings ▾ ] │ │ [ Enhance ][ Undo ][ Send ] │ │ ==== ASSUMPTIONS (1 open) ==== │ │ Tool calls are showing. Open Settings to hide them again. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

session-panel

Session Panel hides tool calls, diffs and command output while Claude works, and shows a calm step checklist above the prompt, with a Hide details / Show details button. It also holds a prompt enhancer, a list of the assumptions Claude states, and grouped settings. Made for people who do not read code.

Version 0.3.63. The version is shown dim beside the band title, as v0.3.63. It is read once per load from the plugin's own manifest, and nothing is shown if that read fails.

Check it: claude plugin validate session-panel and claude plugin test session-panel.

The band

The band sits above the prompt, in this order: the title line, the Settings button, the enhancer buttons, the Assumptions list, and the checklist.

  • Title: Session Panel (or Session Panel · off when hidden), then the version.
  • Hide details / Show details and /simple on|off toggle the hiding. The setting is kept between sessions. It starts on.
  • Settings opens the Settings card (below).
  • Stop appears only while a turn is running. It cancels that turn. The toast is Stopped, or Nothing to stop: the turn has ended. if the turn already ended.
  • Subagents: a subagent's or teammate's turn does not start a job, change the checklist or move the Stop button; only the session's own turns do.
  • Checklist: Claude plans the job with mcp__session-panel__plan_steps and reports progress with mcp__session-panel__report_progress. Questions go through AskUserQuestion while the question box is on (the default); with it off and /askchoices on, mcp__session-panel__ask_choices shows numbered choices in the band.
  • NO_COLOR is honoured. NO_MOTION, REDUCE_MOTION or PREFERS_REDUCED_MOTION gives a static meter.

Commands

CommandWhat it does
/simple on, /simple offShows or hides tool details. With no argument, flips it.
/simple bars, /simple listSets the progress look.
/progressFlips between the bar view and the list view.
/progress-clearRemoves the finished bars.
/askchoices on, /askchoices offTurns the ask_choices picker on or off. Off by default. While the question box is on, the picker is refused even when this is on.
/enhanceEnhances the text in the prompt box.
/enhance <draft>Enhances a draft given in the command. From the prompt box, the result goes back in the box, as Enhance does. Elsewhere (for example claude -p), it shows the result and the model that answered (via), with its notes.
/enhance-keyAdds Ctrl+X E for Enhance to your keybindings.json.

Questions

Settings → Questions holds three switches:

  • Question box (on by default): Claude asks with AskUserQuestion, never in prose, for permission to act (edits, deletes, sends, pushes, installs) and for choices that change scope, cost, files or direction. Routine details are not asked; Claude decides them and states them as ASSUMPTION: lines. The rule is in the system-prompt section session-panel:rules; it is an instruction, so Claude can still miss it. The grill skills (grilling, grill-me and grill-with-docs, also when named with their plugin) get a note added to their own text when they load: ask each question with AskUserQuestion, one call per question, the recommended option first. It is added only while the question box is on, and costs no context on other turns. While it is on, the ask_choices picker is refused with The question box is on: ask this with AskUserQuestion instead.
  • Picker above the prompt (off by default): the ask_choices buttons. It works only while the question box is off.
  • Warn on prose questions (on by default): when a reply ends with a question and no AskUserQuestion call was made in that turn, a toast says Claude asked in prose instead of the question box. Turn the warning off in Settings → Questions if it is not wanted. It needs the question box on. It only warns; it cannot make Claude ask again.

The plan gate

Until the job has a plan, other tools are refused for the main agent. Subagents are never gated.

  • The refusal says: Blocked by the Session Panel mod until this session calls mcp__session-panel__plan_steps (see the Session Panel system section).
  • The rule itself is in a system-prompt section, session-panel:plan. It asks for plan_steps before any other tool, and says to skip the plan if the tool is still missing.
  • Escape: after 3 refusals in a row with no plan, tools go through. The toast says Session Panel: no plan_steps call after 3 tries, so tools are open. A plan, or the end of the job, resets the count.
  • If the gate hook throws an error, tools are allowed through. A gate hook that cannot answer in time is refused by Claude Code itself, which now blocks an action whose hook gives no answer.

The enhancer

The enhancer rewrites the prompt box so Claude can act on it, using the project context. It is on by default.

  • Buttons: Enhance, Undo and Send.
  • Enhance rewrites the box. The result is Enhanced. Check the box, then Send.
  • Undo restores the text from before the last enhance.
  • Send submits the box as it stands.
  • Notes: after an enhance, 2 to 4 short notes show under the buttons, saying what was added and which sources were used.
  • Questions: if the draft is vague, the enhancer asks up to 3 questions, one ask call each, then enhances again with the answers.
  • No picker: in a session with no picker (for example claude -p), the questions cannot be asked. The result is Enhancer: it needs answers, and no picker is open in this session.
  • /enhance <draft>: from the prompt box, the enhanced draft goes back in the box, ready to send. Elsewhere, the result is shown. For a draft too long for the band to stay on screen, start it with /enhance.
  • A key for Enhance: Settings → Add Ctrl+X E for Enhance, or /enhance-key. It adds Ctrl+X, then E, to keybindings.json in your Claude config folder; the key enhances the box as it stands, even when the band is off screen. It runs only when you ask. If Ctrl+X E is already bound, or the file is not readable JSON, nothing is changed. By hand, the same key in the Chat context:
  { "bindings": [{ "context": "Chat", "bindings": { "ctrl+x e": "command:enhance" } }] }
  • Auto-enhance on Enter (a setting, off by default): the first Enter enhances and fills the box, and does not send. The second Enter sends the enhanced text.
  • Other messages: The box is empty. Type a draft first. when there is nothing to enhance. The enhancer is off. Open Settings to turn it on. when it is off. Enhancer: something went wrong. The box is unchanged. on an unexpected error.

Model and chat

  • Model: Haiku by default. Settings offer Haiku, Sonnet and Opus.
  • Chat context (a setting):
  • Recent (default): sends the last 6 user and assistant text turns to the model on the chosen model, Haiku unless you pick another. Each turn is capped at 1500 characters, and the whole excerpt at 6000 characters, keeping the newest end.
  • Full chat: asks the session itself, on the session's model, so the model sees the whole conversation. It falls back to the chosen model only if there is nothing to ask yet.

Context sources

Each source has its own toggle in Settings → Enhancer, under a short name. Each is capped at 4000 characters.

SourceName in SettingsDefaultWhat it reads
CLAUDE.md and AGENTS.md (project and global)CLAUDE.md, AGENTS.mdonThe global ~/.claude/CLAUDE.md, and the CLAUDE.md and AGENTS.md files in the folders above the session.
Project skills listSkills listonThe folder names in ~/.claude/skills and .claude/skills. A missing folder counts as none.
CONTEXT.md, PRD, ADRs and glossaryContext docsonCONTEXT.md, PRD.md, GLOSSARY.md, and the file names in docs/adr.
GitHub (wayfinder map, else recent issues)GitHuboffThe open issue labelled wayfinder:map: its body, with the titles of its open child tickets (GitHub sub-issues). If there is no open map, gh issue list --limit 10. gh runs in the session's folder, so that folder must be a GitHub repository. A gh that is missing or not signed in gives nothing.

Each file's digest is cached by its modification time, in the plugin store. A file that cannot be read is skipped.

First-run setup

The first interactive session with no saved setup asks five questions, once:

  1. Turn the enhancer on? (On, Off)
  2. Which model should the enhancer use? (Haiku, Sonnet, Opus)
  3. Which band view by default? (List, Bars)
  4. Which context sources should the enhancer read? (All, Instructions and skills only, None)
  5. How much chat should the enhancer read? (Haiku + recent, Full chat (session model)). In Settings these are called Recent and Full chat.

The answers are saved. Settings → Setup → Run setup again asks the questions again.

Settings

Settings on the band opens a card under the button. The card floats over the band, with a solid background, so opening it or switching tabs does not move the checklist or anything else in the band. While it is open, the band keeps at least enough height for the whole card (14 rows), so the card's bottom border always shows; a band that is already taller does not change.

  • Tabs: Band, Enhancer, Questions, Assumptions, Shelf and Setup. The open tab is a filled label with a gap in the rule under it; the others sit on the rule.
  • Table: each tab is a table with three columns: the control (ON or OFF, the choices, or a button such as [Add] or [Run]), the setting's name, and what it does. Each column is as wide as its widest entry on that tab, so every row's name and description start at the same place, on every page of the tab. A description too long for its column wraps onto the next line, in the same column. On a card too narrow for a 30-character description column, each description goes on the lines under its name instead.
  • Pages: the card shows 6 lines of settings. A tab with more has pages; ◀ Prev, the page number and Next ▶ sit on the card's title line, so they stay in view. Switching tabs goes back to the first page.
  • Band: Hide tool details; Progress look (List, Bars).
  • Enhancer: Enhancer (on or off); Auto-enhance on Enter; Add Ctrl+X E for Enhance ([Add]); Model (Haiku, Sonnet, Opus); Chat context (Recent, Full chat); the four context sources.
  • Questions: Picker above the prompt; Question box; Warn on prose questions. See Questions.
  • Assumptions: Track assumptions; Flag undeclared edits; Decision step (off by default); Keep threshold (0.4 to 0.8, default 0.5); Jev pass (off by default).
  • Shelf: placeholder, coming in a later stage.
  • Setup: Run setup again ([Run]).

Migration from clean-view

On the first session that loads this plugin, the choices saved under clean-view@herman-mods (or clean-view) in the user settings are copied into this plugin's store: the hiding setting, the progress look and the ask-choices setting. A value that is already in the store is never overwritten, and the copy runs once.

Assumptions

Claude is asked to state each assumption it makes while building, as its own line:

ASSUMPTION: <one short sentence>

The rule is a system-prompt section, session-panel:assume, on when Track assumptions is on. The line must start with plain ASSUMPTION:, with no bold, bullet or heading. Lines inside a code fence are ignored. Each text is cut at 200 characters.

  • Declared entries: each ASSUMPTION: line in a reply becomes an entry. The same text listed twice in one turn is kept once.
  • Flagged entries: a turn that writes files inside the project root and states no assumption adds one row for the whole turn. Its text is <file name>, no stated assumption for one file, or <N> files, no stated assumption for more. Its path cell shows the first file, relative to the project root, with +N-1 when there are more. Edits outside the project root are ignored. Files are written by Edit, Write, MultiEdit, NotebookEdit, and by Bash or PowerShell commands that write (redirects, Set-Content, Add-Content, Out-File, Copy-Item, Move-Item, sed -i, cp, mv). A denied or failed call writes nothing and is not flagged. The flag needs Track assumptions and Flag undeclared edits both on.
  • The list is newest first, capped at 50 entries. Open entries come first in the panel, then the resolved ones, dimmed with their status.
  • Title: ==== ASSUMPTIONS (N open) ====, with the count of open entries. Click it to open or close the section.
  • Frame: when open, the section sits in a single-line frame: the title at its top, then the column toggles, a divider, the table.
  • Column toggles: a line cols: ID ☑ Turn ☑ Text ☑ Path ☑ Time ☑. Each name is a button; ☑ shows the column, ☐ hides it. Cat, Status and the buttons always show. Hidden columns free their width for the others, and the drop order applies to the rest. Text keeps at least 30 cells while it shows. The choice is saved in the store as assumptionColumns, and read at the next session start.
  • Select a row: pressing its ID or its Text opens the row’s full Text and Path below it, inside the frame, wrapped over as many lines as needed. Pressing it again closes it. Pressing another row moves the open one. One row is open at a time.
  • Reveal: when the section opens with entries, or when the first entries appear while it is open, the frame opens first and then the rows print one by one over about 400 ms. Ordinary redraws never run it again.
  • Confirm sets an entry to confirmed, shown as ✓ Confirm.
  • Wrong sets it to wrong, shown as ✗ Wrong. It then puts the correction prefix in the box for you to finish. Nothing is sent.
  • Delete, shown as ⌫ Delete, removes the entry from the list and the file. There is no undo.
  • A confirmed or wrong entry is deleted at once.
  • An open entry asks first, with the question Delete open assumption <id>? Nobody has decided on it yet. and the answers Delete and Keep. Keep leaves it. With no picker in the session, the entry is kept.
  • Clear resolved, shown as ⌫ Clear resolved in the title line, removes every confirmed and wrong entry and keeps the open ones. It asks nothing. The toast is Cleared <N> resolved assumptions. The button shows only when a resolved entry exists.
  • Stale: an open entry added three or more days ago shows stale in the Status cell, in dim yellow (one letter, t, when shrunk). Its stored status stays open, so Confirm, Wrong and Delete still work on it, and it still counts in Assumptions (N open).
  • Auto-tidy: when the list loads and at each turn end, a confirmed or wrong entry whose resolvedAt is more than seven days old is removed from the list and the file. Open entries are never tidied, whatever their age.
  • A declared entry gives Assumption '<text>' is wrong. Instead:
  • A flagged entry gives About the edits in turn <n> (<first file> and <N-1> more): , or About the edits in turn <n> (<file>): for one file.
  • Confirm and Wrong appear only on open entries. They are two separate buttons, ✓ Confirm and ✗ Wrong. Delete is a third button, ⌫ Delete, on every row. The actions cell is 26 cells wide.
  • Table widths: at 120 cells every column shows (Text keeps 30). At 90, Time drops. At 76, Path and Turn drop too, so the columns are ID, Cat, Status, Text and the buttons. At 60 no optional column fits: ID drops, Cat and Status are one letter, and Text is 23 cells.
  • Table: one row per entry, one column per field: ID, Cat (kind), Status, Turn, Text, Path, Time (at), and actions. Headers are Title Case: ID, Cat, Status, Turn, Text, Path, Time, and an empty column for the buttons. The field names in the data are unchanged. At 90 cells Path shows and Time does not; at 76 cells neither shows. Each fixed column is as wide as its header or its longest cell, and no wider. Spare width goes to Text, and to Path when it shows. Columns are separated by │ , with a rule under the header (─┼─) and a light rule between entries. The panel takes 2N+1 lines for N entries. Colours: the header is bold; Status is yellow (open), green (confirmed) or red (wrong); Cat is cyan (declared) or magenta (flagged); ID, Turn, Time and Path are dim; bars and rules are dim; resolved rows dim their text, but keep the Status colour. Text and the ✓ Confirm ✗ Wrong buttons always stay, and Text keeps at least 30 cells while an optional column shows. As the band narrows, Cat and Status first shrink to one letter (C and S in the headers; d or f, and o, c or w, in the cells). Then the optional columns drop, in this order: Time, Path, Turn, ID. Text is cut with an ellipsis. Times show as HH:MM.
  • Hidden: the panel is not shown at all when there are no assumptions.

The shared file

Each session keeps its list in one file, written on every change and read back when the session starts:

<project root>/.claude/session-panel/assumptions/<name>.json

  • The file name is the session's name, with the team-orchestrator rule: any run of characters that is not a letter, a digit, ., _ or - becomes _ (so Mod Builder 2 is Mod_Builder_2.json). The name comes from the session registry (<config dir>/sessions/<pid>.json, field name), matched by this session's id. With no name, the file is the session id's file, as before.
  • Takeover: when a session closes and a new one starts with the same name, the new session takes over the same file. It keeps the old entries (each still tagged with its old sessionId) and sets the file's top-level sessionId to itself.
  • A new id after /clear: a session that already holds a file for another session id, or a track saved by an older version, finds its file again. It takes over the named file the same way; it never writes into the old session's id file. Its running turn state is reset; its entries come from the file.
  • When the registry is read: only when the session first finds its file, when its id has changed, or for a track saved by an older version. Ordinary tool calls, and status lines, read no registry. A plain tool call that edits no file does nothing for the assumptions. A /rename during a session is picked up at the next /clear or restart.
  • A live owner: if the file's owner is still in the registry, or another live session has the same name, this session writes <name>-<first 8 of the session id>.json instead, and shows one toast. The same tagged file is used when the registry cannot be read in full (see Fail safe).
  • Fail safe: if a registry folder that exists cannot be listed, or a record in it cannot be read, the owner is unknown. The session then uses the tagged file and never takes a file over. A folder that does not exist has no sessions. The registry is read again once after 200 ms before that decision, so a record caught mid-rewrite does not tag the session for good. A takeover is written at once, so the file names the new session even with no new entries.
  • The folder .claude/session-panel/ has a .gitignore with *, so git ignores the whole folder. The project's own .gitignore is not changed.
  • Shape: { "version": 1, "member": "<name>", "sessionId": "<owner session id>", "entries": [...] }. member is the session's name, or its session id when it has no name.
  • Each entry: { id, text, turn, kind: "declared" | "flagged", path?, paths?, status: "open" | "confirmed" | "wrong", at, resolvedAt?, sessionId }. path and paths are on flagged entries only: path is the first file, paths all of them, both relative to the project root.
  • Files from before 0.3.34 (named by session id) are left as they are. A session with no name still reads and writes its own id file.
  • Merge: when a named session loads, its own old <session id>.json (only if its top-level sessionId is this session's) is merged into the named file, once. Statuses are kept. An old id the named file does not have is kept. An old id it already has gets the next free id, recorded in the named file's idMap (old id to new id). Head lines name current ids (the roster reads the named file's ids), so idMap is a record of the renumbering only and is not used for lookup. The merged file name is recorded in the named file's merged list, so a reload does not merge it again. After the named file is saved, the old file is retired: rewritten with no entries and mergedInto naming the named file, so each assumption is counted once. A failed save of the named file retires nothing, so the old file keeps its entries. If the retire write itself fails, the old file is retired on a later load, when its name is still in merged and it still holds entries; it is not merged a second time. Another session's id file is never merged.
  • An unreadable file is never overwritten.
  • Failures show a toast: Assumptions not loaded: <reason>. on a bad file, and Assumptions file not written: <message> if a write fails. A failed write never stops the turn.

Decision step

Off by default. Turn it on in Settings → Assumptions → Decision step. It points the model at the assumptions worth reading this turn.

  • Score: each assumption gets a score from 0 to 1, from five parts: status (wrong 1, open 0.6, confirmed 0.2), age (less stale is higher), kind (declared 1, flagged 0.3), files the prompt names, and word overlap with the prompt.
  • Files: a prompt path with a folder (hooks/decide.ts) matches an entry by its full path or a path suffix (src/hooks/decide.ts matches). A bare file name (decide.ts) matches by base name.
  • Must match: with the local score, an assumption that is not wrong is kept only when it shares a file or a meaningful word with the prompt. Status, age and kind only rank the matches; they never make one.
  • Keep rule: a wrong assumption is always kept. Any other is kept when it matches the prompt and its score is strictly above the threshold. At most 5 are kept for each kind.
  • Keep threshold: Settings → Assumptions → Keep threshold, 0.4 to 0.8, default 0.5. A rule in decide.json wins over it.
  • Digest: one line is added to the turn, for example Read only these assumptions: #19 (wrong: "uses the old API"), #24, #31. Skip the rest. The 5 newest wrong assumptions are quoted in full; any others show as also wrong: #31, #33. The budget is 360 characters, about 60 to 90 tokens. Nothing kept means no digest.
  • Jev pass: Settings → Assumptions → Jev pass, off by default. It is a second opinion from TypeSafe, one request per turn.
  • Key: read from the environment variable TYPESAFE_API_KEY. With no key, Jev is off and nothing is said.
  • Request: one call to the TypeSafe API (https://api.typesafe.ai/v1/systemone, model jev-latest). It asks, for each shortlisted assumption, whether it is relevant to the prompt, and it labels the prompt with one task type: build, fix, review, research, docs or other.
  • Shortlist: before Jev, the local score keeps the assumptions scored strictly above the task threshold. Wrong assumptions stay. Jev then decides relevance, so the match rule does not apply here.
  • Keep: an assumption is kept when Jev's answer is above 0.8 (p > 0.8), at most 5 for each kind. Wrong assumptions are always kept.
  • Fallback: no key, an error, a reply that is not usable, or an answer later than 1.5 s, and the local score is used. A failure shows one toast: Session Panel: the Jev pass could not answer, so the local score was used.
  • Rules file: .claude/session-panel/decide.json in the project root. It is optional and has four keys:
  { "version": 1, "default": 0.6, "paths": { "src/**": 0.7 }, "roles": { "Builder": 0.4 }, "task
Source 6 files
hooks/register.tsx 8 lines
1import type { Register } from 'claude-code'
2
3import { registerSessionPanel } from './session-panel'
4
5export const register: Register = (on, options) => {
6  registerSessionPanel(on, options)
7}
8
hooks/session-panel.tsx 1886 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, RenderChildren, RenderElement, Timer } from 'claude-code'
3
4import type { Assumption, AssumptionTrack, CleanChecklist, CleanFinished, CleanStyle, EnhancerState, Prefs } from '../types'
5import { DEFAULT_PREFS, enhance, runEnhance, sendBox, undoEnhance, type EnhancerDeps, type EnhancerIo, type Outcome } from './enhancer'
6import {
7  GATE_MESSAGE,
8  PLAN_SECTION,
9  rulesSection,
10  GATE_LIMIT,
11  versionFrom,
12  actionLabel,
13  applyTaskCreate,
14  applyTaskUpdate,
15  applyTodos,
16  asksUser,
17  barSegments,
18  cleanName,
19  cleanTitle,
20  clampPercent,
21  easeStep,
22  fitName,
23  formatDuration,
24  friendlyError,
25  isGateExempt,
26  looksLikeUserDenial,
27  SOURCE_SHORT,
28  TABLE_GAP,
29  MIN_DESC,
30  wrapWords,
31  isGrillSkill,
32  GRILL_NOTE,
33  tableWidths,
34  pageRows,
35  meter,
36  migratedValues,
37  newChecklist,
38  planSteps,
39  reportProgress,
40  shortName,
41  stepsOf,
42  startJob,
43  stepNumber,
44  sweep,
45  visibleRows,
46  ASSUME_SECTION,
47  responseText,
48  addDeclared,
49  addEdits,
50  editTargets,
51  finishTurn,
52  loadTrack,
53  newTrack,
54  applyStatuses,
55  assumptionsDir,
56  chooseFile,
57  clip,
58  headsFor,
59  retiredJson,
60  hhmm,
61  homeOf,
62  configDirOf,
63  enhanceKeyEdit,
64  nameFile,
65  normPath,
66  mergeOld,
67  ownerOf,
68  REGISTRY_RECORD,
69  safeName,
70  relativeTo,
71  osFrom,
72  tableColumns,
73  headerPieces,
74  rulePieces,
75  rowPieces,
76  selectedAfter,
77  expandedAfterChange,
78  canHide,
79  KEEP_ONE_MESSAGE,
80  cellColor,
81  type Os,
82  openCount,
83  panelOrder,
84  parseIncoming,
85  removeEntry,
86  hiddenFrom,
87  toggled,
88  TOGGLES,
89  deleteQuestion,
90  DELETE_OPTIONS,
91  dropResolved,
92  tidy,
93  isStale,
94  sortPrompt,
95  parseStored,
96  plainPath,
97  resolveStorePath,
98  setStatus,
99  storedJson,
100  STORE_DIR,
101  STORE_IGNORE,
102  parseAssumptions,
103  redirectText,
104  startTurn,
105} from './logic'
106import { DECIDE_FILE, THRESHOLD_CHOICES, clampThreshold, runDecide, roleFor, type DecideIo } from './decide'
107
108type Hooked = EngineInterface
109
110const enabledA = atom({ plugin: 'session-panel', key: 'sessionPanelEnabled' } as const, true)
111const askEnabledA = atom({ plugin: 'session-panel', key: 'askChoicesEnabled' } as const, false)
112const askRuleA = atom({ plugin: 'session-panel', key: 'questionBoxRule' } as const, true)
113const stopCheckA = atom({ plugin: 'session-panel', key: 'stopCheck' } as const, true)
114const askedA = atom({ plugin: 'session-panel', key: 'askedThisTurn' } as const, false)
115const settingsTabA = atom({ plugin: 'session-panel', key: 'settingsTab' } as const, 'band')
116const settingsPageA = atom({ plugin: 'session-panel', key: 'settingsPage' } as const, 0)
117const settingsOpenA = atom({ plugin: 'session-panel', key: 'settingsOpen' } as const, false)
118const checklistA = atom({ plugin: 'session-panel', key: 'checklist' } as const, newChecklist())
119const tickA = atom({ plugin: 'session-panel', key: 'tick' } as const, 0)
120const styleA = atom({ plugin: 'session-panel', key: 'viewStyle' } as const, 'checklist' as CleanStyle)
121const finishedA = atom({ plugin: 'session-panel', key: 'finished' } as const, [] as CleanFinished[])
122const prefsA = atom({ plugin: 'session-panel', key: 'prefs' } as const, DEFAULT_PREFS as Prefs)
123const enhancerA = atom({ plugin: 'session-panel', key: 'enhancer' } as const, { original: null, passText: null, notes: [], busy: false } as EnhancerState)
124const gateDeniedA = atom({ plugin: 'session-panel', key: 'gateDenied' } as const, 0)
125const trackA = atom({ plugin: 'session-panel', key: 'assumptionTrack' } as const, newTrack() as AssumptionTrack)
126const versionA = atom({ plugin: 'session-panel', key: 'version' } as const, '')
127/** The row whose full text is open, or null. One row at a time. */
128const expandedA = atom({ plugin: 'session-panel', key: 'assumptionExpanded' } as const, null as number | null)
129/** The rows printed while the reveal runs: a count, or null once it has finished (all rows print). */
130const revealA = atom({ plugin: 'session-panel', key: 'assumptionReveal' } as const, null as number | null)
131/** The columns the reader has hidden: ID, Turn, Text, Path or Time. Saved in the store as assumptionColumns. */
132const hiddenColsA = atom({ plugin: 'session-panel', key: 'hiddenColumns' } as const, [] as string[])
133/** The text of the last prompt the person sent. The prompt box is already empty by the time prompt.compose runs, so the
134 * decision step reads the wording of the turn from here, not from the box. */
135const submittedA = atom({ plugin: 'session-panel', key: 'submittedText' } as const, '')
136
137const PLAN = 'mcp__session-panel__plan_steps'
138const REPORT = 'mcp__session-panel__report_progress'
139const ASK = 'mcp__session-panel__ask_choices'
140
141let timer: Timer | undefined
142let doneTimer: Timer | undefined
143let lastInputAt = 0
144
145function patch($: Hooked, fn: (c: CleanChecklist) => CleanChecklist) {
146  return update($, checklistA, fn)
147}
148
149function runClock($: Hooked) {
150  if (timer) return
151  timer = $.clock.every(250, () => {
152    void tickOnce($)
153  })
154}
155
156async function tickOnce($: Hooked) {
157  const c = await read($, checklistA)
158  if (c.phase !== 'working' && c.phase !== 'needs-you') return
159  await update($, tickA, n => (n ?? 0) + 1)
160  await patch($, easeStep)
161}
162
163function stopClock() {
164  timer?.cancel()
165  timer = undefined
166}
167
168async function bell($: Hooked) {
169  const now = await $.clock.now()
170  // No focus API: ring only when the person has been quiet for a while.
171  if (now - lastInputAt < 15000) return
172  const isWindows = (await $.env.get('OS')) === 'Windows_NT'
173  const argv = isWindows
174    ? ['powershell', '-NoProfile', '-Command', '[console]::Beep(880,200)']
175    : ['afplay', '/System/Library/Sounds/Ping.aiff']
176  $.process.run(argv, { timeoutMs: 5000 }).catch(() => undefined)
177}
178
179async function needsYou($: Hooked, reason: string, question: CleanChecklist['question'] = null) {
180  const before = await read($, checklistA)
181  const isNew = before.phase !== 'needs-you' || before.isBelled === false
182  await patch($, c => ({
183    ...c,
184    phase: 'needs-you',
185    needsYouReason: reason,
186    question,
187    isBelled: true,
188    finishedAt: 0,
189  }))
190  runClock($)
191  if (isNew) await bell($)
192}
193
194function resume($: Hooked) {
195  return patch($, c =>
196    c.phase === 'needs-you' || c.phase === 'stuck'
197      ? { ...c, phase: 'working', needsYouReason: '', stuckReason: '', question: null, isBelled: false }
198      : c,
199  )
200}
201
202/** Keeps the /config row in step with a button or slash command; a session without the row ignores the call. */
203async function syncRow($: Hooked, field: string, value: string) {
204  await $.config.set({ key: `session-panel.${field}`, value }).catch(() => undefined)
205}
206
207async function setStyle($: Hooked, value: CleanStyle) {
208  await update($, styleA, () => value)
209  await $.store.set('sessionPanelStyle', value)
210  $.ui.toast(value === 'bars' ? 'Bar view' : 'List view')
211  await syncRow($, 'view', value === 'bars' ? 'bars' : 'list')
212}
213
214async function setEnabled($: Hooked, value: boolean) {
215  await update($, enabledA, () => value)
216  await $.store.set('sessionPanelEnabled', value)
217  $.ui.toast(value ? 'Session Panel is on: details are hidden.' : 'Session Panel is off: details are showing.')
218  await syncRow($, 'sessionPanel', value ? 'on' : 'off')
219}
220
221async function setAskEnabled($: Hooked, value: boolean) {
222  await update($, askEnabledA, () => value)
223  await $.store.set('askChoicesEnabled', value)
224  $.ui.toast(value ? 'ask_choices is on.' : 'ask_choices is off: Claude uses AskUserQuestion.')
225  await syncRow($, 'askChoices', value ? 'on' : 'off')
226}
227
228// One-time copy of the choices the old clean-view plugin saved in its settings row, into this plugin's store.
229// Only keys with no value yet are copied, and the flag keeps it from running twice.
230async function migrateFromCleanView($: Hooked) {
231  if ((await $.store.get('sessionPanelMigrated')) === true) return
232  const user = (await $.settings.read({ source: 'user' }).catch(() => ({}))) as {
233    pluginConfigs?: Record<string, { options?: Record<string, unknown> }>
234  }
235  const stored = {
236    sessionPanelEnabled: await $.store.get('sessionPanelEnabled'),
237    sessionPanelStyle: await $.store.get('sessionPanelStyle'),
238    askChoicesEnabled: await $.store.get('askChoicesEnabled'),
239  }
240  for (const [key, value] of Object.entries(migratedValues(user.pluginConfigs, stored))) await $.store.set(key, value)
241  await $.store.set('sessionPanelMigrated', true)
242}
243
244async function setPrefs($: Hooked, patch: Partial<Prefs>) {
245  const next = { ...(await read($, prefsA)), ...patch }
246  await update($, prefsA, () => next)
247  await $.store.set('prefs', next)
248}
249
250async function setEnhancer($: Hooked, patch: Partial<EnhancerState>) {
251  await update($, enhancerA, s => ({ ...s, ...patch }))
252}
253
254const NO_DRAFT: Partial<EnhancerState> = { original: null, passText: null, notes: [] }
255
256/** Reads the box, enhances it and fills the box with the result. Returns the line to show the person. */
257/** The text `/enhance <draft>` shows: the enhanced prompt, the model that answered, and the notes. */
258function formatOutcome(r: Outcome): string {
259  if (r.kind === 'off') return 'The enhancer is off. Open Settings to turn it on.'
260  if (r.kind === 'empty') return 'Give a draft after /enhance, or type one in the box first.'
261  if (r.kind === 'failed') return `Enhancer: ${r.reason}.`
262  return [`Enhanced (via ${r.via}):`, r.prompt, '', ...r.notes.map(n => `· ${n}`)].join('\n')
263}
264
265/** The band's engine calls and state as plain functions. `$` cannot cross the import into enhancer.ts. */
266function engineIo($: Hooked): EnhancerIo {
267  return {
268    home: async () => (await $.env.get('USERPROFILE')) ?? '',
269    exists: p => $.fs.exists(p),
270    read: p => $.fs.read(p),
271    mtime: async p => (await $.fs.stat(p)).mtimeMs,
272    list: p => $.fs.list(p),
273    ancestors: async names => await $.fs.ancestors({ names }),
274    cacheGet: async k => (await $.store.get(k)) as { mtimeMs: number; text: string } | undefined,
275    cacheSet: (k, v) => $.store.set(k, v),
276    gh: async args => {
277      try {
278        return await $.process.run(['gh', ...args])
279      } catch {
280        return undefined
281      }
282    },
283    messages: async () => (await $.session.messages()).map(m => ({ role: m.role, text: m.text })),
284    fork: async prompt => (await $.model.fork({ prompt })) as Awaited<ReturnType<EnhancerIo['fork']>>,
285    complete: async (model, prompt) => (await $.model.complete({ model, prompt })) as Awaited<ReturnType<EnhancerIo['complete']>>,
286    askUser: (question, options) => $.ui.ask(question, options),
287  }
288}
289
290function deps($: Hooked): EnhancerDeps {
291  return {
292    ...engineIo($),
293    prefs: () => read($, prefsA),
294    state: () => read($, enhancerA),
295    setState: patch => setEnhancer($, patch),
296    readBox: async () => (await $.prompt.read()).text,
297    fillBox: async text => void (await $.prompt.fill({ text, mode: 'replace' })),
298    submitBox: async text => void (await $.prompt.submit({ text, asUser: true })),
299    toast: text => $.ui.toast(text),
300  }
301}
302
303/** First run: four questions, the answers saved. Runs once, or again from Settings. */
304async function runSetup($: Hooked) {
305  const on = (await $.ui.ask('Turn the enhancer on?', ['On (Recommended)', 'Off'])).startsWith('On')
306  const modelAns = await $.ui.ask('Which model should the enhancer use?', ['Haiku (Recommended)', 'Sonnet', 'Opus'])
307  const model = modelAns.startsWith('Sonnet') ? 'sonnet' : modelAns.startsWith('Opus') ? 'opus' : 'haiku'
308  const view = (await $.ui.ask('Which band view by default?', ['List (Recommended)', 'Bars'])).startsWith('Bars') ? 'bars' : 'checklist'
309  const srcAns = await $.ui.ask('Which context sources should the enhancer read?', ['All (Recommended)', 'Instructions and skills only', 'None'])
310  const sources = srcAns.startsWith('All')
311    ? { instructions: true, skills: true, docs: true, github: false }
312    : srcAns.startsWith('Instructions')
313      ? { instructions: true, skills: true, docs: false, github: false }
314      : { instructions: false, skills: false, docs: false, github: false }
315  const chat = (await $.ui.ask('How much chat should the enhancer read?', ['Haiku + recent (Recommended)', 'Full chat (session model)'])).startsWith('Full') ? 'full' : 'recent'
316  await setPrefs($, { enhancerOn: on, model, chat, sources })
317  await setStyle($, view)
318  await $.store.set('setupDone', true)
319}
320
321/** Stop: cancels the running turn by the id turn.start gave us. Shown only while a turn runs. */
322async function stopTurn($: Hooked) {
323  const id = (await read($, trackA)).runningTurnId
324  if (id === '') return
325  await $.turn.abort({ turnId: id }).then(
326    () => $.ui.toast('Stopped'),
327    () => $.ui.toast('Nothing to stop: the turn has ended.'),
328  )
329}
330
331/** Redirect: puts the start of a correction in the box. The person finishes it and sends; nothing is submitted here. */
332async function redirectTo($: Hooked, entry: Assumption) {
333  await $.prompt.fill({ text: redirectText(entry), mode: 'replace' })
334}
335
336/** Reads a file as text, or undefined when it is missing or cannot be read. */
337async function readText($: Hooked, path: string): Promise<string | undefined> {
338  try {
339    const v = await $.fs.read(path)
340    return typeof v === 'string' ? v : undefined
341  } catch {
342    return undefined
343  }
344}
345
346/** /enhance-key and its Settings button, only when the person asks: Ctrl+X E for Enhance in keybindings.json. */
347async function addEnhanceKey($: Hooked): Promise<string> {
348  const none = () => undefined
349  const dir = configDirOf({
350    CLAUDE_CONFIG_DIR: await $.env.get('CLAUDE_CONFIG_DIR').catch(none),
351    USERPROFILE: await $.env.get('USERPROFILE').catch(none),
352    HOME: await $.env.get('HOME').catch(none),
353  })
354  if (dir === '') return 'No Claude config folder was found, so nothing was changed.'
355  const path = `${dir}/keybindings.json`
356  try {
357    // a file that is there but cannot be read is never taken for a missing one
358    const edit = enhanceKeyEdit((await $.fs.exists(path)) ? String(await $.fs.read(path)) : undefined)
359    if (edit.write !== undefined) await $.fs.write(path, edit.write)
360    return edit.line
361  } catch (err) {
362    return `keybindings.json could not be changed: ${String((err as any)?.message ?? err).slice(0, 160)}`
363  }
364}
365
366/**
367 * The sessions the registry lists (~/.claude/sessions/<pid>.json), read once. `complete` is false when a folder that is
368 * there cannot be listed, or a record cannot be read: then no owner can be ruled out. A folder that is not there has no
369 * sessions in it, so it is not a reason for `complete` to be false.
370 */
371async function readRegistry($: Hooked): Promise<{ live: Array<{ sessionId: string; name: string }>; complete: boolean }> {
372  const none = () => undefined
373  const home = (await $.env.get('USERPROFILE').catch(none)) || (await $.env.get('HOME').catch(none))
374  const custom = await $.env.get('CLAUDE_CONFIG_DIR').catch(none)
375  const dirs = [...new Set([custom, home ? `${homeOf({ USERPROFILE: home })}/.claude` : ''].filter((x): x is string => !!x).map(normPath))]
376  const live: Array<{ sessionId: string; name: string }> = []
377  let complete = true
378  for (const dir of dirs) {
379    const at = `${dir}/sessions`
380    const there = await $.fs.exists(at).catch(() => undefined)
381    if (there === undefined) {
382      complete = false
383      continue
384    }
385    if (!there) continue
386    let listed: any[]
387    try {
388      listed = (await $.fs.list(at)) as any[]
389      if (!listed || typeof listed.filter !== 'function') throw new Error('not a list')
390    } catch {
391      complete = false
392      continue
393    }
394    for (const f of listed.filter(f => f.kind === 'file' && REGISTRY_RECORD.test(String(f.name)))) {
395      try {
396        const o = JSON.parse(String(await $.fs.read(`${at}/${f.name}`)))
397        if (typeof o?.sessionId !== 'string') throw new Error('no session id')
398        live.push({ sessionId: o.sessionId, name: typeof o.name === 'string' ? o.name : '' })
399      } catch {
400        // a record that cannot be read: the owner of a file may be in it, so the registry is not complete
401        complete = false
402      }
403    }
404  }
405  return { live, complete }
406}
407
408/**
409 * The registry, read again once after 200 ms when the first read was not whole: a record caught mid-rewrite is then read
410 * cleanly, instead of tagging the session for its whole life.
411 */
412async function liveSessions($: Hooked): Promise<{ live: Array<{ sessionId: string; name: string }>; complete: boolean }> {
413  const first = await readRegistry($)
414  if (first.complete) return first
415  await $.clock.sleep(200)
416  return readRegistry($)
417}
418
419/** The OS as team-orchestrator reads it: the OS variable, then uname -s. Read once per load; it cannot change. */
420let osKnown: Os | undefined
421async function osOf($: Hooked): Promise<Os> {
422  if (osKnown !== undefined) return osKnown
423  const none = () => undefined
424  const osVar = await $.env.get('OS').catch(none)
425  if (osVar === 'Windows_NT') return (osKnown = 'windows')
426  const r = await Promise.resolve().then(() => $.process.run(['uname', '-s'], { timeoutMs: 10000 })).catch(none)
427  osKnown = osFrom(osVar, String(r?.stdout ?? ''))
428  return osKnown
429}
430
431/**
432 * Finds this session's file and loads what it holds. The name (from the registry) decides the file name; a file that
433 * belongs to a closed session is taken over with its entries. Runs the first time a file is needed, so every path
434 * that writes has the entries already. A track from an older load, or from a session that has since changed its id
435 * (after /clear), is located again; a track already located for this session and file is returned as it is.
436 */
437async function locate($: Hooked, t: AssumptionTrack): Promise<AssumptionTrack> {
438  const sessionId = await $.session.id()
439  // a track already located for this session is kept without a registry read; a new id, an older track or a first call reads it.
440  // A /rename is picked up at the next /clear or restart.
441  if (t.file !== '' && t.sessionId === sessionId && !!t.member) return t
442  const root = normPath(await $.session.root())
443  const os = await osOf($)
444  const { live, complete } = await liveSessions($)
445  const name = live.find(s => s.sessionId.toLowerCase() === sessionId.toLowerCase() && s.name.trim() !== '')?.name
446  const probe = name !== undefined ? `${assumptionsDir(root)}/${nameFile(name)}.json` : plainPath(root, sessionId)
447  const baseText = await readText($, probe)
448  const r = chooseFile({ root, sessionId, name, os, baseText, live, complete })
449  if (t.file === r.file && t.sessionId === sessionId && t.member === (name ?? sessionId)) return t
450  // a track located for another session id (before /clear) starts with no running turn; its entries come from the file
451  if (t.sessionId !== '' && t.sessionId !== sessionId) t = { ...t, runningTurnId: '', turnDeclared: false, turnEdits: [], turnNo: 0, seq: 0 }
452  const text = r.file === probe ? baseText : await readText($, r.file)
453  const stored = parseStored(text)
454  if (!stored.ok) {
455    // a file this code cannot read is never written over: the list is shown empty and nothing is saved
456    if (!t.warned) $.ui.toast(`Assumptions not loaded: ${stored.reason}. This session will not write its file.`)
457    return { ...t, file: '', sessionId, member: name ?? sessionId, root, os, warned: true }
458  }
459  let loaded = loadTrack({ ...t, file: r.file, sessionId, member: name ?? sessionId, warned: t.warned || r.tagged, root, os, merged: stored.merged, idMap: stored.idMap }, stored.entries)
460  // this session's own old id file (<session id>.json) is merged into the named file, once; another session's is never read
461  const oldName = `${safeName(sessionId)}.json`
462  const oldPath = plainPath(root, sessionId)
463  let merged = false
464  let old: ReturnType<typeof parseStored> | undefined
465  let oldText: string | undefined
466  if (name !== undefined && !r.tagged && oldPath !== r.file) {
467    oldText = await readText($, oldPath)
468    old = oldText === undefined ? undefined : parseStored(oldText)
469    if (old?.ok && ownerOf(oldText as string) === sessionId && !loaded.merged.includes(oldName)) {
470      loaded = mergeOld(loaded, { fileName: oldName, sessionId, entries: old.entries })
471      merged = true
472    }
473  }
474  // a takeover, or a merge, is written at once: the file's owner is this session, and the merge is recorded
475  let saved = true
476  if (r.takeover || merged) saved = await saveTrack($, loaded).then(wrote => wrote, () => false)
477  // the old id file is retired only once the named file on disk holds its entries, so no assumption is counted twice.
478  // A file merged on an earlier load is retired here if its retire write failed then.
479  const holds = !merged || saved
480  if (holds && old?.ok && ownerOf(oldText as string) === sessionId && old.entries.length > 0 && loaded.merged.includes(oldName)) {
481    await $.fs.write(oldPath, retiredJson(sessionId, sessionId, r.file.split('/').pop() as string)).catch(() => undefined)
482  }
483  if (r.tagged && !t.warned) {
484    $.ui.toast(`Assumptions: this session writes ${r.file.split('/').pop()}, because the usual file belongs to another session.`)
485  }
486  return loaded
487}
488
489/** Writes the list to this session's file, and takes the file over when its owner has closed. A file this code cannot read is left alone. */
490async function saveTrack($: Hooked, t: AssumptionTrack): Promise<boolean> {
491  if (t.file === '') return false
492  if (!parseStored(await readText($, t.file)).ok) return false
493  const ignore = t.file.replace(/\/assumptions\/[^/]+$/, '/.gitignore')
494  if (!(await $.fs.exists(ignore))) await $.fs.write(ignore, STORE_IGNORE)
495  await $.fs.write(t.file, storedJson(t.member, t.sessionId, t.entries, t.merged, t.idMap))
496  return true
497}
498
499/** Changes the track. The file is found first, so new entries carry this session's id. When the list changed, the file is written at once. */
500async function changeTrack($: Hooked, fn: (t: AssumptionTrack) => AssumptionTrack) {
501  const current = await read($, trackA)
502  // a session that cannot say where its file is still keeps its list on screen; it writes no file
503  const before = await locate($, current).catch(() => current)
504  let after = fn(before)
505  if (after.entries !== before.entries) {
506    try {
507      await saveTrack($, after)
508    } catch (err) {
509      // the list still changes on screen; a failed file write must never break the turn that caused it
510      $.ui.toast(`Assumptions file not written: ${String((err as any)?.message ?? err).slice(0, 80)}`)
511    }
512  }
513  await update($, trackA, () => after)
514  // a row whose entry is gone closes, so a later entry with the same id does not open expanded
515  const expanded = await read($, expandedA)
516  const kept = expandedAfterChange(expanded, after.entries)
517  if (kept !== expanded) await update($, expandedA, () => kept)
518  // content that appears while the section is open is revealed once; ordinary changes do not reveal again
519  if (before.entries.length === 0 && after.entries.length > 0 && after.open) await startReveal($, after.entries.length)
520}
521
522/** At session start: find this session's file, read it back, and show what it holds. */
523async function loadAssumptions($: Hooked) {
524  const t = await locate($, await read($, trackA))
525  await update($, trackA, () => t)
526  const now = await $.clock.now()
527  await changeTrack($, t => tidy(t, now))
528}
529
530/** The team heads for this session, read from the roster (read only). Undefined when it cannot be read or this session is not on it. */
531async function headsOfRoster($: Hooked): Promise<Set<string> | undefined> {
532  const root = (await $.session.root()).replace(/\\/g, '/')
533  const text = await readText($, `${root}/.claude/team-orchestrator/roster.json`)
534  return text === undefined ? undefined : headsFor(text, await $.session.id())
535}
536
537// The decision step (#91, slice 1). It never blocks a turn: the flag keeps its toast to one per load.
538let decideWarned = false
539
540/** The decision step's engine calls and state as plain functions, so the step can be tested without the band. */
541function decideIo($: Hooked): DecideIo {
542  return {
543    rules: async () => {
544      const root = (await $.session.root()).replace(/\\/g, '/')
545      return readText($, `${root}/${DECIDE_FILE}`)
546    },
547    role: async () => {
548      const root = (await $.session.root()).replace(/\\/g, '/')
549      const text = await readText($, `${root}/.claude/team-orchestrator/roster.json`)
550      return text === undefined ? undefined : roleFor(text, await $.session.id())
551    },
552    // the box is empty by the time prompt.compose runs, so the turn's wording comes from the last submitted prompt
553    box: async () => read($, submittedA),
554    paths: async () => (await read($, trackA)).turnEdits,
555    entries: async () => (await locate($, await read($, trackA)).catch(() => read($, trackA))).entries,
556    prefs: async () => {
557      const p = await read($, prefsA)
558      return { decide: p.decide, decideThreshold: p.decideThreshold, jev: p.jev }
559    },
560    now: () => $.clock.now(),
561    toast: text => $.ui.toast(text),
562    // the key is read from the environment, never stored, and never logged
563    key: () => $.env.get('TYPESAFE_API_KEY').catch(() => undefined),
564    // the one HTTP call: through $.http.fetch, as #91 requires, with the engine's own cancel-on-timeout
565    fetch: args =>
566      $.http.fetch({ url: args.url, method: args.method, headers: args.headers, body: args.body, timeoutMs: args.timeoutMs }).then(r => ({
567        status: r.status,
568        body: typeof r.body === 'string' ? r.body : '',
569      })),
570  }
571}
572
573/** Applies the status lines in a prompt. Returns the drop for a message that is only status lines, or null to let it go on. */
574async function applyStatusLines($: Hooked, text: string): Promise<{ drop: string } | null> {
575  const parts = sortPrompt(text)
576  if (parts.refusal !== undefined) {
577    // a prompt that is not exactly one envelope applies no status; it is dropped only if it carries status lines
578    $.ui.toast(parts.refusal)
579    return parts.statuses.length > 0 ? { drop: parts.refusal } : null
580  }
581  if (parts.statuses.length === 0) return null
582  const heads = parts.statuses.some(s => s.sender !== 'user') ? await headsOfRoster($) : undefined
583  const items = parts.statuses.map(s => {
584    const cmd = parseIncoming(s.line)!
585    const allowed = s.sender === 'user' || (heads?.has(s.sender) ?? false)
586    const refusal =
587      heads === undefined ? `Refused: the team roster cannot be read, so ${s.sender} cannot be checked.` : `Refused: ${s.sender} is not this session's team head.`
588    return { ...cmd, allowed, refusal }
589  })
590  const now = await $.clock.now()
591  const before = await read($, trackA)
592  const applied = applyStatuses(before, items, now)
593  if (applied.changed) await changeTrack($, () => applied.track)
594  // a head's wrong does what the local Wrong button does: the box gets the Redirect text, and nothing is submitted
595  for (const it of items) {
596    const rid = it.id
597    const was = before.entries.find(e => e.id === rid)
598    const now2 = applied.track.entries.find(e => e.id === rid)
599    if (it.allowed && it.verb === 'wrong' && was?.status === 'open' && now2?.status === 'wrong') await redirectTo($, now2)
600  }
601  const note = applied.notes.join(' ')
602  $.ui.toast(note)
603  return parts.other === '' ? { drop: note } : null
604}
605
606/** Confirm or Wrong on one entry: records the status and writes the file. */
607async function resolveEntry($: Hooked, id: number, status: 'confirmed' | 'wrong') {
608  const now = await $.clock.now()
609  await changeTrack($, t => setStatus(t, id, status, now))
610}
611
612/** Wrong: records it, then puts the correction prefix in the box for the person to finish. */
613async function wrongEntry($: Hooked, entry: Assumption) {
614  await resolveEntry($, entry.id, 'wrong')
615  await redirectTo($, entry)
616}
617
618/** Delete: an open entry is asked about first; a resolved one goes at once. There is no undo. */
619async function deleteEntry($: Hooked, entry: Assumption) {
620  if (entry.status === 'open') {
621    // no picker in this session, or a Keep, leaves the entry where it is
622    const answer = await $.ui.ask(deleteQuestion(entry), DELETE_OPTIONS).catch(() => 'Keep')
623    if (answer !== 'Delete') return
624  }
625  await changeTrack($, t => removeEntry(t, entry.id))
626}
627
628/** Shows or hides one column of the table, and keeps the choice in the store. */
629async function toggleColumn($: Hooked, key: string) {
630  const current = await read($, hiddenColsA)
631  if (!canHide(current, key)) {
632    $.ui.toast(KEEP_ONE_MESSAGE)
633    return
634  }
635  const next = toggled(current, key)
636  await update($, hiddenColsA, () => next)
637  await $.store.set('assumptionColumns', next)
638}
639
640/** Opens the full text of one row, or closes it when it is the open row. */
641async function selectRow($: Hooked, id: number) {
642  await update($, expandedA, cur => selectedAfter(cur, id))
643}
644
645/** Opens or closes the Assumptions section. Opening a section with entries runs the reveal. */
646async function toggleSection($: Hooked) {
647  const before = await read($, trackA)
648  await update($, trackA, t => ({ ...t, open: !t.open }))
649  if (!before.open && before.entries.length > 0) await startReveal($, before.entries.length)
650}
651
652/** Prints the rows one by one over about 400 ms: the frame is already there, then each row. Only the reveal's own timers change it. */
653async function startReveal($: Hooked, count: number) {
654  await update($, revealA, () => 0)
655  const step = Math.max(1, Math.floor(400 / count))
656  for (let i = 1; i <= count; i++) {
657    $.clock.after(i * step, () => void update($, revealA, () => (i === count ? null : i)))
658  }
659}
660
661/** Clear resolved: removes every confirmed and wrong entry, and says how many. */
662async function clearResolved($: Hooked) {
663  let count = 0
664  await changeTrack($, t => {
665    const dropped = dropResolved(t)
666    count = dropped.count
667    return dropped.track
668  })
669  $.ui.toast(`Cleared ${count} resolved assumptions.`)
670}
671
672/** Records the files a tool call wrote, for the Assumptions panel. A denied or failed call wrote nothing. */
673async function recordEdits($: Hooked, e: { tool: unknown }, res: { deny?: unknown; isError?: boolean } | undefined) {
674  const prefs = await read($, prefsA)
675  if (!prefs.assumptions || !prefs.flagUndeclared || res?.deny !== undefined || res?.isError) return
676  // a call that edits no file is left alone, so an ordinary tool call reads no registry
677  const targets = editTargets(String(e.tool), e as unknown as Record<string, unknown>)
678  if (targets.length === 0) return
679  // only edits inside the project root count; the rest (a temp file, say) is ignored. Paths are kept relative to the root.
680  const t = await locate($, await read($, trackA)).catch(() => undefined)
681  if (t === undefined || t.root === '') return
682  const inside = targets.map(p => relativeTo(t.root, p, t.os)).filter((p): p is string => p !== undefined)
683  await update($, trackA, () => addEdits(t, inside))
684}
685
686/** A turn ended: an edit in a turn that stated no assumption becomes a flagged entry, when flagging is on. */
687async function finishAssumptions($: Hooked, e: { agentId?: string }) {
688  if (e.agentId !== undefined) return
689  const prefs = await read($, prefsA)
690  const flag = (await read($, enabledA)) && prefs.assumptions && prefs.flagUndeclared
691  const now = await $.clock.now()
692  await changeTrack($, t => tidy(finishTurn(t, flag, now), now))
693}
694
695function title0(cl: CleanChecklist): string {
696  return cl.title === '' ? 'Working on it' : cl.title
697}
698
699export function registerSessionPanel(on: On, options: Record<string, unknown> = {}) {
700  // ---------- session start: tools, command, saved setting ----------
701  // The /config menu rows (options) win; where a row is unset, the setting kept by a button or command applies.
702  on('session.start', async ($, e, next) => {
703    await migrateFromCleanView($)
704    // the version, read once per load from this plugin's own manifest, as team-orchestrator does
705    const manifest = await readText($, `${$.plugin.root}/.claude-plugin/plugin.json`)
706    await update($, versionA, () => versionFrom(manifest))
707    await loadAssumptions($)
708    // the column choice saved last time; a reload starts from what the reader left
709    const stored = await $.store.get('assumptionColumns')
710    const hidden = hiddenFrom(stored)
711    await update($, hiddenColsA, () => hidden)
712    // a stored state that hid both ID and Text is written back with ID shown
713    if (Array.isArray(stored) && JSON.stringify(stored) !== JSON.stringify(hidden)) await $.store.set('assumptionColumns', hidden)
714    const storedPrefs = (await $.store.get('prefs')) as Partial<Prefs> | undefined
715    if (storedPrefs) await update($, prefsA, p => ({ ...p, ...storedPrefs, sources: { ...p.sources, ...storedPrefs.sources } }))
716    const hasSetup = (await $.store.get('setupDone')) === true || (await $.store.get('sessionPanelEnabled')) !== undefined
717    if (e.isInteractive && !hasSetup) {
718      try {
719        await runSetup($)
720      } catch {
721        // the questions could not be asked; the defaults stay and setup runs again next session
722      }
723    }
724    const saved = options.sessionPanel === 'on' ? true : options.sessionPanel === 'off' ? false : await $.store.get('sessionPanelEnabled')
725    if (typeof saved === 'boolean') await update($, enabledA, () => saved)
726    const savedStyle = options.view === 'bars' ? 'bars' : options.view === 'list' ? 'checklist' : await $.store.get('sessionPanelStyle')
727    if (savedStyle === 'bars' || savedStyle === 'checklist') await update($, styleA, () => savedStyle)
728    const savedAsk = options.askChoices === 'on' ? true : options.askChoices === 'off' ? false : await $.store.get('askChoicesEnabled')
729    if (typeof savedAsk === 'boolean') await update($, askEnabledA, () => savedAsk)
730    await $.tool.register({
731      name: 'plan_steps',
732      description:
733        'Session Panel: list the steps of the job in order, 1 to 8 short plain-English names starting with a verb (no paths, file names, commands or code). The first step starts at once. Call this first for every request.',
734      inputSchema: {
735        type: 'object',
736        properties: { steps: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 8 } },
737        required: ['steps'],
738      },
739    })
740    await $.tool.register({
741      name: 'report_progress',
742      description:
743        'Session Panel: report progress on a planned step by its name and a percent from 0 to 100. Use 100 the moment the step is finished.',
744      inputSchema: {
745        type: 'object',
746        properties: { task: { type: 'string' }, percent: { type: 'number' } },
747        required: ['task', 'percent'],
748      },
749    })
750    await $.tool.register({
751      name: 'ask_choices',
752      description:
753        'Off by default: use AskUserQuestion to ask the person. Call this only if the person has said they turned on ask_choices. When on: ask a question with 2 to 4 options, the recommended option first, then stop and wait for their reply.',
754      inputSchema: {
755        type: 'object',
756        properties: {
757          question: { type: 'string' },
758          options: { type: 'array', items: { type: 'string' }, minItems: 2, maxItems: 4 },
759        },
760        required: ['question', 'options'],
761      },
762    })
763    await $.command.register({
764      name: 'askchoices',
765      description: 'Turn the ask_choices question tool on or off (no argument flips it). Off by default.',
766      argumentHint: 'on|off',
767    })
768    await $.command.register({
769      name: 'simple',
770      description: 'Turn Session Panel on or off (no argument flips it), or pick the look: bars or list.',
771      argumentHint: 'on|off|bars|list',
772    })
773    await $.command.register({ name: 'progress', description: 'Flip between the bar view and the list view.' })
774    await $.command.register({ name: 'progress-clear', description: 'Remove the finished bars.' })
775    await $.command.register({ name: 'enhance', description: 'Rewrite the prompt box, or /enhance <draft> to rewrite a draft given here.', argumentHint: '[draft]' })
776    await $.command.register({ name: 'enhance-key', description: 'Add Ctrl+X E for Enhance to your keybindings.json.' })
777    const c = await read($, checklistA)
778    if (c.phase === 'working' || c.phase === 'needs-you') runClock($)
779    return next(e)
780  })
781
782  // ---------- /simple ----------
783  on('command.run', { command: 'simple' }, async ($, e) => {
784    const arg = e.args.trim().toLowerCase()
785    if (arg === 'bars' || arg === 'list') {
786      await setStyle($, arg === 'bars' ? 'bars' : 'checklist')
787      return { text: arg === 'bars' ? 'Bar view.' : 'List view.' }
788    }
789    const current = await read($, enabledA)
790    const value = arg === 'on' ? true : arg === 'off' ? false : !current
791    await setEnabled($, value)
792    return { text: value ? 'Session Panel is on.' : 'Session Panel is off.' }
793  })
794
795  on('command.run', { command: 'askchoices' }, async ($, e) => {
796    const arg = e.args.trim().toLowerCase()
797    const current = await read($, askEnabledA)
798    const value = arg === 'on' ? true : arg === 'off' ? false : !current
799    await setAskEnabled($, value)
800    return { text: value ? 'ask_choices is on.' : 'ask_choices is off.' }
801  })
802
803  on('command.run', { command: 'progress' }, async ($) => {
804    const now = await read($, styleA)
805    await setStyle($, now === 'bars' ? 'checklist' : 'bars')
806    return { text: now === 'bars' ? 'List view.' : 'Bar view.' }
807  })
808  on('command.run', { command: 'enhance' }, async ($, e) => {
809    const draft = e.args.trim()
810    if (draft === '') return { text: await runEnhance(deps($)) }
811    // typed in the box: the result goes back in the box, as the Enhance button does, for a draft too long for the band
812    if (e.origin.kind === 'composer') return { text: await runEnhance(deps($), draft) }
813    try {
814      return { text: formatOutcome(await enhance(engineIo($), draft, await read($, prefsA))) }
815    } catch (err) {
816      return { text: `Enhancer: ${String((err as any)?.message ?? err).slice(0, 160)}` }
817    }
818  })
819  on('command.run', { command: 'enhance-key' }, async ($) => ({ text: await addEnhanceKey($) }))
820  on('command.run', { command: 'progress-clear' }, async ($) => {
821    await update($, finishedA, () => [])
822    return { text: 'Finished bars removed.' }
823  })
824
825  // ---------- system prompt ----------
826  on('prompt.compose', async ($, e, next) => {
827    const result = await next(e)
828    if (!(await read($, enabledA))) return result
829    const text = rulesSection(await read($, askRuleA), await read($, askEnabledA))
830    // The decision step (#91): one pointer digest of the assumptions worth reading this turn. Its own try keeps any
831    // failure here from touching the sections below, and it never blocks the turn.
832    let digest = ''
833    try {
834      const step = await runDecide(decideIo($), decideWarned)
835      decideWarned = step.warned
836      digest = step.text
837    } catch {
838      digest = ''
839    }
840    return {
841      sections: [
842        ...result.sections,
843        { id: 'session-panel:plan', text: PLAN_SECTION, scope: 'session' as const },
844        ...((await read($, prefsA)).assumptions ? [{ id: 'session-panel:assume', text: ASSUME_SECTION, scope: 'session' as const }] : []),
845        ...(digest === '' ? [] : [{ id: 'session-panel:decide', text: digest, scope: 'session' as const }]),
846        { id: 'session-panel:rules', text, scope: 'session' as const },
847      ],
848    }
849  })
850
851  // A grill skill's questions go through the question box. The note rides on the skill's own text, only when one loads,
852  // so it costs no context on other turns.
853  on('skill.prompt', async ($, e, next) => {
854    const r = await next(e)
855    if (!isGrillSkill(e.skill) || !(await read($, enabledA)) || !(await read($, askRuleA))) return r
856    return { text: `${r.text}\n\n${GRILL_NOTE}` }
857  })
858
859  // ---------- a prompt starts a job ----------
860  on('prompt.submit', async ($, e, next) => {
861    // Status lines (typed by the user, or sent by the team head in an envelope) are applied here. A message that is only
862    // status lines is dropped, so no model turn starts for it; any other text in it goes on to the model.
863    const status = await applyStatusLines($, e.text)
864    if (status !== null) return status
865    await update($, submittedA, () => e.text)
866    lastInputAt = await $.clock.now()
867    const enh = await read($, enhancerA)
868    const prefs = await read($, prefsA)
869    if (enh.passText !== null && e.text === enh.passText) {
870      await setEnhancer($, { passText: null })
871      return next(e)
872    }
873    if (prefs.enhancerOn && prefs.autoEnhance && !e.text.startsWith('/') && e.text.trim() !== '') {
874      const line = await runEnhance(deps($), e.text)
875      return { drop: line }
876    }
877    if ((await read($, enabledA)) && (await read($, checklistA)).phase === 'needs-you') await resume($)
878    return next(e)
879  })
880
881  on('turn.start', async ($, e, next) => {
882    // subagents' and teammates' turns arrive here too; only the main loop's turn is this session's job
883    if ((e as { agentId?: string }).agentId !== undefined) return next(e)
884    await update($, trackA, t => startTurn(t, e.turnId))
885    await update($, askedA, () => false)
886    const result = await next(e)
887    if (!(await read($, enabledA))) return result
888    const text = e.text.trim()
889    if (text === '' || text.startsWith('/')) return result
890    const now = await $.clock.now()
891    const before = await read($, checklistA)
892    const isContinue = before.phase === 'needs-you' || before.phase === 'working'
893    if (isContinue) {
894      await resume($)
895      runClock($)
896      return result
897    }
898    doneTimer?.cancel()
899    await patch($, c => startJob(c, now))
900    const jobId = (await read($, checklistA)).jobId
901    runClock($)
902    void (async () => {
903      const r = await $.model.complete({
904        model: 'haiku',
905        effort: 'low',
906        maxTokens: 30,
907        timeoutMs: 15000,
908        prompt: `Name this job in 2 to 6 plain words, starting with a verb. Reply with the name only: no quotes, no file names, no punctuation.\n\nRequest: ${text.slice(0, 600)}`,
909      })
910      if (!r.isAnswered) return
911      const title = cleanTitle(r.text)
912      await patch($, c => (c.jobId === jobId ? { ...c, title } : c))
913    })().catch(() => undefined)
914    return result
915  })
916
917  // ---------- permission prompts ----------
918  on('classic.Notification', async ($, e, next) => {
919    if ((await read($, enabledA)) && /permission|elicitation/i.test(e.notification_type)) {
920      await needsYou($, 'Claude needs your OK to continue')
921    }
922    return next(e)
923  })
924
925  // ---------- serve our tools ----------
926  on('tool.call', { tool: 'mcp__session-panel__plan_steps' }, async ($, e) => {
927    if (!(await read($, enabledA))) return { result: 'Session Panel is off. No plan needed. Continue the work.' }
928    const steps = (e as { steps?: unknown }).steps
929    // A subagent's plan is its own business: answer it, leave the member's checklist alone.
930    if (e.agentId !== undefined) {
931      const n = Array.isArray(steps) ? Math.min(Array.isArray(steps) ? steps.length : 0, 8) : 0
932      return { result: n < 1 ? 'Give 1 to 8 short step names as an array of strings.' : `Planned ${n} steps. The first one has started.` }
933    }
934    const c = await read($, checklistA)
935    const planned = planSteps(c, steps)
936    if (planned === null) return { result: 'Give 1 to 8 short step names as an array of strings.' }
937    await patch($, () => planned)
938    runClock($)
939    return { result: `Planned ${planned.tasks.length} steps. The first one has started.` }
940  })
941
942  on('tool.call', { tool: 'mcp__session-panel__report_progress' }, async ($, e) => {
943    if (!(await read($, enabledA))) return { result: 'Session Panel is off. No progress report needed. Continue the work.' }
944    const args = e as { task?: unknown; percent?: unknown }
945    // A subagent's report never touches the member's checklist (the dock may read it per agent).
946    if (e.agentId !== undefined) return { result: `Progress noted: ${clampPercent(args.percent)}%.` }
947    let pct = 0
948    await patch($, c => {
949      const r = reportProgress(c, args.task, args.percent)
950      pct = r.pct
951      return r.cl.phase === 'stuck' ? { ...r.cl, phase: 'working', stuckReason: '', failStreak: 0 } : r.cl
952    })
953    runClock($)
954    return { result: `Progress noted: ${pct}%.` }
955  })
956
957  on('tool.call', { tool: 'mcp__session-panel__ask_choices' }, async ($, e) => {
958    if (!(await read($, enabledA)) || !(await read($, askEnabledA))) return { result: 'ask_choices is off. Ask this question with AskUserQuestion instead.' }
959    if (await read($, askRuleA)) return { result: 'The question box is on: ask this with AskUserQuestion instead.' }
960    const args = e as { question?: unknown; options?: unknown }
961    // A helper cannot put a question on the person's screen: no Needs you, no bell, no picker.
962    if (e.agentId !== undefined) {
963      return { result: "Helpers can't ask the user. Put the question and its options in your final result so the agent that launched you can ask." }
964    }
965    const question = cleanName(typeof args.question === 'string' ? args.question.replace(/`[^`]*`/g, '') : '').replace(/…$/, '')
966    const options = Array.isArray(args.options) ? args.options.filter((o): o is string => typeof o === 'string').slice(0, 4) : []
967    if (options.length < 2) return { result: 'Give a question and 2 to 4 options.' }
968    const q = typeof args.question === 'string' && args.question.trim() !== '' ? args.question.trim().slice(0, 160) : question
969    await needsYou($, q, { question: q, options })
970    return { result: 'The question is on the person’s screen. Stop now and wait for their reply.' }
971  })
972
973  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
974    await update($, askedA, () => true)
975    return next(e)
976  })
977
978  // ---------- the gate and the bookkeeping for every other tool ----------
979  on('tool.call', async ($, e, next) => {
980    if (!(await read($, enabledA)) || e.agentId !== undefined) return next(e)
981    const tool = String(e.tool)
982    const c = await read($, checklistA)
983    const isInJob = c.phase === 'working' || c.phase === 'needs-you' || c.phase === 'stuck'
984    // The gate counts its denials. A plan, or the end of the job, resets the count. After GATE_LIMIT denials in a row the
985    // tool goes through, so a session that cannot see plan_steps is never trapped.
986    if (!isInJob || c.hasPlan) await update($, gateDeniedA, () => 0)
987    if (isInJob && !c.hasPlan && !isGateExempt(tool)) {
988      const denied = (await read($, gateDeniedA)) + 1
989      await update($, gateDeniedA, () => denied)
990      if (denied <= GATE_LIMIT) return { deny: GATE_MESSAGE }
991      if (denied === GATE_LIMIT + 1) $.ui.toast('Session Panel: no plan_steps call after 3 tries, so tools are open.')
992    }
993
994    const isOurs = tool === PLAN || tool === REPORT || tool === ASK
995    if (!isOurs) {
996      await patch($, cl => ({
997        ...cl,
998        action: tool === 'ToolSearch' || tool === 'TodoWrite' || tool.startsWith('Task') ? cl.action : actionLabel(tool),
999      }))
1000    }
1001    if (tool === 'AskUserQuestion') {
1002      const qs = (e as { questions?: Array<{ question?: string }> }).questions
1003      await needsYou($, qs?.[0]?.question ?? 'Claude has a question for you')
1004    }
1005
1006    const res = await next(e)
1007    await recordEdits($, e, res)
1008    if (isOurs) return res
1009
1010    const isFailure = res.deny === undefined && res.isError === true
1011    if (res.deny === undefined && !isFailure) {
1012      if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') {
1013        const file = String((e as { file_path?: string; notebook_path?: string }).file_path ?? (e as { notebook_path?: string }).notebook_path ?? '')
1014        if (file !== '') await patch($, cl => (cl.changedFiles.includes(file) ? cl : { ...cl, changedFiles: [...cl.changedFiles, file] }))
1015      }
1016      if (tool === 'TodoWrite') await patch($, cl => applyTodos(cl, (e as { todos?: unknown }).todos))
1017      if (tool === 'TaskCreate') {
1018        const m = /#?(\d+)/.exec(res.text ?? '')
1019        await patch($, cl => applyTaskCreate(cl, (e as { subject?: unknown }).subject, m?.[1] ?? String(cl.tasks.length + 1)))
1020      }
1021      if (tool === 'TaskUpdate') await patch($, cl => applyTaskUpdate(cl, e as { taskId?: unknown; status?: unknown; subject?: unknown }))
1022    }
1023    await patch($, cl => {
1024      let out: CleanChecklist = cl
1025      if (tool === 'AskUserQuestion' || (cl.phase === 'needs-you' && cl.question === null)) {
1026        out = { ...out, phase: 'working', needsYouReason: '', isBelled: false }
1027      }
1028      if (res.deny !== undefined) return out
1029      if (!isFailure) {
1030        return out.phase === 'stuck' ? { ...out, phase: 'working', stuckReason: '', failStreak: 0 } : { ...out, failStreak: 0 }
1031      }
1032      if (looksLikeUserDenial(res.text ?? '')) {
1033        return { ...out, phase: 'stuck', stuckReason: 'you said no to a step, so Claude paused' }
1034      }
1035      const streak = out.failStreak + 1
1036      return streak >= 3
1037        ? { ...out, failStreak: streak, phase: 'stuck', stuckReason: 'a step keeps failing, Claude is trying another way' }
1038        : { ...out, failStreak: streak }
1039    })
1040    return res
1041  })
1042
1043  // ---------- the end of a turn ----------
1044  // ---------- assumptions: what Claude states, and edits that state none ----------
1045  on('session.append', { door: 'response' }, async ($, e, next) => {
1046    const isOn = (await read($, enabledA)) && (await read($, prefsA)).assumptions
1047    if (isOn && (e as { agentId?: string }).agentId === undefined) {
1048      const text = responseText((e.message as { content?: unknown } | undefined)?.content)
1049      const now = await $.clock.now()
1050      await changeTrack($, t => addDeclared(t, parseAssumptions(text), now))
1051    }
1052    return next(e)
1053  })
1054
1055
1056  on('turn.complete', async ($, e, next) => {
1057    const result = await next(e)
1058    await finishAssumptions($, e)
1059    const proseQuestion = e.agentId === undefined && e.reason !== 'aborted' && e.reason !== 'error' && e.reason !== 'refusal' && asksUser(e.answer)
1060    if (proseQuestion && (await read($, enabledA)) && (await read($, askRuleA)) && (await read($, stopCheckA)) && !(await read($, askedA))) {
1061      $.ui.toast('Claude asked in prose instead of the question box. Turn the warning off in Settings → Questions if it is not wanted.')
1062    }
1063    if (e.agentId !== undefined || !(await read($, enabledA))) return result
1064    const now = await $.clock.now()
1065    const c = await read($, checklistA)
1066    if (c.phase === 'idle') return result
1067    if (e.reason === 'aborted') {
1068      stopClock()
1069      await patch($, cl => ({ ...cl, phase: 'stopped', finishedAt: now, action: '', question: null }))
1070    } else if (e.reason === 'error') {
1071      stopClock()
1072      await patch($, cl => ({ ...cl, phase: 'stuck', stuckReason: friendlyError(e.answer), finishedAt: now, action: '' }))
1073    } else if (e.reason === 'refusal') {
1074      stopClock()
1075      await patch($, cl => ({ ...cl, phase: 'stuck', stuckReason: "Claude couldn't help with that request", finishedAt: now, action: '' }))
1076    } else if (c.question !== null) {
1077      await needsYou($, c.question.question, c.question)
1078    } else if (c.hasPlan && c.tasks.some(t => t.status !== 'done') && asksUser(e.answer)) {
1079      // Unfinished steps only mean "waiting for you" when Claude actually asked something; a final answer with no
1080      // question means the job is done, even if the last step was never reported at 100.
1081      await needsYou($, 'Claude is waiting for your reply')
1082    } else {
1083      stopClock()
1084      await patch($, cl => ({
1085        ...cl,
1086        phase: 'done',
1087        finishedAt: now,
1088        action: '',
1089        isCollapsed: false,
1090        tasks: cl.tasks.map(t => ({ ...t, status: 'done' as const, percent: 100, hasReported: true })),
1091      }))
1092      const seconds = Math.max(0, Math.round((now - c.startedAt) / 1000))
1093      await update($, finishedA, f => [...f, { id: `j${c.jobId}`, title: c.title === '' ? 'Working on it' : c.title, seconds, steps: c.tasks.map(t => t.name) }].slice(-3))
1094      doneTimer?.cancel()
1095      doneTimer = $.clock.after(5000, () => {
1096        void patch($, cl => (cl.phase === 'done' ? { ...cl, isCollapsed: true } : cl))
1097      })
1098    }
1099    return result
1100  })
1101
1102  // ---------- hide the noise ----------
1103  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
1104    if (!(await read($, enabledA))) return next(e)
1105    const { Box } = $.ui.resolve(e)
1106    return <Box display="none" />
1107  })
1108  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
1109    if (!(await read($, enabledA))) return next(e)
1110    const { Box } = $.ui.resolve(e)
1111    return <Box display="none" />
1112  })
1113  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
1114    if (!(await read($, enabledA))) return next(e)
1115    const { Box } = $.ui.resolve(e)
1116    return <Box display="none" />
1117  })
1118  on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
1119    if (!(await read($, enabledA))) return next(e)
1120    return next({ ...e, props: { ...e.props, hint: '' } })
1121  })
1122
1123  // ---------- the band ----------
1124  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1125    if (e.props.hasSurvey) return next(e)
1126    const { Box, Text, Button } = $.ui.resolve(e)
1127    // Another mod's band (below us in the chain) stays on screen: it goes above ours, in a column.
1128    let rest: Awaited<ReturnType<typeof next>> | undefined
1129    try {
1130      rest = await next(e)
1131    } catch {
1132      rest = undefined
1133    }
1134    const stack = (own: RenderElement): RenderElement =>
1135      rest ? (
1136        <Box flexDirection="column">
1137          {rest}
1138          {own}
1139        </Box>
1140      ) : (
1141        own
1142      )
1143    try {
1144    const enabled = await read($, enabledA)
1145    const cl = await read($, checklistA)
1146    const tick = await read($, tickA)
1147    const now = await $.clock.now()
1148    const isPlain = ((await $.env.get('NO_COLOR')) ?? '') !== ''
1149    const isStill =
1150      ((await $.env.get('NO_MOTION')) ?? '') !== '' ||
1151      ((await $.env.get('REDUCE_MOTION')) ?? '') !== '' ||
1152      ((await $.env.get('PREFERS_REDUCED_MOTION')) ?? '') !== ''
1153    const color = (c: string): string | undefined => (isPlain ? undefined : c)
1154    // The whole mod sits in one rounded frame so it never blends into another mod's band. Inside it, `width` is the
1155    // room left after the two border cells and one cell of padding on each side.
1156    const outer = e.props.bodyColumns
1157    const width = Math.max(20, outer - 4)
1158    const files = cl.changedFiles.length
1159    const elapsed = formatDuration((cl.finishedAt > 0 ? cl.finishedAt : now) - cl.startedAt)
1160    const style = await read($, styleA)
1161    const finished = await read($, finishedA)
1162    const askOn = await read($, askEnabledA)
1163    const askRule = await read($, askRuleA)
1164    const stopCheck = await read($, stopCheckA)
1165    const settingsOpen = await read($, settingsOpenA)
1166    const settingsTab = await read($, settingsTabA)
1167    const settingsPage = await read($, settingsPageA)
1168    const prefs = await read($, prefsA)
1169    const enh = await read($, enhancerA)
1170    const version = await read($, versionA)
1171    const track = await read($, trackA)
1172    const hiddenCols = await read($, hiddenColsA)
1173    const reveal = await read($, revealA)
1174    const expanded = await read($, expandedA)
1175
1176    // Coloured words that carry the state: a filled pill for what is on or chosen, plain dim words for the rest.
1177    const pillOf = (label: string, bg: string) => (
1178      <Text weight="bold" inverse={isPlain} backgroundColor={isPlain ? undefined : bg} color={isPlain ? undefined : 'white'}>
1179        {` ${label} `}
1180      </Text>
1181    )
1182
1183    // Settings are data: add an entry here and it shows in the dropdown. A toggle is on or off; a choice picks one value.
1184    type Setting =
1185      | { id: string; group: string; label: string; hint: string; kind: 'toggle'; isOn: boolean; set: (v: boolean) => void }
1186      | { id: string; group: string; label: string; hint: string; kind: 'button'; press: () => void; action: string }
1187      | { id: string; group: string; label: string; hint: string; kind: 'choice'; value: string; options: Array<{ value: string; label: string }>; set: (v: string) => void }
1188    const settings: Setting[] = [
1189      {
1190        id: 'details',
1191        group: 'band',
1192        label: 'Hide tool details',
1193        hint: 'Show a step checklist instead of every tool call',
1194        kind: 'toggle',
1195        isOn: enabled,
1196        set: v => void setEnabled($, v),
1197      },
1198      {
1199        id: 'ask',
1200        group: 'questions',
hooks/enhancer.ts 271 lines
1import type { EnhancerState, Prefs } from '../types'
2import { SOURCE_CAP, enhancePrompt, parseEnhanced, recentExcerpt } from './logic'
3
4/**
5 * What the enhancer needs from the engine. The band builds this from `$` in session-panel.tsx: `$` itself cannot be
6 * passed across an import, so each call the enhancer makes is a plain function here.
7 */
8export type EnhancerIo = {
9  home: () => Promise<string>
10  exists: (path: string) => Promise<boolean>
11  read: (path: string) => Promise<string>
12  mtime: (path: string) => Promise<number>
13  list: (path: string) => Promise<Array<{ name: string; kind: string }>>
14  ancestors: (names: string[]) => Promise<Array<{ dir: string; name: string; content: string }>>
15  cacheGet: (key: string) => Promise<{ mtimeMs: number; text: string } | undefined>
16  cacheSet: (key: string, value: { mtimeMs: number; text: string }) => Promise<void>
17  /** runs `gh` with these arguments; undefined when gh cannot run */
18  gh: (args: string[]) => Promise<{ exitCode: number; stdout: string } | undefined>
19  messages: () => Promise<Array<{ role: string; text: string }>>
20  fork: (prompt: string) => Promise<{ isAnswered: true; text: string } | { isAnswered: false; reason: string }>
21  complete: (model: string, prompt: string) => Promise<{ isAnswered: true; text: string } | { isAnswered: false }>
22  askUser: (question: string, options: string[]) => Promise<string>
23}
24
25export type Outcome =
26  | { kind: 'off' }
27  | { kind: 'empty' }
28  | { kind: 'failed'; reason: string }
29  | { kind: 'filled'; prompt: string; notes: string[]; via: string }
30
31/** A digest of one file, reused while its mtime is unchanged. */
32async function cached(io: EnhancerIo, path: string, load: () => Promise<string>): Promise<string> {
33  const mtimeMs = await io.mtime(path)
34  const key = `digest:${path}`
35  const hit = await io.cacheGet(key)
36  if (hit && hit.mtimeMs === mtimeMs) return hit.text
37  const text = (await load()).slice(0, SOURCE_CAP)
38  await io.cacheSet(key, { mtimeMs, text })
39  return text
40}
41
42/** Reads the enabled sources. A source that fails is skipped, never fatal. */
43export async function buildContext(io: EnhancerIo, sources: Prefs['sources']): Promise<{ text: string; used: string[] }> {
44  const parts: string[] = []
45  const used: string[] = []
46  const add = (label: string, body: string) => {
47    if (body.trim() === '') return
48    parts.push(`## ${label}\n${body}`)
49    used.push(label)
50  }
51  // Forward slashes only: a backslash inside a template literal is dropped (the probe found this).
52  const home = (await io.home()).replace(/\\/g, '/')
53
54  if (sources.instructions) {
55    try {
56      const globalPath = `${home}/.claude/CLAUDE.md`
57      if (await io.exists(globalPath)) add('Global CLAUDE.md', await cached(io, globalPath, () => io.read(globalPath)))
58    } catch {
59      // skipped
60    }
61    try {
62      for (const f of await io.ancestors(['CLAUDE.md', 'AGENTS.md'])) {
63        add(`${f.name} (${f.dir})`, await cached(io, `${f.dir}/${f.name}`, async () => f.content))
64      }
65    } catch {
66      // skipped
67    }
68  }
69
70  if (sources.skills) {
71    try {
72      const names = (await io.list(`${home}/.claude/skills`)).filter(e => e.kind === 'dir').map(e => e.name)
73      add('Skills', names.join(', '))
74    } catch {
75      // skipped
76    }
77    try {
78      const names = (await io.list('.claude/skills')).filter(e => e.kind === 'dir').map(e => e.name)
79      add('Project skills', names.join(', '))
80    } catch {
81      // no project skills folder: ENOENT counts as zero
82    }
83  }
84
85  if (sources.docs) {
86    for (const path of ['CONTEXT.md', 'PRD.md', 'GLOSSARY.md']) {
87      try {
88        if (await io.exists(path)) add(path, await cached(io, path, () => io.read(path)))
89      } catch {
90        // skipped
91      }
92    }
93    try {
94      const adrs = (await io.list('docs/adr')).filter(e => e.kind === 'file').map(e => e.name)
95      add('ADRs', adrs.join(', '))
96    } catch {
97      // no ADR folder
98    }
99  }
100
101  if (sources.github) {
102    // the open wayfinder map if there is one, else the recent issues; gh missing or not signed in gives nothing
103    const map = await githubMap(io)
104    if (map !== undefined) {
105      add('GitHub wayfinder map', map)
106    } else {
107      const r = await io.gh(['issue', 'list', '--limit', '10'])
108      if (r && r.exitCode === 0) add('GitHub recent issues (gh)', r.stdout.slice(0, SOURCE_CAP))
109    }
110  }
111
112  return { text: parts.join('\n\n'), used }
113}
114
115/** Reads the recent turns, or nothing when the chat route is the full chat. A failed read is no excerpt. */
116async function messagesOrEmpty(io: EnhancerIo): Promise<Array<{ role: string; text: string }>> {
117  try {
118    return await io.messages()
119  } catch {
120    return []
121  }
122}
123
124/**
125 * One model answer. "recent" sends the docs and the last turns to complete() on the chosen model. "full" forks the
126 * session (its own model and transcript), and falls back to complete() only when there is nothing to fork yet.
127 */
128async function answer(io: EnhancerIo, prefs: Prefs, draft: string, context: string, answers: string[]): Promise<{ text: string; via: string } | null> {
129  if (prefs.chat === 'recent') {
130    const prompt = enhancePrompt(draft, context, answers, recentExcerpt(await messagesOrEmpty(io)))
131    const r = await io.complete(prefs.model, prompt)
132    return r.isAnswered ? { text: r.text, via: `complete:${prefs.model}` } : null
133  }
134  const prompt = enhancePrompt(draft, context, answers)
135  const fork = await io.fork(prompt)
136  if (fork.isAnswered) return { text: fork.text, via: 'fork' }
137  if (fork.reason !== 'nothing-to-fork') return null
138  const plain = await io.complete(prefs.model, prompt)
139  return plain.isAnswered ? { text: plain.text, via: `complete:${prefs.model}` } : null
140}
141
142/** Enhances a draft. Asks up to three questions first when the draft is vague. */
143export async function enhance(io: EnhancerIo, draft: string, prefs: Prefs): Promise<Outcome> {
144  if (!prefs.enhancerOn) return { kind: 'off' }
145  if (draft.trim() === '') return { kind: 'empty' }
146  const context = await buildContext(io, prefs.sources)
147  const first = await answer(io, prefs, draft, context.text, [])
148  let parsed = first === null ? null : parseEnhanced(first.text)
149  if (!parsed) return { kind: 'failed', reason: 'the model gave no usable reply' }
150  let via = first!.via
151
152  const answers: string[] = []
153  try {
154    for (const q of parsed.questions) {
155      answers.push(`${q.question} ${await io.askUser(q.question, q.options)}`)
156    }
157  } catch {
158    // no picker in this session (for example `claude -p`): stop rather than guess the answers
159    return { kind: 'failed', reason: 'it needs answers, and no picker is open in this session' }
160  }
161  if (answers.length > 0) {
162    const second = await answer(io, prefs, draft, context.text, answers)
163    parsed = second === null ? null : parseEnhanced(second.text)
164    if (!parsed) return { kind: 'failed', reason: 'the model gave no usable reply after the questions' }
165    via = second!.via
166  }
167  if (parsed.prompt === '') return { kind: 'failed', reason: 'the model returned no prompt' }
168
169  const notes = [...parsed.notes]
170  if (context.used.length > 0 && notes.length < 4) notes.push(`Sources: ${context.used.join(', ')}`)
171  return { kind: 'filled', prompt: parsed.prompt, notes, via }
172}
173
174/** The defaults before the first-run setup has saved anything. */
175export const DEFAULT_PREFS: Prefs = {
176  enhancerOn: true,
177  autoEnhance: false,
178  model: 'haiku',
179  chat: 'recent',
180  sources: { instructions: true, skills: true, docs: true, github: false },
181  assumptions: true,
182  flagUndeclared: true,
183  decide: false,
184  decideThreshold: 0.5,
185  jev: false,
186}
187
188/** The enhancer's engine calls plus the band's box and state, as plain functions, so the actions can be tested. */
189export type EnhancerDeps = EnhancerIo & {
190  prefs: () => Promise<Prefs>
191  state: () => Promise<EnhancerState>
192  setState: (patch: Partial<EnhancerState>) => Promise<void>
193  readBox: () => Promise<string>
194  fillBox: (text: string) => Promise<void>
195  submitBox: (text: string) => Promise<void>
196  toast: (text: string) => void
197}
198
199const NO_DRAFT: Partial<EnhancerState> = { original: null, passText: null, notes: [] }
200
201/**
202 * Enhances a draft and fills the box with the result. The draft is the box, unless the caller passes one: the
203 * auto-enhance hook passes the prompt being submitted, because that prompt is not in the box at that point.
204 */
205export async function runEnhance(d: EnhancerDeps, draft?: string): Promise<string> {
206  const prefs = await d.prefs()
207  if (!prefs.enhancerOn) return 'The enhancer is off. Open Settings to turn it on.'
208  const text = draft ?? (await d.readBox())
209  if (text.trim() === '') return 'The box is empty. Type a draft first.'
210  await d.setState({ busy: true })
211  try {
212    const r = await enhance(d, text, prefs)
213    if (r.kind !== 'filled') return r.kind === 'failed' ? `Enhancer: ${r.reason}.` : 'Nothing to enhance.'
214    await d.setState({ original: text, passText: r.prompt, notes: r.notes })
215    await d.fillBox(r.prompt)
216    return 'Enhanced. Check the box, then Send.'
217  } catch {
218    return 'Enhancer: something went wrong. The box is unchanged.'
219  } finally {
220    await d.setState({ busy: false })
221  }
222}
223
224export async function undoEnhance(d: EnhancerDeps): Promise<void> {
225  const st = await d.state()
226  if (st.original === null) return
227  await d.fillBox(st.original)
228  await d.setState(NO_DRAFT)
229}
230
231/** Sends the box as the person's own words. The enhanced text passes the submit hook once, so the draft is cleared after. */
232export async function sendBox(d: EnhancerDeps): Promise<void> {
233  const text = await d.readBox()
234  if (text.trim() === '') {
235    d.toast('The box is empty.')
236    return
237  }
238  await d.submitBox(text)
239  await d.setState(NO_DRAFT)
240}
241
242function parseList(text: string): unknown[] | undefined {
243  try {
244    const v = JSON.parse(text)
245    return Array.isArray(v) ? v : undefined
246  } catch {
247    return undefined
248  }
249}
250
251/**
252 * The open wayfinder map: its body, with the titles of its open child tickets (GitHub sub-issues). Undefined when there
253 * is no open map, or gh cannot read it. The text is capped at SOURCE_CAP, and the child titles are kept whole.
254 */
255export async function githubMap(io: EnhancerIo): Promise<string | undefined> {
256  const found = await io.gh(['issue', 'list', '--label', 'wayfinder:map', '--state', 'open', '--limit', '1', '--json', 'number,title,body,url'])
257  if (!found || found.exitCode !== 0) return undefined
258  const map = parseList(found.stdout)?.[0] as { number: number; title: string; body?: string; url: string } | undefined
259  if (!map) return undefined
260  const repo = map.url.match(/github\.com\/([^/]+\/[^/]+)\/issues\//)?.[1]
261  let children: string[] = []
262  if (repo !== undefined) {
263    const subs = await io.gh(['api', `repos/${repo}/issues/${map.number}/sub_issues`, '--jq', '[.[] | select(.state == "open") | .title]'])
264    if (subs && subs.exitCode === 0) children = (parseList(subs.stdout) ?? []).filter((c): c is string => typeof c === 'string')
265  }
266  const head = `# ${map.title}\n`
267  const kids = children.length > 0 ? `\nOpen child tickets:\n${children.map(c => `- ${c}`).join('\n')}\n` : ''
268  const room = Math.max(0, SOURCE_CAP - head.length - kids.length)
269  return head + (map.body ?? '').slice(0, room) + kids
270}
271
hooks/logic.ts 1229 lines
1import type { AssumptionTrack, CleanChecklist, CleanTask } from '../types'
2
3// Pure helpers for Session Panel: no engine calls here, so tests can use them directly.
4
5export const MAX_NAME = 40
6export const MAX_STEPS = 8
7export const METER_CELLS = 10
8
9const CODE_EXT =
10  /\.(?:tsx?|jsx?|mjs|cjs|py|rb|go|rs|java|cs|cpp|c|h|php|sh|ps1|json|ya?ml|toml|md|css|scss|html?|xml|sql|txt|csv|lock|docx?|xlsx?|pptx?)$/i
11
12/** One cleaner for every name: no code, no paths, no file names, 40 characters at most. */
13export function cleanName(raw: unknown): string {
14  const text = typeof raw === 'string' ? raw : ''
15  const withoutCode = text.replace(/`[^`]*`/g, ' ')
16  const words = withoutCode
17    .split(/\s+/)
18    .filter(w => w !== '' && !/[\\/]/.test(w) && !CODE_EXT.test(w.replace(/[.,;:!?)"'\]]+$/, '')))
19  let name = words.join(' ').trim()
20  if (name === '') return 'Working on it'
21  name = name.charAt(0).toUpperCase() + name.slice(1)
22  if (name.length > MAX_NAME) {
23    let cut = name.slice(0, MAX_NAME - 1)
24    const space = cut.lastIndexOf(' ')
25    if (space > 8) cut = cut.slice(0, space)
26    name = cut.trimEnd().replace(/[,.;:\-]+$/, '') + '…'
27  }
28  return name
29}
30
31/** A 2 to 6 word job name from the model's answer. */
32export function cleanTitle(raw: unknown): string {
33  const first = String(raw ?? '').split('\n')[0] ?? ''
34  const words = cleanName(first.replace(/["“”]/g, '')).replace(/…$/, '').split(' ').slice(0, 6)
35  return cleanName(words.join(' '))
36}
37
38export function formatDuration(ms: number): string {
39  const total = Math.max(0, Math.round(ms / 1000))
40  const m = Math.floor(total / 60)
41  const s = total % 60
42  return m > 0 ? `${m}m ${s}s` : `${s}s`
43}
44
45export function meter(percent: number, cells = METER_CELLS): string {
46  const filled = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
47  return '█'.repeat(filled) + '░'.repeat(cells - filled)
48}
49
50/** A moving three-cell block, or a static bar when motion is off. */
51export function sweep(frame: number, isStill: boolean, cells = METER_CELLS): string {
52  if (isStill) return '▒'.repeat(cells)
53  const span = cells + 2
54  const pos = (frame % span) - 2
55  let out = ''
56  for (let i = 0; i < cells; i++) out += i >= pos && i < pos + 3 ? '▓' : '░'
57  return out
58}
59
60export function actionLabel(tool: string): string {
61  if (tool === 'Read') return 'Reading a file'
62  if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') return 'Making changes'
63  if (tool === 'Bash' || tool === 'PowerShell') return 'Running a check'
64  if (tool === 'Grep' || tool === 'Glob') return 'Looking through files'
65  if (tool === 'WebSearch' || tool === 'WebFetch') return 'Searching the web'
66  if (tool === 'Agent') return 'Getting help from a helper'
67  return 'Working'
68}
69
70/** One calm sentence for a failed turn, from the text the engine ended it with. */
71export function friendlyError(text: string): string {
72  const t = text.toLowerCase()
73  if (/rate.?limit|usage limit|429|quota/.test(t)) return 'you hit your usage limit, try again a little later'
74  if (/overload|529|servers? (are )?busy|503/.test(t)) return "Claude's servers are busy, try again in a minute"
75  if (/context|too long|prompt is too long|token limit/.test(t)) return 'type /compact and try again'
76  if (/network|connection|econn|enotfound|timed? ?out|offline|fetch failed/.test(t)) return 'the internet connection dropped'
77  if (/auth|login|api key|401|unauthor|forbidden|403/.test(t)) return 'type /login'
78  return 'something went wrong, try again in a moment'
79}
80
81export function newChecklist(): CleanChecklist {
82  return {
83    title: '',
84    phase: 'idle',
85    tasks: [],
86    needsYouReason: '',
87    stuckReason: '',
88    startedAt: 0,
89    finishedAt: 0,
90    isCollapsed: false,
91    jobId: 0,
92    hasPlan: false,
93    action: '',
94    question: null,
95    changedFiles: [],
96    failStreak: 0,
97    isBelled: false,
98    bar: 0,
99  }
100}
101
102function task(id: string, name: string, status: CleanTask['status']): CleanTask {
103  return { id, name: cleanName(name), status, percent: 0, hasReported: false, shown: 0 }
104}
105
106export function startJob(prev: CleanChecklist, now: number): CleanChecklist {
107  return {
108    ...newChecklist(),
109    title: 'Working on it',
110    phase: 'working',
111    tasks: [task('s1', 'Understand your request', 'active'), task('s2', 'Plan the steps', 'upcoming')],
112    startedAt: now,
113    jobId: prev.jobId + 1,
114  }
115}
116
117/** plan_steps: the first step starts at once. */
118export function planSteps(cl: CleanChecklist, names: unknown): CleanChecklist | null {
119  if (!Array.isArray(names)) return null
120  const list = names.filter((n): n is string => typeof n === 'string').slice(0, MAX_STEPS)
121  if (list.length < 1) return null
122  const tasks = list.map((n, i) => task(`p${i + 1}`, n, i === 0 ? 'active' : 'upcoming'))
123  return { ...cl, tasks, hasPlan: true, phase: cl.phase === 'idle' ? 'working' : cl.phase, isCollapsed: false }
124}
125
126function norm(s: string): string {
127  return s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
128}
129
130/** A reported percent as 0 to 100: a number, rounded, clamped; anything else counts as 0. */
131export function clampPercent(percent: unknown): number {
132  const raw = typeof percent === 'number' && Number.isFinite(percent) ? percent : Number(percent)
133  return Math.max(0, Math.min(100, Number.isFinite(raw) ? Math.round(raw) : 0))
134}
135
136/** report_progress: checks off earlier steps, holds percent, starts the next step at 100. */
137export function reportProgress(cl: CleanChecklist, name: unknown, percent: unknown): { cl: CleanChecklist; pct: number } {
138  const pct = clampPercent(percent)
139  const wanted = norm(cleanName(name))
140  const tasks: CleanTask[] = cl.tasks.map(t => ({ ...t }))
141  let idx = tasks.findIndex(t => norm(t.name) === wanted)
142  if (idx < 0) idx = tasks.findIndex(t => norm(t.name).startsWith(wanted) || wanted.startsWith(norm(t.name)))
143  if (idx < 0) {
144    // An unknown name ticks nothing off: it goes in right after the current step and becomes the active one.
145    const was = tasks.findIndex(t => t.status === 'active')
146    const fresh = task(`n${tasks.length + 1}`, String(name ?? ''), 'active')
147    fresh.hasReported = true
148    fresh.percent = pct
149    if (was >= 0) tasks[was]!.status = 'upcoming'
150    if (pct >= 100) {
151      fresh.status = 'done'
152      if (was >= 0) tasks[was]!.status = 'active'
153    }
154    tasks.splice(was >= 0 ? was + 1 : tasks.length, 0, fresh)
155    return { cl: { ...cl, tasks, hasPlan: true, phase: cl.phase === 'idle' ? 'working' : cl.phase }, pct }
156  }
157  for (let i = 0; i < idx; i++) {
158    const t = tasks[i]!
159    t.status = 'done'
160    t.percent = 100
161    t.hasReported = true
162  }
163  const cur = tasks[idx]!
164  cur.hasReported = true
165  cur.percent = Math.max(cur.percent, pct)
166  if (cur.percent >= 100) {
167    cur.status = 'done'
168    const next = tasks.slice(idx + 1).find(t => t.status !== 'done')
169    if (next) next.status = 'active'
170  } else {
171    cur.status = 'active'
172    for (let i = idx + 1; i < tasks.length; i++) if (tasks[i]!.status === 'active') tasks[i]!.status = 'upcoming'
173  }
174  return {
175    cl: { ...cl, tasks, hasPlan: true, phase: cl.phase === 'idle' ? 'working' : cl.phase },
176    pct,
177  }
178}
179
180type Todo = { content?: unknown; status?: unknown }
181
182/** TodoWrite as checklist rows. */
183export function applyTodos(cl: CleanChecklist, todos: unknown): CleanChecklist {
184  if (!Array.isArray(todos) || todos.length === 0) return cl
185  const old = new Map(cl.tasks.map(t => [norm(t.name), t]))
186  let sawActive = false
187  const tasks = (todos as Todo[]).map((t, i) => {
188    const status: CleanTask['status'] =
189      t.status === 'completed' ? 'done' : t.status === 'in_progress' && !sawActive ? 'active' : 'upcoming'
190    if (status === 'active') sawActive = true
191    const row = task(`t${i + 1}`, String(t.content ?? ''), status)
192    const before = old.get(norm(row.name))
193    if (before) {
194      row.percent = status === 'done' ? 100 : before.percent
195      row.hasReported = before.hasReported || status === 'done'
196      row.shown = before.shown
197    } else if (status === 'done') {
198      row.percent = 100
199      row.hasReported = true
200    }
201    return row
202  })
203  if (!sawActive) {
204    const first = tasks.find(t => t.status === 'upcoming')
205    if (first) first.status = 'active'
206  }
207  return { ...cl, tasks, hasPlan: true }
208}
209
210export function applyTaskCreate(cl: CleanChecklist, subject: unknown, id: string): CleanChecklist {
211  const fresh = cl.hasPlan ? cl.tasks : []
212  const t = task(id, String(subject ?? ''), fresh.some(x => x.status === 'active') ? 'upcoming' : 'active')
213  return { ...cl, tasks: [...fresh, t], hasPlan: true }
214}
215
216export function applyTaskUpdate(cl: CleanChecklist, args: { taskId?: unknown; status?: unknown; subject?: unknown }): CleanChecklist {
217  const id = String(args.taskId ?? '')
218  let tasks = cl.tasks.map(t => ({ ...t }))
219  const t = tasks.find(x => x.id === id)
220  if (!t) return cl
221  if (typeof args.subject === 'string') t.name = cleanName(args.subject)
222  if (args.status === 'completed') {
223    t.status = 'done'
224    t.percent = 100
225    t.hasReported = true
226    if (!tasks.some(x => x.status === 'active')) {
227      const next = tasks.find(x => x.status === 'upcoming')
228      if (next) next.status = 'active'
229    }
230  } else if (args.status === 'in_progress') {
231    tasks.forEach(x => {
232      if (x.status === 'active') x.status = 'upcoming'
233    })
234    t.status = 'active'
235  } else if (args.status === 'deleted') {
236    tasks = tasks.filter(x => x.id !== id)
237  }
238  return { ...cl, tasks }
239}
240
241/** The whole job as one percent: finished steps count full, the others by what was reported. */
242export function jobPercent(cl: CleanChecklist): number {
243  if (cl.phase === 'done') return 100
244  if (cl.tasks.length === 0) return 0
245  const sum = cl.tasks.reduce((n, t) => n + (t.status === 'done' ? 100 : t.percent), 0)
246  return Math.max(0, Math.min(100, Math.round(sum / cl.tasks.length)))
247}
248
249/** One 250 ms step of easing: meters move toward their percent and never back. */
250export function easeStep(cl: CleanChecklist): CleanChecklist {
251  let changed = false
252  const tasks = cl.tasks.map(t => {
253    const target = t.status === 'done' ? 100 : t.percent
254    if (t.shown >= target) return t
255    changed = true
256    const step = Math.max(2, Math.ceil((target - t.shown) * 0.35))
257    return { ...t, shown: Math.min(target, t.shown + step) }
258  })
259  let bar = cl.bar
260  const goal = jobPercent({ ...cl, tasks })
261  if (bar < goal) {
262    changed = true
263    bar = Math.min(goal, bar + Math.max(1, Math.ceil((goal - bar) * 0.35)))
264  }
265  return changed ? { ...cl, tasks, bar } : cl
266}
267
268/** The bar style's cells: a dotted fill that drifts while live, and an empty rest. */
269export function barText(percent: number, cells: number, frame: number, isLive: boolean): { filled: string; rest: string } {
270  const n = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
271  let filled = ''
272  for (let i = 0; i < n; i++) filled += (i + (isLive ? frame : 0)) % 2 === 0 ? '▓' : '▒'
273  return { filled, rest: '░'.repeat(cells - n) }
274}
275
276export type BarSegment = { text: string; kind: 'fill' | 'rest' | 'label' }
277
278/** The bar with its words inside: dotted fill, empty rest, and a label that rides the edge of the fill. */
279export function barSegments(percent: number, cells: number, frame: number, isLive: boolean, label: string): BarSegment[] {
280  const base = barText(percent, cells, frame, isLive)
281  const chars = [...base.filled, ...base.rest]
282  const n = base.filled.length
283  const text = label.trim() === '' ? '' : ` ${shortName(label.trim(), Math.max(1, cells - 4))} `
284  const len = text.length
285  const at = len === 0 || len >= cells ? -1 : Math.max(2, Math.min(cells - len - 1, n - len))
286  const out: BarSegment[] = []
287  const push = (t: string, kind: BarSegment['kind']) => {
288    const last = out[out.length - 1]
289    if (last && last.kind === kind) last.text += t
290    else out.push({ text: t, kind })
291  }
292  for (let i = 0; i < chars.length; i++) {
293    if (at >= 0 && i === at) {
294      push(text, 'label')
295      i += len - 1
296    } else push(chars[i]!, i < n ? 'fill' : 'rest')
297  }
298  return out
299}
300
301/** The steps of a finished job; jobs saved by older versions have none. */
302export function stepsOf(f: { steps?: string[] }): string[] {
303  return Array.isArray(f.steps) ? f.steps : []
304}
305
306export function shortName(name: string, max: number): string {
307  return name.length <= max ? name : name.slice(0, Math.max(1, max - 1)).trimEnd() + '…'
308}
309
310/** True when the end of Claude's answer asks the person something. */
311export function asksUser(answer: unknown): boolean {
312  const tail = String(answer ?? '').trim().slice(-300)
313  if (tail === '') return false
314  if (/[??]/.test(tail)) return true
315  return /\b(let me know|shall i|should i|would you like|do you want|want me to|which (one|option)|please confirm|waiting for your)\b/i.test(tail)
316}
317
318export function stepNumber(cl: CleanChecklist): number {
319  const i = cl.tasks.findIndex(t => t.status === 'active')
320  if (i >= 0) return i + 1
321  return Math.min(cl.tasks.length, cl.tasks.filter(t => t.status === 'done').length + 1)
322}
323
324/** Stable height: past 5 rows, finished steps fold into one line. */
325export function visibleRows(cl: CleanChecklist): { doneCount: number; rows: CleanTask[] } {
326  if (cl.tasks.length <= 5) return { doneCount: 0, rows: cl.tasks }
327  const done = cl.tasks.filter(t => t.status === 'done')
328  return { doneCount: done.length, rows: cl.tasks.filter(t => t.status !== 'done') }
329}
330
331export function fitName(name: string, width: number): string {
332  if (name.length <= width) return name.padEnd(width, ' ')
333  return name.slice(0, Math.max(1, width - 1)) + '…'
334}
335
336const EXEMPT = new Set([
337  'ToolSearch',
338  'TodoWrite',
339  'TaskCreate',
340  'TaskUpdate',
341  'AskUserQuestion',
342  'mcp__session-panel__plan_steps',
343  'mcp__session-panel__report_progress',
344  'mcp__session-panel__ask_choices',
345])
346
347export function isGateExempt(tool: string): boolean {
348  return EXEMPT.has(tool)
349}
350
351/** Factual, not an order: a tool result is data, so the rule itself lives in the system section (PLAN_SECTION). */
352export const GATE_MESSAGE =
353  'Blocked by the Session Panel mod until this session calls mcp__session-panel__plan_steps (see the Session Panel system section).'
354
355/** Denied calls in a row, with no plan_steps call, before the gate lets tools through. Never traps a session. */
356export const GATE_LIMIT = 3
357
358/** The first-tool rule, from the system prompt. It is not in a tool result, so it is not treated as an instruction from one. */
359export const PLAN_SECTION = `Session Panel rule for this session: at the start of each job, before any other tool, call mcp__session-panel__plan_steps with 1 to 8 short plain-English step names. The person sees these steps as their progress checklist. If plan_steps is not in your tool list, load it with ToolSearch. If it is still missing, skip the plan and continue the work.`
360
361export function looksLikeUserDenial(text: string): boolean {
362  return /doesn.?t want to proceed|rejected|permission (to use|for) .* (was )?denied|user denied|declined to/i.test(text)
363}
364
365const SECTION_BASE = `Session Panel is on for this session. The person you are helping is not technical and sees a step checklist instead of tool calls.
366- For EVERY request, even a quick question, call plan_steps first (load it with ToolSearch if it is deferred). Use 1 step for a quick question, 2 to 8 for bigger work.
367- Step names are plain English, under 40 characters, and start with a verb, for example "Build the pricing section". Never put file paths, file names, commands, code or tool names in a step name.
368- Then call report_progress as real progress happens, with the same step name, and call it with percent 100 the moment a step finishes.
369- If you have TodoWrite or TaskCreate you may use that to-do list as the plan instead.
370- Keep written replies short, warm and free of jargon.`
371
372const ASK_CHOICES_LINE =
373  "- Whenever you need the person's input, including quick questions, call ask_choices with 2 to 4 options, the recommended option first, instead of asking in prose. Then stop and wait for their reply."
374
375const QUESTION_BOX_LINE =
376  "- Ask with AskUserQuestion, never in prose, whenever you need the person's go-ahead: permission to act (edits, deletes, sends, pushes, installs), or a choice that changes scope, cost, files or direction. Do not ask about routine details; decide them and state them as ASSUMPTION lines. Then stop and wait for the reply."
377
378/** The rules section. The question-box rule replaces the picker line when it is on; otherwise the picker is used when it is on. */
379export function rulesSection(questionBox: boolean, picker: boolean): string {
380  if (questionBox) return `${SECTION_BASE}\n${QUESTION_BOX_LINE}`
381  if (picker) return `${SECTION_BASE}\n${ASK_CHOICES_LINE}`
382  return SECTION_BASE
383}
384
385/**
386 * The values to copy from the old clean-view settings row into this plugin's store. The row keyed with the
387 * marketplace suffix wins, then the plain key. A store key that already holds a value is left alone.
388 */
389export function migratedValues(
390  rows: Record<string, { options?: Record<string, unknown> }> | undefined,
391  stored: { sessionPanelEnabled?: unknown; sessionPanelStyle?: unknown; askChoicesEnabled?: unknown },
392): Record<string, unknown> {
393  const old = rows?.['clean-view@herman-mods']?.options ?? rows?.['clean-view']?.options ?? {}
394  const out: Record<string, unknown> = {}
395  if (stored.sessionPanelEnabled === undefined && (old.cleanView === 'on' || old.cleanView === 'off')) {
396    out.sessionPanelEnabled = old.cleanView === 'on'
397  }
398  if (stored.sessionPanelStyle === undefined && (old.view === 'bars' || old.view === 'list')) {
399    out.sessionPanelStyle = old.view === 'bars' ? 'bars' : 'checklist'
400  }
401  if (stored.askChoicesEnabled === undefined && (old.askChoices === 'on' || old.askChoices === 'off')) {
402    out.askChoicesEnabled = old.askChoices === 'on'
403  }
404  return out
405}
406
407/** Context sources the enhancer may read. Each one has its own toggle in Settings. */
408export const SOURCE_IDS = ['instructions', 'skills', 'docs', 'github'] as const
409export type SourceId = (typeof SOURCE_IDS)[number]
410
411export const SOURCE_LABELS: Record<SourceId, string> = {
412  instructions: 'CLAUDE.md and AGENTS.md (project and global)',
413  skills: 'Project skills list',
414  docs: 'CONTEXT.md, PRD, ADRs and glossary',
415  github: 'GitHub (wayfinder map, else recent issues)',
416}
417
418/** Characters kept from each source, so one source cannot crowd out the rest. */
419export const SOURCE_CAP = 4000
420
421/** Instructions for the model: rewrite the draft and return JSON only. */
422export function enhancePrompt(draft: string, context: string, answers: string[], chat = ''): string {
423  const answered = answers.length ? `The person answered these questions:\n${answers.join('\n')}\n` : ''
424  return [
425    'Rewrite the draft prompt below so the assistant can act on it, using the project context.',
426    'Reply with JSON only, no prose: {"prompt": string, "notes": string[], "questions": [{"question": string, "options": string[]}]}.',
427    '"notes": 2 to 4 short lines: what you added, and which context you used.',
428    '"questions": up to 3, only when the draft is too vague to act on. Each has 2 to 4 options. Otherwise [].',
429    'If you ask questions, "prompt" is "".',
430    answered,
431    'Project context:',
432    context || '(none)',
433    chat ? `Recent conversation:\n${chat}` : '',
434    '',
435    'Draft:',
436    draft,
437  ].join('\n')
438}
439
440export type Enhanced = { prompt: string; notes: string[]; questions: Array<{ question: string; options: string[] }> }
441
442/** Reads the model's JSON reply. Returns null when no usable object is in it. */
443export function parseEnhanced(text: string): Enhanced | null {
444  const found = text.match(/\{[\s\S]*\}/)
445  if (!found) return null
446  try {
447    const j = JSON.parse(found[0])
448    return {
449      prompt: typeof j.prompt === 'string' ? j.prompt.trim() : '',
450      notes: Array.isArray(j.notes) ? j.notes.filter((s: unknown) => typeof s === 'string').slice(0, 4) : [],
451      questions: Array.isArray(j.questions)
452        ? j.questions
453            .filter((q: any) => q && typeof q.question === 'string' && Array.isArray(q.options) && q.options.length >= 2)
454            .slice(0, 3)
455            .map((q: any) => ({ question: q.question, options: q.options.slice(0, 4).map(String) }))
456        : [],
457    }
458  } catch {
459    return null
460  }
461}
462
463/** The last six user and assistant turns, text only, each capped; the whole excerpt keeps its newest end. */
464export const EXCERPT_TURNS = 6
465export const EXCERPT_MESSAGE_CAP = 1500
466export const EXCERPT_CAP = 6000
467
468export function recentExcerpt(rows: Array<{ role: string; text: string }>): string {
469  const joined = rows
470    .filter(r => (r.role === 'user' || r.role === 'assistant') && r.text.trim() !== '')
471    .slice(-EXCERPT_TURNS)
472    .map(r => `${r.role}: ${r.text.trim().slice(0, EXCERPT_MESSAGE_CAP)}`)
473    .join('\n\n')
474  return joined.length > EXCERPT_CAP ? joined.slice(-EXCERPT_CAP) : joined
475}
476
477// ---------- assumptions (stage 2) ----------
478
479export const ASSUMPTION_CAP = 50
480export const ASSUMPTION_TEXT_CAP = 200
481
482/** The system-prompt rule that asks for ASSUMPTION lines. Kept here so the parser and the rule name the same form. */
483export const ASSUME_SECTION = `Session Panel rule for stated assumptions: when you make an assumption about what the person wants while you build, write it on its own line in exactly this form: ASSUMPTION: <one short sentence>. Write one line per assumption. Use this form only for assumptions, never for facts you checked. The line must start with plain ASSUMPTION: at the start of the line, with no bold, no bullet and no heading. Do not put ASSUMPTION lines inside code blocks.`
484
485const EMPTY_TRACK: AssumptionTrack = { entries: [], seq: 0, turnNo: 0, runningTurnId: '', turnDeclared: false, turnEdits: [], open: false, file: '', sessionId: '', member: '', warned: false, root: '', os: '', merged: [], idMap: {} }
486export const newTrack = (): AssumptionTrack => ({ ...EMPTY_TRACK, entries: [], turnEdits: [] })
487
488/** The texts of the ASSUMPTION lines in a reply, in order. Lines inside a code fence are ignored. */
489export function parseAssumptions(text: string): string[] {
490  const out: string[] = []
491  let inFence = false
492  for (const line of text.split('\n')) {
493    if (/^\s*```/.test(line)) {
494      inFence = !inFence
495      continue
496    }
497    if (inFence) continue
498    const m = line.match(/^\s*ASSUMPTION:\s*(\S.*?)\s*$/)
499    if (m) out.push(m[1].slice(0, ASSUMPTION_TEXT_CAP))
500  }
501  return out
502}
503
504/** A turn began: count it, remember its id for Stop, and clear what the last turn left. */
505export function startTurn(t: AssumptionTrack, turnId: string): AssumptionTrack {
506  return { ...t, turnNo: t.turnNo + 1, runningTurnId: turnId, turnDeclared: false, turnEdits: [] }
507}
508
509/** The reply declared assumptions: they go to the top of the list, newest first. A text already listed for this turn is not added twice. */
510export function addDeclared(t: AssumptionTrack, texts: string[], now: number): AssumptionTrack {
511  const listed = new Set(t.entries.filter(e => e.kind === 'declared' && e.turn === t.turnNo).map(e => e.text))
512  const fresh = texts.filter(text => !listed.has(text) && listed.add(text))
513  if (fresh.length === 0) return t
514  // ids rise in the order written; the list shows the newest first
515  const added = fresh.map((text, i) => ({ id: t.seq + i + 1, text, turn: t.turnNo, kind: 'declared' as const, status: 'open' as const, at: now, sessionId: t.sessionId })).reverse()
516  return { ...t, seq: t.seq + fresh.length, turnDeclared: true, entries: [...added, ...t.entries].slice(0, ASSUMPTION_CAP) }
517}
518
519/** A file was written during the turn. Each path is kept once. */
520export function addEdits(t: AssumptionTrack, paths: string[]): AssumptionTrack {
521  const turnEdits = [...new Set([...t.turnEdits, ...paths])]
522  return { ...t, turnEdits }
523}
524
525/** The turn ended. With flagging on, an edit in a turn that declared no assumption becomes one entry per file. */
526export function finishTurn(t: AssumptionTrack, flag: boolean, now: number): AssumptionTrack {
527  const paths = flag && !t.turnDeclared ? t.turnEdits : []
528  if (paths.length === 0) return { ...t, runningTurnId: '', turnDeclared: false, turnEdits: [] }
529  // one row for the whole turn, however many files it wrote
530  const first = paths[0] as string
531  const name = first.split('/').pop() ?? first
532  const text = paths.length === 1 ? `${name}, no stated assumption` : `${paths.length} files, no stated assumption`
533  const row: Assumption = { id: t.seq + 1, text, turn: t.turnNo, kind: 'flagged', path: first, paths: [...paths], status: 'open', at: now, sessionId: t.sessionId }
534  return { ...t, seq: t.seq + 1, runningTurnId: '', turnDeclared: false, turnEdits: [], entries: [row, ...t.entries].slice(0, ASSUMPTION_CAP) }
535}
536
537/** A path relative to the project root, with forward slashes. Undefined when the path lies outside the root. A relative path is inside it. */
538export function relativeTo(root: string, path: string, os: 'windows' | 'macos' | 'linux' | ''): string | undefined {
539  const p = normPath(path)
540  if (!/^([a-zA-Z]:)?\//.test(p)) return p
541  const r = normPath(root)
542  const fold = (s: string) => (os === 'windows' || os === 'macos' ? s.toLowerCase() : s)
543  if (!fold(p).startsWith(`${fold(r)}/`)) return undefined
544  return p.slice(r.length + 1)
545}
546
547/** Confirm or Wrong on an open entry. A resolved entry does not change again. */
548export function setStatus(t: AssumptionTrack, id: number, status: 'confirmed' | 'wrong', now: number): AssumptionTrack {
549  return { ...t, entries: t.entries.map(e => (e.id === id && e.status === 'open' ? { ...e, status, resolvedAt: now } : e)) }
550}
551
552export const openCount = (t: AssumptionTrack) => t.entries.filter(e => e.status === 'open').length
553
554/** The question asked before an open entry is deleted, and its two answers. */
555export const deleteQuestion = (a: Assumption) => `Delete open assumption ${a.id}? Nobody has decided on it yet.`
556export const DELETE_OPTIONS = ['Delete', 'Keep']
557
558/** Removes one entry, whatever its status. */
559export function removeEntry(t: AssumptionTrack, id: number): AssumptionTrack {
560  return { ...t, entries: t.entries.filter(e => e.id !== id) }
561}
562
563/** Removes every confirmed and wrong entry. Returns how many went. */
564export function dropResolved(t: AssumptionTrack): { track: AssumptionTrack; count: number } {
565  const keep = t.entries.filter(e => e.status === 'open')
566  return { track: { ...t, entries: keep }, count: t.entries.length - keep.length }
567}
568
569export const DAY = 24 * 60 * 60 * 1000
570/** An open entry older than this shows as stale. Display only: the stored status stays open. */
571export const STALE_OPEN_MS = 3 * DAY
572/** A resolved entry older than this (by resolvedAt) is removed when the list is tidied. */
573export const TIDY_RESOLVED_MS = 7 * DAY
574
575/** An open entry added more than three days ago. */
576export const isStale = (a: Assumption, now: number = Date.now()) => a.status === 'open' && now - a.at > STALE_OPEN_MS
577
578/** Removes the resolved entries older than seven days. Open entries stay, whatever their age. Returns the same track when nothing goes. */
579export function tidy(t: AssumptionTrack, now: number): AssumptionTrack {
580  const keep = t.entries.filter(e => e.status === 'open' || e.resolvedAt === undefined || now - e.resolvedAt <= TIDY_RESOLVED_MS)
581  return keep.length === t.entries.length ? t : { ...t, entries: keep }
582}
583
584/** The panel's order: open entries first, then the resolved ones. Each group keeps its newest-first order. */
585export const panelOrder = (entries: Assumption[]) => [...entries.filter(e => e.status === 'open'), ...entries.filter(e => e.status !== 'open')]
586
587/** The text the box takes when the person presses Wrong, by entry kind. The person finishes it; nothing is sent. */
588export const redirectText = (a: Pick<Assumption, 'kind' | 'text' | 'path' | 'turn' | 'paths'>) =>
589  a.kind === 'flagged' ? flaggedRedirect(a) : `Assumption '${a.text}' is wrong. Instead: `
590
591/** The opening for a flagged row: the turn, the first file, and how many more. */
592const flaggedRedirect = (a: Pick<Assumption, 'text' | 'path' | 'turn' | 'paths'>) => {
593  const more = (a.paths?.length ?? 1) - 1
594  return more > 0 ? `About the edits in turn ${a.turn} (${a.path} and ${more} more): ` : `About the edits in turn ${a.turn} (${a.path ?? a.text}): `
595}
596
597// ---------- the stored file: <project>/.claude/session-panel/assumptions/<session id>.json ----------
598
599export const STORE_DIR = '.claude/session-panel/assumptions'
600export const STORE_VERSION = 1
601/** Written into .claude/session-panel/ so git ignores the whole folder. */
602export const STORE_IGNORE = '*\n'
603
604/** A session's file name: its id, with anything unsafe in a file name replaced. */
605export const safeName = (id: string) => id.replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 80) || 'session'
606
607/** A short, stable tag of a session id, used in the second file name when the plain one belongs to another session. */
608export function idTag(id: string): string {
609  let h = 5381
610  for (const ch of id) h = ((h << 5) + h + ch.charCodeAt(0)) >>> 0
611  return h.toString(16).padStart(8, '0')
612}
613
614export const plainPath = (root: string, sessionId: string) => `${root}/${STORE_DIR}/${safeName(sessionId)}.json`
615
616/** The session that a stored file belongs to, or undefined when the text is not a stored file. */
617export function ownerOf(text: string): string | undefined {
618  try {
619    const j = JSON.parse(text)
620    return j && typeof j.sessionId === 'string' ? j.sessionId : undefined
621  } catch {
622    return undefined
623  }
624}
625
626/** This session's file: its plain name, or a tagged name when the plain file holds another session's list (or cannot be read). */
627export function resolveStorePath(root: string, sessionId: string, baseText: string | undefined): { file: string; foreign: boolean } {
628  if (baseText === undefined || ownerOf(baseText) === sessionId) return { file: plainPath(root, sessionId), foreign: false }
629  return { file: `${root}/${STORE_DIR}/${safeName(sessionId)}-${idTag(sessionId)}.json`, foreign: true }
630}
631
632const isEntry = (e: any) =>
633  !!e &&
634  typeof e.id === 'number' &&
635  typeof e.text === 'string' &&
636  typeof e.turn === 'number' &&
637  (e.kind === 'declared' || e.kind === 'flagged') &&
638  (e.status === 'open' || e.status === 'confirmed' || e.status === 'wrong')
639
640export function storedJson(member: string, sessionId: string, entries: Assumption[], merged: string[] = [], idMap: Record<string, number> = {}): string {
641  return JSON.stringify({ version: STORE_VERSION, member, sessionId, entries, merged, idMap }, null, 2) + '\n'
642}
643
644/** The file an old id file is retired to once its entries are merged: no entries, and the named file it went into. */
645export const retiredJson = (member: string, sessionId: string, mergedInto: string) =>
646  JSON.stringify({ version: STORE_VERSION, member, sessionId, entries: [], mergedInto }, null, 2) + '\n'
647
648/** A registry record: <pid>.json. The pattern is pinned by a test, since its backslashes were lost twice in editing. */
649export const REGISTRY_RECORD = /^\d+\.json$/
650
651/** The entries a stored file holds, and the old id files already merged into it, or why it cannot be read. A file this code cannot read is never overwritten. */
652export function parseStored(text: string | undefined): { ok: true; entries: Assumption[]; merged: string[]; idMap: Record<string, number> } | { ok: false; reason: string } {
653  if (text === undefined) return { ok: true, entries: [], merged: [], idMap: {} }
654  let j: any
655  try {
656    j = JSON.parse(text)
657  } catch {
658    return { ok: false, reason: 'the file is not JSON' }
659  }
660  if (!j || j.version !== STORE_VERSION || !Array.isArray(j.entries)) return { ok: false, reason: 'the file is not a version 1 assumptions file' }
661  // an entry written before 0.3.34 has no sessionId: it belongs to the file's owner
662  const entries = j.entries.filter(isEntry).map((e: any) => ({ ...e, sessionId: typeof e.sessionId === 'string' ? e.sessionId : String(j.sessionId ?? '') })) as Assumption[]
663  const merged = Array.isArray(j.merged) ? j.merged.filter((x: unknown): x is string => typeof x === 'string') : []
664  const idMap: Record<string, number> = {}
665  if (j.idMap && typeof j.idMap === 'object') for (const [k, v] of Object.entries(j.idMap)) if (typeof v === 'number') idMap[k] = v
666  return { ok: true, entries, merged, idMap }
667}
668
669/** The track after a load: the stored entries, with the counters moved past them so new entries do not clash. */
670export function loadTrack(t: AssumptionTrack, entries: Assumption[]): AssumptionTrack {
671  // the counters never go back: a file loaded in the middle of a turn keeps the turn number that turn already has
672  const seq = entries.reduce((m, e) => Math.max(m, e.id), t.seq)
673  const turnNo = entries.reduce((m, e) => Math.max(m, e.turn), t.turnNo)
674  return { ...t, entries: entries.slice(0, ASSUMPTION_CAP), seq, turnNo }
675}
676
677/**
678 * Merges one old id file (this session's own, named `<session id>.json`) into the named file's track. Its entries keep
679 * their status and get this session's id from the old file; their ids follow the named file's highest id. Each old file
680 * is merged once: its name is recorded in `merged`, and a file already listed there is not merged again.
681 */
682export function mergeOld(t: AssumptionTrack, old: { fileName: string; sessionId: string; entries: Assumption[] }): AssumptionTrack {
683  if (t.merged.includes(old.fileName)) return t
684  // an old id the named file does not have is kept; an old id it already has gets the next free id, recorded in idMap
685  const named = new Set(t.entries.map(e => e.id))
686  const kept = new Set(old.entries.map(e => e.id).filter(id => !named.has(id)))
687  const taken = new Set([...named, ...kept])
688  let seq = Math.max(t.seq, ...kept)
689  const idMap = { ...t.idMap }
690  const added = old.entries.map(e => {
691    if (!named.has(e.id)) return { ...e, sessionId: old.sessionId }
692    do seq++
693    while (taken.has(seq))
694    taken.add(seq)
695    idMap[String(e.id)] = seq
696    return { ...e, id: seq, sessionId: old.sessionId }
697  })
698  return { ...t, seq, idMap, merged: [...t.merged, old.fileName], entries: [...t.entries, ...added].slice(0, ASSUMPTION_CAP) }
699}
700/** The text of the text blocks in a row's content. Anything else (no content, not a list, a null block) is no text. */
701export function responseText(content: unknown): string {
702  if (!Array.isArray(content)) return ''
703  return content
704    .map(b => (b && typeof b === 'object' && (b as { type?: unknown }).type === 'text' && typeof (b as { text?: unknown }).text === 'string' ? (b as { text: string }).text : ''))
705    .filter(text => text !== '')
706    .join('\n')
707}
708
709const NOT_A_FILE = /^(\/dev\/null|nul|\$null|&\d)$/i
710const clean = (raw: string) => raw.replace(/^["']|["']$/g, '')
711
712/** The file paths a shell command writes to. Best effort: redirects, a few cmdlets and common file tools. */
713export function shellTargets(command: string): string[] {
714  const found: string[] = []
715  for (const m of command.matchAll(/(?:^|[^>\d&])\d?>>?\s*("[^"]+"|'[^']+'|[^\s;&|<>"']+)/g)) found.push(clean(m[1]))
716  for (const m of command.matchAll(/\b(?:Set-Content|Add-Content|Out-File|Copy-Item|Move-Item)\b([^;|\n]*)/gi)) {
717    const named = m[1].match(/-(?:FilePath|Path|LiteralPath|Destination)\s+("[^"]+"|'[^']+'|\S+)/i)
718    const first = m[1].split(/\s+/).find(w => w !== '' && !w.startsWith('-'))
719    const target = named ? named[1] : first
720    if (target) found.push(clean(target))
721  }
722  for (const m of command.matchAll(/\bsed\s+-i[^;|&\n]*/g)) {
723    const last = m[0].trim().split(/\s+/).pop()
724    if (last) found.push(clean(last))
725  }
726  for (const m of command.matchAll(/\b(?:cp|mv)\s+[^;|&\n]+/g)) {
727    const last = m[0].trim().split(/\s+/).pop()
728    if (last) found.push(clean(last))
729  }
730  return [...new Set(found.filter(p => p !== '' && !NOT_A_FILE.test(p)))]
731}
732
733/** The files a tool call writes to, by tool name and its input. Other tools write nothing this panel tracks. */
734export function editTargets(tool: string, input: Record<string, unknown>): string[] {
735  const str = (v: unknown) => (typeof v === 'string' && v !== '' ? [v] : [])
736  if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') return str(input.file_path)
737  if (tool === 'NotebookEdit') return str(input.notebook_path)
738  if (tool === 'Bash' || tool === 'PowerShell') return shellTargets(String(input.command ?? ''))
739  return []
740}
741
742// ---------- messages from the team head (0.3.2) ----------
743
744/** The head's verbs: confirm and wrong set a status; delete removes the entry. */
745export type Verb = 'confirm' | 'wrong' | 'delete'
746
747/** The line the team head sends: `SESSION-PANEL: confirm 3`, `SESSION-PANEL: wrong 3` or `SESSION-PANEL: delete 3`. */
748export function parseIncoming(text: string): { verb: Verb; id: number } | null {
749  const m = text.trim().match(/^SESSION-PANEL: (confirm|wrong|delete) (\d+)$/)
750  return m ? { verb: m[1] as Verb, id: Number(m[2]) } : null
751}
752
753/** Applies a head's message to this session's list. An unknown id, or an entry already resolved, changes nothing and says why. */
754export function applyIncoming(t: AssumptionTrack, cmd: { verb: Verb; id: number }, now: number): { track: AssumptionTrack; note: string; changed: boolean } {
755  // a head line names the current id: the roster reads the ids of the named file. idMap is a record only
756  const id = cmd.id
757  const entry = t.entries.find(e => e.id === id)
758  if (!entry) return { track: t, note: `No assumption ${cmd.id} in this session.`, changed: false }
759  if (cmd.verb === 'delete') return { track: removeEntry(t, id), note: `Assumption ${id} deleted.`, changed: true }
760  if (entry.status !== 'open') return { track: t, note: `Assumption ${cmd.id} is already ${entry.status}.`, changed: false }
761  const status = cmd.verb === 'confirm' ? 'confirmed' : 'wrong'
762  return { track: setStatus(t, id, status, now), note: `Assumption ${cmd.id} marked ${status}.`, changed: true }
763}
764
765// ---------- status lines inside cross-session envelopes (0.3.3) ----------
766
767/**
768 * A message from another session arrives in this envelope. Only unescaped tags are envelope tags: the harness writes a tag
769 * that appears inside a body with a backslash (`<\cross-session-message`, `<\/cross-session-message>`), which is plain text.
770 */
771const OPEN = /<cross-session-message\b([^>]*)>/g
772const CLOSE = /<\/cross-session-message>/g
773const PREAMBLE = /^\s*Another Claude session sent a message:\s*$/gm
774
775/**
776 * Splits a prompt into the status lines it carries and the other text. A status line typed by the user has sender
777 * `user`; one inside the envelope has the envelope's `from-name`. A prompt with more than one envelope, or a broken one,
778 * is refused as a whole: its status lines are not applied, and `refusal` says why.
779 */
780export function sortPrompt(text: string): { statuses: Array<{ line: string; sender: string }>; other: string; refusal?: string } {
781  const opens = [...text.matchAll(OPEN)]
782  const closes = [...text.matchAll(CLOSE)]
783  const lines = (t: string) => t.split('\n').map(l => l.trim()).filter(l => l !== '')
784  const refuse = (why: string) => ({
785    statuses: lines(text).filter(l => parseIncoming(l) !== null).map(line => ({ line, sender: '' })),
786    other: '',
787    refusal: why,
788  })
789  if (opens.length > 1 || closes.length > 1) return refuse('Refused: more than one message envelope.')
790  if (opens.length !== closes.length) return refuse('Refused: the message envelope is malformed.')
791  const statuses: Array<{ line: string; sender: string }> = []
792  const other: string[] = []
793  const take = (body: string, sender: string) => {
794    for (const line of lines(body)) {
795      if (parseIncoming(line) !== null) statuses.push({ line, sender })
796      else other.push(line)
797    }
798  }
799  let rest = text
800  const open = opens[0]
801  const close = closes[0]
802  if (open !== undefined && close !== undefined) {
803    if (close.index! < open.index! + open[0].length) return refuse('Refused: the message envelope is malformed.')
804    take(text.slice(open.index! + open[0].length, close.index), open[1].match(/from-name="([^"]*)"/)?.[1] ?? '')
805    rest = text.slice(0, open.index) + text.slice(close.index! + close[0].length)
806  }
807  take(rest.replace(PREAMBLE, ''), 'user')
808  return { statuses, other: other.join('\n') }
809}
810
811/** The team roster's heads for this session: its boss, and the team top (the member whose boss is the user). Undefined when the roster cannot be read or this session is not on it. */
812export function headsFor(rosterText: string, sessionId: string): Set<string> | undefined {
813  let roster: any
814  try {
815    roster = JSON.parse(rosterText)
816  } catch {
817    return undefined
818  }
819  if (!Array.isArray(roster)) return undefined
820  const me = roster.find((m: any) => m && m.sessionId === sessionId)
821  if (!me || typeof me.team !== 'string') return undefined
822  const heads = new Set<string>()
823  if (typeof me.boss === 'string') heads.add(me.boss)
824  for (const m of roster) if (m && m.team === me.team && m.boss === 'user' && typeof m.name === 'string') heads.add(m.name)
825  return heads
826}
827
828/** Applies status lines in order. A refused line changes nothing and its refusal is the note. */
829export function applyStatuses(
830  t: AssumptionTrack,
831  items: Array<{ verb: Verb; id: number; allowed: boolean; refusal: string }>,
832  now: number,
833): { track: AssumptionTrack; notes: string[]; changed: boolean } {
834  let track = t
835  let changed = false
836  const notes: string[] = []
837  for (const it of items) {
838    if (!it.allowed) {
839      notes.push(it.refusal)
840      continue
841    }
842    const r = applyIncoming(track, it, now)
843    track = r.track
844    changed = changed || r.changed
845    notes.push(r.note)
846  }
847  return { track, notes, changed }
848}
849
850// ---------- the version in the band (0.3.31) ----------
851
852/** The `version` a plugin manifest states, or '' when the text is missing, not JSON, or has no version. */
853export function versionFrom(manifest: string | undefined): string {
854  if (manifest === undefined) return ''
855  try {
856    const v = JSON.parse(manifest)?.version
857    return typeof v === 'string' ? v : ''
858  } catch {
859    return ''
860  }
861}
862
863// ---------- file names, OS and paths: the team-orchestrator rules (0.3.34) ----------
864
865export type Os = 'windows' | 'macos' | 'linux'
866export type Live = { sessionId: string; name: string }
867
868/** A file name for a session name: the rule team-orchestrator uses for its status files. Runs of other characters become "_". */
869export const nameFile = (name: string) => name.replace(/[^\p{L}\p{N}._-]+/gu, '_')
870
871/** The OS from the OS variable (Windows_NT on Windows) and `uname -s` elsewhere, as team-orchestrator reads them. */
872export const osFrom = (osVar: string | undefined, uname: string): Os => (osVar === 'Windows_NT' ? 'windows' : /darwin/i.test(uname) ? 'macos' : 'linux')
873
874/** Two names are the same name: case-insensitive on Windows and macOS, case-sensitive on Linux. */
875export const sameName = (a: string, b: string, os: Os) => (os === 'linux' ? a === b : a.toLowerCase() === b.toLowerCase())
876
877/** A path with forward slashes and no trailing slash. */
878export const normPath = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '')
879
880/** The home folder: USERPROFILE, else HOME, as team-orchestrator resolves it. */
881export const homeOf = (env: { USERPROFILE?: string; HOME?: string }) => normPath(env.USERPROFILE || env.HOME || '')
882
883/** The Claude config folder: CLAUDE_CONFIG_DIR, else <home>/.claude, as the session registry is found. '' when none is set. */
884export const configDirOf = (env: { CLAUDE_CONFIG_DIR?: string; USERPROFILE?: string; HOME?: string }) =>
885  env.CLAUDE_CONFIG_DIR ? normPath(env.CLAUDE_CONFIG_DIR) : homeOf(env) ? `${homeOf(env)}/.claude` : ''
886
887/** The key /enhance-key adds to keybindings.json: Ctrl+X, then E, in the Chat context, running /enhance. */
888export const ENHANCE_CHORD = 'ctrl+x e'
889export const ENHANCE_ACTION = 'command:enhance'
890const RESTART = 'Restart the session if the key does nothing.'
891
892/**
893 * What /enhance-key does to keybindings.json, given its text (undefined: there is no file): the text to write, if any,
894 * and the one line to show. A file that does not parse, or a Ctrl+X E already bound in Chat (to anything, null too), is
895 * left as it is. Otherwise every other field is kept, with the file's own indent and line ends.
896 */
897export function enhanceKeyEdit(text: string | undefined): { write?: string; line: string } {
898  const added = `Added Ctrl+X E for Enhance. ${RESTART}`
899  if (text === undefined) {
900    const file = {
901      $schema: 'https://www.schemastore.org/claude-code-keybindings.json',
902      $docs: 'https://code.claude.com/docs/en/keybindings',
903      bindings: [{ context: 'Chat', bindings: { [ENHANCE_CHORD]: ENHANCE_ACTION } }],
904    }
905    return { write: `${JSON.stringify(file, null, 2)}\n`, line: added }
906  }
907  const unreadable = { line: 'keybindings.json is not readable JSON, so nothing was changed.' }
908  let file: any
909  try {
910    file = JSON.parse(text)
911  } catch {
912    return unreadable
913  }
914  if (!file || typeof file !== 'object' || Array.isArray(file) || (file.bindings !== undefined && !Array.isArray(file.bindings))) return unreadable
915  const chats = ((file.bindings ?? []) as any[]).filter(b => b?.context === 'Chat' && b.bindings && typeof b.bindings === 'object' && !Array.isArray(b.bindings))
916  // keys are read case-insensitively, with any spacing between the two presses
917  const isChord = (k: string) => k.trim().toLowerCase().replace(/^control\+/, 'ctrl+').split(/\s+/).join(' ') === ENHANCE_CHORD
918  const bound = chats.flatMap(c => Object.entries(c.bindings).filter(([k]) => isChord(k)).map(([, v]) => v))
919  if (bound.length > 0) {
920    const v = bound[bound.length - 1]
921    if (v === ENHANCE_ACTION) return { line: `Ctrl+X E is already set for Enhance. ${RESTART}` }
922    return { line: `Ctrl+X E is taken by ${typeof v === 'string' ? v : JSON.stringify(v)} in keybindings.json, so nothing was changed.` }
923  }
924  if (chats.length > 0) chats[0].bindings[ENHANCE_CHORD] = ENHANCE_ACTION
925  else file.bindings = [...(file.bindings ?? []), { context: 'Chat', bindings: { [ENHANCE_CHORD]: ENHANCE_ACTION } }]
926  const indent = text.match(/^([ \t]+)\S/m)?.[1] ?? 2
927  const eol = text.includes('\r\n') ? '\r\n' : '\n'
928  return { write: JSON.stringify(file, null, indent).split('\n').join(eol) + (/\n$/.test(text) ? eol : ''), line: added }
929}
930
931export const assumptionsDir = (root: string) => `${normPath(root)}/${STORE_DIR}`
932
933/**
934 * The file a session writes. With a name, the name's file; with the name's file held by a session that is not this one
935 * (and still live), or by a session that cannot be read, or when another live session has the same name, the tagged
936 * `<name>-<first 8 of the session id>.json`. Without a name, the session-id file as before. `takeover` means the name's
937 * file belongs to a session that has closed, so this session takes it over and keeps its entries.
938 */
939export function chooseFile(p: { root: string; sessionId: string; name: string | undefined; os: Os; baseText: string | undefined; live: Live[]; complete: boolean }): {
940  file: string
941  tagged: boolean
942  takeover: boolean
943} {
944  if (p.name === undefined || p.name.trim() === '') {
945    const r = resolveStorePath(p.root, p.sessionId, p.baseText)
946    return { file: r.file, tagged: r.foreign, takeover: false }
947  }
948  const dir = assumptionsDir(p.root)
949  const owner = p.baseText === undefined ? undefined : ownerOf(p.baseText)
950  const unreadable = p.baseText !== undefined && owner === undefined
951  const sameNameLive = p.live.some(s => s.sessionId !== p.sessionId && sameName(s.name, p.name as string, p.os))
952  const ownerLive = owner !== undefined && owner !== p.sessionId && p.live.some(s => s.sessionId === owner)
953  // the registry must have been read whole: a folder that could not be listed, or a record that could not be parsed,
954  // leaves the owner unknown, so this session never takes a file over
955  if (!p.complete || unreadable || sameNameLive || ownerLive) {
956    return { file: `${dir}/${nameFile(p.name)}-${p.sessionId.slice(0, 8)}.json`, tagged: true, takeover: false }
957  }
958  return { file: `${dir}/${nameFile(p.name)}.json`, tagged: false, takeover: owner !== undefined && owner !== p.sessionId }
959}
960
961// ---------- the table (0.3.34; column rules 0.3.35; separators 0.3.39; fitted widths and colours 0.3.40) ----------
962
963/** The text cut to `n` cells, with "…" as its last cell when it is cut. */
964export const clip = (s: string, n: number) => (n <= 0 ? '' : s.length <= n ? s : n <= 1 ? '…' : `${s.slice(0, n - 1)}…`)
965
966/** A time as HH:MM, in this machine's clock. */
967export const hhmm = (ms: number) => {
968  const d = new Date(ms)
969  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
970}
971
972export type Col = { key: 'id' | 'kind' | 'status' | 'turn' | 'text' | 'path' | 'at' | 'actions'; header: string; width: number; short?: boolean }
973
974/** The text column keeps at least this many cells while any optional column shows. */
975export const TEXT_MIN = 30
976/** A path column narrower than this is not worth showing: it is dropped instead. */
977const PATH_MIN = 8
978/** The optional columns, in the order they drop as the band narrows. Text and the actions always stay. */
979const DROP_ORDER = ['at', 'path', 'turn', 'id'] as const
980/** The actions column: [Confirm] [Wrong] [Delete] on an open row, [Delete] on a resolved one. */
981const ACTIONS = 26
982/** The bar between two columns, with a space either side. It is three cells wide. */
983export const SEP = ' │ '
984/** The rule's crossing under each bar. */
985const CROSS = '─┼─'
986
987/** The header of each column, in Title Case: the display label only; the field names stay the same. */
988export const TITLE: Record<Col['key'], string> = { id: 'ID', kind: 'Cat', status: 'Status', turn: 'Turn', text: 'Text', path: 'Path', at: 'Time', actions: '' }
989/** The header of a shortened column. */
990const SHORT_TITLE: Partial<Record<Col['key'], string>> = { kind: 'C', status: 'S' }
991
992/** The width each fixed column needs: its header, or its longest cell, whichever is longer. */
993export type Sizes = { id: number; kind: number; status: number; turn: number; at: number }
994export function sizesOf(entries: readonly Assumption[]): Sizes {
995  const longest = (header: string, cells: string[]) => Math.max(header.length, ...cells.map(c => c.length))
996  return {
997    id: longest('id', entries.map(a => String(a.id))),
998    kind: longest('kind', entries.map(a => a.kind)),
999    status: longest('status', entries.map(a => a.status)),
1000    turn: longest('turn', entries.map(a => String(a.turn))),
1001    at: longest('at', entries.map(a => hhmm(a.at))),
1002  }
1003}
1004
1005/**
1006 * One layout: the columns in `present` (plus kind, status, text and actions), with kind and status as full words or as
1007 * one letter. `ok` is false when the text would fall below TEXT_MIN, or the path below PATH_MIN, while an optional column shows.
1008 */
1009function build(width: number, present: ReadonlySet<string>, short: boolean, sizes: Sizes, textOn: boolean): { cols: Col[]; ok: boolean } {
1010  const cols: Array<{ key: Col['key']; width: number }> = []
1011  if (present.has('id')) cols.push({ key: 'id', width: sizes.id })
1012  cols.push({ key: 'kind', width: short ? 1 : sizes.kind })
1013  cols.push({ key: 'status', width: short ? 1 : sizes.status })
1014  if (present.has('turn')) cols.push({ key: 'turn', width: sizes.turn })
1015  if (textOn) cols.push({ key: 'text', width: 0 })
1016  if (present.has('path')) cols.push({ key: 'path', width: 0 })
1017  if (present.has('at')) cols.push({ key: 'at', width: sizes.at })
1018  cols.push({ key: 'actions', width: ACTIONS })
1019  // a bar sits between each pair of columns: the bars come out of the width first
1020  const room = width - cols.reduce((n, c) => n + c.width, 0) - SEP.length * (cols.length - 1)
1021  const pathOn = present.has('path')
1022  // with text hidden, path takes all the spare width; with text shown, text keeps its share
1023  const text = !textOn ? 0 : pathOn ? Math.max(TEXT_MIN, Math.floor(room * 0.6)) : room
1024  const path = !pathOn ? 0 : textOn ? room - text : room
1025  const ok = (!textOn || text >= TEXT_MIN) && (!pathOn || path >= PATH_MIN)
1026  const laid = cols.map(c => ({
1027    key: c.key,
1028    header: c.key === 'actions' ? '' : short && SHORT_TITLE[c.key] !== undefined ? (SHORT_TITLE[c.key] as string) : TITLE[c.key],
1029    width: c.key === 'text' ? Math.max(text, 1) : c.key === 'path' ? path : c.width,
1030    ...(c.key === 'kind' || c.key === 'status' ? { short } : {}),
1031  })) as Col[]
1032  return { cols: laid, ok }
1033}
1034
1035/**
1036 * The table's columns for a band of `width` cells, sized to the entries. The fixed columns take what their content needs.
1037 * As the band narrows, kind and status first shrink to one letter, then the optional columns drop in the order at, path,
1038 * turn, id. Spare width goes to text, and to path when it shows. Text never falls below TEXT_MIN while an optional column shows.
1039 */
1040export function tableColumns(width: number, entries: readonly Assumption[] = [], hidden: readonly string[] = []): Col[] {
1041  const sizes = sizesOf(entries)
1042  // a column the reader hid is not drawn, and frees its width for the others; the drop order applies to what is left
1043  const textOn = !hidden.includes('text')
1044  for (let dropped = 0; dropped <= DROP_ORDER.length; dropped++) {
1045    const present = new Set<string>(DROP_ORDER.slice(dropped).filter(k => !hidden.includes(k)))
1046    for (const short of [false, true]) {
1047      const laid = build(width, present, short, sizes, textOn)
1048      if (laid.ok) return laid.cols
1049    }
1050  }
1051  return build(width, new Set(), true, sizes, textOn).cols
1052}
1053
1054/** The row whose full text is open: one at a time. Pressing the open row again closes it. */
1055export const selectedAfter = (current: number | null, id: number): number | null => (current === id ? null : id)
1056
1057/** The open row closes when its entry is no longer in the list, so a new entry that reuses the id does not open expanded. */
1058export const expandedAfterChange = (expanded: number | null, entries: readonly Assumption[]): number | null =>
1059  expanded !== null && !entries.some(e => e.id === expanded) ? null : expanded
1060
1061/** The columns a reader can hide, in the order of the toggle line. Cat, Status and the actions column always show. */
1062export const TOGGLES: ReadonlyArray<{ key: 'id' | 'turn' | 'text' | 'path' | 'at'; label: string }> = [
1063  { key: 'id', label: 'ID' },
1064  { key: 'turn', label: 'Turn' },
1065  { key: 'text', label: 'Text' },
1066  { key: 'path', label: 'Path' },
1067  { key: 'at', label: 'Time' },
1068]
1069
1070/** Hidden columns are kept as a list of keys; anything else in the stored value is ignored. */
1071export function hiddenFrom(stored: unknown): string[] {
1072  const keys = new Set(TOGGLES.map(t => t.key as string))
1073  const hidden = Array.isArray(stored) ? stored.filter((k): k is string => typeof k === 'string' && keys.has(k)) : []
1074  // ID and Text are never both hidden: a row must still open its full text. A stored state that has both shows ID.
1075  return hidden.includes('id') && hidden.includes('text') ? hidden.filter(k => k !== 'id') : hidden
1076}
1077
1078/** Shown when a toggle would hide the last of ID and Text. */
1079export const KEEP_ONE_MESSAGE = 'Keep ID or Text visible, so each row can still open its full text.'
1080
1081/** False when hiding `key` would leave both ID and Text hidden. Showing a column is always allowed. */
1082export function canHide(hidden: readonly string[], key: string): boolean {
1083  if (hidden.includes(key)) return true
1084  const pair = key === 'id' ? 'text' : key === 'text' ? 'id' : null
1085  return pair === null || !hidden.includes(pair)
1086}
1087
1088/** Shows a hidden column, or hides a shown one. */
1089export function toggled(hidden: readonly string[], key: string): string[] {
1090  return hidden.includes(key) ? hidden.filter(k => k !== key) : [...hidden, key]
1091}
1092
1093/** The one-letter status when shortened: o, c, w, and t for a stale open entry. */
1094const STATUS_LETTER: Record<string, string> = { open: 'o', confirmed: 'c', wrong: 'w', stale: 't' }
1095
1096/** The text of one cell, cut to its column. */
1097export function cellText(c: Col, a: Assumption, now: number = Date.now()): string {
1098  if (c.key === 'text') return clip(a.text, c.width)
1099  if (c.key === 'path') {
1100    // the first file, and how many more the turn wrote
1101    const more = (a.paths?.length ?? 1) - 1
1102    return clip(more > 0 ? `${a.path ?? ''} +${more}` : (a.path ?? ''), c.width)
1103  }
1104  if (c.key === 'at') return hhmm(a.at)
1105  if (c.key === 'turn') return String(a.turn)
1106  if (c.key === 'id') return String(a.id)
1107  // one letter when shortened: d or f for kind, o, c, w or t for status
1108  if (c.key === 'kind') return c.short ? a.kind[0]! : a.kind
1109  if (c.key === 'status') {
1110    const s = isStale(a, now) ? 'stale' : a.status
1111    return c.short ? STATUS_LETTER[s]! : s
1112  }
1113  return ''
1114}
1115
1116/** The colour of a status or kind cell. Other cells have none. */
1117export const CELL_COLOR: { status: Record<string, string>; kind: Record<string, string> } = {
1118  status: { open: 'yellow', confirmed: 'green', wrong: 'red' },
1119  kind: { declared: 'cyan', flagged: 'magenta' },
1120}
1121export const cellColor = (key: 'status' | 'kind', value: string): string | undefined => CELL_COLOR[key][value]
1122
1123/** A cell padded to its column's width. */
1124const fit = (s: string, w: number) => clip(s, w).padEnd(w)
1125
1126/** One piece of a table line: a cell, a bar, or a rule. The band styles each piece; the text of the pieces is the line. */
1127export type Piece = { key: Col['key'] | 'bar' | 'rule'; text: string }
1128
1129/** The header line, in pieces: each header above its column, with the bars between. */
1130export function headerPieces(cols: Col[]): Piece[] {
1131  return cols.flatMap((c, i) => [...(i > 0 ? [{ key: 'bar' as const, text: SEP }] : []), { key: c.key, text: fit(c.header, c.width) }])
1132}
1133
1134/** The rule under the header, and between rows, in pieces: a dash under each column, with a crossing under each bar. */
1135export function rulePieces(cols: Col[]): Piece[] {
1136  return cols.flatMap((c, i) => [...(i > 0 ? [{ key: 'rule' as const, text: CROSS }] : []), { key: 'rule' as const, text: '─'.repeat(c.width) }])
1137}
1138
1139/**
1140 * One data row, in pieces, up to and including the bar before the actions column. The buttons are drawn beside it, in
1141 * the actions column, which is the last column's width.
1142 */
1143export function rowPieces(cols: Col[], a: Assumption, now: number = Date.now()): Piece[] {
1144  const shown = cols.filter(c => c.key !== 'actions')
1145  return shown.flatMap((c, i) => [...(i > 0 ? [{ key: 'bar' as const, text: SEP }] : []), { key: c.key, text: fit(cellText(c, a, now), c.width) }]).concat([{ key: 'bar', text: SEP }])
1146}
1147
1148const joined = (ps: Piece[]) => ps.map(p => p.text).join('')
1149export const tableHeader = (cols: Col[]) => joined(headerPieces(cols))
1150export const tableRule = (cols: Col[]) => joined(rulePieces(cols))
1151export const tableRow = (cols: Col[], a: Assumption) => joined(rowPieces(cols, a))
1152
1153/** Short names for the enhancer sources in the Settings table, with the detail in each one's description. */
1154export const SOURCE_SHORT: Record<SourceId, { label: string; hint: string }> = {
1155  instructions: { label: 'CLAUDE.md, AGENTS.md', hint: 'Project and global instruction files. Capped at 4000 characters.' },
1156  skills: { label: 'Skills list', hint: 'The project skills list. Capped at 4000 characters.' },
1157  docs: { label: 'Context docs', hint: 'CONTEXT.md, PRD, ADRs and glossary. Capped at 4000 characters.' },
1158  github: { label: 'GitHub', hint: 'Wayfinder map, else recent issues. Off by default. Uses gh.' },
1159}
1160
1161/** The space between two columns of the Settings table. */
1162export const TABLE_GAP = 2
1163
1164/** The narrowest the description column gets; on a narrower card the row runs past the edge rather than lose its words. */
1165export const MIN_DESC = 12
1166
1167/** Word-wraps text into lines no wider than width. A word longer than the width is cut across lines. */
1168export function wrapWords(text: string, width: number): string[] {
1169  const w = Math.max(1, width)
1170  const lines: string[] = []
1171  let cur = ''
1172  for (const word of text.split(/\s+/).filter(x => x !== '')) {
1173    let piece = word
1174    while (piece.length > w) {
1175      if (cur !== '') {
1176        lines.push(cur)
1177        cur = ''
1178      }
1179      lines.push(piece.slice(0, w))
1180      piece = piece.slice(w)
1181    }
1182    if (piece === '') continue
1183    if (cur === '') cur = piece
1184    else if (cur.length + 1 + piece.length <= w) cur += ` ${piece}`
1185    else {
1186      lines.push(cur)
1187      cur = piece
1188    }
1189  }
1190  if (cur !== '') lines.push(cur)
1191  return lines.length > 0 ? lines : ['']
1192}
1193
1194/**
1195 * Column widths for the Settings table. The selector and name columns fit their widest entry across every tab, so the
1196 * columns do not move when the tab changes; the description takes what is left of the card.
1197 */
1198export function tableWidths(inner: number, selectorWidths: number[], names: string[]): { selector: number; name: number; desc: number } {
1199  const selector = Math.max(0, ...selectorWidths)
1200  const name = Math.max(0, ...names.map(n => n.length))
hooks/decide.ts 514 lines
1import type { Assumption, Prefs } from '../types'
2import { STALE_OPEN_MS } from './logic'
3
4/**
5 * The decision step, slice 1 (#91): a free local score for assumptions, the keep rule, the threshold rules read from
6 * `.claude/session-panel/decide.json`, and the pointer digest the band injects through `prompt.compose`.
7 * Everything here is pure, so the tests use it directly. No model call, no live HTTP and no new dependency: TypeSafe
8 * Jev, the skill and context-source candidates, file spans and self-tuning are slice 2 and later.
9 */
10
11/** At most this many scored items are kept for each candidate kind. Wrong assumptions are never dropped by the cap. */
12export const MAX_PER_KIND = 5
13/** The threshold a session starts with, before `decide.json` and Settings are read. */
14export const DEFAULT_THRESHOLD = 0.5
15/** The thresholds the Settings row offers. */
16export const THRESHOLD_CHOICES = [0.4, 0.5, 0.6, 0.7, 0.8] as const
17/** The digest's character budget: about 60-90 tokens. */
18export const DIGEST_CHAR_CAP = 360
19export const DIGEST_HEAD = 'Read only these assumptions: '
20/** At most this many wrong assumptions are quoted in full; any others are listed as ids after "also wrong:". */
21export const WRONG_QUOTED = 5
22export const DIGEST_TAIL = '\nSkip the rest.'
23/** The per-project rules file, relative to the project root. */
24export const DECIDE_FILE = '.claude/session-panel/decide.json'
25/** What each part of the score is worth. The five parts add up to 1. */
26export const SCORE_WEIGHTS = { status: 0.3, age: 0.15, kind: 0.2, files: 0.2, overlap: 0.15 } as const
27export const RULES_TOAST = 'Session Panel: decide.json could not be read, so the default threshold was used.'
28export const FAIL_TOAST = 'Session Panel: the decision step could not run. No pointer was added.'
29
30const STATUS_SCORE: Record<string, number> = { wrong: 1, open: 0.6, confirmed: 0.2 }
31const KIND_SCORE: Record<string, number> = { declared: 1, flagged: 0.3 }
32
33/** Words too common to say anything about what a turn is about. */
34const STOP = new Set([
35  'the', 'and', 'for', 'that', 'this', 'with', 'from', 'not', 'but', 'are', 'was', 'were', 'you', 'your', 'its', 'it',
36  'into', 'than', 'then', 'them', 'they', 'she', 'him', 'his', 'her', 'our', 'out', 'any', 'all', 'can', 'will',
37  'would', 'should', 'could', 'have', 'has', 'had', 'does', 'did', 'use', 'using', 'get', 'got', 'one', 'two', 'when',
38  'what', 'which', 'who', 'how', 'why', 'where', 'each', 'only', 'also', 'more', 'most', 'some', 'such', 'per',
39])
40
41export type ScoreParts = { status: number; age: number; kind: number; files: number; overlap: number }
42
43/** The entry fields the score reads. A plain object is enough, so the tests need no whole track. */
44export type Scorable = Pick<Assumption, 'id' | 'text' | 'status' | 'kind' | 'at'> & Partial<Pick<Assumption, 'path' | 'paths'>>
45
46export type Scored = {
47  id: number
48  text: string
49  score: number
50  parts: ScoreParts
51  /** every candidate kind is an assumption in slice 1; the field is here so slice 2 can add kinds */
52  candidateKind: string
53  isWrong: boolean
54  /**
55   * True when the entry shares a file (files > 0) or a meaningful word (overlap > 0) with the prompt. Only a matching
56   * entry can be kept by the local score: status, age and kind rank the matches, they never make one.
57   */
58  matched: boolean
59}
60
61export const clampThreshold = (n: number): number => (Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : DEFAULT_THRESHOLD)
62
63/** The words that carry meaning, in lower case: no punctuation, no very short words, no common words. */
64export function words(text: string): string[] {
65  return text
66    .toLowerCase()
67    .split(/[^\p{L}\p{N}]+/u)
68    .filter(w => w.length > 2 && !STOP.has(w))
69}
70
71/** The share of the entry's own words that the prompt also uses, 0 to 1. */
72export function overlapOf(text: string, prompt: string): number {
73  const mine = [...new Set(words(text))]
74  if (mine.length === 0) return 0
75  const theirs = new Set(words(prompt))
76  return clampThreshold(mine.filter(w => theirs.has(w)).length / mine.length)
77}
78
79/** The path-like words in a prompt: `src/app/page.tsx`, `hooks/decide.ts`, `notes.md`. */
80export function promptPaths(prompt: string): string[] {
81  const out: string[] = []
82  for (const m of prompt.matchAll(/[\w.@-]+(?:[/\\][\w.@-]+)+|[\w-]+\.[A-Za-z0-9]{1,5}\b/g)) out.push(m[0].replace(/\\/g, '/').toLowerCase())
83  return [...new Set(out)]
84}
85
86export const pathsOf = (a: Scorable): string[] => [...new Set([...(a.path === undefined ? [] : [a.path]), ...(a.paths ?? [])])]
87
88export const baseName = (p: string) => p.replace(/\\/g, '/').split('/').pop() ?? p
89
90/**
91 * 1 when the entry names a file the prompt also names, 0 otherwise.
92 *
93 * A prompt path that carries a folder (`hooks/decide.ts`) matches only by the full path or by a path suffix, so an
94 * entry for `src/hooks/decide.ts` matches `hooks/decide.ts`. A bare file name in the prompt (`decide.ts`) still
95 * matches by base name, whatever folder either path sits in.
96 */
97export function filesTouched(a: Scorable, prompt: string): number {
98  const named = promptPaths(prompt)
99  if (named.length === 0) return 0
100  const mine = pathsOf(a).map(p => p.replace(/\\/g, '/').toLowerCase())
101  const hit = mine.some(p =>
102    named.some(n => (n.includes('/') ? p === n || p.endsWith(`/${n}`) : baseName(p) === n)),
103  )
104  return hit ? 1 : 0
105}
106
107/** The five parts and the weighted total for one entry, 0 to 1. */
108export function scoreAssumption(a: Scorable, prompt: string, now: number): Scored {
109  const parts: ScoreParts = {
110    status: STATUS_SCORE[a.status] ?? 0,
111    age: clampThreshold(1 - Math.max(0, now - a.at) / STALE_OPEN_MS),
112    kind: KIND_SCORE[a.kind] ?? 0,
113    files: filesTouched(a, prompt),
114    overlap: overlapOf(a.text, prompt),
115  }
116  const score = clampThreshold(
117    parts.status * SCORE_WEIGHTS.status +
118      parts.age * SCORE_WEIGHTS.age +
119      parts.kind * SCORE_WEIGHTS.kind +
120      parts.files * SCORE_WEIGHTS.files +
121      parts.overlap * SCORE_WEIGHTS.overlap,
122  )
123  const isWrong = a.status === 'wrong'
124  return { id: a.id, text: a.text, score, parts, candidateKind: 'assumption', isWrong, matched: parts.files > 0 || parts.overlap > 0 }
125}
126
127export const scoreAll = (entries: readonly Scorable[], prompt: string, now: number): Scored[] =>
128  entries.map(a => scoreAssumption(a, prompt, now))
129
130/**
131 * The keep rule. A wrong entry is always kept. Any other entry is kept only when BOTH hold:
132 *
133 * - it matches the prompt at all (`matched`: it shares a file or a meaningful word), and
134 * - its score is strictly over the threshold.
135 *
136 * Status, age and kind only rank the matches; they never make one, so a fresh, declared, open assumption about
137 * something else entirely is not kept. At most `maxPerKind` scored entries are kept for each kind. Wrong entries come
138 * first, then the rest by score, highest first.
139 */
140export function keepShortlist(items: readonly Scored[], threshold: number, maxPerKind = MAX_PER_KIND): Scored[] {
141  const t = clampThreshold(threshold)
142  const byKind = new Map<string, Scored[]>()
143  for (const i of items) {
144    if (i.isWrong || !i.matched || i.score <= t) continue
145    const group = byKind.get(i.candidateKind) ?? []
146    group.push(i)
147    byKind.set(i.candidateKind, group)
148  }
149  const byScore = (a: Scored, b: Scored) => b.score - a.score || a.id - b.id
150  const scored: Scored[] = []
151  for (const group of byKind.values()) scored.push(...[...group].sort(byScore).slice(0, maxPerKind))
152  return [...items.filter(i => i.isWrong).sort(byScore), ...scored.sort(byScore)]
153}
154
155const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
156
157/**
158 * The pointer digest: one line naming the assumptions to read this turn, wrong ones quoted in full, then the line that
159 * tells the model to skip the rest. Nothing is kept means no digest at all.
160 *
161 * The five newest wrong assumptions are quoted in full; any others are listed as ids only after `also wrong:`. Wrong
162 * entries always fit, however long they are. A scored entry is dropped once the budget is spent, so the digest stays
163 * near 60-90 tokens.
164 */
165export function digestOf(kept: readonly Scored[], cap = DIGEST_CHAR_CAP): string {
166  if (kept.length === 0) return ''
167  const wrong = [...kept.filter(k => k.isWrong)].sort((a, b) => b.id - a.id)
168  const quoted = wrong.slice(0, WRONG_QUOTED)
169  const rest = wrong.slice(WRONG_QUOTED)
170  const parts: string[] = quoted.map(i => `#${i.id} (wrong: "${oneLine(i.text)}")`)
171  let len = DIGEST_HEAD.length + DIGEST_TAIL.length + parts.reduce((n, s) => n + s.length + 2, 0)
172  const alsoWrong = rest.length > 0 ? `also wrong: ${rest.map(i => `#${i.id}`).join(', ')}` : ''
173  if (alsoWrong !== '') {
174    parts.push(alsoWrong)
175    len += alsoWrong.length + 2
176  }
177  for (const i of kept.filter(k => !k.isWrong)) {
178    const s = `#${i.id}`
179    if (len + s.length > cap) break
180    parts.push(s)
181    len += s.length + 2
182  }
183  return `${DIGEST_HEAD}${parts.join(', ')}${DIGEST_TAIL}`
184}
185
186// ---------- the rules file: `.claude/session-panel/decide.json` ----------
187
188/** The threshold rules of one project. Keys are matched in lower case. */
189export type DecideRules = {
190  version: number
191  default?: number
192  paths: Record<string, number>
193  roles: Record<string, number>
194  /** thresholds by task type, the label Jev's Choice gives the prompt (slice 2) */
195  tasks: Record<string, number>
196}
197export const EMPTY_RULES: DecideRules = { version: 1, paths: {}, roles: {}, tasks: {} }
198
199/** The task types the Jev Choice labels a prompt with, and what each means to the model. */
200export const TASK_TYPES = ['build', 'fix', 'review', 'research', 'docs', 'other'] as const
201export type TaskType = (typeof TASK_TYPES)[number]
202export const TASK_CRITERIA: Record<TaskType, string> = {
203  build: 'Add or change behaviour: a new feature, page, or file',
204  fix: 'Repair something broken: a bug, a failing test, an error',
205  review: 'Read and judge work that already exists, without changing it',
206  research: 'Find out something and report back: an investigation or a question',
207  docs: 'Write or update explanation: a README, comment or guide',
208  other: 'A request that fits none of the above',
209}
210
211/** Reads the rules file. A missing file is no rules; junk is a reason, and the caller falls back to the default. */
212export function parseRules(text: string | undefined): { ok: true; rules: DecideRules } | { ok: false; reason: string } {
213  if (text === undefined || text.trim() === '') return { ok: true, rules: EMPTY_RULES }
214  let raw: unknown
215  try {
216    raw = JSON.parse(text)
217  } catch {
218    return { ok: false, reason: 'not JSON' }
219  }
220  if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return { ok: false, reason: 'not an object' }
221  const o = raw as Record<string, unknown>
222  const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? clampThreshold(v) : undefined)
223  const map = (v: unknown): Record<string, number> => {
224    if (typeof v !== 'object' || v === null || Array.isArray(v)) return {}
225    const out: Record<string, number> = {}
226    for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
227      const n = num(val)
228      if (n !== undefined) out[k.toLowerCase()] = n
229    }
230    return out
231  }
232  return { ok: true, rules: { version: 1, default: num(o.default), paths: map(o.paths), roles: map(o.roles), tasks: map(o.tasks) } }
233}
234
235const escapeChar = (c: string) => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
236
237/** A small glob: `**` crosses folders, `*` and `?` stay inside one. Matching ignores case. */
238export function globMatch(glob: string, path: string): boolean {
239  const target = path.replace(/\\/g, '/').toLowerCase()
240  let re = ''
241  for (let i = 0; i < glob.length; i++) {
242    const c = glob[i]
243    if (c === '*') {
244      if (glob[i + 1] === '*') {
245        i++
246        if (glob[i + 1] === '/') i++
247        re += '.*'
248      } else re += '[^/]*'
249    } else if (c === '?') re += '[^/]'
250    else re += escapeChar(c)
251  }
252  try {
253    return new RegExp(`^${re}$`).test(target)
254  } catch {
255    return false
256  }
257}
258
259/**
260 * The threshold for this turn. The most specific rule wins: a path glob that matches one of the turn's paths (the
261 * longest matching glob when several do), then the session's role, then the file's own default, then the Settings
262 * value. Task-type rules are slice 2, because they need Jev's label.
263 */
264export function pickThreshold(rules: DecideRules, paths: readonly string[], role: string | undefined, fallback: number): number {
265  return pickThresholdFor(rules, { paths, role }, fallback)
266}
267
268/**
269 * The threshold for this turn, with the task type in the order. The most specific rule wins: a path glob that matches
270 * one of the turn's paths (the longest matching glob when several do), then the session's role, then the task type the
271 * Jev Choice labelled the prompt with, then the file's own default, then the Settings value.
272 */
273export function pickThresholdFor(
274  rules: DecideRules,
275  ctx: { paths?: readonly string[]; role?: string | undefined; task?: string | undefined },
276  fallback: number,
277): number {
278  const hits = Object.entries(rules.paths).filter(([glob]) => (ctx.paths ?? []).some(p => globMatch(glob, p)))
279  if (hits.length > 0) return clampThreshold([...hits].sort((a, b) => b[0].length - a[0].length)[0][1])
280  const key = ctx.role?.trim().toLowerCase()
281  if (key !== undefined && key !== '' && Object.hasOwn(rules.roles, key)) return clampThreshold(rules.roles[key])
282  const task = ctx.task?.trim().toLowerCase()
283  if (task !== undefined && task !== '' && Object.hasOwn(rules.tasks, task)) return clampThreshold(rules.tasks[task])
284  if (rules.default !== undefined) return clampThreshold(rules.default)
285  return clampThreshold(fallback)
286}
287
288/** This session's role, from the team roster, or undefined when the roster cannot be read or is not on it. */
289export function roleFor(rosterText: string, sessionId: string): string | undefined {
290  try {
291    const roster = JSON.parse(rosterText)
292    if (!Array.isArray(roster)) return undefined
293    const me = roster.find((m: any) => m && m.sessionId === sessionId)
294    const role = me?.role
295    return typeof role === 'string' && role.trim() !== '' ? role.trim() : undefined
296  } catch {
297    return undefined
298  }
299}
300
301// ---------- the step itself ----------
302
303// ---------- the Jev pass (slice 2): TypeSafe System One, opt-in ----------
304
305export const JEV_URL = 'https://api.typesafe.ai/v1/systemone'
306export const JEV_MODEL = 'jev-latest'
307export const JEV_KEY_VAR = 'TYPESAFE_API_KEY'
308/** The Jev pass must answer inside this many milliseconds, or the local score is used instead. */
309export const JEV_TIMEOUT_MS = 1500
310/** A Jev Noul keeps an assertion at this probability and above. */
311export const JEV_KEEP = 0.8
312/** The one question asked of every shortlist item. */
313export const JEV_QUESTION = 'Is this assumption relevant to this prompt?'
314export const JEV_NO_KEY_TOAST = '' // no key means Jev is off: nothing is said
315export const JEV_FAIL_TOAST = 'Session Panel: the Jev pass could not answer, so the local score was used.'
316
317/** What one Jev call returned: the probability for each id, and the task type the prompt was labelled with. */
318export type JevAnswer = { isOk: true; scores: Record<number, number>; task: TaskType } | { isOk: false; reason: string }
319
320/** The HTTP call the Jev pass makes. The band hands over `$.http.fetch`; tests hand over their own. */
321export type JevFetch = (args: { url: string; method: string; headers: Record<string, string>; body: string; timeoutMs: number }) => Promise<{ status: number; body: string }>
322
323export const jevId = (id: number) => `assumption_${id}`
324
325/** The request body: one Noul per shortlist item, and one Choice for the task type. */
326export function jevRequestBody(items: readonly Scored[], prompt: string): string {
327  const questions: Record<string, unknown> = {}
328  for (const i of items) questions[jevId(i.id)] = { type: 'noul', instructions: JEV_QUESTION }
329  questions.task_type = { type: 'choice', instructions: 'What kind of request is this?', criteria: TASK_CRITERIA }
330  return JSON.stringify({ state: prompt, model: JEV_MODEL, questions })
331}
332
333/** Reads the answers back. A missing or malformed answer leaves that item to the local score. */
334export function parseJevReply(body: string, items: readonly Scored[]): { scores: Record<number, number>; task: TaskType } {
335  const scores: Record<number, number> = {}
336  let task: TaskType = 'other'
337  let parsed: any
338  try {
339    parsed = JSON.parse(body)
340  } catch {
341    return { scores, task }
342  }
343  const answers = parsed?.answers
344  if (typeof answers !== 'object' || answers === null) return { scores, task }
345  for (const i of items) {
346    const noul = answers[jevId(i.id)]?.noul
347    if (typeof noul === 'number' && Number.isFinite(noul)) scores[i.id] = clampThreshold(noul)
348  }
349  const chosen = answers.task_type?.choice
350  if (typeof chosen === 'string' && (TASK_TYPES as readonly string[]).includes(chosen)) task = chosen as TaskType
351  return { scores, task }
352}
353
354/** Why a Jev answer cannot be used: no key, an HTTP error, or an answer that took too long. */
355export type JevIo = {
356  key: () => Promise<string | undefined>
357  fetch: JevFetch
358  now: () => Promise<number>
359}
360
361/**
362 * One Jev request for the whole shortlist. It returns isOk:false for no key, a non-200 answer, a thrown call, a reply
363 * that is not JSON, and an answer that arrived after JEV_TIMEOUT_MS. It never throws and never retries.
364 */
365export async function runJev(io: JevIo, items: readonly Scored[], prompt: string): Promise<JevAnswer> {
366  let key: string | undefined
367  try {
368    key = await io.key()
369  } catch {
370    return { isOk: false, reason: 'no key' }
371  }
372  if (key === undefined || key.trim() === '') return { isOk: false, reason: 'no key' }
373  const started = await io.now()
374  let status = 0
375  let body = ''
376  try {
377    const r = await io.fetch({
378      url: JEV_URL,
379      method: 'POST',
380      headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
381      body: jevRequestBody(items, prompt),
382      timeoutMs: JEV_TIMEOUT_MS,
383    })
384    status = r?.status ?? 0
385    body = typeof r?.body === 'string' ? r.body : ''
386  } catch (err) {
387    return { isOk: false, reason: String((err as any)?.message ?? err).slice(0, 80) }
388  }
389  // the clock is read after the call, so a slow answer falls back however long the call itself took
390  const took = (await io.now()) - started
391  if (took > JEV_TIMEOUT_MS) return { isOk: false, reason: 'over 1.5 s' }
392  if (status !== 200) return { isOk: false, reason: `HTTP ${status}` }
393  let scores: Record<number, number>
394  let task: TaskType
395  try {
396    const out = parseJevReply(body, items)
397    scores = out.scores
398    task = out.task
399  } catch {
400    return { isOk: false, reason: 'the reply could not be read' }
401  }
402  if (Object.keys(scores).length === 0) return { isOk: false, reason: 'the reply held no answer' }
403  return { isOk: true, scores, task }
404}
405
406/**
407 * The Jev keep rule: keep a scored item when Jev's probability is over 0.8, at most five per kind. A wrong assumption
408 * is always kept, whatever Jev said. An item Jev did not answer keeps its local place.
409 */
410export function keepJev(items: readonly Scored[], scores: Record<number, number>, maxPerKind = MAX_PER_KIND): Scored[] {
411  const byScore = (a: Scored, b: Scored) => b.score - a.score || a.id - b.id
412  const byKind = new Map<string, Scored[]>()
413  for (const i of items) {
414    if (i.isWrong) continue
415    const p = scores[i.id]
416    if (p === undefined || p <= JEV_KEEP) continue
417    const group = byKind.get(i.candidateKind) ?? []
418    group.push(i)
419    byKind.set(i.candidateKind, group)
420  }
421  const kept: Scored[] = []
422  for (const group of byKind.values()) kept.push(...[...group].sort(byScore).slice(0, maxPerKind))
423  return [...items.filter(i => i.isWrong).sort(byScore), ...kept.sort(byScore)]
424}
425
426/** What the step needs from the engine. The band builds this from `$` in session-panel.tsx. */
427export type DecideIo = {
428  /** the rules file's text, or undefined when it is missing or cannot be read */
429  rules: () => Promise<string | undefined>
430  /** this session's role, from the roster */
431  role: () => Promise<string | undefined>
432  /** the prompt box's text */
433  box: () => Promise<string>
434  /** the files this turn has touched, relative to the project root */
435  paths: () => Promise<readonly string[]>
436  entries: () => Promise<readonly Scorable[]>
437  prefs: () => Promise<Pick<Prefs, 'decide' | 'decideThreshold' | 'jev'>>
438  now: () => Promise<number>
439  toast: (text: string) => void
440  /** the TYPESAFE_API_KEY, or undefined when it is not set */
441  key: () => Promise<string | undefined>
442  /** the TypeSafe call; the band passes this through from $.http.fetch, never from a direct fetch */
443  fetch: JevFetch
444}
445
446export type DecideOutcome = { text: string; warned: boolean }
447
448/**
449 * Runs the step for one turn. It never throws and never blocks the turn: on any error it returns no digest and asks
450 * for one toast, at most once per load.
451 *
452 * Order (ticket #91): the settings rules build the local score and the shortlist first. Then, when the Jev pass is on
453 * and a key is set, ONE TypeSafe request re-scores that shortlist and labels the task type. The task type then picks the
454 * threshold, and the digest is built from the Jev keep rule. The digest is '' when the step is off, there is nothing to
455 * point at, or nothing is kept.
456 */
457export async function runDecide(io: DecideIo, warned: boolean): Promise<DecideOutcome> {
458  const say = (text: string) => {
459    try {
460      if (text !== '') io.toast(text)
461    } catch {
462      // a toast that cannot be shown never breaks a turn
463    }
464  }
465  try {
466    const prefs = await io.prefs()
467    if (!prefs.decide) return { text: '', warned }
468    const entries = await io.entries()
469    if (entries.length === 0) return { text: '', warned }
470    const prompt = await io.box()
471    const parsed = parseRules(await io.rules())
472    let seen = warned
473    if (!parsed.ok && !seen) {
474      say(RULES_TOAST)
475      seen = true
476    }
477    const rules = parsed.ok ? parsed.rules : EMPTY_RULES
478    const role = parsed.ok ? await io.role().catch(() => undefined) : undefined
479    const now = await io.now()
480    // the path rules read the prompt's paths and the files this turn has touched, so a rule fits the work at hand
481    const paths = [...new Set([...promptPaths(prompt), ...(await io.paths())])]
482    // the settings rules first: the local score and the shortlist they build
483    const local = scoreAll(entries, prompt, now)
484    const shortlist = keepShortlist(local, pickThreshold(rules, paths, role, prefs.decideThreshold))
485    if (shortlist.length === 0) return { text: '', warned: seen }
486
487    // then the Jev pass, when it is on and a key is set. A failure here always falls back to the local shortlist.
488    if (prefs.jev) {
489      const jev = await runJev({ key: io.key, fetch: io.fetch, now: io.now }, shortlist, prompt)
490      if (jev.isOk) {
491        const threshold = pickThresholdFor(rules, { paths, role, task: jev.task }, prefs.decideThreshold)
492        return { text: digestOf(keepJev(keepJevShortlist(shortlist, threshold), jev.scores)), warned: seen }
493      }
494      if (jev.reason !== 'no key' && !seen) {
495        say(JEV_FAIL_TOAST)
496        seen = true
497      }
498    }
499    return { text: digestOf(shortlist), warned: seen }
500  } catch {
501    if (!warned) say(FAIL_TOAST)
502    return { text: '', warned: true }
503  }
504}
505
506/**
507 * The shortlist the task type re-thresholds before Jev scores it: the local cut again, on the same list, with the
508 * threshold the task type picked. The cut is strict, as #91 says (`score > threshold`). A wrong assumption still stays,
509 * and Jev's own `p > 0.8` is the relevance check from here on, so nothing else is filtered.
510 */
511export function keepJevShortlist(items: readonly Scored[], threshold: number): Scored[] {
512  const t = clampThreshold(threshold)
513  return items.filter(i => i.isWrong || i.score > t)
514}
types/index.d.ts 152 lines
1export type CleanTaskStatus = 'done' | 'active' | 'upcoming'
2
3export type CleanTask = {
4  id: string
5  name: string
6  status: CleanTaskStatus
7  /** Highest percent reported so far, 0 to 100. Never goes down. */
8  percent: number
9  /** True once Claude reported a percent for this step. */
10  hasReported: boolean
11  /** The eased value the meter draws; moves toward percent on each tick. */
12  shown: number
13}
14
15export type CleanPhase = 'idle' | 'working' | 'needs-you' | 'stuck' | 'stopped' | 'done'
16
17export type CleanQuestion = { question: string; options: string[] }
18
19export type CleanChecklist = {
20  title: string
21  phase: CleanPhase
22  tasks: CleanTask[]
23  needsYouReason: string
24  stuckReason: string
25  startedAt: number
26  finishedAt: number
27  isCollapsed: boolean
28  /** Counts jobs, so a slow job name from an old job is ignored. */
29  jobId: number
30  /** True once plan_steps or a to-do list made a real plan for this job. */
31  hasPlan: boolean
32  /** The plain verb line under the current step, "" for none. */
33  action: string
34  /** The ask_choices question being shown, or null. */
35  question: CleanQuestion | null
36  /** Distinct files changed by Edit, Write or NotebookEdit during the job. */
37  changedFiles: string[]
38  /** Consecutive failed tool calls. */
39  failStreak: number
40  /** True once the bell rang for the current Needs-you event. */
41  isBelled: boolean
42  /** The eased whole-job percent the bar style draws; never goes down. */
43  bar: number
44}
45
46/** Enhancer choices, saved in the store and changed in Settings. */
47export type Prefs = {
48  enhancerOn: boolean
49  autoEnhance: boolean
50  model: string
51  chat: 'recent' | 'full'
52  assumptions: boolean
53  flagUndeclared: boolean
54  /** the decision step: a pointer digest of the assumptions worth reading this turn (#91) */
55  decide: boolean
56  /** the fallback threshold the keep rule uses when decide.json names none for this turn */
57  decideThreshold: number
58  /** the TypeSafe Jev pass: a second, opt-in opinion on the shortlist (#91 slice 2) */
59  jev: boolean
60  sources: { instructions: boolean; skills: boolean; docs: boolean; github: boolean }
61}
62
63/** One line in the Assumptions panel: a declared ASSUMPTION line, or a flagged undeclared edit. */
64export type Assumption = {
65  id: number
66  text: string
67  turn: number
68  kind: 'declared' | 'flagged'
69  /** flagged: the first edited file, relative to the project root */
70  path?: string
71  /** flagged: every edited file of the turn, relative to the project root */
72  paths?: string[]
73  status: 'open' | 'confirmed' | 'wrong'
74  /** when it was added, ms since the epoch */
75  at: number
76  resolvedAt?: number
77  /** the session that added it; a file can hold entries of sessions that closed */
78  sessionId: string
79}
80
81/** Per-session assumption state: the list (newest first), the turn in progress, and the panel's open state. */
82export type AssumptionTrack = {
83  entries: Assumption[]
84  seq: number
85  turnNo: number
86  runningTurnId: string
87  turnDeclared: boolean
88  turnEdits: string[]
89  open: boolean
90  /** this session's file, absolute with forward slashes; '' until located, or when the file could not be read */
91  file: string
92  sessionId: string
93  /** the session's name, or its session id when it has no name */
94  member: string
95  /** the project root, forward slashes, '' until located */
96  root: string
97  /** the old id files already merged into this file, by file name */
98  merged: string[]
99  /** old ids renumbered by a merge: old id -> the id the entry has here */
100  idMap: Record<string, number>
101  /** the OS, for case rules; '' until located */
102  os: 'windows' | 'macos' | 'linux' | ''
103  /** true once the 'another session' toast has shown for this session */
104  warned: boolean
105}
106
107/** What the band's enhancer holds for the draft: the text before it was enhanced, and the notes. */
108export type EnhancerState = { original: string | null; passText: string | null; notes: string[]; busy: boolean }
109
110export type CleanStyle = 'checklist' | 'bars'
111
112/** A finished job kept as a dismissible bar. */
113export type CleanFinished = { id: string; title: string; seconds: number; steps: string[] }
114
115declare module 'claude-code' {
116  interface PluginState {
117    'session-panel': {
118      sessionPanelEnabled: boolean
119      askChoicesEnabled: boolean
120      /** the question-box rule: Claude asks with AskUserQuestion, and ask_choices is refused */
121      questionBoxRule: boolean
122      /** warn with a toast when a reply ends with a question that was not asked in the question box */
123      stopCheck: boolean
124      /** whether AskUserQuestion was called in the turn in progress */
125      askedThisTurn: boolean
126      settingsOpen: boolean
127      /** the Settings tab that is open */
128      settingsTab: string
129      /** the page of Settings rows that is open for the tab */
130      settingsPage: number
131      checklist: CleanChecklist
132      tick: number
133      viewStyle: CleanStyle
134      finished: CleanFinished[]
135      prefs: Prefs
136      enhancer: EnhancerState
137      gateDenied: number
138      assumptionTrack: AssumptionTrack
139      /** the plugin's manifest version, read once per load; '' when it cannot be read */
140      version: string
141      /** the assumption row whose full text is open, or null */
142      assumptionExpanded: number | null
143      /** the rows printed while the assumptions reveal runs, or null when it has finished */
144      assumptionReveal: number | null
145      /** the table columns the reader has hidden: id, turn, text, path, at */
146      hiddenColumns: string[]
147      /** the text of the last prompt the person sent; the decision step reads the turn's wording from here */
148      submittedText: string
149    }
150  }
151}
152