SLOPSHOPPER

workflows

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

newbandguardcommandstatusprocess
★ 23v6.44.0no licenseupdated 2026-10-09edwinhu/workflows
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · workflows
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ Denied by workflows: 🛑 `bun test` runs the whole repo in one serial process. Run multi-file bun suites in ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /farm ⎿ workflows: No farm or work runs in this session. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Workflows

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.

Quick Start

# 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:

ToolPurposeUsed by
nlmNotebookLM CLIlibrarian agent, internal nlm skill
readwise-customReadwise RAG/chat/uploadlibrarian agent, internal readwise-chat skill
scholarGoogle Scholar searchlibrarian agent, internal google-scholar skill
consensusAcademic paper searchlibrarian agent, internal consensus skill
morgenCalendar & tasksDirect Bash, or session in ~/areas/assistant/
superhumanEmailemail-handling skill (via Bash)

Requires gh (GitHub CLI). Tools already on your $PATH are skipped.


User Commands

These are the skills you invoke directly with /name:

Core Workflows

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

WorkflowAdds to the work loop
/worknothing — the generic loop for any task worth doing properly
/devTDD discipline: a failing test before the change, and lens reviews for security, performance and test coverage
/dsa computed data-quality gate (DQ1-DQ6, M1, R1) over the panel the run builds
/writinga computed plan-grammar and citation gate, plus the domain style register the plan's Domain: selects
/workshopa computed deck gate over the Typst slides and speaker notes built from a paper
/workflow-creatordesigns, 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.

Document Formats

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.

SkillPurpose
/docxWord document creation, editing, tracked changes
/pdfPDF extraction, creation, form filling
/pptxPresentation creation and editing
/xlsxSpreadsheet creation and analysis
/docx-renderFaithful Word export to PDF/PNG
/law-review-docxMarkdown/legal draft → law-review-styled Word doc
/law-econ-docxMarkdown 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 in scripts/. See references/document-skills.md for the full group and how the stages decouple.

Data

SkillPurpose
/ds-tablesPublication tables in Python — pyfixest.etable() regression tables and great_tables GT formatting
/ds-figuresPublication-ready, accessible figures for papers, slides, and notebooks
/crsp-lseg-spliceExtend stale CRSP stock panels with current LSEG data
/npx-ownership-panelBuild the WRDS proxy-voting × ownership panel
/fuzzy-name-matchingEntity resolution / record linkage by name — char n-gram TF-IDF + sparse_dot_topn top-k cosine, normalize-first, scoped + global two-pass

Writing, Research & Citation

SkillPurpose
/cite-checkVerify academic citations against source PDFs
/de-ai-reviseRevise flagged prose to remove corpus-validated AI writing tics

Meta

SkillPurpose
/skill-creatorSkill creation with superpowers enforcement patterns
/plugin-creatorPlugin-level creation and editing across manifests, hooks, and skills
/workflow-creatorCreate a new structured workflow through shared-v1
/workflow-creator-improveAudit, repair, redesign, or migrate an existing workflow

Auto-Invoked and Internal Skills

These skills have user-invocable: false — Claude loads them automatically when relevant or a workflow dispatches them internally. You don't call them directly.

Legal & Citation

bluebook, bluebook-audit, docx-repair, source-verify

Data Access

wrds, lseg-data, gemini-vertex

Knowledge Management

nlm, google-scholar, readwise, readwise-chat, readwise-search, readwise-docs, readwise-prune

Research

consensus, research

Notebook Tools

marimo, jupytext, notebook-debug

Utilities

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.

Internal Workflow Phases

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.


Agents

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/:

AgentRoleScopeHooks
librarianKnowledge management orchestration (NLM, Readwise, Scholar, Workspace)plugin—
dsEmpirical implementer — datasets, tables, figures, numbers; C/V/A/E constraints arrive as task refsuser—
ds-reviewerRead-only grading of existing empirical work against C/V/A/E constraintsuser—
workshopTalk implementer — Typst deck and speaker notes from a paper; preloads typst:typstuser—
workshop-reviewerRead-only grading of slides.typ and notes.typ against the canonical Typst modulesuser—
writingGeneral long-form prose — memos, letters, briefs, reportsusersource-first PreToolUse guard
writing-legalLaw review prose — footnotes, Bluebook short formsusersource-first PreToolUse guard
writing-econFinance and accounting journal proseusersource-first PreToolUse guard
writing-reviewerRead-only prose grading against the preloaded register and the tic tableuser—

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.

