@event4u/agent-config 9.32.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 (76) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +96 -1
  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/orchestration-telemetry.md +3 -0
  6. package/dist/agent-src/rules/delegation-policy.md +6 -0
  7. package/dist/agent-src/scripts/roadmap_gates.ts +107 -3
  8. package/dist/agent-src/skills/docker/SKILL.md +11 -0
  9. package/dist/agent-src/skills/laravel/SKILL.md +11 -0
  10. package/dist/agent-src/skills/review-routing/SKILL.md +23 -6
  11. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  12. package/dist/agent-src/templates/scripts/ownership-map.example.yml +1 -1
  13. package/dist/cli/registry.js +4 -4
  14. package/dist/cli/registry.js.map +1 -1
  15. package/dist/cli-delegate/chunk-HTIU3W56.js +66 -0
  16. package/dist/cli-delegate/{chunk-ITIQSGOQ.js → chunk-MA4GFWDY.js} +7 -4
  17. package/dist/cli-delegate/{chunk-542DZR4P.js → chunk-PMQ6Z3T3.js} +1651 -144
  18. package/dist/cli-delegate/{chunk-PFBV6XRT.js → chunk-Q26HT4QT.js} +1 -63
  19. package/dist/cli-delegate/{chunk-LVRYBKTI.js → chunk-YPAWXXXR.js} +158 -0
  20. package/dist/cli-delegate/cmd_conformance.js +256 -226
  21. package/dist/cli-delegate/cmd_doctor.js +3 -2
  22. package/dist/cli-delegate/cmd_export.js +1 -1
  23. package/dist/cli-delegate/cmd_handoff.js +19 -26
  24. package/dist/cli-delegate/cmd_prune.js +1 -1
  25. package/dist/cli-delegate/cmd_session_recycle.js +13 -3
  26. package/dist/cli-delegate/cmd_settings_get.js +4 -2
  27. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  28. package/dist/cli-delegate/cmd_settings_set.js +5 -3
  29. package/dist/cli-delegate/cmd_sync.js +1 -1
  30. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  31. package/dist/cli-delegate/cmd_validate.js +1 -1
  32. package/dist/discovery/deprecation-report.md +1 -1
  33. package/dist/discovery/discovery-manifest.json +8 -8
  34. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  35. package/dist/discovery/discovery-manifest.summary.md +1 -1
  36. package/dist/discovery/orphan-report.md +1 -1
  37. package/dist/discovery/packs.json +2 -2
  38. package/dist/discovery/trust-report.md +1 -1
  39. package/dist/discovery/workspaces.json +2 -2
  40. package/dist/hooks/dispatch.js +18 -0
  41. package/dist/install/install.mjs +7 -4
  42. package/dist/mcp/registry-manifest.json +2 -2
  43. package/dist/mcp/server.json +2 -2
  44. package/dist/mcp/server.mjs +3637 -1158
  45. package/package.json +1 -1
  46. package/src/agent-src/scripts/roadmap_gates.ts +107 -3
  47. package/src/agent-src/templates/scripts/ownership-map.example.yml +1 -1
  48. package/src/config/cost-parity-budget.json +167 -0
  49. package/src/config/gate-coverage.yml +44 -5
  50. package/src/config/quorum-attendance-budget.json +39 -0
  51. package/src/scripts/_cli/cmd_doctor.ts +69 -1
  52. package/src/scripts/_cli/cmd_session_recycle.ts +19 -3
  53. package/src/scripts/_dispatch.bash +5 -3
  54. package/src/scripts/_lib/orchestration_record.ts +45 -0
  55. package/src/scripts/ai_council/clients.ts +31 -0
  56. package/src/scripts/ai_council/events_log.ts +128 -6
  57. package/src/scripts/ai_council/orchestrator.ts +12 -3
  58. package/src/scripts/ai_council/pricing.ts +48 -1
  59. package/src/scripts/ai_council/quorum.ts +20 -0
  60. package/src/scripts/ai_council/session.ts +11 -0
  61. package/src/scripts/ask_transport.ts +20 -1
  62. package/src/scripts/cache_realization_report.ts +38 -0
  63. package/src/scripts/check_release_highlights.ts +7 -1
  64. package/src/scripts/conformance_scan.ts +105 -0
  65. package/src/scripts/cost/track.mjs +39 -0
  66. package/src/scripts/cost_summary.ts +104 -1
  67. package/src/scripts/council_cli.ts +131 -13
  68. package/src/scripts/dispatch_economy_report.ts +5 -2
  69. package/src/scripts/install.ts +20 -6
  70. package/src/scripts/lint_budget_ownership.ts +81 -0
  71. package/src/scripts/lint_skill_frontmatter_safety.ts +55 -1
  72. package/src/scripts/lint_workflow_paths.ts +384 -0
  73. package/src/scripts/preamble_byte_census.ts +9 -1
  74. package/src/scripts/report_carrier_divergence.ts +10 -0
  75. package/src/scripts/report_conformance_funnel.ts +40 -0
  76. package/src/scripts/schemas/subagent.schema.json +3 -2
