Skip to content

Commit a5cd45a

Browse files
committed
chore(wheelhouse): cascade template@cf3f2d2
Auto-applied by socket-wheelhouse sync-scaffolding into socket-cli. 26 file(s) touched: - .claude/hooks/dirty-worktree-on-stop-reminder/index.mts - .claude/hooks/dirty-worktree-on-stop-reminder/test/index.test.mts - .claude/hooks/follow-direct-imperative-reminder/README.md - .claude/hooks/follow-direct-imperative-reminder/index.mts - .claude/hooks/follow-direct-imperative-reminder/package.json - .claude/hooks/follow-direct-imperative-reminder/test/index.test.mts - .claude/hooks/follow-direct-imperative-reminder/tsconfig.json - .claude/hooks/immutable-release-pattern-guard/README.md - .claude/settings.json - .claude/skills/prose/SKILL.md - .claude/skills/prose/references/examples.md - .claude/skills/prose/references/phrases.md - .claude/skills/prose/references/structures.md - .config/oxlint-plugin/rules/prefer-non-capturing-group.mts - .config/oxlint-plugin/test/prefer-non-capturing-group.test.mts - .config/socket-registry-pins.json - .git-hooks/_helpers.mts - .git-hooks/commit-msg.mts - .git-hooks/test/_helpers.test.mts - .github/workflows/ci.yml ... and 6 more
1 parent ab1e49b commit a5cd45a

26 files changed

Lines changed: 1550 additions & 103 deletions

File tree

