@mmerterden/multi-agent-pipeline 20.11.1 → 20.12.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 CHANGED
@@ -16,6 +16,21 @@ Internal file-layout changes that don't affect the slash-command surface are sti
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [20.12.0] - 2026-10-01
20
+
21
+ ### Added
22
+
23
+ - `usage-report.mjs` records the outcome of every send attempt in `~/.claude/logs/multi-agent/usage-last-send.json`: `accepted`, `not-accepted`, `rejected` with the status code, `redirected` with the host it pointed to, `failed`, or `skipped` with its reason. It carries the run id, the event and the endpoint host, never the token. Any outcome other than `accepted` also prints one line on stderr. Previously the response was discarded, so a failed send left no trace on the panel or the machine.
24
+ - `/multi-agent:doctor` gains the `usage-report` check: it reads that record and turns anything but `accepted` into a WARN with the step that fixes it.
25
+ - `lib/agent-model-sync.mjs`: the installed agents whose `preferredModel` is `fable` (code-reviewer, plan-critic, the iOS, Android and backend architects) have their `model:` line written from `modelFallback.fableEnabled`, `fable` when on and `opus` when off. `/multi-agent:model on|off` re-applies it on every flip and the installer on every install, so the one switch covers agents spawned without an explicit model as well as routed dispatches. Sources keep `model: fable`; `/multi-agent:sync` normalizes the repo copy back with `--fable-on`.
26
+
27
+ ### Fixed
28
+
29
+ - `migrate-prefs.mjs` rewrites a `usageLog.endpoint` on `mmerterden.vercel.app` to `mmerterden.com`. The platform host answers every request with a 308, which the usage clients never follow.
30
+ - The unattended guard resolves a directory's worktree root once per decision instead of up to three times, which cuts a program-provenance check from seven git calls to five.
31
+ - `test/unattended-guard.test.mjs` is now `test/unattended-guard.serial.mjs` and runs after the parallel `test/*.test.mjs` pool. Inside the pool its guard decisions could outlast the guard's 5-second internal deadline, and the guard fails closed when that happens.
32
+ - Lockfile: `brace-expansion` 5.0.12 and `fast-uri` 3.1.8 (transitive), which clears the high and moderate advisories the scorecard gates on.
33
+
19
34
  ## [20.11.1] - 2026-09-29
20
35
 
21
36
  ### Changed
package/docs/facts.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$comment": "Generated by pipeline/scripts/gen-facts.mjs. Do not hand-edit: the site reads this, and a number edited here instead of at its source is the drift this file removes.",
3
- "generatedAt": "2026-09-29",
4
- "version": "20.11.1",
3
+ "generatedAt": "2026-10-01",
4
+ "version": "20.12.0",
5
5
  "phaseSchema": 2,
