Skip to content

Commit ae27e55

Browse files
committed
chore(sync): cascade fleet template@936e30b
Auto-applied by socket-wheelhouse sync-scaffolding into cascade-socket-registry-2574. 39 file(s) touched: - .claude/hooks/ask-suppression-reminder/README.md - .claude/hooks/ask-suppression-reminder/index.mts - .claude/hooks/ask-suppression-reminder/package.json - .claude/hooks/ask-suppression-reminder/test/index.test.mts - .claude/hooks/ask-suppression-reminder/tsconfig.json - .claude/hooks/codex-no-write-guard/README.md - .claude/hooks/codex-no-write-guard/index.mts - .claude/hooks/codex-no-write-guard/package.json - .claude/hooks/codex-no-write-guard/test/index.test.mts - .claude/hooks/codex-no-write-guard/tsconfig.json - .claude/hooks/concurrent-cargo-build-guard/README.md - .claude/hooks/concurrent-cargo-build-guard/index.mts - .claude/hooks/concurrent-cargo-build-guard/package.json - .claude/hooks/concurrent-cargo-build-guard/test/index.test.mts - .claude/hooks/concurrent-cargo-build-guard/tsconfig.json - .claude/hooks/minimum-release-age-guard/README.md - .claude/hooks/minimum-release-age-guard/index.mts - .claude/hooks/minimum-release-age-guard/package.json - .claude/hooks/minimum-release-age-guard/test/index.test.mts - .claude/hooks/minimum-release-age-guard/tsconfig.json ... and 19 more
1 parent 3c92405 commit ae27e55

39 files changed

