@mmerterden/multi-agent-pipeline 20.27.2 → 20.29.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 (99) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +20 -6
  3. package/README.tr.md +6 -5
  4. package/docs/adr/0008-installer-modularization-and-secret-leak-defense.md +0 -1
  5. package/docs/architecture.md +4 -4
  6. package/docs/ecosystem.md +5 -5
  7. package/docs/facts.json +14 -12
  8. package/docs/features.md +2 -0
  9. package/install/_dev-only-files.mjs +1 -0
  10. package/install/catalog-history.json +1 -1
  11. package/install/claude.mjs +112 -0
  12. package/install/index.mjs +0 -2
  13. package/install/templates/claude-hooks.json +30 -1
  14. package/manifest.json +101 -67
  15. package/package.json +4 -1
  16. package/pipeline/agents/code-reviewer.md +1 -0
  17. package/pipeline/agents/test-gen-analyst.md +98 -0
  18. package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -1
  19. package/pipeline/commands/multi-agent/resume/SKILL.md +2 -1
  20. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  21. package/pipeline/commands/multi-agent/test-gen/SKILL.md +124 -0
  22. package/pipeline/contract/types/index.d.ts +1 -1
  23. package/pipeline/lib/hook-registration.mjs +252 -0
  24. package/pipeline/lib/prefs-value.mjs +44 -0
  25. package/pipeline/lib/run-completion.mjs +148 -0
  26. package/pipeline/multi-agent-refs/analysis/render.md +4 -4
  27. package/pipeline/multi-agent-refs/cross-cli-contract.md +11 -5
  28. package/pipeline/multi-agent-refs/features/doctor.md +24 -0
  29. package/pipeline/multi-agent-refs/features/test-classification.md +76 -0
  30. package/pipeline/multi-agent-refs/features/usage-reporting.md +17 -0
  31. package/pipeline/multi-agent-refs/help/en.md +1 -0
  32. package/pipeline/multi-agent-refs/help/tr.md +1 -0
  33. package/pipeline/multi-agent-refs/phases/operations.md +19 -2
  34. package/pipeline/multi-agent-refs/phases/phase-0-init.md +6 -2
  35. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -2
  36. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +7 -8
  37. package/pipeline/multi-agent-refs/phases/phase-3-review.md +5 -5
  38. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -6
  39. package/pipeline/multi-agent-refs/phases/phase-5-report.md +10 -8
  40. package/pipeline/multi-agent-refs/test-gen/stages.md +227 -0
  41. package/pipeline/preferences-template.json +10 -0
  42. package/pipeline/rules/figma-pipeline.md +2 -0
  43. package/pipeline/schemas/agent-state.schema.json +20 -5
  44. package/pipeline/schemas/phases.json +3 -2
  45. package/pipeline/schemas/phases.schema.json +5 -1
  46. package/pipeline/schemas/prefs.schema.json +51 -0
  47. package/pipeline/schemas/runs-index.schema.json +1 -1
  48. package/pipeline/schemas/test-gen-discovery.schema.json +73 -0
  49. package/pipeline/schemas/test-gen-gaps.schema.json +122 -0
  50. package/pipeline/schemas/test-gen-inventory.schema.json +59 -0
  51. package/pipeline/schemas/test-gen-results.schema.json +44 -0
  52. package/pipeline/scripts/_active-run.mjs +158 -0
  53. package/pipeline/scripts/_autopilot-outcomes.mjs +1 -0
  54. package/pipeline/scripts/_guard_rules.py +605 -0
  55. package/pipeline/scripts/_handle-patterns.mjs +166 -0
  56. package/pipeline/scripts/_http-post.mjs +141 -0
  57. package/pipeline/scripts/_stack-routing.mjs +4 -0
  58. package/pipeline/scripts/_test-gaming.mjs +227 -0
  59. package/pipeline/scripts/_unified-diff.mjs +104 -0
  60. package/pipeline/scripts/_worktree-snapshot.mjs +113 -0
  61. package/pipeline/scripts/agent-guard.py +118 -7
  62. package/pipeline/scripts/agent-guard.sh +50 -3
  63. package/pipeline/scripts/argv-secret-scan.mjs +114 -0
  64. package/pipeline/scripts/autopilot-runner.mjs +8 -2
  65. package/pipeline/scripts/confirm-command.mjs +70 -0
  66. package/pipeline/scripts/doctor.mjs +105 -0
  67. package/pipeline/scripts/evidence-gate.mjs +82 -7
  68. package/pipeline/scripts/gate-ledger.mjs +163 -7
  69. package/pipeline/scripts/handle-diff-audit.mjs +188 -0
  70. package/pipeline/scripts/plan-coverage-gate.mjs +9 -8
  71. package/pipeline/scripts/pre-commit-check.sh +12 -0
  72. package/pipeline/scripts/routine-registry.mjs +5 -1
  73. package/pipeline/scripts/run-scope-guard.mjs +188 -0
  74. package/pipeline/scripts/run-scope-guard.sh +23 -0
  75. package/pipeline/scripts/run-stop-guard.mjs +246 -0
  76. package/pipeline/scripts/run-stop-guard.sh +25 -0
  77. package/pipeline/scripts/run-ui-tests.sh +27 -3
  78. package/pipeline/scripts/test-classify.mjs +296 -0
  79. package/pipeline/scripts/test-gen-gate.mjs +409 -0
  80. package/pipeline/scripts/test-gen-state.mjs +313 -0
  81. package/pipeline/scripts/test-integrity-gate.mjs +76 -11
  82. package/pipeline/scripts/uninstall.mjs +2 -0
  83. package/pipeline/scripts/usage-register-beacon.mjs +239 -0
  84. package/pipeline/scripts/usage-register.mjs +128 -33
  85. package/pipeline/scripts/usage-report.mjs +43 -33
  86. package/pipeline/scripts/validate-state.mjs +26 -0
  87. package/pipeline/scripts/write-state.mjs +114 -0
  88. package/pipeline/skills/.skill-manifest.json +23 -3
  89. package/pipeline/skills/.skills-index.json +57 -2
  90. package/pipeline/skills/shared/README.md +10 -5
  91. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  92. package/pipeline/skills/shared/core/multi-agent-test-gen/SKILL.md +120 -0
  93. package/pipeline/skills/shared/external/fix-review-verdicts/SKILL.md +133 -0
  94. package/pipeline/skills/shared/external/handle-request/SKILL.md +97 -0
  95. package/pipeline/skills/shared/external/scenario-gap-analysis/SKILL.md +129 -0
  96. package/pipeline/skills/shared/external/scenario-gap-analysis/references/scenario-schema.md +60 -0
  97. package/pipeline/skills/shared/external/test-result-triage/SKILL.md +160 -0
  98. package/pipeline/skills/skills-index.md +7 -2
  99. package/install/_telemetry.mjs +0 -61
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * usage-register-beacon.mjs - tell the reporting site that registration failed.
4
+ *
5
+ * A machine whose registration fails sends nothing else, so whoever runs the
6
+ * reporting site cannot see that the person tried at all. This sends one small
7
+ * report to `<register URL>/failure`: { u, v, reason, os }. `u` is the login
8
+ * registration used (proven or declared), `v` the installed pipeline version,
9
+ * `reason` one word from REASONS, `os` darwin when known. Nothing else: no
10
+ * token, no header beyond Content-Type, no host name, no failure text.
11
+ *
12
+ * usage-register.mjs starts it detached once a registration ends in a failure
13
+ * the person did not choose, so it never blocks or fails the caller. It sends
14
+ * at most once per (reason, version) per day; a report that was not delivered
15
+ * is tried again on the next failed registration. The state lives in
16
+ * `~/.claude/logs/multi-agent/usage-register-beacon.json` and holds no secret.
17
+ *
18
+ * Usage (internal): MULTI_AGENT_REGISTER_BEACON='<json>' node usage-register-beacon.mjs
19
+ *
20
+ * @module pipeline/scripts/usage-register-beacon
21
+ */
22
+
23
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import { join } from "node:path";
26
+ import { pathToFileURL } from "node:url";
27
+ import { postJson } from "./_http-post.mjs";
28
+
29
+ export const BEACON_FILE = "usage-register-beacon.json";
30
+ export const BEACON_TIMEOUT_MS = 3000;
31
+ export const BEACON_ENV = "MULTI_AGENT_REGISTER_BEACON";
32
+
33
+ export const REASONS = Object.freeze([
34
+ "endpoint-unreachable",
35
+ "tls-untrusted",
36
+ "proxy-refused",
37
+ "timeout",
38
+ "redirected",
39
+ "not-collaborator",
40
+ "gh-account-mismatch",
41
+ "proof-rejected",
42
+ "proof-required",
43
+ "no-proof",
44
+ "rate-limited",
45
+ "server-error",
46
+ "credential-store",
47
+ "prefs-write",
48
+ "other",
49
+ ]);
50
+
51
+ const LOGIN_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,38}$/;
52
+ const VERSION_RE = /^v?\d{1,5}\.\d{1,5}\.\d{1,5}(-[0-9A-Za-z.-]{1,24})?$/;
53
+
54
+ const TLS_FETCH =
55
+ /CERT|SELF_SIGNED|UNABLE_TO_GET_ISSUER|UNABLE_TO_VERIFY|DEPTH_ZERO|ERR_TLS|ERR_SSL|EPROTO/;
56
+ const TLS_CURL = new Set([35, 51, 53, 54, 58, 59, 60, 64, 66, 77, 80, 82, 83, 90, 91]);
57
+ const PROXY_CURL = new Set([5, 56, 97]);
58
+
59
+ function curlExit(failure) {
60
+ const m = /^curl-exit-(\d+)$/.exec(String(failure ?? ""));
61
+ return m ? Number(m[1]) : null;
62
+ }
63
+
64
+ /**
65
+ * The reason for a request that reached no HTTP answer, from the fetch cause
66
+ * and curl's exit code (_http-post.mjs). curl is asked second and honours the
67
+ * proxy and the system trust store, so its verdict is read first.
68
+ */
69
+ export function transportReason(fetchFailure, curlFailure) {
70
+ const exit = curlExit(curlFailure);
71
+ if (exit !== null && PROXY_CURL.has(exit)) return "proxy-refused";
72
+ if (exit !== null && TLS_CURL.has(exit)) return "tls-untrusted";
73
+ if (exit === 28 || fetchFailure === "timeout") return "timeout";
74
+ if (TLS_FETCH.test(String(fetchFailure ?? ""))) return "tls-untrusted";
75
+ return "endpoint-unreachable";
76
+ }
77
+
78
+ /** The reason for an HTTP answer that issued no token. */
79
+ export function httpReason(status, code) {
80
+ if (code === "login_mismatch") return "gh-account-mismatch";
81
+ if (code === "not_collaborator") return "not-collaborator";
82
+ if (code === "proof_required") return "proof-required";
83
+ if (status >= 300 && status < 400) return "redirected";
84
+ if (status === 401) return "proof-rejected";
85
+ if (status === 403) return "not-collaborator";
86
+ if (status === 407) return "proxy-refused";
87
+ if (status === 429) return "rate-limited";
88
+ if (status >= 500) return "server-error";
89
+ return "other";
90
+ }
91
+
92
+ /** The failure URL next to the register URL. */
93
+ export function beaconUrl(registerUrl) {
94
+ return `${String(registerUrl).replace(/\/+$/, "")}/failure`;
95
+ }
96
+
97
+ /** The installed pipeline version: the host's stamp, else the package next to this script. */
98
+ export function pipelineVersion(home = homedir()) {
99
+ for (const root of [".claude", ".copilot", ".codex"]) {
100
+ try {
101
+ const v = readFileSync(join(home, root, ".pipeline-version"), "utf8").trim();
102
+ if (VERSION_RE.test(v)) return v.replace(/^v/, "");
103
+ } catch {
104
+ /* no stamp for this host */
105
+ }
106
+ }
107
+ try {
108
+ const v = JSON.parse(
109
+ readFileSync(join(import.meta.dirname, "..", "..", "package.json"), "utf8"),
110
+ ).version;
111
+ if (typeof v === "string" && VERSION_RE.test(v)) return v.replace(/^v/, "");
112
+ } catch {
113
+ /* not inside a package */
114
+ }
115
+ return null;
116
+ }
117
+
118
+ /** The exact body sent, or null when any field would be refused. */
119
+ export function beaconBody({ login, version, reason, os = process.platform }) {
120
+ if (typeof login !== "string" || !LOGIN_RE.test(login)) return null;
121
+ if (typeof version !== "string" || !VERSION_RE.test(version)) return null;
122
+ if (!REASONS.includes(reason)) return null;
123
+ const body = { u: login, v: version.replace(/^v/, ""), reason };
124
+ if (os === "darwin") body.os = os;
125
+ return body;
126
+ }
127
+
128
+ export function beaconStatePath(home = homedir()) {
129
+ return join(home, ".claude", "logs", "multi-agent", BEACON_FILE);
130
+ }
131
+
132
+ function today(now) {
133
+ return new Date(now).toISOString().slice(0, 10);
134
+ }
135
+
136
+ export function readBeaconState(home = homedir()) {
137
+ try {
138
+ const j = JSON.parse(readFileSync(beaconStatePath(home), "utf8"));
139
+ return j && typeof j === "object" && !Array.isArray(j) ? j : {};
140
+ } catch {
141
+ return {};
142
+ }
143
+ }
144
+
145
+ function writeBeaconState(home, state) {
146
+ const target = beaconStatePath(home);
147
+ const tmp = `${target}.${process.pid}.tmp`;
148
+ try {
149
+ if (!existsSync(join(home, ".claude"))) return;
150
+ mkdirSync(join(home, ".claude", "logs", "multi-agent"), { recursive: true });
151
+ writeFileSync(tmp, JSON.stringify(state) + "\n", { mode: 0o600 });
152
+ renameSync(tmp, target);
153
+ } catch {
154
+ /* logs dir unwritable - never break the caller */
155
+ }
156
+ }
157
+
158
+ /** Was this (reason, version) already delivered today? */
159
+ export function alreadySent(state, body, now = Date.now()) {
160
+ return state?.sent?.[`${body.reason}@${body.v}`] === today(now);
161
+ }
162
+
163
+ /**
164
+ * Send one beacon unless the same (reason, version) was delivered today.
165
+ * Resolves to { outcome: "sent" | "failed" | "duplicate" | "invalid", status? };
166
+ * never throws.
167
+ */
168
+ export async function sendRegisterBeacon({
169
+ home = homedir(),
170
+ url,
171
+ login,
172
+ version = pipelineVersion(home),
173
+ reason,
174
+ os = process.platform,
175
+ now = Date.now(),
176
+ post = postJson,
177
+ timeoutMs = BEACON_TIMEOUT_MS,
178
+ }) {
179
+ const body = beaconBody({ login, version, reason, os });
180
+ if (!body || typeof url !== "string" || !url) return { outcome: "invalid" };
181
+ const state = readBeaconState(home);
182
+ if (alreadySent(state, body, now)) return { outcome: "duplicate" };
183
+ let r;
184
+ try {
185
+ r = await post({ url, headers: {}, body: JSON.stringify(body), timeoutMs });
186
+ } catch {
187
+ r = { failure: "error" };
188
+ }
189
+ const delivered = !r.failure && r.status >= 200 && r.status < 300;
190
+ const day = today(now);
191
+ const sent = Object.fromEntries(
192
+ Object.entries(state.sent && typeof state.sent === "object" ? state.sent : {}).filter(
193
+ ([, d]) => d === day,
194
+ ),
195
+ );
196
+ if (delivered) sent[`${body.reason}@${body.v}`] = day;
197
+ const last = {
198
+ t: new Date(now).toISOString(),
199
+ reason: body.reason,
200
+ version: body.v,
201
+ outcome: delivered ? "sent" : "failed",
202
+ };
203
+ if (r.failure) last.cause = String(r.failure).slice(0, 40);
204
+ else last.status = r.status;
205
+ writeBeaconState(home, { sent, last });
206
+ return delivered ? { outcome: "sent", status: r.status } : { outcome: "failed", ...last };
207
+ }
208
+
209
+ function isEntrypoint() {
210
+ const arg = process.argv[1];
211
+ if (!arg) return false;
212
+ try {
213
+ return import.meta.url === pathToFileURL(arg).href;
214
+ } catch {
215
+ return false;
216
+ }
217
+ }
218
+
219
+ function payloadFromEnv() {
220
+ try {
221
+ return JSON.parse(process.env[BEACON_ENV] || "null");
222
+ } catch {
223
+ return null;
224
+ }
225
+ }
226
+
227
+ if (isEntrypoint()) {
228
+ const payload = payloadFromEnv();
229
+ if (payload && typeof payload === "object" && existsSync(payload.home || homedir())) {
230
+ sendRegisterBeacon({
231
+ home: payload.home || homedir(),
232
+ url: payload.url,
233
+ login: payload.login,
234
+ reason: payload.reason,
235
+ }).finally(() => {
236
+ process.exitCode = 0;
237
+ });
238
+ }
239
+ }
@@ -52,7 +52,16 @@
52
52
  * @module pipeline/scripts/usage-register
