@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +164 -186
- package/README.md +1 -1
- package/dist/agent-src/commands/cost/report.md +23 -0
- package/dist/agent-src/commands/evals/roadmap.json +8 -0
- package/dist/agent-src/commands/fix/ci.md +39 -1
- package/dist/agent-src/commands/pr/create.md +65 -4
- package/dist/agent-src/commands/roadmap/next.md +28 -5
- package/dist/agent-src/commands/work.md +16 -0
- package/dist/agent-src/contexts/contracts/command-suggestion-flow.md +47 -0
- package/dist/agent-src/contexts/execution/contract-decision-sheet.md +94 -0
- package/dist/agent-src/contexts/execution/plan-confidence-gate.md +30 -0
- package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +21 -8
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +55 -8
- package/dist/agent-src/contexts/execution/subagent-response-contract.md +42 -0
- package/dist/agent-src/contexts/execution/subagent-routing.md +148 -33
- package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +29 -0
- package/dist/agent-src/contexts/subagent-configuration.md +69 -7
- package/dist/agent-src/rules/autonomous-execution.md +1 -1
- package/dist/agent-src/rules/command-suggestion-policy.md +33 -0
- package/dist/agent-src/rules/design-fidelity.md +1 -1
- package/dist/agent-src/rules/design-review-after-ui-write.md +36 -14
- package/dist/agent-src/rules/evaluator-independence.md +27 -5
- package/dist/agent-src/rules/git-history-discipline.md +5 -1
- package/dist/agent-src/rules/missing-skill-recovery.md +89 -0
- package/dist/agent-src/rules/ui-audit-gate.md +34 -12
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
- package/dist/agent-src/scripts/gate_execute.ts +395 -0
- package/dist/agent-src/scripts/resume_probe.ts +422 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +138 -4
- package/dist/agent-src/scripts/update_roadmap_progress.ts +70 -4
- package/dist/agent-src/skills/ai-council/SKILL.md +61 -0
- package/dist/agent-src/skills/authz-review/SKILL.md +4 -0
- package/dist/agent-src/skills/design-system-capture/SKILL.md +4 -0
- package/dist/agent-src/skills/design-system-capture/references/snapshot-preference-order.md +77 -0
- package/dist/agent-src/skills/experiment-loop/SKILL.md +116 -0
- package/dist/agent-src/skills/experiment-loop/evals/output-schema.yml +27 -0
- package/dist/agent-src/skills/experiment-loop/evals/triggers.json +17 -0
- package/dist/agent-src/skills/experiment-loop/references/pivot-ladder.md +54 -0
- package/dist/agent-src/skills/experiment-loop/references/protocol.md +61 -0
- package/dist/agent-src/skills/experiment-loop/references/register-format.md +66 -0
- package/dist/agent-src/skills/merge-conflicts/SKILL.md +4 -0
- package/dist/agent-src/skills/prompt-optimizer/SKILL.md +17 -1
- package/dist/agent-src/skills/roadmap-management/SKILL.md +2 -2
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +5 -5
- package/dist/agent-src/skills/subagent-orchestration/schemas/team-review-status.json +8 -3
- package/dist/agent-src/skills/systematic-debugging/SKILL.md +4 -0
- package/dist/agent-src/skills/threat-modeling/SKILL.md +3 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +43 -3
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-K34QTSD5.js → chunk-7PZ4FSZB.js} +368 -191
- package/dist/cli-delegate/{chunk-55WZN5VF.js → chunk-EVTY7ISR.js} +2 -2
- package/dist/cli-delegate/{chunk-O52B4F5S.js → chunk-F3YUF7SA.js} +1 -1
- package/dist/cli-delegate/{chunk-LKZ5DOYW.js → chunk-XURJ4AQC.js} +11 -3
- package/dist/cli-delegate/cmd_conformance.js +224 -104
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +73 -30
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +7 -5
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +6 -4
- package/dist/hooks/dispatch.js +2653 -1365
- package/dist/install/install.mjs +12 -4
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +2679 -887
- package/dist/router.json +1 -1
- package/dist/scripts/condense.js +1 -1
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/schemas/settings.js +2 -2
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/ui/assets/{index-B0aMJnuz.js → index-C1pBRm1f.js} +2 -2
- package/dist/ui/assets/index-C1pBRm1f.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/docs/guidelines/design-fidelity-mechanics.md +21 -0
- package/docs/guidelines/design-handover-extraction.md +87 -0
- package/docs/guidelines/prompt-templates.md +24 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
- package/src/agent-src/scripts/gate_execute.ts +395 -0
- package/src/agent-src/scripts/resume_probe.ts +422 -0
- package/src/agent-src/scripts/roadmap_gates.ts +138 -4
- package/src/agent-src/scripts/update_roadmap_progress.ts +70 -4
- package/src/config/ci-local-parity.yml +13 -0
- package/src/config/evaluator-budgets.json +9 -8
- package/src/config/gate-coverage.yml +140 -12
- package/src/config/gate-violation-baselines.json +9 -3
- package/src/config/hook-token-budget.json +8 -1
- package/src/config/rule-activation-census.json +41 -0
- package/src/config/rule-stub-ceilings.json +52 -0
- package/src/scripts/_dispatch.bash +4 -0
- package/src/scripts/_lib/bench_ab_complexity.ts +439 -0
- package/src/scripts/_lib/bench_ab_safety_tier.ts +142 -0
- package/src/scripts/_lib/bench_ab_scoring_v2.ts +28 -3
- package/src/scripts/_lib/bench_ab_search_adherence.ts +423 -0
- package/src/scripts/_lib/bench_ab_size_claim.ts +206 -0
- package/src/scripts/_lib/bench_ab_workspace.ts +111 -0
- package/src/scripts/_lib/evaluator_contract.ts +159 -0
- package/src/scripts/_lib/gh_transient.ts +80 -0
- package/src/scripts/_lib/release_material.ts +16 -5
- package/src/scripts/_lib/review_independence.ts +111 -0
- package/src/scripts/_lib/session_register.ts +14 -0
- package/src/scripts/_lib/skill_catalogue.ts +278 -5
- package/src/scripts/_lib/skill_catalogue_series.ts +411 -0
- package/src/scripts/_lib/tier_budget_routing.ts +31 -331
- package/src/scripts/_lib/turn_end_refusals.ts +482 -0
- package/src/scripts/ai_council/_py_json.ts +96 -0
- package/src/scripts/ai_council/cli_call_budget.ts +357 -0
- package/src/scripts/ai_council/clients.ts +101 -164
- package/src/scripts/ai_council/config.ts +25 -11
- package/src/scripts/ai_council/probe_store.ts +196 -0
- package/src/scripts/ai_council/qualification.ts +425 -0
- package/src/scripts/ai_council/qualification_wiring.ts +243 -0
- package/src/scripts/ai_council/quorum_wiring.ts +211 -0
- package/src/scripts/ai_team/team_dispatch.ts +40 -6
- package/src/scripts/bench_ab_v2_complexity.ts +252 -0
- package/src/scripts/bench_ab_v2_run.ts +3 -42
- package/src/scripts/bench_ab_v2_safety.ts +296 -0
- package/src/scripts/bench_ab_v2_search.ts +343 -0
- package/src/scripts/bench_ab_v2_stats.ts +191 -0
- package/src/scripts/build_archive_index.ts +383 -0
- package/src/scripts/capture_skill_catalogue.ts +369 -4
- package/src/scripts/check_evaluator_schema.ts +249 -0
- package/src/scripts/check_pr_ci_current.ts +409 -0
- package/src/scripts/check_review_schema.ts +195 -0
- package/src/scripts/check_rule_activation_census.ts +451 -0
- package/src/scripts/check_rule_stub_ceiling.ts +1128 -0
- package/src/scripts/check_source_size_budget.ts +428 -0
- package/src/scripts/check_suppression_hygiene.ts +5 -1
- package/src/scripts/condense.ts +1 -1
- package/src/scripts/cost/backfill_rates.mjs +298 -0
- package/src/scripts/cost/budget.mjs +12 -32
- package/src/scripts/cost_summary.ts +25 -0
- package/src/scripts/council_cli.ts +141 -191
- package/src/scripts/dispatch_r2_reviewer.ts +8 -1
- package/src/scripts/external_sources_denylist.json +1 -0
- package/src/scripts/hook_manifest.yaml +99 -6
- package/src/scripts/hooks/concern_registry.ts +6 -0
- package/src/scripts/hooks/dispatch_hook.ts +43 -1
- package/src/scripts/hooks/interruption_ledger_hook.ts +256 -0
- package/src/scripts/hooks/ship_diff_volume_hook.ts +180 -0
- package/src/scripts/hooks/skill_route_hook.ts +226 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +119 -26
- package/src/scripts/hooks/ui_route_nudge_hook.ts +6 -3
- package/src/scripts/hooks_doctor.ts +112 -0
- package/src/scripts/interruption_report.ts +443 -0
- package/src/scripts/lint_design_antipattern_parity.ts +40 -6
- package/src/scripts/lint_evidence_artifacts.ts +359 -0
- package/src/scripts/lint_harvest_provenance.ts +35 -1
- package/src/scripts/lint_roadmap_blockers.ts +65 -0
- package/src/scripts/lint_roadmap_complexity.ts +7 -1
- package/src/scripts/lint_rule_skill_pack_reach.ts +43 -0
- package/src/scripts/lint_skill_router_head.ts +21 -0
- package/src/scripts/mcp_server/consumer_tool_catalog.json +26 -3
- package/src/scripts/mcp_server/tool_catalog_source.ts +6 -24
- package/src/scripts/mcp_server/tools.ts +87 -0
- package/src/scripts/release.ts +43 -58
- package/src/scripts/routing_doctor.ts +50 -13
- package/src/scripts/rule_activation_census.ts +292 -0
- package/src/scripts/schemas/command.schema.json +1 -1
- package/src/scripts/schemas/evaluator-output.schema.json +60 -0
- package/src/scripts/schemas/review-findings.schema.json +31 -0
- package/src/scripts/schemas/skill.schema.json +18 -1
- package/src/scripts/self_review_gate.ts +6 -0
- package/src/scripts/session_register_hook.ts +30 -1
- package/src/scripts/sync_pr_branch.ts +271 -0
- package/src/scripts/test_council_qualification.ts +213 -0
- package/src/scripts/trigger_coverage.ts +224 -3
- package/src/server/schemas/settings.ts +2 -2
- package/dist/ui/assets/index-B0aMJnuz.js.map +0 -1
- 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
|
+
}
|