Skip to content

Commit 1deb48d

Browse files
committed
chore(wheelhouse): cascade template@7dbe4abc
1 parent e96366d commit 1deb48d

71 files changed

Lines changed: 3557 additions & 2100 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/hooks/fleet/_shared/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Helper modules shared across multiple hooks under `.claude/hooks/`. **Not a depl
2222

2323
## When to reach for what (new hook quick-reference)
2424

25-
- Writing a **Stop hook** that just emits a reminder when patterns match? → `import { runStopReminder } from '../_shared/stop-reminder.mts'`. See `comment-tone-reminder` or `excuse-detector` for the shape.
25+
- Writing a **Stop hook** that just emits a reminder when patterns match? → `import { runStopReminder } from '../_shared/stop-reminder.mts'`. See `excuse-detector` for the single-group shape, or `prose-tone-reminder` (uses `runStopReminders`) for merging several pattern tables into one process while keeping per-group disable env vars.
2626

2727
- Writing a **PreToolUse hook** that inspects a tool call's input? → `import { ToolCallPayload, readCommand, readFilePath } from '../_shared/payload.mts'`. Saves you the `typeof === 'string'` guard.
2828

.claude/hooks/fleet/_shared/hook-env.mts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ import process from 'node:process'
2525
* env-var name in their disable hint.
2626
*
2727
* HookDisableEnvVar('no-revert-guard') → 'SOCKET_NO_REVERT_GUARD_DISABLED'
28-
* hookDisableEnvVar('comment-tone-reminder') →
29-
* 'SOCKET_COMMENT_TONE_REMINDER_DISABLED'
28+
* hookDisableEnvVar('prose-tone-reminder') →
29+
* 'SOCKET_PROSE_TONE_REMINDER_DISABLED'
3030
* hookDisableEnvVar('auth-rotation-reminder') →
3131
* 'SOCKET_AUTH_ROTATION_REMINDER_DISABLED'
3232
*/

.claude/hooks/fleet/_shared/stop-reminder.mts

Lines changed: 207 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import process from 'node:process'
1717
import {
1818
readLastAssistantText,
1919
readStdin,
20+
readUserText,
2021
stripCodeFences,
2122
stripQuotedSpans,
2223
} from './transcript.mts'
@@ -90,6 +91,121 @@ export interface ReminderConfig {
9091
readonly stripQuotedSpans?: boolean | undefined
9192
}
9293

