claude-code-autoconfig 1.0.198 → 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.
- package/.claude/hooks/terminal-title.directive.md +17 -12
- package/.claude/hooks/terminal-title.js +165 -36
- package/CHANGELOG.md +5 -3
- package/bin/cli.js +15 -35
- package/bin/update-summary.js +122 -0
- package/package.json +2 -2
|
@@ -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
|
|
56
|
-
|
|
57
|
-
(
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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}".
|
|
73
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
//
|
|
135
|
-
//
|
|
136
|
-
// is
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
|
|
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
|
-
|
|
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 =
|
|
156
|
-
logCtx.diag = `ask=${
|
|
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
|
-
|
|
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
|
-
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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,10 @@
|
|
|
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
|
+
|
|
3
8
|
## v1.0.198
|
|
4
9
|
- feat(arcade-beeps): ship opt-in Pole Position status beeps via CCA
|
|
5
10
|
|
|
@@ -145,6 +150,3 @@
|
|
|
145
150
|
## v1.0.150
|
|
146
151
|
- style: use pointing emoji in inside-Claude message
|
|
147
152
|
|
|
148
|
-
## v1.0.149
|
|
149
|
-
- style: consolidate inside-Claude message to two lines
|
|
150
|
-
|
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
|
|
960
|
-
|
|
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
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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.
|
|
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",
|