Tells Claude which repo, branch, package manager and uncommitted work a prompt is about, plus recent commits and the latest spec at session start. Adds /where.

A small, language-neutral scaffold you copy into a repo so coding agents start with sensible guardrails. It is Claude Code–native, with AGENTS.md as the cross-tool contract that Cursor, Codex, Copilot, and others read too.
It is deliberately small. It leans on Claude Code's built-in controls where they exist, and adds one hook for the part that has to be project-specific: running your formatter and your tests.
scaffold/ ← copy this into your repo
├── AGENTS.md project facts, commands, conventions, security (fill in)
├── CLAUDE.md imports AGENTS.md, plus a few Claude-specific notes
├── .gitignore lines to add to yours
└── .claude/
├── settings.json permission rules and hook wiring
├── hooks/
│ ├── checks.mjs runs your formatter after edits, your tests before Claude finishes
│ └── checks.json the commands it runs (empty until you fill it in)
└── skills/zenn/ /zenn: optional spec-first workflow for larger work
providers.md notes for Cursor / Copilot / Codex / Gemini / Devin
mods/ user-level Claude Code mods (see mods/README.md)
tests/ node --test "tests/*.test.mjs": the hook, the scaffold rules, the mods
cp -r /path/to/agents/scaffold/. /path/to/your-repo/
The trailing /. copies the contents, including the dot-directories. If the repo already has a .gitignore, AGENTS.md, or CLAUDE.md, merge those by hand instead of overwriting them. Node on PATH is the only requirement.
Replace each <!-- placeholder --> with the project's name, stack, and real commands, and delete the sections you don't need.
Edit .claude/hooks/checks.json. Both lists are empty by default, which turns the hook off.
{
"format": {
"py": "ruff format {file}",
"ts,tsx,js": "npx --no-install prettier --write {file}"
},
"verify": ["pytest -q"]
}
format maps file extensions to a command that runs after Claude edits a file of that type. {file} is the edited file's path, already quoted. If the command fails, its output goes back to Claude to fix.verify commands run when Claude finishes a turn in which it edited files. If one fails, Claude keeps working until it passes.CLAUDE_SKIP_CHECKS=1 claude turns the hook off for a session.On macOS, Linux, or WSL2, run /sandbox in Claude Code. It confines shell commands to the project directory and to network hosts you approve. The secret paths denied in settings.json apply inside the sandbox too.
| Layer | What it covers | | :- | :- | | deny rules | Secrets are never read or written: .env*, .dev.vars*, key files, ~/.ssh, ~/.gnupg, cloud credentials (AWS, GCP, Azure, Kubernetes), tool and registry credentials (GitHub CLI, Docker, npm, PyPI, RubyGems, ~/.git-credentials, ~/.netrc). .env.example stays usable. Lockfiles (package-lock.json, pnpm-lock.yaml, *.lock, *.lockb, go.sum) are not read either: they are large, and they only change through the package manager. | | File search | settings.json sets CLAUDE_CODE_GLOB_NO_IGNORE=false, so Claude's file search respects .gitignore. By default it also lists ignored files such as node_modules and build output. | | ask rules | A person approves git push, git reset --hard, git clean, gh pr merge, CI workflow edits, and any command retried outside the sandbox. These prompt in every permission mode, including auto. | | Built into Claude Code | Writes to .claude/, .git/, .mcp.json, and shell startup files are never auto-approved. rm -rf on the project, home, or root is always stopped. settings.json also disables bypass-permissions mode. | | Checks hook | Your formatter and tests run without anyone remembering to. | | AGENTS.md | Conventions and intent. It shapes what agents try; it enforces nothing. |
There are no allow rules: nothing is pre-approved. Claude Code already runs read-only commands without asking, and saves your own "don't ask again" choices to .claude/settings.local.json.
No permission mode is pinned either. Use Manual, auto, or plan as you prefer; the deny and ask rules hold in all of them.
Bash(git push *) catches git push origin main but not git -C . push. The rules stop the usual form, not a determined workaround. For anything that must never happen, protect the branch on the remote.cat. A script that opens a file itself is only stopped by the OS sandbox.*.pem and *.key are denied wholesale.** Delete those two lines from settings.json if your repo keeps non-secret files with those extensions.npm ls <pkg>, pnpm why <pkg>), or delete the lockfile lines from settings.json.mods/ holds Claude Code mods that load into every session on the machine, whatever repo it runs in, the Desktop Code tab included. Each one fixes friction that kept repeating across real sessions:
| Mod | In short | | :- | :- | | shell-sense | Denies shell commands that are certain to fail on Windows, and says what works instead. | | package-gate | Asks you before any package install, with a registry link per package. | | secret-shield | Keeps secret files and token values out of shell reads and the transcript. | | repo-lock | Denies dependency changes with the wrong package manager or from the wrong folder. | | context-meter | A band above the prompt: context fill against quality marks (30% fading, 40% "dumb zone"), rate limits, and model and effort switchers. | | session-context | Tells Claude the repo, branch and uncommitted work with each prompt. |
Setup, the full behaviour of each, and how to work on them: mods/README.md.
node --test "tests/*.test.mjs"
The tests exercise checks.mjs, enforce the scaffold's own rules (file size limits, valid hook paths, rule syntax), and check that the mods' shared files match. Each mod also has its own tests: claude plugin test mods/<name>. Using another agent? See providers.md.
Earlier versions (per-language standards skills, command-guard hooks, the multi-provider scaffolds) live in git history.
hooks/register.ts 116 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { type Pm, type SpecFile, excerpt, newestSpec, notRepoLine, parseStatus, repoLine } from './rules'
4
5const LOCKFILES: [string, string][] = [
6 ['pnpm-lock.yaml', 'pnpm'],
7 ['package-lock.json', 'npm'],
8 ['yarn.lock', 'yarn'],
9 ['bun.lock', 'bun'],
10]
11const SKIP = new Set(['node_modules', '.git', '.claude', 'dist', '.astro', '.wrangler'])
12
13// What Claude last read, so an unchanged repo line is not sent again.
14let lastLine = ''
15let isFirstPrompt = true
16
17async function git($: EngineInterface, cwd: string, args: string[]): Promise<string | undefined> {
18 try {
19 const r = await $.process.run(['git', ...args], { cwd, timeoutMs: 8000 })
20 return r.exitCode === 0 ? r.stdout.trim() : undefined
21 } catch {
22 return undefined
23 }
24}
25
26// Every check below runs at once rather than one after another: this runs on each
27// typed prompt, and a folder of 15 subfolders was 60 file checks in a row.
28async function lockfileIn($: EngineInterface, dir: string): Promise<string | undefined> {
29 const found = await Promise.all(LOCKFILES.map(([file]) => $.fs.exists(`${dir}/${file}`).catch(() => false)))
30 const i = found.indexOf(true)
31 return i < 0 ? undefined : LOCKFILES[i][1]
32}
33
34// The package manager at the repo root, or in one folder below it (benhickman.dev/astro).
35async function packageManager($: EngineInterface, top: string): Promise<Pm | undefined> {
36 const atRoot = await lockfileIn($, top)
37 if (atRoot) return { name: atRoot, where: '' }
38 const entries = await $.fs.list(top).catch(() => [])
39 const dirs = entries.filter(x => x.kind === 'dir' && !SKIP.has(x.name)).slice(0, 15)
40 const names = await Promise.all(dirs.map(d => lockfileIn($, `${top}/${d.name}`)))
41 const i = names.findIndex(Boolean)
42 return i < 0 ? undefined : { name: names[i] as string, where: dirs[i].name }
43}
44
45async function specNote($: EngineInterface, top: string): Promise<string | undefined> {
46 const entries = await $.fs.list(`${top}/specs`).catch(() => [])
47 const found = await Promise.all(
48 entries.map(async (entry): Promise<SpecFile | undefined> => {
49 if (entry.kind === 'file' && entry.name.endsWith('.md')) return { path: `specs/${entry.name}`, mtimeMs: entry.mtimeMs }
50 if (entry.kind !== 'dir') return undefined
51 const status = await $.fs.stat(`${top}/specs/${entry.name}/status.md`).catch(() => undefined)
52 return status ? { path: `specs/${entry.name}/status.md`, mtimeMs: status.mtimeMs } : undefined
53 }),
54 )
55 const files = found.filter((f): f is SpecFile => f !== undefined)
56 const newest = newestSpec(files)
57 if (!newest) return undefined
58 const text = await $.fs.read(`${top}/${newest.path}`).catch(() => undefined)
59 return typeof text === 'string' ? `Latest spec, ${newest.path}:\n${excerpt(text)}` : undefined
60}
61
62type Snapshot = { line: string; extra: string[] }
63
64async function snapshot($: EngineInterface, withHistory: boolean): Promise<Snapshot> {
65 const cwd = await $.session.cwd()
66 // git status works from any folder in the repo, so it need not wait for the top.
67 const [top, statusOut] = await Promise.all([
68 git($, cwd, ['rev-parse', '--show-toplevel']),
69 git($, cwd, ['status', '--porcelain=v2', '--branch']),
70 ])
71 if (!top) {
72 const entries = await $.fs.list(cwd).catch(() => [])
73 const dirs = entries.filter(x => x.kind === 'dir' && !x.name.startsWith('.') && !x.name.startsWith('_'))
74 const isRepo = await Promise.all(dirs.map(d => $.fs.exists(`${cwd}/${d.name}/.git`).catch(() => false)))
75 return { line: notRepoLine(cwd, dirs.filter((_, i) => isRepo[i]).map(d => d.name)), extra: [] }
76 }
77 const [pm, log, note] = await Promise.all([
78 packageManager($, top),
79 withHistory ? git($, top, ['log', '-3', '--format=%h %s (%cr)']) : undefined,
80 withHistory ? specNote($, top) : undefined,
81 ])
82 const line = repoLine(top.split('/').pop() ?? top, pm, parseStatus(statusOut ?? ''))
83 const extra: string[] = []
84 if (log) extra.push(`Recent commits:\n${log}`)
85 if (note) extra.push(note)
86 return { line, extra }
87}
88
89export const register: Register = on => {
90 on('session.start', async ($, e, next) => {
91 await $.command.register({
92 name: 'where',
93 description: 'Show the repo, branch, uncommitted work, recent commits and latest spec for this session',
94 })
95 return next(e)
96 })
97
98 on('command.run', { command: 'where' }, async $ => {
99 const s = await snapshot($, true)
100 return { text: [s.line, ...s.extra].join('\n\n') }
101 })
102
103 // Typed prompts (and Remote Control ones) get the repo line when it changed,
104 // and the first prompt also gets recent commits and the latest spec note.
105 on('prompt.submit', async ($, e, next) => {
106 if (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge') return next(e)
107 const s = await snapshot($, isFirstPrompt).catch(() => undefined)
108 if (!s) return next(e)
109 const blocks = [s.line !== lastLine ? s.line : '', ...s.extra].filter(Boolean)
110 isFirstPrompt = false
111 lastLine = s.line
112 if (!blocks.length) return next(e)
113 return next({ ...e, context: [...(e.context ?? []), `session-context:\n${blocks.join('\n\n')}`] })
114 })
115}
116hooks/rules.ts 56 lines1// Pure logic for session-context: turn git and folder facts into the short
2// lines Claude reads with a prompt.
3
4export type GitStatus = {
5 branch: string
6 hasUpstream: boolean
7 ahead: number
8 behind: number
9 changed: number
10 untracked: number
11}
12
13// `git status --porcelain=v2 --branch`
14export function parseStatus(out: string): GitStatus {
15 const s: GitStatus = { branch: '?', hasUpstream: false, ahead: 0, behind: 0, changed: 0, untracked: 0 }
16 for (const line of out.split(/\r?\n/)) {
17 if (line.startsWith('# branch.head ')) s.branch = line.slice(14).trim()
18 else if (line.startsWith('# branch.upstream ')) s.hasUpstream = true
19 else if (line.startsWith('# branch.ab ')) {
20 const m = line.match(/\+(\d+) -(\d+)/)
21 if (m) [s.ahead, s.behind] = [Number(m[1]), Number(m[2])]
22 } else if (/^[12u] /.test(line)) s.changed++
23 else if (line.startsWith('? ')) s.untracked++
24 }
25 return s
26}
27
28export type Pm = { name: string; where: string } // where: '' for the repo root, else a subfolder
29
30export function repoLine(repo: string, pm: Pm | undefined, s: GitStatus): string {
31 const parts = [`Repo ${repo}${pm ? ` (${pm.name}${pm.where ? ` in ${pm.where}/` : ''})` : ''}`, `branch ${s.branch === '(detached)' ? 'detached HEAD' : s.branch}`]
32 const dirty = [s.changed && `${s.changed} changed`, s.untracked && `${s.untracked} untracked`].filter(Boolean)
33 parts.push(dirty.length ? dirty.join(', ') : 'clean')
34 if (!s.hasUpstream) parts.push('no upstream')
35 else if (s.ahead || s.behind) parts.push([s.ahead && `${s.ahead} ahead`, s.behind && `${s.behind} behind`].filter(Boolean).join(', ') + ' of upstream')
36 return parts.join(' · ')
37}
38
39export function notRepoLine(dir: string, repos: readonly string[]): string {
40 const list = repos.length ? ` It holds ${repos.length} repos (${repos.slice(0, 12).join(', ')}${repos.length > 12 ? ', ...' : ''}).` : ''
41 return `Folder ${dir} is not a git repo.${list} Start each repo command with an absolute cd, for example: cd /z/github_projects/<repo> && ...`
42}
43
44export type SpecFile = { path: string; mtimeMs: number }
45
46// The newest spec status: specs/<slug>/status.md, or a specs/<slug>.md file.
47export const newestSpec = (files: readonly SpecFile[]) =>
48 [...files].sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
49
50// The first lines of a note, front matter and blank lines dropped.
51export function excerpt(text: string, maxLines = 12): string {
52 const body = text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '')
53 const lines = body.split(/\r?\n/).filter(l => l.trim())
54 return lines.slice(0, maxLines).join('\n') + (lines.length > maxLines ? '\n...' : '')
55}
56