claudeos-core 2.2.0 → 2.3.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +1649 -907
  2. package/CONTRIBUTING.md +92 -92
  3. package/README.de.md +32 -0
  4. package/README.es.md +32 -0
  5. package/README.fr.md +32 -0
  6. package/README.hi.md +32 -0
  7. package/README.ja.md +32 -0
  8. package/README.ko.md +1018 -986
  9. package/README.md +1020 -987
  10. package/README.ru.md +32 -0
  11. package/README.vi.md +1019 -987
  12. package/README.zh-CN.md +32 -0
  13. package/bin/cli.js +152 -148
  14. package/bin/commands/init.js +1673 -1554
  15. package/bin/commands/lint.js +62 -0
  16. package/bin/commands/memory.js +438 -438
  17. package/bin/lib/cli-utils.js +206 -206
  18. package/claude-md-validator/index.js +184 -0
  19. package/claude-md-validator/reporter.js +66 -0
  20. package/claude-md-validator/structural-checks.js +528 -0
  21. package/content-validator/index.js +666 -441
  22. package/lib/expected-guides.js +23 -23
  23. package/lib/expected-outputs.js +90 -90
  24. package/lib/language-config.js +35 -35
  25. package/lib/memory-scaffold.js +1058 -1054
  26. package/lib/plan-parser.js +165 -165
  27. package/lib/staged-rules.js +118 -118
  28. package/manifest-generator/index.js +174 -174
  29. package/package.json +90 -87
  30. package/pass-json-validator/index.js +337 -337
  31. package/pass-prompts/templates/common/claude-md-scaffold.md +52 -10
  32. package/pass-prompts/templates/common/pass3-footer.md +402 -224
  33. package/pass-prompts/templates/common/pass3b-core-header.md +43 -0
  34. package/pass-prompts/templates/common/pass4.md +375 -305
  35. package/pass-prompts/templates/common/staging-override.md +26 -26
  36. package/pass-prompts/templates/node-vite/pass1.md +117 -117
  37. package/pass-prompts/templates/node-vite/pass2.md +78 -78
  38. package/pass-prompts/templates/python-flask/pass1.md +119 -119
  39. package/pass-prompts/templates/python-flask/pass2.md +85 -85
  40. package/plan-installer/domain-grouper.js +76 -76
  41. package/plan-installer/index.js +137 -137
  42. package/plan-installer/prompt-generator.js +188 -145
  43. package/plan-installer/scanners/scan-frontend.js +505 -473
  44. package/plan-installer/scanners/scan-java.js +226 -226
  45. package/plan-installer/scanners/scan-node.js +57 -57
  46. package/plan-installer/scanners/scan-python.js +85 -85
  47. package/plan-installer/stack-detector.js +482 -482
  48. package/plan-installer/structure-scanner.js +65 -65
  49. package/sync-checker/index.js +177 -177
