@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.
Files changed (100) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +243 -0
  5. package/README.md +1 -1
  6. package/agents/templates/.ai-council.yml.example +10 -0
  7. package/dist/agent-src/commands/analyze/inbox.md +1 -1
  8. package/dist/agent-src/commands/analyze/{reference-repo.md → repo.md} +247 -32
  9. package/dist/agent-src/commands/analyze/roadmap-repos.md +129 -0
  10. package/dist/agent-src/commands/analyze.md +12 -7
  11. package/dist/agent-src/commands/feature/plan.md +55 -16
  12. package/dist/agent-src/commands/feature/refactor.md +8 -3
  13. package/dist/agent-src/commands/humanize.md +48 -4
  14. package/dist/agent-src/contexts/execution/contract-decision-sheet.md +22 -1
  15. package/dist/agent-src/contexts/execution/host-capability-manifest.md +4 -1
  16. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +35 -2
  17. package/dist/agent-src/contexts/execution/terminal-states.md +4 -1
  18. package/dist/agent-src/rules/evaluator-independence.md +4 -4
  19. package/dist/agent-src/rules/external-reference-deep-dive.md +2 -2
  20. package/dist/agent-src/skills/competitive-positioning/SKILL.md +2 -2
  21. package/dist/agent-src/skills/humanizer/references/anti-aiisms.md +27 -11
  22. package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +1 -1
  23. package/dist/agent-src/skills/markitdown/SKILL.md +1 -1
  24. package/dist/agent-src/skills/repomix-packer/SKILL.md +1 -1
  25. package/dist/agent-src/skills/technical-specification/SKILL.md +4 -0
  26. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  27. package/dist/agent-src/templates/features.md +5 -0
  28. package/dist/agent-src/templates/roadmaps.md +15 -0
  29. package/dist/cli-delegate/{chunk-KPBWFOPZ.js → chunk-2FTMENJL.js} +194 -37
  30. package/dist/cli-delegate/{chunk-PI2GU3DP.js → chunk-2TAQ2K4V.js} +4 -1
  31. package/dist/cli-delegate/{chunk-C6IPZRDA.js → chunk-643Z7FIC.js} +1 -1
  32. package/dist/cli-delegate/{chunk-NUMQHLS5.js → chunk-J3RFNKG4.js} +21 -2
  33. package/dist/cli-delegate/cmd_conformance.js +17 -3
  34. package/dist/cli-delegate/cmd_doctor.js +3 -3
  35. package/dist/cli-delegate/cmd_export.js +2 -2
  36. package/dist/cli-delegate/cmd_handoff.js +1 -1
  37. package/dist/cli-delegate/cmd_prune.js +2 -2
  38. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  39. package/dist/cli-delegate/cmd_sync.js +2 -2
  40. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  41. package/dist/cli-delegate/cmd_validate.js +2 -2
  42. package/dist/discovery/deprecation-report.md +1 -1
  43. package/dist/discovery/discovery-manifest.json +56 -25
  44. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  45. package/dist/discovery/discovery-manifest.summary.md +3 -3
  46. package/dist/discovery/dormancy-report.md +1 -1
  47. package/dist/discovery/orphan-report.md +1 -1
  48. package/dist/discovery/packs.json +7 -6
  49. package/dist/discovery/trust-report.md +2 -2
  50. package/dist/discovery/workspaces.json +6 -5
  51. package/dist/hooks/dispatch.js +479 -241
  52. package/dist/install/install.mjs +4 -1
  53. package/dist/mcp/registry-manifest.json +2 -2
  54. package/dist/mcp/server.json +2 -2
  55. package/dist/mcp/server.mjs +313 -63
  56. package/dist/scripts/ai_council/content_ceiling.js +63 -0
  57. package/dist/scripts/ai_council/seat_policy.js +106 -0
  58. package/dist/scripts/ai_council/transport_resolver.js +21 -0
  59. package/llms.txt +14 -14
  60. package/package.json +1 -1
  61. package/src/config/gate-violation-baselines.json +3 -3
  62. package/src/config/release-gate-locality.yml +4 -3
  63. package/src/scripts/_lib/blocked_by_marker.ts +189 -0
  64. package/src/scripts/_lib/host_capability.ts +38 -1
  65. package/src/scripts/_lib/release_highlights.ts +19 -211
  66. package/src/scripts/_lib/release_material.ts +27 -77
  67. package/src/scripts/_lib/structured_ask.ts +117 -0
  68. package/src/scripts/_lib/tty_prompt.ts +76 -0
  69. package/src/scripts/ai_council/api_transport.ts +163 -0
  70. package/src/scripts/ai_council/clients.ts +58 -63
  71. package/src/scripts/ai_council/config.ts +13 -0
  72. package/src/scripts/ai_council/content_ceiling.ts +79 -0
  73. package/src/scripts/ai_council/jury_aggregate.ts +178 -0
  74. package/src/scripts/ai_council/seat_policy.ts +137 -0
  75. package/src/scripts/ai_council/status_surface.ts +79 -0
  76. package/src/scripts/ai_council/transport_resolver.ts +59 -1
  77. package/src/scripts/ai_tells_rules.ts +446 -5
  78. package/src/scripts/ask_block_census.ts +499 -0
  79. package/src/scripts/bench_humanizer_eval.ts +264 -40
  80. package/src/scripts/bench_prose_tells_fp.ts +198 -0
  81. package/src/scripts/check_branch_work_committed.ts +234 -0
  82. package/src/scripts/check_pack_size.ts +1 -1
  83. package/src/scripts/check_release_highlights.ts +0 -157
  84. package/src/scripts/council_cli.ts +18 -27
  85. package/src/scripts/detect_ai_tells.ts +142 -20
  86. package/src/scripts/harvest_reference_tokens.ts +698 -0
  87. package/src/scripts/hook_manifest.json +1 -1
  88. package/src/scripts/hook_manifest.yaml +31 -1
  89. package/src/scripts/hooks/concern_registry.ts +2 -0
  90. package/src/scripts/hooks/delegation_nudge_hook.ts +45 -1
  91. package/src/scripts/hooks/one_question_per_ask_hook.ts +191 -0
  92. package/src/scripts/install-hooks.sh +52 -0
  93. package/src/scripts/lint_roadmap_blockers.ts +51 -1
  94. package/src/scripts/migrate_command_suggestions.ts +1 -1
  95. package/src/scripts/probe_unblocked_ask.ts +135 -2
  96. package/src/scripts/release.ts +26 -71
  97. package/src/scripts/release_drill.ts +6 -10
  98. package/src/scripts/release_env.ts +1 -0
  99. package/src/scripts/release_publication.ts +34 -29
  100. 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. xAI and Perplexity both expose the
167
- // `/v1/chat/completions` shape, so their clients reuse the `openai` SDK with a
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 via the `google-genai` SDK.
828
- *
829
- * Lazy-imports `google.genai` on first `ask()` so disabled members do not
830
- * require the SDK to be installed. Tests inject a mock client shaped like
831
- * `genai.Client(api_key=...)` — `self._client.models.generate_content(...)`
832
- * returns an object with `.text` and
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
- throw new Error('google-genai package not installed. `pip install google-genai`.');
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
- * Both vendors implement `/v1/chat/completions` and accept the `openai` Python
907
- * SDK with a custom `base_url`. The reasoning-model branch from `OpenAIClient`
908
- * is intentionally omitted — neither xAI nor Perplexity ships a reasoning model
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: { model: string; client?: unknown; api_key?: string | null | undefined }) {
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
- throw new Error('openai package not installed. `pip install openai`.');
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({ model: opts.model ?? DEFAULT_XAI_MODEL, client: opts.client, api_key: opts.api_key });
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 `_curlJsonPost` only, so when a member resolves
1032
- // to `cli · subscription` the old cap was still live — and a deep design run
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
+ }