Keeps the instructions you give Claude ("never…", "from now on…", "in this project…") as standing orders for the project or the session, offered in a band…

Claude Code mods (function-hook plugins) I find helpful. Each folder is one plugin.
Requires a Claude Code build with function-hook plugins (2.1.289 or newer). machine-guard reads macOS tools (sysctl, memory_pressure, ioreg).
git clone https://github.com/joeldg/claude-mods ~/Projects/claude-mods
A pane of your project's running dev servers, so you don't have to ask Claude to restart them.
/servers opens the Servers pane:package.json dev/start/serve/preview scripts (run with your lockfile's package manager), .claude/launch.json and Procfile~/.claude/dev-servers/<project>/.Port 4000 is held by node (pid 123, up 2h, in /Users/me/other).servers: :4000 :5173.lsof and ps every 15s.Puts files you just downloaded into your prompt with one click.
~/Downloads (top level). When a new file arrives (PDF, Markdown, images, 3MF/STL/OBJ, zip, video…), a band appears above the prompt: New in Downloads: paper.pdf, model-b.3mf · 2m ago [Attach] [Dismiss].@"/Users/you/Downloads/paper.pdf" mentions in your prompt. Dismiss hides those files./downloads lists the 10 newest files, numbered. /downloads attach 1 3 (or 2-4) adds those, and /downloads clear dismisses everything new.Settings: folder (~/Downloads), extensions, pollSeconds (5), maxAgeMinutes (120).
Sets effort per message, so you don't have to switch it by hand.
avoidModel (a regex such as fable) to send those requests to fallbackModel instead, subagents included./route shows the last decision and the session's counts. /route off and /route on toggle it; /route deep and /route routine force the next turn.effort: low (routine).Settings: routineEffort (low), deepEffort (max), routinePattern, deepPattern, avoidModel, fallbackModel (opus), stickyTurns (2), freeSwitchTokens (30000), cacheTtlMinutes (60).
A Jobs pane for long-running work: training runs, downloads, extractions.
nohup … > log & launches by itself./watch <log> [label] adds any other log file./ and /Volumes/* (the NAS).jobs: 2 running · 1 stalled./jobs opens the pane, /unwatch <label|done|all> removes jobs.tail, checks processes with ps, and runs df.Settings (in /config): stall minutes (10), refresh seconds (10), how long finished jobs stay (120 min), auto-open (on), which disks to show.
Memory, swap and GPU on the status line. It refuses heavy local jobs when the Mac can't take them.
RAM tight 12% free · swap 7.9/8G · top python 31G · GPU 87%./busy.When memory is only tight, the job runs and Claude gets a note to start one heavy job at a time.
/busy 3h training a vision model reserves the Mac in every Claude session. /busy off lifts it. The reservation lives in ~/.claude/machine-guard.json, so a training script can write it too: ``bash echo '{"reason":"overnight training","until":'$(( ($(date +%s) + 8*3600) * 1000 ))'}' > ~/.claude/machine-guard.json ``/guard shows what it sees. /guard pause 15m lets heavy jobs through in this session; /guard on resumes the guard.modal run, ssh), tests (pytest) and installs are never treated as heavy.overnight_|nightly_run\.sh.Watches how the other mods behave in real use, without changing them. It is listed first in CLAUDE_CODE_PLUGIN_DIRS, so the other mods' hooks run beneath it.
next.trace), with the mod's name, the event and how long it ran. Slow hooks (over 1.5 s) are recorded too. The first failure of each mod in a session raises a toast./second-opinion, /recall ask) and file writes (folders only, never contents)./mods: a pane with one row per mod: ✓ active, ⚠ failing, ✗ not seen this session, · seen but idle. Each row shows today's counts and last activity, with Details for its recent events. It also says which mods it can't see, if any of them run above it./mods report [24h|7d|30d]: a per-mod report across all sessions, also written to ~/.claude/mods/monitor/report-latest.md for a scheduled review or Claude to read./mods failures [7d]: failures and failed subprocesses only.~/.claude/mods/monitor/<date>/<session>.jsonl, flushed every minute and at session end, with secrets masked and old days removed after 30 days.$.ui.log with wording like "failed" or "could not"): shown in Details and in /mods failures. Three in an hour mark the mod ⚠ and raise one toast. That is how effort-router's per-request hook, which runs inside the response stream where no monitor should sit, reports a failure. It also always sends the request on unchanged./secrets records there) are watched for failures and slow runs, but not counted per run.Settings: alerts (on), slowMs (1500), watchRender (on), watchCommands (on; off stops "mod-monitor" appearing beside other mods' command output), watchAppend (on), retentionDays (30), flushSeconds (60).
Keeps an eye on Modal so idle GPU containers don't burn credits.
Modal: 1 running (2 containers). Deployed apps with no containers cost nothing, so they stay off it.alertMinutes (30), repeated at most every 30 minutes./modal opens a pane of apps with state, containers and uptime. Stop asks for Confirm, then runs modal app stop. Nothing is stopped any other way.budgetToday where the Modal CLI supports billing report (1.3.3+, Team/Enterprise workspaces). Otherwise /modal says why spend isn't shown.modal or python3 -m modal. It checks PATH first rather than running a command that can only fail, and stays silent when Modal isn't set up.Does the "merged #219, clean up branches and start #214" round trip for you, and surfaces CI failures with their logs.
gh pr create Claude runs. It polls gh pr view every 60s.PRs: #219 ✓ · #220 CI… · #221 ✗. Toasts when CI fails (with the failing check names) or passes.git fetch --prune, switch to the default branch (only from the PR's own branch) and git pull --ff-only.--force, never other branches.gh pr checks and the tail of the failed log attached, so you don't paste it./prs lists watched PRs. /prs watch <n|url> and /prs forget <n|all> add and remove them.gh and git, at about one GitHub API call per open PR per minute.Settings:
pollSeconds (60)attachCiLogs (on)logLines (120)deleteRemoteBranch (off): deletes the branch on GitHub too, only while it still points at the merged commit. GitHub's own "Automatically delete head branches" setting does the same job.It never closes issues; put "Closes #N" in PR bodies for that.
Search everything you've done with coding agents, from Claude or from /recall. It replaces the broken agent-memory plugin.
/remember notes. Routine (scheduled) runs are left out unless you add routines:include to a query.grep and cat are kept but ranked low.search, expand, recap and list, which run without permission prompts. It checks them when you say "like last time" or "what did we decide", and before asking you something you already settled./recall <query> opens a pane of hits grouped by session. Open shows the conversation around a hit, Attach sends it with your next message, and Copy resume command copies claude --resume <id>./recall last [n] recaps your last session in this repo: last asks, last answer, PRs, commits, open tasks and decisions. Send to Claude attaches it./recall timeline [7d|30d|90d] [all]/recall decisions|commands|files|prs|commits|issues|urls|tasks|notes [query]/recall ask <question> answers from your history with Haiku 4.5, citing sessions. It costs a little usage and sends the matching excerpts to the model./recall stats, /recall reindex, /recall forget session <id>|project <name>|before <date> (asks you to confirm), /recall help./remember <fact>, /remember list, /remember forget <ref>.Last session here (2d ago): "…" · PR #99 · 3 open tasks [Recap].#214, ABC-12, a file name or a quoted phrase seen in past sessions, a band offers what happened then. Nothing is sent unless you click.OR gives alternatives, "quotes" an exact phrase, and -word excludes. Filters: project:name, kind:decision, since:7d, until:2026-09-30, source:codex, routines:include.~/.claude/recall/index.db, readable only by you, and never goes in a repo.~/.zshrc, ~/.zprofile, ~/.bashrc and ~/.bash_profile, and any literal strings you list in ~/.claude/recall/redact.txt (one per line). Editing that list re-masks the existing index on the next update./recall ask. The first index takes about 2 minutes in the background, with progress on the status line. After that it updates incrementally (about 1s) at session start and every 10 minutes./usr/bin/python3 (Command Line Tools), whose SQLite has FTS5. Nothing else to install.Settings: dbPath, python, sources, includeSubagents (on), includeRoutines (off), updateMinutes (10), relatedBand (on), lastSessionBand (on), maxResults (8), askModel (claude-haiku-4-5-20251001).
Catches Claude up on the repo when a session starts, so you don't have to ask "check the recent commits/PRs and issues".
owner, todo, P0 or blockedmain ↑1 · 3 changed · PRs #123 ✗ #124 ✓ · 2 owner issues · 2 stale branches · last commit 2h ago. Hide dismisses it./brief re-gathers now and prints the full summary.git and gh. The band refreshes after a turn at most every 2 minutes.Settings: focus labels, refresh minutes, and whether to brief Claude.
Keeps scheduled routines (daily digests, newsletters) from silently stalling while you're away.
AskUserQuestion, you get a Mac notification and a toast, and the status line shows routine: daily-report · waiting on you 3m.Routine daily-report finished after 23m · waited on you 2 times.notifyCommand runs a command on the same events, e.g. curl -s -d {message} ntfy.sh/your-topic. {title} and {message} are filled in as single arguments, never through a shell.allowWebReads (off by default): lets routines use WebFetch and WebSearch without asking. It only replaces a prompt; your deny rules still apply, and nothing else is ever auto-allowed./routine shows the routine's name, how long it has run, its waits, and the settings.A Fable review in the background, without switching your session's model. Each run is one Fable call against your usage.
/second-opinion: reviews recent work. On a feature branch that's the branch against the default branch; otherwise the last 12 commits, plus the diff and git status, capped at 60k characters./second-opinion commits 5/second-opinion diff (uncommitted changes)/second-opinion file docs/ADR-007.md/second-opinion <question>: adds a question for Fable to answer first.second opinion: reviewing…. When the review is ready you get a toast, and a pane opens with it, ranked: wrong assumptions, bugs and risks, what's missing, what to do next.~/.claude/second-opinions/<project>/. /second-opinion list lists them, and /second-opinion show [n] reopens one.Settings: model (claude-fable-5-1), effort (high), maxContextChars (60000).
Keeps your "always / never / don't / from now on" instructions alive across compaction.
Keep as a standing order? [Project] [This session] [No]. Nothing is saved without a click.~/.claude/standing-orders/<repo>.json and apply to every session in that repo. Session orders and your active /goal last for the session./clear, so the prompt cache isn't disturbed. A newly saved order also rides along once with your next message./orders lists them. /orders add [project|session] <text>, /orders forget <n>, /orders clear session|project, and /orders export (a Markdown block for CLAUDE.md).Stops keys and passwords from going into a prompt, and so into your transcripts, and turns them into env vars instead.
$NAME references, placeholders, plain URLs, paths, git SHAs and ordinary prose about passwords.…vxrm) with a suggested name such as OPENDATALAB_SECRET_ACCESS_KEY, which you can edit:export NAME='…' to ~/.zshrc (reusing an existing identical export) and replaces the secret in your prompt with $NAME./secrets test <text> output is masked too./secrets test <text> shows what would be caught. /secrets off and /secrets on toggle it for the session.Settings: enabled (on), extraPatterns (a regex), zshrcPath (~/.zshrc).
Makes Claude's open commands hand 3D files to the right slicer.
full.?spectrum|snapmaker-only|-fs\.3mf$|-u1[-.], orAn open -a BambuStudio … for one becomes open -b com.snapmaker.snapmaker-orca …, with the rest of the command untouched. You get a toast, and Claude gets a note so it doesn't try again.
/slice <file> [bambu|snapmaker|orca] opens a file yourself, with the same rules.open -a <app>, open -a /Applications/X.app and open -b <bundle id>, including variables set earlier in the command (S=… && open -a BambuStudio "$S/x.3mf") and files copied in the same command.Settings:
closePrevious (on): turn it off if you keep your own slicer window open, since the quit request reaches your windows too.fullSpectrumPattern (the regex above)checkContents (on)The quit request goes out when Claude issues the command, before any permission prompt for it.
--plugin-dir once per mod, e.g. claude --plugin-dir ~/Projects/claude-mods/job-watch --plugin-dir ~/Projects/claude-mods/pr-autopilot~/.claude/settings.json. Put mod-monitor first so it sees the others; CLAUDE_CODE_PLUGIN_DIR_WATCH makes desktop sessions pick up edits and show mod failures: ``json { "env": { "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1", "CLAUDE_CODE_PLUGIN_DIRS": "~/Projects/claude-mods/mod-monitor:~/Projects/claude-mods/job-watch:~/Projects/claude-mods/machine-guard:~/Projects/claude-mods/repo-brief:~/Projects/claude-mods/slicer-handoff:~/Projects/claude-mods/pr-autopilot:~/Projects/claude-mods/routine-watch:~/Projects/claude-mods/modal-meter:~/Projects/claude-mods/second-opinion:~/Projects/claude-mods/downloads-drop:~/Projects/claude-mods/dev-servers:~/Projects/claude-mods/standing-orders:~/Projects/claude-mods/effort-router:~/Projects/claude-mods/secret-guard:~/Projects/claude-mods/recall" } } ``Run with Claude Code 2.1.289 or newer; older CLIs ignore per-test settings, so a few tests fall back to defaults.
claude plugin validate job-watch && claude plugin test job-watch
claude plugin validate machine-guard && claude plugin test machine-guard
claude plugin validate repo-brief && claude plugin test repo-brief
claude plugin validate slicer-handoff && claude plugin test slicer-handoff
claude plugin validate pr-autopilot && claude plugin test pr-autopilot
claude plugin validate routine-watch && claude plugin test routine-watch
claude plugin validate modal-meter && claude plugin test modal-meter
claude plugin validate second-opinion && claude plugin test second-opinion
claude plugin validate downloads-drop && claude plugin test downloads-drop
claude plugin validate dev-servers && claude plugin test dev-servers
claude plugin validate standing-orders && claude plugin test standing-orders
claude plugin validate effort-router && claude plugin test effort-router
claude plugin validate secret-guard && claude plugin test secret-guard
claude plugin validate recall && claude plugin test recall
claude plugin validate mod-monitor && claude plugin test mod-monitor
(cd recall/engine && /usr/bin/python3 -m unittest)hooks/register.tsx 397 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Candidate, Order, Scope, Unsent } from '../types'
5import {
6 USAGE,
7 blockText,
8 exportMarkdown,
9 findDirective,
10 goalChange,
11 hasOrder,
12 isBlankTree,
13 listText,
14 normalizeOrder,
15 ordersPath,
16 parseOrdersCommand,
17 parseProjectFile,
18 sameOrder,
19 serializeProjectFile,
20 tildePath,
21 unsentText,
22} from './orders'
23
24type Engine = EngineInterface
25
26/** The name the orders render under in the conversation's context. */
27const BLOCK = 'standingOrders'
28
29const candidate = atom({ plugin: 'standing-orders', key: 'candidate' } as const, null)
30const sessionOrders = atom({ plugin: 'standing-orders', key: 'sessionOrders' } as const, [])
31const goal = atom({ plugin: 'standing-orders', key: 'goal' } as const, null)
32const unsent = atom({ plugin: 'standing-orders', key: 'unsent' } as const, [])
33const dismissed = atom({ plugin: 'standing-orders', key: 'dismissed' } as const, [])
34
35/** Prompts the person wrote: the only ones whose directives are offered, and the ones new orders ride along with. */
36const PERSONAL = new Set(['composer', 'bridge', 'sdk'])
37/** How many later prompts an unanswered band stays up for. */
38const CANDIDATE_PROMPTS = 3
39/** How many directives answered No are remembered, so they are not offered again. */
40const MAX_DISMISSED = 50
41const GIT_MS = 3_000
42
43type Config = { capture: boolean; deliver: boolean; maxOrders: number }
44
45let config: Config = { capture: true, deliver: true, maxOrders: 30 }
46/** The folder git was last asked about, and the project root it answered. */
47let rootCache: { cwd: string; root: string } | null = null
48
49function debug($: Engine, line: string) {
50 $.ui.log(`standing-orders: ${line}`, { to: 'debug' })
51}
52
53/** The git work tree's top folder, or the session's folder outside one. */
54async function projectRoot($: Engine): Promise<string> {
55 const cwd = await $.session.cwd()
56 if (rootCache?.cwd === cwd) {
57 return rootCache.root
58 }
59 const out = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd, timeoutMs: GIT_MS }).catch(() => null)
60 const top = out?.exitCode === 0 ? out.stdout.trim() : ''
61 rootCache = { cwd, root: top || cwd }
62 return rootCache.root
63}
64
65type Project = {
66 root: string
67 /** The orders file; null when no home folder is known. */
68 path: string | null
69 home: string | undefined
70 orders: Order[]
71 /** True when the file is there but holds nothing this mod can read: it is then never overwritten. */
72 isBroken: boolean
73}
74
75/** This project's orders, read from `~/.claude/standing-orders/<key>.json` (none while it does not exist). */
76async function loadProject($: Engine): Promise<Project> {
77 const root = await projectRoot($)
78 const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
79 const path = home ? ordersPath(home, root) : null
80 if (path === null) {
81 return { root, path, home, orders: [], isBroken: false }
82 }
83 const text = await $.fs.read(path).catch(() => null)
84 const orders = text === null ? [] : parseProjectFile(text)
85 return { root, path, home, orders: orders ?? [], isBroken: orders === null }
86}
87
88/** Why project orders cannot be changed right now, or null when they can. */
89function projectBlocker(project: Project): string | null {
90 if (project.path === null) {
91 return 'standing-orders: HOME is not set, so there is nowhere to keep project orders.'
92 }
93 return project.isBroken
94 ? `standing-orders: ${tildePath(project.path, project.home)} is not a file of orders this mod can read; fix or remove it first (it was left as it is).`
95 : null
96}
97
98/** Writes the project's orders back; the error in words when the write failed. */
99async function saveProject($: Engine, project: Project, orders: readonly Order[]): Promise<string | null> {
100 if (project.path === null) {
101 return 'standing-orders: HOME is not set, so there is nowhere to keep project orders.'
102 }
103 try {
104 await $.fs.write(project.path, serializeProjectFile(project.root, orders))
105 return null
106 } catch (error) {
107 return `standing-orders: could not write ${tildePath(project.path, project.home)}: ${String(error)}`
108 }
109}
110
111const scopeWords = (scope: Scope): string => (scope === 'project' ? 'this project' : 'this session')
112
113type Kept = { isKept: boolean; text: string }
114
115/** Keeps `raw` as a standing order of `scope`, and queues it for the next prompt; says what happened. */
116async function keep($: Engine, scope: Scope, raw: string): Promise<Kept> {
117 const text = normalizeOrder(raw)
118 const now = await $.clock.now()
119 const full = `standing-orders: ${scopeWords(scope)} already has ${config.maxOrders} orders (maxOrders); /orders forget one first.`
120 if (scope === 'project') {
121 const project = await loadProject($)
122 const blocker = projectBlocker(project)
123 if (blocker) {
124 return { isKept: false, text: blocker }
125 }
126 if (hasOrder(project.orders, text)) {
127 return { isKept: false, text: `Already a standing order for this project: ${text}` }
128 }
129 if (project.orders.length >= config.maxOrders) {
130 return { isKept: false, text: full }
131 }
132 const failed = await saveProject($, project, [...project.orders, { text, addedAt: now }])
133 if (failed) {
134 return { isKept: false, text: failed }
135 }
136 } else {
137 const list = await read($, sessionOrders)
138 if (hasOrder(list, text)) {
139 return { isKept: false, text: `Already a standing order for this session: ${text}` }
140 }
141 if (list.length >= config.maxOrders) {
142 return { isKept: false, text: full }
143 }
144 await update($, sessionOrders, current => [...(current ?? []), { text, addedAt: now }])
145 }
146 if (config.deliver) {
147 await update($, unsent, current => [...(current ?? []).filter(one => !sameOrder(one.text, text)), { text, scope }])
148 }
149 return { isKept: true, text: `Standing order kept for ${scopeWords(scope)}: ${text}` }
150}
151
152/** Drops orders no longer in force from the queue for the next prompt. */
153async function unqueue($: Engine, isGone: (order: Unsent) => boolean) {
154 await update($, unsent, current => (current ?? []).filter(order => !isGone(order)))
155}
156
157/** Whether a directive is worth offering: not answered No this session, and not already kept. */
158async function isNew($: Engine, text: string): Promise<boolean> {
159 if ((await read($, dismissed)).includes(text.toLowerCase())) {
160 return false
161 }
162 if (hasOrder(await read($, sessionOrders), text)) {
163 return false
164 }
165 return !hasOrder((await loadProject($)).orders, text)
166}
167
168/** Offers the prompt's directive in the band (the newest replaces any other); an unanswered one ages out. */
169async function offer($: Engine, prompt: string) {
170 const found = findDirective(prompt)
171 if (found !== null && (await isNew($, found))) {
172 await update($, candidate, () => ({ text: found, promptsSince: 0 }))
173 return
174 }
175 if ((await read($, candidate)) === null) {
176 return
177 }
178 await update($, candidate, current =>
179 !current || current.promptsSince + 1 >= CANDIDATE_PROMPTS ? null : { ...current, promptsSince: current.promptsSince + 1 },
180 )
181}
182
183/** The band's answer: keep the offered directive for the project or the session, or let it go. */
184async function answer($: Engine, choice: Scope | 'dismiss') {
185 let taken: Candidate | null = null
186 await update($, candidate, current => {
187 taken = current ?? null
188 return null
189 })
190 const offered = taken as Candidate | null
191 if (offered === null) {
192 return
193 }
194 if (choice === 'dismiss') {
195 const key = offered.text.toLowerCase()
196 await update($, dismissed, current => [...(current ?? []).filter(one => one !== key), key].slice(-MAX_DISMISSED))
197 return
198 }
199 const kept = await keep($, choice, offered.text)
200 $.ui.toast(kept.text)
201}
202
203/** `/orders forget <n>`: project orders are numbered first, then session orders. */
204async function forget($: Engine, number: number): Promise<string> {
205 const project = await loadProject($)
206 if (number <= project.orders.length) {
207 const gone = project.orders[number - 1]
208 const blocker = projectBlocker(project)
209 if (!gone || blocker) {
210 return blocker ?? USAGE
211 }
212 const failed = await saveProject($, project, project.orders.filter((_, i) => i !== number - 1))
213 if (failed) {
214 return failed
215 }
216 await unqueue($, order => order.scope === 'project' && sameOrder(order.text, gone.text))
217 return `Forgot standing order ${number} (this project), it no longer applies: ${gone.text}`
218 }
219 const session = await read($, sessionOrders)
220 const gone = session[number - project.orders.length - 1]
221 if (!gone) {
222 return `standing-orders: there is no order ${number}; /orders lists them.`
223 }
224 await update($, sessionOrders, current => (current ?? []).filter(order => !sameOrder(order.text, gone.text)))
225 await unqueue($, order => order.scope === 'session' && sameOrder(order.text, gone.text))
226 return `Forgot standing order ${number} (this session), it no longer applies: ${gone.text}`
227}
228
229/** `/orders clear session|project`. */
230async function clear($: Engine, scope: Scope): Promise<string> {
231 let count = 0
232 if (scope === 'project') {
233 const project = await loadProject($)
234 const blocker = projectBlocker(project)
235 if (blocker) {
236 return blocker
237 }
238 count = project.orders.length
239 const failed = count > 0 ? await saveProject($, project, []) : null
240 if (failed) {
241 return failed
242 }
243 } else {
244 count = (await read($, sessionOrders)).length
245 await update($, sessionOrders, () => [])
246 }
247 await unqueue($, order => order.scope === scope)
248 return count === 0
249 ? `No standing orders for ${scopeWords(scope)} to clear.`
250 : `Cleared ${count} standing order${count === 1 ? '' : 's'} for ${scopeWords(scope)}; they no longer apply.`
251}
252
253/** What `/orders <args>` prints. */
254async function runOrders($: Engine, args: string): Promise<string> {
255 const command = parseOrdersCommand(args)
256 switch (command.verb) {
257 case 'add':
258 return (await keep($, command.scope, command.text)).text
259 case 'forget':
260 return forget($, command.number)
261 case 'clear':
262 return clear($, command.scope)
263 case 'export': {
264 const project = await loadProject($)
265 const markdown = exportMarkdown([...project.orders, ...(await read($, sessionOrders))])
266 return markdown ? `Paste this into CLAUDE.md:\n\n${markdown}` : 'No standing orders to export.'
267 }
268 case 'list': {
269 const project = await loadProject($)
270 const file = project.path === null ? null : tildePath(project.path, project.home)
271 const text = listText({
272 root: project.root,
273 file,
274 project: project.orders,
275 session: await read($, sessionOrders),
276 goal: await read($, goal),
277 })
278 return project.isBroken ? `${text}\n(${file} could not be read as orders; it was left as it is.)` : text
279 }
280 default:
281 return USAGE
282 }
283}
284
285export const register: Register = (on, options) => {
286 const max = Number(options.maxOrders ?? 30)
287 config = {
288 capture: options.capture !== false,
289 deliver: options.deliver !== false,
290 maxOrders: Number.isFinite(max) ? Math.max(1, Math.min(200, Math.round(max))) : 30,
291 }
292 rootCache = null
293
294 on('session.start', async ($, e, next) => {
295 await $.command.register({
296 name: 'orders',
297 description: 'Standing orders Claude keeps through compaction: list, add, forget, clear, export',
298 argumentHint: '[add [project|session] <text> | forget <n> | clear session|project | export]',
299 immediate: true,
300 })
301 return next(e)
302 })
303
304 // Spots a lasting instruction in the person's prompt and offers it in the band; the prompt passes untouched,
305 // save for orders kept since the last one, which ride along once so Claude learns them before any compaction.
306 on('prompt.submit', async ($, e, next) => {
307 if (!PERSONAL.has(e.origin.kind)) {
308 return next(e)
309 }
310 let waiting: Unsent[] = []
311 try {
312 if (config.deliver && (await read($, unsent)).length > 0) {
313 await update($, unsent, current => {
314 waiting = current ?? []
315 return []
316 })
317 }
318 if (config.capture) {
319 await offer($, e.text)
320 }
321 } catch (error) {
322 debug($, `could not read the prompt for orders: ${String(error)}`)
323 }
324 if (waiting.length === 0) {
325 return next(e)
326 }
327 const entered = await next({ ...e, context: [...(e.context ?? []), unsentText(waiting)] })
328 if (entered.drop !== undefined) {
329 // The prompt never entered, so neither did the note: it waits for the next one.
330 const again = waiting
331 await update($, unsent, current => [...again, ...(current ?? [])])
332 }
333 return entered
334 })
335
336 // Read once per conversation and again after a compaction or /clear: exactly when the orders are needed.
337 on('prompt.context', async ($, e, next) => {
338 const result = await next(e)
339 if (!config.deliver) {
340 return result
341 }
342 try {
343 const project = await loadProject($)
344 const text = blockText(project.orders, await read($, sessionOrders), await read($, goal))
345 await update($, unsent, () => [])
346 const others = result.blocks.filter(block => block.name !== BLOCK)
347 return { ...result, blocks: text === null ? others : [...others, { name: BLOCK, text }] }
348 } catch (error) {
349 debug($, `could not add the orders to the context: ${String(error)}`)
350 return result
351 }
352 })
353
354 // The built-in /goal, observed: its argument is the session's active goal; `/goal clear` clears it.
355 on('command.run', { command: 'goal' }, async ($, e, next) => {
356 const change = goalChange(e.args)
357 if (change !== undefined) {
358 await update($, goal, () => change).catch((error: unknown) => debug($, `could not note the goal: ${String(error)}`))
359 }
360 return next(e)
361 })
362
363 on('command.run', { command: 'orders' }, async ($, e) => ({ text: await runOrders($, e.args) }))
364
365 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
366 const offered = await read($, candidate)
367 if (e.props.hasSurvey || offered === null) {
368 return next(e)
369 }
370 const { Box, Text, Button } = $.ui.resolve(e)
371 const band = (
372 <Box flexDirection="row" gap={1}>
373 <Box flexShrink={1}>
374 <Text wrap="truncate-end">
375 Keep as a standing order? <Text bold>"{offered.text}"</Text>
376 </Text>
377 </Box>
378 <Box flexShrink={0} flexDirection="row" gap={1}>
379 <Button key="keep-project" label="Project" hotkey="p" variant="primary" onPress={() => answer($, 'project')} />
380 <Button key="keep-session" label="This session" hotkey="s" onPress={() => answer($, 'session')} />
381 <Button key="dismiss" label="No" hotkey="n" onPress={() => answer($, 'dismiss')} />
382 </Box>
383 </Box>
384 )
385 // Another plugin's band beneath stays, under this one.
386 const below = await next(e)
387 return isBlankTree(below) ? (
388 band
389 ) : (
390 <Box flexDirection="column">
391 {band}
392 {below}
393 </Box>
394 )
395 })
396}
397hooks/orders.ts 473 lines1import type { RenderElement } from 'claude-code'
2import type { Order, Scope, Unsent } from '../types'
3
4/** The longest order kept, in characters. */
5export const MAX_TEXT = 200
6/** The longest goal kept, in characters. */
7const MAX_GOAL = 500
8/** A sentence longer than this is prose or a paste, never offered as an order. */
9const MAX_SENTENCE = 320
10/** A line longer than this is pasted output (minified code, a JSON blob). */
11const MAX_LINE = 600
12/** A prompt with more lines than this is read only at its first and last few: a paste sits between. */
13const LONG_PROMPT_LINES = 12
14const EDGE_LINES = 3
15/** The fewest words an order is offered with. */
16const MIN_WORDS = 3
17
18const clip = (text: string, max: number): string => (text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`)
19
20/** Curly apostrophes and quotes as their ASCII forms. */
21const plain = (text: string): string => text.replace(/[‘’ʼ]/g, "'").replace(/[“”]/g, '"')
22
23/** One line, trimmed, wrapping quotes and trailing punctuation dropped, at most MAX_TEXT characters. */
24export const normalizeOrder = (text: string): string => {
25 let one = plain(text).replace(/\s+/g, ' ').trim()
26 const quoted = one.match(/^"(.*)"$/)
27 if (quoted) {
28 one = (quoted[1] ?? '').trim()
29 }
30 return clip(one.replace(/[\s.,;:!]+$/, ''), MAX_TEXT)
31}
32
33/** Whether two orders say the same thing, ignoring case, spacing and trailing punctuation. */
34export const sameOrder = (a: string, b: string): boolean => normalizeOrder(a).toLowerCase() === normalizeOrder(b).toLowerCase()
35
36export const hasOrder = (orders: readonly Order[], text: string): boolean => orders.some(order => sameOrder(order.text, text))
37
38// ---------------------------------------------------------------------------------------------
39// Spotting a directive in a prompt
40
41/** A line that starts like pasted output: a quote, a shell prompt, a comment, a log, a stack frame, a timestamp, JSON. */
42const PASTED_START =
43 /^(?:>|\$\s|#|\/\/|\/\*|\||[{}[\]]|at\s+\S+.*[(:]\d|\(?\d{1,4}[-/:.]\d{1,2}|\d+\s*[|:]\s|npm\s|yarn\s|pnpm\s|error\s*:|warning\s*:|exception\s*:|traceback\b|caused by\b)/i
44const LOG_LEVEL = /^(?:ERROR|ERR|WARN|WARNING|INFO|DEBUG|TRACE|FATAL|CRITICAL|NOTICE)\b/
45const NAMED_ERROR = /\b\w+(?:Error|Exception):/
46/** Characters that are rare in someone's own sentence and common in code and logs. */
47const CODE_CHARS = /[{}<>;=|\\$@%^&*()[\]_/:~+#]/g
48
49/** Whether a line reads as pasted output or code rather than something the person wrote. */
50const looksPasted = (line: string): boolean => {
51 if (line.length > MAX_LINE || PASTED_START.test(line) || LOG_LEVEL.test(line) || NAMED_ERROR.test(line)) {
52 return true
53 }
54 if (/[;{]$/.test(line)) {
55 return true
56 }
57 const prose = line.replace(/`[^`]*`/g, 'code')
58 return (prose.match(CODE_CHARS) ?? []).length / prose.length > 0.2
59}
60
61const LIST_MARK = /^(?:[-*•]\s+|\d{1,2}[.)]\s+)/
62
63/** The prompt's own lines worth reading: code fences, quotes and pasted output left out. */
64const proseLines = (prompt: string): string[] => {
65 const lines: string[] = []
66 let isFenced = false
67 for (const raw of plain(prompt).split(/\r?\n/)) {
68 if (/^\s*(?:```|~~~)/.test(raw)) {
69 isFenced = !isFenced
70 continue
71 }
72 const line = raw.trim()
73 if (!isFenced && line) {
74 lines.push(line)
75 }
76 }
77 const read = lines.length > LONG_PROMPT_LINES ? [...lines.slice(0, EDGE_LINES), ...lines.slice(-EDGE_LINES)] : lines
78 return read.filter(line => !looksPasted(line)).map(line => line.replace(LIST_MARK, ''))
79}
80
81/** Sentences end at . ! ? or ; followed by a space or the line's end, so `~/.claude/CLAUDE.md` stays whole. */
82const SENTENCE = /(?:[^.!?;]|[.!?;](?!\s|$))+[.!?;]*/g
83
84const sentencesOf = (line: string): string[] => (line.match(SENTENCE) ?? []).map(part => part.trim()).filter(Boolean)
85
86/** Where a new clause may start inside a sentence: after a comma or colon, a dash, or a joining word. */
87const BOUNDARY = /[,:]\s+|\s+[-–—]+\s+|\s+(?:but|so|and|then|because)\s+/gi
88
89/** Words that lead into a clause and carry nothing of it: `please`, `ok,`, `and also`, `Claude,`. */
90const FILLER =
91 /^(?:(?:and|also|but|so|ok|okay|oh|btw|hey|please|plz|pls|just|then|now|alright|actually|anyway|again|one more thing|one thing|like i said|as i said|as i mentioned|for the record|note|reminder|just a reminder|quick reminder)\b[,:!]?|claude[,:!])\s*/i
92
93const stripLead = (clause: string): string => {
94 let text = clause.trim()
95 for (let previous = ''; previous !== text; ) {
96 previous = text
97 text = text.replace(FILLER, '').trim()
98 }
99 return text
100}
101
102const QUESTION =
103 /^(?:what|why|how|who|whom|whose|where|when|which|can|could|would|will|shall|is|are|was|were|does|did|has|am|isn't|aren't|wasn't|doesn't|didn't|won't|wouldn't|couldn't|shouldn't|can't|any idea)\b|^do\s+(?:you|we|i|they|it|these|those|this|that)\b|^should\s+(?:we|i|you|it|this|that|they)\b/i
104
105/** Phrasing that marks the instruction as about this moment only. */
106const ONE_OFF =
107 /\b(?:yet|for now|right now|just now|at the moment|this time|today|tonight|this one|that one|for a (?:sec|second|minute|moment|bit))\b/i
108
109/** Clause openings that make it a statement about someone or something, not an instruction. */
110const NOT_INSTRUCTION_START =
111 /^(?:i|i'm|im|i'll|i've|i'd|it|it's|its|that|that's|this|there|there's|he|she|they|they're|my|maybe|perhaps|probably|hopefully|if|when|whether)\b/i
112
113/** Words after `always`/`never` that make it a remark ("always nice to see", "never mind"), not an instruction. */
114const NOT_AFTER_ALWAYS = new Set([
115 'a', 'an', 'the', 'mind', 'seen', 'been', 'heard', 'thought', 'knew', 'known', 'saw', 'had', 'did', 'said', 'was',
116 'were', 'is', 'are', 'am', 'ever', 'before', 'once', 'so', 'too', 'such', 'more', 'less', 'true', 'fine', 'easy',
117 'hard', 'worth', 'better', 'best', 'worse', 'nice', 'good', 'great', 'fun', 'cool', 'interesting', 'helpful',
118 'glad', 'happy', 'there', 'here', 'enough', 'really', 'quite', 'pretty', 'again', 'gonna', 'going', 'works',
119 'happens', 'gets', 'got', 'felt', 'feels', 'seems', 'something', 'someone', 'somebody', 'anything', 'knows',
120 'know', 'late', 'fails', 'amazing', 'awesome', 'weird', 'strange', 'funny', 'lovely', 'welcome', 'sure', 'the',
121 'ending', 'mine', 'yours', 'ours', 'you', 'me', 'us', 'them', 'it', 'that', 'this',
122])
123/** `-ed` words that are verbs in their own right, so `never embed` stays an instruction. */
124const ED_VERBS = new Set(['need', 'embed', 'proceed', 'exceed', 'feed', 'seed', 'speed', 'succeed', 'shred', 'spread', 'bleed', 'breed', 'heed'])
125/** `-ing` words that are verbs in their own right, so `always bring` stays an instruction. */
126const ING_VERBS = new Set(['bring', 'string', 'spring', 'swing', 'sting', 'cling', 'fling', 'wring'])
127
128/** Whether the word after `always`/`never` reads as a verb in the imperative. */
129const isImperativeAfterAlways = (word: string): boolean => {
130 const lower = word.toLowerCase()
131 if (NOT_AFTER_ALWAYS.has(lower)) {
132 return false
133 }
134 if (lower.length > 4 && lower.endsWith('ed') && !ED_VERBS.has(lower)) {
135 return false
136 }
137 // `always uses`, `never has`: a third-person remark; `never pass`, `always focus` are still verbs.
138 if (/[^su]s$/.test(lower)) {
139 return false
140 }
141 return !(lower.length > 5 && lower.endsWith('ing') && !ING_VERBS.has(lower))
142}
143
144/** Verbs after `don't` that make it reassurance or a remark ("don't worry", "we don't know"), not an instruction. */
145const NOT_AFTER_DONT = new Set([
146 'worry', 'mind', 'panic', 'know', 'think', 'understand', 'see', 'get', 'hesitate', 'sweat', 'fret', 'bother',
147 'stress', 'remember', 'recall', 'believe', 'have', 'care', 'agree', 'feel', 'really', 'even', 'quite', 'actually',
148 'necessarily', 'like', 'love', 'hate', 'thank', 'judge', 'blame', 'you', 'mean', 'seem', 'expect', 'suppose',
149 'need',
150])
151
152/** Words that leave an instruction with nothing to follow once its trigger is gone: `never do that again`. */
153const VAGUE = new Set([
154 'do', 'doing', 'does', 'did', 'that', 'this', 'it', 'so', 'anything', 'something', 'again', 'please', 'ever',
155 'now', 'then', 'there', 'here', 'those', 'these', 'them', 'one', 'thing', 'things', 'the', 'a', 'an', 'any', 'more',
156 'like', 'stuff', 'me', 'yourself', 'what', 'said', 'say', 'ok', 'okay', 'thanks', 'thank', 'you', 'to', 'of', 'with',
157 'for', 'and', 'or', 'but', 'is', 'be', "that's", "it's", 'way', 'either', 'too', 'also', 'all', 'just', 'anymore',
158])
159
160const hasContent = (rest: string): boolean =>
161 (rest.toLowerCase().match(/[a-z0-9`~/._'-]+/g) ?? []).some(word => !VAGUE.has(word.replace(/^'+|'+$/g, '')))
162
163/** `always`/`never` given as an instruction: bare, after `we`, or after `you should` (a bare `you always` is a complaint). */
164const ALWAYS =
165 /^(?:we\s+(?:should\s+|must\s+|need\s+to\s+|have\s+to\s+)?|(?:you|claude)\s+(?:should|must|need\s+to|have\s+to)\s+)?(?:always|never)\s+([a-z']+)/i
166/** `don't` given as an instruction: bare or after `we` (`you don't listen` is a complaint). */
167const DONT = /^(?:we\s+)?(?:don'?t|do\s+not)\s+([a-z']+)/i
168const SHOULD_NOT = /^(?:you|we|claude)\s+(?:should(?:n'?t|\s+not)|must(?:n'?t|\s+not)|shall\s+not)\s+([a-z']+)/i
169const LEADS = [
170 /^make\s+sure\s+(?:to|you|that|we)\b/i,
171 /^remember\s+(?:to|that)\b/i,
172 /^stop\s+[a-z]+ing\b/i,
173 /^(?:in|for)\s+this\s+(?:project|repo|repository|codebase|job|session)\b/i,
174]
175
176/** "I told you not to X", "I want you to always X": a restated or wished-for order, rewritten as the order itself. */
177const REPHRASE: readonly (readonly [RegExp, string])[] = [
178 [/^i(?:'ve|'d)?\s+(?:already\s+)?(?:told|asked|want|need|would\s+like|like)\s+you\s+(?:already\s+|again\s+)?not\s+to\s+/i, "don't "],
179 [/^i(?:'ve|'d)?\s+(?:already\s+)?(?:told|asked|want|need|would\s+like|like)\s+you\s+(?:already\s+|again\s+)?never\s+to\s+/i, 'never '],
180 [/^i(?:'ve|'d)?\s+(?:already\s+)?(?:told|asked|want|need|would\s+like|like)\s+you\s+(?:already\s+|again\s+)?to\s+(always|never|stop)\b/i, '$1'],
181]
182
183const rephrase = (clause: string): string => {
184 for (const [pattern, replacement] of REPHRASE) {
185 if (pattern.test(clause)) {
186 return clause.replace(pattern, replacement)
187 }
188 }
189 return clause
190}
191const SCOPED_HABIT =
192 /^(?:we|you)\s+(?:always\s+|only\s+)?(?:use|prefer|keep|follow|run|need|want)\b(.*)\b(?:in|for)\s+this\s+(?:project|repo|repository|codebase|job)\b/i
193const FROM_NOW = /\b(?:from now on|going forward|moving forward|from here on(?: out)?)\b/i
194
195/** What follows the clause's trigger when the clause is an instruction; null when it is not one. */
196const instructionRest = (clause: string): string | null => {
197 const always = clause.match(ALWAYS)
198 if (always) {
199 const verb = always[1] ?? ''
200 return isImperativeAfterAlways(verb) ? clause.slice(always[0].length - verb.length) : null
201 }
202 const dont = clause.match(DONT) ?? clause.match(SHOULD_NOT)
203 if (dont) {
204 const verb = dont[1] ?? ''
205 return NOT_AFTER_DONT.has(verb.toLowerCase()) ? null : clause.slice(dont[0].length - verb.length)
206 }
207 for (const lead of LEADS) {
208 const found = clause.match(lead)
209 if (found) {
210 return clause.slice(found[0].length)
211 }
212 }
213 const habit = clause.match(SCOPED_HABIT)
214 if (habit) {
215 return habit[1] ?? ''
216 }
217 const phrase = clause.match(FROM_NOW)
218 if (phrase && !NOT_INSTRUCTION_START.test(clause)) {
219 return clause.replace(FROM_NOW, ' ')
220 }
221 return null
222}
223
224/** The instruction a sentence gives, from the clause where it starts; null when it gives none. */
225export const directiveOf = (sentence: string): string | null => {
226 const whole = sentence.trim()
227 if (whole.length > MAX_SENTENCE || whole.endsWith('?') || QUESTION.test(stripLead(whole))) {
228 return null
229 }
230 const starts = [0, ...[...whole.matchAll(BOUNDARY)].map(found => (found.index ?? 0) + found[0].length)]
231 for (const start of starts) {
232 const clause = rephrase(stripLead(whole.slice(start)))
233 const rest = instructionRest(clause)
234 if (rest === null) {
235 continue
236 }
237 const order = normalizeOrder(clause)
238 if (order.split(' ').length >= MIN_WORDS && !ONE_OFF.test(order) && hasContent(rest)) {
239 return order
240 }
241 }
242 return null
243}
244
245/**
246 * The first lasting instruction a prompt gives ("never open bambu with full spectrum files"), normalized;
247 * null when it gives none. Questions, one-off phrasing, code fences and pasted output are passed over.
248 */
249export const findDirective = (prompt: string): string | null => {
250 if (prompt.trimStart().startsWith('/')) {
251 return null
252 }
253 for (const line of proseLines(prompt)) {
254 for (const sentence of sentencesOf(line)) {
255 const order = directiveOf(sentence)
256 if (order) {
257 return order
258 }
259 }
260 }
261 return null
262}
263
264// ---------------------------------------------------------------------------------------------
265// Where project orders live
266
267/** FNV-1a over the UTF-16 units, as 8 hex digits. */
268const fnv1a = (text: string): string => {
269 let hash = 0x811c9dc5
270 for (let i = 0; i < text.length; i += 1) {
271 hash ^= text.charCodeAt(i)
272 hash = Math.imul(hash, 0x01000193) >>> 0
273 }
274 return hash.toString(16).padStart(8, '0')
275}
276
277const MAX_KEY = 120
278
279/**
280 * The project's folder as a file name, the way `~/.claude/projects` names them: every character
281 * but a letter or digit becomes `-` (`/Users/me/project` is `-Users-me-project`); a key longer
282 * than MAX_KEY is cut and ends in a hash of the whole path.
283 */
284export const projectKey = (root: string): string => {
285 const path = root.length > 1 ? root.replace(/[\\/]+$/, '') : root
286 const key = path.replace(/[^A-Za-z0-9]/g, '-')
287 return key.length <= MAX_KEY ? key : `${key.slice(0, MAX_KEY - 9)}-${fnv1a(path)}`
288}
289
290const withoutTrailingSlash = (path: string): string => (path.length > 1 ? path.replace(/[\\/]+$/, '') : path)
291
292/** `~/.claude/standing-orders/<key>.json` under `home`. */
293export const ordersPath = (home: string, root: string): string =>
294 `${withoutTrailingSlash(home)}/.claude/standing-orders/${projectKey(root)}.json`
295
296/** `path` with the home folder written as `~`. */
297export const tildePath = (path: string, home: string | undefined): string => {
298 const base = home ? withoutTrailingSlash(home) : ''
299 return base && (path === base || path.startsWith(`${base}/`)) ? `~${path.slice(base.length)}` : path
300}
301
302/** The orders a project file holds; null when the file is not JSON of that shape (it is then left alone). */
303export const parseProjectFile = (text: string): Order[] | null => {
304 if (text.trim() === '') {
305 return []
306 }
307 let data: unknown
308 try {
309 data = JSON.parse(text) as unknown
310 } catch {
311 return null
312 }
313 const list: unknown = Array.isArray(data) ? data : (data as { orders?: unknown } | null)?.orders
314 if (!Array.isArray(list)) {
315 return null
316 }
317 const orders: Order[] = []
318 for (const item of list as unknown[]) {
319 const raw = typeof item === 'string' ? item : (item as { text?: unknown } | null)?.text
320 const at = typeof item === 'object' && item !== null ? (item as { addedAt?: unknown }).addedAt : undefined
321 const text = typeof raw === 'string' ? normalizeOrder(raw) : ''
322 if (text) {
323 orders.push({ text, addedAt: typeof at === 'number' && Number.isFinite(at) ? at : 0 })
324 }
325 }
326 return orders
327}
328
329export const serializeProjectFile = (root: string, orders: readonly Order[]): string =>
330 `${JSON.stringify({ root, orders }, null, 2)}\n`
331
332// ---------------------------------------------------------------------------------------------
333// What Claude and the person read
334
335export const BLOCK_LEAD = 'Standing orders (kept by the standing-orders mod; follow them unless the user says otherwise):'
336
337/** The context block: project orders, session orders and the active goal; null when there are none. */
338export const blockText = (project: readonly Order[], session: readonly Order[], goal: string | null): string | null => {
339 if (project.length === 0 && session.length === 0 && !goal) {
340 return null
341 }
342 const lines = [BLOCK_LEAD]
343 if (project.length > 0) {
344 lines.push('For this project:', ...project.map(order => `- ${order.text}`))
345 }
346 if (session.length > 0) {
347 lines.push('For this session:', ...session.map(order => `- ${order.text}`))
348 }
349 if (goal) {
350 lines.push(`Active goal: ${goal}`)
351 }
352 return lines.join('\n')
353}
354
355const scopeWords = (scope: Scope): string => (scope === 'project' ? 'this project' : 'this session')
356
357/** The note attached to the next prompt for orders kept mid-conversation. */
358export const unsentText = (list: readonly Unsent[]): string => {
359 const lead =
360 list.length === 1
361 ? 'A standing order was just added (kept by the standing-orders mod; follow it unless the user says otherwise):'
362 : 'Standing orders were just added (kept by the standing-orders mod; follow them unless the user says otherwise):'
363 return [lead, ...list.map(order => `- ${order.text} (for ${scopeWords(order.scope)})`)].join('\n')
364}
365
366export type Listing = {
367 root: string
368 /** Where project orders are saved, as shown; null when there is no home folder to save them in. */
369 file: string | null
370 project: readonly Order[]
371 session: readonly Order[]
372 goal: string | null
373}
374
375/** `/orders`: project orders numbered first, then session orders, then the goal. */
376export const listText = ({ root, file, project, session, goal }: Listing): string => {
377 const lines: string[] = []
378 if (project.length === 0 && session.length === 0) {
379 lines.push(
380 `No standing orders for ${root}. When you give a lasting instruction the band above the prompt offers to keep it, or use /orders add [project|session] <text>.`,
381 )
382 } else {
383 lines.push(`Standing orders for ${root}:`)
384 if (project.length > 0) {
385 lines.push(`This project${file ? ` (${file})` : ''}:`, ...project.map((order, i) => ` ${i + 1}. ${order.text}`))
386 }
387 if (session.length > 0) {
388 lines.push('This session:', ...session.map((order, i) => ` ${project.length + i + 1}. ${order.text}`))
389 }
390 }
391 if (goal) {
392 lines.push(`Active goal: ${goal}`)
393 }
394 return lines.join('\n')
395}
396
397/** `/orders export`: every order as a Markdown list in a fence, to paste into CLAUDE.md; null when there are none. */
398export const exportMarkdown = (orders: readonly Order[]): string | null => {
399 if (orders.length === 0) {
400 return null
401 }
402 const fence = orders.some(order => order.text.includes('```')) ? '````' : '```'
403 return [`${fence}markdown`, '## Standing orders', '', ...orders.map(order => `- ${order.text}`), fence].join('\n')
404}
405
406// ---------------------------------------------------------------------------------------------
407// Commands
408
409export const USAGE = [
410 'Usage:',
411 ' /orders list the standing orders, numbered',
412 ' /orders add [project|session] <text> keep an order (project when no scope is given)',
413 ' /orders forget <n> drop order number n',
414 ' /orders clear session|project drop every order of one scope',
415 ' /orders export print them as Markdown for CLAUDE.md',
416].join('\n')
417
418export type OrdersCommand =
419 | { verb: 'list' }
420 | { verb: 'add'; scope: Scope; text: string }
421 | { verb: 'forget'; number: number }
422 | { verb: 'clear'; scope: Scope }
423 | { verb: 'export' }
424 | { verb: 'usage' }
425
426const asScope = (word: string | undefined): Scope | null => {
427 const lower = word?.toLowerCase()
428 return lower === 'project' || lower === 'session' ? lower : null
429}
430
431export const parseOrdersCommand = (args: string): OrdersCommand => {
432 const text = args.replace(/\s+/g, ' ').trim()
433 const [word = '', ...rest] = text.split(' ')
434 const verb = word.toLowerCase()
435 if (verb === '' || verb === 'list') {
436 return rest.length === 0 ? { verb: 'list' } : { verb: 'usage' }
437 }
438 if (verb === 'add') {
439 const scope = asScope(rest[0])
440 const order = normalizeOrder((scope ? rest.slice(1) : rest).join(' '))
441 return order ? { verb: 'add', scope: scope ?? 'project', text: order } : { verb: 'usage' }
442 }
443 if (verb === 'forget' || verb === 'remove' || verb === 'rm') {
444 const number = Number((rest[0] ?? '').replace(/^#/, ''))
445 return rest.length === 1 && Number.isInteger(number) && number > 0 ? { verb: 'forget', number } : { verb: 'usage' }
446 }
447 if (verb === 'clear') {
448 const scope = asScope(rest[0])
449 return scope && rest.length === 1 ? { verb: 'clear', scope } : { verb: 'usage' }
450 }
451 if (verb === 'export' && rest.length === 0) {
452 return { verb: 'export' }
453 }
454 return { verb: 'usage' }
455}
456
457/** What `/goal <args>` does to the active goal: a new one, null for `/goal clear`, undefined for a bare `/goal`. */
458export const goalChange = (args: string): string | null | undefined => {
459 const text = args.replace(/\s+/g, ' ').trim()
460 if (!text) {
461 return undefined
462 }
463 return /^clear$/i.test(text) ? null : clip(text, MAX_GOAL)
464}
465
466/** True for what the engine draws when no plugin draws the band, or an empty Box. */
467export function isBlankTree(tree: RenderElement): boolean {
468 if (tree.type === 'engine') {
469 return true
470 }
471 return tree.type === 'Box' && (tree.children ?? []).length === 0
472}
473types/index.d.ts 34 lines1/** Where an order holds: this repo or folder, across sessions; or this session alone. */
2export type Scope = 'project' | 'session'
3
4export type Order = {
5 /** The instruction, one line of at most 200 characters. */
6 text: string
7 /** When it was kept, in epoch milliseconds. */
8 addedAt: number
9}
10
11/** A directive from the person's prompt that the band offers to keep. */
12export type Candidate = {
13 text: string
14 /** Prompts submitted since it was offered; left unanswered for a few, it is dropped. */
15 promptsSince: number
16}
17
18/** An order kept mid-conversation, waiting to ride along with the next prompt. */
19export type Unsent = { text: string; scope: Scope }
20
21declare module 'claude-code' {
22 interface PluginState {
23 'standing-orders': {
24 candidate: Candidate | null
25 sessionOrders: Order[]
26 /** The `/goal` last set in this session; null when none, or after `/goal clear`. */
27 goal: string | null
28 unsent: Unsent[]
29 /** Directives answered No this session (lower-cased), so they are not offered again. */
30 dismissed: string[]
31 }
32 }
33}
34