@event4u/agent-config 9.32.0 → 9.33.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 +96 -1
- package/dist/agent-src/commands/review/routing.md +3 -4
- package/dist/agent-src/contexts/communication/rules-auto/reply-close-mechanics.md +65 -0
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +3 -0
- package/dist/agent-src/rules/delegation-policy.md +6 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +107 -3
- package/dist/agent-src/skills/docker/SKILL.md +11 -0
- package/dist/agent-src/skills/laravel/SKILL.md +11 -0
- package/dist/agent-src/skills/review-routing/SKILL.md +23 -6
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/ownership-map.example.yml +1 -1
- package/dist/cli/registry.js +4 -4
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-HTIU3W56.js +66 -0
- package/dist/cli-delegate/{chunk-ITIQSGOQ.js → chunk-MA4GFWDY.js} +7 -4
- package/dist/cli-delegate/{chunk-542DZR4P.js → chunk-PMQ6Z3T3.js} +1651 -144
- package/dist/cli-delegate/{chunk-PFBV6XRT.js → chunk-Q26HT4QT.js} +1 -63
- package/dist/cli-delegate/{chunk-LVRYBKTI.js → chunk-YPAWXXXR.js} +158 -0
- package/dist/cli-delegate/cmd_conformance.js +256 -226
- package/dist/cli-delegate/cmd_doctor.js +3 -2
- package/dist/cli-delegate/cmd_export.js +1 -1
- package/dist/cli-delegate/cmd_handoff.js +19 -26
- package/dist/cli-delegate/cmd_prune.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +13 -3
- package/dist/cli-delegate/cmd_settings_get.js +4 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
- package/dist/cli-delegate/cmd_settings_set.js +5 -3
- package/dist/cli-delegate/cmd_sync.js +1 -1
- package/dist/cli-delegate/cmd_uninstall.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +8 -8
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +18 -0
- package/dist/install/install.mjs +7 -4
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +3637 -1158
- package/package.json +1 -1
- package/src/agent-src/scripts/roadmap_gates.ts +107 -3
- package/src/agent-src/templates/scripts/ownership-map.example.yml +1 -1
- package/src/config/cost-parity-budget.json +167 -0
- package/src/config/gate-coverage.yml +44 -5
- package/src/config/quorum-attendance-budget.json +39 -0
- package/src/scripts/_cli/cmd_doctor.ts +69 -1
- package/src/scripts/_cli/cmd_session_recycle.ts +19 -3
- package/src/scripts/_dispatch.bash +5 -3
- package/src/scripts/_lib/orchestration_record.ts +45 -0
- package/src/scripts/ai_council/clients.ts +31 -0
- package/src/scripts/ai_council/events_log.ts +128 -6
- package/src/scripts/ai_council/orchestrator.ts +12 -3
- package/src/scripts/ai_council/pricing.ts +48 -1
- package/src/scripts/ai_council/quorum.ts +20 -0
- package/src/scripts/ai_council/session.ts +11 -0
- package/src/scripts/ask_transport.ts +20 -1
- package/src/scripts/cache_realization_report.ts +38 -0
- package/src/scripts/check_release_highlights.ts +7 -1
- package/src/scripts/conformance_scan.ts +105 -0
- package/src/scripts/cost/track.mjs +39 -0
- package/src/scripts/cost_summary.ts +104 -1
- package/src/scripts/council_cli.ts +131 -13
- package/src/scripts/dispatch_economy_report.ts +5 -2
- package/src/scripts/install.ts +20 -6
- package/src/scripts/lint_budget_ownership.ts +81 -0
- package/src/scripts/lint_skill_frontmatter_safety.ts +55 -1
- package/src/scripts/lint_workflow_paths.ts +384 -0
- package/src/scripts/preamble_byte_census.ts +9 -1
- package/src/scripts/report_carrier_divergence.ts +10 -0
- package/src/scripts/report_conformance_funnel.ts +40 -0
- package/src/scripts/schemas/subagent.schema.json +3 -2
|
@@ -130,7 +130,7 @@ Tier 1 — power-user (release shape, audit, migration):
|
|
|
130
130
|
user-authored files; persists standing consent
|
|
131
131
|
(install.auto_converge) on first use.
|
|
132
132
|
Flags: --dry-run | --yes
|
|
133
|
-
conformance:behavior Replay local transcripts through the mechanised conformance checks
|
|
133
|
+
conformance:behavior Replay local transcripts through the mechanised conformance checks (--why <id> traces one)
|
|
134
134
|
conformance Consumer conformance contract: doctor --ci +
|
|
135
135
|
installed-and-firing checks. Exits non-zero on
|
|
136
136
|
a failed check.
|
|
@@ -197,7 +197,7 @@ Tier 2 — maintenance / internal (hooks, MCP, memory, telemetry):
|
|
|
197
197
|
session register). Flags: --json
|
|
198
198
|
sessions:claim Claim a roadmap for this session so other sessions skip
|
|
199
199
|
it; --release clears the claim
|
|
200
|
-
session:recycle Validate + write the main-session recycle envelope
|
|
200
|
+
session:recycle Validate + write the main-session recycle envelope (--verify validates only)
|
|
201
201
|
(deliberate recycle instead of auto-compact). Flags:
|
|
202
202
|
--file <json> | --template; default reads stdin
|
|
203
203
|
roadmap:progress Regenerate agents/roadmaps-progress.md from open roadmaps
|
|
@@ -207,7 +207,9 @@ Tier 2 — maintenance / internal (hooks, MCP, memory, telemetry):
|
|
|
207
207
|
gates Open decisions that need you, rendered as actions —
|
|
208
208
|
roadmap blockers filtered by owner, most-unblocking
|
|
209
209
|
first. Flags: --all (include maintainer/external),
|
|
210
|
-
--json
|
|
210
|
+
--json, --reply (reply-close form: the one blocking
|
|
211
|
+
decision in full, the rest as a count; prints
|
|
212
|
+
nothing when none are yours)
|
|
211
213
|
capabilities:index Regenerate CAPABILITIES.yaml — the package coverage index
|
|
212
214
|
(capability area → coverage → backing skills/commands → gaps).
|
|
213
215
|
Pass --check to fail if stale (for CI). Reads src/ (package repo).
|
|
@@ -64,6 +64,20 @@ export interface RecordInput {
|
|
|
64
64
|
dispatch_tokens?: number | null | undefined;
|
|
65
65
|
/** The orchestrator's own tier — the baseline the downshift cost-% measures against. */
|
|
66
66
|
session_tier?: string | null | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* The model id that was ASKED for. `tier_chosen`, `tier_source`,
|
|
69
|
+
* `session_tier` and the downshift cost-percentage are all read off the
|
|
70
|
+
* requested tier, so on an alias or a provider substitution every one of
|
|
71
|
+
* them attributes the saving to a model that never ran. Recording the
|
|
72
|
+
* requested id beside the served one is what makes that detectable.
|
|
73
|
+
*/
|
|
74
|
+
model_requested?: string | null | undefined;
|
|
75
|
+
/**
|
|
76
|
+
* The model id the provider reported SERVING. `''` / null when the
|
|
77
|
+
* transport reports none (every CLI client) — absent is honest, not a
|
|
78
|
+
* defect, and is NOT a divergence.
|
|
79
|
+
*/
|
|
80
|
+
model_served?: string | null | undefined;
|
|
67
81
|
dispatch_outcome?: DispatchOutcome | undefined;
|
|
68
82
|
verify_mode?: VerifyMode | undefined;
|
|
69
83
|
/** QUALITY: subagent return adopted without parent rework. Optional boolean (null = not measured). */
|
|
@@ -176,6 +190,23 @@ function isInt(n: unknown): n is number {
|
|
|
176
190
|
return typeof n === 'number' && Number.isFinite(n) && Number.isInteger(n);
|
|
177
191
|
}
|
|
178
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Did the provider answer with a different model than the one requested?
|
|
195
|
+
*
|
|
196
|
+
* `null` — not decidable — whenever either id is missing or empty. That is the
|
|
197
|
+
* common case (every CLI transport reports no served id) and it is not the
|
|
198
|
+
* same as "no divergence": a `false` here would claim a comparison that never
|
|
199
|
+
* happened, which is the exact mis-attribution this field exists to expose.
|
|
200
|
+
*/
|
|
201
|
+
function modelDivergent(
|
|
202
|
+
requested: string | null | undefined,
|
|
203
|
+
served: string | null | undefined,
|
|
204
|
+
): boolean | null {
|
|
205
|
+
if (typeof requested !== 'string' || typeof served !== 'string') return null;
|
|
206
|
+
if (!requested || !served) return null;
|
|
207
|
+
return requested !== served;
|
|
208
|
+
}
|
|
209
|
+
|
|
179
210
|
/**
|
|
180
211
|
* Build + validate the audit-log-v1 line. Returns `{line, errors}`; when
|
|
181
212
|
* `errors` is non-empty, `line` is null and the caller must NOT write.
|
|
@@ -290,6 +321,13 @@ export function buildOrchestrationLine(input: RecordInput): BuiltLine {
|
|
|
290
321
|
errors.push(`trigger_arm_earlier must be one of ${TRIGGER_ARMS.join(' | ')} (or omitted / null when neither arm fired)`);
|
|
291
322
|
}
|
|
292
323
|
|
|
324
|
+
for (const [key, v] of [
|
|
325
|
+
['model_requested', input.model_requested],
|
|
326
|
+
['model_served', input.model_served],
|
|
327
|
+
] as const) {
|
|
328
|
+
if (v != null && typeof v !== 'string') errors.push(`${key} must be a string or omitted`);
|
|
329
|
+
}
|
|
330
|
+
|
|
293
331
|
if (!input.ts) errors.push('ts (ISO-8601 UTC) is required');
|
|
294
332
|
if (!input.id) errors.push('id (ULID or content hash) is required');
|
|
295
333
|
|
|
@@ -310,6 +348,13 @@ export function buildOrchestrationLine(input: RecordInput): BuiltLine {
|
|
|
310
348
|
tier_source: input.tier_source ?? null,
|
|
311
349
|
dispatch_tokens: input.dispatch_tokens ?? null,
|
|
312
350
|
session_tier: input.session_tier ?? null,
|
|
351
|
+
// Served-model attribution. `model_divergent` is DERIVED, never
|
|
352
|
+
// supplied: `null` when either id is absent — a transport that reports
|
|
353
|
+
// no served id cannot disagree with anything, and calling that `false`
|
|
354
|
+
// would read as "checked, and they matched".
|
|
355
|
+
model_requested: input.model_requested ?? null,
|
|
356
|
+
model_served: input.model_served ?? null,
|
|
357
|
+
model_divergent: modelDivergent(input.model_requested, input.model_served),
|
|
313
358
|
first_pass_success: input.first_pass_success ?? null,
|
|
314
359
|
escalated: input.escalated ?? null,
|
|
315
360
|
agent_combo: input.agent_combo ?? [],
|
|
@@ -195,10 +195,34 @@ export function assertCacheBreakpointOrder(ttls: readonly PromptCacheTtl[]): voi
|
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* The model id a provider reports on its own response, or `''` when it reports
|
|
200
|
+
* none.
|
|
201
|
+
*
|
|
202
|
+
* `field` is a parameter because the name is NOT uniform across providers:
|
|
203
|
+
* Gemini reports it as `model_version`, so a literal single-field read would
|
|
204
|
+
* leave Gemini silently empty — which is indistinguishable from a transport
|
|
205
|
+
* that genuinely reports nothing. Non-string values collapse to `''` rather
|
|
206
|
+
* than being coerced: a guess is worse than an honest blank here, because the
|
|
207
|
+
* whole point of the field is detecting a substitution.
|
|
208
|
+
*/
|
|
209
|
+
function _servedModel(response: unknown, field = 'model'): string {
|
|
210
|
+
const value = _getattr(response, field, '');
|
|
211
|
+
return typeof value === 'string' ? value : '';
|
|
212
|
+
}
|
|
213
|
+
|
|
198
214
|
/** Normalised output from a single council member (dataclass `CouncilResponse`). */
|
|
199
215
|
export class CouncilResponse {
|
|
200
216
|
provider: string;
|
|
201
217
|
model: string;
|
|
218
|
+
// The model id the provider reported serving, when it reports one. NEVER
|
|
219
|
+
// overwrites `model`: that is the REQUESTED id, and it is what the tier
|
|
220
|
+
// decision was made against — keeping both is what makes a silent alias or
|
|
221
|
+
// provider substitution detectable at all. Attribution-only; no consumer
|
|
222
|
+
// may route on it. Defaults to '' (the honest value for a transport that
|
|
223
|
+
// reports no served id, e.g. every CLI client) so existing callers and the
|
|
224
|
+
// persisted response JSON stay backward-compatible.
|
|
225
|
+
model_served: string;
|
|
202
226
|
text: string;
|
|
203
227
|
input_tokens: number;
|
|
204
228
|
output_tokens: number;
|
|
@@ -215,6 +239,7 @@ export class CouncilResponse {
|
|
|
215
239
|
constructor(opts: {
|
|
216
240
|
provider: string;
|
|
217
241
|
model: string;
|
|
242
|
+
model_served?: string;
|
|
218
243
|
text: string;
|
|
219
244
|
input_tokens?: number;
|
|
220
245
|
output_tokens?: number;
|
|
@@ -226,6 +251,7 @@ export class CouncilResponse {
|
|
|
226
251
|
}) {
|
|
227
252
|
this.provider = opts.provider;
|
|
228
253
|
this.model = opts.model;
|
|
254
|
+
this.model_served = opts.model_served ?? '';
|
|
229
255
|
this.text = opts.text;
|
|
230
256
|
this.input_tokens = opts.input_tokens ?? 0;
|
|
231
257
|
this.output_tokens = opts.output_tokens ?? 0;
|
|
@@ -616,6 +642,7 @@ export class AnthropicClient extends ExternalAIClient {
|
|
|
616
642
|
return new CouncilResponse({
|
|
617
643
|
provider: this.name,
|
|
618
644
|
model: this.model,
|
|
645
|
+
model_served: _servedModel(response),
|
|
619
646
|
text,
|
|
620
647
|
input_tokens: usage ? (_getattr(usage, 'input_tokens', 0) as number) : 0,
|
|
621
648
|
output_tokens: usage ? (_getattr(usage, 'output_tokens', 0) as number) : 0,
|
|
@@ -719,6 +746,7 @@ export class OpenAIClient extends ExternalAIClient {
|
|
|
719
746
|
return new CouncilResponse({
|
|
720
747
|
provider: this.name,
|
|
721
748
|
model: this.model,
|
|
749
|
+
model_served: _servedModel(response),
|
|
722
750
|
text: (text as string) || '',
|
|
723
751
|
input_tokens: usage ? (_getattr(usage, 'prompt_tokens', 0) as number) : 0,
|
|
724
752
|
output_tokens: usage ? (_getattr(usage, 'completion_tokens', 0) as number) : 0,
|
|
@@ -796,6 +824,8 @@ export class GeminiClient extends ExternalAIClient {
|
|
|
796
824
|
return new CouncilResponse({
|
|
797
825
|
provider: this.name,
|
|
798
826
|
model: this.model,
|
|
827
|
+
// Gemini names it `model_version`, not `model`.
|
|
828
|
+
model_served: _servedModel(response, 'model_version'),
|
|
799
829
|
text,
|
|
800
830
|
input_tokens: usage ? (_getattr(usage, 'prompt_token_count', 0) as number) : 0,
|
|
801
831
|
output_tokens: usage ? (_getattr(usage, 'candidates_token_count', 0) as number) : 0,
|
|
@@ -878,6 +908,7 @@ export class _OpenAICompatibleClient extends ExternalAIClient {
|
|
|
878
908
|
return new CouncilResponse({
|
|
879
909
|
provider: this.name,
|
|
880
910
|
model: this.model,
|
|
911
|
+
model_served: _servedModel(response),
|
|
881
912
|
text: (text as string) || '',
|
|
882
913
|
input_tokens: usage ? (_getattr(usage, 'prompt_tokens', 0) as number) : 0,
|
|
883
914
|
output_tokens: usage ? (_getattr(usage, 'completion_tokens', 0) as number) : 0,
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Ported from the retired Python `src/scripts/ai_council/events_log.py` (ADR-200 —
|
|
5
5
|
* Python→TS migration, Phase 1). Appends one JSON line per council event to
|
|
6
|
-
* `<project_root>/agents/runtime/council/events.log`.
|
|
7
|
-
* minimum needed to answer
|
|
8
|
-
*
|
|
6
|
+
* `<project_root>/agents/runtime/council/events.log`. The schema carries the
|
|
7
|
+
* minimum needed to answer "why did the council skip / block this?" and,
|
|
8
|
+
* since v2, "who actually attended?" — at retro time, without leaking prompt
|
|
9
|
+
* content. v2 added the `quorum_result` action and nothing else; see
|
|
10
|
+
* `appendQuorumEvent`.
|
|
9
11
|
*
|
|
10
12
|
* Privacy floor:
|
|
11
13
|
* `original_ask` is never written verbatim — the caller passes the raw
|
|
@@ -25,14 +27,24 @@ import * as fs from 'node:fs';
|
|
|
25
27
|
import * as path from 'node:path';
|
|
26
28
|
import { fileURLToPath } from 'node:url';
|
|
27
29
|
|
|
28
|
-
|
|
30
|
+
import { isSoloConcluded, type QuorumResult } from './quorum.js';
|
|
31
|
+
import type { AbsentReason } from './transport_resolver.js';
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
/**
|
|
34
|
+
* v2 adds the `quorum_result` action (and nothing else) — see
|
|
35
|
+
* `appendQuorumEvent`. Every schema-v1 field keeps its name, type and
|
|
36
|
+
* position, so a v1 reader parses a v2 necessity line unchanged; only the
|
|
37
|
+
* action vocabulary widened.
|
|
38
|
+
*/
|
|
39
|
+
export const SCHEMA_VERSION = 2;
|
|
40
|
+
|
|
41
|
+
export type EventAction = 'proceed' | 'skip_necessity' | 'block_quota' | 'quorum_result';
|
|
31
42
|
|
|
32
43
|
const _VALID_ACTIONS: ReadonlySet<string> = new Set([
|
|
33
44
|
'proceed',
|
|
34
45
|
'skip_necessity',
|
|
35
46
|
'block_quota',
|
|
47
|
+
'quorum_result',
|
|
36
48
|
]);
|
|
37
49
|
|
|
38
50
|
/**
|
|
@@ -194,7 +206,7 @@ export function appendEvent(
|
|
|
194
206
|
original_ask_hash: _hash_original_ask(rawAsk),
|
|
195
207
|
};
|
|
196
208
|
// Pass-through for any caller-supplied diagnostic fields that are not in
|
|
197
|
-
// the
|
|
209
|
+
// the reserved set. The reserved fields above always win on
|
|
198
210
|
// collision.
|
|
199
211
|
const reserved = new Set([...Object.keys(record), 'original_ask']);
|
|
200
212
|
for (const [k, v] of Object.entries(event)) {
|
|
@@ -215,6 +227,116 @@ export function defaultLogPath(): string {
|
|
|
215
227
|
return _DEFAULT_LOG_PATH;
|
|
216
228
|
}
|
|
217
229
|
|
|
230
|
+
// ── quorum attendance (schema v2) ───────────────────────────────────
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Which of the two `evaluateQuorum` call sites produced the record.
|
|
234
|
+
*
|
|
235
|
+
* `pre_run` is `build_members`' construction-time reading — who could be
|
|
236
|
+
* built at all. `post_run` is `_postRunQuorum`'s re-derivation over what
|
|
237
|
+
* actually returned a usable response. One pass emits BOTH, and they
|
|
238
|
+
* disagree whenever a member constructs and then fails mid-flight, so a
|
|
239
|
+
* consumer that counts attendance without splitting on this field
|
|
240
|
+
* double-counts every pass.
|
|
241
|
+
*/
|
|
242
|
+
export type QuorumEventPhase = 'pre_run' | 'post_run';
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* One absent member, in the vocabulary the tree actually uses:
|
|
246
|
+
* `AbsentReason` plus the two runtime fallbacks the CLI writes directly —
|
|
247
|
+
* `'unavailable'` (transport resolved to ∅) and `'binary_missing'` (a CLI
|
|
248
|
+
* client refused to construct).
|
|
249
|
+
*/
|
|
250
|
+
export type QuorumAbsentReason = AbsentReason | 'unavailable' | 'binary_missing';
|
|
251
|
+
|
|
252
|
+
export interface QuorumAbsence {
|
|
253
|
+
readonly member: string;
|
|
254
|
+
readonly reason: QuorumAbsentReason;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Which CLI path produced the line. `estimate` spends nothing — it is a cost
|
|
259
|
+
* preview — so every rate computed over these lines must exclude it, and it
|
|
260
|
+
* is recorded rather than suppressed so the exclusion is the consumer's
|
|
261
|
+
* explicit act instead of an invisible one.
|
|
262
|
+
*/
|
|
263
|
+
export type QuorumCommand = 'run' | 'estimate' | 'debate';
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* `single` means `--single` filtered the roster down to one member BEFORE
|
|
267
|
+
* the pass ran. Without this field a deliberate solo dispatch is byte-identical
|
|
268
|
+
* to a configured one-member council, and the solo-conclusion rate cannot tell
|
|
269
|
+
* the two apart — which is the one distinction it exists to make.
|
|
270
|
+
*/
|
|
271
|
+
export type QuorumDispatch = 'full' | 'single';
|
|
272
|
+
|
|
273
|
+
export interface QuorumEventInput {
|
|
274
|
+
readonly lens: string;
|
|
275
|
+
readonly invocation: string;
|
|
276
|
+
readonly phase: QuorumEventPhase;
|
|
277
|
+
readonly command: QuorumCommand;
|
|
278
|
+
readonly dispatch: QuorumDispatch;
|
|
279
|
+
/**
|
|
280
|
+
* Enabled members in the config, BEFORE `--single` / `--siblings`
|
|
281
|
+
* filtering and before construction failures. `result.total` is the
|
|
282
|
+
* roster that survived; when the two differ, the gap is exactly the
|
|
283
|
+
* "council degraded by configuration" case a post_run-only reading is
|
|
284
|
+
* otherwise blind to.
|
|
285
|
+
*/
|
|
286
|
+
readonly configuredTotal: number;
|
|
287
|
+
readonly result: QuorumResult;
|
|
288
|
+
readonly absent: readonly QuorumAbsence[];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Append one `quorum_result` line — the attendance record that makes a
|
|
293
|
+
* solo-concluded pass distinguishable from a full-attendance one.
|
|
294
|
+
*
|
|
295
|
+
* Privacy floor, by construction: `QuorumAbsence` carries a member name and
|
|
296
|
+
* a closed-vocabulary reason and has **no field able to hold free-form
|
|
297
|
+
* content**. The CLI's own absent entries carry a `detail` string built from
|
|
298
|
+
* provider error text (which can embed paths and prompts); it is dropped
|
|
299
|
+
* here rather than scrubbed, so there is no scrubber to fail.
|
|
300
|
+
*
|
|
301
|
+
* Fail-open: attendance telemetry must never be able to kill a council
|
|
302
|
+
* pass, so every error is swallowed and reported as `false`. The reachable
|
|
303
|
+
* ones are filesystem errors from `appendEvent`'s own `mkdirSync` /
|
|
304
|
+
* `appendFileSync` — an unwritable path, ENOTDIR, a full disk. Its other
|
|
305
|
+
* throw (an invalid action) is unreachable from here, since this function
|
|
306
|
+
* supplies the action itself.
|
|
307
|
+
*/
|
|
308
|
+
export function appendQuorumEvent(
|
|
309
|
+
input: QuorumEventInput,
|
|
310
|
+
opts: AppendEventOptions = {},
|
|
311
|
+
): boolean {
|
|
312
|
+
try {
|
|
313
|
+
return appendEvent(
|
|
314
|
+
{
|
|
315
|
+
lens: input.lens,
|
|
316
|
+
invocation: input.invocation,
|
|
317
|
+
action: 'quorum_result',
|
|
318
|
+
verdict: input.result.status,
|
|
319
|
+
phase: input.phase,
|
|
320
|
+
command: input.command,
|
|
321
|
+
dispatch: input.dispatch,
|
|
322
|
+
threshold: input.result.threshold,
|
|
323
|
+
configured_total: input.configuredTotal,
|
|
324
|
+
total: input.result.total,
|
|
325
|
+
present: input.result.present,
|
|
326
|
+
// Written by the predicate, never re-derived from the numbers
|
|
327
|
+
// downstream: one definition of "concluded on a single voice",
|
|
328
|
+
// in `quorum.ts`, so a change to it cannot leave a consumer's
|
|
329
|
+
// copy silently stale.
|
|
330
|
+
solo: isSoloConcluded(input.result),
|
|
331
|
+
absent: input.absent.map((a) => ({ member: a.member, reason: a.reason })),
|
|
332
|
+
},
|
|
333
|
+
opts,
|
|
334
|
+
);
|
|
335
|
+
} catch {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
218
340
|
// ── helpers ─────────────────────────────────────────────────────────
|
|
219
341
|
|
|
220
342
|
/**
|
|
@@ -70,7 +70,7 @@ import { count_dissenters, dissent_quota_met, is_near_duplicate } from './debate
|
|
|
70
70
|
import { parse_stance_line, render_vote_tally, tally_stances } from './stance_tally.js';
|
|
71
71
|
import { CHAIRMAN_FIELDS_ADDENDUM, render_deanonymization_block } from './blind_review.js';
|
|
72
72
|
import type { AbsentReason } from './transport_resolver.js';
|
|
73
|
-
import type
|
|
73
|
+
import { isSoloConcluded, type QuorumResult } from './quorum.js';
|
|
74
74
|
import { isEmptyHandoff, type HandoffEnvelope } from './handoff.js';
|
|
75
75
|
|
|
76
76
|
// ── Python-format / stdlib parity helpers ────────────────────────────────
|
|
@@ -1979,10 +1979,19 @@ export function render(responses: CouncilResponse[], opts: RenderOptions = {}):
|
|
|
1979
1979
|
return blocks.join('\n\n---\n\n');
|
|
1980
1980
|
}
|
|
1981
1981
|
|
|
1982
|
-
/**
|
|
1982
|
+
/**
|
|
1983
|
+
* One line naming the k-of-n outcome — machine-parseable, human-readable.
|
|
1984
|
+
*
|
|
1985
|
+
* The `solo` marker is the artifact half of the signal the `quorum_result`
|
|
1986
|
+
* event carries in the log: a pass concluded by ONE voice otherwise reads
|
|
1987
|
+
* identically to a full-attendance one, and the reader of a rendered pass is
|
|
1988
|
+
* exactly who needs to see the difference. Derived through `isSoloConcluded`
|
|
1989
|
+
* so the predicate keeps one definition and the renderer does not restate it.
|
|
1990
|
+
*/
|
|
1983
1991
|
function _render_quorum_line(q: QuorumResult): string {
|
|
1984
1992
|
const verdict = q.status === 'concluded' ? 'concluded' : 'INCONCLUSIVE — release gate holds';
|
|
1985
|
-
|
|
1993
|
+
const solo = isSoloConcluded(q) ? ' · **solo** — one voice concluded this pass' : '';
|
|
1994
|
+
return `**Quorum:** ${q.present}/${q.total} present, needed ${q.threshold} — ${verdict}.${solo}`;
|
|
1986
1995
|
}
|
|
1987
1996
|
|
|
1988
1997
|
/**
|
|
@@ -49,8 +49,55 @@ export interface PriceTable {
|
|
|
49
49
|
prices: Map<string, Price>; // keyed by priceKey(provider, model)
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Price for (provider, model) — exact key first, then the longest matching
|
|
54
|
+
* key PREFIX within the same provider.
|
|
55
|
+
*
|
|
56
|
+
* Exact match stays the first hit, so no call that resolves today changes
|
|
57
|
+
* path or price. The fallback exists because vendors ship dated aliases:
|
|
58
|
+
* `claude-sonnet-4-5-20260101` missed a `claude-sonnet-4-5` row and priced at
|
|
59
|
+
* **nothing** — a silent zero, not an error. Longest-first means a more
|
|
60
|
+
* specific row always wins over a shorter one that also prefixes the id
|
|
61
|
+
* (`claude-opus-4-1` over a hypothetical `claude-opus`).
|
|
62
|
+
*
|
|
63
|
+
* The prefix must be followed by a separator (`-`, `.`, `:`, `@`) or end the
|
|
64
|
+
* id, so `claude-opus-4-1` never matches a `claude-opus-4-15` row: a bare
|
|
65
|
+
* `startsWith` would price one model at another's rate, which is worse than
|
|
66
|
+
* the zero it replaces because it looks correct.
|
|
67
|
+
*
|
|
68
|
+
* Provider is part of the composite key, so cross-provider bleed is
|
|
69
|
+
* impossible by construction.
|
|
70
|
+
*/
|
|
52
71
|
export function lookup(table: PriceTable, provider: string, model: string): Price | null {
|
|
53
|
-
|
|
72
|
+
const exact = table.prices.get(priceKey(provider, model));
|
|
73
|
+
if (exact !== undefined) {
|
|
74
|
+
return exact;
|
|
75
|
+
}
|
|
76
|
+
// Derived from `priceKey`, never hand-rebuilt: it owns the composite-key
|
|
77
|
+
// encoding, and a second copy here would let a separator change follow the
|
|
78
|
+
// exact-match path while this loop silently matched nothing — regressing to
|
|
79
|
+
// the very silent zero the fallback exists to remove.
|
|
80
|
+
const prefix = priceKey(provider, '');
|
|
81
|
+
let best: Price | null = null;
|
|
82
|
+
let bestLen = -1;
|
|
83
|
+
for (const [key, price] of table.prices) {
|
|
84
|
+
if (!key.startsWith(prefix)) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const candidate = key.slice(prefix.length);
|
|
88
|
+
if (!candidate || !model.startsWith(candidate)) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const next = model.charAt(candidate.length);
|
|
92
|
+
if (next !== '' && next !== '-' && next !== '.' && next !== ':' && next !== '@') {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (candidate.length > bestLen) {
|
|
96
|
+
best = price;
|
|
97
|
+
bestLen = candidate.length;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return best;
|
|
54
101
|
}
|
|
55
102
|
|
|
56
103
|
// ── token + cost arithmetic ────────────────────────────────────────
|
|
@@ -82,3 +82,23 @@ export function evaluateQuorum(
|
|
|
82
82
|
const status: QuorumStatus = clampedPresent >= threshold ? 'concluded' : 'inconclusive';
|
|
83
83
|
return { status, threshold, total, present: clampedPresent };
|
|
84
84
|
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Did this pass conclude on a single voice?
|
|
88
|
+
*
|
|
89
|
+
* Derived — deliberately NOT a third `QuorumStatus`. `ceil(n / 2)` makes
|
|
90
|
+
* 1-of-2 a legitimate `concluded`, which is the intended behaviour (see the
|
|
91
|
+
* module header); this predicate does not dispute it, it only makes the
|
|
92
|
+
* shape visible so a reader can tell a one-member conclusion from a
|
|
93
|
+
* full-attendance one. Advisory render and telemetry only: no gate reads it,
|
|
94
|
+
* and nothing downstream may branch on it without its own decision record.
|
|
95
|
+
*
|
|
96
|
+
* `total` is deliberately NOT consulted. A council configured with a single
|
|
97
|
+
* member concludes solo by construction, and that is still a conclusion
|
|
98
|
+
* reached on one voice — collapsing it into "not solo" would hide exactly
|
|
99
|
+
* the passes a solo-conclusion rate is measured to find. A consumer that
|
|
100
|
+
* cares about the distinction reads `result.total` alongside.
|
|
101
|
+
*/
|
|
102
|
+
export function isSoloConcluded(result: QuorumResult): boolean {
|
|
103
|
+
return result.status === 'concluded' && result.present === 1;
|
|
104
|
+
}
|
|
@@ -189,6 +189,17 @@ function _serialise_response(r: CouncilResponse): Record<string, unknown> {
|
|
|
189
189
|
const payload: Record<string, unknown> = {
|
|
190
190
|
provider: r.provider,
|
|
191
191
|
model: r.model,
|
|
192
|
+
// `model` stays the REQUESTED id — the tier decision was made against
|
|
193
|
+
// it. `model_served` is what the provider reported answering with, so a
|
|
194
|
+
// silent alias or substitution is visible instead of being recorded as
|
|
195
|
+
// the model that never ran. Attribution-only: no consumer routes on it.
|
|
196
|
+
//
|
|
197
|
+
// Additive on the MANIFEST's own response row, which is this artefact —
|
|
198
|
+
// NOT a `cost-summary/v1` field: that contract describes
|
|
199
|
+
// `agents/cost-tracking/sessions.jsonl`, a different file with a
|
|
200
|
+
// different producer. Absent on manifests written before this, and `''`
|
|
201
|
+
// for every transport that reports no served id.
|
|
202
|
+
model_served: r.model_served,
|
|
192
203
|
input_tokens: r.input_tokens,
|
|
193
204
|
output_tokens: r.output_tokens,
|
|
194
205
|
latency_ms: r.latency_ms,
|
|
@@ -48,10 +48,20 @@ import type { ExternalAIClient } from './ai_council/clients.js';
|
|
|
48
48
|
import { buildOrchestrationLine } from './_lib/orchestration_record.js';
|
|
49
49
|
import { DEFAULT_DIR } from './orchestration_record.js';
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Placeholder written into `AskResult.model` when the member reported no id.
|
|
53
|
+
* Named so the audit path can recognise and refuse it rather than passing a
|
|
54
|
+
* sentinel off as a real model id.
|
|
55
|
+
*/
|
|
56
|
+
export const UNKNOWN_MODEL = 'unknown';
|
|
57
|
+
|
|
51
58
|
export interface AskResult {
|
|
52
59
|
answer: string;
|
|
53
60
|
member: string;
|
|
61
|
+
/** The REQUESTED model id — what the member was configured to call. */
|
|
54
62
|
model: string;
|
|
63
|
+
/** The model the provider reported serving; `''` when it reports none. */
|
|
64
|
+
model_served: string;
|
|
55
65
|
input_tokens: number;
|
|
56
66
|
output_tokens: number;
|
|
57
67
|
latency_ms: number;
|
|
@@ -94,6 +104,14 @@ function recordAskLine(auditDir: string, result: AskResult | null, member: strin
|
|
|
94
104
|
dispatch_tokens: result === null ? null : result.input_tokens + result.output_tokens,
|
|
95
105
|
wall_clock_ms: result === null ? 0 : Math.max(0, Math.round(result.latency_ms)),
|
|
96
106
|
agent_combo: [member || 'none'],
|
|
107
|
+
// `AskResult.model` substitutes an `'unknown'` SENTINEL when the member
|
|
108
|
+
// reported no id (below). That sentinel must never reach the audit line
|
|
109
|
+
// as a model_requested: `modelDivergent` treats any non-empty string as
|
|
110
|
+
// a real id, so it would fabricate a `true` on a line whose entire
|
|
111
|
+
// purpose is making real substitutions credible. null keeps the
|
|
112
|
+
// three-valued semantics — and the field's own "id only" contract.
|
|
113
|
+
model_requested: result === null || result.model === UNKNOWN_MODEL ? null : result.model,
|
|
114
|
+
model_served: result === null ? null : result.model_served,
|
|
97
115
|
origin: 'dispatch-economy-2026',
|
|
98
116
|
ts,
|
|
99
117
|
id,
|
|
@@ -145,7 +163,8 @@ export async function askOnce(prompt: string, opts: AskOptions = {}): Promise<As
|
|
|
145
163
|
{
|
|
146
164
|
answer: text,
|
|
147
165
|
member: provider,
|
|
148
|
-
model: r.model ??
|
|
166
|
+
model: r.model ?? UNKNOWN_MODEL,
|
|
167
|
+
model_served: r.model_served ?? '',
|
|
149
168
|
input_tokens: r.input_tokens ?? 0,
|
|
150
169
|
output_tokens: r.output_tokens ?? 0,
|
|
151
170
|
latency_ms: r.latency_ms ?? 0,
|
|
@@ -88,6 +88,25 @@ export interface ColdStartStats {
|
|
|
88
88
|
first_call_cache_read_share: number;
|
|
89
89
|
/** Σ(first-call cache_creation_input_tokens) / Σ(all subagent cache_creation_input_tokens) — the C-1 metric. */
|
|
90
90
|
cold_start_share_of_write_volume: number;
|
|
91
|
+
/**
|
|
92
|
+
* The write-share SIGNATURE across ALL subagent records (not just first
|
|
93
|
+
* calls): how the billable input splits between cache writes, cache reads,
|
|
94
|
+
* and uncached input.
|
|
95
|
+
*
|
|
96
|
+
* The three shares are over the same denominator and sum to 1 (up to
|
|
97
|
+
* float error) whenever any billable input exists, which is what makes it
|
|
98
|
+
* a signature rather than three unrelated ratios — a workload that is
|
|
99
|
+
* mostly `write` is paying the cache premium without collecting the read
|
|
100
|
+
* discount, and that is invisible in `cold_start_share_of_write_volume`,
|
|
101
|
+
* which only says how much of the writing happened on a first call.
|
|
102
|
+
*
|
|
103
|
+
* All three are 0 when there is no billable input at all. That is the
|
|
104
|
+
* empty-corpus reading, NOT "a perfectly uncached workload" — read it
|
|
105
|
+
* beside `legs`.
|
|
106
|
+
*/
|
|
107
|
+
write_share_of_billable: number;
|
|
108
|
+
read_share_of_billable: number;
|
|
109
|
+
uncached_share_of_billable: number;
|
|
91
110
|
}
|
|
92
111
|
|
|
93
112
|
/**
|
|
@@ -132,12 +151,22 @@ export function computeColdStarts(records: readonly TranscriptRecord[]): ColdSta
|
|
|
132
151
|
const sumFirstCallBillable = firstCalls.reduce((s, r) => s + billableInputTokens(r.usage), 0);
|
|
133
152
|
const sumFirstCallWrite = firstCalls.reduce((s, r) => s + r.usage.cache_creation_input_tokens, 0);
|
|
134
153
|
|
|
154
|
+
// Signature over ALL subagent records — the shape of the workload, where
|
|
155
|
+
// the metrics above describe only its first calls.
|
|
156
|
+
const sumBillable = subagentRecords.reduce((s, r) => s + billableInputTokens(r.usage), 0);
|
|
157
|
+
const sumWrite = totalWriteVolume;
|
|
158
|
+
const sumRead = subagentRecords.reduce((s, r) => s + r.usage.cache_read_input_tokens, 0);
|
|
159
|
+
const sumUncached = subagentRecords.reduce((s, r) => s + r.usage.input_tokens, 0);
|
|
160
|
+
|
|
135
161
|
return {
|
|
136
162
|
legs: firstCalls.length,
|
|
137
163
|
median_first_call_written_or_uncached: median(writtenOrUncached),
|
|
138
164
|
mean_first_call_written_or_uncached: mean(writtenOrUncached),
|
|
139
165
|
first_call_cache_read_share: sumFirstCallBillable > 0 ? sumFirstCallRead / sumFirstCallBillable : 0,
|
|
140
166
|
cold_start_share_of_write_volume: totalWriteVolume > 0 ? sumFirstCallWrite / totalWriteVolume : 0,
|
|
167
|
+
write_share_of_billable: sumBillable > 0 ? sumWrite / sumBillable : 0,
|
|
168
|
+
read_share_of_billable: sumBillable > 0 ? sumRead / sumBillable : 0,
|
|
169
|
+
uncached_share_of_billable: sumBillable > 0 ? sumUncached / sumBillable : 0,
|
|
141
170
|
};
|
|
142
171
|
}
|
|
143
172
|
|
|
@@ -737,6 +766,15 @@ export function renderText(r: Report): string {
|
|
|
737
766
|
);
|
|
738
767
|
out.push(` first_call_cache_read_share=${pct(cs.first_call_cache_read_share)}`);
|
|
739
768
|
out.push(` cold_start_share_of_subagent_write_volume=${pct(cs.cold_start_share_of_write_volume)}`);
|
|
769
|
+
// Write-share signature over ALL subagent records: the three shares sum to
|
|
770
|
+
// 100% and say where the billable input actually went. A write-heavy split
|
|
771
|
+
// is paying the cache premium without collecting the read discount — which
|
|
772
|
+
// the cold-start metrics above cannot show, since they only look at first
|
|
773
|
+
// calls. All-zero means no billable input at all; read it beside legs.
|
|
774
|
+
out.push(
|
|
775
|
+
` write_share=${pct(cs.write_share_of_billable)} read_share=${pct(cs.read_share_of_billable)}` +
|
|
776
|
+
` uncached_share=${pct(cs.uncached_share_of_billable)} (of billable input, all subagent records)`,
|
|
777
|
+
);
|
|
740
778
|
out.push('');
|
|
741
779
|
|
|
742
780
|
out.push(`Per-agent breakdown: ${r.by_agent.length} distinct subagent legs (--format json carries all; top 10 by weighted_input_units below)`);
|
|
@@ -114,7 +114,13 @@ export function highlight_contradictions(
|
|
|
114
114
|
return out;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
|
|
117
|
+
/**
|
|
118
|
+
* Exported so the advisory-vs-blocking decision is a fixture, not a claim.
|
|
119
|
+
* `stale_draft_labels` firing while the exit code stays 0 is the behaviour the
|
|
120
|
+
* cadence blocker adjudicates; pinning it here makes either branch of that
|
|
121
|
+
* decision a one-line diff with a test that notices.
|
|
122
|
+
*/
|
|
123
|
+
export function main(argv: readonly string[]): number {
|
|
118
124
|
let version: string | null = null;
|
|
119
125
|
let from: string | null = null;
|
|
120
126
|
let to = 'HEAD';
|