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

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 sits above the prompt, in this order: the title line, the Settings button, the enhancer buttons, the Assumptions list, and the checklist.
Session Panel (or Session Panel · off when hidden), then the version./simple on|off toggle the hiding. The setting is kept between sessions. It starts on.Stopped, or Nothing to stop: the turn has ended. if the turn already ended.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.| Command | What it does |
|---|---|
/simple on, /simple off | Shows or hides tool details. With no argument, flips it. |
/simple bars, /simple list | Sets the progress look. |
/progress | Flips between the bar view and the list view. |
/progress-clear | Removes the finished bars. |
/askchoices on, /askchoices off | Turns 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. |
/enhance | Enhances 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-key | Adds Ctrl+X E for Enhance to your keybindings.json. |
Settings → Questions holds three switches:
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.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.Until the job has a plan, other tools are refused for the main agent. Subagents are never gated.
Blocked by the Session Panel mod until this session calls mcp__session-panel__plan_steps (see the Session Panel system 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.Session Panel: no plan_steps call after 3 tries, so tools are open. A plan, or the end of the job, resets the count.The enhancer rewrites the prompt box so Claude can act on it, using the project context. It is on by default.
Enhanced. Check the box, then Send.ask call each, then enhances again with the answers.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./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" } }] }
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.Each source has its own toggle in Settings → Enhancer, under a short name. Each is capped at 4000 characters.
| Source | Name in Settings | Default | What it reads |
|---|---|---|---|
| CLAUDE.md and AGENTS.md (project and global) | CLAUDE.md, AGENTS.md | on | The global ~/.claude/CLAUDE.md, and the CLAUDE.md and AGENTS.md files in the folders above the session. |
| Project skills list | Skills list | on | The folder names in ~/.claude/skills and .claude/skills. A missing folder counts as none. |
| CONTEXT.md, PRD, ADRs and glossary | Context docs | on | CONTEXT.md, PRD.md, GLOSSARY.md, and the file names in docs/adr. |
| GitHub (wayfinder map, else recent issues) | GitHub | off | The 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.
The first interactive session with no saved setup asks five questions, once:
Turn the enhancer on? (On, Off)Which model should the enhancer use? (Haiku, Sonnet, Opus)Which band view by default? (List, Bars)Which context sources should the enhancer read? (All, Instructions and skills only, None)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 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.
[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.[Add]); Model (Haiku, Sonnet, Opus); Chat context (Recent, Full chat); the four context sources.[Run]).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.
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.
ASSUMPTION: line in a reply becomes an entry. The same text listed twice in one turn is kept once.<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.==== ASSUMPTIONS (N open) ====, with the count of open entries. Click it to open or close the section.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.confirmed, shown as ✓ Confirm.wrong, shown as ✗ Wrong. It then puts the correction prefix in the box for you to finish. Nothing is sent.⌫ Delete, removes the entry from the list and the file. There is no undo.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 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 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).resolvedAt is more than seven days old is removed from the list and the file. Open entries are never tidied, whatever their age.Assumption '<text>' is wrong. Instead: 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. Delete is a third button, ⌫ Delete, on every row. The actions cell is 26 cells wide.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.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
., _ 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.sessionId) and sets the file's top-level sessionId to itself./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./rename during a session is picked up at the next /clear or restart.<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)..claude/session-panel/ has a .gitignore with *, so git ignores the whole folder. The project's own .gitignore is not changed.{ "version": 1, "member": "<name>", "sessionId": "<owner session id>", "entries": [...] }. member is the session's name, or its session id when it has no name.{ 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.<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.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.Off by default. Turn it on in Settings → Assumptions → Decision step. It points the model at the assumptions worth reading this turn.
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.decide.json wins over it.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.TYPESAFE_API_KEY. With no key, Jev is off and nothing is said.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.Session Panel: the Jev pass could not answer, so the local score was used..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 }, "taskhooks/register.tsx 8 lines1import type { Register } from 'claude-code'
2
3import { registerSessionPanel } from './session-panel'
4
5export const register: Register = (on, options) => {
6 registerSessionPanel(on, options)
7}
8hooks/session-panel.tsx 1886 lines1import { 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 lines1import 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}
271hooks/logic.ts 1229 lines1import 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 lines1import 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 lines1export 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