@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,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
- printf 'Last %s lines follow. Read the file above for the rest.\n' "$TAIL_LINES"
194
- tail -n "$TAIL_LINES" "$BUF"
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
- if [ ! -t 0 ]; then
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
- # allow only: `bash $HOME/.claude/scripts/<name>` (our vetted scripts)
89
- if not re.fullmatch(r'bash \$HOME/\.claude/scripts/[A-Za-z0-9._-]+', c.strip()):
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|\$\(|`', c):
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;