@event4u/agent-config 9.31.0 → 9.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +146 -0
  3. package/dist/agent-src/commands/review/routing.md +3 -4
  4. package/dist/agent-src/contexts/communication/rules-auto/reply-close-mechanics.md +65 -0
  5. package/dist/agent-src/contexts/execution/host-capability-manifest.md +32 -1
  6. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +3 -0
  7. package/dist/agent-src/rules/delegation-policy.md +17 -1
  8. package/dist/agent-src/scripts/roadmap_gates.ts +107 -3
  9. package/dist/agent-src/skills/docker/SKILL.md +11 -0
  10. package/dist/agent-src/skills/laravel/SKILL.md +11 -0
  11. package/dist/agent-src/skills/review-routing/SKILL.md +23 -6
  12. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  13. package/dist/agent-src/templates/scripts/ownership-map.example.yml +1 -1
  14. package/dist/agent-src/templates/scripts/work_engine/delivery_state.ts +6 -0
  15. package/dist/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +332 -0
  16. package/dist/agent-src/templates/scripts/work_engine/directives/backend/refine.ts +106 -1
  17. package/dist/agent-src/templates/scripts/work_engine/directives/backend/test.ts +50 -15
  18. package/dist/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +49 -15
  19. package/dist/agent-src/templates/scripts/work_engine/state.ts +74 -0
  20. package/dist/agent-src/templates/scripts/work_engine/state_io.ts +6 -0
  21. package/dist/cli/registry.js +4 -4
  22. package/dist/cli/registry.js.map +1 -1
  23. package/dist/cli-delegate/chunk-2WELHO3P.js +582 -0
  24. package/dist/cli-delegate/chunk-HTIU3W56.js +66 -0
  25. package/dist/cli-delegate/{chunk-ITIQSGOQ.js → chunk-MA4GFWDY.js} +7 -4
  26. package/dist/cli-delegate/{chunk-2WCLVDWC.js → chunk-PMQ6Z3T3.js} +1642 -165
  27. package/dist/cli-delegate/chunk-PZWRPRWP.js +38 -0
  28. package/dist/cli-delegate/{chunk-PFBV6XRT.js → chunk-Q26HT4QT.js} +1 -63
  29. package/dist/cli-delegate/{chunk-LVRYBKTI.js → chunk-YPAWXXXR.js} +158 -0
  30. package/dist/cli-delegate/cmd_conformance.js +308 -227
  31. package/dist/cli-delegate/cmd_doctor.js +4 -2
  32. package/dist/cli-delegate/cmd_export.js +1 -1
  33. package/dist/cli-delegate/cmd_handoff.js +160 -58
  34. package/dist/cli-delegate/cmd_prune.js +1 -1
  35. package/dist/cli-delegate/cmd_session_recycle.js +30 -5
  36. package/dist/cli-delegate/cmd_settings_get.js +4 -2
  37. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  38. package/dist/cli-delegate/cmd_settings_set.js +5 -3
  39. package/dist/cli-delegate/cmd_sync.js +1 -1
  40. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  41. package/dist/cli-delegate/cmd_validate.js +1 -1
  42. package/dist/discovery/deprecation-report.md +1 -1
  43. package/dist/discovery/discovery-manifest.json +8 -8
  44. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  45. package/dist/discovery/discovery-manifest.summary.md +1 -1
  46. package/dist/discovery/orphan-report.md +1 -1
  47. package/dist/discovery/packs.json +2 -2
  48. package/dist/discovery/trust-report.md +1 -1
  49. package/dist/discovery/workspaces.json +2 -2
  50. package/dist/hooks/dispatch.js +1136 -690
  51. package/dist/install/install.mjs +7 -4
  52. package/dist/mcp/registry-manifest.json +2 -2
  53. package/dist/mcp/server.json +2 -2
  54. package/dist/mcp/server.mjs +3688 -1159
  55. package/package.json +1 -1
  56. package/src/agent-src/scripts/roadmap_gates.ts +107 -3
  57. package/src/agent-src/templates/scripts/ownership-map.example.yml +1 -1
  58. package/src/agent-src/templates/scripts/work_engine/delivery_state.ts +6 -0
  59. package/src/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +332 -0
  60. package/src/agent-src/templates/scripts/work_engine/directives/backend/refine.ts +106 -1
  61. package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +50 -15
  62. package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +49 -15
  63. package/src/agent-src/templates/scripts/work_engine/state.ts +74 -0
  64. package/src/agent-src/templates/scripts/work_engine/state_io.ts +6 -0
  65. package/src/config/cost-parity-budget.json +167 -0
  66. package/src/config/gate-coverage.yml +44 -5
  67. package/src/config/quorum-attendance-budget.json +39 -0
  68. package/src/scripts/_cli/cmd_doctor.ts +69 -1
  69. package/src/scripts/_cli/cmd_session_recycle.ts +44 -3
  70. package/src/scripts/_cli/handoff_generate.ts +36 -1
  71. package/src/scripts/_cli/handoff_sessions.ts +140 -9
  72. package/src/scripts/_dispatch.bash +5 -3
  73. package/src/scripts/_lib/carrier_divergence.ts +98 -27
  74. package/src/scripts/_lib/envelope_grounding.ts +206 -0
  75. package/src/scripts/_lib/host_capability.ts +79 -0
  76. package/src/scripts/_lib/orchestration_record.ts +45 -0
  77. package/src/scripts/_lib/session_eol.ts +85 -0
  78. package/src/scripts/_lib/subagent_capsule.ts +256 -1
  79. package/src/scripts/ai_council/clients.ts +31 -0
  80. package/src/scripts/ai_council/events_log.ts +128 -6
  81. package/src/scripts/ai_council/orchestrator.ts +12 -3
  82. package/src/scripts/ai_council/pricing.ts +48 -1
  83. package/src/scripts/ai_council/quorum.ts +20 -0
  84. package/src/scripts/ai_council/session.ts +11 -0
  85. package/src/scripts/ask_transport.ts +20 -1
  86. package/src/scripts/cache_realization_report.ts +38 -0
  87. package/src/scripts/check_release_highlights.ts +7 -1
  88. package/src/scripts/check_secret_leak.test.ts +43 -0
  89. package/src/scripts/check_secret_leak.ts +12 -0
  90. package/src/scripts/conformance_scan.ts +105 -0
  91. package/src/scripts/cost/track.mjs +39 -0
  92. package/src/scripts/cost_summary.ts +104 -1
  93. package/src/scripts/council_cli.ts +131 -13
  94. package/src/scripts/dispatch_economy_report.ts +5 -2
  95. package/src/scripts/handoff_context_hook.ts +66 -18
  96. package/src/scripts/hooks/session_eol_hook.ts +12 -12
  97. package/src/scripts/install.ts +20 -6
  98. package/src/scripts/lint_budget_ownership.ts +81 -0
  99. package/src/scripts/lint_skill_frontmatter_safety.ts +55 -1
  100. package/src/scripts/lint_workflow_paths.ts +384 -0
  101. package/src/scripts/preamble_byte_census.ts +9 -1
  102. package/src/scripts/report_carrier_divergence.ts +104 -29
  103. package/src/scripts/report_conformance_funnel.ts +51 -4
  104. package/src/scripts/routing_doctor.ts +25 -2
  105. package/src/scripts/schemas/dod.schema.json +45 -0
  106. package/src/scripts/schemas/subagent.schema.json +3 -2
  107. package/dist/cli-delegate/chunk-RVGBH4XK.js +0 -142
