@event4u/agent-config 14.20.0 → 14.21.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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +243 -0
- package/README.md +1 -1
- package/agents/templates/.ai-council.yml.example +10 -0
- package/dist/agent-src/commands/analyze/inbox.md +1 -1
- package/dist/agent-src/commands/analyze/{reference-repo.md → repo.md} +247 -32
- package/dist/agent-src/commands/analyze/roadmap-repos.md +129 -0
- package/dist/agent-src/commands/analyze.md +12 -7
- package/dist/agent-src/commands/feature/plan.md +55 -16
- package/dist/agent-src/commands/feature/refactor.md +8 -3
- package/dist/agent-src/commands/humanize.md +48 -4
- package/dist/agent-src/contexts/execution/contract-decision-sheet.md +22 -1
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +4 -1
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +35 -2
- package/dist/agent-src/contexts/execution/terminal-states.md +4 -1
- package/dist/agent-src/rules/evaluator-independence.md +4 -4
- package/dist/agent-src/rules/external-reference-deep-dive.md +2 -2
- package/dist/agent-src/skills/competitive-positioning/SKILL.md +2 -2
- package/dist/agent-src/skills/humanizer/references/anti-aiisms.md +27 -11
- package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +1 -1
- package/dist/agent-src/skills/markitdown/SKILL.md +1 -1
- package/dist/agent-src/skills/repomix-packer/SKILL.md +1 -1
- package/dist/agent-src/skills/technical-specification/SKILL.md +4 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/features.md +5 -0
- package/dist/agent-src/templates/roadmaps.md +15 -0
- package/dist/cli-delegate/{chunk-KPBWFOPZ.js → chunk-2FTMENJL.js} +194 -37
- package/dist/cli-delegate/{chunk-PI2GU3DP.js → chunk-2TAQ2K4V.js} +4 -1
- package/dist/cli-delegate/{chunk-C6IPZRDA.js → chunk-643Z7FIC.js} +1 -1
- package/dist/cli-delegate/{chunk-NUMQHLS5.js → chunk-J3RFNKG4.js} +21 -2
- package/dist/cli-delegate/cmd_conformance.js +17 -3
- package/dist/cli-delegate/cmd_doctor.js +3 -3
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +56 -25
- 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 +7 -6
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +6 -5
- package/dist/hooks/dispatch.js +479 -241
- package/dist/install/install.mjs +4 -1
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +313 -63
- package/dist/scripts/ai_council/content_ceiling.js +63 -0
- package/dist/scripts/ai_council/seat_policy.js +106 -0
- package/dist/scripts/ai_council/transport_resolver.js +21 -0
- package/llms.txt +14 -14
- package/package.json +1 -1
- package/src/config/gate-violation-baselines.json +3 -3
- package/src/config/release-gate-locality.yml +4 -3
- package/src/scripts/_lib/blocked_by_marker.ts +189 -0
- package/src/scripts/_lib/host_capability.ts +38 -1
- package/src/scripts/_lib/release_highlights.ts +19 -211
- package/src/scripts/_lib/release_material.ts +27 -77
- package/src/scripts/_lib/structured_ask.ts +117 -0
- package/src/scripts/_lib/tty_prompt.ts +76 -0
- package/src/scripts/ai_council/api_transport.ts +163 -0
- package/src/scripts/ai_council/clients.ts +58 -63
- package/src/scripts/ai_council/config.ts +13 -0
- package/src/scripts/ai_council/content_ceiling.ts +79 -0
- package/src/scripts/ai_council/jury_aggregate.ts +178 -0
- package/src/scripts/ai_council/seat_policy.ts +137 -0
- package/src/scripts/ai_council/status_surface.ts +79 -0
- package/src/scripts/ai_council/transport_resolver.ts +59 -1
- package/src/scripts/ai_tells_rules.ts +446 -5
- package/src/scripts/ask_block_census.ts +499 -0
- package/src/scripts/bench_humanizer_eval.ts +264 -40
- package/src/scripts/bench_prose_tells_fp.ts +198 -0
- package/src/scripts/check_branch_work_committed.ts +234 -0
- package/src/scripts/check_pack_size.ts +1 -1
- package/src/scripts/check_release_highlights.ts +0 -157
- package/src/scripts/council_cli.ts +18 -27
- package/src/scripts/detect_ai_tells.ts +142 -20
- package/src/scripts/harvest_reference_tokens.ts +698 -0
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +31 -1
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/delegation_nudge_hook.ts +45 -1
- package/src/scripts/hooks/one_question_per_ask_hook.ts +191 -0
- package/src/scripts/install-hooks.sh +52 -0
- package/src/scripts/lint_roadmap_blockers.ts +51 -1
- package/src/scripts/migrate_command_suggestions.ts +1 -1
- package/src/scripts/probe_unblocked_ask.ts +135 -2
- package/src/scripts/release.ts +26 -71
- package/src/scripts/release_drill.ts +6 -10
- package/src/scripts/release_env.ts +1 -0
- package/src/scripts/release_publication.ts +34 -29
- package/src/scripts/self_review_gate.ts +409 -15
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The council's synchronous HTTP transport, and the per-provider shims that
|
|
3
|
+
* turn it into the SDK-shaped callables `clients.ts::ask()` reads.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from `clients.ts` (road-to-admissible-council-seats 1.1). Two
|
|
6
|
+
* reasons, and only the first is about tidiness:
|
|
7
|
+
*
|
|
8
|
+
* 1. `clients.ts` is ~1,370 lines past the 1,500-line ceiling
|
|
9
|
+
* `check_source_size_budget` ratchets, so every line added there costs one
|
|
10
|
+
* excess line. The three shims below are new code; putting them in a file
|
|
11
|
+
* UNDER the ceiling is what lets 1.1 land without raising a baseline.
|
|
12
|
+
* 2. Before 1.1 exactly one provider (OpenAI) had a live transport and the
|
|
13
|
+
* other four constructors threw `… package not installed. pip install …`
|
|
14
|
+
* after the api-key check — so `mode: api` was dead for gemini, xai and
|
|
15
|
+
* perplexity. A shared shim module is the honest home for "how this
|
|
16
|
+
* package actually reaches a provider over HTTP", rather than four
|
|
17
|
+
* copies of the same curl call inside four constructors.
|
|
18
|
+
*
|
|
19
|
+
* The injectable seam. Every shim takes a `JsonPost` rather than calling `curlJsonPost` directly.
|
|
20
|
+
* A test can then construct a REAL client with a REAL api_key — exercising
|
|
21
|
+
* the shim-building path a mock `client:` object skips entirely — and still
|
|
22
|
+
* make no network call. That is the difference between testing the transport
|
|
23
|
+
* and testing around it.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { spawnSync } from 'node:child_process';
|
|
27
|
+
|
|
28
|
+
/** The one network primitive. Swappable so tests never reach the network. */
|
|
29
|
+
export type JsonPost = (url: string, extraHeaders: string[], body: unknown) => unknown;
|
|
30
|
+
|
|
31
|
+
export const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Synchronous JSON POST via `curl` (no Node SDK, no Python). Matches the
|
|
35
|
+
* council's `spawnSync` transport model so the live-call clients stay
|
|
36
|
+
* synchronous. Returns the parsed response JSON (the provider HTTP APIs return
|
|
37
|
+
* exactly the SDK response shape `ask()` reads). Throws on transport failure or
|
|
38
|
+
* a non-2xx status so `ask()`'s catch surfaces it as a member error.
|
|
39
|
+
*/
|
|
40
|
+
export function curlJsonPost(url: string, extraHeaders: string[], body: unknown): unknown {
|
|
41
|
+
const args = ['-sS', '-X', 'POST', url, '-H', 'content-type: application/json'];
|
|
42
|
+
for (const h of extraHeaders) {
|
|
43
|
+
args.push('-H', h);
|
|
44
|
+
}
|
|
45
|
+
// `--connect-timeout` fast-fails a dead host; `--max-time` lets curl abort
|
|
46
|
+
// itself cleanly (surfacing a real `curl exited` error with stderr) ~10s
|
|
47
|
+
// before the `spawnSync` timeout would kill it with an opaque ETIMEDOUT.
|
|
48
|
+
// A full 16k-token generation legitimately runs several minutes, so the
|
|
49
|
+
// ceiling is 300s, not 120s (the old value timed out long Anthropic calls).
|
|
50
|
+
args.push('--connect-timeout', '30', '--max-time', '290');
|
|
51
|
+
args.push('-w', '\n%{http_code}', '--data-binary', '@-');
|
|
52
|
+
const r = spawnSync('curl', args, {
|
|
53
|
+
input: JSON.stringify(body),
|
|
54
|
+
encoding: 'utf8',
|
|
55
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
56
|
+
timeout: 300_000,
|
|
57
|
+
});
|
|
58
|
+
if (r.error) {
|
|
59
|
+
throw new Error(`curl spawn failed: ${(r.error as Error).message}`);
|
|
60
|
+
}
|
|
61
|
+
if (r.status !== 0) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`curl exited ${r.status ?? 'null'}: ${(r.stderr ?? '').toString().slice(0, 500)}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
const out = (r.stdout ?? '').toString();
|
|
67
|
+
const nl = out.lastIndexOf('\n');
|
|
68
|
+
const httpCode = (nl >= 0 ? out.slice(nl + 1) : out).trim();
|
|
69
|
+
const bodyText = nl >= 0 ? out.slice(0, nl) : out;
|
|
70
|
+
if (!/^2\d\d$/.test(httpCode)) {
|
|
71
|
+
throw new Error(`HTTP ${httpCode}: ${bodyText.slice(0, 500)}`);
|
|
72
|
+
}
|
|
73
|
+
return JSON.parse(bodyText);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `chat.completions.create` over any OpenAI-compatible `/v1/chat/completions`.
|
|
78
|
+
* Used by OpenAI itself and by the two OpenAI-compatible vendors (xAI,
|
|
79
|
+
* Perplexity), whose only difference is the base URL.
|
|
80
|
+
*
|
|
81
|
+
* `baseUrl` is passed rather than read off `this.base_url`: a subclass field
|
|
82
|
+
* initializer (`override base_url = XAI_BASE_URL`) runs AFTER the base
|
|
83
|
+
* constructor, so reading the field during construction would silently build
|
|
84
|
+
* every compatible client against the empty string.
|
|
85
|
+
*/
|
|
86
|
+
export function openAiCompatibleClient(
|
|
87
|
+
baseUrl: string,
|
|
88
|
+
apiKey: string,
|
|
89
|
+
post: JsonPost = curlJsonPost,
|
|
90
|
+
): unknown {
|
|
91
|
+
const url = `${baseUrl.replace(/\/+$/, '')}/chat/completions`;
|
|
92
|
+
return {
|
|
93
|
+
chat: {
|
|
94
|
+
completions: {
|
|
95
|
+
create: (kwargs: Record<string, unknown>): unknown =>
|
|
96
|
+
post(url, [`authorization: Bearer ${apiKey}`], kwargs),
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* `models.generate_content` over the Gemini REST API, normalised to the
|
|
104
|
+
* `google-genai` SDK response shape `GeminiClient.ask()` reads.
|
|
105
|
+
*
|
|
106
|
+
* The normalisation is the whole point and is not cosmetic: REST answers with
|
|
107
|
+
* `candidates[0].content.parts[*].text` and `usageMetadata.promptTokenCount`,
|
|
108
|
+
* while `ask()` reads `.text`, `.model_version` and
|
|
109
|
+
* `.usage_metadata.{prompt_token_count, candidates_token_count}`. Rewriting
|
|
110
|
+
* `ask()` to read REST instead would break every injected SDK-shaped mock in
|
|
111
|
+
* the existing tests, so the adapter lives here, at the transport boundary,
|
|
112
|
+
* where a shape mismatch is a transport concern.
|
|
113
|
+
*/
|
|
114
|
+
export function geminiRestClient(
|
|
115
|
+
apiKey: string,
|
|
116
|
+
post: JsonPost = curlJsonPost,
|
|
117
|
+
baseUrl: string = GEMINI_BASE_URL,
|
|
118
|
+
): unknown {
|
|
119
|
+
return {
|
|
120
|
+
models: {
|
|
121
|
+
generate_content: (kwargs: Record<string, unknown>): unknown => {
|
|
122
|
+
const model = String(kwargs['model'] ?? '');
|
|
123
|
+
const cfg = (kwargs['config'] ?? {}) as Record<string, unknown>;
|
|
124
|
+
const url = `${baseUrl.replace(/\/+$/, '')}/models/${model}:generateContent`;
|
|
125
|
+
const raw = post(
|
|
126
|
+
url,
|
|
127
|
+
[`x-goog-api-key: ${apiKey}`],
|
|
128
|
+
{
|
|
129
|
+
contents: [{ role: 'user', parts: [{ text: String(kwargs['contents'] ?? '') }] }],
|
|
130
|
+
generationConfig: { maxOutputTokens: cfg['max_output_tokens'] ?? null },
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
return normaliseGeminiRest(raw);
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** REST envelope → the `google-genai` SDK response shape. Exported for tests. */
|
|
140
|
+
export function normaliseGeminiRest(raw: unknown): {
|
|
141
|
+
text: string;
|
|
142
|
+
model_version: string;
|
|
143
|
+
usage_metadata: { prompt_token_count: number; candidates_token_count: number };
|
|
144
|
+
} {
|
|
145
|
+
const env = (raw ?? {}) as Record<string, unknown>;
|
|
146
|
+
const candidates = Array.isArray(env['candidates']) ? (env['candidates'] as unknown[]) : [];
|
|
147
|
+
const first = (candidates[0] ?? {}) as Record<string, unknown>;
|
|
148
|
+
const content = (first['content'] ?? {}) as Record<string, unknown>;
|
|
149
|
+
const parts = Array.isArray(content['parts']) ? (content['parts'] as unknown[]) : [];
|
|
150
|
+
const text = parts
|
|
151
|
+
.map((p) => String(((p ?? {}) as Record<string, unknown>)['text'] ?? ''))
|
|
152
|
+
.join('');
|
|
153
|
+
const usage = (env['usageMetadata'] ?? {}) as Record<string, unknown>;
|
|
154
|
+
const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
155
|
+
return {
|
|
156
|
+
text,
|
|
157
|
+
model_version: String(env['modelVersion'] ?? ''),
|
|
158
|
+
usage_metadata: {
|
|
159
|
+
prompt_token_count: num(usage['promptTokenCount']),
|
|
160
|
+
candidates_token_count: num(usage['candidatesTokenCount']),
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -47,6 +47,12 @@ import * as path from 'node:path';
|
|
|
47
47
|
|
|
48
48
|
import { hardenedSpawnEnv } from '../_lib/spawn_env.js';
|
|
49
49
|
import { boundFor } from './cli_agency_bounds.js';
|
|
50
|
+
import {
|
|
51
|
+
curlJsonPost as _curlJsonPost,
|
|
52
|
+
geminiRestClient,
|
|
53
|
+
openAiCompatibleClient,
|
|
54
|
+
type JsonPost,
|
|
55
|
+
} from './api_transport.js';
|
|
50
56
|
import { SESSION_ROLE_ENV } from '../_lib/session_role.js';
|
|
51
57
|
import { load_agent_settings } from '../_lib/agent_settings.js';
|
|
52
58
|
|
|
@@ -163,9 +169,8 @@ export const DEFAULT_OPENAI_CLI_MODEL = OPENAI_CLI_VENDOR_DEFAULT;
|
|
|
163
169
|
export const DEFAULT_ANTHROPIC_CLI_MODEL = 'sonnet';
|
|
164
170
|
export const DEFAULT_GEMINI_CLI_MODEL = 'gemini-2.5-pro';
|
|
165
171
|
|
|
166
|
-
// OpenAI-API-compatible endpoints
|
|
167
|
-
// `/v1/chat/completions` shape,
|
|
168
|
-
// custom `base_url`. Gemini has its own SDK (`google-genai`).
|
|
172
|
+
// OpenAI-API-compatible endpoints — xAI and Perplexity both expose the
|
|
173
|
+
// `/v1/chat/completions` shape, reached by one curl shim in `api_transport.ts`.
|
|
169
174
|
export const XAI_BASE_URL = 'https://api.x.ai/v1';
|
|
170
175
|
export const PERPLEXITY_BASE_URL = 'https://api.perplexity.ai';
|
|
171
176
|
|
|
@@ -461,6 +466,12 @@ interface ApiClientOptions {
|
|
|
461
466
|
// Phase 4). Default '5m' when omitted — see `DEFAULT_PROMPT_CACHE_TTL`.
|
|
462
467
|
// Only meaningful when `enable_prompt_cache` is true.
|
|
463
468
|
prompt_cache_ttl?: PromptCacheTtl | undefined;
|
|
469
|
+
/**
|
|
470
|
+
* HTTP seam. Omitted → the live `curl` post. A test passes a double
|
|
471
|
+
* TOGETHER with a real `api_key`, which exercises the shim-building
|
|
472
|
+
* path that an injected `client:` object skips entirely.
|
|
473
|
+
*/
|
|
474
|
+
transport?: JsonPost | undefined;
|
|
464
475
|
}
|
|
465
476
|
|
|
466
477
|
/** Shared ctor-options shape for the CLI clients. */
|
|
@@ -479,47 +490,6 @@ interface CliClientOptions {
|
|
|
479
490
|
consumer?: string | undefined;
|
|
480
491
|
}
|
|
481
492
|
|
|
482
|
-
/**
|
|
483
|
-
* Synchronous JSON POST via `curl` (no Node SDK, no Python). Matches the
|
|
484
|
-
* council's `spawnSync` transport model so the live-call clients stay
|
|
485
|
-
* synchronous. Returns the parsed response JSON (the provider HTTP APIs return
|
|
486
|
-
* exactly the SDK response shape `ask()` reads). Throws on transport failure or
|
|
487
|
-
* a non-2xx status so `ask()`'s catch surfaces it as a member error.
|
|
488
|
-
*/
|
|
489
|
-
function _curlJsonPost(url: string, extraHeaders: string[], body: unknown): unknown {
|
|
490
|
-
const args = ['-sS', '-X', 'POST', url, '-H', 'content-type: application/json'];
|
|
491
|
-
for (const h of extraHeaders) {
|
|
492
|
-
args.push('-H', h);
|
|
493
|
-
}
|
|
494
|
-
// `--connect-timeout` fast-fails a dead host; `--max-time` lets curl abort
|
|
495
|
-
// itself cleanly (surfacing a real `curl exited` error with stderr) ~10s
|
|
496
|
-
// before the `spawnSync` timeout would kill it with an opaque ETIMEDOUT.
|
|
497
|
-
// A full 16k-token generation legitimately runs several minutes, so the
|
|
498
|
-
// ceiling is 300s, not 120s (the old value timed out long Anthropic calls).
|
|
499
|
-
args.push('--connect-timeout', '30', '--max-time', '290');
|
|
500
|
-
args.push('-w', '\n%{http_code}', '--data-binary', '@-');
|
|
501
|
-
const r = spawnSync('curl', args, {
|
|
502
|
-
input: JSON.stringify(body),
|
|
503
|
-
encoding: 'utf8',
|
|
504
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
505
|
-
timeout: 300_000,
|
|
506
|
-
});
|
|
507
|
-
if (r.error) {
|
|
508
|
-
throw new Error(`curl spawn failed: ${(r.error as Error).message}`);
|
|
509
|
-
}
|
|
510
|
-
if (r.status !== 0) {
|
|
511
|
-
throw new Error(`curl exited ${r.status ?? 'null'}: ${(r.stderr ?? '').toString().slice(0, 500)}`);
|
|
512
|
-
}
|
|
513
|
-
const out = (r.stdout ?? '').toString();
|
|
514
|
-
const nl = out.lastIndexOf('\n');
|
|
515
|
-
const httpCode = (nl >= 0 ? out.slice(nl + 1) : out).trim();
|
|
516
|
-
const bodyText = nl >= 0 ? out.slice(0, nl) : out;
|
|
517
|
-
if (!/^2\d\d$/.test(httpCode)) {
|
|
518
|
-
throw new Error(`HTTP ${httpCode}: ${bodyText.slice(0, 500)}`);
|
|
519
|
-
}
|
|
520
|
-
return JSON.parse(bodyText);
|
|
521
|
-
}
|
|
522
|
-
|
|
523
493
|
export class AnthropicClient extends ExternalAIClient {
|
|
524
494
|
override name = 'anthropic';
|
|
525
495
|
override billable = true;
|
|
@@ -824,13 +794,12 @@ export class OpenAIClient extends ExternalAIClient {
|
|
|
824
794
|
// ── Gemini / xAI / Perplexity (Phase 0 — Step 6) ─────────────────────
|
|
825
795
|
|
|
826
796
|
/**
|
|
827
|
-
* Google Gemini
|
|
828
|
-
*
|
|
829
|
-
*
|
|
830
|
-
*
|
|
831
|
-
* `
|
|
832
|
-
*
|
|
833
|
-
* `.usage_metadata.{prompt_token_count, candidates_token_count}`.
|
|
797
|
+
* Google Gemini over the REST API (1.1 — there is no SDK dependency; the old
|
|
798
|
+
* `google-genai` lazy-import never existed in the TS port and the constructor
|
|
799
|
+
* simply threw). `ask()` still reads the SDK RESPONSE shape — `.text`,
|
|
800
|
+
* `.model_version`, `.usage_metadata.{prompt_token_count,
|
|
801
|
+
* candidates_token_count}` — which `geminiRestClient` normalises to, so every
|
|
802
|
+
* injected-mock test is unaffected.
|
|
834
803
|
*/
|
|
835
804
|
export class GeminiClient extends ExternalAIClient {
|
|
836
805
|
override name = 'gemini';
|
|
@@ -851,7 +820,12 @@ export class GeminiClient extends ExternalAIClient {
|
|
|
851
820
|
'Use `api_key_ref: env:GEMINI_API_KEY` in ~/.event4u/agent-config/settings/.ai-council.yml.',
|
|
852
821
|
);
|
|
853
822
|
}
|
|
854
|
-
|
|
823
|
+
// Live transport (1.1). Was an unconditional `pip install google-genai`
|
|
824
|
+
// throw, which made `mode: api` dead for this provider however the key
|
|
825
|
+
// was configured. `geminiRestClient` normalises the REST envelope into
|
|
826
|
+
// the SDK shape `ask()` below already reads, so `ask()` is untouched
|
|
827
|
+
// and every existing injected-mock test keeps passing.
|
|
828
|
+
this._client = geminiRestClient(api_key, opts.transport ?? _curlJsonPost);
|
|
855
829
|
}
|
|
856
830
|
|
|
857
831
|
override ask(
|
|
@@ -901,19 +875,26 @@ export class GeminiClient extends ExternalAIClient {
|
|
|
901
875
|
}
|
|
902
876
|
|
|
903
877
|
/**
|
|
904
|
-
* Shared shape for OpenAI-API-compatible providers (xAI, Perplexity)
|
|
905
|
-
*
|
|
906
|
-
*
|
|
907
|
-
*
|
|
908
|
-
*
|
|
909
|
-
* that requires `max_completion_tokens` as of 2026-05-14.
|
|
878
|
+
* Shared shape for OpenAI-API-compatible providers (xAI, Perplexity): both
|
|
879
|
+
* implement `/v1/chat/completions`, so 1.1 reaches them with one curl shim and
|
|
880
|
+
* a base URL. The reasoning-model branch from `OpenAIClient` is intentionally
|
|
881
|
+
* omitted — neither ships a reasoning model that requires
|
|
882
|
+
* `max_completion_tokens` as of 2026-05-14.
|
|
910
883
|
*/
|
|
911
884
|
export class _OpenAICompatibleClient extends ExternalAIClient {
|
|
912
885
|
override billable = true;
|
|
913
886
|
base_url = '';
|
|
914
887
|
protected _client: unknown;
|
|
915
888
|
|
|
916
|
-
constructor(opts: {
|
|
889
|
+
constructor(opts: {
|
|
890
|
+
model: string;
|
|
891
|
+
client?: unknown;
|
|
892
|
+
api_key?: string | null | undefined;
|
|
893
|
+
transport?: JsonPost | undefined;
|
|
894
|
+
// Passed, never read off `this.base_url`: a subclass field initializer
|
|
895
|
+
// runs AFTER this constructor, so the field is still '' right here.
|
|
896
|
+
base_url: string;
|
|
897
|
+
}) {
|
|
917
898
|
super();
|
|
918
899
|
this.model = opts.model;
|
|
919
900
|
if (opts.client !== undefined && opts.client !== null) {
|
|
@@ -926,7 +907,13 @@ export class _OpenAICompatibleClient extends ExternalAIClient {
|
|
|
926
907
|
`${this.constructor.name} requires explicit api_key or injected client.`,
|
|
927
908
|
);
|
|
928
909
|
}
|
|
929
|
-
|
|
910
|
+
// Live transport (1.1) — replaces an unconditional `pip install openai`
|
|
911
|
+
// throw that made `mode: api` dead for xai and perplexity.
|
|
912
|
+
this._client = openAiCompatibleClient(
|
|
913
|
+
opts.base_url,
|
|
914
|
+
api_key,
|
|
915
|
+
opts.transport ?? _curlJsonPost,
|
|
916
|
+
);
|
|
930
917
|
}
|
|
931
918
|
|
|
932
919
|
override ask(
|
|
@@ -989,7 +976,13 @@ export class XAIClient extends _OpenAICompatibleClient {
|
|
|
989
976
|
override base_url = XAI_BASE_URL;
|
|
990
977
|
|
|
991
978
|
constructor(opts: ApiClientOptions = {}) {
|
|
992
|
-
super({
|
|
979
|
+
super({
|
|
980
|
+
model: opts.model ?? DEFAULT_XAI_MODEL,
|
|
981
|
+
client: opts.client,
|
|
982
|
+
api_key: opts.api_key,
|
|
983
|
+
transport: opts.transport,
|
|
984
|
+
base_url: XAI_BASE_URL,
|
|
985
|
+
});
|
|
993
986
|
}
|
|
994
987
|
}
|
|
995
988
|
|
|
@@ -1003,6 +996,8 @@ export class PerplexityClient extends _OpenAICompatibleClient {
|
|
|
1003
996
|
model: opts.model ?? DEFAULT_PERPLEXITY_MODEL,
|
|
1004
997
|
client: opts.client,
|
|
1005
998
|
api_key: opts.api_key,
|
|
999
|
+
transport: opts.transport,
|
|
1000
|
+
base_url: PERPLEXITY_BASE_URL,
|
|
1006
1001
|
});
|
|
1007
1002
|
}
|
|
1008
1003
|
}
|
|
@@ -1028,8 +1023,8 @@ export {
|
|
|
1028
1023
|
//
|
|
1029
1024
|
// Raised 120 → 300 (2026-08-13) to match the API transport, which has carried
|
|
1030
1025
|
// `--max-time 290` / `timeout: 300_000` since the 2026-06-24 repair of the same
|
|
1031
|
-
// symptom. That repair landed in `
|
|
1032
|
-
//
|
|
1026
|
+
// symptom. That repair landed in `api_transport.ts::curlJsonPost` only, so with
|
|
1027
|
+
// a member on `cli · subscription` the old cap was still live — a deep run
|
|
1033
1028
|
// reproduced it exactly: both members returned `error: timeout` at
|
|
1034
1029
|
// `latency_ms: 122921` with `timeout_seconds: 120`, and the run reported
|
|
1035
1030
|
// `0/2 present — INCONCLUSIVE`.
|
|
@@ -83,6 +83,8 @@ import * as budget from './cli_call_budget.js';
|
|
|
83
83
|
// Converting it to a value import — or adding any other runtime import from
|
|
84
84
|
// quorum into config — creates a real ESM cycle in the loader's init path.
|
|
85
85
|
import { OPENAI_CLI_VENDOR_DEFAULT } from './clients.js';
|
|
86
|
+
import { parseSeatFields, type PolicyExclusion } from './seat_policy.js';
|
|
87
|
+
import type { ContentClass } from './content_ceiling.js';
|
|
86
88
|
import { SOLO_FLOOR_MIN_PRESENT } from './quorum.js';
|
|
87
89
|
|
|
88
90
|
const _VALID_PROVIDERS: ReadonlySet<string> = new Set([
|
|
@@ -330,6 +332,12 @@ export interface MemberConfig {
|
|
|
330
332
|
* demanding a date nobody would refresh.
|
|
331
333
|
*/
|
|
332
334
|
readonly verified_at: string | null;
|
|
335
|
+
/** Seat policy — parsed and documented by `seat_policy.ts::parseSeatFields`.
|
|
336
|
+
* All three are NOTES or REFUSALS: none can make a seat available that was
|
|
337
|
+
* not already available. */
|
|
338
|
+
readonly disabled_reason: string | null;
|
|
339
|
+
readonly policy_exclusion: PolicyExclusion | null;
|
|
340
|
+
readonly content_ceiling: ContentClass;
|
|
333
341
|
}
|
|
334
342
|
|
|
335
343
|
/**
|
|
@@ -1875,6 +1883,10 @@ function _build_member(
|
|
|
1875
1883
|
}
|
|
1876
1884
|
verified_at = coerced;
|
|
1877
1885
|
}
|
|
1886
|
+
const seat = parseSeatFields(name, cfg as Record<string, unknown>);
|
|
1887
|
+
if ('error' in seat) {
|
|
1888
|
+
throw new CouncilConfigError(seat.error);
|
|
1889
|
+
}
|
|
1878
1890
|
const prompt_cache_raw = _get(cfg, 'prompt_cache', null);
|
|
1879
1891
|
let prompt_cache_ttl: '5m' | '1h' = '5m';
|
|
1880
1892
|
if (prompt_cache_raw !== null && prompt_cache_raw !== undefined && !_isBool(prompt_cache_raw)) {
|
|
@@ -1907,6 +1919,7 @@ function _build_member(
|
|
|
1907
1919
|
tier,
|
|
1908
1920
|
prompt_cache_ttl,
|
|
1909
1921
|
verified_at,
|
|
1922
|
+
...seat.fields,
|
|
1910
1923
|
};
|
|
1911
1924
|
}
|
|
1912
1925
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One binary content ceiling per seat — road-to-admissible-council-seats 2.2.
|
|
3
|
+
*
|
|
4
|
+
* Redaction today is a SECRETS floor and says so
|
|
5
|
+
* (`src/skills/ai-council/references/cost-and-redaction.md`). Nothing above it
|
|
6
|
+
* could express "this project's diff may not go to that seat", which is a
|
|
7
|
+
* different question with a different answer: a diff carrying no secret at all
|
|
8
|
+
* is still the consumer's private source.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately BINARY. A graded scale invites a middle value nobody can define
|
|
11
|
+
* and every caller reads differently; two values force the decision at
|
|
12
|
+
* authoring time, where it belongs.
|
|
13
|
+
*
|
|
14
|
+
* public-artifact — repo-owned artefacts: skill descriptions, the trigger
|
|
15
|
+
* corpus, rule text, bench fixtures. Already public.
|
|
16
|
+
* project-content — everything a consumer supplies. The DEFAULT, so an
|
|
17
|
+
* unclassified payload is treated as private rather than
|
|
18
|
+
* as publishable.
|
|
19
|
+
*
|
|
20
|
+
* A seat's ceiling is the most sensitive class it may receive. `public-artifact`
|
|
21
|
+
* is therefore the NARROWER seat: it admits public artefacts only.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const CONTENT_CLASSES = ['public-artifact', 'project-content'] as const;
|
|
25
|
+
export type ContentClass = (typeof CONTENT_CLASSES)[number];
|
|
26
|
+
|
|
27
|
+
/** The default for anything not explicitly classified: private, not public. */
|
|
28
|
+
export const DEFAULT_CONTENT_CLASS: ContentClass = 'project-content';
|
|
29
|
+
|
|
30
|
+
/** The default seat ceiling: unchanged behaviour for every existing seat. */
|
|
31
|
+
export const DEFAULT_SEAT_CEILING: ContentClass = 'project-content';
|
|
32
|
+
|
|
33
|
+
const _SET: ReadonlySet<string> = new Set<string>(CONTENT_CLASSES);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Any configured value → a content class. Unrecognised input becomes
|
|
37
|
+
* `project-content`, the RESTRICTIVE value: a typo must narrow what a seat may
|
|
38
|
+
* receive, never widen it.
|
|
39
|
+
*/
|
|
40
|
+
export function classifyContentClass(raw: unknown, fallback: ContentClass = DEFAULT_CONTENT_CLASS): ContentClass {
|
|
41
|
+
if (typeof raw === 'string' && _SET.has(raw)) {
|
|
42
|
+
return raw as ContentClass;
|
|
43
|
+
}
|
|
44
|
+
return fallback;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface CeilingVerdict {
|
|
48
|
+
readonly admitted: boolean;
|
|
49
|
+
/** Non-null exactly when `admitted` is false. */
|
|
50
|
+
readonly reason: string | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Does a seat whose ceiling is `seatCeiling` admit a payload of `contentClass`?
|
|
55
|
+
*
|
|
56
|
+
* The one refusal is `project-content` into a `public-artifact` seat. Every
|
|
57
|
+
* other pairing is admitted, including public artefacts into a
|
|
58
|
+
* `project-content` seat — a seat cleared for private content is trivially
|
|
59
|
+
* cleared for public content, and refusing that would be a nonsense that
|
|
60
|
+
* disables the default seat for its own repo's fixtures.
|
|
61
|
+
*
|
|
62
|
+
* Refusal is a GREEN path (this roadmap's 2.2 verify): the caller drops the
|
|
63
|
+
* seat and the run continues. It is not an error, because a policy working as
|
|
64
|
+
* designed is not a failure.
|
|
65
|
+
*/
|
|
66
|
+
export function admitsContent(
|
|
67
|
+
seatCeiling: ContentClass,
|
|
68
|
+
contentClass: ContentClass,
|
|
69
|
+
): CeilingVerdict {
|
|
70
|
+
if (seatCeiling === 'public-artifact' && contentClass === 'project-content') {
|
|
71
|
+
return {
|
|
72
|
+
admitted: false,
|
|
73
|
+
reason:
|
|
74
|
+
"seat ceiling is 'public-artifact'; the payload is classified " +
|
|
75
|
+
"'project-content' (consumer-supplied)",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return { admitted: true, reason: null };
|
|
79
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jury aggregation — road-to-admissible-council-seats 3.2.
|
|
3
|
+
*
|
|
4
|
+
* BESIDE `consensus.ts`, not inside it, and the separation is the design.
|
|
5
|
+
* `consensus.ts` aggregates a COUNCIL, whose members carry verdicts.
|
|
6
|
+
* A jury does not: per ADR-257 a route this package did not pay for may propose
|
|
7
|
+
* and may score, never decide. Folding jury scoring into the consensus path
|
|
8
|
+
* would put a scorer on the code path that produces verdicts, which is the
|
|
9
|
+
* exact transition this roadmap's Risk 5 names — "scoring quietly becomes
|
|
10
|
+
* deciding".
|
|
11
|
+
*
|
|
12
|
+
* Two rules, and both are refusals.
|
|
13
|
+
*
|
|
14
|
+
* 1. **At least two model families, or the panel is ABSENT.** Not a warning, a
|
|
15
|
+
* refusal: `docs/CLAIMS.md` records that a same-posture second vendor's
|
|
16
|
+
* catches were a strict SUBSET of the first's. A same-family panel therefore
|
|
17
|
+
* measures redundancy, and reporting redundancy as agreement is the failure
|
|
18
|
+
* a diversity requirement exists to prevent.
|
|
19
|
+
* 2. **The aggregate is a trimmed statistic, never a vote count.** For the same
|
|
20
|
+
* reason: counting agreeing voices rewards a panel for being correlated.
|
|
21
|
+
*
|
|
22
|
+
* What "trimmed" means here, exactly: with n >= 3 the single lowest and single highest score are dropped and the
|
|
23
|
+
* rest averaged — which at n = 3 IS the median, and is why one corrupted score
|
|
24
|
+
* in a three-judge panel cannot move the result the way it moves an arithmetic
|
|
25
|
+
* mean. With n = 2 there is nothing to trim; the mean is returned with
|
|
26
|
+
* `trimmed: 0` so a reader can see that no robustness was available rather than
|
|
27
|
+
* inferring it from a method name.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
judgeBothOrders,
|
|
32
|
+
positionConsistency,
|
|
33
|
+
type JudgeConsistency,
|
|
34
|
+
type PairResolution,
|
|
35
|
+
type PairwiseJudge,
|
|
36
|
+
type SwapObservation,
|
|
37
|
+
} from './judge_position_bias.js';
|
|
38
|
+
|
|
39
|
+
/** One judge's score for one item. `family` is the MODEL family, not the seat. */
|
|
40
|
+
export interface JuryScore {
|
|
41
|
+
readonly judge: string;
|
|
42
|
+
readonly family: string;
|
|
43
|
+
/** Any finite number. The aggregator does not impose a scale. */
|
|
44
|
+
readonly score: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Why a panel produced no aggregate. Each value is a REFUSAL, not an error. */
|
|
48
|
+
export type JuryAbsentReason = 'no_scores' | 'single_family' | 'non_finite_score';
|
|
49
|
+
|
|
50
|
+
export type JuryResult =
|
|
51
|
+
| {
|
|
52
|
+
readonly present: false;
|
|
53
|
+
readonly absentReason: JuryAbsentReason;
|
|
54
|
+
readonly reason: string;
|
|
55
|
+
}
|
|
56
|
+
| {
|
|
57
|
+
readonly present: true;
|
|
58
|
+
readonly aggregate: number;
|
|
59
|
+
readonly method: 'trimmed-mean';
|
|
60
|
+
/** How many scores the trim discarded. 0 when n < 3. */
|
|
61
|
+
readonly trimmed: number;
|
|
62
|
+
readonly judges: number;
|
|
63
|
+
readonly families: readonly string[];
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Aggregate one item's jury scores.
|
|
68
|
+
*
|
|
69
|
+
* Never throws on panel composition: a refused panel is a VERDICT the caller
|
|
70
|
+
* reads, the same shape the transport resolver uses for a refused seat. An
|
|
71
|
+
* exception would make "this panel does not qualify" indistinguishable from a
|
|
72
|
+
* bug at every call site.
|
|
73
|
+
*/
|
|
74
|
+
export function juryAggregate(scores: readonly JuryScore[]): JuryResult {
|
|
75
|
+
if (scores.length === 0) {
|
|
76
|
+
return { present: false, absentReason: 'no_scores', reason: 'no jury scores supplied' };
|
|
77
|
+
}
|
|
78
|
+
for (const s of scores) {
|
|
79
|
+
if (!Number.isFinite(s.score)) {
|
|
80
|
+
return {
|
|
81
|
+
present: false,
|
|
82
|
+
absentReason: 'non_finite_score',
|
|
83
|
+
reason: `judge ${s.judge} returned a non-finite score; the panel is refused rather than silently dropping it`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const families = [...new Set(scores.map((s) => s.family))].sort();
|
|
88
|
+
if (families.length < 2) {
|
|
89
|
+
return {
|
|
90
|
+
present: false,
|
|
91
|
+
absentReason: 'single_family',
|
|
92
|
+
reason:
|
|
93
|
+
`panel spans ${families.length} model family (${families.join(', ') || 'none'}); ` +
|
|
94
|
+
'at least two are required — a same-family panel measures redundancy, not agreement',
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const sorted = [...scores.map((s) => s.score)].sort((a, b) => a - b);
|
|
98
|
+
const trimmed = sorted.length >= 3 ? 2 : 0;
|
|
99
|
+
const kept = trimmed === 0 ? sorted : sorted.slice(1, sorted.length - 1);
|
|
100
|
+
const aggregate = kept.reduce((acc, v) => acc + v, 0) / kept.length;
|
|
101
|
+
return {
|
|
102
|
+
present: true,
|
|
103
|
+
aggregate,
|
|
104
|
+
method: 'trimmed-mean',
|
|
105
|
+
trimmed,
|
|
106
|
+
judges: scores.length,
|
|
107
|
+
families,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** One judge's swapped observation plus the family it belongs to. */
|
|
112
|
+
export interface JuryPairwiseJudge {
|
|
113
|
+
readonly judge: string;
|
|
114
|
+
readonly family: string;
|
|
115
|
+
readonly fn: PairwiseJudge;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface JuryPairwiseResult {
|
|
119
|
+
readonly present: boolean;
|
|
120
|
+
readonly absentReason: JuryAbsentReason | null;
|
|
121
|
+
readonly reason: string | null;
|
|
122
|
+
/** Panel resolution, or `null` when the panel is absent. */
|
|
123
|
+
readonly resolution: PairResolution | null;
|
|
124
|
+
readonly observations: readonly SwapObservation[];
|
|
125
|
+
readonly consistency: readonly JudgeConsistency[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Run a pairwise jury with the position swap applied PER JUDGE.
|
|
130
|
+
*
|
|
131
|
+
* Reuses `judge_position_bias.ts` rather than re-implementing the swap: a
|
|
132
|
+
* second implementation of "show both orders and reconcile" is a second place
|
|
133
|
+
* for the reconciliation to be subtly wrong, and that module already resolves a
|
|
134
|
+
* verdict that does not survive the swap to `inconsistent` rather than to a
|
|
135
|
+
* winner.
|
|
136
|
+
*
|
|
137
|
+
* The panel resolution is the strict majority of the judges' own resolutions,
|
|
138
|
+
* and `inconsistent` judges are counted in the DENOMINATOR — a judge that
|
|
139
|
+
* flipped has an opinion about nothing, and dropping it from the denominator
|
|
140
|
+
* would let two stable judges out of five look unanimous.
|
|
141
|
+
*/
|
|
142
|
+
export function juryPairwise(
|
|
143
|
+
id: string,
|
|
144
|
+
a: string,
|
|
145
|
+
b: string,
|
|
146
|
+
judges: readonly JuryPairwiseJudge[],
|
|
147
|
+
): JuryPairwiseResult {
|
|
148
|
+
const families = [...new Set(judges.map((j) => j.family))].sort();
|
|
149
|
+
if (judges.length === 0 || families.length < 2) {
|
|
150
|
+
return {
|
|
151
|
+
present: false,
|
|
152
|
+
absentReason: judges.length === 0 ? 'no_scores' : 'single_family',
|
|
153
|
+
reason:
|
|
154
|
+
judges.length === 0
|
|
155
|
+
? 'no judges supplied'
|
|
156
|
+
: `panel spans ${families.length} model family; at least two are required`,
|
|
157
|
+
resolution: null,
|
|
158
|
+
observations: [],
|
|
159
|
+
consistency: [],
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
const observations: SwapObservation[] = [];
|
|
163
|
+
const consistency: JudgeConsistency[] = [];
|
|
164
|
+
const tally = new Map<PairResolution, number>();
|
|
165
|
+
for (const j of judges) {
|
|
166
|
+
const obs = judgeBothOrders(id, a, b, j.fn);
|
|
167
|
+
observations.push(obs);
|
|
168
|
+
consistency.push(positionConsistency(j.judge, [obs]));
|
|
169
|
+
tally.set(obs.resolution, (tally.get(obs.resolution) ?? 0) + 1);
|
|
170
|
+
}
|
|
171
|
+
let resolution: PairResolution = 'inconsistent';
|
|
172
|
+
for (const [k, v] of tally) {
|
|
173
|
+
if (v * 2 > judges.length) {
|
|
174
|
+
resolution = k;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { present: true, absentReason: null, reason: null, resolution, observations, consistency };
|
|
178
|
+
}
|