@event4u/agent-config 9.28.0 → 9.29.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 (147) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/AGENTS.md +6 -6
  3. package/CHANGELOG.md +66 -0
  4. package/README.md +1 -1
  5. package/agents/templates/.ai-council.yml.example +55 -24
  6. package/dist/agent-src/commands/cost/report.md +2 -2
  7. package/dist/agent-src/commands/team/adversarial.md +8 -7
  8. package/dist/agent-src/commands/team/delegate.md +13 -12
  9. package/dist/agent-src/commands/team/review.md +9 -8
  10. package/dist/agent-src/commands/team/status.md +9 -8
  11. package/dist/agent-src/commands/team.md +29 -19
  12. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +88 -2
  13. package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +29 -21
  14. package/dist/agent-src/contexts/execution/host-capability-manifest.md +26 -15
  15. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +18 -0
  16. package/dist/agent-src/contexts/execution/subagent-response-contract.md +54 -0
  17. package/dist/agent-src/contexts/execution/subagent-routing.md +13 -11
  18. package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +25 -0
  19. package/dist/agent-src/contexts/execution/subagent-steering.md +6 -3
  20. package/dist/agent-src/rules/delegation-policy.md +11 -7
  21. package/dist/agent-src/rules/settings-ask-protocol.md +8 -7
  22. package/dist/agent-src/rules/token-budget-discipline.md +1 -1
  23. package/dist/agent-src/skills/forensics-report/SKILL.md +114 -0
  24. package/dist/agent-src/skills/forensics-report/evals/triggers.json +17 -0
  25. package/dist/agent-src/skills/premortem/SKILL.md +30 -1
  26. package/dist/agent-src/skills/reasoning-orchestrator/SKILL.md +2 -1
  27. package/dist/agent-src/skills/subagent-orchestration/SKILL.md +7 -5
  28. package/dist/agent-src/skills/subagent-orchestration/schemas/subagent-status.json +61 -3
  29. package/dist/agent-src/skills/test-case-discovery/SKILL.md +1 -1
  30. package/dist/agent-src/templates/AGENTS.md +4 -4
  31. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  32. package/dist/agent-src/templates/roadmaps.md +23 -0
  33. package/dist/cli-delegate/{chunk-22GCIJ2O.js → chunk-66OVWGB5.js} +1 -1
  34. package/dist/cli-delegate/{chunk-GB2FSUEE.js → chunk-LCFJIQ4J.js} +32 -0
  35. package/dist/cli-delegate/{chunk-VSYT7OHO.js → chunk-LVRYBKTI.js} +31 -2
  36. package/dist/cli-delegate/{chunk-DVRS2KVR.js → chunk-MMXA5GY7.js} +10 -17
  37. package/dist/cli-delegate/{chunk-JXARCGVN.js → chunk-MP3JT3IU.js} +1 -1
  38. package/dist/cli-delegate/{chunk-PQSAQKHW.js → chunk-NZ5N2XQL.js} +3 -3
  39. package/dist/cli-delegate/{chunk-7OMQCH4V.js → chunk-OJM3MBJM.js} +1 -1
  40. package/dist/cli-delegate/{chunk-2BE5I2MN.js → chunk-RCIA67XN.js} +111 -90
  41. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  42. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  43. package/dist/cli-delegate/cmd_conformance.js +6 -43
  44. package/dist/cli-delegate/cmd_doctor.js +9 -5
  45. package/dist/cli-delegate/cmd_explain.js +2 -2
  46. package/dist/cli-delegate/cmd_export.js +4 -4
  47. package/dist/cli-delegate/cmd_handoff.js +3 -3
  48. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  49. package/dist/cli-delegate/cmd_migrate.js +2 -2
  50. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  51. package/dist/cli-delegate/cmd_prune.js +4 -4
  52. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  53. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  54. package/dist/cli-delegate/cmd_settings_get.js +1 -7
  55. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  56. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  57. package/dist/cli-delegate/cmd_sync.js +4 -4
  58. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  59. package/dist/cli-delegate/cmd_update.js +1 -1
  60. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  61. package/dist/cli-delegate/cmd_validate.js +4 -4
  62. package/dist/cli-delegate/cmd_versions.js +1 -1
  63. package/dist/discovery/deprecation-report.md +1 -1
  64. package/dist/discovery/discovery-manifest.json +69 -20
  65. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  66. package/dist/discovery/discovery-manifest.summary.md +3 -2
  67. package/dist/discovery/orphan-report.md +1 -1
  68. package/dist/discovery/packs.json +39 -2
  69. package/dist/discovery/trust-report.md +2 -2
  70. package/dist/discovery/workspaces.json +12 -4
  71. package/dist/hooks/dispatch.js +373 -35
  72. package/dist/install/install.mjs +42 -17
  73. package/dist/mcp/registry-manifest.json +2 -2
  74. package/dist/mcp/server.json +2 -2
  75. package/dist/mcp/server.mjs +651 -262
  76. package/dist/router.json +1 -1
  77. package/dist/scripts/_lib/agent_settings.js +54 -0
  78. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  79. package/dist/scripts/condense.js +147 -2
  80. package/dist/scripts/condense.js.map +1 -1
  81. package/dist/server/schemas/settings.js +6 -7
  82. package/dist/server/schemas/settings.js.map +1 -1
  83. package/dist/shared/settingsCarveOut.js +0 -9
  84. package/dist/shared/settingsCarveOut.js.map +1 -1
  85. package/dist/shared/settingsClasses.js +11 -9
  86. package/dist/shared/settingsClasses.js.map +1 -1
  87. package/dist/ui/assets/{index-DmR0mbk4.js → index-JDAF00bC.js} +2 -2
  88. package/dist/ui/assets/{index-DmR0mbk4.js.map → index-JDAF00bC.js.map} +1 -1
  89. package/dist/ui/index.html +1 -1
  90. package/docs/contracts/settings-classes.md +39 -9
  91. package/package.json +1 -1
  92. package/src/agent-src/templates/AGENTS.md +4 -4
  93. package/src/config/agent-settings.template.yml +52 -66
  94. package/src/config/discovery/packs.yml +11 -0
  95. package/src/config/discovery/workspaces.yml +1 -1
  96. package/src/config/gate-violation-baselines.json +3 -3
  97. package/src/scripts/_cli/cmd_conformance.ts +14 -42
  98. package/src/scripts/_cli/cmd_doctor.ts +65 -48
  99. package/src/scripts/_lib/agent_settings.ts +58 -0
  100. package/src/scripts/_lib/auto_dispatch.corpus.test.ts +1 -1
  101. package/src/scripts/_lib/auto_dispatch.ts +23 -18
  102. package/src/scripts/_lib/capsule_trigger.ts +124 -0
  103. package/src/scripts/_lib/host_capability.ts +63 -6
  104. package/src/scripts/_lib/judgment_ladder.ts +382 -0
  105. package/src/scripts/_lib/orchestration_gate.ts +8 -0
  106. package/src/scripts/_lib/orchestration_record.ts +37 -0
  107. package/src/scripts/_lib/orchestration_savings.ts +1 -1
  108. package/src/scripts/_lib/self_repair.ts +160 -13
  109. package/src/scripts/_lib/self_repair_store.ts +28 -0
  110. package/src/scripts/_lib/subagent_capsule.ts +182 -0
  111. package/src/scripts/_lib/subagent_response.ts +19 -0
  112. package/src/scripts/_lib/subagent_spawn.ts +13 -0
  113. package/src/scripts/_lib/subagent_steering.ts +9 -4
  114. package/src/scripts/_lib/tier_budget_routing.ts +6 -1
  115. package/src/scripts/_lib/worker_budget.ts +37 -2
  116. package/src/scripts/ai_council/clients.ts +76 -54
  117. package/src/scripts/ai_council/config.ts +94 -6
  118. package/src/scripts/ai_council/handoff.ts +113 -0
  119. package/src/scripts/ai_council/modes.ts +19 -4
  120. package/src/scripts/ai_council/orchestrator.ts +105 -0
  121. package/src/scripts/ai_council/quorum.ts +84 -0
  122. package/src/scripts/ai_council/session.ts +44 -0
  123. package/src/scripts/ai_council/transport_resolver.ts +127 -1
  124. package/src/scripts/ai_team/availability.ts +110 -0
  125. package/src/scripts/ai_team/config.ts +34 -14
  126. package/src/scripts/ai_team/team_dispatch.ts +71 -29
  127. package/src/scripts/bench_critic_protocol.ts +396 -0
  128. package/src/scripts/check_detector_corpus.ts +202 -0
  129. package/src/scripts/check_hook_bundle_freshness.ts +4 -1
  130. package/src/scripts/condense.ts +162 -2
  131. package/src/scripts/council_cli.ts +401 -25
  132. package/src/scripts/forensics_report.ts +333 -0
  133. package/src/scripts/hooks/delegation_nudge_hook.ts +69 -35
  134. package/src/scripts/lint_discovery_vocabulary.ts +2 -0
  135. package/src/scripts/lint_never_silent.ts +276 -0
  136. package/src/scripts/lint_no_activation_gates.ts +271 -0
  137. package/src/scripts/orchestration_record.ts +8 -0
  138. package/src/scripts/rebuild_hook_bundle.ts +177 -0
  139. package/src/scripts/release.ts +74 -2
  140. package/src/scripts/routing_doctor.ts +21 -36
  141. package/src/scripts/score_skill_selection.ts +19 -5
  142. package/src/scripts/self_repair_cli.ts +287 -57
  143. package/src/scripts/team_events_spike.ts +271 -0
  144. package/src/scripts/team_review_gate_hook.ts +31 -9
  145. package/src/server/schemas/settings.ts +10 -17
  146. package/src/shared/settingsCarveOut.ts +0 -10
  147. package/src/shared/settingsClasses.ts +11 -9