6
6
  "phases": [
7
7
  {
@@ -45,6 +45,7 @@ import {
45
45
  import { EXTERNAL_SKILLS_MANIFEST } from "./_platform-filter.mjs";
46
46
  import { DEV_ONLY_SCRIPTS, countDevOnlyFiles } from "./_dev-only-files.mjs";
47
47
  import { registerMcpServer } from "./_mcp-register.mjs";
48
+ import { applyFableRung, fableEnabled } from "../pipeline/lib/agent-model-sync.mjs";
48
49
 
49
50
  /**
50
51
  * A matcher an older install wrote. Hook matchers only ever see the tool name,
@@ -371,6 +372,14 @@ function installAgents(pipelineSrc, dest, useSymlinks) {
371
372
  }
372
373
  copyDir(agentsSrc, target, { useSymlinks });
373
374
  console.log(` -> ${countFiles(agentsSrc)} files copied to ${dest}`);
375
+ if (useSymlinks || isDryRun()) return;
376
+ const on = fableEnabled(join(dirname(dest), "multi-agent-preferences.json"));
377
+ const changed = applyFableRung(target, on);
378
+ if (changed.length) {
379
+ console.log(
380
+ ` -> ${changed.length} fable-tier agents on ${on ? "fable" : "opus"} (fableEnabled)`,
381
+ );
382
+ }
374
383
  }
375
384
 
376
385
  function installRules(pipelineSrc, dest, useSymlinks) {
package/manifest.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-pipeline",
3
- "version": "20.11.1",
4
- "commit": "86a41de90b8af136ea964b1f0ecefbbb4e4d7fd6",
5
- "generatedAt": "2026-09-29T14:46:56.105Z",
3
+ "version": "20.12.0",
4
+ "commit": "67faa4d431ba4b6d942630d6acbcfedbec220c9a",
5
+ "generatedAt": "2026-10-01T18:29:41.758Z",
6
6
  "algorithm": "sha256",
7
- "fileCount": 1230,
7
+ "fileCount": 1231,
8
8
  "files": {
9
- "CHANGELOG.md": "819283cb6a641af7676a5aef9ad84b97735ad0cedf23ae07ef9369cb5d7c147a",
9
+ "CHANGELOG.md": "787a8bd3432c3fdbbf572f8d8ef13c548291f0a46691abba28811943d516eaef",
10
10
  "LICENSE": "8d8c51aaf1e1538b180c69965fa038db71be86798cc3e26a143bebfefe16a43e",
11
11
  "README.md": "0613eebb0b38062bd94c44043595d1950b5c9b470874dd8e402c052fd20960cf",
12
12
  "README.tr.md": "0c6b5e6943d6c3d3bf3a7ed99c705fb9a78d15d7722e6252f58404d3f6d3524d",
@@ -33,7 +33,7 @@
33
33
  "docs/best-practices.md": "f8b75b0d38b5896a6a852bb30a1edddeb24dab8b5b7b1d9313290e1241c72024",
34
34
  "docs/ecosystem.md": "e6c00f30e95b5e178f77bdfbefd686c8a4164f39c03865a89ab23465d1a37acf",
35
35
  "docs/engineering.md": "550528cd47c7d2c921729c68d0782a00ac8e77a4bdfec479f6749f9c3235d188",
36
- "docs/facts.json": "cd66b23dbfb820000960a20413f377280b1f9c6bb3e8c926e315434183289634",
36
+ "docs/facts.json": "8f5a257e848433f3c3deca22a452e5407d7853913b5c7f60a304ff5a5aab4fee",
37
37
  "docs/features.md": "6c9f0efb00c683e6f611c440c87847b187f8b0eee59e9e812f3a241695bc2ae1",
38
38
  "docs/performance.md": "bbf2f3922c25dcba0c1a9e69ef420e3871a729af5b35009a49b48a5d3c5cd151",
39
39
  "docs/recovery-guide.md": "59a8f1b66b34cb9ff5507b3d54dac3b9915e184aa044d9c758d18cac818d510e",
@@ -52,7 +52,7 @@
52
52
  "install/_telemetry.mjs": "5433d753f909beed2c8789272bba05e245482c472f83d3444f58000c4c9a634b",
53
53
  "install/_unattended-profile.mjs": "e1ed82bde35d19105ee12c2a551eb778912b0e5c93ab227fd15c993098d98de0",
54
54
  "install/catalog-history.json": "15422563f83ca98278f94ae3e34730112e65888262e1da0442768352f46a75d4",
55
- "install/claude.mjs": "c7b0a6eff15e967778e6ab8c463949c79dade05309878b03836f023da704fb3a",
55
+ "install/claude.mjs": "6af22bb9b2594aee502c36550020d2c9c4c3222061ed8a88a0885687cd91ebec",
56
56
  "install/codex.mjs": "2c2238cd9dd2f95b4a50a505387e8e49ddc1513ae13998f05f1ed3e3a71b18e7",
57
57
  "install/copilot.mjs": "300a74f17084d1a38f16428ca7477394c29e33cd755156e19b92e457999be234",
58
58
  "install/index.mjs": "07ec26ecdd81cc983f146faf4b7095c98edce39c89a6fd83c24ce43229d2d9e7",
@@ -62,11 +62,11 @@
62
62
  "install/templates/multi-agent-autopilot-awake.plist.template": "ddd25c76c56fcbb38a89de8e35637372d70ace993d6bc487a88ca9cfb702fe1f",
63
63
  "install/templates/multi-agent-autopilot.plist.template": "122c8a61cd927edd99375d6e3da40f1d42bcceed5b6641785c8b28bb930bc77e",
64
64
  "install/unattended-profile-legacy.json": "f52e86b998956f09ad306b1735a6c10b58580d7082d8a55665a4c6bb15c3aa1f",
65
- "package.json": "533c73c39b466b487bd72cd53e7f8363b228d9902a5f88a55b31597b865df5d0",
65
+ "package.json": "64ce800c06e8882db62e426c03d3f729ec4344f44d9c6f50602d539c319ec054",
66
66
  "pipeline/agents/android-architect.md": "11fb74543cb0ebcf9525ca6654fe95d53da7229e0bf4a1149b31fd9005abc453",
67
67
  "pipeline/agents/backend-architect.md": "b02a606c301e4714131aed70ae6d16eeb77fbc819a001e2752cff2488d4246de",
68
68
  "pipeline/agents/bulk-reader.md": "6597052cd7d205bac2b1c7633f5232fe1a40730664a913b8eb77efd5a786dc25",
69
- "pipeline/agents/code-reviewer.md": "995c72b5d79bc64e45230b54e8ac5b90fbab73f187a509b331f83e5ec3493800",
69
+ "pipeline/agents/code-reviewer.md": "213798685e9bff10a08f0774f57bcfbab42ee15993095ec3a2c84e13a3453725",
70
70
  "pipeline/agents/dev-critic.md": "33843216b79977ce2926fc9c54f8c9eef30b91632a11ef624647229b61a9b36a",
71
71
  "pipeline/agents/explorer.md": "12564aec29d4581684bf84313b86a4add5a6989d3b8db5c35769674cab5649c6",
72
72
  "pipeline/agents/ios-architect.md": "e1fe0c034a4dfa76794ed04ba9f4bf85d5cdd3a5d6b948cc0635e8dda8baeef9",
@@ -104,7 +104,7 @@
104
104
  "pipeline/commands/multi-agent/language/SKILL.md": "4b58b25bda9449a52df005f8d3417ace2f8b3a18d84524dc021a9561f1d16b9d",
105
105
  "pipeline/commands/multi-agent/log/SKILL.md": "033bafcf4c9b8d117a892f50a3039fd11d753b4db070ed14c50e8388613482fa",
106
106
  "pipeline/commands/multi-agent/manual-test/SKILL.md": "7b81b1ff572fb4865495283c39f93d5c803fff2e8ce9008bf1a2ab51046c3d38",
107
- "pipeline/commands/multi-agent/model/SKILL.md": "4d0a5a9fcea459134a8d72d0ffff32134f0762f79cbb56638a717002ccb8da3c",
107
+ "pipeline/commands/multi-agent/model/SKILL.md": "56b636f8ab42c24defd3d1260f1a5e4dd332f4556d9b6d1a700322cbfa6f4f3d",
108
108
  "pipeline/commands/multi-agent/prune-logs/SKILL.md": "a7b22228ec338623c386cfd3744092488eed6871c65b531bdb6a0eb8dc8c2e9b",
109
109
  "pipeline/commands/multi-agent/prune-prompts/SKILL.md": "1aefd2b737b34aaff18f3fd12090322c04dbc41c981200622d7989c9ad45e9c2",
110
110
  "pipeline/commands/multi-agent/purge/SKILL.md": "c881805f80d7db909bcf09f1a5c980ebaf0c31d9a8ee9cae4f729387ff4ca19d",
@@ -130,7 +130,7 @@
130
130
  "pipeline/commands/multi-agent/status/SKILL.md": "a2ed316434aca49f692aafe11f95873822b9b955558a53f4f6a9667ee32a3659",
131
131
  "pipeline/commands/multi-agent/steer/SKILL.md": "07e738c228ba328f717f86e97e7af207df090b16433e28e613d430c3ff0aee3e",
132
132
  "pipeline/commands/multi-agent/store-ready/SKILL.md": "2fd7a908ee84669361bded693e7af53a17eb811e27cb4aa94237c0fce80591fa",
133
- "pipeline/commands/multi-agent/sync/SKILL.md": "17049e9ef98613825b0c4d074655477f033df460c9b760728c661f6c843a7276",
133
+ "pipeline/commands/multi-agent/sync/SKILL.md": "8d2a1d86a97cb27c4af5feba0eaa64bc937f979c3cba1e38dc1821557c243300",
134
134
  "pipeline/commands/multi-agent/test-accessibility/SKILL.md": "d62992541303a0776ca088d5f3d5475108fd3fba5e25af04efb3ea4bbbd1fe21",
135
135
  "pipeline/commands/multi-agent/test-dark-mode/SKILL.md": "6bfe158c94f76fc6f4d06454978d0c5cad6ec00645ab48c1d1cee15ad93ae095",
136
136
  "pipeline/commands/multi-agent/test-dynamic-type/SKILL.md": "c093fe1a78a4266ad4cf1e48b3cc233d9abd7e1c524d0b8e5db0c81d8b4d58fd",
@@ -162,6 +162,7 @@
162
162
  "pipeline/contract/types/index.d.ts": "5b738dd173c4946f8dae9cb926ad63b8adac68b77000085895608bfd87ac1aa9",
163
163
  "pipeline/lib/_jira-auth.sh": "fc468736057f8841d824eb1a4db527442562f8979fea333ebaef5527f4369e9f",
164
164
  "pipeline/lib/account-resolver.sh": "f7bef9beba3f839268509361651be0b1e1f319f2f23821f90ba466fdf185a8f6",
165
+ "pipeline/lib/agent-model-sync.mjs": "2e0264ca8d8fef761563db219d588fed9067847043f960f68f055c186c20b668",
165
166
  "pipeline/lib/analysis-jira-write.sh": "477e7b4e2aaaa8289ab0cdb315f04c3a9b22beb8f075bbb73b6fa174d6679197",
166
167
  "pipeline/lib/ask-choice.sh": "b77282645cf64d016cdebf43d1ee299874a1411f5c51b6ae3b6c580d1bbfe78a",
167
168
  "pipeline/lib/autopilot-activation.sh": "29102f7936ea8b8238fac4e9c557bdbb39e2ea5afdc3b6cdd20622593d36da9b",
@@ -193,7 +194,7 @@
193
194
  "pipeline/lib/json-file-lock.mjs": "6ff8d8d7b890fc549e07287eca1e8261b69f930ec523d08fb73786677aa0a45a",
194
195
  "pipeline/lib/md2confluence-v3.py": "3677ca47931e4b3d0fd2ce3e11299fc5f6d24799eb8332e37dd60b6c3c4653a2",
195
196
  "pipeline/lib/model-dispatch.sh": "7edd5c92d41ee3bf74972e0827ccbb671853d5410e7fb0af0b7cb020f95e05c5",
196
- "pipeline/lib/model-rung.sh": "2a5e04af3c6829d4c72e5f518e668a9cc2feffe6f2abb90a68c485f52e81959a",
197
+ "pipeline/lib/model-rung.sh": "8cecb4d390f306270713bd162e311dee879995e393970405c9a8f9af11da1727",
197
198
  "pipeline/lib/multi-repo-pipeline.sh": "5cca6bcc4fb857448ddadb31add343952be89423a328267e56496c7b7cdc7710",
198
199
  "pipeline/lib/normalize-text.mjs": "c2eeed65ab85cdc27a12ecaadc45dfa00b49a7611250c9ad881b9e93882a51c1",
199
200
  "pipeline/lib/outbound-gate.mjs": "1ebc840e975f19b0da4699b3a37361ecc8a2be1a7a558d2b2fb39b8b64244011",
@@ -263,13 +264,13 @@
263
264
  "pipeline/multi-agent-refs/features/cost-analysis.md": "42f1b90b41ef454f29ccfd3f93c959920f0e74cbca65e140695e9a30d6d90b87",
264
265
  "pipeline/multi-agent-refs/features/design-conformance.md": "a88ca595827cafa0c87c2facfcf9190dac69485e0596e5c198c6fc1613880452",
265
266
  "pipeline/multi-agent-refs/features/dev-critic.md": "08e01edef04b5ac216f8f43332126966d33da7b95625e47a8a6cd8ac105b263f",
266
- "pipeline/multi-agent-refs/features/doctor.md": "cf26e9fe4b51a47223ea0160c7c3c9c7ea177e70ec60c379271687e42fd4f594",
267
+ "pipeline/multi-agent-refs/features/doctor.md": "708aba37c507bf7559af816e6328a11ba0acdc6d61d023bc10afc727d068876a",
267
268
  "pipeline/multi-agent-refs/features/external-context-injection.md": "fe0373f27c3e9aae660a55a3ba9a8163df329b4d95c727d6023b6b3afaf71d66",
268
269
  "pipeline/multi-agent-refs/features/jira-context.md": "7d14802ed9ad1747e1a06dec26f22ff9e8959e03c47d7a018af45b8274aea47c",
269
270
  "pipeline/multi-agent-refs/features/maturity-followup.md": "e663e5bcf0b3b4d4d2a67f4516dbb9fb12520dfbdedb47a9c5f57c13a9a0633e",
270
- "pipeline/multi-agent-refs/features/model-fallback.md": "54063d87eb5db3e17c5666fdf0e04a88b9e13255d06f3de63875c4eb0868cad2",
271
+ "pipeline/multi-agent-refs/features/model-fallback.md": "e6b9635124f3557013dfbfa71d414a9d9abe9ddc89f2228a5496086c8e3b0c72",
271
272
  "pipeline/multi-agent-refs/features/package-manager.md": "b9e52f798f5dcba43abff1fe5cad8d5d5374058c9a6847c993975c665acab05a",
272
- "pipeline/multi-agent-refs/features/phone-api.md": "5e81290c85c4cb2cd83e3925b0fb502a19298ae3699aac351194f8ce3f21c791",
273
+ "pipeline/multi-agent-refs/features/phone-api.md": "817eeaffd3772b0a80811b0cd17dee3498f08f6eccf8fd7e56deb0bc24c1c434",
273
274
  "pipeline/multi-agent-refs/features/plan-critic.md": "d218c8599d893211480a412d46efdbba4aa48e3b18e8bb0ebfd66782f3b8ebc5",
274
275
  "pipeline/multi-agent-refs/features/plan-todos.md": "8780d80b4541daee2b05080759a0694ef7e6270abe9a4621deb92ec7bac94e8e",
275
276
  "pipeline/multi-agent-refs/features/prior-fix-detection.md": "9bd172fa116b0c334576ee3df2dc478810a7e282422410e045be74ac2299253d",
@@ -288,9 +289,9 @@
288
289
  "pipeline/multi-agent-refs/features/stack-adapters.md": "71e23385da872ceed35263f4838f154800aa2da55bb6688ccca74726b70a3ef5",
289
290
  "pipeline/multi-agent-refs/features/stack-skill-routing.md": "1989f7f64be6c5ee0a42d8e9962aead7cd7472d795915ab953d89d9382f317e6",
290
291
  "pipeline/multi-agent-refs/features/unattended-gates.md": "46b516cac81a4eacf5d58bc3b1613e2266c26f1559be8dac060d1dc6b7d7ca01",
291
- "pipeline/multi-agent-refs/features/unattended-security.md": "eeb8656845ac78ab612b2e3352b241a6ba2d8b8f6d4a6d5c2e7cab1f6592a4d6",
292
+ "pipeline/multi-agent-refs/features/unattended-security.md": "18da3d51c175a42ab6312051a50b9d0339524dad2cc34e0350de62fbf0cece91",
292
293
  "pipeline/multi-agent-refs/features/url-enrichment.md": "72e01a799d9fa54b56c762ba404a9f9236adbbf98320e395a96f6b0f5917e76b",
293
- "pipeline/multi-agent-refs/features/usage-reporting.md": "6c232060da49033ef2c9ccead69084dfb7cfe45333f670b407b8afada8b99007",
294
+ "pipeline/multi-agent-refs/features/usage-reporting.md": "095d86b839c1289c1023919da3972e561c1bbd4a32a1fc9f8e694abe887005ac",
294
295
  "pipeline/multi-agent-refs/features/verify-by-test.md": "b0735adaa7ead797167b8d9ea48a10851a138083c6e638738cc96c66b02a1e37",
295
296
  "pipeline/multi-agent-refs/features/verify.md": "1a6db5e7239eac01e51100353013abd16bf785a7fce88b40139fca27f4166be4",
296
297
  "pipeline/multi-agent-refs/features/visual-evidence.md": "2b6aa2e4ff05c7a1fbe9e0fedfa4b28b9ad01c93a64f1ca6f6cd3e02293866aa",
@@ -481,7 +482,7 @@
481
482
  "pipeline/scripts/crush-json.mjs": "303382670f8f170c8c6710202853a2de2915625813cd157185f9be4e2b27258e",
482
483
  "pipeline/scripts/diff-explain.mjs": "194d0635639607cb1af3de064453162e01c0d59dc0c2e187d21d45bae3bc559c",
483
484
  "pipeline/scripts/diff-risk-score.mjs": "b3e8da42fea1258162e40f4eb7809074831339b7563874527f7e825f7aa795ca",
484
- "pipeline/scripts/doctor.mjs": "a832682b5afb5641fbae180aa9f1cf8a0678d8c8daded452b86b9d717e049b19",
485
+ "pipeline/scripts/doctor.mjs": "0ac54a2630fc00b39bdf430f9466f91e6b52296a23dca8694ac1a1bd894bcfea",
485
486
  "pipeline/scripts/evidence-gate.mjs": "347a8d3fa1d17d996de345585c216bd7071f4c1d687999daf6e48881720ea5df",
486
487
  "pipeline/scripts/feedback-send.mjs": "d3ed66e9dacdfb4b5eacf1b244bf66752304fb4fcb144531441b458f3ab806be",
487
488
  "pipeline/scripts/finding-fingerprint.mjs": "fa12ab10701db5d2bfff014e41d1cd4c0b2b772aee5fc69205de14b2761b0c83",
@@ -518,7 +519,7 @@
518
519
  "pipeline/scripts/maturity-followup.mjs": "531eb9578a71ea5bb2510bb24a48f264c36aaf7adceca7b4ec3dccd1c63ae412",
519
520
  "pipeline/scripts/memory-load.sh": "c6a1976372ed033c9f3b55e757e22d05b4ef0d9774bfe185453d8847525b4a89",
520
521
  "pipeline/scripts/memory-save.sh": "ab6b4c3d78707e0ac66fe56726308782568f247463d4c7edf2316b2dd399ee6b",
521
- "pipeline/scripts/migrate-prefs.mjs": "bb389c2527edaa5bf317ee1be55c2f449da21100fc9660943764d2bcc757d665",
522
+ "pipeline/scripts/migrate-prefs.mjs": "9d38ef7dac14c48bf1c6c8053680999728d3179c598e6c9e53f5a66290fc323e",
522
523
  "pipeline/scripts/migrate-state.mjs": "b6fb42f56ea56ab10c00e947f013e3aecaeef1aa0d819493eedbef7cdf7d4370",
523
524
  "pipeline/scripts/note-session.sh": "1466d93f401c66074705635117a052a3f811ca22f928defa1ec2e4c804c19fc1",
524
525
  "pipeline/scripts/observations.mjs": "edef08a8ab8f967030da0575d83f501eb6256460c0b72c2ba377c629b3ee0674",
@@ -583,13 +584,13 @@
583
584
  "pipeline/scripts/token-budget-report.mjs": "508bbc659df478c535c0ecdd3f8611372af8fef0b0932c7b19a247af2b2b9265",
584
585
  "pipeline/scripts/triage-memory.mjs": "2d8541145f1749593ae5728fb4c9eb3001484edb5e4d37be473aeec34d1dbe7c",
585
586
  "pipeline/scripts/ui-tree-dumper.swift": "a09e42ef069eafa08c05afd8333e05b754ef6a7d8b9687712039b0f2c9801273",
586
- "pipeline/scripts/unattended_policy.py": "0f5dc7284421c529368e1dcbf30e3a85e43e73076fd09909ac185cf4ba1eff90",
587
+ "pipeline/scripts/unattended_policy.py": "f1df6470c798bd7d4bc9f8338eaabf98bdcfab00198d98b7e2e654aab84b709c",
587
588
  "pipeline/scripts/uninstall.mjs": "880923522ef59fdd98cb7ecb1bdc85c0f15d1e2eed2d79d6d353e88712ca95fa",
588
589
  "pipeline/scripts/update-check.sh": "9c00647e7ca4249414b2e5af08f61705ab5bd2106b6f66ae8ebce8aa143dc957",
589
590
  "pipeline/scripts/update-issue-progress.sh": "ccfef87743e91272e009e396ac07461d2784b2fa84a06c802a582526eeeb9ed9",
590
591
  "pipeline/scripts/usage-identity.mjs": "47c1f907fb1ab47672687ea0cbf2d0737985ae91ec2b48b4fe260e14c3d35d70",
591
592
  "pipeline/scripts/usage-register.mjs": "cc19d7ce5efe57255ce1521d33ad4b9a75b6833385ed6edcc5e339faf3c6e06c",
592
- "pipeline/scripts/usage-report.mjs": "93c3f4055592c9cf5007cccf4710d06900576ccc10d1523c631acd13ca68cf10",
593
+ "pipeline/scripts/usage-report.mjs": "af1672206b1fa7d6612fa70aa46fd63372552619fdba866d89819a12ba943549",
593
594
  "pipeline/scripts/validate-analysis-doc.mjs": "ae56b7578ef51e008a99f84eba9def3e0f1dd2bd87a3880f2083aa98b365f7a1",
594
595
  "pipeline/scripts/validate-analysis.mjs": "d2dc9ccf45f6ca2702908761939edd830d18b3b273016a4b449f26fe285d5ea1",
595
596
  "pipeline/scripts/validate-code-graph.mjs": "0ceac2f5b59f641334d54dc30bee5265361fb5fe1fc97e2f9791b546efe79941",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-pipeline",
3
- "version": "20.11.1",
3
+ "version": "20.12.0",
4
4
  "description": "6-phase AI development pipeline with full orchestration on Claude Code, Copilot CLI and Codex CLI. Analysis, planning, TDD, CLI-aware parallel review with consensus surfacing + Fable triage, default-FAIL evidence gates, secret + intent guards, per-phase cost ledger, persistent learnings memory, wiki generation, commit automation. Token-preserving uninstall.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -20,12 +20,12 @@
20
20
  },
21
21
  "scripts": {
22
22
  "start": "node index.js",
23
- "test": "npm run format:check && npm run lint && node --test test/*.test.mjs && node pipeline/scripts/run-smokes.mjs && node pipeline/scripts/lint-skills.mjs && node pipeline/scripts/gen-skills-index.mjs --check && node pipeline/scripts/gen-catalog-history.mjs --check && node pipeline/scripts/lint-personas.mjs && node pipeline/scripts/lint-mcp-refs.mjs && node pipeline/scripts/eval-triage.mjs && node pipeline/scripts/eval-golden-tasks.mjs && node pipeline/scripts/eval-intent.mjs && node pipeline/scripts/eval-recall.mjs && node pipeline/scripts/eval-guard-robustness.mjs && node pipeline/scripts/validate-schemas.mjs && node pipeline/scripts/validate-prefs.mjs && node pipeline/scripts/scorecard.mjs",
24
- "test:unit": "node --test test/*.test.mjs",
23
+ "test": "npm run format:check && npm run lint && node --test test/*.test.mjs && node --test test/*.serial.mjs && node pipeline/scripts/run-smokes.mjs && node pipeline/scripts/lint-skills.mjs && node pipeline/scripts/gen-skills-index.mjs --check && node pipeline/scripts/gen-catalog-history.mjs --check && node pipeline/scripts/lint-personas.mjs && node pipeline/scripts/lint-mcp-refs.mjs && node pipeline/scripts/eval-triage.mjs && node pipeline/scripts/eval-golden-tasks.mjs && node pipeline/scripts/eval-intent.mjs && node pipeline/scripts/eval-recall.mjs && node pipeline/scripts/eval-guard-robustness.mjs && node pipeline/scripts/validate-schemas.mjs && node pipeline/scripts/validate-prefs.mjs && node pipeline/scripts/scorecard.mjs",
24
+ "test:unit": "node --test test/*.test.mjs && node --test test/*.serial.mjs",
25
25
  "test:smoke": "node pipeline/scripts/run-smokes.mjs",
26
26
  "lint:skills": "node pipeline/scripts/lint-skills.mjs",
27
- "test:quick": "node --test test/*.test.mjs && node pipeline/scripts/lint-skills.mjs && node pipeline/scripts/lint-personas.mjs && node pipeline/scripts/lint-mcp-refs.mjs",
28
- "test:coverage": "c8 --clean --reporter=none node --test test/*.test.mjs && c8 --clean=false --check-coverage --reporter=text --reporter=lcov node pipeline/scripts/run-smokes.mjs",
27
+ "test:quick": "node --test test/*.test.mjs && node --test test/*.serial.mjs && node pipeline/scripts/lint-skills.mjs && node pipeline/scripts/lint-personas.mjs && node pipeline/scripts/lint-mcp-refs.mjs",
28
+ "test:coverage": "c8 --clean --reporter=none node --test test/*.test.mjs && c8 --clean=false --reporter=none node --test test/*.serial.mjs && c8 --clean=false --check-coverage --reporter=text --reporter=lcov node pipeline/scripts/run-smokes.mjs",
29
29
  "lint": "eslint .",
30
30
  "lint:fix": "eslint . --fix",
31
31
  "format": "prettier --write \"**/*.{js,mjs,json,yml}\" --ignore-path .gitignore --ignore-path .prettierignore",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: code-reviewer
3
- description: "Code reviewer for multi-agent Phase 3 - security, architecture, quality, performance. Default model is fable (opus is the first fallback); Phase 3 orchestrator overrides to sonnet for Reviewer 3."
3
+ description: "Code reviewer for multi-agent Phase 3 - security, architecture, quality, performance. Model follows the fable switch (fable when on, opus when off); Phase 3 orchestrator overrides to sonnet for Reviewer 3."
4
4
  model: fable
5
5
  preferredModel: fable
6
6
  modelRationale: "Reviewer 1 tier - deep security + architecture review runs on fable, the top tier (opus is the first fallback). Phase 3 orchestrator overrides to sonnet for Reviewer 3 (quality/correctness focus) via CLAUDE_CODE_SUBAGENT_MODEL before dispatch. Copilot CLI adds Reviewer 2 on gpt-5.4 for cross-model diversity."
@@ -38,6 +38,13 @@ This command sets both, together:
38
38
  Flipping one without the other is the defect this closes, so the pair moves as
39
39
  one write or not at all.
40
40
 
41
+ The same step re-applies the installed agents (`lib/agent-model-sync.mjs`): every
42
+ agent under `~/.claude/agents` whose `preferredModel` is `fable` gets
43
+ `model: fable` while the switch is on and `model: opus` while it is off. An agent
44
+ spawned without an explicit model runs on that line, so without this the switch
45
+ would cover routed dispatches only. The installer applies the same rule on every
46
+ install and update, so a sync never puts the agents back on a rung that is off.
47
+
41
48
  ## What it reports with no argument
42
49
 
43
50
  The current rung, the pricing model, and **what the switch means on this host** -
@@ -134,12 +134,10 @@ Step 0 gate rules and why: `features/doctor.md`.
134
134
  ~/multi-agent-pipeline/pipeline/commands/ 2>/dev/null \
135
135
  && { echo "ABORT: corporate reference leaked into pipeline/commands"; exit 1; } || true
136
136
  ```
137
- - **Localized picker descriptions are an installed-tree artifact.** When
138
- `outputLanguage=tr`, the installed `description:` values are Turkish and a
139
- `description-en:` sidecar holds the English original (see
140
- `localize-commands.mjs`). The repo must ALWAYS receive the English form:
141
- after copying commands into `pipeline/commands/`, normalize the copy and
142
- verify no sidecar or Turkish description leaked:
137
+ - **Installed-tree artifacts.** Picker descriptions are Turkish with a
138
+ `description-en:` sidecar when `outputLanguage=tr`, and fable-tier agents
139
+ carry `model: opus` while `fableEnabled` is off. The repo always gets the
140
+ English form and `model: fable`, so normalize after copying:
143
141
  ```bash
144
142
  # normalize: restore English descriptions, strip description-en sidecars
145
143
  node ~/multi-agent-pipeline/pipeline/scripts/localize-commands.mjs restore \
@@ -149,6 +147,7 @@ Step 0 gate rules and why: `features/doctor.md`.
149
147
  # backstop: repo description lines must be English (Turkish-diacritics proxy)
150
148
  grep -rn '^description:.*[çğıöşüÇĞİÖŞÜ]' ~/multi-agent-pipeline/pipeline/commands/ 2>/dev/null \
151
149
  && { echo "ABORT: localized description leaked into pipeline/commands"; exit 1; } || true
150
+ node ~/multi-agent-pipeline/pipeline/lib/agent-model-sync.mjs --dir ~/multi-agent-pipeline/pipeline/agents --fable-on
152
151
  ```
153
152
 
154
153
  6. **Cross-platform smoke gate** (final step of the REPO sync, before push):
@@ -0,0 +1,95 @@
1
+ /**
2
+ * agent-model-sync.mjs - installed agents follow the fable switch.
3
+ *
4
+ * prefs.global.modelFallback.fableEnabled is the one switch for the fable rung.
5
+ * The router honours it for every dispatch it resolves, but an agent spawned
6
+ * without an explicit model runs on its frontmatter `model:`. So every installed
7
+ * agent whose `preferredModel` is fable has that line written from the switch:
8
+ * fable while it is on, opus while it is off. Sources keep `model: fable`; only
9
+ * installed copies change. Called by model-rung.sh on every flip and by the
10
+ * installer after it lays the agents down.
11
+ *
12
+ * Usage:
13
+ * node agent-model-sync.mjs [--dir <agents dir>] [--prefs <prefs file>] [--fable-on]
14
+ *
15
+ * --fable-on ignores the switch and writes fable: the sync step uses it to put
16
+ * the repo copy back on its source form.
17
+ *
18
+ * @module pipeline/lib/agent-model-sync
19
+ */
20
+
21
+ import { existsSync, lstatSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { pathToFileURL } from "node:url";
25
+
26
+ const FRONTMATTER = /^---\n([\s\S]*?)\n---\n/;
27
+
28
+ /**
29
+ * @param {string} dir - an installed agents directory
30
+ * @param {boolean} fableOn
31
+ * @returns {string[]} the file names whose model line changed
32
+ */
33
+ export function applyFableRung(dir, fableOn) {
34
+ if (!existsSync(dir)) return [];
35
+ // A linked agents directory is the source tree itself (a maintainer's
36
+ // --symlink install); writing there would edit the shipped personas.
37
+ if (lstatSync(dir).isSymbolicLink()) return [];
38
+ const want = fableOn ? "fable" : "opus";
39
+ const changed = [];
40
+ for (const name of readdirSync(dir).sort()) {
41
+ if (!name.endsWith(".md")) continue;
42
+ const path = join(dir, name);
43
+ let text;
44
+ try {
45
+ text = readFileSync(path, "utf8");
46
+ } catch {
47
+ continue;
48
+ }
49
+ const front = FRONTMATTER.exec(text);
50
+ if (!front || !/^preferredModel:\s*fable\s*$/m.test(front[1])) continue;
51
+ const nextFront = front[1].replace(/^model:.*$/m, `model: ${want}`);
52
+ if (nextFront === front[1]) continue;
53
+ const next = text.replace(front[1], nextFront);
54
+ if (lstatSync(path).isSymbolicLink()) rmSync(path);
55
+ writeFileSync(path, next);
56
+ changed.push(name);
57
+ }
58
+ return changed;
59
+ }
60
+
61
+ /**
62
+ * @param {string} prefsPath
63
+ * @returns {boolean}
64
+ */
65
+ export function fableEnabled(prefsPath) {
66
+ try {
67
+ const prefs = JSON.parse(readFileSync(prefsPath, "utf8"));
68
+ return prefs?.global?.modelFallback?.fableEnabled === true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ function arg(argv, flag) {
75
+ const i = argv.indexOf(flag);
76
+ return i >= 0 ? argv[i + 1] : undefined;
77
+ }
78
+
79
+ const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
80
+ if (invokedDirectly) {
81
+ const argv = process.argv.slice(2);
82
+ const claude = join(homedir(), ".claude");
83
+ const dir = arg(argv, "--dir") ?? join(claude, "agents");
84
+ const prefs =
85
+ arg(argv, "--prefs") ??
86
+ (process.env.MULTI_AGENT_PREFS || undefined) ??
87
+ join(claude, "multi-agent-preferences.json");
88
+ const on = argv.includes("--fable-on") || fableEnabled(prefs);
89
+ const changed = applyFableRung(dir, on);
90
+ if (changed.length) {
91
+ process.stdout.write(
92
+ `agents on ${on ? "fable" : "opus"}: ${changed.map((n) => n.replace(/\.md$/, "")).join(", ")}\n`,
93
+ );
94
+ }
95
+ }
@@ -128,6 +128,13 @@ fi
128
128
  mv "$TMP" "$PREFS"
129
129
  trap - EXIT
130
130
 
131
+ # Agents spawned without an explicit model run on their frontmatter, so the
132
+ # installed fable-tier agents follow the switch in the same step.
133
+ if command -v node >/dev/null 2>&1 && [ -f "$_MA_MR_HERE/agent-model-sync.mjs" ]; then
134
+ node "$_MA_MR_HERE/agent-model-sync.mjs" --prefs "$PREFS" ||
135
+ echo "model-rung: installed agents could not be updated; run node $_MA_MR_HERE/agent-model-sync.mjs" >&2
136
+ fi
137
+
131
138
  echo "fable rung: $CUR -> $WANT"
132
139
  echo "cost pricing: $PRICING -> $WANT_PRICING"
133
140
  echo ""
@@ -293,6 +293,16 @@ readiness, not correctness: a machine that fails all four still runs the
293
293
  pipeline perfectly well with someone watching it. What they catch is the
294
294
  failure mode of an unwatched run - stopping without saying so.
295
295
 
296
+
297
+ ### usage-report
298
+
299
+ The outcome `usage-report.mjs` recorded for its last send attempt, read from
300
+ `logs/multi-agent/usage-last-send.json`. SKIP when `usageLog.enabled` is off or
301
+ nothing has been sent yet; OK when the last send was accepted; WARN for every
302
+ other outcome (`redirected`, `rejected`, `not-accepted`, `failed`, `skipped`),
303
+ with the status code or reason and the step that fixes it. The emitter runs
304
+ detached from phase-tracker with its output discarded, so this record is the
305
+ only surface on which a reporting path that stopped working shows up.
296
306
  ### unattended-contract
297
307
 
298
308
  Is `multi-agent-refs/unattended-contract.md` installed. It is the only place
@@ -132,6 +132,16 @@ evidence, and there is now one fewer of them. Triage also runs on `opus`, which
132
132
  makes it the same model as Reviewer 1; the Step 3 anonymisation requirement
133
133
  already covers that case and is not optional here.
134
134
 
135
+ **Installed agents follow the switch.** Five personas declare
136
+ `preferredModel: fable` (code-reviewer, plan-critic, and the iOS, Android and
137
+ backend architects). A dispatch the router resolves passes its model explicitly,
138
+ but an agent spawned without one runs on its frontmatter `model:`. So the
139
+ installed copies are written from `fableEnabled`: `fable` while it is on, `opus`
140
+ while it is off. `model-rung.sh` re-applies them on every flip and the installer
141
+ on every install; sources keep `model: fable`, so `lint-personas.mjs` still holds
142
+ `model === preferredModel` there. A `--symlink` install links the source tree and
143
+ is left alone.
144
+
135
145
  **Commands without Phase 0 resolve it themselves.** `/multi-agent:review`,
136
146
  `/multi-agent:review-analysis` and the untracked-branch tail of
137
147
  `/multi-agent:resume` dispatch reviewers and triage without a Phase 0 Step 0, so
@@ -303,4 +303,4 @@ Scripts: `contract-server.mjs` (phone branch, `isPhonePath`), `_phone-auth.mjs`
303
303
  `unattended-policy.json` (`publisherScripts`). Tests:
304
304
  `test/phone-api.test.mjs`, `test/contract-server.test.mjs`,
305
305
  `test/launch-request.test.mjs`, `test/redact.test.mjs`,
306
- `test/contract-kit.test.mjs`, `test/unattended-guard.test.mjs`.
306
+ `test/contract-kit.test.mjs`, `test/unattended-guard.serial.mjs`.
@@ -35,7 +35,7 @@ the variable the same way.
35
35
 
36
36
  With the variable unset every hook behaves as it did without this feature:
37
37
  `smoke-unattended-redteam.sh` runs the same inputs both ways, and
38
- `test/unattended-guard.test.mjs` holds the attended verdicts.
38
+ `test/unattended-guard.serial.mjs` holds the attended verdicts.
39
39
 
40
40
  ## The guard
41
41
 
@@ -722,7 +722,7 @@ Scripts: `agent-guard.sh`, `agent-guard.py`, `unattended_policy.py`,
722
722
  `lib/outbound-gate.mjs`, `lib/unattended-settings-location.mjs`,
723
723
  `install/_unattended-profile.mjs`. Data:
724
724
  `schemas/unattended-policy.json`, `schemas/unattended-profile.json`,
725
- `schemas/pr-request.schema.json`. Tests: `test/unattended-guard.test.mjs`,
725
+ `schemas/pr-request.schema.json`. Tests: `test/unattended-guard.serial.mjs`,
726
726
  `test/pr-request.test.mjs`, `test/autopilot-publish.test.mjs`,
727
727
  the runner's tests, `test/outbound-gate.test.mjs`,
728
728
  `test/unattended-profile.test.mjs`, `test/agent-guard.test.mjs`; smokes
@@ -147,6 +147,19 @@ reason, `unavailable` with the cause, `declined`, `registered`, `enabled`, `dry-
147
147
  "you turned it off" and "we could not reach the endpoint" are different facts
148
148
  about the same empty panel.
149
149
 
150
+ Every send attempt past the on-switch overwrites
151
+ `~/.claude/logs/multi-agent/usage-last-send.json` with its outcome: `accepted`,
152
+ `not-accepted` (the server answered but did not keep the record), `rejected`
153
+ (with the status code), `redirected` (with the host it pointed to; a redirect is
154
+ never followed, because the token would ride along), `failed` (network or
155
+ timeout), or `skipped` (`no-token`, `no-login`, `endpoint-not-allowed`). It
156
+ carries the run id, the event and the endpoint host, never the token. The
157
+ phase-tracker ping discards the emitter's output, so `/multi-agent:doctor`
158
+ reads this file (check `usage-report`) and turns anything but `accepted` into a
159
+ WARN with one step. `migrate-prefs.mjs` rewrites an endpoint on the platform
160
+ host to the site's domain, so a stored redirecting endpoint does not outlive an
161
+ update.
162
+
150
163
  `prefs.global.usageLog.endpoint` overrides where both calls go - the register URL
151
164
  is derived from it, so a self-hosted ingest gets its own registration rather than
152
165
  this one's. Absent means the shipped default, and every shipped default names the
@@ -99,6 +99,7 @@ const CHECK_IDS = [
99
99
  "mcp-surface",
100
100
  "disk-space",
101
101
  "worktree-residue",
102
+ "usage-report",
102
103
  ];
103
104
 
104
105
  // Only under `--profile server`. Kept in their own list so `--list-checks`
@@ -883,6 +884,52 @@ function checkMcpSurface() {
883
884
  );
884
885
  }
885
886
 
887
+ // usage-report.mjs runs detached with its output discarded, so the record it
888
+ // writes after every send attempt is the one place a broken reporting path
889
+ // becomes visible.
890
+ const USAGE_STEPS = {
891
+ "no-login": () => `run node ${join(HERE, "usage-identity.mjs")} --refresh`,
892
+ "no-token": () => `run node ${join(HERE, "usage-register.mjs")}`,
893
+ "endpoint-not-allowed": () => "set usageLog.endpoint to an https address",
894
+ rejected: () => `run node ${join(HERE, "usage-register.mjs")}`,
895
+ "not-accepted": () => `run node ${join(HERE, "usage-identity.mjs")} --refresh`,
896
+ redirected: (r) =>
897
+ r.location
898
+ ? `set usageLog.endpoint to the address on ${r.location} it redirects to`
899
+ : `run node ${join(HERE, "migrate-prefs.mjs")}`,
900
+ failed: () => "re-run a phase once the reporting endpoint is reachable",
901
+ };
902
+
903
+ function checkUsageReport() {
904
+ const prefs = readJson(join(CLAUDE, "multi-agent-preferences.json"));
905
+ const usage = prefs?.global?.usageLog;
906
+ if (usage?.enabled !== true || usage?.optOut === true) {
907
+ skip("usage-report", "usage reporting is off");
908
+ return;
909
+ }
910
+ const last = readJson(join(CLAUDE, "logs", "multi-agent", "usage-last-send.json"));
911
+ if (!last || typeof last.outcome !== "string") {
912
+ skip("usage-report", "no report has been sent from this machine yet");
913
+ return;
914
+ }
915
+ if (last.outcome === "accepted") {
916
+ ok("usage-report");
917
+ return;
918
+ }
919
+ const key = last.outcome === "skipped" ? last.reason : last.outcome;
920
+ const stepFor = USAGE_STEPS[key] ?? USAGE_STEPS.failed;
921
+ const detail = [last.status, last.reason, last.location && `to ${last.location}`]
922
+ .filter(Boolean)
923
+ .join(", ");
924
+ const when = last.t ? ` at ${last.t}` : "";
925
+ report(
926
+ "usage-report",
927
+ "WARN",
928
+ `the last usage report${last.id ? ` for ${last.id}` : ""} was ${last.outcome}${detail ? ` (${detail})` : ""}${when}, so runs are missing from the usage panel`,
929
+ stepFor(last),
930
+ );
931
+ }
932
+
886
933
  function checkDiskSpace() {
887
934
  let freeGb = null;
888
935
  try {
@@ -1175,6 +1222,7 @@ function main() {
1175
1222
  checkMcpSurface();
1176
1223
  checkDiskSpace();
1177
1224
  checkWorktreeResidue();
1225
+ checkUsageReport();
1178
1226
  if (PROFILE === "server") checkServerProfile(resolved);
1179
1227
 
1180
1228
  const blocked = results.filter((r) => r.severity === "BLOCK");
@@ -15,6 +15,7 @@ import path from "node:path";
15
15
  import os from "node:os";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { runMain } from "../lib/fatal.mjs";
18
+ import { canonicalEndpoint } from "../lib/usage-endpoint.mjs";
18
19
  import { lockTimeoutFrom, withFileLockSync, writeJsonAtomicSync } from "../lib/json-file-lock.mjs";
19
20
 
20
21
  const DEFAULT_FILE = path.join(os.homedir(), ".claude", "multi-agent-preferences.json");
@@ -445,9 +446,22 @@ function migrate(prefs) {
445
446
  // reportContent / wikiScope structure. Idempotent - safe to re-run.
446
447
  migrateReportChannels(out, changes);
447
448
 
449
+ // 13. usageLog.endpoint on the platform host: it answers every request with a
450
+ // redirect, and the usage clients never follow one with a credential attached.
451
+ migrateUsageEndpoint(out, changes);
452
+
448
453
  return { out, changes, alreadyMigrated: changes.length === 0 };
449
454
  }
450
455
 
456
+ function migrateUsageEndpoint(out, changes) {
457
+ const usage = out.global.usageLog;
458
+ if (!usage || typeof usage !== "object" || typeof usage.endpoint !== "string") return;
459
+ const next = canonicalEndpoint(usage.endpoint);
460
+ if (next === usage.endpoint) return;
461
+ changes.push(`usageLog.endpoint: ${usage.endpoint} → ${next}`);
462
+ usage.endpoint = next;
463
+ }
464
+
451
465
  // v5.7+ sub-migration: legacy jiraCommentDefault / confluenceDefault / wikiDefault booleans
452
466
  // → new reportChannels {pr,jira,confluence,wiki} object + wikiScope array. Also initializes
453
467
  // reportContent + autopilotReportTimeoutSeconds with schema defaults. Old fields are removed
@@ -570,15 +570,22 @@ def _run_base_sha(env=None):
570
570
  return sha if re.match(r"^[0-9a-f]{7,40}$", sha) else None
571
571
 
572
572
 
573
+ _TOP_CACHE = {}
574
+
575
+
573
576
  def _worktree_top(d):
577
+ if d in _TOP_CACHE:
578
+ return _TOP_CACHE[d]
574
579
  try:
575
580
  import subprocess
576
581
 
577
582
  r = subprocess.run(["git", "-C", d, "rev-parse", "--show-toplevel"],
578
583
  capture_output=True, text=True, timeout=3)
579
- return os.path.realpath(r.stdout.strip()) if r.returncode == 0 and r.stdout.strip() else None
584
+ top = os.path.realpath(r.stdout.strip()) if r.returncode == 0 and r.stdout.strip() else None
580
585
  except Exception:
581
- return None
586
+ top = None
587
+ _TOP_CACHE[d] = top
588
+ return top
582
589
 
583
590
 
584
591
  def _tracked_clean(path, env=None, cwd=None):
@@ -609,11 +616,11 @@ def _tracked_clean(path, env=None, cwd=None):
609
616
 
610
617
  # The file's path relative to the worktree root, so the base lookup
611
618
  # names it correctly and a file outside this worktree is rejected.
612
- top = g("rev-parse", "--show-toplevel")
619
+ top = _worktree_top(d)
613
620
  ls = g("ls-files", "--error-unmatch", "--", name)
614
621
  st = g("status", "--porcelain", "--untracked-files=all", "--", name)
615
- in_worktree = top.returncode == 0 and bool(top.stdout.strip())
616
- rel = os.path.relpath(p, os.path.realpath(top.stdout.strip())) if in_worktree else None
622
+ in_worktree = top is not None
623
+ rel = os.path.relpath(p, top) if in_worktree else None
617
624
  at_base = (
618
625
  g("cat-file", "-e", f"{base}:{rel}").returncode == 0
619
626
  and g("diff", "--quiet", base, "--", name).returncode == 0
@@ -27,7 +27,15 @@
27
27
  * @module pipeline/scripts/usage-report
28
28
  */
29
29
 
30
- import { readFileSync, existsSync, readdirSync, appendFileSync } from "fs";
30
+ import {
31
+ readFileSync,
32
+ existsSync,
33
+ readdirSync,
34
+ appendFileSync,
35
+ mkdirSync,
36
+ renameSync,
37
+ writeFileSync,
38
+ } from "fs";
31
39
  import { canonicalEndpoint } from "../lib/usage-endpoint.mjs";
32
40
  import { homedir, platform } from "os";
33
41
  import { dirname, join } from "path";
@@ -709,27 +717,78 @@ function buildEvent(state, { event = null, login = null } = {}) {
709
717
  };
710
718
  }
711
719
 
720
+ function hostOf(url) {
721
+ try {
722
+ return new URL(url).hostname;
723
+ } catch {
724
+ return null;
725
+ }
726
+ }
727
+
728
+ /**
729
+ * The outcome of one POST. A redirect is never followed, because the ingest
730
+ * token would ride along to wherever it points; its target host is reported
731
+ * instead, so a moved endpoint is visible rather than silent.
732
+ */
712
733
  async function post(endpoint, token, event) {
713
734
  const ctrl = new AbortController();
714
735
  const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
715
736
  try {
716
- await fetch(endpoint, {
737
+ const res = await fetch(endpoint, {
717
738
  method: "POST",
718
739
  headers: {
719
740
  "Content-Type": "application/json",
720
741
  "X-Usage-Token": token,
721
742
  },
722
743
  body: JSON.stringify(event),
723
- redirect: "error",
744
+ redirect: "manual",
724
745
  signal: ctrl.signal,
725
746
  });
726
- } catch {
727
- /* never break the run on a telemetry failure */
747
+ if (res.status >= 300 && res.status < 400) {
748
+ return {
749
+ outcome: "redirected",
750
+ status: res.status,
751
+ location: hostOf(res.headers.get("location") || ""),
752
+ };
753
+ }
754
+ if (!res.ok) return { outcome: "rejected", status: res.status };
755
+ let body = null;
756
+ try {
757
+ body = await res.json();
758
+ } catch {
759
+ /* a 2xx without a JSON body is still a delivery */
760
+ }
761
+ return { outcome: body?.accepted === false ? "not-accepted" : "accepted", status: res.status };
762
+ } catch (err) {
763
+ const reason = ctrl.signal.aborted ? "timeout" : (err?.cause?.code ?? err?.name ?? "error");
764
+ return { outcome: "failed", reason: codeOf(String(reason), 40) };
728
765
  } finally {
729
766
  clearTimeout(timer);
730
767
  }
731
768
  }
732
769
 
770
+ export const LAST_SEND_FILE = "usage-last-send.json";
771
+
772
+ /**
773
+ * Overwrites the record of the most recent send attempt. The emitter runs
774
+ * detached with its output discarded, so this file is the only place a broken
775
+ * reporting path shows up; doctor reads it. It carries no token and no path.
776
+ */
777
+ function recordSend(entry) {
778
+ const dir = join(homedir(), ".claude", "logs", "multi-agent");
779
+ const target = join(dir, LAST_SEND_FILE);
780
+ const tmp = `${target}.${process.pid}.tmp`;
781
+ try {
782
+ mkdirSync(dir, { recursive: true });
783
+ writeFileSync(tmp, JSON.stringify({ t: new Date().toISOString(), ...entry }) + "\n", {
784
+ mode: 0o600,
785
+ });
786
+ renameSync(tmp, target);
787
+ } catch {
788
+ /* logs dir unwritable - never break the run */
789
+ }
790
+ }
791
+
733
792
  // The write-only ingest token rides in a header; sending it over http would
734
793
  // leak it in cleartext, contradicting the setup contract ("transmitted only
735
794
  // over TLS"). Accept https anywhere, and http ONLY for loopback (local dev).
@@ -759,10 +818,16 @@ async function main() {
759
818
  if (!args.dryRun) {
760
819
  if (prefs.optOut === true) return;
761
820
  if (prefs.enabled !== true) return;
762
- if (!endpointAllowed(endpoint)) return;
821
+ if (!endpointAllowed(endpoint)) {
822
+ recordSend({ id: args.taskId, outcome: "skipped", reason: "endpoint-not-allowed" });
823
+ return;
824
+ }
763
825
  }
764
826
  const token = args.dryRun ? "" : resolveToken();
765
- if (!args.dryRun && !token) return;
827
+ if (!args.dryRun && !token) {
828
+ recordSend({ id: args.taskId, outcome: "skipped", reason: "no-token" });
829
+ return;
830
+ }
766
831
 
767
832
  const statePath = resolveStatePath(args);
768
833
  let state = statePath ? readJson(statePath) : null;
@@ -782,7 +847,10 @@ async function main() {
782
847
  const who = resolveReportingLogin();
783
848
  if (!who.login) {
784
849
  process.stderr.write(`usage-report: not sent - ${who.reason}\n`);
785
- if (!args.dryRun) return;
850
+ if (!args.dryRun) {
851
+ recordSend({ id: runId(state), ev: kind, outcome: "skipped", reason: "no-login" });
852
+ return;
853
+ }
786
854
  }
787
855
 
788
856
  const event = buildEvent(state, { event: kind, login: who.login });
@@ -793,7 +861,14 @@ async function main() {
793
861
  }
794
862
 
795
863
  if (event.st !== "in_progress") appendErrorLedger(event);
796
- await post(endpoint, token, event);
864
+ const result = await post(endpoint, token, event);
865
+ recordSend({ id: event.id, ev: event.ev, host: hostOf(endpoint), ...result });
866
+ if (result.outcome !== "accepted") {
867
+ const detail = result.status ?? result.reason;
868
+ const where = result.location ? ` to ${result.location}` : "";
869
+ const verb = result.outcome === "not-accepted" ? "not accepted" : result.outcome;
870
+ process.stderr.write(`usage-report: ${verb}${where} (${detail}) by ${hostOf(endpoint)}\n`);
871
+ }
797
872
  }
798
873
 
799
874
  // Entry-point check: compare file URLs, never string-split a path.