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

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.
/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.
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.
~/.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.Enforce: <status>; <enforcer>; <what it checks>.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:~/.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:skill-triggers.json) inside that repo, outside .git;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;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.
| Status | Meaning |
|---|---|
exists | an enforcer already fails on the rule's BAD example; the line names it |
new | an enforcer you run could check it; the line names its file and the check to add |
partial | an enforcer can catch a plain form of it; the line says which part |
none | a 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).
/learned opens the list. Each rule shows where it came from, its file and its Enforce line:
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.docs/learned-rules and opens one pull request to main.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.
main.hooks/register.tsx 206 lines1import { 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}
206hooks/actions.ts 95 lines1import 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}
95hooks/host.ts 38 lines1export 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}
38hooks/learn.ts 184 lines1import { 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}
184hooks/push.ts 31 lines1import 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}
31hooks/rules.ts 131 lines1import 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}
131hooks/rules-view.tsx 64 lines1import 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}
64hooks/rules-client.tsx 13 lines1import 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
13hooks/apply.ts 148 lines1import 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}
148hooks/inventory.ts 142 lines1import 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}
142hooks/lease.ts 33 lines1import 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}
33hooks/worktree.ts 59 lines1import 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