diff --git a/.claude/agents/fleet/code-reviewer.md b/.claude/agents/fleet/code-reviewer.md deleted file mode 100644 index 28b2441..0000000 --- a/.claude/agents/fleet/code-reviewer.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -name: code-reviewer -description: Reviews code in this repository against the rules in CLAUDE.md and reports style violations, logic bugs, and test gaps. Spawned by the scanning-quality skill or invoked directly on a diff. -tools: Read, Grep, Glob, Bash(git:*), Bash(rg:*), Bash(grep:*), Bash(find:*), Bash(ls:*), Bash(wc:*), Bash(cat:*), Bash(head:*), Bash(tail:*) ---- - - -You are the code reviewer for this repository. The project's CLAUDE.md defines the style rules, conventions, and forbidden patterns. Read CLAUDE.md before every review — that's the source of truth. - - - - -Apply the rules from the project's CLAUDE.md exactly. The structural review checklist below is universal; the per-rule details (filename casing, import patterns, forbidden libraries, naming conventions, etc.) come from CLAUDE.md. - -## Read first - -Before reviewing any file, load CLAUDE.md. Pay attention to the sections covering: - -- **File structure** — naming conventions, layout, language extensions. -- **TypeScript / JavaScript style** — type rules, import patterns, `null` vs `undefined`, prototype-pollution defenses. -- **Imports** — what's cherry-picked, what's default-imported, what's banned. -- **File operations** — file existence checks, deletion helpers, forbidden raw filesystem APIs. -- **Object construction** — when to use `{ __proto__: null, ... }`. -- **HTTP / network** — sanctioned clients, forbidden patterns. -- **Comments** — when to add them, what to avoid. -- **Promise.race in loops** — the leaky pattern called out in the fleet's CLAUDE.md. -- **Backward compatibility** — typically forbidden to maintain. -- **Build commands** — script naming convention. -- **Tests** — functional vs source-text scanning. - -If a finding hinges on a rule, cite the CLAUDE.md section so the author can look it up. - -## Review checklist - -For each file in the diff, walk these categories: - -### 1. Style violations - -Apply CLAUDE.md style rules. Common categories: - -- File extensions, filename casing, file headers. -- Import sorting / grouping / cherry-picking. -- `any` usage (typically forbidden — use `unknown` or specific types). -- Type imports (typically `import type`, separate statements). -- `null` vs `undefined` (varies per repo — read CLAUDE.md). -- Object literal shape for config / return / internal-state objects. -- Comment style (default no, only for non-obvious _why_). -- Naming conventions (constants, helpers, exports). -- Sorting (lists, properties, exports, destructuring). - -Flag each violation with `path:line` + the CLAUDE.md rule it violates. - -### 2. Logic issues - -- Bugs (off-by-one, wrong operator, missing edge case). -- Missing error handling on async / I/O operations. -- Race conditions, particularly `Promise.race` in loops with persistent pools. -- Resource leaks (unclosed handles, uncleared timers, retained listeners). -- Type coercion that could silently fail. -- Untrusted input merged into objects or interpolated into shell commands. - -Flag with `path:line` + a one-sentence description. - -### 3. Test gaps - -- Code paths the test suite doesn't cover. -- New exports without corresponding test cases. -- Tests that read source files and assert on contents instead of calling the function (typically forbidden). - -Flag with `path:line` + a suggested test. - -## Cross-fleet rules to enforce - -These apply across the fleet regardless of CLAUDE.md specifics: - -- No `npx`, `pnpm dlx`, or `yarn dlx`. Flag any of these in scripts, hooks, package.json, or CI YAML. -- No `process.chdir`. Pass `cwd:` to spawn or resolve paths from a known root. -- Don't write a real customer / company name into commits, PRs, GitHub comments, or release notes — replace with `Acme Inc` or drop. Don't reference issue-tracker IDs (Linear / Sentry / etc.) in code or PR titles. -- Don't introduce a new HTTP client without explicit user approval. - -## Output - -For each file you review, report: - -- **Style violations**: list with `path:line` + the rule violated (cite CLAUDE.md section if applicable). -- **Logic issues**: bugs, edge cases, missing error handling — `path:line` + a one-sentence description. -- **Test gaps**: code paths the test suite doesn't cover — `path:line` + suggested test. -- **Suggested fix** for each finding, in one sentence. - -If the diff has zero findings, say so explicitly — don't pad with non-actionable observations. - - diff --git a/.claude/agents/fleet/fix.md b/.claude/agents/fleet/fix.md deleted file mode 100644 index 5205bb3..0000000 --- a/.claude/agents/fleet/fix.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -name: fix -description: Applies fixes for a findings report from scanning-quality / reviewing-code. Deterministic fixers (lint/format/the finding's named script) run FIRST; AI patches only the residue, one finding at a time, verifying + committing each. Spawned to make a findings report actionable headlessly. -tools: Read, Edit, Write, Grep, Glob, Bash(git:*), Bash(rg:*), Bash(grep:*), Bash(find:*), Bash(ls:*), Bash(pnpm run:*), Bash(pnpm test:*), Bash(pnpm exec:*), Bash(node:*), Bash(cat:*), Bash(head:*), Bash(tail:*) ---- - - -You apply fixes for a structured findings report (from `scanning-quality`, -`reviewing-code`, or a check script). You are the mutating counterpart to the -read-only `code-reviewer` — it finds, you fix. The project's CLAUDE.md is the -source of truth for style and conventions; read it before patching. - - - - -The governing rule is `code-first-then-ai`: a deterministic fixer runs FIRST; -AI authors a patch ONLY for the residue the script can't resolve. Never hand-fix -something a script owns. - -## Procedure - -1. **Deterministic pass first.** Before any AI patch, run the fixers that own the - mechanical findings: - - `pnpm run fix` — oxlint autofix (lint findings). - - `pnpm run format` — oxfmt (format findings). - - The exact script named in a finding's `fix` field, if it's a check-script - finding (e.g. a `sync`/`reconcile`/`gen` script). Run that script — do not - hand-edit the artifact it owns. - Re-run the relevant check (`pnpm run lint` / `pnpm run check` / `pnpm test - `) and remove every finding the deterministic pass cleared. -2. **Residue, one finding at a time.** For each remaining finding, apply the - smallest AI patch that resolves it. After EACH patch, re-run the relevant check - / test to confirm the fix works and broke nothing else. A patch that turns - another check red is reverted, not stacked on. -3. **Commit per fix.** Each fix is its own commit (`fix(): `) — never - bundle unrelated fixes. The root cause goes in the message. -4. **Stop on ambiguity.** If a finding looks misdiagnosed (the "fix" would mask a - real bug, or the finding contradicts the code), do NOT patch it — report it back - as a disputed finding. A wrong fix for a wrong finding is worse than an open one. - -## Scope protocol - -Fix only what the findings report names. Don't add features, refactor unrelated -code, or make improvements beyond the findings. Simplest patch that resolves the -finding. - -## Verification protocol - -Run the actual check/test after every patch and state what you verified — never -claim a fix without a tool result that shows the check now passes. Re-read every -file you modified; confirm nothing references something that no longer exists. -Run `pnpm run build` only if the change touches `src/` or `tsconfig.json`. - -## Cross-fleet rules - -- No `npx` / `pnpm dlx` / `yarn dlx`; use `pnpm run -``` - -Or, for code that genuinely belongs in an external file: - -```html - -``` - -## What it covers - -| File extension | Checked? | -| -------------------------------------------------------- | --------------- | -| `.html` / `.htm` | full text | -| `.njk` / `.ejs` / `.hbs` / `.handlebars` | full text | -| `.svelte` / `.vue` / `.astro` | full text | -| `.ts` / `.tsx` / `.mts` / `.cts` / `.js` / `.jsx` / etc. | new_string only | -| anything else | not checked | - -## Bypass - -Type the canonical phrase in a new message: - - Allow inline-defer bypass - -Use sparingly — the bug is silent in production. - -## Companion: oxlint rule - -`socket/no-inline-defer-async` catches the same shape at commit time -even when edits happened outside Claude. diff --git a/.claude/hooks/fleet/inline-script-defer-guard/index.mts b/.claude/hooks/fleet/inline-script-defer-guard/index.mts deleted file mode 100644 index ff30bc3..0000000 --- a/.claude/hooks/fleet/inline-script-defer-guard/index.mts +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — inline-script-defer-guard. -// -// Blocks Edit/Write operations that add a script tag with defer or -// async attribute to an HTML / template file when the same tag lacks a -// `src=` attribute. Per HTML spec, `defer` and `async` are no-ops on -// inline (no-src) script tags — the script executes immediately, -// even though the author intent is "wait for DOMContentLoaded." Browsers -// don't warn; the failure mode is a silent broken page (e.g. unstyled -// `
` blocks when the script that styles them runs before its
-// targets exist).
-//
-// Detection: regex over the after-edit text. Find script openers with
-// defer or async in their attrs, check the same tag for `src=`.
-// If absent → block.
-//
-// Fix: wrap the script body in
-//
-//     
-//
-// Files covered: `*.html` / `*.htm` / `*.njk` / `*.ejs` / `*.hbs` /
-// `*.handlebars` / `*.svelte` / `*.vue` / `*.astro`. Also fires on TS/JS
-// source files that contain HTML string literals matching the pattern —
-// SSR / static-gen code paths.
-//
-// Bypass: `Allow inline-defer bypass` typed verbatim in a recent user turn.
-
-import { block, defineHook, editGuard, runHook } from '../_shared/guard.mts'
-import { resolveEditedText } from '../_shared/payload.mts'
-
-// File extensions where we check the full text content. For other
-// extensions, only the new_string is checked (template strings embedded
-// in TS/JS source).
-const HTML_EXT_RE = /\.(?:astro|ejs|handlebars|hbs|htm|html|njk|svelte|vue)$/i
-
-// JS/TS source extensions: optional `m` or `c` prefix, then `js`/`ts`
-// with an optional `x` suffix (JSX/TSX), anchored to the end of the path.
-const SOURCE_EXT_RE = /\.(?:cjs|cts|m?[jt]sx?)$/i
-
-// Match each `',
-      '',
-      '  Or — if the script DOES belong in an external file:',
-      '',
-      '      ',
-      '',
-    ].join('\n'),
-  )
-})
-
-export const hook = defineHook({
-  bypass: ['inline-defer'],
-  bypassOptional: true,
-  check,
-  event: 'PreToolUse',
-  matcher: ['Edit', 'Write', 'MultiEdit'],
-  type: 'guard',
-})
-
-void runHook(hook, import.meta.url)
diff --git a/.claude/hooks/fleet/inline-script-defer-guard/package.json b/.claude/hooks/fleet/inline-script-defer-guard/package.json
deleted file mode 100644
index 43b2da5..0000000
--- a/.claude/hooks/fleet/inline-script-defer-guard/package.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
-  "name": "hook-inline-script-defer-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/inline-script-defer-guard/tsconfig.json b/.claude/hooks/fleet/inline-script-defer-guard/tsconfig.json
deleted file mode 100644
index 19458cf..0000000
--- a/.claude/hooks/fleet/inline-script-defer-guard/tsconfig.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
-  "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/issue-autolink-nudge/index.mts b/.claude/hooks/fleet/issue-autolink-nudge/index.mts
deleted file mode 100644
index 0447f43..0000000
--- a/.claude/hooks/fleet/issue-autolink-nudge/index.mts
+++ /dev/null
@@ -1,70 +0,0 @@
-#!/usr/bin/env node
-// Claude Code PreToolUse hook — issue-autolink-nudge.
-//
-// On a Bash command that writes to a public Git or GitHub surface (a commit,
-// or a pr, issue, comment, or release body), warn when the text contains a
-// bare `#N`. GitHub auto-links `#3` into a reference to issue or PR 3 of the
-// target repo — so a `#3` that meant "list item 3" or "task 3" silently turns
-// into a cross-reference to an unrelated issue. Suggest backticking it
-// (`` `#3` ``) or reshaping ("item 3").
-//
-// Advisory only — a bare `#N` is sometimes a deliberate, correct reference.
-// The nudge just prompts the author to confirm intent before it sends. Never
-// blocks (notify, exit 0). Universal: bare `#N` auto-links on ANY GitHub repo,
-// so this is not fleet-scoped. Internal task lists in the agent's own prose
-// never sent to a public surface, are unaffected.
-
-import { bashGuard, defineHook, notify, runHook } from '../_shared/guard.mts'
-import { isPublicSurface } from '../_shared/public-surfaces.mts'
-
-export const triggers: readonly string[] = ['gh', 'git']
-
-// A bare `#N`: a `#` immediately followed by digits, NOT preceded by a backtick
-// already code-formatted, or a word char (so `abc#3` and `v1.2.3#4` don't
-// count). A `#L12` line anchor starts with a letter, so it never matches. This
-// scans message text for a display artifact, not shell-command structure.
-const BARE_ISSUE_REF_RE = /(?()
-  let match: RegExpExecArray | null = BARE_ISSUE_REF_RE.exec(text)
-  while (match) {
-    seen.add(`#${match[1]!}`)
-    match = BARE_ISSUE_REF_RE.exec(text)
-  }
-  return [...seen]
-}
-
-export const check = bashGuard(command => {
-  if (!isPublicSurface(command)) {
-    return undefined
-  }
-  const refs = findBareIssueRefs(command)
-  if (!refs.length) {
-    return undefined
-  }
-  return notify(
-    [
-      '[issue-autolink-nudge] This command writes to a public GitHub surface',
-      `  and contains a bare reference: ${refs.join(', ')}.`,
-      '',
-      '  GitHub auto-links `#N` to issue or PR N of the target repo. If you',
-      '  meant a list item or task number (not that issue), it becomes a wrong',
-      '  cross-reference once it sends.',
-      '',
-      '  • A real issue or PR reference? Leave it — that is the intended link.',
-      '  • Otherwise wrap it in backticks (`#3`) or reshape ("item 3", "task 3")',
-      '    before the command runs.',
-    ].join('\n'),
-  )
-})
-
-export const hook = defineHook({
-  check,
-  event: 'PreToolUse',
-  matcher: ['Bash'],
-  triggers,
-  type: 'nudge',
-})
-
-void runHook(hook, import.meta.url)
diff --git a/.claude/hooks/fleet/judgment-nudge/README.md b/.claude/hooks/fleet/judgment-nudge/README.md
deleted file mode 100644
index 9c1f52f..0000000
--- a/.claude/hooks/fleet/judgment-nudge/README.md
+++ /dev/null
@@ -1,58 +0,0 @@
-# judgment-nudge
-
-Stop hook that flags hedging language in the assistant's most-recent turn. Two-layer detection: regex for fixed phrases, compromise.js for modal-verb judgment hedges.
-
-## Why
-
-CLAUDE.md "Judgment & self-evaluation":
-
-- "Default to perfectionist when you have latitude."
-- "If a fix fails twice: stop, re-read top-down, state where the mental model was wrong, try something fundamentally different."
-
-Hedging undermines those rules — it offloads judgment back to the user instead of executing the perfectionist default.
-
-## What it catches
-
-### Fixed-phrase regex layer
-
-| Phrase                                       | Why it's flagged                                         |
-| -------------------------------------------- | -------------------------------------------------------- |
-| `I'm not sure` / `I am not sure`             | Hedge; state a recommendation with rationale instead.    |
-| `you decide` / `your call` / `up to you`     | Offloads judgment. Pick the recommended path.            |
-| `either approach works` / `either way works` | False-equivalence hedging. Pick one.                     |
-| `let me know` / `your preference`            | Hand-off phrasing. Ask one specific question or execute. |
-| `maybe X` / `perhaps X` (sentence-initial)   | Front-loaded uncertainty user didn't ask for.            |
-
-### Modal-verb NLP layer (compromise.js)
-
-Flags first-person modals in judgment contexts:
-
-- `I could go either way`
-- `we might want to consider`
-- `I may pick the simpler approach`
-
-The compromise.js library tags verbs with POS so we can distinguish judgment hedges ("I could go") from technical conditionals ("the parser could throw") — regex alone would false-positive on the latter.
-
-**Fail-open**: if compromise.js fails to load, the hook degrades to a regex-only fallback that catches the most common shape but misses some context.
-
-## Why it doesn't block
-
-Stop hooks fire after the assistant has produced its response. Blocking would truncate. The warning surfaces alongside the response so the user reads both and can push back next turn.
-
-## Relationship to other reminders
-
-- `excuse-detector` — catches fix-vs-defer choice menus
-- `reply-prose-nudge` (perfectionist group) — catches speed-vs-depth choice menus
-- `judgment-nudge` (this) — catches hedging within a single position
-
-All three address the same underlying anti-pattern: offloading judgment the assistant should have made.
-
-## Dependencies
-
-- `compromise@14.15.1` — NLP library for POS-tagged modal-verb detection. Lazy-loaded; optional.
-
-## Test
-
-```sh
-pnpm test
-```
diff --git a/.claude/hooks/fleet/judgment-nudge/index.mts b/.claude/hooks/fleet/judgment-nudge/index.mts
deleted file mode 100644
index 0e2e5df..0000000
--- a/.claude/hooks/fleet/judgment-nudge/index.mts
+++ /dev/null
@@ -1,214 +0,0 @@
-#!/usr/bin/env node
-// Claude Code Stop hook — judgment-nudge.
-//
-// Flags hedging language in the assistant's most recent turn.
-// CLAUDE.md "Judgment & self-evaluation":
-//   - "If the request is based on a misconception, say so..."
-//   - "Default to perfectionist when you have latitude."
-//   - "If a fix fails twice: stop, re-read top-down..."
-//
-// Hedging ("I'm not sure", "you decide", "either approach works",
-// modal "might/could/may") undermines those rules — it offloads the
-// judgment back onto the user instead of executing the perfectionist
-// default.
-//
-// What this catches:
-//
-//   - Fixed phrases (regex): "I'm not sure", "you decide", "either
-//     approach works", "your call", "up to you", "let me know", etc.
-//   - Modal verbs (compromise.js POS): could / might / may / perhaps /
-//     maybe, when used as judgment hedges rather than technical
-//     conditionals.
-//
-// The compromise.js NLP layer is what makes modal detection useful:
-// "this could throw" (technical conditional, OK) vs "I could go either
-// way", judgment hedge, flag. The library tags each token with POS
-// and lets us inspect the verb context. Regex alone gets too many
-// false positives on the technical use.
-//
-// Fail-open contract: if compromise.js fails to load (or its data
-// initializer throws), fall back to regex-only detection — the hook
-// still flags fixed phrases, just misses the modal-verb signal.
-//
-// This is a NUDGE, never blocks: when hits are found it returns
-// `notify(message)` so the runner prints to stderr and exits 0.
-
-import { defineHook, notify, runHook } from '../_shared/guard.mts'
-import type { GuardResult } from '../_shared/guard.mts'
-import type { ToolCallPayload } from '../_shared/payload.mts'
-import {
-  extractSnippet,
-  formatReminderBlock,
-  scanReminderText,
-} from '../_shared/stop-nudge.mts'
-import type { ReminderHit, RuleViolation } from '../_shared/stop-nudge.mts'
-import {
-  readLastAssistantText,
-  stripCodeFences,
-} from '../_shared/transcript.mts'
-
-// Try-require compromise.js for modal-verb detection. Lazy + optional
-// because the dep is heavy (~2.5 MB unpacked) and the fixed-phrase
-// regex catches the most common hedging patterns without it. Modal
-// detection is an enhancement, not a requirement — if compromise is
-// missing (e.g. downstream repo didn't pnpm install the hook's deps
-// yet), the hook degrades gracefully to regex-only.
-interface NlpDoc {
-  readonly verbs: () => {
-    readonly out: (mode: 'array') => readonly string[]
-  }
-  readonly sentences: () => {
-    readonly out: (mode: 'array') => readonly string[]
-  }
-}
-type NlpFn = (text: string) => NlpDoc
-
-let cachedNlp: NlpFn | undefined
-export async function loadCompromise(): Promise {
-  if (cachedNlp !== undefined) {
-    return cachedNlp
-  }
-  try {
-    const mod = await import('compromise')
-    /* c8 ignore start - defensive fallbacks for non-standard compromise export shapes */
-    const candidate = (mod as { default?: unknown | undefined }).default ?? mod
-    cachedNlp =
-      typeof candidate === 'function' ? (candidate as NlpFn) : undefined
-    /* c8 ignore stop */
-  } catch {
-    /* c8 ignore start - only reachable when compromise is not installed */
-    cachedNlp = undefined
-    /* c8 ignore stop */
-  }
-  return cachedNlp
-}
-
-// Sentence-starting hedge modals — "I could go either way", "this
-// might be the better path", "perhaps we should..." These read as
-// the assistant deferring judgment rather than stating a position.
-//
-// We filter to hedge contexts (first-person subject + modal + judgment
-// verb) so technical conditionals like "the parser could throw if X"
-// don't false-positive. The compromise pattern matches:
-//   - (i|we) + (could|might|may)
-//   - sentence-initial perhaps/maybe + we/I/it
-const HEDGE_VERB_REGEX =
-  /\b(?:i|we)\s+(?:could|may|might)\s+(?:approach|choose|consider|do|go|pick|try|use)\b/i
-
-export async function detectModalHedges(
-  text: string,
-): Promise {
-  const nlp = await loadCompromise()
-  /* c8 ignore start - only reachable when compromise is not installed */
-  if (!nlp) {
-    // Fallback: regex-only. We still catch the most common shape.
-    const match = HEDGE_VERB_REGEX.exec(text)
-    if (!match) {
-      return []
-    }
-    return [
-      {
-        label: 'modal-verb hedge (regex fallback)',
-        why: "Modal verbs (could/might/may) used in first-person judgment context. State the position; don't hedge.",
-        snippet: extractSnippet(text, match.index, match[0].length),
-      },
-    ]
-    /* c8 ignore stop */
-  }
-
-  // Compromise.js path: walk sentences, flag any that contain a
-  // first-person modal in a judgment context. The library tags each
-  // verb with POS; we check sentence-by-sentence so the snippet is
-  // useful, a single sentence rather than the whole turn.
-  const doc = nlp(text)
-  const sentences = doc.sentences().out('array')
-  const hits: ReminderHit[] = []
-  for (let i = 0, { length } = sentences; i < length; i += 1) {
-    const sentence = sentences[i]!
-    if (!HEDGE_VERB_REGEX.test(sentence)) {
-      continue
-    }
-    // Compromise gives us POS-aware verb detection; we use it to
-    // confirm the modal isn't part of a code-shape conditional like
-    // "could throw" / "might return", technical, not judgment.
-    const sentenceDoc = nlp(sentence)
-    const verbs = sentenceDoc.verbs().out('array')
-    const hasJudgmentVerb = verbs.some(v =>
-      /\b(?:approach|choose|consider|do|go|pick|try|use)\b/i.test(v),
-    )
-    if (!hasJudgmentVerb) {
-      continue
-    }
-    hits.push({
-      label: 'modal-verb hedge',
-      why: "First-person modal (could/might/may) used in judgment context. State the position; don't hedge.",
-      snippet: sentence.length > 80 ? sentence.slice(0, 77) + '…' : sentence,
-    })
-    // One hit per turn is enough — flag and move on.
-    break
-  }
-  return hits
-}
-
-const FIXED_HEDGE_PATTERNS: readonly RuleViolation[] = [
-  {
-    label: 'I’m not sure / I am not sure',
-    // Match "I’m not sure" or "I am not sure" with straight or curly apostrophe,
-    // as a whole word, case-insensitive.
-    regex: /\bi['‘’]?m\s+not\s+sure\b|\bi\s+am\s+not\s+sure\b/i,
-    why: 'Hedging. State a recommendation with rationale, or say "I need to verify X" and then do it.',
-  },
-  {
-    label: 'you decide / your call / up to you',
-    // Match any of three judgment-offload phrases as whole words, case-insensitive.
-    regex: /\b(?:up\s+to\s+you|you\s+decide|your\s+call)\b/i,
-    why: 'Offloads judgment. Default-perfectionist: pick the recommended path and execute.',
-  },
-  {
-    label: 'either approach works / either way works',
-    // Match false-equivalence phrases: "either  works"
-    // or "either is fine", as whole words, case-insensitive.
-    regex:
-      /\b(?:either\s+(?:approach|option|path|way)\s+works|either\s+is\s+fine)\b/i,
-    why: 'False-equivalence hedging. Even when paths are close, name the one with the smaller blast radius and pick it.',
-  },
-  {
-    label: 'let me know / your preference',
-    // Match hand-off phrases ("let me know", "your preference", "tell me what")
-    // as whole words, case-insensitive.
-    regex: /\b(?:let\s+me\s+know|tell\s+me\s+what|your\s+preference)\b/i,
-    why: 'Hand-off phrasing. If the user already gave intent, execute; if not, ask one specific question, not "let me know."',
-  },
-  {
-    label: 'maybe / perhaps as judgment hedge',
-    regex: /^(?:maybe|perhaps)\s+/im,
-    why: 'Sentence-initial hedge. State the position; "maybe" at the front signals uncertainty the user didn\'t ask for.',
-  },
-]
-
-const CLOSING_HINT =
-  'CLAUDE.md "Judgment & self-evaluation": default to perfectionist; state the recommendation, name the trade-off, then execute. Hedging asks the user to think for you.'
-
-export async function check(payload: ToolCallPayload): Promise {
-  const rawText = readLastAssistantText(payload?.transcript_path)
-  if (!rawText) {
-    return undefined
-  }
-  const text = stripCodeFences(rawText)
-  const hits = await scanReminderText(
-    text,
-    FIXED_HEDGE_PATTERNS,
-    detectModalHedges,
-  )
-  if (hits.length === 0) {
-    return undefined
-  }
-  return notify(formatReminderBlock('judgment-nudge', hits, CLOSING_HINT))
-}
-
-export const hook = defineHook({
-  check,
-  event: 'Stop',
-  type: 'nudge',
-})
-void runHook(hook, import.meta.url)
diff --git a/.claude/hooks/fleet/judgment-nudge/package.json b/.claude/hooks/fleet/judgment-nudge/package.json
deleted file mode 100644
index d020633..0000000
--- a/.claude/hooks/fleet/judgment-nudge/package.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
-  "name": "hook-judgment-nudge",
-  "private": true,
-  "type": "module",
-  "main": "./index.mts",
-  "exports": {
-    ".": "./index.mts"
-  },
-  "scripts": {
-    "test": "node --test test/*.test.mts"
-  },
-  "dependencies": {
-    "compromise": "catalog:"
-  },
-  "devDependencies": {
-    "@types/node": "catalog:"
-  }
-}
diff --git a/.claude/hooks/fleet/judgment-nudge/tsconfig.json b/.claude/hooks/fleet/judgment-nudge/tsconfig.json
deleted file mode 100644
index 19458cf..0000000
--- a/.claude/hooks/fleet/judgment-nudge/tsconfig.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
-  "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/keep-working-while-waiting-nudge/README.md b/.claude/hooks/fleet/keep-working-while-waiting-nudge/README.md
deleted file mode 100644
index 6440b71..0000000
--- a/.claude/hooks/fleet/keep-working-while-waiting-nudge/README.md
+++ /dev/null
@@ -1,24 +0,0 @@
-# keep-working-while-waiting-nudge
-
-Stop hook. When the session is about to idle on an in-flight blocker — a remote
-CI job it launched to watch, a background shell job, a spawned Workflow, or
-background agents — this nudge reminds you that **waiting is not the same as
-being blocked**: advance a different queued todo, or tidy the task list, while
-the result lands. Only work truly blocked on the pending result should pause.
-
-## What it detects
-
-A scan of the recent assistant tool-use blocks for a wait signal:
-
-- a detached `Bash` call left running (`run_in_background: true`)
-- a `Bash` command watching/polling remote CI (`gh run watch`, `gh pr checks
-  --watch`, a `gh api …/runs` poll, a bare `sleep` delay)
-- a `Workflow` call (background orchestration in flight)
-- an `Agent` call not explicitly foregrounded (agents run detached by default)
-
-## Verdict
-
-Notify — never blocks. A Stop hook fires after the turn ended, so there is no
-tool call to refuse; the reminder surfaces for the next turn.
-
-See [`judgment-and-self-evaluation`](../../../../docs/agents.md/fleet/judgment-and-self-evaluation.md).
diff --git a/.claude/hooks/fleet/keep-working-while-waiting-nudge/index.mts b/.claude/hooks/fleet/keep-working-while-waiting-nudge/index.mts
deleted file mode 100644
index 1fbdcfe..0000000
--- a/.claude/hooks/fleet/keep-working-while-waiting-nudge/index.mts
+++ /dev/null
@@ -1,98 +0,0 @@
-#!/usr/bin/env node
-// Claude Code Stop hook — keep-working-while-waiting-nudge.
-//
-// Fires at turn-end. When the session has an in-flight blocker it is about to
-// idle on — a remote CI job it launched to watch, a background shell command, a
-// spawned Workflow, or background agents — this nudge points out that waiting is
-// not the same as being blocked: the task QUEUE almost always has other work
-// that does NOT depend on that result. Advance a different queued todo, or take
-// the wait window to tidy the task list, instead of sitting idle until the
-// result lands. Only work TRULY blocked on the pending result should pause.
-//
-// Detection is a scan of the recent assistant tool-use blocks for wait signals:
-//   • a Bash tool call with `run_in_background: true`, a detached job still running
-//   • a Bash command that watches/polls remote CI (`gh run watch`, `gh pr checks
-//     --watch`, a `gh api …/runs` poll, a bare `sleep` delay)
-//   • a `Workflow` tool call, background orchestration in flight
-//   • an `Agent` tool call not explicitly foregrounded, agents run detached by default
-//
-// Verdict: notify, never blocks. A Stop hook fires after the turn ended, so
-// there is no tool call to refuse — this is a reminder for the next turn.
-
-import { defineHook, notify, runHook } from '../_shared/guard.mts'
-import type { GuardResult } from '../_shared/guard.mts'
-import type { ToolCallPayload } from '../_shared/payload.mts'
-import type { ToolUseEvent } from '../_shared/transcript.mts'
-import {
-  readLastAssistantToolUses,
-  readPriorAssistantToolUses,
-} from '../_shared/transcript.mts'
-
-// How many prior assistant turns to scan alongside the latest one. A launched
-// job typically shows in the last turn or two; a small window keeps the scan
-// cheap on long transcripts.
-const LOOKBACK_TURNS = 3
-
-// A Bash command that watches or polls a remote CI result. Each branch is a
-// distinct wait shape: `gh run watch` / `gh run view` tail a run; `gh pr checks`
-// with `--watch` blocks on PR checks; `gh api …/runs` is a hand-rolled poll; a
-// bare `sleep` between polls is the classic busy-wait. Kept broad on purpose —
-// a false positive only shows an advisory reminder.
-const CI_WAIT_RE =
-  /\bgh\s+(?:api[^\n]*\/runs|pr\s+checks?.*--watch|run\s+(?:view|watch))\b|(?:^|\s)sleep\s+\d/
-
-/**
- * Inspect a set of recent tool-use events for an in-flight blocker the session
- * is about to idle on. Returns the human-readable reason for the first signal
- * found, or undefined when nothing is waiting.
- */
-export function detectWaitSignal(
-  toolUses: readonly ToolUseEvent[],
-): string | undefined {
-  for (let i = 0, { length } = toolUses; i < length; i += 1) {
-    const { input, name } = toolUses[i]!
-    if (name === 'Workflow') {
-      return 'a background Workflow is in flight'
-    }
-    if (name === 'Agent' && input['run_in_background'] !== false) {
-      return 'spawned agents are running in the background'
-    }
-    if (name === 'Bash') {
-      if (input['run_in_background'] === true) {
-        return 'a background shell job is still running'
-      }
-      const command =
-        typeof input['command'] === 'string' ? input['command'] : ''
-      if (CI_WAIT_RE.test(command)) {
-        return 'a remote CI job is being watched/polled'
-      }
-    }
-  }
-  return undefined
-}
-
-export const check = (payload: ToolCallPayload): GuardResult => {
-  const transcriptPath = payload.transcript_path
-  const recent: ToolUseEvent[] = [
-    ...readLastAssistantToolUses(transcriptPath),
-    ...readPriorAssistantToolUses(transcriptPath, LOOKBACK_TURNS),
-  ]
-  const reason = detectWaitSignal(recent)
-  if (!reason) {
-    return undefined
-  }
-  return notify(
-    `[keep-working-while-waiting-nudge] Looks like ${reason}. Waiting is not the same as being blocked:\n` +
-      '  • Advance a DIFFERENT queued todo that does not depend on that result.\n' +
-      '  • Or use the wait window to tidy the task list (drop stale items, split big ones).\n' +
-      '  • Pause only for work that is TRULY blocked on the pending result.\n' +
-      'Reminder-only; not a block.',
-  )
-}
-
-export const hook = defineHook({
-  check,
-  event: 'Stop',
-  type: 'nudge',
-})
-void runHook(hook, import.meta.url)
diff --git a/.claude/hooks/fleet/keep-working-while-waiting-nudge/package.json b/.claude/hooks/fleet/keep-working-while-waiting-nudge/package.json
deleted file mode 100644
index 77e3912..0000000
--- a/.claude/hooks/fleet/keep-working-while-waiting-nudge/package.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
-  "name": "hook-keep-working-while-waiting-nudge",
-  "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/keep-working-while-waiting-nudge/tsconfig.json b/.claude/hooks/fleet/keep-working-while-waiting-nudge/tsconfig.json
deleted file mode 100644
index 19458cf..0000000
--- a/.claude/hooks/fleet/keep-working-while-waiting-nudge/tsconfig.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
-  "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/land-as-you-go-nudge/README.md b/.claude/hooks/fleet/land-as-you-go-nudge/README.md
deleted file mode 100644
index cefce84..0000000
--- a/.claude/hooks/fleet/land-as-you-go-nudge/README.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# land-as-you-go-nudge
-
-PostToolUse hook, non-blocking. After a successful `git commit` on the
-default branch it counts the local commits not yet on origin, and at three or
-more it nudges to land the queue — push, or the managing-worktrees land flow
-— before the next chunk starts. The commit-time twin of
-`unpushed-main-nudge`: that one reminds at turn end; this one fires in the
-moment the queue grows, when landing is one command and the context is still
-loaded.
-
-- **Trigger:** PostToolUse on Bash commands whose parsed argv contains a
-  `git commit` invocation (honoring `git -C `); matching is on argv
-  words, so `commit` inside a message or path never counts.
-- **Verdict:** a stderr notice at queue depth ≥ 3; silent off the default
-  branch (worktree branches land as a unit), silent without an
-  `origin/` counterpart, silent below the threshold.
-- **Why:** an unpushed pile is fragile in a parallel-session fleet — squash
-  cadences and repair flows move origin under it, and every extra commit
-  widens the eventual conflict surface.
-
-Companions: `unpushed-main-nudge` (turn-end reminder),
-`commit-size-nudge` (per-commit line budget), `land-fast-nudge` (diverged
-default branch routes to the land engine).
diff --git a/.claude/hooks/fleet/land-as-you-go-nudge/index.mts b/.claude/hooks/fleet/land-as-you-go-nudge/index.mts
deleted file mode 100644
index 8f8eea2..0000000
--- a/.claude/hooks/fleet/land-as-you-go-nudge/index.mts
+++ /dev/null
@@ -1,139 +0,0 @@
-#!/usr/bin/env node
-// Claude Code PostToolUse hook — land-as-you-go-nudge.
-//
-// After a successful `git commit` on the default branch, counts the local
-// commits not yet on origin. At the threshold it nudges to land the queue —
-// push, or the managing-worktrees land flow — BEFORE starting the next chunk.
-// This is the commit-time twin of `unpushed-main-nudge`: that one reminds at
-// turn end, this one fires in the moment the queue grows, when landing is one
-// command and the context is still loaded. An unpushed pile is fragile in a
-// parallel-session fleet — squash cadences and repair flows move origin under
-// it — and every extra commit widens the eventual conflict surface.
-//
-// Never blocks. Silent off the default branch, since worktree branches land
-// as a unit; silent when origin/ is unknown — a fresh repo or an
-// offline run; and silent below the threshold.
-
-import path from 'node:path'
-
-import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child'
-
-import { defineHook, notify, runHook } from '../_shared/guard.mts'
-import type { GuardResult } from '../_shared/guard.mts'
-import type { ToolCallPayload } from '../_shared/payload.mts'
-import { parseCommands } from '../_shared/shell-command.mts'
-
-const NAME = 'land-as-you-go-nudge'
-
-// Nudge when the unpushed queue reaches this many commits.
-const QUEUE_THRESHOLD = 3
-
-/**
- * The directory a `git commit` in `command` targets: the `-C ` value
- * when given, `.` otherwise, or undefined when no commit invocation exists.
- * Matches on parsed argv words, never the raw string, so `commit` inside a
- * message or path never counts. Exported for tests.
- */
-export function findCommitDir(command: string): string | undefined {
-  const commands = parseCommands(command)
-  for (let i = 0, { length } = commands; i < length; i += 1) {
-    const parsed = commands[i]!
-    if (parsed.binary !== 'git') {
-      continue
-    }
-    const words = parsed.args
-    let dir: string | undefined
-    for (let w = 0, { length: wlen } = words; w < wlen; w += 1) {
-      const word = words[w]!
-      if (word === '-C' && w + 1 < wlen) {
-        dir = words[w + 1]!
-        w += 1
-        continue
-      }
-      if (word === 'commit') {
-        return dir ?? '.'
-      }
-      // Any other subcommand word ends this invocation's scan.
-      if (!word.startsWith('-')) {
-        break
-      }
-    }
-  }
-  return undefined
-}
-
-function git(args: string[], cwd: string): string | undefined {
-  const r = spawnSync('git', args, { cwd, encoding: 'utf8' })
-  return r.status === 0 ? String(r.stdout).trim() : undefined
-}
-
-/**
- * The unpushed-commit count for the checkout at `dir`, or undefined when the
- * checkout is not on its default branch, has no origin counterpart, or is
- * not a git checkout at all. Exported for tests.
- */
-export function unpushedQueueDepth(dir: string): number | undefined {
-  const branch = git(['branch', '--show-current'], dir)
-  if (!branch) {
-    return undefined
-  }
-  const originHead = git(
-    ['symbolic-ref', 'refs/remotes/origin/HEAD', '--short'],
-    dir,
-  )
-  const defaultBranch = originHead
-    ? originHead.replace(/^origin\//, '')
-    : 'main'
-  if (branch !== defaultBranch && branch !== 'master') {
-    return undefined
-  }
-  const count = git(['rev-list', '--count', `origin/${branch}..HEAD`], dir)
-  if (count === undefined) {
-    return undefined
-  }
-  const n = Number.parseInt(count, 10)
-  return Number.isFinite(n) ? n : undefined
-}
-
-export const check = (payload: ToolCallPayload): GuardResult => {
-  if (payload.tool_name !== 'Bash') {
-    return undefined
-  }
-  const command = payload.tool_input?.command
-  if (typeof command !== 'string' || !command.includes('commit')) {
-    return undefined
-  }
-  const commitDir = findCommitDir(command)
-  if (commitDir === undefined) {
-    return undefined
-  }
-  // The session cwd rides in the hook payload — hooks never read the
-  // process's own cwd, which is unstable across the dispatcher.
-  const baseDir = payload.cwd
-  if (!baseDir && !path.isAbsolute(commitDir)) {
-    return undefined
-  }
-  const dir = path.isAbsolute(commitDir)
-    ? commitDir
-    : path.resolve(baseDir!, commitDir)
-  const depth = unpushedQueueDepth(dir)
-  if (depth === undefined || depth < QUEUE_THRESHOLD) {
-    return undefined
-  }
-  return notify(
-    `[${NAME}] ${depth} unpushed commit(s) on the default branch at ${dir}.\n` +
-      '  Land as you go: push the queue (or run the managing-worktrees land\n' +
-      '  flow) before starting the next chunk — origin moves under an\n' +
-      '  unpushed pile, and every extra commit widens the eventual conflict\n' +
-      '  surface.',
-  )
-}
-
-export const hook = defineHook({
-  check,
-  event: 'PostToolUse',
-  matcher: ['Bash'],
-  scope: 'convention',
-  type: 'nudge',
-})
-void runHook(hook, import.meta.url)
diff --git a/.claude/hooks/fleet/land-as-you-go-nudge/package.json b/.claude/hooks/fleet/land-as-you-go-nudge/package.json
deleted file mode 100644
index 092595f..0000000
--- a/.claude/hooks/fleet/land-as-you-go-nudge/package.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
-  "name": "hook-land-as-you-go-nudge",
-  "private": true,
-  "type": "module",
-  "main": "./index.mts",
-  "exports": {
-    ".": "./index.mts"
-  },
-  "scripts": {
-    "test": "node --test test/*.test.mts"
-  },
-  "devDependencies": {
-    "@types/node": "catalog:"
-  },
-  "dependencies": {
-    "@socketsecurity/lib-stable": "catalog:"
-  }
-}
diff --git a/.claude/hooks/fleet/land-as-you-go-nudge/tsconfig.json b/.claude/hooks/fleet/land-as-you-go-nudge/tsconfig.json
deleted file mode 100644
index 19458cf..0000000
--- a/.claude/hooks/fleet/land-as-you-go-nudge/tsconfig.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
-  "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/land-fast-nudge/README.md b/.claude/hooks/fleet/land-fast-nudge/README.md
deleted file mode 100644
index a178392..0000000
--- a/.claude/hooks/fleet/land-fast-nudge/README.md
+++ /dev/null
@@ -1,22 +0,0 @@
-# land-fast-nudge
-
-Stop hook. Nudges at turn-end when the checkout is on the default branch
-(main / master) and local HEAD has **diverged** from origin — it is BOTH
-ahead AND behind `origin/`.
-
-A diverged default branch is the state where a direct `git push` is
-rejected and a `reset --hard` would discard local work. In a
-parallel-session fleet it happens routinely: another session squashes your
-commits onto origin via PR while your local keeps the unsquashed
-originals. Rather than hand-roll a cherry-pick + force, the reminder points
-at the `managing-worktrees land` engine
-(`.claude/skills/fleet/managing-worktrees/lib/land.mts`): it re-asserts the
-lint gate, cherry-picks the local-only commits onto a throwaway
-`origin/` worktree, and fast-forwards (never force). The fleet lints
-as it edits, so this needs no heavy re-run.
-
-Only fires when BOTH ahead AND behind — ahead-only is the
-`unpushed-main-nudge`'s job, behind-only just needs a pull.
-
-Fails open: any hook error is swallowed so a reminder bug never disrupts
-the turn.
diff --git a/.claude/hooks/fleet/land-fast-nudge/index.mts b/.claude/hooks/fleet/land-fast-nudge/index.mts
deleted file mode 100644
index da8b096..0000000
--- a/.claude/hooks/fleet/land-fast-nudge/index.mts
+++ /dev/null
@@ -1,132 +0,0 @@
-#!/usr/bin/env node
-// Claude Code Stop hook — land-fast-nudge.
-//
-// Fires at turn-end. When the current checkout is ON the default branch
-// (main / master) and local HEAD has DIVERGED from origin (it is BOTH
-// ahead AND behind origin/), it nudges toward the fast-land path
-// instead of a hand-rolled cherry-pick + force dance.
-//
-// Why: a diverged default branch is the state where a direct `git push`
-// is rejected (non-fast-forward) and a `reset --hard origin/`
-// would discard local work. It happens routinely in a parallel-session
-// fleet: another session squashes your commits onto origin via PR (so
-// origin gained commits your local doesn't have as the same SHAs), while
-// your local kept the unsquashed originals. Hand-resolving this — manual
-// cherry-pick onto a fresh worktree, verify fast-forward, push — is the
-// exact friction the `managing-worktrees land` engine (lib/land.mts)
-// automates: it re-asserts the lint gate (the fleet lints as it edits, so
-// no heavy re-run), cherry-picks onto a throwaway origin/ worktree,
-// and fast-forwards, never force. This reminder points there at the turn
-// the divergence is visible.
-//
-// Only fires on the default branch when BOTH ahead AND behind: an
-// ahead-only main is the unpushed-main-nudge's job, just push; a
-// behind-only main just needs a pull; the diverged case is the one the
-// fast-land path exists for.
-//
-// Exit codes: 0 — always, informational Stop hook. Fails open.
-
-import {
-  currentBranch,
-  gitOut,
-  isDefaultBranch,
-} from '../_shared/git-branch.mts'
-import { isSquashOptIn } from '../_shared/fleet-roster.mts'
-import { defineHook, notify, runHook } from '../_shared/guard.mts'
-import { resolveProjectDir } from '../_shared/project-dir.mts'
-
-export function getProjectDir(): string {
-  return resolveProjectDir()
-}
-
-// Ahead / behind counts vs origin/. `git rev-list --left-right
-// --count origin/...HEAD` prints "\t". Returns
-// undefined when there's no upstream to compare against.
-export function aheadBehind(
-  repoDir: string,
-  branch: string,
-): { ahead: number; behind: number } | undefined {
-  const out = gitOut(repoDir, [
-    'rev-list',
-    '--left-right',
-    '--count',
-    `origin/${branch}...HEAD`,
-  ])
-  if (out === undefined) {
-    return undefined
-  }
-  const parts = out.split(/\s+/)
-  /* c8 ignore start - parts[0]/parts[1] are always defined for valid git output; ?? '' fallback and NaN guard are defensive-only and unreachable from a real git repo */
-  const behind = Number.parseInt(parts[0] ?? '', 10)
-  const ahead = Number.parseInt(parts[1] ?? '', 10)
-  if (!Number.isFinite(behind) || !Number.isFinite(ahead)) {
-    return undefined
-  }
-  /* c8 ignore stop */
-  return { ahead, behind }
-}
-
-// Diverged = BOTH ahead and behind. That's the non-fast-forward state the
-// fast-land path is for; ahead-only / behind-only are not.
-export function isDiverged(counts: { ahead: number; behind: number }): boolean {
-  return counts.ahead > 0 && counts.behind > 0
-}
-
-export const check = () => {
-  const repoDir = getProjectDir()
-  const branch = currentBranch(repoDir)
-  if (!branch || !isDefaultBranch(repoDir, branch)) {
-    return undefined
-  }
-  const counts = aheadBehind(repoDir, branch)
-  if (!counts || !isDiverged(counts)) {
-    return undefined
-  }
-  // A squash-history repo's default branch is INTENTIONALLY diverged from
-  // origin: origin holds the pre-squash history and local  is
-  // canonical. The fast-land cherry-pick-onto-origin path does NOT apply —
-  // landing is a force-push. Point there instead of the fast-forward path.
-  if (isSquashOptIn(repoDir)) {
-    return notify(
-      [
-        `[land-fast-nudge] ${branch} is diverged from origin/${branch} ` +
-          `(${counts.ahead} ahead, ${counts.behind} behind), but this is a`,
-        `squash-history repo — local ${branch} is canonical and origin holds`,
-        'the pre-squash history. Do NOT fast-land / cherry-pick onto origin.',
-        'Land via the squashing-history force-push:',
-        `    SQUASH_HISTORY=1 git push --force-with-lease origin ${branch}`,
-        '',
-        'The SQUASH_HISTORY sentinel must be in the hook process ENV (export it),',
-        'not just an inline shell assignment — an inline `SQUASH_HISTORY=1 git`',
-        'lives in the command string, which the PreToolUse no-force-push-guard',
-        'does not read, so it still blocks. If it does, the bypass phrase is',
-        '`Allow force-push bypass`.',
-        '',
-      ].join('\n'),
-    )
-  }
-  return notify(
-    [
-      `[land-fast-nudge] ${branch} has DIVERGED from origin/${branch}: ` +
-        `${counts.ahead} ahead, ${counts.behind} behind.`,
-      '',
-      'A direct push will be rejected, and `reset --hard` would discard local',
-      'work (a parallel session likely squashed onto origin). Do NOT hand-roll',
-      'a cherry-pick + force. Fast-land the local-only commits instead:',
-      '    node .claude/skills/fleet/managing-worktrees/lib/land.mts --last ',
-      '    node .claude/skills/fleet/managing-worktrees/lib/land.mts --last  --push',
-      '',
-      'It re-asserts the lint gate, cherry-picks onto a throwaway origin/' +
-        `${branch} worktree, and fast-forwards (never force).`,
-      '',
-    ].join('\n'),
-  )
-}
-
-export const hook = defineHook({
-  check,
-  event: 'Stop',
-  type: 'nudge',
-})
-
-void runHook(hook, import.meta.url)
diff --git a/.claude/hooks/fleet/land-fast-nudge/package.json b/.claude/hooks/fleet/land-fast-nudge/package.json
deleted file mode 100644
index 899a6aa..0000000
--- a/.claude/hooks/fleet/land-fast-nudge/package.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
-  "name": "hook-land-fast-nudge",
-  "private": true,
-  "type": "module",
-  "main": "./index.mts",
-  "exports": {
-    ".": "./index.mts"
-  },
-  "scripts": {
-    "test": "node --test test/*.test.mts"
-  },
-  "devDependencies": {
-    "@socketsecurity/lib-stable": "catalog:",
-    "@types/node": "catalog:"
-  }
-}
diff --git a/.claude/hooks/fleet/land-fast-nudge/tsconfig.json b/.claude/hooks/fleet/land-fast-nudge/tsconfig.json
deleted file mode 100644
index 19458cf..0000000
--- a/.claude/hooks/fleet/land-fast-nudge/tsconfig.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
-  "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/latest-release-pin-guard/index.mts b/.claude/hooks/fleet/latest-release-pin-guard/index.mts
deleted file mode 100644
index 21c7da9..0000000
--- a/.claude/hooks/fleet/latest-release-pin-guard/index.mts
+++ /dev/null
@@ -1,464 +0,0 @@
-#!/usr/bin/env node
-// Claude Code PreToolUse hook — latest-release-pin-guard.
-//
-// BLOCKS an Edit/Write to `.gitmodules` or a `*lockstep.json` manifest that
-// SETS or CHANGES an upstream pin to something OLDER than the newest shipped
-// release tag. Porting an upstream means the LATEST release — always. A pin left
-// at a stale/inherited release is a work-loss trap: the opentui incident pinned
-// v0.1.99, 211 commits and 3 minor releases behind v0.4.5, and ~31k lines were
-// ported against it before anyone noticed.
-//
-// What it checks, per changed pin:
-//   - `.gitmodules`    — a `[submodule "…"]` block's `ref`/`branch` pin.
-//   - `*lockstep.json` — a version-pin row's `pinned_sha`/`pinned_tag`; the
-//     upstream repo URL is resolved from the manifest's `upstreams` map.
-// It fetches the upstream's tags with `git ls-remote --tags `, finds the
-// newest STABLE tag sharing the pin's version scheme, and blocks when the pin
-// resolves to an older one — naming the newer release. Only pins that are NEW or
-// whose value CHANGED in this edit are checked: the post-edit text via
-// `resolveEditedText` is diffed against the on-disk file, so touching an
-// unrelated field never false-blocks a pin already committed.
-//
-// Fails OPEN on anything it can't determine — an offline `ls-remote`, an
-// unparseable tag scheme, a sha that maps to no release tag, a fragment edit
-// whose post-edit text can't be reconstructed. The CI-side lockstep drift check
-// (`scripts/fleet/lockstep/checks.mts`) is the online backstop.
-//
-// Convention: docs/agents.md/fleet/lockstep.md + docs/agents.md/fleet/drift-watch.md.
-// Bypass: `Allow latest-release-pin bypass`.
-
-import { safeReadFileSync } from '@socketsecurity/lib-stable/fs/read-file'
-import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize'
-import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child'
-
-import { isFleetTarget } from '../_shared/fleet-context.mts'
-import { block, defineHook, editGuard, runHook } from '../_shared/guard.mts'
-import { resolveEditedText } from '../_shared/payload.mts'
-
-export const triggers: readonly string[] = ['.gitmodules', 'lockstep']
-
-// A pin file lives at `.gitmodules` or a lockstep manifest — `lockstep.json`,
-// `.config/repo/lockstep.json` (or legacy `.config/lockstep.json`), or a
-// `lockstep-.json` include. The emitted
-// `lockstep.schema.json` is not a manifest, so the `.schema.json` tail excludes
-// it.
-const GITMODULES_RE = /(?:^|\/)\.gitmodules$/
-// `(?:^|/)` path boundary, `lockstep`, an optional `-` suffix, then a
-// literal `.json` at end — matches the manifests above but not `.schema.json`.
-const LOCKSTEP_RE = /(?:^|\/)lockstep(?:-[a-z0-9-]+)?\.json$/
-
-export function isGitmodulesFile(filePath: string): boolean {
-  return GITMODULES_RE.test(normalizePath(filePath))
-}
-
-export function isLockstepFile(filePath: string): boolean {
-  return LOCKSTEP_RE.test(normalizePath(filePath))
-}
-
-export function isPinFile(filePath: string): boolean {
-  return isGitmodulesFile(filePath) || isLockstepFile(filePath)
-}
-
-// ---------------------------------------------------------------------------
-// Version-tag math. Mirrors the tag resolver in
-// `scripts/fleet/lockstep/auto-bump.mts` — the hook can't import across the
-// bundle boundary, so the small parse/compare core is duplicated here and
-// tested alongside it.
-// ---------------------------------------------------------------------------
-
-export interface TagVersion {
-  major: number
-  minor: number
-  patch: number
-}
-
-export interface ParsedTag {
-  prefix: string
-  version: TagVersion
-}
-
-// Pre-release / nightly suffixes: a stale STABLE pin is the hazard, so
-// pre-releases are never a "newer release" to bump toward.
-const PRERELEASE_RE =
-  /-(?:alpha|beta|dev|nightly|preview|rc|snapshot)(?:[._-]?\d+)?$/iu
-
-export function isStableTag(tag: string): boolean {
-  return !PRERELEASE_RE.test(tag)
-}
-
-// Parse `v1.2.3`, `1.2.3`, `-1.2.3`, `_1_2_3` into
-// { prefix, version }; two-component forms take patch 0. `undefined` when no
-// semver triple is present, so an exotic tag never proposes a bump.
-export function parseVersionTag(tag: string): ParsedTag | undefined {
-  // Underscore scheme (`_1_2_3`): digits joined by underscores.
-  const underscore = /^(.*?)[._-]?(\d+)_(\d+)(?:_(\d+))?$/u.exec(tag)
-  if (underscore && tag.includes('_')) {
-    return {
-      prefix: underscore[1]!.replace(/[._-]$/u, ''),
-      version: {
-        major: Number(underscore[2]),
-        minor: Number(underscore[3]),
-        patch: Number(underscore[4] ?? 0),
-      },
-    }
-  }
-  // Dotted scheme, optionally `v`- or `-` prefixed.
-  const dotted = /^(.*?)(\d+)\.(\d+)(?:\.(\d+))?$/u.exec(tag)
-  if (dotted) {
-    return {
-      prefix: dotted[1]!.replace(/[._-]$/u, '').replace(/^v$/u, ''),
-      version: {
-        major: Number(dotted[2]),
-        minor: Number(dotted[3]),
-        patch: Number(dotted[4] ?? 0),
-      },
-    }
-  }
-  return undefined
-}
-
-export function compareTagVersions(a: TagVersion, b: TagVersion): number {
-  if (a.major !== b.major) {
-    return a.major - b.major
-  }
-  if (a.minor !== b.minor) {
-    return a.minor - b.minor
-  }
-  return a.patch - b.patch
-}
-
-// The newest STABLE tag sharing `pinTag`'s scheme prefix, iff it is strictly
-// newer than `pinTag`. Constrained to the pin's prefix so a `v`-scheme pin never
-// "upgrades" onto a `-` tag from another epoch. `undefined` when the pin
-// is already newest, its scheme is unparseable, or no candidate shares it.
-export function newerReleaseThan(
-  pinTag: string,
-  tagNames: readonly string[],
-): string | undefined {
-  const current = parseVersionTag(pinTag)
-  if (!current) {
-    return undefined
-  }
-  let best: { raw: string; version: TagVersion } | undefined
-  for (let i = 0, { length } = tagNames; i < length; i += 1) {
-    const name = tagNames[i]!
-    if (!isStableTag(name)) {
-      continue
-    }
-    const parsed = parseVersionTag(name)
-    if (!parsed || parsed.prefix !== current.prefix) {
-      continue
-    }
-    if (!best || compareTagVersions(parsed.version, best.version) > 0) {
-      best = { raw: name, version: parsed.version }
-    }
-  }
-  if (best && compareTagVersions(best.version, current.version) > 0) {
-    return best.raw
-  }
-  return undefined
-}
-
-// ---------------------------------------------------------------------------
-// Pin parsing + evaluation.
-// ---------------------------------------------------------------------------
-
-export interface RemoteTag {
-  name: string
-  sha: string
-}
-
-export type ListTags = (url: string) => readonly RemoteTag[]
-
-export interface GitmodulesPin {
-  name: string
-  url: string | undefined
-  ref: string | undefined
-  branch: string | undefined
-}
-
-export interface LockstepPin {
-  id: string
-  upstream: string
-  repo: string | undefined
-  pinnedSha: string | undefined
-  pinnedTag: string | undefined
-}
-
-export interface PinViolation {
-  name: string
-  pinned: string
-  newest: string
-}
-
-// Parse `.gitmodules` blocks: a `[submodule ""]` header opens a block; the
-// fleet's `url`, `ref` pinned commit, and `branch` pinned tag are captured.
-export function parseGitmodulesPins(content: string): GitmodulesPin[] {
-  const pins: GitmodulesPin[] = []
-  let current: GitmodulesPin | undefined
-  const lines = content.split('\n')
-  for (let i = 0, { length } = lines; i < length; i += 1) {
-    const line = lines[i]!.trim()
-    const header = /^\[submodule\s+"([^"]+)"\]$/.exec(line)
-    if (header) {
-      current = {
-        name: header[1]!,
-        url: undefined,
-        ref: undefined,
-        branch: undefined,
-      }
-      pins.push(current)
-      continue
-    }
-    if (!current) {
-      continue
-    }
-    // `key = value` inside a block.
-    const kv = /^([A-Za-z][A-Za-z0-9-]*)\s*=\s*(.+)$/.exec(line)
-    if (!kv) {
-      continue
-    }
-    const key = kv[1]!.toLowerCase()
-    const value = kv[2]!.trim()
-    if (key === 'branch') {
-      current.branch = value
-    } else if (key === 'ref') {
-      current.ref = value
-    } else if (key === 'url') {
-      current.url = value
-    }
-  }
-  return pins
-}
-
-// Parse a lockstep manifest's version-pin rows, resolving each row's repo URL
-// from the top-level `upstreams` map. `resolveEditedText` hands us the full
-// post-edit JSON, so an Edit fragment still parses as a complete document.
-export function parseLockstepPins(content: string): LockstepPin[] {
-  let doc: unknown
-  try {
-    doc = JSON.parse(content)
-  } catch {
-    return []
-  }
-  if (!doc || typeof doc !== 'object') {
-    return []
-  }
-  const obj = doc as {
-    upstreams?: unknown | undefined
-    rows?: unknown | undefined
-  }
-  const upstreams =
-    obj.upstreams && typeof obj.upstreams === 'object'
-      ? (obj.upstreams as Record)
-      : {}
-  const rows = Array.isArray(obj.rows) ? obj.rows : []
-  const pins: LockstepPin[] = []
-  for (let i = 0, { length } = rows; i < length; i += 1) {
-    const row = rows[i] as {
-      kind?: unknown | undefined
-      id?: unknown | undefined
-      upstream?: unknown | undefined
-      pinned_sha?: unknown | undefined
-      pinned_tag?: unknown | undefined
-    }
-    if (!row || row.kind !== 'version-pin') {
-      continue
-    }
-    const upstream = typeof row.upstream === 'string' ? row.upstream : ''
-    const up = upstreams[upstream]
-    pins.push({
-      id: typeof row.id === 'string' ? row.id : '',
-      pinnedSha:
-        typeof row.pinned_sha === 'string' ? row.pinned_sha : undefined,
-      pinnedTag:
-        typeof row.pinned_tag === 'string' ? row.pinned_tag : undefined,
-      repo: up && typeof up.repo === 'string' ? up.repo : undefined,
-      upstream,
-    })
-  }
-  return pins
-}
-
-// Parse `git ls-remote --tags` output into { name, sha } pairs. Peeled entries
-// (`refs/tags/x^{}`) are kept as their own pair with the `^{}` stripped, so a
-// sha-only pin at an annotated tag's COMMIT still resolves to the tag name.
-export function parseLsRemote(out: string): RemoteTag[] {
-  const tags: RemoteTag[] = []
-  const lines = out.split('\n')
-  for (let i = 0, { length } = lines; i < length; i += 1) {
-    // `` hex 7-40, whitespace, `refs/tags/`, then the captured tag name.
-    const m = /^([0-9a-f]{7,40})\s+refs\/tags\/(.+)$/.exec(lines[i]!.trim())
-    if (m) {
-      tags.push({ name: m[2]!.replace(/\^\{\}$/, ''), sha: m[1]! })
-    }
-  }
-  return tags
-}
-
-// The tag name a pinned sha resolves to, matching full or shared-prefix shas.
-function tagForSha(
-  sha: string,
-  tags: readonly RemoteTag[],
-): string | undefined {
-  if (!sha) {
-    return undefined
-  }
-  for (let i = 0, { length } = tags; i < length; i += 1) {
-    const t = tags[i]!
-    if (t.sha === sha || (sha.length >= 7 && t.sha.startsWith(sha))) {
-      return t.name
-    }
-  }
-  return undefined
-}
-
-// The release tag a pin currently resolves to: its explicit tag when set, else
-// the tag its sha points at.
-function pinnedReleaseTag(
-  explicitTag: string | undefined,
-  sha: string | undefined,
-  tags: readonly RemoteTag[],
-): string | undefined {
-  if (explicitTag) {
-    return explicitTag
-  }
-  return sha ? tagForSha(sha, tags) : undefined
-}
-
-export function evaluateGitmodules(
-  before: string,
-  after: string,
-  listTags: ListTags,
-): PinViolation[] {
-  const beforeByName = new Map(
-    parseGitmodulesPins(before).map(p => [p.name, p]),
-  )
-  const violations: PinViolation[] = []
-  const pins = parseGitmodulesPins(after)
-  for (let i = 0, { length } = pins; i < length; i += 1) {
-    const pin = pins[i]!
-    const prev = beforeByName.get(pin.name)
-    const changed = !prev || prev.ref !== pin.ref || prev.branch !== pin.branch
-    if (!changed || !pin.url) {
-      continue
-    }
-    const tags = listTags(pin.url)
-    if (!tags.length) {
-      continue
-    }
-    const pinTag = pinnedReleaseTag(pin.branch, pin.ref, tags)
-    if (!pinTag) {
-      continue
-    }
-    const newest = newerReleaseThan(
-      pinTag,
-      tags.map(t => t.name),
-    )
-    if (newest) {
-      violations.push({ name: pin.name, newest, pinned: pinTag })
-    }
-  }
-  return violations
-}
-
-export function evaluateLockstep(
-  before: string,
-  after: string,
-  listTags: ListTags,
-): PinViolation[] {
-  const beforeById = new Map(parseLockstepPins(before).map(p => [p.id, p]))
-  const violations: PinViolation[] = []
-  const pins = parseLockstepPins(after)
-  for (let i = 0, { length } = pins; i < length; i += 1) {
-    const pin = pins[i]!
-    if (!pin.repo) {
-      continue
-    }
-    const prev = beforeById.get(pin.id)
-    const changed =
-      !prev ||
-      prev.pinnedSha !== pin.pinnedSha ||
-      prev.pinnedTag !== pin.pinnedTag
-    if (!changed) {
-      continue
-    }
-    const tags = listTags(pin.repo)
-    if (!tags.length) {
-      continue
-    }
-    const pinTag = pinnedReleaseTag(pin.pinnedTag, pin.pinnedSha, tags)
-    if (!pinTag) {
-      continue
-    }
-    const newest = newerReleaseThan(
-      pinTag,
-      tags.map(t => t.name),
-    )
-    if (newest) {
-      violations.push({ name: pin.upstream, newest, pinned: pinTag })
-    }
-  }
-  return violations
-}
-
-export function formatBlock(violations: readonly PinViolation[]): string {
-  // One line per stale pin; the fix rides the first line.
-  const lines: string[] = []
-  for (let i = 0, { length } = violations; i < length; i += 1) {
-    const v = violations[i]!
-    lines.push(
-      i === 0
-        ? `🚨 latest-release-pin-guard: stale pin "${v.name} at ${v.pinned}" — ${v.newest} has shipped; \`git fetch --tags\` and pin ${v.newest} (gen/gitmodules-hash.mts --set / lockstep version-pin row, docs/agents.md/fleet/lockstep.md)`
-        : `   "${v.name} at ${v.pinned}" — ${v.newest} has shipped`,
-    )
-  }
-  return lines.join('\n') + '\n'
-}
-
-// The real tag lister: query the remote directly, so a not-yet-cloned submodule
-// still resolves. NETWORK spawn — a fixed timeout, never platform-scaled (see
-// _shared/spawn-timeout.mts). Any failure returns [] and the guard fails open.
-export function listRemoteTags(url: string): RemoteTag[] {
-  try {
-    const result = spawnSync('git', ['ls-remote', '--tags', url], {
-      stdio: ['ignore', 'pipe', 'ignore'],
-      stdioString: true,
-      timeout: 15_000,
-    })
-    if (result.status !== 0 || typeof result.stdout !== 'string') {
-      return []
-    }
-    return parseLsRemote(result.stdout)
-  } catch {
-    return []
-  }
-}
-
-export const check = editGuard((filePath, _content, payload) => {
-  if (!isPinFile(filePath) || !isFleetTarget(payload)) {
-    return undefined
-  }
-  const after = resolveEditedText(payload)
-  if (after === undefined) {
-    return undefined
-  }
-  const before = safeReadFileSync(filePath) ?? ''
-  const violations = isGitmodulesFile(filePath)
-    ? evaluateGitmodules(before, after, listRemoteTags)
-    : evaluateLockstep(before, after, listRemoteTags)
-  if (!violations.length) {
-    return undefined
-  }
-  return block(formatBlock(violations))
-})
-
-export const hook = defineHook({
-  bypass: ['latest-release-pin'],
-  check,
-  event: 'PreToolUse',
-  matcher: ['Edit', 'MultiEdit', 'Write'],
-  triggers,
-  type: 'guard',
-})
-void runHook(hook, import.meta.url)
diff --git a/.claude/hooks/fleet/link-protocol-dep-guard/README.md b/.claude/hooks/fleet/link-protocol-dep-guard/README.md
deleted file mode 100644
index 468555b..0000000
--- a/.claude/hooks/fleet/link-protocol-dep-guard/README.md
+++ /dev/null
@@ -1,127 +0,0 @@
-# link-protocol-dep-guard
-
-PreToolUse Edit/Write hook that blocks adding an unpinned dependency
-spec to any dependency block of a `package.json`. Note: an unpinned
-spec is a `link:`/`file:` local path, or a `workspace:` range.
-
-## Why
-
-**A `link:` means a package needs publishing.** The spec points a
-dependency at a local path, which carries no registry identity and no
-integrity hash. The install resolves to whatever happens to sit at that
-path on the machine running it, and on a fresh clone — CI, a new
-contributor, a release runner — to nothing at all. The reason the path
-is there is almost always that the package it names is **unpublished**,
-so the fix is release work: reserve the name and wire trusted publishing
-(`scripts/fleet/publish-infra/{npm,cargo}/placeholder.mts`,
-`cargo/trusted-publisher.mts`), then depend on the published version.
-
-A `workspace:` range has the same floating problem inside the repo:
-`workspace:*` and `workspace:^1.2.3` resolve to whichever sibling
-version the tree happens to carry, and pnpm expands the range at publish
-time into a range every consumer inherits.
-
-The fleet's preference order among the pinned forms is **`catalog:` >
-exact `1.2.3` > `workspace:1.2.3`** — a catalog entry pins just as hard
-and one central bump upgrades every repo, where the other two cost a
-manifest bump per dependent on each release. Full order, the blocked
-forms, and the `peerDependencies` exemption:
-[`dependency-spec-pinning`](../../../../docs/agents.md/fleet/dependency-spec-pinning.md).
-
-The worked example for the rarer glob case is decmpfs: five `link:`
-entries shipped in the committed `pnpm-lock.yaml` pointing at
-`napi/decmpfs/npm//`, which are **gitignored generated output**.
-The lockfile depended on artifacts absent from a fresh clone, and it
-went unnoticed for weeks.
-
-## What it blocks
-
-An Edit/Write to a `package.json` that ADDS (or changes the value of) a
-`link:`/`file:` spec or a `workspace:` range in any of:
-
-    dependencies
-    devDependencies
-    optionalDependencies
-    peerDependencies
-    overrides            (walked recursively — overrides nest)
-    resolutions          (walked recursively)
-    pnpm.overrides       (walked recursively)
-
-An existing spec left untouched by the edit does not block; the hook
-diffs before-vs-after and reports only what the edit introduces.
-
-## What it does NOT block
-
-- `catalog:` — PREFERRED. A catalog entry is itself exact-pinned in
-  `.config/fleet/pnpm-workspace.fleet.yaml`, so `catalog:` pins as hard
-  as a literal version while keeping one central bump site.
-- An exact registry version (`1.2.3`, `npm:alias@1.2.3`).
-- `workspace:1.2.3` — legal, and a FALLBACK rather than a destination.
-  The check reports these under a "prefer `catalog:`" heading as the
-  conversion backlog; it never fails on them, because a repo whose
-  sibling is unpublished has nowhere else to go.
-- A git or tarball URL.
-- A bare registry range (`^1.2.3`, `>=5.0.0`). These are **classified**
-  as `registry-range` and reported by the companion check, but not
-  blocked while the fleet's remaining bare ranges convert.
-  `isBlockingSpecKind` in `_shared/dependency-spec-forms.mts` is the
-  seam that flips it on. `peerDependencies` stay exempt permanently: a
-  peer range states the span of host versions a package supports.
-
-## The gap this hook cannot close
-
-In decmpfs the `link:` specs were **never hand-written**. pnpm generated
-them: `pnpm-workspace.yaml` declared a `packages:` glob
-(`napi/decmpfs/npm/*`) over generated directories, and with
-`linkWorkspacePackages: true` pnpm resolved the matching
-`optionalDependencies` to `link:` specs in the lockfile. No manifest
-edit happened, so no edit-time guard could ever have fired.
-
-That case is caught by the companion commit-time gate,
-`scripts/fleet/check/dependency-specs-are-registry-or-workspace.mts`,
-which scans the committed `pnpm-lock.yaml` and fails when a `link:`
-target is not a git-tracked directory. The two tiers together are the
-rule: this hook stops the hand-written spec, the check stops the
-generated one.
-
-## Bypass
-
-Type the canonical phrase in a new message:
-
-    Allow link-protocol-dep bypass
-
-Legitimate case: a throwaway local reproduction, or a test fixture whose
-whole point is to exercise local-path resolution. Neither belongs in a
-committed manifest without a reason stated in review.
-
-## Detection
-
-Parses before+after JSON, classifies every spec through the shared
-`_shared/dependency-spec-forms.mts` module (the same classifier the
-check uses, so the two tiers cannot drift), keys each finding
-`.`, and reports the set difference. Fails open
-on JSON parse errors.
-
-## Fix
-
-```jsonc
-// package.json
-
-{
-  "dependencies": {
-    // PREFERRED — published package, centrally pinned in the fleet
-    // catalog (.config/fleet/pnpm-workspace.fleet.yaml):
-    "typescript": "catalog:",
-    // Published package that does not belong in the fleet-wide catalog:
-    "defu": "6.1.6",
-    // FALLBACK — an in-repo package that genuinely cannot be published.
-    // List its directory under `packages:` in pnpm-workspace.yaml, then
-    // pin it exactly. Publish it and move to `catalog:` when you can:
-    "my-in-repo-pkg": "workspace:1.2.3",
-  },
-}
-```
-
-Generated per-platform output (a napi `npm//` directory) is not
-a workspace member. Keep its glob out of `packages:` and let the publish
-engine find those packages by convention instead.
diff --git a/.claude/hooks/fleet/link-protocol-dep-guard/index.mts b/.claude/hooks/fleet/link-protocol-dep-guard/index.mts
deleted file mode 100644
index 88bd08d..0000000
--- a/.claude/hooks/fleet/link-protocol-dep-guard/index.mts
+++ /dev/null
@@ -1,142 +0,0 @@
-#!/usr/bin/env node
-// Claude Code PreToolUse hook — link-protocol-dep-guard.
-//
-// Blocks Edit/Write to a `package.json` that adds an unpinned dependency
-// spec to any dependency block — `dependencies`, `devDependencies`,
-// `optionalDependencies`, `peerDependencies`, `overrides`, `resolutions`,
-// or `pnpm.overrides`.
-//
-// Two blocking classes, from `_shared/dependency-spec-forms.mts`:
-//
-//   local-path      `link:` / `file:`. The dependency resolves to a
-//                   directory on the installing machine, which means the
-//                   package it names is UNPUBLISHED. Publishing it and
-//                   routing it through the fleet catalog is the fix;
-//                   narrowing a `packages:` glob is the rarer one.
-//   workspace-range `workspace:*` / `workspace:^1.2.3`. A range floats.
-//
-// The fleet preference order among the pinned forms is `catalog:` > exact
-// `1.2.3` > `workspace:1.2.3`, because one central catalog bump beats a
-// manifest bump per dependent on every sibling release. Full rationale:
-// `docs/agents.md/fleet/dependency-spec-pinning.md`.
-//
-// Two kinds are classified but never blocked here: `registry-range` (a bare
-// `^1.2.3`, staged while the fleet's remaining ranges convert) and
-// `workspace-pin` (`workspace:1.2.3` — legal, reported by the check as the
-// `catalog:` conversion backlog). `isBlockingSpecKind` is the seam.
-//
-// Companion gate: `scripts/fleet/check/dependency-specs-are-registry-or-
-// workspace.mts` catches the specs this hook cannot see — the ones pnpm
-// GENERATES into `pnpm-lock.yaml` when a `packages:` glob in
-// `pnpm-workspace.yaml` covers generated/gitignored directories. No
-// manifest edit happens in that flow, so an edit-time guard alone would
-// never fire.
-//
-// Bypass: `Allow link-protocol-dep bypass` typed verbatim.
-//
-// Fails open on JSON parse errors.
-
-import path from 'node:path'
-
-import { safeReadFileSync } from '@socketsecurity/lib-stable/fs/read-file'
-
-import {
-  collectDependencySpecFindings,
-  dependencySpecFindingKey,
-  isBlockingSpecKind,
-} from '../_shared/dependency-spec-forms.mts'
-import { block, defineHook, editGuard, runHook } from '../_shared/guard.mts'
-import { resolveEditedText } from '../_shared/payload.mts'
-
-import type { DependencySpecFinding } from '../_shared/dependency-spec-forms.mts'
-
-export {
-  isBlockingSpecKind,
-  isRegistryRangeSpec,
-  isWorkspaceRangeSpec,
-  localPathProtocol,
-} from '../_shared/dependency-spec-forms.mts'
-
-export function isPackageJson(filePath: string): boolean {
-  return path.basename(filePath) === 'package.json'
-}
-
-// Every out-of-contract spec in a package.json's dependency surface, keyed
-// `.` so a caller can diff before-vs-after and report
-// only what an edit ADDS.
-export function collectDependencySpecMap(
-  text: string,
-): Map {
-  const out = new Map()
-  const findings = collectDependencySpecFindings(text)
-  for (let i = 0, { length } = findings; i < length; i += 1) {
-    const finding = findings[i]!
-    out.set(dependencySpecFindingKey(finding), finding)
-  }
-  return out
-}
-
-export function describeFindings(
-  findings: readonly DependencySpecFinding[],
-): string {
-  return findings
-    .map(finding => `${finding.field}.${finding.name} "${finding.value}"`)
-    .join(', ')
-}
-
-export const check = editGuard((filePath, _content, payload) => {
-  if (!isPackageJson(filePath)) {
-    return undefined
-  }
-
-  const afterText = resolveEditedText(payload)
-  if (afterText === undefined) {
-    return undefined
-  }
-  const currentText = safeReadFileSync(filePath) ?? '{}'
-
-  const beforeSpecs = collectDependencySpecMap(currentText)
-  const afterSpecs = collectDependencySpecMap(afterText)
-
-  const added: DependencySpecFinding[] = []
-  for (const [key, finding] of afterSpecs) {
-    if (!isBlockingSpecKind(finding.kind)) {
-      continue
-    }
-    const before = beforeSpecs.get(key)
-    if (before === undefined || before.value !== finding.value) {
-      added.push(finding)
-    }
-  }
-  if (added.length === 0) {
-    return undefined
-  }
-
-  const localPath = added.filter(finding => finding.kind === 'local-path')
-
-  // Priority order among the pinned forms: `catalog:` (one central bump)
-  // > exact `1.2.3` > `workspace:1.2.3` as the last resort.
-  const reason =
-    localPath.length > 0
-      ? 'a `link:`/`file:` spec means the package is UNPUBLISHED — publish it ' +
-        '(node scripts/fleet/publish-infra/npm/placeholder.mts), then pin via ' +
-        '`catalog:` (preferred) > exact "1.2.3" > `workspace:1.2.3` (last ' +
-        'resort); a generated dir swept by a `packages:` glob instead needs ' +
-        'the glob narrowed'
-      : 'a `workspace:` RANGE floats — pin via `catalog:` (preferred) > ' +
-        'exact "1.2.3" > `workspace:1.2.3` (last resort)'
-  return block(
-    `🚨 link-protocol-dep-guard: blocked ${describeFindings(added)} in ` +
-      `${filePath} — ${reason}.`,
-  )
-})
-
-export const hook = defineHook({
-  bypass: ['link-protocol-dep'],
-  check,
-  event: 'PreToolUse',
-  matcher: ['Edit', 'Write', 'MultiEdit'],
-  type: 'guard',
-})
-
-void runHook(hook, import.meta.url)
diff --git a/.claude/hooks/fleet/live-edit-collision-guard/index.mts b/.claude/hooks/fleet/live-edit-collision-guard/index.mts
deleted file mode 100644
index 25805ab..0000000
--- a/.claude/hooks/fleet/live-edit-collision-guard/index.mts
+++ /dev/null
@@ -1,199 +0,0 @@
-/*
- * @file Claude Code PreToolUse hook — live-edit-collision-guard.
- *
- * Blocks an Edit / Write / NotebookEdit operation when the target path was
- * written by a DIFFERENT live actor within the last 5 minutes. "Live" means
- * the other actor's ledger file has an updatedAt within the 15-minute TTL.
- *
- * Problem observed live (#239): while a background workflow edited extension
- * src files, the interactive session blind-edited the same files (survived
- * by luck) and was then blocked for three consecutive turns by
- * dirty-worktree-stop-guard because the dirty paths belonged to the live run.
- * The collision is better caught HERE, before the write lands.
- *
- * Actor key: sha256(transcript_path).slice(0,16). The transcript_path
- * discriminates SEPARATE interactive sessions. It does NOT discriminate a
- * spawned subagent from its parent — Claude Code delivers the PARENT
- * session's transcript_path to hooks even for a subagent's writes, so a
- * subagent's edits collapse into the parent actor's ledger (the stop guard
- * detects live children from their own transcript files instead). See the
- * computeActorId note in _shared/active-edits-ledger.mts.
- *
- * Block message shape: What / Where / Saw-vs-wanted / Fix — three sanctioned
- * moves:
- *   (a) stop the other run first (TaskStop, then resume via its journal),
- *   (b) queue the edit for after the run lands,
- *   (c) user types `Allow live-edit-collision bypass` verbatim.
- *
- * Fail-open: any IO / parse error falls through, no block, per the fleet's
- * hook contract — "a buggy hook silently allows" beats "a buggy hook wedges
- * the session."
- */
-
-import path from 'node:path'
-import process from 'node:process'
-
-import {
-  COLLISION_WINDOW_MS,
-  computeActorId,
-  isActorLive,
-  LEDGER_TTL_MS,
-  ledgerFilePath,
-  listOtherActorLedgerPaths,
-  lookupPath,
-  normalizeForLedger,
-  pruneLedger,
-  readActorLedger,
-  resolveStoreRoot,
-} from '../_shared/active-edits-ledger.mts'
-import { block, defineHook, runHook } from '../_shared/guard.mts'
-import { readFilePath } from '../_shared/payload.mts'
-import type { ToolCallPayload } from '../_shared/payload.mts'
-import type { GuardResult } from '../_shared/guard.mts'
-
-function getProjectDir(): string {
-  // oxlint-disable-next-line socket/no-process-cwd-in-scripts-hooks -- reads the agent-provided CLAUDE_PROJECT_DIR first; process.cwd() is only the fallback when that env var is absent
-  return process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd()
-}
-
-/**
- * Pure decision core — injectable clock, no disk IO. Returns the collision
- * result when the target path was written by a live foreign actor within the
- * collision window; `undefined` (allow) otherwise.
- *
- * Factored out so tests can call it directly without real filesystem or
- * transcript IO.
- */
-export interface CollisionResult {
-  readonly otherActorId: string
-  readonly secondsAgo: number
-}
-
-export function detectCollision(
-  ownActorId: string,
-  normalizedPath: string,
-  otherLedgerPaths: readonly string[],
-  config: {
-    now: number
-    collisionWindowMs: number
-    ttlMs: number
-    // Own actor's last-write timestamp for this path, if any. When defined and
-    // strictly newer than a foreign write, the foreign write is ignored — we
-    // already re-claimed the file after the foreign actor wrote it.
-    ownWriteTs?: number | undefined
-  },
-): CollisionResult | undefined {
-  const { now, collisionWindowMs, ttlMs, ownWriteTs } = {
-    __proto__: null,
-    ...config,
-  } as typeof config
-  for (let i = 0, { length } = otherLedgerPaths; i < length; i += 1) {
-    const fp = otherLedgerPaths[i]!
-    const raw = readActorLedger(fp)
-    if (!raw) {
-      continue
-    }
-    if (raw.actorId === ownActorId) {
-      continue
-    }
-    if (!isActorLive(raw, { now, ttlMs })) {
-      continue
-    }
-    const ledger = pruneLedger(raw, { now, ttlMs })
-    if (!ledger) {
-      continue
-    }
-    const lastWrite = lookupPath(ledger, normalizedPath)
-    if (lastWrite === undefined) {
-      continue
-    }
-    if (now - lastWrite > collisionWindowMs) {
-      continue
-    }
-    // Own actor wrote this file MORE RECENTLY than the foreign actor → we
-    // re-claimed it; the foreign write is superseded. Allow the re-edit.
-    if (ownWriteTs !== undefined && ownWriteTs >= lastWrite) {
-      continue
-    }
-    return {
-      otherActorId: raw.actorId,
-      secondsAgo: Math.round((now - lastWrite) / 1000),
-    }
-  }
-  return undefined
-}
-
-export function check(payload: ToolCallPayload): GuardResult {
-  const tool = payload?.tool_name
-  if (tool !== 'Edit' && tool !== 'NotebookEdit' && tool !== 'Write') {
-    return undefined
-  }
-
-  const filePath = readFilePath(payload)
-  if (!filePath) {
-    return undefined
-  }
-
-  const ownActorId = computeActorId(payload.transcript_path)
-  if (!ownActorId) {
-    return undefined
-  }
-
-  const projectDir = getProjectDir()
-  const storeRoot = resolveStoreRoot(projectDir)
-  const absPath = path.resolve(projectDir, filePath)
-  const normalizedPath = normalizeForLedger(absPath)
-
-  // Fail-open: if own ledger lookup is unavailable, skip any self-check.
-  const ownFp = ledgerFilePath(storeRoot, ownActorId)
-  const ownLedger = readActorLedger(ownFp)
-  const now = Date.now()
-  // Our own write timestamp for this path — used to determine recency vs foreign.
-  const ownWrite = ownLedger ? lookupPath(ownLedger, normalizedPath) : undefined
-
-  const otherPaths = listOtherActorLedgerPaths(storeRoot, ownActorId)
-  if (!otherPaths.length) {
-    return undefined
-  }
-
-  const collision = detectCollision(ownActorId, normalizedPath, otherPaths, {
-    now,
-    collisionWindowMs: COLLISION_WINDOW_MS,
-    ttlMs: LEDGER_TTL_MS,
-    ownWriteTs: ownWrite,
-  })
-  if (!collision) {
-    return undefined
-  }
-
-  const shortPath = path.relative(projectDir, absPath)
-  return block(
-    [
-      `🚨 live-edit-collision-guard: another live session last wrote this path`,
-      `${collision.secondsAgo}s ago — editing now risks a blind overwrite.`,
-      ``,
-      `File:         ${shortPath}`,
-      `Other actor:  ${collision.otherActorId}`,
-      `Last write:   ${collision.secondsAgo}s ago (within ${COLLISION_WINDOW_MS / 1000 / 60}-min collision window)`,
-      ``,
-      `Sanctioned moves:`,
-      `  (a) Stop the other run first — use TaskStop, then resume via its`,
-      `      journal (.claude/plans/...) once it has landed.`,
-      `  (b) Queue this edit for after the other run completes; work on a`,
-      `      different file in the meantime.`,
-      `  (c) The other run is already finished or abandoned — the user`,
-      `      supplies the bypass phrase to proceed.`,
-      ``,
-    ].join('\n'),
-  )
-}
-
-export const hook = defineHook({
-  bypass: ['live-edit-collision'],
-  check,
-  event: 'PreToolUse',
-  matcher: ['Edit', 'NotebookEdit', 'Write'],
-  type: 'guard',
-})
-
-void runHook(hook, import.meta.url)
diff --git a/.claude/hooks/fleet/lock-step-ref-nudge/README.md b/.claude/hooks/fleet/lock-step-ref-nudge/README.md
deleted file mode 100644
index 02964a1..0000000
--- a/.claude/hooks/fleet/lock-step-ref-nudge/README.md
+++ /dev/null
@@ -1,62 +0,0 @@
-# lock-step-ref-nudge
-
-PreToolUse hook (informational; never blocks) that flags malformed and stale `Lock-step` comments at the moment they land in a file.
-
-## Why
-
-Per CLAUDE.md's _Code style → Cross-port files_ rule, files that ship in multiple language implementations use a `Lock-step` comment convention to cross-reference the canonical impl. The full forms live in [`docs/agents.md/fleet/parser-comments.md`](../../../docs/agents.md/fleet/parser-comments.md) §5–6.
-
-The CI gate (`scripts/fleet/check/lock-step-refs-resolve.mts`) catches stale `` references at commit time, but two classes of bugs slip past it:
-
-1. **Typos in the `Lock-step` shape itself** — `lockstep`, `Lock step`, `Lock-step Rust:` (missing `with`/`from`), `Lock-step with: ` (missing ``). The CI regex doesn't match these, so they silently rot forever as illegitimate comments.
-2. **Same-keystroke staleness** — a porter typing `// Lock-step with Rust: crates/parser-stmt/src/foo.rs` after `parser-stmt/` was renamed last week. The CI gate catches it at commit; the hook catches it at the keystroke so the porter sees the breadcrumb before committing.
-
-## What it catches
-
-**Malformed:**
-
-```rust
-// lockstep with Go: parser.go:42         // wrong: hyphen missing
-// Lock step with Go: parser.go:42        // wrong: hyphen missing
-// Lock-step Rust: src/foo.rs             // wrong: missing with/from
-// Lock-step with: src/foo.rs             // wrong: missing 
-// Lock-step with Go, parser.go           // wrong: comma instead of colon
-```
-
-**Stale (when `.config/lock-step-refs.json` is present):**
-
-```rust
-// Lock-step with Rust: crates/parser-stmt/src/foo.rs   // crate doesn't exist
-//! Lock-step from Go: src/parser-old/class.go          // dir was renamed
-```
-
-**Accepted:**
-
-```rust
-//! Lock-step with Go: src/parser/class.go
-//! Lock-step from Rust: crates/parser/src/class.rs
-// Lock-step with Go: parser.go:6450-6457
-// Lock-step note: reshaped for borrowck — Zig's `defer s.restore()` ...
-```
-
-## Scope
-
-- Source-file extensions: `.rs`, `.go`, `.cpp`, `.hpp`, `.h`, `.ts`, `.mts`, `.cts`, `.tsx`, `.py`, `.zig`, `.js`, `.mjs`, `.cjs`, `.jsx`.
-- Skips `test/` directories and `*.test.*` files — illustrative example refs are common in tests and don't represent real port-tracking claims.
-- Stale-path checking is **opt-in per repo**: requires `.config/lock-step-refs.json` to declare `` → impl-root mappings. Without the config, only malformed-shape detection runs.
-- Malformed-shape detection always runs, regardless of opt-in. Typos are typos.
-
-## Behavior
-
-- Exit code 0 in all cases. Hook is informational; the next turn sees the stderr breadcrumb and can fix.
-- The blocking layer is the CI gate `scripts/fleet/check/lock-step-refs-resolve.mts`, run by `pnpm check`.
-
-## Bypass
-
-- Type `Allow lock-step bypass` in a recent user message (also accepts `Allow lockstep bypass` / `Allow lock step bypass`).
-
-## Test
-
-```sh
-pnpm test
-```
diff --git a/.claude/hooks/fleet/lock-step-ref-nudge/index.mts b/.claude/hooks/fleet/lock-step-ref-nudge/index.mts
deleted file mode 100644
index 799f57d..0000000
--- a/.claude/hooks/fleet/lock-step-ref-nudge/index.mts
+++ /dev/null
@@ -1,358 +0,0 @@
-#!/usr/bin/env node
-// Claude Code PreToolUse hook — lock-step-ref-nudge.
-//
-// renamed-from: lock-step-ref-guard
-//
-// Flags two failure modes in `Lock-step` comments at the moment they
-// land in a file, before they reach CI (which is gated separately by
-// `scripts/fleet/check/lock-step-refs-resolve.mts`):
-//
-//   1. STALE — the comment names a path that no longer exists in the
-//      target impl. The CI gate also catches this; the hook catches it
-//      one keystroke earlier so the porter can fix as they type.
-//   2. MALFORMED — the comment uses an almost-right shape (`lockstep`,
-//      `Lock step`, `Lock-step Go:` missing `with`/`from`, missing the
-//      `: ` separator). These wouldn't be matched by the
-//      CI scanner at all — they'd silently rot forever. The hook is
-//      the only place that catches the typo class.
-//
-// Convention spec: `docs/agents.md/fleet/parser-comments.md` §5–6.
-// Recognized forms:
-//
-//   //! Lock-step with :                canonical side
-//   //! Lock-step from :                port side
-//   // Lock-step with : [:], inline cross-ref
-//   // Lock-step note:                    (rationale; not validated)
-//
-// Behavior:
-//   - Never blocks. Hook is informational; the breadcrumb in stderr is
-//     the next-turn nudge. The blocking layer is the CI gate in
-//     `pnpm check`.
-//   - Opt-in per repo: when `.config/repo/lock-step-refs.json` is absent,
-//     STALE checks are skipped (the gate is disabled at the repo
-//     level). MALFORMED checks always run — they detect typos
-//     regardless of whether the repo has opted into validation.
-//   - Only fires for the new content the edit introduces. Comments
-//     that were already in the file but unchanged aren't re-flagged.
-//
-// Scope:
-//   - Source-file extensions: .rs, .go, .cpp, .hpp, .h, .ts, .mts,
-//     .cts, .tsx, .py, .zig, .js, .mjs, .cjs, .jsx.
-//   - Skips test/ directories and *.test.* files — illustrative
-//     example refs are common in tests.
-//
-// Bypass: type `Allow lock-step bypass` in a recent user message.
-
-import { existsSync, readFileSync } from 'node:fs'
-import path from 'node:path'
-
-import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize'
-
-import { defineHook, editGuard, notify, runHook } from '../_shared/guard.mts'
-import { resolveProjectDir } from '../_shared/project-dir.mts'
-
-interface LockStepConfig {
-  readonly roots: Readonly>
-  readonly scan: readonly string[]
-  readonly extensions: readonly string[]
-}
-
-const SOURCE_EXT_RE =
-  /\.(?:cjs|cpp|cts|go|h|hh|hpp|js|jsx|mjs|mts|py|rs|ts|tsx|zig)$/
-
-// Canonical form: `Lock-step (with|from) : [:]`.
-// Path must contain `.` or `/` so prose like "Lock-step with Go: JSON
-// parser" doesn't false-positive.
-const CANONICAL_RE =
-  /Lock-step (?
from|with) (?[A-Za-z][A-Za-z0-9+#-]*): (?[^\s:,]*[./][^\s:,]*)(?::(?:\d+(?:-\d+)?))?/g - -// Note form is rationale-only; we accept it but don't validate. -const NOTE_RE = /Lock-step note:/ - -// Common typos / near-misses we catch as MALFORMED. Each pattern is a -// shape that LOOKS like a lock-step comment but isn't quite right. -// -// 1. Lowercased / unhyphenated: `lockstep`, `lock step`, `Lockstep`. -// 2. Missing `with`/`from`/`note` discriminator: `Lock-step Rust: …`. -// 3. Hyphen-in-Lang gone wrong: `Lock-step with: …`, no lang. -// 4. Comma instead of colon: `Lock-step with Rust, src/foo.rs`. -const MALFORMED_PATTERNS: ReadonlyArray<{ - readonly re: RegExp - readonly hint: string -}> = [ - { - re: /\blockstep\b/i, - hint: - 'spell it "Lock-step" with a hyphen — the canonical form ' + - 'matches `grep -r "Lock-step"`', - }, - { - re: /\bLock[ _]step\b/, - hint: - 'use a hyphen — write "Lock-step" not "Lock step" or "Lock_step" ' + - 'so the audit grep is uniform', - }, - { - // "Lock-step" followed by an uppercase letter that isn't the start of an - // allowed discriminator keyword, from, note, with — catches bare "Lock-step Go:" - // or similar missing-keyword forms. - re: /Lock-step (?!(?:from|note|with)\b)[A-Z]/, - hint: - 'missing discriminator — write "Lock-step with :" or ' + - '"Lock-step from :" or "Lock-step note:"', - }, - { - re: /Lock-step (?:from|with) :/, - hint: - 'missing token — write "Lock-step with Go: " ' + - 'not "Lock-step with : "', - }, - { - re: /Lock-step (?:from|with) [A-Za-z][A-Za-z0-9+#-]*,\s/, - hint: - 'use ":" between and , not "," — ' + - '"Lock-step with Go: parser.go" not "Lock-step with Go, parser.go"', - }, -] - -export function checkStale( - refs: readonly MatchedRef[], - config: LockStepConfig, - repoRoot: string, -): StaleHit[] { - const hits: StaleHit[] = [] - for (let i = 0, { length } = refs; i < length; i += 1) { - const ref = refs[i]! - const roots = config.roots[ref.lang] - if (!roots || !roots.length) { - hits.push({ - lineNumber: ref.lineNumber, - preview: ref.preview, - reason: 'unknown-lang', - lang: ref.lang, - refPath: ref.refPath, - }) - continue - } - let found = false - if (existsSync(path.join(repoRoot, ref.refPath))) { - found = true - } else { - for (let r = 0, { length: rLen } = roots; r < rLen; r += 1) { - if (existsSync(path.join(repoRoot, roots[r]!, ref.refPath))) { - found = true - break - } - } - } - if (!found) { - hits.push({ - lineNumber: ref.lineNumber, - preview: ref.preview, - reason: 'path-not-found', - lang: ref.lang, - refPath: ref.refPath, - }) - } - } - return hits -} - -interface MatchedRef { - readonly form: 'with' | 'from' - readonly lang: string - readonly refPath: string - readonly lineNumber: number - readonly preview: string -} - -interface MalformedHit { - readonly lineNumber: number - readonly preview: string - readonly hint: string -} - -interface StaleHit { - readonly lineNumber: number - readonly preview: string - readonly reason: 'unknown-lang' | 'path-not-found' - readonly lang: string - readonly refPath: string -} - -export function findCanonicalRefs(content: string): MatchedRef[] { - const hits: MatchedRef[] = [] - const lines = content.split('\n') - for (let i = 0, { length } = lines; i < length; i += 1) { - const line = lines[i]! - CANONICAL_RE.lastIndex = 0 - let match: RegExpExecArray | null - while ((match = CANONICAL_RE.exec(line)) !== null) { - hits.push({ - form: match.groups!['form'] as 'with' | 'from', - lang: match.groups!['lang']!, - refPath: match.groups!['refPath']!, - lineNumber: i + 1, - preview: line.trim().slice(0, 100), - }) - } - } - return hits -} - -export function findMalformed( - content: string, - canonical: readonly MatchedRef[], - noteLines: ReadonlySet, -): MalformedHit[] { - const canonicalLines = new Set(canonical.map(h => h.lineNumber)) - const hits: MalformedHit[] = [] - const lines = content.split('\n') - for (let i = 0, { length } = lines; i < length; i += 1) { - const lineNumber = i + 1 - // If a line already contains a canonical ref or a Lock-step note, - // don't also flag it as malformed. Heuristic: a single line can - // have BOTH a canonical ref and a typo elsewhere, but in practice - // the typos we catch are alternative spellings on the SAME phrase - // — flagging both would be noise. - if (canonicalLines.has(lineNumber) || noteLines.has(lineNumber)) { - continue - } - const line = lines[i]! - for (let p = 0, { length: pLen } = MALFORMED_PATTERNS; p < pLen; p += 1) { - const { re, hint } = MALFORMED_PATTERNS[p]! - if (re.test(line)) { - hits.push({ - lineNumber, - preview: line.trim().slice(0, 100), - hint, - }) - break - } - } - } - return hits -} - -export function findNoteLines(content: string): Set { - const out = new Set() - const lines = content.split('\n') - for (let i = 0, { length } = lines; i < length; i += 1) { - if (NOTE_RE.test(lines[i]!)) { - out.add(i + 1) - } - } - return out -} - -function readJsonObject(file: string): Record | undefined { - let raw: string - try { - raw = readFileSync(file, 'utf8') - } catch { - return undefined - } - try { - const parsed = JSON.parse(raw) as unknown - return parsed && typeof parsed === 'object' - ? (parsed as Record) - : undefined - } catch { - // Malformed — the CI gate reports it; the hook stays silent. - return undefined - } -} - -function asLockStepConfig(value: unknown): LockStepConfig | undefined { - return value && - typeof value === 'object' && - 'roots' in value && - 'scan' in value && - 'extensions' in value - ? (value as LockStepConfig) - : undefined -} - -export function loadConfig(repoRoot: string): LockStepConfig | undefined { - // Per-repo config lives in ONE member-owned file (no-new-config-guard): the - // `lockstep` section of `.config/repo/socket-wheelhouse.json`. - const settings = path.join( - repoRoot, - '.config', - 'repo', - 'socket-wheelhouse.json', - ) - if (!existsSync(settings)) { - return undefined - } - return asLockStepConfig(readJsonObject(settings)?.['lockstep']) -} - -export const check = editGuard((filePath, content, payload) => { - const normalizedFilePath = normalizePath(filePath) - if (!SOURCE_EXT_RE.test(normalizedFilePath)) { - return undefined - } - // Skip tests — illustrative example refs are common. - if ( - /(^|\/)test\//.test(normalizedFilePath) || - /\.test\.[a-z]+$/.test(normalizedFilePath) - ) { - return undefined - } - if (!content) { - return undefined - } - const refs = findCanonicalRefs(content) - const noteLines = findNoteLines(content) - const malformed = findMalformed(content, refs, noteLines) - - const repoRoot = resolveProjectDir(payload.cwd) - const config = loadConfig(repoRoot) - const stale = config ? checkStale(refs, config, repoRoot) : [] - - if (malformed.length === 0 && stale.length === 0) { - return undefined - } - - const out: string[] = [`[lock-step-ref-nudge] ${filePath}:`, ''] - if (malformed.length > 0) { - out.push(' Malformed Lock-step comment(s) — fix the shape:') - for (let i = 0, { length } = malformed; i < length; i += 1) { - const h = malformed[i]! - out.push(` • line ${h.lineNumber}: "${h.preview}"`) - out.push(` → ${h.hint}`) - } - out.push('') - } - if (stale.length > 0) { - out.push(' Stale Lock-step reference(s) — fix or remove:') - for (let i = 0, { length } = stale; i < length; i += 1) { - const h = stale[i]! - const tag = - h.reason === 'unknown-lang' - ? `unknown "${h.lang}" (add to .config/repo/lock-step-refs.json roots)` - : `path not found: ${h.refPath}` - out.push(` • line ${h.lineNumber}: ${tag}`) - out.push(` "${h.preview}"`) - } - out.push('') - } - out.push(' Spec: docs/agents.md/fleet/parser-comments.md §5–6.') - out.push( - ' CI gate: scripts/fleet/check/lock-step-refs-resolve.mts (run via `pnpm check`).', - ) - out.push('') - return notify(out.join('\n')) -}) - -export const hook = defineHook({ - bypass: ['lock-step'], - bypassOptional: true, - check, - event: 'PreToolUse', - matcher: ['Edit', 'Write', 'MultiEdit'], - scope: 'convention', - type: 'nudge', -}) -void runHook(hook, import.meta.url) diff --git a/.claude/hooks/fleet/lock-step-ref-nudge/package.json b/.claude/hooks/fleet/lock-step-ref-nudge/package.json deleted file mode 100644 index 68a9272..0000000 --- a/.claude/hooks/fleet/lock-step-ref-nudge/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "hook-lock-step-ref-nudge", - "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/lock-step-ref-nudge/tsconfig.json b/.claude/hooks/fleet/lock-step-ref-nudge/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/lock-step-ref-nudge/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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/logger-guard/README.md b/.claude/hooks/fleet/logger-guard/README.md deleted file mode 100644 index 198fc92..0000000 --- a/.claude/hooks/fleet/logger-guard/README.md +++ /dev/null @@ -1,104 +0,0 @@ -# logger-guard - -A **Claude Code hook** that runs before `Edit` or `Write` tool calls -on TypeScript source files and **blocks** edits that introduce direct -stream writes — `process.stderr.write`, `process.stdout.write`, -`console.log` / `error` / `warn` / `info` / `debug` — into source -code that's supposed to use a logger. - -> If you haven't worked with Claude Code hooks before: hooks are tiny -> scripts that run at specific lifecycle points. A `PreToolUse` hook -> like this one fires _before_ Claude calls a tool. It can either -> **prime** (write to stderr, exit 0, model carries on) or **block** -> (exit 2, edit never happens). This one blocks. - -## Why a logger and not console.log - -Source code in this fleet uses `getDefaultLogger()` from -`@socketsecurity/lib-stable/logger` for all output. That logger handles: - -- **Color and theme.** Terminal colors honor the user's environment - (no-color, light/dark, etc.). Direct `console.log` doesn't. -- **Indentation tracking.** Nested operations indent their output. - Direct writes don't, so you get unaligned messages. -- **Stream redirection in tests.** Vitest captures and asserts on - logger output. Direct writes go to the real stdout/stderr and - pollute test reports. -- **Layout-sensitive features.** Spinners, progress bars, and footer - rendering all increment counters the logger maintains. Bypassing - the logger leaves those counters wrong, which produces visual - artifacts (a spinner that doesn't clear, a footer that - duplicates). - -The block is what keeps the logger as the single source of truth. -If even one file directly writes to stdout, the next person on a -related file sees the precedent and follows it; the convention -erodes. - -## Scope - -The hook is intentionally narrow: - -- **Fires** on `Edit` and `Write` calls. -- **Inspects** files matching `*.{ts,mts,tsx,cts}` under repo source. -- **Exempts** `.claude/hooks/`, `.git-hooks/`, `scripts/`, tests, - fixtures, and external/vendored code — those have legitimate - reasons to write directly. -- **Exempts** lines tagged `# socket-lint: allow console` (canonical - per-line opt-out — names the construct being allowed, not the - recommended replacement). The bare form `# socket-lint: allow` - also works for blanket suppression. Legacy `allow logger` is - accepted as an alias for one deprecation cycle. -- **Exempts** lines that look like documentation: lines starting - with `*`, `//`, or `#`; JSDoc tags; fully-backticked code spans. - -## Suggested replacements - -When the hook blocks, it surfaces a concrete rewrite per hit so the -agent can apply it directly: - -| Direct call | Logger equivalent | -| ------------------------- | ------------------- | -| `process.stderr.write(s)` | `logger.error(s)` | -| `process.stdout.write(s)` | `logger.info(s)` | -| `console.error(...)` | `logger.error(...)` | -| `console.warn(...)` | `logger.warn(...)` | -| `console.info(...)` | `logger.info(...)` | -| `console.debug(...)` | `logger.debug(...)` | -| `console.log(...)` | `logger.info(...)` | - -## Wiring - -`.claude/settings.json`: - -```json -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "node .claude/hooks/fleet/logger-guard/index.mts" - } - ] - } - ] - } -} -``` - -## Testing - -```bash -cd .claude/hooks/logger-guard -node --test test/*.test.mts -``` - -## Cross-fleet sync - -This README and the hook itself live in -[`socket-wheelhouse`](https://github.com/SocketDev/socket-wheelhouse/tree/main/template/.claude/hooks/logger-guard) -and are required to be byte-identical across every fleet repo. -`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. diff --git a/.claude/hooks/fleet/logger-guard/index.mts b/.claude/hooks/fleet/logger-guard/index.mts deleted file mode 100644 index d184e12..0000000 --- a/.claude/hooks/fleet/logger-guard/index.mts +++ /dev/null @@ -1,263 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — logger-guard. -// -// Blocks Edit/Write tool calls that would introduce direct calls to -// `process.stderr.write`, `process.stdout.write`, `console.log`, -// `console.error`, `console.warn`, `console.info`, or `console.debug` -// in source files. Exit code 2 makes Claude Code refuse the tool call -// so the diff never lands. The model sees the rejection reason on -// stderr and retries using the lib's logger. -// -// Why this rule: -// -// The fleet's source code uses `getDefaultLogger()` from -// `@socketsecurity/lib-stable/logger/default` for every output. Direct stream -// writes bypass color/theme handling, indentation tracking, stream -// redirection in tests, and spinner-counter increments — producing -// inconsistent output that breaks layout-sensitive workflows. -// -// Scope: -// -// - Fires only on `Edit` and `Write` tool calls. -// - Only inspects `.ts` / `.mts` / `.cts` / `.tsx` source files. -// Hooks, git-hooks, scripts, tests, fixtures, external/vendored -// code are exempt — see EXEMPT_PATH_PATTERNS. -// - Lines marked `// socket-lint: allow console` are exempt. -// -// AST-based detector (acorn-wasm in `_shared/ast/`). -// Replaced the regex implementation that had to compensate for -// string-literal / comment / template-literal false positives via -// `looksLikeDocumentation` heuristics — the parser handles all of -// that intrinsically because it only reaches CallExpression nodes -// for actual calls, not text-shapes that look like calls. -// -// The hook fails OPEN on its own bugs (exit 0 + stderr log) so a bad -// hook deploy can't brick the session. - -// Logger-leak detection (the FORBIDDEN_LOGGER_CALLS table + the AST walk) is -// shared with the commit-time scanLoggerLeaks via the gate-free -// _shared/logger-leaks.mts, so the edit-time and commit-time gates agree. -import { - findLoggerDecoration, - findLoggerLeaks, -} from '../../../../.git-hooks/_shared/logger-leaks.mts' -import type { LoggerDecoration } from '../../../../.git-hooks/_shared/logger-leaks.mts' -import { block, defineHook, editGuard, runHook } from '../_shared/guard.mts' -import { suppressionCoversLine } from '../_shared/markers.mts' - -const EXEMPT_PATH_PATTERNS: RegExp[] = [ - /\.claude\/hooks\//, - /\.git-hooks\//, - // The dep-0 bootstrap (`bootstrap/fleet.mjs`, `bootstrap/prepare.mts`) is - // bare by design — it never imports socket-lib (it's the fetcher that runs - // before any dep exists), so it must call `console.*` directly. Relocating it - // out of `scripts/` lost the `scripts/` exemption below; restore it here. - /(?:^|\/)bootstrap\//, - /(?:^|\/)scripts\//, - /\.(?:spec|test)\.(?:cts|m?[jt]s|mts|tsx?)$/, - /(?:^|\/)tests?\//, - /(?:^|\/)fixtures\//, - /(?:^|\/)external\//, - /(?:^|\/)vendor\//, - /(?:^|\/)upstream\//, - // The logger is its own owner — these files implement the Logger - // class + its browser shim and must call console.* directly. - /(?:^|\/)src\/logger\//, -] - -// The forbidden-call table + AST detector live in the shared -// _shared/logger-leaks.mts (FORBIDDEN_LOGGER_CALLS / findLoggerLeaks), so the -// commit-time scanLoggerLeaks and this edit-time guard use one source. - -export function emitBlock(filePath: string, hits: Hit[]): string { - const out: string[] = [] - out.push('') - out.push('[logger-guard] Blocked: direct stream write found') - out.push( - ' Use `getDefaultLogger()` from `@socketsecurity/lib-stable/logger/default` instead.', - ) - out.push(` File: ${filePath}`) - const hs = hits.slice(0, 3) - for (let i = 0, { length } = hs; i < length; i += 1) { - const h = hs[i]! - out.push(` Line ${h.line}: ${h.text}`) - out.push( - ` Fix: replace \`${h.fullCall}(\` with \`${h.replacement}(\``, - ) - } - if (hits.length > 3) { - out.push(` …and ${hits.length - 3} more.`) - } - out.push( - ' Opt-out for one line (rare): add `// socket-lint: allow console` on its own line above for a ' + - '`console.*` call, or `// socket-lint: allow process-stdio` for a raw ' + - '`process.std{out,err}.write` (the id must match the call kind).', - ) - out.push('') - return out.join('\n') -} - -interface Hit { - line: number - text: string - fullCall: string - replacement: string -} - -export function isInScope(filePath: string): boolean { - if (!filePath) { - return false - } - // Match TypeScript source extensions: .ts, .mts, .cts, and .tsx. - if (!/\.(?:cts|m?ts|tsx)$/.test(filePath)) { - return false - } - for (let i = 0, { length } = EXEMPT_PATH_PATTERNS; i < length; i += 1) { - const re = EXEMPT_PATH_PATTERNS[i]! - if (re.test(filePath)) { - return false - } - } - return true -} - -export function scan(source: string): Hit[] { - const lines = source.split('\n') - const hits: Hit[] = [] - for (const leak of findLoggerLeaks(source)) { - // Per-line allow marker, keyed by leak kind so the edit-time guard agrees - // with the pre-push `scanLoggerLeaks`: `console.*` waives with - // `// socket-lint: allow console`, raw `process.std*.write` waives with the - // more deliberate `// socket-lint: allow process-stdio`. The marker sits on - // the call's own line, or alone on the line above it. - const rule = leak.fullCall.startsWith('process.') - ? 'process-stdio' - : 'console' - if (suppressionCoversLine(lines, leak.line - 1, rule)) { - continue - } - hits.push({ - line: leak.line, - text: leak.text, - fullCall: leak.fullCall, - replacement: leak.replacement, - }) - } - hits.sort((a, b) => a.line - b.line) - return hits -} - -// Decoration applies more broadly than the console-leak rule: scripts/ and -// .claude/hooks/ legitimately call console in a few spots, hence exempt above -// but must NOT hand-roll logger prefixes. So decoration has its own, narrower -// exempt set — external/vendored code, test files (which build expected-output -// fixtures with glyphs), and the logger's own implementation. -const DECORATION_EXEMPT_PATTERNS: RegExp[] = [ - /(?:^|\/)external\//, - /(?:^|\/)vendor\//, - /(?:^|\/)upstream\//, - /(?:^|\/)fixtures\//, - /(?:^|\/)src\/logger\//, - /\.(?:spec|test)\.(?:cts|m?[jt]s|mts|tsx?)$/, -] - -export function isInDecorationScope(filePath: string): boolean { - // Same extension gate as isInScope: only .ts, .mts, .cts, and .tsx files. - if (!filePath || !/\.(?:cts|m?ts|tsx)$/.test(filePath)) { - return false - } - for (let i = 0, { length } = DECORATION_EXEMPT_PATTERNS; i < length; i += 1) { - if (DECORATION_EXEMPT_PATTERNS[i]!.test(filePath)) { - return false - } - } - return true -} - -export function scanDecoration(source: string): LoggerDecoration[] { - const lines = source.split('\n') - const out: LoggerDecoration[] = [] - for (const deco of findLoggerDecoration(source)) { - if (suppressionCoversLine(lines, deco.line - 1, 'logger-decoration')) { - continue - } - out.push(deco) - } - return out -} - -export function emitDecorationBlock( - filePath: string, - decos: readonly LoggerDecoration[], -): string { - const out: string[] = [] - out.push('') - out.push('[logger-guard] Blocked: hand-rolled logger decoration') - out.push( - ' The logger method owns its glyph; group()/substep() own indentation.', - ) - out.push(` File: ${filePath}`) - for (let i = 0, { length } = decos; i < length && i < 3; i += 1) { - const d = decos[i]! - out.push(` Line ${d.line}: ${d.text}`) - if (d.kind === 'glyph') { - out.push( - /* c8 ignore next - glyph is always a GLYPH_OWNER key so ownerMethod is always defined */ - ` Fix: drop the \`${d.glyph}\` and call \`logger.${d.ownerMethod ?? 'fail'}(...)\` (the method renders the glyph).`, - ) - } else if (d.kind === 'indent') { - out.push( - ' Fix: wrap items in `logger.group()`/`logger.groupEnd()` (or use `logger.substep()`) — drop the leading spaces.', - ) - } else { - out.push( - ' Fix: use `logger.substep(...)` for an indented sub-item — drop the leading bullet.', - ) - } - } - if (decos.length > 3) { - out.push(` …and ${decos.length - 3} more.`) - } - out.push( - ' Opt-out for one line (rare): add `// socket-lint: allow logger-decoration` on its own line above.', - ) - out.push('') - return out.join('\n') -} - -export const check = editGuard( - (filePath, content) => { - const source = content ?? '' - if (!source) { - return undefined - } - const blocks: string[] = [] - if (isInScope(filePath)) { - const hits = scan(source) - if (hits.length > 0) { - blocks.push(emitBlock(filePath, hits)) - } - } - if (isInDecorationScope(filePath)) { - const decos = scanDecoration(source) - if (decos.length > 0) { - blocks.push(emitDecorationBlock(filePath, decos)) - } - } - if (blocks.length === 0) { - return undefined - } - return block(blocks.join('\n')) - }, - { fleetOnly: true }, -) - -export const hook = defineHook({ - check, - event: 'PreToolUse', - matcher: ['Edit', 'Write', 'MultiEdit'], - scope: 'convention', - type: 'guard', -}) - -void runHook(hook, import.meta.url) diff --git a/.claude/hooks/fleet/logger-guard/package.json b/.claude/hooks/fleet/logger-guard/package.json deleted file mode 100644 index c08efff..0000000 --- a/.claude/hooks/fleet/logger-guard/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "hook-logger-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - }, - "dependencies": { - "@ultrathink/acorn.rs.wasm": "catalog:" - } -} diff --git a/.claude/hooks/fleet/logger-guard/tsconfig.json b/.claude/hooks/fleet/logger-guard/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/logger-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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/long-running-task-nudge/index.mts b/.claude/hooks/fleet/long-running-task-nudge/index.mts deleted file mode 100644 index 759717e..0000000 --- a/.claude/hooks/fleet/long-running-task-nudge/index.mts +++ /dev/null @@ -1,495 +0,0 @@ -#!/usr/bin/env node -// Claude Code PostToolUse hook — long-running-task-nudge. -// -// Catches a background Workflow run or background Agent that grinds on one -// task without progress. A single background run once ground on one hard task -// for about an hour — a huge transcript, many failed iterations — before the -// orchestrator noticed. This surfaces the run after a modest threshold so the -// orchestrator verifies it is progressing and, if stuck, TaskStops it and -// researches the real root cause instead of letting it grind. -// -// Clock: PostToolUse fires after every tool call, the only event that fires -// periodically during an active turn, so it is the natural place for an -// elapsed-time check. Caveat: it fires only while the ORCHESTRATOR is itself -// calling tools. If the orchestrator sits fully idle waiting on the background -// task, the nudge lands at its next tool call, not at the exact threshold. That -// is on goal — the point is to prompt a progress check the next time it acts. -// -// Discovery: two on-disk sources derived from the payload transcript_path. -// 1. Workflow runs at /workflows/wf_*.json — runId, status, and -// startTime in epoch ms. Terminal status ends a run; anything else runs. -// 2. Agents at /subagents/agent-*.jsonl — no status field, so an -// agent runs while its transcript mtime is fresh within the live window; -// age is now minus the transcript ctime. -// Paths anchor on os.homedir() + transcript_path, never a hardcoded temp path. -// -// Idempotent: warns once per task per threshold crossing. A fail-open JSON -// store maps each task id to the highest tier warned; a task re-warns only when -// it crosses a higher tier. Fail-open everywhere — a broken read never blocks a -// tool call. - -import { - existsSync, - mkdirSync, - readdirSync, - readFileSync, - statSync, - writeFileSync, -} from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -import { safeDeleteSync } from '@socketsecurity/lib-stable/fs/safe' - -import { - CHILD_LIVE_WINDOW_MS, - deriveSubagentsDir, -} from '../_shared/active-edits-ledger.mts' -import { defineHook, notify, runHook } from '../_shared/guard.mts' -import type { GuardResult } from '../_shared/guard.mts' -import type { ToolCallPayload } from '../_shared/payload.mts' -import { resolveRepoRoot } from '../_shared/repo-root.mts' - -// First and second (escalated) age tiers, in minutes. Named constants are the -// single source for the age math and the warn-once bookkeeping. -export const LONGRUN_MINUTES = 5 -export const LONGRUN_ESCALATE_MINUTES = 10 - -const MS_PER_MINUTE = 60_000 - -// Store for warn-once state: .cache/, dep-0 runtime state, -// never tracked. Falls back to the OS temp dir. -const STORE_NAME = 'socket-long-running-task-nudge' - -// A session store older than this is swept — the session ended or went idle. -export const SEEN_STORE_TTL_MS = 60 * 60 * 1000 - -// Workflow statuses that mark a run as done. Anything else is treated as still -// running so a live run is never missed. -export const TERMINAL_STATUSES: ReadonlySet = new Set([ - 'cancelled', - 'completed', - 'error', - 'failed', - 'killed', -]) - -/** - * One background Workflow run, narrowed from its wf_*.json file. - */ -export interface WorkflowRecord { - readonly runId: string - readonly startTime?: number | undefined - readonly status?: string | undefined - readonly workflowName?: string | undefined -} - -/** - * One background Agent, narrowed from its transcript + meta companion. - */ -export interface AgentRecord { - readonly ctimeMs: number - readonly description?: string | undefined - readonly id: string - readonly mtimeMs: number -} - -/** - * A running background task with its computed age. - */ -export interface RunningTask { - readonly ageMs: number - readonly id: string - readonly kind: 'agent' | 'workflow' - readonly label?: string | undefined -} - -/** - * A running task that crossed a warn tier this check. - */ -export interface WarnDecision { - readonly ageMs: number - readonly id: string - readonly kind: 'agent' | 'workflow' - readonly label?: string | undefined - readonly tier: number -} - -/** - * The on-disk warn-once store: task id → highest tier already warned. - */ -export interface SeenStore { - readonly seen: Record - readonly updatedAt: number -} - -// ── Pure core ─────────────────────────────────────────────────────────────── - -/** - * True when a workflow status marks the run as done. An absent status is - * treated as running. Pure. - */ -export function isTerminalStatus(status: string | undefined): boolean { - return status !== undefined && TERMINAL_STATUSES.has(status) -} - -/** - * The workflows directory for a session, the sibling of the subagents dir: - * `/.jsonl` → `//workflows`. Returns `undefined` - * when the path is not a `.jsonl` transcript. Pure. - */ -export function deriveWorkflowsDir( - transcriptPath: string | undefined, -): string | undefined { - const subagentsDir = deriveSubagentsDir(transcriptPath) - if (!subagentsDir) { - return undefined - } - return path.join(path.dirname(subagentsDir), 'workflows') -} - -/** - * The session id for a transcript path — the basename minus `.jsonl`. Returns - * `undefined` when the path is not a `.jsonl` transcript. Pure. - */ -export function sessionIdFromTranscript( - transcriptPath: string | undefined, -): string | undefined { - if (!transcriptPath || !transcriptPath.endsWith('.jsonl')) { - return undefined - } - return path.basename(transcriptPath, '.jsonl') -} - -/** - * The highest warn tier an age has crossed: `LONGRUN_ESCALATE_MINUTES`, - * `LONGRUN_MINUTES`, or `0` for under the first threshold. Pure. - */ -export function tierFor(ageMs: number): number { - if (ageMs >= LONGRUN_ESCALATE_MINUTES * MS_PER_MINUTE) { - return LONGRUN_ESCALATE_MINUTES - } - if (ageMs >= LONGRUN_MINUTES * MS_PER_MINUTE) { - return LONGRUN_MINUTES - } - return 0 -} - -/** - * The running background tasks with their ages. A workflow runs when it has a - * `startTime` and a non-terminal status; an agent runs when its transcript - * mtime is fresh within `liveWindowMs`. Negative ages from clock skew are - * dropped. Pure — no IO, injectable clock. - */ -export function runningTaskAges(config: { - agents: readonly AgentRecord[] - liveWindowMs?: number | undefined - now: number - workflows: readonly WorkflowRecord[] -}): RunningTask[] { - const cfg = { __proto__: null, ...config } as typeof config - const liveWindowMs = cfg.liveWindowMs ?? CHILD_LIVE_WINDOW_MS - const out: RunningTask[] = [] - for (const wf of cfg.workflows) { - if (typeof wf.startTime !== 'number' || isTerminalStatus(wf.status)) { - continue - } - const ageMs = cfg.now - wf.startTime - if (ageMs < 0) { - continue - } - out.push({ ageMs, id: wf.runId, kind: 'workflow', label: wf.workflowName }) - } - for (const agent of cfg.agents) { - if (cfg.now - agent.mtimeMs > liveWindowMs) { - continue - } - const ageMs = cfg.now - agent.ctimeMs - if (ageMs < 0) { - continue - } - out.push({ ageMs, id: agent.id, kind: 'agent', label: agent.description }) - } - return out -} - -/** - * The tasks to warn about: those whose crossed tier exceeds the highest tier - * already recorded in `seen`. This is the idempotency core — a task under the - * first threshold, or one already warned at its current tier, is silent. Pure. - */ -export function tasksToWarn( - running: readonly RunningTask[], - seen: Readonly>, -): WarnDecision[] { - const out: WarnDecision[] = [] - for (const task of running) { - const tier = tierFor(task.ageMs) - if (tier === 0) { - continue - } - const prior = seen[task.id] ?? 0 - if (tier <= prior) { - continue - } - out.push({ - ageMs: task.ageMs, - id: task.id, - kind: task.kind, - label: task.label, - tier, - }) - } - return out -} - -/** - * A new seen map with each warned task recorded at its crossed tier. Pure. - */ -export function mergeSeen( - seen: Readonly>, - warned: readonly WarnDecision[], -): Record { - const next: Record = { ...seen } - for (const decision of warned) { - next[decision.id] = decision.tier - } - return next -} - -/** - * The nudge text naming each over-threshold task and its age. Pure. - */ -export function formatLongrunNudge(warned: readonly WarnDecision[]): string { - const lines: string[] = [] - for (let i = 0, { length } = warned; i < length; i += 1) { - const decision = warned[i]! - const label = decision.label ? ` "${decision.label}"` : '' - const minutes = Math.floor(decision.ageMs / MS_PER_MINUTE) - const escalated = - decision.tier >= LONGRUN_ESCALATE_MINUTES ? ' ESCALATED' : '' - const prefix = - i === 0 - ? '💡 long-running-task-nudge: verify progress (TaskGet/transcript growth) or TaskStop + root-cause; lint/format thrash → autofixer (`pnpm run fix`) first — ' - : ' ' - lines.push( - `${prefix}${decision.kind} ${decision.id}${label} — ${minutes}min${escalated}`, - ) - } - return lines.join('\n') -} - -// ── Thin fs shell ───────────────────────────────────────────────────────── - -/** - * The warn-once store dir. Prefers - * `/.cache/`; falls back to the OS temp dir. - * The git-toplevel anchor is what keeps every caller on ONE store instead - * of scattering a `.cache/` per cwd — including one inside - * `template/base/`, the cascade payload (see _shared/repo-root.mts). - */ -export function resolveSeenStoreDir(projectDir: string | undefined): string { - if (projectDir) { - return path.join(resolveRepoRoot(projectDir), '.cache', 'fleet', STORE_NAME) - } - return path.join( - process.env['TMPDIR'] ?? - process.env['TMP'] ?? - process.env['TEMP'] ?? - '/tmp', - STORE_NAME, - ) -} - -// The narrowed description from an agent's meta companion, or undefined. -export function readAgentDescription( - subagentsDir: string, - id: string, -): string | undefined { - const metaPath = path.join(subagentsDir, `${id}.meta.json`) - if (!existsSync(metaPath)) { - return undefined - } - try { - const parsed = JSON.parse(readFileSync(metaPath, 'utf8')) - const desc = parsed?.description - return typeof desc === 'string' ? desc : undefined - } catch { - return undefined - } -} - -// Parse the wf_*.json workflow runs under a workflows dir. Fail-open. -function readWorkflowRecords(workflowsDir: string): WorkflowRecord[] { - try { - if (!existsSync(workflowsDir)) { - return [] - } - const out: WorkflowRecord[] = [] - for (const entry of readdirSync(workflowsDir)) { - if (!entry.startsWith('wf_') || !entry.endsWith('.json')) { - continue - } - try { - const parsed = JSON.parse( - readFileSync(path.join(workflowsDir, entry), 'utf8'), - ) - if (!parsed || typeof parsed !== 'object') { - continue - } - const runId = - typeof parsed.runId === 'string' - ? parsed.runId - : entry.slice(0, -'.json'.length) - out.push({ - runId, - startTime: - typeof parsed.startTime === 'number' ? parsed.startTime : undefined, - status: typeof parsed.status === 'string' ? parsed.status : undefined, - workflowName: - typeof parsed.workflowName === 'string' - ? parsed.workflowName - : undefined, - }) - } catch { - // Fail-open per file. - } - } - return out - } catch { - return [] - } -} - -// Read the agent-*.jsonl transcripts under a subagents dir with their ctime + -// mtime and meta description. Fail-open. -function readAgentRecords(subagentsDir: string): AgentRecord[] { - try { - if (!existsSync(subagentsDir)) { - return [] - } - const out: AgentRecord[] = [] - for (const entry of readdirSync(subagentsDir)) { - if (!entry.startsWith('agent-') || !entry.endsWith('.jsonl')) { - continue - } - const id = entry.slice(0, -'.jsonl'.length) - try { - // oxlint-disable-next-line socket/prefer-exists-sync -- statSync for ctime/mtime, not existence; we need the timestamps - const stat = statSync(path.join(subagentsDir, entry)) - out.push({ - ctimeMs: stat.ctimeMs, - description: readAgentDescription(subagentsDir, id), - id, - mtimeMs: stat.mtimeMs, - }) - } catch { - // Fail-open per file. - } - } - return out - } catch { - return [] - } -} - -// Read the warn-once store for a session. Returns an empty store on any error. -function readSeenStore(filePath: string): SeenStore { - const empty: SeenStore = { seen: {}, updatedAt: 0 } - if (!existsSync(filePath)) { - return empty - } - try { - const parsed = JSON.parse(readFileSync(filePath, 'utf8')) - if ( - !parsed || - typeof parsed !== 'object' || - !parsed.seen || - typeof parsed.seen !== 'object' - ) { - return empty - } - return parsed as SeenStore - } catch { - return empty - } -} - -// Flush the warn-once store. Fail-open — a broken store must not block a call. -export function writeSeenStore(filePath: string, store: SeenStore): void { - try { - mkdirSync(path.dirname(filePath), { recursive: true }) - writeFileSync(filePath, JSON.stringify(store), 'utf8') - } catch { - // Fail-open. - } -} - -// Expire session stores past the TTL to bound store growth. Fail-open. -export function sweepStaleSeenStores( - storeDir: string, - config: { now: number; ttlMs: number }, -): void { - const { now, ttlMs } = { __proto__: null, ...config } as typeof config - try { - if (!existsSync(storeDir)) { - return - } - for (const entry of readdirSync(storeDir)) { - if (!entry.endsWith('.json')) { - continue - } - const fp = path.join(storeDir, entry) - try { - // oxlint-disable-next-line socket/prefer-exists-sync -- statSync for mtime, not existence; we need the modification timestamp - if (now - statSync(fp).mtimeMs > ttlMs) { - safeDeleteSync(fp) - } - } catch { - // Fail-open per file. - } - } - } catch { - // Fail-open for the whole sweep. - } -} - -export const check = (payload: ToolCallPayload): GuardResult => { - const transcriptPath = payload?.transcript_path - const session = sessionIdFromTranscript(transcriptPath) - const workflowsDir = deriveWorkflowsDir(transcriptPath) - const subagentsDir = deriveSubagentsDir(transcriptPath) - if (!session || (!workflowsDir && !subagentsDir)) { - return undefined - } - const now = Date.now() - const running = runningTaskAges({ - agents: subagentsDir ? readAgentRecords(subagentsDir) : [], - now, - workflows: workflowsDir ? readWorkflowRecords(workflowsDir) : [], - }) - - const storeDir = resolveSeenStoreDir( - process.env['CLAUDE_PROJECT_DIR'] || undefined, - ) - const storeFile = path.join(storeDir, `${session}.json`) - const store = readSeenStore(storeFile) - const warned = tasksToWarn(running, store.seen) - if (warned.length === 0) { - return undefined - } - - writeSeenStore(storeFile, { - seen: mergeSeen(store.seen, warned), - updatedAt: now, - }) - sweepStaleSeenStores(storeDir, { now, ttlMs: SEEN_STORE_TTL_MS }) - return notify(formatLongrunNudge(warned)) -} - -export const hook = defineHook({ - check, - event: 'PostToolUse', - type: 'nudge', -}) -void runHook(hook, import.meta.url) diff --git a/.claude/hooks/fleet/markdown-filename-guard/README.md b/.claude/hooks/fleet/markdown-filename-guard/README.md deleted file mode 100644 index b4a33df..0000000 --- a/.claude/hooks/fleet/markdown-filename-guard/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# markdown-filename-guard - -PreToolUse Edit/Write hook that blocks markdown files with non-canonical filenames. - -## What it enforces - -| Filename shape | Allowed at | Notes | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------- | ------------------------------------------------------------- | -| `README.md`, `LICENSE` | anywhere | Special-cased by GitHub. | -| `AUTHORS.md`, `CHANGELOG.md`, `CITATION.md`, `CLAUDE.md`, `CODE_OF_CONDUCT.md`, `CONTRIBUTING.md`, `CONTRIBUTORS.md`, `COPYING`, `CREDITS.md`, `GOVERNANCE.md`, `MAINTAINERS.md`, `NOTICE.md`, `SECURITY.md`, `SUPPORT.md`, `TRADEMARK.md` | repo root, `docs/` (top level), or `.claude/` (top level) | The SCREAMING_CASE allowlist. GitHub renders these specially. | -| `lowercase-with-hyphens.md` | inside `docs/` or `.claude/` (any depth) | All other docs. | -| `SKILL.md` | skill roots: a directory under a `skills/` segment | The Anthropic Agent Skills manifest — spec-dictated name. | - -Blocked: - -- Custom SCREAMING_CASE filenames (`NOTES.md`, `MY_DESIGN.md`, etc.) — rename to `notes.md` / `my-design.md`. -- `.MD` extension — use `.md`. -- `camelCase.md` / `snake_case.md` / `Spaces In Filename.md` — convert to lowercase-with-hyphens. -- Lowercase-hyphenated docs at repo root — move to `docs/` or `.claude/`. -- `SKILL.md` outside a skill root — the name is spec-reserved; move it to `//SKILL.md`. - -## Why - -SCREAMING_CASE doc filenames optimize for "noticeable in a repo root" but read as shouty + opaque inside body text and TOC links. Lowercase-with-hyphens reads naturally and matches the rest of the fleet's slug-style identifiers (URLs, CSS classes, CLI flags, package names). The narrow SCREAMING_CASE allowlist is the set GitHub renders specially — adding more dilutes the signal. - -The fleet's `scripts/fleet/check/markdown-filenames-are-canonical.mts` does the same check at commit time; this hook catches it earlier, at edit time, so the model gets immediate feedback when it picks a wrong name. - -## Companion files - -- `index.mts` — the hook itself. -- `test/index.test.mts` — node:test specs (15 cases). -- `package.json` — workspace declaration so `taze` can see the hook's deps. -- `tsconfig.json` — fleet-canonical TS config. - -## Adding a new allowed filename - -If GitHub adds a new specially-rendered file (e.g. `FUNDING.md`), update `ALLOWED_SCREAMING_CASE` in `index.mts` and the table above. Don't add custom project-specific SCREAMING_CASE filenames here — those break the convention. - -## Failing open - -The hook fails open on its own bugs (exit 0 + stderr log) so a bad deploy can't brick the session. The `scripts/fleet/check/markdown-filenames-are-canonical.mts` gate at commit time is the second line of defense. diff --git a/.claude/hooks/fleet/markdown-filename-guard/index.mts b/.claude/hooks/fleet/markdown-filename-guard/index.mts deleted file mode 100644 index 95c0ae4..0000000 --- a/.claude/hooks/fleet/markdown-filename-guard/index.mts +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — markdown-filename-guard. -// -// Blocks Edit/Write tool calls that would create a markdown file -// with a non-canonical filename. Per the fleet's docs convention: -// -// - Allowed everywhere: README.md, LICENSE. -// - Allowed at root, docs/, .claude/ top level only, or any -// package root (a directory holding package.json — npm renders -// these files from there): the conventional SCREAMING_CASE set -// (AUTHORS, CHANGELOG, CLAUDE, CODE_OF_CONDUCT, CONTRIBUTING, -// GOVERNANCE, MAINTAINERS, NOTICE, SECURITY, SUPPORT, etc.). -// - Everything else must be lowercase-with-hyphens AND placed -// under `docs/` or `.claude/`, at any depth. -// -// Why: SCREAMING_CASE doc filenames optimize for "noticeable in a -// repo root" but read as shouty + opaque inside body text and TOC -// links. Hyphenated lowercase reads naturally and matches every -// other slug-style identifier the fleet uses (URLs, CSS classes, -// CLI flags, package names). The narrow SCREAMING_CASE allowlist is -// the set GitHub renders specially — adding more would dilute the -// signal. -// -// The classification itself lives in `_shared/markdown-path.mts`, shared with -// the commit-time belt check `scripts/fleet/check/markdown-filenames-are-canonical.mts`; -// this hook catches violations earlier, at edit time, so the model gets -// immediate feedback when it picks a wrong name. -// -// Exit code 2 makes Claude Code refuse the tool call. -// -// Reads a Claude Code PreToolUse JSON payload from stdin: -// { "tool_name": "Edit"|"Write", -// "tool_input": { "file_path": "...", "content"|"new_string": "..." } } -// -// Fails open on hook bugs (exit 0 + stderr log). - -import { existsSync } from 'node:fs' - -import { isFleetTarget } from '../_shared/fleet-context.mts' -import { block, defineHook, editGuard, runHook } from '../_shared/guard.mts' -import { classifyMarkdownPath } from '../_shared/markdown-path.mts' -import type { Verdict } from '../_shared/markdown-path.mts' - -export function emitBlock(filePath: string, verdict: Verdict): string { - const issue = - verdict.message ?? - 'doc filenames are lowercase-with-hyphens under docs/ or .claude/ (README.md / LICENSE anywhere; the SCREAMING_CASE allowlist at root, docs/, .claude/, or a package root).' - const fix = verdict.suggestion ? ` Rename to ${verdict.suggestion}.` : '' - return `🚨 markdown-filename-guard: blocked non-canonical doc filename "${filePath}" — ${issue}${fix}\n` -} - -export const check = editGuard((filePath, content, payload) => { - void content - const verdict = classifyMarkdownPath(filePath) - if (verdict.ok) { - return undefined - } - // The fleet doc-filename convention only governs fleet repos — an external / - // sibling clone (e.g. a GitHub wiki where `Home.md` is the page slug) owns - // its own naming. - if (!isFleetTarget(payload)) { - return undefined - } - // Only block CREATION of a new non-canonical name. Editing a file that - // already exists on disk — whose name predates this rule and which we are - // not renaming — must never be blocked. - if (existsSync(filePath)) { - return undefined - } - return block(emitBlock(filePath, verdict)) -}) - -export const hook = defineHook({ - bypass: ['markdown-filename'], - bypassOptional: true, - check, - event: 'PreToolUse', - matcher: ['Edit', 'Write', 'MultiEdit'], - type: 'guard', -}) - -void runHook(hook, import.meta.url) diff --git a/.claude/hooks/fleet/markdown-filename-guard/package.json b/.claude/hooks/fleet/markdown-filename-guard/package.json deleted file mode 100644 index 35a1a1a..0000000 --- a/.claude/hooks/fleet/markdown-filename-guard/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "hook-markdown-filename-guard", - "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/markdown-filename-guard/tsconfig.json b/.claude/hooks/fleet/markdown-filename-guard/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/markdown-filename-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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/mass-delete-guard/README.md b/.claude/hooks/fleet/mass-delete-guard/README.md deleted file mode 100644 index 09a800e..0000000 --- a/.claude/hooks/fleet/mass-delete-guard/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# mass-delete-guard - -PreToolUse hook. Blocks a `git commit` whose staged tree would delete a catastrophic fraction of the repo — **≥ 50 files**, or **> 75% of the tracked tree**. - -## Why - -That deletion shape is almost never intentional. It's the fingerprint of a clobbered index: a stray `git read-tree`, a `git commit` fired against a near-empty or foreign index, a leftover rename/test artifact, or a misfired scripted commit. The commit records a tiny tree plus tens of thousands of deletions; once pushed, recovery is painful. - -A session committed `2396 files / 329k deletions` from a 1-file index **twice in a row**, the second on top of the first, and only recovered because nothing had been pushed — `git reset --mixed` to the prior good commit, worktree intact. This gate catches it before the bad commit exists. - -## How - -On a `git commit` (detected via the shared shell-command AST parser, not a regex), it counts: - -- staged deletions — `git diff --cached --diff-filter=D --name-only` -- tracked files — `git ls-files` - -and blocks (exit 2) when deletions ≥ 50 OR deletions / tracked > 0.75. Commits with zero staged deletions, or below both thresholds, pass untouched. Fails **open** on any error — a guard bug must never wedge commits. - -## Bypass - -- `Allow mass-delete bypass` in a recent user turn — for a genuine large removal (dropping a vendored tree, deleting a retired package). -- `FLEET_SYNC=1` command prefix — cascade commits legitimately replace whole fleet directories. diff --git a/.claude/hooks/fleet/mass-delete-guard/index.mts b/.claude/hooks/fleet/mass-delete-guard/index.mts deleted file mode 100644 index f415600..0000000 --- a/.claude/hooks/fleet/mass-delete-guard/index.mts +++ /dev/null @@ -1,235 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — mass-delete-guard. -// -// Blocks a `git commit` whose STAGED tree would delete a catastrophic -// fraction of the repo: ≥ 50 deleted files, OR > 75% of the tree's tracked -// files. That shape is almost never an intentional change — it's a clobbered -// index: a `git read-tree`, a `git commit` fired against a near-empty or -// foreign index, a stray rename/test artifact left in the worktree, or a -// misfired scripted commit. The commit lands a tree with a handful of files -// and tens of thousands of deletions; if it gets pushed, recovery is ugly. -// -// Why a guard and not just "be careful": a session committed -// `2396 files / 329k deletions` from a 1-file index TWICE in a row (the second -// on top of the first), and only recovered because nothing had been pushed — -// `git reset --mixed` to the prior good commit, worktree intact. A pre-commit -// gate catches it before the bad commit exists. -// -// Detection: on a `git commit`, list staged deletions -// (`git diff --cached --diff-filter=D --name-only`), set aside the benign -// untrackings (see _shared/benign-untracking.mts — a path still on disk that -// git ignores, the `git rm --cached` signature), and weigh only the remainder -// against the tree's tracked file count (`git ls-files`). Block when those -// deletions ≥ DELETE_FLOOR or deletions / max(tracked, 1) > DELETE_RATIO. -// -// Fails OPEN on any hook error (exit 0 + stderr note) — a guard bug must never -// wedge commits. -// -// Bypass: -// - `Allow mass-delete bypass` in a recent user turn — for a genuine large -// removal, dropping a vendored tree, deleting a retired package. -// - `FLEET_SYNC=1` prefix — cascade commits legitimately replace whole -// fleet dirs and are trusted. -// -// Reads a Claude Code PreToolUse JSON payload from stdin: -// { "tool_name": "Bash", -// "tool_input": { "command": "..." }, -// "transcript_path": "/.../session.jsonl" } - -import { existsSync } from 'node:fs' -import path from 'node:path' - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' - -import type { StagedDeletionSplit } from '../_shared/benign-untracking.mts' -import { - describeBenignUntrackings, - ignoredAmong, - splitStagedDeletions, -} from '../_shared/benign-untracking.mts' -import { isGitCommit } from '../_shared/commit-command.mts' -import { bashGuard, block, defineHook, runHook } from '../_shared/guard.mts' -import { isFleetSyncCommand } from '../_shared/shell-command.mts' -import { spawnTimeoutMs } from '../_shared/spawn-timeout.mts' -import { squashSentinelAllows } from '../_shared/squash-sentinel.mts' -import { bypassPhrasePresent } from '../_shared/transcript.mts' -import { resolveProjectDir } from '../_shared/project-dir.mts' - -const BYPASS_PHRASES = ['Allow mass-delete bypass'] as const - -// Pre-flight triggers: the dispatcher skips importing this guard unless the raw -// payload contains one of these substrings. The guard can only ever block when -// `isGitCommit(command)` is true, and that detection (the shared -// `_shared/commit-command.mts` segment parse) requires `commit` as the -// subcommand verb of a real `git` segment. So `commit` is a necessary -// substring of every blocking command — safe to gate on. -export const triggers: readonly string[] = ['commit'] - -// A commit deleting at least this many files is catastrophic regardless of -// repo size — catches a wipe in a large repo where the ratio alone wouldn't -// trip until far too late. -const DELETE_FLOOR = 50 -// …or deleting more than this fraction of the tracked tree — catches a wipe -// in a small repo where 50 files is most of it. -const DELETE_RATIO = 0.75 - -export function getRepoDir(): string { - return resolveProjectDir() -} - -export { isGitCommit } - -/** - * Paths staged for DELETION in the index (vs HEAD). - */ -export function stagedDeletionPaths(repoDir: string): string[] { - const r = spawnSync( - 'git', - ['diff', '--cached', '--diff-filter=D', '--name-only'], - { cwd: repoDir, timeout: spawnTimeoutMs(5000) }, - ) - if (r.status !== 0) { - return [] - } - return String(r.stdout) - .split('\n') - .map((s: string) => s.trim()) - .filter(Boolean) -} - -/** - * Split staged deletions, setting aside the ones explained by untracking a - * still-present gitignored path. Only the remainder is wipe-shaped. - */ -export function splitDeletions( - repoDir: string, - paths: readonly string[], -): StagedDeletionSplit { - const ignored = ignoredAmong(paths, stdin => { - const r = spawnSync('git', ['check-ignore', '--no-index', '--stdin'], { - cwd: repoDir, - input: stdin, - timeout: spawnTimeoutMs(5000), - }) - return { status: r.status ?? undefined, stdout: String(r.stdout ?? '') } - }) - return splitStagedDeletions(paths, ignored, relPath => - existsSync(path.join(repoDir, relPath)), - ) -} - -/** - * Count files tracked in HEAD's tree, the denominator for the ratio test. - */ -export function countTrackedFiles(repoDir: string): number { - const r = spawnSync('git', ['ls-files'], { - cwd: repoDir, - timeout: spawnTimeoutMs(5000), - }) - if (r.status !== 0) { - return 0 - } - return String(r.stdout) - .split('\n') - .map((s: string) => s.trim()) - .filter(Boolean).length -} - -/** - * Decide whether a staged deletion count is catastrophic for a tree of the - * given size. Returns the reason string when it is, or undefined. - */ -export function catastrophicReason( - deletions: number, - tracked: number, -): string | undefined { - if (deletions >= DELETE_FLOOR) { - return `${deletions} files staged for deletion (≥ ${DELETE_FLOOR})` - } - const ratio = deletions / Math.max(tracked, 1) - if (ratio > DELETE_RATIO) { - return `${deletions} of ${tracked} tracked files staged for deletion (> ${Math.round( - DELETE_RATIO * 100, - )}%)` - } - return undefined -} - -export const check = bashGuard((command, payload) => { - if (!isGitCommit(command)) { - return undefined - } - // Cascade commits legitimately replace whole fleet directories; the - // FLEET_SYNC sentinel marks a trusted cascade run (same opt-in the - // no-revert / overeager-staging guards honor). - if (isFleetSyncCommand(command)) { - return undefined - } - // The squashing-history collapse commit deletes files removed since the root - // commit; the hardened SQUASH_HISTORY=1 sentinel authorizes it, no phrase. - if (squashSentinelAllows(command)) { - return undefined - } - - const repoDir = getRepoDir() - const staged = stagedDeletionPaths(repoDir) - if (staged.length === 0) { - return undefined - } - // Untracking ignored files (`git rm --cached`) stages a deletion per path - // and is routinely hundreds at once; only wipe-shaped deletions count. - const split = splitDeletions(repoDir, staged) - const deletions = split.clobberish.length - if (deletions === 0) { - return undefined - } - const tracked = countTrackedFiles(repoDir) - const reason = catastrophicReason(deletions, tracked) - if (!reason) { - return undefined - } - const excused = describeBenignUntrackings(split) - - const transcriptPath = payload.transcript_path - if ( - transcriptPath && - bypassPhrasePresent(transcriptPath, BYPASS_PHRASES, 3) - ) { - return undefined - } - - return block( - [ - `[mass-delete-guard] Blocked: this commit would wipe most of the repo.`, - '', - ` ${reason}.`, - ...(excused ? ['', ` ${excused}`] : []), - '', - ' A commit that deletes this much is almost always a clobbered index', - ' (a stray git read-tree, a commit fired against a near-empty or', - ' foreign index, a misfired scripted commit), not an intentional', - ' change. Pushing it makes recovery ugly.', - '', - ' Check first:', - ' git status # is the worktree actually intact?', - ' git diff --cached --stat | tail -1', - ' If the index is wrong, reset it (worktree is usually fine):', - ' git reset --mixed HEAD', - ' then stage only what you meant: git add …', - '', - ' Genuinely removing a large tree (vendored dir, retired package)?', - ' Type "Allow mass-delete bypass" in chat, then retry.', - ].join('\n'), - ) -}) - -export const hook = defineHook({ - bypass: ['mass-delete'], - bypassMode: 'manual', - check, - event: 'PreToolUse', - matcher: ['Bash'], - triggers, - type: 'guard', -}) -void runHook(hook, import.meta.url) diff --git a/.claude/hooks/fleet/mass-delete-guard/package.json b/.claude/hooks/fleet/mass-delete-guard/package.json deleted file mode 100644 index bf3bf2e..0000000 --- a/.claude/hooks/fleet/mass-delete-guard/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "hook-mass-delete-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "dependencies": { - "@socketsecurity/lib-stable": "catalog:", - "shell-quote": "catalog:" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/fleet/memory-codify-nudge/README.md b/.claude/hooks/fleet/memory-codify-nudge/README.md deleted file mode 100644 index bac4e3e..0000000 --- a/.claude/hooks/fleet/memory-codify-nudge/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# memory-codify-nudge - -**Type:** PostToolUse hook on Edit/MultiEdit/Write (NUDGE — informational, -never blocks). - -## Trigger - -Fires when the written `file_path` is a Claude memory-store file: - -- `…/.claude/projects//memory/.md` — a per-cwd store entry -- `…/memory/MEMORY.md` — a store's index - -Silent on every other write. - -## What it says - -The reminder that lands in the same turn as the save: a memory steers ONE -agent on ONE machine, a guard steers them all. When the memory encodes a rule, -correction, or convention, ALSO codify it as an enforceable artifact — a hook -guard/nudge, a `socket/*` lint rule, a `scripts/fleet/check/*` check, or a -`docs/agents.md` rule — then stamp the memory with an `enforcement:` line. It -points at `/codifying-disciplines` and `scripts/fleet/codify-rule.mts` for the -mechanics. Reference/user memories need no enforcer. - -## Division of labor - -- **This hook** is immediate and unconditional on a memory write — the - cheapest moment to codify, while the context is loaded. -- **`uncodified-lesson-nudge`** (Stop) is shape-gated: it re-raises at turn end - when the written lesson is enforceable AND cites no enforcer, escalating - across sessions via the learning ledger. -- **`scripts/fleet/check/memories-are-codified.mts`** audits the whole store. - -Detail: [`memory-codification`](../../../../docs/agents.md/fleet/memory-codification.md) - -## Bypass - -None — it only prints informational text and cannot block or mutate anything. diff --git a/.claude/hooks/fleet/memory-codify-nudge/index.mts b/.claude/hooks/fleet/memory-codify-nudge/index.mts deleted file mode 100644 index 1a9da46..0000000 --- a/.claude/hooks/fleet/memory-codify-nudge/index.mts +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env node -// Claude Code PostToolUse(Edit/Write) hook — memory-codify-nudge. -// -// Fires the moment a memory-store file is written. A memory steers ONE agent -// on ONE machine; a guard, lint rule, or check steers every agent in the -// fleet. When the memory being saved encodes a rule, correction, or -// convention, the write moment is the cheapest time to also codify it — the -// context is loaded and the lesson is fresh. -// -// Division of labor across the memory-codification surfaces: -// - THIS hook: immediate, unconditional on a memory-store write — the -// reminder lands in the same turn as the save. -// - uncodified-lesson-nudge (Stop): shape-gated — re-raises at turn end when -// the written lesson is enforceable AND cites no enforcer, with -// cross-session escalation via the learning ledger. -// - scripts/fleet/check/memories-are-codified.mts: the audit over the whole -// store. -// -// PostToolUse, notify only — never blocks, always exits 0. No bypass phrase. - -import { defineHook, editGuard, notify, runHook } from '../_shared/guard.mts' - -// A Claude memory-store file, separator-normalized: -// …/.claude/projects//memory/.md, per-cwd store -// …/memory/MEMORY.md, a store's index -const MEMORY_STORE_RE = - /\/\.claude\/projects\/[^/]+\/memory\/[^/]+\.md$|\/memory\/MEMORY\.md$/ - -export function isMemoryStorePath(filePath: string): boolean { - return MEMORY_STORE_RE.test(filePath.replaceAll('\\', '/')) -} - -// The reminder, pure so the tests pin it directly. -export function buildMemoryCodifyNudge(filePath: string): string { - const short = filePath.replace(/^.*\/memory\//, 'memory/') - return [ - `[memory-codify-nudge] Memory saved (${short}). A memory steers ONE agent; a guard steers them all.`, - ' If this memory encodes a rule, correction, or convention, ALSO codify it as an enforceable artifact:', - ' a hook guard/nudge, a socket/* lint rule, a scripts/fleet/check/* check, or a docs/agents.md rule —', - ' then stamp the memory with an `enforcement:` line. Run `/codifying-disciplines`, or for a single rule', - ' `node scripts/fleet/codify-rule.mts --memory --apply`. Reference/user memories need no enforcer.', - ].join('\n') -} - -export const check = editGuard(filePath => { - if (!isMemoryStorePath(filePath)) { - return undefined - } - return notify(buildMemoryCodifyNudge(filePath)) -}) - -export const hook = defineHook({ - check, - event: 'PostToolUse', - matcher: ['Edit', 'MultiEdit', 'Write'], - type: 'nudge', -}) - -void runHook(hook, import.meta.url) diff --git a/.claude/hooks/fleet/memory-codify-nudge/package.json b/.claude/hooks/fleet/memory-codify-nudge/package.json deleted file mode 100644 index f87afda..0000000 --- a/.claude/hooks/fleet/memory-codify-nudge/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-memory-codify-nudge", - "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/memory-codify-nudge/tsconfig.json b/.claude/hooks/fleet/memory-codify-nudge/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/memory-codify-nudge/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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/memory-discovery-nudge/README.md b/.claude/hooks/fleet/memory-discovery-nudge/README.md deleted file mode 100644 index eb3eee3..0000000 --- a/.claude/hooks/fleet/memory-discovery-nudge/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# memory-discovery-nudge - -**Type:** SessionStart hook (NUDGE — informational, never blocks). - -## Trigger - -Fires once at session start when a discoverable memory store exists. It resolves: - -1. **This repo's** store: `~/.claude/projects//memory/`, where `` is - the session cwd's absolute path with every `/` (including the leading one) - replaced by `-`. -2. **The shared fleet (wheelhouse) store**: the same scheme applied to the - sibling `socket-wheelhouse` checkout (`/socket-wheelhouse`). - -If either has a `MEMORY.md` index, it prints — as SessionStart -`additionalContext` — where the store(s) are and the filing convention. Silent -when neither store has an index (new/empty projects add no noise). When the -session is already in the wheelhouse, the "fleet store" line is omitted (it would -point at itself). - -## Why - -Persistent memory lives in `~/.claude`, keyed to cwd — **not committed, not -shared across checkouts, not inherited by spawned subagents**. A session -therefore has no way to know its memory exists, nor that a *different* repo's -store, the fleet-wide wheelhouse one, is the correct home for a given fact. The -result, without this hook: fleet-wide lessons get siloed under whatever repo the -session happened to be standing in, invisible to a session in another fleet repo -doing the same fleet work. - -This hook surfaces both stores and the rule: **remember a fact in the store of -the repo that OWNS it** — fleet/cross-repo facts → the wheelhouse store; this-repo -facts → here — resolving any repo's store generically as -`~/.claude/projects//memory/`. So every fleet session (and any -agent reading this) knows where to look and where to file. - -## Bypass - -None — it only prints informational text and cannot block or mutate anything. -It stays silent on its own when no memory store with a `MEMORY.md` index exists. diff --git a/.claude/hooks/fleet/memory-discovery-nudge/index.mts b/.claude/hooks/fleet/memory-discovery-nudge/index.mts deleted file mode 100644 index a02fd14..0000000 --- a/.claude/hooks/fleet/memory-discovery-nudge/index.mts +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env node -// Claude Code SessionStart hook — memory-discovery-nudge. -// -// Persistent file-based memory lives OUTSIDE the repo, under the user's home: -// ~/.claude/projects//memory/ (slug = absolute project path, "/" → "-") -// keyed to the session's cwd. It is per-user, per-cwd — NOT committed, NOT shared -// across checkouts, NOT inherited by spawned subagents. So a session has no way to -// know it exists, or that a DIFFERENT repo's memory (e.g. the fleet-wide wheelhouse -// store) is the right place for a given fact, unless told. -// -// This hook tells it, at session start: -// 1. Where THIS repo's memory store is, resolved generically from cwd. -// 2. Where the shared FLEET/wheelhouse store is, the cross-repo brain — so a -// fact owned by the fleet gets filed there, not siloed under whatever repo -// the session happens to be standing in. -// 3. The filing convention: remember a fact in the store of the repo that OWNS -// it; resolve any repo's store as ~/.claude/projects//memory/. -// -// Only surfaces a store that actually exists + has a MEMORY.md index (silent -// otherwise, so empty/new projects add no noise). Pure-informational: never -// blocks, never writes, never fails the session. - -import { existsSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { defineHook, notify, runHook } from '../_shared/guard.mts' -import { resolveProjectDir } from '../_shared/project-dir.mts' - -// The wheelhouse is the fleet's shared memory store — facts that apply to every -// fleet repo, canonical rules, cascade mechanics, cross-repo standards, belong -// here, NOT under the repo a session happens to be in. Resolved by the wheelhouse -// checkout's conventional sibling location relative to the current repo's parent. -const WHEELHOUSE_DIR_NAME = 'socket-wheelhouse' - -// Slugify an absolute project path the way the harness keys its memory store: -// every "/", including the leading one, becomes "-". -export function projectSlug(absPath: string): string { - return absPath.replace(/\//g, '-') -} - -// The memory dir for a given absolute project path, or undefined if the path is -// not absolute, can't be slugified into a stable key. -export function memoryDirFor(absPath: string): string | undefined { - if (!absPath || !path.isAbsolute(absPath)) { - return undefined - } - return path.join( - os.homedir(), - '.claude', - 'projects', - projectSlug(absPath), - 'memory', - ) -} - -// A store counts as "present" only when it has a MEMORY.md index to read. -export function storeHasIndex(memoryDir: string | undefined): boolean { - return ( - memoryDir !== undefined && existsSync(path.join(memoryDir, 'MEMORY.md')) - ) -} - -// Resolve the sibling wheelhouse checkout's path from the current cwd. Fleet -// repos are checked out as siblings (…/projects/socket-btm, …/projects/socket- -// wheelhouse), so the wheelhouse is /socket-wheelhouse. -export function wheelhousePathFrom(cwd: string): string | undefined { - if (!cwd || !path.isAbsolute(cwd)) { - return undefined - } - return path.join(path.dirname(cwd), WHEELHOUSE_DIR_NAME) -} - -// Build the session-start hint, or undefined when neither store is discoverable. -// Pure — the test drives it directly. -export function memoryHint(cwd: string): string | undefined { - const repoMemory = memoryDirFor(cwd) - const wheelhousePath = wheelhousePathFrom(cwd) - const fleetMemory = wheelhousePath ? memoryDirFor(wheelhousePath) : undefined - - const repoPresent = storeHasIndex(repoMemory) - // The fleet store is only "other" when this session isn't already IN the - // wheelhouse (else repo == fleet and we'd point at ourselves). - const inWheelhouse = - repoMemory !== undefined && - fleetMemory !== undefined && - repoMemory === fleetMemory - const fleetPresent = !inWheelhouse && storeHasIndex(fleetMemory) - - if (!repoPresent && !fleetPresent) { - return undefined - } - - const lines: string[] = [ - 'This repo has persistent file-based memory (per-user, per-cwd, NOT committed). ' + - 'Convention: remember a fact in the store of the repo that OWNS it — ' + - 'fleet/cross-repo facts go in the wheelhouse store, this-repo facts go here. ' + - 'Resolve any repo’s store as ~/.claude/projects//memory/.', - ] - if (repoPresent) { - lines.push(`This repo's memory: ${repoMemory} (read its MEMORY.md index).`) - } - if (fleetPresent) { - lines.push( - `Shared FLEET (wheelhouse) memory: ${fleetMemory} (read its MEMORY.md) — ` + - 'file fleet-wide facts THERE, not under this repo.', - ) - } - return lines.join(' ') -} - -export const check = () => { - const hint = memoryHint(resolveProjectDir()) - if (!hint) { - return undefined - } - return notify(`[memory-discovery] ${hint}`) -} - -export const hook = defineHook({ - check, - event: 'SessionStart', - type: 'nudge', -}) - -void runHook(hook, import.meta.url) diff --git a/.claude/hooks/fleet/memory-enforcement-stamp-guard/README.md b/.claude/hooks/fleet/memory-enforcement-stamp-guard/README.md deleted file mode 100644 index bd63990..0000000 --- a/.claude/hooks/fleet/memory-enforcement-stamp-guard/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# memory-enforcement-stamp-guard - -PreToolUse guard. Blocks a Write / Edit / MultiEdit to a durable memory **entry** -whose frontmatter carries no `enforcement:` key. - -## Why - -A memory steers one agent; an enforcer steers them all. Every codifiable memory -must therefore declare how it is enforced, and -`scripts/fleet/check/memories-are-codified.mts` fails the commit-time gate when -one does not. That check detects the gap after the fact. This guard prevents it, -so a stamped store stays stamped. Full rationale: -[`memory-codification`](../../../../docs/agents.md/fleet/memory-codification.md). - -## Scope - -- Fires on `…/.claude/projects//memory/.md` only. -- Skips the store's `MEMORY.md` index — it carries no frontmatter and states no - rule. -- Skips every markdown file outside a memory store. -- Fails open when the payload carries no readable written text. - -## Purely mechanical, on purpose - -The guard asks one decidable question: is there a non-empty `enforcement:` key? -It never judges whether the disposition names a real enforcer. That judgment is -`uncodified-lesson-nudge`'s job — a Stop hook, non-blocking, which fires when a -memory has an enforceable always/never/MUST shape but cites no enforcer. Two -concerns, two surfaces; keep them separate. - -## Accepted dispositions - -```yaml -enforcement: .claude/hooks/fleet/ # a hook, lint rule, or check ref -enforcement: deferred # # a tracked follow-up -enforcement: n/a — # a pure-preference lesson -``` - -## Bypass - -`Allow memory-enforcement-stamp bypass` diff --git a/.claude/hooks/fleet/memory-enforcement-stamp-guard/index.mts b/.claude/hooks/fleet/memory-enforcement-stamp-guard/index.mts deleted file mode 100644 index 5b10441..0000000 --- a/.claude/hooks/fleet/memory-enforcement-stamp-guard/index.mts +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env node -/* - * @file Claude Code PreToolUse hook — memory-enforcement-stamp-guard. - * - * Blocks a Write / Edit / MultiEdit to a durable memory ENTRY - * (`…/.claude/projects//memory/.md`) whose frontmatter carries no - * `enforcement:` key. `scripts/fleet/check/memories-are-codified.mts` catches - * the same gap at commit time; this guard stops it being written in the first - * place, so a stamped store stays stamped. - * - * Purely MECHANICAL: the key must be present and non-empty. Whether the - * disposition names a REAL enforcer is a judgment call and belongs to - * `uncodified-lesson-nudge` (a Stop hook, non-blocking, deliberately a - * separate surface — one surface per concern). Do not merge the two. - * - * Out of scope, never blocked: the store's `MEMORY.md` index (it carries no - * frontmatter and states no rule) and any markdown file outside a memory - * store. - * - * Fails OPEN on a parse / payload error, like its siblings. - * - * Bypass: `Allow memory-enforcement-stamp bypass`. - */ - -import { block, defineHook, editGuard, runHook } from '../_shared/guard.mts' -import { - hasEnforcementStamp, - isMemoryEntryPath, -} from '../_shared/memory-store.mts' - -/** - * The three accepted dispositions, verbatim, so the caller can copy one. - */ -export const ACCEPTED_DISPOSITIONS = [ - 'enforcement: .claude/hooks/fleet/ # a hook, lint rule, or check ref', - 'enforcement: deferred # # a tracked follow-up', - 'enforcement: n/a — # a pure-preference lesson', -] as const - -/** - * The block message: What / Where / Saw vs. wanted / Fix. - */ -export function stampBlockMessage(filePath: string): string { - const name = filePath.slice(filePath.lastIndexOf('/') + 1) - return [ - '🚨 memory-enforcement-stamp-guard: refusing a memory entry with no', - ' `enforcement:` disposition.', - '', - `Where: ${name}`, - '', - 'Saw: frontmatter with no `enforcement:` key.', - 'Wanted: every codifiable memory declares how it is enforced, so the store', - ' never drifts into policy-on-paper.', - '', - 'Fix: add ONE of these lines to the frontmatter:', - '', - ...ACCEPTED_DISPOSITIONS.map(line => ` ${line}`), - '', - ' Detail: docs/agents.md/fleet/memory-codification.md.', - ].join('\n') -} - -export const check = editGuard((filePath, content) => { - if (!isMemoryEntryPath(filePath)) { - return undefined - } - // No readable written text (a MultiEdit shape the payload reader can't - // flatten) — fail open rather than block on a guess. - if (typeof content !== 'string' || !content) { - return undefined - } - if (hasEnforcementStamp(content)) { - return undefined - } - return block(stampBlockMessage(filePath)) -}) - -export const hook = defineHook({ - bypass: ['memory-enforcement-stamp'], - bypassOptional: true, - check, - event: 'PreToolUse', - matcher: ['Edit', 'MultiEdit', 'Write'], - type: 'guard', -}) - -void runHook(hook, import.meta.url) diff --git a/.claude/hooks/fleet/memory-enforcement-stamp-guard/package.json b/.claude/hooks/fleet/memory-enforcement-stamp-guard/package.json deleted file mode 100644 index 4a3eacd..0000000 --- a/.claude/hooks/fleet/memory-enforcement-stamp-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-memory-enforcement-stamp-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/memory-enforcement-stamp-guard/tsconfig.json b/.claude/hooks/fleet/memory-enforcement-stamp-guard/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/memory-enforcement-stamp-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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/mermaid-github-safe-nudge/index.mts b/.claude/hooks/fleet/mermaid-github-safe-nudge/index.mts deleted file mode 100644 index 0437750..0000000 --- a/.claude/hooks/fleet/mermaid-github-safe-nudge/index.mts +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — mermaid-github-safe-nudge. -// -// GitHub renders mermaid with floating control clusters INSIDE the -// diagram container (copy/expand top-right, a six-button pan/zoom -// cluster mid-right), so diagram content near the right edge gets -// covered. This took three PR-body render cycles to learn on a sequence -// diagram; the shapes are codified in _shared/mermaid-github.mts and -// this nudge fires whenever written content carries a mermaid fence -// that violates them — naming the exact rewrite and the wheelhouse -// fixer. Advisory only: a diagram not destined for GitHub is fine as -// written. - -import { analyzeMarkdownMermaid } from '../_shared/mermaid-github.mts' -import { defineHook, notify, runHook } from '../_shared/guard.mts' -import type { GuardResult } from '../_shared/guard.mts' -import type { ToolCallPayload } from '../_shared/payload.mts' -import { resolveEditedText } from '../_shared/payload.mts' - -const MAX_LISTED = 6 - -function analyzeText(text: string): GuardResult { - if (!text.includes('```mermaid')) { - return undefined - } - const { issues } = analyzeMarkdownMermaid(text) - if (issues.length === 0) { - return undefined - } - const listed = issues.slice(0, MAX_LISTED) - return notify( - `mermaid-github-safe-nudge: ${issues.length} GitHub-render issue(s) in the mermaid block(s):\n` + - `${listed.map(i => ` - line ${i.line}: ${i.message}`).join('\n')}\n` + - ` GitHub floats its control clusters over the diagram's right edge.\n` + - ` Fix in place: node scripts/repo/gen/mermaid-github-safe.mts (wheelhouse), or apply the rewrites above.`, - ) -} - -export const check = (payload: ToolCallPayload): GuardResult => { - const tool = payload.tool_name - if (tool === 'Edit' || tool === 'MultiEdit' || tool === 'Write') { - const text = resolveEditedText(payload) - if (!text) { - return undefined - } - return analyzeText(text) - } - if (tool === 'Bash') { - // A PR body assembled inline (gh pr create --body "…" / a heredoc) - // never passes through Edit/Write — scan the command text itself. - const command = payload.tool_input?.command - if (typeof command !== 'string') { - return undefined - } - return analyzeText(command) - } - return undefined -} - -export const hook = defineHook({ - check, - event: 'PreToolUse', - matcher: ['Edit', 'Write', 'MultiEdit', 'Bash'], - triggers: ['mermaid'], - type: 'nudge', -}) -void runHook(hook, import.meta.url) diff --git a/.claude/hooks/fleet/minimum-release-age-guard/README.md b/.claude/hooks/fleet/minimum-release-age-guard/README.md deleted file mode 100644 index c5152ae..0000000 --- a/.claude/hooks/fleet/minimum-release-age-guard/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# minimum-release-age-guard - -PreToolUse Edit/Write hook that blocks additions to `pnpm-workspace.yaml` -`minimumReleaseAge.exclude[]`. - -## Why - -`pnpm`'s `minimumReleaseAge` (typically set to `7d`) refuses to install -packages whose npm publish date is younger than the cap. The cap is -malware-soak protection: packages published within the last week are still -in the suspicion window for typosquats, postinstall malware, and supply-chain -attacks that haven't yet been caught by Socket / npm / community signal. - -`minimumReleaseAge.exclude[]` opts specific packages OUT of the soak. Every -entry is a malware-protection hole — and most attempts to add to it are -quick-fix shortcuts to install a package that just published, not legitimate -emergency CVE patches. - -## What it blocks - -| Pattern | Block? | -| ------------------------------------------------------------------- | ------ | -| Edit/Write that adds a name to `minimumReleaseAge.exclude[]` | yes | -| Edit/Write that removes a name from `minimumReleaseAge.exclude[]` | no | -| Edit/Write touching `pnpm-workspace.yaml` but not the exclude array | no | -| Edit/Write to any other file | no | - -## Bypass - -Type the canonical phrase in a new message: - - Allow soak-time bypass - -`Allow minimumReleaseAge bypass` still works as an alias. The matcher folds -hyphens to spaces, so `Allow soak time bypass` matches too. - -Use sparingly. The legitimate cases are: - -- Emergency CVE patch published in the last 7 days. -- First-party package you control (lower attack-surface risk). - -## Detection - -The hook parses both the current file contents and the after-edit contents -as YAML (permissive, narrow to the `minimumReleaseAge.exclude` block), then -computes the set difference. Names added → block. Names removed or unchanged -→ pass. - -Fails open on YAML parse errors — better to under-block than to brick edits -when the file is in a transient bad state. diff --git a/.claude/hooks/fleet/minimum-release-age-guard/index.mts b/.claude/hooks/fleet/minimum-release-age-guard/index.mts deleted file mode 100644 index 835886e..0000000 --- a/.claude/hooks/fleet/minimum-release-age-guard/index.mts +++ /dev/null @@ -1,178 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — minimum-release-age-guard. -// -// Blocks Edit/Write operations that add entries to a `pnpm-workspace.yaml` -// file's `minimumReleaseAge.exclude[]` array. The 7-day soak is intentional -// malware-soak protection — packages on npm <7 days are still in the -// suspicion window for typosquats / postinstall-script malware / etc. -// Adding to the exclude list bypasses that protection. -// -// Socket-owned scopes are EXEMPT: `@socketregistry/*` and `@socketsecurity/*` -// are first-party packages Socket itself publishes, blanket-excluded from the -// soak in `.npmrc` (`min-release-age-exclude[]=@socketregistry/*` / -// `@socketsecurity/*`). The soak defends against THIRD-PARTY supply-chain -// attacks; there is no external attacker to soak against on a registry Socket -// controls, so a same-day `@socketregistry/*` bump is never a soak violation and -// this guard does not flag it. -// -// Detection model: -// - Fires only on Edit / Write to files named `pnpm-workspace.yaml`. -// - For Edit: applies new_string-over-old_string to current file contents, -// parses before+after as YAML, computes the set difference of the -// `minimumReleaseAge.exclude` array. New names → block. -// - For Write: compares against current contents (absent file = empty -// exclude array). -// -// Bypass: `Allow soak-time bypass` (alias: `Allow minimumReleaseAge bypass`) -// typed verbatim in a recent user turn — for emergency CVE patches where a -// legitimately-published-yesterday fix must be installed before the 7-day -// window closes. The matcher folds hyphens to spaces, so `soak-time` and -// `soak time` both match the same phrase. -// -// Fails open on parse errors (better to under-block than to brick edits -// when the file isn't parseable YAML). - -import path from 'node:path' - -import { safeReadFileSync } from '@socketsecurity/lib-stable/fs/read-file' - -import { block, defineHook, editGuard, runHook } from '../_shared/guard.mts' -import { resolveEditedText } from '../_shared/payload.mts' - -// Permissive YAML extraction tailored to the `minimumReleaseAge.exclude` -// block. We don't pull in a full YAML library — the block shape is narrow: -// -// minimumReleaseAge: -// exclude: -// - pkg-a -// - "@scope/pkg-b" -// -// Returns the set of `- ` entries under the exclude list. Empty set -// when the block isn't present. -export function extractExcludeNames(yamlText: string): Set { - const lines = yamlText.split(/\r?\n/) - const out = new Set() - let inMra = false - let mraIndent = -1 - let inExclude = false - let excludeIndent = -1 - for (let i = 0, { length } = lines; i < length; i += 1) { - const raw = lines[i]! - const line = raw.replace(/\s+#.*$/, '') - const trimmed = line.trim() - if (!trimmed) { - continue - } - const indent = line.length - line.trimStart().length - - if (!inMra) { - if (/^minimumReleaseAge\s*:\s*$/.test(trimmed)) { - inMra = true - mraIndent = indent - } - continue - } - - if (indent <= mraIndent && trimmed.length > 0) { - inMra = false - inExclude = false - continue - } - - if (!inExclude) { - if (/^exclude\s*:\s*$/.test(trimmed)) { - inExclude = true - excludeIndent = indent - } - continue - } - - if (indent <= excludeIndent && trimmed.length > 0) { - inExclude = false - continue - } - - const itemMatch = /^-\s+(?.+)$/.exec(trimmed) - if (!itemMatch) { - continue - } - let name = itemMatch.groups!['item']!.trim() - // Strip a surrounding pair of single or double quotes from a YAML scalar value. - name = name.replace(/^["']|["']$/g, '') - if (name) { - out.add(name) - } - } - return out -} - -// Socket-owned first-party scopes, blanket soak-excluded in `.npmrc`. An -// exclude entry for one of these — the `@scope/*` glob itself or any concrete -// member (`@socketregistry/packageurl-js`, `@socketregistry/foo@1.4.8`) — is -// policy-exempt, not a bypass case, so the guard never blocks it. -const SOCKET_OWNED_SCOPES = ['@socketregistry/', '@socketsecurity/'] as const - -export function isSocketOwnedScope(name: string): boolean { - return SOCKET_OWNED_SCOPES.some(scope => name.startsWith(scope)) -} - -export const check = editGuard((filePath, _content, payload) => { - if (path.basename(filePath) !== 'pnpm-workspace.yaml') { - return undefined - } - const currentText = safeReadFileSync(filePath) ?? '' - const afterText = resolveEditedText(payload) - if (afterText === undefined) { - return undefined - } - - const beforeNames = extractExcludeNames(currentText) - const afterNames = extractExcludeNames(afterText) - - const added: string[] = [] - for (const name of afterNames) { - // Socket-owned first-party scopes are soak-exempt by policy — never flag them. - if (!beforeNames.has(name) && !isSocketOwnedScope(name)) { - added.push(name) - } - } - if (added.length === 0) { - return undefined - } - - added.sort() - return block( - [ - '[minimum-release-age-guard] Blocked: minimumReleaseAge.exclude additions', - '', - ` File: ${filePath}`, - ` New entries: ${added.map(n => `\`${n}\``).join(', ')}`, - '', - ' The 7-day `minimumReleaseAge` soak is intentional malware-soak', - ' protection. Packages on npm < 7 days are still in the typosquat /', - ' postinstall-malware suspicion window. Adding to `exclude[]`', - ' bypasses that protection for the listed packages.', - '', - ' Socket-owned scopes (`@socketregistry/*`, `@socketsecurity/*`) are', - ' already soak-exempt in `.npmrc` and are never flagged here — this block', - ' is for a THIRD-PARTY package still inside the malware-suspicion window.', - '', - ' Legitimate case (rare): an emergency CVE patch published < 7 days ago.', - '', - " Don't hand-edit the exclude list — run the canonical helper, which", - ' looks up the npm publish date and writes the dated annotation for you:', - ' node scripts/fleet/soak-bypass.mts @', - ' (the daily updating-daily job removes the entry once its soak clears).', - ].join('\n'), - ) -}) - -export const hook = defineHook({ - bypass: ['soak-time', 'minimum-release-age'], - check, - event: 'PreToolUse', - matcher: ['Edit', 'Write', 'MultiEdit'], - type: 'guard', -}) - -void runHook(hook, import.meta.url) diff --git a/.claude/hooks/fleet/minimum-release-age-guard/package.json b/.claude/hooks/fleet/minimum-release-age-guard/package.json deleted file mode 100644 index b321f0d..0000000 --- a/.claude/hooks/fleet/minimum-release-age-guard/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "hook-minimum-release-age-guard", - "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/minimum-release-age-guard/tsconfig.json b/.claude/hooks/fleet/minimum-release-age-guard/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/minimum-release-age-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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/module-noun-name-guard/README.md b/.claude/hooks/fleet/module-noun-name-guard/README.md deleted file mode 100644 index 63ad7ae..0000000 --- a/.claude/hooks/fleet/module-noun-name-guard/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# module-noun-name-guard - -**Event:** PreToolUse (`Edit`, `Write`, `MultiEdit`) · **Type:** guard (blocks) · **Scope:** fleet repos only - -Blocks **creating** a new `src/` module whose filename is a verb-phrase action — `trim-publish-manifest.ts`, `create-release.ts`, `fetch-packument.ts`. Fleet/socket-lib modules are concise **NOUN** names that group the related functions for a domain (`manifest.ts` holds `trimPublishManifest` + `createPackageJson`, reachable via one `exports` entry); we don't do one-method-per-file. - -## Allowed (passes) - -- Single-word names — `manifest.ts`, `normalize.ts`: a one-word verb reads as the domain. -- Noun-phrase names — `package-json.ts` (first segment isn't an action verb). -- Predicate prefixes — `is-number.ts`, `has-foo.ts`. -- Exempt stems — `index`, `types`, `constants`, `primordials`; `.test.mts`; `.d.ts`. -- Anything outside a `src/` segment (scripts, config). -- Editing a file that already exists (creation-only; never disturbs prior layout). - -## Bypass - -Type `Allow module-noun-name bypass` for a deliberate exception. - -See [`docs/agents.md/fleet/module-naming.md`](../../../../docs/agents.md/fleet/module-naming.md). diff --git a/.claude/hooks/fleet/module-noun-name-guard/index.mts b/.claude/hooks/fleet/module-noun-name-guard/index.mts deleted file mode 100644 index 21bcdbf..0000000 --- a/.claude/hooks/fleet/module-noun-name-guard/index.mts +++ /dev/null @@ -1,196 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — module-noun-name-guard. -// -// Blocks creating a new `src/` module file whose name is a verb-phrase -// (an ACTION), e.g. `trim-publish-manifest.ts`, `create-release.ts`, -// `generate-notes.ts`, `fetch-packument.ts`. -// -// Fleet/socket-lib convention: a module is a concise NOUN that names a -// DOMAIN and groups the related functions for it — `manifest.ts`, -// `exports.ts`, `tarball.ts`, `normalize.ts`. The trim/create/fetch -// helpers live INSIDE the relevant noun module (trimPublishManifest + -// createPackageJson both sit in `manifest.ts`), reachable through that -// module's single `exports` entry. We do NOT do one-method-per-file, and -// we do NOT name a file after the verb phrase of the one function it holds. -// -// Why: verb-phrase filenames fragment a domain across a dozen tiny files, -// multiply the hand-maintained `exports` map, and bury the noun a reader -// is actually looking for. Grouping by noun keeps the public surface small -// and the related code co-located. -// -// The check is filename-only and fires on CREATION, Write of a new path -// so it never disturbs files that predate the rule. Single-word names are -// always allowed (a one-word verb like `normalize` reads as the domain); -// only a multi-segment kebab phrase LED BY an action verb is blocked. -// -// Exit code 2 makes Claude Code refuse the tool call. -// -// Reads a Claude Code PreToolUse JSON payload from stdin: -// { "tool_name": "Write", -// "tool_input": { "file_path": "...", "content": "..." } } -// -// Fails open on hook bugs (exit 0 + stderr log). - -import { existsSync } from 'node:fs' -import path from 'node:path' - -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' - -import { isFleetTarget } from '../_shared/fleet-context.mts' -import { block, defineHook, editGuard, runHook } from '../_shared/guard.mts' - -// Leading action verbs that mark a filename as an ACTION rather than a -// domain noun. Deliberately excludes predicate prefixes (is/has/can/should) -// and stand-alone single-word names — those are handled by the -// single-segment carve-out, so `normalize.ts` / `validate.ts` stay legal. -const ACTION_VERBS: ReadonlySet = new Set([ - 'add', - 'apply', - 'build', - 'bump', - 'calculate', - 'check', - 'clean', - 'clear', - 'collect', - 'compile', - 'compute', - 'convert', - 'copy', - 'create', - 'delete', - 'detect', - 'download', - 'ensure', - 'extract', - 'fetch', - 'filter', - 'find', - 'fix', - 'format', - 'gather', - 'generate', - 'get', - 'handle', - 'init', - 'initialize', - 'install', - 'load', - 'make', - 'merge', - 'parse', - 'print', - 'process', - 'read', - 'remove', - 'render', - 'resolve', - 'run', - 'save', - 'scan', - 'send', - 'set', - 'sort', - 'split', - 'strip', - 'sync', - 'trim', - 'update', - 'upload', - 'validate', - 'verify', - 'write', -]) - -// Basenames, without extension, that are structural, not domain modules. -const EXEMPT_STEMS: ReadonlySet = new Set([ - 'constants', - 'index', - 'primordials', - 'types', -]) - -export type Verdict = { - ok: boolean - message?: string | undefined - suggestion?: string | undefined -} - -export function classifyModulePath(absPath: string): Verdict { - const normalized = normalizePath(absPath) - const filename = path.basename(normalized) - - // Source modules only: TypeScript under a `src/` segment. - if (!/\.(?:mts|ts)$/.test(filename) || filename.endsWith('.d.ts')) { - return { ok: true } - } - const segments = normalized.split('/') - if (!segments.includes('src')) { - return { ok: true } - } - // Tests own their own naming (`.test.mts`); never govern them. - if (segments.includes('test') || segments.includes('__tests__')) { - return { ok: true } - } - - const stem = filename.replace(/\.(?:mts|ts)$/, '') - if (EXEMPT_STEMS.has(stem) || stem.endsWith('.test')) { - return { ok: true } - } - - // Single-word names are domain nouns by construction (`manifest`, - // `normalize`, `tarball`) — always allowed. - const parts = stem.split('-') - if (parts.length < 2) { - return { ok: true } - } - - const lead = parts[0]!.toLowerCase() - if (!ACTION_VERBS.has(lead)) { - return { ok: true } - } - - const domain = parts[parts.length - 1]! - return { - ok: false, - message: `${filename} is a verb-phrase (action "${lead}"), not a domain noun`, - suggestion: `add the function to an existing noun module (e.g. \`${domain}.ts\`), or name the file after its domain noun`, - } -} - -export function emitBlock(filePath: string, verdict: Verdict): string { - const issue = verdict.message ?? 'verb-phrase module name, not a domain noun' - const fix = - verdict.suggestion ?? - 'name the file after the domain noun its functions share' - return `🚨 module-noun-name-guard: blocked "${filePath}" — ${issue} — ${fix}` -} - -export const check = editGuard((filePath, content, payload) => { - void content - const verdict = classifyModulePath(filePath) - if (verdict.ok) { - return undefined - } - // Fleet convention only — a sibling/external clone owns its own layout. - if (!isFleetTarget(payload)) { - return undefined - } - // Only block CREATION of a new verb-phrase module. Editing a file that - // already exists predates this rule and must never be blocked. - if (existsSync(filePath)) { - return undefined - } - return block(emitBlock(filePath, verdict)) -}) - -export const hook = defineHook({ - bypass: ['module-noun-name'], - bypassOptional: true, - check, - event: 'PreToolUse', - matcher: ['Edit', 'Write', 'MultiEdit'], - type: 'guard', -}) - -void runHook(hook, import.meta.url) diff --git a/.claude/hooks/fleet/module-noun-name-guard/package.json b/.claude/hooks/fleet/module-noun-name-guard/package.json deleted file mode 100644 index dfb6a0a..0000000 --- a/.claude/hooks/fleet/module-noun-name-guard/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "hook-module-noun-name-guard", - "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/module-noun-name-guard/tsconfig.json b/.claude/hooks/fleet/module-noun-name-guard/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/module-noun-name-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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/new-hook-claude-md-guard/README.md b/.claude/hooks/fleet/new-hook-claude-md-guard/README.md deleted file mode 100644 index 4c3d52a..0000000 --- a/.claude/hooks/fleet/new-hook-claude-md-guard/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# new-hook-claude-md-guard - -**Wheelhouse-only** PreToolUse hook. Blocks `Write` / `Edit` to a hook's `index.mts` unless `template/CLAUDE.md` contains an `(enforced by `.claude/hooks//`)` reference for that hook. - -## Why - -Fleet repos read `template/CLAUDE.md` as the source of truth for behavioral rules. A hook without a corresponding CLAUDE.md entry is policy that exists in code but not on paper — users get blocked by a rule they never read. - -This hook closes that drift the moment it would land. Without the CLAUDE.md entry, the hook commit is refused. - -## What it requires - -Adding a new hook (`template/.claude/hooks/my-rule/index.mts`) must be accompanied by an entry in `template/CLAUDE.md`: - -```markdown -🚨 Never do bad thing X — explanation here (enforced by `.claude/hooks/my-rule/`). -``` - -The pattern: **one minimal line, attached to the rule it enforces**, with the parenthetical hook reference in `(enforced by `.claude/hooks//`)` form. Don't add prose; the hook's README carries the detail. - -Accepted variants: - -- ``(enforced by `.claude/hooks/my-rule/`)`` — preferred -- ``(enforced by `.claude/hooks/my-rule`)`` — trailing slash optional -- `` enforced by `.claude/hooks/my-rule/` `` — without parens (less common but accepted) - -## 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/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 - -- `template/.claude/hooks/_shared/...` — helpers, not hooks -- `test/*.test.mts` — test files -- `new-hook-claude-md-guard` itself — chicken-and-egg -- Any hook listed in `WHEELHOUSE_ONLY_HOOKS` in index.mts - -## Bypass - -For follow-up commits on the same PR where the CLAUDE.md entry lands separately, type any of these in a user message: - -- `Allow new-hook bypass` -- `Allow new hook bypass` -- `Allow newhook bypass` - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/fleet/new-hook-claude-md-guard/index.mts b/.claude/hooks/fleet/new-hook-claude-md-guard/index.mts deleted file mode 100644 index 8237436..0000000 --- a/.claude/hooks/fleet/new-hook-claude-md-guard/index.mts +++ /dev/null @@ -1,212 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — new-hook-claude-md-guard. -// -// Blocks Write/Edit operations that create or modify a hook's -// `index.mts` unless the relevant CLAUDE.md contains a backticked -// `(`.claude/hooks//`)` citation (minimal form — no prose -// wrapper required). -// -// Two-mode behavior: -// -// 1. In socket-wheelhouse (path matches `template/base/.claude/hooks/`): -// checks `template/base/CLAUDE.md` — the fleet-canonical source. -// Forces any new hook to land alongside a documented rule. -// -// 2. In every fleet repo (path matches `.claude/hooks/` at repo -// root): checks the repo's `CLAUDE.md`. Catches downstream -// forks — if someone adds a hook locally (against the -// no-fleet-fork rule), the missing citation in the cascaded -// fleet block blocks the edit. Defense in depth on top of -// no-fleet-fork-guard. -// -// Fires on: -// - Write to `/template/base/.claude/hooks//index.mts` (wheelhouse) -// - Edit to `/template/base/.claude/hooks//index.mts` (wheelhouse) -// - Write/Edit to `/.claude/hooks//index.mts`, any fleet repo -// -// Skips: -// - `_shared/`, not a hook, just helpers -// - Test files (`test/*.test.mts`) -// - This hook itself (chicken-and-egg) -// -// Bypass: `Allow new-hook bypass` in a recent user turn. - -import { existsSync, readFileSync } from 'node:fs' -import path from 'node:path' -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' - -import { block, defineHook, editGuard, runHook } from '../_shared/guard.mts' - -// Match either: -// /template/.claude/hooks//index.mts (wheelhouse) -// /.claude/hooks//index.mts, any fleet repo -// -// Captures the hook name in group 1. The optional `template/` segment -// covers the wheelhouse path; the optional `fleet/` or `repo/` segment -// covers the docs-style `.claude/hooks/{fleet,repo}//` layout -// (matches the parallel docs/agents.md/{fleet,repo}/ convention). -// hookName is the LEAF name (e.g. `avoid-cd-nudge`), 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\/(?:(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 -// the fleet should know about. Update when adding more. -const WHEELHOUSE_ONLY_HOOKS: ReadonlySet = new Set([ - 'drift-check-nudge', - 'new-hook-claude-md-guard', -]) - -export function findCanonicalClaudeMd( - filePath: string, - cwd: string | undefined, -): string | undefined { - const normalizedFilePath = normalizePath(filePath) - // Wheelhouse mode: `/template/base/.claude/hooks//index.mts` - // → check `/template/base/CLAUDE.md`, the fleet-canonical source. - const tplIdx = normalizedFilePath.indexOf('/template/base/.claude/hooks/') - if (tplIdx >= 0) { - return normalizedFilePath.slice(0, tplIdx) + '/template/base/CLAUDE.md' - } - // Downstream mode: `/.claude/hooks//index.mts` - // → check `/CLAUDE.md`, the cascaded fleet block lives here. - const repoIdx = normalizedFilePath.indexOf('/.claude/hooks/') - if (repoIdx >= 0) { - return normalizedFilePath.slice(0, repoIdx) + '/CLAUDE.md' - } - // Fallback: try cwd-relative. Prefer template/ if present, else - // fall back to repo-root CLAUDE.md. - if (cwd) { - const tplCandidate = path.join(cwd, 'template', 'base', 'CLAUDE.md') - if (existsSync(tplCandidate)) { - return tplCandidate - } - const rootCandidate = path.join(cwd, 'CLAUDE.md') - if (existsSync(rootCandidate)) { - return rootCandidate - } - } - return undefined -} - -export const check = editGuard((filePath, _content, payload) => { - const toolName = payload.tool_name - const normalizedFilePath = normalizePath(filePath) - const match = HOOK_INDEX_PATH_RE.exec(normalizedFilePath) - if (!match) { - return undefined - } - // 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)) { - return undefined - } - // 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 undefined - } - const claudeMdPath = findCanonicalClaudeMd(normalizedFilePath, payload.cwd) - if (!claudeMdPath || !existsSync(claudeMdPath)) { - // Can't find CLAUDE.md; fail-open rather than blocking on - // infrastructure problems. - return undefined - } - let content: string - try { - content = readFileSync(claudeMdPath, 'utf8') - } catch { - return undefined - } - // Three citation shapes recognized (the backticked path is the citation — - // no prose wrapper required; minimal `(\`.claude/hooks/fleet//\`)` is - // the canonical form): - // 1. Inline rule: `(\`.claude/hooks/fleet//\`)` - // 2. Comma-listed: `(\`.claude/hooks/fleet/a/\`, \`.../b/\`)` - // 3. Brace-grouped: `(\`.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 citedIn = (text: string): boolean => - text.includes(literalSlashed) || - text.includes(literalBare) || - braceRe.test(text) - - // A citation in the linked hook-registry doc counts too. CLAUDE.md is - // size-capped (claude-md-size-guard), so the registry — which CLAUDE.md's - // `### Hook registry` section explicitly points at as the "full listing" - // — is the canonical low-cost home for per-hook associations. The registry - // lists each fleet hook as a `- \`\` — description` bullet, so a - // backticked leaf there satisfies the gate, in addition to the path forms. - const registryPath = claudeMdPath.replace( - /CLAUDE\.md$/, - 'docs/agents.md/fleet/hook-registry.md', - ) - let registryCited = false - if (registryPath !== claudeMdPath && existsSync(registryPath)) { - try { - const registry = readFileSync(registryPath, 'utf8') - const bulletRe = new RegExp(`^\\s*-\\s*\`${escape(leaf)}\``, 'm') - registryCited = citedIn(registry) || bulletRe.test(registry) - } catch { - // Registry unreadable — fall back to the CLAUDE.md result. - } - } - - if (citedIn(content) || registryCited) { - return undefined - } - - const lines = [ - `[new-hook-claude-md-guard] Hook "${hookPathSuffix}" missing its enforcement reference.`, - '', - ` ${toolName} blocked: the hook needs a one-line association before it`, - ' lands, in EITHER place:', - '', - ` - the hook-registry doc (preferred — CLAUDE.md is size-capped):`, - ` docs/agents.md/fleet/hook-registry.md, as a bullet:`, - ` - \`${leaf}\` — `, - ` - or inline in CLAUDE.md, attached to the rule it enforces:`, - ` (\`.claude/hooks/${hookPathSuffix}/\`)`, - '', - ' Why: fleet repos read CLAUDE.md + its linked docs as the source of', - " truth. A hook with no entry is policy that doesn't exist on paper —", - " users won't know why they got blocked. Prefer the registry bullet;", - ' it keeps CLAUDE.md under the 40 KB cap.', - ] - return block(lines.join('\n') + '\n') -}) - -export const hook = defineHook({ - bypass: ['new-hook'], - bypassOptional: true, - check, - event: 'PreToolUse', - matcher: ['Edit', 'Write', 'MultiEdit'], - scope: 'convention', - type: 'guard', -}) -void runHook(hook, import.meta.url) diff --git a/.claude/hooks/fleet/new-hook-claude-md-guard/package.json b/.claude/hooks/fleet/new-hook-claude-md-guard/package.json deleted file mode 100644 index 8ccd14a..0000000 --- a/.claude/hooks/fleet/new-hook-claude-md-guard/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "hook-new-hook-claude-md-guard", - "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/new-hook-claude-md-guard/tsconfig.json b/.claude/hooks/fleet/new-hook-claude-md-guard/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/new-hook-claude-md-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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/no-amend-foreign-commit-guard/README.md b/.claude/hooks/fleet/no-amend-foreign-commit-guard/README.md deleted file mode 100644 index 58db184..0000000 --- a/.claude/hooks/fleet/no-amend-foreign-commit-guard/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# no-amend-foreign-commit-guard - -**Type:** PreToolUse(Bash) hook (BLOCK — exit 2). - -## Trigger - -Blocks `git commit --amend` when **both** hold for the target repo's HEAD: - -1. HEAD is **ahead of the remote default branch** (`origin/..HEAD ≥ 1`) — the amend rewrites local-only, unpushed history; and -2. HEAD's commit timestamp is **older than ~10 min** — it isn't a commit you authored this turn. - -Together those mean you're amending an unpushed commit a **parallel session** authored. The amend would fold your change + message into their feature commit, with no remote copy to recover from. - -Allowed (not blocked): amending the commit you made this turn (fresh tip, within the threshold), and amending a commit that's already pushed (HEAD == remote tip — that's a force-push concern handled by other guards). - -Detection reads git state from the repo (`extractGitCwd`); the block decision is the pure `shouldBlockAmend(info, nowMs)`. - -## Why - -A session amended a parallel session's unpushed feature commit while landing an unrelated change — it swept the change into the wrong commit and rewrote its message, costing a reflog recovery. A `git status` HEAD-check before amending catches it; this enforces that check at the Bash layer so no amend path skips it. - -## Bypass - -The rare intentional amend of an older own-commit: - -``` -Allow amend-foreign bypass -``` - -Fails open on a malformed payload or unreadable git state. diff --git a/.claude/hooks/fleet/no-amend-foreign-commit-guard/index.mts b/.claude/hooks/fleet/no-amend-foreign-commit-guard/index.mts deleted file mode 100644 index fce7f6e..0000000 --- a/.claude/hooks/fleet/no-amend-foreign-commit-guard/index.mts +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-amend-foreign-commit-guard. -// -// Blocks `git commit --amend` when HEAD is an UNPUSHED commit that this session -// almost certainly did NOT author — i.e. a parallel agent session's in-flight -// work sitting on the shared checkout's branch. Amending it rewrites someone -// else's commit (folding your change + message into their feature commit), and -// because it's unpushed there's no remote copy to recover from. -// -// The safe, common case — amending the commit you JUST made — is allowed: a -// freshly-authored tip has a commit time within minutes of now. The dangerous -// case — amending a commit that has been sitting unpushed for a while (another -// session made it) — is blocked. Two conditions must BOTH hold to block: -// 1. HEAD is ahead of the remote default branch (origin/..HEAD ≥ 1), -// so the amend rewrites local-only history; AND -// 2. HEAD's commit timestamp is older than a freshly-made-tip threshold -// it isn't a commit you just created this turn. -// -// Detection reads git state from the target repo (extractGitCwd); the -// block/allow decision is the pure `shouldBlockAmend`, which the test drives. -// -// Why: a session amended a parallel session's unpushed feature commit while -// trying to quickly land an unrelated change — it swept the change into the -// wrong commit and rewrote its message. A `git status` HEAD-check before -// amending would have caught it; this enforces that check. -// -// Squash-history repos, roster opt-in, are EXEMPT: their lone `chore: initial -// commit` is amended by every session by design (the collaborative land model), -// so the unpushed+old heuristic always mis-fires there and stands down. -// -// Bypass: `Allow amend-foreign bypass` (the rare intentional amend of an older -// own-commit). Exit 0 allow / 2 block. Fails open on any internal error. - -// oxlint-disable-next-line socket/prefer-async-spawn -- PreToolUse hook needs a sync git read to gate the command before it runs; typed string return. -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' - -import { isSquashOptIn } from '../_shared/fleet-roster.mts' -import { bashGuard, block, defineHook, runHook } from '../_shared/guard.mts' -import type { GuardResult } from '../_shared/guard.mts' -import { resolveDefaultBranch } from '../_shared/git-branch.mts' -import { extractGitCwd } from '../_shared/git-cwd.mts' -import type { ToolCallPayload } from '../_shared/payload.mts' -import { commandsFor } from '../_shared/shell-command.mts' - -// Pre-flight skip set. The only path to a block is gated by `isAmendCommit`, -// which requires a `git` segment whose args include both `commit` and -// `--amend`; `--amend` is therefore present verbatim in EVERY blocking command. -// The dispatcher skips importing this guard when the payload lacks it. -export const triggers: readonly string[] = ['--amend'] - -// A commit younger than this is treated as "freshly authored this turn" — safe -// to amend. Older + unpushed → likely a parallel session's commit. -const FRESH_TIP_MS = 10 * 60 * 1000 - -// Read-only snapshot of the git state the amend decision needs. -export interface AmendHeadInfo { - // HEAD commits ahead of the remote default branch (origin/..HEAD). - aheadOfRemote: number - // HEAD committer timestamp in epoch ms, or undefined when unreadable. - headCommitMs: number | undefined -} - -// Is this command a `git commit --amend`? any git segment carrying both. -export function isAmendCommit(command: string): boolean { - for (const c of commandsFor(command, 'git')) { - if (c.args.includes('commit') && c.args.includes('--amend')) { - return true - } - } - return false -} - -// The pure block decision. Blocks only when the amend rewrites an unpushed, -// not-freshly-made tip, a parallel session's commit. `nowMs` is injected so -// the test is deterministic. Returns a reason when blocking, else undefined. -export function shouldBlockAmend( - info: AmendHeadInfo, - nowMs: number, -): string | undefined { - if (info.aheadOfRemote < 1) { - // HEAD matches the remote tip — amending re-authors a pushed commit, a - // force-push concern handled elsewhere, not a foreign-commit one. - return undefined - } - if (info.headCommitMs === undefined) { - return undefined - } - const ageMs = nowMs - info.headCommitMs - if (ageMs <= FRESH_TIP_MS) { - // Freshly authored this turn — the safe, common amend. - return undefined - } - const ageMin = Math.round(ageMs / 60_000) - return `HEAD is an unpushed commit from ~${ageMin} min ago (not one you made this turn) — amending it rewrites a parallel session's work` -} - -// Read the git state for the decision from `repoDir`. Sync so the PreToolUse -// hook can decide before the command runs. -export function readAmendHeadInfo(repoDir: string): AmendHeadInfo { - const run = (args: readonly string[]): string => { - const r = spawnSync('git', ['-C', repoDir, ...args], { encoding: 'utf8' }) - /* c8 ignore next - spawnSync with encoding:'utf8' always returns a string; ?? '' is a structural guard */ - return String(r.stdout ?? '').trim() - } - const base = resolveDefaultBranch(repoDir) - const aheadOfRemote = Number( - run(['rev-list', '--count', `origin/${base}..HEAD`]), - ) - const tsSec = Number(run(['log', '-1', '--format=%ct', 'HEAD'])) - return { - /* c8 ignore start - git --count and %ct always output digits or empty string (→ 0); non-integer fallbacks are structural guards */ - aheadOfRemote: Number.isInteger(aheadOfRemote) ? aheadOfRemote : 0, - headCommitMs: Number.isInteger(tsSec) ? tsSec * 1000 : undefined, - /* c8 ignore stop */ - } -} - -export const check = bashGuard( - (command: string, payload: ToolCallPayload): GuardResult => { - void payload - if (!isAmendCommit(command)) { - return undefined - } - const repoDir = extractGitCwd(command, { subcommand: 'commit' }) - // A squash-history repo collapses to ONE shared `chore: initial commit` that - // every session AMENDS by design (the collaborative land model — see the - // squashing-history skill / fleet-roster.isSquashOptIn). The foreign-commit - // heuristic (unpushed + not-freshly-made) ALWAYS trips on that canonical - // commit, so it must stand down here — amending it is the expected land, not - // a parallel session's clobber. Recognized by the repo's roster opt-in. - if (isSquashOptIn(repoDir)) { - return undefined - } - const reason = shouldBlockAmend(readAmendHeadInfo(repoDir), Date.now()) - if (!reason) { - return undefined - } - return block( - [ - '[no-amend-foreign-commit-guard] Blocked: `git commit --amend` onto a foreign unpushed commit.', - '', - ` Repo: ${repoDir}`, - ` ${reason}.`, - '', - ' Amending an unpushed commit you did not author this turn folds your', - " change into a parallel session's commit (and rewrites its message),", - ' with no remote copy to recover from.', - '', - ' Fix: verify HEAD first — `git log -1 --format=%s` +', - ' `git rev-list --count origin/..HEAD`. If the tip is another', - " session's, commit your change as a NEW commit, not an amend.", - ].join('\n'), - ) - }, -) - -export const hook = defineHook({ - bypass: ['amend-foreign'], - check, - event: 'PreToolUse', - matcher: ['Bash'], - triggers, - type: 'guard', -}) -void runHook(hook, import.meta.url) diff --git a/.claude/hooks/fleet/no-blanket-file-exclusion-guard/README.md b/.claude/hooks/fleet/no-blanket-file-exclusion-guard/README.md deleted file mode 100644 index d1793fa..0000000 --- a/.claude/hooks/fleet/no-blanket-file-exclusion-guard/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# no-blanket-file-exclusion-guard - -PreToolUse hook that blocks Edit/Write tool calls introducing a `max-file-lines:` file-size exemption marker that does not name a real category. - -A file may not wave itself past the soft/hard line cap by asserting it deems itself acceptable. The only valid marker is `max-file-lines: `: a single hyphenated category word naming WHAT the file is, plus a separated reason for WHY it can't split. A self-judgment word (`legitimate`, `ok`, `exempt`, `acceptable`, …) is not a description and does not exempt. - -This is the edit-time layer of a three-layer defense: the `socket/max-file-lines` oxlint rule catches the same shape at lint time, and the soft/hard caps fire at every commit. - -The marker is **hard-cap-only** (>1000 lines): a file in the soft band (501–1000) gets no exemption and must split. The oxlint rule ignores any marker in the soft band and reports anyway; this hook enforces the shape contract on whatever marker does land. In almost every case the answer is to split along a natural seam — reach for a marker only for a genuine single cohesive unit past 1000 lines (or a generated file). - -## Allowed - -- `max-file-lines: parser — recursive-descent grammar` -- `max-file-lines: state-machine — exhaustive transition table` -- `max-file-lines: integration-test — one end-to-end scenario per file` - -## Blocked - -- `max-file-lines: legitimate` (self-judgment, no category) -- `max-file-lines: legitimate — one cohesive module` (self-judgment leads) -- `max-file-lines: ok — it's fine` (self-judgment word as category) -- `max-file-lines: parser` (category present, no `— reason`) - -## Bypass - -No bypass — name a real category (or, better, split the file along a natural seam so it no longer needs an exemption). diff --git a/.claude/hooks/fleet/no-blanket-file-exclusion-guard/index.mts b/.claude/hooks/fleet/no-blanket-file-exclusion-guard/index.mts deleted file mode 100644 index 3a99530..0000000 --- a/.claude/hooks/fleet/no-blanket-file-exclusion-guard/index.mts +++ /dev/null @@ -1,196 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-blanket-file-exclusion-guard. -// -// Blocks Edit/Write tool calls that introduce a file-size exemption -// marker that does NOT name a real category. The only valid marker is -// `max-file-lines: `: a single hyphenated category -// word naming WHAT the file is (parser, state-machine, table, cli, -// integration-test, vendored, …) plus a separated reason for WHY it -// can't split. -// -// Why: "no blanket file exclusions". A file may not wave itself past the -// soft/hard line cap by asserting it deems itself acceptable — a marker -// like `max-file-lines: legitimate` (or `ok` / `exempt` / `acceptable`) -// is a self-judgment, not a description. It says "trust me" where the -// rule asks "what is this file". Naming a real category forces the -// author to admit the file's shape, which a reviewer can sanity-check, -// and steers the default toward SPLITTING rather than exempting. -// -// This is the edit-time layer of a three-layer defense: the -// `socket/max-file-lines` oxlint rule catches the same shape at lint -// time, and the soft/hard caps fire at every commit. Catching it at -// Write time means the padded marker never lands in the first place. -// -// HARD-CAP-ONLY: the exemption marker exempts a file only past the -// 1000-line HARD cap (the rare genuine cohesive-unit / generated case). -// A file in the SOFT band (501–1000) gets NO exemption — it must split, -// so the `socket/max-file-lines` rule ignores any marker there and reports -// anyway. This hook can't see the line count from a single Edit's -// new_string, so it enforces only the shape contract here (a marker that -// lands must name a real category + reason); the rule enforces the size -// gate. Splitting is the soft-band answer in every case — the block -// message says so. -// -// Recognized banned shapes (a size-exemption marker that fails the -// `` contract): -// max-file-lines: legitimate, self-judgment, no category -// max-file-lines: legitimate — one cohesive module, self-judgment leads -// max-file-lines: ok — it's fine, self-judgment word -// max-file-lines: parser, category, no reason -// -// Allowed shapes, pass through: -// max-file-lines: parser — recursive-descent grammar -// max-file-lines: state-machine — exhaustive transition table -// max-file-lines: integration-test — one end-to-end scenario -// -// The valid-marker regex is kept in lock-step with the -// `socket/max-file-lines` oxlint rule's BYPASS_RE — both must agree on -// what a real marker looks like. -// -// Only leading comments (lines 1–5) are scanned, matching the rule: a -// file-level exemption has to communicate intent at the file level, not -// buried mid-file. -// -// Reads PreToolUse JSON payload from stdin: -// { "tool_name": "Edit"|"Write"|"MultiEdit", -// "tool_input": { "file_path": "...", "content"|"new_string": "..." } } -// -// Exit codes: -// 0 — pass. -// 2 — block (a blanket / self-judgment marker found). -// -// Fails open on malformed payloads (exit 0 + stderr log). - -import { block, defineHook, editGuard, runHook } from '../_shared/guard.mts' - -// A size-exemption marker is present at all, any category text after it. -const MARKER_RE = /max-file-lines:\s*\S/i - -// A VALID marker: ``. The category is one hyphenated -// token immediately after the colon, immediately followed by a `—`/`-`/`:` -// separator and a non-empty reason. The self-judgment word `legitimate` -// is explicitly NOT a category. Lock-step with the `socket/max-file-lines` -// oxlint rule's BYPASS_RE — keep both in sync. -const VALID_MARKER_RE = - /max-file-lines:\s*(?!legitimate\b)[a-z][a-z-]*\s*[—:-]\s*\S/i - -// Self-judgment words that are never a real category. Reported by name so -// the fix message can point at the offending word. `legitimate` is caught -// by VALID_MARKER_RE's negative lookahead; the rest fail the contract for -// other reasons (e.g. `ok — fine` has a category-shaped `ok` that passes -// the regex), so they get an explicit denylist check. -const SELF_JUDGMENT_WORDS: readonly string[] = [ - 'acceptable', - 'allowed', - 'exempt', - 'fine', - 'legit', - 'legitimate', - 'okay', - 'ok', - 'valid', -] - -interface Finding { - readonly line: number - readonly text: string - readonly selfJudgmentWord: string | undefined -} - -export function findSelfJudgmentWord(markerLine: string): string | undefined { - const m = /max-file-lines:\s*([a-z][a-z-]*)/i.exec(markerLine) - if (!m) { - return undefined - } - const category = m[1]!.toLowerCase() - for (let i = 0, { length } = SELF_JUDGMENT_WORDS; i < length; i += 1) { - if (category === SELF_JUDGMENT_WORDS[i]) { - return category - } - } - return undefined -} - -export function findBlanketExclusions(text: string): Finding[] { - const findings: Finding[] = [] - const lines = text.split('\n') - // Scan leading comments only — match the rule's first-5-lines window so a - // marker buried mid-file is not treated as a file-level exemption. - const limit = Math.min(lines.length, 5) - for (let i = 0; i < limit; i += 1) { - const line = lines[i]! - if (!MARKER_RE.test(line)) { - continue - } - const selfJudgmentWord = findSelfJudgmentWord(line) - // A marker is banned if it leads with a self-judgment word OR fails the - // `` contract entirely (e.g. category with no reason). - if (selfJudgmentWord !== undefined || !VALID_MARKER_RE.test(line)) { - findings.push({ line: i + 1, text: line.trim(), selfJudgmentWord }) - } - } - return findings -} - -export const check = editGuard((filePath, content) => { - const newContent = content ?? '' - const findings = findBlanketExclusions(newContent) - if (findings.length === 0) { - return undefined - } - const out: string[] = [] - out.push( - '🚨 no-blanket-file-exclusion-guard: blocked Edit/Write — a `max-file-lines:` marker must name a real category.', - ) - out.push('') - /* c8 ignore next - editGuard returns undefined for an empty filePath before this runs */ - out.push(`File: ${filePath || ''}`) - out.push('') - for (let i = 0, { length } = findings; i < length; i += 1) { - const f = findings[i]! - out.push(` Line ${f.line}: ${f.text}`) - if (f.selfJudgmentWord !== undefined) { - out.push( - ` \`${f.selfJudgmentWord}\` is a self-judgment, not a category.`, - ) - } - } - out.push('') - out.push('The only valid exemption marker is:') - out.push(' max-file-lines: ') - out.push('') - out.push( - 'where is ONE hyphenated word naming WHAT the file is (parser,', - ) - out.push( - 'state-machine, table, cli, integration-test, vendored, …) and says', - ) - out.push( - 'WHY it cannot split. No blanket file exclusions — say what the file is,', - ) - out.push('not that you deem it acceptable.') - out.push('') - out.push( - 'And the marker is HARD-CAP-ONLY (>1000 lines): a file in the soft band', - ) - out.push( - '(501–1000) gets NO exemption — it MUST split. So in almost every case the', - ) - out.push( - 'answer is the same: SPLIT along a natural seam. Reach for the marker only', - ) - out.push( - 'for a genuine single cohesive unit past 1000 lines (or a generated file).', - ) - return block(out.join('\n') + '\n') -}) - -export const hook = defineHook({ - check, - event: 'PreToolUse', - matcher: ['Edit', 'Write', 'MultiEdit'], - scope: 'convention', - type: 'guard', -}) - -void runHook(hook, import.meta.url) diff --git a/.claude/hooks/fleet/no-blanket-file-exclusion-guard/package.json b/.claude/hooks/fleet/no-blanket-file-exclusion-guard/package.json deleted file mode 100644 index c553c38..0000000 --- a/.claude/hooks/fleet/no-blanket-file-exclusion-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-no-blanket-file-exclusion-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-blanket-file-exclusion-guard/tsconfig.json b/.claude/hooks/fleet/no-blanket-file-exclusion-guard/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/no-blanket-file-exclusion-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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/no-blind-keychain-read-guard/README.md b/.claude/hooks/fleet/no-blind-keychain-read-guard/README.md deleted file mode 100644 index 22a1796..0000000 --- a/.claude/hooks/fleet/no-blind-keychain-read-guard/README.md +++ /dev/null @@ -1,65 +0,0 @@ -# no-blind-keychain-read-guard - -`PreToolUse(Bash)` blocker that refuses direct keychain READ calls -from Bash. The keychain APIs surface a UI auth prompt per call; -reading three times costs three prompts. The fleet's canonical -in-process resolver (`api-token.mts.findApiToken()`) caches the -value module-scoped after the first hit, so subsequent code paths -should never need to re-read the keychain. - -## Detected reads - -| Platform | Pattern | -| -------------- | ---------------------------------------------- | -| macOS | `security find-{generic,internet}-password` | -| Linux | `secret-tool lookup` / `secret-tool search` | -| Windows | `Get-StoredCredential` | -| Windows | `Get-Credential … \| ConvertFrom-SecureString` | -| cross-platform | `keyring get` | - -## Allowed (not flagged) - -Writes and deletes — these only happen during operator-driven -setup / rotation, never on hot paths: - -- `security add-generic-password` / `security delete-generic-password` -- `secret-tool store` / `secret-tool clear` -- `New-StoredCredential` / `Remove-StoredCredential` -- `keyring set` / `keyring del` - -## Bypass - -Type the canonical phrase verbatim in your next user turn: - -``` -Allow blind-keychain-read bypass -``` - -Use when you genuinely need a fresh keychain read — operator-invoked -diagnostics, verifying an entry exists, etc. - -## Why - -`security find-generic-password` on macOS prompts the user every call -unless the calling process is on the entry's ACL. Claude Code's Bash -tool spawns a fresh process per call, so each `security` invocation -re-prompts. The same shape exists on Linux (`secret-tool` against -gnome-keyring / kwallet) and Windows (`Get-StoredCredential` against -the CredentialManager UI). - -The right answer is to read the cached value from process state: - -```ts -import { findApiToken } from '../setup-security-tools/lib/api-token.mts' -const { token } = findApiToken() // module-cached after first call -``` - -Or from a child process spawned by hooks: - -```bash -echo "$SOCKET_API_KEY" # populated by wheelhouse shell-rc bridge -``` - -The bridge writes the token to `~/.zshenv` (or platform equivalent) -so every new shell exports `SOCKET_API_KEY` + `SOCKET_API_TOKEN` -without a keychain read. diff --git a/.claude/hooks/fleet/no-blind-keychain-read-guard/index.mts b/.claude/hooks/fleet/no-blind-keychain-read-guard/index.mts deleted file mode 100644 index 833c331..0000000 --- a/.claude/hooks/fleet/no-blind-keychain-read-guard/index.mts +++ /dev/null @@ -1,197 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-blind-keychain-read-guard. -// -// Blocks Bash invocations that READ a credential from the OS -// keychain. Reading via the platform CLI surfaces a per-call UI auth -// prompt on the user's screen ("this app wants to access your -// keychain"), and the prompt fires once per call — a hook chain that -// reads the keychain three times costs three prompts. Tokens are -// already cached in process memory after the first resolution; the -// fleet's canonical resolver (`api-token.mts.findApiToken()`) hits -// the cache, then env, then keychain, in that order. Bash callers -// that go straight to `security find-generic-password` skip all of -// that and re-prompt the user every time. -// -// Detects (case-sensitive, structural — not just substring): -// -// macOS: -// security find-generic-password -// security find-internet-password -// -// Linux: -// secret-tool lookup -// secret-tool search -// -// Windows (PowerShell): -// Get-StoredCredential (CredentialManager module) -// Get-Credential (when piping to ConvertFrom-SecureString) -// -// Cross-platform (Python keyring CLI): -// keyring get -// -// Allowed (writes / deletes — necessary for operator-driven setup / -// rotation, never on hot paths): -// -// security add-generic-password security delete-generic-password -// secret-tool store secret-tool clear -// New-StoredCredential Remove-StoredCredential -// keyring set keyring del -// -// Bypass: `Allow blind-keychain-read bypass` in a recent user turn. -// Use when you genuinely need to verify a keychain entry exists -// (e.g. operator-invoked diagnostics). -// -// Verdict, uniform guard contract: `check` returns `block(message)` to block -// (the runner prints the message + sets exitCode 2) or `undefined` to allow. -// `runGuard` fails open on malformed payloads — the fleet's hook contract. - -import { block, defineHook, runHook } from '../_shared/guard.mts' -import { readCommand } from '../_shared/payload.mts' -import type { ToolCallPayload } from '../_shared/payload.mts' - -interface Hit { - readonly tool: string - readonly platform: 'macos' | 'linux' | 'windows' | 'cross-platform' - readonly snippet: string -} - -// Pre-flight triggers — the dispatcher imports + runs this guard only -// when the raw command contains at least one of these substrings. Each -// is the literal anchor a `READ_PATTERNS` entry requires, so no command -// can match a pattern without containing one of them: `find-*-password` -// (macOS `security`), `secret-tool` (Linux), `Get-StoredCredential` and -// `ConvertFrom-SecureString`, Windows readback pipe, `keyring` (Python -// CLI). Writes/deletes share these substrings too, so the guard still -// runs for them and correctly returns no hit. -export const triggers: readonly string[] = [ - 'ConvertFrom-SecureString', - 'Get-StoredCredential', - 'find-generic-password', - 'find-internet-password', - 'keyring', - 'secret-tool', -] - -// Token-bearing read patterns. Each entry: the literal verb that -// surfaces a UI prompt + a label for the error message. Writes / -// deletes are intentionally absent from this list. -const READ_PATTERNS: ReadonlyArray<{ - readonly re: RegExp - readonly tool: string - readonly platform: Hit['platform'] -}> = [ - // macOS — `security(1)`. The `-w` flag prints the password to - // stdout, but even the metadata-only form triggers the ACL prompt. - { - re: /\bsecurity\s+(?:find-generic-password|find-internet-password)\b/, - tool: 'security find-*-password', - platform: 'macos', - }, - // Linux — `secret-tool`. `lookup` returns the password; `search` - // lists matches, also surfaces the libsecret prompt. - { - re: /\bsecret-tool\s+(?:lookup|search)\b/, - tool: 'secret-tool lookup/search', - platform: 'linux', - }, - // Windows PowerShell — CredentialManager module. The - // `Get-StoredCredential` cmdlet returns a PSCredential; reading - // `.Password | ConvertFrom-SecureString` is the read pattern. - { - re: /\bGet-StoredCredential\b/, - tool: 'Get-StoredCredential', - platform: 'windows', - }, - // PowerShell `Get-Credential -Credential` piped to - // `ConvertFrom-SecureString -AsPlainText` is the readback shape. - // The bare `Get-Credential`, no pipe, is a fresh-prompt-the-user - // flow and not the issue here — match only the readback pipe. - { - re: /\bGet-Credential\b[^|]*\|\s*ConvertFrom-SecureString\b/, - tool: 'Get-Credential | ConvertFrom-SecureString', - platform: 'windows', - }, - // Python `keyring` CLI — `keyring get `. - { - re: /\bkeyring\s+get\b/, - tool: 'keyring get', - platform: 'cross-platform', - }, -] - -/** - * Scan a Bash command string for keychain READ patterns. Returns one hit per - * matching subcommand so the error message can name them all (a `&&`-chained - * command might have multiple). - */ -export function findKeychainReads(command: string): Hit[] { - const hits: Hit[] = [] - for (let i = 0, { length } = READ_PATTERNS; i < length; i += 1) { - const entry = READ_PATTERNS[i]! - const m = entry.re.exec(command) - if (!m) { - continue - } - // Pull a short snippet around the match (up to 80 chars) so the - // operator can see the context. Centered on the match start. - const start = Math.max(0, m.index - 10) - const end = Math.min(command.length, m.index + m[0].length + 50) - const snippet = command.slice(start, end) - hits.push({ - tool: entry.tool, - platform: entry.platform, - snippet: snippet.length < command.length ? `…${snippet}…` : snippet, - }) - } - return hits -} - -/** - * Pure detection. Returns the exact block message when the payload is a Bash - * call whose command reads the keychain; `undefined` otherwise (non-Bash tool, - * absent command, or no keychain read). The internal `typeof === 'string'` - * narrow in `readCommand` keeps the `unknown` payload fields safe. - */ -export function keychainReadMessage( - payload: ToolCallPayload, -): string | undefined { - if (payload?.tool_name !== 'Bash') { - return undefined - } - const command = readCommand(payload) - if (!command) { - return undefined - } - const hits = findKeychainReads(command) - if (hits.length === 0) { - return undefined - } - const lines: string[] = [ - '🚨 no-blind-keychain-read-guard: blocked a keychain READ (each one costs a UI auth prompt) — use $SOCKET_API_TOKEN/$SOCKET_API_KEY env or findApiToken(); writes/deletes stay allowed', - ] - for (let i = 0, { length } = hits; i < length; i += 1) { - const h = hits[i]! - lines.push(` ${h.platform} ${h.tool} — ${h.snippet}`) - } - return lines.join('\n') -} - -// The block logic. Blocks when a keychain read is found; allows (returns -// undefined) otherwise. -export const check = (payload: ToolCallPayload) => { - const message = keychainReadMessage(payload) - if (!message) { - return undefined - } - return block(message) -} - -export const hook = defineHook({ - bypass: ['blind-keychain-read'], - check, - event: 'PreToolUse', - matcher: ['Bash'], - triggers, - type: 'guard', -}) -void runHook(hook, import.meta.url) diff --git a/.claude/hooks/fleet/no-blind-keychain-read-guard/package.json b/.claude/hooks/fleet/no-blind-keychain-read-guard/package.json deleted file mode 100644 index 819429b..0000000 --- a/.claude/hooks/fleet/no-blind-keychain-read-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-no-blind-keychain-read-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-blind-keychain-read-guard/tsconfig.json b/.claude/hooks/fleet/no-blind-keychain-read-guard/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/no-blind-keychain-read-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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/no-boolean-trap-guard/README.md b/.claude/hooks/fleet/no-boolean-trap-guard/README.md deleted file mode 100644 index e48c438..0000000 --- a/.claude/hooks/fleet/no-boolean-trap-guard/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# no-boolean-trap-guard - -PreToolUse Write/Edit guard that blocks introducing a boolean positional -parameter in a TypeScript function signature — the -[boolean-trap](https://ariya.io/2011/08/hall-of-api-shame-boolean-trap) -antipattern. - -## Why - -`function foo(x: T, dry: boolean)` forces callers to write -`foo(x, true)` where the `true` is silent and meaningless. Six months -later nobody knows what it means. An options object names the flag at -the call site: `foo(x, { dry: true })`. - -## The fleet options-object pattern - -```ts -// Declaration -export interface FooOptions { - dry?: boolean | undefined - verbose?: boolean | undefined -} -export function foo(x: T, options?: FooOptions | undefined): void { - // Null-prototype spread — immune to poisoned Object.prototype. - const opts = { __proto__: null, ...options } as FooOptions - const dry = opts.dry === true - … -} -``` - -Key invariants: field types `?: T | undefined` (both `?` AND `| undefined`); -options param `?: TypedOptions | undefined`; body resolves via the -`{ __proto__: null, ...options }` spread. Full recipe in -[`docs/agents.md/fleet/options-object.md`](../../../docs/agents.md/fleet/options-object.md). - -## Allowed - -- A function with a **single** boolean param and no other params — - predicate pattern (`isEnabled(value: boolean)`). -- `boolean` fields inside an interface body (not params). -- Generated / dist / build files. -- Bypass: `Allow boolean-trap bypass`. - -## Cross-fleet sync - -Lives in `socket-wheelhouse/template/.claude/hooks/fleet/` and is -byte-identical across every fleet repo. diff --git a/.claude/hooks/fleet/no-boolean-trap-guard/index.mts b/.claude/hooks/fleet/no-boolean-trap-guard/index.mts deleted file mode 100644 index e47d263..0000000 --- a/.claude/hooks/fleet/no-boolean-trap-guard/index.mts +++ /dev/null @@ -1,237 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-boolean-trap-guard. -// -// Blocks Write/Edit ops that introduce a boolean positional parameter -// in a TypeScript function signature — the "boolean trap" antipattern -// (https://ariya.io/2011/08/hall-of-api-shame-boolean-trap). -// -// A boolean positional forces callers to write `foo(x, true)` where the -// `true` is meaningless at the call site. The fix: take an options -// object instead. Fleet pattern: -// -// options?: TypedOptions | undefined // param declaration -// TypedOptions = { foo?: bar | undefined } // interface definition -// const opts = { __proto__: null, ...options } as TypedOptions // body -// -// Banned shapes: -// function f(x: string, flag: boolean) { … } -// function f(a: T, b: boolean, c: boolean) { … } -// async function f(x: T, dry?: boolean) { … } -// export function f(x: T, verbose: boolean | undefined) { … } -// -// Allowed, passes through: -// - A single boolean param with NO other params — pure predicate -// (`function isValid(value: boolean): boolean`). -// - Overload signatures (no body — these are type-only contracts and -// are resolved by the implementation). -// - Generated / vendor files (dist/, build/, node_modules/). -// - This guard's own source + tests. -// - Bypass: `Allow boolean-trap bypass` in a recent turn. -// -// Exit codes: 0 pass, 2 block. Fails open on malformed payloads. - -import { block, defineHook, editGuard, runHook } from '../_shared/guard.mts' -import { resolveEditedText } from '../_shared/payload.mts' -import { isRepoTestHome } from '../_shared/repo-test-home.mts' -import { safeReadFileSync } from '@socketsecurity/lib-stable/fs/read-file' -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' - -interface Finding { - readonly line: number - readonly text: string - readonly param: string -} - -// Match a function signature line that has AT LEAST TWO params and at -// least one of them is typed boolean/boolean|undefined/boolean?. -// Pattern: `function name(` or `)(` continuation — we scan per line for -// the inline single-line case; multi-line signatures are flagged when -// a line contains a boolean param AND the enclosing paren context has -// other params on the same line, simple heuristic. -// -// We detect: a parameter name followed by `?:` or `:` and then -// `boolean` (optionally `| undefined` or `| null`), when the line -// also contains a comma, other params present, or is a multi-param -// function header. -const BOOL_PARAM_RE = - /\b([A-Za-z_$][A-Za-z0-9_$]*)\??:\s*boolean(?:\s*\|\s*(?:null|undefined))?\b/g - -// Detect that a line is a function/method header with params. -const FUNC_HEADER_RE = - /\b(?:async\s+)?(?:function\s*\*?\s*[A-Za-z_$][A-Za-z0-9_$]*|(?:abstract\s+|export\s+(?:default\s+)?|override\s+|private\s+|protected\s+|public\s+|static\s+)*(?:async\s+)?function|(?:export\s+(?:default\s+)?)?(?:async\s+)?\b[A-Za-z_$][A-Za-z0-9_$]*)\s*[<(]/ - -/** - * The substring inside the first balanced `(...)` on a line — the parameter - * list, excluding the return-type annotation that follows `)`. Returns - * undefined when the line has no `(` or the parens don't close on this line - * a multi-line signature. Balances `()[]{}` so a nested object-type param - * or default value doesn't end the list early. This is what stops a - * return-type field (`): { ok: boolean }`) from being read as a param. - */ -export function paramListSpan(line: string): string | undefined { - const open = line.indexOf('(') - if (open === -1) { - return undefined - } - let depth = 0 - for (let i = open, { length } = line; i < length; i += 1) { - const ch = line[i]! - if (ch === '(' || ch === '[' || ch === '{') { - depth += 1 - } else if (ch === ')' || ch === ']' || ch === '}') { - depth -= 1 - if (depth === 0) { - return line.slice(open + 1, i) - } - } - } - return undefined -} - -/** - * Blank out every character nested inside a `{...}`, `[...]`, or `(...)` group - * within a parameter-list span, preserving length and the top-level structure. - * A boolean that is a PROPERTY of an object-type literal - * (`props: { active?: boolean }`), an ELEMENT of a tuple type - * (`pair: [boolean, string]`), or a PARAMETER of a callback-type - * (`cb: (x: boolean) => void`) is not a top-level positional boolean trap of - * the function being declared — only a bare `name: boolean` at the param - * list's top level is. Blanking nested groups also drops their inner commas so - * the multi-param check counts only real param separators. - */ -export function stripNestedTypeGroups(paramList: string): string { - let depth = 0 - let out = '' - for (let i = 0, { length } = paramList; i < length; i += 1) { - const ch = paramList[i]! - if (ch === '(' || ch === '[' || ch === '{') { - out += depth === 0 ? ch : ' ' - depth += 1 - } else if (ch === ')' || ch === ']' || ch === '}') { - depth = Math.max(0, depth - 1) - out += depth === 0 ? ch : ' ' - } else { - out += depth === 0 ? ch : ' ' - } - } - return out -} - -export function findBooleanTrapParams(text: string): Finding[] { - const findings: Finding[] = [] - const lines = text.split('\n') - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i]! - // Only flag lines that look like a function/method parameter list. - if (!FUNC_HEADER_RE.test(line) && !line.trim().startsWith('(')) { - continue - } - // Scan ONLY the parameter list, never the return-type annotation after - // `)` — a `{ ok: boolean }` return type is not a boolean-trap param. - // Then blank nested type groups so a boolean PROPERTY/element/callback-param - // inside a param's type (`opts: { active?: boolean }`) is never read as a - // top-level positional boolean. - const scanText = stripNestedTypeGroups(paramListSpan(line) ?? line) - // Count commas to know whether there are multiple params. A boolean - // as the ONLY param is a predicate pattern — leave it alone. - const commaCount = (scanText.match(/,/g) ?? []).length - if (commaCount === 0) { - continue - } - BOOL_PARAM_RE.lastIndex = 0 - let m: RegExpExecArray | null - while ((m = BOOL_PARAM_RE.exec(scanText)) !== null) { - const param = m[1]! - findings.push({ line: i + 1, text: line.trim(), param }) - } - } - return findings -} - -export function isExemptPath(filePath: string): boolean { - return ( - normalizePath(filePath).includes('/dist/') || - normalizePath(filePath).includes('/build/') || - normalizePath(filePath).includes('/node_modules/') || - normalizePath(filePath).includes( - '/.claude/hooks/fleet/no-boolean-trap-guard/', - ) || - isRepoTestHome(filePath) - ) -} - -// Identity for a finding across edits: the param name + its normalized -// signature line. NEVER the line number — unrelated edits shift lines and -// would make a pre-existing trap read as new. -export function findingKey(f: Finding): string { - return `${f.param}:${f.text}` -} - -export const check = editGuard( - (filePath, content, payload) => { - if (isExemptPath(filePath)) { - return undefined - } - // Match TypeScript file extensions: .ts, .mts, .cts, .tsx, .mtsx, .ctsx. - if (!/\.(?:c|m)?tsx?$/.test(filePath)) { - return undefined - } - // Scan the POST-EDIT document (handles Edit and MultiEdit), not the raw - // new_string fragment — a fragment whose context window merely QUOTES a - // pre-existing signature is not an introduction. - const text = resolveEditedText(payload) ?? content ?? '' - if (!text) { - return undefined - } - const findings = findBooleanTrapParams(text) - if (findings.length === 0) { - return undefined - } - // Diff-aware: block only traps ABSENT from the on-disk file. The - // pre-existing backlog belongs to the lint gate, not an edit block. - const before = safeReadFileSync(filePath) - const beforeKeys = new Set( - typeof before === 'string' - ? findBooleanTrapParams(before).map(f => findingKey(f)) - : [], - ) - const fresh = findings.filter(f => !beforeKeys.has(findingKey(f))) - if (fresh.length === 0) { - return undefined - } - const lines = fresh - .map(f => ` ${filePath}:${f.line} param \`${f.param}\`\n ${f.text}`) - .join('\n') - return block( - `no-boolean-trap-guard: refusing to introduce a boolean positional parameter.\n` + - `\n` + - `${lines}\n` + - `\n` + - `A boolean positional forces callers to write foo(x, true) where\n` + - `the \`true\` is meaningless at the call site. Use an options object:\n` + - `\n` + - ` // instead of: function foo(x: T, dry: boolean)\n` + - ` export interface FooOptions { dry?: boolean | undefined }\n` + - ` export function foo(x: T, options?: FooOptions | undefined): void {\n` + - ` const opts = { __proto__: null, ...options } as FooOptions\n` + - ` const dry = opts.dry === true\n` + - ` …\n` + - ` }\n` + - `\n` + - `See docs/agents.md/fleet/options-object.md for the full recipe.\n`, - ) - }, - { fleetOnly: true }, -) - -export const hook = defineHook({ - bypass: ['boolean-trap'], - bypassOptional: true, - check, - event: 'PreToolUse', - matcher: ['Edit', 'Write', 'MultiEdit'], - scope: 'convention', - type: 'guard', -}) - -void runHook(hook, import.meta.url) diff --git a/.claude/hooks/fleet/no-boolean-trap-guard/package.json b/.claude/hooks/fleet/no-boolean-trap-guard/package.json deleted file mode 100644 index cdd357f..0000000 --- a/.claude/hooks/fleet/no-boolean-trap-guard/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "hook-no-boolean-trap-guard", - "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/no-boolean-trap-guard/tsconfig.json b/.claude/hooks/fleet/no-boolean-trap-guard/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/no-boolean-trap-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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/no-branch-reuse-nudge/README.md b/.claude/hooks/fleet/no-branch-reuse-nudge/README.md deleted file mode 100644 index 9a93020..0000000 --- a/.claude/hooks/fleet/no-branch-reuse-nudge/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# no-branch-reuse-nudge - -PreToolUse Bash hook (reminder, NOT a block) that fires on `git commit` -when the current branch already has upstream history — meaning the agent -is committing onto a shared/existing branch rather than cutting a fresh -one for the current logical change. - -## Why - -Reusing a branch mixes unrelated commits into one PR, complicates code -review, and causes rebase pain when the branch is already on the remote. -The shape this rule prevents: a session cuts a `feat/` branch -because it assumes a PR workflow, then has to work around the -feature-branch instead of pushing straight to main. The correct move -was `git push origin feat/:main` — which would have been obvious -if the branch hadn't been created at all. - -## When it fires - -On `git commit` (not `--amend`) when: - -- The current branch is NOT the default (`main`/`master`), AND -- The branch already has an upstream tracking ref with commits. - -A branch with no upstream (freshly cut this session) is never flagged. - -## Suggested actions - -- If the change belongs on main: `git push origin :` -- If a fresh branch is needed: `git checkout -b ` - -## Bypass - -Type `Allow branch-reuse bypass` in a recent message to proceed. - -## Cross-fleet sync - -Lives in `socket-wheelhouse/template/.claude/hooks/fleet/` and is -byte-identical across every fleet repo. diff --git a/.claude/hooks/fleet/no-branch-reuse-nudge/index.mts b/.claude/hooks/fleet/no-branch-reuse-nudge/index.mts deleted file mode 100644 index 749129c..0000000 --- a/.claude/hooks/fleet/no-branch-reuse-nudge/index.mts +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-branch-reuse-nudge. -// -// renamed-from: no-branch-reuse-guard -// -// Reminder (NOT a block) on `git commit` when the current branch is NOT -// the default branch (main/master) AND the branch already has an upstream -// tracking ref on the remote — meaning the agent is committing onto an -// existing shared branch rather than cutting a fresh one per logical -// change. -// -// Per CLAUDE.md "Smallest chunks / branch discipline": cut a FRESH branch -// per logical change, never reuse or commit onto an existing branch that -// belongs to a different logical unit of work. -// -// Why this matters: reusing a branch merges unrelated commits into a -// single PR / push, complicates code review, and causes rebase pain when -// the branch is already on the remote. The incident that prompted this -// rule: 2026-06-02 a session cut `feat/spawn-kill-tree` on socket-lib -// (assuming PR workflow), then had to create a PR to land the work — the -// correct move was `git push origin feat/spawn-kill-tree:main` directly, -// which would have been obvious if the branch hadn't been created at all. -// -// Allowed, passes through: -// - Committing on main/master, direct-push-to-main is the fleet default. -// - A branch with NO remote upstream, freshly cut this session. -// - Bypass: `Allow branch-reuse bypass` in a recent turn. -// -// Fires as a PreToolUse Bash hook; exits 0 always (reminder-only). - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' - -import { actedOnPath } from '../_shared/fleet-context.mts' -import { currentBranch, resolveDefaultBranch } from '../_shared/git-branch.mts' -import { bashGuard, defineHook, notify, runHook } from '../_shared/guard.mts' -import { spawnTimeoutMs } from '../_shared/spawn-timeout.mts' -import { gitCommitSegments } from '../_shared/commit-command.mts' - -// Amend excluded on purpose: amending the tip is not branch reuse. The -// segment parse is the shared one — a positional arg that merely CONTAINS -// the word commit (a path, `git log commit`) never matches. -export function isGitCommit(command: string): boolean { - return gitCommitSegments(command).some(c => !c.args.includes('--amend')) -} - -// True when the branch has a remote upstream tracking ref AND that -// upstream already has commits (i.e. the branch was pushed to the remote -// before this session started). A branch with no upstream is freshly cut -// this session — leave it alone. -export function hasExistingRemoteHistory(cwd: string, branch: string): boolean { - // Does the branch have an upstream configured? - const upstreamRef = spawnSync( - 'git', - ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], - { cwd, timeout: spawnTimeoutMs(5000) }, - ) - if (upstreamRef.status !== 0) { - return false - } - // Does the upstream have at least one commit? - const upstream = String(upstreamRef.stdout).trim() - const revParse = spawnSync('git', ['rev-parse', '--verify', upstream], { - cwd, - timeout: spawnTimeoutMs(5000), - }) - return revParse.status === 0 -} - -export const check = bashGuard((command, payload) => { - if (!isGitCommit(command)) { - return undefined - } - // Honor a subshell `cd` in the command (actedOnPath), not just the session - // cwd — a `(cd && git commit)` must be judged against the repo - // the commit LANDS in, not the session's home checkout. Judging the session - // cwd made this nudge report a sibling repo's branch (a false "reusing a - // feature branch" alarm) when the commit actually targeted a repo on main. - const cwd = actedOnPath(payload) - const branch = currentBranch(cwd) - if (!branch) { - return undefined - } - const defaultBranch = resolveDefaultBranch(cwd) - // Committing on the default branch is fine — direct-push-to-main. - if (branch === defaultBranch) { - return undefined - } - // A branch with no remote history was cut fresh this session — fine. - if (!hasExistingRemoteHistory(cwd, branch)) { - return undefined - } - return notify( - [ - `no-branch-reuse-nudge: committing onto an existing remote branch`, - ``, - ` Repo: ${cwd}`, - ` Branch: ${branch} (this checkout's CURRENT branch — already has history on origin)`, - ``, - ` Per CLAUDE.md "branch discipline" — cut a FRESH branch per logical`, - ` change; never reuse an existing branch for unrelated work. Reusing`, - ` mixes commits into one PR, complicates review, and causes rebase pain.`, - ``, - ` If this is the right branch for this change, push straight to main:`, - ``, - ` git push origin ${branch}:${defaultBranch}`, - ``, - ` If you need a new branch: git checkout -b `, - ``, - ` Reminder-only; not a block.`, - ``, - ].join('\n'), - ) -}) - -export const hook = defineHook({ - bypass: ['branch-reuse'], - bypassOptional: true, - check, - event: 'PreToolUse', - matcher: ['Bash'], - scope: 'convention', - type: 'nudge', -}) - -void runHook(hook, import.meta.url) diff --git a/.claude/hooks/fleet/no-branch-reuse-nudge/package.json b/.claude/hooks/fleet/no-branch-reuse-nudge/package.json deleted file mode 100644 index b69324d..0000000 --- a/.claude/hooks/fleet/no-branch-reuse-nudge/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "hook-no-branch-reuse-nudge", - "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/no-branch-reuse-nudge/tsconfig.json b/.claude/hooks/fleet/no-branch-reuse-nudge/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/no-branch-reuse-nudge/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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/no-cascade-transient-git-guard/index.mts b/.claude/hooks/fleet/no-cascade-transient-git-guard/index.mts deleted file mode 100644 index 7aaf0ba..0000000 --- a/.claude/hooks/fleet/no-cascade-transient-git-guard/index.mts +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-cascade-transient-git-guard. -// -// Blocks a cascade-shaped `git commit` when the target repo is in a -// transient git state — detached HEAD or in-progress rebase / merge / -// cherry-pick. Committing in that state lands the cascade on a stale or -// throwaway ref instead of the branch tip, stranding the commit and -// corrupting another session's in-flight operation. -// -// Why this exists: 2026-06-02 a fleet cascade's manual commit loop ran -// `git commit -m "chore(wheelhouse): cascade template@"` across -// every fleet repo. socket-lib was mid-`git cherry-pick` on a detached -// HEAD, another session's work; the loop ignored that and committed the -// cascade onto the detached HEAD, breaking the cherry-pick sequencer. -// sync-scaffolding's own auto-commit already skips this state — but a -// hand-typed loop bypassed that check. This hook enforces it at the Bash -// layer so NO commit path, script, loop, or manual, can land a cascade on -// a transient ref. -// -// Skipped silently: -// - tool_name !== 'Bash'. -// - Command isn't a cascade-prefixed `git commit`. -// - Target repo is on a normal branch tip, the common case. -// -// No bypass: there is never a legitimate reason to land a cascade commit -// on a transient ref. Finish, or abort, the in-progress operation first. -// -// Exit codes: -// 0 — allow. -// 2 — block. Stderr carries the operator-facing message. -// -// Fails open on any internal error (exit 0 + stderr log). - -import { extractGitCwd } from '../_shared/git-cwd.mts' -import { isInTransientGitState } from '../_shared/git-state.mts' -import { bashGuard, block, defineHook, runHook } from '../_shared/guard.mts' -import { commandsFor } from '../_shared/shell-command.mts' - -const CASCADE_PREFIX = 'chore(wheelhouse): cascade template@' - -/** - * Extract the `-m` / `--message` value from a `git commit` invocation, if any. - * Returns the first message argument or undefined. - */ -export function commitMessage(command: string): string | undefined { - for (const c of commandsFor(command, 'git')) { - if (!c.args.includes('commit')) { - continue - } - for (let i = 0, { length } = c.args; i < length; i += 1) { - const a = c.args[i] - if ((a === '--message' || a === '-m') && c.args[i + 1] !== undefined) { - return c.args[i + 1] - } - if (a?.startsWith('--message=')) { - return a.slice('--message='.length) - } - } - } - return undefined -} - -export const check = bashGuard(command => { - const message = commitMessage(command) - if (message === undefined || !message.startsWith(CASCADE_PREFIX)) { - return undefined - } - // Scope the cwd lookup to the `git commit` invocation itself — a `-C` on - // an unrelated invocation (e.g. a `rev-parse` inside a `$(…)` substitution) - // must not redirect the transient-state probe to a different repo. - const repoDir = extractGitCwd(command, { subcommand: 'commit' }) - if (!isInTransientGitState(repoDir)) { - return undefined - } - return block( - [ - '[no-cascade-transient-git-guard] Blocked: cascade commit on a transient git ref.', - '', - ` Repo: ${repoDir}`, - ' State: detached HEAD or in-progress rebase / merge / cherry-pick.', - '', - ' Committing a cascade here lands it on a stale or throwaway ref,', - " strands the commit, and can corrupt another session's in-flight", - ' operation (this stranded a cascade on socket-lib mid cherry-pick', - ' on 2026-06-02).', - '', - ' Fix: finish or abort the in-progress operation, get the repo back', - ' on its branch tip, then re-run the cascade. No bypass.', - '', - ].join('\n'), - ) -}) - -export const hook = defineHook({ - check, - event: 'PreToolUse', - matcher: ['Bash'], - type: 'guard', -}) - -void runHook(hook, import.meta.url) diff --git a/.claude/hooks/fleet/no-chained-pausing-git-guard/index.mts b/.claude/hooks/fleet/no-chained-pausing-git-guard/index.mts deleted file mode 100644 index 6914adc..0000000 --- a/.claude/hooks/fleet/no-chained-pausing-git-guard/index.mts +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-chained-pausing-git-guard. -// -// Blocks one Bash call that chains a PAUSING git operation (rebase, -// merge, cherry-pick, am, revert, stash pop/apply) ahead of another git -// MUTATION in the same command line. -// -// Why this exists: a pausing op does not finish when it hits a conflict -// — it stops mid-flight, leaves HEAD detached, and prints the -// resolution instructions. Chained, two things then go wrong at once: -// with `;` the next command runs anyway, and with `| tail`/`| grep` the -// instructions are swallowed, so the failure is invisible. A real -// incident: `git rebase origin/main 2>&1 | tail -1; git push --no-verify -// origin HEAD:main` — the rebase stopped on a conflict, its output was -// eaten by the pipe, and the push ran against a DETACHED HEAD, where it -// reported "Everything up-to-date" while six commits sat unpushed. A -// silent no-op that reads like success is the worst possible outcome for -// a state-changing command. -// -// The rule is not "never chain git". Chaining read-only git (status, -// log, diff, rev-parse) is how you keep a turn cheap, and even -// `git commit && git push` is fine: commit either succeeds or fails -// cleanly, and `&&` respects that. Only the ops that can PAUSE are the -// hazard, because their failure mode is a half-finished repository -// rather than a nonzero exit. -// -// DENIES (pausing op + a later git mutation, any separator): -// - git rebase … ; git push … -// - git rebase … && git push … -// - git merge … && git commit … -// - git cherry-pick … ; git reset … -// -// ALLOWS: -// - git rebase … alone (run it, read it, then act) -// - git rebase … && git status / git log (reads after) -// - git commit … && git push … (neither op pauses) -// - git fetch && git rebase … (fetch cannot pause) -// - any single git command, however piped -// -// Bypass: `Allow chained git bypass`, typed by the human in a genuine -// user turn. - -import { splitGitSubcommand } from '../_shared/git-subcommand.mts' -import { bashGuard, block, defineHook, runHook } from '../_shared/guard.mts' -import { parseCommands } from '../_shared/shell-command.mts' - -import type { SubcommandSplit } from '../_shared/git-subcommand.mts' - -const NAME = 'no-chained-pausing-git-guard' - -export const triggers: readonly string[] = [ - 'rebase', - 'merge', - 'cherry-pick', - 'am', - 'revert', -] - -// Ops that can stop mid-flight and leave the repo in a resolve-me state. -export const PAUSING_OPS: ReadonlySet = new Set([ - 'am', - 'cherry-pick', - 'merge', - 'rebase', - 'revert', -]) - -// Ops that write refs or the worktree. A pausing op ahead of one of -// these is what turns a stalled rebase into a wrong write. -export const MUTATING_OPS: ReadonlySet = new Set([ - 'am', - 'branch', - 'checkout', - 'cherry-pick', - 'commit', - 'merge', - 'push', - 'rebase', - 'reset', - 'revert', - 'stash', - 'switch', - 'tag', -]) - -// The subcommand split of a parsed segment, or an empty split when the -// segment is not `git`. The shared parse skips a global option's separate -// value token, so `git -C /repo rebase main` resolves to `rebase`. -const NOT_GIT: SubcommandSplit = { - ambiguous: false, - rest: [], - sub: undefined, -} - -function gitSegment(cmd: { - binary: string - args: readonly string[] -}): SubcommandSplit { - return cmd.binary === 'git' ? splitGitSubcommand(cmd.args) : NOT_GIT -} - -/** - * Index of the first pausing op that is followed by a later mutation. - * `stash` only pauses on pop/apply, so it is judged by its own second - * word rather than by name alone. - */ -export function findChainedPause( - commands: ReadonlyArray<{ binary: string; args: readonly string[] }>, -): { pausing: string; mutating: string } | undefined { - for (let i = 0; i < commands.length; i += 1) { - const cmd = commands[i] - if (!cmd) { - continue - } - const { rest, sub } = gitSegment(cmd) - // `--abort` / `--quit` TERMINATE an in-progress operation; they cannot - // leave the repo half-done, and chaining them is how you recover - // (`git rebase --abort && git checkout main`). Blocking recovery — the - // command reached for when already stuck — is worse than the hazard. - // `--continue` / `--skip` stay in scope: they re-enter the operation - // and can stop on the next conflict. - const terminates = cmd.args.some(a => a === '--abort' || a === '--quit') - const pauses = - !terminates && - ((sub !== undefined && PAUSING_OPS.has(sub)) || - (sub === 'stash' && ['pop', 'apply'].includes(rest[0] ?? ''))) - if (!pauses) { - continue - } - for (let j = i + 1; j < commands.length; j += 1) { - const later = commands[j] - if (!later) { - continue - } - const laterSub = gitSegment(later).sub - if (laterSub !== undefined && MUTATING_OPS.has(laterSub)) { - return { mutating: `git ${laterSub}`, pausing: `git ${sub}` } - } - } - } - return undefined -} - -const check = bashGuard(command => { - const found = findChainedPause(parseCommands(command)) - if (!found) { - return undefined - } - return block( - [ - `[${NAME}] Refusing to chain \`${found.mutating}\` after \`${found.pausing}\` in one command.`, - '', - `\`${found.pausing}\` can stop MID-FLIGHT on a conflict: it leaves HEAD`, - 'detached and prints the resolution steps. Chained, both halves of that', - 'go wrong — `;` runs the next command anyway, and a `| tail` / `| grep`', - 'swallows the instructions — so the repo is half-rebased while the next', - 'write lands somewhere unintended. A push in that state reports', - '"Everything up-to-date" and silently pushes nothing.', - '', - 'Run them as separate calls, and read the first result before the second:', - ` ${found.pausing} … # unpiped, so conflicts are visible`, - ` ${found.mutating} … # only after confirming the first finished`, - '', - 'Chaining read-only git (status, log, diff, rev-parse) is fine, and so', - 'is `git commit && git push` — neither of those can pause.', - ].join('\n'), - ) -}) - -export const hook = defineHook({ - bypass: ['chained-git'], - bypassMode: 'manual', - bypassOptional: true, - check, - event: 'PreToolUse', - matcher: ['Bash'], - triggers, - type: 'guard', -}) - -void runHook(hook, import.meta.url) diff --git a/.claude/hooks/fleet/no-chained-pausing-git-guard/package.json b/.claude/hooks/fleet/no-chained-pausing-git-guard/package.json deleted file mode 100644 index 9b754d6..0000000 --- a/.claude/hooks/fleet/no-chained-pausing-git-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-no-chained-pausing-git-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-chained-pausing-git-guard/tsconfig.json b/.claude/hooks/fleet/no-chained-pausing-git-guard/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/no-chained-pausing-git-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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/no-clipboard-access-guard/README.md b/.claude/hooks/fleet/no-clipboard-access-guard/README.md deleted file mode 100644 index 92e8c9a..0000000 --- a/.claude/hooks/fleet/no-clipboard-access-guard/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# no-clipboard-access-guard - -`PreToolUse(Bash | Edit | Write)` blocker that refuses clipboard access from a -script, hook, or Bash command. The system clipboard is a cross-process exfil + -overwrite surface: a secret copied there leaks to every app, and an OSC-52 -escape written to the terminal can silently overwrite (or, on permissive -terminals, read) it. Fleet tooling never needs the clipboard, so any attempt is -a mistake or a poisoning fingerprint. - -## Detected - -| Surface | Pattern | -| ----------- | --------------------------------------------------------------- | -| Bash | `pbcopy` / `pbpaste` (macOS) | -| Bash | `xclip` / `xsel` / `wl-copy` / `wl-paste` (Linux) | -| Bash | `clip` / `clip.exe` (Windows) | -| Edit /Write | source emitting an OSC-52 escape (`ESC ] 52 ;`, any spelling) | - -Bash detection is AST-parsed via the fleet shell parser (`findInvocation`), not -a loose regex, so a path fragment or quoted literal doesn't false-fire. The -OSC-52 match covers the raw ESC byte and the `\x1b` / `\033` / `` / `\e` -escaped spellings. - -## Bypass - -Type the canonical phrase verbatim in your next user turn: - -``` -Allow clipboard-access bypass -``` - -Use only for a genuine, operator-driven clipboard need (rare). - -## Why - -The terminal "attempted to access the clipboard but it was denied" banner comes -from an OSC-52 escape reaching the emulator. The denial is the safe default; this -hook stops fleet code from emitting one (or shelling out to a clipboard CLI) in -the first place, so the attempt never happens rather than relying on the -terminal to refuse it. diff --git a/.claude/hooks/fleet/no-clipboard-access-guard/index.mts b/.claude/hooks/fleet/no-clipboard-access-guard/index.mts deleted file mode 100644 index 1f486b0..0000000 --- a/.claude/hooks/fleet/no-clipboard-access-guard/index.mts +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-clipboard-access-guard. -// -// Blocks a script / hook / Bash command from READING the system clipboard, and -// blocks an OSC-52 escape emitted from source. Reading is a cross-process exfil -// surface (a secret on the clipboard, or another app's copied data, pulled into -// the agent's context), and a source-embedded OSC-52 escape is a silent -// overwrite / poisoning fingerprint. Explicit WRITES (an operator's `pbcopy` to -// hand a snippet to the clipboard) are allowed — putting data ONTO the clipboard -// is a deliberate, visible operator action, not an exfil. -// -// Two surfaces, gated on tool_name: -// -// 1. Bash — a clipboard READ CLI in the command line. AST-parsed via the -// fleet shell parser (commandsFor), not a loose regex, so a path fragment -// like `pbpasterc` or a quoted literal doesn't false-fire: -// macOS: pbpaste (read-only) -// Linux: wl-paste (read-only) -// xclip -o / -out / -output, xclip writes by default -// xsel, default outputs, unless a write flag (-i/-a/-c/-k) -// Write-only tools (`pbcopy`, `wl-copy`, `clip`/`clip.exe`) and a writing -// `xclip` / `xsel -i` are NOT blocked — writing to the clipboard is fine. -// -// 2. Edit / Write — source that emits an OSC-52 clipboard escape -// (`ESC ] 52 ; ...`) in any of its literal spellings (\x1b / \033 / -// / the raw control byte). That's the sequence the earlier -// Terminal "attempted to access the clipboard" denial came from — a silent -// escape in committed source is blocked regardless of read/write intent. -// -// Bypass: `Allow clipboard-access bypass` in a recent user turn — for a -// genuine, operator-driven clipboard READ (rare). - -import { block, defineHook, runHook } from '../_shared/guard.mts' -import type { ToolCallPayload } from '../_shared/payload.mts' -import { commandsFor } from '../_shared/shell-command.mts' - -// Pre-flight skip set: the dispatcher only imports this guard when the raw -// payload contains one of these. Every block path requires one — a clipboard -// READ binary name for the Bash arm (write-only `pbcopy`/`wl-copy`/`clip` are -// deliberately absent so a write never even imports the guard), or the `]52;` -// OSC-52 prefix, present under every escape spelling, for the Edit/Write arm. -export const triggers: readonly string[] = [ - ']52;', - 'pbpaste', - 'wl-paste', - 'xclip', - 'xsel', -] - -// xsel flags that mean the invocation WRITES (or clears/keeps) rather than -// prints the selection. xsel with none of these prints the current selection — -// i.e. it reads. Presence of any means it is not a read. -const XSEL_WRITE_FLAGS = new Set([ - '--append', - '--clear', - '--input', - '--keep', - '-a', - '-c', - '-i', - '-k', -]) - -// Clipboard READ CLIs, by platform, each with a predicate over the matched -// command segment's args deciding whether THIS invocation reads the clipboard. -const CLIPBOARD_READERS: ReadonlyArray<{ - readonly binary: string - readonly platform: string - readonly reads: (args: readonly string[]) => boolean -}> = [ - // pbpaste / wl-paste are read-only tools — every invocation reads. - { binary: 'pbpaste', platform: 'macOS', reads: () => true }, - { binary: 'wl-paste', platform: 'Linux', reads: () => true }, - // xclip writes from stdin by default (-i / -in); it READS only with an out - // flag (`-o` / `-out` / `-output`). - { - binary: 'xclip', - platform: 'Linux', - reads: args => - args.some(a => a === '-o' || a === '-out' || a === '-output'), - }, - // xsel prints the selection by default, a read; a write/clear/keep flag - // means it is not reading. - { - binary: 'xsel', - platform: 'Linux', - reads: args => !args.some(a => XSEL_WRITE_FLAGS.has(a)), - }, -] - -// OSC-52 clipboard escape in any literal spelling a source file might carry: -// the raw ESC byte, or an escaped \x1b / \033 / , immediately followed -// by `]52;`. Matching the prefix is enough — the payload after `52;` is the -// clipboard data and need not be parsed. -const OSC52_RE = /(?:\\033|\\e|\\u001b|\\x1b|\x1b)\]52;/i - -// The clipboard READ CLI invoked in a Bash command line, or undefined when none -// (a write-only tool, or a writing xclip/xsel, is not a read → undefined). -export function clipboardReadIn(command: string): string | undefined { - for (let i = 0, { length } = CLIPBOARD_READERS; i < length; i += 1) { - const reader = CLIPBOARD_READERS[i]! - const segments = commandsFor(command, reader.binary) - for (let j = 0, segLen = segments.length; j < segLen; j += 1) { - if (reader.reads(segments[j]!.args)) { - return reader.binary - } - } - } - return undefined -} - -// True when `text` emits an OSC-52 clipboard escape. -export function hasOsc52(text: string): boolean { - return OSC52_RE.test(text) -} - -// Decide what, if anything, to block for a payload. Returns the block reason, -// or undefined to pass. Pure — the test drives it directly. -export function clipboardViolation( - payload: ToolCallPayload, -): string | undefined { - const toolName = payload.tool_name - const input = payload.tool_input - if (!input) { - return undefined - } - if (toolName === 'Bash') { - const command = input.command - if (typeof command === 'string') { - const binary = clipboardReadIn(command) - if (binary) { - return `Bash command READS the clipboard via \`${binary}\`` - } - } - return undefined - } - if (toolName === 'Edit' || toolName === 'MultiEdit' || toolName === 'Write') { - const text = input.content ?? input.new_string - if (typeof text === 'string' && hasOsc52(text)) { - return 'content writes an OSC-52 clipboard escape sequence' - } - } - return undefined -} - -export const check = (payload: ToolCallPayload) => { - const reason = clipboardViolation(payload) - if (!reason) { - return undefined - } - return block( - [ - '[no-clipboard-access-guard] Blocked: clipboard read', - '', - ` ${reason}.`, - '', - ' READING the clipboard is a cross-process exfil surface — it pulls a', - ' secret or another app’s copied data into the agent’s context; a', - ' source-embedded OSC-52 escape can silently overwrite/read it. Writing', - ' TO the clipboard (e.g. `pbcopy`) is allowed — that is not blocked.', - ].join('\n'), - ) -} - -export const hook = defineHook({ - bypass: ['clipboard-access'], - check, - event: 'PreToolUse', - matcher: ['Bash'], - triggers, - type: 'guard', -}) -void runHook(hook, import.meta.url) diff --git a/.claude/hooks/fleet/no-commit-ai-attribution-guard/index.mts b/.claude/hooks/fleet/no-commit-ai-attribution-guard/index.mts deleted file mode 100644 index dbd0b1c..0000000 --- a/.claude/hooks/fleet/no-commit-ai-attribution-guard/index.mts +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-commit-ai-attribution-guard. -// -// Blocks a `git commit` tool call whose command text carries AI-attribution -// boilerplate ("Assisted-by: Claude Code:…", "Co-Authored-By: Claude", -// "🤖 Generated with …") before the commit exists. Fleet repos already strip -// attribution at the commit-msg git-hook stage and block it at pre-push, but -// this guard runs at the TOOL layer via the user-global dispatcher, so it -// also covers NON-fleet repos that have no fleet git hooks — the surface -// where the trailer kept landing (depscan, 2026-07-23: three PR branches -// needed history rewrites to strip it; one required a re-signing pass to -// satisfy the repo's verified-signature rule). -// -// Precedence, not correction: some repos (e.g. depscan) intentionally -// REQUIRE an attribution trailer as their own policy. This guard does not -// dispute that policy — it outranks it for the operator's sessions, the -// way a tool-layer deny always outranks repo instructions. Other agents -// and humans in those repos remain bound by the repo's rules. -// -// The detector is the SAME `containsAiAttribution` the git hooks and -// no-github-ai-attribution-guard use, imported from the gate-free shared -// module so the surfaces never diverge on what counts as attribution. -// -// Detection scans the WHOLE flattened command once a `git commit` segment is -// present — not just the extracted `-m` value — because commit messages are -// routinely passed via `-m "$(cat <<'EOF' … EOF)"` heredocs the arg parser -// cannot expand. The tradeoff (a compound that both commits and mentions an -// attribution string elsewhere gets blocked) is rare and bypassable. -// -// Skipped silently: -// - tool_name !== 'Bash'. -// - Command has no `git commit` segment. -// - No attribution text anywhere in the command. -// -// Bypass: `Allow ai-attribution bypass` (same phrase as the gh/MCP prose -// guard — one surface for the operator). - -import { containsAiAttribution } from '../_shared/ai-attribution.mts' -import { isGitCommit } from '../_shared/commit-command.mts' -import { bashGuard, block, defineHook, runHook } from '../_shared/guard.mts' - -// Dispatcher pre-flight: every `git commit` invocation carries the literal -// `commit` substring the dispatcher gates on before importing this guard. -export const triggers: readonly string[] = ['commit'] - -export const check = bashGuard(command => { - if (!isGitCommit(command)) { - return undefined - } - if (!containsAiAttribution(command)) { - return undefined - } - return block( - [ - '🚨 no-commit-ai-attribution-guard: blocked a `git commit` carrying', - ' AI-attribution boilerplate in its message.', - '', - 'Commit messages carry no AI byline — no "Assisted-by: …",', - '"Co-Authored-By: Claude", "🤖 Generated with …", or similar.', - 'This wheelhouse rule takes PRECEDENCE over a repo CLAUDE.md that', - 'requires an attribution trailer (e.g. depscan) — do not add one', - 'even when the repo instructions ask for it.', - '', - 'Fix: drop the attribution line(s) from the message and re-run.', - '', - 'Bypass (the user must type verbatim in a recent turn):', - ' `Allow ai-attribution bypass`', - ].join('\n'), - ) -}) - -export const hook = defineHook({ - bypass: ['ai-attribution'], - bypassOptional: true, - check, - event: 'PreToolUse', - global: true, - matcher: ['Bash'], - scope: 'convention', - triggers, - type: 'guard', -}) -void runHook(hook, import.meta.url) diff --git a/.claude/hooks/fleet/no-copyleft-source-read/index.mts b/.claude/hooks/fleet/no-copyleft-source-read/index.mts deleted file mode 100644 index f37a1fd..0000000 --- a/.claude/hooks/fleet/no-copyleft-source-read/index.mts +++ /dev/null @@ -1,572 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-copyleft-source-read. -// -// BLOCKS every route an agent has to the IMPLEMENTATION of a copyleft upstream. -// A copyleft project may be RUN as a tool and OBSERVED through its own tests — -// behavior is not implementation — but reading, copying, or deriving from its -// source makes the consuming package a derivative work and forces the -// upstream's license onto it. The roster, the tests allowlist, and the matcher -// all live in `_shared/copyleft-upstreams.mts`, which the commit-time belt -// `copyleft-slices-are-tests-only.mts` shares, so guard and gate cannot drift. -// -// STRUCTURE IS NOT CONTENT. A directory tree — paths, file names, blob shas, -// counts — is FACT, not expression, and copyright does not reach it. Only the -// code itself is off limits. So enumeration is ALLOWED everywhere and only -// content reads are blocked. Conflating the two is not merely over-strict, it -// is actively harmful: it blocks the very listing needed to verify that a -// roster entry's tests allowlist matches the upstream's real test corpus, so -// the guard's own data silently rots behind the guard. -// -// ALLOWED — enumeration, yields paths and names, never file bytes: -// - `ls` at any depth, `tree`, `find` with name-style output. -// - `git ls-tree` / `git ls-files`; a blob sha names a blob, it is not one. -// - `gh api repos///git/trees/` — the remote tree listing. -// - The Glob tool; its results ARE paths, including a bare submodule-root -// pattern such as `upstream//**`. -// - Read of a DIRECTORY, which yields an entry listing rather than content. -// - `rg -l` / `grep -l` / `--files-with-matches` / `--count` — path-only -// output. See docs/agents.md/fleet/copyleft-boundaries.md for why the -// theoretical content-oracle in `-l` is accepted rather than blocked. -// -// BLOCKED — content: -// - Read of a non-test FILE under `upstream//`. -// - `cat` / `head` / `tail` / `less` / `strings` and equivalents on a -// non-test file, whether named directly or reached by a leading `cd`. -// - `rg` / `grep` in default LINE-PRINTING mode against a non-test scope; -// matching lines are content. The Grep tool likewise blocks only when -// `output_mode` is `content`. -// - `find … -exec`/`-execdir`/`-ok`, which runs an arbitrary reader per hit. -// - `git show :`, `git cat-file` of a non-test blob, and -// `git archive`. `git show HEAD:` prints a tree listing rather than -// content, but the guard cannot tell a dir from a file in a rev-spec, so it -// stays blocked and `git ls-tree` is the sanctioned enumeration route. -// - `gh api repos///contents/` for a non-test path. -// - `curl` / `wget` against `raw.githubusercontent.com`, a -// `github.com///{blob,raw}` file view, or a whole-tree archive from -// `codeload.github.com` / `/archive` / `/tarball` / `/zipball`. -// - `git sparse-checkout set|add|disable|reapply` that would WIDEN a copyleft -// submodule's cone past its tests allowlist. This is the route that matters -// most: widening the cone materializes the implementation on disk, after -// which every later read looks like an ordinary local file. -// - WebFetch of the same URLs. WebSearch carries a query, not a fetchable -// URL, so there is nothing for this guard to match on it; the URL its -// results lead to arrives as a WebFetch and is gated there. -// -// Fails open on parse errors — a guard bug must never wedge a session. -// -// Convention: docs/agents.md/fleet/copyleft-boundaries.md. -// Bypass: `Allow copyleft-source-read bypass`. - -import { statSync } from 'node:fs' - -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' - -import { - copyleftSparseRecipe, - detectCopyleftImplementationRead, - detectCopyleftScopeRead, - detectCopyleftUrlRead, - findCopyleftUpstreamByRepo, - isCopyleftObservablePath, - isCopyleftSparsePatternAllowed, -} from '../_shared/copyleft-upstreams.mts' -import { bashGuard, block, defineHook, runHook } from '../_shared/guard.mts' -import { - commandsFor, - commandWorkingDir, - parseCommands, -} from '../_shared/shell-command.mts' - -import type { - CopyleftReadFinding, - CopyleftUpstream, -} from '../_shared/copyleft-upstreams.mts' -import type { GuardResult } from '../_shared/guard.mts' -import type { ToolCallPayload } from '../_shared/payload.mts' - -// Pre-flight keywords the dispatcher tests against the raw payload before -// importing this hook. Every route names its upstream — the submodule dir, the -// URL, the gh-api slug all carry the repo name — so the roster's repo names -// plus the `upstream/` prefix cover the surface. The literal array is -// load-bearing: gen/hook-dispatch.mts parses these tokens STATICALLY out of the -// source, so a computed list would read as no triggers at all. A test asserts -// every roster entry's repo name appears here. -// `upstream/` is deliberately the broadest entry here, and it is the safety -// net rather than sloppiness: it routes ANY read under the vendored submodule -// tree to this hook, including an upstream nobody has added a keyword for yet. -// Narrowing it to per-upstream paths would recreate the gap the roster test -// exists to catch — a new copyleft submodule would sit unguarded until someone -// remembered to list it. Over-matching costs one cheap module import; -// under-matching costs a silent bypass of a license boundary. -export const triggers: readonly string[] = [ - 'rust-cache', - 'trufflehog', - 'upstream/', -] - -// git subcommands that stream a blob or a tree out of a repository. -const GIT_READ_SUBCOMMANDS = new Set(['archive', 'cat-file', 'show']) -// git sparse-checkout operations that can widen a cone. -const GIT_SPARSE_WIDENING = new Set(['add', 'disable', 'reapply', 'set']) -// Fetchers whose arguments are URLs. -const URL_FETCHERS: readonly string[] = ['curl', 'wget'] -// Binaries that stream a file's BYTES to stdout. Every bare path operand is a -// content read. `ls` / `tree` / `find` are deliberately absent — they emit -// names, which is structure. -const CONTENT_READERS = new Set([ - 'bat', - 'cat', - 'head', - 'less', - 'more', - 'nl', - 'od', - 'strings', - 'tail', - 'xxd', -]) -// Search binaries that print MATCHING LINES by default. Lines are content, so -// these block unless a flag reduces the output to paths. -const SEARCH_BINARIES = new Set(['grep', 'rg', 'ripgrep']) -// Flags that reduce a search to paths, or to per-path tallies — the same -// information class as a directory listing. `-L` is absent on purpose: it means -// files-without-match in grep but follow-symlinks in rg, and a flag that blocks -// in one tool and not the other is worse than requiring the long spelling. -const SEARCH_PATH_ONLY_FLAGS = new Set([ - '--count', - '--files', - '--files-with-matches', - '--files-without-match', - '-c', - '-l', -]) -// `find` actions that hand each hit to an arbitrary command, which is how a -// name-only walk turns into a content read. -const FIND_EXEC_ACTIONS = new Set(['-exec', '-execdir', '-ok', '-okdir']) - -/** - * A blocked copyleft read: the finding plus the human label for HOW it was - * reached, which becomes the message's Where line. - */ -export interface CopyleftBlock { - readonly finding: CopyleftReadFinding - readonly how: string -} - -// The copyleft upstream a directory sits inside, or undefined. Used for the -// git routes, where the submodule is named by `-C`/`cd` rather than by the -// path argument. -function copyleftUpstreamForDir(dir: string): CopyleftUpstream | undefined { - const normalized = normalizePath(dir).replace(/\/+$/, '') - // `(?:^|\/)upstream\/` anchors the segment so `my-upstream/` cannot match; - // `([^/]+)` is the submodule directory name. - const match = /(?:^|\/)upstream\/([^/]+)(?:\/|$)/.exec(normalized) - return match ? findCopyleftUpstreamByRepo(match[1]!) : undefined -} - -// The blob path inside a `git show`/`git cat-file` revision argument. Both -// accept `:`; a bare `` names no path. -function blobPathInRevision(arg: string): string | undefined { - const colon = arg.indexOf(':') - return colon === -1 ? undefined : arg.slice(colon + 1) -} - -// Pre-subcommand git flags that CONSUME the next token. Their value is a bare -// token, so a naive non-flag filter would read `git -C show` as the -// subcommand `` and miss the read entirely. -const GIT_FLAGS_WITH_VALUE = new Set(['--git-dir', '--work-tree', '-C', '-c']) - -// The bare, non-flag tokens of a parsed git command's argument list, with the -// values of value-taking global flags removed so `bare[0]` is the subcommand. -function bareArgs(args: readonly string[]): string[] { - const bare: string[] = [] - for (let i = 0, { length } = args; i < length; i += 1) { - const arg = args[i]! - if (GIT_FLAGS_WITH_VALUE.has(arg)) { - i += 1 - continue - } - if (!arg.startsWith('-')) { - bare.push(arg) - } - } - return bare -} - -/** - * Detect a `git show` / `git cat-file` / `git archive` that would stream a - * copyleft implementation. The submodule is resolved from the command's - * effective working directory first — `git -C upstream/` and a leading - * `cd` both land there — and otherwise from an `upstream//…` path typed - * into the arguments themselves. - */ -export function detectCopyleftGitRead( - command: string, -): CopyleftBlock | undefined { - const cwdUpstream = copyleftUpstreamForDir(commandWorkingDir(command)) - const gitCmds = commandsFor(command, 'git') - for (let i = 0, { length } = gitCmds; i < length; i += 1) { - const bare = bareArgs(gitCmds[i]!.args) - const sub = bare[0] - if (!sub || !GIT_READ_SUBCOMMANDS.has(sub)) { - continue - } - // `git archive` streams the whole tree; no revision path narrows it enough - // to be observable, so any copyleft target is a block. - if (sub === 'archive' && cwdUpstream) { - return { - finding: { path: '', route: 'submodule-path', upstream: cwdUpstream }, - how: 'a `git archive` of the whole tree', - } - } - for (let j = 1, { length: blen } = bare; j < blen; j += 1) { - const arg = bare[j]! - // An `upstream//…` path typed directly into the arguments. - const direct = detectCopyleftImplementationRead(arg) - if (direct) { - return { finding: direct, how: `a \`git ${sub}\`` } - } - const blobPath = blobPathInRevision(arg) - if ( - cwdUpstream && - blobPath !== undefined && - !isCopyleftObservablePath(cwdUpstream, blobPath) - ) { - return { - finding: { - path: blobPath, - route: 'submodule-path', - upstream: cwdUpstream, - }, - how: `a \`git ${sub}\` of a tracked blob`, - } - } - } - } - return undefined -} - -// The copyleft upstream named by any bare token of a sparse-checkout command, -// for the `git sparse-checkout … upstream/` spelling that does not go -// through `-C` or a leading `cd`. -function sparseTargetInArgs( - bare: readonly string[], -): CopyleftUpstream | undefined { - for (let i = 2, { length } = bare; i < length; i += 1) { - const hit = copyleftUpstreamForDir(bare[i]!) - if (hit) { - return hit - } - } - return undefined -} - -/** - * Detect a `git sparse-checkout` operation that would widen a copyleft - * submodule's cone past its tests allowlist. `disable` and `reapply` are - * blocked outright: `disable` restores the FULL tree by definition, and - * `reapply` re-materializes whatever the on-disk cone config currently says — - * which the guard cannot prove is still the tests slice. Re-establishing the - * sanctioned cone with an explicit `set` is the allowed path, and it is exactly - * the command the Fix line hands back. - */ -export function detectCopyleftSparseWiden( - command: string, -): CopyleftBlock | undefined { - const cwdUpstream = copyleftUpstreamForDir(commandWorkingDir(command)) - const gitCmds = commandsFor(command, 'git') - for (let i = 0, { length } = gitCmds; i < length; i += 1) { - const bare = bareArgs(gitCmds[i]!.args) - if (bare[0] !== 'sparse-checkout') { - continue - } - const op = bare[1] - if (!op || !GIT_SPARSE_WIDENING.has(op)) { - continue - } - const target = cwdUpstream ?? sparseTargetInArgs(bare) - if (!target) { - continue - } - if (op === 'disable' || op === 'reapply') { - return { - finding: { path: '', route: 'sparse-widen', upstream: target }, - how: `a \`git sparse-checkout ${op}\``, - } - } - for (let j = 2, { length: blen } = bare; j < blen; j += 1) { - if (!isCopyleftSparsePatternAllowed(target, bare[j]!)) { - return { - finding: { path: bare[j]!, route: 'sparse-widen', upstream: target }, - how: `a \`git sparse-checkout ${op}\` pattern`, - } - } - } - } - return undefined -} - -// A path operand judged as a FILE read, tried both as typed and as resolved -// against the command's working dir, so `cd upstream/ && cat pkg/x.go` -// is caught even though the operand carries no `upstream/` segment. -function copyleftFileFinding( - cwd: string, - arg: string, -): CopyleftReadFinding | undefined { - return ( - detectCopyleftImplementationRead(arg) ?? - detectCopyleftImplementationRead(`${cwd}/${arg}`) - ) -} - -// The same, judged as a SEARCH SCOPE: a directory operand counts because a -// recursive search under it reads every file it holds. -function copyleftScopeFinding( - cwd: string, - arg: string, -): CopyleftReadFinding | undefined { - return ( - detectCopyleftScopeRead(arg) ?? detectCopyleftScopeRead(`${cwd}/${arg}`) - ) -} - -/** - * True when a search invocation prints only paths or tallies. Covers the long - * flags, the bare `-l`/`-c`, and a short-flag cluster such as `-rl` / `-ln`. - */ -export function isPathOnlySearch(args: readonly string[]): boolean { - for (let i = 0, { length } = args; i < length; i += 1) { - const arg = args[i]! - if (SEARCH_PATH_ONLY_FLAGS.has(arg)) { - return true - } - // `^-[A-Za-z]+$` is a short-flag cluster, no `--` and no `=value`; an `l` - // anywhere inside it is the files-with-matches flag. - if (/^-[A-Za-z]+$/.test(arg) && arg.includes('l')) { - return true - } - } - return false -} - -/** - * Detect a LOCAL content read of a copyleft implementation: a `cat`-family - * reader on a non-test file, a line-printing `grep`/`rg` over a non-test scope, - * or a `find … -exec` that hands each hit to an arbitrary command. - * - * Enumeration passes straight through — `ls`, `tree`, a name-only `find`, and - * `git ls-tree`/`ls-files` are never in scope here. - */ -export function detectCopyleftContentRead( - command: string, -): CopyleftBlock | undefined { - const cwd = commandWorkingDir(command) - const commands = parseCommands(command) - for (let i = 0, { length } = commands; i < length; i += 1) { - const cmd = commands[i]! - const bare = cmd.args.filter(a => !a.startsWith('-')) - if (CONTENT_READERS.has(cmd.binary)) { - for (let j = 0, { length: blen } = bare; j < blen; j += 1) { - const finding = copyleftFileFinding(cwd, bare[j]!) - if (finding) { - return { finding, how: `a \`${cmd.binary}\`` } - } - } - } else if (SEARCH_BINARIES.has(cmd.binary)) { - if (isPathOnlySearch(cmd.args)) { - continue - } - // The first bare operand is the PATTERN unless `-e`/`--regexp` supplied - // it, so skipping it keeps a search FOR the text of an upstream path from - // reading as a search INSIDE that path. - const patternIsFlagged = - cmd.args.includes('-e') || cmd.args.includes('--regexp') - for ( - let j = patternIsFlagged ? 0 : 1, { length: blen } = bare; - j < blen; - j += 1 - ) { - const finding = copyleftScopeFinding(cwd, bare[j]!) - if (finding) { - return { finding, how: `a line-printing \`${cmd.binary}\`` } - } - } - } else if (cmd.binary === 'find') { - if (!cmd.args.some(a => FIND_EXEC_ACTIONS.has(a))) { - continue - } - for (let j = 0, { length: blen } = bare; j < blen; j += 1) { - const finding = copyleftScopeFinding(cwd, bare[j]!) - if (finding) { - return { finding, how: 'a `find … -exec`' } - } - } - } - } - return undefined -} - -/** - * Detect a Bash network read of a copyleft implementation: a `gh api - * repos///contents/` call, or a `curl`/`wget` against a raw blob, - * a `github.com` file view, or a whole-tree archive. - */ -export function detectCopyleftNetworkRead( - command: string, -): CopyleftBlock | undefined { - const ghCmds = commandsFor(command, 'gh') - for (let i = 0, { length } = ghCmds; i < length; i += 1) { - const { args } = ghCmds[i]! - if (args[0] !== 'api') { - continue - } - for (let j = 1, { length: alen } = args; j < alen; j += 1) { - const finding = detectCopyleftUrlRead(args[j]!) - if (finding) { - return { finding, how: 'a `gh api` contents read' } - } - } - } - for (let i = 0, { length } = URL_FETCHERS; i < length; i += 1) { - const fetcher = URL_FETCHERS[i]! - const cmds = commandsFor(command, fetcher) - for (let j = 0, { length: clen } = cmds; j < clen; j += 1) { - const { args } = cmds[j]! - for (let k = 0, { length: alen } = args; k < alen; k += 1) { - const finding = detectCopyleftUrlRead(args[k]!) - if (finding) { - return { finding, how: `a \`${fetcher}\` download` } - } - } - } - } - return undefined -} - -/** - * The full Bash surface: network fetch, git blob/tree read, sparse-cone widen. - */ -export function detectCopyleftBashRead( - command: string, -): CopyleftBlock | undefined { - return ( - detectCopyleftNetworkRead(command) ?? - detectCopyleftSparseWiden(command) ?? - detectCopyleftGitRead(command) ?? - detectCopyleftContentRead(command) - ) -} - -/** - * The block message: one verdict line naming the route, the target, and the - * SPDX id, plus one evidence line carrying the tests-only cone recipe (and the - * permissive alternative when one is recorded). - */ -export function formatCopyleftBlock(detection: CopyleftBlock): string { - const { finding, how } = detection - const { upstream } = finding - const slug = `${upstream.owner}/${upstream.repo}` - const target = - finding.path === '' - ? `the whole \`${slug}\` tree` - : `\`${finding.path}\` in \`${slug}\`` - const alt = upstream.permissiveAlternative - ? `; permissive alternative: ${upstream.permissiveAlternative}` - : '' - const lines = [ - `🚨 no-copyleft-source-read: blocked ${how} of ${target} — ${slug} is ${upstream.spdx}; run it as a tool and observe it through its OWN tests only (${upstream.testPathPatterns.join(', ')})${alt} (docs/agents.md/fleet/copyleft-boundaries.md)`, - ` enumeration is fine (\`ls\` / \`tree\` / \`git ls-tree\` / Glob / \`rg -l\`); tests-only cone: ${copyleftSparseRecipe(upstream)}`, - ] - return `${lines.join('\n')}\n` -} - -// Read narrows to one file; Grep/Glob narrow to a scope, so the scope matcher -// runs for them. -/** - * True when a Read targets a DIRECTORY, whose result is an entry listing rather - * than file bytes. Structure is fact, so a directory Read is enumeration and - * passes. A path that cannot be stat'd is treated as a file: that is the - * fail-safe side, and a Read of a nonexistent path errors on its own anyway. - */ -export function isDirectoryRead(filePath: string): boolean { - try { - return statSync(filePath).isDirectory() - } catch { - return false - } -} - -// Grep's `output_mode`: 'content' prints matching LINES, which is content. -// 'files_with_matches' — the tool's DEFAULT — and 'count' emit paths and -// tallies, the same information class as a listing. -function grepPrintsContent(input: ToolCallPayload['tool_input']): boolean { - return input?.output_mode === 'content' -} - -function checkReadTools(payload: ToolCallPayload): GuardResult { - const tool = payload?.tool_name - const input = payload?.tool_input - if (tool === 'Read') { - const filePath = typeof input?.file_path === 'string' ? input.file_path : '' - // Listing a directory inside the submodule is enumeration, not a read. - if (isDirectoryRead(filePath)) { - return undefined - } - const finding = detectCopyleftImplementationRead(filePath) - return finding - ? block(formatCopyleftBlock({ finding, how: 'a Read' })) - : undefined - } - // Glob is never gated: its results ARE paths, so even a bare - // `upstream//**` is a listing. - if (tool !== 'Grep' || !grepPrintsContent(input)) { - return undefined - } - const searchPath = typeof input?.path === 'string' ? input.path : undefined - if (searchPath) { - const finding = detectCopyleftScopeRead(searchPath) - if (finding) { - return block( - formatCopyleftBlock({ finding, how: 'a line-printing Grep scope' }), - ) - } - } - return undefined -} - -function checkWebFetch(payload: ToolCallPayload): GuardResult { - if (payload?.tool_name !== 'WebFetch') { - return undefined - } - const url = payload?.tool_input?.url - if (typeof url !== 'string') { - return undefined - } - const finding = detectCopyleftUrlRead(url) - return finding - ? block(formatCopyleftBlock({ finding, how: 'a WebFetch' })) - : undefined -} - -const bashCheck = bashGuard(command => { - const detection = detectCopyleftBashRead(command) - return detection ? block(formatCopyleftBlock(detection)) : undefined -}) - -export async function check(payload: ToolCallPayload): Promise { - return ( - checkReadTools(payload) ?? - checkWebFetch(payload) ?? - (await bashCheck(payload)) - ) -} - -export const hook = defineHook({ - bypass: ['copyleft-source-read'], - check, - event: 'PreToolUse', - matcher: ['Bash', 'Grep', 'Read', 'WebFetch'], - triggers, - type: 'guard', -}) -void runHook(hook, import.meta.url) diff --git a/.claude/hooks/fleet/no-copyleft-source-read/package.json b/.claude/hooks/fleet/no-copyleft-source-read/package.json deleted file mode 100644 index 8c864ce..0000000 --- a/.claude/hooks/fleet/no-copyleft-source-read/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "hook-no-copyleft-source-read", - "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/no-copyleft-source-read/tsconfig.json b/.claude/hooks/fleet/no-copyleft-source-read/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/no-copyleft-source-read/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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/no-corepack-guard/README.md b/.claude/hooks/fleet/no-corepack-guard/README.md deleted file mode 100644 index 43576fc..0000000 --- a/.claude/hooks/fleet/no-corepack-guard/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# no-corepack-guard - -**Type:** PreToolUse guard (Bash) — BLOCKS (exit 2). - -**Trigger:** a Bash command that activates corepack to provision a package -manager — `corepack enable`, `corepack prepare` (e.g. `corepack prepare -pnpm@9 --activate`), `corepack use`, or `corepack install`. Detected by -AST-parsing the command (`commandsFor`), not a raw regex. `corepack --version` -/ `corepack --help` / `corepack disable` provision nothing and are left alone. - -**Why:** corepack is verboten fleet-wide. The fleet pins pnpm in -`external-tools.json` and installs it from that exact version via download + -Subresource-Integrity — `scripts/fleet/setup/setup-tools.mjs` locally, the -SocketDev/socket-registry `setup` composite action in CI — so the bytes are -integrity-checked before they run. corepack instead fetches a package manager -from the npm registry at activation time, outside that gate, keyed off a -mutable `packageManager` field: a second, un-pinned provisioning path that -bypasses the fleet's supply-chain controls. CLAUDE.md already bans -`npx`/`dlx`/`tsx` for adjacent reasons; this guard closes the corepack hole. - -**Not the `packageManager` field:** that field stays in package.json as a -declared-version RECORD, kept in lockstep with `external-tools.json` (see -`scripts/repo/tools/pnpm.mts`). This guard blocks only the corepack COMMANDS -that would act on it, never the field itself. - -**Fix the message gives:** -- local bootstrap: `node scripts/fleet/setup/setup-tools.mjs` -- CI: the same step runs via the socket-registry `setup` action (no caller change) - -**Bypass:** `Allow corepack bypass` typed verbatim in a recent user turn. - -**Fails open** on parse / payload errors (exit 0) — a guard bug must not wedge -every Bash call. diff --git a/.claude/hooks/fleet/no-corepack-guard/index.mts b/.claude/hooks/fleet/no-corepack-guard/index.mts deleted file mode 100644 index dce1602..0000000 --- a/.claude/hooks/fleet/no-corepack-guard/index.mts +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-corepack-guard. -// -// BLOCKS any Bash command that activates corepack to provision a package -// manager: `corepack enable`, `corepack prepare`, `corepack use`, or -// `corepack install` (with or without a `pnpm@` / `--activate` argument). -// -// Why corepack is verboten fleet-wide: the fleet installs pnpm from a pinned -// version via download + Subresource-Integrity (the `setup-tools.mjs` -// bootstrap locally, the SocketDev/socket-registry `setup` composite action in -// CI) so the exact bytes are integrity-checked before they run. corepack -// instead fetches a package manager from the npm registry at activation time, -// outside that gate, and keys off Corepack's own `packageManager` field — a -// second, un-pinned provisioning path that bypasses the fleet's supply-chain -// controls. Corepack and its `packageManager` field are retired fleet-wide -// (docs/agents.md/fleet/tooling.md); `devEngines.packageManager` — a -// major-bounded SemVer range derived from external-tools.json by -// sync-package-manager-pins.mts — is the enforced pin instead, and pnpm -// resolves it natively with no corepack activation. This guard blocks the -// corepack COMMANDS outright; it has nothing to leave in place. -// -// Detection, AST-parsed via the shared shell-command helper, not a raw regex: -// the command runs the `corepack` binary with an activating subcommand. -// `corepack --version` / `corepack --help` are allowed, they activate nothing. -// -// Bypass: `Allow corepack bypass` typed verbatim in a recent user turn. -// -// Fails open on parse / payload errors — a guard bug must not wedge every Bash -// call. - -import { isFleetTarget } from '../_shared/fleet-context.mts' -import { bashGuard, block, defineHook, runHook } from '../_shared/guard.mts' -import { commandsFor } from '../_shared/shell-command.mts' - -// corepack subcommands that fetch + activate a package manager. `enable` -// shims the PMs onto PATH; `prepare`/`use`/`install` download a specific -// version. Anything else (`--version`, `--help`, `disable`) provisions -// nothing and is left alone. -const ACTIVATING_SUBCOMMANDS = ['enable', 'install', 'prepare', 'use'] as const - -// Pre-flight skip hint for the dispatcher: detection only ever fires when the -// `corepack` binary is invoked, so a command that lacks the substring -// `corepack` can never block. The activating subcommands (enable/install/ -// prepare/use) are subordinate — they matter only once `corepack` is present — -// so the binary name is the single necessary-and-sufficient trigger. -export const triggers: readonly string[] = ['corepack'] - -export interface CorepackDetection { - readonly detected: boolean - // The activating subcommand seen (enable / prepare / use / install), for - // the message. Empty when nothing was detected. - readonly subcommand: string -} - -export function detectCorepack(command: string): CorepackDetection { - const corepackCmds = commandsFor(command, 'corepack') - for (const { args } of corepackCmds) { - // The first non-flag token is the subcommand (`corepack enable`, - // `corepack prepare pnpm@9`). A leading flag (`corepack --version`) - // means no activating subcommand. - for (let i = 0, { length } = args; i < length; i += 1) { - const arg = args[i]! - if (arg.startsWith('-')) { - continue - } - if ((ACTIVATING_SUBCOMMANDS as readonly string[]).includes(arg)) { - return { detected: true, subcommand: arg } - } - // First bare token is some other subcommand (e.g. `disable`) — stop. - break - } - } - return { detected: false, subcommand: '' } -} - -export function formatBlock(d: CorepackDetection): string { - return ( - [ - `[no-corepack-guard] Blocked: \`corepack ${d.subcommand}\` activates a package manager outside the fleet's supply-chain gate.`, - '', - ' The fleet pins pnpm in external-tools.json and installs it from that', - ' exact version via download + SRI-integrity — never corepack:', - '', - ' node scripts/fleet/setup/setup-tools.mjs (local bootstrap)', - ' # CI runs the same step via the socket-registry `setup` action', - '', - ' Corepack and its `packageManager` field are retired fleet-wide;', - ' package.json `devEngines.packageManager` is the enforced pin, kept', - ' in lockstep with external-tools.json — pnpm resolves it natively.', - ].join('\n') + '\n' - ) -} - -export const check = bashGuard((command, payload) => { - const detection = detectCorepack(command) - if (!detection.detected) { - return undefined - } - // The corepack ban is a fleet tooling CONVENTION — the fleet pins its pnpm - // version (corepack would auto-update past the pin + soak). Outside a fleet - // repo, corepack is the standard Node way to manage the package-manager - // version and is the project's own choice. No supply-chain fetch-exec angle. - if (!isFleetTarget(payload)) { - return undefined - } - return block(formatBlock(detection)) -}) - -export const hook = defineHook({ - bypass: ['corepack'], - check, - event: 'PreToolUse', - matcher: ['Bash'], - triggers, - type: 'guard', -}) -void runHook(hook, import.meta.url) diff --git a/.claude/hooks/fleet/no-corepack-guard/package.json b/.claude/hooks/fleet/no-corepack-guard/package.json deleted file mode 100644 index da94f2a..0000000 --- a/.claude/hooks/fleet/no-corepack-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-no-corepack-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-corepack-guard/tsconfig.json b/.claude/hooks/fleet/no-corepack-guard/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/no-corepack-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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/no-description-aside-guard/README.md b/.claude/hooks/fleet/no-description-aside-guard/README.md deleted file mode 100644 index d3acbfb..0000000 --- a/.claude/hooks/fleet/no-description-aside-guard/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# no-description-aside-guard - -PreToolUse hook that BLOCKS a write to a package manifest (`package.json` or -`Cargo.toml`) when the `description` field ends with a listy parenthetical -aside. - -## Why - -A manifest description reads best as a plain statement of what the package is. A -trailing `(a, b, c)` or `(x + y)` tail re-lists detail the sentence already -carries, so it reads as filler. This guard blocks that tail at write time. Fleet -convention: `-guard` blocks, `-nudge` nudges. - -## What it catches - -The `description` value, once trailing punctuation is trimmed, ends with a -parenthetical whose inner text is a list (items joined by a comma, ` + `, ` / `, -or ` and `) or runs five or more words. - -| Description | Verdict | -| ----------------------------------------- | ------- | -| `TUI for court lookups (bulk CSV + live)` | blocked | -| `Parser for the Foo format (fast, small)` | blocked | -| `Parser for the Foo format` | allowed | -| `JSON reader (RFC 8259)` | allowed | - -The detector is shared with `anti-prose-guard` via -`_shared/trailing-aside.mts`, which flags the same shape on Markdown headings. - -## Scope - -Only `package.json` and `Cargo.toml` are checked, matched against the normalized -path. Other files pass untouched. - -## Bypass - -The user types `Allow description-aside bypass` verbatim in a recent turn. - -## Test - -Specs live in `test/repo/integration/hooks/no-description-aside-guard.test.mts`. diff --git a/.claude/hooks/fleet/no-description-aside-guard/index.mts b/.claude/hooks/fleet/no-description-aside-guard/index.mts deleted file mode 100644 index 5b2bf14..0000000 --- a/.claude/hooks/fleet/no-description-aside-guard/index.mts +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-description-aside-guard. -// -// BLOCKS Write/Edit to a package manifest (package.json, Cargo.toml) when the -// `description` field ends with a listy parenthetical aside — the "extra bits" -// tail that re-lists what the description already says. A manifest description -// states the thing plainly; detail that matters belongs in the sentence. -// -// Bypass: `Allow description-aside bypass` typed verbatim in a recent user turn. - -import path from 'node:path' - -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' - -import { block, defineHook, editGuard, runHook } from '../_shared/guard.mts' -import { bypassPhrasePresent } from '../_shared/transcript.mts' -import { trailingListyAside } from '../_shared/trailing-aside.mts' - -const BYPASS_PHRASE = 'Allow description-aside bypass' - -// package.json and Cargo.toml at any depth, matched on the normalized path. -const MANIFEST_RE = /(?:^|\/)(?:Cargo\.toml|package\.json)$/ - -// package.json: "description": "…" Cargo.toml: description = "…" -const JSON_DESC_RE = /"description"\s*:\s*"((?:[^"\\]|\\.)*)"/g -const TOML_DESC_RE = /^\s*description\s*=\s*"([^"]*)"/gm - -function descriptionValues(content: string): string[] { - const values: string[] = [] - for (const match of content.matchAll(JSON_DESC_RE)) { - values.push(match[1] ?? '') - } - for (const match of content.matchAll(TOML_DESC_RE)) { - values.push(match[1] ?? '') - } - return values -} - -export const check = editGuard((filePath, content, payload) => { - if (content === undefined) { - return undefined - } - const normalized = normalizePath(filePath) - if (!MANIFEST_RE.test(normalized)) { - return undefined - } - const offenders: string[] = [] - for (const value of descriptionValues(content)) { - const aside = trailingListyAside(value) - if (aside) { - offenders.push(aside) - } - } - if (!offenders.length) { - return undefined - } - if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE)) { - return undefined - } - const rel = path.basename(filePath) - // One line per offending description; the fix and bypass ride the first line. - const lines: string[] = [] - for (let i = 0, { length } = offenders; i < length; i += 1) { - lines.push( - i === 0 - ? `🚨 no-description-aside-guard: drop the trailing listy aside "(${offenders[i]})" from ${rel}'s description — fold detail into the sentence (bypass response "${BYPASS_PHRASE}")` - : ` "(${offenders[i]})"`, - ) - } - return block(lines.join('\n')) -}) - -export const hook = defineHook({ - bypass: ['description-aside'], - bypassMode: 'manual', - check, - event: 'PreToolUse', - matcher: ['Edit', 'MultiEdit', 'Write'], - scope: 'convention', - type: 'guard', -}) - -void runHook(hook, import.meta.url) diff --git a/.claude/hooks/fleet/no-description-aside-guard/package.json b/.claude/hooks/fleet/no-description-aside-guard/package.json deleted file mode 100644 index aba047c..0000000 --- a/.claude/hooks/fleet/no-description-aside-guard/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "hook-no-description-aside-guard", - "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/no-description-aside-guard/tsconfig.json b/.claude/hooks/fleet/no-description-aside-guard/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/no-description-aside-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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/no-designated-ignore-guard/README.md b/.claude/hooks/fleet/no-designated-ignore-guard/README.md deleted file mode 100644 index 1e160d6..0000000 --- a/.claude/hooks/fleet/no-designated-ignore-guard/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# no-designated-ignore-guard - -PreToolUse hook that blocks Edit/Write tool calls ADDING an ignore marker for a designated fix-only lint rule. - -Some rules are designated fix-only: every finding has a real fix, so an ignore marker is always the wrong move. The first designee is `socket/max-comment-block-lines` — a long comment block is fixed by shortening it or moving the depth into `docs/agents.md/**`, and a JSDoc documentation block already gets the doubled doc budget (`MAX_DOC_COMMENT_LINES`), so contracts and lock-step notes never need excusing in the first place. - -Detection is additive: the guard compares the about-to-land content against what it replaces (Edit `old_string`, the on-disk file for Write, folded edits for MultiEdit) and fires only when a designated marker count goes UP. Existing markers are grandfathered — editing around one, or rewriting a file that retains one, passes. - -## Blocked (when added) - -- `// socket-lint: allow long-comment-block -- ` -- `/* oxlint-disable socket/max-comment-block-lines */` -- `// oxlint-disable-next-line socket/max-comment-block-lines` - -## Allowed - -- Any edit that keeps the marker count flat or removes markers. -- Ignore markers for non-designated rules (other guards own those shapes). - -## Exemptions - -The oxlint plugin's rule subtrees (`.config/fleet/oxlint-plugin/fleet/`, `.config/repo/oxlint-plugin/`) and this guard's own files — the banned shape is lookup-table data or test fixture there. - -## Designating a rule - -Add an entry to `DESIGNATED_RULES` in `index.mts` (`allowId`, `ruleId`, `fix`) and rebuild the hook bundle. Designation is a fleet decision: the rule's every finding must have a mechanical fix the block message can name. - -## Bypass - -`Allow designated-ignore bypass` typed verbatim by the user in a recent turn. diff --git a/.claude/hooks/fleet/no-designated-ignore-guard/index.mts b/.claude/hooks/fleet/no-designated-ignore-guard/index.mts deleted file mode 100644 index c801d02..0000000 --- a/.claude/hooks/fleet/no-designated-ignore-guard/index.mts +++ /dev/null @@ -1,218 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-designated-ignore-guard. -// -// Blocks Edit/Write tool calls that ADD an ignore marker for a designated -// lint rule — one whose findings must be fixed, never excused. The first -// designee is socket/max-comment-block-lines: a long comment block is fixed -// by shortening it or moving the depth into `docs/agents.md/**`, and a JSDoc -// documentation block already gets the doubled doc budget, so an ignore -// marker for it is always the wrong move. -// -// Detection is ADDITIVE: a marker already present in the edited region -// (Edit `old_string`) or on disk (Write target) passes through — the guard -// fires only when the about-to-land content carries MORE designated markers -// than the content it replaces. -// -// Recognized banned shapes, per designated rule: -// // socket-lint: allow ... (the rule's own opt-out) -// /* oxlint-disable */ (any oxlint disable form -// // oxlint-disable-next-line naming the rule) -// -// Exemption: the oxlint plugin's own rule subtrees and this guard's own -// files — the banned shape is lookup-table data or test fixture there. -// -// Reads PreToolUse JSON payload from stdin: -// { "tool_name": "Edit"|"Write"|"MultiEdit", -// "tool_input": { "file_path": "...", ... } } -// -// Exit codes: -// 0 — pass. -// 2 — block, a designated ignore marker was added. -// -// Bypass: `Allow designated-ignore bypass`. Fails open on malformed payloads. - -import { safeReadFileSync } from '@socketsecurity/lib-stable/fs/read-file' - -import { block, defineHook, runHook } from '../_shared/guard.mts' -import type { GuardResult } from '../_shared/guard.mts' -import { readFilePath } from '../_shared/payload.mts' -import type { ToolCallPayload, ToolInput } from '../_shared/payload.mts' - -export interface DesignatedRule { - // The `socket-lint: allow ` opt-out id the rule honors. - readonly allowId: string - // The oxlint rule id an `oxlint-disable` comment would name. - readonly ruleId: string - // The real fix, stated in the block message. - readonly fix: string -} - -// Rules whose findings are always fixable — an ignore marker for them is the -// reach-for-ignore reflex this guard exists to stop. Grows by fleet decision, -// one entry per rule. -export const DESIGNATED_RULES: readonly DesignatedRule[] = [ - { - allowId: 'long-comment-block', - ruleId: 'socket/max-comment-block-lines', - fix: 'shorten the block or move the depth into docs/agents.md/**, linked from a one-line pointer. A JSDoc doc block already gets the doubled doc budget.', - }, -] - -// The banned shape is lookup-table data or test fixture under these paths. -const EXEMPT_PATH_SUFFIXES: readonly string[] = [ - '.config/fleet/oxlint-plugin/fleet/', - '.config/repo/oxlint-plugin/', - '.claude/hooks/fleet/no-designated-ignore-guard/', - 'test/repo/integration/hooks/no-designated-ignore-guard', -] - -function escapeRegExp(text: string): string { - return text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&') -} - -function markerPatterns(rule: DesignatedRule): RegExp[] { - return [ - new RegExp( - `socket-lint:\\s*allow\\s+${escapeRegExp(rule.allowId)}(?![\\w-])`, - ), - new RegExp( - `oxlint-disable(?:-next-line|-line)?\\b[^\\n]*${escapeRegExp(rule.ruleId)}(?![\\w-])`, - ), - ] -} - -export function countDesignatedMarkers( - text: string, - rule: DesignatedRule, -): number { - const patterns = markerPatterns(rule) - let count = 0 - const lines = text.split('\n') - for (let i = 0, { length } = lines; i < length; i += 1) { - const line = lines[i]! - for (let p = 0, plen = patterns.length; p < plen; p += 1) { - if (patterns[p]!.test(line)) { - count += 1 - break - } - } - } - return count -} - -export function isExemptPath(filePath: string): boolean { - for (let i = 0, { length } = EXEMPT_PATH_SUFFIXES; i < length; i += 1) { - if (filePath.includes(EXEMPT_PATH_SUFFIXES[i]!)) { - return true - } - } - return false -} - -export interface EditPair { - readonly prior: string - readonly next: string -} - -// Resolve what the edit replaces (prior) and what lands (next), per tool. -// Write compares against the on-disk file so a full-file rewrite that merely -// RETAINS an existing marker passes. -function readEditPair(payload: ToolCallPayload): EditPair | undefined { - const input: ToolInput = payload?.tool_input ?? {} - const tool = payload?.tool_name - if (tool === 'Write') { - if (typeof input.content !== 'string') { - return undefined - } - const onDisk = safeReadFileSync(readFilePath(payload) ?? '') - return { - prior: typeof onDisk === 'string' ? onDisk : '', - next: input.content, - } - } - if (tool === 'Edit') { - if (typeof input.new_string !== 'string') { - return undefined - } - return { - prior: typeof input.old_string === 'string' ? input.old_string : '', - next: input.new_string, - } - } - if (tool === 'MultiEdit' && Array.isArray(input.edits)) { - let prior = '' - let next = '' - for (let i = 0, { length } = input.edits; i < length; i += 1) { - const edit = input.edits[i] - if (!edit || typeof edit !== 'object') { - continue - } - const { new_string: n, old_string: o } = edit as Record - if (typeof o === 'string') { - prior += `${o}\n` - } - if (typeof n === 'string') { - next += `${n}\n` - } - } - return { prior, next } - } - return undefined -} - -export interface Finding { - readonly rule: DesignatedRule - readonly added: number -} - -export function findAddedDesignatedIgnores(pair: EditPair): Finding[] { - const findings: Finding[] = [] - for (let i = 0, { length } = DESIGNATED_RULES; i < length; i += 1) { - const rule = DESIGNATED_RULES[i]! - const before = countDesignatedMarkers(pair.prior, rule) - const after = countDesignatedMarkers(pair.next, rule) - if (after > before) { - findings.push({ rule, added: after - before }) - } - } - return findings -} - -export async function check(payload: ToolCallPayload): Promise { - const tool = payload?.tool_name - if (tool !== 'Edit' && tool !== 'MultiEdit' && tool !== 'Write') { - return undefined - } - const filePath = readFilePath(payload) - if (!filePath || isExemptPath(filePath)) { - return undefined - } - const pair = readEditPair(payload) - if (!pair) { - return undefined - } - const findings = findAddedDesignatedIgnores(pair) - if (findings.length === 0) { - return undefined - } - const lines: string[] = [] - for (let i = 0, { length } = findings; i < length; i += 1) { - const f = findings[i]! - const prefix = i === 0 ? '🚨 no-designated-ignore-guard: ' : ' ' - lines.push( - `${prefix}blocked +${f.added} ignore marker(s) for fix-only rule ${f.rule.ruleId} in ${filePath} (existing markers stay) — Fix: ${f.rule.fix}`, - ) - } - return block(lines.join('\n') + '\n') -} - -export const hook = defineHook({ - bypass: ['designated-ignore'], - check, - event: 'PreToolUse', - matcher: ['Edit', 'Write', 'MultiEdit'], - scope: 'convention', - type: 'guard', -}) - -void runHook(hook, import.meta.url) diff --git a/.claude/hooks/fleet/no-designated-ignore-guard/package.json b/.claude/hooks/fleet/no-designated-ignore-guard/package.json deleted file mode 100644 index eb35ea5..0000000 --- a/.claude/hooks/fleet/no-designated-ignore-guard/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "hook-no-designated-ignore-guard", - "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/no-designated-ignore-guard/tsconfig.json b/.claude/hooks/fleet/no-designated-ignore-guard/tsconfig.json deleted file mode 100644 index 19458cf..0000000 --- a/.claude/hooks/fleet/no-designated-ignore-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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/no-direct-linter-guard/README.md b/.claude/hooks/fleet/no-direct-linter-guard/README.md deleted file mode 100644 index b253b2a..0000000 --- a/.claude/hooks/fleet/no-direct-linter-guard/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# no-direct-linter-guard - -PreToolUse(Bash) hook that blocks invoking a linter or formatter binary -directly. The fleet runs lint/format only through the repo scripts (`pnpm run -lint` / `fix` / `check` / `format`) and the `scripts/fleet/*` wrappers — those -own the explicit `-c .config/fleet/` flag and the ignore set. - -## What it catches - -A Bash command whose resolved binary is one of `oxlint`, `oxfmt`, `eslint`, -`prettier`, `biome`, `dprint`, `rustfmt`, or `gofmt` (including the -`node_modules/.bin/` path form), or a `cargo fmt` / `cargo clippy` -subcommand. Detected by AST-parsing the command -(`shell-command.mts`/`findInvocation`), so it matches across pipes, `&&` chains, -and leading env vars and never false-matches a substring. `pnpm run …` and a -`node scripts/fleet/…` invocation pass; non-format `cargo` subcommands -(`cargo build`, `cargo test`) pass. - -This is a CONVENTION guard: it consults `isFleetTarget` and fires ONLY inside a -fleet repo. In a non-fleet repo (a sibling clone, an external checkout, a Rust -project formatted with native `cargo fmt`) the native binary or the project's -own script is the sanctioned path, so the guard no-ops. A fleet-rooted session -acting on a non-fleet repo via a leading `cd && ` is -judged against that repo. - -## Why - -A bare formatter run is a double hazard. Configless `oxfmt`/`oxlint` falls back -to its own defaults (double-quote + semicolon) and corrupts fleet files; the -scripts always pass `-c .config/fleet/…`. A bare formatter also has no ignore -scoping and will reformat vendored `upstream/` trees the fleet must never touch -(the fleet `oxlintrc`/`oxfmtrc` ignore lists exclude `upstream/`, -`third_party/`, `vendor/`, `external/`). `eslint` / `prettier` / `biome` / -`dprint` are not fleet tools at all (see `no-other-linters-guard`); `cargo fmt` -/ `rustfmt` / `gofmt` reflow hand-formatted code. Reaching past the scripts -re-introduces every one of these. The committed-state companion is -`scripts/fleet/check/linters-are-oxlint-oxfmt-only.mts`; the source-ref companion is -`socket/no-other-linters-guard`. - -The scripts' own internal `node_modules/.bin/oxlint` spawns are child processes, -not Claude Bash invocations, so this hook never sees them — only a top-level -direct call is blocked. - -## Bypass - -Type `Allow direct-linter bypass` in a recent turn (for a genuine one-off). - -## Exit codes - -- `0` — pass (not Bash, a script wrapper, a non-format command, or bypassed) -- `2` — block -- Fails open on any internal error. diff --git a/.claude/hooks/fleet/no-direct-linter-guard/index.mts b/.claude/hooks/fleet/no-direct-linter-guard/index.mts deleted file mode 100644 index 40950a0..0000000 --- a/.claude/hooks/fleet/no-direct-linter-guard/index.mts +++ /dev/null @@ -1,216 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse(Bash) hook — no-direct-linter-guard. -// -// Blocks invoking a linter, formatter, or TypeScript compiler binary directly. -// The fleet runs lint/format/type-check ONLY through the repo scripts -// (`pnpm run lint` / `fix` / `check` / `format`) and the `scripts/fleet/*` -// wrappers — those own the explicit `-c .config/fleet/` / -// `-p .config/fleet/tsconfig.check.json` flag and the ignore set. A bare binary -// call is a double hazard (for `tsc`/`tsgo`: the default tsconfig misses -// `allowImportingTsExtensions` → bogus TS5097 on every `.mts` import): -// -// 1. Configless `oxfmt`/`oxlint` falls back to its own defaults (double-quote -// + semicolon) and corrupts fleet files. The scripts always pass `-c`. -// 2. A bare formatter has no ignore scoping and will reformat vendored -// `upstream/` trees the fleet must never touch. -// -// Foreign tools (`eslint`/`prettier`/`biome`/`dprint`) are not fleet tools at -// all, see no-other-linters-guard; `cargo fmt` / `rustfmt` / `gofmt` reflow -// hand-formatted code. Runner-wrapped forms (`yarn prettier`, `npx prettier`, -// `pnpm exec prettier`, `bunx prettier`) are caught too; only -// ` run