claude-code-autoconfig 1.0.198 → 1.0.200

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.
@@ -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 };
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.0.200
4
+ - feat(changelog): Release notes now written in plain language
5
+
6
+ ## v1.0.199
7
+ - feat(cli): Clearer 'what's new' summary when updating
8
+ - feat(terminal-title): More reliable 'awaiting your reply' tab indicator
9
+
3
10
  ## v1.0.198
4
11
  - feat(arcade-beeps): ship opt-in Pole Position status beeps via CCA
5
12
 
@@ -142,9 +149,3 @@
142
149
  ## v1.0.151
143
150
  - fix: scope find permission to project directory for security
144
151
 
145
- ## v1.0.150
146
- - style: use pointing emoji in inside-Claude message
147
-
148
- ## v1.0.149
149
- - style: consolidate inside-Claude message to two lines
150
-
package/CLAUDE.md CHANGED
@@ -32,6 +32,25 @@ npm run test:install # Run CLI install tests only
32
32
  npm version patch && npm publish
33
33
  ```
34
34
 
35
+ After publish, push the branch AND the release tag explicitly (the postversion changelog
36
+ hook re-tags lightweight): `git push origin main && git push origin v<x.y.z>`.
37
+
38
+ ### Changelog is user-facing — write commits accordingly
39
+
40
+ CHANGELOG.md regenerates from commit **subjects** on every `npm version`, and the installer
41
+ shows those bullets verbatim to users on upgrade (`bin/update-summary.js`). So:
42
+
43
+ - **Subjects may stay technical**, but then the commit body MUST carry a plain-language
44
+ trailer the changelog uses instead: `Changelog: More reliable 'awaiting your reply' tab
45
+ indicator`. Write it for a CCA user, not a maintainer — name the visible outcome, never
46
+ internals (no hook/regex/TDZ/function names).
47
+ - `Changelog: none` (or `skip`) hides an internal-only commit from the changelog.
48
+ - Keep the `feat:`/`fix:` conventional prefix on the subject — it drives the summary's
49
+ "New features" vs "Fixes & improvements" grouping.
50
+ - Already-pushed commits can't be reworded in git; add a row to `OVERRIDES` in
51
+ `scripts/generate-changelog.js` instead (value `null` drops the bullet). New work never
52
+ uses OVERRIDES — author the trailer.
53
+
35
54
  ## Team Feedback
36
55
 
37
56
  See `.claude/feedback/` for corrections and guidance from the team.
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);
@@ -956,43 +957,22 @@ if (isUpgrade) {
956
957
  console.log('\x1b[33m║ ║\x1b[0m');
957
958
  console.log('\x1b[33m╚════════════════════════════════════════════╝\x1b[0m');
958
959
  }
959
- // Show changelog on upgrade
960
- 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) {
961
965
  const changelogPath = path.join(packageDir, 'CHANGELOG.md');
962
- if (fs.existsSync(changelogPath)) {
963
- const changelog = fs.readFileSync(changelogPath, 'utf8');
964
- const prevPatch = parseInt(previousVersion.split('.').pop(), 10);
965
- const entries = [];
966
- let currentEntry = null;
967
- for (const line of changelog.split(/\r?\n/)) {
968
- if (line.startsWith('## v')) {
969
- const ver = line.slice(3).trim();
970
- const patch = parseInt(ver.split('.').pop(), 10);
971
- currentEntry = patch > prevPatch ? { ver, items: [] } : null;
972
- } else if (currentEntry && line.startsWith('- ')) {
973
- currentEntry.items.push(line.slice(2));
974
- } else if (currentEntry && line === '' && currentEntry.items.length > 0) {
975
- entries.push(currentEntry);
976
- currentEntry = null;
977
- }
978
- }
979
- if (currentEntry && currentEntry.items.length > 0) entries.push(currentEntry);
980
- if (entries.length > 0) {
981
- console.log(`\x1b[90m What's new since v${previousVersion}:\x1b[0m`);
982
- console.log();
983
- const show = entries.slice(0, 10);
984
- for (const e of show) {
985
- for (const item of e.items) {
986
- console.log(`\x1b[90m ${e.ver} — ${item}\x1b[0m`);
987
- }
988
- }
989
- const remaining = entries.length - show.length;
990
- if (remaining > 0) {
991
- console.log(`\x1b[90m ... and ${remaining} more (see CHANGELOG.md)\x1b[0m`);
992
- }
993
- console.log();
994
- }
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`);
995
974
  }
975
+ console.log();
996
976
  }
997
977
  if (!isUpgrade) {
998
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.198",
3
+ "version": "1.0.200",
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 && node test/changelog-gen.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",