@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.
- package/CHANGELOG.md +41 -0
- package/README.md +20 -6
- package/README.tr.md +6 -5
- package/docs/adr/0008-installer-modularization-and-secret-leak-defense.md +0 -1
- package/docs/architecture.md +4 -4
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +14 -12
- package/docs/features.md +2 -0
- package/install/_dev-only-files.mjs +1 -0
- package/install/catalog-history.json +1 -1
- package/install/claude.mjs +112 -0
- package/install/index.mjs +0 -2
- package/install/templates/claude-hooks.json +30 -1
- package/manifest.json +101 -67
- package/package.json +4 -1
- package/pipeline/agents/code-reviewer.md +1 -0
- package/pipeline/agents/test-gen-analyst.md +98 -0
- package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -1
- package/pipeline/commands/multi-agent/resume/SKILL.md +2 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
- package/pipeline/commands/multi-agent/test-gen/SKILL.md +124 -0
- package/pipeline/contract/types/index.d.ts +1 -1
- package/pipeline/lib/hook-registration.mjs +252 -0
- package/pipeline/lib/prefs-value.mjs +44 -0
- package/pipeline/lib/run-completion.mjs +148 -0
- package/pipeline/multi-agent-refs/analysis/render.md +4 -4
- package/pipeline/multi-agent-refs/cross-cli-contract.md +11 -5
- package/pipeline/multi-agent-refs/features/doctor.md +24 -0
- package/pipeline/multi-agent-refs/features/test-classification.md +76 -0
- package/pipeline/multi-agent-refs/features/usage-reporting.md +17 -0
- package/pipeline/multi-agent-refs/help/en.md +1 -0
- package/pipeline/multi-agent-refs/help/tr.md +1 -0
- package/pipeline/multi-agent-refs/phases/operations.md +19 -2
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +6 -2
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -2
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +7 -8
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +5 -5
- package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -6
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +10 -8
- package/pipeline/multi-agent-refs/test-gen/stages.md +227 -0
- package/pipeline/preferences-template.json +10 -0
- package/pipeline/rules/figma-pipeline.md +2 -0
- package/pipeline/schemas/agent-state.schema.json +20 -5
- package/pipeline/schemas/phases.json +3 -2
- package/pipeline/schemas/phases.schema.json +5 -1
- package/pipeline/schemas/prefs.schema.json +51 -0
- package/pipeline/schemas/runs-index.schema.json +1 -1
- package/pipeline/schemas/test-gen-discovery.schema.json +73 -0
- package/pipeline/schemas/test-gen-gaps.schema.json +122 -0
- package/pipeline/schemas/test-gen-inventory.schema.json +59 -0
- package/pipeline/schemas/test-gen-results.schema.json +44 -0
- package/pipeline/scripts/_active-run.mjs +158 -0
- package/pipeline/scripts/_autopilot-outcomes.mjs +1 -0
- package/pipeline/scripts/_guard_rules.py +605 -0
- package/pipeline/scripts/_handle-patterns.mjs +166 -0
- package/pipeline/scripts/_http-post.mjs +141 -0
- package/pipeline/scripts/_stack-routing.mjs +4 -0
- package/pipeline/scripts/_test-gaming.mjs +227 -0
- package/pipeline/scripts/_unified-diff.mjs +104 -0
- package/pipeline/scripts/_worktree-snapshot.mjs +113 -0
- package/pipeline/scripts/agent-guard.py +118 -7
- package/pipeline/scripts/agent-guard.sh +50 -3
- package/pipeline/scripts/argv-secret-scan.mjs +114 -0
- package/pipeline/scripts/autopilot-runner.mjs +8 -2
- package/pipeline/scripts/confirm-command.mjs +70 -0
- package/pipeline/scripts/doctor.mjs +105 -0
- package/pipeline/scripts/evidence-gate.mjs +82 -7
- package/pipeline/scripts/gate-ledger.mjs +163 -7
- package/pipeline/scripts/handle-diff-audit.mjs +188 -0
- package/pipeline/scripts/plan-coverage-gate.mjs +9 -8
- package/pipeline/scripts/pre-commit-check.sh +12 -0
- package/pipeline/scripts/routine-registry.mjs +5 -1
- package/pipeline/scripts/run-scope-guard.mjs +188 -0
- package/pipeline/scripts/run-scope-guard.sh +23 -0
- package/pipeline/scripts/run-stop-guard.mjs +246 -0
- package/pipeline/scripts/run-stop-guard.sh +25 -0
- package/pipeline/scripts/run-ui-tests.sh +27 -3
- package/pipeline/scripts/test-classify.mjs +296 -0
- package/pipeline/scripts/test-gen-gate.mjs +409 -0
- package/pipeline/scripts/test-gen-state.mjs +313 -0
- package/pipeline/scripts/test-integrity-gate.mjs +76 -11
- package/pipeline/scripts/uninstall.mjs +2 -0
- package/pipeline/scripts/usage-register-beacon.mjs +239 -0
- package/pipeline/scripts/usage-register.mjs +128 -33
- package/pipeline/scripts/usage-report.mjs +43 -33
- package/pipeline/scripts/validate-state.mjs +26 -0
- package/pipeline/scripts/write-state.mjs +114 -0
- package/pipeline/skills/.skill-manifest.json +23 -3
- package/pipeline/skills/.skills-index.json +57 -2
- package/pipeline/skills/shared/README.md +10 -5
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
- package/pipeline/skills/shared/core/multi-agent-test-gen/SKILL.md +120 -0
- package/pipeline/skills/shared/external/fix-review-verdicts/SKILL.md +133 -0
- package/pipeline/skills/shared/external/handle-request/SKILL.md +97 -0
- package/pipeline/skills/shared/external/scenario-gap-analysis/SKILL.md +129 -0
- package/pipeline/skills/shared/external/scenario-gap-analysis/references/scenario-schema.md +60 -0
- package/pipeline/skills/shared/external/test-result-triage/SKILL.md +160 -0
- package/pipeline/skills/skills-index.md +7 -2
- 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 {
|
|
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
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
741
|
-
|
|
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
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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)
|
|
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);
|