SLOPSHOPPER

nudge-band

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

newbandspinnerpromptprocess
v0.1.0no licenseupdated 2026-10-10Anyesh/harness/configs/claude-code/mods/nudge-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · nudge-band
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM scope: missing [ Write scope ] [ Hide ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
scope: missing [ Write scope ] [ Hide ] ⟨Claude Code's own drawing⟩
README

harness

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

harness install demo

Numbers

MetricCount
Config templates80
Managed files deployed52
Hooks (Claude Code)35
Hooks (Cursor events)9
Skills (shared)19
Plugins (Claude Code)10
MCP servers5
Modules9
Template variables5
No-op redeploy<2s

Install

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).

Lite install (no memory)

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.

What gets deployed

Claude Code

~/.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)
Account profiles

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

~/.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

~/.codex/config.toml           ← model, approval mode
~/.codex/instructions.md       ← shared instructions
~/.agents/skills/              ← shared skills (Codex reads user skills here)

opencode

~/.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 (Claude Code)

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.

ModWhat it does
nudge-bandBand 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-guardTracks 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-watchWatches 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.
handoffAnswers 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.

Hooks

HookTriggerPurpose
pre-edit-comment-guard.pyPreToolUse (Write/Edit)Blocks AI-generated narration comments
pre-edit-code-quality.shPreToolUse (Write/Edit)Rejects shortcut-seeking language
stop-sloppiness-guard.shPostResponseDetects lazy patterns, injects correction
cost-guard.shPreToolUse (Bash)Blocks unbounded output in main context
context-cold-start-guard.shbeforeSubmitPrompt / preCompact (Cursor)Blocks cold re-bill on large context after model change, compaction, or cache idle
pre-bash-guard.shPreToolUse (Bash)Validates commands before execution
format-on-save.shPostToolUse (Write/Edit)Auto-formats written files
post-compact-context.shSessionStart compact (Claude Code, Codex) / preCompact + postToolUse (Cursor)Re-injects the active .scope.md after compaction
compact-note.shCalled by post-compact-context.sh and the opencode harness-compact-scope.js pluginRenders .scope.md within the injection cap
session-start.shSessionStartActivates modes, checks environment
session-end-ingest.shSessionEndIngests session into second-brain (machine memory)
session-start-wiki.shSessionStartInjects project wiki context + devlog
worktree-sweep.shSessionStart (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-*.jsVariousHumanize mode activation, config
ownit-*.jsVariousOwnership mindset activation, config
attention-notify.shNotification, 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.shCalled by attention-notify.sh and the opencode harness-attention.js pluginArms, 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.

Phone push when a session needs you

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).

MCP Servers

ServerTransportPurpose
second-brainstdioPersonal knowledge graph (KùzuDB, BGE embeddings, 384-dim)
cognitive-cachestdioOptimal file selection for LLM context
web-stripstdioClean markdown extraction from URLs (Readability)
markitdownstdioPDF/DOCX/XLSX → markdown
verdantstdioCross-session tool call caching (per-project)

Skills

CategorySkills
SEOseo, 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
Knowledgewiki (Karpathy-pattern LLM wiki)
Workflowterminal-gif, humanize, ownit

Modules

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)

Wiki (Knowledge Base)

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:

  • Every session, the LLM sees wiki context (project devlog, index) via session-start hook
  • CLAUDE.md instructs the LLM to proactively write wiki pages when plans, decisions, spikes, or concepts emerge
  • Pages are organized per-project: wiki/projects/<slug>/ with devlog, decisions/, plans/, spikes/
  • Pre-compact hook reminds the LLM to capture anything valuable before context compression
  • /wiki skill provides full operations: ingest external sources, query, lint

What 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

Template Variables

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

Commands

./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

Architecture

~/.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

Deploy Flow

  1. install.sh sources all libs and modules
  2. Detects installed tools via PATH and config dirs
  3. Reads ~/.harness.env for template variables
  4. Per module: renders templates → checksums → skips if unchanged → backs up → deploys
  5. Removes orphans, deployed files whose configs/ 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
  6. Writes ~/.harness-manifest.json with file hashes
  7. Idempotent: re-run anytime, only changed files get touched

New Machine Setup

# 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

Requirements

  • bash 4+
  • git
  • jq
  • perl (template rendering)
  • claude CLI (for plugin/MCP registration; configs deploy without it)
  • Node.js (for web-strip)
  • Python 3.9+ with uv/uvx (for cognitive-cache, markitdown)
Source 4 files
hooks/register.tsx 117 lines
1import { 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}
117
hooks/probe.ts 76 lines
1import { 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}
76
hooks/rules.ts 189 lines
1import 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}
189
types/index.d.ts 46 lines
1export 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