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.
- modelrot-0.1.0/.bitacora/cli.mjs +630 -0
- modelrot-0.1.0/.claude/hooks/bitacora-session-end.sh +50 -0
- modelrot-0.1.0/.claude/hooks/bitacora-session-start.sh +53 -0
- modelrot-0.1.0/.claude/settings.json +34 -0
- modelrot-0.1.0/.claude/skills/close-session/SKILL.md +63 -0
- modelrot-0.1.0/.claude/skills/log-mistake/SKILL.md +104 -0
- modelrot-0.1.0/.claude/skills/recall/SKILL.md +57 -0
- modelrot-0.1.0/.github/workflows/ci.yml +23 -0
- modelrot-0.1.0/.github/workflows/refresh-catalog.yml +64 -0
- modelrot-0.1.0/.gitignore +7 -0
- modelrot-0.1.0/.pre-commit-hooks.yaml +7 -0
- modelrot-0.1.0/ARCHITECTURE.md +76 -0
- modelrot-0.1.0/CLAUDE.md +87 -0
- modelrot-0.1.0/DECISIONS.md +189 -0
- modelrot-0.1.0/LEARNINGS.md +36 -0
- modelrot-0.1.0/LICENSE +21 -0
- modelrot-0.1.0/MISTAKES.md +8 -0
- modelrot-0.1.0/PKG-INFO +165 -0
- modelrot-0.1.0/README.md +150 -0
- modelrot-0.1.0/STATE.md +80 -0
- modelrot-0.1.0/bitacora.config.json +11 -0
- modelrot-0.1.0/modelrot/__init__.py +5 -0
- modelrot-0.1.0/modelrot/__main__.py +3 -0
- modelrot-0.1.0/modelrot/catalog.py +75 -0
- modelrot-0.1.0/modelrot/checks.py +170 -0
- modelrot-0.1.0/modelrot/cli.py +79 -0
- modelrot-0.1.0/modelrot/data/models.json +2110 -0
- modelrot-0.1.0/modelrot/data/sources.json +60 -0
- modelrot-0.1.0/modelrot/report.py +92 -0
- modelrot-0.1.0/modelrot/scan.py +180 -0
- modelrot-0.1.0/pyproject.toml +28 -0
- modelrot-0.1.0/scripts/refresh_catalog.py +343 -0
- modelrot-0.1.0/tests/test_modelrot.py +279 -0
|
@@ -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
|