@mmerterden/multi-agent-pipeline 20.15.1 → 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.
- package/CHANGELOG.md +45 -0
- package/README.md +6 -5
- package/README.tr.md +6 -5
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +14 -12
- package/docs/features.md +9 -1
- package/install/catalog-history.json +1 -1
- package/install/claude.mjs +40 -1
- package/install/codex.mjs +4 -3
- package/manifest.json +70 -51
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/bug-bash/SKILL.md +154 -0
- package/pipeline/commands/multi-agent/review/SKILL.md +46 -1
- package/pipeline/commands/multi-agent/setup/SKILL.md +8 -6
- package/pipeline/commands/multi-agent/sync/SKILL.md +4 -3
- package/pipeline/commands/multi-agent/update/SKILL.md +5 -1
- package/pipeline/commands/sim-test.md +71 -6
- package/pipeline/lib/plugin-skill-roots.mjs +80 -0
- package/pipeline/lib/usage-lifecycle.mjs +83 -0
- package/pipeline/multi-agent-refs/cross-cli-contract.md +20 -4
- package/pipeline/multi-agent-refs/features/assertion-judge.md +76 -0
- package/pipeline/multi-agent-refs/features/exploratory-testing.md +140 -0
- package/pipeline/multi-agent-refs/features/review-decision.md +2 -1
- package/pipeline/multi-agent-refs/features/skill-conformance.md +1 -1
- package/pipeline/multi-agent-refs/features/usage-reporting.md +59 -5
- package/pipeline/multi-agent-refs/features/valven.md +110 -0
- package/pipeline/multi-agent-refs/help/en.md +2 -0
- package/pipeline/multi-agent-refs/help/tr.md +2 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +2 -1
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -0
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +2 -4
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +5 -5
- package/pipeline/multi-agent-refs/phases/phase-4-commit.md +3 -2
- package/pipeline/schemas/agent-state.schema.json +16 -0
- package/pipeline/schemas/bug-bash-verification.schema.json +41 -0
- package/pipeline/schemas/explore-run.schema.json +108 -0
- package/pipeline/schemas/prefs.schema.json +21 -0
- package/pipeline/scripts/_stack-routing.mjs +2 -0
- package/pipeline/scripts/bug-bash.mjs +379 -0
- package/pipeline/scripts/explore-findings.mjs +410 -0
- package/pipeline/scripts/pr-timeline.mjs +135 -0
- package/pipeline/scripts/pre-commit-check.sh +4 -2
- package/pipeline/scripts/review-decision-gate.mjs +1 -1
- package/pipeline/scripts/skill-conformance.mjs +17 -71
- package/pipeline/scripts/smoke-cross-cli-behavior.sh +1 -1
- package/pipeline/scripts/uninstall.mjs +28 -3
- package/pipeline/scripts/usage-identity.mjs +36 -10
- package/pipeline/scripts/usage-register.mjs +95 -8
- package/pipeline/scripts/usage-report.mjs +52 -2
- package/pipeline/scripts/valven-gate.mjs +1069 -0
- package/pipeline/scripts/valven-port.mjs +169 -0
- package/pipeline/scripts/valven-split.mjs +341 -0
- package/pipeline/scripts/verify-citations.mjs +14 -1
- package/pipeline/skills/.skill-manifest.json +18 -6
- package/pipeline/skills/.skills-index.json +126 -71
- package/pipeline/skills/shared/README.md +8 -5
- package/pipeline/skills/shared/core/multi-agent-bug-bash/SKILL.md +150 -0
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +2 -0
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +5 -1
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +4 -3
- package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +4 -1
- package/pipeline/skills/shared/external/e2e-testing/SKILL.md +124 -0
- package/pipeline/skills/shared/external/e2e-testing/references/document-fixtures.md +65 -0
- package/pipeline/skills/shared/external/valven-rules/SKILL.md +93 -0
- package/pipeline/skills/shared/external/valven-rules/references/rules.yml +186 -0
- package/pipeline/skills/skills-index.md +76 -71
|
@@ -83,6 +83,14 @@ export const PIPELINE_AGENT_FILES = [
|
|
|
83
83
|
*/
|
|
84
84
|
export const PIPELINE_CORE_SKILL_DIRS = ["apple-archive-compliance", "google-play-compliance"];
|
|
85
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"];
|
|
93
|
+
|
|
86
94
|
/**
|
|
87
95
|
* Files the installer ships into ~/.claude/templates (install/templates/).
|
|
88
96
|
* Used only for an install that predates the install manifest; a unit test
|
|
@@ -319,7 +327,7 @@ function rmManifestFiles(hostDir, keepInLib) {
|
|
|
319
327
|
* @param {string} path
|
|
320
328
|
*/
|
|
321
329
|
function rmIfExists(path) {
|
|
322
|
-
if (!existsSync(path)) return false;
|
|
330
|
+
if (!existsSync(path) && !isSymlink(path)) return false;
|
|
323
331
|
if (dryRun) {
|
|
324
332
|
report("would remove", path);
|
|
325
333
|
return false;
|
|
@@ -534,6 +542,15 @@ function rmExternalDeliveredSkills(skillsDir) {
|
|
|
534
542
|
return n;
|
|
535
543
|
}
|
|
536
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
|
+
|
|
537
554
|
function rmMatchingDirs(parent, predicate) {
|
|
538
555
|
if (!existsSync(parent)) return 0;
|
|
539
556
|
let count = 0;
|
|
@@ -875,7 +892,11 @@ export async function main() {
|
|
|
875
892
|
name === "figma-common" ||
|
|
876
893
|
name === "figma-to-component",
|
|
877
894
|
);
|
|
878
|
-
n += rmMatchingDirs(
|
|
895
|
+
n += rmMatchingDirs(
|
|
896
|
+
skills,
|
|
897
|
+
(name) =>
|
|
898
|
+
PIPELINE_CORE_SKILL_DIRS.includes(name) || PIPELINE_RETIRED_SKILL_DIRS.includes(name),
|
|
899
|
+
);
|
|
879
900
|
n += rmExternalDeliveredSkills(skills);
|
|
880
901
|
if (n > 0) console.log(` removed ${n} skill dir(s) under ${skills}`);
|
|
881
902
|
rmIfExists(join(skills, ".skills-index.json"));
|
|
@@ -910,7 +931,11 @@ export async function main() {
|
|
|
910
931
|
name === "figma-common" ||
|
|
911
932
|
name === "figma-to-component",
|
|
912
933
|
);
|
|
913
|
-
n += rmMatchingDirs(
|
|
934
|
+
n += rmMatchingDirs(
|
|
935
|
+
skills,
|
|
936
|
+
(name) =>
|
|
937
|
+
PIPELINE_CORE_SKILL_DIRS.includes(name) || PIPELINE_RETIRED_SKILL_DIRS.includes(name),
|
|
938
|
+
);
|
|
914
939
|
n += rmPluginDeliveredSkills(skills);
|
|
915
940
|
n += rmExternalDeliveredSkills(skills);
|
|
916
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 }
|
|
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
|
-
|
|
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))
|
|
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:
|
|
181
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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 {
|
|
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, {
|
|
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 =
|
|
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}` : "";
|