claude-code-autoconfig 1.0.197 → 1.0.199

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.
@@ -0,0 +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
+ ```
@@ -0,0 +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
+ ```
@@ -878,6 +878,16 @@
878
878
  <span class="tree-file-icon">šŸ“„</span>
879
879
  <span class="file">commit-and-push.md</span>
880
880
  </div>
881
+ <div class="tree-item indent-3 hidden" data-info="disable-arcade-beeps" data-parent="commands">
882
+ <span class="tree-spacer"></span>
883
+ <span class="tree-file-icon">šŸ“„</span>
884
+ <span class="file">disable-arcade-beeps.md</span>
885
+ </div>
886
+ <div class="tree-item indent-3 hidden" data-info="enable-arcade-beeps" data-parent="commands">
887
+ <span class="tree-spacer"></span>
888
+ <span class="tree-file-icon">šŸ“„</span>
889
+ <span class="file">enable-arcade-beeps.md</span>
890
+ </div>
881
891
  <div class="tree-item indent-3 hidden" data-info="extract-rules" data-parent="commands">
882
892
  <span class="tree-spacer"></span>
883
893
  <span class="tree-file-icon">šŸ“„</span>
@@ -933,6 +943,11 @@
933
943
  <span class="tree-folder-icon">šŸ“</span>
934
944
  <span class="folder">hooks</span>
935
945
  </div>
946
+ <div class="tree-item indent-3 hidden" data-info="arcade-beeps-hook" data-parent="hooks">
947
+ <span class="tree-spacer"></span>
948
+ <span class="tree-file-icon">šŸ“„</span>
949
+ <span class="file">arcade-beeps.js</span>
950
+ </div>
936
951
  <div class="tree-item indent-3 hidden" data-info="feedback-rule-check-hook" data-parent="hooks">
937
952
  <span class="tree-spacer"></span>
938
953
  <span class="tree-file-icon">šŸ“„</span>
@@ -1263,7 +1278,7 @@
1263
1278
  title: '.claude/ Directory',
1264
1279
  desc: 'Commands, rules, settings, and these docs. Keeps configuration organized as your project grows.'
1265
1280
  },
1266
- 'rules': {
1281
+ 'rules': {
1267
1282
  title: 'rules/',
1268
1283
  desc: 'Path-scoped context that loads when Claude works on matching files.'
1269
1284
  },
@@ -1298,6 +1313,16 @@
1298
1313
  desc: 'Runs tests, then stages all changes, generates a conventional commit message, commits, and pushes.<div style="margin-top: 12px;"><strong>Parameters</strong><div style="margin-top: 4px; opacity: 0.6;">None</div></div><div style="margin-top: 12px;"><strong>Responses</strong><table style="margin-top: 6px; border-collapse: collapse; font-size: 0.9em; text-align: left;"><tr style="text-align: left; border-bottom: 1px solid var(--border);"><th style="padding: 4px 8px 4px 0; white-space: nowrap;">Status</th><th style="padding: 4px 8px 4px 0;">Description</th></tr><tr style="border-bottom: 1px solid var(--border);"><td style="padding: 4px 8px 4px 0; vertical-align: top; white-space: nowrap;"><code>success</code></td><td style="padding: 4px 8px 4px 0; vertical-align: top;">Changes committed and pushed to current branch.</td></tr><tr style="border-bottom: 1px solid var(--border);"><td style="padding: 4px 8px 4px 0; vertical-align: top; white-space: nowrap;"><code>test-failure</code></td><td style="padding: 4px 8px 4px 0; vertical-align: top;">Tests failed — no commit made.</td></tr></table></div><div style="margin-top: 12px;"><strong>Side Effects</strong><div style="margin-top: 4px; font-size: 0.9em;">Runs test suite, stages all changes, creates git commit, pushes to remote</div></div><div style="margin-top: 12px;"><strong>Examples</strong><div style="margin-top: 6px; background: var(--bg-elevated); border-radius: 6px; padding: 8px 12px; font-family: monospace; font-size: 0.85em;"><div><code>/commit-and-push</code> <span style="opacity: 0.6;">— Run tests, commit, and push</span></div></div></div>',
1299
1314
  trigger: '/commit-and-push'
1300
1315
  },
1316
+ 'disable-arcade-beeps': {
1317
+ title: 'disable-arcade-beeps.md',
1318
+ desc: 'disable-arcade-beeps.md in commands/<div style="margin-top: 12px;"><strong>Parameters</strong><div style="margin-top: 4px; opacity: 0.6;">None</div></div>',
1319
+ trigger: '/disable-arcade-beeps'
1320
+ },
1321
+ 'enable-arcade-beeps': {
1322
+ title: 'enable-arcade-beeps.md',
1323
+ desc: 'enable-arcade-beeps.md in commands/<div style="margin-top: 12px;"><strong>Parameters</strong><div style="margin-top: 4px; opacity: 0.6;">None</div></div>',
1324
+ trigger: '/enable-arcade-beeps'
1325
+ },
1301
1326
  'extract-rules': {
1302
1327
  title: 'extract-rules.md',
1303
1328
  desc: 'Scan Claude artifacts and extract structured rules into .claude/rules/<div style="margin-top: 12px;"><strong>Parameters</strong><table style="margin-top: 6px; border-collapse: collapse; font-size: 0.9em; text-align: left;"><tr style="text-align: left; border-bottom: 1px solid var(--border);"><th style="padding: 4px 8px 4px 0; white-space: nowrap;">Name</th><th style="padding: 4px 8px 4px 0; white-space: nowrap;">Type</th><th style="padding: 4px 8px 4px 0; white-space: nowrap;">Required</th><th style="padding: 4px 8px 4px 0;">Description</th></tr><tr style="border-bottom: 1px solid var(--border);"><td style="padding: 4px 8px 4px 0; vertical-align: top; white-space: nowrap;"><code>select</code></td><td style="padding: 4px 8px 4px 0; vertical-align: top; white-space: nowrap;"><code>string</code></td><td style="padding: 4px 8px 4px 0; vertical-align: top;">optional</td><td style="padding: 4px 8px 4px 0; vertical-align: top;">Write only specific rules by number: "1,3,5". Default: all.</td></tr><tr style="border-bottom: 1px solid var(--border);"><td style="padding: 4px 8px 4px 0; vertical-align: top; white-space: nowrap;"><code>keep-sources</code></td><td style="padding: 4px 8px 4px 0; vertical-align: top; white-space: nowrap;"><code>boolean</code></td><td style="padding: 4px 8px 4px 0; vertical-align: top;">optional</td><td style="padding: 4px 8px 4px 0; vertical-align: top;">Write rules but skip source cleanup (Step 8)</td></tr></table></div><div style="margin-top: 12px;"><strong>Responses</strong><table style="margin-top: 6px; border-collapse: collapse; font-size: 0.9em; text-align: left;"><tr style="text-align: left; border-bottom: 1px solid var(--border);"><th style="padding: 4px 8px 4px 0; white-space: nowrap;">Status</th><th style="padding: 4px 8px 4px 0;">Description</th></tr><tr style="border-bottom: 1px solid var(--border);"><td style="padding: 4px 8px 4px 0; vertical-align: top; white-space: nowrap;"><code>success</code></td><td style="padding: 4px 8px 4px 0; vertical-align: top;">Rules extracted, written, and sources cleaned up.</td></tr><tr style="border-bottom: 1px solid var(--border);"><td style="padding: 4px 8px 4px 0; vertical-align: top; white-space: nowrap;"><code>no-rules</code></td><td style="padding: 4px 8px 4px 0; vertical-align: top;">No extractable rules found in scanned sources.</td></tr></table></div><div style="margin-top: 12px;"><strong>Side Effects</strong><div style="margin-top: 4px; font-size: 0.9em;">Creates .claude/rules/ files and removes extracted content from source files</div></div><div style="margin-top: 12px;"><strong>Examples</strong><div style="margin-top: 6px; background: var(--bg-elevated); border-radius: 6px; padding: 8px 12px; font-family: monospace; font-size: 0.85em;"><div><code>/extract-rules</code> <span style="opacity: 0.6;">— Scan, propose, prompt for approval, write, and clean up</span></div><div><code>/extract-rules --keep-sources</code> <span style="opacity: 0.6;">— Write rules without modifying source files</span></div><div><code>/extract-rules --select 1,3</code> <span style="opacity: 0.6;">— Write only rules #1 and #3</span></div></div></div>',
@@ -1333,6 +1358,11 @@
1333
1358
  desc: 'Placeholder — drop your project\'s custom subagent definitions in this directory.',
1334
1359
  trigger: 'Background agent'
1335
1360
  },
