@mmerterden/multi-agent-pipeline 20.24.0 → 20.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/docs/facts.json +3 -3
  3. package/docs/features.md +9 -4
  4. package/install/catalog-history.json +1 -1
  5. package/install/templates/claude-hooks.json +1 -1
  6. package/install/templates/codex-hooks.json +18 -0
  7. package/manifest.json +71 -68
  8. package/package.json +1 -1
  9. package/pipeline/agents/task-clarifier.md +1 -1
  10. package/pipeline/commands/multi-agent/model/SKILL.md +2 -2
  11. package/pipeline/commands/multi-agent/postmortem/SKILL.md +18 -6
  12. package/pipeline/commands/multi-agent/review/SKILL.md +5 -5
  13. package/pipeline/commands/multi-agent/route-on/SKILL.md +1 -1
  14. package/pipeline/commands/multi-agent/setup/SKILL.md +7 -5
  15. package/pipeline/commands/multi-agent/testflight-validation/SKILL.md +2 -3
  16. package/pipeline/contract/fixtures/runs-failed.json +1 -1
  17. package/pipeline/contract/fixtures/runs-old-schema.json +1 -1
  18. package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +1 -1
  19. package/pipeline/contract/fixtures/runs-pr-opened.json +1 -1
  20. package/pipeline/contract/fixtures/runs-running.json +1 -1
  21. package/pipeline/lib/credential-inventory.sh +5 -5
  22. package/pipeline/lib/fetch-crashlytics.sh +6 -8
  23. package/pipeline/lib/model-rung.sh +1 -1
  24. package/pipeline/lib/plan-todos.sh +21 -4
  25. package/pipeline/lib/post-pr-review.sh +4 -6
  26. package/pipeline/lib/route-state.sh +1 -1
  27. package/pipeline/lib/shadow-git.sh +4 -5
  28. package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -5
  29. package/pipeline/multi-agent-refs/features/cost-analysis.md +4 -0
  30. package/pipeline/multi-agent-refs/features/model-fallback.md +17 -12
  31. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  32. package/pipeline/multi-agent-refs/phases/phase-3-review.md +5 -5
  33. package/pipeline/multi-agent-refs/picker-contract.md +1 -1
  34. package/pipeline/multi-agent-refs/tracker-contract.md +6 -6
  35. package/pipeline/schemas/plan-todos.schema.json +4 -4
  36. package/pipeline/schemas/prefs.schema.json +4 -4
  37. package/pipeline/scripts/_code-graph.mjs +2 -2
  38. package/pipeline/scripts/_transcript-hosts.mjs +406 -0
  39. package/pipeline/scripts/build-stack-plugins.mjs +3 -3
  40. package/pipeline/scripts/capture-flush.sh +7 -7
  41. package/pipeline/scripts/code-graph-rules/android.json +1 -1
  42. package/pipeline/scripts/cost-analyze.mjs +26 -19
  43. package/pipeline/scripts/cost-table.json +35 -19
  44. package/pipeline/scripts/edit-syntax-gate.mjs +151 -29
  45. package/pipeline/scripts/edit-syntax-gate.sh +9 -6
  46. package/pipeline/scripts/gc-abandoned.sh +2 -2
  47. package/pipeline/scripts/gen-mode-dispatch.mjs +3 -3
  48. package/pipeline/scripts/git-path.mjs +1 -2
  49. package/pipeline/scripts/host-hooks.mjs +265 -0
  50. package/pipeline/scripts/jira-attach.sh +1 -1
  51. package/pipeline/scripts/keychain.py +3 -3
  52. package/pipeline/scripts/output-quality-check.sh +1 -1
  53. package/pipeline/scripts/phase-tracker.sh +5 -6
  54. package/pipeline/scripts/phase0-exit-gate.mjs +3 -3
  55. package/pipeline/scripts/pre-push-check.sh +3 -6
  56. package/pipeline/scripts/transcript-index.mjs +423 -242
  57. package/pipeline/scripts/uninstall.mjs +90 -4
  58. package/pipeline/scripts/update-issue-progress.sh +4 -6
  59. package/pipeline/scripts/usage-report.mjs +2 -2
  60. package/pipeline/skills/.skill-manifest.json +8 -8
  61. package/pipeline/skills/shared/core/multi-agent/SKILL.md +6 -6
  62. package/pipeline/skills/shared/core/multi-agent-model/SKILL.md +2 -2
  63. package/pipeline/skills/shared/core/multi-agent-postmortem/SKILL.md +10 -7
  64. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +5 -5
  65. package/pipeline/skills/shared/core/multi-agent-route-on/SKILL.md +1 -1
  66. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +7 -5
  67. package/pipeline/skills/shared/core/multi-agent-testflight-validation/SKILL.md +2 -3
  68. package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