.claude/hooks/dirty-worktree-on-stop-reminder/index.mts

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.claude/hooks/dirty-worktree-on-stop-reminder/test/index.test.mts

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# follow-direct-imperative-reminder
2+
3+
Stop hook that flags assistant turns which respond to a bare imperative user command with hedging or re-litigation before the tool call.
4+
5+
## Why
6+
7+
CLAUDE.md "Judgment & self-evaluation" rule:
8+
9+
> Direct imperatives → execute, don't litigate. When the user issues a bare command ("use nvm 26.2.0", "cancel the build", "do it", "kill it"), the response is the tool call, not a paragraph weighing trade-offs.
10+
11+
Past incident (the trigger for this hook): user typed "use nvm use 26.2.0". Assistant responded with a paragraph explaining why it wouldn't help the in-flight build, instead of switching Node. Same turn the user typed "cancel the build right now". Assistant kept narrating build phases instead of killing the process. User asked for a hook to stop the behavior.
12+
13+
The failure mode is analysis-before-action when the command was unambiguous. The user already weighed the trade-off. Re-litigating wastes a turn and signals the directive was optional. It wasn't.
14+
15+
## Detection
16+
17+
Two-signal rule, both must hit:
18+
19+
1. **Previous user turn is a bare imperative.** Single short sentence (≤ 8 words), starts with an action verb (`cancel`, `kill`, `use`, `run`, `commit`, `push`, `do`, `continue`, etc.) or common imperative phrase (`let's`, `just`, `please`). No question mark (questions invite analysis).
20+
2. **Assistant turn contains hedge / re-litigation markers**:
21+
- `doesn't help` / `won't help`
22+
- `before I do that` / `let me explain` / `let me first`
23+
- `to be clear` / `worth noting` / `that said` / `actually`
24+
- `the in-flight X` (re-litigating in-flight state)
25+
- `caveat:` / `note:` / `important:`
26+
27+
Both signals fire: stderr reminder lands in the next turn's context.
28+
29+
## What it does NOT catch
30+
31+
- Questions from the user ("should I use Node 26?"). Analysis is invited.
32+
- Long contextual user messages. Those carry their own framing.
33+
- Assistant turns that hedge after the tool call. Post-action qualification is fine.
34+
35+
## Disable
36+
37+
```bash
38+
SOCKET_FOLLOW_DIRECT_IMPERATIVE_DISABLED=1
39+
```
40+
41+
## Related
42+
43+
- `dont-stop-mid-queue-reminder`: Stop hook for premature "what's next?" after authorized continuous-work directives.
44+
- `ask-suppression-reminder`: Stop hook for AskUserQuestion when recent transcript already authorized the obvious default.
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
#!/usr/bin/env node
2+
// Claude Code Stop hook — follow-direct-imperative-reminder.
3+
//
4+
// Fires at turn-end. If the immediately-preceding user turn was a bare
5+
// imperative command (short, action-verb-led) AND the just-emitted
6+
// assistant text contains hedge / re-litigation patterns BEFORE any
7+
// tool call, emit a stderr reminder pointing at the failure mode.
8+
//
9+
// The fleet rule (CLAUDE.md "Judgment & self-evaluation"):
10+
//
11+
// Direct imperatives → execute, don't litigate. When the user
12+
// issues a bare command ("use nvm 26.2.0", "cancel the build",
13+
// "do it", "kill it"), the response is the tool call, not a
14+
// paragraph weighing trade-offs.
15+
//
16+
// Past incident: user typed "use nvm use 26.2.0"; assistant responded
17+
// with a paragraph explaining why it wouldn't help the in-flight
18+
// build instead of running the command. Same turn the user typed
19+
// "cancel the build right now" — assistant continued narrating
20+
// build phases instead of killing the process. The user explicitly
21+
// asked for a hook to stop this.
22+
//
23+
// Detection:
24+
// - Last user turn is a single short imperative (≤ 8 words,
25+
// starts with an action verb or a known imperative form).
26+
// - Last assistant turn (just emitted) contains hedge openers
27+
// OR a leading analysis paragraph that precedes any tool call.
28+
//
29+
// Why a reminder, not a block: Stop hooks fire AFTER the turn ended.
30+
// The reminder lands in the next turn's context so the agent sees
31+
// the pattern it just exhibited.
32+
//
33+
// Exit codes:
34+
// 0 — always. Informational; never blocks.
35+
//
36+
// Disabled via `SOCKET_FOLLOW_DIRECT_IMPERATIVE_DISABLED=1`.
37+
38+
import { readFileSync } from 'node:fs'
39+
import process from 'node:process'
40+
41+
interface StopPayload {
42+
readonly transcript_path?: string | undefined
43+
}
44+
45+
interface TranscriptEntry {
46+
readonly type?: string | undefined
47+
readonly role?: string | undefined
48+
readonly message?:
49+
| {
50+
readonly content?: unknown | undefined
51+
readonly role?: string | undefined
52+
}
53+
| undefined
54+
readonly content?: unknown | undefined
55+
}
56+
57+
export async function drainStdinJson(): Promise<StopPayload> {
58+
return await new Promise<StopPayload>(resolve => {
59+
let raw = ''
60+
process.stdin.on('data', d => {
61+
raw += d.toString('utf8')
62+
})
63+
process.stdin.on('end', () => {
64+
try {
65+
resolve(raw ? (JSON.parse(raw) as StopPayload) : {})
66+
} catch {
67+
resolve({})
68+
}
69+
})
70+
process.stdin.on('error', () => resolve({}))
71+
setTimeout(() => resolve({}), 200)
72+
})
73+
}
74+
75+
// Read the last N entries from a JSONL transcript file. The harness
76+
// uses one JSON object per line.
77+
export function readTranscriptTail(
78+
path: string,
79+
count: number,
80+
): TranscriptEntry[] {
81+
let text: string
82+
try {
83+
text = readFileSync(path, 'utf8')
84+
} catch {
85+
return []
86+
}
87+
const lines = text.split('\n').filter(Boolean)
88+
const tail = lines.slice(-count)
89+
const out: TranscriptEntry[] = []
90+
for (const line of tail) {
91+
try {
92+
out.push(JSON.parse(line) as TranscriptEntry)
93+
} catch {
94+
// ignore malformed
95+
}
96+
}
97+
return out
98+
}
99+
100+
// Flatten content (string | content-block-array) into one string.
101+
export function flattenContent(content: unknown): string {
102+
if (typeof content === 'string') {
103+
return content
104+
}
105+
if (Array.isArray(content)) {
106+
const parts: string[] = []
107+
for (const block of content) {
108+
if (block && typeof block === 'object') {
109+
const b = block as { type?: string; text?: string }
110+
if (b.type === 'text' && typeof b.text === 'string') {
111+
parts.push(b.text)
112+
}
113+
}
114+
}
115+
return parts.join('\n')
116+
}
117+
return ''
118+
}
119+
120+
// Role detection across the two shapes the transcript uses.
121+
export function entryRole(e: TranscriptEntry): string | undefined {
122+
return e.role ?? e.message?.role ?? e.type
123+
}
124+
125+
export function entryText(e: TranscriptEntry): string {
126+
return flattenContent(e.message?.content ?? e.content ?? '')
127+
}
128+
129+
// Imperative-command opening verbs/forms. Kept conservative —
130+
// over-matching would trigger the reminder on normal conversation.
131+
const IMPERATIVE_OPENERS = [
132+
// Single-verb commands.
133+
'cancel',
134+
'kill',
135+
'stop',
136+
'abort',
137+
'do',
138+
'use',
139+
'run',
140+
'commit',
141+
'push',
142+
'fix',
143+
'try',
144+
'continue',
145+
'restart',
146+
'rerun',
147+
'redo',
148+
'execute',
149+
'go',
150+
'land',
151+
'merge',
152+
'rebase',
153+
'reset',
154+
'add',
155+
'remove',
156+
'delete',
157+
'install',
158+
'switch',
159+
'check',
160+
'show',
161+
'list',
162+
'open',
163+
'close',
164+
'undo',
165+
'revert',
166+
'apply',
167+
'build',
168+
'test',
169+
'deploy',
170+
'finish',
171+
'follow',
172+
'now',
173+
// Common imperative phrases.
174+
"let's",
175+
'just',
176+
'please',
177+
]
178+
179+
// Returns true when the text looks like a bare imperative directive
180+
// (short, action-verb-led, no question mark, no long context).
181+
export function looksLikeImperative(text: string): boolean {
182+
const trimmed = text.trim().toLowerCase()
183+
if (!trimmed) {
184+
return false
185+
}
186+
// Strip leading punctuation.
187+
const body = trimmed.replace(/^[!,.\s]+/, '')
188+
// Skip questions entirely — questions invite analysis.
189+
if (body.includes('?')) {
190+
return false
191+
}
192+
// Bounded length: long contextual messages are not bare imperatives.
193+
const wordCount = body.split(/\s+/).filter(Boolean).length
194+
if (wordCount > 8) {
195+
return false
196+
}
197+
// Pull the first word.
198+
const firstWord = body.split(/\s+/)[0] ?? ''
199+
return IMPERATIVE_OPENERS.includes(firstWord)
200+
}
201+
202+
// Hedge / re-litigation markers in the assistant's text. The goal is
203+
// to catch paragraphs that explain WHY the command might not help
204+
// before the tool call lands.
205+
const HEDGE_MARKERS = [
206+
/\bdoesn't help\b/i,
207+
/\bwon't help\b/i,
208+
/\bbefore (?:i|we) (?:do that|run|kick|switch|cancel)\b/i,
209+
/\blet me (?:explain|first|note)\b/i,
210+
/\b(?:to be clear|just so we'?re clear)\b/i,
211+
/\bworth (?:checking|confirming|noting)\b/i,
212+
/\bone thing to (?:note|flag)\b/i,
213+
/\bthat said\b/i,
214+
/\bactually,?\s+/i,
215+
/\b(?:however|but),?\s+(?:that|the|this)\b/i,
216+
// "the in-flight X is past Y" — re-litigation of in-flight state.
217+
/\bthe in-?flight\b/i,
218+
// Heavy throat-clearing.
219+
/\b(?:caveat|note|important):/i,
220+
]
221+
222+
export function hasHedge(text: string): boolean {
223+
for (const re of HEDGE_MARKERS) {
224+
if (re.test(text)) {
225+
return true
226+
}
227+
}
228+
return false
229+
}
230+
231+
async function main(): Promise<void> {
232+
if (process.env['SOCKET_FOLLOW_DIRECT_IMPERATIVE_DISABLED']) {
233+
return
234+
}
235+
const payload = await drainStdinJson()
236+
const transcriptPath = payload.transcript_path
237+
if (!transcriptPath) {
238+
return
239+
}
240+
// Pull the last ~6 entries — usually covers the last user + last
241+
// assistant turn plus any tool result entries between them.
242+
const tail = readTranscriptTail(transcriptPath, 8)
243+
if (tail.length === 0) {
244+
return
245+
}
246+
247+
// Find the last assistant entry (what we just emitted) and the
248+
// last user entry BEFORE it.
249+
let lastAssistantIdx = -1
250+
for (let i = tail.length - 1; i >= 0; i -= 1) {
251+
if (entryRole(tail[i]!) === 'assistant') {
252+
lastAssistantIdx = i
253+
break
254+
}
255+
}
256+
if (lastAssistantIdx === -1) {
257+
return
258+
}
259+
let lastUserIdx = -1
260+
for (let i = lastAssistantIdx - 1; i >= 0; i -= 1) {
261+
if (entryRole(tail[i]!) === 'user') {
262+
lastUserIdx = i
263+
break
264+
}
265+
}
266+
if (lastUserIdx === -1) {
267+
return
268+
}
269+
270+
const userText = entryText(tail[lastUserIdx]!)
271+
const assistantText = entryText(tail[lastAssistantIdx]!)
272+
if (!userText || !assistantText) {
273+
return
274+
}
275+
if (!looksLikeImperative(userText)) {
276+
return
277+
}
278+
if (!hasHedge(assistantText)) {
279+
return
280+
}
281+
282+
const userPreview = userText.trim().slice(0, 60)
283+
process.stderr.write(
284+
[
285+
'[follow-direct-imperative-reminder] You hedged before executing a direct imperative.',
286+
'',
287+
` User said: "${userPreview}"`,
288+
'',
289+
' The response to a bare command should be the tool call,',
290+
' not a paragraph weighing trade-offs. Hedge openers ("That',
291+
' won\'t help…", "Let me explain…", "Before I do that…") +',
292+
' analysis-before-action when the command was unambiguous',
293+
' are the failure mode the rule targets.',
294+
'',
295+
' Fix: state the intent in one short sentence at most, then',
296+
' run the command. If you genuinely think the directive is',
297+
" wrong, run it AFTER raising the concern — don't refuse to act.",
298+
'',
299+
" CLAUDE.md → 'Judgment & self-evaluation' → Direct imperatives.",
300+
'',
301+
].join('\n'),
302+
)
303+
}
304+
305+
main().catch(e => {
306+
process.stderr.write(
307+
`[follow-direct-imperative-reminder] hook bug — fail-open. ${e instanceof Error ? e.message : String(e)}\n`,
308+
)
309+
})

0 commit comments

Comments
 (0)