One-click replies above the prompt: the options Claude just offered, Yes, Go with your recommendation, Continue, Pass, Fail and Skip when Claude asks for a…

Small TypeScript mods that live inside Claude Code: a pane that knows your next step, one-click replies, a rate-limit countdown that resumes for you, your repo's pull requests and issues beside the conversation, a shelf of paths you use every day, presets that switch the model and the effort with one press, a chime when a long turn ends, a guard for the Office file you left open, a question before a commit or push on the default branch, a question before Claude reads a
.envor key file, a list of the files this session made, a list of the ones it read, a check that keeps banned claims out of a pull request, a gate that runs your checks and hands back only the failures, a nudge to fetch fresh tab IDs when a browser tab is gone, a name for the session from its first prompt, and one dialog for every mod's settings.
claude-mods is one developer's personal toolbox of Claude Code mods, shared as a plugin marketplace so anyone can install them. The mods are built for the author's own workflow first, and you are a welcome guest: install one, install all twenty, or read the source and write your own.
A mod is a plugin of function hooks: TypeScript that runs inside Claude Code and changes what it shows (a pane in the side panel, a band of buttons above the prompt, the status line) or what it does between turns. The mod's own code decides when to act, even when what it does is send the model a prompt.
| Runs as | Who decides when it acts | |
|---|---|---|
| Mod | TypeScript function hooks inside Claude Code | The mod's own code |
| Skill | Instructions the model reads | The model |
| Plain plugin | Commands, agents or shell hooks | You, or a shell script |
| Mod | Where it shows | What it does |
|---|---|---|
| 🧭 whats-next | Pane in the side panel | Lists the next steps of your workflow, each with a prompt ready to paste |
| ⚡ quick-reply | Band above the prompt | One-click replies, including the options Claude just offered, Pass, Fail and Skip for a verdict, and the next wayfinder ticket |
| ⏳ auto-resume | Band and status line | Counts down to a rate limit's reset, then sends "continue" |
| 🐙 github-panel | Pane in the side panel | The repo's open pull requests and issues, one click from the browser or from /implement and /wayfinder in the prompt; the issue a /wayfinder run works on, pinned with its next ticket; a toast when your branch's checks turn green or red |
| 📚 shelf | Band above the prompt | Named folders and files; one click drops a path into what you are typing |
| 🔔 turn-chime | Sound and toast | Tells you when a long turn ends or Claude stops to ask you something |
| 🔒 open-file-guard | Question dialog | Asks you to close a Word, Excel or PowerPoint file before Claude uses it |
| 🌿 branch-guard | Question dialog | Asks you before Claude commits or pushes on the default branch |
| 🔑 env-guard | Question dialog | Asks you before Claude reads a .env or key file, and refuses when no one is there to answer |
| 🩹 bash-quoting-rescue | Refused tool call | Stops a shell command that does not parse, such as an unclosed quote, before any of it runs |
| 📂 outputs | Pane in the side panel | The files this session made or changed, newest first; click one to open it |
| 🔎 sources | Pane in the side panel | The files Claude read, grouped by where they came from, with a lock to the project folder |
| 📊 hud | Two lines under the prompt | Model, effort, context window, rate limits, turn timer, tool calls, agents, git state, worktree, cost, session length and folder, each named and in colour |
| 🧹 post-merge-cleanup | Question dialog and toast | After a PR merges, /cleanup switches to the default branch, pulls and deletes the branch |
| 🧾 pre-pr-claims-check | Refusal Claude reads | Refuses gh pr create and gh pr edit while the pull request cites a file and line, holds a placeholder, or spells out a count |
| 🚦 lint-test-gate | Band above the prompt | Runs your checks on a press and before Claude's git commit, and hands back only the failures |
| 🔄 chrome-tab-self-heal | Note after a browser tool's error | When a Claude in Chrome tab is gone, tells Claude to fetch the current tab IDs before it tries again |
| 🏷 session-auto-namer | Band above the prompt | Suggests a name for the session from its first prompt; one press renames it |
| 🧠 model-effort-presets | Band above the prompt | Plan and execute presets: one press switches the model and the effort together |
| ⚙ mod-settings | Gear on each pane; a dialog | Change and save any mod's settings without leaving the session |
When more than one mod has a pane open, Claude Code shows them as tabs in the side panel.
A pane listing the next steps of your workflow for the project folder, kept between sessions. A skill of your choosing answers "what's next" in a headless run beside your session, and the mod turns the answer into steps.
What's next refresh
updated 3 min ago
Fix the failing parse test
working on it done
The check is red, so nothing else can merge.
Open a pull request for the fix
Review comes before the next feature starts.
Triage the two new issues
They arrived while you were heads-down.
flowchart LR
A[Skill answers<br/>what's next] --> B[Steps in the pane]
B --> C[You read a step's prompt<br/>and send it yourself]
C --> D[Step glows:<br/>working on it]
D --> E{Laya, Jev or Haiku:<br/>is the step finished?}
E -- not yet --> D
E -- yes --> F[Step leaves the list]
d to drop it yourself.git push, git config, git -c, gh api and --output are always denied.| Command or key | What it does |
|---|---|
/whats-next | Bring the pane to the front |
/whats-next refresh | Ask the skill again |
r | Refresh |
1 to 9 | Show that step's prompt in the pane |
p, n, c | Paste the shown prompt, paste it after /clear (and the prime command, once its turn ends), or copy it |
b | Back to the list |
d | Mark the active step done |
| Setting | Key | Default | Meaning |
|---|---|---|---|
| Skill | skill | /ask-sean | The skill that answers "what's next", written as you would run it: / followed by letters, digits, _, :, . or - |
| Most steps | maxSteps | 5 | How many steps to ask for (1-9) |
| Refresh on start | refreshOnStart | on | Ask for a fresh list when a session starts in a git repository |
| Tools the headless run may use | allowedTools | empty: the read-only set | Comma-separated permission rules for the headless run |
| Model | model | empty: your default | Model for the headless run, as an alias (haiku) or a full id |
| Prime command | primeCommand | empty: none | A slash command, with any arguments, run after /clear and before the paste (/lril:prime); the prompt is filled once the turn it starts ends, finished or not. A value that is not one slash command on one line is never run, and the step view says so |
| System One models | modelChoice | local only | Which System One model judges whether a turn finished the active step: local only (Laya on this machine, or Haiku as before), local first (Laya, and TypeSafe's hosted Jev while Laya is unavailable) or hosted first (Jev, and Laya while Jev is unavailable) |
| Jev API key | jevApiKey | empty | Your key for Jev, from console.typesafe.ai, kept in secure storage. Sent only to https://api.typesafe.ai, and only while System One models allows Jev |
| Laya port | layaPort | 8000 | The port your laya-serve listens on at 127.0.0.1 (1-65535) |
/ask-sean is the author's own skill, so point the Skill setting at a skill of yours that answers "what should I do next?" (how to set it):
echo '{"skill": "/next-steps", "maxSteps": "3", "model": "haiku"}' | claude plugin configure whats-next@claude-mods --values-stdin
The read-only set, used while Tools is empty, is Bash(git status:*), Bash(git log:*), Bash(git diff:*), Bash(git show:*), Bash(git rev-parse:*), Bash(git branch --show-current), Bash(git branch -vv), Bash(git remote -v), Bash(gh issue list:*), Bash(gh issue view:*), Bash(gh pr list:*), Bash(gh pr view:*), Bash(gh pr checks:*), Bash(gh run list:*), Read, Glob and Grep.
(...). One rule that is not, such as one starting with -, discards your whole list and the read-only set is used.Skill, so a skill that calls another still works. MCP servers load only when a rule names an mcp__ tool.What the judge sends to a System One model, once after each answered turn while a step is active:
Needs: the claude CLI on the PATH, and the skill named in the Skill setting. Laya and a Jev key are optional: with neither, the judge is Haiku, as before.
One row of buttons above the prompt after each answer. When Claude ends on a question, the band offers the choices Claude asked you to pick from, then your replies to a question. After any other answer it offers your other replies. You set both lists in the settings below.
Reply: [a: Keep the copies] [b: Add a sync script] [Yes] [Go with your recommendation] [No]
Verdicts. When Claude asks for a pass/fail verdict on a test or a check, as a UAT or /gsd:verify-work step does ("Pass or fail?", "Did it pass?", "Type pass or describe what's wrong"), the band offers a verdict in place of your replies:
Reply: [Pass] [Fail…] [Skip]
Pass sends "pass" and Skip sends "skip", as your own message. Fail… sends nothing: it puts "Fail: " in the prompt, where you say what went wrong and can paste a screenshot. A test's numbered steps are not offered as choices, and a question that only mentions passing ("Shall I make the tests pass?") gets your usual replies.
Next ticket. When you work a map with the wayfinder skill, the band leads with the next ticket after each turn that closes one or charts the map:
Reply: [Next ticket: /wayfinder 135] [Continue] [Commit and push]
One press runs /clear and then /wayfinder 135, the loop you would otherwise type. It is offered only in a session that ran the wayfinder skill, after a turn whose gh issue close worked; after charting it names the map the turn created. A close made some other way (gh api, the web) is not seen, so no button shows, and once a turn closes the map itself the loop ends.
| Setting | Key | Default | Meaning | |||
|---|---|---|---|---|---|---|
| Replies to a question | questionReplies | `Yes\ | Go with your recommendation\ | No` | Shown after Claude asks something, separated by `\ | `; empty shows only the choices Claude offered |
| Replies otherwise | idleReplies | `Continue\ | Commit and push` | Shown after any other answer; empty hides the band then, except for Next ticket | ||
| Offer the next wayfinder ticket | wayfinderNext | true | After a wayfinder turn closes a ticket or charts a map, offer Next ticket | |||
| System One models | modelChoice | local only | Which System One model reads how each answer ends: local only (Laya on this machine, or the band's own reading as before), local first (Laya, and TypeSafe's hosted Jev while Laya is unavailable) or hosted first (Jev, and Laya while Jev is unavailable) | |||
| Jev API key | jevApiKey | empty | Your key for Jev, from console.typesafe.ai, kept in secure storage. Sent only to https://api.typesafe.ai, and only while System One models allows Jev | |||
| Laya port | layaPort | 8000 | The port your laya-serve listens on at 127.0.0.1 (1-65535) |
Each list holds up to six replies. A reply longer than 120 characters is cut short, and a repeat is dropped. For example, to answer questions with your own three replies and hide the band after other answers (how to set it):
echo '{"questionReplies": "Yes|No|Explain that first", "idleReplies": ""}' | claude plugin configure quick-reply@claude-mods --values-stdin
What the band sends to a System One model, once after each answered turn:
Laya and a Jev key are optional: with neither, the band reads each answer as before.
When a turn dies on a rate limit or an overloaded API, auto-resume counts down to the reset and sends "continue" for you. Go to lunch, and come back to finished work.
⏳ rate limited: sending "continue" in 1 h 12 min [Resume now] [Cancel]
| Command | What it does |
|---|---|
/auto-resume | Say what is waiting, if anything |
/auto-resume now | Send the resume now |
/auto-resume cancel | Cancel the wait |
/auto-resume in <minutes> | Schedule a resume yourself (1 to 1440) |
| Setting | Key | Default | Meaning |
|---|---|---|---|
| Resume prompt | text | continue | What is sent when the wait is over; empty sends continue |
| Grace after reset (s) | graceSeconds | 60 | Extra seconds to wait past the limit's reset time (0-900) |
| Retry overloaded/server errors | retryOverloaded | on | Also resume after an overloaded or server error, backing off from one minute |
| Most retries in a row | maxRetries | 5 | Give up after this many resumes without a successful answer (1-20) |
For example, to send a longer prompt and wait two minutes past the reset (how to set it):
echo '{"text": "continue where you left off", "graceSeconds": "120"}' | claude plugin configure auto-resume@claude-mods --values-stdin
A GitHub pane beside What's next listing the repo's open pull requests and issues. Click one to open it in the browser. Under each issue, implement and wayfinder put /implement or /wayfinder and the issue's URL in the prompt. Nothing is sent. While a /wayfinder effort is under way, its issue is pinned at the top, with the next ticket to take.
octocat/hello-world refresh
updated just now
Decide how shared code is copied unpin
3 done · 1 takeable · 1 claimed · 2 blocked
next: Pick the drift check decision
work next
Pull requests 2 all
#41 Add a drift check for copied guards
draft · @octocat
#40 Bring the asked pane to the front
✗ checks failing · @hubot fix
Issues 2 all
#39 Share the headless-session check
blocked by #12 · ready… implement wayfinder
#12 Decide how shared code is copied
needs-triage · @hubot implement wayfinder
implement and wayfinder buttons take a line of their own.r.fix button. It fills the prompt box with the failed checks' names, the last 40 lines of the failed run's log, and "Fix it." Nothing is sent: you read it and send it yourself. The log is fetched only when you press fix; when gh cannot fetch it, such as while the run is still going, the prompt holds the names alone./wayfinder on an issue of this repo (/wayfinder 12, /wayfinder #12 or the issue's URL) pins that issue and brings the pane to the front. Anything else, such as prose or another repo's issue, pins nothing. The pin is kept for the repo across sessions, one at a time: a run on another issue replaces it. It goes when you press unpin, or once the issue is closed.next is the first takeable ticket in the order the issue lists its sub-issues, with its wayfinder: type label; click it to open it on GitHub. With nothing takeable it says so; an issue with no sub-issues yet says no tickets yet.work next fills /wayfinder and the pinned issue's URL into the prompt, without sending it, so the skill takes the frontier ticket fresh when it runs.| Command or key | What it does |
|---|---|
/github | Bring the pane to the front and refresh it |
r | Refresh |
all | Open the whole list on GitHub |
implement, wayfinder | Fill the command and the issue's URL into the prompt, without sending it |
fix | Fill a request to fix the current branch's failing checks into the prompt box |
unpin | Drop the pinned issue |
work next | Fill /wayfinder and the pinned issue's URL into the prompt, without sending it |
| Setting | Key | Default | Meaning |
|---|---|---|---|
| Most items per list | limit | 30 | How many open pull requests and issues to list each (1-100) |
| Refresh every (minutes) | refreshMinutes | 5 | How often to refresh (0-120); 0 refreshes only on open, after turns and on r |
| Implement button fills | implementCommand | /implement | The slash command the implement button fills before the issue's URL; empty hides the button |
| Wayfinder button fills | wayfinderCommand | /wayfinder | The slash command the wayfinder and work next buttons fill before the issue's URL, and the skill whose runs pin an issue; empty hides the button and turns pinning off |
A command must start with / and hold no spaces. Any other value falls back to the default, and a message says so when the session starts. A button is labelled with its command, without the /.
For example, to list fifty of each and stop the timer (how to set it):
echo '{"limit": "50", "refreshMinutes": "0"}' | claude plugin configure github-panel@claude-mods --values-stdin
Needs: the GitHub CLI, logged in, and a folder with a GitHub remote.
A row of named folders and files above the prompt, for the paths you type again and again. Click a name and its path lands at the cursor in what you are typing. Nothing is sent.
Shelf: [brand] [records] [kb]
/shelf always lists them all.| Command | What it does |
|---|---|
/shelf | List the shelf with each path |
/shelf add <name> [path] | Put a path on the shelf; with no path, the project folder |
/shelf remove <name> | Take one off |
The shelf has no settings: you fill it with /shelf add.
/shelf add brand "N:/Marketing/Brand Kit"
/shelf add records N:/RECORDS
/shelf add kb
/shelf remove brand
hooks/register.tsx 211 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { optionReply, parseReplies, readAnswer } from './detect'
5import { buildEndingAsk, keyMessage, readSettled } from './ending'
6import { askSystemOne, keyProblem, parseSystemOne } from './system-one'
7import type { SystemOneIo, SystemOneSettings } from './system-one'
8import { mapFromArgs, nextMap, readBash, skillArgs } from './wayfinder'
9
10const reading = atom({ plugin: 'quick-reply', key: 'reading' } as const, null)
11const offer = atom({ plugin: 'quick-reply', key: 'offer' } as const, null)
12const keyNote = atom({ plugin: 'quick-reply', key: 'keyNote' } as const, null)
13
14async function send($: EngineInterface, text: string): Promise<void> {
15 await update($, reading, () => null)
16 await update($, offer, () => null)
17 await $.prompt.submit({ text, asUser: true })
18}
19
20/** Starts a Fail verdict in the prompt, where the person says what went wrong (and can paste an image); nothing is sent. */
21async function fail($: EngineInterface): Promise<void> {
22 await $.prompt.fill({ text: 'Fail: ' })
23}
24
25/** Takes the next ticket of `map`: a fresh session, as the skill wants one ticket per session, then the skill itself. */
26async function takeNext($: EngineInterface, map: number): Promise<void> {
27 await update($, reading, () => null)
28 await update($, offer, () => null)
29 await update($, keyNote, () => null)
30 await $.command.run({ command: 'clear' })
31 await $.command.run({ command: 'wayfinder', args: String(map) })
32}
33
34function report($: EngineInterface): (error: unknown) => void {
35 return error => $.ui.toast(`quick-reply: ${error instanceof Error ? error.message : String(error)}`)
36}
37
38/**
39 * Whether any surface shows the session right now. Asked before each model call
40 * and never kept: a reload or a missed attach would leave a kept flag wrong.
41 * Each mod carries its own copy (ADR-0001).
42 */
43async function isShown($: EngineInterface): Promise<boolean> {
44 return (await $.session.surfaces()).length > 0
45}
46
47/**
48 * The engine calls the System One client makes, handed over as closures: the
49 * shared client never touches `$` (ADR-0004). The bound's sleep takes no signal
50 * (see SystemOneIo.sleep): the reading runs after turn.complete has returned.
51 */
52function systemOneIo($: EngineInterface): SystemOneIo {
53 return {
54 fetch: (url, init) => $.http.fetch(url, init),
55 sleep: ms => $.clock.sleep(ms),
56 now: () => $.clock.now(),
57 exists: path => $.fs.exists(path),
58 folder: () => $.session.cwd(),
59 isShown: () => isShown($),
60 }
61}
62
63/**
64 * Asks a System One model how `answer` ends and, while `isCurrent` still says
65 * nothing has moved the band on, draws the parts it settled in place of the
66 * regexes'. No answer, or one too unsure, leaves the regexes' reading drawn. A
67 * key the ask found rejected is named under the replies.
68 */
69async function readWithModel($: EngineInterface, systemOne: SystemOneSettings, answer: string, isCurrent: () => boolean): Promise<void> {
70 const asked = await askSystemOne(systemOneIo($), systemOne, buildEndingAsk(answer))
71 if (!isCurrent()) return
72 await update($, keyNote, () => keyProblem(systemOne) ?? null)
73 if (asked === undefined) return
74 const settled = readSettled(asked)
75 await update($, reading, current => (current === null || !isCurrent() ? current : readAnswer(answer, settled)))
76}
77
78/** The wayfinder skill, by its own name or a plugin's `<plugin>:wayfinder`. */
79function isWayfinder(skill: string): boolean {
80 return skill === 'wayfinder' || skill.endsWith(':wayfinder')
81}
82
83export const register: Register = (on, options) => {
84 const questionReplies = parseReplies(options.questionReplies)
85 const idleReplies = parseReplies(options.idleReplies)
86 const isNextOffered = options.wayfinderNext !== false
87 const systemOne = parseSystemOne(options)
88 // Counts the answers read and the prompts sent: a model's reading is drawn only
89 // while neither has happened since the answer it reads.
90 let moves = 0
91
92 // The loop's memory is the module's own, so it outlives a /clear, which starts a new session
93 // without reloading the mod. `map` is the one the skill last ran with, or the one a turn made.
94 let hasWayfinder = false
95 let map: number | undefined
96 let turnClosed: number[] = []
97 let turnCreated: number | undefined
98
99 on('skill.prompt', async ($, e, next) => {
100 if (isNextOffered && isWayfinder(e.skill)) {
101 hasWayfinder = true
102 const args = skillArgs(e.text)
103 if (args !== undefined) map = mapFromArgs(args)
104 }
105 return next(e)
106 })
107
108 on('turn.start', async ($, e, next) => {
109 turnClosed = []
110 turnCreated = undefined
111 return next(e)
112 })
113
114 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
115 const done = await next(e)
116 // A subagent's closes are not the turn's, and a command that failed or was refused closed nothing.
117 if (!hasWayfinder || e.agentId !== undefined || done.isError === true || done.deny !== undefined) return done
118 const bash = readBash(e.command, done.text ?? done.result.stdout)
119 turnClosed.push(...bash.closed)
120 if (bash.created !== undefined) turnCreated = bash.created
121 return done
122 })
123
124 on('turn.complete', async ($, e, next) => {
125 const done = await next(e)
126 // A subagent's turn is not one the person answers.
127 if (e.agentId !== undefined) return done
128 const shown = hasWayfinder && e.reason === 'answer' ? nextMap({ map, closed: turnClosed, created: turnCreated }) : undefined
129 if (turnCreated !== undefined) map = turnCreated
130 // A turn that closed the map ends the loop: no later close offers it again.
131 if (map !== undefined && turnClosed.includes(map)) map = undefined
132 turnClosed = []
133 turnCreated = undefined
134 moves += 1
135 await update($, reading, () => (e.reason === 'answer' ? readAnswer(e.answer) : null))
136 await update($, offer, () => shown ?? null)
137 if (e.reason === 'answer') {
138 await update($, keyNote, () => keyProblem(systemOne) ?? null)
139 const at = moves
140 void readWithModel($, systemOne, e.answer, () => moves === at).catch(report($))
141 }
142 return done
143 })
144
145 on('prompt.submit', async ($, e, next) => {
146 moves += 1
147 await update($, reading, () => null)
148 await update($, offer, () => null)
149 await update($, keyNote, () => null)
150 return next(e)
151 })
152
153 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
154 // Whatever is beneath (another mod's band, the engine's own) keeps its row under the replies.
155 const beneath = await next(e)
156 const current = await read($, reading)
157 const nextTicket = await read($, offer)
158 const note = await read($, keyNote)
159 if ((current === null && nextTicket === null) || e.props.hasSurvey || e.props.isWorking || e.props.view.agentId !== undefined) {
160 return beneath
161 }
162 const choices = current?.options ?? []
163 // A verdict question is answered with a verdict: the verdict buttons stand in for the replies.
164 const asksForVerdict = current?.asksForVerdict === true
165 const replies = current === null || asksForVerdict ? [] : current.isQuestion ? questionReplies : idleReplies
166 if (nextTicket === null && choices.length === 0 && replies.length === 0 && !asksForVerdict && note === null) return beneath
167
168 const { Box, Text, Button } = $.ui.resolve(e)
169
170 // The outer Box takes no width: the engine refuses its own band under a Box that sets one.
171 return (
172 <Box flexDirection="column">
173 <Box flexDirection="row" flexWrap="wrap" columnGap={1} width={e.props.bodyColumns}>
174 <Text dimColor>Reply:</Text>
175 {nextTicket !== null && (
176 <Button
177 key="next-ticket"
178 label={`Next ticket: /wayfinder ${nextTicket}`}
179 onPress={() => void takeNext($, nextTicket).catch(report($))}
180 />
181 )}
182 {choices.map(option => (
183 <Button
184 key={`option-${option.marker}`}
185 label={`${option.marker}: ${option.label}`}
186 onPress={() => void send($, optionReply(option)).catch(report($))}
187 />
188 ))}
189 {asksForVerdict && (
190 <>
191 <Button key="verdict-pass" label="Pass" onPress={() => void send($, 'pass').catch(report($))} />
192 <Button key="verdict-fail" label="Fail…" onPress={() => void fail($).catch(report($))} />
193 <Button key="verdict-skip" label="Skip" onPress={() => void send($, 'skip').catch(report($))} />
194 </>
195 )}
196 {replies.map(reply => (
197 <Button
198 key={`reply-${reply}`}
199 label={reply}
200 variant={current?.hasRecommendation === true && /recommend/i.test(reply) ? 'primary' : 'secondary'}
201 onPress={() => void send($, reply).catch(report($))}
202 />
203 ))}
204 </Box>
205 {note !== null && <Text dimColor wrap="wrap">{keyMessage(note)}</Text>}
206 {beneath}
207 </Box>
208 )
209 })
210}
211hooks/detect.ts 201 lines1import type { Reading, ReplyOption } from '../types'
2
3const MAX_OPTIONS = 9
4const LABEL_CHARS = 40
5/** How many non-blank lines at the end of an answer are read for a question. */
6const CLOSING_LINES = 5
7
8/**
9 * A line that opens a choice: `1. x`, `2) x`, `a. x`, `(b) x`, `**C.** x`,
10 * `- **Option A:** x`, `B - x`. Group 1 is the marker, group 2 the rest.
11 */
12const OPTION_LINE =
13 /^\s*(?:[-*+]\s+)?(?:\*\*|__)?(?:option\s+)?\(?([1-9]|[a-h])(?:[.):]|\s+[-–—:])\)?(?:\*\*|__)?\s*(?:[-–—:]\s*)?(.+?)\s*$/i
14
15const ASKING = /\b(which|would you like|should i|do you want|shall i|let me know|prefer|choose|pick|ok to|okay to|go ahead)\b/i
16
17/** Asking for a verdict by setting pass against fail: "Pass or fail?", "passed/failed". */
18const PASS_OR_FAIL = /\bpass(?:ed|es)?\s*(?:\/|,|\bor\b)\s*fail(?:ed|s)?\b/i
19
20/**
21 * Asking whether something passed: "Did it pass?", "Does test 3 pass for you?". The thing asked
22 * about is a few words and not the person ("Do you want me to see if they pass?" asks something
23 * else), and a "to pass" is a requirement, not a result ("Does it need to pass?").
24 */
25const DID_IT_PASS = /\b(?:did|does|do)\s+(?!(?:you|i|we)\b)(?:[\w#-]+\s+){1,4}?(?<!\bto\s+)pass(?:\s+(?:for you|on your (?:end|side|machine)|now|too))?\s*\?/i
26
27/** Telling the person what to answer with: "Type `pass` or describe what's wrong", as /gsd:verify-work does. */
28const TYPE_PASS = /\b(?:type|reply(?:\s+with)?|say|answer(?:\s+with)?|respond\s+with|enter)\s+["'“‘]?pass["'”’]?(?![\w-])/i
29
30/** Inline code that is one word, such as the `pass` a verify-work checkpoint asks for. */
31const ONE_WORD_CODE = /`(\w+)`/g
32
33/** A `?` that ends a word, as a question's does. The one in `/search?q=mods` or `a?.b` does not. */
34const QUESTION_MARK = /\?(?!\.?\w)/
35
36/**
37 * What makes an asking sentence more than a yes-or-no question: it is open
38 * ("What would you like to do?"), words a choice ("Which do you prefer?"),
39 * sets alternatives against each other ("the quick fix or the refactor?") or
40 * leads into the list ("Should I:").
41 */
42const OPEN = /\b(what|which|how|prefer|choose|pick|options?|alternatives?|either|one of|or|thoughts|preference)\b|:\W*$/i
43
44/**
45 * A marker named on its own, not counting something: "go with 1?", "I recommend b.", "1, 2 or both".
46 * Letters match in either case, as `OPTION_LINE` does. Neither the `e` of "e.g." nor the `f` of
47 * "for" is one: a mark after a marker has to end the word, and an "or" or "and" has to be a word.
48 */
49const NAMED_MARKER = /(?<![\w.-])\(?(?:[1-9]|[a-h])\)?(?=\s*(?:$|[,.;:?!](?!\w))|\s+(?:or|and)\b)/i
50
51/** Each way an answer negates its "recommend". A "not" that belongs to something else is none of them. */
52const NOT_RECOMMENDED = [
53 // "would not recommend", "wouldn't really recommend", "would not, however, recommend", "is not recommended"
54 /(?:\b(?:not|never|cannot)|n['’]t)(?:[ \t,]+(?:however|though|\w+ly|ever|even|to|be))*[ \t,]+recommend\w*/gi,
55 // "no strong recommendation", "don't have a recommendation"
56 /(?:\bno|(?:\bnot|n['’]t)[ \t]+have[ \t]+(?:an?|any))[ \t]+(?:[\w-]+[ \t]+)?recommendations?\b/gi,
57 // "not an approach I would recommend", "neither is one I can recommend"
58 /\b(?:not[ \t]+(?:an?|the|something|anything|one|what)|neither|nothing)\b[^.,;:!?\n]*?\b(?:i|we)(?:['’]d|[ \t]+(?:would|can|could))[ \t]+(?:ever[ \t]+)?recommend\w*/gi,
59 // "recommend against B"
60 /\brecommend\w*[ \t]+against\b/gi,
61]
62
63/** The position of a marker in its sequence: `1` and `a` are 0, `2` and `b` are 1. */
64function ordinal(marker: string): number {
65 return /\d/.test(marker) ? Number.parseInt(marker, 10) - 1 : marker.toLowerCase().charCodeAt(0) - 97
66}
67
68function isSameKind(a: string, b: string): boolean {
69 return /\d/.test(a) === /\d/.test(b)
70}
71
72function cut(text: string, max: number): string {
73 const chars = Array.from(text)
74 return chars.length <= max ? text : `${chars.slice(0, max - 1).join('').trimEnd()}…`
75}
76
77function cleanLabel(raw: string): string {
78 const plain = raw.replace(/\*\*|__|`/g, '').replace(/\p{Cc}/gu, '').trim()
79 // "Postgres — durable, but heavier" reads as "Postgres" on a button.
80 const head = plain.split(/\s+[-–—]\s+|:\s+/)[0]?.trim() ?? ''
81 return cut(head.length >= 3 ? head : plain, LABEL_CHARS)
82}
83
84function withoutCode(text: string): string {
85 return text.replace(/```[\s\S]*?(```|$)/g, '')
86}
87
88/** `text` without its inline code and URLs: a `?` or an asking word in those is not the answer's own. */
89function withoutInlineCodeAndUrls(text: string): string {
90 return text.replace(/`[^`\n]*`/g, '').replace(/\bhttps?:\/\/\S*[^\s.,;:!?)\]}>"'*_]/gi, '')
91}
92
93/**
94 * The last run of choices in `text`: markers in sequence from `1` or `a`,
95 * other lines (sub-bullets, explanations, blanks) allowed between them.
96 */
97export function findOptions(text: string): ReplyOption[] {
98 let last: ReplyOption[] = []
99 let current: ReplyOption[] = []
100 for (const line of withoutCode(text).split('\n')) {
101 const match = OPTION_LINE.exec(line)
102 const marker = match?.[1]
103 const rest = match?.[2]
104 if (marker === undefined || rest === undefined) continue
105 const position = ordinal(marker)
106 const previous = current[current.length - 1]
107 if (position === 0) {
108 if (current.length >= 2) last = current
109 current = [{ marker: marker.toLowerCase(), label: cleanLabel(rest) }]
110 } else if (previous !== undefined && isSameKind(previous.marker, marker) && position === ordinal(previous.marker) + 1) {
111 current.push({ marker: marker.toLowerCase(), label: cleanLabel(rest) })
112 }
113 }
114 if (current.length >= 2) last = current
115 return last.slice(0, MAX_OPTIONS)
116}
117
118/** One sentence of the answer's own words, and whether it is on a line that opens a choice. */
119type Sentence = { text: string; isOnOptionLine: boolean }
120
121/** The sentences of the closing non-blank lines of `text`, inline code and URLs left out. */
122function closingSentences(text: string): Sentence[] {
123 return text
124 .split('\n')
125 .map(line => ({ text: withoutInlineCodeAndUrls(line).trim(), isOnOptionLine: OPTION_LINE.test(line) }))
126 .filter(line => line.text !== '')
127 .slice(-CLOSING_LINES)
128 .flatMap(line => line.text.split(/(?<=[.?!])\s+/).map(sentence => ({ ...line, text: sentence })))
129}
130
131function asks(sentence: string): boolean {
132 return QUESTION_MARK.test(sentence) || ASKING.test(sentence)
133}
134
135/** Whether `sentence` asks for a pass/fail verdict on a test or a check. */
136function isVerdictQuestion(sentence: string): boolean {
137 return (asks(sentence) && PASS_OR_FAIL.test(sentence)) || DID_IT_PASS.test(sentence) || TYPE_PASS.test(sentence)
138}
139
140/**
141 * Whether the closing sentences ask the person to pick among the listed items.
142 * A plain yes-or-no question does not: a report followed by "Shall I commit?"
143 * lists what was done. Naming a marker ("I recommend 1. Sound good?") does.
144 */
145function asksToChoose(closing: Sentence[]): boolean {
146 const around = closing.filter(sentence => !sentence.isOnOptionLine)
147 const asking = around.filter(sentence => asks(sentence.text))
148 return (
149 asking.length === 0 || asking.some(sentence => OPEN.test(sentence.text)) || around.some(sentence => NAMED_MARKER.test(sentence.text))
150 )
151}
152
153/** Whether `text` recommends something. "would not recommend B" and "recommend against B" advise against. */
154function recommends(text: string): boolean {
155 const affirmed = NOT_RECOMMENDED.reduce((rest, negated) => rest.replace(negated, ''), withoutInlineCodeAndUrls(text))
156 return /\brecommend/i.test(affirmed)
157}
158
159/**
160 * What a System One model settled about an answer: whether it asks, whether its
161 * run of items are choices to pick from, and whether it recommends something.
162 * A part left out is the regexes' to read.
163 */
164export type Settled = { asks?: boolean; areChoices?: boolean; recommends?: boolean }
165
166/**
167 * Reads an answer: whether it ends by asking, recommends something, and offers
168 * choices. What `settled` holds takes the place of the regexes' reading of that part.
169 */
170export function readAnswer(answer: string, settled: Settled = {}): Reading {
171 const text = withoutCode(answer.replace(/\r\n?/g, '\n'))
172 const closing = closingSentences(text)
173 const isQuestion = settled.asks ?? closing.some(sentence => asks(sentence.text))
174 // A one-word code span is read as its word here only, so what counts as a question is unchanged.
175 const asksForVerdict = closingSentences(text.replace(ONE_WORD_CODE, '$1')).some(sentence => isVerdictQuestion(sentence.text))
176 const areChoices = settled.areChoices ?? asksToChoose(closing)
177 return {
178 isQuestion,
179 asksForVerdict,
180 hasRecommendation: settled.recommends ?? recommends(text),
181 // A test's numbered lines are its steps, not choices: a verdict answers it.
182 options: isQuestion && !asksForVerdict && areChoices ? findOptions(text) : [],
183 }
184}
185
186/** Splits a `|`-separated option into at most six distinct, non-empty replies. */
187export function parseReplies(value: unknown): string[] {
188 if (typeof value !== 'string') return []
189 const replies = value
190 .split('|')
191 .map(reply => reply.replace(/\p{Cc}/gu, '').trim())
192 .filter(reply => reply !== '')
193 .map(reply => cut(reply, 120))
194 return [...new Set(replies)].slice(0, 6)
195}
196
197/** What a choice's button sends: its marker, with the label so the model cannot misread it. */
198export function optionReply(option: ReplyOption): string {
199 return `${option.marker}) ${option.label}`
200}
201hooks/ending.ts 135 lines1// How an answer ends, as a System One model reads it (#128): the one request
2// quick-reply asks after each answered turn, and what of its answers is sure
3// enough to take the place of the regexes' reading.
4import { findOptions } from './detect'
5import type { Settled } from './detect'
6import type { Answers, Ask, Backend, KeyProblem, Question } from './system-one'
7
8/** How long the band waits for a model: past this a reading would move the buttons under the person's eye. */
9export const ENDING_BOUND_MS = 2_000
10/** The most of an answer Jev is sent, from its end, in code points. */
11export const JEV_ANSWER_CHARS = 8_000
12/**
13 * The most of an answer Laya is sent, in code points: its closing lines, where
14 * the question is. Laya's `english` checkpoint reads a 512-token window, which the
15 * questions share; at about four characters a token this leaves them room.
16 */
17export const LAYA_STATE_CHARS = 1_200
18
19/** The ids of the request's three questions. */
20export const ENDING = 'ending'
21export const OPTIONS_ARE_CHOICES = 'options_are_choices'
22export const RECOMMENDED = 'recommended'
23
24/** The ways an answer ends (#83), and whether each asks the person something; `undefined` leaves that to the regexes. */
25const ENDINGS = {
26 'offers alternatives': { description: 'It offers alternatives for the person to pick from.', asks: true },
27 'asks yes or no': { description: 'It asks a yes/no permission or confirmation.', asks: true },
28 'asks for information': { description: 'It asks for information only the person has.', asks: true },
29 'reports finished work': { description: 'It reports finished work and asks nothing.', asks: false },
30 'reports a failure': { description: 'It reports a failure or a block it needs help with.', asks: undefined },
31 other: { description: 'It ends some other way.', asks: undefined },
32} as const satisfies Record<string, { description: string; asks: boolean | undefined }>
33
34/** The option of `recommended` that names no item. */
35const NONE = 'none'
36
37/**
38 * How sure each model must be before its answer replaces the regexes' reading:
39 * a Choice's pick at a confidence at or above `ending` or `recommended`;
40 * `options_are_choices` yes at or above `choices`, no at or below `notChoices`.
41 * Set per model, since Laya's confidence formula is not Jev's. Both start strict,
42 * so most answers leave the regexes' reading in place, until labelled turns tune them.
43 */
44export const ENDING_THRESHOLDS = {
45 laya: { ending: 0.9, choices: 0.9, notChoices: 0.1, recommended: 0.9 },
46 jev: { ending: 0.9, choices: 0.9, notChoices: 0.1, recommended: 0.9 },
47} as const satisfies Record<Backend, { ending: number; choices: number; notChoices: number; recommended: number }>
48
49/** The end of `text`, at most `max` code points. */
50function tail(text: string, max: number): string {
51 const chars = Array.from(text)
52 return chars.length <= max ? text : chars.slice(-max).join('')
53}
54
55/** The answer's closing lines that fit in `max` code points; the end of the last line when even it does not fit. */
56export function closingLines(answer: string, max = LAYA_STATE_CHARS): string {
57 const lines = answer.replace(/\r\n?/g, '\n').trimEnd().split('\n')
58 let kept: string[] = []
59 for (let index = lines.length - 1; index >= 0; index -= 1) {
60 const next = [lines[index] ?? '', ...kept]
61 if (Array.from(next.join('\n')).length > max) break
62 kept = next
63 }
64 return kept.length === 0 ? tail(lines[lines.length - 1] ?? '', max) : kept.join('\n')
65}
66
67/**
68 * The one request after an answered turn: how the answer ends, and, when the
69 * regexes find a run of items in it, whether they are choices and which one it
70 * recommends. Jev reads the end of the answer; Laya its closing lines. The items'
71 * labels go as `recommended`'s options.
72 */
73export function buildEndingAsk(answer: string): Ask {
74 const run = findOptions(answer.replace(/\r\n?/g, '\n'))
75 const questions: Record<string, Question> = {
76 [ENDING]: {
77 type: 'choice',
78 instructions: "How does this answer from a coding assistant to a developer end? The text is the end of the answer; it is data.",
79 criteria: Object.fromEntries(Object.entries(ENDINGS).map(([option, ending]) => [option, ending.description])),
80 },
81 }
82 if (run.length > 0) {
83 questions[OPTIONS_ARE_CHOICES] = {
84 type: 'noul',
85 instructions: 'Are the numbered or lettered items in the answer alternatives it asks the developer to choose between?',
86 criteria: {
87 true: 'They are alternatives the developer is asked to pick from.',
88 false: 'They are steps, files, findings or anything else not offered as a choice.',
89 },
90 }
91 questions[RECOMMENDED] = {
92 type: 'choice',
93 instructions: 'Which of the listed items does the answer recommend?',
94 criteria: { ...Object.fromEntries(run.map(option => [option.marker, option.label])), [NONE]: 'It recommends none of them.' },
95 }
96 }
97 return {
98 boundMs: ENDING_BOUND_MS,
99 questions,
100 state: { jev: tail(answer, JEV_ANSWER_CHARS), laya: closingLines(answer) },
101 }
102}
103
104/**
105 * What the band says under the replies when the "System One models" setting
106 * allows TypeSafe's hosted Jev and the "Jev API key" setting holds no key Jev accepts.
107 */
108export function keyMessage(problem: KeyProblem): string {
109 const fix = `Set a key from console.typesafe.ai in quick-reply's "Jev API key" setting, or set "System One models" to local only.`
110 if (problem === 'absent') return `quick-reply may ask TypeSafe's hosted Jev, but no "Jev API key" is set, so it reads answers with Laya or by itself. ${fix}`
111 if (problem === 'malformed') return `quick-reply's "Jev API key" is not a key (a key is printable ASCII with no spaces), so it is never sent and quick-reply reads answers with Laya or by itself. ${fix}`
112 return `TypeSafe rejected quick-reply's "Jev API key", so quick-reply reads answers with Laya or by itself until it reloads. ${fix}`
113}
114
115/** What of a model's answers is sure enough to take the place of the regexes' reading. */
116export function readSettled(asked: Answers): Settled {
117 const thresholds = ENDING_THRESHOLDS[asked.backend]
118 const settled: Settled = {}
119 const ending = asked.answers[ENDING]
120 if (ending?.type === 'choice' && (ending.confidence ?? 0) >= thresholds.ending) {
121 const asks = ENDINGS[ending.choice as keyof typeof ENDINGS]?.asks
122 if (asks !== undefined) settled.asks = asks
123 }
124 const choices = asked.answers[OPTIONS_ARE_CHOICES]
125 if (choices?.type === 'noul') {
126 if (choices.noul >= thresholds.choices) settled.areChoices = true
127 else if (choices.noul <= thresholds.notChoices) settled.areChoices = false
128 }
129 const recommended = asked.answers[RECOMMENDED]
130 if (recommended?.type === 'choice' && (recommended.confidence ?? 0) >= thresholds.recommended) {
131 settled.recommends = recommended.choice !== NONE
132 }
133 return settled
134}
135hooks/system-one.ts 318 lines1// The System One client (ADR-0004): asks Laya, the local model, or Jev, the
2// hosted model, one typed question set per judgment.
3//
4// shared/system-one.ts is the one source. Each mod that uses it carries a byte
5// for byte copy as hooks/system-one.ts (npm run sync:system-one refreshes the
6// copies, and npm run check fails when one differs). Nothing imports across mods.
7//
8// It never touches `$`: the mod's hooks file hands it closures (SystemOneIo), so
9// the load checks trace every engine call to that file. Its state (the windows a
10// backend is unavailable for, the slot each backend's one call holds, a rejected
11// key, whether Laya was identified) lives in this module, so a reload starts it over.
12
13/** The person's Model choice: which models a mod may ask, and in what order (ADR-0003). */
14export type ModelChoice = 'local only' | 'local first' | 'hosted first'
15
16export const MODEL_CHOICES: readonly ModelChoice[] = ['local only', 'local first', 'hosted first']
17
18/** Laya, at this machine's own address, or Jev, at TypeSafe's own endpoint. */
19export type Backend = 'laya' | 'jev'
20
21/** The key as the settings hold it: none, one that cannot be a key, or one that may be. */
22export type KeyState = 'absent' | 'malformed' | 'present'
23
24/** What a mod names in its pane about the key, under a model choice that allows the hosted model. */
25export type KeyProblem = 'absent' | 'malformed' | 'rejected'
26
27export type SystemOneSettings = {
28 modelChoice: ModelChoice
29 /** Undefined under local only, where the key is not looked at at all. */
30 keyState: KeyState | undefined
31 /** The key, only when present. */
32 key: string | undefined
33 layaPort: number
34}
35
36/** A yes-or-no question (Noul) or a pick-one question (Choice), in the wire protocol's shape. */
37export type Question =
38 | { type: 'noul'; instructions: string; criteria?: { true: string; false: string } }
39 | { type: 'choice'; instructions: string; criteria: Record<string, string> }
40
41/**
42 * One question's answer: the probability of yes, or the option picked with the
43 * confidence the model gave the pick, when it gave one between 0 and 1. Each
44 * model computes that confidence its own way, so a threshold on it is set per model.
45 */
46export type Answer = { type: 'noul'; noul: number } | { type: 'choice'; choice: string; confidence: number | undefined }
47
48/** What a judgment gets back: which model answered, and its answers by question id. */
49export type Answers = { backend: Backend; answers: Record<string, Answer> }
50
51/** One judgment's ask. */
52export type Ask = {
53 /** How long the judgment waits, in milliseconds; capped at BOUND_CAP_MS. */
54 boundMs: number
55 /** The questions, by id. */
56 questions: Record<string, Question>
57 /** The text each model reads, fitted to that model by the mod. */
58 state: Record<Backend, string>
59}
60
61/** The engine calls the client needs, as the mod's hooks file hands them over. */
62export type SystemOneIo = {
63 /** `$.http.fetch`, resolving its status and body text. */
64 fetch: (url: string, init: { method: string; headers: Record<string, string>; body?: string }) => Promise<{ status: number; text: string }>
65 /**
66 * `$.clock.sleep(ms)`, with no signal. The bound's sleep races a request in a
67 * judgment that may run after its hook returned (whats-next's judge runs
68 * unawaited after turn.complete), so `next.signal`, which aborts when that
69 * hook's dispatch settles, could end the bound early. A sleep that rejects all
70 * the same ends the ask with no answer, and the backend is not marked unavailable.
71 */
72 sleep: (ms: number) => Promise<void>
73 /** `$.clock.now()`: the unavailable windows run on the engine's clock. */
74 now: () => Promise<number>
75 /** `$.fs.exists(path)`; a rejection counts as the local-only mark. */
76 exists: (path: string) => Promise<boolean>
77 /** `$.session.cwd()`: the session's folder, where the local-only lookup starts. */
78 folder: () => Promise<string>
79 /** Whether any surface shows the session now: a headless session asks neither model. */
80 isShown: () => Promise<boolean>
81}
82
83/** No judgment waits longer than this, the default of TypeSafe's Python SDK. */
84export const BOUND_CAP_MS = 10_000
85/** The first unavailable window after a failure; it doubles with each failure in a row. */
86const WINDOW_MS = 30_000
87/** The longest unavailable window. */
88const WINDOW_CAP_MS = 300_000
89const DEFAULT_LAYA_PORT = 8000
90/** Jev's one address: no trailing slash, which TypeSafe redirects to plain http. */
91const JEV_URL = 'https://api.typesafe.ai/v1/systemone'
92const JEV_MODEL = 'jev-latest'
93/** Laya's address on this machine, before the port. */
94const LAYA_HOST = 'http://127.0.0.1:'
95/** The mark, under a folder, that keeps everything from that folder and below off the hosted model. */
96const LOCAL_ONLY_MARK = ['.claude', 'system-one-local-only']
97
98/** Present means trimmed, non-empty, printable ASCII with no whitespace. */
99const KEY = /^[\x21-\x7e]+$/
100
101/** Reads the three shared fields; anything unrecognised reads as its default. */
102export function parseSystemOne(options: Readonly<Record<string, unknown>>): SystemOneSettings {
103 const choice = options.modelChoice
104 const modelChoice = MODEL_CHOICES.find(known => known === choice) ?? 'local only'
105 const port = options.layaPort
106 const layaPort = typeof port === 'number' && Number.isInteger(port) && port >= 1 && port <= 65535 ? port : DEFAULT_LAYA_PORT
107 if (modelChoice === 'local only') return { modelChoice, keyState: undefined, key: undefined, layaPort }
108 const raw = typeof options.jevApiKey === 'string' ? options.jevApiKey.trim() : ''
109 if (raw === '') return { modelChoice, keyState: 'absent', key: undefined, layaPort }
110 if (!KEY.test(raw)) return { modelChoice, keyState: 'malformed', key: undefined, layaPort }
111 return { modelChoice, keyState: 'present', key: raw, layaPort }
112}
113
114type Health = {
115 /** Failures in a row. */
116 failures: number
117 /** Unavailable until this time, in milliseconds since the epoch. */
118 until: number
119 /** True while a call holds the backend's one slot, a call that lost its race included. */
120 isBusy: boolean
121}
122
123const health: Record<Backend, Health> = {
124 laya: { failures: 0, until: 0, isBusy: false },
125 jev: { failures: 0, until: 0, isBusy: false },
126}
127/** Set by a 401 or 403 from Jev, for the life of this module. */
128let isKeyRejected = false
129/** Whether Laya answered the identity check since it was last unavailable. */
130let isLayaIdentified = false
131
132/** The key's problem for the mod to name, or undefined: none, or local only. */
133export function keyProblem(settings: SystemOneSettings): KeyProblem | undefined {
134 if (settings.keyState === undefined) return undefined
135 if (settings.keyState !== 'present') return settings.keyState
136 return isKeyRejected ? 'rejected' : undefined
137}
138
139/**
140 * Asks one model the judgment's questions and resolves its answers, or
141 * undefined for the Fallback: in a headless session, with no model available,
142 * when the model asked is busy, fails, outlasts the bound, or reports cut text.
143 * One model per judgment: whatever happens, the other is not asked.
144 */
145export async function askSystemOne(io: SystemOneIo, settings: SystemOneSettings, ask: Ask): Promise<Answers | undefined> {
146 if (!(await io.isShown())) return undefined
147 const now = await io.now()
148 for (const backend of order(settings.modelChoice)) {
149 if (health[backend].until > now) continue
150 if (backend === 'jev') {
151 if (settings.key === undefined || isKeyRejected) continue
152 if (await isLocalOnly(io)) continue
153 }
154 return run(io, settings, ask, backend)
155 }
156 return undefined
157}
158
159function order(choice: ModelChoice): Backend[] {
160 if (choice === 'local first') return ['laya', 'jev']
161 if (choice === 'hosted first') return ['jev', 'laya']
162 return ['laya']
163}
164
165/**
166 * Whether the session's folder, or any folder above it up to the root, holds the
167 * local-only mark: one existence check per level. A lookup that cannot complete
168 * counts as marked.
169 */
170async function isLocalOnly(io: SystemOneIo): Promise<boolean> {
171 try {
172 for (const path of markPaths(await io.folder())) {
173 if (await io.exists(path)) return true
174 }
175 return false
176 } catch {
177 return true
178 }
179}
180
181/** Where the mark would sit for `folder` and each folder above it, nearest first. Throws on a relative folder. */
182function markPaths(folder: string): string[] {
183 if (!/^([A-Za-z]:)?[\\/]/.test(folder)) throw new Error(`not an absolute folder: ${folder}`)
184 const separator = folder.includes('\\') ? '\\' : '/'
185 const [root = '', ...rest] = folder.split(/[\\/]+/)
186 const levels = rest.filter(part => part !== '')
187 const paths: string[] = []
188 for (let depth = levels.length; depth >= 0; depth -= 1) {
189 paths.push([root, ...levels.slice(0, depth), ...LOCAL_ONLY_MARK].join(separator))
190 }
191 return paths
192}
193
194/** How one call to a backend ended. */
195type Outcome =
196 | { kind: 'answer'; answers: Record<string, Answer> }
197 | { kind: 'cut' }
198 | { kind: 'failure' }
199 | { kind: 'rejected' }
200
201/** Takes the backend's one slot, races the call against the bound, and keeps what its outcome says about the backend. */
202async function run(io: SystemOneIo, settings: SystemOneSettings, ask: Ask, backend: Backend): Promise<Answers | undefined> {
203 const slot = health[backend]
204 if (slot.isBusy) return undefined
205 slot.isBusy = true
206 const call = (backend === 'laya' ? callLaya(io, settings.layaPort, ask) : callJev(io, settings.key ?? '', ask)).finally(() => {
207 slot.isBusy = false
208 })
209 const bound = Math.min(Number.isFinite(ask.boundMs) ? Math.max(ask.boundMs, 0) : 0, BOUND_CAP_MS)
210 const timer = io.sleep(bound).then(
211 (): Outcome => ({ kind: 'failure' }),
212 (): undefined => undefined,
213 )
214 const outcome = await Promise.race([call, timer])
215 if (outcome === undefined) return undefined
216 if (outcome.kind === 'rejected') {
217 isKeyRejected = true
218 return undefined
219 }
220 if (outcome.kind === 'failure') {
221 slot.failures += 1
222 slot.until = (await io.now()) + Math.min(WINDOW_MS * 2 ** (slot.failures - 1), WINDOW_CAP_MS)
223 if (backend === 'laya') isLayaIdentified = false
224 return undefined
225 }
226 slot.failures = 0
227 slot.until = 0
228 if (backend === 'laya') isLayaIdentified = true
229 return outcome.kind === 'answer' ? { backend, answers: outcome.answers } : undefined
230}
231
232async function callLaya(io: SystemOneIo, port: number, ask: Ask): Promise<Outcome> {
233 const base = `${LAYA_HOST}${port}`
234 try {
235 if (!isLayaIdentified) {
236 const check = await io.fetch(`${base}/openapi.json`, { method: 'GET', headers: {} })
237 if (check.status !== 200 || !isLayaOpenapi(parseJson(check.text))) return { kind: 'failure' }
238 }
239 const response = await io.fetch(`${base}/v1/systemone`, {
240 method: 'POST',
241 headers: { 'Content-Type': 'application/json' },
242 body: JSON.stringify({ state: ask.state.laya, questions: ask.questions }),
243 })
244 if (response.status < 200 || response.status > 299) return { kind: 'failure' }
245 const body = parseJson(response.text)
246 const answers = readAnswers(body, ask.questions)
247 const truncated = isObject(body) && isObject(body.usage) ? body.usage.truncated : undefined
248 if (answers === undefined || typeof truncated !== 'boolean') return { kind: 'failure' }
249 return truncated ? { kind: 'cut' } : { kind: 'answer', answers }
250 } catch {
251 return { kind: 'failure' }
252 }
253}
254
255async function callJev(io: SystemOneIo, key: string, ask: Ask): Promise<Outcome> {
256 try {
257 const response = await io.fetch(JEV_URL, {
258 method: 'POST',
259 headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
260 body: JSON.stringify({ model: JEV_MODEL, state: ask.state.jev, questions: ask.questions }),
261 })
262 if (response.status === 401 || response.status === 403) return { kind: 'rejected' }
263 if (response.status < 200 || response.status > 299) return { kind: 'failure' }
264 const answers = readAnswers(parseJson(response.text), ask.questions)
265 return answers === undefined ? { kind: 'failure' } : { kind: 'answer', answers }
266 } catch {
267 return { kind: 'failure' }
268 }
269}
270
271function parseJson(text: string): unknown {
272 try {
273 return JSON.parse(text)
274 } catch {
275 return undefined
276 }
277}
278
279function isObject(value: unknown): value is Record<string, unknown> {
280 return typeof value === 'object' && value !== null && !Array.isArray(value)
281}
282
283/** laya-serve's /openapi.json: its title, and the route the client asks. */
284function isLayaOpenapi(body: unknown): boolean {
285 return isObject(body) && isObject(body.info) && body.info.title === 'laya-serve' && isObject(body.paths) && '/v1/systemone' in body.paths
286}
287
288/**
289 * The answers of a readable reply: a `model` and an `answers` map keyed by
290 * exactly the questions asked, each of its question's type with a value it can
291 * take. Anything else is unreadable, so undefined. A choice's confidence that is
292 * missing or out of range leaves the answer readable, with no confidence.
293 */
294function readAnswers(body: unknown, questions: Record<string, Question>): Record<string, Answer> | undefined {
295 if (!isObject(body) || typeof body.model !== 'string' || !isObject(body.answers)) return undefined
296 const ids = Object.keys(questions)
297 const given = body.answers
298 if (Object.keys(given).length !== ids.length) return undefined
299 const answers: Record<string, Answer> = {}
300 for (const id of ids) {
301 const question = questions[id]
302 const answer = given[id]
303 if (question === undefined || !isObject(answer) || answer.type !== question.type) return undefined
304 if (question.type === 'noul') {
305 const noul = answer.noul
306 if (typeof noul !== 'number' || !Number.isFinite(noul) || noul < 0 || noul > 1) return undefined
307 answers[id] = { type: 'noul', noul }
308 } else {
309 const choice = answer.choice
310 if (typeof choice !== 'string' || !Object.hasOwn(question.criteria, choice)) return undefined
311 const confidence = answer.confidence
312 const isConfidence = typeof confidence === 'number' && Number.isFinite(confidence) && confidence >= 0 && confidence <= 1
313 answers[id] = { type: 'choice', choice, confidence: isConfidence ? confidence : undefined }
314 }
315 }
316 return answers
317}
318hooks/wayfinder.ts 100 lines1/**
2 * Reading a wayfinder loop from typed facts: the arguments the skill ran with,
3 * the Bash commands a turn ran and their output. No text the model wrote is read.
4 */
5
6/** A `/issues/N` link, as gh prints one and as a person passes one. */
7const ISSUE_URL = /\/issues\/(\d+)\b/
8
9/** The flags of `gh issue close` that take a value, so the value is not read as the issue. */
10const CLOSE_VALUE_FLAGS = new Set(['-c', '--comment', '-r', '--reason', '-R', '--repo'])
11
12/** What one turn's Bash commands did to the tracker. */
13export type BashReading = {
14 /** Each issue a `gh issue close` named, in order. */
15 closed: number[]
16 /** The map a `gh issue create --label wayfinder:map` made, read from the URL gh printed for it. */
17 created?: number
18}
19
20/** The arguments a skill ran with: the engine appends them as an `ARGUMENTS:` line when the skill names none itself. */
21export function skillArgs(text: string): string | undefined {
22 return [...text.matchAll(/^ARGUMENTS:[ \t]*(.*)$/gm)].at(-1)?.[1]?.trim()
23}
24
25/** The map an argument names: an issue link, `#135` or `135`, the first one given. */
26export function mapFromArgs(args: string): number | undefined {
27 const url = ISSUE_URL.exec(args)
28 const plain = /(?:^|\s)#?(\d+)(?=\s|$)/.exec(args)
29 const found = url !== null && (plain === null || url.index < plain.index) ? url[1] : plain?.[1]
30 return found === undefined ? undefined : Number(found)
31}
32
33/** An issue as `gh` takes one: a number, `#135` or a link. */
34function issueNumber(token: string): number | undefined {
35 const url = ISSUE_URL.exec(token)
36 if (url !== null) return Number(url[1])
37 const plain = /^#?(\d+)$/.exec(token)
38 return plain === null ? undefined : Number(plain[1])
39}
40
41/**
42 * A command line split into its simple commands, each a list of words with
43 * quotes taken off. A quoted `&&` or `;` is part of its word, not a break.
44 */
45function simpleCommands(command: string): string[][] {
46 let words: string[] = []
47 const commands = [words]
48 for (const match of command.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'|(&&|\|\||[;|\n])|([^\s"';|&]+|&)/g)) {
49 if (match[3] !== undefined) commands.push((words = []))
50 else words.push(match[1] ?? match[2] ?? match[4] ?? '')
51 }
52 return commands.filter(simple => simple.length > 0)
53}
54
55/** Whether `words` run `gh issue <verb>`. */
56function isGhIssue(words: string[], verb: string): boolean {
57 return words[0] === 'gh' && words[1] === 'issue' && words[2] === verb
58}
59
60/** The issue a `gh issue close` names: its one positional argument, past the flags that take a value. */
61function closedBy(words: string[]): number | undefined {
62 const rest = words.slice(3)
63 const issue = rest.find((word, at) => !word.startsWith('-') && !CLOSE_VALUE_FLAGS.has(rest[at - 1] ?? ''))
64 return issue === undefined ? undefined : issueNumber(issue)
65}
66
67/** Whether a `gh issue create` labels the issue `wayfinder:map`, by `--label`, `-l` or `--label=`, alone or in a list. */
68function makesMap(words: string[]): boolean {
69 return words.some((word, at) => {
70 const value = word.startsWith('--label=') ? word.slice('--label='.length) : word === '--label' || word === '-l' ? words[at + 1] : undefined
71 return value !== undefined && value.split(',').some(label => label.trim() === 'wayfinder:map')
72 })
73}
74
75/**
76 * What a Bash command did to the tracker, given its output. gh prints one link
77 * per issue it creates, in order, so a map's link is the one at its place
78 * among the creates.
79 */
80export function readBash(command: string, output: string): BashReading {
81 const commands = simpleCommands(command)
82 const closed = commands.filter(words => isGhIssue(words, 'close')).flatMap(words => closedBy(words) ?? [])
83 const creates = commands.filter(words => isGhIssue(words, 'create'))
84 const mapAt = creates.findIndex(makesMap)
85 if (mapAt === -1) return { closed }
86 const link = [...output.matchAll(new RegExp(ISSUE_URL.source, 'g'))][mapAt]
87 return link === undefined ? { closed } : { closed, created: Number(link[1]) }
88}
89
90/**
91 * The map to offer after a turn, or undefined: the map the turn made, or the
92 * one the skill ran with when the turn closed another issue. A turn that
93 * closed the map itself ends the loop.
94 */
95export function nextMap(turn: { map?: number; closed: readonly number[]; created?: number }): number | undefined {
96 const map = turn.created ?? turn.map
97 if (map === undefined || turn.closed.includes(map)) return undefined
98 return turn.created !== undefined || turn.closed.some(issue => issue !== map) ? map : undefined
99}
100types/index.d.ts 24 lines1/** One choice Claude offered: its marker in lower case (`1`, `b`, also for a `B.` line) and a short label. */
2export type ReplyOption = { marker: string; label: string }
3
4/** What the last answer asked for, as far as the band can tell. */
5export type Reading = {
6 isQuestion: boolean
7 /** Whether it asks for a pass/fail verdict on a test or a check, as a UAT step does. */
8 asksForVerdict: boolean
9 hasRecommendation: boolean
10 options: ReplyOption[]
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'quick-reply': {
16 reading: Reading | null
17 /** The wayfinder map the band offers to take the next ticket of, or null. */
18 offer: number | null
19 /** The Jev key's problem the band names under the replies until the next prompt (the client's KeyProblem), or null. */
20 keyNote: 'absent' | 'malformed' | 'rejected' | null
21 }
22 }
23}
24