@@ -0,0 +1,406 @@
1
+ /**
2
+ * @file _transcript-hosts.mjs - Copilot CLI and Codex session logs, read as
3
+ * the Claude Code records transcript-index.mjs already understands.
4
+ *
5
+ * Each host writes a different JSONL:
6
+ *
7
+ * claude ~/.claude/projects/<cwd slug>/<session>.jsonl: `type` user /
8
+ * assistant / system, the message under `message`
9
+ * copilot ~/.copilot/session-state/<session>/events.jsonl: dotted event
10
+ * types (`session.start`, `assistant.message`,
11
+ * `tool.execution_complete`, ...), the body under `data`
12
+ * codex ~/.codex/sessions/YYYY/MM/DD/rollout-<time>-<session>.jsonl:
13
+ * `type` session_meta / response_item / event_msg / compacted /
14
+ * turn_context, the body under `payload`
15
+ *
16
+ * detectFormat() names the format from one record. createAdapter() returns a
17
+ * function that turns each record of a file into zero or more records in the
18
+ * Claude Code shape, so the indexer has one reading loop and every finding
19
+ * still cites the line of the file it came from. Two record kinds exist only
20
+ * here and are handled by the indexer directly:
21
+ *
22
+ * x-usage token use the host reports outside a message (Codex
23
+ * token_count, Copilot CLI's shutdown totals)
24
+ * a user record with `subagentStats`, carrying a background subagent's
25
+ * tokens and duration with its completion notice
26
+ *
27
+ * A tool result may carry `denied: true` when the host marks a refusal with a
28
+ * code rather than a message.
29
+ *
30
+ * @module pipeline/scripts/_transcript-hosts
31
+ */
32
+
33
+ const CODEX_TYPES = new Set([
34
+ "session_meta",
35
+ "response_item",
36
+ "event_msg",
37
+ "compacted",
38
+ "turn_context",
39
+ ]);
40
+
41
+ /** "claude", "copilot" or "codex" for one parsed record. */
42
+ export function detectFormat(rec) {
43
+ if (!rec || typeof rec !== "object") return "claude";
44
+ if (
45
+ typeof rec.type === "string" &&
46
+ rec.type.includes(".") &&
47
+ rec.data &&
48
+ typeof rec.data === "object"
49
+ )
50
+ return "copilot";
51
+ if (CODEX_TYPES.has(rec.type) && rec.payload && typeof rec.payload === "object") return "codex";
52
+ return "claude";
53
+ }
54
+
55
+ const asObject = (raw) => {
56
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) return raw;
57
+ if (typeof raw !== "string") return {};
58
+ try {
59
+ const v = JSON.parse(raw);
60
+ return v && typeof v === "object" && !Array.isArray(v) ? v : { arguments: raw };
61
+ } catch {
62
+ return { arguments: raw };
63
+ }
64
+ };
65
+
66
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
67
+
68
+ /** A prompt as the indexer reads it: a leading slash command becomes a command record. */
69
+ function promptRecord(timestamp, text) {
70
+ const t = String(text ?? "");
71
+ const slash = t.trim().match(/^(\/[^\s<]+)/);
72
+ const content = slash ? `<command-name>${slash[1]}</command-name>` : t;
73
+ return { type: "user", timestamp, message: { role: "user", content } };
74
+ }
75
+
76
+ const interrupt = (timestamp, reason) => ({
77
+ type: "user",
78
+ timestamp,
79
+ message: {
80
+ role: "user",
81
+ content: `[Request interrupted by user]${reason ? ` (${reason})` : ""}`,
82
+ },
83
+ });
84
+
85
+ const toolResult = (timestamp, id, body, isError, extra = {}) => ({
86
+ type: "user",
87
+ timestamp,
88
+ message: {
89
+ role: "user",
90
+ content: [
91
+ {
92
+ type: "tool_result",
93
+ tool_use_id: id,
94
+ content: body,
95
+ is_error: isError,
96
+ ...(extra.denied ? { denied: true } : {}),
97
+ },
98
+ ],
99
+ },
100
+ ...(extra.toolUseResult !== undefined ? { toolUseResult: extra.toolUseResult } : {}),
101
+ });
102
+
103
+ const notice = (timestamp, id, status, stats) => ({
104
+ type: "user",
105
+ timestamp,
106
+ message: {
107
+ role: "user",
108
+ content: `<task-notification><task-id>${id}</task-id><status>${status}</status></task-notification>`,
109
+ },
110
+ subagentStats: stats,
111
+ });
112
+
113
+ // --- Copilot CLI -----------------------------------------------------------------
114
+
115
+ const COPILOT_SUBAGENT_TOOL = "task";
116
+ const COPILOT_DENIAL_CODES = new Set(["denied", "rejected"]);
117
+
118
+ function copilotAdapter() {
119
+ const toolNames = new Map();
120
+ const finished = new Map();
121
+ const counted = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
122
+ let shutdown = false;
123
+
124
+ const adapt = (rec) => {
125
+ const d = rec.data || {};
126
+ const ts = rec.timestamp;
127
+ switch (rec.type) {
128
+ case "user.message":
129
+ return [promptRecord(ts, d.content)];
130
+ case "assistant.message": {
131
+ const content = [];
132
+ if (typeof d.content === "string" && d.content.trim())
133
+ content.push({ type: "text", text: d.content });
134
+ for (const r of Array.isArray(d.toolRequests) ? d.toolRequests : []) {
135
+ if (!r || typeof r.name !== "string") continue;
136
+ if (r.toolCallId) toolNames.set(r.toolCallId, r.name);
137
+ content.push({
138
+ type: "tool_use",
139
+ id: r.toolCallId,
140
+ name: r.name,
141
+ input: asObject(r.arguments),
142
+ });
143
+ }
144
+ const out = num(d.outputTokens);
145
+ counted.output += out;
146
+ return [
147
+ {
148
+ type: "assistant",
149
+ timestamp: ts,
150
+ isSidechain: Boolean(d.parentToolCallId),
151
+ message: {
152
+ id: d.messageId,
153
+ role: "assistant",
154
+ content,
155
+ ...(d.outputTokens !== undefined ? { usage: { output_tokens: out } } : {}),
156
+ },
157
+ },
158
+ ];
159
+ }
160
+ case "tool.execution_complete": {
161
+ const ok = d.success !== false;
162
+ const body = ok
163
+ ? String(d.result?.content ?? "")
164
+ : String(d.error?.message ?? d.result?.content ?? "failed");
165
+ const denied = !ok && COPILOT_DENIAL_CODES.has(d.error?.code);
166
+ let toolUseResult;
167
+ if (toolNames.get(d.toolCallId) === COPILOT_SUBAGENT_TOOL) {
168
+ const stats = finished.get(d.toolCallId);
169
+ toolUseResult = stats
170
+ ? {
171
+ status: stats.status,
172
+ agentId: d.toolCallId,
173
+ totalTokens: stats.tokens,
174
+ totalDurationMs: stats.durationMs,
175
+ totalToolUseCount: stats.toolUses,
176
+ }
177
+ : { status: ok ? "async_launched" : "error", agentId: d.toolCallId };
178
+ }
179
+ return [toolResult(ts, d.toolCallId, body, !ok, { denied, toolUseResult })];
180
+ }
181
+ case "subagent.completed":
182
+ case "subagent.failed": {
183
+ const stats = {
184
+ status: rec.type === "subagent.failed" ? "failed" : "completed",
185
+ tokens: Number.isFinite(d.totalTokens) ? d.totalTokens : undefined,
186
+ durationMs: Number.isFinite(d.durationMs) ? d.durationMs : undefined,
187
+ toolUses: Number.isFinite(d.totalToolCalls) ? d.totalToolCalls : undefined,
188
+ };
189
+ finished.set(d.toolCallId, stats);
190
+ return [notice(ts, d.toolCallId, stats.status, stats)];
191
+ }
192
+ case "session.compaction_complete":
193
+ if (d.success === false)
194
+ return [{ type: "system", timestamp: ts, level: "error", content: "compaction failed" }];
195
+ return [
196
+ {
197
+ type: "system",
198
+ subtype: "compact_boundary",
199
+ timestamp: ts,
200
+ compactMetadata: { preTokens: d.preCompactionTokens },
201
+ },
202
+ ];
203
+ case "hook.end":
204
+ if (d.success !== false) return [];
205
+ return [
206
+ {
207
+ type: "system",
208
+ timestamp: ts,
209
+ hookErrors: [`${d.hookType || "hook"}: ${d.error?.message || "failed"}`],
210
+ },
211
+ ];
212
+ case "session.error":
213
+ return [
214
+ {
215
+ type: "system",
216
+ timestamp: ts,
217
+ level: "error",
218
+ content: `${d.errorType ? `${d.errorType}: ` : ""}${d.message || ""}`,
219
+ },
220
+ ];
221
+ case "abort":
222
+ return [interrupt(ts, d.reason)];
223
+ case "session.shutdown": {
224
+ shutdown = true;
225
+ const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
226
+ for (const m of Object.values(d.modelMetrics || {})) {
227
+ const u = m?.usage || {};
228
+ total.input += num(u.inputTokens);
229
+ total.output += num(u.outputTokens);
230
+ total.cacheRead += num(u.cacheReadTokens);
231
+ total.cacheWrite += num(u.cacheWriteTokens);
232
+ }
233
+ const add = {};
234
+ for (const k of Object.keys(total)) {
235
+ add[k] = Math.max(0, total[k] - counted[k]);
236
+ counted[k] += add[k];
237
+ }
238
+ return [
239
+ { type: "x-usage", timestamp: ts, usage: add, countsMessage: false, inTurn: false },
240
+ ];
241
+ }
242
+ default:
243
+ return [];
244
+ }
245
+ };
246
+ // Input and cache tokens arrive only in session.shutdown; without it the
247
+ // session's usage is its output tokens alone.
248
+ adapt.usagePartial = () => !shutdown;
249
+ return adapt;
250
+ }
251
+
252
+ // --- Codex -------------------------------------------------------------------------
253
+
254
+ const EXIT_CODE = /(?:^|\n)(?:Process exited with code|Exit code:?)\s*(-?\d+)/;
255
+ const WALL_TIME = /(?:^|\n)Wall time:\s*([\d.]+)\s*seconds?/;
256
+
257
+ function codexOutput(raw) {
258
+ if (typeof raw === "string") {
259
+ try {
260
+ const v = JSON.parse(raw);
261
+ if (v && typeof v === "object" && !Array.isArray(v) && ("output" in v || "metadata" in v)) {
262
+ const meta = v.metadata || {};
263
+ return {
264
+ text: String(v.output ?? ""),
265
+ exitCode: Number.isFinite(meta.exit_code) ? meta.exit_code : null,
266
+ durationMs: Number.isFinite(meta.duration_seconds)
267
+ ? Math.round(meta.duration_seconds * 1000)
268
+ : null,
269
+ };
270
+ }
271
+ } catch {
272
+ // Plain text output.
273
+ }
274
+ const code = raw.match(EXIT_CODE);
275
+ const wall = raw.match(WALL_TIME);
276
+ return {
277
+ text: raw,
278
+ exitCode: code ? Number(code[1]) : null,
279
+ durationMs: wall ? Math.round(Number(wall[1]) * 1000) : null,
280
+ };
281
+ }
282
+ if (Array.isArray(raw)) {
283
+ const text = raw
284
+ .map((b) => (typeof b === "string" ? b : typeof b?.text === "string" ? b.text : ""))
285
+ .filter(Boolean)
286
+ .join("\n");
287
+ return codexOutput(text);
288
+ }
289
+ if (raw && typeof raw === "object") return codexOutput(raw.content ?? JSON.stringify(raw));
290
+ return { text: "", exitCode: null, durationMs: null };
291
+ }
292
+
293
+ function codexInput(name, raw) {
294
+ const input = asObject(raw);
295
+ for (const k of ["command", "cmd"]) {
296
+ if (Array.isArray(input[k])) return { ...input, [k]: input[k].join(" ") };
297
+ }
298
+ return input;
299
+ }
300
+
301
+ function codexAdapter() {
302
+ let lastTotal = null;
303
+
304
+ const toolUse = (ts, id, name, input) => ({
305
+ type: "assistant",
306
+ timestamp: ts,
307
+ message: { role: "assistant", content: [{ type: "tool_use", id, name, input }] },
308
+ });
309
+
310
+ return (rec) => {
311
+ const p = rec.payload || {};
312
+ const ts = rec.timestamp;
313
+ if (rec.type === "compacted")
314
+ return [{ type: "system", subtype: "compact_boundary", timestamp: ts, compactMetadata: {} }];
315
+ if (rec.type === "event_msg") {
316
+ switch (p.type) {
317
+ case "user_message":
318
+ return [promptRecord(ts, p.message)];
319
+ case "token_count": {
320
+ const info = p.info;
321
+ const total = info?.total_token_usage?.total_tokens;
322
+ if (!info || total === lastTotal) return [];
323
+ lastTotal = total;
324
+ const u = info.last_token_usage || {};
325
+ const cached = num(u.cached_input_tokens);
326
+ return [
327
+ {
328
+ type: "x-usage",
329
+ timestamp: ts,
330
+ usage: {
331
+ input: Math.max(0, num(u.input_tokens) - cached),
332
+ output: num(u.output_tokens),
333
+ cacheRead: cached,
334
+ cacheWrite: num(u.cache_write_input_tokens),
335
+ },
336
+ countsMessage: true,
337
+ inTurn: true,
338
+ },
339
+ ];
340
+ }
341
+ case "error":
342
+ return [{ type: "system", timestamp: ts, level: "error", content: p.message || "" }];
343
+ case "turn_aborted":
344
+ return [interrupt(ts, p.reason)];
345
+ default:
346
+ return [];
347
+ }
348
+ }
349
+ if (rec.type !== "response_item") return [];
350
+ switch (p.type) {
351
+ case "message":
352
+ if (p.role !== "assistant") return [];
353
+ return [
354
+ {
355
+ type: "assistant",
356
+ timestamp: ts,
357
+ message: {
358
+ role: "assistant",
359
+ content: (Array.isArray(p.content) ? p.content : [])
360
+ .filter((b) => typeof b?.text === "string")
361
+ .map((b) => ({ type: "text", text: b.text })),
362
+ },
363
+ },
364
+ ];
365
+ case "function_call":
366
+ return [toolUse(ts, p.call_id, p.name, codexInput(p.name, p.arguments))];
367
+ case "custom_tool_call":
368
+ return [toolUse(ts, p.call_id, p.name, { input: String(p.input ?? "") })];
369
+ case "local_shell_call":
370
+ return [toolUse(ts, p.call_id, "local_shell", codexInput("local_shell", p.action || {}))];
371
+ case "web_search_call":
372
+ return [toolUse(ts, undefined, "web_search", { query: p.action?.query ?? "" })];
373
+ case "function_call_output":
374
+ case "custom_tool_call_output": {
375
+ const out = codexOutput(p.output);
376
+ const failed = (out.exitCode !== null && out.exitCode !== 0) || p.output?.success === false;
377
+ return [
378
+ toolResult(ts, p.call_id, out.text, failed, {
379
+ toolUseResult: out.durationMs !== null ? { durationMs: out.durationMs } : undefined,
380
+ }),
381
+ ];
382
+ }
383
+ default:
384
+ return [];
385
+ }
386
+ };
387
+ }
388
+
389
+ /**
390
+ * The record converter for `format`: a function from one parsed record to the
391
+ * Claude Code records it stands for (often none). A Copilot CLI converter also
392
+ * has `usagePartial()`, true while the session has written no shutdown record.
393
+ */
394
+ export function createAdapter(format) {
395
+ if (format === "copilot") return copilotAdapter();
396
+ if (format === "codex") return codexAdapter();
397
+ return (rec) => [rec];
398
+ }
399
+
400
+ /** The working directory a Copilot CLI or Codex session started in, from its opening record. */
401
+ export function sessionCwd(rec) {
402
+ const format = detectFormat(rec);
403
+ if (format === "copilot" && rec.type === "session.start") return rec.data?.context?.cwd ?? null;
404
+ if (format === "codex" && rec.type === "session_meta") return rec.payload?.cwd ?? null;
405
+ return null;
406
+ }
@@ -177,9 +177,9 @@ for (const skill of allSkills) {
177
177
  // --- 2b. routing audit ---------------------------------------------------
178
178
  // The loop above adds a skill to EVERY plugin whose pattern matches, so a bare
179
179
  // substring alternative that also reads as a suffix of another stack's skill
180
- // name ships that skill into two plugins. `architecture` did that to
181
- // android-architecture and swift-architecture, which is how ai-backend-toolkit
182
- // came to carry Compose and SwiftUI guidance.
180
+ // name ships that skill into two plugins. A bare `architecture` matches
181
+ // android-architecture and swift-architecture, and would put Compose and
182
+ // SwiftUI guidance into ai-backend-toolkit.
183
183
  //
184
184
  // --check-routing runs this audit alone. It reads nothing but the authoring
185
185
  // source in this repo, so CI can gate it without a checkout of the plugins repo.
@@ -5,14 +5,14 @@
5
5
  #
6
6
  # WHY THIS EXISTS
7
7
  #
8
- # Every persistent write used to live in Phase 5: triage ingest, the learnings
9
- # ledger distill, the knowledge-base append, the code-graph refresh. And Phase 5
10
- # is, by the pipeline's own admission in features/code-graph.md, the phase a run
11
- # is LEAST likely to reach. A run killed in Phase 3, a session that hits its
12
- # context ceiling, a crash after review - each one threw away everything it had
13
- # established, and the next run on the same repo rediscovered it from scratch.
8
+ # The persistent writes are triage ingest, the learnings ledger distill, the
9
+ # knowledge-base append and the code-graph refresh. Phase 5 is, per
10
+ # features/code-graph.md, the phase a run is LEAST likely to reach. A run killed
11
+ # in Phase 3, a session that hits its context ceiling, a crash after review -
12
+ # if the writes waited for Phase 5, each would throw away everything it had
13
+ # established, and the next run on the same repo would rediscover it.
14
14
  #
15
- # So the writes move here, and Phase 5 becomes the LAST flush rather than the
15
+ # So the writes happen here, and Phase 5 is the LAST flush rather than the
16
16
  # only one. Phase boundaries call this, and so does SessionEnd. Nothing about
17
17
  # the trigger depends on a model noticing that a moment qualifies: it hangs on
18
18
  # a phase transition and on process exit, both objectively visible without any
@@ -110,5 +110,5 @@
110
110
  "Unit",
111
111
  "Any"
112
112
  ],
