@mmerterden/multi-agent-pipeline 20.16.0 → 20.17.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 (60) hide show
  1. package/CHANGELOG.md +30 -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 +9 -1
  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 +59 -5
  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 +1 -1
  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 +36 -10
  44. package/pipeline/scripts/usage-register.mjs +95 -8
  45. package/pipeline/scripts/usage-report.mjs +52 -2
  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
@@ -81,11 +81,15 @@ export const PIPELINE_AGENT_FILES = [
81
81
  * Non-multi-agent core skills the installer ships from
82
82
  * pipeline/skills/shared/core/ (the multi-agent* dirs are matched by prefix).
83
83
  */
84
- export const PIPELINE_CORE_SKILL_DIRS = [
85
- "apple-archive-compliance",
86
- "google-play-compliance",
87
- "valven-rules",
88
- ];
84
+ export const PIPELINE_CORE_SKILL_DIRS = ["apple-archive-compliance", "google-play-compliance"];
85
+
86
+ /**
87
+ * Skills an older install placed in the skills dir that now reach the host from
88
+ * a marketplace plugin (Claude Code) or the external catalog (Copilot, Codex).
89
+ * An install upgraded since then has already pruned them; one that was not
90
+ * still carries the old copy, so uninstall removes these names too.
91
+ */
92
+ export const PIPELINE_RETIRED_SKILL_DIRS = ["valven-rules"];
89
93
 
90
94
  /**
91
95
  * Files the installer ships into ~/.claude/templates (install/templates/).
@@ -323,7 +327,7 @@ function rmManifestFiles(hostDir, keepInLib) {
323
327
  * @param {string} path
324
328
  */
325
329
  function rmIfExists(path) {
326
- if (!existsSync(path)) return false;
330
+ if (!existsSync(path) && !isSymlink(path)) return false;
327
331
  if (dryRun) {
328
332
  report("would remove", path);
329
333
  return false;
@@ -538,6 +542,15 @@ function rmExternalDeliveredSkills(skillsDir) {
538
542
  return n;
539
543
  }
540
544
 
545
+ /** True for a symlink, dangling or not; existsSync follows the link and misses a dangling one. */
546
+ function isSymlink(path) {
547
+ try {
548
+ return lstatSync(path).isSymbolicLink();
549
+ } catch {
550
+ return false;
551
+ }
552
+ }
553
+
541
554
  function rmMatchingDirs(parent, predicate) {
542
555
  if (!existsSync(parent)) return 0;
543
556
  let count = 0;
@@ -879,7 +892,11 @@ export async function main() {
879
892
  name === "figma-common" ||
880
893
  name === "figma-to-component",
881
894
  );
882
- n += rmMatchingDirs(skills, (name) => PIPELINE_CORE_SKILL_DIRS.includes(name));
895
+ n += rmMatchingDirs(
896
+ skills,
897
+ (name) =>
898
+ PIPELINE_CORE_SKILL_DIRS.includes(name) || PIPELINE_RETIRED_SKILL_DIRS.includes(name),
899
+ );
883
900
  n += rmExternalDeliveredSkills(skills);
884
901
  if (n > 0) console.log(` removed ${n} skill dir(s) under ${skills}`);
885
902
  rmIfExists(join(skills, ".skills-index.json"));
@@ -914,7 +931,11 @@ export async function main() {
914
931
  name === "figma-common" ||
915
932
  name === "figma-to-component",
916
933
  );
917
- n += rmMatchingDirs(skills, (name) => PIPELINE_CORE_SKILL_DIRS.includes(name));
934
+ n += rmMatchingDirs(
935
+ skills,
936
+ (name) =>
937
+ PIPELINE_CORE_SKILL_DIRS.includes(name) || PIPELINE_RETIRED_SKILL_DIRS.includes(name),
938
+ );
918
939
  n += rmPluginDeliveredSkills(skills);
919
940
  n += rmExternalDeliveredSkills(skills);
920
941
  if (n > 0) console.log(` removed ${n} skill dir(s) under ${skills}`);
@@ -16,9 +16,16 @@
16
16
  * account's token in the child's environment (GH_TOKEN), never on argv and
17
17
  * never written anywhere. The first account that can read the repository wins.
18
18
  *
19
+ * When none can, the active account (else the first) is used as a fallback and
20
+ * marked so (`fallback: true`, `usageLog.loginFallback`). A work machine often
21
+ * has only an enterprise-managed account logged in, which can never be a
22
+ * collaborator of a personal repository; the server decides whether it accepts
23
+ * that login, and refuses it with a status line when it does not. A fallback is
24
+ * cached for NO_LOGIN_TTL_MS, so an account with access added later takes over.
25
+ *
19
26
  * The result is cached in prefs as `usageLog.login` + `usageLog.loginCheckedAt`
20
27
  * (and `usageLog.loginRepo`; `usageLog.loginReason` says why none qualified), re-checked after LOGIN_TTL_MS, or after
21
- * NO_LOGIN_TTL_MS when no account had access, or on `--refresh`.
28
+ * NO_LOGIN_TTL_MS when no account had access or the login is a fallback, or on `--refresh`.
22
29
  *
23
30
  * Usage:
24
31
  * node usage-identity.mjs [--refresh] [--prefs <path>] [--json]
@@ -158,7 +165,8 @@ function orderCandidates(accounts, preferred) {
158
165
 
159
166
  /**
160
167
  * Check each logged-in account against the access repository, live.
161
- * Returns { login, reason } where login is null when no account has access.
168
+ * Returns { login, fallback, reason }: the first account with access, else the
169
+ * active (or first) account with `fallback: true`, else login null.
162
170
  */
163
171
  export function checkAccess({
164
172
  repo = ACCESS_REPO_DEFAULT,
@@ -169,16 +177,22 @@ export function checkAccess({
169
177
  if (accounts.length === 0) {
170
178
  return {
171
179
  login: null,
180
+ fallback: false,
172
181
  reason: `no GitHub account is logged in to gh, so ${repo} cannot be checked`,
173
182
  };
174
183
  }
175
- for (const a of orderCandidates(accounts, preferred)) {
184
+ const ordered = orderCandidates(accounts, preferred);
185
+ for (const a of ordered) {
176
186
  const token = tokenFor(a.login, env);
177
- if (token && canRead(repo, token, env)) return { login: a.login, reason: `can read ${repo}` };
187
+ if (token && canRead(repo, token, env)) {
188
+ return { login: a.login, fallback: false, reason: `can read ${repo}` };
189
+ }
178
190
  }
191
+ const fallback = accounts.find((a) => a.active) || accounts[0];
179
192
  return {
180
- login: null,
181
- reason: `none of the logged-in GitHub accounts (${accounts.map((a) => a.login).join(", ")}) can read ${repo}`,
193
+ login: fallback.login,
194
+ fallback: true,
195
+ reason: `none of the logged-in GitHub accounts (${accounts.map((a) => a.login).join(", ")}) can read ${repo}; the server decides whether it accepts ${fallback.login}`,
182
196
  };
183
197
  }
184
198
 
@@ -186,7 +200,7 @@ function cacheFresh(u, repo, now) {
186
200
  const at = Date.parse(u?.loginCheckedAt || "");
187
201
  if (Number.isNaN(at)) return false;
188
202
  if ((u.loginRepo || ACCESS_REPO_DEFAULT) !== repo) return false;
189
- const ttl = u.login ? LOGIN_TTL_MS : NO_LOGIN_TTL_MS;
203
+ const ttl = u.login && u.loginFallback !== true ? LOGIN_TTL_MS : NO_LOGIN_TTL_MS;
190
204
  return now - at < ttl && now >= at;
191
205
  }
192
206
 
@@ -224,10 +238,19 @@ export function resolveReportingLogin({
224
238
  const repo = accessRepoOf(prefs);
225
239
  if (!refresh && cacheFresh(u, repo, now)) {
226
240
  if (typeof u.login === "string" && LOGIN_RE.test(u.login)) {
227
- return { login: u.login, reason: `cached: can read ${repo}`, cached: true };
241
+ if (u.loginFallback === true) {
242
+ return {
243
+ login: u.login,
244
+ fallback: true,
245
+ reason: u.loginReason || `cached: no logged-in GitHub account can read ${repo}`,
246
+ cached: true,
247
+ };
248
+ }
249
+ return { login: u.login, fallback: false, reason: `cached: can read ${repo}`, cached: true };
228
250
  }
229
251
  return {
230
252
  login: null,
253
+ fallback: false,
231
254
  reason: u.loginReason || `cached: no logged-in GitHub account can read ${repo}`,
232
255
  cached: true,
233
256
  };
@@ -239,9 +262,10 @@ export function resolveReportingLogin({
239
262
  prefsPath,
240
263
  {
241
264
  login: result.login,
265
+ loginFallback: result.fallback,
242
266
  loginRepo: repo,
243
267
  loginCheckedAt: new Date(now).toISOString(),
244
- loginReason: result.login ? null : result.reason,
268
+ loginReason: result.login && !result.fallback ? null : result.reason,
245
269
  },
246
270
  env,
247
271
  );
@@ -263,8 +287,10 @@ function main(argv) {
263
287
  });
264
288
  if (opts.json) {
265
289
  process.stdout.write(
266
- `${JSON.stringify({ login: r.login, reason: r.reason, cached: r.cached })}\n`,
290
+ `${JSON.stringify({ login: r.login, fallback: r.fallback, reason: r.reason, cached: r.cached })}\n`,
267
291
  );
292
+ } else if (r.login && r.fallback) {
293
+ process.stdout.write(`usage identity: ${r.login} (fallback - ${r.reason})\n`);
268
294
  } else if (r.login) {
269
295
  process.stdout.write(`usage identity: ${r.login} (${r.reason})\n`);
270
296
  } else {
@@ -44,7 +44,7 @@
44
44
  */
45
45
 
46
46
  import { existsSync, readFileSync } from "node:fs";
47
- import { spawnSync } from "node:child_process";
47
+ import { spawn, spawnSync } from "node:child_process";
48
48
  import { join } from "node:path";
49
49
  import { homedir, hostname } from "node:os";
50
50
  import { pathToFileURL } from "node:url";
@@ -90,6 +90,7 @@ export function decideRegistration(
90
90
  forFeedback = false,
91
91
  login = undefined,
92
92
  loginReason = null,
93
+ fallback = false,
93
94
  } = {},
94
95
  ) {
95
96
  const g = prefs?.global || {};
@@ -105,6 +106,12 @@ export function decideRegistration(
105
106
  if (login === null) {
106
107
  return { act: false, reason: loginReason || "no logged-in GitHub account has access" };
107
108
  }
109
+ // A registered token stays pinned to its login whatever gh holds later: a
110
+ // fallback account never replaces it, so a personal account verified once
111
+ // keeps naming the runs after it is logged out.
112
+ if (hasStoredToken && fallback && u.registeredAs) {
113
+ return { act: false, reason: "token already onboarded" };
114
+ }
108
115
  // The server pins a token to the login it was issued for. A token issued to a
109
116
  // different login, or before the login was verified, reports under the wrong
110
117
  // name, so it is replaced.
@@ -181,7 +188,9 @@ async function requestToken(url, body, proof) {
181
188
  if (res.status === 403) {
182
189
  return {
183
190
  token: null,
184
- why: `endpoint answered 403${detail} - ${body.u} needs collaborator access to the pipeline repository`,
191
+ why:
192
+ `endpoint answered 403${detail} - ${body.u} needs collaborator access to the pipeline repository; ` +
193
+ `add the account that has it with \`gh auth login\`, then rerun with --refresh`,
185
194
  };
186
195
  }
187
196
  return { token: null, why: `endpoint answered ${res.status}${detail}` };
@@ -189,7 +198,10 @@ async function requestToken(url, body, proof) {
189
198
  if (json?.accepted === false)
190
199
  return { token: null, declined: true, why: "the endpoint declined this login" };
191
200
  const token = typeof json?.token === "string" ? json.token.trim() : "";
192
- return token ? { token, why: null } : { token: null, why: "endpoint returned no token" };
201
+ const pinned = typeof json?.u === "string" && json.u.trim() ? json.u.trim() : null;
202
+ return token
203
+ ? { token, why: null, login: pinned }
204
+ : { token: null, why: "endpoint returned no token" };
193
205
  } catch (e) {
194
206
  return {
195
207
  token: null,
@@ -213,7 +225,7 @@ async function requestToken(url, body, proof) {
213
225
  export function persistPrefs(
214
226
  prefsPath,
215
227
  entryName = null,
216
- { enableTelemetry = true, registeredAs = null } = {},
228
+ { enableTelemetry = true, registeredAs = null, registeredFallback = false } = {},
217
229
  ) {
218
230
  try {
219
231
  const written = updateJsonFileSync(
@@ -227,7 +239,11 @@ export function persistPrefs(
227
239
  }
228
240
  j.global.usageLog = j.global.usageLog || {};
229
241
  if (enableTelemetry) j.global.usageLog.enabled = true;
230
- if (registeredAs) j.global.usageLog.registeredAs = registeredAs;
242
+ if (registeredAs) {
243
+ j.global.usageLog.registeredAs = registeredAs;
244
+ j.global.usageLog.registeredFallback = registeredFallback === true;
245
+ j.global.usageLog.registeredAt = new Date().toISOString();
246
+ }
231
247
  return j;
232
248
  },
233
249
  { mode: 0o600, followSymlink: true, timeoutMs: lockTimeoutFrom(process.env) },
@@ -238,6 +254,53 @@ export function persistPrefs(
238
254
  }
239
255
  }
240
256
 
257
+ /**
258
+ * True while this machine's runs are not pinned to an account that can read the
259
+ * pipeline repository: no registration, or one made with a fallback account.
260
+ * Setup and update read it from `--json` and offer to add that account once.
261
+ */
262
+ export function personalAccountMissing(prefs) {
263
+ const u = prefs?.global?.usageLog || {};
264
+ if (u.optOut === true) return false;
265
+ if (process.env.MULTI_AGENT_USAGE_TOKEN) return false;
266
+ if (typeof u.token === "string" && u.token.trim()) return false;
267
+ return !(typeof u.registeredAs === "string" && u.registeredAs && u.registeredFallback !== true);
268
+ }
269
+
270
+ /**
271
+ * Report the installed version when it changed since the last report, detached.
272
+ * A first install happens before registration and an update runs this call
273
+ * right after installing, so both are reported from here.
274
+ */
275
+ function sendInstalledVersion(prefsPath) {
276
+ try {
277
+ spawn(process.execPath, [join(import.meta.dirname, "usage-report.mjs"), "--lifecycle"], {
278
+ detached: true,
279
+ stdio: "ignore",
280
+ env: { ...process.env, MULTI_AGENT_PREFS: prefsPath },
281
+ }).unref();
282
+ } catch {
283
+ // reporting never fails the caller
284
+ }
285
+ }
286
+
287
+ const QUIET_REASONS = new Set([
288
+ "opted out",
289
+ "token in environment",
290
+ "token in prefs",
291
+ "token already onboarded",
292
+ "no prefs",
293
+ ]);
294
+
295
+ /**
296
+ * Whether an outcome leaves a machine silent for a reason its user can fix.
297
+ * `--quiet` callers discard stdout, so this is the one line they still see.
298
+ */
299
+ function reportingStaysOff(status, detail) {
300
+ if (status === "unavailable" || status === "declined") return true;
301
+ return status === "skipped" && !QUIET_REASONS.has(detail);
302
+ }
303
+
241
304
  async function main(argv) {
242
305
  const opts = {
243
306
  quiet: false,
@@ -264,7 +327,13 @@ async function main(argv) {
264
327
  if (!opts.quiet) process.stdout.write(`${line}\n`);
265
328
  };
266
329
  const done = (status, detail) => {
267
- if (opts.json) process.stdout.write(`${JSON.stringify({ status, detail })}\n`);
330
+ if (opts.json) {
331
+ const needsPersonalAccount = personalAccountMissing(readJson(prefsPath));
332
+ process.stdout.write(`${JSON.stringify({ status, detail, needsPersonalAccount })}\n`);
333
+ }
334
+ if (opts.quiet && reportingStaysOff(status, detail)) {
335
+ process.stderr.write(`multi-agent: usage reporting is off - ${detail}\n`);
336
+ }
268
337
  return 0;
269
338
  };
270
339
 
@@ -295,6 +364,7 @@ async function main(argv) {
295
364
  forFeedback: opts.feedback,
296
365
  login: who.login,
297
366
  loginReason: who.reason,
367
+ fallback: who.fallback === true,
298
368
  });
299
369
  if (!decision.act) {
300
370
  const repairable = ["token in environment", "token in prefs", "token already onboarded"];
@@ -305,12 +375,19 @@ async function main(argv) {
305
375
  const name = current.global?.keychainMapping?.usage_ingest || null;
306
376
  if (persistPrefs(prefsPath, name)) {
307
377
  say(" -> operational reporting: enabled (a token was already onboarded)");
378
+ sendInstalledVersion(prefsPath);
308
379
  return done("enabled", decision.reason);
309
380
  }
310
381
  say(" -> operational reporting: could not write preferences");
311
382
  return done("unavailable", "prefs write failed");
312
383
  }
313
384
  }
385
+ if (
386
+ decision.reason === "token already onboarded" &&
387
+ current.global?.usageLog?.enabled === true
388
+ ) {
389
+ sendInstalledVersion(prefsPath);
390
+ }
314
391
  say(` -> operational reporting: unchanged (${decision.reason})`);
315
392
  return done("skipped", decision.reason);
316
393
  }
@@ -343,7 +420,12 @@ async function main(argv) {
343
420
  return done("unavailable", why);
344
421
  }
345
422
 
346
- const { token, why, declined } = await requestToken(url, { u: user, c: host }, proof);
423
+ const {
424
+ token,
425
+ why,
426
+ declined,
427
+ login: pinnedLogin,
428
+ } = await requestToken(url, { u: user, c: host }, proof);
347
429
  if (declined) {
348
430
  say(` -> operational reporting left off (${why})`);
349
431
  return done("declined", why);
@@ -364,7 +446,11 @@ async function main(argv) {
364
446
  say(" -> operational reporting left off (the credential store refused the token)");
365
447
  return done("unavailable", "credential store write failed");
366
448
  }
367
- persistPrefs(prefsPath, entry, { enableTelemetry: !optedOut, registeredAs: user });
449
+ persistPrefs(prefsPath, entry, {
450
+ enableTelemetry: !optedOut,
451
+ registeredAs: pinnedLogin || user,
452
+ registeredFallback: who.fallback === true,
453
+ });
368
454
  if (optedOut) {
369
455
  say(" -> feedback token registered; telemetry stays off (usageLog.optOut is set)");
370
456
  return done("registered-feedback-only", entry);
@@ -373,6 +459,7 @@ async function main(argv) {
373
459
  " -> operational reporting: registered this machine (write-only token in the credential store)",
374
460
  );
375
461
  say(" opt out any time: set global.usageLog.optOut=true in multi-agent-preferences.json");
462
+ sendInstalledVersion(prefsPath);
376
463
  return done("registered", entry);
377
464
  }
378
465
 
@@ -22,6 +22,7 @@
22
22
  * node usage-report.mjs --state /path/to/agent-state.json [--event <kind>]
23
23
  * node usage-report.mjs --task-id PROJ-123 [--phase N] [--event <kind>]
24
24
  *
25
+ * node usage-report.mjs --lifecycle (report the installed version when it changed)
25
26
  * --event phase | completed | halted | parked (default: derived from state)
26
27
  *
27
28
  * @module pipeline/scripts/usage-report
@@ -44,11 +45,13 @@ import { execFileSync } from "child_process";
44
45
  import { createHash } from "crypto";
45
46
  import { costUsd } from "./_cost.mjs";
46
47
  import { defaultPrefsPath, resolveReportingLogin } from "./usage-identity.mjs";
48
+ import { installedVersion, lifecycleEvent } from "../lib/usage-lifecycle.mjs";
49
+ import { lockTimeoutFrom, updateJsonFileSync } from "../lib/json-file-lock.mjs";
47
50
  import { KNOWN_STACKS, pluginsForStacks } from "./_stack-routing.mjs";
48
51
 
49
52
  const __dirname = dirname(fileURLToPath(import.meta.url));
50
53
  const ENDPOINT_DEFAULT = "https://mmerterden.com/api/usage/ingest";
51
- const TIMEOUT_MS = 2500;
54
+ const TIMEOUT_MS = 6000;
52
55
 
53
56
  // The emitter ships verbatim into ~/.claude, ~/.copilot and ~/.codex, so it must
54
57
  // never hard-code one host's tree: a Copilot- or Codex-only install has no
@@ -71,6 +74,7 @@ function parseArgs(argv) {
71
74
  status: null,
72
75
  event: null,
73
76
  dryRun: false,
77
+ lifecycle: false,
74
78
  };
75
79
  for (let i = 0; i < argv.length; i += 1) {
76
80
  const a = argv[i];
@@ -80,6 +84,7 @@ function parseArgs(argv) {
80
84
  else if (a === "--status") out.status = argv[++i];
81
85
  else if (a === "--event") out.event = argv[++i];
82
86
  else if (a === "--dry-run") out.dryRun = true;
87
+ else if (a === "--lifecycle") out.lifecycle = true;
83
88
  }
84
89
  return out;
85
90
  }
@@ -806,6 +811,42 @@ function endpointAllowed(endpoint) {
806
811
  return false;
807
812
  }
808
813
 
814
+ /**
815
+ * Send an install or update record when the installed version (from the
816
+ * installer's stamp) is not the last one reported, and move
817
+ * `usageLog.reportedVersion` forward once the server accepted it.
818
+ */
819
+ async function reportInstalledVersion(endpoint, token, login, usageLog) {
820
+ const e = lifecycleEvent({ installed: installedVersion(homedir()), usageLog });
821
+ if (!e) return;
822
+ const r = await post(endpoint, token, { ...e, u: login, os: detectOs(), cli: detectCli() });
823
+ if (r.outcome !== "accepted") return;
824
+ try {
825
+ updateJsonFileSync(
826
+ defaultPrefsPath(),
827
+ (j) => {
828
+ if (!j || typeof j !== "object" || Array.isArray(j)) return undefined;
829
+ j.global = j.global || {};
830
+ j.global.usageLog = { ...(j.global.usageLog || {}), reportedVersion: e.v };
831
+ return j;
832
+ },
833
+ { mode: 0o600, followSymlink: true, timeoutMs: lockTimeoutFrom(process.env) },
834
+ );
835
+ } catch {
836
+ // the next send reports the same version again, under the same id
837
+ }
838
+ }
839
+
840
+ /**
841
+ * The login a registered token is pinned to. The server reports every record
842
+ * under it whatever the client sends, so a machine that registered once needs
843
+ * no gh call to name its runs, and keeps the name after that account logs out.
844
+ */
845
+ function registeredLogin(prefs) {
846
+ const login = typeof prefs?.registeredAs === "string" ? prefs.registeredAs.trim() : "";
847
+ return /^[A-Za-z0-9][A-Za-z0-9_-]{0,38}$/.test(login) ? { login, reason: "registered" } : null;
848
+ }
849
+
809
850
  async function main() {
810
851
  const args = parseArgs(process.argv.slice(2));
811
852
  if (process.env.MULTI_AGENT_SMOKE && !args.dryRun) return;
@@ -829,6 +870,13 @@ async function main() {
829
870
  return;
830
871
  }
831
872
 
873
+ if (args.lifecycle) {
874
+ if (args.dryRun) return;
875
+ const who = registeredLogin(prefs) ?? resolveReportingLogin();
876
+ if (who.login) await reportInstalledVersion(endpoint, token, who.login, prefs);
877
+ return;
878
+ }
879
+
832
880
  const statePath = resolveStatePath(args);
833
881
  let state = statePath ? readJson(statePath) : null;
834
882
 
@@ -844,7 +892,7 @@ async function main() {
844
892
  if (args.status) state.status = args.status;
845
893
  const kind = EVENT_KINDS.includes(args.event) ? args.event : null;
846
894
 
847
- const who = resolveReportingLogin();
895
+ const who = registeredLogin(prefs) ?? resolveReportingLogin();
848
896
  if (!who.login) {
849
897
  process.stderr.write(`usage-report: not sent - ${who.reason}\n`);
850
898
  if (!args.dryRun) {
@@ -863,6 +911,8 @@ async function main() {
863
911
  if (event.st !== "in_progress") appendErrorLedger(event);
864
912
  const result = await post(endpoint, token, event);
865
913
  recordSend({ id: event.id, ev: event.ev, host: hostOf(endpoint), ...result });
914
+ if (result.outcome === "accepted")
915
+ await reportInstalledVersion(endpoint, token, who.login, prefs);
866
916
  if (result.outcome !== "accepted") {
867
917
  const detail = result.status ?? result.reason;
868
918
  const where = result.location ? ` to ${result.location}` : "";