@@ -1,438 +1,438 @@
1
- /**
2
- * ClaudeOS-Core — Memory command (L4 memory)
3
- *
4
- * Subcommands:
5
- * memory compact — apply 4-stage compaction to decision-log.md / failure-patterns.md
6
- * memory score — recompute importance of failure-patterns.md entries
7
- * memory propose-rules — analyze failure patterns, append suggestions to auto-rule-update.md
8
- */
9
-
10
- const path = require("path");
11
- const fs = require("fs");
12
- const { PROJECT_ROOT, log } = require("../lib/cli-utils");
13
- const { readFileSafe, writeFileSafe, ensureDir } = require("../../lib/safe-fs");
14
-
15
- const MEMORY_DIR = path.join(PROJECT_ROOT, "claudeos-core/memory");
16
- const GEN_DIR = path.join(PROJECT_ROOT, "claudeos-core/generated");
17
-
18
- const FILE_SIZE_LINE_CAP = 400;
19
- const DAY_MS = 24 * 60 * 60 * 1000;
20
-
21
- /**
22
- * Parse a memory file into header + list of `## ...` entries.
23
- * Returns { header, entries: [{ heading, body, meta }] }
24
- * meta.lastSeen (Date|null), meta.frequency (number|null), meta.importance (number|null)
25
- */
26
- function parseEntries(raw) {
27
- const lines = raw.split("\n");
28
- const headerLines = [];
29
- let i = 0;
30
- // Track whether we're inside a fenced code block so `## ...` lines
31
- // that appear as example markdown inside ```...``` are treated as
32
- // body content, not as new entry headings.
33
- let inFence = false;
34
- const FENCE_RE = /^(```|~~~)/;
35
- for (; i < lines.length; i++) {
36
- if (FENCE_RE.test(lines[i])) inFence = !inFence;
37
- if (!inFence && /^##\s+/.test(lines[i])) break;
38
- headerLines.push(lines[i]);
39
- }
40
- const entries = [];
41
- let cur = null;
42
- for (; i < lines.length; i++) {
43
- if (FENCE_RE.test(lines[i])) inFence = !inFence;
44
- if (!inFence && /^##\s+/.test(lines[i])) {
45
- if (cur) entries.push(cur);
46
- cur = { heading: lines[i], body: [] };
47
- } else if (cur) {
48
- cur.body.push(lines[i]);
49
- }
50
- }
51
- if (cur) entries.push(cur);
52
-
53
- for (const e of entries) {
54
- const full = [e.heading, ...e.body].join("\n");
55
- e.meta = {
56
- lastSeen: parseDate(full),
57
- frequency: parseField(full, "frequency") ?? parseField(full, "count"),
58
- importance: parseField(full, "importance"),
59
- };
60
- }
61
- return { header: headerLines.join("\n"), entries };
62
- }
63
-
64
- function parseDate(text) {
65
- // Match a proper field line: `- last seen: 2026-04-17`, `- **last seen**: ...`,
66
- // `- date: ...`. Anchored to line start (`^...m`) and a leading hyphen so
67
- // a date mentioned inside a verbose `Fix:` line (e.g. "apply at 2026-04-17")
68
- // doesn't accidentally get picked up as the entry's lastSeen.
69
- const fieldRe = /^[\s*-]+\*{0,2}\s*(?:last\s*seen|date)\s*\*{0,2}\s*[:=]\s*(\d{4}-\d{2}-\d{2})/im;
70
- const m = text.match(fieldRe);
71
- if (m) return new Date(m[1]);
72
- // Fallback: decision-log style heading `## 2026-04-17 — ...`
73
- const h = text.match(/^##\s+(\d{4}-\d{2}-\d{2})/m);
74
- if (h) return new Date(h[1]);
75
- return null;
76
- }
77
-
78
- function parseField(text, key) {
79
- // Anchor to field-line format: `- <key>: N`, `- **<key>**: N`, with optional
80
- // leading whitespace/markdown. Requires start-of-line + a hyphen (or asterisk)
81
- // so pseudo-fields inside a Fix body (e.g. "set the frequency: 10 in config")
82
- // are not picked up as the entry's real frequency value.
83
- // The callers order auto-scored lines (e.g. `- **importance**: 10`) ahead
84
- // of any user-entered plain line, so first-match still favors auto-scored.
85
- const re = new RegExp(`^[\\s*-]+\\*{0,2}\\s*${key}\\*{0,2}\\s*[:=]\\s*([0-9]+)`, "im");
86
- const m = text.match(re);
87
- return m ? parseInt(m[1], 10) : null;
88
- }
89
-
90
- function serialize({ header, entries }) {
91
- // Reconcile meta values back into body lines before writing.
92
- // Stage 2 (merge duplicates) updates `e.meta.frequency` in memory but the
93
- // body still carries the original `- frequency: N` line from the first
94
- // occurrence. Without rewriting those lines, the merged frequency total
95
- // would be silently discarded on disk. Same for `last seen:`.
96
- const body = entries.map(e => {
97
- const updatedBody = e.body.map(line => {
98
- // Preserve indentation/prefix — match `[- **]frequency:[**] N` etc.
99
- if (/^\s*-\s+\*?\*?frequency\*?\*?:\s*/i.test(line)
100
- && e.meta.frequency !== null && e.meta.frequency !== undefined) {
101
- return line.replace(
102
- /^(\s*-\s+\*?\*?frequency\*?\*?:\s*).*/i,
103
- `$1${e.meta.frequency}`
104
- );
105
- }
106
- if (/^\s*-\s+\*?\*?last seen\*?\*?:\s*/i.test(line)
107
- && e.meta.lastSeen instanceof Date && !isNaN(e.meta.lastSeen)) {
108
- const iso = e.meta.lastSeen.toISOString().slice(0, 10);
109
- return line.replace(
110
- /^(\s*-\s+\*?\*?last seen\*?\*?:\s*).*/i,
111
- `$1${iso}`
112
- );
113
- }
114
- return line;
115
- });
116
- return [e.heading, ...updatedBody].join("\n");
117
- }).join("\n");
118
- return header + (body ? "\n" + body : "") + (body.endsWith("\n") ? "" : "\n");
119
- }
120
-
121
- function daysSince(date) {
122
- if (!date) return Infinity;
123
- return (Date.now() - date.getTime()) / DAY_MS;
124
- }
125
-
126
- function loadActiveRulePaths() {
127
- const manifestPath = path.join(GEN_DIR, "rule-manifest.json");
128
- if (!fs.existsSync(manifestPath)) return new Set();
129
- try {
130
- const mf = JSON.parse(readFileSafe(manifestPath, "{}"));
131
- const paths = new Set();
132
- for (const r of mf.rules || []) {
133
- if (Array.isArray(r.paths)) r.paths.forEach(p => paths.add(p));
134
- }
135
- return paths;
136
- } catch (_e) {
137
- return new Set();
138
- }
139
- }
140
-
141
- function isPreserved(entry, activeRulePaths) {
142
- if ((entry.meta.importance ?? 0) >= 7) return true;
143
- if (daysSince(entry.meta.lastSeen) < 30) return true;
144
- const full = [entry.heading, ...entry.body].join("\n");
145
- for (const p of activeRulePaths) {
146
- // Skip glob patterns (`**/*`, `src/**/*.java`, etc.) — they are
147
- // applicability scopes, not anchor references. A literal glob inside
148
- // entry body (e.g. a Fix line explaining a glob) would otherwise make
149
- // every matching low-importance entry permanently preserved. Only
150
- // concrete file paths count as anchors.
151
- if (!p || /[*?[\]]/.test(p)) continue;
152
- if (full.includes(p)) return true;
153
- }
154
- return false;
155
- }
156
-
157
- function compactFile(filePath, activeRulePaths) {
158
- if (!fs.existsSync(filePath)) return { changed: false, reason: "missing" };
159
- const raw = readFileSafe(filePath);
160
- const parsed = parseEntries(raw);
161
- const before = parsed.entries.length;
162
-
163
- // Stage 1: Summarize aged entries (>30 days, not preserved)
164
- // Safety: skip entries with no parseable lastSeen date — don't assume "aged" from absence
165
- // Preserve metadata lines (frequency/last seen/importance) so later stages
166
- // (Stage 3 drop, next compact run) can still parse them. Only drop the
167
- // verbose prose lines.
168
- //
169
- // Fix line matching is anchored to the field format `- Fix: ...` or
170
- // `- **fix**: ...` (optionally also `solution:`) so that verbose prose
171
- // containing words like "solve" or "fixing" does not get picked up as
172
- // the fix line by accident.
173
- const FIX_LINE_RE = /^\s*-\s*\*{0,2}\s*(fix|solution)\s*\*{0,2}\s*[:=]/i;
174
- for (const e of parsed.entries) {
175
- if (!e.meta.lastSeen) continue;
176
- if (!isPreserved(e, activeRulePaths) && daysSince(e.meta.lastSeen) > 30) {
177
- const metaLines = e.body.filter(l =>
178
- /^\s*-\s*\*{0,2}\s*(frequency|last\s*seen|importance)\s*\*{0,2}\s*[:=]/i.test(l)
179
- );
180
- const fixLine = e.body.find(l => FIX_LINE_RE.test(l)) || "- (fix omitted)";
181
- // Summary marker formatted as a proper markdown list item so that:
182
- // 1. parseEntries can re-read it as a body line in future compactions
183
- // 2. GitHub/IDE markdown renderers format it consistently with the
184
- // surrounding list (previously an inline italic string broke the
185
- // list flow visually).
186
- const summaryLine = `- _Summarized on ${new Date().toISOString().slice(0, 10)} — original body dropped._`;
187
- e.body = [
188
- ...metaLines,
189
- summaryLine,
190
- fixLine,
191
- ];
192
- }
193
- }
194
-
195
- // Stage 2: Merge duplicates (by heading)
196
- const map = new Map();
197
- for (const e of parsed.entries) {
198
- const key = e.heading.trim();
199
- if (map.has(key)) {
200
- const prev = map.get(key);
201
- const prevFreq = prev.meta.frequency || 1;
202
- const curFreq = e.meta.frequency || 1;
203
- prev.meta.frequency = prevFreq + curFreq;
204
- if ((e.meta.lastSeen?.getTime() || 0) > (prev.meta.lastSeen?.getTime() || 0)) {
205
- prev.meta.lastSeen = e.meta.lastSeen;
206
- prev.body = e.body;
207
- }
208
- } else {
209
- map.set(key, e);
210
- }
211
- }
212
- parsed.entries = Array.from(map.values());
213
-
214
- // Stage 3: Drop low-importance (<3) older than 60 days
215
- // Safety: require parseable lastSeen AND explicit low importance — don't drop undated entries.
216
- // Also respect isPreserved() — an entry anchored by an active rule (concrete
217
- // file path match) must not be silently dropped even if it scored low and
218
- // is old. That anchor is the whole point of the rule-memory connection.
219
- parsed.entries = parsed.entries.filter(e => {
220
- if (!e.meta.lastSeen) return true;
221
- if (isPreserved(e, activeRulePaths)) return true;
222
- if ((e.meta.importance ?? 5) < 3 && daysSince(e.meta.lastSeen) > 60) return false;
223
- return true;
224
- });
225
-
226
- // Stage 4: Enforce line cap
227
- let totalLines = parsed.header.split("\n").length + parsed.entries.reduce((a, e) => a + 1 + e.body.length, 0);
228
- if (totalLines > FILE_SIZE_LINE_CAP) {
229
- parsed.entries.sort((a, b) => {
230
- const ap = isPreserved(a, activeRulePaths) ? 1 : 0;
231
- const bp = isPreserved(b, activeRulePaths) ? 1 : 0;
232
- if (ap !== bp) return bp - ap;
233
- return (b.meta.lastSeen?.getTime() || 0) - (a.meta.lastSeen?.getTime() || 0);
234
- });
235
- while (totalLines > FILE_SIZE_LINE_CAP && parsed.entries.length > 0) {
236
- const dropped = parsed.entries.pop();
237
- totalLines -= (1 + dropped.body.length);
238
- }
239
- }
240
-
241
- const after = parsed.entries.length;
242
- writeFileSafe(filePath, serialize(parsed));
243
- return { changed: true, before, after };
244
- }
245
-
246
- function cmdCompact() {
247
- ensureDir(MEMORY_DIR);
248
- const activeRulePaths = loadActiveRulePaths();
249
-
250
- const files = ["decision-log.md", "failure-patterns.md"];
251
- const summaries = [];
252
- for (const f of files) {
253
- const r = compactFile(path.join(MEMORY_DIR, f), activeRulePaths);
254
- summaries.push({ file: f, ...r });
255
- if (r.changed) log(` ✅ ${f}: ${r.before} → ${r.after} entries`);
256
- else log(` ⏭️ ${f}: ${r.reason}`);
257
- }
258
-
259
- // Update compaction.md "Last Compaction" section.
260
- // Replace ONLY the "## Last Compaction" section (up to next `##` heading or EOF).
261
- // Preserves any user-added content that follows.
262
- const compPath = path.join(MEMORY_DIR, "compaction.md");
263
- if (fs.existsSync(compPath)) {
264
- const raw = readFileSafe(compPath);
265
- const marker = "## Last Compaction";
266
- const body = `${marker}\nRan at ${new Date().toISOString()}\n${summaries.map(s => `- ${s.file}: ${s.before} → ${s.after}`).join("\n")}\n`;
267
- const idx = raw.indexOf(marker);
268
- let updated;
269
- if (idx >= 0) {
270
- // Find next `##` heading after marker (or EOF)
271
- const afterMarker = raw.slice(idx + marker.length);
272
- const nextHeadingRel = afterMarker.search(/\n## /);
273
- const sectionEnd = nextHeadingRel >= 0 ? idx + marker.length + nextHeadingRel + 1 : raw.length;
274
- updated = raw.slice(0, idx) + body + raw.slice(sectionEnd);
275
- } else {
276
- updated = raw + (raw.endsWith("\n") ? "" : "\n") + body;
277
- }
278
- writeFileSafe(compPath, updated);
279
- }
280
- }
281
-
282
- function cmdScore() {
283
- const file = path.join(MEMORY_DIR, "failure-patterns.md");
284
- if (!fs.existsSync(file)) {
285
- log(" ⏭️ failure-patterns.md not found");
286
- return;
287
- }
288
- const raw = readFileSafe(file);
289
- const parsed = parseEntries(raw);
290
- let scored = 0;
291
- for (const e of parsed.entries) {
292
- const freq = e.meta.frequency || 1;
293
- const ageDays = daysSince(e.meta.lastSeen);
294
- const recency = ageDays === Infinity ? 0 : Math.max(0, 1 - ageDays / 90);
295
- const importance = Math.min(10, Math.round((freq * 1.5) + (recency * 5)));
296
- const line = `- **importance**: ${importance} _(auto-scored ${new Date().toISOString().slice(0, 10)}, freq=${freq}, recency=${recency.toFixed(2)})_`;
297
-
298
- // Remove ALL existing importance lines (both the bold auto-scored variant
299
- // and the plain `- importance: N` variant). Without this, the first score
300
- // run leaves two importance lines — the auto-scored one at the top and
301
- // the original user-written one below it — which is confusing and makes
302
- // the file look like it has conflicting values.
303
- const IMPORTANCE_LINE_RE = /^\s*-\s*\*{0,2}\s*importance\s*\*{0,2}\s*[:=]/i;
304
- e.body = e.body.filter(l => !IMPORTANCE_LINE_RE.test(l));
305
- e.body.unshift(line);
306
- e.meta.importance = importance;
307
- scored++;
308
- }
309
- writeFileSafe(file, serialize(parsed));
310
- log(` ✅ Scored ${scored} entries in failure-patterns.md`);
311
- }
312
-
313
- /**
314
- * Compute a confidence score for an auto-rule-update proposal.
315
- *
316
- * Replaces the v1 formula `min(1, freq/10 + imp/20)` which saturated too fast
317
- * (freq=10 → 1.0 regardless of recency/importance). New formula uses a
318
- * sigmoid on weighted evidence + an anchor-match multiplier.
319
- *
320
- * Inputs:
321
- * freq — number of times the pattern was seen (>= 3 at this point)
322
- * imp — importance score 1..10 (null → treated as below-average)
323
- * anchored — true if an active rule path actually matches this pattern's body
324
- *
325
- * Design:
326
- * evidence = 1.5 * freq + 0.5 * imp (freq weighs 3× importance)
327
- * sigmoid(x) = 1 / (1 + exp(-k * (x - x0))) (x0=8 center, k=0.35 slope)
328
- * unanchored × 0.6 (we're less confident which rule is affected)
329
- * low evidence cap: if imp is null/undefined, cap evidence at 6 so the
330
- * sigmoid cannot exceed ~0.36 — importance must be set for a strong score.
331
- *
332
- * Rough calibration:
333
- * freq=3, imp=5, anchored → ~0.35
334
- * freq=5, imp=7, anchored → ~0.66
335
- * freq=10, imp=8, anchored → ~0.91
336
- * freq=10, imp=8, unanchored → ~0.55
337
- * freq=3, imp=null, anchored → ~0.26
338
- */
339
- function computeConfidence(freq, imp, anchored) {
340
- const f = Math.max(0, freq || 0);
341
- const hasImp = imp !== null && imp !== undefined;
342
- const i = hasImp ? imp : 0;
343
- let evidence = 1.5 * f + 0.5 * i;
344
- // Penalise missing importance: cap evidence so sigmoid can't exceed ~0.36
345
- if (!hasImp) evidence = Math.min(evidence, 6);
346
- const k = 0.35;
347
- const x0 = 8;
348
- const sig = 1 / (1 + Math.exp(-k * (evidence - x0)));
349
- const multiplier = anchored ? 1.0 : 0.6;
350
- return Math.max(0, Math.min(1, sig * multiplier));
351
- }
352
-
353
- function cmdProposeRules() {
354
- const fpPath = path.join(MEMORY_DIR, "failure-patterns.md");
355
- const proposalPath = path.join(MEMORY_DIR, "auto-rule-update.md");
356
- if (!fs.existsSync(fpPath)) {
357
- log(" ⏭️ failure-patterns.md not found");
358
- return;
359
- }
360
- const parsed = parseEntries(readFileSafe(fpPath));
361
- const candidates = parsed.entries.filter(e => (e.meta.frequency || 0) >= 3);
362
- if (candidates.length === 0) {
363
- log(" ℹ️ No failure patterns with frequency >= 3 — nothing to propose");
364
- return;
365
- }
366
-
367
- const activeRulePaths = loadActiveRulePaths();
368
- const rulesArr = Array.from(activeRulePaths);
369
- const META_LINE_RE = /^\s*-\s*\*{0,2}\s*(?:frequency|count|last\s*seen|importance)\*{0,2}\s*[:=]/i;
370
- const proposals = [];
371
- for (const e of candidates) {
372
- const id = e.heading.replace(/^##\s+/, "").trim();
373
- const body = e.body.join("\n");
374
- // Same glob-skip logic as isPreserved(): a rule's `paths: ["**/*"]`
375
- // is a scope, not a concrete anchor. Only count matches against
376
- // literal file paths so we don't falsely anchor a pattern just because
377
- // its body mentions a glob string.
378
- const matchedRule = rulesArr.find(p => p && !/[*?[\]]/.test(p) && body.includes(p));
379
- const anchored = !!matchedRule;
380
- const affectedRule = matchedRule || "(no matching active rule — consider new rule)";
381
- const confidence = computeConfidence(e.meta.frequency, e.meta.importance, anchored);
382
- // Build a meaningful summary by skipping metadata and blank lines,
383
- // taking up to 3 content-bearing lines. Metadata-only summaries
384
- // (which happened when an entry's first 3 lines were frequency/last seen/importance)
385
- // are useless for the reviewer.
386
- const summary = e.body
387
- .filter(l => l.trim() && !META_LINE_RE.test(l))
388
- .slice(0, 3)
389
- .join(" ")
390
- .trim() || "(no body content)";
391
- proposals.push({
392
- id,
393
- frequency: e.meta.frequency,
394
- importance: e.meta.importance,
395
- anchored,
396
- affectedRule,
397
- confidence: confidence.toFixed(2),
398
- summary,
399
- });
400
- }
401
-
402
- const existing = readFileSafe(proposalPath, "# Auto Rule Update Proposals\n");
403
- const block = `\n## Generated at ${new Date().toISOString()}\n\n${proposals.map(p => `### ${p.id}\n- **Affected rule:** \`${p.affectedRule}\`${p.anchored ? "" : " _(unanchored — confidence reduced)_"}\n- **Frequency:** ${p.frequency ?? "?"} · **Importance:** ${p.importance ?? "?"} · **Confidence:** ${p.confidence}\n- **Summary:** ${p.summary}\n- **Proposed change:** <review failure-patterns.md "${p.id}" and edit the affected rule accordingly>\n`).join("\n")}\n`;
404
-
405
- writeFileSafe(proposalPath, existing + block);
406
- log(` ✅ Appended ${proposals.length} proposal(s) to auto-rule-update.md`);
407
- }
408
-
409
- function showHelp() {
410
- log(`
411
- Usage: npx claudeos-core memory <subcommand>
412
-
413
- Subcommands:
414
- compact Apply 4-stage compaction to decision-log.md and failure-patterns.md
415
- score Recompute importance of failure-patterns.md entries (frequency × recency)
416
- propose-rules Analyze failure patterns, append rule update suggestions to auto-rule-update.md
417
- `);
418
- }
419
-
420
- async function cmdMemory(parsedArgs) {
421
- const sub = process.argv.slice(3)[0];
422
- switch (sub) {
423
- case "compact": return cmdCompact();
424
- case "score": return cmdScore();
425
- case "propose-rules": return cmdProposeRules();
426
- case undefined:
427
- case "--help":
428
- case "-h":
429
- showHelp();
430
- return;
431
- default:
432
- log(`Unknown memory subcommand: ${sub}`);
433
- showHelp();
434
- process.exit(1);
435
- }
436
- }
437
-
438
- module.exports = { cmdMemory, computeConfidence };
1
+ /**
2
+ * ClaudeOS-Core — Memory command (L4 memory)
3
+ *
4
+ * Subcommands:
5
+ * memory compact — apply 4-stage compaction to decision-log.md / failure-patterns.md
6
+ * memory score — recompute importance of failure-patterns.md entries
7
+ * memory propose-rules — analyze failure patterns, append suggestions to auto-rule-update.md
8
+ */
9
+
10
+ const path = require("path");
11
+ const fs = require("fs");
12
+ const { PROJECT_ROOT, log } = require("../lib/cli-utils");
13
+ const { readFileSafe, writeFileSafe, ensureDir } = require("../../lib/safe-fs");
14
+
15
+ const MEMORY_DIR = path.join(PROJECT_ROOT, "claudeos-core/memory");
16
+ const GEN_DIR = path.join(PROJECT_ROOT, "claudeos-core/generated");
17
+
18
+ const FILE_SIZE_LINE_CAP = 400;
19
+ const DAY_MS = 24 * 60 * 60 * 1000;
20
+
21
+ /**
22
+ * Parse a memory file into header + list of `## ...` entries.
23
+ * Returns { header, entries: [{ heading, body, meta }] }
24
+ * meta.lastSeen (Date|null), meta.frequency (number|null), meta.importance (number|null)
25
+ */
26
+ function parseEntries(raw) {
27
+ const lines = raw.split("\n");
28
+ const headerLines = [];
29
+ let i = 0;
30
+ // Track whether we're inside a fenced code block so `## ...` lines
31
+ // that appear as example markdown inside ```...``` are treated as
32
+ // body content, not as new entry headings.
33
+ let inFence = false;
34
+ const FENCE_RE = /^(```|~~~)/;
35
+ for (; i < lines.length; i++) {
36
+ if (FENCE_RE.test(lines[i])) inFence = !inFence;
37
+ if (!inFence && /^##\s+/.test(lines[i])) break;
38
+ headerLines.push(lines[i]);
39
+ }
40
+ const entries = [];
41
+ let cur = null;
42
+ for (; i < lines.length; i++) {
43
+ if (FENCE_RE.test(lines[i])) inFence = !inFence;
44
+ if (!inFence && /^##\s+/.test(lines[i])) {
45
+ if (cur) entries.push(cur);
46
+ cur = { heading: lines[i], body: [] };
47
+ } else if (cur) {
48
+ cur.body.push(lines[i]);
49
+ }
50
+ }
51
+ if (cur) entries.push(cur);
52
+
53
+ for (const e of entries) {
54
+ const full = [e.heading, ...e.body].join("\n");
55
+ e.meta = {
56
+ lastSeen: parseDate(full),
57
+ frequency: parseField(full, "frequency") ?? parseField(full, "count"),
58
+ importance: parseField(full, "importance"),
59
+ };
60
+ }
61
+ return { header: headerLines.join("\n"), entries };
62
+ }
63
+
64
+ function parseDate(text) {
65
+ // Match a proper field line: `- last seen: 2026-04-17`, `- **last seen**: ...`,
66
+ // `- date: ...`. Anchored to line start (`^...m`) and a leading hyphen so
67
+ // a date mentioned inside a verbose `Fix:` line (e.g. "apply at 2026-04-17")
68
+ // doesn't accidentally get picked up as the entry's lastSeen.
69
+ const fieldRe = /^[\s*-]+\*{0,2}\s*(?:last\s*seen|date)\s*\*{0,2}\s*[:=]\s*(\d{4}-\d{2}-\d{2})/im;
70
+ const m = text.match(fieldRe);
71
+ if (m) return new Date(m[1]);
72
+ // Fallback: decision-log style heading `## 2026-04-17 — ...`
73
+ const h = text.match(/^##\s+(\d{4}-\d{2}-\d{2})/m);
74
+ if (h) return new Date(h[1]);
75
+ return null;
76
+ }
77
+
78
+ function parseField(text, key) {
79
+ // Anchor to field-line format: `- <key>: N`, `- **<key>**: N`, with optional
80
+ // leading whitespace/markdown. Requires start-of-line + a hyphen (or asterisk)
81
+ // so pseudo-fields inside a Fix body (e.g. "set the frequency: 10 in config")
82
+ // are not picked up as the entry's real frequency value.
83
+ // The callers order auto-scored lines (e.g. `- **importance**: 10`) ahead
84
+ // of any user-entered plain line, so first-match still favors auto-scored.
85
+ const re = new RegExp(`^[\\s*-]+\\*{0,2}\\s*${key}\\*{0,2}\\s*[:=]\\s*([0-9]+)`, "im");
86
+ const m = text.match(re);
87
+ return m ? parseInt(m[1], 10) : null;
88
+ }
89
+
90
+ function serialize({ header, entries }) {
91
+ // Reconcile meta values back into body lines before writing.
92
+ // Stage 2 (merge duplicates) updates `e.meta.frequency` in memory but the
93
+ // body still carries the original `- frequency: N` line from the first
94
+ // occurrence. Without rewriting those lines, the merged frequency total
95
+ // would be silently discarded on disk. Same for `last seen:`.
96
+ const body = entries.map(e => {
97
+ const updatedBody = e.body.map(line => {
98
+ // Preserve indentation/prefix — match `[- **]frequency:[**] N` etc.
99
+ if (/^\s*-\s+\*?\*?frequency\*?\*?:\s*/i.test(line)
100
+ && e.meta.frequency !== null && e.meta.frequency !== undefined) {
101
+ return line.replace(
102
+ /^(\s*-\s+\*?\*?frequency\*?\*?:\s*).*/i,
103
+ `$1${e.meta.frequency}`
104
+ );
105
+ }
106
+ if (/^\s*-\s+\*?\*?last seen\*?\*?:\s*/i.test(line)
107
+ && e.meta.lastSeen instanceof Date && !isNaN(e.meta.lastSeen)) {
108
+ const iso = e.meta.lastSeen.toISOString().slice(0, 10);
109
+ return line.replace(
110
+ /^(\s*-\s+\*?\*?last seen\*?\*?:\s*).*/i,
111
+ `$1${iso}`
112
+ );
113
+ }
114
+ return line;
115
+ });
116
+ return [e.heading, ...updatedBody].join("\n");
117
+ }).join("\n");
118
+ return header + (body ? "\n" + body : "") + (body.endsWith("\n") ? "" : "\n");
119
+ }
120
+
121
+ function daysSince(date) {
122
+ if (!date) return Infinity;
123
+ return (Date.now() - date.getTime()) / DAY_MS;
124
+ }
125
+
126
+ function loadActiveRulePaths() {
127
+ const manifestPath = path.join(GEN_DIR, "rule-manifest.json");
128
+ if (!fs.existsSync(manifestPath)) return new Set();
129
+ try {
130
+ const mf = JSON.parse(readFileSafe(manifestPath, "{}"));
131
+ const paths = new Set();
132
+ for (const r of mf.rules || []) {
133
+ if (Array.isArray(r.paths)) r.paths.forEach(p => paths.add(p));
134
+ }
135
+ return paths;
136
+ } catch (_e) {
137
+ return new Set();
138
+ }
139
+ }
140
+
141
+ function isPreserved(entry, activeRulePaths) {
142
+ if ((entry.meta.importance ?? 0) >= 7) return true;
143
+ if (daysSince(entry.meta.lastSeen) < 30) return true;
144
+ const full = [entry.heading, ...entry.body].join("\n");
145
+ for (const p of activeRulePaths) {
146
+ // Skip glob patterns (`**/*`, `src/**/*.java`, etc.) — they are
147
+ // applicability scopes, not anchor references. A literal glob inside
148
+ // entry body (e.g. a Fix line explaining a glob) would otherwise make
149
+ // every matching low-importance entry permanently preserved. Only
150
+ // concrete file paths count as anchors.
151
+ if (!p || /[*?[\]]/.test(p)) continue;
152
+ if (full.includes(p)) return true;
153
+ }
154
+ return false;
155
+ }
156
+
157
+ function compactFile(filePath, activeRulePaths) {
158
+ if (!fs.existsSync(filePath)) return { changed: false, reason: "missing" };
159
+ const raw = readFileSafe(filePath);
160
+ const parsed = parseEntries(raw);
161
+ const before = parsed.entries.length;
162
+
163
+ // Stage 1: Summarize aged entries (>30 days, not preserved)
164
+ // Safety: skip entries with no parseable lastSeen date — don't assume "aged" from absence
165
+ // Preserve metadata lines (frequency/last seen/importance) so later stages
166
+ // (Stage 3 drop, next compact run) can still parse them. Only drop the
167
+ // verbose prose lines.
168
+ //
169
+ // Fix line matching is anchored to the field format `- Fix: ...` or
170
+ // `- **fix**: ...` (optionally also `solution:`) so that verbose prose
171
+ // containing words like "solve" or "fixing" does not get picked up as
172
+ // the fix line by accident.
173
+ const FIX_LINE_RE = /^\s*-\s*\*{0,2}\s*(fix|solution)\s*\*{0,2}\s*[:=]/i;
174
+ for (const e of parsed.entries) {
175
+ if (!e.meta.lastSeen) continue;
176
+ if (!isPreserved(e, activeRulePaths) && daysSince(e.meta.lastSeen) > 30) {
177
+ const metaLines = e.body.filter(l =>
178
+ /^\s*-\s*\*{0,2}\s*(frequency|last\s*seen|importance)\s*\*{0,2}\s*[:=]/i.test(l)
179
+ );
180
+ const fixLine = e.body.find(l => FIX_LINE_RE.test(l)) || "- (fix omitted)";
181
+ // Summary marker formatted as a proper markdown list item so that:
182
+ // 1. parseEntries can re-read it as a body line in future compactions
183
+ // 2. GitHub/IDE markdown renderers format it consistently with the
184
+ // surrounding list (previously an inline italic string broke the
185
+ // list flow visually).
186
+ const summaryLine = `- _Summarized on ${new Date().toISOString().slice(0, 10)} — original body dropped._`;
187
+ e.body = [
188
+ ...metaLines,
189
+ summaryLine,
190
+ fixLine,
191
+ ];
192
+ }
193
+ }
194
+
195
+ // Stage 2: Merge duplicates (by heading)
196
+ const map = new Map();
197
+ for (const e of parsed.entries) {
198
+ const key = e.heading.trim();
199
+ if (map.has(key)) {
200
+ const prev = map.get(key);
201
+ const prevFreq = prev.meta.frequency || 1;
202
+ const curFreq = e.meta.frequency || 1;
203
+ prev.meta.frequency = prevFreq + curFreq;
204
+ if ((e.meta.lastSeen?.getTime() || 0) > (prev.meta.lastSeen?.getTime() || 0)) {
205
+ prev.meta.lastSeen = e.meta.lastSeen;
206
+ prev.body = e.body;
207
+ }
208
+ } else {
209
+ map.set(key, e);
210
+ }
211
+ }
212
+ parsed.entries = Array.from(map.values());
213
+
214
+ // Stage 3: Drop low-importance (<3) older than 60 days
215
+ // Safety: require parseable lastSeen AND explicit low importance — don't drop undated entries.
216
+ // Also respect isPreserved() — an entry anchored by an active rule (concrete
217
+ // file path match) must not be silently dropped even if it scored low and
218
+ // is old. That anchor is the whole point of the rule-memory connection.
219
+ parsed.entries = parsed.entries.filter(e => {
220
+ if (!e.meta.lastSeen) return true;
221
+ if (isPreserved(e, activeRulePaths)) return true;
222
+ if ((e.meta.importance ?? 5) < 3 && daysSince(e.meta.lastSeen) > 60) return false;
223
+ return true;
224
+ });
225
+
226
+ // Stage 4: Enforce line cap
227
+ let totalLines = parsed.header.split("\n").length + parsed.entries.reduce((a, e) => a + 1 + e.body.length, 0);
228
+ if (totalLines > FILE_SIZE_LINE_CAP) {
229
+ parsed.entries.sort((a, b) => {
230
+ const ap = isPreserved(a, activeRulePaths) ? 1 : 0;
231
+ const bp = isPreserved(b, activeRulePaths) ? 1 : 0;
232
+ if (ap !== bp) return bp - ap;
233
+ return (b.meta.lastSeen?.getTime() || 0) - (a.meta.lastSeen?.getTime() || 0);
234
+ });
235
+ while (totalLines > FILE_SIZE_LINE_CAP && parsed.entries.length > 0) {
236
+ const dropped = parsed.entries.pop();
237
+ totalLines -= (1 + dropped.body.length);
238
+ }
239
+ }
240
+
241
+ const after = parsed.entries.length;
242
+ writeFileSafe(filePath, serialize(parsed));
243
+ return { changed: true, before, after };
244
+ }
245
+
246
+ function cmdCompact() {
247
+ ensureDir(MEMORY_DIR);
248
+ const activeRulePaths = loadActiveRulePaths();
249
+
250
+ const files = ["decision-log.md", "failure-patterns.md"];
251
+ const summaries = [];
252
+ for (const f of files) {
253
+ const r = compactFile(path.join(MEMORY_DIR, f), activeRulePaths);
254
+ summaries.push({ file: f, ...r });
255
+ if (r.changed) log(` ✅ ${f}: ${r.before} → ${r.after} entries`);
256
+ else log(` ⏭️ ${f}: ${r.reason}`);
257
+ }
258
+
259
+ // Update compaction.md "Last Compaction" section.
260
+ // Replace ONLY the "## Last Compaction" section (up to next `##` heading or EOF).
261
+ // Preserves any user-added content that follows.
262
+ const compPath = path.join(MEMORY_DIR, "compaction.md");
263
+ if (fs.existsSync(compPath)) {
264
+ const raw = readFileSafe(compPath);
265
+ const marker = "## Last Compaction";
266
+ const body = `${marker}\nRan at ${new Date().toISOString()}\n${summaries.map(s => `- ${s.file}: ${s.before} → ${s.after}`).join("\n")}\n`;
267
+ const idx = raw.indexOf(marker);
268
+ let updated;
269
+ if (idx >= 0) {
270
+ // Find next `##` heading after marker (or EOF)
271
+ const afterMarker = raw.slice(idx + marker.length);
272
+ const nextHeadingRel = afterMarker.search(/\n## /);
273
+ const sectionEnd = nextHeadingRel >= 0 ? idx + marker.length + nextHeadingRel + 1 : raw.length;
274
+ updated = raw.slice(0, idx) + body + raw.slice(sectionEnd);
275
+ } else {
276
+ updated = raw + (raw.endsWith("\n") ? "" : "\n") + body;
277
+ }
278
+ writeFileSafe(compPath, updated);
279
+ }
280
+ }
281
+
282
+ function cmdScore() {
283
+ const file = path.join(MEMORY_DIR, "failure-patterns.md");
284
+ if (!fs.existsSync(file)) {
285
+ log(" ⏭️ failure-patterns.md not found");
286
+ return;
287
+ }
288
+ const raw = readFileSafe(file);
289
+ const parsed = parseEntries(raw);
290
+ let scored = 0;
291
+ for (const e of parsed.entries) {
292
+ const freq = e.meta.frequency || 1;
293
+ const ageDays = daysSince(e.meta.lastSeen);
294
+ const recency = ageDays === Infinity ? 0 : Math.max(0, 1 - ageDays / 90);
295
+ const importance = Math.min(10, Math.round((freq * 1.5) + (recency * 5)));
296
+ const line = `- **importance**: ${importance} _(auto-scored ${new Date().toISOString().slice(0, 10)}, freq=${freq}, recency=${recency.toFixed(2)})_`;
297
+
298
+ // Remove ALL existing importance lines (both the bold auto-scored variant
299
+ // and the plain `- importance: N` variant). Without this, the first score
300
+ // run leaves two importance lines — the auto-scored one at the top and
301
+ // the original user-written one below it — which is confusing and makes
302
+ // the file look like it has conflicting values.
303
+ const IMPORTANCE_LINE_RE = /^\s*-\s*\*{0,2}\s*importance\s*\*{0,2}\s*[:=]/i;
304
+ e.body = e.body.filter(l => !IMPORTANCE_LINE_RE.test(l));
305
+ e.body.unshift(line);
306
+ e.meta.importance = importance;
307
+ scored++;
308
+ }
309
+ writeFileSafe(file, serialize(parsed));
310
+ log(` ✅ Scored ${scored} entries in failure-patterns.md`);
311
+ }
312
+
313
+ /**
314
+ * Compute a confidence score for an auto-rule-update proposal.
315
+ *
316
+ * Replaces the v1 formula `min(1, freq/10 + imp/20)` which saturated too fast
317
+ * (freq=10 → 1.0 regardless of recency/importance). New formula uses a
318
+ * sigmoid on weighted evidence + an anchor-match multiplier.
319
+ *
320
+ * Inputs:
321
+ * freq — number of times the pattern was seen (>= 3 at this point)
322
+ * imp — importance score 1..10 (null → treated as below-average)
323
+ * anchored — true if an active rule path actually matches this pattern's body
324
+ *
325
+ * Design:
326
+ * evidence = 1.5 * freq + 0.5 * imp (freq weighs 3× importance)
327
+ * sigmoid(x) = 1 / (1 + exp(-k * (x - x0))) (x0=8 center, k=0.35 slope)
328
+ * unanchored × 0.6 (we're less confident which rule is affected)
329
+ * low evidence cap: if imp is null/undefined, cap evidence at 6 so the
330
+ * sigmoid cannot exceed ~0.36 — importance must be set for a strong score.
331
+ *
332
+ * Rough calibration:
333
+ * freq=3, imp=5, anchored → ~0.35
334
+ * freq=5, imp=7, anchored → ~0.66
335
+ * freq=10, imp=8, anchored → ~0.91
336
+ * freq=10, imp=8, unanchored → ~0.55
337
+ * freq=3, imp=null, anchored → ~0.26
338
+ */
339
+ function computeConfidence(freq, imp, anchored) {
340
+ const f = Math.max(0, freq || 0);
341
+ const hasImp = imp !== null && imp !== undefined;
342
+ const i = hasImp ? imp : 0;
343
+ let evidence = 1.5 * f + 0.5 * i;
344
+ // Penalise missing importance: cap evidence so sigmoid can't exceed ~0.36
345
+ if (!hasImp) evidence = Math.min(evidence, 6);
346
+ const k = 0.35;
347
+ const x0 = 8;
348
+ const sig = 1 / (1 + Math.exp(-k * (evidence - x0)));
349
+ const multiplier = anchored ? 1.0 : 0.6;
350
+ return Math.max(0, Math.min(1, sig * multiplier));
351
+ }
352
+
353
+ function cmdProposeRules() {
354
+ const fpPath = path.join(MEMORY_DIR, "failure-patterns.md");
355
+ const proposalPath = path.join(MEMORY_DIR, "auto-rule-update.md");
356
+ if (!fs.existsSync(fpPath)) {
357
+ log(" ⏭️ failure-patterns.md not found");
358
+ return;
359
+ }
360
+ const parsed = parseEntries(readFileSafe(fpPath));
361
+ const candidates = parsed.entries.filter(e => (e.meta.frequency || 0) >= 3);
362
+ if (candidates.length === 0) {
363
+ log(" ℹ️ No failure patterns with frequency >= 3 — nothing to propose");
364
+ return;
365
+ }
366
+
367
+ const activeRulePaths = loadActiveRulePaths();
368
+ const rulesArr = Array.from(activeRulePaths);
369
+ const META_LINE_RE = /^\s*-\s*\*{0,2}\s*(?:frequency|count|last\s*seen|importance)\*{0,2}\s*[:=]/i;
370
+ const proposals = [];
371
+ for (const e of candidates) {
372
+ const id = e.heading.replace(/^##\s+/, "").trim();
373
+ const body = e.body.join("\n");
374
+ // Same glob-skip logic as isPreserved(): a rule's `paths: ["**/*"]`
375
+ // is a scope, not a concrete anchor. Only count matches against
376
+ // literal file paths so we don't falsely anchor a pattern just because
377
+ // its body mentions a glob string.
378
+ const matchedRule = rulesArr.find(p => p && !/[*?[\]]/.test(p) && body.includes(p));
379
+ const anchored = !!matchedRule;
380
+ const affectedRule = matchedRule || "(no matching active rule — consider new rule)";
381
+ const confidence = computeConfidence(e.meta.frequency, e.meta.importance, anchored);
382
+ // Build a meaningful summary by skipping metadata and blank lines,
383
+ // taking up to 3 content-bearing lines. Metadata-only summaries
384
+ // (which happened when an entry's first 3 lines were frequency/last seen/importance)
385
+ // are useless for the reviewer.
386
+ const summary = e.body
387
+ .filter(l => l.trim() && !META_LINE_RE.test(l))
388
+ .slice(0, 3)
389
+ .join(" ")
390
+ .trim() || "(no body content)";
391
+ proposals.push({
392
+ id,
393
+ frequency: e.meta.frequency,
394
+ importance: e.meta.importance,
395
+ anchored,
396
+ affectedRule,
397
+ confidence: confidence.toFixed(2),
398
+ summary,
399
+ });
400
+ }
401
+
402
+ const existing = readFileSafe(proposalPath, "# Auto Rule Update Proposals\n");
403
+ const block = `\n## Generated at ${new Date().toISOString()}\n\n${proposals.map(p => `### ${p.id}\n- **Affected rule:** \`${p.affectedRule}\`${p.anchored ? "" : " _(unanchored — confidence reduced)_"}\n- **Frequency:** ${p.frequency ?? "?"} · **Importance:** ${p.importance ?? "?"} · **Confidence:** ${p.confidence}\n- **Summary:** ${p.summary}\n- **Proposed change:** <review failure-patterns.md "${p.id}" and edit the affected rule accordingly>\n`).join("\n")}\n`;
404
+
405
+ writeFileSafe(proposalPath, existing + block);
406
+ log(` ✅ Appended ${proposals.length} proposal(s) to auto-rule-update.md`);
407
+ }
408
+
409
+ function showHelp() {
410
+ log(`
411
+ Usage: npx claudeos-core memory <subcommand>
412
+
413
+ Subcommands:
414
+ compact Apply 4-stage compaction to decision-log.md and failure-patterns.md
415
+ score Recompute importance of failure-patterns.md entries (frequency × recency)
416
+ propose-rules Analyze failure patterns, append rule update suggestions to auto-rule-update.md
417
+ `);
418
+ }
419
+
420
+ async function cmdMemory(parsedArgs) {
421
+ const sub = process.argv.slice(3)[0];
422
+ switch (sub) {
423
+ case "compact": return cmdCompact();
424
+ case "score": return cmdScore();
425
+ case "propose-rules": return cmdProposeRules();
426
+ case undefined:
427
+ case "--help":
428
+ case "-h":
429
+ showHelp();
430
+ return;
431
+ default:
432
+ log(`Unknown memory subcommand: ${sub}`);
433
+ showHelp();
434
+ process.exit(1);
435
+ }
436
+ }
437
+
438
+ module.exports = { cmdMemory, computeConfidence };