113
- "note": "Kotlin declaration modifiers are not separate patterns: `data class Foo`, `sealed class Foo` and `annotation class Foo` all still contain `class Foo`, so one pattern covers them. `enum` allows an optional `class` between keyword and name, which is the only shape that differs between Java (`enum Foo`) and Kotlin (`enum class Foo`). `object` matches the named form only - `companion object {` and the anonymous `object : Iface {` carry no name and correctly produce nothing. Imports are fully qualified on the JVM, so the pattern captures the final segment: `import com.example.ui.Button` resolves against the file named Button.kt, which is how the engine links a file-level import edge. A star import (`import com.example.ui.*`) captures nothing, deliberately - it names a package, and pointing every star import at an arbitrary file in that package would invent an edge nobody wrote. Pattern ORDER is load-bearing: `enum class Cabin` matches both the enum pattern and the class pattern, and the first match to register the name decides its kind, so the more specific patterns are listed first. With class first, every Kotlin enum was filed as a class and the report's symbol-kind census was wrong."
113
+ "note": "Kotlin declaration modifiers are not separate patterns: `data class Foo`, `sealed class Foo` and `annotation class Foo` all still contain `class Foo`, so one pattern covers them. `enum` allows an optional `class` between keyword and name, which is the only shape that differs between Java (`enum Foo`) and Kotlin (`enum class Foo`). `object` matches the named form only - `companion object {` and the anonymous `object : Iface {` carry no name and correctly produce nothing. Imports are fully qualified on the JVM, so the pattern captures the final segment: `import com.example.ui.Button` resolves against the file named Button.kt, which is how the engine links a file-level import edge. A star import (`import com.example.ui.*`) captures nothing, deliberately - it names a package, and pointing every star import at an arbitrary file in that package would invent an edge nobody wrote. Pattern ORDER is load-bearing: `enum class Cabin` matches both the enum pattern and the class pattern, and the first match to register the name decides its kind, so the more specific patterns are listed first. With class first, every Kotlin enum would be filed as a class and the report's symbol-kind census would be wrong."
114
114
  }
