@mmerterden/multi-agent-pipeline 20.16.0 → 20.17.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 (60) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +6 -5
  3. package/README.tr.md +6 -5
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/facts.json +14 -12
  7. package/docs/features.md +10 -2
  8. package/install/catalog-history.json +1 -1
  9. package/install/claude.mjs +41 -6
  10. package/install/codex.mjs +3 -3
  11. package/manifest.json +62 -49
  12. package/package.json +1 -1
  13. package/pipeline/commands/multi-agent/bug-bash/SKILL.md +154 -0
  14. package/pipeline/commands/multi-agent/review/SKILL.md +13 -9
  15. package/pipeline/commands/multi-agent/setup/SKILL.md +8 -6
  16. package/pipeline/commands/multi-agent/sync/SKILL.md +4 -3
  17. package/pipeline/commands/multi-agent/update/SKILL.md +5 -1
  18. package/pipeline/commands/sim-test.md +71 -6
  19. package/pipeline/lib/plugin-skill-roots.mjs +80 -0
  20. package/pipeline/lib/usage-lifecycle.mjs +83 -0
  21. package/pipeline/multi-agent-refs/cross-cli-contract.md +20 -4
  22. package/pipeline/multi-agent-refs/features/assertion-judge.md +76 -0
  23. package/pipeline/multi-agent-refs/features/exploratory-testing.md +140 -0
  24. package/pipeline/multi-agent-refs/features/skill-conformance.md +1 -1
  25. package/pipeline/multi-agent-refs/features/usage-reporting.md +129 -36
  26. package/pipeline/multi-agent-refs/features/valven.md +30 -3
  27. package/pipeline/multi-agent-refs/help/en.md +2 -0
  28. package/pipeline/multi-agent-refs/help/tr.md +2 -0
  29. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  30. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +2 -2
  31. package/pipeline/schemas/bug-bash-verification.schema.json +41 -0
  32. package/pipeline/schemas/explore-run.schema.json +108 -0
  33. package/pipeline/schemas/prefs.schema.json +24 -3
  34. package/pipeline/scripts/_stack-routing.mjs +2 -0
  35. package/pipeline/scripts/bug-bash.mjs +379 -0
  36. package/pipeline/scripts/explore-findings.mjs +410 -0
  37. package/pipeline/scripts/gen-skills-index.mjs +1 -2
  38. package/pipeline/scripts/pr-timeline.mjs +135 -0
  39. package/pipeline/scripts/pre-commit-check.sh +4 -2
  40. package/pipeline/scripts/skill-conformance.mjs +17 -71
  41. package/pipeline/scripts/smoke-cross-cli-behavior.sh +1 -1
  42. package/pipeline/scripts/uninstall.mjs +29 -8
  43. package/pipeline/scripts/usage-identity.mjs +311 -26
  44. package/pipeline/scripts/usage-register.mjs +268 -62
  45. package/pipeline/scripts/usage-report.mjs +59 -7
  46. package/pipeline/scripts/valven-gate.mjs +292 -11
  47. package/pipeline/skills/.skill-manifest.json +18 -10
  48. package/pipeline/skills/.skills-index.json +28 -6
  49. package/pipeline/skills/shared/README.md +6 -4
  50. package/pipeline/skills/shared/core/multi-agent-bug-bash/SKILL.md +150 -0
  51. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +1 -1
  52. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +5 -1
  53. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +4 -3
  54. package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +4 -1
  55. package/pipeline/skills/shared/external/e2e-testing/SKILL.md +124 -0
  56. package/pipeline/skills/shared/external/e2e-testing/references/document-fixtures.md +65 -0
  57. package/pipeline/skills/shared/external/valven-rules/SKILL.md +93 -0
  58. package/pipeline/skills/shared/{core → external}/valven-rules/references/rules.yml +52 -6
  59. package/pipeline/skills/skills-index.md +4 -2
  60. package/pipeline/skills/shared/core/valven-rules/SKILL.md +0 -62
