SLOPSHOPPER

learned-rules

Turns your corrections into skill and repo rules you accept one by one, each with how a static check could enforce it

newpanebandcommandtoastprompt
v0.2.0no licenseupdated 2026-10-15Elmanuel1/claude-mods/learned-rules
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · learned-rules
│ ┃ Learned rules ✕ › fix the failing auth test and add an audit log call │ ┃ No learned rules to review. │ ⏺ 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 │ │ › /learned │ ⎿ learned-rules: Learned rules opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Learned rules
No learned rules to review.
README

learned-rules

A Claude Code mod that turns your corrections into rules. When you tell Claude how something should be done ("don't catch and rethrow", "use the version catalog"), it writes that down as a rule in the right skill or CLAUDE.md, and says how a linter or architecture test you already run could enforce it. You accept or reject each rule in /learned.

Install

/plugin install learned-rules --marketplace Elmanuel1/claude-mods

Answer y to add the marketplace, then pick a scope. Work in Claude Code as usual; after a session with corrections ends, the next session you open says N learned rule(s) to review: /learned.

Requirements

git and the Claude Code CLI (claude) on PATH. The GitHub CLI (gh), logged in, is needed to read GitHub's merge rules and to open the rules pull request. It runs the same on macOS, Linux and Windows: the mod uses no shell, jq or OS notification tool.

