@mmerterden/multi-agent-pipeline 16.25.0 → 16.27.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 (47) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +1 -1
  3. package/README.tr.md +1 -1
  4. package/install/templates/claude-hooks.json +32 -1
  5. package/package.json +1 -1
  6. package/pipeline/commands/multi-agent/help/SKILL.md +2 -0
  7. package/pipeline/commands/multi-agent/refactor/SKILL.md +23 -1
  8. package/pipeline/commands/multi-agent/search/SKILL.md +28 -0
  9. package/pipeline/commands/multi-agent/setup/SKILL.md +18 -44
  10. package/pipeline/commands/multi-agent/status/SKILL.md +9 -0
  11. package/pipeline/lib/credential-inventory.sh +142 -18
  12. package/pipeline/lib/fetch-crashlytics.sh +123 -28
  13. package/pipeline/multi-agent-refs/features/url-enrichment.md +1 -1
  14. package/pipeline/multi-agent-refs/features/visual-evidence.md +5 -0
  15. package/pipeline/multi-agent-refs/keychain.md +65 -20
  16. package/pipeline/multi-agent-refs/knowledge.md +27 -0
  17. package/pipeline/multi-agent-refs/phases/operations.md +7 -1
  18. package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +3 -1
  19. package/pipeline/multi-agent-refs/phases/phase-4-review.md +1 -8
  20. package/pipeline/multi-agent-refs/phases/phase-7-report.md +11 -21
  21. package/pipeline/multi-agent-refs/picker-contract.md +1 -1
  22. package/pipeline/multi-agent-refs/refactor/observations.md +81 -0
  23. package/pipeline/multi-agent-refs/setup/firebase.md +151 -0
  24. package/pipeline/schemas/learnings-ledger.schema.json +5 -0
  25. package/pipeline/schemas/prefs.schema.json +31 -3
  26. package/pipeline/schemas/skill-observation.schema.json +73 -0
  27. package/pipeline/scripts/capture-flush.sh +158 -0
  28. package/pipeline/scripts/capture-resume.sh +87 -0
  29. package/pipeline/scripts/crush-json.mjs +283 -0
  30. package/pipeline/scripts/firebase-app-discovery.sh +114 -0
  31. package/pipeline/scripts/keychain-save.sh +5 -8
  32. package/pipeline/scripts/keychain.py +76 -14
  33. package/pipeline/scripts/learn-from-transcripts.mjs +625 -0
  34. package/pipeline/scripts/learning-curve.mjs +22 -4
  35. package/pipeline/scripts/learnings-ledger.mjs +86 -12
  36. package/pipeline/scripts/note-session.sh +187 -0
  37. package/pipeline/scripts/observations.mjs +347 -0
  38. package/pipeline/scripts/offload-ref.sh +45 -2
  39. package/pipeline/scripts/pre-commit-check.sh +31 -1
  40. package/pipeline/scripts/scan-agent-config.sh +12 -3
  41. package/pipeline/scripts/skill-siblings.mjs +187 -0
  42. package/pipeline/scripts/triage-memory.mjs +73 -9
  43. package/pipeline/skills/.skill-manifest.json +1 -1
  44. package/pipeline/skills/shared/core/multi-agent-refactor/SKILL.md +23 -1
  45. package/pipeline/skills/shared/core/multi-agent-search/SKILL.md +28 -0
  46. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +37 -6
  47. package/pipeline/skills/shared/core/multi-agent-status/SKILL.md +9 -0