@@ -18,15 +18,19 @@
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
22
- * logged-in GitHub account that can read the pipeline repository
23
- * (usage-identity.mjs). No such account, no registration. The request
24
- * carries that account's gh token as `Authorization: Bearer` so the server
25
- * can confirm the caller owns the login; the server pins the login to the
26
- * token it issues.
27
- * - It never sends the gh token over plain http to anything but loopback, never
28
- * follows a redirect with it, never prints it, and never puts it on an argv:
29
- * gh prints it on its own stdout and it goes straight into the header.
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:
25
+ * a gh or credential-helper token as `Authorization: Bearer`, or for an SSH
26
+ * key a signature over a one-time server challenge
27
+ * (`ssh-keygen -Y sign`), sent with no Authorization header at all. The
28
+ * server pins the login to the token it issues.
29
+ * - It never sends a GitHub token over plain http to anything but loopback,
30
+ * never follows a redirect with it, never prints it, never stores it, and
31
+ * never puts it on an argv: gh or git prints it on its own stdout and it goes
32
+ * straight into the header. The credential-helper token is read again at
33
+ * registration rather than kept from the identity check.
30
34
  * - It never fails a caller. Offline, endpoint down, ingest disabled by the
31
35
  * admin, no credential store: one status line, exit 0.
32
36
  * - It never reports run CONTENT. That is usage-report.mjs's contract, and it
@@ -35,22 +39,29 @@
35
39
  * Usage:
36
40
  * node usage-register.mjs [--prefs <path>] [--json] [--quiet] [--dry-run] [--feedback] [--refresh]
37
41
  *
38
- * `--refresh` re-checks which logged-in GitHub account can read the pipeline
39
- * repository instead of using the cached `usageLog.login`.
42
+ * `--refresh` re-checks which GitHub account on this machine can read the
43
+ * pipeline repository instead of using the cached `usageLog.login`.
40
44
  *
41
45
  * Exit: always 0. `--json` says what happened; the shell does not need to care.
42
46
  *
43
47
  * @module pipeline/scripts/usage-register
44
48
  */
45
49
 
46
- import { existsSync, readFileSync } from "node:fs";
47
- import { spawnSync } from "node:child_process";
50
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
51
+ import { spawn, spawnSync } from "node:child_process";
48
52
  import { join } from "node:path";
49
- import { homedir, hostname } from "node:os";
53
+ import { homedir, hostname, tmpdir } from "node:os";
50
54
  import { pathToFileURL } from "node:url";
51
55
  import { hostCommand } from "../lib/user-facing.mjs";
52
56
  import { canonicalEndpoint } from "../lib/usage-endpoint.mjs";
53
- import { ghTokenFor, resolveReportingLogin } from "./usage-identity.mjs";
57
+ import {
58
+ SSH_TIMEOUT_MS,
59
+ credentialToken,
60
+ displayPath,
61
+ ghTokenFor,
62
+ resolveReportingLogin,
63
+ sshKeygenBin,
64
+ } from "./usage-identity.mjs";
54
65
  import { lockTimeoutFrom, updateJsonFileSync } from "../lib/json-file-lock.mjs";
55
66
 
56
67
  const DEFAULT_ENDPOINT = "https://mmerterden.com/api/usage/ingest";
@@ -66,10 +77,10 @@ function readJson(file) {
66
77
  }
67
78
 
68
79
  /**
69
- * The name this machine registers under: the logged-in GitHub account that can
70
- * read the pipeline repository (usage-identity.mjs), or null when none can.
71
- * Never the git identity, never the OS user, never the account gh merely has
72
- * active.
80
+ * The name this machine registers under: the GitHub account on this machine
81
+ * that can read the pipeline repository (usage-identity.mjs), with the method
82
+ * it was found by, or login null when none can. Never the git identity, never
83
+ * the OS user, never the account gh merely has active.
73
84
  */