Terms

  • Correction: something you typed about how code, PRs, docs or replies should be done, as opposed to a task or a question.
  • Rule: a correction written into a skill (~/.claude/skills/*/SKILL.md) or a repo file (CLAUDE.md, AGENTS.md, .claude/skills/), with a short why and a BAD/GOOD example. Each rule is one git commit, so you can accept or reject it on its own.
  • Enforcer: anything that already fails a build or blocks an action when a rule is broken: a linter config, an architecture test, a baseline ratchet, a CI workflow, a GitHub required check, a Claude Code hook or permission rule.
  • Enforce line: what each rule's commit says about enforcing it, Enforce: <status>; <enforcer>; <what it checks>.

How it works

  1. Recording. The mod keeps what you type in each session: at the terminal, in the desktop app, or through Remote Control. Notifications, scheduled prompts, other sessions' messages and slash commands are left out.
  2. Queueing. When a session ends, it is queued if you typed at least a few lines.
  3. Learning. The next open session runs claude -p on the queue, one session at a time across all your open sessions. The run gets your typed text and an inventory of the enforcers that exist where the session ran:
  4. the enforcer files on the repo's default branch (linter, architecture-test, ratchet, policy and workflow configs);
  5. what GitHub requires before a merge (rulesets, branch protection), or why it cannot be read;
  6. your Claude Code hooks and permission rules, user-wide and in the repo.
  7. Writing rules. The run lists your corrections, skips the ones a rule already covers, and answers with each new rule as a set of edits. The run only reads, since text pasted into a session reaches it: its tools are Read, Grep and Glob, and it loads only project settings, from an empty folder it starts in, so none of your settings (which may allow every command), hooks, plugins, MCP servers or skills apply. The mod then applies each rule as one git commit: personal ones in ~/.claude/skills (made a git repo on first use), repo ones on the branch docs/learned-rules in a worktree at ~/.claude/learned/repo-worktree, which the mod brings up to date with main before the run. It applies a rule only when:
  8. the file is a Markdown file (or skill-triggers.json) inside that repo, outside .git;
  9. each edit matches the file exactly once;
  10. the edit adds or changes no hooks: or allowed-tools: in a skill's frontmatter, and adds or changes no ` !command or ``! block a skill runs when it loads, since personal rules are live once committed;
  11. the entry has the expected shape.

A rule whose commit fails leaves its file and the index as they were.

Nothing is pushed. The run's summary, and why any rule was not applied, is saved in ~/.claude/learned/<session id>.md. A run that fails (an expired login, a usage limit, an answer with no rules) keeps the session queued and tries again 30 minutes later, three times at most.

  1. Enforcement. For each rule the run reads the enforcers that could see it and writes its Enforce line:
StatusMeaning
existsan enforcer already fails on the rule's BAD example; the line names it
newan enforcer you run could check it; the line names its file and the check to add
partialan enforcer can catch a plain form of it; the line says which part
nonea judgment no tool can see

It targets an enforcer you already run, following how that file writes its checks. Only when none can see the rule does it name a new tool, from references/enforcers.md, with worked examples per language in references/ (java.md: ArchUnit and PMD with baselines).

Reviewing

/learned opens the list. Each rule shows where it came from, its file and its Enforce line:

  • Accept keeps it. Reject drops the commit. Correct puts a prompt in the box to reword it in place. Diff shows the change.
  • Enforce (for new and partial) puts a prompt in the box to add the check: read that enforcer, add the check the way it writes its others, record the violations that exist today so only new ones fail, and show it fails on the BAD example and passes on the GOOD one. You send it in a normal session, so the check is built and reviewed like any change.
  • Once every repo rule is reviewed, Push accepted rules and open the PR pushes docs/learned-rules and opens one pull request to main.

Evals

tests/ holds the free unit tests (claude plugin test learned-rules). evals/ holds the eval suite for the learning run's judgment, which runs on your credentials: claude plugin eval learned-rules --scaffold --ablation none.

Limits

  • A learning run stops if the session running it closes; its session stays queued and the next session runs it again.
  • The repo worktree serves one repository at a time: a session from another repository waits until that repository's rules PR is merged. The rules PR targets main.
Source 13 files
hooks/register.tsx 206 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4import type { Rule, RuleMessage, RulesView } from '../types'
5import { ACTION_LABELS, act, pressedRule } from './actions'
6import type { RulesUi } from './actions'
7import { drain } from './host'
8import type { Host } from './host'
9import { enqueueEnded, isTypedPrompt, learnNext, recordTyped } from './learn'
10import { push } from './push'
11import { scan } from './rules'
12import { bandView, hasRows, paneView } from './rules-view'
13
14const PANE = 'learned-rules'
15
16const pending = atom({ plugin: 'learned-rules', key: 'pending' } as const, null)
17const diff = atom({ plugin: 'learned-rules', key: 'diff' } as const, null)
18const isPushing = atom({ plugin: 'learned-rules', key: 'isPushing' } as const, false)
19const isPushingRef = { plugin: 'learned-rules', key: 'isPushing' } as const
20
21const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error))
22
23async function host($: Engine): Promise<Host> {
24  return {
25    run: async (argv, init) => {
26      try {
27        return await $.process.run(argv, { timeoutMs: 120000, ...init })
28      } catch (error) {
29        return { exitCode: 127, stdout: '', stderr: messageOf(error) }
30      }
31    },
32    read: path => $.fs.read(path).catch(() => null),
33    write: (path, text) => $.fs.write(path, text),
34    exists: path => $.fs.exists(path).catch(() => false),
35    get: key => $.store.get(key),
36    set: (key, value) => $.store.set(key, value),
37    remove: key => $.store.delete(key),
38    userSettings: () => $.settings.read({ source: 'user' }),
39    claude: (args, prompt, cwd) =>
40      drain($.process.spawn({ argv: ['claude', ...args], cwd, env: { LEARN_FROM_CORRECTIONS: '1' }, input: prompt })),
41    toast: text => $.ui.toast(text),
42    notify: text => {
43      $.ui.toast(text)
44      void $.ui.notify(text, { title: 'Learned rules' })
45    },
46    now: () => $.clock.now(),
47    sessionId: () => $.session.id(),
48    // USERPROFILE is Windows' home.
49    home: (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? '',
50    pluginRoot: $.plugin.root,
51  }
52}
53
54// The learning run's own headless session records and queues nothing.
55async function isLearningRun($: Engine): Promise<boolean> {
56  return (await $.env.get('LEARN_FROM_CORRECTIONS')) !== undefined
57}
58
59// Every redraw hands the surface new press handles; a press landing on a tree the desktop has not
60// replaced yet reaches no handler. Skipping writes that change nothing keeps those windows rare.
61async function refresh($: Engine): Promise<void> {
62  const found = await scan(await host($))
63  if (JSON.stringify(await read($, pending)) !== JSON.stringify(found)) {
64    await update($, pending, () => found)
65  }
66}
67
68// A press only claims the push and returns; the processes run after it. The compare-and-set on
69// isPushing is what drops a second press, from either the band or the pane, while one runs.
70async function startPush($: Engine): Promise<void> {
71  const held = await $.state.get(isPushingRef)
72  if (held.value === true || !(await $.state.set(isPushingRef, true, { ifVersion: held.version })).isSet) {
73    return
74  }
75  void (async () => {
76    try {
77      await push(await host($))
78      await refresh($)
79    } finally {
80      await $.state.set(isPushingRef, false)
81    }
82  })()
83}
84
85async function rulesUi($: Engine): Promise<RulesUi> {
86  return {
87    io: await host($),
88    rule: async key => (await read($, pending))?.rules.find(r => r.key === key),
89    hide: async (rule: Rule) => void (await update($, pending, current => (current ? { ...current, rules: current.rules.filter(r => r.key !== rule.key) } : current))),
90    refresh: () => refresh($),
91    fill: async text => void (await $.prompt.fill({ text })),
92    diffKey: async () => (await read($, diff))?.key ?? null,
93    showDiff: async shown => void (await update($, diff, () => shown)),
94    startPush: () => startPush($),
95  }
96}
97
98// A press returns at once and the work runs after it, so the surface never waits on git or the store.
99function actInBackground($: Engine, message: RuleMessage): void {
100  void rulesUi($).then(ui => act(ui, message)).catch(error => $.ui.toast(`${ACTION_LABELS[message.action]} failed: ${messageOf(error)}`))
101}
102
103async function learnInBackground($: Engine): Promise<void> {
104  if (await isLearningRun($)) {
105    return
106  }
107  try {
108    await learnNext(await host($))
109    await refresh($)
110  } catch (error) {
111    $.ui.toast(`Learning run failed: ${messageOf(error)}`)
112  }
113}
114
115export const register: Register = on => {
116  on('session.start', async ($, e, next) => {
117    await $.command.register({ name: 'learned', description: 'Accept or reject the rules learned from your corrections' })
118    // A reload drops the module that ran an unfinished push, which would leave isPushing stuck.
119    await update($, isPushing, () => false)
120    const found = await scan(await host($))
121    await update($, pending, () => found)
122    if (found.rules.length > 0) {
123      $.ui.toast(`${found.rules.length} learned rule(s) to review: /learned`)
124    }
125    void learnInBackground($)
126
127    return next(e)
128  })
129
130  on('prompt.submit', async ($, e, next) => {
131    if (isTypedPrompt(e) && !(await isLearningRun($))) {
132      await recordTyped(await host($), e.text)
133    }
134
135    return next(e)
136  })
137
138  // Ending only queues the session; a session that is open runs the learning, so nothing outlives its process.
139  on('classic.SessionEnd', async ($, e, next) => {
140    if (!(await isLearningRun($))) {
141      await enqueueEnded(await host($), e.session_id, e.cwd)
142    }
143
144    return next(e)
145  })
146
147  on('turn.complete', async ($, e, next) => {
148    await refresh($)
149    void learnInBackground($)
150
151    return next(e)
152  })
153
154  on('command.run', { command: 'learned' }, async $ => {
155    await refresh($)
156    await $.ui.open({ id: PANE, title: 'Learned rules' })
157
158    return { text: 'Learned rules opened.' }
159  })
160
161  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
162    const toReview = (await read($, pending))?.rules.length ?? 0
163    const pushing = await read($, isPushing)
164    // Accepted rules leave the prompt at once; pushing them is in the /learned pane.
165    if (e.props.hasSurvey || (toReview === 0 && !pushing)) {
166      return next(e)
167    }
168
169    return bandView($.ui.resolve(e), toReview)
170  })
171
172  // Presses on the buttons the hooks draw (the band, and the pane outside the terminal) answer here, by key.
173  on('ui.press', { plugin: 'learned-rules' }, async ($, e, next) => {
174    if (e.element === 'review') {
175      await $.ui.open({ id: PANE, title: 'Learned rules' })
176      return { element: e.element }
177    }
178    const message = pressedRule(e.element)
179    if (message === null) {
180      return next(e)
181    }
182    actInBackground($, message)
183    return { element: e.element }
184  })
185
186  on('ui.message', { element: 'rules' }, ($, e) => {
187    actInBackground($, e.data as RuleMessage)
188
189    return {}
190  })
191
192  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
193    const current = await read($, pending)
194    const rules = current ? current.rules : []
195    const canPush = current !== null && rules.length === 0 && current.unpushed > 0
196    const view: RulesView = { rules, diff: await read($, diff), canPush, isPushing: await read($, isPushing) }
197
198    // The desktop draws a Client in terminal cells: its Buttons come out as plain text, so it gets its own.
199    if (e.surface === 'terminal' && hasRows(view)) {
200      const { Client } = $.ui.resolve(e)
201      return <Client key="rules" module="./rules-client.tsx" props={view} />
202    }
203    return paneView($.ui.resolve(e), view)
204  })
205}
206
hooks/actions.ts 95 lines
1import type { Rule, RuleAction, RuleMessage } from '../types'
2import type { Host } from './host'
3import { markReviewed, reject, repoPath, ruleDiff } from './rules'
4import { isEnforceable } from './rules-view'
5
6export const ACTION_LABELS: Record<RuleAction, string> = {
7  accept: 'Accept',
8  reject: 'Reject',
9  correct: 'Correct',
10  diff: 'Diff',
11  enforce: 'Enforce',
12  push: 'Push',
13}
14
15const RULE_ACTIONS: readonly RuleAction[] = ['accept', 'reject', 'correct', 'diff', 'enforce']
16
17// A Button's key is its address (`accept-<rule key>`, `pane-push`), the same on every redraw, so a press is
18// read from the key the engine reports instead of from the closure of the tree it was drawn in.
19export function pressedRule(element: string): RuleMessage | null {
20  if (element === 'pane-push') {
21    return { action: 'push', key: '' }
22  }
23  const dash = element.indexOf('-')
24  const action = element.slice(0, dash) as RuleAction
25  if (dash < 0 || !RULE_ACTIONS.includes(action)) {
26    return null
27  }
28  return { action, key: element.slice(dash + 1) }
29}
30
31// The correction is applied in place: a fixup squashed into the rule's own commit keeps its subject, so the
32// rule stays one entry to accept or reject.
33export function correctionPrompt(io: Host, rule: Rule): string {
34  const path = repoPath(io, rule.repo)
35  return [
36    `Correct the learned rule "${rule.title}" (${rule.file}, commit ${rule.sha.slice(0, 9)} in ${path}).`,
37    `Edit the rule text in that file, then: git -C ${path} commit --fixup=${rule.sha} -- <file>;`,
38    `GIT_SEQUENCE_EDITOR=true git -C ${path} rebase -i --autostash --autosquash ${rule.sha}^.`,
39    'Correction: ',
40  ].join(' ')
41}
42
43// The check is built in the session the person sends this from, so it is compiled, run and reviewed like any change.
44export function enforcePrompt(rule: Rule): string {
45  const enforce = rule.enforce
46  const where = rule.repo === 'project' ? 'in this repo' : 'in ~/.claude (a hook or a permissions rule)'
47  return [
48    `Add a static check for the learned rule "${rule.title}" (${rule.file}) ${where}: ${enforce?.tool}, ${enforce?.check}.`,
49    enforce?.status === 'partial' ? 'It covers part of the rule; say which part stays a review item.' : '',
50    'Read that enforcer first and add the check the way it writes its others (rule name, message, how it records the',
51    "violations that exist today, so only new ones fail), and show it fails on the rule's BAD example and passes on its",
52    'GOOD one.',
53  ].filter(part => part.length > 0).join(' ')
54}
55
56// What a press may change; register.tsx builds it from the engine's state.
57export type RulesUi = {
58  io: Host
59  rule: (key: string) => Promise<Rule | undefined>
60  /** Accept and reject drop the rule from the list before their git work; a failed reject comes back on refresh. */
61  hide: (rule: Rule) => Promise<void>
62  refresh: () => Promise<void>
63  fill: (text: string) => Promise<void>
64  diffKey: () => Promise<string | null>
65  showDiff: (shown: { key: string; text: string } | null) => Promise<void>
66  startPush: () => Promise<void>
67}
68
69// The rule is looked up when the press arrives, so a press never acts on a list from before a rescan.
70export async function act(ui: RulesUi, { action, key }: RuleMessage): Promise<void> {
71  if (action === 'push') {
72    return ui.startPush()
73  }
74  const rule = await ui.rule(key)
75  if (!rule) {
76    return
77  }
78  switch (action) {
79    case 'accept':
80      await ui.hide(rule)
81      await markReviewed(ui.io, rule.key, 'accepted')
82      return ui.refresh()
83    case 'reject':
84      await ui.hide(rule)
85      await reject(ui.io, rule)
86      return ui.refresh()
87    case 'correct':
88      return ui.fill(correctionPrompt(ui.io, rule))
89    case 'diff':
90      return ui.showDiff((await ui.diffKey()) === rule.key ? null : { key: rule.key, text: await ruleDiff(ui.io, rule) })
91    case 'enforce':
92      return isEnforceable(rule) ? ui.fill(enforcePrompt(rule)) : undefined
93  }
94}
95
hooks/host.ts 38 lines
1export type RunResult = { exitCode: number; stdout: string; stderr: string }
2
3export type RunInit = { cwd?: string; timeoutMs?: number }
4
5// The engine follows `$` only within the hooks module's own file, so register.tsx builds this from `$` and the
6// other files take it instead.
7export type Host = {
8  /** Never rejects: a command that cannot start or outlives its timeout resolves with exit code 127. */
9  run: (argv: string[], init?: RunInit) => Promise<RunResult>
10  /** null when the file is missing or unreadable */
11  read: (path: string) => Promise<string | null>
12  write: (path: string, text: string) => Promise<void>
13  exists: (path: string) => Promise<boolean>
14  get: (key: string) => Promise<unknown>
15  set: (key: string, value: unknown) => Promise<void>
16  remove: (key: string) => Promise<void>
17  userSettings: () => Promise<unknown>
18  /** Runs `claude` headless with the prompt on stdin; `code` is null when the child was killed. */
19  claude: (args: string[], prompt: string, cwd: string) => Promise<{ code: number | null; output: string }>
20  toast: (text: string) => void
21  /** A toast, and the person's own notification channel */
22  notify: (text: string) => void
23  now: () => Promise<number>
24  sessionId: () => Promise<string>
25  home: string
26  pluginRoot: string
27}
28
29export async function drain(child: AsyncGenerator<{ text: string }, { code: number | null }>): Promise<{ code: number | null; output: string }> {
30  let output = ''
31  let step = await child.next()
32  while (!step.done) {
33    output += step.value.text
34    step = await child.next()
35  }
36  return { code: step.value.code, output }
37}
38
hooks/learn.ts 184 lines
1import { applyRules, parseRules } from './apply'
2import { enforcerInventory } from './inventory'
3import type { Host } from './host'
4import { holdsLease, releaseLease, takeLease } from './lease'
5import { repoPath } from './rules'
6import { prepareWorktree } from './worktree'
7import type { WorktreeState } from './worktree'
8
9export type QueuedSession = { sessionId: string; cwd: string; attempts?: number; retryAt?: number }
10
11type Ready = { item: QueuedSession; worktree: WorktreeState }
12
13const TYPED_LIMIT = 80000
14const MIN_TYPED = 400
15const MAX_ATTEMPTS = 3
16const RETRY_AFTER_MS = 30 * 60 * 1000
17// Not what the user typed: reminders, notifications, skill bodies, markup and interrupts.
18const NOT_TYPED = /system-reminder|task-notification|Base directory for this skill|^\s*<|^\[Request interrupted/
19
20// The typed text steers the run, pasted text included, so the run only reads, and the mod applies the rules it
21// returns. It loads only project settings, from an empty folder it starts in, so none of the user's settings (which
22// may allow every command), hooks or plugins apply.
23function runArgs(readable: string[]): string[] {
24  return [
25    '-p', '--setting-sources', 'project', '--strict-mcp-config', '--disable-slash-commands',
26    '--tools', 'Read,Grep,Glob', ...readable.flatMap(dir => ['--add-dir', dir]),
27    '--permission-mode', 'default', '--allowedTools', 'Read,Grep,Glob',
28  ]
29}
30
31// What a person typed: at the terminal, through Remote Control, or in an app that drives Claude Code through the SDK
32// (the desktop app). Notifications, triggers, other sessions' messages and slash commands are not corrections.
33const TYPED_ORIGINS: readonly string[] = ['composer', 'bridge', 'sdk']
34
35const typedKey = (sessionId: string) => `typed:${sessionId}`
36
37export function isTypedPrompt(e: { command?: unknown; isMeta?: true; origin: { kind: string } }): boolean {
38  return !e.command && !e.isMeta && TYPED_ORIGINS.includes(e.origin.kind)
39}
40
41// One learning run per session at a time; a run the session's end kills leaves its item queued for the next one.
42let isRunning = false
43
44export async function recordTyped(io: Host, text: string): Promise<void> {
45  if (NOT_TYPED.test(text)) {
46    return
47  }
48  const key = typedKey(await io.sessionId())
49  const typed = ((await io.get(key)) as string | undefined) ?? ''
50  await io.set(key, `${typed}${text}\n`.slice(-TYPED_LIMIT))
51}
52
53export async function enqueueEnded(io: Host, sessionId: string, cwd: string): Promise<void> {
54  const typed = ((await io.get(typedKey(sessionId))) as string | undefined) ?? ''
55  if (typed.length < MIN_TYPED) {
56    await io.remove(typedKey(sessionId))
57    return
58  }
59  await io.set('queue', [...(await queued(io)).filter(item => item.sessionId !== sessionId), { sessionId, cwd }])
60}
61
62export async function learnNext(io: Host): Promise<void> {
63  const now = await io.now()
64  const due = (await queued(io)).filter(item => (item.retryAt ?? 0) <= now)
65  if (isRunning || due.length === 0) {
66    return
67  }
68  const skills = await ensureSkillsRepo(io)
69  const lease = await takeLease(io, skills)
70  if (!lease) {
71    return
72  }
73  isRunning = true
74  try {
75    const ready = await nextReady(io, due)
76    if (ready) {
77      await learnFrom(io, skills, ready, lease)
78    }
79  } finally {
80    isRunning = false
81    await releaseLease(io, skills, lease)
82  }
83}
84
85// A busy item waits as a failed one does, without using an attempt, so turns do not keep checking it.
86async function nextReady(io: Host, due: QueuedSession[]): Promise<Ready | undefined> {
87  for (const item of due) {
88    const worktree = await prepareWorktree(io, item.cwd)
89    if (worktree !== 'busy') {
90      return { item, worktree }
91    }
92    await setRetry(io, item, { retryAt: (await io.now()) + RETRY_AFTER_MS })
93  }
94  return undefined
95}
96
97async function learnFrom(io: Host, skills: string, { item, worktree }: Ready, lease: string): Promise<void> {
98  const runDir = `${io.home}/.claude/learned/run`
99  await io.write(`${runDir}/README.md`, 'The learning run starts here, in a folder with no settings.\n')
100  const project = worktree === 'ready' || worktree === 'unsynced' ? repoPath(io, 'project') : null
101  // The files it reads lie outside the folder it starts in, and a read there needs a person under -p otherwise.
102  const readable = [skills, io.pluginRoot, ...(project ? [project] : []), ...((await io.exists(item.cwd)) ? [item.cwd] : [])]
103  const { code, output } = await io.claude(runArgs(readable), await buildPrompt(io, skills, item, worktree), runDir)
104  if (code === null) {
105    return
106  }
107  const summary = `${io.home}/.claude/learned/${item.sessionId}.md`
108  const rules = parseRules(output)
109  if (code !== 0 || rules === null) {
110    await io.write(summary, output)
111    await retryLater(io, item, summary)
112    return
113  }
114  if (!(await holdsLease(io, skills, lease))) {
115    await io.write(summary, output)
116    return
117  }
118  const applied = await applyRules(io, rules, { personal: skills, project }, item.sessionId)
119  await io.write(summary, [output, ...applied.refused.map(refusal => `Not applied: ${refusal}`)].join('\n'))
120  await finish(io, item)
121  if (applied.refused.length > 0) {
122    io.notify(`${applied.refused.length} learned rule(s) were not applied. See ${summary}`)
123  }
124  if (applied.committed > 0) {
125    io.notify(`${applied.committed} rule(s) suggested. Accept or reject each with /learned.`)
126  }
127}
128
129async function retryLater(io: Host, item: QueuedSession, summary: string): Promise<void> {
130  const attempts = (item.attempts ?? 0) + 1
131  if (attempts >= MAX_ATTEMPTS) {
132    await finish(io, item)
133    io.notify(`The learning run failed ${MAX_ATTEMPTS} times; that session's corrections were dropped. See ${summary}`)
134    return
135  }
136  await setRetry(io, item, { attempts, retryAt: (await io.now()) + RETRY_AFTER_MS })
137  io.notify(`The learning run failed; it tries again in 30 minutes. See ${summary}`)
138}
139
140async function setRetry(io: Host, item: QueuedSession, retry: Pick<QueuedSession, 'attempts' | 'retryAt'>): Promise<void> {
141  await io.set('queue', (await queued(io)).map(queuedItem => (queuedItem.sessionId === item.sessionId ? { ...queuedItem, ...retry } : queuedItem)))
142}
143
144async function finish(io: Host, item: QueuedSession): Promise<void> {
145  await io.set('queue', (await queued(io)).filter(queuedItem => queuedItem.sessionId !== item.sessionId))
146  await io.remove(typedKey(item.sessionId))
147}
148
149const WORKTREE_NOTES: Record<WorktreeState, string> = {
150  ready: 'on the branch docs/learned-rules, up to date with the latest main',
151  unsynced: 'on the branch docs/learned-rules; its earlier rules could not be rebased onto main, so add to it as it is',
152  busy: '',
153  none: '',
154}
155const NO_WORKTREE = 'none: no repository worktree could be prepared for this session, so skip repo rules'
156
157async function buildPrompt(io: Host, skills: string, item: QueuedSession, worktree: WorktreeState): Promise<string> {
158  const template = (await io.read(`${io.pluginRoot}/prompts/learn.md`)) ?? ''
159  const instructions = template
160    .replaceAll('{{CWD}}', item.cwd)
161    .replaceAll('{{PLUGIN_ROOT}}', io.pluginRoot)
162    .replaceAll('{{SKILLS}}', skills)
163    .replaceAll('{{WORKTREE}}', WORKTREE_NOTES[worktree] ? `${repoPath(io, 'project')}, ${WORKTREE_NOTES[worktree]}` : NO_WORKTREE)
164  const typed = ((await io.get(typedKey(item.sessionId))) as string | undefined) ?? ''
165  const inventory = await enforcerInventory(io, item.cwd)
166  return [instructions, 'The enforcers that exist:', '-----', inventory, '-----', "The user's messages:", '-----', typed, '-----'].join('\n')
167}
168
169// Personal rules are commits in ~/.claude/skills, so /learned can accept or revert each one.
170async function ensureSkillsRepo(io: Host): Promise<string> {
171  const skills = `${io.home}/.claude/skills`
172  if ((await io.run(['git', '-C', skills, 'rev-parse', '--git-dir'])).exitCode === 0) {
173    return skills
174  }
175  await io.run(['git', 'init', '-q', skills])
176  await io.run(['git', '-C', skills, 'add', '-A'])
177  await io.run(['git', '-C', skills, 'commit', '-q', '--allow-empty', '-m', 'skills before learned rules'])
178  return skills
179}
180
181async function queued(io: Host): Promise<QueuedSession[]> {
182  return ((await io.get('queue')) as QueuedSession[] | undefined) ?? []
183}
184
hooks/push.ts 31 lines
1import type { Host, RunResult } from './host'
2import { BRANCH, repoPath } from './rules'
3
4const PR_TITLE = 'docs: rules learned from review corrections'
5const PR_BODY = 'Rules the learned-rules mod wrote from review corrections, each accepted one by one in /learned.'
6
7function failureOf(run: RunResult): string {
8  const lastLine = (text: string) => text.trim().split('\n').pop() ?? ''
9  return lastLine(run.stderr) || lastLine(run.stdout) || `exit code ${run.exitCode}`
10}
11
12export async function push(io: Host): Promise<void> {
13  const worktree = repoPath(io, 'project')
14  const pushed = await io.run(['git', '-C', worktree, 'push', '--force-with-lease', '-u', 'origin', BRANCH], { timeoutMs: 120000 })
15  if (pushed.exitCode !== 0) {
16    io.toast(`Push failed: ${failureOf(pushed)}`)
17    return
18  }
19
20  const existing = await io.run(['gh', 'pr', 'view', BRANCH, '--json', 'url,state', '-q', 'select(.state == "OPEN") | .url'], { cwd: worktree })
21  const openUrl = existing.exitCode === 0 ? existing.stdout.trim() : ''
22  if (openUrl) {
23    io.toast(`Pushed. PR updated: ${openUrl}`)
24    return
25  }
26
27  const created = await io.run(['gh', 'pr', 'create', '--base', 'main', '--head', BRANCH, '--title', PR_TITLE, '--body', PR_BODY], { cwd: worktree, timeoutMs: 60000 })
28  const createdUrl = created.stdout.trim().split('\n').pop()
29  io.toast(created.exitCode === 0 ? `Pushed. PR opened: ${createdUrl}` : `Pushed, but the PR was not opened: ${failureOf(created)}`)
30}
31
hooks/rules.ts 131 lines
1import type { EnforceStatus, Enforcement, Pending, Rule, RuleRepo } from '../types'
2import type { Host } from './host'
3
4export const BRANCH = 'docs/learned-rules'
5const REVIEWED_KEY = 'reviewed'
6const FIELD = '\u001f'
7const RECORD = '\u001e'
8const FETCH_EVERY_MS = 5 * 60 * 1000
9
10let lastFetch = 0
11
12export function repoPath(io: Host, repo: RuleRepo): string {
13  return repo === 'project' ? `${io.home}/.claude/learned/repo-worktree` : `${io.home}/.claude/skills`
14}
15
16async function reviewedKeys(io: Host): Promise<Record<string, 'accepted' | 'rejected'>> {
17  return ((await io.get(REVIEWED_KEY)) as Record<string, 'accepted' | 'rejected'> | undefined) ?? {}
18}
19
20export async function markReviewed(io: Host, key: string, verdict: 'accepted' | 'rejected'): Promise<void> {
21  await io.set(REVIEWED_KEY, { ...(await reviewedKeys(io)), [key]: verdict })
22}
23
24export async function scan(io: Host): Promise<Pending> {
25  const worktree = repoPath(io, 'project')
26  await fetchMain(io, worktree)
27  let unpushed = await aheadOfMain(io, worktree)
28  if (unpushed > 0 && (await squashMerged(io, worktree)) && (await resetToMain(io, worktree))) {
29    unpushed = 0
30  }
31
32  const reviewed = await reviewedKeys(io)
33  const project = await ruleCommits(io, 'project')
34  const personal = await ruleCommits(io, 'personal')
35  const rules = [...project, ...personal].filter(rule => !reviewed[rule.key])
36
37  return { rules, unpushed }
38}
39
40export async function hasOpenRules(io: Host, worktree: string): Promise<boolean> {
41  return (await aheadOfMain(io, worktree)) > 0 && !(await squashMerged(io, worktree))
42}
43
44async function aheadOfMain(io: Host, worktree: string): Promise<number> {
45  const ahead = await io.run(['git', '-C', worktree, 'rev-list', '--count', `origin/main..${BRANCH}`])
46  return ahead.exitCode === 0 ? Number(ahead.stdout.trim()) : 0
47}
48
49async function ruleCommits(io: Host, repo: RuleRepo): Promise<Rule[]> {
50  const path = repoPath(io, repo)
51  const range = repo === 'project' ? [`origin/main..${BRANCH}`] : []
52  const log = await io.run(['git', '-C', path, 'log', '--reverse', '--grep=^rule:', `--format=%H${FIELD}%s${FIELD}%b${RECORD}`, ...range])
53  if (log.exitCode !== 0) {
54    return []
55  }
56  return log.stdout
57    .split(RECORD)
58    .map(entry => entry.trim())
59    .filter(entry => entry.length > 0)
60    .map(entry => {
61      const [sha = '', subject = '', body = ''] = entry.split(FIELD)
62      return {
63        key: `${repo}:${body.match(/^Rule-Id: (.*)$/m)?.[1] ?? subject}`,
64        repo,
65        sha,
66        title: subject.replace(/^rule:\s*/, ''),
67        correction: body.match(/^Correction: (.*)$/m)?.[1] ?? '',
68        file: body.match(/^File: (.*)$/m)?.[1] ?? '',
69        enforce: enforcementOf(body),
70      }
71    })
72}
73
74// "Enforce: <status>; <tool>; <check>", written by the learning run; the check may itself hold semicolons.
75function enforcementOf(body: string): Enforcement | null {
76  const line = body.match(/^Enforce: (exists|new|partial|none);([^;]*);?(.*)$/m)
77  if (!line) {
78    return null
79  }
80  const [, status = '', tool = '', check = ''] = line
81  return { status: status as EnforceStatus, tool: tool.trim(), check: check.trim() }
82}
83
84// scan runs after every turn; fetching at most every five minutes keeps it off the network most turns.
85async function fetchMain(io: Host, worktree: string): Promise<void> {
86  if (Date.now() - lastFetch < FETCH_EVERY_MS) {
87    return
88  }
89  lastFetch = Date.now()
90  await io.run(['git', '-C', worktree, 'fetch', '-q', 'origin', 'main'], { timeoutMs: 30000 })
91}
92
93// The rules PR is squash-merged, so its commits stay "ahead" of main by hash forever. The branch is merged when
94// merging it into main would leave main's tree unchanged.
95async function squashMerged(io: Host, worktree: string): Promise<boolean> {
96  const merged = await io.run(['git', '-C', worktree, 'merge-tree', '--write-tree', 'origin/main', BRANCH])
97  const main = await io.run(['git', '-C', worktree, 'rev-parse', 'origin/main^{tree}'])
98  const mainTree = main.exitCode === 0 ? main.stdout.trim() : ''
99  return merged.exitCode === 0 && mainTree !== '' && merged.stdout.trim().split('\n')[0] === mainTree
100}
101
102// Only a clean worktree is reset, so a learning run that is mid-commit keeps its work.
103export async function resetToMain(io: Host, worktree: string): Promise<boolean> {
104  const status = await io.run(['git', '-C', worktree, 'status', '--porcelain'])
105  if (status.exitCode !== 0 || status.stdout.trim().length > 0) {
106    return false
107  }
108  return (await io.run(['git', '-C', worktree, 'reset', '-q', '--hard', 'origin/main'])).exitCode === 0
109}
110
111// Rejecting drops the rule: a project commit leaves the branch, a personal one is reverted.
112export async function reject(io: Host, rule: Rule): Promise<void> {
113  const path = repoPath(io, rule.repo)
114  const command = rule.repo === 'project'
115    ? ['git', '-C', path, 'rebase', '--onto', `${rule.sha}^`, rule.sha, BRANCH]
116    : ['git', '-C', path, 'revert', '--no-edit', rule.sha]
117  const undone = await io.run(command)
118  if (undone.exitCode !== 0) {
119    await io.run(['git', '-C', path, rule.repo === 'project' ? 'rebase' : 'revert', '--abort'])
120    io.toast(`Could not reject "${rule.title}": ${undone.stderr.trim().split('\n').pop()}`)
121    return
122  }
123  await markReviewed(io, rule.key, 'rejected')
124  io.toast(`Rejected: ${rule.title}`)
125}
126
127export async function ruleDiff(io: Host, rule: Rule): Promise<string> {
128  const shown = await io.run(['git', '-C', repoPath(io, rule.repo), 'show', '--format=', rule.sha])
129  return shown.stdout.slice(0, 20000)
130}
131
hooks/rules-view.tsx 64 lines
1import type { ClientElements } from 'claude-code'
2
3import type { Rule, RuleAction, RulesView } from '../types'
4
5type Tags = Pick<ClientElements, 'Box' | 'Button' | 'Markdown' | 'Text'>
6
7// A rule an existing check already covers, or that no tool can see, has nothing to build.
8export const isEnforceable = (rule: Rule) => rule.enforce?.status === 'new' || rule.enforce?.status === 'partial'
9
10// Drawn by the Client's surface module where the surface has one, and by the pane hook itself elsewhere.
11export function rulesView({ Box, Button, Markdown, Text }: Tags, view: RulesView, onAction: (action: RuleAction, key: string) => void) {
12  return (
13    <Box flexDirection="column">
14      {view.rules.map(rule => (
15        <Box key={rule.key} flexDirection="column">
16          <Text bold>
17            {rule.title} ({rule.repo === 'project' ? 'repo' : 'personal'})
18          </Text>
19          {rule.correction && <Text dimColor>From: {rule.correction}</Text>}
20          {rule.file && <Text dimColor>File: {rule.file}</Text>}
21          {rule.enforce && (
22            <Text dimColor>
23              Enforce: {rule.enforce.status}
24              {rule.enforce.tool !== '-' && ` · ${rule.enforce.tool}`}
25              {rule.enforce.check && `: ${rule.enforce.check}`}
26            </Text>
27          )}
28          <Box gap={1}>
29            <Button key={`accept-${rule.key}`} label="Accept" onPress={() => onAction('accept', rule.key)} />
30            <Button key={`reject-${rule.key}`} label="Reject" onPress={() => onAction('reject', rule.key)} />
31            <Button key={`correct-${rule.key}`} label="Correct" onPress={() => onAction('correct', rule.key)} />
32            <Button
33              key={`diff-${rule.key}`}
34              label={view.diff?.key === rule.key ? 'Hide diff' : 'Diff'}
35              onPress={() => onAction('diff', rule.key)}
36            />
37            {isEnforceable(rule) && <Button key={`enforce-${rule.key}`} label="Enforce" onPress={() => onAction('enforce', rule.key)} />}
38          </Box>
39          {view.diff?.key === rule.key && <Markdown text={`\`\`\`diff\n${view.diff.text}\n\`\`\``} />}
40        </Box>
41      ))}
42      {view.canPush && view.isPushing && <Text dimColor>Pushing accepted rules and opening the PR…</Text>}
43      {view.canPush && !view.isPushing && <Button key="pane-push" label="Push accepted rules and open the PR" onPress={() => onAction('push', '')} />}
44    </Box>
45  )
46}
47
48// The line above the prompt; its Review press answers in the hooks module, by key.
49export function bandView({ Box, Button, Text }: Pick<Tags, 'Box' | 'Button' | 'Text'>, toReview: number) {
50  return (
51    <Box>
52      <Text>{toReview > 0 ? `${toReview} learned rule(s) to review ` : 'Pushing learned rules… '}</Text>
53      {toReview > 0 && <Button key="review" label="Review" onPress={() => {}} />}
54    </Box>
55  )
56}
57
58export const hasRows = (view: RulesView) => view.rules.length > 0 || view.canPush
59
60// The pane where the hooks module draws it itself: every surface but the terminal, and the empty list.
61export function paneView(tags: Tags, view: RulesView) {
62  return hasRows(view) ? rulesView(tags, view, () => {}) : <tags.Text dimColor>No learned rules to review.</tags.Text>
63}
64
hooks/rules-client.tsx 13 lines
1import type { ClientModule } from 'claude-code'
2
3import type { RulesView } from '../types'
4import { rulesView } from './rules-view'
5
6// A Button the hooks module draws is pressed through a handle that every redraw replaces, so a click that
7// races a redraw (the pane taking focus, a rescan) is dropped. This module's buttons live on the surface
8// and survive redraws; a press posts the action and the hooks module looks the rule up when it arrives.
9const RulesClient: ClientModule<RulesView> = (view, surface) =>
10  rulesView(surface.elements, view, (action, key) => surface.post({ action, key }))
11
12export default RulesClient
13
hooks/apply.ts 148 lines
1import type { Host } from './host'
2
3export type RuleEdit = { old: string; new: string }
4
5export type LearnedRule = {
6  repo: 'personal' | 'project'
7  file: string
8  edits: RuleEdit[]
9  subject: string
10  correction: string
11  enforce: string
12}
13
14/** `project` is null when no repo worktree was prepared for this run. */
15export type RuleRoots = { personal: string; project: string | null }
16
17export type Applied = { committed: number; refused: string[] }
18
19const RULES_BLOCK = /```rules\s*\n([\s\S]*?)\n```/g
20const POWER_KEY = /^["']?(hooks|allowed-tools)["']?\s*:/
21// A skill runs !`cmd` and ```! blocks when it loads.
22const SHELL_IN_SKILL = /(^|\s)!`[^`]+`|```![\s\S]*?```/g
23const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---/
24
25/** The entries of the run's last ```rules block; null when there is none or it is not JSON with a rules list. */
26export function parseRules(output: string): unknown[] | null {
27  const block = [...output.matchAll(RULES_BLOCK)].pop()?.[1]
28  try {
29    const parsed = JSON.parse(block ?? '') as { rules?: unknown }
30    return Array.isArray(parsed.rules) ? parsed.rules : null
31  } catch {
32    return null
33  }
34}
35
36// The mod, not the run, writes and commits each rule, one commit per rule: the file must be a Markdown rule (or the
37// skill-trigger list) inside its repo, each edit must match exactly once, and no edit may add or change a skill's
38// hooks or allowed-tools, which would run commands or widen what the skill may do.
39export async function applyRules(io: Host, entries: unknown[], roots: RuleRoots, runId: string): Promise<Applied> {
40  const refused: string[] = []
41  for (const [index, entry] of entries.entries()) {
42    const problem = isLearnedRule(entry) ? await applyRule(io, entry, roots, `${runId}.${index + 1}`) : 'not in the expected shape'
43    if (problem) {
44      refused.push(`${isLearnedRule(entry) ? entry.subject : `entry ${index + 1}`}: ${problem}`)
45    }
46  }
47  return { committed: entries.length - refused.length, refused }
48}
49
50async function applyRule(io: Host, rule: LearnedRule, roots: RuleRoots, ruleId: string): Promise<string | null> {
51  const root = rule.repo === 'personal' ? roots.personal : roots.project
52  if (!root) {
53    return 'there is no repo worktree this run'
54  }
55  if (!isRuleFile(rule) || !rule.subject.startsWith('rule: ')) {
56    return `not a rule file or rule subject: ${rule.file}`
57  }
58  const path = `${root}/${rule.file}`
59  const existing = await io.read(path)
60  const before = existing ?? ''
61  const after = rule.edits.reduce<string | null>((text, edit) => (text === null ? null : applyEdit(text, edit)), before)
62  if (after === null) {
63    return 'an edit does not match the file exactly once'
64  }
65  if (powers(after) !== powers(before)) {
66    return 'it adds or changes hooks or allowed-tools'
67  }
68  if (commands(after) !== commands(before)) {
69    return 'it adds or changes a shell command a skill runs when it loads'
70  }
71  // A rule's own add, and the reset after a failed commit, would replace or drop what the user staged in that file.
72  if ((await io.run(['git', '-C', root, 'diff', '--cached', '--quiet', '--', rule.file])).exitCode !== 0) {
73    return 'the file has staged changes'
74  }
75  // An existing file git does not track is the user's own work; committing it would put that work in the rule.
76  if (existing !== null && (await io.run(['git', '-C', root, 'ls-files', '--error-unmatch', '--', rule.file])).exitCode !== 0) {
77    return 'the file is not tracked by git'
78  }
79  await io.write(path, after)
80  await io.run(['git', '-C', root, 'add', '--', rule.file])
81  const committed = await io.run(['git', '-C', root, 'commit', '-q', '-m', rule.subject, '-m', bodyOf(rule, ruleId), '--', rule.file])
82  if (committed.exitCode === 0) {
83    return null
84  }
85  await undoWrite(io, root, rule.file, existing)
86  return `commit failed: ${committed.stderr.trim().split('\n').pop()}`
87}
88
89// The file and the index go back to how they were, so neither repo is left dirty by a rule that did not land.
90async function undoWrite(io: Host, root: string, file: string, existing: string | null): Promise<void> {
91  await io.run(['git', '-C', root, 'reset', '-q', '--', file])
92  if (existing !== null) {
93    await io.write(`${root}/${file}`, existing)
94    return
95  }
96  await io.run(['git', '-C', root, 'clean', '-q', '-f', '--', file])
97}
98
99function commands(text: string): string {
100  return [...text.matchAll(SHELL_IN_SKILL)].map(match => match[0].trim()).join('\n')
101}
102
103function bodyOf(rule: LearnedRule, ruleId: string): string {
104  const lines = [`Correction: ${rule.correction}`, `File: ${rule.file}`, rule.enforce ? `Enforce: ${rule.enforce}` : '', `Rule-Id: ${ruleId}`]
105  return lines.filter(line => line.length > 0).join('\n')
106}
107
108// An empty `old` appends `new` at the end, which also creates a file.
109function applyEdit(text: string, edit: RuleEdit): string | null {
110  if (edit.old === '') {
111    return `${text}${text === '' || text.endsWith('\n') ? '' : '\n'}${edit.new}`
112  }
113  const at = text.indexOf(edit.old)
114  if (at < 0 || text.indexOf(edit.old, at + 1) >= 0) {
115    return null
116  }
117  return text.slice(0, at) + edit.new + text.slice(at + edit.old.length)
118}
119
120function isRuleFile({ repo, file }: LearnedRule): boolean {
121  const parts = file.split('/')
122  const isInside = !/^([/\\]|[A-Za-z]:)/.test(file) && !file.includes('\\') && !parts.includes('..') && !parts.includes('.git')
123  return isInside && (file.endsWith('.md') || (repo === 'personal' && file === 'skill-triggers.json'))
124}
125
126// Only the hooks and allowed-tools entries of the frontmatter: each key's line and the indented lines under it.
127function powers(text: string): string {
128  const front = text.match(FRONTMATTER)?.[1] ?? ''
129  let isPower = false
130  return front
131    .split(/\r?\n/)
132    .filter(line => {
133      isPower = /^\S/.test(line) ? POWER_KEY.test(line) : isPower
134      return isPower
135    })
136    .join('\n')
137}
138
139function isLearnedRule(value: unknown): value is LearnedRule {
140  const rule = value as Partial<LearnedRule> | null
141  const isText = (field: unknown) => typeof field === 'string'
142  return (
143    (rule?.repo === 'personal' || rule?.repo === 'project') &&
144    isText(rule.file) && isText(rule.subject) && isText(rule.correction) && isText(rule.enforce) &&
145    Array.isArray(rule.edits) && rule.edits.every(edit => isText(edit?.old) && isText(edit?.new))
146  )
147}
148
hooks/inventory.ts 142 lines
1import type { Host } from './host'
2
3// Tracked files that configure a linter, a structural check, a ratchet, a policy or a CI gate, in any language.
4const ENFORCER_FILES = new RegExp(
5  '(^|/)(' +
6    [
7      '(pmd|checkstyle|spotbugs|codenarc|detekt|lint|linters)/[^/]+',
8      '[^/]*(pmd|checkstyle|spotbugs|codenarc|detekt)[^/]*\\.(xml|groovy|ya?ml)',
9      '[^/]*(ArchitectureRules|ArchRule|Architecture(Test|Spec)|ArchitecturePredicates|Konsist)[^/]*',
10      'archunit[^/]*',
11      'rewrite\\.ya?ml',
12      '[^/]*(ratchet|baseline|coverage-floors)[^/]*',
13      'spotless\\.gradle',
14      'sonar-project\\.properties',
15      'eslint\\.config\\.[cm]?[jt]s',
16      '\\.eslintrc[^/]*',
17      'biome\\.jsonc?',
18      '\\.dependency-cruiser[^/]*',
19      'knip\\.jsonc?',
20      '\\.?clippy\\.toml',
21      'deny\\.toml',
22      '\\.golangci\\.ya?ml',
23      'ruff\\.toml',
24      '\\.importlinter',
25      '\\.editorconfig',
26      '[^/]*\\.ruleset',
27      '\\.squawk\\.toml',
28      '\\.sqlfluff',
29      '\\.redocly[^/]*',
30      '\\.spectral[^/]*',
31      '\\.tflint\\.hcl',
32      '[^/]*\\.rego',
33      '\\.checkov[^/]*',
34      '\\.vale\\.ini',
35      '\\.markdownlint[^/]*',
36      '\\.pre-commit-config\\.ya?ml',
37      'lefthook\\.ya?ml',
38      '\\.husky/[^/]+',
39      '[Dd]angerfile[^/]*',
40      'CODEOWNERS',
41      '\\.semgrep[^/]*',
42      'sgconfig\\.ya?ml',
43      '\\.gitleaks\\.toml',
44      'zizmor\\.ya?ml',
45      '\\.github/workflows/[^/]+',
46      '\\.claude/hooks/[^/]+',
47      '\\.claude/settings\\.json',
48    ].join('|') +
49    ')$',
50)
51const NOT_ENFORCERS = /\/archunit_store\/|\.sql$/
52const MAX_FILES = 120
53
54type HookGroup = { matcher?: string; hooks?: { command?: string }[] }
55type SettingsHooks = { hooks?: Record<string, HookGroup[]>; permissions?: { deny?: unknown[] } }
56
57// What already enforces rules where the session ran: the repo's checks, what GitHub requires before a merge, and the
58// Claude hooks.
59export async function enforcerInventory(io: Host, cwd: string): Promise<string> {
60  const lines: string[] = []
61  const top = await io.run(['git', '-C', cwd, 'rev-parse', '--show-toplevel'])
62  const root = top.exitCode === 0 ? top.stdout.trim() : ''
63  if (root) {
64    lines.push(...(await repoEnforcers(io, root)))
65  }
66  lines.push("The user's Claude hooks and permission rules:")
67  lines.push(...settingsLines('user settings', await io.userSettings()))
68  if (root) {
69    lines.push(...settingsLines(`${root}/.claude/settings.json`, parsed(await io.read(`${root}/.claude/settings.json`))))
70  }
71  return lines.join('\n')
72}
73
74async function repoEnforcers(io: Host, root: string): Promise<string[]> {
75  // Rules land on main (prepareWorktree has just fetched it), which a session's checkout can trail.
76  const ref = (await io.run(['git', '-C', root, 'rev-parse', '-q', '--verify', 'origin/main'])).exitCode === 0 ? 'origin/main' : 'HEAD'
77  const tree = await io.run(['git', '-C', root, 'ls-tree', '-r', '--name-only', ref])
78  const files = tree.stdout.split('\n').filter(path => ENFORCER_FILES.test(path) && !NOT_ENFORCERS.test(path))
79  const lines = [
80    `Enforcer files on ${ref} in ${root} (the repo worktree, when there is one, holds this same tree):`,
81    ...files.slice(0, MAX_FILES).map(path => `- ${path}`),
82  ]
83  const remote = await io.run(['git', '-C', root, 'remote', 'get-url', 'origin'])
84  const slug = remote.stdout.trim().match(/github\.com[:/]([^/]+\/[^/]+?)(\.git)?$/)?.[1]
85  if (slug) {
86    lines.push(...(await githubRules(io, slug, 'main')))
87  }
88  return lines
89}
90
91async function githubRules(io: Host, slug: string, branch: string): Promise<string[]> {
92  const lines = [`What GitHub enforces on ${slug} ${branch} (a check that is not required here does not block a merge):`]
93  const rules = await io.run(['gh', 'api', `repos/${slug}/rules/branches/${branch}`])
94  lines.push(...answered(rules, 'rulesets', body => {
95    const found = (body as { type: string; parameters?: { required_status_checks?: { context: string }[] } }[]).map(rule => {
96      const checks = rule.parameters?.required_status_checks?.map(check => check.context).join(', ')
97      return `- ruleset rule ${rule.type}${checks ? `: ${checks}` : ''}`
98    })
99    return found.length > 0 ? found : ['- no ruleset rules']
100  }))
101  const protection = await io.run(['gh', 'api', `repos/${slug}/branches/${branch}/protection`])
102  lines.push(...answered(protection, 'branch protection', body => {
103    const contexts = (body as { required_status_checks?: { contexts?: string[] } }).required_status_checks?.contexts ?? []
104    return [`- branch protection, required checks: ${contexts.join(', ')}`]
105  }))
106  return lines
107}
108
109// gh prints GitHub's error body on stdout too, so a refusal (a plan without rulesets, no access) names its reason.
110function answered(run: { exitCode: number; stdout: string }, what: string, read: (body: unknown) => string[]): string[] {
111  const body = parsed(run.stdout)
112  if (run.exitCode === 0 && body !== null) {
113    return read(body)
114  }
115  const reason = (body as { message?: string } | null)?.message ?? (run.exitCode === 127 ? 'gh is not installed' : 'no response')
116  return [`- ${what} not readable: ${reason}`]
117}
118
119function settingsLines(source: string, settings: unknown): string[] {
120  if (settings === null || typeof settings !== 'object') {
121    return []
122  }
123  const { hooks = {}, permissions } = settings as SettingsHooks
124  const hookLines = Object.entries(hooks).flatMap(([event, groups]) =>
125    groups.flatMap(group =>
126      (group.hooks ?? []).map(hook => `- ${source} ${event}${group.matcher ? ` [${group.matcher}]` : ''}: ${hook.command ?? ''}`),
127    ),
128  )
129  return [...hookLines, `- ${source} permissions.deny: ${permissions?.deny?.length ?? 0} rules`]
130}
131
132function parsed(text: string | null): unknown {
133  if (text === null) {
134    return null
135  }
136  try {
137    return JSON.parse(text)
138  } catch {
139    return null
140  }
141}
142
hooks/lease.ts 33 lines
1import type { Host } from './host'
2
3const LEASE_REF = 'refs/learned/lease'
4const NO_REF = '0'.repeat(40)
5const LEASE_S = 60 * 60
6
7// $.store has no compare-and-set; git update-ref with the expected old value does, so of two sessions that both see
8// the lease free or expired, one takes it. The lease is a commit whose time is when it was taken.
9export async function takeLease(io: Host, skills: string): Promise<string | null> {
10  const made = await io.run(['git', '-C', skills, 'commit-tree', 'HEAD^{tree}', '-m', `learning lease: ${await io.sessionId()}`])
11  const mine = made.stdout.trim()
12  if (made.exitCode !== 0 || !mine) {
13    return null
14  }
15  const held = await io.run(['git', '-C', skills, 'log', '-1', '--format=%H %ct', LEASE_REF])
16  const [sha = NO_REF, takenAt = '0'] = held.exitCode === 0 ? held.stdout.trim().split(' ') : []
17  if (sha !== NO_REF && Number(takenAt) > (await io.now()) / 1000 - LEASE_S) {
18    return null
19  }
20  const taken = await io.run(['git', '-C', skills, 'update-ref', LEASE_REF, mine, sha])
21  return taken.exitCode === 0 ? mine : null
22}
23
24// A run can outlive its lease; the session that took the lease over then owns the queue item, so this one applies
25// nothing.
26export async function holdsLease(io: Host, skills: string, mine: string): Promise<boolean> {
27  return (await io.run(['git', '-C', skills, 'rev-parse', '-q', '--verify', LEASE_REF])).stdout.trim() === mine
28}
29
30export async function releaseLease(io: Host, skills: string, mine: string): Promise<void> {
31  await io.run(['git', '-C', skills, 'update-ref', '-d', LEASE_REF, mine])
32}
33
hooks/worktree.ts 59 lines
1import type { Host } from './host'
2import { BRANCH, hasOpenRules, repoPath, resetToMain } from './rules'
3
4/**
5 * `unsynced`: it holds earlier rules that could not be rebased onto main; `busy`: it holds another repository's open
6 * rules; `none`: the session ran outside a git repository, or no worktree could be added.
7 */
8export type WorktreeState = 'ready' | 'unsynced' | 'busy' | 'none'
9
10// The mod sets the worktree up itself, so the learning run needs no git command but add and commit. One worktree
11// holds one repository's rules at a time: a session from another repository waits until those rules are merged.
12export async function prepareWorktree(io: Host, cwd: string): Promise<WorktreeState> {
13  const worktree = repoPath(io, 'project')
14  const theirs = await commonDir(io, cwd)
15  if (!theirs) {
16    return 'none'
17  }
18  const ours = await commonDir(io, worktree)
19  // The folder outlived its repository, and git cannot add a worktree over it; the mod has no way to delete files.
20  if (!ours && (await io.exists(worktree))) {
21    io.notify(`Remove ${worktree}: its repository is gone, so repo rules wait until it is deleted.`)
22    return 'none'
23  }
24  if (ours && ours !== theirs && (await hasOpenRules(io, worktree))) {
25    return 'busy'
26  }
27  if (ours && ours !== theirs) {
28    await io.run(['git', `--git-dir=${ours}`, 'worktree', 'remove', '--force', worktree])
29  }
30  await io.run(['git', `--git-dir=${theirs}`, 'fetch', '-q', 'origin'], { timeoutMs: 60000 })
31  if (ours !== theirs && !(await addWorktree(io, theirs, worktree))) {
32    return 'none'
33  }
34  return (await syncWithMain(io, worktree)) ? 'ready' : 'unsynced'
35}
36
37async function addWorktree(io: Host, gitDir: string, worktree: string): Promise<boolean> {
38  const hasBranch = (await io.run(['git', `--git-dir=${gitDir}`, 'rev-parse', '-q', '--verify', `refs/heads/${BRANCH}`])).exitCode === 0
39  const start = hasBranch ? [BRANCH] : ['-b', BRANCH, 'origin/main']
40  return (await io.run(['git', `--git-dir=${gitDir}`, 'worktree', 'add', '-q', worktree, ...start])).exitCode === 0
41}
42
43// New rules start from the latest main: open rules are rebased onto it, a branch with none is reset to it.
44async function syncWithMain(io: Host, worktree: string): Promise<boolean> {
45  if (!(await hasOpenRules(io, worktree))) {
46    return resetToMain(io, worktree)
47  }
48  if ((await io.run(['git', '-C', worktree, 'rebase', '-q', 'origin/main'])).exitCode === 0) {
49    return true
50  }
51  await io.run(['git', '-C', worktree, 'rebase', '--abort'])
52  return false
53}
54
55async function commonDir(io: Host, path: string): Promise<string> {
56  const found = await io.run(['git', '-C', path, 'rev-parse', '--path-format=absolute', '--git-common-dir'])
57  return found.exitCode === 0 ? found.stdout.trim() : ''
58}
59