@event4u/agent-config 13.0.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 +160 -333
- package/README.md +1 -1
- 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-spawn-contract.md +29 -0
- package/dist/agent-src/rules/command-suggestion-policy.md +33 -0
- package/dist/agent-src/rules/design-review-after-ui-write.md +25 -8
- package/dist/agent-src/rules/ui-audit-gate.md +25 -8
- 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 +53 -2
- package/dist/agent-src/skills/ai-council/SKILL.md +61 -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/roadmap-management/SKILL.md +2 -2
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
- package/dist/agent-src/skills/subagent-orchestration/schemas/team-review-status.json +8 -3
- 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-N7FEST6D.js → chunk-7PZ4FSZB.js} +351 -174
- package/dist/cli-delegate/{chunk-3B72LSZL.js → chunk-F3YUF7SA.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +224 -104
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +40 -18
- 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 +6 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +5 -4
- package/dist/hooks/dispatch.js +2018 -1320
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1652 -892
- package/dist/router.json +1 -1
- package/dist/scripts/condense.js +1 -1
- package/dist/scripts/condense.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/package.json +1 -1
- 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 +53 -2
- package/src/config/ci-local-parity.yml +13 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +111 -5
- package/src/config/gate-violation-baselines.json +2 -2
- 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/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 +20 -0
- package/src/scripts/_lib/skill_catalogue_series.ts +411 -0
- 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 +80 -143
- 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/capture_skill_catalogue.ts +264 -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/condense.ts +1 -1
- package/src/scripts/council_cli.ts +141 -191
- package/src/scripts/dispatch_r2_reviewer.ts +8 -1
- package/src/scripts/hook_manifest.yaml +72 -5
- package/src/scripts/hooks/concern_registry.ts +4 -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/turn_end_gate_hook.ts +119 -26
- package/src/scripts/hooks_doctor.ts +112 -0
- package/src/scripts/interruption_report.ts +443 -0
- package/src/scripts/lint_evidence_artifacts.ts +359 -0
- package/src/scripts/lint_roadmap_blockers.ts +65 -0
- package/src/scripts/lint_roadmap_complexity.ts +7 -1
- package/src/scripts/release.ts +38 -53
- package/src/scripts/rule_activation_census.ts +292 -0
- package/src/scripts/schemas/evaluator-output.schema.json +60 -0
- package/src/scripts/schemas/review-findings.schema.json +31 -0
- 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/dist/ui/assets/index-B0aMJnuz.js.map +0 -1
|
@@ -76,6 +76,7 @@ import * as path from 'node:path';
|
|
|
76
76
|
import { parse as parseYaml } from 'yaml';
|
|
77
77
|
|
|
78
78
|
import * as user_global_paths from '../_lib/user_global_paths.js';
|
|
79
|
+
import * as budget from './cli_call_budget.js';
|
|
79
80
|
// LAYERING CONSTRAINT, load-bearing: this is a VALUE import from quorum, while
|
|
80
81
|
// `quorum.ts` imports `QuorumSetting` back from here. That back-edge is
|
|
81
82
|
// `import type` and is erased at build, so there is no runtime cycle today.
|
|
@@ -1927,13 +1928,23 @@ function _build_member(
|
|
|
1927
1928
|
* Generous per-provider guard for `mode: cli` / `mode: auto` members
|
|
1928
1929
|
* (road-to-always-on-orchestration Phase 3.4). Before this default the map
|
|
1929
1930
|
* shipped empty and an unlisted provider ran uncapped — a plan-quota guard
|
|
1930
|
-
* that only existed if the user remembered to opt in.
|
|
1931
|
-
*
|
|
1932
|
-
*
|
|
1933
|
-
*
|
|
1934
|
-
*
|
|
1931
|
+
* that only existed if the user remembered to opt in.
|
|
1932
|
+
*
|
|
1933
|
+
* Re-exported from `cli_call_budget.ts`, which owns it: `build_members` and
|
|
1934
|
+
* `cmd_quota` both need the same number, and re-deriving it at either site is
|
|
1935
|
+
* how the reported cap and the enforced cap drift apart.
|
|
1935
1936
|
*/
|
|
1936
|
-
const
|
|
1937
|
+
export const DEFAULT_CLI_CALLS_PER_DAY = budget.DEFAULT_CLI_CALLS_PER_DAY;
|
|
1938
|
+
|
|
1939
|
+
/**
|
|
1940
|
+
* Resolve the per-provider daily cap map — the SINGLE authority, shared with the
|
|
1941
|
+
* gate and the report. Thin wrapper that supplies this module's provider set;
|
|
1942
|
+
* the contract (defaults on omission, lenient where this module is strict) is
|
|
1943
|
+
* documented on `budget.resolveCliCallCaps`.
|
|
1944
|
+
*/
|
|
1945
|
+
export function resolve_cli_call_caps(raw: unknown): Record<string, number> {
|
|
1946
|
+
return budget.resolveCliCallCaps(raw, _VALID_PROVIDERS);
|
|
1947
|
+
}
|
|
1937
1948
|
|
|
1938
1949
|
function _build_cli_call_budget(d: Dict): CliCallBudgetConfig {
|
|
1939
1950
|
if (!_isDict(d)) {
|
|
@@ -1945,10 +1956,11 @@ function _build_cli_call_budget(d: Dict): CliCallBudgetConfig {
|
|
|
1945
1956
|
'`cli_call_budget.max_calls_per_day` must be a mapping.',
|
|
1946
1957
|
);
|
|
1947
1958
|
}
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1959
|
+
// Validate FIRST, then resolve. Splitting the two is what lets the strict
|
|
1960
|
+
// builder and the lenient `resolve_cli_call_caps` share one seeding
|
|
1961
|
+
// implementation without either weakening the other: every rejection below
|
|
1962
|
+
// still throws, and what survives is handed to the shared resolver rather
|
|
1963
|
+
// than seeded a second time here.
|
|
1952
1964
|
for (const [provider, value] of Object.entries(raw_caps)) {
|
|
1953
1965
|
if (!_VALID_PROVIDERS.has(provider)) {
|
|
1954
1966
|
throw new CouncilConfigError(
|
|
@@ -1962,8 +1974,10 @@ function _build_cli_call_budget(d: Dict): CliCallBudgetConfig {
|
|
|
1962
1974
|
`non-negative integer (got ${_pyRepr(value)}).`,
|
|
1963
1975
|
);
|
|
1964
1976
|
}
|
|
1965
|
-
caps.set(provider, value);
|
|
1966
1977
|
}
|
|
1978
|
+
const caps = new Map<string, number>(
|
|
1979
|
+
Object.entries(resolve_cli_call_caps(raw_caps)),
|
|
1980
|
+
);
|
|
1967
1981
|
const warn_at_raw = _get(d, 'warn_at', 0.8);
|
|
1968
1982
|
if (_isBool(warn_at_raw) || !_isNumber(warn_at_raw)) {
|
|
1969
1983
|
throw new CouncilConfigError(
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The observation half of provider qualification (road-to-release-review-p0
|
|
3
|
+
* Phase 3).
|
|
4
|
+
*
|
|
5
|
+
* `qualification.ts` is pure and answers "given what has been observed, what
|
|
6
|
+
* may this seat claim". This module is the only thing that observes. It exists
|
|
7
|
+
* because the qualification ladder's strongest rung — has a real exchange with
|
|
8
|
+
* this provider ever succeeded — cannot be answered from configuration, and
|
|
9
|
+
* answering it with a fresh probe would mean spending money every time anyone
|
|
10
|
+
* asked whether the council was healthy.
|
|
11
|
+
*
|
|
12
|
+
* So nothing here probes. A council run that receives a non-empty answer from a
|
|
13
|
+
* seat has already performed the exchange; this records that it happened. The
|
|
14
|
+
* cost of the observation is zero because the observation is a by-product of
|
|
15
|
+
* work the operator asked for anyway, and a seat therefore decays out of
|
|
16
|
+
* `unknown` through normal use rather than through a maintenance ritual.
|
|
17
|
+
*
|
|
18
|
+
* **The store is deliberately runtime-local and gitignored.** `agents/runtime/`
|
|
19
|
+
* is not tracked, which has a consequence worth stating rather than
|
|
20
|
+
* discovering: a fresh worktree starts with no observations, so every seat
|
|
21
|
+
* reads `unknown` there until a run happens in it. That is the honest reading —
|
|
22
|
+
* a checkout that has never talked to a provider genuinely has no evidence that
|
|
23
|
+
* it can — and it is strictly better than the alternative of committing a
|
|
24
|
+
* machine-specific health claim into a shared tree, where it would be stale for
|
|
25
|
+
* everyone but the machine that wrote it.
|
|
26
|
+
*
|
|
27
|
+
* Corrupt or unreadable state is treated as "no observations", never as an
|
|
28
|
+
* error: the same tolerant-reader doctrine the settings surface follows. A
|
|
29
|
+
* status command that crashes because a JSON file has a stray byte is worse
|
|
30
|
+
* than one that reports `unknown`, which is what the absence of evidence
|
|
31
|
+
* actually means.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
|
|
37
|
+
import { isEnvKillSwitchActive } from '../_lib/env_kill_switch.js';
|
|
38
|
+
import type { CliFailureClass } from './transport_resolver.js';
|
|
39
|
+
import type { ProbeRecord } from './qualification.js';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The same kill-switch the council event log honours.
|
|
43
|
+
*
|
|
44
|
+
* Sharing it rather than minting a second name is deliberate: both write
|
|
45
|
+
* council observational state into the worktree, and the suite that needs one
|
|
46
|
+
* suppressed needs the other suppressed for the identical reason. The
|
|
47
|
+
* read-only witness in `tests/scripts/witness/` fails on ANY worktree path
|
|
48
|
+
* appearing while it runs — including a gitignored one — so a test that
|
|
49
|
+
* exercises `cmd_run` and silently dropped a probe row here would break that
|
|
50
|
+
* witness whenever sharding put the two files together. Measured on this
|
|
51
|
+
* branch: the first version of this module had no switch and wrote
|
|
52
|
+
* `agents/runtime/state/council-probes.json` during the council suite.
|
|
53
|
+
*
|
|
54
|
+
* The coupling has a real cost worth stating: an operator who suppresses the
|
|
55
|
+
* event log also stops accumulating observations, so seats stay `unknown`.
|
|
56
|
+
* That is the correct direction — a run whose telemetry is switched off did
|
|
57
|
+
* not record evidence, and pretending otherwise is the over-claim this whole
|
|
58
|
+
* module exists to remove.
|
|
59
|
+
*/
|
|
60
|
+
const _KILL_SWITCH_ENV = 'AGENT_CONFIG_NO_EVENTS_LOG';
|
|
61
|
+
|
|
62
|
+
/** Repo-relative location. Runtime state, gitignored, never committed. */
|
|
63
|
+
export const PROBE_STORE_RELPATH = path.join('agents', 'runtime', 'state', 'council-probes.json');
|
|
64
|
+
|
|
65
|
+
export interface ProbeStore {
|
|
66
|
+
readonly schema: 1;
|
|
67
|
+
readonly members: Readonly<Record<string, ProbeRecord>>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const EMPTY: ProbeStore = { schema: 1, members: {} };
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The closed set a stored `outcome` may hold — `ok` plus every
|
|
74
|
+
* `CliFailureClass`. Duplicated as a runtime value because the type erases,
|
|
75
|
+
* and this is a trust boundary: the file is hand-editable.
|
|
76
|
+
*/
|
|
77
|
+
const VALID_OUTCOMES: ReadonlySet<string> = new Set<'ok' | CliFailureClass>([
|
|
78
|
+
'ok',
|
|
79
|
+
'binary_missing',
|
|
80
|
+
'auth_rejected',
|
|
81
|
+
'cli_unsupported',
|
|
82
|
+
'model_unservable',
|
|
83
|
+
'timeout',
|
|
84
|
+
'server_error',
|
|
85
|
+
'quota_exhausted',
|
|
86
|
+
'other',
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
export function probeStorePath(root: string): string {
|
|
90
|
+
return path.join(root, PROBE_STORE_RELPATH);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Read the store. Any failure — missing, unreadable, malformed, wrong schema —
|
|
95
|
+
* yields the empty store.
|
|
96
|
+
*
|
|
97
|
+
* Individual member entries are validated one by one rather than trusting the
|
|
98
|
+
* object wholesale: a single corrupt row must not discard the other seats'
|
|
99
|
+
* evidence, because that would silently downgrade healthy seats to `unknown`
|
|
100
|
+
* and the operator would have no way to tell the two causes apart.
|
|
101
|
+
*/
|
|
102
|
+
export function readProbeStore(root: string): ProbeStore {
|
|
103
|
+
let raw: string;
|
|
104
|
+
try {
|
|
105
|
+
raw = fs.readFileSync(probeStorePath(root), 'utf8');
|
|
106
|
+
} catch {
|
|
107
|
+
return EMPTY;
|
|
108
|
+
}
|
|
109
|
+
let parsed: unknown;
|
|
110
|
+
try {
|
|
111
|
+
parsed = JSON.parse(raw);
|
|
112
|
+
} catch {
|
|
113
|
+
return EMPTY;
|
|
114
|
+
}
|
|
115
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
116
|
+
return EMPTY;
|
|
117
|
+
}
|
|
118
|
+
const obj = parsed as Record<string, unknown>;
|
|
119
|
+
if (obj['schema'] !== 1) {
|
|
120
|
+
return EMPTY;
|
|
121
|
+
}
|
|
122
|
+
const membersRaw = obj['members'];
|
|
123
|
+
if (typeof membersRaw !== 'object' || membersRaw === null) {
|
|
124
|
+
return EMPTY;
|
|
125
|
+
}
|
|
126
|
+
const members: Record<string, ProbeRecord> = {};
|
|
127
|
+
for (const [name, value] of Object.entries(membersRaw as Record<string, unknown>)) {
|
|
128
|
+
if (typeof value !== 'object' || value === null) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const v = value as Record<string, unknown>;
|
|
132
|
+
const at = v['at'];
|
|
133
|
+
const outcome = v['outcome'];
|
|
134
|
+
if (typeof at !== 'string' || at === '' || typeof outcome !== 'string') {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
// Validated against the closed set, not cast into it. A bare
|
|
138
|
+
// non-empty-string check let a corrupt or hand-edited value through,
|
|
139
|
+
// where `_liveProbe` read it as `!== 'ok'` and not impaired — i.e. a
|
|
140
|
+
// typo silently downgraded a healthy seat to `unavailable`
|
|
141
|
+
// (R2 finding 9). Dropping the row instead yields `unknown`, which is
|
|
142
|
+
// the honest reading of "this record is unreadable".
|
|
143
|
+
if (!VALID_OUTCOMES.has(outcome)) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
members[name] = { at, outcome: outcome as 'ok' | CliFailureClass };
|
|
147
|
+
}
|
|
148
|
+
return { schema: 1, members };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Record one member's outcome, merging into whatever is already stored.
|
|
153
|
+
*
|
|
154
|
+
* Best-effort by construction: a write failure is swallowed. Telemetry that can
|
|
155
|
+
* break a council run is worse than telemetry that occasionally misses a row —
|
|
156
|
+
* the miss costs one `unknown`, the throw costs the run.
|
|
157
|
+
*/
|
|
158
|
+
export function recordProbe(
|
|
159
|
+
root: string,
|
|
160
|
+
name: string,
|
|
161
|
+
outcome: 'ok' | CliFailureClass,
|
|
162
|
+
at: string,
|
|
163
|
+
): void {
|
|
164
|
+
recordProbes(root, [{ name, outcome, at }]);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** One observation, as a caller collects them during a pass. */
|
|
168
|
+
export interface ProbeEntry {
|
|
169
|
+
readonly name: string;
|
|
170
|
+
readonly outcome: 'ok' | CliFailureClass;
|
|
171
|
+
readonly at: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Batch form — one read/write for a whole pass rather than one per member. */
|
|
175
|
+
export function recordProbes(root: string, entries: readonly ProbeEntry[]): void {
|
|
176
|
+
if (entries.length === 0 || isEnvKillSwitchActive(_KILL_SWITCH_ENV)) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const current = readProbeStore(root);
|
|
181
|
+
const members: Record<string, ProbeRecord> = { ...current.members };
|
|
182
|
+
for (const e of entries) {
|
|
183
|
+
members[e.name] = { at: e.at, outcome: e.outcome };
|
|
184
|
+
}
|
|
185
|
+
const target = probeStorePath(root);
|
|
186
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
187
|
+
fs.writeFileSync(target, `${JSON.stringify({ schema: 1, members }, null, 2)}\n`, 'utf8');
|
|
188
|
+
} catch {
|
|
189
|
+
// Intentionally silent — see the docstring.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The record for one member, or null when nothing was ever observed. */
|
|
194
|
+
export function probeFor(store: ProbeStore, name: string): ProbeRecord | null {
|
|
195
|
+
return store.members[name] ?? null;
|
|
196
|
+
}
|
|
@@ -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
|
+
}
|