@mmerterden/multi-agent-pipeline 20.21.0 → 20.23.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 (69) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/docs/adr/0010-own-code-graph.md +3 -2
  3. package/docs/adr/0017-change-impact-from-the-graph.md +69 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/ecosystem.md +6 -5
  6. package/docs/facts.json +5 -4
  7. package/docs/features.md +2 -2
  8. package/install/_usage-lifecycle.mjs +44 -0
  9. package/install/catalog-history.json +1 -1
  10. package/install/index.mjs +2 -0
  11. package/install/templates/copilot-instructions.md +1 -0
  12. package/manifest.json +72 -65
  13. package/package.json +1 -1
  14. package/pipeline/commands/multi-agent/create-jira/SKILL.md +1 -1
  15. package/pipeline/commands/multi-agent/review/SKILL.md +2 -1
  16. package/pipeline/commands/multi-agent/setup/SKILL.md +9 -18
  17. package/pipeline/commands/multi-agent/update/SKILL.md +3 -3
  18. package/pipeline/lib/json-file-lock.mjs +24 -2
  19. package/pipeline/multi-agent-refs/_dev-context.md +15 -0
  20. package/pipeline/multi-agent-refs/analysis/evidence.md +6 -4
  21. package/pipeline/multi-agent-refs/analysis/locked.md +1 -1
  22. package/pipeline/multi-agent-refs/cross-cli-contract.md +2 -0
  23. package/pipeline/multi-agent-refs/features/analysis-jira.md +5 -0
  24. package/pipeline/multi-agent-refs/features/code-graph.md +66 -2
  25. package/pipeline/multi-agent-refs/features/product-context.md +238 -0
  26. package/pipeline/multi-agent-refs/features/usage-reporting.md +40 -23
  27. package/pipeline/multi-agent-refs/generate-issue.md +11 -1
  28. package/pipeline/multi-agent-refs/knowledge.md +20 -0
  29. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +9 -14
  30. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +1 -1
  31. package/pipeline/multi-agent-refs/phases/phase-3-review.md +7 -6
  32. package/pipeline/multi-agent-refs/platform-parity.md +23 -9
  33. package/pipeline/multi-agent-refs/setup/token-save-flow.md +24 -0
  34. package/pipeline/schemas/agent-state.schema.json +31 -0
  35. package/pipeline/schemas/analysis-spec.schema.json +14 -0
  36. package/pipeline/schemas/code-graph.schema.json +13 -2
  37. package/pipeline/schemas/diff-risk.schema.json +2 -1
  38. package/pipeline/schemas/prefs.schema.json +84 -4
  39. package/pipeline/scripts/_code-graph.mjs +188 -6
  40. package/pipeline/scripts/_diff-impact.mjs +319 -0
  41. package/pipeline/scripts/_supertypes.mjs +133 -0
  42. package/pipeline/scripts/analysis-story-body.mjs +24 -8
  43. package/pipeline/scripts/capture-flush.sh +62 -4
  44. package/pipeline/scripts/code-graph-rules/android.json +1 -0
  45. package/pipeline/scripts/code-graph-rules/ios.json +5 -0
  46. package/pipeline/scripts/code-graph-rules/node.json +1 -0
  47. package/pipeline/scripts/code-graph-rules/python.json +1 -0
  48. package/pipeline/scripts/diff-risk-score.mjs +80 -9
  49. package/pipeline/scripts/git-path.mjs +21 -0
  50. package/pipeline/scripts/graph-affected.mjs +168 -36
  51. package/pipeline/scripts/graph-confirm.mjs +140 -0
  52. package/pipeline/scripts/graph-query.mjs +16 -4
  53. package/pipeline/scripts/learnings-ledger.mjs +3 -2
  54. package/pipeline/scripts/product-context.mjs +550 -0
  55. package/pipeline/scripts/smoke-cross-cli-behavior.sh +39 -1
  56. package/pipeline/scripts/symbol-existence-gate.mjs +21 -9
  57. package/pipeline/scripts/test-gap-scan.mjs +4 -1
  58. package/pipeline/scripts/test-strength.mjs +7 -8
  59. package/pipeline/scripts/usage-identity.mjs +1 -1
  60. package/pipeline/scripts/usage-register.mjs +181 -15
  61. package/pipeline/scripts/validate-code-graph.mjs +3 -2
  62. package/pipeline/scripts/validate-diff-risk.mjs +1 -0
  63. package/pipeline/scripts/valven-gate.mjs +9 -1
  64. package/pipeline/scripts/valven-port.mjs +16 -5
  65. package/pipeline/scripts/valven-split.mjs +6 -4
  66. package/pipeline/skills/.skill-manifest.json +4 -4
  67. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +5 -1
  68. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +11 -9
  69. package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +3 -3
@@ -236,15 +236,14 @@ function prepare(opts) {
236
236
  }
237
237
 
