@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
@@ -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,25 @@ 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 { isEnvKillSwitchActive } from '../_lib/env_kill_switch.js';
31
+ import { isSoloConcluded, type QuorumResult } from './quorum.js';
32
+ import type { AbsentReason } from './transport_resolver.js';
29
33
 
30
- export type EventAction = 'proceed' | 'skip_necessity' | 'block_quota';
34
+ /**
35
+ * v2 adds the `quorum_result` action (and nothing else) — see
36
+ * `appendQuorumEvent`. Every schema-v1 field keeps its name, type and
37
+ * position, so a v1 reader parses a v2 necessity line unchanged; only the
38
+ * action vocabulary widened.
39
+ */
40
+ export const SCHEMA_VERSION = 2;
41
+
42
+ export type EventAction = 'proceed' | 'skip_necessity' | 'block_quota' | 'quorum_result';
31
43
 
32
44
  const _VALID_ACTIONS: ReadonlySet<string> = new Set([
33
45
  'proceed',
34
46
  'skip_necessity',
35
47
  'block_quota',
48
+ 'quorum_result',
36
49
  ]);
37
50
 
38
51
  /**
@@ -127,8 +140,7 @@ function _utf8Bytes(cp: number): string {
127
140
  }
128
141
 
129
142
  function _kill_switch_active(): boolean {
130
- const value = process.env[_KILL_SWITCH_ENV] ?? '';
131
- return !(value === '' || value === '0' || value === 'false' || value === 'False');
143
+ return isEnvKillSwitchActive(_KILL_SWITCH_ENV);
132
144
  }
133
145
 
134
146
  export interface AppendEventOptions {
@@ -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
  /**
@@ -58,6 +58,7 @@ import type { AdvisorPlan } from './advisors.js';
58
58
  import {
59
59
  advisor_system_prompt,
60
60
  ANTI_CONFORMITY_DIRECTIVE,
61
+ describe_verdict_mismatch,
61
62
  STANCE_LINE_CONTRACT,
62
63
  build_extraction_user_prompt,
63
64
  build_peer_review_user_prompt,
@@ -65,12 +66,13 @@ import {
65
66
  peer_review_synthesis_addendum,
66
67
  synthesis_template,
67
68
  system_prompt_for,
69
+ VERDICT_LINE_CONTRACT,
68
70
  } from './prompts.js';
69
71
  import { count_dissenters, dissent_quota_met, is_near_duplicate } from './debate_gates.js';
70
72
  import { parse_stance_line, render_vote_tally, tally_stances } from './stance_tally.js';
71
73
  import { CHAIRMAN_FIELDS_ADDENDUM, render_deanonymization_block } from './blind_review.js';
72
74
  import type { AbsentReason } from './transport_resolver.js';
73
- import type { QuorumResult } from './quorum.js';
75
+ import { isSoloConcluded, type QuorumResult } from './quorum.js';
74
76
  import { isEmptyHandoff, type HandoffEnvelope } from './handoff.js';
75
77
 
76
78
  // ── Python-format / stdlib parity helpers ────────────────────────────────
@@ -1927,6 +1929,23 @@ export function render(responses: CouncilResponse[], opts: RenderOptions = {}):
1927
1929
  // addendum so both extensions stack predictably.
1928
1930
  template = template ? `${template}\n\n${CHAIRMAN_FIELDS_ADDENDUM}` : CHAIRMAN_FIELDS_ADDENDUM;
1929
1931
  }
1932
+ if (opts.stance_tally === true && template !== '') {
1933
+ // Phase 2.1: the synthesis owes the same machine-readable closing line
1934
+ // the members owe. Appended only when a tally will be rendered — there
1935
+ // is nothing to check a verdict against otherwise. Composed last so it
1936
+ // is the final instruction, matching the contract's "and nothing after
1937
+ // it". The template's own `VERDICT: <option-label>` placeholder is
1938
+ // parsed as absent (see `parse_verdict_line`), so the un-summarised
1939
+ // render stays green.
1940
+ //
1941
+ // The `template !== ''` guard is load-bearing: an empty template is
1942
+ // `prose_synthesis: true`, documented at `:1859-1862` as a BARE slot.
1943
+ // Appending here would replace `*to be summarised by the host agent*`
1944
+ // with raw contract instructions and silently override an option the
1945
+ // caller set on purpose — a creative-lens pass would start reading like
1946
+ // a filled-in form.
1947
+ template = `${template}\n\n${VERDICT_LINE_CONTRACT}`;
1948
+ }
1930
1949
  let body: string;
1931
1950
  if (template) {
1932
1951
  body = template;
@@ -1951,6 +1970,25 @@ export function render(responses: CouncilResponse[], opts: RenderOptions = {}):
1951
1970
  .map((r) => ({ member: `${r.provider}:${r.model}`, text: r.text })),
1952
1971
  );
1953
1972
  blocks.push(render_vote_tally(tally));
1973
+ // Phase 2.1: the verdict half of the same projection. A synthesis that
1974
+ // states a `VERDICT:` line the tally did not clear is contradicting its
1975
+ // own count, and the reader of the rendered pass is exactly who needs to
1976
+ // see that.
1977
+ //
1978
+ // Surfaced, never thrown, and the ordering above is part of the fix: a
1979
+ // throw here would discard the whole artifact — every member response,
1980
+ // the peer review, the quorum bookkeeping — AFTER every provider call
1981
+ // was paid for, which is the hazard this roadmap already recorded for
1982
+ // the sibling shape check. `describe_verdict_mismatch` returns the
1983
+ // string; `assert_synthesis_matches_tally` still throws for a caller
1984
+ // holding a finished synthesis it can refuse.
1985
+ //
1986
+ // Conditional by construction: an absent verdict line yields null, so
1987
+ // every synthesis written before the contract shipped stays byte-identical.
1988
+ const mismatch = describe_verdict_mismatch(body, tally);
1989
+ if (mismatch !== null) {
1990
+ blocks.push(`> **Verdict/tally mismatch:** ${mismatch}`);
1991
+ }
1954
1992
  }
1955
1993
  blocks.push(`## Convergence / Divergence\n\n${body}`);
1956
1994
  // Phase 4.1: closest to the synthesis it was extracted from — ahead of
@@ -1979,10 +2017,19 @@ export function render(responses: CouncilResponse[], opts: RenderOptions = {}):
1979
2017
  return blocks.join('\n\n---\n\n');
1980
2018
  }
1981
2019
 
1982
- /** One line naming the k-of-n outcome — machine-parseable, human-readable. */
2020
+ /**
2021
+ * One line naming the k-of-n outcome — machine-parseable, human-readable.
2022
+ *
2023
+ * The `solo` marker is the artifact half of the signal the `quorum_result`
2024
+ * event carries in the log: a pass concluded by ONE voice otherwise reads
2025
+ * identically to a full-attendance one, and the reader of a rendered pass is
2026
+ * exactly who needs to see the difference. Derived through `isSoloConcluded`
2027
+ * so the predicate keeps one definition and the renderer does not restate it.
2028
+ */
1983
2029
  function _render_quorum_line(q: QuorumResult): string {
1984
2030
  const verdict = q.status === 'concluded' ? 'concluded' : 'INCONCLUSIVE — release gate holds';
1985
- return `**Quorum:** ${q.present}/${q.total} present, needed ${q.threshold} — ${verdict}.`;
2031
+ const solo = isSoloConcluded(q) ? ' · **solo** — one voice concluded this pass' : '';
2032
+ return `**Quorum:** ${q.present}/${q.total} present, needed ${q.threshold} — ${verdict}.${solo}`;
1986
2033
  }