Lines changed: 3173 additions & 0 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# ask-suppression-reminder
2+
3+
PreToolUse hook (reminder, NOT a block) that fires on AskUserQuestion when
4+
the recent transcript carries explicit go-ahead directives.
5+
6+
## Why
7+
8+
The user has flagged repeated AskUserQuestion as friction-generating
9+
behavior. Memory captures the rule in `feedback_dont_ask_proceed`: when the
10+
user has said "do it" / "yes" / "proceed" / "1", the assistant should pick
11+
the obvious default and execute, not pose a clarifying question.
12+
13+
A blocker would be too aggressive — sometimes a binary question after "yes"
14+
is genuinely scoping (e.g. "yes proceed — but which of these N approaches?").
15+
A reminder gives the assistant the signal to reconsider without preventing
16+
legitimate scoping.
17+
18+
## What it surfaces
19+
20+
| User turn pattern | Reminder? |
21+
| --------------------------------------------- | --------- |
22+
| `yes` / `y` / `do it` / `proceed` / `go` | yes |
23+
| `continue` / `1` / `all of them` / `ship it` | yes |
24+
| `ok` / `sure` / `k` | yes |
25+
| Long paragraph that happens to contain "yes" | no |
26+
| (must be the full trimmed message body) | |
27+
| Question or scoping requests in the user turn | no |
28+
29+
Scans the last 3 user turns. The matched turn must be the ENTIRE trimmed
30+
message body, not a substring — this avoids firing on "yes" buried in
31+
sentence prose.
32+
33+
## Disable
34+
35+
Set `SOCKET_ASK_SUPPRESSION_REMINDER_DISABLED=1` in the environment.
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
#!/usr/bin/env node
2+
// Claude Code PreToolUse hook — ask-suppression-reminder.
3+
//
4+
// Fires (with a stderr reminder, not a block) when the assistant invokes
5+
// AskUserQuestion while the recent transcript carries an explicit go-ahead
6+
// directive from the user. The hook DOES NOT block — it surfaces a one-line
7+
// reminder so the assistant notices the dont-ask-proceed signal and picks
8+
// the obvious default instead of asking.
9+
//
10+
// Reasoning behind reminder-only:
11+
// - Sometimes the question is genuinely scoping ("which of these N
12+
// options?" after the user said "yes, proceed"). Blocking would prevent
13+
// legitimate scoping.
14+
// - A noisy stderr nudge keeps the cost low; the assistant's response is
15+
// to skip the question, not to refuse.
16+
//
17+
// Detection model:
18+
// - Fires only on AskUserQuestion tool calls.
19+
// - Reads the most recent N user turns from the transcript.
20+
// - Looks for go-ahead directives: standalone "yes" / "do it" / "proceed"
21+
// / "go" / "continue" / digit-only ("1") / "all of them".
22+
// - Conservative: only flags when at least one directive appears AS the
23+
// most recent user turn's text content (not buried in a paragraph).
24+
//
25+
// Disable: SOCKET_ASK_SUPPRESSION_REMINDER_DISABLED=1 env var.
26+
27+
import { readFileSync } from 'node:fs'
28+
import process from 'node:process'
29+
30+
import { readStdin } from '../_shared/transcript.mts'
31+
32+
interface ToolInput {
33+
readonly tool_name?: string | undefined
34+
readonly transcript_path?: string | undefined
35+
}
36+
37+
const ENV_DISABLE = 'SOCKET_ASK_SUPPRESSION_REMINDER_DISABLED'
38+
39+
// Patterns that signal "you have go-ahead; don't ask again". Match against
40+
// the full trimmed text of a user turn — must be the entire message body,
41+
// not a substring (to avoid firing on "yes" mid-paragraph).
42+
const GO_AHEAD_PATTERNS = [
43+
/^yes\.?$/i,
44+
/^y\.?$/i,
45+
/^do it\.?$/i,
46+
/^proceed\.?$/i,
47+
/^go\.?$/i,
48+
/^continue\.?$/i,
49+
/^continue\.?\s*$/i,
50+
/^[0-9]+\.?$/, // digit-only ("1", "2")
51+
/^all of them\.?$/i,
52+
/^all\.?$/i,
53+
/^ship (?:it|them)\.?$/i,
54+
/^k\.?$/i,
55+
/^ok\.?$/i,
56+
/^sure\.?$/i,
57+
]
58+
59+
// How many recent user turns to scan. Larger windows catch stale directives;
60+
// smaller windows lose context. 3 is a balance.
61+
const RECENT_TURN_WINDOW = 3
62+
63+
function readRecentUserTurns(transcriptPath: string, window: number): string[] {
64+
let raw: string
65+
try {
66+
raw = readFileSync(transcriptPath, 'utf8')
67+
} catch {
68+
return []
69+
}
70+
const turns: string[] = []
71+
for (const line of raw.split(/\r?\n/)) {
72+
if (!line.trim()) {
73+
continue
74+
}
75+
let entry: unknown
76+
try {
77+
entry = JSON.parse(line)
78+
} catch {
79+
continue
80+
}
81+
if (entry === null || typeof entry !== 'object') {
82+
continue
83+
}
84+
if ((entry as { type?: string }).type !== 'user') {
85+
continue
86+
}
87+
const msg = (entry as { message?: { content?: unknown } }).message
88+
if (!msg) {
89+
continue
90+
}
91+
const c = msg.content
92+
if (typeof c === 'string') {
93+
turns.push(c)
94+
} else if (Array.isArray(c)) {
95+
// Newer format — content is an array of segments.
96+
const text = c
97+
.map(seg =>
98+
typeof seg === 'string'
99+
? seg
100+
: typeof (seg as { text?: unknown }).text === 'string'
101+
? (seg as { text: string }).text
102+
: '',
103+
)
104+
.join('\n')
105+
turns.push(text)
106+
}
107+
}
108+
return turns.slice(-window)
109+
}
110+
111+
function matchesGoAhead(text: string): boolean {
112+
const trimmed = text.trim()
113+
if (!trimmed) {
114+
return false
115+
}
116+
for (const re of GO_AHEAD_PATTERNS) {
117+
if (re.test(trimmed)) {
118+
return true
119+
}
120+
}
121+
return false
122+
}
123+
124+
async function main(): Promise<void> {
125+
if (process.env[ENV_DISABLE]) {
126+
process.exit(0)
127+
}
128+
let raw: string
129+
try {
130+
raw = await readStdin()
131+
} catch {
132+
process.exit(0)
133+
}
134+
if (!raw) {
135+
process.exit(0)
136+
}
137+
let payload: ToolInput
138+
try {
139+
payload = JSON.parse(raw) as ToolInput
140+
} catch {
141+
process.exit(0)
142+
}
143+
144+
if (payload.tool_name !== 'AskUserQuestion') {
145+
process.exit(0)
146+
}
147+
148+
if (!payload.transcript_path) {
149+
process.exit(0)
150+
}
151+
152+
const turns = readRecentUserTurns(payload.transcript_path, RECENT_TURN_WINDOW)
153+
if (turns.length === 0) {
154+
process.exit(0)
155+
}
156+
157+
// Find the most recent user turn that matches the go-ahead pattern.
158+
let matched: string | undefined
159+
for (let i = turns.length - 1; i >= 0; i -= 1) {
160+
if (matchesGoAhead(turns[i]!)) {
161+
matched = turns[i]
162+
break
163+
}
164+
}
165+
if (!matched) {
166+
process.exit(0)
167+
}
168+
169+
// Reminder-only — exit 0, write to stderr. Claude Code surfaces the
170+
// stderr text to the assistant without blocking the tool call.
171+
process.stderr.write(
172+
[
173+
'[ask-suppression-reminder] AskUserQuestion with recent go-ahead directive',
174+
'',
175+
` Recent user turn: "${matched.trim().slice(0, 80)}"`,
176+
'',
177+
' The user has given you explicit permission to proceed. Reconsider',
178+
' whether the question is genuinely scoping (a real ambiguity you',
179+
' cannot resolve from context) or whether you should pick the',
180+
' obvious default and execute.',
181+
'',
182+
' Per CLAUDE.md Judgment & self-evaluation: skip AskUserQuestion',
183+
' when intent is clear; pick the obvious default and execute.',
184+
'',
185+
' Disable this reminder: set SOCKET_ASK_SUPPRESSION_REMINDER_DISABLED=1.',
186+
'',
187+
].join('\n'),
188+
)
189+
process.exit(0)
190+
}
191+
192+
main().catch(e => {
193+
process.stderr.write(
194+
`[ask-suppression-reminder] hook error (allowing): ${(e as Error).message}\n`,
195+
)
196+
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "hook-ask-suppression-reminder",
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+
"devDependencies": {
13+
"@types/node": "catalog:"
14+
}
15+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// node --test specs for the ask-suppression-reminder hook.
2+
3+
import { spawn } from 'node:child_process'
4+
import { mkdtempSync, writeFileSync } from 'node:fs'
5+
import { tmpdir } from 'node:os'
6+
import path from 'node:path'
7+
import { fileURLToPath } from 'node:url'
8+
import test from 'node:test'
9+
import assert from 'node:assert/strict'
10+
11+
const here = path.dirname(fileURLToPath(import.meta.url))
12+
const HOOK = path.join(here, '..', 'index.mts')
13+
14+
type Result = { code: number; stderr: string }
15+
16+
function writeTranscript(userTurns: string[]): string {
17+
const dir = mkdtempSync(path.join(tmpdir(), 'ask-suppress-tx-'))
18+
const transcriptPath = path.join(dir, 'session.jsonl')
19+
const lines = userTurns.map(t =>
20+
JSON.stringify({ type: 'user', message: { content: t } }),
21+
)
22+
writeFileSync(transcriptPath, lines.join('\n') + '\n')
23+
return transcriptPath
24+
}
25+
26+
async function runHook(payload: Record<string, unknown>): Promise<Result> {
27+
const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' })
28+
child.stdin.end(JSON.stringify(payload))
29+
let stderr = ''
30+
child.stderr.on('data', chunk => {
31+
stderr += chunk.toString('utf8')
32+
})
33+
return new Promise(resolve => {
34+
child.on('exit', code => {
35+
resolve({ code: code ?? 0, stderr })
36+
})
37+
})
38+
}
39+
40+
test('non-AskUserQuestion passes silently', async () => {
41+
const r = await runHook({
42+
tool_name: 'Bash',
43+
tool_input: { command: 'echo hi' },
44+
transcript_path: writeTranscript(['yes']),
45+
})
46+
assert.strictEqual(r.code, 0)
47+
assert.strictEqual(r.stderr, '')
48+
})
49+
50+
test('AskUserQuestion with no recent directive — no reminder', async () => {
51+
const r = await runHook({
52+
tool_name: 'AskUserQuestion',
53+
transcript_path: writeTranscript([
54+
'Can you investigate the bug?',
55+
'I think it is in the parser.',
56+
]),
57+
})
58+
assert.strictEqual(r.code, 0)
59+
assert.strictEqual(r.stderr, '')
60+
})
61+
62+
test('AskUserQuestion with recent "do it" — reminder fires', async () => {
63+
const r = await runHook({
64+
tool_name: 'AskUserQuestion',
65+
transcript_path: writeTranscript(['First find them.', 'do it']),
66+
})
67+
assert.strictEqual(r.code, 0)
68+
assert.ok(r.stderr.includes('go-ahead directive'))
69+
})
70+
71+
test('AskUserQuestion with "yes" — reminder fires', async () => {
72+
const r = await runHook({
73+
tool_name: 'AskUserQuestion',
74+
transcript_path: writeTranscript(['yes']),
75+
})
76+
assert.strictEqual(r.code, 0)
77+
assert.ok(r.stderr.includes('go-ahead directive'))
78+
})
79+
80+
test('AskUserQuestion with "yes" buried in paragraph — no reminder', async () => {
81+
const r = await runHook({
82+
tool_name: 'AskUserQuestion',
83+
transcript_path: writeTranscript([
84+
'yes, but only after you read the docs and report what you find',
85+
]),
86+
})
87+
assert.strictEqual(r.code, 0)
88+
assert.strictEqual(r.stderr, '')
89+
})
90+
91+
test('digit-only directive ("1") fires reminder', async () => {
92+
const r = await runHook({
93+
tool_name: 'AskUserQuestion',
94+
transcript_path: writeTranscript(['Pick one of these:', '1']),
95+
})
96+
assert.strictEqual(r.code, 0)
97+
assert.ok(r.stderr.includes('go-ahead directive'))
98+
})
99+
100+
test('disabled via env var', async () => {
101+
const child = spawn(process.execPath, [HOOK], {
102+
stdio: 'pipe',
103+
env: {
104+
...process.env,
105+
SOCKET_ASK_SUPPRESSION_REMINDER_DISABLED: '1',
106+
},
107+
})
108+
child.stdin.end(
109+
JSON.stringify({
110+
tool_name: 'AskUserQuestion',
111+
transcript_path: writeTranscript(['do it']),
112+
}),
113+
)
114+
let stderr = ''
115+
child.stderr.on('data', chunk => {
116+
stderr += chunk.toString('utf8')
117+
})
118+
const code = await new Promise<number>(resolve => {
119+
child.on('exit', c => resolve(c ?? 0))
120+
})
121+
assert.strictEqual(code, 0)
122+
assert.strictEqual(stderr, '')
123+
})
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"compilerOptions": {
3+
"declarationMap": false,
4+
"erasableSyntaxOnly": true,
5+
"module": "nodenext",
6+
"moduleResolution": "nodenext",
7+
"noEmit": true,
8+
"rewriteRelativeImportExtensions": true,
9+
"skipLibCheck": true,
10+
"sourceMap": false,
11+
"strict": true,
12+
"target": "esnext",
13+
"types": ["node"],
14+
"verbatimModuleSyntax": true
15+
}
16+
}

0 commit comments

Comments
 (0)