@@ -130,7 +130,7 @@ Tier 1 — power-user (release shape, audit, migration):
130
130
  user-authored files; persists standing consent
131
131
  (install.auto_converge) on first use.
132
132
  Flags: --dry-run | --yes
133
- conformance:behavior Replay local transcripts through the mechanised conformance checks
133
+ conformance:behavior Replay local transcripts through the mechanised conformance checks (--why <id> traces one)
134
134
  conformance Consumer conformance contract: doctor --ci +
135
135
  installed-and-firing checks. Exits non-zero on
136
136
  a failed check.
@@ -197,7 +197,7 @@ Tier 2 — maintenance / internal (hooks, MCP, memory, telemetry):
197
197
  session register). Flags: --json
198
198
  sessions:claim Claim a roadmap for this session so other sessions skip
199
199
  it; --release clears the claim
200
- session:recycle Validate + write the main-session recycle envelope
200
+ session:recycle Validate + write the main-session recycle envelope (--verify validates only)
201
201
  (deliberate recycle instead of auto-compact). Flags:
202
202
  --file <json> | --template; default reads stdin
203
203
  roadmap:progress Regenerate agents/roadmaps-progress.md from open roadmaps
@@ -207,7 +207,9 @@ Tier 2 — maintenance / internal (hooks, MCP, memory, telemetry):
207
207
  gates Open decisions that need you, rendered as actions —
208
208
  roadmap blockers filtered by owner, most-unblocking
209
209
  first. Flags: --all (include maintainer/external),
210
- --json
210
+ --json, --reply (reply-close form: the one blocking
211
+ decision in full, the rest as a count; prints
212
+ nothing when none are yours)
211
213
  capabilities:index Regenerate CAPABILITIES.yaml — the package coverage index
212
214
  (capability area → coverage → backing skills/commands → gaps).
213
215
  Pass --check to fail if stale (for CI). Reads src/ (package repo).
