Skip to content

Commit dd2e76f

Browse files
committed
chore(sync): cascade fleet template@8675f41
Auto-applied by socket-wheelhouse sync-scaffolding into cascade-socket-cli-58809. 8 file(s) touched: - .claude/hooks/readme-fleet-shape-guard/README.md - .claude/hooks/readme-fleet-shape-guard/index.mts - .claude/hooks/readme-fleet-shape-guard/package.json - .claude/hooks/readme-fleet-shape-guard/test/index.test.mts - .claude/hooks/readme-fleet-shape-guard/tsconfig.json - .claude/settings.json - .claude/skills/cascading-fleet/SKILL.md - .claude/skills/cascading-fleet/lib/cascade-template.mts
1 parent 37444fe commit dd2e76f

8 files changed

Lines changed: 808 additions & 2 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# readme-fleet-shape-guard
2+
3+
PreToolUse Edit/Write hook that blocks edits to the **root `README.md`** when the resulting content violates the canonical fleet skeleton.
4+
5+
## Why
6+
7+
Root READMEs across fleet repos drift in three predictable ways: (a) the canonical 5-section structure gets reordered or partially missing, (b) `socket-wheelhouse` (a private repo) leaks into prose or links, (c) commands invoke sibling-repo relative paths (`node ../socket-foo/scripts/...`) that outside readers can't follow. All three are public-facing failure modes.
8+
9+
The fleet has matching surfaces at three layers:
10+
11+
- **Lint-time**`template/.config/markdownlint-rules/socket-{readme-required-sections, no-private-wheelhouse-leak, no-relative-sibling-script}.mjs`.
12+
- **Sync-time**`scripts/sync-scaffolding/checks/readme-skeleton-drift.mts` (report-only; no autofix because README content is contextual).
13+
- **Edit-time** — this hook. Fires at the earliest surface, before the drift can be committed or pushed.
14+
15+
## How
16+
17+
On `Edit` / `MultiEdit` / `Write` whose `file_path` resolves to the repo-root `README.md`, the hook:
18+
19+
1. Reconstructs the post-edit text (Write → `content`; Edit → splice `old_string``new_string` against the on-disk file).
20+
2. Runs three checks: section list (5 required, in order); `socket-wheelhouse` mention (outside fenced code blocks); sibling-repo relative path patterns.
21+
3. If any check fires AND the user hasn't typed the bypass phrase, exits 2 with a stderr explaining which rule was hit, the canonical fix, and the bypass instructions.
22+
23+
Nested READMEs (`packages/*/README.md`, `docs/*/README.md`, etc.) are silently ignored — they're scoped docs with their own shape.
24+
25+
## Bypass
26+
27+
User types **`Allow readme-fleet-shape bypass`** verbatim in a recent message (within the last 8 user turns). Case-sensitive; paraphrases don't count.
28+
29+
## Failing open
30+
31+
The hook fails open on its own bugs (exit 0 + stderr log) so a buggy hook can't brick the session. The trade-off: a bug means the check silently doesn't apply for that edit. The sync-time check and the lint-time check still catch the drift later.
32+
33+
## Related
34+
35+
- `.claude/hooks/no-meta-comments-guard/` — structural template; same `_shared/transcript.mts` bypass pattern.
36+
- `.claude/hooks/plan-location-guard/` — same PreToolUse + bypass shape, blocking on file-path classification.
Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
#!/usr/bin/env node
2+
// Claude Code PreToolUse hook — readme-fleet-shape-guard.
3+
//
4+
// Blocks Edit/Write of the root README.md when the resulting content
5+
// violates the canonical fleet skeleton:
6+
//
7+
// (a) Missing or out-of-order canonical section. The 5 level-2
8+
// sections must appear in this order:
9+
// Why this repo exists / Install / Usage / Development / License
10+
//
11+
// (b) Mentions `socket-wheelhouse` outside fenced code blocks.
12+
// socket-wheelhouse is a private repo; the link 404s for outside
13+
// readers.
14+
//
15+
// (c) Invokes a command against a sibling-repo relative path.
16+
// `node ../socket-foo/scripts/...` and similar shapes assume the
17+
// reader has the sibling repo checked out at exactly the right
18+
// relative level — almost never true for an outside user.
19+
//
20+
// Only fires on the REPO-ROOT README.md (basename === 'README.md' AND
21+
// directory is repo root). Nested READMEs (packages/, docs/, .claude/,
22+
// etc.) are scoped docs with their own shape; this hook is silent for
23+
// them.
24+
//
25+
// Bypass phrase: `Allow readme-fleet-shape bypass`. Reading recent user
26+
// turns follows the same pattern as no-revert-guard, plan-location-guard.
27+
//
28+
// Companion to:
29+
// - scripts/sync-scaffolding/checks/readme-skeleton-drift.mts
30+
// (sync-time check, no autofix)
31+
// - template/.config/markdownlint-rules/socket-{readme-required-sections,
32+
// no-private-wheelhouse-leak, no-relative-sibling-script}.mjs
33+
// (lint-time check)
34+
//
35+
// This hook is the edit-time enforcement — it fires when the README is
36+
// being written, catching the failure mode at its earliest surface.
37+
//
38+
// Reads a Claude Code PreToolUse JSON payload from stdin:
39+
// { "tool_name": "Edit" | "MultiEdit" | "Write",
40+
// "tool_input": { "file_path": "...",
41+
// "content"?: "...",
42+
// "new_string"?: "...",
43+
// "old_string"?: "..." },
44+
// "transcript_path": "/.../session.jsonl" }
45+
//
46+
// Exits:
47+
// 0 — allowed.
48+
// 2 — blocked (with stderr message that explains rule + fix + bypass).
49+
// 0 (with stderr log) — fail-open on hook bugs.
50+
51+
import { existsSync, readFileSync } from 'node:fs'
52+
import path from 'node:path'
53+
import process from 'node:process'
54+
55+
import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts'
56+
57+
type ToolInput = {
58+
tool_input?:
59+
| {
60+
content?: string | undefined
61+
file_path?: string | undefined
62+
new_string?: string | undefined
63+
old_string?: string | undefined
64+
}
65+
| undefined
66+
tool_name?: string | undefined
67+
transcript_path?: string | undefined
68+
}
69+
70+
const BYPASS_PHRASE = 'Allow readme-fleet-shape bypass'
71+
const BYPASS_LOOKBACK_USER_TURNS = 8
72+
73+
const REQUIRED_SECTIONS = [
74+
'Why this repo exists',
75+
'Install',
76+
'Usage',
77+
'Development',
78+
'License',
79+
] as const
80+
81+
const WHEELHOUSE_LEAK_RE = /socket-wheelhouse/i
82+
const SIBLING_PATH_RES: readonly RegExp[] = [
83+
/\b(?:bun|deno|node|npm|pnpm|yarn)\s+\.\.\/[\w@-]+\//,
84+
// socket-hook: allow regex-alternation-order
85+
/(?:^|\s)\.\.\/socket-[\w-]+\//i,
86+
// socket-hook: allow regex-alternation-order
87+
/(?:^|\s)\.\.\/sdxgen\//,
88+
// socket-hook: allow regex-alternation-order
89+
/(?:^|\s)\.\.\/stuie\//,
90+
]
91+
92+
/**
93+
* Repo-root README detection. The hook only fires on the root README.md,
94+
* not nested READMEs. The check is path-shape only — basename match +
95+
* parent directory ≠ another README's parent.
96+
*/
97+
export function isRootReadme(filePath: string): boolean {
98+
const normalized = filePath.replace(/\\/g, '/')
99+
if (path.basename(normalized) !== 'README.md') {
100+
return false
101+
}
102+
const dir = path.dirname(normalized)
103+
// Nested-README markers: any path segment that says "this is a
104+
// scoped doc, not the repo root."
105+
const segments = dir.split('/').filter(Boolean)
106+
const SCOPED_PARENTS = new Set([
107+
'.claude',
108+
'apps',
109+
'crates',
110+
'docs',
111+
'examples',
112+
'packages',
113+
'pkg-node',
114+
'scripts',
115+
'template',
116+
'test',
117+
'tests',
118+
])
119+
for (const seg of segments) {
120+
if (SCOPED_PARENTS.has(seg)) {
121+
return false
122+
}
123+
}
124+
return true
125+
}
126+
127+
/**
128+
* Compute the post-edit text for an Edit (splice old_string → new_string
129+
* against the on-disk file) or a Write (just `content`). Returns
130+
* undefined when the post-edit text can't be reliably computed (Edit
131+
* against a file that doesn't exist, or old_string not found).
132+
*/
133+
export function computePostEditText(
134+
toolName: string,
135+
filePath: string,
136+
newString: string | undefined,
137+
oldString: string | undefined,
138+
content: string | undefined,
139+
): string | undefined {
140+
if (toolName === 'Write') {
141+
return content
142+
}
143+
if (toolName === 'Edit' || toolName === 'MultiEdit') {
144+
if (!existsSync(filePath)) {
145+
// Edit against a non-existent file is unusual; let it through.
146+
return undefined
147+
}
148+
let onDisk: string
149+
try {
150+
onDisk = readFileSync(filePath, 'utf8')
151+
} catch {
152+
return undefined
153+
}
154+
if (oldString === undefined || newString === undefined) {
155+
return undefined
156+
}
157+
const idx = onDisk.indexOf(oldString)
158+
if (idx === -1) {
159+
return undefined
160+
}
161+
return (
162+
onDisk.slice(0, idx) + newString + onDisk.slice(idx + oldString.length)
163+
)
164+
}
165+
return undefined
166+
}
167+
168+
interface ShapeFinding {
169+
kind: 'missing-section' | 'wheelhouse-leak' | 'relative-sibling'
170+
detail: string
171+
}
172+
173+
export function findShapeViolations(text: string): ShapeFinding[] {
174+
const lines = text.split('\n')
175+
const findings: ShapeFinding[] = []
176+
177+
const headings: string[] = []
178+
for (let i = 0, { length } = lines; i < length; i += 1) {
179+
const m = /^##\s+(.+?)\s*$/.exec(lines[i] ?? '')
180+
if (m && m[1]) {
181+
headings.push(m[1])
182+
}
183+
}
184+
let cursor = 0
185+
for (let r = 0, { length } = REQUIRED_SECTIONS; r < length; r += 1) {
186+
const want = REQUIRED_SECTIONS[r]
187+
let found = -1
188+
for (let h = cursor; h < headings.length; h += 1) {
189+
if (headings[h] === want) {
190+
found = h
191+
break
192+
}
193+
}
194+
if (found === -1) {
195+
findings.push({
196+
kind: 'missing-section',
197+
detail: `Missing canonical section "## ${want}" (or out of order)`,
198+
})
199+
break
200+
}
201+
cursor = found + 1
202+
}
203+
204+
let inFence = false
205+
for (let i = 0, { length } = lines; i < length; i += 1) {
206+
const line = lines[i] ?? ''
207+
if (/^\s*(?:```|~~~)/.test(line)) {
208+
inFence = !inFence
209+
continue
210+
}
211+
if (inFence) {
212+
continue
213+
}
214+
if (WHEELHOUSE_LEAK_RE.test(line)) {
215+
findings.push({
216+
kind: 'wheelhouse-leak',
217+
detail: `Line ${i + 1} mentions socket-wheelhouse: ${line.trim().slice(0, 120)}`,
218+
})
219+
break
220+
}
221+
}
222+
223+
for (let i = 0, { length } = lines; i < length; i += 1) {
224+
const line = lines[i] ?? ''
225+
let matched = false
226+
for (let j = 0, jl = SIBLING_PATH_RES.length; j < jl; j += 1) {
227+
if (SIBLING_PATH_RES[j]!.test(line)) {
228+
matched = true
229+
break
230+
}
231+
}
232+
if (matched) {
233+
findings.push({
234+
kind: 'relative-sibling',
235+
detail: `Line ${i + 1} invokes a sibling-relative path: ${line.trim().slice(0, 120)}`,
236+
})
237+
break
238+
}
239+
}
240+
241+
return findings
242+
}
243+
244+
async function main(): Promise<number> {
245+
const raw = await readStdin()
246+
if (!raw.trim()) {
247+
return 0
248+
}
249+
250+
let payload: ToolInput
251+
try {
252+
payload = JSON.parse(raw) as ToolInput
253+
} catch {
254+
process.stderr.write(
255+
'readme-fleet-shape-guard: failed to parse stdin payload — fail-open\n',
256+
)
257+
return 0
258+
}
259+
260+
const tool = payload.tool_name
261+
if (tool !== 'Edit' && tool !== 'MultiEdit' && tool !== 'Write') {
262+
return 0
263+
}
264+
265+
const filePath = payload.tool_input?.file_path
266+
if (!filePath || !isRootReadme(filePath)) {
267+
return 0
268+
}
269+
270+
const postEdit = computePostEditText(
271+
tool,
272+
filePath,
273+
payload.tool_input?.new_string,
274+
payload.tool_input?.old_string,
275+
payload.tool_input?.content,
276+
)
277+
if (postEdit === undefined) {
278+
return 0
279+
}
280+
281+
const findings = findShapeViolations(postEdit)
282+
if (findings.length === 0) {
283+
return 0
284+
}
285+
286+
if (
287+
bypassPhrasePresent(
288+
payload.transcript_path,
289+
BYPASS_PHRASE,
290+
BYPASS_LOOKBACK_USER_TURNS,
291+
)
292+
) {
293+
return 0
294+
}
295+
296+
const lines: string[] = [
297+
`🚨 readme-fleet-shape-guard: blocked Edit/Write of root README.md.`,
298+
``,
299+
`File: ${filePath}`,
300+
``,
301+
`Violations:`,
302+
]
303+
for (let i = 0, { length } = findings; i < length; i += 1) {
304+
lines.push(` - ${findings[i]!.detail}`)
305+
}
306+
lines.push(``)
307+
lines.push(
308+
`Per the fleet "Canonical README" rule (CLAUDE.md → Canonical README),`,
309+
)
310+
lines.push(`root README.md must follow the skeleton at:`)
311+
lines.push(` socket-wheelhouse/template/README.md`)
312+
lines.push(``)
313+
lines.push(`Required sections in order:`)
314+
for (let i = 0, { length } = REQUIRED_SECTIONS; i < length; i += 1) {
315+
lines.push(` ${i + 1}. ## ${REQUIRED_SECTIONS[i]}`)
316+
}
317+
lines.push(``)
318+
lines.push(
319+
`One-shot bypass (rare): user types "${BYPASS_PHRASE}" verbatim in a recent message.`,
320+
)
321+
lines.push(``)
322+
process.stderr.write(`${lines.join('\n')}`)
323+
return 2
324+
}
325+
326+
main().then(
327+
code => process.exit(code),
328+
err => {
329+
process.stderr.write(
330+
`readme-fleet-shape-guard: hook error — fail-open: ${String(err)}\n`,
331+
)
332+
process.exit(0)
333+
},
334+
)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "hook-readme-fleet-shape-guard",
3+
"private": true,
4+
"type": "module",
5+
"main": "./index.mts",
6+
"exports": {
7+
".": "./index.mts"
8+
},
9+
"scripts": {
10+
"test": "node --test test/*.test.mts"
11+
},
12+
"dependencies": {
13+
"@socketsecurity/lib-stable": "catalog:"
14+
},
15+
"devDependencies": {
16+
"@types/node": "catalog:"
17+
}
18+
}

0 commit comments

Comments
 (0)