Polyglot dev-workflow hooks for Claude Code: auto-lint on edit, verify tests/linters before stopping, a PreToolUse dangerous-command guard (catastrophic…

A Claude Code plugin bundling polyglot, project-agnostic dev-workflow hooks plus the companion skills the hooks point at. Each hook script detects the project's own toolchain (Ruby/Rails, JavaScript/TypeScript, Python) — there is nothing to configure.
Part of the dev-hooks marketplace, alongside coding-onboarding, thinking-tools, and writing.
| Event | Script | Purpose | ||
|---|---|---|---|---|
UserPromptSubmit | prompt-log.sh | Append one JSON line per user prompt (timestamp, repo cwd, session id, prompt length, first 500 chars) to ~/.claude/automation-review/prompts.jsonl (created 0600) — the cross-repo data source the thinking-tools weekly-automation-review skill clusters to spot repetitive requests worth automating. Credential-shaped values (vendor token prefixes, JWTs, private-key blocks, SECRET=-style assignments) are redacted to [REDACTED] before the line is written. Local-only, silent, never blocks the prompt. Opt out with DEV_HOOKS_PROMPT_LOG=false. | ||
UserPromptSubmit | intent-check-reminder.sh | When a prompt is a task (an imperative change request — "add…", "refactor…") but states neither a why (intent) nor non-goals (what's out of scope), inject one advisory nudging Claude to briefly restate its assumed goal + out-of-scope before substantial work, and to ask clarifying questions when the task is ambiguous or high-stakes. Deliberately conservative — skips questions, follow-ups, one-liners, and prompts that already carry intent or scope markers, so it fires only on a genuinely thin brief. Advisory, never blocks; pairs with the agent-brief skill. Opt out with DEV_HOOKS_INTENT_CHECK=false. | ||
UserPromptSubmit | data-before-design-reminder.sh | When a prompt asks to build a surface that displays stored data (a build verb plus a display surface — card, panel, dashboard, table, report, profile, badge, tile, widget, …), inject one advisory pointing at the data-before-design skill: profile every field the view will show (non-null count, distinct values, length min/mean/max, coverage, cardinality per parent, date presence) and state the numbers before writing markup, then mock several options from the busiest, the typical and the empty record and let the user pick. Conservative — stays silent for pure restyling (colour/padding/font/spacing/alignment) and fires once per session. Advisory, never blocks. Opt out with DEV_HOOKS_DATA_BEFORE_DESIGN=false. | ||
PreToolUse (Bash) | dangerous-command-guard.sh | Block the catastrophic, irreversible few before they run: wipe the disk/home (rm -rf /), fork bomb, format a filesystem (mkfs), overwrite a raw block device (dd of=/dev/…), chmod -R 777 /. Flags and targets are judged per simple command, split the way the shell would (quotes honoured; $(…), backticks, bash -c '…', eval and a heredoc fed to a shell split out), so cd ~ && rm -rf build/ isn't read as rm -rf ~ and `rg "fnox get | bws secret get" isn't read as a secret read. Risky-but-legitimate commands (rm -rf a path, git reset --hard, force-push, curl … \ | bash, sudo) are not gated — the normal permission flow already prompts on them, and Claude Code's auto-mode classifier catches them besides. Aimed at beginners not running auto mode, whose agents shouldn't be able to run a machine-wiping command on their say-so. The deny is configurable with DEV_HOOKS_GUARD_DENY (deny default / ask / allow) for advanced users. Opt-in extra (DEV_HOOKS_GUARD_MAIN=1, seeded by the coding-onboarding plugin's getting-started skill): ask before committing/pushing straight to main/master. Separately, it blocks a command that would print a secret value into the transcript — cat .env, cat config/master.key, bws secret get, op read, echo $GITHUB_TOKEN, and credential probes such as git credential fill, a helper's get, gh auth token, gh auth status -t, secret-tool lookup and cat ~/.git-credentials — since a leaked value can only really be fixed by rotating it; template files (.env.example), *.pub halves, inject-only wrappers (fnox run), source .env, counting greps, stdout sent to /dev/null or a file, and a value captured into a variable (v=$(fnox get X)) stay silent (2>/dev/null hides only stderr, so it doesn't). Configurable with DEV_HOOKS_GUARD_SECRETS (deny default / ask / allow) — it asked until 2.40.0, until an auto-mode classifier approved a ${VAR:-UNSET} presence check and leaked a live token. Opt out of the whole hook with DEV_HOOKS_BASH_GUARD=false`. |
PostToolUse (Bash) | ci-watch-reminder.sh | After a git push in a repo with GitHub Actions workflows, remind Claude to watch the CI run so a red pipeline comes back to it instead of going unnoticed — watch to completion when idle, or background the watch (or hand it to a sub-agent) when there's more work, rather than pushing and forgetting. Reminder-only: the hook never runs gh; Claude does. Fires on real git push invocations (git push, git -C dir push, … && git push), not git config …pushurl/switch/pushall, and only when .github/workflows/ exists. Advisory, never blocks. Stands down where the mod's CI watch (below) watches the run itself (it sets DEV_HOOKS_CI_WATCH_SESSION to the session id). Opt out of both with DEV_HOOKS_CI_WATCH=false. | ||
PostToolUse (Bash) | worktree-provision-reminder.sh | After git worktree add, check whether the new worktree is missing gitignored state the main checkout has (Rails storage/ blobs, config/credentials/master.key, .env, service-account JSON, uploads) and point at [[worktree-setup]]'s setup-worktree.sh. Compares exact ignored paths, not top-level dirs — storage/ exists in a fresh worktree because storage/.keep is tracked, while every blob is absent, so the app boots and then 500s on each image as if the view were broken. Load-bearing entries lead the report; editor/tool caches are excluded. Silent when the worktree is correctly provisioned. Advisory, never blocks. Opt out with DEV_HOOKS_WORKTREE_PROVISION=false. | ||
PostToolUse (Write\ | Edit\ | MultiEdit) | lint-on-edit.sh | Auto-fix/format the file Claude just wrote using the linter this project configures (RuboCop/Standard, herb/erb_lint for ERB, Biome/Prettier/ESLint, Ruff/Black). Safe fixes only, never blocks. For Python it marks the code-deleting rules unfixable (F401 unused import, F841 unused variable) so a beat-early import/binding isn't stripped before the line that uses it lands — verify-work.sh/CI still flag them; override with DEV_HOOKS_RUFF_KEEP (set empty to restore full autofix). |
PostToolUse (Write\ | Edit\ | MultiEdit) | latest-deps-reminder.sh | When Claude writes a dependency manifest (requirements.txt, package.json, Gemfile, pyproject.toml, …) or hand-writes a lockfile, remind it to verify the versions are current (training data goes stale) with the right lookup command per ecosystem, or to regenerate lockfiles via the package manager. On manifest edits it also nudges Claude to keep the README/CLAUDE.md key-package versions in sync (creating those docs if missing). Advisory only, never blocks; fires once per session per ecosystem. |
PostToolUse (Write\ | Edit\ | MultiEdit) | scaffold-reminder.sh | When Claude creates a new project manifest or framework entrypoint by hand (Gemfile, package.json, pyproject.toml, Cargo.toml, go.mod, mix.exs, composer.json, build.gradle/pom.xml, manage.py, config/application.rb, …), remind it to run the framework's official generator (rails new, npm create vite@latest, django-admin startproject, cargo new, …) instead of scaffolding from memory — and to check the framework's current stable release and the generator's current flags first, unless the user pinned a version. Write-tool only; files git already tracks are skipped. Advisory only, never blocks; fires once per session. Opt out with DEV_HOOKS_SCAFFOLD=false. |
PostToolUse (Write\ | Edit\ | MultiEdit) | dockerfile-reminder.sh | When Claude writes a Dockerfile/Containerfile, run hadolint on it and feed the findings (or a clean pass) back to Claude, plus a layer-ordering nudge that points at the dockerfile skill. If hadolint isn't installed, falls back to a once-per-session ordering/gotchas reminder. Advisory only — reports every time, never blocks. |
PostToolUse (Write\ | Edit\ | MultiEdit) | secret-plaintext-reminder.sh | When Claude writes what looks like a plaintext secret value (a named API_KEY/SECRET/TOKEN/PASSWORD assignment to a real literal, a private-key block, or an AWS key id), nudge it to migrate to fnox via the env-to-fnox skill instead of committing the value. Fires at write time (before gitleaks would at commit). Env-var refs and obvious placeholders are ignored. Advisory only, never blocks; fires once per session. |
PostToolUse (Write\ | Edit\ | MultiEdit) | popover-reminder.sh | When Claude writes popover/tooltip/dropdown/menu UI (a frontend file — the shared extension list, see inline-svg-reminder.sh notes — that has a popover/tooltip/dropdown controller filename, role="tooltip"/the popover attribute, an @floating-ui/popper/tippy import, a data-controller naming one, or a tooltip/popover/dropdown class/data-attribute), nudge it to use a collision-aware positioner (flip + shift) rendered in the top layer/a portal instead of hand-rolled top/left math, and point at the popovers-tooltips skill. Advisory only, never blocks; fires once per session. Opt out with DEV_HOOKS_POPOVER=false. |
PostToolUse (Write\ | Edit\ | MultiEdit) | ci-action-ref-reminder.sh | When Claude writes/edits a GitHub Actions workflow (a *.yml/*.yaml pinning uses: owner/repo@ref), point it at the github-actions skill's supply-chain checklist (SHA-pin every action, read-only GITHUB_TOKEN, no untrusted input in run:) and have it verify the pins with the bundled check_action_refs.sh (which resolves each pin's # vX.Y.Z comment via git ls-remote and fails on a missing/mismatched tag); the hook itself never hits the network. Advisory only, never blocks; fires once per session per file. |
PostToolUse (Write\ | Edit\ | MultiEdit) | inline-svg-reminder.sh | When Claude hand-writes inline SVG into a frontend file (an <svg> blob with real drawing content — <path>/<circle>/<rect>/… or long d="M…" path data — or a data:image/svg+xml URI), feed a correction back (exit 2, every occurrence): use the project's icon library (named from package.json/Gemfile when found), else extract to a dedicated .svg file/sprite and reference it. Good patterns stay silent: <use href> sprite refs, writing .svg files, , markdown, test files, data-driven chart markup (<rect x={…}>), and pre-existing SVG (Writes deduped against HEAD, Edits against old_string). Opt out with DEV_HOOKS_SVG_INLINE=false. |
PostToolUse (Write\ | Edit\ | MultiEdit) | migration-safety-reminder.sh | When Claude writes a database migration (Rails db/migrate/*.rb, Django <app>/migrations/*.py, Alembic …/versions/*.py), nudge it to check safe-migration practice — reversibility (change/up+down/downgrade), no data backfill inside a schema migration, lock-safe column adds (nullable → batched backfill → constraint), and concurrent index creation (algorithm: :concurrently + disable_ddl_transaction!, CREATE INDEX CONCURRENTLY, AddIndexConcurrently). Advisory only, never blocks; fires once per session. Opt out with DEV_HOOKS_MIGRATION=false. |
PostToolUse (Write\ | Edit\ | MultiEdit) | a11y-reminder.sh | When Claude writes frontend markup with common accessibility gaps — an <img> with no alt, an icon-only <button>/<a> with no accessible name, a click handler on a non-interactive <div>/<span>, or an unlabeled form <input> — flag them and point at the accessibility skill. Scans only what the call adds; heuristic. Advisory only, never blocks; fires once per session. Opt out with DEV_HOOKS_A11Y=false. |
PostToolUse (Write\ | Edit\ | MultiEdit) | sql-injection-reminder.sh | When Claude writes SQL with a value interpolated straight into the query string (a Python f-string, Ruby #{}, or string concatenation around SQL keywords), nudge it toward parameterized queries / ORM bind variables. The safe %s/:name/? placeholder styles are not flagged. Advisory only, never blocks; fires once per session. Opt out with DEV_HOOKS_SQL_INJECTION=false. |
PostToolUse (Write\ | Edit\ | MultiEdit) | error-swallow-reminder.sh | When Claude writes a handler that silently swallows the error (a Python bare except: or except …: pass, an empty JS/TS catch {}, or an empty Ruby rescue … end), nudge it to catch the specific exception and handle/log/re-raise instead. Scans only what the call adds. Advisory only, never blocks; fires once per session. Opt out with DEV_HOOKS_ERROR_SWALLOW=false. |
Stop | verify-work.sh | On stop, detect changed code files and run the project's linters/tests (RuboCop, herb + brakeman for Rails, Minitest/RSpec, Ruff/pytest, ESLint/JS tests). Blocks the stop with real failures so Claude fixes them before finishing — these re-block on every stop until fixed, including the continuation another Stop hook forced (capped at 3 blocks in a row there). When code changed but no tooling is recognised, it nudges Claude to check manually at most once per session, then lets it finish (no Stop loop). Turn the whole hook off with DEV_HOOKS_VERIFY=false (per-repo/user). | ||
Stop | debug-leftover-reminder.sh | On stop, flag debug statements Claude newly introduced this session (console.log/debugger, binding.pry/byebug/Ruby p, breakpoint()/pdb) — diffed against HEAD so pre-existing lines are ignored — and feed them back (blocks the stop) to strip before finishing. Test files excluded. Fires at most once per session. | ||
Stop | missing-test-reminder.sh | On stop, if Claude added a new source file this session with no matching test (*_spec.rb/*_test.rb, test_*.py/*_test.py, *.test.*/*.spec.*), nudge it (blocks the stop) to add one. Counts files added by commits made during the session as well as uncommitted ones, so committing as you go doesn't hide them. Skips test files, low-value targets (barrels, type defs, config, migrations, __init__/conftest), and vendored/generated code (dirs from the repo's .jscpd.json, plus minified *.min.*). Fires at most once per session. | ||
SessionStart | dev-env-reminder.sh | If the repo is yours and the dev-env standard applies but isn't met (missing mise/hk/CI/gitleaks, or behind the version stamp), nudge Claude to flag it and offer the dev-env-setup skill. Advisory only — never edits. Owner-gated (see env vars below); opt out per repo. | ||
SessionStart | docs-context.sh | If the project has a docs/ or doc/ directory containing Markdown files, emit a brief index (titles + optional descriptions from YAML frontmatter) so Claude knows where documentation lives and can consult the right files when working on related features. Docs whose frontmatter carries an opt-in stale_after: YYYY-MM-DD (past) or status: stale/deprecated/draft get their line flagged with ⚠ so Claude doesn't blindly trust an out-of-date doc; docs without the fields are unaffected. Advisory only — never blocks. Opt out with DEV_HOOKS_DOCS_CONTEXT=false. | ||
SessionStart | script-index.sh | List the custom CLI tools in your saved script library — each executable shebang script's path + its # short-description: line — so Claude knows what already exists and reaches for it instead of re-solving the problem, like a lightweight skill index. The library is DEV_HOOKS_SCRIPT_DIR, a colon-separated list of roots like PATH (default ~/.local/bin), each scanned recursively so a cloned scripts repo with subdirectories works. Scripts with no # short-description: are listed under a placeholder telling Claude to run <path> --help and ask you to add one. Hide scripts that aren't your own tools (installed/third-party CLIs, app launchers) with DEV_HOOKS_SCRIPT_IGNORE — a colon-separated list of globs matched against each script's basename or full path (e.g. *vocalinux*:gext). Never executes a script (so no --help side effects at startup). Paired with the script-library skill. Advisory only — never blocks. Opt out with DEV_HOOKS_SCRIPT_INDEX=false. | ||
Stop | plan-reminder.sh | If .claude/current_plan.md exists and is stale, remind Claude to update the multi-session plan before ending. Nudges once per version of the plan, not once per stop. | ||
Stop | review-reminder.sh | On stop, if this session touched code files but no code review ran since, remind Claude (blocks the stop) to run a review and keep iterating until it comes back clean. "This session's work" counts uncommitted changes and commits made since the session started, so the commit-as-you-go workflow's clean tree no longer silences it. "Already reviewed" is a tool-use scan for /code-review, a code-reviewer agent, requesting-code-review, or a dispatched agent whose description says "review" (how a subagent-driven session reviews) — not a bare name grep, which would match the transcript's skill listing in every session. Re-arms rather than firing once: while no review has run it asks at every stop (once only for a change under 10 added lines, up to 3 times above that, so a Claude that cannot review still terminates); once one has run, the session's added-code-line total becomes a baseline and the hook speaks again when it grows by ≥ 20 — code written after a review makes that review stale. Opt out with DEV_HOOKS_REVIEW=false. | ||
Stop | compress-comments-reminder.sh | On stop, if the session's work added a noticeable number of comment lines to code files (≥ 3, counting added lines in one diff from the last commit before the session started — the transcript's first timestamp — to the working tree, plus untracked code files, so commit-as-you-go sessions still trigger and rewriting this session's own comments is not growth; shebangs and directive comments like shellcheck/noqa/eslint don't count), remind Claude (blocks the stop) to run the compress-comments skill — delete comments that restate the code, compress the rest. Re-arms rather than firing once: the comment total at each reminder is stored per session, and the hook fires again whenever the total grows by ≥ 3 — one large commit or purely uncommitted edits alike. An unchanged total stays silent (no Stop loop), a dropped total (cleanup) rebases the baseline, and a skill run seeds it (detected by tool-use scan, not a bare name grep — the transcript's skill listing names every installed skill). Opt out with DEV_HOOKS_COMPRESS_COMMENTS=false. | ||
Stop | memory-reminder.sh | On stop of a substantial session (≥ 6 human turns), remind Claude (blocks the stop) to capture durable, non-obvious learnings into its file-based memory — memory dir only, never CLAUDE.md, with an explicit "nothing worth saving" escape hatch. Fires at most once per session, and not at all once a memory file was written this session (including by a subagent, when the mod runs the Stop hooks). Opt-in via DEV_HOOKS_MEMORY=1, or auto-enabled once you use Claude's memory feature anywhere. | ||
Stop | big-change-reminder.sh | On stop, if the working tree holds a very large uncommitted change (default: ≥ 25 files or ≥ 800 added lines), nudge Claude (blocks the stop) to slow down — commit the working pieces in small, focused commits, run tests, get a review, and consider plan mode for the next chunk. Stays silent when a multi-session plan is already in progress (.claude/current_plan.md). Aimed at beginners, for whom a giant uncommitted diff is hard to review and easy to lose. Fires once per session. Thresholds tunable via DEV_HOOKS_BIG_CHANGE_FILES/DEV_HOOKS_BIG_CHANGE_LINES; opt out with DEV_HOOKS_BIG_CHANGE=false. | ||
Stop | change-summary-reminder.sh | On stop, if the session changed a meaningful number of files (default: ≥ 3), nudge Claude (blocks the stop) to give a short, plain-language summary of what changed in each file — an aid for reviewing the session's work without re-reading the raw diff, for technical and non-technical readers alike. Fires once per session. Threshold tunable via DEV_HOOKS_CHANGE_SUMMARY_FILES; opt out with DEV_HOOKS_CHANGE_SUMMARY=false. | ||
Stop | save-script-reminder.sh | On stop, if Claude wrote a script this session (a Write of shebang-prefixed content, wherever it landed — scratchpad, /tmp, or inside a project repo; only scripts already in a library root are excluded), nudge it (blocks the stop) to decide per script: a broadly useful tool gets genericized to the saved-script standard (PEP 723 + uv run shebang + # short-description: + chmod +x) and added to a library root (or a subdirectory) so the script-index hook surfaces it next session — even one already committed to a repo can be worth promoting — while a genuinely task-specific or throwaway script is left where it is. Points at the script-library skill. Fires at most once per session. Library roots come from DEV_HOOKS_SCRIPT_DIR; opt out with DEV_HOOKS_SAVE_SCRIPT=false. |
The companion skills the hooks point at:
| Skill | Use when |
|---|---|
dev-env-setup | Auditing/setting up a repo against an opinionated dev-env standard, version-tracked via DEV_ENV_VERSION: mise pinning the toolchain, an hk pre-commit hook running linters/tests + gitleaks + zizmor & actionlint (GitHub Actions security + correctness checks, v18) (Rails gets the v17 ERB + security + correctness suite — herb, brakeman, bundler-audit, importmap audit, strong_migrations, database_consistency, fasterer, rubocop plugins), a CI workflow mirroring those checks, a version-sync gate (v23) asserting that every file pinning a toolchain or service version — mise.toml, the .<lang>-version files, the Dockerfile ARGs, package.json's packageManager, and the compose/deploy/CI image: tags, with a floating mise spec resolved through mise.lock — names the same one, and that every CI setup step reads that pin rather than floating, hardcoding or omitting a version (v26) — a full release, with mise.lock always in the comparison (v27), a 4-day dependency cooldown (uv exclude-newer enforced on Python repos; Ruby/JS package managers documented), and project docs — a README.md and CLAUDE.md recording the project's pinned key-package versions, dispatching a subagent to create them when missing. Paired with the dev-env-reminder hook; trimmed from Nate Berkopec's dev-env-setup (kept/dropped rationale in the skill; per-version migration steps in references/upgrade-guide.md). Ships a fleet mode: after a standard bump, scripts/fleet_roster.sh discovers every DEV_ENV_VERSION-stamped repo live and the skill backfills them, canary first, one isolated agent per repo. |
| github-actions | Writing, reviewing, or hardening a GitHub Actions workflow, or bumping a whole fleet's action pins. Carries the supply-chain security checklist (SHA-pin actions, read-only GITHUB_TOKEN, no pull_request_target/untrusted input in run:, OIDC for cloud creds, run actionlint + zizmor) in references/security-checklist.md, plus the fleet-wide SHA-pin/bump procedure (pinact run -u + check_action_refs.sh). Paired with the ci-action-ref-reminder hook; the dev-env CI templates ship pre-hardened to this standa
hooks/register.tsx 804 lines1// dev-hooks' function-hook module (Claude Code mods, 2.1.287+). Command hooks stay in
2// hooks.json; this file holds what only a mod can do, such as drawing UI.
3//
4// /context-bar: the context window as a stacked bar above the prompt, one color per
5// /context category, refreshed after every turn from the local (API-free) estimate.
6//
7// Session facts: what this session did, recorded as it happens rather than rebuilt
8// from git and the transcript at Stop — skills expanded (main loop and subagents),
9// subagents dispatched, files edited per repo with net growth, and the Stop verdict
10// the command hooks returned. The Stop orchestration below hands it to the Stop hooks.
11//
12// Stop orchestration: the module runs dev-hooks' own Stop command hooks itself and
13// returns their reasons as one block. It marks the session with DEV_HOOKS_MOD_SESSION,
14// which makes the copies Claude Code runs directly stand down (reminder_stop_init), and
15// runs its own copies with DEV_HOOKS_ORCHESTRATED set. Without this module (an older
16// Claude Code, mods off) nothing is marked and the command hooks run as before.
17//
18// Stop-nudge outcomes: each reason the orchestration returns is resolved at the next
19// Stop as acted on or not (nudge-outcomes.ts), shown by /session-facts nudges.
20//
21// CI watch: after a git push the module follows that push's GitHub Actions runs and
22// tells the person and Claude how they ended (the block marked below).
23import type { EngineInterface, Register } from 'claude-code'
24
25import type { Facts, Nudge, NudgeRecord, Segment, Snapshot } from '../types'
26import {
27 ciNote,
28 ciStatusLine,
29 ciToast,
30 ciVerdict,
31 githubRepo,
32 isDryRun,
33 isGitPush,
34 parsePushOutput,
35 parseRuns,
36 pushDir,
37 RUN_FIELDS,
38 watchingNote,
39} from './ci-watch'
40import type { Run, Watch } from './ci-watch'
41import {
42 formatNudgeStats,
43 hookName,
44 mergeNudgeLog,
45 newNudge,
46 NUDGE_LOG_KEY,
47 nudgeExport,
48 resolvePending,
49 toRecord,
50} from './nudge-outcomes'
51
52const isShown = { plugin: 'dev-hooks', key: 'contextBarShown' } as const
53const snapshot = { plugin: 'dev-hooks', key: 'contextBarSnapshot' } as const
54
55const formatTokens = (n: number) =>
56 n >= 1000 ? `${(n / 1000).toFixed(n >= 100_000 ? 0 : 1)}k` : `${n}`
57
58// Largest-remainder split, so the cells always add up to the bar's width
59// and any category with tokens gets at least the cell its share earns.
60export const allocate = (segments: Segment[], width: number): number[] => {
61 const total = segments.reduce((sum, s) => sum + s.tokens, 0)
62 if (total === 0 || width <= 0) return segments.map(() => 0)
63 const exact = segments.map(s => (s.tokens / total) * width)
64 const cells = exact.map(Math.floor)
65 let left = width - cells.reduce((sum, c) => sum + c, 0)
66 const byRemainder = exact
67 .map((x, i) => ({ i, rest: x - Math.floor(x) }))
68 .sort((a, b) => b.rest - a.rest)
69 for (const { i } of byRemainder) {
70 if (left-- <= 0) break
71 cells[i] = (cells[i] ?? 0) + 1
72 }
73 return cells
74}
75
76async function refresh($: EngineInterface) {
77 const { context } = await $.session.usage({ breakdown: 'summary' })
78 const b = context.breakdown
79 if (!b) return
80 const next: Snapshot = {
81 segments: b.categories
82 .filter(c => c.kind !== 'deferred' && c.tokens > 0)
83 .map(c => ({ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind as Segment['kind'] })),
84 totalTokens: b.totalTokens,
85 maxTokens: b.rawMaxTokens,
86 percentage: b.percentage,
87 }
88 await $.state.set(snapshot, next)
89}
90
91type Patch = { lines: string[] }[]
92
93// Net growth: a replaced line is a -/+ pair, not growth. A created file's
94// patch is empty, so count its content instead.
95export const netAdded = (patch: Patch | undefined, created: string | undefined): number => {
96 const lines = (patch ?? []).flatMap(hunk => hunk.lines)
97 if (lines.length === 0 && created !== undefined) {
98 return created === '' ? 0 : created.replace(/\n$/, '').split('\n').length
99 }
100 return lines.filter(l => l.startsWith('+')).length - lines.filter(l => l.startsWith('-')).length
101}
102
103const FACTS_TTL_MS = 30 * 86_400_000
104const emptyFacts = (): Facts => ({ updatedAt: 0, skills: [], agents: [], repos: [], stops: [] })
105
106// $.store, not $.state: facts must survive a reboot or a resumed session.
107async function loadFacts($: EngineInterface, sessionId?: string): Promise<Facts> {
108 const id = sessionId ?? (await $.session.id())
109 return ((await $.store.get(`facts:${id}`)) as Facts | undefined) ?? emptyFacts()
110}
111
112// The store has no compare-and-set and tool calls run in parallel, so every
113// read-modify-write goes through one queue.
114let factsQueue: Promise<unknown> = Promise.resolve()
115
116function changeFacts($: EngineInterface, fn: (f: Facts) => Facts, sessionId?: string): Promise<Facts> {
117 const run = factsQueue.then(async () => {
118 const id = sessionId ?? (await $.session.id())
119 const next = { ...fn(await loadFacts($, id)), updatedAt: Date.now() }
120 await $.store.set(`facts:${id}`, next)
121 return next
122 })
123 factsQueue = run.catch(() => undefined)
124 return run
125}
126
127// Returns the facts kept, by session id.
128async function pruneFacts($: EngineInterface): Promise<Map<string, Facts>> {
129 const cutoff = Date.now() - FACTS_TTL_MS
130 const kept = new Map<string, Facts>()
131 for (const key of await $.store.keys()) {
132 if (!key.startsWith('facts:')) continue
133 const f = (await $.store.get(key)) as Facts | undefined
134 if (!f || f.updatedAt < cutoff) await $.store.delete(key)
135 else kept.set(key.slice('facts:'.length), f)
136 }
137 return kept
138}
139
140export const ago = (at: number, now: number): string => {
141 const mins = Math.floor((now - at) / 60_000)
142 if (mins < 1) return 'just now'
143 if (mins < 60) return `${mins}m ago`
144 if (mins < 24 * 60) return `${Math.floor(mins / 60)}h${mins % 60 ? `${mins % 60}m` : ''} ago`
145 return `${Math.floor(mins / (24 * 60))}d ago`
146}
147
148const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
149
150// This session's Stop nudges, shown only once one fired.
151const nudgeSection = (nudges: Nudge[], when: (at: number) => string): string[] =>
152 nudges.length === 0
153 ? []
154 : [
155 '',
156 `Stop nudges (${nudges.length})`,
157 ...nudges.map(n => ` ${when(n.at)} ${n.hook} · ${n.outcome ? `${n.outcome} (${n.evidence})` : 'pending'}`),
158 ]
159
160// Plain text: command output is shown as-is, not rendered as markdown.
161export const formatFacts = (f: Facts, now: number, home: string): string => {
162 const tilde = (path: string) => (home && path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path)
163 const when = (at: number) => ago(at, now).padEnd(8)
164 // "Title: none" when empty; otherwise the heading (with its count) and the rows.
165 const section = (title: string, count: string, rows: string[]) =>
166 rows.length === 0 ? [`${title}: none`] : [`${title}${count}`, ...rows, '']
167
168 const repos = [...f.repos].sort((a, b) => Number(a.root === null) - Number(b.root === null))
169 const edits = repos.flatMap(r => {
170 const where = r.root === null ? 'outside any repo' : tilde(r.root)
171 const inRepo = (file: string) => r.root !== null && file.startsWith(`${r.root}/`)
172 const bySub = r.bySubagent > 0 ? ` · ${plural(r.bySubagent, 'edit')} by subagents` : ''
173 return [
174 ` ${where} · ${r.added >= 0 ? '+' : ''}${r.added} lines · ${plural(r.files.length, 'file')}${bySub}`,
175 ...r.files.map(file => ` ${inRepo(file) ? file.slice((r.root ?? '').length + 1) : tilde(file)}`),
176 ]
177 })
178 const blocked = f.stops.filter(s => s.block !== null)
179
180 const lines = [
181 f.updatedAt ? `Session facts · updated ${ago(f.updatedAt, now)}` : 'Session facts · nothing recorded yet',
182 '',
183 ...section('Skills', ` (${f.skills.length})`, f.skills.map(s => ` ${when(s.at)} ${s.skill}`)),
184 ...section(
185 'Subagents',
186 ` (${f.agents.length})`,
187 f.agents.map(a => ` ${when(a.at)} ${a.subagentType} · ${a.description}`),
188 ),
189 ...section('Edits via Edit/Write (Bash-made edits are not tracked)', '', edits),
190 ...(f.stops.length === 0
191 ? ['Stops: none']
192 : [
193 `Stops (${f.stops.length}, ${blocked.length || 'none'} blocked)`,
194 ...blocked.map(s => ` ${when(s.at)} ${(s.block ?? '').split('\n')[0]}`),
195 ]),
196 ...nudgeSection(f.nudges ?? [], when),
197 ]
198 if (lines[lines.length - 1] !== '') lines.push('')
199 return [
200 ...lines,
201 '/session-facts json prints the raw record; /session-facts nudges, Stop-nudge outcomes across sessions.',
202 ].join('\n')
203}
204
205const repoRoots = new Map<string, string | null>()
206
207async function repoRoot($: EngineInterface, file: string): Promise<string | null> {
208 const dir = file.slice(0, file.lastIndexOf('/')) || '/'
209 const known = repoRoots.get(dir)
210 if (known !== undefined) return known
211 const run = await $.process.run(['git', '-C', dir, 'rev-parse', '--show-toplevel'])
212 const root = run.exitCode === 0 ? run.stdout.trim() : null
213 repoRoots.set(dir, root)
214 return root
215}
216
217export type StopHook = { command: string; timeoutMs: number }
218
219// Claude Code's default for a command hook with no `timeout`, in seconds.
220const DEFAULT_HOOK_TIMEOUT_S = 600
221
222type HookEntry = { type?: string; command?: string; timeout?: number }
223
224export const stopHooks = (config: unknown): StopHook[] => {
225 const groups = (config as { hooks?: { Stop?: { hooks?: HookEntry[] }[] } } | null)?.hooks?.Stop ?? []
226 return groups
227 .flatMap(group => group.hooks ?? [])
228 .filter((h): h is HookEntry & { command: string } => h.type === 'command' && typeof h.command === 'string')
229 .map(h => ({ command: h.command, timeoutMs: (h.timeout ?? DEFAULT_HOOK_TIMEOUT_S) * 1000 }))
230}
231
232// A command hook's Stop answer, read as Claude Code reads it: exit 2 blocks with
233// stderr as the reason; exit 0 blocks only with a decision:block JSON answer.
234export const stopReason = (run: { exitCode: number; stdout: string; stderr: string }): string | null => {
235 if (run.exitCode === 2) return run.stderr.trim() || null
236 if (run.exitCode !== 0) return null
237 try {
238 const out = JSON.parse(run.stdout) as { decision?: unknown; reason?: unknown }
239 return out.decision === 'block' && typeof out.reason === 'string' && out.reason.trim() ? out.reason : null
240 } catch {
241 return null
242 }
243}
244
245export const mergeBlocks = (below: string | undefined, reasons: string[]): string | undefined => {
246 const all = [...(below ? [below] : []), ...reasons]
247 return all.length > 0 ? all.join('\n\n') : undefined
248}
249
250async function ownStopHooks($: EngineInterface): Promise<StopHook[]> {
251 return stopHooks(JSON.parse(await $.fs.read(`${$.plugin.root}/hooks/hooks.json`)))
252}
253
254// A failed orchestration is remembered against this plugin version, so a broken
255// release doesn't cost every new session its first Stop; a new version tries again.
256const ORCHESTRATION_FAILED = 'stopOrchestrationFailed'
257
258async function pluginVersion($: EngineInterface): Promise<string> {
259 const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: string }
260 return manifest.version ?? 'unknown'
261}
262
263// Runs dev-hooks' Stop commands as Claude Code would and merges their reasons after
264// `below` (any block from hooks beneath the module).
265async function orchestrateStop(
266 $: EngineInterface,
267 e: StopInput,
268 below: string | undefined,
269): Promise<string | undefined> {
270 const stdin = JSON.stringify(e)
271 // The facts reach the shell hooks as a file (reminder_transcript_invoked reads it):
272 // they see skills and agents run inside subagents, which the transcript doesn't.
273 // Optional — without them the hooks fall back to the transcript.
274 const factsFile = `${(await $.env.get('TMPDIR')) || '/tmp'}/dev-hooks-facts-${e.session_id}.json`
275 const hasFacts = await $.fs
276 .write(factsFile, JSON.stringify(await loadFacts($)))
277 .then(() => true, () => false)
278 const env = {
279 CLAUDE_PLUGIN_ROOT: $.plugin.root,
280 DEV_HOOKS_ORCHESTRATED: '1',
281 ...(hasFacts ? { DEV_HOOKS_FACTS_FILE: factsFile } : {}),
282 }
283 const hooks = await ownStopHooks($)
284 const runs = await Promise.all(
285 hooks.map(hook =>
286 $.process
287 .run(['bash', '-c', hook.command], { cwd: e.cwd, stdin, env, timeoutMs: hook.timeoutMs })
288 .then(stopReason, () => null),
289 ),
290 )
291 const block = mergeBlocks(below, runs.filter((r): r is string => r !== null))
292 const stop = { at: Date.now(), block: block ?? null }
293 await changeFacts($, f => ({ ...f, stops: [...f.stops, stop].slice(-20) })).catch(() => undefined)
294 const fired = hooks.flatMap((hook, i) => {
295 const reason = runs[i]
296 return reason ? [{ hook: hookName(hook.command), reason }] : []
297 })
298 await trackNudges($, e, fired).catch(() => undefined)
299 return block
300}
301
302// ── Guard dialog ─────────────────────────────────────────────────────────────────
303// When dangerous-command-guard.sh asks (its `ask` modes), the mod puts the question to
304// the person in Claude Code's own dialog: a PreToolUse `ask` is answered by the
305// auto-mode classifier, the dialog is not. Allow only withdraws the guard's question —
306// the normal permission flow still runs — and Deny refuses; a hard deny is never asked.
307export const GUARD_PREFIX = 'dev-hooks guard — '
308
309export const isGuardAsk = (ask: string | undefined): ask is string => !!ask && ask.startsWith(GUARD_PREFIX)
310
311const COMMAND_SHOWN = 300
312
313const waitLabel = (seconds: number) => (seconds % 60 === 0 ? `${seconds / 60} min` : `${seconds}s`)
314
315export const guardQuestion = (ask: string, command: string, seconds = 0): string => {
316 const reason = ask.slice(GUARD_PREFIX.length).replace(/^please confirm:\s*/i, '')
317 const shown = command.length > COMMAND_SHOWN ? `${command.slice(0, COMMAND_SHOWN)}…` : command
318 const wait = seconds > 0 ? `\n\n(No answer within ${waitLabel(seconds)} refuses it.)` : ''
319 return `${reason}\n\nCommand: ${shown}${wait}\n\nLet it run?`
320}
321
322// An unanswered dialog refuses after this long, and tells Claude the safer route instead of
323// leaving it stuck. DEV_HOOKS_GUARD_DIALOG_TIMEOUT (seconds; 0 waits forever) set in front of
324// the command itself wins — Claude may pick a longer wait, since a timeout only ever refuses —
325// then the session's setting, then this default.
326const DEFAULT_GUARD_TIMEOUT_S = 120
327
328const COMMAND_TIMEOUT_RE =
329 /(?:^|[;&|]\s*)(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*DEV_HOOKS_GUARD_DIALOG_TIMEOUT=(['"]?)(\d+)\1(?=\s)/
330
331export const commandTimeout = (command: string): number | undefined => {
332 const match = COMMAND_TIMEOUT_RE.exec(command)
333 return match ? Number(match[2]) : undefined
334}
335
336export const guardTimeoutReason = (ask: string, seconds: number): string => {
337 const base =
338 `No answer in the dev-hooks guard dialog within ${seconds}s, so this command did not run.` +
339 ' (If the person may need longer, put DEV_HOOKS_GUARD_DIALOG_TIMEOUT=<seconds> in front of the command; 0 waits indefinitely.)'
340 return /the `[^`]+` branch directly/.test(ask)
341 ? `${base} Make this change on a separate branch in a git worktree instead (branch off the current HEAD), not directly on the main branch.`
342 : `${base} Ask the person in the chat before trying again, or find a route that doesn't need this command.`
343}
344
345// A dialog can't be closed from the mod, so one that timed out stays on screen. The mod
346// remembers it (question text -> note) and its render hook redraws it as a note: what was
347// asked, what Claude was told instead, and options that just close it.
348const GUARD_TIMED_OUT = { plugin: 'dev-hooks', key: 'guardTimedOut' } as const
349const TIMED_OUT_KEPT = 20
350
351export const timedOutNote = (command: string, seconds: number, toldClaude: string): string => {
352 const shown = command.length > COMMAND_SHOWN ? `${command.slice(0, COMMAND_SHOWN)}…` : command
353 return (
354 `⏱ This guard question timed out after ${waitLabel(seconds)} and no longer does anything.` +
355 `\n\nIt asked about: ${shown}\n\nClaude was told: ${toldClaude}\n\nEither option just closes this.`
356 )
357}
358
359// The dialog outlives its timeout (VS Code doesn't draw it through the render hook above), and
360// the person may still answer it. That late answer reaches Claude as a prompt; a late Allow
361// also lets that exact command through once, for a while, without asking again.
362const LATE_ALLOW_MS = 10 * 60_000
363const lateAllowed = new Map<string, number>()
364
365export const lateAnswerNote = (command: string, answer: string): string => {
366 const head = `[dev-hooks guard] The person answered the guard question about \`${command}\` after it had timed out:`
367 if (answer === 'Allow')
368 return `${head} Allow. If you still want it, run that exact command again — it will go through once without asking (for the next 10 minutes).`
369 if (answer === 'Deny') return `${head} Deny. Don't run it.`
370 return `${head} "${answer}"`
371}
372
373// From session.start: whether a person is there to answer the dialog.
374let isInteractive = false
375
376async function guardTimeoutSeconds($: EngineInterface, command: string): Promise<number> {
377 const own = commandTimeout(command)
378 if (own !== undefined) return own
379 const raw = await $.env.get('DEV_HOOKS_GUARD_DIALOG_TIMEOUT').catch(() => undefined)
380 const seconds = Number(raw)
381 return raw !== undefined && raw.trim() !== '' && Number.isFinite(seconds) && seconds >= 0
382 ? seconds
383 : DEFAULT_GUARD_TIMEOUT_S
384}
385
386// ── Stop-nudge outcomes ─────────────────────────────────────────────────────────
387// Each reason the orchestration returns is a nudge in this session's facts; the next
388// Stop resolves the pending ones as acted/ignored/declined/unknown (nudge-outcomes.ts
389// holds the remedy per hook), and the resolved ones go to a cross-session log in the
390// store (`nudgeOutcomes`), exported for the weekly automation review.
391
392type StopInput = { session_id: string; cwd: string; last_assistant_message?: string }
393
394// A session idle this long with a nudge still pending never reached another Stop or a
395// clean session end (killed, crashed): resolve it from its facts at the next start.
396const NUDGE_SWEEP_IDLE_MS = 6 * 3_600_000
397
398async function trackNudges($: EngineInterface, e: StopInput, fired: { hook: string; reason: string }[]) {
399 const now = Date.now()
400 let resolved: Nudge[] = []
401 await changeFacts($, f => {
402 const ev = { facts: f, refired: fired.map(x => x.hook), lastMessage: e.last_assistant_message, atStop: true }
403 const r = resolvePending(f, ev, now)
404 resolved = r.resolved
405 if (fired.length === 0) return r.facts
406 const added = fired.map(x => newNudge(x.hook, x.reason, now))
407 return { ...r.facts, nudges: [...(r.facts.nudges ?? []), ...added].slice(-100) }
408 })
409 await logNudges($, resolved.map(n => toRecord(e.session_id, n)))
410}
411
412// The session is ending: whatever is still pending gets no further Stop.
413async function closeNudges($: EngineInterface, sessionId: string) {
414 if (!((await loadFacts($, sessionId)).nudges ?? []).some(n => n.outcome === undefined)) return
415 let resolved: Nudge[] = []
416 await changeFacts(
417 $,
418 f => {
419 const r = resolvePending(f, { facts: f, refired: [], atStop: false }, Date.now())
420 resolved = r.resolved
421 return r.facts
422 },
423 sessionId,
424 )
425 await logNudges($, resolved.map(n => toRecord(sessionId, n)))
426}
427
428// At session start: resolve abandoned sessions' pending nudges, and re-add every resolved
429// nudge the kept facts hold, repairing a log write another process raced.
430async function sweepNudges($: EngineInterface, kept: Map<string, Facts>, current: string) {
431 const now = Date.now()
432 const records: NudgeRecord[] = []
433 for (const [id, f] of kept) {
434 let facts = f
435 const pending = (f.nudges ?? []).some(n => n.outcome === undefined)
436 if (id !== current && pending && f.updatedAt < now - NUDGE_SWEEP_IDLE_MS) {
437 facts = resolvePending(f, { facts: f, refired: [], atStop: false }, now).facts
438 await $.store.set(`facts:${id}`, facts)
439 }
440 for (const n of facts.nudges ?? []) if (n.outcome !== undefined) records.push(toRecord(id, n))
441 }
442 await logNudges($, records)
443}
444
445async function loadNudgeLog($: EngineInterface): Promise<NudgeRecord[]> {
446 return ((await $.store.get(NUDGE_LOG_KEY)) as NudgeRecord[] | undefined) ?? []
447}
448
449async function logNudges($: EngineInterface, records: NudgeRecord[]) {
450 if (records.length === 0) return
451 const now = Date.now()
452 const log = mergeNudgeLog(await loadNudgeLog($), records, now)
453 await $.store.set(NUDGE_LOG_KEY, log)
454 // Beside the prompt log and hook-fires.jsonl; only where that directory already exists.
455 const dir = `${(await $.env.get('HOME')) ?? ''}/.claude/automation-review`
456 if (await $.fs.exists(dir)) await $.fs.write(`${dir}/stop-nudges.json`, `${JSON.stringify(nudgeExport(log, now))}\n`)
457}
458
459// ── CI watch ────────────────────────────────────────────────────────────────────────
460// After a successful `git push` the module watches that push's GitHub Actions runs:
461// progress in the status line, a toast when they end, and the outcome handed to Claude
462// (a pass as a conversation note, a failure as a prompt that wakes the session). It
463// marks the session with DEV_HOOKS_CI_WATCH_SESSION so ci-watch-reminder.sh stands down;
464// DEV_HOOKS_CI_WATCH=false turns both off. Watches live in module variables: a reload
465// of the module drops them.
466const CI_FIRST_POLL_MS = 3_000
467const CI_APPEAR_MS = 120_000
468const CI_RETRY_MS = 5_000
469const CI_POLL_MS = 15_000
470const CI_GIVE_UP_MS = 90 * 60_000
471
472const ciWatches = new Map<string, Watch & { startedAt: number }>()
473
474async function ciOptedOut($: EngineInterface): Promise<boolean> {
475 return (await $.env.get('DEV_HOOKS_CI_WATCH')) === 'false'
476}
477
478async function claimCiWatch($: EngineInterface) {
479 if (!(await ciOptedOut($))) await $.env.set('DEV_HOOKS_CI_WATCH_SESSION', await $.session.id())
480}
481
482function showCiStatus($: EngineInterface) {
483 $.ui.status(ciStatusLine([...ciWatches.values()]))
484}
485
486async function git($: EngineInterface, cwd: string, ...args: string[]): Promise<string | null> {
487 const run = await $.process.run(['git', '-C', cwd, ...args]).catch(() => null)
488 return run?.exitCode === 0 ? run.stdout.trim() : null
489}
490
491// The commits a push sent to GitHub, from its output; a quiet push (`-q`) prints no
492// ref lines, so it falls back to HEAD and the branch's default remote.
493async function pushTargets($: EngineInterface, command: string, output: string): Promise<{ repo: string; sha: string }[]> {
494 const root = await git($, pushDir(command, await $.session.cwd()), 'rev-parse', '--show-toplevel')
495 if (root === null) return []
496 const workflows = await $.fs.list(`${root}/.github/workflows`).catch(() => [])
497 if (!workflows.some(w => /\.ya?ml$/.test(w.name))) return []
498
499 const pushed = parsePushOutput(output)
500 if (pushed.refs.length === 0 && /Everything up-to-date/.test(output)) return []
501 const repo = githubRepo(pushed.remote ?? (await git($, root, 'ls-remote', '--get-url')) ?? '')
502 if (repo === null) return []
503 const refs = pushed.refs.length > 0 ? pushed.refs.map(r => r.sha ?? r.src) : ['HEAD']
504 const shas = await Promise.all(refs.map(ref => git($, root, 'rev-parse', `${ref}^{commit}`)))
505 return [...new Set(shas.filter((s): s is string => s !== null))].map(sha => ({ repo, sha }))
506}
507
508async function startCiWatch($: EngineInterface, target: { repo: string; sha: string }) {
509 const key = `${target.repo}@${target.sha}`
510 if (ciWatches.has(key)) return
511 ciWatches.set(key, { ...target, done: 0, total: 0, startedAt: await $.clock.now() })
512 showCiStatus($)
513 $.clock.after(CI_FIRST_POLL_MS, () => void pollCi($, key))
514}
515
516async function pollCi($: EngineInterface, key: string) {
517 const w = ciWatches.get(key)
518 if (!w) return
519 try {
520 let runs: Run[] = []
521 let error: string | undefined
522 try {
523 const argv = ['gh', 'run', 'list', '--repo', w.repo, '--commit', w.sha, '--json', RUN_FIELDS, '--limit', '50']
524 const run = await $.process.run(argv, { timeoutMs: 30_000 })
525 if (run.exitCode === 0) runs = parseRuns(run.stdout)
526 else error = run.stderr.trim().split('\n')[0] || `gh exited ${run.exitCode}`
527 } catch (err) {
528 error = String(err)
529 }
530 const { verdict, runs: mine } = ciVerdict(runs, w.sha)
531 const elapsed = (await $.clock.now()) - w.startedAt
532 if (verdict === 'pending' ? elapsed < CI_GIVE_UP_MS : verdict === 'none' && elapsed < CI_APPEAR_MS) {
533 w.done = mine.filter(r => r.status === 'completed').length
534 w.total = mine.length
535 showCiStatus($)
536 $.clock.after(verdict === 'none' ? CI_RETRY_MS : CI_POLL_MS, () => void pollCi($, key))
537 return
538 }
539
540 ciWatches.delete(key)
541 showCiStatus($)
542 $.ui.toast(ciToast(w.repo, w.sha, verdict, mine), { timeoutMs: verdict === 'success' ? 8_000 : 20_000 })
543 const note = ciNote(w.repo, w.sha, verdict, mine, error)
544 // A failure needs Claude to act, so it starts a turn (once the session is idle);
545 // anything else is read with Claude's next request without waking it.
546 if (verdict === 'failure') await $.prompt.submit({ text: note })
547 else await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: note }] } })
548 } catch (err) {
549 ciWatches.delete(key)
550 showCiStatus($)
551 $.ui.log(`dev-hooks: CI watch for ${key} failed: ${String(err)}`)
552 }
553}
554// ── end CI watch ────────────────────────────────────────────────────────────────────
555
556async function guardNow($: EngineInterface): Promise<number> {
557 return $.clock.now().catch(() => Date.now())
558}
559
560async function lateGuardAnswer($: EngineInterface, command: string, answer: string) {
561 if (answer === 'Allow') lateAllowed.set(command, (await guardNow($)) + LATE_ALLOW_MS)
562 await $.prompt.submit({ text: lateAnswerNote(command, answer) }).catch(() => undefined)
563}
564
565export const register: Register = on => {
566 on('session.start', async ($, e, next) => {
567 isInteractive = e.isInteractive
568 await $.command.register({
569 name: 'context-bar',
570 description: 'Toggle a stacked context-usage bar above the prompt',
571 })
572 await $.command.register({
573 name: 'session-facts',
574 description: 'Show what dev-hooks recorded about this session (nudges: Stop-nudge outcomes)',
575 })
576 const kept = await pruneFacts($)
577 await sweepNudges($, kept, await $.session.id()).catch(() => undefined)
578 if ((await $.state.get(isShown)).value) await refresh($)
579 // Only take the Stop hooks over once their config reads cleanly, and not on a
580 // version whose orchestration already failed; otherwise leave the session unmarked
581 // so the command hooks keep running directly.
582 const failedOn = await $.store.get(ORCHESTRATION_FAILED)
583 if ((await ownStopHooks($)).length > 0 && failedOn !== (await pluginVersion($))) {
584 await $.env.set('DEV_HOOKS_MOD_SESSION', await $.session.id())
585 }
586 await claimCiWatch($)
587
588 return next(e)
589 })
590
591 on('command.run', { command: 'context-bar' }, async $ => {
592 const shown = !(await $.state.get(isShown)).value
593 await $.state.set(isShown, shown)
594 if (shown) await refresh($)
595
596 return { text: shown ? 'Context bar on.' : 'Context bar off.' }
597 })
598
599 on('session.measure', async ($, e, next) => {
600 if ((await $.state.get(isShown)).value) await refresh($)
601
602 return next(e)
603 })
604
605 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
606 if (e.props.hasSurvey || !(await $.state.get(isShown)).value) return next(e)
607 const snap = (await $.state.get(snapshot)).value
608 const { Box, Text } = $.ui.resolve(e)
609
610 if (!snap) {
611 return (
612 <Box>
613 <Text dimColor>Context bar: waiting for the first measurement…</Text>
614 </Box>
615 )
616 }
617
618 const summary = ` ${snap.percentage}% · ${formatTokens(snap.totalTokens)}/${formatTokens(snap.maxTokens)}`
619 const width = Math.max(10, e.props.bodyColumns - summary.length - 1)
620 const cells = allocate(snap.segments, width)
621 const glyph = (s: Segment) => (s.kind === 'free' ? '░' : s.kind === 'buffer' ? '▒' : '█')
622
623 return (
624 <Box flexDirection="column">
625 <Box>
626 <Text>
627 {snap.segments.map((s, i) => (
628 <Text color={s.color} dimColor={s.kind === 'free'}>
629 {glyph(s).repeat(cells[i] ?? 0)}
630 </Text>
631 ))}
632 </Text>
633 <Text dimColor>{summary}</Text>
634 </Box>
635 <Box flexWrap="wrap">
636 {snap.segments.map(s => (
637 <Text>
638 <Text color={s.color}>{glyph(s)} </Text>
639 <Text dimColor>
640 {s.name} {formatTokens(s.tokens)}
641 {' '}
642 </Text>
643 </Text>
644 ))}
645 </Box>
646 </Box>
647 )
648 })
649
650 on('command.run', { command: 'session-facts' }, async ($, e) => {
651 const [sub, arg] = e.args.trim().split(/\s+/)
652 if (sub === 'nudges') {
653 const days = Number(arg) > 0 ? Number(arg) : 7
654 return { text: formatNudgeStats(await loadNudgeLog($), Date.now(), days) }
655 }
656 const f = await loadFacts($)
657 if (sub === 'json') return { text: JSON.stringify(f, null, 2) }
658
659 return { text: formatFacts(f, Date.now(), (await $.env.get('HOME')) ?? '') }
660 })
661
662 on('skill.prompt', async ($, e, next) => {
663 const at = Date.now()
664 await changeFacts($, f => ({ ...f, skills: [...f.skills, { skill: e.skill, at }].slice(-200) }))
665
666 return next(e)
667 })
668
669 on('agent.spawn', async ($, e, next) => {
670 const agent = { description: e.description, subagentType: e.subagentType, at: Date.now() }
671 await changeFacts($, f => ({ ...f, agents: [...f.agents, agent].slice(-200) }))
672
673 return next(e)
674 })
675
676 on('tool.call', async ($, e, next) => {
677 const ran = await next(e)
678 if (ran.deny !== undefined || ran.isError || (e.tool !== 'Edit' && e.tool !== 'Write')) return ran
679
680 const result = ran.result as { structuredPatch?: Patch; type?: string } | undefined
681 const created = e.tool === 'Write' && result?.type === 'create' ? e.content : undefined
682 const added = netAdded(result?.structuredPatch, created)
683 const root = await repoRoot($, e.file_path)
684 const bySubagent = e.agentId === undefined ? 0 : 1
685
686 await changeFacts($, f => {
687 const prev = f.repos.find(r => r.root === root) ?? { root, files: [], added: 0, bySubagent: 0 }
688 const repo = {
689 root,
690 files: prev.files.includes(e.file_path) ? prev.files : [...prev.files, e.file_path],
691 added: prev.added + added,
692 bySubagent: prev.bySubagent + bySubagent,
693 }
694 const lastEdit = { ...f.lastEdit, [e.file_path]: Date.now() }
695 return { ...f, repos: [...f.repos.filter(r => r.root !== root), repo], lastEdit }
696 })
697
698 return ran
699 })
700
701 on('session.end', async ($, e, next) => {
702 await closeNudges($, e.sessionId).catch(() => undefined)
703
704 return next(e)
705 })
706
707 // CI watch: a push that went through starts a watch on its runs.
708 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
709 const ran = await next(e)
710 if (ran.deny !== undefined || ran.isError || e.tool !== 'Bash') return ran
711 if (!isGitPush(e.command) || isDryRun(e.command) || (await ciOptedOut($))) return ran
712
713 const targets = await pushTargets($, e.command, ran.text ?? '')
714 if (targets.length === 0) return ran
715 for (const target of targets) await startCiWatch($, target)
716
717 return { ...ran, context: [...(ran.context ?? []), watchingNote(targets)] }
718 })
719
720 // Sees the command hooks' Stop verdict folded last-write-wins: with several
721 // blocking, only the last reason arrives here (Claude still gets them all).
722 on('classic.Stop', async ($, e, next) => {
723 const below = await next(e)
724 if ((await $.env.get('DEV_HOOKS_MOD_SESSION')) !== e.session_id) return below
725
726 try {
727 const block = await orchestrateStop($, e, below.block)
728 return block === undefined ? below : { ...below, block }
729 } catch (error) {
730 // The command hooks stood down for this session, so a failure here would
731 // silence every later Stop too: hand Stop back to them from the next turn.
732 await $.env.set('DEV_HOOKS_MOD_SESSION', undefined)
733 await $.store.set(ORCHESTRATION_FAILED, await pluginVersion($).catch(() => 'unknown')).catch(() => undefined)
734 $.ui.log(`dev-hooks: Stop orchestration failed, the Stop hooks run directly from now on: ${String(error)}`)
735 return below
736 }
737 })
738
739 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
740 const first = e.props.questions[0] as { question?: unknown } | undefined
741 if (!first || typeof first.question !== 'string') return next(e)
742 // Read while drawing, so a timeout recorded later redraws this dialog.
743 const note = ((await $.state.get(GUARD_TIMED_OUT)).value ?? {})[first.question]
744 if (!note) return next(e)
745 const closes = { description: 'Closes this note; it changes nothing.' }
746 return next({
747 ...e,
748 props: {
749 ...e.props,
750 questions: [{ ...first, question: note, multiSelect: false, options: [{ label: 'Close', ...closes }, { label: 'OK', ...closes }] }],
751 },
752 })
753 })
754
755 on('classic.PreToolUse', async ($, e, next) => {
756 const decided = await next(e)
757 if (e.tool !== 'Bash' || !isGuardAsk(decided.ask)) return decided
758 const allowedUntil = lateAllowed.get(e.command)
759 if (allowedUntil !== undefined) {
760 lateAllowed.delete(e.command)
761 if (allowedUntil > (await guardNow($))) {
762 const { ask: _answeredLate, ...rest } = decided
763 return rest
764 }
765 }
766 const seconds = await guardTimeoutSeconds($, e.command)
767 const question = guardQuestion(decided.ask, e.command, seconds)
768 const asked = $.ui.ask(question, {
769 header: 'dev-hooks',
770 options: ['Allow', 'Deny'],
771 })
772 let answer: string
773 try {
774 const never = new Promise<never>(() => {})
775 // A timer that can't run means no timeout, never a decision.
776 const timedOut = seconds > 0 ? $.clock.sleep(seconds * 1000).then(() => undefined, () => never) : never
777 const first = await Promise.race([asked, timedOut])
778 if (first === undefined) {
779 asked.then(
780 answer => lateGuardAnswer($, e.command, answer),
781 () => undefined,
782 )
783 const reason = guardTimeoutReason(decided.ask, seconds)
784 const notes = (await $.state.get(GUARD_TIMED_OUT)).value ?? {}
785 const kept = Object.entries(notes).slice(-(TIMED_OUT_KEPT - 1))
786 await $.state.set(GUARD_TIMED_OUT, { ...Object.fromEntries(kept), [question]: timedOutNote(e.command, seconds, reason) })
787 return { deny: reason }
788 }
789 answer = first
790 } catch {
791 // Dismissed: refuse, since handing the question back would let auto mode answer
792 // it. With no one to ask at all (claude -p) the guard's question stands as it was.
793 return isInteractive
794 ? { deny: 'The dev-hooks guard dialog was dismissed, so this command did not run.' }
795 : decided
796 }
797 if (answer === 'Allow') {
798 const { ask: _withdrawn, ...rest } = decided
799 return rest
800 }
801 return { deny: `The person declined this command in the dev-hooks guard dialog (answer: ${answer}).` }
802 })
803}
804hooks/ci-watch.ts 164 lines1// CI watch: the pure half. register.tsx detects a push, resolves what was pushed and
2// polls GitHub with these; nothing here touches `$`, so tests import it directly.
3
4// ci-watch-reminder.sh's rule, kept line-local: `git` (env-prefixed or flagged) walking
5// tokens to a `push` subcommand. `git push`, `git -C dir push`, `… && git push`; not
6// `git config …pushurl`, `git switch`, `git pushall`.
7const GIT_PUSH = /(^|[^A-Za-z0-9_])git([ \t]+\S+)*[ \t]+push([^A-Za-z0-9_]|$)/m
8
9export const isGitPush = (command: string): boolean => GIT_PUSH.test(command)
10
11// A dry run prints the same ref lines but triggers no run, so a watch would only
12// find the last real push's run.
13export const isDryRun = (command: string): boolean =>
14 /(^|[^A-Za-z0-9_])git([ \t]+\S+)*[ \t]+push([ \t]+[^\s;&|]+)*[ \t]+(--dry-run|-n)([ \t;&|]|$)/m.test(command)
15
16const unquote = (s: string) => s.replace(/^(['"])(.*)\1$/, '$2')
17
18const resolveDir = (dir: string, cwd: string) => {
19 const d = unquote(dir)
20 return d.startsWith('/') ? d : `${cwd.replace(/\/$/, '')}/${d}`
21}
22
23// The directory the push ran in: `git -C <dir> push`, a `cd <dir> &&` before it, or cwd.
24export const pushDir = (command: string, cwd: string): string => {
25 const flag = /(^|[^A-Za-z0-9_])git[ \t]+(?:\S+[ \t]+)*?-C[ \t]+("[^"]*"|'[^']*'|\S+)(?:[ \t]+\S+)*?[ \t]+push/m.exec(command)
26 if (flag?.[2]) return resolveDir(flag[2], cwd)
27 const cd = /(^|[;&|][ \t]*)cd[ \t]+("[^"]*"|'[^']*'|\S+)[ \t]*(&&|;)[^\n]*git([ \t]+\S+)*[ \t]+push/m.exec(command)
28 if (cd?.[2]) return resolveDir(cd[2], cwd)
29 return cwd
30}
31
32export type PushedRef = { src: string; dst: string; sha?: string }
33export type PushOutput = { remote?: string; refs: PushedRef[] }
34
35// git push's human output (stderr, which the Bash tool's result carries):
36// To github.com:owner/repo.git
37// 762caa3..abc1234 main -> main
38// + 1111111...2222222 main -> main (forced update)
39// * [new branch] feat -> feat
40// A deletion (` - [deleted]`) and a rejection (` ! [rejected]`) start no run.
41export const parsePushOutput = (text: string): PushOutput => {
42 const remote = /^To (\S+)/m.exec(text)?.[1]
43 const refs: PushedRef[] = []
44 for (const line of text.split('\n')) {
45 const updated = /^\s*[+ ]?\s*[0-9a-f]{7,40}\.\.\.?([0-9a-f]{7,40})\s+(\S+)\s+->\s+(\S+)/.exec(line)
46 if (updated?.[1] && updated[2] && updated[3]) {
47 refs.push({ sha: updated[1], src: updated[2], dst: updated[3] })
48 continue
49 }
50 const created = /^\s*\*\s+\[new (?:branch|tag|reference)\]\s+(\S+)\s+->\s+(\S+)/.exec(line)
51 if (created?.[1] && created[2]) refs.push({ src: created[1], dst: created[2] })
52 }
53 return { remote, refs }
54}
55
56// owner/repo of a GitHub remote URL in any spelling git accepts (scp-like, ssh://,
57// https:// with or without credentials); null for any other host.
58export const githubRepo = (url: string): string | null => {
59 const m = /(?:^|[@/])github\.com[:/]+([^/\s:]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(url.trim())
60 return m?.[1] && m[2] ? `${m[1]}/${m[2]}` : null
61}
62
63export type Run = {
64 databaseId: number
65 workflowName: string
66 status: string
67 conclusion: string
68 headSha: string
69 url: string
70 event: string
71}
72
73export const RUN_FIELDS = 'databaseId,workflowName,status,conclusion,headSha,url,event'
74
75export const parseRuns = (stdout: string): Run[] => {
76 try {
77 const rows = JSON.parse(stdout) as unknown
78 return Array.isArray(rows) ? (rows as Run[]) : []
79 } catch {
80 return []
81 }
82}
83
84export type Verdict = 'none' | 'pending' | 'success' | 'failure' | 'cancelled'
85
86const FAILED = new Set(['failure', 'timed_out', 'startup_failure', 'action_required'])
87
88// Runs are selected by head sha, never by recency: the newest run in the list can
89// belong to an older push still running.
90export const ciVerdict = (runs: Run[], sha: string): { verdict: Verdict; runs: Run[] } => {
91 const mine = runs.filter(r => r.headSha === sha)
92 if (mine.length === 0) return { verdict: 'none', runs: mine }
93 if (mine.some(r => r.status !== 'completed')) return { verdict: 'pending', runs: mine }
94 if (mine.some(r => FAILED.has(r.conclusion))) return { verdict: 'failure', runs: mine }
95 if (mine.some(r => r.conclusion === 'cancelled')) return { verdict: 'cancelled', runs: mine }
96 return { verdict: 'success', runs: mine }
97}
98
99export type Watch = { repo: string; sha: string; done: number; total: number }
100
101const short = (sha: string) => sha.slice(0, 7)
102const name = (repo: string) => repo.slice(repo.indexOf('/') + 1)
103
104export const ciStatusLine = (watches: Watch[]): string | undefined => {
105 if (watches.length === 0) return undefined
106 const parts = watches.map(w =>
107 w.total === 0
108 ? `${name(w.repo)}@${short(w.sha)} waiting for its run`
109 : `${name(w.repo)}@${short(w.sha)} ${w.done}/${w.total} runs done`,
110 )
111 return `CI: ${parts.join(' · ')}`
112}
113
114const runLine = (r: Run) => `- ${r.workflowName}: ${r.conclusion || r.status} (${r.event}) ${r.url}`
115
116export const ciToast = (repo: string, sha: string, verdict: Verdict, runs: Run[]): string => {
117 const where = `${name(repo)}@${short(sha)}`
118 const bad = runs.filter(r => FAILED.has(r.conclusion)).map(r => r.workflowName)
119 switch (verdict) {
120 case 'success':
121 return `✓ CI passed · ${where}`
122 case 'failure':
123 return `✗ CI failed · ${where}: ${bad.join(', ')}`
124 case 'cancelled':
125 return `◌ CI cancelled · ${where}`
126 case 'none':
127 return `? No CI run appeared · ${where}`
128 default:
129 return `… CI still running · ${where}`
130 }
131}
132
133// What the model reads. A failure arrives as a prompt of its own, so it says what to do.
134export const ciNote = (repo: string, sha: string, verdict: Verdict, runs: Run[], error?: string): string => {
135 const head = `[dev-hooks CI watch] ${repo}@${short(sha)}`
136 const lines = runs.map(runLine)
137 switch (verdict) {
138 case 'success':
139 return [`${head}: CI passed.`, ...lines].join('\n')
140 case 'failure': {
141 const failed = runs.find(r => FAILED.has(r.conclusion))
142 return [
143 `${head}: CI FAILED on the commit you pushed.`,
144 ...lines,
145 `Find the cause (\`gh run view ${failed?.databaseId ?? '<id>'} --repo ${repo} --log-failed\`), fix it, and push the fix.`,
146 ].join('\n')
147 }
148 case 'cancelled':
149 return [`${head}: CI was cancelled (often a newer push superseded it).`, ...lines].join('\n')
150 case 'none':
151 return error
152 ? `${head}: couldn't read its GitHub Actions runs (${error}). Watch it yourself: \`gh run list --repo ${repo} --commit ${sha}\`.`
153 : `${head}: no GitHub Actions run appeared for this commit; its workflows may not trigger on this ref. Check \`gh run list --repo ${repo}\` if you expected one.`
154 default:
155 return [`${head}: CI is still running; the watch gave up waiting.`, ...lines].join('\n')
156 }
157}
158
159// Read with the push's own tool result, so Claude doesn't start a watch of its own.
160export const watchingNote = (targets: { repo: string; sha: string }[]): string =>
161 `[dev-hooks CI watch] Watching the GitHub Actions run(s) for ${targets
162 .map(t => `${t.repo}@${short(t.sha)}`)
163 .join(', ')} in the background. Don't run \`gh run watch\` or poll for it: a failure comes back to you as a new message, and a pass is noted in the conversation.`
164hooks/nudge-outcomes.ts 225 lines1// Stop-nudge outcomes: did Claude act on a Stop hook's reason? Pure functions only —
2// register.tsx owns every `$` call and passes the facts in.
3//
4// A nudge fires when the Stop orchestration returns a hook's reason, and is resolved at
5// the next Stop (the forced continuation the block caused, normally) or at session end,
6// from what the session facts show happened after it fired.
7import type { Facts, Nudge, NudgeCounts, NudgeOutcome, NudgeRecord } from '../types'
8
9// The hook a Stop command runs, by its script name: `bash ".../review-reminder.sh"` →
10// `review-reminder`. Names hooks without relying on each reason's tag.
11export const hookName = (command: string): string =>
12 command.match(/([\w.-]+)\.sh\b/g)?.at(-1)?.replace(/\.sh$/, '') ?? command
13
14// The files a reason lists, one per " path" or " path:line: text" line.
15const reasonFiles = (reason: string): string[] =>
16 reason
17 .split('\n')
18 .filter(l => /^ {2}\S/.test(l) && !l.startsWith(' ... and'))
19 .map(l => l.trim().replace(/:\d+:.*$/, ''))
20
21const LISTS_FILES = new Set(['debug-leftover-reminder', 'missing-test-reminder'])
22
23export const newNudge = (hook: string, reason: string, at: number): Nudge => {
24 const files = LISTS_FILES.has(hook) ? reasonFiles(reason) : []
25 return {
26 hook,
27 at,
28 summary: (reason.split('\n')[0] ?? '').slice(0, 120),
29 ...(files.length > 0 ? { files } : {}),
30 }
31}
32
33// Mirrors review-reminder.sh's reminder_transcript_invoked needles (tests/test_nudge_parity.py).
34export const REVIEW_NEEDLES = ['code-review', 'code_review', 'requesting-code-review', 'code-reviewer']
35// Mirrors hook_helpers.MEMORY_DIR_RE.
36export const MEMORY_DIR_RE = /\/\.claude\/(?:projects\/[^/]+\/)?memory\//
37
38// Mirrors hook_helpers._AGENT_DOER_RE and _agent_job_re: "Review task 8" is a review,
39// "Add product review form" is not.
40const AGENT_DOER_RE = /^\s*(?:fix|fixes|implement|build|add|create|write|update|refactor|address|apply|predict|simulate)\b/i
41const agentJobRe = (w: string) =>
42 new RegExp(
43 `^\\s*(?:re-?)?${w}(?:ing)?\\b` +
44 `|\\bre-?${w}\\b` +
45 `|^\\s*(?:code|final|whole-branch|final whole-branch)[- ]${w}\\b(?!\\s+fix)` +
46 `|\\bwhole-branch ${w}\\b` +
47 `|\\b${w}(?:ing)?(?=\\s*(?:$|[:,;(—–-]|\\s+(?:of|for|round|pass|loop)\\b))`,
48 'i',
49 )
50
51// A skill or subagent run after `at` that names one of `names`, or a subagent described
52// as doing `job`; its description for the evidence, or undefined.
53const invokedSince = (f: Facts, at: number, names: string[], job?: string): string | undefined => {
54 const named = (s: string) => names.some(n => s.includes(n))
55 const skill = f.skills.find(s => s.at > at && named(s.skill))
56 if (skill) return `skill ${skill.skill}`
57 const jobRe = job ? agentJobRe(job) : undefined
58 const agent = f.agents.find(
59 a =>
60 a.at > at &&
61 (named(a.subagentType) || (jobRe !== undefined && !AGENT_DOER_RE.test(a.description) && jobRe.test(a.description))),
62 )
63 return agent ? `agent ${agent.subagentType}: ${agent.description}`.slice(0, 100) : undefined
64}
65
66const editedSince = (f: Facts, at: number, match: (file: string) => boolean): string | undefined =>
67 Object.entries(f.lastEdit ?? {}).find(([file, t]) => t > at && match(file))?.[0]
68
69const base = (path: string) => path.slice(path.lastIndexOf('/') + 1)
70const stem = (path: string) => base(path).replace(/\.[^.]*$/, '')
71const isTestPath = (path: string) =>
72 /(^|\/)(tests?|spec|__tests__)\//.test(path) || /(^test_|_test\.|\.test\.|_spec\.|\.spec\.)/.test(base(path))
73
74export type NudgeEvidence = {
75 facts: Facts
76 // Hooks whose reason the resolving Stop returned again.
77 refired: string[]
78 // The resolving Stop's last assistant message; undefined when resolved at session end.
79 lastMessage?: string
80 // false when resolved at session end or a later sweep: no Stop ran after the nudge.
81 atStop: boolean
82}
83
84type Resolution = { outcome: NudgeOutcome; evidence: string }
85
86// What counts as acting on each hook's reason. A hook without a remedy the facts can show
87// is "unknown", never guessed. The facts see Edit/Write only, so a remedy done through
88// Bash (a test file made by a generator, a memory file written with cat) reads as ignored.
89export const resolveNudge = (n: Nudge, ev: NudgeEvidence): Resolution => {
90 const acted = (evidence: string): Resolution => ({ outcome: 'acted', evidence })
91 const missed = (what: string): Resolution =>
92 ev.atStop
93 ? { outcome: 'ignored', evidence: `no ${what} before the next Stop` }
94 : { outcome: 'unknown', evidence: `session ended before the next Stop; no ${what}` }
95
96 switch (n.hook) {
97 case 'review-reminder': {
98 const hit = invokedSince(ev.facts, n.at, REVIEW_NEEDLES, 'review')
99 return hit ? acted(hit) : missed('review skill or review agent')
100 }
101 case 'compress-comments-reminder': {
102 const hit = invokedSince(ev.facts, n.at, ['compress-comments'])
103 return hit ? acted(hit) : missed('compress-comments run')
104 }
105 case 'memory-reminder': {
106 const file = editedSince(ev.facts, n.at, f => MEMORY_DIR_RE.test(f))
107 if (file) return acted(`wrote memory ${base(file)}`)
108 // The reason's own escape hatch: "Say 'nothing worth saving' in one line and stop."
109 if (/nothing worth saving/i.test(ev.lastMessage ?? '')) {
110 return { outcome: 'declined', evidence: "said 'nothing worth saving'" }
111 }
112 return missed('memory write')
113 }
114 case 'missing-test-reminder': {
115 const stems = (n.files ?? []).map(stem)
116 const file = editedSince(ev.facts, n.at, f => isTestPath(f) && stems.some(s => base(f).includes(s)))
117 return file ? acted(`wrote test ${base(file)}`) : missed('test written for a listed file')
118 }
119 case 'debug-leftover-reminder': {
120 const files = n.files ?? []
121 const file = editedSince(ev.facts, n.at, f => files.some(l => f === l || f.endsWith(`/${l}`)))
122 return file ? acted(`edited ${base(file)}`) : missed('edit to a listed file')
123 }
124 case 'verify-work': {
125 // Only a failure can be re-checked: verify-work runs again at the continuation's
126 // Stop and blocks again while the failure stands.
127 if (!n.summary.startsWith('Verification failed')) return { outcome: 'unknown', evidence: 'advisory, nothing to re-check' }
128 if (!ev.atStop) return { outcome: 'unknown', evidence: 'session ended before verify-work ran again' }
129 return ev.refired.includes('verify-work')
130 ? { outcome: 'ignored', evidence: 'still failing at the next Stop' }
131 : acted('passed at the next Stop')
132 }
133 default:
134 return { outcome: 'unknown', evidence: 'no remedy the session facts can show' }
135 }
136}
137
138// Resolves every nudge fired before `now` that is still pending.
139export const resolvePending = (f: Facts, ev: NudgeEvidence, now: number): { facts: Facts; resolved: Nudge[] } => {
140 const resolved: Nudge[] = []
141 const nudges = (f.nudges ?? []).map(n => {
142 if (n.outcome !== undefined || n.at >= now) return n
143 const r = { ...n, ...resolveNudge(n, ev), resolvedAt: now }
144 resolved.push(r)
145 return r
146 })
147 return { facts: resolved.length > 0 ? { ...f, nudges } : f, resolved }
148}
149
150export const NUDGE_LOG_KEY = 'nudgeOutcomes'
151const LOG_DAYS = 90
152const LOG_MAX = 3000
153const DAY_MS = 86_400_000
154
155export const toRecord = (session: string, n: Nudge): NudgeRecord => ({
156 hook: n.hook,
157 session,
158 at: n.at,
159 resolvedAt: n.resolvedAt ?? n.at,
160 outcome: n.outcome ?? 'unknown',
161 evidence: n.evidence ?? '',
162})
163
164// Union by (session, hook, at), so re-adding a record is harmless; keeps the last
165// LOG_DAYS days and at most LOG_MAX records, which bounds the store key.
166export const mergeNudgeLog = (log: NudgeRecord[] | undefined, add: NudgeRecord[], now: number): NudgeRecord[] => {
167 const byId = new Map<string, NudgeRecord>()
168 for (const r of [...(log ?? []), ...add]) byId.set(`${r.session}|${r.hook}|${r.at}`, r)
169 return [...byId.values()]
170 .filter(r => r.at >= now - LOG_DAYS * DAY_MS)
171 .sort((a, b) => a.at - b.at)
172 .slice(-LOG_MAX)
173}
174
175export type NudgeSummary = { hook: string } & NudgeCounts
176
177// Per hook, the outcomes of nudges fired since `since`, most-fired first.
178export const summarizeNudges = (log: NudgeRecord[], since: number): NudgeSummary[] => {
179 const byHook = new Map<string, NudgeSummary>()
180 for (const r of log) {
181 if (r.at < since) continue
182 const s = byHook.get(r.hook) ?? { hook: r.hook, fired: 0, acted: 0, ignored: 0, declined: 0, unknown: 0 }
183 s.fired++
184 s[r.outcome]++
185 byHook.set(r.hook, s)
186 }
187 return [...byHook.values()].sort((a, b) => b.fired - a.fired || a.hook.localeCompare(b.hook))
188}
189
190// Acted-on rate over the nudges whose outcome is known; null when none is.
191export const actedRate = (s: NudgeCounts): number | null => {
192 const known = s.acted + s.ignored + s.declined
193 return known === 0 ? null : s.acted / known
194}
195
196export const formatNudgeStats = (log: NudgeRecord[], now: number, days = 7): string => {
197 const rows = summarizeNudges(log, now - days * DAY_MS)
198 if (rows.length === 0) return `Stop nudges · none resolved in the last ${days} days`
199 const width = Math.max(4, ...rows.map(r => r.hook.length))
200 const pct = (s: NudgeCounts) => {
201 const r = actedRate(s)
202 return r === null ? ' -' : `${Math.round(r * 100)}%`.padStart(4)
203 }
204 const cell = (n: number | string) => String(n).padStart(8)
205 return [
206 `Stop nudges · last ${days} days · acted = acted / (acted + ignored + declined)`,
207 '',
208 `${'hook'.padEnd(width)} ${cell('fired')} ${cell('acted')} ${cell('ignored')} ${cell('declined')} ${cell('unknown')} rate`,
209 ...rows.map(
210 r => `${r.hook.padEnd(width)} ${cell(r.fired)} ${cell(r.acted)} ${cell(r.ignored)} ${cell(r.declined)} ${cell(r.unknown)} ${pct(r)}`,
211 ),
212 '',
213 '/session-facts nudges 30 widens the window; the weekly review reads ~/.claude/automation-review/stop-nudges.json.',
214 ].join('\n')
215}
216
217// The export the weekly automation review reads.
218export const nudgeExport = (log: NudgeRecord[], now: number) => ({
219 generatedAt: new Date(now).toISOString(),
220 retentionDays: LOG_DAYS,
221 last7Days: summarizeNudges(log, now - 7 * DAY_MS),
222 last30Days: summarizeNudges(log, now - 30 * DAY_MS),
223 records: log,
224})
225types/index.d.ts 49 lines1export type Segment = { name: string; tokens: number; color: string; kind: 'used' | 'free' | 'buffer' }
2export type Snapshot = { segments: Segment[]; totalTokens: number; maxTokens: number; percentage: number }
3
4// Session facts, kept in $.store under `facts:<session id>`.
5export type RepoEdits = { root: string | null; files: string[]; added: number; bySubagent: number }
6export type Facts = {
7 updatedAt: number
8 skills: { skill: string; at: number }[]
9 agents: { description: string; subagentType: string; at: number }[]
10 // root null: edited outside any git repo (scratchpad, memory dir, ~/.claude).
11 repos: RepoEdits[]
12 stops: { at: number; block: string | null }[]
13 // Per file, when an Edit/Write last touched it: the evidence window for Stop nudges.
14 lastEdit?: Record<string, number>
15 // Stop nudges this session: one per hook reason the Stop orchestration returned.
16 nudges?: Nudge[]
17}
18
19// Stop-nudge outcomes. A nudge is pending until a later Stop (or the session's end)
20// resolves it from what the session did after it fired.
21export type NudgeOutcome = 'acted' | 'ignored' | 'declined' | 'unknown'
22export type Nudge = {
23 hook: string
24 at: number
25 // The reason's first line, cut short.
26 summary: string
27 // Files the reason named (debug-leftover, missing-test).
28 files?: string[]
29 outcome?: NudgeOutcome
30 resolvedAt?: number
31 evidence?: string
32}
33// One resolved nudge, as kept across sessions in $.store under `nudgeOutcomes`.
34export type NudgeRecord = {
35 hook: string
36 session: string
37 at: number
38 resolvedAt: number
39 outcome: NudgeOutcome
40 evidence: string
41}
42export type NudgeCounts = { fired: number } & Record<NudgeOutcome, number>
43
44declare module 'claude-code' {
45 interface PluginState {
46 'dev-hooks': { contextBarShown: boolean; contextBarSnapshot: Snapshot | null; guardTimedOut: Record<string, string> }
47 }
48}
49