94+
/**
95+
* A reminder rule-group for the multiplexed `runStopReminders`. Same shape as
96+
* ReminderConfig minus the process-lifecycle bits (name + per-group env var +
97+
* patterns + hint); `blocking` is intentionally absent — a multiplexed group is
98+
* informational only (mixing block + non-block decisions across groups in one
99+
* process can't emit a single coherent Stop decision).
100+
*/
101+
export interface ReminderGroup {
102+
readonly name: string
103+
readonly disabledEnvVar: string
104+
readonly patterns: readonly RuleViolation[]
105+
readonly closingHint?: string | undefined
106+
readonly stripQuotedSpans?: boolean | undefined
107+
}
108+
109+
/**
110+
* Scan `text` against a pattern list (+ optional extraCheck), returning hits.
111+
* The pure core shared by `runStopReminder` and `runStopReminders`.
112+
*/
113+
export async function scanReminderText(
114+
text: string,
115+
patterns: readonly RuleViolation[],
116+
extraCheck?: ReminderConfig['extraCheck'],
117+
): Promise<ReminderHit[]> {
118+
const hits: ReminderHit[] = []
119+
for (let i = 0, { length } = patterns; i < length; i += 1) {
120+
const pattern = patterns[i]!
121+
const match = pattern.regex.exec(text)
122+
if (!match) {
123+
continue
124+
}
125+
hits.push({
126+
label: pattern.label,
127+
why: pattern.why,
128+
snippet: extractSnippet(text, match.index, match[0].length),
129+
})
130+
}
131+
if (extraCheck) {
132+
try {
133+
const extra = await extraCheck(text)
134+
for (let i = 0, { length } = extra; i < length; i += 1) {
135+
hits.push(extra[i]!)
136+
}
137+
} catch {
138+
// Fail-open: a buggy extra-check must not suppress the regex hits.
139+
}
140+
}
141+
return hits
142+
}
143+
144+
/**
145+
* Format the stderr block for one group's hits.
146+
*/
147+
export function formatReminderBlock(
148+
name: string,
149+
hits: readonly ReminderHit[],
150+
closingHint?: string | undefined,
151+
): string {
152+
const lines = [
153+
`[${name}] Assistant turn matched reminder patterns:`,
154+
'',
155+
...hits.flatMap(h => [` • "${h.label}" — ${h.snippet}`, ` ${h.why}`]),
156+
]
157+
if (closingHint) {
158+
lines.push('', ` ${closingHint}`)
159+
}
160+
lines.push('')
161+
return lines.join('\n')
162+
}
163+
164+
/**
165+
* Run several informational reminder groups in ONE Stop-hook process. Reads
166+
* stdin + the most-recent assistant turn once, then scans each group whose
167+
* `disabledEnvVar` is unset — preserving per-group disabling exactly as if each
168+
* were its own hook. Emits one stderr block per group with hits. Always exits 0.
169+
* Use when merging pure-table reminders to cut process count without losing the
170+
* granular disable env vars.
171+
*/
172+
export async function runStopReminders(
173+
groups: readonly ReminderGroup[],
174+
): Promise<void> {
175+
const payloadRaw = await readStdin()
176+
let payload: StopPayload
177+
try {
178+
payload = JSON.parse(payloadRaw) as StopPayload
179+
} catch {
180+
process.exit(0)
181+
}
182+
const rawText = readLastAssistantText(payload.transcript_path)
183+
if (!rawText) {
184+
process.exit(0)
185+
}
186+
const fencesStripped = stripCodeFences(rawText)
187+
const blocks: string[] = []
188+
for (let i = 0, { length } = groups; i < length; i += 1) {
189+
const group = groups[i]!
190+
if (process.env[group.disabledEnvVar]) {
191+
continue
192+
}
193+
const text = group.stripQuotedSpans
194+
? stripQuotedSpans(fencesStripped)
195+
: fencesStripped
196+
// eslint-disable-next-line no-await-in-loop
197+
const hits = await scanReminderText(text, group.patterns)
198+
if (hits.length > 0) {
199+
blocks.push(formatReminderBlock(group.name, hits, group.closingHint))
200+
}
201+
}
202+
if (blocks.length === 0) {
203+
process.exit(0)
204+
}
205+
process.stderr.write(blocks.join('\n') + '\n')
206+
process.exit(0)
207+
}
208+
93209
/**
94210
* Run a Stop-hook reminder. Reads stdin, scans the most-recent assistant turn,
95211
* and writes hits to stderr. Always exits 0.
@@ -115,51 +231,13 @@ export async function runStopReminder(config: ReminderConfig): Promise<void> {
115231
? stripQuotedSpans(fencesStripped)
116232
: fencesStripped
117233

118-
const hits: ReminderHit[] = []
119-
const { patterns } = config
120-
const { length: patternsLength } = patterns
121-
for (let i = 0; i < patternsLength; i += 1) {
122-
const pattern = patterns[i]!
123-
const match = pattern.regex.exec(text)
124-
if (!match) {
125-
continue
126-
}
127-
hits.push({
128-
label: pattern.label,
129-
why: pattern.why,
130-
snippet: extractSnippet(text, match.index, match[0].length),
131-
})
132-
}
133-
134-
if (config.extraCheck) {
135-
try {
136-
const extra = await config.extraCheck(text)
137-
for (
138-
let i = 0, { length: extraLength } = extra;
139-
i < extraLength;
140-
i += 1
141-
) {
142-
hits.push(extra[i]!)
143-
}
144-
} catch {
145-
// Fail-open: a buggy extra-check must not suppress the regex hits.
146-
}
147-
}
234+
const hits = await scanReminderText(text, config.patterns, config.extraCheck)
148235

149236
if (hits.length === 0) {
150237
process.exit(0)
151238
}
152239

153-
const lines = [
154-
`[${config.name}] Assistant turn matched reminder patterns:`,
155-
'',
156-
...hits.flatMap(h => [` • "${h.label}" — ${h.snippet}`, ` ${h.why}`]),
157-
]
158-
if (config.closingHint) {
159-
lines.push('', ` ${config.closingHint}`)
160-
}
161-
lines.push('')
162-
const message = lines.join('\n')
240+
const message = formatReminderBlock(config.name, hits, config.closingHint)
163241

164242
// Blocking mode: emit a Stop-hook block decision so Claude must
165243
// continue the turn and address the matched phrase. Suppressed
@@ -176,3 +254,92 @@ export async function runStopReminder(config: ReminderConfig): Promise<void> {
176254
process.stderr.write(message + '\n')
177255
process.exit(0)
178256
}
257+
258+
/**
259+
* Config for a turn-pair reminder: fires only when the last USER turn matches a
260+
* trigger AND the most-recent ASSISTANT turn matches a deflection. The shape
261+
* shared by answer-passing-questions / answer-status-requests /
262+
* follow-direct-imperative — "user asked X, assistant did Y instead".
263+
*/
264+
/**
265+
* A turn-pair matcher. `label` + `why` describe it; matching is by `regex`
266+
* OR — when the rule needs logic a regex can't express (word-count bounds,
267+
* first-word-only, question filtering, like follow-direct-imperative's
268+
* `looksLikeImperative`) — by an explicit `match` predicate. Exactly one of
269+
* `regex` / `match` is consulted (`match` wins when present).
270+
*/
271+
export interface TurnPairRule {
272+
readonly label: string
273+
readonly why: string
274+
readonly regex?: RegExp | undefined
275+
readonly match?: ((text: string) => boolean) | undefined
276+
}
277+
278+
export interface TurnPairConfig {
279+
readonly name: string
280+
readonly disabledEnvVar: string
281+
readonly userTriggers: readonly TurnPairRule[]
282+
readonly assistantDeflections: readonly TurnPairRule[]
283+
readonly closingHint?: string | undefined
284+
}
285+
286+
function turnPairMatches(rule: TurnPairRule, text: string): boolean {
287+
if (rule.match) {
288+
return rule.match(text)
289+
}
290+
return rule.regex ? rule.regex.test(text) : false
291+
}
292+
293+
/**
294+
* Run a turn-pair Stop reminder. Reads the last user turn + the most-recent
295+
* assistant turn (via transcript.mts — no per-hook re-implementation of
296+
* JSONL parsing / role detection / content flattening), and emits a reminder
297+
* only when BOTH a user trigger and an assistant deflection match. Always
298+
* exits 0. The fired message names the matched trigger + deflection so the
299+
* reader sees what pair tripped it.
300+
*/
301+
export async function runTurnPairReminder(
302+
config: TurnPairConfig,
303+
): Promise<void> {
304+
const payloadRaw = await readStdin()
305+
if (process.env[config.disabledEnvVar]) {
306+
process.exit(0)
307+
}
308+
let payload: StopPayload
309+
try {
310+
payload = JSON.parse(payloadRaw) as StopPayload
311+
} catch {
312+
process.exit(0)
313+
}
314+
const userText = stripCodeFences(readUserText(payload.transcript_path, 1))
315+
const assistantText = stripCodeFences(
316+
readLastAssistantText(payload.transcript_path),
317+
)
318+
if (!userText || !assistantText) {
319+
process.exit(0)
320+
}
321+
const trigger = config.userTriggers.find(p => turnPairMatches(p, userText))
322+
if (!trigger) {
323+
process.exit(0)
324+
}
325+
const deflection = config.assistantDeflections.find(p =>
326+
turnPairMatches(p, assistantText),
327+
)
328+
if (!deflection) {
329+
process.exit(0)
330+
}
331+
const userPreview = userText.trim().slice(0, 60).replace(/\s+/g, ' ')
332+
const lines = [
333+
`[${config.name}] User asked, assistant deflected:`,
334+
'',
335+
` User trigger: "${trigger.label}" — "${userPreview}"`,
336+
` Assistant deflection: "${deflection.label}"`,
337+
` ${deflection.why}`,
338+
]
339+
if (config.closingHint) {
340+
lines.push('', ` ${config.closingHint}`)
341+
}
342+
lines.push('')
343+
process.stderr.write(lines.join('\n') + '\n')
344+
process.exit(0)
345+
}

