maiass 5.12.3 → 5.13.0

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,959 @@
1
+ // Changelog cleanup (MAI-47).
2
+ //
3
+ // `--cleanup-changelogs` runs an AI-assisted pass over CHANGELOG.md (public)
4
+ // and .CHANGELOG_internal.md (internal) when they exist. Each file is treated
5
+ // independently — neither is derived from the other. The pass:
6
+ //
7
+ // 1. Backs up each file (overwrites previous .bak; auto-gitignored).
8
+ // 2. Walks the file's version sections grouped by minor version (newest
9
+ // first). For each group it sends the existing block + the git commits
10
+ // that fall in that version range to the AI, then receives a cleaned
11
+ // block back in the canonical MAIASS format.
12
+ // 3. Re-emits the file from the cleaned groups + any original preamble.
13
+ //
14
+ // Style differences are encoded in the AI prompt: internal keeps MAI-XXX
15
+ // tickets and author names; public strips both and rewrites in a customer-
16
+ // facing voice. Either file may be absent — the cleanup just skips what
17
+ // isn't there.
18
+ //
19
+ // Failure mode: any error (no token, AI timeout, parse failure) restores
20
+ // the .bak and exits non-zero. The bump pipeline is never blocked because
21
+ // this command is opt-in / explicit.
22
+
23
+ import { execSync, spawnSync } from 'child_process';
24
+ import fs from 'fs/promises';
25
+ import { existsSync, copyFileSync, readFileSync, writeFileSync } from 'fs';
26
+ import path from 'path';
27
+ import { log } from './logger.js';
28
+ import { SYMBOLS } from './symbols.js';
29
+ import colors from './colors.js';
30
+ import { createAnonymousSubscriptionIfNeeded } from './commit.js';
31
+ import { generateMachineFingerprint } from './machine-fingerprint.js';
32
+ import { getClientName, getClientVersion } from './client-info.js';
33
+ import { getSingleCharInput } from './input-utils.js';
34
+
35
+ export const FLAGS = ['--cleanup-changelogs'];
36
+
37
+ /** Largest single AI request, bytes of combined input. Beyond this, a minor
38
+ * group is split by date before sending. */
39
+ const CHUNK_THRESHOLD_BYTES = 20 * 1024;
40
+
41
+ /** Hard cap on AI output tokens per chunk. Commit messages need 150; a
42
+ * cleaned changelog chunk can run to ~1500–3000 chars, so 2000 tokens is a
43
+ * safe ceiling. */
44
+ const MAX_OUTPUT_TOKENS = 2000;
45
+
46
+ /**
47
+ * MAIASS_AI_MODE values that count as "AI on". The bump pipeline treats
48
+ * anything other than 'off' as on; we mirror that here.
49
+ */
50
+ function isAIModeActive() {
51
+ const mode = String(process.env.MAIASS_AI_MODE || 'ask').toLowerCase();
52
+ return mode !== 'off' && mode !== 'false' && mode !== 'disabled';
53
+ }
54
+
55
+ function executeGitCommand(command) {
56
+ try {
57
+ return execSync(command, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Build the version → commits map from git history. We anchor on
65
+ * "Bumped version to X" commits rather than tags because tags are
66
+ * frequently missing (e.g. nodemaiass has tags for 5.10.0/.1/.2/.4 but not
67
+ * .3, and for 5.12.0/.2/.3/.9 but not .1/.4–.8). Bump commits are produced
68
+ * by MAIASS itself on every release, so they're comprehensive.
69
+ *
70
+ * Returns { ordered, byVersion }:
71
+ * ordered: array of version strings, newest first
72
+ * byVersion: Map<version, Array<{hash, author, body}>>
73
+ * — content commits only, with the bump commit + merges filtered out.
74
+ */
75
+ function buildVersionCommitMap() {
76
+ // %ai is the author date in ISO 8601 (committer-date independent). We grab
77
+ // it alongside the hash + subject so the cleanup prompt can fill in a date
78
+ // line when the existing changelog block has none.
79
+ const raw = executeGitCommand('git log --pretty=format:"%H\t%ai\t%s" --grep="^Bumped version to "');
80
+ if (!raw) return { ordered: [], byVersion: new Map(), dateByVersion: new Map() };
81
+
82
+ const bumps = [];
83
+ for (const line of raw.split('\n')) {
84
+ const parts = line.split('\t');
85
+ if (parts.length < 3) continue;
86
+ const [hash, isoDate, subject] = parts;
87
+ const m = subject.match(/^Bumped version to (\S+)/);
88
+ if (m) bumps.push({ hash, version: m[1], isoDate });
89
+ }
90
+
91
+ const byVersion = new Map();
92
+ const dateByVersion = new Map();
93
+ for (let i = 0; i < bumps.length; i++) {
94
+ const { hash, version, isoDate } = bumps[i];
95
+ const olderBumpHash = bumps[i + 1]?.hash;
96
+ const range = olderBumpHash ? `${olderBumpHash}..${hash}` : hash;
97
+ byVersion.set(version, readCommitRange(range, hash));
98
+ // Use the bump commit's date as the version's reference date — that's
99
+ // the actual moment of release.
100
+ dateByVersion.set(version, isoDate.slice(0, 10));
101
+ }
102
+
103
+ return {
104
+ ordered: bumps.map(b => b.version),
105
+ byVersion,
106
+ dateByVersion
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Read commits in a git range with author + full message. Uses ASCII RS/US
112
+ * separators so commit bodies can contain anything (including newlines and
113
+ * the colons MAIASS uses) without confusing the parser.
114
+ *
115
+ * Excludes:
116
+ * - the bump commit itself (excludeHash) — its only content is "Bumped version to X"
117
+ * - merge commits (via --no-merges)
118
+ * - any commit whose subject still looks like a bump / merge-conflict fixup
119
+ */
120
+ function readCommitRange(range, excludeHash) {
121
+ const RS = '\x1e';
122
+ const US = '\x1f';
123
+ const fmt = `%H${US}%an${US}%B${RS}`;
124
+ const raw = executeGitCommand(`git log --no-merges --pretty=format:"${fmt}" ${range}`);
125
+ if (!raw) return [];
126
+
127
+ const out = [];
128
+ for (const record of raw.split(RS)) {
129
+ const trimmed = record.trim();
130
+ if (!trimmed) continue;
131
+ const [hash, author, body] = trimmed.split(US);
132
+ if (!hash || hash === excludeHash) continue;
133
+ const subject = (body || '').split('\n')[0];
134
+ if (/^(Bumped version to|Merge|fixing merge conflicts)/i.test(subject)) continue;
135
+ out.push({ hash, author: author || '', body: (body || '').trim() });
136
+ }
137
+ return out;
138
+ }
139
+
140
+ /**
141
+ * Parse changelog text into { header, sections }. Each section captures the
142
+ * lines between one `## VERSION` line and the next. The date line (whatever
143
+ * follows the version header, modulo blank lines) is extracted; everything
144
+ * else is body content.
145
+ *
146
+ * The parser is lenient on whitespace by design — the whole point of the
147
+ * cleanup is to fix malformed input, so we accept stacked blank lines,
148
+ * missing dates, etc. and let the AI rebuild.
149
+ */
150
+ function parseChangelog(content) {
151
+ const lines = content.replace(/\r\n/g, '\n').split('\n');
152
+ const sections = [];
153
+ let headerLines = [];
154
+ let cur = null;
155
+ let firstHeaderSeen = false;
156
+
157
+ for (let i = 0; i < lines.length; i++) {
158
+ const m = lines[i].match(/^## (.+)$/);
159
+ if (m) {
160
+ if (!firstHeaderSeen) {
161
+ headerLines = lines.slice(0, i);
162
+ firstHeaderSeen = true;
163
+ }
164
+ if (cur) sections.push(cur);
165
+ cur = { version: m[1].trim(), dateLine: '', body: [] };
166
+ // The next non-blank line (within reason) is the date line.
167
+ let j = i + 1;
168
+ while (j < lines.length && !lines[j].trim() && j - i <= 3) j++;
169
+ if (j < lines.length && !/^## /.test(lines[j])) {
170
+ cur.dateLine = lines[j].trim();
171
+ cur._dateLineIdx = j;
172
+ }
173
+ } else if (cur) {
174
+ // Skip the line we already captured as the date.
175
+ if (i !== cur._dateLineIdx) cur.body.push(lines[i]);
176
+ }
177
+ }
178
+ if (cur) sections.push(cur);
179
+
180
+ for (const s of sections) {
181
+ delete s._dateLineIdx;
182
+ while (s.body.length && !s.body[0].trim()) s.body.shift();
183
+ while (s.body.length && !s.body[s.body.length - 1].trim()) s.body.pop();
184
+ }
185
+
186
+ return {
187
+ header: headerLines.join('\n').replace(/\s+$/g, ''),
188
+ sections
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Compare two dot-separated version strings. Returns true iff a > b.
194
+ * Used by the same-date consolidator to pick which patch number to use
195
+ * as the merged section header.
196
+ */
197
+ function semverGt(a, b) {
198
+ const pa = String(a).split('.').map(n => parseInt(n, 10) || 0);
199
+ const pb = String(b).split('.').map(n => parseInt(n, 10) || 0);
200
+ const len = Math.max(pa.length, pb.length);
201
+ for (let i = 0; i < len; i++) {
202
+ const x = pa[i] || 0;
203
+ const y = pb[i] || 0;
204
+ if (x !== y) return x > y;
205
+ }
206
+ return false;
207
+ }
208
+
209
+ /**
210
+ * Normalise a date line to a comparable key. Strips the optional weekday
211
+ * suffix ("(Wednesday)") and trims surrounding whitespace, so
212
+ * "13 May 2026 (Wednesday)" and "13 May 2026" collapse to the same key.
213
+ * Returns an empty string for unparseable lines so they cluster together
214
+ * separately rather than being randomly merged.
215
+ */
216
+ function dateKey(dateLine) {
217
+ if (!dateLine) return '';
218
+ const m = String(dateLine).match(/^\s*(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})/);
219
+ return m ? `${m[1]} ${m[2]} ${m[3]}` : '';
220
+ }
221
+
222
+ /**
223
+ * Post-process a cleaned chunk to merge same-date sections.
224
+ *
225
+ * The AI is supposed to consolidate same-date patches into one section via
226
+ * the prompt's grouping rule, but it under-applies when the patches aren't
227
+ * adjacent in the version sequence (e.g. 5.12.7 on 13 May and 5.12.13 on
228
+ * 13 May stayed as two sections because 5.12.8/9/10/11/12 sit between them).
229
+ *
230
+ * This pass parses the AI output back into sections, groups by date, and
231
+ * for each date with multiple sections produces ONE merged section whose
232
+ * header is the latest patch number for that date and whose body is the
233
+ * concatenated bullets (line-level dedup). Sections with empty/unparseable
234
+ * date lines pass through unchanged.
235
+ *
236
+ * Order is preserved by the position of the latest-patch section in each
237
+ * group, so the output stays newest-first within the minor.
238
+ */
239
+ function consolidateSameDateSections(text) {
240
+ const lines = String(text).replace(/\r\n/g, '\n').split('\n');
241
+ const sections = [];
242
+ let cur = null;
243
+ let sawDate = false;
244
+
245
+ const finish = () => {
246
+ if (!cur) return;
247
+ while (cur.body.length && !cur.body[0].trim()) cur.body.shift();
248
+ while (cur.body.length && !cur.body[cur.body.length - 1].trim()) cur.body.pop();
249
+ sections.push(cur);
250
+ };
251
+
252
+ for (const line of lines) {
253
+ const m = line.match(/^## (.+)$/);
254
+ if (m) {
255
+ finish();
256
+ cur = { version: m[1].trim(), dateLine: '', body: [] };
257
+ sawDate = false;
258
+ continue;
259
+ }
260
+ if (!cur) continue;
261
+ if (!sawDate) {
262
+ if (!line.trim()) continue; // skip blanks between header and date
263
+ if (line.trim().startsWith('-')) {
264
+ // No date line at all — bullet starts straight away.
265
+ sawDate = true;
266
+ cur.body.push(line);
267
+ continue;
268
+ }
269
+ cur.dateLine = line.trim();
270
+ sawDate = true;
271
+ continue;
272
+ }
273
+ cur.body.push(line);
274
+ }
275
+ finish();
276
+
277
+ if (sections.length === 0) return text;
278
+
279
+ // Bucket sections by normalised date key. Sections with no date go into
280
+ // their own "no-date" bucket and aren't merged with each other.
281
+ const buckets = new Map();
282
+ for (const s of sections) {
283
+ const key = dateKey(s.dateLine) || `__nodate__${sections.indexOf(s)}`;
284
+ if (!buckets.has(key)) buckets.set(key, []);
285
+ buckets.get(key).push(s);
286
+ }
287
+
288
+ const merged = new Map();
289
+ for (const [key, bucket] of buckets) {
290
+ if (bucket.length === 1) {
291
+ merged.set(key, bucket[0]);
292
+ continue;
293
+ }
294
+ // Pick the section with the highest patch as the header source.
295
+ let latest = bucket[0];
296
+ for (const s of bucket) if (semverGt(s.version, latest.version)) latest = s;
297
+
298
+ // Concatenate bodies with line-level dedup on bullet lines.
299
+ const out = [];
300
+ const seenBullets = new Set();
301
+ for (const s of bucket) {
302
+ for (const line of s.body) {
303
+ if (line.trim().startsWith('-')) {
304
+ const k = line.trim();
305
+ if (seenBullets.has(k)) continue;
306
+ seenBullets.add(k);
307
+ }
308
+ out.push(line);
309
+ }
310
+ }
311
+ while (out.length && !out[0].trim()) out.shift();
312
+ while (out.length && !out[out.length - 1].trim()) out.pop();
313
+
314
+ merged.set(key, { version: latest.version, dateLine: latest.dateLine, body: out });
315
+ }
316
+
317
+ // Emit in the order the first member of each bucket appeared, so we
318
+ // preserve the AI's newest-first ordering for distinct dates.
319
+ const emitOrder = [];
320
+ const seenKeys = new Set();
321
+ for (const s of sections) {
322
+ const key = dateKey(s.dateLine) || `__nodate__${sections.indexOf(s)}`;
323
+ if (seenKeys.has(key)) continue;
324
+ seenKeys.add(key);
325
+ emitOrder.push(merged.get(key));
326
+ }
327
+
328
+ return emitOrder
329
+ .map(s => {
330
+ const header = `## ${s.version}`;
331
+ const date = s.dateLine ? `\n${s.dateLine}` : '';
332
+ const body = s.body.length ? `\n\n${s.body.join('\n')}` : '';
333
+ return `${header}${date}${body}`;
334
+ })
335
+ .join('\n\n');
336
+ }
337
+
338
+ /**
339
+ * Build minor-version groups in newest-first order. We seed the order from
340
+ * git (so versions present in git but missing from the changelog surface
341
+ * naturally) and append any changelog-only versions at the end.
342
+ *
343
+ * Same-minor versions are coalesced into ONE group even when git history
344
+ * has them non-adjacent — long-lived projects often have a Bumped-version
345
+ * commit from an old branch lineage interleaved with newer ones, and we
346
+ * don't want to clean the same minor twice.
347
+ *
348
+ * Group order is first-occurrence-newest: the position of each minor in
349
+ * the result is the position of its newest-seen version.
350
+ */
351
+ function groupByMinor(sections, gitOrderedVersions) {
352
+ const minorOf = v => v.split('.').slice(0, 2).join('.');
353
+
354
+ const sectionByVersion = new Map(sections.map(s => [s.version, s]));
355
+ const seen = new Set();
356
+ const ordered = [];
357
+ for (const v of gitOrderedVersions) {
358
+ if (!seen.has(v)) { seen.add(v); ordered.push(v); }
359
+ }
360
+ for (const s of sections) {
361
+ if (!seen.has(s.version)) { seen.add(s.version); ordered.push(s.version); }
362
+ }
363
+
364
+ const byMinor = new Map();
365
+ for (const v of ordered) {
366
+ const m = minorOf(v);
367
+ if (!byMinor.has(m)) {
368
+ byMinor.set(m, { minor: m, versions: [], sections: [] });
369
+ }
370
+ const g = byMinor.get(m);
371
+ g.versions.push(v);
372
+ const sec = sectionByVersion.get(v);
373
+ if (sec) g.sections.push(sec);
374
+ }
375
+ return Array.from(byMinor.values());
376
+ }
377
+
378
+ /**
379
+ * Build the system + user messages for a cleanup chunk.
380
+ *
381
+ * style: 'internal' | 'public'
382
+ * group: { minor, versions, sections } from groupByMinor
383
+ * commitsByVersion: Map<version, commits[]>
384
+ */
385
+ function buildChunkMessages(style, group, commitsByVersion, commitDateByVersion) {
386
+ const isInternal = style === 'internal';
387
+
388
+ const dateFormat = isInternal
389
+ ? '"DD Month YYYY (Weekday)" — e.g. "14 May 2026 (Thursday)"'
390
+ : '"DD Month YYYY" — e.g. "14 May 2026"';
391
+
392
+ const styleRules = isInternal
393
+ ? [
394
+ 'Preserve MAI-XXX ticket prefixes from the source. If the source entry has no MAI-XXX, do NOT invent one — just omit it.',
395
+ 'Preserve the real author name from the source (e.g. "Mark Pottie", "vsmsh", "Tyler Durton"). Do NOT use the literal string "Author Name", "AUTHOR", or any placeholder. If no author is available, omit the author prefix.',
396
+ 'Bullet shape with author + ticket: `- Mark Pottie: MAI-13 short summary`',
397
+ 'Bullet shape with author, no ticket: `- Mark Pottie: short summary`',
398
+ 'Bullet shape with neither (rare): `- short summary`',
399
+ 'Indent continuation lines with a single tab character.',
400
+ 'Deduplicate entries that describe the same change.',
401
+ 'Drop "Bumped version to X" entries, merge commits, and "fixing merge conflicts" entries entirely.',
402
+ 'If a version has no meaningful commits AND the existing block is empty, omit the section entirely.'
403
+ ]
404
+ : [
405
+ 'Strip MAI-XXX ticket prefixes.',
406
+ 'Strip author names — no "vsmsh:", "Mark:", etc.',
407
+ 'Use end-user voice (past tense, customer-facing). Rewrite vague subjects when sub-bullets make the change clearer; otherwise omit the entry.',
408
+ 'Deduplicate entries that describe the same change.',
409
+ 'Drop "Bumped version to X" entries, merge commits, internal refactor noise, and anything not user-visible.',
410
+ 'If a version has nothing user-visible, omit the section entirely.'
411
+ ];
412
+
413
+ const consolidationExample = [
414
+ 'CONSOLIDATION EXAMPLE (critical — do this every time):',
415
+ 'If versions 5.11.0, 5.11.1, 5.11.2 were all bumped on 25 April 2026 with these commits:',
416
+ ' 5.11.0: "Add pull-request flag handling"',
417
+ ' 5.11.1: "Return user to original branch after release"',
418
+ ' 5.11.2: (no new commits — bump only)',
419
+ 'Then output ONE section with the latest patch as the header:',
420
+ '## 5.11.2',
421
+ isInternal ? '25 April 2026 (Saturday)' : '25 April 2026',
422
+ '',
423
+ isInternal
424
+ ? '- vsmsh: MAI-11 Add pull-request flag handling\n- vsmsh: MAI-12 Return user to original branch after release'
425
+ : '- Added pull-request flag handling.\n- The release workflow now returns to your original branch.',
426
+ '',
427
+ 'Do NOT emit separate `## 5.11.0` and `## 5.11.1` sections in this case. Merge them.'
428
+ ].join('\n');
429
+
430
+ const systemContent = [
431
+ 'You are a changelog editor for the MAIASS git workflow tool.',
432
+ 'Return ONLY the cleaned changelog block(s) for the supplied minor version.',
433
+ 'No preamble, no explanation, no code fences, no markdown headings other than `## VERSION` lines.',
434
+ '',
435
+ 'CANONICAL FORMAT (whitespace exact):',
436
+ '## VERSION',
437
+ `DATE_STRING — ${dateFormat}`,
438
+ '',
439
+ '- entry one',
440
+ '\tcontinuation indented with a single tab',
441
+ '- entry two',
442
+ '',
443
+ '## NEXT_VERSION',
444
+ 'NEXT_DATE_STRING',
445
+ '...',
446
+ '',
447
+ 'EVERY section MUST have a date line directly under the version header. If the existing block is missing a date, use the commit date provided for that version in the GIT COMMITS section (formatted as required above).',
448
+ '',
449
+ 'GROUPING:',
450
+ '- Within a minor (e.g. 5.12.x), if multiple patches were released on the same date, consolidate them into ONE section whose header is the LATEST patch number for that date, and merge their entries (deduped).',
451
+ '- Different dates within the same minor stay as separate sections, newest date first.',
452
+ '',
453
+ consolidationExample,
454
+ '',
455
+ `STYLE (${style}):`,
456
+ ...styleRules.map(r => `- ${r}`),
457
+ '',
458
+ 'If after applying these rules the entire minor group has zero remaining sections, return an empty string.'
459
+ ].join('\n');
460
+
461
+ // Existing block
462
+ const existingText = group.sections.map(s => {
463
+ const date = s.dateLine ? `\n${s.dateLine}` : '';
464
+ return `## ${s.version}${date}\n\n${s.body.join('\n')}`.trim();
465
+ }).join('\n\n') || '(no existing entries for this minor)';
466
+
467
+ // Git commits per version, with the commit date so the AI can fill missing date lines.
468
+ const commitsText = group.versions.map(v => {
469
+ const commits = commitsByVersion.get(v) || [];
470
+ const dateHint = commitDateByVersion.get(v);
471
+ const header = dateHint ? `### ${v} (bumped ${dateHint})` : `### ${v}`;
472
+ if (commits.length === 0) return `${header}\n(no content commits in git; this version was a bump-only release)`;
473
+ const lines = commits.map(c => `- ${c.author}: ${c.body.replace(/\n/g, '\n ')}`);
474
+ return `${header}\n${lines.join('\n')}`;
475
+ }).join('\n\n');
476
+
477
+ const userContent = [
478
+ `MINOR VERSION GROUP: ${group.minor}.x`,
479
+ `Versions (newest first): ${group.versions.join(', ')}`,
480
+ '',
481
+ 'EXISTING CHANGELOG BLOCK (may be empty, malformed, or missing dates):',
482
+ '---',
483
+ existingText,
484
+ '---',
485
+ '',
486
+ 'GIT COMMITS PER VERSION (authoritative — use to backfill missing/empty sections; use the bumped date when the existing block lacks one):',
487
+ '---',
488
+ commitsText,
489
+ '---',
490
+ '',
491
+ 'Return the cleaned block(s) for this minor group only.'
492
+ ].join('\n');
493
+
494
+ return { systemContent, userContent };
495
+ }
496
+
497
+ /**
498
+ * Single AI call. Mirrors the shape used by getAICommitSuggestion in
499
+ * commit.js, but with cleanup-appropriate max_tokens. Throws on any
500
+ * failure so the caller can roll back.
501
+ *
502
+ * Updates MAIASS_CREDITS_REMAINING on every successful response so the
503
+ * pipeline-end credits readout picks up cleanup spend.
504
+ */
505
+ async function callCleanupAI(systemContent, userContent) {
506
+ const token = await createAnonymousSubscriptionIfNeeded();
507
+ if (!token) {
508
+ throw new Error('No MAIASS AI token available — cleanup needs an AI subscription. Run `maiass --account-info` for details.');
509
+ }
510
+
511
+ const aiHost = process.env.MAIASS_AI_HOST || 'https://pound.maiass.net';
512
+ const aiPath = process.env.MAIASS_AI_PATH || '/proxy';
513
+ const aiEndpoint = aiHost + aiPath;
514
+ // Cleanup is much more instruction-sensitive than commit message generation.
515
+ // gpt-3.5-turbo (the commit default) consistently bungles the consolidation
516
+ // rule and hallucinates "Author Name" / "MAI-XXX" placeholder strings, so we
517
+ // auto-pick a stronger model based on input size. User override wins.
518
+ const inputBytes = Buffer.byteLength(systemContent + userContent, 'utf8');
519
+ const aiModel = process.env.MAIASS_AI_CHANGELOG_MODEL
520
+ || (inputBytes > 8 * 1024 ? 'gpt-4o' : 'gpt-4o-mini');
521
+ const aiTemperature = parseFloat(process.env.MAIASS_AI_TEMPERATURE || '0.7');
522
+ const timeoutMs = parseInt(process.env.MAIASS_AI_TIMEOUT || '30', 10) * 1000;
523
+
524
+ if (process.env.MAIASS_DEBUG === 'true') {
525
+ log.debug(SYMBOLS.INFO, `[cleanup] model=${aiModel} input=${inputBytes}B`);
526
+ }
527
+
528
+ const requestBody = {
529
+ model: aiModel,
530
+ messages: [
531
+ { role: 'system', content: systemContent },
532
+ { role: 'user', content: userContent }
533
+ ],
534
+ max_tokens: MAX_OUTPUT_TOKENS,
535
+ temperature: aiTemperature
536
+ };
537
+
538
+ const controller = new AbortController();
539
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
540
+
541
+ let response;
542
+ try {
543
+ response = await fetch(aiEndpoint, {
544
+ method: 'POST',
545
+ headers: {
546
+ 'Content-Type': 'application/json',
547
+ 'Authorization': `Bearer ${token}`,
548
+ 'X-Machine-Fingerprint': generateMachineFingerprint(),
549
+ 'X-Client-Name': getClientName(),
550
+ 'X-Client-Version': getClientVersion(),
551
+ 'X-Subscription-ID': process.env.MAIASS_SUBSCRIPTION_ID || ''
552
+ },
553
+ body: JSON.stringify(requestBody),
554
+ signal: controller.signal
555
+ });
556
+ } catch (err) {
557
+ clearTimeout(timeoutId);
558
+ if (err.name === 'AbortError') {
559
+ throw new Error(`AI request timed out after ${timeoutMs / 1000}s`);
560
+ }
561
+ throw err;
562
+ }
563
+ clearTimeout(timeoutId);
564
+
565
+ if (!response.ok) {
566
+ const errText = await response.text().catch(() => '');
567
+ throw new Error(`AI API ${response.status} ${response.statusText}${errText ? `: ${errText.slice(0, 200)}` : ''}`);
568
+ }
569
+
570
+ const data = await response.json();
571
+
572
+ if (data.billing) {
573
+ if (data.billing.credits_remaining !== undefined) {
574
+ process.env.MAIASS_CREDITS_REMAINING = String(data.billing.credits_remaining);
575
+ }
576
+ if (data.billing.warning) {
577
+ log.warning(SYMBOLS.WARNING, data.billing.warning);
578
+ }
579
+ }
580
+
581
+ const content = data.choices?.[0]?.message?.content;
582
+ if (typeof content !== 'string') {
583
+ throw new Error('AI response had no usable content');
584
+ }
585
+
586
+ return content.trim();
587
+ }
588
+
589
+ /**
590
+ * AI-clean a single just-generated entry block during a version bump.
591
+ * Used by lib/changelog.js as the inline resilience hook.
592
+ *
593
+ * Returns the cleaned bullet block (without the ## VERSION header or date —
594
+ * those are added by the caller after this returns) or throws on failure.
595
+ * The caller is expected to catch and fall back to the unprocessed entries.
596
+ *
597
+ * Intentionally small-scope: this is per-bump, runs every time the flag is
598
+ * on, so we keep the prompt tight and the output a single section's worth
599
+ * of bullets.
600
+ */
601
+ export async function aiCleanSingleEntry({ entries, version, dateLine, style }) {
602
+ const isInternal = style === 'internal';
603
+
604
+ const styleRules = isInternal
605
+ ? [
606
+ 'Preserve MAI-XXX ticket prefixes from the source. If a source entry has no ticket, do NOT invent one — just omit the prefix.',
607
+ 'Preserve the real author name from the source (e.g. "Mark Pottie", "vsmsh"). Do NOT use the literal string "Author Name", "AUTHOR", or any placeholder. If no author appears in the source, omit the author prefix.',
608
+ 'Shape with author + ticket: `- Mark Pottie: MAI-13 short summary`',
609
+ 'Shape with author only: `- Mark Pottie: short summary`',
610
+ 'Shape with neither: `- short summary`',
611
+ 'Indent continuation lines with a single tab character.',
612
+ 'Deduplicate entries that describe the same change.',
613
+ 'Drop "Bumped version to X" and merge-commit entries.'
614
+ ]
615
+ : [
616
+ 'Strip MAI-XXX ticket prefixes.',
617
+ 'Strip author names.',
618
+ 'Use end-user voice (past tense, customer-facing).',
619
+ 'Deduplicate. Rewrite vague subjects when sub-bullets clarify the change; otherwise omit them.',
620
+ 'Drop "Bumped version to X" and merge-commit entries.'
621
+ ];
622
+
623
+ const systemContent = [
624
+ 'You are a changelog editor for the MAIASS git workflow tool.',
625
+ `You are cleaning the bullets for ONE section: version ${version}, dated ${dateLine}.`,
626
+ 'Return ONLY the cleaned bullet list — no `## VERSION` header, no date line, no preamble, no code fences.',
627
+ 'If after applying the rules there is nothing to say, return an empty string.',
628
+ '',
629
+ 'FORMAT:',
630
+ '- entry one',
631
+ '\tcontinuation indented with a single tab',
632
+ '- entry two',
633
+ '',
634
+ `STYLE (${style}):`,
635
+ ...styleRules.map(r => `- ${r}`)
636
+ ].join('\n');
637
+
638
+ const userContent = [
639
+ `Version: ${version}`,
640
+ `Date: ${dateLine}`,
641
+ '',
642
+ 'DRAFT BULLETS (from MAIASS regex formatter — may be ugly or duplicated):',
643
+ '---',
644
+ entries,
645
+ '---',
646
+ '',
647
+ 'Return only the cleaned bullets.'
648
+ ].join('\n');
649
+
650
+ return await callCleanupAI(systemContent, userContent);
651
+ }
652
+
653
+ /**
654
+ * Exported for lib/changelog.js so the bump pipeline can check the same
655
+ * "AI mode is on" rule without duplicating the predicate.
656
+ *
657
+ * The parsing + grouping helpers are also exported so tests and tools can
658
+ * inspect what the cleanup would do without invoking the AI.
659
+ */
660
+ export { isAIModeActive, parseChangelog, groupByMinor, buildVersionCommitMap, consolidateSameDateSections };
661
+
662
+ /**
663
+ * Ensure the listed paths are present in .gitignore. Idempotent — only
664
+ * appends entries that aren't already there. Mirrors the first-run logic
665
+ * in maiass.mjs so user-visible behaviour is consistent.
666
+ */
667
+ function ensureGitignoreEntries(entries) {
668
+ const gitignorePath = '.gitignore';
669
+ let content = '';
670
+ if (existsSync(gitignorePath)) {
671
+ content = readFileSync(gitignorePath, 'utf8');
672
+ }
673
+ const missing = entries.filter(e => !content.split('\n').some(l => l.trim() === e));
674
+ if (missing.length === 0) return;
675
+ if (content && !content.endsWith('\n')) content += '\n';
676
+ content += `\n# MAIASS changelog cleanup backups\n${missing.join('\n')}\n`;
677
+ writeFileSync(gitignorePath, content, 'utf8');
678
+ }
679
+
680
+ /**
681
+ * Process one changelog file end-to-end.
682
+ * - Returns true on success, false on failure (with .bak already restored).
683
+ * - On success, leaves the .bak in place for the caller's gitignore step.
684
+ */
685
+ async function cleanupOneFile(filePath, style, versionMap) {
686
+ if (!existsSync(filePath)) {
687
+ log.info(SYMBOLS.INFO, `${path.basename(filePath)} does not exist — skipping`);
688
+ return { processed: false, bakPath: null };
689
+ }
690
+
691
+ const bakPath = `${filePath}.bak`;
692
+ copyFileSync(filePath, bakPath);
693
+ log.info(SYMBOLS.INFO, `Wrote backup: ${bakPath}`);
694
+
695
+ const original = await fs.readFile(filePath, 'utf8');
696
+ let parsed;
697
+ try {
698
+ parsed = parseChangelog(original);
699
+ } catch (err) {
700
+ log.error(SYMBOLS.CROSS, `Failed to parse ${filePath}: ${err.message}`);
701
+ copyFileSync(bakPath, filePath);
702
+ return { processed: false, bakPath };
703
+ }
704
+
705
+ const groups = groupByMinor(parsed.sections, versionMap.ordered);
706
+ if (groups.length === 0) {
707
+ log.info(SYMBOLS.INFO, `${path.basename(filePath)} has no version sections and no git bumps — nothing to clean`);
708
+ return { processed: true, bakPath };
709
+ }
710
+
711
+ log.info(SYMBOLS.INFO, `Cleaning ${groups.length} minor version group(s) in ${path.basename(filePath)}…`);
712
+
713
+ const cleanedGroups = [];
714
+ for (const group of groups) {
715
+ const { systemContent, userContent } = buildChunkMessages(style, group, versionMap.byVersion, versionMap.dateByVersion);
716
+
717
+ // Soft size guard. If a single chunk goes beyond the threshold we still
718
+ // try it — overshoot is preferable to silently splitting and producing
719
+ // mis-grouped output. We surface a debug log so it's visible.
720
+ const inputBytes = Buffer.byteLength(systemContent + userContent, 'utf8');
721
+ if (inputBytes > CHUNK_THRESHOLD_BYTES) {
722
+ log.debug(SYMBOLS.INFO, `[cleanup] minor ${group.minor}.x chunk is ${inputBytes} bytes (> ${CHUNK_THRESHOLD_BYTES}); sending as-is`);
723
+ }
724
+
725
+ let cleaned;
726
+ try {
727
+ cleaned = await callCleanupAI(systemContent, userContent);
728
+ } catch (err) {
729
+ log.error(SYMBOLS.CROSS, `AI cleanup failed on ${group.minor}.x: ${err.message}`);
730
+ copyFileSync(bakPath, filePath);
731
+ log.warning(SYMBOLS.WARNING, `Restored ${path.basename(filePath)} from backup.`);
732
+ return { processed: false, bakPath };
733
+ }
734
+
735
+ // Deterministic post-pass: the AI is told to consolidate same-date
736
+ // sections into one (latest-patch-as-header), but it under-applies the
737
+ // rule when same-date patches aren't adjacent in the version sequence
738
+ // (observed: 5.12.7 and 5.12.13 both dated 13 May 2026 stayed as two
739
+ // sections). The consolidator below merges them by parsed date —
740
+ // mechanical and reliable, no AI discretion.
741
+ const consolidated = consolidateSameDateSections(cleaned).trim();
742
+ cleanedGroups.push(consolidated);
743
+ log.success(SYMBOLS.CHECKMARK, `Cleaned ${group.minor}.x`);
744
+ }
745
+
746
+ const body = cleanedGroups.filter(Boolean).join('\n\n') + '\n';
747
+ const final = parsed.header
748
+ ? `${parsed.header}\n\n${body}`
749
+ : body;
750
+
751
+ await fs.writeFile(filePath, final, 'utf8');
752
+ log.success(SYMBOLS.CHECKMARK, `Wrote cleaned ${path.basename(filePath)}`);
753
+ return { processed: true, bakPath };
754
+ }
755
+
756
+ /**
757
+ * Lightweight git-state helpers for the branch-safety guard. These use
758
+ * spawnSync so we can capture stderr cleanly for the caller's logs.
759
+ */
760
+ function gitOK(args) {
761
+ const r = spawnSync('git', args, { encoding: 'utf8' });
762
+ return { ok: r.status === 0, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim() };
763
+ }
764
+
765
+ function currentBranch() {
766
+ const r = gitOK(['rev-parse', '--abbrev-ref', 'HEAD']);
767
+ return r.ok ? r.stdout : null;
768
+ }
769
+
770
+ function workingTreeClean() {
771
+ const r = gitOK(['status', '--porcelain']);
772
+ return r.ok && r.stdout === '';
773
+ }
774
+
775
+ /**
776
+ * Run the actual cleanup pipeline (file walk + AI calls). Returns
777
+ * { ok, results } where ok reflects whether all files processed cleanly.
778
+ * Separated out so the branch-safety wrapper can run it inside a try/finally
779
+ * without duplicating the body.
780
+ */
781
+ async function runCleanupPass(publicFile, internalFile, publicExists, internalExists) {
782
+ const versionMap = buildVersionCommitMap();
783
+ if (versionMap.ordered.length === 0) {
784
+ log.warning(SYMBOLS.WARNING, 'No "Bumped version to X" commits found in git history. Backfill will be disabled — only formatting will run.');
785
+ } else {
786
+ log.info(SYMBOLS.INFO, `Found ${versionMap.ordered.length} versioned bump(s) in git history.`);
787
+ }
788
+
789
+ const results = [];
790
+ if (publicExists) {
791
+ results.push(await cleanupOneFile(publicFile, 'public', versionMap));
792
+ }
793
+ if (internalExists) {
794
+ results.push(await cleanupOneFile(internalFile, 'internal', versionMap));
795
+ }
796
+
797
+ const bakNames = results
798
+ .filter(r => r && r.bakPath)
799
+ .map(r => path.basename(r.bakPath));
800
+ if (bakNames.length) ensureGitignoreEntries(bakNames);
801
+
802
+ return { ok: !results.some(r => !r.processed), results };
803
+ }
804
+
805
+ /**
806
+ * Public entry point — invoked from maiass.mjs when --cleanup-changelogs is
807
+ * supplied. Exits the process with status 1 on hard failure so CI / scripts
808
+ * can react.
809
+ *
810
+ * Branch safety: changelog cleanup writes to files that the version-bump
811
+ * pipeline will prepend to next. If cleanup runs on a feature branch, the
812
+ * feature branch's cleaned changelog drifts from develop's, and the next
813
+ * develop bump produces a merge conflict that's awkward to resolve. So
814
+ * cleanup is forced onto `$MAIASS_DEVELOPBRANCH` (default `develop`) — we
815
+ * check it out, run, commit, and switch back. The switch is gated by a
816
+ * confirmation prompt unless auto-approval is configured.
817
+ */
818
+ export async function handleCleanupCommand() {
819
+ if (!isAIModeActive()) {
820
+ log.warning(SYMBOLS.WARNING, 'AI mode is off — cannot run changelog cleanup.');
821
+ log.info(SYMBOLS.INFO, 'Set MAIASS_AI_MODE=ask or =ai_only and try again.');
822
+ process.exit(1);
823
+ }
824
+
825
+ // Branch-safety preflight — required because cleanup modifies committed files.
826
+ const insideRepo = gitOK(['rev-parse', '--is-inside-work-tree']).ok;
827
+ if (!insideRepo) {
828
+ log.error(SYMBOLS.CROSS, 'Not inside a git repository — cleanup requires git history for backfill.');
829
+ process.exit(1);
830
+ }
831
+ const developBranch = process.env.MAIASS_DEVELOPBRANCH || 'develop';
832
+ const origBranch = currentBranch();
833
+ const onDevelop = origBranch === developBranch;
834
+
835
+ if (!workingTreeClean()) {
836
+ log.error(SYMBOLS.CROSS, 'Working tree has uncommitted changes — please commit, stash, or revert before running cleanup.');
837
+ log.info(SYMBOLS.INFO, 'Cleanup writes to CHANGELOG.md / .CHANGELOG_internal.md, so we need a clean tree to keep the diff reviewable.');
838
+ process.exit(1);
839
+ }
840
+
841
+ if (!onDevelop) {
842
+ console.log('');
843
+ console.log(colors.BYellow(`Cleanup must run on ${developBranch} (currently on ${origBranch}).`));
844
+ console.log(colors.Gray(` Running on a feature branch would create a changelog conflict at the next bump.`));
845
+ console.log(colors.Gray(` I will: checkout ${developBranch} → pull --ff-only → run cleanup → commit → switch back to ${origBranch}.`));
846
+ const autoApprove = String(process.env.MAIASS_AUTO_APPROVE_AI_SUGGESTIONS || '').toLowerCase() === 'true';
847
+ if (!autoApprove) {
848
+ const answer = (await getSingleCharInput('Proceed? [Y/n] ')).toLowerCase();
849
+ if (answer === 'n') {
850
+ log.info(SYMBOLS.INFO, 'Aborted.');
851
+ process.exit(0);
852
+ }
853
+ }
854
+
855
+ const co = gitOK(['checkout', developBranch]);
856
+ if (!co.ok) {
857
+ log.error(SYMBOLS.CROSS, `Failed to checkout ${developBranch}: ${co.stderr}`);
858
+ process.exit(1);
859
+ }
860
+ const pull = gitOK(['pull', '--ff-only', 'origin', developBranch]);
861
+ if (!pull.ok) {
862
+ log.warning(SYMBOLS.WARNING, `Could not fast-forward ${developBranch} from origin: ${pull.stderr}`);
863
+ log.info(SYMBOLS.INFO, 'Continuing with local state — your cleanup may not reflect remote-only bumps.');
864
+ }
865
+ }
866
+
867
+ const changelogDir = process.env.MAIASS_CHANGELOG_PATH || '.';
868
+ const changelogName = process.env.MAIASS_CHANGELOG_NAME || 'CHANGELOG.md';
869
+ const internalDir = process.env.MAIASS_CHANGELOG_INTERNAL_PATH || changelogDir;
870
+ const internalName = process.env.MAIASS_CHANGELOG_INTERNAL_NAME || '.CHANGELOG_internal.md';
871
+
872
+ const publicFile = path.join(changelogDir, changelogName);
873
+ const internalFile = path.join(internalDir, internalName);
874
+
875
+ const publicExists = existsSync(publicFile);
876
+ const internalExists = existsSync(internalFile);
877
+
878
+ if (!publicExists && !internalExists) {
879
+ log.warning(SYMBOLS.WARNING, 'No changelog files found — nothing to do.');
880
+ log.info(SYMBOLS.INFO, `Looked for: ${publicFile} and ${internalFile}`);
881
+ if (!onDevelop) gitOK(['checkout', origBranch]);
882
+ process.exit(0);
883
+ }
884
+
885
+ console.log('');
886
+ console.log(colors.BCyan(`Changelog cleanup (on ${developBranch})`));
887
+ console.log(colors.Gray(' Reads git history + existing changelog and rewrites in canonical format.'));
888
+ console.log('');
889
+
890
+ let pass;
891
+ try {
892
+ pass = await runCleanupPass(publicFile, internalFile, publicExists, internalExists);
893
+ } catch (err) {
894
+ log.error(SYMBOLS.CROSS, `Cleanup failed: ${err.message}`);
895
+ if (!onDevelop) gitOK(['checkout', origBranch]);
896
+ process.exit(1);
897
+ }
898
+
899
+ if (!pass.ok) {
900
+ log.error(SYMBOLS.CROSS, 'One or more files failed; originals restored from .bak.');
901
+ if (!onDevelop) gitOK(['checkout', origBranch]);
902
+ process.exit(1);
903
+ }
904
+
905
+ // Commit the cleanup on develop. We include .gitignore in the candidate
906
+ // set because ensureGitignoreEntries() may have added the .bak filenames
907
+ // there — leaving that unstaged would block the post-cleanup checkout
908
+ // back to the user's original branch.
909
+ const candidates = ['.gitignore'];
910
+ if (publicExists) candidates.push(publicFile);
911
+ if (internalExists) candidates.push(internalFile);
912
+
913
+ const status = gitOK(['status', '--porcelain', '--', ...candidates]);
914
+ const hasChanges = status.ok && status.stdout !== '';
915
+ if (hasChanges) {
916
+ const add = gitOK(['add', ...candidates]);
917
+ if (!add.ok) {
918
+ log.warning(SYMBOLS.WARNING, `git add failed: ${add.stderr} — leaving changes uncommitted on ${developBranch}.`);
919
+ } else {
920
+ const commit = gitOK(['commit', '-m', 'chore: changelog cleanup via --cleanup-changelogs']);
921
+ if (!commit.ok) {
922
+ log.warning(SYMBOLS.WARNING, `git commit failed: ${commit.stderr} — changes are staged on ${developBranch}.`);
923
+ } else {
924
+ log.success(SYMBOLS.CHECKMARK, `Committed cleanup on ${developBranch}.`);
925
+ if (String(process.env.MAIASS_AUTO_PUSH_COMMITS || '').toLowerCase() === 'true') {
926
+ const push = gitOK(['push', 'origin', developBranch]);
927
+ if (!push.ok) {
928
+ log.warning(SYMBOLS.WARNING, `git push failed: ${push.stderr} — push manually when ready.`);
929
+ } else {
930
+ log.success(SYMBOLS.CHECKMARK, `Pushed cleanup to origin/${developBranch}.`);
931
+ }
932
+ } else {
933
+ log.info(SYMBOLS.INFO, `Push when ready: git push origin ${developBranch}`);
934
+ }
935
+ }
936
+ }
937
+ } else {
938
+ log.info(SYMBOLS.INFO, 'No changelog content changed — nothing to commit.');
939
+ }
940
+
941
+ // Return to the user's original branch.
942
+ if (!onDevelop) {
943
+ const back = gitOK(['checkout', origBranch]);
944
+ if (!back.ok) {
945
+ log.warning(SYMBOLS.WARNING, `Failed to checkout back to ${origBranch}: ${back.stderr}`);
946
+ } else {
947
+ log.success(SYMBOLS.CHECKMARK, `Returned to ${origBranch}.`);
948
+ }
949
+ }
950
+
951
+ if (process.env.MAIASS_CREDITS_REMAINING) {
952
+ console.log('');
953
+ log.info(SYMBOLS.INFO, `Credits remaining: ${process.env.MAIASS_CREDITS_REMAINING}`);
954
+ }
955
+ console.log('');
956
+ log.success(SYMBOLS.CHECKMARK, 'Changelog cleanup complete.');
957
+ }
958
+
959
+ export default { handleCleanupCommand, FLAGS };