1361
+ 'arcade-beeps-hook': {
1362
+ title: 'arcade-beeps.js',
1363
+ desc: 'arcade-beeps.js in hooks/',
1364
+ trigger: 'PostToolUse hook'
1365
+ },
1336
1366
  'feedback-rule-check-hook': {
1337
1367
  title: 'feedback-rule-check.js',
1338
1368
  desc: 'After FEEDBACK.md is edited, notifies Claude to evaluate',
@@ -1569,7 +1599,7 @@ CRITICAL: A plausible-looking cause from code reading is NOT confirmed evidence.
1569
1599
 
1570
1600
  > Run \`/autoconfig\` to populate this based on your project.`
1571
1601
  },
1572
- 'autoconfig-update': {
1602
+ 'autoconfig-update': {
1573
1603
  filename: 'autoconfig-update.md',
1574
1604
  content: `<!-- @applied
1575
1605
  -->
@@ -1656,6 +1686,32 @@ Use conventional commits: \`type(scope): description\`
1656
1686
  Types: feat, fix, docs, style, refactor, test, chore
1657
1687
 
1658
1688
  Keep the subject line under 50 chars. Add body if the change needs explanation.`
1689
+ },
1690
+ 'disable-arcade-beeps': {
1691
+ filename: 'disable-arcade-beeps.md',
1692
+ content: `---
1693
+ description: Turn OFF Pole Position tab status beeps
1694
+ allowed-tools: Bash
1695
+ ---
1696
+
1697
+ Run exactly this command, then reply with a single confirmation line and nothing else:
1698
+
1699
+ \`\`\`
1700
+ rm -f ~/.claude/sounds/arcade-beeps.enabled && echo "arcade beeps DISABLED."
1701
+ \`\`\``
1702
+ },
1703
+ 'enable-arcade-beeps': {
1704
+ filename: 'enable-arcade-beeps.md',
1705
+ content: `---
1706
+ description: Turn ON Pole Position tab status beeps (awaiting = low tone, complete = high tone)
1707
+ allowed-tools: Bash
1708
+ ---
1709
+
1710
+ Run exactly this command, then reply with a single confirmation line and nothing else:
1711
+
1712
+ \`\`\`
1713
+ 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."
1714
+ \`\`\``
1659
1715
  },
1660
1716
  'extract-rules': {
1661
1717
  filename: 'extract-rules.md',
@@ -1837,6 +1893,39 @@ claude-code-autoconfig ships this folder empty so you can populate it with agent
1837
1893
 
1838
1894
  See Anthropic's subagents docs for the file format:
1839
1895
  https://docs.anthropic.com/en/docs/claude-code/sub-agents`
1896
+ },
1897
+ 'arcade-beeps-hook': {
1898
+ filename: 'arcade-beeps.js',
1899
+ content: `#!/usr/bin/env node
1900
+ /**
1901
+ * arcade-beeps — optional Pole-Position status cues for the Claude Code tab.
1902
+ * Companion to terminal-title.js. Registered (settings.json) on Stop + Notification.
1903
+ *
1904
+ * OFF unless the enable flag exists: ~/.claude/sounds/arcade-beeps.enabled
1905
+ * toggle via /enable-arcade-beeps /disable-arcade-beeps
1906
+ * The flag is GLOBAL (homedir), so one toggle covers every project you've installed into.
1907
+ *
1908
+ * Mapping (matches the ◐/✻ tab glyph that terminal-title.js paints on the SAME event):
1909
+ * Stop, turn ended on a question -> ◐ awaiting -> get-ready tick (pp3-getready-G4.wav, G4/384Hz)
1910
+ * Stop, turn ended normally -> ✻ complete -> GO beep (pp3-go-F#5.wav, F#5/759Hz)
1911
+ * Notification (permission_prompt) -> ◐ awaiting -> get-ready tick
1912
+ *
1913
+ * Tones are an extracted+smoothed take on the Pole Position race-start cue (arcade-ping
1914
+ * envelope). LOWER tick = awaiting, HIGHER GO = complete.
1915
+ *
1916
+ * Assets resolve relative to THIS file (../sounds) so a per-project CCA install finds its own
1917
+ * copy at <project>/.claude/sounds — no dependency on a global sounds dir.
1918
+ *
1919
+ * Playback is cross-platform and best-effort: PowerShell SoundPlayer on Windows, afplay on
1920
+ * macOS, paplay||aplay on Linux. If none is present the hook simply stays silent — never errors.
1921
+ *
1922
+ * Playback BLOCKS (spawnSync) so the sound finishes inside the hook's lifetime — a detached
1923
+ * fire-and-forget child can be killed by the hook runner's job/process-group cleanup before it
1924
+ * plays. Blocking costs ~0.5s on turn-end but is reliable. Still fail-safe: wrapped so it never
1925
+ * throws into the turn, and every path ends in exit(0).
1926
+ *
1927
+ * State detection reuses terminal-title.js's exported inspectLastResponse (lazy-required only
1928
+ * when enabled) so sound and glyph agree. A tiny inline '?' check is the fallback.`
1840
1929
  },
1841
1930
  'feedback-rule-check-hook': {
1842
1931
  filename: 'feedback-rule-check.js',
@@ -2082,6 +2171,10 @@ Claude reads this file but must never write to it. When Claude discovers project
2082
2171
  {
2083
2172
  "type": "command",
2084
2173
  "command": "node .claude/hooks/terminal-title.js"
2174
+ },
2175
+ {
2176
+ "type": "command",
2177
+ "command": "node .claude/hooks/arcade-beeps.js"
2085
2178
  }
2086
2179
  ]
2087
2180
  }
