Gives each finding of done-gate, assertion-guard, self-audit and zukan a later verdict from what the session did next, keeps each rule's precision across…

English | 日本語
Personal Claude Code mods (function-hook plugins), published as one marketplace.
| Mod | What it does |
|---|---|
agent-pane | Live pane of the session's subagents: running ones with elapsed time, finished ones with result and duration |
done-gate | Flags unverified edits, stops repeated failing commands, and blocks git --no-verify |
sandbox-lens | Tallies sandbox denials seen in Bash output and reports them with /sandbox-report |
skill-hint | Suggests up to three matching skills above the prompt from what was typed; never invokes them. |
todo-agent | Collects #TODO(agent) follow-ups from Claude's replies into a per-project list with a /todos pane. |
context-meter | Band above the prompt with a Gruvbox sparkline of context use per turn, plus tokens and cost |
tool-row | Redraws finished tool-call rows in the terminal as one compact line with a Nerd Font icon, Gruvbox colors and a short stat |
image-preview | Thumbnails of the images a session writes, reads or generates: under the tool's row in the terminal, and in an /images pane |
self-audit | After each turn, a cached fork of the conversation audits Claude's own reply against the CLAUDE.md reporting rules and flags violations above the prompt |
context-diet | Trims the skill listing to what the project kind needs (off by default); /context-diet shows what was dropped and the tokens saved |
browser-cam | Live pane of the browser Claude drives: the latest screenshot from Claude in Chrome, the page URL and the recent actions |
prompt-coach | Holds back a typed prompt that looks ambiguous (no done-condition, unclear target) and offers guesses at what it meant, sending it as is, or adding one; on by default |
loopback | Feeds what self-audit, done-gate and sandbox-lens found back to Claude beside the next prompt, instead of only showing it to the person, skipping rules hit-rate found mostly wrong; off by default |
zukan | Error book: catches the errors Bash commands hit across sessions and repos, records what got past each one, and when one comes back tells Claude and the person how it was fixed last time |
pr-lamp | The current branch's pull request as a lamp: checks, review, unresolved threads and what is left before a merge, in the status line and a /pr pane; red checks and new review comments go to Claude beside the next prompt |
fog-map | Exploration map: the repository as a tree under fog, cleared where Claude read and marked where it edited, made a file or hit an error, with the importers an edit may have broken unseen; /map |
assertion-guard | Catches edits that weaken tests to pass: assertions removed, expectations loosened or rewritten, tests skipped, errors swallowed; asks Claude for the reason beside the edit |
negative-space | When an edit adds authorization, validation or error handling, lists the refusal and boundary-value tests no test touched this session covers; /negative-space |
mutation-tap | /mutate runs a mutation test limited to the lines changed since HEAD and lists the mutants the tests did not catch |
perf-delta | /perf compares a benchmark before and after (a saved baseline, or a git ref in a temporary worktree): median time, peak memory, spread, and whether the change beats the noise |
handoff-capsule | Before a compaction and at session end, keeps the goal, decisions, open items, commands to reproduce and files touched; the next session picks which parts to bring back |
port-lantern | The dev servers the session started: URL, port, whether each still listens, and their latest log lines, in the status line and /ports |
artifact-shelf | A shelf of what sessions made (CSV, PDF, HTML, video, patches…), tied to the call and request that made each; /shelf searches by kind, word and time |
focus-hud | One status line: goal, target, what is running and for how long, and what the session waits on; /focus |
side-effect-ledger | A ledger of what a git diff never shows (push, publish, deploy, PRs, API writes, messages) with outcome, how to undo, and whether the undo was checked; /ledger |
trust-label | Labels external text (web, issues, logs, browser pages, services) with its source beside the result and flags candidate injected instructions; a heuristic, never a safety verdict; /trust |
visual-diff | Compares browser screenshots of the same URL and size before and after an edit: side by side, a map of changed cells, regions, and the edits in between; /vdiff |
journey-trace | Records Claude's browser actions and the expected states as a replayable recipe; /journey md for a bug report, /journey e2e for a Playwright draft |
hit-rate | Gives each finding of done-gate, assertion-guard, self-audit and zukan a later verdict from what the session did next, keeps each rule's precision across sessions, and names the rules loopback stops feeding; /hit-rate; off by default |
token-lens | Checks an element's actual colours, spacing and type against the project's design tokens and lists off-token values with the nearest token; /token-lens <selector> |
At the prompt of a terminal session:
/plugin install <mod> --marketplace simota/claude-mods
From a shell:
claude plugin install <mod> --marketplace simota/claude-mods
This repository is private: installing needs read access on GitHub and a git credential for github.com on the machine (an SSH key, or gh auth login then gh auth setup-git).
Options are set with /plugin configure <mod>@simota-mods in a session, or from a shell:
echo '{"enabled":"true"}' | claude plugin configure <mod>@simota-mods --values-stdin
A change made with /plugin configure reloads the mod; one made from a shell applies after a restart. Bands above the prompt stack: each mod draws its row above what the others draw.
/agent-pane opens the pane: running subagents first with elapsed time, then finished ones with ✓/✗ and duration; a background run that was stopped shows ■ 停止. A background run that left the roster without a result shows ? 結果不明; a teammate or remote launch shows ↗ 起動済み(追跡外).agents: N running while any run.Works on its own, no command:
ls build, npm ci are not checks), and only when its exit status is the command's: the check is last, or only && follows it (npm test | tail does not count).--no-verify (or a prefix of it from --no-ver), and git commit -n / -nm, is denied, also inside subshells, $(…), backticks, bash -c "…", eval and wrappers such as sudo/timeout. The value of an option (-m "…"), a comment or a heredoc never triggers it. So is a git command that moves its hooks for the run (-c core.hooksPath=…, --config-env core.hooksPath=…) or carries a hook manager's off switch in its environment (HUSKY=0, LEFTHOOK=0, pre-commit's SKIP=…), or moves the hooks through config in its environment (GIT_CONFIG_KEY_n=core.hooksPath, GIT_CONFIG_PARAMETERS). ANSI-C quoting ($'--no-\x76erify'), env -S "…", bash -c -- '…' and a shell reading its script from stdin (bash <<< '…', a heredoc, echo '…' | sh) are read through. It is a best-effort guard: git aliases (git ci -n), a hook path written into config beforehand and an environment exported by an earlier command are not detected./sandbox-report lists the sandbox denials seen in Bash output, for this session and all time: denied hosts and paths, counts, last seen, an example command, and which setting would allow each./sandbox-report reset clears the record.127.0.0.1:3000), since an entry without a port opens every port.<sandbox_violations> block, and Operation not permitted lines a program printed about itself, are read. For a command that succeeded or was interrupted, the block is taken from where the engine puts it, apart from the command's own output, so one inside a shown file or page is ignored even when it ends the output. A failed command keeps only its error text: the block that ends it (before an agent-proxy note) is read, and there a block the command printed itself cannot be told from the harness's. Credentials in the example command (tokens, passwords, Authorization/Cookie and other credential headers such as X-API-Key, URL userinfo, keys in env assignments including run-together names such as PGPASSWORD, known key shapes) are masked. Each section shows the top 10; when the record is full (200), the least recently seen entry gives way.After a prompt is sent, up to three skills matching it show above the prompt (💡 skill候補: /coding-debug …), only ones that exist in the session. Nothing is invoked; 閉じる or the next prompt (a slash command included) clears it. A prompt that already names a skill (/name anywhere, or a hyphenated skill name such as coding-debug) gets no hint; built-in commands such as /clear and path segments such as src/review/ do not count as naming one. Plugin skills (plugin:name) are suggested by their full name.
#TODO(agent): <action> (after an optional quote or list bullet) in Claude's main-loop replies are collected per project (repo root or cwd). A mention inside a sentence, a table or inline code is not a TODO; subagent replies are ignored./todos opens the pane of this project's open items, each with a 完了 button./todos all lists every project's open items as text, marked as stored data.TODO(agent): N while the project has open items.A band with a sparkline of context use per turn and ctx 63% ▲4 · 126k · turn in 120k / out 8k · $1.23: the context size of the last request, then the whole turn's input/output (shown when the turn reported usage). The percentage turns yellow from 50% and red from 80%.
| Option | Default | |
|---|---|---|
band | true | Draw the band (samples are recorded either way) |
Finished tool calls are drawn as one compact line with a Nerd Font icon: Edit/Write path +A −D, Read with line count, Bash (red on error), Agent, web and MCP calls. The result block under a compacted row is hidden, so a call is one line; an errored result stays. A row another mod decorated (an image-preview thumbnail) is left as it is, result included. Runs Claude Code folds into one count line (Read 3 files, ran 2 shell commands) stay folded. Terminal only.
| Option | Default | |
|---|---|---|
compact | true | Off leaves every row and result as Claude Code draws them |
/images opens a pane, newest first; h/l step through them.sips after the tool result has gone back, so tool calls never wait on it. Converted files live in a private cache ($TMPDIR or ~/.cache), newest 50 kept. A killed session's folder is removed by a later session's start once a day untouched; a running session touches its own every hour (again after a plugin reload), so it is kept while idle, though not across a day the machine sleeps./tmp/a b/x.jpg) joins at most 8 preceding pieces and 4096 characters; the first 64 paths named are read, and of those the first 4 that exist are shown.| Option | Default | |
|---|---|---|
inline | true | Thumbnail under the tool row |
maxRows | 12 | Rows a thumbnail may take, 1 to 60 |
After each reply of at least minChars characters, one tool-less fork of the conversation (served mostly from the prompt cache), given the reply itself as quoted data, checks it against the CLAUDE.md rules: claims done without evidence, first line not the result, unverified assertions, bypassed checks, scope inflation. Only violations of the five rules whose quote is found in the reply are kept (a made-up quote or rule is dropped); the quote is compared past markdown marks, bullets, curly quotes, case, spacing and a closing full stop, and one shortened with … is found piece by piece. Violations show in red above the prompt; 詳細 or /self-audit opens the pane with quotes and the fork's token use. Results belong to the latest reply only: when it was not audited, the pane says why.
| Option | Default | |
|---|---|---|
enabled | true | |
minChars | 200 | Shorter replies are not audited |
Cost: one extra request per turn on the session's model, plus the reply text (up to 12,000 characters) as uncached input; a cold cache (after 5 minutes idle or a model switch) resends the whole conversation.
Off by default. When on, the skill listing is trimmed to what the project kind needs (Swift, Android, Rust, web, Python, Go, detected from the repo root and one level below). Coding, quality, planning and research skills are always kept; red-team, dynamic-*, persona and operation skills are dropped; image, design, ux-scan and brand-scan are dropped for Rust, Python and Go. Only the main loop's listing is reported. A dropped skill named in a prompt is still described for that prompt.
/context-diet shows the project kind, what was kept and dropped in the latest listing, and the tokens saved (estimate). A listing it cannot read reliably goes out unchanged./context-diet on|off|reset overrides the option for this session. Switching rebuilds the listing, so the prompt cache misses once.| Option | Default | |
|---|---|---|
enabled | false | |
keep | Comma list of names or prefix* never dropped; wins over drop | |
drop | Comma list of names or prefix* always dropped |
type ‹7文字›), since the field may be a password; a URL's secrets (token parameters, userinfo, key-shaped parts) are masked./cam toggles the pane; in a fullscreen terminal it opens by itself once per session, on the first browser call. Screenshots are decoded only while the pane is open, and written to a private per-session folder that is removed at session end. A killed session's folder is removed by a later session's start once a day untouched; a running session touches its own every hour (again after a plugin reload), so it is kept while idle, though not across a day the machine sleeps.| Option | Default | |
|---|---|---|
autoOpen | true | Open on the first browser call (fullscreen terminal) |
On by default (enabled: false turns it off). A typed prompt that looks ambiguous (no done-condition and an unclear target, or only evaluative words like いい感じ) is held back, and a band shows what is missing with up to three guesses at what it meant (judged along the lines of the clarify skill: the likeliest reading first, each a different deliverable with a checkable done-condition):
a / b / d: a guess goes into the prompt to edit and send (not held again)s: このまま送る sends it unchangede: 補足して送る puts it back with 完了条件: to fill in (not held again)q: 閉じる puts it back in the promptTyping another prompt instead sends that one and puts the held text back into the prompt. The held text is never lost: when a send fails or is dropped beneath, or putting words back fails, it stays held with its band (unless another prompt was held since) and its words are shown in a toast; a prompt still held at /clear goes straight back into the box, and one held when the session quits comes back at this project's next interactive session start. Words go into an empty box as they are, and are appended after a draft typed meanwhile on a line of their own, never over it.
Slash commands, prompts under 12 or over 1500 characters, prompts with any attachment or an @ mention, prompts sent by a plugin, and prompts typed while a turn runs are not judged. A timeout or error sends the prompt unchanged; the same text is not held twice (within the last 50 judged). Sending can wait up to timeoutMs.
| Option | Default | |
|---|---|---|
enabled | true | |
model | claude-haiku-5-5 | Judging model (alias or id) |
timeoutMs | 3000 | 500 to 3000 |
Off by default. When on, the new findings of the detector mods are attached to the next prompt typed at an idle prompt, as context only the model reads, so the correction does not wait for the person to retype it:
self-audit: the violations of the latest audited replydone-gate: edits with no passing check after them (again after a check passed and new edits were left unverified; 閉じる on its band takes the flag down, and nothing is fed)sandbox-lens: denials seen this session, each onceEach finding goes back once per session, marked as data that may be wrong for Claude to check before acting. A toast says how many went back and from where. Slash commands, hidden prompts, prompts sent by a plugin and prompts typed while a turn runs carry nothing. A detector that is not installed contributes nothing.
With hit-rate on, the rules it muted are not fed (sandbox-lens denials never are muted).
/loopback shows what was fed last and the self-audit repeat rate: of the rules fed back, how many self-audit flagged again on the next audited reply (all sessions). The rate is what the design is judged by (.genius/decisions/loopback.md). It also names the rules hit-rate muted.| Option | Default | |
|---|---|---|
enabled | false |
Cost: the findings ride as extra input on the prompt they are attached to (each quote clipped to 300 characters, at most 12 findings).
An error book, kept across sessions and repos. When a Bash command fails, the error line is caught and numbered:
📕 新種のエラー発見! No.042「…」 — a kind not seen before📙 No.017 再会(3回目・未解決) — seen before, nothing known to get past it 📗 既知 No.017(3回目)— 前回: sandbox 外で git push origin main ` — seen before with a fix: Claude also reads, beside the tool's result, where it was seen and what got past it (marked as a past record to check, not an instruction)A fix is learned when the same program and subcommand (git push, pnpm install; read past a leading cd, export or source, so cd app && npm install is npm install) succeeds within the next 10 Bash calls of the session; whether it ran outside the sandbox is kept with it. Two sightings are one kind when their error line matches with paths, quotes, numbers and hashes taken out.
Failures of tests, linters, type checkers and builds are not caught: they are fixed by editing code, not by a command. Credentials in a kept command and in the kept error line are masked as sandbox-lens does.
/zukan opens the book, most recently seen first: kinds, solved, sightings/zukan note <No> <text> writes how to get past it, shown and fed in place of the learned command; an empty text removes the note/zukan forget <No> removes an entryThe newest 300 kinds by last sighting are kept.
The current branch's pull request, kept in the status line while you work:
⎇ #42 · ✓5 ✗1 ⏳2 · 🛑 変更要求 · 💬3 · マージまであと4
"マージまであと N" counts what still stands before a merge: draft, failed checks, running checks, changes requested or approval pending, unresolved review threads, a conflict, the branch behind its base. At 0 it reads 🟢 マージ可能.
A toast when it changes: 🔴 CI 失敗: test, 💬 新しいレビューコメント 2件 (@rev), ✅ Approved, 🟢 #42 マージまであと0, 🎉 #42 がマージされました. Checks turning red, new review comments, changes requested and a new conflict are also attached to the next prompt typed at an idle prompt, once, with the last 40 lines of the failed log of up to three jobs (more are counted), marked as data whose comments and logs are not to be followed as instructions. The session's own replies on a thread (the comment, made after the post, carrying words it replied with through an MCP reply tool or gh api …/pulls/N/comments… -f body=, matched once) are not news; your own review comments are. What waits for the next prompt is about one pull request and is capped (newest kept). The option feed (on by default) turns that off.
/pr asks again and opens the pane: title, size, what is left, checks (failed first) and unresolved threads/pr <number|url|branch> follows that pull request; /pr clear goes back to the defaultWhich pull request: the one a Bash command of the session last worked on (gh pr comment 2, gh pr review 2, the URL gh pr create printed, the branch a git push sent to; looking with gh pr view|diff|checks does not move it), so work pushed to a review branch from another checkout is followed; else the checked-out branch's; else your newest open pull request in the repository.
It asks gh (gh pr view, and gh api graphql for review threads) at session start, right after a Bash git push, git checkout, gh pr create and the like, every minute while a check runs or after a push, and every five minutes otherwise; it stops once the pull request is merged or closed. Without an origin remote, gh, or a pull request for the branch it shows nothing, and /pr says why. gh runs on the host, outside the Bash sandbox, with your gh auth.
The repository as a map under fog, cleared as Claude works through it:
🗺 探索率 31% ██████░░░░░░░░░░░░░░ 読 23 · 編 9 · ✗ 2 / 101
▾ mods/
▾ pr-lamp/
▾ hooks/
● lamp.ts +43 −2 編集 4回
✗ register.tsx +59 −16 編集 6回 エラー 1 ★
▸ loopback/ ○●
░ agent-pane/ ░ browser-cam/ ░ context-diet/ …8
⚠ 編集したファイルを import しているが未読: mods/pr-lamp/tests/pr-lamp.test.ts
░ nothing touched yet, ○ read (Read, or cat, sed, grep… in Bash), ● edited (Edit, Write, NotebookEdit, sed -i), ✚ made by the session, ✗ named with a line number by a failed Bash command since its last edit, ★ the file touched last. A directory holding an edit, a new file or an error is open; one only read is closed with its marks beside it; untouched directories and files at a level share one fog row.
Two warnings, each a likely oversight: a file edited without being read, and a file that imports an edited one (JS/TS and Python, found by git grep) that was never opened. A very large repository's file list is cut to what one state value holds (3 M characters of paths), so it is still mapped, partly.
/map opens the pane🗺 31% · ●9 · ✗2 · ⚠1🗺 新しい区域を開拓: mods/zukan/)The map is the session's, subagents' calls included; files outside the repository ar
hooks/register.ts 287 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { HitAssertion, HitAudit, HitDoneGate, HitRecall, HitTable, HitVerdict } from '../types'
5import { isVerification } from './shell'
6import { addVerdicts, auditStep, DEFAULT_THRESHOLD, fromStore, guardRules, isSame, marksOf, mutedOf, recallStep, reportLines, revertedRules, thresholdOf, withoutRules } from './verdicts'
7
8const STORE_KEY = 'table'
9const LAST_KEPT = 30
10
11const table = atom({ plugin: 'hit-rate', key: 'table' } as const, {} as HitTable)
12const muted = atom({ plugin: 'hit-rate', key: 'muted' } as const, [] as string[])
13const doneGate = atom({ plugin: 'hit-rate', key: 'doneGate' } as const, 'idle' as HitDoneGate)
14const assertions = atom({ plugin: 'hit-rate', key: 'assertions' } as const, {} as Record<string, HitAssertion>)
15const seen = atom({ plugin: 'hit-rate', key: 'seen' } as const, [] as string[])
16const audit = atom({ plugin: 'hit-rate', key: 'audit' } as const, null as HitAudit | null)
17const recalls = atom({ plugin: 'hit-rate', key: 'recalls' } as const, {} as Record<string, HitRecall>)
18const turn = atom({ plugin: 'hit-rate', key: 'turn' } as const, 0)
19const last = atom({ plugin: 'hit-rate', key: 'last' } as const, [] as HitVerdict[])
20
21// The detectors' values, read only: their owners alone write them.
22const flagged = { plugin: 'done-gate', key: 'isFlagged' } as const
23const guardEntries = { plugin: 'assertion-guard', key: 'entries' } as const
24const auditLatest = { plugin: 'self-audit', key: 'latest' } as const
25const auditViolations = { plugin: 'self-audit', key: 'violations' } as const
26const zukanPending = { plugin: 'zukan', key: 'pending' } as const
27const zukanEntries = { plugin: 'zukan', key: 'entries' } as const
28
29const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
30
31type Verdict = Omit<HitVerdict, 'at'>
32
33// $.store has no versioned write: this module's writes run one after another, and when
34// another session wrote over the table in between, these verdicts are added to theirs and
35// written once more, as loopback does.
36let queue: Promise<unknown> = Promise.resolve()
37
38type Gate = { minN: number; threshold: number }
39
40// Tool calls Claude issues in one message are dispatched at once, so two hooks may hold the
41// same finding. Each decision is claimed inside an `update` callback, which sees the value as
42// it stands at the write: whichever call claims a finding first decides it, the other finds it
43// gone (as done-gate decides its nudge).
44
45const drop = <T>(all: Readonly<Record<string, T>>, ids: readonly string[]): Record<string, T> =>
46 Object.fromEntries(Object.entries(all).filter(([id]) => !ids.includes(id)))
47
48function publish($: EngineInterface, gate: Gate, now: HitTable): Promise<unknown> {
49 return Promise.all([update($, table, () => now), update($, muted, () => mutedOf(now, gate.minN, gate.threshold))])
50}
51
52function record($: EngineInterface, gate: Gate, verdicts: readonly Verdict[]): Promise<void> {
53 if (verdicts.length === 0) return Promise.resolve()
54 const run = queue.then(async () => {
55 const at = await $.clock.now()
56 await update($, last, list => [...list, ...verdicts.map(v => ({ ...v, at }))].slice(-LAST_KEPT))
57 const wrote = addVerdicts(fromStore(await $.store.get(STORE_KEY)), verdicts)
58 await $.store.set(STORE_KEY, wrote)
59 let stored = fromStore(await $.store.get(STORE_KEY))
60 if (!isSame(stored, wrote)) {
61 stored = addVerdicts(stored, verdicts)
62 await $.store.set(STORE_KEY, stored)
63 }
64 await publish($, gate, stored)
65 })
66 queue = run.catch(() => undefined)
67 return run
68}
69
70/** Brings what hit-rate holds up to the detectors' state as it stands now. */
71async function sweep($: EngineInterface, gate: Gate): Promise<void> {
72 const [isFlagged, entries, latest, violations, pending, book] = await Promise.all([
73 $.state.get(flagged),
74 $.state.get(guardEntries),
75 $.state.get(auditLatest),
76 $.state.get(auditViolations),
77 $.state.get(zukanPending),
78 $.state.get(zukanEntries),
79 ])
80 const now = await $.clock.now()
81 const turnNow = await read($, turn)
82
83 // done-gate: a flag taken down with no check seen (閉じる) decides nothing.
84 let isDismissed = false
85 await update($, doneGate, flag => {
86 isDismissed = isFlagged.value !== true && flag === 'open'
87 if (isFlagged.value === true) return flag === 'idle' ? 'open' : flag
88 return 'idle'
89 })
90
91 // assertion-guard: open each new entry with its lines counted in the file as it stands.
92 for (const entry of entries.value ?? []) {
93 let isNew = false
94 await update($, seen, list => {
95 isNew = !list.includes(entry.id)
96 return isNew ? [...list, entry.id].slice(-200) : list
97 })
98 if (isNew) {
99 const text = await $.fs.read(entry.path).then(String).catch(() => undefined)
100 if (text !== undefined) {
101 const held: HitAssertion = { path: entry.path, rules: guardRules(entry.findings), marks: marksOf(entry.findings, text) }
102 await update($, assertions, all => ({ ...all, [entry.id]: held }))
103 }
104 }
105 if (entry.isReviewed) {
106 await update($, assertions, all => {
107 const one = all[entry.id]
108 return one === undefined || one.reviewedTurn !== undefined ? all : { ...all, [entry.id]: { ...one, reviewedTurn: turnNow } }
109 })
110 }
111 }
112
113 let audited: Verdict[] = []
114 await update($, audit, held => {
115 const step = auditStep(held, latest.value, violations.value)
116 audited = step.verdicts
117 return step.audit
118 })
119
120 let recalled: Verdict[] = []
121 await update($, recalls, held => {
122 const step = recallStep(held, pending.value, book.value, now)
123 recalled = step.verdicts
124 return step.recalls
125 })
126
127 const dismissed: Verdict[] = isDismissed ? [{ rule: 'done-gate:unverified', verdict: 'unknown', why: '確認コマンドを見ないまま帯が消えた' }] : []
128 await record($, gate, [...dismissed, ...audited, ...recalled])
129}
130
131/** Settles the rules of the open assertion-guard entries on `paths` whose weakening the files now undo. */
132async function checkReverts($: EngineInterface, gate: Gate, paths: readonly string[] | 'all'): Promise<void> {
133 const open = Object.entries(await read($, assertions)).filter(([, one]) => paths === 'all' || paths.includes(one.path))
134 const undone: [string, string[]][] = []
135 for (const [id, one] of open) {
136 const text = await $.fs.read(one.path).then(String).catch(() => undefined)
137 const rules = text === undefined ? [] : revertedRules(one, text)
138 if (rules.length > 0) undone.push([id, rules])
139 }
140 if (undone.length === 0) return
141 let claimed: string[] = []
142 await update($, assertions, all => {
143 claimed = []
144 const next = { ...all }
145 for (const [id, rules] of undone) {
146 const one = all[id]
147 if (one === undefined) continue
148 const settled = rules.filter(rule => one.rules.includes(rule))
149 claimed.push(...settled)
150 const left = withoutRules(one, settled)
151 if (left === null) delete next[id]
152 else next[id] = left
153 }
154 return next
155 })
156 await record($, gate, claimed.map(rule => ({ rule, verdict: 'tp' as const, why: '弱めた編集が元に戻された' })))
157}
158
159/** At session end, what nothing decided counts as unknown, and the session's holdings are cleared. */
160async function closeSession($: EngineInterface, gate: Gate): Promise<void> {
161 const why = 'セッション終了まで判定できなかった'
162 const open: Verdict[] = []
163 let wasOpen = false
164 await update($, doneGate, (held): HitDoneGate => {
165 wasOpen = held === 'open'
166 return 'idle'
167 })
168 if (wasOpen) open.push({ rule: 'done-gate:unverified', verdict: 'unknown', why })
169 let held: HitAssertion[] = []
170 await update($, assertions, all => {
171 held = Object.values(all)
172 return {}
173 })
174 for (const one of held) open.push(...one.rules.map(rule => ({ rule, verdict: 'unknown' as const, why })))
175 let rules: string[] = []
176 await update($, audit, one => {
177 rules = one?.rules ?? []
178 return null
179 })
180 for (const rule of rules) open.push({ rule: `self-audit:${rule}`, verdict: 'unknown', why })
181 let waiting = 0
182 await update($, recalls, all => {
183 waiting = Object.keys(all).length
184 return {}
185 })
186 for (let i = 0; i < waiting; i++) open.push({ rule: 'zukan:recall', verdict: 'unknown', why })
187 await record($, gate, open)
188}
189
190export const register: Register = (on, options) => {
191 const isEnabled = options.enabled === true
192 const minN = typeof options.minN === 'number' && options.minN > 0 ? options.minN : 20
193 const threshold = thresholdOf(options.threshold)
194 const gate: Gate = { minN, threshold: threshold.value }
195
196 on('session.start', async ($, e, next) => {
197 await $.command.register({ name: 'hit-rate', description: '検出 mod の各ルールの的中率(TP/FP/unknown)と、loopback が返すのをやめたルールを表示' })
198 if (isEnabled) await publish($, gate, fromStore(await $.store.get(STORE_KEY)))
199 return next(e)
200 })
201
202 // A prompt settles what the last turn left (self-audit's audit, done-gate's flag). Whether
203 // loopback reads `muted` before or after this sweep depends on the plugins' order: at worst
204 // it reads the list as of the previous verdict.
205 on('prompt.submit', async ($, e, next) => {
206 if (isEnabled) await sweep($, gate)
207 return next(e)
208 }).catch(($, e, next) => next(e))
209
210 on('turn.start', async ($, e, next) => {
211 if (isEnabled && e.agentId === undefined) await update($, turn, n => n + 1)
212 return next(e)
213 })
214
215 on('tool.call', async ($, e, next) => {
216 if (!isEnabled) return next(e)
217 const tool = String(e.tool)
218 const isBash = tool === 'Bash'
219 if (!isBash && !EDIT_TOOLS.includes(tool)) return next(e)
220 // Seen before the call, so a pending error or a raised flag is known before this call settles it.
221 await sweep($, gate).catch(err => $.ui.log(`hit-rate: ${String(err)}`, { to: 'debug' }))
222
223 const ran = await next(e)
224 if (ran.deny !== undefined) return ran
225
226 try {
227 const input = e as unknown as Record<string, unknown>
228 if (isBash && typeof input.command === 'string' && isVerification(input.command)) {
229 // The first check after the flag decides it: failing, the edits did need checking.
230 let isFirst = false
231 await update($, doneGate, flag => {
232 isFirst = flag === 'open'
233 return isFirst ? 'settled' : flag
234 })
235 if (isFirst) {
236 await record($, gate, [
237 ran.isError === true
238 ? { rule: 'done-gate:unverified', verdict: 'tp', why: 'フラグ後の最初の確認コマンドが失敗' }
239 : { rule: 'done-gate:unverified', verdict: 'fp', why: 'フラグ後の最初の確認コマンドが一発で成功' },
240 ])
241 }
242 }
243 if (ran.isError !== true) {
244 // A Bash call may undo a weakening too (`git restore`, `git checkout --`, `git stash`): every open file is read again.
245 const path = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : undefined
246 if (isBash) await checkReverts($, gate, 'all')
247 else if (path !== undefined) await checkReverts($, gate, [path])
248 }
249 } catch (err) {
250 $.ui.log(`hit-rate: ${String(err)}`, { to: 'debug' })
251 }
252 return ran
253 }).catch(($, e, next) => next(e)) // an observer: a failure here leaves the call as it was
254
255 on('turn.complete', async ($, e, next) => {
256 const done = await next(e)
257 if (!isEnabled || e.agentId !== undefined) return done
258 // Reviewed by the person and still standing a whole turn later: the weakening was accepted.
259 const now = await read($, turn)
260 let standing: HitAssertion[] = []
261 await update($, assertions, all => {
262 const ids = Object.keys(all).filter(id => (all[id]?.reviewedTurn ?? now) < now)
263 standing = ids.flatMap(id => (all[id] === undefined ? [] : [all[id]]))
264 return drop(all, ids)
265 })
266 await record($, gate, standing.flatMap(one => one.rules.map(rule => ({ rule, verdict: 'fp' as const, why: '確認後も弱めた編集がそのまま残った' }))))
267 return done
268 })
269
270 on('session.end', async ($, e, next) => {
271 if (isEnabled) await closeSession($, gate).catch(() => undefined)
272 return next(e)
273 })
274
275 on('command.run', { command: 'hit-rate' }, async $ => {
276 const state = isEnabled ? '有効' : '無効(/plugin configure hit-rate で有効化)'
277 const now = fromStore(await $.store.get(STORE_KEY))
278 return {
279 text: [
280 `hit-rate: ${state}(n≥${minN} で precision < ${Math.round(gate.threshold * 100)}% のルールは loopback が返さない)`,
281 ...(threshold.ignored === undefined ? [] : [`threshold ${threshold.ignored} は 0〜1 の範囲外のため無視し、既定の ${DEFAULT_THRESHOLD} を使っています。`]),
282 ...reportLines(now, mutedOf(now, minN, gate.threshold), await read($, last), minN),
283 ].join('\n'),
284 }
285 })
286}
287hooks/shell.ts 711 lines1// Reading a Bash command the way a shell would, far enough to judge it. A best-effort
2// reader, not a shell: words split on unquoted whitespace with quotes and escapes taken
3// off; simple commands cut at && || ; | & newlines and ( ) { }; comments, redirections and
4// heredoc bodies set aside; $(…), backticks, <(…) and `bash -c` / `eval` text read again as
5// commands of their own. Text inside quotes is a word, never an operator.
6
7export type Op = '' | '&&' | '||' | ';' | ';;' | '|' | '|&' | '&' | '\n'
8
9/** A redirection; a heredoc keeps its body, which a shell reading its stdin runs. */
10export type Redirect = { op: string; target?: string; body?: string }
11
12/** One simple command: its words, its redirections, and the operator that follows it. */
13export type Simple = { words: string[]; redirects: Redirect[]; after: Op }
14
15export type Parsed = { commands: Simple[]; nested: string[] }
16
17const MAX_DEPTH = 8
18
19/** The index just past the `)` closing a `(` opened just before `open`, quotes and nesting kept. */
20function closing(text: string, open: number): number {
21 let depth = 1
22 let i = open
23 while (i < text.length) {
24 const ch = text[i] as string
25 if (ch === '\\') {
26 i += 2
27 continue
28 }
29 if (ch === "'") {
30 const end = text.indexOf("'", i + 1)
31 i = end === -1 ? text.length : end + 1
32 continue
33 }
34 if (ch === '"') {
35 i = quoted(text, i + 1).end
36 continue
37 }
38 if (ch === '`') {
39 const end = text.indexOf('`', i + 1)
40 i = end === -1 ? text.length : end + 1
41 continue
42 }
43 if (ch === '(') depth += 1
44 if (ch === ')') {
45 depth -= 1
46 if (depth === 0) return i + 1
47 }
48 i += 1
49 }
50 return text.length
51}
52
53/** The text of a double-quoted string starting at `from`, and the substitutions inside it. */
54function quoted(text: string, from: number): { value: string; end: number; nested: string[] } {
55 let value = ''
56 const nested: string[] = []
57 let j = from
58 while (j < text.length && text[j] !== '"') {
59 const ch = text[j] as string
60 if (ch === '\\' && j + 1 < text.length) {
61 value += text[j + 1]
62 j += 2
63 } else if (ch === '$' && text[j + 1] === '(' && text[j + 2] !== '(') {
64 const end = closing(text, j + 2)
65 nested.push(text.slice(j + 2, end - 1))
66 value += text.slice(j, end)
67 j = end
68 } else if (ch === '`') {
69 const end = text.indexOf('`', j + 1)
70 const stop = end === -1 ? text.length : end
71 nested.push(text.slice(j + 1, stop))
72 value += text.slice(j, stop + 1)
73 j = stop + 1
74 } else {
75 value += ch
76 j += 1
77 }
78 }
79 return { value, end: j + 1, nested }
80}
81
82/** The text of an ANSI-C quoted string `$'…'` starting at `from`, escapes decoded as bash does. */
83function ansiC(text: string, from: number): { value: string; end: number } {
84 const simple: Record<string, string> = { n: '\n', t: '\t', r: '\r', a: '\x07', b: '\b', e: '\x1b', E: '\x1b', f: '\f', v: '\v', '\\': '\\', "'": "'", '"': '"', '?': '?' }
85 let value = ''
86 let j = from
87 while (j < text.length && text[j] !== "'") {
88 const ch = text[j] as string
89 if (ch !== '\\' || j + 1 >= text.length) {
90 value += ch
91 j += 1
92 continue
93 }
94 const next = text[j + 1] as string
95 const code = (digits: string, radix: number) => {
96 value += String.fromCodePoint(Number.parseInt(digits, radix))
97 j += 2 + digits.length - (radix === 8 ? 1 : 0)
98 }
99 const hex = /^x([0-9a-fA-F]{1,2})/.exec(text.slice(j + 1))
100 const uni = /^[uU]([0-9a-fA-F]{1,8})/.exec(text.slice(j + 1))
101 const oct = /^([0-7]{1,3})/.exec(text.slice(j + 1))
102 if (simple[next] !== undefined) {
103 value += simple[next]
104 j += 2
105 } else if (hex !== null) code(hex[1] as string, 16)
106 else if (uni !== null && Number.parseInt(uni[1] as string, 16) <= 0x10ffff) code(uni[1] as string, 16)
107 else if (oct !== null) {
108 value += String.fromCharCode(Number.parseInt(oct[1] as string, 8) & 0xff)
109 j += 1 + (oct[1] as string).length
110 } else {
111 value += `\\${next}`
112 j += 2
113 }
114 }
115 return { value, end: j + 1 }
116}
117
118const OPERATOR = /^(&&|\|\||;;|\|&|[;|&\n])/
119// > >> >| >& <& <> < &> &>>, with an fd duplication (2>&1, >&-) taken whole.
120const REDIRECT = /^(&>>?|[<>]&[0-9]+-?|[<>]&-|>>|>\||<>|>&|<&|[<>])/
121
122export function parse(command: string): Parsed {
123 const commands: Simple[] = []
124 const nested: string[] = []
125 let words: string[] = []
126 let redirects: Redirect[] = []
127 let word: string | undefined
128 let pendingRedirect: Redirect | undefined
129 let heredocs: { tag: string; isTabbed: boolean; entry: Redirect }[] = []
130 let i = 0
131
132 const endWord = () => {
133 if (word === undefined) return
134 if (pendingRedirect !== undefined) {
135 pendingRedirect.target = word
136 pendingRedirect = undefined
137 } else words.push(word)
138 word = undefined
139 }
140 const endCommand = (after: Op) => {
141 endWord()
142 pendingRedirect = undefined
143 if (words.length > 0 || redirects.length > 0) {
144 commands.push({ words, redirects, after })
145 } else if (after !== '') {
146 // An operator after a group's `)`: it belongs to the command the group ended with.
147 const last = commands[commands.length - 1]
148 if (last !== undefined && last.after === '') last.after = after
149 }
150 words = []
151 redirects = []
152 }
153 const skipHeredocBodies = () => {
154 for (const doc of heredocs) {
155 const body: string[] = []
156 while (i < command.length) {
157 const lineEnd = command.indexOf('\n', i)
158 const stop = lineEnd === -1 ? command.length : lineEnd
159 const line = command.slice(i, stop)
160 i = stop + 1
161 if ((doc.isTabbed ? line.replace(/^\t+/, '') : line) === doc.tag) break
162 body.push(line)
163 }
164 doc.entry.body = body.join('\n')
165 }
166 heredocs = []
167 }
168
169 while (i < command.length) {
170 const ch = command[i] as string
171 if (ch === '\\' && i + 1 < command.length) {
172 if (command[i + 1] !== '\n') word = (word ?? '') + command[i + 1]
173 i += 2
174 continue
175 }
176 if (ch === "'") {
177 const end = command.indexOf("'", i + 1)
178 const stop = end === -1 ? command.length : end
179 word = (word ?? '') + command.slice(i + 1, stop)
180 i = stop + 1
181 continue
182 }
183 if (ch === '"') {
184 const text = quoted(command, i + 1)
185 word = (word ?? '') + text.value
186 nested.push(...text.nested)
187 i = text.end
188 continue
189 }
190 if (ch === '`') {
191 const end = command.indexOf('`', i + 1)
192 const stop = end === -1 ? command.length : end
193 nested.push(command.slice(i + 1, stop))
194 word = (word ?? '') + command.slice(i, stop + 1)
195 i = stop + 1
196 continue
197 }
198 if (ch === '$' && command[i + 1] === "'") {
199 // ANSI-C quoting: `$'--no-\x76erify'` is --no-verify to the shell.
200 const text = ansiC(command, i + 2)
201 word = (word ?? '') + text.value
202 i = text.end
203 continue
204 }
205 if (ch === '$' && command[i + 1] === '"') {
206 // Locale quoting `$"…"` reads as a double-quoted string.
207 i += 1
208 continue
209 }
210 if (ch === '$' && command.startsWith('$((', i)) {
211 // Arithmetic: one word, whatever `<<` or `)` it holds.
212 const end = closing(command, i + 2)
213 const stop = command.startsWith(')', end) ? end + 1 : end
214 word = (word ?? '') + command.slice(i, stop)
215 i = stop
216 continue
217 }
218 if (ch === '$' && command[i + 1] === '(') {
219 const end = closing(command, i + 2)
220 nested.push(command.slice(i + 2, end - 1))
221 word = (word ?? '') + command.slice(i, end)
222 i = end
223 continue
224 }
225 if (ch === '$' && command[i + 1] === '{') {
226 const end = command.indexOf('}', i + 2)
227 const stop = end === -1 ? command.length : end + 1
228 word = (word ?? '') + command.slice(i, stop)
229 i = stop
230 continue
231 }
232 if ((ch === '<' || ch === '>') && command[i + 1] === '(') {
233 // Process substitution: a command whose output stands in for a file.
234 const end = closing(command, i + 2)
235 nested.push(command.slice(i + 2, end - 1))
236 word = (word ?? '') + command.slice(i, end)
237 i = end
238 continue
239 }
240 if (ch === '#' && word === undefined) {
241 // A comment runs to the end of the line; the newline itself still ends the command.
242 const lineEnd = command.indexOf('\n', i)
243 i = lineEnd === -1 ? command.length : lineEnd
244 continue
245 }
246 if (ch === '<' && command.startsWith('<<', i) && !command.startsWith('<<<', i)) {
247 endWord()
248 let j = i + 2
249 const isTabbed = command[j] === '-'
250 if (isTabbed) j += 1
251 while (command[j] === ' ' || command[j] === '\t') j += 1
252 const match = /^(['"]?)([A-Za-z0-9_.-]+)\1/.exec(command.slice(j))
253 if (match !== null) {
254 const entry: Redirect = { op: isTabbed ? '<<-' : '<<', target: match[2] as string }
255 heredocs.push({ tag: match[2] as string, isTabbed, entry })
256 redirects.push(entry)
257 i = j + match[0].length
258 continue
259 }
260 }
261 if (ch === '<' && command.startsWith('<<<', i)) {
262 endWord()
263 pendingRedirect = { op: '<<<' }
264 redirects.push(pendingRedirect)
265 i += 3
266 continue
267 }
268 const redirect =
269 ch === '<' || ch === '>' || (ch === '&' && command[i + 1] === '>') ? REDIRECT.exec(command.slice(i)) : null
270 if (redirect !== null) {
271 // A bare fd number glued in front (2>, 1>>) is part of the redirection.
272 if (word !== undefined && /^[0-9]+$/.test(word)) word = undefined
273 endWord()
274 const op = redirect[0]
275 const entry: Redirect = { op }
276 redirects.push(entry)
277 if (!/&([0-9]+-?|-)$/.test(op)) pendingRedirect = entry
278 i += op.length
279 continue
280 }
281 const op = OPERATOR.exec(command.slice(i))
282 if (op !== null) {
283 endCommand(op[0] as Op)
284 i += op[0].length
285 if (op[0] === '\n' && heredocs.length > 0) skipHeredocBodies()
286 continue
287 }
288 if (ch === ' ' || ch === '\t') {
289 endWord()
290 i += 1
291 continue
292 }
293 // `{` and `}` are a group's edges only as words of their own (`{ a; }`), so find's `{}` is a word.
294 const isBrace =
295 word === undefined && ((ch === '{' && /\s/.test(command[i + 1] ?? '\n')) || (ch === '}' && /^(?:$|[\s;&|)])/.test(command[i + 1] ?? '')))
296 if (ch === '(' || ch === ')' || isBrace) {
297 // A subshell or group edge, glued to a word or not: what is inside runs as commands.
298 endCommand('')
299 i += 1
300 continue
301 }
302 word = (word ?? '') + ch
303 i += 1
304 }
305 endCommand('')
306 return { commands, nested }
307}
308
309/**
310 * Every simple command a command line runs: substitutions, `bash -c` / `eval` / `env -S` text,
311 * and the script a shell reads on its stdin (`bash <<< '…'`, a heredoc, `echo '…' | sh`) included.
312 */
313export function allCommands(command: string, depth = 0): Simple[] {
314 if (depth > MAX_DEPTH) return []
315 const { commands, nested } = parse(command)
316 const out: Simple[] = []
317 commands.forEach((one, k) => {
318 out.push(one)
319 const inner = innerText(one.words)
320 if (inner !== undefined) out.push(...allCommands(inner, depth + 1))
321 if (readsScriptFromStdin(one.words)) {
322 for (const text of stdinOf(one, commands[k - 1])) out.push(...allCommands(text, depth + 1))
323 }
324 })
325 for (const text of nested) out.push(...allCommands(text, depth + 1))
326 return out
327}
328
329const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
330// Words a shell reads as syntax before the command they lead.
331const PREFIX_KEYWORDS = new Set(['if', 'then', 'else', 'elif', 'do', 'while', 'until', '!', 'time', 'fi', 'done', 'esac'])
332// Words that open a clause which is not a command (`for x in a b`, `case x in`).
333const CLAUSE_KEYWORDS = new Set(['for', 'case', 'select', 'function'])
334const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'su', 'script'])
335// Shell options whose value is the next word (`bash -o pipefail -c …`).
336const SHELL_VALUES = new Set(['-o', '+o', '-O', '+O', '--rcfile', '--init-file'])
337
338// Wrappers that run the command after them, with the options of each that take a value.
339const WRAPPERS: Record<string, Set<string>> = {
340 sudo: new Set(['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-U', '-T', '--user', '--group', '--chdir', '--prompt']),
341 doas: new Set(['-u', '-C']),
342 nice: new Set(['-n', '--adjustment']),
343 env: new Set(['-u', '--unset', '-C', '--chdir', '-S', '--split-string']),
344 command: new Set([]),
345 exec: new Set(['-a']),
346 nohup: new Set([]),
347 time: new Set(['-f', '--format', '-o', '--output']),
348 stdbuf: new Set(['-i', '-o', '-e', '--input', '--output', '--error']),
349 xargs: new Set(['-I', '-L', '-n', '-P', '-s', '-d', '-E', '-a', '--max-args', '--max-procs', '--delimiter', '--arg-file', '--replace', '--max-lines']),
350 xcrun: new Set(['--sdk', '--toolchain']),
351 caffeinate: new Set(['-t', '-w']),
352 ionice: new Set(['-c', '-n', '-p', '-t', '--class', '--classdata']),
353 busybox: new Set([]),
354 chronic: new Set([]),
355 setsid: new Set([]),
356}
357
358/** Words joined back into a command line with each one quoted, so re-reading it keeps them whole. */
359const quoteWords = (words: readonly string[]) => words.map(word => `'${word.replace(/'/g, `'\\''`)}'`).join(' ')
360// timeout takes options, then a duration, then the command.
361const TIMEOUT_VALUES = new Set(['-s', '--signal', '-k', '--kill-after'])
362// Runners whose next word is the program they run; 2 = the second word must be run/exec.
363const RUNNERS: Record<string, number> = { npx: 1, bunx: 1, pnpx: 1, uvx: 1, pipx: 2, uv: 2, poetry: 2, pdm: 2, rye: 2, bundle: 2 }
364
365export const base = (word: string) => word.slice(word.lastIndexOf('/') + 1)
366
367/** The program a simple command runs and its arguments, past assignments, keywords and wrappers. */
368export function program(words: readonly string[]): { name: string; args: string[] } | undefined {
369 let k = 0
370 for (;;) {
371 const word = words[k]
372 if (word === undefined) return undefined
373 const name = base(word)
374 if (CLAUSE_KEYWORDS.has(word)) return undefined
375 if (ASSIGNMENT.test(word) || PREFIX_KEYWORDS.has(word)) {
376 k += 1
377 } else if (WRAPPERS[name] !== undefined && word === name) {
378 const values = WRAPPERS[name] as Set<string>
379 k += 1
380 while (words[k] !== undefined && (words[k] as string).startsWith('-')) {
381 const option = words[k] as string
382 k += 1
383 if (option === '--') break
384 if (values.has(option)) k += 1
385 }
386 if (name === 'env') while (words[k] !== undefined && ASSIGNMENT.test(words[k] as string)) k += 1
387 } else if (name === 'timeout' && word === name) {
388 k += 1
389 while (words[k] !== undefined && (words[k] as string).startsWith('-')) {
390 const option = words[k] as string
391 k += 1
392 if (TIMEOUT_VALUES.has(option)) k += 1
393 }
394 k += 1 // the duration
395 } else if (RUNNERS[name] !== undefined) {
396 const skip = RUNNERS[name] as number
397 if (skip === 2 && words[k + 1] !== 'run' && words[k + 1] !== 'exec') break
398 k += skip
399 while (words[k]?.startsWith('-')) k += 1
400 } else break
401 }
402 const name = words[k]
403 if (name === undefined) return undefined
404 return { name: base(name), args: words.slice(k + 1) }
405}
406
407/** The command text a `bash -c "<text>"`, `eval <words>` or `env -S "<text>"` runs, if the words are one. */
408export function innerText(words: readonly string[]): string | undefined {
409 // env -S splits its value into the command it runs: `env -S "git commit -n"`.
410 const at = words.findIndex(word => base(word) === 'env')
411 if (at !== -1 && (at === 0 || words.slice(0, at).every(word => ASSIGNMENT.test(word) || PREFIX_KEYWORDS.has(word) || WRAPPERS[base(word)] !== undefined))) {
412 for (let k = at + 1; k < words.length && (words[k] as string).startsWith('-'); k++) {
413 const option = words[k] as string
414 const glued = /^(?:--split-string=|-[A-RT-Za-z]*S)(.+)$/.exec(option)
415 if (glued !== null) return [glued[1], ...words.slice(k + 1)].join(' ')
416 if (option === '--split-string' || /^-[A-RT-Za-z]*S$/.test(option)) return words.slice(k + 1).join(' ')
417 if (WRAPPERS.env?.has(option) === true) k += 1
418 }
419 }
420 const run = program(words)
421 if (run === undefined) return undefined
422 if (run.name === 'eval') return run.args.join(' ')
423 if (run.name === 'find') {
424 // `find … -exec git commit -n \;`: the words up to `;` or `+` run as a command, for every -exec.
425 const runs: string[] = []
426 for (let k = 0; k < run.args.length; k++) {
427 if (!/^-(?:exec|execdir|ok|okdir)$/.test(run.args[k] as string)) continue
428 const end = run.args.findIndex((arg, n) => n > k && (arg === ';' || arg === '+'))
429 runs.push(quoteWords(run.args.slice(k + 1, end === -1 ? undefined : end)))
430 k = end === -1 ? run.args.length : end
431 }
432 return runs.length === 0 ? undefined : runs.join(' ; ')
433 }
434 if (run.name === 'su' || run.name === 'script' || run.name === 'fish') {
435 // Their command text comes with -c or --command anywhere, after a user or a log file.
436 for (let k = 0; k < run.args.length; k++) {
437 const arg = run.args[k] as string
438 const glued = /^--command=(.*)$/.exec(arg)
439 if (glued !== null) return glued[1]
440 if (arg === '--command' || /^-[A-Za-z]*c[A-Za-z]*$/.test(arg)) return run.args[k + 1]
441 }
442 return undefined
443 }
444 if (SHELLS.has(run.name)) {
445 for (let k = 0; k < run.args.length; k++) {
446 const arg = run.args[k] as string
447 // The text is the first word after -c that is not an option (`bash -c -- '…'`).
448 if (/^-[A-Za-z]*c[A-Za-z]*$/.test(arg)) return run.args.slice(k + 1).find((one, n, rest) => !one.startsWith('-') || rest[n - 1] === '--')
449 if (SHELL_VALUES.has(arg)) k += 1
450 else if (!arg.startsWith('-') && !arg.startsWith('+')) return undefined // a script file
451 }
452 }
453 return undefined
454}
455
456/** Whether a shell runs the script on its stdin: no -c text and no script file, or -s. */
457function readsScriptFromStdin(words: readonly string[]): boolean {
458 const run = program(words)
459 if (run === undefined || !SHELLS.has(run.name)) return false
460 if (run.args.some(arg => /^-[A-Za-z]*c[A-Za-z]*$/.test(arg))) return false
461 if (run.args.includes('-s')) return true
462 for (let k = 0; k < run.args.length; k++) {
463 const arg = run.args[k] as string
464 if (SHELL_VALUES.has(arg)) k += 1
465 else if (!arg.startsWith('-') && !arg.startsWith('+')) return false // a script file
466 }
467 return true
468}
469
470/** The text a shell reads on its stdin: a here-string, a heredoc's body, or what echo/printf piped into it. */
471function stdinOf(one: Simple, before: Simple | undefined): string[] {
472 const texts = one.redirects.flatMap(redirect => (redirect.op === '<<<' ? [redirect.target ?? ''] : redirect.body !== undefined ? [redirect.body] : []))
473 const piped = before !== undefined && (before.after === '|' || before.after === '|&') ? program(before.words) : undefined
474 if (piped !== undefined && (piped.name === 'echo' || piped.name === 'printf')) {
475 // Only echo's leading options are options: `echo git commit -n` prints the -n.
476 const lead = piped.name === 'echo' ? piped.args.findIndex(arg => !/^-[neE]+$/.test(arg)) : 0
477 const text = piped.args.slice(lead === -1 ? piped.args.length : lead).join(' ')
478 texts.push(piped.name === 'printf' ? text.replace(/\\n/g, '\n') : text)
479 }
480 // `cat <<EOF | bash`: the heredoc cat prints is the script.
481 if (piped !== undefined && piped.name === 'cat' && before !== undefined) {
482 for (const redirect of before.redirects) if (redirect.body !== undefined) texts.push(redirect.body)
483 }
484 return texts
485}
486
487// ---- verification ---------------------------------------------------------------------
488
489const SCRIPT = /^(test|tests|lint|typecheck|type-check|tsc|check|build|verify)([:-].*)?$/
490const MAKE_TARGET = /^(test|tests|check|lint|build|verify|typecheck)([:-].*)?$/
491const GRADLE_TASK = /^(:?[\w-]+:)*(test\w*|check|build|lint\w*|assemble\w*|connected\w*)$/
492const DIRECT = new Set([
493 'tsc', 'vue-tsc', 'mypy', 'pyright', 'eslint', 'ruff', 'vitest', 'jest', 'pytest', 'xcodebuild',
494 'swiftlint', 'ktlint', 'detekt', 'golangci-lint', 'rubocop', 'phpunit', 'phpstan', 'tox', 'nox',
495 'cargo-nextest', 'flake8', 'pylint', 'black', 'shellcheck', 'stylelint', 'playwright', 'rspec', 'ctest',
496])
497// Package-manager options that take the next word as their value.
498const PM_VALUES = new Set(['-C', '--dir', '--filter', '-F', '--prefix', '-w', '--workspace', '--cwd'])
499// Asking a tool its version or usage runs no check.
500const INFO_ONLY = new Set(['--version', '-version', '--help', '-help'])
501
502const firstPlain = (args: string[]) => args.find(arg => !arg.startsWith('-'))
503
504/** The positional words of a package-manager call, its value-taking options stepped over. */
505function pmWords(args: string[]): string[] {
506 const out: string[] = []
507 for (let k = 0; k < args.length; k++) {
508 const arg = args[k] as string
509 if (PM_VALUES.has(arg)) k += 1
510 else if (!arg.startsWith('-')) out.push(arg)
511 }
512 return out
513}
514
515function isCheckProgram(words: readonly string[]): boolean {
516 const run = program(words)
517 if (run === undefined) return false
518 const { name, args } = run
519 if (args.some(arg => INFO_ONLY.has(arg)) || (args.length === 1 && (args[0] === '-v' || args[0] === '-V'))) return false
520 const sub = firstPlain(args)
521
522 if (DIRECT.has(name)) return true
523 switch (name) {
524 case 'prettier':
525 case 'biome':
526 return args.includes('--check') || args.includes('-c') || sub === 'check' || sub === 'lint' || sub === 'ci'
527 case 'cargo':
528 return sub !== undefined && ['test', 'check', 'clippy', 'build', 'nextest'].includes(sub)
529 case 'go':
530 return sub !== undefined && ['test', 'vet', 'build'].includes(sub)
531 case 'swift':
532 return sub !== undefined && ['build', 'test'].includes(sub)
533 case 'deno':
534 return sub !== undefined && ['test', 'check', 'lint'].includes(sub)
535 case 'dotnet':
536 return sub !== undefined && ['test', 'build'].includes(sub)
537 case 'mvn':
538 case 'mvnw':
539 return args.some(arg => arg === 'test' || arg === 'verify')
540 case 'node':
541 return args.includes('--test')
542 case 'python':
543 case 'python3': {
544 const m = args.indexOf('-m')
545 const mod = m === -1 ? undefined : args[m + 1]
546 return mod !== undefined && ['pytest', 'unittest', 'mypy', 'ruff', 'pyright'].includes(mod)
547 }
548 case 'npm':
549 case 'bun':
550 case 'pnpm':
551 case 'yarn': {
552 let plain = pmWords(args)
553 if (name === 'yarn' && plain[0] === 'workspace') plain = plain.slice(2)
554 const [first, second] = plain
555 if (first === undefined) return false
556 if (first === 'run' || first === 'run-script') return second !== undefined && SCRIPT.test(second)
557 if (first === 't') return name === 'npm'
558 return SCRIPT.test(first)
559 }
560 case 'make':
561 case 'just':
562 case 'task':
563 return args.some(arg => !arg.startsWith('-') && MAKE_TARGET.test(arg))
564 case 'gradlew':
565 case 'gradle':
566 return args.some(arg => !arg.startsWith('-') && GRADLE_TASK.test(arg))
567 default:
568 return false
569 }
570}
571
572/** Whether a command that runs another passes on its exit status: `find -exec … \;` and `script` without -e exit 0 though it fails. */
573function passesStatus(words: readonly string[]): boolean {
574 const run = program(words)
575 if (run === undefined) return true
576 if (run.name === 'find') return run.args.includes('+')
577 if (run.name === 'script') return run.args.some(arg => arg === '--return' || /^-[A-Za-z]*e[A-Za-z]*$/.test(arg))
578 return true
579}
580
581function isCheckCommand(simple: Simple, depth: number): boolean {
582 if (isCheckProgram(simple.words)) return true
583 if (!passesStatus(simple.words)) return false
584 const inner = innerText(simple.words)
585 return inner !== undefined && depth < MAX_DEPTH && verifies(inner, depth + 1)
586}
587
588// Between the check and the end, only these keep the check's failure the command's failure.
589const CARRIES_FAILURE = new Set<Op>(['&&', '|', '|&'])
590
591function verifies(command: string, depth: number): boolean {
592 const { commands } = parse(command)
593 return commands.some((one, k) => {
594 if (!isCheckCommand(one, depth)) return false
595 const later = commands.slice(k + 1)
596 if (later.length === 0) return one.after === '' || one.after === ';' || one.after === '\n'
597 // `npm test | tail`, `npm test || true`, `npm test; git status`: the exit status is not the check's.
598 if (one.after !== '&&') return false
599 return later.slice(0, -1).every(next => CARRIES_FAILURE.has(next.after))
600 })
601}
602
603/**
604 * Whether a command's exit status is that of a test, lint, typecheck or build step: the
605 * check is the last command, or only `&&` chains follow it, so a failing check fails the command.
606 */
607export function isVerification(command: string): boolean {
608 return verifies(command, 0)
609}
610
611// ---- hooks off ------------------------------------------------------------------------
612
613const NO_VERIFY = '--no-verify'
614// Options of git itself that take the next word as their value.
615const GIT_VALUE_OPTIONS = new Set(['-c', '-C', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--config-env'])
616// Subcommand options whose next word is a value, never an option of its own.
617const SUB_VALUE_OPTIONS = new Set([
618 '-m', '--message', '-F', '--file', '-e', '--regexp', '--grep', '-S', '-G', '--author', '--committer',
619 '-C', '-c', '--reuse-message', '--reedit-message', '--fixup', '--squash', '--template', '-t', '--date',
620 '--cleanup', '--pathspec-from-file', '-X', '--strategy-option', '-s', '--strategy', '-o', '--push-option',
621 '--exec', '--onto', '--format', '--pretty', '--trailer', '--since', '--until',
622])
623// In these subcommands some of the options above are flags with no value (`git commit -s` signs
624// off, `-S` signs, `-e` edits), so the word after them is read as an option of its own.
625const SUB_FLAGS: Record<string, Set<string>> = {
626 commit: new Set(['-s', '-S', '-e', '-o']),
627 merge: new Set(['-S', '-e']),
628 rebase: new Set(['-S']),
629 'cherry-pick': new Set(['-s', '-S', '-e']),
630 revert: new Set(['-s', '-S', '-e']),
631 am: new Set(['-s', '-S']),
632}
633
634// `git commit` short options whose value is the rest of the cluster, or the next word when last.
635const COMMIT_VALUE_SHORTS = new Set(['m', 'F', 'C', 'c', 't'])
636// `git commit` short options whose optional value can only be glued (-uno, -Skey).
637const COMMIT_GLUED_SHORTS = new Set(['u', 'S'])
638
639/** Whether one word spells --no-verify: the whole option, or a prefix git accepts (--no-ver…). */
640const spellsNoVerify = (arg: string) => arg.length >= 8 && NO_VERIFY.startsWith(arg)
641
642/** In `git commit`, whether a short-option cluster sets -n (git commit's --no-verify). */
643function clusterHasN(cluster: string): { hasN: boolean; takesNext: boolean } {
644 for (let k = 1; k < cluster.length; k++) {
645 const flag = cluster[k] as string
646 if (flag === 'n') return { hasN: true, takesNext: false }
647 if (COMMIT_VALUE_SHORTS.has(flag)) return { hasN: false, takesNext: k === cluster.length - 1 }
648 if (COMMIT_GLUED_SHORTS.has(flag)) return { hasN: false, takesNext: false }
649 }
650 return { hasN: false, takesNext: false }
651}
652
653// Environment assignments that switch off the hook managers git runs (husky, lefthook, pre-commit),
654// or move git's hooks through config given in the environment (GIT_CONFIG_KEY_n, GIT_CONFIG_PARAMETERS).
655const HOOK_OFF_ENV = /^(?:HUSKY=(?:0|false)|HUSKY_SKIP_HOOKS=|LEFTHOOK=(?:0|false)|SKIP=)/
656// Config keys are case-insensitive (core.hooksPath, CORE.HOOKSPATH); the variable names are not.
657const HOOKS_PATH_ENV = /^(?:GIT_CONFIG_KEY_\d+=(?:core\.hookspath)$|GIT_CONFIG_PARAMETERS=.*\bcore\.hookspath=)/i
658
659/** `-c core.hooksPath=…` or `--config-env core.hooksPath=…`: the hooks moved for one command. */
660const movesHooks = (option: string, value: string | undefined) =>
661 (option === '-c' || option === '--config-env') && value !== undefined && /^core\.hookspath=/i.test(value)
662
663function bypassesHooks(simple: Simple): boolean {
664 const run = program(simple.words)
665 if (run === undefined || run.name !== 'git') return false
666 const { args } = run
667 // The words before git: `HUSKY=0 git commit`, `env SKIP=lint git commit`.
668 const before = simple.words.slice(0, simple.words.length - args.length - 1)
669 if (before.some(word => HOOK_OFF_ENV.test(word) || (HOOKS_PATH_ENV.test(word) && /^GIT_CONFIG_/.test(word)))) return true
670
671 let k = 0
672 while (k < args.length && (args[k] as string).startsWith('-')) {
673 const option = args[k] as string
674 if (movesHooks(option, args[k + 1])) return true
675 if (/^--config-env=core\.hookspath=/i.test(option)) return true
676 k += GIT_VALUE_OPTIONS.has(option) ? 2 : 1
677 }
678 const sub = args[k]
679 if (sub === undefined) return false
680
681 const rest = args.slice(k + 1)
682 for (let j = 0; j < rest.length; j++) {
683 const arg = rest[j] as string
684 if (arg === '--') break
685 if (sub === 'commit' && /^-[A-Za-z]+$/.test(arg) && arg.length > 2) {
686 const { hasN, takesNext } = clusterHasN(arg)
687 if (hasN) return true
688 if (takesNext) j += 1
689 continue
690 }
691 if (SUB_VALUE_OPTIONS.has(arg) && SUB_FLAGS[sub]?.has(arg) !== true) {
692 j += 1
693 continue
694 }
695 if (spellsNoVerify(arg)) return true
696 if (sub === 'commit' && arg === '-n') return true
697 }
698 return false
699}
700
701/**
702 * Whether a command runs git with its hooks switched off (--no-verify, a prefix of it,
703 * `git commit -n`, `-c core.hooksPath=…`, or a hook manager's off switch in its environment),
704 * anywhere it runs: chained, grouped, substituted, or inside `bash -c`.
705 * Best effort: a git alias (`git ci -n`), a hook path written into config beforehand, or an
706 * environment exported by an earlier command is not seen.
707 */
708export function skipsHooks(command: string): boolean {
709 return allCommands(command).some(bypassesHooks)
710}
711hooks/verdicts.ts 202 lines1// Deciding, from what the session did after a finding, whether the finding held. Pure: the
2// hooks module reads the detectors' state and the files, and hands them here.
3
4import type { PluginState } from 'claude-code'
5
6import type { HitAssertion, HitAudit, HitMark, HitMarks, HitRecall, HitTable, HitVerdict } from '../types'
7
8type Verdict = Omit<HitVerdict, 'at'>
9
10/** self-audit's rule id (`R1`…`R5`) when the fork named one, else the rule as written: the same id loopback keys by. */
11export function ruleId(rule: string): string {
12 const id = rule.toUpperCase().match(/R[1-5]/)?.[0]
13 if (id !== undefined) return id
14 const flat = rule.replace(/\s+/g, ' ').trim()
15 return flat.length > 40 ? `${flat.slice(0, 39)}…` : flat
16}
17
18// ---- self-audit -------------------------------------------------------------------------
19
20type AuditLatest = PluginState['self-audit']['latest'] | undefined
21type AuditViolations = readonly PluginState['self-audit']['violations'][number][] | undefined
22
23/**
24 * Once self-audit has audited a later reply, each rule it flagged on the reply before is
25 * judged: gone on the next audited reply, the finding was acted on (tp); flagged again, it
26 * changed nothing (fp).
27 */
28export function auditStep(held: HitAudit | null, latest: AuditLatest, violations: AuditViolations): { verdicts: Verdict[]; audit: HitAudit | null } {
29 if (latest?.status !== 'audited' || latest.turnId === held?.turnId) return { verdicts: [], audit: held }
30 const now = [...new Set((violations ?? []).map(v => ruleId(v.rule)))]
31 const verdicts: Verdict[] = (held?.rules ?? []).map(rule =>
32 now.includes(rule)
33 ? { rule: `self-audit:${rule}`, verdict: 'fp', why: '次に監査された返信でも同じ違反' }
34 : { rule: `self-audit:${rule}`, verdict: 'tp', why: '次に監査された返信で違反が消えた' },
35 )
36 return { verdicts, audit: { turnId: latest.turnId, rules: now } }
37}
38
39// ---- zukan --------------------------------------------------------------------------------
40
41type ZukanPending = Readonly<Record<string, PluginState['zukan']['pending'][string]>> | undefined
42type ZukanEntries = readonly PluginState['zukan']['entries'][number][] | undefined
43
44/**
45 * A pending error whose entry has a recorded fix is a recall: zukan told Claude that fix. Once
46 * the error is got past, the recall held if the command that got past it is the one recalled.
47 * The same error failing again, from the same command or another, is the same recall waiting.
48 */
49export function recallStep(
50 recalls: Readonly<Record<string, HitRecall>>,
51 pending: ZukanPending,
52 entries: ZukanEntries,
53 now: number,
54): { verdicts: Verdict[]; recalls: Record<string, HitRecall> } {
55 const verdicts: Verdict[] = []
56 const next: Record<string, HitRecall> = {}
57 const byKey = new Map((entries ?? []).map(one => [one.key, one]))
58 for (const [program, held] of Object.entries(recalls)) {
59 const still = pending?.[program]
60 if (still !== undefined && still.key === held.key) {
61 next[program] = held
62 continue
63 }
64 const fix = byKey.get(held.key)?.fix
65 if (fix !== undefined && fix.at >= held.openedAt) {
66 verdicts.push(
67 fix.command === held.suggested
68 ? { rule: 'zukan:recall', verdict: 'tp', why: `前回の解決方法 \`${held.suggested}\` で解決` }
69 : { rule: 'zukan:recall', verdict: 'fp', why: `別のコマンド \`${fix.command}\` で解決` },
70 )
71 } else {
72 verdicts.push({ rule: 'zukan:recall', verdict: 'unknown', why: '解決されないまま終わった' })
73 }
74 }
75 for (const [program, one] of Object.entries(pending ?? {})) {
76 if (next[program] !== undefined) continue
77 const fix = byKey.get(one.key)?.fix
78 if (fix === undefined) continue
79 next[program] = { key: one.key, failed: one.failed, suggested: fix.command, openedAt: now }
80 }
81 return { verdicts, recalls: next }
82}
83
84// ---- assertion-guard ------------------------------------------------------------------------
85
86export const occurrences = (text: string, line: string): number =>
87 line === '' ? 0 : text.split('\n').filter(one => one.trim() === line).length
88
89type GuardFinding = PluginState['assertion-guard']['entries'][number]['findings'][number]
90
91const guardRule = (finding: GuardFinding): string => `assertion-guard:${finding.kind}`
92
93/** Each finding's rule and its before and after lines, counted in the file as it stands when the finding is opened. */
94export function marksOf(findings: readonly GuardFinding[], text: string): HitMarks[] {
95 const mark = (side: HitMark['side'], raw: string | undefined): HitMark | undefined => {
96 const line = raw?.trim() ?? ''
97 return line === '' ? undefined : { side, line, count: occurrences(text, line) }
98 }
99 return findings.map(f => {
100 const before = mark('before', f.before)
101 const after = mark('after', f.after)
102 return { rule: guardRule(f), ...(before === undefined ? {} : { before }), ...(after === undefined ? {} : { after }) }
103 })
104}
105
106/**
107 * Whether the file now undoes one finding's weakening: the old line came back and the new one
108 * went (or only the one it has did). Counts, not presence, so a duplicate assertion elsewhere
109 * in the file does not look like a revert.
110 */
111function isUndone({ before, after }: HitMarks, text: string): boolean {
112 if (before === undefined && after === undefined) return false
113 const isBack = before === undefined || occurrences(text, before.line) > before.count
114 const isGone = after === undefined || occurrences(text, after.line) < after.count
115 return isBack && isGone
116}
117
118/** Whether the file now undoes the weakening of some finding. */
119export const isReverted = (marks: readonly HitMarks[], text: string): boolean => marks.some(one => isUndone(one, text))
120
121/**
122 * The rules of one entry the file now undoes: a rule (a kind of weakening) is undone when one of
123 * its findings is, so undoing a loosened matcher scores `loosened` and leaves a swallowed error
124 * in the same edit open. An entry gets one verdict per rule: two loosened matchers with one
125 * undone score `loosened` once, as tp.
126 */
127export const revertedRules = (one: HitAssertion, text: string): string[] => [...new Set(one.marks.filter(mark => isUndone(mark, text)).map(mark => mark.rule))]
128
129/** The entry with `rules` settled: those rules and their findings gone, or null when nothing is left open. */
130export function withoutRules(one: HitAssertion, rules: readonly string[]): HitAssertion | null {
131 const left = one.rules.filter(rule => !rules.includes(rule))
132 if (left.length === 0) return null
133 return { ...one, rules: left, marks: one.marks.filter(mark => !rules.includes(mark.rule)) }
134}
135
136export const guardRules = (findings: readonly GuardFinding[]): string[] => [...new Set(findings.map(guardRule))]
137
138// ---- the table ------------------------------------------------------------------------------
139
140export function addVerdicts(table: HitTable, verdicts: readonly Pick<HitVerdict, 'rule' | 'verdict'>[]): HitTable {
141 const next: HitTable = { ...table }
142 for (const v of verdicts) {
143 const row = next[v.rule] ?? { tp: 0, fp: 0, unknown: 0 }
144 next[v.rule] = { ...row, [v.verdict]: row[v.verdict] + 1 }
145 }
146 return next
147}
148
149export function fromStore(value: unknown): HitTable {
150 const table: HitTable = {}
151 if (typeof value !== 'object' || value === null) return table
152 const n = (x: unknown) => (typeof x === 'number' && Number.isFinite(x) && x >= 0 ? x : 0)
153 for (const [rule, row] of Object.entries(value as Record<string, Record<string, unknown>>)) {
154 if (typeof row !== 'object' || row === null) continue
155 table[rule] = { tp: n(row.tp), fp: n(row.fp), unknown: n(row.unknown) }
156 }
157 return table
158}
159
160export const isSame = (a: HitTable, b: HitTable): boolean => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort())
161
162export const DEFAULT_THRESHOLD = 0.5
163
164/**
165 * The precision threshold as configured, when it is one (0 to 1). Any other value, such as a
166 * percentage typed as 50, would mute rules that are always right, so the default is used and
167 * the value given is reported.
168 */
169export function thresholdOf(value: unknown): { value: number; ignored?: string } {
170 if (value === undefined || value === null || value === '') return { value: DEFAULT_THRESHOLD }
171 if (typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1) return { value }
172 return { value: DEFAULT_THRESHOLD, ignored: String(value) }
173}
174
175/** Rules judged often enough whose precision is below the threshold. */
176export function mutedOf(table: HitTable, minN: number, threshold: number): string[] {
177 return Object.entries(table)
178 .filter(([, row]) => row.tp + row.fp >= minN && row.tp / (row.tp + row.fp) < threshold)
179 .map(([rule]) => rule)
180 .sort()
181}
182
183/** The /hit-rate report: one line per rule, the unknown share the decision record is refuted by, and the latest verdicts. */
184export function reportLines(table: HitTable, muted: readonly string[], last: readonly HitVerdict[], minN: number): string[] {
185 const rows = Object.entries(table).sort(([a], [b]) => a.localeCompare(b))
186 if (rows.length === 0) return ['まだ判定はありません。']
187 const total = rows.reduce((sum, [, r]) => ({ tp: sum.tp + r.tp, fp: sum.fp + r.fp, unknown: sum.unknown + r.unknown }), { tp: 0, fp: 0, unknown: 0 })
188 const all = total.tp + total.fp + total.unknown
189 const lines = rows.map(([rule, r]) => {
190 const n = r.tp + r.fp
191 const precision = n === 0 ? '—' : `${Math.round((r.tp / n) * 100)}%`
192 const flag = muted.includes(rule) ? ' 🔇 loopback 停止中' : n < minN ? ` (n<${minN})` : ''
193 return `${rule} TP ${r.tp} / FP ${r.fp} / unknown ${r.unknown} precision ${precision} n=${n}${flag}`
194 })
195 const unknownRate = all === 0 ? '—' : `${Math.round((total.unknown / all) * 100)}%`
196 return [
197 ...lines,
198 `unknown 率: ${total.unknown}/${all}(${unknownRate}、全セッション累計)`,
199 ...(last.length === 0 ? [] : ['このセッションの最近の判定:', ...last.slice(-8).map(v => `- ${v.verdict.toUpperCase()} ${v.rule}: ${v.why}`)]),
200 ]
201}
202types/index.d.ts 62 lines1/** What happened after a finding: it held (tp), it did not (fp), or nothing observable decided it. */
2export type HitVerdictKind = 'tp' | 'fp' | 'unknown'
3
4export type HitVerdict = {
5 /** The detector's rule: `done-gate:unverified`, `assertion-guard:<kind>`, `self-audit:R1`, `zukan:recall`. */
6 rule: string
7 verdict: HitVerdictKind
8 /** The outcome that decided it, in a few words. */
9 why: string
10 at: number
11}
12
13/** Across sessions: each rule's verdict counts. */
14export type HitTable = Record<string, { tp: number; fp: number; unknown: number }>
15
16/** done-gate's unverified flag: not raised, raised and waiting for a check, or decided while the flag still stands. */
17export type HitDoneGate = 'idle' | 'open' | 'settled'
18
19/** A line of the edited file and how many times it occurred when the finding was opened. */
20export type HitMark = { side: 'before' | 'after'; line: string; count: number }
21
22/** One finding's rule (`assertion-guard:<kind>`) and its before and after lines. */
23export type HitMarks = { rule: string; before?: HitMark; after?: HitMark }
24
25/** One assertion-guard entry waiting for a revert or a review left standing. */
26export type HitAssertion = {
27 path: string
28 /** The rules still open: a rule leaves once its weakening is undone. */
29 rules: string[]
30 /** Per finding: its rule, and its before line and after line as counted in the file. */
31 marks: HitMarks[]
32 /** The turn number when the person was first seen to have reviewed it. */
33 reviewedTurn?: number
34}
35
36/** The rules self-audit flagged on its latest audited reply, waiting for the next audited reply. */
37export type HitAudit = { turnId: string; rules: string[] }
38
39/** A recalled zukan fix waiting for the error to be got past. */
40export type HitRecall = { key: string; failed: string; suggested: string; openedAt: number }
41
42declare module 'claude-code' {
43 interface PluginState {
44 'hit-rate': {
45 /** Mirror of the stored table, for /hit-rate and for loopback. */
46 table: HitTable
47 /** Rules with enough verdicts and a precision below the threshold: loopback does not feed them. */
48 muted: string[]
49 doneGate: HitDoneGate
50 assertions: Record<string, HitAssertion>
51 /** assertion-guard entry ids already opened, so a settled one is not opened again. */
52 seen: string[]
53 audit: HitAudit | null
54 recalls: Record<string, HitRecall>
55 /** Main-loop turns started this session. */
56 turn: number
57 /** The latest verdicts of this session, newest last. */
58 last: HitVerdict[]
59 }
60 }
61}
62