@event4u/agent-config 9.34.0 → 9.35.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 (69) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +91 -0
  3. package/CONTRIBUTING.md +23 -14
  4. package/dist/agent-src/commands/roadmap/next.md +37 -5
  5. package/dist/agent-src/scripts/roadmap_gates.ts +119 -4
  6. package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +25 -0
  7. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  8. package/dist/agent-src/templates/scripts/memory_lookup.ts +1 -2
  9. package/dist/agent-src/templates/scripts/memory_signal.ts +2 -4
  10. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
  11. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
  12. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
  13. package/dist/cli/registry.js +2 -2
  14. package/dist/cli/registry.js.map +1 -1
  15. package/dist/cli-delegate/{chunk-REMEZSVP.js → chunk-2TGMGLBL.js} +2 -2
  16. package/dist/cli-delegate/{chunk-YPAWXXXR.js → chunk-55RI5UXK.js} +17 -0
  17. package/dist/cli-delegate/{chunk-MA4GFWDY.js → chunk-H6F2CQFI.js} +1 -1
  18. package/dist/cli-delegate/{chunk-UVKNIJH2.js → chunk-ZAXH3ZWU.js} +3 -0
  19. package/dist/cli-delegate/cmd_conformance.js +2 -2
  20. package/dist/cli-delegate/cmd_doctor.js +2 -2
  21. package/dist/cli-delegate/cmd_export.js +2 -2
  22. package/dist/cli-delegate/cmd_handoff.js +1 -1
  23. package/dist/cli-delegate/cmd_prune.js +2 -2
  24. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  25. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  26. package/dist/cli-delegate/cmd_sync.js +2 -2
  27. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  28. package/dist/cli-delegate/cmd_validate.js +2 -2
  29. package/dist/discovery/deprecation-report.md +1 -1
  30. package/dist/discovery/discovery-manifest.json +4 -4
  31. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  32. package/dist/discovery/discovery-manifest.summary.md +1 -1
  33. package/dist/discovery/dormancy-report.md +1 -1
  34. package/dist/discovery/orphan-report.md +1 -1
  35. package/dist/discovery/packs.json +2 -2
  36. package/dist/discovery/trust-report.md +1 -1
  37. package/dist/discovery/workspaces.json +2 -2
  38. package/dist/hooks/dispatch.js +259 -78
  39. package/dist/install/install.mjs +3 -0
  40. package/dist/mcp/registry-manifest.json +2 -2
  41. package/dist/mcp/server.json +2 -2
  42. package/dist/mcp/server.mjs +74 -2
  43. package/dist/server/schemas/settings.js +1 -0
  44. package/dist/server/schemas/settings.js.map +1 -1
  45. package/docs/contracts/settings-classes.md +3 -2
  46. package/package.json +1 -1
  47. package/src/agent-src/scripts/roadmap_gates.ts +119 -4
  48. package/src/agent-src/templates/scripts/memory_lookup.ts +1 -2
  49. package/src/agent-src/templates/scripts/memory_signal.ts +2 -4
  50. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
  51. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
  52. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
  53. package/src/config/agent-settings.template.yml +11 -2
  54. package/src/config/quorum-attendance-budget.json +10 -2
  55. package/src/scripts/_dispatch.bash +6 -2
  56. package/src/scripts/_lib/session_register.ts +67 -1
  57. package/src/scripts/ai_council/config.ts +60 -0
  58. package/src/scripts/ai_council/events_log.ts +103 -4
  59. package/src/scripts/ai_council/quorum.ts +83 -6
  60. package/src/scripts/council_cli.ts +36 -1
  61. package/src/scripts/hooks/turn_end_gate_hook.ts +172 -11
  62. package/src/scripts/lint_framework_leakage.ts +195 -28
  63. package/src/scripts/lint_framework_leakage_allowlist.json +94 -86
  64. package/src/scripts/memory_lookup.ts +1 -2
  65. package/src/scripts/schemas/command.schema.json +4 -0
  66. package/src/scripts/schemas/skill.schema.json +4 -0
  67. package/src/scripts/session_register_hook.ts +238 -18
  68. package/src/scripts/sessions_cli.ts +377 -12
  69. package/src/server/schemas/settings.ts +3 -0
