@event4u/agent-config 12.1.0 → 14.0.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 (185) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +164 -186
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/cost/report.md +23 -0
  5. package/dist/agent-src/commands/evals/roadmap.json +8 -0
  6. package/dist/agent-src/commands/fix/ci.md +39 -1
  7. package/dist/agent-src/commands/pr/create.md +65 -4
  8. package/dist/agent-src/commands/roadmap/next.md +28 -5
  9. package/dist/agent-src/commands/work.md +16 -0
  10. package/dist/agent-src/contexts/contracts/command-suggestion-flow.md +47 -0
  11. package/dist/agent-src/contexts/execution/contract-decision-sheet.md +94 -0
  12. package/dist/agent-src/contexts/execution/plan-confidence-gate.md +30 -0
  13. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +21 -8
  14. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +55 -8
  15. package/dist/agent-src/contexts/execution/subagent-response-contract.md +42 -0
  16. package/dist/agent-src/contexts/execution/subagent-routing.md +148 -33
  17. package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +29 -0
  18. package/dist/agent-src/contexts/subagent-configuration.md +69 -7
  19. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  20. package/dist/agent-src/rules/command-suggestion-policy.md +33 -0
  21. package/dist/agent-src/rules/design-fidelity.md +1 -1
  22. package/dist/agent-src/rules/design-review-after-ui-write.md +36 -14
  23. package/dist/agent-src/rules/evaluator-independence.md +27 -5
  24. package/dist/agent-src/rules/git-history-discipline.md +5 -1
  25. package/dist/agent-src/rules/missing-skill-recovery.md +89 -0
  26. package/dist/agent-src/rules/ui-audit-gate.md +34 -12
  27. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
  28. package/dist/agent-src/scripts/gate_execute.ts +395 -0
  29. package/dist/agent-src/scripts/resume_probe.ts +422 -0
  30. package/dist/agent-src/scripts/roadmap_gates.ts +138 -4
  31. package/dist/agent-src/scripts/update_roadmap_progress.ts +70 -4
  32. package/dist/agent-src/skills/ai-council/SKILL.md +61 -0
  33. package/dist/agent-src/skills/authz-review/SKILL.md +4 -0
  34. package/dist/agent-src/skills/design-system-capture/SKILL.md +4 -0
  35. package/dist/agent-src/skills/design-system-capture/references/snapshot-preference-order.md +77 -0
  36. package/dist/agent-src/skills/experiment-loop/SKILL.md +116 -0
  37. package/dist/agent-src/skills/experiment-loop/evals/output-schema.yml +27 -0
  38. package/dist/agent-src/skills/experiment-loop/evals/triggers.json +17 -0
  39. package/dist/agent-src/skills/experiment-loop/references/pivot-ladder.md +54 -0
  40. package/dist/agent-src/skills/experiment-loop/references/protocol.md +61 -0
  41. package/dist/agent-src/skills/experiment-loop/references/register-format.md +66 -0
  42. package/dist/agent-src/skills/merge-conflicts/SKILL.md +4 -0
  43. package/dist/agent-src/skills/prompt-optimizer/SKILL.md +17 -1
  44. package/dist/agent-src/skills/roadmap-management/SKILL.md +2 -2
  45. package/dist/agent-src/skills/roadmap-writing/SKILL.md +5 -5
  46. package/dist/agent-src/skills/subagent-orchestration/schemas/team-review-status.json +8 -3
  47. package/dist/agent-src/skills/systematic-debugging/SKILL.md +4 -0
  48. package/dist/agent-src/skills/threat-modeling/SKILL.md +3 -0
  49. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  50. package/dist/agent-src/templates/roadmaps.md +43 -3
  51. package/dist/cli/registry.js +1 -0
  52. package/dist/cli/registry.js.map +1 -1
  53. package/dist/cli-delegate/{chunk-K34QTSD5.js → chunk-7PZ4FSZB.js} +368 -191
  54. package/dist/cli-delegate/{chunk-55WZN5VF.js → chunk-EVTY7ISR.js} +2 -2
  55. package/dist/cli-delegate/{chunk-O52B4F5S.js → chunk-F3YUF7SA.js} +1 -1
  56. package/dist/cli-delegate/{chunk-LKZ5DOYW.js → chunk-XURJ4AQC.js} +11 -3
  57. package/dist/cli-delegate/cmd_conformance.js +224 -104
  58. package/dist/cli-delegate/cmd_doctor.js +2 -2
  59. package/dist/cli-delegate/cmd_export.js +2 -2
  60. package/dist/cli-delegate/cmd_handoff.js +1 -1
  61. package/dist/cli-delegate/cmd_prune.js +2 -2
  62. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  63. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  64. package/dist/cli-delegate/cmd_sync.js +2 -2
  65. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  66. package/dist/cli-delegate/cmd_validate.js +2 -2
  67. package/dist/discovery/deprecation-report.md +1 -1
  68. package/dist/discovery/discovery-manifest.json +73 -30
  69. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  70. package/dist/discovery/discovery-manifest.summary.md +3 -3
  71. package/dist/discovery/dormancy-report.md +1 -1
  72. package/dist/discovery/orphan-report.md +1 -1
  73. package/dist/discovery/packs.json +7 -5
  74. package/dist/discovery/trust-report.md +3 -3
  75. package/dist/discovery/workspaces.json +6 -4
  76. package/dist/hooks/dispatch.js +2653 -1365
  77. package/dist/install/install.mjs +12 -4
  78. package/dist/mcp/registry-manifest.json +3 -3
  79. package/dist/mcp/server.json +2 -2
  80. package/dist/mcp/server.mjs +2679 -887
  81. package/dist/router.json +1 -1
  82. package/dist/scripts/condense.js +1 -1
  83. package/dist/scripts/condense.js.map +1 -1
  84. package/dist/server/schemas/settings.js +2 -2
  85. package/dist/server/schemas/settings.js.map +1 -1
  86. package/dist/ui/assets/{index-B0aMJnuz.js → index-C1pBRm1f.js} +2 -2
  87. package/dist/ui/assets/index-C1pBRm1f.js.map +1 -0
  88. package/dist/ui/index.html +1 -1
  89. package/docs/guidelines/design-fidelity-mechanics.md +21 -0
  90. package/docs/guidelines/design-handover-extraction.md +87 -0
  91. package/docs/guidelines/prompt-templates.md +24 -1
  92. package/package.json +1 -1
  93. package/src/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
  94. package/src/agent-src/scripts/gate_execute.ts +395 -0
  95. package/src/agent-src/scripts/resume_probe.ts +422 -0
  96. package/src/agent-src/scripts/roadmap_gates.ts +138 -4
  97. package/src/agent-src/scripts/update_roadmap_progress.ts +70 -4
  98. package/src/config/ci-local-parity.yml +13 -0
  99. package/src/config/evaluator-budgets.json +9 -8
  100. package/src/config/gate-coverage.yml +140 -12
  101. package/src/config/gate-violation-baselines.json +9 -3
  102. package/src/config/hook-token-budget.json +8 -1
  103. package/src/config/rule-activation-census.json +41 -0
  104. package/src/config/rule-stub-ceilings.json +52 -0
  105. package/src/scripts/_dispatch.bash +4 -0
  106. package/src/scripts/_lib/bench_ab_complexity.ts +439 -0
  107. package/src/scripts/_lib/bench_ab_safety_tier.ts +142 -0
  108. package/src/scripts/_lib/bench_ab_scoring_v2.ts +28 -3
  109. package/src/scripts/_lib/bench_ab_search_adherence.ts +423 -0
  110. package/src/scripts/_lib/bench_ab_size_claim.ts +206 -0
  111. package/src/scripts/_lib/bench_ab_workspace.ts +111 -0
  112. package/src/scripts/_lib/evaluator_contract.ts +159 -0
  113. package/src/scripts/_lib/gh_transient.ts +80 -0
  114. package/src/scripts/_lib/release_material.ts +16 -5
  115. package/src/scripts/_lib/review_independence.ts +111 -0
  116. package/src/scripts/_lib/session_register.ts +14 -0
  117. package/src/scripts/_lib/skill_catalogue.ts +278 -5
  118. package/src/scripts/_lib/skill_catalogue_series.ts +411 -0
  119. package/src/scripts/_lib/tier_budget_routing.ts +31 -331
  120. package/src/scripts/_lib/turn_end_refusals.ts +482 -0
  121. package/src/scripts/ai_council/_py_json.ts +96 -0
  122. package/src/scripts/ai_council/cli_call_budget.ts +357 -0
  123. package/src/scripts/ai_council/clients.ts +101 -164
  124. package/src/scripts/ai_council/config.ts +25 -11
  125. package/src/scripts/ai_council/probe_store.ts +196 -0
  126. package/src/scripts/ai_council/qualification.ts +425 -0
  127. package/src/scripts/ai_council/qualification_wiring.ts +243 -0
  128. package/src/scripts/ai_council/quorum_wiring.ts +211 -0
  129. package/src/scripts/ai_team/team_dispatch.ts +40 -6
  130. package/src/scripts/bench_ab_v2_complexity.ts +252 -0
  131. package/src/scripts/bench_ab_v2_run.ts +3 -42
  132. package/src/scripts/bench_ab_v2_safety.ts +296 -0
  133. package/src/scripts/bench_ab_v2_search.ts +343 -0
  134. package/src/scripts/bench_ab_v2_stats.ts +191 -0
  135. package/src/scripts/build_archive_index.ts +383 -0
  136. package/src/scripts/capture_skill_catalogue.ts +369 -4
  137. package/src/scripts/check_evaluator_schema.ts +249 -0
  138. package/src/scripts/check_pr_ci_current.ts +409 -0
  139. package/src/scripts/check_review_schema.ts +195 -0
  140. package/src/scripts/check_rule_activation_census.ts +451 -0
  141. package/src/scripts/check_rule_stub_ceiling.ts +1128 -0
  142. package/src/scripts/check_source_size_budget.ts +428 -0
  143. package/src/scripts/check_suppression_hygiene.ts +5 -1
  144. package/src/scripts/condense.ts +1 -1
  145. package/src/scripts/cost/backfill_rates.mjs +298 -0
  146. package/src/scripts/cost/budget.mjs +12 -32
  147. package/src/scripts/cost_summary.ts +25 -0
  148. package/src/scripts/council_cli.ts +141 -191
  149. package/src/scripts/dispatch_r2_reviewer.ts +8 -1
  150. package/src/scripts/external_sources_denylist.json +1 -0
  151. package/src/scripts/hook_manifest.yaml +99 -6
  152. package/src/scripts/hooks/concern_registry.ts +6 -0
  153. package/src/scripts/hooks/dispatch_hook.ts +43 -1
  154. package/src/scripts/hooks/interruption_ledger_hook.ts +256 -0
  155. package/src/scripts/hooks/ship_diff_volume_hook.ts +180 -0
  156. package/src/scripts/hooks/skill_route_hook.ts +226 -0
  157. package/src/scripts/hooks/turn_end_gate_hook.ts +119 -26
  158. package/src/scripts/hooks/ui_route_nudge_hook.ts +6 -3
  159. package/src/scripts/hooks_doctor.ts +112 -0
  160. package/src/scripts/interruption_report.ts +443 -0
  161. package/src/scripts/lint_design_antipattern_parity.ts +40 -6
  162. package/src/scripts/lint_evidence_artifacts.ts +359 -0
  163. package/src/scripts/lint_harvest_provenance.ts +35 -1
  164. package/src/scripts/lint_roadmap_blockers.ts +65 -0
  165. package/src/scripts/lint_roadmap_complexity.ts +7 -1
  166. package/src/scripts/lint_rule_skill_pack_reach.ts +43 -0
  167. package/src/scripts/lint_skill_router_head.ts +21 -0
  168. package/src/scripts/mcp_server/consumer_tool_catalog.json +26 -3
  169. package/src/scripts/mcp_server/tool_catalog_source.ts +6 -24
  170. package/src/scripts/mcp_server/tools.ts +87 -0
  171. package/src/scripts/release.ts +43 -58
  172. package/src/scripts/routing_doctor.ts +50 -13
  173. package/src/scripts/rule_activation_census.ts +292 -0
  174. package/src/scripts/schemas/command.schema.json +1 -1
  175. package/src/scripts/schemas/evaluator-output.schema.json +60 -0
  176. package/src/scripts/schemas/review-findings.schema.json +31 -0
  177. package/src/scripts/schemas/skill.schema.json +18 -1
  178. package/src/scripts/self_review_gate.ts +6 -0
  179. package/src/scripts/session_register_hook.ts +30 -1
  180. package/src/scripts/sync_pr_branch.ts +271 -0
  181. package/src/scripts/test_council_qualification.ts +213 -0
  182. package/src/scripts/trigger_coverage.ts +224 -3
  183. package/src/server/schemas/settings.ts +2 -2
  184. package/dist/ui/assets/index-B0aMJnuz.js.map +0 -1
  185. package/src/config/budget-routing.json +0 -7
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Provider qualification for a council seat (road-to-release-review-p0 Phase 3).
3
+ *
4
+ * The failure this module exists to stop is recorded, not hypothetical: a seat
5
+ * reported as `CONFIGURED` while being entirely dead, and the pass printed a
6
+ * quorum it never reached. `cmd_status` answered from `member.enabled`, which
7
+ * is a boolean in a YAML file — it says the operator wrote the seat down, not
8
+ * that anything on the other end can answer. A boolean cannot express the
9
+ * middle, so this module produces FOUR verdicts and an ordered ladder of the
10
+ * checks that produced them.
11
+ *
12
+ * The four verdicts, and what each one licenses:
13
+ *
14
+ * - `available` — every applicable check passed AND a live exchange with the
15
+ * provider has been observed within the freshness window.
16
+ * - `degraded` — the seat is reachable but impaired (a capped quota, a
17
+ * transport that performs no provider call, a transient
18
+ * fault). It may answer; its answer may be worth less.
19
+ * - `unavailable` — a check hard-failed. Nothing will answer.
20
+ * - `unknown` — a check could not be evaluated. Most often: nothing has
21
+ * ever been observed to work on this seat.
22
+ *
23
+ * **`unknown` is the load-bearing one.** Before this module the tree had no way
24
+ * to say "configured, plausible, and never once demonstrated" — that state read
25
+ * as available, which is precisely how a dead seat passed for a live one. A
26
+ * never-probed seat therefore qualifies as `unknown`, and `unknown` is not
27
+ * countable toward presence.
28
+ *
29
+ * That is deliberately not a permanent tax on the operator. A council run that
30
+ * gets a non-empty answer from a seat IS the observation, so a probe record
31
+ * costs nothing extra and normal use decays `unknown` to `available` on its
32
+ * own. No check in this module performs a provider call, and none may: the
33
+ * module is pure — no filesystem, no environment, no clock beyond an injected
34
+ * `now` — for the same reason `quorum.ts` is. A qualification pass that could
35
+ * spend money would be a qualification pass nobody dares run.
36
+ *
37
+ * HONEST SCOPE — two of the seven rungs have no production caller yet.
38
+ * Neither `build_members` nor `cmd_status` supplies `systemPrompt` or
39
+ * `toolsIsolated`, so `system_prompt_path` and `tool_isolation` always report
40
+ * `skipped` in a real run and can move no verdict. They are specified,
41
+ * implemented and unit-tested; they are not yet WIRED, because nothing in the
42
+ * council config records either fact today. Said plainly (R2 finding 6)
43
+ * rather than left for a reader to discover: a ladder that prints seven rungs
44
+ * while five of them decide anything is a coverage claim, and an unstated one
45
+ * is the kind this repository treats as inflation.
46
+ *
47
+ * HONEST SCOPE: this module decides nothing on its own. It classifies. Whether
48
+ * an `unknown` seat is refused, warned about, or counted is the caller's
49
+ * choice, and the callers that make it are named in the roadmap's Phase 3
50
+ * steps 3 and 4 — `cmd_status` for visibility and the quorum sites for
51
+ * presence.
52
+ */
53
+
54
+ import type { AbsentReason, CliFailureClass, Transport } from './transport_resolver.js';
55
+
56
+ /** The four-value verdict. A boolean is what this replaces; do not narrow it back. */
57
+ export type QualificationVerdict = 'available' | 'degraded' | 'unavailable' | 'unknown';
58
+
59
+ /**
60
+ * Per-check outcome.
61
+ *
62
+ * `skipped` and `unknown` are different findings and must not be merged.
63
+ * `skipped` means the check does not apply to this seat (there is no system
64
+ * prompt to validate, so nothing is being left unverified). `unknown` means the
65
+ * check applies and could not be answered — which is the state that must not
66
+ * read as success.
67
+ */
68
+ export type CheckStatus = 'pass' | 'degraded' | 'fail' | 'unknown' | 'skipped';
69
+
70
+ /**
71
+ * The ladder, in the order the roadmap names it. Order is part of the contract:
72
+ * a seat with no binary has nothing to authenticate against, so reporting an
73
+ * auth finding underneath a missing binary would be noise dressed as detail.
74
+ */
75
+ export type QualificationCheckId =
76
+ | 'installed'
77
+ | 'authenticated'
78
+ | 'transport_semantics'
79
+ | 'system_prompt_path'
80
+ | 'model_identifier'
81
+ | 'tool_isolation'
82
+ | 'live_probe';
83
+
84
+ export const QUALIFICATION_LADDER: readonly QualificationCheckId[] = [
85
+ 'installed',
86
+ 'authenticated',
87
+ 'transport_semantics',
88
+ 'system_prompt_path',
89
+ 'model_identifier',
90
+ 'tool_isolation',
91
+ 'live_probe',
92
+ ];
93
+
94
+ export interface QualificationCheck {
95
+ readonly id: QualificationCheckId;
96
+ readonly status: CheckStatus;
97
+ /** One line, always present — a `pass` explains what was observed, not only what failed. */
98
+ readonly detail: string;
99
+ }
100
+
101
+ /** A recorded observation of a real exchange with the provider. */
102
+ export interface ProbeRecord {
103
+ /** ISO-8601 date (`YYYY-MM-DD`) or timestamp. Compared by date only. */
104
+ readonly at: string;
105
+ /** `ok` for a non-empty answer; otherwise the classified failure. */
106
+ readonly outcome: 'ok' | CliFailureClass;
107
+ }
108
+
109
+ /**
110
+ * Everything the ladder needs, gathered by the caller.
111
+ *
112
+ * The transport slice is exactly the shape `resolveMemberTransport` already
113
+ * returns, so a caller passes its result straight through rather than
114
+ * re-deriving anything. Optional fields are genuinely optional: an absent
115
+ * `systemPrompt` means the seat declares none, which is `skipped`, not a gap.
116
+ */
117
+ export interface MemberQualificationInput {
118
+ readonly name: string;
119
+ readonly transport: {
120
+ readonly available: boolean;
121
+ readonly transport: Transport | null;
122
+ readonly reason: string | null;
123
+ readonly absentReason: AbsentReason | null;
124
+ };
125
+ /** The model identifier the seat would send. Empty or null is a hard fail. */
126
+ readonly modelId?: string | null;
127
+ /**
128
+ * A declared system-prompt path and whether it resolves. `exists: null`
129
+ * means the caller declined to stat it — that is `unknown`, not `pass`.
130
+ */
131
+ readonly systemPrompt?: { readonly declared: string | null; readonly exists: boolean | null } | null;
132
+ /**
133
+ * Whether the seat runs with the host's tools withheld. `false` is
134
+ * `degraded`, never `fail`: the seat still answers, but a second opinion
135
+ * that can read the same tree is not the independent voice a council is
136
+ * convened for. Absent means the seat makes no tool claim either way.
137
+ */
138
+ readonly toolsIsolated?: boolean | null;
139
+ /** The most recent recorded exchange, or null when nothing was ever observed. */
140
+ readonly lastProbe?: ProbeRecord | null;
141
+ /** How long an `ok` observation stays fresh. Default 30 days. */
142
+ readonly probeMaxAgeDays?: number;
143
+ /** Injected for determinism — never read a clock inside a classifier. */
144
+ readonly now?: Date;
145
+ }
146
+
147
+ export interface MemberQualification {
148
+ readonly name: string;
149
+ readonly verdict: QualificationVerdict;
150
+ readonly checks: readonly QualificationCheck[];
151
+ /** The check that set the verdict, or null when everything passed. */
152
+ readonly decidedBy: QualificationCheckId | null;
153
+ }
154
+
155
+ export const DEFAULT_PROBE_MAX_AGE_DAYS = 30;
156
+
157
+ /**
158
+ * Verdict severity, worst first.
159
+ *
160
+ * The rule this encodes: **the verdict is the weakest claim the evidence
161
+ * supports.** A seat with one impaired check and one unevaluated check reads
162
+ * `unknown`, not `degraded` — `degraded` asserts reachability, and an
163
+ * unevaluated check means reachability was not established. Ordering `unknown`
164
+ * above `degraded` is the whole anti-over-claim property of this module, so it
165
+ * is not an arbitrary tie-break and must not be reordered for tidiness.
166
+ */
167
+ const VERDICT_SEVERITY: Record<QualificationVerdict, number> = {
168
+ unavailable: 3,
169
+ unknown: 2,
170
+ degraded: 1,
171
+ available: 0,
172
+ };
173
+
174
+ const STATUS_TO_VERDICT: Record<Exclude<CheckStatus, 'skipped'>, QualificationVerdict> = {
175
+ fail: 'unavailable',
176
+ unknown: 'unknown',
177
+ degraded: 'degraded',
178
+ pass: 'available',
179
+ };
180
+
181
+ /**
182
+ * Failure classes that mean the seat is alive but impaired, rather than dead.
183
+ *
184
+ * **`other` is deliberately NOT here, and putting it back re-opens the exact
185
+ * defect this module exists to close.** `other` is `classifyCliFailure`'s
186
+ * catch-all, and `_postRunQuorum` routes a member that produced nothing
187
+ * through it — `'empty response body'` and `'no response'` both classify as
188
+ * `other`. Listing it as impaired therefore recorded a seat that was
189
+ * dispatched and returned silence as `degraded`, i.e. countable, i.e. present
190
+ * again on the next pass. That is the over-claim one layer down, and it
191
+ * survived until the R2 review of this branch caught it (finding 1, high).
192
+ * An unclassifiable failure is `unavailable`: the weakest claim the evidence
193
+ * supports, which is this module's whole ordering rule.
194
+ *
195
+ * `quota_exhausted` is the clearest case and the reason this set exists: the
196
+ * cap is one the operator deliberately set, so the seat is working exactly as
197
+ * configured. Treating it as `unavailable` would tell an operator to go fix a
198
+ * provider that has nothing wrong with it. `timeout` and `server_error` are
199
+ * transient by nature — a seat that timed out once yesterday is not a seat
200
+ * that will never answer.
201
+ */
202
+ const IMPAIRED_FAILURES: ReadonlySet<CliFailureClass> = new Set([
203
+ 'quota_exhausted',
204
+ 'timeout',
205
+ 'server_error',
206
+ ]);
207
+
208
+ function _daysBetween(from: Date, to: Date): number {
209
+ const ms = to.getTime() - from.getTime();
210
+ return ms / 86_400_000;
211
+ }
212
+
213
+ function _installed(input: MemberQualificationInput): QualificationCheck {
214
+ if (input.transport.absentReason === 'no_binary') {
215
+ return { id: 'installed', status: 'fail', detail: input.transport.reason ?? 'no provider binary on PATH' };
216
+ }
217
+ if (input.transport.available) {
218
+ return { id: 'installed', status: 'pass', detail: `transport ${String(input.transport.transport)} resolved` };
219
+ }
220
+ // Unavailable for some reason the resolver could not classify. That is not
221
+ // proof of a missing binary, so it is not a fail on THIS rung — the
222
+ // transport-semantics rung below will catch a null transport.
223
+ return { id: 'installed', status: 'unknown', detail: input.transport.reason ?? 'transport unresolved' };
224
+ }
225
+
226
+ function _authenticated(input: MemberQualificationInput): QualificationCheck {
227
+ if (input.transport.absentReason === 'no_auth') {
228
+ return { id: 'authenticated', status: 'fail', detail: input.transport.reason ?? 'no credential resolved' };
229
+ }
230
+ if (input.transport.available) {
231
+ return { id: 'authenticated', status: 'pass', detail: 'credential resolved by the transport resolver' };
232
+ }
233
+ return { id: 'authenticated', status: 'unknown', detail: 'not evaluated — transport unresolved' };
234
+ }
235
+
236
+ function _transportSemantics(input: MemberQualificationInput): QualificationCheck {
237
+ if (input.transport.transport === null) {
238
+ return {
239
+ id: 'transport_semantics',
240
+ status: 'fail',
241
+ detail: input.transport.reason ?? 'no usable transport',
242
+ };
243
+ }
244
+ if (input.transport.transport === 'manual') {
245
+ // `manual` performs no provider call at all. It is a legitimate
246
+ // configuration and a legitimate seat, but it cannot contribute a
247
+ // model's answer, so counting it as a full voice is the same
248
+ // over-claim in a different costume.
249
+ return {
250
+ id: 'transport_semantics',
251
+ status: 'degraded',
252
+ detail: 'manual transport performs no provider call',
253
+ };
254
+ }
255
+ return { id: 'transport_semantics', status: 'pass', detail: `${input.transport.transport} transport` };
256
+ }
257
+
258
+ function _systemPromptPath(input: MemberQualificationInput): QualificationCheck {
259
+ const sp = input.systemPrompt ?? null;
260
+ if (sp === null || sp.declared === null || sp.declared === '') {
261
+ return { id: 'system_prompt_path', status: 'skipped', detail: 'no system-prompt path declared' };
262
+ }
263
+ if (sp.exists === null) {
264
+ return { id: 'system_prompt_path', status: 'unknown', detail: `declared ${sp.declared}, not checked` };
265
+ }
266
+ return sp.exists
267
+ ? { id: 'system_prompt_path', status: 'pass', detail: `${sp.declared} resolves` }
268
+ : { id: 'system_prompt_path', status: 'fail', detail: `${sp.declared} does not exist` };
269
+ }
270
+
271
+ function _modelIdentifier(input: MemberQualificationInput): QualificationCheck {
272
+ const id = (input.modelId ?? '').trim();
273
+ if (id === '') {
274
+ return { id: 'model_identifier', status: 'fail', detail: 'no model identifier configured' };
275
+ }
276
+ // The recorded codex failure: the transport rejected the model id itself.
277
+ // A static config read can never see this, which is why the probe record
278
+ // is consulted on this rung and not only on the last one.
279
+ // `model_unservable` ONLY. `cli_unsupported` was here and was wrong:
280
+ // `classifyCliFailure` returns it for `parse_failed` — a response the CLI
281
+ // could not parse — which says nothing about the model identifier and
282
+ // would have this rung report "<id> was rejected by the transport" about
283
+ // an id the transport never objected to. That failure still reaches
284
+ // `unavailable`, via the `live_probe` rung where it belongs, so nothing is
285
+ // lost except a wrong reason. (R2 finding 13.)
286
+ if (input.lastProbe?.outcome === 'model_unservable') {
287
+ return {
288
+ id: 'model_identifier',
289
+ status: 'fail',
290
+ detail: `${id} was rejected by the transport on ${input.lastProbe.at}`,
291
+ };
292
+ }
293
+ return { id: 'model_identifier', status: 'pass', detail: id };
294
+ }
295
+
296
+ function _toolIsolation(input: MemberQualificationInput): QualificationCheck {
297
+ if (input.toolsIsolated === undefined || input.toolsIsolated === null) {
298
+ return { id: 'tool_isolation', status: 'skipped', detail: 'no tool-isolation claim' };
299
+ }
300
+ return input.toolsIsolated
301
+ ? { id: 'tool_isolation', status: 'pass', detail: 'host tools withheld' }
302
+ : { id: 'tool_isolation', status: 'degraded', detail: 'seat runs with host tools available' };
303
+ }
304
+
305
+ function _liveProbe(input: MemberQualificationInput): QualificationCheck {
306
+ const probe = input.lastProbe ?? null;
307
+ if (probe === null) {
308
+ return {
309
+ id: 'live_probe',
310
+ status: 'unknown',
311
+ detail: 'no exchange with this provider has ever been recorded',
312
+ };
313
+ }
314
+ if (probe.outcome !== 'ok') {
315
+ const impaired = IMPAIRED_FAILURES.has(probe.outcome);
316
+ return {
317
+ id: 'live_probe',
318
+ status: impaired ? 'degraded' : 'fail',
319
+ detail: `last exchange ${probe.at} — ${probe.outcome}`,
320
+ };
321
+ }
322
+ const maxAge = input.probeMaxAgeDays ?? DEFAULT_PROBE_MAX_AGE_DAYS;
323
+ const at = new Date(probe.at);
324
+ if (Number.isNaN(at.getTime())) {
325
+ return { id: 'live_probe', status: 'unknown', detail: `unparseable probe date ${probe.at}` };
326
+ }
327
+ const age = _daysBetween(at, input.now ?? new Date());
328
+ if (age > maxAge) {
329
+ return {
330
+ id: 'live_probe',
331
+ status: 'unknown',
332
+ detail: `last successful exchange ${probe.at} is older than ${String(maxAge)} days`,
333
+ };
334
+ }
335
+ return { id: 'live_probe', status: 'pass', detail: `successful exchange ${probe.at}` };
336
+ }
337
+
338
+ const CHECK_FNS: Record<QualificationCheckId, (i: MemberQualificationInput) => QualificationCheck> = {
339
+ installed: _installed,
340
+ authenticated: _authenticated,
341
+ transport_semantics: _transportSemantics,
342
+ system_prompt_path: _systemPromptPath,
343
+ model_identifier: _modelIdentifier,
344
+ tool_isolation: _toolIsolation,
345
+ live_probe: _liveProbe,
346
+ };
347
+
348
+ /**
349
+ * Run the ladder and reduce it to one verdict.
350
+ *
351
+ * A `fail` short-circuits: every later rung is reported `skipped` rather than
352
+ * evaluated, because `unavailable` is terminal and a list of consequential
353
+ * findings under a dead seat reads as five problems where there is one. The
354
+ * `skipped` entries are still emitted so the ladder always has the same shape
355
+ * and a reader can see where evaluation stopped.
356
+ */
357
+ export function qualifyMember(input: MemberQualificationInput): MemberQualification {
358
+ const checks: QualificationCheck[] = [];
359
+ let verdict: QualificationVerdict = 'available';
360
+ let decidedBy: QualificationCheckId | null = null;
361
+ let halted = false;
362
+
363
+ for (const id of QUALIFICATION_LADDER) {
364
+ if (halted) {
365
+ checks.push({ id, status: 'skipped', detail: 'not evaluated — an earlier check failed' });
366
+ continue;
367
+ }
368
+ const check = (CHECK_FNS[id] as (i: MemberQualificationInput) => QualificationCheck)(input);
369
+ checks.push(check);
370
+ if (check.status === 'skipped') {
371
+ continue;
372
+ }
373
+ const candidate = STATUS_TO_VERDICT[check.status];
374
+ if (VERDICT_SEVERITY[candidate] > VERDICT_SEVERITY[verdict]) {
375
+ verdict = candidate;
376
+ decidedBy = id;
377
+ }
378
+ if (check.status === 'fail') {
379
+ halted = true;
380
+ }
381
+ }
382
+
383
+ return { name: input.name, verdict, checks, decidedBy };
384
+ }
385
+
386
+ /**
387
+ * May this seat be counted toward presence in a quorum?
388
+ *
389
+ * `available` and `degraded` yes; `unavailable` and `unknown` no. The second
390
+ * half is the repair: a seat nobody has ever seen answer contributes nothing to
391
+ * a k-of-n reading, however confidently the config declares it.
392
+ *
393
+ * Note what this deliberately does NOT do — it does not shrink `n`. Dropping an
394
+ * unqualified seat from the total would LOWER the threshold (`ceil(n/2)`) and
395
+ * make a short pass easier to conclude, which is the opposite of the intent.
396
+ * The roster stays the roster; only attendance is gated.
397
+ */
398
+ export function isCountableForQuorum(verdict: QualificationVerdict): boolean {
399
+ return verdict === 'available' || verdict === 'degraded';
400
+ }
401
+
402
+ /**
403
+ * Attendance a set of seats may claim before anything is dispatched.
404
+ *
405
+ * `total` is the configured roster, unchanged, so a shortfall stays visible in
406
+ * the `k/n` line. `countable` is what may be reported present.
407
+ */
408
+ export function qualifiedAttendance(quals: readonly MemberQualification[]): {
409
+ readonly total: number;
410
+ readonly countable: number;
411
+ readonly blocked: readonly MemberQualification[];
412
+ } {
413
+ const blocked = quals.filter((q) => !isCountableForQuorum(q.verdict));
414
+ return { total: quals.length, countable: quals.length - blocked.length, blocked };
415
+ }
416
+
417
+ /** One line per seat, for a status surface. Stable and greppable in both directions. */
418
+ export function formatQualificationLine(q: MemberQualification): string {
419
+ const because = q.decidedBy === null ? '' : ` (${q.decidedBy}: ${_detailFor(q, q.decidedBy)})`;
420
+ return `${q.name}: ${q.verdict}${because}`;
421
+ }
422
+
423
+ function _detailFor(q: MemberQualification, id: QualificationCheckId): string {
424
+ return q.checks.find((c) => c.id === id)?.detail ?? '';
425
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Where provider qualification meets the CLI (road-to-release-review-p0
3
+ * Phase 3).
4
+ *
5
+ * `qualification.ts` classifies and `probe_store.ts` observes; both are pure of
6
+ * the council's own plumbing. This module is the seam: it turns a resolved
7
+ * transport plus a stored observation into a verdict, decides what that verdict
8
+ * does to attendance, and renders it for the two surfaces that show it.
9
+ *
10
+ * **It exists as its own file because the size ratchet said so, and the ratchet
11
+ * was right.** All of this first landed inside `council_cli.ts`, which was
12
+ * already 4,058 lines — over the 1,500-line ceiling — so 253 lines of wiring
13
+ * scored 253 against `check_source_size_budget`, whose metric is
14
+ * `Σ max(0, lines(f) − CEILING)`. The four new modules the same change added
15
+ * scored zero, being well under. That asymmetry is the gate working: it does
16
+ * not object to new code, it objects to new code in the file that is already
17
+ * too big to read. Extracting here costs one import and leaves the seam
18
+ * testable on its own terms.
19
+ *
20
+ * ## The injection contract
21
+ *
22
+ * `build_members` evaluates qualification ONLY when its caller supplies a
23
+ * `probe_store`. Omission is the safe default here rather than the unsafe one,
24
+ * and the reason is determinism, not timidity: the store lives under
25
+ * `agents/runtime/`, which is gitignored, so a `build_members` that read it
26
+ * implicitly would give a different answer on a machine that had run a council
27
+ * pass than on one that had not — the exact per-machine flakiness the
28
+ * `environment_report` injection point already exists to keep out of that
29
+ * function. Every production entry point supplies it; a unit test supplies a
30
+ * fixture or nothing.
31
+ *
32
+ * The matching out-param, `qualification_out`, is empty when no store was
33
+ * passed. Empty means **not evaluated**, which is a different statement from
34
+ * "every seat qualified" and must never be read as the latter.
35
+ *
36
+ * ## What the status surface consulted, and what it did not
37
+ *
38
+ * Reading the store means `cmd_status` now touches a path under the project
39
+ * root. Its ADR-104 claim — that the project tree is never consulted — is
40
+ * therefore scoped to CONFIG resolution, and the probe path is published
41
+ * beside it rather than folded into a hedge.
42
+ */
43
+
44
+ import {
45
+ formatQualificationLine,
46
+ isCountableForQuorum,
47
+ qualifyMember,
48
+ type MemberQualification,
49
+ } from './qualification.js';
50
+ import { probeFor, recordProbes, type ProbeEntry, type ProbeStore } from './probe_store.js';
51
+ import type { AbsentReason, CliFailureClass, Transport } from './transport_resolver.js';
52
+
53
+ /** The slice of a resolved transport the ladder consumes. */
54
+ export interface ResolvedSlice {
55
+ readonly available: boolean;
56
+ readonly transport: Transport | null;
57
+ readonly reason: string | null;
58
+ readonly absentReason: AbsentReason | null;
59
+ }
60
+
61
+ /**
62
+ * Qualify one enabled member against the store.
63
+ *
64
+ * Called from the member loop BEFORE any branch decides how the seat is built:
65
+ * the branches differ in construction, not in what would qualify, and a
66
+ * per-branch call is how the `manual` path would silently stop being qualified.
67
+ */
68
+ export function qualifySeat(
69
+ name: string,
70
+ resolved: ResolvedSlice,
71
+ modelId: string | null,
72
+ store: ProbeStore,
73
+ ): MemberQualification {
74
+ return qualifyMember({
75
+ name,
76
+ transport: {
77
+ available: resolved.available,
78
+ transport: resolved.transport,
79
+ reason: resolved.reason,
80
+ absentReason: resolved.absentReason,
81
+ },
82
+ modelId,
83
+ lastProbe: probeFor(store, name),
84
+ });
85
+ }
86
+
87
+ export interface AttendanceGate {
88
+ /** Seats to record absent — already-absent names excluded, so no double count. */
89
+ readonly toRecordAbsent: readonly MemberQualification[];
90
+ /** ONE line for the whole roster, or null when every seat is countable. */
91
+ readonly noticeLine: string | null;
92
+ }
93
+
94
+ /**
95
+ * Which qualified seats may not be counted present, and the one line that says
96
+ * so.
97
+ *
98
+ * Two properties this encodes, both of which were review findings:
99
+ *
100
+ * - **A seat already recorded absent is excluded**, so it is not subtracted
101
+ * twice — the caller routes the rest through its own `record_absent`, which
102
+ * keeps the pre-run event's `total - present == absent.length` invariant.
103
+ * - **One line, not one per seat.** A fresh machine has no store, so every
104
+ * seat reads `unknown` and the per-seat form printed the full roster on
105
+ * every invocation, spend-free previews included.
106
+ */
107
+ export function attendanceGate(
108
+ qualifications: readonly MemberQualification[],
109
+ alreadyAbsent: ReadonlySet<string>,
110
+ ): AttendanceGate {
111
+ const blocked = qualifications.filter(
112
+ (q) => !alreadyAbsent.has(q.name) && !isCountableForQuorum(q.verdict),
113
+ );
114
+ if (blocked.length === 0) {
115
+ return { toRecordAbsent: [], noticeLine: null };
116
+ }
117
+ return {
118
+ toRecordAbsent: blocked,
119
+ noticeLine:
120
+ `[council] ${String(blocked.length)} seat(s) not counted toward attendance — ` +
121
+ `${blocked.map(formatQualificationLine).join(' · ')}`,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * The `QuorumAbsentReason` an unqualified seat is recorded under.
127
+ *
128
+ * `unavailable` for BOTH `unavailable` and `unknown`, deliberately: that enum
129
+ * is closed and carries no `unqualified` member, and minting one would be a
130
+ * contract change on an event other instruments parse — to express a
131
+ * distinction the absence `detail` carries losslessly on the same row.
132
+ */
133
+ export function absenceReasonFor(_q: MemberQualification): 'unavailable' {
134
+ return 'unavailable';
135
+ }
136
+
137
+ /** The `qualification` block a status payload publishes. */
138
+ export function qualificationJson(
139
+ qualifications: readonly MemberQualification[],
140
+ ): Record<string, unknown> {
141
+ return Object.fromEntries(
142
+ qualifications.map((q) => [
143
+ q.name,
144
+ {
145
+ verdict: q.verdict,
146
+ decided_by: q.decidedBy,
147
+ countable_for_quorum: isCountableForQuorum(q.verdict),
148
+ checks: q.checks.map((c) => ({ id: c.id, status: c.status, detail: c.detail })),
149
+ },
150
+ ]),
151
+ );
152
+ }
153
+
154
+ /**
155
+ * The human status lines: one per seat, then the advice.
156
+ *
157
+ * TWO warnings, not one. A single "run a pass and it resolves itself" was
158
+ * emitted for every uncountable seat including `unavailable` ones, where it is
159
+ * false — and it sent the operator to re-run instead of to the actual fault.
160
+ */
161
+ export function qualificationStatusLines(
162
+ qualifications: readonly MemberQualification[],
163
+ ): string[] {
164
+ const lines = qualifications.map((q) => ` qualification ${formatQualificationLine(q)}`);
165
+ const total = String(qualifications.length);
166
+ const unknown = qualifications.filter((q) => q.verdict === 'unknown').length;
167
+ const unavailable = qualifications.filter((q) => q.verdict === 'unavailable').length;
168
+ if (unknown > 0) {
169
+ lines.push(
170
+ ` ⚠️ ${String(unknown)} of ${total} seat(s) have no recorded exchange and read \`unknown\`, so they are`,
171
+ ' not counted toward a quorum. Run a council pass and this resolves itself.',
172
+ );
173
+ }
174
+ if (unavailable > 0) {
175
+ lines.push(
176
+ ` ❌ ${String(unavailable)} of ${total} seat(s) are \`unavailable\` — a pass will NOT fix these.`,
177
+ ' Read the deciding check on each line above and repair that.',
178
+ );
179
+ }
180
+ return lines;
181
+ }
182
+
183
+ /** How many seats a status surface may count toward a quorum. */
184
+ export function countableSeats(qualifications: readonly MemberQualification[]): number {
185
+ return qualifications.filter((q) => isCountableForQuorum(q.verdict)).length;
186
+ }
187
+
188
+ /**
189
+ * Per-member observations from one index-aligned round of responses.
190
+ *
191
+ * ONE definition, used by the run path and the debate path both. A second copy
192
+ * is how the two would drift on what counts as an answer — and "non-empty text,
193
+ * not merely the absence of an error" is a distinction this repository already
194
+ * paid for once, when a 290 s timeout returned an empty body with no error set
195
+ * and the banner printed full attendance.
196
+ *
197
+ * Typed structurally rather than against the client classes on purpose: this
198
+ * module has no business importing the transport layer to read two fields, and
199
+ * the looser signature is what lets it be tested without constructing a client.
200
+ */
201
+ export function probesFromRound(
202
+ members: readonly { readonly name: string }[],
203
+ responses: readonly ({ readonly error?: unknown; readonly text: string } | undefined)[],
204
+ classify: (raw: string) => CliFailureClass,
205
+ at: string,
206
+ ): ProbeEntry[] {
207
+ const probes: ProbeEntry[] = [];
208
+ for (let i = 0; i < members.length; i++) {
209
+ const m = members[i] as { readonly name: string };
210
+ const r = responses[i];
211
+ if (r !== undefined && !r.error && r.text.trim() !== '') {
212
+ probes.push({ name: m.name, outcome: 'ok', at });
213
+ continue;
214
+ }
215
+ const raw =
216
+ (r?.error as string | undefined) ??
217
+ (r !== undefined && r.text.trim() === '' ? 'empty response body' : 'no response');
218
+ probes.push({ name: m.name, outcome: classify(String(raw)), at });
219
+ }
220
+ return probes;
221
+ }
222
+
223
+ /** Today, as the store records it. Extracted so a test can pin the date. */
224
+ export function observationDate(now: Date = new Date()): string {
225
+ return now.toISOString().slice(0, 10);
226
+ }
227
+
228
+ /**
229
+ * Record one round's observations into the store — the whole side effect, at
230
+ * one call site per path.
231
+ *
232
+ * Unguarded by any success check on purpose: a pass that errored is as
233
+ * informative an observation as one that answered, and `recordProbes` is
234
+ * best-effort by construction, so a write failure cannot break the run.
235
+ */
236
+ export function recordRoundObservations(
237
+ root: string,
238
+ members: readonly { readonly name: string }[],
239
+ responses: readonly ({ readonly error?: unknown; readonly text: string } | undefined)[],
240
+ classify: (raw: string) => CliFailureClass,
241
+ ): void {
242
+ recordProbes(root, probesFromRound(members, responses, classify, observationDate()));
243
+ }