SLOPSHOPPER

standing-orders

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…

newbandcommandtoastpromptprocess
v0.1.0no licenseupdated 2026-10-07joeldg/claude-mods/standing-orders
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · standing-orders
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /orders ⎿ standing-orders: No standing orders for /work/app. When you give a lasting instruction the band above the prompt offers to keep ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

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

dev-servers

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:
  • running servers whose working folder is in this repo: name, port, pid and uptime, with Restart, Stop and Log
  • known start commands that aren't running, with Start: package.json dev/start/serve/preview scripts (run with your lockfile's package manager), .claude/launch.json and Procfile
  • a count of other listeners on the Mac
  • Only button presses start or stop anything:
  • Start runs the command detached, logging to ~/.claude/dev-servers/<project>/.
  • Stop sends SIGTERM. If the server ignores it, pressing again within 10s force-stops it.
  • Restart stops the server, waits for the port to free up, then starts it.
  • Before any signal, it checks the pid still runs the same command.
  • When a command fails with "address already in use", Claude gets a note (and you a toast) naming the holder, e.g. Port 4000 is held by node (pid 123, up 2h, in /Users/me/other).
  • Status line: servers: :4000 :5173.
  • Makes no model calls: lsof and ps every 15s.

downloads-drop

Puts files you just downloaded into your prompt with one click.

  • Watches ~/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].
  • Attach puts @"/Users/you/Downloads/paper.pdf" mentions in your prompt. Dismiss hides those files.
  • Waits until a file has finished downloading (skips partial downloads and files still growing), and ignores hidden and zero-byte files.
  • /downloads lists the 10 newest files, numbered. /downloads attach 1 3 (or 2-4) adds those, and /downloads clear dismisses everything new.
  • Stacks with other mods' bands (repo-brief, standing-orders, secret-guard) instead of hiding them.
  • Makes no model calls.

Settings: folder (~/Downloads), extensions, pollSeconds (5), maxAgeMinutes (120).

effort-router

Sets effort per message, so you don't have to switch it by hand.

  • Git chores ("merged", "#219 merged", "commit and push", "push it", "open a PR", "close the issue") run at low effort and come back faster.
  • Deep asks (audit, review, plan, design, investigate, root cause, "why does…", "figure out") run at max.
  • Everything else, including approvals like "yes", "go ahead" and "continue" and anything that starts new work ("merged 219, go ahead with #214"), keeps your session's own effort.
  • Prompt cache: changing effort makes the whole conversation get cached again. So it never switches mid-turn, raises effort at once, and over a large, warm cache lowers it only after 2 routine turns in a row. In small contexts, or once the cache has lapsed, it switches right away.
  • Model guard: set 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.
  • Status line while a turn is routed: effort: low (routine).
  • Makes no model calls.

Settings: routineEffort (low), deepEffort (max), routinePattern, deepPattern, avoidModel, fallbackModel (opus), stickyTurns (2), freeSwitchTokens (30000), cacheTtlMinutes (60).

job-watch

