claude-code-autoconfig 1.0.192 → 1.0.194

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.
@@ -11,6 +11,7 @@
11
11
 
12
12
  Tokens (substituted at runtime):
13
13
  {{TITLE_FILE}} — absolute path to this session's title file the model writes
14
+ {{ASK_FILE}} — absolute path to this session's one-shot {sid}.ask flag (PENDING block)
14
15
  {{FOLDER}} — the working-directory folder name (scope fallback)
15
16
  {{EMDASH}} — the ' — ' segment separator
16
17
  {{CMD}} — the slash-command name (COMMAND block only)
@@ -63,9 +64,14 @@ already exists, read it first then overwrite.
63
64
  <!-- /DIRECTIVE:SHIFT -->
64
65
 
65
66
  <!-- DIRECTIVE:PENDING -->
66
- Pending-question signal: if you END this turn by asking the user a question they must answer
67
- before you can proceed, phrase it so your FINAL line ends with a question mark ('?') -- the tab
68
- indicator then flips to the AWAITING half-circle automatically (instead of the idle asterisk).
67
+ Pending-question signal: when you END this turn on a question the user must answer before you can
68
+ proceed, do BOTH of these as near-final actions so the tab flips to the AWAITING half-circle
69
+ (instead of the idle asterisk):
70
+ 1. Write the flag file {{ASK_FILE}} (any short content, e.g. "1"). This is the RELIABLE trigger --
71
+ it is on disk before the turn ends, so it never misses (the transcript-text check in 2 can race
72
+ the turn-end write and silently miss). The flag is one-shot: it is consumed at turn end and
73
+ auto-cleared next turn, so write it ONLY on a turn you are genuinely blocked on an answer.
74
+ 2. Phrase your FINAL line so it ends with a question mark ('?') -- a backup signal, and good UX.
69
75
  Make the closing question self-contained: answerable from the question alone, without re-reading
70
- the response above it. Only end on a '?' for a genuine blocking question, never a rhetorical one.
76
+ the response above it. Only do this for a genuine blocking question, never a rhetorical one or a recap.
71
77
  <!-- /DIRECTIVE:PENDING -->
@@ -2,7 +2,8 @@
2
2
  /**
3
3
  * Terminal Title — distributable plugin hook (installed to <project>/.claude/hooks/terminal-title.js).
4
4
  * ONE self-dispatching hook for five events (keyed on hook_event_name):
5
- * UserPromptSubmit -> ⬤ working + inject the title directive into the model's context
5
+ * UserPromptSubmit -> ⬤ working + inject the title directive (incl. the {sid}.ask path) +
6
+ * clear any stale {sid}.ask flag so it reflects only the turn about to run
6
7
  * PostToolUse -> ⬤ working (refresh, so a mid-turn title flip shows live + clears a stale ◐)
7
8
  * Notification -> ◐ awaiting your approval (permission_prompt matcher only)
8
9
  * Stop -> ✻ idle / done — OR ◐ awaiting (+ a 2nd BEL = gold tab) when the turn ended
@@ -40,18 +41,23 @@ const GLYPH = {
40
41
  // Per-invocation context for the optional debug log (populated in handle, read by titleLog).
41
42
  let logCtx = null;
42
43
 
43
- let input = '';
44
- process.stdin.setEncoding('utf8');
45
- process.stdin.on('data', chunk => (input += chunk));
46
- process.stdin.on('end', () => {
47
- try {
48
- handle(JSON.parse(input));
49
- } catch (err) {
50
- process.exit(0); // never break the turn on a title error — emit nothing
51
- }
52
- });
44
+ // Only drive from stdin when run AS the hook (`node terminal-title.js`). When require()'d by a test the
45
+ // functions below are exported instead, so the stdin read doesn't hang the runner. async because the
46
+ // Stop branch may await a short re-read beat (see the flush-race guard in handle).
47
+ if (require.main === module) {
48
+ let input = '';
49
+ process.stdin.setEncoding('utf8');
50
+ process.stdin.on('data', chunk => (input += chunk));
51
+ process.stdin.on('end', async () => {
52
+ try {
53
+ await handle(JSON.parse(input));
54
+ } catch (err) {
55
+ process.exit(0); // never break the turn on a title error — emit nothing
56
+ }
57
+ });
58
+ }
53
59
 
54
- function handle(data) {
60
+ async function handle(data) {
55
61
  const event = data.hook_event_name || '';
56
62
  const sid = data.session_id || '';
57
63
  const cwd = data.cwd || process.cwd();
@@ -65,6 +71,10 @@ function handle(data) {
65
71
  // Ensure the state dir exists, but NOT the file — the model's Write tool refuses to overwrite a
66
72
  // file it hasn't read, so a pre-created empty file would make its first title write fail.
67
73
  try { fs.mkdirSync(dir, { recursive: true }); } catch (_) { /* ignore */ }
