`) — 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 (?