`) — never
- bundle unrelated fixes. The root cause goes in the message.
-4. **Stop on ambiguity.** If a finding looks misdiagnosed (the "fix" would mask a
- real bug, or the finding contradicts the code), do NOT patch it — report it back
- as a disputed finding. A wrong fix for a wrong finding is worse than an open one.
-
-## Scope protocol
-
-Fix only what the findings report names. Don't add features, refactor unrelated
-code, or make improvements beyond the findings. Simplest patch that resolves the
-finding.
-
-## Verification protocol
-
-Run the actual check/test after every patch and state what you verified — never
-claim a fix without a tool result that shows the check now passes. Re-read every
-file you modified; confirm nothing references something that no longer exists.
-Run `pnpm run build` only if the change touches `src/` or `tsconfig.json`.
-
-## Cross-fleet rules
-
-- No `npx` / `pnpm dlx` / `yarn dlx`; use `pnpm run
-```
-
-Or, for code that genuinely belongs in an external file:
-
-```html
-
-```
-
-## What it covers
-
-| File extension | Checked? |
-| -------------------------------------------------------- | --------------- |
-| `.html` / `.htm` | full text |
-| `.njk` / `.ejs` / `.hbs` / `.handlebars` | full text |
-| `.svelte` / `.vue` / `.astro` | full text |
-| `.ts` / `.tsx` / `.mts` / `.cts` / `.js` / `.jsx` / etc. | new_string only |
-| anything else | not checked |
-
-## Bypass
-
-Type the canonical phrase in a new message:
-
- Allow inline-defer bypass
-
-Use sparingly — the bug is silent in production.
-
-## Companion: oxlint rule
-
-`socket/no-inline-defer-async` catches the same shape at commit time
-even when edits happened outside Claude.
diff --git a/.claude/hooks/fleet/inline-script-defer-guard/index.mts b/.claude/hooks/fleet/inline-script-defer-guard/index.mts
deleted file mode 100644
index ff30bc3..0000000
--- a/.claude/hooks/fleet/inline-script-defer-guard/index.mts
+++ /dev/null
@@ -1,135 +0,0 @@
-#!/usr/bin/env node
-// Claude Code PreToolUse hook — inline-script-defer-guard.
-//
-// Blocks Edit/Write operations that add a script tag with defer or
-// async attribute to an HTML / template file when the same tag lacks a
-// `src=` attribute. Per HTML spec, `defer` and `async` are no-ops on
-// inline (no-src) script tags — the script executes immediately,
-// even though the author intent is "wait for DOMContentLoaded." Browsers
-// don't warn; the failure mode is a silent broken page (e.g. unstyled
-// `` blocks when the script that styles them runs before its
-// targets exist).
-//
-// Detection: regex over the after-edit text. Find script openers with
-// defer or async in their attrs, check the same tag for `src=`.
-// If absent → block.
-//
-// Fix: wrap the script body in
-//
-//
-//
-// Files covered: `*.html` / `*.htm` / `*.njk` / `*.ejs` / `*.hbs` /
-// `*.handlebars` / `*.svelte` / `*.vue` / `*.astro`. Also fires on TS/JS
-// source files that contain HTML string literals matching the pattern —
-// SSR / static-gen code paths.
-//
-// Bypass: `Allow inline-defer bypass` typed verbatim in a recent user turn.
-
-import { block, defineHook, editGuard, runHook } from '../_shared/guard.mts'
-import { resolveEditedText } from '../_shared/payload.mts'
-
-// File extensions where we check the full text content. For other
-// extensions, only the new_string is checked (template strings embedded
-// in TS/JS source).
-const HTML_EXT_RE = /\.(?:astro|ejs|handlebars|hbs|htm|html|njk|svelte|vue)$/i
-
-// JS/TS source extensions: optional `m` or `c` prefix, then `js`/`ts`
-// with an optional `x` suffix (JSX/TSX), anchored to the end of the path.
-const SOURCE_EXT_RE = /\.(?:cjs|cts|m?[jt]sx?)$/i
-
-// Match each `',
- '',
- ' Or — if the script DOES belong in an external file:',
- '',
- ' ',
- '',
- ].join('\n'),
- )
-})
-
-export const hook = defineHook({
- bypass: ['inline-defer'],
- bypassOptional: true,
- check,
- event: 'PreToolUse',
- matcher: ['Edit', 'Write', 'MultiEdit'],
- type: 'guard',
-})
-
-void runHook(hook, import.meta.url)
diff --git a/.claude/hooks/fleet/inline-script-defer-guard/package.json b/.claude/hooks/fleet/inline-script-defer-guard/package.json
deleted file mode 100644
index 43b2da5..0000000
--- a/.claude/hooks/fleet/inline-script-defer-guard/package.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "name": "hook-inline-script-defer-guard",
- "private": true,
- "type": "module",
- "main": "./index.mts",
- "exports": {
- ".": "./index.mts"
- },
- "scripts": {
- "test": "node --test test/*.test.mts"
- },
- "devDependencies": {
- "@types/node": "catalog:"
- }
-}
diff --git a/.claude/hooks/fleet/inline-script-defer-guard/tsconfig.json b/.claude/hooks/fleet/inline-script-defer-guard/tsconfig.json
deleted file mode 100644
index 19458cf..0000000
--- a/.claude/hooks/fleet/inline-script-defer-guard/tsconfig.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "compilerOptions": {
- "declarationMap": false,
- "erasableSyntaxOnly": true,
- "module": "nodenext",
- "moduleResolution": "nodenext",
- "noEmit": true,
- "rewriteRelativeImportExtensions": true,
- "skipLibCheck": true,
- "sourceMap": false,
- "strict": true,
- "target": "esnext",
- "types": ["node"],
- "verbatimModuleSyntax": true
- }
-}
diff --git a/.claude/hooks/fleet/issue-autolink-nudge/index.mts b/.claude/hooks/fleet/issue-autolink-nudge/index.mts
deleted file mode 100644
index 0447f43..0000000
--- a/.claude/hooks/fleet/issue-autolink-nudge/index.mts
+++ /dev/null
@@ -1,70 +0,0 @@
-#!/usr/bin/env node
-// Claude Code PreToolUse hook — issue-autolink-nudge.
-//
-// On a Bash command that writes to a public Git or GitHub surface (a commit,
-// or a pr, issue, comment, or release body), warn when the text contains a
-// bare `#N`. GitHub auto-links `#3` into a reference to issue or PR 3 of the
-// target repo — so a `#3` that meant "list item 3" or "task 3" silently turns
-// into a cross-reference to an unrelated issue. Suggest backticking it
-// (`` `#3` ``) or reshaping ("item 3").
-//
-// Advisory only — a bare `#N` is sometimes a deliberate, correct reference.
-// The nudge just prompts the author to confirm intent before it sends. Never
-// blocks (notify, exit 0). Universal: bare `#N` auto-links on ANY GitHub repo,
-// so this is not fleet-scoped. Internal task lists in the agent's own prose
-// never sent to a public surface, are unaffected.
-
-import { bashGuard, defineHook, notify, runHook } from '../_shared/guard.mts'
-import { isPublicSurface } from '../_shared/public-surfaces.mts'
-
-export const triggers: readonly string[] = ['gh', 'git']
-
-// A bare `#N`: a `#` immediately followed by digits, NOT preceded by a backtick
-// already code-formatted, or a word char (so `abc#3` and `v1.2.3#4` don't
-// count). A `#L12` line anchor starts with a letter, so it never matches. This
-// scans message text for a display artifact, not shell-command structure.
-const BARE_ISSUE_REF_RE = /(?()
- let match: RegExpExecArray | null = BARE_ISSUE_REF_RE.exec(text)
- while (match) {
- seen.add(`#${match[1]!}`)
- match = BARE_ISSUE_REF_RE.exec(text)
- }
- return [...seen]
-}
-
-export const check = bashGuard(command => {
- if (!isPublicSurface(command)) {
- return undefined
- }
- const refs = findBareIssueRefs(command)
- if (!refs.length) {
- return undefined
- }
- return notify(
- [
- '[issue-autolink-nudge] This command writes to a public GitHub surface',
- ` and contains a bare reference: ${refs.join(', ')}.`,
- '',
- ' GitHub auto-links `#N` to issue or PR N of the target repo. If you',
- ' meant a list item or task number (not that issue), it becomes a wrong',
- ' cross-reference once it sends.',
- '',
- ' • A real issue or PR reference? Leave it — that is the intended link.',
- ' • Otherwise wrap it in backticks (`#3`) or reshape ("item 3", "task 3")',
- ' before the command runs.',
- ].join('\n'),
- )
-})
-
-export const hook = defineHook({
- check,
- event: 'PreToolUse',
- matcher: ['Bash'],
- triggers,
- type: 'nudge',
-})
-
-void runHook(hook, import.meta.url)
diff --git a/.claude/hooks/fleet/judgment-nudge/README.md b/.claude/hooks/fleet/judgment-nudge/README.md
deleted file mode 100644
index 9c1f52f..0000000
--- a/.claude/hooks/fleet/judgment-nudge/README.md
+++ /dev/null
@@ -1,58 +0,0 @@
-# judgment-nudge
-
-Stop hook that flags hedging language in the assistant's most-recent turn. Two-layer detection: regex for fixed phrases, compromise.js for modal-verb judgment hedges.
-
-## Why
-
-CLAUDE.md "Judgment & self-evaluation":
-
-- "Default to perfectionist when you have latitude."
-- "If a fix fails twice: stop, re-read top-down, state where the mental model was wrong, try something fundamentally different."
-
-Hedging undermines those rules — it offloads judgment back to the user instead of executing the perfectionist default.
-
-## What it catches
-
-### Fixed-phrase regex layer
-
-| Phrase | Why it's flagged |
-| -------------------------------------------- | -------------------------------------------------------- |
-| `I'm not sure` / `I am not sure` | Hedge; state a recommendation with rationale instead. |
-| `you decide` / `your call` / `up to you` | Offloads judgment. Pick the recommended path. |
-| `either approach works` / `either way works` | False-equivalence hedging. Pick one. |
-| `let me know` / `your preference` | Hand-off phrasing. Ask one specific question or execute. |
-| `maybe X` / `perhaps X` (sentence-initial) | Front-loaded uncertainty user didn't ask for. |
-
-### Modal-verb NLP layer (compromise.js)
-
-Flags first-person modals in judgment contexts:
-
-- `I could go either way`
-- `we might want to consider`
-- `I may pick the simpler approach`
-
-The compromise.js library tags verbs with POS so we can distinguish judgment hedges ("I could go") from technical conditionals ("the parser could throw") — regex alone would false-positive on the latter.
-
-**Fail-open**: if compromise.js fails to load, the hook degrades to a regex-only fallback that catches the most common shape but misses some context.
-
-## Why it doesn't block
-
-Stop hooks fire after the assistant has produced its response. Blocking would truncate. The warning surfaces alongside the response so the user reads both and can push back next turn.
-
-## Relationship to other reminders
-
-- `excuse-detector` — catches fix-vs-defer choice menus
-- `reply-prose-nudge` (perfectionist group) — catches speed-vs-depth choice menus
-- `judgment-nudge` (this) — catches hedging within a single position
-
-All three address the same underlying anti-pattern: offloading judgment the assistant should have made.
-
-## Dependencies
-
-- `compromise@14.15.1` — NLP library for POS-tagged modal-verb detection. Lazy-loaded; optional.
-
-## Test
-
-```sh
-pnpm test
-```
diff --git a/.claude/hooks/fleet/judgment-nudge/index.mts b/.claude/hooks/fleet/judgment-nudge/index.mts
deleted file mode 100644
index 0e2e5df..0000000
--- a/.claude/hooks/fleet/judgment-nudge/index.mts
+++ /dev/null
@@ -1,214 +0,0 @@
-#!/usr/bin/env node
-// Claude Code Stop hook — judgment-nudge.
-//
-// Flags hedging language in the assistant's most recent turn.
-// CLAUDE.md "Judgment & self-evaluation":
-// - "If the request is based on a misconception, say so..."
-// - "Default to perfectionist when you have latitude."
-// - "If a fix fails twice: stop, re-read top-down..."
-//
-// Hedging ("I'm not sure", "you decide", "either approach works",
-// modal "might/could/may") undermines those rules — it offloads the
-// judgment back onto the user instead of executing the perfectionist
-// default.
-//
-// What this catches:
-//
-// - Fixed phrases (regex): "I'm not sure", "you decide", "either
-// approach works", "your call", "up to you", "let me know", etc.
-// - Modal verbs (compromise.js POS): could / might / may / perhaps /
-// maybe, when used as judgment hedges rather than technical
-// conditionals.
-//
-// The compromise.js NLP layer is what makes modal detection useful:
-// "this could throw" (technical conditional, OK) vs "I could go either
-// way", judgment hedge, flag. The library tags each token with POS
-// and lets us inspect the verb context. Regex alone gets too many
-// false positives on the technical use.
-//
-// Fail-open contract: if compromise.js fails to load (or its data
-// initializer throws), fall back to regex-only detection — the hook
-// still flags fixed phrases, just misses the modal-verb signal.
-//
-// This is a NUDGE, never blocks: when hits are found it returns
-// `notify(message)` so the runner prints to stderr and exits 0.
-
-import { defineHook, notify, runHook } from '../_shared/guard.mts'
-import type { GuardResult } from '../_shared/guard.mts'
-import type { ToolCallPayload } from '../_shared/payload.mts'
-import {
- extractSnippet,
- formatReminderBlock,
- scanReminderText,
-} from '../_shared/stop-nudge.mts'
-import type { ReminderHit, RuleViolation } from '../_shared/stop-nudge.mts'
-import {
- readLastAssistantText,
- stripCodeFences,
-} from '../_shared/transcript.mts'
-
-// Try-require compromise.js for modal-verb detection. Lazy + optional
-// because the dep is heavy (~2.5 MB unpacked) and the fixed-phrase
-// regex catches the most common hedging patterns without it. Modal
-// detection is an enhancement, not a requirement — if compromise is
-// missing (e.g. downstream repo didn't pnpm install the hook's deps
-// yet), the hook degrades gracefully to regex-only.
-interface NlpDoc {
- readonly verbs: () => {
- readonly out: (mode: 'array') => readonly string[]
- }
- readonly sentences: () => {
- readonly out: (mode: 'array') => readonly string[]
- }
-}
-type NlpFn = (text: string) => NlpDoc
-
-let cachedNlp: NlpFn | undefined
-export async function loadCompromise(): Promise {
- if (cachedNlp !== undefined) {
- return cachedNlp
- }
- try {
- const mod = await import('compromise')
- /* c8 ignore start - defensive fallbacks for non-standard compromise export shapes */
- const candidate = (mod as { default?: unknown | undefined }).default ?? mod
- cachedNlp =
- typeof candidate === 'function' ? (candidate as NlpFn) : undefined
- /* c8 ignore stop */
- } catch {
- /* c8 ignore start - only reachable when compromise is not installed */
- cachedNlp = undefined
- /* c8 ignore stop */
- }
- return cachedNlp
-}
-
-// Sentence-starting hedge modals — "I could go either way", "this
-// might be the better path", "perhaps we should..." These read as
-// the assistant deferring judgment rather than stating a position.
-//
-// We filter to hedge contexts (first-person subject + modal + judgment
-// verb) so technical conditionals like "the parser could throw if X"
-// don't false-positive. The compromise pattern matches:
-// - (i|we) + (could|might|may)
-// - sentence-initial perhaps/maybe + we/I/it
-const HEDGE_VERB_REGEX =
- /\b(?:i|we)\s+(?:could|may|might)\s+(?:approach|choose|consider|do|go|pick|try|use)\b/i
-
-export async function detectModalHedges(
- text: string,
-): Promise {
- const nlp = await loadCompromise()
- /* c8 ignore start - only reachable when compromise is not installed */
- if (!nlp) {
- // Fallback: regex-only. We still catch the most common shape.
- const match = HEDGE_VERB_REGEX.exec(text)
- if (!match) {
- return []
- }
- return [
- {
- label: 'modal-verb hedge (regex fallback)',
- why: "Modal verbs (could/might/may) used in first-person judgment context. State the position; don't hedge.",
- snippet: extractSnippet(text, match.index, match[0].length),
- },
- ]
- /* c8 ignore stop */
- }
-
- // Compromise.js path: walk sentences, flag any that contain a
- // first-person modal in a judgment context. The library tags each
- // verb with POS; we check sentence-by-sentence so the snippet is
- // useful, a single sentence rather than the whole turn.
- const doc = nlp(text)
- const sentences = doc.sentences().out('array')
- const hits: ReminderHit[] = []
- for (let i = 0, { length } = sentences; i < length; i += 1) {
- const sentence = sentences[i]!
- if (!HEDGE_VERB_REGEX.test(sentence)) {
- continue
- }
- // Compromise gives us POS-aware verb detection; we use it to
- // confirm the modal isn't part of a code-shape conditional like
- // "could throw" / "might return", technical, not judgment.
- const sentenceDoc = nlp(sentence)
- const verbs = sentenceDoc.verbs().out('array')
- const hasJudgmentVerb = verbs.some(v =>
- /\b(?:approach|choose|consider|do|go|pick|try|use)\b/i.test(v),
- )
- if (!hasJudgmentVerb) {
- continue
- }
- hits.push({
- label: 'modal-verb hedge',
- why: "First-person modal (could/might/may) used in judgment context. State the position; don't hedge.",
- snippet: sentence.length > 80 ? sentence.slice(0, 77) + '…' : sentence,
- })
- // One hit per turn is enough — flag and move on.
- break
- }
- return hits
-}
-
-const FIXED_HEDGE_PATTERNS: readonly RuleViolation[] = [
- {
- label: 'I’m not sure / I am not sure',
- // Match "I’m not sure" or "I am not sure" with straight or curly apostrophe,
- // as a whole word, case-insensitive.
- regex: /\bi['‘’]?m\s+not\s+sure\b|\bi\s+am\s+not\s+sure\b/i,
- why: 'Hedging. State a recommendation with rationale, or say "I need to verify X" and then do it.',
- },
- {
- label: 'you decide / your call / up to you',
- // Match any of three judgment-offload phrases as whole words, case-insensitive.
- regex: /\b(?:up\s+to\s+you|you\s+decide|your\s+call)\b/i,
- why: 'Offloads judgment. Default-perfectionist: pick the recommended path and execute.',
- },
- {
- label: 'either approach works / either way works',
- // Match false-equivalence phrases: "either works"
- // or "either is fine", as whole words, case-insensitive.
- regex:
- /\b(?:either\s+(?:approach|option|path|way)\s+works|either\s+is\s+fine)\b/i,
- why: 'False-equivalence hedging. Even when paths are close, name the one with the smaller blast radius and pick it.',
- },
- {
- label: 'let me know / your preference',
- // Match hand-off phrases ("let me know", "your preference", "tell me what")
- // as whole words, case-insensitive.
- regex: /\b(?:let\s+me\s+know|tell\s+me\s+what|your\s+preference)\b/i,
- why: 'Hand-off phrasing. If the user already gave intent, execute; if not, ask one specific question, not "let me know."',
- },
- {
- label: 'maybe / perhaps as judgment hedge',
- regex: /^(?:maybe|perhaps)\s+/im,
- why: 'Sentence-initial hedge. State the position; "maybe" at the front signals uncertainty the user didn\'t ask for.',
- },
-]
-
-const CLOSING_HINT =
- 'CLAUDE.md "Judgment & self-evaluation": default to perfectionist; state the recommendation, name the trade-off, then execute. Hedging asks the user to think for you.'
-
-export async function check(payload: ToolCallPayload): Promise {
- const rawText = readLastAssistantText(payload?.transcript_path)
- if (!rawText) {
- return undefined
- }
- const text = stripCodeFences(rawText)
- const hits = await scanReminderText(
- text,
- FIXED_HEDGE_PATTERNS,
- detectModalHedges,
- )
- if (hits.length === 0) {
- return undefined
- }
- return notify(formatReminderBlock('judgment-nudge', hits, CLOSING_HINT))
-}
-
-export const hook = defineHook({
- check,
- event: 'Stop',
- type: 'nudge',
-})
-void runHook(hook, import.meta.url)
diff --git a/.claude/hooks/fleet/judgment-nudge/package.json b/.claude/hooks/fleet/judgment-nudge/package.json
deleted file mode 100644
index d020633..0000000
--- a/.claude/hooks/fleet/judgment-nudge/package.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- "name": "hook-judgment-nudge",
- "private": true,
- "type": "module",
- "main": "./index.mts",
- "exports": {
- ".": "./index.mts"
- },
- "scripts": {
- "test": "node --test test/*.test.mts"
- },
- "dependencies": {
- "compromise": "catalog:"
- },
- "devDependencies": {
- "@types/node": "catalog:"
- }
-}
diff --git a/.claude/hooks/fleet/judgment-nudge/tsconfig.json b/.claude/hooks/fleet/judgment-nudge/tsconfig.json
deleted file mode 100644
index 19458cf..0000000
--- a/.claude/hooks/fleet/judgment-nudge/tsconfig.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "compilerOptions": {
- "declarationMap": false,
- "erasableSyntaxOnly": true,
- "module": "nodenext",
- "moduleResolution": "nodenext",
- "noEmit": true,
- "rewriteRelativeImportExtensions": true,
- "skipLibCheck": true,
- "sourceMap": false,
- "strict": true,
- "target": "esnext",
- "types": ["node"],
- "verbatimModuleSyntax": true
- }
-}
diff --git a/.claude/hooks/fleet/keep-working-while-waiting-nudge/README.md b/.claude/hooks/fleet/keep-working-while-waiting-nudge/README.md
deleted file mode 100644
index 6440b71..0000000
--- a/.claude/hooks/fleet/keep-working-while-waiting-nudge/README.md
+++ /dev/null
@@ -1,24 +0,0 @@
-# keep-working-while-waiting-nudge
-
-Stop hook. When the session is about to idle on an in-flight blocker — a remote
-CI job it launched to watch, a background shell job, a spawned Workflow, or
-background agents — this nudge reminds you that **waiting is not the same as
-being blocked**: advance a different queued todo, or tidy the task list, while
-the result lands. Only work truly blocked on the pending result should pause.
-
-## What it detects
-
-A scan of the recent assistant tool-use blocks for a wait signal:
-
-- a detached `Bash` call left running (`run_in_background: true`)
-- a `Bash` command watching/polling remote CI (`gh run watch`, `gh pr checks
- --watch`, a `gh api …/runs` poll, a bare `sleep` delay)
-- a `Workflow` call (background orchestration in flight)
-- an `Agent` call not explicitly foregrounded (agents run detached by default)
-
-## Verdict
-
-Notify — never blocks. A Stop hook fires after the turn ended, so there is no
-tool call to refuse; the reminder surfaces for the next turn.
-
-See [`judgment-and-self-evaluation`](../../../../docs/agents.md/fleet/judgment-and-self-evaluation.md).
diff --git a/.claude/hooks/fleet/keep-working-while-waiting-nudge/index.mts b/.claude/hooks/fleet/keep-working-while-waiting-nudge/index.mts
deleted file mode 100644
index 1fbdcfe..0000000
--- a/.claude/hooks/fleet/keep-working-while-waiting-nudge/index.mts
+++ /dev/null
@@ -1,98 +0,0 @@
-#!/usr/bin/env node
-// Claude Code Stop hook — keep-working-while-waiting-nudge.
-//
-// Fires at turn-end. When the session has an in-flight blocker it is about to
-// idle on — a remote CI job it launched to watch, a background shell command, a
-// spawned Workflow, or background agents — this nudge points out that waiting is
-// not the same as being blocked: the task QUEUE almost always has other work
-// that does NOT depend on that result. Advance a different queued todo, or take
-// the wait window to tidy the task list, instead of sitting idle until the
-// result lands. Only work TRULY blocked on the pending result should pause.
-//
-// Detection is a scan of the recent assistant tool-use blocks for wait signals:
-// • a Bash tool call with `run_in_background: true`, a detached job still running
-// • a Bash command that watches/polls remote CI (`gh run watch`, `gh pr checks
-// --watch`, a `gh api …/runs` poll, a bare `sleep` delay)
-// • a `Workflow` tool call, background orchestration in flight
-// • an `Agent` tool call not explicitly foregrounded, agents run detached by default
-//
-// Verdict: notify, never blocks. A Stop hook fires after the turn ended, so
-// there is no tool call to refuse — this is a reminder for the next turn.
-
-import { defineHook, notify, runHook } from '../_shared/guard.mts'
-import type { GuardResult } from '../_shared/guard.mts'
-import type { ToolCallPayload } from '../_shared/payload.mts'
-import type { ToolUseEvent } from '../_shared/transcript.mts'
-import {
- readLastAssistantToolUses,
- readPriorAssistantToolUses,
-} from '../_shared/transcript.mts'
-
-// How many prior assistant turns to scan alongside the latest one. A launched
-// job typically shows in the last turn or two; a small window keeps the scan
-// cheap on long transcripts.
-const LOOKBACK_TURNS = 3
-
-// A Bash command that watches or polls a remote CI result. Each branch is a
-// distinct wait shape: `gh run watch` / `gh run view` tail a run; `gh pr checks`
-// with `--watch` blocks on PR checks; `gh api …/runs` is a hand-rolled poll; a
-// bare `sleep` between polls is the classic busy-wait. Kept broad on purpose —
-// a false positive only shows an advisory reminder.
-const CI_WAIT_RE =
- /\bgh\s+(?:api[^\n]*\/runs|pr\s+checks?.*--watch|run\s+(?:view|watch))\b|(?:^|\s)sleep\s+\d/
-
-/**
- * Inspect a set of recent tool-use events for an in-flight blocker the session
- * is about to idle on. Returns the human-readable reason for the first signal
- * found, or undefined when nothing is waiting.
- */
-export function detectWaitSignal(
- toolUses: readonly ToolUseEvent[],
-): string | undefined {
- for (let i = 0, { length } = toolUses; i < length; i += 1) {
- const { input, name } = toolUses[i]!
- if (name === 'Workflow') {
- return 'a background Workflow is in flight'
- }
- if (name === 'Agent' && input['run_in_background'] !== false) {
- return 'spawned agents are running in the background'
- }
- if (name === 'Bash') {
- if (input['run_in_background'] === true) {
- return 'a background shell job is still running'
- }
- const command =
- typeof input['command'] === 'string' ? input['command'] : ''
- if (CI_WAIT_RE.test(command)) {
- return 'a remote CI job is being watched/polled'
- }
- }
- }
- return undefined
-}
-
-export const check = (payload: ToolCallPayload): GuardResult => {
- const transcriptPath = payload.transcript_path
- const recent: ToolUseEvent[] = [
- ...readLastAssistantToolUses(transcriptPath),
- ...readPriorAssistantToolUses(transcriptPath, LOOKBACK_TURNS),
- ]
- const reason = detectWaitSignal(recent)
- if (!reason) {
- return undefined
- }
- return notify(
- `[keep-working-while-waiting-nudge] Looks like ${reason}. Waiting is not the same as being blocked:\n` +
- ' • Advance a DIFFERENT queued todo that does not depend on that result.\n' +
- ' • Or use the wait window to tidy the task list (drop stale items, split big ones).\n' +
- ' • Pause only for work that is TRULY blocked on the pending result.\n' +
- 'Reminder-only; not a block.',
- )
-}
-
-export const hook = defineHook({
- check,
- event: 'Stop',
- type: 'nudge',
-})
-void runHook(hook, import.meta.url)
diff --git a/.claude/hooks/fleet/keep-working-while-waiting-nudge/package.json b/.claude/hooks/fleet/keep-working-while-waiting-nudge/package.json
deleted file mode 100644
index 77e3912..0000000
--- a/.claude/hooks/fleet/keep-working-while-waiting-nudge/package.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "name": "hook-keep-working-while-waiting-nudge",
- "private": true,
- "type": "module",
- "main": "./index.mts",
- "exports": {
- ".": "./index.mts"
- },
- "scripts": {
- "test": "node --test test/*.test.mts"
- },
- "devDependencies": {
- "@types/node": "catalog:"
- }
-}
diff --git a/.claude/hooks/fleet/keep-working-while-waiting-nudge/tsconfig.json b/.claude/hooks/fleet/keep-working-while-waiting-nudge/tsconfig.json
deleted file mode 100644
index 19458cf..0000000
--- a/.claude/hooks/fleet/keep-working-while-waiting-nudge/tsconfig.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "compilerOptions": {
- "declarationMap": false,
- "erasableSyntaxOnly": true,
- "module": "nodenext",
- "moduleResolution": "nodenext",
- "noEmit": true,
- "rewriteRelativeImportExtensions": true,
- "skipLibCheck": true,
- "sourceMap": false,
- "strict": true,
- "target": "esnext",
- "types": ["node"],
- "verbatimModuleSyntax": true
- }
-}
diff --git a/.claude/hooks/fleet/land-as-you-go-nudge/README.md b/.claude/hooks/fleet/land-as-you-go-nudge/README.md
deleted file mode 100644
index cefce84..0000000
--- a/.claude/hooks/fleet/land-as-you-go-nudge/README.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# land-as-you-go-nudge
-
-PostToolUse hook, non-blocking. After a successful `git commit` on the
-default branch it counts the local commits not yet on origin, and at three or
-more it nudges to land the queue — push, or the managing-worktrees land flow
-— before the next chunk starts. The commit-time twin of
-`unpushed-main-nudge`: that one reminds at turn end; this one fires in the
-moment the queue grows, when landing is one command and the context is still
-loaded.
-
-- **Trigger:** PostToolUse on Bash commands whose parsed argv contains a
- `git commit` invocation (honoring `git -C `); matching is on argv
- words, so `commit` inside a message or path never counts.
-- **Verdict:** a stderr notice at queue depth ≥ 3; silent off the default
- branch (worktree branches land as a unit), silent without an
- `origin/` counterpart, silent below the threshold.
-- **Why:** an unpushed pile is fragile in a parallel-session fleet — squash
- cadences and repair flows move origin under it, and every extra commit
- widens the eventual conflict surface.
-
-Companions: `unpushed-main-nudge` (turn-end reminder),
-`commit-size-nudge` (per-commit line budget), `land-fast-nudge` (diverged
-default branch routes to the land engine).
diff --git a/.claude/hooks/fleet/land-as-you-go-nudge/index.mts b/.claude/hooks/fleet/land-as-you-go-nudge/index.mts
deleted file mode 100644
index 8f8eea2..0000000
--- a/.claude/hooks/fleet/land-as-you-go-nudge/index.mts
+++ /dev/null
@@ -1,139 +0,0 @@
-#!/usr/bin/env node
-// Claude Code PostToolUse hook — land-as-you-go-nudge.
-//
-// After a successful `git commit` on the default branch, counts the local
-// commits not yet on origin. At the threshold it nudges to land the queue —
-// push, or the managing-worktrees land flow — BEFORE starting the next chunk.
-// This is the commit-time twin of `unpushed-main-nudge`: that one reminds at
-// turn end, this one fires in the moment the queue grows, when landing is one
-// command and the context is still loaded. An unpushed pile is fragile in a
-// parallel-session fleet — squash cadences and repair flows move origin under
-// it — and every extra commit widens the eventual conflict surface.
-//
-// Never blocks. Silent off the default branch, since worktree branches land
-// as a unit; silent when origin/ is unknown — a fresh repo or an
-// offline run; and silent below the threshold.
-
-import path from 'node:path'
-
-import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child'
-
-import { defineHook, notify, runHook } from '../_shared/guard.mts'
-import type { GuardResult } from '../_shared/guard.mts'
-import type { ToolCallPayload } from '../_shared/payload.mts'
-import { parseCommands } from '../_shared/shell-command.mts'
-
-const NAME = 'land-as-you-go-nudge'
-
-// Nudge when the unpushed queue reaches this many commits.
-const QUEUE_THRESHOLD = 3
-
-/**
- * The directory a `git commit` in `command` targets: the `-C ` value
- * when given, `.` otherwise, or undefined when no commit invocation exists.
- * Matches on parsed argv words, never the raw string, so `commit` inside a
- * message or path never counts. Exported for tests.
- */
-export function findCommitDir(command: string): string | undefined {
- const commands = parseCommands(command)
- for (let i = 0, { length } = commands; i < length; i += 1) {
- const parsed = commands[i]!
- if (parsed.binary !== 'git') {
- continue
- }
- const words = parsed.args
- let dir: string | undefined
- for (let w = 0, { length: wlen } = words; w < wlen; w += 1) {
- const word = words[w]!
- if (word === '-C' && w + 1 < wlen) {
- dir = words[w + 1]!
- w += 1
- continue
- }
- if (word === 'commit') {
- return dir ?? '.'
- }
- // Any other subcommand word ends this invocation's scan.
- if (!word.startsWith('-')) {
- break
- }
- }
- }
- return undefined
-}
-
-function git(args: string[], cwd: string): string | undefined {
- const r = spawnSync('git', args, { cwd, encoding: 'utf8' })
- return r.status === 0 ? String(r.stdout).trim() : undefined
-}
-
-/**
- * The unpushed-commit count for the checkout at `dir`, or undefined when the
- * checkout is not on its default branch, has no origin counterpart, or is
- * not a git checkout at all. Exported for tests.
- */
-export function unpushedQueueDepth(dir: string): number | undefined {
- const branch = git(['branch', '--show-current'], dir)
- if (!branch) {
- return undefined
- }
- const originHead = git(
- ['symbolic-ref', 'refs/remotes/origin/HEAD', '--short'],
- dir,
- )
- const defaultBranch = originHead
- ? originHead.replace(/^origin\//, '')
- : 'main'
- if (branch !== defaultBranch && branch !== 'master') {
- return undefined
- }
- const count = git(['rev-list', '--count', `origin/${branch}..HEAD`], dir)
- if (count === undefined) {
- return undefined
- }
- const n = Number.parseInt(count, 10)
- return Number.isFinite(n) ? n : undefined
-}
-
-export const check = (payload: ToolCallPayload): GuardResult => {
- if (payload.tool_name !== 'Bash') {
- return undefined
- }
- const command = payload.tool_input?.command
- if (typeof command !== 'string' || !command.includes('commit')) {
- return undefined
- }
- const commitDir = findCommitDir(command)
- if (commitDir === undefined) {
- return undefined
- }
- // The session cwd rides in the hook payload — hooks never read the
- // process's own cwd, which is unstable across the dispatcher.
- const baseDir = payload.cwd
- if (!baseDir && !path.isAbsolute(commitDir)) {
- return undefined
- }
- const dir = path.isAbsolute(commitDir)
- ? commitDir
- : path.resolve(baseDir!, commitDir)
- const depth = unpushedQueueDepth(dir)
- if (depth === undefined || depth < QUEUE_THRESHOLD) {
- return undefined
- }
- return notify(
- `[${NAME}] ${depth} unpushed commit(s) on the default branch at ${dir}.\n` +
- ' Land as you go: push the queue (or run the managing-worktrees land\n' +
- ' flow) before starting the next chunk — origin moves under an\n' +
- ' unpushed pile, and every extra commit widens the eventual conflict\n' +
- ' surface.',
- )
-}
-
-export const hook = defineHook({
- check,
- event: 'PostToolUse',
- matcher: ['Bash'],
- scope: 'convention',
- type: 'nudge',
-})
-void runHook(hook, import.meta.url)
diff --git a/.claude/hooks/fleet/land-as-you-go-nudge/package.json b/.claude/hooks/fleet/land-as-you-go-nudge/package.json
deleted file mode 100644
index 092595f..0000000
--- a/.claude/hooks/fleet/land-as-you-go-nudge/package.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- "name": "hook-land-as-you-go-nudge",
- "private": true,
- "type": "module",
- "main": "./index.mts",
- "exports": {
- ".": "./index.mts"
- },
- "scripts": {
- "test": "node --test test/*.test.mts"
- },
- "devDependencies": {
- "@types/node": "catalog:"
- },
- "dependencies": {
- "@socketsecurity/lib-stable": "catalog:"
- }
-}
diff --git a/.claude/hooks/fleet/land-as-you-go-nudge/tsconfig.json b/.claude/hooks/fleet/land-as-you-go-nudge/tsconfig.json
deleted file mode 100644
index 19458cf..0000000
--- a/.claude/hooks/fleet/land-as-you-go-nudge/tsconfig.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "compilerOptions": {
- "declarationMap": false,
- "erasableSyntaxOnly": true,
- "module": "nodenext",
- "moduleResolution": "nodenext",
- "noEmit": true,
- "rewriteRelativeImportExtensions": true,
- "skipLibCheck": true,
- "sourceMap": false,
- "strict": true,
- "target": "esnext",
- "types": ["node"],
- "verbatimModuleSyntax": true
- }
-}
diff --git a/.claude/hooks/fleet/land-fast-nudge/README.md b/.claude/hooks/fleet/land-fast-nudge/README.md
deleted file mode 100644
index a178392..0000000
--- a/.claude/hooks/fleet/land-fast-nudge/README.md
+++ /dev/null
@@ -1,22 +0,0 @@
-# land-fast-nudge
-
-Stop hook. Nudges at turn-end when the checkout is on the default branch
-(main / master) and local HEAD has **diverged** from origin — it is BOTH
-ahead AND behind `origin/`.
-
-A diverged default branch is the state where a direct `git push` is
-rejected and a `reset --hard` would discard local work. In a
-parallel-session fleet it happens routinely: another session squashes your
-commits onto origin via PR while your local keeps the unsquashed
-originals. Rather than hand-roll a cherry-pick + force, the reminder points
-at the `managing-worktrees land` engine
-(`.claude/skills/fleet/managing-worktrees/lib/land.mts`): it re-asserts the
-lint gate, cherry-picks the local-only commits onto a throwaway
-`origin/` worktree, and fast-forwards (never force). The fleet lints
-as it edits, so this needs no heavy re-run.
-
-Only fires when BOTH ahead AND behind — ahead-only is the
-`unpushed-main-nudge`'s job, behind-only just needs a pull.
-
-Fails open: any hook error is swallowed so a reminder bug never disrupts
-the turn.
diff --git a/.claude/hooks/fleet/land-fast-nudge/index.mts b/.claude/hooks/fleet/land-fast-nudge/index.mts
deleted file mode 100644
index da8b096..0000000
--- a/.claude/hooks/fleet/land-fast-nudge/index.mts
+++ /dev/null
@@ -1,132 +0,0 @@
-#!/usr/bin/env node
-// Claude Code Stop hook — land-fast-nudge.
-//
-// Fires at turn-end. When the current checkout is ON the default branch
-// (main / master) and local HEAD has DIVERGED from origin (it is BOTH
-// ahead AND behind origin/), it nudges toward the fast-land path
-// instead of a hand-rolled cherry-pick + force dance.
-//
-// Why: a diverged default branch is the state where a direct `git push`
-// is rejected (non-fast-forward) and a `reset --hard origin/`
-// would discard local work. It happens routinely in a parallel-session
-// fleet: another session squashes your commits onto origin via PR (so
-// origin gained commits your local doesn't have as the same SHAs), while
-// your local kept the unsquashed originals. Hand-resolving this — manual
-// cherry-pick onto a fresh worktree, verify fast-forward, push — is the
-// exact friction the `managing-worktrees land` engine (lib/land.mts)
-// automates: it re-asserts the lint gate (the fleet lints as it edits, so
-// no heavy re-run), cherry-picks onto a throwaway origin/ worktree,
-// and fast-forwards, never force. This reminder points there at the turn
-// the divergence is visible.
-//
-// Only fires on the default branch when BOTH ahead AND behind: an
-// ahead-only main is the unpushed-main-nudge's job, just push; a
-// behind-only main just needs a pull; the diverged case is the one the
-// fast-land path exists for.
-//
-// Exit codes: 0 — always, informational Stop hook. Fails open.
-
-import {
- currentBranch,
- gitOut,
- isDefaultBranch,
-} from '../_shared/git-branch.mts'
-import { isSquashOptIn } from '../_shared/fleet-roster.mts'
-import { defineHook, notify, runHook } from '../_shared/guard.mts'
-import { resolveProjectDir } from '../_shared/project-dir.mts'
-
-export function getProjectDir(): string {
- return resolveProjectDir()
-}
-
-// Ahead / behind counts vs origin/. `git rev-list --left-right
-// --count origin/...HEAD` prints "\t". Returns
-// undefined when there's no upstream to compare against.
-export function aheadBehind(
- repoDir: string,
- branch: string,
-): { ahead: number; behind: number } | undefined {
- const out = gitOut(repoDir, [
- 'rev-list',
- '--left-right',
- '--count',
- `origin/${branch}...HEAD`,
- ])
- if (out === undefined) {
- return undefined
- }
- const parts = out.split(/\s+/)
- /* c8 ignore start - parts[0]/parts[1] are always defined for valid git output; ?? '' fallback and NaN guard are defensive-only and unreachable from a real git repo */
- const behind = Number.parseInt(parts[0] ?? '', 10)
- const ahead = Number.parseInt(parts[1] ?? '', 10)
- if (!Number.isFinite(behind) || !Number.isFinite(ahead)) {
- return undefined
- }
- /* c8 ignore stop */
- return { ahead, behind }
-}
-
-// Diverged = BOTH ahead and behind. That's the non-fast-forward state the
-// fast-land path is for; ahead-only / behind-only are not.
-export function isDiverged(counts: { ahead: number; behind: number }): boolean {
- return counts.ahead > 0 && counts.behind > 0
-}
-
-export const check = () => {
- const repoDir = getProjectDir()
- const branch = currentBranch(repoDir)
- if (!branch || !isDefaultBranch(repoDir, branch)) {
- return undefined
- }
- const counts = aheadBehind(repoDir, branch)
- if (!counts || !isDiverged(counts)) {
- return undefined
- }
- // A squash-history repo's default branch is INTENTIONALLY diverged from
- // origin: origin holds the pre-squash history and local is
- // canonical. The fast-land cherry-pick-onto-origin path does NOT apply —
- // landing is a force-push. Point there instead of the fast-forward path.
- if (isSquashOptIn(repoDir)) {
- return notify(
- [
- `[land-fast-nudge] ${branch} is diverged from origin/${branch} ` +
- `(${counts.ahead} ahead, ${counts.behind} behind), but this is a`,
- `squash-history repo — local ${branch} is canonical and origin holds`,
- 'the pre-squash history. Do NOT fast-land / cherry-pick onto origin.',
- 'Land via the squashing-history force-push:',
- ` SQUASH_HISTORY=1 git push --force-with-lease origin ${branch}`,
- '',
- 'The SQUASH_HISTORY sentinel must be in the hook process ENV (export it),',
- 'not just an inline shell assignment — an inline `SQUASH_HISTORY=1 git`',
- 'lives in the command string, which the PreToolUse no-force-push-guard',
- 'does not read, so it still blocks. If it does, the bypass phrase is',
- '`Allow force-push bypass`.',
- '',
- ].join('\n'),
- )
- }
- return notify(
- [
- `[land-fast-nudge] ${branch} has DIVERGED from origin/${branch}: ` +
- `${counts.ahead} ahead, ${counts.behind} behind.`,
- '',
- 'A direct push will be rejected, and `reset --hard` would discard local',
- 'work (a parallel session likely squashed onto origin). Do NOT hand-roll',
- 'a cherry-pick + force. Fast-land the local-only commits instead:',
- ' node .claude/skills/fleet/managing-worktrees/lib/land.mts --last ',
- ' node .claude/skills/fleet/managing-worktrees/lib/land.mts --last --push',
- '',
- 'It re-asserts the lint gate, cherry-picks onto a throwaway origin/' +
- `${branch} worktree, and fast-forwards (never force).`,
- '',
- ].join('\n'),
- )
-}
-
-export const hook = defineHook({
- check,
- event: 'Stop',
- type: 'nudge',
-})
-
-void runHook(hook, import.meta.url)
diff --git a/.claude/hooks/fleet/land-fast-nudge/package.json b/.claude/hooks/fleet/land-fast-nudge/package.json
deleted file mode 100644
index 899a6aa..0000000
--- a/.claude/hooks/fleet/land-fast-nudge/package.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "name": "hook-land-fast-nudge",
- "private": true,
- "type": "module",
- "main": "./index.mts",
- "exports": {
- ".": "./index.mts"
- },
- "scripts": {
- "test": "node --test test/*.test.mts"
- },
- "devDependencies": {
- "@socketsecurity/lib-stable": "catalog:",
- "@types/node": "catalog:"
- }
-}
diff --git a/.claude/hooks/fleet/land-fast-nudge/tsconfig.json b/.claude/hooks/fleet/land-fast-nudge/tsconfig.json
deleted file mode 100644
index 19458cf..0000000
--- a/.claude/hooks/fleet/land-fast-nudge/tsconfig.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "compilerOptions": {
- "declarationMap": false,
- "erasableSyntaxOnly": true,
- "module": "nodenext",
- "moduleResolution": "nodenext",
- "noEmit": true,
- "rewriteRelativeImportExtensions": true,
- "skipLibCheck": true,
- "sourceMap": false,
- "strict": true,
- "target": "esnext",
- "types": ["node"],
- "verbatimModuleSyntax": true
- }
-}
diff --git a/.claude/hooks/fleet/latest-release-pin-guard/index.mts b/.claude/hooks/fleet/latest-release-pin-guard/index.mts
deleted file mode 100644
index 21c7da9..0000000
--- a/.claude/hooks/fleet/latest-release-pin-guard/index.mts
+++ /dev/null
@@ -1,464 +0,0 @@
-#!/usr/bin/env node
-// Claude Code PreToolUse hook — latest-release-pin-guard.
-//
-// BLOCKS an Edit/Write to `.gitmodules` or a `*lockstep.json` manifest that
-// SETS or CHANGES an upstream pin to something OLDER than the newest shipped
-// release tag. Porting an upstream means the LATEST release — always. A pin left
-// at a stale/inherited release is a work-loss trap: the opentui incident pinned
-// v0.1.99, 211 commits and 3 minor releases behind v0.4.5, and ~31k lines were
-// ported against it before anyone noticed.
-//
-// What it checks, per changed pin:
-// - `.gitmodules` — a `[submodule "…"]` block's `ref`/`branch` pin.
-// - `*lockstep.json` — a version-pin row's `pinned_sha`/`pinned_tag`; the
-// upstream repo URL is resolved from the manifest's `upstreams` map.
-// It fetches the upstream's tags with `git ls-remote --tags `, finds the
-// newest STABLE tag sharing the pin's version scheme, and blocks when the pin
-// resolves to an older one — naming the newer release. Only pins that are NEW or
-// whose value CHANGED in this edit are checked: the post-edit text via
-// `resolveEditedText` is diffed against the on-disk file, so touching an
-// unrelated field never false-blocks a pin already committed.
-//
-// Fails OPEN on anything it can't determine — an offline `ls-remote`, an
-// unparseable tag scheme, a sha that maps to no release tag, a fragment edit
-// whose post-edit text can't be reconstructed. The CI-side lockstep drift check
-// (`scripts/fleet/lockstep/checks.mts`) is the online backstop.
-//
-// Convention: docs/agents.md/fleet/lockstep.md + docs/agents.md/fleet/drift-watch.md.
-// Bypass: `Allow latest-release-pin bypass`.
-
-import { safeReadFileSync } from '@socketsecurity/lib-stable/fs/read-file'
-import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize'
-import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child'
-
-import { isFleetTarget } from '../_shared/fleet-context.mts'
-import { block, defineHook, editGuard, runHook } from '../_shared/guard.mts'
-import { resolveEditedText } from '../_shared/payload.mts'
-
-export const triggers: readonly string[] = ['.gitmodules', 'lockstep']
-
-// A pin file lives at `.gitmodules` or a lockstep manifest — `lockstep.json`,
-// `.config/repo/lockstep.json` (or legacy `.config/lockstep.json`), or a
-// `lockstep-.json` include. The emitted
-// `lockstep.schema.json` is not a manifest, so the `.schema.json` tail excludes
-// it.
-const GITMODULES_RE = /(?:^|\/)\.gitmodules$/
-// `(?:^|/)` path boundary, `lockstep`, an optional `-` suffix, then a
-// literal `.json` at end — matches the manifests above but not `.schema.json`.
-const LOCKSTEP_RE = /(?:^|\/)lockstep(?:-[a-z0-9-]+)?\.json$/
-
-export function isGitmodulesFile(filePath: string): boolean {
- return GITMODULES_RE.test(normalizePath(filePath))
-}
-
-export function isLockstepFile(filePath: string): boolean {
- return LOCKSTEP_RE.test(normalizePath(filePath))
-}
-
-export function isPinFile(filePath: string): boolean {
- return isGitmodulesFile(filePath) || isLockstepFile(filePath)
-}
-
-// ---------------------------------------------------------------------------
-// Version-tag math. Mirrors the tag resolver in
-// `scripts/fleet/lockstep/auto-bump.mts` — the hook can't import across the
-// bundle boundary, so the small parse/compare core is duplicated here and
-// tested alongside it.
-// ---------------------------------------------------------------------------
-
-export interface TagVersion {
- major: number
- minor: number
- patch: number
-}
-
-export interface ParsedTag {
- prefix: string
- version: TagVersion
-}
-
-// Pre-release / nightly suffixes: a stale STABLE pin is the hazard, so
-// pre-releases are never a "newer release" to bump toward.
-const PRERELEASE_RE =
- /-(?:alpha|beta|dev|nightly|preview|rc|snapshot)(?:[._-]?\d+)?$/iu
-
-export function isStableTag(tag: string): boolean {
- return !PRERELEASE_RE.test(tag)
-}
-
-// Parse `v1.2.3`, `1.2.3`, `-1.2.3`, `_1_2_3` into
-// { prefix, version }; two-component forms take patch 0. `undefined` when no
-// semver triple is present, so an exotic tag never proposes a bump.
-export function parseVersionTag(tag: string): ParsedTag | undefined {
- // Underscore scheme (`_1_2_3`): digits joined by underscores.
- const underscore = /^(.*?)[._-]?(\d+)_(\d+)(?:_(\d+))?$/u.exec(tag)
- if (underscore && tag.includes('_')) {
- return {
- prefix: underscore[1]!.replace(/[._-]$/u, ''),
- version: {
- major: Number(underscore[2]),
- minor: Number(underscore[3]),
- patch: Number(underscore[4] ?? 0),
- },
- }
- }
- // Dotted scheme, optionally `v`- or `-` prefixed.
- const dotted = /^(.*?)(\d+)\.(\d+)(?:\.(\d+))?$/u.exec(tag)
- if (dotted) {
- return {
- prefix: dotted[1]!.replace(/[._-]$/u, '').replace(/^v$/u, ''),
- version: {
- major: Number(dotted[2]),
- minor: Number(dotted[3]),
- patch: Number(dotted[4] ?? 0),
- },
- }
- }
- return undefined
-}
-
-export function compareTagVersions(a: TagVersion, b: TagVersion): number {
- if (a.major !== b.major) {
- return a.major - b.major
- }
- if (a.minor !== b.minor) {
- return a.minor - b.minor
- }
- return a.patch - b.patch
-}
-
-// The newest STABLE tag sharing `pinTag`'s scheme prefix, iff it is strictly
-// newer than `pinTag`. Constrained to the pin's prefix so a `v`-scheme pin never
-// "upgrades" onto a `-` tag from another epoch. `undefined` when the pin
-// is already newest, its scheme is unparseable, or no candidate shares it.
-export function newerReleaseThan(
- pinTag: string,
- tagNames: readonly string[],
-): string | undefined {
- const current = parseVersionTag(pinTag)
- if (!current) {
- return undefined
- }
- let best: { raw: string; version: TagVersion } | undefined
- for (let i = 0, { length } = tagNames; i < length; i += 1) {
- const name = tagNames[i]!
- if (!isStableTag(name)) {
- continue
- }
- const parsed = parseVersionTag(name)
- if (!parsed || parsed.prefix !== current.prefix) {
- continue
- }
- if (!best || compareTagVersions(parsed.version, best.version) > 0) {
- best = { raw: name, version: parsed.version }
- }
- }
- if (best && compareTagVersions(best.version, current.version) > 0) {
- return best.raw
- }
- return undefined
-}
-
-// ---------------------------------------------------------------------------
-// Pin parsing + evaluation.
-// ---------------------------------------------------------------------------
-
-export interface RemoteTag {
- name: string
- sha: string
-}
-
-export type ListTags = (url: string) => readonly RemoteTag[]
-
-export interface GitmodulesPin {
- name: string
- url: string | undefined
- ref: string | undefined
- branch: string | undefined
-}
-
-export interface LockstepPin {
- id: string
- upstream: string
- repo: string | undefined
- pinnedSha: string | undefined
- pinnedTag: string | undefined
-}
-
-export interface PinViolation {
- name: string
- pinned: string
- newest: string
-}
-
-// Parse `.gitmodules` blocks: a `[submodule ""]` header opens a block; the
-// fleet's `url`, `ref` pinned commit, and `branch` pinned tag are captured.
-export function parseGitmodulesPins(content: string): GitmodulesPin[] {
- const pins: GitmodulesPin[] = []
- let current: GitmodulesPin | undefined
- const lines = content.split('\n')
- for (let i = 0, { length } = lines; i < length; i += 1) {
- const line = lines[i]!.trim()
- const header = /^\[submodule\s+"([^"]+)"\]$/.exec(line)
- if (header) {
- current = {
- name: header[1]!,
- url: undefined,
- ref: undefined,
- branch: undefined,
- }
- pins.push(current)
- continue
- }
- if (!current) {
- continue
- }
- // `key = value` inside a block.
- const kv = /^([A-Za-z][A-Za-z0-9-]*)\s*=\s*(.+)$/.exec(line)
- if (!kv) {
- continue
- }
- const key = kv[1]!.toLowerCase()
- const value = kv[2]!.trim()
- if (key === 'branch') {
- current.branch = value
- } else if (key === 'ref') {
- current.ref = value
- } else if (key === 'url') {
- current.url = value
- }
- }
- return pins
-}
-
-// Parse a lockstep manifest's version-pin rows, resolving each row's repo URL
-// from the top-level `upstreams` map. `resolveEditedText` hands us the full
-// post-edit JSON, so an Edit fragment still parses as a complete document.
-export function parseLockstepPins(content: string): LockstepPin[] {
- let doc: unknown
- try {
- doc = JSON.parse(content)
- } catch {
- return []
- }
- if (!doc || typeof doc !== 'object') {
- return []
- }
- const obj = doc as {
- upstreams?: unknown | undefined
- rows?: unknown | undefined
- }
- const upstreams =
- obj.upstreams && typeof obj.upstreams === 'object'
- ? (obj.upstreams as Record)
- : {}
- const rows = Array.isArray(obj.rows) ? obj.rows : []
- const pins: LockstepPin[] = []
- for (let i = 0, { length } = rows; i < length; i += 1) {
- const row = rows[i] as {
- kind?: unknown | undefined
- id?: unknown | undefined
- upstream?: unknown | undefined
- pinned_sha?: unknown | undefined
- pinned_tag?: unknown | undefined
- }
- if (!row || row.kind !== 'version-pin') {
- continue
- }
- const upstream = typeof row.upstream === 'string' ? row.upstream : ''
- const up = upstreams[upstream]
- pins.push({
- id: typeof row.id === 'string' ? row.id : '',
- pinnedSha:
- typeof row.pinned_sha === 'string' ? row.pinned_sha : undefined,
- pinnedTag:
- typeof row.pinned_tag === 'string' ? row.pinned_tag : undefined,
- repo: up && typeof up.repo === 'string' ? up.repo : undefined,
- upstream,
- })
- }
- return pins
-}
-
-// Parse `git ls-remote --tags` output into { name, sha } pairs. Peeled entries
-// (`refs/tags/x^{}`) are kept as their own pair with the `^{}` stripped, so a
-// sha-only pin at an annotated tag's COMMIT still resolves to the tag name.
-export function parseLsRemote(out: string): RemoteTag[] {
- const tags: RemoteTag[] = []
- const lines = out.split('\n')
- for (let i = 0, { length } = lines; i < length; i += 1) {
- // `` hex 7-40, whitespace, `refs/tags/`, then the captured tag name.
- const m = /^([0-9a-f]{7,40})\s+refs\/tags\/(.+)$/.exec(lines[i]!.trim())
- if (m) {
- tags.push({ name: m[2]!.replace(/\^\{\}$/, ''), sha: m[1]! })
- }
- }
- return tags
-}
-
-// The tag name a pinned sha resolves to, matching full or shared-prefix shas.
-function tagForSha(
- sha: string,
- tags: readonly RemoteTag[],
-): string | undefined {
- if (!sha) {
- return undefined
- }
- for (let i = 0, { length } = tags; i < length; i += 1) {
- const t = tags[i]!
- if (t.sha === sha || (sha.length >= 7 && t.sha.startsWith(sha))) {
- return t.name
- }
- }
- return undefined
-}
-
-// The release tag a pin currently resolves to: its explicit tag when set, else
-// the tag its sha points at.
-function pinnedReleaseTag(
- explicitTag: string | undefined,
- sha: string | undefined,
- tags: readonly RemoteTag[],
-): string | undefined {
- if (explicitTag) {
- return explicitTag
- }
- return sha ? tagForSha(sha, tags) : undefined
-}
-
-export function evaluateGitmodules(
- before: string,
- after: string,
- listTags: ListTags,
-): PinViolation[] {
- const beforeByName = new Map(
- parseGitmodulesPins(before).map(p => [p.name, p]),
- )
- const violations: PinViolation[] = []
- const pins = parseGitmodulesPins(after)
- for (let i = 0, { length } = pins; i < length; i += 1) {
- const pin = pins[i]!
- const prev = beforeByName.get(pin.name)
- const changed = !prev || prev.ref !== pin.ref || prev.branch !== pin.branch
- if (!changed || !pin.url) {
- continue
- }
- const tags = listTags(pin.url)
- if (!tags.length) {
- continue
- }
- const pinTag = pinnedReleaseTag(pin.branch, pin.ref, tags)
- if (!pinTag) {
- continue
- }
- const newest = newerReleaseThan(
- pinTag,
- tags.map(t => t.name),
- )
- if (newest) {
- violations.push({ name: pin.name, newest, pinned: pinTag })
- }
- }
- return violations
-}
-
-export function evaluateLockstep(
- before: string,
- after: string,
- listTags: ListTags,
-): PinViolation[] {
- const beforeById = new Map(parseLockstepPins(before).map(p => [p.id, p]))
- const violations: PinViolation[] = []
- const pins = parseLockstepPins(after)
- for (let i = 0, { length } = pins; i < length; i += 1) {
- const pin = pins[i]!
- if (!pin.repo) {
- continue
- }
- const prev = beforeById.get(pin.id)
- const changed =
- !prev ||
- prev.pinnedSha !== pin.pinnedSha ||
- prev.pinnedTag !== pin.pinnedTag
- if (!changed) {
- continue
- }
- const tags = listTags(pin.repo)
- if (!tags.length) {
- continue
- }
- const pinTag = pinnedReleaseTag(pin.pinnedTag, pin.pinnedSha, tags)
- if (!pinTag) {
- continue
- }
- const newest = newerReleaseThan(
- pinTag,
- tags.map(t => t.name),
- )
- if (newest) {
- violations.push({ name: pin.upstream, newest, pinned: pinTag })
- }
- }
- return violations
-}
-
-export function formatBlock(violations: readonly PinViolation[]): string {
- // One line per stale pin; the fix rides the first line.
- const lines: string[] = []
- for (let i = 0, { length } = violations; i < length; i += 1) {
- const v = violations[i]!
- lines.push(
- i === 0
- ? `🚨 latest-release-pin-guard: stale pin "${v.name} at ${v.pinned}" — ${v.newest} has shipped; \`git fetch --tags\` and pin ${v.newest} (gen/gitmodules-hash.mts --set / lockstep version-pin row, docs/agents.md/fleet/lockstep.md)`
- : ` "${v.name} at ${v.pinned}" — ${v.newest} has shipped`,
- )
- }
- return lines.join('\n') + '\n'
-}
-
-// The real tag lister: query the remote directly, so a not-yet-cloned submodule
-// still resolves. NETWORK spawn — a fixed timeout, never platform-scaled (see
-// _shared/spawn-timeout.mts). Any failure returns [] and the guard fails open.
-export function listRemoteTags(url: string): RemoteTag[] {
- try {
- const result = spawnSync('git', ['ls-remote', '--tags', url], {
- stdio: ['ignore', 'pipe', 'ignore'],
- stdioString: true,
- timeout: 15_000,
- })
- if (result.status !== 0 || typeof result.stdout !== 'string') {
- return []
- }
- return parseLsRemote(result.stdout)
- } catch {
- return []
- }
-}
-
-export const check = editGuard((filePath, _content, payload) => {
- if (!isPinFile(filePath) || !isFleetTarget(payload)) {
- return undefined
- }
- const after = resolveEditedText(payload)
- if (after === undefined) {
- return undefined
- }
- const before = safeReadFileSync(filePath) ?? ''
- const violations = isGitmodulesFile(filePath)
- ? evaluateGitmodules(before, after, listRemoteTags)
- : evaluateLockstep(before, after, listRemoteTags)
- if (!violations.length) {
- return undefined
- }
- return block(formatBlock(violations))
-})
-
-export const hook = defineHook({
- bypass: ['latest-release-pin'],
- check,
- event: 'PreToolUse',
- matcher: ['Edit', 'MultiEdit', 'Write'],
- triggers,
- type: 'guard',
-})
-void runHook(hook, import.meta.url)
diff --git a/.claude/hooks/fleet/link-protocol-dep-guard/README.md b/.claude/hooks/fleet/link-protocol-dep-guard/README.md
deleted file mode 100644
index 468555b..0000000
--- a/.claude/hooks/fleet/link-protocol-dep-guard/README.md
+++ /dev/null
@@ -1,127 +0,0 @@
-# link-protocol-dep-guard
-
-PreToolUse Edit/Write hook that blocks adding an unpinned dependency
-spec to any dependency block of a `package.json`. Note: an unpinned
-spec is a `link:`/`file:` local path, or a `workspace:` range.
-
-## Why
-
-**A `link:` means a package needs publishing.** The spec points a
-dependency at a local path, which carries no registry identity and no
-integrity hash. The install resolves to whatever happens to sit at that
-path on the machine running it, and on a fresh clone — CI, a new
-contributor, a release runner — to nothing at all. The reason the path
-is there is almost always that the package it names is **unpublished**,
-so the fix is release work: reserve the name and wire trusted publishing
-(`scripts/fleet/publish-infra/{npm,cargo}/placeholder.mts`,
-`cargo/trusted-publisher.mts`), then depend on the published version.
-
-A `workspace:` range has the same floating problem inside the repo:
-`workspace:*` and `workspace:^1.2.3` resolve to whichever sibling
-version the tree happens to carry, and pnpm expands the range at publish
-time into a range every consumer inherits.
-
-The fleet's preference order among the pinned forms is **`catalog:` >
-exact `1.2.3` > `workspace:1.2.3`** — a catalog entry pins just as hard
-and one central bump upgrades every repo, where the other two cost a
-manifest bump per dependent on each release. Full order, the blocked
-forms, and the `peerDependencies` exemption:
-[`dependency-spec-pinning`](../../../../docs/agents.md/fleet/dependency-spec-pinning.md).
-
-The worked example for the rarer glob case is decmpfs: five `link:`
-entries shipped in the committed `pnpm-lock.yaml` pointing at
-`napi/decmpfs/npm//`, which are **gitignored generated output**.
-The lockfile depended on artifacts absent from a fresh clone, and it
-went unnoticed for weeks.
-
-## What it blocks
-
-An Edit/Write to a `package.json` that ADDS (or changes the value of) a
-`link:`/`file:` spec or a `workspace:` range in any of:
-
- dependencies
- devDependencies
- optionalDependencies
- peerDependencies
- overrides (walked recursively — overrides nest)
- resolutions (walked recursively)
- pnpm.overrides (walked recursively)
-
-An existing spec left untouched by the edit does not block; the hook
-diffs before-vs-after and reports only what the edit introduces.
-
-## What it does NOT block
-
-- `catalog:` — PREFERRED. A catalog entry is itself exact-pinned in
- `.config/fleet/pnpm-workspace.fleet.yaml`, so `catalog:` pins as hard
- as a literal version while keeping one central bump site.
-- An exact registry version (`1.2.3`, `npm:alias@1.2.3`).
-- `workspace:1.2.3` — legal, and a FALLBACK rather than a destination.
- The check reports these under a "prefer `catalog:`" heading as the
- conversion backlog; it never fails on them, because a repo whose
- sibling is unpublished has nowhere else to go.
-- A git or tarball URL.
-- A bare registry range (`^1.2.3`, `>=5.0.0`). These are **classified**
- as `registry-range` and reported by the companion check, but not
- blocked while the fleet's remaining bare ranges convert.
- `isBlockingSpecKind` in `_shared/dependency-spec-forms.mts` is the
- seam that flips it on. `peerDependencies` stay exempt permanently: a
- peer range states the span of host versions a package supports.
-
-## The gap this hook cannot close
-
-In decmpfs the `link:` specs were **never hand-written**. pnpm generated
-them: `pnpm-workspace.yaml` declared a `packages:` glob
-(`napi/decmpfs/npm/*`) over generated directories, and with
-`linkWorkspacePackages: true` pnpm resolved the matching
-`optionalDependencies` to `link:` specs in the lockfile. No manifest
-edit happened, so no edit-time guard could ever have fired.
-
-That case is caught by the companion commit-time gate,
-`scripts/fleet/check/dependency-specs-are-registry-or-workspace.mts`,
-which scans the committed `pnpm-lock.yaml` and fails when a `link:`
-target is not a git-tracked directory. The two tiers together are the
-rule: this hook stops the hand-written spec, the check stops the
-generated one.
-
-## Bypass
-
-Type the canonical phrase in a new message:
-
- Allow link-protocol-dep bypass
-
-Legitimate case: a throwaway local reproduction, or a test fixture whose
-whole point is to exercise local-path resolution. Neither belongs in a
-committed manifest without a reason stated in review.
-
-## Detection
-
-Parses before+after JSON, classifies every spec through the shared
-`_shared/dependency-spec-forms.mts` module (the same classifier the
-check uses, so the two tiers cannot drift), keys each finding
-`.`, and reports the set difference. Fails open
-on JSON parse errors.
-
-## Fix
-
-```jsonc
-// package.json
-
-{
- "dependencies": {
- // PREFERRED — published package, centrally pinned in the fleet
- // catalog (.config/fleet/pnpm-workspace.fleet.yaml):
- "typescript": "catalog:",
- // Published package that does not belong in the fleet-wide catalog:
- "defu": "6.1.6",
- // FALLBACK — an in-repo package that genuinely cannot be published.
- // List its directory under `packages:` in pnpm-workspace.yaml, then
- // pin it exactly. Publish it and move to `catalog:` when you can:
- "my-in-repo-pkg": "workspace:1.2.3",
- },
-}
-```
-
-Generated per-platform output (a napi `npm//` directory) is not
-a workspace member. Keep its glob out of `packages:` and let the publish
-engine find those packages by convention instead.
diff --git a/.claude/hooks/fleet/link-protocol-dep-guard/index.mts b/.claude/hooks/fleet/link-protocol-dep-guard/index.mts
deleted file mode 100644
index 88bd08d..0000000
--- a/.claude/hooks/fleet/link-protocol-dep-guard/index.mts
+++ /dev/null
@@ -1,142 +0,0 @@
-#!/usr/bin/env node
-// Claude Code PreToolUse hook — link-protocol-dep-guard.
-//
-// Blocks Edit/Write to a `package.json` that adds an unpinned dependency
-// spec to any dependency block — `dependencies`, `devDependencies`,
-// `optionalDependencies`, `peerDependencies`, `overrides`, `resolutions`,
-// or `pnpm.overrides`.
-//
-// Two blocking classes, from `_shared/dependency-spec-forms.mts`:
-//
-// local-path `link:` / `file:`. The dependency resolves to a
-// directory on the installing machine, which means the
-// package it names is UNPUBLISHED. Publishing it and
-// routing it through the fleet catalog is the fix;
-// narrowing a `packages:` glob is the rarer one.
-// workspace-range `workspace:*` / `workspace:^1.2.3`. A range floats.
-//
-// The fleet preference order among the pinned forms is `catalog:` > exact
-// `1.2.3` > `workspace:1.2.3`, because one central catalog bump beats a
-// manifest bump per dependent on every sibling release. Full rationale:
-// `docs/agents.md/fleet/dependency-spec-pinning.md`.
-//
-// Two kinds are classified but never blocked here: `registry-range` (a bare
-// `^1.2.3`, staged while the fleet's remaining ranges convert) and
-// `workspace-pin` (`workspace:1.2.3` — legal, reported by the check as the
-// `catalog:` conversion backlog). `isBlockingSpecKind` is the seam.
-//
-// Companion gate: `scripts/fleet/check/dependency-specs-are-registry-or-
-// workspace.mts` catches the specs this hook cannot see — the ones pnpm
-// GENERATES into `pnpm-lock.yaml` when a `packages:` glob in
-// `pnpm-workspace.yaml` covers generated/gitignored directories. No
-// manifest edit happens in that flow, so an edit-time guard alone would
-// never fire.
-//
-// Bypass: `Allow link-protocol-dep bypass` typed verbatim.
-//
-// Fails open on JSON parse errors.
-
-import path from 'node:path'
-
-import { safeReadFileSync } from '@socketsecurity/lib-stable/fs/read-file'
-
-import {
- collectDependencySpecFindings,
- dependencySpecFindingKey,
- isBlockingSpecKind,
-} from '../_shared/dependency-spec-forms.mts'
-import { block, defineHook, editGuard, runHook } from '../_shared/guard.mts'
-import { resolveEditedText } from '../_shared/payload.mts'
-
-import type { DependencySpecFinding } from '../_shared/dependency-spec-forms.mts'
-
-export {
- isBlockingSpecKind,
- isRegistryRangeSpec,
- isWorkspaceRangeSpec,
- localPathProtocol,
-} from '../_shared/dependency-spec-forms.mts'
-
-export function isPackageJson(filePath: string): boolean {
- return path.basename(filePath) === 'package.json'
-}
-
-// Every out-of-contract spec in a package.json's dependency surface, keyed
-// `.` so a caller can diff before-vs-after and report
-// only what an edit ADDS.
-export function collectDependencySpecMap(
- text: string,
-): Map {
- const out = new Map()
- const findings = collectDependencySpecFindings(text)
- for (let i = 0, { length } = findings; i < length; i += 1) {
- const finding = findings[i]!
- out.set(dependencySpecFindingKey(finding), finding)
- }
- return out
-}
-
-export function describeFindings(
- findings: readonly DependencySpecFinding[],
-): string {
- return findings
- .map(finding => `${finding.field}.${finding.name} "${finding.value}"`)
- .join(', ')
-}
-
-export const check = editGuard((filePath, _content, payload) => {
- if (!isPackageJson(filePath)) {
- return undefined
- }
-
- const afterText = resolveEditedText(payload)
- if (afterText === undefined) {
- return undefined
- }
- const currentText = safeReadFileSync(filePath) ?? '{}'
-
- const beforeSpecs = collectDependencySpecMap(currentText)
- const afterSpecs = collectDependencySpecMap(afterText)
-
- const added: DependencySpecFinding[] = []
- for (const [key, finding] of afterSpecs) {
- if (!isBlockingSpecKind(finding.kind)) {
- continue
- }
- const before = beforeSpecs.get(key)
- if (before === undefined || before.value !== finding.value) {
- added.push(finding)
- }
- }
- if (added.length === 0) {
- return undefined
- }
-
- const localPath = added.filter(finding => finding.kind === 'local-path')
-
- // Priority order among the pinned forms: `catalog:` (one central bump)
- // > exact `1.2.3` > `workspace:1.2.3` as the last resort.
- const reason =
- localPath.length > 0
- ? 'a `link:`/`file:` spec means the package is UNPUBLISHED — publish it ' +
- '(node scripts/fleet/publish-infra/npm/placeholder.mts), then pin via ' +
- '`catalog:` (preferred) > exact "1.2.3" > `workspace:1.2.3` (last ' +
- 'resort); a generated dir swept by a `packages:` glob instead needs ' +
- 'the glob narrowed'
- : 'a `workspace:` RANGE floats — pin via `catalog:` (preferred) > ' +
- 'exact "1.2.3" > `workspace:1.2.3` (last resort)'
- return block(
- `🚨 link-protocol-dep-guard: blocked ${describeFindings(added)} in ` +
- `${filePath} — ${reason}.`,
- )
-})
-
-export const hook = defineHook({
- bypass: ['link-protocol-dep'],
- check,
- event: 'PreToolUse',
- matcher: ['Edit', 'Write', 'MultiEdit'],
- type: 'guard',
-})
-
-void runHook(hook, import.meta.url)
diff --git a/.claude/hooks/fleet/live-edit-collision-guard/index.mts b/.claude/hooks/fleet/live-edit-collision-guard/index.mts
deleted file mode 100644
index 25805ab..0000000
--- a/.claude/hooks/fleet/live-edit-collision-guard/index.mts
+++ /dev/null
@@ -1,199 +0,0 @@
-/*
- * @file Claude Code PreToolUse hook — live-edit-collision-guard.
- *
- * Blocks an Edit / Write / NotebookEdit operation when the target path was
- * written by a DIFFERENT live actor within the last 5 minutes. "Live" means
- * the other actor's ledger file has an updatedAt within the 15-minute TTL.
- *
- * Problem observed live (#239): while a background workflow edited extension
- * src files, the interactive session blind-edited the same files (survived
- * by luck) and was then blocked for three consecutive turns by
- * dirty-worktree-stop-guard because the dirty paths belonged to the live run.
- * The collision is better caught HERE, before the write lands.
- *
- * Actor key: sha256(transcript_path).slice(0,16). The transcript_path
- * discriminates SEPARATE interactive sessions. It does NOT discriminate a
- * spawned subagent from its parent — Claude Code delivers the PARENT
- * session's transcript_path to hooks even for a subagent's writes, so a
- * subagent's edits collapse into the parent actor's ledger (the stop guard
- * detects live children from their own transcript files instead). See the
- * computeActorId note in _shared/active-edits-ledger.mts.
- *
- * Block message shape: What / Where / Saw-vs-wanted / Fix — three sanctioned
- * moves:
- * (a) stop the other run first (TaskStop, then resume via its journal),
- * (b) queue the edit for after the run lands,
- * (c) user types `Allow live-edit-collision bypass` verbatim.
- *
- * Fail-open: any IO / parse error falls through, no block, per the fleet's
- * hook contract — "a buggy hook silently allows" beats "a buggy hook wedges
- * the session."
- */
-
-import path from 'node:path'
-import process from 'node:process'
-
-import {
- COLLISION_WINDOW_MS,
- computeActorId,
- isActorLive,
- LEDGER_TTL_MS,
- ledgerFilePath,
- listOtherActorLedgerPaths,
- lookupPath,
- normalizeForLedger,
- pruneLedger,
- readActorLedger,
- resolveStoreRoot,
-} from '../_shared/active-edits-ledger.mts'
-import { block, defineHook, runHook } from '../_shared/guard.mts'
-import { readFilePath } from '../_shared/payload.mts'
-import type { ToolCallPayload } from '../_shared/payload.mts'
-import type { GuardResult } from '../_shared/guard.mts'
-
-function getProjectDir(): string {
- // oxlint-disable-next-line socket/no-process-cwd-in-scripts-hooks -- reads the agent-provided CLAUDE_PROJECT_DIR first; process.cwd() is only the fallback when that env var is absent
- return process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd()
-}
-
-/**
- * Pure decision core — injectable clock, no disk IO. Returns the collision
- * result when the target path was written by a live foreign actor within the
- * collision window; `undefined` (allow) otherwise.
- *
- * Factored out so tests can call it directly without real filesystem or
- * transcript IO.
- */
-export interface CollisionResult {
- readonly otherActorId: string
- readonly secondsAgo: number
-}
-
-export function detectCollision(
- ownActorId: string,
- normalizedPath: string,
- otherLedgerPaths: readonly string[],
- config: {
- now: number
- collisionWindowMs: number
- ttlMs: number
- // Own actor's last-write timestamp for this path, if any. When defined and
- // strictly newer than a foreign write, the foreign write is ignored — we
- // already re-claimed the file after the foreign actor wrote it.
- ownWriteTs?: number | undefined
- },
-): CollisionResult | undefined {
- const { now, collisionWindowMs, ttlMs, ownWriteTs } = {
- __proto__: null,
- ...config,
- } as typeof config
- for (let i = 0, { length } = otherLedgerPaths; i < length; i += 1) {
- const fp = otherLedgerPaths[i]!
- const raw = readActorLedger(fp)
- if (!raw) {
- continue
- }
- if (raw.actorId === ownActorId) {
- continue
- }
- if (!isActorLive(raw, { now, ttlMs })) {
- continue
- }
- const ledger = pruneLedger(raw, { now, ttlMs })
- if (!ledger) {
- continue
- }
- const lastWrite = lookupPath(ledger, normalizedPath)
- if (lastWrite === undefined) {
- continue
- }
- if (now - lastWrite > collisionWindowMs) {
- continue
- }
- // Own actor wrote this file MORE RECENTLY than the foreign actor → we
- // re-claimed it; the foreign write is superseded. Allow the re-edit.
- if (ownWriteTs !== undefined && ownWriteTs >= lastWrite) {
- continue
- }
- return {
- otherActorId: raw.actorId,
- secondsAgo: Math.round((now - lastWrite) / 1000),
- }
- }
- return undefined
-}
-
-export function check(payload: ToolCallPayload): GuardResult {
- const tool = payload?.tool_name
- if (tool !== 'Edit' && tool !== 'NotebookEdit' && tool !== 'Write') {
- return undefined
- }
-
- const filePath = readFilePath(payload)
- if (!filePath) {
- return undefined
- }
-
- const ownActorId = computeActorId(payload.transcript_path)
- if (!ownActorId) {
- return undefined
- }
-
- const projectDir = getProjectDir()
- const storeRoot = resolveStoreRoot(projectDir)
- const absPath = path.resolve(projectDir, filePath)
- const normalizedPath = normalizeForLedger(absPath)
-
- // Fail-open: if own ledger lookup is unavailable, skip any self-check.
- const ownFp = ledgerFilePath(storeRoot, ownActorId)
- const ownLedger = readActorLedger(ownFp)
- const now = Date.now()
- // Our own write timestamp for this path — used to determine recency vs foreign.
- const ownWrite = ownLedger ? lookupPath(ownLedger, normalizedPath) : undefined
-
- const otherPaths = listOtherActorLedgerPaths(storeRoot, ownActorId)
- if (!otherPaths.length) {
- return undefined
- }
-
- const collision = detectCollision(ownActorId, normalizedPath, otherPaths, {
- now,
- collisionWindowMs: COLLISION_WINDOW_MS,
- ttlMs: LEDGER_TTL_MS,
- ownWriteTs: ownWrite,
- })
- if (!collision) {
- return undefined
- }
-
- const shortPath = path.relative(projectDir, absPath)
- return block(
- [
- `🚨 live-edit-collision-guard: another live session last wrote this path`,
- `${collision.secondsAgo}s ago — editing now risks a blind overwrite.`,
- ``,
- `File: ${shortPath}`,
- `Other actor: ${collision.otherActorId}`,
- `Last write: ${collision.secondsAgo}s ago (within ${COLLISION_WINDOW_MS / 1000 / 60}-min collision window)`,
- ``,
- `Sanctioned moves:`,
- ` (a) Stop the other run first — use TaskStop, then resume via its`,
- ` journal (.claude/plans/...) once it has landed.`,
- ` (b) Queue this edit for after the other run completes; work on a`,
- ` different file in the meantime.`,
- ` (c) The other run is already finished or abandoned — the user`,
- ` supplies the bypass phrase to proceed.`,
- ``,
- ].join('\n'),
- )
-}
-
-export const hook = defineHook({
- bypass: ['live-edit-collision'],
- check,
- event: 'PreToolUse',
- matcher: ['Edit', 'NotebookEdit', 'Write'],
- type: 'guard',
-})
-
-void runHook(hook, import.meta.url)
diff --git a/.claude/hooks/fleet/lock-step-ref-nudge/README.md b/.claude/hooks/fleet/lock-step-ref-nudge/README.md
deleted file mode 100644
index 02964a1..0000000
--- a/.claude/hooks/fleet/lock-step-ref-nudge/README.md
+++ /dev/null
@@ -1,62 +0,0 @@
-# lock-step-ref-nudge
-
-PreToolUse hook (informational; never blocks) that flags malformed and stale `Lock-step` comments at the moment they land in a file.
-
-## Why
-
-Per CLAUDE.md's _Code style → Cross-port files_ rule, files that ship in multiple language implementations use a `Lock-step` comment convention to cross-reference the canonical impl. The full forms live in [`docs/agents.md/fleet/parser-comments.md`](../../../docs/agents.md/fleet/parser-comments.md) §5–6.
-
-The CI gate (`scripts/fleet/check/lock-step-refs-resolve.mts`) catches stale `` references at commit time, but two classes of bugs slip past it:
-
-1. **Typos in the `Lock-step` shape itself** — `lockstep`, `Lock step`, `Lock-step Rust:` (missing `with`/`from`), `Lock-step with: ` (missing ``). The CI regex doesn't match these, so they silently rot forever as illegitimate comments.
-2. **Same-keystroke staleness** — a porter typing `// Lock-step with Rust: crates/parser-stmt/src/foo.rs` after `parser-stmt/` was renamed last week. The CI gate catches it at commit; the hook catches it at the keystroke so the porter sees the breadcrumb before committing.
-
-## What it catches
-
-**Malformed:**
-
-```rust
-// lockstep with Go: parser.go:42 // wrong: hyphen missing
-// Lock step with Go: parser.go:42 // wrong: hyphen missing
-// Lock-step Rust: src/foo.rs // wrong: missing with/from
-// Lock-step with: src/foo.rs // wrong: missing
-// Lock-step with Go, parser.go // wrong: comma instead of colon
-```
-
-**Stale (when `.config/lock-step-refs.json` is present):**
-
-```rust
-// Lock-step with Rust: crates/parser-stmt/src/foo.rs // crate doesn't exist
-//! Lock-step from Go: src/parser-old/class.go // dir was renamed
-```
-
-**Accepted:**
-
-```rust
-//! Lock-step with Go: src/parser/class.go
-//! Lock-step from Rust: crates/parser/src/class.rs
-// Lock-step with Go: parser.go:6450-6457
-// Lock-step note: reshaped for borrowck — Zig's `defer s.restore()` ...
-```
-
-## Scope
-
-- Source-file extensions: `.rs`, `.go`, `.cpp`, `.hpp`, `.h`, `.ts`, `.mts`, `.cts`, `.tsx`, `.py`, `.zig`, `.js`, `.mjs`, `.cjs`, `.jsx`.
-- Skips `test/` directories and `*.test.*` files — illustrative example refs are common in tests and don't represent real port-tracking claims.
-- Stale-path checking is **opt-in per repo**: requires `.config/lock-step-refs.json` to declare `` → impl-root mappings. Without the config, only malformed-shape detection runs.
-- Malformed-shape detection always runs, regardless of opt-in. Typos are typos.
-
-## Behavior
-
-- Exit code 0 in all cases. Hook is informational; the next turn sees the stderr breadcrumb and can fix.
-- The blocking layer is the CI gate `scripts/fleet/check/lock-step-refs-resolve.mts`, run by `pnpm check`.
-
-## Bypass
-
-- Type `Allow lock-step bypass` in a recent user message (also accepts `Allow lockstep bypass` / `Allow lock step bypass`).
-
-## Test
-
-```sh
-pnpm test
-```
diff --git a/.claude/hooks/fleet/lock-step-ref-nudge/index.mts b/.claude/hooks/fleet/lock-step-ref-nudge/index.mts
deleted file mode 100644
index 799f57d..0000000
--- a/.claude/hooks/fleet/lock-step-ref-nudge/index.mts
+++ /dev/null
@@ -1,358 +0,0 @@
-#!/usr/bin/env node
-// Claude Code PreToolUse hook — lock-step-ref-nudge.
-//
-// renamed-from: lock-step-ref-guard
-//
-// Flags two failure modes in `Lock-step` comments at the moment they
-// land in a file, before they reach CI (which is gated separately by
-// `scripts/fleet/check/lock-step-refs-resolve.mts`):
-//
-// 1. STALE — the comment names a path that no longer exists in the
-// target impl. The CI gate also catches this; the hook catches it
-// one keystroke earlier so the porter can fix as they type.
-// 2. MALFORMED — the comment uses an almost-right shape (`lockstep`,
-// `Lock step`, `Lock-step Go:` missing `with`/`from`, missing the
-// `: ` separator). These wouldn't be matched by the
-// CI scanner at all — they'd silently rot forever. The hook is
-// the only place that catches the typo class.
-//
-// Convention spec: `docs/agents.md/fleet/parser-comments.md` §5–6.
-// Recognized forms:
-//
-// //! Lock-step with : canonical side
-// //! Lock-step from : port side
-// // Lock-step with : [:], inline cross-ref
-// // Lock-step note: (rationale; not validated)
-//
-// Behavior:
-// - Never blocks. Hook is informational; the breadcrumb in stderr is
-// the next-turn nudge. The blocking layer is the CI gate in
-// `pnpm check`.
-// - Opt-in per repo: when `.config/repo/lock-step-refs.json` is absent,
-// STALE checks are skipped (the gate is disabled at the repo
-// level). MALFORMED checks always run — they detect typos
-// regardless of whether the repo has opted into validation.
-// - Only fires for the new content the edit introduces. Comments
-// that were already in the file but unchanged aren't re-flagged.
-//
-// Scope:
-// - Source-file extensions: .rs, .go, .cpp, .hpp, .h, .ts, .mts,
-// .cts, .tsx, .py, .zig, .js, .mjs, .cjs, .jsx.
-// - Skips test/ directories and *.test.* files — illustrative
-// example refs are common in tests.
-//
-// Bypass: type `Allow lock-step bypass` in a recent user message.
-
-import { existsSync, readFileSync } from 'node:fs'
-import path from 'node:path'
-
-import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize'
-
-import { defineHook, editGuard, notify, runHook } from '../_shared/guard.mts'
-import { resolveProjectDir } from '../_shared/project-dir.mts'
-
-interface LockStepConfig {
- readonly roots: Readonly>
- readonly scan: readonly string[]
- readonly extensions: readonly string[]
-}
-
-const SOURCE_EXT_RE =
- /\.(?:cjs|cpp|cts|go|h|hh|hpp|js|jsx|mjs|mts|py|rs|ts|tsx|zig)$/
-
-// Canonical form: `Lock-step (with|from) : [:]`.
-// Path must contain `.` or `/` so prose like "Lock-step with Go: JSON
-// parser" doesn't false-positive.
-const CANONICAL_RE =
- /Lock-step (?