@@ -189,6 +189,17 @@ function _serialise_response(r: CouncilResponse): Record<string, unknown> {
189
189
  const payload: Record<string, unknown> = {
190
190
  provider: r.provider,
191
191
  model: r.model,
192
+ // `model` stays the REQUESTED id — the tier decision was made against
193
+ // it. `model_served` is what the provider reported answering with, so a
194
+ // silent alias or substitution is visible instead of being recorded as
195
+ // the model that never ran. Attribution-only: no consumer routes on it.
196
+ //
197
+ // Additive on the MANIFEST's own response row, which is this artefact —
198
+ // NOT a `cost-summary/v1` field: that contract describes
199
+ // `agents/cost-tracking/sessions.jsonl`, a different file with a
200
+ // different producer. Absent on manifests written before this, and `''`
201
+ // for every transport that reports no served id.
202
+ model_served: r.model_served,
192
203
  input_tokens: r.input_tokens,
193
204
  output_tokens: r.output_tokens,
194
205
  latency_ms: r.latency_ms,
@@ -48,10 +48,20 @@ import type { ExternalAIClient } from './ai_council/clients.js';
48
48
  import { buildOrchestrationLine } from './_lib/orchestration_record.js';
49
49
  import { DEFAULT_DIR } from './orchestration_record.js';
50
50
 
51
+ /**
52
+ * Placeholder written into `AskResult.model` when the member reported no id.
53
+ * Named so the audit path can recognise and refuse it rather than passing a
54
+ * sentinel off as a real model id.
55
+ */
56
+ export const UNKNOWN_MODEL = 'unknown';
57
+
51
58
  export interface AskResult {
52
59
  answer: string;
53
60
  member: string;
61
+ /** The REQUESTED model id — what the member was configured to call. */
54
62
  model: string;
63
+ /** The model the provider reported serving; `''` when it reports none. */
64
+ model_served: string;
55
65
  input_tokens: number;
56
66
  output_tokens: number;
57
67
  latency_ms: number;
@@ -94,6 +104,14 @@ function recordAskLine(auditDir: string, result: AskResult | null, member: strin
94
104
  dispatch_tokens: result === null ? null : result.input_tokens + result.output_tokens,
95
105
  wall_clock_ms: result === null ? 0 : Math.max(0, Math.round(result.latency_ms)),
96
106
  agent_combo: [member || 'none'],
107
+ // `AskResult.model` substitutes an `'unknown'` SENTINEL when the member
108
+ // reported no id (below). That sentinel must never reach the audit line
109
+ // as a model_requested: `modelDivergent` treats any non-empty string as
110
+ // a real id, so it would fabricate a `true` on a line whose entire
111
+ // purpose is making real substitutions credible. null keeps the
112
+ // three-valued semantics — and the field's own "id only" contract.
113
+ model_requested: result === null || result.model === UNKNOWN_MODEL ? null : result.model,
114
+ model_served: result === null ? null : result.model_served,
97
115
  origin: 'dispatch-economy-2026',
98
116
  ts,
99
117
  id,
@@ -145,7 +163,8 @@ export async function askOnce(prompt: string, opts: AskOptions = {}): Promise<As
145
163
  {
146
164
  answer: text,
147
165
  member: provider,
148
- model: r.model ?? 'unknown',
166
+ model: r.model ?? UNKNOWN_MODEL,
167
+ model_served: r.model_served ?? '',
149
168
  input_tokens: r.input_tokens ?? 0,
150
169
  output_tokens: r.output_tokens ?? 0,
151
170
  latency_ms: r.latency_ms ?? 0,
@@ -88,6 +88,25 @@ export interface ColdStartStats {
88
88
  first_call_cache_read_share: number;
89
89
  /** Σ(first-call cache_creation_input_tokens) / Σ(all subagent cache_creation_input_tokens) — the C-1 metric. */
90
90
  cold_start_share_of_write_volume: number;
91
+ /**
92
+ * The write-share SIGNATURE across ALL subagent records (not just first
93
+ * calls): how the billable input splits between cache writes, cache reads,
94
+ * and uncached input.
95
+ *
96
+ * The three shares are over the same denominator and sum to 1 (up to
97
+ * float error) whenever any billable input exists, which is what makes it
98
+ * a signature rather than three unrelated ratios — a workload that is
99
+ * mostly `write` is paying the cache premium without collecting the read
100
+ * discount, and that is invisible in `cold_start_share_of_write_volume`,
101
+ * which only says how much of the writing happened on a first call.
102
+ *
103
+ * All three are 0 when there is no billable input at all. That is the
104
+ * empty-corpus reading, NOT "a perfectly uncached workload" — read it
105
+ * beside `legs`.
106
+ */
107
+ write_share_of_billable: number;
108
+ read_share_of_billable: number;
109
+ uncached_share_of_billable: number;
91
110
  }
92
111
 
93
112
  /**
@@ -132,12 +151,22 @@ export function computeColdStarts(records: readonly TranscriptRecord[]): ColdSta
132
151
  const sumFirstCallBillable = firstCalls.reduce((s, r) => s + billableInputTokens(r.usage), 0);
133
152
  const sumFirstCallWrite = firstCalls.reduce((s, r) => s + r.usage.cache_creation_input_tokens, 0);
134
153
 
154
+ // Signature over ALL subagent records — the shape of the workload, where
155
+ // the metrics above describe only its first calls.
156
+ const sumBillable = subagentRecords.reduce((s, r) => s + billableInputTokens(r.usage), 0);
157
+ const sumWrite = totalWriteVolume;
158
+ const sumRead = subagentRecords.reduce((s, r) => s + r.usage.cache_read_input_tokens, 0);
159
+ const sumUncached = subagentRecords.reduce((s, r) => s + r.usage.input_tokens, 0);
160
+
135
161
  return {
136
162
  legs: firstCalls.length,
137
163
  median_first_call_written_or_uncached: median(writtenOrUncached),
138
164
  mean_first_call_written_or_uncached: mean(writtenOrUncached),
139
165
  first_call_cache_read_share: sumFirstCallBillable > 0 ? sumFirstCallRead / sumFirstCallBillable : 0,
140
166
  cold_start_share_of_write_volume: totalWriteVolume > 0 ? sumFirstCallWrite / totalWriteVolume : 0,
167
+ write_share_of_billable: sumBillable > 0 ? sumWrite / sumBillable : 0,
168
+ read_share_of_billable: sumBillable > 0 ? sumRead / sumBillable : 0,
169
+ uncached_share_of_billable: sumBillable > 0 ? sumUncached / sumBillable : 0,
141
170
  };
142
171
  }
143
172
 
@@ -737,6 +766,15 @@ export function renderText(r: Report): string {
737
766
  );
738
767
  out.push(` first_call_cache_read_share=${pct(cs.first_call_cache_read_share)}`);
739
768
  out.push(` cold_start_share_of_subagent_write_volume=${pct(cs.cold_start_share_of_write_volume)}`);
769
+ // Write-share signature over ALL subagent records: the three shares sum to
770
+ // 100% and say where the billable input actually went. A write-heavy split
771
+ // is paying the cache premium without collecting the read discount — which
772
+ // the cold-start metrics above cannot show, since they only look at first
773
+ // calls. All-zero means no billable input at all; read it beside legs.
774
+ out.push(
775
+ ` write_share=${pct(cs.write_share_of_billable)} read_share=${pct(cs.read_share_of_billable)}` +
776
+ ` uncached_share=${pct(cs.uncached_share_of_billable)} (of billable input, all subagent records)`,
777
+ );
740
778
  out.push('');
741
779
 
742
780
  out.push(`Per-agent breakdown: ${r.by_agent.length} distinct subagent legs (--format json carries all; top 10 by weighted_input_units below)`);
@@ -114,7 +114,13 @@ export function highlight_contradictions(
114
114
  return out;
115
115
  }
116
116
 
117
- function main(argv: readonly string[]): number {
117
+ /**
118
+ * Exported so the advisory-vs-blocking decision is a fixture, not a claim.
119
+ * `stale_draft_labels` firing while the exit code stays 0 is the behaviour the
120
+ * cadence blocker adjudicates; pinning it here makes either branch of that
121
+ * decision a one-line diff with a test that notices.
122
+ */
123
+ export function main(argv: readonly string[]): number {
118
124
  let version: string | null = null;
119
125
  let from: string | null = null;
120
126
  let to = 'HEAD';
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from 'node:child_process';
1
2
  import * as fs from 'node:fs';
2
3
  import * as os from 'node:os';
3
4
  import * as path from 'node:path';
@@ -45,3 +46,45 @@ describe('check_secret_leak — .secret-allow', () => {
45
46
  expect(readAllowFile(tmp)).toEqual([]);
46
47
  });
47
48
  });
49
+
50
+ describe('check_secret_leak — R2 review-input packages are a derived copy', () => {
51
+ let tmp: string;
52
+
53
+ beforeEach(() => {
54
+ tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'secret-review-input-')));
55
+ execFileSync('git', ['init', '--quiet'], { cwd: tmp, stdio: 'ignore' });
56
+ });
57
+ afterEach(() => {
58
+ fs.rmSync(tmp, { recursive: true, force: true });
59
+ });
60
+
61
+ function track(rel: string, body: string): void {
62
+ const abs = path.join(tmp, rel);
63
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
64
+ fs.writeFileSync(abs, body);
65
+ execFileSync('git', ['add', '--', rel], { cwd: tmp, stdio: 'ignore' });
66
+ }
67
+
68
+ const FAKE_KEY = 'aws = AKIA1234567890ABCDEF\n';
69
+
70
+ it('skips a diff copied into a .review-input package', () => {
71
+ // The dispatcher copies the branch diff VERBATIM, so a fixture-legal
72
+ // fake key is laundered out of the tests/ carve-out into a tracked
73
+ // path. Scanning the copy adds no coverage and reds the gate.
74
+ track('agents/evidence/reviews/branch.review-input/diff.patch', FAKE_KEY);
75
+ expect(scanRepo(tmp, 'all')).toEqual([]);
76
+ });
77
+
78
+ it('still catches the same key at a normal path — the exclusion is narrow', () => {
79
+ track('src/scripts/thing.ts', FAKE_KEY);
80
+ const hits = scanRepo(tmp, 'all');
81
+ expect(hits).toHaveLength(1);
82
+ expect(hits[0]?.file).toBe('src/scripts/thing.ts');
83
+ });
84
+
85
+ it('does not exclude the findings artefact itself — only the input package', () => {
86
+ // `<slug>.findings.md` is authored prose, not a copied diff.
87
+ track('agents/evidence/reviews/branch.findings.md', FAKE_KEY);
88
+ expect(scanRepo(tmp, 'all')).toHaveLength(1);
89
+ });
90
+ });
@@ -52,6 +52,18 @@ const DEFAULT_EXCLUDE: readonly RegExp[] = [
52
52
  /(^|\/)\.claude(\/|$)/,
53
53
  /(^|\/)fixtures(\/|$)/,
54
54
  /(^|\/)tests(\/|$)/, // test + golden + parity data legitimately holds secret-shaped strings
55
+ // R2 review-input packages: a VERBATIM copy of the branch diff, written by
56
+ // `dispatch_r2_reviewer`. Every byte in it already exists in the branch and
57
+ // is scanned at its own path under its own exclusions, so scanning the copy
58
+ // adds no coverage — and it DEFEATS the two carve-outs above by
59
+ // construction: a secret-shaped string that is legal in a test fixture gets
60
+ // laundered into a scanned path the moment a completion review runs. That
61
+ // is not hypothetical; it red this gate on the PR that added this line.
62
+ // What the exclusion cannot lose: content present only in the copy and
63
+ // nowhere else in the branch. The package is machine-generated from
64
+ // `git diff` and cannot invent content, and `--verify-current` re-derives
65
+ // its hashes, so a hand-edit is detectable rather than silent.
66
+ /(^|\/)[^/]+\.review-input(\/|$)/,
55
67
  /(^|\/)__[a-z_]+__(\/|$)/, // snapshot dirs (e.g. __parity_snapshots__)
56
68
  /\.test\.[a-z]+$/,
57
69
  /\.spec\.[a-z]+$/,
@@ -174,6 +174,79 @@ export interface ScanReport {
174
174
  rate: RateRecord;
175
175
  }
176
176
 
177
+ export type CheckId = Violation["check"];
178
+
179
+ export const CHECK_IDS: readonly CheckId[] = [
180
+ "language-pin",
181
+ "git-authorization",
182
+ "vacuous-evidence",
183
+ "evidence-steering",
184
+ ];
185
+
186
+ /**
187
+ * What each mechanised check actually detects, in the words a reader needs in
188
+ * order to decide whether a hit is a defect or a false read. `--why` prints
189
+ * this ALONGSIDE the hits rather than instead of them: a count with no
190
+ * definition is the shape that let a figure like "109 divergent pairs" travel
191
+ * through five reviews unchallenged.
192
+ */
193
+ export const CHECK_MEANINGS: Record<CheckId, string> = {
194
+ "language-pin":
195
+ "An assistant turn whose user-visible prose is not in the language that " +
196
+ "turn's own pin named. Carries provenance (turns_since_prompt, " +
197
+ "compaction_since_prompt) so a pin that was ABSENT because compaction " +
198
+ "removed it stays distinguishable from one that was present and ignored.",
199
+ "git-authorization":
200
+ "A gated git operation performed in a session where no authorization for " +
201
+ "that operation was observed. Authorization is per-operation and never " +
202
+ "carries from one to the next.",
203
+ "vacuous-evidence":
204
+ "A CI poll read as settled without ever observing an in-flight run — the " +
205
+ "poll gap that reports a green settle over a window in which nothing was " +
206
+ "pending yet.",
207
+ "evidence-steering":
208
+ "A self-commissioned evaluation whose prompt pre-loaded its own verdict, " +
209
+ "or a second self-scoped evaluation of the same subject within one turn.",
210
+ };
211
+
212
+ /**
213
+ * `--why <id>` — trace one conformance id: what it detects, whether it fired in
214
+ * this window, and every hit with its session and detail.
215
+ *
216
+ * A check that did not fire prints as *did not fire*, never as silence. Zero is
217
+ * a real answer, and the difference between "clean" and "not measured" is the
218
+ * whole reason to print it.
219
+ */
220
+ export function renderWhy(report: ScanReport, id: CheckId): string {
221
+ const hits = report.per_session.flatMap((s) => s.violations.filter((v) => v.check === id));
222
+ const lines: string[] = [
223
+ `conformance:why · ${id} · ${report.sessions} session(s) · ${report.store}`,
224
+ "",
225
+ " What it detects:",
226
+ ` ${CHECK_MEANINGS[id]}`,
227
+ "",
228
+ ];
229
+ if (hits.length === 0) {
230
+ lines.push(` Did NOT fire in this window (0 hits over ${report.sessions} session(s)).`);
231
+ lines.push(" A measured zero, not an unmeasured one — the check ran.");
232
+ return lines.join("\n");
233
+ }
234
+ lines.push(` Fired ${hits.length} time(s):`);
235
+ for (const v of hits) {
236
+ lines.push(` · ${v.session} @ ${v.at}`);
237
+ lines.push(` ${v.detail}`);
238
+ const since = (v as { turns_since_prompt?: number }).turns_since_prompt;
239
+ const compacted = (v as { compaction_since_prompt?: boolean }).compaction_since_prompt;
240
+ if (since !== undefined || compacted !== undefined) {
241
+ lines.push(
242
+ ` provenance: turns_since_prompt=${since ?? "—"} · ` +
243
+ `compaction_since_prompt=${compacted ?? "—"}`,
244
+ );
245
+ }
246
+ }
247
+ return lines.join("\n");
248
+ }
249
+
177
250
  type Entry = Record<string, unknown>;
178
251
 
179
252
  function _isObject(v: unknown): v is Record<string, unknown> {
@@ -641,6 +714,7 @@ export function main(argv?: string[]): number {
641
714
  let json = false;
642
715
  let out: string | null = null;
643
716
  let record: string | null = null;
717
+ let why: string | null = null;
644
718
  for (let i = 0; i < args.length; i += 1) {
645
719
  const a = args[i];
646
720
  if (a === "--store" && args[i + 1] !== undefined) {
@@ -651,6 +725,8 @@ export function main(argv?: string[]): number {
651
725
  json = true;
652
726
  } else if (a === "--output" && args[i + 1] !== undefined) {
653
727
  out = args[++i] as string;
728
+ } else if (a === "--why" && args[i + 1] !== undefined) {
729
+ why = args[++i] as string;
654
730
  } else if (a === "--record") {
655
731
  // Opt-in, and a bare `--record` takes the default path rather than
656
732
  // swallowing the next flag as a filename.
@@ -660,6 +736,13 @@ export function main(argv?: string[]): number {
660
736
  : DEFAULT_RATE_SERIES;
661
737
  }
662
738
  }
739
+ if (why !== null && !CHECK_IDS.includes(why as CheckId)) {
740
+ process.stderr.write(
741
+ `conformance:why: unknown check id ${JSON.stringify(why)} — known ids: ` +
742
+ `${CHECK_IDS.join(", ")}\n`,
743
+ );
744
+ return 2;
745
+ }
663
746
  const resolved = store ?? defaultStore(process.cwd());
664
747
  if (!fs.existsSync(resolved)) {
665
748
  process.stderr.write(`conformance:behavior: no transcript store at ${resolved}\n`);
@@ -675,6 +758,28 @@ export function main(argv?: string[]): number {
675
758
  // fs.writeSync, not process.stdout.write: `process.exit()` below does not
676
759
  // flush an async pipe write, so `--json | jq` reproducibly received a
677
760
  // truncated 64 KB document with no error (measured: 85,169 vs 65,536 bytes).
761
+ if (why !== null) {
762
+ const id = why as CheckId;
763
+ fs.writeSync(
764
+ 1,
765
+ json
766
+ ? `${JSON.stringify(
767
+ {
768
+ check: id,
769
+ meaning: CHECK_MEANINGS[id],
770
+ store: report.store,
771
+ sessions: report.sessions,
772
+ hits: report.per_session.flatMap((s) =>
773
+ s.violations.filter((v) => v.check === id),
774
+ ),
775
+ },
776
+ null,
777
+ 2,
778
+ )}\n`
779
+ : `${renderWhy(report, id)}\n`,
780
+ );
781
+ return 0;
782
+ }
678
783
  fs.writeSync(1, json ? `${JSON.stringify(report, null, 2)}\n` : `${render(report)}\n`);
679
784
  return 0;
680
785
  }
@@ -59,6 +59,19 @@ function modelTier(model) {
59
59
  // cache_creation_input_tokens NOT covered by that split (older records that
60
60
  // predate the TTL breakdown) is priced at the 5m rate — Anthropic's default
61
61
  // TTL — rather than silently dropped.
62
+ // A tier with no PRICING row prices at zero. That zero is indistinguishable
63
+ // from a genuinely free message, so the ROW carries `rate_missing` and the run
64
+ // warns once on stderr — the alternative (throwing) would lose a whole session
65
+ // of real token counts over one unrecognised model id. Token counts are kept
66
+ // untouched precisely so a later re-pricing pass can backfill the cost.
67
+ // Every token class that costs money. A record where this is 0 costs 0 at any
68
+ // rate, so a missing rate cannot understate it.
69
+ function billableTokens(u) {
70
+ if (!u) return 0;
71
+ return (u.input_tokens || 0) + (u.output_tokens || 0)
72
+ + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0);
73
+ }
74
+
62
75
  function costForUsage(tier, u) {
63
76
  const p = PRICING[tier];
64
77
  if (!p || !u) return 0;
@@ -163,6 +176,8 @@ function summarizeSession(files) {
163
176
  let sessionId = null, cwd = null;
164
177
  let totalSeen = 0;
165
178
  const seen = new Set();
179
+ // Model ids that priced at zero because no PRICING tier claimed them.
180
+ const rateMissingModels = new Set();
166
181
 
167
182
  for (const file of files) {
168
183
  let text;
@@ -188,6 +203,15 @@ function summarizeSession(files) {
188
203
  const tier = modelTier(model);
189
204
  const u = usageTokens(m.message.usage);
190
205
  const cost = costForUsage(tier, u);
206
+ // Key on the ACTUAL zero condition — `!PRICING[tier]`, what
207
+ // costForUsage returns 0 on — not on `tier === 'unknown'`. The two
208
+ // coincide only because `unknown` is today the sole tier modelTier()
209
+ // returns that PRICING lacks; adding a vendor family without a rate row
210
+ // would otherwise restore the exact silent zero this flag removes.
211
+ // The usage guard keeps the flag worth reading: a message with no
212
+ // billable tokens costs nothing at any rate, so flagging it would claim
213
+ // an understatement that is not there.
214
+ if (!PRICING[tier] && billableTokens(u) > 0) rateMissingModels.add(model);
191
215
 
192
216
  const slot = byModel[model] || { tier, input_tokens: 0, output_tokens: 0,
193
217
  cache_creation_input_tokens: 0, cache_read_input_tokens: 0, messages: 0, cost_usd: 0 };
@@ -213,6 +237,12 @@ function summarizeSession(files) {
213
237
  const dedup_ratio = totalSeen > 0 ? (totalSeen - dedupedCount) / totalSeen : 0;
214
238
  return { sessionId, cwd, startedAt: firstTs, endedAt: lastTs, messageCount,
215
239
  byModel, byTier, byBucket, total_cost_usd: totalCost,
240
+ // `rate_missing` marks a row whose total_cost_usd is understated because
241
+ // at least one model priced at zero for want of a rate — never because the
242
+ // work was free. `rate_missing_models` carries the ids so a backfill has a
243
+ // shape to work against instead of re-deriving it from the transcript.
244
+ rate_missing: rateMissingModels.size > 0,
245
+ rate_missing_models: [...rateMissingModels].sort(),
216
246
  totalRecordsSeen: totalSeen, dedupedRecordsCount: dedupedCount, dedup_ratio,
217
247
  capturedAt: new Date().toISOString() };
218
248
  }
@@ -237,6 +267,15 @@ function main() {
237
267
  }
238
268
  const files = collectSessionFiles(projectDir, sessionPath);
239
269
  const summary = summarizeSession(files);
270
+ // ONE warning per run, before the quiet return: a suppressed report is a
271
+ // display choice, an understated cost figure is a data-integrity problem.
272
+ if (summary.rate_missing) {
273
+ console.error(
274
+ `cost-track: rate_missing — no price tier for ${summary.rate_missing_models.join(', ')}; ` +
275
+ `those messages priced at $0 and the session total is understated. ` +
276
+ `Token counts are kept, so the row can be re-priced later.`,
277
+ );
278
+ }
240
279
  if (process.env.TRACK_OUT) writeFileSync(process.env.TRACK_OUT, JSON.stringify(summary, null, 2));
241
280
  const store = process.env.TRACK_STORE || DEFAULT_STORE;
242
281
  let res = { ok: false, reason: 'dry-run' };
@@ -18,6 +18,8 @@ import * as fs from 'node:fs';
18
18
  import * as path from 'node:path';
19
19
  import { fileURLToPath, pathToFileURL } from 'node:url';
20
20
 
21
+ import { CACHE_READ_MULTIPLIER, CACHE_WRITE_MULTIPLIER_5M } from './ai_council/pricing.js';
22
+
21
23
  const _HERE = fileURLToPath(import.meta.url);
22
24
  // src/scripts/cost_summary.ts → parents[2] is the repo root (mirrors
23
25
  // `Path(__file__).resolve().parent.parent.parent` in the .py).
@@ -70,6 +72,13 @@ interface KvBucket {
70
72
  cache_read_input_tokens: number;
71
73
  cache_creation_input_tokens: number;
72
74
  telegraph_delta_tokens: number;
75
+ // Source rows whose `total_cost_usd` is a FLOOR, not a figure: at least
76
+ // one message priced at $0 for want of a rate (`cost/track.mjs`
77
+ // `rate_missing`). Carried into every grouping because the understatement
78
+ // travels with the row — a summary that aggregates past it reports the
79
+ // silent zero one layer up, which is the defect the row-level flag exists
80
+ // to remove.
81
+ rate_missing_sessions: number;
73
82
  }
74
83
 
75
84
  function _zero_kv(): KvBucket {
@@ -81,6 +90,7 @@ function _zero_kv(): KvBucket {
81
90
  cache_read_input_tokens: 0,
82
91
  cache_creation_input_tokens: 0,
83
92
  telegraph_delta_tokens: 0,
93
+ rate_missing_sessions: 0,
84
94
  };
85
95
  }
86
96
 
@@ -94,6 +104,7 @@ interface ModelBucket {
94
104
  // cache itself is model-scoped — see pricing.ts), so by_model carries it.
95
105
  cache_read_input_tokens: number;
96
106
  cache_creation_input_tokens: number;
107
+ rate_missing_sessions: number;
97
108
  }
98
109
 
99
110
  function _zero_model(): ModelBucket {
@@ -104,15 +115,58 @@ function _zero_model(): ModelBucket {
104
115
  output_tokens: 0,
105
116
  cache_read_input_tokens: 0,
106
117
  cache_creation_input_tokens: 0,
118
+ rate_missing_sessions: 0,
107
119
  };
108
120
  }
109
121
 
122
+ /**
123
+ * What the prompt cache bought, in INPUT-TOKEN EQUIVALENTS — not dollars.
124
+ *
125
+ * A cached read bills at `CACHE_READ_MULTIPLIER` of the input rate, so each
126
+ * read token saved `1 - multiplier` of one. A cache write bills at a premium,
127
+ * so each written token cost an extra `multiplier - 1`. Netting the two is the
128
+ * only honest single number here.
129
+ *
130
+ * Deliberately NOT priced in USD: the summary aggregates across models with
131
+ * different input rates, and `totals` carries no per-model token split to
132
+ * apply them to — a dollar figure would have to pick one rate and would be
133
+ * wrong for every other model in the row.
134
+ *
135
+ * The write premium uses the 5-minute multiplier. Rows carry no TTL split, and
136
+ * 5m is both Anthropic's default and the assumption `cost/track.mjs` already
137
+ * makes for unaccounted writes — so the two cost paths stay consistent rather
138
+ * than disagreeing quietly. A 1h-heavy workload therefore reads as slightly
139
+ * OPTIMISTIC, which is stated in the contract.
140
+ */
141
+ function _cacheSavings(readTokens: number, writeTokens: number): number {
142
+ const saved = readTokens * (1 - CACHE_READ_MULTIPLIER);
143
+ const premium = writeTokens * (CACHE_WRITE_MULTIPLIER_5M - 1);
144
+ return Math.round(saved - premium);
145
+ }
146
+
147
+ /** UTC calendar date (`YYYY-MM-DD`) of a row, or `'unknown'`. */
148
+ function _rowDate(row: Row): string {
149
+ // `startedAt` is the row's own first-message timestamp (track.mjs); the
150
+ // snake_case spelling is accepted for the same reason `session_id` is.
151
+ const raw = row['startedAt'] ?? row['started_at'];
152
+ if (typeof raw !== 'string' || !raw) {
153
+ return 'unknown';
154
+ }
155
+ const parsed = new Date(raw);
156
+ if (Number.isNaN(parsed.getTime())) {
157
+ return 'unknown';
158
+ }
159
+ return parsed.toISOString().slice(0, 10);
160
+ }
161
+
110
162
  /** Aggregate rows into the cost-summary structure (PyFloat-aware output). */
111
163
  export function aggregate(rows: Row[]): Json {
112
164
  // Insertion-ordered maps to mirror Python's dict ordering + sorted().
113
165
  const by_sess = new Map<string, KvBucket>();
114
166
  const by_conv = new Map<string, KvBucket>();
115
167
  const by_model = new Map<string, ModelBucket>();
168
+ const by_date = new Map<string, KvBucket>();
169
+ const rate_missing_models = new Set<string>();
116
170
  const totals = _zero_kv();
117
171
 
118
172
  for (const row of rows) {
@@ -125,10 +179,17 @@ export function aggregate(rows: Row[]): Json {
125
179
  const cread = _int(row['cache_read_input_tokens']);
126
180
  const cwrite = _int(row['cache_creation_input_tokens']);
127
181
  const delta = _delta(row);
182
+ // A row is understated when its producer says so. `_pyTruthy` keeps the
183
+ // absent-reading identical to every other additive field: no flag → 0.
184
+ const rateMissing = _pyTruthy(row['rate_missing']) ? 1 : 0;
185
+ for (const id of _strList(row['rate_missing_models'])) {
186
+ rate_missing_models.add(id);
187
+ }
128
188
 
129
189
  const sessBucket = _getOr(by_sess, sid, _zero_kv);
130
190
  const convBucket = _getOr(by_conv, cid, _zero_kv);
131
- for (const bucket of [sessBucket, convBucket, totals]) {
191
+ const dateBucket = _getOr(by_date, _rowDate(row), _zero_kv);
192
+ for (const bucket of [sessBucket, convBucket, dateBucket, totals]) {
132
193
  bucket.sessions += 1;
133
194
  bucket.total_cost_usd += cost;
134
195
  bucket.input_tokens += itok;
@@ -136,6 +197,7 @@ export function aggregate(rows: Row[]): Json {
136
197
  bucket.cache_read_input_tokens += cread;
137
198
  bucket.cache_creation_input_tokens += cwrite;
138
199
  bucket.telegraph_delta_tokens += delta;
200
+ bucket.rate_missing_sessions += rateMissing;
139
201
  }
140
202
  const m = _getOr(by_model, model, _zero_model);
141
203
  m.sessions += 1;
@@ -144,6 +206,7 @@ export function aggregate(rows: Row[]): Json {
144
206
  m.output_tokens += otok;
145
207
  m.cache_read_input_tokens += cread;
146
208
  m.cache_creation_input_tokens += cwrite;
209
+ m.rate_missing_sessions += rateMissing;
147
210
  }
148
211
 
149
212
  return {
@@ -159,6 +222,15 @@ export function aggregate(rows: Row[]): Json {
159
222
  telegraph_delta_tokens: totals.telegraph_delta_tokens,
160
223
  telegraph_multiplier_version: MULTIPLIER_VERSION,
161
224
  telegraph_multiplier_active: MULTIPLIER_ACTIVE,
225
+ // Additive per the schema's own rule; token-equivalents, not USD.
226
+ cache_savings_input_token_equivalents: _cacheSavings(
227
+ totals.cache_read_input_tokens,
228
+ totals.cache_creation_input_tokens,
229
+ ),
230
+ // The understated-cost qualification, surfaced instead of
231
+ // aggregated past: a non-zero count means total_cost_usd is a floor.
232
+ rate_missing_sessions: totals.rate_missing_sessions,
233
+ rate_missing_models: [...rate_missing_models].sort(),
162
234
  },
163
235
  by_session: _sortedEntries(by_sess).map(([k, v]) => ({
164
236
  key: k,
@@ -169,6 +241,7 @@ export function aggregate(rows: Row[]): Json {
169
241
  cache_read_input_tokens: v.cache_read_input_tokens,
170
242
  cache_creation_input_tokens: v.cache_creation_input_tokens,
171
243
  telegraph_delta_tokens: v.telegraph_delta_tokens,
244
+ rate_missing_sessions: v.rate_missing_sessions,
172
245
  })),
173
246
  by_conversation: _sortedEntries(by_conv).map(([k, v]) => ({
174
247
  key: k,
@@ -179,6 +252,22 @@ export function aggregate(rows: Row[]): Json {
179
252
  cache_read_input_tokens: v.cache_read_input_tokens,
180
253
  cache_creation_input_tokens: v.cache_creation_input_tokens,
181
254
  telegraph_delta_tokens: v.telegraph_delta_tokens,
255
+ rate_missing_sessions: v.rate_missing_sessions,
256
+ })),
257
+ // Ordered by date ascending — `_sortedEntries` is codepoint order, and
258
+ // ISO `YYYY-MM-DD` sorts chronologically under it. `'unknown'` sorts
259
+ // last (`u` > any digit), so a row with no timestamp never displaces a
260
+ // real day.
261
+ by_date: _sortedEntries(by_date).map(([k, v]) => ({
262
+ key: k,
263
+ sessions: v.sessions,
264
+ total_cost_usd: new PyFloat(v.total_cost_usd),
265
+ input_tokens: v.input_tokens,
266
+ output_tokens: v.output_tokens,
267
+ cache_read_input_tokens: v.cache_read_input_tokens,
268
+ cache_creation_input_tokens: v.cache_creation_input_tokens,
269
+ telegraph_delta_tokens: v.telegraph_delta_tokens,
270
+ rate_missing_sessions: v.rate_missing_sessions,
182
271
  })),
183
272
  by_model: _sortedEntries(by_model).map(([k, v]) => ({
184
273
  model: k,
@@ -188,6 +277,7 @@ export function aggregate(rows: Row[]): Json {
188
277
  output_tokens: v.output_tokens,
189
278
  cache_read_input_tokens: v.cache_read_input_tokens,
190
279
  cache_creation_input_tokens: v.cache_creation_input_tokens,
280
+ rate_missing_sessions: v.rate_missing_sessions,
191
281
  })),
192
282
  };
193
283
  }
@@ -219,6 +309,19 @@ function _str(value: unknown): string {
219
309
  return String(value);
220
310
  }
221
311
 
312
+ /**
313
+ * Read a list-of-strings field defensively — absent, wrong-typed, or holding
314
+ * non-string members all read as "nothing to add" rather than throwing or
315
+ * coercing. A summary must never fail on a malformed source row; it skips it,
316
+ * exactly as `_load` skips an unparseable line.
317
+ */
318
+ function _strList(value: unknown): string[] {
319
+ if (!Array.isArray(value)) {
320
+ return [];
321
+ }
322
+ return value.filter((v): v is string => typeof v === 'string' && v.length > 0);
323
+ }
324
+
222
325
  /** Mirror `int(row.get(k) or 0)` — falsy → 0; numeric/string → truncated int. */
223
326
  function _int(value: unknown): number {
224
327
  if (!_pyTruthy(value)) {