SLOPSHOPPER

guardrails

Blocks edits outside the task spec, made-up packages, check-weakening edits and destructive git; gates finishing, committing and /ship on passing checks.

newguardcommandtoaststatusprompt
v0.9.0MITupdated 2026-10-20effinrich/claude-mods/guardrails
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · guardrails
› 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(rm -rf build && git push --force origin main) ⎿ Denied by guardrails: guardrails: blocked `rm -rf build && git push --force origin main`: force-pushing re ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /guard ⎿ guardrails: on ⎿ guardrails: no .claude/spec.md: scope unrestricted ⎿ guardrails: checks: fail (edits since) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ guardrails: guard · no spec · ✗ checks
README

claude-mods

Guardrails, compaction handoffs, drift checks and default skills for Claude Code, Codex CLI, Gemini CLI, Cursor and Copilot CLI. None of them call the model on their own.

One-command install (every tool)

npx --allow-git=all github:effinrich/claude-mods

--allow-git=all is needed from npm 12 on, which refuses to fetch packages from git by default (EALLOWGIT); older npm ignores it.

It detects which of the five tools you have and wires each one up. Claude Code gets its plugins from this repo's marketplace. The others get command hooks that call a small Node runtime copied to ~/.claude-mods/runtime/<version>/. Before editing any config file you own, it writes a .claude-mods-backup-<time> copy next to it.

CommandDoes
npx --allow-git=all github:effinrich/claude-mods installinstall (the default command)
… updatereinstall with your last choices, after a new version lands
… uninstallremove only what it added (hook entries, /ship and /checks, copied skills, the marked instruction block)
… doctorwhat is installed where, a stale Node path in the hooks, tokens pinned into every turn, blocks per rule over the last 7 days, payload capture status, recent hook errors
… projectadd shared claude-mods files to the repo you are in; --remove takes them out (see below)

Options: --tools claude,codex,gemini,cursor,copilot (default: detected), --mods guardrails,compaction-guard,context-audit,default-skills (default: all), --skills "ponytail:ponytail full", --dry-run.

Needs Node 20 or newer. MIT licensed.

What each tool gets

CodexGemini CLICursorCopilot CLI
Block out-of-scope, lockfile and check-weakening edits✓✓◐ Write tool input is undocumented: blocked when a path is found◐ same for edit/create
Block made-up packages✓✓◐◐
Block destructive git and --no-verify✓✓✓✓
Commit gate on checks✓✓✓✓
Plain wording in commit, PR and issue text✓✓✓✓
End-of-turn check gate (2 retries)✓ Stop✓ AfterAgent✓ stop follow-up✓ agentStop
Long prompt saved as .claude/spec.md draft✓✓✓✓
Handoff saved before compaction✓✓✓✓
Handoff returned after compaction✓ SessionStart compact✓ next prompt◐ next session start◐ next session start
Drift check on global instruction files✓✓✓✓
/ship and /checksskills ($ship)/ship commandskillsskills
Default skills (ponytail, plain-writing) always on✓ AGENTS.md✓ GEMINI.md◐ skill copied; add the rule in Cursor Settings → Rules, or per repo with project✓ copilot-instructions.md

In a git repo the gates see every change to the working tree, including edits made through the shell, and skip the check run when nothing changed since the checks last passed. With no checks: in the spec they run the typecheck, lint and test scripts, with CI=true so test runners don't start in watch mode. At the end of a turn, a test script that is just vitest, vitest run or jest runs only the tests related to changed files (vitest run --changed, jest --onlyChanged); commits run the whole suite. With backgroundChecks on (auto does this for those changed-only runs; on for any checks, off never), the checks start in the background a moment after each edit; when the turn ends the gate uses that result if it was run on exactly the current files, waits for a run that is still going, or runs the checks itself if the files changed since. A repo with none of those gets a one-time notice instead of a silent pass. A reply that claims success ("tests pass", "all green", "fixed", "verified") is checked once per turn: if the checks fail, the agent is sent back to correct the reply. Codex and Gemini CLI pass the reply to their hooks; Cursor and Copilot don't, so they skip that part.