@@ -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
 
@@ -48,10 +48,22 @@ import {
48
48
  type BillingClass,
49
49
  type EnvironmentReport,
50
50
  } from '../_lib/environment_detector.js';
51
+ import { resolve_mode } from './modes.js';
51
52
 
52
53
  /** A concrete transport a member can actually run on. `auto` is not one. */
53
54
  export type Transport = 'api' | 'manual' | 'cli';
54
55
 
56
+ /**
57
+ * Machine-readable reason a member is `absent` from a pass
58
+ * (road-to-always-on-orchestration Phase 3.2). The static half — `no_binary`
59
+ * / `no_auth` — is decided here, at resolution time, before any provider
60
+ * call is attempted. The runtime half — `timeout` / `quota` — only exists
61
+ * once a call was actually attempted and failed; see
62
+ * `absentReasonFromCliFailure` below for the mapping FROM the mid-flight
63
+ * `CliFailureClass` a caller already classifies its errors into.
64
+ */
65
+ export type AbsentReason = 'no_binary' | 'no_auth' | 'timeout' | 'quota';
66
+
55
67
  /** The accepted values of the `mode` key, including the resolver-only `auto`. */
56
68
  export const VALID_TRANSPORT_MODES: ReadonlySet<string> = new Set([
57
69
  'api',
@@ -73,6 +85,16 @@ export interface ResolvedTransport {
73
85
  readonly makesProviderCall: boolean;
74
86
  /** One-line reason. Non-empty exactly when `available` is false. */
75
87
  readonly reason: string | null;
88
+ /**
89
+ * Machine-readable classification of `reason`, for a pass artifact that
90
+ * needs to bucket absent members without parsing prose (Phase 3.2).
91
+ * Non-null for the two STATIC auto-chain failures this function can
92
+ * classify (`no_binary` / `no_auth`); `null` when `available` is true,
93
+ * and also `null` for the "unknown mode" validation-error path below —
94
+ * that is a config bug, not a missing-capability finding, so it does not
95
+ * belong in this enum.
96
+ */
97
+ readonly absentReason: AbsentReason | null;
76
98
  }
77
99
 
78
100
  export interface ResolveTransportOptions {
@@ -149,6 +171,7 @@ export function resolveTransport(opts: ResolveTransportOptions): ResolvedTranspo
149
171
  authSource,
150
172
  makesProviderCall: false,
151
173
  reason: null,
174
+ absentReason: null,
152
175
  };
153
176
  }
154
177
 
@@ -160,6 +183,7 @@ export function resolveTransport(opts: ResolveTransportOptions): ResolvedTranspo
160
183
  authSource,
161
184
  makesProviderCall: true,
162
185
  reason: null,
186
+ absentReason: null,
163
187
  };