53
53
  */
54
54
 
55
- import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
55
+ import { postJson as postHttp } from "./_http-post.mjs";
56
+ import {
57
+ existsSync,
58
+ mkdirSync,
59
+ mkdtempSync,
60
+ readFileSync,
61
+ renameSync,
62
+ rmSync,
63
+ writeFileSync,
64
+ } from "node:fs";
56
65
  import { spawn, spawnSync } from "node:child_process";
57
66
  import { randomBytes } from "node:crypto";
58
67
  import { join } from "node:path";
@@ -70,6 +79,7 @@ import {
70
79
  sshKeygenBin,
71
80
  } from "./usage-identity.mjs";
72
81
  import { lockTimeoutFrom, updateJsonFileSync } from "../lib/json-file-lock.mjs";
82
+ import { BEACON_ENV, beaconUrl, httpReason, transportReason } from "./usage-register-beacon.mjs";
73
83
 
74
84
  const DEFAULT_ENDPOINT = "https://mmerterden.com/api/usage/ingest";
75
85
  const BUILT_IN_HOSTS = ["mmerterden.com"];
@@ -196,30 +206,39 @@ export function registerUrlAllowed(url, trustedHosts = []) {
196
206
  return trusted.has(u.hostname.toLowerCase());
197
207
  }