238
238
  function changedFiles(root, base, head) {
239
- const r = git(root, ["diff", "--no-renames", "--name-status", base, head]);
239
+ const r = git(root, ["diff", "--no-renames", "--name-status", "-z", base, head]);
240
240
  if (!r.ok) throw new UsageError(`git diff failed: ${r.err}`);
241
- return r.out
242
- .split("\n")
243
- .filter(Boolean)
244
- .map((line) => {
245
- const [status, ...rest] = line.split("\t");
246
- return { status: status[0], path: rest.join("\t") };
247
- });
241
+ const fields = r.out.split("\0");
242
+ const out = [];
243
+ for (let i = 0; i + 1 < fields.length; i += 2) {
244
+ if (fields[i]) out.push({ status: fields[i][0], path: fields[i + 1] });
245
+ }
246
+ return out;
248
247
  }
249
248
 
250
249
  function testCommandFor(opts, adapter, worktree, file) {
@@ -67,7 +67,7 @@ const HOST = "github.com";
67
67
  const API_DEFAULT = "https://api.github.com";
68
68
  const DEFAULT_KEYS = ["id_ed25519", "id_ecdsa", "id_rsa"];
69
69
  export const LOGIN_METHODS = ["gh", "credential", "ssh"];
70
- const LOGIN_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,38}$/;
70
+ export const LOGIN_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,38}$/;
71
71
  const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
72
72
 
