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

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.
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.
| Command | Does |
|---|---|
npx --allow-git=all github:effinrich/claude-mods install | install (the default command) |
… update | reinstall with your last choices, after a new version lands |
… uninstall | remove only what it added (hook entries, /ship and /checks, copied skills, the marked instruction block) |
… doctor | what 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 |
… project | add 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.
| Codex | Gemini CLI | Cursor | Copilot 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 /checks | skills ($ship) | /ship command | skills | skills |
| 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.
/hooks.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.
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.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]
| File | What claude-mods writes |
|---|---|
.cursor/rules/claude-mods.mdc | an always-applied Cursor rule (owned by claude-mods) |
AGENTS.md | a marked block at the end; the rest of the file is left as is |
.claude/settings.json | merged in: the claude-mods marketplace under extraKnownMarketplaces and <mod>@claude-mods: true under enabledPlugins |
.claude/claude-mods.json | the 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.
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:
CLAUDE_MODS_DEBUG=1 in the tool's environment, or add "debug": true to ~/.claude-mods/config.json (a reinstall keeps it).~/.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.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.
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.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.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./hooks; they do not run until you do.Then run doctor to check, and the git reset --hard test above.
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.
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
scope or inside forbid. The deny tells Claude to list the change under "Suggestions:" instead, so ideas still reach you.package.json above the file (type-only imports, relatives, tsconfig path aliases and Node builtins are fine)..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.expect(, assert(, .should) or lower a coverage threshold in the test config.vitest -u, jest --updateSnapshot, pnpm test -- -u, playwright --update-snapshots.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 !.git commit while checks fail..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.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
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./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.
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.
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.! lines) are pinned as written, not run.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.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.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%.
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.
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:
package.json, tsconfig*.json, .github/**, plus the plan's shared list) belong to the lead; specialists ask for changes under "Suggestions:".crew merge checks the diff again, so a shell edit can't slip past.worktree.baseRef to "head" in .claude/settings.local.json so their worktrees start from the run branch.Things to know:
setup: in the plan; none turns it off). pnpm keeps that fast..claude/worktrees/. If your test or lint config scans every folder, exclude .claude/.npx --allow-git=all github:effinrich/claude-mods crew status <feature>.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.
These were reviewed for this repo on 2026-10-19. None are bundled.
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./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.plain-writing covers the same ground in about 400./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.context-audit records your current setup on its first run and only reports changes after that, so review your global CLAUDE.md once yourself.claude plugin test <plugin folder>.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.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.hooks/register.ts 690 lines1import 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}
690hooks/checks.ts 106 lines1// 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'))
106hooks/imports.ts 112 lines1// 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))
112hooks/rules.ts 209 lines1// 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}
209hooks/crew.ts 202 lines1// 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}
202hooks/profile.ts 79 lines1// 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})
79hooks/spec.ts 97 lines1// 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