198
208
 
209
+ // Transport goes through _http-post.mjs, which retries a connection-level
210
+ // failure through curl (proxy variables, system trust store). The failure text
211
+ // keeps both causes so a blocked corporate network is diagnosable.
199
212
  async function postJson(url, body, bearer = null) {
200
- const ctrl = new AbortController();
201
- const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
202
- const headers = { "Content-Type": "application/json" };
203
- if (bearer) headers.Authorization = `Bearer ${bearer}`;
204
- try {
205
- const res = await fetch(url, {
206
- method: "POST",
207
- headers,
208
- body: JSON.stringify(body),
209
- redirect: "error",
210
- signal: ctrl.signal,
211
- });
212
- const json = await res.json().catch(() => null);
213
- return { res, json, failure: null };
214
- } catch (e) {
213
+ const headers = bearer ? { Authorization: `Bearer ${bearer}` } : {};
214
+ const r = await postHttp({ url, headers, body: JSON.stringify(body), timeoutMs: TIMEOUT_MS });
215
+ if (r.failure) {
216
+ const causes = [r.fetchFailure && `fetch ${r.fetchFailure}`, `curl ${r.failure}`]
217
+ .filter(Boolean)
218
+ .join(", ");
219
+ const what = r.fetchFailure === "timeout" ? "endpoint timed out" : "endpoint unreachable";
215
220
  return {
216
221
  res: null,
217
222
  json: null,
218
- failure: e?.name === "AbortError" ? "endpoint timed out" : "endpoint unreachable",
223
+ failure: `${what} (${causes})`,
224
+ reason: transportReason(r.fetchFailure, r.failure),
219
225
  };
220
- } finally {
221
- clearTimeout(timer);
222
226
  }
227
+ if (r.status >= 300 && r.status < 400) {
228
+ return {
229
+ res: null,
230
+ json: null,
231
+ failure: `endpoint redirected (${r.status})`,
232
+ reason: "redirected",
233
+ };
234
+ }
235
+ let json = null;
236
+ try {
237
+ json = JSON.parse(r.body);
238
+ } catch {
239
+ /* non-JSON answer: the status alone decides */
240
+ }
241
+ return { res: { ok: r.status >= 200 && r.status < 300, status: r.status }, json, failure: null };
223
242
  }
224
243
 
225
244
  const SSH_REFUSALS = {
@@ -265,11 +284,16 @@ function refusal(status, json, body, method) {
265
284
  }
266
285
 
267
286
  async function requestToken(url, body, proof, method) {
268
- const { res, json, failure } = await postJson(url, body, proof);
269
- if (failure) return { token: null, why: failure };
287
+ const { res, json, failure, reason } = await postJson(url, body, proof);
288
+ if (failure) return { token: null, why: failure, reason };
270
289
  if (!res.ok) {
271
290
  const code = typeof json?.code === "string" ? json.code : null;
272
- return { token: null, code, why: refusal(res.status, json, body, method) };
291
+ return {
292
+ token: null,
293
+ code,
294
+ why: refusal(res.status, json, body, method),
295
+ reason: httpReason(res.status, code),
296
+ };
273
297
  }
274
298
  if (json?.accepted === false)
275
299
  return { token: null, declined: true, why: "the endpoint declined this login" };
@@ -277,7 +301,7 @@ async function requestToken(url, body, proof, method) {
277
301
  const pinnedLogin = typeof json?.u === "string" && json.u.trim() ? json.u.trim() : null;
278
302
  return token
279
303
  ? { token, why: null, login: pinnedLogin }
280
- : { token: null, why: "endpoint returned no token" };
304
+ : { token: null, why: "endpoint returned no token", reason: "other" };
281
305
  }
282
306
 
283
307
  /**
@@ -323,20 +347,26 @@ export function challengeUrl(url) {
323
347
 
324
348
  async function requestTokenWithSsh(url, body, key) {
325
349
  const ch = await postJson(challengeUrl(url), { u: body.u });
326
- if (ch.failure) return { token: null, why: ch.failure };
350
+ if (ch.failure) return { token: null, why: ch.failure, reason: ch.reason };
327
351
  if (!ch.res.ok) {
328
352
  if (ch.res.status === 404 || ch.res.status === 405) {
329
353
  return {
330
354
  token: null,
331
355
  why: `the endpoint does not accept an SSH key as proof yet (challenge answered ${ch.res.status})`,
356
+ reason: "other",
332
357
  };
333
358
  }
334
- return { token: null, why: refusal(ch.res.status, ch.json, body, "ssh") };
359
+ const code = typeof ch.json?.code === "string" ? ch.json.code : null;
360
+ return {
361
+ token: null,
362
+ why: refusal(ch.res.status, ch.json, body, "ssh"),
363
+ reason: httpReason(ch.res.status, code),
364
+ };
335
365
  }
336
366
  const challenge = typeof ch.json?.challenge === "string" ? ch.json.challenge : "";
337
367
  const namespace = typeof ch.json?.namespace === "string" ? ch.json.namespace : "";
338
368
  if (!challenge || !/^[A-Za-z0-9@._-]{1,128}$/.test(namespace)) {
339
- return { token: null, why: "the endpoint returned no usable SSH challenge" };
369
+ return { token: null, why: "the endpoint returned no usable SSH challenge", reason: "other" };
340
370
  }
341
371
  const signature = sshSign(key, namespace, challenge);
342
372
  if (!signature) {
@@ -344,6 +374,7 @@ async function requestTokenWithSsh(url, body, key) {
344
374
  return {
345
375
  token: null,
346
376
  why: `could not sign with the SSH key ${shown} (it needs a passphrase and is not in ssh-agent); run \`ssh-add ${shown}\`, then retry with --refresh`,
377
+ reason: "no-proof",
347
378
  };
348
379
  }
349
380
  return requestToken(url, { ...body, ssh: { challenge, signature } }, null, "ssh");
@@ -538,6 +569,57 @@ function reportingStaysOff(status, detail) {
538
569
  return status === "skipped" && !QUIET_REASONS.has(detail);
539
570
  }
540
571
 
572
+ export const LAST_REGISTER_FILE = "usage-last-register.json";
573
+
574
+ /**
575
+ * Overwrites the record of the most recent registration attempt. Registration
576
+ * runs quietly from setup, update and Phase 0, so this file is where a machine
577
+ * that never registers shows why; doctor reads it. It holds no token.
578
+ */
579
+ function recordRegister(home, status, detail) {
580
+ const dir = join(home, ".claude", "logs", "multi-agent");
581
+ const target = join(dir, LAST_REGISTER_FILE);
582
+ const tmp = `${target}.${process.pid}.tmp`;
583
+ try {
584
+ mkdirSync(dir, { recursive: true });
585
+ const entry = {
586
+ t: new Date().toISOString(),
587
+ status,
588
+ detail: String(detail ?? "").slice(0, 300),
589
+ };
590
+ writeFileSync(tmp, JSON.stringify(entry) + "\n", { mode: 0o600 });
591
+ renameSync(tmp, target);
592
+ } catch {
593
+ /* logs dir unwritable - never break the caller */
594
+ }
595
+ }
596
+
597
+ /**
598
+ * Report a failed registration to the site, detached so it never delays or
599
+ * fails the caller (usage-register-beacon.mjs). Only for a failure the person
600
+ * did not choose, never on a dry run or for an opted-out machine, only under a
601
+ * login this run had, and only to a register URL the proof may go to.
602
+ */
603
+ function sendFailureBeacon({ home, prefsPath, login, reason, dryRun }) {
604
+ if (dryRun || !reason || typeof login !== "string" || !LOGIN_RE.test(login)) return;
605
+ const usageLog = readJson(prefsPath)?.global?.usageLog || {};
606
+ if (usageLog.optOut === true) return;
607
+ const url = registerUrl(usageLog.endpoint);
608
+ if (!registerUrlAllowed(url, usageLog.trustedHosts || [])) return;
609
+ try {
610
+ spawn(process.execPath, [join(import.meta.dirname, "usage-register-beacon.mjs")], {
611
+ detached: true,
612
+ stdio: "ignore",
613
+ env: {
614
+ ...process.env,
615
+ [BEACON_ENV]: JSON.stringify({ home, url: beaconUrl(url), login, reason }),
616
+ },
617
+ }).unref();
618
+ } catch {
619
+ // reporting never fails the caller
620
+ }
621
+ }
622
+
541
623
  async function main(argv) {
542
624
  const opts = {
543
625
  quiet: false,
@@ -566,7 +648,16 @@ async function main(argv) {
566
648
  if (!opts.quiet) process.stdout.write(`${line}\n`);
567
649
  };
568
650
  const identity = { login: undefined };
569
- const done = (status, detail) => {
651
+ const beacon = { login: null };
652
+ const done = (status, detail, reason = null) => {
653
+ if (!opts.dryRun) recordRegister(HOME, status, detail);
654
+ sendFailureBeacon({
655
+ home: HOME,
656
+ prefsPath,
657
+ login: beacon.login,
658
+ reason,
659
+ dryRun: opts.dryRun,
660
+ });
570
661
  if (opts.json) {
571
662
  const needsPersonalAccount = personalAccountMissing(readJson(prefsPath), identity.login);
572
663
  process.stdout.write(`${JSON.stringify({ status, detail, needsPersonalAccount })}\n`);
@@ -602,7 +693,8 @@ async function main(argv) {
602
693
  }
603
694
  if (!opts.dryRun && !declareLogin(prefsPath, name)) {
604
695
  say(" -> operational reporting: could not write preferences");
605
- return done("unavailable", "prefs write failed");
696
+ beacon.login = name;
697
+ return done("unavailable", "prefs write failed", "prefs-write");
606
698
  }
607
699
  opts.refresh = true;
608
700
  }
@@ -629,6 +721,7 @@ async function main(argv) {
629
721
  who = { login: declared, method: "claim", key: null, reason: "declared by the person" };
630
722
  }
631
723
  identity.login = who.login;
724
+ beacon.login = who.login || null;
632
725
  const decision = decideRegistration(current, {
633
726
  hasStoredToken: has,
634
727
  forFeedback: opts.feedback,
@@ -649,7 +742,8 @@ async function main(argv) {
649
742
  return done("enabled", decision.reason);
650
743
  }
651
744
  say(" -> operational reporting: could not write preferences");
652
- return done("unavailable", "prefs write failed");
745
+ beacon.login = beacon.login || cu.registeredAs || null;
746
+ return done("unavailable", "prefs write failed", "prefs-write");
653
747
  }
654
748
  }
655
749
  if (
@@ -702,11 +796,11 @@ async function main(argv) {
702
796
  ? `the git credential helper no longer returns a GitHub token for ${user}; retry with --refresh`
703
797
  : `gh has no token for ${user}; run \`gh auth login\` for that account`;
704
798
  say(` -> operational reporting left off (${why})`);
705
- return done("unavailable", why);
799
+ return done("unavailable", why, "no-proof");
706
800
  }
707
801
  result = await requestToken(url, { u: user, c: host }, proof, who.method || "gh");
708
802
  }
709
- const { token, why, declined, code, login: pinnedLogin } = result;
803
+ const { token, why, declined, code, reason, login: pinnedLogin } = result;
710
804
  if (who.method === "claim" && ["not_collaborator", "proof_required"].includes(code))
711
805
  declareLogin(prefsPath, null);
712
806
  else if (who.method === "claim" && !token && !declined)
@@ -717,7 +811,7 @@ async function main(argv) {
717
811
  }
718
812
  if (!token) {
719
813
  say(` -> operational reporting left off (${why})`);
720
- return done("unavailable", why);
814
+ return done("unavailable", why, reason || "other");
721
815
  }
722
816
 
723
817
  const optedOut = prefs.global?.usageLog?.optOut === true;
@@ -729,7 +823,8 @@ async function main(argv) {
729
823
  });
730
824
  if (set.status !== 0) {
731
825
  say(" -> operational reporting left off (the credential store refused the token)");
732
- return done("unavailable", "credential store write failed");
826
+ beacon.login = pinnedLogin || user;
827
+ return done("unavailable", "credential store write failed", "credential-store");
733
828
  }
734
829
  persistPrefs(prefsPath, entry, {
735
830
  enableTelemetry: !optedOut,
@@ -22,6 +22,7 @@
22
22
  * Usage:
23
23
  * node usage-report.mjs --state /path/to/agent-state.json [--event <kind>]
24
24
  * node usage-report.mjs --task-id PROJ-123 [--phase N] [--event <kind>]
25
+ * node usage-report.mjs --task-id PROJ-123 --print-state (prints the run's agent-state.json path)
25
26
  *
26
27
  * node usage-report.mjs --lifecycle (report the installed version when it changed)
27
28
  * --event phase | completed | halted | parked (default: derived from state)
@@ -29,6 +30,7 @@
29
30
  * @module pipeline/scripts/usage-report
30
31
  */
31
32
 
33
+ import { postJson } from "./_http-post.mjs";
32
34
  import {
33
35
  readFileSync,
34
36
  existsSync,
@@ -86,6 +88,7 @@ function parseArgs(argv) {
86
88
  else if (a === "--event") out.event = argv[++i];
87
89
  else if (a === "--dry-run") out.dryRun = true;
88
90
  else if (a === "--lifecycle") out.lifecycle = true;
91
+ else if (a === "--print-state") out.printState = true;
89
92
  }
90
93
  return out;
91
94
  }
@@ -737,40 +740,31 @@ function hostOf(url) {
737
740
  * instead, so a moved endpoint is visible rather than silent.
738
741
  */
739
742
  async function post(endpoint, token, event) {
740
- const ctrl = new AbortController();
741
- const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
743
+ const r = await postJson({
744
+ url: endpoint,
745
+ headers: { "X-Usage-Token": token },
746
+ body: JSON.stringify(event),
747
+ timeoutMs: TIMEOUT_MS,
748
+ });
749
+ const via = r.fetchFailure ? { transport: r.transport, fetchFailure: r.fetchFailure } : {};
750
+ if (r.failure) {
751
+ return { outcome: "failed", reason: codeOf(String(r.failure), 40), ...via };
752
+ }
753
+ if (r.status >= 300 && r.status < 400) {
754
+ return { outcome: "redirected", status: r.status, location: hostOf(r.location || ""), ...via };
755
+ }
756
+ if (r.status < 200 || r.status >= 300) return { outcome: "rejected", status: r.status, ...via };
757
+ let body = null;
742
758
  try {
743
- const res = await fetch(endpoint, {
744
- method: "POST",
745
- headers: {
746
- "Content-Type": "application/json",
747
- "X-Usage-Token": token,
748
- },
749
- body: JSON.stringify(event),
750
- redirect: "manual",
751
- signal: ctrl.signal,
752
- });
753
- if (res.status >= 300 && res.status < 400) {
754
- return {
755
- outcome: "redirected",
756
- status: res.status,
757
- location: hostOf(res.headers.get("location") || ""),
758
- };
759
- }
760
- if (!res.ok) return { outcome: "rejected", status: res.status };
761
- let body = null;
762
- try {
763
- body = await res.json();
764
- } catch {
765
- /* a 2xx without a JSON body is still a delivery */
766
- }
767
- return { outcome: body?.accepted === false ? "not-accepted" : "accepted", status: res.status };
768
- } catch (err) {
769
- const reason = ctrl.signal.aborted ? "timeout" : (err?.cause?.code ?? err?.name ?? "error");
770
- return { outcome: "failed", reason: codeOf(String(reason), 40) };
771
- } finally {
772
- clearTimeout(timer);
759
+ body = JSON.parse(r.body);
760
+ } catch {
761
+ /* a 2xx without a JSON body is still a delivery */
773
762
  }
763
+ return {
764
+ outcome: body?.accepted === false ? "not-accepted" : "accepted",
765
+ status: r.status,
766
+ ...via,
767
+ };
774
768
  }
775
769
 
776
770
  export const LAST_SEND_FILE = "usage-last-send.json";
@@ -852,6 +846,13 @@ function registeredLogin(prefs) {
852
846
  async function main() {
853
847
  const args = parseArgs(process.argv.slice(2));
854
848
  if (process.env.MULTI_AGENT_SMOKE && !args.dryRun) return;
849
+ // Resolving the state path needs no prefs and sends nothing: a shell block
850
+ // whose $STATE_FILE did not survive from an earlier command gets it back here.
851
+ if (args.printState) {
852
+ const path = resolveStatePath(args);
853
+ if (path) process.stdout.write(`${path}\n`);
854
+ return;
855
+ }
855
856
  const prefs = resolvePrefs();
856
857
  const endpoint = canonicalEndpoint(prefs.endpoint || ENDPOINT_DEFAULT);
857
858
 
@@ -883,7 +884,16 @@ async function main() {
883
884
  let state = statePath ? readJson(statePath) : null;
884
885
 
885
886
  if (!state) {
886
- if (!args.taskId) return;
887
+ if (!args.taskId) {
888
+ // An unset shell variable reaches here as --state "", and a silent return
889
+ // would make a run that never reports look exactly like one that did.
890
+ recordSend({
891
+ ev: EVENT_KINDS.includes(args.event) ? args.event : null,
892
+ outcome: "skipped",
893
+ reason: args.state === "" ? "empty-state-path" : "no-state",
894
+ });
895
+ return;
896
+ }
887
897
  state = { taskId: args.taskId };
888
898
  }
889
899
 
@@ -16,6 +16,7 @@
16
16
  // - root is not an object
17
17
  // - currentPhase is missing or out of the 0..5 range (resume keys on it)
18
18
  // - phases is present but not an object, or an entry is not an object
19
+ // - waitingFor names a step resume cannot re-enter
19
20
  // Phase status strings are NOT enum-checked: real writers use "completed"
20
21
  // alongside the schema's "done", and an odd status does not make a resume
21
22
  // unsafe (resume keys on currentPhase, not phase status). Zero deps, same
@@ -32,6 +33,14 @@
32
33
 
33
34
  import { readFileSync } from "node:fs";
34
35
 
36
+ const WAITING_FOR = new Set([
37
+ "maturity",
38
+ "user-channels-choice",
39
+ "local-test",
40
+ "attended-reply",
41
+ "question",
42
+ ]);
43
+
35
44
  function readInput() {
36
45
  const arg = process.argv[2];
37
46
  if (!arg) {
@@ -129,6 +138,23 @@ async function main() {
129
138
  }
130
139
  }
131
140
 
141
+ // waitingFor is the first field resume reads: a value it has no row for, or
142
+ // a question with nothing to re-ask, leaves the run unable to re-enter.
143
+ if (s.waitingFor !== undefined && s.waitingFor !== null) {
144
+ if (!WAITING_FOR.has(s.waitingFor)) {
145
+ errors.push(
146
+ `waitingFor must be one of ${[...WAITING_FOR].join(", ")} or null, got ${JSON.stringify(s.waitingFor)}`,
147
+ );
148
+ } else if (
149
+ s.waitingFor === "question" &&
150
+ (typeof s.pendingQuestion?.stepId !== "string" || !s.pendingQuestion.stepId)
151
+ ) {
152
+ errors.push(
153
+ 'waitingFor "question" needs a pendingQuestion with a stepId; a typed wait records "attended-reply"',
154
+ );
155
+ }
156
+ }
157
+
132
158
  if (errors.length > 0) {
133
159
  emit({ ok: false, code: 1, errors });
134
160
  process.exit(1);