SLOPSHOPPER

ronce

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

newbandguardcommandpromptprocess
★ 8v0.5.2MITupdated 2026-10-22ChariereFiedler/ronce-racine/mods/ronce
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ronce
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /ronce-hits ⎿ ronce: | Règle | Mode | Action | Déclenchements | Contournements | Dernier exemple | ⎿ ronce: |---|---|---|---|---|---| ⎿ ronce: | aucune | | | 0 | 0 | | ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Ronce Racine

Ronce Racine

npm license

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:

  • 13 rules, always on, injected by file type. The standing constraints an agent should never need to be reminded of.
  • 36 skills, on demand. Procedures the agent loads when what you asked for matches, from modeling a domain to triaging a bug.
  • 6 hooks (9 scripts), wired into settings.json for you. Small automatic behaviors on Claude Code events.
  • 2 agents and 1 detection script, for the jobs that deserve their own context.

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

Why it is packaged at all

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.

Quickstart

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

Propose, install, then catch a local edit with the drift gate

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.md says 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.

What you get

Rules: the constraints that are always on

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.

RuleApplies toWhat it prevents
commits.mdalwayscommit messages that nobody can scan six months later
pre-commit-secret-detection.mdalwaysa key or token reaching a commit, where a revert no longer saves you
minimal-code.mdcode (rs/ts/tsx/vue/py/go)speculative code, and its opposite: unreadable one-liners written to save a line
error-handling-discipline.mdcode (ts/js/vue/py/go/rs/php/java)swallowed errors, and a panic/unwrap on a fallible path
doc-code-parity.mdREADME.md, docs/**a documented command that no longer matches the code, which the reader trusts anyway
clean-architecture-deps.mdcode (ts/py/go/rs/php/java)business logic leaking into handlers, dependencies pointing away from the domain
no-raw-sql-interpolation.mdcode (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, *.tsan email, IP or token in the logs, which is a GDPR problem
subscription-cleanup.mdfrontend (ts/tsx/js/jsx/vue)subscriptions, listeners and timers with no teardown, and the leaks they cause
ui-states-complete.mdcomponents (tsx/jsx/vue/svelte)a screen that handles the happy path and shows nothing while loading or failing
test-discipline.md*.spec.ts, *.test.tstests that pass without ever having been seen to fail
detection-gap-protocol.mddocs/postmortems/**treating a P0 found by a user as a bug rather than as a detection failure

Skills: the procedures, loaded on demand

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 sayWhat runsWhat you get
"this is broken: ..."bug-triage-structureda reproduction, the root cause with file:line, then an argued fix-now-or-open-a-ticket decision
"commit this"commit-readiness-reviewthe staged diff scanned for secrets, debug leftovers and disabled tests before anything is written
"is it really ready?"adversarial-feature-challengethe feature attacked from several personas; zero flaws found means a bad challenge, not a perfect feature
"three names for the same thing here"domain-glossaryone 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-profilingthe noise floor measured before anything else, then a profile, with "inconclusive" as a valid answer

The full set, grouped by intention:

GroupSkillsFor example
Designing, before code5domain-modeling-design, comprehensive-test-strategy
Implementing6ddd-backend-implementation, api-contract-versioning
Testing and validating4writing-robust-tests, visual-regression-check
Fixing a bug5bug-triage-structured, production-incident-diagnostic
Reviewing and shipping4merge-request-review, ci-pipeline-orchestration
Sweeping and auditing12detection-sweep, and the opt-in audit-* family

What each one is for, in full: docs/catalog.md.

Hooks: the small automatic behaviors

HookEventWhy it is there
skill-reminder.tsUserPromptSubmitsurfaces the skills matching your prompt, so a relevant one is not missed. Self-maintained, silent when nothing matches
bash-npm-silent.tsPreToolUse / Bashappends --silent to setup npm install / npm ci, which otherwise burn context on noise
truncate-output.ts (+ truncate-bash-output.ts)PreToolUse / Bashcaps verbose command output past a threshold, always preserving it on error
session-writer + session-inject + session-precompactStop / SessionStart(compact) / PreCompacta per-branch session memo: persists the context at session end and re-injects it after a compaction
worktree-env-setup.tsSessionStartsymlinks the main repo's .env into the current worktree, so a worktree session is not born broken
readme-freshness.tsPreToolUse / Bashbefore 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.

Agents and scripts

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

Why you should not trust the tables above

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.

Staying in sync

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.

Install options

The CLI (recommended)

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.

Or as a Claude Code plugin

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

Or all three together

Once the ronce mod is installed, each layer has one channel:

LayerChannel
Rulesthe CLI, install <repo> --rules-only, versioned in the repository
Skills and agentsthe ronce-racine plugin, user scope
Hooksthe ronce mod, driven by the repository's .claude/ronce.json

To move an adopted repository over, run uninstall then install --rules-only.

The ronce mod (early access)

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:

KeyWhat it doesDefault
worktreeLinks the main checkout's .env (or the files listed) into a linked worktreeon
memoAfter a compaction, appends the branch, the ticket, the last request and a stale decisions fileon
bandA line above the prompt: project, branch, context, cost, budgetson
freshnessFetches the base branch at session start and warns when the local copy lagsoff
agentBudgetRefuses a dev subagent past the open merge request or worktree cap; agent types that only read are exemptoff
skillHintsAdds a skill suggestion to a prompt that matches a patternnone
skillCatalogOtherwise, 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 hookon, 3 at most
checks.onEditAfter an edit, runs the checks whose match fits the file and hands any finding to the modelrustfmt, ESLint, Biome on scripts/, ast-grep
checks.backgroundAfter 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.

Contributing

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.

The name

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.

Source 23 files
hooks/register.tsx 644 lines
1import { 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}
644
src/checks/plan.ts 111 lines
1import 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}
111
src/features/budget.ts 44 lines
1export 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}
44
src/features/env-file.ts 7 lines
1/** 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}
7
src/features/freshness.ts 12 lines
1/** 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}
12
src/features/memo.ts 31 lines
1const 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}
31
src/features/skill-catalog.ts 58 lines
1import 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}
58
src/features/skill-hints.ts 18 lines
1import 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}
18
src/features/worktree-env.ts 13 lines
1/** 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}
13
src/policy/defaults.ts 167 lines
1import 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]
167
src/policy/evaluate.ts 92 lines
1import 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}
92
src/policy/hits.ts 61 lines
1import 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