SLOPSHOPPER

session-context

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.

newcommandpromptprocess
v0.1.0no licenseupdated 2026-10-08cleverfakealias/agents/mods/session-context
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-context
› 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 › /where ⎿ session-context: Repo app (bun) · branch ? · clean · no upstream ⎿ session-context: ⎿ session-context: Recent commits: ⎿ session-context: a1b2c3d fix: refresh expired tokens (2 minutes ago) ⎿ session-context: 9f8e7d6 feat: add audit log (3 hours ago) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

agents — a clean starting point for agentic development

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.

What's in the box

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

Setup

1. Copy the scaffold into your repo

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.

2. Fill in AGENTS.md

Replace each <!-- placeholder --> with the project's name, stack, and real commands, and delete the sections you don't need.

3. Tell the checks hook what to run

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.
  • Commands run from the repo root. A command whose tool isn't installed is skipped. CLAUDE_SKIP_CHECKS=1 claude turns the hook off for a session.

4. Turn on the OS sandbox (optional)

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.

How the guardrails work

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

Limits worth knowing

  • Shell rules match the command as written. 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.
  • Read rules don't see inside scripts. They cover Claude's file tools and common shell readers such as cat. A script that opens a file itself is only stopped by the OS sandbox.
  • The sandbox doesn't run on native Windows. Use WSL2 or a dev container there if you need real isolation.
  • ***.pem and *.key are denied wholesale.** Delete those two lines from settings.json if your repo keeps non-secret files with those extensions.
  • Lockfiles can't be read. To check a resolved version, ask the package manager (npm ls <pkg>, pnpm why <pkg>), or delete the lockfile lines from settings.json.

User-level mods

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.

Working on this repo

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.

Source 2 files
hooks/register.ts 116 lines
1import 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}
116
hooks/rules.ts 56 lines
1// 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