@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.
Files changed (118) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +160 -333
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/evals/roadmap.json +8 -0
  5. package/dist/agent-src/commands/fix/ci.md +39 -1
  6. package/dist/agent-src/commands/pr/create.md +65 -4
  7. package/dist/agent-src/commands/roadmap/next.md +28 -5
  8. package/dist/agent-src/commands/work.md +16 -0
  9. package/dist/agent-src/contexts/contracts/command-suggestion-flow.md +47 -0
  10. package/dist/agent-src/contexts/execution/contract-decision-sheet.md +94 -0
  11. package/dist/agent-src/contexts/execution/plan-confidence-gate.md +30 -0
  12. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +21 -8
  13. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +55 -8
  14. package/dist/agent-src/contexts/execution/subagent-response-contract.md +42 -0
  15. package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +29 -0
  16. package/dist/agent-src/rules/command-suggestion-policy.md +33 -0
  17. package/dist/agent-src/rules/design-review-after-ui-write.md +25 -8
  18. package/dist/agent-src/rules/ui-audit-gate.md +25 -8
  19. package/dist/agent-src/scripts/gate_execute.ts +395 -0
  20. package/dist/agent-src/scripts/resume_probe.ts +422 -0
  21. package/dist/agent-src/scripts/roadmap_gates.ts +138 -4
  22. package/dist/agent-src/scripts/update_roadmap_progress.ts +53 -2
  23. package/dist/agent-src/skills/ai-council/SKILL.md +61 -0
  24. package/dist/agent-src/skills/experiment-loop/SKILL.md +116 -0
  25. package/dist/agent-src/skills/experiment-loop/evals/output-schema.yml +27 -0
  26. package/dist/agent-src/skills/experiment-loop/evals/triggers.json +17 -0
  27. package/dist/agent-src/skills/experiment-loop/references/pivot-ladder.md +54 -0
  28. package/dist/agent-src/skills/experiment-loop/references/protocol.md +61 -0
  29. package/dist/agent-src/skills/experiment-loop/references/register-format.md +66 -0
  30. package/dist/agent-src/skills/roadmap-management/SKILL.md +2 -2
  31. package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
  32. package/dist/agent-src/skills/subagent-orchestration/schemas/team-review-status.json +8 -3
  33. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  34. package/dist/agent-src/templates/roadmaps.md +43 -3
  35. package/dist/cli/registry.js +1 -0
  36. package/dist/cli/registry.js.map +1 -1
  37. package/dist/cli-delegate/{chunk-N7FEST6D.js → chunk-7PZ4FSZB.js} +351 -174
  38. package/dist/cli-delegate/{chunk-3B72LSZL.js → chunk-F3YUF7SA.js} +1 -1
  39. package/dist/cli-delegate/cmd_conformance.js +224 -104
  40. package/dist/cli-delegate/cmd_doctor.js +2 -2
  41. package/dist/cli-delegate/cmd_handoff.js +1 -1
  42. package/dist/discovery/deprecation-report.md +1 -1
  43. package/dist/discovery/discovery-manifest.json +40 -18
  44. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  45. package/dist/discovery/discovery-manifest.summary.md +3 -3
  46. package/dist/discovery/dormancy-report.md +1 -1
  47. package/dist/discovery/orphan-report.md +1 -1
  48. package/dist/discovery/packs.json +6 -5
  49. package/dist/discovery/trust-report.md +2 -2
  50. package/dist/discovery/workspaces.json +5 -4
  51. package/dist/hooks/dispatch.js +2018 -1320
  52. package/dist/mcp/registry-manifest.json +3 -3
  53. package/dist/mcp/server.json +2 -2
  54. package/dist/mcp/server.mjs +1652 -892
  55. package/dist/router.json +1 -1
  56. package/dist/scripts/condense.js +1 -1
  57. package/dist/scripts/condense.js.map +1 -1
  58. package/dist/ui/assets/{index-B0aMJnuz.js → index-C1pBRm1f.js} +2 -2
  59. package/dist/ui/assets/index-C1pBRm1f.js.map +1 -0
  60. package/dist/ui/index.html +1 -1
  61. package/package.json +1 -1
  62. package/src/agent-src/scripts/gate_execute.ts +395 -0
  63. package/src/agent-src/scripts/resume_probe.ts +422 -0
  64. package/src/agent-src/scripts/roadmap_gates.ts +138 -4
  65. package/src/agent-src/scripts/update_roadmap_progress.ts +53 -2
  66. package/src/config/ci-local-parity.yml +13 -0
  67. package/src/config/evaluator-budgets.json +4 -4
  68. package/src/config/gate-coverage.yml +111 -5
  69. package/src/config/gate-violation-baselines.json +2 -2
  70. package/src/config/rule-activation-census.json +41 -0
  71. package/src/config/rule-stub-ceilings.json +52 -0
  72. package/src/scripts/_dispatch.bash +4 -0
  73. package/src/scripts/_lib/evaluator_contract.ts +159 -0
  74. package/src/scripts/_lib/gh_transient.ts +80 -0
  75. package/src/scripts/_lib/release_material.ts +16 -5
  76. package/src/scripts/_lib/review_independence.ts +111 -0
  77. package/src/scripts/_lib/session_register.ts +14 -0
  78. package/src/scripts/_lib/skill_catalogue.ts +20 -0
  79. package/src/scripts/_lib/skill_catalogue_series.ts +411 -0
  80. package/src/scripts/_lib/turn_end_refusals.ts +482 -0
  81. package/src/scripts/ai_council/_py_json.ts +96 -0
  82. package/src/scripts/ai_council/cli_call_budget.ts +357 -0
  83. package/src/scripts/ai_council/clients.ts +80 -143
  84. package/src/scripts/ai_council/config.ts +25 -11
  85. package/src/scripts/ai_council/probe_store.ts +196 -0
  86. package/src/scripts/ai_council/qualification.ts +425 -0
  87. package/src/scripts/ai_council/qualification_wiring.ts +243 -0
  88. package/src/scripts/ai_council/quorum_wiring.ts +211 -0
  89. package/src/scripts/ai_team/team_dispatch.ts +40 -6
  90. package/src/scripts/capture_skill_catalogue.ts +264 -4
  91. package/src/scripts/check_evaluator_schema.ts +249 -0
  92. package/src/scripts/check_pr_ci_current.ts +409 -0
  93. package/src/scripts/check_review_schema.ts +195 -0
  94. package/src/scripts/check_rule_activation_census.ts +451 -0
  95. package/src/scripts/check_rule_stub_ceiling.ts +1128 -0
  96. package/src/scripts/condense.ts +1 -1
  97. package/src/scripts/council_cli.ts +141 -191
  98. package/src/scripts/dispatch_r2_reviewer.ts +8 -1
  99. package/src/scripts/hook_manifest.yaml +72 -5
  100. package/src/scripts/hooks/concern_registry.ts +4 -0
  101. package/src/scripts/hooks/dispatch_hook.ts +43 -1
  102. package/src/scripts/hooks/interruption_ledger_hook.ts +256 -0
  103. package/src/scripts/hooks/ship_diff_volume_hook.ts +180 -0
  104. package/src/scripts/hooks/turn_end_gate_hook.ts +119 -26
  105. package/src/scripts/hooks_doctor.ts +112 -0
  106. package/src/scripts/interruption_report.ts +443 -0
  107. package/src/scripts/lint_evidence_artifacts.ts +359 -0
  108. package/src/scripts/lint_roadmap_blockers.ts +65 -0
  109. package/src/scripts/lint_roadmap_complexity.ts +7 -1
  110. package/src/scripts/release.ts +38 -53
  111. package/src/scripts/rule_activation_census.ts +292 -0
  112. package/src/scripts/schemas/evaluator-output.schema.json +60 -0
  113. package/src/scripts/schemas/review-findings.schema.json +31 -0
  114. package/src/scripts/self_review_gate.ts +6 -0
  115. package/src/scripts/session_register_hook.ts +30 -1
  116. package/src/scripts/sync_pr_branch.ts +271 -0
  117. package/src/scripts/test_council_qualification.ts +213 -0
  118. 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. Every known provider
1931
- * now carries this floor unless a config entry overrides it; sized as a
1932
- * GUARD against silent quota exhaustion on an always-on pass, not a brake on
1933
- * normal use (see the template's own worked-example sizing for the
1934
- * reasoning behind a TIGHTER per-provider number).
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 _DEFAULT_CLI_CALLS_PER_DAY = 50;
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
- const caps = new Map<string, number>();
1949
- for (const provider of _VALID_PROVIDERS) {
1950
- caps.set(provider, _DEFAULT_CLI_CALLS_PER_DAY);
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
+ }