@event4u/agent-config 9.32.0 → 9.34.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 (109) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +85 -72
  3. package/dist/agent-src/commands/agent-handoff.md +15 -3
  4. package/dist/agent-src/commands/review/routing.md +3 -4
  5. package/dist/agent-src/contexts/communication/rules-auto/reply-close-mechanics.md +65 -0
  6. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +3 -0
  7. package/dist/agent-src/rules/delegation-policy.md +6 -0
  8. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
  9. package/dist/agent-src/scripts/roadmap_gates.ts +107 -3
  10. package/dist/agent-src/skills/corpus-grounding/SKILL.md +21 -12
  11. package/dist/agent-src/skills/design-tokens/SKILL.md +15 -0
  12. package/dist/agent-src/skills/docker/SKILL.md +11 -0
  13. package/dist/agent-src/skills/laravel/SKILL.md +11 -0
  14. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +24 -0
  15. package/dist/agent-src/skills/review-routing/SKILL.md +23 -6
  16. package/dist/agent-src/skills/rule-writing/SKILL.md +23 -0
  17. package/dist/agent-src/skills/skill-writing/SKILL.md +80 -2
  18. package/dist/agent-src/skills/systematic-debugging/SKILL.md +5 -3
  19. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +24 -0
  20. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  21. package/dist/agent-src/templates/scripts/ownership-map.example.yml +1 -1
  22. package/dist/cli/registry.js +4 -4
  23. package/dist/cli/registry.js.map +1 -1
  24. package/dist/cli-delegate/chunk-HTIU3W56.js +66 -0
  25. package/dist/cli-delegate/{chunk-2WELHO3P.js → chunk-I5EAFRII.js} +35 -0
  26. package/dist/cli-delegate/{chunk-ITIQSGOQ.js → chunk-MA4GFWDY.js} +7 -4
  27. package/dist/cli-delegate/{chunk-PFBV6XRT.js → chunk-Q26HT4QT.js} +1 -63
  28. package/dist/cli-delegate/{chunk-542DZR4P.js → chunk-REMEZSVP.js} +1660 -146
  29. package/dist/cli-delegate/{chunk-3EWYC3YK.js → chunk-UEZ7D35A.js} +4 -3
  30. package/dist/cli-delegate/{chunk-LVRYBKTI.js → chunk-YPAWXXXR.js} +158 -0
  31. package/dist/cli-delegate/cmd_conformance.js +256 -226
  32. package/dist/cli-delegate/cmd_doctor.js +3 -2
  33. package/dist/cli-delegate/cmd_export.js +1 -1
  34. package/dist/cli-delegate/cmd_handoff.js +21 -28
  35. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  36. package/dist/cli-delegate/cmd_prune.js +1 -1
  37. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  38. package/dist/cli-delegate/cmd_session_recycle.js +20 -5
  39. package/dist/cli-delegate/cmd_settings_get.js +4 -2
  40. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  41. package/dist/cli-delegate/cmd_settings_set.js +5 -3
  42. package/dist/cli-delegate/cmd_sync.js +1 -1
  43. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  44. package/dist/cli-delegate/cmd_validate.js +1 -1
  45. package/dist/discovery/deprecation-report.md +1 -1
  46. package/dist/discovery/discovery-manifest.json +17 -17
  47. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  48. package/dist/discovery/discovery-manifest.summary.md +2 -2
  49. package/dist/discovery/dormancy-report.md +17 -0
  50. package/dist/discovery/orphan-report.md +1 -1
  51. package/dist/discovery/packs.json +3 -3
  52. package/dist/discovery/trust-report.md +1 -1
  53. package/dist/discovery/workspaces.json +3 -3
  54. package/dist/hooks/dispatch.js +114 -7
  55. package/dist/install/install.mjs +7 -4
  56. package/dist/mcp/registry-manifest.json +3 -3
  57. package/dist/mcp/server.json +2 -2
  58. package/dist/mcp/server.mjs +3755 -1168
  59. package/docs/guidelines/agent-infra/failure-signatures.md +47 -12
  60. package/package.json +2 -2
  61. package/src/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
  62. package/src/agent-src/scripts/roadmap_gates.ts +107 -3
  63. package/src/agent-src/templates/scripts/ownership-map.example.yml +1 -1
  64. package/src/config/cost-parity-budget.json +167 -0
  65. package/src/config/gate-coverage.yml +44 -5
  66. package/src/config/quorum-attendance-budget.json +39 -0
  67. package/src/scripts/_cli/cmd_doctor.ts +69 -1
  68. package/src/scripts/_cli/cmd_session_recycle.ts +25 -4
  69. package/src/scripts/_dispatch.bash +5 -3
  70. package/src/scripts/_lib/env_kill_switch.ts +37 -0
  71. package/src/scripts/_lib/orchestration_record.ts +45 -0
  72. package/src/scripts/_lib/security_lint.ts +18 -4
  73. package/src/scripts/_lib/self_repair.ts +60 -0
  74. package/src/scripts/_lib/self_repair_store.ts +65 -5
  75. package/src/scripts/_lib/subagent_capsule.ts +78 -1
  76. package/src/scripts/ai_council/clients.ts +31 -0
  77. package/src/scripts/ai_council/events_log.ts +130 -8
  78. package/src/scripts/ai_council/orchestrator.ts +50 -3
  79. package/src/scripts/ai_council/pricing.ts +48 -1
  80. package/src/scripts/ai_council/prompts.ts +165 -3
  81. package/src/scripts/ai_council/quorum.ts +30 -0
  82. package/src/scripts/ai_council/session.ts +11 -0
  83. package/src/scripts/ai_team/review_gate.ts +2 -2
  84. package/src/scripts/ask_transport.ts +20 -1
  85. package/src/scripts/build_discovery_manifest.ts +165 -0
  86. package/src/scripts/cache_realization_report.ts +38 -0
  87. package/src/scripts/check_claims.ts +51 -2
  88. package/src/scripts/check_release_highlights.ts +7 -1
  89. package/src/scripts/ci_time_ratio.ts +3 -1
  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/discovery_graph.ts +201 -27
  95. package/src/scripts/dispatch_economy_report.ts +5 -2
  96. package/src/scripts/dispatch_r2_reviewer.ts +102 -1
  97. package/src/scripts/install.ts +20 -6
  98. package/src/scripts/lint_budget_ownership.ts +81 -0
  99. package/src/scripts/lint_handoffs.ts +92 -2
  100. package/src/scripts/lint_hedge_words.ts +317 -0
  101. package/src/scripts/lint_mcp_config_security.ts +1 -1
  102. package/src/scripts/lint_skill_frontmatter_safety.ts +56 -2
  103. package/src/scripts/lint_workflow_paths.ts +384 -0
  104. package/src/scripts/preamble_byte_census.ts +9 -1
  105. package/src/scripts/report_carrier_divergence.ts +10 -0
  106. package/src/scripts/report_conformance_funnel.ts +40 -0
  107. package/src/scripts/schemas/subagent.schema.json +3 -2
  108. package/src/scripts/self_repair_cli.ts +26 -1
  109. package/src/scripts/skill_linter.ts +58 -2