74
+ // Clear any stale {sid}.ask left by an interrupted prior turn (Stop never ran to consume it), so a
75
+ // leftover flag can't paint a false ◐ on this turn's end. The flag must reflect ONLY this turn.
76
+ const askFile = path.join(dir, `${sid}.ask`);
77
+ if (fileExists(askFile)) { try { fs.unlinkSync(askFile); } catch (_) { /* ignore */ } }
68
78
  const title = normalize(readTitle(file) || folderName(cwd));
69
79
  const out = setTitle(GLYPH.working, title);
70
80
  out.hookSpecificOutput = {
@@ -100,11 +110,32 @@ function handle(data) {
100
110
  // so VS Code paints the (otherwise bell-less) tab gold. "Ended on a question" = last visible
101
111
  // assistant text ends in '?' (transcript heuristic) OR an explicit {sid}.ask flag (consumed here).
102
112
  const askFile = path.join(dir, `${sid}.ask`);
103
- let pending = lastResponseEndsWithQuestion(data.transcript_path);
113
+ const askPresent = fileExists(askFile);
114
+
115
+ // Grade the transcript, guarding the flush race: the final assistant text can land in the JSONL a beat
116
+ // AFTER Stop fires (the screen renders from the live stream; the file append lags by ~200ms). The tell
117
+ // is `suspectRace`: the freshest on-disk assistant block is text-less (thinking/tool_use only), or no
118
+ // text was found at all — both mean the real final message is still flushing. When that's the case,
119
+ // re-read a few times (~120ms apart) before grading, so a turn that actually ended on '?' isn't painted
120
+ // ✻ idle off a stale earlier block. A fully-flushed turn hits the text block first → suspectRace=false →
121
+ // zero delay. The {sid}.ask flag remains the race-proof backstop.
122
+ let q = inspectLastResponse(data.transcript_path);
123
+ let reread = 0;
124
+ while (!q.ends && (q.suspectRace || !q.found) && reread < 7) {
125
+ await delay(120);
126
+ reread++;
127
+ q = inspectLastResponse(data.transcript_path);
128
+ if (q.ends || (q.found && !q.suspectRace)) break;
129
+ }
130
+
131
+ let pending = q.ends;
104
132
  let note = pending ? 'q-mark' : 'idle';
105
- if (!pending && fileExists(askFile)) { pending = true; note = 'ask-flag'; }
106
- if (fileExists(askFile)) { try { fs.unlinkSync(askFile); } catch (_) { /* ignore */ } }
107
- if (logCtx) logCtx.note = note;
133
+ if (!pending && askPresent) { pending = true; note = 'ask-flag'; }
134
+ if (askPresent) { try { fs.unlinkSync(askFile); } catch (_) { /* ignore */ } }
135
+ if (logCtx) {
136
+ logCtx.note = note;
137
+ logCtx.diag = `ask=${askPresent ? 1 : 0} qmark=${q.ends ? 1 : 0} found=${q.found ? 1 : 0} reread=${reread} tail="${q.tail}"`;
138
+ }
108
139
  const glyph = pending ? GLYPH.awaiting : GLYPH.idle;
109
140
  emit(setTitle(glyph, normalize(readTitle(file) || folderName(cwd)), pending));
110
141
  }
@@ -130,9 +161,10 @@ function titleLog(glyph, title, ring) {
130
161
  const name = glyph === GLYPH.working ? 'working'
131
162
  : glyph === GLYPH.awaiting ? 'awaiting'
132
163
  : glyph === GLYPH.idle ? 'idle' : 'other';
164
+ const diag = logCtx.diag ? ` ${logCtx.diag}` : '';
133
165
  const line = `${new Date().toISOString()} ${logCtx.event.padEnd(16)} `
134
166
  + `${name.padEnd(8)} ring=${ring ? 1 : 0} note=${(logCtx.note || '-').padEnd(8)} `
135
- + `sid=${logCtx.sid} | ${title}\n`;
167
+ + `sid=${logCtx.sid} | ${title}${diag}\n`;
136
168
  const f = path.join(logCtx.dir, '_debug.log');
137
169
  try { if (fs.statSync(f).size > 512 * 1024) fs.renameSync(f, `${f}.1`); } catch (_) { /* none yet */ }
138
170
  fs.appendFileSync(f, line);
@@ -145,16 +177,26 @@ function fileExists(file) {
145
177
 
146
178
  // Stop heuristic: did the turn end on a question? Read the JSONL transcript, find the most-recent
147
179
  // assistant message with VISIBLE text (skip pure tool_use turns so a final title/memory Write doesn't
148
- // mask the question), test whether it ends in '?' (allowing trailing whitespace / ) * _ "). Any error → false.
149
- function lastResponseEndsWithQuestion(transcriptPath) {
150
- if (!transcriptPath) return false;
180
+ // mask the question), test whether it ends in '?' (allowing trailing whitespace / ) * _ "). Returns a
181
+ // diagnostic record { ends, found, tail }: `ends` is the old boolean the caller branches on; `found`
182
+ // and `tail` feed the debug log so a missing half-circle can be told apart — a transcript-flush race
183
+ // shows found=0 (or a stale tail), a genuine regex miss shows a tail that's present but doesn't end
184
+ // in '?'. Any error → a blank record (treated as "no question"), matching the old false return.
185
+ function inspectLastResponse(transcriptPath) {
186
+ const blank = { ends: false, found: false, tail: '', suspectRace: false };
187
+ if (!transcriptPath) return blank;
151
188
  let content;
152
189
  try {
153
190
  content = fs.readFileSync(transcriptPath, 'utf8');
154
191
  } catch (_) {
155
- return false;
192
+ return blank;
156
193
  }
157
194
  const lines = content.split('\n');
195
+ // Did we pass a TEXT-LESS assistant block (thinking-only / tool_use-only) on the way back to the last
196
+ // text block? At a fully-flushed Stop the final assistant message HAS text, so we hit it first and this
197
+ // stays false. If it's true, the turn's real final text is most likely still being appended to the
198
+ // JSONL (the flush race) — the caller re-reads after a beat before grading rather than trust this block.
199
+ let sawTextlessAssistant = false;
158
200
  for (let i = lines.length - 1; i >= 0; i--) {
159
201
  const line = lines[i].trim();
160
202
  if (!line) continue;
@@ -171,11 +213,21 @@ function lastResponseEndsWithQuestion(transcriptPath) {
171
213
  .map(b => b.text)
172
214
  .join('\n');
173
215
  }
174
- if (text.trim()) return /\?[\s)*_"]*$/.test(text);
216
+ if (text.trim()) {
217
+ // last ~60 chars, collapsed to one line and quote-stripped so it can't break the log framing
218
+ const tail = text.trim().slice(-60).replace(/\s+/g, ' ').replace(/"/g, "'");
219
+ return { ends: /\?[\s)*_"]*$/.test(text), found: true, tail, suspectRace: sawTextlessAssistant };
220
+ }
221
+ // assistant message with no visible text = a thinking-only or tool_use-only block sitting AFTER the
222
+ // last text we'll grade — a strong hint the final text line hasn't flushed yet.
223
+ sawTextlessAssistant = true;
175
224
  }
176
- return false;
225
+ return blank;
177
226
  }
178
227
 
228
+ // Event-loop-friendly sleep for the Stop flush-race re-read beat (handle awaits this; no busy-wait).
229
+ function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
230
+
179
231
  function readTitle(file) {
180
232
  try {
181
233
  if (fs.existsSync(file)) return fs.readFileSync(file, 'utf8').trim();
@@ -221,6 +273,7 @@ function buildDirective(data, file, cwd) {
221
273
  const combined = pending ? `${block}\n\n${pending}` : block;
222
274
  return combined
223
275
  .split('{{TITLE_FILE}}').join(file)
276
+ .split('{{ASK_FILE}}').join(file.replace(/\.txt$/, '.ask'))
224
277
  .split('{{FOLDER}}').join(folderName(cwd))
225
278
  .split('{{EMDASH}}').join(EMDASH)
226
279
  .split('{{CMD}}').join(cmd);
@@ -233,3 +286,6 @@ function extractBlock(tpl, name) {
233
286
  const m = tpl.match(re);
234
287
  return m ? m[1].trim() : '';
235
288
  }
289
+
290
+ // Exported for tests (require()'d when require.main !== module). The hook itself never reads these.
291
+ module.exports = { inspectLastResponse, normalize, GLYPH };
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.0.194
4
+ - fix(terminal-title): guard the Stop question-grade against the transcript-flush race
5
+
6
+ ## v1.0.193
7
+ - fix(installer): always refresh cca-managed title hooks on upgrade
8
+ - fix(terminal-title): arm the {sid}.ask flag so awaiting ◐ is race-free
9
+ - docs(publish): document web-auth/passkey publish flow in Discoveries
10
+
3
11
  ## v1.0.192
4
12
  - fix(pkg): exclude runtime .titles/ from published tarball
5
13
 
@@ -142,6 +150,3 @@
142
150
  ## v1.0.144
143
151
  - feat: swagger-style docs, wider layout, better install UX
144
152
 
145
- ## v1.0.143
146
- - feat: add /validate-cca-install command
147
-
package/CLAUDE.md CHANGED
@@ -42,6 +42,18 @@ See `.claude/feedback/` for corrections and guidance from the team.
42
42
  ## Discoveries
43
43
  <!-- Claude: append project-specific learnings, gotchas, and context below. This section persists across /autoconfig runs. -->
44
44
 
45
+ ### Publishing to npm — web-auth (passkey) flow, run by the human
46
+
47
+ `npm publish` for this package is a **two-actor** flow; a bare in-agent `npm publish` will FAIL. (Supersedes the auto-generated "## Publishing" one-liner above.)
48
+
49
+ - The npm account has **2FA**. A sandboxed/agent shell can't open a browser, so `npm publish` there dies with `npm error code EOTP`. Don't retry it in-agent; don't default to asking for a typed OTP.
50
+ - **Working flow:** the agent runs `npm test` + `npm version patch` (commit + tag), then the **human runs the publish in their own terminal** (needs a browser):
51
+ ```
52
+ cd C:\CODE\claude-code-autoconfig && npm login --auth-type=web && npm publish
53
+ ```
54
+ → browser opens → pick the Google passkey → close it → `+ claude-code-autoconfig@<version>`.
55
+ - **After** the success line, the agent pushes: `git push origin main --follow-tags`. Never double-bump if the version is already bumped. Full procedure: `.claude/commands/publish.md`.
56
+
45
57
  ## Debugging Methodology — Evidence Before Solutions
46
58
 
47
59
  **NEVER jump to a fix based on assumptions.** When investigating a bug:
package/bin/cli.js CHANGED
@@ -767,10 +767,21 @@ if (fs.existsSync(feedbackSrc)) {
767
767
  copyFn(feedbackSrc, path.join(claudeDest, 'feedback'));
768
768
  }
769
769
 
770
- // Copy hooks directory (preserve user customizations unless --force)
770
+ // Copy hooks directory. Genuinely user-authorable hooks are preserved on upgrade
771
+ // (copyDirIfMissing), BUT the cca-managed title-hook files are ALWAYS refreshed so bug-fixes
772
+ // reach existing installs — without this, copyDirIfMissing leaves stale hooks in place forever
773
+ // (same always-overwrite rationale as scripts/ below). --force already overwrites everything.
774
+ const MANAGED_HOOKS = ['terminal-title.js', 'terminal-title.directive.md'];
771
775
  if (fs.existsSync(hooksSrc)) {
772
776
  const copyFn = forceMode ? copyDir : copyDirIfMissing;
773
777
  copyFn(hooksSrc, path.join(claudeDest, 'hooks'));
778
+ if (!forceMode) {
779
+ const hooksDestDir = path.join(claudeDest, 'hooks');
780
+ for (const name of MANAGED_HOOKS) {
781
+ const src = path.join(hooksSrc, name);
782
+ if (fs.existsSync(src)) fs.copyFileSync(src, path.join(hooksDestDir, name));
783
+ }
784
+ }
774
785
  }
775
786
 
776
787
  // Copy scripts directory (always overwrite — these are utility scripts, not user-customizable)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-autoconfig",
3
- "version": "1.0.192",
3
+ "version": "1.0.194",
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",