@@ -64,6 +64,20 @@ export interface RecordInput {
64
64
  dispatch_tokens?: number | null | undefined;
65
65
  /** The orchestrator's own tier — the baseline the downshift cost-% measures against. */
66
66
  session_tier?: string | null | undefined;
67
+ /**
68
+ * The model id that was ASKED for. `tier_chosen`, `tier_source`,
69
+ * `session_tier` and the downshift cost-percentage are all read off the
70
+ * requested tier, so on an alias or a provider substitution every one of
71
+ * them attributes the saving to a model that never ran. Recording the
72
+ * requested id beside the served one is what makes that detectable.
73
+ */
74
+ model_requested?: string | null | undefined;
75
+ /**
76
+ * The model id the provider reported SERVING. `''` / null when the
77
+ * transport reports none (every CLI client) — absent is honest, not a
78
+ * defect, and is NOT a divergence.
79
+ */
80
+ model_served?: string | null | undefined;
67
81
  dispatch_outcome?: DispatchOutcome | undefined;
68
82
  verify_mode?: VerifyMode | undefined;
69
83
  /** QUALITY: subagent return adopted without parent rework. Optional boolean (null = not measured). */
@@ -176,6 +190,23 @@ function isInt(n: unknown): n is number {
176
190
  return typeof n === 'number' && Number.isFinite(n) && Number.isInteger(n);
177
191
  }
178
192
 
193
+ /**
194
+ * Did the provider answer with a different model than the one requested?
195
+ *
196
+ * `null` — not decidable — whenever either id is missing or empty. That is the
197
+ * common case (every CLI transport reports no served id) and it is not the
198
+ * same as "no divergence": a `false` here would claim a comparison that never
199
+ * happened, which is the exact mis-attribution this field exists to expose.
200
+ */
201
+ function modelDivergent(
202
+ requested: string | null | undefined,
203
+ served: string | null | undefined,
204
+ ): boolean | null {
205
+ if (typeof requested !== 'string' || typeof served !== 'string') return null;
206
+ if (!requested || !served) return null;
207
+ return requested !== served;
208
+ }
209
+
179
210
  /**
180
211
  * Build + validate the audit-log-v1 line. Returns `{line, errors}`; when
181
212
  * `errors` is non-empty, `line` is null and the caller must NOT write.
@@ -290,6 +321,13 @@ export function buildOrchestrationLine(input: RecordInput): BuiltLine {
290
321
  errors.push(`trigger_arm_earlier must be one of ${TRIGGER_ARMS.join(' | ')} (or omitted / null when neither arm fired)`);
291
322
  }
292
323
 
324
+ for (const [key, v] of [
325
+ ['model_requested', input.model_requested],
326
+ ['model_served', input.model_served],
327
+ ] as const) {
328
+ if (v != null && typeof v !== 'string') errors.push(`${key} must be a string or omitted`);
329
+ }
330
+
293
331
  if (!input.ts) errors.push('ts (ISO-8601 UTC) is required');
294
332
  if (!input.id) errors.push('id (ULID or content hash) is required');
295
333
 
@@ -310,6 +348,13 @@ export function buildOrchestrationLine(input: RecordInput): BuiltLine {
310
348
  tier_source: input.tier_source ?? null,
311
349
  dispatch_tokens: input.dispatch_tokens ?? null,
312
350
  session_tier: input.session_tier ?? null,
351
+ // Served-model attribution. `model_divergent` is DERIVED, never
352
+ // supplied: `null` when either id is absent — a transport that reports
353
+ // no served id cannot disagree with anything, and calling that `false`
354
+ // would read as "checked, and they matched".
355
+ model_requested: input.model_requested ?? null,
356
+ model_served: input.model_served ?? null,
357
+ model_divergent: modelDivergent(input.model_requested, input.model_served),
313
358
  first_pass_success: input.first_pass_success ?? null,
314
359
  escalated: input.escalated ?? null,
315
360
  agent_combo: input.agent_combo ?? [],
@@ -195,10 +195,34 @@ export function assertCacheBreakpointOrder(ttls: readonly PromptCacheTtl[]): voi
195
195
  }
196
196
  }
197
197
 
198
+ /**
199
+ * The model id a provider reports on its own response, or `''` when it reports
200
+ * none.
201
+ *
202
+ * `field` is a parameter because the name is NOT uniform across providers:
203
+ * Gemini reports it as `model_version`, so a literal single-field read would
204
+ * leave Gemini silently empty — which is indistinguishable from a transport
205
+ * that genuinely reports nothing. Non-string values collapse to `''` rather
206
+ * than being coerced: a guess is worse than an honest blank here, because the
207
+ * whole point of the field is detecting a substitution.
208
+ */
209
+ function _servedModel(response: unknown, field = 'model'): string {
210
+ const value = _getattr(response, field, '');
211
+ return typeof value === 'string' ? value : '';
212
+ }
213
+
198
214
  /** Normalised output from a single council member (dataclass `CouncilResponse`). */
199
215
  export class CouncilResponse {
200
216
  provider: string;
201
217
  model: string;
218
+ // The model id the provider reported serving, when it reports one. NEVER
219
+ // overwrites `model`: that is the REQUESTED id, and it is what the tier
220
+ // decision was made against — keeping both is what makes a silent alias or
221
+ // provider substitution detectable at all. Attribution-only; no consumer
222
+ // may route on it. Defaults to '' (the honest value for a transport that
223
+ // reports no served id, e.g. every CLI client) so existing callers and the
224
+ // persisted response JSON stay backward-compatible.
225
+ model_served: string;
202
226
  text: string;
203
227
  input_tokens: number;
204
228
  output_tokens: number;
@@ -215,6 +239,7 @@ export class CouncilResponse {
215
239
  constructor(opts: {
216
240
  provider: string;
217
241
  model: string;
242
+ model_served?: string;
218
243
  text: string;
219
244
  input_tokens?: number;
220
245
  output_tokens?: number;
@@ -226,6 +251,7 @@ export class CouncilResponse {
226
251
  }) {
227
252
  this.provider = opts.provider;
228
253
  this.model = opts.model;
254
+ this.model_served = opts.model_served ?? '';
229
255
  this.text = opts.text;
230
256
  this.input_tokens = opts.input_tokens ?? 0;
231
257
  this.output_tokens = opts.output_tokens ?? 0;
@@ -616,6 +642,7 @@ export class AnthropicClient extends ExternalAIClient {
616
642
  return new CouncilResponse({
617
643
  provider: this.name,
618
644
  model: this.model,
645
+ model_served: _servedModel(response),
619
646
  text,
620
647
  input_tokens: usage ? (_getattr(usage, 'input_tokens', 0) as number) : 0,
621
648
  output_tokens: usage ? (_getattr(usage, 'output_tokens', 0) as number) : 0,
@@ -719,6 +746,7 @@ export class OpenAIClient extends ExternalAIClient {
719
746
  return new CouncilResponse({
720
747
  provider: this.name,
721
748
  model: this.model,
749
+ model_served: _servedModel(response),
722
750
  text: (text as string) || '',
723
751
  input_tokens: usage ? (_getattr(usage, 'prompt_tokens', 0) as number) : 0,
724
752
  output_tokens: usage ? (_getattr(usage, 'completion_tokens', 0) as number) : 0,
@@ -796,6 +824,8 @@ export class GeminiClient extends ExternalAIClient {
796
824
  return new CouncilResponse({
797
825
  provider: this.name,
798
826
  model: this.model,
827
+ // Gemini names it `model_version`, not `model`.
828
+ model_served: _servedModel(response, 'model_version'),
799
829
  text,
800
830
  input_tokens: usage ? (_getattr(usage, 'prompt_token_count', 0) as number) : 0,
801
831
  output_tokens: usage ? (_getattr(usage, 'candidates_token_count', 0) as number) : 0,
@@ -878,6 +908,7 @@ export class _OpenAICompatibleClient extends ExternalAIClient {
878
908
  return new CouncilResponse({
879
909
  provider: this.name,
880
910
  model: this.model,
911
+ model_served: _servedModel(response),
881
912
  text: (text as string) || '',
882
913
  input_tokens: usage ? (_getattr(usage, 'prompt_tokens', 0) as number) : 0,
883
914
  output_tokens: usage ? (_getattr(usage, 'completion_tokens', 0) as number) : 0,
@@ -3,9 +3,11 @@
3
3
  *
4
4
  * Ported from the retired Python `src/scripts/ai_council/events_log.py` (ADR-200 —
5
5
  * Python→TS migration, Phase 1). Appends one JSON line per council event to
6
- * `<project_root>/agents/runtime/council/events.log`. Schema v1 carries the
7
- * minimum needed to answer the "why did the council skip / block this?"
8
- * question at retro time without leaking prompt content.
6
+ * `<project_root>/agents/runtime/council/events.log`. The schema carries the
7
+ * minimum needed to answer "why did the council skip / block this?" and,
8
+ * since v2, "who actually attended?" — at retro time, without leaking prompt
9
+ * content. v2 added the `quorum_result` action and nothing else; see
10
+ * `appendQuorumEvent`.
9
11
  *
10
12
  * Privacy floor:
11
13
  * `original_ask` is never written verbatim — the caller passes the raw
@@ -25,14 +27,24 @@ import * as fs from 'node:fs';
25
27
  import * as path from 'node:path';
26
28
  import { fileURLToPath } from 'node:url';
27
29
 
28
- export const SCHEMA_VERSION = 1;
30
+ import { isSoloConcluded, type QuorumResult } from './quorum.js';
31
+ import type { AbsentReason } from './transport_resolver.js';
29
32
 
30
- export type EventAction = 'proceed' | 'skip_necessity' | 'block_quota';
33
+ /**
34
+ * v2 adds the `quorum_result` action (and nothing else) — see
35
+ * `appendQuorumEvent`. Every schema-v1 field keeps its name, type and
36
+ * position, so a v1 reader parses a v2 necessity line unchanged; only the
37
+ * action vocabulary widened.
38
+ */
39
+ export const SCHEMA_VERSION = 2;
40
+
41
+ export type EventAction = 'proceed' | 'skip_necessity' | 'block_quota' | 'quorum_result';
31
42
 
32
43
  const _VALID_ACTIONS: ReadonlySet<string> = new Set([
33
44
  'proceed',
34
45
  'skip_necessity',
35
46
  'block_quota',
47
+ 'quorum_result',
36
48
  ]);
37
49
 
38
50
  /**
@@ -194,7 +206,7 @@ export function appendEvent(
194
206
  original_ask_hash: _hash_original_ask(rawAsk),
195
207
  };
196
208
  // Pass-through for any caller-supplied diagnostic fields that are not in
197
- // the schema-v1 reserved set. The schema-v1 fields above always win on
209
+ // the reserved set. The reserved fields above always win on
198
210
  // collision.
199
211
  const reserved = new Set([...Object.keys(record), 'original_ask']);
200
212
  for (const [k, v] of Object.entries(event)) {
@@ -215,6 +227,116 @@ export function defaultLogPath(): string {
215
227
  return _DEFAULT_LOG_PATH;
216
228
  }
217
229
 
230
+ // ── quorum attendance (schema v2) ───────────────────────────────────
231
+
232
+ /**
233
+ * Which of the two `evaluateQuorum` call sites produced the record.
234
+ *
235
+ * `pre_run` is `build_members`' construction-time reading — who could be
236
+ * built at all. `post_run` is `_postRunQuorum`'s re-derivation over what
237
+ * actually returned a usable response. One pass emits BOTH, and they
238
+ * disagree whenever a member constructs and then fails mid-flight, so a
239
+ * consumer that counts attendance without splitting on this field
240
+ * double-counts every pass.
241
+ */
242
+ export type QuorumEventPhase = 'pre_run' | 'post_run';
243
+
244
+ /**
245
+ * One absent member, in the vocabulary the tree actually uses:
246
+ * `AbsentReason` plus the two runtime fallbacks the CLI writes directly —
247
+ * `'unavailable'` (transport resolved to ∅) and `'binary_missing'` (a CLI
248
+ * client refused to construct).
249
+ */
250
+ export type QuorumAbsentReason = AbsentReason | 'unavailable' | 'binary_missing';
251
+
252
+ export interface QuorumAbsence {
253
+ readonly member: string;
254
+ readonly reason: QuorumAbsentReason;
255
+ }
256
+
257
+ /**
258
+ * Which CLI path produced the line. `estimate` spends nothing — it is a cost
259
+ * preview — so every rate computed over these lines must exclude it, and it
260
+ * is recorded rather than suppressed so the exclusion is the consumer's
261
+ * explicit act instead of an invisible one.
262
+ */
263
+ export type QuorumCommand = 'run' | 'estimate' | 'debate';
264
+
265
+ /**
266
+ * `single` means `--single` filtered the roster down to one member BEFORE
267
+ * the pass ran. Without this field a deliberate solo dispatch is byte-identical
268
+ * to a configured one-member council, and the solo-conclusion rate cannot tell
269
+ * the two apart — which is the one distinction it exists to make.
270
+ */
271
+ export type QuorumDispatch = 'full' | 'single';
272
+
273
+ export interface QuorumEventInput {
274
+ readonly lens: string;
275
+ readonly invocation: string;
276
+ readonly phase: QuorumEventPhase;
277
+ readonly command: QuorumCommand;
278
+ readonly dispatch: QuorumDispatch;
279
+ /**
280
+ * Enabled members in the config, BEFORE `--single` / `--siblings`
281
+ * filtering and before construction failures. `result.total` is the
282
+ * roster that survived; when the two differ, the gap is exactly the
283
+ * "council degraded by configuration" case a post_run-only reading is
284
+ * otherwise blind to.
285
+ */
286
+ readonly configuredTotal: number;
287
+ readonly result: QuorumResult;
288
+ readonly absent: readonly QuorumAbsence[];
289
+ }
290
+
291
+ /**
292
+ * Append one `quorum_result` line — the attendance record that makes a
293
+ * solo-concluded pass distinguishable from a full-attendance one.
294
+ *
295
+ * Privacy floor, by construction: `QuorumAbsence` carries a member name and
296
+ * a closed-vocabulary reason and has **no field able to hold free-form
297
+ * content**. The CLI's own absent entries carry a `detail` string built from
298
+ * provider error text (which can embed paths and prompts); it is dropped
299
+ * here rather than scrubbed, so there is no scrubber to fail.
300
+ *
301
+ * Fail-open: attendance telemetry must never be able to kill a council
302
+ * pass, so every error is swallowed and reported as `false`. The reachable
303
+ * ones are filesystem errors from `appendEvent`'s own `mkdirSync` /
304
+ * `appendFileSync` — an unwritable path, ENOTDIR, a full disk. Its other
305
+ * throw (an invalid action) is unreachable from here, since this function
306
+ * supplies the action itself.
307
+ */
308
+ export function appendQuorumEvent(
309
+ input: QuorumEventInput,
310
+ opts: AppendEventOptions = {},
311
+ ): boolean {
312
+ try {
313
+ return appendEvent(
314
+ {
315
+ lens: input.lens,
316
+ invocation: input.invocation,
317
+ action: 'quorum_result',
318
+ verdict: input.result.status,
319
+ phase: input.phase,
320
+ command: input.command,
321
+ dispatch: input.dispatch,
322
+ threshold: input.result.threshold,
323
+ configured_total: input.configuredTotal,
324
+ total: input.result.total,
325
+ present: input.result.present,
326
+ // Written by the predicate, never re-derived from the numbers
327
+ // downstream: one definition of "concluded on a single voice",
328
+ // in `quorum.ts`, so a change to it cannot leave a consumer's
329
+ // copy silently stale.
330
+ solo: isSoloConcluded(input.result),
331
+ absent: input.absent.map((a) => ({ member: a.member, reason: a.reason })),
332
+ },
333
+ opts,
334
+ );
335
+ } catch {
336
+ return false;
337
+ }
338
+ }
339
+
218
340
  // ── helpers ─────────────────────────────────────────────────────────
219
341
 
220
342
  /**
@@ -70,7 +70,7 @@ import { count_dissenters, dissent_quota_met, is_near_duplicate } from './debate
70
70
  import { parse_stance_line, render_vote_tally, tally_stances } from './stance_tally.js';
71
71
  import { CHAIRMAN_FIELDS_ADDENDUM, render_deanonymization_block } from './blind_review.js';
72
72
  import type { AbsentReason } from './transport_resolver.js';
73
- import type { QuorumResult } from './quorum.js';
73
+ import { isSoloConcluded, type QuorumResult } from './quorum.js';
74
74
  import { isEmptyHandoff, type HandoffEnvelope } from './handoff.js';
75
75
 
76
76
  // ── Python-format / stdlib parity helpers ────────────────────────────────
@@ -1979,10 +1979,19 @@ export function render(responses: CouncilResponse[], opts: RenderOptions = {}):
1979
1979
  return blocks.join('\n\n---\n\n');
1980
1980
  }
1981
1981
 
1982
- /** One line naming the k-of-n outcome — machine-parseable, human-readable. */
1982
+ /**
1983
+ * One line naming the k-of-n outcome — machine-parseable, human-readable.
1984
+ *
1985
+ * The `solo` marker is the artifact half of the signal the `quorum_result`
1986
+ * event carries in the log: a pass concluded by ONE voice otherwise reads
1987
+ * identically to a full-attendance one, and the reader of a rendered pass is
1988
+ * exactly who needs to see the difference. Derived through `isSoloConcluded`
1989
+ * so the predicate keeps one definition and the renderer does not restate it.
1990
+ */
1983
1991
  function _render_quorum_line(q: QuorumResult): string {
1984
1992
  const verdict = q.status === 'concluded' ? 'concluded' : 'INCONCLUSIVE — release gate holds';
1985
- return `**Quorum:** ${q.present}/${q.total} present, needed ${q.threshold} — ${verdict}.`;
1993
+ const solo = isSoloConcluded(q) ? ' · **solo** — one voice concluded this pass' : '';
1994
+ return `**Quorum:** ${q.present}/${q.total} present, needed ${q.threshold} — ${verdict}.${solo}`;
1986
1995
  }
1987
1996
 
1988
1997
  /**
@@ -49,8 +49,55 @@ export interface PriceTable {
49
49
  prices: Map<string, Price>; // keyed by priceKey(provider, model)
50
50
  }
51
51
 
52
+ /**
53
+ * Price for (provider, model) — exact key first, then the longest matching
54
+ * key PREFIX within the same provider.
55
+ *
56
+ * Exact match stays the first hit, so no call that resolves today changes
57
+ * path or price. The fallback exists because vendors ship dated aliases:
58
+ * `claude-sonnet-4-5-20260101` missed a `claude-sonnet-4-5` row and priced at
59
+ * **nothing** — a silent zero, not an error. Longest-first means a more
60
+ * specific row always wins over a shorter one that also prefixes the id
61
+ * (`claude-opus-4-1` over a hypothetical `claude-opus`).
62
+ *
63
+ * The prefix must be followed by a separator (`-`, `.`, `:`, `@`) or end the
64
+ * id, so `claude-opus-4-1` never matches a `claude-opus-4-15` row: a bare
65
+ * `startsWith` would price one model at another's rate, which is worse than
66
+ * the zero it replaces because it looks correct.
67
+ *
68
+ * Provider is part of the composite key, so cross-provider bleed is
69
+ * impossible by construction.
70
+ */
52
71
  export function lookup(table: PriceTable, provider: string, model: string): Price | null {
53
- return table.prices.get(priceKey(provider, model)) ?? null;
72
+ const exact = table.prices.get(priceKey(provider, model));
73
+ if (exact !== undefined) {
74
+ return exact;
75
+ }
76
+ // Derived from `priceKey`, never hand-rebuilt: it owns the composite-key
77
+ // encoding, and a second copy here would let a separator change follow the
78
+ // exact-match path while this loop silently matched nothing — regressing to
79
+ // the very silent zero the fallback exists to remove.
80
+ const prefix = priceKey(provider, '');
81
+ let best: Price | null = null;
82
+ let bestLen = -1;
83
+ for (const [key, price] of table.prices) {
84
+ if (!key.startsWith(prefix)) {
85
+ continue;
86
+ }
87
+ const candidate = key.slice(prefix.length);
88
+ if (!candidate || !model.startsWith(candidate)) {
89
+ continue;
90
+ }
91
+ const next = model.charAt(candidate.length);
92
+ if (next !== '' && next !== '-' && next !== '.' && next !== ':' && next !== '@') {
93
+ continue;
94
+ }
95
+ if (candidate.length > bestLen) {
96
+ best = price;
97
+ bestLen = candidate.length;
98
+ }
99
+ }
100
+ return best;
54
101
  }
55
102
 
56
103
  // ── token + cost arithmetic ────────────────────────────────────────
@@ -82,3 +82,23 @@ export function evaluateQuorum(
82
82
  const status: QuorumStatus = clampedPresent >= threshold ? 'concluded' : 'inconclusive';
83
83
  return { status, threshold, total, present: clampedPresent };
84
84
  }
85
+
86
+ /**
87
+ * Did this pass conclude on a single voice?
88
+ *
89
+ * Derived — deliberately NOT a third `QuorumStatus`. `ceil(n / 2)` makes
90
+ * 1-of-2 a legitimate `concluded`, which is the intended behaviour (see the
91
+ * module header); this predicate does not dispute it, it only makes the
92
+ * shape visible so a reader can tell a one-member conclusion from a
93
+ * full-attendance one. Advisory render and telemetry only: no gate reads it,
94
+ * and nothing downstream may branch on it without its own decision record.
95
+ *
96
+ * `total` is deliberately NOT consulted. A council configured with a single
97
+ * member concludes solo by construction, and that is still a conclusion
98
+ * reached on one voice — collapsing it into "not solo" would hide exactly
99
+ * the passes a solo-conclusion rate is measured to find. A consumer that
100
+ * cares about the distinction reads `result.total` alongside.
101
+ */
102
+ export function isSoloConcluded(result: QuorumResult): boolean {
103
+ return result.status === 'concluded' && result.present === 1;
104
+ }
@@ -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';