@mmerterden/multi-agent-pipeline 20.22.0 → 20.23.1

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 (51) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/docs/adr/0010-own-code-graph.md +3 -2
  3. package/docs/facts.json +3 -3
  4. package/docs/features.md +2 -2
  5. package/install/_usage-lifecycle.mjs +44 -0
  6. package/install/catalog-history.json +1 -1
  7. package/install/index.mjs +2 -0
  8. package/manifest.json +54 -51
  9. package/package.json +1 -1
  10. package/pipeline/commands/multi-agent/create-jira/SKILL.md +1 -1
  11. package/pipeline/commands/multi-agent/setup/SKILL.md +3 -3
  12. package/pipeline/commands/multi-agent/update/SKILL.md +3 -3
  13. package/pipeline/lib/analysis-jira-write.sh +25 -5
  14. package/pipeline/lib/json-file-lock.mjs +24 -2
  15. package/pipeline/multi-agent-refs/analysis/evidence.md +1 -1
  16. package/pipeline/multi-agent-refs/cross-cli-contract.md +1 -0
  17. package/pipeline/multi-agent-refs/features/analysis-jira.md +13 -2
  18. package/pipeline/multi-agent-refs/features/code-graph.md +40 -3
  19. package/pipeline/multi-agent-refs/features/usage-reporting.md +40 -23
  20. package/pipeline/multi-agent-refs/generate-issue.md +11 -1
  21. package/pipeline/multi-agent-refs/knowledge.md +18 -0
  22. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -2
  23. package/pipeline/schemas/code-graph.schema.json +3 -3
  24. package/pipeline/schemas/prefs.schema.json +29 -6
  25. package/pipeline/scripts/_code-graph.mjs +121 -12
  26. package/pipeline/scripts/_diff-impact.mjs +67 -32
  27. package/pipeline/scripts/_supertypes.mjs +146 -0
  28. package/pipeline/scripts/analysis-story-body.mjs +24 -8
  29. package/pipeline/scripts/analysis-story-tree.mjs +16 -2
  30. package/pipeline/scripts/capture-flush.sh +4 -1
  31. package/pipeline/scripts/code-graph-rules/android.json +1 -0
  32. package/pipeline/scripts/code-graph-rules/ios.json +6 -1
  33. package/pipeline/scripts/code-graph-rules/node.json +1 -0
  34. package/pipeline/scripts/code-graph-rules/python.json +1 -0
  35. package/pipeline/scripts/git-path.mjs +21 -0
  36. package/pipeline/scripts/graph-affected.mjs +2 -1
  37. package/pipeline/scripts/graph-confirm.mjs +140 -0
  38. package/pipeline/scripts/learnings-ledger.mjs +3 -2
  39. package/pipeline/scripts/product-context.mjs +30 -19
  40. package/pipeline/scripts/symbol-existence-gate.mjs +21 -9
  41. package/pipeline/scripts/test-gap-scan.mjs +4 -1
  42. package/pipeline/scripts/test-strength.mjs +7 -8
  43. package/pipeline/scripts/usage-identity.mjs +1 -1
  44. package/pipeline/scripts/usage-register.mjs +181 -15
  45. package/pipeline/scripts/validate-code-graph.mjs +3 -3
  46. package/pipeline/scripts/valven-gate.mjs +9 -1
  47. package/pipeline/scripts/valven-port.mjs +16 -5
  48. package/pipeline/scripts/valven-split.mjs +6 -4
  49. package/pipeline/skills/.skill-manifest.json +3 -3
  50. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +3 -3
  51. package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +3 -3
@@ -41,7 +41,8 @@
41
41
  * Distill the triage's rejected[] findings into rejected-preference
42
42
  * entries so reviewers stop re-raising them. SAFETY: blocking-
43
43
  * severity rejections are NOT distilled - a wrong rejection of a
44
- * blocking issue must never permanently suppress that class.
44
+ * blocking issue must never permanently suppress that class. Exit 2
45
+ * when nothing new was distilled (a repeat of the same triage).
45
46
  * forget [--statement "..."] [--kind <kind>] [--repo-slug <slug>]
46
47
  * Remove ledger entries matching the statement substring and/or
47
48
  * kind (rewrites the file). Use to clear a bad/stale learning.
@@ -311,7 +312,7 @@ function cmdFromTriage() {
311
312
  }
312
313
  }
