@event4u/agent-config 9.28.0 → 9.30.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/AGENTS.md +6 -6
- package/CHANGELOG.md +172 -5
- package/README.md +2 -1
- package/agents/templates/.ai-council.yml.example +55 -24
- package/dist/agent-src/commands/analyze/inbox.md +22 -1
- package/dist/agent-src/commands/cost/report.md +2 -2
- package/dist/agent-src/commands/explain-run.md +31 -1
- package/dist/agent-src/commands/team/adversarial.md +8 -7
- package/dist/agent-src/commands/team/delegate.md +14 -12
- package/dist/agent-src/commands/team/review.md +9 -8
- package/dist/agent-src/commands/team/status.md +9 -8
- package/dist/agent-src/commands/team.md +29 -19
- package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +88 -2
- package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +29 -21
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +26 -15
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +22 -1
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +17 -0
- package/dist/agent-src/contexts/execution/subagent-response-contract.md +65 -1
- package/dist/agent-src/contexts/execution/subagent-routing.md +13 -11
- package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +25 -0
- package/dist/agent-src/contexts/execution/subagent-steering.md +6 -3
- package/dist/agent-src/rules/delegation-policy.md +11 -7
- package/dist/agent-src/rules/preservation-guard.md +9 -0
- package/dist/agent-src/rules/settings-ask-protocol.md +8 -7
- package/dist/agent-src/rules/token-budget-discipline.md +1 -1
- package/dist/agent-src/scripts/roadmap_gates.ts +312 -0
- package/dist/agent-src/scripts/update_roadmap_progress.ts +72 -4
- package/dist/agent-src/skills/forensics-report/SKILL.md +114 -0
- package/dist/agent-src/skills/forensics-report/evals/triggers.json +17 -0
- package/dist/agent-src/skills/premortem/SKILL.md +30 -1
- package/dist/agent-src/skills/reasoning-orchestrator/SKILL.md +2 -1
- package/dist/agent-src/skills/subagent-orchestration/SKILL.md +17 -5
- package/dist/agent-src/skills/subagent-orchestration/schemas/subagent-status.json +61 -3
- package/dist/agent-src/skills/test-case-discovery/SKILL.md +1 -1
- package/dist/agent-src/templates/AGENTS.md +4 -4
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +23 -0
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-22GCIJ2O.js → chunk-66OVWGB5.js} +1 -1
- package/dist/cli-delegate/{chunk-7OMQCH4V.js → chunk-BTJNRUX3.js} +22 -35
- package/dist/cli-delegate/{chunk-DVRS2KVR.js → chunk-DDUNNFYX.js} +24 -17
- package/dist/cli-delegate/{chunk-PQSAQKHW.js → chunk-EQY4FLJB.js} +3 -3
- package/dist/cli-delegate/{chunk-2BE5I2MN.js → chunk-IYTGIG2Y.js} +155 -91
- package/dist/cli-delegate/{chunk-GB2FSUEE.js → chunk-LCFJIQ4J.js} +32 -0
- package/dist/cli-delegate/{chunk-VSYT7OHO.js → chunk-LVRYBKTI.js} +31 -2
- package/dist/cli-delegate/chunk-MMJL4TIX.js +18 -0
- package/dist/cli-delegate/{chunk-JXARCGVN.js → chunk-MP3JT3IU.js} +1 -1
- package/dist/cli-delegate/chunk-O3CO5BFB.js +161 -0
- package/dist/cli-delegate/{chunk-REKCMYOU.js → chunk-PFBV6XRT.js} +36 -20
- package/dist/cli-delegate/chunk-RVGBH4XK.js +142 -0
- package/dist/cli-delegate/cmd_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +1 -1
- package/dist/cli-delegate/cmd_conformance.js +2803 -137
- package/dist/cli-delegate/cmd_doctor.js +10 -5
- package/dist/cli-delegate/cmd_explain.js +2 -2
- package/dist/cli-delegate/cmd_export.js +4 -4
- package/dist/cli-delegate/cmd_handoff.js +88 -8
- package/dist/cli-delegate/cmd_mcp_available.js +1 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_packs_active.js +2 -2
- package/dist/cli-delegate/cmd_prune.js +4 -4
- package/dist/cli-delegate/cmd_route_audit.js +3 -2
- package/dist/cli-delegate/cmd_route_explain.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +177 -0
- package/dist/cli-delegate/cmd_settings_get.js +2 -8
- package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
- package/dist/cli-delegate/cmd_settings_set.js +2 -2
- package/dist/cli-delegate/cmd_sync.js +4 -4
- package/dist/cli-delegate/cmd_uninstall.js +4 -4
- package/dist/cli-delegate/cmd_update.js +1 -1
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +4 -4
- package/dist/cli-delegate/cmd_versions.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +72 -23
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -2
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +39 -2
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +12 -4
- package/dist/hooks/dispatch.js +2278 -611
- package/dist/install/install.mjs +56 -17
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +4122 -699
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/agent_settings.js +54 -0
- package/dist/scripts/_lib/agent_settings.js.map +1 -1
- package/dist/scripts/condense.js +147 -2
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/schemas/settings.js +12 -7
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/shared/settingsCarveOut.js +0 -9
- package/dist/shared/settingsCarveOut.js.map +1 -1
- package/dist/shared/settingsClasses.js +11 -9
- package/dist/shared/settingsClasses.js.map +1 -1
- package/dist/ui/assets/index-B5qGYOJo.js +26 -0
- package/dist/ui/assets/index-B5qGYOJo.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/docs/contracts/settings-classes.md +58 -12
- package/package.json +1 -1
- package/src/agent-src/scripts/roadmap_gates.ts +312 -0
- package/src/agent-src/scripts/update_roadmap_progress.ts +72 -4
- package/src/agent-src/templates/AGENTS.md +4 -4
- package/src/config/agent-settings.template.yml +88 -66
- package/src/config/discovery/packs.yml +11 -0
- package/src/config/discovery/workspaces.yml +1 -1
- package/src/config/dispatch-economy-metrics.json +61 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-violation-baselines.json +3 -3
- package/src/config/hook-token-budget.json +91 -0
- package/src/config/recycle-threshold-budget.json +12 -0
- package/src/config/rule-enforcement-baseline.json +86 -0
- package/src/scripts/_cli/cmd_conformance.ts +31 -44
- package/src/scripts/_cli/cmd_doctor.ts +65 -48
- package/src/scripts/_cli/cmd_session_recycle.ts +226 -0
- package/src/scripts/_dispatch.bash +23 -0
- package/src/scripts/_lib/agent_settings.ts +58 -0
- package/src/scripts/_lib/auto_dispatch.corpus.test.ts +1 -1
- package/src/scripts/_lib/auto_dispatch.ts +23 -18
- package/src/scripts/_lib/capsule_trigger.ts +124 -0
- package/src/scripts/_lib/host_capability.ts +63 -6
- package/src/scripts/_lib/judgment_ladder.ts +634 -0
- package/src/scripts/_lib/orchestration_gate.ts +8 -0
- package/src/scripts/_lib/orchestration_record.ts +78 -10
- package/src/scripts/_lib/orchestration_savings.ts +1 -1
- package/src/scripts/_lib/recycle_envelope_paths.ts +35 -0
- package/src/scripts/_lib/self_repair.ts +354 -13
- package/src/scripts/_lib/self_repair_store.ts +28 -0
- package/src/scripts/_lib/session_eol.ts +230 -0
- package/src/scripts/_lib/session_role.ts +42 -0
- package/src/scripts/_lib/subagent_capsule.ts +341 -0
- package/src/scripts/_lib/subagent_response.ts +94 -0
- package/src/scripts/_lib/subagent_spawn.ts +13 -0
- package/src/scripts/_lib/subagent_steering.ts +9 -4
- package/src/scripts/_lib/tier_budget_routing.ts +6 -1
- package/src/scripts/_lib/worker_budget.ts +37 -2
- package/src/scripts/ai_council/clients.ts +126 -55
- package/src/scripts/ai_council/config.ts +94 -6
- package/src/scripts/ai_council/handoff.ts +113 -0
- package/src/scripts/ai_council/modes.ts +19 -4
- package/src/scripts/ai_council/orchestrator.ts +105 -0
- package/src/scripts/ai_council/quorum.ts +84 -0
- package/src/scripts/ai_council/session.ts +44 -0
- package/src/scripts/ai_council/transport_resolver.ts +127 -1
- package/src/scripts/ai_team/availability.ts +110 -0
- package/src/scripts/ai_team/config.ts +34 -14
- package/src/scripts/ai_team/team_dispatch.ts +71 -29
- package/src/scripts/ask_transport.ts +221 -0
- package/src/scripts/bench_critic_protocol.ts +396 -0
- package/src/scripts/bench_hook_injection.ts +234 -0
- package/src/scripts/check_adr_frontmatter.ts +84 -40
- package/src/scripts/check_branch_freshness.ts +1 -0
- package/src/scripts/check_corpus_staleness.ts +18 -4
- package/src/scripts/check_detector_corpus.ts +219 -0
- package/src/scripts/check_gitignore_freshness.ts +42 -8
- package/src/scripts/check_hook_bundle_freshness.ts +24 -1
- package/src/scripts/check_kernel_prefix_stability.ts +1 -0
- package/src/scripts/check_standing_rule_delivery.ts +73 -32
- package/src/scripts/check_static_layer_stability.ts +154 -0
- package/src/scripts/check_suppression_hygiene.ts +48 -0
- package/src/scripts/condense.ts +162 -2
- package/src/scripts/council_cli.ts +401 -25
- package/src/scripts/dispatch_economy_report.ts +448 -0
- package/src/scripts/dispatch_r2_reviewer.ts +15 -2
- package/src/scripts/explain_run.ts +206 -4
- package/src/scripts/forensics_report.ts +333 -0
- package/src/scripts/generate_settings_reference.ts +1 -0
- package/src/scripts/handoff_context_hook.ts +112 -4
- package/src/scripts/hook_manifest.yaml +117 -10
- package/src/scripts/hooks/concern_registry.ts +8 -0
- package/src/scripts/hooks/delegation_nudge_hook.ts +106 -35
- package/src/scripts/hooks/dispatch_hook.ts +39 -2
- package/src/scripts/hooks/edit_shape_hook.ts +303 -0
- package/src/scripts/hooks/orchestration_record_hook.ts +19 -1
- package/src/scripts/hooks/reread_guard_hook.ts +314 -0
- package/src/scripts/hooks/rtk_wrap_hook.ts +133 -14
- package/src/scripts/hooks/session_eol_hook.ts +268 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +507 -0
- package/src/scripts/install-hooks.sh +1 -1
- package/src/scripts/language_mirror_hook.ts +89 -0
- package/src/scripts/lint_decision_impact.ts +14 -1
- package/src/scripts/lint_discovery_vocabulary.ts +2 -0
- package/src/scripts/lint_hook_manifest.ts +82 -0
- package/src/scripts/lint_never_silent.ts +276 -0
- package/src/scripts/lint_no_activation_gates.ts +271 -0
- package/src/scripts/lint_readme_serial_comma.ts +1 -0
- package/src/scripts/lint_rule_enforcement_declaration.ts +347 -0
- package/src/scripts/measure_turn_end_gate.ts +194 -0
- package/src/scripts/orchestration_record.ts +12 -1
- package/src/scripts/rebuild_hook_bundle.ts +178 -0
- package/src/scripts/release.ts +74 -2
- package/src/scripts/report_conformance_funnel.ts +284 -0
- package/src/scripts/report_imperative_density.ts +1 -0
- package/src/scripts/routing_doctor.ts +118 -36
- package/src/scripts/score_skill_selection.ts +19 -5
- package/src/scripts/self_repair_cli.ts +337 -59
- package/src/scripts/session_eol_report.ts +261 -0
- package/src/scripts/team_events_spike.ts +271 -0
- package/src/scripts/team_review_gate_hook.ts +31 -9
- package/src/server/schemas/settings.ts +24 -17
- package/src/shared/settingsCarveOut.ts +0 -10
- package/src/shared/settingsClasses.ts +11 -9
- package/src/templates/compact-instructions.md +40 -0
- package/dist/ui/assets/index-DmR0mbk4.js +0 -26
- package/dist/ui/assets/index-DmR0mbk4.js.map +0 -1
|
@@ -21,9 +21,12 @@
|
|
|
21
21
|
* a locally-installed CLI under subscription auth (free for
|
|
22
22
|
* first-party CLIs, billable for community wrappers); `auto` = pick
|
|
23
23
|
* per provider per invocation (cli → api → unavailable), resolved by
|
|
24
|
-
* `transport_resolver.ts`. `auto` is
|
|
25
|
-
*
|
|
26
|
-
*
|
|
24
|
+
* `transport_resolver.ts`. `auto` is the SHIPPED DEFAULT
|
|
25
|
+
* (road-to-always-on-orchestration Phase 3.1 — CLI-first is the
|
|
26
|
+
* owner-set transport doctrine: ride the vendor CLI under subscription
|
|
27
|
+
* auth first, fall to the metered API only where no CLI resolves).
|
|
28
|
+
* `manual` and a pinned `api`/`cli` remain valid explicit per-member or
|
|
29
|
+
* per-invocation overrides — `auto` never displaces one.
|
|
27
30
|
* 3. `members.<name>` keys are restricted to the known provider set.
|
|
28
31
|
* 4. `cost_budget.*` numeric fields are >= 0.
|
|
29
32
|
* 5. Enabled members carry a non-empty `model` and `api_key_ref` when
|
|
@@ -37,7 +40,18 @@
|
|
|
37
40
|
* 8. `binary:` is only valid when the member's effective mode is `cli` or
|
|
38
41
|
* `auto` (auto may resolve to the cli rung);
|
|
39
42
|
* `cli_call_budget.max_calls_per_day.<provider>` keys must be valid
|
|
40
|
-
* providers.
|
|
43
|
+
* providers. `max_calls_per_day` SHIPS POPULATED with a generous
|
|
44
|
+
* per-provider default (50/day, road-to-always-on-orchestration
|
|
45
|
+
* Phase 3.4) for every known provider — a guard against a silent
|
|
46
|
+
* always-on pass exhausting a subscription's plan quota, not a brake;
|
|
47
|
+
* an explicit per-provider entry overrides just that provider's default.
|
|
48
|
+
* 9. `quorum` ∈ {`"majority"`, a positive integer} (default `"majority"`,
|
|
49
|
+
* road-to-always-on-orchestration Phase 3.3). `"majority"` at n members
|
|
50
|
+
* resolves to `ceil(n / 2)` — a SIMPLE majority, deliberately NOT
|
|
51
|
+
* "more than half": at n=2 that is 1-of-2, because 2-of-2 turns any
|
|
52
|
+
* single absent member into a deadlocked release gate (council-verified
|
|
53
|
+
* 2026-08-09). See `quorum.ts` for the resolver a caller applies to its
|
|
54
|
+
* own present/total member counts.
|
|
41
55
|
*
|
|
42
56
|
* Parity notes (intentional, documented):
|
|
43
57
|
* - YAML is parsed with `yaml` (npm) at `version: '1.1'`, matching
|
|
@@ -562,6 +576,15 @@ export interface CliCallBudgetConfig {
|
|
|
562
576
|
readonly warn_at: number;
|
|
563
577
|
}
|
|
564
578
|
|
|
579
|
+
/**
|
|
580
|
+
* `"majority"` (default) or a positive integer k — the number of members a
|
|
581
|
+
* pass needs to conclude (road-to-always-on-orchestration Phase 3.3). See
|
|
582
|
+
* `quorum.ts::resolveQuorumThreshold` for how a caller turns this into a
|
|
583
|
+
* concrete threshold against its own total-member count, and the module
|
|
584
|
+
* docstring rule 9 for why `"majority"` is `ceil(n / 2)`, not `> n / 2`.
|
|
585
|
+
*/
|
|
586
|
+
export type QuorumSetting = 'majority' | number;
|
|
587
|
+
|
|
565
588
|
export interface CouncilConfig {
|
|
566
589
|
readonly enabled: boolean;
|
|
567
590
|
readonly defaults: DefaultsConfig;
|
|
@@ -570,6 +593,7 @@ export interface CouncilConfig {
|
|
|
570
593
|
readonly advisors: ReadonlyMap<string, AdvisorConfig>;
|
|
571
594
|
readonly consensus_scoring: ConsensusScoringConfig;
|
|
572
595
|
readonly cli_call_budget: CliCallBudgetConfig;
|
|
596
|
+
readonly quorum: QuorumSetting;
|
|
573
597
|
readonly necessity_classifier: NecessityClassifierConfig;
|
|
574
598
|
readonly model_downgrade: ModelDowngradeConfig;
|
|
575
599
|
readonly debate: DebateConfig;
|
|
@@ -578,6 +602,7 @@ export interface CouncilConfig {
|
|
|
578
602
|
readonly chairman: ChairmanConfig;
|
|
579
603
|
readonly debate_gates: DebateGatesConfig;
|
|
580
604
|
readonly restate: RestateConfig;
|
|
605
|
+
readonly critic_protocol: CriticProtocol;
|
|
581
606
|
readonly decision_resolution: DecisionResolutionConfig;
|
|
582
607
|
readonly routing: RoutingConfig;
|
|
583
608
|
readonly low_impact: LowImpactConfig;
|
|
@@ -796,6 +821,7 @@ export function _build_config(raw: Dict, source_path: PathLike): CouncilConfig {
|
|
|
796
821
|
const cli_call_budget = _build_cli_call_budget(
|
|
797
822
|
_asDict(_getOr(raw, 'cli_call_budget', {})),
|
|
798
823
|
);
|
|
824
|
+
const quorum = _build_quorum(_get(raw, 'quorum', 'majority'));
|
|
799
825
|
const necessity_classifier = _build_necessity_classifier(
|
|
800
826
|
_asDict(_getOr(raw, 'necessity_classifier', {})),
|
|
801
827
|
);
|
|
@@ -821,6 +847,7 @@ export function _build_config(raw: Dict, source_path: PathLike): CouncilConfig {
|
|
|
821
847
|
'debate_gates',
|
|
822
848
|
);
|
|
823
849
|
const restate = _build_restate(_asDict(_getOr(raw, 'restate', {})), 'restate');
|
|
850
|
+
const critic_protocol = _build_critic_protocol(_getOr(raw, 'critic_protocol', 'legacy'));
|
|
824
851
|
const decision_resolution = _build_decision_resolution(
|
|
825
852
|
_asDict(_getOr(raw, 'decision_resolution', {})),
|
|
826
853
|
);
|
|
@@ -843,6 +870,7 @@ export function _build_config(raw: Dict, source_path: PathLike): CouncilConfig {
|
|
|
843
870
|
advisors,
|
|
844
871
|
consensus_scoring: consensus,
|
|
845
872
|
cli_call_budget,
|
|
873
|
+
quorum,
|
|
846
874
|
necessity_classifier,
|
|
847
875
|
model_downgrade,
|
|
848
876
|
debate,
|
|
@@ -851,6 +879,7 @@ export function _build_config(raw: Dict, source_path: PathLike): CouncilConfig {
|
|
|
851
879
|
chairman,
|
|
852
880
|
debate_gates,
|
|
853
881
|
restate,
|
|
882
|
+
critic_protocol,
|
|
854
883
|
decision_resolution,
|
|
855
884
|
routing,
|
|
856
885
|
low_impact,
|
|
@@ -1115,6 +1144,27 @@ function _build_restate(d: Dict, scope: string): RestateConfig {
|
|
|
1115
1144
|
return { enabled: Boolean(enabled) };
|
|
1116
1145
|
}
|
|
1117
1146
|
|
|
1147
|
+
/**
|
|
1148
|
+
* `critic_protocol` (road-to-judgment-and-forensic-evidence Phase 2) — which
|
|
1149
|
+
* critic posture adversarial/skeptic review passes use. `legacy` is the
|
|
1150
|
+
* shipped free-hunt skeptic; `load_bearing` is the fixed protocol that must
|
|
1151
|
+
* name the single load-bearing assumption and may return "holds". Default
|
|
1152
|
+
* `legacy` regardless of the A/B outcome (promotion is a separate decision).
|
|
1153
|
+
*/
|
|
1154
|
+
export type CriticProtocol = 'legacy' | 'load_bearing';
|
|
1155
|
+
|
|
1156
|
+
const _VALID_CRITIC_PROTOCOLS: ReadonlySet<string> = new Set(['legacy', 'load_bearing']);
|
|
1157
|
+
|
|
1158
|
+
function _build_critic_protocol(v: unknown): CriticProtocol {
|
|
1159
|
+
if (v === undefined || v === null) return 'legacy';
|
|
1160
|
+
if (!(_isStr(v) && _VALID_CRITIC_PROTOCOLS.has(v))) {
|
|
1161
|
+
throw new CouncilConfigError(
|
|
1162
|
+
`\`critic_protocol\`=${_pyRepr(v)} not in ${_sortedListRepr(_VALID_CRITIC_PROTOCOLS)}.`,
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
return v as CriticProtocol;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1118
1168
|
const _VALID_RESOLUTION_MODES: ReadonlySet<string> = new Set([
|
|
1119
1169
|
'agent',
|
|
1120
1170
|
'council',
|
|
@@ -1427,7 +1477,11 @@ function _build_defaults(d: Dict): DefaultsConfig {
|
|
|
1427
1477
|
if (!_isDict(d)) {
|
|
1428
1478
|
throw new CouncilConfigError('`defaults` must be a mapping.');
|
|
1429
1479
|
}
|
|
1430
|
-
|
|
1480
|
+
// road-to-always-on-orchestration Phase 3.1: CLI-first is the shipped
|
|
1481
|
+
// default (was `api`) — see the module docstring rule 2. `manual` and a
|
|
1482
|
+
// pinned `api`/`cli` remain valid overrides at every layer resolve_mode
|
|
1483
|
+
// checks; this only changes what a config that never sets `mode` gets.
|
|
1484
|
+
const mode = _get(d, 'mode', 'auto');
|
|
1431
1485
|
if (!(_isStr(mode) && _VALID_MODES.has(mode))) {
|
|
1432
1486
|
throw new CouncilConfigError(
|
|
1433
1487
|
`defaults.mode=${_pyRepr(mode)} not in ${_sortedListRepr(_VALID_MODES)}.`,
|
|
@@ -1630,7 +1684,13 @@ function _build_cost_budget(d: Dict): CostBudgetConfig {
|
|
|
1630
1684
|
function _build_member(
|
|
1631
1685
|
name: string,
|
|
1632
1686
|
cfg: Dict,
|
|
1633
|
-
|
|
1687
|
+
// road-to-always-on-orchestration Phase 3.1: kept in sync with
|
|
1688
|
+
// `_build_defaults`'s `auto` default for consistency. The sole call site
|
|
1689
|
+
// (`_build_config`) always passes `defaults.mode` explicitly, so this
|
|
1690
|
+
// parameter default is unreachable on the live path today; it exists so
|
|
1691
|
+
// a future caller that omits the third argument observes the same
|
|
1692
|
+
// doctrine rather than silently reverting to the retired `api` default.
|
|
1693
|
+
default_mode = 'auto',
|
|
1634
1694
|
): MemberConfig {
|
|
1635
1695
|
if (!_VALID_PROVIDERS.has(name)) {
|
|
1636
1696
|
throw new CouncilConfigError(
|
|
@@ -1775,6 +1835,18 @@ function _build_member(
|
|
|
1775
1835
|
};
|
|
1776
1836
|
}
|
|
1777
1837
|
|
|
1838
|
+
/**
|
|
1839
|
+
* Generous per-provider guard for `mode: cli` / `mode: auto` members
|
|
1840
|
+
* (road-to-always-on-orchestration Phase 3.4). Before this default the map
|
|
1841
|
+
* shipped empty and an unlisted provider ran uncapped — a plan-quota guard
|
|
1842
|
+
* that only existed if the user remembered to opt in. Every known provider
|
|
1843
|
+
* now carries this floor unless a config entry overrides it; sized as a
|
|
1844
|
+
* GUARD against silent quota exhaustion on an always-on pass, not a brake on
|
|
1845
|
+
* normal use (see the template's own worked-example sizing for the
|
|
1846
|
+
* reasoning behind a TIGHTER per-provider number).
|
|
1847
|
+
*/
|
|
1848
|
+
const _DEFAULT_CLI_CALLS_PER_DAY = 50;
|
|
1849
|
+
|
|
1778
1850
|
function _build_cli_call_budget(d: Dict): CliCallBudgetConfig {
|
|
1779
1851
|
if (!_isDict(d)) {
|
|
1780
1852
|
throw new CouncilConfigError('`cli_call_budget` must be a mapping.');
|
|
@@ -1786,6 +1858,9 @@ function _build_cli_call_budget(d: Dict): CliCallBudgetConfig {
|
|
|
1786
1858
|
);
|
|
1787
1859
|
}
|
|
1788
1860
|
const caps = new Map<string, number>();
|
|
1861
|
+
for (const provider of _VALID_PROVIDERS) {
|
|
1862
|
+
caps.set(provider, _DEFAULT_CLI_CALLS_PER_DAY);
|
|
1863
|
+
}
|
|
1789
1864
|
for (const [provider, value] of Object.entries(raw_caps)) {
|
|
1790
1865
|
if (!_VALID_PROVIDERS.has(provider)) {
|
|
1791
1866
|
throw new CouncilConfigError(
|
|
@@ -1817,6 +1892,19 @@ function _build_cli_call_budget(d: Dict): CliCallBudgetConfig {
|
|
|
1817
1892
|
return { max_calls_per_day: caps, warn_at };
|
|
1818
1893
|
}
|
|
1819
1894
|
|
|
1895
|
+
function _build_quorum(raw: Json): QuorumSetting {
|
|
1896
|
+
if (raw === 'majority') {
|
|
1897
|
+
return 'majority';
|
|
1898
|
+
}
|
|
1899
|
+
if (_isInt(raw) && !_isBool(raw) && (raw as number) >= 1) {
|
|
1900
|
+
return raw as number;
|
|
1901
|
+
}
|
|
1902
|
+
throw new CouncilConfigError(
|
|
1903
|
+
`\`quorum\`=${_pyRepr(raw)} must be 'majority' or an integer >= 1 ` +
|
|
1904
|
+
`(got ${_pyTypeName(raw)}).`,
|
|
1905
|
+
);
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1820
1908
|
function _build_advisor(name: string, cfg: Dict): AdvisorConfig {
|
|
1821
1909
|
if (!_isDict(cfg)) {
|
|
1822
1910
|
throw new CouncilConfigError(`advisors.${name}: must be a mapping.`);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verdict → handoff envelope (Phase 4.1 of road-to-always-on-orchestration).
|
|
3
|
+
*
|
|
4
|
+
* A council pass produces a verdict a human currently has to read and
|
|
5
|
+
* re-type into whatever executes next. This module is the machine-readable
|
|
6
|
+
* bridge: an additive `HandoffEnvelope` — decision, rejected alternatives
|
|
7
|
+
* with reasons, binding constraints — injectable as the dispatched work
|
|
8
|
+
* order of an implementing subagent, so deliberation output becomes
|
|
9
|
+
* execution input without a human transcription step.
|
|
10
|
+
*
|
|
11
|
+
* ## The honesty constraint
|
|
12
|
+
*
|
|
13
|
+
* A council pass has exactly ONE place in this codebase where "the
|
|
14
|
+
* decision" is a discrete, machine-parsed value rather than free-text prose
|
|
15
|
+
* a human (or an NLP guess) would have to interpret: the option-level
|
|
16
|
+
* stance tally (`stance_tally.ts`, opt-in via `ai_council.stance_tally`).
|
|
17
|
+
* Every other synthesis path — the default template, a chairman's prose
|
|
18
|
+
* verdict, consensus scoring's qualitative Strong/Findings/Minority
|
|
19
|
+
* buckets — has no comparable discrete "decision" field to extract from
|
|
20
|
+
* without parsing prose, which is exactly the kind of confident-looking
|
|
21
|
+
* fabrication this module refuses to do.
|
|
22
|
+
*
|
|
23
|
+
* So: `buildHandoffFromStanceTally` is the ONLY builder this module ships.
|
|
24
|
+
* No tally (the common case — the flag defaults off) or a tally that split
|
|
25
|
+
* (no option cleared the consensus threshold) → every field is `null`,
|
|
26
|
+
* never a guessed decision. A future builder over a different structured
|
|
27
|
+
* source (a chairman synthesis that itself emits structured fields, a
|
|
28
|
+
* consensus-scoring extension) is additive — it does not change this one.
|
|
29
|
+
*
|
|
30
|
+
* @see docs/contracts/ai-council-config.md § Handoff envelope
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { StanceTallyResult } from './stance_tally.js';
|
|
34
|
+
|
|
35
|
+
/** One option the tally counted but did not conclude on. */
|
|
36
|
+
export interface RejectedAlternative {
|
|
37
|
+
readonly option: string;
|
|
38
|
+
/**
|
|
39
|
+
* A factual, derived-from-the-tally sentence — never invented prose.
|
|
40
|
+
* States the option's own backer count and weight against the
|
|
41
|
+
* threshold the winner had to clear, nothing else.
|
|
42
|
+
*/
|
|
43
|
+
readonly reason: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The machine-readable work order a council verdict hands to whatever
|
|
48
|
+
* executes next. Every field is independently nullable: `null` means "no
|
|
49
|
+
* structured source existed to populate this field this pass", never a
|
|
50
|
+
* fabricated placeholder. A consumer checks for `null`, not for an empty
|
|
51
|
+
* array — an empty array would claim "checked, found none", which this
|
|
52
|
+
* module never asserts when it never actually checked.
|
|
53
|
+
*/
|
|
54
|
+
export interface HandoffEnvelope {
|
|
55
|
+
readonly decision: string | null;
|
|
56
|
+
readonly rejected_alternatives: readonly RejectedAlternative[] | null;
|
|
57
|
+
/**
|
|
58
|
+
* Binding constraints the decision carries. No structured source for
|
|
59
|
+
* this exists anywhere in the current synthesis pipeline (a
|
|
60
|
+
* dealbreaker count describes objection PRESSURE on an option, not a
|
|
61
|
+
* named constraint on the winner) — always `null` until one does.
|
|
62
|
+
*/
|
|
63
|
+
readonly constraints: readonly string[] | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** An envelope with every field `null` — the honest "nothing to hand off" shape. */
|
|
67
|
+
export const EMPTY_HANDOFF: HandoffEnvelope = {
|
|
68
|
+
decision: null,
|
|
69
|
+
rejected_alternatives: null,
|
|
70
|
+
constraints: null,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** True when every field is `null` — nothing worth rendering or dispatching. */
|
|
74
|
+
export function isEmptyHandoff(h: HandoffEnvelope): boolean {
|
|
75
|
+
return h.decision === null && h.rejected_alternatives === null && h.constraints === null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build a `HandoffEnvelope` from a stance tally, or the honest empty
|
|
80
|
+
* envelope when there is nothing structured to extract:
|
|
81
|
+
*
|
|
82
|
+
* - `tally` is `null` — stance tally never ran this pass (the default,
|
|
83
|
+
* opt-in-off case).
|
|
84
|
+
* - `tally.consensus` is `null` — the tally ran but split; no option
|
|
85
|
+
* cleared the two-thirds threshold, so there is no decision to report,
|
|
86
|
+
* only a split (which is quorum's/the render's job to surface, not this
|
|
87
|
+
* module's).
|
|
88
|
+
*
|
|
89
|
+
* Otherwise: `decision` is the winning option's display label;
|
|
90
|
+
* `rejected_alternatives` lists every OTHER non-abstain option the tally
|
|
91
|
+
* counted, each with a reason sentence built strictly from that option's
|
|
92
|
+
* own tally numbers (backer count, weight, the threshold the winner
|
|
93
|
+
* cleared) — never a narrative guess at WHY members backed it.
|
|
94
|
+
*/
|
|
95
|
+
export function buildHandoffFromStanceTally(tally: StanceTallyResult | null): HandoffEnvelope {
|
|
96
|
+
if (tally === null || tally.consensus === null) {
|
|
97
|
+
return EMPTY_HANDOFF;
|
|
98
|
+
}
|
|
99
|
+
const winner = tally.consensus;
|
|
100
|
+
const rejected: RejectedAlternative[] = tally.options
|
|
101
|
+
.filter((o) => o !== winner)
|
|
102
|
+
.map((o) => ({
|
|
103
|
+
option: o.label,
|
|
104
|
+
reason:
|
|
105
|
+
`backed by ${o.backers.length} member(s), weight ${o.weight.toFixed(2)} of ` +
|
|
106
|
+
`${tally.threshold.toFixed(2)} needed to conclude`,
|
|
107
|
+
}));
|
|
108
|
+
return {
|
|
109
|
+
decision: winner.label,
|
|
110
|
+
rejected_alternatives: rejected.length > 0 ? rejected : null,
|
|
111
|
+
constraints: null,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -33,21 +33,36 @@
|
|
|
33
33
|
* conflated them. They are now named separately:
|
|
34
34
|
*
|
|
35
35
|
* - **Loader default** — when a config file omits `defaults.mode` entirely,
|
|
36
|
-
* `config.ts::_build_defaults` fills `
|
|
37
|
-
*
|
|
36
|
+
* `config.ts::_build_defaults` fills `auto` (road-to-always-on-orchestration
|
|
37
|
+
* Phase 3.1 — CLI-first is now the owner-set transport doctrine; before this
|
|
38
|
+
* flip the loader default was `api`). This is what every real config
|
|
39
|
+
* observes, because the loader always populates the key.
|
|
38
40
|
* - **Built-in fallback** — when NO layer supplies a mode at all (a settings
|
|
39
41
|
* dict handed straight to the resolver, no config file involved), the answer
|
|
40
42
|
* is `manual`, below. That is the fail-closed direction the no-silent-spend
|
|
41
43
|
* Iron Law requires: a caller who supplied no transport preference has not
|
|
42
44
|
* asked to spend money, so the free transport wins.
|
|
45
|
+
*
|
|
46
|
+
* ### Why `DEFAULT_MODE` did NOT flip to `auto` alongside the loader default
|
|
47
|
+
*
|
|
48
|
+
* `auto` still resolves to a paying rung (the api fallback) when no CLI is
|
|
49
|
+
* usable — it is a routing rule, not a free transport. `DEFAULT_MODE` is only
|
|
50
|
+
* reached by a caller that bypasses the config loader entirely (no
|
|
51
|
+
* `.ai-council.yml`, a hand-built settings dict handed straight to
|
|
52
|
+
* `resolve_mode`/`resolve_modes`). Flipping it to `auto` would let such a
|
|
53
|
+
* caller spend money on a config that never asked for `auto` in the first
|
|
54
|
+
* place — the same no-silent-spend violation the built-in fallback exists to
|
|
55
|
+
* prevent. Every real, file-backed config always has `defaults.mode`
|
|
56
|
+
* populated by the loader (now `auto`), so `DEFAULT_MODE` is never consulted
|
|
57
|
+
* on that path — verified in modes.test.ts and config.test.ts.
|
|
43
58
|
*/
|
|
44
59
|
|
|
45
60
|
export const VALID_MODES: ReadonlySet<string> = new Set(['api', 'manual', 'cli', 'auto']);
|
|
46
61
|
|
|
47
62
|
/**
|
|
48
63
|
* Built-in fallback when no layer supplies a mode. `manual` — free, no key,
|
|
49
|
-
* no spend. NOT the same as the loader's `defaults.mode` default (`
|
|
50
|
-
* the module header.
|
|
64
|
+
* no spend. NOT the same as the loader's `defaults.mode` default (`auto`
|
|
65
|
+
* since road-to-always-on-orchestration Phase 3.1); see the module header.
|
|
51
66
|
*/
|
|
52
67
|
export const DEFAULT_MODE = 'manual';
|
|
53
68
|
|
|
@@ -69,6 +69,9 @@ import {
|
|
|
69
69
|
import { count_dissenters, dissent_quota_met, is_near_duplicate } from './debate_gates.js';
|
|
70
70
|
import { parse_stance_line, render_vote_tally, tally_stances } from './stance_tally.js';
|
|
71
71
|
import { CHAIRMAN_FIELDS_ADDENDUM, render_deanonymization_block } from './blind_review.js';
|
|
72
|
+
import type { AbsentReason } from './transport_resolver.js';
|
|
73
|
+
import type { QuorumResult } from './quorum.js';
|
|
74
|
+
import { isEmptyHandoff, type HandoffEnvelope } from './handoff.js';
|
|
72
75
|
|
|
73
76
|
// ── Python-format / stdlib parity helpers ────────────────────────────────
|
|
74
77
|
//
|
|
@@ -1811,6 +1814,38 @@ export interface RenderOptions {
|
|
|
1811
1814
|
* directly in `chairman.text`. Default `null` → byte-identical.
|
|
1812
1815
|
*/
|
|
1813
1816
|
blind?: { label_to_source: ReadonlyMap<string, string> } | null;
|
|
1817
|
+
/**
|
|
1818
|
+
* Phase 3.2 (road-to-always-on-orchestration): members that produced no
|
|
1819
|
+
* usable response this pass, rendered as a trailing section so the
|
|
1820
|
+
* human-readable report shows the same graded-degradation picture the
|
|
1821
|
+
* session manifest carries structurally. Default `null`/empty →
|
|
1822
|
+
* byte-identical — no existing caller passes this, so no existing
|
|
1823
|
+
* output changes.
|
|
1824
|
+
*/
|
|
1825
|
+
absent_members?: readonly RenderAbsentMember[] | null;
|
|
1826
|
+
/**
|
|
1827
|
+
* Phase 3.3: this pass's quorum verdict, rendered as one line right
|
|
1828
|
+
* before the absent-members section (or standalone when there is
|
|
1829
|
+
* nothing absent to report). Default `null` → byte-identical.
|
|
1830
|
+
*/
|
|
1831
|
+
quorum?: QuorumResult | null;
|
|
1832
|
+
/**
|
|
1833
|
+
* Phase 4.1: the verdict → handoff envelope, rendered as a `### Handoff`
|
|
1834
|
+
* section right after the Convergence/Divergence slot — closest to the
|
|
1835
|
+
* synthesis it was extracted from, ahead of the trailing quorum/absent
|
|
1836
|
+
* bookkeeping. Rendered only when at least one field is non-`null`
|
|
1837
|
+
* (`isEmptyHandoff` — an all-null envelope has nothing worth a section
|
|
1838
|
+
* for, same "only show when there's substance" call `absent_members`
|
|
1839
|
+
* makes). Default `null` → byte-identical.
|
|
1840
|
+
*/
|
|
1841
|
+
handoff?: HandoffEnvelope | null;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
/** The minimal shape `render()` needs for one absent-member row. */
|
|
1845
|
+
export interface RenderAbsentMember {
|
|
1846
|
+
readonly member: string;
|
|
1847
|
+
readonly reason: AbsentReason | null;
|
|
1848
|
+
readonly detail: string;
|
|
1814
1849
|
}
|
|
1815
1850
|
|
|
1816
1851
|
/**
|
|
@@ -1918,14 +1953,84 @@ export function render(responses: CouncilResponse[], opts: RenderOptions = {}):
|
|
|
1918
1953
|
blocks.push(render_vote_tally(tally));
|
|
1919
1954
|
}
|
|
1920
1955
|
blocks.push(`## Convergence / Divergence\n\n${body}`);
|
|
1956
|
+
// Phase 4.1: closest to the synthesis it was extracted from — ahead of
|
|
1957
|
+
// the de-anonymization map and the trailing quorum/absent bookkeeping.
|
|
1958
|
+
// An all-null envelope renders nothing (`isEmptyHandoff`), so a caller
|
|
1959
|
+
// that always attaches one (the common shape once a producer wires
|
|
1960
|
+
// this in) does not spam every response with "nothing to hand off".
|
|
1961
|
+
if (opts.handoff && !isEmptyHandoff(opts.handoff)) {
|
|
1962
|
+
blocks.push(_render_handoff(opts.handoff));
|
|
1963
|
+
}
|
|
1921
1964
|
if (blind !== null) {
|
|
1922
1965
|
// Ü1: de-anonymization AFTER the synthesis slot — blind is only at
|
|
1923
1966
|
// decision time, never the archive.
|
|
1924
1967
|
blocks.push(render_deanonymization_block('### De-anonymization map', blind.label_to_source));
|
|
1925
1968
|
}
|
|
1969
|
+
// Phase 3.2/3.3: trailing, opt-in sections — absent no caller passes
|
|
1970
|
+
// either option, so the join below adds nothing and every existing
|
|
1971
|
+
// render() output stays byte-identical.
|
|
1972
|
+
if (opts.quorum) {
|
|
1973
|
+
blocks.push(_render_quorum_line(opts.quorum));
|
|
1974
|
+
}
|
|
1975
|
+
const absent = opts.absent_members ?? [];
|
|
1976
|
+
if (absent.length > 0) {
|
|
1977
|
+
blocks.push(_render_absent_members(absent));
|
|
1978
|
+
}
|
|
1926
1979
|
return blocks.join('\n\n---\n\n');
|
|
1927
1980
|
}
|
|
1928
1981
|
|
|
1982
|
+
/** One line naming the k-of-n outcome — machine-parseable, human-readable. */
|
|
1983
|
+
function _render_quorum_line(q: QuorumResult): string {
|
|
1984
|
+
const verdict = q.status === 'concluded' ? 'concluded' : 'INCONCLUSIVE — release gate holds';
|
|
1985
|
+
return `**Quorum:** ${q.present}/${q.total} present, needed ${q.threshold} — ${verdict}.`;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
/**
|
|
1989
|
+
* `### Absent Members` — one row per member that never produced a usable
|
|
1990
|
+
* response this pass, naming the machine-readable reason when classified.
|
|
1991
|
+
*/
|
|
1992
|
+
function _render_absent_members(absent: readonly RenderAbsentMember[]): string {
|
|
1993
|
+
const lines = ['### Absent Members', ''];
|
|
1994
|
+
for (const a of absent) {
|
|
1995
|
+
const tag = a.reason !== null ? ` (${a.reason})` : '';
|
|
1996
|
+
lines.push(`- **${a.member}**${tag} — ${a.detail}`);
|
|
1997
|
+
}
|
|
1998
|
+
return lines.join('\n');
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
/**
|
|
2002
|
+
* `### Handoff` — the verdict → work-order envelope (Phase 4.1). Only
|
|
2003
|
+
* called once the caller already confirmed `!isEmptyHandoff(h)`, so at
|
|
2004
|
+
* least one field is populated; the other two still render their own
|
|
2005
|
+
* honest "none recorded" line rather than being silently dropped, so a
|
|
2006
|
+
* reader sees exactly what this pass did and did not extract.
|
|
2007
|
+
*/
|
|
2008
|
+
function _render_handoff(h: HandoffEnvelope): string {
|
|
2009
|
+
const lines = ['### Handoff', ''];
|
|
2010
|
+
lines.push(`**Decision:** ${h.decision ?? 'none recorded — no structured verdict this pass.'}`);
|
|
2011
|
+
lines.push('');
|
|
2012
|
+
if (h.rejected_alternatives !== null && h.rejected_alternatives.length > 0) {
|
|
2013
|
+
lines.push('**Rejected alternatives:**');
|
|
2014
|
+
lines.push('');
|
|
2015
|
+
for (const alt of h.rejected_alternatives) {
|
|
2016
|
+
lines.push(`- **${alt.option}** — ${alt.reason}`);
|
|
2017
|
+
}
|
|
2018
|
+
} else {
|
|
2019
|
+
lines.push('**Rejected alternatives:** none recorded.');
|
|
2020
|
+
}
|
|
2021
|
+
lines.push('');
|
|
2022
|
+
if (h.constraints !== null && h.constraints.length > 0) {
|
|
2023
|
+
lines.push('**Constraints:**');
|
|
2024
|
+
lines.push('');
|
|
2025
|
+
for (const c of h.constraints) {
|
|
2026
|
+
lines.push(`- ${c}`);
|
|
2027
|
+
}
|
|
2028
|
+
} else {
|
|
2029
|
+
lines.push('**Constraints:** none recorded.');
|
|
2030
|
+
}
|
|
2031
|
+
return lines.join('\n');
|
|
2032
|
+
}
|
|
2033
|
+
|
|
1929
2034
|
/**
|
|
1930
2035
|
* Render the peer-review block under deterministic Reviewer labels.
|
|
1931
2036
|
*
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quorum resolution for a council pass (Phase 3.3 of
|
|
3
|
+
* road-to-always-on-orchestration).
|
|
4
|
+
*
|
|
5
|
+
* A pass concludes when at least `k` of `n` enabled members produced a
|
|
6
|
+
* usable response ("present"); below `k` the pass is `inconclusive`. At a
|
|
7
|
+
* release gate an inconclusive pass HOLDS the gate for a human — it is
|
|
8
|
+
* NEVER silently downgraded to advisory (council-verified, 2026-08-09).
|
|
9
|
+
* Deciding what to DO with an `inconclusive` status (block a merge, retry,
|
|
10
|
+
* surface to the user) is the caller's job; this module only answers
|
|
11
|
+
* whether the threshold was met.
|
|
12
|
+
*
|
|
13
|
+
* `majority` is a SIMPLE majority — `ceil(n / 2)` — deliberately NOT the
|
|
14
|
+
* traditional "more than half" (`floor(n / 2) + 1`). At n=2 those two
|
|
15
|
+
* definitions diverge: `ceil(2/2)=1` vs `floor(2/2)+1=2`. The stricter
|
|
16
|
+
* 2-of-2 reading turns any single absent member into a deadlocked release
|
|
17
|
+
* gate, which is exactly the failure mode council members flagged for the
|
|
18
|
+
* n=2 case this module is built around — 1-of-2 is the deliberate choice,
|
|
19
|
+
* not an off-by-one.
|
|
20
|
+
*
|
|
21
|
+
* This module is pure — no filesystem, no environment, no config parsing.
|
|
22
|
+
* `config.ts::QuorumSetting` is the validated shape a loaded
|
|
23
|
+
* `.ai-council.yml` supplies; a caller reads `cfg.quorum` and passes it
|
|
24
|
+
* straight through to `evaluateQuorum`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { QuorumSetting } from './config.js';
|
|
28
|
+
|
|
29
|
+
export type QuorumStatus = 'concluded' | 'inconclusive';
|
|
30
|
+
|
|
31
|
+
export interface QuorumResult {
|
|
32
|
+
/** `concluded` when `present >= threshold`; `inconclusive` otherwise. */
|
|
33
|
+
readonly status: QuorumStatus;
|
|
34
|
+
/** The concrete k this pass needed, after resolving `setting` against `total`. */
|
|
35
|
+
readonly threshold: number;
|
|
36
|
+
/** Total enabled members configured for this pass — the `n`. */
|
|
37
|
+
readonly total: number;
|
|
38
|
+
/** Members that actually produced a usable response. */
|
|
39
|
+
readonly present: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve `setting` into a concrete integer threshold for `total` members.
|
|
44
|
+
*
|
|
45
|
+
* `'majority'` → `ceil(total / 2)` (0 when `total` is 0 — there is nothing
|
|
46
|
+
* to require attendance from). A fixed numeric `setting` is clamped to
|
|
47
|
+
* `[1, total]` so neither a misconfigured cap above `total` (structurally
|
|
48
|
+
* unwinnable) nor one below 1 (would resolve trivially) can escape the
|
|
49
|
+
* config validation `config.ts::_build_quorum` already runs at load time —
|
|
50
|
+
* this is a second, cheap floor for any caller that builds a `QuorumSetting`
|
|
51
|
+
* by hand instead of through the loader.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveQuorumThreshold(total: number, setting: QuorumSetting = 'majority'): number {
|
|
54
|
+
if (!Number.isInteger(total) || total < 0) {
|
|
55
|
+
throw new RangeError(
|
|
56
|
+
`resolveQuorumThreshold: total must be a non-negative integer (got ${total}).`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (total === 0) {
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
if (setting === 'majority') {
|
|
63
|
+
return Math.ceil(total / 2);
|
|
64
|
+
}
|
|
65
|
+
return Math.min(Math.max(setting, 1), total);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Evaluate whether a pass met quorum.
|
|
70
|
+
*
|
|
71
|
+
* `present` is clamped into `[0, total]` before comparison — a caller that
|
|
72
|
+
* miscounts (more "present" than were ever configured) cannot manufacture a
|
|
73
|
+
* `concluded` verdict past what `total` actually allows.
|
|
74
|
+
*/
|
|
75
|
+
export function evaluateQuorum(
|
|
76
|
+
total: number,
|
|
77
|
+
present: number,
|
|
78
|
+
setting: QuorumSetting = 'majority',
|
|
79
|
+
): QuorumResult {
|
|
80
|
+
const threshold = resolveQuorumThreshold(total, setting);
|
|
81
|
+
const clampedPresent = Math.min(Math.max(present, 0), total);
|
|
82
|
+
const status: QuorumStatus = clampedPresent >= threshold ? 'concluded' : 'inconclusive';
|
|
83
|
+
return { status, threshold, total, present: clampedPresent };
|
|
84
|
+
}
|
|
@@ -46,6 +46,24 @@ import { PyFloat, py_json_dumps_indent2 } from '../_lib/security_lint.js';
|
|
|
46
46
|
import { pyRound } from '../_lib/value_ladder.js';
|
|
47
47
|
import { CouncilResponse } from './clients.js';
|
|
48
48
|
import { render } from './orchestrator.js';
|
|
49
|
+
import type { AbsentReason } from './transport_resolver.js';
|
|
50
|
+
import type { QuorumResult } from './quorum.js';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A member that produced no usable response this pass
|
|
54
|
+
* (road-to-always-on-orchestration Phase 3.2) — never constructed (no CLI +
|
|
55
|
+
* no key), or constructed but lost mid-run (timeout, quota exhaustion). The
|
|
56
|
+
* caller assembling the manifest is the one that knows which; this record
|
|
57
|
+
* only carries what it decided.
|
|
58
|
+
*/
|
|
59
|
+
export interface AbsentMemberRecord {
|
|
60
|
+
/** Provider name (or "provider/model" — whatever the caller's roster used). */
|
|
61
|
+
readonly member: string;
|
|
62
|
+
/** Machine-readable bucket, or `null` when the failure did not classify. */
|
|
63
|
+
readonly reason: AbsentReason | null;
|
|
64
|
+
/** Human-readable detail — the CLI-hints / install-instructions text. */
|
|
65
|
+
readonly detail: string;
|
|
66
|
+
}
|
|
49
67
|
|
|
50
68
|
// src/scripts/ai_council/session.py → parents[3] == repo root.
|
|
51
69
|
const _HERE = fileURLToPath(import.meta.url);
|
|
@@ -76,6 +94,19 @@ export class SessionManifest {
|
|
|
76
94
|
rounds: number;
|
|
77
95
|
cost_usd_estimated: number;
|
|
78
96
|
cost_usd_actual: number;
|
|
97
|
+
/**
|
|
98
|
+
* Members that never produced a usable response this pass (Phase 3.2).
|
|
99
|
+
* Empty by default — a caller that never populates it (every caller
|
|
100
|
+
* before this field existed) writes the same manifest shape it always
|
|
101
|
+
* did, plus one empty array.
|
|
102
|
+
*/
|
|
103
|
+
absent_members: AbsentMemberRecord[];
|
|
104
|
+
/**
|
|
105
|
+
* Quorum verdict for this pass (Phase 3.3), or `null` when the caller
|
|
106
|
+
* did not evaluate one (e.g. a single-member solo dispatch, where
|
|
107
|
+
* quorum has nothing to say).
|
|
108
|
+
*/
|
|
109
|
+
quorum: QuorumResult | null;
|
|
79
110
|
extra: Record<string, unknown>;
|
|
80
111
|
|
|
81
112
|
constructor(opts: {
|
|
@@ -86,6 +117,8 @@ export class SessionManifest {
|
|
|
86
117
|
rounds?: number;
|
|
87
118
|
cost_usd_estimated?: number;
|
|
88
119
|
cost_usd_actual?: number;
|
|
120
|
+
absent_members?: AbsentMemberRecord[];
|
|
121
|
+
quorum?: QuorumResult | null;
|
|
89
122
|
extra?: Record<string, unknown>;
|
|
90
123
|
}) {
|
|
91
124
|
this.mode = opts.mode;
|
|
@@ -95,6 +128,8 @@ export class SessionManifest {
|
|
|
95
128
|
this.rounds = opts.rounds ?? 1;
|
|
96
129
|
this.cost_usd_estimated = opts.cost_usd_estimated ?? 0.0;
|
|
97
130
|
this.cost_usd_actual = opts.cost_usd_actual ?? 0.0;
|
|
131
|
+
this.absent_members = opts.absent_members ?? [];
|
|
132
|
+
this.quorum = opts.quorum ?? null;
|
|
98
133
|
// Python dataclass uses `field(default_factory=dict)` — each instance
|
|
99
134
|
// gets its own fresh dict.
|
|
100
135
|
this.extra = opts.extra ?? {};
|
|
@@ -504,6 +539,15 @@ export function save(opts: {
|
|
|
504
539
|
responses_per_round: rounds_data.map((round_responses) =>
|
|
505
540
|
round_responses.map((r) => _serialise_response(r)),
|
|
506
541
|
),
|
|
542
|
+
// Phase 3.2/3.3 — graded degradation + quorum, both machine-readable.
|
|
543
|
+
// Empty array / `null` when the caller never populated them, so an
|
|
544
|
+
// old caller's manifest keeps its existing keys plus these two.
|
|
545
|
+
absent_members: manifest.absent_members.map((a) => ({
|
|
546
|
+
member: a.member,
|
|
547
|
+
reason: a.reason,
|
|
548
|
+
detail: a.detail,
|
|
549
|
+
})),
|
|
550
|
+
quorum: manifest.quorum,
|
|
507
551
|
...manifest.extra,
|
|
508
552
|
};
|
|
509
553
|
|