One loop — work — for work worth doing properly: clarify, an approved plan that is the run's only authority, delegated implementation, independent verification…

A curated collection of development, data science, writing, workshop, legal, and research workflows for Claude Code.
Requires Claude Code 2.1.287 or later. The tool-call guards and bulk-guard run only as a mod, and mods load from 2.1.287. The plugin manifest has no field for a minimum Claude Code version, so on an older version the SessionStart hook prints a warning that the guards are inactive.
# 1. Install the plugin
/plugin marketplace add edwinhu/workflows
# 2. Install dependencies — per-skill node packages (bun), plus the optional CLIs for
# knowledge management, email, and calendar
bash ~/.claude/plugins/cache/edwinhu-plugins/workflows/*/bin/install-deps.sh
# 3. (Optional) Live marimo kernel work — only needed by the `marimo` skill
claude plugin marketplace add marimo-team/marimo-pair
claude plugin install marimo-pair@marimo-pair
marimo-pair is a separate upstream plugin, deliberately not declared as a hard dependency: an uninstalled dependency stops the whole plugin from loading, and one skill should not be able to take the other sixty with it. The marimo skill routes live-kernel work to Skill(skill="marimo-pair:marimo-pair") and works for notebook authoring without it.
The script's first step runs bun install in every skill that ships a package.json — cite-check, deep-research and farm-out at the time of writing, each with its own lockfile. node_modules/ is gitignored and never shipped, so a fresh clone or a fresh plugin install has no node dependencies until this runs, and those skills' own test suites cannot even resolve their imports before it. The rest of the script is only needed for skills that use the external tools below. The plugin's own TypeScript hooks and JavaScript/TypeScript workflow runners use Bun; the core workflows do not require the optional CLIs installed by this script.
The script recognizes macOS, Linux, and Windows-like environments on x64 or arm64 and attempts to download matching pre-built binaries from GitHub Releases. If a tool does not publish an asset for the detected platform, the script warns and skips it:
| Tool | Purpose | Used by |
|---|---|---|
| nlm | NotebookLM CLI | librarian agent, internal nlm skill |
| readwise-custom | Readwise RAG/chat/upload | librarian agent, internal readwise-chat skill |
| scholar | Google Scholar search | librarian agent, internal google-scholar skill |
| consensus | Academic paper search | librarian agent, internal consensus skill |
| morgen | Calendar & tasks | Direct Bash, or session in ~/areas/assistant/ |
| superhuman | email-handling skill (via Bash) |
Requires gh (GitHub CLI). Tools already on your $PATH are skipped.
These are the skills you invoke directly with /name:
/work is the spine: clarify with the user, draft a plan they edit and approve, self-set a goal, run workflow.js to implement and independently verify, then put the result in front of a human in tuicr. Human rejection routes back to CLARIFY. The domain workflows dispatch through it and add their own computed gate.
| Workflow | Adds to the work loop |
|---|---|
/work | nothing — the generic loop for any task worth doing properly |
/dev | TDD discipline: a failing test before the change, and lens reviews for security, performance and test coverage |
/ds | a computed data-quality gate (DQ1-DQ6, M1, R1) over the panel the run builds |
/writing | a computed plan-grammar and citation gate, plus the domain style register the plan's Domain: selects |
/workshop | a computed deck gate over the Typst slides and speaker notes built from a paper |
/workflow-creator | designs, repairs and audits workflows themselves |
Plan review is computed and happens before dispatch: plan-lint.ts over the built args and plan-preflight.ts executing their commands at baseline, enforced by work-dispatch.sh while the run is still armed. No agent reads the plan markdown looking for defects.
Part of the document skill group — one pipeline (extract → create → repair → build → render → verify) bundling the Anthropic Office skills with this project's repair/build/render tooling.
| Skill | Purpose |
|---|---|
/docx | Word document creation, editing, tracked changes |
/pdf | PDF extraction, creation, form filling |
/pptx | Presentation creation and editing |
/xlsx | Spreadsheet creation and analysis |
/docx-render | Faithful Word export to PDF/PNG |
/law-review-docx | Markdown/legal draft → law-review-styled Word doc |
/law-econ-docx | Markdown law-and-economics manuscript → author-date, journal-ready Word doc |
Office format skills sourced from anthropics/skills via git submodule. Shared converters + the Google-export OOXML package repair (
scripts/docx_repair.py) live inscripts/. See references/document-skills.md for the full group and how the stages decouple.
| Skill | Purpose |
|---|---|
/ds-tables | Publication tables in Python — pyfixest.etable() regression tables and great_tables GT formatting |
/ds-figures | Publication-ready, accessible figures for papers, slides, and notebooks |
/crsp-lseg-splice | Extend stale CRSP stock panels with current LSEG data |
/npx-ownership-panel | Build the WRDS proxy-voting × ownership panel |
/fuzzy-name-matching | Entity resolution / record linkage by name — char n-gram TF-IDF + sparse_dot_topn top-k cosine, normalize-first, scoped + global two-pass |
| Skill | Purpose |
|---|---|
/cite-check | Verify academic citations against source PDFs |
/de-ai-revise | Revise flagged prose to remove corpus-validated AI writing tics |
| Skill | Purpose |
|---|---|
/skill-creator | Skill creation with superpowers enforcement patterns |
/plugin-creator | Plugin-level creation and editing across manifests, hooks, and skills |
/workflow-creator | Create a new structured workflow through shared-v1 |
/workflow-creator-improve | Audit, repair, redesign, or migrate an existing workflow |
These skills have user-invocable: false — Claude loads them automatically when relevant or a workflow dispatches them internally. You don't call them directly.
bluebook, bluebook-audit, docx-repair, source-verify
wrds, lseg-data, gemini-vertex
nlm, google-scholar, readwise, readwise-chat, readwise-search, readwise-docs, readwise-prune
consensus, research
marimo, jupytext, notebook-debug
farm-out, look-at, visual-verify, visual-mockup, data-context, continuous-learning, pattern-capture, ai-anti-patterns, obsidian-organize, pptx-render, headline-card
farm-out is the dispatcher work runs its agents through — work-dispatch.sh uses the sibling copy by default, so the plugin dispatches without an outside install. It fetches its own SDK on first run.
None. The work spine has no sub-skills: the phases are beats inside skills/work/workflow.js, dispatched as agents, so there is nothing to invoke by name and nothing to keep in sync.
Specialized subagents. The directory states the scope: agents/ is auto-discovered by Claude Code and registers plugin-scoped (workflows:<name>), while user-agents/ is not auto-discovered and registers user-scoped (bare name, hooks: honoured) via a symlink into ~/.claude/agents/:
| Agent | Role | Scope | Hooks |
|---|---|---|---|
librarian | Knowledge management orchestration (NLM, Readwise, Scholar, Workspace) | plugin | — |
ds | Empirical implementer — datasets, tables, figures, numbers; C/V/A/E constraints arrive as task refs | user | — |
ds-reviewer | Read-only grading of existing empirical work against C/V/A/E constraints | user | — |
workshop | Talk implementer — Typst deck and speaker notes from a paper; preloads typst:typst | user | — |
workshop-reviewer | Read-only grading of slides.typ and notes.typ against the canonical Typst modules | user | — |
writing | General long-form prose — memos, letters, briefs, reports | user | source-first PreToolUse guard |
writing-legal | Law review prose — footnotes, Bluebook short forms | user | source-first PreToolUse guard |
writing-econ | Finance and accounting journal prose | user | source-first PreToolUse guard |
writing-reviewer | Read-only prose grading against the preloaded register and the tic table | user | — |
The work spine's per-beat verifiers are still dispatched from skills/work/workflow.js with the prompt the run needs, so no agent file exists for them. Implementers are the exception: /ds, /writing and /workshop each set implementerAgentType to the matching agent above, and the teaching plugin sets it to its own lecture-impl. /dev and /workflow-creator deliberately leave it unset.
Claude Code's system prompt tells the model what kind of work it is doing. Its # Doing tasks section opens with "The user will primarily request you to perform software engineering tasks", and instructs that an unclear instruction be read in that context. A separate # Tone and style section asks for short, concise responses and file_path:line_number references. Neither is wrong for code. Both are wrong for a law review article, a lecture, or a seminar deck, where the deliverable is long and the reader is a person rather than a terminal.
An output style can remove the first of those and cannot remove the second. Setting a style drops # Doing tasks entirely unless the style's frontmatter sets keep-coding-instructions: true; # Tone and style is emitted unconditionally. So a custom style does not replace the framing — it competes with what survives, and the surviving half is precisely the half that shortens prose and formats references for an editor.
A subagent replaces the prompt instead of arguing with it. A custom subagent's body is its entire system prompt: Anthropic's documentation says a subagent receives that prompt plus environment details, not the full Claude Code system prompt, and claude --agent <name> applies the same to a main session. Asking one confirms it — it reports neither the software-engineering sentence nor a # Tone and style section at all. That is the whole reason this plugin routes prose, decks, teaching material and empirical work through agents rather than tuning a style.
The directory split follows from a second quirk. A hooks: block in agent frontmatter is ignored for plugin-shipped agents and honoured for user-scoped ones, so any agent that needs a blocking guard has to be user-scoped. user-agents/ is not a discovery location, so a symlink into ~/.claude/agents/ registers each file user-scoped under its bare name with its hooks live, while agents/ stays auto-discovered and plugin-scoped. It is one file either way, and the plugin still ships both.
Judging is not writing, and the roster's shape follows from that. A review lens only reads, so the built-in Explore plus a good prompt and the right reference paths is sufficient and cheaper; an agent earns a file only when it needs a custom prompt, hooks, or preloaded skills. The three domain reviewers exist because grading against a constraint set needs a body this repo controls — a built-in judge's prompt is predefined, so the modules it grades against have to reach it as task refs rather than as anything the lens can skip — and no exam reviewer exists because a prompt covers it. Constraint prose itself is never a skill: it has one canonical home under constraints/ (Typst rule texts under ~/.claude/skills/typst/rules/, checkers under constraints/), reaches dispatched agents as refs, and reaches interactive ones through the typst:typst bang line. The same test explains the two workflows that set no implementer override: /dev and /workflow-creator produce code and workflow definitions, where the software-engineering framing is correct rather than a defect.
The register skills — writing-general, writing-legal, writing-econ, ai-anti-patterns — are user-invocable: false for the same reason. Loading a register into the main chat stacks it on top of the framing it is meant to displace; routing the work to an agent that preloads it replaces that prompt instead. They deliberately do not set disable-model-invocation: true, which would break both the skills: preload and the Skill tool path a persona session needs.
Every workflow runs the same loop, in skills/work/workflow.js. The plan's <!-- work:dispatch --> block is the sole authority and its canonical specHash is verified by each dispatched agent; the gate is computed in JS from raw counts, fails closed on a dead agent, and returns the selector that drives the fix loop. A domain workflow contributes its own mechanical checks and review lenses — it does not get its own lifecycle.
Hooks auto-run at specific lifecycle events. The table has one row per command target registered in hooks/hooks.json:
| Script | Event | Trigger | Purpose |
|---|---|---|---|
session-start.ts | SessionStart | startup/resume/clear/compact | Inject using-skills meta-skill; report an unfinished work run |
session-end.ts | Stop | * | Update LEARNINGS.md timestamp |
lint-check.ts | PostToolUse | Edit/Write | Lint after file changes (ESLint, ruff, lintr) |
writing-prose-check.ts | PostToolUse | Edit/Write | Check edited prose for writing-quality violations |
cite-fidelity-lint.ts | PostToolUse | Edit/Write | Check edited writing for citation-ledger fidelity |
pr-url-logger.ts | PostToolUse | Bash | Log PR URLs and GitHub Actions status |
overflow-check.ts | PostToolUse | Bash | Detect Typst content overflow after compilation |
pattern-scan.ts | SessionEnd | clear/logout/prompt_input_exit/other | Scan session for reusable patterns |
The per-call guards run in-process on tool.call in the plugin's mod (hooks/guards/mod.ts), not as spawned settings hooks. Each can only deny or add context, never approve. The scripts of the same name under hooks/ stay as their settings-hook entry points (skills wire some in frontmatter) and share the code; tests/mod-guards-parity.test.ts holds the two to identical answers.
| Guard | Before / after | Tools | Purpose |
|---|---|---|---|
image-read-guard | before | Read | Redirect to look-at for media files |
read-guard | before | Read, Bash | Deny unbounded dumps of large files |
suggest-compact | before | Edit, Write | Suggest compaction at edit-count checkpoints |
pgrep-self-match | before | Bash, Monitor | Deny self-matching pgrep/pkill -f and path-less rg |
bun-parallel-guard | before | Bash | Deny a multi-file bun test without --parallel |
cron-delete-guard | before / after | CronDelete / CronCreate | Keep an in-flight work run's heartbeat; record new ids |
atomic-constraint-guard | after | Edit, Write | Validate atomic constraint file structure |
typst-convention-guard | after | Edit, Write | Typst convention violations |
validate-skill-paths | after | Edit, Write | ${CLAUDE_*} references to missing files |
hooks/bulk-guard.mjs is a Claude Code mod (2.1.287+), listed under "modules" in hooks/hooks.json. It runs in every session that loads the plugin, farm-out children included. It enforces the rule that per-document coding or extraction over many files is ONE gemini-vertex job on pre-cut excerpts. On tool.call it does the following:
| Rule | Tools | Action |
|---|---|---|
Distinct documents read (≥20 KB; .txt .htm .html .xml .sgml .pdf .nc, or a path containing filings/archives/edgar/raw/prospect), via Read or a Bash dump (cat, head, tail, sed -n, less, pdftotext, strings, python/awk one-liners). Rereads do not count | Read, Bash | note at 5, deny at 10 |
farm.sh / farm-team.sh / work-dispatch.sh --tasks with ≥5 rows near-identical to row 0 after masking paths, numbers, CIKs and accessions (similarity ≥0.8) | Bash | deny |
A for/while/xargs loop that runs claude -p, codex exec, gemini or agy -p per item | Bash | deny |
A model API call inside a loop (/v1/messages, Anthropic/OpenAI SDK, generateContent outside a batch flow). batches.create, batchPredictionJobs and request-JSONL writing are allowed; an Edit only trips the rule if it introduces the pattern | Bash; Write/Edit of .py .ts .js .sh | deny |
| A Read, Bash or Grep result over 20K tokens (chars/4) | Read, Bash, Grep | full output saved under $XDG_RUNTIME_DIR/bulk-guard/; the model sees head 6K + tail 2K |
Every threshold has an env override: BULK_GUARD_WARN_DOCS, _DENY_DOCS, _DOC_BYTES, _FANOUT_ROWS, _SIMILARITY, _TRIM_TOKENS, _HEAD_TOKENS, _TAIL_TOKENS. BULK_GUARD_OFF=1 in the user's environment disables the guard, and any command that sets it is denied. Each warn, deny and trim is appended to $XDG_RUNTIME_DIR/bulk-guard/events.jsonl, and the prompt band shows docs read N/10 · trimmed K results.
A work run keeps two records, one owner each: the approved plan at .claude/plans/<slug>.md is the run's authority and the file work hashes in place, and .work/<run-id>/ holds the args, the verdict JSON and the plan bytes each round actually ran under. Project auto-memory retains reusable facts; project directories retain real inputs and deliverables.
For cross-session task persistence, set CLAUDE_CODE_TASK_LIST_ID in .envrc:
export CLAUDE_CODE_TASK_LIST_ID="my-project"
workflows/
├── .claude-plugin/ # Plugin manifest
│ ├── plugin.json # Version and metadata
│ └── marketplace.json # Marketplace listing
├── agents/ # Plugin-scoped subagents (auto-discovered)
├── user-agents/ # User-scoped subagents (symlinked into ~/.claude/agents/)
├── skills/ # User-facing and internal skills
│ ├── work/ # The spine: workflow.js, plan-lint, dispatch, gate
│ ├── dev/, ds/, writing/, workshop/, workflow-creator/ # Domain workflows
│ ├── farm-out/ # The dispatcher the `work` skill farms agents out through
│ ├── docx, pdf, pptx, xlsx # Document formats (symlinks)
│ └── ... # Internal phases and auto-invoked skills
├── bin/ # Optional dependency installer
├── docs/ # Architecture and investigation records
├── hooks/ # Hook scripts
│ ├── hooks.json # Hook configuration
│ └── *.ts # Hook implementations run with Bun
├── references/ # Shared constraint and reference docs
├── scripts/ # Compilers, checks, renderers, and support tools
├── tests/ # Contract and regression tests
├── workflows/ # Shared and domain workflow runners
│ ├── lib/ # Runner libraries and task contracts
│ └── templates/ # Dynamic workflow templates
├── external/
│ └── anthropic-skills/ # Git submodule for document skills
└── PHILOSOPHY.md # Workflow design philosophy
Key Points:
skills/ contains both user-facing and internal phase skills (auto-discovered; internal skills use user-invocable: false)agents/ contains plugin-scoped subagents, auto-discovered by Claude Code as workflows:<name>user-agents/ contains user-scoped subagents; they reach Claude Code only through the symlink into ~/.claude/agents/ that ~/dotfiles/scripts/setup-claude-symlinks.sh createshooks/ contains TypeScript hook entry points called directly by hooks.jsonworkflows/ contains the shared runner plus writing, workshop, and workflow-creator adaptersscripts/ contains deterministic compilers, validation checks, renderers, and support toolsreferences/ contains shared constraint and enforcement docsThe office format skills come from Anthropic's official skills repo. To update:
git submodule update --remote external/anthropic-skills
This project was heavily inspired by obra/superpowers, particularly:
Office format skills (docx, pdf, pptx, xlsx) are from anthropics/skills.
MIT
Edwin Hu
hooks/register.ts 20 lines1// The plugin's one hooks module: hooks.json `modules` takes a single path per plugin, so each mod
2// exports a registrar and this file calls them in order.
3import type { Register } from 'claude-code'
4import { register as registerBulkGuard } from './bulk-guard.mjs'
5import { registerGuards } from './guards/mod.ts'
6import { registerJevForecast } from './jev/forecast-mod.ts'
7import { registerJevEdit } from './jev/mod.ts'
8import { registerWatcher } from './watch/watcher.ts'
9
10export const register: Register = (on) => {
11 // First, so outermost: its AbovePrompt band stacks on bulk-guard's rather than being hidden by it.
12 registerJevForecast(on)
13 registerBulkGuard(on)
14 registerWatcher(on)
15 // Above the guards: an edit they deny never reaches it.
16 registerJevEdit(on)
17 // Last, so innermost: the guards sit where the hooks.json settings hooks they replace ran.
18 registerGuards(on)
19}
20hooks/bulk-guard.mjs 555 lines1// bulk-guard: a Claude Code mod that stops per-document work at scale from billing Claude/Codex
2// accounts. Per-document coding or extraction over many files goes to Gemini via gemini-vertex
3// (Flex for ≤10 documents, ONE Batch job beyond), never an agent fan-out or an agent-written
4// model-API script.
5//
6// Rules (docs/bulk-guard.md): 1 distinct-document tripwire, 2 template fan-out and per-item agent
7// loops, 3 model-API calls inside a loop, plus trimming of oversized Read/Bash/Grep results.
8// No .catch handlers on purpose: a hook that throws is skipped (fail open). A heuristic guard must
9// never cost the user Bash because of its own bug.
10
11// ── thresholds: each overridable by the env var named beside it ──────────────────────────────
12const DEFAULTS = {
13 warnDocs: 5, // BULK_GUARD_WARN_DOCS
14 denyDocs: 10, // BULK_GUARD_DENY_DOCS
15 docMinBytes: 20480, // BULK_GUARD_DOC_BYTES
16 fanoutRows: 5, // BULK_GUARD_FANOUT_ROWS
17 fanoutSimilarity: 0.8, // BULK_GUARD_SIMILARITY
18 trimTokens: 20000, // BULK_GUARD_TRIM_TOKENS
19 headTokens: 6000, // BULK_GUARD_HEAD_TOKENS
20 tailTokens: 2000, // BULK_GUARD_TAIL_TOKENS
21};
22
23export const DENY_MESSAGE =
24 "Per-document reading at scale: route this through Gemini (Skill workflows:gemini-vertex) — ≤10 documents: one Cloud Flex call each; more: ONE Cloud Batch job on pre-cut excerpts (cost gate applies). Reading more filings here bills Claude/Codex accounts per document. Override only if the user sets BULK_GUARD_OFF=1.";
25export const WARN_NOTE =
26 "bulk-guard: this session has now read {n} distinct documents one by one. If the task is per-document coding or extraction, stop and route it through Skill workflows:gemini-vertex — ≤10 documents: Cloud Flex, one call each; more: ONE Cloud Batch job on pre-cut excerpts. At {deny} documents further reads are denied.";
27export const OFF_MESSAGE =
28 "bulk-guard: BULK_GUARD_OFF is the user's override, set in their environment before the session starts; a command may not set it.";
29
30const DOC_EXT = /\.(txt|htm|html|xml|sgml|pdf|nc)$/i;
31const DOC_DIR = /(^|[\/_.-])(filings|archives|edgar|raw|prospect)/i;
32const READ_VERB = /^(cat|head|tail|less|more|pdftotext|strings|bat|zcat)$/;
33const SCRIPT_VERB = /^(python3?|awk|gawk|mawk|perl)$/;
34const SCRIPT_EXT = /\.(py|ts|js|mjs|cjs|sh|bash)$/i;
35const FANOUT_SCRIPT = /(^|\/)(farm|farm-team|work-dispatch)\.sh$/;
36
37// ── config ───────────────────────────────────────────────────────────────────────────────────
38// $.env.get needs a string literal per name, so each override is spelled out.
39let configPromise;
40async function loadConfig($) {
41 const num = (v, d) => (v !== undefined && v !== "" && Number.isFinite(Number(v)) ? Number(v) : d);
42 const [off, w, d, b, r, s, t, h, tl, xdg] = await Promise.all([
43 $.env.get("BULK_GUARD_OFF"),
44 $.env.get("BULK_GUARD_WARN_DOCS"),
45 $.env.get("BULK_GUARD_DENY_DOCS"),
46 $.env.get("BULK_GUARD_DOC_BYTES"),
47 $.env.get("BULK_GUARD_FANOUT_ROWS"),
48 $.env.get("BULK_GUARD_SIMILARITY"),
49 $.env.get("BULK_GUARD_TRIM_TOKENS"),
50 $.env.get("BULK_GUARD_HEAD_TOKENS"),
51 $.env.get("BULK_GUARD_TAIL_TOKENS"),
52 $.env.get("XDG_RUNTIME_DIR"),
53 ]);
54 return {
55 off: off !== undefined && off !== "" && off !== "0",
56 warnDocs: num(w, DEFAULTS.warnDocs),
57 denyDocs: num(d, DEFAULTS.denyDocs),
58 docMinBytes: num(b, DEFAULTS.docMinBytes),
59 fanoutRows: num(r, DEFAULTS.fanoutRows),
60 fanoutSimilarity: num(s, DEFAULTS.fanoutSimilarity),
61 trimTokens: num(t, DEFAULTS.trimTokens),
62 headTokens: num(h, DEFAULTS.headTokens),
63 tailTokens: num(tl, DEFAULTS.tailTokens),
64 dir: `${xdg || "/tmp"}/bulk-guard`,
65 };
66}
67// Read once per load: the model cannot change the engine's environment mid-session.
68const config = ($) => (configPromise ??= loadConfig($));
69
70// ── per-session state (subagents share their parent's count) ──────────────────────────────────
71const sessions = new Map();
72async function stateFor($) {
73 const id = await $.session.id();
74 if (!sessions.has(id)) sessions.set(id, { id, docs: new Set(), warned: false, trimmed: 0 });
75 return sessions.get(id);
76}
77
78// ── shell parsing ────────────────────────────────────────────────────────────────────────────
79/** Quote-aware split of a shell command into segments (on ; & | newline) of words. */
80export function shellSegments(cmd) {
81 const segs = [];
82 let words = [];
83 let cur = "";
84 let had = false;
85 let q = null;
86 const endWord = () => {
87 if (had) words.push(cur);
88 cur = "";
89 had = false;
90 };
91 const endSeg = () => {
92 endWord();
93 if (words.length) segs.push(words);
94 words = [];
95 };
96 for (let i = 0; i < cmd.length; i++) {
97 const c = cmd[i];
98 if (q) {
99 if (c === q) q = null;
100 else if (c === "\\" && q === '"' && i + 1 < cmd.length) cur += cmd[++i];
101 else cur += c;
102 continue;
103 }
104 if (c === "'" || c === '"') {
105 q = c;
106 had = true;
107 } else if (c === "\\" && i + 1 < cmd.length) {
108 cur += cmd[++i];
109 had = true;
110 } else if (c === ";" || c === "&" || c === "|" || c === "\n" || c === "(" || c === ")" || c === "`") {
111 endSeg();
112 } else if (c === " " || c === "\t") {
113 endWord();
114 } else {
115 cur += c;
116 had = true;
117 }
118 }
119 endSeg();
120 return segs;
121}
122
123/** The command word of a segment: skips env assignments and wrappers like sudo/xargs/do/then. */
124function commandWord(seg) {
125 let i = 0;
126 while (i < seg.length) {
127 const w = seg[i];
128 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w) || /^(sudo|env|nice|time|command|exec|do|then|else|xargs|nohup)$/.test(w)) {
129 i++;
130 while (i < seg.length && seg[i - 1] === "xargs" && seg[i].startsWith("-")) i++;
131 continue;
132 }
133 if (seg[i - 1] === "xargs" || (i > 0 && /^-/.test(w) && seg[i - 1] && /^xargs$/.test(seg[i - 1]))) {
134 i++;
135 continue;
136 }
137 return { word: w.replace(/^.*\//, ""), full: w, rest: seg.slice(i + 1) };
138 }
139 return null;
140}
141
142const literals = (s) => [...s.matchAll(/["']([^"'\n]{1,400})["']/g)].map((m) => m[1]);
143
144/**
145 * Paths and globs a Bash command reads with a reader verb or a script one-liner.
146 * Redirect targets (> file) are writes and do not count.
147 */
148export function readerCandidates(cmd) {
149 const segs = shellSegments(cmd);
150 const out = new Set();
151 let reads = false;
152 let scripts = false;
153 for (const seg of segs) {
154 const cw = commandWord(seg);
155 if (!cw) continue;
156 const isRead = READ_VERB.test(cw.word) || (cw.word === "sed" && cw.rest.some((w) => /^-[a-zA-Z]*n/.test(w)));
157 const isScript = SCRIPT_VERB.test(cw.word);
158 if (!isRead && !isScript) continue;
159 reads ||= isRead;
160 scripts ||= isScript;
161 for (let i = 0; i < cw.rest.length; i++) {
162 const w = cw.rest[i];
163 if (/^>>?$/.test(w) || /^\d?>/.test(w)) {
164 i++;
165 continue;
166 }
167 if (w.startsWith("-")) continue;
168 out.add(w);
169 if (isScript) for (const l of literals(w)) out.add(l);
170 }
171 }
172 if (scripts) for (const l of literals(cmd)) out.add(l); // heredoc bodies and -c code
173 if (reads || scripts) {
174 // for f in a/*.txt b.txt; do head "$f"; done — the list feeds the reader
175 for (const m of cmd.matchAll(/\bfor\s+\w+\s+in\s+([^;\n]*?)\s*(;|\n)\s*do\b/g)) {
176 for (const seg of shellSegments(m[1])) for (const w of seg) out.add(w);
177 }
178 }
179 return [...out].filter((p) => p && !p.includes("$") && !/^\d+$/.test(p) && /[\/.]/.test(p));
180}
181
182/** A path is a document candidate by its name; size is checked separately. */
183export const looksLikeDocument = (p) => DOC_EXT.test(p) || DOC_DIR.test(p);
184
185const AGENT_CALL =
186 /(^|[\s;&|(`"'])((\S*\/)?claude\s+([^\n;|&]*\s)?(-p|--print)\b|(\S*\/)?codex\s+exec\b|(\S*\/)?gemini(\s|$|["'])|(\S*\/)?agy\s+([^\n;|&]*\s)?-p\b|(\S*\/)?(farm|farm-team|work-dispatch)\.sh\b)/;
187
188/** Loop bodies of a shell command: for/while/until … do … done, xargs/parallel args, find -exec. */
189export function shellLoopBodies(cmd) {
190 const bodies = [];
191 for (const m of cmd.matchAll(/\b(for|while|until)\b[\s\S]*?\bdo\b([\s\S]*?)\bdone\b/g)) bodies.push(m[2]);
192 for (const m of cmd.matchAll(/\b(xargs|parallel)\b([^|;\n]*)/g)) bodies.push(" " + m[2]);
193 for (const m of cmd.matchAll(/\s-exec(dir)?\s([^;\n]*?)(\\;|\+)/g)) bodies.push(" " + m[2]);
194 return bodies;
195}
196
197/** Rule 2b: a loop that starts a model agent once per item. */
198export const agentLoop = (cmd) => shellLoopBodies(cmd).some((b) => AGENT_CALL.test(b));
199
200// ── rule 3: model API inside a loop ─────────────────────────────────────────────────────────
201const PROVIDER =
202 /\/v1\/messages|ANTHROPIC_BASE_URL|\banthropic\.|Anthropic\(|\bopenai\.|OpenAI\(|chat\.completions|generateContent|generate_content|\bgenai\b|google\.genai|GoogleGenAI|\/v1\/chat\/completions|api\.openai\.com|api\.anthropic\.com/;
203const CALL =
204 /\/v1\/messages|\/v1\/chat\/completions|messages\.create\(|messages\.stream\(|completions\.create\(|responses\.create\(|generateContent|generate_content\b|generate_content_stream|models\.generate|ANTHROPIC_BASE_URL.*(curl|fetch|requests|post)|(curl|fetch|requests\.post|httpx\.post).*(api\.anthropic\.com|api\.openai\.com|generativelanguage|aiplatform)/;
205const GENAI_GENERATE = /generateContent|generate_content|models\.generate/;
206const BATCH_FLOW = /batches\.create|batchPredictionJobs|batch_prediction|BatchPredictionJob|batches\.create_embeddings/;
207const RETRY_HEADER = /range\(\s*\d{1,2}\s*\)|attempt|retr(y|ies)|backoff|while\s+True\b|while\s*\(\s*true\s*\)|tries/i;
208const JSONL_WRITE = /["']url["']\s*:|["']method["']\s*:|json\.dumps|JSON\.stringify|\.write\(|writelines|jsonl/i;
209
210const indentOf = (l) => l.match(/^\s*/)[0].replace(/\t/g, " ").length;
211
212/** Python: the loop headers enclosing line i (by indentation), plus same-line comprehensions. */
213function pyEnclosing(lines, i) {
214 const loops = [];
215 let fn = null;
216 if (/\bfor\s+\S.*\bin\b/.test(lines[i])) loops.push(lines[i]);
217 let ind = indentOf(lines[i]);
218 for (let j = i - 1; j >= 0 && ind > 0; j--) {
219 const l = lines[j];
220 if (!l.trim() || l.trim().startsWith("#")) continue;
221 const k = indentOf(l);
222 if (k < ind) {
223 ind = k;
224 if (/^\s*(async\s+)?(for|while)\b/.test(l)) loops.push(l);
225 const d = l.match(/^\s*(async\s+)?def\s+(\w+)/);
226 if (d && !fn) fn = d[2];
227 }
228 }
229 return { loops, fn };
230}
231
232/** JS/TS: the lines opening the braces that enclose line i, and the enclosing function's name. */
233function jsEnclosing(lines, i) {
234 const loops = [];
235 let fn = null;
236 if (/\bfor\s*\(|\.(map|forEach|flatMap)\(/.test(lines[i])) loops.push(lines[i]);
237 let depth = 0;
238 for (let j = i - 1; j >= 0; j--) {
239 const l = lines[j];
240 for (let c = l.length - 1; c >= 0; c--) {
241 if (l[c] === "}") depth++;
242 else if (l[c] === "{") {
243 if (depth === 0) {
244 if (/\b(for|while)\s*\(|\bfor\s+await\b|\.(map|forEach|flatMap)\(/.test(l)) loops.push(l);
245 const d = l.match(/function\s*\*?\s*(\w+)|(?:const|let|var)\s+(\w+)\s*=|^\s*(?:async\s+)?(\w+)\s*\([^)]*\)\s*\{/);
246 if (d && !fn) fn = d[1] || d[2] || d[3];
247 } else depth--;
248 }
249 }
250 }
251 return { loops, fn };
252}
253
254const dataLoop = (headers) => headers.some((h) => !RETRY_HEADER.test(h));
255
256function inLoop(lines, i, lang, depth = 0) {
257 const { loops, fn } = (lang === "py" ? pyEnclosing : jsEnclosing)(lines, i);
258 if (dataLoop(loops)) return true;
259 if (!fn || depth >= 2) return false;
260 // indirection: the enclosing function is mapped over items or called inside a loop
261 const name = fn.replace(/[$]/g, "\\$");
262 const mapped = new RegExp(`\\b(map|imap|imap_unordered|starmap|submit|apply_async|run_in_executor|gather)\\([^)]*\\b${name}\\b|\\.(map|forEach|flatMap)\\(\\s*${name}\\b`);
263 const call = new RegExp(`\\b${name}\\s*\\(`);
264 for (let j = 0; j < lines.length; j++) {
265 if (j === i) continue;
266 const l = lines[j];
267 if (mapped.test(l)) return true;
268 if (call.test(l) && !/^\s*(async\s+)?(def|function)\b/.test(l) && inLoop(lines, j, lang, depth + 1)) return true;
269 }
270 return false;
271}
272
273/**
274 * Rule 3. True when `text` calls a model API inside a loop over items. `lang` is py, js or sh
275 * (a Bash command is checked as sh and as py, so heredoc'd scripts are covered).
276 */
277export function apiInLoop(text, lang) {
278 if (!PROVIDER.test(text) && !CALL.test(text)) return false;
279 const batch = BATCH_FLOW.test(text);
280 const isCall = (l) => CALL.test(l) && !/batch/i.test(l) && !JSONL_WRITE.test(l) && !(batch && GENAI_GENERATE.test(l));
281 if (lang === "sh") return shellLoopBodies(text).some((b) => b.split("\n").some(isCall));
282 const lines = text.split("\n");
283 for (let i = 0; i < lines.length; i++) {
284 if (/^\s*(#|\/\/|\*)/.test(lines[i])) continue;
285 if (isCall(lines[i]) && inLoop(lines, i, lang)) return true;
286 }
287 return false;
288}
289
290export const langOf = (path) =>
291 /\.py$/i.test(path) ? "py" : /\.(sh|bash)$/i.test(path) ? "sh" : SCRIPT_EXT.test(path) ? "js" : null;
292
293// ── rule 2: template fan-out ─────────────────────────────────────────────────────────────────
294/** Mask what varies per item in a templated prompt: paths, accession numbers, CIKs, numbers. */
295export function maskPrompt(s) {
296 return s
297 .replace(/\b\d{10}-\d{2}-\d{6}\b/g, " ACC ")
298 .replace(/(~|\.{0,2})?\/?[\w.~-]+(\/[\w.~@%+-]+)+\/?/g, " PATH ")
299 .replace(/\b[\w-]+\.(txt|htm|html|xml|sgml|pdf|nc|json|csv|md|py|typ|bib)\b/gi, " FILE ")
300 .replace(/\bCIK\s*[:#]?\s*\d+/gi, " CIK ")
301 .replace(/\d+([.,]\d+)*/g, " N ");
302}
303
304const tokens = (s) => s.toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean);
305
306function levenshtein(a, b) {
307 if (a === b) return 0;
308 let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
309 for (let i = 1; i <= a.length; i++) {
310 const cur = [i];
311 for (let j = 1; j <= b.length; j++)
312 cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
313 prev = cur;
314 }
315 return prev[b.length];
316}
317
318/** Similarity of two masked prompts: the larger of token Jaccard and normalised Levenshtein. */
319export function similarity(a, b) {
320 const ta = new Set(tokens(a));
321 const tb = new Set(tokens(b));
322 let inter = 0;
323 for (const t of ta) if (tb.has(t)) inter++;
324 const union = ta.size + tb.size - inter;
325 const jac = union ? inter / union : 1;
326 // Levenshtein is quadratic; only for short prompts, where a token set is too coarse.
327 const lev = a.length <= 1500 && b.length <= 1500 ? 1 - levenshtein(a, b) / Math.max(a.length, b.length, 1) : 0;
328 return Math.max(jac, lev);
329}
330
331/** Rows (row 0 included) that are near-identical to the row-0 template after masking. */
332export function templateRows(rows, threshold) {
333 if (!Array.isArray(rows) || rows.length < 2) return rows?.length ?? 0;
334 const text = (r) => (typeof r?.prompt === "string" ? r.prompt : JSON.stringify(r));
335 const t0 = maskPrompt(text(rows[0]));
336 let n = 1;
337 for (let i = 1; i < rows.length; i++) if (similarity(t0, maskPrompt(text(rows[i]))) >= threshold) n++;
338 return n;
339}
340
341/** The --tasks files of farm.sh / farm-team.sh / work-dispatch.sh calls in a Bash command. */
342export function fanoutTaskFiles(cmd) {
343 const files = [];
344 for (const seg of shellSegments(cmd)) {
345 if (!seg.some((w) => FANOUT_SCRIPT.test(w))) continue;
346 for (let i = 0; i < seg.length; i++) {
347 if (seg[i] === "--tasks" && seg[i + 1]) files.push(seg[i + 1]);
348 else if (seg[i].startsWith("--tasks=")) files.push(seg[i].slice(8));
349 }
350 }
351 return files;
352}
353
354export const setsOverride = (cmd) => /\bBULK_GUARD_OFF\s*=|\bexport\s+BULK_GUARD_OFF\b|\bunset\s+BULK_GUARD_OFF\b/.test(cmd);
355
356// ── result trimming ──────────────────────────────────────────────────────────────────────────
357export const estTokens = (s) => Math.ceil(s.length / 4);
358
359/** Head + marker + tail, or null when `text` is under the limit. */
360export function trimText(text, cfg, path) {
361 const n = estTokens(text);
362 if (n <= cfg.trimTokens) return null;
363 let head = text.slice(0, cfg.headTokens * 4);
364 let tail = text.slice(text.length - cfg.tailTokens * 4);
365 const hb = head.lastIndexOf("\n");
366 if (hb > head.length * 0.9) head = head.slice(0, hb + 1);
367 const tb = tail.indexOf("\n");
368 if (tb >= 0 && tb < tail.length * 0.1) tail = tail.slice(tb + 1);
369 const cut = n - estTokens(head) - estTokens(tail);
370 return { text: `${head}\n[bulk-guard: trimmed ${cut} tokens; full output at ${path}]\n${tail}`, cut };
371}
372
373// ── side effects ─────────────────────────────────────────────────────────────────────────────
374async function logEvent($, cfg, rec) {
375 const line = JSON.stringify({ ts: new Date().toISOString(), session: (await $.session.id()) ?? null, ...rec });
376 try {
377 // >> append is atomic per line across the sessions and children sharing the file
378 await $.process.run(["sh", "-c", 'mkdir -p "$1" && printf "%s\\n" "$2" >> "$1/events.jsonl"', "sh", cfg.dir, line]);
379 } catch {
380 /* the log is visibility, never a gate */
381 }
382}
383
384function redraw($) {
385 try {
386 $.ui.invalidate("ui.render");
387 } catch {
388 /* headless */
389 }
390}
391
392async function absolute($, p) {
393 if (p.startsWith("/")) return p;
394 if (p.startsWith("~/")) return `${(await $.env.get("HOME")) ?? ""}/${p.slice(2)}`;
395 return `${await $.session.cwd()}/${p.replace(/^\.\//, "")}`;
396}
397
398const globRe = (g) =>
399 new RegExp("^" + g.replace(/[.+^${}()|\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]") + "$");
400
401/** Distinct documents (absolute path ≥ docMinBytes, document-shaped name) a call would read. */
402export async function documentsRead($, cfg, candidates) {
403 const docs = new Set();
404 for (const raw of candidates) {
405 if (!looksLikeDocument(raw)) continue;
406 const p = await absolute($, raw);
407 if (/[*?]/.test(p)) {
408 const slash = p.lastIndexOf("/");
409 const dir = p.slice(0, slash) || "/";
410 if (/[*?[]/.test(dir)) continue;
411 const re = globRe(p.slice(slash + 1));
412 let entries = [];
413 try {
414 entries = await $.fs.list(dir);
415 } catch {
416 continue;
417 }
418 for (const ent of entries) {
419 if (!re.test(ent.name)) continue;
420 const full = `${dir}/${ent.name}`;
421 let size = ent.size;
422 if (ent.isLink) size = (await $.fs.stat(full, { resolve: true }).catch(() => null))?.size ?? 0;
423 else if (ent.kind !== "file") continue;
424 if (size >= cfg.docMinBytes) docs.add(full);
425 }
426 continue;
427 }
428 const st = await $.fs.stat(p, { resolve: true }).catch(() => null);
429 if (st && st.kind === "file" && st.size >= cfg.docMinBytes) docs.add(p);
430 }
431 return docs;
432}
433
434// ── the guard ────────────────────────────────────────────────────────────────────────────────
435async function deny($, cfg, rule, e, detail) {
436 await logEvent($, cfg, { kind: "deny", rule, tool: e.tool, agent: e.agentId ?? null, ...detail });
437 return { deny: rule === "override" ? OFF_MESSAGE : DENY_MESSAGE };
438}
439
440/** Rules 1-3 before the call; a deny result, or the documents this call would add. */
441async function precheck($, e, cfg, st) {
442 let candidates = [];
443 if (e.tool === "Bash") {
444 const cmd = String(e.command ?? "");
445 if (setsOverride(cmd)) return deny($, cfg, "override", e, { command: cmd.slice(0, 300) });
446 if (agentLoop(cmd)) return deny($, cfg, "agent-loop", e, { command: cmd.slice(0, 300) });
447 for (const f of fanoutTaskFiles(cmd)) {
448 let rows;
449 try {
450 rows = JSON.parse(await $.fs.read(await absolute($, f)));
451 } catch {
452 continue; // farm.sh refuses a missing or malformed file itself
453 }
454 const n = templateRows(rows, cfg.fanoutSimilarity);
455 if (n >= cfg.fanoutRows) return deny($, cfg, "template-fanout", e, { tasks: f, rows: rows.length, template_rows: n });
456 }
457 if (apiInLoop(cmd, "sh") || apiInLoop(cmd, "py") || apiInLoop(cmd, "js"))
458 return deny($, cfg, "api-in-loop", e, { command: cmd.slice(0, 300) });
459 candidates = readerCandidates(cmd);
460 } else if (e.tool === "Read") {
461 candidates = [String(e.file_path ?? "")];
462 } else if (e.tool === "Write" || e.tool === "Edit") {
463 const path = String(e.file_path ?? "");
464 const lang = langOf(path);
465 if (!lang) return { add: new Set() };
466 const old = await $.fs.read(path).catch(() => "");
467 let text = String(e.content ?? "");
468 if (e.tool === "Edit") {
469 const from = String(e.old_string ?? "");
470 const to = String(e.new_string ?? "");
471 text = from ? (e.replace_all ? old.split(from).join(to) : old.replace(from, () => to)) : to;
472 }
473 // Only a change that introduces the pattern: existing tools (pincite.py) stay editable.
474 if (apiInLoop(text, lang) && !apiInLoop(old, lang)) return deny($, cfg, "api-in-loop", e, { file: path });
475 return { add: new Set() };
476 }
477 const docs = await documentsRead($, cfg, candidates);
478 const add = new Set([...docs].filter((d) => !st.docs.has(d)));
479 if (add.size && st.docs.size + add.size >= cfg.denyDocs) {
480 return deny($, cfg, "documents", e, { read: st.docs.size, would_add: [...add].slice(0, 20) });
481 }
482 return { add };
483}
484
485async function guard($, e, next) {
486 const cfg = await config($);
487 if (cfg.off) return next(e);
488 const st = await stateFor($);
489 const pre = await precheck($, e, cfg, st);
490 if (pre.deny) return pre;
491
492 const before = st.docs.size;
493 for (const d of pre.add) st.docs.add(d);
494 const r = await next(e);
495 let out = r;
496 if (!r.deny && !r.isError && ["Read", "Bash", "Grep"].includes(e.tool)) out = await trim($, e, cfg, st, r);
497 if (st.docs.size !== before) redraw($);
498 if (!st.warned && st.docs.size >= cfg.warnDocs && before < cfg.warnDocs) {
499 st.warned = true;
500 await logEvent($, cfg, { kind: "warn", rule: "documents", tool: e.tool, read: st.docs.size });
501 const note = WARN_NOTE.replace("{n}", st.docs.size).replace("{deny}", cfg.denyDocs);
502 out = { ...out, context: [...(out.context ?? []), note] };
503 }
504 return out;
505}
506
507/** The secondary rule: Read/Bash/Grep results over trimTokens keep head + tail; the rest is saved. */
508async function trim($, e, cfg, st, r) {
509 const res = r.result;
510 let field = null;
511 if (e.tool === "Bash" && typeof res?.stdout === "string") field = ["stdout"];
512 else if (e.tool === "Read" && res?.type === "text" && typeof res.file?.content === "string") field = ["file", "content"];
513 else if (e.tool === "Grep" && typeof res?.content === "string") field = ["content"];
514 if (!field) return r;
515 const text = field.length === 1 ? res[field[0]] : res.file.content;
516 if (estTokens(text) <= cfg.trimTokens) return r;
517 const path = `${cfg.dir}/${(await $.session.id()).slice(0, 8)}-${Date.now()}-${e.tool.toLowerCase()}.txt`;
518 try {
519 await $.fs.write(path, text);
520 } catch {
521 await $.process.run(["sh", "-c", 'mkdir -p "$(dirname "$1")" && cat > "$1"', "sh", path], { stdin: text });
522 }
523 const t = trimText(text, cfg, path);
524 const result = field.length === 1 ? { ...res, [field[0]]: t.text } : { ...res, file: { ...res.file, content: t.text } };
525 st.trimmed++;
526 redraw($);
527 await logEvent($, cfg, { kind: "trim", tool: e.tool, tokens: estTokens(text), cut: t.cut, path });
528 return r.context ? { result, context: r.context } : { result };
529}
530
531export function register(on) {
532 on("tool.call", { tool: ["Read", "Bash", "Grep", "Write", "Edit"] }, guard);
533
534 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
535 const cfg = await config($);
536 const st = sessions.get(await $.session.id());
537 if (cfg.off || e.props?.hasSurvey || !st || (st.docs.size === 0 && st.trimmed === 0)) return next(e);
538 const { Box, Text } = $.ui.resolve(e);
539 const hot = st.docs.size >= cfg.warnDocs;
540 return Box({
541 paddingX: 1,
542 children: [
543 Text({ color: hot ? "yellow" : undefined, dimColor: !hot, children: `docs read ${st.docs.size}/${cfg.denyDocs}` }),
544 Text({ dimColor: true, children: ` · trimmed ${st.trimmed} results` }),
545 ],
546 });
547 });
548}
549
550/** Test seam: forget per-session state and the cached config. */
551export function _reset() {
552 sessions.clear();
553 configPromise = undefined;
554}
555hooks/guards/mod.ts 210 lines1// The plugin's tool-call guards as one mod: each guard that used to be a hooks.json settings hook
2// runs here in-process, on `tool.call`, over the same payload the settings hook read on stdin.
3//
4// NEVER AN APPROVAL. A guard here can only deny or add context: the handler returns `{ deny }` or
5// what `next(e)` resolved to, and it hooks no `tool.check`, the one event that can allow past an ask
6// rule. A crashed PreToolUse gate denies (as `denyOnCrash` made the script deny); a crashed
7// PostToolUse guard adds nothing (as the script's exit 1 was non-blocking).
8import type { EngineInterface, On, ToolCallResult } from 'claude-code'
9import { absolute, crashDeny, type EnvName, type FileStat, type Guard, type GuardIO, type Payload } from './core.ts'
10import { atomicConstraintGuard } from './atomic-constraint.ts'
11import { bunParallelGuard } from './bun-test.ts'
12import { cronDeleteGuard, cronRecord } from './cron-delete.ts'
13import { imageReadGuard } from './image-read.ts'
14import { pgrepSelfMatch } from './pgrep.ts'
15import { readGuard } from './read-guard.ts'
16import { validateSkillPaths } from './skill-paths.ts'
17import { suggestCompact } from './suggest-compact.ts'
18import { typstConventionGuard } from './typst-convention.ts'
19
20type $ = EngineInterface
21
22export interface GuardSpec {
23 /** The settings-hook script this guard was, under hooks/ (its gate name for a crash deny). */
24 script: string
25 gate: string
26 event: 'PreToolUse' | 'PostToolUse'
27 /** The hooks.json matcher it had, as tool names. */
28 tools: string[]
29 guard: Guard
30}
31
32/** In hooks.json order: a call several guards match gets the first deny, contexts in this order. */
33export const GUARDS: GuardSpec[] = [
34 { script: 'image-read-guard.ts', gate: 'IMAGE READ GUARD', event: 'PreToolUse', tools: ['Read'], guard: imageReadGuard },
35 { script: 'read-guard.ts', gate: 'READ GUARD', event: 'PreToolUse', tools: ['Read', 'Bash'], guard: readGuard },
36 { script: 'suggest-compact.ts', gate: 'SUGGEST COMPACT', event: 'PreToolUse', tools: ['Edit', 'Write'], guard: suggestCompact },
37 { script: 'pgrep-self-match.ts', gate: 'PGREP GUARD', event: 'PreToolUse', tools: ['Bash', 'Monitor'], guard: pgrepSelfMatch },
38 { script: 'bun-parallel-guard.ts', gate: 'BUN PARALLEL GUARD', event: 'PreToolUse', tools: ['Bash'], guard: bunParallelGuard },
39 { script: 'cron-delete-guard.ts', gate: 'CRON DELETE GUARD', event: 'PreToolUse', tools: ['CronDelete'], guard: cronDeleteGuard },
40 { script: 'atomic-constraint-guard.ts', gate: 'ATOMIC CONSTRAINT GUARD', event: 'PostToolUse', tools: ['Edit', 'Write'], guard: atomicConstraintGuard },
41 { script: 'typst-convention-guard.ts', gate: 'TYPST CONVENTION GUARD', event: 'PostToolUse', tools: ['Edit', 'Write'], guard: typstConventionGuard },
42 { script: 'validate-skill-paths.ts', gate: 'VALIDATE SKILL PATHS', event: 'PostToolUse', tools: ['Edit', 'Write'], guard: validateSkillPaths },
43 { script: 'cron-delete-guard.ts --record', gate: 'CRON RECORD', event: 'PostToolUse', tools: ['CronCreate'], guard: cronRecord },
44]
45
46/** Every tool some guard matches. registerGuards spells it as a literal regex, which is what
47 * `claude plugin validate` can read; tests/mod-guards-parity.test.ts holds the two equal. */
48export const TOOLS = [...new Set(GUARDS.flatMap(g => g.tools))]
49
50/** The tool.call input's reserved keys; everything else is the tool's own arguments. */
51const RESERVED = new Set(['tool', 'tool_use_id', 'consent'])
52
53/** Each variable read by its literal name: `claude plugin validate` lists them, and requires it. */
54async function readEnv($: $): Promise<Record<EnvName, string | undefined>> {
55 const [a, b, c, d, e, f, g, h, i] = await Promise.all([
56 $.env.get('LOOK_AT_NESTED'),
57 $.env.get('READ_GUARD_BYTES'),
58 $.env.get('COMPACT_THRESHOLD'),
59 $.env.get('COMPACT_INTERVAL'),
60 $.env.get('CLAUDE_CODE_SESSION_ID'),
61 $.env.get('TMPDIR'),
62 $.env.get('TEMP'),
63 $.env.get('TMP'),
64 $.env.get('WORK_ALLOW_CRON_DELETE'),
65 ])
66 return {
67 LOOK_AT_NESTED: a, READ_GUARD_BYTES: b, COMPACT_THRESHOLD: c, COMPACT_INTERVAL: d,
68 CLAUDE_CODE_SESSION_ID: e, TMPDIR: f, TEMP: g, TMP: h, WORK_ALLOW_CRON_DELETE: i,
69 }
70}
71
72/** GuardIO over `$`. Relative paths resolve against the session's cwd, as the scripts' did. */
73export function modIO($: $, env: Record<EnvName, string | undefined>, cwd: string, sessionId: string): GuardIO {
74 const abs = (p: string): string | null => (typeof p === 'string' && p !== '' ? absolute(cwd, p) : null)
75 const read = async (p: string): Promise<string | null> => {
76 const a = abs(p)
77 if (a === null) return null
78 try {
79 return await $.fs.read(a)
80 } catch {
81 return null
82 }
83 }
84 return {
85 env: name => env[name],
86 cwd,
87 pluginRoot: $.plugin.root,
88 // node's os.tmpdir() on POSIX: TMPDIR, TMP, TEMP, then /tmp, a trailing slash dropped.
89 osTmpdir: ((env.TMPDIR || env.TMP || env.TEMP || '/tmp').replace(/(.)\/+$/, '$1')),
90 fallbackKey: sessionId,
91 nowMs: () => Date.now(),
92 stat: async (p): Promise<FileStat | null> => {
93 const a = abs(p)
94 if (a === null) return null
95 try {
96 const s = await $.fs.stat(a)
97 return { kind: s.kind, size: s.size, mtimeMs: s.mtimeMs }
98 } catch {
99 return null
100 }
101 },
102 read,
103 list: async p => {
104 const a = abs(p)
105 if (a === null) return null
106 try {
107 return (await $.fs.list(a)).map(e => e.name)
108 } catch {
109 return null
110 }
111 },
112 write: async (p, text) => {
113 const a = abs(p)
114 if (a === null) throw new Error(`cannot write ${String(p)}`)
115 await $.fs.write(a, text)
116 },
117 // $.fs has no append: read, then write the whole file. Only the record half appends, to a file
118 // of one id per line that this session alone writes.
119 append: async (p, text) => {
120 const a = abs(p)
121 if (a === null) throw new Error(`cannot append ${String(p)}`)
122 await $.fs.write(a, ((await read(a)) ?? '') + text)
123 },
124 }
125}
126
127/** The settings-hook payload for a tool.call event, as Claude Code would have sent it on stdin. */
128export function payloadFor(
129 e: Record<string, unknown>,
130 event: 'PreToolUse' | 'PostToolUse',
131 sessionId: string,
132 cwd: string,
133 toolResponse?: unknown,
134): Payload {
135 const toolInput: Record<string, unknown> = {}
136 for (const [k, v] of Object.entries(e)) if (!RESERVED.has(k)) toolInput[k] = v
137 const p: Payload = {
138 session_id: sessionId,
139 cwd,
140 hook_event_name: event,
141 tool_name: e.tool,
142 tool_input: toolInput,
143 }
144 if (e.tool_use_id !== undefined) p.tool_use_id = e.tool_use_id
145 if (event === 'PostToolUse') p.tool_response = toolResponse
146 return p
147}
148
149/** Run every guard of `event` matching `tool`; the first deny ends a PreToolUse round. */
150export async function runGuards(
151 specs: readonly GuardSpec[],
152 event: 'PreToolUse' | 'PostToolUse',
153 tool: string,
154 payload: Payload,
155 io: GuardIO,
156): Promise<{ deny?: string; context: string[] }> {
157 const context: string[] = []
158 for (const spec of specs) {
159 if (spec.event !== event || !spec.tools.includes(tool)) continue
160 let out
161 try {
162 out = await spec.guard(payload, io)
163 } catch (error) {
164 if (event === 'PreToolUse') return { deny: crashDeny(spec.gate, 'throw', error), context }
165 continue
166 }
167 if (event === 'PreToolUse' && out.deny !== undefined) return { deny: out.deny, context }
168 if (out.context !== undefined) context.push(out.context)
169 }
170 return { context }
171}
172
173/** `specs` narrows the set for the parity test; the plugin registers all of GUARDS. */
174export function registerGuards(on: On, specs: readonly GuardSpec[] = GUARDS): void {
175 // What next(e) settled to, per call, for the catch handler: once the tool has run, a failure
176 // after it must hand back that result, never run the call a second time.
177 const settled = new Map<string, ToolCallResult>()
178
179 on('tool.call', { tool: /^(Read|Bash|Edit|Write|Monitor|CronDelete|CronCreate)$/ }, async ($, e, next) => {
180 const [sessionId, cwd, env] = await Promise.all([$.session.id(), $.session.cwd(), readEnv($)])
181 const io = modIO($, env, cwd, sessionId)
182 const input = e as unknown as Record<string, unknown>
183
184 const pre = await runGuards(specs, 'PreToolUse', e.tool, payloadFor(input, 'PreToolUse', sessionId, cwd), io)
185 if (pre.deny !== undefined) return { deny: pre.deny }
186
187 const result = await next(e)
188 const key = String(e.tool_use_id ?? '')
189 settled.set(key, result)
190 try {
191 // PostToolUse fires only on a call that ran and succeeded; a refused call gets neither the
192 // post guards nor the pre context, which has nowhere to go.
193 if (result.deny !== undefined) return result
194 const post = result.isError
195 ? { context: [] as string[] }
196 : await runGuards(specs, 'PostToolUse', e.tool, payloadFor(input, 'PostToolUse', sessionId, cwd, result.result), io)
197 const added = [...pre.context, ...post.context]
198 return added.length ? { ...result, context: [...(result.context ?? []), ...added] } : result
199 } finally {
200 settled.delete(key)
201 }
202 }).catch(async ($, e, next) => {
203 // Before next(e): the call has not run, so a gate that could not decide denies. After: the
204 // result next(e) settled to stands.
205 const ran = settled.get(String(e.tool_use_id ?? ''))
206 if (next.called && ran) return ran
207 return { deny: crashDeny('PLUGIN GUARDS', next.error.kind, next.error.message) }
208 })
209}
210hooks/jev/forecast-mod.ts 45 lines1// The Jev forecast band, above the prompt: `Jev $19.77 · $3.40/day · ~6 days` (hooks/jev/forecast.ts).
2//
3// tick hooks/watch/watcher.ts forecastTick, every 10 min from the watcher's session.start (the
4// engine takes ONE session.start per module; interactive sessions only, never a farm child):
5// `openrouter-credits.ts --sample`, which reads the API only when the credit-warn cache is
6// an hour old (never more than credit-warn's one call an hour, shared by every session),
7// then one read of that cache, handed here through setForecastCache
8// render memory only, never the network or a file: yellow when under 3 days or under
9// $OPENROUTER_LOW_BALANCE, red OUT OF CREDITS at <= $0; nothing on too few or stale samples
10//
11// Registered outermost of the AbovePrompt hooks so the band stacks on bulk-guard's instead of hiding it.
12import type { On, RenderElement } from 'claude-code'
13import { forecast, type CreditsCache } from './forecast.ts'
14
15// Set by the watcher's forecast tick (hooks/watch/watcher.ts); the engine follows `$` into no
16// function across an import, so the tick, which needs `$`, lives there and hands the parsed cache here.
17let cache: CreditsCache | null = null
18
19export function setForecastCache(c: unknown): void {
20 cache = typeof (c as CreditsCache | null)?.checkedAt === 'number' ? (c as CreditsCache) : null
21}
22
23export function registerJevForecast(on: On) {
24 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
25 const inner = await next(e)
26 if (e.props.hasSurvey) return inner
27 // credit-warn's threshold, read as scripts/lib/openrouter-credits.ts reads it
28 const raw = await $.env.get('OPENROUTER_LOW_BALANCE')
29 const t = raw ? Number(raw) : NaN
30 const v = forecast(cache, await $.clock.now(), Number.isFinite(t) ? t : 3)
31 if (!v) return inner
32 const { Box, Text } = $.ui.resolve(e)
33 const line = Box({
34 paddingX: 1,
35 children: [
36 v.level === 'ok'
37 ? Text({ dimColor: true, children: v.text })
38 : Text({ color: v.level === 'out' ? 'red' : 'yellow', bold: v.level === 'out', children: v.text }),
39 ],
40 }) as RenderElement
41 if ((inner as { type?: string } | undefined)?.type === 'engine') return line
42 return Box({ flexDirection: 'column', children: [line, inner] }) as RenderElement
43 })
44}
45hooks/jev/mod.ts 230 lines1// The per-edit Jev mod: after an Edit/Write/MultiEdit lands, score the file against its rule set's
2// WIRED Jev rules and add one line of context per rule at p(VIOLATED) >= 0.85, or >= 0.95 when the
3// luna fallback answered (rules.ts decisionThreshold). Advisory only.
4//
5// rule set hooks/jev/rules.ts ruleSetFor: exam question .typ -> exams, skill/agent/command
6// files, manifests and .planning/ -> authoring, lecture notes -> notes and a lecture
7// deck -> slides (all three from the
8// teaching plugin, $TEACHING_PLUGIN_ROOT or ~/.claude/skills/teaching; writing when it
9// is absent), a talk's slides/notes .typ (or any .typ under a `workflow: workshop`
10// cursor) -> typst, addenda/*.typ -> elide, other prose -> writing, tests and shell -> dev, .py under a
11// `workflow: ds` ACTIVE_WORKFLOW.md -> ds; anything else is left alone. Writing prose
12// also gets its register set (legal, econ) when that cursor says `style: legal|econ`
13// scoring ONE $.process.run of skills/work/scripts/rule-check.ts --batch: the set's own
14// evidence.py (uncalibrated/ is below its glob), then ONE Decisions call for all its
15// rules through work-hold.ts decisionsCall, the plugin's one Jev transport
16// debounce at most one evaluation per file per 10 s. An edit inside the window is deferred to
17// its end, and the deferred lines ride the NEXT tool result of any tool; a newer edit
18// to the file supersedes a pending, in-flight or undelivered one
19// where interactive sessions; headless only with JEV_EDIT_MOD=1 (work-round.sh sets it for
20// the implementers); JEV_EDIT_MOD=0 turns it off everywhere
21//
22// NEVER A DECISION. It calls next(e) with the input unchanged and returns what that settled to,
23// with context added or not; it never denies. Any failure adds nothing and goes to the debug log.
24import type { EngineInterface, On, ToolCallResult } from 'claude-code'
25import {
26 RULE_DIRS, TEACHING_PROBE, TEACHING_SETS, ancestors, changedFromInput, contextLines, enabled, merge, rangesFromDiff, registerDir, ruleSetFor,
27 styleOf, workflowOf, dirname, type Range, type RuleSet, type Verdict,
28} from './rules.ts'
29
30type $ = EngineInterface
31
32export const WINDOW_MS = 10_000
33const RUN_TIMEOUT_MS = 20_000
34const JEV_MAX_SECONDS = '15'
35
36interface FileState {
37 lastStart: number
38 gen: number
39 pending: Range[]
40 timer?: { cancel: () => void }
41}
42
43// Debounce state per file, and deferred lines per file awaiting the next tool result. Module state,
44// lost on reload by design: a reload forgets the windows and the undelivered lines.
45const files = new Map<string, FileState>()
46const deferred = new Map<string, string[]>()
47
48const log = ($: $, text: string) => $.ui.log(`jev-edit: ${text}`, { to: 'debug' })
49
50function absolute(cwd: string, p: string): string {
51 return p.startsWith('/') ? p : `${cwd.replace(/\/+$/, '')}/${p}`
52}
53
54function shown(cwd: string, file: string): string {
55 const root = cwd.replace(/\/+$/, '') + '/'
56 return file.startsWith(root) ? file.slice(root.length) : file
57}
58
59/** The nearest `.planning/ACTIVE_WORKFLOW.md` text at or above `dir`, up to $HOME. */
60async function cursorAt($: $, dir: string): Promise<string | null> {
61 const home = (await $.env.get('HOME')) || '/'
62 for (const d of ancestors(dir, home)) {
63 try {
64 return await $.fs.read(`${d === '/' ? '' : d}/.planning/ACTIVE_WORKFLOW.md`)
65 } catch {
66 continue
67 }
68 }
69 return null
70}
71
72async function workflowAt($: $, dir: string): Promise<string | null> {
73 const text = await cursorAt($, dir)
74 return text === null ? null : workflowOf(text)
75}
76
77/** The teaching plugin's root when the set's rules are there to run, else null. */
78async function teachingRoot($: $, set: RuleSet): Promise<string | null> {
79 const root = (await $.env.get('TEACHING_PLUGIN_ROOT')) || `${(await $.env.get('HOME')) || ''}/.claude/skills/teaching`
80 try {
81 await $.fs.read(`${root}/${TEACHING_PROBE[set]}`)
82 return root
83 } catch {
84 return null
85 }
86}
87
88/** The set and the rules directory an edit is scored against, plus a register set on top of writing. */
89interface Target {
90 set: RuleSet
91 dir: string
92 register?: string
93}
94
95async function targetFor($: $, set: RuleSet, file: string): Promise<Target> {
96 if (set === 'writing') {
97 const reg = registerDir(set, styleOf((await cursorAt($, dirname(file))) ?? ''))
98 return { set, dir: `${$.plugin.root}/${RULE_DIRS[set]}`, ...(reg ? { register: `${$.plugin.root}/${reg}` } : {}) }
99 }
100 if (!TEACHING_SETS.has(set)) return { set, dir: `${$.plugin.root}/${RULE_DIRS[set]}` }
101 const root = await teachingRoot($, set)
102 if (root) return { set, dir: `${root}/${RULE_DIRS[set]}` }
103 log($, `no teaching plugin with constraints/jev: teaching ${set} scored as writing`)
104 return { set: 'writing', dir: `${$.plugin.root}/${RULE_DIRS.writing}` }
105}
106
107async function changedLines($: $, tool: string, input: Record<string, unknown>, file: string): Promise<Range[]> {
108 let text = ''
109 try {
110 text = await $.fs.read(file)
111 } catch {}
112 const fromInput = changedFromInput(tool, input, text)
113 if (fromInput) return fromInput
114 const d = await $.process.run(['git', '-C', dirname(file), 'diff', '--no-color', '-U0', 'HEAD', '--', file], { timeoutMs: 5000 })
115 const fromGit = d.exitCode === 0 ? rangesFromDiff(d.stdout) : []
116 return fromGit.length ? fromGit : [[1, Math.max(1, text.split('\n').length)]]
117}
118
119/** The context lines for one evaluation; [] when nothing reaches the bar or anything failed. */
120async function evaluate($: $, { set, dir, register }: Target, file: string, ranges: Range[], cwd: string): Promise<string[]> {
121 // the spend log's caller tag (scripts/jev-spend.ts); `env` because the tag is all this run changes
122 const argv = [
123 'env', 'JEV_CALLER=jev-edit',
124 'bun', `${$.plugin.root}/skills/work/scripts/rule-check.ts`, '--batch',
125 '--rules', dir, ...(register ? ['--rules', register] : []), '--files', file,
126 '--changed-lines', '-', '--max-time', JEV_MAX_SECONDS,
127 ]
128 const started = await $.clock.now()
129 let run: Awaited<ReturnType<$['process']['run']>>
130 try {
131 run = await $.process.run(argv, { stdin: JSON.stringify({ [file]: ranges }), timeoutMs: RUN_TIMEOUT_MS, cwd: dirname(file) })
132 } catch (err) {
133 log($, `${file}: rule-check did not finish (${String(err)})`)
134 return []
135 }
136 let out: { verdicts?: Verdict[]; unavailable?: { rule: string; reason: string }[] }
137 try {
138 out = JSON.parse(run.stdout)
139 } catch {
140 log($, `${file}: rule-check exit ${run.exitCode}, no JSON: ${run.stderr.slice(0, 300)}`)
141 return []
142 }
143 const ms = (await $.clock.now()) - started
144 if (out.unavailable?.length) log($, `${file}: unavailable ${out.unavailable.map(u => `${u.rule} (${u.reason})`).join('; ')}`)
145 log($, `${file}: ${set}${register ? `+${register.split('/').pop()}` : ''} ${(out.verdicts ?? []).map(v => `${v.rule}=${v.p}${v.provider === 'openai' ? '(luna)' : ''}`).join(' ')} in ${ms} ms`)
146 return contextLines(out.verdicts ?? [], shown(cwd, file), ranges)
147}
148
149/** Deferred to the window's end: its lines wait in `deferred` for the next tool result. */
150function defer($: $, file: string, st: FileState, target: Target, cwd: string, now: number) {
151 st.timer?.cancel()
152 st.timer = $.clock.after(Math.max(0, st.lastStart + WINDOW_MS - now), () => {
153 st.timer = undefined
154 void (async () => {
155 const gen = st.gen
156 const ranges = st.pending
157 st.pending = []
158 st.lastStart = await $.clock.now()
159 const lines = await evaluate($, target, file, ranges, cwd)
160 if (st.gen !== gen) {
161 st.pending = merge(st.pending, ranges)
162 return
163 }
164 if (lines.length) deferred.set(file, lines)
165 })().catch(err => log($, `${file}: deferred evaluation failed (${String(err)})`))
166 })
167}
168
169async function afterEdit($: $, e: Record<string, unknown>, result: ToolCallResult): Promise<ToolCallResult> {
170 // Interactive = some surface draws (the terminal, the desktop app, a phone); `claude -p` has none.
171 const isInteractive = (await $.session.surfaces()).length > 0
172 if (!enabled(await $.env.get('JEV_EDIT_MOD'), isInteractive)) return result
173 const raw = e.file_path
174 if (typeof raw !== 'string' || raw === '') return result
175 const cwd = await $.session.cwd()
176 const file = absolute(cwd, raw)
177 const set = await ruleSetFor(file, d => workflowAt($, d))
178 if (!set) return result
179 const target = await targetFor($, set, file)
180
181 const tool = String(e.tool)
182 const ranges = await changedLines($, tool, e, file)
183 const now = await $.clock.now()
184 const st = files.get(file) ?? { lastStart: -Infinity, gen: 0, pending: [] }
185 files.set(file, st)
186 const gen = ++st.gen
187 st.pending = merge(st.pending, ranges)
188 st.timer?.cancel()
189 st.timer = undefined
190 deferred.delete(file)
191
192 if (now - st.lastStart < WINDOW_MS) {
193 defer($, file, st, target, cwd, now)
194 return result
195 }
196 st.lastStart = now
197 const want = st.pending
198 st.pending = []
199 const lines = await evaluate($, target, file, want, cwd)
200 if (st.gen !== gen) {
201 // A newer edit to this file arrived while this one was scored: its evaluation covers these lines.
202 st.pending = merge(st.pending, want)
203 return result
204 }
205 return lines.length ? { ...result, context: [...(result.context ?? []), lines.join('\n')] } as ToolCallResult : result
206}
207
208export function registerJevEdit(on: On): void {
209 // Every tool: an edit is scored, and any call carries the deferred lines that are ready.
210 on('tool.call', async ($, e, next) => {
211 const result = await next(e)
212 if (result.deny !== undefined) return result
213 let out: ToolCallResult = result
214 try {
215 if (/^(Edit|Write|MultiEdit)$/.test(String(e.tool)) && !result.isError)
216 out = await afterEdit($, e as unknown as Record<string, unknown>, result)
217 } catch (err) {
218 log($, `${String((e as { file_path?: unknown }).file_path)}: ${String(err)}`)
219 }
220 if (!deferred.size || out.deny !== undefined) return out
221 const ready = [...deferred.values()].flat()
222 deferred.clear()
223 return { ...out, context: [...(out.context ?? []), ready.join('\n')] } as ToolCallResult
224 }).catch(($, e, next) => {
225 // Replay-safe: once called, next(e) resolves to what the tool already settled to.
226 log($, `hook failed (${next.error.kind}: ${next.error.message})`)
227 return next(e)
228 })
229}
230hooks/watch/watcher.ts 220 lines1// The watcher mod, registered from hooks/watch/watcher.ts: the farm-out runs and work rounds THIS session launched, read from files only.
2//
3// status line one compact line while anything runs; cleared when nothing does
4// wake $.prompt.submit ONCE per run that reaches DONE or GONE, remembered in $.store
5// /farm a table of this session's runs, printed at once, no turn
6// beacon <TMPDIR>/farm-events/<session>/watcher.alive, rewritten by every tick that completes:
7// the one thing outside this module that can tell a session whose mods never loaded
8//
9// It replaces the farm-runs plugin monitor, which never re-armed once it stopped. A timer started
10// in session.start comes back with every reload. A headless session (`claude -p`, the SDK, a farm
11// child with FARM_OUT_CHILD=1) registers nothing: it has nobody to draw for, and a farm child
12// waking itself about its own runs would loop.
13import type { EngineInterface, On } from 'claude-code'
14import {
15 BEACON, classify, parseEvents, statusLine, table, TICK_MS, pastHorizon, wakeable, wakeText, waitText, workPhase, workRound,
16 type Facts, type Run, type View,
17} from './runs.ts'
18import { setForecastCache } from '../jev/forecast-mod.ts'
19
20// The Jev forecast band's sampling tick (hooks/jev/forecast-mod.ts): ten minutes, and the script's
21// own hourly gate decides whether that reaches the network.
22const FORECAST_TICK_MS = 10 * 60_000
23
24// Notified keys older than this are pruned from the shared store.
25const KEEP_NOTIFIED_MS = 7 * 24 * 3600_000
26
27// Module state: lost on reload by design. The store holds what must survive one.
28const sessions = new Set<string>()
29const firstSeen = new Map<string, number>()
30const cache = new Map<string, { mtimeMs: number; size: number; runs: Run[] }>()
31const woke = new Set<string>()
32let lastStatus: string | undefined
33let ticking = false
34
35async function tmpRoot($: EngineInterface): Promise<string> {
36 return ((await $.env.get('TMPDIR')) || '/tmp').replace(/\/+$/, '')
37}
38
39async function eventDirs($: EngineInterface): Promise<string[]> {
40 const sid = await $.session.id()
41 if (sid) sessions.add(sid)
42 const roots = [...new Set([await tmpRoot($), '/tmp'])]
43 const dirs: string[] = []
44 for (const root of roots) for (const s of sessions) dirs.push(`${root.replace(/\/+$/, '')}/farm-events/${s}`)
45 return dirs
46}
47
48async function readRuns($: EngineInterface, nowMs: number): Promise<Run[]> {
49 const runs: Run[] = []
50 for (const dir of await eventDirs($)) {
51 let entries: Awaited<ReturnType<EngineInterface['fs']['list']>>
52 try { entries = await $.fs.list(dir) } catch { continue }
53 const sid = dir.split('/').pop() ?? ''
54 for (const ent of entries) {
55 const m = /^(\d+)\.ndjson$/.exec(ent.name)
56 if (!m || ent.kind !== 'file') continue
57 const file = `${dir}/${ent.name}`
58 if (!firstSeen.has(file)) firstSeen.set(file, Math.min(nowMs, ent.mtimeMs || nowMs))
59 const hit = cache.get(file)
60 if (hit && hit.mtimeMs === ent.mtimeMs && hit.size === ent.size) { runs.push(...hit.runs); continue }
61 let text = ''
62 try { text = await $.fs.read(file) } catch { continue }
63 const parsed = parseEvents(text, file, Number(m[1]), sid)
64 for (const r of parsed) r.fileMtimeMs = ent.mtimeMs
65 cache.set(file, { mtimeMs: ent.mtimeMs, size: ent.size, runs: parsed })
66 runs.push(...parsed)
67 }
68 }
69 return runs
70}
71
72async function nonEmpty($: EngineInterface, p: string): Promise<boolean> {
73 try { return (await $.fs.stat(p)).size > 0 } catch { return false }
74}
75
76async function readText($: EngineInterface, p: string): Promise<string | undefined> {
77 try { return await $.fs.read(p) } catch { return undefined }
78}
79
80/** Everything classify() needs, gathered with one ps call and a stat per artifact. */
81async function gather($: EngineInterface, runs: Run[]): Promise<Facts> {
82 const facts: Facts = {
83 alive: new Set(), present: new Set(), firstSeen, loopExit: new Map(), loopExitAt: new Map(), round: new Map(), phase: new Map(),
84 }
85 const open = [...new Set(runs.filter(r => !r.done).map(r => r.pid))]
86 if (open.length) {
87 try {
88 const ps = await $.process.run(['ps', '-o', 'pid=', '-p', open.join(',')], { timeoutMs: 5000 })
89 for (const tok of ps.stdout.split(/\s+/)) if (/^\d+$/.test(tok)) facts.alive.add(Number(tok))
90 } catch {
91 // ps unavailable: claim every open run alive rather than wake the session with false GONEs.
92 for (const p of open) facts.alive.add(p)
93 }
94 }
95 const paths = new Set<string>()
96 const workDirs = new Set<string>()
97 for (const r of runs) {
98 for (const p of [r.out, ...r.claims]) if (p && p.startsWith('/')) paths.add(p)
99 if ((r.label === 'work-round' || r.label === 'work-loop') && r.out) workDirs.add(r.out.replace(/\/[^/]*$/, ''))
100 }
101 for (const p of paths) if (await nonEmpty($, p)) facts.present.add(p)
102 for (const d of workDirs) {
103 const exit = await readText($, `${d}/loop.exit`)
104 if (exit !== undefined && exit.trim() !== '') {
105 facts.loopExit.set(`${d}/loop.exit`, exit.trim())
106 try { facts.loopExitAt!.set(`${d}/loop.exit`, (await $.fs.stat(`${d}/loop.exit`)).mtimeMs) } catch {}
107 }
108 const loopLog = await readText($, `${d}/loop.log`)
109 const round = loopLog && workRound(loopLog)
110 if (round) facts.round.set(d, round)
111 // Round N>1 logs to run-<HHMMSS>.log; the newest run*.log is the live round's.
112 let log = `${d}/run.log`
113 try {
114 const logs = (await $.fs.list(d)).filter((e) => /^run.*\.log$/.test(e.name))
115 logs.sort((a, b) => b.mtimeMs - a.mtimeMs)
116 if (logs[0]) log = `${d}/${logs[0].name}`
117 } catch {}
118 const runLog = await readText($, log)
119 const phase = runLog && workPhase(runLog)
120 if (phase) facts.phase.set(d, phase)
121 }
122 return facts
123}
124
125async function snapshot($: EngineInterface): Promise<{ views: View[]; nowMs: number }> {
126 const nowMs = await $.clock.now()
127 const runs = await readRuns($, nowMs)
128 return { views: classify(runs, await gather($, runs)), nowMs }
129}
130
131async function wake($: EngineInterface, views: View[], nowMs: number) {
132 for (const v of wakeable(views)) {
133 // A live run's newest WAIT alert wakes once per waits value: the run stays running.
134 if (v.state === 'running' && v.wait) {
135 const wid = `${v.id}:wait:${v.wait.waits}`
136 if (!woke.has(wid)) {
137 woke.add(wid)
138 const wkey = `notified:${wid}`
139 if (!(await $.store.get(wkey)) && !pastHorizon(v, nowMs)) {
140 await $.store.set(wkey, nowMs)
141 void $.prompt.submit({ text: waitText(v) })
142 }
143 }
144 continue
145 }
146 if (v.state === 'running' || woke.has(v.id)) continue
147 woke.add(v.id)
148 const key = `notified:${v.id}`
149 if (await $.store.get(key)) continue
150 if (pastHorizon(v, nowMs)) continue
151 // Recorded BEFORE the submit: a reload between the two must not wake twice.
152 await $.store.set(key, nowMs)
153 // Never awaited: submit resolves when the turn starts, and this tick may run mid-turn.
154 void $.prompt.submit({ text: wakeText(v, nowMs) })
155 }
156}
157
158async function prune($: EngineInterface, nowMs: number) {
159 for (const k of await $.store.keys()) {
160 if (!k.startsWith('notified:')) continue
161 const at = Number(await $.store.get(k))
162 if (!(nowMs - at < KEEP_NOTIFIED_MS)) await $.store.delete(k)
163 }
164}
165
166async function tick($: EngineInterface) {
167 if (ticking) return
168 ticking = true
169 try {
170 const { views, nowMs } = await snapshot($)
171 const line = statusLine(views, nowMs)
172 if (line !== lastStatus) {
173 lastStatus = line
174 $.ui.status(line)
175 }
176 await wake($, views, nowMs)
177 // Last, so a tick that throws leaves the beacon to go stale. write() creates the directory, so
178 // the beacon is there before this session's first run files itself.
179 const sid = await $.session.id()
180 if (sid) await $.fs.write(`${await tmpRoot($)}/farm-events/${sid}/${BEACON}`, String(Math.floor(nowMs / 1000)))
181 } finally {
182 ticking = false
183 }
184}
185
186/** One forecast sample, under credit-warn's hourly gate, then the cache handed to the band. */
187async function forecastTick($: EngineInterface): Promise<void> {
188 try {
189 await $.process.run(['bun', `${$.plugin.root}/scripts/lib/openrouter-credits.ts`, '--sample'], { timeoutMs: 20_000 })
190 } catch { /* the read below still shows the last sample */ }
191 let c: unknown = null
192 try { c = JSON.parse(await $.fs.read(`${await tmpRoot($)}/openrouter-credits.json`)) } catch { /* no cache yet */ }
193 setForecastCache(c)
194 try { $.ui.invalidate('ui.render') } catch { /* headless */ }
195}
196
197export function registerWatcher(on: On) {
198 on('session.start', async ($, e, next) => {
199 if (!e.isInteractive || (await $.env.get('FARM_OUT_CHILD')) === '1') return next(e)
200 // The promise is returned so a test clock can settle on it; the engine does not wait on it.
201 $.clock.every(TICK_MS, () => tick($))
202 void tick($)
203 void prune($, await $.clock.now())
204 // The module's one session.start, so the Jev forecast band's tick starts here too.
205 $.clock.every(FORECAST_TICK_MS, () => forecastTick($))
206 void forecastTick($)
207 await $.command.register({
208 name: 'farm',
209 description: "This session's farm-out runs and work rounds: state, elapsed, artifact, report",
210 immediate: true,
211 })
212 return next(e)
213 })
214
215 on('command.run', { command: 'farm' }, async ($) => {
216 const { views, nowMs } = await snapshot($)
217 return { text: table(views, nowMs) }
218 })
219}
220hooks/guards/core.ts 78 lines1// The plugin's tool-call guards, written once for two hosts: the settings-hook scripts in hooks/
2// (bun, node fs, stdin/stdout) and the mod (hooks/guards/mod.ts, $.fs/$.env). A hooks module runs
3// with no Node, so nothing under hooks/guards/ may import node:* except node-io.ts, which only the
4// scripts load. Every guard is `(payload, io) => Promise<Outcome>` over the settings-hook payload.
5
6/** The settings-hook stdin payload, as Claude Code sends it (PreToolUse / PostToolUse). */
7export type Payload = Record<string, unknown>
8
9/** A guard's answer. Neither field: no opinion. There is no allow — silence is the pass. */
10export interface Outcome {
11 deny?: string
12 context?: string
13}
14
15export interface FileStat {
16 kind: 'file' | 'dir' | 'other'
17 size: number
18 mtimeMs: number
19}
20
21/** The guard's whole view of the world. Relative paths resolve against `cwd`. */
22export interface GuardIO {
23 env: (name: EnvName) => string | undefined
24 cwd: string
25 pluginRoot: string
26 /** node's os.tmpdir(): TMPDIR, TMP, TEMP, then /tmp. */
27 osTmpdir: string
28 /** The script's process.ppid; the mod's session id. Only a last-resort counter key. */
29 fallbackKey: string
30 nowMs: () => number
31 stat: (p: string) => Promise<FileStat | null>
32 read: (p: string) => Promise<string | null>
33 list: (p: string) => Promise<string[] | null>
34 write: (p: string, text: string) => Promise<void>
35 append: (p: string, text: string) => Promise<void>
36}
37
38/** Every variable a guard reads. The mod reads each by literal name (validate requires it). */
39export const ENV_NAMES = [
40 'LOOK_AT_NESTED',
41 'READ_GUARD_BYTES',
42 'COMPACT_THRESHOLD',
43 'COMPACT_INTERVAL',
44 'CLAUDE_CODE_SESSION_ID',
45 'TMPDIR',
46 'TEMP',
47 'TMP',
48 'WORK_ALLOW_CRON_DELETE',
49] as const
50export type EnvName = (typeof ENV_NAMES)[number]
51
52export type Guard = (payload: Payload, io: GuardIO) => Promise<Outcome>
53
54/** `path.join(a, b)` for the shapes guards build: collapses duplicate slashes. */
55export function joinPath(...parts: string[]): string {
56 return parts.filter(p => p !== '').join('/').replace(/\/{2,}/g, '/')
57}
58
59/** Absolute form of `p` against `cwd`, the way the scripts' relative fs calls resolve. */
60export function absolute(cwd: string, p: string): string {
61 return p.startsWith('/') ? p : joinPath(cwd, p)
62}
63
64/** The deny a PreToolUse gate gives when it throws: the same text `denyOnCrash` prints. */
65export function crashDeny(gate: string, kind: string, error: unknown): string {
66 let detail: string
67 try {
68 detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error)
69 } catch {
70 detail = 'an unrepresentable value was thrown'
71 }
72 return (
73 `${gate}: this gate crashed (${kind}: ${detail}) and could not decide. A gate that cannot ` +
74 `resolve identity or policy denies; a non-zero exit would have been treated as non-blocking ` +
75 `and silently permitted this call. Re-run after fixing the underlying fault.`
76 )
77}
78hooks/guards/atomic-constraint.ts 74 lines1// PostToolUse(Edit|Write): guard against monolithic constraint files.
2//
3// Two anti-patterns: a .md under references/ (not constraints/) that looks like bundled constraints,
4// and a .md under constraints/ with 3+ ### rule headings. Non-blocking: context only.
5//
6// The heading count reproduces Python's `re.findall(r"^###\s+", content, re.MULTILINE)`: Python's
7// `\s` is [ \t\n\r\f\v] and MULTILINE `^` matches only at start-of-string or after "\n"; JS's `\s`
8// and `m` flag are both wider.
9import type { Guard } from './core.ts'
10
11/** len(re.findall(r"^###\s+", content, re.MULTILINE)) — Python semantics. */
12function countH3(text: string): number {
13 const re = /###[ \t\n\r\f\v]+/g
14 let n = 0
15 for (const m of text.matchAll(re)) {
16 const i = m.index!
17 if (i === 0 || text[i - 1] === '\n') n++
18 }
19 return n
20}
21
22export const atomicConstraintGuard: Guard = async (payload, io) => {
23 const toolName = String(payload.tool_name ?? '')
24 const toolInput = (payload.tool_input as Record<string, unknown>) ?? {}
25 if (toolName !== 'Edit' && toolName !== 'Write') return {}
26
27 const filePath = (toolInput.file_path ?? '') as string
28 if (!filePath) return {}
29
30 // Python pathlib: parts / name / stem / suffix.
31 const parts = String(filePath)
32 .split('/')
33 .filter(p => p !== '' && p !== '.')
34 const name = parts.length ? parts[parts.length - 1]! : ''
35 const dot = name.lastIndexOf('.')
36 const suffix = dot > 0 ? name.slice(dot) : ''
37 const stem = suffix ? name.slice(0, -suffix.length) : name
38
39 if (suffix.toLowerCase() !== '.md') return {}
40 if (!parts.includes('references')) return {}
41
42 const content = await io.read(String(filePath))
43 if (content === null) return {}
44
45 const messages: string[] = []
46 const refIdx = parts.lastIndexOf('references')
47 const inConstraintsDir = refIdx + 1 < parts.length && parts[refIdx + 1] === 'constraints'
48
49 if (!inConstraintsDir) {
50 const h3Count = countH3(content)
51 if ((stem.endsWith('-constraints') || stem.endsWith('-conventions')) && h3Count >= 3) {
52 messages.push(
53 `MONOLITH DETECTED: ${name} has ${h3Count} sections and looks like bundled constraints. ` +
54 `Split into individual .md files in constraints/ — one rule per file. ` +
55 `See the atomic-constraints constraint for details.`,
56 )
57 }
58 }
59
60 if (inConstraintsDir) {
61 const h3Count = countH3(content)
62 // Allow the meta-constraint itself to have structure
63 if (h3Count >= 3 && stem !== 'atomic-constraints') {
64 messages.push(
65 `POTENTIAL MONOLITH: ${name} has ${h3Count} ### headings. ` +
66 `Each constraint file should contain ONE rule. ` +
67 `If these headings describe different rules, split into separate files.`,
68 )
69 }
70 }
71
72 return messages.length ? { context: messages.join('\n') } : {}
73}
74hooks/guards/bun-test.ts 157 lines1/**
2 * PreToolUse (Bash) BLOCKING GATE over Bash command TEXT: a `bun test` over more than one test file
3 * — several paths, a directory or name filter, or no path at all (the whole repo) — without
4 * `--parallel`.
5 *
6 * Measured 2026-10-02: this repo's suite took 661-881 s run serially from under ~/.tmp, and 63 s with
7 * `bun test --parallel` from /tmp (scripts/test.sh). bun 1.4.0 has no bunfig.toml key or environment
8 * variable that makes `--parallel` the default: `[test] parallel = true` is silently ignored, so the
9 * flag on the command line is the only switch, and this gate asks for it.
10 *
11 * Exempt by construction: a single test file, `bun test --help`, a command that already passes
12 * `--parallel`, and any harness that runs `bun test` internally (scripts/test.sh included) — the gate
13 * sees only the command text, so it judges only a `bun test` in a command position of that text.
14 *
15 * CONSERVATIVE. Anything the text cannot settle is allowed: a path from a variable or a command
16 * substitution, a quoted glob, `xargs bun test`, global bun flags before `test`. Never rewritten,
17 * never approved: the answer is a deny or silence.
18 */
19import type { Guard } from './core.ts'
20import { commandWordIndex, segments, type Word } from './pgrep.ts'
21
22/** Flags whose operand is the NEXT token when not spelled `--flag=value`, from `bun test --help`
23 * (bun 1.4.0) plus the runtime flags `bun test` accepts. `--parallel`, `--bail`, `--coverage` and
24 * `--changed` take an optional `=value` and do NOT consume the next token (measured). */
25const VALUE_LONG = new Set([
26 '--timeout', '--rerun-each', '--retry', '--seed', '--coverage-reporter', '--coverage-dir',
27 '--test-name-pattern', '--reporter', '--reporter-outfile', '--max-concurrency',
28 '--path-ignore-patterns', '--parallel-delay', '--shard', '--timings',
29 '--preload', '--cwd', '--env-file', '--config', '--tsconfig-override', '--define', '--loader', '--conditions',
30])
31const VALUE_SHORT = new Set(['-t', '-r', '-c', '-d', '-l'])
32
33/** Commands that run their arguments as a command, after their own options. */
34const WRAPPERS = new Set(['timeout', 'nice', 'ionice', 'stdbuf'])
35
36/** A wrapper's own option or operand: `-k 10`, `600`, `1.5m`, `KILL`, `VAR=x`. */
37const wrapperArg = (t: string): boolean =>
38 t.startsWith('-') || /^[\d.]+[smhd]?$/.test(t) || /^[A-Z][A-Z0-9]*$/.test(t) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(t)
39
40const SCRIPT_FILE = /\.[cm]?[jt]sx?$/
41
42const basename = (t: string): string => t.split('/').pop() ?? t
43
44/** Drop heredoc bodies: a `bun test` line inside `cat <<EOF` is text being written, not run. */
45function stripHeredocs(command: string): string {
46 const out: string[] = []
47 const pending: string[] = []
48 for (const line of command.split('\n')) {
49 if (pending.length) {
50 if (line.trim() === pending[0]) pending.shift()
51 continue
52 }
53 out.push(line)
54 for (const m of line.matchAll(/(^|[^<])<<-?\s*(['"]?)([A-Za-z_][\w.-]*)\2/g)) pending.push(m[3]!)
55 }
56 return out.join('\n')
57}
58
59/**
60 * Leave a `$SUBST` word where each command substitution opens. The tokenizer ends a segment at `$(`
61 * and at a backtick, so without it `bun test $(git ls-files)` would read as a bare `bun test`; with
62 * it the outer segment has a target the text cannot count, and the inner command is still judged.
63 */
64function markSubstitutions(command: string): string {
65 let ticks = 0
66 return command.replace(/\$\(|`/g, m => (m === '`' ? (ticks++ % 2 === 0 ? ' $SUBST `' : '`') : ' $SUBST $('))
67}
68
69/** The index of the `bun` word of a `bun test` in this segment, or -1. */
70function bunTestAt(words: Word[]): number {
71 let i = commandWordIndex(words)
72 if (i < 0) return -1
73 while (i < words.length && !words[i]!.quoted && WRAPPERS.has(basename(words[i]!.text))) {
74 i++
75 while (i < words.length && wrapperArg(words[i]!.text) && basename(words[i]!.text) !== 'bun') i++
76 }
77 const bun = words[i]
78 const sub = words[i + 1]
79 if (!bun || !sub || bun.quoted || sub.quoted || basename(bun.text) !== 'bun' || sub.text !== 'test') return -1
80 return i
81}
82
83/**
84 * Why this segment's `bun test` runs several files serially, or null when it does not (not a
85 * `bun test`, `--parallel` or `--help` given, one file, or a target the text cannot settle).
86 */
87function serialReason(words: Word[]): string | null {
88 const at = bunTestAt(words)
89 if (at < 0) return null
90 const args = words.slice(at + 2)
91 const positionals: Word[] = []
92 let endOfFlags = false
93 for (let i = 0; i < args.length; i++) {
94 const w = args[i]!
95 const t = w.text
96 if (!w.quoted && /^(\d*|&)[<>]/.test(t)) {
97 // `2>&1` and `>out` carry their operand; a bare `>` / `2>` / `<` takes the next token.
98 if (/^(\d*|&)(<|>>?)$/.test(t)) i++
99 continue
100 }
101 if (!endOfFlags && t === '--') {
102 endOfFlags = true
103 continue
104 }
105 if (!endOfFlags && t.startsWith('-') && t.length > 1) {
106 const long = t.split('=')[0]!
107 if (long === '--parallel' || long === '--help' || t === '-h') return null
108 if ((VALUE_LONG.has(long) && !t.includes('=')) || VALUE_SHORT.has(t)) i++
109 continue
110 }
111 positionals.push(w)
112 }
113 // A path from a variable, a substitution or a quoted glob: the text cannot count the files.
114 if (positionals.some(p => /[$`]/.test(p.text) || (p.quoted && /[*?[]/.test(p.text)))) return null
115 if (positionals.length === 0) return 'the whole repo'
116 if (positionals.length > 1) return `${positionals.length} paths`
117 const only = positionals[0]!
118 if (/[*?[]/.test(only.text)) return `the glob ${only.text}`
119 if (SCRIPT_FILE.test(only.text)) return null
120 return `${only.text}, a directory or name filter that can match many files,`
121}
122
123/** Truncate an untrusted segment before echoing it back into the model's context. */
124function show(s: string): string {
125 const t = s.length > 80 ? s.slice(0, 77) + '...' : s
126 return t.replace(/[\n\r]/g, ' ')
127}
128
129export const FIX =
130 'Run multi-file bun suites in parallel: bun test --parallel ... (or scripts/test.sh where the repo ' +
131 'has one), with TMPDIR outside ~/.tmp.'
132
133/** Every serial multi-file `bun test` in one Bash command, as `[segment text, reason]`. */
134export function serialBunTests(command: string): [string, string][] {
135 const found: [string, string][] = []
136 for (const seg of segments(markSubstitutions(stripHeredocs(command)))) {
137 const why = serialReason(seg.words)
138 if (why) found.push([seg.words.map(w => w.text).join(' '), why])
139 }
140 return found
141}
142
143export const bunParallelGuard: Guard = async payload => {
144 if (payload.tool_name !== 'Bash') return {}
145 const toolInput = (payload.tool_input ?? {}) as Record<string, unknown>
146 const command = typeof toolInput.command === 'string' ? toolInput.command : ''
147 if (!command) return {}
148 const found = serialBunTests(command)
149 if (!found.length) return {}
150 return {
151 deny:
152 '🛑 ' +
153 found.map(([seg, why]) => `\`${show(seg)}\` runs ${why} in one serial process.`).join('\n') +
154 '\n' + FIX,
155 }
156}
157hooks/guards/cron-delete.ts 214 lines1// PreToolUse(CronDelete): refuse to cancel the loop that drives a work run still in flight.
2// PostToolUse(CronCreate), `cronRecord`: file the new job id under the run(s) its prompt names.
3//
4// The refusal is TASK-SPECIFIC. The record half appends the new job id to `heartbeatCrons` in the
5// args.json of every `.work/<run>` whose name the prompt names, so the guard denies only when a run
6// CLAIMING this id is in flight; an id no run claims falls back to the old rule (deny while any run
7// is in flight).
8//
9// In flight is decided as work-goal-resend.sh decides it -- a run directory holds args.json with no
10// non-empty result.json beside it. That is a property of the filesystem, not of anyone's belief
11// that the run is over, which is exactly where the judgement failed (measured 2026-09-14: a loop
12// deleted at round 2 of 6 with the goal unmet, on the reasoning that the run had been halted).
13import { joinPath, type Guard, type GuardIO } from './core.ts'
14import { holdStateName, unevaluatedNote } from './hold.ts'
15
16/** A cron job id as CronCreate mints them: 8 lowercase hex chars. */
17const JOB_ID = /\b[0-9a-f]{8}\b/
18
19/** Run directories under `<cwd>/.work` holding an args.json, with what that file says. */
20interface Run {
21 name: string
22 /** The run root this one was found under. */
23 base: string
24 argsPath: string
25 argsMtime: number
26 inFlight: boolean
27 crons: string[]
28}
29
30const RUN_ROOTS = ['.work']
31
32async function runsUnder(io: GuardIO, cwd: string): Promise<Run[] | null> {
33 const runs: Run[] = []
34 let sawRoot = false
35 for (const base of RUN_ROOTS) {
36 const root = joinPath(cwd, base)
37 const entries = await io.list(root)
38 if (entries === null) continue // this root is absent
39 sawRoot = true
40 for (const name of entries) {
41 const argsPath = joinPath(root, name, 'args.json')
42 const args = await io.stat(argsPath)
43 if (!args) continue // not a run directory
44 const result = await io.stat(joinPath(root, name, 'result.json'))
45 // An absent result.json IS the in-flight shape; a non-empty one is a verdict.
46 const inFlight = !(result && result.size > 0)
47 let crons: string[] = []
48 try {
49 const parsed = JSON.parse((await io.read(argsPath)) ?? '')
50 if (parsed && typeof parsed === 'object' && Array.isArray(parsed.heartbeatCrons)) {
51 crons = parsed.heartbeatCrons.filter((x: unknown) => typeof x === 'string')
52 }
53 } catch {
54 // Unparseable args.json claims nothing; it is still a run directory for the fallback rule.
55 }
56 runs.push({ name, base, argsPath, argsMtime: args.mtimeMs, inFlight, crons })
57 }
58 }
59 // Neither root exists: a determinate "no run here".
60 return sawRoot ? runs : null
61}
62
63// grind runs OUTSIDE every session, so the hourly backstop a launching session keeps for it is
64// claimed by no `.work` run and would fall to "nobody claims this id, and something is in flight ->
65// deny". The record half marks such an id beside the hold ledger the guard already reads, so no
66// project file and no per-workflow state is added. farm.sh --workflow prints the same backstop,
67// naming its own run directory. The parenthesised nudge is matched, not the bare word: "farm out
68// the review" is prose about delegating, not a heartbeat.
69const NONRUN_PROMPT = /\bgrind\b|\(farm [^)\n]+\)/i
70
71const tmp = (io: GuardIO): string => io.env('TMPDIR') || io.osTmpdir
72const markedPath = (io: GuardIO, session: string): string => joinPath(tmp(io), `work-cron-nonrun-${session}.txt`)
73
74async function isMarked(io: GuardIO, session: string, id: string): Promise<boolean> {
75 if (!session) return false
76 const text = await io.read(markedPath(io, session))
77 return text !== null && text.split('\n').includes(id)
78}
79
80/**
81 * NEVER blocks and NEVER answers: recording is best-effort, and a run whose id was never recorded
82 * simply falls back to the old rule. Every failure is swallowed.
83 */
84export const cronRecord: Guard = async (payload, io) => {
85 try {
86 const toolInput = (payload?.tool_input ?? {}) as Record<string, unknown>
87 const prompt = String(toolInput?.prompt ?? '')
88 const response = payload?.tool_response as unknown
89
90 // tool_response is an object for some tools and a bare string for others, so both are read.
91 let id = ''
92 if (response && typeof response === 'object' && typeof (response as Record<string, unknown>).id === 'string') {
93 id = (response as Record<string, unknown>).id as string
94 } else if (typeof response === 'string') {
95 id = response.match(JOB_ID)?.[0] ?? ''
96 }
97 if (!JOB_ID.test(id)) return {}
98
99 const cwd = String(payload?.cwd ?? '') || io.cwd
100 const runs = (await runsUnder(io, cwd)) ?? []
101
102 // A prompt that names a run belongs to that run, whatever else it says; only an id NO run claims
103 // can be a grind or farm heartbeat.
104 const session = String(payload?.session_id ?? '')
105 if (session && NONRUN_PROMPT.test(prompt) && !runs.some(r => r.name && prompt.includes(r.name))) {
106 if (!(await isMarked(io, session, id))) await io.append(markedPath(io, session), id + '\n')
107 }
108
109 for (const run of runs) {
110 if (!run.name || !prompt.includes(run.name)) continue
111 if (run.crons.includes(id)) continue // idempotent
112 const raw = await io.read(run.argsPath)
113 if (raw === null) throw new Error('args.json unreadable')
114 const parsed = JSON.parse(raw)
115 if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue
116 parsed.heartbeatCrons = [...run.crons, id]
117 // Keep the file's own formatting: the indent of its first nested line, and its trailing newline.
118 const indent = raw.match(/^\{\r?\n([ \t]+)"/)?.[1] ?? ''
119 await io.write(run.argsPath, JSON.stringify(parsed, null, indent) + (raw.endsWith('\n') ? '\n' : ''))
120 }
121 } catch {
122 // best-effort
123 }
124 return {}
125}
126
127const HOLD_ARMED =
128 'A hold is ARMED for this session, so its objective has not closed yet. The heartbeat ' +
129 'is what re-enters the session while the hold is working; deleting it now leaves the hold ' +
130 'with nothing to wake it. Let the hold release itself (the check goes green AND the ' +
131 'classifier judges the goal met), or have the USER confirm `work-hold.sh --disarm` at a ' +
132 'terminal. If the USER has abandoned this run, retire it with ' +
133 "`work-abandon.sh <run-dir> --why '<reason>'`: it writes the run's verdict, releases the " +
134 'hold, and this delete is then allowed.'
135
136export const cronDeleteGuard: Guard = async (payload, io) => {
137 if (String(payload?.tool_name ?? '') !== 'CronDelete') return {}
138
139 // The deliberate override, for genuinely abandoning a run.
140 if (io.env('WORK_ALLOW_CRON_DELETE') === '1') return {}
141
142 const cwd = String(payload?.cwd ?? '') || io.cwd
143 const deleteId = String(((payload?.tool_input ?? {}) as Record<string, unknown>)?.id ?? '')
144
145 // THE HOLD GATE: DONE MEANS GOAL MET. The authority on "closed" is the hold's own release, read
146 // from the per-session ledger work-hold.ts writes -- not this guard's opinion and not the
147 // session's. ONLY the payload's session_id: an ambient CLAUDE_CODE_SESSION_ID leaking in from the
148 // session that launched the process once made this gate answer about the wrong hold (2026-09-27).
149 // An ARMED hold only; a RELEASED one is not this gate's business. An UNEVALUATED hold still
150 // denies, and names the skew, which is the only thing that ends that deadlock.
151 const session = String(payload?.session_id ?? '')
152 const statePath = joinPath(tmp(io), holdStateName(session))
153 if (session && (await io.stat(statePath))) {
154 let skew: string | null = null
155 try {
156 skew = unevaluatedNote(JSON.parse((await io.read(statePath)) ?? ''), Math.floor(io.nowMs() / 1000))
157 } catch {
158 // An unreadable state file says nothing about the hook's liveness; the deny is unchanged.
159 }
160 return {
161 deny:
162 (skew ? `The hold for this session was ${skew}. Until that is fixed the hold cannot release itself, so this delete stays refused — reload, then let the hold run. ` : '') +
163 HOLD_ARMED,
164 }
165 }
166
167 // No .work at all is a determinate "no run here", not a failure to decide, so it passes.
168 const runs = await runsUnder(io, cwd)
169 if (runs === null) return {}
170
171 // A run that CLAIMS this id answers the question by itself: a heartbeat recorded for run A says
172 // nothing about run B, so an unrelated in-flight run must not hold A's finished loop open.
173 const claiming = deleteId ? runs.filter(r => r.crons.includes(deleteId)) : []
174
175 // A heartbeat recorded as belonging to no run -- a grind or farm backstop -- is not a work run's
176 // loop. A run that CLAIMS the id still wins.
177 if (!claiming.length && (await isMarked(io, session, deleteId))) return {}
178
179 const candidates = claiming.length ? claiming : runs
180
181 // The newest in-flight run among the candidates, by args.json mtime -- the file the dispatch writes.
182 let newest = ''
183 let newestBase = '.work'
184 let newestMtime = 0
185 for (const run of candidates) {
186 if (!run.inFlight) continue
187 if (run.argsMtime > newestMtime) {
188 newestMtime = run.argsMtime
189 newest = run.name
190 newestBase = run.base
191 }
192 }
193 if (!newest) return {}
194
195 // A run-directory name that is not a plain slug is withheld rather than repeated: .work can be
196 // repo-shipped, so the name is untrusted text inside a message the reader acts on.
197 const run = /^[A-Za-z0-9._-]+$/.test(newest) ? newest : `(a run under ${newestBase}/)`
198
199 return {
200 deny:
201 (claiming.length
202 ? `The loop you are deleting drives a work run that is still in flight: ${newestBase}/${run}/args.json ` +
203 'records this cron in heartbeatCrons and has no verdict beside it. '
204 : `A work run is still in flight: ${newestBase}/${run}/args.json has no verdict beside it, and no run ` +
205 "claims this cron, so it cannot be told apart from that run's heartbeat. ") +
206 'The loop is usually what drives that run to completion -- it is what re-enters the session to ' +
207 'read the verdict, fix what failed and redispatch. Deleting it now strands the run: the ' +
208 'dispatch keeps going detached and nothing comes back for it. Let the run finish ' +
209 '(work-result.sh exits 0, or the round cap or time ceiling is reached), then delete the loop. ' +
210 'If you genuinely mean to abandon the run, set WORK_ALLOW_CRON_DELETE=1 for the call and say ' +
211 'so out loud.',
212 }
213}
214hooks/guards/image-read.ts 40 lines1// PreToolUse(Read): block Read on image files and redirect to the look-at skill.
2//
3// The case trap: the extension test lowercases `file_path`, but the deny echoes the ORIGINAL value
4// into `--file`. Reusing the lowered string is invisible until a `.PNG` payload arrives.
5import type { Guard } from './core.ts'
6
7const IMAGE_EXTENSIONS = [
8 '.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif',
9 '.gif', '.bmp', '.tiff', '.tif', '.ico', '.svg',
10]
11
12export const imageReadGuard: Guard = async (payload, io) => {
13 const toolName = String(payload?.tool_name ?? '')
14 const toolInput = (payload?.tool_input ?? {}) as Record<string, unknown>
15 if (toolName !== 'Read') return {}
16
17 // look_at.sh sets this in the child it spawns. That child's whole job is to Read the image, and
18 // denying it would point it back at look_at.sh, which spawns another child: unbounded recursion.
19 if (io.env('LOOK_AT_NESTED')) return {}
20
21 const rawFilePath = (toolInput.file_path ?? '') as string
22 const filePath = rawFilePath.toLowerCase()
23 if (!filePath) return {}
24 if (!IMAGE_EXTENSIONS.some(ext => filePath.endsWith(ext))) return {}
25
26 const lookAtScript = `${io.pluginRoot}/skills/look-at/scripts/look_at.sh`
27 return {
28 deny:
29 'Use look-at skill instead of Read for images.\n\n' +
30 'Reading images directly wastes context tokens. ' +
31 'Use the look-at skill to extract only relevant information:\n\n' +
32 '```bash\n' +
33 `${lookAtScript} \\\n` +
34 ` --file "${rawFilePath}" \\\n` +
35 ' --goal "Describe what is in this image"\n' +
36 '```\n\n' +
37 'Set Bash description to: look-at: [your goal]',
38 }
39}
40hooks/guards/pgrep.ts 445 lines1/**
2 * PreToolUse (Bash|Monitor) BLOCKING GATE over Bash command TEXT. Two rules, each one a command
3 * whose failure mode is a hang or a suicide rather than an error:
4 *
5 * 1. `pgrep -f` / `pkill -f` whose pattern matches the invoking shell's own command line.
6 * 2. `rg` / `rga` with no path argument, in a segment nothing pipes into.
7 *
8 * The file keeps its original name: rule 1 is the one with the incident history below, and the
9 * hook path is wired in hooks.json and referenced by the test.
10 *
11 * ── RULE 2: `rg` WITH NO PATH ───────────────────────────────────────────────────────────────────
12 *
13 * ripgrep searches STDIN when it is given no path and stdin is not a terminal. Under the Bash tool
14 * stdin is never a terminal, so a path-less `rg` reads a pipe that no one will ever close. Measured
15 * 2026-09-28 on ripgrep 15.2.0, stdin a fifo held open by a sleeping writer:
16 *
17 * rg foo -> blocked until killed (timeout, exit 124)
18 * rg foo . -> exit 0, immediate
19 * rg -e foo -> blocked
20 * rg -e foo . -> exit 0
21 * rg -f patterns -> blocked (-f gives the pattern; there is still no path)
22 * rg --files / --type-list -> exit 0 (no-pattern modes never read stdin)
23 * rg (no pattern at all) -> exit 2 (usage error; rg never gets as far as stdin)
24 *
25 * The incident: a farmed agent ran
26 * rg -n -i craft --hidden -g '!.git' -g '!CHANGELOG.md' -c | sort
27 * with no path. It hung for 34 minutes until killed. Note the pipe is on the WRONG SIDE — the
28 * segment pipes OUT to sort and nothing pipes IN, so rg's stdin was still the tool's own.
29 *
30 * Parsing rg's flags is the whole difficulty, because a value-taking flag's operand is not a path:
31 * in `-g '!.git' foo` the only positional is the pattern. So the value-taking flags are enumerated
32 * from `rg -h` rather than guessed, and `-e`/`-f` are tracked separately because they supply the
33 * pattern, which makes the FIRST positional already a path.
34 *
35 * ── RULE 1: pgrep/pkill SELF-MATCH ──────────────────────────────────────────────────────────────
36 *
37 * `-f` matches the FULL command line, and the shell running the pgrep is itself in the process table
38 * carrying that pattern in its argv. So `while pgrep -f myjob; do sleep 5; done` finds itself every
39 * time and never exits, and `pkill -f myjob` kills its own subshell.
40 *
41 * WHY THE BRACKET HEURISTIC IS GONE. The previous revision treated '[m]yproc' and an absolute path
42 * as "guarded" and only ever warned. Measured 2026-09-28:
43 *
44 * pkill -f '[f]arm.sh --tasks .*tasks11.json'; ...; pgrep -f '[t]asks11.json' | xargs -r kill
45 *
46 * killed the invoking shell (exit 144). Both patterns are bracketed, so the old rule stayed silent —
47 * but `[t]asks11.json` matches the literal text `tasks11.json` sitting EARLIER ON THE SAME COMMAND
48 * LINE, inside the first pkill's pattern. Bracketing only stops a pattern matching its own spelling;
49 * it says nothing about the rest of the argv. So the heuristic is replaced by the thing it was
50 * approximating: compile the pattern and TEST IT against the command string the tool will run. That
51 * test subsumes both old exemptions — '[m]yproc' does not match its own text and passes, an absolute
52 * path does match its own text and is a real self-match.
53 *
54 * ONE LAYER, DETERMINISTIC. A model layer that judged over-breadth was built and measured, and
55 * dropped: it scored the incident and a harmless tail alike.
56 *
57 * CRASH POLICY. A crash DENIES in both hosts: the script through `denyOnCrash`, the mod through its
58 * catch (hooks/guards/mod.ts). A non-zero exit is treated as NON-BLOCKING by Claude Code, i.e. a
59 * silent allow.
60 */
61import type { Guard } from './core.ts'
62
63/** Short options that consume a value (pgrep/pkill: -d delim, -u/-U uid, -P ppid, ...). */
64const VALUE_SHORT = "dgGPstuUF";
65/** Long options that consume a following token. */
66const VALUE_LONG = new Set([
67 "--delimiter", "--pgroup", "--group", "--parent", "--session", "--terminal",
68 "--euid", "--uid", "--ns", "--nslist", "--signal", "--pidfile",
69]);
70
71export interface Word {
72 text: string;
73 /** True when every character came from inside quotes -- `echo "pgrep -f x"` must not count. */
74 quoted: boolean;
75}
76
77/**
78 * Split a command into pipeline segments of words, quote-aware.
79 *
80 * Segment boundaries are the unquoted shell separators. This is what makes `echo "pgrep -f x"` a
81 * single `echo` segment (the pgrep text is one quoted WORD, never a command position) while
82 * `while pgrep -f worker.py; do ...` is a segment whose command word is pgrep. Splitting on `$(`
83 * and backtick is what makes `kill $(pgrep -f x)` read as a kill segment plus a pgrep segment.
84 */
85export function segments(command: string): { words: Word[]; piped: boolean }[] {
86 const out: { words: Word[]; piped: boolean }[] = [];
87 let words: Word[] = [];
88 let cur = "";
89 let curQuoted = false;
90 let curStarted = false;
91 let quote: '"' | "'" | null = null;
92 let pipedInto = false;
93
94 const endWord = () => {
95 if (curStarted) words.push({ text: cur, quoted: curQuoted && cur.length > 0 });
96 cur = "";
97 curQuoted = false;
98 curStarted = false;
99 };
100 const endSegment = (nextPiped: boolean) => {
101 endWord();
102 if (words.length) out.push({ words, piped: pipedInto });
103 words = [];
104 pipedInto = nextPiped;
105 };
106
107 for (let i = 0; i < command.length; i++) {
108 const ch = command[i];
109 if (quote) {
110 if (ch === quote) quote = null;
111 else if (ch === "\\" && quote === '"' && i + 1 < command.length) {
112 cur += command[++i];
113 curStarted = true;
114 } else {
115 cur += ch;
116 curStarted = true;
117 }
118 continue;
119 }
120 if (ch === "'" || ch === '"') {
121 quote = ch as '"' | "'";
122 curStarted = true;
123 if (cur.length === 0) curQuoted = true;
124 continue;
125 }
126 if (ch === "\\" && i + 1 < command.length) {
127 cur += command[++i];
128 curStarted = true;
129 continue;
130 }
131 if (ch === " " || ch === "\t") {
132 endWord();
133 continue;
134 }
135 if (ch === "|") {
136 const isOr = command[i + 1] === "|";
137 endSegment(!isOr);
138 if (isOr) i++;
139 continue;
140 }
141 if (ch === ";" || ch === "&" || ch === "\n" || ch === "(" || ch === ")" || ch === "`" || ch === "{" || ch === "}") {
142 if (ch === "&" && command[i + 1] === "&") i++;
143 endSegment(false);
144 continue;
145 }
146 if (ch === "$" && command[i + 1] === "(") {
147 endSegment(false);
148 i++;
149 continue;
150 }
151 cur += ch;
152 curStarted = true;
153 }
154 endSegment(false);
155 return out;
156}
157
158/** Shell keywords and wrappers that sit in front of the real command word. */
159const PREFIXES = new Set(["while", "until", "if", "elif", "then", "do", "done", "!", "sudo", "time", "command", "exec", "nohup", "env"]);
160
161/** Strip leading keywords and `VAR=value` assignments; return the index of the command word. */
162export function commandWordIndex(words: Word[]): number {
163 let i = 0;
164 while (i < words.length) {
165 const w = words[i].text;
166 if (PREFIXES.has(w) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) {
167 i++;
168 continue;
169 }
170 return i;
171 }
172 return -1;
173}
174
175/** `... | grep -v $$` (in any flag spelling) excludes the invoking shell. */
176function excludesSelf(segs: { words: Word[]; piped: boolean }[]): boolean {
177 for (const seg of segs) {
178 if (!seg.piped) continue;
179 const ci = commandWordIndex(seg.words);
180 if (ci < 0) continue;
181 const name = seg.words[ci].text.split("/").pop();
182 if (name !== "grep" && name !== "rg") continue;
183 const rest = seg.words.slice(ci + 1);
184 const hasV = rest.some(w => /^--invert-match$/.test(w.text) || (/^-[A-Za-z]+$/.test(w.text) && w.text.includes("v")));
185 const hasSelf = rest.some(w => w.text.includes("$$"));
186 if (hasV && hasSelf) return true;
187 }
188 return false;
189}
190
191interface Invocation {
192 tool: "pgrep" | "pkill";
193 pattern: string | null;
194}
195
196export interface Analysis {
197 /** Invocations whose pattern MATCHES the command string itself — certain, no model needed. */
198 selfMatches: Invocation[];
199 /** `rg`/`rga` segments with no path and no incoming pipe or redirect — they read a live stdin. */
200 pathlessRg: string[];
201}
202
203// ── rg FLAG TABLE (enumerated from `rg -h`, ripgrep 15.2.0) ─────────────────────────────────────
204
205/** Short flags whose operand is the NEXT token (or the rest of the cluster): -A3, -g '!x', -tpy. */
206const RG_VALUE_SHORT = "ABCdeEfgjmMrtT";
207
208/** Long flags whose operand is the next token when not spelled `--flag=value`. */
209const RG_VALUE_LONG = new Set([
210 "--after-context", "--before-context", "--color", "--colors", "--context",
211 "--context-separator", "--dfa-size-limit", "--encoding", "--engine", "--file",
212 "--field-context-separator", "--field-match-separator", "--generate", "--glob",
213 "--hostname-bin", "--hyperlink-format", "--iglob", "--ignore-file", "--max-columns",
214 "--max-count", "--max-depth", "--max-filesize", "--path-separator", "--pre", "--pre-glob",
215 "--regexp", "--regex-size-limit", "--replace", "--sort", "--sortr", "--threads", "--type",
216 "--type-add", "--type-clear", "--type-not",
217]);
218
219/** Flags that supply the PATTERN, which makes the first positional already a PATH. */
220const RG_PATTERN_FLAGS = new Set(["-e", "--regexp", "-f", "--file"]);
221
222/** Modes that take no pattern and never read stdin — verified empirically, see the header. */
223const RG_NO_PATTERN_MODE = new Set(["--files", "--type-list", "--version", "--help", "--generate"]);
224
225/** An unquoted redirect token: `<`, `<<EOF`, `<<<x`, `2>`, `>out`, `<&3`. */
226function redirectKind(w: Word): "in" | "out" | null {
227 if (w.quoted) return null;
228 if (/^\d*<|^<</.test(w.text)) return "in";
229 if (/^\d*>/.test(w.text)) return "out";
230 return null;
231}
232
233/**
234 * Decide whether one segment is an `rg`/`rga` that will read a stdin nobody closes.
235 *
236 * Returns the offending segment's text, or null. A segment fed by a pipe or by ANY input redirect
237 * is fine — stdin is then a real, finite source, which is ripgrep's documented streaming mode.
238 */
239function pathlessRgSegment(seg: { words: Word[]; piped: boolean }): string | null {
240 const ci = commandWordIndex(seg.words);
241 if (ci < 0) return null;
242 const word = seg.words[ci];
243 if (word.quoted) return null; // came out of quotes; not a command position
244 const name = word.text.split("/").pop();
245 if (name !== "rg" && name !== "rga") return null;
246 if (seg.piped) return null; // `cmd | rg foo` — stdin is the pipe, and it ends
247
248 const args = seg.words.slice(ci + 1);
249 let positionals = 0;
250 let patternFromFlag = false;
251 let noPatternMode = false;
252 let endOfFlags = false;
253
254 for (let i = 0; i < args.length; i++) {
255 const w = args[i];
256 const t = w.text;
257
258 if (!endOfFlags) {
259 const redir = redirectKind(w);
260 if (redir === "in") return null; // `rg foo < file`, heredoc, here-string
261 if (redir === "out") {
262 // A bare `>` / `2>` carries its operand in the next token; `>out.txt` carries its own.
263 if (/^\d*>>?$/.test(t)) i++;
264 continue;
265 }
266 if (t === "--") {
267 endOfFlags = true;
268 continue;
269 }
270 if (t.startsWith("--")) {
271 const long = t.split("=")[0];
272 if (RG_NO_PATTERN_MODE.has(long)) noPatternMode = true;
273 if (RG_PATTERN_FLAGS.has(long)) patternFromFlag = true;
274 if (RG_VALUE_LONG.has(long) && !t.includes("=")) i++;
275 continue;
276 }
277 if (t.length > 1 && t.startsWith("-")) {
278 const chars = t.slice(1);
279 for (let c = 0; c < chars.length; c++) {
280 const ch = chars[c];
281 if (RG_VALUE_SHORT.includes(ch)) {
282 if (ch === "e" || ch === "f") patternFromFlag = true;
283 if (c === chars.length - 1) i++; // operand is the next word
284 break; // rest of this token is the operand
285 }
286 }
287 continue;
288 }
289 if (t === "-") return null; // `-` is the explicit "read stdin" spelling; deliberate
290 }
291 positionals++;
292 }
293
294 if (noPatternMode) return null; // --files / --type-list / --version: no pattern, no stdin read
295 // Without -e/-f the first positional is the PATTERN, so a path needs a SECOND one. With -e/-f the
296 // pattern is already in hand, so the first positional is a path.
297 const paths = patternFromFlag ? positionals : positionals - 1;
298 if (paths > 0) return null;
299 // No pattern anywhere: rg exits 2 on a usage error without ever reaching stdin.
300 if (!patternFromFlag && positionals === 0) return null;
301
302 return seg.words.map(x => x.text).join(" ");
303}
304
305/**
306 * Compile a pgrep pattern the way pgrep does — as an extended regex.
307 *
308 * JS RegExp is ERE plus extensions, which is the right direction for a guard: it accepts everything
309 * pgrep accepts except POSIX character classes (`[[:alpha:]]`), which fail to compile here.
310 */
311function compilePattern(pattern: string): RegExp | null {
312 try {
313 return new RegExp(pattern);
314 } catch {
315 return null;
316 }
317}
318
319/** Parse one pgrep/pkill segment. Returns null when the segment is not one, or uses -x. */
320function parseInvocation(seg: { words: Word[] }): { inv: Invocation; full: boolean } | null {
321 const ci = commandWordIndex(seg.words);
322 if (ci < 0) return null;
323 const word = seg.words[ci];
324 if (word.quoted) return null; // came out of quotes; not a command position
325 const name = word.text.split("/").pop();
326 if (name !== "pgrep" && name !== "pkill") return null;
327
328 let full = false;
329 let exact = false;
330 let pattern: string | null = null;
331
332 const args = seg.words.slice(ci + 1);
333 for (let i = 0; i < args.length; i++) {
334 const t = args[i].text;
335 if (t === "--") continue;
336 if (t.startsWith("--")) {
337 const long = t.split("=")[0];
338 if (long === "--full") full = true;
339 if (long === "--exact") exact = true;
340 if (VALUE_LONG.has(long) && !t.includes("=")) i++;
341 continue;
342 }
343 if (t.length > 1 && t.startsWith("-")) {
344 if (/^-\d+$/.test(t) || /^-[A-Z]+[0-9]*$/.test(t)) continue; // pkill signal: -9, -TERM
345 const chars = t.slice(1);
346 for (let c = 0; c < chars.length; c++) {
347 const ch = chars[c];
348 if (ch === "f") full = true;
349 else if (ch === "x") exact = true;
350 else if (VALUE_SHORT.includes(ch)) {
351 if (c === chars.length - 1) i++; // value is the next word
352 break; // rest of this token is the value
353 }
354 }
355 continue;
356 }
357 pattern = t;
358 break;
359 }
360
361 if (exact) return null; // -x is a whole-name match; it cannot pick up a pattern from an argv
362 return { inv: { tool: name, pattern }, full };
363}
364
365/**
366 * Classify every pgrep/pkill in one Bash command string.
367 *
368 * The deterministic test is the whole point: each `-f` pattern is compiled and matched against
369 * `command`, which is what the shell's own argv will carry. A hit means the process will find
370 * itself, and that is certain rather than judged. Anything the text cannot settle — no `-f`, a
371 * pattern from a variable, a pattern that will not compile — is allowed silently: an uncompilable
372 * `-f` pattern is an error pgrep itself will report, and this gate exists only for the certain case.
373 */
374export function analyze(command: string): Analysis {
375 const segs = segments(command);
376 const pathlessRg: string[] = [];
377 for (const seg of segs) {
378 const bad = pathlessRgSegment(seg);
379 if (bad) pathlessRg.push(bad);
380 }
381
382 if (excludesSelf(segs)) return { selfMatches: [], pathlessRg };
383 const selfMatches: Invocation[] = [];
384
385 for (const seg of segs) {
386 const parsed = parseInvocation(seg);
387 if (!parsed) continue;
388 const { inv, full } = parsed;
389 if (!full || inv.pattern === null) continue; // a name-only match cannot pick the pattern out of an argv
390 const re = compilePattern(inv.pattern);
391 if (re && re.test(command)) selfMatches.push(inv);
392 }
393 return { selfMatches, pathlessRg };
394}
395
396/** Truncate an untrusted pattern before echoing it back into the model's context. */
397function show(pattern: string | null): string {
398 if (pattern === null) return "<pattern from a variable>";
399 const p = pattern.length > 60 ? pattern.slice(0, 57) + "..." : pattern;
400 return p.replace(/[\n\r]/g, " ");
401}
402
403/** The one-line fix, named in every message this gate emits. */
404const FIX =
405 "Fix: bracket the pattern ('[m]yproc') AND make sure that literal text appears nowhere " +
406 "else in the command — or better, signal exact pids, use `pgrep -x <name>` without -f, or a pidfile.";
407
408
409export const pgrepSelfMatch: Guard = async payload => {
410 const toolInput = (payload.tool_input ?? {}) as Record<string, unknown>;
411 const command = typeof toolInput.command === "string" ? toolInput.command : "";
412 if (!command) return {};
413
414 const { selfMatches, pathlessRg } = analyze(command);
415
416 // A path-less rg reads the tool's own stdin, which never closes: the process hangs rather than
417 // erroring, so nothing downstream ever reports it.
418 if (pathlessRg.length) {
419 return {
420 deny:
421 "🛑 " +
422 pathlessRg.map(s => `\`${show(s)}\` has no path argument.`).join("\n") +
423 "\nrg reads stdin when given no path and stdin is not a terminal; pass a path, e.g. `rg PATTERN .`",
424 };
425 }
426
427 // Any self-match denies: a self-matching kill takes out its own shell, and a self-matching wait
428 // loop never exits.
429 if (selfMatches.length) {
430 return {
431 deny:
432 "🛑 " +
433 selfMatches
434 .map(
435 f =>
436 `\`${f.tool} -f '${show(f.pattern)}'\` matches THIS command's own text, so it will find the ` +
437 `shell running it${f.tool === "pkill" ? " and kill it" : " (a wait loop on it never exits)"}.`,
438 )
439 .join("\n") +
440 "\n" + FIX,
441 };
442 }
443 return {};
444};
445