@@ -50,7 +50,16 @@
50
50
  * longer than the TTL means another session may claim your branch.
51
51
  * 2. **This is advisory, not a mutex.** Two sessions can claim in the same
52
52
  * millisecond. Nothing here provides exclusion, and no later feature may be
53
- * built on it as if it did.
53
+ * built on it as if it did. `sessions:claim` refusing to WRITE a claim a peer
54
+ * holds is a consistency check on its own write, not exclusion: it protects
55
+ * the record from being false, never the peer from being duplicated.
56
+ * 3. **The claim is per SESSION, and the bridge file has to be too.** The
57
+ * paragraph above rules `agents/runtime/state/` out for the register and the
58
+ * same argument applies to the claim that feeds it: one shared file per
59
+ * checkout means a second session in the same directory reads the first one's
60
+ * claim as its own. Measured — four live records carrying one identical,
61
+ * already-archived slug. The file is keyed on the host session id where one
62
+ * exists, and degrades to the shared path where none does.
54
63
  *
55
64
  * ## Failure is always open
56
65
  *
@@ -359,3 +368,60 @@ export function foreign_live_records(
359
368
  const own = safe_stem(session_id);
360
369
  return read_live_records(dir, options).filter((r) => safe_stem(r.session_id) !== own);
361
370
  }
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // Collisions — and why `branch` alone was the wrong axis
374
+ // ---------------------------------------------------------------------------
375
+
376
+ /**
377
+ * What two live sessions are colliding over.
378
+ *
379
+ * `branch` was the only axis this register checked, and it is the CHEAP one: two
380
+ * sessions on one branch see each other immediately, and coordinating is a
381
+ * `git worktree add`. `roadmap` is the expensive one and went unchecked —
382
+ * measured twice (PR #1277/#1280, PR #1280/#1281), both times two sessions built
383
+ * the SAME roadmap phase under DIFFERENT branch names
384
+ * (`feat/dispatch-safety-confirmation` vs `feat/dispatch-safety-confirmed-execution`),
385
+ * so the branch comparison was silent by construction while a whole
386
+ * implementation was duplicated. One of the two PRs is thrown away either way.
387
+ */
388
+ export type CollisionKind = 'roadmap' | 'branch';
389
+
390
+ export interface Collision {
391
+ kind: CollisionKind;
392
+ record: SessionRecord;
393
+ }
394
+
395
+ /**
396
+ * Classify what this session collides with, roadmap-first.
397
+ *
398
+ * Ordering is the finding, not a preference: a roadmap collision means the work
399
+ * is being done twice and one branch is already wasted, while a branch collision
400
+ * means two sessions can see each other's commits. Reporting the cheap one first
401
+ * is what let the expensive one pass unremarked.
402
+ *
403
+ * A `null` slug never collides — "no roadmap claimed" is the state of every
404
+ * session before it picks one, and treating absence as a match would make the
405
+ * warning fire on every pair of fresh sessions and be switched off.
406
+ */
407
+ export function classify_collisions(
408
+ others: readonly SessionRecord[],
409
+ here: { branch: string | null; roadmap_slug: string | null },
410
+ ): Collision[] {
411
+ const out: Collision[] = [];
412
+ for (const r of others) {
413
+ if (
414
+ here.roadmap_slug !== null &&
415
+ r.roadmap_slug !== null &&
416
+ r.roadmap_slug === here.roadmap_slug
417
+ ) {
418
+ out.push({ kind: 'roadmap', record: r });
419
+ }
420
+ }
421
+ for (const r of others) {
422
+ if (here.branch !== null && r.branch === here.branch) {
423
+ out.push({ kind: 'branch', record: r });
424
+ }
425
+ }
426
+ return out;
427
+ }
@@ -52,6 +52,11 @@
52
52
  * single absent member into a deadlocked release gate (council-verified
53
53
  * 2026-08-09). See `quorum.ts` for the resolver a caller applies to its