@@ -2093,6 +2186,10 @@ Claude reads this file but must never write to it. When Claude discovers project
2093
2186
  {
2094
2187
  "type": "command",
2095
2188
  "command": "node .claude/hooks/terminal-title.js"
2189
+ },
2190
+ {
2191
+ "type": "command",
2192
+ "command": "node .claude/hooks/arcade-beeps.js"
2096
2193
  }
2097
2194
  ]
2098
2195
  }
@@ -0,0 +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 };
@@ -52,25 +52,30 @@ Maintain it across the session:
52
52
  title. Name the goal the command ACCOMPLISHES ("{{FOLDER}} {{EMDASH}} <verb the goal>",
53
53
  never "{{FOLDER}} {{EMDASH}} <command-name>").
54
54
 
55
- Pending-question signal: when you END a turn on a question the user must answer before you
56
- can proceed, do BOTH as near-final actions so the tab flips to the AWAITING half-circle
57
- (instead of the idle asterisk):
55
+ Pending-question signal -- END-OF-TURN TEST, apply it on every turn: does your final
56
+ paragraph ask the user something you need answered before the work can continue?
57
+ - YES (you are blocked on the user) -> do BOTH as near-final actions so the tab flips to the
58
+ AWAITING half-circle (instead of the idle asterisk):
58
59
  1. Write the flag file {{ASK_FILE}} (any short content, e.g. "1"). This is the RELIABLE
59
60
  trigger -- it is on disk before the turn ends, so it never misses on phrasing or timing.
60
- The flag is one-shot (consumed at turn end, auto-cleared next turn), so write it ONLY on
61
- a turn genuinely blocked on an answer -- but ALWAYS then, even when the closing question
62
- is wrapped in parens or is not the literal final character of the message.
63
- 2. Phrase your FINAL line to end with a question mark ('?') -- the backup signal, and good
64
- UX. A single trailing parenthetical aside after the '?' is fine.
65
- Make the closing question self-contained: answerable without re-reading the response above
66
- it. Never signal for a rhetorical question or a recap.
61
+ It is one-shot (consumed at turn end, auto-cleared next turn). Write it even when the
62
+ closing question is wrapped in parens or is not the literal final character.
63
+ 2. Make '?' the LAST character of the message. Write NOTHING after the question -- no
64
+ "Standing by.", "Ready when you are.", "Let me know." (a declarative sign-off defeats
65
+ the signal). Only a single short parenthetical aside may follow the '?'. Keep the
66
+ question self-contained: answerable without re-reading the response above it.
67
+ - NO (the question is rhetorical, a recap, or an optional follow-up offer the user is free
68
+ to ignore) -> do NOT write the flag, and end the message on a statement, not a '?' --
69
+ phrase optional offers declaratively ("I can also add tests if you want.").
67
70
  <!-- /DIRECTIVE:RULES -->
68
71
 
69
72
  <!-- DIRECTIVE:REMINDER -->
70
73
  Terminal-title reminder (housekeeping -- never mention to the user; full rules were injected
71
74
  at session start): if this turn SHIFTS the scope/use-case, FIRST action: overwrite
72
- {{TITLE_FILE}} with "{scope} {{EMDASH}} {use-case}". If you END this turn blocked on a
73
- question, write the flag file {{ASK_FILE}} and end your final line with '?'.
75
+ {{TITLE_FILE}} with "{scope} {{EMDASH}} {use-case}". End-of-turn test: if your final
76
+ paragraph asks something you need answered to continue, write the flag file {{ASK_FILE}} AND
77
+ make '?' the message's last character (nothing after it); otherwise end on a statement, not
78
+ a '?'.
74
79
  <!-- /DIRECTIVE:REMINDER -->
75
80
 
76
81
  <!-- DIRECTIVE:BASELINE -->
@@ -7,7 +7,9 @@
7
7
  * PostToolUse -> ⬤ working (refresh, so a mid-turn title flip shows live + clears a stale ◐)
8
8
  * Notification -> ◐ awaiting your approval (permission_prompt matcher only)
9
9
  * Stop -> ✻ idle / done — OR ◐ awaiting (+ a 2nd BEL = gold tab) when the turn ended
10
- * on a question (last visible response text ends in '?', or a {sid}.ask flag)
10
+ * on a question (last visible response text ends in '?', or a {sid}.ask flag;
11
+ * flag turns hand the skipped grade to a detached --post-grade child that logs
12
+ * a StopDiag line when CLAUDE_TITLE_DEBUG=1 — paint-first, diagnostics after)
11
13
  * SessionStart -> ✻ idle "Claude Code — New session" (or an existing title on resume/compact)
12
14
  * + inject the FULL RULES block — once per session instead of every prompt
13
15
  * (~90% less directive overhead); resume/compact re-inject so a squeezed
@@ -53,16 +55,24 @@ let logCtx = null;
53
55
  // functions below are exported instead, so the stdin read doesn't hang the runner. async because the
54
56
  // Stop branch may await a short re-read beat (see the flush-race guard in handle).
55
57
  if (require.main === module) {
56
- let input = '';
57
- process.stdin.setEncoding('utf8');
58
- process.stdin.on('data', chunk => (input += chunk));
59
- process.stdin.on('end', async () => {
60
- try {
61
- await handle(JSON.parse(input));
62
- } catch (err) {
63
- process.exit(0); // never break the turn on a title error — emit nothing
64
- }
65
- });
58
+ if (process.argv[2] === '--post-grade') {
59
+ // Detached child mode: grade a flag-turn transcript purely for the debug log (see postGrade).
60
+ // No stdin — the payload rides argv so the parent never waits on this process. setImmediate
61
+ // defers the run until module evaluation has finished, so no declaration below this block can
62
+ // be hit while still in its temporal dead zone.
63
+ setImmediate(() => postGrade(process.argv[3]).then(() => process.exit(0), () => process.exit(0)));
64
+ } else {
65
+ let input = '';
66
+ process.stdin.setEncoding('utf8');
67
+ process.stdin.on('data', chunk => (input += chunk));
68
+ process.stdin.on('end', async () => {
69
+ try {
70
+ await handle(JSON.parse(input));
71
+ } catch (err) {
72
+ process.exit(0); // never break the turn on a title error — emit nothing
73
+ }
74
+ });
75
+ }
66
76
  }
