modelrot 0.1.0__tar.gz

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,630 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bitacora — the logbook your coding agent keeps.
4
+ *
5
+ * Zero dependencies. Lives inside your repo on purpose: no supply chain,
6
+ * no version drift, and your agent can read the source of its own tooling.
7
+ *
8
+ * Usage:
9
+ * node .bitacora/cli.mjs doctor [--strict]
10
+ * node .bitacora/cli.mjs new mistake "Scraper overwrote manual prices" --tags pricing,data-loss --severity high
11
+ * node .bitacora/cli.mjs recall pricing [--brief]
12
+ * node .bitacora/cli.mjs rotate [--dry-run]
13
+ * node .bitacora/cli.mjs stats
14
+ */
15
+
16
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
17
+ import { join, dirname } from 'node:path';
18
+
19
+ const ROOT = process.cwd();
20
+ const MARKER = '<!-- bitacora:entry';
21
+ const ARCHIVE_HEADING = '## Archived';
22
+ const HAS_FILL_ME = /<!--\s*bitacora:fill-me/;
23
+ const STRIP_FILL_ME = /<!--\s*bitacora:fill-me[\s\S]*?-->/g;
24
+ const SEVERITIES = ['low', 'medium', 'high'];
25
+ const RECALL_FULL = 5; // entries printed in full before falling back to an index
26
+ const MIN_SECTION_CHARS = 40; // below this, a section is a gesture rather than a thought
27
+ const RECENT_DAYS = 90;
28
+ const MIN_KEEP = 3; // a log with fewer live entries than this is not a log
29
+
30
+ const DEFAULTS = {
31
+ version: 1,
32
+ logs: {
33
+ 'MISTAKES.md': {
34
+ prefix: 'M',
35
+ maxLines: 400,
36
+ keepEntries: 20,
37
+ severity: true,
38
+ sections: ['What happened', 'Root cause', 'Guardrail'],
39
+ },
40
+ 'LEARNINGS.md': {
41
+ prefix: 'L',
42
+ maxLines: 400,
43
+ keepEntries: 20,
44
+ sections: ['What worked', 'Why it worked', 'Reuse it when'],
45
+ },
46
+ 'DECISIONS.md': {
47
+ prefix: 'D',
48
+ maxLines: 600,
49
+ keepEntries: 40,
50
+ sections: ['Context', 'Decision', 'Consequences'],
51
+ },
52
+ },
53
+ state: { file: 'STATE.md', maxLines: 200, maxAgeDays: 14 },
54
+ required: ['CLAUDE.md', 'ARCHITECTURE.md', 'STATE.md', 'MISTAKES.md', 'LEARNINGS.md', 'DECISIONS.md'],
55
+ archiveDir: 'docs/bitacora-archive',
56
+ };
57
+
58
+ const KIND_TO_FILE = { mistake: 'MISTAKES.md', learning: 'LEARNINGS.md', decision: 'DECISIONS.md' };
59
+
60
+ // ---------------------------------------------------------------- utilities
61
+
62
+ // Colour is off when asked, and off when nobody is watching — hook output and
63
+ // test assertions must never have to match escape codes.
64
+ const PLAIN = Boolean(process.env.NO_COLOR) || !process.stdout.isTTY;
65
+ const paint = (code) => (s) => (PLAIN ? s : `\x1b[${code}m${s}\x1b[0m`);
66
+ const C = { red: paint(31), green: paint(32), yellow: paint(33), dim: paint(2), bold: paint(1) };
67
+
68
+ function fail(msg) {
69
+ console.error(C.red(msg));
70
+ process.exit(1);
71
+ }
72
+
73
+ function loadConfig() {
74
+ const p = join(ROOT, 'bitacora.config.json');
75
+ if (!existsSync(p)) return DEFAULTS;
76
+ let user;
77
+ try {
78
+ user = JSON.parse(readFileSync(p, 'utf8'));
79
+ } catch (e) {
80
+ fail(`bitacora.config.json is not valid JSON: ${e.message}`);
81
+ }
82
+ // Per-log specs merge field by field: overriding maxLines must not silently
83
+ // drop the section requirements that give doctor its teeth.
84
+ const logs = { ...DEFAULTS.logs };
85
+ for (const [file, spec] of Object.entries(user.logs || {})) {
86
+ logs[file] = { ...(DEFAULTS.logs[file] || {}), ...spec };
87
+ }
88
+ return { ...DEFAULTS, ...user, logs, state: { ...DEFAULTS.state, ...(user.state || {}) } };
89
+ }
90
+
91
+ // Markdown with its code stripped. A logbook documents its own format, so
92
+ // prose quotes bitacora's markers constantly; scanning raw text for them makes
93
+ // every such sentence a false positive. Quote a marker in backticks and it is
94
+ // prose; write it bare and it is a marker. (See M-0001 and M-0006.)
95
+ // An inline span may wrap across lines — hand-written markdown at 80 columns
96
+ // does it constantly — but never across a blank line, which would mean the
97
+ // backticks are unbalanced rather than spanning.
98
+ const withoutCode = (text) =>
99
+ text.replace(/```[\s\S]*?```/g, '').replace(/`(?:[^`\n]|\n(?!\s*\n))*`/g, '');
100
+
101
+ const read = (file) => (existsSync(join(ROOT, file)) ? readFileSync(join(ROOT, file), 'utf8') : null);
102
+ const today = () => new Date().toISOString().slice(0, 10);
103
+ function plural(n, word) {
104
+ if (n === 1) return `1 ${word}`;
105
+ if (/[^aeiou]y$/.test(word)) return `${n} ${word.slice(0, -1)}ies`;
106
+ if (/(s|x|z|ch|sh)$/.test(word)) return `${n} ${word}es`;
107
+ return `${n} ${word}s`;
108
+ }
109
+ const kindOf = (file) => file.replace('.md', '').toLowerCase().replace(/s$/, '');
110
+
111
+ function parseList(raw) {
112
+ if (!raw || raw === '[]') return [];
113
+ return raw
114
+ .replace(/^\[|\]$/g, '')
115
+ .split(',')
116
+ .map((s) => s.trim().replace(/^["']|["']$/g, ''))
117
+ .filter(Boolean);
118
+ }
119
+
120
+ function daysSince(iso) {
121
+ const then = Date.parse(iso);
122
+ return Number.isNaN(then) ? null : Math.floor((Date.now() - then) / 86400000);
123
+ }
124
+
125
+ /**
126
+ * Split a log into { head, entries[], tail }.
127
+ *
128
+ * Each entry owns its raw text, from its marker to the next marker, the
129
+ * "## Archived" heading, or end of file. Nothing is ever regenerated from
130
+ * parsed fields, which is what makes hand-edited entries safe.
131
+ */
132
+ function parseEntries(text) {
133
+ if (!text) return { head: '', entries: [], tail: '' };
134
+ const archiveAt = text.indexOf(`\n${ARCHIVE_HEADING}`);
135
+ const body = archiveAt === -1 ? text : text.slice(0, archiveAt);
136
+ const tail = archiveAt === -1 ? '' : text.slice(archiveAt);
137
+
138
+ // Anchored to the start of a line on purpose: prose that quotes the marker
139
+ // inline (this project's own templates do) must not register as an entry.
140
+ const starts = [];
141
+ const anchor = new RegExp(`^${MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'gm');
142
+ for (let m = anchor.exec(body); m !== null; m = anchor.exec(body)) starts.push(m.index);
143
+ if (starts.length === 0) return { head: body, entries: [], tail };
144
+
145
+ const entries = starts.map((start, n) => {
146
+ const raw = body.slice(start, n + 1 < starts.length ? starts[n + 1] : body.length);
147
+ const close = raw.indexOf('-->');
148
+ const meta = {};
149
+ for (const line of (close === -1 ? '' : raw.slice(MARKER.length, close)).split('\n')) {
150
+ const m = line.match(/^\s*([a-zA-Z_]+)\s*:\s*(.*?)\s*$/);
151
+ if (m) meta[m[1]] = m[2];
152
+ }
153
+ const title = raw.match(/^#{2,4}\s+(.+)$/m);
154
+ return {
155
+ raw,
156
+ id: meta.id || null,
157
+ date: meta.date || null,
158
+ tags: parseList(meta.tags),
159
+ severity: meta.severity || null,
160
+ files: parseList(meta.files),
161
+ title: title ? title[1].trim() : '(untitled)',
162
+ };
163
+ });
164
+ return { head: body.slice(0, starts[0]), entries, tail };
165
+ }
166
+
167
+ /** The prose under a `**Name.**` heading, with placeholders stripped. */
168
+ function sectionBody(raw, name) {
169
+ const needle = `**${name}.**`;
170
+ const at = raw.indexOf(needle);
171
+ if (at === -1) return null;
172
+ const rest = raw.slice(at + needle.length);
173
+ const next = rest.search(/\n\*\*[A-Z]/);
174
+ return (next === -1 ? rest : rest.slice(0, next))
175
+ .replace(STRIP_FILL_ME, '')
176
+ .replace(/\s+/g, ' ')
177
+ .trim();
178
+ }
179
+
180
+ /** Every entry anywhere — live logs and the archive. */
181
+ function allEntries(cfg) {
182
+ const out = [];
183
+ for (const file of Object.keys(cfg.logs)) {
184
+ for (const e of parseEntries(read(file)).entries) out.push({ ...e, file, archived: false });
185
+ }
186
+ const dir = join(ROOT, cfg.archiveDir);
187
+ if (existsSync(dir)) {
188
+ for (const f of readdirSync(dir).filter((f) => f.endsWith('.md'))) {
189
+ const rel = `${cfg.archiveDir}/${f}`;
190
+ for (const e of parseEntries(readFileSync(join(dir, f), 'utf8')).entries) {
191
+ out.push({ ...e, file: rel, archived: true });
192
+ }
193
+ }
194
+ }
195
+ return out;
196
+ }
197
+
198
+ /** Next free id, counting the archive so a manual deletion cannot cause reuse. */
199
+ function nextId(prefix, cfg) {
200
+ let max = 0;
201
+ for (const e of allEntries(cfg)) {
202
+ const m = (e.id || '').match(new RegExp(`^${prefix}-(\\d+)$`));
203
+ if (m) max = Math.max(max, parseInt(m[1], 10));
204
+ }
205
+ return `${prefix}-${String(max + 1).padStart(4, '0')}`;
206
+ }
207
+
208
+ // ------------------------------------------------------------------ doctor
209
+
210
+ function doctor(cfg, args) {
211
+ const errors = [];
212
+ const warnings = [];
213
+
214
+ // 1. Required files exist.
215
+ for (const f of cfg.required) {
216
+ if (!existsSync(join(ROOT, f))) errors.push(`missing required file: ${f}`);
217
+ }
218
+
219
+ // 2. Template placeholders outside entries (entry-level ones are reported by id below).
220
+ for (const f of cfg.required) {
221
+ const t = read(f);
222
+ if (!t) continue;
223
+ const { head } = parseEntries(t);
224
+ if (HAS_FILL_ME.test(withoutCode(cfg.logs[f] ? head : t))) {
225
+ errors.push(`${f} still has a bitacora:fill-me placeholder — write the real thing or delete the block`);
226
+ }
227
+ }
228
+
229
+ // 3. Context budget.
230
+ for (const [file, spec] of Object.entries(cfg.logs)) {
231
+ const t = read(file);
232
+ if (t === null) continue;
233
+ const n = t.split('\n').length;
234
+ if (n > spec.maxLines) {
235
+ errors.push(
236
+ `${file} is ${n} lines, budget is ${spec.maxLines} — run "rotate", which archives from the bottom until it fits` +
237
+ ` (if it is already down to ${plural(MIN_KEEP, 'entry')}, raise maxLines in bitacora.config.json instead)`
238
+ );
239
+ } else if (n > spec.maxLines * 0.85) {
240
+ warnings.push(
241
+ `${file} is at ${Math.round((n / spec.maxLines) * 100)}% of its ${spec.maxLines}-line budget` +
242
+ ' — rotate starts archiving from the bottom once it crosses, and does nothing before that'
243
+ );
244
+ }
245
+ }
246
+
247
+ // 4. Entries: metadata, unique ids across live and archive, and real content.
248
+ const seen = new Map();
249
+ for (const e of allEntries(cfg)) {
250
+ if (e.id && !seen.has(e.id)) seen.set(e.id, e.file);
251
+ else if (e.id) errors.push(`duplicate id ${e.id} (${seen.get(e.id)} and ${e.file})`);
252
+ }
253
+
254
+ for (const [file, spec] of Object.entries(cfg.logs)) {
255
+ const { entries } = parseEntries(read(file));
256
+ let previous = null;
257
+
258
+ entries.forEach((e, n) => {
259
+ const where = `${file} ${e.id || `entry #${n + 1}`} ("${e.title}")`;
260
+
261
+ if (!e.id) errors.push(`${where} has no id`);
262
+ else if (!new RegExp(`^${spec.prefix}-\\d{4}$`).test(e.id))
263
+ errors.push(`${where} has id "${e.id}", expected ${spec.prefix}-0000 form`);
264
+
265
+ const age = e.date ? daysSince(e.date) : null;
266
+ if (age === null) errors.push(`${where} has an unparseable date: "${e.date}"`);
267
+ else if (age < 0) errors.push(`${where} is dated in the future ("${e.date}") — probably a typo`);
268
+
269
+ if (e.tags.length === 0) errors.push(`${where} has no tags — recall cannot find it`);
270
+ else if (e.tags.includes('example'))
271
+ warnings.push(`${where} is still tagged "example" — delete the template entry once you have a real one`);
272
+
273
+ if (spec.severity) {
274
+ if (!e.severity) errors.push(`${where} has no severity (${SEVERITIES.join(' | ')})`);
275
+ else if (!SEVERITIES.includes(e.severity))
276
+ errors.push(`${where} has severity "${e.severity}", expected one of ${SEVERITIES.join(' | ')}`);
277
+ }
278
+
279
+ for (const f of e.files) {
280
+ if (!existsSync(join(ROOT, f))) warnings.push(`${where} references ${f}, which no longer exists`);
281
+ }
282
+
283
+ // The check the whole method rests on: an entry with a missing or
284
+ // gestural Guardrail is a complaint, and a log of complaints compounds
285
+ // into nothing.
286
+ if (HAS_FILL_ME.test(withoutCode(e.raw))) {
287
+ errors.push(`${where} still has unfilled bitacora:fill-me blocks`);
288
+ } else {
289
+ for (const name of spec.sections || []) {
290
+ const body = sectionBody(e.raw, name);
291
+ if (body === null) errors.push(`${where} has no "**${name}.**" section`);
292
+ else if (body.length < MIN_SECTION_CHARS)
293
+ errors.push(
294
+ `${where} has a ${body.length ? 'near-empty' : 'blank'} "${name}" section` +
295
+ (name === 'Guardrail'
296
+ ? ' — name the check, test, type or refusal that makes this impossible to repeat, not an intention to be careful'
297
+ : ` — under ${MIN_SECTION_CHARS} characters is a gesture, not a thought`)
298
+ );
299
+ }
300
+ }
301
+
302
+ // rotate retires from the bottom, so the ordering invariant is load-bearing.
303
+ if (previous && e.date && previous.date && Date.parse(e.date) > Date.parse(previous.date)) {
304
+ errors.push(`${where} is dated after ${previous.id} above it — entries run newest first, and rotate archives from the bottom`);
305
+ }
306
+ previous = e;
307
+ });
308
+ }
309
+
310
+ // 5. STATE.md is a fresh snapshot, not a diary.
311
+ const stateText = read(cfg.state.file);
312
+ if (stateText) {
313
+ const m = stateText.match(/^updated:\s*(\S+)/m);
314
+ if (!m) {
315
+ errors.push(`${cfg.state.file} has no "updated: YYYY-MM-DD" line`);
316
+ } else {
317
+ const age = daysSince(m[1]);
318
+ if (age === null) errors.push(`${cfg.state.file} updated date is unparseable: "${m[1]}"`);
319
+ else if (age < 0) errors.push(`${cfg.state.file} is dated in the future ("${m[1]}")`);
320
+ else if (age > cfg.state.maxAgeDays)
321
+ warnings.push(
322
+ `${cfg.state.file} was last updated ${plural(age, 'day')} ago (limit ${cfg.state.maxAgeDays})` +
323
+ ' — a stale snapshot is worse than none, because your agent cannot tell'
324
+ );
325
+ }
326
+ const n = stateText.split('\n').length;
327
+ if (n > cfg.state.maxLines)
328
+ errors.push(
329
+ `${cfg.state.file} is ${n} lines, budget is ${cfg.state.maxLines} — it is a snapshot, not a diary.` +
330
+ ' Move the history into the logs, where recall can find it by tag.'
331
+ );
332
+ }
333
+
334
+ // 6. The context leak: one eager import quietly undoes the whole design.
335
+ const claude = read('CLAUDE.md');
336
+ if (claude) {
337
+ for (const file of Object.keys(cfg.logs)) {
338
+ if (new RegExp(`^\\s*@${file.replace('.', '\\.')}\\s*$`, 'm').test(claude)) {
339
+ errors.push(`CLAUDE.md does "@${file}" — that loads the whole log into every session. Let the agent recall by tag instead.`);
340
+ }
341
+ }
342
+ }
343
+
344
+ for (const w of warnings) console.log(`${C.yellow('warn')} ${w}`);
345
+ for (const e of errors) console.log(`${C.red('error')} ${e}`);
346
+
347
+ if (errors.length === 0) {
348
+ const counts = Object.keys(cfg.logs)
349
+ .map((f) => plural(parseEntries(read(f)).entries.length, kindOf(f)))
350
+ .join(', ');
351
+ console.log(`${C.green('ok')} bitacora is healthy ${C.dim(`(${counts})`)}`);
352
+ }
353
+
354
+ const strictFail = args.strict && warnings.length > 0;
355
+ if (strictFail) console.log(C.dim('--strict: failing on warnings'));
356
+ process.exit(errors.length > 0 || strictFail ? 1 : 0);
357
+ }
358
+
359
+ // --------------------------------------------------------------------- new
360
+
361
+ const BODY = {
362
+ mistake: [
363
+ '**What happened.** <!-- bitacora:fill-me one paragraph, concrete, no blame -->',
364
+ '',
365
+ '**Root cause.** <!-- bitacora:fill-me why it was possible, not just what broke -->',
366
+ '',
367
+ '**Guardrail.** <!-- bitacora:fill-me the check, test, type or refusal that makes this impossible to repeat. Not "be careful" -->',
368
+ ],
369
+ learning: [
370
+ '**What worked.** <!-- bitacora:fill-me -->',
371
+ '',
372
+ '**Why it worked.** <!-- bitacora:fill-me the transferable part -->',
373
+ '',
374
+ '**Reuse it when.** <!-- bitacora:fill-me the trigger that should bring this back -->',
375
+ ],
376
+ decision: [
377
+ '**Context.** <!-- bitacora:fill-me the forces in play at the time -->',
378
+ '',
379
+ '**Decision.** <!-- bitacora:fill-me -->',
380
+ '',
381
+ '**Consequences.** <!-- bitacora:fill-me what this makes easy, and what it makes expensive -->',
382
+ ],
383
+ };
384
+
385
+ function newEntry(cfg, args) {
386
+ const kind = args._[0];
387
+ const title = args._.slice(1).join(' ');
388
+ if (!KIND_TO_FILE[kind] || !title) {
389
+ fail('usage: new <mistake|learning|decision> "<title>" [--tags a,b] [--severity low|medium|high] [--files a.ts,b.ts]');
390
+ }
391
+
392
+ const file = KIND_TO_FILE[kind];
393
+ const spec = cfg.logs[file];
394
+ const text = read(file);
395
+ if (text === null) fail(`${file} does not exist. Run the installer first.`);
396
+
397
+ const tags = parseList(args.tags);
398
+ if (tags.length === 0) fail('--tags is required: an untagged entry is an entry nobody will ever recall.');
399
+
400
+ const severity = args.severity || 'medium';
401
+ if (spec.severity && !SEVERITIES.includes(severity)) {
402
+ fail(`--severity must be one of ${SEVERITIES.join(' | ')}`);
403
+ }
404
+
405
+ const meta = [`id: ${nextId(spec.prefix, cfg)}`, `date: ${today()}`, `tags: [${tags.join(', ')}]`];
406
+ if (spec.severity) meta.push(`severity: ${severity}`);
407
+ const files = parseList(args.files);
408
+ if (files.length) meta.push(`files: [${files.join(', ')}]`);
409
+
410
+ const { head, entries, tail } = parseEntries(text);
411
+ const entry = [MARKER, ...meta, '-->', `### ${title}`, '', ...BODY[kind], '', ''].join('\n');
412
+
413
+ // Newest first: the agent reads top-down and stops early.
414
+ writeFileSync(join(ROOT, file), head + entry + entries.map((e) => e.raw).join('') + tail);
415
+
416
+ const id = meta[0].slice(4);
417
+ console.log(`${C.green('added')} ${id} to ${file} ${C.dim(`[${tags.join(', ')}]`)}`);
418
+ console.log(C.dim('Now fill the blocks. doctor fails while they are unwritten, and again if the'));
419
+ console.log(C.dim(`${kind === 'mistake' ? 'Guardrail' : (cfg.logs[file].sections || []).slice(-1)[0]} section says nothing.`));
420
+ }
421
+
422
+ // ------------------------------------------------------------------ recall
423
+
424
+ function recall(cfg, args) {
425
+ const needle = (args._[0] || '').toLowerCase();
426
+ if (!needle) fail('usage: recall <tag-or-path-or-phrase> [--brief]');
427
+
428
+ // Ranked, because an exact tag match is a different kind of hit from a word
429
+ // that happens to appear in someone's prose.
430
+ const hits = [];
431
+ for (const e of allEntries(cfg)) {
432
+ let score = 0;
433
+ if (e.tags.some((t) => t.toLowerCase() === needle)) score = 3;
434
+ else if (e.files.some((f) => f.toLowerCase().includes(needle))) score = 2;
435
+ else if (e.title.toLowerCase().includes(needle)) score = 2;
436
+ else if (e.raw.toLowerCase().includes(needle)) score = 1;
437
+ if (score) hits.push({ ...e, score });
438
+ }
439
+ hits.sort((a, b) => b.score - a.score || String(b.date).localeCompare(String(a.date)));
440
+
441
+ if (hits.length === 0) {
442
+ // The loop closes here or nowhere. A miss is not a dead end: it is the
443
+ // exact moment a future entry is born, so hand over the command rather
444
+ // than leaving the agent to remember that it exists.
445
+ console.log(C.dim(`nothing logged under "${needle}" yet.`));
446
+ console.log(C.dim('An area with no history is one where the first mistake has not been made yet,'));
447
+ console.log(C.dim('which makes it more likely to happen here, not less. When it does:'));
448
+ console.log('');
449
+ console.log(C.dim(` node .bitacora/cli.mjs new mistake "<what happened>" --tags ${needle} --severity high`));
450
+ console.log('');
451
+ return;
452
+ }
453
+
454
+ const brief = Boolean(args.brief);
455
+ const full = brief ? [] : hits.slice(0, RECALL_FULL);
456
+ const listed = brief ? hits : hits.slice(RECALL_FULL);
457
+
458
+ for (const h of full) {
459
+ console.log(C.dim(`─── ${h.file}${h.archived ? ' (archived)' : ''} ───`));
460
+ console.log(h.raw.replace(/^<!-- bitacora:entry[\s\S]*?-->\n/, '').trim());
461
+ console.log(C.dim(` ${h.id} · ${h.date} · [${h.tags.join(', ')}]${h.severity ? ` · ${h.severity}` : ''}`));
462
+ console.log('');
463
+ }
464
+
465
+ if (listed.length) {
466
+ if (full.length) console.log(C.dim(`${plural(listed.length, 'further match')}, titles only:`));
467
+ for (const h of listed) {
468
+ console.log(`${C.bold(`${h.id} ${h.title}`)}${h.archived ? C.dim(' (archived)') : ''}`);
469
+ console.log(C.dim(` ${h.file} · ${h.date} · [${h.tags.join(', ')}]`));
470
+ }
471
+ console.log('');
472
+ }
473
+
474
+ console.log(C.dim(`${plural(hits.length, 'entry')} for "${needle}". Honour the guardrails; if one looks wrong, say so rather than routing around it.`));
475
+ }
476
+
477
+ // ------------------------------------------------------------------ rotate
478
+
479
+ function rotate(cfg, args) {
480
+ const dry = Boolean(args['dry-run']);
481
+ let moved = 0;
482
+
483
+ for (const [file, spec] of Object.entries(cfg.logs)) {
484
+ const text = read(file);
485
+ if (text === null) continue;
486
+ const { head, entries, tail } = parseEntries(text);
487
+
488
+ // Defensive: doctor enforces newest-first, so this sort is normally a
489
+ // no-op. It stops a hand-inserted entry from being archived by position.
490
+ const ordered = [...entries].sort((a, b) => String(b.date).localeCompare(String(a.date)));
491
+
492
+ // The index lines already in the file. Rebuilt from the ids it lists, so
493
+ // repeated rotates cannot duplicate a line.
494
+ const previous = tail
495
+ .split('\n')
496
+ .map((l) => ({ id: (l.match(/^- `([A-Z]-\d{4})`/) || [])[1], line: l }))
497
+ .filter((x) => x.id);
498
+
499
+ const archiveLine = (e) => {
500
+ const year = (e.date || today()).slice(0, 4);
501
+ const rel = `${cfg.archiveDir}/${kindOf(file)}s-${year}.md`;
502
+ return { id: e.id, line: `- \`${e.id}\` ${e.title} — [${e.tags.join(', ')}] → \`${rel}\`` };
503
+ };
504
+
505
+ const tailFor = (retire) => {
506
+ const index = [...retire.map(archiveLine), ...previous]
507
+ .filter((x, i, all) => all.findIndex((y) => y.id === x.id) === i);
508
+ return [
509
+ `\n${ARCHIVE_HEADING}`,
510
+ '',
511
+ 'Older entries, one line each. `recall` still searches them in full.',
512
+ '',
513
+ ...index.map((x) => x.line),
514
+ '',
515
+ ].join('\n');
516
+ };
517
+
518
+ // Two budgets, and the line budget is the one that bites first: entries
519
+ // long enough to be worth keeping blow through maxLines well before they
520
+ // reach keepEntries. Retire on whichever binds (M-0012). The projection is
521
+ // the real rendered file, never an approximation of it (M-0017).
522
+ const render = (n) => head + ordered.slice(0, n).map((e) => e.raw).join('') + tailFor(ordered.slice(n));
523
+ const lines = (n) => render(n).split('\n').length;
524
+
525
+ let keepCount = Math.min(ordered.length, spec.keepEntries);
526
+ while (keepCount > MIN_KEEP && lines(keepCount) > spec.maxLines) keepCount--;
527
+ if (keepCount >= ordered.length) continue;
528
+
529
+ const keep = ordered.slice(0, keepCount);
530
+ const retire = ordered.slice(keepCount);
531
+
532
+ const byYear = new Map();
533
+ for (const e of retire) {
534
+ const year = (e.date || today()).slice(0, 4);
535
+ if (!byYear.has(year)) byYear.set(year, []);
536
+ byYear.get(year).push(e);
537
+ }
538
+
539
+ for (const [year, group] of byYear) {
540
+ const rel = `${cfg.archiveDir}/${kindOf(file)}s-${year}.md`;
541
+ const target = join(ROOT, rel);
542
+ if (!dry) {
543
+ const header = `# ${file.replace('.md', '')} — ${year}\n\n> Archived by bitacora. Still searchable with "recall".\n\n`;
544
+ mkdirSync(dirname(target), { recursive: true });
545
+ writeFileSync(target, (existsSync(target) ? readFileSync(target, 'utf8') : header) + group.map((e) => e.raw).join(''));
546
+ }
547
+ moved += group.length;
548
+ }
549
+
550
+ const newTail = tailFor(retire);
551
+
552
+ if (!dry) writeFileSync(join(ROOT, file), head + keep.map((e) => e.raw).join('') + newTail);
553
+ console.log(`${dry ? C.yellow('would move') : C.green('moved')} ${plural(retire.length, 'entry')} out of ${file}`);
554
+ }
555
+
556
+ if (moved === 0) console.log(C.dim('nothing to rotate — every log is within its entry budget'));
557
+ else if (!dry) console.log(C.dim(`\n${plural(moved, 'entry')} archived. Run doctor to confirm the live files are back inside budget.`));
558
+ }
559
+
560
+ // ------------------------------------------------------------------- stats
561
+
562
+ function stats(cfg) {
563
+ const tally = new Map();
564
+ let total = 0;
565
+ let recent = 0;
566
+
567
+ for (const e of allEntries(cfg).filter((e) => !e.archived)) {
568
+ total++;
569
+ const age = e.date ? daysSince(e.date) : null;
570
+ const isRecent = age !== null && age >= 0 && age <= RECENT_DAYS;
571
+ if (isRecent) recent++;
572
+ for (const t of e.tags) {
573
+ const row = tally.get(t) || { all: 0, recent: 0 };
574
+ row.all++;
575
+ if (isRecent) row.recent++;
576
+ tally.set(t, row);
577
+ }
578
+ }
579
+
580
+ if (total === 0) return console.log(C.dim('no entries yet'));
581
+
582
+ const ranked = [...tally.entries()].sort((a, b) => b[1].recent - a[1].recent || b[1].all - a[1].all).slice(0, 12);
583
+ const width = Math.max(...ranked.map(([t]) => t.length));
584
+ const top = Math.max(...ranked.map(([, r]) => r.all));
585
+
586
+ console.log(C.bold(`\n${plural(total, 'entry')} in the live logs, ${recent} from the last ${RECENT_DAYS} days. Where the friction is:\n`));
587
+ for (const [tag, row] of ranked) {
588
+ const bar = '█'.repeat(Math.max(1, Math.round((row.all / top) * 26)));
589
+ const note = row.recent === row.all ? '' : C.dim(` (${row.recent} recent)`);
590
+ console.log(` ${tag.padEnd(width)} ${bar} ${row.all}${note}`);
591
+ }
592
+ console.log(C.dim('\nRanked by recent activity. The top tag is usually one missing abstraction, told n times.\n'));
593
+ }
594
+
595
+ // -------------------------------------------------------------------- main
596
+
597
+ function parseArgv(argv) {
598
+ const out = { _: [] };
599
+ for (let i = 0; i < argv.length; i++) {
600
+ const a = argv[i];
601
+ if (a.startsWith('--')) {
602
+ const [k, v] = a.slice(2).split('=');
603
+ out[k] = v !== undefined ? v : argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true;
604
+ } else out._.push(a);
605
+ }
606
+ return out;
607
+ }
608
+
609
+ const [, , cmd, ...rest] = process.argv;
610
+ const args = parseArgv(rest);
611
+ const cfg = loadConfig();
612
+
613
+ switch (cmd) {
614
+ case 'doctor': doctor(cfg, args); break;
615
+ case 'new': newEntry(cfg, args); break;
616
+ case 'recall': recall(cfg, args); break;
617
+ case 'rotate': rotate(cfg, args); break;
618
+ case 'stats': stats(cfg); break;
619
+ default:
620
+ console.log(`bitacora — the logbook your coding agent keeps
621
+
622
+ doctor [--strict] structure, freshness, context budget, and whether
623
+ entries actually say anything
624
+ new <kind> "<title>" --tags a,b add an entry (kind: mistake | learning | decision)
625
+ recall <tag> [--brief] pull only the entries that matter right now
626
+ rotate [--dry-run] archive old entries, keep the live files lean
627
+ stats where your friction actually is
628
+ `);
629
+ process.exit(cmd ? 1 : 0);
630
+ }
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env bash
2
+ # bitacora — session end.
3
+ #
4
+ # The habit is the hard part, so the close is checked rather than trusted.
5
+ #
6
+ # Stop is NOT one of the events whose plain stdout reaches the model — that
7
+ # goes to the debug log, where nobody reads it. The reminder therefore has to
8
+ # travel as JSON on stdout, in the `systemMessage` field, with exit 0.
9
+ #
10
+ # Exit 2 would also work and would block the stop, but blocking a session over
11
+ # bookkeeping is how a hook ends up deleted. This reports; it never blocks.
12
+
13
+ set -uo pipefail
14
+ cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
15
+ [ -f bitacora.config.json ] || exit 0
16
+ command -v node >/dev/null 2>&1 || exit 0
17
+
18
+ problems=()
19
+
20
+ if [ -f STATE.md ]; then
21
+ updated=$(grep -m1 '^updated:' STATE.md | awk '{print $2}')
22
+ if [ "${updated:-}" != "$(date +%F)" ]; then
23
+ problems+=("- STATE.md still says \`updated: ${updated:-none}\`. If anything changed this session, rewrite the sections that moved and set today's date.")
24
+ fi
25
+ fi
26
+
27
+ if [ -f .bitacora/cli.mjs ]; then
28
+ if ! out=$(NO_COLOR=1 node .bitacora/cli.mjs doctor 2>&1); then
29
+ problems+=("- \`doctor\` is failing:")
30
+ problems+=("$(echo "$out" | sed 's/^/ /')")
31
+ fi
32
+ fi
33
+
34
+ [ ${#problems[@]} -eq 0 ] && exit 0
35
+
36
+ {
37
+ echo "Before this session closes:"
38
+ echo
39
+ printf '%s\n' "${problems[@]}"
40
+ echo
41
+ echo "Also worth a moment: did anything break, or work surprisingly well? Log it now —"
42
+ echo '`node .bitacora/cli.mjs new mistake|learning "..." --tags <area>`. At the end of'
43
+ echo "the next session the details are gone."
44
+ } | node -e '
45
+ let s = "";
46
+ process.stdin.on("data", (d) => (s += d));
47
+ process.stdin.on("end", () => process.stdout.write(JSON.stringify({ systemMessage: s.trim() })));
48
+ '
49
+
50
+ exit 0