74
85
  export function reportingUser(prefsPath, { env = process.env, refresh = false } = {}) {
75
86
  return resolveReportingLogin({ prefsPath, env, refresh });
@@ -82,6 +93,12 @@ export function reportingUser(prefsPath, { env = process.env, refresh = false }
82
93
  * them, and "you opted out" and "we could not reach the endpoint" are different
83
94
  * facts about the same silence.
84
95
  */
96
+ function pinned(u) {
97
+ return (
98
+ typeof u.registeredAs === "string" && u.registeredAs !== "" && u.registeredFallback !== true
99
+ );
100
+ }
101
+
85
102
  export function decideRegistration(
86
103
  prefs,
87
104
  {
@@ -102,8 +119,18 @@ export function decideRegistration(
102
119
  if (env.MULTI_AGENT_USAGE_TOKEN) return { act: false, reason: "token in environment" };
103
120
  if (typeof u.token === "string" && u.token.trim())
104
121
  return { act: false, reason: "token in prefs" };
122
+ // A registered token stays pinned to its login whatever this machine holds
123
+ // later, so an account verified once keeps naming the runs after it is logged
124
+ // out. A registration marked `registeredFallback` (20.17.0) was made with an
125
+ // account that cannot read the repository, so it is not pinned.
126
+ if (hasStoredToken && login === null && pinned(u)) {
127
+ return { act: false, reason: "token already onboarded" };
128
+ }
105
129
  if (login === null) {
106
- return { act: false, reason: loginReason || "no logged-in GitHub account has access" };
130
+ return { act: false, reason: loginReason || "no GitHub account on this machine has access" };
131
+ }
132
+ if (hasStoredToken && login && u.registeredFallback === true) {
133
+ return { act: true, reason: `replacing a fallback registration with ${login}` };
107
134
  }
108
135
  // The server pins a token to the login it was issued for. A token issued to a
109
136
  // different login, or before the login was verified, reports under the wrong
@@ -150,56 +177,148 @@ export function registerUrlAllowed(url, trustedHosts = []) {
150
177
  return trusted.has(u.hostname.toLowerCase());
151
178
  }
152
179
 
153
- async function requestToken(url, body, proof) {
180
+ async function postJson(url, body, bearer = null) {
154
181
  const ctrl = new AbortController();
155
182
  const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
183
+ const headers = { "Content-Type": "application/json" };
184
+ if (bearer) headers.Authorization = `Bearer ${bearer}`;
156
185
  try {
157
186
  const res = await fetch(url, {
158
187
  method: "POST",
159
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${proof}` },
188
+ headers,
160
189
  body: JSON.stringify(body),
161
190
  redirect: "error",
162
191
  signal: ctrl.signal,
163
192
  });
164
193
  const json = await res.json().catch(() => null);
165
- if (!res.ok) {
166
- const detail = typeof json?.error === "string" ? `: ${json.error.slice(0, 120)}` : "";
167
- if (json?.code === "login_mismatch") {
168
- return {
169
- token: null,
170
- why: `the gh token does not belong to ${body.u}; run \`gh auth status\` and retry with --refresh`,
171
- };
172
- }
173
- if (res.status === 401) {
174
- return {
175
- token: null,
176
- why:
177
- `endpoint answered ${res.status}${detail} - GitHub did not accept the token for ${body.u}; ` +
178
- `check \`gh auth status\` lists that account, then retry with --refresh`,
179
- };
180
- }
181
- if (res.status === 403) {
182
- return {
183
- token: null,
184
- why: `endpoint answered 403${detail} - ${body.u} needs collaborator access to the pipeline repository`,
185
- };
186
- }
187
- return { token: null, why: `endpoint answered ${res.status}${detail}` };
188
- }
189
- if (json?.accepted === false)
190
- return { token: null, declined: true, why: "the endpoint declined this login" };
191
- const token = typeof json?.token === "string" ? json.token.trim() : "";
192
- return token ? { token, why: null } : { token: null, why: "endpoint returned no token" };
194
+ return { res, json, failure: null };
193
195
  } catch (e) {
194
196
  return {
195
- token: null,
196
- why: e?.name === "AbortError" ? "endpoint timed out" : "endpoint unreachable",
197
+ res: null,
198
+ json: null,
199
+ failure: e?.name === "AbortError" ? "endpoint timed out" : "endpoint unreachable",
197
200
  };
198
201
  } finally {
199
202
  clearTimeout(timer);
200
203
  }
201
204
  }
202
205
 
206
+ const SSH_REFUSALS = {
207
+ ssh_signature_invalid: (u) =>
208
+ `the server could not verify the SSH signature for ${u}; retry with --refresh`,
209
+ ssh_key_not_on_account: (u) =>
210
+ `the SSH key is not on ${u}'s GitHub account; add its .pub under GitHub Settings > SSH keys, then retry with --refresh`,
211
+ ssh_key_unsupported: (u) =>
212
+ `the server does not accept this SSH key type for ${u}; put an ed25519, rsa or ecdsa-p256 key on that account, then retry with --refresh`,
213
+ };
214
+
215
+ function refusal(status, json, body, method) {
216
+ const detail = typeof json?.error === "string" ? `: ${json.error.slice(0, 120)}` : "";
217
+ const code = typeof json?.code === "string" ? json.code : "";
218
+ if (SSH_REFUSALS[code]) return `endpoint answered ${status} - ${SSH_REFUSALS[code](body.u)}`;
219
+ if (code === "login_mismatch") {
220
+ return method === "gh"
221
+ ? `the gh token does not belong to ${body.u}; run \`gh auth status\` and retry with --refresh`
222
+ : `the proof does not belong to ${body.u}; retry with --refresh`;
223
+ }
224
+ if (status === 401) {
225
+ const fix =
226
+ method === "gh"
227
+ ? "check `gh auth status` lists that account, then retry with --refresh"
228
+ : "retry with --refresh";
229
+ return `endpoint answered ${status}${detail} - GitHub did not accept the proof for ${body.u}; ${fix}`;
230
+ }
231
+ if (status === 403) {
232
+ return (
233
+ `endpoint answered 403${detail} - ${body.u} needs collaborator access to the pipeline repository; ` +
234
+ "log in the account that has it (`gh auth login`, or add its SSH key), then rerun with --refresh"
235
+ );
236
+ }
237
+ return `endpoint answered ${status}${detail}`;
238
+ }
239
+
240
+ async function requestToken(url, body, proof, method) {
241
+ const { res, json, failure } = await postJson(url, body, proof);
242
+ if (failure) return { token: null, why: failure };
243
+ if (!res.ok) return { token: null, why: refusal(res.status, json, body, method) };
244
+ if (json?.accepted === false)
245
+ return { token: null, declined: true, why: "the endpoint declined this login" };
246
+ const token = typeof json?.token === "string" ? json.token.trim() : "";
247
+ const pinnedLogin = typeof json?.u === "string" && json.u.trim() ? json.u.trim() : null;
248
+ return token
249
+ ? { token, why: null, login: pinnedLogin }
250
+ : { token: null, why: "endpoint returned no token" };
251
+ }
252
+
253
+ /**
254
+ * Sign `challenge` with the SSH key at `key` under `namespace`. The exact
255
+ * challenge bytes go into a 0600 file in a fresh temp dir that is removed
256
+ * afterwards. stdin is closed and askpass is disabled, so a key that needs a
257
+ * passphrase fails at once; the `.pub` is then tried, which signs through
258
+ * ssh-agent when the key is loaded there. Returns the armored signature or null.
259
+ */
260
+ export function sshSign(key, namespace, challenge, env = process.env) {
261
+ let dir = null;
262
+ try {
263
+ dir = mkdtempSync(join(tmpdir(), "ma-usage-sign-"));
264
+ const file = join(dir, "challenge");
265
+ writeFileSync(file, challenge, { mode: 0o600 });
266
+ const tries = key.endsWith(".pub") ? [key] : [key, `${key}.pub`];
267
+ for (const f of tries) {
268
+ if (!existsSync(f)) continue;
269
+ rmSync(`${file}.sig`, { force: true });
270
+ const r = spawnSync(sshKeygenBin(env), ["-Y", "sign", "-f", f, "-n", namespace, file], {
271
+ encoding: "utf8",
272
+ timeout: SSH_TIMEOUT_MS,
273
+ stdio: ["ignore", "ignore", "ignore"],
274
+ env: { ...env, SSH_ASKPASS_REQUIRE: "never" },
275
+ });
276
+ if (r.status === 0 && existsSync(`${file}.sig`)) {
277
+ const sig = readFileSync(`${file}.sig`, "utf8");
278
+ if (sig.includes("BEGIN SSH SIGNATURE")) return sig;
279
+ }
280
+ }
281
+ return null;
282
+ } catch {
283
+ return null;
284
+ } finally {
285
+ if (dir) rmSync(dir, { recursive: true, force: true });
286
+ }
287
+ }
288
+
289
+ /** The challenge URL next to the register URL. */
290
+ export function challengeUrl(url) {
291
+ return `${url.replace(/\/+$/, "")}/challenge`;
292
+ }
293
+
294
+ async function requestTokenWithSsh(url, body, key) {
295
+ const ch = await postJson(challengeUrl(url), { u: body.u });
296
+ if (ch.failure) return { token: null, why: ch.failure };
297
+ if (!ch.res.ok) {
298
+ if (ch.res.status === 404 || ch.res.status === 405) {
299
+ return {
300
+ token: null,
301
+ why: `the endpoint does not accept an SSH key as proof yet (challenge answered ${ch.res.status})`,
302
+ };
303
+ }
304
+ return { token: null, why: refusal(ch.res.status, ch.json, body, "ssh") };
305
+ }
306
+ const challenge = typeof ch.json?.challenge === "string" ? ch.json.challenge : "";
307
+ const namespace = typeof ch.json?.namespace === "string" ? ch.json.namespace : "";
308
+ if (!challenge || !/^[A-Za-z0-9@._-]{1,128}$/.test(namespace)) {
309
+ return { token: null, why: "the endpoint returned no usable SSH challenge" };
310
+ }
311
+ const signature = sshSign(key, namespace, challenge);
312
+ if (!signature) {
313
+ const shown = displayPath(key);
314
+ return {
315
+ token: null,
316
+ 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`,
317
+ };
318
+ }
319
+ return requestToken(url, { ...body, ssh: { challenge, signature } }, null, "ssh");
320
+ }
321
+
203
322
  /**
204
323
  * Write the switch, and the credential-store ENTRY NAME when there is one.
205
324
  *
@@ -227,7 +346,11 @@ export function persistPrefs(
227
346
  }
228
347
  j.global.usageLog = j.global.usageLog || {};
229
348
  if (enableTelemetry) j.global.usageLog.enabled = true;
230
- if (registeredAs) j.global.usageLog.registeredAs = registeredAs;
349
+ if (registeredAs) {
350
+ j.global.usageLog.registeredAs = registeredAs;
351
+ delete j.global.usageLog.registeredFallback;
352
+ j.global.usageLog.registeredAt = new Date().toISOString();
353
+ }
231
354
  return j;
232
355
  },
233
356
  { mode: 0o600, followSymlink: true, timeoutMs: lockTimeoutFrom(process.env) },
@@ -238,6 +361,62 @@ export function persistPrefs(
238
361
  }
239
362
  }
240
363
 
364
+ /**
365
+ * True when this machine's runs are not pinned to a verified account AND no
366
+ * GitHub account on it can read the pipeline repository (no gh account, no
367
+ * credential-helper token and no SSH key with access). `login` is the identity
368
+ * check's answer when it ran; otherwise the cached `usageLog.login` stands in.
369
+ * Setup and update read it from `--json` and offer the fix once.
370
+ */
371
+ export function personalAccountMissing(prefs, login = undefined, env = process.env) {
372
+ const u = prefs?.global?.usageLog || {};
373
+ if (u.optOut === true) return false;
374
+ if (env.MULTI_AGENT_USAGE_TOKEN) return false;
375
+ if (typeof u.token === "string" && u.token.trim()) return false;
376
+ if (pinned(u)) return false;
377
+ const found =
378
+ login !== undefined
379
+ ? login
380
+ : u.loginFallback === true || typeof u.login !== "string"
381
+ ? null
382
+ : u.login;
383
+ return !found;
384
+ }
385
+
386
+ /**
387
+ * Report the installed version when it changed since the last report, detached.
388
+ * A first install happens before registration and an update runs this call
389
+ * right after installing, so both are reported from here.
390
+ */
391
+ function sendInstalledVersion(prefsPath) {
392
+ try {
393
+ spawn(process.execPath, [join(import.meta.dirname, "usage-report.mjs"), "--lifecycle"], {
394
+ detached: true,
395
+ stdio: "ignore",
396
+ env: { ...process.env, MULTI_AGENT_PREFS: prefsPath },
397
+ }).unref();
398
+ } catch {
399
+ // reporting never fails the caller
400
+ }
401
+ }
402
+
403
+ const QUIET_REASONS = new Set([
404
+ "opted out",
405
+ "token in environment",
406
+ "token in prefs",
407
+ "token already onboarded",
408
+ "no prefs",
409
+ ]);
410
+
411
+ /**
412
+ * Whether an outcome leaves a machine silent for a reason its user can fix.
413
+ * `--quiet` callers discard stdout, so this is the one line they still see.
414
+ */
415
+ function reportingStaysOff(status, detail) {
416
+ if (status === "unavailable" || status === "declined") return true;
417
+ return status === "skipped" && !QUIET_REASONS.has(detail);
418
+ }
419
+
241
420
  async function main(argv) {
242
421
  const opts = {
243
422
  quiet: false,
@@ -263,8 +442,15 @@ async function main(argv) {
263
442
  const say = (line) => {
264
443
  if (!opts.quiet) process.stdout.write(`${line}\n`);
265
444
  };
445
+ const identity = { login: undefined };
266
446
  const done = (status, detail) => {
267
- if (opts.json) process.stdout.write(`${JSON.stringify({ status, detail })}\n`);
447
+ if (opts.json) {
448
+ const needsPersonalAccount = personalAccountMissing(readJson(prefsPath), identity.login);
449
+ process.stdout.write(`${JSON.stringify({ status, detail, needsPersonalAccount })}\n`);
450
+ }
451
+ if (opts.quiet && reportingStaysOff(status, detail)) {
452
+ process.stderr.write(`multi-agent: usage reporting is off - ${detail}\n`);
453
+ }
268
454
  return 0;
269
455
  };
270
456
 
@@ -289,6 +475,7 @@ async function main(argv) {
289
475
  const who = identityMatters
290
476
  ? reportingUser(prefsPath, { refresh: opts.refresh })
291
477
  : { login: undefined, reason: null };
478
+ identity.login = who.login;
292
479
  const current = readJson(prefsPath) || prefs;
293
480
  const decision = decideRegistration(current, {
294
481
  hasStoredToken: has,
@@ -305,12 +492,19 @@ async function main(argv) {
305
492
  const name = current.global?.keychainMapping?.usage_ingest || null;
306
493
  if (persistPrefs(prefsPath, name)) {
307
494
  say(" -> operational reporting: enabled (a token was already onboarded)");
495
+ sendInstalledVersion(prefsPath);
308
496
  return done("enabled", decision.reason);
309
497
  }
310
498
  say(" -> operational reporting: could not write preferences");
311
499
  return done("unavailable", "prefs write failed");
312
500
  }
313
501
  }
502
+ if (
503
+ decision.reason === "token already onboarded" &&
504
+ current.global?.usageLog?.enabled === true
505
+ ) {
506
+ sendInstalledVersion(prefsPath);
507
+ }
314
508
  say(` -> operational reporting: unchanged (${decision.reason})`);
315
509
  return done("skipped", decision.reason);
316
510
  }
@@ -336,14 +530,22 @@ async function main(argv) {
336
530
  return done("dry-run", url);
337
531
  }
338
532
 
339
- const proof = ghTokenFor(user);
340
- if (!proof) {
341
- const why = `gh has no token for ${user}; run \`gh auth login\` for that account`;
342
- say(` -> operational reporting left off (${why})`);
343
- return done("unavailable", why);
533
+ let result;
534
+ if (who.method === "ssh") {
535
+ result = await requestTokenWithSsh(url, { u: user, c: host }, who.key);
536
+ } else {
537
+ const proof = who.method === "credential" ? credentialToken() : ghTokenFor(user);
538
+ if (!proof) {
539
+ const why =
540
+ who.method === "credential"
541
+ ? `the git credential helper no longer returns a GitHub token for ${user}; retry with --refresh`
542
+ : `gh has no token for ${user}; run \`gh auth login\` for that account`;
543
+ say(` -> operational reporting left off (${why})`);
544
+ return done("unavailable", why);
545
+ }
546
+ result = await requestToken(url, { u: user, c: host }, proof, who.method || "gh");
344
547
  }
345
-
346
- const { token, why, declined } = await requestToken(url, { u: user, c: host }, proof);
548
+ const { token, why, declined, login: pinnedLogin } = result;
347
549
  if (declined) {
348
550
  say(` -> operational reporting left off (${why})`);
349
551
  return done("declined", why);
@@ -364,7 +566,10 @@ async function main(argv) {
364
566
  say(" -> operational reporting left off (the credential store refused the token)");
365
567
  return done("unavailable", "credential store write failed");
366
568
  }
367
- persistPrefs(prefsPath, entry, { enableTelemetry: !optedOut, registeredAs: user });
569
+ persistPrefs(prefsPath, entry, {
570
+ enableTelemetry: !optedOut,
571
+ registeredAs: pinnedLogin || user,
572
+ });
368
573
  if (optedOut) {
369
574
  say(" -> feedback token registered; telemetry stays off (usageLog.optOut is set)");
370
575
  return done("registered-feedback-only", entry);
@@ -373,6 +578,7 @@ async function main(argv) {
373
578
  " -> operational reporting: registered this machine (write-only token in the credential store)",
374
579
  );
375
580
  say(" opt out any time: set global.usageLog.optOut=true in multi-agent-preferences.json");
581
+ sendInstalledVersion(prefsPath);
376
582
  return done("registered", entry);
377
583
  }
378
584
 
@@ -2,8 +2,9 @@
2
2
  * usage-report.mjs - optional, opt-in per-run operational reporting.
3
3
  *
4
4
  * Off by default; a no-op unless prefs.global.usageLog.enabled is true, a
5
- * token resolves, and a logged-in GitHub account can read the pipeline
6
- * repository (usage-identity.mjs). Emits one compact run record keyed by run
5
+ * token resolves, and a GitHub account on this machine (gh, the git
6
+ * credential helper or an SSH key) can read the pipeline repository
7
+ * (usage-identity.mjs). Emits one compact run record keyed by run
7
8
  * id (`taskId`), upserted at every phase transition and at completion, halt
8
9
  * and park, fire-and-forget - never throws, never delays a run.
9
10
  * Only coarse run metadata is included; never prompts, code, diffs, token
@@ -22,6 +23,7 @@
22
23
  * node usage-report.mjs --state /path/to/agent-state.json [--event <kind>]
23
24
  * node usage-report.mjs --task-id PROJ-123 [--phase N] [--event <kind>]
24
25
  *
26
+ * node usage-report.mjs --lifecycle (report the installed version when it changed)
25
27
  * --event phase | completed | halted | parked (default: derived from state)
26
28
  *
27
29
  * @module pipeline/scripts/usage-report
@@ -44,11 +46,13 @@ import { execFileSync } from "child_process";
44
46
  import { createHash } from "crypto";
45
47
  import { costUsd } from "./_cost.mjs";
46
48
  import { defaultPrefsPath, resolveReportingLogin } from "./usage-identity.mjs";
49
+ import { installedVersion, lifecycleEvent } from "../lib/usage-lifecycle.mjs";
50
+ import { lockTimeoutFrom, updateJsonFileSync } from "../lib/json-file-lock.mjs";
47
51
  import { KNOWN_STACKS, pluginsForStacks } from "./_stack-routing.mjs";
48
52
 
49
53
  const __dirname = dirname(fileURLToPath(import.meta.url));
50
54
  const ENDPOINT_DEFAULT = "https://mmerterden.com/api/usage/ingest";
51
- const TIMEOUT_MS = 2500;
55
+ const TIMEOUT_MS = 6000;
52
56
 
53
57
  // The emitter ships verbatim into ~/.claude, ~/.copilot and ~/.codex, so it must
54
58
  // never hard-code one host's tree: a Copilot- or Codex-only install has no
@@ -71,6 +75,7 @@ function parseArgs(argv) {
71
75
  status: null,
72
76
  event: null,
73
77
  dryRun: false,
78
+ lifecycle: false,
74
79
  };
75
80
  for (let i = 0; i < argv.length; i += 1) {
76
81
  const a = argv[i];
@@ -80,6 +85,7 @@ function parseArgs(argv) {
80
85
  else if (a === "--status") out.status = argv[++i];
81
86
  else if (a === "--event") out.event = argv[++i];
82
87
  else if (a === "--dry-run") out.dryRun = true;
88
+ else if (a === "--lifecycle") out.lifecycle = true;
83
89
  }
84
90
  return out;
85
91
  }
@@ -659,9 +665,9 @@ function appendErrorLedger(event) {
659
665
  }
660
666
  }
661
667
 
662
- // Telemetry identity is the GitHub account that can read the pipeline
663
- // repository (usage-identity.mjs), never the git identity.name and never the
664
- // account gh merely has active. The server replaces it with the login pinned to
668
+ // Telemetry identity is the GitHub account on this machine that can read the
669
+ // pipeline repository (usage-identity.mjs), never the git identity.name and
670
+ // never the account gh merely has active. The server replaces it with the login pinned to
665
671
  // the ingest token, so this value is informational.
666
672
  function resolveUser(opts = {}) {
667
673
  return resolveReportingLogin(opts).login;
@@ -806,6 +812,43 @@ function endpointAllowed(endpoint) {
806
812
  return false;
807
813
  }
808
814
 
815
+ /**
816
+ * Send an install or update record when the installed version (from the
817
+ * installer's stamp) is not the last one reported, and move
818
+ * `usageLog.reportedVersion` forward once the server accepted it.
819
+ */
820
+ async function reportInstalledVersion(endpoint, token, login, usageLog) {
821
+ const e = lifecycleEvent({ installed: installedVersion(homedir()), usageLog });
822
+ if (!e) return;
823
+ const r = await post(endpoint, token, { ...e, u: login, os: detectOs(), cli: detectCli() });
824
+ if (r.outcome !== "accepted") return;
825
+ try {
826
+ updateJsonFileSync(
827
+ defaultPrefsPath(),
828
+ (j) => {
829
+ if (!j || typeof j !== "object" || Array.isArray(j)) return undefined;
830
+ j.global = j.global || {};
831
+ j.global.usageLog = { ...(j.global.usageLog || {}), reportedVersion: e.v };
832
+ return j;
833
+ },
834
+ { mode: 0o600, followSymlink: true, timeoutMs: lockTimeoutFrom(process.env) },
835
+ );
836
+ } catch {
837
+ // the next send reports the same version again, under the same id
838
+ }
839
+ }
840
+
841
+ /**
842
+ * The login a registered token is pinned to. The server reports every record
843
+ * under it whatever the client sends, so a machine that registered once needs
844
+ * no gh call to name its runs, and keeps the name after that account logs out.
845
+ */
846
+ function registeredLogin(prefs) {
847
+ if (prefs?.registeredFallback === true) return null;
848
+ const login = typeof prefs?.registeredAs === "string" ? prefs.registeredAs.trim() : "";
849
+ return /^[A-Za-z0-9][A-Za-z0-9_-]{0,38}$/.test(login) ? { login, reason: "registered" } : null;
850
+ }
851
+
809
852
  async function main() {
810
853
  const args = parseArgs(process.argv.slice(2));
811
854
  if (process.env.MULTI_AGENT_SMOKE && !args.dryRun) return;
@@ -829,6 +872,13 @@ async function main() {
829
872
  return;
830
873
  }
831
874
 
875
+ if (args.lifecycle) {
876
+ if (args.dryRun) return;
877
+ const who = registeredLogin(prefs) ?? resolveReportingLogin();
878
+ if (who.login) await reportInstalledVersion(endpoint, token, who.login, prefs);
879
+ return;
880
+ }
881
+
832
882
  const statePath = resolveStatePath(args);
833
883
  let state = statePath ? readJson(statePath) : null;
834
884
 
@@ -844,7 +894,7 @@ async function main() {
844
894
  if (args.status) state.status = args.status;
845
895
  const kind = EVENT_KINDS.includes(args.event) ? args.event : null;
846
896
 
847
- const who = resolveReportingLogin();
897
+ const who = registeredLogin(prefs) ?? resolveReportingLogin();
848
898
  if (!who.login) {
849
899
  process.stderr.write(`usage-report: not sent - ${who.reason}\n`);
850
900
  if (!args.dryRun) {
@@ -863,6 +913,8 @@ async function main() {
863
913
  if (event.st !== "in_progress") appendErrorLedger(event);
864
914
  const result = await post(endpoint, token, event);
865
915
  recordSend({ id: event.id, ev: event.ev, host: hostOf(endpoint), ...result });
916
+ if (result.outcome === "accepted")
917
+ await reportInstalledVersion(endpoint, token, who.login, prefs);
866
918
  if (result.outcome !== "accepted") {
867
919
  const detail = result.status ?? result.reason;
868
920
  const where = result.location ? ` to ${result.location}` : "";