@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.
- package/CHANGELOG.md +77 -0
- package/README.md +1 -1
- package/README.tr.md +1 -1
- package/install/templates/claude-hooks.json +32 -1
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/help/SKILL.md +2 -0
- package/pipeline/commands/multi-agent/refactor/SKILL.md +23 -1
- package/pipeline/commands/multi-agent/search/SKILL.md +28 -0
- package/pipeline/commands/multi-agent/setup/SKILL.md +18 -44
- package/pipeline/commands/multi-agent/status/SKILL.md +9 -0
- package/pipeline/lib/credential-inventory.sh +142 -18
- package/pipeline/lib/fetch-crashlytics.sh +123 -28
- package/pipeline/multi-agent-refs/features/url-enrichment.md +1 -1
- package/pipeline/multi-agent-refs/features/visual-evidence.md +5 -0
- package/pipeline/multi-agent-refs/keychain.md +65 -20
- package/pipeline/multi-agent-refs/knowledge.md +27 -0
- package/pipeline/multi-agent-refs/phases/operations.md +7 -1
- package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +3 -1
- package/pipeline/multi-agent-refs/phases/phase-4-review.md +1 -8
- package/pipeline/multi-agent-refs/phases/phase-7-report.md +11 -21
- package/pipeline/multi-agent-refs/picker-contract.md +1 -1
- package/pipeline/multi-agent-refs/refactor/observations.md +81 -0
- package/pipeline/multi-agent-refs/setup/firebase.md +151 -0
- package/pipeline/schemas/learnings-ledger.schema.json +5 -0
- package/pipeline/schemas/prefs.schema.json +31 -3
- package/pipeline/schemas/skill-observation.schema.json +73 -0
- package/pipeline/scripts/capture-flush.sh +158 -0
- package/pipeline/scripts/capture-resume.sh +87 -0
- package/pipeline/scripts/crush-json.mjs +283 -0
- package/pipeline/scripts/firebase-app-discovery.sh +114 -0
- package/pipeline/scripts/keychain-save.sh +5 -8
- package/pipeline/scripts/keychain.py +76 -14
- package/pipeline/scripts/learn-from-transcripts.mjs +625 -0
- package/pipeline/scripts/learning-curve.mjs +22 -4
- package/pipeline/scripts/learnings-ledger.mjs +86 -12
- package/pipeline/scripts/note-session.sh +187 -0
- package/pipeline/scripts/observations.mjs +347 -0
- package/pipeline/scripts/offload-ref.sh +45 -2
- package/pipeline/scripts/pre-commit-check.sh +31 -1
- package/pipeline/scripts/scan-agent-config.sh +12 -3
- package/pipeline/scripts/skill-siblings.mjs +187 -0
- package/pipeline/scripts/triage-memory.mjs +73 -9
- package/pipeline/skills/.skill-manifest.json +1 -1
- package/pipeline/skills/shared/core/multi-agent-refactor/SKILL.md +23 -1
- package/pipeline/skills/shared/core/multi-agent-search/SKILL.md +28 -0
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +37 -6
- 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
|
|
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) {
|