diff --git a/.claude/agents/security-reviewer.md b/.claude/agents/fleet/security-reviewer.md similarity index 98% rename from .claude/agents/security-reviewer.md rename to .claude/agents/fleet/security-reviewer.md index 9388ed1..744583a 100644 --- a/.claude/agents/security-reviewer.md +++ b/.claude/agents/fleet/security-reviewer.md @@ -1,6 +1,7 @@ --- name: security-reviewer description: Reviews findings from AgentShield + zizmor against the project's CLAUDE.md security rules and grades the result A-F. Spawned by the scanning-security skill after the static scans run. +model: claude-opus-4-8 tools: Read, Grep, Glob, Bash(git:*), Bash(rg:*), Bash(grep:*), Bash(find:*), Bash(ls:*), Bash(pnpm exec agentshield:*), Bash(zizmor:*), Bash(command -v:*), Bash(cat:*), Bash(head:*), Bash(tail:*) --- diff --git a/.claude/commands/audit-gha-settings.md b/.claude/commands/fleet/audit-gha-settings.md similarity index 100% rename from .claude/commands/audit-gha-settings.md rename to .claude/commands/fleet/audit-gha-settings.md diff --git a/.claude/commands/green-ci.md b/.claude/commands/fleet/green-ci.md similarity index 100% rename from .claude/commands/green-ci.md rename to .claude/commands/fleet/green-ci.md diff --git a/.claude/commands/quality-loop.md b/.claude/commands/fleet/quality-loop.md similarity index 100% rename from .claude/commands/quality-loop.md rename to .claude/commands/fleet/quality-loop.md diff --git a/.claude/commands/security-scan.md b/.claude/commands/fleet/security-scan.md similarity index 100% rename from .claude/commands/security-scan.md rename to .claude/commands/fleet/security-scan.md diff --git a/.claude/commands/setup-security-tools.md b/.claude/commands/fleet/setup-security-tools.md similarity index 92% rename from .claude/commands/setup-security-tools.md rename to .claude/commands/fleet/setup-security-tools.md index 009773c..6f1f739 100644 --- a/.claude/commands/setup-security-tools.md +++ b/.claude/commands/fleet/setup-security-tools.md @@ -25,7 +25,7 @@ If they don't, proceed with SFW free mode. Then run: ```bash -node .claude/hooks/setup-security-tools/index.mts +node .claude/hooks/fleet/setup-security-tools/index.mts ``` After the script completes, add the SFW shim directory to PATH: @@ -42,4 +42,4 @@ export PATH="$HOME/.socket/_wheelhouse/shims:$PATH" - SFW binary is cached via dlx at `~/.socket/_dlx/` - SFW shims are shared across repos at `~/.socket/_wheelhouse/shims/` - `.env.local` must NEVER be committed -- `/update` will check for new versions of these tools via `node .claude/hooks/setup-security-tools/update.mts` +- `/update` will check for new versions of these tools via `node .claude/hooks/fleet/setup-security-tools/update.mts` diff --git a/.claude/commands/squash-history.md b/.claude/commands/fleet/squash-history.md similarity index 100% rename from .claude/commands/squash-history.md rename to .claude/commands/fleet/squash-history.md diff --git a/.claude/commands/update-security.md b/.claude/commands/fleet/update-security.md similarity index 100% rename from .claude/commands/update-security.md rename to .claude/commands/fleet/update-security.md diff --git a/.claude/hooks/_shared/README.md b/.claude/hooks/fleet/_shared/README.md similarity index 100% rename from .claude/hooks/_shared/README.md rename to .claude/hooks/fleet/_shared/README.md diff --git a/.claude/hooks/_shared/acorn/README.md b/.claude/hooks/fleet/_shared/acorn/README.md similarity index 90% rename from .claude/hooks/_shared/acorn/README.md rename to .claude/hooks/fleet/_shared/acorn/README.md index e039213..e0bf5f2 100644 --- a/.claude/hooks/_shared/acorn/README.md +++ b/.claude/hooks/fleet/_shared/acorn/README.md @@ -1,4 +1,4 @@ -# acorn-wasm — shared parser for fleet hooks +# acorn — shared wasm parser for fleet hooks Vendored from [`@ultrathink/acorn-monorepo`](https://github.com/SocketDev/ultrathink/tree/main/packages/acorn)'s @@ -13,7 +13,7 @@ The three vendored files come straight from the ultrathink prod build: - `acorn.wasm` — compiled Rust acorn parser, ~3.3 MB. - `acorn-bindgen.cjs` — wasm-bindgen JS glue. -- `acorn-wasm-sync.mts` — sync ESM loader (no top-level await, +- `acorn-sync.mts` — sync ESM loader (no top-level await, `WebAssembly.Instance` constructed at module import). The artifact is rebuilt in ultrathink with `pnpm run @@ -39,12 +39,12 @@ Last refreshed: 2026-05-20 (ultrathink build dated 2026-05-20). ## Public surface -`template/.claude/hooks/_shared/acorn/index.mts` is the canonical +`template/.claude/hooks/fleet/_shared/acorn/index.mts` is the canonical import path for fleet hooks. It re-exports a narrow `tryParse` / `walkSimple` / `findBareCallsTo` surface — see the module's JSDoc for the parse-failure tolerance + visitor patterns hook authors rely on. -Don't import `acorn-wasm-sync.mts` directly from hooks; the `index.mts` +Don't import `acorn-sync.mts` directly from hooks; the `index.mts` wrapper provides the failure-handling + visitor adapters every hook needs. diff --git a/.claude/hooks/_shared/acorn/acorn-bindgen.cjs b/.claude/hooks/fleet/_shared/acorn/acorn-bindgen.cjs similarity index 100% rename from .claude/hooks/_shared/acorn/acorn-bindgen.cjs rename to .claude/hooks/fleet/_shared/acorn/acorn-bindgen.cjs diff --git a/.claude/hooks/_shared/acorn/acorn-wasm-sync.mts b/.claude/hooks/fleet/_shared/acorn/acorn-sync.mts similarity index 100% rename from .claude/hooks/_shared/acorn/acorn-wasm-sync.mts rename to .claude/hooks/fleet/_shared/acorn/acorn-sync.mts diff --git a/.claude/hooks/_shared/acorn/acorn.wasm b/.claude/hooks/fleet/_shared/acorn/acorn.wasm similarity index 100% rename from .claude/hooks/_shared/acorn/acorn.wasm rename to .claude/hooks/fleet/_shared/acorn/acorn.wasm diff --git a/.claude/hooks/_shared/acorn/index.mts b/.claude/hooks/fleet/_shared/acorn/index.mts similarity index 98% rename from .claude/hooks/_shared/acorn/index.mts rename to .claude/hooks/fleet/_shared/acorn/index.mts index 92f7421..f09403f 100644 --- a/.claude/hooks/_shared/acorn/index.mts +++ b/.claude/hooks/fleet/_shared/acorn/index.mts @@ -1,14 +1,14 @@ /** * @file Shared acorn-wasm wrapper for fleet hooks. Vendored from - * socket-lib/vendor/acorn-wasm pending the `@ultrathink/acorn` npm publish; - * once that lands, fleet hooks switch to the published package and this - * directory can be retired. Surface kept narrow: `parse(source, opts)` for - * raw AST + `simple(source, visitors, opts)` for visitor-based walks. - * Higher-level shape detectors (`findCallsTo`, `findBareCallsTo`) cover the - * common "lint a specific identifier call" pattern that hooks need. + * socket-lib/vendor/acorn pending the `@ultrathink/acorn` npm publish; once + * that lands, fleet hooks switch to the published package and this directory + * can be retired. Surface kept narrow: `parse(source, opts)` for raw AST + + * `simple(source, visitors, opts)` for visitor-based walks. Higher-level + * shape detectors (`findCallsTo`, `findBareCallsTo`) cover the common "lint a + * specific identifier call" pattern that hooks need. */ -import { parse as wasmParse, simple as wasmSimple } from './acorn-wasm-sync.mts' +import { parse as wasmParse, simple as wasmSimple } from './acorn-sync.mts' export interface AcornNode { type: string diff --git a/.claude/hooks/_shared/fleet-repos.mts b/.claude/hooks/fleet/_shared/fleet-repos.mts similarity index 62% rename from .claude/hooks/_shared/fleet-repos.mts rename to .claude/hooks/fleet/_shared/fleet-repos.mts index b16ca05..3234460 100644 --- a/.claude/hooks/_shared/fleet-repos.mts +++ b/.claude/hooks/fleet/_shared/fleet-repos.mts @@ -1,18 +1,17 @@ /** - * @file Single source of truth for fleet-repo membership, shared by the - * hooks that need to know "is this one of ours?": + * @file Single source of truth for fleet-repo membership, shared by the hooks + * that need to know "is this one of ours?": * * - `cross-repo-guard` — blocks `..//…` sibling-path imports. - * - `no-non-fleet-push-guard` — blocks `git push` to a repo not in the - * fleet (a non-fleet repo never has the fleet hook chain installed, so - * the guard has to live agent-side and know the roster itself). - * - * This is the BROAD membership set, intentionally wider than the cascade - * roster in `cascading-fleet/lib/fleet-repos.json` (which lists only - * template-cascade targets and omits e.g. `ultrathink`). Membership here - * answers "may fleet tooling act on this repo at all", not "does the - * wheelhouse cascade into it". Keep the two distinct: a repo can be a - * fleet member (pushable, importable) without being a cascade target. + * - `no-non-fleet-push-guard` — blocks `git push` to a repo not in the fleet (a + * non-fleet repo never has the fleet hook chain installed, so the guard has + * to live agent-side and know the roster itself). This is the BROAD + * membership set, intentionally wider than the cascade roster in + * `cascading-fleet/lib/fleet-repos.json` (which lists only template-cascade + * targets and omits e.g. `ultrathink`). Membership here answers "may fleet + * tooling act on this repo at all", not "does the wheelhouse cascade into + * it". Keep the two distinct: a repo can be a fleet member (pushable, + * importable) without being a cascade target. */ // All under the SocketDev org. Names match the GitHub repo slug @@ -40,24 +39,24 @@ const FLEET_REPO_SET: ReadonlySet = new Set(FLEET_REPO_NAMES) /** * True when `slug` (a bare repo name like `socket-cli`) is a fleet member. - * Case-insensitive — GitHub slugs are case-insensitive and remotes can be - * typed in any case. + * Case-insensitive — GitHub slugs are case-insensitive and remotes can be typed + * in any case. */ export function isFleetRepo(slug: string): boolean { return FLEET_REPO_SET.has(slug.toLowerCase()) } /** - * Extract the bare repo slug from a git remote URL, or `undefined` when the - * URL isn't a recognizable GitHub remote. Handles the three forms git emits: + * Extract the bare repo slug from a git remote URL, or `undefined` when the URL + * isn't a recognizable GitHub remote. Handles the three forms git emits: * - * git@github.com:SocketDev/socket-cli.git (SSH scp-like) - * ssh://git@github.com/SocketDev/socket-cli.git (SSH URL) - * https://github.com/SocketDev/socket-cli.git (HTTPS, optional .git) + * Git@github.com:SocketDev/socket-cli.git (SSH scp-like) + * ssh://git@github.com/SocketDev/socket-cli.git (SSH URL) + * https://github.com/SocketDev/socket-cli.git (HTTPS, optional .git) * * Returns the slug only (`socket-cli`), lowercased. The owner is dropped on - * purpose: membership is keyed on the repo name, and a fork under a - * different owner is still not a fleet push target. + * purpose: membership is keyed on the repo name, and a fork under a different + * owner is still not a fleet push target. */ export function slugFromRemoteUrl(url: string): string | undefined { const trimmed = url.trim() diff --git a/.claude/hooks/_shared/foreign-paths.mts b/.claude/hooks/fleet/_shared/foreign-paths.mts similarity index 78% rename from .claude/hooks/_shared/foreign-paths.mts rename to .claude/hooks/fleet/_shared/foreign-paths.mts index e691c94..4ef3cf0 100644 --- a/.claude/hooks/_shared/foreign-paths.mts +++ b/.claude/hooks/fleet/_shared/foreign-paths.mts @@ -1,24 +1,23 @@ /** * @file Shared heuristic for "which dirty paths in this checkout were authored - * by ANOTHER agent, not this session". Two responsibilities the parallel-agent - * hooks (and overeager-staging-guard) share: + * by ANOTHER agent, not this session". Two responsibilities the + * parallel-agent hooks (and overeager-staging-guard) share: * - * 1. `readTouchedPaths(transcriptPath)` — the set of absolute paths THIS - * session modified: Edit / Write `file_path` targets plus `git add|mv|rm - * ` arguments parsed out of Bash commands. Lifted here from + * 1. `readTouchedPaths(transcriptPath)` — the set of absolute paths THIS session + * modified: Edit / Write `file_path` targets plus `git add|mv|rm ` + * arguments parsed out of Bash commands. Lifted here from * overeager-staging-guard so the three consumers share one implementation * instead of drifting copies. - * 2. `listForeignDirtyPaths(repoDir, touched, opts)` — dirty paths - * (`git status --porcelain`) that this session did NOT touch and whose - * mtime is recent (so stale pre-session dirt doesn't false-fire). These are - * the likely fingerprints of a concurrent Claude session sharing the - * `.git/` — the failure mode where `git add -A` / `git stash` / `git - * reset --hard` would sweep up or destroy another agent's work. - * - * Fail-open contract (matches the rest of `_shared/`): every helper returns a - * safe default on any parse / I/O error rather than throwing. A hook that - * crashes wedges every Claude Code call; one that returns "nothing foreign" - * simply falls through to the hook's default decision. + * 2. `listForeignDirtyPaths(repoDir, touched, opts)` — dirty paths (`git status + * --porcelain`) that this session did NOT touch and whose mtime is recent + * (so stale pre-session dirt doesn't false-fire). These are the likely + * fingerprints of a concurrent Claude session sharing the `.git/` — the + * failure mode where `git add -A` / `git stash` / `git reset --hard` would + * sweep up or destroy another agent's work. Fail-open contract (matches + * the rest of `_shared/`): every helper returns a safe default on any + * parse / I/O error rather than throwing. A hook that crashes wedges every + * Claude Code call; one that returns "nothing foreign" simply falls + * through to the hook's default decision. */ import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' @@ -45,9 +44,13 @@ const UNTRACKED_BY_DEFAULT_PREFIXES = [ const DEFAULT_MAX_AGE_MS = 30 * 60 * 1000 export interface ForeignPathsOptions { - /** Max age (ms) of a dirty path's mtime to count as foreign. */ + /** + * Max age (ms) of a dirty path's mtime to count as foreign. + */ readonly maxAgeMs?: number | undefined - /** Injectable clock for tests. Defaults to `Date.now()`. */ + /** + * Injectable clock for tests. Defaults to `Date.now()`. + */ readonly now?: number | undefined } @@ -62,10 +65,10 @@ export function isUntrackedByDefault(p: string): boolean { /** * Parse `git add|mv|rm ` arguments out of a Bash command line and add the - * resolved absolute paths to `touched`. Broad forms (`git add .` / `-A`) are NOT - * surgical adds and are skipped — they don't establish authorship of a specific - * file. Tolerates leading `NAME=val` env assignments and `&&` / `;` / `|` - * chains. + * resolved absolute paths to `touched`. Broad forms (`git add .` / `-A`) are + * NOT surgical adds and are skipped — they don't establish authorship of a + * specific file. Tolerates leading `NAME=val` env assignments and `&&` / `;` / + * `|` chains. */ export function addTouchedFromBash( command: string, @@ -173,7 +176,7 @@ export interface DirtyEntry { /** * Parse `git status --porcelain` output, dropping untracked-by-default trees. - * Rename entries (`R old -> new`) resolve to the new path. + * Rename entries (`R old -> new`) resolve to the new path. */ export function parsePorcelain(out: string): DirtyEntry[] { const entries: DirtyEntry[] = [] @@ -196,11 +199,11 @@ export function parsePorcelain(out: string): DirtyEntry[] { /** * Dirty paths this session did NOT author and that changed recently — the * fingerprint of a concurrent agent on the same `.git/`. A path qualifies when: - * - it's dirty (modified / deleted / untracked, minus vendored trees), AND - * - its resolved absolute path is not in `touched`, AND - * - its on-disk mtime is within `maxAgeMs` of `now`. - * Deleted paths (no mtime) are included only if their status is `D`/`R` — a - * delete by another agent is still foreign. Returns repo-relative paths. + * - it's dirty (modified / deleted / untracked, minus vendored trees), AND - + * its resolved absolute path is not in `touched`, AND - its on-disk mtime is + * within `maxAgeMs` of `now`. Deleted paths (no mtime) are included only if + * their status is `D`/`R` — a delete by another agent is still foreign. Returns + * repo-relative paths. */ export function listForeignDirtyPaths( repoDir: string, diff --git a/.claude/hooks/_shared/hook-env.mts b/.claude/hooks/fleet/_shared/hook-env.mts similarity index 100% rename from .claude/hooks/_shared/hook-env.mts rename to .claude/hooks/fleet/_shared/hook-env.mts diff --git a/.claude/hooks/_shared/markers.mts b/.claude/hooks/fleet/_shared/markers.mts similarity index 100% rename from .claude/hooks/_shared/markers.mts rename to .claude/hooks/fleet/_shared/markers.mts diff --git a/.claude/hooks/_shared/payload.mts b/.claude/hooks/fleet/_shared/payload.mts similarity index 100% rename from .claude/hooks/_shared/payload.mts rename to .claude/hooks/fleet/_shared/payload.mts diff --git a/.claude/hooks/_shared/shell-command.mts b/.claude/hooks/fleet/_shared/shell-command.mts similarity index 98% rename from .claude/hooks/_shared/shell-command.mts rename to .claude/hooks/fleet/_shared/shell-command.mts index 9a4e6ea..6ba9ed2 100644 --- a/.claude/hooks/_shared/shell-command.mts +++ b/.claude/hooks/fleet/_shared/shell-command.mts @@ -248,7 +248,12 @@ export function detectBroadGitAdd(command: string): string | undefined { } for (let k = 0, { length } = c.args; k < length; k += 1) { const arg = c.args[k]! - if (arg === '--all' || arg === '-A' || arg === '--update' || arg === '-u') { + if ( + arg === '--all' || + arg === '-A' || + arg === '--update' || + arg === '-u' + ) { return `git add ${arg}` } if (arg === '.') { diff --git a/.claude/hooks/_shared/stop-reminder.mts b/.claude/hooks/fleet/_shared/stop-reminder.mts similarity index 100% rename from .claude/hooks/_shared/stop-reminder.mts rename to .claude/hooks/fleet/_shared/stop-reminder.mts diff --git a/.claude/hooks/_shared/test/fleet-repos.test.mts b/.claude/hooks/fleet/_shared/test/fleet-repos.test.mts similarity index 100% rename from .claude/hooks/_shared/test/fleet-repos.test.mts rename to .claude/hooks/fleet/_shared/test/fleet-repos.test.mts diff --git a/.claude/hooks/_shared/test/foreign-paths.test.mts b/.claude/hooks/fleet/_shared/test/foreign-paths.test.mts similarity index 100% rename from .claude/hooks/_shared/test/foreign-paths.test.mts rename to .claude/hooks/fleet/_shared/test/foreign-paths.test.mts diff --git a/.claude/hooks/_shared/test/shell-command.test.mts b/.claude/hooks/fleet/_shared/test/shell-command.test.mts similarity index 85% rename from .claude/hooks/_shared/test/shell-command.test.mts rename to .claude/hooks/fleet/_shared/test/shell-command.test.mts index 2d8b6ff..8b15379 100644 --- a/.claude/hooks/_shared/test/shell-command.test.mts +++ b/.claude/hooks/fleet/_shared/test/shell-command.test.mts @@ -49,31 +49,51 @@ test('parseCommands: comments dropped', () => { }) test('findInvocation: matches plain git push', () => { - assert.ok(findInvocation('git push origin main', { binary: 'git', subcommand: 'push' })) + assert.ok( + findInvocation('git push origin main', { + binary: 'git', + subcommand: 'push', + }), + ) }) test('findInvocation: matches git -C push (subcommand after option value)', () => { - assert.ok(findInvocation('git -C /x push', { binary: 'git', subcommand: 'push' })) + assert.ok( + findInvocation('git -C /x push', { binary: 'git', subcommand: 'push' }), + ) }) test('findInvocation: matches git -c k=v push', () => { - assert.ok(findInvocation('git -c foo=bar push', { binary: 'git', subcommand: 'push' })) + assert.ok( + findInvocation('git -c foo=bar push', { + binary: 'git', + subcommand: 'push', + }), + ) }) test('findInvocation: matches push reached via && chain', () => { assert.ok( - findInvocation('cd /x/depot && git push', { binary: 'git', subcommand: 'push' }), + findInvocation('cd /x/depot && git push', { + binary: 'git', + subcommand: 'push', + }), ) }) test('findInvocation: matches push in a pipe chain', () => { assert.ok( - findInvocation('ls | grep x && git push', { binary: 'git', subcommand: 'push' }), + findInvocation('ls | grep x && git push', { + binary: 'git', + subcommand: 'push', + }), ) }) test('findInvocation: a different subcommand does not match', () => { - assert.ok(!findInvocation('git status', { binary: 'git', subcommand: 'push' })) + assert.ok( + !findInvocation('git status', { binary: 'git', subcommand: 'push' }), + ) }) test('findInvocation: quoted "git push" in a commit message is NOT a push', () => { @@ -122,7 +142,9 @@ test('commandsFor: binary-in-a-path is NOT the binary', () => { }) test('invocationHasFlag: exact flag', () => { - assert.ok(invocationHasFlag('codex --write prompt', 'codex', ['--write', '-w'])) + assert.ok( + invocationHasFlag('codex --write prompt', 'codex', ['--write', '-w']), + ) assert.ok(invocationHasFlag('codex -w prompt', 'codex', ['--write', '-w'])) }) diff --git a/.claude/hooks/_shared/test/transcript.test.mts b/.claude/hooks/fleet/_shared/test/transcript.test.mts similarity index 100% rename from .claude/hooks/_shared/test/transcript.test.mts rename to .claude/hooks/fleet/_shared/test/transcript.test.mts diff --git a/.claude/hooks/_shared/token-patterns.mts b/.claude/hooks/fleet/_shared/token-patterns.mts similarity index 98% rename from .claude/hooks/_shared/token-patterns.mts rename to .claude/hooks/fleet/_shared/token-patterns.mts index bd91c3f..a17656d 100644 --- a/.claude/hooks/_shared/token-patterns.mts +++ b/.claude/hooks/fleet/_shared/token-patterns.mts @@ -195,9 +195,9 @@ export function isTokenKey(key: string): boolean { * inspection). * * Kept short to minimize false positives. A "PASSWORD" mention in a - * commit-message body would otherwise trip every commit, so token-guard - * narrows matches to assignment / flag-value positions rather than any - * occurrence in arbitrary text. + * commit-message body would otherwise trip every commit, so token-guard narrows + * matches to assignment / flag-value positions rather than any occurrence in + * arbitrary text. */ export const SENSITIVE_NAME_FRAGMENTS: readonly string[] = [ 'TOKEN', diff --git a/.claude/hooks/_shared/transcript.mts b/.claude/hooks/fleet/_shared/transcript.mts similarity index 100% rename from .claude/hooks/_shared/transcript.mts rename to .claude/hooks/fleet/_shared/transcript.mts diff --git a/.claude/hooks/_shared/wheelhouse-root.mts b/.claude/hooks/fleet/_shared/wheelhouse-root.mts similarity index 100% rename from .claude/hooks/_shared/wheelhouse-root.mts rename to .claude/hooks/fleet/_shared/wheelhouse-root.mts diff --git a/.claude/hooks/actionlint-on-workflow-edit/README.md b/.claude/hooks/fleet/actionlint-on-workflow-edit/README.md similarity index 100% rename from .claude/hooks/actionlint-on-workflow-edit/README.md rename to .claude/hooks/fleet/actionlint-on-workflow-edit/README.md diff --git a/.claude/hooks/actionlint-on-workflow-edit/index.mts b/.claude/hooks/fleet/actionlint-on-workflow-edit/index.mts similarity index 100% rename from .claude/hooks/actionlint-on-workflow-edit/index.mts rename to .claude/hooks/fleet/actionlint-on-workflow-edit/index.mts diff --git a/.claude/hooks/actionlint-on-workflow-edit/package.json b/.claude/hooks/fleet/actionlint-on-workflow-edit/package.json similarity index 100% rename from .claude/hooks/actionlint-on-workflow-edit/package.json rename to .claude/hooks/fleet/actionlint-on-workflow-edit/package.json diff --git a/.claude/hooks/actionlint-on-workflow-edit/test/index.test.mts b/.claude/hooks/fleet/actionlint-on-workflow-edit/test/index.test.mts similarity index 100% rename from .claude/hooks/actionlint-on-workflow-edit/test/index.test.mts rename to .claude/hooks/fleet/actionlint-on-workflow-edit/test/index.test.mts diff --git a/.claude/hooks/actionlint-on-workflow-edit/tsconfig.json b/.claude/hooks/fleet/actionlint-on-workflow-edit/tsconfig.json similarity index 100% rename from .claude/hooks/actionlint-on-workflow-edit/tsconfig.json rename to .claude/hooks/fleet/actionlint-on-workflow-edit/tsconfig.json diff --git a/.claude/hooks/fleet/alpha-sort-reminder/README.md b/.claude/hooks/fleet/alpha-sort-reminder/README.md new file mode 100644 index 0000000..085fce6 --- /dev/null +++ b/.claude/hooks/fleet/alpha-sort-reminder/README.md @@ -0,0 +1,46 @@ +# alpha-sort-reminder + +PreToolUse Edit/Write hook that nudges (never blocks) when a non-code file edit +introduces a sibling block that looks unsorted. oxlint only sees JS/TS, so the +`socket/sort-*` lint rules can't reach JSON / YAML / markdown / bash. This hook +covers those surfaces per [`docs/claude.md/fleet/sorting.md`](../../../../docs/claude.md/fleet/sorting.md). + +## What it flags + +| Surface | Detects | Key shape | +| -------------------------------------------------- | ------------------------------------------------------------------------- | ----------- | +| JSON / JSONC (`.json`, `.jsonc`, `.oxlintrc.json`) | runs of object keys at one indent, out of ASCII order | `"name": …` | +| YAML (`.yml`, `.yaml`) | runs of mapping keys at one indent (`env:` / `with:` / matrix) | `name:` | +| Markdown (`.md`, `.markdown`) | runs of `-`/`*` bullets out of order; bullets ending in `…`/`...` | `- text` | +| Bash (`.sh`, `.bash`) | runs of all-caps `NAME=…` assignments out of order (cache-key var blocks) | `NAME=…` | + +Detection is conservative: **3+** adjacent siblings at the same indent, ASCII +byte order only. False quiet beats false nag: a missed block is a review catch, +while a wrong nag trains the agent to ignore the hook. + +## Trigger + +Fires on `Edit` / `Write` tool calls. Reads `tool_input.file_path` + +`content`/`new_string` from the PreToolUse payload on stdin. Always exits 0; the +reminder is informational on stderr. + +## Bypass + +No phrase; the hook never blocks. Silence it entirely with the env var +`SOCKET_ALPHA_SORT_REMINDER_DISABLED=1`. For a genuinely order-bearing block, +just leave it unsorted and state the reason inline (the hook is advisory; review +honors the stated reason). + +## Why + +John-David has asked for alphanumeric sorting across every file type repeatedly +(2026-04-17 → 2026-05-29: JSON config keys, README consumer lists, workflow YAML +matrix + bash cache-key vars, "no ellipsis"). Code surfaces got lint rules; the +non-code surfaces had no enforcement. This hook closes that gap at edit time. + +## Companion files + +- `index.mts` — the hook; `findUnsortedBlocks(filePath, content)` is the pure, + exported detector. +- `test/index.test.mts` — node:test specs. +- `package.json` — workspace declaration so `taze` can see the hook's deps. diff --git a/.claude/hooks/fleet/alpha-sort-reminder/index.mts b/.claude/hooks/fleet/alpha-sort-reminder/index.mts new file mode 100644 index 0000000..477a4ce --- /dev/null +++ b/.claude/hooks/fleet/alpha-sort-reminder/index.mts @@ -0,0 +1,249 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — alpha-sort-reminder. +// +// Nudges (never blocks) when an Edit/Write to a non-code file introduces a +// block of sibling items that looks unsorted. oxlint only sees JS/TS, so the +// `socket/sort-*` lint rules can't reach JSON / YAML / markdown / bash — this +// hook covers those surfaces per `docs/claude.md/fleet/sorting.md`: +// +// - JSON / JSONC: runs of `"key":` lines at one indent, ASCII order. +// - YAML: runs of `key:` mapping lines at one indent (env:/with:/matrix). +// - Markdown: runs of `-`/`*` bullets; also flags trailing-ellipsis lines. +// - Bash: runs of `NAME=...` assignments (cache-key var blocks). +// +// Detection is deliberately conservative: 3+ adjacent siblings at the same +// indent, and only ASCII-comparison. False quiet beats false nag — a missed +// block is a review catch, a wrong nag trains the agent to ignore the hook. +// Always exits 0; the message is informational on stderr. +// +// Disable via SOCKET_ALPHA_SORT_REMINDER_DISABLED. + +import path from 'node:path' +import process from 'node:process' + +import { readStdin } from '../_shared/transcript.mts' + +type ToolInput = { + tool_input?: + | { + content?: string | undefined + file_path?: string | undefined + new_string?: string | undefined + } + | undefined + tool_name?: string | undefined +} + +export interface SortFinding { + surface: 'json' | 'yaml' | 'markdown' | 'bash' + hint: string +} + +// Minimum sibling count before a run is worth flagging. Two-item runs carry +// too little signal (and are often guard pairs); 3+ is unambiguously a list. +const MIN_RUN = 3 + +// ASCII byte order, ascending. Returns true when already sorted. +function isAscadSorted(keys: readonly string[]): boolean { + for (let i = 1; i < keys.length; i += 1) { + if (keys[i - 1]! > keys[i]!) { + return false + } + } + return true +} + +// Leading-whitespace width of a line (spaces only; tabs count as one). +function indentOf(line: string): number { + const m = line.match(/^(\s*)/) + return m ? m[1]!.length : 0 +} + +// Walk lines, grouping maximal runs of lines that (a) match `keyFor` to a +// non-undefined key and (b) share the same indent as the run's first line. +// Calls back with each run's keys. Blank lines and non-matching lines break a +// run. +function scanRuns( + lines: readonly string[], + keyFor: (line: string) => string | undefined, + onRun: (keys: string[]) => void, +): void { + let runKeys: string[] = [] + let runIndent = -1 + const flush = () => { + if (runKeys.length >= MIN_RUN) { + onRun(runKeys) + } + runKeys = [] + runIndent = -1 + } + for (const line of lines) { + const key = keyFor(line) + if (key === undefined) { + flush() + continue + } + const ind = indentOf(line) + if (runKeys.length === 0) { + runIndent = ind + runKeys.push(key) + } else if (ind === runIndent) { + runKeys.push(key) + } else { + flush() + runIndent = ind + runKeys.push(key) + } + } + flush() +} + +// JSON / JSONC object keys: `"name": ...` (allow trailing comma). +function jsonKey(line: string): string | undefined { + const m = line.match(/^\s*"([^"]+)"\s*:/) + return m ? m[1] : undefined +} + +// YAML mapping keys: `name:` at line start (not a `- ` sequence item, not a +// comment). Skips document markers and key-less lines. +function yamlKey(line: string): string | undefined { + if (/^\s*#/.test(line) || /^\s*-/.test(line)) { + return undefined + } + const m = line.match(/^\s*([A-Za-z0-9_.-]+)\s*:(\s|$)/) + return m ? m[1] : undefined +} + +// Markdown bullets: `- text` / `* text`. Returns the text after the marker. +function mdBullet(line: string): string | undefined { + const m = line.match(/^\s*[-*]\s+(.*\S)\s*$/) + if (!m) { + return undefined + } + // Skip task-list checkboxes and nested numbered intent. + return m[1]!.toLowerCase() +} + +// Bash all-caps assignments: `NAME=...` (cache-key var style). +function bashAssign(line: string): string | undefined { + const m = line.match(/^\s*([A-Z][A-Z0-9_]+)=/) + return m ? m[1] : undefined +} + +/** + * Inspect file content for likely-unsorted sibling blocks. Pure — no I/O. + * Returns a finding per surface that looks unsorted (deduped by surface). + */ +export function findUnsortedBlocks( + filePath: string, + content: string, +): SortFinding[] { + const ext = path.extname(filePath).toLowerCase() + const base = path.basename(filePath).toLowerCase() + const lines = content.split('\n') + const findings: SortFinding[] = [] + let pushed = false + const note = (surface: SortFinding['surface'], hint: string) => { + if (!pushed) { + findings.push({ surface, hint }) + pushed = true + } + } + + if (ext === '.json' || ext === '.jsonc' || base === '.oxlintrc.json') { + scanRuns(lines, jsonKey, keys => { + if (!isAscadSorted(keys)) { + note( + 'json', + `object keys out of order near: ${keys.slice(0, 4).join(', ')}…`, + ) + } + }) + } else if (ext === '.yml' || ext === '.yaml') { + scanRuns(lines, yamlKey, keys => { + if (!isAscadSorted(keys)) { + note( + 'yaml', + `mapping keys out of order near: ${keys.slice(0, 4).join(', ')}…`, + ) + } + }) + } else if (ext === '.md' || ext === '.markdown') { + scanRuns(lines, mdBullet, keys => { + if (!isAscadSorted(keys)) { + note( + 'markdown', + `bullet list out of order near: ${keys.slice(0, 3).join('; ')}…`, + ) + } + }) + if (!pushed && /^\s*[-*]\s+.*(\.\.\.|…)\s*$/m.test(content)) { + note( + 'markdown', + 'a bullet ends in an ellipsis — list every item or write "N items, see "', + ) + } + } else if (ext === '.sh' || ext === '.bash' || base.endsWith('.bash')) { + scanRuns(lines, bashAssign, keys => { + if (!isAscadSorted(keys)) { + note( + 'bash', + `variable assignments out of order near: ${keys.slice(0, 4).join(', ')}…`, + ) + } + }) + } + return findings +} + +function emit(filePath: string, findings: readonly SortFinding[]): void { + const lines = [ + `[alpha-sort-reminder] ${path.basename(filePath)} may have an unsorted list:`, + ] + for (const f of findings) { + lines.push(` • (${f.surface}) ${f.hint}`) + } + lines.push( + ' Sort sibling items alphanumerically (ASCII order) unless order is load-bearing.', + ' Fully re-sort the block when you touch it. See docs/claude.md/fleet/sorting.md.', + ) + process.stderr.write(lines.join('\n') + '\n') +} + +async function main(): Promise { + if (process.env['SOCKET_ALPHA_SORT_REMINDER_DISABLED']) { + return + } + const raw = await readStdin() + if (!raw) { + return + } + let payload: ToolInput + try { + payload = JSON.parse(raw) as ToolInput + } catch { + return + } + if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { + return + } + const filePath = payload.tool_input?.file_path ?? '' + if (!filePath) { + return + } + // Write → full content; Edit → the replacement text (best-effort window). + const content = + payload.tool_input?.content ?? payload.tool_input?.new_string ?? '' + if (!content) { + return + } + const findings = findUnsortedBlocks(filePath, content) + if (findings.length) { + emit(filePath, findings) + } +} + +main().catch(e => { + // Fail open — a reminder hook must never break a tool call. + process.stderr.write(`[alpha-sort-reminder] skipped: ${String(e)}\n`) +}) diff --git a/.claude/hooks/fleet/alpha-sort-reminder/package.json b/.claude/hooks/fleet/alpha-sort-reminder/package.json new file mode 100644 index 0000000..d346546 --- /dev/null +++ b/.claude/hooks/fleet/alpha-sort-reminder/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-alpha-sort-reminder", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/alpha-sort-reminder/test/index.test.mts b/.claude/hooks/fleet/alpha-sort-reminder/test/index.test.mts new file mode 100644 index 0000000..4fb77a7 --- /dev/null +++ b/.claude/hooks/fleet/alpha-sort-reminder/test/index.test.mts @@ -0,0 +1,82 @@ +/** + * @file Unit tests for the alpha-sort-reminder detector. + */ + +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import { findUnsortedBlocks } from '../index.mts' + +describe('alpha-sort-reminder / findUnsortedBlocks', () => { + test('JSON: flags out-of-order object keys', () => { + const code = '{\n "gamma": 1,\n "alpha": 2,\n "beta": 3\n}\n' + const f = findUnsortedBlocks('config.json', code) + assert.equal(f.length, 1) + assert.equal(f[0]!.surface, 'json') + }) + + test('JSON: quiet on sorted keys', () => { + const code = '{\n "alpha": 1,\n "beta": 2,\n "gamma": 3\n}\n' + assert.equal(findUnsortedBlocks('config.json', code).length, 0) + }) + + test('JSON: quiet on a 2-key run (below MIN_RUN)', () => { + const code = '{\n "gamma": 1,\n "alpha": 2\n}\n' + assert.equal(findUnsortedBlocks('config.json', code).length, 0) + }) + + test('JSON: nested object at different indent is its own run', () => { + // outer keys sorted; inner keys sorted — no finding. + const code = + '{\n "a": {\n "x": 1,\n "y": 2,\n "z": 3\n },\n "b": 2,\n "c": 3\n}\n' + assert.equal(findUnsortedBlocks('config.json', code).length, 0) + }) + + test('YAML: flags out-of-order env block', () => { + const code = 'env:\n ZED: 1\n ALPHA: 2\n MID: 3\n' + const f = findUnsortedBlocks('ci.yml', code) + assert.equal(f.length, 1) + assert.equal(f[0]!.surface, 'yaml') + }) + + test('YAML: ignores sequence items and comments', () => { + const code = 'steps:\n # a comment\n - uses: foo\n - uses: bar\n' + assert.equal(findUnsortedBlocks('ci.yml', code).length, 0) + }) + + test('markdown: flags out-of-order bullets', () => { + const code = '- zebra\n- apple\n- mango\n' + const f = findUnsortedBlocks('README.md', code) + assert.equal(f.length, 1) + assert.equal(f[0]!.surface, 'markdown') + }) + + test('markdown: flags trailing ellipsis even when sorted', () => { + const code = '- apple\n- banana, ...\n' + const f = findUnsortedBlocks('README.md', code) + assert.equal(f.length, 1) + assert.match(f[0]!.hint, /ellipsis/) + }) + + test('markdown: quiet on sorted bullets', () => { + const code = '- apple\n- mango\n- zebra\n' + assert.equal(findUnsortedBlocks('README.md', code).length, 0) + }) + + test('bash: flags out-of-order cache-key vars', () => { + const code = 'ZED_LIB=$(hash)\nALPHA_LIB=$(hash)\nMID_LIB=$(hash)\n' + const f = findUnsortedBlocks('build.sh', code) + assert.equal(f.length, 1) + assert.equal(f[0]!.surface, 'bash') + }) + + test('bash: quiet on sorted vars', () => { + const code = 'ALPHA_LIB=$(hash)\nMID_LIB=$(hash)\nZED_LIB=$(hash)\n' + assert.equal(findUnsortedBlocks('build.sh', code).length, 0) + }) + + test('unknown extension: no findings', () => { + const code = 'const o = { b: 1, a: 2 }\n' + assert.equal(findUnsortedBlocks('app.ts', code).length, 0) + }) +}) diff --git a/.claude/hooks/ask-suppression-reminder/tsconfig.json b/.claude/hooks/fleet/alpha-sort-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/ask-suppression-reminder/tsconfig.json rename to .claude/hooks/fleet/alpha-sort-reminder/tsconfig.json diff --git a/.claude/hooks/fleet/answer-passing-questions-reminder/README.md b/.claude/hooks/fleet/answer-passing-questions-reminder/README.md new file mode 100644 index 0000000..aedb6ce --- /dev/null +++ b/.claude/hooks/fleet/answer-passing-questions-reminder/README.md @@ -0,0 +1,24 @@ +# answer-passing-questions-reminder + +**Lifecycle**: Stop + +**Purpose**: catches the failure mode where the user asks a passing question while Claude is mid-task and the response deflects ("later" / "right now I'm doing X" / "let me finish first") instead of answering inline. + +## What triggers it + +The hook fires on `Stop` and only emits a reminder when both conditions hold: + +1. The most recent user turn contains a question — `?` punctuation, or interrogative leading (`is`, `should`, `do we`, `would`, `can we`, `where`, `why`, `what`, `how`, `which`). +2. The most recent assistant turn either contains a deflection phrase or doesn't contain text that looks like an answer (no statement-shape sentence touching the question keywords). + +## Exception + +Questions containing an explicit pivot signal (`now do X` / `instead let's` / `switch to` / `stop and`) are **redirects, not passing questions**. The hook skips those — the right response is to pivot, not to answer inline. + +## Disable + +Set `SOCKET_ANSWER_PASSING_QUESTIONS_REMINDER_DISABLED=1` in the session env. + +## Why this hook exists + +The assistant's habit of treating passing questions as interruptions instead of opportunities silently degrades collaboration. Users learn not to ask questions mid-task, which means small misunderstandings compound into bigger redirects later. The reminder makes the pattern visible at Stop so the next response can address the unanswered question. diff --git a/.claude/hooks/fleet/answer-passing-questions-reminder/index.mts b/.claude/hooks/fleet/answer-passing-questions-reminder/index.mts new file mode 100644 index 0000000..8f68568 --- /dev/null +++ b/.claude/hooks/fleet/answer-passing-questions-reminder/index.mts @@ -0,0 +1,172 @@ +#!/usr/bin/env node +// Claude Code Stop hook — answer-passing-questions-reminder. +// +// Catches the failure mode where the user asks a passing question +// while Claude is mid-task, and Claude brushes past it ("later" / +// "right now I'm doing X" / "let me finish first") instead of +// answering inline. +// +// What triggers: +// 1. The most recent user turn contains a question — `?` punctuation, +// or interrogative leading ("is", "should", "do we", "would", +// "can we", "where", "why", "what", "how", "which"). +// 2. The most recent assistant turn either (a) contains a deflection +// phrase or (b) doesn't contain text that looks like an answer +// (no statement-shape sentence answering the question keywords). +// +// Exception: if the user's question contains an explicit pivot signal +// ("now do X" / "instead let's" / "switch to" / "stop and"), it's not +// a passing question — it's a redirect, and the assistant should +// pivot. The hook skips those. +// +// Disable via SOCKET_ANSWER_PASSING_QUESTIONS_REMINDER_DISABLED. + +import process from 'node:process' + +import { + readLastAssistantText, + readStdin, + readUserText, + stripCodeFences, +} from '../_shared/transcript.mts' + +interface StopPayload { + readonly transcript_path?: string | undefined +} + +// Phrases that indicate the assistant brushed past the question. +const DEFLECTION_PATTERNS: ReadonlyArray<{ label: string; regex: RegExp }> = [ + { + label: "right now I'm / right now I am", + regex: /\bright\s+now\s+i'?(m|\s+am)\b/i, + }, + { + label: 'let me finish / let me first', + regex: /\b(let\s+me\s+(finish|first|wrap)|finish\s+first)\b/i, + }, + { + label: + "that's a (structural|bigger|separate) (fix|refactor|question) (for|later)", + regex: + /\bthat'?s\s+(a\s+)?(structural|bigger|separate|different)\s+(fix|refactor|question|issue|concern)\s+(for\s+later|though|\.\s)/i, + }, + { + label: 'for now / for the moment', + regex: /\bfor\s+(now|the\s+moment)\s*,?\s+(i'?m|let\s+me|focus)/i, + }, + { + label: "I'll come back to / get to that", + regex: /\bi'?ll\s+(come\s+back\s+to|get\s+to)\s+(that|it|this)\b/i, + }, + { + label: 'later — focus / first', + regex: + /\b(later|that\s+(part|piece))\s*[—–\-]\s*(focus|first|right\s+now)/i, + }, + { + label: 'noted / good question — moving on', + regex: + /\b(noted|good\s+(question|catch)|fair\s+(point|question))\s*[.—\-]\s+(moving|continuing|but\s+first)/i, + }, +] + +// Patterns that say "the user's input is a redirect, not a passing +// question". If any fires, the hook skips — the assistant SHOULD +// pivot. +const PIVOT_PATTERNS: ReadonlyArray = [ + /\b(stop\s+and|stop\s+that|abort|cancel|kill\s+it|halt)\b/i, + /\b(switch\s+to|pivot\s+to|focus\s+on)\b/i, + /\b(instead\s+(of|do)|never\s+mind)\b/i, + // "do X now" — imperative redirect. + /^\s*(do|run|execute|make)\s+\w+\s+now\b/i, +] + +// Question-shape detector applied to the most recent user turn. +function userAsksQuestion(userText: string): boolean { + // Quick win: explicit question mark. + if (userText.includes('?')) { + return true + } + // Interrogative leading words at a sentence boundary (allow leading + // whitespace / punctuation). + const interrogativeLead = + /(?:^|[.\n!])\s*(is|are|was|were|do|does|did|will|would|should|shall|can|could|may|might|have|has|had|where|why|what|how|which|when|who)\b/i + return interrogativeLead.test(userText) +} + +async function main(): Promise { + if (process.env['SOCKET_ANSWER_PASSING_QUESTIONS_REMINDER_DISABLED']) { + return + } + const payloadRaw = await readStdin() + let payload: StopPayload + try { + payload = JSON.parse(payloadRaw) as StopPayload + } catch { + return + } + + // Read only the MOST RECENT user turn (n=1). + const recentUser = readUserText(payload.transcript_path, 1).trim() + if (!recentUser) { + return + } + if (!userAsksQuestion(recentUser)) { + return + } + // If the user's input is a redirect, the assistant should pivot; + // skip the hook. + for (let i = 0, { length } = PIVOT_PATTERNS; i < length; i += 1) { + if (PIVOT_PATTERNS[i]!.test(recentUser)) { + return + } + } + + const rawAssistant = readLastAssistantText(payload.transcript_path) + if (!rawAssistant) { + return + } + const text = stripCodeFences(rawAssistant) + + // Does the assistant turn contain a deflection phrase? + const hits: Array<{ label: string; snippet: string }> = [] + for (let i = 0, { length } = DEFLECTION_PATTERNS; i < length; i += 1) { + const pattern = DEFLECTION_PATTERNS[i]! + const match = pattern.regex.exec(text) + if (!match) { + continue + } + const start = Math.max(0, match.index - 30) + const end = Math.min(text.length, match.index + match[0].length + 50) + hits.push({ + label: pattern.label, + snippet: text.slice(start, end).replace(/\s+/g, ' ').trim(), + }) + } + if (hits.length === 0) { + return + } + + const userSnippet = recentUser.slice(0, 200).replace(/\s+/g, ' ').trim() + const lines = [ + '[answer-passing-questions-reminder] User asked a passing question; assistant turn brushed past it without answering:', + '', + ` User: "${userSnippet}${recentUser.length > 200 ? '…' : ''}"`, + '', + ' Deflection phrases detected in assistant turn:', + ] + for (let i = 0, { length } = hits; i < length; i += 1) { + const hit = hits[i]! + lines.push(` • "${hit.label}" — …${hit.snippet}…`) + } + lines.push('') + lines.push( + ' Answer the question inline (one or two sentences) BEFORE / ALONGSIDE the current work. Not every user comment is a pivot — when a question is in passing, lend a few tokens to it. Continue the in-flight work right after.', + ) + + process.stderr.write(lines.join('\n') + '\n') +} + +main().catch(() => { + // Fail-open: never block a session on this hook's own bug. +}) diff --git a/.claude/hooks/fleet/answer-passing-questions-reminder/package.json b/.claude/hooks/fleet/answer-passing-questions-reminder/package.json new file mode 100644 index 0000000..a35b9ff --- /dev/null +++ b/.claude/hooks/fleet/answer-passing-questions-reminder/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-answer-passing-questions-reminder", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/answer-passing-questions-reminder/test/index.test.mts b/.claude/hooks/fleet/answer-passing-questions-reminder/test/index.test.mts new file mode 100644 index 0000000..c601196 --- /dev/null +++ b/.claude/hooks/fleet/answer-passing-questions-reminder/test/index.test.mts @@ -0,0 +1,35 @@ +/** + * @file Smoke test for answer-passing-questions-reminder. Stop hook that + * catches the failure mode where the user asks a passing question mid-task + * and the assistant deflects. Smoke contract: hook loads + dispatches without + * throwing; empty transcript path → exit 0. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import { spawn } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import test from 'node:test' +import assert from 'node:assert/strict' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +async function runHook(payload: unknown): Promise<{ code: number }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + child.on('error', reject) + child.on('close', code => resolve({ code: code ?? 1 })) + child.stdin.end(JSON.stringify(payload)) + }) +} + +test('empty transcript exits 0', async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'answer-passing-test-')) + const transcript = path.join(dir, 'session.jsonl') + writeFileSync(transcript, '') + const result = await runHook({ transcript_path: transcript }) + assert.equal(result.code, 0) +}) diff --git a/.claude/hooks/auth-rotation-reminder/tsconfig.json b/.claude/hooks/fleet/answer-passing-questions-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/auth-rotation-reminder/tsconfig.json rename to .claude/hooks/fleet/answer-passing-questions-reminder/tsconfig.json diff --git a/.claude/hooks/fleet/answer-status-requests-reminder/README.md b/.claude/hooks/fleet/answer-status-requests-reminder/README.md new file mode 100644 index 0000000..da7dce9 --- /dev/null +++ b/.claude/hooks/fleet/answer-status-requests-reminder/README.md @@ -0,0 +1,26 @@ +# answer-status-requests-reminder + +**Lifecycle**: Stop + +**Purpose**: catches the failure mode where the user explicitly asks for a status update on in-flight work and the assistant declines with a rate-limiting excuse like "too soon since last check" or "skipping". + +## What triggers it + +The hook fires on `Stop` when both conditions hold: + +1. The most recent user turn matches a status-request shape (case-insensitive): + - `check status`, `status?`, `status update` + - `how's it going` / `how's the build` / `how is it` + - `what's it doing` + - `is it done` + - `still running` + - `what's happening` + - `where are we` + - `progress?` +2. The most recent assistant turn matches a decline shape: + - `too soon since (last|the last|my last) check` + - `skipping` + +## Why this hook exists + +Self-imposed rate limiting against the user's explicit ask is the wrong default. The user knows they asked; the answer is to check, not to lecture about cadence. The reminder fires at Stop so the next response actually performs the check. diff --git a/.claude/hooks/fleet/answer-status-requests-reminder/index.mts b/.claude/hooks/fleet/answer-status-requests-reminder/index.mts new file mode 100644 index 0000000..7b23eb1 --- /dev/null +++ b/.claude/hooks/fleet/answer-status-requests-reminder/index.mts @@ -0,0 +1,187 @@ +#!/usr/bin/env node +// Claude Code Stop hook — answer-status-requests-reminder. +// +// Catches the failure mode where the user explicitly asks for a +// status update on in-flight work and the assistant declines with a +// rate-limiting excuse like "too soon since last check" or "skipping". +// +// User status-request shapes (case-insensitive, applied to most recent +// user turn): +// +// - "check status" +// - "status?" +// - "status update" +// - "how's it going" / "how's the build" / "how is it" +// - "what's it doing" +// - "is it done" +// - "still running" +// - "what's happening" +// - "where are we" +// - "progress?" +// +// Assistant decline shapes (case-insensitive): +// +// - "too soon since (last|the last|my last) check" +// - "skipping" +// - "not enough time has passed" +// - "let me wait" / "I'll wait" +// - "no need to check" / "no point checking" +// - "polling is wasted" — even though it's true in some contexts, +// when the user explicitly asks for status, run the check. +// - "cache hasn't refreshed" / "nothing new to report" (without +// having actually checked) +// +// When both fire, emit a reminder: when the user explicitly asks for +// a status update, ALWAYS run the check and report what's there. The +// status is what they're asking for; rate-limiting it is gatekeeping. +// +// Disable via SOCKET_ANSWER_STATUS_REQUESTS_REMINDER_DISABLED. + +import process from 'node:process' + +import { + readLastAssistantText, + readStdin, + readUserText, + stripCodeFences, +} from '../_shared/transcript.mts' + +interface StopPayload { + readonly transcript_path?: string | undefined +} + +// Shapes the user might use to ask for a status update. Applied to +// the most recent user turn ONLY. +const STATUS_REQUEST_PATTERNS: ReadonlyArray = [ + /\bcheck\s+(the\s+)?status\b/i, + /\bstatus\s*\??\s*$/im, + /\bstatus\s+(update|check|report|please)\b/i, + /\bhow'?s\s+(it|the\s+\w+)\s*(going|doing|progressing|coming)\b/i, + /\bhow\s+is\s+(it|the\s+\w+)\s*(going|doing|progressing|coming)\??/i, + /\bwhat'?s\s+(it|the\s+\w+)\s+doing\b/i, + /\bwhat'?s\s+happening\b/i, + /\bis\s+(it|the\s+\w+)\s+done\b/i, + /\bstill\s+running\??/i, + /\bwhere\s+are\s+we\b/i, + /\bprogress\s*\??$/im, + /\bany\s+(updates|progress|news)\b/i, +] + +// Phrases that indicate the assistant declined / rate-limited the +// status request instead of just running the check. +const DECLINE_PATTERNS: ReadonlyArray<{ label: string; regex: RegExp }> = [ + { + label: 'too soon / too early', + regex: /\btoo\s+(soon|early)\b/i, + }, + { + label: 'last check ~N (seconds|minutes) ago', + regex: + /\b(last|the\s+last|my\s+last)\s+check\s+(was\s+)?[~\d]+\s*\d*\s*(seconds?|minutes?|min|sec|s|m)\s+ago\b/i, + }, + { + label: 'skipping', + regex: /\b(skipping|i'?ll\s+skip|gonna\s+skip|going\s+to\s+skip)\s*[.,]/i, + }, + { + label: 'not enough time has passed', + regex: + /\b(not\s+enough\s+time|hasn'?t\s+been\s+(long|enough))\s+(has\s+)?(passed|elapsed|gone\s+by)\b/i, + }, + { + label: "let me wait / I'll wait / wait a bit", + regex: + /\b(let\s+me\s+wait|i'?ll\s+wait|wait\s+(a\s+(bit|moment|few|minute|second)|until))/i, + }, + { + label: 'no need to check / no point', + regex: + /\b(no\s+(need|point)\s+(to\s+)?(check(ing)?|polling|looking)|nothing\s+(to\s+)?check)\b/i, + }, + { + label: 'polling is wasted / pointless', + regex: /\bpoll(ing)?\s+(is\s+)?(wasted|pointless|moot|unnecessary)\b/i, + }, + { + label: 'no change since last check (without checking)', + regex: + /\b(no\s+change|nothing\s+new|same\s+as\s+(before|last))\s+since\s+(the\s+)?last\s+(check|update|time)\b/i, + }, +] + +async function main(): Promise { + if (process.env['SOCKET_ANSWER_STATUS_REQUESTS_REMINDER_DISABLED']) { + return + } + const payloadRaw = await readStdin() + let payload: StopPayload + try { + payload = JSON.parse(payloadRaw) as StopPayload + } catch { + return + } + + // Only the MOST RECENT user turn (n=1). + const recentUser = readUserText(payload.transcript_path, 1).trim() + if (!recentUser) { + return + } + + let askedForStatus = false + for (let i = 0, { length } = STATUS_REQUEST_PATTERNS; i < length; i += 1) { + if (STATUS_REQUEST_PATTERNS[i]!.test(recentUser)) { + askedForStatus = true + break + } + } + if (!askedForStatus) { + return + } + + const rawAssistant = readLastAssistantText(payload.transcript_path) + if (!rawAssistant) { + return + } + const text = stripCodeFences(rawAssistant) + + const hits: Array<{ label: string; snippet: string }> = [] + for (let i = 0, { length } = DECLINE_PATTERNS; i < length; i += 1) { + const pattern = DECLINE_PATTERNS[i]! + const match = pattern.regex.exec(text) + if (!match) { + continue + } + const start = Math.max(0, match.index - 30) + const end = Math.min(text.length, match.index + match[0].length + 50) + hits.push({ + label: pattern.label, + snippet: text.slice(start, end).replace(/\s+/g, ' ').trim(), + }) + } + if (hits.length === 0) { + return + } + + const userSnippet = recentUser.slice(0, 200).replace(/\s+/g, ' ').trim() + const lines = [ + '[answer-status-requests-reminder] User asked for a status update; assistant declined with rate-limiting excuse:', + '', + ` User: "${userSnippet}${recentUser.length > 200 ? '…' : ''}"`, + '', + ' Decline phrases detected in assistant turn:', + ] + for (let i = 0, { length } = hits; i < length; i += 1) { + const hit = hits[i]! + lines.push(` • "${hit.label}" — …${hit.snippet}…`) + } + lines.push('') + lines.push( + ' When the user explicitly asks for a status update, RUN the check and report. "Too soon" / "skipping" / "polling is wasted" are gatekeeping — the user already decided the check is worth it. The auto-notification policy (for background tasks the harness tracks) is YOUR optimization, not theirs.', + ) + + process.stderr.write(lines.join('\n') + '\n') +} + +main().catch(() => { + // Fail-open: never block a session on this hook's own bug. +}) diff --git a/.claude/hooks/fleet/answer-status-requests-reminder/package.json b/.claude/hooks/fleet/answer-status-requests-reminder/package.json new file mode 100644 index 0000000..597ce23 --- /dev/null +++ b/.claude/hooks/fleet/answer-status-requests-reminder/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-answer-status-requests-reminder", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/answer-status-requests-reminder/test/index.test.mts b/.claude/hooks/fleet/answer-status-requests-reminder/test/index.test.mts new file mode 100644 index 0000000..48f0fdb --- /dev/null +++ b/.claude/hooks/fleet/answer-status-requests-reminder/test/index.test.mts @@ -0,0 +1,36 @@ +/** + * @file Smoke test for answer-status-requests-reminder. Stop hook that catches + * the failure mode where the user explicitly asks for a status update and the + * assistant declines with a "too soon since last check" excuse. Smoke + * contract: hook loads + dispatches without throwing; empty transcript path → + * exit 0. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import { spawn } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import test from 'node:test' +import assert from 'node:assert/strict' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +async function runHook(payload: unknown): Promise<{ code: number }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + child.on('error', reject) + child.on('close', code => resolve({ code: code ?? 1 })) + child.stdin.end(JSON.stringify(payload)) + }) +} + +test('empty transcript exits 0', async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'answer-status-test-')) + const transcript = path.join(dir, 'session.jsonl') + writeFileSync(transcript, '') + const result = await runHook({ transcript_path: transcript }) + assert.equal(result.code, 0) +}) diff --git a/.claude/hooks/check-new-deps/tsconfig.json b/.claude/hooks/fleet/answer-status-requests-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/check-new-deps/tsconfig.json rename to .claude/hooks/fleet/answer-status-requests-reminder/tsconfig.json diff --git a/.claude/hooks/ask-suppression-reminder/README.md b/.claude/hooks/fleet/ask-suppression-reminder/README.md similarity index 100% rename from .claude/hooks/ask-suppression-reminder/README.md rename to .claude/hooks/fleet/ask-suppression-reminder/README.md diff --git a/.claude/hooks/ask-suppression-reminder/index.mts b/.claude/hooks/fleet/ask-suppression-reminder/index.mts similarity index 100% rename from .claude/hooks/ask-suppression-reminder/index.mts rename to .claude/hooks/fleet/ask-suppression-reminder/index.mts diff --git a/.claude/hooks/ask-suppression-reminder/package.json b/.claude/hooks/fleet/ask-suppression-reminder/package.json similarity index 100% rename from .claude/hooks/ask-suppression-reminder/package.json rename to .claude/hooks/fleet/ask-suppression-reminder/package.json diff --git a/.claude/hooks/ask-suppression-reminder/test/index.test.mts b/.claude/hooks/fleet/ask-suppression-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/ask-suppression-reminder/test/index.test.mts rename to .claude/hooks/fleet/ask-suppression-reminder/test/index.test.mts diff --git a/.claude/hooks/claude-md-section-size-guard/tsconfig.json b/.claude/hooks/fleet/ask-suppression-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/claude-md-section-size-guard/tsconfig.json rename to .claude/hooks/fleet/ask-suppression-reminder/tsconfig.json diff --git a/.claude/hooks/auth-rotation-reminder/README.md b/.claude/hooks/fleet/auth-rotation-reminder/README.md similarity index 98% rename from .claude/hooks/auth-rotation-reminder/README.md rename to .claude/hooks/fleet/auth-rotation-reminder/README.md index 2b74b1f..91fce07 100644 --- a/.claude/hooks/auth-rotation-reminder/README.md +++ b/.claude/hooks/fleet/auth-rotation-reminder/README.md @@ -110,7 +110,7 @@ In `.claude/settings.json`: "hooks": [ { "type": "command", - "command": "node .claude/hooks/auth-rotation-reminder/index.mts" + "command": "node .claude/hooks/fleet/auth-rotation-reminder/index.mts" } ] } diff --git a/.claude/hooks/auth-rotation-reminder/index.mts b/.claude/hooks/fleet/auth-rotation-reminder/index.mts similarity index 100% rename from .claude/hooks/auth-rotation-reminder/index.mts rename to .claude/hooks/fleet/auth-rotation-reminder/index.mts diff --git a/.claude/hooks/auth-rotation-reminder/package.json b/.claude/hooks/fleet/auth-rotation-reminder/package.json similarity index 100% rename from .claude/hooks/auth-rotation-reminder/package.json rename to .claude/hooks/fleet/auth-rotation-reminder/package.json diff --git a/.claude/hooks/auth-rotation-reminder/services.mts b/.claude/hooks/fleet/auth-rotation-reminder/services.mts similarity index 100% rename from .claude/hooks/auth-rotation-reminder/services.mts rename to .claude/hooks/fleet/auth-rotation-reminder/services.mts diff --git a/.claude/hooks/auth-rotation-reminder/test/auth-rotation-reminder.test.mts b/.claude/hooks/fleet/auth-rotation-reminder/test/auth-rotation-reminder.test.mts similarity index 100% rename from .claude/hooks/auth-rotation-reminder/test/auth-rotation-reminder.test.mts rename to .claude/hooks/fleet/auth-rotation-reminder/test/auth-rotation-reminder.test.mts diff --git a/.claude/hooks/claude-md-size-guard/tsconfig.json b/.claude/hooks/fleet/auth-rotation-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/claude-md-size-guard/tsconfig.json rename to .claude/hooks/fleet/auth-rotation-reminder/tsconfig.json diff --git a/.claude/hooks/fleet/avoid-cd-reminder/index.mts b/.claude/hooks/fleet/avoid-cd-reminder/index.mts new file mode 100644 index 0000000..00e20d7 --- /dev/null +++ b/.claude/hooks/fleet/avoid-cd-reminder/index.mts @@ -0,0 +1,136 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — avoid-cd-reminder. +// +// The Bash tool's working directory PERSISTS across tool calls. That's +// useful for chaining commands but easy to lose track of: a `cd` in +// turn N puts every later command in a different cwd until something +// resets it. The assistant has burned multiple tool calls realizing +// cwd had drifted — see e.g. "Wait — patch ran from current dir. +// But the current dir isn't lsquic upstream." +// +// The fix is one of: +// (a) prefer absolute paths inside a single command — no cd needed: +// patch --dry-run -p1 -d /abs/path/to/source < /abs/path/to/file.patch +// (b) keep the cd local to the command via `()` subshell — pwd is +// confined to the subshell, parent cwd unchanged: +// (cd /abs/path && make) +// (c) end the command with `&& pwd` so the next tool call shows +// evidence in the log where the cwd actually ended up: +// cd /abs/path && some-command && pwd +// +// This hook fires on Bash commands that contain a bare `cd ` +// without one of the above safeguards. Stderr reminder; never blocks. +// +// Scope: Bash tool only. Skips: +// - `cd ` inside a `()` subshell (pattern (b) — safe) +// - `cd ` followed by `&& pwd` or `; pwd` at the end (pattern (c) — +// evidenced) +// - `cd -` (return to previous dir, intentional) +// - `cd 2>/dev/null` short forms used for existence probes +// (caller knows what they're doing) +// +// Disable via SOCKET_AVOID_CD_REMINDER_DISABLED. + +import process from 'node:process' + +import { readStdin } from '../_shared/transcript.mts' + +interface PreToolUseInput { + readonly tool_name?: string | undefined + readonly tool_input?: + | { + readonly command?: string | undefined + } + | undefined +} + +// Matches `cd ` not preceded by `(` (subshell) and not +// followed by anything that suggests evidence-capture. +function detectsBareCd(command: string): boolean { + // Strip line continuations + collapse whitespace for easier matching. + const flat = command.replace(/\\\n/g, ' ').replace(/\s+/g, ' ') + + // Find every `cd ` occurrence and inspect each one's context. + const cdRe = /(^|[\s;&|])cd\s+(\S+)/g + let m: RegExpExecArray | null + while ((m = cdRe.exec(flat)) !== null) { + const target = m[2]! + + // Skip `cd -` (intentional return). + if (target === '-') { + continue + } + // Skip subshell form: `(cd path && ...)`. We look backwards in + // the flattened string for an unmatched `(` before the cd. + const pre = flat.slice(0, m.index) + const opens = (pre.match(/\(/g) ?? []).length + const closes = (pre.match(/\)/g) ?? []).length + if (opens > closes) { + continue + } + // Skip if the lead is empty AND we're at the very start AND the + // command ends with `&& pwd` or `; pwd` — evidence pattern. + if (/(?:&&|;)\s*pwd\b\s*$/.test(flat)) { + continue + } + // Skip the bare-existence-probe shape: `cd 2>/dev/null && …`. + // The `2>/dev/null` redirect signals the caller is using cd as a + // probe, not a permanent move. + const tail = flat.slice(m.index + m[0].length) + if (/^\s*2>\s*\/dev\/null/.test(tail)) { + continue + } + // Bare cd that persists across tool calls. + return true + } + return false +} + +async function main(): Promise { + if (process.env['SOCKET_AVOID_CD_REMINDER_DISABLED']) { + return + } + const payloadRaw = await readStdin() + let payload: PreToolUseInput + try { + payload = JSON.parse(payloadRaw) as PreToolUseInput + } catch { + return + } + if (payload.tool_name !== 'Bash') { + return + } + const command = payload.tool_input?.command + if (typeof command !== 'string' || command.length === 0) { + return + } + if (!detectsBareCd(command)) { + return + } + process.stderr.write( + [ + '[avoid-cd-reminder] Bash command contains a bare `cd `.', + '', + " The Bash tool's cwd PERSISTS across tool calls — a cd here lingers", + ' for every later command until something resets it. Recover with one', + ' of:', + '', + ' (a) Use absolute paths so no cd is needed:', + ' patch -p1 -d /abs/path < /abs/file.patch', + '', + ' (b) Confine the cd to a subshell:', + ' (cd /abs/path && make)', + '', + ' (c) Capture the resulting cwd so the next call can see it:', + ' cd /abs/path && some-command && pwd', + '', + ' Disable: SOCKET_AVOID_CD_REMINDER_DISABLED=1', + '', + ].join('\n'), + ) +} + +main().catch(() => { + // Fail-open: never block a session on this hook's own bug. + process.exitCode = 0 +}) diff --git a/.claude/hooks/fleet/broken-hook-detector/README.md b/.claude/hooks/fleet/broken-hook-detector/README.md new file mode 100644 index 0000000..b535d1b --- /dev/null +++ b/.claude/hooks/fleet/broken-hook-detector/README.md @@ -0,0 +1,25 @@ +# broken-hook-detector + +**Lifecycle**: SessionStart + +**Purpose**: catch the failure mode where every Bash invocation prints noisy `PreToolUse:Bash hook error … node:internal/modules/package_json_reader:314` lines without identifying which hook crashed or what it needed. + +## What it does + +At `SessionStart` (once per session — no Bash spam), the hook walks every `.claude/hooks/*/index.mts` plus `.claude/hooks/_shared/*.mts`, spawns `node --check` on each, and aggregates failures. If any crash with `ERR_MODULE_NOT_FOUND`, the hook surfaces a single structured message naming: + +- The failing hook +- The missing package(s) +- The exact `pnpm i` recovery command + +## Self-imposed constraint: Node built-ins only + +This hook is the safety net for "hook deps are broken"; it must not itself depend on anything installed via pnpm. The entire import surface is `node:fs`, `node:path`, `node:child_process`, `node:url`. Adding a `@socketsecurity/*` import here would make the hook silently fail under the exact condition it exists to detect. + +## Fail-open + +The probe never blocks. On any internal error (timeout, unreadable file, walker exception) the hook exits 0 and the session starts normally. The point is informational diagnosis, not enforcement. + +## When it fires in practice + +Most often after a wheelhouse cascade introduces a new `import` to a `_shared/*.mts` helper and the consuming repo hasn't run `pnpm install` to materialize the dependency. diff --git a/.claude/hooks/fleet/broken-hook-detector/index.mts b/.claude/hooks/fleet/broken-hook-detector/index.mts new file mode 100644 index 0000000..85c5b11 --- /dev/null +++ b/.claude/hooks/fleet/broken-hook-detector/index.mts @@ -0,0 +1,237 @@ +#!/usr/bin/env node +// Claude Code SessionStart hook — broken-hook-detector. +// +// Symptom this hook exists to catch: +// Every Bash invocation prints noisy `PreToolUse:Bash hook error +// Failed with non-blocking status code: node:internal/modules/ +// package_json_reader:314` lines, with no indication of WHICH hook +// crashed or WHAT it needed. Happens whenever a fleet-cascade adds +// a new `import` to a shared hook (e.g. `_shared/shell-command.mts`) +// and the consuming repo hasn't installed the dep yet. +// +// What it does: +// At SessionStart (once per session, no Bash spam), walk every +// `.claude/hooks/*/index.mts` plus `.claude/hooks/_shared/*.mts`, +// spawn `node --check` on each, and aggregate the failures. If any +// crash with ERR_MODULE_NOT_FOUND, surface ONE structured message +// that names: the failing hook, the missing package(s), and the +// exact `pnpm i` recovery command. +// +// **Self-imposed constraint: Node built-ins ONLY.** +// This hook is the safety net for "hook deps are broken"; it must +// not itself depend on anything installed via pnpm. fs, path, child +// process, url — that's the entire import surface. +// +// Fail-open: probe never blocks. On any internal error (timeout, +// permission, whatever) the hook silently exits 0 and lets the +// session proceed — same posture as socket-token-minifier-start. + +import { spawnSync } from 'node:child_process' +import { readdirSync, statSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +const PROJECT_DIR = process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd() +const HOOKS_DIR = path.join(PROJECT_DIR, '.claude', 'hooks') + +// 4-second total budget. Each `node --check` is ~50-150 ms; with +// ~80 hooks that's well under the SessionStart hook timeout. +const PER_PROBE_TIMEOUT_MS = 1500 +const MAX_PROBES = 120 + +interface ProbeFailure { + readonly hookPath: string + readonly missingPackages: readonly string[] + readonly rawStderr: string +} + +function emitAdditionalContext(message: string): void { + // Stdout is the only channel Claude Code reads for SessionStart + // hooks. additionalContext lands as informational text in the + // transcript; it does NOT block the session. + const out = { + hookSpecificOutput: { + hookEventName: 'SessionStart', + additionalContext: `[broken-hook-detector] ${message}`, + }, + } + process.stdout.write(JSON.stringify(out)) +} + +function findHookEntrypoints(): readonly string[] { + const entries: string[] = [] + // Each hook lives at //index.mts. + let topLevel: readonly string[] + try { + topLevel = readdirSync(HOOKS_DIR) + } catch { + // No hooks dir; nothing to probe. + return [] + } + for (const name of topLevel) { + if (entries.length >= MAX_PROBES) { + break + } + if (name === '_shared') { + continue + } + const candidate = path.join(HOOKS_DIR, name, 'index.mts') + try { + if (statSync(candidate).isFile()) { + entries.push(candidate) + } + } catch { + // Hook dir without index.mts is fine; skip. + } + } + return entries +} + +// Module-not-found error shape from Node ≥22: +// Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'shell-quote' +// imported from /…/_shared/shell-command.mts +// at Object.getPackageJSONURL (node:internal/modules/package_json_reader:314:9) +// +// We also tolerate the older CJS shape: +// Error: Cannot find module 'shell-quote' +function parseMissingPackages(stderr: string): readonly string[] { + const pkgs = new Set() + // ESM form: Cannot find package '' … + for (const m of stderr.matchAll(/Cannot find package '([^']+)'/g)) { + pkgs.add(m[1]!) + } + // CJS form: Cannot find module '' + for (const m of stderr.matchAll(/Cannot find module '([^']+)'/g)) { + const name = m[1]! + // Skip relative + absolute paths (those are import-path bugs, not + // missing-dep bugs, and the user can't `pnpm i` a relative path). + if (!name.startsWith('.') && !name.startsWith('/')) { + pkgs.add(name) + } + } + return [...pkgs] +} + +function probeHook(hookPath: string): ProbeFailure | undefined { + // `node --check` does syntax-only validation and won't import the + // graph. Use `--input-type=module` and read the file as the input + // so module resolution actually happens. But that's heavy — the + // cheaper alternative: dynamic import via a tiny one-liner that + // exits 0 after the import succeeds. + const result = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + // Resolving-only via import() lets the resolver run without + // executing top-level code that might block (e.g. start a + // server). Success → loop drains, exit 0. Failure → Node's + // default unhandled-rejection handler prints the error to + // stderr and exits non-zero — the parent reads result.stderr + // for "Cannot find package" matching, no try/catch needed. + // + // file:// form is required for cross-platform correctness: on + // Windows, an absolute path like `C:\foo\bar.mts` looks like a + // URL scheme (`C:`) to the ESM resolver and throws + // ERR_UNSUPPORTED_ESM_URL_SCHEME. pathToFileURL handles the + // platform-specific quoting + scheme prefix. + `await import(${JSON.stringify(pathToFileURL(hookPath).href)})`, + ], + { + timeout: PER_PROBE_TIMEOUT_MS, + // Inherit nothing — keep the probe sandboxed from the real + // session env so any env-var quirks don't surface as false + // positives. CLAUDE_PROJECT_DIR is preserved because some + // hooks read it at import time. + env: { + PATH: process.env['PATH'] ?? '', + HOME: process.env['HOME'] ?? '', + CLAUDE_PROJECT_DIR: PROJECT_DIR, + // Suppress node's deprecation warnings during the probe; + // unrelated to broken-hook detection. + NODE_NO_WARNINGS: '1', + }, + encoding: 'utf8', + }, + ) + if (result.status === 0) { + return undefined + } + // Non-zero exit OR timeout. spawnSync sets status=null on timeout; + // treat timeout as inconclusive (skip rather than false-positive). + if (result.status === null) { + return undefined + } + const stderr = result.stderr ?? '' + // Only flag genuine missing-dep failures. Syntax errors, runtime + // errors, etc. aren't this hook's job to surface. + if ( + !stderr.includes('ERR_MODULE_NOT_FOUND') && + !stderr.includes('Cannot find package') && + !stderr.includes('Cannot find module') + ) { + return undefined + } + const missing = parseMissingPackages(stderr) + if (missing.length === 0) { + return undefined + } + return { + hookPath, + missingPackages: missing, + rawStderr: stderr.slice(0, 2000), + } +} + +function formatReport(failures: readonly ProbeFailure[]): string { + // Aggregate unique missing packages across all failures so the + // suggested `pnpm i` recovers everything in one call. + const allMissing = new Set() + for (const f of failures) { + for (const p of f.missingPackages) { + allMissing.add(p) + } + } + const lines: string[] = [] + lines.push( + `${failures.length} hook${failures.length === 1 ? '' : 's'} failed to load due to missing packages:`, + ) + for (const f of failures) { + const relPath = path.relative(PROJECT_DIR, f.hookPath) + lines.push(` - ${relPath} → ${f.missingPackages.join(', ')}`) + } + const installList = [...allMissing].sort().join(' ') + lines.push('') + lines.push(`Fix: \`pnpm i ${installList}\``) + lines.push( + 'If the dep is a fleet-canonical cascade, the catalog entry + soak-bypass may also need adding (see pnpm-workspace.yaml).', + ) + return lines.join('\n') +} + +function main(): void { + const entrypoints = findHookEntrypoints() + if (entrypoints.length === 0) { + return + } + const failures: ProbeFailure[] = [] + for (const entry of entrypoints) { + const failure = probeHook(entry) + if (failure !== undefined) { + failures.push(failure) + } + } + if (failures.length === 0) { + return + } + emitAdditionalContext(formatReport(failures)) +} + +try { + main() +} catch { + // Fail-open: never block a session on this hook's own bug. + // No exitCode write needed — Node defaults to 0 when the loop + // drains naturally, and we explicitly never want a non-zero here. +} diff --git a/.claude/hooks/fleet/broken-hook-detector/package.json b/.claude/hooks/fleet/broken-hook-detector/package.json new file mode 100644 index 0000000..92efa45 --- /dev/null +++ b/.claude/hooks/fleet/broken-hook-detector/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-broken-hook-detector", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/broken-hook-detector/test/index.test.mts b/.claude/hooks/fleet/broken-hook-detector/test/index.test.mts new file mode 100644 index 0000000..d460988 --- /dev/null +++ b/.claude/hooks/fleet/broken-hook-detector/test/index.test.mts @@ -0,0 +1,33 @@ +/** + * @file Smoke test for broken-hook-detector. SessionStart hook (Node built-ins + * only, self-imposed) that walks every other hook's index.mts + every + * _shared/*.mts, spawns `node --check` on each, and aggregates + * ERR_MODULE_NOT_FOUND failures into one structured recovery message. + * Fail-open by design. Smoke contract: hook loads + dispatches without + * throwing; empty payload → exit 0 (fail-open). + */ + +import { spawn } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import test from 'node:test' +import assert from 'node:assert/strict' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +async function runHook(payload: unknown): Promise<{ code: number }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + child.on('error', reject) + child.on('close', code => resolve({ code: code ?? 1 })) + child.stdin.end(JSON.stringify(payload)) + }) +} + +test('empty payload exits 0 (fail-open)', async () => { + const result = await runHook({}) + // Fail-open: any internal error must exit 0. + assert.equal(result.code, 0) +}) diff --git a/.claude/hooks/codex-no-write-guard/tsconfig.json b/.claude/hooks/fleet/broken-hook-detector/tsconfig.json similarity index 100% rename from .claude/hooks/codex-no-write-guard/tsconfig.json rename to .claude/hooks/fleet/broken-hook-detector/tsconfig.json diff --git a/.claude/hooks/check-new-deps/README.md b/.claude/hooks/fleet/check-new-deps/README.md similarity index 99% rename from .claude/hooks/check-new-deps/README.md rename to .claude/hooks/fleet/check-new-deps/README.md index e513cfc..01c7bf0 100644 --- a/.claude/hooks/check-new-deps/README.md +++ b/.claude/hooks/fleet/check-new-deps/README.md @@ -136,7 +136,7 @@ The hook is registered in `.claude/settings.json`: "hooks": [ { "type": "command", - "command": "node .claude/hooks/check-new-deps/index.mts" + "command": "node .claude/hooks/fleet/check-new-deps/index.mts" } ] } diff --git a/.claude/hooks/check-new-deps/audit.mts b/.claude/hooks/fleet/check-new-deps/audit.mts similarity index 100% rename from .claude/hooks/check-new-deps/audit.mts rename to .claude/hooks/fleet/check-new-deps/audit.mts diff --git a/.claude/hooks/check-new-deps/index.mts b/.claude/hooks/fleet/check-new-deps/index.mts similarity index 100% rename from .claude/hooks/check-new-deps/index.mts rename to .claude/hooks/fleet/check-new-deps/index.mts diff --git a/.claude/hooks/check-new-deps/package.json b/.claude/hooks/fleet/check-new-deps/package.json similarity index 100% rename from .claude/hooks/check-new-deps/package.json rename to .claude/hooks/fleet/check-new-deps/package.json diff --git a/.claude/hooks/check-new-deps/test/extract-deps.test.mts b/.claude/hooks/fleet/check-new-deps/test/extract-deps.test.mts similarity index 100% rename from .claude/hooks/check-new-deps/test/extract-deps.test.mts rename to .claude/hooks/fleet/check-new-deps/test/extract-deps.test.mts diff --git a/.claude/hooks/comment-tone-reminder/tsconfig.json b/.claude/hooks/fleet/check-new-deps/tsconfig.json similarity index 100% rename from .claude/hooks/comment-tone-reminder/tsconfig.json rename to .claude/hooks/fleet/check-new-deps/tsconfig.json diff --git a/.claude/hooks/check-new-deps/types.mts b/.claude/hooks/fleet/check-new-deps/types.mts similarity index 100% rename from .claude/hooks/check-new-deps/types.mts rename to .claude/hooks/fleet/check-new-deps/types.mts diff --git a/.claude/hooks/claude-md-section-size-guard/README.md b/.claude/hooks/fleet/claude-md-section-size-guard/README.md similarity index 96% rename from .claude/hooks/claude-md-section-size-guard/README.md rename to .claude/hooks/fleet/claude-md-section-size-guard/README.md index 54c4b97..9bb9f3d 100644 --- a/.claude/hooks/claude-md-section-size-guard/README.md +++ b/.claude/hooks/fleet/claude-md-section-size-guard/README.md @@ -35,4 +35,4 @@ No bypass phrase — the override env-var is the documented escape valve. If you ## Reading - CLAUDE.md → opening fleet-canonical note (cap is cited there). -- `.claude/hooks/claude-md-size-guard/` — the companion byte-cap hook. +- `.claude/hooks/fleet/claude-md-size-guard/` — the companion byte-cap hook. diff --git a/.claude/hooks/claude-md-section-size-guard/index.mts b/.claude/hooks/fleet/claude-md-section-size-guard/index.mts similarity index 100% rename from .claude/hooks/claude-md-section-size-guard/index.mts rename to .claude/hooks/fleet/claude-md-section-size-guard/index.mts diff --git a/.claude/hooks/claude-md-section-size-guard/package.json b/.claude/hooks/fleet/claude-md-section-size-guard/package.json similarity index 100% rename from .claude/hooks/claude-md-section-size-guard/package.json rename to .claude/hooks/fleet/claude-md-section-size-guard/package.json diff --git a/.claude/hooks/claude-md-section-size-guard/test/index.test.mts b/.claude/hooks/fleet/claude-md-section-size-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/claude-md-section-size-guard/test/index.test.mts rename to .claude/hooks/fleet/claude-md-section-size-guard/test/index.test.mts diff --git a/.claude/hooks/commit-author-guard/tsconfig.json b/.claude/hooks/fleet/claude-md-section-size-guard/tsconfig.json similarity index 100% rename from .claude/hooks/commit-author-guard/tsconfig.json rename to .claude/hooks/fleet/claude-md-section-size-guard/tsconfig.json diff --git a/.claude/hooks/claude-md-size-guard/README.md b/.claude/hooks/fleet/claude-md-size-guard/README.md similarity index 100% rename from .claude/hooks/claude-md-size-guard/README.md rename to .claude/hooks/fleet/claude-md-size-guard/README.md diff --git a/.claude/hooks/claude-md-size-guard/index.mts b/.claude/hooks/fleet/claude-md-size-guard/index.mts similarity index 100% rename from .claude/hooks/claude-md-size-guard/index.mts rename to .claude/hooks/fleet/claude-md-size-guard/index.mts diff --git a/.claude/hooks/claude-md-size-guard/package.json b/.claude/hooks/fleet/claude-md-size-guard/package.json similarity index 100% rename from .claude/hooks/claude-md-size-guard/package.json rename to .claude/hooks/fleet/claude-md-size-guard/package.json diff --git a/.claude/hooks/claude-md-size-guard/test/index.test.mts b/.claude/hooks/fleet/claude-md-size-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/claude-md-size-guard/test/index.test.mts rename to .claude/hooks/fleet/claude-md-size-guard/test/index.test.mts diff --git a/.claude/hooks/commit-message-format-guard/tsconfig.json b/.claude/hooks/fleet/claude-md-size-guard/tsconfig.json similarity index 100% rename from .claude/hooks/commit-message-format-guard/tsconfig.json rename to .claude/hooks/fleet/claude-md-size-guard/tsconfig.json diff --git a/.claude/hooks/codex-no-write-guard/README.md b/.claude/hooks/fleet/codex-no-write-guard/README.md similarity index 100% rename from .claude/hooks/codex-no-write-guard/README.md rename to .claude/hooks/fleet/codex-no-write-guard/README.md diff --git a/.claude/hooks/codex-no-write-guard/index.mts b/.claude/hooks/fleet/codex-no-write-guard/index.mts similarity index 98% rename from .claude/hooks/codex-no-write-guard/index.mts rename to .claude/hooks/fleet/codex-no-write-guard/index.mts index 5a1530e..070a364 100644 --- a/.claude/hooks/codex-no-write-guard/index.mts +++ b/.claude/hooks/fleet/codex-no-write-guard/index.mts @@ -112,9 +112,7 @@ async function main(): Promise { // Check write-intent verbs only in the codex command's OWN args // (the prompt), not the whole shell line — so a sibling command // or a path containing a verb word doesn't trip the guard. - const codexArgText = codexCommands - .flatMap(c => c.args) - .join(' ') + const codexArgText = codexCommands.flatMap(c => c.args).join(' ') const verb = hasWriteIntent(codexArgText) if (verb) { blocked = { kind: 'bash', reason: `write-intent verb "${verb}"` } diff --git a/.claude/hooks/codex-no-write-guard/package.json b/.claude/hooks/fleet/codex-no-write-guard/package.json similarity index 100% rename from .claude/hooks/codex-no-write-guard/package.json rename to .claude/hooks/fleet/codex-no-write-guard/package.json diff --git a/.claude/hooks/codex-no-write-guard/test/index.test.mts b/.claude/hooks/fleet/codex-no-write-guard/test/index.test.mts similarity index 97% rename from .claude/hooks/codex-no-write-guard/test/index.test.mts rename to .claude/hooks/fleet/codex-no-write-guard/test/index.test.mts index f83b5d0..7ea6ea7 100644 --- a/.claude/hooks/codex-no-write-guard/test/index.test.mts +++ b/.claude/hooks/fleet/codex-no-write-guard/test/index.test.mts @@ -50,7 +50,8 @@ test('command mentioning the guard name (codex-no-write-guard) is NOT a codex in const r = await runHook({ tool_name: 'Bash', tool_input: { - command: 'grep -n "write" template/.claude/hooks/codex-no-write-guard/index.mts', + command: + 'grep -n "write" template/.claude/hooks/fleet/codex-no-write-guard/index.mts', }, }) assert.strictEqual(r.code, 0) diff --git a/.claude/hooks/commit-pr-reminder/tsconfig.json b/.claude/hooks/fleet/codex-no-write-guard/tsconfig.json similarity index 100% rename from .claude/hooks/commit-pr-reminder/tsconfig.json rename to .claude/hooks/fleet/codex-no-write-guard/tsconfig.json diff --git a/.claude/hooks/comment-tone-reminder/README.md b/.claude/hooks/fleet/comment-tone-reminder/README.md similarity index 100% rename from .claude/hooks/comment-tone-reminder/README.md rename to .claude/hooks/fleet/comment-tone-reminder/README.md diff --git a/.claude/hooks/comment-tone-reminder/index.mts b/.claude/hooks/fleet/comment-tone-reminder/index.mts similarity index 100% rename from .claude/hooks/comment-tone-reminder/index.mts rename to .claude/hooks/fleet/comment-tone-reminder/index.mts diff --git a/.claude/hooks/comment-tone-reminder/package.json b/.claude/hooks/fleet/comment-tone-reminder/package.json similarity index 100% rename from .claude/hooks/comment-tone-reminder/package.json rename to .claude/hooks/fleet/comment-tone-reminder/package.json diff --git a/.claude/hooks/comment-tone-reminder/test/index.test.mts b/.claude/hooks/fleet/comment-tone-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/comment-tone-reminder/test/index.test.mts rename to .claude/hooks/fleet/comment-tone-reminder/test/index.test.mts diff --git a/.claude/hooks/compound-lessons-reminder/tsconfig.json b/.claude/hooks/fleet/comment-tone-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/compound-lessons-reminder/tsconfig.json rename to .claude/hooks/fleet/comment-tone-reminder/tsconfig.json diff --git a/.claude/hooks/commit-author-guard/README.md b/.claude/hooks/fleet/commit-author-guard/README.md similarity index 100% rename from .claude/hooks/commit-author-guard/README.md rename to .claude/hooks/fleet/commit-author-guard/README.md diff --git a/.claude/hooks/commit-author-guard/index.mts b/.claude/hooks/fleet/commit-author-guard/index.mts similarity index 100% rename from .claude/hooks/commit-author-guard/index.mts rename to .claude/hooks/fleet/commit-author-guard/index.mts diff --git a/.claude/hooks/commit-author-guard/package.json b/.claude/hooks/fleet/commit-author-guard/package.json similarity index 100% rename from .claude/hooks/commit-author-guard/package.json rename to .claude/hooks/fleet/commit-author-guard/package.json diff --git a/.claude/hooks/commit-author-guard/test/index.test.mts b/.claude/hooks/fleet/commit-author-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/commit-author-guard/test/index.test.mts rename to .claude/hooks/fleet/commit-author-guard/test/index.test.mts diff --git a/.claude/hooks/concurrent-cargo-build-guard/tsconfig.json b/.claude/hooks/fleet/commit-author-guard/tsconfig.json similarity index 100% rename from .claude/hooks/concurrent-cargo-build-guard/tsconfig.json rename to .claude/hooks/fleet/commit-author-guard/tsconfig.json diff --git a/.claude/hooks/commit-message-format-guard/README.md b/.claude/hooks/fleet/commit-message-format-guard/README.md similarity index 100% rename from .claude/hooks/commit-message-format-guard/README.md rename to .claude/hooks/fleet/commit-message-format-guard/README.md diff --git a/.claude/hooks/commit-message-format-guard/index.mts b/.claude/hooks/fleet/commit-message-format-guard/index.mts similarity index 100% rename from .claude/hooks/commit-message-format-guard/index.mts rename to .claude/hooks/fleet/commit-message-format-guard/index.mts diff --git a/.claude/hooks/commit-message-format-guard/package.json b/.claude/hooks/fleet/commit-message-format-guard/package.json similarity index 100% rename from .claude/hooks/commit-message-format-guard/package.json rename to .claude/hooks/fleet/commit-message-format-guard/package.json diff --git a/.claude/hooks/commit-message-format-guard/test/format.test.mts b/.claude/hooks/fleet/commit-message-format-guard/test/format.test.mts similarity index 100% rename from .claude/hooks/commit-message-format-guard/test/format.test.mts rename to .claude/hooks/fleet/commit-message-format-guard/test/format.test.mts diff --git a/.claude/hooks/consumer-grep-reminder/tsconfig.json b/.claude/hooks/fleet/commit-message-format-guard/tsconfig.json similarity index 100% rename from .claude/hooks/consumer-grep-reminder/tsconfig.json rename to .claude/hooks/fleet/commit-message-format-guard/tsconfig.json diff --git a/.claude/hooks/commit-pr-reminder/README.md b/.claude/hooks/fleet/commit-pr-reminder/README.md similarity index 100% rename from .claude/hooks/commit-pr-reminder/README.md rename to .claude/hooks/fleet/commit-pr-reminder/README.md diff --git a/.claude/hooks/commit-pr-reminder/index.mts b/.claude/hooks/fleet/commit-pr-reminder/index.mts similarity index 100% rename from .claude/hooks/commit-pr-reminder/index.mts rename to .claude/hooks/fleet/commit-pr-reminder/index.mts diff --git a/.claude/hooks/commit-pr-reminder/package.json b/.claude/hooks/fleet/commit-pr-reminder/package.json similarity index 100% rename from .claude/hooks/commit-pr-reminder/package.json rename to .claude/hooks/fleet/commit-pr-reminder/package.json diff --git a/.claude/hooks/commit-pr-reminder/test/index.test.mts b/.claude/hooks/fleet/commit-pr-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/commit-pr-reminder/test/index.test.mts rename to .claude/hooks/fleet/commit-pr-reminder/test/index.test.mts diff --git a/.claude/hooks/cross-repo-guard/tsconfig.json b/.claude/hooks/fleet/commit-pr-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/cross-repo-guard/tsconfig.json rename to .claude/hooks/fleet/commit-pr-reminder/tsconfig.json diff --git a/.claude/hooks/compound-lessons-reminder/README.md b/.claude/hooks/fleet/compound-lessons-reminder/README.md similarity index 100% rename from .claude/hooks/compound-lessons-reminder/README.md rename to .claude/hooks/fleet/compound-lessons-reminder/README.md diff --git a/.claude/hooks/compound-lessons-reminder/index.mts b/.claude/hooks/fleet/compound-lessons-reminder/index.mts similarity index 100% rename from .claude/hooks/compound-lessons-reminder/index.mts rename to .claude/hooks/fleet/compound-lessons-reminder/index.mts diff --git a/.claude/hooks/compound-lessons-reminder/package.json b/.claude/hooks/fleet/compound-lessons-reminder/package.json similarity index 100% rename from .claude/hooks/compound-lessons-reminder/package.json rename to .claude/hooks/fleet/compound-lessons-reminder/package.json diff --git a/.claude/hooks/compound-lessons-reminder/test/index.test.mts b/.claude/hooks/fleet/compound-lessons-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/compound-lessons-reminder/test/index.test.mts rename to .claude/hooks/fleet/compound-lessons-reminder/test/index.test.mts diff --git a/.claude/hooks/default-branch-guard/tsconfig.json b/.claude/hooks/fleet/compound-lessons-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/default-branch-guard/tsconfig.json rename to .claude/hooks/fleet/compound-lessons-reminder/tsconfig.json diff --git a/.claude/hooks/concurrent-cargo-build-guard/README.md b/.claude/hooks/fleet/concurrent-cargo-build-guard/README.md similarity index 100% rename from .claude/hooks/concurrent-cargo-build-guard/README.md rename to .claude/hooks/fleet/concurrent-cargo-build-guard/README.md diff --git a/.claude/hooks/concurrent-cargo-build-guard/index.mts b/.claude/hooks/fleet/concurrent-cargo-build-guard/index.mts similarity index 100% rename from .claude/hooks/concurrent-cargo-build-guard/index.mts rename to .claude/hooks/fleet/concurrent-cargo-build-guard/index.mts diff --git a/.claude/hooks/concurrent-cargo-build-guard/package.json b/.claude/hooks/fleet/concurrent-cargo-build-guard/package.json similarity index 100% rename from .claude/hooks/concurrent-cargo-build-guard/package.json rename to .claude/hooks/fleet/concurrent-cargo-build-guard/package.json diff --git a/.claude/hooks/concurrent-cargo-build-guard/test/index.test.mts b/.claude/hooks/fleet/concurrent-cargo-build-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/concurrent-cargo-build-guard/test/index.test.mts rename to .claude/hooks/fleet/concurrent-cargo-build-guard/test/index.test.mts diff --git a/.claude/hooks/dirty-worktree-on-stop-reminder/tsconfig.json b/.claude/hooks/fleet/concurrent-cargo-build-guard/tsconfig.json similarity index 100% rename from .claude/hooks/dirty-worktree-on-stop-reminder/tsconfig.json rename to .claude/hooks/fleet/concurrent-cargo-build-guard/tsconfig.json diff --git a/.claude/hooks/consumer-grep-reminder/README.md b/.claude/hooks/fleet/consumer-grep-reminder/README.md similarity index 100% rename from .claude/hooks/consumer-grep-reminder/README.md rename to .claude/hooks/fleet/consumer-grep-reminder/README.md diff --git a/.claude/hooks/consumer-grep-reminder/index.mts b/.claude/hooks/fleet/consumer-grep-reminder/index.mts similarity index 100% rename from .claude/hooks/consumer-grep-reminder/index.mts rename to .claude/hooks/fleet/consumer-grep-reminder/index.mts diff --git a/.claude/hooks/consumer-grep-reminder/package.json b/.claude/hooks/fleet/consumer-grep-reminder/package.json similarity index 100% rename from .claude/hooks/consumer-grep-reminder/package.json rename to .claude/hooks/fleet/consumer-grep-reminder/package.json diff --git a/.claude/hooks/consumer-grep-reminder/test/index.test.mts b/.claude/hooks/fleet/consumer-grep-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/consumer-grep-reminder/test/index.test.mts rename to .claude/hooks/fleet/consumer-grep-reminder/test/index.test.mts diff --git a/.claude/hooks/dont-blame-user-reminder/tsconfig.json b/.claude/hooks/fleet/consumer-grep-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/dont-blame-user-reminder/tsconfig.json rename to .claude/hooks/fleet/consumer-grep-reminder/tsconfig.json diff --git a/.claude/hooks/cross-repo-guard/README.md b/.claude/hooks/fleet/cross-repo-guard/README.md similarity index 97% rename from .claude/hooks/cross-repo-guard/README.md rename to .claude/hooks/fleet/cross-repo-guard/README.md index eed74eb..c56e971 100644 --- a/.claude/hooks/cross-repo-guard/README.md +++ b/.claude/hooks/fleet/cross-repo-guard/README.md @@ -86,7 +86,7 @@ companion git-side scanner in `.git-hooks/_helpers.mts` (`FLEET_REPO_NAMES`) "hooks": [ { "type": "command", - "command": "node .claude/hooks/cross-repo-guard/index.mts" + "command": "node .claude/hooks/fleet/cross-repo-guard/index.mts" } ] } diff --git a/.claude/hooks/cross-repo-guard/index.mts b/.claude/hooks/fleet/cross-repo-guard/index.mts similarity index 100% rename from .claude/hooks/cross-repo-guard/index.mts rename to .claude/hooks/fleet/cross-repo-guard/index.mts diff --git a/.claude/hooks/cross-repo-guard/package.json b/.claude/hooks/fleet/cross-repo-guard/package.json similarity index 100% rename from .claude/hooks/cross-repo-guard/package.json rename to .claude/hooks/fleet/cross-repo-guard/package.json diff --git a/.claude/hooks/cross-repo-guard/test/cross-repo-guard.test.mts b/.claude/hooks/fleet/cross-repo-guard/test/cross-repo-guard.test.mts similarity index 100% rename from .claude/hooks/cross-repo-guard/test/cross-repo-guard.test.mts rename to .claude/hooks/fleet/cross-repo-guard/test/cross-repo-guard.test.mts diff --git a/.claude/hooks/dont-stop-mid-queue-reminder/tsconfig.json b/.claude/hooks/fleet/cross-repo-guard/tsconfig.json similarity index 100% rename from .claude/hooks/dont-stop-mid-queue-reminder/tsconfig.json rename to .claude/hooks/fleet/cross-repo-guard/tsconfig.json diff --git a/.claude/hooks/default-branch-guard/README.md b/.claude/hooks/fleet/default-branch-guard/README.md similarity index 100% rename from .claude/hooks/default-branch-guard/README.md rename to .claude/hooks/fleet/default-branch-guard/README.md diff --git a/.claude/hooks/default-branch-guard/index.mts b/.claude/hooks/fleet/default-branch-guard/index.mts similarity index 100% rename from .claude/hooks/default-branch-guard/index.mts rename to .claude/hooks/fleet/default-branch-guard/index.mts diff --git a/.claude/hooks/default-branch-guard/package.json b/.claude/hooks/fleet/default-branch-guard/package.json similarity index 100% rename from .claude/hooks/default-branch-guard/package.json rename to .claude/hooks/fleet/default-branch-guard/package.json diff --git a/.claude/hooks/default-branch-guard/test/index.test.mts b/.claude/hooks/fleet/default-branch-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/default-branch-guard/test/index.test.mts rename to .claude/hooks/fleet/default-branch-guard/test/index.test.mts diff --git a/.claude/hooks/drift-check-reminder/tsconfig.json b/.claude/hooks/fleet/default-branch-guard/tsconfig.json similarity index 100% rename from .claude/hooks/drift-check-reminder/tsconfig.json rename to .claude/hooks/fleet/default-branch-guard/tsconfig.json diff --git a/.claude/hooks/dirty-worktree-on-stop-reminder/README.md b/.claude/hooks/fleet/dirty-worktree-on-stop-reminder/README.md similarity index 100% rename from .claude/hooks/dirty-worktree-on-stop-reminder/README.md rename to .claude/hooks/fleet/dirty-worktree-on-stop-reminder/README.md diff --git a/.claude/hooks/dirty-worktree-on-stop-reminder/index.mts b/.claude/hooks/fleet/dirty-worktree-on-stop-reminder/index.mts similarity index 100% rename from .claude/hooks/dirty-worktree-on-stop-reminder/index.mts rename to .claude/hooks/fleet/dirty-worktree-on-stop-reminder/index.mts diff --git a/.claude/hooks/dirty-worktree-on-stop-reminder/package.json b/.claude/hooks/fleet/dirty-worktree-on-stop-reminder/package.json similarity index 100% rename from .claude/hooks/dirty-worktree-on-stop-reminder/package.json rename to .claude/hooks/fleet/dirty-worktree-on-stop-reminder/package.json diff --git a/.claude/hooks/dirty-worktree-on-stop-reminder/test/index.test.mts b/.claude/hooks/fleet/dirty-worktree-on-stop-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/dirty-worktree-on-stop-reminder/test/index.test.mts rename to .claude/hooks/fleet/dirty-worktree-on-stop-reminder/test/index.test.mts diff --git a/.claude/hooks/enterprise-push-property-reminder/tsconfig.json b/.claude/hooks/fleet/dirty-worktree-on-stop-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/enterprise-push-property-reminder/tsconfig.json rename to .claude/hooks/fleet/dirty-worktree-on-stop-reminder/tsconfig.json diff --git a/.claude/hooks/dont-blame-user-reminder/README.md b/.claude/hooks/fleet/dont-blame-user-reminder/README.md similarity index 69% rename from .claude/hooks/dont-blame-user-reminder/README.md rename to .claude/hooks/fleet/dont-blame-user-reminder/README.md index a297956..8e78a29 100644 --- a/.claude/hooks/dont-blame-user-reminder/README.md +++ b/.claude/hooks/fleet/dont-blame-user-reminder/README.md @@ -10,14 +10,14 @@ Past incident: the assistant repeatedly claimed "the user reverted my edits" / " ## What it catches -| Phrase shape | Why it's flagged | -| --- | --- | +| Phrase shape | Why it's flagged | +| --------------------------------------------------------------- | ---------------------------------------------------------------------- | | `the user/linter/formatter reverted/stripped/removed/rewrote …` | Attributes state to the user/tool as the cause, with no investigation. | -| `user's intentional/preferred/preserved state` | Same — assumes intent the assistant hasn't evidenced. | -| `removed/reverted/stripped by the user/linter/formatter` | Same. | -| `the user/linter wants/chose to keep/strip/remove …` | Same. | +| `user's intentional/preferred/preserved state` | Same — assumes intent the assistant hasn't evidenced. | +| `removed/reverted/stripped by the user/linter/formatter` | Same. | +| `the user/linter wants/chose to keep/strip/remove …` | Same. | -Quoted spans are stripped before matching, so the hook doesn't self-fire when the assistant *describes* these phrases (e.g. paraphrasing this doc in a turn summary). +Quoted spans are stripped before matching, so the hook doesn't self-fire when the assistant _describes_ these phrases (e.g. paraphrasing this doc in a turn summary). ## Why it blocks diff --git a/.claude/hooks/dont-blame-user-reminder/index.mts b/.claude/hooks/fleet/dont-blame-user-reminder/index.mts similarity index 100% rename from .claude/hooks/dont-blame-user-reminder/index.mts rename to .claude/hooks/fleet/dont-blame-user-reminder/index.mts diff --git a/.claude/hooks/dont-blame-user-reminder/package.json b/.claude/hooks/fleet/dont-blame-user-reminder/package.json similarity index 100% rename from .claude/hooks/dont-blame-user-reminder/package.json rename to .claude/hooks/fleet/dont-blame-user-reminder/package.json diff --git a/.claude/hooks/dont-blame-user-reminder/test/index.test.mts b/.claude/hooks/fleet/dont-blame-user-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/dont-blame-user-reminder/test/index.test.mts rename to .claude/hooks/fleet/dont-blame-user-reminder/test/index.test.mts diff --git a/.claude/hooks/error-message-quality-reminder/tsconfig.json b/.claude/hooks/fleet/dont-blame-user-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/error-message-quality-reminder/tsconfig.json rename to .claude/hooks/fleet/dont-blame-user-reminder/tsconfig.json diff --git a/.claude/hooks/dont-stop-mid-queue-reminder/README.md b/.claude/hooks/fleet/dont-stop-mid-queue-reminder/README.md similarity index 100% rename from .claude/hooks/dont-stop-mid-queue-reminder/README.md rename to .claude/hooks/fleet/dont-stop-mid-queue-reminder/README.md diff --git a/.claude/hooks/dont-stop-mid-queue-reminder/index.mts b/.claude/hooks/fleet/dont-stop-mid-queue-reminder/index.mts similarity index 100% rename from .claude/hooks/dont-stop-mid-queue-reminder/index.mts rename to .claude/hooks/fleet/dont-stop-mid-queue-reminder/index.mts diff --git a/.claude/hooks/dont-stop-mid-queue-reminder/package.json b/.claude/hooks/fleet/dont-stop-mid-queue-reminder/package.json similarity index 100% rename from .claude/hooks/dont-stop-mid-queue-reminder/package.json rename to .claude/hooks/fleet/dont-stop-mid-queue-reminder/package.json diff --git a/.claude/hooks/dont-stop-mid-queue-reminder/test/index.test.mts b/.claude/hooks/fleet/dont-stop-mid-queue-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/dont-stop-mid-queue-reminder/test/index.test.mts rename to .claude/hooks/fleet/dont-stop-mid-queue-reminder/test/index.test.mts diff --git a/.claude/hooks/excuse-detector/tsconfig.json b/.claude/hooks/fleet/dont-stop-mid-queue-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/excuse-detector/tsconfig.json rename to .claude/hooks/fleet/dont-stop-mid-queue-reminder/tsconfig.json diff --git a/.claude/hooks/drift-check-reminder/README.md b/.claude/hooks/fleet/drift-check-reminder/README.md similarity index 100% rename from .claude/hooks/drift-check-reminder/README.md rename to .claude/hooks/fleet/drift-check-reminder/README.md diff --git a/.claude/hooks/drift-check-reminder/index.mts b/.claude/hooks/fleet/drift-check-reminder/index.mts similarity index 100% rename from .claude/hooks/drift-check-reminder/index.mts rename to .claude/hooks/fleet/drift-check-reminder/index.mts diff --git a/.claude/hooks/drift-check-reminder/package.json b/.claude/hooks/fleet/drift-check-reminder/package.json similarity index 100% rename from .claude/hooks/drift-check-reminder/package.json rename to .claude/hooks/fleet/drift-check-reminder/package.json diff --git a/.claude/hooks/drift-check-reminder/test/index.test.mts b/.claude/hooks/fleet/drift-check-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/drift-check-reminder/test/index.test.mts rename to .claude/hooks/fleet/drift-check-reminder/test/index.test.mts diff --git a/.claude/hooks/extension-build-current-guard/tsconfig.json b/.claude/hooks/fleet/drift-check-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/extension-build-current-guard/tsconfig.json rename to .claude/hooks/fleet/drift-check-reminder/tsconfig.json diff --git a/.claude/hooks/enterprise-push-property-reminder/README.md b/.claude/hooks/fleet/enterprise-push-property-reminder/README.md similarity index 94% rename from .claude/hooks/enterprise-push-property-reminder/README.md rename to .claude/hooks/fleet/enterprise-push-property-reminder/README.md index c841f95..c30bae1 100644 --- a/.claude/hooks/enterprise-push-property-reminder/README.md +++ b/.claude/hooks/fleet/enterprise-push-property-reminder/README.md @@ -47,4 +47,4 @@ The pattern requires both error lines for a tight match — generic "permission - `docs/claude.md/fleet/push-policy.md` — full rationale + operator flow. - `scripts/_shared/repo-properties.mts` — `canSkipReviewGate()` implementation used by the cascade. -- `.claude/hooks/pr-vs-push-default-reminder/` — sibling hook for the reverse case (Claude opening a PR when direct push would have worked). +- `.claude/hooks/fleet/pr-vs-push-default-reminder/` — sibling hook for the reverse case (Claude opening a PR when direct push would have worked). diff --git a/.claude/hooks/enterprise-push-property-reminder/index.mts b/.claude/hooks/fleet/enterprise-push-property-reminder/index.mts similarity index 100% rename from .claude/hooks/enterprise-push-property-reminder/index.mts rename to .claude/hooks/fleet/enterprise-push-property-reminder/index.mts diff --git a/.claude/hooks/enterprise-push-property-reminder/package.json b/.claude/hooks/fleet/enterprise-push-property-reminder/package.json similarity index 100% rename from .claude/hooks/enterprise-push-property-reminder/package.json rename to .claude/hooks/fleet/enterprise-push-property-reminder/package.json diff --git a/.claude/hooks/enterprise-push-property-reminder/test/index.test.mts b/.claude/hooks/fleet/enterprise-push-property-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/enterprise-push-property-reminder/test/index.test.mts rename to .claude/hooks/fleet/enterprise-push-property-reminder/test/index.test.mts diff --git a/.claude/hooks/file-size-reminder/tsconfig.json b/.claude/hooks/fleet/enterprise-push-property-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/file-size-reminder/tsconfig.json rename to .claude/hooks/fleet/enterprise-push-property-reminder/tsconfig.json diff --git a/.claude/hooks/error-message-quality-reminder/README.md b/.claude/hooks/fleet/error-message-quality-reminder/README.md similarity index 100% rename from .claude/hooks/error-message-quality-reminder/README.md rename to .claude/hooks/fleet/error-message-quality-reminder/README.md diff --git a/.claude/hooks/error-message-quality-reminder/index.mts b/.claude/hooks/fleet/error-message-quality-reminder/index.mts similarity index 100% rename from .claude/hooks/error-message-quality-reminder/index.mts rename to .claude/hooks/fleet/error-message-quality-reminder/index.mts diff --git a/.claude/hooks/error-message-quality-reminder/package.json b/.claude/hooks/fleet/error-message-quality-reminder/package.json similarity index 100% rename from .claude/hooks/error-message-quality-reminder/package.json rename to .claude/hooks/fleet/error-message-quality-reminder/package.json diff --git a/.claude/hooks/error-message-quality-reminder/test/index.test.mts b/.claude/hooks/fleet/error-message-quality-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/error-message-quality-reminder/test/index.test.mts rename to .claude/hooks/fleet/error-message-quality-reminder/test/index.test.mts diff --git a/.claude/hooks/follow-direct-imperative-reminder/tsconfig.json b/.claude/hooks/fleet/error-message-quality-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/follow-direct-imperative-reminder/tsconfig.json rename to .claude/hooks/fleet/error-message-quality-reminder/tsconfig.json diff --git a/.claude/hooks/excuse-detector/README.md b/.claude/hooks/fleet/excuse-detector/README.md similarity index 100% rename from .claude/hooks/excuse-detector/README.md rename to .claude/hooks/fleet/excuse-detector/README.md diff --git a/.claude/hooks/excuse-detector/index.mts b/.claude/hooks/fleet/excuse-detector/index.mts similarity index 100% rename from .claude/hooks/excuse-detector/index.mts rename to .claude/hooks/fleet/excuse-detector/index.mts diff --git a/.claude/hooks/excuse-detector/package.json b/.claude/hooks/fleet/excuse-detector/package.json similarity index 100% rename from .claude/hooks/excuse-detector/package.json rename to .claude/hooks/fleet/excuse-detector/package.json diff --git a/.claude/hooks/excuse-detector/test/index.test.mts b/.claude/hooks/fleet/excuse-detector/test/index.test.mts similarity index 100% rename from .claude/hooks/excuse-detector/test/index.test.mts rename to .claude/hooks/fleet/excuse-detector/test/index.test.mts diff --git a/.claude/hooks/gh-token-hygiene-guard/tsconfig.json b/.claude/hooks/fleet/excuse-detector/tsconfig.json similarity index 100% rename from .claude/hooks/gh-token-hygiene-guard/tsconfig.json rename to .claude/hooks/fleet/excuse-detector/tsconfig.json diff --git a/.claude/hooks/extension-build-current-guard/README.md b/.claude/hooks/fleet/extension-build-current-guard/README.md similarity index 100% rename from .claude/hooks/extension-build-current-guard/README.md rename to .claude/hooks/fleet/extension-build-current-guard/README.md diff --git a/.claude/hooks/extension-build-current-guard/index.mts b/.claude/hooks/fleet/extension-build-current-guard/index.mts similarity index 100% rename from .claude/hooks/extension-build-current-guard/index.mts rename to .claude/hooks/fleet/extension-build-current-guard/index.mts diff --git a/.claude/hooks/extension-build-current-guard/package.json b/.claude/hooks/fleet/extension-build-current-guard/package.json similarity index 100% rename from .claude/hooks/extension-build-current-guard/package.json rename to .claude/hooks/fleet/extension-build-current-guard/package.json diff --git a/.claude/hooks/extension-build-current-guard/test/index.test.mts b/.claude/hooks/fleet/extension-build-current-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/extension-build-current-guard/test/index.test.mts rename to .claude/hooks/fleet/extension-build-current-guard/test/index.test.mts diff --git a/.claude/hooks/gitmodules-comment-guard/tsconfig.json b/.claude/hooks/fleet/extension-build-current-guard/tsconfig.json similarity index 100% rename from .claude/hooks/gitmodules-comment-guard/tsconfig.json rename to .claude/hooks/fleet/extension-build-current-guard/tsconfig.json diff --git a/.claude/hooks/file-size-reminder/README.md b/.claude/hooks/fleet/file-size-reminder/README.md similarity index 100% rename from .claude/hooks/file-size-reminder/README.md rename to .claude/hooks/fleet/file-size-reminder/README.md diff --git a/.claude/hooks/file-size-reminder/index.mts b/.claude/hooks/fleet/file-size-reminder/index.mts similarity index 100% rename from .claude/hooks/file-size-reminder/index.mts rename to .claude/hooks/fleet/file-size-reminder/index.mts diff --git a/.claude/hooks/file-size-reminder/package.json b/.claude/hooks/fleet/file-size-reminder/package.json similarity index 100% rename from .claude/hooks/file-size-reminder/package.json rename to .claude/hooks/fleet/file-size-reminder/package.json diff --git a/.claude/hooks/file-size-reminder/test/index.test.mts b/.claude/hooks/fleet/file-size-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/file-size-reminder/test/index.test.mts rename to .claude/hooks/fleet/file-size-reminder/test/index.test.mts diff --git a/.claude/hooks/identifying-users-reminder/tsconfig.json b/.claude/hooks/fleet/file-size-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/identifying-users-reminder/tsconfig.json rename to .claude/hooks/fleet/file-size-reminder/tsconfig.json diff --git a/.claude/hooks/follow-direct-imperative-reminder/README.md b/.claude/hooks/fleet/follow-direct-imperative-reminder/README.md similarity index 100% rename from .claude/hooks/follow-direct-imperative-reminder/README.md rename to .claude/hooks/fleet/follow-direct-imperative-reminder/README.md diff --git a/.claude/hooks/follow-direct-imperative-reminder/index.mts b/.claude/hooks/fleet/follow-direct-imperative-reminder/index.mts similarity index 100% rename from .claude/hooks/follow-direct-imperative-reminder/index.mts rename to .claude/hooks/fleet/follow-direct-imperative-reminder/index.mts diff --git a/.claude/hooks/follow-direct-imperative-reminder/package.json b/.claude/hooks/fleet/follow-direct-imperative-reminder/package.json similarity index 100% rename from .claude/hooks/follow-direct-imperative-reminder/package.json rename to .claude/hooks/fleet/follow-direct-imperative-reminder/package.json diff --git a/.claude/hooks/follow-direct-imperative-reminder/test/index.test.mts b/.claude/hooks/fleet/follow-direct-imperative-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/follow-direct-imperative-reminder/test/index.test.mts rename to .claude/hooks/fleet/follow-direct-imperative-reminder/test/index.test.mts diff --git a/.claude/hooks/immutable-release-pattern-guard/tsconfig.json b/.claude/hooks/fleet/follow-direct-imperative-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/immutable-release-pattern-guard/tsconfig.json rename to .claude/hooks/fleet/follow-direct-imperative-reminder/tsconfig.json diff --git a/.claude/hooks/gh-token-hygiene-guard/README.md b/.claude/hooks/fleet/gh-token-hygiene-guard/README.md similarity index 100% rename from .claude/hooks/gh-token-hygiene-guard/README.md rename to .claude/hooks/fleet/gh-token-hygiene-guard/README.md diff --git a/.claude/hooks/gh-token-hygiene-guard/index.mts b/.claude/hooks/fleet/gh-token-hygiene-guard/index.mts similarity index 96% rename from .claude/hooks/gh-token-hygiene-guard/index.mts rename to .claude/hooks/fleet/gh-token-hygiene-guard/index.mts index 092fefa..6dbb2c3 100644 --- a/.claude/hooks/gh-token-hygiene-guard/index.mts +++ b/.claude/hooks/fleet/gh-token-hygiene-guard/index.mts @@ -151,6 +151,18 @@ interface GhAuthStatus { } async function main(): Promise { + // CLI mode: `node index.mts --stamp` writes a fresh timestamp. + // Provides an explicit recovery path for users who ran `gh auth + // refresh` outside Claude's tool flow (so the PreToolUse-driven + // pre-stamp at line ~228 didn't fire) and got stuck on the >8h + // block. Documented in CLAUDE.md's `### gh token hygiene` section. + if (process.argv.includes('--stamp')) { + recordTokenIssuedAt() + process.stdout.write( + `gh-token-hygiene-guard: stamped ${TOKEN_ISSUED_AT_FILE}\n`, + ) + process.exit(0) + } const raw = await readStdin() let payload: PreToolUsePayload try { @@ -398,6 +410,13 @@ function isAuthMaintenanceCommand(command: string): boolean { return /\bgh\s+auth\s+(?:login|logout|refresh|status)\b/.test(command) } +// 2020-01-01T00:00:00Z in epoch ms. Any stamp file value below this is +// either zero, a POSIX-seconds value (~1.7e9) mistakenly written instead +// of ms (~1.7e12), or garbage. Treat as malformed and re-stamp so a +// user who attempted `date "+%s" > ~/.claude/gh-token-issued-at` +// doesn't get permanently blocked. +const MIN_PLAUSIBLE_STAMP_MS = 1_577_836_800_000 + function isTokenFresh(): boolean { if (!existsSync(TOKEN_ISSUED_AT_FILE)) { // First run: stamp now and treat as fresh. This makes the hook @@ -412,6 +431,15 @@ function isTokenFresh(): boolean { if (!Number.isFinite(recorded)) { return false } + // Malformed value (zero, POSIX-seconds, garbage) — re-stamp and + // treat as fresh. The actual gh token in keychain is what matters + // for security; this stamp file just tracks when we last saw a + // confirmed refresh. A wrong value here would lock the user out + // until they figured out the file format. + if (recorded < MIN_PLAUSIBLE_STAMP_MS) { + recordTokenIssuedAt() + return true + } return Date.now() - recorded < TOKEN_TTL_MS } catch { return false diff --git a/.claude/hooks/gh-token-hygiene-guard/package.json b/.claude/hooks/fleet/gh-token-hygiene-guard/package.json similarity index 100% rename from .claude/hooks/gh-token-hygiene-guard/package.json rename to .claude/hooks/fleet/gh-token-hygiene-guard/package.json diff --git a/.claude/hooks/gh-token-hygiene-guard/test/index.test.mts b/.claude/hooks/fleet/gh-token-hygiene-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/gh-token-hygiene-guard/test/index.test.mts rename to .claude/hooks/fleet/gh-token-hygiene-guard/test/index.test.mts diff --git a/.claude/hooks/inline-script-defer-guard/tsconfig.json b/.claude/hooks/fleet/gh-token-hygiene-guard/tsconfig.json similarity index 100% rename from .claude/hooks/inline-script-defer-guard/tsconfig.json rename to .claude/hooks/fleet/gh-token-hygiene-guard/tsconfig.json diff --git a/.claude/hooks/gitmodules-comment-guard/README.md b/.claude/hooks/fleet/gitmodules-comment-guard/README.md similarity index 96% rename from .claude/hooks/gitmodules-comment-guard/README.md rename to .claude/hooks/fleet/gitmodules-comment-guard/README.md index 746b54c..92a810c 100644 --- a/.claude/hooks/gitmodules-comment-guard/README.md +++ b/.claude/hooks/fleet/gitmodules-comment-guard/README.md @@ -62,7 +62,7 @@ In `.claude/settings.json`: "hooks": [ { "type": "command", - "command": "node .claude/hooks/gitmodules-comment-guard/index.mts" + "command": "node .claude/hooks/fleet/gitmodules-comment-guard/index.mts" } ] } diff --git a/.claude/hooks/gitmodules-comment-guard/index.mts b/.claude/hooks/fleet/gitmodules-comment-guard/index.mts similarity index 100% rename from .claude/hooks/gitmodules-comment-guard/index.mts rename to .claude/hooks/fleet/gitmodules-comment-guard/index.mts diff --git a/.claude/hooks/gitmodules-comment-guard/package.json b/.claude/hooks/fleet/gitmodules-comment-guard/package.json similarity index 100% rename from .claude/hooks/gitmodules-comment-guard/package.json rename to .claude/hooks/fleet/gitmodules-comment-guard/package.json diff --git a/.claude/hooks/gitmodules-comment-guard/test/index.test.mts b/.claude/hooks/fleet/gitmodules-comment-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/gitmodules-comment-guard/test/index.test.mts rename to .claude/hooks/fleet/gitmodules-comment-guard/test/index.test.mts diff --git a/.claude/hooks/judgment-reminder/tsconfig.json b/.claude/hooks/fleet/gitmodules-comment-guard/tsconfig.json similarity index 100% rename from .claude/hooks/judgment-reminder/tsconfig.json rename to .claude/hooks/fleet/gitmodules-comment-guard/tsconfig.json diff --git a/.claude/hooks/identifying-users-reminder/README.md b/.claude/hooks/fleet/identifying-users-reminder/README.md similarity index 100% rename from .claude/hooks/identifying-users-reminder/README.md rename to .claude/hooks/fleet/identifying-users-reminder/README.md diff --git a/.claude/hooks/identifying-users-reminder/index.mts b/.claude/hooks/fleet/identifying-users-reminder/index.mts similarity index 100% rename from .claude/hooks/identifying-users-reminder/index.mts rename to .claude/hooks/fleet/identifying-users-reminder/index.mts diff --git a/.claude/hooks/identifying-users-reminder/package.json b/.claude/hooks/fleet/identifying-users-reminder/package.json similarity index 100% rename from .claude/hooks/identifying-users-reminder/package.json rename to .claude/hooks/fleet/identifying-users-reminder/package.json diff --git a/.claude/hooks/identifying-users-reminder/test/index.test.mts b/.claude/hooks/fleet/identifying-users-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/identifying-users-reminder/test/index.test.mts rename to .claude/hooks/fleet/identifying-users-reminder/test/index.test.mts diff --git a/.claude/hooks/lock-step-ref-guard/tsconfig.json b/.claude/hooks/fleet/identifying-users-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/lock-step-ref-guard/tsconfig.json rename to .claude/hooks/fleet/identifying-users-reminder/tsconfig.json diff --git a/.claude/hooks/immutable-release-pattern-guard/README.md b/.claude/hooks/fleet/immutable-release-pattern-guard/README.md similarity index 100% rename from .claude/hooks/immutable-release-pattern-guard/README.md rename to .claude/hooks/fleet/immutable-release-pattern-guard/README.md diff --git a/.claude/hooks/immutable-release-pattern-guard/index.mts b/.claude/hooks/fleet/immutable-release-pattern-guard/index.mts similarity index 100% rename from .claude/hooks/immutable-release-pattern-guard/index.mts rename to .claude/hooks/fleet/immutable-release-pattern-guard/index.mts diff --git a/.claude/hooks/immutable-release-pattern-guard/package.json b/.claude/hooks/fleet/immutable-release-pattern-guard/package.json similarity index 100% rename from .claude/hooks/immutable-release-pattern-guard/package.json rename to .claude/hooks/fleet/immutable-release-pattern-guard/package.json diff --git a/.claude/hooks/immutable-release-pattern-guard/test/index.test.mts b/.claude/hooks/fleet/immutable-release-pattern-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/immutable-release-pattern-guard/test/index.test.mts rename to .claude/hooks/fleet/immutable-release-pattern-guard/test/index.test.mts diff --git a/.claude/hooks/logger-guard/tsconfig.json b/.claude/hooks/fleet/immutable-release-pattern-guard/tsconfig.json similarity index 100% rename from .claude/hooks/logger-guard/tsconfig.json rename to .claude/hooks/fleet/immutable-release-pattern-guard/tsconfig.json diff --git a/.claude/hooks/inline-script-defer-guard/README.md b/.claude/hooks/fleet/inline-script-defer-guard/README.md similarity index 100% rename from .claude/hooks/inline-script-defer-guard/README.md rename to .claude/hooks/fleet/inline-script-defer-guard/README.md diff --git a/.claude/hooks/inline-script-defer-guard/index.mts b/.claude/hooks/fleet/inline-script-defer-guard/index.mts similarity index 100% rename from .claude/hooks/inline-script-defer-guard/index.mts rename to .claude/hooks/fleet/inline-script-defer-guard/index.mts diff --git a/.claude/hooks/inline-script-defer-guard/package.json b/.claude/hooks/fleet/inline-script-defer-guard/package.json similarity index 100% rename from .claude/hooks/inline-script-defer-guard/package.json rename to .claude/hooks/fleet/inline-script-defer-guard/package.json diff --git a/.claude/hooks/inline-script-defer-guard/test/index.test.mts b/.claude/hooks/fleet/inline-script-defer-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/inline-script-defer-guard/test/index.test.mts rename to .claude/hooks/fleet/inline-script-defer-guard/test/index.test.mts diff --git a/.claude/hooks/markdown-filename-guard/tsconfig.json b/.claude/hooks/fleet/inline-script-defer-guard/tsconfig.json similarity index 100% rename from .claude/hooks/markdown-filename-guard/tsconfig.json rename to .claude/hooks/fleet/inline-script-defer-guard/tsconfig.json diff --git a/.claude/hooks/judgment-reminder/README.md b/.claude/hooks/fleet/judgment-reminder/README.md similarity index 100% rename from .claude/hooks/judgment-reminder/README.md rename to .claude/hooks/fleet/judgment-reminder/README.md diff --git a/.claude/hooks/judgment-reminder/index.mts b/.claude/hooks/fleet/judgment-reminder/index.mts similarity index 100% rename from .claude/hooks/judgment-reminder/index.mts rename to .claude/hooks/fleet/judgment-reminder/index.mts diff --git a/.claude/hooks/judgment-reminder/package.json b/.claude/hooks/fleet/judgment-reminder/package.json similarity index 100% rename from .claude/hooks/judgment-reminder/package.json rename to .claude/hooks/fleet/judgment-reminder/package.json diff --git a/.claude/hooks/judgment-reminder/test/index.test.mts b/.claude/hooks/fleet/judgment-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/judgment-reminder/test/index.test.mts rename to .claude/hooks/fleet/judgment-reminder/test/index.test.mts diff --git a/.claude/hooks/marketplace-comment-guard/tsconfig.json b/.claude/hooks/fleet/judgment-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/marketplace-comment-guard/tsconfig.json rename to .claude/hooks/fleet/judgment-reminder/tsconfig.json diff --git a/.claude/hooks/lock-step-ref-guard/README.md b/.claude/hooks/fleet/lock-step-ref-guard/README.md similarity index 100% rename from .claude/hooks/lock-step-ref-guard/README.md rename to .claude/hooks/fleet/lock-step-ref-guard/README.md diff --git a/.claude/hooks/lock-step-ref-guard/index.mts b/.claude/hooks/fleet/lock-step-ref-guard/index.mts similarity index 100% rename from .claude/hooks/lock-step-ref-guard/index.mts rename to .claude/hooks/fleet/lock-step-ref-guard/index.mts diff --git a/.claude/hooks/lock-step-ref-guard/package.json b/.claude/hooks/fleet/lock-step-ref-guard/package.json similarity index 100% rename from .claude/hooks/lock-step-ref-guard/package.json rename to .claude/hooks/fleet/lock-step-ref-guard/package.json diff --git a/.claude/hooks/lock-step-ref-guard/test/index.test.mts b/.claude/hooks/fleet/lock-step-ref-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/lock-step-ref-guard/test/index.test.mts rename to .claude/hooks/fleet/lock-step-ref-guard/test/index.test.mts diff --git a/.claude/hooks/minify-mcp-output/tsconfig.json b/.claude/hooks/fleet/lock-step-ref-guard/tsconfig.json similarity index 100% rename from .claude/hooks/minify-mcp-output/tsconfig.json rename to .claude/hooks/fleet/lock-step-ref-guard/tsconfig.json diff --git a/.claude/hooks/logger-guard/README.md b/.claude/hooks/fleet/logger-guard/README.md similarity index 98% rename from .claude/hooks/logger-guard/README.md rename to .claude/hooks/fleet/logger-guard/README.md index f5966a6..9da1e20 100644 --- a/.claude/hooks/logger-guard/README.md +++ b/.claude/hooks/fleet/logger-guard/README.md @@ -80,7 +80,7 @@ agent can apply it directly: "hooks": [ { "type": "command", - "command": "node .claude/hooks/logger-guard/index.mts" + "command": "node .claude/hooks/fleet/logger-guard/index.mts" } ] } diff --git a/.claude/hooks/logger-guard/index.mts b/.claude/hooks/fleet/logger-guard/index.mts similarity index 100% rename from .claude/hooks/logger-guard/index.mts rename to .claude/hooks/fleet/logger-guard/index.mts diff --git a/.claude/hooks/logger-guard/package.json b/.claude/hooks/fleet/logger-guard/package.json similarity index 100% rename from .claude/hooks/logger-guard/package.json rename to .claude/hooks/fleet/logger-guard/package.json diff --git a/.claude/hooks/logger-guard/test/logger-guard.test.mts b/.claude/hooks/fleet/logger-guard/test/logger-guard.test.mts similarity index 100% rename from .claude/hooks/logger-guard/test/logger-guard.test.mts rename to .claude/hooks/fleet/logger-guard/test/logger-guard.test.mts diff --git a/.claude/hooks/minimum-release-age-guard/tsconfig.json b/.claude/hooks/fleet/logger-guard/tsconfig.json similarity index 100% rename from .claude/hooks/minimum-release-age-guard/tsconfig.json rename to .claude/hooks/fleet/logger-guard/tsconfig.json diff --git a/.claude/hooks/markdown-filename-guard/README.md b/.claude/hooks/fleet/markdown-filename-guard/README.md similarity index 100% rename from .claude/hooks/markdown-filename-guard/README.md rename to .claude/hooks/fleet/markdown-filename-guard/README.md diff --git a/.claude/hooks/markdown-filename-guard/index.mts b/.claude/hooks/fleet/markdown-filename-guard/index.mts similarity index 100% rename from .claude/hooks/markdown-filename-guard/index.mts rename to .claude/hooks/fleet/markdown-filename-guard/index.mts diff --git a/.claude/hooks/markdown-filename-guard/package.json b/.claude/hooks/fleet/markdown-filename-guard/package.json similarity index 100% rename from .claude/hooks/markdown-filename-guard/package.json rename to .claude/hooks/fleet/markdown-filename-guard/package.json diff --git a/.claude/hooks/markdown-filename-guard/test/index.test.mts b/.claude/hooks/fleet/markdown-filename-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/markdown-filename-guard/test/index.test.mts rename to .claude/hooks/fleet/markdown-filename-guard/test/index.test.mts diff --git a/.claude/hooks/new-hook-claude-md-guard/tsconfig.json b/.claude/hooks/fleet/markdown-filename-guard/tsconfig.json similarity index 100% rename from .claude/hooks/new-hook-claude-md-guard/tsconfig.json rename to .claude/hooks/fleet/markdown-filename-guard/tsconfig.json diff --git a/.claude/hooks/marketplace-comment-guard/README.md b/.claude/hooks/fleet/marketplace-comment-guard/README.md similarity index 97% rename from .claude/hooks/marketplace-comment-guard/README.md rename to .claude/hooks/fleet/marketplace-comment-guard/README.md index 8264fdc..45dd258 100644 --- a/.claude/hooks/marketplace-comment-guard/README.md +++ b/.claude/hooks/fleet/marketplace-comment-guard/README.md @@ -86,7 +86,7 @@ In `.claude/settings.json`: "hooks": [ { "type": "command", - "command": "node .claude/hooks/marketplace-comment-guard/index.mts" + "command": "node .claude/hooks/fleet/marketplace-comment-guard/index.mts" } ] } diff --git a/.claude/hooks/marketplace-comment-guard/index.mts b/.claude/hooks/fleet/marketplace-comment-guard/index.mts similarity index 100% rename from .claude/hooks/marketplace-comment-guard/index.mts rename to .claude/hooks/fleet/marketplace-comment-guard/index.mts diff --git a/.claude/hooks/marketplace-comment-guard/package.json b/.claude/hooks/fleet/marketplace-comment-guard/package.json similarity index 100% rename from .claude/hooks/marketplace-comment-guard/package.json rename to .claude/hooks/fleet/marketplace-comment-guard/package.json diff --git a/.claude/hooks/marketplace-comment-guard/test/index.test.mts b/.claude/hooks/fleet/marketplace-comment-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/marketplace-comment-guard/test/index.test.mts rename to .claude/hooks/fleet/marketplace-comment-guard/test/index.test.mts diff --git a/.claude/hooks/no-blind-keychain-read-guard/tsconfig.json b/.claude/hooks/fleet/marketplace-comment-guard/tsconfig.json similarity index 100% rename from .claude/hooks/no-blind-keychain-read-guard/tsconfig.json rename to .claude/hooks/fleet/marketplace-comment-guard/tsconfig.json diff --git a/.claude/hooks/minify-mcp-output/README.md b/.claude/hooks/fleet/minify-mcp-output/README.md similarity index 98% rename from .claude/hooks/minify-mcp-output/README.md rename to .claude/hooks/fleet/minify-mcp-output/README.md index 52af7c5..930f47a 100644 --- a/.claude/hooks/minify-mcp-output/README.md +++ b/.claude/hooks/fleet/minify-mcp-output/README.md @@ -58,7 +58,7 @@ In `.claude/settings.json`: "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/minify-mcp-output/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/minify-mcp-output/index.mts" } ] } diff --git a/.claude/hooks/minify-mcp-output/index.mts b/.claude/hooks/fleet/minify-mcp-output/index.mts similarity index 100% rename from .claude/hooks/minify-mcp-output/index.mts rename to .claude/hooks/fleet/minify-mcp-output/index.mts diff --git a/.claude/hooks/minify-mcp-output/package.json b/.claude/hooks/fleet/minify-mcp-output/package.json similarity index 100% rename from .claude/hooks/minify-mcp-output/package.json rename to .claude/hooks/fleet/minify-mcp-output/package.json diff --git a/.claude/hooks/minify-mcp-output/test/index.test.mts b/.claude/hooks/fleet/minify-mcp-output/test/index.test.mts similarity index 100% rename from .claude/hooks/minify-mcp-output/test/index.test.mts rename to .claude/hooks/fleet/minify-mcp-output/test/index.test.mts diff --git a/.claude/hooks/no-disable-lint-rule-guard/tsconfig.json b/.claude/hooks/fleet/minify-mcp-output/tsconfig.json similarity index 100% rename from .claude/hooks/no-disable-lint-rule-guard/tsconfig.json rename to .claude/hooks/fleet/minify-mcp-output/tsconfig.json diff --git a/.claude/hooks/minimum-release-age-guard/README.md b/.claude/hooks/fleet/minimum-release-age-guard/README.md similarity index 100% rename from .claude/hooks/minimum-release-age-guard/README.md rename to .claude/hooks/fleet/minimum-release-age-guard/README.md diff --git a/.claude/hooks/minimum-release-age-guard/index.mts b/.claude/hooks/fleet/minimum-release-age-guard/index.mts similarity index 100% rename from .claude/hooks/minimum-release-age-guard/index.mts rename to .claude/hooks/fleet/minimum-release-age-guard/index.mts diff --git a/.claude/hooks/minimum-release-age-guard/package.json b/.claude/hooks/fleet/minimum-release-age-guard/package.json similarity index 100% rename from .claude/hooks/minimum-release-age-guard/package.json rename to .claude/hooks/fleet/minimum-release-age-guard/package.json diff --git a/.claude/hooks/minimum-release-age-guard/test/index.test.mts b/.claude/hooks/fleet/minimum-release-age-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/minimum-release-age-guard/test/index.test.mts rename to .claude/hooks/fleet/minimum-release-age-guard/test/index.test.mts diff --git a/.claude/hooks/no-empty-commit-guard/tsconfig.json b/.claude/hooks/fleet/minimum-release-age-guard/tsconfig.json similarity index 100% rename from .claude/hooks/no-empty-commit-guard/tsconfig.json rename to .claude/hooks/fleet/minimum-release-age-guard/tsconfig.json diff --git a/.claude/hooks/new-hook-claude-md-guard/README.md b/.claude/hooks/fleet/new-hook-claude-md-guard/README.md similarity index 92% rename from .claude/hooks/new-hook-claude-md-guard/README.md rename to .claude/hooks/fleet/new-hook-claude-md-guard/README.md index e506a32..9b1f2d9 100644 --- a/.claude/hooks/new-hook-claude-md-guard/README.md +++ b/.claude/hooks/fleet/new-hook-claude-md-guard/README.md @@ -26,7 +26,7 @@ Accepted variants: ## Why wheelhouse-only -Downstream fleet repos receive their CLAUDE.md and hook code via `sync-scaffolding`. They consume the canonical version; they shouldn't be re-policing the source-of-truth mapping. This hook lives in `template/.claude/hooks/new-hook-claude-md-guard/` but is **NOT** listed in `scripts/sync-scaffolding/manifest.mts`'s `IDENTICAL_FILES`, so the cascade skips it. +Downstream fleet repos receive their CLAUDE.md and hook code via `sync-scaffolding`. They consume the canonical version; they shouldn't be re-policing the source-of-truth mapping. This hook lives in `template/.claude/hooks/fleet/new-hook-claude-md-guard/` but is **NOT** listed in `scripts/sync-scaffolding/manifest.mts`'s `IDENTICAL_FILES`, so the cascade skips it. ## Skipped paths diff --git a/.claude/hooks/new-hook-claude-md-guard/index.mts b/.claude/hooks/fleet/new-hook-claude-md-guard/index.mts similarity index 66% rename from .claude/hooks/new-hook-claude-md-guard/index.mts rename to .claude/hooks/fleet/new-hook-claude-md-guard/index.mts index d573642..1160e30 100644 --- a/.claude/hooks/new-hook-claude-md-guard/index.mts +++ b/.claude/hooks/fleet/new-hook-claude-md-guard/index.mts @@ -56,9 +56,15 @@ const BYPASS_PHRASES = [ // /.claude/hooks//index.mts (any fleet repo) // // Captures the hook name in group 1. The optional `template/` segment -// covers the wheelhouse path; the rest is identical. +// covers the wheelhouse path; the optional `fleet/` or `repo/` segment +// covers the docs-style `.claude/hooks/{fleet,repo}//` layout +// (matches the parallel docs/claude.md/{fleet,repo}/ convention). +// hookName is the LEAF name (e.g. `avoid-cd-reminder`), not the +// segment-qualified path — citations and registry refs use the full +// canonical path (`\`.claude/hooks/fleet//\``) so the guard's +// expectedRefs uses that path verbatim when checking. const HOOK_INDEX_PATH_RE = - /.*?(?:\/template)?\/\.claude\/hooks\/([^/]+)\/index\.mts$/ + /.*?(?:\/template)?\/\.claude\/hooks\/(?:(fleet|repo)\/)?([^/]+)\/index\.mts$/ // Hooks that are themselves wheelhouse-only — they don't need a // CLAUDE.md entry because they're internal tooling, not policy rules @@ -108,75 +114,94 @@ export function readPayload(raw: string): PreToolUsePayload | undefined { async function main(): Promise { if (process.env[ENV_DISABLE]) { - process.exit(0) + return } const payloadRaw = await readStdin() const payload = readPayload(payloadRaw) if (!payload) { - process.exit(0) + return } const toolName = payload.tool_name if (toolName !== 'Edit' && toolName !== 'Write') { - process.exit(0) + return } const filePath = payload.tool_input?.['file_path'] if (typeof filePath !== 'string') { - process.exit(0) + return } const match = HOOK_INDEX_PATH_RE.exec(filePath) if (!match) { - process.exit(0) + return } - const hookName = match[1]! + // match[1] = "fleet" | "repo" | undefined (legacy top-level layout). + // match[2] = leaf hook name. + const segment = match[1] + const hookName = match[2]! + // hookPathSuffix is the canonical path under .claude/hooks/, used + // verbatim in CLAUDE.md citations: + // fleet → `fleet/` + // repo → `repo/` (per-repo, normally exempt — see below) + // (none) → `` (legacy top-level) + const hookPathSuffix = segment ? `${segment}/${hookName}` : hookName // Skip _shared (helpers, not a hook) and wheelhouse-only hooks. if (hookName === '_shared' || WHEELHOUSE_ONLY_HOOKS.has(hookName)) { - process.exit(0) + return + } + // Per-repo hooks at `.claude/hooks/repo//` are NOT cascaded + // and live entirely in the host repo. Skip the CLAUDE.md citation + // requirement — repo hooks document themselves in their own README + // + the host repo's CLAUDE.md decides whether to cite them. + if (segment === 'repo') { + return } // Bypass via canonical user phrase. if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASES)) { - process.exit(0) + return } const claudeMdPath = findCanonicalClaudeMd(filePath, payload.cwd) if (!claudeMdPath || !existsSync(claudeMdPath)) { // Can't find CLAUDE.md; fail-open rather than blocking on // infrastructure problems. - process.exit(0) + return } let content: string try { content = readFileSync(claudeMdPath, 'utf8') } catch { - process.exit(0) - } - // The required form is `(enforced by `.claude/hooks//`)`. - // We accept either backtick-quoted or plain-text variants of the - // path — the existing fleet uses backticks consistently, but a - // trailing slash is also optional. - const expectedRefs = [ - `(enforced by \`.claude/hooks/${hookName}/\`)`, - `(enforced by \`.claude/hooks/${hookName}\`)`, - `enforced by \`.claude/hooks/${hookName}/\``, - `enforced by \`.claude/hooks/${hookName}\``, - ] - let found = false - for (let i = 0, { length } = expectedRefs; i < length; i += 1) { - if (content.includes(expectedRefs[i]!)) { - found = true - break - } + return } + // Three citation shapes recognized: + // 1. Inline rule: `enforced by \`.claude/hooks/fleet//\`` + // 2. Comma-listed: `enforced by \`.claude/hooks/fleet/a/\`, \`.../b/\`` + // 3. Brace-grouped: `enforced by \`.claude/hooks/fleet/{a,b,c}/\`` + // 1+2 contain the literal backticked path; 3 is a brace expansion + // — the leaf name appears between `{...}`. + const literalSlashed = `\`.claude/hooks/${hookPathSuffix}/\`` + const literalBare = `\`.claude/hooks/${hookPathSuffix}\`` + const lastSlash = hookPathSuffix.lastIndexOf('/') + const prefix = lastSlash >= 0 ? hookPathSuffix.slice(0, lastSlash + 1) : '' + const leaf = + lastSlash >= 0 ? hookPathSuffix.slice(lastSlash + 1) : hookPathSuffix + const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const braceRe = new RegExp( + `\`\\.claude/hooks/${escape(prefix)}\\{[^}]*\\b${escape(leaf)}\\b[^}]*\\}/\``, + ) + const found = + content.includes(literalSlashed) || + content.includes(literalBare) || + braceRe.test(content) if (found) { - process.exit(0) + return } const lines = [ - `[new-hook-claude-md-guard] Hook "${hookName}" missing CLAUDE.md reference.`, + `[new-hook-claude-md-guard] Hook "${hookPathSuffix}" missing CLAUDE.md reference.`, '', ` ${toolName} blocked: template/CLAUDE.md must contain a one-line`, ` reference to the hook before it lands. Expected form (inline,`, ` attached to the rule the hook enforces):`, '', - ` (enforced by \`.claude/hooks/${hookName}/\`)`, + ` (enforced by \`.claude/hooks/${hookPathSuffix}/\`)`, '', ' Why: fleet repos read CLAUDE.md as the source of truth. A hook', " without a CLAUDE.md entry is policy that doesn't exist on paper —", @@ -193,5 +218,7 @@ async function main(): Promise { } main().catch(() => { - process.exit(0) + // Fail-open: never block a session on this hook's own bug. + // Loop drains naturally to exit 0; explicit set for clarity. + process.exitCode = 0 }) diff --git a/.claude/hooks/new-hook-claude-md-guard/package.json b/.claude/hooks/fleet/new-hook-claude-md-guard/package.json similarity index 100% rename from .claude/hooks/new-hook-claude-md-guard/package.json rename to .claude/hooks/fleet/new-hook-claude-md-guard/package.json diff --git a/.claude/hooks/new-hook-claude-md-guard/test/index.test.mts b/.claude/hooks/fleet/new-hook-claude-md-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/new-hook-claude-md-guard/test/index.test.mts rename to .claude/hooks/fleet/new-hook-claude-md-guard/test/index.test.mts diff --git a/.claude/hooks/no-experimental-strip-types-guard/tsconfig.json b/.claude/hooks/fleet/new-hook-claude-md-guard/tsconfig.json similarity index 100% rename from .claude/hooks/no-experimental-strip-types-guard/tsconfig.json rename to .claude/hooks/fleet/new-hook-claude-md-guard/tsconfig.json diff --git a/.claude/hooks/no-blind-keychain-read-guard/README.md b/.claude/hooks/fleet/no-blind-keychain-read-guard/README.md similarity index 100% rename from .claude/hooks/no-blind-keychain-read-guard/README.md rename to .claude/hooks/fleet/no-blind-keychain-read-guard/README.md diff --git a/.claude/hooks/no-blind-keychain-read-guard/index.mts b/.claude/hooks/fleet/no-blind-keychain-read-guard/index.mts similarity index 100% rename from .claude/hooks/no-blind-keychain-read-guard/index.mts rename to .claude/hooks/fleet/no-blind-keychain-read-guard/index.mts diff --git a/.claude/hooks/no-blind-keychain-read-guard/package.json b/.claude/hooks/fleet/no-blind-keychain-read-guard/package.json similarity index 100% rename from .claude/hooks/no-blind-keychain-read-guard/package.json rename to .claude/hooks/fleet/no-blind-keychain-read-guard/package.json diff --git a/.claude/hooks/no-blind-keychain-read-guard/test/index.test.mts b/.claude/hooks/fleet/no-blind-keychain-read-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/no-blind-keychain-read-guard/test/index.test.mts rename to .claude/hooks/fleet/no-blind-keychain-read-guard/test/index.test.mts diff --git a/.claude/hooks/no-external-issue-ref-guard/tsconfig.json b/.claude/hooks/fleet/no-blind-keychain-read-guard/tsconfig.json similarity index 100% rename from .claude/hooks/no-external-issue-ref-guard/tsconfig.json rename to .claude/hooks/fleet/no-blind-keychain-read-guard/tsconfig.json diff --git a/.claude/hooks/no-disable-lint-rule-guard/README.md b/.claude/hooks/fleet/no-disable-lint-rule-guard/README.md similarity index 100% rename from .claude/hooks/no-disable-lint-rule-guard/README.md rename to .claude/hooks/fleet/no-disable-lint-rule-guard/README.md diff --git a/.claude/hooks/no-disable-lint-rule-guard/index.mts b/.claude/hooks/fleet/no-disable-lint-rule-guard/index.mts similarity index 100% rename from .claude/hooks/no-disable-lint-rule-guard/index.mts rename to .claude/hooks/fleet/no-disable-lint-rule-guard/index.mts diff --git a/.claude/hooks/no-disable-lint-rule-guard/package.json b/.claude/hooks/fleet/no-disable-lint-rule-guard/package.json similarity index 100% rename from .claude/hooks/no-disable-lint-rule-guard/package.json rename to .claude/hooks/fleet/no-disable-lint-rule-guard/package.json diff --git a/.claude/hooks/no-disable-lint-rule-guard/test/index.test.mts b/.claude/hooks/fleet/no-disable-lint-rule-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/no-disable-lint-rule-guard/test/index.test.mts rename to .claude/hooks/fleet/no-disable-lint-rule-guard/test/index.test.mts diff --git a/.claude/hooks/no-file-scope-oxlint-disable-guard/tsconfig.json b/.claude/hooks/fleet/no-disable-lint-rule-guard/tsconfig.json similarity index 100% rename from .claude/hooks/no-file-scope-oxlint-disable-guard/tsconfig.json rename to .claude/hooks/fleet/no-disable-lint-rule-guard/tsconfig.json diff --git a/.claude/hooks/no-empty-commit-guard/README.md b/.claude/hooks/fleet/no-empty-commit-guard/README.md similarity index 100% rename from .claude/hooks/no-empty-commit-guard/README.md rename to .claude/hooks/fleet/no-empty-commit-guard/README.md diff --git a/.claude/hooks/no-empty-commit-guard/index.mts b/.claude/hooks/fleet/no-empty-commit-guard/index.mts similarity index 100% rename from .claude/hooks/no-empty-commit-guard/index.mts rename to .claude/hooks/fleet/no-empty-commit-guard/index.mts diff --git a/.claude/hooks/no-empty-commit-guard/package.json b/.claude/hooks/fleet/no-empty-commit-guard/package.json similarity index 100% rename from .claude/hooks/no-empty-commit-guard/package.json rename to .claude/hooks/fleet/no-empty-commit-guard/package.json diff --git a/.claude/hooks/no-empty-commit-guard/test/index.test.mts b/.claude/hooks/fleet/no-empty-commit-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/no-empty-commit-guard/test/index.test.mts rename to .claude/hooks/fleet/no-empty-commit-guard/test/index.test.mts diff --git a/.claude/hooks/no-fleet-fork-guard/tsconfig.json b/.claude/hooks/fleet/no-empty-commit-guard/tsconfig.json similarity index 100% rename from .claude/hooks/no-fleet-fork-guard/tsconfig.json rename to .claude/hooks/fleet/no-empty-commit-guard/tsconfig.json diff --git a/.claude/hooks/no-experimental-strip-types-guard/README.md b/.claude/hooks/fleet/no-experimental-strip-types-guard/README.md similarity index 100% rename from .claude/hooks/no-experimental-strip-types-guard/README.md rename to .claude/hooks/fleet/no-experimental-strip-types-guard/README.md diff --git a/.claude/hooks/no-experimental-strip-types-guard/index.mts b/.claude/hooks/fleet/no-experimental-strip-types-guard/index.mts similarity index 100% rename from .claude/hooks/no-experimental-strip-types-guard/index.mts rename to .claude/hooks/fleet/no-experimental-strip-types-guard/index.mts diff --git a/.claude/hooks/no-experimental-strip-types-guard/package.json b/.claude/hooks/fleet/no-experimental-strip-types-guard/package.json similarity index 100% rename from .claude/hooks/no-experimental-strip-types-guard/package.json rename to .claude/hooks/fleet/no-experimental-strip-types-guard/package.json diff --git a/.claude/hooks/no-experimental-strip-types-guard/test/index.test.mts b/.claude/hooks/fleet/no-experimental-strip-types-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/no-experimental-strip-types-guard/test/index.test.mts rename to .claude/hooks/fleet/no-experimental-strip-types-guard/test/index.test.mts diff --git a/.claude/hooks/no-meta-comments-guard/tsconfig.json b/.claude/hooks/fleet/no-experimental-strip-types-guard/tsconfig.json similarity index 100% rename from .claude/hooks/no-meta-comments-guard/tsconfig.json rename to .claude/hooks/fleet/no-experimental-strip-types-guard/tsconfig.json diff --git a/.claude/hooks/no-external-issue-ref-guard/README.md b/.claude/hooks/fleet/no-external-issue-ref-guard/README.md similarity index 100% rename from .claude/hooks/no-external-issue-ref-guard/README.md rename to .claude/hooks/fleet/no-external-issue-ref-guard/README.md diff --git a/.claude/hooks/no-external-issue-ref-guard/index.mts b/.claude/hooks/fleet/no-external-issue-ref-guard/index.mts similarity index 100% rename from .claude/hooks/no-external-issue-ref-guard/index.mts rename to .claude/hooks/fleet/no-external-issue-ref-guard/index.mts diff --git a/.claude/hooks/no-external-issue-ref-guard/package.json b/.claude/hooks/fleet/no-external-issue-ref-guard/package.json similarity index 100% rename from .claude/hooks/no-external-issue-ref-guard/package.json rename to .claude/hooks/fleet/no-external-issue-ref-guard/package.json diff --git a/.claude/hooks/no-external-issue-ref-guard/test/index.test.mts b/.claude/hooks/fleet/no-external-issue-ref-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/no-external-issue-ref-guard/test/index.test.mts rename to .claude/hooks/fleet/no-external-issue-ref-guard/test/index.test.mts diff --git a/.claude/hooks/no-non-fleet-push-guard/tsconfig.json b/.claude/hooks/fleet/no-external-issue-ref-guard/tsconfig.json similarity index 100% rename from .claude/hooks/no-non-fleet-push-guard/tsconfig.json rename to .claude/hooks/fleet/no-external-issue-ref-guard/tsconfig.json diff --git a/.claude/hooks/no-file-scope-oxlint-disable-guard/README.md b/.claude/hooks/fleet/no-file-scope-oxlint-disable-guard/README.md similarity index 100% rename from .claude/hooks/no-file-scope-oxlint-disable-guard/README.md rename to .claude/hooks/fleet/no-file-scope-oxlint-disable-guard/README.md diff --git a/.claude/hooks/no-file-scope-oxlint-disable-guard/index.mts b/.claude/hooks/fleet/no-file-scope-oxlint-disable-guard/index.mts similarity index 100% rename from .claude/hooks/no-file-scope-oxlint-disable-guard/index.mts rename to .claude/hooks/fleet/no-file-scope-oxlint-disable-guard/index.mts diff --git a/.claude/hooks/no-file-scope-oxlint-disable-guard/package.json b/.claude/hooks/fleet/no-file-scope-oxlint-disable-guard/package.json similarity index 100% rename from .claude/hooks/no-file-scope-oxlint-disable-guard/package.json rename to .claude/hooks/fleet/no-file-scope-oxlint-disable-guard/package.json diff --git a/.claude/hooks/fleet/no-file-scope-oxlint-disable-guard/test/index.test.mts b/.claude/hooks/fleet/no-file-scope-oxlint-disable-guard/test/index.test.mts new file mode 100644 index 0000000..26c2472 --- /dev/null +++ b/.claude/hooks/fleet/no-file-scope-oxlint-disable-guard/test/index.test.mts @@ -0,0 +1,38 @@ +/** + * @file Smoke test for no-file-scope-oxlint-disable-guard. + * PreToolUse(Edit|Write) hook that blocks file-scope `oxlint-disable` / + * `oxlint-disable-next-line` blocks at the top of a file. The block scope + * silently exempts future edits the author never thought about; per-line + * disables with rationale are the right shape. Smoke contract: + * + * - benign payload (non-Edit/Write tool, or no oxlint-disable in content) → + * exit 0. + * - the hook loads + dispatches without throwing. + */ + +import { spawn } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import test from 'node:test' +import assert from 'node:assert/strict' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +async function runHook(payload: unknown): Promise<{ code: number }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + child.on('error', reject) + child.on('close', code => resolve({ code: code ?? 1 })) + child.stdin.end(JSON.stringify(payload)) + }) +} + +test('benign payload exits 0', async () => { + const result = await runHook({ + tool_name: 'Read', + tool_input: { file_path: '/tmp/example.ts' }, + }) + assert.equal(result.code, 0) +}) diff --git a/.claude/hooks/no-orphaned-staging/tsconfig.json b/.claude/hooks/fleet/no-file-scope-oxlint-disable-guard/tsconfig.json similarity index 100% rename from .claude/hooks/no-orphaned-staging/tsconfig.json rename to .claude/hooks/fleet/no-file-scope-oxlint-disable-guard/tsconfig.json diff --git a/.claude/hooks/no-fleet-fork-guard/README.md b/.claude/hooks/fleet/no-fleet-fork-guard/README.md similarity index 100% rename from .claude/hooks/no-fleet-fork-guard/README.md rename to .claude/hooks/fleet/no-fleet-fork-guard/README.md diff --git a/.claude/hooks/no-fleet-fork-guard/index.mts b/.claude/hooks/fleet/no-fleet-fork-guard/index.mts similarity index 91% rename from .claude/hooks/no-fleet-fork-guard/index.mts rename to .claude/hooks/fleet/no-fleet-fork-guard/index.mts index fcfbe67..16bb1f3 100644 --- a/.claude/hooks/no-fleet-fork-guard/index.mts +++ b/.claude/hooks/fleet/no-fleet-fork-guard/index.mts @@ -74,11 +74,22 @@ const CANONICAL_PREFIXES = [ ] // Carve-out: paths under a CANONICAL_PREFIXES dir that are explicitly -// per-repo (not cascaded). `docs/claude.md/repo/` is the per-repo -// analog of `docs/claude.md/fleet/` — host repos drop architecture / -// commands / build-pipeline detail here to keep CLAUDE.md under the -// whole-file size cap. -const PER_REPO_PREFIXES = ['docs/claude.md/repo/'] +// per-repo (not cascaded). Mirrors the docs convention: +// docs/claude.md/fleet/ — cascaded, edited in template +// docs/claude.md/repo/ — local, edited in the host repo +// And extends it to hooks + scripts: +// .claude/hooks// — fleet (default; cascaded) +// .claude/hooks/repo// — per-repo, local-only +// scripts/ — fleet (default; cascaded) +// scripts/repo/ — per-repo, local-only +// Repo-local hooks/scripts let a host repo address one-off concerns +// (e.g. socket-btm's gypi source-path quirk) without forcing the +// whole fleet to carry the rule. +const PER_REPO_PREFIXES = [ + 'docs/claude.md/repo/', + '.claude/hooks/repo/', + 'scripts/repo/', +] // Fleet-canonical individual files (not under one of the prefix // dirs). Matches relative-to-repo-root. diff --git a/.claude/hooks/no-fleet-fork-guard/package.json b/.claude/hooks/fleet/no-fleet-fork-guard/package.json similarity index 100% rename from .claude/hooks/no-fleet-fork-guard/package.json rename to .claude/hooks/fleet/no-fleet-fork-guard/package.json diff --git a/.claude/hooks/no-fleet-fork-guard/test/index.test.mts b/.claude/hooks/fleet/no-fleet-fork-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/no-fleet-fork-guard/test/index.test.mts rename to .claude/hooks/fleet/no-fleet-fork-guard/test/index.test.mts diff --git a/.claude/hooks/no-package-json-pnpm-overrides-guard/tsconfig.json b/.claude/hooks/fleet/no-fleet-fork-guard/tsconfig.json similarity index 100% rename from .claude/hooks/no-package-json-pnpm-overrides-guard/tsconfig.json rename to .claude/hooks/fleet/no-fleet-fork-guard/tsconfig.json diff --git a/.claude/hooks/no-meta-comments-guard/README.md b/.claude/hooks/fleet/no-meta-comments-guard/README.md similarity index 100% rename from .claude/hooks/no-meta-comments-guard/README.md rename to .claude/hooks/fleet/no-meta-comments-guard/README.md diff --git a/.claude/hooks/no-meta-comments-guard/index.mts b/.claude/hooks/fleet/no-meta-comments-guard/index.mts similarity index 100% rename from .claude/hooks/no-meta-comments-guard/index.mts rename to .claude/hooks/fleet/no-meta-comments-guard/index.mts diff --git a/.claude/hooks/no-meta-comments-guard/package.json b/.claude/hooks/fleet/no-meta-comments-guard/package.json similarity index 100% rename from .claude/hooks/no-meta-comments-guard/package.json rename to .claude/hooks/fleet/no-meta-comments-guard/package.json diff --git a/.claude/hooks/no-meta-comments-guard/test/index.test.mts b/.claude/hooks/fleet/no-meta-comments-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/no-meta-comments-guard/test/index.test.mts rename to .claude/hooks/fleet/no-meta-comments-guard/test/index.test.mts diff --git a/.claude/hooks/no-revert-guard/tsconfig.json b/.claude/hooks/fleet/no-meta-comments-guard/tsconfig.json similarity index 100% rename from .claude/hooks/no-revert-guard/tsconfig.json rename to .claude/hooks/fleet/no-meta-comments-guard/tsconfig.json diff --git a/.claude/hooks/no-non-fleet-push-guard/README.md b/.claude/hooks/fleet/no-non-fleet-push-guard/README.md similarity index 100% rename from .claude/hooks/no-non-fleet-push-guard/README.md rename to .claude/hooks/fleet/no-non-fleet-push-guard/README.md diff --git a/.claude/hooks/no-non-fleet-push-guard/index.mts b/.claude/hooks/fleet/no-non-fleet-push-guard/index.mts similarity index 100% rename from .claude/hooks/no-non-fleet-push-guard/index.mts rename to .claude/hooks/fleet/no-non-fleet-push-guard/index.mts diff --git a/.claude/hooks/no-non-fleet-push-guard/package.json b/.claude/hooks/fleet/no-non-fleet-push-guard/package.json similarity index 100% rename from .claude/hooks/no-non-fleet-push-guard/package.json rename to .claude/hooks/fleet/no-non-fleet-push-guard/package.json diff --git a/.claude/hooks/no-non-fleet-push-guard/test/index.test.mts b/.claude/hooks/fleet/no-non-fleet-push-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/no-non-fleet-push-guard/test/index.test.mts rename to .claude/hooks/fleet/no-non-fleet-push-guard/test/index.test.mts diff --git a/.claude/hooks/no-structured-clone-prefer-json-guard/tsconfig.json b/.claude/hooks/fleet/no-non-fleet-push-guard/tsconfig.json similarity index 100% rename from .claude/hooks/no-structured-clone-prefer-json-guard/tsconfig.json rename to .claude/hooks/fleet/no-non-fleet-push-guard/tsconfig.json diff --git a/.claude/hooks/no-orphaned-staging/README.md b/.claude/hooks/fleet/no-orphaned-staging/README.md similarity index 100% rename from .claude/hooks/no-orphaned-staging/README.md rename to .claude/hooks/fleet/no-orphaned-staging/README.md diff --git a/.claude/hooks/no-orphaned-staging/index.mts b/.claude/hooks/fleet/no-orphaned-staging/index.mts similarity index 100% rename from .claude/hooks/no-orphaned-staging/index.mts rename to .claude/hooks/fleet/no-orphaned-staging/index.mts diff --git a/.claude/hooks/no-orphaned-staging/package.json b/.claude/hooks/fleet/no-orphaned-staging/package.json similarity index 100% rename from .claude/hooks/no-orphaned-staging/package.json rename to .claude/hooks/fleet/no-orphaned-staging/package.json diff --git a/.claude/hooks/no-orphaned-staging/test/index.test.mts b/.claude/hooks/fleet/no-orphaned-staging/test/index.test.mts similarity index 100% rename from .claude/hooks/no-orphaned-staging/test/index.test.mts rename to .claude/hooks/fleet/no-orphaned-staging/test/index.test.mts diff --git a/.claude/hooks/no-token-in-dotenv-guard/tsconfig.json b/.claude/hooks/fleet/no-orphaned-staging/tsconfig.json similarity index 100% rename from .claude/hooks/no-token-in-dotenv-guard/tsconfig.json rename to .claude/hooks/fleet/no-orphaned-staging/tsconfig.json diff --git a/.claude/hooks/no-package-json-pnpm-overrides-guard/README.md b/.claude/hooks/fleet/no-package-json-pnpm-overrides-guard/README.md similarity index 100% rename from .claude/hooks/no-package-json-pnpm-overrides-guard/README.md rename to .claude/hooks/fleet/no-package-json-pnpm-overrides-guard/README.md diff --git a/.claude/hooks/no-package-json-pnpm-overrides-guard/index.mts b/.claude/hooks/fleet/no-package-json-pnpm-overrides-guard/index.mts similarity index 100% rename from .claude/hooks/no-package-json-pnpm-overrides-guard/index.mts rename to .claude/hooks/fleet/no-package-json-pnpm-overrides-guard/index.mts diff --git a/.claude/hooks/no-package-json-pnpm-overrides-guard/package.json b/.claude/hooks/fleet/no-package-json-pnpm-overrides-guard/package.json similarity index 100% rename from .claude/hooks/no-package-json-pnpm-overrides-guard/package.json rename to .claude/hooks/fleet/no-package-json-pnpm-overrides-guard/package.json diff --git a/.claude/hooks/no-package-json-pnpm-overrides-guard/test/index.test.mts b/.claude/hooks/fleet/no-package-json-pnpm-overrides-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/no-package-json-pnpm-overrides-guard/test/index.test.mts rename to .claude/hooks/fleet/no-package-json-pnpm-overrides-guard/test/index.test.mts diff --git a/.claude/hooks/no-underscore-identifier-guard/tsconfig.json b/.claude/hooks/fleet/no-package-json-pnpm-overrides-guard/tsconfig.json similarity index 100% rename from .claude/hooks/no-underscore-identifier-guard/tsconfig.json rename to .claude/hooks/fleet/no-package-json-pnpm-overrides-guard/tsconfig.json diff --git a/.claude/hooks/no-revert-guard/README.md b/.claude/hooks/fleet/no-revert-guard/README.md similarity index 100% rename from .claude/hooks/no-revert-guard/README.md rename to .claude/hooks/fleet/no-revert-guard/README.md diff --git a/.claude/hooks/no-revert-guard/index.mts b/.claude/hooks/fleet/no-revert-guard/index.mts similarity index 88% rename from .claude/hooks/no-revert-guard/index.mts rename to .claude/hooks/fleet/no-revert-guard/index.mts index 0be74a6..4d756fc 100644 --- a/.claude/hooks/no-revert-guard/index.mts +++ b/.claude/hooks/fleet/no-revert-guard/index.mts @@ -14,8 +14,11 @@ // user must type "Allow bypass" where matches the flag // (e.g. "Allow no-verify bypass", "Allow lint bypass", // "Allow gpg bypass"). -// - Force push (--force / -f to push or push-with-lease) → -// user must type "Allow force-push bypass". +// - Force push --force-with-lease (safer; aborts if remote moved) → +// user must type "Allow force-with-lease bypass". +// - Force push --force / -f (CAN silently clobber remote commits) → +// user must type "Allow force-push bypass". Always reach for +// --force-with-lease first; this is the high-friction path. // // Phrase scoping: the hook reads the recent user turns from the // transcript (most recent N user messages). A phrase from a prior @@ -171,15 +174,39 @@ const CHECKS: readonly GuardCheck[] = [ /(?:^|[\s;&|(`])(?:python3?\s+-c\b.*(?:open\([^)]*['"]w['"]?|\.write_text\(|\.write\([^)]*\)\s*$)|sed\s+-i\b|cat\s+<<-?\s*['"]?[A-Z_]+['"]?\b[^|;`]*>\s*[^/]|tee\s+(?!-)\S*\.(?:m?[jt]sx?|json|md|ya?ml|toml|sh|py|rs|go|css)\b|\bdd\s+[^|;`]*\bof=)/, }, { + // --force-with-lease refuses the push if the remote moved since the + // last fetch — safer than --force because it can't silently clobber + // someone else's commits. Always prefer this form. Lower-friction + // bypass phrase so users aren't tempted to reach for raw --force + // when --force-with-lease would do. + bypassPhrase: 'Allow force-with-lease bypass', + label: 'git push --force-with-lease', + matches: command => + commandsFor(command, 'git').some( + c => + c.args.includes('push') && + c.args.some(a => a.startsWith('--force-with-lease')), + ) + ? 'git push --force-with-lease' + : undefined, + }, + { + // Raw --force / -f bypasses the lease check and CAN silently + // overwrite remote commits. Always reach for --force-with-lease + // first; this rule + bypass phrase exist for the narrow cases + // where the remote really should be overwritten unconditionally + // (recovering from corruption, force-clobbering a doomed + // experimental branch the user owns). bypassPhrase: 'Allow force-push bypass', label: 'git push --force / -f', matches: command => commandsFor(command, 'git').some( c => c.args.includes('push') && - (c.args.includes('--force') || - c.args.includes('-f') || - c.args.some(a => a.startsWith('--force-with-lease'))), + (c.args.includes('--force') || c.args.includes('-f')) && + // Allow --force-with-lease through this rule (it's handled + // by the preceding lease-specific rule). + !c.args.some(a => a.startsWith('--force-with-lease')), ) ? 'git push --force' : undefined, diff --git a/.claude/hooks/no-revert-guard/package.json b/.claude/hooks/fleet/no-revert-guard/package.json similarity index 100% rename from .claude/hooks/no-revert-guard/package.json rename to .claude/hooks/fleet/no-revert-guard/package.json diff --git a/.claude/hooks/no-revert-guard/test/index.test.mts b/.claude/hooks/fleet/no-revert-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/no-revert-guard/test/index.test.mts rename to .claude/hooks/fleet/no-revert-guard/test/index.test.mts diff --git a/.claude/hooks/node-modules-staging-guard/tsconfig.json b/.claude/hooks/fleet/no-revert-guard/tsconfig.json similarity index 100% rename from .claude/hooks/node-modules-staging-guard/tsconfig.json rename to .claude/hooks/fleet/no-revert-guard/tsconfig.json diff --git a/.claude/hooks/no-structured-clone-prefer-json-guard/README.md b/.claude/hooks/fleet/no-structured-clone-prefer-json-guard/README.md similarity index 97% rename from .claude/hooks/no-structured-clone-prefer-json-guard/README.md rename to .claude/hooks/fleet/no-structured-clone-prefer-json-guard/README.md index 4b0d962..a1b9eb6 100644 --- a/.claude/hooks/no-structured-clone-prefer-json-guard/README.md +++ b/.claude/hooks/fleet/no-structured-clone-prefer-json-guard/README.md @@ -96,7 +96,7 @@ In `.claude/settings.json`: "hooks": [ { "type": "command", - "command": "node .claude/hooks/no-structured-clone-prefer-json-guard/index.mts" + "command": "node .claude/hooks/fleet/no-structured-clone-prefer-json-guard/index.mts" } ] } diff --git a/.claude/hooks/no-structured-clone-prefer-json-guard/index.mts b/.claude/hooks/fleet/no-structured-clone-prefer-json-guard/index.mts similarity index 100% rename from .claude/hooks/no-structured-clone-prefer-json-guard/index.mts rename to .claude/hooks/fleet/no-structured-clone-prefer-json-guard/index.mts diff --git a/.claude/hooks/no-structured-clone-prefer-json-guard/package.json b/.claude/hooks/fleet/no-structured-clone-prefer-json-guard/package.json similarity index 100% rename from .claude/hooks/no-structured-clone-prefer-json-guard/package.json rename to .claude/hooks/fleet/no-structured-clone-prefer-json-guard/package.json diff --git a/.claude/hooks/no-structured-clone-prefer-json-guard/test/index.test.mts b/.claude/hooks/fleet/no-structured-clone-prefer-json-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/no-structured-clone-prefer-json-guard/test/index.test.mts rename to .claude/hooks/fleet/no-structured-clone-prefer-json-guard/test/index.test.mts diff --git a/.claude/hooks/overeager-staging-guard/tsconfig.json b/.claude/hooks/fleet/no-structured-clone-prefer-json-guard/tsconfig.json similarity index 100% rename from .claude/hooks/overeager-staging-guard/tsconfig.json rename to .claude/hooks/fleet/no-structured-clone-prefer-json-guard/tsconfig.json diff --git a/.claude/hooks/no-token-in-dotenv-guard/README.md b/.claude/hooks/fleet/no-token-in-dotenv-guard/README.md similarity index 100% rename from .claude/hooks/no-token-in-dotenv-guard/README.md rename to .claude/hooks/fleet/no-token-in-dotenv-guard/README.md diff --git a/.claude/hooks/no-token-in-dotenv-guard/index.mts b/.claude/hooks/fleet/no-token-in-dotenv-guard/index.mts similarity index 100% rename from .claude/hooks/no-token-in-dotenv-guard/index.mts rename to .claude/hooks/fleet/no-token-in-dotenv-guard/index.mts diff --git a/.claude/hooks/no-token-in-dotenv-guard/package.json b/.claude/hooks/fleet/no-token-in-dotenv-guard/package.json similarity index 100% rename from .claude/hooks/no-token-in-dotenv-guard/package.json rename to .claude/hooks/fleet/no-token-in-dotenv-guard/package.json diff --git a/.claude/hooks/no-token-in-dotenv-guard/test/index.test.mts b/.claude/hooks/fleet/no-token-in-dotenv-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/no-token-in-dotenv-guard/test/index.test.mts rename to .claude/hooks/fleet/no-token-in-dotenv-guard/test/index.test.mts diff --git a/.claude/hooks/parallel-agent-edit-guard/tsconfig.json b/.claude/hooks/fleet/no-token-in-dotenv-guard/tsconfig.json similarity index 100% rename from .claude/hooks/parallel-agent-edit-guard/tsconfig.json rename to .claude/hooks/fleet/no-token-in-dotenv-guard/tsconfig.json diff --git a/.claude/hooks/no-underscore-identifier-guard/README.md b/.claude/hooks/fleet/no-underscore-identifier-guard/README.md similarity index 100% rename from .claude/hooks/no-underscore-identifier-guard/README.md rename to .claude/hooks/fleet/no-underscore-identifier-guard/README.md diff --git a/.claude/hooks/no-underscore-identifier-guard/index.mts b/.claude/hooks/fleet/no-underscore-identifier-guard/index.mts similarity index 92% rename from .claude/hooks/no-underscore-identifier-guard/index.mts rename to .claude/hooks/fleet/no-underscore-identifier-guard/index.mts index 92ff414..13325e2 100644 --- a/.claude/hooks/no-underscore-identifier-guard/index.mts +++ b/.claude/hooks/fleet/no-underscore-identifier-guard/index.mts @@ -86,6 +86,13 @@ const BANNED_DECL_PATTERNS: readonly RegExp[] = [ const BYPASS_PHRASE = 'Allow underscore-identifier bypass' +// Node CJS exposes `__dirname` and `__filename` as module-scoped free +// variables. ESM modules conventionally re-create them via +// `path.dirname(fileURLToPath(import.meta.url))`, so the identifiers show +// up in a `const ...` declaration. Skip those — they're matching Node's +// published names, not a `_internal` marker. +const ALLOWED_FREE_VARS = new Set(['__dirname', '__filename']) + export function findBannedIdentifiers(text: string): Finding[] { const findings: Finding[] = [] const lines = text.split('\n') @@ -96,9 +103,13 @@ export function findBannedIdentifiers(text: string): Finding[] { pattern.lastIndex = 0 let match: RegExpExecArray | null while ((match = pattern.exec(line)) !== null) { + const identifier = match[1]! + if (ALLOWED_FREE_VARS.has(identifier)) { + continue + } findings.push({ line: i + 1, - identifier: match[1]!, + identifier, text: line.trimEnd(), }) } @@ -138,7 +149,7 @@ export function isInternalDirPath(filePath: string): boolean { // can have its own tests without bypass phrases. export function isPluginOrHookTestPath(filePath: string): boolean { return ( - filePath.includes('/.claude/hooks/no-underscore-identifier-guard/') || + filePath.includes('/.claude/hooks/fleet/no-underscore-identifier-guard/') || filePath.includes( '/.config/oxlint-plugin/rules/no-underscore-identifier.', ) || diff --git a/.claude/hooks/no-underscore-identifier-guard/package.json b/.claude/hooks/fleet/no-underscore-identifier-guard/package.json similarity index 100% rename from .claude/hooks/no-underscore-identifier-guard/package.json rename to .claude/hooks/fleet/no-underscore-identifier-guard/package.json diff --git a/.claude/hooks/no-underscore-identifier-guard/test/index.test.mts b/.claude/hooks/fleet/no-underscore-identifier-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/no-underscore-identifier-guard/test/index.test.mts rename to .claude/hooks/fleet/no-underscore-identifier-guard/test/index.test.mts diff --git a/.claude/hooks/parallel-agent-on-stop-reminder/tsconfig.json b/.claude/hooks/fleet/no-underscore-identifier-guard/tsconfig.json similarity index 100% rename from .claude/hooks/parallel-agent-on-stop-reminder/tsconfig.json rename to .claude/hooks/fleet/no-underscore-identifier-guard/tsconfig.json diff --git a/.claude/hooks/fleet/no-unmocked-network-in-tests-guard/README.md b/.claude/hooks/fleet/no-unmocked-network-in-tests-guard/README.md new file mode 100644 index 0000000..d1d7f35 --- /dev/null +++ b/.claude/hooks/fleet/no-unmocked-network-in-tests-guard/README.md @@ -0,0 +1,32 @@ +# no-unmocked-network-in-tests-guard + +PreToolUse hook. Blocks a Write/Edit to a test file that performs HTTP against a +third-party host without mocking it via [`nock`](https://github.com/nock/nock). + +Live network in tests is flaky, slow, and a data-exfil surface. The fleet +pattern is `nock.disableNetConnect()` + endpoint stubs; the `registry-*.test.mts` +suites are canonical. + +## Fires when + +- Tool is `Write` or `Edit`. +- Target path is a test file (`*.test.*` / `*.spec.*`, or under `test/` / + `__tests__/`). +- Post-edit content calls `httpJson` / `httpText` / `httpRequest` / `fetch` / + `.request(`. +- The content has no `nock` reference. +- At least one network target is a non-localhost host (localhost-only is + allowed). + +## Bypass + +Type `Allow unmocked-network-in-tests bypass` verbatim in a recent message. + +## Why + +2026-05-27, socket-packageurl-js: `purlExists` conda/docker dispatch tests hit +live `api.anaconda.org` / `hub.docker.com`, timing out at 15s. Full rationale: +`docs/claude.md/fleet/no-live-network-in-tests.md`. + +Defense in depth with the fleet `test/setup.mts` (runtime `disableNetConnect()`) +and the CLAUDE.md rule. diff --git a/.claude/hooks/fleet/no-unmocked-network-in-tests-guard/index.mts b/.claude/hooks/fleet/no-unmocked-network-in-tests-guard/index.mts new file mode 100644 index 0000000..927c56e --- /dev/null +++ b/.claude/hooks/fleet/no-unmocked-network-in-tests-guard/index.mts @@ -0,0 +1,160 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — no-unmocked-network-in-tests-guard. +// +// Blocks Write/Edit operations on a test file that performs HTTP against a +// third-party host without mocking it via `nock`. Live network in tests is +// flaky, slow, and a data-exfil surface; the fleet pattern is +// `nock.disableNetConnect()` + endpoint stubs (see the `registry-*.test.mts` +// suites and `docs/claude.md/fleet/no-live-network-in-tests.md`). +// +// Detection model: +// - Fires only on Write/Edit whose target path looks like a test file +// (`*.test.*` or under a `test/` or `__tests__/` directory). +// - Looks at the post-edit file content (`content` for Write, `new_string` +// for Edit). +// - Flags a network call: `httpJson(`, `httpText(`, `httpRequest(`, +// `fetch(`, or `.request(` — the fleet HTTP surface plus raw fetch. +// - If the content references `nock` (the file mocks the network), allow. +// - If every network call targets localhost / 127.0.0.1 (a fixture server), +// allow. +// - Otherwise block. +// +// Bypass: `Allow unmocked-network-in-tests bypass` typed verbatim in a recent +// user turn. +// +// Fails open on parse errors or non-test files — under-blocking beats blocking +// on infrastructure problems. + +import process from 'node:process' + +import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' + +const BYPASS_PHRASE = 'Allow unmocked-network-in-tests bypass' + +interface ToolInput { + readonly tool_name?: string | undefined + readonly tool_input?: + | { + readonly file_path?: string | undefined + readonly new_string?: string | undefined + readonly content?: string | undefined + } + | undefined + readonly transcript_path?: string | undefined +} + +// A path is a test file if its basename matches `*.test.*` / `*.spec.*` or it +// lives under a `test/` or `__tests__/` directory. +export function isTestFilePath(filePath: string): boolean { + const normalized = filePath.replace(/\\/g, '/') + if (/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(normalized)) { + return true + } + return /(?:^|\/)(?:test|tests|__tests__)\//.test(normalized) +} + +// Network-call surfaces flagged in test bodies: the fleet HTTP helpers and raw +// fetch / `.request(`. +const NETWORK_CALL_RE = + /\b(?:httpJson|httpText|httpRequest|fetch)\s*\(|\.request\s*\(/ + +export function hasNetworkCall(text: string): boolean { + return NETWORK_CALL_RE.test(text) +} + +export function referencesNock(text: string): boolean { + return /\bnock\b/.test(text) +} + +// True when every literal URL/host in the text is localhost. If there are no +// literal hosts at all we can't prove it's localhost-only, so return false. +export function onlyLocalhostHosts(text: string): boolean { + const urls = text.match(/https?:\/\/[^\s'"`)]+/g) + if (!urls || urls.length === 0) { + return false + } + return urls.every(u => + /^https?:\/\/(?:127\.0\.0\.1|localhost)(?::|\/|$)/.test(u), + ) +} + +export function shouldBlock(filePath: string, content: string): boolean { + if (!isTestFilePath(filePath)) { + return false + } + if (!hasNetworkCall(content)) { + return false + } + if (referencesNock(content)) { + return false + } + if (onlyLocalhostHosts(content)) { + return false + } + return true +} + +async function main(): Promise { + const raw = await readStdin() + if (!raw) { + return + } + let payload: ToolInput + try { + payload = JSON.parse(raw) as ToolInput + } catch { + return + } + + const toolName = payload.tool_name + if (toolName !== 'Edit' && toolName !== 'Write') { + return + } + + const filePath = payload.tool_input?.file_path + if (!filePath) { + return + } + + const content = + payload.tool_input?.content ?? payload.tool_input?.new_string ?? '' + if (!shouldBlock(filePath, content)) { + return + } + + if ( + payload.transcript_path && + bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE) + ) { + process.exit(0) + } + + process.stderr.write( + [ + '[no-unmocked-network-in-tests-guard] Blocked: test makes a live third-party connection', + '', + ` File: ${filePath}`, + '', + ' This test calls httpJson/httpText/httpRequest/fetch against a', + ' non-localhost host with no `nock` mock in the file. Live network in', + ' tests is flaky, slow, and a data-exfil surface.', + '', + ' Fix: mock the endpoint with nock, like the registry-*.test.mts suites:', + " import nock from 'nock'", + ' beforeEach(() => nock.disableNetConnect())', + ' afterEach(() => { nock.cleanAll(); nock.enableNetConnect() })', + " nock('https://host').get('/path').reply(200, { ... })", + '', + ' Detail: docs/claude.md/fleet/no-live-network-in-tests.md', + ` Bypass: type "${BYPASS_PHRASE}" in a new message, then retry.`, + '', + ].join('\n'), + ) + process.exit(2) +} + +main().catch(e => { + process.stderr.write( + `[no-unmocked-network-in-tests-guard] hook error (allowing): ${(e as Error).message}\n`, + ) +}) diff --git a/.claude/hooks/fleet/no-unmocked-network-in-tests-guard/package.json b/.claude/hooks/fleet/no-unmocked-network-in-tests-guard/package.json new file mode 100644 index 0000000..9ac69cc --- /dev/null +++ b/.claude/hooks/fleet/no-unmocked-network-in-tests-guard/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-no-unmocked-network-in-tests-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/no-unmocked-network-in-tests-guard/test/index.test.mts b/.claude/hooks/fleet/no-unmocked-network-in-tests-guard/test/index.test.mts new file mode 100644 index 0000000..2f251f7 --- /dev/null +++ b/.claude/hooks/fleet/no-unmocked-network-in-tests-guard/test/index.test.mts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { + hasNetworkCall, + isTestFilePath, + onlyLocalhostHosts, + referencesNock, + shouldBlock, +} from '../index.mts' + +describe('isTestFilePath', () => { + it('matches *.test.* and test/ dirs', () => { + assert.equal(isTestFilePath('src/foo.test.mts'), true) + assert.equal(isTestFilePath('test/registry-cran.test.mts'), true) + assert.equal(isTestFilePath('pkg/__tests__/a.spec.ts'), true) + assert.equal(isTestFilePath('src/foo.mts'), false) + assert.equal(isTestFilePath('scripts/build.mts'), false) + }) +}) + +describe('hasNetworkCall', () => { + it('flags fleet HTTP helpers and fetch', () => { + assert.equal(hasNetworkCall('await httpJson(url)'), true) + assert.equal(hasNetworkCall('const r = httpText( x )'), true) + assert.equal(hasNetworkCall('await fetch(`https://x`)'), true) + assert.equal(hasNetworkCall('client.request(opts)'), true) + assert.equal(hasNetworkCall('const x = 1'), false) + }) +}) + +describe('referencesNock / onlyLocalhostHosts', () => { + it('detects nock usage', () => { + assert.equal(referencesNock("import nock from 'nock'"), true) + assert.equal(referencesNock('no mocking here'), false) + }) + it('treats localhost-only hosts as allowed', () => { + assert.equal(onlyLocalhostHosts('fetch("http://127.0.0.1:8080/x")'), true) + assert.equal(onlyLocalhostHosts('fetch("http://localhost/x")'), true) + assert.equal(onlyLocalhostHosts('fetch("https://api.example.com")'), false) + // No literal host present -> can't prove localhost-only. + assert.equal(onlyLocalhostHosts('fetch(url)'), false) + }) +}) + +describe('shouldBlock', () => { + const unmocked = + "import { httpJson } from 'x'\nit('t', async () => { await httpJson('https://api.anaconda.org/p') })" + const mocked = + "import nock from 'nock'\nit('t', async () => { nock('https://api.anaconda.org').get('/p').reply(200,{}); await httpJson('https://api.anaconda.org/p') })" + const localhostOnly = + "it('t', async () => { await fetch('http://127.0.0.1:9/p') })" + + it('blocks an unmocked third-party call in a test file', () => { + assert.equal(shouldBlock('test/x.test.mts', unmocked), true) + }) + it('allows when nock is present', () => { + assert.equal(shouldBlock('test/x.test.mts', mocked), false) + }) + it('allows localhost-only calls', () => { + assert.equal(shouldBlock('test/x.test.mts', localhostOnly), false) + }) + it('ignores non-test files', () => { + assert.equal(shouldBlock('src/x.mts', unmocked), false) + }) + it('ignores test files with no network call', () => { + assert.equal(shouldBlock('test/x.test.mts', 'const a = 1'), false) + }) +}) diff --git a/.claude/hooks/parallel-agent-staging-guard/tsconfig.json b/.claude/hooks/fleet/no-unmocked-network-in-tests-guard/tsconfig.json similarity index 100% rename from .claude/hooks/parallel-agent-staging-guard/tsconfig.json rename to .claude/hooks/fleet/no-unmocked-network-in-tests-guard/tsconfig.json diff --git a/.claude/hooks/node-modules-staging-guard/README.md b/.claude/hooks/fleet/node-modules-staging-guard/README.md similarity index 96% rename from .claude/hooks/node-modules-staging-guard/README.md rename to .claude/hooks/fleet/node-modules-staging-guard/README.md index 4ca4a6a..f1062c1 100644 --- a/.claude/hooks/node-modules-staging-guard/README.md +++ b/.claude/hooks/fleet/node-modules-staging-guard/README.md @@ -7,7 +7,7 @@ paths containing `node_modules/` or `package-lock.json` under ## Why `-f` overrides `.gitignore`. Past incident: an agent ran -`git add -f .claude/hooks/check-new-deps/node_modules/` to "fix" what +`git add -f .claude/hooks/fleet/check-new-deps/node_modules/` to "fix" what looked like a missing dir in a commit. The directory landed in 6 fleet repos via cascade. Removing it required either a history rewrite (`git filter-branch` / `git filter-repo`) + force-push, or living with diff --git a/.claude/hooks/node-modules-staging-guard/index.mts b/.claude/hooks/fleet/node-modules-staging-guard/index.mts similarity index 98% rename from .claude/hooks/node-modules-staging-guard/index.mts rename to .claude/hooks/fleet/node-modules-staging-guard/index.mts index 5b87967..d93fce2 100644 --- a/.claude/hooks/node-modules-staging-guard/index.mts +++ b/.claude/hooks/fleet/node-modules-staging-guard/index.mts @@ -150,7 +150,7 @@ async function main(): Promise { ...blockedArgs.map(a => ` ${a}`), '', ' Past incident: a cascading agent committed', - ' `.claude/hooks/check-new-deps/node_modules/` into 6 fleet repos.', + ' `.claude/hooks/fleet/check-new-deps/node_modules/` into 6 fleet repos.', ' Removing it required force-push (itself a hazard) or filter-branch.', '', ' `node_modules/` and hook `package-lock.json` files are gitignored', diff --git a/.claude/hooks/node-modules-staging-guard/package.json b/.claude/hooks/fleet/node-modules-staging-guard/package.json similarity index 100% rename from .claude/hooks/node-modules-staging-guard/package.json rename to .claude/hooks/fleet/node-modules-staging-guard/package.json diff --git a/.claude/hooks/node-modules-staging-guard/test/index.test.mts b/.claude/hooks/fleet/node-modules-staging-guard/test/index.test.mts similarity index 97% rename from .claude/hooks/node-modules-staging-guard/test/index.test.mts rename to .claude/hooks/fleet/node-modules-staging-guard/test/index.test.mts index ac078eb..bd5d67a 100644 --- a/.claude/hooks/node-modules-staging-guard/test/index.test.mts +++ b/.claude/hooks/fleet/node-modules-staging-guard/test/index.test.mts @@ -63,7 +63,7 @@ test('git add -f node_modules path blocked', async () => { const r = await runHook({ tool_name: 'Bash', tool_input: { - command: 'git add -f .claude/hooks/check-new-deps/node_modules/', + command: 'git add -f .claude/hooks/fleet/check-new-deps/node_modules/', }, }) assert.strictEqual(r.code, 2) diff --git a/.claude/hooks/path-guard/tsconfig.json b/.claude/hooks/fleet/node-modules-staging-guard/tsconfig.json similarity index 100% rename from .claude/hooks/path-guard/tsconfig.json rename to .claude/hooks/fleet/node-modules-staging-guard/tsconfig.json diff --git a/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/README.md b/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/README.md new file mode 100644 index 0000000..a026032 --- /dev/null +++ b/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/README.md @@ -0,0 +1,32 @@ +# non-fleet-pr-issue-ask-guard + +PreToolUse hook that blocks `gh pr create` / `gh issue create` / `gh release create` calls targeting a repository NOT in the fleet roster, unless the user has typed the canonical bypass phrase. + +## Rule + +Public-facing artifacts (PRs, issues, releases) on non-fleet repos go out under the user's gh identity. They're permanent on the upstream side once posted — closing one with an "opened in error" comment doesn't fully un-publish it (the email notification fires, the issue number is consumed, the upstream maintainers see the noise). + +The fleet rule: **never submit to a non-fleet repo without explicit per-action confirmation**. Captured plan text + batched "do all N tasks" directives are NOT standing authorization to post under your identity. + +## Detection + +Fires on Bash commands containing `gh pr create`, `gh issue create`, or `gh release create`. Resolves the target repo via: + +1. `--repo /` flag when present. +2. Otherwise, `git remote get-url origin` from the resolved git cwd (matching the priority order used by `no-non-fleet-push-guard`: `-C `, leading `cd &&`, then process.cwd()). + +Blocks when the resolved slug is not in the fleet roster (`_shared/fleet-repos.mts::isFleetRepo`). + +## Bypass + +Type `Allow non-fleet-publish bypass` verbatim in a recent user turn. Per the fleet bypass-phrase convention. Single-action: a phrase from a previous turn doesn't carry forward indefinitely — the hook reads the active session's transcript. + +## Why a hook + +A captured-plan task that says "file an upstream issue" isn't permission to run `gh issue create` against that repo. 2026-05-28 incident: working through a deferred-tasks list, I ran `gh issue create --repo oxc-project/oxc ...` from a captured plan without re-confirming. The user said "don't create an issue" but the bg `gh` call had already completed; the issue was live until closed post-hoc. + +This hook makes the rule enforceable at edit time — the bg call blocks before the API request fires. + +## Fail-open + +The hook fails open on its own bugs (exit 0 + stderr log) so a bad deploy can't brick the session. diff --git a/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/index.mts b/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/index.mts new file mode 100644 index 0000000..9a0c63a --- /dev/null +++ b/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/index.mts @@ -0,0 +1,205 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — non-fleet-pr-issue-ask-guard. +// +// Blocks `gh pr create` / `gh issue create` / `gh release create` +// calls that target a repository NOT in the fleet roster. The +// canonical fleet rule: never auto-submit publicly-visible artifacts +// (PRs, issues, releases) to upstream / third-party repos without +// explicit user confirmation. Captured plan text + batched "do all N +// tasks" directives are NOT standing authorization to post under the +// user's gh identity. +// +// 2026-05-28 incident: a captured-plan task said "file an oxfmt +// upstream issue" as one bullet. Working through the deferred list, +// I ran `gh issue create --repo oxc-project/oxc ...` without re- +// confirming. The user said "don't create an issue" but the bg `gh` +// call had already completed; the issue was live until closed +// post-hoc with an "opened in error" comment. This hook prevents +// the repeat. +// +// Detection: +// - Fires only on Bash commands containing `gh pr create`, +// `gh issue create`, or `gh release create`. +// - Resolves the target repo via `--repo /` flag +// when present, otherwise via `git remote get-url origin` from +// the resolved git cwd (same priority order as +// `no-non-fleet-push-guard`: -C , then `cd &&`, +// then process.cwd()). +// - Blocks when the slug is not in FLEET_REPO_NAMES. +// +// Bypass: `Allow non-fleet-publish bypass` typed verbatim in a +// recent user turn. +// +// Fails OPEN on resolution ambiguity (can't find the command, the +// dir, or the remote): better to under-block than to wedge a +// legitimate fleet PR/issue when the shape is unfamiliar. + +import path from 'node:path' +import process from 'node:process' +import { spawnSync } from 'node:child_process' + +import { isFleetRepo, slugFromRemoteUrl } from '../_shared/fleet-repos.mts' +import { commandsFor } from '../_shared/shell-command.mts' +import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' + +interface ToolInput { + readonly tool_name?: string | undefined + readonly tool_input?: { readonly command?: string | undefined } | undefined + readonly transcript_path?: string | undefined +} + +const BYPASS_PHRASE = 'Allow non-fleet-publish bypass' + +const GH_DASH_REPO_RE = /--repo[\s=]+("([^"]+)"|'([^']+)'|(\S+))/ +const GIT_DASH_C_RE = /\bgit\s+-C\s+("([^"]+)"|'([^']+)'|(\S+))/ +const LEADING_CD_RE = /(?:^|[;&|]|&&)\s*cd\s+("([^"]+)"|'([^']+)'|(\S+))/ + +// gh subcommands that publish public-facing content. `release create` +// is also in the harness deny list, but the hook layer here catches +// the bypass-phrase escape path so the user has ONE consistent way +// to authorize public-facing actions. +const PUBLIC_SURFACE_SUBCOMMANDS = [ + ['pr', 'create'], + ['issue', 'create'], + ['release', 'create'], +] as const + +export function extractGhTargetRepo(command: string): string | undefined { + const m = GH_DASH_REPO_RE.exec(command) + if (m) { + return m[2] ?? m[3] ?? m[4] + } + return undefined +} + +export function extractGitCwd(command: string): string { + const dashC = GIT_DASH_C_RE.exec(command) + if (dashC) { + return dashC[2] ?? dashC[3] ?? dashC[4] ?? process.cwd() + } + const cd = LEADING_CD_RE.exec(command) + if (cd) { + const dir = cd[2] ?? cd[3] ?? cd[4] + if (dir) { + return path.resolve(process.cwd(), dir) + } + } + return process.cwd() +} + +function originSlugFromCwd(dir: string): string | undefined { + try { + const r = spawnSync('git', ['-C', dir, 'remote', 'get-url', 'origin'], { + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8', + timeout: 5000, + }) + if (r.status !== 0) { + return undefined + } + const url = (r.stdout ?? '').trim() + return slugFromRemoteUrl(url) + } catch { + return undefined + } +} + +// Identifies the gh subcommand. Returns the matching +// [verb, action] pair when one is present at an executable +// position, undefined otherwise. +export function findPublicGhInvocation( + command: string, +): readonly [string, string] | undefined { + const ghCommands = commandsFor(command, 'gh') + for (const c of ghCommands) { + for (const pair of PUBLIC_SURFACE_SUBCOMMANDS) { + if (c.args[0] === pair[0] && c.args[1] === pair[1]) { + return pair + } + } + } + return undefined +} + +async function main(): Promise { + const raw = await readStdin() + let payload: ToolInput + try { + payload = raw ? JSON.parse(raw) : {} + } catch { + process.exit(0) + } + if (payload.tool_name !== 'Bash') { + process.exit(0) + } + const command = payload.tool_input?.command ?? '' + if (!command || !/\bgh\b/.test(command)) { + process.exit(0) + } + const subcommand = findPublicGhInvocation(command) + if (!subcommand) { + process.exit(0) + } + + // Resolve target slug. `--repo` carries owner/repo (shown + // verbatim in messages). For membership, `isFleetRepo` keys on + // the bare repo name, so strip the owner before checking. + let slug: string | undefined + const dashRepo = extractGhTargetRepo(command) + if (dashRepo) { + slug = dashRepo + } else { + const cwd = extractGitCwd(command) + slug = originSlugFromCwd(cwd) + } + if (!slug) { + // Fail open — can't determine target. The user gets the gh + // command's own error if it's malformed. + process.exit(0) + } + const slashIdx = slug.indexOf('/') + const bareSlug = slashIdx === -1 ? slug : slug.slice(slashIdx + 1) + + if (isFleetRepo(bareSlug)) { + // Fleet repo — fall through. The action is authorized by being + // inside the fleet. + process.exit(0) + } + + // Non-fleet target. Check bypass phrase. + if ( + payload.transcript_path && + bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE) + ) { + process.exit(0) + } + + process.stderr.write( + [ + 'non-fleet-pr-issue-ask-guard: blocked', + '', + ` Command targets non-fleet repo: ${slug}`, + ` Subcommand: gh ${subcommand.join(' ')}`, + '', + ` Public-facing artifacts (PRs, issues, releases) on non-fleet`, + ` repos go out under your gh identity. The fleet rule: never`, + ` submit without explicit per-action user confirmation —`, + ` captured plans + "do all N tasks" directives do NOT count.`, + '', + ` If you really want to submit: type the canonical phrase`, + ` in your next message, then re-run:`, + ` ${BYPASS_PHRASE}`, + '', + ' Otherwise: draft locally, share for review, get explicit', + ' yes/no before re-attempting.', + ].join('\n') + '\n', + ) + process.exit(2) +} + +main().catch(err => { + process.stderr.write( + `non-fleet-pr-issue-ask-guard: hook crashed, failing open: ${err instanceof Error ? err.message : String(err)}\n`, + ) + process.exit(0) +}) diff --git a/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/package.json b/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/package.json new file mode 100644 index 0000000..06bf490 --- /dev/null +++ b/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/package.json @@ -0,0 +1,12 @@ +{ + "name": "hook-non-fleet-pr-issue-ask-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + } +} diff --git a/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/test/index.test.mts b/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/test/index.test.mts new file mode 100644 index 0000000..5b426ca --- /dev/null +++ b/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/test/index.test.mts @@ -0,0 +1,106 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const HOOK_PATH = path.join(__dirname, '..', 'index.mts') + +function runHook(payload: object): { stderr: string; exitCode: number } { + const result = spawnSync('node', [HOOK_PATH], { + input: JSON.stringify(payload), + encoding: 'utf8', + }) + return { stderr: result.stderr ?? '', exitCode: result.status ?? -1 } +} + +test('BLOCKS gh pr create --repo against non-fleet repo', () => { + const { stderr, exitCode } = runHook({ + tool_name: 'Bash', + tool_input: { + command: 'gh pr create --repo oxc-project/oxc --title "x" --body "y"', + }, + }) + assert.equal(exitCode, 2) + assert.match(stderr, /non-fleet-pr-issue-ask-guard: blocked/) + assert.match(stderr, /oxc-project\/oxc/) + assert.match(stderr, /gh pr create/) +}) + +test('BLOCKS gh issue create --repo against non-fleet repo', () => { + const { stderr, exitCode } = runHook({ + tool_name: 'Bash', + tool_input: { + command: 'gh issue create --repo nodejs/node --title "x" --body "y"', + }, + }) + assert.equal(exitCode, 2) + assert.match(stderr, /nodejs\/node/) + assert.match(stderr, /gh issue create/) +}) + +test('BLOCKS gh release create --repo against non-fleet repo', () => { + const { exitCode } = runHook({ + tool_name: 'Bash', + tool_input: { + command: 'gh release create v1.0 --repo example/repo', + }, + }) + assert.equal(exitCode, 2) +}) + +test('ALLOWS gh pr create --repo against fleet repo (SocketDev/socket-lib)', () => { + const { exitCode } = runHook({ + tool_name: 'Bash', + tool_input: { + command: + 'gh pr create --repo SocketDev/socket-lib --title "x" --body "y"', + }, + }) + assert.equal(exitCode, 0) +}) + +test('ALLOWS gh pr create --repo against fleet repo (SocketDev/socket-wheelhouse)', () => { + const { exitCode } = runHook({ + tool_name: 'Bash', + tool_input: { + command: + 'gh pr create --repo SocketDev/socket-wheelhouse --title "x" --body "y"', + }, + }) + assert.equal(exitCode, 0) +}) + +test('IGNORES non-public gh subcommands (gh pr view, gh issue list)', () => { + const { exitCode: prView } = runHook({ + tool_name: 'Bash', + tool_input: { command: 'gh pr view --repo oxc-project/oxc 12345' }, + }) + assert.equal(prView, 0) + + const { exitCode: issueList } = runHook({ + tool_name: 'Bash', + tool_input: { command: 'gh issue list --repo oxc-project/oxc' }, + }) + assert.equal(issueList, 0) +}) + +test('IGNORES non-Bash tools', () => { + const { exitCode } = runHook({ + tool_name: 'Edit', + tool_input: { + file_path: '/x.txt', + new_string: 'gh pr create --repo oxc-project/oxc', + }, + }) + assert.equal(exitCode, 0) +}) + +test('IGNORES commands without gh', () => { + const { exitCode } = runHook({ + tool_name: 'Bash', + tool_input: { command: 'ls -la' }, + }) + assert.equal(exitCode, 0) +}) diff --git a/.claude/hooks/path-regex-normalize-reminder/tsconfig.json b/.claude/hooks/fleet/non-fleet-pr-issue-ask-guard/tsconfig.json similarity index 100% rename from .claude/hooks/path-regex-normalize-reminder/tsconfig.json rename to .claude/hooks/fleet/non-fleet-pr-issue-ask-guard/tsconfig.json diff --git a/.claude/hooks/fleet/overeager-staging-guard/README.md b/.claude/hooks/fleet/overeager-staging-guard/README.md new file mode 100644 index 0000000..7add92c --- /dev/null +++ b/.claude/hooks/fleet/overeager-staging-guard/README.md @@ -0,0 +1,33 @@ +# overeager-staging-guard + +**Lifecycle**: PreToolUse (Bash) + +**Purpose**: catch the failure mode where an agent's `git commit` sweeps in files it didn't author — usually another Claude session's work that was already staged when this session opened the repo. + +## Two enforcement layers + +### Layer 1: BLOCK broad-stage commands + +The hook blocks any of: + +- `git add -A` +- `git add .` +- `git add --all` +- `git add -u` +- `git add --update` + +These sweep everything in the working tree into the index, which is hostile to parallel-session repos. Per the fleet CLAUDE.md rule: **surgical `git add ` only — never `-A` / `.`**. + +### Layer 2: WARN on commit with unfamiliar staged files + +On `git commit`, if the index contains files the agent has NOT touched this session (via `Edit` / `Write` / `git add ` / `git rm `), the hook emits a stderr summary listing every unfamiliar staged file. **Exit 0 — informational, not a block.** The point is to give the agent a chance to spot parallel-session work before the commit goes through. + +The detection heuristic walks the transcript's tool-use history; files staged but never touched this session surface as suspicious entries. + +## Bypass + +Type `Allow add-all bypass` verbatim in a recent user turn to permit `-A` / `.` / `-u` for one operation. The bypass is single-use and not persisted across sessions. + +## Why this hook exists + +Past incident: a session's own `pnpm check` surfaced another agent's migration files; the session nearly committed them. The block-broad-stage + warn-on-unfamiliar pair is defense-in-depth for the parallel-Claude-session model. diff --git a/.claude/hooks/overeager-staging-guard/index.mts b/.claude/hooks/fleet/overeager-staging-guard/index.mts similarity index 98% rename from .claude/hooks/overeager-staging-guard/index.mts rename to .claude/hooks/fleet/overeager-staging-guard/index.mts index c6eeae3..f6f9be8 100644 --- a/.claude/hooks/overeager-staging-guard/index.mts +++ b/.claude/hooks/fleet/overeager-staging-guard/index.mts @@ -41,10 +41,7 @@ import path from 'node:path' import process from 'node:process' import { readTouchedPaths } from '../_shared/foreign-paths.mts' -import { - detectBroadGitAdd, - findInvocation, -} from '../_shared/shell-command.mts' +import { detectBroadGitAdd, findInvocation } from '../_shared/shell-command.mts' import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' interface ToolInput { @@ -78,7 +75,6 @@ export function listStagedFiles(repoDir: string): string[] { .filter(Boolean) } - async function main(): Promise { if (process.env[ENV_DISABLE]) { process.exit(0) diff --git a/.claude/hooks/overeager-staging-guard/package.json b/.claude/hooks/fleet/overeager-staging-guard/package.json similarity index 100% rename from .claude/hooks/overeager-staging-guard/package.json rename to .claude/hooks/fleet/overeager-staging-guard/package.json diff --git a/.claude/hooks/overeager-staging-guard/test/index.test.mts b/.claude/hooks/fleet/overeager-staging-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/overeager-staging-guard/test/index.test.mts rename to .claude/hooks/fleet/overeager-staging-guard/test/index.test.mts diff --git a/.claude/hooks/paths-mts-inherit-guard/tsconfig.json b/.claude/hooks/fleet/overeager-staging-guard/tsconfig.json similarity index 100% rename from .claude/hooks/paths-mts-inherit-guard/tsconfig.json rename to .claude/hooks/fleet/overeager-staging-guard/tsconfig.json diff --git a/.claude/hooks/parallel-agent-edit-guard/README.md b/.claude/hooks/fleet/parallel-agent-edit-guard/README.md similarity index 96% rename from .claude/hooks/parallel-agent-edit-guard/README.md rename to .claude/hooks/fleet/parallel-agent-edit-guard/README.md index 9b93609..5147738 100644 --- a/.claude/hooks/parallel-agent-edit-guard/README.md +++ b/.claude/hooks/fleet/parallel-agent-edit-guard/README.md @@ -27,7 +27,7 @@ Incident 2026-05-27: two Claude sessions plus a Codex companion shared one type-error fixes one Edit at a time. The four-times-clobbered fixes only stuck once both sessions stopped touching the same files. -`parallel-agent-staging-guard` catches the *git-op* version of this hazard +`parallel-agent-staging-guard` catches the _git-op_ version of this hazard (`git add -A` / `stash` / `reset --hard`); it can't see a plain `Write` that overwrites a file. This hook closes that gap at the write itself. diff --git a/.claude/hooks/parallel-agent-edit-guard/index.mts b/.claude/hooks/fleet/parallel-agent-edit-guard/index.mts similarity index 100% rename from .claude/hooks/parallel-agent-edit-guard/index.mts rename to .claude/hooks/fleet/parallel-agent-edit-guard/index.mts diff --git a/.claude/hooks/parallel-agent-edit-guard/package.json b/.claude/hooks/fleet/parallel-agent-edit-guard/package.json similarity index 100% rename from .claude/hooks/parallel-agent-edit-guard/package.json rename to .claude/hooks/fleet/parallel-agent-edit-guard/package.json diff --git a/.claude/hooks/parallel-agent-edit-guard/test/index.test.mts b/.claude/hooks/fleet/parallel-agent-edit-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/parallel-agent-edit-guard/test/index.test.mts rename to .claude/hooks/fleet/parallel-agent-edit-guard/test/index.test.mts diff --git a/.claude/hooks/perfectionist-reminder/tsconfig.json b/.claude/hooks/fleet/parallel-agent-edit-guard/tsconfig.json similarity index 100% rename from .claude/hooks/perfectionist-reminder/tsconfig.json rename to .claude/hooks/fleet/parallel-agent-edit-guard/tsconfig.json diff --git a/.claude/hooks/parallel-agent-on-stop-reminder/README.md b/.claude/hooks/fleet/parallel-agent-on-stop-reminder/README.md similarity index 100% rename from .claude/hooks/parallel-agent-on-stop-reminder/README.md rename to .claude/hooks/fleet/parallel-agent-on-stop-reminder/README.md diff --git a/.claude/hooks/parallel-agent-on-stop-reminder/index.mts b/.claude/hooks/fleet/parallel-agent-on-stop-reminder/index.mts similarity index 100% rename from .claude/hooks/parallel-agent-on-stop-reminder/index.mts rename to .claude/hooks/fleet/parallel-agent-on-stop-reminder/index.mts diff --git a/.claude/hooks/parallel-agent-on-stop-reminder/package.json b/.claude/hooks/fleet/parallel-agent-on-stop-reminder/package.json similarity index 100% rename from .claude/hooks/parallel-agent-on-stop-reminder/package.json rename to .claude/hooks/fleet/parallel-agent-on-stop-reminder/package.json diff --git a/.claude/hooks/parallel-agent-on-stop-reminder/test/index.test.mts b/.claude/hooks/fleet/parallel-agent-on-stop-reminder/test/index.test.mts similarity index 89% rename from .claude/hooks/parallel-agent-on-stop-reminder/test/index.test.mts rename to .claude/hooks/fleet/parallel-agent-on-stop-reminder/test/index.test.mts index 6e00020..b070fff 100644 --- a/.claude/hooks/parallel-agent-on-stop-reminder/test/index.test.mts +++ b/.claude/hooks/fleet/parallel-agent-on-stop-reminder/test/index.test.mts @@ -1,10 +1,9 @@ /** - * @file Unit tests for parallel-agent-on-stop-reminder hook. - * - * Stop hook, always exit 0. Emits a stderr reminder listing dirty paths this - * session did not author and that changed recently. Each test builds a real - * git repo in tmpdir, writes foreign / own dirty files, and runs the hook as a - * child process with a synthesized Stop payload. + * @file Unit tests for parallel-agent-on-stop-reminder hook. Stop hook, always + * exit 0. Emits a stderr reminder listing dirty paths this session did not + * author and that changed recently. Each test builds a real git repo in + * tmpdir, writes foreign / own dirty files, and runs the hook as a child + * process with a synthesized Stop payload. */ import assert from 'node:assert/strict' @@ -96,7 +95,7 @@ test('reminds when a foreign dirty file exists (no transcript)', () => { assert.match(r.stderr, /another (Claude )?session|another agent/i) }) -test('silent when the only dirty file is this session\'s', () => { +test("silent when the only dirty file is this session's", () => { const own = writeFile(repo, 'mine.txt') const tx = writeTranscriptTouching(own) const r = runHook({ cwd: repo, transcriptPath: tx }) diff --git a/.claude/hooks/plan-location-guard/tsconfig.json b/.claude/hooks/fleet/parallel-agent-on-stop-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/plan-location-guard/tsconfig.json rename to .claude/hooks/fleet/parallel-agent-on-stop-reminder/tsconfig.json diff --git a/.claude/hooks/parallel-agent-staging-guard/README.md b/.claude/hooks/fleet/parallel-agent-staging-guard/README.md similarity index 72% rename from .claude/hooks/parallel-agent-staging-guard/README.md rename to .claude/hooks/fleet/parallel-agent-staging-guard/README.md index 915ea28..43564e4 100644 --- a/.claude/hooks/parallel-agent-staging-guard/README.md +++ b/.claude/hooks/fleet/parallel-agent-staging-guard/README.md @@ -6,14 +6,14 @@ present** in the checkout. Surgical ops and the all-clear case pass through. ## Gated operations (blocked only when foreign paths exist) -| Op | Hazard | -|----|--------| -| `git add -A` / `.` / `--all` / `-u` | stages their unstaged edits | -| `git commit -a` / `--all` | stages + commits their edits | -| `git stash` / `stash push` | hides their working-tree changes | -| `git reset --hard` | destroys their uncommitted work | -| `git checkout ` / `git switch ` | may clobber on switch | -| `git restore ` | reverts their changes | +| Op | Hazard | +| ----------------------------------------------- | -------------------------------- | +| `git add -A` / `.` / `--all` / `-u` | stages their unstaged edits | +| `git commit -a` / `--all` | stages + commits their edits | +| `git stash` / `stash push` | hides their working-tree changes | +| `git reset --hard` | destroys their uncommitted work | +| `git checkout ` / `git switch ` | may clobber on switch | +| `git restore ` | reverts their changes | Detection runs through the shared shell AST parser (`_shared/shell-command.mts`), so indirection can't dodge it diff --git a/.claude/hooks/parallel-agent-staging-guard/index.mts b/.claude/hooks/fleet/parallel-agent-staging-guard/index.mts similarity index 97% rename from .claude/hooks/parallel-agent-staging-guard/index.mts rename to .claude/hooks/fleet/parallel-agent-staging-guard/index.mts index 81848b8..7c2d6d9 100644 --- a/.claude/hooks/parallel-agent-staging-guard/index.mts +++ b/.claude/hooks/fleet/parallel-agent-staging-guard/index.mts @@ -127,9 +127,8 @@ async function main(): Promise { if (payload.tool_name !== 'Bash') { process.exit(0) } - const command = ( - payload.tool_input as { command?: unknown } | undefined - )?.command + const command = (payload.tool_input as { command?: unknown } | undefined) + ?.command if (typeof command !== 'string' || !command.trim()) { process.exit(0) } @@ -170,7 +169,9 @@ async function main(): Promise { ' same checkout. This operation would sweep up, hide, or destroy', ' their in-flight work:', ...foreign.slice(0, 10).map(p => ` ${p}`), - ...(foreign.length > 10 ? [` ... and ${foreign.length - 10} more`] : []), + ...(foreign.length > 10 + ? [` ... and ${foreign.length - 10} more`] + : []), '', ' Fix: stage only YOUR files by explicit path, and avoid stash /', ' reset --hard / checkout while the other agent is active.', diff --git a/.claude/hooks/parallel-agent-staging-guard/package.json b/.claude/hooks/fleet/parallel-agent-staging-guard/package.json similarity index 100% rename from .claude/hooks/parallel-agent-staging-guard/package.json rename to .claude/hooks/fleet/parallel-agent-staging-guard/package.json diff --git a/.claude/hooks/parallel-agent-staging-guard/test/index.test.mts b/.claude/hooks/fleet/parallel-agent-staging-guard/test/index.test.mts similarity index 90% rename from .claude/hooks/parallel-agent-staging-guard/test/index.test.mts rename to .claude/hooks/fleet/parallel-agent-staging-guard/test/index.test.mts index 1ce39e9..946fddb 100644 --- a/.claude/hooks/parallel-agent-staging-guard/test/index.test.mts +++ b/.claude/hooks/fleet/parallel-agent-staging-guard/test/index.test.mts @@ -1,13 +1,11 @@ /** - * @file Unit tests for parallel-agent-staging-guard hook. - * - * The guard blocks sweep / destructive git ops (add -A, commit -a, stash, - * reset --hard, checkout, restore) ONLY when foreign dirty paths are present: - * dirty, not in this session's transcript touched-set, recently changed. - * - * Each test builds a real git repo in tmpdir, optionally creates a "foreign" - * dirty file (written WITHOUT a corresponding Edit/Write transcript entry), - * and runs the hook as a child process with a synthesized PreToolUse payload. + * @file Unit tests for parallel-agent-staging-guard hook. The guard blocks + * sweep / destructive git ops (add -A, commit -a, stash, reset --hard, + * checkout, restore) ONLY when foreign dirty paths are present: dirty, not in + * this session's transcript touched-set, recently changed. Each test builds a + * real git repo in tmpdir, optionally creates a "foreign" dirty file (written + * WITHOUT a corresponding Edit/Write transcript entry), and runs the hook as + * a child process with a synthesized PreToolUse payload. */ import assert from 'node:assert/strict' @@ -146,7 +144,7 @@ test('allows `git add -A` in a clean repo (no foreign paths)', () => { assert.equal(r.code, 0) }) -test('allows `git stash` when the only dirty file is this session\'s', () => { +test("allows `git stash` when the only dirty file is this session's", () => { const own = writeForeign(repo, 'mine.txt') const tx = writeTranscriptTouching(own) const r = runHook('git stash', { cwd: repo, transcriptPath: tx }) diff --git a/.claude/hooks/plan-review-reminder/tsconfig.json b/.claude/hooks/fleet/parallel-agent-staging-guard/tsconfig.json similarity index 100% rename from .claude/hooks/plan-review-reminder/tsconfig.json rename to .claude/hooks/fleet/parallel-agent-staging-guard/tsconfig.json diff --git a/.claude/hooks/path-guard/README.md b/.claude/hooks/fleet/path-guard/README.md similarity index 100% rename from .claude/hooks/path-guard/README.md rename to .claude/hooks/fleet/path-guard/README.md diff --git a/.claude/hooks/path-guard/index.mts b/.claude/hooks/fleet/path-guard/index.mts similarity index 100% rename from .claude/hooks/path-guard/index.mts rename to .claude/hooks/fleet/path-guard/index.mts diff --git a/.claude/hooks/path-guard/package.json b/.claude/hooks/fleet/path-guard/package.json similarity index 100% rename from .claude/hooks/path-guard/package.json rename to .claude/hooks/fleet/path-guard/package.json diff --git a/.claude/hooks/path-guard/segments.mts b/.claude/hooks/fleet/path-guard/segments.mts similarity index 96% rename from .claude/hooks/path-guard/segments.mts rename to .claude/hooks/fleet/path-guard/segments.mts index c4eb78e..2069a7c 100644 --- a/.claude/hooks/path-guard/segments.mts +++ b/.claude/hooks/fleet/path-guard/segments.mts @@ -1,5 +1,5 @@ // Canonical path-segment vocabulary shared by the path-guard hook -// (.claude/hooks/path-guard/index.mts) and gate (scripts/check-paths.mts). +// (.claude/hooks/fleet/path-guard/index.mts) and gate (scripts/check-paths.mts). // // Mantra: 1 path, 1 reference. This module is the *one* place stage, // build-root, mode, and sibling-package vocabulary is defined. Both diff --git a/.claude/hooks/path-guard/test/path-guard.test.mts b/.claude/hooks/fleet/path-guard/test/path-guard.test.mts similarity index 98% rename from .claude/hooks/path-guard/test/path-guard.test.mts rename to .claude/hooks/fleet/path-guard/test/path-guard.test.mts index 12e35b9..ee79d27 100644 --- a/.claude/hooks/path-guard/test/path-guard.test.mts +++ b/.claude/hooks/fleet/path-guard/test/path-guard.test.mts @@ -261,7 +261,7 @@ describe('path-guard — exempt files', () => { ` const { code } = runHook( 'Write', - '.claude/hooks/path-guard/index.mts', + '.claude/hooks/fleet/path-guard/index.mts', source, ) assert.equal(code, 0) @@ -273,7 +273,7 @@ describe('path-guard — exempt files', () => { ` const { code } = runHook( 'Write', - '.claude/hooks/path-guard/test/path-guard.test.mts', + '.claude/hooks/fleet/path-guard/test/path-guard.test.mts', source, ) assert.equal(code, 0) diff --git a/.claude/hooks/plugin-patch-format-guard/tsconfig.json b/.claude/hooks/fleet/path-guard/tsconfig.json similarity index 100% rename from .claude/hooks/plugin-patch-format-guard/tsconfig.json rename to .claude/hooks/fleet/path-guard/tsconfig.json diff --git a/.claude/hooks/path-regex-normalize-reminder/README.md b/.claude/hooks/fleet/path-regex-normalize-reminder/README.md similarity index 100% rename from .claude/hooks/path-regex-normalize-reminder/README.md rename to .claude/hooks/fleet/path-regex-normalize-reminder/README.md diff --git a/.claude/hooks/path-regex-normalize-reminder/index.mts b/.claude/hooks/fleet/path-regex-normalize-reminder/index.mts similarity index 100% rename from .claude/hooks/path-regex-normalize-reminder/index.mts rename to .claude/hooks/fleet/path-regex-normalize-reminder/index.mts diff --git a/.claude/hooks/path-regex-normalize-reminder/package.json b/.claude/hooks/fleet/path-regex-normalize-reminder/package.json similarity index 100% rename from .claude/hooks/path-regex-normalize-reminder/package.json rename to .claude/hooks/fleet/path-regex-normalize-reminder/package.json diff --git a/.claude/hooks/fleet/path-regex-normalize-reminder/test/index.test.mts b/.claude/hooks/fleet/path-regex-normalize-reminder/test/index.test.mts new file mode 100644 index 0000000..2417bd3 --- /dev/null +++ b/.claude/hooks/fleet/path-regex-normalize-reminder/test/index.test.mts @@ -0,0 +1,36 @@ +/** + * @file Smoke test for path-regex-normalize-reminder. Stop hook that warns when + * the assistant's recent output writes dual- separator regexes like `[/\\]` + * against a path — the fleet helper `normalizePath` already gives one `/` + * representation across platforms. Smoke contract: hook loads + dispatches + * without throwing; empty transcript path → exit 0. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import { spawn } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import test from 'node:test' +import assert from 'node:assert/strict' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +async function runHook(payload: unknown): Promise<{ code: number }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + child.on('error', reject) + child.on('close', code => resolve({ code: code ?? 1 })) + child.stdin.end(JSON.stringify(payload)) + }) +} + +test('empty transcript exits 0', async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'path-regex-reminder-test-')) + const transcript = path.join(dir, 'session.jsonl') + writeFileSync(transcript, '') + const result = await runHook({ transcript_path: transcript }) + assert.equal(result.code, 0) +}) diff --git a/.claude/hooks/pointer-comment-guard/tsconfig.json b/.claude/hooks/fleet/path-regex-normalize-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/pointer-comment-guard/tsconfig.json rename to .claude/hooks/fleet/path-regex-normalize-reminder/tsconfig.json diff --git a/.claude/hooks/paths-mts-inherit-guard/README.md b/.claude/hooks/fleet/paths-mts-inherit-guard/README.md similarity index 100% rename from .claude/hooks/paths-mts-inherit-guard/README.md rename to .claude/hooks/fleet/paths-mts-inherit-guard/README.md diff --git a/.claude/hooks/paths-mts-inherit-guard/index.mts b/.claude/hooks/fleet/paths-mts-inherit-guard/index.mts similarity index 100% rename from .claude/hooks/paths-mts-inherit-guard/index.mts rename to .claude/hooks/fleet/paths-mts-inherit-guard/index.mts diff --git a/.claude/hooks/paths-mts-inherit-guard/package.json b/.claude/hooks/fleet/paths-mts-inherit-guard/package.json similarity index 100% rename from .claude/hooks/paths-mts-inherit-guard/package.json rename to .claude/hooks/fleet/paths-mts-inherit-guard/package.json diff --git a/.claude/hooks/paths-mts-inherit-guard/test/index.test.mts b/.claude/hooks/fleet/paths-mts-inherit-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/paths-mts-inherit-guard/test/index.test.mts rename to .claude/hooks/fleet/paths-mts-inherit-guard/test/index.test.mts diff --git a/.claude/hooks/pr-vs-push-default-reminder/tsconfig.json b/.claude/hooks/fleet/paths-mts-inherit-guard/tsconfig.json similarity index 100% rename from .claude/hooks/pr-vs-push-default-reminder/tsconfig.json rename to .claude/hooks/fleet/paths-mts-inherit-guard/tsconfig.json diff --git a/.claude/hooks/perfectionist-reminder/README.md b/.claude/hooks/fleet/perfectionist-reminder/README.md similarity index 100% rename from .claude/hooks/perfectionist-reminder/README.md rename to .claude/hooks/fleet/perfectionist-reminder/README.md diff --git a/.claude/hooks/perfectionist-reminder/index.mts b/.claude/hooks/fleet/perfectionist-reminder/index.mts similarity index 100% rename from .claude/hooks/perfectionist-reminder/index.mts rename to .claude/hooks/fleet/perfectionist-reminder/index.mts diff --git a/.claude/hooks/perfectionist-reminder/package.json b/.claude/hooks/fleet/perfectionist-reminder/package.json similarity index 100% rename from .claude/hooks/perfectionist-reminder/package.json rename to .claude/hooks/fleet/perfectionist-reminder/package.json diff --git a/.claude/hooks/perfectionist-reminder/test/index.test.mts b/.claude/hooks/fleet/perfectionist-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/perfectionist-reminder/test/index.test.mts rename to .claude/hooks/fleet/perfectionist-reminder/test/index.test.mts diff --git a/.claude/hooks/prefer-rebase-over-revert-guard/tsconfig.json b/.claude/hooks/fleet/perfectionist-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/prefer-rebase-over-revert-guard/tsconfig.json rename to .claude/hooks/fleet/perfectionist-reminder/tsconfig.json diff --git a/.claude/hooks/plan-location-guard/README.md b/.claude/hooks/fleet/plan-location-guard/README.md similarity index 100% rename from .claude/hooks/plan-location-guard/README.md rename to .claude/hooks/fleet/plan-location-guard/README.md diff --git a/.claude/hooks/plan-location-guard/index.mts b/.claude/hooks/fleet/plan-location-guard/index.mts similarity index 100% rename from .claude/hooks/plan-location-guard/index.mts rename to .claude/hooks/fleet/plan-location-guard/index.mts diff --git a/.claude/hooks/plan-location-guard/package.json b/.claude/hooks/fleet/plan-location-guard/package.json similarity index 100% rename from .claude/hooks/plan-location-guard/package.json rename to .claude/hooks/fleet/plan-location-guard/package.json diff --git a/.claude/hooks/plan-location-guard/test/index.test.mts b/.claude/hooks/fleet/plan-location-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/plan-location-guard/test/index.test.mts rename to .claude/hooks/fleet/plan-location-guard/test/index.test.mts diff --git a/.claude/hooks/private-name-guard/tsconfig.json b/.claude/hooks/fleet/plan-location-guard/tsconfig.json similarity index 100% rename from .claude/hooks/private-name-guard/tsconfig.json rename to .claude/hooks/fleet/plan-location-guard/tsconfig.json diff --git a/.claude/hooks/plan-review-reminder/README.md b/.claude/hooks/fleet/plan-review-reminder/README.md similarity index 100% rename from .claude/hooks/plan-review-reminder/README.md rename to .claude/hooks/fleet/plan-review-reminder/README.md diff --git a/.claude/hooks/plan-review-reminder/index.mts b/.claude/hooks/fleet/plan-review-reminder/index.mts similarity index 100% rename from .claude/hooks/plan-review-reminder/index.mts rename to .claude/hooks/fleet/plan-review-reminder/index.mts diff --git a/.claude/hooks/plan-review-reminder/package.json b/.claude/hooks/fleet/plan-review-reminder/package.json similarity index 100% rename from .claude/hooks/plan-review-reminder/package.json rename to .claude/hooks/fleet/plan-review-reminder/package.json diff --git a/.claude/hooks/plan-review-reminder/test/index.test.mts b/.claude/hooks/fleet/plan-review-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/plan-review-reminder/test/index.test.mts rename to .claude/hooks/fleet/plan-review-reminder/test/index.test.mts diff --git a/.claude/hooks/public-surface-reminder/tsconfig.json b/.claude/hooks/fleet/plan-review-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/public-surface-reminder/tsconfig.json rename to .claude/hooks/fleet/plan-review-reminder/tsconfig.json diff --git a/.claude/hooks/plugin-patch-format-guard/README.md b/.claude/hooks/fleet/plugin-patch-format-guard/README.md similarity index 100% rename from .claude/hooks/plugin-patch-format-guard/README.md rename to .claude/hooks/fleet/plugin-patch-format-guard/README.md diff --git a/.claude/hooks/plugin-patch-format-guard/index.mts b/.claude/hooks/fleet/plugin-patch-format-guard/index.mts similarity index 100% rename from .claude/hooks/plugin-patch-format-guard/index.mts rename to .claude/hooks/fleet/plugin-patch-format-guard/index.mts diff --git a/.claude/hooks/plugin-patch-format-guard/package.json b/.claude/hooks/fleet/plugin-patch-format-guard/package.json similarity index 100% rename from .claude/hooks/plugin-patch-format-guard/package.json rename to .claude/hooks/fleet/plugin-patch-format-guard/package.json diff --git a/.claude/hooks/plugin-patch-format-guard/test/index.test.mts b/.claude/hooks/fleet/plugin-patch-format-guard/test/index.test.mts similarity index 98% rename from .claude/hooks/plugin-patch-format-guard/test/index.test.mts rename to .claude/hooks/fleet/plugin-patch-format-guard/test/index.test.mts index 0a6c124..b4c3f0a 100644 --- a/.claude/hooks/plugin-patch-format-guard/test/index.test.mts +++ b/.claude/hooks/fleet/plugin-patch-format-guard/test/index.test.mts @@ -153,7 +153,9 @@ test('classifyPluginPatch: version/filename mismatch blocks', () => { test('isPluginPatchPath: matches only scripts/plugin-patches/*.patch', () => { assert.strictEqual(isPluginPatchPath(PATCH_PATH), true) assert.strictEqual( - isPluginPatchPath('/Users/x/projects/foo/scripts/other/codex-1.0.1-x.patch'), + isPluginPatchPath( + '/Users/x/projects/foo/scripts/other/codex-1.0.1-x.patch', + ), false, ) assert.strictEqual( diff --git a/.claude/hooks/pull-request-target-guard/tsconfig.json b/.claude/hooks/fleet/plugin-patch-format-guard/tsconfig.json similarity index 100% rename from .claude/hooks/pull-request-target-guard/tsconfig.json rename to .claude/hooks/fleet/plugin-patch-format-guard/tsconfig.json diff --git a/.claude/hooks/pointer-comment-guard/README.md b/.claude/hooks/fleet/pointer-comment-guard/README.md similarity index 100% rename from .claude/hooks/pointer-comment-guard/README.md rename to .claude/hooks/fleet/pointer-comment-guard/README.md diff --git a/.claude/hooks/pointer-comment-guard/index.mts b/.claude/hooks/fleet/pointer-comment-guard/index.mts similarity index 100% rename from .claude/hooks/pointer-comment-guard/index.mts rename to .claude/hooks/fleet/pointer-comment-guard/index.mts diff --git a/.claude/hooks/pointer-comment-guard/package.json b/.claude/hooks/fleet/pointer-comment-guard/package.json similarity index 100% rename from .claude/hooks/pointer-comment-guard/package.json rename to .claude/hooks/fleet/pointer-comment-guard/package.json diff --git a/.claude/hooks/pointer-comment-guard/test/index.test.mts b/.claude/hooks/fleet/pointer-comment-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/pointer-comment-guard/test/index.test.mts rename to .claude/hooks/fleet/pointer-comment-guard/test/index.test.mts diff --git a/.claude/hooks/readme-fleet-shape-guard/tsconfig.json b/.claude/hooks/fleet/pointer-comment-guard/tsconfig.json similarity index 100% rename from .claude/hooks/readme-fleet-shape-guard/tsconfig.json rename to .claude/hooks/fleet/pointer-comment-guard/tsconfig.json diff --git a/.claude/hooks/pr-vs-push-default-reminder/README.md b/.claude/hooks/fleet/pr-vs-push-default-reminder/README.md similarity index 100% rename from .claude/hooks/pr-vs-push-default-reminder/README.md rename to .claude/hooks/fleet/pr-vs-push-default-reminder/README.md diff --git a/.claude/hooks/pr-vs-push-default-reminder/index.mts b/.claude/hooks/fleet/pr-vs-push-default-reminder/index.mts similarity index 100% rename from .claude/hooks/pr-vs-push-default-reminder/index.mts rename to .claude/hooks/fleet/pr-vs-push-default-reminder/index.mts diff --git a/.claude/hooks/pr-vs-push-default-reminder/package.json b/.claude/hooks/fleet/pr-vs-push-default-reminder/package.json similarity index 100% rename from .claude/hooks/pr-vs-push-default-reminder/package.json rename to .claude/hooks/fleet/pr-vs-push-default-reminder/package.json diff --git a/.claude/hooks/pr-vs-push-default-reminder/test/index.test.mts b/.claude/hooks/fleet/pr-vs-push-default-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/pr-vs-push-default-reminder/test/index.test.mts rename to .claude/hooks/fleet/pr-vs-push-default-reminder/test/index.test.mts diff --git a/.claude/hooks/release-workflow-guard/tsconfig.json b/.claude/hooks/fleet/pr-vs-push-default-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/release-workflow-guard/tsconfig.json rename to .claude/hooks/fleet/pr-vs-push-default-reminder/tsconfig.json diff --git a/.claude/hooks/fleet/prefer-function-declaration-guard/index.mts b/.claude/hooks/fleet/prefer-function-declaration-guard/index.mts new file mode 100644 index 0000000..c9cdc7c --- /dev/null +++ b/.claude/hooks/fleet/prefer-function-declaration-guard/index.mts @@ -0,0 +1,220 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — prefer-function-declaration-guard. +// +// Edit-time partner of the `socket/prefer-function-declaration` oxlint +// rule. Blocks Write/Edit ops that introduce a module-scope `const`-bound +// function expression — `export const foo = () => {}`, +// `const foo = function () {}`, etc. The oxlint rule autofixes at commit +// time, but by then the agent has burned a turn writing the wrong shape +// (and may push the file to a downstream consumer that re-reads it). +// Catching at edit time keeps the agent from learning the wrong pattern. +// +// Banned shapes (module scope only — leading whitespace == top level): +// export const foo = (...) => { ... } +// export const foo = async (...) => expr +// export const foo = function (...) { ... } +// const foo = (...) => { ... } (no leading whitespace) +// const foo = async () => { ... } +// const foo = function () { ... } +// +// Allowed (passes through): +// - Indented `const foo = () => ...` — that's an inner-function +// expression, not module-scope; arrows correctly inherit `this`. +// - `const foo: SomeType = () => ...` — TS type annotation locks the +// contract; refactor requires human judgment. +// - `const foo = (... rest of complex destructuring ...) = ...` — +// non-Identifier declarators; let the human untangle. +// - `_internal/` files, `dist/`, `build/`, `node_modules/`. +// - Bypass phrase `Allow function-declaration bypass` in a recent turn. +// +// Reads PreToolUse JSON payload from stdin: +// { "tool_name": "Edit"|"Write", +// "tool_input": { "file_path": "...", "content"|"new_string": "..." } } +// +// Exit codes: +// 0 — pass. +// 2 — block (at least one banned const-fn-expression found). +// +// Fails open on malformed payloads (exit 0 + stderr log). + +import process from 'node:process' + +import { bypassPhrasePresent } from '../_shared/transcript.mts' + +interface ToolInput { + readonly tool_input?: + | { + readonly content?: string | undefined + readonly file_path?: string | undefined + readonly new_string?: string | undefined + readonly old_string?: string | undefined + } + | undefined + readonly tool_name?: string | undefined + readonly transcript_path?: string | undefined +} + +interface Finding { + readonly line: number + readonly name: string + readonly text: string +} + +// Module-scope `const`/`let`/`var` binding to an arrow or function +// expression. The leading anchor `^` plus the `(?:export\s+)?` prefix +// ensures we only match top-level declarations — anything indented is +// inside a function/block scope and outside the rule's autofix scope. +// Group 1: 'export ' or '' — preserved so a future autofix could keep +// the export keyword (not used here, only matched). +// Group 2: identifier. +// Group 3: '=' tail, used to scan for the `=>` arrow or `function` token +// further on. +const ARROW_DECL_RE = + /^(export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::\s*[^=]+)?\s*=\s*(?:async\s+)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>/gm +const FUNCEXPR_DECL_RE = + /^(export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::\s*[^=]+)?\s*=\s*(?:async\s+)?function\s*\*?\s*(?:\([^)]*\))/gm + +const BYPASS_PHRASE = 'Allow function-declaration bypass' + +// Files where the rule legitimately appears in fixtures: this hook's own +// tests + the oxlint rule's tests. Plus any `_internal/` dir, generated +// output (dist/build/node_modules), and the rule's own implementation +// files (which discuss the banned shapes in comments + matchers). +export function isExemptPath(filePath: string): boolean { + return ( + filePath.includes('/_internal/') || + filePath.includes('/dist/') || + filePath.includes('/build/') || + filePath.includes('/node_modules/') || + filePath.includes( + '/.claude/hooks/fleet/prefer-function-declaration-guard/', + ) || + filePath.includes( + '/.config/oxlint-plugin/rules/prefer-function-declaration.', + ) || + filePath.includes('/.config/oxlint-plugin/test/prefer-function-declaration') + ) +} + +// `const foo: SomeType = () => ...` — the type annotation makes the +// arrow form the contract. Refactor would need to drop the annotation +// or migrate it to `satisfies`. The oxlint rule skips this shape too. +function hasTypeAnnotation(line: string): boolean { + // Cheap detection: a `:` between the identifier and the `=`. False + // positives on object-destructuring patterns are gated above by the + // identifier-only declarator match — patterns like `const { a }: T =` + // never reach this check. + const eqIdx = line.indexOf('=') + if (eqIdx === -1) { + return false + } + const lhs = line.slice(0, eqIdx) + return lhs.includes(':') +} + +export function findConstFnExpressions(text: string): Finding[] { + const findings: Finding[] = [] + const lines = text.split('\n') + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]! + // Reset stateful flags before each scan. + ARROW_DECL_RE.lastIndex = 0 + FUNCEXPR_DECL_RE.lastIndex = 0 + let m: RegExpExecArray | null + while ((m = ARROW_DECL_RE.exec(line)) !== null) { + if (hasTypeAnnotation(line)) { + continue + } + findings.push({ line: i + 1, name: m[2]!, text: line.trimEnd() }) + } + while ((m = FUNCEXPR_DECL_RE.exec(line)) !== null) { + if (hasTypeAnnotation(line)) { + continue + } + findings.push({ line: i + 1, name: m[2]!, text: line.trimEnd() }) + } + } + return findings +} + +export async function readStdin(): Promise { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk) + } + return Buffer.concat(chunks).toString('utf8') +} + +async function main(): Promise { + let payload: ToolInput + try { + const raw = await readStdin() + payload = JSON.parse(raw) as ToolInput + } catch (err) { + process.stderr.write( + `prefer-function-declaration-guard: payload parse failed (${(err as Error).message})\n`, + ) + process.exit(0) + } + + const toolName = payload.tool_name + if (toolName !== 'Edit' && toolName !== 'Write') { + process.exit(0) + } + + const filePath = payload.tool_input?.file_path ?? '' + if (!filePath || isExemptPath(filePath)) { + process.exit(0) + } + + // Only police TS/JS source. Allow .cts/.mts/.cjs/.mjs/.ts/.tsx/.js/.jsx. + if (!/\.(?:c|m)?[jt]sx?$/.test(filePath)) { + process.exit(0) + } + + const text = + payload.tool_input?.content ?? payload.tool_input?.new_string ?? '' + if (!text) { + process.exit(0) + } + + const findings = findConstFnExpressions(text) + if (findings.length === 0) { + process.exit(0) + } + + if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE)) { + process.stderr.write( + `prefer-function-declaration-guard: ${findings.length} const-fn-expression(s) — bypassed via "${BYPASS_PHRASE}"\n`, + ) + process.exit(0) + } + + const lines = findings + .map(f => ` ${filePath}:${f.line} ${f.name}\n ${f.text}`) + .join('\n') + process.stderr.write( + `prefer-function-declaration-guard: refusing to introduce module-scope const-bound function expression(s).\n` + + `\n` + + `${lines}\n` + + `\n` + + `Use a function declaration instead:\n` + + ` export function foo() { ... } (not export const foo = () => ...)\n` + + ` function foo() { ... } (not const foo = function () ...)\n` + + `\n` + + `Function declarations hoist, have a stable .name in stack traces, and\n` + + `sort cleanly under socket/sort-source-methods. The companion oxlint\n` + + `rule \`socket/prefer-function-declaration\` autofixes at commit time,\n` + + `but at the cost of a wasted turn writing the wrong shape.\n` + + `\n` + + `Bypass: type "${BYPASS_PHRASE}" in a recent message.\n`, + ) + process.exit(2) +} + +main().catch(err => { + process.stderr.write( + `prefer-function-declaration-guard: ${(err as Error).message}\n`, + ) + process.exit(0) +}) diff --git a/.claude/hooks/fleet/prefer-function-declaration-guard/package.json b/.claude/hooks/fleet/prefer-function-declaration-guard/package.json new file mode 100644 index 0000000..7f8d775 --- /dev/null +++ b/.claude/hooks/fleet/prefer-function-declaration-guard/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-prefer-function-declaration-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/prefer-function-declaration-guard/test/index.test.mts b/.claude/hooks/fleet/prefer-function-declaration-guard/test/index.test.mts new file mode 100644 index 0000000..ee533b5 --- /dev/null +++ b/.claude/hooks/fleet/prefer-function-declaration-guard/test/index.test.mts @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { findConstFnExpressions, isExemptPath } from '../index.mts' + +describe('findConstFnExpressions', () => { + it('flags top-level export const arrow', () => { + const src = `export const foo = () => 42\n` + const findings = findConstFnExpressions(src) + assert.equal(findings.length, 1) + assert.equal(findings[0]!.name, 'foo') + assert.equal(findings[0]!.line, 1) + }) + + it('flags top-level const arrow without export', () => { + const src = `const foo = () => 42\n` + const findings = findConstFnExpressions(src) + assert.equal(findings.length, 1) + assert.equal(findings[0]!.name, 'foo') + }) + + it('flags export const function expression', () => { + const src = `export const foo = function () { return 42 }\n` + const findings = findConstFnExpressions(src) + assert.equal(findings.length, 1) + assert.equal(findings[0]!.name, 'foo') + }) + + it('flags export const async arrow', () => { + const src = `export const foo = async () => 42\n` + const findings = findConstFnExpressions(src) + assert.equal(findings.length, 1) + }) + + it('flags export const generator function expression', () => { + const src = `export const foo = function* () { yield 1 }\n` + const findings = findConstFnExpressions(src) + assert.equal(findings.length, 1) + }) + + it('passes export function declaration', () => { + const src = `export function foo() { return 42 }\n` + const findings = findConstFnExpressions(src) + assert.equal(findings.length, 0) + }) + + it('passes indented const arrow (not module-scope)', () => { + const src = `function outer() {\n const inner = () => 42\n return inner\n}\n` + const findings = findConstFnExpressions(src) + assert.equal(findings.length, 0) + }) + + it('passes const arrow with TS type annotation', () => { + const src = `const foo: () => number = () => 42\n` + const findings = findConstFnExpressions(src) + assert.equal(findings.length, 0) + }) + + it('passes export const arrow with TS type annotation', () => { + const src = `export const foo: Handler = () => 42\n` + const findings = findConstFnExpressions(src) + assert.equal(findings.length, 0) + }) + + it('passes non-function const', () => { + const src = `export const FOO = 42\nexport const BAR = "string"\n` + const findings = findConstFnExpressions(src) + assert.equal(findings.length, 0) + }) + + it('passes object literal assignment', () => { + const src = `export const config = { foo: () => 42 }\n` + const findings = findConstFnExpressions(src) + assert.equal(findings.length, 0) + }) + + it('flags multiple in same file', () => { + const src = `export const a = () => 1\nexport const b = () => 2\n` + const findings = findConstFnExpressions(src) + assert.equal(findings.length, 2) + assert.deepEqual( + findings.map(f => f.name), + ['a', 'b'], + ) + }) +}) + +describe('isExemptPath', () => { + it('exempts dist/', () => { + assert.equal(isExemptPath('/foo/dist/bar.js'), true) + }) + + it('exempts node_modules/', () => { + assert.equal(isExemptPath('/foo/node_modules/bar.js'), true) + }) + + it('exempts _internal/', () => { + assert.equal(isExemptPath('/foo/_internal/bar.mts'), true) + }) + + it('exempts hook own tests', () => { + assert.equal( + isExemptPath( + '/foo/.claude/hooks/fleet/prefer-function-declaration-guard/test/x.mts', + ), + true, + ) + }) + + it('exempts oxlint rule + test fixtures', () => { + assert.equal( + isExemptPath( + '/foo/.config/oxlint-plugin/rules/prefer-function-declaration.mts', + ), + true, + ) + assert.equal( + isExemptPath( + '/foo/.config/oxlint-plugin/test/prefer-function-declaration.test.mts', + ), + true, + ) + }) + + it('does not exempt regular source', () => { + assert.equal(isExemptPath('/foo/src/bar.mts'), false) + }) +}) diff --git a/.claude/hooks/scan-label-in-commit-guard/tsconfig.json b/.claude/hooks/fleet/prefer-function-declaration-guard/tsconfig.json similarity index 100% rename from .claude/hooks/scan-label-in-commit-guard/tsconfig.json rename to .claude/hooks/fleet/prefer-function-declaration-guard/tsconfig.json diff --git a/.claude/hooks/prefer-rebase-over-revert-guard/README.md b/.claude/hooks/fleet/prefer-rebase-over-revert-guard/README.md similarity index 100% rename from .claude/hooks/prefer-rebase-over-revert-guard/README.md rename to .claude/hooks/fleet/prefer-rebase-over-revert-guard/README.md diff --git a/.claude/hooks/prefer-rebase-over-revert-guard/index.mts b/.claude/hooks/fleet/prefer-rebase-over-revert-guard/index.mts similarity index 100% rename from .claude/hooks/prefer-rebase-over-revert-guard/index.mts rename to .claude/hooks/fleet/prefer-rebase-over-revert-guard/index.mts diff --git a/.claude/hooks/prefer-rebase-over-revert-guard/package.json b/.claude/hooks/fleet/prefer-rebase-over-revert-guard/package.json similarity index 100% rename from .claude/hooks/prefer-rebase-over-revert-guard/package.json rename to .claude/hooks/fleet/prefer-rebase-over-revert-guard/package.json diff --git a/.claude/hooks/prefer-rebase-over-revert-guard/test/index.test.mts b/.claude/hooks/fleet/prefer-rebase-over-revert-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/prefer-rebase-over-revert-guard/test/index.test.mts rename to .claude/hooks/fleet/prefer-rebase-over-revert-guard/test/index.test.mts diff --git a/.claude/hooks/setup-basics-tools/tsconfig.json b/.claude/hooks/fleet/prefer-rebase-over-revert-guard/tsconfig.json similarity index 100% rename from .claude/hooks/setup-basics-tools/tsconfig.json rename to .claude/hooks/fleet/prefer-rebase-over-revert-guard/tsconfig.json diff --git a/.claude/hooks/private-name-guard/README.md b/.claude/hooks/fleet/private-name-guard/README.md similarity index 96% rename from .claude/hooks/private-name-guard/README.md rename to .claude/hooks/fleet/private-name-guard/README.md index 1b4d1a2..3e5cce5 100644 --- a/.claude/hooks/private-name-guard/README.md +++ b/.claude/hooks/fleet/private-name-guard/README.md @@ -56,7 +56,7 @@ sure that read happens. "hooks": [ { "type": "command", - "command": "node .claude/hooks/private-name-guard/index.mts" + "command": "node .claude/hooks/fleet/private-name-guard/index.mts" } ] } diff --git a/.claude/hooks/private-name-guard/index.mts b/.claude/hooks/fleet/private-name-guard/index.mts similarity index 100% rename from .claude/hooks/private-name-guard/index.mts rename to .claude/hooks/fleet/private-name-guard/index.mts diff --git a/.claude/hooks/private-name-guard/package.json b/.claude/hooks/fleet/private-name-guard/package.json similarity index 100% rename from .claude/hooks/private-name-guard/package.json rename to .claude/hooks/fleet/private-name-guard/package.json diff --git a/.claude/hooks/private-name-guard/test/private-name-guard.test.mts b/.claude/hooks/fleet/private-name-guard/test/private-name-guard.test.mts similarity index 100% rename from .claude/hooks/private-name-guard/test/private-name-guard.test.mts rename to .claude/hooks/fleet/private-name-guard/test/private-name-guard.test.mts diff --git a/.claude/hooks/setup-claude-scanners/tsconfig.json b/.claude/hooks/fleet/private-name-guard/tsconfig.json similarity index 100% rename from .claude/hooks/setup-claude-scanners/tsconfig.json rename to .claude/hooks/fleet/private-name-guard/tsconfig.json diff --git a/.claude/hooks/fleet/provenance-publish-reminder/README.md b/.claude/hooks/fleet/provenance-publish-reminder/README.md new file mode 100644 index 0000000..80b6c94 --- /dev/null +++ b/.claude/hooks/fleet/provenance-publish-reminder/README.md @@ -0,0 +1,53 @@ +# provenance-publish-reminder + +Stop hook that fires after a release commit, queries the npm registry +for the published version, and warns to stderr if the version is +missing provenance attestation or trusted-publisher OIDC metadata. + +## Trigger + +The hook activates when HEAD looks like a release commit: + +- Commit subject matches `chore: bump version to vX.Y.Z` (or + `chore(scope): release vX.Y.Z`), AND the captured version equals + `package.json` version. +- OR HEAD has an annotated tag matching `vX.Y.Z` whose version equals + `package.json` version. + +## Action + +For the resolved name@version: + +1. Fetch `https://registry.npmjs.org//`. +2. If 404: silent (release in flight, retry next Stop). +3. If 2xx and BOTH `dist.attestations` + `_npmUser.trustedPublisher` + are present: silent. +4. Otherwise: warn to stderr listing the missing signals and pointing + at `scripts/check-provenance.mts` for follow-up. + +The hook never fails the turn — Stop hooks shouldn't gate. The warning +surfaces; the operator decides what to do. + +## State + +`.claude/state/provenance-reminder.last` holds the last-checked +`@` string so a given release is checked at most once. +Bumping the version resets the throttle (different stateKey). + +## Configuration + +| Env var | Behavior | +| ------------------------------------- | -------------- | +| `SOCKET_PROVENANCE_REMINDER_DISABLED` | Skip entirely. | + +## Why this exists + +Even with the canonical `scripts/publish.mts --staged + --approve` +flow, an OIDC regression in CI (workflow YAML drift, missing +`id-token: write` permission, fallback to a classic token) can publish +a version without provenance. The publish workflow exits 0; nothing +visible goes wrong; the version on npm just lacks the trust metadata +that ties it back to a specific GitHub Actions run. + +This hook closes that loop: every release commit is followed by a +quick registry check that confirms the trust signals landed. diff --git a/.claude/hooks/fleet/provenance-publish-reminder/index.mts b/.claude/hooks/fleet/provenance-publish-reminder/index.mts new file mode 100644 index 0000000..672adb3 --- /dev/null +++ b/.claude/hooks/fleet/provenance-publish-reminder/index.mts @@ -0,0 +1,255 @@ +#!/usr/bin/env node +// Claude Code Stop hook — provenance-publish-reminder. +// +// After a release commit (HEAD matches `chore: bump version to vX.Y.Z` +// or HEAD has a `vX.Y.Z`-shaped annotated tag), query the npm registry +// for that version's trust metadata and warn if it's missing either: +// - dist.attestations (--provenance was used) +// - _npmUser.trustedPublisher (OIDC trusted publisher) +// +// Why a Stop hook (not a PreToolUse gate): the version's been +// published by the time we can verify. This is post-hoc; the gate +// already failed if it failed. We catch the failure mode where the +// publish workflow ran "successfully" but somehow without OIDC (e.g. +// the workflow regressed, fell back to a classic token without +// updating the trusted-publisher block on npmjs.com). +// +// Behavior on Stop: +// 1. Drain stdin (Stop payload; we don't use it). +// 2. Skip if SOCKET_PROVENANCE_REMINDER_DISABLED is set. +// 3. Read package.json → name + version. +// 4. Check HEAD for release-shape markers. Skip if none. +// 5. Throttle via .claude/state/provenance-reminder.last so each +// release is checked at most once per name@version per session. +// 6. Fetch the registry packument. If version not yet published, +// skip silently (release is in-flight, retry next Stop). +// 7. If version exists AND has both signals → silent. +// 8. If version exists AND missing one or both → emit a warning to +// stderr (visible in transcript, not blocking). +// +// Configuration env vars (all optional): +// SOCKET_PROVENANCE_REMINDER_DISABLED skip entirely +// +// The hook NEVER fails the turn. Stop hooks shouldn't gate; they +// nudge. The warning surfaces so the operator decides what to do. + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +import { errorMessage } from '@socketsecurity/lib-stable/errors' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' + +const RELEASE_MESSAGE_RE = + /^chore(?:\([^)]*\))?:\s+(?:bump version to |release )v?(\d+\.\d+\.\d+)/i +const RELEASE_TAG_RE = /^v?(\d+\.\d+\.\d+)$/ +const STATE_PATH = '.claude/state/provenance-reminder.last' + +interface RegistryVersionInfo { + trustedPublisher?: + | { id: string; oidcConfigId?: string | undefined } + | undefined + attestations?: + | { url: string; provenance: { predicateType: string } } + | undefined +} + +async function main(): Promise { + // Drain stdin. Stop hooks always receive a payload; we don't need it. + await readStdin() + + if (process.env['SOCKET_PROVENANCE_REMINDER_DISABLED']) { + return + } + + const repoRoot = process.cwd() + const pkgPath = path.join(repoRoot, 'package.json') + if (!existsSync(pkgPath)) { + return + } + + let pkg: { name?: string | undefined; version?: string | undefined } + try { + pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as typeof pkg + } catch { + return + } + if (!pkg.name || !pkg.version) { + return + } + + if (!isReleaseHead(repoRoot, pkg.version)) { + return + } + + const stateKey = `${pkg.name}@${pkg.version}` + if (alreadyCheckedThisSession(repoRoot, stateKey)) { + return + } + + const info = await fetchVersionInfo(pkg.name, pkg.version) + if (info === undefined) { + // Version not on registry yet — release in flight or never + // published. Don't warn; the next Stop will re-check. + return + } + + // Mark this version as checked even on the happy path so we don't + // spam-fetch the registry on every Stop event. + recordChecked(repoRoot, stateKey) + + const missing: string[] = [] + if (!info.attestations) { + missing.push('provenance attestation (`--provenance` flag)') + } + if (!info.trustedPublisher) { + missing.push('trusted-publisher OIDC (`_npmUser.trustedPublisher`)') + } + if (missing.length === 0) { + return + } + + process.stderr.write( + [ + `[provenance-publish-reminder] ${stateKey} is published but missing:`, + ...missing.map(m => ` - ${m}`), + ` Verify with: pnpm exec node scripts/check-provenance.mts ${pkg.name} --version ${pkg.version}`, + ` This typically means the publish workflow regressed (e.g. fell back from staged-publish + OIDC to a classic-token publish).`, + '', + ].join('\n'), + ) +} + +/** + * Check whether HEAD looks like a release commit. Two signals: 1. HEAD's commit + * message matches the release-shape regex. 2. HEAD has an annotated tag + * matching vX.Y.Z and the version matches the package.json version (catches the + * case where the tag was created separately from the bump commit). + */ +function isReleaseHead(repoRoot: string, pkgVersion: string): boolean { + // Signal 1: commit message. + const msg = spawnSync('git', ['log', '-1', '--format=%B'], { + cwd: repoRoot, + encoding: 'utf8', + }) + if (msg.status === 0) { + const subject = (msg.stdout as string | undefined)?.split('\n')[0] ?? '' + const m = RELEASE_MESSAGE_RE.exec(subject) + if (m && m[1] === pkgVersion) { + return true + } + } + // Signal 2: HEAD tag. + const tag = spawnSync('git', ['tag', '--points-at', 'HEAD'], { + cwd: repoRoot, + encoding: 'utf8', + }) + if (tag.status !== 0) { + return false + } + const tags = ((tag.stdout as string | undefined) ?? '') + .split('\n') + .filter(Boolean) + for (const t of tags) { + const m = RELEASE_TAG_RE.exec(t) + if (m && m[1] === pkgVersion) { + return true + } + } + return false +} + +function alreadyCheckedThisSession( + repoRoot: string, + stateKey: string, +): boolean { + const statePath = path.join(repoRoot, STATE_PATH) + if (!existsSync(statePath)) { + return false + } + try { + const last = readFileSync(statePath, 'utf8').trim() + return last === stateKey + } catch { + return false + } +} + +function recordChecked(repoRoot: string, stateKey: string): void { + const statePath = path.join(repoRoot, STATE_PATH) + try { + mkdirSync(path.dirname(statePath), { recursive: true }) + writeFileSync(statePath, stateKey, 'utf8') + } catch { + // Best-effort; if we can't write state we'll re-check next Stop. + } +} + +/** + * Fetch a single version's trust info. Returns undefined when the version isn't + * on the registry yet (the publish hasn't propagated or didn't happen). + */ +async function fetchVersionInfo( + name: string, + version: string, +): Promise { + const url = `https://registry.npmjs.org/${encodeURIComponent(name).replace('%40', '@')}/${encodeURIComponent(version)}` + try { + // socket-hook: allow global-fetch -- provenance check probes the npm registry; runs as a standalone hook without the lib http-request helper wired up. + const response = await fetch(url, { + headers: { accept: 'application/json' }, + }) + if (response.status === 404) { + return undefined + } + if (!response.ok) { + return undefined + } + const json = (await response.json()) as { + dist?: + | { + attestations?: + | { url: string; provenance: { predicateType: string } } + | undefined + } + | undefined + _npmUser?: + | { + trustedPublisher?: + | { id: string; oidcConfigId?: string | undefined } + | undefined + } + | undefined + } + return { + ...(json._npmUser?.trustedPublisher + ? { trustedPublisher: json._npmUser.trustedPublisher } + : {}), + ...(json.dist?.attestations + ? { attestations: json.dist.attestations } + : {}), + } + } catch { + return undefined + } +} + +function readStdin(): Promise { + return new Promise(resolve => { + let buf = '' + process.stdin.setEncoding('utf8') + process.stdin.on('data', chunk => { + buf += chunk + }) + process.stdin.on('end', () => { + resolve(buf) + }) + }) +} + +main().catch(e => { + // Stop hooks should never crash the turn. Log + continue. + process.stderr.write( + `[provenance-publish-reminder] hook error (continuing): ${errorMessage(e)}\n`, + ) +}) diff --git a/.claude/hooks/fleet/provenance-publish-reminder/package.json b/.claude/hooks/fleet/provenance-publish-reminder/package.json new file mode 100644 index 0000000..f6de099 --- /dev/null +++ b/.claude/hooks/fleet/provenance-publish-reminder/package.json @@ -0,0 +1,18 @@ +{ + "name": "hook-provenance-publish-reminder", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "dependencies": { + "@socketsecurity/lib-stable": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/fleet/provenance-publish-reminder/test/index.test.mts b/.claude/hooks/fleet/provenance-publish-reminder/test/index.test.mts new file mode 100644 index 0000000..c7324a0 --- /dev/null +++ b/.claude/hooks/fleet/provenance-publish-reminder/test/index.test.mts @@ -0,0 +1,35 @@ +/** + * @file Smoke test for provenance-publish-reminder. Stop hook that fires when + * the assistant's recent turn appears to be a publish action without the + * canonical provenance + trustedPublisher verification steps. Smoke contract: + * hook loads + dispatches without throwing; empty transcript path → exit 0. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import { spawn } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import test from 'node:test' +import assert from 'node:assert/strict' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +async function runHook(payload: unknown): Promise<{ code: number }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + child.on('error', reject) + child.on('close', code => resolve({ code: code ?? 1 })) + child.stdin.end(JSON.stringify(payload)) + }) +} + +test('empty transcript exits 0', async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'provenance-reminder-test-')) + const transcript = path.join(dir, 'session.jsonl') + writeFileSync(transcript, '') + const result = await runHook({ transcript_path: transcript }) + assert.equal(result.code, 0) +}) diff --git a/.claude/hooks/setup-firewall/tsconfig.json b/.claude/hooks/fleet/provenance-publish-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/setup-firewall/tsconfig.json rename to .claude/hooks/fleet/provenance-publish-reminder/tsconfig.json diff --git a/.claude/hooks/public-surface-reminder/README.md b/.claude/hooks/fleet/public-surface-reminder/README.md similarity index 96% rename from .claude/hooks/public-surface-reminder/README.md rename to .claude/hooks/fleet/public-surface-reminder/README.md index 01fde02..d182572 100644 --- a/.claude/hooks/public-surface-reminder/README.md +++ b/.claude/hooks/fleet/public-surface-reminder/README.md @@ -57,7 +57,7 @@ In `.claude/settings.json`: "hooks": [ { "type": "command", - "command": "node .claude/hooks/public-surface-reminder/index.mts" + "command": "node .claude/hooks/fleet/public-surface-reminder/index.mts" } ] } diff --git a/.claude/hooks/public-surface-reminder/index.mts b/.claude/hooks/fleet/public-surface-reminder/index.mts similarity index 100% rename from .claude/hooks/public-surface-reminder/index.mts rename to .claude/hooks/fleet/public-surface-reminder/index.mts diff --git a/.claude/hooks/public-surface-reminder/package.json b/.claude/hooks/fleet/public-surface-reminder/package.json similarity index 100% rename from .claude/hooks/public-surface-reminder/package.json rename to .claude/hooks/fleet/public-surface-reminder/package.json diff --git a/.claude/hooks/public-surface-reminder/test/public-surface-reminder.test.mts b/.claude/hooks/fleet/public-surface-reminder/test/public-surface-reminder.test.mts similarity index 100% rename from .claude/hooks/public-surface-reminder/test/public-surface-reminder.test.mts rename to .claude/hooks/fleet/public-surface-reminder/test/public-surface-reminder.test.mts diff --git a/.claude/hooks/setup-misc-tools/tsconfig.json b/.claude/hooks/fleet/public-surface-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/setup-misc-tools/tsconfig.json rename to .claude/hooks/fleet/public-surface-reminder/tsconfig.json diff --git a/.claude/hooks/pull-request-target-guard/README.md b/.claude/hooks/fleet/pull-request-target-guard/README.md similarity index 100% rename from .claude/hooks/pull-request-target-guard/README.md rename to .claude/hooks/fleet/pull-request-target-guard/README.md diff --git a/.claude/hooks/pull-request-target-guard/index.mts b/.claude/hooks/fleet/pull-request-target-guard/index.mts similarity index 100% rename from .claude/hooks/pull-request-target-guard/index.mts rename to .claude/hooks/fleet/pull-request-target-guard/index.mts diff --git a/.claude/hooks/pull-request-target-guard/package.json b/.claude/hooks/fleet/pull-request-target-guard/package.json similarity index 100% rename from .claude/hooks/pull-request-target-guard/package.json rename to .claude/hooks/fleet/pull-request-target-guard/package.json diff --git a/.claude/hooks/pull-request-target-guard/test/index.test.mts b/.claude/hooks/fleet/pull-request-target-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/pull-request-target-guard/test/index.test.mts rename to .claude/hooks/fleet/pull-request-target-guard/test/index.test.mts diff --git a/.claude/hooks/setup-security-tools/tsconfig.json b/.claude/hooks/fleet/pull-request-target-guard/tsconfig.json similarity index 100% rename from .claude/hooks/setup-security-tools/tsconfig.json rename to .claude/hooks/fleet/pull-request-target-guard/tsconfig.json diff --git a/.claude/hooks/readme-fleet-shape-guard/README.md b/.claude/hooks/fleet/readme-fleet-shape-guard/README.md similarity index 89% rename from .claude/hooks/readme-fleet-shape-guard/README.md rename to .claude/hooks/fleet/readme-fleet-shape-guard/README.md index 20f19c6..5c811fe 100644 --- a/.claude/hooks/readme-fleet-shape-guard/README.md +++ b/.claude/hooks/fleet/readme-fleet-shape-guard/README.md @@ -32,5 +32,5 @@ The hook fails open on its own bugs (exit 0 + stderr log) so a buggy hook can't ## Related -- `.claude/hooks/no-meta-comments-guard/` — structural template; same `_shared/transcript.mts` bypass pattern. -- `.claude/hooks/plan-location-guard/` — same PreToolUse + bypass shape, blocking on file-path classification. +- `.claude/hooks/fleet/no-meta-comments-guard/` — structural template; same `_shared/transcript.mts` bypass pattern. +- `.claude/hooks/fleet/plan-location-guard/` — same PreToolUse + bypass shape, blocking on file-path classification. diff --git a/.claude/hooks/readme-fleet-shape-guard/index.mts b/.claude/hooks/fleet/readme-fleet-shape-guard/index.mts similarity index 100% rename from .claude/hooks/readme-fleet-shape-guard/index.mts rename to .claude/hooks/fleet/readme-fleet-shape-guard/index.mts diff --git a/.claude/hooks/readme-fleet-shape-guard/package.json b/.claude/hooks/fleet/readme-fleet-shape-guard/package.json similarity index 100% rename from .claude/hooks/readme-fleet-shape-guard/package.json rename to .claude/hooks/fleet/readme-fleet-shape-guard/package.json diff --git a/.claude/hooks/readme-fleet-shape-guard/test/index.test.mts b/.claude/hooks/fleet/readme-fleet-shape-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/readme-fleet-shape-guard/test/index.test.mts rename to .claude/hooks/fleet/readme-fleet-shape-guard/test/index.test.mts diff --git a/.claude/hooks/setup-signing/tsconfig.json b/.claude/hooks/fleet/readme-fleet-shape-guard/tsconfig.json similarity index 100% rename from .claude/hooks/setup-signing/tsconfig.json rename to .claude/hooks/fleet/readme-fleet-shape-guard/tsconfig.json diff --git a/.claude/hooks/release-workflow-guard/README.md b/.claude/hooks/fleet/release-workflow-guard/README.md similarity index 97% rename from .claude/hooks/release-workflow-guard/README.md rename to .claude/hooks/fleet/release-workflow-guard/README.md index 5be9f04..6bfbac7 100644 --- a/.claude/hooks/release-workflow-guard/README.md +++ b/.claude/hooks/fleet/release-workflow-guard/README.md @@ -68,7 +68,7 @@ a Claude session: "hooks": [ { "type": "command", - "command": "node .claude/hooks/release-workflow-guard/index.mts" + "command": "node .claude/hooks/fleet/release-workflow-guard/index.mts" } ] } diff --git a/.claude/hooks/release-workflow-guard/index.mts b/.claude/hooks/fleet/release-workflow-guard/index.mts similarity index 99% rename from .claude/hooks/release-workflow-guard/index.mts rename to .claude/hooks/fleet/release-workflow-guard/index.mts index 4c313a9..2d319fc 100644 --- a/.claude/hooks/release-workflow-guard/index.mts +++ b/.claude/hooks/fleet/release-workflow-guard/index.mts @@ -428,7 +428,7 @@ export function workflowDeclaresDryRunInput( export function resolveSearchRoots(command: string): string[] { // Resolution order: $CLAUDE_PROJECT_DIR (Claude Code sets this when // it remembers to) → derive from this hook script's path (the hook - // lives at /.claude/hooks/release-workflow-guard/index.mts, + // lives at /.claude/hooks/fleet/release-workflow-guard/index.mts, // so go three levels up from __dirname) → $PWD as last resort. // The script-path derivation is the most robust because it doesn't // depend on the runner exporting env vars correctly. @@ -438,7 +438,7 @@ export function resolveSearchRoots(command: string): string[] { // invoked via `node `. Walk up to the repo root. const scriptPath = process.argv[1] if (scriptPath) { - // .claude/hooks/release-workflow-guard/index.mts → ../../../ = repo + // .claude/hooks/fleet/release-workflow-guard/index.mts → ../../../ = repo const candidate = path.resolve(scriptPath, '..', '..', '..', '..') if (existsSync(path.join(candidate, '.github', 'workflows'))) { projectDir = candidate diff --git a/.claude/hooks/release-workflow-guard/package.json b/.claude/hooks/fleet/release-workflow-guard/package.json similarity index 100% rename from .claude/hooks/release-workflow-guard/package.json rename to .claude/hooks/fleet/release-workflow-guard/package.json diff --git a/.claude/hooks/release-workflow-guard/test/release-workflow-guard.test.mts b/.claude/hooks/fleet/release-workflow-guard/test/release-workflow-guard.test.mts similarity index 100% rename from .claude/hooks/release-workflow-guard/test/release-workflow-guard.test.mts rename to .claude/hooks/fleet/release-workflow-guard/test/release-workflow-guard.test.mts diff --git a/.claude/hooks/soak-exclude-date-annotation-guard/tsconfig.json b/.claude/hooks/fleet/release-workflow-guard/tsconfig.json similarity index 100% rename from .claude/hooks/soak-exclude-date-annotation-guard/tsconfig.json rename to .claude/hooks/fleet/release-workflow-guard/tsconfig.json diff --git a/.claude/hooks/scan-label-in-commit-guard/README.md b/.claude/hooks/fleet/scan-label-in-commit-guard/README.md similarity index 100% rename from .claude/hooks/scan-label-in-commit-guard/README.md rename to .claude/hooks/fleet/scan-label-in-commit-guard/README.md diff --git a/.claude/hooks/scan-label-in-commit-guard/index.mts b/.claude/hooks/fleet/scan-label-in-commit-guard/index.mts similarity index 100% rename from .claude/hooks/scan-label-in-commit-guard/index.mts rename to .claude/hooks/fleet/scan-label-in-commit-guard/index.mts diff --git a/.claude/hooks/scan-label-in-commit-guard/package.json b/.claude/hooks/fleet/scan-label-in-commit-guard/package.json similarity index 100% rename from .claude/hooks/scan-label-in-commit-guard/package.json rename to .claude/hooks/fleet/scan-label-in-commit-guard/package.json diff --git a/.claude/hooks/scan-label-in-commit-guard/test/index.test.mts b/.claude/hooks/fleet/scan-label-in-commit-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/scan-label-in-commit-guard/test/index.test.mts rename to .claude/hooks/fleet/scan-label-in-commit-guard/test/index.test.mts diff --git a/.claude/hooks/socket-token-minifier-start/tsconfig.json b/.claude/hooks/fleet/scan-label-in-commit-guard/tsconfig.json similarity index 100% rename from .claude/hooks/socket-token-minifier-start/tsconfig.json rename to .claude/hooks/fleet/scan-label-in-commit-guard/tsconfig.json diff --git a/.claude/hooks/setup-basics-tools/README.md b/.claude/hooks/fleet/setup-basics-tools/README.md similarity index 89% rename from .claude/hooks/setup-basics-tools/README.md rename to .claude/hooks/fleet/setup-basics-tools/README.md index 99cabef..5573fd1 100644 --- a/.claude/hooks/setup-basics-tools/README.md +++ b/.claude/hooks/fleet/setup-basics-tools/README.md @@ -7,11 +7,11 @@ TruffleHog, Trivy, OpenGrep, and uv. Slim leaf of the ## When to use ```sh -node .claude/hooks/setup-basics-tools/install.mts +node .claude/hooks/fleet/setup-basics-tools/install.mts ``` For the full setup (firewall + scanners + socket-basics + misc), use -`node .claude/hooks/setup-security-tools/install.mts`. +`node .claude/hooks/fleet/setup-security-tools/install.mts`. ## What gets installed diff --git a/.claude/hooks/setup-basics-tools/install.mts b/.claude/hooks/fleet/setup-basics-tools/install.mts similarity index 88% rename from .claude/hooks/setup-basics-tools/install.mts rename to .claude/hooks/fleet/setup-basics-tools/install.mts index 8aa57da..ab3d470 100644 --- a/.claude/hooks/setup-basics-tools/install.mts +++ b/.claude/hooks/fleet/setup-basics-tools/install.mts @@ -4,9 +4,9 @@ * TruffleHog (secrets scanner), Trivy (vuln/SBOM scanner), OpenGrep (SAST), * and uv (Python package manager bootstrap). Slim leaf of the * `setup-security-tools` umbrella. Run via: node - * .claude/hooks/setup-basics-tools/install.mts For the full setup (firewall + - * scanners + socket-basics + misc), use `node - * .claude/hooks/setup-security-tools/install.mts`. + * .claude/hooks/fleet/setup-basics-tools/install.mts For the full setup + * (firewall + scanners + socket-basics + misc), use `node + * .claude/hooks/fleet/setup-security-tools/install.mts`. */ import process from 'node:process' diff --git a/.claude/hooks/setup-basics-tools/package.json b/.claude/hooks/fleet/setup-basics-tools/package.json similarity index 100% rename from .claude/hooks/setup-basics-tools/package.json rename to .claude/hooks/fleet/setup-basics-tools/package.json diff --git a/.claude/hooks/squash-history-reminder/tsconfig.json b/.claude/hooks/fleet/setup-basics-tools/tsconfig.json similarity index 100% rename from .claude/hooks/squash-history-reminder/tsconfig.json rename to .claude/hooks/fleet/setup-basics-tools/tsconfig.json diff --git a/.claude/hooks/setup-claude-scanners/README.md b/.claude/hooks/fleet/setup-claude-scanners/README.md similarity index 92% rename from .claude/hooks/setup-claude-scanners/README.md rename to .claude/hooks/fleet/setup-claude-scanners/README.md index 4fe2f4a..cda015c 100644 --- a/.claude/hooks/setup-claude-scanners/README.md +++ b/.claude/hooks/fleet/setup-claude-scanners/README.md @@ -13,11 +13,11 @@ claude-config / GitHub-Actions scanners. Slim leaf of the scanning right now is claude-config + workflow YAML. ```sh -node .claude/hooks/setup-claude-scanners/install.mts +node .claude/hooks/fleet/setup-claude-scanners/install.mts ``` For the full setup (firewall + scanners + socket-basics + misc), use -`node .claude/hooks/setup-security-tools/install.mts`. +`node .claude/hooks/fleet/setup-security-tools/install.mts`. ## Relationship to setup-security-tools diff --git a/.claude/hooks/setup-claude-scanners/install.mts b/.claude/hooks/fleet/setup-claude-scanners/install.mts similarity index 90% rename from .claude/hooks/setup-claude-scanners/install.mts rename to .claude/hooks/fleet/setup-claude-scanners/install.mts index 02081e9..51f5715 100644 --- a/.claude/hooks/setup-claude-scanners/install.mts +++ b/.claude/hooks/fleet/setup-claude-scanners/install.mts @@ -3,9 +3,9 @@ * @file Install-only entry point for AgentShield + zizmor — the two * claude-config / GitHub-Actions scanners. Slim leaf of the * `setup-security-tools` umbrella. Run via: node - * .claude/hooks/setup-claude-scanners/install.mts For the full setup + * .claude/hooks/fleet/setup-claude-scanners/install.mts For the full setup * (firewall + scanners + socket-basics + misc), use `node - * .claude/hooks/setup-security-tools/install.mts`. + * .claude/hooks/fleet/setup-security-tools/install.mts`. */ import process from 'node:process' diff --git a/.claude/hooks/setup-claude-scanners/package.json b/.claude/hooks/fleet/setup-claude-scanners/package.json similarity index 100% rename from .claude/hooks/setup-claude-scanners/package.json rename to .claude/hooks/fleet/setup-claude-scanners/package.json diff --git a/.claude/hooks/stale-process-sweeper/tsconfig.json b/.claude/hooks/fleet/setup-claude-scanners/tsconfig.json similarity index 100% rename from .claude/hooks/stale-process-sweeper/tsconfig.json rename to .claude/hooks/fleet/setup-claude-scanners/tsconfig.json diff --git a/.claude/hooks/setup-firewall/README.md b/.claude/hooks/fleet/setup-firewall/README.md similarity index 94% rename from .claude/hooks/setup-firewall/README.md rename to .claude/hooks/fleet/setup-firewall/README.md index 2e09edc..3a7e091 100644 --- a/.claude/hooks/setup-firewall/README.md +++ b/.claude/hooks/fleet/setup-firewall/README.md @@ -13,10 +13,10 @@ free). Slim leaf of the `setup-security-tools` umbrella. ```sh # Install / verify -node .claude/hooks/setup-firewall/install.mts +node .claude/hooks/fleet/setup-firewall/install.mts # Rotate the API token (re-prompts; overwrites keychain) -node .claude/hooks/setup-firewall/install.mts --rotate +node .claude/hooks/fleet/setup-firewall/install.mts --rotate ``` ## Relationship to setup-security-tools diff --git a/.claude/hooks/setup-firewall/install.mts b/.claude/hooks/fleet/setup-firewall/install.mts similarity index 87% rename from .claude/hooks/setup-firewall/install.mts rename to .claude/hooks/fleet/setup-firewall/install.mts index 2369181..5b21d77 100644 --- a/.claude/hooks/setup-firewall/install.mts +++ b/.claude/hooks/fleet/setup-firewall/install.mts @@ -6,10 +6,10 @@ * AgentShield / zizmor / socket-basics tool installers. The actual installer * code lives in `../setup-security-tools/lib/installers.mts`. This entry * point exists so operators can scope their setup precisely: node - * .claude/hooks/setup-firewall/install.mts For the full setup, use `node - * .claude/hooks/setup-security-tools/install.mts` which sequences this leaf - * alongside the others. --rotate is honored here too — re-prompts for - * SOCKET_API_KEY and overwrites the OS keychain entry, just like the + * .claude/hooks/fleet/setup-firewall/install.mts For the full setup, use + * `node .claude/hooks/fleet/setup-security-tools/install.mts` which sequences + * this leaf alongside the others. --rotate is honored here too — re-prompts + * for SOCKET_API_KEY and overwrites the OS keychain entry, just like the * umbrella's --rotate path. */ diff --git a/.claude/hooks/setup-firewall/package.json b/.claude/hooks/fleet/setup-firewall/package.json similarity index 100% rename from .claude/hooks/setup-firewall/package.json rename to .claude/hooks/fleet/setup-firewall/package.json diff --git a/.claude/hooks/sweep-ds-store/tsconfig.json b/.claude/hooks/fleet/setup-firewall/tsconfig.json similarity index 100% rename from .claude/hooks/sweep-ds-store/tsconfig.json rename to .claude/hooks/fleet/setup-firewall/tsconfig.json diff --git a/.claude/hooks/setup-misc-tools/README.md b/.claude/hooks/fleet/setup-misc-tools/README.md similarity index 88% rename from .claude/hooks/setup-misc-tools/README.md rename to .claude/hooks/fleet/setup-misc-tools/README.md index 287134b..26b4a57 100644 --- a/.claude/hooks/setup-misc-tools/README.md +++ b/.claude/hooks/fleet/setup-misc-tools/README.md @@ -6,11 +6,11 @@ and **janus**. Slim leaf of the `setup-security-tools` umbrella. ## When to use ```sh -node .claude/hooks/setup-misc-tools/install.mts +node .claude/hooks/fleet/setup-misc-tools/install.mts ``` For the full setup (firewall + scanners + socket-basics + misc), use -`node .claude/hooks/setup-security-tools/install.mts`. +`node .claude/hooks/fleet/setup-security-tools/install.mts`. ## What gets installed diff --git a/.claude/hooks/setup-misc-tools/install.mts b/.claude/hooks/fleet/setup-misc-tools/install.mts similarity index 85% rename from .claude/hooks/setup-misc-tools/install.mts rename to .claude/hooks/fleet/setup-misc-tools/install.mts index 3a4f524..c666dc9 100644 --- a/.claude/hooks/setup-misc-tools/install.mts +++ b/.claude/hooks/fleet/setup-misc-tools/install.mts @@ -2,9 +2,9 @@ /** * @file Install-only entry point for one-off tools: cdxgen (SBOM), synp * (lockfile interop), and janus. Slim leaf of the `setup-security-tools` - * umbrella. Run via: node .claude/hooks/setup-misc-tools/install.mts For the - * full setup (firewall + scanners + socket-basics + misc), use `node - * .claude/hooks/setup-security-tools/install.mts`. + * umbrella. Run via: node .claude/hooks/fleet/setup-misc-tools/install.mts + * For the full setup (firewall + scanners + socket-basics + misc), use `node + * .claude/hooks/fleet/setup-security-tools/install.mts`. */ import process from 'node:process' diff --git a/.claude/hooks/setup-misc-tools/package.json b/.claude/hooks/fleet/setup-misc-tools/package.json similarity index 100% rename from .claude/hooks/setup-misc-tools/package.json rename to .claude/hooks/fleet/setup-misc-tools/package.json diff --git a/.claude/hooks/token-guard/tsconfig.json b/.claude/hooks/fleet/setup-misc-tools/tsconfig.json similarity index 100% rename from .claude/hooks/token-guard/tsconfig.json rename to .claude/hooks/fleet/setup-misc-tools/tsconfig.json diff --git a/.claude/hooks/setup-security-tools/README.md b/.claude/hooks/fleet/setup-security-tools/README.md similarity index 96% rename from .claude/hooks/setup-security-tools/README.md rename to .claude/hooks/fleet/setup-security-tools/README.md index 6290815..9159a88 100644 --- a/.claude/hooks/setup-security-tools/README.md +++ b/.claude/hooks/fleet/setup-security-tools/README.md @@ -67,7 +67,7 @@ resolves it. pnpm run setup ``` -(That's wired in `package.json` to `node .claude/hooks/setup-security-tools/index.mts`.) +(That's wired in `package.json` to `node .claude/hooks/fleet/setup-security-tools/index.mts`.) The script will detect whether you have a `SOCKET_API_KEY` (or the forward-canonical `SOCKET_API_TOKEN` alternative), ask if unsure, @@ -112,7 +112,7 @@ Safe to run multiple times: The hook is self-contained but has three workspace dependencies. To add it to a new Socket repo: -1. Copy `.claude/hooks/setup-security-tools/` and +1. Copy `.claude/hooks/fleet/setup-security-tools/` and `.claude/commands/setup-security-tools.md`. 2. Make sure the consumer repo's catalog (or `dependencies`) provides `@socketsecurity/lib-stable`, `@socketregistry/packageurl-js-stable`, and @@ -120,7 +120,7 @@ add it to a new Socket repo: 3. Make sure `.claude/hooks/` isn't gitignored — add `!/.claude/hooks/` to `.gitignore` if needed. 4. Add a `setup` script to `package.json`: - `"setup": "node .claude/hooks/setup-security-tools/index.mts"`. + `"setup": "node .claude/hooks/fleet/setup-security-tools/index.mts"`. 5. Run `pnpm install` so the hook's workspace deps resolve. ## Troubleshooting diff --git a/.claude/hooks/setup-security-tools/external-tools.json b/.claude/hooks/fleet/setup-security-tools/external-tools.json similarity index 100% rename from .claude/hooks/setup-security-tools/external-tools.json rename to .claude/hooks/fleet/setup-security-tools/external-tools.json diff --git a/.claude/hooks/setup-security-tools/index.mts b/.claude/hooks/fleet/setup-security-tools/index.mts similarity index 97% rename from .claude/hooks/setup-security-tools/index.mts rename to .claude/hooks/fleet/setup-security-tools/index.mts index 60c6397..1954f2f 100644 --- a/.claude/hooks/setup-security-tools/index.mts +++ b/.claude/hooks/fleet/setup-security-tools/index.mts @@ -31,7 +31,7 @@ // Output: stderr lines starting with `[setup-security-tools]`. Each // finding ends with the exact remediation command: // -// node .claude/hooks/setup-security-tools/install.mts +// node .claude/hooks/fleet/setup-security-tools/install.mts // // Disabled via `SOCKET_SETUP_SECURITY_TOOLS_DISABLED=1`. // @@ -101,7 +101,7 @@ export function checkEdition(): Finding[] { kind: 'edition-mismatch', message: 'SOCKET_API_KEY is set but the SFW shim is the free build. ' + - 'Run `node .claude/hooks/setup-security-tools/install.mts` to ' + + 'Run `node .claude/hooks/fleet/setup-security-tools/install.mts` to ' + 'switch to sfw-enterprise (org-aware malware scanning + private ' + 'package data).', }, @@ -156,7 +156,7 @@ export async function checkShims(): Promise { `(manifest rebuild, manual delete, or cache rotation). Every ` + `command through ${broken.length === 1 ? 'that shim' : 'those shims'} ` + `currently fails with "No such file or directory." Run ` + - `\`node .claude/hooks/setup-security-tools/install.mts\` to ` + + `\`node .claude/hooks/fleet/setup-security-tools/install.mts\` to ` + `re-download SFW and rewrite the shims.`, }, ] @@ -222,7 +222,7 @@ export async function checkToken401( message: 'Socket API returned 401 — the configured SOCKET_API_KEY ' + 'is invalid, expired, or lacks the required permissions. ' + - 'Run `node .claude/hooks/setup-security-tools/install.mts ' + + 'Run `node .claude/hooks/fleet/setup-security-tools/install.mts ' + '--rotate` to re-prompt and overwrite the keychain entry.', }, ] diff --git a/.claude/hooks/setup-security-tools/install.mts b/.claude/hooks/fleet/setup-security-tools/install.mts similarity index 93% rename from .claude/hooks/setup-security-tools/install.mts rename to .claude/hooks/fleet/setup-security-tools/install.mts index cac5d0a..13bac99 100644 --- a/.claude/hooks/setup-security-tools/install.mts +++ b/.claude/hooks/fleet/setup-security-tools/install.mts @@ -16,13 +16,13 @@ * - Stdin isn't a TTY (`!process.stdin.isTTY`). In those skip cases, the script * falls back to sfw-free (the auth- free SFW build) and continues without * persisting a token. Invocation: node - * .claude/hooks/setup-security-tools/install.mts node - * .claude/hooks/setup-security-tools/install.mts --rotate Flags: --rotate - * Re-prompt for SOCKET_API_KEY and overwrite the keychain entry, ignoring - * env/.env/keychain lookup. Use to rotate a leaked or expired token without - * manually clearing the keychain first. --update-token Alias for --rotate. - * Exit codes: 0 — all tools installed + verified. 1 — at least one tool - * failed; details on stderr. + * .claude/hooks/fleet/setup-security-tools/install.mts node + * .claude/hooks/fleet/setup-security-tools/install.mts --rotate Flags: + * --rotate Re-prompt for SOCKET_API_KEY and overwrite the keychain entry, + * ignoring env/.env/keychain lookup. Use to rotate a leaked or expired + * token without manually clearing the keychain first. --update-token Alias + * for --rotate. Exit codes: 0 — all tools installed + verified. 1 — at + * least one tool failed; details on stderr. */ import { existsSync, promises as fs } from 'node:fs' diff --git a/.claude/hooks/setup-security-tools/lib/api-token.mts b/.claude/hooks/fleet/setup-security-tools/lib/api-token.mts similarity index 100% rename from .claude/hooks/setup-security-tools/lib/api-token.mts rename to .claude/hooks/fleet/setup-security-tools/lib/api-token.mts diff --git a/.claude/hooks/setup-security-tools/lib/installers.mts b/.claude/hooks/fleet/setup-security-tools/lib/installers.mts similarity index 99% rename from .claude/hooks/setup-security-tools/lib/installers.mts rename to .claude/hooks/fleet/setup-security-tools/lib/installers.mts index 597abab..de1450d 100644 --- a/.claude/hooks/setup-security-tools/lib/installers.mts +++ b/.claude/hooks/fleet/setup-security-tools/lib/installers.mts @@ -61,7 +61,7 @@ const configSchema = Type.Object({ const __dirname = path.dirname(fileURLToPath(import.meta.url)) // external-tools.json lives one level up at the hook root -// (.claude/hooks/setup-security-tools/external-tools.json) — keep it +// (.claude/hooks/fleet/setup-security-tools/external-tools.json) — keep it // out of `lib/` so it's discoverable as a top-level config file rather // than buried as an implementation detail. Fall back to a sibling path // so an early-installed copy in lib/ still resolves during onboarding. diff --git a/.claude/hooks/setup-security-tools/lib/operator-prompts.mts b/.claude/hooks/fleet/setup-security-tools/lib/operator-prompts.mts similarity index 100% rename from .claude/hooks/setup-security-tools/lib/operator-prompts.mts rename to .claude/hooks/fleet/setup-security-tools/lib/operator-prompts.mts diff --git a/.claude/hooks/setup-security-tools/lib/shell-rc-bridge.mts b/.claude/hooks/fleet/setup-security-tools/lib/shell-rc-bridge.mts similarity index 99% rename from .claude/hooks/setup-security-tools/lib/shell-rc-bridge.mts rename to .claude/hooks/fleet/setup-security-tools/lib/shell-rc-bridge.mts index c12c52c..1a78051 100644 --- a/.claude/hooks/setup-security-tools/lib/shell-rc-bridge.mts +++ b/.claude/hooks/fleet/setup-security-tools/lib/shell-rc-bridge.mts @@ -48,7 +48,7 @@ const BLOCK_END = '# END socket-cli env' export function buildBlockBody(token: string): string { const quoted = shellSingleQuote(token) return `# Token persisted by setup-security-tools install.mts. -# Rotate via: node .claude/hooks/setup-security-tools/install.mts --rotate +# Rotate via: node .claude/hooks/fleet/setup-security-tools/install.mts --rotate # Keychain copy still lives at: security find-generic-password -s socket-cli -a SOCKET_API_KEY # SOCKET_API_KEY is universally supported across Socket tools (CLI, SDK, sfw, # fleet scripts) — one env var covers the whole surface with no fallback chain. diff --git a/.claude/hooks/setup-security-tools/lib/token-storage.mts b/.claude/hooks/fleet/setup-security-tools/lib/token-storage.mts similarity index 100% rename from .claude/hooks/setup-security-tools/lib/token-storage.mts rename to .claude/hooks/fleet/setup-security-tools/lib/token-storage.mts diff --git a/.claude/hooks/setup-security-tools/package.json b/.claude/hooks/fleet/setup-security-tools/package.json similarity index 100% rename from .claude/hooks/setup-security-tools/package.json rename to .claude/hooks/fleet/setup-security-tools/package.json diff --git a/.claude/hooks/setup-security-tools/test/setup-security-tools.test.mts b/.claude/hooks/fleet/setup-security-tools/test/setup-security-tools.test.mts similarity index 100% rename from .claude/hooks/setup-security-tools/test/setup-security-tools.test.mts rename to .claude/hooks/fleet/setup-security-tools/test/setup-security-tools.test.mts diff --git a/.claude/hooks/setup-security-tools/test/shell-rc-bridge.test.mts b/.claude/hooks/fleet/setup-security-tools/test/shell-rc-bridge.test.mts similarity index 100% rename from .claude/hooks/setup-security-tools/test/shell-rc-bridge.test.mts rename to .claude/hooks/fleet/setup-security-tools/test/shell-rc-bridge.test.mts diff --git a/.claude/hooks/trust-downgrade-guard/tsconfig.json b/.claude/hooks/fleet/setup-security-tools/tsconfig.json similarity index 100% rename from .claude/hooks/trust-downgrade-guard/tsconfig.json rename to .claude/hooks/fleet/setup-security-tools/tsconfig.json diff --git a/.claude/hooks/setup-security-tools/update.mts b/.claude/hooks/fleet/setup-security-tools/update.mts similarity index 96% rename from .claude/hooks/setup-security-tools/update.mts rename to .claude/hooks/fleet/setup-security-tools/update.mts index 73cf671..3515cee 100644 --- a/.claude/hooks/setup-security-tools/update.mts +++ b/.claude/hooks/fleet/setup-security-tools/update.mts @@ -18,13 +18,11 @@ import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { safeDelete } from '@socketsecurity/lib-stable/fs' -import { - httpDownload, - httpRequest, -} from '@socketsecurity/lib-stable/http-request' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger' -import { spawn } from '@socketsecurity/lib-stable/spawn' +import { safeDelete } from '@socketsecurity/lib-stable/fs/safe' +import { httpDownload } from '@socketsecurity/lib-stable/http-request/download' +import { httpRequest } from '@socketsecurity/lib-stable/http-request/request' +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' const logger = getDefaultLogger() @@ -126,7 +124,7 @@ export async function ghApiLatestRelease(repo: string): Promise { { stdio: 'pipe' }, ) const stdout = - typeof result.stdout === 'string' ? result.stdout : result.stdout.toString() + typeof result.stdout === 'string' ? result.stdout : String(result.stdout) return JSON.parse(stdout) as GhRelease } @@ -172,12 +170,12 @@ export async function writeConfig(config: Config): Promise { export async function computeSha256(filePath: string): Promise { const content = await fs.readFile(filePath) - return createHash('sha256').update(content).digest('hex') + return crypto.createHash('sha256').update(content).digest('hex') } export async function downloadAndHash(url: string): Promise { const tmpFile = path.join( - tmpdir(), + os.tmpdir(), `security-tools-update-${Date.now()}-${Math.random().toString(36).slice(2)}`, ) try { @@ -491,7 +489,7 @@ export async function updateSfw(config: Config): Promise { // ── Main ── async function main(): Promise { - logger.log('Checking for security tool updates...') + logger.log('Checking for security tool updates…') logger.log('') const config = readConfig() diff --git a/.claude/hooks/setup-signing/README.md b/.claude/hooks/fleet/setup-signing/README.md similarity index 86% rename from .claude/hooks/setup-signing/README.md rename to .claude/hooks/fleet/setup-signing/README.md index 7645fd1..a8adf95 100644 --- a/.claude/hooks/setup-signing/README.md +++ b/.claude/hooks/fleet/setup-signing/README.md @@ -8,9 +8,9 @@ one-time setup mechanical. ## Usage ```sh -node .claude/hooks/setup-signing/install.mts # detect + configure -node .claude/hooks/setup-signing/install.mts --check # report status; exit 0 if configured, 1 if not -node .claude/hooks/setup-signing/install.mts --force # overwrite existing config +node .claude/hooks/fleet/setup-signing/install.mts # detect + configure +node .claude/hooks/fleet/setup-signing/install.mts --check # report status; exit 0 if configured, 1 if not +node .claude/hooks/fleet/setup-signing/install.mts --force # overwrite existing config ``` ## Detection order diff --git a/.claude/hooks/setup-signing/install.mts b/.claude/hooks/fleet/setup-signing/install.mts similarity index 96% rename from .claude/hooks/setup-signing/install.mts rename to .claude/hooks/fleet/setup-signing/install.mts index 1ac7f83..fca4a16 100644 --- a/.claude/hooks/setup-signing/install.mts +++ b/.claude/hooks/fleet/setup-signing/install.mts @@ -7,10 +7,10 @@ * gpg.format` (ssh|openpgp). Paired with the pre-commit signing-config gate * and the pre-push signed-commits enforcement. Without signing set up, those * hooks block commits / pushes; this helper makes the one-time setup - * mechanical. Usage: node .claude/hooks/setup-signing/install.mts node - * .claude/hooks/setup-signing/install.mts --check # report only node - * .claude/hooks/setup-signing/install.mts --force # overwrite existing config - * Auto-detection order (first hit wins): + * mechanical. Usage: node .claude/hooks/fleet/setup-signing/install.mts node + * .claude/hooks/fleet/setup-signing/install.mts --check # report only node + * .claude/hooks/fleet/setup-signing/install.mts --force # overwrite existing + * config Auto-detection order (first hit wins): * * 1. 1Password SSH agent (SOCK at ~/Library/Group Containers/.../agent.sock). If * present + has keys, recommend SSH signing routed through 1Password. diff --git a/.claude/hooks/setup-signing/package.json b/.claude/hooks/fleet/setup-signing/package.json similarity index 100% rename from .claude/hooks/setup-signing/package.json rename to .claude/hooks/fleet/setup-signing/package.json diff --git a/.claude/hooks/variant-analysis-reminder/tsconfig.json b/.claude/hooks/fleet/setup-signing/tsconfig.json similarity index 100% rename from .claude/hooks/variant-analysis-reminder/tsconfig.json rename to .claude/hooks/fleet/setup-signing/tsconfig.json diff --git a/.claude/hooks/soak-exclude-date-annotation-guard/README.md b/.claude/hooks/fleet/soak-exclude-date-annotation-guard/README.md similarity index 96% rename from .claude/hooks/soak-exclude-date-annotation-guard/README.md rename to .claude/hooks/fleet/soak-exclude-date-annotation-guard/README.md index 71afb43..b6e5bc5 100644 --- a/.claude/hooks/soak-exclude-date-annotation-guard/README.md +++ b/.claude/hooks/fleet/soak-exclude-date-annotation-guard/README.md @@ -76,7 +76,7 @@ In `.claude/settings.json`: "hooks": [ { "type": "command", - "command": "node .claude/hooks/soak-exclude-date-annotation-guard/index.mts" + "command": "node .claude/hooks/fleet/soak-exclude-date-annotation-guard/index.mts" } ] } diff --git a/.claude/hooks/soak-exclude-date-annotation-guard/index.mts b/.claude/hooks/fleet/soak-exclude-date-annotation-guard/index.mts similarity index 100% rename from .claude/hooks/soak-exclude-date-annotation-guard/index.mts rename to .claude/hooks/fleet/soak-exclude-date-annotation-guard/index.mts diff --git a/.claude/hooks/soak-exclude-date-annotation-guard/package.json b/.claude/hooks/fleet/soak-exclude-date-annotation-guard/package.json similarity index 100% rename from .claude/hooks/soak-exclude-date-annotation-guard/package.json rename to .claude/hooks/fleet/soak-exclude-date-annotation-guard/package.json diff --git a/.claude/hooks/soak-exclude-date-annotation-guard/test/index.test.mts b/.claude/hooks/fleet/soak-exclude-date-annotation-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/soak-exclude-date-annotation-guard/test/index.test.mts rename to .claude/hooks/fleet/soak-exclude-date-annotation-guard/test/index.test.mts diff --git a/.claude/hooks/verify-rendered-output-before-commit-reminder/tsconfig.json b/.claude/hooks/fleet/soak-exclude-date-annotation-guard/tsconfig.json similarity index 100% rename from .claude/hooks/verify-rendered-output-before-commit-reminder/tsconfig.json rename to .claude/hooks/fleet/soak-exclude-date-annotation-guard/tsconfig.json diff --git a/.claude/hooks/socket-token-minifier-start/README.md b/.claude/hooks/fleet/socket-token-minifier-start/README.md similarity index 97% rename from .claude/hooks/socket-token-minifier-start/README.md rename to .claude/hooks/fleet/socket-token-minifier-start/README.md index 91e1767..449ad2a 100644 --- a/.claude/hooks/socket-token-minifier-start/README.md +++ b/.claude/hooks/fleet/socket-token-minifier-start/README.md @@ -46,7 +46,7 @@ Inserted under `hooks.SessionStart`: "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/socket-token-minifier-start/index.mts", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/socket-token-minifier-start/index.mts", "timeout": 5 } ] diff --git a/.claude/hooks/socket-token-minifier-start/index.mts b/.claude/hooks/fleet/socket-token-minifier-start/index.mts similarity index 100% rename from .claude/hooks/socket-token-minifier-start/index.mts rename to .claude/hooks/fleet/socket-token-minifier-start/index.mts diff --git a/.claude/hooks/socket-token-minifier-start/package.json b/.claude/hooks/fleet/socket-token-minifier-start/package.json similarity index 100% rename from .claude/hooks/socket-token-minifier-start/package.json rename to .claude/hooks/fleet/socket-token-minifier-start/package.json diff --git a/.claude/hooks/fleet/socket-token-minifier-start/test/index.test.mts b/.claude/hooks/fleet/socket-token-minifier-start/test/index.test.mts new file mode 100644 index 0000000..da7f32a --- /dev/null +++ b/.claude/hooks/fleet/socket-token-minifier-start/test/index.test.mts @@ -0,0 +1,32 @@ +/** + * @file Smoke test for socket-token-minifier-start. SessionStart hook that + * auto-starts the socket-token-minifier proxy on `localhost:7779` and exports + * `ANTHROPIC_BASE_URL` only after a health probe succeeds. Fail-closed: + * missing proxy means the session uses api.anthropic.com directly, never + * silently routes through a broken intermediary. Smoke contract: hook loads + + * dispatches without throwing; empty payload → exit 0. + */ + +import { spawn } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import test from 'node:test' +import assert from 'node:assert/strict' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const HOOK = path.join(here, '..', 'index.mts') + +async function runHook(payload: unknown): Promise<{ code: number }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) + child.on('error', reject) + child.on('close', code => resolve({ code: code ?? 1 })) + child.stdin.end(JSON.stringify(payload)) + }) +} + +test('empty payload exits 0', async () => { + const result = await runHook({}) + assert.equal(result.code, 0) +}) diff --git a/.claude/hooks/version-bump-order-guard/tsconfig.json b/.claude/hooks/fleet/socket-token-minifier-start/tsconfig.json similarity index 100% rename from .claude/hooks/version-bump-order-guard/tsconfig.json rename to .claude/hooks/fleet/socket-token-minifier-start/tsconfig.json diff --git a/.claude/hooks/squash-history-reminder/README.md b/.claude/hooks/fleet/squash-history-reminder/README.md similarity index 92% rename from .claude/hooks/squash-history-reminder/README.md rename to .claude/hooks/fleet/squash-history-reminder/README.md index 22d6140..f8282f8 100644 --- a/.claude/hooks/squash-history-reminder/README.md +++ b/.claude/hooks/fleet/squash-history-reminder/README.md @@ -33,4 +33,4 @@ The hook fails open on its own bugs (the catch in `main()`). A buggy hook can ne - `.claude/skills/squashing-history/SKILL.md` — the canonical squash-history skill (does the actual work). - `.claude/skills/cascading-fleet/lib/fleet-repos.json` — the roster + opt-in declarations. -- `.claude/hooks/default-branch-guard/` — sibling hook that enforces `main → master` fallback wherever the default branch is hard-coded. +- `.claude/hooks/fleet/default-branch-guard/` — sibling hook that enforces `main → master` fallback wherever the default branch is hard-coded. diff --git a/.claude/hooks/squash-history-reminder/index.mts b/.claude/hooks/fleet/squash-history-reminder/index.mts similarity index 100% rename from .claude/hooks/squash-history-reminder/index.mts rename to .claude/hooks/fleet/squash-history-reminder/index.mts diff --git a/.claude/hooks/squash-history-reminder/package.json b/.claude/hooks/fleet/squash-history-reminder/package.json similarity index 100% rename from .claude/hooks/squash-history-reminder/package.json rename to .claude/hooks/fleet/squash-history-reminder/package.json diff --git a/.claude/hooks/squash-history-reminder/test/index.test.mts b/.claude/hooks/fleet/squash-history-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/squash-history-reminder/test/index.test.mts rename to .claude/hooks/fleet/squash-history-reminder/test/index.test.mts diff --git a/.claude/hooks/vitest-include-vs-node-test-guard/tsconfig.json b/.claude/hooks/fleet/squash-history-reminder/tsconfig.json similarity index 100% rename from .claude/hooks/vitest-include-vs-node-test-guard/tsconfig.json rename to .claude/hooks/fleet/squash-history-reminder/tsconfig.json diff --git a/.claude/hooks/stale-process-sweeper/README.md b/.claude/hooks/fleet/stale-process-sweeper/README.md similarity index 97% rename from .claude/hooks/stale-process-sweeper/README.md rename to .claude/hooks/fleet/stale-process-sweeper/README.md index 875bb76..c419051 100644 --- a/.claude/hooks/stale-process-sweeper/README.md +++ b/.claude/hooks/fleet/stale-process-sweeper/README.md @@ -57,7 +57,7 @@ In `.claude/settings.json`: "hooks": [ { "type": "command", - "command": "node .claude/hooks/stale-process-sweeper/index.mts" + "command": "node .claude/hooks/fleet/stale-process-sweeper/index.mts" } ] } diff --git a/.claude/hooks/stale-process-sweeper/index.mts b/.claude/hooks/fleet/stale-process-sweeper/index.mts similarity index 100% rename from .claude/hooks/stale-process-sweeper/index.mts rename to .claude/hooks/fleet/stale-process-sweeper/index.mts diff --git a/.claude/hooks/stale-process-sweeper/package.json b/.claude/hooks/fleet/stale-process-sweeper/package.json similarity index 100% rename from .claude/hooks/stale-process-sweeper/package.json rename to .claude/hooks/fleet/stale-process-sweeper/package.json diff --git a/.claude/hooks/stale-process-sweeper/test/stale-process-sweeper.test.mts b/.claude/hooks/fleet/stale-process-sweeper/test/stale-process-sweeper.test.mts similarity index 100% rename from .claude/hooks/stale-process-sweeper/test/stale-process-sweeper.test.mts rename to .claude/hooks/fleet/stale-process-sweeper/test/stale-process-sweeper.test.mts diff --git a/.claude/hooks/workflow-uses-comment-guard/tsconfig.json b/.claude/hooks/fleet/stale-process-sweeper/tsconfig.json similarity index 100% rename from .claude/hooks/workflow-uses-comment-guard/tsconfig.json rename to .claude/hooks/fleet/stale-process-sweeper/tsconfig.json diff --git a/.claude/hooks/sweep-ds-store/README.md b/.claude/hooks/fleet/sweep-ds-store/README.md similarity index 100% rename from .claude/hooks/sweep-ds-store/README.md rename to .claude/hooks/fleet/sweep-ds-store/README.md diff --git a/.claude/hooks/sweep-ds-store/index.mts b/.claude/hooks/fleet/sweep-ds-store/index.mts similarity index 100% rename from .claude/hooks/sweep-ds-store/index.mts rename to .claude/hooks/fleet/sweep-ds-store/index.mts diff --git a/.claude/hooks/sweep-ds-store/package.json b/.claude/hooks/fleet/sweep-ds-store/package.json similarity index 100% rename from .claude/hooks/sweep-ds-store/package.json rename to .claude/hooks/fleet/sweep-ds-store/package.json diff --git a/.claude/hooks/sweep-ds-store/test/index.test.mts b/.claude/hooks/fleet/sweep-ds-store/test/index.test.mts similarity index 100% rename from .claude/hooks/sweep-ds-store/test/index.test.mts rename to .claude/hooks/fleet/sweep-ds-store/test/index.test.mts diff --git a/.claude/hooks/workflow-yaml-multiline-body-guard/tsconfig.json b/.claude/hooks/fleet/sweep-ds-store/tsconfig.json similarity index 100% rename from .claude/hooks/workflow-yaml-multiline-body-guard/tsconfig.json rename to .claude/hooks/fleet/sweep-ds-store/tsconfig.json diff --git a/.claude/hooks/token-guard/README.md b/.claude/hooks/fleet/token-guard/README.md similarity index 100% rename from .claude/hooks/token-guard/README.md rename to .claude/hooks/fleet/token-guard/README.md diff --git a/.claude/hooks/token-guard/index.mts b/.claude/hooks/fleet/token-guard/index.mts similarity index 100% rename from .claude/hooks/token-guard/index.mts rename to .claude/hooks/fleet/token-guard/index.mts diff --git a/.claude/hooks/token-guard/package.json b/.claude/hooks/fleet/token-guard/package.json similarity index 100% rename from .claude/hooks/token-guard/package.json rename to .claude/hooks/fleet/token-guard/package.json diff --git a/.claude/hooks/token-guard/test/token-guard.test.mts b/.claude/hooks/fleet/token-guard/test/token-guard.test.mts similarity index 100% rename from .claude/hooks/token-guard/test/token-guard.test.mts rename to .claude/hooks/fleet/token-guard/test/token-guard.test.mts diff --git a/.claude/hooks/fleet/token-guard/tsconfig.json b/.claude/hooks/fleet/token-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/token-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/trust-downgrade-guard/README.md b/.claude/hooks/fleet/trust-downgrade-guard/README.md similarity index 97% rename from .claude/hooks/trust-downgrade-guard/README.md rename to .claude/hooks/fleet/trust-downgrade-guard/README.md index 1f4f4b7..98269c9 100644 --- a/.claude/hooks/trust-downgrade-guard/README.md +++ b/.claude/hooks/fleet/trust-downgrade-guard/README.md @@ -28,7 +28,7 @@ unless the user typed `Allow trust-downgrade bypass` — and the bypass is counts prior downgrade actions in the assistant tool-use history (mirrors `release-workflow-guard`'s per-dispatch model) and requires an unconsumed phrase occurrence. A persisted bypass — an env var, or a phrase that opens the door for -every future downgrade — is *itself* a trust downgrade, so it's disallowed by +every future downgrade — is _itself_ a trust downgrade, so it's disallowed by design. Each downgrade needs its own freshly-typed phrase. ## The right fix instead of a downgrade diff --git a/.claude/hooks/trust-downgrade-guard/index.mts b/.claude/hooks/fleet/trust-downgrade-guard/index.mts similarity index 100% rename from .claude/hooks/trust-downgrade-guard/index.mts rename to .claude/hooks/fleet/trust-downgrade-guard/index.mts diff --git a/.claude/hooks/trust-downgrade-guard/package.json b/.claude/hooks/fleet/trust-downgrade-guard/package.json similarity index 100% rename from .claude/hooks/trust-downgrade-guard/package.json rename to .claude/hooks/fleet/trust-downgrade-guard/package.json diff --git a/.claude/hooks/trust-downgrade-guard/test/index.test.mts b/.claude/hooks/fleet/trust-downgrade-guard/test/index.test.mts similarity index 96% rename from .claude/hooks/trust-downgrade-guard/test/index.test.mts rename to .claude/hooks/fleet/trust-downgrade-guard/test/index.test.mts index 37680e2..cf2c791 100644 --- a/.claude/hooks/trust-downgrade-guard/test/index.test.mts +++ b/.claude/hooks/fleet/trust-downgrade-guard/test/index.test.mts @@ -1,9 +1,8 @@ /** - * @file Unit tests for trust-downgrade-guard hook. - * - * Spawns the hook as a child process with synthesized PreToolUse payloads. - * Covers Bash + Edit/Write downgrade detection, single-use bypass - * consumption, the disabled env var, and fail-open. + * @file Unit tests for trust-downgrade-guard hook. Spawns the hook as a child + * process with synthesized PreToolUse payloads. Covers Bash + Edit/Write + * downgrade detection, single-use bypass consumption, the disabled env var, + * and fail-open. */ import assert from 'node:assert/strict' diff --git a/.claude/hooks/fleet/trust-downgrade-guard/tsconfig.json b/.claude/hooks/fleet/trust-downgrade-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/trust-downgrade-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/uses-sha-verify-guard/README.md b/.claude/hooks/fleet/uses-sha-verify-guard/README.md new file mode 100644 index 0000000..cb3f5e4 --- /dev/null +++ b/.claude/hooks/fleet/uses-sha-verify-guard/README.md @@ -0,0 +1,33 @@ +# uses-sha-verify-guard + +PreToolUse hook that blocks Edit/Write tool calls introducing GitHub URL pins that aren't full 40-char SHAs reachable in their referenced repo. + +## What it enforces + +Every GitHub URL pin across the fleet needs a full 40-char commit SHA that resolves. Truncated SHAs (`3d33ecebbb` — 10 chars), version tags (`v1.2.3`), branch names (`main`), and SHAs that don't resolve via `gh api repos///commits/` are all blocked. + +Three surfaces: + +| Surface | Required pin shape | +| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `.github/workflows/*.yml` + `.github/actions/*/action.yml` | `uses: /(/)?@<40-hex>` | +| `.gitmodules` | BOTH `# - sha256:<64-hex>` comment AND `ref = <40-hex>` field per `[submodule]` block | +| `package.json` | `git+https://github.com//(.git)?#<40-hex>` for any GitHub-URL dep specifier | + +The `.gitmodules` content-hash (`sha256:`) and the `ref =` (commit SHA) are both required — the comment is the upstream-archive content-hash pin (drift-watch signal); the `ref` is what `git submodule update` checks out. + +## Why a hook + +Typing a truncated SHA into a `uses:` line is a silent fail. The action resolver may quietly succeed against a "close enough" ref, or fail at runtime in CI long after the bad edit landed. The hook catches it at edit time, before the bad pin reaches the commit. It's a companion to `gitmodules-comment-guard` (which enforces the `# -` shape but not SHA correctness). + +## Caching + +`gh api` results are cached at `~/.claude/uses-sha-verify-cache.json` keyed by `/@` with a 7-day TTL. A SHA reachable yesterday is reachable today; re-querying every edit is wasteful and rate-limit-prone. + +## Bypass + +Type the canonical phrase `Allow uses-sha-verify bypass` verbatim in a recent user turn. Per the fleet bypass-phrase convention. + +## Fail-open + +The hook fails open on its own bugs (exit 0 + stderr log) so a bad deploy can't brick the session. diff --git a/.claude/hooks/fleet/uses-sha-verify-guard/index.mts b/.claude/hooks/fleet/uses-sha-verify-guard/index.mts new file mode 100644 index 0000000..da37100 --- /dev/null +++ b/.claude/hooks/fleet/uses-sha-verify-guard/index.mts @@ -0,0 +1,428 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — uses-sha-verify-guard. +// +// Every GitHub URL pin in fleet repos needs a full 40-char SHA that +// resolves in the referenced repo. Blocks Edit/Write tool calls that +// introduce SHA pins that are: +// 1. Truncated (less than 40 hex chars for commit SHAs; less than +// 64 hex chars for content-hash sha256: pins). +// 2. Not actually hex (version tags like `v1.2.3`, branch names +// like `main`, partial SHAs). +// 3. Real-length but not reachable in the referenced repo (via +// `gh api repos///commits/`). +// 4. Missing from a `.gitmodules` submodule block (BOTH the +// `# - sha256:<64hex>` comment AND the +// `ref = <40hex>` field are required). +// +// Three surfaces: +// +// A. `.github/workflows/*.yml` + `.github/actions/*/action.yml`: +// Every `uses: /(?:/)?@` must have a full +// 40-char hex `` that resolves. +// +// B. `.gitmodules` at the repo root: +// Every `[submodule "..."]` block MUST carry BOTH a +// `# - sha256:<64hex>` header comment AND a +// `ref = <40hex>` field. +// +// C. `package.json`: +// Every `git+https://github.com//(?:\.git)?#` +// dep specifier in `dependencies`, `devDependencies`, +// `peerDependencies`, `optionalDependencies`, `overrides`, or +// `resolutions` must have a full 40-char hex ``. +// +// Companion to `gitmodules-comment-guard` (which enforces the +// `# -` shape but not SHA validity). Caching via +// `~/.claude/uses-sha-verify-cache.json` keyed by `@` +// with a 7-day TTL. +// +// Bypass: `Allow uses-sha-verify bypass`. +// +// Exits: +// 0 — allowed (not a tracked file, all SHAs verify, OR bypass). +// 2 — blocked (stderr explains which pin failed + how to fix). +// 0 (with stderr log) — fail-open on hook bugs. + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { spawnSync } from 'node:child_process' + +import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' + +const BYPASS_PHRASE = 'Allow uses-sha-verify bypass' + +const CACHE_FILE = path.join( + os.homedir(), + '.claude', + 'uses-sha-verify-cache.json', +) +const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7 days + +interface Hook { + tool_name?: string | undefined + tool_input?: + | { + file_path?: string | undefined + new_string?: string | undefined + content?: string | undefined + } + | undefined + transcript_path?: string | undefined +} + +interface CacheEntry { + reachable: boolean + checkedAt: number +} + +interface Cache { + entries: Record +} + +function loadCache(): Cache { + if (!existsSync(CACHE_FILE)) { + return { entries: {} } + } + try { + const parsed = JSON.parse(readFileSync(CACHE_FILE, 'utf8')) as Cache + if (!parsed || typeof parsed !== 'object' || !parsed.entries) { + return { entries: {} } + } + return parsed + } catch { + return { entries: {} } + } +} + +function saveCache(cache: Cache): void { + try { + mkdirSync(path.dirname(CACHE_FILE), { recursive: true }) + writeFileSync(CACHE_FILE, JSON.stringify(cache), 'utf8') + } catch { + // best-effort + } +} + +// Verify a commit SHA against `gh api repos///commits/`. +// Cached for 7 days; a previously-reachable SHA stays reachable. +export function verifyCommitSha( + ownerRepo: string, + sha: string, + cache: Cache, +): boolean { + const key = `${ownerRepo}@${sha}` + const entry = cache.entries[key] + if (entry && Date.now() - entry.checkedAt < CACHE_TTL_MS) { + return entry.reachable + } + const result = spawnSync( + 'gh', + ['api', `repos/${ownerRepo}/commits/${sha}`, '--silent'], + { stdio: 'ignore', timeout: 5000 }, + ) + const reachable = result.status === 0 + cache.entries[key] = { reachable, checkedAt: Date.now() } + return reachable +} + +// Match `uses: /(/)?@`. Tolerates leading +// whitespace, list dash (`- uses:`), and trailing comments. +const USES_RE = + /^\s*(?:-\s+)?uses:\s+([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_./-]+)?)@([^\s#]+)/ + +// Match `# - sha256:` header. +const GITMODULES_HEADER_RE = + /^#\s+[a-z0-9]+(?:[a-z0-9.-]*[a-z0-9])?-[^\s]+\s+sha256:([0-9a-f]+)/ + +// Match `ref = ` inside a submodule block. +const GITMODULES_REF_RE = /^\s*ref\s*=\s*([0-9a-f]+)\s*$/ + +// Match `[submodule "PATH"]`. +const SUBMODULE_OPEN_RE = /^\s*\[submodule\s+"([^"]+)"\s*\]\s*$/ + +// Match `git+https://github.com//(.git)?#` in JSON. +// Captures owner/repo and ref. Tolerates quoting around the URL value. +const PACKAGE_JSON_GITHUB_RE = + /git\+https?:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?#([^"]+)/g + +interface UsesIssue { + line: number + raw: string + problem: string +} + +export function findUsesIssues(content: string, cache: Cache): UsesIssue[] { + const issues: UsesIssue[] = [] + const lines = content.split('\n') + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]! + const m = USES_RE.exec(line) + if (!m) { + continue + } + const ownerRepoPath = m[1]! + const ref = m[2]! + const ownerRepo = ownerRepoPath.split('/').slice(0, 2).join('/') + if (!/^[0-9a-f]{40}$/i.test(ref)) { + issues.push({ + line: i + 1, + raw: line.trim(), + problem: /^[0-9a-f]+$/i.test(ref) + ? `truncated SHA (${ref.length} hex chars, need exactly 40)` + : `not a SHA pin (got "${ref}"; fleet requires full 40-char hex)`, + }) + continue + } + if (!verifyCommitSha(ownerRepo, ref, cache)) { + issues.push({ + line: i + 1, + raw: line.trim(), + problem: `SHA ${ref.slice(0, 10)}… not reachable in ${ownerRepo} (gh api 404). Either the SHA was mistyped or the repo is private and gh isn't authed for it.`, + }) + } + } + return issues +} + +interface SubmoduleIssue { + submodule: string + line: number + problem: string +} + +export function findGitmodulesIssues(content: string): SubmoduleIssue[] { + const issues: SubmoduleIssue[] = [] + const lines = content.split('\n') + + interface Block { + name: string + startLine: number + headerCommentSha: string | undefined + refSha: string | undefined + } + const blocks: Block[] = [] + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]! + const open = SUBMODULE_OPEN_RE.exec(line) + if (!open) { + continue + } + const name = open[1]! + let headerSha: string | undefined + for (let j = i - 1; j >= 0; j -= 1) { + const prev = lines[j]! + if (prev.trim() === '' || SUBMODULE_OPEN_RE.test(prev)) { + break + } + const headerMatch = GITMODULES_HEADER_RE.exec(prev) + if (headerMatch) { + headerSha = headerMatch[1] + break + } + } + let refSha: string | undefined + for (let j = i + 1; j < lines.length; j += 1) { + const next = lines[j]! + if (/^\s*\[/.test(next)) { + break + } + const refMatch = GITMODULES_REF_RE.exec(next) + if (refMatch) { + refSha = refMatch[1] + break + } + } + blocks.push({ name, startLine: i + 1, headerCommentSha: headerSha, refSha }) + } + + for (const block of blocks) { + if (!block.headerCommentSha) { + issues.push({ + submodule: block.name, + line: block.startLine, + problem: + 'missing `# - sha256:<64hex>` comment above the [submodule] block (content-hash pin required)', + }) + } else if (!/^[0-9a-f]{64}$/.test(block.headerCommentSha)) { + issues.push({ + submodule: block.name, + line: block.startLine, + problem: `header comment sha256 must be exactly 64 hex chars; got ${block.headerCommentSha.length}`, + }) + } + if (!block.refSha) { + issues.push({ + submodule: block.name, + line: block.startLine, + problem: + 'missing `ref = <40hex>` field inside the [submodule] block (commit-SHA pin required)', + }) + } else if (!/^[0-9a-f]{40}$/.test(block.refSha)) { + issues.push({ + submodule: block.name, + line: block.startLine, + problem: `ref must be exactly 40 hex chars; got ${block.refSha.length}`, + }) + } + } + return issues +} + +interface PackageJsonIssue { + ownerRepo: string + ref: string + problem: string +} + +export function findPackageJsonIssues( + content: string, + cache: Cache, +): PackageJsonIssue[] { + const issues: PackageJsonIssue[] = [] + PACKAGE_JSON_GITHUB_RE.lastIndex = 0 + let match: RegExpExecArray | null = PACKAGE_JSON_GITHUB_RE.exec(content) + while (match) { + const ownerRepo = match[1]! + const ref = match[2]! + if (!/^[0-9a-f]{40}$/i.test(ref)) { + issues.push({ + ownerRepo, + ref, + problem: /^[0-9a-f]+$/i.test(ref) + ? `truncated SHA (${ref.length} hex chars, need exactly 40)` + : `not a SHA pin (got "${ref}"; fleet requires full 40-char hex)`, + }) + } else if (!verifyCommitSha(ownerRepo, ref, cache)) { + issues.push({ + ownerRepo, + ref, + problem: `SHA ${ref.slice(0, 10)}… not reachable in ${ownerRepo} (gh api 404).`, + }) + } + match = PACKAGE_JSON_GITHUB_RE.exec(content) + } + return issues +} + +function readBodyFromPayload(payload: Hook): string { + const ti = payload.tool_input + if (!ti) { + return '' + } + if (typeof ti.new_string === 'string') { + return ti.new_string + } + if (typeof ti.content === 'string') { + return ti.content + } + return '' +} + +function isWorkflowOrActionPath(filePath: string): boolean { + return ( + /\.github\/workflows\/[^/]+\.ya?ml$/.test(filePath) || + /\.github\/actions\/[^/]+\/action\.ya?ml$/.test(filePath) + ) +} + +function isGitmodulesPath(filePath: string): boolean { + return filePath.endsWith('/.gitmodules') || filePath === '.gitmodules' +} + +function isPackageJsonPath(filePath: string): boolean { + // Match repo-root package.json AND nested workspace package.json files. + // Excludes node_modules paths. + if (filePath.includes('/node_modules/')) { + return false + } + return filePath.endsWith('/package.json') || filePath === 'package.json' +} + +async function main(): Promise { + const raw = await readStdin() + let payload: Hook + try { + payload = raw ? JSON.parse(raw) : {} + } catch { + process.exit(0) + } + const toolName = payload.tool_name + if (toolName !== 'Edit' && toolName !== 'Write' && toolName !== 'MultiEdit') { + process.exit(0) + } + const filePath = payload.tool_input?.file_path ?? '' + if (!filePath) { + process.exit(0) + } + const isUses = isWorkflowOrActionPath(filePath) + const isGitmodules = isGitmodulesPath(filePath) + const isPackageJson = isPackageJsonPath(filePath) + if (!isUses && !isGitmodules && !isPackageJson) { + process.exit(0) + } + + const body = readBodyFromPayload(payload) + if (!body) { + process.exit(0) + } + + const cache = loadCache() + const usesIssues = isUses ? findUsesIssues(body, cache) : [] + const gitmodulesIssues = isGitmodules ? findGitmodulesIssues(body) : [] + const packageJsonIssues = isPackageJson + ? findPackageJsonIssues(body, cache) + : [] + saveCache(cache) + + if ( + usesIssues.length === 0 && + gitmodulesIssues.length === 0 && + packageJsonIssues.length === 0 + ) { + process.exit(0) + } + + if ( + payload.transcript_path && + bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE) + ) { + process.exit(0) + } + + const out: string[] = [ + 'uses-sha-verify-guard: SHA pin verification failed', + '', + ] + for (const issue of usesIssues) { + out.push(` ${filePath}:${issue.line}`) + out.push(` ${issue.raw}`) + out.push(` ↳ ${issue.problem}`) + out.push('') + } + for (const issue of gitmodulesIssues) { + out.push(` ${filePath}:${issue.line} [submodule "${issue.submodule}"]`) + out.push(` ↳ ${issue.problem}`) + out.push('') + } + for (const issue of packageJsonIssues) { + out.push( + ` ${filePath}: git+https://github.com/${issue.ownerRepo}#${issue.ref}`, + ) + out.push(` ↳ ${issue.problem}`) + out.push('') + } + out.push('Fix the pin(s) above, or bypass with the canonical phrase:') + out.push(` ${BYPASS_PHRASE}`) + process.stderr.write(`${out.join('\n')}\n`) + process.exit(2) +} + +main().catch(err => { + // Fail-open on hook bugs. + process.stderr.write( + `uses-sha-verify-guard: hook crashed, failing open: ${err instanceof Error ? err.message : String(err)}\n`, + ) + process.exit(0) +}) diff --git a/.claude/hooks/fleet/uses-sha-verify-guard/package.json b/.claude/hooks/fleet/uses-sha-verify-guard/package.json new file mode 100644 index 0000000..6a5801f --- /dev/null +++ b/.claude/hooks/fleet/uses-sha-verify-guard/package.json @@ -0,0 +1,12 @@ +{ + "name": "hook-uses-sha-verify-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + } +} diff --git a/.claude/hooks/fleet/uses-sha-verify-guard/test/index.test.mts b/.claude/hooks/fleet/uses-sha-verify-guard/test/index.test.mts new file mode 100644 index 0000000..a4b3626 --- /dev/null +++ b/.claude/hooks/fleet/uses-sha-verify-guard/test/index.test.mts @@ -0,0 +1,165 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const HOOK_PATH = path.join(__dirname, '..', 'index.mts') + +function runHook(payload: object): { stderr: string; exitCode: number } { + const result = spawnSync('node', [HOOK_PATH], { + input: JSON.stringify(payload), + encoding: 'utf8', + }) + return { stderr: result.stderr ?? '', exitCode: result.status ?? -1 } +} + +// ------- workflow / action: uses: pin ------- + +test('BLOCKS workflow `uses:` with truncated SHA', () => { + const { stderr, exitCode } = runHook({ + tool_name: 'Write', + tool_input: { + file_path: '/repo/.github/workflows/ci.yml', + content: + 'jobs:\n job:\n steps:\n - uses: actions/checkout@abc123\n', + }, + }) + assert.equal(exitCode, 2) + assert.match(stderr, /uses-sha-verify-guard/) + assert.match(stderr, /truncated SHA/) +}) + +test('BLOCKS workflow `uses:` with version tag', () => { + const { stderr, exitCode } = runHook({ + tool_name: 'Write', + tool_input: { + file_path: '/repo/.github/workflows/ci.yml', + content: ' - uses: actions/checkout@v4\n', + }, + }) + assert.equal(exitCode, 2) + assert.match(stderr, /not a SHA pin/) +}) + +test('IGNORES file outside .github/workflows/ + .github/actions/', () => { + const { exitCode } = runHook({ + tool_name: 'Write', + tool_input: { + file_path: '/repo/README.md', + content: ' - uses: actions/checkout@v4\n', + }, + }) + assert.equal(exitCode, 0) +}) + +test('IGNORES non-Edit/Write tools', () => { + const { exitCode } = runHook({ + tool_name: 'Bash', + tool_input: { command: 'git status' }, + }) + assert.equal(exitCode, 0) +}) + +// ------- .gitmodules: BOTH header + ref required ------- + +test('BLOCKS .gitmodules submodule missing both header + ref', () => { + const { stderr, exitCode } = runHook({ + tool_name: 'Write', + tool_input: { + file_path: '/repo/.gitmodules', + content: + '[submodule "vendor/foo"]\n\tpath = vendor/foo\n\turl = https://github.com/owner/foo.git\n', + }, + }) + assert.equal(exitCode, 2) + assert.match(stderr, /missing.*sha256:<64hex>/) + assert.match(stderr, /missing `ref = <40hex>`/) +}) + +test('BLOCKS .gitmodules submodule with header but no ref', () => { + const { stderr, exitCode } = runHook({ + tool_name: 'Write', + tool_input: { + file_path: '/repo/.gitmodules', + content: + '# foo-1.2.3 sha256:' + + 'a'.repeat(64) + + '\n[submodule "vendor/foo"]\n\tpath = vendor/foo\n\turl = https://github.com/owner/foo.git\n', + }, + }) + assert.equal(exitCode, 2) + assert.match(stderr, /missing `ref = <40hex>`/) +}) + +test('BLOCKS .gitmodules header sha256 of wrong length', () => { + const { stderr, exitCode } = runHook({ + tool_name: 'Write', + tool_input: { + file_path: '/repo/.gitmodules', + content: + '# foo-1.2.3 sha256:' + + 'a'.repeat(32) + + '\n[submodule "vendor/foo"]\n\tpath = vendor/foo\n\tref = ' + + 'b'.repeat(40) + + '\n', + }, + }) + assert.equal(exitCode, 2) + assert.match(stderr, /sha256 must be exactly 64 hex chars/) +}) + +test('BLOCKS .gitmodules ref of wrong length', () => { + const { stderr, exitCode } = runHook({ + tool_name: 'Write', + tool_input: { + file_path: '/repo/.gitmodules', + content: + '# foo-1.2.3 sha256:' + + 'a'.repeat(64) + + '\n[submodule "vendor/foo"]\n\tpath = vendor/foo\n\tref = abc123\n', + }, + }) + assert.equal(exitCode, 2) + assert.match(stderr, /ref must be exactly 40 hex chars/) +}) + +// ------- package.json GitHub URL deps ------- + +test('BLOCKS package.json git+https://github.com URL with truncated SHA', () => { + const { stderr, exitCode } = runHook({ + tool_name: 'Write', + tool_input: { + file_path: '/repo/package.json', + content: + '{"dependencies": {"foo": "git+https://github.com/owner/foo#abc123"}}', + }, + }) + assert.equal(exitCode, 2) + assert.match(stderr, /truncated SHA/) +}) + +test('BLOCKS package.json git+https://github.com URL with version tag', () => { + const { stderr, exitCode } = runHook({ + tool_name: 'Write', + tool_input: { + file_path: '/repo/package.json', + content: + '{"dependencies": {"foo": "git+https://github.com/owner/foo.git#v1.2.3"}}', + }, + }) + assert.equal(exitCode, 2) + assert.match(stderr, /not a SHA pin/) +}) + +test('IGNORES node_modules/package.json', () => { + const { exitCode } = runHook({ + tool_name: 'Write', + tool_input: { + file_path: '/repo/node_modules/foo/package.json', + content: '{"dependencies": {"x": "git+https://github.com/owner/x#abc"}}', + }, + }) + assert.equal(exitCode, 0) +}) diff --git a/.claude/hooks/fleet/uses-sha-verify-guard/tsconfig.json b/.claude/hooks/fleet/uses-sha-verify-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/uses-sha-verify-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/variant-analysis-reminder/README.md b/.claude/hooks/fleet/variant-analysis-reminder/README.md similarity index 100% rename from .claude/hooks/variant-analysis-reminder/README.md rename to .claude/hooks/fleet/variant-analysis-reminder/README.md diff --git a/.claude/hooks/variant-analysis-reminder/index.mts b/.claude/hooks/fleet/variant-analysis-reminder/index.mts similarity index 100% rename from .claude/hooks/variant-analysis-reminder/index.mts rename to .claude/hooks/fleet/variant-analysis-reminder/index.mts diff --git a/.claude/hooks/variant-analysis-reminder/package.json b/.claude/hooks/fleet/variant-analysis-reminder/package.json similarity index 100% rename from .claude/hooks/variant-analysis-reminder/package.json rename to .claude/hooks/fleet/variant-analysis-reminder/package.json diff --git a/.claude/hooks/variant-analysis-reminder/test/index.test.mts b/.claude/hooks/fleet/variant-analysis-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/variant-analysis-reminder/test/index.test.mts rename to .claude/hooks/fleet/variant-analysis-reminder/test/index.test.mts diff --git a/.claude/hooks/fleet/variant-analysis-reminder/tsconfig.json b/.claude/hooks/fleet/variant-analysis-reminder/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/variant-analysis-reminder/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/verify-rendered-output-before-commit-reminder/README.md b/.claude/hooks/fleet/verify-rendered-output-before-commit-reminder/README.md similarity index 100% rename from .claude/hooks/verify-rendered-output-before-commit-reminder/README.md rename to .claude/hooks/fleet/verify-rendered-output-before-commit-reminder/README.md diff --git a/.claude/hooks/verify-rendered-output-before-commit-reminder/index.mts b/.claude/hooks/fleet/verify-rendered-output-before-commit-reminder/index.mts similarity index 100% rename from .claude/hooks/verify-rendered-output-before-commit-reminder/index.mts rename to .claude/hooks/fleet/verify-rendered-output-before-commit-reminder/index.mts diff --git a/.claude/hooks/verify-rendered-output-before-commit-reminder/package.json b/.claude/hooks/fleet/verify-rendered-output-before-commit-reminder/package.json similarity index 100% rename from .claude/hooks/verify-rendered-output-before-commit-reminder/package.json rename to .claude/hooks/fleet/verify-rendered-output-before-commit-reminder/package.json diff --git a/.claude/hooks/verify-rendered-output-before-commit-reminder/test/index.test.mts b/.claude/hooks/fleet/verify-rendered-output-before-commit-reminder/test/index.test.mts similarity index 100% rename from .claude/hooks/verify-rendered-output-before-commit-reminder/test/index.test.mts rename to .claude/hooks/fleet/verify-rendered-output-before-commit-reminder/test/index.test.mts diff --git a/.claude/hooks/fleet/verify-rendered-output-before-commit-reminder/tsconfig.json b/.claude/hooks/fleet/verify-rendered-output-before-commit-reminder/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/verify-rendered-output-before-commit-reminder/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/version-bump-order-guard/README.md b/.claude/hooks/fleet/version-bump-order-guard/README.md similarity index 100% rename from .claude/hooks/version-bump-order-guard/README.md rename to .claude/hooks/fleet/version-bump-order-guard/README.md diff --git a/.claude/hooks/version-bump-order-guard/index.mts b/.claude/hooks/fleet/version-bump-order-guard/index.mts similarity index 100% rename from .claude/hooks/version-bump-order-guard/index.mts rename to .claude/hooks/fleet/version-bump-order-guard/index.mts diff --git a/.claude/hooks/version-bump-order-guard/package.json b/.claude/hooks/fleet/version-bump-order-guard/package.json similarity index 100% rename from .claude/hooks/version-bump-order-guard/package.json rename to .claude/hooks/fleet/version-bump-order-guard/package.json diff --git a/.claude/hooks/version-bump-order-guard/test/index.test.mts b/.claude/hooks/fleet/version-bump-order-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/version-bump-order-guard/test/index.test.mts rename to .claude/hooks/fleet/version-bump-order-guard/test/index.test.mts diff --git a/.claude/hooks/fleet/version-bump-order-guard/tsconfig.json b/.claude/hooks/fleet/version-bump-order-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/version-bump-order-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/vitest-include-vs-node-test-guard/README.md b/.claude/hooks/fleet/vitest-include-vs-node-test-guard/README.md similarity index 100% rename from .claude/hooks/vitest-include-vs-node-test-guard/README.md rename to .claude/hooks/fleet/vitest-include-vs-node-test-guard/README.md diff --git a/.claude/hooks/vitest-include-vs-node-test-guard/index.mts b/.claude/hooks/fleet/vitest-include-vs-node-test-guard/index.mts similarity index 100% rename from .claude/hooks/vitest-include-vs-node-test-guard/index.mts rename to .claude/hooks/fleet/vitest-include-vs-node-test-guard/index.mts diff --git a/.claude/hooks/vitest-include-vs-node-test-guard/package.json b/.claude/hooks/fleet/vitest-include-vs-node-test-guard/package.json similarity index 100% rename from .claude/hooks/vitest-include-vs-node-test-guard/package.json rename to .claude/hooks/fleet/vitest-include-vs-node-test-guard/package.json diff --git a/.claude/hooks/vitest-include-vs-node-test-guard/test/index.test.mts b/.claude/hooks/fleet/vitest-include-vs-node-test-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/vitest-include-vs-node-test-guard/test/index.test.mts rename to .claude/hooks/fleet/vitest-include-vs-node-test-guard/test/index.test.mts diff --git a/.claude/hooks/fleet/vitest-include-vs-node-test-guard/tsconfig.json b/.claude/hooks/fleet/vitest-include-vs-node-test-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/vitest-include-vs-node-test-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/workflow-uses-comment-guard/README.md b/.claude/hooks/fleet/workflow-uses-comment-guard/README.md similarity index 96% rename from .claude/hooks/workflow-uses-comment-guard/README.md rename to .claude/hooks/fleet/workflow-uses-comment-guard/README.md index ea2e247..07c3a5f 100644 --- a/.claude/hooks/workflow-uses-comment-guard/README.md +++ b/.claude/hooks/fleet/workflow-uses-comment-guard/README.md @@ -66,7 +66,7 @@ In `.claude/settings.json`: "hooks": [ { "type": "command", - "command": "node .claude/hooks/workflow-uses-comment-guard/index.mts" + "command": "node .claude/hooks/fleet/workflow-uses-comment-guard/index.mts" } ] } diff --git a/.claude/hooks/workflow-uses-comment-guard/index.mts b/.claude/hooks/fleet/workflow-uses-comment-guard/index.mts similarity index 100% rename from .claude/hooks/workflow-uses-comment-guard/index.mts rename to .claude/hooks/fleet/workflow-uses-comment-guard/index.mts diff --git a/.claude/hooks/workflow-uses-comment-guard/package.json b/.claude/hooks/fleet/workflow-uses-comment-guard/package.json similarity index 100% rename from .claude/hooks/workflow-uses-comment-guard/package.json rename to .claude/hooks/fleet/workflow-uses-comment-guard/package.json diff --git a/.claude/hooks/workflow-uses-comment-guard/test/index.test.mts b/.claude/hooks/fleet/workflow-uses-comment-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/workflow-uses-comment-guard/test/index.test.mts rename to .claude/hooks/fleet/workflow-uses-comment-guard/test/index.test.mts diff --git a/.claude/hooks/fleet/workflow-uses-comment-guard/tsconfig.json b/.claude/hooks/fleet/workflow-uses-comment-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/workflow-uses-comment-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/workflow-yaml-multiline-body-guard/README.md b/.claude/hooks/fleet/workflow-yaml-multiline-body-guard/README.md similarity index 100% rename from .claude/hooks/workflow-yaml-multiline-body-guard/README.md rename to .claude/hooks/fleet/workflow-yaml-multiline-body-guard/README.md diff --git a/.claude/hooks/workflow-yaml-multiline-body-guard/index.mts b/.claude/hooks/fleet/workflow-yaml-multiline-body-guard/index.mts similarity index 100% rename from .claude/hooks/workflow-yaml-multiline-body-guard/index.mts rename to .claude/hooks/fleet/workflow-yaml-multiline-body-guard/index.mts diff --git a/.claude/hooks/workflow-yaml-multiline-body-guard/package.json b/.claude/hooks/fleet/workflow-yaml-multiline-body-guard/package.json similarity index 100% rename from .claude/hooks/workflow-yaml-multiline-body-guard/package.json rename to .claude/hooks/fleet/workflow-yaml-multiline-body-guard/package.json diff --git a/.claude/hooks/workflow-yaml-multiline-body-guard/test/index.test.mts b/.claude/hooks/fleet/workflow-yaml-multiline-body-guard/test/index.test.mts similarity index 100% rename from .claude/hooks/workflow-yaml-multiline-body-guard/test/index.test.mts rename to .claude/hooks/fleet/workflow-yaml-multiline-body-guard/test/index.test.mts diff --git a/.claude/hooks/fleet/workflow-yaml-multiline-body-guard/tsconfig.json b/.claude/hooks/fleet/workflow-yaml-multiline-body-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/fleet/workflow-yaml-multiline-body-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/settings.json b/.claude/settings.json index 6895c71..cfc9f7a 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,139 +6,147 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-new-deps/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/alpha-sort-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/claude-md-section-size-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/check-new-deps/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/claude-md-size-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/claude-md-section-size-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/cross-repo-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/claude-md-size-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-disable-lint-rule-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/cross-repo-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/gitmodules-comment-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-disable-lint-rule-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/lock-step-ref-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/gitmodules-comment-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/logger-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/lock-step-ref-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/markdown-filename-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/logger-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/minimum-release-age-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/markdown-filename-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-package-json-pnpm-overrides-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/minimum-release-age-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-fleet-fork-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-package-json-pnpm-overrides-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/new-hook-claude-md-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-fleet-fork-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-file-scope-oxlint-disable-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/new-hook-claude-md-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-meta-comments-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-file-scope-oxlint-disable-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-token-in-dotenv-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-meta-comments-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-underscore-identifier-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-token-in-dotenv-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/parallel-agent-edit-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-underscore-identifier-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/path-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/prefer-function-declaration-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/paths-mts-inherit-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/parallel-agent-edit-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/plan-location-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/path-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/plugin-patch-format-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/paths-mts-inherit-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pull-request-target-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/plan-location-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/readme-fleet-shape-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/plugin-patch-format-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/workflow-uses-comment-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/pull-request-target-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/marketplace-comment-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/readme-fleet-shape-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/vitest-include-vs-node-test-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/workflow-uses-comment-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/workflow-yaml-multiline-body-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/marketplace-comment-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/immutable-release-pattern-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/vitest-include-vs-node-test-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/inline-script-defer-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/workflow-yaml-multiline-body-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/consumer-grep-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/immutable-release-pattern-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/soak-exclude-date-annotation-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/inline-script-defer-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-structured-clone-prefer-json-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/consumer-grep-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/trust-downgrade-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/soak-exclude-date-annotation-guard/index.mts" + }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-structured-clone-prefer-json-guard/index.mts" + }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/trust-downgrade-guard/index.mts" } ] }, @@ -147,7 +155,7 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/ask-suppression-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/ask-suppression-reminder/index.mts" } ] }, @@ -156,7 +164,7 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/codex-no-write-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/codex-no-write-guard/index.mts" } ] }, @@ -165,103 +173,107 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/codex-no-write-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/avoid-cd-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/verify-rendered-output-before-commit-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/codex-no-write-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/commit-author-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/verify-rendered-output-before-commit-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/commit-message-format-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/commit-author-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/concurrent-cargo-build-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/commit-message-format-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/default-branch-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/concurrent-cargo-build-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/gh-token-hygiene-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/default-branch-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-blind-keychain-read-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/gh-token-hygiene-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-experimental-strip-types-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-blind-keychain-read-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-empty-commit-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-experimental-strip-types-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/node-modules-staging-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-empty-commit-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pr-vs-push-default-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/node-modules-staging-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-non-fleet-push-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/pr-vs-push-default-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-external-issue-ref-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-non-fleet-push-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-revert-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-external-issue-ref-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/overeager-staging-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-revert-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/parallel-agent-staging-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/overeager-staging-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/prefer-rebase-over-revert-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/parallel-agent-staging-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/private-name-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/prefer-rebase-over-revert-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/public-surface-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/private-name-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/release-workflow-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/public-surface-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/scan-label-in-commit-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/release-workflow-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/token-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/scan-label-in-commit-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/trust-downgrade-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/token-guard/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/version-bump-order-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/trust-downgrade-guard/index.mts" + }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/version-bump-order-guard/index.mts" } ] } @@ -271,8 +283,13 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/socket-token-minifier-start/index.mts", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/socket-token-minifier-start/index.mts", "timeout": 5 + }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/broken-hook-detector/index.mts", + "timeout": 8 } ] } @@ -283,7 +300,7 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/minify-mcp-output/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/minify-mcp-output/index.mts" } ] }, @@ -292,11 +309,11 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/actionlint-on-workflow-edit/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/actionlint-on-workflow-edit/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/extension-build-current-guard/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/extension-build-current-guard/index.mts" } ] }, @@ -305,7 +322,7 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/enterprise-push-property-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/enterprise-push-property-reminder/index.mts" } ] } @@ -315,95 +332,103 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auth-rotation-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/answer-passing-questions-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/comment-tone-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/answer-status-requests-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/commit-pr-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/auth-rotation-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/compound-lessons-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/comment-tone-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/dirty-worktree-on-stop-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/commit-pr-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/dont-blame-user-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/compound-lessons-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/dont-stop-mid-queue-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/dirty-worktree-on-stop-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/drift-check-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/dont-blame-user-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/error-message-quality-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/dont-stop-mid-queue-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/excuse-detector/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/drift-check-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/file-size-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/error-message-quality-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/follow-direct-imperative-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/excuse-detector/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/identifying-users-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/file-size-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/judgment-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/follow-direct-imperative-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/parallel-agent-on-stop-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/identifying-users-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/perfectionist-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/judgment-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/plan-review-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/parallel-agent-on-stop-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-orphaned-staging/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/perfectionist-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/setup-security-tools/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/plan-review-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/squash-history-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-orphaned-staging/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/stale-process-sweeper/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/setup-security-tools/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/sweep-ds-store/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/squash-history-reminder/index.mts" }, { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/variant-analysis-reminder/index.mts" + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/stale-process-sweeper/index.mts" + }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/sweep-ds-store/index.mts" + }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/variant-analysis-reminder/index.mts" } ] } @@ -413,11 +438,14 @@ "deny": [ "Bash(gh release create:*)", "Bash(gh release delete:*)", - "Bash(git push --force:*)", - "Bash(git push -f:*)", "Bash(npm publish:*)", "Bash(pnpm publish:*)", "Bash(yarn publish:*)" + ], + "ask": [ + "Bash(git push --force:*)", + "Bash(git push -f:*)", + "Bash(git push --force-with-lease:*)" ] } } diff --git a/.claude/skills/_shared/path-guard-rule.md b/.claude/skills/_shared/path-guard-rule.md index 2dae74a..77572b1 100644 --- a/.claude/skills/_shared/path-guard-rule.md +++ b/.claude/skills/_shared/path-guard-rule.md @@ -7,7 +7,7 @@ This file is the source of truth for the rule's wording. Three artifacts embed (or paraphrase) it: 1. CLAUDE.md — every Socket repo's instructions to Claude. - 2. .claude/hooks/path-guard/README.md — what the hook blocks. + 2. .claude/hooks/fleet/path-guard/README.md — what the hook blocks. 3. .claude/skills/guarding-paths/SKILL.md — what the skill enforces. If the wording changes here, re-run `node scripts/sync-scaffolding.mts @@ -32,7 +32,7 @@ Code execution takes priority over docs: violations in `.mts`/`.cts`, Makefiles, ### Three-level enforcement -- **Hook** — `.claude/hooks/path-guard/` blocks `Edit`/`Write` calls that would introduce a violation in a `.mts`/`.cts` file. Refusal at edit time stops new duplication from landing. +- **Hook** — `.claude/hooks/fleet/path-guard/` blocks `Edit`/`Write` calls that would introduce a violation in a `.mts`/`.cts` file. Refusal at edit time stops new duplication from landing. - **Gate** — `scripts/check-paths.mts` runs in `pnpm check`. Fails the build on any violation that isn't allowlisted. - **Skill** — `/guarding-paths` audits the repo and fixes findings; `/guarding-paths check` reports only; `/guarding-paths install` drops the gate + hook + rule into a fresh repo. diff --git a/.claude/skills/auditing-gha-settings/SKILL.md b/.claude/skills/fleet/auditing-gha-settings/SKILL.md similarity index 99% rename from .claude/skills/auditing-gha-settings/SKILL.md rename to .claude/skills/fleet/auditing-gha-settings/SKILL.md index ece1c21..b5faae7 100644 --- a/.claude/skills/auditing-gha-settings/SKILL.md +++ b/.claude/skills/fleet/auditing-gha-settings/SKILL.md @@ -3,6 +3,8 @@ name: auditing-gha-settings description: Audits a repo's GitHub Actions permissions + allowlist against the fleet baseline. Reports drift only. Fixes are manual in Settings → Actions because flipping these silently is unsafe. Use when a CI failure looks like "action X is not allowed to be used", when onboarding a new fleet repo, or as a periodic fleet-wide health check. user-invocable: true allowed-tools: Read, Grep, Glob, Bash(gh:*), Bash(node:*), Bash(jq:*) +model: claude-haiku-4-5 +context: fork --- # auditing-gha-settings diff --git a/.claude/skills/auditing-gha-settings/run.mts b/.claude/skills/fleet/auditing-gha-settings/run.mts similarity index 100% rename from .claude/skills/auditing-gha-settings/run.mts rename to .claude/skills/fleet/auditing-gha-settings/run.mts diff --git a/.claude/skills/cascading-fleet/SKILL.md b/.claude/skills/fleet/cascading-fleet/SKILL.md similarity index 85% rename from .claude/skills/cascading-fleet/SKILL.md rename to .claude/skills/fleet/cascading-fleet/SKILL.md index bfe6dfa..590e7fc 100644 --- a/.claude/skills/cascading-fleet/SKILL.md +++ b/.claude/skills/fleet/cascading-fleet/SKILL.md @@ -3,12 +3,16 @@ name: cascading-fleet description: Propagate a wheelhouse template change to every fleet repo (or a registry-pin chain to every dependent repo). Packages the canonical fleet-repo list, the FLEET_SYNC=1 sentinel pattern, the worktree-per-repo loop, push-direct + PR-fallback, and worktree-cleanup that survives mid-loop crashes. Use when a wheelhouse template SHA needs to land in every fleet repo, when a registry pin chain needs propagation, or when batching multiple template SHAs into one cascade wave. user-invocable: true allowed-tools: Bash(git fetch:*), Bash(git worktree:*), Bash(git branch:*), Bash(git status:*), Bash(git rev-list:*), Bash(git symbolic-ref:*), Bash(git show-ref:*), Bash(git push:*), Bash(git commit:*), Bash(git add:*), Bash(git log:*), Bash(node:*), Bash(gh pr create:*), Bash(gh repo view:*), Read, Bash(bash:*), Bash(chmod:*), Bash(cd:*), Bash(printf:*), Bash(echo:*), Bash(tee:*), Bash(tail:*), Bash(ls:*) +model: claude-haiku-4-5 +context: fork --- # cascading-fleet The fleet runs on `chore(wheelhouse): cascade template@` commits. Every wheelhouse template change has to land in every fleet repo to take effect. This skill packages the operation so it isn't recreated ad-hoc per session. +🚨 **This is mechanical work, not a thinking task.** Run the canonical operation, commit, push. Don't analyze each modified file in the cascade, don't design alternatives, don't write multi-paragraph rationale — the wheelhouse template is the source of truth and the sync runner decides what changes. If a repo's cascade refuses to apply (lockfile policy reject, soak window, broken hook from a stale install), bump the immediate blocker (soak-exclude entry, lockfile rebuild) or defer the repo and report it — don't reason through a multi-step manual reproduction of what the sync runner already does. Cheap/fast model settings are the right default; reserve heavier reasoning for genuine design work. + ## When to use - A wheelhouse `template/` SHA needs to propagate to every fleet repo. @@ -71,6 +75,6 @@ If the wheelhouse template change includes a `@socketsecurity/lib` catalog bump ## Reference -- FLEET_SYNC sentinel: `template/.claude/hooks/no-revert-guard/` + `template/.claude/hooks/overeager-staging-guard/`. +- FLEET_SYNC sentinel: `template/.claude/hooks/fleet/no-revert-guard/` + `template/.claude/hooks/fleet/overeager-staging-guard/`. - Wheelhouse sync-scaffolding: `socket-wheelhouse/scripts/sync-scaffolding/cli.mts`. - Fleet-repo manifest: `lib/fleet-repos.txt`. diff --git a/.claude/skills/cascading-fleet/lib/cascade-template.mts b/.claude/skills/fleet/cascading-fleet/lib/cascade-template.mts similarity index 100% rename from .claude/skills/cascading-fleet/lib/cascade-template.mts rename to .claude/skills/fleet/cascading-fleet/lib/cascade-template.mts diff --git a/.claude/skills/cascading-fleet/lib/cascade-template.sh b/.claude/skills/fleet/cascading-fleet/lib/cascade-template.sh similarity index 100% rename from .claude/skills/cascading-fleet/lib/cascade-template.sh rename to .claude/skills/fleet/cascading-fleet/lib/cascade-template.sh diff --git a/.claude/skills/cascading-fleet/lib/fleet-repos.json b/.claude/skills/fleet/cascading-fleet/lib/fleet-repos.json similarity index 100% rename from .claude/skills/cascading-fleet/lib/fleet-repos.json rename to .claude/skills/fleet/cascading-fleet/lib/fleet-repos.json diff --git a/.claude/skills/cascading-fleet/lib/fleet-repos.txt b/.claude/skills/fleet/cascading-fleet/lib/fleet-repos.txt similarity index 100% rename from .claude/skills/cascading-fleet/lib/fleet-repos.txt rename to .claude/skills/fleet/cascading-fleet/lib/fleet-repos.txt diff --git a/.claude/skills/cleaning-redundant-ci/SKILL.md b/.claude/skills/fleet/cleaning-redundant-ci/SKILL.md similarity index 99% rename from .claude/skills/cleaning-redundant-ci/SKILL.md rename to .claude/skills/fleet/cleaning-redundant-ci/SKILL.md index 1c92189..b27d895 100644 --- a/.claude/skills/cleaning-redundant-ci/SKILL.md +++ b/.claude/skills/fleet/cleaning-redundant-ci/SKILL.md @@ -3,6 +3,8 @@ name: cleaning-redundant-ci description: Sweeps a fleet repo (or every fleet repo) for redundant CI surface. Three classes: orphan workflow YAML files (lint.yml / check.yml / type.yml / test.yml that the unified ci.yml replaced), GitHub-Dependabot auto-fix PRs that the fleet handles via /updating-security, and stale workflow run history in the Actions sidebar. Deletes the YAML files, disables Dependabot automated-security-fixes via gh api, and reports anything that needs a manual UI toggle. Once-and-never-again sweep meant to leave a repo clean. user-invocable: true allowed-tools: Read, Edit, Write, Glob, Grep, Bash(gh:*), Bash(git:*), Bash(ls:*), Bash(rm:*), Bash(find:*), Bash(jq:*) +model: claude-haiku-4-5 +context: fork --- # cleaning-redundant-ci diff --git a/.claude/skills/driving-cursor-bugbot/SKILL.md b/.claude/skills/fleet/driving-cursor-bugbot/SKILL.md similarity index 100% rename from .claude/skills/driving-cursor-bugbot/SKILL.md rename to .claude/skills/fleet/driving-cursor-bugbot/SKILL.md diff --git a/.claude/skills/driving-cursor-bugbot/reference.md b/.claude/skills/fleet/driving-cursor-bugbot/reference.md similarity index 100% rename from .claude/skills/driving-cursor-bugbot/reference.md rename to .claude/skills/fleet/driving-cursor-bugbot/reference.md diff --git a/.claude/skills/greening-ci/SKILL.md b/.claude/skills/fleet/greening-ci/SKILL.md similarity index 100% rename from .claude/skills/greening-ci/SKILL.md rename to .claude/skills/fleet/greening-ci/SKILL.md diff --git a/.claude/skills/greening-ci/run.mts b/.claude/skills/fleet/greening-ci/run.mts similarity index 100% rename from .claude/skills/greening-ci/run.mts rename to .claude/skills/fleet/greening-ci/run.mts diff --git a/.claude/skills/guarding-paths/SKILL.md b/.claude/skills/fleet/guarding-paths/SKILL.md similarity index 94% rename from .claude/skills/guarding-paths/SKILL.md rename to .claude/skills/fleet/guarding-paths/SKILL.md index 4f3a428..be78db5 100644 --- a/.claude/skills/guarding-paths/SKILL.md +++ b/.claude/skills/fleet/guarding-paths/SKILL.md @@ -3,6 +3,8 @@ name: guarding-paths description: Audits and fixes path duplication in a Socket repo. Applies the strict "1 path, 1 reference" rule: every build/test/runtime/config path is constructed exactly once; everywhere else references the constructed value. Default mode finds and fixes; `check` mode reports only; `install` mode drops the gate + hook + rule into a fresh repo. Use when path drift surfaces from `pnpm check`, when a new sibling package needs path conventions, or when bootstrapping a fresh Socket repo. user-invocable: true allowed-tools: Task, Read, Edit, Write, Grep, Glob, AskUserQuestion, Bash(pnpm run check:*), Bash(node scripts/check-paths:*), Bash(rg:*), Bash(grep:*), Bash(find:*), Bash(git:*) +model: claude-haiku-4-5 +context: fork --- # guarding-paths @@ -23,10 +25,10 @@ allowed-tools: Task, Read, Edit, Write, Grep, Glob, AskUserQuestion, Bash(pnpm r The strategy lives in three artifacts that ship together: 1. **CLAUDE.md rule**: the mantra and detection rules in plain language. Every fleet repo's CLAUDE.md carries `## 1 path, 1 reference`. Synced from [`_shared/path-guard-rule.md`](../_shared/path-guard-rule.md). -2. **Hook**: `.claude/hooks/path-guard/index.mts` runs `PreToolUse` on `Edit` / `Write` of `.mts` / `.cts` files. Blocks new violations at edit time. +2. **Hook**: `.claude/hooks/fleet/path-guard/index.mts` runs `PreToolUse` on `Edit` / `Write` of `.mts` / `.cts` files. Blocks new violations at edit time. 3. **Gate**: `scripts/check-paths.mts` runs in `pnpm check` (and CI). Whole-repo scan. Fails the build on any unsanctioned violation. -The hook and gate share their stage / build-root / mode / sibling-package vocabulary via `.claude/hooks/path-guard/segments.mts`: a single canonical source. Adding a new stage segment or fleet package means editing one file; the two consumers can never drift on what counts as a build-output path. +The hook and gate share their stage / build-root / mode / sibling-package vocabulary via `.claude/hooks/fleet/path-guard/segments.mts`: a single canonical source. Adding a new stage segment or fleet package means editing one file; the two consumers can never drift on what counts as a build-output path. This skill is the **audit-and-fix workflow** that makes a repo conform initially and validates conformance over time. @@ -91,7 +93,10 @@ For Socket repos that don't yet have the gate: 5. Append the rule snippet from [`_shared/path-guard-rule.md`](../_shared/path-guard-rule.md) to the repo's `CLAUDE.md` if a `1 path, 1 reference` section is missing. 6. Add the hook entry to `.claude/settings.json` `PreToolUse` matcher `Edit|Write`: ```json - { "type": "command", "command": "node .claude/hooks/path-guard/index.mts" } + { + "type": "command", + "command": "node .claude/hooks/fleet/path-guard/index.mts" + } ``` 7. Run the gate against the repo. Triage findings as you would in audit-and-fix mode. diff --git a/.claude/skills/guarding-paths/reference.md b/.claude/skills/fleet/guarding-paths/reference.md similarity index 100% rename from .claude/skills/guarding-paths/reference.md rename to .claude/skills/fleet/guarding-paths/reference.md diff --git a/.claude/skills/guarding-paths/reference/check-paths.mts.tmpl b/.claude/skills/fleet/guarding-paths/reference/check-paths.mts.tmpl similarity index 100% rename from .claude/skills/guarding-paths/reference/check-paths.mts.tmpl rename to .claude/skills/fleet/guarding-paths/reference/check-paths.mts.tmpl diff --git a/.claude/skills/guarding-paths/reference/claude-md-rule.md b/.claude/skills/fleet/guarding-paths/reference/claude-md-rule.md similarity index 100% rename from .claude/skills/guarding-paths/reference/claude-md-rule.md rename to .claude/skills/fleet/guarding-paths/reference/claude-md-rule.md diff --git a/.claude/skills/guarding-paths/reference/paths-allowlist.yml.tmpl b/.claude/skills/fleet/guarding-paths/reference/paths-allowlist.yml.tmpl similarity index 100% rename from .claude/skills/guarding-paths/reference/paths-allowlist.yml.tmpl rename to .claude/skills/fleet/guarding-paths/reference/paths-allowlist.yml.tmpl diff --git a/.claude/skills/guarding-paths/templates/check-paths.mts.tmpl b/.claude/skills/fleet/guarding-paths/templates/check-paths.mts.tmpl similarity index 100% rename from .claude/skills/guarding-paths/templates/check-paths.mts.tmpl rename to .claude/skills/fleet/guarding-paths/templates/check-paths.mts.tmpl diff --git a/.claude/skills/guarding-paths/templates/paths-allowlist.yml.tmpl b/.claude/skills/fleet/guarding-paths/templates/paths-allowlist.yml.tmpl similarity index 100% rename from .claude/skills/guarding-paths/templates/paths-allowlist.yml.tmpl rename to .claude/skills/fleet/guarding-paths/templates/paths-allowlist.yml.tmpl diff --git a/.claude/skills/handing-off/SKILL.md b/.claude/skills/fleet/handing-off/SKILL.md similarity index 100% rename from .claude/skills/handing-off/SKILL.md rename to .claude/skills/fleet/handing-off/SKILL.md diff --git a/.claude/skills/locking-down-programmatic-claude/SKILL.md b/.claude/skills/fleet/locking-down-programmatic-claude/SKILL.md similarity index 100% rename from .claude/skills/locking-down-programmatic-claude/SKILL.md rename to .claude/skills/fleet/locking-down-programmatic-claude/SKILL.md diff --git a/.claude/skills/plug-leaking-promise-race/SKILL.md b/.claude/skills/fleet/plug-leaking-promise-race/SKILL.md similarity index 100% rename from .claude/skills/plug-leaking-promise-race/SKILL.md rename to .claude/skills/fleet/plug-leaking-promise-race/SKILL.md diff --git a/.claude/skills/prose/SKILL.md b/.claude/skills/fleet/prose/SKILL.md similarity index 100% rename from .claude/skills/prose/SKILL.md rename to .claude/skills/fleet/prose/SKILL.md diff --git a/.claude/skills/prose/references/examples.md b/.claude/skills/fleet/prose/references/examples.md similarity index 100% rename from .claude/skills/prose/references/examples.md rename to .claude/skills/fleet/prose/references/examples.md diff --git a/.claude/skills/prose/references/phrases.md b/.claude/skills/fleet/prose/references/phrases.md similarity index 100% rename from .claude/skills/prose/references/phrases.md rename to .claude/skills/fleet/prose/references/phrases.md diff --git a/.claude/skills/prose/references/structures.md b/.claude/skills/fleet/prose/references/structures.md similarity index 100% rename from .claude/skills/prose/references/structures.md rename to .claude/skills/fleet/prose/references/structures.md diff --git a/.claude/skills/refreshing-history/SKILL.md b/.claude/skills/fleet/refreshing-history/SKILL.md similarity index 99% rename from .claude/skills/refreshing-history/SKILL.md rename to .claude/skills/fleet/refreshing-history/SKILL.md index f1ea818..dd36ab9 100644 --- a/.claude/skills/refreshing-history/SKILL.md +++ b/.claude/skills/fleet/refreshing-history/SKILL.md @@ -3,6 +3,8 @@ name: refreshing-history description: Squashes the repo's default branch (main, falling back to master) to a single signed "Initial commit", refreshes deps + lockfile, runs format / fix / check / type passes, amends results, and force-pushes. Wraps the lower-level `squashing-history` skill with a dep-refresh + integrity check + verified-signature workflow. Use when cutting a fleet-wide history reset or preparing a clean baseline before a major release. user-invocable: true allowed-tools: AskUserQuestion, Bash(git:*), Bash(pnpm:*), Bash(diff:*), Bash(ls:*) +model: claude-haiku-4-5 +context: fork --- # refreshing-history diff --git a/.claude/skills/refreshing-history/run.mts b/.claude/skills/fleet/refreshing-history/run.mts similarity index 100% rename from .claude/skills/refreshing-history/run.mts rename to .claude/skills/fleet/refreshing-history/run.mts diff --git a/.claude/skills/reviewing-code/SKILL.md b/.claude/skills/fleet/reviewing-code/SKILL.md similarity index 99% rename from .claude/skills/reviewing-code/SKILL.md rename to .claude/skills/fleet/reviewing-code/SKILL.md index bf8ff87..3a23c35 100644 --- a/.claude/skills/reviewing-code/SKILL.md +++ b/.claude/skills/fleet/reviewing-code/SKILL.md @@ -3,6 +3,8 @@ name: reviewing-code description: Reviews the current branch against a base ref using multiple AI backends. Routes discovery, discovery-secondary, remediation, and verify passes through the available agents (codex, claude, opencode, kimi, …), gracefully skipping any backend that isn't installed. Writes a markdown findings report under docs/. Use when preparing or updating a PR, before merging a feature branch, or when wanting an independent second opinion from a different agent. user-invocable: true allowed-tools: Read, Grep, Glob, Bash(node:*), Bash(git:*), Bash(command -v:*) +model: claude-opus-4-8 +context: fork --- # reviewing-code diff --git a/.claude/skills/reviewing-code/run.mts b/.claude/skills/fleet/reviewing-code/run.mts similarity index 100% rename from .claude/skills/reviewing-code/run.mts rename to .claude/skills/fleet/reviewing-code/run.mts diff --git a/.claude/skills/running-test262/SKILL.md b/.claude/skills/fleet/running-test262/SKILL.md similarity index 99% rename from .claude/skills/running-test262/SKILL.md rename to .claude/skills/fleet/running-test262/SKILL.md index c2c9d45..f896de4 100644 --- a/.claude/skills/running-test262/SKILL.md +++ b/.claude/skills/fleet/running-test262/SKILL.md @@ -3,6 +3,8 @@ name: running-test262 description: Run the test262 conformance suite against fleet parsers / runtimes (ultrathink acorn variants, socket-btm temporal-infra, future ports) using each repo's canonical runner. Never write homebrew test262 runners. Every parser/runtime in the fleet ships a runner under `test/scripts/test262-*.mts` and an unsupported-features config. Use this skill when asked to run spec tests, check conformance, debug a failing test262 case, or compare a parser against a reference implementation. user-invocable: true allowed-tools: Bash(node:*), Bash(pnpm:*), Bash(ls:*), Bash(cat:*), Bash(grep:*), Bash(find:*), Read +model: claude-haiku-4-5 +context: fork --- # running-test262 diff --git a/.claude/skills/scanning-quality/SKILL.md b/.claude/skills/fleet/scanning-quality/SKILL.md similarity index 99% rename from .claude/skills/scanning-quality/SKILL.md rename to .claude/skills/fleet/scanning-quality/SKILL.md index 5a6d8b4..af08d83 100644 --- a/.claude/skills/scanning-quality/SKILL.md +++ b/.claude/skills/fleet/scanning-quality/SKILL.md @@ -3,6 +3,8 @@ name: scanning-quality description: Scans the codebase for bugs, logic errors, cache races, workflow problems, insecure defaults, security regressions in the diff, and variant analysis on prior findings. Spawns specialized Task agents per scan type, deduplicates findings, and produces an A-F prioritized report. Use when preparing a release, investigating quality issues, running pre-merge checks, or whenever a recent diff touches security-sensitive code. user-invocable: true allowed-tools: Task, Read, Grep, Glob, AskUserQuestion, Bash(pnpm run check:*), Bash(pnpm run test:*), Bash(pnpm test:*), Bash(git status:*), Bash(git diff:*), Bash(git log:*), Bash(rg:*), Bash(grep:*), Bash(find:*), Bash(ls:*) +model: claude-opus-4-8 +context: fork --- # scanning-quality diff --git a/.claude/skills/scanning-quality/scans/bundle-trim.md b/.claude/skills/fleet/scanning-quality/scans/bundle-trim.md similarity index 100% rename from .claude/skills/scanning-quality/scans/bundle-trim.md rename to .claude/skills/fleet/scanning-quality/scans/bundle-trim.md diff --git a/.claude/skills/scanning-quality/scans/differential.md b/.claude/skills/fleet/scanning-quality/scans/differential.md similarity index 100% rename from .claude/skills/scanning-quality/scans/differential.md rename to .claude/skills/fleet/scanning-quality/scans/differential.md diff --git a/.claude/skills/scanning-quality/scans/insecure-defaults.md b/.claude/skills/fleet/scanning-quality/scans/insecure-defaults.md similarity index 100% rename from .claude/skills/scanning-quality/scans/insecure-defaults.md rename to .claude/skills/fleet/scanning-quality/scans/insecure-defaults.md diff --git a/.claude/skills/scanning-quality/scans/variant-analysis.md b/.claude/skills/fleet/scanning-quality/scans/variant-analysis.md similarity index 100% rename from .claude/skills/scanning-quality/scans/variant-analysis.md rename to .claude/skills/fleet/scanning-quality/scans/variant-analysis.md diff --git a/.claude/skills/scanning-security/SKILL.md b/.claude/skills/fleet/scanning-security/SKILL.md similarity index 99% rename from .claude/skills/scanning-security/SKILL.md rename to .claude/skills/fleet/scanning-security/SKILL.md index 489c5ff..7c41c4f 100644 --- a/.claude/skills/scanning-security/SKILL.md +++ b/.claude/skills/fleet/scanning-security/SKILL.md @@ -3,6 +3,8 @@ name: scanning-security description: Runs a multi-tool security scan: AgentShield for Claude config, zizmor for GitHub Actions, and optionally Socket CLI for dependency scanning. Produces an A-F graded security report. Use after modifying `.claude/` config, hooks, agents, or GitHub Actions workflows, and before releases. user-invocable: true allowed-tools: Task, Read, Bash(pnpm exec agentshield:*), Bash(zizmor:*), Bash(command -v:*), Bash(find .cache/external-tools/zizmor:*) +model: claude-opus-4-8 +context: fork --- # scanning-security diff --git a/.claude/skills/squashing-history/SKILL.md b/.claude/skills/fleet/squashing-history/SKILL.md similarity index 98% rename from .claude/skills/squashing-history/SKILL.md rename to .claude/skills/fleet/squashing-history/SKILL.md index 9ddc953..c9b2ff0 100644 --- a/.claude/skills/squashing-history/SKILL.md +++ b/.claude/skills/fleet/squashing-history/SKILL.md @@ -3,6 +3,8 @@ name: squashing-history description: Squashes all commits on the repo's default branch (main, falling back to master) to a single "Initial commit" with backup branch, integrity verification, and user confirmation before force push. Use when cleaning history or preparing for fresh start. user-invocable: true allowed-tools: AskUserQuestion, Bash(git:*), Bash(diff:*), Bash(rm:*), Bash(ls:*) +model: claude-haiku-4-5 +context: fork --- # squashing-history diff --git a/.claude/skills/squashing-history/reference.md b/.claude/skills/fleet/squashing-history/reference.md similarity index 100% rename from .claude/skills/squashing-history/reference.md rename to .claude/skills/fleet/squashing-history/reference.md diff --git a/.claude/skills/trimming-bundle/SKILL.md b/.claude/skills/fleet/trimming-bundle/SKILL.md similarity index 100% rename from .claude/skills/trimming-bundle/SKILL.md rename to .claude/skills/fleet/trimming-bundle/SKILL.md diff --git a/.claude/skills/updating-coverage/SKILL.md b/.claude/skills/fleet/updating-coverage/SKILL.md similarity index 99% rename from .claude/skills/updating-coverage/SKILL.md rename to .claude/skills/fleet/updating-coverage/SKILL.md index 3b60da0..89a71d1 100644 --- a/.claude/skills/updating-coverage/SKILL.md +++ b/.claude/skills/fleet/updating-coverage/SKILL.md @@ -3,6 +3,8 @@ name: updating-coverage description: Refresh the coverage badge in the root README by running the repo's coverage script and rewriting the `![Coverage](https://img.shields.io/badge/coverage-%25-brightgreen)` line. Sibling of `updating-security` / `updating-lockstep` under the `updating` umbrella. user-invocable: true allowed-tools: Read, Edit, Bash(pnpm run cover:*), Bash(pnpm run coverage:*), Bash(pnpm run test:cover:*), Bash(node:*), Bash(git:*), Bash(jq:*), Bash(cat:*) +model: claude-haiku-4-5 +context: fork --- # updating-coverage diff --git a/.claude/skills/updating-lockstep/SKILL.md b/.claude/skills/fleet/updating-lockstep/SKILL.md similarity index 99% rename from .claude/skills/updating-lockstep/SKILL.md rename to .claude/skills/fleet/updating-lockstep/SKILL.md index 38b9245..c814af6 100644 --- a/.claude/skills/updating-lockstep/SKILL.md +++ b/.claude/skills/fleet/updating-lockstep/SKILL.md @@ -3,6 +3,8 @@ name: updating-lockstep description: Acts on `lockstep.json` drift for repos that carry the lockstep manifest. Reads `pnpm run lockstep --json`, auto-bumps mechanical `version-pin` rows, surfaces `file-fork` / `feature-parity` / `spec-conformance` / `lang-parity` rows as advisory. Invoked by the `updating` umbrella skill; can also run standalone. user-invocable: true allowed-tools: Read, Edit, Grep, Glob, Bash(pnpm:*), Bash(npm:*), Bash(git:*), Bash(node:*), Bash(rg:*), Bash(grep:*), Bash(find:*), Bash(ls:*), Bash(cat:*), Bash(head:*), Bash(tail:*), Bash(wc:*), Bash(diff:*) +model: claude-haiku-4-5 +context: fork --- # updating-lockstep diff --git a/.claude/skills/updating-lockstep/reference.md b/.claude/skills/fleet/updating-lockstep/reference.md similarity index 100% rename from .claude/skills/updating-lockstep/reference.md rename to .claude/skills/fleet/updating-lockstep/reference.md diff --git a/.claude/skills/updating-security/SKILL.md b/.claude/skills/fleet/updating-security/SKILL.md similarity index 100% rename from .claude/skills/updating-security/SKILL.md rename to .claude/skills/fleet/updating-security/SKILL.md diff --git a/.claude/skills/updating-security/reference.md b/.claude/skills/fleet/updating-security/reference.md similarity index 100% rename from .claude/skills/updating-security/reference.md rename to .claude/skills/fleet/updating-security/reference.md diff --git a/.claude/skills/updating/SKILL.md b/.claude/skills/fleet/updating/SKILL.md similarity index 77% rename from .claude/skills/updating/SKILL.md rename to .claude/skills/fleet/updating/SKILL.md index 0574466..9318c75 100644 --- a/.claude/skills/updating/SKILL.md +++ b/.claude/skills/fleet/updating/SKILL.md @@ -3,6 +3,8 @@ name: updating description: Umbrella update skill for a Socket fleet repo. Runs `pnpm run update` (npm), validates `lockstep.json` via `pnpm run lockstep` (if present), optionally bumps submodules, checks workflow SHA pins, resolves open Dependabot security alerts, refreshes the README coverage badge when applicable, and audits GitHub repo + Actions settings drift via `scripts/lint-github-settings.mts`. Use when asked to update dependencies, sync upstreams, fix security advisories, refresh coverage, or prepare for a release. user-invocable: true allowed-tools: Task, Skill, Read, Edit, Grep, Glob, Bash(pnpm run:*), Bash(pnpm test:*), Bash(pnpm install:*), Bash(git:*), Bash(claude --version) +model: claude-haiku-4-5 +context: fork --- # updating @@ -17,7 +19,7 @@ Umbrella update skill. Runs `pnpm run update` for npm deps, then adapts to whate ## Update targets -- **npm packages**: `pnpm run update` (every fleet repo has this script). +- **npm packages**: `pnpm run update` (every fleet repo has this script). If the diff bumps `engines.pnpm`, `packageManager`, or `engines.npm`, see **"When the bump includes pnpm or npm"** below. - **lockstep-managed upstreams**: `pnpm run lockstep` when `lockstep.json` exists. Mechanical `version-pin` bumps auto-apply; `file-fork` / `feature-parity` / `spec-conformance` / `lang-parity` rows surface as advisory. - **Other submodules**: repo-specific `updating-*` sub-skills handle `.gitmodules` entries not claimed by a lockstep `version-pin` row. - **Workflow SHA pins**: `_local-not-for-reuse-*.yml` SHAs against the remote's default branch (per CLAUDE.md _Default branch fallback_); run `/updating-workflows` when stale. @@ -27,6 +29,22 @@ Umbrella update skill. Runs `pnpm run update` for npm deps, then adapts to whate This umbrella reads repo state first to discover what applies. Sub-skills are only invoked when relevant. +## When the bump includes pnpm or npm + +A bump to `engines.pnpm`, `packageManager: "pnpm@"`, or `engines.npm` in a fleet repo has a **transitive blast radius**: the socket-registry shared `setup-and-install` GHA action installs pnpm from `external-tools.json` at a specific version; if that version doesn't match the fleet repo's new `packageManager` pin, every CI job fails the version check before tests run. + +The fix order is fixed — **don't try to land the fleet-repo bump first**: + +1. **Defer to socket-registry's `updating-workflows` skill** (lives at `socket-registry/.claude/skills/updating-workflows/SKILL.md`). That skill drives the Layer 1 → 2a → 2b → 3 → 4 cascade in socket-registry, ending at a **Layer 3 merge SHA** known as the **propagation SHA**. The skill's external-tools.json bump bundles the new pnpm version with its 7-platform SRI integrity values. + +2. **Capture the propagation SHA** from step 1. Every fleet-repo `uses: socket-registry/.github/{workflows,actions}/...@` ref bumps to it. + +3. **Update wheelhouse template** in the same wave: `template/package.json` `engines.pnpm` / `engines.npm` / `packageManager` + `template/pnpm-workspace.yaml` `allowBuilds` entries for any new transitive build-scripts the bumped pnpm enforces (`pnpm@11.4` added `[ERR_PNPM_IGNORED_BUILDS]` as hard exit, so `esbuild` and friends need explicit allowlisting). + +4. **Cascade fleet repos** atomically: each downstream socket-\* repo gets the new pnpm pin AND the new propagation SHA in the same cascade commit. Without atomicity, you get the failure mode we hit on 2026-05-28: fleet repo bumps to pnpm@11.4, CI fails because the installed pnpm (11.3 via old setup-action) refuses the pin. + +Why reference, not duplicate: the cascade procedure is fleet-canonical knowledge owned by socket-registry. Duplicating it into wheelhouse means two copies that drift. The wheelhouse `updating` skill encodes "when to run the registry cascade and how to consume its output", not the cascade itself. + ## Phases | # | Phase | Outcome | diff --git a/.claude/skills/updating/reference.md b/.claude/skills/fleet/updating/reference.md similarity index 100% rename from .claude/skills/updating/reference.md rename to .claude/skills/fleet/updating/reference.md diff --git a/.claude/skills/worktree-management/SKILL.md b/.claude/skills/fleet/worktree-management/SKILL.md similarity index 99% rename from .claude/skills/worktree-management/SKILL.md rename to .claude/skills/fleet/worktree-management/SKILL.md index a959cf4..e6bc929 100644 --- a/.claude/skills/worktree-management/SKILL.md +++ b/.claude/skills/fleet/worktree-management/SKILL.md @@ -3,6 +3,8 @@ name: worktree-management description: Manages git worktrees per the fleet's parallel-Claude-sessions rule. Creates new task-worktrees, fans out one worktree per open PR for parallel review, and prunes stale worktrees whose branches were deleted upstream. Use when starting a task that needs an isolated working tree, when reviewing every open PR locally without disturbing the primary checkout, or when cleaning up after merges. user-invocable: true allowed-tools: Bash(git worktree:*), Bash(git branch:*), Bash(git fetch:*), Bash(gh pr list:*), Bash(gh auth status), Bash(ls:*), Read +model: claude-haiku-4-5 +context: fork --- # worktree-management diff --git a/.config/.prettierignore b/.config/.prettierignore index 6cbe4c6..6f928bd 100644 --- a/.config/.prettierignore +++ b/.config/.prettierignore @@ -21,16 +21,16 @@ # `SyntaxError: Unexpected token 'export'`. Past incident: cascaded # to two fleet repos before the break surfaced. # -# The generators we DO own (`acorn-wasm-sync.{mts,cts}`, -# `acorn-wasm-embed.{mts,cts}`) are not listed here on purpose — +# The generators we DO own (`acorn-sync.{mts,cts}`, +# `acorn-embed.{mts,cts}`) are not listed here on purpose — # the ultrathink build emits them already formatted+linted per fleet # rules so they participate in the regular lint pass like any other # JS source. Only the raw wasm blob + the bindgen glue skip the # formatter. Marked `binary` in .gitattributes for the wasm blob too # so PR diffs collapse. -template/.claude/hooks/_shared/acorn/acorn.wasm -template/.claude/hooks/_shared/acorn/acorn-bindgen.cjs -.claude/hooks/_shared/acorn/acorn-bindgen.cjs +template/.claude/hooks/fleet/_shared/acorn/acorn.wasm +template/.claude/hooks/fleet/_shared/acorn/acorn-bindgen.cjs +.claude/hooks/fleet/_shared/acorn/acorn-bindgen.cjs # Vendored / upstream trees — kept byte-identical with their source # of truth. Per CLAUDE.md "Untracked-by-default for vendored / build- diff --git a/.config/esbuild/shorten-paths.mts b/.config/esbuild/shorten-paths.mts index d881093..95c2fdd 100644 --- a/.config/esbuild/shorten-paths.mts +++ b/.config/esbuild/shorten-paths.mts @@ -16,6 +16,8 @@ import type { Comment } from '@babel/types' import { parse } from '@babel/parser' import MagicString from 'magic-string' +import { errorMessage } from '@socketsecurity/lib-stable/errors' + import type { BuildResult, PluginBuild } from 'esbuild' import { NODE_MODULES } from '@socketsecurity/lib-stable/paths/dirnames' @@ -162,7 +164,7 @@ export function createPathShorteningPlugin() { await fs.writeFile(outputPath, magicString.toString(), 'utf8') } catch (e) { logger.error( - `Failed to shorten paths in ${outputPath}: ${e instanceof Error ? e.message : String(e)}`, + `Failed to shorten paths in ${outputPath}: ${errorMessage(e)}`, ) } } diff --git a/.config/oxfmtrc.json b/.config/oxfmtrc.json index d307776..5d5406d 100644 --- a/.config/oxfmtrc.json +++ b/.config/oxfmtrc.json @@ -56,6 +56,8 @@ "**/.pnpm-store/**", "**/vendor/**", "**/wasm_exec.js", + "**/scripts/plugin-patches/**/*.files/**", + "**/scripts/plugin-patches/**/*.patch", "**/.config/lockstep.schema.json", "**/.config/markdownlint-rules/_shared/wheelhouse-self-skip.mjs", "**/.config/markdownlint-rules/socket-no-private-wheelhouse-leak.mjs", @@ -65,6 +67,7 @@ "**/.config/socket-wheelhouse-schema.json", "**/.config/taze.config.mts", "**/.config/tsconfig.base.json", + "**/.config/vitest.coverage.fleet.config.mts", "**/packages/build-infra/lib/release-checksums/consumer.mts", "**/packages/build-infra/lib/release-checksums/core.mts", "**/packages/build-infra/lib/release-checksums/producer.mts", @@ -122,7 +125,10 @@ "**/scripts/test/install-claude-plugins.test.mts", "**/scripts/test/install-git-hooks.test.mts", "**/scripts/update.mts", + "**/scripts/util/multi-package-publish.mts", + "**/scripts/util/pack-app-triplets.mts", "**/scripts/util/run-command.mts", + "**/scripts/util/source-allowlist.mts", "**/scripts/validate-bundle-deps.mts", "**/scripts/validate-config-paths.mts", "**/scripts/validate-file-size.mts", diff --git a/.config/oxlint-plugin/index.mts b/.config/oxlint-plugin/index.mts index 27f5ea4..2bcaab7 100644 --- a/.config/oxlint-plugin/index.mts +++ b/.config/oxlint-plugin/index.mts @@ -40,11 +40,13 @@ import preferAsyncSpawn from './rules/prefer-async-spawn.mts' import preferCachedForLoop from './rules/prefer-cached-for-loop.mts' import preferEllipsisChar from './rules/prefer-ellipsis-char.mts' import preferEnvAsBoolean from './rules/prefer-env-as-boolean.mts' +import preferErrorMessage from './rules/prefer-error-message.mts' import preferExistsSync from './rules/prefer-exists-sync.mts' import preferFunctionDeclaration from './rules/prefer-function-declaration.mts' import preferNodeBuiltinImports from './rules/prefer-node-builtin-imports.mts' import preferNodeModulesDotCache from './rules/prefer-node-modules-dot-cache.mts' import preferNonCapturingGroup from './rules/prefer-non-capturing-group.mts' +import preferPureCallForm from './rules/prefer-pure-call-form.mts' import preferSafeDelete from './rules/prefer-safe-delete.mts' import preferSeparateTypeImport from './rules/prefer-separate-type-import.mts' import preferSpawnOverExecsync from './rules/prefer-spawn-over-execsync.mts' @@ -55,6 +57,7 @@ import socketApiTokenEnv from './rules/socket-api-token-env.mts' import sortBooleanChains from './rules/sort-boolean-chains.mts' import sortEqualityDisjunctions from './rules/sort-equality-disjunctions.mts' import sortNamedImports from './rules/sort-named-imports.mts' +import sortObjectLiteralProperties from './rules/sort-object-literal-properties.mts' import sortRegexAlternations from './rules/sort-regex-alternations.mts' import sortSetArgs from './rules/sort-set-args.mts' import sortSourceMethods from './rules/sort-source-methods.mts' @@ -100,11 +103,13 @@ const plugin = { 'prefer-cached-for-loop': preferCachedForLoop, 'prefer-ellipsis-char': preferEllipsisChar, 'prefer-env-as-boolean': preferEnvAsBoolean, + 'prefer-error-message': preferErrorMessage, 'prefer-exists-sync': preferExistsSync, 'prefer-function-declaration': preferFunctionDeclaration, 'prefer-node-builtin-imports': preferNodeBuiltinImports, 'prefer-node-modules-dot-cache': preferNodeModulesDotCache, 'prefer-non-capturing-group': preferNonCapturingGroup, + 'prefer-pure-call-form': preferPureCallForm, 'prefer-safe-delete': preferSafeDelete, 'prefer-separate-type-import': preferSeparateTypeImport, 'prefer-spawn-over-execsync': preferSpawnOverExecsync, @@ -115,6 +120,7 @@ const plugin = { 'sort-boolean-chains': sortBooleanChains, 'sort-equality-disjunctions': sortEqualityDisjunctions, 'sort-named-imports': sortNamedImports, + 'sort-object-literal-properties': sortObjectLiteralProperties, 'sort-regex-alternations': sortRegexAlternations, 'sort-set-args': sortSetArgs, 'sort-source-methods': sortSourceMethods, diff --git a/.config/oxlint-plugin/rules/no-eslint-biome-config-ref.mts b/.config/oxlint-plugin/rules/no-eslint-biome-config-ref.mts index 47a978a..53e3659 100644 --- a/.config/oxlint-plugin/rules/no-eslint-biome-config-ref.mts +++ b/.config/oxlint-plugin/rules/no-eslint-biome-config-ref.mts @@ -7,7 +7,15 @@ * TS/JS source — package.json + workflow YAML are caught by other tooling * (the SBOM / dep scanners flag the package refs at install time). No * autofix: the right replacement varies (drop the line, swap to - * `oxlint`/`oxfmt`, or rewrite a script invocation). Reporting only. + * `oxlint`/`oxfmt`, or rewrite a script invocation). Reporting only. **Test + * fixtures:** if a pattern-matching test reaches for a real package name that + * happens to start with `eslint-` / `biome` / `@biomejs/`, the rule fires on + * the test fixture even though it isn't a config ref. Use the documented + * neutral placeholder family `acme-*` (`acme-plugin-react`, `acme-foo`, + * `@acme/widget`) — same convention as `Acme Inc` for customer-name + * placeholders in [`fleet/public-surface-hygiene`]. They keep wildcard + * semantics intact without tripping the rule. Reserve the bypass comment for + * genuinely irreplaceable cases (e.g. testing the rule itself). */ import { makeBypassChecker } from '../lib/comment-markers.mts' @@ -65,7 +73,7 @@ const rule = { }, messages: { staleConfig: - '`{{ref}}` is a stale ESLint/Biome reference — the fleet runs oxlint + oxfmt. Drop the line or swap to the oxlint/oxfmt equivalent. (See `template/.config/oxlintrc.json` / `oxfmtrc.json` for the canonical configs.)', + '`{{ref}}` is a stale ESLint/Biome reference — the fleet runs oxlint + oxfmt. Drop the line or swap to the oxlint/oxfmt equivalent. (See `template/.config/oxlintrc.json` / `oxfmtrc.json` for the canonical configs.) If this is a test fixture, rename to the neutral placeholder family `acme-*` (mirrors the `Acme Inc` convention from `fleet/public-surface-hygiene`).', }, schema: [], }, diff --git a/.config/oxlint-plugin/rules/no-inline-defer-async.mts b/.config/oxlint-plugin/rules/no-inline-defer-async.mts index 156207b..06b0031 100644 --- a/.config/oxlint-plugin/rules/no-inline-defer-async.mts +++ b/.config/oxlint-plugin/rules/no-inline-defer-async.mts @@ -4,14 +4,15 @@ * immediately. The author intent (wait for DOMContentLoaded) is silently * ignored. Past incident: same shape bit a fleet project twice; rendered * pages went silently broken when the script tried to operate on DOM nodes - * that didn't exist yet. Sibling: `.claude/hooks/inline-script-defer-guard/` - * catches this at edit time. This lint rule catches it at commit time when - * edits happened outside Claude. Detects: string literals (single-quoted, - * double-quoted, or template) containing `