54
54
  * own present/total member counts.
55
+ * 9b. `quorum_min_present` ∈ positive integers (default 2, ADR-224). The
56
+ * floor a gate-class pass would want — SHADOW ONLY: it is recorded per
57
+ * pass and enforced nowhere, so no value of this key can hold, delay or
58
+ * fail a council pass today. See `quorum.ts::wouldSoloFloorHold` for why
59
+ * the measurement landed without the enforcement.
55
60
  *
56
61
  * Parity notes (intentional, documented):
57
62
  * - YAML is parsed with `yaml` (npm) at `version: '1.1'`, matching
@@ -71,6 +76,12 @@ import * as path from 'node:path';
71
76
  import { parse as parseYaml } from 'yaml';
72
77
 
73
78
  import * as user_global_paths from '../_lib/user_global_paths.js';
79
+ // LAYERING CONSTRAINT, load-bearing: this is a VALUE import from quorum, while
80
+ // `quorum.ts` imports `QuorumSetting` back from here. That back-edge is
81
+ // `import type` and is erased at build, so there is no runtime cycle today.
82
+ // Converting it to a value import — or adding any other runtime import from
83
+ // quorum into config — creates a real ESM cycle in the loader's init path.
84
+ import { SOLO_FLOOR_MIN_PRESENT } from './quorum.js';
74
85
 
