@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,347 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @file observations.mjs - the pipeline's own friction log.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS
|
|
6
|
+
*
|
|
7
|
+
* The learnings ledger records what a run learned about a REPO. Nothing recorded
|
|
8
|
+
* what a run learned about the PIPELINE. `/multi-agent:refactor` existed, but it
|
|
9
|
+
* derived every finding from scratch on each invocation, so a friction noticed
|
|
10
|
+
* on Tuesday was gone by Wednesday unless it was fixed the same hour. Most were
|
|
11
|
+
* not, because they surface mid-task, when stopping to fix them is the wrong
|
|
12
|
+
* call.
|
|
13
|
+
*
|
|
14
|
+
* So: write it down the moment it is noticed, in the same turn, and let a review
|
|
15
|
+
* decide later.
|
|
16
|
+
*
|
|
17
|
+
* STORAGE
|
|
18
|
+
*
|
|
19
|
+
* One markdown file per observation under
|
|
20
|
+
* `$HOME/.claude/memory/multi-agent/_pipeline/observations/NNNN-slug.md`, with
|
|
21
|
+
* YAML-ish frontmatter conforming to schemas/skill-observation.schema.json.
|
|
22
|
+
*
|
|
23
|
+
* The directory listing IS the index. There is no index file, because an index
|
|
24
|
+
* is a second copy of the truth and the second copy is the one that goes stale.
|
|
25
|
+
* `scan` reads frontmatter only and never opens a body, so a queue of several
|
|
26
|
+
* hundred observations still costs a session almost nothing at startup.
|
|
27
|
+
*
|
|
28
|
+
* THE READ IS GUARDED
|
|
29
|
+
*
|
|
30
|
+
* A scan that returns nothing reports two different facts with one answer:
|
|
31
|
+
* there is nothing to find, or the question was never asked. Only the first is
|
|
32
|
+
* a finding. So `scan` counts the files by a literal path, independently of the
|
|
33
|
+
* variable the loop walks, and halts with SCAN BROKEN when the two disagree.
|
|
34
|
+
* This codebase has been bitten by exactly that shape before - a sweep whose
|
|
35
|
+
* variable expanded to one bogus name, exited non-zero, and reported "0 hits"
|
|
36
|
+
* as a clean result.
|
|
37
|
+
*
|
|
38
|
+
* Subcommands:
|
|
39
|
+
* add --title "..." --target <path> [--target <path>...] --area <a>
|
|
40
|
+
* [--session "..."] [--proposes <name>] [--body "..."]
|
|
41
|
+
* [--siblings "..."] (default: filled from skill-siblings.mjs)
|
|
42
|
+
* scan [--status open] [--json] frontmatter only, guarded
|
|
43
|
+
* resolve --id NNNN --status <actioned|declined|superseded|parked>
|
|
44
|
+
* [--resolution "..."] [--parked-until "..."] [--reference "..."]
|
|
45
|
+
* stats [--json]
|
|
46
|
+
* path
|
|
47
|
+
*
|
|
48
|
+
* Exit codes: 0 ok | 1 usage/not-found | 3 SCAN BROKEN (never silent)
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
52
|
+
import { execFileSync } from "node:child_process";
|
|
53
|
+
import { dirname, join } from "node:path";
|
|
54
|
+
import { homedir } from "node:os";
|
|
55
|
+
import { fileURLToPath } from "node:url";
|
|
56
|
+
|
|
57
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
58
|
+
const STORE = join(homedir(), ".claude", "memory", "multi-agent", "_pipeline");
|
|
59
|
+
const OBS = join(STORE, "observations");
|
|
60
|
+
|
|
61
|
+
const argv = process.argv.slice(2);
|
|
62
|
+
const SUB = argv[0];
|
|
63
|
+
const flags = {};
|
|
64
|
+
const repeated = { target: [], proposes: [] };
|
|
65
|
+
for (let i = 1; i < argv.length; i++) {
|
|
66
|
+
const a = argv[i];
|
|
67
|
+
if (!a.startsWith("--")) continue;
|
|
68
|
+
const k = a.slice(2);
|
|
69
|
+
const v = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true";
|
|
70
|
+
if (k in repeated) repeated[k].push(v);
|
|
71
|
+
else flags[k] = v;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const die = (m, c = 1) => {
|
|
75
|
+
process.stderr.write(`ERR: ${m}\n`);
|
|
76
|
+
process.exit(c);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const today = () => new Date().toISOString().slice(0, 10);
|
|
80
|
+
|
|
81
|
+
function slugify(s) {
|
|
82
|
+
return String(s)
|
|
83
|
+
.toLowerCase()
|
|
84
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
85
|
+
.replace(/^-|-$/g, "")
|
|
86
|
+
.slice(0, 60);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Atomically reserve the next id, returned zero-padded.
|
|
91
|
+
*
|
|
92
|
+
* A zero-byte marker created with the "wx" flag IS the claim: O_EXCL fails for
|
|
93
|
+
* the loser of a race, who takes the next number. The marker is kept rather
|
|
94
|
+
* than deleted, so a crash between reserving and writing loses a number instead
|
|
95
|
+
* of handing the same one to the next writer. Numbers are cheap; ambiguous ids
|
|
96
|
+
* are not.
|
|
97
|
+
*/
|
|
98
|
+
function claimId() {
|
|
99
|
+
const taken = existsSync(OBS)
|
|
100
|
+
? readdirSync(OBS)
|
|
101
|
+
.map((f) => {
|
|
102
|
+
const m = /^\.?(\d{4})[-.]/.exec(f);
|
|
103
|
+
return m ? parseInt(m[1], 10) : 0;
|
|
104
|
+
})
|
|
105
|
+
.filter((n) => n > 0)
|
|
106
|
+
: [];
|
|
107
|
+
let n = taken.length ? Math.max(...taken) + 1 : 1;
|
|
108
|
+
for (let attempt = 0; attempt < 1000; attempt++, n++) {
|
|
109
|
+
const pad = String(n).padStart(4, "0");
|
|
110
|
+
try {
|
|
111
|
+
writeFileSync(join(OBS, `.${pad}.id`), "", { flag: "wx" });
|
|
112
|
+
return pad;
|
|
113
|
+
} catch {
|
|
114
|
+
/* lost the race for this number; take the next */
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
die("could not claim an observation id after 1000 attempts", 1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Files in the store, by a literal path - the independent count the guard needs. */
|
|
121
|
+
function listFiles() {
|
|
122
|
+
if (!existsSync(OBS)) return [];
|
|
123
|
+
return readdirSync(OBS)
|
|
124
|
+
.filter((f) => /^\d{4}-.*\.md$/.test(f))
|
|
125
|
+
.sort();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Frontmatter only. Bodies are never read: the whole point of one-file-per-
|
|
130
|
+
* observation is that a scan of hundreds of them stays cheap.
|
|
131
|
+
*/
|
|
132
|
+
function readFrontmatter(file) {
|
|
133
|
+
const text = readFileSync(join(OBS, file), "utf8");
|
|
134
|
+
if (!text.startsWith("---")) return null;
|
|
135
|
+
const end = text.indexOf("\n---", 3);
|
|
136
|
+
if (end === -1) return null;
|
|
137
|
+
const head = text.slice(4, end);
|
|
138
|
+
const fm = {};
|
|
139
|
+
for (const line of head.split("\n")) {
|
|
140
|
+
const m = /^([a-z_]+):\s*(.*)$/.exec(line);
|
|
141
|
+
if (!m) continue;
|
|
142
|
+
const [, k, raw] = m;
|
|
143
|
+
let v = raw.trim();
|
|
144
|
+
if (v.startsWith("[") && v.endsWith("]")) {
|
|
145
|
+
v = v
|
|
146
|
+
.slice(1, -1)
|
|
147
|
+
.split(",")
|
|
148
|
+
.map((x) => x.trim().replace(/^["']|["']$/g, ""))
|
|
149
|
+
.filter(Boolean);
|
|
150
|
+
} else {
|
|
151
|
+
v = v.replace(/^["']|["']$/g, "");
|
|
152
|
+
}
|
|
153
|
+
fm[k] = v;
|
|
154
|
+
}
|
|
155
|
+
return fm;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function siblingsFor(targets) {
|
|
159
|
+
const script = join(HERE, "skill-siblings.mjs");
|
|
160
|
+
if (!existsSync(script) || targets.length === 0) return "";
|
|
161
|
+
const notes = [];
|
|
162
|
+
for (const t of targets) {
|
|
163
|
+
try {
|
|
164
|
+
const out = execFileSync("node", [script, t, "--json"], {
|
|
165
|
+
encoding: "utf8",
|
|
166
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
167
|
+
});
|
|
168
|
+
const d = JSON.parse(out);
|
|
169
|
+
const present = d.surfaces.filter((s) => s.exists).length;
|
|
170
|
+
notes.push(`${d.name}: ${present}/${d.surfaces.length} surfaces present`);
|
|
171
|
+
} catch {
|
|
172
|
+
notes.push(`${t}: not a skill surface`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return notes.join("; ");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (SUB === "path") {
|
|
179
|
+
// No process.exit() after a write: stdout to a pipe is async and exiting
|
|
180
|
+
// truncates it at the buffer. Falling out of this block is safe - the blocks
|
|
181
|
+
// below all test SUB, and none of them can match "path".
|
|
182
|
+
process.stdout.write(OBS + "\n");
|
|
183
|
+
process.exitCode = 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (SUB === "add") {
|
|
187
|
+
const title = flags.title;
|
|
188
|
+
if (!title || title === "true") die("add needs --title");
|
|
189
|
+
const targets = repeated.target;
|
|
190
|
+
if (targets.length === 0) die("add needs at least one --target");
|
|
191
|
+
const area = flags.area && flags.area !== "true" ? flags.area : "unfiled";
|
|
192
|
+
|
|
193
|
+
mkdirSync(OBS, { recursive: true });
|
|
194
|
+
|
|
195
|
+
// Claiming the number has to be ATOMIC. Two sessions can notice friction at
|
|
196
|
+
// the same moment, and this store's premise is that writing is cheap enough
|
|
197
|
+
// to do mid-task - so concurrent writers are the normal case, not an exotic
|
|
198
|
+
// one.
|
|
199
|
+
//
|
|
200
|
+
// Read-the-max-and-add-one is not atomic: eight simultaneous adds all read
|
|
201
|
+
// the same max and all wrote id 0001. The FILES did not collide, since their
|
|
202
|
+
// slugs differ, so nothing looked wrong - but `resolve --id 0001` then picks
|
|
203
|
+
// whichever the directory listing returns first and silently resolves the
|
|
204
|
+
// wrong observation.
|
|
205
|
+
const nextNum = claimId();
|
|
206
|
+
|
|
207
|
+
// The sibling answer is computed, never recalled: recollection is the faculty
|
|
208
|
+
// that produced the drift this field exists to catch.
|
|
209
|
+
const siblings =
|
|
210
|
+
flags.siblings && flags.siblings !== "true" ? flags.siblings : siblingsFor(targets);
|
|
211
|
+
if (!siblings)
|
|
212
|
+
die(
|
|
213
|
+
"siblings_checked cannot be empty - pass --siblings when the targets are not skill surfaces",
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const fm = [
|
|
217
|
+
"---",
|
|
218
|
+
`id: ${nextNum}`,
|
|
219
|
+
`title: ${JSON.stringify(title)}`,
|
|
220
|
+
"status: open",
|
|
221
|
+
`target: [${targets.map((t) => JSON.stringify(t)).join(", ")}]`,
|
|
222
|
+
repeated.proposes.length
|
|
223
|
+
? `proposes_skill: [${repeated.proposes.map((p) => JSON.stringify(p)).join(", ")}]`
|
|
224
|
+
: null,
|
|
225
|
+
`siblings_checked: ${JSON.stringify(siblings)}`,
|
|
226
|
+
`area: ${JSON.stringify(area)}`,
|
|
227
|
+
`date: ${today()}`,
|
|
228
|
+
flags.session && flags.session !== "true"
|
|
229
|
+
? `session_context: ${JSON.stringify(flags.session)}`
|
|
230
|
+
: null,
|
|
231
|
+
"---",
|
|
232
|
+
"",
|
|
233
|
+
flags.body && flags.body !== "true" ? flags.body : "(no detail recorded)",
|
|
234
|
+
"",
|
|
235
|
+
]
|
|
236
|
+
.filter((l) => l !== null)
|
|
237
|
+
.join("\n");
|
|
238
|
+
|
|
239
|
+
const file = `${nextNum}-${slugify(title)}.md`;
|
|
240
|
+
writeFileSync(join(OBS, file), fm);
|
|
241
|
+
process.stdout.write(JSON.stringify({ ok: true, id: nextNum, file: join(OBS, file) }) + "\n");
|
|
242
|
+
process.exitCode = 0;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (SUB === "scan" || SUB === "stats") {
|
|
246
|
+
const files = listFiles();
|
|
247
|
+
const parsed = [];
|
|
248
|
+
for (const f of files) {
|
|
249
|
+
const fm = readFrontmatter(f);
|
|
250
|
+
if (fm) parsed.push({ file: f, ...fm });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// THE GUARD. Files on disk but nothing parsed means the reader is broken, not
|
|
254
|
+
// that the queue is empty - and reporting "0 open" for a broken reader is the
|
|
255
|
+
// failure mode this whole store is meant to avoid.
|
|
256
|
+
if (files.length > 0 && parsed.length === 0) {
|
|
257
|
+
process.stderr.write(
|
|
258
|
+
`SCAN BROKEN: ${files.length} observation file(s) on disk, 0 parsed.\n` +
|
|
259
|
+
`The frontmatter reader is not matching what is written. This is a defect, not an empty queue.\n`,
|
|
260
|
+
);
|
|
261
|
+
process.exit(3);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (SUB === "stats") {
|
|
265
|
+
const byStatus = {};
|
|
266
|
+
for (const p of parsed) byStatus[p.status || "?"] = (byStatus[p.status || "?"] || 0) + 1;
|
|
267
|
+
const out = { total: parsed.length, byStatus, path: OBS };
|
|
268
|
+
process.stdout.write(
|
|
269
|
+
flags.json
|
|
270
|
+
? JSON.stringify(out, null, 2) + "\n"
|
|
271
|
+
: `${out.total} observation(s)\n` +
|
|
272
|
+
Object.entries(byStatus)
|
|
273
|
+
.map(([k, v]) => ` ${k}: ${v}`)
|
|
274
|
+
.join("\n") +
|
|
275
|
+
"\n",
|
|
276
|
+
);
|
|
277
|
+
process.exit(0);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const want = flags.status && flags.status !== "true" ? flags.status : null;
|
|
281
|
+
const rows = want ? parsed.filter((p) => p.status === want) : parsed;
|
|
282
|
+
if (flags.json) {
|
|
283
|
+
process.stdout.write(
|
|
284
|
+
JSON.stringify({ count: rows.length, scanned: files.length, observations: rows }, null, 2) +
|
|
285
|
+
"\n",
|
|
286
|
+
);
|
|
287
|
+
} else if (rows.length === 0) {
|
|
288
|
+
process.stdout.write(
|
|
289
|
+
`no observations${want ? ` with status ${want}` : ""} (${files.length} scanned)\n`,
|
|
290
|
+
);
|
|
291
|
+
} else {
|
|
292
|
+
for (const r of rows) {
|
|
293
|
+
process.stdout.write(
|
|
294
|
+
`${r.id} ${String(r.status).padEnd(10)} ${r.area ? `[${r.area}] ` : ""}${r.title}\n`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
process.exitCode = 0;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (SUB === "resolve") {
|
|
302
|
+
const id = flags.id;
|
|
303
|
+
const status = flags.status;
|
|
304
|
+
if (!id || id === "true") die("resolve needs --id");
|
|
305
|
+
const allowed = ["actioned", "declined", "superseded", "parked", "open"];
|
|
306
|
+
if (!allowed.includes(status)) die(`resolve needs --status one of: ${allowed.join(", ")}`);
|
|
307
|
+
// A park with no named event is a deferral in disguise: it leaves the queue
|
|
308
|
+
// and never comes back, which is a silent decline wearing a friendlier word.
|
|
309
|
+
if (status === "parked" && (!flags["parked-until"] || flags["parked-until"] === "true")) {
|
|
310
|
+
die("parked needs --parked-until naming the event that unblocks it");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const file = listFiles().find((f) => f.startsWith(`${id}-`));
|
|
314
|
+
if (!file) die(`no observation with id ${id}`);
|
|
315
|
+
const p = join(OBS, file);
|
|
316
|
+
let text = readFileSync(p, "utf8");
|
|
317
|
+
|
|
318
|
+
const setField = (k, v) => {
|
|
319
|
+
const re = new RegExp(`^${k}:.*$`, "m");
|
|
320
|
+
// Dates go in bare, matching how `add` writes `date:`. Quoting one and not
|
|
321
|
+
// the other is how a store ends up with a reader that handles one form.
|
|
322
|
+
const bare = typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v);
|
|
323
|
+
const line = `${k}: ${bare || typeof v !== "string" ? v : JSON.stringify(v)}`;
|
|
324
|
+
text = re.test(text)
|
|
325
|
+
? text.replace(re, line)
|
|
326
|
+
: text.replace(/^status:.*$/m, (m) => `${m}\n${line}`);
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
text = text.replace(/^status:.*$/m, `status: ${status}`);
|
|
330
|
+
if (status !== "open") setField("resolved", today());
|
|
331
|
+
if (flags.resolution && flags.resolution !== "true") setField("resolution", flags.resolution);
|
|
332
|
+
if (flags["parked-until"] && flags["parked-until"] !== "true")
|
|
333
|
+
setField("parked_until", flags["parked-until"]);
|
|
334
|
+
if (flags.reference && flags.reference !== "true") setField("reference", flags.reference);
|
|
335
|
+
writeFileSync(p, text);
|
|
336
|
+
process.stdout.write(JSON.stringify({ ok: true, id, status, file: p }) + "\n");
|
|
337
|
+
process.exitCode = 0;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Guarded by an explicit list, not by reachability. The subcommand blocks above
|
|
341
|
+
// no longer exit after writing (stdout to a pipe is async, and exiting
|
|
342
|
+
// truncates it), so control now falls through to here on a HANDLED subcommand
|
|
343
|
+
// too - and an unguarded usage error would fire on every successful run.
|
|
344
|
+
const HANDLED = new Set(["add", "scan", "stats", "resolve", "path"]);
|
|
345
|
+
if (!HANDLED.has(SUB)) {
|
|
346
|
+
die("usage: observations.mjs <add|scan|resolve|stats|path> [flags]");
|
|
347
|
+
}
|
|
@@ -190,6 +190,49 @@ FENCE=$(printf '%*s' "$fence_len" '' | tr ' ' '`')
|
|
|
190
190
|
KB=$(( (BYTES + 1023) / 1024 ))
|
|
191
191
|
printf '[[ref:%s]] %s - %s lines, %s KB, full text: %s\n' \
|
|
192
192
|
"$NODE_ID" "$LABEL" "$LINES" "$KB" "${REF_FILE#"$ROOT"/}"
|
|
193
|
-
|
|
194
|
-
tail
|
|
193
|
+
|
|
194
|
+
# For a log, the tail IS the stub: the failure is at the bottom. For JSON it is
|
|
195
|
+
# close to worthless - the last twenty lines of a 200-element array are the final
|
|
196
|
+
# element and some brackets, which say nothing about the 199 above. So a payload
|
|
197
|
+
# that parses as JSON gets a summary instead: field shape, outliers, and every
|
|
198
|
+
# element carrying an error, with the repetition dropped.
|
|
199
|
+
#
|
|
200
|
+
# Everything about this is fail-open. crush-json.mjs returns its input unchanged
|
|
201
|
+
# on anything that is not JSON, on any internal error, and on any result that is
|
|
202
|
+
# not meaningfully smaller - so the worst case is the tail we would have printed
|
|
203
|
+
# anyway.
|
|
204
|
+
CRUSHER=""
|
|
205
|
+
for c in "$(dirname "${BASH_SOURCE[0]:-$0}")/crush-json.mjs" "$HOME/.claude/scripts/crush-json.mjs"; do
|
|
206
|
+
[ -f "$c" ] && { CRUSHER="$c"; break; }
|
|
207
|
+
done
|
|
208
|
+
|
|
209
|
+
# The EXIT CODE decides, not the length. crush-json.mjs exits 0 only when it
|
|
210
|
+
# actually summarised and 3 when it passed the payload through - and length is
|
|
211
|
+
# useless here because command substitution strips trailing newlines, so a
|
|
212
|
+
# passthrough always looks a byte or two "shorter". Reading it that way meant
|
|
213
|
+
# every non-JSON payload was treated as crushed and the stub carried the whole
|
|
214
|
+
# log it existed to replace.
|
|
215
|
+
CRUSHED=""
|
|
216
|
+
CRUSH_RC=1
|
|
217
|
+
if [ -n "$CRUSHER" ] && command -v node >/dev/null 2>&1; then
|
|
218
|
+
CRUSHED=$(node "$CRUSHER" "$BUF" 2>/dev/null) || CRUSH_RC=$?
|
|
219
|
+
[ -n "$CRUSHED" ] && [ "$CRUSH_RC" = "1" ] && CRUSH_RC=0
|
|
220
|
+
fi
|
|
221
|
+
|
|
222
|
+
if [ "$CRUSH_RC" -eq 0 ] && [ -n "$CRUSHED" ]; then
|
|
223
|
+
printf 'Structural summary follows (errors and outliers kept). Read the file above for the rest.\n'
|
|
224
|
+
printf '%s\n' "$CRUSHED"
|
|
225
|
+
# Record what the summary saved, so the value of this path is measurable
|
|
226
|
+
# rather than asserted. A metric nobody emits is a column that reads "-"
|
|
227
|
+
# forever and quietly justifies whatever anyone wants to believe.
|
|
228
|
+
LOGGER="$(dirname "${BASH_SOURCE[0]:-$0}")/log-metric.sh"
|
|
229
|
+
[ -f "$LOGGER" ] || LOGGER="$HOME/.claude/scripts/log-metric.sh"
|
|
230
|
+
if [ -f "$LOGGER" ]; then
|
|
231
|
+
bash "$LOGGER" "${TASK_ID:-unknown}" "${PHASE:-0}" offload.crushed \
|
|
232
|
+
saved_bytes=$(( BYTES - ${#CRUSHED} )) original_bytes="$BYTES" >/dev/null 2>&1 || true
|
|
233
|
+
fi
|
|
234
|
+
else
|
|
235
|
+
printf 'Last %s lines follow. Read the file above for the rest.\n' "$TAIL_LINES"
|
|
236
|
+
tail -n "$TAIL_LINES" "$BUF"
|
|
237
|
+
fi
|
|
195
238
|
exit 0
|
|
@@ -15,7 +15,26 @@ set -uo pipefail
|
|
|
15
15
|
# fall through to the full scan. Extraction failure also falls through - the
|
|
16
16
|
# only cost is an unnecessary scan, never a skipped one on a real commit.
|
|
17
17
|
SCAN_UNSTAGED=0
|
|
18
|
-
|
|
18
|
+
# --scan-file <path>: run the detectors over ONE file that git knows nothing
|
|
19
|
+
# about, then exit on the result. It exists so callers outside the commit path
|
|
20
|
+
# can reuse these patterns instead of copying them - learn-from-transcripts.mjs
|
|
21
|
+
# runs every candidate learning through here before it can reach the ledger,
|
|
22
|
+
# because a transcript is the least-redacted artefact on the machine and
|
|
23
|
+
# anything mined from one has to be proven clean first.
|
|
24
|
+
#
|
|
25
|
+
# Copying the regexes into the caller was the alternative, and it is the shape
|
|
26
|
+
# that rots: the copy stops matching a pattern this file later gains, and the
|
|
27
|
+
# gap stays invisible until something leaks.
|
|
28
|
+
#
|
|
29
|
+
# The flag is READ here, before the hook-mode stdin read below (which blocks on
|
|
30
|
+
# a stdin that never closes), and ACTED ON after scan_file is defined.
|
|
31
|
+
SCAN_FILE_TARGET=""
|
|
32
|
+
if [ "${1:-}" = "--scan-file" ]; then
|
|
33
|
+
SCAN_FILE_TARGET="${2:-}"
|
|
34
|
+
[ -f "$SCAN_FILE_TARGET" ] || { echo "ERR: --scan-file needs an existing path" >&2; exit 1; }
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
if [ -z "$SCAN_FILE_TARGET" ] && [ ! -t 0 ]; then
|
|
19
38
|
HOOK_INPUT="$(cat 2>/dev/null || true)"
|
|
20
39
|
if [ -n "$HOOK_INPUT" ]; then
|
|
21
40
|
HOOK_COMMAND=""
|
|
@@ -209,6 +228,17 @@ scan_file() {
|
|
|
209
228
|
fi
|
|
210
229
|
}
|
|
211
230
|
|
|
231
|
+
if [ -n "$SCAN_FILE_TARGET" ]; then
|
|
232
|
+
is_binary "$SCAN_FILE_TARGET" && exit 0
|
|
233
|
+
scan_file "$SCAN_FILE_TARGET" "$(cat "$SCAN_FILE_TARGET" 2>/dev/null)"
|
|
234
|
+
if [ "$FOUND" -eq 1 ]; then
|
|
235
|
+
echo "" >&2
|
|
236
|
+
echo "Secret check failed on $SCAN_FILE_TARGET." >&2
|
|
237
|
+
exit 2
|
|
238
|
+
fi
|
|
239
|
+
exit 0
|
|
240
|
+
fi
|
|
241
|
+
|
|
212
242
|
# NUL-delimited so filenames with spaces are scanned (a space-split loop would
|
|
213
243
|
# silently skip them - a secret false-negative).
|
|
214
244
|
|
|
@@ -85,10 +85,19 @@ for _event, entries in (t.get("hooks") or {}).items():
|
|
|
85
85
|
for e in entries:
|
|
86
86
|
for h in e.get("hooks", []):
|
|
87
87
|
c = h.get("command", "")
|
|
88
|
-
#
|
|
89
|
-
|
|
88
|
+
# Allow only `bash $HOME/.claude/scripts/<name>` plus plain long
|
|
89
|
+
# flags. Flags had to be admitted because a hook that cannot take
|
|
90
|
+
# one cannot be correct: SessionEnd runs capture-flush with
|
|
91
|
+
# --if-stale, and without it every session end would re-flush a run
|
|
92
|
+
# that already finished. What stays banned is everything that could
|
|
93
|
+
# turn a flag into a second command - no spaces inside a token, no
|
|
94
|
+
# quotes, no metacharacters, and the eval/substitution check below
|
|
95
|
+
# still runs on the whole string.
|
|
96
|
+
if not re.fullmatch(
|
|
97
|
+
r'bash \$HOME/\.claude/scripts/[A-Za-z0-9._-]+(?: --[a-z0-9][a-z0-9-]*)*',
|
|
98
|
+
c.strip()):
|
|
90
99
|
bad.append(c)
|
|
91
|
-
if re.search(r'\beval\b|\$\(
|
|
100
|
+
if re.search(r'\beval\b|\$\(|`|[;&|><]', c):
|
|
92
101
|
bad.append(c)
|
|
93
102
|
print("\n".join(bad))
|
|
94
103
|
PY
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @file skill-siblings.mjs - every copy of one skill, resolved mechanically.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS
|
|
6
|
+
*
|
|
7
|
+
* A command in this tree is never one file. `/multi-agent:refactor` is authored
|
|
8
|
+
* at `pipeline/commands/multi-agent/refactor/SKILL.md`, mirrored into
|
|
9
|
+
* `pipeline/skills/shared/core/multi-agent-refactor/SKILL.md`, and installed a
|
|
10
|
+
* third and fourth time into the Copilot and Codex trees by the sync chain. A
|
|
11
|
+
* fix applied to the copy that happened to be open drifts from the other three
|
|
12
|
+
* silently: nothing errors, the gates stay green, and the behaviour differs by
|
|
13
|
+
* host until someone notices months later.
|
|
14
|
+
*
|
|
15
|
+
* So "did you check the siblings?" is a question with a mechanical answer, and
|
|
16
|
+
* this prints it. observations.mjs fills its `siblings_checked` field from here
|
|
17
|
+
* rather than from a model's recollection, because recollection is exactly the
|
|
18
|
+
* faculty that produced the drift.
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* ./skill-siblings.mjs <path-or-name> human-readable
|
|
22
|
+
* ./skill-siblings.mjs <path-or-name> --json
|
|
23
|
+
* ./skill-siblings.mjs --audit every command, sibling status
|
|
24
|
+
* ./skill-siblings.mjs --audit --json
|
|
25
|
+
*
|
|
26
|
+
* `<path-or-name>` takes any of: a repo-relative or absolute path to a SKILL.md,
|
|
27
|
+
* the directory holding one, a bare command name (`refactor`), or the installed
|
|
28
|
+
* form (`multi-agent-refactor`).
|
|
29
|
+
*
|
|
30
|
+
* Exit codes:
|
|
31
|
+
* 0 resolved (or: audit found every command's authored sibling present)
|
|
32
|
+
* 1 bad usage / unresolvable name
|
|
33
|
+
* 2 audit found at least one command whose in-repo sibling is missing
|
|
34
|
+
*
|
|
35
|
+
* The installed trees (Copilot, Codex) are REPORTED but never make the audit
|
|
36
|
+
* fail: they are generated by `/multi-agent:sync` and their absence means "not
|
|
37
|
+
* synced yet on this machine", which is a fact about the machine and not a
|
|
38
|
+
* defect in the repo.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
42
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
43
|
+
import { homedir } from "node:os";
|
|
44
|
+
import { fileURLToPath } from "node:url";
|
|
45
|
+
|
|
46
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
47
|
+
const ROOT = resolve(HERE, "..", "..");
|
|
48
|
+
const HOME = homedir();
|
|
49
|
+
|
|
50
|
+
const CMD_DIR = join(ROOT, "pipeline", "commands", "multi-agent");
|
|
51
|
+
const CORE_DIR = join(ROOT, "pipeline", "skills", "shared", "core");
|
|
52
|
+
const COPILOT_DIR = join(HOME, ".copilot", "skills");
|
|
53
|
+
const CODEX_DIR = join(HOME, ".codex", "multi-agent-refs", "commands");
|
|
54
|
+
// The dispatcher is the one skill Codex installs as a SKILL: the 51 sub-commands
|
|
55
|
+
// become refs there, because Codex truncates a large skills block. Resolving it
|
|
56
|
+
// under the refs tree like the others reported a false "absent" - and a sibling
|
|
57
|
+
// check that invents a missing copy is worse than none, since it sends the next
|
|
58
|
+
// change to a path that should not exist.
|
|
59
|
+
const CODEX_ROUTER = join(HOME, ".codex", "skills", "multi-agent", "SKILL.md");
|
|
60
|
+
const CLAUDE_DIR = join(HOME, ".claude", "commands", "multi-agent");
|
|
61
|
+
|
|
62
|
+
const argv = process.argv.slice(2);
|
|
63
|
+
const JSON_OUT = argv.includes("--json");
|
|
64
|
+
const AUDIT = argv.includes("--audit");
|
|
65
|
+
const TARGET = argv.find((a) => !a.startsWith("--"));
|
|
66
|
+
|
|
67
|
+
function die(msg, code = 1) {
|
|
68
|
+
process.stderr.write(`ERR: ${msg}\n`);
|
|
69
|
+
process.exit(code);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Every command that actually ships, read from disk at call time. */
|
|
73
|
+
function commandNames() {
|
|
74
|
+
if (!existsSync(CMD_DIR)) return [];
|
|
75
|
+
return readdirSync(CMD_DIR)
|
|
76
|
+
.filter((d) => {
|
|
77
|
+
const p = join(CMD_DIR, d);
|
|
78
|
+
return statSync(p).isDirectory() && existsSync(join(p, "SKILL.md"));
|
|
79
|
+
})
|
|
80
|
+
.sort();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Reduce whatever the caller passed to a bare command name.
|
|
85
|
+
*
|
|
86
|
+
* A path is the common case (an observation records the file it was about), but
|
|
87
|
+
* the same skill arrives under four different names depending on which tree the
|
|
88
|
+
* caller was looking at, so all four forms resolve to the same answer.
|
|
89
|
+
*/
|
|
90
|
+
function toName(input) {
|
|
91
|
+
let s = String(input).trim();
|
|
92
|
+
if (s.endsWith("SKILL.md")) s = dirname(s);
|
|
93
|
+
s = s.replace(/\/+$/, "");
|
|
94
|
+
let name = basename(s);
|
|
95
|
+
if (name === "multi-agent" && s.includes("commands")) return "multi-agent";
|
|
96
|
+
name = name.replace(/^multi-agent-/, "");
|
|
97
|
+
return name;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function siblingsOf(name) {
|
|
101
|
+
const authored =
|
|
102
|
+
name === "multi-agent" ? join(CMD_DIR, "SKILL.md") : join(CMD_DIR, name, "SKILL.md");
|
|
103
|
+
const shared = join(
|
|
104
|
+
CORE_DIR,
|
|
105
|
+
name === "multi-agent" ? "multi-agent" : `multi-agent-${name}`,
|
|
106
|
+
"SKILL.md",
|
|
107
|
+
);
|
|
108
|
+
const installedName = name === "multi-agent" ? "multi-agent" : `multi-agent-${name}`;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
name,
|
|
112
|
+
surfaces: [
|
|
113
|
+
{ tree: "command (authored)", path: authored, exists: existsSync(authored), inRepo: true },
|
|
114
|
+
{ tree: "shared core skill", path: shared, exists: existsSync(shared), inRepo: true },
|
|
115
|
+
{
|
|
116
|
+
tree: "claude (installed)",
|
|
117
|
+
path: join(CLAUDE_DIR, name === "multi-agent" ? "SKILL.md" : join(name, "SKILL.md")),
|
|
118
|
+
exists: false,
|
|
119
|
+
inRepo: false,
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
tree: "copilot (installed)",
|
|
123
|
+
path: join(COPILOT_DIR, installedName, "SKILL.md"),
|
|
124
|
+
exists: false,
|
|
125
|
+
inRepo: false,
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
tree: "codex (installed)",
|
|
129
|
+
path: name === "multi-agent" ? CODEX_ROUTER : join(CODEX_DIR, name, "SKILL.md"),
|
|
130
|
+
exists: false,
|
|
131
|
+
inRepo: false,
|
|
132
|
+
},
|
|
133
|
+
].map((s) => ({ ...s, exists: existsSync(s.path) })),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (AUDIT) {
|
|
138
|
+
const names = commandNames();
|
|
139
|
+
if (names.length === 0) die("no commands found - the tree layout has changed", 1);
|
|
140
|
+
const rows = names.map((n) => {
|
|
141
|
+
const r = siblingsOf(n);
|
|
142
|
+
const missingRepo = r.surfaces.filter((s) => s.inRepo && !s.exists).map((s) => s.tree);
|
|
143
|
+
const missingInstalled = r.surfaces.filter((s) => !s.inRepo && !s.exists).map((s) => s.tree);
|
|
144
|
+
return { name: n, missingRepo, missingInstalled };
|
|
145
|
+
});
|
|
146
|
+
const broken = rows.filter((r) => r.missingRepo.length > 0);
|
|
147
|
+
if (JSON_OUT) {
|
|
148
|
+
process.stdout.write(JSON.stringify({ commands: names.length, broken, rows }, null, 2) + "\n");
|
|
149
|
+
} else {
|
|
150
|
+
process.stdout.write(`${names.length} command(s) checked\n`);
|
|
151
|
+
if (broken.length === 0) {
|
|
152
|
+
process.stdout.write(" every command has its authored sibling in the repo\n");
|
|
153
|
+
}
|
|
154
|
+
for (const r of broken) {
|
|
155
|
+
process.stdout.write(` MISSING ${r.name}: ${r.missingRepo.join(", ")}\n`);
|
|
156
|
+
}
|
|
157
|
+
const unsynced = rows.filter((r) => r.missingInstalled.length > 0).length;
|
|
158
|
+
if (unsynced > 0) {
|
|
159
|
+
process.stdout.write(
|
|
160
|
+
` ${unsynced} command(s) not present in every installed tree (run /multi-agent:sync)\n`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
process.exit(broken.length > 0 ? 2 : 0);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!TARGET) die("usage: skill-siblings.mjs <path-or-name> [--json] | --audit [--json]", 1);
|
|
168
|
+
|
|
169
|
+
const name = toName(TARGET);
|
|
170
|
+
const result = siblingsOf(name);
|
|
171
|
+
if (!result.surfaces.some((s) => s.exists)) {
|
|
172
|
+
die(`no surface found for "${TARGET}" (resolved to "${name}")`, 1);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (JSON_OUT) {
|
|
176
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
177
|
+
} else {
|
|
178
|
+
process.stdout.write(`${name}\n`);
|
|
179
|
+
for (const s of result.surfaces) {
|
|
180
|
+
const mark = s.exists ? "found " : "absent ";
|
|
181
|
+
const rel = s.path.startsWith(ROOT) ? s.path.slice(ROOT.length + 1) : s.path.replace(HOME, "~");
|
|
182
|
+
process.stdout.write(` ${mark} ${s.tree.padEnd(20)} ${rel}\n`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// exitCode rather than process.exit(): the sibling table can be long, and
|
|
186
|
+
// exiting straight after writing it truncates the write on a pipe.
|
|
187
|
+
process.exitCode = 0;
|