@event4u/agent-config 13.0.0 → 14.1.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 +195 -315
- 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 +336 -112
- 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 +2128 -1326
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1764 -899
- 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 +5 -5
- 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/gate_ledger.ts +5 -1
- 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/build_archive_index.ts +62 -3
- package/src/scripts/capture_skill_catalogue.ts +264 -4
- package/src/scripts/check_evaluator_schema.ts +249 -0
- package/src/scripts/check_gate_coverage.ts +93 -0
- package/src/scripts/check_pr_ci_current.ts +492 -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/git_authorization_hook.ts +186 -1
- package/src/scripts/hook_manifest.yaml +72 -5
- package/src/scripts/hooks/block_unauthorized_git.ts +62 -6
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The quorum seam between `quorum.ts` and the council CLI.
|
|
3
|
+
*
|
|
4
|
+
* `quorum.ts` is pure arithmetic: given n, present and a setting, it answers
|
|
5
|
+
* whether a pass concluded. Everything here is the plumbing around that answer
|
|
6
|
+
* — reading the setting out of a possibly hand-built config dict, re-deriving
|
|
7
|
+
* presence from a round of responses, emitting the attendance event, and
|
|
8
|
+
* rendering the operator-facing banner.
|
|
9
|
+
*
|
|
10
|
+
* **Extracted for the same reason its sibling `qualification_wiring.ts` was,
|
|
11
|
+
* and the reason is worth stating rather than implying.** Adding provider
|
|
12
|
+
* qualification to `council_cli.ts` pushed that file from 4,058 to 4,311
|
|
13
|
+
* lines, and `check_source_size_budget` scores `Σ max(0, lines(f) − 1500)` —
|
|
14
|
+
* so every added line in a file already 2,500 over the ceiling counts against
|
|
15
|
+
* the ratchet, while the new modules beside it count zero. Moving the
|
|
16
|
+
* qualification seam out recovered 164 of those lines; the residual 89 was
|
|
17
|
+
* call-site wiring that cannot leave `council_cli.ts`. This module is the
|
|
18
|
+
* operator's answer to that residual: pull the quorum plumbing — the same
|
|
19
|
+
* layer, and the code the qualification change already reaches into — into a
|
|
20
|
+
* file whose subject it actually is.
|
|
21
|
+
*
|
|
22
|
+
* That makes the extraction a real boundary rather than budget accounting. The
|
|
23
|
+
* discriminator: every function here is about a k-of-n verdict and its
|
|
24
|
+
* reporting, and none of them is about parsing argv, constructing clients, or
|
|
25
|
+
* writing an output payload — which is what `council_cli.ts` is for.
|
|
26
|
+
*
|
|
27
|
+
* `Dict` is redeclared locally rather than imported: it is `Record<string,
|
|
28
|
+
* unknown>` in the CLI too, and importing a type alias that shallow would
|
|
29
|
+
* couple this module to the file it was just separated from.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { CouncilResponse, ExternalAIClient } from './clients.js';
|
|
33
|
+
import type { QuorumSetting } from './config.js';
|
|
34
|
+
import {
|
|
35
|
+
appendQuorumEvent,
|
|
36
|
+
type QuorumAbsence,
|
|
37
|
+
type QuorumCommand,
|
|
38
|
+
type QuorumDispatch,
|
|
39
|
+
type QuorumEventPhase,
|
|
40
|
+
} from './events_log.js';
|
|
41
|
+
import { evaluateQuorum, SOLO_FLOOR_MIN_PRESENT, type QuorumResult } from './quorum.js';
|
|
42
|
+
import { absentReasonFromCliFailure, classifyCliFailure } from './transport_resolver.js';
|
|
43
|
+
|
|
44
|
+
type Dict = Record<string, unknown>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `ai['quorum']` as forwarded by `_synthesize_ai_council_block` (already
|
|
48
|
+
* validated by `config.ts::_build_quorum` on that path) — or, for a caller
|
|
49
|
+
* that hands `build_members` a hand-built dict bypassing the loader
|
|
50
|
+
* entirely, a defensive re-check that fails soft to `'majority'` rather
|
|
51
|
+
* than throwing. `build_members` is not the config-validation layer;
|
|
52
|
+
* `evaluateQuorum` degrades an unrecognisable setting toward
|
|
53
|
+
* `inconclusive`, never toward a false `concluded`, so failing soft here
|
|
54
|
+
* costs nothing on the safety side.
|
|
55
|
+
*/
|
|
56
|
+
export function _quorum_setting_from(ai: Dict): QuorumSetting {
|
|
57
|
+
const raw = ai['quorum'];
|
|
58
|
+
if (raw === 'majority') {
|
|
59
|
+
return 'majority';
|
|
60
|
+
}
|
|
61
|
+
if (typeof raw === 'number' && Number.isInteger(raw) && raw >= 1) {
|
|
62
|
+
return raw;
|
|
63
|
+
}
|
|
64
|
+
return 'majority';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* `ai['quorum_min_present']` with the same fail-soft posture as
|
|
69
|
+
* `_quorum_setting_from`: the loader has already validated it on the normal
|
|
70
|
+
* path, and a hand-built dict that bypasses the loader falls back to the
|
|
71
|
+
* ADR-224 default rather than throwing.
|
|
72
|
+
*
|
|
73
|
+
* Failing soft costs nothing on the safety side here, and less than it does
|
|
74
|
+
* for `quorum`: this value feeds a counterfactual boolean that no gate reads,
|
|
75
|
+
* so the worst outcome of a wrong fallback is one mis-recorded telemetry line,
|
|
76
|
+
* never a pass that concluded when it should not have.
|
|
77
|
+
*/
|
|
78
|
+
export function _quorum_min_present_from(ai: Dict): number {
|
|
79
|
+
const raw = ai['quorum_min_present'];
|
|
80
|
+
if (typeof raw === 'number' && Number.isInteger(raw) && raw >= 1) {
|
|
81
|
+
return raw;
|
|
82
|
+
}
|
|
83
|
+
return SOLO_FLOOR_MIN_PRESENT;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Emit one `quorum_result` line for a resolved quorum.
|
|
88
|
+
*
|
|
89
|
+
* Both `evaluateQuorum` call sites route through here, tagged by `phase`, so
|
|
90
|
+
* a solo-concluded pass is distinguishable from a full-attendance one in the
|
|
91
|
+
* event log — the gap `road-to-always-on-orchestration`'s Risk 6 asserted was
|
|
92
|
+
* already closed and was not.
|
|
93
|
+
*
|
|
94
|
+
* The emit sits at the call sites rather than inside `_postRunQuorum` on
|
|
95
|
+
* purpose: that function is exported and unit-tested as a pure derivation,
|
|
96
|
+
* and a write buried in it would make every test of it a log writer.
|
|
97
|
+
*
|
|
98
|
+
* `absent` entries arrive as the CLI's own `{member, reason, detail}` dicts;
|
|
99
|
+
* `detail` is dropped by `appendQuorumEvent`'s input type, which cannot carry
|
|
100
|
+
* free-form text at all.
|
|
101
|
+
*/
|
|
102
|
+
export function _emitQuorumEvent(
|
|
103
|
+
phase: QuorumEventPhase,
|
|
104
|
+
quorum: QuorumResult,
|
|
105
|
+
absent: readonly Dict[],
|
|
106
|
+
ctx: {
|
|
107
|
+
command: QuorumCommand;
|
|
108
|
+
dispatch?: QuorumDispatch;
|
|
109
|
+
/** Enabled members before `--single` filtering; defaults to the roster that ran. */
|
|
110
|
+
configuredTotal?: number;
|
|
111
|
+
lens?: string;
|
|
112
|
+
invocation?: string;
|
|
113
|
+
/**
|
|
114
|
+
* The ADR-224 shadow floor for this pass. `gateClass` is deliberately
|
|
115
|
+
* NOT in this context object: no CLI path declares itself gate-class,
|
|
116
|
+
* so a parameter here would be one no caller ever sets, and its
|
|
117
|
+
* absence is what the `false` on every line honestly records.
|
|
118
|
+
*/
|
|
119
|
+
minPresent?: number;
|
|
120
|
+
},
|
|
121
|
+
): void {
|
|
122
|
+
appendQuorumEvent({
|
|
123
|
+
lens: ctx.lens ?? '',
|
|
124
|
+
invocation: ctx.invocation ?? '',
|
|
125
|
+
phase,
|
|
126
|
+
command: ctx.command,
|
|
127
|
+
dispatch: ctx.dispatch ?? 'full',
|
|
128
|
+
configuredTotal: ctx.configuredTotal ?? quorum.total,
|
|
129
|
+
result: quorum,
|
|
130
|
+
...(ctx.minPresent !== undefined ? { minPresent: ctx.minPresent } : {}),
|
|
131
|
+
absent: absent.map(
|
|
132
|
+
(a): QuorumAbsence => ({
|
|
133
|
+
member: String(a['member'] ?? ''),
|
|
134
|
+
reason: (a['reason'] as QuorumAbsence['reason']) ?? 'unavailable',
|
|
135
|
+
}),
|
|
136
|
+
),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Re-derive quorum AFTER the provider calls, over what was actually USABLE —
|
|
142
|
+
* never what merely CONSTRUCTED (M3, independent-review finding). `members`
|
|
143
|
+
* and `responses` must be index-aligned (`consult()`'s own contract: one
|
|
144
|
+
* `CouncilResponse` per member, from the final round); `members.length` is
|
|
145
|
+
* the `n` — deliberately the roster that actually ran (post `--single` /
|
|
146
|
+
* `--siblings` filtering), never the full config roster a filtered run
|
|
147
|
+
* never attempted. A response carrying `.error` (or a missing response
|
|
148
|
+
* entry altogether) counts as absent, classified through the same
|
|
149
|
+
* `AbsentReason` vocabulary a static (pre-call) resolution failure uses, so
|
|
150
|
+
* one artefact buckets every absence — construction-time and mid-flight —
|
|
151
|
+
* under one taxonomy.
|
|
152
|
+
*/
|
|
153
|
+
export function _postRunQuorum(
|
|
154
|
+
members: ExternalAIClient[],
|
|
155
|
+
responses: CouncilResponse[],
|
|
156
|
+
ai_cfg: Dict,
|
|
157
|
+
): { quorum: QuorumResult; absent: Dict[] } {
|
|
158
|
+
const absent: Dict[] = [];
|
|
159
|
+
let present = 0;
|
|
160
|
+
for (let i = 0; i < members.length; i++) {
|
|
161
|
+
const m = members[i] as ExternalAIClient;
|
|
162
|
+
const r = responses[i];
|
|
163
|
+
// Round 7 § 5.2 — attendance is a NON-EMPTY answer, not the absence of an
|
|
164
|
+
// error. `!r.error` alone counted a member that returned zero bytes, and
|
|
165
|
+
// that is not hypothetical: a 290 s curl timeout returned an empty body
|
|
166
|
+
// with no error set in two sessions (`9fc9ba3e`, `4ac2f7ac`) and the
|
|
167
|
+
// banner printed `2/2 present` — a single-voice verdict presented as
|
|
168
|
+
// convergence, on a paid run. An empty answer contributes nothing to a
|
|
169
|
+
// quorum by definition, whatever the transport thought of it.
|
|
170
|
+
if (r !== undefined && !r.error && r.text.trim() !== '') {
|
|
171
|
+
present += 1;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const raw = r?.error ?? (r !== undefined && r.text.trim() === '' ? 'empty response body' : 'no response');
|
|
175
|
+
const failure = classifyCliFailure(raw);
|
|
176
|
+
absent.push({
|
|
177
|
+
member: m.name,
|
|
178
|
+
reason: absentReasonFromCliFailure(failure) ?? 'unavailable',
|
|
179
|
+
detail: raw,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return { quorum: evaluateQuorum(members.length, present, _quorum_setting_from(ai_cfg)), absent };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* One-line k-of-n banner, mirrored from `orchestrator.ts::_render_quorum_line`
|
|
187
|
+
* but for the CLI's own stdout stream rather than the rendered report —
|
|
188
|
+
* "attendance is telemetry, never a silent drop" applies to the estimate
|
|
189
|
+
* preview too, not only to a completed pass.
|
|
190
|
+
*/
|
|
191
|
+
export function _format_quorum_line(q: QuorumResult, phase?: QuorumEventPhase): string {
|
|
192
|
+
// The phase tag is not decoration. A degraded run prints attendance TWICE —
|
|
193
|
+
// the pre-run reading before the estimate table, the post-run reading after
|
|
194
|
+
// the consult — and the two disagree by construction: `2/2 present …
|
|
195
|
+
// concluded` then `0/2 present … INCONCLUSIVE`. Same prefix, opposite
|
|
196
|
+
// content, and until 2026-08-12 neither line said which was which, so an
|
|
197
|
+
// operator skimming (or anything grepping `council:quorum ·`) could take the
|
|
198
|
+
// stale one. The event log had solved this from the start with an explicit
|
|
199
|
+
// `phase` field; stdout had not. Optional, because the estimate path prints
|
|
200
|
+
// exactly one line and a tag there would claim a distinction it does not make.
|
|
201
|
+
const tag = phase === undefined ? '' : `${phase === 'pre_run' ? 'before the run' : 'after the run'} · `;
|
|
202
|
+
const verdict = q.status === 'concluded' ? 'concluded' : 'INCONCLUSIVE — release gate holds';
|
|
203
|
+
// Round 7 § 5.3 — a degraded pass says so. `1/2 present, needed 1 —
|
|
204
|
+
// concluded` is literally true and reads as agreement; with a threshold of 1
|
|
205
|
+
// a solo answer concludes, and nothing in the line distinguishes "both
|
|
206
|
+
// members agreed" from "one member answered". The counts were always there;
|
|
207
|
+
// what was missing is the word that stops a reader inferring convergence.
|
|
208
|
+
const degraded =
|
|
209
|
+
q.present < q.total ? ` ⚠️ DEGRADED — ${String(q.total - q.present)} member(s) did not answer; this is not convergence.` : '';
|
|
210
|
+
return `council:quorum · ${tag}${q.present}/${q.total} present, needed ${q.threshold} — ${verdict}.${degraded}`;
|
|
211
|
+
}
|
|
@@ -43,9 +43,11 @@ import * as path from 'node:path';
|
|
|
43
43
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
44
44
|
|
|
45
45
|
import {
|
|
46
|
+
CLI_CONSUMER_TEAM,
|
|
46
47
|
type CouncilResponse,
|
|
47
48
|
load_cli_call_counts,
|
|
48
49
|
OpenAICliClient,
|
|
50
|
+
QUOTA_SOURCE_PROVIDER,
|
|
49
51
|
} from '../ai_council/clients.js';
|
|
50
52
|
import {
|
|
51
53
|
checkCodexAvailability,
|
|
@@ -361,7 +363,9 @@ export interface TeamReviewFinding {
|
|
|
361
363
|
}
|
|
362
364
|
|
|
363
365
|
export interface TeamReviewQuota {
|
|
364
|
-
/**
|
|
366
|
+
/** The shared-bucket key this run books into — read from the client, not assumed. */
|
|
367
|
+
readonly provider: string;
|
|
368
|
+
/** Today's call count in that bucket AFTER this run. */
|
|
365
369
|
readonly used: number;
|
|
366
370
|
/** `ai_team.max_calls_per_day` — the team-side ceiling on the shared bucket. */
|
|
367
371
|
readonly ceiling: number;
|
|
@@ -549,9 +553,23 @@ export interface RunTeamReviewOptions {
|
|
|
549
553
|
halted?: boolean;
|
|
550
554
|
}
|
|
551
555
|
|
|
552
|
-
|
|
556
|
+
/**
|
|
557
|
+
* Read the bucket this run actually books into.
|
|
558
|
+
*
|
|
559
|
+
* `provider` comes from the CLIENT (`client.name`), never from a literal. It was
|
|
560
|
+
* hardcoded to `'openai'`, which happened to be correct because
|
|
561
|
+
* `TeamReviewCliClient extends OpenAICliClient` — and would have gone silently
|
|
562
|
+
* wrong the moment the team transport moved, reporting a bucket nobody was
|
|
563
|
+
* booking while the real one filled up. A report that names its own subject
|
|
564
|
+
* cannot drift away from it.
|
|
565
|
+
*/
|
|
566
|
+
function _quota_snapshot(
|
|
567
|
+
config: AiTeamConfig,
|
|
568
|
+
cli_calls_path: string | null,
|
|
569
|
+
provider: string,
|
|
570
|
+
): TeamReviewQuota {
|
|
553
571
|
const counts = load_cli_call_counts(cli_calls_path);
|
|
554
|
-
return { used: counts[
|
|
572
|
+
return { provider, used: counts[provider] ?? 0, ceiling: config.max_calls_per_day };
|
|
555
573
|
}
|
|
556
574
|
|
|
557
575
|
function _envelope_from_response(
|
|
@@ -571,9 +589,19 @@ function _envelope_from_response(
|
|
|
571
589
|
'codex CLI auth failed (auth_expired) — run `codex login`, then retry. ' +
|
|
572
590
|
'No review was produced.';
|
|
573
591
|
} else if (response.error === 'cli_quota_exhausted') {
|
|
592
|
+
// `cli_quota_exhausted` covers two events with opposite remedies, so
|
|
593
|
+
// the message must not assert one of them unconditionally. "Resets at
|
|
594
|
+
// UTC midnight" is true only of OUR counter; when the vendor refused
|
|
595
|
+
// us, their window governs and we do not know it.
|
|
574
596
|
blocking_reason =
|
|
575
|
-
|
|
576
|
-
|
|
597
|
+
response.metadata?.['quota_source'] === QUOTA_SOURCE_PROVIDER
|
|
598
|
+
? `the ${quota.provider} CLI refused the call as over its own quota ` +
|
|
599
|
+
`(local bucket ${quota.used}/${quota.ceiling}) — the provider's window ` +
|
|
600
|
+
'governs, not ours. No review was produced.'
|
|
601
|
+
: `daily cli-call quota exhausted for the shared ${quota.provider} bucket ` +
|
|
602
|
+
`(${quota.used}/${quota.ceiling}) — resets at UTC midnight, or clear it with ` +
|
|
603
|
+
'`agent-config council:quota --reset <provider> --confirm`. ' +
|
|
604
|
+
'No review was produced.';
|
|
577
605
|
} else {
|
|
578
606
|
blocking_reason = `codex CLI transport failed: ${response.error}. No review was produced.`;
|
|
579
607
|
}
|
|
@@ -649,10 +677,16 @@ export function run_team_review(opts: RunTeamReviewOptions = {}): TeamReviewRunR
|
|
|
649
677
|
model: config.model,
|
|
650
678
|
max_calls_per_day: config.max_calls_per_day,
|
|
651
679
|
cli_calls_path,
|
|
680
|
+
// The team half of the two enumerated consumers of the shared
|
|
681
|
+
// counter. Declared here because the client cannot know its
|
|
682
|
+
// caller — the bucket standing at 171 calls with zero recorded
|
|
683
|
+
// council exchanges is precisely what an unattributed counter
|
|
684
|
+
// cannot explain.
|
|
685
|
+
consumer: CLI_CONSUMER_TEAM,
|
|
652
686
|
});
|
|
653
687
|
|
|
654
688
|
const response = client.ask(TEAM_REVIEW_SYSTEM_PROMPT, render_review_user_prompt(bundle));
|
|
655
|
-
const quota = _quota_snapshot(config, cli_calls_path);
|
|
689
|
+
const quota = _quota_snapshot(config, cli_calls_path, client.name);
|
|
656
690
|
const envelope = _envelope_from_response(response, bundle, quota);
|
|
657
691
|
out(JSON.stringify(envelope, null, 2));
|
|
658
692
|
return { mode: 'call', header, bundle, envelope };
|
|
@@ -45,6 +45,7 @@ import * as path from 'node:path';
|
|
|
45
45
|
import process from 'node:process';
|
|
46
46
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
47
47
|
|
|
48
|
+
import { GateLedger } from './_lib/gate_ledger.js';
|
|
48
49
|
import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
|
|
49
50
|
import { parse_frontmatter } from './validate_frontmatter.js';
|
|
50
51
|
|
|
@@ -217,12 +218,24 @@ export function archiveFiles(dir: string): string[] {
|
|
|
217
218
|
.sort();
|
|
218
219
|
}
|
|
219
220
|
|
|
220
|
-
|
|
221
|
-
|
|
221
|
+
/**
|
|
222
|
+
* Build entries from an enumeration the CALLER already performed.
|
|
223
|
+
*
|
|
224
|
+
* Split out so the ledger's plan and the results can come from ONE walk. With
|
|
225
|
+
* two walks, a file created between them reports as a skip that never happened
|
|
226
|
+
* and a file deleted between them yields an entry that was never planned — the
|
|
227
|
+
* plan would describe a different directory state than the results.
|
|
228
|
+
*/
|
|
229
|
+
export function buildIndexFrom(dir: string, names: readonly string[]): ArchiveEntry[] {
|
|
230
|
+
return names.map((name) =>
|
|
222
231
|
buildEntry(name.replace(/\.md$/, ''), fs.readFileSync(path.join(dir, name), 'utf8')),
|
|
223
232
|
);
|
|
224
233
|
}
|
|
225
234
|
|
|
235
|
+
export function buildIndex(dir: string): ArchiveEntry[] {
|
|
236
|
+
return buildIndexFrom(dir, archiveFiles(dir));
|
|
237
|
+
}
|
|
238
|
+
|
|
226
239
|
/** Table-cell text: pipes escaped, newlines flattened, never empty. */
|
|
227
240
|
function _cell(value: string | null): string {
|
|
228
241
|
if (value === null) {
|
|
@@ -309,7 +322,44 @@ export function main(argv: readonly string[]): number {
|
|
|
309
322
|
const quiet = argv.includes('--quiet');
|
|
310
323
|
const dir = path.join(ROOT, ARCHIVE_REL);
|
|
311
324
|
|
|
312
|
-
|
|
325
|
+
// ONE walk feeds both the plan and the results.
|
|
326
|
+
//
|
|
327
|
+
// Planning the result set and completing it in the next statement would make
|
|
328
|
+
// `planned === completed, skipped=0` true by construction — an accounting
|
|
329
|
+
// line that can never report anything. Planning a SECOND walk is only
|
|
330
|
+
// marginally better: it describes a directory state the results did not come
|
|
331
|
+
// from. Enumerating once and passing that list into the builder makes the
|
|
332
|
+
// gap real — the natural future fix for an unreadable archive file is a
|
|
333
|
+
// `try/catch { continue }` in the builder, and that file then shows up here
|
|
334
|
+
// as a skip instead of vanishing.
|
|
335
|
+
//
|
|
336
|
+
// `not-extractable` is a real reading rather than a skip and therefore
|
|
337
|
+
// completes — but a frontmatter PARSE failure is swallowed by `_frontmatter`
|
|
338
|
+
// and zeroes title/verdict/status, which is a genuine per-target degrade the
|
|
339
|
+
// disposition tally cannot show. Both are visible here as the difference
|
|
340
|
+
// between the planned and completed sets.
|
|
341
|
+
const walked = archiveFiles(dir);
|
|
342
|
+
const entries = buildIndexFrom(dir, walked);
|
|
343
|
+
const planned = walked.map((f) => path.basename(f, '.md'));
|
|
344
|
+
const produced = new Set(entries.map((e) => e.slug));
|
|
345
|
+
const ledger = new GateLedger('build_archive_index');
|
|
346
|
+
ledger.plan(planned);
|
|
347
|
+
for (const slug of planned) {
|
|
348
|
+
// `check_did_not_run`, not `no_applicable_files`: the file IS one the
|
|
349
|
+
// check applies to — the entry build did not produce a reading for it.
|
|
350
|
+
if (produced.has(slug)) ledger.complete(slug);
|
|
351
|
+
else ledger.skip(slug, 'check_did_not_run');
|
|
352
|
+
}
|
|
353
|
+
// A produced entry outside the plan means the two sets came apart despite
|
|
354
|
+
// the single walk; the ledger would under-state the denominator silently.
|
|
355
|
+
const unplanned = [...produced].filter((s) => !planned.includes(s));
|
|
356
|
+
if (unplanned.length > 0) {
|
|
357
|
+
throw new Error(
|
|
358
|
+
`build_archive_index: ${String(unplanned.length)} entry/entries were produced but never ` +
|
|
359
|
+
`planned (${unplanned.slice(0, 5).join(', ')}) — the ledger denominator is wrong.`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
313
363
|
try {
|
|
314
364
|
reportScanned({
|
|
315
365
|
gate: 'build_archive_index',
|
|
@@ -325,6 +375,15 @@ export function main(argv: readonly string[]): number {
|
|
|
325
375
|
throw err;
|
|
326
376
|
}
|
|
327
377
|
|
|
378
|
+
// Reported before the `--check` and write paths branch, so both publish the
|
|
379
|
+
// same accounting. NOT on all three: the earliest exit — `reportScanned`
|
|
380
|
+
// throwing `DeadScopeError` on an empty walk — returns above this line and
|
|
381
|
+
// reports nothing, which is the one exit where the accounting would say the
|
|
382
|
+
// most. Left as-is rather than papered over: the ledger has no targets to
|
|
383
|
+
// report there either, and moving it above the scope assertion would publish
|
|
384
|
+
// a plan for a root the assertion is about to reject.
|
|
385
|
+
ledger.report();
|
|
386
|
+
|
|
328
387
|
const mdPath = path.join(dir, INDEX_MD);
|
|
329
388
|
const jsonPath = path.join(dir, INDEX_JSON);
|
|
330
389
|
const md = renderMarkdown(entries);
|