@mmerterden/multi-agent-pipeline 20.27.2 → 20.28.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 (65) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +6 -5
  3. package/README.tr.md +6 -5
  4. package/docs/adr/0008-installer-modularization-and-secret-leak-defense.md +0 -1
  5. package/docs/architecture.md +4 -4
  6. package/docs/ecosystem.md +5 -5
  7. package/docs/facts.json +14 -12
  8. package/docs/features.md +2 -0
  9. package/install/_dev-only-files.mjs +1 -0
  10. package/install/catalog-history.json +1 -1
  11. package/install/index.mjs +0 -2
  12. package/manifest.json +67 -44
  13. package/package.json +4 -1
  14. package/pipeline/agents/code-reviewer.md +1 -0
  15. package/pipeline/agents/test-gen-analyst.md +98 -0
  16. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  17. package/pipeline/commands/multi-agent/test-gen/SKILL.md +124 -0
  18. package/pipeline/multi-agent-refs/cross-cli-contract.md +11 -5
  19. package/pipeline/multi-agent-refs/features/doctor.md +13 -0
  20. package/pipeline/multi-agent-refs/features/test-classification.md +76 -0
  21. package/pipeline/multi-agent-refs/features/usage-reporting.md +17 -0
  22. package/pipeline/multi-agent-refs/help/en.md +1 -0
  23. package/pipeline/multi-agent-refs/help/tr.md +1 -0
  24. package/pipeline/multi-agent-refs/phases/operations.md +2 -2
  25. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +6 -7
  26. package/pipeline/multi-agent-refs/phases/phase-3-review.md +3 -3
  27. package/pipeline/multi-agent-refs/phases/phase-5-report.md +6 -5
  28. package/pipeline/multi-agent-refs/test-gen/stages.md +227 -0
  29. package/pipeline/preferences-template.json +3 -0
  30. package/pipeline/schemas/prefs.schema.json +14 -0
  31. package/pipeline/schemas/test-gen-discovery.schema.json +73 -0
  32. package/pipeline/schemas/test-gen-gaps.schema.json +122 -0
  33. package/pipeline/schemas/test-gen-inventory.schema.json +59 -0
  34. package/pipeline/schemas/test-gen-results.schema.json +44 -0
  35. package/pipeline/scripts/_handle-patterns.mjs +166 -0
  36. package/pipeline/scripts/_http-post.mjs +141 -0
  37. package/pipeline/scripts/_stack-routing.mjs +4 -0
  38. package/pipeline/scripts/_test-gaming.mjs +227 -0
  39. package/pipeline/scripts/_unified-diff.mjs +104 -0
  40. package/pipeline/scripts/_worktree-snapshot.mjs +113 -0
  41. package/pipeline/scripts/doctor.mjs +51 -0
  42. package/pipeline/scripts/evidence-gate.mjs +74 -3
  43. package/pipeline/scripts/handle-diff-audit.mjs +188 -0
  44. package/pipeline/scripts/routine-registry.mjs +5 -1
  45. package/pipeline/scripts/run-ui-tests.sh +27 -3
  46. package/pipeline/scripts/test-classify.mjs +296 -0
  47. package/pipeline/scripts/test-gen-gate.mjs +409 -0
  48. package/pipeline/scripts/test-gen-state.mjs +313 -0
  49. package/pipeline/scripts/test-integrity-gate.mjs +76 -11
  50. package/pipeline/scripts/uninstall.mjs +1 -0
  51. package/pipeline/scripts/usage-register-beacon.mjs +239 -0
  52. package/pipeline/scripts/usage-register.mjs +128 -33
  53. package/pipeline/scripts/usage-report.mjs +43 -33
  54. package/pipeline/skills/.skill-manifest.json +23 -3
  55. package/pipeline/skills/.skills-index.json +57 -2
  56. package/pipeline/skills/shared/README.md +10 -5
  57. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  58. package/pipeline/skills/shared/core/multi-agent-test-gen/SKILL.md +120 -0
  59. package/pipeline/skills/shared/external/fix-review-verdicts/SKILL.md +133 -0
  60. package/pipeline/skills/shared/external/handle-request/SKILL.md +97 -0
  61. package/pipeline/skills/shared/external/scenario-gap-analysis/SKILL.md +129 -0
  62. package/pipeline/skills/shared/external/scenario-gap-analysis/references/scenario-schema.md +60 -0
  63. package/pipeline/skills/shared/external/test-result-triage/SKILL.md +160 -0
  64. package/pipeline/skills/skills-index.md +7 -2
  65. package/install/_telemetry.mjs +0 -61
@@ -52,7 +52,16 @@
52
52
  * @module pipeline/scripts/usage-register
53
53
  */
54
54
 
55
- import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
55
+ import { postJson as postHttp } from "./_http-post.mjs";
56
+ import {
57
+ existsSync,
58
+ mkdirSync,
59
+ mkdtempSync,
60
+ readFileSync,
61
+ renameSync,
62
+ rmSync,
63
+ writeFileSync,
64
+ } from "node:fs";
56
65
  import { spawn, spawnSync } from "node:child_process";
57
66
  import { randomBytes } from "node:crypto";
58
67
  import { join } from "node:path";
@@ -70,6 +79,7 @@ import {
70
79
  sshKeygenBin,
71
80
  } from "./usage-identity.mjs";
72
81
  import { lockTimeoutFrom, updateJsonFileSync } from "../lib/json-file-lock.mjs";
