claude-code-autoconfig 1.0.200 → 1.0.202
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/disable-arcade-beeps.md +10 -10
- package/.claude/commands/enable-arcade-beeps.md +10 -10
- package/.claude/hooks/arcade-beeps.js +136 -136
- package/.claude/hooks/terminal-title.directive.md +19 -11
- package/.claude/hooks/terminal-title.js +79 -15
- package/.claude/settings.json +9 -9
- package/CHANGELOG.md +7 -6
- package/bin/cli.js +1043 -1014
- package/package.json +2 -2
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: Turn OFF Pole Position tab status beeps
|
|
3
|
-
allowed-tools: Bash
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
Run exactly this command, then reply with a single confirmation line and nothing else:
|
|
7
|
-
|
|
8
|
-
```
|
|
9
|
-
rm -f ~/.claude/sounds/arcade-beeps.enabled && echo "arcade beeps DISABLED."
|
|
10
|
-
```
|
|
1
|
+
---
|
|
2
|
+
description: Turn OFF Pole Position tab status beeps
|
|
3
|
+
allowed-tools: Bash
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Run exactly this command, then reply with a single confirmation line and nothing else:
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
rm -f ~/.claude/sounds/arcade-beeps.enabled && echo "arcade beeps DISABLED."
|
|
10
|
+
```
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: Turn ON Pole Position tab status beeps (awaiting = low tone, complete = high tone)
|
|
3
|
-
allowed-tools: Bash
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
Run exactly this command, then reply with a single confirmation line and nothing else:
|
|
7
|
-
|
|
8
|
-
```
|
|
9
|
-
mkdir -p ~/.claude/sounds && touch ~/.claude/sounds/arcade-beeps.enabled && echo "arcade beeps ENABLED (every turn-end beeps: awaiting=low, complete=high). Disable with /disable-arcade-beeps."
|
|
10
|
-
```
|
|
1
|
+
---
|
|
2
|
+
description: Turn ON Pole Position tab status beeps (awaiting = low tone, complete = high tone)
|
|
3
|
+
allowed-tools: Bash
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Run exactly this command, then reply with a single confirmation line and nothing else:
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
mkdir -p ~/.claude/sounds && touch ~/.claude/sounds/arcade-beeps.enabled && echo "arcade beeps ENABLED (every turn-end beeps: awaiting=low, complete=high). Disable with /disable-arcade-beeps."
|
|
10
|
+
```
|
|
@@ -1,136 +1,136 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* arcade-beeps — optional Pole-Position status cues for the Claude Code tab.
|
|
4
|
-
* Companion to terminal-title.js. Registered (settings.json) on Stop + Notification.
|
|
5
|
-
*
|
|
6
|
-
* OFF unless the enable flag exists: ~/.claude/sounds/arcade-beeps.enabled
|
|
7
|
-
* toggle via /enable-arcade-beeps /disable-arcade-beeps
|
|
8
|
-
* The flag is GLOBAL (homedir), so one toggle covers every project you've installed into.
|
|
9
|
-
*
|
|
10
|
-
* Mapping (matches the ◐/✻ tab glyph that terminal-title.js paints on the SAME event):
|
|
11
|
-
* Stop, turn ended on a question -> ◐ awaiting -> get-ready tick (pp3-getready-G4.wav, G4/384Hz)
|
|
12
|
-
* Stop, turn ended normally -> ✻ complete -> GO beep (pp3-go-F#5.wav, F#5/759Hz)
|
|
13
|
-
* Notification (permission_prompt) -> ◐ awaiting -> get-ready tick
|
|
14
|
-
*
|
|
15
|
-
* Tones are an extracted+smoothed take on the Pole Position race-start cue (arcade-ping
|
|
16
|
-
* envelope). LOWER tick = awaiting, HIGHER GO = complete.
|
|
17
|
-
*
|
|
18
|
-
* Assets resolve relative to THIS file (../sounds) so a per-project CCA install finds its own
|
|
19
|
-
* copy at <project>/.claude/sounds — no dependency on a global sounds dir.
|
|
20
|
-
*
|
|
21
|
-
* Playback is cross-platform and best-effort: PowerShell SoundPlayer on Windows, afplay on
|
|
22
|
-
* macOS, paplay||aplay on Linux. If none is present the hook simply stays silent — never errors.
|
|
23
|
-
*
|
|
24
|
-
* Playback BLOCKS (spawnSync) so the sound finishes inside the hook's lifetime — a detached
|
|
25
|
-
* fire-and-forget child can be killed by the hook runner's job/process-group cleanup before it
|
|
26
|
-
* plays. Blocking costs ~0.5s on turn-end but is reliable. Still fail-safe: wrapped so it never
|
|
27
|
-
* throws into the turn, and every path ends in exit(0).
|
|
28
|
-
*
|
|
29
|
-
* State detection reuses terminal-title.js's exported inspectLastResponse (lazy-required only
|
|
30
|
-
* when enabled) so sound and glyph agree. A tiny inline '?' check is the fallback.
|
|
31
|
-
*
|
|
32
|
-
* Diagnostic log (bounded ~64KB) at ~/.claude/hooks/.titles/arcade-beeps.log records every
|
|
33
|
-
* invocation + choice, so we can prove whether the hook fires on a real Stop.
|
|
34
|
-
*/
|
|
35
|
-
const fs = require('fs');
|
|
36
|
-
const os = require('os');
|
|
37
|
-
const path = require('path');
|
|
38
|
-
const { spawnSync } = require('child_process');
|
|
39
|
-
|
|
40
|
-
const ASSET_DIR = path.join(__dirname, '..', 'sounds'); // wavs ship beside the hook
|
|
41
|
-
const FLAG = path.join(os.homedir(), '.claude', 'sounds', 'arcade-beeps.enabled'); // global toggle
|
|
42
|
-
const LOG = path.join(os.homedir(), '.claude', 'hooks', '.titles', 'arcade-beeps.log');
|
|
43
|
-
const DEBUG = process.env.ARCADE_BEEPS_DEBUG === '1';
|
|
44
|
-
|
|
45
|
-
function logLine(msg) {
|
|
46
|
-
try {
|
|
47
|
-
try { if (fs.statSync(LOG).size > 64 * 1024) fs.renameSync(LOG, `${LOG}.1`); } catch (_) { /* none yet */ }
|
|
48
|
-
fs.appendFileSync(LOG, `${new Date().toISOString()} ${msg}\n`);
|
|
49
|
-
} catch (_) { /* logging must never throw */ }
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function enabled() { try { return fs.existsSync(FLAG); } catch (_) { return false; } }
|
|
53
|
-
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
54
|
-
|
|
55
|
-
// Pick a blocking audio player for the current OS. Returns [cmd, args].
|
|
56
|
-
function playerFor(wav) {
|
|
57
|
-
if (process.platform === 'win32') {
|
|
58
|
-
return ['powershell', ['-NoProfile', '-Command', `(New-Object Media.SoundPlayer '${wav}').PlaySync()`]];
|
|
59
|
-
}
|
|
60
|
-
if (process.platform === 'darwin') {
|
|
61
|
-
return ['afplay', [wav]];
|
|
62
|
-
}
|
|
63
|
-
// linux / other: prefer paplay (PulseAudio/PipeWire), fall back to aplay (ALSA)
|
|
64
|
-
return ['sh', ['-c', `paplay "${wav}" 2>/dev/null || aplay "${wav}" 2>/dev/null`]];
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function play(wavName, why) {
|
|
68
|
-
if (DEBUG) process.stderr.write(`arcade-beeps: chose ${wavName} (${why})\n`);
|
|
69
|
-
logLine(`play ${wavName} (${why})`);
|
|
70
|
-
try {
|
|
71
|
-
const wav = path.join(ASSET_DIR, wavName);
|
|
72
|
-
if (!fs.existsSync(wav)) { logLine(`MISSING ${wav}`); return; }
|
|
73
|
-
// Block until the sound finishes so it can't be reaped with the hook process.
|
|
74
|
-
const [cmd, cmdArgs] = playerFor(wav);
|
|
75
|
-
spawnSync(cmd, cmdArgs, { stdio: 'ignore', windowsHide: true });
|
|
76
|
-
} catch (e) { logLine(`play-error ${e && e.message}`); }
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Fallback: does the last visible assistant text end on a question? (Mirrors terminal-title.js's regex.)
|
|
80
|
-
function endsOnQuestionInline(transcriptPath) {
|
|
81
|
-
try {
|
|
82
|
-
const lines = fs.readFileSync(transcriptPath, 'utf8').split('\n');
|
|
83
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
84
|
-
const l = lines[i].trim(); if (!l) continue;
|
|
85
|
-
let o; try { o = JSON.parse(l); } catch (_) { continue; }
|
|
86
|
-
if (!o || o.type !== 'assistant' || !o.message) continue;
|
|
87
|
-
const c = o.message.content; let t = '';
|
|
88
|
-
if (typeof c === 'string') t = c;
|
|
89
|
-
else if (Array.isArray(c)) t = c.filter(b => b && b.type === 'text' && typeof b.text === 'string').map(b => b.text).join('\n');
|
|
90
|
-
if (t.trim()) return /\?[\s)*_"]*(\([^()]*\)[\s.*_"]*)?$/.test(t);
|
|
91
|
-
}
|
|
92
|
-
} catch (_) { /* ignore */ }
|
|
93
|
-
return false;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async function main(input) {
|
|
97
|
-
let data = {};
|
|
98
|
-
try { data = JSON.parse(input); } catch (_) { /* ignore */ }
|
|
99
|
-
const event = data.hook_event_name || '?';
|
|
100
|
-
const en = enabled();
|
|
101
|
-
logLine(`invoked event=${event} enabled=${en ? 1 : 0}`);
|
|
102
|
-
if (!en) return; // opt-in only
|
|
103
|
-
|
|
104
|
-
if (event === 'Notification') { play('pp3-getready-G4.wav', 'notification'); return; }
|
|
105
|
-
if (event !== 'Stop') return;
|
|
106
|
-
|
|
107
|
-
// awaiting (question) vs complete — same signals terminal-title.js uses, so the tone matches the glyph.
|
|
108
|
-
const sid = data.session_id || '';
|
|
109
|
-
const askFile = path.join(os.homedir(), '.claude', 'hooks', '.titles', `${sid}.ask`);
|
|
110
|
-
let pending = false;
|
|
111
|
-
try { pending = fs.existsSync(askFile); } catch (_) { /* ignore */ }
|
|
112
|
-
|
|
113
|
-
if (!pending) {
|
|
114
|
-
let inspect = null;
|
|
115
|
-
try { ({ inspectLastResponse: inspect } = require('./terminal-title.js')); } catch (_) { /* fallback below */ }
|
|
116
|
-
if (inspect) {
|
|
117
|
-
let q = inspect(data.transcript_path);
|
|
118
|
-
let n = 0;
|
|
119
|
-
while (!q.ends && (q.suspectRace || !q.found) && n < 5) { await delay(120); q = inspect(data.transcript_path); n++; }
|
|
120
|
-
pending = q.ends;
|
|
121
|
-
} else {
|
|
122
|
-
pending = endsOnQuestionInline(data.transcript_path);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
play(pending ? 'pp3-getready-G4.wav' : 'pp3-go-F#5.wav', pending ? 'awaiting' : 'complete');
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
if (require.main === module) {
|
|
130
|
-
let input = '';
|
|
131
|
-
process.stdin.setEncoding('utf8');
|
|
132
|
-
process.stdin.on('data', c => (input += c));
|
|
133
|
-
process.stdin.on('end', async () => { try { await main(input); } catch (_) { /* ignore */ } process.exit(0); });
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
module.exports = { endsOnQuestionInline };
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* arcade-beeps — optional Pole-Position status cues for the Claude Code tab.
|
|
4
|
+
* Companion to terminal-title.js. Registered (settings.json) on Stop + Notification.
|
|
5
|
+
*
|
|
6
|
+
* OFF unless the enable flag exists: ~/.claude/sounds/arcade-beeps.enabled
|
|
7
|
+
* toggle via /enable-arcade-beeps /disable-arcade-beeps
|
|
8
|
+
* The flag is GLOBAL (homedir), so one toggle covers every project you've installed into.
|
|
9
|
+
*
|
|
10
|
+
* Mapping (matches the ◐/✻ tab glyph that terminal-title.js paints on the SAME event):
|
|
11
|
+
* Stop, turn ended on a question -> ◐ awaiting -> get-ready tick (pp3-getready-G4.wav, G4/384Hz)
|
|
12
|
+
* Stop, turn ended normally -> ✻ complete -> GO beep (pp3-go-F#5.wav, F#5/759Hz)
|
|
13
|
+
* Notification (permission_prompt) -> ◐ awaiting -> get-ready tick
|
|
14
|
+
*
|
|
15
|
+
* Tones are an extracted+smoothed take on the Pole Position race-start cue (arcade-ping
|
|
16
|
+
* envelope). LOWER tick = awaiting, HIGHER GO = complete.
|
|
17
|
+
*
|
|
18
|
+
* Assets resolve relative to THIS file (../sounds) so a per-project CCA install finds its own
|
|
19
|
+
* copy at <project>/.claude/sounds — no dependency on a global sounds dir.
|
|
20
|
+
*
|
|
21
|
+
* Playback is cross-platform and best-effort: PowerShell SoundPlayer on Windows, afplay on
|
|
22
|
+
* macOS, paplay||aplay on Linux. If none is present the hook simply stays silent — never errors.
|
|
23
|
+
*
|
|
24
|
+
* Playback BLOCKS (spawnSync) so the sound finishes inside the hook's lifetime — a detached
|
|
25
|
+
* fire-and-forget child can be killed by the hook runner's job/process-group cleanup before it
|
|
26
|
+
* plays. Blocking costs ~0.5s on turn-end but is reliable. Still fail-safe: wrapped so it never
|
|
27
|
+
* throws into the turn, and every path ends in exit(0).
|
|
28
|
+
*
|
|
29
|
+
* State detection reuses terminal-title.js's exported inspectLastResponse (lazy-required only
|
|
30
|
+
* when enabled) so sound and glyph agree. A tiny inline '?' check is the fallback.
|
|
31
|
+
*
|
|
32
|
+
* Diagnostic log (bounded ~64KB) at ~/.claude/hooks/.titles/arcade-beeps.log records every
|
|
33
|
+
* invocation + choice, so we can prove whether the hook fires on a real Stop.
|
|
34
|
+
*/
|
|
35
|
+
const fs = require('fs');
|
|
36
|
+
const os = require('os');
|
|
37
|
+
const path = require('path');
|
|
38
|
+
const { spawnSync } = require('child_process');
|
|
39
|
+
|
|
40
|
+
const ASSET_DIR = path.join(__dirname, '..', 'sounds'); // wavs ship beside the hook
|
|
41
|
+
const FLAG = path.join(os.homedir(), '.claude', 'sounds', 'arcade-beeps.enabled'); // global toggle
|
|
42
|
+
const LOG = path.join(os.homedir(), '.claude', 'hooks', '.titles', 'arcade-beeps.log');
|
|
43
|
+
const DEBUG = process.env.ARCADE_BEEPS_DEBUG === '1';
|
|
44
|
+
|
|
45
|
+
function logLine(msg) {
|
|
46
|
+
try {
|
|
47
|
+
try { if (fs.statSync(LOG).size > 64 * 1024) fs.renameSync(LOG, `${LOG}.1`); } catch (_) { /* none yet */ }
|
|
48
|
+
fs.appendFileSync(LOG, `${new Date().toISOString()} ${msg}\n`);
|
|
49
|
+
} catch (_) { /* logging must never throw */ }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function enabled() { try { return fs.existsSync(FLAG); } catch (_) { return false; } }
|
|
53
|
+
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
54
|
+
|
|
55
|
+
// Pick a blocking audio player for the current OS. Returns [cmd, args].
|
|
56
|
+
function playerFor(wav) {
|
|
57
|
+
if (process.platform === 'win32') {
|
|
58
|
+
return ['powershell', ['-NoProfile', '-Command', `(New-Object Media.SoundPlayer '${wav}').PlaySync()`]];
|
|
59
|
+
}
|
|
60
|
+
if (process.platform === 'darwin') {
|
|
61
|
+
return ['afplay', [wav]];
|
|
62
|
+
}
|
|
63
|
+
// linux / other: prefer paplay (PulseAudio/PipeWire), fall back to aplay (ALSA)
|
|
64
|
+
return ['sh', ['-c', `paplay "${wav}" 2>/dev/null || aplay "${wav}" 2>/dev/null`]];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function play(wavName, why) {
|
|
68
|
+
if (DEBUG) process.stderr.write(`arcade-beeps: chose ${wavName} (${why})\n`);
|
|
69
|
+
logLine(`play ${wavName} (${why})`);
|
|
70
|
+
try {
|
|
71
|
+
const wav = path.join(ASSET_DIR, wavName);
|
|
72
|
+
if (!fs.existsSync(wav)) { logLine(`MISSING ${wav}`); return; }
|
|
73
|
+
// Block until the sound finishes so it can't be reaped with the hook process.
|
|
74
|
+
const [cmd, cmdArgs] = playerFor(wav);
|
|
75
|
+
spawnSync(cmd, cmdArgs, { stdio: 'ignore', windowsHide: true });
|
|
76
|
+
} catch (e) { logLine(`play-error ${e && e.message}`); }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Fallback: does the last visible assistant text end on a question? (Mirrors terminal-title.js's regex.)
|
|
80
|
+
function endsOnQuestionInline(transcriptPath) {
|
|
81
|
+
try {
|
|
82
|
+
const lines = fs.readFileSync(transcriptPath, 'utf8').split('\n');
|
|
83
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
84
|
+
const l = lines[i].trim(); if (!l) continue;
|
|
85
|
+
let o; try { o = JSON.parse(l); } catch (_) { continue; }
|
|
86
|
+
if (!o || o.type !== 'assistant' || !o.message) continue;
|
|
87
|
+
const c = o.message.content; let t = '';
|
|
88
|
+
if (typeof c === 'string') t = c;
|
|
89
|
+
else if (Array.isArray(c)) t = c.filter(b => b && b.type === 'text' && typeof b.text === 'string').map(b => b.text).join('\n');
|
|
90
|
+
if (t.trim()) return /\?[\s)*_"]*(\([^()]*\)[\s.*_"]*)?$/.test(t);
|
|
91
|
+
}
|
|
92
|
+
} catch (_) { /* ignore */ }
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function main(input) {
|
|
97
|
+
let data = {};
|
|
98
|
+
try { data = JSON.parse(input); } catch (_) { /* ignore */ }
|
|
99
|
+
const event = data.hook_event_name || '?';
|
|
100
|
+
const en = enabled();
|
|
101
|
+
logLine(`invoked event=${event} enabled=${en ? 1 : 0}`);
|
|
102
|
+
if (!en) return; // opt-in only
|
|
103
|
+
|
|
104
|
+
if (event === 'Notification') { play('pp3-getready-G4.wav', 'notification'); return; }
|
|
105
|
+
if (event !== 'Stop') return;
|
|
106
|
+
|
|
107
|
+
// awaiting (question) vs complete — same signals terminal-title.js uses, so the tone matches the glyph.
|
|
108
|
+
const sid = data.session_id || '';
|
|
109
|
+
const askFile = path.join(os.homedir(), '.claude', 'hooks', '.titles', `${sid}.ask`);
|
|
110
|
+
let pending = false;
|
|
111
|
+
try { pending = fs.existsSync(askFile); } catch (_) { /* ignore */ }
|
|
112
|
+
|
|
113
|
+
if (!pending) {
|
|
114
|
+
let inspect = null;
|
|
115
|
+
try { ({ inspectLastResponse: inspect } = require('./terminal-title.js')); } catch (_) { /* fallback below */ }
|
|
116
|
+
if (inspect) {
|
|
117
|
+
let q = inspect(data.transcript_path);
|
|
118
|
+
let n = 0;
|
|
119
|
+
while (!q.ends && (q.suspectRace || !q.found) && n < 5) { await delay(120); q = inspect(data.transcript_path); n++; }
|
|
120
|
+
pending = q.ends;
|
|
121
|
+
} else {
|
|
122
|
+
pending = endsOnQuestionInline(data.transcript_path);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
play(pending ? 'pp3-getready-G4.wav' : 'pp3-go-F#5.wav', pending ? 'awaiting' : 'complete');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (require.main === module) {
|
|
130
|
+
let input = '';
|
|
131
|
+
process.stdin.setEncoding('utf8');
|
|
132
|
+
process.stdin.on('data', c => (input += c));
|
|
133
|
+
process.stdin.on('end', async () => { try { await main(input); } catch (_) { /* ignore */ } process.exit(0); });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { endsOnQuestionInline };
|
|
@@ -53,29 +53,37 @@ Maintain it across the session:
|
|
|
53
53
|
never "{{FOLDER}} {{EMDASH}} <command-name>").
|
|
54
54
|
|
|
55
55
|
Pending-question signal -- END-OF-TURN TEST, apply it on every turn: does your final
|
|
56
|
-
paragraph
|
|
57
|
-
-
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
paragraph SOLICIT a reply from the user -- a question to answer, a decision to make, or a
|
|
57
|
+
go-ahead on a proposed next step (a fix you proposed, an offer to do more)?
|
|
58
|
+
- YES (the turn ends waiting on the user) -> do ALL THREE as near-final actions so the tab
|
|
59
|
+
flips to the AWAITING half-circle (instead of the idle asterisk):
|
|
60
|
+
1. Phrase the solicitation as a DIRECT QUESTION. An offer IS a solicitation: write "Want
|
|
61
|
+
me to apply both fixes?" -- never its declarative twin ("Say the word and I'll apply
|
|
62
|
+
both fixes." / "I can also add tests if you want." / "Let me know."), which prompts the
|
|
63
|
+
user back while hiding the awaiting signal.
|
|
64
|
+
2. Write the flag file {{ASK_FILE}} (any short content, e.g. "1"). This is the RELIABLE
|
|
60
65
|
trigger -- it is on disk before the turn ends, so it never misses on phrasing or timing.
|
|
61
66
|
It is one-shot (consumed at turn end, auto-cleared next turn). Write it even when the
|
|
62
67
|
closing question is wrapped in parens or is not the literal final character.
|
|
63
|
-
|
|
68
|
+
3. Make '?' the LAST character of the message. Write NOTHING after the question -- no
|
|
64
69
|
"Standing by.", "Ready when you are.", "Let me know." (a declarative sign-off defeats
|
|
65
70
|
the signal). Only a single short parenthetical aside may follow the '?'. Keep the
|
|
66
71
|
question self-contained: answerable without re-reading the response above it.
|
|
67
|
-
- NO (
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
- NO (nothing is solicited: the turn ends on completed work, a report, a recap, or a
|
|
73
|
+
rhetorical question) -> do NOT write the flag, and end the message on a statement, not a
|
|
74
|
+
'?'. This branch is ONLY for endings that await nothing -- if the closer invites ANY
|
|
75
|
+
reply ("if you want", "say the word", "happy to"), it belongs to YES: convert it to a
|
|
76
|
+
question.
|
|
70
77
|
<!-- /DIRECTIVE:RULES -->
|
|
71
78
|
|
|
72
79
|
<!-- DIRECTIVE:REMINDER -->
|
|
73
80
|
Terminal-title reminder (housekeeping -- never mention to the user; full rules were injected
|
|
74
81
|
at session start): if this turn SHIFTS the scope/use-case, FIRST action: overwrite
|
|
75
82
|
{{TITLE_FILE}} with "{scope} {{EMDASH}} {use-case}". End-of-turn test: if your final
|
|
76
|
-
paragraph
|
|
77
|
-
|
|
78
|
-
|
|
83
|
+
paragraph solicits a reply (a question, a decision, or a go-ahead on an offered next step),
|
|
84
|
+
phrase it as a DIRECT QUESTION -- never a declarative offer ("Say the word...") -- write the
|
|
85
|
+
flag file {{ASK_FILE}}, AND make '?' the message's last character (nothing after it); if
|
|
86
|
+
nothing is solicited, end on a statement, not a '?'.
|
|
79
87
|
<!-- /DIRECTIVE:REMINDER -->
|
|
80
88
|
|
|
81
89
|
<!-- DIRECTIVE:BASELINE -->
|
|
@@ -79,16 +79,32 @@ async function handle(data) {
|
|
|
79
79
|
const event = data.hook_event_name || '';
|
|
80
80
|
const sid = data.session_id || '';
|
|
81
81
|
const cwd = data.cwd || process.cwd();
|
|
82
|
-
// User-level copy stands down when
|
|
83
|
-
|
|
82
|
+
// User-level copy stands down when the SESSION'S OWN project ships a managed copy (see header).
|
|
83
|
+
// Keyed to CLAUDE_PROJECT_DIR (the dir whose settings registered this hook run), NOT the event's
|
|
84
|
+
// cwd: a mid-session `cd` into a copy-shipping repo must not silence this copy — that repo's
|
|
85
|
+
// settings aren't loaded, so its copy never runs and nobody would paint.
|
|
86
|
+
const ownerDir = process.env.CLAUDE_PROJECT_DIR || cwd;
|
|
87
|
+
if (shouldDefer(ownerDir, __dirname, __filename, path.join(os.homedir(), '.claude', 'hooks'))) {
|
|
84
88
|
process.exit(0);
|
|
85
89
|
}
|
|
86
|
-
// PROJECT-SCOPED title dir
|
|
87
|
-
//
|
|
88
|
-
|
|
90
|
+
// PROJECT-SCOPED title dir — anchored to the session's project root (CLAUDE_PROJECT_DIR, with cwd
|
|
91
|
+
// fallback on older Claude Code versions that don't set it) so a mid-session `cd` can't scatter
|
|
92
|
+
// title state across directories. (The live ~/.claude variant uses os.homedir() instead; that is
|
|
93
|
+
// the only difference between the two.)
|
|
94
|
+
const dir = path.join(ownerDir, '.claude', 'hooks', '.titles');
|
|
89
95
|
const file = path.join(dir, `${sid}.txt`);
|
|
90
96
|
logCtx = { event, sid, dir, note: '' };
|
|
91
97
|
|
|
98
|
+
// HARNESS-CONTRACT CANARY (debug-only surface): if Claude Code stops supplying a field the
|
|
99
|
+
// keying depends on, every fix above silently degrades to its fallback. Record the gap so the
|
|
100
|
+
// stale-glyph audit surfaces a platform change as one log line instead of the next mystery.
|
|
101
|
+
const degraded = [];
|
|
102
|
+
if (!process.env.CLAUDE_PROJECT_DIR) degraded.push('CLAUDE_PROJECT_DIR');
|
|
103
|
+
if (!data.cwd) degraded.push('cwd');
|
|
104
|
+
if (!sid) degraded.push('session_id');
|
|
105
|
+
if (event === 'Stop' && !data.transcript_path) degraded.push('transcript_path');
|
|
106
|
+
if (degraded.length) logCtx.contract = degraded.join(',');
|
|
107
|
+
|
|
92
108
|
if (event === 'UserPromptSubmit') {
|
|
93
109
|
// Ensure the state dir exists, but NOT the file — the model's Write tool refuses to overwrite a
|
|
94
110
|
// file it hasn't read, so a pre-created empty file would make its first title write fail.
|
|
@@ -182,10 +198,15 @@ async function handle(data) {
|
|
|
182
198
|
if (q.ends || (q.found && !q.suspectRace)) break;
|
|
183
199
|
}
|
|
184
200
|
|
|
185
|
-
|
|
201
|
+
// LEXICAL RESCUE — no '?' on the final line, but its closing sentence is a formulaic offer
|
|
202
|
+
// ("Say the word and I'll…", "Want me to…"): the phrasing directive slipped. Paint ◐ anyway so
|
|
203
|
+
// the user still gets the awaiting signal; via=lex marks it a prose DEFECT for the miss-audit,
|
|
204
|
+
// not a working-as-intended path.
|
|
205
|
+
const lex = !q.ends && q.solicits === true;
|
|
206
|
+
const pending = q.ends || lex;
|
|
186
207
|
if (logCtx) {
|
|
187
|
-
logCtx.note = pending ? 'q-mark' : 'idle';
|
|
188
|
-
logCtx.diag = `ask=0 qmark=${q.ends ? 1 : 0} via=${q.via || '-'} found=${q.found ? 1 : 0} reread=${reread} model=${q.model || '-'} tail="${q.tail}"`;
|
|
208
|
+
logCtx.note = pending ? (lex ? 'lex' : 'q-mark') : 'idle';
|
|
209
|
+
logCtx.diag = `ask=0 qmark=${q.ends ? 1 : 0} via=${lex ? 'lex' : (q.via || '-')} found=${q.found ? 1 : 0} reread=${reread} model=${q.model || '-'} tail="${q.tail}"`;
|
|
189
210
|
}
|
|
190
211
|
const glyph = pending ? GLYPH.awaiting : GLYPH.idle;
|
|
191
212
|
emit(setTitle(glyph, normalize(readTitle(file) || folderName(cwd)), pending));
|
|
@@ -213,9 +234,10 @@ function titleLog(glyph, title, ring) {
|
|
|
213
234
|
: glyph === GLYPH.awaiting ? 'awaiting'
|
|
214
235
|
: glyph === GLYPH.idle ? 'idle' : 'other';
|
|
215
236
|
const diag = logCtx.diag ? ` ${logCtx.diag}` : '';
|
|
237
|
+
const contract = logCtx.contract ? ` contract=degraded(${logCtx.contract})` : '';
|
|
216
238
|
const line = `${new Date().toISOString()} ${logCtx.event.padEnd(16)} `
|
|
217
239
|
+ `${name.padEnd(8)} ring=${ring ? 1 : 0} note=${(logCtx.note || '-').padEnd(8)} `
|
|
218
|
-
+ `sid=${logCtx.sid} | ${title}${diag}\n`;
|
|
240
|
+
+ `sid=${logCtx.sid} | ${title}${diag}${contract}\n`;
|
|
219
241
|
const f = path.join(logCtx.dir, '_debug.log');
|
|
220
242
|
try { if (fs.statSync(f).size > 512 * 1024) fs.renameSync(f, `${f}.1`); } catch (_) { /* none yet */ }
|
|
221
243
|
fs.appendFileSync(f, line);
|
|
@@ -235,7 +257,7 @@ function fileExists(file) {
|
|
|
235
257
|
// shows found=0 (or a stale tail), a genuine regex miss shows a tail that's present but doesn't end
|
|
236
258
|
// in '?'. Any error → a blank record (treated as "no question"), matching the old false return.
|
|
237
259
|
function inspectLastResponse(transcriptPath) {
|
|
238
|
-
const blank = { ends: false, via: '', found: false, tail: '', suspectRace: false, model: '' };
|
|
260
|
+
const blank = { ends: false, via: '', found: false, tail: '', suspectRace: false, model: '', solicits: false };
|
|
239
261
|
if (!transcriptPath) return blank;
|
|
240
262
|
let content;
|
|
241
263
|
try {
|
|
@@ -301,6 +323,7 @@ function inspectLastResponse(transcriptPath) {
|
|
|
301
323
|
return {
|
|
302
324
|
ends: q.ends, via: q.via, found: true, tail,
|
|
303
325
|
suspectRace: sawTextlessAssistant, model: (obj.message && obj.message.model) || '',
|
|
326
|
+
solicits: solicitsReply(text),
|
|
304
327
|
};
|
|
305
328
|
}
|
|
306
329
|
// assistant message with no visible text = a thinking-only or tool_use-only block sitting AFTER the
|
|
@@ -352,6 +375,44 @@ function isRealUserPrompt(obj) {
|
|
|
352
375
|
// Event-loop-friendly sleep for the Stop flush-race re-read beat (handle awaits this; no busy-wait).
|
|
353
376
|
function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
|
|
354
377
|
|
|
378
|
+
// LEXICAL solicitation detector (the '?'-less rescue). STRONG, formulaic offer phrases only, tested
|
|
379
|
+
// against the CLOSING SENTENCE of the final non-empty line. Weak/courtesy phrases ("let me know",
|
|
380
|
+
// "if you want", "happy to") are deliberately absent: as sign-offs after completed work they are
|
|
381
|
+
// legitimate statement endings, and enforcing them would trade a rare false-✻ for chronic false-◐.
|
|
382
|
+
// "green-light"/"confirm" are imperative-anchored (sentence-initial) so a recap that merely MENTIONS
|
|
383
|
+
// them ("Andrew green-lighted the batch.") can't fire.
|
|
384
|
+
const SOLICIT_STRONG = [
|
|
385
|
+
/\bwant me to\b/i,
|
|
386
|
+
/\bwould you like\b/i,
|
|
387
|
+
/\bshould i\b/i,
|
|
388
|
+
/\bshall i\b/i,
|
|
389
|
+
/\bdo you want\b/i,
|
|
390
|
+
/\bsay the word\b/i,
|
|
391
|
+
/\by\/n\b/i,
|
|
392
|
+
/\bok(?:ay)? to proceed\b/i,
|
|
393
|
+
/^green-?light\b/i,
|
|
394
|
+
/^confirm\b/i,
|
|
395
|
+
// Harvested 2026-07-08 from real _debug.log flag-turn tails — the historical "flag masked the
|
|
396
|
+
// prose violation" shapes. All are unambiguous waiting-on-you closers:
|
|
397
|
+
/^tell me\b/i,
|
|
398
|
+
/\byour call\b/i,
|
|
399
|
+
/\bready when you are\b/i,
|
|
400
|
+
/\bstanding by\b/i,
|
|
401
|
+
/\bon your go\b/i,
|
|
402
|
+
/\bwhenever you say\b/i,
|
|
403
|
+
];
|
|
404
|
+
function solicitsReply(text) {
|
|
405
|
+
if (!text) return false;
|
|
406
|
+
const lines = String(text).trim().split('\n').filter(l => l.trim());
|
|
407
|
+
const lastLine = (lines[lines.length - 1] || '').trim();
|
|
408
|
+
// Any '?' on the final line means the question grade (qtail/signoff) owns the verdict — the
|
|
409
|
+
// lexicon exists solely for the question-less slip, and must never second-guess a graded line.
|
|
410
|
+
if (!lastLine || lastLine.includes('?')) return false;
|
|
411
|
+
const parts = lastLine.split(/[.!:;]\s+/);
|
|
412
|
+
const closing = (parts[parts.length - 1] || '').trim();
|
|
413
|
+
return SOLICIT_STRONG.some(re => re.test(closing));
|
|
414
|
+
}
|
|
415
|
+
|
|
355
416
|
// Deferred flag-turn grade (debug-gated). The `.ask` fast path paints ◐ without reading the
|
|
356
417
|
// transcript, which blinded _debug.log's qmark/via/tail diagnostics on exactly the turns where the
|
|
357
418
|
// flag+sign-off misuse pattern shows up. Grading BEFORE emit() would re-open the kill window the
|
|
@@ -460,12 +521,15 @@ function buildBlocks(names, file, cwd, cmd) {
|
|
|
460
521
|
}
|
|
461
522
|
|
|
462
523
|
// Should THIS copy of the hook stand down? True only for the user-level copy (~/.claude/hooks)
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
|
|
524
|
+
// when the session's OWN project (ownerDir = CLAUDE_PROJECT_DIR, cwd fallback) ships a managed
|
|
525
|
+
// copy — that's the only case where the project copy is registered and will paint; the project
|
|
526
|
+
// copy wins so a session gets ONE directive and ONE title file. The event's live cwd must NOT be
|
|
527
|
+
// used here (mid-session cd into a copy-shipping repo would silence both copies — 2026-07-08).
|
|
528
|
+
// Parameterized (no ambient __dirname/homedir) for tests.
|
|
529
|
+
function shouldDefer(ownerDir, selfDir, selfFile, homeHooksDir) {
|
|
466
530
|
try {
|
|
467
531
|
if (path.resolve(selfDir) !== path.resolve(homeHooksDir)) return false; // we ARE the project copy
|
|
468
|
-
const projectCopy = path.join(
|
|
532
|
+
const projectCopy = path.join(ownerDir, '.claude', 'hooks', 'terminal-title.js');
|
|
469
533
|
return fs.existsSync(projectCopy) && path.resolve(projectCopy) !== path.resolve(selfFile);
|
|
470
534
|
} catch (_) {
|
|
471
535
|
return false;
|
|
@@ -481,4 +545,4 @@ function extractBlock(tpl, name) {
|
|
|
481
545
|
}
|
|
482
546
|
|
|
483
547
|
// Exported for tests (require()'d when require.main !== module). The hook itself never reads these.
|
|
484
|
-
module.exports = { inspectLastResponse, endsOnQuestion, normalize, GLYPH, shouldDefer };
|
|
548
|
+
module.exports = { inspectLastResponse, endsOnQuestion, normalize, GLYPH, shouldDefer, solicitsReply };
|
package/.claude/settings.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"hooks": [
|
|
11
11
|
{
|
|
12
12
|
"type": "command",
|
|
13
|
-
"command": "node
|
|
13
|
+
"command": "node \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/migrate-feedback.js\""
|
|
14
14
|
}
|
|
15
15
|
]
|
|
16
16
|
},
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"hooks": [
|
|
20
20
|
{
|
|
21
21
|
"type": "command",
|
|
22
|
-
"command": "node
|
|
22
|
+
"command": "node \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/terminal-title.js\""
|
|
23
23
|
}
|
|
24
24
|
]
|
|
25
25
|
}
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"hooks": [
|
|
31
31
|
{
|
|
32
32
|
"type": "command",
|
|
33
|
-
"command": "node
|
|
33
|
+
"command": "node \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/terminal-title.js\""
|
|
34
34
|
}
|
|
35
35
|
]
|
|
36
36
|
}
|
|
@@ -41,11 +41,11 @@
|
|
|
41
41
|
"hooks": [
|
|
42
42
|
{
|
|
43
43
|
"type": "command",
|
|
44
|
-
"command": "node
|
|
44
|
+
"command": "node \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/terminal-title.js\""
|
|
45
45
|
},
|
|
46
46
|
{
|
|
47
47
|
"type": "command",
|
|
48
|
-
"command": "node
|
|
48
|
+
"command": "node \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/arcade-beeps.js\""
|
|
49
49
|
}
|
|
50
50
|
]
|
|
51
51
|
}
|
|
@@ -56,11 +56,11 @@
|
|
|
56
56
|
"hooks": [
|
|
57
57
|
{
|
|
58
58
|
"type": "command",
|
|
59
|
-
"command": "node
|
|
59
|
+
"command": "node \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/terminal-title.js\""
|
|
60
60
|
},
|
|
61
61
|
{
|
|
62
62
|
"type": "command",
|
|
63
|
-
"command": "node
|
|
63
|
+
"command": "node \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/arcade-beeps.js\""
|
|
64
64
|
}
|
|
65
65
|
]
|
|
66
66
|
}
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"hooks": [
|
|
72
72
|
{
|
|
73
73
|
"type": "command",
|
|
74
|
-
"command": "node
|
|
74
|
+
"command": "node \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/feedback-rule-check.js\""
|
|
75
75
|
}
|
|
76
76
|
]
|
|
77
77
|
},
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
"hooks": [
|
|
81
81
|
{
|
|
82
82
|
"type": "command",
|
|
83
|
-
"command": "node
|
|
83
|
+
"command": "node \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/terminal-title.js\""
|
|
84
84
|
}
|
|
85
85
|
]
|
|
86
86
|
}
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v1.0.202
|
|
4
|
+
- feat(terminal-title): The 'awaiting your reply' tab indicator now also catches sign-off style endings like 'Ready when you are', and quietly logs a warning if a Claude Code update changes the data the hooks rely on.
|
|
5
|
+
|
|
6
|
+
## v1.0.201
|
|
7
|
+
- fix(install): Hook commands now survive cd'ing around your project — no more 'Cannot find module' errors after changing directories, and upgrades fix existing installs automatically.
|
|
8
|
+
- fix(terminal-title): The tab title no longer freezes when a session cd's into another project, and the 'awaiting your reply' half-circle now catches replies that ask for your go-ahead without a question mark.
|
|
9
|
+
|
|
3
10
|
## v1.0.200
|
|
4
11
|
- feat(changelog): Release notes now written in plain language
|
|
5
12
|
|
|
@@ -143,9 +150,3 @@
|
|
|
143
150
|
## v1.0.153
|
|
144
151
|
- style: left-align all table cells in docs info cards
|
|
145
152
|
|
|
146
|
-
## v1.0.152
|
|
147
|
-
- fix: warn against ! prefix workaround in inside-Claude message
|
|
148
|
-
|
|
149
|
-
## v1.0.151
|
|
150
|
-
- fix: scope find permission to project directory for security
|
|
151
|
-
|