diff --git a/.claude/agents/fleet/code-reviewer.md b/.claude/agents/fleet/code-reviewer.md new file mode 100644 index 0000000000..28b2441971 --- /dev/null +++ b/.claude/agents/fleet/code-reviewer.md @@ -0,0 +1,92 @@ +--- +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 new file mode 100644 index 0000000000..5205bb3dd4 --- /dev/null +++ b/.claude/agents/fleet/fix.md @@ -0,0 +1,70 @@ +--- +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 new file mode 100644 index 0000000000..ff30bc3856 --- /dev/null +++ b/.claude/hooks/fleet/inline-script-defer-guard/index.mts @@ -0,0 +1,135 @@ +#!/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
new file mode 100644
index 0000000000..43b2da5931
--- /dev/null
+++ b/.claude/hooks/fleet/inline-script-defer-guard/package.json
@@ -0,0 +1,15 @@
+{
+  "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
new file mode 100644
index 0000000000..19458cf0c8
--- /dev/null
+++ b/.claude/hooks/fleet/inline-script-defer-guard/tsconfig.json
@@ -0,0 +1,16 @@
+{
+  "compilerOptions": {
+    "declarationMap": false,
+    "erasableSyntaxOnly": true,
+    "module": "nodenext",
+    "moduleResolution": "nodenext",
+    "noEmit": true,
+    "rewriteRelativeImportExtensions": true,
+    "skipLibCheck": true,
+    "sourceMap": false,
+    "strict": true,
+    "target": "esnext",
+    "types": ["node"],
+    "verbatimModuleSyntax": true
+  }
+}
diff --git a/.claude/hooks/fleet/issue-autolink-nudge/index.mts b/.claude/hooks/fleet/issue-autolink-nudge/index.mts
new file mode 100644
index 0000000000..0447f4381c
--- /dev/null
+++ b/.claude/hooks/fleet/issue-autolink-nudge/index.mts
@@ -0,0 +1,70 @@
+#!/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
new file mode 100644
index 0000000000..9c1f52f8ce
--- /dev/null
+++ b/.claude/hooks/fleet/judgment-nudge/README.md
@@ -0,0 +1,58 @@
+# 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
new file mode 100644
index 0000000000..0e2e5dfb55
--- /dev/null
+++ b/.claude/hooks/fleet/judgment-nudge/index.mts
@@ -0,0 +1,214 @@
+#!/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
new file mode 100644
index 0000000000..d020633505
--- /dev/null
+++ b/.claude/hooks/fleet/judgment-nudge/package.json
@@ -0,0 +1,18 @@
+{
+  "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
new file mode 100644
index 0000000000..19458cf0c8
--- /dev/null
+++ b/.claude/hooks/fleet/judgment-nudge/tsconfig.json
@@ -0,0 +1,16 @@
+{
+  "compilerOptions": {
+    "declarationMap": false,
+    "erasableSyntaxOnly": true,
+    "module": "nodenext",
+    "moduleResolution": "nodenext",
+    "noEmit": true,
+    "rewriteRelativeImportExtensions": true,
+    "skipLibCheck": true,
+    "sourceMap": false,
+    "strict": true,
+    "target": "esnext",
+    "types": ["node"],
+    "verbatimModuleSyntax": true
+  }
+}
diff --git a/.claude/hooks/fleet/keep-working-while-waiting-nudge/README.md b/.claude/hooks/fleet/keep-working-while-waiting-nudge/README.md
new file mode 100644
index 0000000000..6440b71b22
--- /dev/null
+++ b/.claude/hooks/fleet/keep-working-while-waiting-nudge/README.md
@@ -0,0 +1,24 @@
+# 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
new file mode 100644
index 0000000000..1fbdcfef30
--- /dev/null
+++ b/.claude/hooks/fleet/keep-working-while-waiting-nudge/index.mts
@@ -0,0 +1,98 @@
+#!/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
new file mode 100644
index 0000000000..77e3912fd7
--- /dev/null
+++ b/.claude/hooks/fleet/keep-working-while-waiting-nudge/package.json
@@ -0,0 +1,15 @@
+{
+  "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
new file mode 100644
index 0000000000..19458cf0c8
--- /dev/null
+++ b/.claude/hooks/fleet/keep-working-while-waiting-nudge/tsconfig.json
@@ -0,0 +1,16 @@
+{
+  "compilerOptions": {
+    "declarationMap": false,
+    "erasableSyntaxOnly": true,
+    "module": "nodenext",
+    "moduleResolution": "nodenext",
+    "noEmit": true,
+    "rewriteRelativeImportExtensions": true,
+    "skipLibCheck": true,
+    "sourceMap": false,
+    "strict": true,
+    "target": "esnext",
+    "types": ["node"],
+    "verbatimModuleSyntax": true
+  }
+}
diff --git a/.claude/hooks/fleet/land-as-you-go-nudge/README.md b/.claude/hooks/fleet/land-as-you-go-nudge/README.md
new file mode 100644
index 0000000000..cefce84023
--- /dev/null
+++ b/.claude/hooks/fleet/land-as-you-go-nudge/README.md
@@ -0,0 +1,23 @@
+# 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
new file mode 100644
index 0000000000..8f8eea20a8
--- /dev/null
+++ b/.claude/hooks/fleet/land-as-you-go-nudge/index.mts
@@ -0,0 +1,139 @@
+#!/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
new file mode 100644
index 0000000000..092595f1b4
--- /dev/null
+++ b/.claude/hooks/fleet/land-as-you-go-nudge/package.json
@@ -0,0 +1,18 @@
+{
+  "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
new file mode 100644
index 0000000000..19458cf0c8
--- /dev/null
+++ b/.claude/hooks/fleet/land-as-you-go-nudge/tsconfig.json
@@ -0,0 +1,16 @@
+{
+  "compilerOptions": {
+    "declarationMap": false,
+    "erasableSyntaxOnly": true,
+    "module": "nodenext",
+    "moduleResolution": "nodenext",
+    "noEmit": true,
+    "rewriteRelativeImportExtensions": true,
+    "skipLibCheck": true,
+    "sourceMap": false,
+    "strict": true,
+    "target": "esnext",
+    "types": ["node"],
+    "verbatimModuleSyntax": true
+  }
+}
diff --git a/.claude/hooks/fleet/land-fast-nudge/README.md b/.claude/hooks/fleet/land-fast-nudge/README.md
new file mode 100644
index 0000000000..fd32f82bea
--- /dev/null
+++ b/.claude/hooks/fleet/land-fast-nudge/README.md
@@ -0,0 +1,22 @@
+# 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 — the fleet lints as it edits, no heavy re-run — cherry-picks the
+local-only commits onto a throwaway `origin/` worktree, and
+fast-forwards (never force).
+
+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
new file mode 100644
index 0000000000..da8b096115
--- /dev/null
+++ b/.claude/hooks/fleet/land-fast-nudge/index.mts
@@ -0,0 +1,132 @@
+#!/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
new file mode 100644
index 0000000000..899a6aa2a1
--- /dev/null
+++ b/.claude/hooks/fleet/land-fast-nudge/package.json
@@ -0,0 +1,16 @@
+{
+  "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
new file mode 100644
index 0000000000..19458cf0c8
--- /dev/null
+++ b/.claude/hooks/fleet/land-fast-nudge/tsconfig.json
@@ -0,0 +1,16 @@
+{
+  "compilerOptions": {
+    "declarationMap": false,
+    "erasableSyntaxOnly": true,
+    "module": "nodenext",
+    "moduleResolution": "nodenext",
+    "noEmit": true,
+    "rewriteRelativeImportExtensions": true,
+    "skipLibCheck": true,
+    "sourceMap": false,
+    "strict": true,
+    "target": "esnext",
+    "types": ["node"],
+    "verbatimModuleSyntax": true
+  }
+}
diff --git a/.claude/hooks/fleet/latest-release-pin-guard/index.mts b/.claude/hooks/fleet/latest-release-pin-guard/index.mts
new file mode 100644
index 0000000000..083c3bb06c
--- /dev/null
+++ b/.claude/hooks/fleet/latest-release-pin-guard/index.mts
@@ -0,0 +1,481 @@
+#!/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 {
+  const lines: string[] = [
+    `[latest-release-pin-guard] Blocked: a pin is being set to a STALE release, not the newest.`,
+    '',
+  ]
+  for (let i = 0, { length } = violations; i < length; i += 1) {
+    const v = violations[i]!
+    lines.push(
+      `  ${v.name}: pinned at ${v.pinned}, but ${v.newest} has shipped.`,
+    )
+  }
+  lines.push('')
+  lines.push(
+    '  Porting an upstream means the LATEST shipped release — always. Pinning a',
+  )
+  lines.push(
+    '  stale or inherited release ports against code that is already behind; the',
+  )
+  lines.push(
+    '  opentui incident lost ~31k lines to a pin 3 minor releases old.',
+  )
+  lines.push('')
+  lines.push('  Fix: `git fetch --tags`, then pin the newest release above —')
+  lines.push(
+    '  `gen/gitmodules-hash.mts --set` for .gitmodules, the version-pin row for',
+  )
+  lines.push('  lockstep.json. See docs/agents.md/fleet/lockstep.md and')
+  lines.push('  docs/agents.md/fleet/drift-watch.md.')
+  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.
+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
new file mode 100644
index 0000000000..d251b9d4d2
--- /dev/null
+++ b/.claude/hooks/fleet/link-protocol-dep-guard/README.md
@@ -0,0 +1,127 @@
+# link-protocol-dep-guard
+
+PreToolUse Edit/Write hook that blocks adding an unpinned dependency
+spec — a `link:`/`file:` local path, or a `workspace:` range — to any
+dependency block of a `package.json`.
+
+## 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
new file mode 100644
index 0000000000..6deb1487a2
--- /dev/null
+++ b/.claude/hooks/fleet/link-protocol-dep-guard/index.mts
@@ -0,0 +1,217 @@
+#!/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
+}
+
+const LOCAL_PATH_FIX_LINES: readonly string[] = [
+  '  A `link:`/`file:` spec is a PUBLISHING GAP wearing a dependency’s',
+  '  clothes. The install resolves to whatever sits at that path on the',
+  '  machine running it, and to NOTHING on a fresh clone — CI, a new',
+  '  contributor, a release runner. Treat it as release work, not cleanup.',
+  '',
+  '  Fix — in priority order:',
+  '    1. PUBLISH THE PACKAGE, THEN GO THROUGH THE CATALOG. Reserve the',
+  '       name and wire trusted publishing:',
+  '         node scripts/fleet/publish-infra/npm/placeholder.mts',
+  '         node scripts/fleet/publish-infra/cargo/placeholder.mts',
+  '         node scripts/fleet/publish-infra/cargo/trusted-publisher.mts',
+  '       Add the published version to the fleet catalog',
+  '       (`.config/fleet/pnpm-workspace.fleet.yaml`), then depend on it',
+  '       as `"pkg": "catalog:"`. `catalog:` is the PREFERRED fleet form:',
+  '       it pins hard, and one central bump upgrades every repo at once.',
+  '    2. Exact published version — `"pkg": "1.2.3"`. Use when the package',
+  '       does not belong in the fleet-wide catalog; it costs a manifest',
+  '       bump per release.',
+  '    3. FALLBACK, in-repo package that genuinely cannot be published —',
+  '       `"pkg": "workspace:1.2.3"` (exact, not `workspace:*`) and list',
+  '       its directory under `packages:` in `pnpm-workspace.yaml`. This is',
+  '       the last resort, NOT the recommended destination: every sibling',
+  '       release forces a manifest bump in each dependent.',
+  '    4. Generated per-platform output a `packages:` glob swept in (the',
+  '       decmpfs shape: `napi/decmpfs/npm//` build artifacts) —',
+  '       NARROW THE GLOB so it stops matching generated dirs, then',
+  '       regenerate the lockfile. Those packages resolve from the',
+  '       registry and the publish engine finds them by convention.',
+]
+
+const WORKSPACE_RANGE_FIX_LINES: readonly string[] = [
+  '  A range lets the resolved sibling version drift with whatever tree the',
+  '  install runs against, and pnpm expands it at publish time into a range',
+  '  every consumer inherits.',
+  '',
+  '  Fix — in priority order:',
+  '    1. `"pkg": "catalog:"` — PREFERRED. Publish the sibling if it is not',
+  '       published yet, add it to the fleet catalog',
+  '       (`.config/fleet/pnpm-workspace.fleet.yaml`), and depend on it',
+  '       through the catalog. One central bump upgrades every repo.',
+  '    2. `"pkg": "1.2.3"` — the sibling’s exact published version, when it',
+  '       does not belong in the fleet-wide catalog.',
+  '    3. `"pkg": "workspace:1.2.3"` — FALLBACK only, for a sibling that',
+  '       genuinely cannot be published. Legal, but it buys a manifest bump',
+  '       in every dependent on each sibling release.',
+]
+
+function describeFindings(
+  findings: readonly DependencySpecFinding[],
+): string[] {
+  const lines: string[] = []
+  for (let i = 0, { length } = findings; i < length; i += 1) {
+    const finding = findings[i]!
+    lines.push(`  • ${finding.field}.${finding.name}: "${finding.value}"`)
+  }
+  return lines
+}
+
+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')
+  const workspaceRange = added.filter(
+    finding => finding.kind === 'workspace-range',
+  )
+
+  const headline =
+    localPath.length > 0
+      ? '[link-protocol-dep-guard] Blocked: this dependency is UNPUBLISHED — publish it'
+      : '[link-protocol-dep-guard] Blocked: unpinned `workspace:` range in package.json'
+  const lines: string[] = [headline, '', `  File: ${filePath}`, '']
+
+  if (localPath.length > 0) {
+    lines.push(...describeFindings(localPath))
+    lines.push(
+      '',
+      '  Saw:    a `link:`/`file:` spec — this dependency resolves to a',
+      '          local path, so the package it points at is NOT published.',
+      '  Wanted: a published package reached through the fleet catalog —',
+      '          `catalog:` first, an exact registry version (`"1.2.3"`)',
+      '          second, `workspace:1.2.3` only as a fallback for a package',
+      '          that genuinely cannot be published.',
+      '',
+      ...LOCAL_PATH_FIX_LINES,
+    )
+  }
+
+  if (workspaceRange.length > 0) {
+    if (localPath.length > 0) {
+      lines.push('')
+    }
+    lines.push(...describeFindings(workspaceRange))
+    lines.push(
+      '',
+      '  Saw:    a `workspace:` RANGE — `*`, `^`, or `~` floats the version.',
+      '  Wanted: `catalog:` — the preferred fleet form for a pinned sibling.',
+      '',
+      ...WORKSPACE_RANGE_FIX_LINES,
+    )
+  }
+
+  return block(lines.join('\n'))
+})
+
+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
new file mode 100644
index 0000000000..25805abb34
--- /dev/null
+++ b/.claude/hooks/fleet/live-edit-collision-guard/index.mts
@@ -0,0 +1,199 @@
+/*
+ * @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
new file mode 100644
index 0000000000..02964a144a
--- /dev/null
+++ b/.claude/hooks/fleet/lock-step-ref-nudge/README.md
@@ -0,0 +1,62 @@
+# 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
new file mode 100644
index 0000000000..799f57d9fd
--- /dev/null
+++ b/.claude/hooks/fleet/lock-step-ref-nudge/index.mts
@@ -0,0 +1,358 @@
+#!/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 new file mode 100644 index 0000000000..68a9272151 --- /dev/null +++ b/.claude/hooks/fleet/lock-step-ref-nudge/package.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000..19458cf0c8 --- /dev/null +++ b/.claude/hooks/fleet/lock-step-ref-nudge/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/logger-guard/README.md b/.claude/hooks/fleet/logger-guard/README.md new file mode 100644 index 0000000000..198fc923a9 --- /dev/null +++ b/.claude/hooks/fleet/logger-guard/README.md @@ -0,0 +1,104 @@ +# 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 new file mode 100644 index 0000000000..24440d02aa --- /dev/null +++ b/.claude/hooks/fleet/logger-guard/index.mts @@ -0,0 +1,267 @@ +#!/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 { lineIsSuppressed } 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): append `// socket-lint: allow console` 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 must be + // on the same source line as the call. + const rule = leak.fullCall.startsWith('process.') + ? 'process-stdio' + : 'console' + /* c8 ignore next - AST line numbers are always within the source range */ + const sourceLine = lines[leak.line - 1] ?? '' + if (lineIsSuppressed(sourceLine, 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)) { + /* c8 ignore next - AST line numbers are always within the source range */ + const sourceLine = lines[deco.line - 1] ?? '' + if (lineIsSuppressed(sourceLine, '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): append `// socket-lint: allow logger-decoration`.', + ) + 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 new file mode 100644 index 0000000000..c08effff72 --- /dev/null +++ b/.claude/hooks/fleet/logger-guard/package.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000..19458cf0c8 --- /dev/null +++ b/.claude/hooks/fleet/logger-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/long-running-task-nudge/index.mts b/.claude/hooks/fleet/long-running-task-nudge/index.mts new file mode 100644 index 0000000000..c01b519f56 --- /dev/null +++ b/.claude/hooks/fleet/long-running-task-nudge/index.mts @@ -0,0 +1,525 @@ +#!/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' +import { WAITING_DISCIPLINE_GUIDANCE } from '../_shared/waiting-discipline.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 + +// Domain triage for a thrashing task, appended to every nudge. Lint/format +// failures have an AUTOFIXER — rerunning a hand-edit loop against them is the +// classic thrash shape, and the fix is mechanical: autofix, then re-run the +// linter. Exported so the behavioral tests pin the guidance, not its wording +// drift sites. +export const AUTOFIX_FIRST_GUIDANCE: readonly string[] = [ + 'If the failing step is LINT or FORMAT and the toolchain has an autofixer:', + ' - FIRST move is the autofixer over the affected files — `pnpm run fix` or the tool’s `--fix`.', + ' - verification is re-running the linter; its exit code is the proof.', + ' - plant-probes and per-finding hand-verification are for semantic domains with no autofixer.', +] + +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[] = [''] + lines.push( + `[long-running-task-nudge] ${warned.length} background task(s) running past threshold:`, + ) + lines.push('') + for (const decision of warned) { + const label = decision.label ? ` "${decision.label}"` : '' + const minutes = Math.floor(decision.ageMs / MS_PER_MINUTE) + const escalated = + decision.tier >= LONGRUN_ESCALATE_MINUTES ? ' — ESCALATED' : '' + lines.push( + ` ${decision.kind} ${decision.id}${label} — ${minutes}min${escalated}`, + ) + } + lines.push('') + lines.push('Verify each is PROGRESSING, not thrashing:') + lines.push( + ' - transcript still growing, result count rising, or phase advancing.', + ) + lines.push( + ' - use TaskGet or read the transcript to confirm forward motion.', + ) + lines.push( + 'If a task is stuck, repeating the same failed step with no new output:', + ) + lines.push( + ' - TaskStop it, then research the real root cause before relaunching.', + ) + lines.push(...AUTOFIX_FIRST_GUIDANCE) + lines.push(...WAITING_DISCIPLINE_GUIDANCE) + lines.push('Do not let it grind for an hour before intervening.') + lines.push('') + 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. +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. +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. +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 new file mode 100644 index 0000000000..b4a33df7a0 --- /dev/null +++ b/.claude/hooks/fleet/markdown-filename-guard/README.md @@ -0,0 +1,41 @@ +# 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 new file mode 100644 index 0000000000..d32b60985c --- /dev/null +++ b/.claude/hooks/fleet/markdown-filename-guard/index.mts @@ -0,0 +1,100 @@ +#!/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 lines: string[] = [] + lines.push('[markdown-filename-guard] Blocked: non-canonical doc filename.') + lines.push(` File: ${filePath}`) + if (verdict.message) { + lines.push(` Issue: ${verdict.message}`) + } + if (verdict.suggestion) { + lines.push(` Suggestion: ${verdict.suggestion}`) + } + lines.push('') + lines.push(' Fleet doc-filename rules:') + lines.push(' - README.md / LICENSE — allowed anywhere.') + lines.push( + ' - SCREAMING_CASE allowlist (AUTHORS, CHANGELOG, CLAUDE, CONTRIBUTING,', + ) + lines.push( + ' GOVERNANCE, MAINTAINERS, NOTICE, README, SECURITY, SUPPORT, …) —', + ) + lines.push(' allowed at root / docs/ / .claude/ (top level only).') + lines.push( + ' - Everything else: lowercase-with-hyphens, in docs/ or .claude/.', + ) + return lines.join('\n') + '\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 new file mode 100644 index 0000000000..35a1a1add8 --- /dev/null +++ b/.claude/hooks/fleet/markdown-filename-guard/package.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000..19458cf0c8 --- /dev/null +++ b/.claude/hooks/fleet/markdown-filename-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/mass-delete-guard/README.md b/.claude/hooks/fleet/mass-delete-guard/README.md new file mode 100644 index 0000000000..04fefcfe2f --- /dev/null +++ b/.claude/hooks/fleet/mass-delete-guard/README.md @@ -0,0 +1,23 @@ +# 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 new file mode 100644 index 0000000000..f4ae09475c --- /dev/null +++ b/.claude/hooks/fleet/mass-delete-guard/index.mts @@ -0,0 +1,194 @@ +#!/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`, count staged deletions +// (`git diff --cached --diff-filter=D --name-only`) and the tree's tracked +// file count (`git ls-files`). Block when 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 { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' + +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 } + +/** + * Count files staged for DELETION in the index (vs HEAD). + */ +export function countStagedDeletions(repoDir: string): number { + const r = spawnSync( + 'git', + ['diff', '--cached', '--diff-filter=D', '--name-only'], + { 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 +} + +/** + * 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 deletions = countStagedDeletions(repoDir) + if (deletions === 0) { + return undefined + } + const tracked = countTrackedFiles(repoDir) + const reason = catastrophicReason(deletions, tracked) + if (!reason) { + return undefined + } + + 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}.`, + '', + ' 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 new file mode 100644 index 0000000000..bf3bf2e633 --- /dev/null +++ b/.claude/hooks/fleet/mass-delete-guard/package.json @@ -0,0 +1,19 @@ +{ + "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 new file mode 100644 index 0000000000..bac4e3e8d3 --- /dev/null +++ b/.claude/hooks/fleet/memory-codify-nudge/README.md @@ -0,0 +1,38 @@ +# 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 new file mode 100644 index 0000000000..1a9da46235 --- /dev/null +++ b/.claude/hooks/fleet/memory-codify-nudge/index.mts @@ -0,0 +1,59 @@ +#!/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 new file mode 100644 index 0000000000..f87afdab90 --- /dev/null +++ b/.claude/hooks/fleet/memory-codify-nudge/package.json @@ -0,0 +1,15 @@ +{ + "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 new file mode 100644 index 0000000000..19458cf0c8 --- /dev/null +++ b/.claude/hooks/fleet/memory-codify-nudge/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/memory-discovery-nudge/README.md b/.claude/hooks/fleet/memory-discovery-nudge/README.md new file mode 100644 index 0000000000..bedc285677 --- /dev/null +++ b/.claude/hooks/fleet/memory-discovery-nudge/README.md @@ -0,0 +1,40 @@ +# 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 new file mode 100644 index 0000000000..a02fd14041 --- /dev/null +++ b/.claude/hooks/fleet/memory-discovery-nudge/index.mts @@ -0,0 +1,126 @@ +#!/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 new file mode 100644 index 0000000000..bd639901b6 --- /dev/null +++ b/.claude/hooks/fleet/memory-enforcement-stamp-guard/README.md @@ -0,0 +1,41 @@ +# 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 new file mode 100644 index 0000000000..5b10441c1f --- /dev/null +++ b/.claude/hooks/fleet/memory-enforcement-stamp-guard/index.mts @@ -0,0 +1,87 @@ +#!/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 new file mode 100644 index 0000000000..4a3eacd290 --- /dev/null +++ b/.claude/hooks/fleet/memory-enforcement-stamp-guard/package.json @@ -0,0 +1,15 @@ +{ + "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 new file mode 100644 index 0000000000..19458cf0c8 --- /dev/null +++ b/.claude/hooks/fleet/memory-enforcement-stamp-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/mermaid-github-safe-nudge/index.mts b/.claude/hooks/fleet/mermaid-github-safe-nudge/index.mts new file mode 100644 index 0000000000..0437750a4f --- /dev/null +++ b/.claude/hooks/fleet/mermaid-github-safe-nudge/index.mts @@ -0,0 +1,67 @@ +#!/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 new file mode 100644 index 0000000000..c5152aec7e --- /dev/null +++ b/.claude/hooks/fleet/minimum-release-age-guard/README.md @@ -0,0 +1,50 @@ +# 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 new file mode 100644 index 0000000000..835886eaa7 --- /dev/null +++ b/.claude/hooks/fleet/minimum-release-age-guard/index.mts @@ -0,0 +1,178 @@ +#!/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 new file mode 100644 index 0000000000..b321f0da09 --- /dev/null +++ b/.claude/hooks/fleet/minimum-release-age-guard/package.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000..19458cf0c8 --- /dev/null +++ b/.claude/hooks/fleet/minimum-release-age-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/module-noun-name-guard/README.md b/.claude/hooks/fleet/module-noun-name-guard/README.md new file mode 100644 index 0000000000..63ad7ae106 --- /dev/null +++ b/.claude/hooks/fleet/module-noun-name-guard/README.md @@ -0,0 +1,20 @@ +# 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 new file mode 100644 index 0000000000..87facee886 --- /dev/null +++ b/.claude/hooks/fleet/module-noun-name-guard/index.mts @@ -0,0 +1,207 @@ +#!/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 (an action), not a domain noun. Fleet modules are concise NOUN names that group the related functions for a domain.`, + suggestion: `Add this function to an existing noun module (e.g. \`${domain}.ts\`), or name the file after its domain noun — not \`${lead}-…\`.`, + } +} + +export function emitBlock(filePath: string, verdict: Verdict): string { + const lines: string[] = [] + lines.push('[module-noun-name-guard] Blocked: verb-phrase module name.') + lines.push(` File: ${filePath}`) + if (verdict.message) { + lines.push(` Issue: ${verdict.message}`) + } + if (verdict.suggestion) { + lines.push(` Suggestion: ${verdict.suggestion}`) + } + lines.push('') + lines.push(' Fleet module-naming convention:') + lines.push(' - A module is a NOUN naming a domain (manifest, tarball).') + lines.push(' - It GROUPS the related functions; not one method per file.') + lines.push(' - trimPublishManifest + createPackageJson both live in') + lines.push(' manifest.ts, reachable via one `exports` entry.') + return lines.join('\n') + '\n' +} + +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 new file mode 100644 index 0000000000..dfb6a0aa9f --- /dev/null +++ b/.claude/hooks/fleet/module-noun-name-guard/package.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000..19458cf0c8 --- /dev/null +++ b/.claude/hooks/fleet/module-noun-name-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/new-hook-claude-md-guard/README.md b/.claude/hooks/fleet/new-hook-claude-md-guard/README.md new file mode 100644 index 0000000000..4c3d52ad84 --- /dev/null +++ b/.claude/hooks/fleet/new-hook-claude-md-guard/README.md @@ -0,0 +1,50 @@ +# 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 new file mode 100644 index 0000000000..8237436e13 --- /dev/null +++ b/.claude/hooks/fleet/new-hook-claude-md-guard/index.mts @@ -0,0 +1,212 @@ +#!/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 new file mode 100644 index 0000000000..8ccd14ae7a --- /dev/null +++ b/.claude/hooks/fleet/new-hook-claude-md-guard/package.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000..19458cf0c8 --- /dev/null +++ b/.claude/hooks/fleet/new-hook-claude-md-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/no-amend-foreign-commit-guard/README.md b/.claude/hooks/fleet/no-amend-foreign-commit-guard/README.md new file mode 100644 index 0000000000..58db184bdb --- /dev/null +++ b/.claude/hooks/fleet/no-amend-foreign-commit-guard/README.md @@ -0,0 +1,30 @@ +# 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 new file mode 100644 index 0000000000..fce7f6e224 --- /dev/null +++ b/.claude/hooks/fleet/no-amend-foreign-commit-guard/index.mts @@ -0,0 +1,165 @@ +#!/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 new file mode 100644 index 0000000000..d1793fa5b6 --- /dev/null +++ b/.claude/hooks/fleet/no-blanket-file-exclusion-guard/README.md @@ -0,0 +1,26 @@ +# 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 new file mode 100644 index 0000000000..3a9953040a --- /dev/null +++ b/.claude/hooks/fleet/no-blanket-file-exclusion-guard/index.mts @@ -0,0 +1,196 @@ +#!/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 new file mode 100644 index 0000000000..c553c38aee --- /dev/null +++ b/.claude/hooks/fleet/no-blanket-file-exclusion-guard/package.json @@ -0,0 +1,15 @@ +{ + "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 new file mode 100644 index 0000000000..19458cf0c8 --- /dev/null +++ b/.claude/hooks/fleet/no-blanket-file-exclusion-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/no-blind-keychain-read-guard/README.md b/.claude/hooks/fleet/no-blind-keychain-read-guard/README.md new file mode 100644 index 0000000000..22a1796f9c --- /dev/null +++ b/.claude/hooks/fleet/no-blind-keychain-read-guard/README.md @@ -0,0 +1,65 @@ +# 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 new file mode 100644 index 0000000000..4c02d14a9a --- /dev/null +++ b/.claude/hooks/fleet/no-blind-keychain-read-guard/index.mts @@ -0,0 +1,221 @@ +#!/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[] = [] + lines.push( + '[no-blind-keychain-read-guard] Blocked: direct keychain READ from Bash.', + ) + lines.push('') + for (let i = 0, { length } = hits; i < length; i += 1) { + const h = hits[i]! + lines.push(` ${h.platform.padEnd(15)} ${h.tool}`) + lines.push(` Saw: ${h.snippet}`) + } + lines.push('') + lines.push(' Reading the keychain via the platform CLI surfaces a UI auth') + lines.push(" prompt on the user's screen — and the prompt fires once per") + lines.push(' call. A hook chain that reads three times costs three prompts.') + lines.push('') + lines.push(' The token is almost certainly already available without a') + lines.push(' keychain read:') + lines.push('') + lines.push(' - In-process: call findApiToken() from setup-security-tools/') + lines.push(' lib/api-token.mts. It returns the module-cached value from') + lines.push(' the first call onward, then env, then keychain.') + lines.push('') + lines.push(' - From Bash: read process.env.SOCKET_API_KEY or') + lines.push( + ' process.env.SOCKET_API_TOKEN. The wheelhouse shell-rc bridge', + ) + lines.push(' exports both for every new shell session.') + lines.push('') + lines.push(' Writes / deletes (security add-generic-password / secret-tool') + lines.push(' store / New-StoredCredential / etc.) are allowed — they only') + lines.push(' happen during operator-driven setup / rotation.') + return lines.join('\n') + '\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 new file mode 100644 index 0000000000..819429bd22 --- /dev/null +++ b/.claude/hooks/fleet/no-blind-keychain-read-guard/package.json @@ -0,0 +1,15 @@ +{ + "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 new file mode 100644 index 0000000000..19458cf0c8 --- /dev/null +++ b/.claude/hooks/fleet/no-blind-keychain-read-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/no-boolean-trap-guard/README.md b/.claude/hooks/fleet/no-boolean-trap-guard/README.md new file mode 100644 index 0000000000..e48c43859b --- /dev/null +++ b/.claude/hooks/fleet/no-boolean-trap-guard/README.md @@ -0,0 +1,47 @@ +# 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 new file mode 100644 index 0000000000..e47d2638e0 --- /dev/null +++ b/.claude/hooks/fleet/no-boolean-trap-guard/index.mts @@ -0,0 +1,237 @@ +#!/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 new file mode 100644 index 0000000000..cdd357f057 --- /dev/null +++ b/.claude/hooks/fleet/no-boolean-trap-guard/package.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000..19458cf0c8 --- /dev/null +++ b/.claude/hooks/fleet/no-boolean-trap-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/no-branch-reuse-nudge/README.md b/.claude/hooks/fleet/no-branch-reuse-nudge/README.md new file mode 100644 index 0000000000..9a93020e44 --- /dev/null +++ b/.claude/hooks/fleet/no-branch-reuse-nudge/README.md @@ -0,0 +1,39 @@ +# 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 new file mode 100644 index 0000000000..749129c699 --- /dev/null +++ b/.claude/hooks/fleet/no-branch-reuse-nudge/index.mts @@ -0,0 +1,125 @@ +#!/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 new file mode 100644 index 0000000000..b69324dd37 --- /dev/null +++ b/.claude/hooks/fleet/no-branch-reuse-nudge/package.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000..19458cf0c8 --- /dev/null +++ b/.claude/hooks/fleet/no-branch-reuse-nudge/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/no-cascade-transient-git-guard/index.mts b/.claude/hooks/fleet/no-cascade-transient-git-guard/index.mts new file mode 100644 index 0000000000..7aaf0ba6bf --- /dev/null +++ b/.claude/hooks/fleet/no-cascade-transient-git-guard/index.mts @@ -0,0 +1,101 @@ +#!/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 new file mode 100644 index 0000000000..6914adc683 --- /dev/null +++ b/.claude/hooks/fleet/no-chained-pausing-git-guard/index.mts @@ -0,0 +1,181 @@ +#!/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 new file mode 100644 index 0000000000..9b754d6e75 --- /dev/null +++ b/.claude/hooks/fleet/no-chained-pausing-git-guard/package.json @@ -0,0 +1,15 @@ +{ + "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 new file mode 100644 index 0000000000..19458cf0c8 --- /dev/null +++ b/.claude/hooks/fleet/no-chained-pausing-git-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/no-clipboard-access-guard/README.md b/.claude/hooks/fleet/no-clipboard-access-guard/README.md new file mode 100644 index 0000000000..92e8c9afb5 --- /dev/null +++ b/.claude/hooks/fleet/no-clipboard-access-guard/README.md @@ -0,0 +1,40 @@ +# 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 new file mode 100644 index 0000000000..1f486b073f --- /dev/null +++ b/.claude/hooks/fleet/no-clipboard-access-guard/index.mts @@ -0,0 +1,173 @@ +#!/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 new file mode 100644 index 0000000000..220e628496 --- /dev/null +++ b/.claude/hooks/fleet/no-commit-ai-attribution-guard/index.mts @@ -0,0 +1,82 @@ +#!/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', + 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 new file mode 100644 index 0000000000..a74ef5db7a --- /dev/null +++ b/.claude/hooks/fleet/no-copyleft-source-read/index.mts @@ -0,0 +1,586 @@ +#!/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: What / Where / Saw vs. wanted / Fix, naming the SPDX id, + * the tests-only rule, 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 where = + finding.path === '' + ? ` Where: ${how} covering the whole \`${slug}\` tree.` + : ` Where: ${how} targeting \`${finding.path}\` in \`${slug}\`.` + const lines = [ + `[no-copyleft-source-read] Blocked: reading ${slug} implementation, ${upstream.spdx}.`, + '', + ` What: ${slug} is ${upstream.spdx} copyleft. Reading, copying, or`, + ' deriving from its implementation makes the consuming package a', + ' derivative work and forces that license onto it.', + where, + ' Wanted: run it as a tool and observe it through its OWN tests —', + ` ${upstream.testPathPatterns.join(', ')} — and nothing else.`, + ' Fix: derive from a permissively licensed source instead, and keep the', + ' submodule cone tests-only:', + ` ${copyleftSparseRecipe(upstream)}`, + ' Enumerating the tree is FINE — structure is fact, not expression.', + ' Use `ls` / `tree` / `find`, `git ls-tree`, Glob, a directory Read,', + ' or `rg -l` when you need to know what is there.', + ] + if (upstream.permissiveAlternative) { + lines.push( + ` Recorded permissive alternative: ${upstream.permissiveAlternative}.`, + ) + } + lines.push(' See docs/agents.md/fleet/copyleft-boundaries.md.') + 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 new file mode 100644 index 0000000000..8c864ce124 --- /dev/null +++ b/.claude/hooks/fleet/no-copyleft-source-read/package.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000..19458cf0c8 --- /dev/null +++ b/.claude/hooks/fleet/no-copyleft-source-read/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/no-corepack-guard/README.md b/.claude/hooks/fleet/no-corepack-guard/README.md new file mode 100644 index 0000000000..43576fc225 --- /dev/null +++ b/.claude/hooks/fleet/no-corepack-guard/README.md @@ -0,0 +1,33 @@ +# 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 new file mode 100644 index 0000000000..f0fa5daf7c --- /dev/null +++ b/.claude/hooks/fleet/no-corepack-guard/index.mts @@ -0,0 +1,114 @@ +#!/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 a mutable `packageManager` field — a second, +// un-pinned provisioning path that bypasses the fleet's supply-chain controls. +// The `packageManager` field stays in package.json as a declared-version +// RECORD (kept in lockstep with external-tools.json); this guard only blocks +// the corepack COMMANDS that would activate it. +// +// 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', + '', + ' The package.json `packageManager` field is a declared-version record', + ' kept in lockstep with external-tools.json; leave it in place, just', + ' do not invoke corepack to act on it.', + ].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 new file mode 100644 index 0000000000..da94f2adf9 --- /dev/null +++ b/.claude/hooks/fleet/no-corepack-guard/package.json @@ -0,0 +1,15 @@ +{ + "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 new file mode 100644 index 0000000000..19458cf0c8 --- /dev/null +++ b/.claude/hooks/fleet/no-corepack-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/no-description-aside-guard/README.md b/.claude/hooks/fleet/no-description-aside-guard/README.md new file mode 100644 index 0000000000..d3acbfbb09 --- /dev/null +++ b/.claude/hooks/fleet/no-description-aside-guard/README.md @@ -0,0 +1,41 @@ +# 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 new file mode 100644 index 0000000000..9331ee4dfd --- /dev/null +++ b/.claude/hooks/fleet/no-description-aside-guard/index.mts @@ -0,0 +1,89 @@ +#!/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) + const lines: string[] = [ + `🚨 no-description-aside-guard: blocked write to ${rel}.`, + '', + ] + for (let i = 0, { length } = offenders; i < length; i += 1) { + lines.push(` ✗ description ends with a listy aside: (${offenders[i]})`) + } + lines.push( + '', + 'A manifest description states the thing plainly. Drop the trailing', + 'parenthetical that re-lists what the line already says; fold detail that', + 'matters into the sentence.', + '', + `Bypass (rare): the user types "${BYPASS_PHRASE}" verbatim.`, + ) + 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 new file mode 100644 index 0000000000..aba047ca3c --- /dev/null +++ b/.claude/hooks/fleet/no-description-aside-guard/package.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000..19458cf0c8 --- /dev/null +++ b/.claude/hooks/fleet/no-description-aside-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/fleet/no-direct-linter-guard/README.md b/.claude/hooks/fleet/no-direct-linter-guard/README.md new file mode 100644 index 0000000000..b253b2aaae --- /dev/null +++ b/.claude/hooks/fleet/no-direct-linter-guard/README.md @@ -0,0 +1,52 @@ +# 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 new file mode 100644 index 0000000000..40950a0610 --- /dev/null +++ b/.claude/hooks/fleet/no-direct-linter-guard/index.mts @@ -0,0 +1,216 @@ +#!/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