164
188
  }
165
189
 
@@ -185,6 +209,7 @@ export function resolveTransport(opts: ResolveTransportOptions): ResolvedTranspo
185
209
  authSource,
186
210
  makesProviderCall: true,
187
211
  reason: null,
212
+ absentReason: null,
188
213
  };
189
214
  }
190
215
 
@@ -197,16 +222,80 @@ export function resolveTransport(opts: ResolveTransportOptions): ResolvedTranspo
197
222
  authSource,
198
223
  makesProviderCall: true,
199
224
  reason: null,
225
+ absentReason: null,
200
226
  };
201
227
  }
202
228
 
203
- return unavailable(provider, billing, autoUnavailableReason(provider, binary, cliAuthed));
229
+ return unavailable(
230
+ provider,
231
+ billing,
232
+ autoUnavailableReason(provider, binary, cliAuthed),
233
+ classifyStaticAbsentReason(binary),
234
+ );
235
+ }
236
+
237
+ export interface ResolveMemberTransportOptions {
238
+ readonly provider: string;
239
+ /** The single read-only environment report `resolveTransport` needs. */
240
+ readonly report: EnvironmentReport;
241
+ /** e.g. `/council mode:manual`. Highest precedence layer. */
242
+ readonly invocationMode?: string | null;
243
+ /** The member's own config sub-dict (`ai_council.members.<name>`). */
244
+ readonly memberSettings?: ReadonlyMap<string, unknown> | Record<string, unknown> | null;
245
+ /** `ai_council.mode` / `defaults.mode`, already extracted by the caller. */
246
+ readonly globalMode?: string | null;
247
+ readonly binaryOverride?: string | null;
248
+ readonly apiKeyPresent?: boolean;
249
+ }
250
+
251
+ export interface ResolvedMemberTransport extends ResolvedTransport {
252
+ /** The mode `resolve_mode` chose BEFORE any `auto` expansion. */
253
+ readonly configuredMode: string;
254
+ }
255
+
256
+ /**
257
+ * The single reconciled entry point for "given this member's config, what
258
+ * transport does it actually run on" (road-to-always-on-orchestration
259
+ * Phase 3.1). Composes `modes.ts::resolve_mode` (which layer decided the
260
+ * mode) with `resolveTransport` (what `auto` expands to on THIS machine) so
261
+ * a caller never has to replicate either step by hand.
262
+ *
263
+ * This exists because the two functions previously lived at arm's length:
264
+ * `resolve_mode` can legitimately return the literal string `'auto'`, and
265
+ * nothing forced a caller to then route that value through
266
+ * `resolveTransport` — a hand-rolled `mode === 'api' | 'cli' | 'manual'`
267
+ * switch (the shape `council_cli.ts::build_members` still uses today) has
268
+ * no `'auto'` case and treats it as an unrecognised transport instead of
269
+ * expanding it. `resolveMemberTransport` is the fix: there is no longer a
270
+ * gap between "which mode was configured" and "which transport that becomes"
271
+ * for any caller that uses it instead of the two functions separately.
272
+ */
273
+ export function resolveMemberTransport(
274
+ opts: ResolveMemberTransportOptions,
275
+ ): ResolvedMemberTransport {
276
+ const configuredMode = resolve_mode(opts.provider, {
277
+ invocationMode: opts.invocationMode ?? null,
278
+ memberSettings: opts.memberSettings ?? null,
279
+ globalMode: opts.globalMode ?? null,
280
+ });
281
+ const resolved = resolveTransport({
282
+ provider: opts.provider,
283
+ mode: configuredMode,
284
+ report: opts.report,
285
+ binaryOverride: opts.binaryOverride ?? null,
286
+ // `exactOptionalPropertyTypes` rejects an explicit `undefined` for an
287
+ // optional-boolean field, so the key is omitted entirely rather than
288
+ // forwarded as `apiKeyPresent: undefined`.
289
+ ...(opts.apiKeyPresent !== undefined ? { apiKeyPresent: opts.apiKeyPresent } : {}),
290
+ });
291
+ return { ...resolved, configuredMode };
204
292
  }