67
77
 
68
78
  async function handle(data) {
@@ -128,16 +138,41 @@ async function handle(data) {
128
138
  // Stop: idle, UNLESS the turn ended on a question the user must answer — then awaiting + a 2nd BEL
129
139
  // so VS Code paints the (otherwise bell-less) tab gold. "Ended on a question" = last visible
130
140
  // assistant text ends in '?' (transcript heuristic) OR an explicit {sid}.ask flag (consumed here).
141
+
142
+ // FAILSAFE PRE-PAINT — flip to ✻ idle SYNCHRONOUSLY now, before the async grade below. That grade
143
+ // reads/re-reads the transcript to dodge the flush race and can either throw (caught → exit 0, emits
144
+ // nothing) or be killed on a huge, slow-to-flush final message — either way it would otherwise leave
145
+ // the tab stuck on the last ⬤. process.title (SetConsoleTitleW) takes effect immediately and persists
146
+ // after exit, so the tab is correct even if we die below. Idle is the default Stop outcome; the grade
147
+ // only ever UPGRADES it to ◐ awaiting (worst case: a <1s ✻ flash before ◐ on a question turn, and the
148
+ // {sid}.ask flag already backstops that case).
149
+ try { process.title = `${GLYPH.idle} ${normalize(readTitle(file) || folderName(cwd))}`; } catch (_) { /* ignore */ }
150
+
131
151
  const askFile = path.join(dir, `${sid}.ask`);
132
152
  const askPresent = fileExists(askFile);
153
+ if (askPresent) { try { fs.unlinkSync(askFile); } catch (_) { /* ignore */ } }
133
154
 
134
- // Grade the transcript, guarding the flush race: the final assistant text can land in the JSONL a beat
135
- // AFTER Stop fires (the screen renders from the live stream; the file append lags by ~200ms). The tell
136
- // is `suspectRace`: the freshest on-disk assistant block is text-less (thinking/tool_use only), or no
137
- // text was found at all — both mean the real final message is still flushing. When that's the case,
138
- // re-read a few times (~120ms apart) before grading, so a turn that actually ended on '?' isn't painted
139
- // ✻ idle off a stale earlier block. A fully-flushed turn hits the text block first → suspectRace=false →
140
- // zero delay. The {sid}.ask flag remains the race-proof backstop.
155
+ // FAST PATH — the {sid}.ask flag is the race-proof "ended on a question" signal, written to disk BEFORE
156
+ // Stop fires. When present, paint ◐ awaiting and emit() IMMEDIATELY, skipping the transcript grade below.
157
+ // This is the stuck-⬤ fix: emit() (the terminalSequence CC applies on clean exit) is the ONLY paint VS
158
+ // Code honors, and the async grade can be KILLED on a huge / slow-to-flush transcript before it reaches
159
+ // emit() — which froze the tab on the last ⬤ working. Reaching emit() synchronously here closes that
160
+ // window for every flagged question turn (the common case).
161
+ if (askPresent) {
162
+ const deferred = spawnDeferredGrade(data, dir, sid, file, cwd);
163
+ if (logCtx) {
164
+ logCtx.note = 'ask-flag';
165
+ logCtx.diag = `ask=1 fast-path (${deferred ? 'grade deferred to StopDiag' : 'grade skipped'})`;
166
+ }
167
+ emit(setTitle(GLYPH.awaiting, normalize(readTitle(file) || folderName(cwd)), true));
168
+ return; // emit() exits; the return keeps control flow honest if that ever changes
169
+ }
170
+
171
+ // No flag → default idle, but the turn may have ended on '?' without one. Grade the transcript, guarding
172
+ // the flush race: the final assistant text can land in the JSONL a beat AFTER Stop fires (~200ms append
173
+ // lag). `suspectRace` (freshest on-disk assistant block is text-less, or none found) means the real final
174
+ // message is still flushing → re-read a few times before grading. Each pass reads only the transcript
175
+ // TAIL, so the loop stays fast on a multi-MB transcript and always reaches emit().
141
176
  let q = inspectLastResponse(data.transcript_path);
142
177
  let reread = 0;
143
178
  while (!q.ends && (q.suspectRace || !q.found) && reread < 7) {
@@ -147,13 +182,10 @@ async function handle(data) {
147
182
  if (q.ends || (q.found && !q.suspectRace)) break;
148
183
  }
149
184
 
150
- let pending = q.ends;
151
- let note = pending ? 'q-mark' : 'idle';
152
- if (!pending && askPresent) { pending = true; note = 'ask-flag'; }
153
- if (askPresent) { try { fs.unlinkSync(askFile); } catch (_) { /* ignore */ } }
185
+ const pending = q.ends;
154
186
  if (logCtx) {
155
- logCtx.note = note;
156
- logCtx.diag = `ask=${askPresent ? 1 : 0} qmark=${q.ends ? 1 : 0} found=${q.found ? 1 : 0} reread=${reread} tail="${q.tail}"`;
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}"`;
157
189
  }
158
190
  const glyph = pending ? GLYPH.awaiting : GLYPH.idle;
159
191
  emit(setTitle(glyph, normalize(readTitle(file) || folderName(cwd)), pending));
@@ -203,11 +235,30 @@ function fileExists(file) {
203
235
  // shows found=0 (or a stale tail), a genuine regex miss shows a tail that's present but doesn't end
204
236
  // in '?'. Any error → a blank record (treated as "no question"), matching the old false return.
205
237
  function inspectLastResponse(transcriptPath) {
206
- const blank = { ends: false, found: false, tail: '', suspectRace: false };
238
+ const blank = { ends: false, via: '', found: false, tail: '', suspectRace: false, model: '' };
207
239
  if (!transcriptPath) return blank;
208
240
  let content;
209
241
  try {
210
- content = fs.readFileSync(transcriptPath, 'utf8');
242
+ // Read only the TAIL of the transcript. The current turn's final message sits at the very end, and
243
+ // reading the whole multi-MB JSONL of a long session — then re-reading it up to 7Ɨ in the flush-race
244
+ // loop — is what let a Stop grade run long enough to be killed before it painted (the stuck-⬤ bug).
245
+ // A fixed tail keeps every pass fast regardless of session length; the leading partial line is dropped.
246
+ const TAIL_BYTES = 1024 * 1024;
247
+ const size = fs.statSync(transcriptPath).size;
248
+ if (size <= TAIL_BYTES) {
249
+ content = fs.readFileSync(transcriptPath, 'utf8');
250
+ } else {
251
+ const fd = fs.openSync(transcriptPath, 'r');
252
+ try {
253
+ const buf = Buffer.alloc(TAIL_BYTES);
254
+ fs.readSync(fd, buf, 0, TAIL_BYTES, size - TAIL_BYTES);
255
+ const tail = buf.toString('utf8');
256
+ const nl = tail.indexOf('\n');
257
+ content = nl >= 0 ? tail.slice(nl + 1) : tail; // drop the partial first line
258
+ } finally {
259
+ fs.closeSync(fd);
260
+ }
261
+ }
211
262
  } catch (_) {
212
263
  return blank;
213
264
  }
@@ -244,15 +295,13 @@ function inspectLastResponse(transcriptPath) {
244
295
  if (text.trim()) {
245
296
  // last ~60 chars, collapsed to one line and quote-stripped so it can't break the log framing
246
297
  const tail = text.trim().slice(-60).replace(/\s+/g, ' ').replace(/"/g, "'");
247
- // Ends on a question? A '?' at the end, tolerating trailing whitespace / ) * _ " — AND an optional
248
- // SINGLE trailing parenthetical aside after it ("How should we handle it? (I lean option 2.)"), a
249
- // common shape: ask, then a bracketed recommendation/clarifier. The aside is one level only
250
- // (`[^()]`, no nesting) and must sit at the very end, so a mid-message rhetorical '?' or a plain
251
- // statement ending in ')' still won't match — only a genuine closing question does. Belt to the
252
- // {sid}.ask flag's suspenders: the flag is the primary, parse-free path; this only hardens the
253
- // fallback for turns that ended on a parenthetical question without writing one.
254
- const endsOnQuestion = /\?[\s)*_"]*(\([^()]*\)[\s.*_"]*)?$/.test(text);
255
- return { ends: endsOnQuestion, found: true, tail, suspectRace: sawTextlessAssistant };
298
+ const q = endsOnQuestion(text);
299
+ // `model` (e.g. "claude-fable-5") rides into the debug diag so per-model miss rates can be
300
+ // compared straight from _debug.log.
301
+ return {
302
+ ends: q.ends, via: q.via, found: true, tail,
303
+ suspectRace: sawTextlessAssistant, model: (obj.message && obj.message.model) || '',
304
+ };
256
305
  }
257
306
  // assistant message with no visible text = a thinking-only or tool_use-only block sitting AFTER the
258
307
  // last text we'll grade — a strong hint the final text line hasn't flushed yet.
@@ -261,6 +310,36 @@ function inspectLastResponse(transcriptPath) {
261
310
  return blank;
262
311
  }
263
312
 
313
+ // Did the text end on a question the user must answer? Two tiers (via names the matched one):
314
+ // 'qtail' — the text itself ends on '?', tolerating trailing whitespace / ) * _ " AND one
315
+ // trailing parenthetical aside ("How should we handle it? (I lean option 2.)").
316
+ // A mid-message rhetorical '?' or a plain statement ending in ')' won't match.
317
+ // 'signoff' — the text ends on ONE short declarative sign-off line BELOW a question-ending
318
+ // line ("…which do you prefer?\n\nLet me know."). The directive forbids the
319
+ // sign-off, but a model that forgets it shouldn't cost the user the ◐ — so
320
+ // tolerate exactly one trailing line, and only when it is short (≤48 chars),
321
+ // '?'-free, and not list/heading/quote/table/fence content, so a question
322
+ // followed by real elaboration (options list, explanation) still grades idle.
323
+ // Same-line trailing statements ("Want me to proceed? Done.") stay non-questions
324
+ // — a mid-line '?' is exactly the rhetorical shape the grade must not fire on.
325
+ // Belt to the {sid}.ask flag's suspenders: the flag is the primary, parse-free path; this only
326
+ // hardens the transcript fallback for turns that didn't write one.
327
+ // QTAIL lives INSIDE the function (not module-level const) — the --post-grade child calls into
328
+ // this during module evaluation, and a module-level const below the require.main block is still
329
+ // in its temporal dead zone at that point.
330
+ function endsOnQuestion(text) {
331
+ const QTAIL = /\?[\s)*_"]*(\([^()]*\)[\s.*_"]*)?$/;
332
+ if (QTAIL.test(text)) return { ends: true, via: 'qtail' };
333
+ const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
334
+ if (lines.length >= 2) {
335
+ const last = lines[lines.length - 1];
336
+ const isSignoff = last.length <= 48 && !last.includes('?')
337
+ && !/^(?:[-*•>]\s|#{1,6}\s|\||\d+[.)]\s|`{3})/.test(last);
338
+ if (isSignoff && QTAIL.test(lines[lines.length - 2])) return { ends: true, via: 'signoff' };
339
+ }
340
+ return { ends: false, via: '' };
341
+ }
342
+
264
343
  // A genuine human prompt (real text) vs a tool_result-carrier user message (content is only
265
344
  // tool_result blocks). Marks the boundary of the current turn's response while walking the transcript.
266
345
  function isRealUserPrompt(obj) {
@@ -273,6 +352,56 @@ function isRealUserPrompt(obj) {
273
352
  // Event-loop-friendly sleep for the Stop flush-race re-read beat (handle awaits this; no busy-wait).
274
353
  function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
275
354
 
355
+ // Deferred flag-turn grade (debug-gated). The `.ask` fast path paints ◐ without reading the
356
+ // transcript, which blinded _debug.log's qmark/via/tail diagnostics on exactly the turns where the
357
+ // flag+sign-off misuse pattern shows up. Grading BEFORE emit() would re-open the kill window the
358
+ // fast path exists to close, and nothing runs after emit() (it exits) — so hand the grade to a
359
+ // DETACHED child (this same file, --post-grade) that logs on its own time. unref() + ignored stdio
360
+ // mean the parent's exit — and therefore the paint — is never delayed by even one re-read beat.
361
+ function spawnDeferredGrade(data, dir, sid, file, cwd) {
362
+ if (process.env.CLAUDE_TITLE_DEBUG !== '1') return false;
363
+ try {
364
+ const payload = JSON.stringify({
365
+ sid, dir,
366
+ transcriptPath: data.transcript_path || '',
367
+ title: normalize(readTitle(file) || folderName(cwd)),
368
+ });
369
+ const { spawn } = require('child_process');
370
+ spawn(process.execPath, [__filename, '--post-grade', payload], {
371
+ detached: true, stdio: 'ignore', windowsHide: true,
372
+ env: Object.assign({}, process.env, { CLAUDE_TITLE_DEBUG: '1' }),
373
+ }).unref();
374
+ return true;
375
+ } catch (_) {
376
+ return false; // diagnostics are best-effort; the paint path never depends on this
377
+ }
378
+ }
379
+
380
+ // Child mode (--post-grade): grade the transcript purely for the debug log, after the paint already
381
+ // happened. Same flush-race re-read loop as the main Stop grade (and by child-start the final message
382
+ // has usually flushed, so this data is CLEANER than an inline grade would have been). Logs as
383
+ // event=StopDiag, so flag-turn protocol compliance reads straight out of _debug.log:
384
+ // qmark=1 via=qtail -> full compliance (flag AND '?'-last)
385
+ // qmark=1 via=signoff -> flag + banned closer appended after the question (the either/or pattern)
386
+ // qmark=0 -> flag-only; the message ended on a statement
387
+ async function postGrade(payloadJson) {
388
+ const p = JSON.parse(payloadJson || '{}');
389
+ let q = inspectLastResponse(p.transcriptPath);
390
+ let reread = 0;
391
+ while (!q.ends && (q.suspectRace || !q.found) && reread < 7) {
392
+ await delay(120);
393
+ reread++;
394
+ q = inspectLastResponse(p.transcriptPath);
395
+ if (q.ends || (q.found && !q.suspectRace)) break;
396
+ }
397
+ logCtx = {
398
+ event: 'StopDiag', sid: p.sid || '', dir: p.dir || '', note: 'ask-flag',
399
+ diag: `ask=1 qmark=${q.ends ? 1 : 0} via=${q.via || '-'} found=${q.found ? 1 : 0} reread=${reread} model=${q.model || '-'} tail="${q.tail}"`,
400
+ };
401
+ // Reuses titleLog's capped append; glyph/ring mirror what the fast path actually painted.
402
+ titleLog(GLYPH.awaiting, p.title || '', true);
403
+ }
404
+
276
405
  function readTitle(file) {
277
406
  try {
278
407
  if (fs.existsSync(file)) return fs.readFileSync(file, 'utf8').trim();
@@ -352,4 +481,4 @@ function extractBlock(tpl, name) {
352
481
  }
353
482
 
354
483
  // Exported for tests (require()'d when require.main !== module). The hook itself never reads these.
355
- module.exports = { inspectLastResponse, normalize, GLYPH, shouldDefer };
484
+ module.exports = { inspectLastResponse, endsOnQuestion, normalize, GLYPH, shouldDefer };
@@ -42,6 +42,10 @@
42
42
  {
43
43
  "type": "command",
44
44
  "command": "node .claude/hooks/terminal-title.js"
45
+ },
46
+ {
47
+ "type": "command",
48
+ "command": "node .claude/hooks/arcade-beeps.js"
45
49
  }
46
50
  ]
47
51
  }
@@ -53,6 +57,10 @@
53
57
  {
54
58
  "type": "command",
55
59
  "command": "node .claude/hooks/terminal-title.js"
60
+ },
61
+ {
62
+ "type": "command",
63
+ "command": "node .claude/hooks/arcade-beeps.js"
56
64
  }
57
65
  ]
58
66
  }
Binary file
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.0.199
4
+ - feat(cli): grouped update summary on upgrade
5
+ - feat(terminal-title): awaiting-signal hardening — sign-off-tolerant grade, deferred flag-turn diagnostics, failsafe pre-paint
6
+ - fix(terminal-title): require the '?' to be the last character of a pending-question turn
7
+
8
+ ## v1.0.198
9
+ - feat(arcade-beeps): ship opt-in Pole Position status beeps via CCA
10
+
3
11
  ## v1.0.197
4
12
  - perf(terminal-title): deliver the directive once per session; dedupe user/project hooks
5
13
 
@@ -142,9 +150,3 @@
142
150
  ## v1.0.150
143
151
  - style: use pointing emoji in inside-Claude message
144
152
 
145
- ## v1.0.149
146
- - style: consolidate inside-Claude message to two lines
147
-
148
- ## v1.0.148
149
- - fix: refine inside-Claude error wording
150
-
package/README.md CHANGED
@@ -61,7 +61,8 @@ your-project/
61
61
  ā”œā”€ā”€ hooks/ # Hook scripts
62
62
  │ ā”œā”€ā”€ format.js # Auto-format on Write/Edit
63
63
  │ ā”œā”€ā”€ terminal-title.js # Use-case terminal tab titles + live state
64
- │ └── terminal-title.directive.md # Injected title directive (tunable wording)
64
+ │ ā”œā”€ā”€ terminal-title.directive.md # Injected title directive (tunable wording)
65
+ │ └── arcade-beeps.js # Optional Pole Position status beeps (opt-in)
65
66
  ā”œā”€ā”€ docs/ # Interactive documentation
66
67
  │ └── autoconfig.docs.html # Open with /show-docs
67
68
  ā”œā”€ā”€ updates/ # Pending config updates
@@ -70,6 +71,9 @@ your-project/
70
71
  │ └── readme-sync.md # Sync README & docs before publish
71
72
  ā”œā”€ā”€ scripts/ # Utility scripts
72
73
  │ └── sync-docs.js # Regenerate interactive HTML docs
74
+ ā”œā”€ā”€ sounds/ # Status-cue audio for arcade-beeps
75
+ │ ā”œā”€ā”€ pp3-getready-G4.wav # Awaiting tone (get-ready tick)
76
+ │ └── pp3-go-F#5.wav # Complete tone (GO beep)
73
77
  ā”œā”€ā”€ .mcp.json # MCP server configs (empty placeholder)
74
78
  └── settings.json # Permissions & security
75
79
  ```
@@ -110,6 +114,8 @@ Autoconfig is **self-configuring**. Run `/autoconfig` and Claude:
110
114
  | `/gls` | Views latest screenshot for visual context |
111
115
  | `/validate-cca-install` | Validates installation against latest published version |
112
116
  | `/extract-rules` | Scan Claude artifacts and extract structured rules |
117
+ | `/enable-arcade-beeps` | Turn on opt-in Pole Position status beeps |
118
+ | `/disable-arcade-beeps` | Turn off the status beeps |
113
119
 
114
120
  ### Updates
115
121
 
@@ -135,6 +141,10 @@ Autoconfig writes a debug methodology to Claude's persistent memory (`MEMORY.md`
135
141
 
136
142
  Run several Claude Code sessions at once and the tabs all look alike. Autoconfig retitles each tab as `{scope} — {what you're working on}` with a live state indicator — ⬤ working, ◐ waiting on you, ✻ idle — so you can tell sessions apart at a glance. Claude updates the title itself as the work shifts; nothing to configure.
137
143
 
144
+ ### Status Beeps (opt-in)
145
+
146
+ Prefer your ears to your eyes? Run `/enable-arcade-beeps` for Pole Position–style tab cues that mirror the title glyph: a low get-ready tick when a session is **waiting on you** and a higher GO tone when it **finishes**. Cross-platform (Windows / macOS / Linux) and off by default — toggle back off any time with `/disable-arcade-beeps`.
147
+
138
148
  ### Team Feedback
139
149
 
140
150
  When Claude makes a mistake, add an entry to `.claude/feedback/FEEDBACK.md`:
package/bin/cli.js CHANGED
@@ -4,6 +4,7 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const readline = require('readline');
6
6
  const { execSync, spawn } = require('child_process');
7
+ const { formatUpdateSummary } = require('./update-summary.js');
7
8
 
8
9
  const cwd = process.cwd();
9
10
  const packageDir = path.dirname(__dirname);
@@ -27,7 +28,7 @@ const WINDOWS_RESERVED = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'C
27
28
  'LPT6', 'LPT7', 'LPT8', 'LPT9'];
28
29
 
29
30
  // Files/folders installed by autoconfig - don't backup these
30
- const AUTOCONFIG_FILES = ['commands', 'docs', 'agents', 'migration', 'hooks', 'scripts', 'rules', 'feedback', 'settings.json', 'settings.local.json', '.mcp.json', '.autoconfig-version', '.autoconfig-plugins.json'];
31
+ const AUTOCONFIG_FILES = ['commands', 'docs', 'agents', 'migration', 'hooks', 'scripts', 'sounds', 'rules', 'feedback', 'settings.json', 'settings.local.json', '.mcp.json', '.autoconfig-version', '.autoconfig-plugins.json'];
31
32
 
32
33
  function isReservedName(name) {
33
34
  const baseName = name.replace(/\.[^.]*$/, '').toUpperCase();
@@ -775,7 +776,7 @@ if (fs.existsSync(feedbackSrc)) {
775
776
  // (copyDirIfMissing), BUT the cca-managed title-hook files are ALWAYS refreshed so bug-fixes
776
777
  // reach existing installs — without this, copyDirIfMissing leaves stale hooks in place forever
777
778
  // (same always-overwrite rationale as scripts/ below). --force already overwrites everything.
778
- const MANAGED_HOOKS = ['terminal-title.js', 'terminal-title.directive.md'];
779
+ const MANAGED_HOOKS = ['terminal-title.js', 'terminal-title.directive.md', 'arcade-beeps.js'];
779
780
  if (fs.existsSync(hooksSrc)) {
780
781
  const copyFn = forceMode ? copyDir : copyDirIfMissing;
781
782
  copyFn(hooksSrc, path.join(claudeDest, 'hooks'));
@@ -793,6 +794,12 @@ if (fs.existsSync(scriptsSrc)) {
793
794
  copyDir(scriptsSrc, path.join(claudeDest, 'scripts'));
794
795
  }
795
796
 
797
+ // Copy sounds directory (binary status-cue assets for arcade-beeps; always overwrite)
798
+ const soundsSrc = path.join(packageDir, '.claude', 'sounds');
799
+ if (fs.existsSync(soundsSrc)) {
800
+ copyDir(soundsSrc, path.join(claudeDest, 'sounds'));
801
+ }
802
+
796
803
  // Note: updates directory is no longer copied to user projects.
797
804
  // Update files are only used by --pull-updates (for /autoconfig-update).
798
805
  // On fresh install, all updates are pre-marked as applied and the content
@@ -950,43 +957,22 @@ if (isUpgrade) {
950
957
  console.log('\x1b[33mā•‘ ā•‘\x1b[0m');
951
958
  console.log('\x1b[33mā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•\x1b[0m');
952
959
  }
953
- // Show changelog on upgrade
954
- if (isUpgrade && previousVersion) {
960
+ // Show what changed on the upgrade path so a re-run never looks like "nothing came down":
961
+ // grouped features/fixes since the installed version, or a single confirmation line when
962
+ // already on the latest. Rendered here so it lands right before the ENTER prompt.
963
+ // Logic lives in update-summary.js (pure + unit-tested).
964
+ if (isUpgrade) {
955
965
  const changelogPath = path.join(packageDir, 'CHANGELOG.md');
956
- if (fs.existsSync(changelogPath)) {
957
- const changelog = fs.readFileSync(changelogPath, 'utf8');
958
- const prevPatch = parseInt(previousVersion.split('.').pop(), 10);
959
- const entries = [];
960
- let currentEntry = null;
961
- for (const line of changelog.split(/\r?\n/)) {
962
- if (line.startsWith('## v')) {
963
- const ver = line.slice(3).trim();
964
- const patch = parseInt(ver.split('.').pop(), 10);
965
- currentEntry = patch > prevPatch ? { ver, items: [] } : null;
966
- } else if (currentEntry && line.startsWith('- ')) {
967
- currentEntry.items.push(line.slice(2));
968
- } else if (currentEntry && line === '' && currentEntry.items.length > 0) {
969
- entries.push(currentEntry);
970
- currentEntry = null;
971
- }
972
- }
973
- if (currentEntry && currentEntry.items.length > 0) entries.push(currentEntry);
974
- if (entries.length > 0) {
975
- console.log(`\x1b[90m What's new since v${previousVersion}:\x1b[0m`);
976
- console.log();
977
- const show = entries.slice(0, 10);
978
- for (const e of show) {
979
- for (const item of e.items) {
980
- console.log(`\x1b[90m ${e.ver} — ${item}\x1b[0m`);
981
- }
982
- }
983
- const remaining = entries.length - show.length;
984
- if (remaining > 0) {
985
- console.log(`\x1b[90m ... and ${remaining} more (see CHANGELOG.md)\x1b[0m`);
986
- }
987
- console.log();
988
- }
966
+ const changelogText = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, 'utf8') : '';
967
+ console.log();
968
+ for (const seg of formatUpdateSummary(previousVersion, currentVersion, changelogText)) {
969
+ if (seg.kind === 'latest') console.log(`\x1b[32m āœ“ ${seg.text}\x1b[0m`);
970
+ else if (seg.kind === 'heading') console.log(`\x1b[36m ${seg.text}\x1b[0m`);
971
+ else if (seg.kind === 'group') console.log(`\x1b[33m ${seg.text}:\x1b[0m`);
972
+ else if (seg.kind === 'item') console.log(`\x1b[90m • ${seg.text}\x1b[0m`);
973
+ else if (seg.kind === 'more') console.log(`\x1b[90m ${seg.text}\x1b[0m`);
989
974
  }
975
+ console.log();
990
976
  }
991
977
  if (!isUpgrade) {
992
978
  console.log('\x1b[90m%s\x1b[0m', "You'll need to approve a few file prompts to complete the installation.");
@@ -0,0 +1,122 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * update-summary.js — renders the "what changed" summary shown on the installer's
5
+ * upgrade path (right before the ENTER prompt).
6
+ *
7
+ * Pure + side-effect free so it can be unit-tested without executing the installer
8
+ * (cli.js runs its whole flow on require, so its inline logic can't be imported).
9
+ *
10
+ * formatUpdateSummary() returns an array of { kind, text } segments; the caller
11
+ * (cli.js) maps kind -> color + indent:
12
+ * 'latest' -> already on the newest version (a single line, no feature relist)
13
+ * 'heading' -> "What's new since your last update (v…):"
14
+ * 'group' -> "New features" / "Fixes & improvements"
15
+ * 'item' -> one change bullet
16
+ * 'more' -> "… and N more" overflow line
17
+ *
18
+ * Source of truth is CHANGELOG.md ("## vX.Y.Z" headers, "- type(scope): summary" bullets).
19
+ */
20
+
21
+ const MAX_ITEMS = 12;
22
+ // Conventional-commit types that are housekeeping, not user-facing features/fixes.
23
+ const SKIP_TYPES = new Set(['chore', 'docs', 'test', 'ci', 'build', 'style']);
24
+
25
+ function parseVersion(v) {
26
+ return String(v || '').replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0);
27
+ }
28
+
29
+ // -1 if a < b, 0 if equal, 1 if a > b (segment-wise numeric compare).
30
+ function compareVersions(a, b) {
31
+ const pa = parseVersion(a), pb = parseVersion(b);
32
+ const len = Math.max(pa.length, pb.length);
33
+ for (let i = 0; i < len; i++) {
34
+ const d = (pa[i] || 0) - (pb[i] || 0);
35
+ if (d !== 0) return d < 0 ? -1 : 1;
36
+ }
37
+ return 0;
38
+ }
39
+
40
+ // Parse "## vX.Y.Z" sections -> [{ ver, items: [rawBullet, ...] }] in file order (newest first).
41
+ function parseChangelog(text) {
42
+ const entries = [];
43
+ let cur = null;
44
+ for (const line of String(text || '').split(/\r?\n/)) {
45
+ if (line.startsWith('## v')) {
46
+ cur = { ver: line.slice(3).trim(), items: [] };
47
+ entries.push(cur);
48
+ } else if (cur && line.startsWith('- ')) {
49
+ cur.items.push(line.slice(2).trim());
50
+ }
51
+ }
52
+ return entries;
53
+ }
54
+
55
+ function capitalize(s) {
56
+ s = String(s || '').trim();
57
+ return s ? s[0].toUpperCase() + s.slice(1) : s;
58
+ }
59
+
60
+ // "feat(scope): do a thing" -> { type: 'feat', text: 'Do a thing' }
61
+ function classifyBullet(raw) {
62
+ const m = String(raw).match(/^(\w+)(?:\([^)]*\))?:\s*(.*)$/);
63
+ if (!m) return { type: 'other', text: capitalize(raw) };
64
+ return { type: m[1].toLowerCase(), text: capitalize(m[2]) };
65
+ }
66
+
67
+ function formatUpdateSummary(previousVersion, currentVersion, changelogText) {
68
+ // Already current — a re-run. One confirmation line, no feature relist.
69
+ if (previousVersion && compareVersions(previousVersion, currentVersion) === 0) {
70
+ return [{ kind: 'latest', text: `You're already on the latest version (v${currentVersion})` }];
71
+ }
72
+
73
+ const entries = parseChangelog(changelogText);
74
+
75
+ // Which versions to describe:
76
+ // known older prev -> everything newer than prev (since your last update)
77
+ // unknown prev -> just the current version's own entry
78
+ let relevant, heading;
79
+ if (previousVersion) {
80
+ relevant = entries.filter(e => compareVersions(e.ver, previousVersion) > 0);
81
+ heading = `What's new since your last update (v${previousVersion}):`;
82
+ } else {
83
+ relevant = entries.filter(e => compareVersions(e.ver, currentVersion) === 0);
84
+ heading = `This install includes (v${currentVersion}):`;
85
+ }
86
+
87
+ // Split bullets into two buckets, dropping housekeeping types.
88
+ const features = [], fixes = [];
89
+ for (const e of relevant) {
90
+ for (const raw of e.items) {
91
+ const c = classifyBullet(raw);
92
+ if (SKIP_TYPES.has(c.type)) continue;
93
+ (c.type === 'feat' ? features : fixes).push(c.text);
94
+ }
95
+ }
96
+
97
+ // Upgrade with nothing user-facing (e.g. all housekeeping) — just confirm the bump.
98
+ if (features.length === 0 && fixes.length === 0) {
99
+ return [{ kind: 'latest', text: `Updated to v${currentVersion}` }];
100
+ }
101
+
102
+ const out = [{ kind: 'heading', text: heading }];
103
+ let shown = 0, overflow = 0;
104
+
105
+ function emitGroup(label, items) {
106
+ if (items.length === 0) return;
107
+ const take = items.slice(0, Math.max(0, MAX_ITEMS - shown));
108
+ overflow += items.length - take.length;
109
+ if (take.length === 0) return; // no room left; counted toward overflow
110
+ out.push({ kind: 'group', text: label });
111
+ for (const t of take) { out.push({ kind: 'item', text: t }); shown++; }
112
+ }
113
+
114
+ emitGroup('New features', features);
115
+ emitGroup('Fixes & improvements', fixes);
116
+
117
+ if (overflow > 0) out.push({ kind: 'more', text: `… and ${overflow} more (see CHANGELOG.md)` });
118
+
119
+ return out;
120
+ }
121
+
122
+ module.exports = { formatUpdateSummary, compareVersions, parseChangelog, classifyBullet };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-autoconfig",
3
- "version": "1.0.197",
3
+ "version": "1.0.199",
4
4
  "description": "Intelligent, self-configuring setup for Claude Code. One command analyzes your project, configures Claude, and shows you what it did.",
5
5
  "author": "ADAC 1001 <info@adac1001.com>",
6
6
  "license": "MIT",
@@ -22,7 +22,7 @@
22
22
  "cli"
23
23
  ],
24
24
  "scripts": {
25
- "test": "node test/box-alignment.test.js && node test/cli-install.test.js && node test/update-system.test.js && node test/plugin-system.test.js && node test/terminal-title.test.js",
25
+ "test": "node test/box-alignment.test.js && node test/cli-install.test.js && node test/update-system.test.js && node test/plugin-system.test.js && node test/terminal-title.test.js && node test/update-summary.test.js",
26
26
  "pub": "npm whoami && npm publish",
27
27
  "test:box": "node test/box-alignment.test.js",
28
28
  "test:install": "node test/cli-install.test.js",