@@ -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)) {
@@ -77,7 +77,14 @@ import { InvalidModeError, resolve_global_mode } from './ai_council/modes.js';
77
77
  import { resolveMemberTransport } from './ai_council/transport_resolver.js';
78
78
  import { absentReasonFromCliFailure, classifyCliFailure, type AbsentReason } from './ai_council/transport_resolver.js';
79
79
  import { evaluateQuorum, type QuorumResult } from './ai_council/quorum.js';
80
- import { appendEvent } from './ai_council/events_log.js';
80
+ import {
81
+ appendEvent,
82
+ appendQuorumEvent,
83
+ type QuorumAbsence,
84
+ type QuorumCommand,
85
+ type QuorumDispatch,
86
+ type QuorumEventPhase,
87
+ } from './ai_council/events_log.js';
81
88
  import {
82
89
  type ClassificationResult,
83
90
  type SizeFitVerdict,
@@ -576,6 +583,16 @@ interface BuildMembersOptions {
576
583
  * "null = never evaluated" convention.
577
584
  */
578
585
  quorum_out?: { result: QuorumResult | null } | null;
586
+ /**
587
+ * Which CLI path is constructing — recorded on the `pre_run` quorum
588
+ * event so a consumer can exclude the paths that never run a pass.
589
+ * `estimate` is a spend-free preview and `run` may still abort at the
590
+ * necessity or size-fit gate after this point, so a `pre_run` line is
591
+ * evidence that construction happened, never that a pass followed;
592
+ * attendance rates are computed over `post_run` lines for exactly that
593
+ * reason. Defaults to `run` for a caller that does not say.
594
+ */
595
+ command?: QuorumCommand;
579
596
  /**
580
597
  * Test-only injection point, mirroring the `settings`/`members`/`table`
581
598
  * DI pattern the CLI commands already use. Production callers never set
@@ -630,6 +647,52 @@ function _quorum_setting_from(ai: Dict): QuorumSetting {
630
647
  return 'majority';
631
648
  }
632
649
 
650
+ /**
651
+ * Emit one `quorum_result` line for a resolved quorum.
652
+ *
653
+ * Both `evaluateQuorum` call sites route through here, tagged by `phase`, so
654
+ * a solo-concluded pass is distinguishable from a full-attendance one in the
655
+ * event log — the gap `road-to-always-on-orchestration`'s Risk 6 asserted was
656
+ * already closed and was not.
657
+ *
658
+ * The emit sits at the call sites rather than inside `_postRunQuorum` on
659
+ * purpose: that function is exported and unit-tested as a pure derivation,
660
+ * and a write buried in it would make every test of it a log writer.
661
+ *
662
+ * `absent` entries arrive as the CLI's own `{member, reason, detail}` dicts;
663
+ * `detail` is dropped by `appendQuorumEvent`'s input type, which cannot carry
664
+ * free-form text at all.
665
+ */
666
+ function _emitQuorumEvent(
667
+ phase: QuorumEventPhase,
668
+ quorum: QuorumResult,
669
+ absent: readonly Dict[],
670
+ ctx: {
671
+ command: QuorumCommand;
672
+ dispatch?: QuorumDispatch;
673
+ /** Enabled members before `--single` filtering; defaults to the roster that ran. */
674
+ configuredTotal?: number;
675
+ lens?: string;
676
+ invocation?: string;
677
+ },
678
+ ): void {
679
+ appendQuorumEvent({
680
+ lens: ctx.lens ?? '',
681
+ invocation: ctx.invocation ?? '',
682
+ phase,
683
+ command: ctx.command,
684
+ dispatch: ctx.dispatch ?? 'full',
685
+ configuredTotal: ctx.configuredTotal ?? quorum.total,
686
+ result: quorum,
687
+ absent: absent.map(
688
+ (a): QuorumAbsence => ({
689
+ member: String(a['member'] ?? ''),
690
+ reason: (a['reason'] as QuorumAbsence['reason']) ?? 'unavailable',
691
+ }),
692
+ ),
693
+ });
694
+ }
695
+
633
696
  /**
634
697
  * Re-derive quorum AFTER the provider calls, over what was actually USABLE —
635
698
  * never what merely CONSTRUCTED (M3, independent-review finding). `members`
@@ -762,6 +825,18 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
762
825
  // that caller shape.
763
826
  let total_enabled = 0;
764
827
  let absent_count = 0;
828
+ // Every absence, regardless of whether the caller asked for the
829
+ // human-readable `skipped` out-param — the quorum event needs the
830
+ // member/reason pairs even when `skipped` is `null`, and reconstructing
831
+ // them from `absent_count` alone is impossible.
832
+ const absent_entries: Dict[] = [];
833
+ const record_absent = (entry: Dict): void => {
834
+ absent_count += 1;
835
+ absent_entries.push(entry);
836
+ if (skipped !== null) {
837
+ skipped.push(entry);
838
+ }
839
+ };
765
840
  for (const name of Object.keys(members_cfg)) {
766
841
  const cfg = ((members_cfg[name] as Dict) || {}) as Dict;
767
842
  if (!_pyBool(cfg['enabled'])) {
@@ -834,10 +909,7 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
834
909
  reason: resolved.absentReason ?? 'unavailable',
835
910
  detail,
836
911
  };
837
- absent_count += 1;
838
- if (skipped !== null) {
839
- skipped.push(entry);
840
- }
912
+ record_absent(entry);
841
913
  _stderr(`[council] SKIP ${name}: ${detail}\n`);
842
914
  continue;
843
915
  }
@@ -877,10 +949,7 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
877
949
  }
878
950
  const detail = exc.message;
879
951
  const entry: Dict = { member: name, reason: 'no_auth', detail };
880
- absent_count += 1;
881
- if (skipped !== null) {
882
- skipped.push(entry);
883
- }
952
+ record_absent(entry);
884
953
  _stderr(`[council] SKIP ${name}: ${detail}\n`);
885
954
  continue;
886
955
  }
@@ -910,10 +979,7 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
910
979
  reason: 'binary_missing',
911
980
  detail,
912
981
  };
913
- absent_count += 1;
914
- if (skipped !== null) {
915
- skipped.push(entry);
916
- }
982
+ record_absent(entry);
917
983
  _stderr(`[council] SKIP ${name}: ${detail}\n`);
918
984
  continue;
919
985
  }
@@ -935,6 +1001,15 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
935
1001
  if (quorum_out !== null) {
936
1002
  const present = total_enabled - absent_count;
937
1003
  quorum_out.result = evaluateQuorum(total_enabled, present, _quorum_setting_from(ai));
1004
+ // Construction-time attendance. `lens` / `invocation` stay empty
1005
+ // here on purpose: neither is known at member-construction time,
1006
+ // and `invocation_mode` (api / cli / manual) is a different axis
1007
+ // that would read as the same field under a shared name.
1008
+ // `command` IS recorded, because a preview and an aborted run both
1009
+ // reach this line and neither is a pass.
1010
+ _emitQuorumEvent('pre_run', quorum_out.result, absent_entries, {
1011
+ command: opts.command ?? 'run',
1012
+ });
938
1013
  }
939
1014
  if (members.length === 0) {
940
1015
  if (skipped && skipped.length > 0) {
@@ -1703,6 +1778,10 @@ function cmd_estimate(
1703
1778
  siblings_overrides: _parse_siblings_overrides(_getattr<string[] | null>(args, 'siblings', null)),
1704
1779
  skipped,
1705
1780
  quorum_out,
1781
+ // A cost preview spends nothing and runs no pass. The line is
1782
+ // still written — tagged, so a consumer excludes it deliberately
1783
+ // instead of never learning it was there.
1784
+ command: 'estimate',
1706
1785
  });
1707
1786
  }
1708
1787
  if (table === null) {
@@ -2365,6 +2444,13 @@ function cmd_run(
2365
2444
  quorum_out,
2366
2445
  });
2367
2446
  }
2447
+ // Measured, never assumed: `_apply_solo_dispatch` escalates back to the
2448
+ // full roster on three paths (empty fallback chain, no chain member
2449
+ // runtime-present, the picked member vanished). `--single` is therefore a
2450
+ // request, not an outcome, and a `dispatch` field written from the flag
2451
+ // would claim a solo pass that did not happen — contaminating the one
2452
+ // split the solo-conclusion rate depends on.
2453
+ const roster_before_dispatch = members.length;
2368
2454
  if (_getattr(args, 'single', false)) {
2369
2455
  const [filtered, solo_banner] = _apply_solo_dispatch(members);
2370
2456
  members = filtered;
@@ -2373,6 +2459,8 @@ function cmd_run(
2373
2459
  }
2374
2460
  _emit_shadow_slo_banner();
2375
2461
  }
2462
+ const dispatch_shape: QuorumDispatch =
2463
+ members.length < roster_before_dispatch ? 'single' : 'full';
2376
2464
  if (table === null) {
2377
2465
  // Anchor to the PROJECT root — the module default writes into the
2378
2466
  // installed package dir when run from a consumer (pollutes the npm
@@ -2538,9 +2626,30 @@ function cmd_run(
2538
2626
  // (mid-flight failures appended to the same array construction-time
2539
2627
  // skips already populate, so `payload['absent_members']` needs no
2540
2628
  // separate wiring either).
2629
+ // The pre-run reading, before it is overwritten — `build_members` set it
2630
+ // from `total_enabled`, i.e. the enabled roster before `--single` filtering
2631
+ // and before any construction failure. Without carrying it forward, a pass
2632
+ // with 3 configured members where 2 fail to construct and 1 answers emits
2633
+ // `{total: 1, present: 1}` and reads as full attendance.
2634
+ const configured_total = quorum_out.result?.total ?? null;
2541
2635
  const post_run = _postRunQuorum(members, responses, ai_cfg);
2542
2636
  quorum_out.result = post_run.quorum;
2543
2637
  skipped.push(...post_run.absent);
2638
+ // Attendance telemetry for the reading that actually decided the pass.
2639
+ // `post_run.absent` is the mid-flight set only; the construction-time
2640
+ // skips already went out under `phase: 'pre_run'` from `build_members`,
2641
+ // and merging them here would double-count a member absent in both.
2642
+ _emitQuorumEvent('post_run', post_run.quorum, post_run.absent, {
2643
+ command: 'run',
2644
+ // Whether the roster actually shrank — see `dispatch_shape`. Without
2645
+ // this the line is byte-identical to a configured one-member council,
2646
+ // and the solo-conclusion rate cannot make the one distinction it
2647
+ // exists for.
2648
+ dispatch: dispatch_shape,
2649
+ ...(configured_total !== null ? { configuredTotal: configured_total } : {}),
2650
+ lens: question.mode,
2651
+ invocation: String(_getattr(args, 'invocation', 'agent')),
2652
+ });
2544
2653
  // Phase 4.1 — verdict → handoff envelope. Same tally `render()` computes
2545
2654
  // internally for the Vote Tally block (mirrored here, not imported from
2546
2655
  // there, because `render()` re-derives it from the SAVED payload on a
@@ -2817,12 +2926,21 @@ function cmd_debate(
2817
2926
  const advisor_plans = _build_advisor_plans(ai_cfg, REPO_ROOT);
2818
2927
  const explicit_overrides = _parse_model_overrides(_getattr<string[] | null>(args, 'model', null));
2819
2928
  const skipped: Dict[] = [];
2929
+ // Debate spends the same providers as `run`, so its attendance belongs in
2930
+ // the same log. It carries the construction-time half only: there is no
2931
+ // `_postRunQuorum` on this path, and adding one would need a usable-response
2932
+ // definition for a multi-round debate that this change does not have. The
2933
+ // gap is declared in quorum-attendance-budget.json rather than left for a
2934
+ // reader to discover from a missing row.
2935
+ const quorum_out: { result: QuorumResult | null } = { result: null };
2820
2936
  if (members === null) {
2821
2937
  members = build_members(settings, {
2822
2938
  invocation_mode: args.mode_override,
2823
2939
  model_overrides: _advisor_model_overrides(advisor_plans, explicit_overrides),
2824
2940
  siblings_overrides: _parse_siblings_overrides(_getattr<string[] | null>(args, 'siblings', null)),
2825
2941
  skipped,
2942
+ quorum_out,
2943
+ command: 'debate',
2826
2944
  });
2827
2945
  }
2828
2946
  if (table === null) {