75
86
  const _VALID_PROVIDERS: ReadonlySet<string> = new Set([
76
87
  'anthropic',
@@ -585,6 +596,25 @@ export interface CliCallBudgetConfig {
585
596
  */
586
597
  export type QuorumSetting = 'majority' | number;
587
598
 
599
+ /**
600
+ * The `min_present` floor ADR-224 authorized, as an operator-visible key.
601
+ *
602
+ * A SIBLING key rather than a widened `QuorumSetting`, deliberately.
603
+ * `QuorumSetting` is consumed by `resolveQuorumThreshold`, `evaluateQuorum`,
604
+ * `_quorum_setting_from` and their tests; widening it into a union with an
605
+ * object would touch every one of them to express a value none of them read.
606
+ * The floor is not a second threshold — `quorum` answers "did enough members
607
+ * answer to conclude", the floor answers "was that conclusion reached on too
608
+ * few voices to gate on", and they resolve at different call sites.
609
+ *
610
+ * Validated at load like every other key; defaults to
611
+ * `quorum.ts::SOLO_FLOOR_MIN_PRESENT` so the shadow fire-rate accumulates
612
+ * without the operator having to opt in — an unset floor that recorded nothing
613
+ * would leave ADR-224's review trigger (b) with no data to trigger on. Nothing
614
+ * is enforced at any value: see `wouldSoloFloorHold`.
615
+ */
616
+ export type QuorumMinPresent = number;
617
+
588
618
  export interface CouncilConfig {
589
619
  readonly enabled: boolean;
590
620
  readonly defaults: DefaultsConfig;
@@ -594,6 +624,7 @@ export interface CouncilConfig {
594
624
  readonly consensus_scoring: ConsensusScoringConfig;
595
625
  readonly cli_call_budget: CliCallBudgetConfig;
596
626
  readonly quorum: QuorumSetting;
627
+ readonly quorum_min_present: QuorumMinPresent;
597
628
  readonly necessity_classifier: NecessityClassifierConfig;
598
629
  readonly model_downgrade: ModelDowngradeConfig;
599
630
  readonly debate: DebateConfig;
@@ -822,6 +853,9 @@ export function _build_config(raw: Dict, source_path: PathLike): CouncilConfig {
822
853
  _asDict(_getOr(raw, 'cli_call_budget', {})),
823
854
  );
824
855
  const quorum = _build_quorum(_get(raw, 'quorum', 'majority'));
856
+ const quorum_min_present = _build_quorum_min_present(
857
+ _get(raw, 'quorum_min_present', SOLO_FLOOR_MIN_PRESENT),
858
+ );
825
859
  const necessity_classifier = _build_necessity_classifier(
826
860
  _asDict(_getOr(raw, 'necessity_classifier', {})),
827
861
  );
@@ -871,6 +905,7 @@ export function _build_config(raw: Dict, source_path: PathLike): CouncilConfig {
871
905
  consensus_scoring: consensus,
872
906
  cli_call_budget,
873
907
  quorum,
908
+ quorum_min_present,
874
909
  necessity_classifier,
875
910
  model_downgrade,
876
911
  debate,
@@ -1905,6 +1940,31 @@ function _build_quorum(raw: Json): QuorumSetting {
1905
1940
  );
1906
1941
  }
1907
1942
 
1943
+ /**
1944
+ * Validate `quorum_min_present` — the shadow floor's configured value.
1945
+ *
1946
+ * Rejects the same shapes `_build_quorum` rejects, and for the same reason: a
1947
+ * silently coerced floor produces a fire-rate that is an artefact of the
1948
+ * coercion rather than of the council. `_isInt` already requires
1949
+ * `typeof value === 'number'`, so booleans are excluded by it — the sibling's
1950
+ * extra `!_isBool` conjunct is a Python-ism (there `bool` really is an `int`
1951
+ * subclass) and is not repeated here.
1952
+ *
1953
+ * There is no upper bound — a floor above the roster is clamped per-pass by
1954
+ * `wouldSoloFloorHold`, where the roster is actually known, and rejecting it
1955
+ * at load would refuse a config that is legitimate the moment a member is
1956
+ * added.
1957
+ */
1958
+ function _build_quorum_min_present(raw: Json): QuorumMinPresent {
1959
+ if (_isInt(raw) && (raw as number) >= 1) {
1960
+ return raw as number;
1961
+ }
1962
+ throw new CouncilConfigError(
1963
+ `\`quorum_min_present\`=${_pyRepr(raw)} must be an integer >= 1 ` +
1964
+ `(got ${_pyTypeName(raw)}).`,
1965
+ );
1966
+ }
1967
+
1908
1968
  function _build_advisor(name: string, cfg: Dict): AdvisorConfig {
1909
1969
  if (!_isDict(cfg)) {
1910
1970
  throw new CouncilConfigError(`advisors.${name}: must be a mapping.`);
@@ -6,8 +6,9 @@
6
6
  * `<project_root>/agents/runtime/council/events.log`. The schema carries the
7
7
  * minimum needed to answer "why did the council skip / block this?" and,
8
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
+ * content. v2 added the `quorum_result` action and nothing else; v3 added two
10
+ * booleans to that line (`gate_class`, `floor_would_hold`) and nothing else.
11
+ * See `appendQuorumEvent`.
11
12
  *
12
13
  * Privacy floor:
13
14
  * `original_ask` is never written verbatim — the caller passes the raw
@@ -28,7 +29,12 @@ import * as path from 'node:path';
28
29
  import { fileURLToPath } from 'node:url';
29
30
 
30
31
  import { isEnvKillSwitchActive } from '../_lib/env_kill_switch.js';
31
- import { isSoloConcluded, type QuorumResult } from './quorum.js';
32
+ import {
33
+ isSoloConcluded,
34
+ SOLO_FLOOR_MIN_PRESENT,
35
+ wouldSoloFloorHold,
36
+ type QuorumResult,
37
+ } from './quorum.js';
32
38
  import type { AbsentReason } from './transport_resolver.js';
33
39
 
34
40
  /**
@@ -36,8 +42,29 @@ import type { AbsentReason } from './transport_resolver.js';
36
42
  * `appendQuorumEvent`. Every schema-v1 field keeps its name, type and
37
43
  * position, so a v1 reader parses a v2 necessity line unchanged; only the
38
44
  * action vocabulary widened.
45
+ *
46
+ * v3 adds two booleans to the `quorum_result` line — `gate_class` (declared
47
+ * by the caller) and `floor_would_hold` (the ADR-224 counterfactual). Purely
48
+ * additive, in the same sense v2 was: no field is renamed, retyped or moved,
49
+ * no action is added, and a v2 reader parses a v3 line unchanged because it
50
+ * reads fields by name and never saw these two. The version moves anyway, so
51
+ * a consumer computing the shadow fire-rate can tell a line that recorded
52
+ * `false` from a line written before the field existed — a distinction it
53
+ * cannot make from the absent field alone.
54
+ *
55
+ * v4 adds `min_present` to the same line: the floor value `floor_would_hold`
56
+ * was actually computed against. Additive in the v2/v3 sense — no field is
57
+ * renamed, retyped or moved — and it closes a provenance hole rather than
58
+ * adding a capability. `quorum_min_present` is an operator-facing key in the
59
+ * **user-global** `.ai-council.yml` (ADR-104), so before v4 two machines
60
+ * emitted byte-identical lines while measuring different counterfactuals, and
61
+ * a configured `quorum_min_present: 1` — valid, and provably unable to hold
62
+ * anything — zeroed `shadow_floor_fire_rate` with no trace on the data. A
63
+ * boolean whose meaning depends on an unrecorded per-machine value is not a
64
+ * measurement; recording the value is what makes the series comparable and a
65
+ * zero reading attributable.
39
66
  */
40
- export const SCHEMA_VERSION = 2;
67
+ export const SCHEMA_VERSION = 4;
41
68
 
42
69
  export type EventAction = 'proceed' | 'skip_necessity' | 'block_quota' | 'quorum_result';
43
70
 
@@ -286,6 +313,42 @@ export interface QuorumEventInput {
286
313
  readonly configuredTotal: number;
287
314
  readonly result: QuorumResult;
288
315
  readonly absent: readonly QuorumAbsence[];
316
+ /**
317
+ * Did the CALLER declare this pass gate-class? Declared, never inferred,
318
+ * and it defaults to `false` — the fail-safe direction, because an
319
+ * un-declared pass is the one nobody classified and the cost of reading it
320
+ * as advisory is a missing measurement rather than a held gate.
321
+ *
322
+ * Inference from the invocation context was the rejected alternative: it
323
+ * silently reclassifies passes whenever the context shape changes, which
324
+ * is the one failure a rate computed over this field could not detect.
325
+ * `command` + `phase` remain on the line, so a consumer that wants the
326
+ * inferred reading can still compute it and see it disagree.
327
+ *
328
+ * **No producer exists today, by design, and that is not a wiring bug.**
329
+ * Nothing in the tree branches on `QuorumStatus` to hold a gate, so there
330
+ * is no gate-class caller to mark; `council_cli.ts::_emitQuorumEvent`
331
+ * therefore omits it from its context object deliberately rather than by
332
+ * oversight, and `gate_class` is `false` on every line the tree can emit.
333
+ * The parameter is kept because the first consumer that holds something on
334
+ * quorum status IS by construction the first gate-class caller: it sets
335
+ * this, and `shadow_floor_fire_rate` filtered on it becomes the
336
+ * enforcement-relevant rate. A field introduced only at that point would
337
+ * leave every earlier line ambiguous between "not gate-class" and "written
338
+ * before anyone asked".
339
+ */
340
+ readonly gateClass?: boolean;
341
+ /**
342
+ * The floor to evaluate the counterfactual against — the operator's
343
+ * `quorum_min_present`, defaulting to `SOLO_FLOOR_MIN_PRESENT`.
344
+ *
345
+ * Written to the line as `min_present` since schema v4, not only consumed:
346
+ * it comes from the user-global config (ADR-104), so without it on the line
347
+ * a reader cannot tell which counterfactual a `floor_would_hold` refers to,
348
+ * and cannot distinguish "the floor never would have fired" from "this
349
+ * operator configured a floor that cannot fire".
350
+ */
351
+ readonly minPresent?: number;
289
352
  }
290
353
 
291
354
  /**
@@ -310,6 +373,10 @@ export function appendQuorumEvent(
310
373
  opts: AppendEventOptions = {},
311
374
  ): boolean {
312
375
  try {
376
+ // The same default `wouldSoloFloorHold` would have applied, hoisted so
377
+ // the recorded value and the evaluated value are one expression rather
378
+ // than two that happen to agree.
379
+ const minPresent = input.minPresent ?? SOLO_FLOOR_MIN_PRESENT;
313
380
  return appendEvent(
314
381
  {
315
382
  lens: input.lens,
@@ -328,6 +395,38 @@ export function appendQuorumEvent(
328
395
  // in `quorum.ts`, so a change to it cannot leave a consumer's
329
396
  // copy silently stale.
330
397
  solo: isSoloConcluded(input.result),
398
+ // Declared class, never inferred — see `QuorumEventInput`.
399
+ gate_class: input.gateClass ?? false,
400
+ // The counterfactual, written on every line: would a
401
+ // `min_present` floor have held this pass? Recorded as a FIELD
402
+ // on `quorum_result` rather than as a new action, deliberately:
403
+ // a new action is invisible to every consumer filtering
404
+ // `action === 'quorum_result'`, which would split the
405
+ // attendance population in two and silently move the
406
+ // denominator of all four registered metrics. An additive
407
+ // boolean moves no existing bucket.
408
+ //
409
+ // Mutually exclusive with `verdict: 'inconclusive'` by
410
+ // construction (`wouldSoloFloorHold` returns false unless the
411
+ // pass concluded), which is what makes "held by the floor" and
412
+ // "threshold not met" readable apart from this line alone.
413
+ // `configuredTotal` is passed, not omitted: post_run `total`
414
+ // is the roster that CONSTRUCTED, so without it a pass that
415
+ // lost a member at construction time reads total=1/present=1
416
+ // and the floor cannot fire on it — the construction-degraded
417
+ // solo case ADR-224 was actually decided on.
418
+ //
419
+ // `min_present` records the floor the counterfactual was
420
+ // computed against, resolved ONCE below and used for both the
421
+ // record and the predicate so the line can never disagree with
422
+ // the boolean it explains — the one-definition discipline
423
+ // `solo` follows by deferring to `isSoloConcluded`.
424
+ min_present: minPresent,
425
+ floor_would_hold: wouldSoloFloorHold(
426
+ input.result,
427
+ minPresent,
428
+ input.configuredTotal,
429
+ ),
331
430
  absent: input.absent.map((a) => ({ member: a.member, reason: a.reason })),
332
431
  },
333
432
  opts,
@@ -96,12 +96,13 @@ export function evaluateQuorum(
96
96
  * ONE such record now exists, and it is deliberately narrow: ADR-224
97
97
  * (`docs/decisions/ADR-224-gate-scoped-solo-attendance-floor.md`) authorizes a
98
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.
99
+ * only**, decided against a measured 12.5 % solo-conclusion rate. What landed
100
+ * from that authorization is `wouldSoloFloorHold` below, which is a
101
+ * counterfactual and branches nothing — so the sentence above still describes
102
+ * the code: no caller holds a pass on solo status today. It is stated here so
103
+ * a reader finds the authorization instead of re-deriving the prohibition, and
104
+ * so the next branch is visibly a separate argument: the exception is
105
+ * gate-class passes, not solo status generally.
105
106
  *
106
107
  * `total` is deliberately NOT consulted. A council configured with a single
107
108
  * member concludes solo by construction, and that is still a conclusion
@@ -112,3 +113,79 @@ export function evaluateQuorum(
112
113
  export function isSoloConcluded(result: QuorumResult): boolean {
113
114
  return result.status === 'concluded' && result.present === 1;
114
115
  }
116
+
117
+ /**
118
+ * The floor value ADR-224 named: a gate-class pass wants at least two voices.
119
+ *
120
+ * A constant rather than a bare `2` at the call site so the shadow telemetry
121
+ * below and any future enforcement read the same number, and so changing it
122
+ * is one edit with one place to argue about.
123
+ */
124
+ export const SOLO_FLOOR_MIN_PRESENT = 2;
125
+
126
+ /**
127
+ * WOULD a `min_present` floor have held this pass? Counterfactual only — this
128
+ * predicate changes nothing and no caller branches on it to hold a gate.
129
+ *
130
+ * ADR-224 chose a gate-scoped `min_present: 2` floor against a solo-conclusion
131
+ * rate of 1 of 8 passes, whose 95 % interval spans roughly 0.3 %–53 %. Two
132
+ * facts, both read off the tree rather than assumed, decided that what lands
133
+ * first is the measurement and not the enforcement:
134
+ *
135
+ * - **Nothing in the tree branches on `QuorumStatus` to hold anything.** The
136
+ * only non-`quorum.ts` reader of `'inconclusive'` is
137
+ * `council_cli.ts::_deserialise_quorum`, which validates a persisted string.
138
+ * An enforced floor would therefore change zero behaviour today — there is
139
+ * no gate for it to hold — while still being able to hang an advisory pass
140
+ * the moment a consumer appears.
141
+ * - **ADR-224's own review trigger (b) is "the gate-class floor lands and its
142
+ * own fire-rate telemetry accumulates"**, which presumes exactly this
143
+ * counterfactual is being recorded.
144
+ *
145
+ * So the floor is evaluated on EVERY pass and recorded, never applied. The
146
+ * fire-rate that accumulates from it is what makes the enforcement decision an
147
+ * evidence call later instead of a second point estimate.
148
+ *
149
+ * `minPresent` is clamped with the same discipline `resolveQuorumThreshold`
150
+ * applies, and the clamp carries meaning rather than only safety: where the
151
+ * ceiling resolves to 1 the floor never fires, so a council **configured**
152
+ * with one member is not counted as a degraded pass. That is the split
153
+ * `quorum-attendance-budget.json` already insists on for the solo-conclusion
154
+ * rate — a one-member roster and a two-member roster that lost one are
155
+ * different findings, and an unclamped floor would merge them.
156
+ *
157
+ * **The ceiling is the larger of `result.total` and `configuredTotal`, and it
158
+ * has to be.** Clamping against `result.total` alone was the first version of
159
+ * this function and it was structurally unable to fire on the exact case
160
+ * ADR-224 was decided on: `_postRunQuorum` computes `total` over the roster
161
+ * that CONSTRUCTED, so a two-member council where one member fails to
162
+ * construct reads `total = 1, present = 1`, the ceiling collapses to 1, and a
163
+ * conclusion reached on one of two configured voices records as "the floor
164
+ * would not have held". The series review trigger (b) accumulates would have
165
+ * been biased low by construction — a metric blind to its own target
166
+ * population. `configured_total` exists on the event line precisely to keep
167
+ * that pass distinguishable, and the fix is to consult it.
168
+ *
169
+ * `max`, not `configuredTotal` outright: `total > configuredTotal` is
170
+ * legitimate and not a shortfall, because `--siblings` fans one enabled config
171
+ * ENTRY into N clients. `configuredTotal` counts entries, `total` counts
172
+ * clients, and the honest ceiling is the largest roster the pass could have
173
+ * drawn on either way.
174
+ *
175
+ * A pass that never reached `concluded` is not held by the floor: it failed
176
+ * the threshold, which is a different outcome with a different cause. The two
177
+ * are mutually exclusive by construction, which is what makes them readable
178
+ * apart from the event log alone.
179
+ */
180
+ export function wouldSoloFloorHold(
181
+ result: QuorumResult,
182
+ minPresent: number = SOLO_FLOOR_MIN_PRESENT,
183
+ configuredTotal?: number,
184
+ ): boolean {
185
+ if (result.status !== 'concluded') {
186
+ return false;
187
+ }
188
+ const ceiling = Math.max(result.total, configuredTotal ?? result.total);
189
+ const floor = Math.min(Math.max(Math.trunc(minPresent), 1), ceiling);
190
+ return result.present < floor;
191
+ }
@@ -76,7 +76,7 @@ import { AuthCache, select_solo_member } from './ai_council/solo_dispatch.js';
76
76
  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
- import { evaluateQuorum, type QuorumResult } from './ai_council/quorum.js';
79
+ import { evaluateQuorum, SOLO_FLOOR_MIN_PRESENT, type QuorumResult } from './ai_council/quorum.js';
80
80
  import {
81
81
  appendEvent,
82
82
  appendQuorumEvent,
@@ -525,6 +525,12 @@ function _synthesize_ai_council_block(cfg: CouncilConfig): Dict {
525
525
  // configured `quorum: <k>`. Additive key — callers that never read
526
526
  // it observe no change.
527
527
  quorum: cfg.quorum,
528
+ // Forwarded for the same reason `quorum` above had to be: a key that
529
+ // `_build_config` validates and this block drops is a key the loader
530
+ // enforces and the runtime never sees. That defect shipped once here
531
+ // already; repeating it for the shadow floor would silently pin every
532
+ // `floor_would_hold` to the default no matter what the operator set.
533
+ quorum_min_present: cfg.quorum_min_present,
528
534
  necessity_classifier: {
529
535
  enabled: cfg.necessity_classifier.enabled,
530
536
  mode: cfg.necessity_classifier.mode,
@@ -647,6 +653,25 @@ function _quorum_setting_from(ai: Dict): QuorumSetting {
647
653
  return 'majority';
648
654
  }
649
655
 
656
+ /**
657
+ * `ai['quorum_min_present']` with the same fail-soft posture as
658
+ * `_quorum_setting_from`: the loader has already validated it on the normal
659
+ * path, and a hand-built dict that bypasses the loader falls back to the
660
+ * ADR-224 default rather than throwing.
661
+ *
662
+ * Failing soft costs nothing on the safety side here, and less than it does
663
+ * for `quorum`: this value feeds a counterfactual boolean that no gate reads,
664
+ * so the worst outcome of a wrong fallback is one mis-recorded telemetry line,
665
+ * never a pass that concluded when it should not have.
666
+ */
667
+ function _quorum_min_present_from(ai: Dict): number {
668
+ const raw = ai['quorum_min_present'];
669
+ if (typeof raw === 'number' && Number.isInteger(raw) && raw >= 1) {
670
+ return raw;
671
+ }
672
+ return SOLO_FLOOR_MIN_PRESENT;
673
+ }
674
+
650
675
  /**
651
676
  * Emit one `quorum_result` line for a resolved quorum.
652
677
  *
@@ -674,6 +699,13 @@ function _emitQuorumEvent(
674
699
  configuredTotal?: number;
675
700
  lens?: string;
676
701
  invocation?: string;
702
+ /**
703
+ * The ADR-224 shadow floor for this pass. `gateClass` is deliberately
704
+ * NOT in this context object: no CLI path declares itself gate-class,
705
+ * so a parameter here would be one no caller ever sets, and its
706
+ * absence is what the `false` on every line honestly records.
707
+ */
708
+ minPresent?: number;
677
709
  },
678
710
  ): void {
679
711
  appendQuorumEvent({
@@ -684,6 +716,7 @@ function _emitQuorumEvent(
684
716
  dispatch: ctx.dispatch ?? 'full',
685
717
  configuredTotal: ctx.configuredTotal ?? quorum.total,
686
718
  result: quorum,
719
+ ...(ctx.minPresent !== undefined ? { minPresent: ctx.minPresent } : {}),
687
720
  absent: absent.map(
688
721
  (a): QuorumAbsence => ({
689
722
  member: String(a['member'] ?? ''),
@@ -1009,6 +1042,7 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
1009
1042
  // reach this line and neither is a pass.
1010
1043
  _emitQuorumEvent('pre_run', quorum_out.result, absent_entries, {
1011
1044
  command: opts.command ?? 'run',
1045
+ minPresent: _quorum_min_present_from(ai),
1012
1046
  });
1013
1047
  }
1014
1048
  if (members.length === 0) {
@@ -2646,6 +2680,7 @@ function cmd_run(
2646
2680
  // and the solo-conclusion rate cannot make the one distinction it
2647
2681
  // exists for.
2648
2682
  dispatch: dispatch_shape,
2683
+ minPresent: _quorum_min_present_from(ai_cfg),
2649
2684
  ...(configured_total !== null ? { configuredTotal: configured_total } : {}),
2650
2685
  lens: question.mode,
2651
2686
  invocation: String(_getattr(args, 'invocation', 'agent')),