Not ported: context-meter (these tools have no custom status line command) and cli-passthrough (it is specific to Claude Code's own CLI). The context-percentage hold is also missing, because none of these tools tell hooks how full the context is, and so is the research-first hold, because their hooks don't see reads and searches. They get the matching instruction line instead.

First run in each tool

  • Codex: hooks don't run until you approve them in /hooks.
  • Gemini CLI: it lists the new hooks once for you to confirm.
  • Cursor: restart Cursor.
  • Copilot CLI: start a new session.

Then check it works: ask the agent to run git reset --hard. It should be refused with a guardrails: reason. If it isn't, run doctor and see ~/.claude-mods/hook.log.

The hooks call Node by the absolute path it had at install time, so GUI apps without your shell's PATH still find it. After a Node upgrade (nvm, fnm, Homebrew) that path can disappear; doctor warns about it, and update fixes it.

One profile for every tool

Skills and gate settings live in one file that every part reads: the Claude Code plugins, the other tools' hooks, install, project and crew agents.

  • ~/.claude-mods/profile.json is yours. A repo's .claude/claude-mods.json is the team's, gets committed, and overrides yours key by key.
{
  "skills": ["ponytail:ponytail full", "default-skills:plain-writing"],
  "roles": { "designer": "frontend-design", "backend": "api-style" },
  "guardrails": { "writingGate": true, "stopGate": true, "researchHoldLines": 150, "checkTimeoutMinutes": 10, "backgroundChecks": "auto" },
  "crew": { "cap": 8 },
  "tokenBudget": 2000
}
  • skills replaces the default-skills list (and --skills for the other tools). roles adds skills per crew specialist: crew claim lists them and the specialist loads them before editing, so the skills reach agents that never see the main session's prompt.
  • guardrails overrides the plugin settings; crew.cap is the default width when a plan sets none; tokenBudget is the limit doctor and /default-skills warn at.
  • Keys with the wrong type are ignored, so a typo can't switch a gate off.

Shared repo setup

Run this inside a repo (any folder in it; the files go at the git root):

npx --allow-git=all github:effinrich/claude-mods project [--mods ...] [--skills ...] [--dry-run] [--remove]
FileWhat claude-mods writes
.cursor/rules/claude-mods.mdcan always-applied Cursor rule (owned by claude-mods)
AGENTS.mda marked block at the end; the rest of the file is left as is
.claude/settings.jsonmerged in: the claude-mods marketplace under extraKnownMarketplaces and <mod>@claude-mods: true under enabledPlugins
.claude/claude-mods.jsonthe team profile with the chosen skills, if the repo has none yet (an existing one is read, not changed)

The rule and the block carry the guardrails lines and one line per default skill. Each skill line points at ~/.agents/skills/<skill>/SKILL.md and tells the agent to skip it when that file is missing, so teammates without the skill are not affected. --mods also accepts context-meter and cli-passthrough for the Claude Code settings. A plugin someone set to false in the settings stays off.

--remove takes out exactly what project added and deletes files it created. Before changing a file you already had, it saves a copy under ~/.claude-mods/backups/<repo>-<hash>/<time>/, outside your working tree. Re-running is safe.

Help pin the Cursor and Copilot edit fields

Cursor's preToolUse input for its Write tool and Copilot CLI's toolArgs for edit and create are not documented. The adapters try the likely field names and let an edit through when they find no path, so scope and lockfile blocks can miss there. (Cursor's afterFileEdit still marks the turn for the end-of-turn gate; Copilot has no such hook.) One real payload is enough to fix that:

  1. Turn on capture with CLAUDE_MODS_DEBUG=1 in the tool's environment, or add "debug": true to ~/.claude-mods/config.json (a reinstall keeps it).
  2. Ask the agent to create or edit a small file.
  3. Open ~/.claude-mods/payloads.log. Each line has the tool, event, top-level keys, argument keys and the payload with long strings cut at 200 characters.
  4. Send one preToolUse line for a Write (Cursor) or edit/create (Copilot) in a GitHub issue. Check it first: it holds part of the file content and your paths.

Turn capture off again afterwards. The log rolls over at 2 MB.

New users and teams

  1. Each person, once per machine: install Node 20 or newer, then run npx --allow-git=all github:effinrich/claude-mods. Hooks and skills live in each user's own config, so nobody can install them for someone else.
  2. Each shared repo, once: run npx --allow-git=all github:effinrich/claude-mods project and commit .cursor/rules/claude-mods.mdc, AGENTS.md and .claude/settings.json. Claude Code then offers the claude-mods plugins to everyone who opens the repo.
  3. Default skills: plain-writing ships with claude-mods, so everyone gets it. ponytail and feature-based-architecture are not part of this repo; each person's install copies them from their own Claude Code installs into ~/.agents/skills/. Anyone who lacks a skill just skips it; nothing breaks.
  4. First-run approvals, per person and tool:
  5. Claude Code: when you open the repo, accept the claude-mods marketplace and the plugins it lists.
  6. Codex: approve the hooks in /hooks; they do not run until you do.
  7. Gemini CLI: confirm the new hooks when it lists them.
  8. Cursor: restart Cursor.
  9. Copilot CLI: start a new session.

Then run doctor to check, and the git reset --hard test above.

Claude Code mods

These run inside Claude Code's plugin engine. Guardrails adds one short system prompt line (scope, Suggestions, no invented APIs, prefer established libraries and their existing APIs), which you can turn off.

guardrails

Enforcement by blocking, not by instructions in a markdown file.

Spec. Put the task in .claude/spec.md:

---
scope: [src/features/auth/**, tests/auth/**]   # where Claude may edit; empty means anywhere
forbid: [package.json, "**/*.snap"]            # never edit these
checks: [pnpm typecheck, pnpm test auth]       # must pass; empty means typecheck + lint + test scripts
---
Add OAuth login. Acceptance: ...

A prompt over 1500 characters with no spec yet is saved there as a draft for you to fill in.

What it blocks

  • Edits outside scope or inside forbid. The deny tells Claude to list the change under "Suggestions:" instead, so ideas still reach you.
  • Imports of packages that are neither installed nor declared in any package.json above the file (type-only imports, relatives, tsconfig path aliases and Node builtins are fine).
  • Edits that weaken checks: .skip / .only, @ts-ignore, eslint-disable, new any types, vi.mock/jest.mock of your own modules (relative paths). Only what the edit adds counts.
  • Edits that remove assertions from a test file (expect(, assert(, .should) or lower a coverage threshold in the test config.
  • Snapshot rewrites: vitest -u, jest --updateSnapshot, pnpm test -- -u, playwright --update-snapshots.
  • Hand edits to lockfiles.
  • git commit, git tag and gh pr/gh issue text with an em dash or a stock phrase (delve, seamless, leverage, robust, comprehensive, "it's worth noting", "I hope this helps"). Claude rewords it and runs the command again. The list is short so it rarely fires on real text.
  • --no-verify, git commit -n, git push --force (lease is fine), git reset --hard, git checkout -- ., git clean -f, rm -rf ~ or /. You can still run these yourself with !.
  • Finishing a turn that changed files while checks fail. In a git repo any change counts, shell edits included. Claude is sent back up to 2 times, then stops and reports.
  • Finishing with a reply that says tests pass, all green, fixed or verified while the checks fail. Claude is sent back once to correct it. Checks that never ran are run first.
  • git commit while checks fail.
  • At the end of a turn, everything the turn changed is checked against where it started, whatever made the change: a deleted test file, or a .skip, @ts-ignore, removed assertion or lowered threshold added through the shell, sends Claude back once to restore it or explain. A moved test isn't counted as deleted, and changes that were already there before the turn aren't blamed on it.
  • With no checks: in the spec, the checks are the typecheck, lint and test scripts (npm init's placeholder test script is ignored). A repo with none of them gets a one-time notice. The end-of-turn gate runs only changed tests for a bare vitest or jest script; commits and /ship run everything.

Every block, hold and send-back is logged to ~/.claude-mods/blocks.log (shared with the other tools). npx --allow-git=all github:effinrich/claude-mods doctor counts them per rule for the last 7 days, so you can see which rules earn their keep.

What it holds back once

  • A new source file of 150+ lines that imports a library the session hasn't searched, read under node_modules/, or fetched docs for. Claude is told to check the library's docs, repo examples and source for an existing API first; writing the file again goes through. React, Vue, Svelte, Next and similar frameworks don't count.
  • A long new prompt when context is over 75%: held once with a nudge to /clear (and /handoff-load); send it again to go ahead.

Commands

  • /ship runs checks and, only if they pass, has Claude commit, push and open a draft PR.
  • /checks runs the checks now.
  • /guard shows status; /guard off and /guard on switch it for the session.

Settings in /config: end-of-turn gate, system prompt line, spec drafts, writing check, research hold size (0 turns it off), context hold threshold, extra allowed imports, check timeout.

default-skills

Pins skills into the system prompt of every session, as if you had run them at the start, and keeps them on through compaction (no more re-running /ponytail:ponytail full after a compact). It reads each skill from your own installs (plugin cache, ~/.claude/skills, skills synced from your claude.ai account, or the project's .claude/skills), so no third-party skill text lives in this repo. A configured skill you don't have is skipped. The one exception is plain-writing, which this repo wrote and ships.

  • Default: ponytail:ponytail full, feature-based-architecture, default-skills:plain-writing. Change the list in /config (comma-separated, plugin:skill args).
  • plain-writing (about 400 tokens per turn) covers anything a person reads: lead with the answer, plain words, no em dashes or formula (not-X-but-Y, groups of three), length that fits, and no "done" or "tests pass" without having run them. Drop it from the list to turn it off.
  • /default-skills shows what is pinned, from where, and roughly how many tokens it adds per turn. Over ~2,000 tokens in total it warns at session start; doctor reports the same total for every tool.
  • Each pinned skill is sent with every turn (cached with the system prompt), and is cut at 16,000 characters.
  • Skills that run shell commands while they expand (! lines) are pinned as written, not run.

compaction-guard

Before each compaction it saves the original task, the latest plan, todos, recent prompts and touched files to ~/.claude/handoffs/<project>-<hash>.md. After the compaction it adds that handoff back into context. Three compactions within ten minutes raise a warning to fork.

  • /handoff saves the handoff now.
  • /handoff-load loads this project's last handoff into a fresh session.

context-audit

Five seconds after a session starts it measures the fixed context every turn carries and shows it in the status line (ctx base 46k). It toasts only when that number is over your limit and is new or has grown by more than 10%. It also compares global CLAUDE.md files, enabled plugins and user hooks with the last session, and marks added lines that fetch remote content.

  • /context-audit prints the breakdown.

context-meter

Status line: ctx 62% ⚠ · $3.41 · 2× compacted · 5h 72% · opus-5-5. Warns at 60%, suggests forking at 80% (both in /config). The rate limit appears once a window passes 50%.

cli-passthrough

Typing claude plugin install x@y, claude mcp add … or several of them joined by && at the prompt runs them directly and shows the output, with no model turn. Anything with other shell syntax goes to the model as usual.

crew

A lead agent plans a goal as a graph of tickets, then runs specialist agents in parallel, each in its own git worktree, and merges their work into one branch.

  • /crew:crew-plan <feature> <goal>: the lead reads the code, asks you the open questions (numbered, each with a recommendation), then writes .claude/crew/<feature>/plan.md and one file per ticket. Each ticket names a role (designer, frontend, backend, tester, docs), the files it owns, what blocks it and its checks. You approve the graph before anything runs.
  • /crew:crew-run <feature>: the lead creates the branch crew/<feature>, starts every unblocked ticket at once (8 by default; a run can ask for more after a cost check), and merges each finished ticket. A merge goes through only when the specialist touched no files outside its ticket, its checks pass, and the plan's full checks pass after the merge; otherwise the merge is undone and the specialist is sent back once. At the end the reviewer agent checks the whole branch against the tickets, and /ship opens the draft PR.
  • /crew [feature] shows every ticket's state.

How the agents stay out of each other's way:

  • Two tickets run at the same time only if their files can't overlap. Shared files (package.json, tsconfig*.json, .github/**, plus the plan's shared list) belong to the lead; specialists ask for changes under "Suggestions:".
  • Guardrails reads each worktree's ticket and blocks edits outside it, and crew merge checks the diff again, so a shell edit can't slip past.
  • Specialists run in Claude Code's own worktree isolation. crew sets worktree.baseRef to "head" in .claude/settings.local.json so their worktrees start from the run branch.

Things to know:

  • Each worktree is a full checkout and runs an install (setup: in the plan; none turns it off). pnpm keeps that fast.
  • Worktrees live under .claude/worktrees/. If your test or lint config scans every folder, exclude .claude/.
  • Parallel width is limited by how independent the tickets are, not only by the cap. Every agent costs a full context.
  • The same CLI works outside Claude Code: npx --allow-git=all github:effinrich/claude-mods crew status <feature>.

Install (Claude Code only)

At the prompt of a terminal session, one line per mod you want:

/plugin install guardrails --marketplace effinrich/claude-mods
/plugin install compaction-guard --marketplace effinrich/claude-mods
/plugin install context-audit --marketplace effinrich/claude-mods
/plugin install context-meter --marketplace effinrich/claude-mods
/plugin install cli-passthrough --marketplace effinrich/claude-mods
/plugin install default-skills --marketplace effinrich/claude-mods
/plugin install crew --marketplace effinrich/claude-mods

Answer y to add the marketplace, then pick the user scope. From a shell, use claude plugin install <mod> --marketplace effinrich/claude-mods. In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with effinrich/claude-mods, then Add plugin.

Skills that pair well, and ones to skip

These were reviewed for this repo on 2026-10-19. None are bundled.

  • Matt Pocock's skills (github.com/mattpocock/skills, MIT): use on demand. His flow is grill-me, then to-spec, to-tickets, then implement with tdd. It fits guardrails: the end-of-turn gate runs after his red-green cycle finishes inside a turn, and his ticket files live in .scratch/, not .claude/spec.md. Two things to know. His specs leave out file paths, so put the paths a ticket may touch in .claude/spec.md scope yourself. And give prefactor tickets their own scope, or the scope block will stop them.
  • Claude Code /goal: use on demand with a check as the goal ("pnpm test passes and …"). Guardrails still blocks bad edits and commits inside the loop. Avoid third-party goal loops with high continuation caps.
  • humanizer (blader/humanizer): good for a one-off rewrite of a long document. At about 7,000 tokens it is too big to pin; plain-writing covers the same ground in about 400.
  • superdesign: skipped. It sends your code to a hosted service and needs an account.
  • ultrawork (oh-my-claudecode, MIT): skipped. The main repo retired it in v5.0.0 in favour of /oh-my-claudecode:execute. Its persistent mode used a Stop hook that kept blocking (capped at 50 to 500 passes) even with no todos left. Next to the guardrails gate that would override the 2-retry limit and bury the "checks fail" message under "do not stop". The OpenCode version (oh-my-opencode) has a source-available license and reported runaway loops. Ideas worth borrowing by hand: plan parallel work in waves with a dependency list, route simple lookups to a cheaper model, and keep subagent reports short.

Notes

  • Where an organization manages Claude Code, hooks on compaction, the system prompt and Stop only run for plugins the organization lists in managed settings.
  • context-audit records your current setup on its first run and only reports changes after that, so review your global CLAUDE.md once yourself.
  • Run the tests with claude plugin test <plugin folder>.

Developing

  • Shared logic lives in core/. Each Claude plugin carries a generated copy, so after editing core/ run npm run build; it syncs the copies and compiles dist/.
  • dist/ is committed, because npx github: installs run no build step.
  • Tests: npm test covers the runner, adapters and installer; npm run check checks the core copies and types; claude plugin test <plugin> covers each Claude mod. CI runs the first two on Node 20 and 22 and fails when dist/ does not match a fresh build.
Source 7 files
hooks/register.ts 690 lines
1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2import { CHECK_ENV, composeFingerprint, isChangedOnly, resolveChecks, summarizeFailures, untrackedPaths, type CheckMode, type CheckRun } from './checks.ts'
3import { addedPackages, declaredDependencies, isSourceFile, parentDirs, pathAliases, unresearchedPackages } from './imports.ts'
4import { blockEntry, claimedSuccess, isCoverageConfig, judgeTurnChanges, parseNameStatus, type TurnChange, isGitCommit, isLockfile, judgeCommand, judgeProse, judgeWeakening } from './rules.ts'
5import { judgeOwnership, MARKER, parseMarker, type Marker } from './crew.ts'
6import { mergeProfiles, parseProfile, PROFILE_FILE, USER_PROFILE_FILE, type Profile } from './profile.ts'
7import { draftSpec, judgeScope, parseSpec, SPEC_PATH, type Spec } from './spec.ts'
8
9type Config = {
10  stopGate: boolean
11  promptRule: boolean
12  draftSpecs: boolean
13  writingGate: boolean
14  researchHoldLines: number
15  backgroundChecks: 'auto' | 'on' | 'off'
16  newTaskPercent: number
17  allowImports: ReadonlySet<string>
18  checkTimeoutMs: number
19}
20
21type GuardState = {
22  isOff: boolean
23  isDirty: boolean
24  lastResult: 'pass' | 'fail' | undefined
25  stopBlocks: number
26  heldPrompt: { text: string; at: number } | undefined
27  // Inputs of the session's non-edit tool calls (reads, searches, fetches), for the research-first hold.
28  lookups: string[]
29  heldWrites: Set<string>
30  // Working-tree fingerprints (git): at the start of the turn, and when checks last passed.
31  turnStartFp: string | undefined
32  passFp: string | undefined
33  // Whether the last passing run was the full suite (commits need that) or the end-of-turn subset.
34  lastPassFull: boolean
35  claimChecked: boolean
36  warnedNoChecks: boolean
37  // Where the turn started (git), so the end of the turn can judge every change, shell edits included.
38  turnBase: TurnBase | undefined
39  turnChangesChecked: boolean
40}
41
42type TurnBase = { commit: string; untracked: Record<string, string> }
43
44const MAX_STOP_BLOCKS = 2
45const FAILURE_LINES = 30
46const NEW_TASK_MIN_CHARS = 300
47const SPEC_DRAFT_MIN_CHARS = 1500
48const HELD_PROMPT_MS = 5 * 60_000
49const SHIP_DELAY_MS = 100
50const TYPED_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge'])
51
52const RULE =
53  `Guardrails are on. Follow ${SPEC_PATH} exactly when it exists; edits outside its scope are blocked. ` +
54  'Put out-of-scope ideas under "Suggestions:" (at most 3 bullets) instead of doing them. Be terse. ' +
55  'Never invent APIs, packages, flags or file contents: read, grep or say you do not know. ' +
56  "Unless the user asks for a custom build, prefer an established, widely used library or the existing API of one already in use; before building on a library, search its docs, examples and source; check a package exists and is maintained before adding it. " +
57  'Checks must pass before you finish.'
58
59const SHIP_PROMPT =
60  'Ship the current work: commit the changes that belong to this task with a concise message in the repo style, ' +
61  'push the branch, and open a draft PR that says what changed and why. Make no other changes. ' +
62  'If any step fails, stop and report it.'
63
64const REWORD = 'Reword it plainly (period, comma or parentheses instead of em dashes; plain words) and run it again.'
65
66const MAX_LOOKUPS = 400
67const LOOKUP_CHARS = 2_000
68const EDIT_TOOLS: ReadonlySet<string> = new Set(['Edit', 'Write', 'NotebookEdit'])
69
70const SUGGEST = 'If it needs changing, list it under "Suggestions:" in your reply.'
71
72const state: GuardState = {
73  isOff: false,
74  isDirty: false,
75  lastResult: undefined,
76  stopBlocks: 0,
77  heldPrompt: undefined,
78  lookups: [],
79  heldWrites: new Set(),
80  turnStartFp: undefined,
81  passFp: undefined,
82  lastPassFull: false,
83  claimChecked: false,
84  warnedNoChecks: false,
85  turnBase: undefined,
86  turnChangesChecked: false,
87}
88
89const readConfig = (options: PluginOptions): Config => ({
90  stopGate: options.stopGate !== false,
91  promptRule: options.promptRule !== false,
92  draftSpecs: options.draftSpecs !== false,
93  writingGate: options.writingGate !== false,
94  researchHoldLines:
95    typeof options.researchHoldLines === 'number' && options.researchHoldLines >= 0 ? options.researchHoldLines : 150,
96  backgroundChecks: options.backgroundChecks === 'on' || options.backgroundChecks === 'off' ? options.backgroundChecks : 'auto',
97  newTaskPercent:
98    typeof options.newTaskPercent === 'number' && options.newTaskPercent > 0 ? options.newTaskPercent : 75,
99  allowImports: new Set(
100    typeof options.allowImports === 'string'
101      ? options.allowImports.split(',').map(name => name.trim()).filter(name => name !== '')
102      : [],
103  ),
104  checkTimeoutMs:
105    (typeof options.checkTimeoutMinutes === 'number' && options.checkTimeoutMinutes > 0
106      ? options.checkTimeoutMinutes
107      : 10) * 60_000,
108})
109
110const readOptional = async ($: EngineInterface, path: string | undefined): Promise<string | undefined> => {
111  if (path === undefined) return undefined
112  const text = await $.fs.read(path).catch(() => undefined)
113  return typeof text === 'string' ? text : undefined
114}
115
116// The user's ~/.claude-mods/profile.json, overridden by the repo's .claude/claude-mods.json.
117const loadProfile = async ($: EngineInterface): Promise<Profile> => {
118  const [modsHome, home, root] = await Promise.all([$.env.get('CLAUDE_MODS_HOME'), $.env.get('HOME'), $.session.root()])
119  const dir = modsHome ?? (home === undefined ? undefined : `${home}/.claude-mods`)
120  const [user, repo] = await Promise.all([
121    readOptional($, dir === undefined ? undefined : `${dir}/${USER_PROFILE_FILE}`),
122    readOptional($, `${root}/${PROFILE_FILE}`),
123  ])
124  return mergeProfiles(parseProfile(user), parseProfile(repo))
125}
126
127const withProfile = (config: Config, profile: Profile): Config => {
128  const settings = profile.guardrails ?? {}
129  return {
130    ...config,
131    stopGate: settings.stopGate ?? config.stopGate,
132    writingGate: settings.writingGate ?? config.writingGate,
133    researchHoldLines: settings.researchHoldLines ?? config.researchHoldLines,
134    backgroundChecks: settings.backgroundChecks ?? config.backgroundChecks,
135    checkTimeoutMs: settings.checkTimeoutMinutes === undefined ? config.checkTimeoutMs : settings.checkTimeoutMinutes * 60_000,
136  }
137}
138
139const clip = (text: string, max = 80): string => (text.length <= max ? text : `${text.slice(0, max)}…`)
140
141const isAbsolute = (path: string): boolean => /^([\\/]|[A-Za-z]:[\\/])/.test(path)
142
143const absoluteIn = (root: string, path: string): string => (isAbsolute(path) ? path : `${root}/${path}`)
144
145// Project-relative path with forward slashes, or undefined for a path outside the project.
146const relativeTo = (root: string, path: string): string | undefined => {
147  const normalizedRoot = root.replace(/\\/g, '/').replace(/\/+$/, '')
148  const normalized = path.replace(/\\/g, '/')
149  if (!isAbsolute(path)) return normalized.replace(/^\.\//, '')
150  return normalized.startsWith(`${normalizedRoot}/`) ? normalized.slice(normalizedRoot.length + 1) : undefined
151}
152
153const loadSpec = async ($: EngineInterface, root: string): Promise<Spec | undefined> => {
154  const text = await $.fs.read(`${root}/${SPEC_PATH}`).catch(() => undefined)
155  return typeof text === 'string' ? parseSpec(text) : undefined
156}
157
158const showStatus = ($: EngineInterface, spec: Spec | undefined): void => {
159  if (state.isOff) return $.ui.status('guard off')
160  const checks = state.lastResult === 'pass' ? '✓ checks' : state.lastResult === 'fail' ? '✗ checks' : 'checks –'
161  const specPart = spec === undefined ? 'no spec' : `spec: ${spec.scope.length || 'any'} scope`
162  $.ui.status(`guard · ${specPart} · ${checks}`)
163}
164
165const missingPackage = async (
166  $: EngineInterface,
167  config: Config,
168  absolutePath: string,
169  names: readonly string[],
170): Promise<string | undefined> => {
171  if (names.length === 0) return undefined
172  const dirs = parentDirs(absolutePath)
173  const declared = new Set<string>()
174  const aliases: string[] = []
175  let hasPackageJson = false
176  for (const dir of dirs) {
177    const packageJson = await $.fs.read(`${dir}/package.json`).catch(() => undefined)
178    if (typeof packageJson === 'string') {
179      hasPackageJson = true
180      for (const name of declaredDependencies(packageJson)) declared.add(name)
181    }
182    const tsconfig = await $.fs.read(`${dir}/tsconfig.json`).catch(() => undefined)
183    if (typeof tsconfig === 'string') aliases.push(...pathAliases(tsconfig))
184  }
185  if (!hasPackageJson) return undefined
186
187  for (const name of names) {
188    const isKnown =
189      declared.has(name) ||
190      config.allowImports.has(name) ||
191      aliases.some(alias => alias !== '' && (name === alias || name.startsWith(`${alias}/`)))
192    if (isKnown) continue
193    let isInstalled = false
194    for (const dir of dirs) {
195      if (await $.fs.exists(`${dir}/node_modules/${name}/package.json`)) {
196        isInstalled = true
197        break
198      }
199    }
200    if (!isInstalled) return name
201  }
202  return undefined
203}
204
205// A crew specialist's worktree carries a ticket marker at its root; the ticket, not the spec, decides what it may edit.
206const findMarker = async ($: EngineInterface, absolutePath: string): Promise<{ root: string; marker: Marker } | undefined> => {
207  for (const dir of parentDirs(absolutePath)) {
208    if (!(await $.fs.exists(`${dir}/${MARKER}`))) continue
209    const text = await $.fs.read(`${dir}/${MARKER}`).catch(() => undefined)
210    const marker = typeof text === 'string' ? parseMarker(text) : undefined
211    return marker === undefined ? undefined : { root: dir, marker }
212  }
213  return undefined
214}
215
216const isInCrewWorktree = async ($: EngineInterface, path: string): Promise<boolean> =>
217  (await findMarker($, absoluteIn(await $.session.root(), path))) !== undefined
218
219const judgeEdit = async (
220  $: EngineInterface,
221  config: Config,
222  path: string,
223  newCode: string,
224  oldCode: string,
225): Promise<string | undefined> => {
226  const sessionRoot = await $.session.root()
227  const crew = await findMarker($, absoluteIn(sessionRoot, path))
228  const root = crew?.root ?? sessionRoot
229  const relative = relativeTo(root, path)
230  if (relative === undefined) return undefined
231
232  if (isLockfile(relative)) {
233    return `guardrails: don't hand-edit ${relative}. Change the manifest and let the package manager regenerate it.`
234  }
235  if (crew !== undefined) {
236    const reason = judgeOwnership(relative, crew.marker)
237    if (reason !== undefined) return `guardrails: ${reason}.`
238  } else {
239    const verdict = judgeScope(relative, await loadSpec($, root))
240    if (!verdict.isAllowed) return `guardrails: ${verdict.reason}. Don't edit it. ${SUGGEST}`
241  }
242  if (!isSourceFile(relative) && !isCoverageConfig(relative)) return undefined
243
244  const weakening = judgeWeakening(newCode, oldCode, relative)
245  if (weakening !== undefined) {
246    return `guardrails: this edit ${weakening}. Fix the cause instead. If it is truly needed, explain why under "Suggestions:" and let the user decide.`
247  }
248  if (!isSourceFile(relative)) return undefined
249  const missing = await missingPackage($, config, absoluteIn(root, path), addedPackages(newCode, oldCode))
250  if (missing !== undefined) {
251    return `guardrails: '${missing}' is not installed or declared in any package.json above ${relative}. Don't guess packages: use an installed one, or propose adding it under "Suggestions:".`
252  }
253  return undefined
254}
255
256const rememberLookup = (input: unknown): void => {
257  state.lookups = [...state.lookups, JSON.stringify(input).slice(0, LOOKUP_CHARS)].slice(-MAX_LOOKUPS)
258}
259
260// A large new file built on a library the session never looked at is often a hand-rolled copy of an API the library
261// already has. Held once; writing it again goes through.
262const holdForResearch = (config: Config, path: string, content: string): string | undefined => {
263  if (config.researchHoldLines === 0 || !isSourceFile(path) || state.heldWrites.has(path)) return undefined
264  const lines = content.split('\n').length
265  if (lines < config.researchHoldLines) return undefined
266  const names = unresearchedPackages(content, state.lookups, config.allowImports)
267  if (names.length === 0) return undefined
268  state.heldWrites.add(path)
269  const list = names.map(name => `'${name}'`).join(', ')
270  return (
271    `guardrails: this new ${lines}-line file builds on ${list}, which this session has not looked up yet. ` +
272    `Check for an existing API first: the docs, the examples in its repo, and its source under node_modules/${names[0]}. ` +
273    'Prefer what the library already provides over custom code. If nothing fits, write the file again and it will go through.'
274  )
275}
276
277type CheckOutcome = { failure: string | undefined; count: number }
278
279// Changes to the working tree from any source, shell edits included. Undefined outside git or when the output was cut.
280const fingerprint = async ($: EngineInterface): Promise<string | undefined> => {
281  const root = await $.session.root()
282  const git = (args: readonly string[], stdin?: string) =>
283    $.process.run(['git', ...args], { cwd: root, timeoutMs: 15_000, ...(stdin === undefined ? {} : { stdin }) }).catch(() => undefined)
284  if ((await git(['rev-parse', '--is-inside-work-tree']))?.exitCode !== 0) return undefined
285  const [tree, diff, others] = await Promise.all([
286    git(['rev-parse', 'HEAD^{tree}']),
287    git(['diff', 'HEAD', '--no-color', '--no-ext-diff']),
288    git(['ls-files', '--others', '--exclude-standard', '-z']),
289  ])
290  if (diff?.isStdoutTruncated === true || others?.isStdoutTruncated === true) return undefined
291  const untracked = untrackedPaths(others?.exitCode === 0 ? others.stdout : '')
292  const blobs = untracked.length === 0 ? undefined : await git(['hash-object', '--stdin-paths'], untracked.join('\n'))
293  return composeFingerprint(tree?.exitCode === 0 ? tree.stdout : '', diff?.exitCode === 0 ? diff.stdout : '', untracked, blobs?.stdout ?? '')
294}
295
296const MAX_TURN_FILES = 200
297const RESTORE = 'Restore it. If it is truly needed, say why under "Suggestions:" and let the user decide.'
298
299const gitText = async ($: EngineInterface, args: readonly string[], stdin?: string): Promise<string | undefined> => {
300  const ran = await $.process
301    .run(['git', ...args], { cwd: await $.session.root(), timeoutMs: 15_000, ...(stdin === undefined ? {} : { stdin }) })
302    .catch(() => undefined)
303  return ran?.exitCode === 0 ? ran.stdout : undefined
304}
305
306const untrackedBlobs = async ($: EngineInterface): Promise<Record<string, string>> => {
307  const paths = untrackedPaths((await gitText($, ['ls-files', '--others', '--exclude-standard', '-z'])) ?? '').slice(0, MAX_TURN_FILES)
308  if (paths.length === 0) return {}
309  const blobs = ((await gitText($, ['hash-object', '--stdin-paths'], paths.join('\n'))) ?? '').trim().split('\n')
310  return Object.fromEntries(paths.map((path, i) => [path, blobs[i] ?? '']))
311}
312
313// A commit holding the working tree (`git stash create` touches no refs) plus the untracked files' blobs.
314const captureTurnBase = async ($: EngineInterface): Promise<TurnBase | undefined> => {
315  if ((await gitText($, ['rev-parse', '--is-inside-work-tree'])) === undefined) return undefined
316  const commit = (await gitText($, ['stash', 'create']))?.trim() || (await gitText($, ['rev-parse', 'HEAD']))?.trim()
317  return commit === undefined || commit === '' ? undefined : { commit, untracked: await untrackedBlobs($) }
318}
319
320const turnChanges = async ($: EngineInterface, base: TurnBase): Promise<TurnChange[]> => {
321  const root = await $.session.root()
322  const read = async (path: string): Promise<string> => {
323    const text = await $.fs.read(`${root}/${path}`).catch(() => '')
324    return typeof text === 'string' ? text : ''
325  }
326  const tracked: TurnChange[] = []
327  for (const change of parseNameStatus((await gitText($, ['diff', '--name-status', '-M', base.commit])) ?? '').slice(0, MAX_TURN_FILES)) {
328    tracked.push({
329      path: change.path,
330      status: change.status,
331      oldText: change.status === 'added' ? '' : ((await gitText($, ['show', `${base.commit}:${change.oldPath}`])) ?? ''),
332      newText: change.status === 'deleted' ? '' : await read(change.path),
333    })
334  }
335  const now = await untrackedBlobs($)
336  const added: TurnChange[] = []
337  for (const [path, blob] of Object.entries(now)) {
338    if (base.untracked[path] !== blob) added.push({ path, status: 'added', oldText: '', newText: await read(path) })
339  }
340  const removed = Object.keys(base.untracked)
341    .filter(path => now[path] === undefined && !tracked.some(change => change.path === path))
342    .map((path): TurnChange => ({ path, status: 'deleted', oldText: '', newText: '' }))
343  return [...tracked, ...added, ...removed]
344}
345
346const isUnchanged = (current: string | undefined, needsFull = false): boolean =>
347  (!needsFull || state.lastPassFull) && current !== undefined && state.lastResult === 'pass' && current === state.passFp
348
349const MAX_BLOCK_LINES = 2000
350
351// Shared with the other tools' runtime, so `doctor` counts every block, hold and send-back in one place.
352const recordBlock = async ($: EngineInterface, tool: string, reason: string): Promise<void> => {
353  try {
354    const [modsHome, home, now] = await Promise.all([$.env.get('CLAUDE_MODS_HOME'), $.env.get('HOME'), $.clock.now()])
355    const dir = modsHome ?? (home === undefined ? undefined : `${home}/.claude-mods`)
356    if (dir === undefined) return
357    const path = `${dir}/blocks.log`
358    const current = await $.fs.read(path).catch(() => '')
359    const lines = (typeof current === 'string' ? current : '').split('\n').filter(line => line !== '').slice(-MAX_BLOCK_LINES)
360    await $.fs.write(path, `${[...lines, JSON.stringify(blockEntry(tool, reason, new Date(now)))].join('\n')}\n`)
361  } catch {
362    // Logging must never break a hook.
363  }
364}
365
366const denied = async ($: EngineInterface, tool: string, deny: string): Promise<{ deny: string }> => {
367  await recordBlock($, tool, deny)
368  return { deny }
369}
370
371type Executed = { failure: string | undefined; labels: readonly string[] }
372
373const resolveCommands = async ($: EngineInterface, mode: CheckMode) => {
374  const root = await $.session.root()
375  const [spec, packageJson, entries] = await Promise.all([
376    loadSpec($, root),
377    $.fs.read(`${root}/package.json`).catch(() => undefined),
378    $.fs.list(root).catch(() => []),
379  ])
380  const commands = resolveChecks(
381    spec?.checks ?? [],
382    typeof packageJson === 'string' ? packageJson : undefined,
383    new Set(entries.map(entry => entry.name)),
384    mode,
385  )
386  return { root, spec, commands }
387}
388
389// Runs the checks and reports; changes no state, so a background run can do it too.
390const executeChecks = async ($: EngineInterface, config: Config, mode: CheckMode, isQuiet = false): Promise<Executed> => {
391  const { root, commands } = await resolveCommands($, mode)
392  const runs: CheckRun[] = []
393  for (const command of commands) {
394    if (!isQuiet) $.ui.status(`guard · running ${command.label}`)
395    const ran = await $.process
396      .run(command.argv, { cwd: root, timeoutMs: config.checkTimeoutMs, env: { ...CHECK_ENV } })
397      .catch((error: unknown) => ({ exitCode: 127, stdout: '', stderr: String(error) }))
398    runs.push({ label: command.label, exitCode: ran.exitCode, output: `${ran.stdout}\n${ran.stderr}` })
399  }
400  const failure = summarizeFailures(runs, FAILURE_LINES)
401  return { failure: failure === '' ? undefined : failure, labels: commands.map(command => command.label) }
402}
403
404// The end-of-turn checks started after edits, keyed by the working tree they ran on. In memory: the module lives as
405// long as the session.
406type Background = { fingerprint: string; done: Promise<Executed | undefined> }
407const background: { current: Background | undefined; generation: number; isRunning: boolean; isRerunWanted: boolean } = {
408  current: undefined,
409  generation: 0,
410  isRunning: false,
411  isRerunWanted: false,
412}
413const BACKGROUND_DELAY_MS = 1_500
414
415const isBackgroundOn = async ($: EngineInterface, config: Config): Promise<boolean> => {
416  if (!config.stopGate || config.backgroundChecks === 'off') return false
417  if (config.backgroundChecks === 'on') return true
418  return (await resolveCommands($, 'turn')).commands.some(command => isChangedOnly(command.label))
419}
420
421const startBackground = async ($: EngineInterface, config: Config): Promise<void> => {
422  const tree = await fingerprint($)
423  if (tree === undefined || background.current?.fingerprint === tree) return
424  if (background.isRunning) {
425    background.isRerunWanted = true
426    return
427  }
428  background.isRunning = true
429  const done = executeChecks($, config, 'turn', true)
430    .then(async result => ((await fingerprint($)) === tree ? result : undefined))
431    .catch(() => undefined)
432    .finally(() => {
433      background.isRunning = false
434      if (background.isRerunWanted) {
435        background.isRerunWanted = false
436        scheduleBackground($, config)
437      }
438    })
439  background.current = { fingerprint: tree, done }
440}
441
442// Debounced: only the last edit in a burst starts a run.
443const scheduleBackground = ($: EngineInterface, config: Config): void => {
444  const generation = ++background.generation
445  $.clock.after(BACKGROUND_DELAY_MS, () => {
446    if (generation !== background.generation) return
447    void isBackgroundOn($, config).then(isOn => (isOn ? startBackground($, config) : undefined)).catch(() => undefined)
448  })
449}
450
451const backgroundResult = async (tree: string | undefined): Promise<Executed | undefined> =>
452  tree !== undefined && background.current?.fingerprint === tree ? background.current.done : undefined
453
454const runChecks = async ($: EngineInterface, config: Config, mode: CheckMode): Promise<CheckOutcome> => {
455  const ready = mode === 'turn' ? await backgroundResult(await fingerprint($)) : undefined
456  const { failure, labels } = ready ?? (await executeChecks($, config, mode))
457  state.lastResult = failure === undefined ? 'pass' : 'fail'
458  state.isDirty = failure !== undefined
459  state.passFp = failure === undefined ? await fingerprint($) : undefined
460  state.lastPassFull = failure === undefined && (mode === 'full' || !labels.some(isChangedOnly))
461  showStatus($, await loadSpec($, await $.session.root()))
462  return { failure, count: labels.length }
463}
464
465const runChecksIfNeeded = async ($: EngineInterface, config: Config): Promise<string | undefined> => {
466  if (!state.isDirty && state.lastResult === 'pass' && state.passFp === undefined) return undefined
467  if (isUnchanged(await fingerprint($), true)) {
468    state.isDirty = false
469    return undefined
470  }
471  return (await runChecks($, config, 'full')).failure
472}
473
474const NO_CHECKS =
475  'guardrails: this repo has no checks to run (no `checks:` in .claude/spec.md and no typecheck, lint or test script), so the end-of-turn gate passes without testing anything.'
476
477// A reply that says tests pass, fixed or verified is held to the checks once per turn; unverified claims get them run now.
478const judgeClaim = async ($: EngineInterface, config: Config, message: string | undefined, current: string | undefined): Promise<string | undefined> => {
479  if (message === undefined || state.claimChecked) return undefined
480  const claim = claimedSuccess(message)
481  if (claim === undefined) return undefined
482  state.claimChecked = true
483  if (isUnchanged(current)) return undefined
484  const wasDirty = state.isDirty
485  const { failure } = await runChecks($, config, 'turn')
486  // The claim gets one send-back; it doesn't arm the edit gate's retries on a turn that changed nothing.
487  state.isDirty = wasDirty
488  return failure === undefined
489    ? undefined
490    : `guardrails: your reply says "${claim}", but the checks fail. Correct the reply to say what actually passes and what doesn't (or fix it first).\n${failure}`
491}
492
493const holdNewTask = async ($: EngineInterface, config: Config, text: string): Promise<string | undefined> => {
494  if (text.length < NEW_TASK_MIN_CHARS) return undefined
495  const [{ context }, now] = await Promise.all([$.session.usage(), $.clock.now()])
496  if ((context.percent ?? 0) < config.newTaskPercent) return undefined
497  if (state.heldPrompt?.text === text && now - state.heldPrompt.at < HELD_PROMPT_MS) {
498    state.heldPrompt = undefined
499    return undefined
500  }
501  state.heldPrompt = { text, at: now }
502  return (
503    `guardrails: context is at ${context.percent}%. A new task here will thrash compaction. ` +
504    '/clear first (then /handoff-load if you need the old thread), or send the same prompt again to go ahead.'
505  )
506}
507
508const saveSpecDraft = async ($: EngineInterface, text: string): Promise<void> => {
509  if (text.length < SPEC_DRAFT_MIN_CHARS) return
510  const root = await $.session.root()
511  const path = `${root}/${SPEC_PATH}`
512  if (await $.fs.exists(path)) return
513  await $.fs.write(path, draftSpec(text))
514  $.ui.toast(`Saved your long prompt as ${SPEC_PATH}. Add scope: globs there to enforce it.`, { timeoutMs: 10_000 })
515}
516
517const guardStatusText = async ($: EngineInterface): Promise<string> => {
518  const spec = await loadSpec($, await $.session.root())
519  const lines = [
520    state.isOff ? 'OFF for this session' : 'on',
521    spec === undefined
522      ? `no ${SPEC_PATH}: scope unrestricted`
523      : `scope: ${spec.scope.join(', ') || 'anywhere'} · forbid: ${spec.forbid.join(', ') || 'none'} · checks: ${spec.checks.join(', ') || 'typecheck + lint + test'}`,
524    `checks: ${state.lastResult ?? 'not run'}${state.isDirty ? ' (edits since)' : ''}`,
525  ]
526  return lines.join('\n')
527}
528
529export const register: Register = (on, options) => {
530  // Plugin options first; a profile (user, then repo) overrides them at session start.
531  let config = readConfig(options)
532
533  on('session.start', async ($, e, next) => {
534    const result = await next(e)
535    config = withProfile(readConfig(options), await loadProfile($).catch(() => ({})))
536    await Promise.all([
537      $.command.register({ name: 'guard', description: 'Guardrails: status, or `on` / `off` for this session.', argumentHint: '[on|off]' }),
538      $.command.register({ name: 'checks', description: 'Run the spec checks (or typecheck + lint + test) now.' }),
539      $.command.register({ name: 'ship', description: 'Run checks; if they pass, commit, push and open a draft PR.' }),
540    ])
541    showStatus($, await loadSpec($, e.cwd))
542    return result
543  })
544
545  on('prompt.compose', async ($, e, next) => {
546    const result = await next(e)
547    if (!config.promptRule) return result
548    return { ...result, sections: [...result.sections, { id: 'guardrails:rule', text: RULE, scope: 'session' as const }] }
549  })
550
551  on('tool.call', async ($, e, next) => {
552    if (!EDIT_TOOLS.has(e.tool)) rememberLookup(e)
553    return next(e)
554  }).catch(($, e, next) => next(e))
555
556  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
557    if (state.isOff) return next(e)
558    const deny = await judgeEdit($, config, e.file_path, e.new_string, e.old_string)
559    if (deny !== undefined) return denied($, 'Edit', deny)
560    const result = await next(e)
561    if (result.deny === undefined && result.isError !== true && !(await isInCrewWorktree($, e.file_path))) {
562      state.isDirty = true
563      scheduleBackground($, config)
564    }
565    return result
566  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'guardrails: the edit check failed, so the edit is held.' }))
567
568  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
569    if (state.isOff) return next(e)
570    const isNew = !(await $.fs.exists(e.file_path))
571    const existing = isNew ? '' : await $.fs.read(e.file_path).catch(() => '')
572    const deny =
573      (await judgeEdit($, config, e.file_path, e.content, typeof existing === 'string' ? existing : '')) ??
574      (isNew ? holdForResearch(config, e.file_path, e.content) : undefined)
575    if (deny !== undefined) return denied($, 'Write', deny)
576    const result = await next(e)
577    if (result.deny === undefined && result.isError !== true && !(await isInCrewWorktree($, e.file_path))) {
578      state.isDirty = true
579      scheduleBackground($, config)
580    }
581    return result
582  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'guardrails: the write check failed, so the write is held.' }))
583
584  on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
585    if (state.isOff) return next(e)
586    const deny = await judgeEdit($, config, e.notebook_path, e.new_source, '')
587    if (deny !== undefined) return denied($, 'NotebookEdit', deny)
588    const result = await next(e)
589    if (result.deny === undefined && result.isError !== true) state.isDirty = true
590    return result
591  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'guardrails: the notebook check failed, so the edit is held.' }))
592
593  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
594    if (state.isOff) return next(e)
595    const reason = judgeCommand(e.command)
596    if (reason !== undefined) {
597      return denied($, 'Bash', `guardrails: blocked \`${clip(e.command)}\`: ${reason}. If the user wants it, they can run it with !.`)
598    }
599    const prose = config.writingGate ? judgeProse(e.command) : undefined
600    if (prose !== undefined) return denied($, 'Bash', `guardrails: ${prose}. ${REWORD}`)
601    if (isGitCommit(e.command)) {
602      const failure = await runChecksIfNeeded($, config)
603      if (failure !== undefined) return denied($, 'Bash', `guardrails: commit blocked, checks fail:\n${failure}`)
604    }
605    return next(e)
606  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'guardrails: the command check failed, so the command is held.' }))
607
608  on('classic.Stop', async ($, e, next) => {
609    if (state.isOff) return next(e)
610    if (state.turnBase !== undefined && !state.turnChangesChecked) {
611      state.turnChangesChecked = true
612      const reason = judgeTurnChanges(await turnChanges($, state.turnBase))
613      if (reason !== undefined) {
614        const block = `guardrails: this turn ${reason}. ${RESTORE}`
615        await recordBlock($, 'Stop', block)
616        return { block }
617      }
618    }
619    if (!config.stopGate) return next(e)
620    const current = await fingerprint($)
621    const isChanged = state.isDirty || (current !== undefined && state.turnStartFp !== undefined && current !== state.turnStartFp)
622    if (isChanged && isUnchanged(current)) {
623      state.isDirty = false
624      state.stopBlocks = 0
625    } else if (isChanged) {
626      const { failure, count } = await runChecks($, config, 'turn')
627      if (failure === undefined) {
628        state.stopBlocks = 0
629        if (count === 0 && !state.warnedNoChecks) {
630          state.warnedNoChecks = true
631          $.ui.log(NO_CHECKS)
632          await recordBlock($, 'Stop', NO_CHECKS)
633        }
634      } else if (state.stopBlocks >= MAX_STOP_BLOCKS) {
635        state.stopBlocks = 0
636        const gaveUp = `guardrails: checks still fail after ${MAX_STOP_BLOCKS} retries; stopping so you can decide.\n${failure}`
637        $.ui.log(gaveUp)
638        await recordBlock($, 'Stop', gaveUp)
639      } else {
640        state.stopBlocks += 1
641        const block = `guardrails: checks fail; fix them before finishing (retry ${state.stopBlocks}/${MAX_STOP_BLOCKS}).\n${failure}`
642        await recordBlock($, 'Stop', block)
643        return { block }
644      }
645    }
646    const claim = await judgeClaim($, config, e.last_assistant_message, current)
647    if (claim === undefined) return next(e)
648    await recordBlock($, 'Stop', claim)
649    return { block: claim }
650  }).catch(($, e, next) => next(e))
651
652  on('prompt.submit', async ($, e, next) => {
653    if (state.isOff || e.command !== undefined || e.isMeta === true || !TYPED_ORIGINS.has(e.origin.kind)) return next(e)
654    const hold = await holdNewTask($, config, e.text)
655    if (hold !== undefined) {
656      await recordBlock($, 'prompt', hold)
657      return { drop: hold }
658    }
659    if (config.draftSpecs) await saveSpecDraft($, e.text)
660    state.claimChecked = false
661    state.turnChangesChecked = false
662    state.turnBase = await captureTurnBase($)
663    if (config.stopGate) state.turnStartFp = await fingerprint($)
664    return next(e)
665  }).catch(($, e, next) => next(e))
666
667  on('command.run', { command: 'guard' }, async ($, e) => {
668    const arg = e.args.trim().toLowerCase()
669    if (arg === 'off') state.isOff = true
670    if (arg === 'on') state.isOff = false
671    showStatus($, await loadSpec($, await $.session.root()))
672    return { text: await guardStatusText($) }
673  })
674
675  on('command.run', { command: 'checks' }, async $ => {
676    const { failure } = await runChecks($, config, 'full')
677    return { text: failure === undefined ? '✓ checks pass' : `checks fail\n${failure}` }
678  })
679
680  on('command.run', { command: 'ship' }, async $ => {
681    const { failure } = await runChecks($, config, 'full')
682    if (failure !== undefined) return { text: `not shipping, checks fail\n${failure}` }
683    // A command cannot start a turn while it holds this one; the timer submits once it has returned.
684    $.clock.after(SHIP_DELAY_MS, () => {
685      $.prompt.submit({ text: SHIP_PROMPT }).catch(error => $.ui.log(`guardrails: /ship could not start: ${String(error)}`))
686    })
687    return { text: '✓ checks pass, shipping.' }
688  })
689}
690
hooks/checks.ts 106 lines
1// Generated from core/checks.ts by scripts/sync-core.mjs. Edit core/checks.ts, then run the script.
2export type CheckCommand = { label: string; argv: readonly string[] }
3
4export type CheckRun = { label: string; exitCode: number; output: string }
5
6const DEFAULT_SCRIPTS = ['typecheck', 'lint', 'test'] as const
7
8export const tokenize = (command: string): string[] =>
9  [...command.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map(match => match[1] ?? match[2] ?? match[3] ?? '')
10
11export const packageManager = (files: ReadonlySet<string>): string => {
12  if (files.has('pnpm-lock.yaml')) return 'pnpm'
13  if (files.has('bun.lockb') || files.has('bun.lock')) return 'bun'
14  if (files.has('yarn.lock')) return 'yarn'
15  return 'npm'
16}
17
18// npm init's placeholder test script always fails, so it doesn't count as a check.
19const isPlaceholder = (command: unknown): boolean => typeof command === 'string' && /no test specified/.test(command)
20
21const readScripts = (packageJson: string | undefined): Record<string, string> => {
22  if (packageJson === undefined) return {}
23  try {
24    const scripts = (JSON.parse(packageJson) as { scripts?: Record<string, unknown> }).scripts ?? {}
25    return Object.fromEntries(
26      Object.entries(scripts).filter((entry): entry is [string, string] => typeof entry[1] === 'string' && !isPlaceholder(entry[1])),
27    )
28  } catch {
29    return {}
30  }
31}
32
33// `turn`: the end-of-turn gate, where a plain vitest or jest script runs only the tests related to changed files.
34// `full`: commits and merges, which run the whole test script.
35export type CheckMode = 'turn' | 'full'
36
37const EXEC: Readonly<Record<string, readonly string[]>> = {
38  npm: ['npx', '--no-install'],
39  pnpm: ['pnpm', 'exec'],
40  yarn: ['yarn'],
41  bun: ['bunx'],
42}
43
44// Only scripts that are the bare runner; anything with its own flags or wrappers runs as written.
45const changedOnly = (script: string): readonly string[] | undefined => {
46  const trimmed = script.trim()
47  if (/^vitest(\s+run)?$/.test(trimmed)) return ['vitest', 'run', '--changed']
48  if (trimmed === 'jest') return ['jest', '--onlyChanged']
49  return undefined
50}
51
52// A check that ran only the tests related to changed files; a commit still needs the full suite after it.
53export const isChangedOnly = (label: string): boolean => /--changed\b|--onlyChanged\b/.test(label)
54
55export const resolveChecks = (
56  specChecks: readonly string[],
57  packageJson: string | undefined,
58  rootFiles: ReadonlySet<string>,
59  mode: CheckMode = 'full',
60): CheckCommand[] => {
61  if (specChecks.length > 0) return specChecks.map(command => ({ label: command, argv: tokenize(command) }))
62  const scripts = readScripts(packageJson)
63  const manager = packageManager(rootFiles)
64  return DEFAULT_SCRIPTS.filter(name => scripts[name] !== undefined).map(name => {
65    const runner = mode === 'turn' && name === 'test' ? changedOnly(scripts[name] ?? '') : undefined
66    if (runner !== undefined) return { label: runner.join(' '), argv: [...(EXEC[manager] ?? EXEC.npm ?? []), ...runner] }
67    return { label: `${manager} run ${name}`, argv: [manager, 'run', name] }
68  })
69}
70
71export const summarizeFailures = (runs: readonly CheckRun[], lines: number): string =>
72  runs
73    .filter(run => run.exitCode !== 0)
74    .map(run => {
75      const tail = run.output.trimEnd().split('\n').slice(-lines).join('\n')
76      return `✗ ${run.label} (exit ${run.exitCode})\n${tail}`
77    })
78    .join('\n\n')
79
80export const isTestCommand = (label: string): boolean => /\b(test|tests|vitest|jest|mocha|ava|playwright|cypress|pytest|go test|cargo test)\b/.test(label)
81
82// Test runners that default to watch mode run once when CI is set.
83export const CHECK_ENV: Readonly<Record<string, string>> = { CI: 'true' }
84
85// Untracked files that belong elsewhere: Claude Code's subagent worktrees live inside the repo.
86export const untrackedPaths = (lsFilesZ: string): string[] =>
87  lsFilesZ
88    .split('\0')
89    .filter(path => path !== '' && !path.startsWith('.claude/worktrees/'))
90    .slice(0, 2000)
91
92// Not cryptographic: two FNV-1a lanes, enough to tell whether the working tree changed.
93export const hashText = (text: string): string => {
94  let a = 0x811c9dc5
95  let b = 0x01000193 ^ text.length
96  for (let i = 0; i < text.length; i++) {
97    const code = text.charCodeAt(i)
98    a = Math.imul(a ^ code, 0x01000193) >>> 0
99    b = Math.imul(b ^ code, 0x5bd1e995) >>> 0
100  }
101  return `${a.toString(16).padStart(8, '0')}${b.toString(16).padStart(8, '0')}`
102}
103
104export const composeFingerprint = (tree: string, diff: string, untracked: readonly string[], blobs: string): string =>
105  hashText([tree, diff, untracked.join('\0'), blobs].join('\n\0\n'))
106
hooks/imports.ts 112 lines
1// Generated from core/imports.ts by scripts/sync-core.mjs. Edit core/imports.ts, then run the script.
2const SOURCE_FILE = /\.(c|m)?(t|j)sx?$/
3
4const BUILTINS: ReadonlySet<string> = new Set([
5  'assert', 'async_hooks', 'buffer', 'child_process', 'cluster', 'console', 'constants', 'crypto', 'dgram',
6  'diagnostics_channel', 'dns', 'domain', 'events', 'fs', 'http', 'http2', 'https', 'inspector', 'module', 'net',
7  'os', 'path', 'perf_hooks', 'process', 'punycode', 'querystring', 'readline', 'repl', 'stream', 'string_decoder',
8  'sys', 'test', 'timers', 'tls', 'trace_events', 'tty', 'url', 'util', 'v8', 'vm', 'wasi', 'worker_threads', 'zlib',
9])
10
11const LOCAL_PREFIXES = ['.', '/', '#', '~', '@/', 'virtual:', 'node:', 'bun:', 'data:', 'http:', 'https:', 'npm:', 'jsr:']
12
13// Value imports only: `import type` and `export type` never reach the runtime.
14const IMPORT_PATTERNS = [
15  /\bimport\s+(?!type\b)(?:[\w*{}\s,$]+\s+from\s+)?['"]([^'"]+)['"]/g,
16  /\bexport\s+(?!type\b)[\w*{}\s,$]+\s+from\s+['"]([^'"]+)['"]/g,
17  /\brequire\(\s*['"]([^'"]+)['"]\s*\)/g,
18  /\bimport\(\s*['"]([^'"]+)['"]\s*\)/g,
19]
20
21export const isSourceFile = (path: string): boolean => SOURCE_FILE.test(path)
22
23export const packageName = (specifier: string): string | undefined => {
24  if (LOCAL_PREFIXES.some(prefix => specifier.startsWith(prefix))) return undefined
25  const segments = specifier.split('/')
26  const name = specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]
27  if (name === undefined || name === '' || BUILTINS.has(name)) return undefined
28  return name
29}
30
31export const importedPackages = (code: string): string[] => {
32  const names = new Set<string>()
33  for (const pattern of IMPORT_PATTERNS) {
34    for (const match of code.matchAll(pattern)) {
35      const name = packageName(match[1] ?? '')
36      if (name !== undefined) names.add(name)
37    }
38  }
39  return [...names]
40}
41
42// Imports the edit adds: those in the new text that the replaced text did not already have.
43export const addedPackages = (newCode: string, oldCode: string): string[] => {
44  const before = new Set(importedPackages(oldCode))
45  return importedPackages(newCode).filter(name => !before.has(name))
46}
47
48export const declaredDependencies = (packageJson: string): Set<string> => {
49  const names = new Set<string>()
50  try {
51    const parsed: unknown = JSON.parse(packageJson)
52    if (typeof parsed !== 'object' || parsed === null) return names
53    const record = parsed as Record<string, unknown>
54    if (typeof record.name === 'string') names.add(record.name)
55    for (const field of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
56      const deps = record[field]
57      if (typeof deps === 'object' && deps !== null) for (const name of Object.keys(deps)) names.add(name)
58    }
59  } catch {
60    return names
61  }
62  return names
63}
64
65const stripJsonComments = (text: string): string =>
66  text.replace(/("(?:\\.|[^"\\])*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (_, quoted: string | undefined) => quoted ?? '')
67
68// tsconfig `paths` keys (`@app/*` → `@app`) count as local aliases, not packages.
69export const pathAliases = (tsconfig: string): string[] => {
70  try {
71    const parsed: unknown = JSON.parse(stripJsonComments(tsconfig).replace(/,\s*([}\]])/g, '$1'))
72    const paths = (parsed as { compilerOptions?: { paths?: Record<string, unknown> } }).compilerOptions?.paths
73    return paths === undefined ? [] : Object.keys(paths).map(key => key.replace(/\/\*$/, '').replace(/\*$/, ''))
74  } catch {
75    return []
76  }
77}
78
79export const parentDirs = (filePath: string): string[] => {
80  const dirs: string[] = []
81  let dir = filePath.replace(/[\\/][^\\/]*$/, '')
82  while (dir !== '' && !dirs.includes(dir)) {
83    dirs.push(dir)
84    const parent = dir.replace(/[\\/][^\\/]*$/, '')
85    if (parent === dir) break
86    dir = parent
87  }
88  if (filePath.startsWith('/') && !dirs.includes('/')) dirs.push('/')
89  return dirs
90}
91
92// Frameworks almost every file imports; holding each new component for `react` would only be noise.
93const UBIQUITOUS: ReadonlySet<string> = new Set(['react', 'react-dom', 'preact', 'vue', 'svelte', 'solid-js', 'next', '@angular/core'])
94
95// A scoped package also counts by its scope (`@tldraw/editor` → "tldraw"), which is how people search for it.
96const searchTerms = (name: string): string[] => {
97  const scope = name.startsWith('@') ? name.split('/')[0]?.slice(1) : undefined
98  return [name, ...(scope !== undefined && scope.length >= 3 ? [scope] : [])]
99}
100
101const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
102
103export const isResearched = (name: string, seen: readonly string[]): boolean =>
104  searchTerms(name).some(term => {
105    const pattern = new RegExp(`(^|[^\\w-])${escapeRegExp(term)}($|[^\\w-])`, 'i')
106    return seen.some(text => pattern.test(text))
107  })
108
109// Packages a new file builds on that the session has not looked up yet (docs, source, examples, a search).
110export const unresearchedPackages = (code: string, seen: readonly string[], allowed: ReadonlySet<string>): string[] =>
111  importedPackages(code).filter(name => !UBIQUITOUS.has(name) && !allowed.has(name) && !isResearched(name, seen))
112
hooks/rules.ts 209 lines
1// Generated from core/rules.ts by scripts/sync-core.mjs. Edit core/rules.ts, then run the script.
2import { isSourceFile } from './imports.ts'
3
4type Rule = { pattern: RegExp; reason: string; isTypeScriptOnly?: true }
5
6const COMMAND_RULES: readonly Rule[] = [
7  { pattern: /--no-verify\b/, reason: 'skips the hooks that guard commits; fix what they report instead' },
8  { pattern: /\bgit\s+commit\b[^|;&]*\s-[a-zA-Z]*n[a-zA-Z]*\b/, reason: '`-n` skips the commit hooks; fix what they report instead' },
9  {
10    pattern: /\bgit\s+push\b[^|;&]*(\s--force(?!-with-lease)\b|\s-[a-zA-Z]*f\b)/,
11    reason: 'force-pushing rewrites shared history; use --force-with-lease on your own branch only if the user asks',
12  },
13  { pattern: /\bgit\s+reset\s+[^|;&]*--hard\b/, reason: 'destroys uncommitted work' },
14  { pattern: /\bgit\s+(checkout|restore)\s+(--\s+)?\.(\s|$)/, reason: 'discards every uncommitted change' },
15  { pattern: /\bgit\s+clean\s+-[a-zA-Z]*f/, reason: 'deletes untracked files for good' },
16  { pattern: /\brm\s+-[a-zA-Z]*r[a-zA-Z]*\s+(\/|~|\$HOME)(\/?\s|\/?$)/, reason: 'deletes a home or root directory' },
17  {
18    pattern: /\b(vitest|jest|playwright)\b[^|;&]*\s(-u|--update|--updateSnapshot|--update-snapshots)\b/,
19    reason: 'rewrites snapshots to match the current output; fix the code, or ask the user to approve new snapshots',
20  },
21  {
22    pattern: /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b[^|;&]*\s(-u|--update|--updateSnapshot|--update-snapshots)\b/,
23    reason: 'rewrites snapshots to match the current output; fix the code, or ask the user to approve new snapshots',
24  },
25]
26
27export const judgeCommand = (command: string): string | undefined =>
28  COMMAND_RULES.find(rule => rule.pattern.test(command))?.reason
29
30export const isGitCommit = (command: string): boolean => /\bgit\s+commit\b/.test(command)
31
32const WEAKENING: readonly Rule[] = [
33  { pattern: /\b(it|test|describe)\.(skip|only)\s*\(/, reason: 'skips or isolates tests' },
34  { pattern: /\b(xit|xdescribe|xtest)\s*\(/, reason: 'skips tests' },
35  { pattern: /@ts-(ignore|nocheck|expect-error)\b/, reason: 'silences the type checker' },
36  { pattern: /eslint-disable/, reason: 'silences the linter' },
37  { pattern: /\bas\s+any\b|:\s*any\b(?!\w)|<any>/, reason: 'adds an `any` type', isTypeScriptOnly: true },
38  {
39    pattern: /\b(vi|jest)\.(mock|doMock)\(\s*['"`]\.\.?\//,
40    reason: 'mocks one of your own modules; mock only system boundaries (network, time, filesystem) and test the real code',
41  },
42]
43
44const TEST_FILE = /(\.(test|spec)\.[cm]?[jt]sx?$)|((^|\/)(__tests__|tests?)\/.+\.[cm]?[jt]sx?$)/
45const ASSERTION = /\bexpect\s*\(|\bassert(\.\w+)?\s*\(|\.should\b/g
46
47const countAssertions = (code: string): number => code.match(ASSERTION)?.length ?? 0
48
49const COVERAGE_CONFIG = /(^|\/)((vitest|vite|jest)\.config\.[cm]?[jt]s|jest\.config\.json|package\.json|\.nycrc(\.json)?)$/
50const THRESHOLD = /\b(lines|branches|functions|statements)['"]?\s*:\s*(\d+(\.\d+)?)/g
51
52const thresholds = (code: string): Map<string, number> => {
53  const found = new Map<string, number>()
54  for (const match of code.matchAll(THRESHOLD)) {
55    const key = match[1] ?? ''
56    const value = Number(match[2])
57    found.set(key, Math.min(found.get(key) ?? value, value))
58  }
59  return found
60}
61
62// Test-runner config is not source, but lowering its coverage thresholds weakens the checks all the same.
63export const isCoverageConfig = (path: string): boolean => COVERAGE_CONFIG.test(path)
64
65const loweredThreshold = (newCode: string, oldCode: string): string | undefined => {
66  const after = thresholds(newCode)
67  for (const [key, before] of thresholds(oldCode)) {
68    const now = after.get(key)
69    if (now !== undefined && now < before) return `lowers the ${key} coverage threshold from ${before} to ${now}`
70  }
71  return undefined
72}
73
74const LOCKFILES = /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|Cargo\.lock|poetry\.lock|uv\.lock)$/
75
76export const isLockfile = (path: string): boolean => LOCKFILES.test(path)
77
78// Only what the edit adds counts, so touching a file that already has a suppression is fine.
79export const judgeWeakening = (newCode: string, oldCode: string, path: string): string | undefined => {
80  if (COVERAGE_CONFIG.test(path)) {
81    const lowered = loweredThreshold(newCode, oldCode)
82    if (lowered !== undefined) return lowered
83  }
84  if (TEST_FILE.test(path) && countAssertions(newCode) < countAssertions(oldCode)) {
85    return `removes assertions from a test (${countAssertions(oldCode)} → ${countAssertions(newCode)})`
86  }
87  const isTypeScript = /\.(c|m)?tsx?$/.test(path)
88  for (const rule of WEAKENING) {
89    if (rule.isTypeScriptOnly === true && !isTypeScript) continue
90    const count = (code: string): number => code.split('\n').filter(line => rule.pattern.test(line)).length
91    if (count(newCode) > count(oldCode)) return rule.reason
92  }
93  return undefined
94}
95
96// Commands whose text lands in front of people: commit messages, tags, PR and issue text.
97const PROSE_COMMAND = /\bgit\s+(commit|tag)\b|\bgh\s+(pr|issue)\s+(create|edit|comment|review)\b/
98
99// Few and unambiguous on purpose: each one blocks a commit until the message is reworded.
100const SLOP: readonly { pattern: RegExp; label: string }[] = [
101  { pattern: /—/, label: 'an em dash' },
102  { pattern: /\bdelv(e|es|ed|ing)\b/i, label: '"delve"' },
103  { pattern: /\bseamless(ly)?\b/i, label: '"seamless"' },
104  { pattern: /\bleverag(e|es|ed|ing)\b/i, label: '"leverage"' },
105  { pattern: /\brobust(ly|ness)?\b/i, label: '"robust"' },
106  { pattern: /\bcomprehensive\b/i, label: '"comprehensive"' },
107  { pattern: /\bit['’]?s worth noting\b/i, label: '"it\'s worth noting"' },
108  { pattern: /\b(a testament to|rich tapestry|game[- ]changer|in today['’]s fast-paced)\b/i, label: 'stock AI phrasing' },
109  { pattern: /\bI hope this helps\b/i, label: '"I hope this helps"' },
110]
111
112export const judgeProse = (command: string): string | undefined => {
113  if (!PROSE_COMMAND.test(command)) return undefined
114  const hits = SLOP.filter(rule => rule.pattern.test(command)).map(rule => rule.label)
115  return hits.length === 0 ? undefined : `the message uses ${hits.join(', ')}`
116}
117
118const CLAIM = /\b((all\s+)?(tests?|checks?|specs?|the\s+suite)\s+(now\s+)?(pass(es|ed|ing)?|are\s+(passing|green)|succeed(ed|s)?)|all\s+green|build\s+(passes|succeeds)|everything\s+(passes|works)|verified|fixed)\b/i
119const NEGATED = /(\bnot|n['\u2019]t|\bnever|\bno|\bunable\s+to|\bcould\s+not|\bwithout)\s+(\w+\s+){0,2}$/i
120
121// The first success claim in a reply that isn't negated just before it ("not verified", "couldn't get tests to pass").
122export const claimedSuccess = (text: string): string | undefined => {
123  const pattern = new RegExp(CLAIM.source, 'gi')
124  for (const match of text.matchAll(pattern)) {
125    if (!NEGATED.test(text.slice(Math.max(0, (match.index ?? 0) - 40), match.index))) return match[0]
126  }
127  return undefined
128}
129
130// Which rule produced a block, for the block log; matched on the wording each rule uses.
131const RULE_NAMES: readonly [RegExp, string][] = [
132  [/outside the scope|forbidden by/, 'scope'],
133  [/not owned by ticket|only the crew lead edits/, 'crew-ownership'],
134  [/hand-edit/, 'lockfile'],
135  [/deletes the test file/, 'deleted-test'],
136  [/removes assertions/, 'removed-assertions'],
137  [/coverage threshold/, 'coverage'],
138  [/mocks one of your own modules/, 'own-module-mock'],
139  [/skips|silences|adds an `any`/, 'weakening'],
140  [/not installed or declared/, 'made-up-package'],
141  [/rewrites snapshots/, 'snapshot-update'],
142  [/the message uses/, 'writing'],
143  [/commit blocked/, 'commit-gate'],
144  [/still fail after/, 'stop-gate-gave-up'],
145  [/checks fail; fix them/, 'stop-gate'],
146  [/your reply says/, 'claim'],
147  [/has not looked up yet/, 'research-hold'],
148  [/no checks to run/, 'no-checks'],
149  [/context is at/, 'context-hold'],
150  [/blocked `/, 'command'],
151]
152
153export const ruleOf = (reason: string): string => RULE_NAMES.find(([pattern]) => pattern.test(reason))?.[1] ?? 'other'
154
155export type BlockEntry = { at: string; tool: string; rule: string; reason: string }
156
157export const blockEntry = (tool: string, reason: string, now: Date): BlockEntry => ({
158  at: now.toISOString(),
159  tool,
160  rule: ruleOf(reason),
161  reason: reason.replace(/^guardrails: /, '').split('\n')[0]?.slice(0, 200) ?? '',
162})
163
164// Counts per rule over the entries newer than `since`, most frequent first.
165export const summarizeBlocks = (logText: string, since: Date): [string, number][] => {
166  const counts = new Map<string, number>()
167  for (const line of logText.split('\n')) {
168    try {
169      const entry = JSON.parse(line) as Partial<BlockEntry>
170      if (typeof entry.rule !== 'string' || typeof entry.at !== 'string' || new Date(entry.at) < since) continue
171      counts.set(entry.rule, (counts.get(entry.rule) ?? 0) + 1)
172    } catch {
173      // A torn line from two hooks writing at once is skipped.
174    }
175  }
176  return [...counts.entries()].sort((a, b) => b[1] - a[1])
177}
178
179export type TurnChange = { path: string; status: 'added' | 'modified' | 'deleted'; oldText: string; newText: string }
180
181// `git diff --name-status -M` lines: renames carry both paths, so a moved test isn't read as a deleted one.
182export type NameStatus = { status: TurnChange['status']; oldPath: string; path: string }
183
184export const parseNameStatus = (output: string): NameStatus[] =>
185  output
186    .split('\n')
187    .filter(line => line.trim() !== '')
188    .map((line): NameStatus => {
189      const [code = '', first = '', second] = line.split('\t')
190      if (code.startsWith('R') || code.startsWith('C')) return { status: 'modified', oldPath: first, path: second ?? first }
191      if (code === 'D') return { status: 'deleted', oldPath: first, path: first }
192      if (code === 'A') return { status: 'added', oldPath: first, path: first }
193      return { status: 'modified', oldPath: first, path: first }
194    })
195
196// Everything a turn changed, whatever tool made the change: deleted tests and the same weakening rules the edit hooks apply.
197export const judgeTurnChanges = (changes: readonly TurnChange[]): string | undefined => {
198  for (const change of changes) {
199    if (change.status === 'deleted') {
200      if (TEST_FILE.test(change.path)) return `deletes the test file ${change.path}`
201      continue
202    }
203    if (!isSourceFile(change.path) && !isCoverageConfig(change.path)) continue
204    const reason = judgeWeakening(change.newText, change.oldText, change.path)
205    if (reason !== undefined) return `${reason} in ${change.path}`
206  }
207  return undefined
208}
209
hooks/crew.ts 202 lines
1// Generated from core/crew.ts by scripts/sync-core.mjs. Edit core/crew.ts, then run the script.
2import { globToRegExp, matchesAny } from './spec.ts'
3
4export type Ticket = {
5  id: string
6  title: string
7  role: string
8  owns: readonly string[]
9  reads: readonly string[]
10  blockedBy: readonly string[]
11  checks: readonly string[]
12  body: string
13}
14
15export type CrewPlan = {
16  goal: string
17  shared: readonly string[]
18  checks: readonly string[]
19  setup: string | undefined
20  // Unset means the profile's crew cap, else DEFAULT_CAP.
21  cap: number | undefined
22}
23
24export type TicketStatus = 'queued' | 'running' | 'merged' | 'failed'
25
26// Written at the root of a specialist's worktree by `crew claim`; the edit hooks read it to enforce ownership.
27export type Marker = { feature: string; ticket: string; owns: readonly string[]; shared: readonly string[] }
28
29export const MARKER = '.crew-ticket.json'
30export const CREW_DIR = '.claude/crew'
31export const DEFAULT_CAP = 8
32export const ROLES = ['designer', 'frontend', 'backend', 'tester', 'docs', 'reviewer'] as const
33
34// Files every ticket tends to touch. Only the lead edits them, between waves, so parallel tickets never collide there.
35export const DEFAULT_SHARED: readonly string[] = ['package.json', '**/package.json', 'tsconfig*.json', '**/tsconfig*.json', '.github/**', MARKER]
36
37const unquote = (value: string): string => value.trim().replace(/^(['"])(.*)\1$/, '$2')
38
39const inlineList = (value: string): string[] =>
40  value
41    .replace(/^\[|\]$/g, '')
42    .split(',')
43    .map(unquote)
44    .filter(item => item !== '')
45
46// `key: value`, `key: [a, b]`, or `key:` followed by `- item` lines. Every value comes back as a list.
47export const readFrontmatter = (text: string): { fields: Record<string, string[]>; body: string } => {
48  const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text)
49  if (match === null) return { fields: {}, body: text.trim() }
50  const fields: Record<string, string[]> = {}
51  let current: string | undefined
52  for (const line of (match[1] ?? '').split(/\r?\n/)) {
53    const item = /^\s*-\s+(.+)$/.exec(line)
54    if (item !== null && current !== undefined) {
55      fields[current]?.push(unquote(item[1] ?? ''))
56      continue
57    }
58    const pair = /^([A-Za-z_]+):\s*(.*)$/.exec(line)
59    if (pair === null) continue
60    current = pair[1] ?? ''
61    const value = (pair[2] ?? '').trim()
62    fields[current] = value === '' ? [] : value.startsWith('[') ? inlineList(value) : [unquote(value)]
63  }
64  return { fields, body: (match[2] ?? '').trim() }
65}
66
67export const parseTicket = (id: string, text: string): Ticket => {
68  const { fields, body } = readFrontmatter(text)
69  const heading = /^#\s+(.+)$/m.exec(body)?.[1]
70  return {
71    id,
72    title: fields.title?.[0] ?? heading ?? id,
73    role: fields.role?.[0] ?? '',
74    owns: fields.owns ?? [],
75    reads: fields.reads ?? [],
76    blockedBy: fields.blockedBy ?? [],
77    checks: fields.checks ?? [],
78    body,
79  }
80}
81
82export const parsePlan = (text: string): CrewPlan => {
83  const { fields, body } = readFrontmatter(text)
84  const cap = Number(fields.cap?.[0])
85  return {
86    goal: fields.goal?.[0] ?? body.split('\n')[0] ?? '',
87    shared: [...DEFAULT_SHARED, ...(fields.shared ?? [])],
88    checks: fields.checks ?? [],
89    setup: fields.setup?.[0],
90    cap: Number.isInteger(cap) && cap > 0 ? cap : undefined,
91  }
92}
93
94// The literal part of a glob before its first wildcard, cut back to a whole path segment.
95const staticPrefix = (glob: string): string => {
96  const cut = glob.replace(/^\.\//, '').search(/[*?[{]/)
97  const literal = cut === -1 ? glob.replace(/^\.\//, '') : glob.replace(/^\.\//, '').slice(0, cut)
98  return cut === -1 ? literal : literal.slice(0, literal.lastIndexOf('/') + 1)
99}
100
101// Conservative: two globs may overlap when either's literal prefix contains the other's. False alarms only delay a ticket.
102export const mayOverlap = (a: readonly string[], b: readonly string[]): boolean =>
103  a.some(left =>
104    b.some(right => {
105      const [p, q] = [staticPrefix(left), staticPrefix(right)]
106      if (!/[*?[{]/.test(left)) return globToRegExp(right).test(p)
107      if (!/[*?[{]/.test(right)) return globToRegExp(left).test(q)
108      return p.startsWith(q) || q.startsWith(p)
109    }),
110  )
111
112const ancestors = (id: string, byId: ReadonlyMap<string, Ticket>, seen = new Set<string>()): Set<string> => {
113  for (const blocker of byId.get(id)?.blockedBy ?? []) {
114    if (seen.has(blocker)) continue
115    seen.add(blocker)
116    ancestors(blocker, byId, seen)
117  }
118  return seen
119}
120
121const findCycle = (tickets: readonly Ticket[], byId: ReadonlyMap<string, Ticket>): string[] | undefined => {
122  const state = new Map<string, 'visiting' | 'done'>()
123  const visit = (id: string, path: string[]): string[] | undefined => {
124    if (state.get(id) === 'done') return undefined
125    if (state.get(id) === 'visiting') return [...path.slice(path.indexOf(id)), id]
126    state.set(id, 'visiting')
127    for (const blocker of byId.get(id)?.blockedBy ?? []) {
128      const cycle = visit(blocker, [...path, id])
129      if (cycle !== undefined) return cycle
130    }
131    state.set(id, 'done')
132    return undefined
133  }
134  for (const ticket of tickets) {
135    const cycle = visit(ticket.id, [])
136    if (cycle !== undefined) return cycle
137  }
138  return undefined
139}
140
141export const validateGraph = (tickets: readonly Ticket[], plan: CrewPlan): { errors: string[]; warnings: string[] } => {
142  const errors: string[] = []
143  const warnings: string[] = []
144  const byId = new Map(tickets.map(ticket => [ticket.id, ticket]))
145  if (tickets.length === 0) errors.push('no tickets')
146  if (byId.size !== tickets.length) errors.push('two tickets share an id')
147  for (const ticket of tickets) {
148    if (!(ROLES as readonly string[]).includes(ticket.role)) errors.push(`${ticket.id}: role must be one of ${ROLES.join(', ')}`)
149    if (ticket.owns.length === 0 && ticket.role !== 'reviewer') errors.push(`${ticket.id}: owns is empty`)
150    for (const blocker of ticket.blockedBy) if (!byId.has(blocker)) errors.push(`${ticket.id}: blockedBy ${blocker}, which does not exist`)
151    const sharedOwned = ticket.owns.filter(glob => plan.shared.some(shared => !/[*?]/.test(shared) && globToRegExp(glob).test(shared)))
152    if (sharedOwned.length > 0) errors.push(`${ticket.id}: owns shared files (${sharedOwned.join(', ')}); only the lead edits those`)
153  }
154  const cycle = findCycle(tickets, byId)
155  if (cycle !== undefined) errors.push(`blockedBy cycle: ${cycle.join(' → ')}`)
156  if (cycle === undefined) {
157    for (const [i, a] of tickets.entries()) {
158      for (const b of tickets.slice(i + 1)) {
159        const isOrdered = ancestors(a.id, byId).has(b.id) || ancestors(b.id, byId).has(a.id)
160        if (!isOrdered && mayOverlap(a.owns, b.owns)) warnings.push(`${a.id} and ${b.id} may touch the same files, so they will not run at the same time`)
161      }
162    }
163  }
164  return { errors, warnings }
165}
166
167// Tickets to start now: queued, every blocker merged, no file overlap with anything running, within the cap.
168export const frontier = (tickets: readonly Ticket[], statuses: Readonly<Record<string, TicketStatus>>, cap: number): Ticket[] => {
169  const running = tickets.filter(ticket => statuses[ticket.id] === 'running')
170  const picked: Ticket[] = []
171  for (const ticket of tickets) {
172    if (running.length + picked.length >= cap) break
173    if ((statuses[ticket.id] ?? 'queued') !== 'queued') continue
174    if (!ticket.blockedBy.every(blocker => statuses[blocker] === 'merged')) continue
175    if ([...running, ...picked].some(other => mayOverlap(ticket.owns, other.owns))) continue
176    picked.push(ticket)
177  }
178  return picked
179}
180
181export const outsideOwnership = (relativePaths: readonly string[], marker: Pick<Marker, 'owns' | 'shared'>): { shared: string[]; foreign: string[] } => ({
182  shared: relativePaths.filter(path => matchesAny(path, marker.shared)),
183  foreign: relativePaths.filter(path => !matchesAny(path, marker.shared) && !matchesAny(path, marker.owns)),
184})
185
186export const judgeOwnership = (relativePath: string, marker: Marker): string | undefined => {
187  const { shared, foreign } = outsideOwnership([relativePath], marker)
188  if (shared.length > 0) return `${relativePath} is shared, so only the crew lead edits it. Put the change you need under "Suggestions:"`
189  if (foreign.length > 0) return `${relativePath} is not owned by ticket ${marker.ticket} (owns: ${marker.owns.join(', ')}). Don't edit it; put what it needs under "Suggestions:"`
190  return undefined
191}
192
193export const parseMarker = (text: string): Marker | undefined => {
194  try {
195    const value = JSON.parse(text) as Partial<Marker>
196    if (typeof value.ticket !== 'string' || !Array.isArray(value.owns) || !Array.isArray(value.shared)) return undefined
197    return { feature: String(value.feature ?? ''), ticket: value.ticket, owns: value.owns.map(String), shared: value.shared.map(String) }
198  } catch {
199    return undefined
200  }
201}
202
hooks/profile.ts 79 lines
1// Generated from core/profile.ts by scripts/sync-core.mjs. Edit core/profile.ts, then run the script.
2// One file that sets skills and gate settings for every surface: Claude Code plugins, the other tools' hooks, the
3// installer, `project` and crew agents. A repo's `.claude/claude-mods.json` (committed, the team's) overrides the
4// user's `~/.claude-mods/profile.json` key by key.
5
6export type GuardrailsSettings = {
7  stopGate?: boolean
8  writingGate?: boolean
9  researchHoldLines?: number
10  checkTimeoutMinutes?: number
11  backgroundChecks?: 'auto' | 'on' | 'off'
12}
13
14export type Profile = {
15  skills?: string
16  roles?: Readonly<Record<string, string>>
17  guardrails?: GuardrailsSettings
18  crewCap?: number
19  tokenBudget?: number
20}
21
22export const PROFILE_FILE = '.claude/claude-mods.json'
23export const USER_PROFILE_FILE = 'profile.json'
24export const DEFAULT_TOKEN_BUDGET = 2000
25
26const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
27
28const skillText = (value: unknown): string | undefined =>
29  typeof value === 'string' ? value : Array.isArray(value) && value.every(item => typeof item === 'string') ? value.join(', ') : undefined
30
31const positive = (value: unknown): number | undefined => (typeof value === 'number' && value >= 0 ? value : undefined)
32
33const flag = (value: unknown): boolean | undefined => (typeof value === 'boolean' ? value : undefined)
34
35const backgroundMode = (value: unknown): 'auto' | 'on' | 'off' | undefined =>
36  value === 'auto' || value === 'on' || value === 'off' ? value : undefined
37
38const withoutUndefined = <T extends Record<string, unknown>>(record: T): T =>
39  Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T
40
41// Unknown keys and wrong types are dropped rather than trusted, so a typo can't switch a gate off by accident.
42export const parseProfile = (text: string | undefined): Profile => {
43  if (text === undefined) return {}
44  let parsed: unknown
45  try {
46    parsed = JSON.parse(text)
47  } catch {
48    return {}
49  }
50  if (!isRecord(parsed)) return {}
51  const roles = isRecord(parsed.roles)
52    ? Object.fromEntries(Object.entries(parsed.roles).flatMap(([role, skills]) => (skillText(skills) === undefined ? [] : [[role, skillText(skills) ?? '']])))
53    : undefined
54  const guardrails = isRecord(parsed.guardrails)
55    ? withoutUndefined({
56        stopGate: flag(parsed.guardrails.stopGate),
57        writingGate: flag(parsed.guardrails.writingGate),
58        researchHoldLines: positive(parsed.guardrails.researchHoldLines),
59        checkTimeoutMinutes: positive(parsed.guardrails.checkTimeoutMinutes),
60        backgroundChecks: backgroundMode(parsed.guardrails.backgroundChecks),
61      })
62    : undefined
63  const crew = isRecord(parsed.crew) ? parsed.crew : {}
64  return withoutUndefined({
65    skills: skillText(parsed.skills),
66    roles,
67    guardrails,
68    crewCap: positive(crew.cap),
69    tokenBudget: positive(parsed.tokenBudget),
70  })
71}
72
73export const mergeProfiles = (user: Profile, repo: Profile): Profile => ({
74  ...user,
75  ...repo,
76  roles: { ...user.roles, ...repo.roles },
77  guardrails: { ...user.guardrails, ...repo.guardrails },
78})
79
hooks/spec.ts 97 lines
1// Generated from core/spec.ts by scripts/sync-core.mjs. Edit core/spec.ts, then run the script.
2export type Spec = {
3  scope: readonly string[]
4  forbid: readonly string[]
5  checks: readonly string[]
6  body: string
7}
8
9export const SPEC_PATH = '.claude/spec.md'
10
11const LIST_KEYS = ['scope', 'forbid', 'checks'] as const
12type ListKey = (typeof LIST_KEYS)[number]
13
14const isListKey = (key: string): key is ListKey => (LIST_KEYS as readonly string[]).includes(key)
15
16const unquote = (value: string): string => value.trim().replace(/^(['"])(.*)\1$/, '$2')
17
18const inlineList = (value: string): string[] =>
19  value
20    .replace(/^\[|\]$/g, '')
21    .split(',')
22    .map(unquote)
23    .filter(item => item !== '')
24
25// A small frontmatter reader: `key: [a, b]`, `key: a`, or `key:` followed by `- item` lines.
26export const parseSpec = (text: string): Spec => {
27  const lists: Record<ListKey, string[]> = { scope: [], forbid: [], checks: [] }
28  const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text)
29  if (match === null) return { ...lists, body: text.trim() }
30
31  let current: ListKey | undefined
32  for (const line of (match[1] ?? '').split(/\r?\n/)) {
33    const item = /^\s*-\s+(.+)$/.exec(line)
34    if (item !== null && current !== undefined) {
35      lists[current].push(unquote(item[1] ?? ''))
36      continue
37    }
38    const pair = /^([A-Za-z_]+):\s*(.*)$/.exec(line)
39    if (pair === null) continue
40    const key = pair[1] ?? ''
41    current = isListKey(key) ? key : undefined
42    const value = (pair[2] ?? '').trim()
43    if (current !== undefined && value !== '') lists[current].push(...inlineList(value))
44  }
45  return { ...lists, body: (match[2] ?? '').trim() }
46}
47
48const escapeRegExp = (text: string): string => text.replace(/[.+^${}()|[\]\\]/g, '\\$&')
49
50export const globToRegExp = (glob: string): RegExp => {
51  const normalized = glob.replace(/^\.\//, '').replace(/\/$/, '/**')
52  let source = ''
53  for (let i = 0; i < normalized.length; i++) {
54    const char = normalized[i] ?? ''
55    if (char === '*' && normalized[i + 1] === '*') {
56      const isSegment = normalized[i + 2] === '/'
57      source += isSegment ? '(?:.*/)?' : '.*'
58      i += isSegment ? 2 : 1
59    } else if (char === '*') source += '[^/]*'
60    else if (char === '?') source += '[^/]'
61    else source += escapeRegExp(char)
62  }
63  return new RegExp(`^${source}$`)
64}
65
66export const matchesAny = (path: string, globs: readonly string[]): boolean =>
67  globs.some(glob => globToRegExp(glob).test(path))
68
69export type ScopeVerdict = { isAllowed: true } | { isAllowed: false; reason: string }
70
71export const judgeScope = (relativePath: string, spec: Spec | undefined): ScopeVerdict => {
72  if (spec === undefined) return { isAllowed: true }
73  if (relativePath === SPEC_PATH) return { isAllowed: true }
74  if (matchesAny(relativePath, spec.forbid)) {
75    return { isAllowed: false, reason: `${relativePath} is forbidden by ${SPEC_PATH}` }
76  }
77  if (spec.scope.length > 0 && !matchesAny(relativePath, spec.scope)) {
78    return { isAllowed: false, reason: `${relativePath} is outside the scope in ${SPEC_PATH}` }
79  }
80  return { isAllowed: true }
81}
82
83export const draftSpec = (prompt: string): string =>
84  [
85    '---',
86    '# Paths Claude may edit (globs). Empty means anywhere.',
87    'scope: []',
88    '# Paths Claude must not edit.',
89    'forbid: []',
90    '# Commands that must pass before finishing or committing. Empty means the typecheck, lint and test scripts from package.json.',
91    'checks: []',
92    '---',
93    '',
94    prompt.trim(),
95    '',
96  ].join('\n')
97