73
73
  export function defaultPrefsPath(env = process.env) {
@@ -18,10 +18,12 @@
18
18
  * - It never registers when `usageLog.optOut` is true. That is permanent and
19
19
  * checked before anything else, including any credential read (the
20
20
  * credential store and gh).
21
- * - It never registers a name it has not checked: the login sent is the GitHub
22
- * account on this machine that can read the pipeline repository
23
- * (usage-identity.mjs: gh, the git credential helper, or an SSH key). No such
24
- * account, no registration. The request proves the caller owns the login:
21
+ * - It registers the GitHub account on this machine that can read the
22
+ * pipeline repository (usage-identity.mjs: gh, the git credential helper, or
23
+ * an SSH key). With no such account it registers only a name the person
24
+ * typed (`--login`, kept as `usageLog.declaredLogin`), as a declared claim
25
+ * the server accepts for a collaborator and marks `claimed`. Otherwise the
26
+ * request proves the caller owns the login:
25
27
  * a gh or credential-helper token as `Authorization: Bearer`, or for an SSH
26
28
  * key a signature over a one-time server challenge
27
29
  * (`ssh-keygen -Y sign`), sent with no Authorization header at all. The
@@ -37,11 +39,14 @@
37
39
  * is coarse metadata only.
38
40
  *
39
41
  * Usage:
40
- * node usage-register.mjs [--prefs <path>] [--json] [--quiet] [--dry-run] [--feedback] [--refresh]
42
+ * node usage-register.mjs [--prefs <path>] [--json] [--quiet] [--dry-run] [--feedback] [--refresh] [--login <name>]
41
43
  *
42
44
  * `--refresh` re-checks which GitHub account on this machine can read the
43
45
  * pipeline repository instead of using the cached `usageLog.login`.
44
46
  *
47
+ * `--login <name>` records the person's GitHub username, for a machine that
48
+ * proves no account (a work machine), and implies `--refresh`.
49
+ *
45
50
  * Exit: always 0. `--json` says what happened; the shell does not need to care.
46
51
  *
47
52
  * @module pipeline/scripts/usage-register
@@ -49,12 +54,14 @@
49
54
 
50
55
  import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
51
56
  import { spawn, spawnSync } from "node:child_process";
57
+ import { randomBytes } from "node:crypto";
52
58
  import { join } from "node:path";
53
59
  import { homedir, hostname, tmpdir } from "node:os";
54
60
  import { pathToFileURL } from "node:url";
55
61
  import { hostCommand } from "../lib/user-facing.mjs";
56
62
  import { canonicalEndpoint } from "../lib/usage-endpoint.mjs";
57
63
  import {
64
+ LOGIN_RE,
58
65
  SSH_TIMEOUT_MS,
59
66
  credentialToken,
60
67
  displayPath,
@@ -82,8 +89,11 @@ function readJson(file) {
82
89
  * it was found by, or login null when none can. Never the git identity, never
83
90
  * the OS user, never the account gh merely has active.
84
91
  */
85
- export function reportingUser(prefsPath, { env = process.env, refresh = false } = {}) {
86
- return resolveReportingLogin({ prefsPath, env, refresh });
92
+ export function reportingUser(
93
+ prefsPath,
94
+ { env = process.env, refresh = false, write = true } = {},
95
+ ) {
96
+ return resolveReportingLogin({ prefsPath, env, refresh, write });
87
97
  }
88
98
 
89
99
  /**
@@ -107,6 +117,7 @@ export function decideRegistration(
107
117
  forFeedback = false,
108
118
  login = undefined,
109
119
  loginReason = null,
120
+ proven = true,
110
121
  } = {},
111
122
  ) {
112
123
  const g = prefs?.global || {};
@@ -129,13 +140,21 @@ export function decideRegistration(
129
140
  if (login === null) {
130
141
  return { act: false, reason: loginReason || "no GitHub account on this machine has access" };
131
142
  }
143
+ if (hasStoredToken && login && proven && u.registeredClaim === true) {
144
+ return { act: true, reason: `replacing a declared registration with a proof for ${login}` };
145
+ }
132
146
  if (hasStoredToken && login && u.registeredFallback === true) {
133
147
  return { act: true, reason: `replacing a fallback registration with ${login}` };
134
148
  }
135
149
  // The server pins a token to the login it was issued for. A token issued to a
136
150
  // different login, or before the login was verified, reports under the wrong
137
151
  // name, so it is replaced.
138
- if (hasStoredToken && login && u.registeredAs && u.registeredAs !== login) {
152
+ if (
153
+ hasStoredToken &&
154
+ login &&
155
+ u.registeredAs &&
156
+ u.registeredAs.toLowerCase() !== login.toLowerCase()
157
+ ) {
139
158
  return { act: true, reason: `reporting account changed to ${login}` };
140
159
  }
141
160
  if (hasStoredToken && login && !u.registeredAs) {
@@ -228,6 +247,14 @@ function refusal(status, json, body, method) {
228
247
  : "retry with --refresh";
229
248
  return `endpoint answered ${status}${detail} - GitHub did not accept the proof for ${body.u}; ${fix}`;
230
249
  }
250
+ if (code === "proof_required") {
251
+ return `${body.u} is the repository owner, which registers with a proof: log in with \`gh auth login\` or add its SSH key, then retry with --refresh`;
252
+ }
253
+ if (code === "not_collaborator") {
254
+ const rerun =
255
+ method === "claim" ? "rerun setup and enter the name again" : "rerun with --refresh";
256
+ return `${body.u} is not a collaborator of the pipeline repository; the repository owner adds it, then ${rerun}`;
257
+ }
231
258
  if (status === 403) {
232
259
  return (
233
260
  `endpoint answered 403${detail} - ${body.u} needs collaborator access to the pipeline repository; ` +
@@ -240,7 +267,10 @@ function refusal(status, json, body, method) {
240
267
  async function requestToken(url, body, proof, method) {
241
268
  const { res, json, failure } = await postJson(url, body, proof);
242
269
  if (failure) return { token: null, why: failure };
243
- if (!res.ok) return { token: null, why: refusal(res.status, json, body, method) };
270
+ if (!res.ok) {
271
+ const code = typeof json?.code === "string" ? json.code : null;
272
+ return { token: null, code, why: refusal(res.status, json, body, method) };
273
+ }
244
274
  if (json?.accepted === false)
245
275
  return { token: null, declined: true, why: "the endpoint declined this login" };
246
276
  const token = typeof json?.token === "string" ? json.token.trim() : "";
@@ -319,6 +349,94 @@ async function requestTokenWithSsh(url, body, key) {
319
349
  return requestToken(url, { ...body, ssh: { challenge, signature } }, null, "ssh");
320
350
  }
321
351
 
352
+ /** The account name the person typed at setup or update, when it is a GitHub login. */
353
+ export function declaredLoginOf(prefs) {
354
+ const d = prefs?.global?.usageLog?.declaredLogin;
355
+ return typeof d === "string" && LOGIN_RE.test(d.trim()) ? d.trim() : null;
356
+ }
357
+
358
+ /**
359
+ * Keep a typed account name and prefer it as the account to report under (the
360
+ * reportAs it replaces is kept as `usageLog.reportAsBefore`), or
361
+ * forget it (name null) once the server says it is not a collaborator, so the
362
+ * next setup asks again instead of retrying a name that cannot register.
363
+ */
364
+ function declareLogin(prefsPath, name) {
365
+ try {
366
+ const written = updateJsonFileSync(
367
+ prefsPath,
368
+ (j) => {
369
+ if (!j || typeof j !== "object" || Array.isArray(j)) return undefined;
370
+ j.global = j.global || {};
371
+ const u = { ...(j.global.usageLog || {}) };
372
+ if (name) {
373
+ if (u.reportAs && u.reportAs !== u.declaredLogin && u.reportAsBefore === undefined)
374
+ u.reportAsBefore = u.reportAs;
375
+ Object.assign(u, { declaredLogin: name, reportAs: name });
376
+ delete u.claimFailedAt;
377
+ } else if (u.declaredLogin) {
378
+ if (u.reportAs === u.declaredLogin) {
379
+ if (u.reportAsBefore) u.reportAs = u.reportAsBefore;
380
+ else delete u.reportAs;
381
+ }
382
+ delete u.reportAsBefore;
383
+ delete u.declaredLogin;
384
+ }
385
+ j.global.usageLog = u;
386
+ return j;
387
+ },
388
+ { mode: 0o600, followSymlink: true, timeoutMs: lockTimeoutFrom(process.env) },
389
+ );
390
+ return written !== undefined;
391
+ } catch {
392
+ return false;
393
+ }
394
+ }
395
+
396
+ /** Merge `fields` into `usageLog` (undefined removes a key). True when written. */
397
+ function patchUsageLog(prefsPath, fields) {
398
+ try {
399
+ const written = updateJsonFileSync(
400
+ prefsPath,
401
+ (j) => {
402
+ if (!j || typeof j !== "object" || Array.isArray(j)) return undefined;
403
+ j.global = j.global || {};
404
+ const u = { ...(j.global.usageLog || {}) };
405
+ for (const [k, v] of Object.entries(fields)) {
406
+ if (v === undefined) delete u[k];
407
+ else u[k] = v;
408
+ }
409
+ j.global.usageLog = u;
410
+ return j;
411
+ },
412
+ { mode: 0o600, followSymlink: true, timeoutMs: lockTimeoutFrom(process.env) },
413
+ );
414
+ return written !== undefined;
415
+ } catch {
416
+ return false;
417
+ }
418
+ }
419
+
420
+ /**
421
+ * A declared claim that failed (`usageLog.claimFailedAt`) is not sent again for
422
+ * a day unless the person types the name again.
423
+ */
424
+ const CLAIM_RETRY_MS = 24 * 3600 * 1000;
425
+
426
+ /**
427
+ * The machine label a declared registration is sent with: the host name and a
428
+ * random suffix kept in prefs (`usageLog.claimNonce`). The server replaces a login's token for the same
429
+ * label, so a label nobody else can guess keeps a stranger from replacing it.
430
+ */
431
+ function claimLabel(prefsPath, usageLog, host) {
432
+ let nonce = usageLog?.claimNonce;
433
+ if (typeof nonce !== "string" || !/^[0-9a-f]{8,32}$/.test(nonce)) {
434
+ nonce = randomBytes(6).toString("hex");
435
+ patchUsageLog(prefsPath, { claimNonce: nonce });
436
+ }
437
+ return `${host.slice(0, 30)}-${nonce}`;
438
+ }
439
+
322
440
  /**
323
441
  * Write the switch, and the credential-store ENTRY NAME when there is one.
324
442
  *
@@ -332,7 +450,7 @@ async function requestTokenWithSsh(url, body, key) {
332
450
  export function persistPrefs(
333
451
  prefsPath,
334
452
  entryName = null,
335
- { enableTelemetry = true, registeredAs = null } = {},
453
+ { enableTelemetry = true, registeredAs = null, claimed = false } = {},
336
454
  ) {
337
455
  try {
338
456
  const written = updateJsonFileSync(
@@ -349,6 +467,8 @@ export function persistPrefs(
349
467
  if (registeredAs) {
350
468
  j.global.usageLog.registeredAs = registeredAs;
351
469
  delete j.global.usageLog.registeredFallback;
470
+ if (claimed) j.global.usageLog.registeredClaim = true;
471
+ else delete j.global.usageLog.registeredClaim;
352
472
  j.global.usageLog.registeredAt = new Date().toISOString();
353
473
  }
354
474
  return j;
@@ -371,6 +491,7 @@ export function persistPrefs(
371
491
  export function personalAccountMissing(prefs, login = undefined, env = process.env) {
372
492
  const u = prefs?.global?.usageLog || {};
373
493
  if (u.optOut === true) return false;
494
+ if (declaredLoginOf(prefs)) return false;
374
495
  if (env.MULTI_AGENT_USAGE_TOKEN) return false;
375
496
  if (typeof u.token === "string" && u.token.trim()) return false;
376
497
  if (pinned(u)) return false;
@@ -425,6 +546,7 @@ async function main(argv) {
425
546
  prefs: null,
426
547
  feedback: false,
427
548
  refresh: false,
549
+ login: null,
428
550
  };
429
551
  for (let i = 0; i < argv.length; i += 1) {
430
552
  const a = argv[i];
@@ -434,6 +556,7 @@ async function main(argv) {
434
556
  else if (a === "--feedback") opts.feedback = true;
435
557
  else if (a === "--refresh") opts.refresh = true;
436
558
  else if (a === "--prefs") opts.prefs = argv[++i];
559
+ else if (a === "--login") opts.login = argv[++i] ?? "";
437
560
  }
438
561
  const HOME = homedir();
439
562
  const prefsPath = opts.prefs || join(HOME, ".claude", "multi-agent-preferences.json");
@@ -464,6 +587,26 @@ async function main(argv) {
464
587
  return done("skipped", "no prefs");
465
588
  }
466
589
 
590
+ // A name the person typed: kept as `declaredLogin` and preferred as the
591
+ // account to report under. A proof for it is still used when this machine
592
+ // has one; without one it is registered as declared (see below).
593
+ if (opts.login !== null) {
594
+ if (prefs.global?.usageLog?.optOut === true && !opts.feedback) {
595
+ say(" -> operational reporting: unchanged (opted out)");
596
+ return done("skipped", "opted out");
597
+ }
598
+ const name = String(opts.login).trim();
599
+ if (!LOGIN_RE.test(name)) {
600
+ say(` -> operational reporting: "${name.slice(0, 40)}" is not a GitHub login`);
601
+ return done("skipped", `"${name.slice(0, 40)}" is not a GitHub login`);
602
+ }
603
+ if (!opts.dryRun && !declareLogin(prefsPath, name)) {
604
+ say(" -> operational reporting: could not write preferences");
605
+ return done("unavailable", "prefs write failed");
606
+ }
607
+ opts.refresh = true;
608
+ }
609
+
467
610
  // Decided before any credential is read: an opted-out machine, or one whose
468
611
  // token is already in the environment or prefs, never touches the credential
469
612
  // store or gh.
@@ -472,16 +615,26 @@ async function main(argv) {
472
615
  pre.reason,
473
616
  );
474
617
  const has = identityMatters ? Boolean(storedToken(prefs, credStore)) : false;
475
- const who = identityMatters
476
- ? reportingUser(prefsPath, { refresh: opts.refresh })
618
+ let who = identityMatters
619
+ ? reportingUser(prefsPath, { refresh: opts.refresh, write: !opts.dryRun })
477
620
  : { login: undefined, reason: null };
478
- identity.login = who.login;
479
621
  const current = readJson(prefsPath) || prefs;
622
+ const declared =
623
+ opts.dryRun && opts.login !== null ? String(opts.login).trim() : declaredLoginOf(current);
624
+ // A typed name is used only when this machine proves no account at all; a
625
+ // proof, for that name or another, is evidence and a declaration is not.
626
+ const cu = current.global?.usageLog || {};
627
+ const provenPin = has && pinned(cu) && cu.registeredClaim !== true;
628
+ if (identityMatters && declared && !who.login && !provenPin && !opts.feedback) {
629
+ who = { login: declared, method: "claim", key: null, reason: "declared by the person" };
630
+ }
631
+ identity.login = who.login;
480
632
  const decision = decideRegistration(current, {
481
633
  hasStoredToken: has,
482
634
  forFeedback: opts.feedback,
483
635
  login: who.login,
484
636
  loginReason: who.reason,
637
+ proven: who.method !== "claim",
485
638
  });
486
639
  if (!decision.act) {
487
640
  const repairable = ["token in environment", "token in prefs", "token already onboarded"];
@@ -531,7 +684,15 @@ async function main(argv) {
531
684
  }
532
685
 
533
686
  let result;
534
- if (who.method === "ssh") {
687
+ if (who.method === "claim") {
688
+ const failedAt = Date.parse(current.global?.usageLog?.claimFailedAt ?? "");
689
+ if (opts.login === null && !Number.isNaN(failedAt) && Date.now() - failedAt < CLAIM_RETRY_MS) {
690
+ say(" -> operational reporting: unchanged (the declared name was refused in the last day)");
691
+ return done("skipped", "declared registration refused recently");
692
+ }
693
+ const label = claimLabel(prefsPath, current.global?.usageLog, host);
694
+ result = await requestToken(url, { u: user, c: label, claim: true }, null, "claim");
695
+ } else if (who.method === "ssh") {
535
696
  result = await requestTokenWithSsh(url, { u: user, c: host }, who.key);
536
697
  } else {
537
698
  const proof = who.method === "credential" ? credentialToken() : ghTokenFor(user);
@@ -545,7 +706,11 @@ async function main(argv) {
545
706
  }
546
707
  result = await requestToken(url, { u: user, c: host }, proof, who.method || "gh");
547
708
  }
548
- const { token, why, declined, login: pinnedLogin } = result;
709
+ const { token, why, declined, code, login: pinnedLogin } = result;
710
+ if (who.method === "claim" && ["not_collaborator", "proof_required"].includes(code))
711
+ declareLogin(prefsPath, null);
712
+ else if (who.method === "claim" && !token && !declined)
713
+ patchUsageLog(prefsPath, { claimFailedAt: new Date().toISOString() });
549
714
  if (declined) {
550
715
  say(` -> operational reporting left off (${why})`);
551
716
  return done("declined", why);
@@ -569,6 +734,7 @@ async function main(argv) {
569
734
  persistPrefs(prefsPath, entry, {
570
735
  enableTelemetry: !optedOut,
571
736
  registeredAs: pinnedLogin || user,
737
+ claimed: who.method === "claim",
572
738
  });
573
739
  if (optedOut) {
574
740
  say(" -> feedback token registered; telemetry stays off (usageLog.optOut is set)");
@@ -17,7 +17,7 @@ import { readFileSync } from "node:fs";
17
17
 
18
18
  const ALLOWED_STACK = new Set(["ios", "android", "node", "python", "go"]);
19
19
  const ALLOWED_NODE_KIND = new Set(["file", "symbol", "module"]);
20
- const ALLOWED_EDGE_KIND = new Set(["defines", "imports", "references"]);
20
+ const ALLOWED_EDGE_KIND = new Set(["defines", "imports", "references", "inherits"]);
21
21
  const MAX_REPORTED_DANGLING = 5;
22
22
 
23
23
  function readInput() {
@@ -137,7 +137,8 @@ export function validate(obj) {
137
137
  if (typeof obj !== "object" || obj === null || Array.isArray(obj))
138
138
  return ["root must be an object"];
139
139
 
140
- if (obj.schemaVersion !== "1.0.0") errors.push(`schemaVersion must be "1.0.0"`);
140
+ if (!["1.0.0", "1.1.0", "1.2.0"].includes(obj.schemaVersion))
141
+ errors.push(`schemaVersion must be "1.0.0", "1.1.0" or "1.2.0"`);
141
142
  if (!ALLOWED_STACK.has(obj.stack))
142
143
  errors.push(`stack must be one of ${[...ALLOWED_STACK].join(",")}`);
143
144
  if (typeof obj.root !== "string" || obj.root.length === 0)
@@ -24,6 +24,7 @@ const ALLOWED_SIGNALS = new Set([
24
24
  "ui_critical",
25
25
  "migration",
26
26
  "test_lines_removed",
27
+ "dependents_reached",
27
28
  ]);
28
29
 
29
30
  function readInput() {
@@ -837,7 +837,15 @@ function measureRework(repo, mergeBase, range, human, windowDays, now) {
837
837
  if (f.deleted === 0 || f.binary) continue;
838
838
  const old = f.oldPath ?? f.path;
839
839
  if (f.untracked) continue;
840
- const diff = git(repo, ["diff", "-U0", "-M", ...range, "--", old, f.path]);
840
+ const diff = git(repo, [
841
+ "diff",
842
+ "-U0",
843
+ "-M",
844
+ ...range,
845
+ "--",
846
+ `:(literal)${old}`,
847
+ `:(literal)${f.path}`,
848
+ ]);
841
849
  for (const [start, count] of removedRanges(diff)) {
842
850
  const porcelain = git(repo, [
843
851
  "blame",
@@ -75,6 +75,7 @@ function fail(msg) {
75
75
  }
76
76
 
77
77
  const lines = (out) => out.split("\n").filter(Boolean);
78
+ const paths = (out) => out.split("\0").filter(Boolean);
78
79
 
79
80
  async function main() {
80
81
  let args;
@@ -93,9 +94,11 @@ async function main() {
93
94
  const headSha = git(repo, ["rev-parse", head]).trim();
94
95
  const baseSha = git(repo, ["rev-parse", args.base]).trim();
95
96
  const branch = args.branch ?? git(repo, ["rev-parse", "--abbrev-ref", head]).trim();
96
- const changed = lines(git(repo, ["diff", "--name-only", "-M", mergeBase, headSha]));
97
+ const changed = paths(git(repo, ["diff", "-z", "--name-only", "-M", mergeBase, headSha]));
97
98
  if (!changed.length) return fail(`no change between ${args.base} and ${head}`);
98
- const baseTouched = new Set(lines(git(repo, ["diff", "--name-only", mergeBase, baseSha])));
99
+ const baseTouched = new Set(
100
+ paths(git(repo, ["diff", "-z", "--name-only", mergeBase, baseSha])),
101
+ );
99
102
  const exists = (name) => git(repo, ["branch", "--list", name]).trim() !== "";
100
103
  const to = args.to ?? nextName(branch, exists);
101
104
  const subject =
@@ -139,7 +142,7 @@ async function main() {
139
142
  encoding: "utf-8",
140
143
  });
141
144
  if (r.status !== 0) {
142
- const unmerged = lines(git(repo, ["diff", "--name-only", "--diff-filter=U"]));
145
+ const unmerged = paths(git(repo, ["diff", "-z", "--name-only", "--diff-filter=U"]));
143
146
  plan.conflicts = unmerged.length ? unmerged : lines(r.stderr).slice(0, 20);
144
147
  git(repo, ["reset", "-q", "--hard"]);
145
148
  git(repo, ["checkout", "-q", back]);
@@ -149,8 +152,16 @@ async function main() {
149
152
  git(repo, ["commit", "-q", "-m", plan.subject]);
150
153
  plan.applied = true;
151
154
  plan.portedSha = git(repo, ["rev-parse", "HEAD"]).trim();
152
- plan.differsFromHead = lines(
153
- git(repo, ["diff", "--name-only", plan.head, "HEAD", "--", ...plan.changedPaths]),
155
+ plan.differsFromHead = paths(
156
+ git(repo, [
157
+ "diff",
158
+ "-z",
159
+ "--name-only",
160
+ plan.head,
161
+ "HEAD",
162
+ "--",
163
+ ...plan.changedPaths.map((p) => `:(literal)${p}`),
164
+ ]),
154
165
  );
155
166
  git(repo, ["checkout", "-q", back]);
156
167
  }
@@ -320,12 +320,14 @@ async function main() {
320
320
  git(repo, ["commit", "-q", "--allow-empty", "-m", plan.slices[i].subject]);
321
321
  parent = name;
322
322
  }
323
- const drift = git(repo, ["diff", "--name-only", parent, headSha]).trim();
323
+ const drift = git(repo, ["diff", "-z", "--name-only", parent, headSha])
324
+ .split("\0")
325
+ .filter(Boolean);
324
326
  git(repo, ["checkout", "-q", original === "HEAD" ? headSha : original]);
325
327
  plan.applied = true;
326
- plan.topMatchesBranch = drift === "";
327
- if (drift) {
328
- plan.drift = drift.split("\n");
328
+ plan.topMatchesBranch = drift.length === 0;
329
+ if (drift.length) {
330
+ plan.drift = drift;
329
331
  process.exitCode = 1;
330
332
  }
331
333
  } catch (err) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": "1.0.0",
3
- "generatedAt": "2026-10-12T02:17:06Z",
3
+ "generatedAt": "2026-10-13T08:10:09Z",
4
4
  "skillCount": 228,
5
5
  "entries": [
6
6
  {
@@ -165,7 +165,7 @@
165
165
  },
166
166
  {
167
167
  "path": "shared/core/multi-agent-review/SKILL.md",
168
- "sha256": "2b6934d6086a467bdf145b326061ab4b83d5479bfb1a2fb36420ff8012a02e5d"
168
+ "sha256": "1a22618e6d3d9d07b267bd61fc591bb33726e1d7ef8567285401f61bc7be0ad5"
169
169
  },
170
170
  {
171
171
  "path": "shared/core/multi-agent-route-off/SKILL.md",
@@ -213,7 +213,7 @@
213
213
  },
214
214
  {
215
215
  "path": "shared/core/multi-agent-setup/SKILL.md",
216
- "sha256": "e3523960d5d178125302e5ceeb865902d7b12ea5d615670cddeb4d542fa48035"
216
+ "sha256": "73a1d1d8484d32b6d83107632a84aeced2f230f8581b7d47cad5e2d14e13860a"
217
217
  },
218
218
  {
219
219
  "path": "shared/core/multi-agent-stack/SKILL.md",
@@ -265,7 +265,7 @@
265
265
  },
266
266
  {
267
267
  "path": "shared/core/multi-agent-update/SKILL.md",
268
- "sha256": "013317432263490a01d081b657928b977012afb78f75a64d278d94d2daa8e894"
268
+ "sha256": "2f9e224e240e1c9053a59066678f869ae36f18c088482488f3bda8fd207148ea"
269
269
  },
270
270
  {
271
271
  "path": "shared/core/multi-agent/SKILL.md",
@@ -76,7 +76,11 @@ Skip Phase 0-3 and review a diff only. Input shapes: a PR (`#N`, `repo#N`, GitHu
76
76
  resolves whose `stack` is the other mobile platform and the diff touches a screen, a
77
77
  service, a request model or a localization file, compare the change against that repo
78
78
  on four axes: endpoints called, parameters sent, business rules around the call,
79
- localization keys used. The counterpart is resolved automatically and in both
79
+ localization keys used. When the project belongs to a product
80
+ (`prefs.global.products`), the counterparts are the product's other repositories,
81
+ resolved with `product-context.mjs refs` and compared at their latest integration
82
+ branch through the toolkit's `repo_*` tools, pinned to a sha
83
+ (`features/product-context.md`). Otherwise the counterpart is resolved automatically and in both
80
84
  directions (ios ↔ android) from four sources in this order, first hit wins:
81
85
  `--with <path|owner/repo>` (a one-off override, remembered like any other resolution),
82
86
  then a remembered `prefs.projects[<slug>].counterpartRoots[]`, then the primary
@@ -24,6 +24,10 @@ node "$HOME/.copilot/scripts/doctor.mjs"
24
24
 
25
25
  Run it before the first question and again at the end. The first run makes setup targeted: never ask for a token that is already mapped and answering. The second run is how setup ends - "setup complete" is a claim, and the doctor's exit code is the evidence. Report both in `outputLanguage`; if the second run still shows a BLOCK, say so plainly rather than closing on the word "complete".
26
26
 
27
+ ## Sections
28
+
29
+ `/multi-agent-setup hosts`: the Step 3.5 host prompt per mapped hosted service, then Token Save Flow Step 3.7 per mapped GitHub/Bitbucket token. `accounts`: Step 3.6. `jira-keys`: the Jira key mapping. Tokens are left alone.
30
+
27
31
  ## Setup (Credential Store + Git Identity Onboarding)
28
32
 
29
33
  Uses `~/.copilot/lib/credential-store.sh` to read/write secrets in the macOS Keychain (`security`, built in); the pipeline is macOS only (ADR-0012). Run `~/.copilot/lib/credential-store.sh doctor` first - it prints the platform and whether the backend is usable. The shell driver auto-delegates `get`/`set`/`delete` to the deterministic Python helper at `$HOME/.copilot/scripts/keychain.py` (Python 3 stdlib only, no external deps). The Python path writes both `-l` (label) and `-s` (service) attributes so items are findable regardless of how callers look them up. Set `KEYCHAIN_DELEGATE=0` to force the raw shell path.
@@ -136,9 +140,9 @@ Then, **whether they pasted or skipped**, run:
136
140
  node "$HOME/.copilot/scripts/usage-register.mjs" --json
137
141
  ```
138
142
 
139
- If its JSON line says `"needsPersonalAccount":true`, offer once: `gh auth login`,
140
- or an SSH key on the personal account (the "Personal account" step in
141
- `$HOME/.claude/multi-agent-refs/features/usage-reporting.md`).
143
+ If its JSON line says `"needsPersonalAccount":true`, ask once for the person's
144
+ GitHub username (free text; Skip leaves reporting off) and run
145
+ `node "$HOME/.copilot/scripts/usage-register.mjs" --login <name> --json`.
142
146
 
143
147
  It maps whatever token exists and switches reporting on, and requests a
144
148
  per-machine write-only one when none resolves. Setup is a registration entry point
@@ -194,7 +198,7 @@ The Step 2 discovery summary is never the end of setup. Immediately after showin
194
198
  Loop rules:
195
199
 
196
200
  - **One prompt per service.** Never collapse the list into a single "add missing tokens now?" yes/no, and never offer one multi-select covering all services: each service has its own source URL, key name, identity binding and host prompt, so each gets its own Token Save Flow pass.
197
- - **Each service pass asks, in order: token → author → host.** Step A/B collect the secret, Step C binds the git identity (author) - Bitbucket and GitHub included; every saved token gets an author - and Step 3.5 asks the host domain inline for hosted services (jira, confluence, bitbucket, fortify, graylog) - plus the Fortify project versions and the optional Graylog test instance. GitHub and Figma skip the host question (fixed domains); `bitbucket_user` skips author binding (username, not a PAT). Never end a service pass with the token saved but the author or a required host unasked.
201
+ - **Each service pass asks, in order: token → author → host → read binding (GitHub, Bitbucket; Token Save Flow Step 3.7).** Step A/B collect the secret, Step C binds the git identity (author) - Bitbucket and GitHub included; every saved token gets an author - and Step 3.5 asks the host domain inline for hosted services (jira, confluence, bitbucket, fortify, graylog) - plus the Fortify project versions and the optional Graylog test instance. GitHub and Figma skip the host question (fixed domains); `bitbucket_user` skips author binding (username, not a PAT). Never end a service pass with the token saved but the author or a required host unasked.
198
202
  - **Skip is per-service.** A Skip answer leaves that service's `keychainMapping` at `null` and the loop continues with the next service. Skips never abort the loop.
199
203
  - **`figma` and `figma_mcp` are distinct credentials for distinct tiers - ask both.** Skipping the PAT does not imply skipping MCP, and vice versa. The `figma_mcp` pass opens with a Remote / Local mode question instead of a clipboard paste (see "Figma MCP Setup" below): Remote generates a `figu_` OAuth token for the current user, Local wires the PAT-based local server.
200
204
  - The loop ends only when every missing service has been asked exactly once; then continue to Step 4 (Verify all).
@@ -219,17 +223,15 @@ Runs inside Step 3, only when the target repo is Android. One entry, `google_pla
219
223
 
220
224
  ### Step 3.5 - Host Prompt (embedded in Token Save Flow)
221
225
 
222
- Full procedure: `$HOME/.claude/multi-agent-refs/setup/token-save-flow.md`. Read it for the host question inside the Token Save Flow.
226
+ Part of the Token Save Flow reference above.
223
227
 
224
228
  ### Step 3.6 - Per-account host overrides (optional)
225
229
 
226
230
  **Runs only when** `~/.copilot/lib/account-resolver.sh` returns **more than one** account AND at least one account has Jira or Bitbucket tokens.
227
231
 
228
- Use case: one user with two identities (e.g. agency + client), each with its own Jira host; `prefs.global.hosts.jira` holds only one default.
229
-
230
- For each non-default account, ask once: "Account {label} has Jira/Bitbucket tokens but no host override yet. Use the global default ({hosts.jira}) or set a per-account host?" with options **1) Use global default** / **2) Set per-account host**. On `2`, prompt for `jiraHost` / `bitbucketHost` / `confluenceHost` (each optional) and save to `prefs.global.accounts[]`, e.g. `{ "id": "client-x", "jiraHost": "jira.client-x.example.com", "bitbucketHost": "bitbucket.client-x.example.com" }`.
232
+ For each non-default account (two identities, each with its own Jira host), ask once: "Account {label} has Jira/Bitbucket tokens but no host override yet. Use the global default ({hosts.jira}) or set a per-account host?" with options **1) Use global default** / **2) Set per-account host**. On `2`, prompt for `jiraHost` / `bitbucketHost` / `confluenceHost` (each optional) and save to `prefs.global.accounts[]`, e.g. `{ "id": "client-x", "jiraHost": "jira.client-x.example.com", "bitbucketHost": "bitbucket.client-x.example.com" }`.
231
233
 
232
- `account-resolver.sh` reads this on every invocation; per-account override wins over `global.hosts.*`. Skip this step on autopilot - `accounts[]` stays empty until the user opts in via `/multi-agent-setup accounts`, which opens this editor without re-running token discovery.
234
+ `account-resolver.sh` reads this on every invocation; per-account override wins over `global.hosts.*`. Skip this step on autopilot; `/multi-agent-setup accounts` opens it later.
233
235
 
234
236
  ---
235
237
 
@@ -44,9 +44,9 @@ Update the pipeline with a single command. Preferences are preserved; only skill
44
44
  already onboarded, and permanently blocked by `usageLog.optOut: true`. Offline
45
45
  or endpoint down leaves reporting off with one line, never an error. Contract:
46
46
  `$HOME/.claude/multi-agent-refs/features/usage-reporting.md`.
47
- If its JSON line says `"needsPersonalAccount":true`, offer once: `gh auth login`,
48
- or an SSH key on the personal account (the "Personal account" step in
49
- `$HOME/.claude/multi-agent-refs/features/usage-reporting.md`).
47
+ If its JSON line says `"needsPersonalAccount":true`, ask once for the person's
48
+ GitHub username (free text; Skip leaves reporting off) and run
49
+ `node "$HOME/.copilot/scripts/usage-register.mjs" --login <name> --json`.
50
50
  6. Show the new version + changelog
51
51
  7. Smoke test (schema + cross-cli)
52
52