A Jobs pane for long-running work: training runs, downloads, extractions.

  • Picks up background Bash tasks and detached nohup … > log & launches by itself.
  • /watch <log> [label] adds any other log file.
  • Shows progress, ETA and the last log line, and flags a job as stalled when its log goes quiet.
  • Shows free space on / and /Volumes/* (the NAS).
  • Toasts when a job finishes or stalls. The status line shows jobs: 2 running · 1 stalled.
  • /jobs opens the pane, /unwatch <label|done|all> removes jobs.
  • Makes no model calls: it reads logs with 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.

machine-guard

Memory, swap and GPU on the status line. It refuses heavy local jobs when the Mac can't take them.

  • Status line: RAM tight 12% free · swap 7.9/8G · top python 31G · GPU 87%.
  • It refuses heavy jobs (training, inference, rendering, extraction, Blender, Docker, ffmpeg) when:
  • macOS reports critical memory pressure, or
  • the Mac is reserved with /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.
  • Remote runs (modal run, ssh), tests (pytest) and installs are never treated as heavy.
  • Add your own heavy commands with the "Also heavy" setting (a regex), e.g. overnight_|nightly_run\.sh.

mod-monitor

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.

  • Failures: any mod hook that throws, times out or rejects, read from the hook chain's results (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.
  • What each mod did: its toasts ("#219 merged → …", "Blocked: …"), status-line changes, the mod commands you used (never their arguments), and failed subprocesses (a burst of 5 in 10 minutes raises a toast). Git checks run outside a repository are logged as expected, not as failures. It also records model calls (the only usage the mods cost: /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.
  • Logs: ~/.claude/mods/monitor/<date>/<session>.jsonl, flushed every minute and at session end, with secrets masked and old days removed after 30 days.
  • Makes no model calls and adds no measurable latency.
  • Error lines mods log themselves ($.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.
  • Transcript-row hooks (secret-guard masks /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).

modal-meter

Keeps an eye on Modal so idle GPU containers don't burn credits.

  • Status line while containers run: Modal: 1 running (2 containers). Deployed apps with no containers cost nothing, so they stay off it.
  • A toast when an app has had containers up longer than 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.
  • Shows today's spend and alerts on a budgetToday where the Modal CLI supports billing report (1.3.3+, Team/Enterprise workspaces). Otherwise /modal says why spend isn't shown.
  • Finds the CLI as 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.
  • Makes no model calls: only the Modal CLI, every 60s.

pr-autopilot

Does the "merged #219, clean up branches and start #214" round trip for you, and surfaces CI failures with their logs.

  • Watches your open PRs in the session's repo: it adopts them at session start, and picks up every gh pr create Claude runs. It polls gh pr view every 60s.
  • Status line: PRs: #219 ✓ · #220 CI… · #221 ✗. Toasts when CI fails (with the failing check names) or passes.
  • When a PR merges, it cleans up with plain local git, then toasts the outcome and suggests carrying on (Tab to accept):
  • git fetch --prune, switch to the default branch (only from the PR's own branch) and git pull --ff-only.
  • Never with uncommitted changes, never --force, never other branches.
  • Deletes the local branch only if it points at exactly the commit GitHub merged, so nothing local is lost. It also leaves a branch checked out in another worktree alone.
  • A merge seen mid-turn is cleaned up when the turn ends, so git never races Claude.
  • When you mention failing CI ("#258 is failing", "CI failed, fix it"), your message goes to Claude with 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.
  • Makes no model calls: only 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.

recall

Search everything you've done with coding agents, from Claude or from /recall. It replaces the broken agent-memory plugin.

  • What it searches: Claude Code sessions, Codex sessions, subagent and workflow runs, Claude's memory files, standing orders, second-opinion reviews, and your /remember notes. Routine (scheduled) runs are left out unless you add routines:include to a query.
  • What it keeps: prompts, answers, compaction summaries, session titles, commands, files touched, commits, PRs, issues, URLs, tasks and decisions (what you approved or ruled out). Read-only look-ups like grep and cat are kept but ranked low.
  • History survives cleanup: extracts stay searchable after Claude Code deletes old transcripts.
  • Claude searches it itself with four read-only tools, 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.
  • Commands:
  • /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>.
  • Bands:
  • Once per session: Last session here (2d ago): "…" · PR #99 · 3 open tasks [Recap].
  • When a prompt mentions #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.
  • Query syntax: words must all match. 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.
  • Privacy:
  • The index lives at ~/.claude/recall/index.db, readable only by you, and never goes in a repo.
  • Secrets are masked before anything is stored: known token shapes, labelled values ("password: …"), the values of secret-named exports in ~/.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.
  • Cost: no model calls except /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.
  • Requires macOS's /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).

repo-brief

Catches Claude up on the repo when a session starts, so you don't have to ask "check the recent commits/PRs and issues".

  • Gathers in the background at session start:
  • branch, ahead/behind and uncommitted files
  • the last 8 commits
  • open PRs with CI ✓/✗/…
  • issues labelled owner, todo, P0 or blocked
  • stale branches (merged, or upstream gone)
  • A one-line band above the prompt, e.g. main ↑1 · 3 changed · PRs #123 ✗ #124 ✓ · 2 owner issues · 2 stale branches · last commit 2h ago. Hide dismisses it.
  • Claude gets the same summary once, in its first message, so the prompt cache stays warm. It refreshes after compaction.
  • /brief re-gathers now and prints the full summary.
  • Makes no model calls: only git and gh. The band refreshes after a turn at most every 2 minutes.

Settings: focus labels, refresh minutes, and whether to brief Claude.

routine-watch

Keeps scheduled routines (daily digests, newsletters) from silently stalling while you're away.

  • Knows a session is a routine from its scheduled-task prompt, and does nothing in your other sessions.
  • When a routine stops to wait for your OK on a permission prompt or an AskUserQuestion, you get a Mac notification and a toast, and the status line shows routine: daily-report · waiting on you 3m.
  • When a turn ends in an error, or the routine finishes, you get a notification: Routine daily-report finished after 23m · waited on you 2 times.
  • Phone push (optional): 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.
  • Makes no model calls.

second-opinion

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.
  • Other forms:
  • /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.
  • The command returns at once, and the status line shows 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.
  • Send to Claude attaches the review to your next prompt (once) and drafts "What do you agree with, and what would you act on?".
  • Reviews are saved in ~/.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).

standing-orders

Keeps your "always / never / don't / from now on" instructions alive across compaction.

  • When you write an instruction like "never open bambu with full spectrum files", a band asks: Keep as a standing order? [Project] [This session] [No]. Nothing is saved without a click.
  • Project orders live in ~/.claude/standing-orders/<repo>.json and apply to every session in that repo. Session orders and your active /goal last for the session.
  • Claude gets them at the start of every conversation and again after each compaction or /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).
  • Makes no model calls.

secret-guard

Stops keys and passwords from going into a prompt, and so into your transcripts, and turns them into env vars instead.

  • Catches known token shapes: AWS, GitHub, Anthropic, OpenAI, Slack, Google, Hugging Face, GitLab, npm, Stripe, private keys and bearer tokens.
  • Also catches labelled values ("password: …", "api key = …", "the wifi password is …") and the two-line "Access Key ID / Secret Access Key" paste.
  • Leaves alone $NAME references, placeholders, plain URLs, paths, git SHAs and ordinary prose about passwords.
  • On a hit, the prompt isn't sent and goes back in the box. A band shows the secret masked (…vxrm) with a suggested name such as OPENDATALAB_SECRET_ACCESS_KEY, which you can edit:
  • Save as env var appends export NAME='…' to ~/.zshrc (reusing an existing identical export) and replaces the secret in your prompt with $NAME.
  • Send anyway lets exactly that text through once.
  • Edit dismisses the band.
  • The value is never shown in toasts, status, state or the transcript, and /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.
  • Makes no model calls.

Settings: enabled (on), extraPatterns (a regex), zshrcPath (~/.zshrc).

slicer-handoff

Makes Claude's open commands hand 3D files to the right slicer.

  • Full-spectrum files go to Snapmaker Orca. Bambu Studio and OrcaSlicer can't open them. A file counts as full-spectrum when:
  • its name or folder matches full.?spectrum|snapmaker-only|-fs\.3mf$|-u1[-.], or
  • its 3MF names a Full Spectrum filament profile.

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

  • Earlier windows close first. Before opening a file, it asks the running slicer to quit (a normal quit, never forced), so windows don't pile up. If one won't close, for example because it's waiting on a save prompt, it stops trying and tells Claude to leave it alone.
  • /slice <file> [bambu|snapmaker|orca] opens a file yourself, with the same rules.
  • Recognizes 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.
  • Makes no model calls.

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.

Loading

  • One session from a terminal: pass --plugin-dir once per mod, e.g. claude --plugin-dir ~/Projects/claude-mods/job-watch --plugin-dir ~/Projects/claude-mods/pr-autopilot
  • Every session, including the desktop app: add to ~/.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" } } ``

Checking

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)
Source 3 files
hooks/register.tsx 397 lines
1import { 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}
397
hooks/orders.ts 473 lines
1import 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}
473
types/index.d.ts 34 lines
1/** 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