@event4u/agent-config 9.32.0 → 9.34.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 +85 -72
- package/dist/agent-src/commands/agent-handoff.md +15 -3
- 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/archive_completed_roadmaps.ts +35 -1
- package/dist/agent-src/scripts/roadmap_gates.ts +107 -3
- package/dist/agent-src/skills/corpus-grounding/SKILL.md +21 -12
- package/dist/agent-src/skills/design-tokens/SKILL.md +15 -0
- 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/react-shadcn-ui/SKILL.md +24 -0
- package/dist/agent-src/skills/review-routing/SKILL.md +23 -6
- package/dist/agent-src/skills/rule-writing/SKILL.md +23 -0
- package/dist/agent-src/skills/skill-writing/SKILL.md +80 -2
- package/dist/agent-src/skills/systematic-debugging/SKILL.md +5 -3
- package/dist/agent-src/skills/tailwind-engineer/SKILL.md +24 -0
- 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-2WELHO3P.js → chunk-I5EAFRII.js} +35 -0
- package/dist/cli-delegate/{chunk-ITIQSGOQ.js → chunk-MA4GFWDY.js} +7 -4
- package/dist/cli-delegate/{chunk-PFBV6XRT.js → chunk-Q26HT4QT.js} +1 -63
- package/dist/cli-delegate/{chunk-542DZR4P.js → chunk-REMEZSVP.js} +1660 -146
- package/dist/cli-delegate/{chunk-3EWYC3YK.js → chunk-UEZ7D35A.js} +4 -3
- 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 +21 -28
- package/dist/cli-delegate/cmd_memory_get.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +1 -1
- package/dist/cli-delegate/cmd_route_audit.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +20 -5
- 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 +17 -17
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +2 -2
- package/dist/discovery/dormancy-report.md +17 -0
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +3 -3
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +3 -3
- package/dist/hooks/dispatch.js +114 -7
- package/dist/install/install.mjs +7 -4
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +3755 -1168
- package/docs/guidelines/agent-infra/failure-signatures.md +47 -12
- package/package.json +2 -2
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +35 -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 +25 -4
- package/src/scripts/_dispatch.bash +5 -3
- package/src/scripts/_lib/env_kill_switch.ts +37 -0
- package/src/scripts/_lib/orchestration_record.ts +45 -0
- package/src/scripts/_lib/security_lint.ts +18 -4
- package/src/scripts/_lib/self_repair.ts +60 -0
- package/src/scripts/_lib/self_repair_store.ts +65 -5
- package/src/scripts/_lib/subagent_capsule.ts +78 -1
- package/src/scripts/ai_council/clients.ts +31 -0
- package/src/scripts/ai_council/events_log.ts +130 -8
- package/src/scripts/ai_council/orchestrator.ts +50 -3
- package/src/scripts/ai_council/pricing.ts +48 -1
- package/src/scripts/ai_council/prompts.ts +165 -3
- package/src/scripts/ai_council/quorum.ts +30 -0
- package/src/scripts/ai_council/session.ts +11 -0
- package/src/scripts/ai_team/review_gate.ts +2 -2
- package/src/scripts/ask_transport.ts +20 -1
- package/src/scripts/build_discovery_manifest.ts +165 -0
- package/src/scripts/cache_realization_report.ts +38 -0
- package/src/scripts/check_claims.ts +51 -2
- package/src/scripts/check_release_highlights.ts +7 -1
- package/src/scripts/ci_time_ratio.ts +3 -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/discovery_graph.ts +201 -27
- package/src/scripts/dispatch_economy_report.ts +5 -2
- package/src/scripts/dispatch_r2_reviewer.ts +102 -1
- package/src/scripts/install.ts +20 -6
- package/src/scripts/lint_budget_ownership.ts +81 -0
- package/src/scripts/lint_handoffs.ts +92 -2
- package/src/scripts/lint_hedge_words.ts +317 -0
- package/src/scripts/lint_mcp_config_security.ts +1 -1
- package/src/scripts/lint_skill_frontmatter_safety.ts +56 -2
- 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
- package/src/scripts/self_repair_cli.ts +26 -1
- package/src/scripts/skill_linter.ts +58 -2
|
@@ -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,25 @@ 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 { isEnvKillSwitchActive } from '../_lib/env_kill_switch.js';
|
|
31
|
+
import { isSoloConcluded, type QuorumResult } from './quorum.js';
|
|
32
|
+
import type { AbsentReason } from './transport_resolver.js';
|
|
29
33
|
|
|
30
|
-
|
|
34
|
+
/**
|
|
35
|
+
* v2 adds the `quorum_result` action (and nothing else) — see
|
|
36
|
+
* `appendQuorumEvent`. Every schema-v1 field keeps its name, type and
|
|
37
|
+
* position, so a v1 reader parses a v2 necessity line unchanged; only the
|
|
38
|
+
* action vocabulary widened.
|
|
39
|
+
*/
|
|
40
|
+
export const SCHEMA_VERSION = 2;
|
|
41
|
+
|
|
42
|
+
export type EventAction = 'proceed' | 'skip_necessity' | 'block_quota' | 'quorum_result';
|
|
31
43
|
|
|
32
44
|
const _VALID_ACTIONS: ReadonlySet<string> = new Set([
|
|
33
45
|
'proceed',
|
|
34
46
|
'skip_necessity',
|
|
35
47
|
'block_quota',
|
|
48
|
+
'quorum_result',
|
|
36
49
|
]);
|
|
37
50
|
|
|
38
51
|
/**
|
|
@@ -127,8 +140,7 @@ function _utf8Bytes(cp: number): string {
|
|
|
127
140
|
}
|
|
128
141
|
|
|
129
142
|
function _kill_switch_active(): boolean {
|
|
130
|
-
|
|
131
|
-
return !(value === '' || value === '0' || value === 'false' || value === 'False');
|
|
143
|
+
return isEnvKillSwitchActive(_KILL_SWITCH_ENV);
|
|
132
144
|
}
|
|
133
145
|
|
|
134
146
|
export interface AppendEventOptions {
|
|
@@ -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
|
/**
|
|
@@ -58,6 +58,7 @@ import type { AdvisorPlan } from './advisors.js';
|
|
|
58
58
|
import {
|
|
59
59
|
advisor_system_prompt,
|
|
60
60
|
ANTI_CONFORMITY_DIRECTIVE,
|
|
61
|
+
describe_verdict_mismatch,
|
|
61
62
|
STANCE_LINE_CONTRACT,
|
|
62
63
|
build_extraction_user_prompt,
|
|
63
64
|
build_peer_review_user_prompt,
|
|
@@ -65,12 +66,13 @@ import {
|
|
|
65
66
|
peer_review_synthesis_addendum,
|
|
66
67
|
synthesis_template,
|
|
67
68
|
system_prompt_for,
|
|
69
|
+
VERDICT_LINE_CONTRACT,
|
|
68
70
|
} from './prompts.js';
|
|
69
71
|
import { count_dissenters, dissent_quota_met, is_near_duplicate } from './debate_gates.js';
|
|
70
72
|
import { parse_stance_line, render_vote_tally, tally_stances } from './stance_tally.js';
|
|
71
73
|
import { CHAIRMAN_FIELDS_ADDENDUM, render_deanonymization_block } from './blind_review.js';
|
|
72
74
|
import type { AbsentReason } from './transport_resolver.js';
|
|
73
|
-
import type
|
|
75
|
+
import { isSoloConcluded, type QuorumResult } from './quorum.js';
|
|
74
76
|
import { isEmptyHandoff, type HandoffEnvelope } from './handoff.js';
|
|
75
77
|
|
|
76
78
|
// ── Python-format / stdlib parity helpers ────────────────────────────────
|
|
@@ -1927,6 +1929,23 @@ export function render(responses: CouncilResponse[], opts: RenderOptions = {}):
|
|
|
1927
1929
|
// addendum so both extensions stack predictably.
|
|
1928
1930
|
template = template ? `${template}\n\n${CHAIRMAN_FIELDS_ADDENDUM}` : CHAIRMAN_FIELDS_ADDENDUM;
|
|
1929
1931
|
}
|
|
1932
|
+
if (opts.stance_tally === true && template !== '') {
|
|
1933
|
+
// Phase 2.1: the synthesis owes the same machine-readable closing line
|
|
1934
|
+
// the members owe. Appended only when a tally will be rendered — there
|
|
1935
|
+
// is nothing to check a verdict against otherwise. Composed last so it
|
|
1936
|
+
// is the final instruction, matching the contract's "and nothing after
|
|
1937
|
+
// it". The template's own `VERDICT: <option-label>` placeholder is
|
|
1938
|
+
// parsed as absent (see `parse_verdict_line`), so the un-summarised
|
|
1939
|
+
// render stays green.
|
|
1940
|
+
//
|
|
1941
|
+
// The `template !== ''` guard is load-bearing: an empty template is
|
|
1942
|
+
// `prose_synthesis: true`, documented at `:1859-1862` as a BARE slot.
|
|
1943
|
+
// Appending here would replace `*to be summarised by the host agent*`
|
|
1944
|
+
// with raw contract instructions and silently override an option the
|
|
1945
|
+
// caller set on purpose — a creative-lens pass would start reading like
|
|
1946
|
+
// a filled-in form.
|
|
1947
|
+
template = `${template}\n\n${VERDICT_LINE_CONTRACT}`;
|
|
1948
|
+
}
|
|
1930
1949
|
let body: string;
|
|
1931
1950
|
if (template) {
|
|
1932
1951
|
body = template;
|
|
@@ -1951,6 +1970,25 @@ export function render(responses: CouncilResponse[], opts: RenderOptions = {}):
|
|
|
1951
1970
|
.map((r) => ({ member: `${r.provider}:${r.model}`, text: r.text })),
|
|
1952
1971
|
);
|
|
1953
1972
|
blocks.push(render_vote_tally(tally));
|
|
1973
|
+
// Phase 2.1: the verdict half of the same projection. A synthesis that
|
|
1974
|
+
// states a `VERDICT:` line the tally did not clear is contradicting its
|
|
1975
|
+
// own count, and the reader of the rendered pass is exactly who needs to
|
|
1976
|
+
// see that.
|
|
1977
|
+
//
|
|
1978
|
+
// Surfaced, never thrown, and the ordering above is part of the fix: a
|
|
1979
|
+
// throw here would discard the whole artifact — every member response,
|
|
1980
|
+
// the peer review, the quorum bookkeeping — AFTER every provider call
|
|
1981
|
+
// was paid for, which is the hazard this roadmap already recorded for
|
|
1982
|
+
// the sibling shape check. `describe_verdict_mismatch` returns the
|
|
1983
|
+
// string; `assert_synthesis_matches_tally` still throws for a caller
|
|
1984
|
+
// holding a finished synthesis it can refuse.
|
|
1985
|
+
//
|
|
1986
|
+
// Conditional by construction: an absent verdict line yields null, so
|
|
1987
|
+
// every synthesis written before the contract shipped stays byte-identical.
|
|
1988
|
+
const mismatch = describe_verdict_mismatch(body, tally);
|
|
1989
|
+
if (mismatch !== null) {
|
|
1990
|
+
blocks.push(`> **Verdict/tally mismatch:** ${mismatch}`);
|
|
1991
|
+
}
|
|
1954
1992
|
}
|
|
1955
1993
|
blocks.push(`## Convergence / Divergence\n\n${body}`);
|
|
1956
1994
|
// Phase 4.1: closest to the synthesis it was extracted from — ahead of
|
|
@@ -1979,10 +2017,19 @@ export function render(responses: CouncilResponse[], opts: RenderOptions = {}):
|
|
|
1979
2017
|
return blocks.join('\n\n---\n\n');
|
|
1980
2018
|
}
|
|
1981
2019
|
|
|
1982
|
-
/**
|
|
2020
|
+
/**
|
|
2021
|
+
* One line naming the k-of-n outcome — machine-parseable, human-readable.
|
|
2022
|
+
*
|
|
2023
|
+
* The `solo` marker is the artifact half of the signal the `quorum_result`
|
|
2024
|
+
* event carries in the log: a pass concluded by ONE voice otherwise reads
|
|
2025
|
+
* identically to a full-attendance one, and the reader of a rendered pass is
|
|
2026
|
+
* exactly who needs to see the difference. Derived through `isSoloConcluded`
|
|
2027
|
+
* so the predicate keeps one definition and the renderer does not restate it.
|
|
2028
|
+
*/
|
|
1983
2029
|
function _render_quorum_line(q: QuorumResult): string {
|
|
1984
2030
|
const verdict = q.status === 'concluded' ? 'concluded' : 'INCONCLUSIVE — release gate holds';
|
|
1985
|
-
|
|
2031
|
+
const solo = isSoloConcluded(q) ? ' · **solo** — one voice concluded this pass' : '';
|
|
2032
|
+
return `**Quorum:** ${q.present}/${q.total} present, needed ${q.threshold} — ${verdict}.${solo}`;
|
|
1986
2033
|
}
|
|
1987
2034
|
|
|
1988
2035
|
/**
|
|
@@ -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 ────────────────────────────────────────
|
|
@@ -460,9 +460,19 @@ export const REQUIRED_SYNTHESIS_SECTIONS = ['Kill criteria', 'Concrete next step
|
|
|
460
460
|
* fills the Convergence / Divergence slot) — not the template prompt. Every
|
|
461
461
|
* lens must close with a non-empty `### Kill criteria` and `### Concrete next
|
|
462
462
|
* step`; a missing or placeholder-empty section throws `SynthesisRenderError`
|
|
463
|
-
* naming the first offender.
|
|
464
|
-
*
|
|
465
|
-
*
|
|
463
|
+
* naming the first offender. Safe to call on decision and creative lenses
|
|
464
|
+
* alike, since both now require the two sections.
|
|
465
|
+
*
|
|
466
|
+
* **Not wired onto the emit path, deliberately.** This docstring used to claim
|
|
467
|
+
* it was "called on the synthesis-emit path"; measured, the function had zero
|
|
468
|
+
* production call sites and only the test imported it. Wiring it
|
|
469
|
+
* unconditionally into `render()` is not available either: with no chairman the
|
|
470
|
+
* rendered body is the literal `*to be summarised by the host agent*`, which
|
|
471
|
+
* carries neither section, so every templated render would throw (verified —
|
|
472
|
+
* the probe raises `missing the required "### Kill criteria" section`). A
|
|
473
|
+
* caller that HAS a completed synthesis calls this; the templated default path
|
|
474
|
+
* has nothing to validate. The tally check below is the half that could be
|
|
475
|
+
* wired, because it is conditional on a verdict line the template never emits.
|
|
466
476
|
*/
|
|
467
477
|
export function assert_synthesis_sections(text: string): void {
|
|
468
478
|
const lines = text.split('\n');
|
|
@@ -488,6 +498,158 @@ export function assert_synthesis_sections(text: string): void {
|
|
|
488
498
|
}
|
|
489
499
|
}
|
|
490
500
|
|
|
501
|
+
/** The reserved verdict label a synthesis uses when the tally did not converge. */
|
|
502
|
+
export const SPLIT_VERDICT_LABEL = 'split';
|
|
503
|
+
|
|
504
|
+
// Mirrors `_STANCE_RE`'s shape discipline: an explicit, single-field closing
|
|
505
|
+
// line, never read out of surrounding prose.
|
|
506
|
+
//
|
|
507
|
+
// **Case-SENSITIVE, deliberately.** The `i` flag was here and was wrong: the
|
|
508
|
+
// pattern is line-anchored, so `Verdict: option A is the stronger choice` —
|
|
509
|
+
// ordinary chairman prose opening a line — parsed as a machine-readable verdict
|
|
510
|
+
// carrying the whole sentence as its label. That is precisely the prose
|
|
511
|
+
// inference the paragraph above forbids, arriving through the regex instead of
|
|
512
|
+
// through a fallback. `VERDICT_LINE_CONTRACT` asks for the literal uppercase
|
|
513
|
+
// token, so requiring it costs a compliant author nothing and closes the class.
|
|
514
|
+
const _VERDICT_RE = /^\s*VERDICT:\s*(.+?)\s*$/gm;
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Mandatory closing-line contract for a synthesis rendered alongside a stance
|
|
518
|
+
* tally. The synthesis states its verdict as a machine-readable label rather
|
|
519
|
+
* than leaving it to be mined out of the recommendation prose — the same
|
|
520
|
+
* discipline `STANCE_LINE_CONTRACT` imposes on members.
|
|
521
|
+
*/
|
|
522
|
+
export const VERDICT_LINE_CONTRACT = `Close the synthesis with EXACTLY this line, and nothing after it:
|
|
523
|
+
VERDICT: <option-label>
|
|
524
|
+
Use the SAME <option-label> the Vote Tally cleared. Use \`${SPLIT_VERDICT_LABEL}\` when no option cleared the threshold — an honest split is a verdict, and claiming a winner the tally did not record is a render error.`;
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Parse the synthesis verdict line. Returns the LAST match (the final,
|
|
528
|
+
* most-authoritative one) or `null` when no well-formed line exists.
|
|
529
|
+
*
|
|
530
|
+
* `null` is a repair marker, never a guess: this function does not fall back to
|
|
531
|
+
* reading agreement out of the prose. That is the invariant `stance_tally`
|
|
532
|
+
* states for members ("the tally never infers a stance from the surrounding
|
|
533
|
+
* prose") and the one `check_finding_dispositions` states for the release
|
|
534
|
+
* ledger ("a comment is mutable and unaudited; it is transport, not a record").
|
|
535
|
+
*/
|
|
536
|
+
export function parse_verdict_line(text: string): { label: string; display: string } | null {
|
|
537
|
+
_VERDICT_RE.lastIndex = 0;
|
|
538
|
+
let last: RegExpExecArray | null = null;
|
|
539
|
+
let m: RegExpExecArray | null;
|
|
540
|
+
while ((m = _VERDICT_RE.exec(text)) !== null) {
|
|
541
|
+
last = m;
|
|
542
|
+
}
|
|
543
|
+
if (last === null) {
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
const raw = (last[1] ?? '').trim();
|
|
547
|
+
if (raw.length === 0) {
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
// An angle-bracketed label is the CONTRACT's own placeholder, not a verdict.
|
|
551
|
+
// This matters on the templated path: with no chairman the rendered body IS
|
|
552
|
+
// `VERDICT_LINE_CONTRACT`'s template text, so a parser without this guard
|
|
553
|
+
// would read `<option-label>` as a claimed winner and throw on every
|
|
554
|
+
// un-summarised render. Same treatment `assert_synthesis_sections` gives a
|
|
555
|
+
// placeholder-empty section.
|
|
556
|
+
if (/^<.*>$/.test(raw)) {
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
return { label: raw.toLowerCase(), display: raw };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Validate a completed synthesis against the stance tally it was rendered
|
|
564
|
+
* beside: a verdict naming a winner the tally did not clear throws
|
|
565
|
+
* `SynthesisRenderError`, the same way a missing section does.
|
|
566
|
+
*
|
|
567
|
+
* The defect this closes: `assert_synthesis_sections` is a *shape* check, so a
|
|
568
|
+
* synthesis could report agreement over a tally that recorded dissent and
|
|
569
|
+
* nothing downstream disagreed. That is the class behind the recorded 9.14.0
|
|
570
|
+
* release failure, where the head read "Security and correctness: none" over a
|
|
571
|
+
* critical finding — `check_finding_dispositions.ts` is the same check on the
|
|
572
|
+
* release surface.
|
|
573
|
+
*
|
|
574
|
+
* **An absent verdict line does not throw.** It is a repair marker under the
|
|
575
|
+
* same rule as an unparseable stance: the fail-safe direction is to record that
|
|
576
|
+
* the claim was not made, never to infer one from prose. That also keeps every
|
|
577
|
+
* synthesis rendered before `VERDICT_LINE_CONTRACT` shipped green.
|
|
578
|
+
*/
|
|
579
|
+
export function assert_synthesis_matches_tally(text: string, tally: TallyView): void {
|
|
580
|
+
const mismatch = describe_verdict_mismatch(text, tally);
|
|
581
|
+
if (mismatch !== null) {
|
|
582
|
+
throw new SynthesisRenderError(mismatch);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** The slice of a `StanceTallyResult` the verdict check reads. */
|
|
587
|
+
export interface TallyView {
|
|
588
|
+
consensus: { label: string } | null;
|
|
589
|
+
split: boolean;
|
|
590
|
+
options: ReadonlyArray<{ label: string }>;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* The same comparison as `assert_synthesis_matches_tally`, returning the
|
|
595
|
+
* mismatch as a string instead of throwing — `null` means "no contradiction".
|
|
596
|
+
*
|
|
597
|
+
* **Why both shapes exist.** Throwing is right for a caller holding a finished
|
|
598
|
+
* synthesis it can refuse. It is wrong on the render path: `render()` throws
|
|
599
|
+
* before the tally and the Convergence blocks are pushed, so a mismatch would
|
|
600
|
+
* destroy the ENTIRE artifact — every member response, the peer review, the
|
|
601
|
+
* quorum bookkeeping — *after* every provider call has already been paid for.
|
|
602
|
+
* The module's own answer for an unverifiable claim is the `needs_repair`
|
|
603
|
+
* marker `render_vote_tally` prints, not a discarded pass, and the roadmap had
|
|
604
|
+
* already recorded that unconditionally wiring the sibling shape check was
|
|
605
|
+
* unsafe for this same reason. So the render path surfaces the contradiction
|
|
606
|
+
* where a reader will see it and keeps the artifact.
|
|
607
|
+
*/
|
|
608
|
+
export function describe_verdict_mismatch(text: string, tally: TallyView): string | null {
|
|
609
|
+
const verdict = parse_verdict_line(text);
|
|
610
|
+
if (verdict === null) {
|
|
611
|
+
return null; // repair marker, not a guess
|
|
612
|
+
}
|
|
613
|
+
// The reserved sense of `split` yields to a real option that carries the
|
|
614
|
+
// same label (R2 finding 8): the label vocabulary is free text mined from
|
|
615
|
+
// member `STANCE:` lines, so nothing stops an option genuinely called
|
|
616
|
+
// `split`, and when the tally clears it the CORRECT verdict would otherwise
|
|
617
|
+
// read as a contradiction. Deciding by whether the tally actually holds
|
|
618
|
+
// that option keeps one namespace without an escape syntax authors would
|
|
619
|
+
// have to learn.
|
|
620
|
+
const optionNamedSplit = tally.options.some(
|
|
621
|
+
(o) => o.label.toLowerCase() === SPLIT_VERDICT_LABEL,
|
|
622
|
+
);
|
|
623
|
+
const claimsSplit = verdict.label === SPLIT_VERDICT_LABEL && !optionNamedSplit;
|
|
624
|
+
|
|
625
|
+
if (tally.consensus === null) {
|
|
626
|
+
if (claimsSplit) {
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
// `split` is the tally's own word for this state; reading it here rather
|
|
630
|
+
// than re-deriving "no consensus ⇒ split" keeps the message honest when
|
|
631
|
+
// the tally is empty (zero options is not a split across zero options).
|
|
632
|
+
const how = tally.split
|
|
633
|
+
? `split across ${tally.options.length} option(s)`
|
|
634
|
+
: 'no option-level stances parsed';
|
|
635
|
+
return (
|
|
636
|
+
`synthesis claims "${verdict.display}" but the tally recorded no consensus ` +
|
|
637
|
+
`(${how}); use \`${SPLIT_VERDICT_LABEL}\` or re-run the round`
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (claimsSplit) {
|
|
642
|
+
return `synthesis claims a split but the tally cleared "${tally.consensus.label}"`;
|
|
643
|
+
}
|
|
644
|
+
if (verdict.label !== tally.consensus.label.toLowerCase()) {
|
|
645
|
+
return (
|
|
646
|
+
`synthesis claims "${verdict.display}" but the tally cleared ` +
|
|
647
|
+
`"${tally.consensus.label}"`
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
|
|
491
653
|
/**
|
|
492
654
|
* Drop any *whole line* containing a host-agent identity substring.
|
|
493
655
|
*
|
|
@@ -82,3 +82,33 @@ 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
|
+
* ONE such record now exists, and it is deliberately narrow: ADR-224
|
|
97
|
+
* (`docs/decisions/ADR-224-gate-scoped-solo-attendance-floor.md`) authorizes a
|
|
98
|
+
* `min_present: 2` floor to branch on this predicate **for gate-class passes
|
|
99
|
+
* only**, decided against a measured 12.5 % solo-conclusion rate. Nothing has
|
|
100
|
+
* landed yet — no caller branches on it today — so the sentence above still
|
|
101
|
+
* describes the code. It is stated here so a reader finds the authorization
|
|
102
|
+
* instead of re-deriving the prohibition, and so the next branch is visibly a
|
|
103
|
+
* separate argument: the exception is gate-class passes, not solo status
|
|
104
|
+
* generally.
|
|
105
|
+
*
|
|
106
|
+
* `total` is deliberately NOT consulted. A council configured with a single
|
|
107
|
+
* member concludes solo by construction, and that is still a conclusion
|
|
108
|
+
* reached on one voice — collapsing it into "not solo" would hide exactly
|
|
109
|
+
* the passes a solo-conclusion rate is measured to find. A consumer that
|
|
110
|
+
* cares about the distinction reads `result.total` alongside.
|
|
111
|
+
*/
|
|
112
|
+
export function isSoloConcluded(result: QuorumResult): boolean {
|
|
113
|
+
return result.status === 'concluded' && result.present === 1;
|
|
114
|
+
}
|
|
@@ -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,
|
|
@@ -42,6 +42,7 @@ import * as os from 'node:os';
|
|
|
42
42
|
import * as path from 'node:path';
|
|
43
43
|
|
|
44
44
|
import { claude_config_dir, CODEX_MARKETPLACE_NAME, CODEX_PLUGIN_ID } from '../_lib/claude_plugin.js';
|
|
45
|
+
import { isEnvKillSwitchActive } from '../_lib/env_kill_switch.js';
|
|
45
46
|
import { atomic_write_json } from '../hooks/state_io.js';
|
|
46
47
|
import { AI_TEAM_DEFAULTS, load_ai_team_config, type AiTeamConfig } from './config.js';
|
|
47
48
|
|
|
@@ -170,8 +171,7 @@ export function circuit_breaker_notice(bound: number): string {
|
|
|
170
171
|
// ── ledger (events-log convention) ──────────────────────────────────
|
|
171
172
|
|
|
172
173
|
function _kill_switch_active(): boolean {
|
|
173
|
-
|
|
174
|
-
return !(value === '' || value === '0' || value === 'false' || value === 'False');
|
|
174
|
+
return isEnvKillSwitchActive(_KILL_SWITCH_ENV);
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
function _iso_seconds_z(d: Date): string {
|