313
314
  process.stdout.write(JSON.stringify({ ok: true, slug, added: written, skippedBlocking }) + "\n");
314
- process.exitCode = 0;
315
+ process.exitCode = written ? 0 : 2;
315
316
  return;
316
317
  }
317
318
 
@@ -40,19 +40,11 @@
40
40
  */
41
41
 
42
42
  import { execFileSync } from "node:child_process";
43
- import {
44
- appendFileSync,
45
- chmodSync,
46
- existsSync,
47
- mkdirSync,
48
- readFileSync,
49
- renameSync,
50
- statSync,
51
- writeFileSync,
52
- } from "node:fs";
43
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
53
44
  import { homedir } from "node:os";
54
45
  import { dirname, join } from "node:path";
55
46
  import { fileURLToPath } from "node:url";
47
+ import { lockTimeoutFrom, withFileLockSync, writeJsonAtomicSync } from "../lib/json-file-lock.mjs";
56
48
 
57
49
  export const STACKS = ["ios", "android", "web", "backend", "resources"];
58
50
  const AUTH_SCHEMES = ["basic", "bearer"];
@@ -429,13 +421,26 @@ function defaultPrefsPath() {
429
421
  );
430
422
  }
431
423
 
432
- function writePrefsAtomic(file, prefs) {
424
+ /**
425
+ * Read, change and replace the preferences file under the lock write-state and
426
+ * migrate-prefs take, so a setup or a second run writing at the same moment is
427
+ * never overwritten. A file that cannot be parsed stops the write instead of
428
+ * being replaced; a symlinked file is written through to its target, at its
429
+ * own mode.
430
+ */
431
+ function updatePrefs(file, mutate) {
433
432
  mkdirSync(dirname(file), { recursive: true });
434
- const tmp = `${file}.tmp.${process.pid}`;
435
- const mode = existsSync(file) ? statSync(file).mode & 0o777 : 0o600;
436
- writeFileSync(tmp, `${JSON.stringify(prefs, null, 2)}\n`, { mode });
437
- chmodSync(tmp, mode);
438
- renameSync(tmp, file);
433
+ return withFileLockSync(
434
+ file,
435
+ () => {
436
+ const current = existsSync(file) ? readJson(file) : { global: {}, projects: {} };
437
+ const next = mutate(current);
438
+ const mode = existsSync(file) ? statSync(file).mode & 0o777 : 0o600;
439
+ writeJsonAtomicSync(file, next, { mode, followSymlink: true });
440
+ return next;
441
+ },
442
+ { timeoutMs: lockTimeoutFrom(process.env) },
443
+ );
439
444
  }
440
445
 
441
446
  function readJson(file) {
@@ -491,11 +496,13 @@ function main() {
491
496
  fail("learn needs --project, --product and --repos");
492
497
  let next;
493
498
  try {
494
- next = learnProduct(loadPrefs(), args.project, args.product, readJson(args.repos));
499
+ const repos = readJson(args.repos);
500
+ next = updatePrefs(prefsFile, (prefs) =>
501
+ learnProduct(prefs, args.project, args.product, repos),
502
+ );
495
503
  } catch (e) {
496
504
  fail(e.message);
497
505
  }
498
- writePrefsAtomic(prefsFile, next);
499
506
  process.stdout.write(
500
507
  `${JSON.stringify({ product: args.product, repos: next.global.products[args.product].repos.length })}\n`,
501
508
  );
@@ -510,7 +517,11 @@ function main() {
510
517
  }
511
518
  case "decline": {
512
519
  if (!args.project) fail("decline needs --project");
513
- writePrefsAtomic(prefsFile, declineProduct(loadPrefs(), args.project));
520
+ try {
521
+ updatePrefs(prefsFile, (prefs) => declineProduct(prefs, args.project));
522
+ } catch (e) {
523
+ fail(e.message);
524
+ }
514
525
  process.stdout.write(`${JSON.stringify({ project: args.project, product: "" })}\n`);
515
526
  break;
516
527
  }
@@ -46,6 +46,7 @@ import { invokedDirectly } from "../lib/invoked-directly.mjs";
46
46
  import { attendedVerdict } from "../lib/unattended.mjs";
47
47
  import { getAdapter, resolveStack, AdapterError } from "./_stack-adapter.mjs";
48
48
  import { globToRegExp } from "./glob-match.mjs";
49
+ import { diffHeaderPath } from "./git-path.mjs";
49
50
  import { gateContext, recordQuietly } from "./gate-ledger.mjs";
50
51
 
51
52
  const GATE = "symbol-existence";
@@ -208,13 +209,13 @@ function keyExists(ref, { keys, idents }) {
208
209
 
209
210
  function listFiles(dir, head) {
210
211
  if (head) {
211
- const r = git(dir, ["ls-tree", "-r", "--name-only", head]);
212
+ const r = git(dir, ["ls-tree", "-r", "-z", "--name-only", head]);
212
213
  if (!r.ok) throw new UsageError(`cannot list ${head}: ${r.err}`);
213
- return r.out.split("\n").filter(Boolean);
214
+ return r.out.split("\0").filter(Boolean);
214
215
  }
215
- const tracked = git(dir, ["ls-files", "--cached"]);
216
- const untracked = git(dir, ["ls-files", "--others", "--exclude-standard"]);
217
- return [...new Set([...tracked.out.split("\n"), ...untracked.out.split("\n")].filter(Boolean))];
216
+ const tracked = git(dir, ["ls-files", "-z", "--cached"]);
217
+ const untracked = git(dir, ["ls-files", "-z", "--others", "--exclude-standard"]);
218
+ return [...new Set([...tracked.out.split("\0"), ...untracked.out.split("\0")].filter(Boolean))];
218
219
  }
219
220
 
220
221
  function readAt(dir, head, file) {
@@ -236,14 +237,25 @@ function readAt(dir, head, file) {
236
237
  */
237
238
  export function addedLines(dir, base, head) {
238
239
  const range = head ? [base, head] : [base];
239
- const d = git(dir, ["diff", "-U0", "--no-color", "--no-ext-diff", "--diff-filter=AMR", ...range]);
240
+ const d = git(dir, [
241
+ "diff",
242
+ "-U0",
243
+ "--no-color",
244
+ "--no-ext-diff",
245
+ "--src-prefix=a/",
246
+ "--dst-prefix=b/",
247
+ "--diff-filter=AMR",
248
+ "--end-of-options",
249
+ ...range,
250
+ ]);
240
251
  if (!d.ok) throw new UsageError(`git diff failed: ${d.err}`);
241
252
  const out = [];
242
253
  let file = null;
243
254
  let line = 0;
244
255
  for (const raw of d.out.split("\n")) {
245
256
  if (raw.startsWith("+++ ")) {
246
- file = raw === "+++ /dev/null" ? null : raw.slice(4).replace(/^b\//, "");
257
+ const path = diffHeaderPath(raw.slice(4));
258
+ file = path === "/dev/null" ? null : path.replace(/^b\//, "");
247
259
  continue;
248
260
  }
249
261
  const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
@@ -258,8 +270,8 @@ export function addedLines(dir, base, head) {
258
270
  }
259
271
  }
260
272
  if (!head) {
261
- const untracked = git(dir, ["ls-files", "--others", "--exclude-standard"]);
262
- for (const f of untracked.out.split("\n").filter(Boolean)) {
273
+ const untracked = git(dir, ["ls-files", "-z", "--others", "--exclude-standard"]);
274
+ for (const f of untracked.out.split("\0").filter(Boolean)) {
263
275
  const text = readAt(dir, null, f);
264
276
  if (text === null || text.includes("\u0000")) continue;
265
277
  text.split("\n").forEach((t, i) => out.push({ file: f, line: i + 1, text: t }));
@@ -207,7 +207,10 @@ function isTestPath(path) {
207
207
  rules.testPathPrefixes.some((p) => basename(path).startsWith(p))
208
208
  )
209
209
  return true;
210
- if (Array.isArray(rules.testPathContains) && rules.testPathContains.some((c) => path.includes(c)))
210
+ if (
211
+ Array.isArray(rules.testPathContains) &&
212
+ rules.testPathContains.some((c) => `/${path}`.includes(c))
213
+ )
211
214
  return true;
212
215
  return false;
213
216
  }
@@ -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,8 +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 (!["1.0.0", "1.1.0"].includes(obj.schemaVersion))
141
- errors.push(`schemaVersion must be "1.0.0" or "1.1.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"`);
142
142
  if (!ALLOWED_STACK.has(obj.stack))
143
143
  errors.push(`stack must be one of ${[...ALLOWED_STACK].join(",")}`);
144
144
  if (typeof obj.root !== "string" || obj.root.length === 0)
@@ -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) {