@@ -0,0 +1,625 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file learn-from-transcripts.mjs - lessons the model never noticed.
4
+ *
5
+ * WHY THIS EXISTS
6
+ *
7
+ * Phase 7 asks a subagent to read the run log and propose a few memory entries.
8
+ * That works for what the model noticed, and it is structurally blind to what it
9
+ * did not: the same wrong path tried in three sessions, the command that fails
10
+ * every time until its `--flag` variant is used, the directory that never has
11
+ * the answer. Those are the repetitions worth capturing, and a model that missed
12
+ * them once misses them again on the read-back.
13
+ *
14
+ * So this correlates instead of interpreting. It reads the raw transcripts, pairs
15
+ * each failure with whatever succeeded next in the same shape, and writes the
16
+ * difference. No model runs. The output is deterministic - the same transcripts
17
+ * produce the same lessons, which is also what makes it testable.
18
+ *
19
+ * FIVE CORRELATIONS
20
+ *
21
+ * path-correction a read of X failed, a read of a same-named file elsewhere
22
+ * then succeeded -> fact
23
+ * environment a command failed repeatedly and a variant of it worked
24
+ * -> convention
25
+ * search-scope a search returned nothing, a wider one then returned hits
26
+ * -> fact
27
+ * refused-pattern a tool call the user denied, repeatedly
28
+ * -> rejected-preference
29
+ * large-file a read that tripped the read-size threshold -> fact
30
+ *
31
+ * PRIVACY
32
+ *
33
+ * Two independent defenses, because a transcript holds whatever the session read.
34
+ *
35
+ * 1. Structural. A statement is assembled from tokens this script chose - a
36
+ * path, a command's head word, a count. It never concatenates free text out
37
+ * of the transcript, so there is no route by which a value becomes a
38
+ * statement.
39
+ * 2. Belt and braces. Every candidate is written to a temp file and run
40
+ * through `pre-commit-check.sh --scan-file` before it can reach the ledger.
41
+ * A hit is dropped, counted and reported - never written. The patterns live
42
+ * in that script alone; copying them here would produce the copy that stops
43
+ * matching whatever it later gains.
44
+ *
45
+ * A turn whose user message contains a <private> block is skipped whole.
46
+ *
47
+ * Usage:
48
+ * ./learn-from-transcripts.mjs [--dir <transcripts>] [--repo-slug <slug>]
49
+ * [--since-days N] [--json] [--apply]
50
+ *
51
+ * Default is DRY RUN: nothing is written without --apply.
52
+ *
53
+ * Exit: 0 ok (including "nothing found") | 1 usage
54
+ */
55
+
56
+ import {
57
+ existsSync,
58
+ mkdtempSync,
59
+ readFileSync,
60
+ readdirSync,
61
+ rmSync,
62
+ statSync,
63
+ writeFileSync,
64
+ } from "node:fs";
65
+ import { execFileSync } from "node:child_process";
66
+ import { basename, dirname, join } from "node:path";
67
+ import { homedir, tmpdir } from "node:os";
68
+ import { fileURLToPath } from "node:url";
69
+
70
+ const HERE = dirname(fileURLToPath(import.meta.url));
71
+ const argv = process.argv.slice(2);
72
+ const flags = {};
73
+ for (let i = 0; i < argv.length; i++) {
74
+ const a = argv[i];
75
+ if (!a.startsWith("--")) continue;
76
+ const k = a.slice(2);
77
+ flags[k] = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : true;
78
+ }
79
+ const APPLY = flags.apply === true;
80
+ const JSON_OUT = flags.json === true;
81
+
82
+ function repoSlug() {
83
+ if (typeof flags["repo-slug"] === "string") return flags["repo-slug"];
84
+ try {
85
+ const top = execFileSync("git", ["rev-parse", "--show-toplevel"], {
86
+ encoding: "utf8",
87
+ stdio: ["ignore", "pipe", "ignore"],
88
+ }).trim();
89
+ if (top) return basename(top);
90
+ } catch {
91
+ /* not a repo; fall through */
92
+ }
93
+ return basename(process.cwd());
94
+ }
95
+
96
+ function transcriptDir() {
97
+ if (typeof flags.dir === "string") return flags.dir;
98
+ const slug = process.cwd().replace(/[^A-Za-z0-9]/g, "-");
99
+ return join(homedir(), ".claude", "projects", slug);
100
+ }
101
+
102
+ /* ------------------------------------------------------------------ reading */
103
+
104
+ /** One flat, ordered list of events. Everything downstream is a walk over it. */
105
+ function readEvents(file) {
106
+ const events = [];
107
+ let text;
108
+ try {
109
+ text = readFileSync(file, "utf8");
110
+ } catch {
111
+ return events;
112
+ }
113
+ const pending = new Map();
114
+ let skipTurn = false;
115
+
116
+ for (const line of text.split("\n")) {
117
+ if (!line.trim()) continue;
118
+ let d;
119
+ try {
120
+ d = JSON.parse(line);
121
+ } catch {
122
+ continue;
123
+ }
124
+ const content = (d.message || {}).content;
125
+
126
+ // A <private> block in a user message takes the whole turn out of scope.
127
+ // The user marked it; nothing from it is mined, not even the shape.
128
+ if (d.type === "user") {
129
+ const raw =
130
+ typeof content === "string"
131
+ ? content
132
+ : Array.isArray(content)
133
+ ? content.map((b) => (b && b.text) || "").join(" ")
134
+ : "";
135
+ if (raw.includes("<private>")) skipTurn = true;
136
+ else if (raw.trim()) skipTurn = false;
137
+ }
138
+ if (skipTurn || !Array.isArray(content)) continue;
139
+
140
+ for (const b of content) {
141
+ if (!b || typeof b !== "object") continue;
142
+ if (b.type === "tool_use") {
143
+ pending.set(b.id, { name: b.name || "?", input: b.input || {} });
144
+ } else if (b.type === "tool_result") {
145
+ const call = pending.get(b.tool_use_id);
146
+ pending.delete(b.tool_use_id);
147
+ if (!call) continue;
148
+ const c = b.content;
149
+ const out = Array.isArray(c)
150
+ ? c.map((x) => (x && x.text) || "").join(" ")
151
+ : typeof c === "string"
152
+ ? c
153
+ : "";
154
+ events.push({
155
+ tool: call.name,
156
+ input: call.input,
157
+ isError: Boolean(b.is_error),
158
+ denied: out.includes("has been denied"),
159
+ empty: out.trim().length === 0,
160
+ outLen: out.length,
161
+ });
162
+ }
163
+ }
164
+ }
165
+ return events;
166
+ }
167
+
168
+ /* --------------------------------------------------------------- extraction */
169
+
170
+ /** The path a call reads, whichever tool did the reading. */
171
+ function readPath(e) {
172
+ if (e.tool === "Read") return typeof e.input.file_path === "string" ? e.input.file_path : null;
173
+ if (e.tool !== "Bash") return null;
174
+ const cmd = typeof e.input.command === "string" ? e.input.command : "";
175
+ // Single-line invocations only, and the captured token has to look like a
176
+ // path. Reading the first word after `cat` out of a multi-line script yielded
177
+ // things like `SKILL.md;` - a shell fragment presented as a file.
178
+ const first = cmd.trim().split("\n")[0];
179
+ if (/[;&|]/.test(first)) return null;
180
+ const m = /^\s*(?:cat|head|tail|sed -n \S+)\s+(?:-\S+\s+)*("[^"]+"|'[^']+'|\S+)\s*$/.exec(first);
181
+ if (!m) return null;
182
+ const p = m[1].replace(/^["']|["']$/g, "");
183
+ if (p.startsWith("-") || /[;&|<>*?$`]/.test(p)) return null;
184
+ return p;
185
+ }
186
+
187
+ // Shell syntax is not a program. An early version mined `export`, `set`, `for`
188
+ // and `echo` as environment facts, which is what happens when the first token of
189
+ // a multi-line script is treated as a command name.
190
+ const SHELL_WORDS = new Set([
191
+ "export",
192
+ "set",
193
+ "for",
194
+ "while",
195
+ "do",
196
+ "done",
197
+ "if",
198
+ "then",
199
+ "else",
200
+ "fi",
201
+ "case",
202
+ "esac",
203
+ "echo",
204
+ "printf",
205
+ "local",
206
+ "return",
207
+ "source",
208
+ "unset",
209
+ "read",
210
+ "shift",
211
+ "trap",
212
+ "eval",
213
+ "true",
214
+ "false",
215
+ "wait",
216
+ "sleep",
217
+ "cd",
218
+ ]);
219
+
220
+ function headWord(cmd) {
221
+ if (typeof cmd !== "string") return null;
222
+ // Only the FIRST line is a candidate. A heredoc or a multi-line script has no
223
+ // single command name, and pretending otherwise is where the path fragments
224
+ // came from.
225
+ const first = cmd.trim().split("\n")[0];
226
+ const toks = first.trim().split(/\s+/);
227
+ for (let i = 0; i < toks.length; i++) {
228
+ const t = toks[i];
229
+ if (["sudo", "env", "time", "command", "exec"].includes(t)) continue;
230
+ if (t === "cd" || t === "pushd") {
231
+ i++;
232
+ continue;
233
+ }
234
+ if (["&&", ";", "|", "\\"].includes(t)) continue;
235
+ if (/^[-/$({"'!]/.test(t)) return null;
236
+ if (t.includes("=")) return null; // VAR=value, an assignment
237
+ const base = t.split("/").pop();
238
+ if (!/^[a-z][a-z0-9_.-]{0,31}$/.test(base)) return null;
239
+ return SHELL_WORDS.has(base) ? null : base;
240
+ }
241
+ return null;
242
+ }
243
+
244
+ function searchPath(e) {
245
+ if (e.tool === "Grep" || e.tool === "Glob") {
246
+ return typeof e.input.path === "string" ? e.input.path : null;
247
+ }
248
+ return null;
249
+ }
250
+
251
+ function mine(events) {
252
+ const found = [];
253
+ const seen = new Set();
254
+ const push = (kind, statement, scope, diagnosis) => {
255
+ const key = `${kind}::${statement}`;
256
+ if (seen.has(key)) return;
257
+ seen.add(key);
258
+ found.push({ kind, statement, scope: scope || "global", diagnosis: diagnosis || null });
259
+ };
260
+
261
+ // 1. path correction: a failed read, then a successful read of the same
262
+ // basename somewhere else. The basename is the join key - a rename would
263
+ // not be a correction, a relocation is.
264
+ const failedReads = [];
265
+ for (const e of events) {
266
+ const p = readPath(e);
267
+ if (!p) continue;
268
+ if (e.isError) {
269
+ failedReads.push(p);
270
+ continue;
271
+ }
272
+ const hit = failedReads.find((f) => basename(f) === basename(p) && f !== p);
273
+ if (hit) {
274
+ push(
275
+ "fact",
276
+ `${basename(p)} lives at ${p}, not ${hit}`,
277
+ p,
278
+ `a read of ${hit} failed and the same file resolved at ${p}`,
279
+ );
280
+ failedReads.splice(failedReads.indexOf(hit), 1);
281
+ }
282
+ }
283
+
284
+ // 2. environment: the SAME command, character for character, failing again and
285
+ // again - and then a different invocation of the same program working.
286
+ //
287
+ // An earlier version grouped by program name and fired at two failures. It
288
+ // mined "`ls` failed twice", "`cat` failed twice", "`chmod` failed twice" -
289
+ // ordinary exploration hitting files that were not there, presented as
290
+ // durable environment facts. Grouping by the exact text is what separates
291
+ // "I looked for something that did not exist" from "this invocation does
292
+ // not work here", and the threshold is 3 because two is still a coincidence.
293
+ const REPEAT_THRESHOLD = 3;
294
+ const sameCmdFail = new Map();
295
+ const progOk = new Set();
296
+ for (const e of events) {
297
+ if (e.tool !== "Bash") continue;
298
+ const raw = typeof e.input.command === "string" ? e.input.command : "";
299
+ const norm = raw.trim().split("\n")[0].replace(/\s+/g, " ");
300
+ const h = headWord(raw);
301
+ if (!h || !norm) continue;
302
+ if (e.isError) {
303
+ const cur = sameCmdFail.get(norm) || { n: 0, head: h };
304
+ cur.n += 1;
305
+ sameCmdFail.set(norm, cur);
306
+ } else {
307
+ progOk.add(h);
308
+ }
309
+ }
310
+ // The command text is intentionally not read here: it is the one part of
311
+ // this correlation that could carry an argument, so it stays out of the
312
+ // statement entirely.
313
+ for (const [_norm, { n, head }] of sameCmdFail) {
314
+ if (n < REPEAT_THRESHOLD || !progOk.has(head)) continue;
315
+ // The statement names the program and the count, never the command text:
316
+ // the text is the one part of this that could carry an argument.
317
+ push(
318
+ "convention",
319
+ `the same \`${head}\` invocation failed ${n} times here before a different one worked - check the form against this repo first`,
320
+ "global",
321
+ "an identical command repeated and kept failing",
322
+ );
323
+ }
324
+
325
+ // 3. search scope: an empty search, then a hit from a wider path. Only counted
326
+ // when the later path is a prefix of the earlier one, which is what makes
327
+ // "wider" mechanical rather than a guess.
328
+ const emptySearch = [];
329
+ for (const e of events) {
330
+ if (e.tool !== "Grep" && e.tool !== "Glob") continue;
331
+ const p = searchPath(e);
332
+ if (!p) continue;
333
+ if (e.empty || e.isError) {
334
+ emptySearch.push(p);
335
+ continue;
336
+ }
337
+ const narrower = emptySearch.find((q) => q !== p && q.startsWith(p));
338
+ if (narrower) {
339
+ push("fact", `searching ${narrower} alone misses matches that ${p} finds`, p);
340
+ emptySearch.splice(emptySearch.indexOf(narrower), 1);
341
+ }
342
+ }
343
+
344
+ // 4. refused pattern: the user said no, more than once, to the same tool.
345
+ // A single refusal is a moment; a repeat is a preference.
346
+ const denied = new Map();
347
+ for (const e of events) {
348
+ if (!e.denied) continue;
349
+ // A refusal whose program cannot be named is not a lesson: "Bash:? was
350
+ // refused" tells a future run nothing it can act on.
351
+ let key = e.tool;
352
+ if (e.tool === "Bash") {
353
+ const h = headWord(e.input.command);
354
+ if (!h) continue;
355
+ key = `Bash:${h}`;
356
+ }
357
+ denied.set(key, (denied.get(key) || 0) + 1);
358
+ }
359
+ for (const [key, n] of denied) {
360
+ if (n >= 2) {
361
+ push(
362
+ "rejected-preference",
363
+ `${key} was refused ${n} times - propose it before running it`,
364
+ "global",
365
+ "the user declined the same call repeatedly",
366
+ );
367
+ }
368
+ }
369
+
370
+ // 5. large file: a read whose output is big enough that the next one should be
371
+ // ranged. The threshold matches check-read-size.sh's own default.
372
+ const BIG = 100000;
373
+ const bigSeen = new Set();
374
+ for (const e of events) {
375
+ const p = readPath(e);
376
+ if (!p || e.isError || e.outLen < BIG || bigSeen.has(p)) continue;
377
+ bigSeen.add(p);
378
+ push("fact", `${p} is large - read it with an offset and limit, not whole`, p);
379
+ }
380
+
381
+ return found;
382
+ }
383
+
384
+ /* ----------------------------------------------------------------- privacy */
385
+
386
+ /**
387
+ * The single-source secret check. Candidates go to a temp file and through
388
+ * pre-commit-check.sh, which owns the patterns; a hit is dropped, never written.
389
+ */
390
+ function dropSecrets(items) {
391
+ const scanner = existsSync(join(HERE, "pre-commit-check.sh"))
392
+ ? join(HERE, "pre-commit-check.sh")
393
+ : join(homedir(), ".claude", "scripts", "pre-commit-check.sh");
394
+ if (!existsSync(scanner)) {
395
+ return { kept: items, dropped: 0, scanned: false };
396
+ }
397
+ const dir = mkdtempSync(join(tmpdir(), "lft-"));
398
+ const kept = [];
399
+ let dropped = 0;
400
+ try {
401
+ for (const it of items) {
402
+ const f = join(dir, "candidate.txt");
403
+ writeFileSync(f, `${it.statement}\n${it.diagnosis || ""}\n${it.scope}\n`);
404
+ let clean = true;
405
+ try {
406
+ execFileSync("bash", [scanner, "--scan-file", f], { stdio: "ignore" });
407
+ } catch {
408
+ clean = false;
409
+ }
410
+ if (clean) kept.push(it);
411
+ else dropped++;
412
+ }
413
+ } finally {
414
+ rmSync(dir, { recursive: true, force: true });
415
+ }
416
+ return { kept, dropped, scanned: true };
417
+ }
418
+
419
+ /* -------------------------------------------------------------------- main */
420
+
421
+ function main() {
422
+ const dir = transcriptDir();
423
+ if (!existsSync(dir)) {
424
+ const out = { status: "noop", reason: "no-transcripts", dir };
425
+ process.stdout.write(JSON_OUT ? JSON.stringify(out) + "\n" : `no transcripts under ${dir}\n`);
426
+ return;
427
+ }
428
+
429
+ const sinceDays = Number(flags["since-days"] || 30);
430
+ const cutoff = Date.now() - sinceDays * 86400000;
431
+ const files = readdirSync(dir)
432
+ .filter((f) => f.endsWith(".jsonl"))
433
+ .map((f) => join(dir, f))
434
+ .filter((f) => {
435
+ try {
436
+ return statSync(f).mtimeMs >= cutoff;
437
+ } catch {
438
+ return false;
439
+ }
440
+ });
441
+
442
+ // Count what was examined, not just what was found. Zero lessons from zero
443
+ // events and zero lessons from fifty thousand are different facts, and a miner
444
+ // that reports only the second number cannot tell you which one it is looking
445
+ // at - the same ambiguity the observation store's SCAN BROKEN guard exists for.
446
+ let all = [];
447
+ let examined = 0;
448
+ for (const f of files) {
449
+ const events = readEvents(f);
450
+ examined += events.length;
451
+ all = all.concat(mine(events));
452
+ }
453
+
454
+ // Same lesson from two transcripts is one lesson.
455
+ const uniq = [];
456
+ const seenKey = new Set();
457
+ for (const it of all) {
458
+ const k = `${it.kind}::${it.statement}`;
459
+ if (seenKey.has(k)) continue;
460
+ seenKey.add(k);
461
+ uniq.push(it);
462
+ }
463
+
464
+ const { kept, dropped, scanned } = dropSecrets(uniq);
465
+ const slug = repoSlug();
466
+
467
+ let written = 0;
468
+ let deduped = 0;
469
+ if (APPLY && kept.length) {
470
+ const ledger = existsSync(join(HERE, "learnings-ledger.mjs"))
471
+ ? join(HERE, "learnings-ledger.mjs")
472
+ : join(homedir(), ".claude", "scripts", "learnings-ledger.mjs");
473
+ for (const it of kept) {
474
+ const args = [
475
+ ledger,
476
+ "add",
477
+ "--kind",
478
+ it.kind,
479
+ "--statement",
480
+ it.statement,
481
+ "--scope",
482
+ it.scope,
483
+ "--source",
484
+ "transcript-mining",
485
+ "--confidence",
486
+ "low",
487
+ "--repo-slug",
488
+ slug,
489
+ ];
490
+ if (it.diagnosis) args.push("--diagnosis", it.diagnosis);
491
+ try {
492
+ execFileSync("node", args, { stdio: "ignore" });
493
+ written++;
494
+ } catch (e) {
495
+ // exit 2 is the ledger's "already known", which is a success here.
496
+ if (e && e.status === 2) deduped++;
497
+ }
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Render the lessons into a marked block in CLAUDE.local.md.
503
+ *
504
+ * The ledger is where a phase reads them; this file is where a PERSON does. The
505
+ * markers make the write idempotent: a re-run replaces what is between them and
506
+ * touches nothing else, so a hand-written note above the block survives every
507
+ * future run. Without them the choice is append (a file that grows forever) or
508
+ * overwrite (a file nobody dares edit).
509
+ *
510
+ * `.local.md` because these are facts about THIS checkout on THIS machine - a
511
+ * path that resolved here, a command that failed here. The gitignore guard is
512
+ * the same one memory-save.sh applies, for the same reason.
513
+ */
514
+ function writeLocalBlock(items) {
515
+ const START = "<!-- multi-agent:learn:start -->";
516
+ const END = "<!-- multi-agent:learn:end -->";
517
+ let root;
518
+ try {
519
+ root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
520
+ encoding: "utf8",
521
+ stdio: ["ignore", "pipe", "ignore"],
522
+ }).trim();
523
+ } catch {
524
+ return null;
525
+ }
526
+ if (!root) return null;
527
+
528
+ const file = join(root, "CLAUDE.local.md");
529
+ const body = [
530
+ START,
531
+ "## Learned from past sessions",
532
+ "",
533
+ "Mined mechanically by `learn-from-transcripts.mjs` - each line is a correlation",
534
+ "between something that failed and whatever worked next, never a model's opinion.",
535
+ "Delete a line that has stopped being true; the next run will not put it back",
536
+ "unless the transcripts still say it.",
537
+ "",
538
+ ...items.map((it) => `- **${it.kind}**: ${it.statement}`),
539
+ END,
540
+ ].join("\n");
541
+
542
+ let text = existsSync(file) ? readFileSync(file, "utf8") : "";
543
+ if (text.includes(START) && text.includes(END)) {
544
+ const head = text.slice(0, text.indexOf(START));
545
+ const tail = text.slice(text.indexOf(END) + END.length);
546
+ text = head + body + tail;
547
+ } else {
548
+ text = (text.trimEnd() + "\n\n" + body + "\n").trimStart();
549
+ }
550
+ writeFileSync(file, text.endsWith("\n") ? text : text + "\n");
551
+
552
+ // Never commit machine-local facts. Same guard memory-save.sh applies.
553
+ const gi = join(root, ".gitignore");
554
+ const line = "CLAUDE.local.md";
555
+ let ignored = false;
556
+ try {
557
+ ignored = readFileSync(gi, "utf8")
558
+ .split("\n")
559
+ .some((l) => l.trim() === line);
560
+ } catch {
561
+ /* no .gitignore yet */
562
+ }
563
+ if (!ignored) {
564
+ const prev = existsSync(gi) ? readFileSync(gi, "utf8") : "";
565
+ writeFileSync(
566
+ gi,
567
+ `${prev.trimEnd()}\n\n# Machine-local learned facts - never commit.\n${line}\n`.trimStart(),
568
+ );
569
+ }
570
+ return file;
571
+ }
572
+
573
+ let localFile = null;
574
+ if (APPLY && kept.length) localFile = writeLocalBlock(kept);
575
+
576
+ // Record how many lessons the machine found, kept apart from the ones a model
577
+ // proposed. Pooling them lets a "lessons learned" number climb while the half
578
+ // that actually prevents a repeat stays flat.
579
+ if (APPLY && written > 0) {
580
+ const logger = existsSync(join(HERE, "log-metric.sh"))
581
+ ? join(HERE, "log-metric.sh")
582
+ : join(homedir(), ".claude", "scripts", "log-metric.sh");
583
+ if (existsSync(logger)) {
584
+ try {
585
+ execFileSync("bash", [logger, "unknown", "7", "learning.mined", `rows=${written}`], {
586
+ stdio: "ignore",
587
+ });
588
+ } catch {
589
+ /* a metric that cannot be written is never a reason to fail the mine */
590
+ }
591
+ }
592
+ }
593
+
594
+ const result = {
595
+ status: "ok",
596
+ repo: slug,
597
+ transcripts: files.length,
598
+ toolResultsExamined: examined,
599
+ candidates: uniq.length,
600
+ droppedBySecretScan: dropped,
601
+ secretScanRan: scanned,
602
+ applied: APPLY,
603
+ written,
604
+ deduped,
605
+ localFile,
606
+ learnings: kept,
607
+ };
608
+
609
+ if (JSON_OUT) {
610
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
611
+ } else if (kept.length === 0) {
612
+ process.stdout.write(
613
+ `${files.length} transcript(s), ${examined} tool result(s) examined, nothing new to learn\n`,
614
+ );
615
+ } else {
616
+ process.stdout.write(
617
+ `${files.length} transcript(s) read, ${kept.length} candidate lesson(s)` +
618
+ `${dropped ? `, ${dropped} dropped by the secret scan` : ""}` +
619
+ `${APPLY ? ` - ${written} written, ${deduped} already known` : " (dry run; pass --apply to write)"}\n`,
620
+ );
621
+ for (const it of kept) process.stdout.write(` [${it.kind}] ${it.statement}\n`);
622
+ }
623
+ }
624
+
625
+ main();
@@ -8,7 +8,9 @@
8
8
  //
9
9
  // Buckets tasks by the timestamp of their first event, then per bucket reports:
10
10
  // tasks, first-pass-clean rate, avg review cycles, rework/task,
11
- // tokens/task, cache-reuse ratio, memory-recall precision. Prints a
11
+ // tokens/task, cache-reuse ratio, memory-recall precision, mined lessons per
12
+ // task (learn-from-transcripts.mjs) and bytes saved by structural offload
13
+ // (crush-json.mjs). Prints a
12
14
  // first->last trend for the headline KPIs. Read-only, zero deps, no API/model calls.
13
15
  //
14
16
  // Usage:
@@ -75,6 +77,8 @@ for (const e of events) {
75
77
  tokensCached: 0,
76
78
  memInjected: 0,
77
79
  memUsed: 0,
80
+ minedLessons: 0,
81
+ offloadSaved: 0,
78
82
  };
79
83
  tasks.set(id, t);
80
84
  }
@@ -98,6 +102,13 @@ for (const e of events) {
98
102
  // the case the instruction invites. An ABSENT rows field still means one hit
99
103
  // (the event happened); a present zero means zero.
100
104
  if (e.event === "memory.hit") t.memUsed += d.rows === undefined ? 1 : num(d.rows);
105
+ // Two things the loop learned to count once the pipeline started producing
106
+ // them. They are kept apart from the model-written lessons on purpose: a
107
+ // machine-mined path correction and a model's architectural claim are
108
+ // different evidence, and a single "lessons learned" number can climb while
109
+ // the half that actually helps stays flat.
110
+ if (e.event === "learning.mined") t.minedLessons += d.rows === undefined ? 1 : num(d.rows);
111
+ if (e.event === "offload.crushed") t.offloadSaved += num(d.saved_bytes);
101
112
  t.tokensIn += num(d.tokens_in);
102
113
  t.tokensOut += num(d.tokens_out);
103
114
  t.tokensCached += num(d.tokens_cached);
@@ -132,6 +143,8 @@ const buckets = [...byBucket.keys()].sort().map((start) => {
132
143
  const totCached = list.reduce((s, t) => s + t.tokensCached, 0);
133
144
  const totInjected = list.reduce((s, t) => s + t.memInjected, 0);
134
145
  const totUsed = list.reduce((s, t) => s + t.memUsed, 0);
146
+ const totMined = list.reduce((s, t) => s + t.minedLessons, 0);
147
+ const totSaved = list.reduce((s, t) => s + t.offloadSaved, 0);
135
148
  return {
136
149
  bucketStart: start,
137
150
  tasks: n,
@@ -143,6 +156,11 @@ const buckets = [...byBucket.keys()].sort().map((start) => {
143
156
  tokensPerTask: Math.round((totIn + totOut) / n),
144
157
  cacheRatio: totIn + totCached ? round(totCached / (totIn + totCached)) : null,
145
158
  memoryPrecision: totInjected ? round(totUsed / totInjected) : null,
159
+ // null, not 0, when nothing was recorded: "no data" and "measured zero" are
160
+ // different answers, and a chart that draws them the same way invites the
161
+ // wrong conclusion about a feature that was simply never switched on.
162
+ minedLessonsPerTask: totMined ? round(totMined / n) : null,
163
+ offloadBytesSavedPerTask: totSaved ? Math.round(totSaved / n) : null,
146
164
  };
147
165
  });
148
166
 
@@ -179,12 +197,12 @@ const arrow = (v, goodIsUp) => {
179
197
  };
180
198
  console.log(`# Learning curve (${opts.bucketDays}-day buckets)\n`);
181
199
  console.log(
182
- "| Bucket | Tasks | First-pass clean | Avg review cycles | Rework/task | Tokens/task | Cache ratio | Memory precision |",
200
+ "| Bucket | Tasks | First-pass clean | Avg review cycles | Rework/task | Tokens/task | Cache ratio | Memory precision | Mined lessons/task | Offload bytes saved/task |",
183
201
  );
184
- console.log("|---|---|---|---|---|---|---|---|");
202
+ console.log("|---|---|---|---|---|---|---|---|---|---|");
185
203
  for (const b of buckets) {
186
204
  console.log(
187
- `| ${b.bucketStart} | ${b.tasks} | ${b.firstPassCleanRate ?? "-"} | ${b.avgReviewCycles ?? "-"} | ${b.reworkPerTask} | ${b.tokensPerTask} | ${b.cacheRatio ?? "-"} | ${b.memoryPrecision ?? "-"} |`,
205
+ `| ${b.bucketStart} | ${b.tasks} | ${b.firstPassCleanRate ?? "-"} | ${b.avgReviewCycles ?? "-"} | ${b.reworkPerTask} | ${b.tokensPerTask} | ${b.cacheRatio ?? "-"} | ${b.memoryPrecision ?? "-"} | ${b.minedLessonsPerTask ?? "-"} | ${b.offloadBytesSavedPerTask ?? "-"} |`,
188
206
  );
189
207
  }
190
208
  if (trend) {