205
293
 
206
294
  function unavailable(
207
295
  _provider: string,
208
296
  billing: BillingClass,
209
297
  reason: string,
298
+ absentReason: AbsentReason | null = null,
210
299
  ): ResolvedTransport {
211
300
  return {
212
301
  available: false,
@@ -217,9 +306,21 @@ function unavailable(
217
306
  authSource: null,
218
307
  makesProviderCall: false,
219
308
  reason,
309
+ absentReason,
220
310
  };
221
311
  }
222
312
 
313
+ /**
314
+ * `no_binary` when the CLI binary itself never resolved (whether or not a
315
+ * credential was found — a credential with nothing to run it against is
316
+ * still "the binary is missing" from the caller's point of view); `no_auth`
317
+ * when the binary IS present but neither the cli-auth nor the key rung
318
+ * resolved. Mirrors `autoUnavailableReason`'s three-way branch below.
319
+ */
320
+ function classifyStaticAbsentReason(binary: string | null): AbsentReason {
321
+ return binary === null ? 'no_binary' : 'no_auth';
322
+ }
323
+
223
324
  /** The one-line reason `auto` prints when no rung resolved. */
224
325
  function autoUnavailableReason(
225
326
  provider: string,
@@ -298,6 +399,31 @@ export function isFallbackEligible(failure: CliFailureClass): boolean {
298
399
  return FALLBACK_ELIGIBLE.has(failure);
299
400
  }
300
401
 
402
+ /**
403
+ * Maps a mid-flight `CliFailureClass` (a call was actually attempted and
404
+ * failed) onto the same `AbsentReason` enum a static resolution failure
405
+ * uses (road-to-always-on-orchestration Phase 3.2/3.4), so a pass artifact
406
+ * can bucket every absent member — whether it never got a transport at all
407
+ * or lost one partway through — under one vocabulary. Returns `null` for a
408
+ * failure class outside the four-value enum (`cli_unsupported`,
409
+ * `server_error`, `other`); a caller falls back to the raw failure detail
410
+ * for those rather than mis-classifying them.
411
+ */
412
+ export function absentReasonFromCliFailure(failure: CliFailureClass): AbsentReason | null {
413
+ switch (failure) {
414
+ case 'binary_missing':
415
+ return 'no_binary';
416
+ case 'auth_rejected':
417
+ return 'no_auth';
418
+ case 'timeout':
419
+ return 'timeout';
420
+ case 'quota_exhausted':
421
+ return 'quota';
422
+ default:
423
+ return null;
424
+ }
425
+ }
426
+
301
427
  /**
302
428
  * One-shot, per-provider fallback ledger for a single invocation.
303
429
  *
@@ -0,0 +1,110 @@
1
+ /**
2
+ * `/team` availability — CLI-first, not settings-gated
3
+ * (road-to-always-on-orchestration Phase 1, Step 1.3).
4
+ *
5
+ * `ai_team.enabled` — the former master switch — was DELETED. `/team`'s
6
+ * on/off state is now a FACT resolved from the machine, the same doctrine
7
+ * already applied to the subagent and council layers (Phase 1): the codex
8
+ * CLI binary must resolve on `$PATH`, AND some auth for the `openai`
9
+ * provider must be detectable (subscription login, API key, key file, or
10
+ * env key — the same transport-preference order `auto` walks via
11
+ * `strongestAuth`). Reuses the existing, read-only, zero-spend,
12
+ * per-process-cached detector (`_lib/environment_detector.ts`); this module
13
+ * adds NO new probing and NO new cache of its own.
14
+ *
15
+ * `ai_team.allow_delegate` is UNCHANGED by this module: a distinct,
16
+ * write-access authorization gate that stacks ON TOP of availability, never
17
+ * a replacement for it (`assert_delegate_allowed` in `team_dispatch.ts`
18
+ * still checks it separately, after availability).
19
+ */
20
+ import { detectEnvironment, strongestAuth } from '../_lib/environment_detector.js';
21
+ import { load_agent_settings, type SettingsDict } from '../_lib/agent_settings.js';
22
+ import { _team_codex_binary_name, shutilWhich } from '../_cli/cmd_doctor.js';
23
+
24
+ /** Tool id `environment_detector` uses for the codex CLI (`install/toolDetection.ts`). */
25
+ const CODEX_HOST_ID = 'codex';
26
+
27
+ /** Provider id `environment_detector` / `PROVIDER_CLI_META` uses for the codex CLI. */
28
+ const CODEX_PROVIDER = 'openai';
29
+
30
+ /** The provider default `_team_codex_binary_name` falls back to with no override configured. */
31
+ const DEFAULT_CODEX_BINARY = 'codex';
32
+
33
+ export interface TeamAvailability {
34
+ readonly available: boolean;
35
+ /** One clear line for the user — populated only when `available` is false. */
36
+ readonly reason: string | null;
37
+ }
38
+
39
+ /**
40
+ * codex CLI binary + auth presence — the availability half of the `/team`
41
+ * gate. Cached per process via `detectEnvironment()`'s own no-argument memo
42
+ * (`resetEnvironmentCache()` in tests); this function adds no caching of
43
+ * its own.
44
+ *
45
+ * m5 fix (independent-review finding) — `detectEnvironment()`'s host probe
46
+ * is hardcoded to the literal binary name `codex`; it has no way to be told
47
+ * "check this other name/path instead". So an operator who set
48
+ * `members.openai.binary:` in `.ai-council.yml` (honoured under an
49
+ * effective mode of `cli` OR `auto` — `cmd_doctor.ts::_mayRunOverCli`) was
50
+ * invisible to this check: `/team` could report "codex CLI not available"
51
+ * even with a working, custom-named/pathed binary configured and
52
+ * functional. `_team_codex_binary_name` (shared with `cmd_doctor.ts`'s own
53
+ * `council-cli` / `team` checks, so the override-resolution logic has one
54
+ * source) resolves the SAME name `cmd_doctor` would use; when it differs
55
+ * from the provider default, this probes that name directly instead of
56
+ * trusting the fixed-name host detector.
57
+ */
58
+ export function checkCodexAvailability(project_root: string = process.cwd()): TeamAvailability {
59
+ const report = detectEnvironment();
60
+ const configuredBinary = _team_codex_binary_name(project_root);
61
+ const installed =
62
+ configuredBinary !== DEFAULT_CODEX_BINARY
63
+ ? shutilWhich(configuredBinary) !== null
64
+ : (report.hosts.find((h) => h.id === CODEX_HOST_ID)?.installed ?? false);
65
+ if (!installed) {
66
+ return {
67
+ available: false,
68
+ reason:
69
+ 'codex CLI not available — /team needs the codex CLI installed and ' +
70
+ 'authenticated. Install: `npm install -g @openai/codex`, then run ' +
71
+ '`codex login`. Verify with `agent-config doctor --check team`.',
72
+ };
73
+ }
74
+ if (strongestAuth(report, CODEX_PROVIDER) === null) {
75
+ return {
76
+ available: false,
77
+ reason:
78
+ 'codex CLI not available — no auth detected for it. Run `codex login` ' +
79
+ '(or set `OPENAI_API_KEY`). Verify with `agent-config doctor --check team`.',
80
+ };
81
+ }
82
+ return { available: true, reason: null };
83
+ }
84
+
85
+ /**
86
+ * `emergency.orchestration_halt` — the one audited incident switch over the
87
+ * always-on orchestration stack (subagents, council, TEAM). Reads the
88
+ * project settings cascade fresh (or an injected dict for tests) on every
89
+ * call — no cache of its own, so an armed halt takes effect on the very
90
+ * next read, same as every other `orchestration_halt` reader in the tree
91
+ * (`routing_doctor.ts`, `delegation_nudge_hook.ts`).
92
+ */
93
+ export function isOrchestrationHalted(
94
+ settings?: SettingsDict | null,
95
+ cwd?: string | null,
96
+ ): boolean {
97
+ const merged = settings ?? load_agent_settings({ cwd: cwd ?? process.cwd() });
98
+ const emergency = (merged as Record<string, unknown>)['emergency'];
99
+ if (typeof emergency !== 'object' || emergency === null || Array.isArray(emergency)) {
100
+ return false;
101
+ }
102
+ return (emergency as Record<string, unknown>)['orchestration_halt'] === true;
103
+ }
104
+
105
+ /** The one-line halt notice — `/team` prints this verbatim when halted. */
106
+ export const ORCHESTRATION_HALT_MESSAGE =
107
+ '/team is halted (`emergency.orchestration_halt: true`) — the always-on ' +
108
+ 'orchestration stack (subagents, council, team) is paused for incident ' +
109
+ 'response. Resume: set `orchestration_halt: false` with a non-empty ' +
110
+ '`orchestration_halt_justification` in `.agent-settings.yml`.';