1987
2034
 
1988
2035
  /**
@@ -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 ────────────────────────────────────────
@@ -460,9 +460,19 @@ export const REQUIRED_SYNTHESIS_SECTIONS = ['Kill criteria', 'Concrete next step
460
460
  * fills the Convergence / Divergence slot) — not the template prompt. Every
461
461
  * lens must close with a non-empty `### Kill criteria` and `### Concrete next
462
462
  * step`; a missing or placeholder-empty section throws `SynthesisRenderError`
463
- * naming the first offender. Called on the synthesis-emit path (the chairman
464
- * synthesis in Phase 2, and any future synthesis-record step); safe to call on
465
- * decision and creative lenses alike, since both now require the two sections.
463
+ * naming the first offender. Safe to call on decision and creative lenses
464
+ * alike, since both now require the two sections.
465
+ *
466
+ * **Not wired onto the emit path, deliberately.** This docstring used to claim
467
+ * it was "called on the synthesis-emit path"; measured, the function had zero
468
+ * production call sites and only the test imported it. Wiring it
469
+ * unconditionally into `render()` is not available either: with no chairman the
470
+ * rendered body is the literal `*to be summarised by the host agent*`, which
471
+ * carries neither section, so every templated render would throw (verified —
472
+ * the probe raises `missing the required "### Kill criteria" section`). A
473
+ * caller that HAS a completed synthesis calls this; the templated default path
474
+ * has nothing to validate. The tally check below is the half that could be
475
+ * wired, because it is conditional on a verdict line the template never emits.
466
476
  */
