Guardrails, output shaping and telemetry for Claude Code sessions, driven by a per-project policy


A personal Claude Code config, packaged so it installs into any repository and stays up to date there.
It is a set of habits I use every day, written down once and shipped by a CLI:
settings.json for you. Small automatic behaviors on Claude Code events.Not a framework and not a team product. One person's working setup, made installable so twelve repositories cannot quietly become twelve different configurations.
If you work across several repositories and keep telling Claude the same things, this should save you the repeating. If you are looking for a team standard to adopt wholesale, this is not one: take the parts you agree with.
Quickstart · What you get · How it is proven · Staying in sync · Install options · Contributing
Copy-pasted .claude/ config rots. You write a good rule in one project, paste it into the next two, improve it in the third, and six months later no two repositories agree. Nobody remembers which version came from where, and the CI agent works from a config nobody has read since the day it landed.
So the config is a package: an installer that proposes only what a project's stack justifies, a lockfile so a repository that drifts says so, and detach for the artifacts you customized on purpose.
npx ronce-racine plan . # 1. read-only: what fits this repo, and why
npx ronce-racine install . # 2. apply, then review the diff before committing

Nothing is written by plan. Here is a real run on a full-stack repository, abridged:
$ npx ronce-racine plan ./my-app
Analysis of ./my-app
Signals: git (.git/), ci (CI config), infra (Docker/infra), frontend (frontend dependencies), backend (Node backend dependencies), tests (E2E deps), code (sources detected)
✓ Recommended (installed by default):
Rules (10) :
• minimal-code - YAGNI + readability, any code project
• secure-logging - GDPR: never log sensitive data
• subscription-cleanup - frontend: subscription teardown
• clean-architecture-deps - backend: dependency direction
[... 6 more]
Skills (23) :
• recording-decisions - record non-obvious choices
• commit-readiness-review - self-review before commit (+ scripts/precommit-scan.ts)
• bug-triage-structured - full triage of a bug
[... 20 more]
Scripts (1) :
• subscription-leak-scan.ts - detects subscriptions/listeners/timers without teardown
Hooks (3) :
• skill-reminder.ts - suggests the relevant skills for the prompt
[... 2 more]
Agents (2) :
• code-reviewer - diff review agent
• qa-tester - E2E testing agent
• Optional (add with --all):
Rules (1) :
• detection-gap-protocol - P0 found by user = detection failure
Skills (12) :
• audit-industrialisation - maturity audit orchestrator
[... 11 more]
Hooks (3) :
• readme-freshness.ts - re-reads the README against what a push changes
[... 2 more]
(The [... n more] lines mark where this transcript is abridged; a real run prints every entry, then the exact install command to copy.)
Then git add .claude && git commit: the config travels with the repository, so your teammates and your CI agent work from the same one you do.
The installer runs code on your machine and wires hooks into your
settings.json.SECURITY.mdsays exactly what runs and when. Worth two minutes before you install anything, here or anywhere else.
Requirements: Node 18 or later in the target repo. Hooks ship pre-built, rules and skills are plain Markdown; only the optional detection scripts need npx tsx. The hook wirings use the exec form ("command": "node" plus args) and ${CLAUDE_PROJECT_DIR}, which need Claude Code 2.1.139 or later (changelog: args: string[] (exec form) landed there); the optional readme-freshness hook also needs the claude CLI on PATH. Rules and skills alone have no such floor.
Rules are injected automatically based on the files in play, so they cost you nothing to remember. Each one exists because forgetting it produced a real defect.
| Rule | Applies to | What it prevents |
|---|---|---|
commits.md | always | commit messages that nobody can scan six months later |
pre-commit-secret-detection.md | always | a key or token reaching a commit, where a revert no longer saves you |
minimal-code.md | code (rs/ts/tsx/vue/py/go) | speculative code, and its opposite: unreadable one-liners written to save a line |
error-handling-discipline.md | code (ts/js/vue/py/go/rs/php/java) | swallowed errors, and a panic/unwrap on a fallible path |
doc-code-parity.md | README.md, docs/** | a documented command that no longer matches the code, which the reader trusts anyway |
clean-architecture-deps.md | code (ts/py/go/rs/php/java) | business logic leaking into handlers, dependencies pointing away from the domain |
no-raw-sql-interpolation.md | code (ts/js/py/go/rs/php/java) | a query built by string concatenation, which is an injection |
sql-migrations-discipline.md | /migrations/ | a migration edited after it shipped, or one that cannot be replayed |
secure-logging.md | *.rs, *.ts | an email, IP or token in the logs, which is a GDPR problem |
subscription-cleanup.md | frontend (ts/tsx/js/jsx/vue) | subscriptions, listeners and timers with no teardown, and the leaks they cause |
ui-states-complete.md | components (tsx/jsx/vue/svelte) | a screen that handles the happy path and shows nothing while loading or failing |
test-discipline.md | *.spec.ts, *.test.ts | tests that pass without ever having been seen to fail |
detection-gap-protocol.md | docs/postmortems/** | treating a P0 found by a user as a bug rather than as a detection failure |
There is nothing to type. Each skill's description carries trigger phrases, and the agent loads the one that matches what you asked for, in English or in French. Five that show the character of the set:
| You say | What runs | What you get |
|---|---|---|
| "this is broken: ..." | bug-triage-structured | a reproduction, the root cause with file:line, then an argued fix-now-or-open-a-ticket decision |
| "commit this" | commit-readiness-review | the staged diff scanned for secrets, debug leftovers and disabled tests before anything is written |
| "is it really ready?" | adversarial-feature-challenge | the feature attacked from several personas; zero flaws found means a bad challenge, not a perfect feature |
| "three names for the same thing here" | domain-glossary | one name per concept in a GLOSSARY.md, the rejected synonyms recorded so a search for the wrong word still lands right |
| "why is this slow?" | performance-profiling | the noise floor measured before anything else, then a profile, with "inconclusive" as a valid answer |
The full set, grouped by intention:
| Group | Skills | For example |
|---|---|---|
| Designing, before code | 5 | domain-modeling-design, comprehensive-test-strategy |
| Implementing | 6 | ddd-backend-implementation, api-contract-versioning |
| Testing and validating | 4 | writing-robust-tests, visual-regression-check |
| Fixing a bug | 5 | bug-triage-structured, production-incident-diagnostic |
| Reviewing and shipping | 4 | merge-request-review, ci-pipeline-orchestration |
| Sweeping and auditing | 12 | detection-sweep, and the opt-in audit-* family |
What each one is for, in full: docs/catalog.md.
| Hook | Event | Why it is there |
|---|---|---|
skill-reminder.ts | UserPromptSubmit | surfaces the skills matching your prompt, so a relevant one is not missed. Self-maintained, silent when nothing matches |
bash-npm-silent.ts | PreToolUse / Bash | appends --silent to setup npm install / npm ci, which otherwise burn context on noise |
truncate-output.ts (+ truncate-bash-output.ts) | PreToolUse / Bash | caps verbose command output past a threshold, always preserving it on error |
session-writer + session-inject + session-precompact | Stop / SessionStart(compact) / PreCompact | a per-branch session memo: persists the context at session end and re-injects it after a compaction |
worktree-env-setup.ts | SessionStart | symlinks the main repo's .env into the current worktree, so a worktree session is not born broken |
readme-freshness.ts | PreToolUse / Bash | before a git push, has Claude re-read the README against the diff and report the claims it contradicts. Opt-in, warns without blocking |
Every hook fails open: it runs on every session of every repository that installed it, so an error ends in a silent exit 0 rather than blocking you. The two to scrutinize before installing (worktree-env-setup symlinks your .env, the session-memo trio writes to disk) are documented in SECURITY.md.
code-reviewer: architecture, reliability and correctness review of a diff. Read-only, and it ends on an actionable verdict.qa-tester: runs and writes E2E tests, favoring stable locators over timing hacks.subscription-leak-scan.ts: scans a staged frontend diff for subscriptions, listeners and timers with no teardown.A project-specific agent or skill, coupled to your stack and your tracker, takes precedence over its generic version here. Every generic skill says so up front.
A skill nobody can trigger does not exist, and prose cannot be unit-tested. So two things are mechanical here rather than hoped for.
Routing is linted. Ten realistic sentences must reach the right skill ahead of the ones it is confusable with; half of them failed on the first run.
Every skill is measured with and without itself. Each carries a manifest (fixture, prompt, gates derived from its own exit conditions, checks over what the agent DID, a judge for the rest), and the two arms run as paired trials in a container so the only difference between them is the skill. A skill prescribes more than a deliverable - a script to run, a context to gather before acting, an act to refrain from - and none of that survives into the final tree, so those claims are graded from the agent's trajectory instead. Each dimension is read on its own, never averaged, and no lift is claimed below six pairs. It regularly says no: refactoring-shared-component-api is recorded as no measurable lift after three attempts to find one.
What that costs and what else it caught: docs/quality-bar.md · how to run it: docs/evaluating-skills.md.
npx ronce-racine check <repo> # drift vs the canonical source
npx ronce-racine check <repo> --strict # same, but fails CI
npx ronce-racine detach <repo> <token> # exempt an artifact you customized on purpose
The lockfile records what was installed, check --strict turns drift into a failing job, and detach keeps a deliberate local customization from having to lie to the gate. Drop in the anti-drift CI job and the config can no longer rot silently.
Mechanics (the settings.json merge, the two distribution layers, the lockfile states): docs/distribution-model.md · docs/architecture.md.
npx ronce-racine plan <repo> # proposes, read-only
npx ronce-racine install <repo> [--all] # applies (--all = + optionals)
npx ronce-racine install <repo> --yes # no selector: the pre-checked set (CI, non-TTY)
npx ronce-racine install <repo> --pick <kind:name...> # exactly these, e.g. skill:detection-sweep
npx ronce-racine install <repo> --rules-only # rules + lockfile only
npx ronce-racine check <repo> [--strict] # drift vs canonical (warns, or fails CI)
npx ronce-racine detach <repo> <token> # exempt a customized artifact from control
npx ronce-racine uninstall <repo> [--dry-run] # remove what it installed, nothing else
It copies into <repo>/.claude/ and merges the hook wirings into settings.json without disturbing what is already there. Full procedure: docs/adopting-a-repo.md.
Changed your mind? uninstall reverses it (--dry-run first), driven by the lockfile so it removes what it installed and nothing else. It unwires its own hooks from settings.json and leaves the rest of that file alone, keeps anything you detached, and gives you back any file it overwrote on the way in.
Try it against throwaway repositories first with playground/setup.ts.
/plugin marketplace add ChariereFiedler/ronce-racine
/plugin install ronce-racine@ronce-racine
The skills and the agents, subscribed rather than copied: they update when you update the plugin, and nothing lands in your repository. The rules, the hooks and the drift control are deliberately not in it, because always-on context and code that runs on your machine belong in a reviewed diff. Reasoning: docs/distribution-model.md.
Once the ronce mod is installed, each layer has one channel:
| Layer | Channel |
|---|---|
| Rules | the CLI, install <repo> --rules-only, versioned in the repository |
| Skills and agents | the ronce-racine plugin, user scope |
| Hooks | the ronce mod, driven by the repository's .claude/ronce.json |
To move an adopted repository over, run uninstall then install --rules-only.
mods/ronce/ is a Claude Code mod: one in-process plugin of function hooks, meant to take over from a stack of command hooks. It checks tool calls against a per-project policy, measures what output shaping would save, and writes one JSONL telemetry file per turn under ~/.claude/ronce/events/.
Version 0.2 enforces the unambiguous guards by default: shell reads of project files, files created through a heredoc, a destructive git command chained after ;, Claude, AI or LLM in a commit message, and a cd to the current directory (stripped). It shapes long successful outputs, keeping the whole of them under ~/.claude/ronce/outputs/. Every other rule starts in shadow mode, where it records what it would have done and changes nothing; a project's policy switches each rule with "modes": { "<id>": "enforce" }. It does the job of the truncate-output, bash-npm-silent and shell-read-guard hooks: unwire those where the mod runs.
Installed at the user scope, it runs in every project. A project without a policy gets the engine's default rules; "telemetry": null in its policy turns the event files off.
Version 0.3 also does what the session hooks did, each one a key of the policy:
| Key | What it does | Default |
|---|---|---|
worktree | Links the main checkout's .env (or the files listed) into a linked worktree | on |
memo | After a compaction, appends the branch, the ticket, the last request and a stale decisions file | on |
band | A line above the prompt: project, branch, context, cost, budgets | on |
freshness | Fetches the base branch at session start and warns when the local copy lags | off |
agentBudget | Refuses a dev subagent past the open merge request or worktree cap; agent types that only read are exempt | off |
skillHints | Adds a skill suggestion to a prompt that matches a pattern | none |
skillCatalog | Otherwise, names the skills whose quoted triggers or name words the prompt holds, from the project's .claude/skills and the ronce-racine plugin's catalog; this is the skill-reminder hook | on, 3 at most |
checks.onEdit | After an edit, runs the checks whose match fits the file and hands any finding to the model | rustfmt, ESLint, Biome on scripts/, ast-grep |
checks.background | After a turn, runs the checks of the changed files in the background, once per state of those files; a finding wakes the session (at most maxWakes times in a row) | vitest related |
A check is a command with placeholders ({file}, {rel}, {files}, {group}), a directory, a timeout and the rule that turns its output into a finding; it is skipped where its tool or its configuration is missing. A check marked deferIfBusy waits for the next turn while a heavy process (busyPattern) runs, so two sessions on the same machine do not compile at once; onlyIfFilesContain keeps a costly check for the changes it is about (a SQL check for the files that hold a query).
Install it from a local clone. A folder marketplace is read in place, so what runs is exactly what you have checked out, and /reload-plugins picks up an edit:
claude plugin marketplace add /path/to/ronce-racine
claude plugin install ronce@ronce-racine
A project's rules live in its own .claude/ronce.json, in a reviewed diff like any other code. Two tools work on that policy and on what the mod records:
npx tsx tools/ronce-policy.ts <repo>/.claude/ronce.json # loads it as the mod does: rules, patterns, examples
npx tsx tools/ronce-report.ts --since 2026-10-08 # calls, failures, shell reads, repetitions, $ per 1 000 calls
In a session, /ronce-hits lists the rules that fired, shadow ones included.
One admission bar: an artifact lives here only if it is truly generic.
CONTRIBUTING.md for the bar and the workflow · docs/writing-a-rule.md · docs/writing-a-skill.md · docs/evaluating-skills.md · docs/fixtures.md for where the eval fixtures come from · docs/measuring-prose.md for the method, reusable without this code · docs/developing.md for working on the toolkit itself · docs/decisions.md for why things are the way they are · AGENTS.md if you are the agent reading this.
Ronce Racine is French for "bramble root", a plant that roots deep, spreads everywhere and refuses to die. A fitting name for a layer of engineering discipline meant to take hold in every repository, with just enough thorns to remind you it says "discipline".
MIT licensed. Built by Cedric Chariere Fiedler.
hooks/register.tsx 644 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
3import type {
4 AgentBudgetConfig,
5 BandStatus,
6 BudgetSnapshot,
7 CatalogSkill,
8 CheckSpec,
9 FreshnessConfig,
10 HitStats,
11 LoadedPolicy,
12 MemoConfig,
13 MergeRequestBudget,
14 SessionFacts,
15 SkillCatalogSource,
16 ToolEvent,
17 WorktreeBudget,
18} from '../types'
19import { backgroundRuns, changedPaths, checksForPath, expandRun, findingOf, parseEnv, reachTarget, reportText, scopeKey } from '../src/checks/plan.ts'
20import { activeWorktrees, blockingMergeRequests, budgetDenial, isExempt } from '../src/features/budget.ts'
21import type { OpenMergeRequest, WorktreeState } from '../src/features/budget.ts'
22import { envValue } from '../src/features/env-file.ts'
23import { freshnessNote } from '../src/features/freshness.ts'
24import { memoText, ticketFrom } from '../src/features/memo.ts'
25import { catalogDir, catalogMatches, catalogText, mergeCatalogs, parseSkill } from '../src/features/skill-catalog.ts'
26import { hintFor, hintText } from '../src/features/skill-hints.ts'
27import { linksToCreate } from '../src/features/worktree-env.ts'
28import { DEFAULT_RULES } from '../src/policy/defaults.ts'
29import { decide, evaluate } from '../src/policy/evaluate.ts'
30import { formatHits, mergeHits, recordHits, sampleOf } from '../src/policy/hits.ts'
31import { loadPolicy } from '../src/policy/load.ts'
32import { policyCandidates } from '../src/policy/locate.ts'
33import { allowedRuleIds } from '../src/policy/normalize.ts'
34import { baseName, expandHome, parentDir } from '../src/policy/paths.ts'
35import { toRawCall } from '../src/policy/raw-call.ts'
36import { fullOutputPath, planShaping } from '../src/shaping/truncate.ts'
37import { appendCapped, batchPath, toJsonl, toolEvent, turnEvent } from '../src/telemetry/event.ts'
38import type { ToolOutcome } from '../src/telemetry/event.ts'
39import { bandLine, projectTitle } from '../src/ui/band.ts'
40
41// Declared here, not imported: the engine lists the values a module reads and writes from the atoms of its own file.
42const policyState = atom({ plugin: 'ronce', key: 'policy' } as const, null)
43const factsState = atom({ plugin: 'ronce', key: 'facts' } as const, null)
44const telemetryState = atom({ plugin: 'ronce', key: 'telemetry' } as const, [])
45const hitsState = atom({ plugin: 'ronce', key: 'hits' } as const, {})
46const flushSeqState = atom({ plugin: 'ronce', key: 'flushSeq' } as const, 0)
47const lastIntentState = atom({ plugin: 'ronce', key: 'lastIntent' } as const, null)
48const budgetState = atom({ plugin: 'ronce', key: 'budget' } as const, null)
49const bandState = atom({ plugin: 'ronce', key: 'band' } as const, null)
50const checkScopeState = atom({ plugin: 'ronce', key: 'checkScope' } as const, null)
51const wakesState = atom({ plugin: 'ronce', key: 'wakes' } as const, 0)
52const catalogState = atom({ plugin: 'ronce', key: 'catalog' } as const, null)
53
54const HITS_STORE_KEY = 'hits'
55const BAND_REFRESH_MS = 60_000
56const AGENT_BUDGET_RULE = 'agent-budget'
57const GIT_TIMEOUT_MS = 10_000
58const FETCH_TIMEOUT_MS = 8_000
59const MINUTE_MS = 60_000
60const DAY_MS = 86_400_000
61const WORKTREE_INFIX = '-wt-'
62const PROBE_TIMEOUT_MS = 2_000
63const BUSY_PROBE_TIMEOUT_MS = 5_000
64// A module variable on purpose: a reload starts with no background run, which is the truth.
65let isBackgroundRunning = false
66
67// The engine follows `$` only into functions declared in this file, never across an import:
68// every function below that touches `$` lives here, and the logic it calls lives in src/.
69
70/** `git -C <cwd> <args>`, its trimmed output, or null when git fails or cannot start. */
71async function gitOut($: EngineInterface, cwd: string, args: readonly string[], timeoutMs = GIT_TIMEOUT_MS): Promise<string | null> {
72 try {
73 const run = await $.process.run(['git', '-C', cwd, ...args], { timeoutMs })
74 return run.exitCode === 0 ? run.stdout.trim() : null
75 } catch (error) {
76 $.ui.log(`ronce : git ${args.join(' ')} n'a pas abouti (${String(error)})`, { to: 'debug' })
77 return null
78 }
79}
80
81async function repoRoots($: EngineInterface, cwd: string): Promise<SessionFacts['roots']> {
82 const worktreeRoot = await gitOut($, cwd, ['rev-parse', '--show-toplevel'])
83 const commonDir = await gitOut($, cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
84 if (!worktreeRoot || !commonDir) return undefined
85 return { worktreeRoot, mainRoot: commonDir.replace(/\/\.git\/?$/, '') }
86}
87
88/** Links the files a linked worktree misses, in the session's worktree and in `.claude/worktrees/*`. */
89async function linkWorktreeFiles($: EngineInterface, roots: NonNullable<SessionFacts['roots']>, names: readonly string[]): Promise<void> {
90 const agentWorktrees = `${roots.mainRoot}/.claude/worktrees`
91 const nested = (await $.fs.exists(agentWorktrees))
92 ? (await $.fs.list(agentWorktrees)).filter(entry => entry.kind === 'dir').map(entry => `${agentWorktrees}/${entry.name}`)
93 : []
94 for (const worktreeRoot of [roots.worktreeRoot, ...nested]) {
95 const existing = new Set<string>()
96 for (const name of names) {
97 for (const path of [`${worktreeRoot}/${name}`, `${roots.mainRoot}/${name}`]) if (await $.fs.exists(path)) existing.add(path)
98 }
99 for (const { link, target } of linksToCreate({ worktreeRoot, mainRoot: roots.mainRoot, names, exists: p => existing.has(p) })) {
100 await $.process.run(['ln', '-s', target, link])
101 }
102 }
103}
104
105/** Fetches the base branch and, when the local copy lags, tells the model before it starts. */
106async function warnIfStale($: EngineInterface, cwd: string, freshness: FreshnessConfig): Promise<void> {
107 const { remote, branch: base } = freshness
108 await gitOut($, cwd, ['fetch', '-q', remote, base], FETCH_TIMEOUT_MS)
109 const behind = Number(await gitOut($, cwd, ['rev-list', '--count', `${base}..${remote}/${base}`]))
110 const ahead = Number((await gitOut($, cwd, ['rev-list', '--count', `${remote}/${base}..${base}`])) ?? 0)
111 const branch = (await gitOut($, cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])) ?? '?'
112 const note = freshnessNote({ behind, ahead, branch, remote, base })
113 if (!note) return
114 $.ui.toast(note.split('\n')[0]!)
115 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: note }] } })
116}
117
118async function mergeRequestBudget($: EngineInterface, budget: MergeRequestBudget, mainRoot: string): Promise<BudgetSnapshot['mergeRequests']> {
119 const tokenFile = `${mainRoot}/${budget.tokenFile}`
120 const token = (await $.fs.exists(tokenFile)) ? envValue(await $.fs.read(tokenFile), budget.tokenVar) : null
121 if (!token) return undefined
122 const url = `${budget.gitlabUrl}/api/v4/projects/${encodeURIComponent(budget.project)}/merge_requests?state=opened&per_page=100`
123 const response = await $.http.fetch(url, { headers: { 'PRIVATE-TOKEN': token } })
124 if (!response.ok) return undefined
125 const open = JSON.parse(response.text) as OpenMergeRequest[]
126 const blocking = blockingMergeRequests(open.map(mr => ({ ...mr, labels: mr.labels ?? [] })), budget.humanGatedLabels)
127 return { count: blocking.length, cap: budget.cap, iids: blocking.map(mr => mr.iid) }
128}
129
130async function worktreeState($: EngineInterface, path: string, name: string, baseRef: string, now: number): Promise<WorktreeState> {
131 if (!(await $.fs.exists(`${path}/.git`))) return { name, isRepo: false, commitsAhead: null, hasChanges: null, ageMinutes: 0 }
132 const ahead = await gitOut($, path, ['rev-list', '--count', 'HEAD', `^${baseRef}`])
133 const status = await gitOut($, path, ['status', '--porcelain'])
134 const modifiedAt = (await $.fs.stat(`${path}/.git`)).mtimeMs
135 return {
136 name,
137 isRepo: true,
138 commitsAhead: ahead === null ? null : Number(ahead),
139 hasChanges: status === null ? null : status.length > 0,
140 ageMinutes: (now - modifiedAt) / MINUTE_MS,
141 }
142}
143
144async function worktreeBudget($: EngineInterface, budget: WorktreeBudget, mainRoot: string, now: number): Promise<BudgetSnapshot['worktrees']> {
145 const parent = parentDir(mainRoot)
146 const prefix = `${baseName(mainRoot)}${WORKTREE_INFIX}`
147 const entries = (await $.fs.list(parent)).filter(entry => entry.name.startsWith(prefix) && (entry.kind === 'dir' || entry.isLink))
148 const states: WorktreeState[] = []
149 for (const entry of entries) states.push(await worktreeState($, `${parent}/${entry.name}`, entry.name, budget.baseRef, now))
150 const active = activeWorktrees(states, budget.emptyAgeMinutes)
151 return { count: active.length, cap: budget.cap, names: active.map(w => w.name) }
152}
153
154/** The budget reading, served from the session's cache while it is younger than `cacheMs`. */
155async function readBudget($: EngineInterface, budget: AgentBudgetConfig, mainRoot: string): Promise<BudgetSnapshot> {
156 const now = await $.clock.now()
157 const cached = await read($, budgetState)
158 if (cached && now - cached.at < budget.cacheMs) return cached
159 const snapshot: BudgetSnapshot = { at: now }
160 if (budget.mergeRequests) snapshot.mergeRequests = await mergeRequestBudget($, budget.mergeRequests, mainRoot)
161 if (budget.worktrees) snapshot.worktrees = await worktreeBudget($, budget.worktrees, mainRoot, now)
162 await update($, budgetState, () => snapshot)
163 return snapshot
164}
165
166async function appendMemo($: EngineInterface, facts: SessionFacts, memo: MemoConfig, lastIntent: string | null): Promise<void> {
167 const branch = (await gitOut($, facts.cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])) ?? '?'
168 const decisionsPath = memo.decisionsFile && facts.roots ? `${facts.roots.worktreeRoot}/${memo.decisionsFile}` : null
169 let decisions: { file: string; ageDays: number; staleDays: number } | undefined
170 if (decisionsPath && memo.decisionsFile && (await $.fs.exists(decisionsPath))) {
171 const ageDays = Math.floor(((await $.clock.now()) - (await $.fs.stat(decisionsPath)).mtimeMs) / DAY_MS)
172 decisions = { file: memo.decisionsFile, ageDays, staleDays: memo.staleDays }
173 }
174 const text = memoText({ branch, ticket: ticketFrom(branch, memo.ticketPattern), lastIntent, decisions })
175 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
176}
177
178/** The nearest directory from `dir` up to `root` that holds `marker`; null when none does. */
179async function packageDir($: EngineInterface, dir: string, marker: string, root: string): Promise<string | null> {
180 for (let current = dir; current.startsWith(root); current = parentDir(current)) {
181 if (await $.fs.exists(`${current}/${marker}`)) return current
182 if (current === root) break
183 }
184 return null
185}
186
187async function runDirectory($: EngineInterface, spec: CheckSpec, root: string, firstFile: string): Promise<string | null> {
188 if (spec.cwd !== 'package') return spec.cwd ? `${root}/${spec.cwd}` : root
189 return spec.packageMarker ? packageDir($, parentDir(firstFile), spec.packageMarker, root) : null
190}
191
192/** Runs one check and returns what the model should read of it; a tool that is missing or hangs counts as no finding. */
193async function runCheck(
194 $: EngineInterface,
195 spec: CheckSpec,
196 context: { root: string; cwd: string; argv: string[]; mentions: string[]; facts: SessionFacts; hookInputFile?: string },
197): Promise<string | null> {
198 if (spec.requiresAny) {
199 let isAvailable = false
200 for (const path of spec.requiresAny) if (await $.fs.exists(`${context.cwd}/${path}`)) isAvailable = true
201 if (!isAvailable) return null
202 }
203 const env = spec.envFile
204 ? { ...parseEnv(await $.fs.read(`${context.root}/${spec.envFile}`)), PATH: context.facts.env.PATH ?? '', HOME: context.facts.env.HOME ?? '' }
205 : undefined
206 const stdin = spec.stdin === 'hook-input' ? JSON.stringify({ tool_input: { file_path: context.hookInputFile } }) : undefined
207 try {
208 const run = await $.process.run(context.argv, { cwd: context.cwd, timeoutMs: spec.timeoutMs, ...(env ? { env } : {}), ...(stdin ? { stdin } : {}) })
209 return findingOf(spec, run.exitCode, `${run.stdout}\n${run.stderr}`, context.mentions)
210 } catch (error) {
211 $.ui.log(`ronce : vérification ${spec.id} non lancée (${String(error)})`, { to: 'debug' })
212 return null
213 }
214}
215
216/** The findings of the checks an edited file calls for, worded for the model's context. */
217async function runEditChecks($: EngineInterface, policy: LoadedPolicy, facts: SessionFacts, file: string): Promise<string[]> {
218 const root = facts.roots?.worktreeRoot
219 if (!root || !file.startsWith(`${root}/`)) return []
220 const rel = file.slice(root.length + 1)
221 const findings: string[] = []
222 for (const spec of checksForPath(policy.features.checks.onEdit, rel)) {
223 const cwd = await runDirectory($, spec, root, file)
224 if (!cwd) continue
225 const argv = expandRun(spec.run, { file, rel, files: [file] })
226 const finding = await runCheck($, spec, { root, cwd, argv, mentions: [file, rel], facts, hookInputFile: file })
227 if (finding) findings.push(`ronce/check:${spec.id} : ${spec.title}\n${finding}`)
228 }
229 return findings
230}
231
232async function isMachineBusy($: EngineInterface, pattern: string): Promise<boolean> {
233 try {
234 return (await $.process.run(['pgrep', '-f', pattern], { timeoutMs: BUSY_PROBE_TIMEOUT_MS })).exitCode === 0
235 } catch (error) {
236 $.ui.log(`ronce : pgrep impossible (${String(error)}), machine supposée libre`, { to: 'debug' })
237 return false
238 }
239}
240
241async function isReachable($: EngineInterface, root: string, probe: NonNullable<CheckSpec['skipUnlessReachable']>): Promise<boolean> {
242 const envPath = `${root}/${probe.envFile}`
243 if (!(await $.fs.exists(envPath))) return false
244 const url = parseEnv(await $.fs.read(envPath))[probe.envVar]
245 const target = url ? reachTarget(url, probe.defaultPort) : null
246 if (!target) return false
247 try {
248 const run = await $.process.run(['bash', '-c', `exec 3<>/dev/tcp/${target.host}/${target.port}`], { timeoutMs: PROBE_TIMEOUT_MS })
249 return run.exitCode === 0
250 } catch (error) {
251 $.ui.log(`ronce : ${target.host}:${target.port} injoignable (${String(error)})`, { to: 'debug' })
252 return false
253 }
254}
255
256async function changedScope($: EngineInterface, root: string): Promise<{ changed: string[]; key: string }> {
257 const changed = changedPaths((await gitOut($, root, ['status', '--porcelain', '-uall'])) ?? '')
258 const entries: { path: string; mtimeMs: number }[] = []
259 for (const path of changed) {
260 if (await $.fs.exists(`${root}/${path}`)) entries.push({ path, mtimeMs: (await $.fs.stat(`${root}/${path}`)).mtimeMs })
261 }
262 return { changed, key: scopeKey(entries) }
263}
264
265/** The background checks of the changed files, once per state of those files; a failure reaches the model. */
266async function runBackgroundChecks($: EngineInterface): Promise<void> {
267 const { policy, facts } = await ensureSession($)
268 const root = facts.roots?.worktreeRoot
269 const config = policy.features.checks
270 if (!root || config.background.length === 0) return
271 const { changed, key } = await changedScope($, root)
272 if (changed.length === 0 || key === (await read($, checkScopeState))) return
273 const failures: { title: string; body: string }[] = []
274 // Only the files a content-gated check looks at are read; one that cannot be read counts as empty.
275 const contents = new Map<string, string>()
276 const gated = config.background.filter(spec => spec.onlyIfFilesContain)
277 for (const rel of changed.filter(path => checksForPath(gated, path).length > 0)) {
278 try {
279 contents.set(rel, await $.fs.read(`${root}/${rel}`))
280 } catch (error) {
281 $.ui.log(`ronce : ${rel} illisible pour les vérifications (${String(error)})`, { to: 'debug' })
282 }
283 }
284 for (const run of backgroundRuns(config.background, changed, rel => contents.get(rel))) {
285 const { spec } = run
286 // Deferred: the scope is not recorded, so the next turn tries again.
287 if (spec.deferIfBusy && (await isMachineBusy($, config.busyPattern))) return
288 if (spec.skipUnlessReachable && !(await isReachable($, root, spec.skipUnlessReachable))) continue
289 const files = run.files.map(rel => `${root}/${rel}`)
290 const cwd = await runDirectory($, spec, root, files[0]!)
291 if (!cwd) continue
292 const argv = expandRun(spec.run, { file: files[0], rel: run.files[0], group: run.group, files })
293 const extra = spec.addArgsIfFileMatches
294 if (extra) {
295 const path = `${root}/${extra.file.replaceAll('{group}', run.group ?? '')}`
296 if ((await $.fs.exists(path)) && new RegExp(extra.pattern, 'm').test(await $.fs.read(path))) argv.push(...extra.args)
297 }
298 const fromCwd = cwd === root ? run.files : run.files.map(rel => rel.replace(`${cwd.slice(root.length + 1)}/`, ''))
299 const finding = await runCheck($, spec, { root, cwd, argv, mentions: [...run.files, ...fromCwd], facts })
300 if (finding) failures.push({ title: run.group ? `${spec.title} : ${run.group}` : spec.title, body: finding })
301 }
302 await update($, checkScopeState, () => key)
303 if (failures.length === 0) return
304 const text = reportText(failures)
305 $.ui.toast(`ronce : ${failures.length} vérification(s) en échec sur le scope modifié`)
306 const wakes = await read($, wakesState)
307 if (wakes < config.maxWakes) {
308 await update($, wakesState, n => n + 1)
309 await $.prompt.submit({ text })
310 } else {
311 // Past the cap the session is not woken again: the report waits in the conversation.
312 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
313 }
314}
315
316/** What the band shows; budgets come from the cached reading, refreshed when stale. */
317async function refreshBand($: EngineInterface, policy: LoadedPolicy, facts: SessionFacts): Promise<void> {
318 const usage = await $.session.usage()
319 const root = facts.roots?.mainRoot ?? facts.cwd
320 const status: BandStatus = {
321 project: projectTitle(baseName(root)),
322 branch: (await gitOut($, facts.cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])) ?? undefined,
323 contextPercent: usage.context.percent,
324 costUsd: usage.cost?.usd,
325 }
326 const budget = policy.features.agentBudget
327 if (budget && facts.roots) {
328 const reading = await readBudget($, budget, facts.roots.mainRoot)
329 if (reading.worktrees) status.worktrees = { count: reading.worktrees.count, cap: reading.worktrees.cap }
330 if (reading.mergeRequests) status.mergeRequests = { count: reading.mergeRequests.count, cap: reading.mergeRequests.cap }
331 }
332 await update($, bandState, () => status)
333}
334const GUARD_ERROR_RULE = 'guard-error'
335const GUARD_ERROR_MESSAGE =
336 `ronce/${GUARD_ERROR_RULE} : le garde-fou a échoué sur cette commande, refusée par prudence (détail dans le journal de debug). ` +
337 `Pour passer outre une fois : ajouter « # ronce:allow ${GUARD_ERROR_RULE} » à la commande.`
338
339type Session = { policy: LoadedPolicy; facts: SessionFacts }
340
341async function readPolicyText($: EngineInterface, cwd: string): Promise<string | null> {
342 for (const path of policyCandidates(cwd)) {
343 if (await $.fs.exists(path)) return $.fs.read(path)
344 }
345 return null
346}
347
348/** The project's policy, or the defaults alone with the reason when the file cannot be read. */
349async function readPolicy($: EngineInterface, cwd: string): Promise<LoadedPolicy> {
350 try {
351 return loadPolicy(DEFAULT_RULES, await readPolicyText($, cwd))
352 } catch (error) {
353 const defaults = loadPolicy(DEFAULT_RULES, null)
354 return { ...defaults, errors: [`politique illisible (${String(error)}), règles par défaut seules`, ...defaults.errors] }
355 }
356}
357
358async function loadSession($: EngineInterface): Promise<Session> {
359 const cwd = await $.session.cwd()
360 // The engine wants each variable named literally, so that what a module reads can be listed.
361 const env = { CLAUDE_AGENT: await $.env.get('CLAUDE_AGENT'), HOME: await $.env.get('HOME'), PATH: await $.env.get('PATH') }
362 const roots = await repoRoots($, cwd)
363 const facts: SessionFacts = { cwd, env, isInteractive: (await $.session.surfaces()).length > 0, ...(roots ? { roots } : {}) }
364 const policy = await readPolicy($, cwd)
365 await update($, policyState, () => policy)
366 await update($, factsState, () => facts)
367 if (policy.errors.length > 0) $.ui.toast(`ronce : ${policy.errors.length} erreur(s) dans la politique, voir /ronce-hits`)
368 return { policy, facts }
369}
370
371/** The skills of one source; a missing folder, or a skill without a readable SKILL.md, is left out. */
372async function readCatalogSource($: EngineInterface, source: SkillCatalogSource, facts: SessionFacts): Promise<CatalogSkill[]> {
373 const dir = catalogDir(source.dir, $.plugin.root, facts.roots?.worktreeRoot ?? facts.cwd, facts.env.HOME)
374 if (!(await $.fs.exists(dir))) return []
375 const skills = await Promise.all(
376 (await $.fs.list(dir)).map(entry =>
377 $.fs
378 .read(`${dir}/${entry.name}/SKILL.md`)
379 .then(text => parseSkill(entry.name, text, source.namespace))
380 .catch(() => null),
381 ),
382 )
383 return skills.filter((skill): skill is CatalogSkill => skill !== null)
384}
385
386/** The catalog, read on first need: the session start reads it ahead, a prompt that comes first waits for it. */
387async function ensureCatalog($: EngineInterface, sources: readonly SkillCatalogSource[], facts: SessionFacts): Promise<CatalogSkill[]> {
388 const known = await read($, catalogState)
389 if (known) return known
390 const catalog = mergeCatalogs(await Promise.all(sources.map(source => readCatalogSource($, source, facts))))
391 await update($, catalogState, () => catalog)
392 return catalog
393}
394
395/** Work the session start sets going without holding up the first prompt; a failure is logged, never thrown. */
396async function startBackground($: EngineInterface): Promise<void> {
397 const { policy, facts } = await ensureSession($)
398 const { worktree, freshness, band, skillCatalog } = policy.features
399 const tasks: [string, () => Promise<void>][] = [
400 ['liens du worktree', async () => { if (worktree && facts.roots) await linkWorktreeFiles($, facts.roots, worktree.links) }],
401 ['fraîcheur de la branche de base', async () => { if (freshness) await warnIfStale($, facts.cwd, freshness) }],
402 ['catalogue de skills', async () => { if (skillCatalog) await ensureCatalog($, skillCatalog.sources, facts) }],
403 ['bandeau', async () => { if (band) await refreshBand($, policy, facts) }],
404 ]
405 for (const [name, task] of tasks) {
406 try {
407 await task()
408 } catch (error) {
409 $.ui.log(`ronce : ${name} en échec (${String(error)})`, { to: 'debug' })
410 }
411 }
412 if (band) {
413 $.clock.every(BAND_REFRESH_MS, () => {
414 refreshBand($, policy, facts).catch(error => $.ui.log(`ronce : bandeau en échec (${String(error)})`, { to: 'debug' }))
415 })
416 }
417}
418
419/** A dev agent past the merge request or worktree budget; null when it may go (budget off, exempt, under the caps). */
420async function agentBudgetDenial($: EngineInterface, policy: LoadedPolicy, facts: SessionFacts, subagentType: string | undefined): Promise<string | null> {
421 const budget = policy.features.agentBudget
422 if (!budget || !facts.roots || isExempt(subagentType, budget.exempt)) return null
423 const reading = await readBudget($, budget, facts.roots.mainRoot)
424 const denial = budgetDenial(reading)
425 return denial ? `ronce/${AGENT_BUDGET_RULE} : ${denial}` : null
426}
427
428/** The policy's own hint first; otherwise the catalog skills whose triggers the prompt holds. */
429async function skillReminder($: EngineInterface, { policy, facts }: Session, prompt: string): Promise<string | null> {
430 const hint = hintFor(policy.features.skillHints, prompt)
431 if (hint) return hintText(hint)
432 const catalog = policy.features.skillCatalog
433 if (!catalog) return null
434 const names = catalogMatches(await ensureCatalog($, catalog.sources, facts), prompt, catalog.max)
435 return names.length > 0 ? catalogText(names) : null
436}
437
438/** The session's policy and facts, loaded on first need (a reload of the module keeps them). */
439async function ensureSession($: EngineInterface): Promise<Session> {
440 const policy = await read($, policyState)
441 const facts = await read($, factsState)
442 return policy && facts ? { policy, facts } : loadSession($)
443}
444
445async function shapeBash(
446 $: EngineInterface,
447 ran: ToolCallResult,
448 policy: LoadedPolicy,
449 home: string | undefined,
450 sessionId: string,
451 toolUseId: string,
452): Promise<{ ran: ToolCallResult; shaping?: ToolEvent['shaping'] }> {
453 const stdout = (ran.result as { stdout?: unknown } | undefined)?.stdout
454 if (typeof stdout !== 'string') return { ran }
455 const path = fullOutputPath(expandHome(policy.shaping.dir, home), sessionId, toolUseId)
456 const plan = planShaping(stdout, policy.shaping, path)
457 if (!plan) return { ran }
458 const sizes = { originalChars: plan.originalChars, keptChars: plan.keptChars }
459 if (policy.shaping.mode !== 'enforce') return { ran, shaping: { ...sizes, applied: false } }
460 try {
461 await $.fs.write(path, stdout)
462 } catch (error) {
463 // Without the full output on disk the cut would lose data: the model gets the whole output instead.
464 $.ui.log(`ronce : sortie complète non écrite (${String(error)}), façonnage annulé`, { to: 'debug' })
465 return { ran, shaping: { ...sizes, applied: false } }
466 }
467 const shaped = { result: { ...(ran.result as object), stdout: plan.text }, ...(ran.context ? { context: ran.context } : {}) }
468 return { ran: shaped as ToolCallResult, shaping: { ...sizes, applied: true } }
469}
470
471function withWarnings(ran: ToolCallResult, warnings: readonly string[]): ToolCallResult {
472 if (warnings.length === 0 || ran.deny !== undefined) return ran
473 return { ...ran, context: [...(ran.context ?? []), ...warnings] } as ToolCallResult
474}
475
476async function flush($: EngineInterface): Promise<void> {
477 const { policy, facts } = await ensureSession($)
478 const sessionId = await $.session.id()
479 const ts = new Date(await $.clock.now()).toISOString()
480 const usage = await $.session.usage().catch((error: unknown) => {
481 $.ui.log(`ronce : coût de session indisponible (${String(error)})`, { to: 'debug' })
482 return undefined
483 })
484 const turn = turnEvent({ ts, sessionId, projectDir: facts.cwd, costUsd: usage?.cost?.usd, contextPercent: usage?.context.percent })
485 await update($, telemetryState, buffer => appendCapped(buffer, turn))
486
487 const delta = await read($, hitsState)
488 if (Object.keys(delta).length > 0) {
489 const stored = ((await $.store.get(HITS_STORE_KEY)) ?? {}) as HitStats
490 await $.store.set(HITS_STORE_KEY, mergeHits(stored, delta))
491 await update($, hitsState, () => ({}))
492 }
493
494 const events = await read($, telemetryState)
495 if (!policy.telemetry) {
496 await update($, telemetryState, () => [])
497 return
498 }
499 const seq = await update($, flushSeqState, n => n + 1)
500 const path = batchPath(expandHome(policy.telemetry.dir, facts.env.HOME), ts, sessionId, seq)
501 try {
502 await $.fs.write(path, toJsonl(events))
503 await update($, telemetryState, buffer => buffer.slice(events.length))
504 } catch (error) {
505 // The buffer is kept (capped) and written at the next turn.
506 $.ui.log(`ronce : télémétrie non écrite (${String(error)})`, { to: 'debug' })
507 }
508}
509
510export const register: Register = on => {
511 on('session.start', async ($, e, next) => {
512 const started = await next(e)
513 await loadSession($)
514 // Literal names, here and in the matcher below: the engine then knows this hook answers its own command.
515 await $.command.register({ name: 'ronce-hits', description: 'Règles ronce déclenchées (shadow compris) et erreurs de politique' })
516 // Detached from the start dispatch: a fetch or a budget reading must not hold up the first prompt.
517 $.clock.after(0, () => {
518 startBackground($).catch(error => $.ui.log(`ronce : démarrage en arrière-plan en échec (${String(error)})`, { to: 'debug' }))
519 })
520 return started
521 })
522
523 on('tool.call', async ($, e, next) => {
524 const { policy, facts } = await ensureSession($)
525 const call = toRawCall(e as unknown as Record<string, unknown>)
526 const verdicts = evaluate(policy.rules, call, facts)
527 const decision = decide(verdicts)
528 if (verdicts.length > 0) await update($, hitsState, stats => recordHits(stats, verdicts, sampleOf(call)))
529 const sessionId = await $.session.id()
530 const track = async (outcome: ToolOutcome): Promise<void> => {
531 const ts = new Date(await $.clock.now()).toISOString()
532 const event = toolEvent({ ts, sessionId, projectDir: facts.cwd, agentId: e.agentId, call, verdicts, outcome })
533 await update($, telemetryState, buffer => appendCapped(buffer, event))
534 }
535
536 if (decision.deny) {
537 await track({ success: false, denied: true, durationMs: 0 })
538 return { deny: `ronce/${decision.deny.ruleId} : ${decision.deny.message}` }
539 }
540
541 if (e.tool === 'Agent') {
542 const overBudget = await agentBudgetDenial($, policy, facts, e.subagent_type).catch((error: unknown) => {
543 // The budget is a brake, not a gate: when it cannot be read, the agent goes.
544 $.ui.log(`ronce : budget d'agents illisible (${String(error)})`, { to: 'debug' })
545 return null
546 })
547 if (overBudget) {
548 await track({ success: false, denied: true, durationMs: 0 })
549 return { deny: overBudget }
550 }
551 }
552
553 const startedAt = await $.clock.now()
554 const rewritten = decision.rewrite?.rewrite?.command
555 const ran = e.tool === 'Bash' && rewritten !== undefined ? await next({ ...e, command: rewritten }) : await next(e)
556 const durationMs = (await $.clock.now()) - startedAt
557 const success = ran.deny === undefined && ran.isError !== true
558 const toolUseId = e.tool_use_id ?? String(startedAt)
559 const shaped = success && e.tool === 'Bash'
560 ? await shapeBash($, ran, policy, facts.env.HOME, sessionId, toolUseId)
561 : { ran }
562 await track({ success, denied: ran.deny !== undefined, durationMs, shaping: shaped.shaping })
563 const isFileEdit = success && (e.tool === 'Edit' || e.tool === 'Write') && call.file_path !== undefined
564 const checkFindings = isFileEdit ? await runEditChecks($, policy, facts, call.file_path!) : []
565 return withWarnings(shaped.ran, [...decision.warns.map(v => `ronce/${v.ruleId} : ${v.message}`), ...checkFindings])
566 }).catch((_$, e, next) => {
567 // A failure after the tool ran replays its result. Before it ran, a shell command is refused,
568 // since the footguns live there; any other tool goes through, having no marker to lift a refusal.
569 if (next.called || e.tool !== 'Bash' || allowedRuleIds(e.command).includes(GUARD_ERROR_RULE)) return next(e)
570 return { deny: GUARD_ERROR_MESSAGE }
571 })
572
573 on('tool.check', async ($, e, next) => {
574 const verdict = await next(e)
575 if (verdict.decision === 'deny') return verdict
576 const { policy, facts } = await ensureSession($)
577 const call = toRawCall({ ...(e.input as Record<string, unknown>), tool: e.tool })
578 const asks = decide(evaluate(policy.rules, call, facts)).asks
579 if (asks.length === 0) return verdict
580 return { decision: 'ask', reason: asks.map(v => `ronce/${v.ruleId} : ${v.message}`).join(' · ') }
581 }).catch((_$, e, next) => next(e))
582
583 on('prompt.submit', async ($, e, next) => {
584 await update($, lastIntentState, () => e.text)
585 // A person spoke: the background checks may wake the session again.
586 if (e.origin.kind !== 'plugin') await update($, wakesState, () => 0)
587 const reminder = await skillReminder($, await ensureSession($), e.text)
588 return reminder ? next({ ...e, context: [...(e.context ?? []), reminder] }) : next(e)
589 }).catch((_$, e, next) => next(e))
590
591 on('session.compact', async ($, e, next) => {
592 const done = await next(e)
593 if (e.agentId) return done
594 const { policy, facts } = await ensureSession($)
595 if (policy.features.memo) {
596 await appendMemo($, facts, policy.features.memo, await read($, lastIntentState)).catch((error: unknown) =>
597 $.ui.log(`ronce : mémo de compaction non ajouté (${String(error)})`, { to: 'debug' }),
598 )
599 }
600 return done
601 })
602
603 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
604 // A render hook only reads: the policy is loaded by the session start, never from here.
605 const policy = await read($, policyState)
606 const status = await read($, bandState)
607 if (!policy?.features.band || !status || e.props.hasSurvey) return next(e)
608 const { Box, Text } = $.ui.resolve(e)
609 return (
610 <Box>
611 <Text dimColor>{bandLine(status)}</Text>
612 </Box>
613 )
614 })
615
616 on('turn.complete', async ($, e, next) => {
617 const done = await next(e)
618 await flush($)
619 // Detached and one at a time: the checks may take minutes, and the turn is over.
620 if (!isBackgroundRunning) {
621 isBackgroundRunning = true
622 $.clock.after(0, () => {
623 runBackgroundChecks($)
624 .catch(error => $.ui.log(`ronce : vérifications de fond en échec (${String(error)})`, { to: 'debug' }))
625 .finally(() => {
626 isBackgroundRunning = false
627 })
628 })
629 }
630 return done
631 })
632
633 on('session.end', async ($, e, next) => {
634 await flush($)
635 return next(e)
636 })
637
638 on('command.run', { command: 'ronce-hits' }, async $ => {
639 const { policy } = await ensureSession($)
640 const stored = ((await $.store.get(HITS_STORE_KEY)) ?? {}) as HitStats
641 return { text: formatHits(mergeHits(stored, await read($, hitsState)), policy.errors) }
642 })
643}
644src/checks/plan.ts 111 lines1import type { CheckSpec } from '../../types'
2
3const ESCAPE = String.fromCharCode(27)
4/** Terminal colour codes, which tools print even when piped and which the model has no use for. */
5const ANSI = new RegExp(`${ESCAPE}\\[[0-9;]*m`, 'g')
6const REPORT_HEADER = 'Quality-gates en échec sur le scope modifié, à corriger avant commit ou push :'
7
8export function checksForPath(specs: readonly CheckSpec[], rel: string): CheckSpec[] {
9 return specs.filter(spec => new RegExp(spec.match).test(rel) && !(spec.unless && new RegExp(spec.unless).test(rel)))
10}
11
12/** `{files}` alone spreads into one argument per file; `{file}`, `{rel}` and `{group}` are filled anywhere. */
13export function expandRun(run: readonly string[], vars: { file?: string; rel?: string; group?: string; files?: string[] }): string[] {
14 return run.flatMap(arg => {
15 if (arg === '{files}') return vars.files ?? []
16 return [arg.replaceAll('{file}', vars.file ?? '').replaceAll('{rel}', vars.rel ?? '').replaceAll('{group}', vars.group ?? '')]
17 })
18}
19
20function clip(lines: readonly string[], maxLines: number): string {
21 if (lines.length <= maxLines) return lines.join('\n')
22 return [...lines.slice(0, maxLines), `… (${lines.length - maxLines} ligne(s) retirée(s))`].join('\n')
23}
24
25/** What the model needs to read of a check's output, or null when the check found nothing. */
26export function findingOf(spec: CheckSpec, exitCode: number, output: string, files: readonly string[]): string | null {
27 let lines = output.replace(ANSI, '').split('\n').filter(line => line.trim() !== '')
28 if (spec.keepLinesMatching) {
29 const keep = new RegExp(spec.keepLinesMatching)
30 lines = lines.filter(line => keep.test(line))
31 }
32 if (spec.onlyLinesMentioningFiles) {
33 const also = spec.alsoReportOnFailure && exitCode !== 0 ? new RegExp(spec.alsoReportOnFailure) : null
34 const failing = also ? lines.filter(line => also.test(line)) : []
35 const mentioned = lines.filter(line => files.some(file => line.includes(file)) && !failing.includes(line))
36 const kept = [...failing, ...mentioned]
37 return kept.length > 0 ? clip(kept, spec.maxLines) : null
38 }
39 const found = spec.findingWhen ? new RegExp(spec.findingWhen, 'm').test(output) : exitCode !== 0
40 if (!found) return null
41 return clip(lines.length > 0 ? lines : [`code de sortie ${exitCode}`], spec.maxLines)
42}
43
44export type BackgroundRun = { spec: CheckSpec; group?: string; files: string[] }
45
46/**
47 * The runs a set of changed files calls for: one per group value, or one with every matched file.
48 * `contentOf` answers a changed file's text, for the checks that only run on some content.
49 */
50export function backgroundRuns(
51 specs: readonly CheckSpec[],
52 changed: readonly string[],
53 contentOf: (rel: string) => string | undefined = () => undefined,
54): BackgroundRun[] {
55 return specs.flatMap(spec => {
56 const matched = changed.filter(rel => checksForPath([spec], rel).length > 0)
57 if (matched.length === 0) return []
58 if (spec.onlyIfFilesContain) {
59 const wanted = new RegExp(spec.onlyIfFilesContain, 'm')
60 if (!matched.some(rel => wanted.test(contentOf(rel) ?? ''))) return []
61 }
62 if (!spec.group) return [{ spec, files: matched }]
63 const pattern = new RegExp(spec.group)
64 const byGroup = new Map<string, string[]>()
65 for (const rel of matched) {
66 const value = pattern.exec(rel)?.[1]
67 if (value !== undefined) byGroup.set(value, [...(byGroup.get(value) ?? []), rel])
68 }
69 return [...byGroup].map(([group, files]) => ({ spec, group, files }))
70 })
71}
72
73/** The paths `git status --porcelain` lists, a rename taken at its new name. */
74export function changedPaths(porcelain: string): string[] {
75 return porcelain
76 .split('\n')
77 .filter(line => line.length > 3)
78 .map(line => line.slice(3).replace(/^.* -> /, '').replace(/^"|"$/g, ''))
79}
80
81/** Equal for the same files at the same times, whatever their order. */
82export function scopeKey(entries: readonly { path: string; mtimeMs: number }[]): string {
83 return entries
84 .map(entry => `${entry.path}:${entry.mtimeMs}`)
85 .sort()
86 .join('\n')
87}
88
89export function parseEnv(text: string): Record<string, string> {
90 const vars: Record<string, string> = {}
91 for (const line of text.split('\n')) {
92 const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim())
93 if (assignment) vars[assignment[1]!] = assignment[2]!.trim().replace(/^["']|["']$/g, '')
94 }
95 return vars
96}
97
98/**
99 * The host and port of `scheme://user:pass@host:port/...`; null when the string holds none.
100 * The host is limited to name characters: it ends up in a shell command that probes the port.
101 */
102export function reachTarget(url: string, defaultPort: number): { host: string; port: number } | null {
103 const target = /@([A-Za-z0-9.-]+)(?::(\d+))?\//.exec(url)
104 if (!target) return null
105 return { host: target[1]!, port: target[2] ? Number(target[2]) : defaultPort }
106}
107
108export function reportText(failures: readonly { title: string; body: string }[]): string {
109 return [REPORT_HEADER, ...failures.map(failure => `── ${failure.title} ──\n${failure.body}`)].join('\n\n')
110}
111src/features/budget.ts 44 lines1export type OpenMergeRequest = { iid: number; title: string; draft: boolean; labels: string[] }
2export type WorktreeState = {
3 name: string
4 /** False for a folder that matches the naming but holds no repository (an archive of patches). */
5 isRepo: boolean
6 commitsAhead: number | null
7 hasChanges: boolean | null
8 ageMinutes: number
9}
10
11/** A missing agent type is the general-purpose agent, which writes code: it is never exempt. */
12export function isExempt(subagentType: string | undefined, exempt: readonly string[]): boolean {
13 if (subagentType === undefined) return false
14 // A plugin serves its agents as `<plugin>:<name>`: the policy lists the name alone.
15 const unqualified = subagentType.slice(subagentType.lastIndexOf(':') + 1)
16 return exempt.includes(subagentType) || exempt.includes(unqualified)
17}
18
19/** Open merge requests that count against the cap: neither drafts nor those waiting on a person. */
20export function blockingMergeRequests(mrs: readonly OpenMergeRequest[], humanGatedLabels: readonly string[]): OpenMergeRequest[] {
21 return mrs.filter(mr => !mr.draft && !mr.labels.some(label => humanGatedLabels.includes(label)))
22}
23
24/** Worktrees that count against the cap: repositories that are not empty and settled. */
25export function activeWorktrees(states: readonly WorktreeState[], emptyAgeMinutes: number): WorktreeState[] {
26 const isEmpty = (w: WorktreeState): boolean => w.commitsAhead === 0 && w.hasChanges === false && w.ageMinutes > emptyAgeMinutes
27 return states.filter(w => w.isRepo && !isEmpty(w))
28}
29
30export function budgetDenial(args: {
31 mergeRequests?: { count: number; cap: number; iids: number[] }
32 worktrees?: { count: number; cap: number; names: string[] }
33}): string | null {
34 const denials: string[] = []
35 const mrs = args.mergeRequests
36 if (mrs && mrs.count >= mrs.cap) {
37 denials.push(`MR budget atteint (${mrs.count}/${mrs.cap} MRs ouvertes : ${mrs.iids.map(iid => `!${iid}`).join(', ')})`)
38 }
39 const wts = args.worktrees
40 if (wts && wts.count >= wts.cap) denials.push(`Worktree budget atteint (${wts.count}/${wts.cap} actifs : ${wts.names.join(', ')})`)
41 if (denials.length === 0) return null
42 return `${denials.join(' · ')}. Merger ou fermer des MR, finir des worktrees (worktree.ts finish <iid>) avant de dispatcher un nouvel agent de dev.`
43}
44src/features/env-file.ts 7 lines1/** The value of `name` in a `.env` file, quotes removed; null when it is missing or empty. */
2export function envValue(text: string, name: string): string | null {
3 const line = text.split('\n').find(entry => entry.startsWith(`${name}=`))
4 const value = line?.slice(name.length + 1).trim().replace(/^["']|["']$/g, '')
5 return value ? value : null
6}
7src/features/freshness.ts 12 lines1/** A warning when the local copy of `base` lags behind `remote/base`; null when it does not. */
2export function freshnessNote(args: { behind: number; ahead: number; branch: string; remote: string; base: string }): string | null {
3 if (!Number.isFinite(args.behind) || args.behind <= 0) return null
4 const divergence = args.ahead > 0 ? ` (et ${args.ahead} commit(s) local(aux) non poussés → divergence)` : ''
5 return [
6 `⚠️ ${args.base} local en retard de ${args.behind} commit(s) sur ${args.remote}/${args.base}${divergence}`,
7 ` Branche courante : ${args.branch}.`,
8 ` Avant tout chantier : \`git fetch ${args.remote} ${args.base}\` puis brancher ou rebaser depuis ${args.remote}/${args.base}.`,
9 ' Un diagnostic sur une base périmée donne des conclusions fausses.',
10 ].join('\n')
11}
12src/features/memo.ts 31 lines1const INTENT_CHARS = 600
2
3export function ticketFrom(branch: string, pattern: string): string | null {
4 return new RegExp(pattern).exec(branch)?.[1] ?? null
5}
6
7const quote = (text: string): string => `> ${text.replace(/\n/g, '\n> ')}`
8
9/** The note appended after a compaction, so the summary does not lose where the session stood. */
10export function memoText(args: {
11 branch: string
12 ticket: string | null
13 lastIntent: string | null
14 decisions?: { file: string; ageDays: number; staleDays: number }
15}): string {
16 const ticket = args.ticket ? ` (ticket #${args.ticket})` : ''
17 const sections = [
18 '## Contexte de session (ronce, après compaction)',
19 `**Branche :** \`${args.branch}\`${ticket}`,
20 ]
21 if (args.lastIntent) sections.push(`## Dernière intention utilisateur\n${quote(args.lastIntent.slice(0, INTENT_CHARS))}`)
22 const decisions = args.decisions
23 if (decisions && decisions.ageDays >= decisions.staleDays) {
24 sections.push(
25 `## ${decisions.file} non mis à jour depuis ${decisions.ageDays} jours\n` +
26 `> Aucune décision architecturale tracée depuis ${decisions.ageDays} jours ; /record-decision si des choix importants ont été faits.`,
27 )
28 }
29 return `${sections.join('\n\n')}\n`
30}
31src/features/skill-catalog.ts 58 lines1import type { CatalogSkill } from '../../types'
2import { expandHome } from '../policy/paths.ts'
3
4/** Shorter triggers match too much incidental vocabulary to mean anything. */
5const MIN_TRIGGER_LENGTH = 4
6const PLUGIN_ROOT = '{pluginRoot}'
7
8/** The quoted strings of a description ("…" or « … ») and the long words of the name, in lower case. */
9function triggersOf(name: string, description: string): string[] {
10 const quoted = [...description.matchAll(/"([^"]+)"|«\s*([^»]+?)\s*»/g)].map(m => m[1] ?? m[2] ?? '')
11 return [...quoted, ...name.split('-')]
12 .map(trigger => trigger.toLowerCase().trim())
13 .filter(trigger => trigger.length >= MIN_TRIGGER_LENGTH)
14}
15
16/** A skill from its SKILL.md text; null without a frontmatter description. CRLF files read like LF ones. */
17export function parseSkill(name: string, text: string, namespace?: string): CatalogSkill | null {
18 const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1]
19 const description = frontmatter && /description:\s*([^\r\n]*)/.exec(frontmatter)?.[1]
20 if (!description) return null
21 return { name, ...(namespace ? { namespace } : {}), triggers: triggersOf(name, description) }
22}
23
24/** One skill per name, the first source holding it winning: a project's own copy outranks the plugin's. */
25export function mergeCatalogs(catalogs: readonly CatalogSkill[][]): CatalogSkill[] {
26 const seen = new Set<string>()
27 return catalogs.flat().filter(skill => {
28 if (seen.has(skill.name)) return false
29 seen.add(skill.name)
30 return true
31 })
32}
33
34const qualifiedName = (skill: CatalogSkill): string => (skill.namespace ? `${skill.namespace}:${skill.name}` : skill.name)
35
36/** The names of the skills with most triggers in the prompt, at most `max`, none when nothing matches. */
37export function catalogMatches(skills: readonly CatalogSkill[], prompt: string, max: number): string[] {
38 const text = prompt.toLowerCase()
39 return skills
40 .map(skill => ({ name: qualifiedName(skill), hits: skill.triggers.filter(trigger => text.includes(trigger)).length }))
41 .filter(scored => scored.hits > 0)
42 .sort((a, b) => b.hits - a.hits)
43 .slice(0, max)
44 .map(scored => scored.name)
45}
46
47/** Worded as a fact: the model reads it as information, not as an order from a tool. */
48export function catalogText(names: readonly string[]): string {
49 return `<skill-reminder>\nSkills dont les déclencheurs figurent dans cette demande : ${names.join(', ')}.\n</skill-reminder>`
50}
51
52/** A source directory made absolute: `{pluginRoot}` and `~` expanded, anything else from the repository root. */
53export function catalogDir(dir: string, pluginRoot: string, repoRoot: string, home: string | undefined): string {
54 if (dir.startsWith(PLUGIN_ROOT)) return `${pluginRoot}${dir.slice(PLUGIN_ROOT.length)}`
55 const expanded = expandHome(dir, home)
56 return expanded.startsWith('/') ? expanded : `${repoRoot}/${expanded}`
57}
58src/features/skill-hints.ts 18 lines1import type { SkillHint } from '../../types'
2
3/** The first hint whose pattern matches the prompt, ignoring case. */
4export function hintFor(hints: readonly SkillHint[], prompt: string): SkillHint | null {
5 return hints.find(hint => new RegExp(hint.pattern, 'i').test(prompt)) ?? null
6}
7
8/** Worded as a fact: the model reads it as information, not as an order from a tool. */
9export function hintText(hint: SkillHint): string {
10 const lines = [
11 '<skill-reminder>',
12 `Le skill \`${hint.skill}\` correspond à cette demande (${hint.reason}).`,
13 ...(hint.note ? [hint.note] : []),
14 '</skill-reminder>',
15 ]
16 return lines.join('\n')
17}
18src/features/worktree-env.ts 13 lines1/** The links a linked worktree is missing: a file the main checkout has and the worktree has not. */
2export function linksToCreate(args: {
3 worktreeRoot: string
4 mainRoot: string
5 names: readonly string[]
6 exists: (path: string) => boolean
7}): { link: string; target: string }[] {
8 if (args.worktreeRoot === args.mainRoot) return []
9 return args.names
10 .map(name => ({ link: `${args.worktreeRoot}/${name}`, target: `${args.mainRoot}/${name}` }))
11 .filter(({ link, target }) => !args.exists(link) && args.exists(target))
12}
13src/policy/defaults.ts 167 lines1import type { CheckSpec, Features, Rule, ShapingConfig, TelemetryConfig } from '../../types'
2
3const AGENT_PROMPT_LIMIT = 8000
4
5export const DEFAULT_SHAPING: ShapingConfig = {
6 mode: 'enforce',
7 maxChars: 4000,
8 headLines: 20,
9 tailLines: 20,
10 // Under the home directory: a full output may hold what the cut hid, and /tmp is readable by every local user.
11 dir: '~/.claude/ronce/outputs',
12}
13
14export const DEFAULT_TELEMETRY: TelemetryConfig = { dir: '~/.claude/ronce/events' }
15
16const JS_FILES = '\\.(ts|tsx|js|jsx|mjs|cjs|mts|cts|vue)$'
17const ESLINT_CONFIGS = [
18 'eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs', 'eslint.config.ts',
19 '.eslintrc', '.eslintrc.js', '.eslintrc.cjs', '.eslintrc.json', '.eslintrc.yml', '.eslintrc.yaml',
20]
21
22/** What the global lint-changed hook did after each edit; each check is skipped where its tool is missing. */
23export const DEFAULT_EDIT_CHECKS: readonly CheckSpec[] = [
24 { id: 'rustfmt', title: 'rustfmt (lancer cargo fmt)', match: '\\.rs$', run: ['rustfmt', '--edition', '2021', '--check', '{file}'], timeoutMs: 30_000, maxLines: 20 },
25 {
26 id: 'eslint', title: 'ESLint (--max-warnings=0)', match: JS_FILES, unless: '\\.d\\.ts$',
27 run: ['./node_modules/.bin/eslint', '{file}', '--max-warnings=0'], cwd: 'package', packageMarker: 'node_modules/.bin/eslint',
28 requiresAny: ESLINT_CONFIGS, timeoutMs: 45_000, maxLines: 20,
29 },
30 {
31 id: 'biome-scripts', title: 'Biome (scripts/), correction : npx biome check --write <fichier>', match: '(^|/)scripts/.*\\.ts$',
32 run: ['./node_modules/.bin/biome', 'check', '{file}'], requiresAny: ['node_modules/.bin/biome'], timeoutMs: 30_000, maxLines: 15,
33 },
34 {
35 id: 'ast-grep', title: 'ast-grep (règles du projet)', match: '\\.(rs|ts|tsx)$', run: ['ast-grep', 'scan', '-c', 'sgconfig.yml', '{file}'],
36 requiresAny: ['sgconfig.yml'], findingWhen: 'error\\[', keepLinesMatching: '^(?!Help:)', timeoutMs: 20_000, maxLines: 20,
37 },
38]
39
40/** The related tests, moved off the edit: they took up to 90 s each time a file was saved. */
41export const DEFAULT_BACKGROUND_CHECKS: readonly CheckSpec[] = [
42 {
43 id: 'vitest-related', title: 'Vitest (tests liés)', match: JS_FILES, unless: '\\.d\\.ts$',
44 run: ['./node_modules/.bin/vitest', 'related', '{files}', '--run'], cwd: 'package', packageMarker: 'node_modules/.bin/vitest',
45 keepLinesMatching: 'FAIL|✗|×|AssertionError|Error[: ]|expected|Tests +[0-9]|Test Files', timeoutMs: 120_000, maxLines: 20,
46 },
47]
48
49/** Generic features are on; those that need a project's own settings (remote, budgets, hints) are off. */
50export const DEFAULT_FEATURES: Features = {
51 worktree: { links: ['.env'] },
52 freshness: null,
53 agentBudget: null,
54 memo: { ticketPattern: '(?:feat|fix|chore)\\/(\\d+)', staleDays: 14 },
55 skillHints: [],
56 // The project's own skills, then the catalog of the ronce-racine plugin, shipped beside this mod.
57 skillCatalog: {
58 sources: [{ dir: '.claude/skills' }, { dir: '{pluginRoot}/../../skills', namespace: 'ronce-racine' }],
59 max: 3,
60 },
61 band: true,
62 checks: {
63 onEdit: [...DEFAULT_EDIT_CHECKS],
64 background: [...DEFAULT_BACKGROUND_CHECKS],
65 busyPattern: 'cargo (build|test|nextest|clippy|check)',
66 maxWakes: 2,
67 },
68}
69
70/** Rules every project gets. A project policy may disable, replace or switch the mode of each. */
71export const DEFAULT_RULES: readonly Rule[] = [
72 {
73 id: 'cd-cwd-noop',
74 tool: 'Bash',
75 builtin: 'cd-cwd-noop',
76 action: 'rewrite',
77 mode: 'enforce',
78 message: 'Préfixe « cd <répertoire courant> » retiré : il ne change rien et coûte une demande de permission.',
79 examples: {
80 match: [{ command: 'cd /repo && git status' }],
81 pass: [{ command: 'cd /repo/backend && cargo test' }, { command: 'git status' }],
82 },
83 },
84 {
85 id: 'shell-read',
86 tool: 'Bash',
87 builtin: 'shell-read',
88 action: 'deny',
89 mode: 'enforce',
90 message: 'Lire ce fichier avec Read (offset/limit) : une lecture par le shell verse tout le fichier dans un contexte que chaque appel suivant repaie.',
91 examples: {
92 match: [{ command: 'sed -n 1,80p src/main.rs' }, { command: 'cd /repo/wt && cat src/lib.ts' }],
93 pass: [{ command: 'cat src/lib.ts | head -20' }, { command: 'cat /tmp/out.log' }],
94 },
95 },
96 {
97 id: 'heredoc-create',
98 tool: 'Bash',
99 builtin: 'heredoc-create',
100 action: 'deny',
101 mode: 'enforce',
102 message: 'Créer ce fichier avec Write : un fichier créé par le shell échappe aux hooks de format, de lint et de détection de secrets.',
103 examples: {
104 match: [{ command: "cat > src/new.ts <<'EOF'\nexport {}\nEOF" }],
105 pass: [{ command: "cat > /tmp/notes.md <<'EOF'\nx\nEOF" }],
106 },
107 },
108 {
109 id: 'destructive-after-semicolon',
110 tool: 'Bash',
111 command: ';\\s*git\\s+(reset\\s+--hard|checkout\\s+--|clean\\s+-\\w*f|push\\b.*--force)',
112 // The `cd X;` this rule is about is exactly what normalization strips.
113 raw: true,
114 action: 'deny',
115 mode: 'enforce',
116 message: "Commande git destructive enchaînée après « ; » : elle s'exécute même si la précédente échoue. La lancer seule, avec git -C <chemin>.",
117 examples: {
118 match: [{ command: 'cd ../wt; git reset --hard origin/master' }],
119 pass: [{ command: 'git -C ../wt reset --hard origin/master' }],
120 },
121 },
122 {
123 id: 'devnull-hides-errors',
124 tool: 'Bash',
125 command: '\\b(cargo|npm|npx)\\b[^|;&]*2>\\s*/dev/null',
126 action: 'warn',
127 mode: 'enforce',
128 message: '2>/dev/null masque les erreurs : garder 2>&1 et filtrer la sortie.',
129 examples: { match: [{ command: 'cargo build 2>/dev/null' }], pass: [{ command: 'cargo build 2>&1 | tail -20' }] },
130 },
131 {
132 id: 'zsh-wordsplit',
133 tool: 'Bash',
134 command: '\\bfor\\s+\\w+\\s+in\\s+\\$\\w+(\\s|;)',
135 action: 'warn',
136 mode: 'enforce',
137 // biome-ignore lint/suspicious/noTemplateCurlyInString: ${=VAR} is the zsh syntax this message teaches.
138 message: 'Sous zsh, $VAR ne se découpe pas en mots dans un for : écrire ${=VAR}.',
139 examples: {
140 match: [{ command: 'for f in $FILES; do echo $f; done' }],
141 // biome-ignore lint/suspicious/noTemplateCurlyInString: a zsh command, not a template.
142 pass: [{ command: 'for f in ${=FILES}; do echo $f; done' }],
143 },
144 },
145 {
146 id: 'agent-prompt-too-large',
147 tool: 'Agent',
148 promptLongerThan: AGENT_PROMPT_LIMIT,
149 action: 'deny',
150 mode: 'shadow',
151 message: 'Prompt de sous-agent de plus de 8 000 caractères : écrire le contexte lourd dans des fichiers et passer leurs chemins.',
152 examples: { match: [{ prompt: 'x'.repeat(AGENT_PROMPT_LIMIT + 1) }], pass: [{ prompt: 'Explore le module X.' }] },
153 },
154 {
155 id: 'commit-mentions-ai',
156 tool: 'Bash',
157 command: '\\bgit\\s+commit\\b[\\s\\S]*([Cc]laude|\\bAI\\b|\\bLLM\\b)',
158 action: 'deny',
159 mode: 'enforce',
160 message: 'Un message de commit ne mentionne jamais Claude, AI ni LLM (y compris en trailer Co-Authored-By).',
161 examples: {
162 match: [{ command: 'git commit -m "feat: x\n\nCo-Authored-By: Claude <noreply@anthropic.com>"' }],
163 pass: [{ command: 'git commit -m "feat(api): ajouter le filtre"' }],
164 },
165 },
166]
167src/policy/evaluate.ts 92 lines1import type { RawCall, Rule, SessionFacts, Verdict } from '../../types'
2import { runBuiltin } from './builtins.ts'
3import { normalizeCommand } from './normalize.ts'
4
5/** Commands are matched on their first characters only, so a 1 MB heredoc costs no more than a long command. */
6export const MAX_MATCH_CHARS = 20_000
7
8const compiled = new Map<string, RegExp>()
9
10/** Compiled once per process; loadPolicy has already rejected the patterns that do not compile. */
11function pattern(source: string): RegExp {
12 let regex = compiled.get(source)
13 if (!regex) {
14 regex = new RegExp(source)
15 compiled.set(source, regex)
16 }
17 return regex
18}
19
20const toolsOf = (rule: Rule): readonly string[] => (Array.isArray(rule.tool) ? rule.tool : [rule.tool])
21
22function whenHolds(rule: Rule, facts: SessionFacts): boolean {
23 const when = rule.when
24 if (!when) return true
25 if (when.interactive !== undefined && when.interactive !== facts.isInteractive) return false
26 return Object.entries(when.env ?? {}).every(([name, expected]) =>
27 expected === null ? facts.env[name] === undefined : facts.env[name] === expected,
28 )
29}
30
31type Match = { detail?: string; rewrite?: RawCall }
32
33function matchRule(rule: Rule, call: RawCall, normalized: string | undefined, facts: SessionFacts): Match | null {
34 if (!toolsOf(rule).includes(call.tool) || !whenHolds(rule, facts)) return null
35 if (rule.command !== undefined) {
36 const subject = rule.raw ? call.command : normalized
37 if (subject === undefined || !pattern(rule.command).test(subject)) return null
38 }
39 if (rule.unless !== undefined && call.command !== undefined && pattern(rule.unless).test(call.command)) return null
40 if (rule.path !== undefined && !pattern(rule.path).test(call.file_path ?? '')) return null
41 if (rule.content !== undefined && !pattern(rule.content).test(call.content ?? '')) return null
42 if (rule.readWithoutLimit && call.limit !== undefined) return null
43 if (rule.subagentType !== undefined && !pattern(rule.subagentType).test(call.subagent_type ?? '')) return null
44 if (rule.model !== undefined && call.model !== rule.model) return null
45 if (rule.promptLongerThan !== undefined && (call.prompt?.length ?? 0) <= rule.promptLongerThan) return null
46 if (rule.builtin === undefined) return {}
47 const found = runBuiltin(rule.builtin, call, facts)
48 return found.matched ? { detail: found.detail, rewrite: found.rewrite } : null
49}
50
51export function evaluate(rules: readonly Rule[], call: RawCall, facts: SessionFacts): Verdict[] {
52 const isTooLong = (call.command?.length ?? 0) > MAX_MATCH_CHARS
53 const matched: RawCall = isTooLong ? { ...call, command: call.command!.slice(0, MAX_MATCH_CHARS) } : call
54 const command = matched.command === undefined ? undefined : normalizeCommand(matched.command)
55 const verdicts: Verdict[] = []
56 for (const rule of rules) {
57 const found = matchRule(rule, matched, command?.normalized, facts)
58 if (!found) continue
59 const canRewrite = found.rewrite !== undefined && !isTooLong
60 verdicts.push({
61 ruleId: rule.id,
62 action: rule.action,
63 mode: rule.mode,
64 message: found.detail ? `${rule.message} (${found.detail})` : rule.message,
65 bypassed: command?.allowed.includes(rule.id) ?? false,
66 ...(canRewrite ? { rewrite: found.rewrite } : {}),
67 })
68 }
69 return verdicts
70}
71
72export type Decision = {
73 deny?: Verdict
74 rewrite?: Verdict
75 asks: Verdict[]
76 warns: Verdict[]
77}
78
79const isEnforced = (verdict: Verdict): boolean => verdict.mode === 'enforce' && !verdict.bypassed
80
81/** What the enforced verdicts require; a deny wins over everything else. */
82export function decide(verdicts: readonly Verdict[]): Decision {
83 const active = verdicts.filter(isEnforced)
84 const deny = active.find(v => v.action === 'deny')
85 if (deny) return { deny, asks: [], warns: [] }
86 return {
87 rewrite: active.find(v => v.action === 'rewrite' && v.rewrite !== undefined),
88 asks: active.filter(v => v.action === 'ask'),
89 warns: active.filter(v => v.action === 'warn'),
90 }
91}
92src/policy/hits.ts 61 lines1import type { HitStats, RawCall, Verdict } from '../../types'
2import { redact } from '../telemetry/redact.ts'
3
4const MAX_SAMPLES = 5
5const SAMPLE_CHARS = 160
6const PROMPT_PREVIEW_CHARS = 80
7
8/** What identifies a call in a sample: its command, its file, or its agent type and prompt. */
9export function sampleOf(call: RawCall): string {
10 if (call.command !== undefined) return call.command
11 if (call.file_path !== undefined) return call.file_path
12 return `${call.subagent_type ?? call.tool} : ${(call.prompt ?? '').slice(0, PROMPT_PREVIEW_CHARS)}`
13}
14
15export function recordHits(stats: Readonly<HitStats>, verdicts: readonly Verdict[], sample: string): HitStats {
16 const next: HitStats = { ...stats }
17 const clipped = redact(sample).slice(0, SAMPLE_CHARS)
18 for (const verdict of verdicts) {
19 const previous = next[verdict.ruleId] ?? { mode: verdict.mode, action: verdict.action, count: 0, bypassed: 0, samples: [] }
20 next[verdict.ruleId] = {
21 mode: verdict.mode,
22 action: verdict.action,
23 count: previous.count + 1,
24 bypassed: previous.bypassed + (verdict.bypassed ? 1 : 0),
25 samples: [...previous.samples, clipped].slice(-MAX_SAMPLES),
26 }
27 }
28 return next
29}
30
31export function mergeHits(base: Readonly<HitStats>, delta: Readonly<HitStats>): HitStats {
32 const merged: HitStats = { ...base }
33 for (const [id, added] of Object.entries(delta)) {
34 const previous = merged[id]
35 merged[id] = previous
36 ? {
37 mode: added.mode,
38 action: added.action,
39 count: previous.count + added.count,
40 bypassed: previous.bypassed + added.bypassed,
41 samples: [...previous.samples, ...added.samples].slice(-MAX_SAMPLES),
42 }
43 : added
44 }
45 return merged
46}
47
48const cell = (text: string): string => text.replace(/[|`\n]/g, ' ')
49
50export function formatHits(stats: Readonly<HitStats>, errors: readonly string[]): string {
51 const rows = Object.entries(stats).sort(([, a], [, b]) => b.count - a.count)
52 const table = [
53 '| Règle | Mode | Action | Déclenchements | Contournements | Dernier exemple |',
54 '|---|---|---|---|---|---|',
55 ...rows.map(([id, s]) => `| ${id} | ${s.mode} | ${s.action} | ${s.count} | ${s.bypassed} | \`${cell(s.samples.at(-1) ?? '')}\` |`),
56 ]
57 if (rows.length === 0) table.push('| aucune | | | 0 | 0 | |')
58 const problems = errors.length > 0 ? ['', '**Erreurs de politique**', ...errors.map(error => `- ${error}`)] : []
59 return [...table, ...problems].join('\n')
60}
61