Why subagents

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.


Workflow lifecycle architecture

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

Hooks auto-run at specific lifecycle events. The table has one row per command target registered in hooks/hooks.json:

ScriptEventTriggerPurpose
session-start.tsSessionStartstartup/resume/clear/compactInject using-skills meta-skill; report an unfinished work run
session-end.tsStop*Update LEARNINGS.md timestamp
lint-check.tsPostToolUseEdit/WriteLint after file changes (ESLint, ruff, lintr)
writing-prose-check.tsPostToolUseEdit/WriteCheck edited prose for writing-quality violations
cite-fidelity-lint.tsPostToolUseEdit/WriteCheck edited writing for citation-ledger fidelity
pr-url-logger.tsPostToolUseBashLog PR URLs and GitHub Actions status
overflow-check.tsPostToolUseBashDetect Typst content overflow after compilation
pattern-scan.tsSessionEndclear/logout/prompt_input_exit/otherScan session for reusable patterns

Tool-call guards (mod)

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.

GuardBefore / afterToolsPurpose
image-read-guardbeforeReadRedirect to look-at for media files
read-guardbeforeRead, BashDeny unbounded dumps of large files
suggest-compactbeforeEdit, WriteSuggest compaction at edit-count checkpoints
pgrep-self-matchbeforeBash, MonitorDeny self-matching pgrep/pkill -f and path-less rg
bun-parallel-guardbeforeBashDeny a multi-file bun test without --parallel
cron-delete-guardbefore / afterCronDelete / CronCreateKeep an in-flight work run's heartbeat; record new ids
atomic-constraint-guardafterEdit, WriteValidate atomic constraint file structure
typst-convention-guardafterEdit, WriteTypst convention violations
validate-skill-pathsafterEdit, Write${CLAUDE_*} references to missing files

Bulk-extraction guard (mod)

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:

RuleToolsAction
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 countRead, Bashnote 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)Bashdeny
A for/while/xargs loop that runs claude -p, codex exec, gemini or agy -p per itemBashdeny
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 patternBash; Write/Edit of .py .ts .js .shdeny
A Read, Bash or Grep result over 20K tokens (chars/4)Read, Bash, Grepfull 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.


Session Continuity

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"

Repository Structure

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 creates
  • hooks/ contains TypeScript hook entry points called directly by hooks.json
  • workflows/ contains the shared runner plus writing, workshop, and workflow-creator adapters
  • scripts/ contains deterministic compilers, validation checks, renderers, and support tools
  • references/ contains shared constraint and enforcement docs

Updating External Skills

The office format skills come from Anthropic's official skills repo. To update:

git submodule update --remote external/anthropic-skills

Acknowledgments

This project was heavily inspired by obra/superpowers, particularly:

  • The SessionStart hook pattern for injecting meta-skills
  • The "using-skills" approach that teaches HOW to use skills rather than listing WHAT skills exist
  • The philosophy that skills should be invoked on-demand, not dumped into every session

Office format skills (docx, pdf, pptx, xlsx) are from anthropics/skills.

License

MIT

Author

Edwin Hu

Source 21 files
hooks/register.ts 20 lines
1// 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}
20
hooks/bulk-guard.mjs 555 lines
1// 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}
555
hooks/guards/mod.ts 210 lines
1// 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}
210
hooks/jev/forecast-mod.ts 45 lines
1// 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}
45
hooks/jev/mod.ts 230 lines
1// 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}
230
hooks/watch/watcher.ts 220 lines
1// 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}
220
hooks/guards/core.ts 78 lines
1// 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}
78
hooks/guards/atomic-constraint.ts 74 lines
1// 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}
74
hooks/guards/bun-test.ts 157 lines
1/**
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}
157
hooks/guards/cron-delete.ts 214 lines
1// 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}
214
hooks/guards/image-read.ts 40 lines
1// 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}
40
hooks/guards/pgrep.ts 445 lines
1/**
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