82
+ import { BEACON_ENV, beaconUrl, httpReason, transportReason } from "./usage-register-beacon.mjs";
73
83
 
74
84
  const DEFAULT_ENDPOINT = "https://mmerterden.com/api/usage/ingest";
75
85
  const BUILT_IN_HOSTS = ["mmerterden.com"];
@@ -196,30 +206,39 @@ export function registerUrlAllowed(url, trustedHosts = []) {
196
206
  return trusted.has(u.hostname.toLowerCase());
197
207
  }
198
208
 
209
+ // Transport goes through _http-post.mjs, which retries a connection-level
210
+ // failure through curl (proxy variables, system trust store). The failure text
211
+ // keeps both causes so a blocked corporate network is diagnosable.
199
212
  async function postJson(url, body, bearer = null) {
200
- const ctrl = new AbortController();
201
- const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
202
- const headers = { "Content-Type": "application/json" };
203
- if (bearer) headers.Authorization = `Bearer ${bearer}`;
204
- try {
205
- const res = await fetch(url, {
206
- method: "POST",
207
- headers,
208
- body: JSON.stringify(body),
209
- redirect: "error",
210
- signal: ctrl.signal,
211
- });
212
- const json = await res.json().catch(() => null);
213
- return { res, json, failure: null };
214
- } catch (e) {
213
+ const headers = bearer ? { Authorization: `Bearer ${bearer}` } : {};
214
+ const r = await postHttp({ url, headers, body: JSON.stringify(body), timeoutMs: TIMEOUT_MS });
215
+ if (r.failure) {
216
+ const causes = [r.fetchFailure && `fetch ${r.fetchFailure}`, `curl ${r.failure}`]
217
+ .filter(Boolean)
218
+ .join(", ");
219
+ const what = r.fetchFailure === "timeout" ? "endpoint timed out" : "endpoint unreachable";
215
220
  return {
216
221
  res: null,
217
222
  json: null,
218
- failure: e?.name === "AbortError" ? "endpoint timed out" : "endpoint unreachable",
223
+ failure: `${what} (${causes})`,
224
+ reason: transportReason(r.fetchFailure, r.failure),
219
225
  };
220
- } finally {
221
- clearTimeout(timer);
222
226
  }
227
+ if (r.status >= 300 && r.status < 400) {
228
+ return {
229
+ res: null,
230
+ json: null,
231
+ failure: `endpoint redirected (${r.status})`,
232
+ reason: "redirected",
233
+ };
234
+ }
235
+ let json = null;
236
+ try {
237
+ json = JSON.parse(r.body);
238
+ } catch {
239
+ /* non-JSON answer: the status alone decides */
240
+ }
241
+ return { res: { ok: r.status >= 200 && r.status < 300, status: r.status }, json, failure: null };
223
242
  }
224
243
 
225
244
  const SSH_REFUSALS = {
@@ -265,11 +284,16 @@ function refusal(status, json, body, method) {
265
284
  }
266
285
 
267
286
  async function requestToken(url, body, proof, method) {
268
- const { res, json, failure } = await postJson(url, body, proof);
269
- if (failure) return { token: null, why: failure };
287
+ const { res, json, failure, reason } = await postJson(url, body, proof);
288
+ if (failure) return { token: null, why: failure, reason };
270
289
  if (!res.ok) {
271
290
  const code = typeof json?.code === "string" ? json.code : null;
272
- return { token: null, code, why: refusal(res.status, json, body, method) };
291
+ return {
292
+ token: null,
293
+ code,
294
+ why: refusal(res.status, json, body, method),
295
+ reason: httpReason(res.status, code),
296
+ };
273
297
  }
274
298
  if (json?.accepted === false)
275
299
  return { token: null, declined: true, why: "the endpoint declined this login" };
@@ -277,7 +301,7 @@ async function requestToken(url, body, proof, method) {
277
301
  const pinnedLogin = typeof json?.u === "string" && json.u.trim() ? json.u.trim() : null;
278
302
  return token
279
303
  ? { token, why: null, login: pinnedLogin }
280
- : { token: null, why: "endpoint returned no token" };
304
+ : { token: null, why: "endpoint returned no token", reason: "other" };
281
305
  }
282
306
 
283
307
  /**
@@ -323,20 +347,26 @@ export function challengeUrl(url) {
323
347
 
324
348
  async function requestTokenWithSsh(url, body, key) {
325
349
  const ch = await postJson(challengeUrl(url), { u: body.u });
326
- if (ch.failure) return { token: null, why: ch.failure };
350
+ if (ch.failure) return { token: null, why: ch.failure, reason: ch.reason };
327
351
  if (!ch.res.ok) {
328
352
  if (ch.res.status === 404 || ch.res.status === 405) {
329
353
  return {
330
354
  token: null,
331
355
  why: `the endpoint does not accept an SSH key as proof yet (challenge answered ${ch.res.status})`,
356
+ reason: "other",
332
357
  };
333
358
  }
334
- return { token: null, why: refusal(ch.res.status, ch.json, body, "ssh") };
359
+ const code = typeof ch.json?.code === "string" ? ch.json.code : null;
360
+ return {
361
+ token: null,
362
+ why: refusal(ch.res.status, ch.json, body, "ssh"),
363
+ reason: httpReason(ch.res.status, code),
364
+ };
335
365
  }
336
366
  const challenge = typeof ch.json?.challenge === "string" ? ch.json.challenge : "";
337
367
  const namespace = typeof ch.json?.namespace === "string" ? ch.json.namespace : "";
338
368
  if (!challenge || !/^[A-Za-z0-9@._-]{1,128}$/.test(namespace)) {
339
- return { token: null, why: "the endpoint returned no usable SSH challenge" };
369
+ return { token: null, why: "the endpoint returned no usable SSH challenge", reason: "other" };
340
370
  }
341
371
  const signature = sshSign(key, namespace, challenge);
342
372
  if (!signature) {
@@ -344,6 +374,7 @@ async function requestTokenWithSsh(url, body, key) {
344
374
  return {
345
375
  token: null,
346
376
  why: `could not sign with the SSH key ${shown} (it needs a passphrase and is not in ssh-agent); run \`ssh-add ${shown}\`, then retry with --refresh`,
377
+ reason: "no-proof",
347
378
  };
348
379
  }
349
380
  return requestToken(url, { ...body, ssh: { challenge, signature } }, null, "ssh");
@@ -538,6 +569,57 @@ function reportingStaysOff(status, detail) {
538
569
  return status === "skipped" && !QUIET_REASONS.has(detail);
539
570
  }
540
571
 
572
+ export const LAST_REGISTER_FILE = "usage-last-register.json";
573
+
574
+ /**
575
+ * Overwrites the record of the most recent registration attempt. Registration
576
+ * runs quietly from setup, update and Phase 0, so this file is where a machine
577
+ * that never registers shows why; doctor reads it. It holds no token.
578
+ */
579
+ function recordRegister(home, status, detail) {
580
+ const dir = join(home, ".claude", "logs", "multi-agent");
581
+ const target = join(dir, LAST_REGISTER_FILE);
582
+ const tmp = `${target}.${process.pid}.tmp`;
583
+ try {
584
+ mkdirSync(dir, { recursive: true });
585
+ const entry = {
586
+ t: new Date().toISOString(),
587
+ status,
588
+ detail: String(detail ?? "").slice(0, 300),
589
+ };
590
+ writeFileSync(tmp, JSON.stringify(entry) + "\n", { mode: 0o600 });
591
+ renameSync(tmp, target);
592
+ } catch {
593
+ /* logs dir unwritable - never break the caller */
594
+ }
595
+ }
596
+
597
+ /**
598
+ * Report a failed registration to the site, detached so it never delays or
599
+ * fails the caller (usage-register-beacon.mjs). Only for a failure the person
600
+ * did not choose, never on a dry run or for an opted-out machine, only under a
601
+ * login this run had, and only to a register URL the proof may go to.
602
+ */
603
+ function sendFailureBeacon({ home, prefsPath, login, reason, dryRun }) {
604
+ if (dryRun || !reason || typeof login !== "string" || !LOGIN_RE.test(login)) return;
605
+ const usageLog = readJson(prefsPath)?.global?.usageLog || {};
606
+ if (usageLog.optOut === true) return;
607
+ const url = registerUrl(usageLog.endpoint);
608
+ if (!registerUrlAllowed(url, usageLog.trustedHosts || [])) return;
609
+ try {
610
+ spawn(process.execPath, [join(import.meta.dirname, "usage-register-beacon.mjs")], {
611
+ detached: true,
612
+ stdio: "ignore",
613
+ env: {
614
+ ...process.env,
615
+ [BEACON_ENV]: JSON.stringify({ home, url: beaconUrl(url), login, reason }),
616
+ },
617
+ }).unref();
618
+ } catch {
619
+ // reporting never fails the caller
620
+ }
621
+ }
622
+
541
623
  async function main(argv) {
542
624
  const opts = {
543
625
  quiet: false,
@@ -566,7 +648,16 @@ async function main(argv) {
566
648
  if (!opts.quiet) process.stdout.write(`${line}\n`);
567
649
  };
568
650
  const identity = { login: undefined };
569
- const done = (status, detail) => {
651
+ const beacon = { login: null };
652
+ const done = (status, detail, reason = null) => {
653
+ if (!opts.dryRun) recordRegister(HOME, status, detail);
654
+ sendFailureBeacon({
655
+ home: HOME,
656
+ prefsPath,
657
+ login: beacon.login,
658
+ reason,
659
+ dryRun: opts.dryRun,
660
+ });
570
661
  if (opts.json) {
571
662
  const needsPersonalAccount = personalAccountMissing(readJson(prefsPath), identity.login);
572
663
  process.stdout.write(`${JSON.stringify({ status, detail, needsPersonalAccount })}\n`);
@@ -602,7 +693,8 @@ async function main(argv) {
602
693
  }
603
694
  if (!opts.dryRun && !declareLogin(prefsPath, name)) {
604
695
  say(" -> operational reporting: could not write preferences");
605
- return done("unavailable", "prefs write failed");
696
+ beacon.login = name;
697
+ return done("unavailable", "prefs write failed", "prefs-write");
606
698
  }
607
699
  opts.refresh = true;
608
700
  }
@@ -629,6 +721,7 @@ async function main(argv) {
629
721
  who = { login: declared, method: "claim", key: null, reason: "declared by the person" };
630
722
  }
631
723
  identity.login = who.login;
724
+ beacon.login = who.login || null;
632
725
  const decision = decideRegistration(current, {
633
726
  hasStoredToken: has,
634
727
  forFeedback: opts.feedback,
@@ -649,7 +742,8 @@ async function main(argv) {
649
742
  return done("enabled", decision.reason);
650
743
  }
651
744
  say(" -> operational reporting: could not write preferences");
652
- return done("unavailable", "prefs write failed");
745
+ beacon.login = beacon.login || cu.registeredAs || null;
746
+ return done("unavailable", "prefs write failed", "prefs-write");
653
747
  }
654
748
  }
655
749
  if (
@@ -702,11 +796,11 @@ async function main(argv) {
702
796
  ? `the git credential helper no longer returns a GitHub token for ${user}; retry with --refresh`
703
797
  : `gh has no token for ${user}; run \`gh auth login\` for that account`;
704
798
  say(` -> operational reporting left off (${why})`);
705
- return done("unavailable", why);
799
+ return done("unavailable", why, "no-proof");
706
800
  }
707
801
  result = await requestToken(url, { u: user, c: host }, proof, who.method || "gh");
708
802
  }
709
- const { token, why, declined, code, login: pinnedLogin } = result;
803
+ const { token, why, declined, code, reason, login: pinnedLogin } = result;
710
804
  if (who.method === "claim" && ["not_collaborator", "proof_required"].includes(code))
711
805
  declareLogin(prefsPath, null);
712
806
  else if (who.method === "claim" && !token && !declined)
@@ -717,7 +811,7 @@ async function main(argv) {
717
811
  }
718
812
  if (!token) {
719
813
  say(` -> operational reporting left off (${why})`);
720
- return done("unavailable", why);
814
+ return done("unavailable", why, reason || "other");
721
815
  }
722
816
 
723
817
  const optedOut = prefs.global?.usageLog?.optOut === true;
@@ -729,7 +823,8 @@ async function main(argv) {
729
823
  });
730
824
  if (set.status !== 0) {
731
825
  say(" -> operational reporting left off (the credential store refused the token)");
732
- return done("unavailable", "credential store write failed");
826
+ beacon.login = pinnedLogin || user;
827
+ return done("unavailable", "credential store write failed", "credential-store");
733
828
  }
734
829
  persistPrefs(prefsPath, entry, {
735
830
  enableTelemetry: !optedOut,
@@ -22,6 +22,7 @@
22
22
  * Usage:
23
23
  * node usage-report.mjs --state /path/to/agent-state.json [--event <kind>]
24
24
  * node usage-report.mjs --task-id PROJ-123 [--phase N] [--event <kind>]
25
+ * node usage-report.mjs --task-id PROJ-123 --print-state (prints the run's agent-state.json path)
25
26
  *
26
27
  * node usage-report.mjs --lifecycle (report the installed version when it changed)
27
28
  * --event phase | completed | halted | parked (default: derived from state)
@@ -29,6 +30,7 @@
29
30
  * @module pipeline/scripts/usage-report
30
31
  */
31
32
 
33
+ import { postJson } from "./_http-post.mjs";
32
34
  import {
33
35
  readFileSync,
34
36
  existsSync,
@@ -86,6 +88,7 @@ function parseArgs(argv) {
86
88
  else if (a === "--event") out.event = argv[++i];
87
89
  else if (a === "--dry-run") out.dryRun = true;
88
90
  else if (a === "--lifecycle") out.lifecycle = true;
91
+ else if (a === "--print-state") out.printState = true;
89
92
  }
90
93
  return out;
91
94
  }
@@ -737,40 +740,31 @@ function hostOf(url) {
737
740
  * instead, so a moved endpoint is visible rather than silent.
738
741
  */
739
742
  async function post(endpoint, token, event) {
740
- const ctrl = new AbortController();
741
- const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
743
+ const r = await postJson({
744
+ url: endpoint,
745
+ headers: { "X-Usage-Token": token },
746
+ body: JSON.stringify(event),
747
+ timeoutMs: TIMEOUT_MS,
748
+ });
749
+ const via = r.fetchFailure ? { transport: r.transport, fetchFailure: r.fetchFailure } : {};
750
+ if (r.failure) {
751
+ return { outcome: "failed", reason: codeOf(String(r.failure), 40), ...via };
752
+ }
753
+ if (r.status >= 300 && r.status < 400) {
754
+ return { outcome: "redirected", status: r.status, location: hostOf(r.location || ""), ...via };
755
+ }
756
+ if (r.status < 200 || r.status >= 300) return { outcome: "rejected", status: r.status, ...via };
757
+ let body = null;
742
758
  try {
743
- const res = await fetch(endpoint, {
744
- method: "POST",
745
- headers: {
746
- "Content-Type": "application/json",
747
- "X-Usage-Token": token,
748
- },
749
- body: JSON.stringify(event),
750
- redirect: "manual",
751
- signal: ctrl.signal,
752
- });
753
- if (res.status >= 300 && res.status < 400) {
754
- return {
755
- outcome: "redirected",
756
- status: res.status,
757
- location: hostOf(res.headers.get("location") || ""),
758
- };
759
- }
760
- if (!res.ok) return { outcome: "rejected", status: res.status };
761
- let body = null;
762
- try {
763
- body = await res.json();
764
- } catch {
765
- /* a 2xx without a JSON body is still a delivery */
766
- }
767
- return { outcome: body?.accepted === false ? "not-accepted" : "accepted", status: res.status };
768
- } catch (err) {
769
- const reason = ctrl.signal.aborted ? "timeout" : (err?.cause?.code ?? err?.name ?? "error");
770
- return { outcome: "failed", reason: codeOf(String(reason), 40) };
771
- } finally {
772
- clearTimeout(timer);
759
+ body = JSON.parse(r.body);
760
+ } catch {
761
+ /* a 2xx without a JSON body is still a delivery */
773
762
  }
763
+ return {
764
+ outcome: body?.accepted === false ? "not-accepted" : "accepted",
765
+ status: r.status,
766
+ ...via,
767
+ };
774
768
  }
775
769
 
776
770
  export const LAST_SEND_FILE = "usage-last-send.json";
@@ -852,6 +846,13 @@ function registeredLogin(prefs) {
852
846
  async function main() {
853
847
  const args = parseArgs(process.argv.slice(2));
854
848
  if (process.env.MULTI_AGENT_SMOKE && !args.dryRun) return;
849
+ // Resolving the state path needs no prefs and sends nothing: a shell block
850
+ // whose $STATE_FILE did not survive from an earlier command gets it back here.
851
+ if (args.printState) {
852
+ const path = resolveStatePath(args);
853
+ if (path) process.stdout.write(`${path}\n`);
854
+ return;
855
+ }
855
856
  const prefs = resolvePrefs();
856
857
  const endpoint = canonicalEndpoint(prefs.endpoint || ENDPOINT_DEFAULT);
857
858
 
@@ -883,7 +884,16 @@ async function main() {
883
884
  let state = statePath ? readJson(statePath) : null;
884
885
 
885
886
  if (!state) {
886
- if (!args.taskId) return;
887
+ if (!args.taskId) {
888
+ // An unset shell variable reaches here as --state "", and a silent return
889
+ // would make a run that never reports look exactly like one that did.
890
+ recordSend({
891
+ ev: EVENT_KINDS.includes(args.event) ? args.event : null,
892
+ outcome: "skipped",
893
+ reason: args.state === "" ? "empty-state-path" : "no-state",
894
+ });
895
+ return;
896
+ }
887
897
  state = { taskId: args.taskId };
888
898
  }
889
899
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": "1.0.0",
3
- "generatedAt": "2026-10-20T13:08:30Z",
4
- "skillCount": 229,
3
+ "generatedAt": "2026-10-24T11:39:37Z",
4
+ "skillCount": 234,
5
5
  "entries": [
6
6
  {
7
7
  "path": "shared/core/apple-archive-compliance/SKILL.md",
@@ -237,7 +237,7 @@
237
237
  },
238
238
  {
239
239
  "path": "shared/core/multi-agent-sync/SKILL.md",
240
- "sha256": "1e418fe472a93cc7b4b5bc19ff9e9b8b9d89b545e45d343a643f17a492cb0624"
240
+ "sha256": "e96afa9b89a2ab6e2fe13a01158f98272c6828e5cef03c8909240766aaefd44b"
241
241
  },
242
242
  {
243
243
  "path": "shared/core/multi-agent-test-accessibility/SKILL.md",
@@ -251,6 +251,10 @@
251
251
  "path": "shared/core/multi-agent-test-dynamic-type/SKILL.md",
252
252
  "sha256": "e1cadd5eb8d680b0edfc40a4912733dc6a432493c868f4997b2dec0db6638dd4"
253
253
  },
254
+ {
255
+ "path": "shared/core/multi-agent-test-gen/SKILL.md",
256
+ "sha256": "0da450ed0c74ab610d3c8f0cf1b0facd2a4ae179afc832440939c3367de7e762"
257
+ },
254
258
  {
255
259
  "path": "shared/core/multi-agent-test-screenshots/SKILL.md",
256
260
  "sha256": "a41507304e4650079e6359b3ad5854f90f45a90172911f487c4c923ef8839f91"
@@ -487,6 +491,10 @@
487
491
  "path": "shared/external/firebase/SKILL.md",
488
492
  "sha256": "d377217a03e7fc63bd7eba285c6e2b450f293b13c6e65680e71b022cd14872a3"
489
493
  },
494
+ {
495
+ "path": "shared/external/fix-review-verdicts/SKILL.md",
496
+ "sha256": "a728b7c87fbf175e959e23b655c626294b7a7a2949c62a37e7b3d4a172ce57d5"
497
+ },
490
498
  {
491
499
  "path": "shared/external/github-actions-templates/SKILL.md",
492
500
  "sha256": "13b6fec454c2ebf69b06fcdc00033a6dab98d6111e0346447c8d474db547f801"
@@ -495,6 +503,10 @@
495
503
  "path": "shared/external/gradle-kotlin-dsl/SKILL.md",
496
504
  "sha256": "af7b1e30e2448f43b5100b2a935bbaadde86ccd490844c901736c9270f8cc3ce"
497
505
  },
506
+ {
507
+ "path": "shared/external/handle-request/SKILL.md",
508
+ "sha256": "05ecf72d8e40eeca8dfc0913764b1188b9b1189ea098ad23b4f0c8256f2c671c"
509
+ },
498
510
  {
499
511
  "path": "shared/external/healthkit/SKILL.md",
500
512
  "sha256": "fece1986bc35925e34fed8d8cb13aa59a3577b8cedefdc02c736c0d4a5d38aff"
@@ -703,6 +715,10 @@
703
715
  "path": "shared/external/room-database/SKILL.md",
704
716
  "sha256": "45fe377469f1fb4036431194d3b525d3caaa79231808625f643468863c6e814a"
705
717
  },
718
+ {
719
+ "path": "shared/external/scenario-gap-analysis/SKILL.md",
720
+ "sha256": "fcd9f614dc18ea0d4610469f7edcf9214d3b14d65976fa3fb7c4a524330a392a"
721
+ },
706
722
  {
707
723
  "path": "shared/external/search-first/SKILL.md",
708
724
  "sha256": "c9d5ffb698bbfb76a89da6a35dfc0f1d72a31d2b4a0e9e9f3e027be7a8fddfde"
@@ -855,6 +871,10 @@
855
871
  "path": "shared/external/tailwind-css/SKILL.md",
856
872
  "sha256": "9eda0a871962f52e381781b95c948128626984a82ddbd47be53ea869cd250b7e"
857
873
  },
874
+ {
875
+ "path": "shared/external/test-result-triage/SKILL.md",
876
+ "sha256": "72b0e0049fcf549b61a189900ca8e503b779e93cd26575d7b7328ae785465e38"
877
+ },
858
878
  {
859
879
  "path": "shared/external/testing-backend/SKILL.md",
860
880
  "sha256": "d3eea1c6d1e2a40a0b66bc6460194ee313e17fc2af8f75047030b7840c2db857"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": "1.0.0",
3
- "skillCount": 229,
3
+ "skillCount": 234,
4
4
  "entries": [
5
5
  {
6
6
  "name": "accessibility-compliance-accessibility-audit",
@@ -191,7 +191,7 @@
191
191
  },
192
192
  {
193
193
  "name": "app-store-review",
194
- "description": "App Store submission readiness and rejection prevention: Review Guidelines 2.1, 2.3, 2.5.1, 3.1.1, 4.2 and 5.1.2, PrivacyInfo.xcprivacy manifests and required-reason codes, IAP and StoreKit rules, ATT consent, EU DMA distribution, entitlements and usage strings, metadata limits, screenshots, TestFli",
194
+ "description": "App Store submission readiness and rejection prevention: Review Guidelines 1.2, 2.1, 2.3, 2.3.14, 2.5.1, 2.5.19, 3.1.1, 4.2 and 5.1.2, upload floors, age rating and medical device declarations, PrivacyInfo.xcprivacy manifests and required-reason codes, IAP and StoreKit rules, ATT consent, EU DMA dis",
195
195
  "platform": null,
196
196
  "group": "external",
197
197
  "plugin": "ai-ios-toolkit",
@@ -596,6 +596,17 @@
596
596
  "triggerPaths": [],
597
597
  "relativePath": "shared/external/firebase/SKILL.md"
598
598
  },
599
+ {
600
+ "name": "fix-review-verdicts",
601
+ "description": "Independent fix review with three verdicts, test-gaming, scope and root-cause, each backed by file:line findings; the result is computed from findings, never from a claim. Use when reviewing a fix made to turn tests green. Not a full review checklist.",
602
+ "platform": null,
603
+ "group": "external",
604
+ "plugin": "ai-common-toolkit",
605
+ "invokeAs": "ai-common-toolkit:fix-review-verdicts",
606
+ "triggerKeywords": [],
607
+ "triggerPaths": [],
608
+ "relativePath": "shared/external/fix-review-verdicts/SKILL.md"
609
+ },
599
610
  {
600
611
  "name": "github-actions-templates",
601
612
  "description": "Authoring GitHub Actions workflows: triggers, matrices, caching, concurrency, least-privilege permissions, SHA pinning, OIDC, reusable workflows, composite actions, Xcode runners. Use when writing or hardening a workflow file.",
@@ -629,6 +640,17 @@
629
640
  "triggerPaths": [],
630
641
  "relativePath": "shared/external/gradle-kotlin-dsl/SKILL.md"
631
642
  },
643
+ {
644
+ "name": "handle-request",
645
+ "description": "Add the test handles a scenario needs and answer each request with ADDED, ALREADY or COULD NOT ADD plus file:line or reason. Identity values only, attribute-only diffs; iOS accessibilityIdentifier, Compose testTag, web data-testid. Use when a test needs an element handle the UI lacks.",
646
+ "platform": null,
647
+ "group": "external",
648
+ "plugin": "ai-common-toolkit",
649
+ "invokeAs": "ai-common-toolkit:handle-request",
650
+ "triggerKeywords": [],
651
+ "triggerPaths": [],
652
+ "relativePath": "shared/external/handle-request/SKILL.md"
653
+ },
632
654
  {
633
655
  "name": "healthkit",
634
656
  "description": "HealthKit on iOS, iPadOS, watchOS and visionOS: HKHealthStore authorization, sample, statistics and chart-ready collection queries, saving HKQuantitySample values, background delivery, live workouts (HKWorkoutSession, HKLiveWorkoutBuilder), HKUnit, HKQuantityTypeIdentifier. Use when reading, writing",
@@ -1685,6 +1707,17 @@
1685
1707
  "triggerPaths": [],
1686
1708
  "relativePath": "shared/core/multi-agent-test-dynamic-type/SKILL.md"
1687
1709
  },
1710
+ {
1711
+ "name": "multi-agent-test-gen",
1712
+ "description": "Write the tests an app lacks, stage by stage, each gated on its artefact: inventory, read-only gap analysis, handles, tests, runs, per-test classification. Use when screens, flows or a change need tests.",
1713
+ "platform": null,
1714
+ "group": "core",
1715
+ "plugin": null,
1716
+ "invokeAs": "multi-agent-test-gen",
1717
+ "triggerKeywords": [],
1718
+ "triggerPaths": [],
1719
+ "relativePath": "shared/core/multi-agent-test-gen/SKILL.md"
1720
+ },
1688
1721
  {
1689
1722
  "name": "multi-agent-test-screenshots",
1690
1723
  "description": "App Store screenshot set from a booted simulator / emulator in a given locale, taken as an argument and defaulting to tr. Alias pinning the screenshot scenario. Use when generating store screenshots or checking a localised build renders.",
@@ -1927,6 +1960,17 @@
1927
1960
  "triggerPaths": [],
1928
1961
  "relativePath": "shared/external/room-database/SKILL.md"
1929
1962
  },
1963
+ {
1964
+ "name": "scenario-gap-analysis",
1965
+ "description": "Set an inventory against existing tests, sort each behaviour into COVERED, WRONGLY COVERED or MISSING, and write gaps as measurable, prioritised scenarios with cross-screen flows. Use when planning tests or auditing coverage. Not for writing tests or judging a run.",
1966
+ "platform": null,
1967
+ "group": "external",
1968
+ "plugin": "ai-common-toolkit",
1969
+ "invokeAs": "ai-common-toolkit:scenario-gap-analysis",
1970
+ "triggerKeywords": [],
1971
+ "triggerPaths": [],
1972
+ "relativePath": "shared/external/scenario-gap-analysis/SKILL.md"
1973
+ },
1930
1974
  {
1931
1975
  "name": "search-first",
1932
1976
  "description": "Research-before-coding workflow. Before writing a custom implementation, check what already exists (repo utilities, installed libraries, packages, internal components) and decide adopt / extend / compose / build on evidence. Use at the start of any non-trivial feature or utility, especially when you",
@@ -2345,6 +2389,17 @@
2345
2389
  "triggerPaths": [],
2346
2390
  "relativePath": "shared/external/tailwind-css/SKILL.md"
2347
2391
  },
2392
+ {
2393
+ "name": "test-result-triage",
2394
+ "description": "Judge each test result on changed code as product-bug, stale-test, valid or ambiguous (the default), and list behaviour no test covers. stale-test needs a diff file:line, product-bug needs evidence. Use when tests ran on a new commit, PR or fix. Not for writing or editing tests.",
2395
+ "platform": null,
2396
+ "group": "external",
2397
+ "plugin": "ai-common-toolkit",
2398
+ "invokeAs": "ai-common-toolkit:test-result-triage",
2399
+ "triggerKeywords": [],
2400
+ "triggerPaths": [],
2401
+ "relativePath": "shared/external/test-result-triage/SKILL.md"
2402
+ },
2348
2403
  {
2349
2404
  "name": "testing-backend",
2350
2405
  "description": "Backend testing: pytest patterns (Python), Jest patterns (Node.js), integration testing, database fixtures/factories, API contract testing. Use when writing or reviewing backend tests: unit, integration, database and API level.",
@@ -2,24 +2,24 @@
2
2
 
3
3
  Single source of truth for skills delivered to both Claude Code (`~/.claude/skills/`) and Copilot CLI (`~/.copilot/skills/`) by the installer.
4
4
 
5
- **Total:** 229 skills (68 core + 161 external). Auto-generated by `scripts/gen-skills-index.mjs` - do not edit by hand.
5
+ **Total:** 234 skills (69 core + 165 external). Auto-generated by `scripts/gen-skills-index.mjs` - do not edit by hand.
6
6
 
7
7
  ## Directory layout
8
8
 
9
- - **`core/`** - 68 `multi-agent*` orchestration skills that are pipeline-critical. Edits here are core-code changes.
10
- - **`external/`** - 161 iOS / Android / generic skills that ship into the stack plugins. Each names its origin in `metadata.source`: `multi-agent-pipeline` when written here, or the upstream and license a `NOTICE-*.md` in this directory covers.
9
+ - **`core/`** - 69 `multi-agent*` orchestration skills that are pipeline-critical. Edits here are core-code changes.
10
+ - **`external/`** - 165 iOS / Android / generic skills that ship into the stack plugins. Each names its origin in `metadata.source`: `multi-agent-pipeline` when written here, or the upstream and license a `NOTICE-*.md` in this directory covers.
11
11
  - Install destinations (ADR-0009): Claude Code gets NO local copy of `external/` - it loads those skills from the `multi-agent-plugins` marketplace, namespaced (`ai-<stack>-toolkit:<name>`); only the two compliance catalogs from `core/` land in `~/.claude/skills/`. Copilot CLI and Codex CLI receive a flat copy filtered to the enabled stacks. `external/` remains the single authoring source that `build-stack-plugins.mjs` publishes from.
12
12
 
13
13
  Source layout is logical grouping only - skill discovery at runtime is unchanged.
14
14
 
15
15
  ## Categories
16
16
 
17
- - [Pipeline Orchestration](#pipeline-orchestration) - 68
17
+ - [Pipeline Orchestration](#pipeline-orchestration) - 69
18
18
  - [iOS / Apple Ecosystem](#ios-apple-ecosystem) - 94
19
19
  - [Android / Kotlin](#android-kotlin) - 16
20
20
  - [Web](#web) - 10
21
21
  - [Backend / API](#backend-api) - 11
22
- - [Cross-cutting](#cross-cutting) - 30
22
+ - [Cross-cutting](#cross-cutting) - 34
23
23
 
24
24
  ## Pipeline Orchestration
25
25
 
@@ -89,6 +89,7 @@ Source layout is logical grouping only - skill discovery at runtime is unchang
89
89
  | [`multi-agent-test-accessibility`](./core/multi-agent-test-accessibility/) | `core` | Accessibility audit on a booted simulator / emulator: VoiceOver labels, sub-44pt tap targets, contrast, traits. Alias pinning the accessibil |
90
90
  | [`multi-agent-test-dark-mode`](./core/multi-agent-test-dark-mode/) | `core` | Dark mode UI test on a booted simulator / emulator: walk every screen light then dark, report contrast + colour bugs. Alias pinning the dark |
91
91
  | [`multi-agent-test-dynamic-type`](./core/multi-agent-test-dynamic-type/) | `core` | Dynamic Type layout test on a booted simulator / emulator: re-walk every screen at XL through accessibility-extra-large, report truncation a |
92
+ | [`multi-agent-test-gen`](./core/multi-agent-test-gen/) | `core` | Write the tests an app lacks, stage by stage, each gated on its artefact: inventory, read-only gap analysis, handles, tests, runs, per-test |
92
93
  | [`multi-agent-test-screenshots`](./core/multi-agent-test-screenshots/) | `core` | App Store screenshot set from a booted simulator / emulator in a given locale, taken as an argument and defaulting to tr. Alias pinning the |
93
94
  | [`multi-agent-testflight-validation`](./core/multi-agent-testflight-validation/) | `core` | iOS-pinned alias of store-ready: same three gates on an iOS build, one implementation. Validates a TestFlight / App Store package, never upl |
94
95
  | [`multi-agent-uninstall`](./core/multi-agent-uninstall/) | `core` | Uninstall the pipeline from Claude Code + Copilot CLI. Keychain access tokens are always left untouched; --all-data also clears pipeline set |
@@ -264,15 +265,19 @@ Source layout is logical grouping only - skill discovery at runtime is unchang
264
265
  | [`e2e-testing`](./external/e2e-testing/) | `external` | \| |
265
266
  | [`evidence-github`](./external/evidence-github/) | `external` | Search GitHub for facts an analysis can cite - a known issue in a dependency, the PR that fixed it, what a release actually changed, prior a |
266
267
  | [`evidence-registry`](./external/evidence-registry/) | `external` | Check a package's real version, deprecation status and release notes on npm, PyPI, Maven Central or Swift Package Index. Use when a spec or |
268
+ | [`fix-review-verdicts`](./external/fix-review-verdicts/) | `external` | Independent fix review with three verdicts, test-gaming, scope and root-cause, each backed by file:line findings; the result is computed fro |
267
269
  | [`github-actions-templates`](./external/github-actions-templates/) | `external` | Authoring GitHub Actions workflows: triggers, matrices, caching, concurrency, least-privilege permissions, SHA pinning, OIDC, reusable workf |
270
+ | [`handle-request`](./external/handle-request/) | `external` | Add the test handles a scenario needs and answer each request with ADDED, ALREADY or COULD NOT ADD plus file:line or reason. Identity values |
268
271
  | [`humanizer`](./external/humanizer/) | `external` | \| |
269
272
  | [`localization-reuse-map`](./external/localization-reuse-map/) | `external` | Localization reuse map for one redesigned screen on iOS, Android and web: new keys set against each platform's legacy key, legacy values and |
270
273
  | [`pdfkit`](./external/pdfkit/) | `external` | PDFKit on iOS: PDFView display and SwiftUI wrapping, creating and editing PDFDocument and PDFPage, highlight, note, signature and form annot |
274
+ | [`scenario-gap-analysis`](./external/scenario-gap-analysis/) | `external` | Set an inventory against existing tests, sort each behaviour into COVERED, WRONGLY COVERED or MISSING, and write gaps as measurable, priorit |
271
275
  | [`search-first`](./external/search-first/) | `external` | \| |
272
276
  | [`security-review`](./external/security-review/) | `external` | Run a defensive, static security review of a diff or a repo: build a run-scoped threat model, find vulnerabilities joined to OWASP + CWE wit |
273
277
  | [`signal-community`](./external/signal-community/) | `external` | Check whether other people hit the same problem, via Stack Overflow and Hacker News (keyless) plus opt-in Reddit and X. Use when a bug repor |
274
278
  | [`skill-creator`](./external/skill-creator/) | `external` | House guide for authoring plugin skills: creating a new skill, trimming or splitting a bloated one, writing or sharpening a description, and |
275
279
  | [`spm-build-analysis`](./external/spm-build-analysis/) | `external` | Analyze Swift Package Manager dependencies, package plugins, module variants, and CI-oriented build overhead that slow Xcode builds. Use whe |
280
+ | [`test-result-triage`](./external/test-result-triage/) | `external` | Judge each test result on changed code as product-bug, stale-test, valid or ambiguous (the default), and list behaviour no test covers. stal |
276
281
  | [`valven-rules`](./external/valven-rules/) | `external` | Delivery-flow rules from metrics Valven reports: PR size, one concern per PR, tests with source, issue keys, no WIP commits, churn, PR body, |
277
282
  | [`xcode-build-benchmark`](./external/xcode-build-benchmark/) | `external` | Benchmark Xcode clean and incremental builds with repeatable inputs, timing summaries, and timestamped `.build-benchmark/` artifacts. Use wh |
278
283
  | [`xcode-build-fixer`](./external/xcode-build-fixer/) | `external` | Apply approved Xcode build optimization changes following best practices, then re-benchmark to verify improvement. Use when a developer has |