.claude/hooks/fleet/actionlint-on-workflow-edit/index.mts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
// preinstalled, but downstreams may not.
2323

2424
import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child'
25-
import process from 'node:process'
2625

2726
import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default'
2827

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# c8-ignore-reason-guard
2+
3+
PreToolUse (Edit|Write) hook. Blocks introducing a `/* c8 ignore … */` or
4+
`/* v8 ignore … */` coverage-ignore directive that carries no reason.
5+
6+
## Why
7+
8+
The fleet rule (`docs/claude.md/fleet/c8-ignore-directives.md`): a coverage
9+
ignore is for external-library paths and genuinely-unreachable branches only,
10+
and every directive must state *why* in the same comment. A reason lets a
11+
reader distinguish a principled ignore from a coverage dodge on core SDK logic
12+
(which is forbidden — write a test instead).
13+
14+
## Triggers
15+
16+
- A `c8`/`v8` `ignore next`/`ignore start` directive with no `- <reason>` /
17+
`— <reason>` trailing text, in a `.ts`/`.mts`/`.cts`/`.js`/… source file.
18+
- `ignore stop` is exempt (its paired `start` carries the reason).
19+
- `test/`, `fixtures/`, `external/`, `vendor/` paths are exempt.
20+
21+
## Bypass
22+
23+
- Type `Allow c8-ignore-reason bypass` in a recent message, or set
24+
`SOCKET_C8_IGNORE_REASON_GUARD_DISABLED=1`.

0 commit comments

Comments
 (0)