@@ -168,30 +168,37 @@ const asNumber = (v, fallback) => {
168
168
  const TABLE = JSON.parse(readFileSync(join(HERE, "cost-table.json"), "utf-8"));
169
169
 
170
170
  /**
171
- * modelId -> rung. The transcripts carry the wire value ("claude-opus-5"); the
172
- * price table is keyed by rung. Matching on modelId rather than on the rung
173
- * name is what keeps a generation move a one-line data edit, which is the
174
- * contract cost-table.json states for itself.
171
+ * Wire ID -> rate. Transcripts carry the wire value ("claude-opus-5-5"); the
172
+ * price table is keyed by rung, so each rung's `modelId` and `copilotModelId`
173
+ * map to its rate, `legacy` adds previous-generation IDs at their own rates,
174
+ * and `models` overrides the rung rate for a dispatched ID whose model is not
175
+ * the rung's (Copilot CLI's older models bill at their own rate). Matching on
176
+ * the ID rather than the rung name keeps a generation move a data edit, which
177
+ * is the contract cost-table.json states for itself.
175
178
  */
176
- const RUNG_BY_MODEL_ID = new Map(
177
- Object.entries(TABLE.prices || {}).map(([rung, p]) => [p.modelId, rung]),
178
- );
179
+ const RATE_BY_MODEL_ID = new Map([
180
+ ...Object.entries(TABLE.legacy || {}),
181
+ ...Object.values(TABLE.prices || {}).flatMap((p) =>
182
+ [p.modelId, p.copilotModelId].filter(Boolean).map((id) => [id, p]),
183
+ ),
184
+ ...Object.entries(TABLE.models || {}),
185
+ ]);
186
+ const IDS_LONGEST_FIRST = [...RATE_BY_MODEL_ID.keys()].sort((a, b) => b.length - a.length);
179
187
 
180
188
  function rateFor(modelId) {
181
189
  if (!modelId) return null;
182
- const rung = RUNG_BY_MODEL_ID.get(modelId);
183
- if (rung) return TABLE.prices[rung];
184
- // A dated snapshot id (an alias plus a release date suffix) prices as its
185
- // alias - written as a shape rather than as a literal, because
190
+ const exact = RATE_BY_MODEL_ID.get(modelId);
191
+ if (exact) return exact;
192
+ // A dated snapshot id (a known id, a hyphen, a release date) prices as that
193
+ // id; the longest known id wins, so a current id is never read as a shorter
194
+ // legacy one. Written as a shape rather than as a literal, because
186
195
  // smoke-model-fallback.sh reads every model id in this tree and requires it
187
- // to exist in cost-table.json, and an EXAMPLE id is exactly the kind of thing
188
- // that would make that gate wrong about what ships. An
189
- // id nobody knows prices as nothing and is COUNTED as unpriced rather than
190
- // folded in at zero.
191
- for (const [id, rung] of RUNG_BY_MODEL_ID) {
192
- if (modelId.startsWith(id)) return TABLE.prices[rung];
193
- }
194
- return null;
196
+ // to exist in cost-table.json. An id nobody knows prices as nothing and is
197
+ // COUNTED as unpriced rather than folded in at zero.
198
+ const id = IDS_LONGEST_FIRST.find(
199
+ (known) => modelId.startsWith(`${known}-`) || modelId.startsWith(`${known}[`),
200
+ );
201
+ return id ? RATE_BY_MODEL_ID.get(id) : null;
195
202
  }
196
203
 
197
204
  /**
@@ -1,38 +1,54 @@
1
1
  {
2
- "_readme": "Per-model unit prices in USD per million tokens. Source: Anthropic public pricing (Claude-family rows verified 2026-07-28 against the current model table; the gpt-* rows remain approximate). Update when Anthropic publishes new tiers. Rung names (fable / opus / sonnet / haiku) are the pipeline's stable identifiers and `modelId` is the wire value they currently resolve to - dispatch reads the rung, so a generation move is a `modelId` edit here plus the phase specs, never a rename of the rungs. `provider` says which layer a rung belongs to: an `anthropic` rung is reachable from every call site; `free-chain` (the `free` rung) only from bulk-read and delegate, where the multi-agent-toolkit's verified free-model chain answers and the caller's default takes over when it escalates; any other provider from no call site. model-dispatch.sh enforces this. Unknown models render USD as ' - ' and emit a footnote - never block PR-body generation. cacheReadPerMtok is the discounted rate for prompt-cache hits (~10% of inPerMtok); the renderer prices a phase's tokens_cached at this rate when the tracker records it, so resume/cache reuse is visible in the ledger.",
2
+ "_readme": "Per-model unit prices in USD per million tokens. Source: Anthropic public pricing (Claude-family rows verified 2026-10-14 against the current model table; the gpt-* rows remain approximate). Update when Anthropic publishes new tiers. Rung names (fable / opus / sonnet / haiku) are the pipeline's stable identifiers and `modelId` is the wire value they currently resolve to - dispatch reads the rung, so a generation move is a `modelId` edit here plus the phase specs, never a rename of the rungs. `copilotModelId`, where present, is the name Copilot CLI dispatches for the rung: one of the dotted IDs `copilot help config` lists (Copilot rejects any other as not available), the newest of the rung's family there. A rung without one (fable) is not offered on Copilot CLI, where its personas take the opus rung. smoke-model-fallback.sh checks the IDs against `copilot help config` when the CLI is installed. `provider` says which layer a rung belongs to: an `anthropic` rung is reachable from every call site; `free-chain` (the `free` rung) only from bulk-read and delegate, where the multi-agent-toolkit's verified free-model chain answers and the caller's default takes over when it escalates; any other provider from no call site. model-dispatch.sh enforces this. Unknown models render USD as ' - ' and emit a footnote - never block PR-body generation. cacheReadPerMtok is the discounted rate for prompt-cache hits, published per model and well below inPerMtok; the renderer prices a phase's tokens_cached at this rate when the tracker records it, so resume/cache reuse is visible in the ledger.",
3
3
  "schemaVersion": "1.1.0",
4
+ "_legacyReadme": "`legacy` prices previous-generation wire IDs a transcript may still name, keyed by the exact ID (a dated snapshot of one prices as it). Dispatch never reads it; cost-analyze consults it after the current `modelId` values and the `models` map. Official Anthropic prices per million tokens.",
5
+ "legacy": {
6
+ "claude-fable-5": { "inPerMtok": 10.0, "outPerMtok": 50.0, "cacheReadPerMtok": 1.0 },
7
+ "claude-opus-5": { "inPerMtok": 5.0, "outPerMtok": 25.0, "cacheReadPerMtok": 0.5 },
8
+ "claude-sonnet-5": { "inPerMtok": 2.0, "outPerMtok": 10.0, "cacheReadPerMtok": 0.2 },
9
+ "claude-haiku-4-5": { "inPerMtok": 1.0, "outPerMtok": 5.0, "cacheReadPerMtok": 0.1 }
10
+ },
11
+ "_modelsReadme": "`models` prices wire IDs a rung dispatches whose model is not the rung's `modelId`: Copilot CLI's `copilotModelId` values name an older model than the rung's, billed at that model's own rate. Keyed by the exact ID; cost-analyze consults it before the rung rates, so a Copilot CLI session is priced at its own model. Official Anthropic prices per million tokens, verified 2026-10-14.",
12
+ "models": {
13
+ "claude-opus-4.7": { "inPerMtok": 5.0, "outPerMtok": 25.0, "cacheReadPerMtok": 0.5 },
14
+ "claude-sonnet-4.6": { "inPerMtok": 3.0, "outPerMtok": 15.0, "cacheReadPerMtok": 0.3 },
15
+ "claude-haiku-4.5": { "inPerMtok": 1.0, "outPerMtok": 5.0, "cacheReadPerMtok": 0.1 }
16
+ },
4
17
  "prices": {
5
18
  "fable": {
6
19
  "inPerMtok": 10.0,
7
20
  "outPerMtok": 50.0,
8
- "cacheReadPerMtok": 1.0,
9
- "modelId": "claude-fable-5",
21
+ "cacheReadPerMtok": 0.25,
22
+ "modelId": "claude-fable-5-1",
10
23
  "provider": "anthropic",
11
- "note": "Top tier (restored v10.6.0) - architects, Reviewer 1, triage. Verified against Anthropic pricing 2026-07-02."
24
+ "note": "Top tier - architects, Reviewer 1 and triage on Claude Code. Claude Fable 5.1, verified against Anthropic pricing 2026-10-14."
12
25
  },
13
26
  "opus": {
14
- "inPerMtok": 5.0,
15
- "outPerMtok": 25.0,
16
- "cacheReadPerMtok": 0.5,
17
- "modelId": "claude-opus-5",
27
+ "inPerMtok": 4.0,
28
+ "outPerMtok": 20.0,
29
+ "cacheReadPerMtok": 0.2,
30
+ "modelId": "claude-opus-5-5",
31
+ "copilotModelId": "claude-opus-4.7",
18
32
  "provider": "anthropic",
19
- "note": "Second tier - Reviewer 1 and triage on Copilot CLI, and the opus rung of the fable -> opus -> sonnet fallback ladder. Same rate as the Opus 4.8 it replaces, so the ledger needed no reprice on the generation move. Claude Opus 5 draws on a rate-limit pool SEPARATE from the combined Opus 4.x pool - moving traffic here neither frees headroom on the old bucket nor inherits it."
33
+ "note": "Second tier - Reviewer 2 on Claude Code, Reviewer 1 and triage on Copilot CLI, and the opus rung of the fable -> opus -> sonnet fallback ladder. Claude Opus 5.5, verified against Anthropic pricing 2026-10-14."
20
34
  },
21
35
  "sonnet": {
22
- "inPerMtok": 3.0,
23
- "outPerMtok": 15.0,
24
- "cacheReadPerMtok": 0.3,
25
- "modelId": "claude-sonnet-5",
36
+ "inPerMtok": 2.0,
37
+ "outPerMtok": 10.0,
38
+ "cacheReadPerMtok": 0.1,
39
+ "modelId": "claude-sonnet-5-5",
40
+ "copilotModelId": "claude-sonnet-4.6",
26
41
  "provider": "anthropic",
27
- "note": "Floor tier for Claude-family dispatch - Reviewer 3 on both hosts, and the terminal rung of the fallback ladder. Priced at the standard 3/15 rather than the 2/10 introductory rate that runs through 2026-08-31: over-reporting during the intro window is the safe direction for a cost ledger, and it needs no dated edit when the intro ends."
42
+ "note": "Floor tier for Claude-family dispatch - Reviewer 3 on both hosts, the dev phase, and the terminal rung of the fallback ladder. Claude Sonnet 5.5, verified against Anthropic pricing 2026-10-14."
28
43
  },
29
44
  "haiku": {
30
- "inPerMtok": 1.0,
31
- "outPerMtok": 5.0,
32
- "cacheReadPerMtok": 0.1,
33
- "modelId": "claude-haiku-4-5",
45
+ "inPerMtok": 0.5,
46
+ "outPerMtok": 2.5,
47
+ "cacheReadPerMtok": 0.05,
48
+ "modelId": "claude-haiku-5-5",
49
+ "copilotModelId": "claude-haiku-4.5",
34
50
  "provider": "anthropic",
35
- "note": "Speed tier - task-clarifier and other latency-sensitive dispatches, plus the terminal rung of the fallback ladder. Named by its alias like every other rung here rather than by a dated snapshot, so the four rungs stay comparable at a glance."
51
+ "note": "Speed tier - task-clarifier and other latency-sensitive dispatches, plus the terminal rung of the fallback ladder. Claude Haiku 5.5 is priced in two tiers by prompt length (in 0.10, cache-read 0.01, out 0.50 up to 100k input tokens; in 0.50, cache-read 0.05, out 2.50 above); this table holds one rate per rung, so it carries the over-100k tier: over-reporting is the safe direction for a cost ledger. Verified against Anthropic pricing 2026-10-14."
36
52
  },
37
53
  "gpt-5.4": {
38
54
  "inPerMtok": 10.0,