Scope, wiki and repo hygiene state in a band above the prompt, with edge-triggered reminders for Claude

Single-command bootstrap for AI coding tools. One repo, one install, full environment reproducible on any linux machine in under 30 seconds.

| Metric | Count |
|---|---|
| Config templates | 80 |
| Managed files deployed | 52 |
| Hooks (Claude Code) | 35 |
| Hooks (Cursor events) | 9 |
| Skills (shared) | 19 |
| Plugins (Claude Code) | 10 |
| MCP servers | 5 |
| Modules | 9 |
| Template variables | 5 |
| No-op redeploy | <2s |
curl -fsSL https://raw.githubusercontent.com/anyesh/harness/main/install.sh | bash
First run clones the repo to ~/.harness, detects installed tools, and deploys everything. Subsequent runs are idempotent (checksum-based skip).
For a machine that should get the hooks, skills, commands and rules but neither second-brain nor the wiki (an interview laptop, say), pass --no-memory. Through the curl bootstrap the flag goes after bash -s and the double-hyphen separator:
curl -fsSL https://raw.githubusercontent.com/anyesh/harness/main/install.sh | bash -s -- --no-memory
HARNESS_NO_MEMORY=1 does the same and also survives the bootstrap re-exec. A lite install skips the second-brain and wiki modules (no cargo build, daemon, sync timer or MCP registration), the memory hooks, the two memory rules, the wiki skill, the devlog, plan and decision commands and the WIKI_VAULT env entry. --only second-brain and --only wiki cannot be combined with it.
Lite mode is for machines that never had the full install. Switching from full to lite does not undo the earlier install: the second-brain systemd unit and sync timer, the claude mcp add entry in ~/.claude.json, and the MCP entries in the Codex, Cursor and opencode configs stay until removed by hand, because uninstall only restores files the manifest tracks. Switching from lite to full just works.
~/.claude/settings.json ← permissions, hooks config, enabledMcpjsonServers
~/.claude/.mcp.json ← MCP server definitions (cognitive-cache, web-strip, markitdown, second-brain)
~/.claude/CLAUDE.md ← global instructions (exploration-first, TDD, scope gates, no AI comments)
~/.claude/hooks/ ← 35 hook scripts (see below)
~/.claude/skills/ ← 19 skill definitions
~/.claude/plugins/ ← 10 plugins from 2 marketplaces (claude-plugins-official, harness-mods)
~/.claude/harness-mods/ ← local folder marketplace holding the harness's own mods
~/.local/bin/claude-profile ← one Claude Code profile per account (below)
claude-profile asks for a name and creates ~/.claude-<name> plus a claude-<name> launcher that runs Claude with CLAUDE_CONFIG_DIR pointed at it, then offers claude auth login. Each profile signs in to its own account and keeps its own .claude.json, while settings, hooks, CLAUDE.md, skills, commands, plugins, mods and projects/ (transcripts and auto-memory) are symlinks into ~/.claude, so every profile runs the same harness and can resume the others' sessions. User-scope MCP servers are merged in from ~/.claude.json.
claude-profile list shows each profile's account, claude-profile remove <name> deletes one, and claude-profile sync relinks all of them; every install runs sync, so profiles pick up new entries and MCP servers. An install started from inside a profile session still targets ~/.claude. If Claude replaces a shared symlink with its own file (a settings write, say), sync reports it instead of overwriting it.
~/.cursor/rules/*.mdc ← 15 shared rules (alwaysApply)
~/.cursor/mcp.json ← MCP servers (merged with existing; harness adds second-brain, cognitive-cache, web-strip, markitdown)
~/.cursor/hooks.json ← 9 hook events (session, prompt, tool, shell, edit, stop, compact)
~/.cursor/hooks/ ← shared hook scripts (same family as Claude Code)
~/.cursor/skills/ ← shared skills (converge, humanize, ownit, wiki, terminal-gif)
~/.cursor/commands/ ← shared slash commands (plan, scope, commit, decision, devlog)
~/.codex/config.toml ← model, approval mode
~/.codex/instructions.md ← shared instructions
~/.agents/skills/ ← shared skills (Codex reads user skills here)
~/.config/opencode/AGENTS.md ← shared global rules
~/.config/opencode/commands/ ← shared slash commands (plan, scope, commit, decision, devlog)
~/.agents/skills/ ← shared skills (opencode auto-scans this dir and ~/.claude/skills)
opencode.json(c) stays unmanaged (model/provider config is machine-specific); a reference template lives at configs/opencode/opencode.jsonc.tmpl.
Mods are Claude Code plugins with an in-process hooks module (CLI 2.1.287+). The harness keeps them in configs/claude-code/mods/, a folder marketplace named harness-mods. install.sh copies it to ~/.claude/harness-mods/ (tests excluded), registers and installs each mod, and settings.json.tmpl carries the matching extraKnownMarketplaces and enabledPlugins entries so a redeploy never drops them. Claude Code reads an installed mod straight from that folder, so after a redeploy /reload-plugins picks up the change.
| Mod | What it does |
|---|---|
nudge-band | Band above the prompt showing scope (.scope.md missing or stale), wiki (devlog and index not written this session) and untracked junk, with buttons that pre-fill /scope, /devlog or a cleanup prompt. Claude gets one reminder when a condition first holds; the wiki reminder repeats after 3 more edits or 10 more turns without a wiki write. Active humanize and ownit modes show in the footer beside Claude Code's own modes. |
cache-guard | Tracks when the main thread last got a response and whether each turn's first request found the prompt cache warm or cold, learning the cache lifetime from those hits and misses (60 minutes until it has seen one). The status line ends with cache 42m, or cache cold ≈ $3.04 in yellow once the next send would re-write the context. When an idle composer prompt would re-write at least gateUsd (default $1) of context, it asks whether to send anyway, compact first or keep the prompt as a draft, and a dismissed or unanswerable question sends. A cold turn that re-wrote 50k tokens or more raises a toast with its estimated cost at API prices. Subagent steps are tracked the same way against their own learnt TTL (agentTtlMinutes, default 5): the status line adds agents 3 (or agents 3 · 1 cold in yellow) while $.agent.list() shows live agents, a cold resumed agent raises its own toast, and the band above the prompt lists up to three agents that sit idle past the TTL with 50k tokens or more, or have run over an hour, each with a Dismiss button. Ended subagents leave the list within a minute, so their records age out after a day instead. |
guard-watch | Watches tool.check verdicts and counts every call a settings PreToolUse guard denied, naming the guard from its message ([hook:cost-guard], BLOCKED by pre-bash-guard, or the hook script's path). The status line shows 2 blocks, in yellow once a subagent was blocked, and a subagent's block raises a toast (cost-guard blocked Explore's Read: ...) because it never reaches the main transcript. It only observes: the verdict passes through unchanged. |
context-audit | /context-audit prints what /context does not: MCP tokens per server (loaded and deferred), skills grouped by plugin or source, memory files, agent definitions and slash commands, each sorted by tokens, then up to five trim candidates (an MCP server over 5k loaded tokens, a skill over 1k, a memory file over 3k) with their share of the window. It reads $.session.usage({ breakdown: 'summary' }) on demand and runs nothing otherwise. |
handoff | Answers main-thread compaction (auto, /compact, and a plugin's such as cache-guard's "Compact first") with one handoff message instead of the built-in summary: handoff-digest.sh supplies the facts (files touched, repo state, last plan, newest turns) and a tool-less $.model.fork writes goal, next step, decisions, ruled-out paths, open questions and the verify command. Each handoff is saved as ~/.claude/handoffs/<slug>/<session>.handoff-<n>.md. A failed fork keeps the built-in summary with the digest appended; a failed digest asks the built-in summarizer to cover the reasoning sections. /compact classic [text] gets the built-in summary, and subagent compactions are left alone. |
Develop a mod with claude --plugin-dir configs/claude-code/mods/<mod> (hot reload), and check it with claude plugin validate --strict <dir> and claude plugin test <dir>; tests/test_harness_mods.sh runs both for every mod. A mod must not register a Bash tool.call hook while anthropics/claude-code#92533 is open, because that breaks worktree-isolated subagents.
A mod cannot draw on the status line itself, so a mod with something to show there writes { "text", "tone", "at" } to ${XDG_STATE_HOME:-~/.local/state}/harness/status/<session id>/<mod>.json and removes it when the session ends. statusline.sh appends each segment in a fixed order, draws warn in yellow, and skips a file older than 10 minutes.
| Hook | Trigger | Purpose | |
|---|---|---|---|
pre-edit-comment-guard.py | PreToolUse (Write/Edit) | Blocks AI-generated narration comments | |
pre-edit-code-quality.sh | PreToolUse (Write/Edit) | Rejects shortcut-seeking language | |
stop-sloppiness-guard.sh | PostResponse | Detects lazy patterns, injects correction | |
cost-guard.sh | PreToolUse (Bash) | Blocks unbounded output in main context | |
context-cold-start-guard.sh | beforeSubmitPrompt / preCompact (Cursor) | Blocks cold re-bill on large context after model change, compaction, or cache idle | |
pre-bash-guard.sh | PreToolUse (Bash) | Validates commands before execution | |
format-on-save.sh | PostToolUse (Write/Edit) | Auto-formats written files | |
post-compact-context.sh | SessionStart compact (Claude Code, Codex) / preCompact + postToolUse (Cursor) | Re-injects the active .scope.md after compaction | |
compact-note.sh | Called by post-compact-context.sh and the opencode harness-compact-scope.js plugin | Renders .scope.md within the injection cap | |
session-start.sh | SessionStart | Activates modes, checks environment | |
session-end-ingest.sh | SessionEnd | Ingests session into second-brain (machine memory) | |
session-start-wiki.sh | SessionStart | Injects project wiki context + devlog | |
worktree-sweep.sh | SessionStart (Claude Code, async, logs to ~/.local/state/harness/worktree-sweep.log) | Removes idle worktrees whose commits landed on main, salvaging uncommitted files to .git/worktree-salvage/; Codex and Cursor have no async session start, so run it by hand | |
humanize-*.js | Various | Humanize mode activation, config | |
ownit-*.js | Various | Ownership mindset activation, config | |
attention-notify.sh | Notification, PreToolUse `AskUserQuestion\ | ExitPlanMode`, prompt, tool and session-end events (Claude Code); PermissionRequest, Stop and the same cancel events (Codex); stop, prompt, tool and session-end (Cursor) | Pushes to ntfy when a session waits on you or finishes (see below) |
attention-core.sh | Called by attention-notify.sh and the opencode harness-attention.js plugin | Arms, cancels and sends the ntfy push |
On Claude Code the scope, wiki and hygiene nudges (scope-drift-detector.sh, wiki-nudge.sh, cleanup-nudge.sh) are replaced by the nudge-band mod below; Cursor and Codex keep the shell scripts.
Guards match on the code a command runs, not on quoted text or heredoc bodies (shell-code.sh), and every block is appended to ~/.local/state/harness/hook-blocks.jsonl (log-block.sh; override with HARNESS_BLOCK_LOG). Two scripts keep false positives visible:
scripts/hook-blocks.sh [--days N] summarises recent blocks per hook.scripts/guard-replay.sh [--transcripts N] [--hook NAME] replays real tool calls from Claude Code transcripts through the repo's current guards and lists what each would block. Run it before shipping a guard change and read every sample.Set NTFY_URL (your ntfy server) and NTFY_TOPIC in the environment or ~/.harness.env, plus NTFY_TOKEN if the topic needs a bearer token. Nothing is sent until both the URL and topic are set, so there is no fallback to the public ntfy.sh. A session that blocks on you (a question, plan approval or permission prompt) pushes at priority 4 after NTFY_BLOCK_DELAY seconds (default 15), and a finished turn pushes at priority 3 after NTFY_DONE_DELAY (default 60; Claude Code's own idle notice already waits a minute, so it adds nothing there). Answering, pressing Esc or typing a prompt inside that window cancels the push, and a session that has exited stays quiet.
Headless claude -p never pushes; set HARNESS_NOTIFY=0 for codex exec, opencode run or other scripted runs. Cursor only gets finished turns because it has no hook for approvals or agent questions. Codex asks you to trust new hook entries through /hooks after a redeploy. Every arm, cancel, send and skip is logged with its reason to ~/.local/state/harness/attention/log (last 200 lines).
| Server | Transport | Purpose |
|---|---|---|
second-brain | stdio | Personal knowledge graph (KùzuDB, BGE embeddings, 384-dim) |
cognitive-cache | stdio | Optimal file selection for LLM context |
web-strip | stdio | Clean markdown extraction from URLs (Readability) |
markitdown | stdio | PDF/DOCX/XLSX → markdown |
verdant | stdio | Cross-session tool call caching (per-project) |
| Category | Skills |
|---|---|
| SEO | seo, seo-audit, seo-page, seo-plan, seo-technical, seo-schema, seo-sitemap, seo-content, seo-images, seo-hreflang, seo-geo, seo-competitor-pages, seo-programmatic |
| Knowledge | wiki (Karpathy-pattern LLM wiki) |
| Workflow | terminal-gif, humanize, ownit |
claude Claude Code: configs, plugins, hooks, skills, MCP
cursor Cursor: rules, mcp.json, hooks.json, skills, commands
codex Codex: config.toml, instructions
opencode opencode: AGENTS.md, commands, skills via ~/.agents/skills
second-brain second-brain daemon + MCP server registration
wiki Obsidian wiki vault init (Karpathy pattern, per-project)
watch File watcher for auto-ingest
leakguard gitleaks + global pre-commit scan for personal info/secrets;
pairs with the publish-leak-guard Claude hook that scans built
artifacts (sdists/wheels) before uv publish/twine upload.
Escape hatches: '#gitleaks:allow' inline, LEAKGUARD_SKIP=1.
Caveat: repos that set their own core.hooksPath (like this
one) bypass the global scan; they must scan themselves.
lib Shared functions (logging, template rendering, manifest)
The harness deploys an LLM-maintained wiki based on Andrej Karpathy's LLM Wiki pattern. The LLM builds and maintains structured Obsidian pages as you work; you never write the wiki yourself.
Setup: Set WIKI_VAULT in ~/.harness.env to your Obsidian vault path. The wiki module creates the structure on install.
How it works:
wiki/projects/<slug>/ with devlog, decisions/, plans/, spikes//wiki skill provides full operations: ingest external sources, query, lintWhat gets captured: plans, PRDs, architecture decisions (ADRs), exploration spikes, concepts, devlogs. Not captured: routine fixes, debugging transcripts, AI bloat.
Vault structure:
$WIKI_VAULT/
├── wiki/
│ ├── projects/<slug>/ ← per-project knowledge
│ ├── concepts/ ← cross-project concepts
│ ├── synthesis/ ← filed query answers
│ ├── sources/ ← external source summaries
│ ├── index.md ← master catalog
│ └── log.md ← activity timeline
├── raw/ ← immutable source copies
└── WIKI_SCHEMA.md ← conventions
Set in ~/.harness.env:
USER_EMAIL=you@example.com
GITHUB_USER=yourhandle
DATA_ROOT=/path/to/data
MCP_COGNITIVE_CACHE_PATH=cognitive-cache-mcp
HOME_DIR=/home/youruser
./install.sh # Full deploy (default)
./install.sh --only claude # Single module
./install.sh --force # Redeploy even if unchanged
./install.sh --dry-run # Preview without changes
./install.sh --no-memory # Skip second-brain and the wiki
./install.sh --override-settings # Replace ~/.claude/settings.json instead of merging into it
./install.sh status # Show managed file state
./install.sh uninstall # Restore all backups
~/.harness.env ← Personal variables (not committed)
~/.harness-manifest.json ← Deployed file checksums (auto-generated)
harness/
├── configs/
│ ├── claude-code/ ← Templates: settings, CLAUDE.md, .mcp.json, hooks, skills
│ ├── cursor/ ← Templates: rules, mcp.json
│ ├── codex/ ← Templates: config.toml, instructions
│ └── opencode/ ← Templates: AGENTS.md, opencode.jsonc (reference)
├── modules/ ← Deploy logic per tool (claude.sh, cursor.sh, etc.)
├── lib/ ← Shared: template rendering, manifest, backup, detect
├── tools/ ← Bundled tools (web-strip)
└── install.sh ← Entrypoint
install.sh sources all libs and modules~/.harness.env for template variablesconfigs/ source was renamed or deleted: an unchanged copy the harness created is deleted along with any folder it leaves empty, one that replaced a file you had is swapped back to your original, and an edited copy or one with no pre-existence record stays in place for status to flag~/.harness-manifest.json with file hashes# 1. Install
curl -fsSL https://raw.githubusercontent.com/anyesh/harness/main/install.sh | bash
# 2. Configure
cat > ~/.harness.env << 'VARS'
USER_EMAIL=you@example.com
GITHUB_USER=yourhandle
DATA_ROOT=/path/to/data
HOME_DIR=$HOME
VARS
# 3. Deploy
~/.harness/install.sh
# 4. Verify
claude mcp list # Should show 5 servers connected
claude CLI (for plugin/MCP registration; configs deploy without it)uv/uvx (for cognitive-cache, markitdown)hooks/register.tsx 117 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { probe } from './probe'
5import type { ProbeIO } from './probe'
6import { countTurnEdits, decide, initialArmed, modeLabels } from './rules'
7import type { Snapshot } from '../types'
8
9const turns = atom({ plugin: 'nudge-band', key: 'turns' } as const, 0)
10const edits = atom({ plugin: 'nudge-band', key: 'edits' } as const, 0)
11const startedAt = atom({ plugin: 'nudge-band', key: 'startedAt' } as const, null)
12const snapshot = atom({ plugin: 'nudge-band', key: 'snapshot' } as const, null)
13const armed = atom({ plugin: 'nudge-band', key: 'armed' } as const, initialArmed)
14const isHidden = atom({ plugin: 'nudge-band', key: 'isHidden' } as const, false)
15
16async function sessionStart($: EngineInterface): Promise<number> {
17 const known = await read($, startedAt)
18 if (known !== null) return known
19 const now = await $.clock.now()
20 await update($, startedAt, () => now)
21 return now
22}
23
24function probeIO($: EngineInterface): ProbeIO {
25 return {
26 exists: path => $.fs.exists(path),
27 stat: path => $.fs.stat(path),
28 read: path => $.fs.read(path),
29 run: (argv, cwd) => $.process.run(argv, cwd ? { cwd } : undefined),
30 vars: async () => ({
31 home: await $.env.get('HOME'),
32 configDir: await $.env.get('CLAUDE_CONFIG_DIR'),
33 wikiVault: await $.env.get('WIKI_VAULT'),
34 }),
35 cwd: () => $.session.cwd(),
36 now: () => $.clock.now(),
37 }
38}
39
40async function refresh($: EngineInterface): Promise<Snapshot> {
41 const snap = await probe(probeIO($), await sessionStart($))
42 await update($, snapshot, () => snap)
43 return snap
44}
45
46function cleanupDraft(junk: readonly string[]): string {
47 return `Delete these untracked files if they are leftover scratch artifacts, and keep anything still in use: ${junk.join(', ')}`
48}
49
50export const register: Register = on => {
51 on('session.start', async ($, e, next) => {
52 await refresh($)
53 return next(e)
54 })
55
56 on('prompt.submit', async ($, e, next) => {
57 const turn = await update($, turns, n => n + 1)
58 const snap = await refresh($)
59 const decision = decide({ turns: turn, edits: await read($, edits), snapshot: snap, armed: await read($, armed) })
60 await update($, armed, () => decision.armed)
61 if (decision.messages.length === 0) return next(e)
62 return next({ ...e, context: [...(e.context ?? []), ...decision.messages] })
63 })
64
65 on('turn.complete', async ($, e, next) => {
66 if (e.agentId === undefined) {
67 const added = countTurnEdits(await $.session.messages())
68 if (added > 0) await update($, edits, n => n + added)
69 await refresh($)
70 }
71 return next(e)
72 })
73
74 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
75 const snap = await read($, snapshot)
76 if (e.props.hasSurvey || snap === null || (await read($, isHidden))) return next(e)
77
78 const scope = snap.scope && snap.scope.kind !== 'fresh' ? snap.scope : null
79 const wiki = snap.wiki && !(snap.wiki.devlogWritten && snap.wiki.indexWritten) ? snap.wiki : null
80 const junk = snap.junk
81 if (!scope && !wiki && junk.length === 0) return next(e)
82
83 const { Box, Button, Text } = $.ui.resolve(e)
84 const fill = (text: string) => () => void $.prompt.fill({ text })
85 const unwritten = wiki
86 ? [!wiki.devlogWritten && 'devlog', !wiki.indexWritten && 'index'].filter(Boolean).join(', ')
87 : ''
88
89 const row = (
90 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
91 {scope && (
92 <Text dimColor>scope: {scope.kind === 'missing' ? 'missing' : `${scope.ageHours}h old`}</Text>
93 )}
94 {scope && <Button key="scope" label="Write scope" dimColor onPress={fill('/scope')} />}
95 {wiki && <Text dimColor>wiki: {unwritten} not written</Text>}
96 {wiki && <Button key="wiki" label="Log it" dimColor onPress={fill('/devlog')} />}
97 {junk.length > 0 && <Text dimColor>junk: {junk.length} {junk.length === 1 ? 'file' : 'files'}</Text>}
98 {junk.length > 0 && <Button key="junk" label="Clean up" dimColor onPress={fill(cleanupDraft(junk))} />}
99 <Button key="hide" label="Hide" dimColor role="dismiss" onPress={() => update($, isHidden, () => true)} />
100 </Box>
101 )
102 return (
103 <Box flexDirection="column">
104 {row}
105 {await next(e)}
106 </Box>
107 )
108 })
109
110 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
111 const snap = await read($, snapshot)
112 const ours = snap ? modeLabels(snap) : []
113 if (ours.length === 0) return next(e)
114 return next({ ...e, props: { ...e.props, modes: [...e.props.modes, ...ours] } })
115 })
116}
117hooks/probe.ts 76 lines1import { isJunk, isToolingDir, projectSlug, scopeStatus } from './rules'
2import type { Snapshot, WikiStatus } from '../types'
3
4export type ProbeIO = {
5 exists: (path: string) => Promise<boolean>
6 stat: (path: string) => Promise<{ kind: 'file' | 'dir' | 'other'; mtimeMs: number }>
7 read: (path: string) => Promise<string>
8 run: (argv: readonly string[], cwd?: string) => Promise<{ exitCode: number; stdout: string }>
9 vars: () => Promise<{ home?: string; configDir?: string; wikiVault?: string }>
10 cwd: () => Promise<string>
11 now: () => Promise<number>
12}
13
14async function mtime(io: ProbeIO, path: string): Promise<number | null> {
15 if (!(await io.exists(path))) return null
16 const stat = await io.stat(path)
17 return stat.kind === 'file' ? stat.mtimeMs : null
18}
19
20async function flag(io: ProbeIO, path: string): Promise<string | null> {
21 if ((await mtime(io, path)) === null) return null
22 const text = (await io.read(path)).trim().toLowerCase()
23 return text.length > 0 && text.length <= 64 ? text : null
24}
25
26async function git(io: ProbeIO, args: string[], cwd?: string): Promise<string | null> {
27 const result = await io.run(['git', ...args], cwd)
28 return result.exitCode === 0 ? result.stdout : null
29}
30
31async function wikiStatus(io: ProbeIO, vault: string | undefined, slug: string, startedAt: number): Promise<WikiStatus | null> {
32 if (!vault || !slug) return null
33 const devlogPath = `${vault}/wiki/projects/${slug}/devlog.md`
34 const indexPath = `${vault}/wiki/index.md`
35 const [devlog, index] = await Promise.all([mtime(io, devlogPath), mtime(io, indexPath)])
36 const since = (m: number | null) => (m !== null && m >= startedAt ? m : 0)
37 return {
38 devlogPath,
39 indexPath,
40 devlogWritten: since(devlog) > 0,
41 indexWritten: since(index) > 0,
42 latestWriteMs: Math.max(since(devlog), since(index)),
43 }
44}
45
46export async function probe(io: ProbeIO, startedAt: number): Promise<Snapshot> {
47 const vars = await io.vars()
48 const home = vars.home ?? ''
49 const configDir = vars.configDir || `${home}/.claude`
50 const top = await git(io, ['rev-parse', '--show-toplevel'])
51 const isGit = top !== null
52 const cwd = isGit ? top.trim() : await io.cwd()
53 const root = cwd && !isToolingDir(cwd, home) ? cwd : null
54 const slug = root ? projectSlug(root) : ''
55 const now = await io.now()
56
57 const [scopeMtime, wiki, untracked, humanize, ownit] = await Promise.all([
58 root ? mtime(io, `${root}/.scope.md`) : Promise.resolve(null),
59 root ? wikiStatus(io, vars.wikiVault, slug, startedAt) : Promise.resolve(null),
60 root && isGit ? git(io, ['ls-files', '--others', '--exclude-standard'], root) : Promise.resolve(null),
61 flag(io, `${configDir}/.humanize-active`),
62 flag(io, `${configDir}/.ownit-active`),
63 ])
64
65 return {
66 root,
67 isGit,
68 slug,
69 scope: root ? scopeStatus(scopeMtime, now) : null,
70 wiki,
71 junk: (untracked ?? '').split('\n').filter(path => path.length > 0 && isJunk(path)),
72 humanize,
73 ownit,
74 }
75}
76hooks/rules.ts 189 lines1import type { Armed, ScopeStatus, Snapshot } from '../types'
2
3export const THRESHOLDS = {
4 scopeTurn: 10,
5 scopeFreshMs: 2 * 3_600_000,
6 wikiFirstTurn: 5,
7 wikiRepeatEdits: 3,
8 wikiRepeatTurns: 10,
9 cleanupTurn: 5,
10 cleanupListMax: 10,
11} as const
12
13export const initialArmed: Armed = {
14 scope: true,
15 wikiFired: false,
16 wikiAnchorTurn: 0,
17 wikiAnchorEdits: 0,
18 wikiSeenWriteMs: 0,
19 cleanupReported: [],
20}
21
22const PROTECTED_DOCS = new Set([
23 'readme.md', 'readme.txt', 'changelog.md', 'contributing.md', 'license', 'license.md',
24 'license.txt', 'security.md', 'code_of_conduct.md', 'authors.md', 'maintainers.md',
25 'notice.md', 'claude.md', 'agents.md', '.scope.md',
26])
27
28const ROOT_SCRATCH_NAMES = new Set([
29 'plan.md', 'plans.md', 'handover.md', 'handoff.md', 'product.md', 'prd.md', 'notes.md',
30 'note.md', 'scratch.md', 'summary.md', 'implementation.md', 'implementation_plan.md',
31 'implementation-plan.md', 'changes.md', 'review.md', 'analysis.md', 'walkthrough.md',
32 'brainstorm.md', 'ideas.md', 'draft.md', 'worklog.md', 'progress.md', 'status.md',
33 'report.md', 'findings.md', 'investigation.md', 'todo.md', 'tasks.md', 'out.txt',
34 'output.txt', 'debug.txt', 'dump.txt',
35])
36
37const ROOT_SCRATCH_PATTERNS = [/\.plan\.md$/, /\.notes\.md$/, /\.scratch\.md$/, /^scratch\./, /^tmp\./, /^debug\./]
38const ANY_DEPTH_SUFFIXES = /(\.tmp|\.temp|\.bak|\.orig|\.rej|\.swp|\.swo|~|\.log)$/
39const ANY_DEPTH_NAMES = new Set(['nohup.out', 'core'])
40const SCRATCH_DIRS = /(^|\/)(screenshots|screenshot|scratch|tmp)\/|^\.scratch\/|^__scratch__\//
41const ROOT_IMAGES = /\.(png|jpg|jpeg|gif|webp|bmp)$/
42
43export const EDIT_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit', 'mcp__verdant__write', 'mcp__verdant__edit'])
44
45export function isJunk(path: string): boolean {
46 const base = path.slice(path.lastIndexOf('/') + 1)
47 const lower = base.toLowerCase()
48 if (PROTECTED_DOCS.has(lower)) return false
49 const atRoot = !path.includes('/')
50 if (atRoot && (ROOT_SCRATCH_NAMES.has(lower) || ROOT_SCRATCH_PATTERNS.some(p => p.test(lower)))) return true
51 if (ANY_DEPTH_SUFFIXES.test(lower) || ANY_DEPTH_NAMES.has(lower)) return true
52 if (SCRATCH_DIRS.test(path)) return true
53 return atRoot && ROOT_IMAGES.test(base)
54}
55
56export function projectSlug(root: string): string {
57 const base = root.replace(/\/+$/, '').split('/').pop() ?? ''
58 return base.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
59}
60
61export function isToolingDir(root: string, home: string): boolean {
62 const base = root.split('/').pop() ?? ''
63 return [`${home}/.cursor`, `${home}/.claude`, `${home}/.codex`].includes(root) || base === '.cursor' || base === '.claude'
64}
65
66export function scopeStatus(mtimeMs: number | null, nowMs: number): ScopeStatus {
67 if (mtimeMs === null) return { kind: 'missing' }
68 const age = nowMs - mtimeMs
69 if (age < THRESHOLDS.scopeFreshMs) return { kind: 'fresh' }
70 return { kind: 'stale', ageHours: Math.floor(age / 3_600_000) }
71}
72
73type MessageRow = {
74 role: 'user' | 'assistant'
75 toolUses: readonly { tool: string }[]
76 toolResults?: readonly unknown[]
77}
78
79export function countTurnEdits(rows: readonly MessageRow[]): number {
80 let edits = 0
81 for (let i = rows.length - 1; i >= 0; i -= 1) {
82 const row = rows[i]!
83 if (row.role === 'user' && !(row.toolResults && row.toolResults.length > 0)) break
84 if (row.role === 'assistant') edits += row.toolUses.filter(use => EDIT_TOOLS.has(use.tool)).length
85 }
86 return edits
87}
88
89type DecideInput = { turns: number; edits: number; snapshot: Snapshot; armed: Armed }
90type Decision = { armed: Armed; messages: string[] }
91
92export function decide({ turns, edits, snapshot, armed }: DecideInput): Decision {
93 const messages: string[] = []
94 let next = { ...armed }
95
96 const scope = decideScope(turns, snapshot, next)
97 next = scope.armed
98 if (scope.message) messages.push(scope.message)
99
100 const wiki = decideWiki(turns, edits, snapshot, next)
101 next = wiki.armed
102 if (wiki.message) messages.push(wiki.message)
103
104 const cleanup = decideCleanup(turns, snapshot, next)
105 next = cleanup.armed
106 if (cleanup.message) messages.push(cleanup.message)
107
108 return { armed: next, messages }
109}
110
111type Step = { armed: Armed; message?: string }
112
113function decideScope(turns: number, snapshot: Snapshot, armed: Armed): Step {
114 const status = snapshot.scope
115 if (!snapshot.root || !status) return { armed }
116 if (status.kind === 'fresh') return { armed: { ...armed, scope: true } }
117 if (!armed.scope || turns < THRESHOLDS.scopeTurn) return { armed }
118 const condition = status.kind === 'missing' ? '.scope.md is missing' : `.scope.md is ${status.ageHours}h old and may not match the current task`
119 return {
120 armed: { ...armed, scope: false },
121 message:
122 `SCOPE CHECK (turn ${turns}): ${condition}. If you are doing feature work or multi-file changes, ` +
123 'answer the Three Questions before continuing: (1) Context: where are specs, plans and the wiki? ' +
124 '(2) Definition of done: what declares this complete? (3) Feedback loop: how are you validating? ' +
125 'Write the answers to .scope.md. If this is a quick fix or config change, ignore this reminder.',
126 }
127}
128
129function decideWiki(turns: number, edits: number, snapshot: Snapshot, armed: Armed): Step {
130 const wiki = snapshot.wiki
131 if (!wiki) return { armed }
132 let next = armed
133 if (wiki.latestWriteMs > armed.wikiSeenWriteMs) {
134 next = { ...next, wikiSeenWriteMs: wiki.latestWriteMs, wikiAnchorTurn: turns, wikiAnchorEdits: edits }
135 }
136 const written = wiki.devlogWritten && wiki.indexWritten
137 const editsSince = edits - next.wikiAnchorEdits
138 const turnsSince = turns - next.wikiAnchorTurn
139 const due = next.wikiFired || written
140 ? editsSince >= THRESHOLDS.wikiRepeatEdits || turnsSince >= THRESHOLDS.wikiRepeatTurns
141 : turns >= THRESHOLDS.wikiFirstTurn
142 if (!due) return { armed: next }
143 const fired = { ...next, wikiFired: true, wikiAnchorTurn: turns, wikiAnchorEdits: edits }
144 if (written) {
145 return {
146 armed: fired,
147 message:
148 `WIKI: ${editsSince} edits and ${turnsSince} turns since the last wiki update. If that work produced ` +
149 `decisions, plans, findings or shipped changes, add a devlog entry at ${wiki.devlogPath} and update ${wiki.indexPath}.`,
150 }
151 }
152 const missing = [!wiki.devlogWritten && 'the project devlog', !wiki.indexWritten && 'the wiki index'].filter(Boolean).join(' and ')
153 return {
154 armed: fired,
155 message:
156 `WIKI: You are ${turns} turns into this session and have not written ${missing}. ` +
157 `Write a devlog entry at ${wiki.devlogPath} summarizing work so far. If you made plans, decisions, ` +
158 `or ran spikes, create pages in the corresponding subdirectory. Then update ${wiki.indexPath}: ` +
159 'every session must update it. Do this now, before your next task response.',
160 }
161}
162
163function decideCleanup(turns: number, snapshot: Snapshot, armed: Armed): Step {
164 const current = new Set(snapshot.junk)
165 const kept = armed.cleanupReported.filter(path => current.has(path))
166 const reported = new Set(kept)
167 const fresh = snapshot.junk.filter(path => !reported.has(path))
168 if (turns < THRESHOLDS.cleanupTurn || fresh.length === 0) {
169 return { armed: { ...armed, cleanupReported: kept } }
170 }
171 const shown = fresh.slice(0, THRESHOLDS.cleanupListMax).join(', ')
172 const extra = fresh.length > THRESHOLDS.cleanupListMax ? ` (and ${fresh.length - THRESHOLDS.cleanupListMax} more)` : ''
173 return {
174 armed: { ...armed, cleanupReported: [...kept, ...fresh] },
175 message:
176 `HYGIENE: untracked scratch artifacts are sitting in this repo: ${shown}${extra}. If any are leftover ` +
177 'working files (temp docs, screenshots from testing, debug dumps, junk), delete them so they do not ' +
178 "pollute the tree. Anything worth keeping belongs in the project's docs or notes, not as loose files " +
179 'in the repo. Leave anything still in use.',
180 }
181}
182
183export function modeLabels(snap: Pick<Snapshot, 'humanize' | 'ownit'>): string[] {
184 const labels: string[] = []
185 if (snap.humanize) labels.push(`humanize ${snap.humanize}`)
186 if (snap.ownit) labels.push(`ownit ${snap.ownit}`)
187 return labels
188}
189types/index.d.ts 46 lines1export type ScopeStatus =
2 | { kind: 'fresh' }
3 | { kind: 'missing' }
4 | { kind: 'stale'; ageHours: number }
5
6export type WikiStatus = {
7 devlogPath: string
8 indexPath: string
9 devlogWritten: boolean
10 indexWritten: boolean
11 latestWriteMs: number
12}
13
14export type Snapshot = {
15 root: string | null
16 isGit: boolean
17 slug: string
18 scope: ScopeStatus | null
19 wiki: WikiStatus | null
20 junk: string[]
21 humanize: string | null
22 ownit: string | null
23}
24
25export type Armed = {
26 scope: boolean
27 wikiFired: boolean
28 wikiAnchorTurn: number
29 wikiAnchorEdits: number
30 wikiSeenWriteMs: number
31 cleanupReported: string[]
32}
33
34declare module 'claude-code' {
35 interface PluginState {
36 'nudge-band': {
37 turns: number
38 edits: number
39 startedAt: number | null
40 snapshot: Snapshot | null
41 armed: Armed
42 isHidden: boolean
43 }
44 }
45}
46