467
477
  export function assert_synthesis_sections(text: string): void {
468
478
  const lines = text.split('\n');
@@ -488,6 +498,158 @@ export function assert_synthesis_sections(text: string): void {
488
498
  }
489
499
  }
490
500
 
501
+ /** The reserved verdict label a synthesis uses when the tally did not converge. */
502
+ export const SPLIT_VERDICT_LABEL = 'split';
503
+
504
+ // Mirrors `_STANCE_RE`'s shape discipline: an explicit, single-field closing
505
+ // line, never read out of surrounding prose.
506
+ //
507
+ // **Case-SENSITIVE, deliberately.** The `i` flag was here and was wrong: the
508
+ // pattern is line-anchored, so `Verdict: option A is the stronger choice` —
509
+ // ordinary chairman prose opening a line — parsed as a machine-readable verdict
510
+ // carrying the whole sentence as its label. That is precisely the prose
511
+ // inference the paragraph above forbids, arriving through the regex instead of
512
+ // through a fallback. `VERDICT_LINE_CONTRACT` asks for the literal uppercase
513
+ // token, so requiring it costs a compliant author nothing and closes the class.
514
+ const _VERDICT_RE = /^\s*VERDICT:\s*(.+?)\s*$/gm;
515
+
516
+ /**
517
+ * Mandatory closing-line contract for a synthesis rendered alongside a stance
518
+ * tally. The synthesis states its verdict as a machine-readable label rather
519
+ * than leaving it to be mined out of the recommendation prose — the same
520
+ * discipline `STANCE_LINE_CONTRACT` imposes on members.
521
+ */
522
+ export const VERDICT_LINE_CONTRACT = `Close the synthesis with EXACTLY this line, and nothing after it:
523
+ VERDICT: <option-label>
524
+ Use the SAME <option-label> the Vote Tally cleared. Use \`${SPLIT_VERDICT_LABEL}\` when no option cleared the threshold — an honest split is a verdict, and claiming a winner the tally did not record is a render error.`;
525
+
526
+ /**
527
+ * Parse the synthesis verdict line. Returns the LAST match (the final,
528
+ * most-authoritative one) or `null` when no well-formed line exists.
529
+ *
530
+ * `null` is a repair marker, never a guess: this function does not fall back to
531
+ * reading agreement out of the prose. That is the invariant `stance_tally`
532
+ * states for members ("the tally never infers a stance from the surrounding
533
+ * prose") and the one `check_finding_dispositions` states for the release
534
+ * ledger ("a comment is mutable and unaudited; it is transport, not a record").
535
+ */
536
+ export function parse_verdict_line(text: string): { label: string; display: string } | null {
537
+ _VERDICT_RE.lastIndex = 0;
538
+ let last: RegExpExecArray | null = null;
539
+ let m: RegExpExecArray | null;
540
+ while ((m = _VERDICT_RE.exec(text)) !== null) {
541
+ last = m;
542
+ }
543
+ if (last === null) {
544
+ return null;
545
+ }
546
+ const raw = (last[1] ?? '').trim();
547
+ if (raw.length === 0) {
548
+ return null;
549
+ }
550
+ // An angle-bracketed label is the CONTRACT's own placeholder, not a verdict.
551
+ // This matters on the templated path: with no chairman the rendered body IS
552
+ // `VERDICT_LINE_CONTRACT`'s template text, so a parser without this guard
553
+ // would read `<option-label>` as a claimed winner and throw on every
554
+ // un-summarised render. Same treatment `assert_synthesis_sections` gives a
555
+ // placeholder-empty section.
556
+ if (/^<.*>$/.test(raw)) {
557
+ return null;
558
+ }
559
+ return { label: raw.toLowerCase(), display: raw };
560
+ }
561
+
562
+ /**
563
+ * Validate a completed synthesis against the stance tally it was rendered
564
+ * beside: a verdict naming a winner the tally did not clear throws
565
+ * `SynthesisRenderError`, the same way a missing section does.
566
+ *
567
+ * The defect this closes: `assert_synthesis_sections` is a *shape* check, so a
568
+ * synthesis could report agreement over a tally that recorded dissent and
569
+ * nothing downstream disagreed. That is the class behind the recorded 9.14.0
570
+ * release failure, where the head read "Security and correctness: none" over a
571
+ * critical finding — `check_finding_dispositions.ts` is the same check on the
572
+ * release surface.
573
+ *
574
+ * **An absent verdict line does not throw.** It is a repair marker under the
575
+ * same rule as an unparseable stance: the fail-safe direction is to record that
576
+ * the claim was not made, never to infer one from prose. That also keeps every
577
+ * synthesis rendered before `VERDICT_LINE_CONTRACT` shipped green.
578
+ */
579
+ export function assert_synthesis_matches_tally(text: string, tally: TallyView): void {
580
+ const mismatch = describe_verdict_mismatch(text, tally);
581
+ if (mismatch !== null) {
582
+ throw new SynthesisRenderError(mismatch);
583
+ }
584
+ }
585
+
586
+ /** The slice of a `StanceTallyResult` the verdict check reads. */
587
+ export interface TallyView {
588
+ consensus: { label: string } | null;
589
+ split: boolean;
590
+ options: ReadonlyArray<{ label: string }>;
591
+ }
592
+
593
+ /**
594
+ * The same comparison as `assert_synthesis_matches_tally`, returning the
595
+ * mismatch as a string instead of throwing — `null` means "no contradiction".
596
+ *
597
+ * **Why both shapes exist.** Throwing is right for a caller holding a finished
598
+ * synthesis it can refuse. It is wrong on the render path: `render()` throws
599
+ * before the tally and the Convergence blocks are pushed, so a mismatch would
600
+ * destroy the ENTIRE artifact — every member response, the peer review, the
601
+ * quorum bookkeeping — *after* every provider call has already been paid for.
602
+ * The module's own answer for an unverifiable claim is the `needs_repair`
603
+ * marker `render_vote_tally` prints, not a discarded pass, and the roadmap had
604
+ * already recorded that unconditionally wiring the sibling shape check was
605
+ * unsafe for this same reason. So the render path surfaces the contradiction
606
+ * where a reader will see it and keeps the artifact.
607
+ */
608
+ export function describe_verdict_mismatch(text: string, tally: TallyView): string | null {
609
+ const verdict = parse_verdict_line(text);
610
+ if (verdict === null) {
611
+ return null; // repair marker, not a guess
612
+ }
613
+ // The reserved sense of `split` yields to a real option that carries the
614
+ // same label (R2 finding 8): the label vocabulary is free text mined from
615
+ // member `STANCE:` lines, so nothing stops an option genuinely called
616
+ // `split`, and when the tally clears it the CORRECT verdict would otherwise
617
+ // read as a contradiction. Deciding by whether the tally actually holds
618
+ // that option keeps one namespace without an escape syntax authors would
619
+ // have to learn.
620
+ const optionNamedSplit = tally.options.some(
621
+ (o) => o.label.toLowerCase() === SPLIT_VERDICT_LABEL,
622
+ );
623
+ const claimsSplit = verdict.label === SPLIT_VERDICT_LABEL && !optionNamedSplit;
624
+
625
+ if (tally.consensus === null) {
626
+ if (claimsSplit) {
627
+ return null;
628
+ }
629
+ // `split` is the tally's own word for this state; reading it here rather
630
+ // than re-deriving "no consensus ⇒ split" keeps the message honest when
631
+ // the tally is empty (zero options is not a split across zero options).
632
+ const how = tally.split
633
+ ? `split across ${tally.options.length} option(s)`
634
+ : 'no option-level stances parsed';
635
+ return (
636
+ `synthesis claims "${verdict.display}" but the tally recorded no consensus ` +
637
+ `(${how}); use \`${SPLIT_VERDICT_LABEL}\` or re-run the round`
638
+ );
639
+ }
640
+
641
+ if (claimsSplit) {
642
+ return `synthesis claims a split but the tally cleared "${tally.consensus.label}"`;
643
+ }
644
+ if (verdict.label !== tally.consensus.label.toLowerCase()) {
645
+ return (
646
+ `synthesis claims "${verdict.display}" but the tally cleared ` +
647
+ `"${tally.consensus.label}"`
648
+ );
649
+ }
650
+ return null;
651
+ }
652
+
491
653
  /**
492
654
  * Drop any *whole line* containing a host-agent identity substring.
493
655
  *
@@ -82,3 +82,33 @@ 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
+ * ONE such record now exists, and it is deliberately narrow: ADR-224
97
+ * (`docs/decisions/ADR-224-gate-scoped-solo-attendance-floor.md`) authorizes a
98
+ * `min_present: 2` floor to branch on this predicate **for gate-class passes
99
+ * only**, decided against a measured 12.5 % solo-conclusion rate. Nothing has
100
+ * landed yet — no caller branches on it today — so the sentence above still
101
+ * describes the code. It is stated here so a reader finds the authorization
102
+ * instead of re-deriving the prohibition, and so the next branch is visibly a
103
+ * separate argument: the exception is gate-class passes, not solo status
104
+ * generally.
105
+ *
106
+ * `total` is deliberately NOT consulted. A council configured with a single
107
+ * member concludes solo by construction, and that is still a conclusion
108
+ * reached on one voice — collapsing it into "not solo" would hide exactly
109
+ * the passes a solo-conclusion rate is measured to find. A consumer that
110
+ * cares about the distinction reads `result.total` alongside.
111
+ */
112
+ export function isSoloConcluded(result: QuorumResult): boolean {
113
+ return result.status === 'concluded' && result.present === 1;
114
+ }
@@ -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,
@@ -42,6 +42,7 @@ import * as os from 'node:os';
42
42
  import * as path from 'node:path';
43
43
 
44
44
  import { claude_config_dir, CODEX_MARKETPLACE_NAME, CODEX_PLUGIN_ID } from '../_lib/claude_plugin.js';
45
+ import { isEnvKillSwitchActive } from '../_lib/env_kill_switch.js';
45
46
  import { atomic_write_json } from '../hooks/state_io.js';
46
47
  import { AI_TEAM_DEFAULTS, load_ai_team_config, type AiTeamConfig } from './config.js';
47
48
 
@@ -170,8 +171,7 @@ export function circuit_breaker_notice(bound: number): string {
170
171
  // ── ledger (events-log convention) ──────────────────────────────────
171
172
 
172
173
  function _kill_switch_active(): boolean {
173
- const value = process.env[_KILL_SWITCH_ENV] ?? '';
174
- return !(value === '' || value === '0' || value === 'false' || value === 'False');
174
+ return isEnvKillSwitchActive(_KILL_SWITCH_ENV);
175
175
  }
176
176
 
177
177
  function _iso_seconds_z(d: Date): string {