@event4u/agent-config 9.3.0 → 9.4.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 +78 -0
- package/CONTRIBUTING.md +14 -0
- package/README.md +12 -4
- package/dist/agent-src/commands/contribution-precheck.md +100 -0
- package/dist/agent-src/commands/team.md +4 -0
- package/dist/agent-src/contexts/model-recommendations.md +6 -0
- package/dist/agent-src/rules/token-efficiency.md +7 -0
- package/dist/agent-src/skills/adversarial-review/SKILL.md +5 -0
- package/dist/agent-src/skills/ai-council/SKILL.md +3 -0
- package/dist/agent-src/skills/build-buy-partner/SKILL.md +1 -0
- package/dist/agent-src/skills/decision-record/SKILL.md +35 -1
- package/dist/agent-src/skills/decision-record/evals/triggers.json +82 -12
- package/dist/agent-src/skills/decision-record/references/weighted-matrix.md +119 -0
- package/dist/agent-src/skills/evaluate-llm-feature/SKILL.md +142 -0
- package/dist/agent-src/skills/judge-synthesis/SKILL.md +1 -0
- package/dist/agent-src/skills/learning-tutor/SKILL.md +215 -0
- package/dist/agent-src/skills/rice-prioritization/SKILL.md +1 -0
- package/dist/agent-src/skills/stakeholder-tradeoff/SKILL.md +3 -1
- package/dist/agent-src/skills/subagent-orchestration/SKILL.md +2 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +94 -24
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +13 -10
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +9 -6
- package/dist/mcp/registry-manifest.json +3 -3
- package/docs/CLAIMS.md +16 -2
- package/docs/announcements/2026-07-honest-cost-sweep-launch.md +84 -0
- package/docs/anti-reskin-gate.md +60 -0
- package/docs/architecture.md +2 -2
- package/docs/catalog.md +303 -278
- package/docs/command-flows.md +2 -1
- package/docs/comparison.yaml +10 -0
- package/docs/contracts/adoption-signal-floor.md +17 -0
- package/docs/contracts/ai-council-config.md +25 -0
- package/docs/contracts/mcp-tool-inventory.md +25 -22
- package/docs/contracts/tier-3-contrib-plugin.md +12 -4
- package/docs/featured-skills.md +2 -2
- package/docs/getting-started-by-role.md +1 -1
- package/docs/getting-started.md +1 -1
- package/docs/governance-advantage.md +1 -1
- package/docs/proof.md +10 -6
- package/docs/self-review-gate.md +14 -0
- package/docs/setup/per-ide/antigravity.md +8 -3
- package/docs/us-vs-the-category.md +38 -0
- package/docs/wedge/production-validator/README.md +24 -0
- package/docs/wedge/production-validator/first-run-check.sh +42 -0
- package/package.json +1 -1
- package/src/scripts/_lib/catalog_score.ts +64 -0
- package/src/scripts/_lib/shingle_similarity.ts +146 -0
- package/src/scripts/ai_council/clients.ts +50 -1
- package/src/scripts/ai_council/config.ts +28 -3
- package/src/scripts/ai_council/low_impact.ts +16 -0
- package/src/scripts/ai_council/necessity.ts +11 -0
- package/src/scripts/ai_council/orchestrator.ts +78 -26
- package/src/scripts/ai_council/pricing.ts +59 -0
- package/src/scripts/ai_council/stance_tally.ts +42 -5
- package/src/scripts/build_catalog_index.ts +188 -0
- package/src/scripts/council_cli.ts +56 -1
- package/src/scripts/generate_index.ts +6 -3
- package/src/scripts/lint_originality.ts +464 -0
- package/src/scripts/mcp_server/catalog_tools.ts +114 -0
- package/src/scripts/mcp_server/consumer_tool_catalog.json +46 -0
- package/src/scripts/schemas/flow.schema.json +17 -0
- package/src/scripts/schemas/skill.schema.json +166 -32
- package/src/scripts/self_review_gate.test.ts +48 -0
- package/src/scripts/self_review_gate.ts +73 -5
- package/src/scripts/validate_flow_teams.ts +127 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity-neutralized shingle-overlap primitive — the anti-reskin engine behind
|
|
3
|
+
* `lint_originality.ts`.
|
|
4
|
+
*
|
|
5
|
+
* Deterministic, no embeddings, no network. This is a DIFFERENT primitive from
|
|
6
|
+
* `text_similarity.ts` (token-set Jaccard, dedup) and `audit_skill_overlap.ts`
|
|
7
|
+
* (keyword-cosine, advisory) — both of which a find-replace re-skin
|
|
8
|
+
* ("Laravel" → "Symfony", "Vietnam" → "Korea") can defeat, because the swapped
|
|
9
|
+
* proper nouns shift the token/keyword vectors enough to drop the score.
|
|
10
|
+
*
|
|
11
|
+
* The defence is two-fold:
|
|
12
|
+
* 1. NEUTRALIZE entities — replace framework / vendor / language / region /
|
|
13
|
+
* currency proper nouns with a single placeholder BEFORE comparison, so a
|
|
14
|
+
* find-replace re-skin collapses to the original text.
|
|
15
|
+
* 2. SHINGLE — compare k-word shingles (default k = 8), not a bag of tokens,
|
|
16
|
+
* so re-skins that only swap scattered nouns still share long verbatim
|
|
17
|
+
* runs, and unrelated files that happen to share vocabulary do not.
|
|
18
|
+
*
|
|
19
|
+
* Overlap is CONTAINMENT (`|a∩b| / min(|a|,|b|)`), not Jaccard: a small file
|
|
20
|
+
* copied verbatim into a large padded one must still score high — the exact
|
|
21
|
+
* shape of an embedded re-skin.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately does NOT touch `text_similarity.ts` or `memory_signal.ts`
|
|
24
|
+
* (byte-faithful parity contracts). This module is a new, additional layer.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Proper-noun families that a find-replace re-skin swaps. Neutralized to a
|
|
29
|
+
* single placeholder so the swap does not move the score. Word-boundary
|
|
30
|
+
* matched, case-insensitive. **Extend via PR, never inline at a call site** —
|
|
31
|
+
* the list is the calibration surface, and a scattered entity list drifts.
|
|
32
|
+
*
|
|
33
|
+
* Seed the list from OUR corpus (the frameworks / vendors / regions the suite
|
|
34
|
+
* actually names), not from any external source.
|
|
35
|
+
*/
|
|
36
|
+
export const ENTITY_TERMS: readonly string[] = [
|
|
37
|
+
// Frameworks / libraries
|
|
38
|
+
'laravel', 'symfony', 'react', 'vue', 'preact', 'angular', 'svelte', 'nextjs',
|
|
39
|
+
'next', 'nuxt', 'livewire', 'flux', 'inertia', 'blade', 'tailwind', 'eloquent',
|
|
40
|
+
'doctrine', 'pest', 'phpunit', 'vitest', 'jest', 'playwright', 'express',
|
|
41
|
+
'fastify', 'django', 'flask', 'rails', 'spring',
|
|
42
|
+
// Languages
|
|
43
|
+
'php', 'typescript', 'javascript', 'python', 'ruby', 'golang', 'rust', 'java',
|
|
44
|
+
'kotlin', 'swift', 'csharp', 'elixir',
|
|
45
|
+
// Vendors / platforms / services
|
|
46
|
+
'aws', 'gcp', 'azure', 'hetzner', 'cloudflare', 'vercel', 'netlify', 'stripe',
|
|
47
|
+
'paddle', 'paypal', 'github', 'gitlab', 'bitbucket', 'linear', 'jira',
|
|
48
|
+
'confluence', 'slack', 'sentry', 'datadog', 'grafana', 'prometheus', 'redis',
|
|
49
|
+
'postgres', 'postgresql', 'mysql', 'mariadb', 'sqlite', 'mongodb', 'kafka',
|
|
50
|
+
'rabbitmq', 'terraform', 'terragrunt', 'kubernetes', 'docker', 'ansible',
|
|
51
|
+
'openai', 'anthropic', 'gemini', 'claude',
|
|
52
|
+
// Cloud regions / locales that re-skins swap
|
|
53
|
+
'us-east-1', 'us-west-2', 'eu-west-1', 'eu-central-1', 'ap-southeast-1',
|
|
54
|
+
// Currency codes / symbols
|
|
55
|
+
'usd', 'eur', 'gbp', 'chf', 'jpy',
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const _ENTITY_RE = new RegExp(
|
|
59
|
+
`\\b(?:${ENTITY_TERMS.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\b`,
|
|
60
|
+
'gi',
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
/** Placeholder every entity family collapses to. Distinct token so it shingles. */
|
|
64
|
+
export const ENTITY_PLACEHOLDER = 'entterm';
|
|
65
|
+
|
|
66
|
+
/** Word token — mirrors the upstream re-skin gate's word regex so a calibrated
|
|
67
|
+
* threshold ports across corpora. Digits, `+ . # _ -` kept (e.g. `c#`, `9.3.0`). */
|
|
68
|
+
const _WORD_RE = /[a-z0-9][a-z0-9+.#_-]*/g;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Lowercase, strip frontmatter + markdown syntax, then replace entity proper
|
|
72
|
+
* nouns with `ENTITY_PLACEHOLDER`. `extra` is an optional caller-supplied regex
|
|
73
|
+
* (global) of additional terms to neutralize for one call (e.g. a corpus-
|
|
74
|
+
* specific product name); it never mutates the shared list.
|
|
75
|
+
*/
|
|
76
|
+
export function neutralizeEntities(text: string, extra?: RegExp): string {
|
|
77
|
+
let t = text;
|
|
78
|
+
// Leading YAML frontmatter.
|
|
79
|
+
t = t.replace(/^---\n[\s\S]*?\n---\n?/, '');
|
|
80
|
+
// Fenced code blocks (``` or ~~~).
|
|
81
|
+
t = t.replace(/^([`~]{3,}).*$[\s\S]*?^\1[ \t]*$/gm, ' ');
|
|
82
|
+
// HTML comments.
|
|
83
|
+
t = t.replace(/<!--[\s\S]*?-->/g, ' ');
|
|
84
|
+
// Inline code spans.
|
|
85
|
+
t = t.replace(/`[^`]*`/g, ' ');
|
|
86
|
+
// Links / images: keep the visible label, drop the target.
|
|
87
|
+
t = t.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1');
|
|
88
|
+
t = t.toLowerCase();
|
|
89
|
+
// Heading / emphasis / list / blockquote / table punctuation → spaces, so
|
|
90
|
+
// the same prose scores identically regardless of markdown decoration.
|
|
91
|
+
t = t.replace(/[#*_>|~]+/g, ' ');
|
|
92
|
+
if (extra) {
|
|
93
|
+
t = t.replace(extra, ENTITY_PLACEHOLDER);
|
|
94
|
+
}
|
|
95
|
+
t = t.replace(_ENTITY_RE, ENTITY_PLACEHOLDER);
|
|
96
|
+
return t;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Ordered word tokens of the neutralized text (no set — shingles need order). */
|
|
100
|
+
export function words(text: string): string[] {
|
|
101
|
+
return text.match(_WORD_RE) ?? [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Word-level k-shingles over the neutralized token stream. A file with fewer
|
|
106
|
+
* than `k` tokens yields the empty set (guarded downstream → score 0, never
|
|
107
|
+
* NaN). Shingles join on a single space so they are comparable across files.
|
|
108
|
+
*/
|
|
109
|
+
export function shingles(text: string, k = 8): Set<string> {
|
|
110
|
+
const toks = words(neutralizeEntities(text));
|
|
111
|
+
const out = new Set<string>();
|
|
112
|
+
if (toks.length < k) {
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
for (let i = 0; i + k <= toks.length; i++) {
|
|
116
|
+
out.add(toks.slice(i, i + k).join(' '));
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Containment overlap in percent: `100 * |a∩b| / min(|a|,|b|)`.
|
|
123
|
+
*
|
|
124
|
+
* Containment (not Jaccard) so a small file copied verbatim into a large padded
|
|
125
|
+
* one still scores high. Two empty shingle sets → 0 (a file too short to
|
|
126
|
+
* shingle carries no originality signal — never flag it). One empty → 0.
|
|
127
|
+
*/
|
|
128
|
+
export function overlapPercent(a: Set<string>, b: Set<string>): number {
|
|
129
|
+
const min = Math.min(a.size, b.size);
|
|
130
|
+
if (min === 0) {
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
const [small, large] = a.size <= b.size ? [a, b] : [b, a];
|
|
134
|
+
let shared = 0;
|
|
135
|
+
for (const s of small) {
|
|
136
|
+
if (large.has(s)) {
|
|
137
|
+
shared++;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return (100 * shared) / min;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Convenience: shingle both texts and score. */
|
|
144
|
+
export function shingleOverlap(a: string, b: string, k = 8): number {
|
|
145
|
+
return overlapPercent(shingles(a, k), shingles(b, k));
|
|
146
|
+
}
|
|
@@ -281,6 +281,27 @@ export abstract class ExternalAIClient {
|
|
|
281
281
|
user_prompt: string,
|
|
282
282
|
max_tokens?: number,
|
|
283
283
|
): CouncilResponse;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Cache-aware variant (A3 cross-round read unlock): the caller splits the
|
|
287
|
+
* user prompt into a byte-stable prefix (system + artefact — identical
|
|
288
|
+
* across rounds) and a volatile suffix (per-round critiques, stance
|
|
289
|
+
* contract). Clients with native prompt-cache support override this and
|
|
290
|
+
* place the cache breakpoint on the stable block only, so round N+1 READS
|
|
291
|
+
* the prefix instead of re-writing it. Default: plain concatenation —
|
|
292
|
+
* byte-identical to `ask()` for every transport without cache support.
|
|
293
|
+
*/
|
|
294
|
+
ask_split(
|
|
295
|
+
system_prompt: string,
|
|
296
|
+
stable_prompt: string,
|
|
297
|
+
volatile_suffix: string,
|
|
298
|
+
max_tokens?: number,
|
|
299
|
+
): CouncilResponse {
|
|
300
|
+
const user_prompt = volatile_suffix
|
|
301
|
+
? `${stable_prompt}${volatile_suffix}`
|
|
302
|
+
: stable_prompt;
|
|
303
|
+
return this.ask(system_prompt, user_prompt, max_tokens);
|
|
304
|
+
}
|
|
284
305
|
}
|
|
285
306
|
|
|
286
307
|
/** Shared ctor-options shape for the API clients. */
|
|
@@ -395,6 +416,27 @@ export class AnthropicClient extends ExternalAIClient {
|
|
|
395
416
|
user_prompt: string,
|
|
396
417
|
max_tokens: number = DEFAULT_MAX_TOKENS,
|
|
397
418
|
): CouncilResponse {
|
|
419
|
+
return this._ask_impl(system_prompt, user_prompt, '', max_tokens);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
override ask_split(
|
|
423
|
+
system_prompt: string,
|
|
424
|
+
stable_prompt: string,
|
|
425
|
+
volatile_suffix: string,
|
|
426
|
+
max_tokens: number = DEFAULT_MAX_TOKENS,
|
|
427
|
+
): CouncilResponse {
|
|
428
|
+
return this._ask_impl(system_prompt, stable_prompt, volatile_suffix, max_tokens);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private _ask_impl(
|
|
432
|
+
system_prompt: string,
|
|
433
|
+
stable_prompt: string,
|
|
434
|
+
volatile_suffix: string,
|
|
435
|
+
max_tokens: number,
|
|
436
|
+
): CouncilResponse {
|
|
437
|
+
const user_prompt = volatile_suffix
|
|
438
|
+
? `${stable_prompt}${volatile_suffix}`
|
|
439
|
+
: stable_prompt;
|
|
398
440
|
const t0 = _nowMs();
|
|
399
441
|
let response: unknown;
|
|
400
442
|
try {
|
|
@@ -426,12 +468,19 @@ export class AnthropicClient extends ExternalAIClient {
|
|
|
426
468
|
messages: [
|
|
427
469
|
{
|
|
428
470
|
role: 'user',
|
|
471
|
+
// Breakpoint on the STABLE block only — the
|
|
472
|
+
// volatile suffix (round critiques) rides after
|
|
473
|
+
// it, so round N+1 reads the cached prefix
|
|
474
|
+
// instead of re-writing it (A3 read unlock).
|
|
429
475
|
content: [
|
|
430
476
|
{
|
|
431
477
|
type: 'text',
|
|
432
|
-
text:
|
|
478
|
+
text: stable_prompt,
|
|
433
479
|
cache_control: { type: 'ephemeral' },
|
|
434
480
|
},
|
|
481
|
+
...(volatile_suffix
|
|
482
|
+
? [{ type: 'text', text: volatile_suffix }]
|
|
483
|
+
: []),
|
|
435
484
|
],
|
|
436
485
|
},
|
|
437
486
|
],
|
|
@@ -437,6 +437,13 @@ export interface NecessityClassifierConfig {
|
|
|
437
437
|
export interface ModelDowngradeConfig {
|
|
438
438
|
readonly enabled: boolean;
|
|
439
439
|
readonly auto_apply: boolean;
|
|
440
|
+
/**
|
|
441
|
+
* Per-run escape hatch (roadmap A3): member name → model id. A member
|
|
442
|
+
* listed here is pinned to that model for the run — the size classifier
|
|
443
|
+
* and the cache-coupling gate are both skipped for it. Set it in
|
|
444
|
+
* `.ai-council.yml` when a specific artefact needs a specific tier.
|
|
445
|
+
*/
|
|
446
|
+
readonly model_tier_override: Readonly<Record<string, string>>;
|
|
440
447
|
}
|
|
441
448
|
|
|
442
449
|
/** Pre-flight cost-disclosure toggle (Phase 8). */
|
|
@@ -908,11 +915,27 @@ function _build_model_downgrade(d: Dict): ModelDowngradeConfig {
|
|
|
908
915
|
if (!_isBool(enabled)) {
|
|
909
916
|
throw new CouncilConfigError('`model_downgrade.enabled` must be a bool.');
|
|
910
917
|
}
|
|
911
|
-
|
|
918
|
+
// A3 (2026-07-20): auto_apply default flipped to TRUE — auto-downgrade is
|
|
919
|
+
// the default and `auto_apply: false` (or `enabled: false`) is the opt-out.
|
|
920
|
+
// The cache-coupling gate in council_cli._size_fit_gate keeps it honest.
|
|
921
|
+
const auto_apply = _get(d, 'auto_apply', true);
|
|
912
922
|
if (!_isBool(auto_apply)) {
|
|
913
923
|
throw new CouncilConfigError('`model_downgrade.auto_apply` must be a bool.');
|
|
914
924
|
}
|
|
915
|
-
|
|
925
|
+
const override_raw = _get(d, 'model_tier_override', {});
|
|
926
|
+
if (!_isDict(override_raw)) {
|
|
927
|
+
throw new CouncilConfigError('`model_downgrade.model_tier_override` must be a mapping.');
|
|
928
|
+
}
|
|
929
|
+
const model_tier_override: Record<string, string> = {};
|
|
930
|
+
for (const [k, v] of Object.entries(override_raw as Dict)) {
|
|
931
|
+
if (!_isStr(v) || (v as string).length === 0) {
|
|
932
|
+
throw new CouncilConfigError(
|
|
933
|
+
`\`model_downgrade.model_tier_override.${k}\` must be a non-empty string model id.`,
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
model_tier_override[k] = v as string;
|
|
937
|
+
}
|
|
938
|
+
return { enabled: Boolean(enabled), auto_apply: Boolean(auto_apply), model_tier_override };
|
|
916
939
|
}
|
|
917
940
|
|
|
918
941
|
function _build_cost_disclosure(d: Dict, scope: string): CostDisclosureConfig {
|
|
@@ -1308,7 +1331,8 @@ function _build_lens_overrides(d: Dict): LensOverridesConfig {
|
|
|
1308
1331
|
`\`lenses.${lens_name}.model_downgrade.enabled\` must be a bool.`,
|
|
1309
1332
|
);
|
|
1310
1333
|
}
|
|
1311
|
-
|
|
1334
|
+
// Lens overrides inherit the A3 auto-by-default posture.
|
|
1335
|
+
const md_auto = _get(md_block, 'auto_apply', true);
|
|
1312
1336
|
if (!_isBool(md_auto)) {
|
|
1313
1337
|
throw new CouncilConfigError(
|
|
1314
1338
|
`\`lenses.${lens_name}.model_downgrade.auto_apply\` must be a bool.`,
|
|
@@ -1317,6 +1341,7 @@ function _build_lens_overrides(d: Dict): LensOverridesConfig {
|
|
|
1317
1341
|
md_overrides.set(lens_name, {
|
|
1318
1342
|
enabled: Boolean(md_enabled),
|
|
1319
1343
|
auto_apply: Boolean(md_auto),
|
|
1344
|
+
model_tier_override: {},
|
|
1320
1345
|
});
|
|
1321
1346
|
}
|
|
1322
1347
|
const cd_block = lens_cfg.cost_disclosure;
|
|
@@ -598,6 +598,10 @@ export function resolve_low_impact(
|
|
|
598
598
|
clients: ReadonlyMap<string, ExternalAIClient> | Record<string, ExternalAIClient>,
|
|
599
599
|
price_table: unknown = null,
|
|
600
600
|
now: (() => Date) | null = null,
|
|
601
|
+
model_downgrade: {
|
|
602
|
+
enabled: boolean;
|
|
603
|
+
model_tier_override?: Readonly<Record<string, string>>;
|
|
604
|
+
} | null = null,
|
|
601
605
|
): FastPathResolution {
|
|
602
606
|
if (!plan.is_resolvable) {
|
|
603
607
|
return new FastPathResolution({
|
|
@@ -618,6 +622,18 @@ export function resolve_low_impact(
|
|
|
618
622
|
|
|
619
623
|
for (const member of plan.members) {
|
|
620
624
|
const client = getClient(member.name);
|
|
625
|
+
// A3: the fast-path is one-shot (no same-model cache reads to lose),
|
|
626
|
+
// so a configured ladder downgrades to its CHEAPEST rung outright —
|
|
627
|
+
// low-impact questions are the definition of small/low-stakes. The
|
|
628
|
+
// per-run model_tier_override pin wins; enabled: false opts out.
|
|
629
|
+
if (client && model_downgrade?.enabled) {
|
|
630
|
+
const pinned = model_downgrade.model_tier_override?.[member.name];
|
|
631
|
+
const cheapest = member.model_ladder.length > 0 ? member.model_ladder[0] : null;
|
|
632
|
+
const target = pinned && pinned.length > 0 ? pinned : cheapest;
|
|
633
|
+
if (target && target !== client.model) {
|
|
634
|
+
client.model = target;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
621
637
|
if (client === undefined || client === null) {
|
|
622
638
|
answers.push(
|
|
623
639
|
new MemberAnswer({
|
|
@@ -402,6 +402,17 @@ function _length_tier(text: string): LengthTier {
|
|
|
402
402
|
* @param lens active lens; `debate` lens disables downgrade suggestions to
|
|
403
403
|
* keep dissent quality high.
|
|
404
404
|
* @returns A {@link SizeFitVerdict}.
|
|
405
|
+
*
|
|
406
|
+
* SIZE DEFINITION (A3 precondition, verified 2026-07-20): "small" is measured
|
|
407
|
+
* in Unicode code points (`_pyLen`, Python-`len()` parity) against the
|
|
408
|
+
* character thresholds `_SHORT_PROMPT_MAX`/`_MEDIUM_PROMPT_MAX` (200/800) plus
|
|
409
|
+
* a complexity-trigger heuristic — NOT bytes and NOT tokens. A token-based
|
|
410
|
+
* classifier was considered and rejected: it would add a tokenizer dependency
|
|
411
|
+
* (or a paid pre-flight count_tokens call) on the sequential-dispatch path
|
|
412
|
+
* (orchestrator.ts § "Members are called sequentially"), where every
|
|
413
|
+
* per-member pre-flight adds wall-clock latency before the first real call.
|
|
414
|
+
* The chars/4 heuristic in `pricing.estimate_input_tokens` remains the shared
|
|
415
|
+
* token approximation where cost math needs one.
|
|
405
416
|
*/
|
|
406
417
|
export function classify_size_fit(
|
|
407
418
|
prompt: string,
|
|
@@ -484,7 +484,10 @@ export function consult(
|
|
|
484
484
|
|
|
485
485
|
const spent: Spent = { input: 0, output: 0, usd: 0.0 };
|
|
486
486
|
let last_results: CouncilResponse[] = [];
|
|
487
|
-
|
|
487
|
+
// A3 read unlock: the stable prefix is ALWAYS the original user prompt;
|
|
488
|
+
// per-round critiques + the STANCE contract ride in a volatile suffix so
|
|
489
|
+
// cache-capable clients read the [system + artefact] prefix on round N+1.
|
|
490
|
+
let current_suffix = '';
|
|
488
491
|
|
|
489
492
|
const stance_tally = opts.stance_tally ?? false;
|
|
490
493
|
for (let round_idx = 0; round_idx < rounds; round_idx++) {
|
|
@@ -492,11 +495,11 @@ export function consult(
|
|
|
492
495
|
// contract when the tally is enabled. Off (default) → the original
|
|
493
496
|
// question object flows through untouched, byte-identical to today.
|
|
494
497
|
const is_final = round_idx === rounds - 1;
|
|
495
|
-
const
|
|
496
|
-
const prompt_for_round =
|
|
498
|
+
const suffix_for_round =
|
|
497
499
|
is_final && stance_tally
|
|
498
|
-
? `${
|
|
499
|
-
:
|
|
500
|
+
? `${current_suffix}\n\n---\n\n${STANCE_LINE_CONTRACT}`
|
|
501
|
+
: current_suffix;
|
|
502
|
+
const prompt_for_round = `${question.user_prompt}${suffix_for_round}`;
|
|
500
503
|
const round_question =
|
|
501
504
|
round_idx === 0 && prompt_for_round === question.user_prompt
|
|
502
505
|
? question
|
|
@@ -511,16 +514,15 @@ export function consult(
|
|
|
511
514
|
project,
|
|
512
515
|
original_ask,
|
|
513
516
|
advisor_plans,
|
|
517
|
+
split: suffix_for_round
|
|
518
|
+
? { stable: question.user_prompt, suffix: suffix_for_round }
|
|
519
|
+
: null,
|
|
514
520
|
});
|
|
515
521
|
if (on_round_complete !== null) {
|
|
516
522
|
on_round_complete(round_idx, last_results);
|
|
517
523
|
}
|
|
518
524
|
if (round_idx + 1 < rounds) {
|
|
519
|
-
|
|
520
|
-
question.user_prompt,
|
|
521
|
-
last_results,
|
|
522
|
-
round_idx + 2,
|
|
523
|
-
);
|
|
525
|
+
current_suffix = _critique_suffix(last_results, round_idx + 2);
|
|
524
526
|
}
|
|
525
527
|
}
|
|
526
528
|
|
|
@@ -579,6 +581,14 @@ interface RunRoundOptions {
|
|
|
579
581
|
project: ProjectContext | null;
|
|
580
582
|
original_ask: string;
|
|
581
583
|
advisor_plans?: Map<string, AdvisorPlan> | null;
|
|
584
|
+
/**
|
|
585
|
+
* A3 cross-round read unlock: when set, members are called via
|
|
586
|
+
* `ask_split(system, stable, suffix)` so cache-capable clients keep the
|
|
587
|
+
* breakpoint on the byte-stable [system + artefact] prefix and the
|
|
588
|
+
* per-round critiques ride behind it. `question.user_prompt` stays the
|
|
589
|
+
* full concatenation (estimates + persistence unchanged).
|
|
590
|
+
*/
|
|
591
|
+
split?: { stable: string; suffix: string } | null;
|
|
582
592
|
}
|
|
583
593
|
|
|
584
594
|
/** Run a single round; mutate `spent` with cumulative totals. */
|
|
@@ -626,11 +636,18 @@ function _run_round(
|
|
|
626
636
|
if (!(_getattr(member, 'billable', true) as boolean)) {
|
|
627
637
|
let response: CouncilResponse;
|
|
628
638
|
try {
|
|
629
|
-
response =
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
639
|
+
response = opts.split
|
|
640
|
+
? member.ask_split(
|
|
641
|
+
_system_prompt_for_member(member),
|
|
642
|
+
opts.split.stable,
|
|
643
|
+
opts.split.suffix,
|
|
644
|
+
question.max_tokens,
|
|
645
|
+
)
|
|
646
|
+
: member.ask(
|
|
647
|
+
_system_prompt_for_member(member),
|
|
648
|
+
question.user_prompt,
|
|
649
|
+
question.max_tokens,
|
|
650
|
+
);
|
|
634
651
|
} catch (exc) {
|
|
635
652
|
response = new CouncilResponse({
|
|
636
653
|
provider: member.name,
|
|
@@ -702,11 +719,18 @@ function _run_round(
|
|
|
702
719
|
// ── actual call ──────────────────────────────────────────────
|
|
703
720
|
let response: CouncilResponse;
|
|
704
721
|
try {
|
|
705
|
-
response =
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
722
|
+
response = opts.split
|
|
723
|
+
? member.ask_split(
|
|
724
|
+
_system_prompt_for_member(member),
|
|
725
|
+
opts.split.stable,
|
|
726
|
+
opts.split.suffix,
|
|
727
|
+
question.max_tokens,
|
|
728
|
+
)
|
|
729
|
+
: member.ask(
|
|
730
|
+
_system_prompt_for_member(member),
|
|
731
|
+
question.user_prompt,
|
|
732
|
+
question.max_tokens,
|
|
733
|
+
);
|
|
710
734
|
} catch (exc) {
|
|
711
735
|
response = new CouncilResponse({
|
|
712
736
|
provider: member.name,
|
|
@@ -796,6 +820,19 @@ function _augment_for_next_round(
|
|
|
796
820
|
original_prompt: string,
|
|
797
821
|
prior_responses: CouncilResponse[],
|
|
798
822
|
next_round_number: number,
|
|
823
|
+
): string {
|
|
824
|
+
return `${original_prompt}${_critique_suffix(prior_responses, next_round_number)}`;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* The volatile per-round block ONLY (A3 read unlock) — everything after the
|
|
829
|
+
* byte-stable original prompt. Empty string when no prior response survives.
|
|
830
|
+
* `_augment_for_next_round` = original + this; the rounds loop feeds this to
|
|
831
|
+
* `ask_split` so the cached [system + artefact] prefix stays byte-identical.
|
|
832
|
+
*/
|
|
833
|
+
function _critique_suffix(
|
|
834
|
+
prior_responses: CouncilResponse[],
|
|
835
|
+
next_round_number: number,
|
|
799
836
|
): string {
|
|
800
837
|
const blocks: string[] = [];
|
|
801
838
|
let label_idx = 0;
|
|
@@ -808,11 +845,11 @@ function _augment_for_next_round(
|
|
|
808
845
|
blocks.push(`### Reviewer ${label}\n\n${_pyStrip(r.text)}`);
|
|
809
846
|
}
|
|
810
847
|
if (blocks.length === 0) {
|
|
811
|
-
return
|
|
848
|
+
return '';
|
|
812
849
|
}
|
|
813
850
|
const prior_block = blocks.join('\n\n');
|
|
814
851
|
return (
|
|
815
|
-
|
|
852
|
+
`\n\n` +
|
|
816
853
|
`---\n\n` +
|
|
817
854
|
`## Prior round critiques (round ${next_round_number - 1})\n\n` +
|
|
818
855
|
`You are now in round ${next_round_number}. Below are anonymised\n` +
|
|
@@ -910,6 +947,15 @@ function _augment_for_debate_round(
|
|
|
910
947
|
// Phase 3: when the debate gates are enabled, prepend the anti-conformity
|
|
911
948
|
// directive. Default `false` keeps the prompt byte-identical to today.
|
|
912
949
|
anti_conformity = false,
|
|
950
|
+
): string {
|
|
951
|
+
return `${original_prompt}${_debate_suffix(prior_responses, next_round_number, anti_conformity)}`;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** Debate twin of `_critique_suffix` — the volatile block only (A3). */
|
|
955
|
+
function _debate_suffix(
|
|
956
|
+
prior_responses: CouncilResponse[],
|
|
957
|
+
next_round_number: number,
|
|
958
|
+
anti_conformity = false,
|
|
913
959
|
): string {
|
|
914
960
|
const blocks: string[] = [];
|
|
915
961
|
let label_idx = 0;
|
|
@@ -922,12 +968,12 @@ function _augment_for_debate_round(
|
|
|
922
968
|
blocks.push(`### Reviewer ${label}\n\n${_pyStrip(r.text)}`);
|
|
923
969
|
}
|
|
924
970
|
if (blocks.length === 0) {
|
|
925
|
-
return
|
|
971
|
+
return '';
|
|
926
972
|
}
|
|
927
973
|
const prior_block = blocks.join('\n\n');
|
|
928
974
|
const anti_conformity_block = anti_conformity ? `${ANTI_CONFORMITY_DIRECTIVE}\n\n` : '';
|
|
929
975
|
return (
|
|
930
|
-
|
|
976
|
+
`\n\n` +
|
|
931
977
|
`---\n\n` +
|
|
932
978
|
`## Prior round positions (round ${next_round_number - 1})\n\n` +
|
|
933
979
|
`You are now in round ${next_round_number} of a structured\n` +
|
|
@@ -1122,6 +1168,9 @@ export function run_debate(
|
|
|
1122
1168
|
|
|
1123
1169
|
const spent: Spent = { input: 0, output: 0, usd: 0.0 };
|
|
1124
1170
|
const all_rounds: CouncilResponse[][] = [];
|
|
1171
|
+
// A3 read unlock (debate twin): stable prefix = the original prompt; the
|
|
1172
|
+
// per-round positions block rides in a volatile suffix (see consult loop).
|
|
1173
|
+
let current_suffix = '';
|
|
1125
1174
|
let current_user_prompt = question.user_prompt;
|
|
1126
1175
|
|
|
1127
1176
|
// Phase 3 restate: one pre-round-1 call per member. Runs BEFORE any debate
|
|
@@ -1167,6 +1216,9 @@ export function run_debate(
|
|
|
1167
1216
|
project,
|
|
1168
1217
|
original_ask,
|
|
1169
1218
|
advisor_plans,
|
|
1219
|
+
split: current_suffix
|
|
1220
|
+
? { stable: question.user_prompt, suffix: current_suffix }
|
|
1221
|
+
: null,
|
|
1170
1222
|
});
|
|
1171
1223
|
}
|
|
1172
1224
|
|
|
@@ -1195,12 +1247,12 @@ export function run_debate(
|
|
|
1195
1247
|
// Prep the user-prompt for the next round so the cost estimate
|
|
1196
1248
|
// below covers the augmented bytes.
|
|
1197
1249
|
if (round_idx + 1 < max_rounds) {
|
|
1198
|
-
|
|
1199
|
-
question.user_prompt,
|
|
1250
|
+
current_suffix = _debate_suffix(
|
|
1200
1251
|
results,
|
|
1201
1252
|
round_number + 1,
|
|
1202
1253
|
opts.debate_gates ?? false,
|
|
1203
1254
|
);
|
|
1255
|
+
current_user_prompt = `${question.user_prompt}${current_suffix}`;
|
|
1204
1256
|
// Hard-cap + continue-prompt gating before kicking off N+1.
|
|
1205
1257
|
let next_round_usd: number;
|
|
1206
1258
|
if (table !== null) {
|
|
@@ -119,6 +119,65 @@ export interface CacheTokenBreakdown {
|
|
|
119
119
|
output_tokens: number;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/** A1↔A3 coupling verdict for a proposed model downgrade (Phase A3). */
|
|
123
|
+
export interface DowngradeCoupling {
|
|
124
|
+
/** Full-rate cost delta per call: estimate(current) − estimate(suggested). */
|
|
125
|
+
downgrade_savings_usd: number;
|
|
126
|
+
/**
|
|
127
|
+
* Cache value forfeited by leaving the current model: the prompt cache is
|
|
128
|
+
* model-scoped, so a downgraded member re-pays the full input rate on the
|
|
129
|
+
* span that would have been read back at CACHE_READ_MULTIPLIER.
|
|
130
|
+
*/
|
|
131
|
+
lost_cache_savings_usd: number;
|
|
132
|
+
/** True iff downgrade_savings_usd > lost_cache_savings_usd. */
|
|
133
|
+
net_positive: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Decide whether a suggested downgrade is net-positive AFTER accounting for
|
|
138
|
+
* the model-scoped prompt cache (roadmap A3 gate: downgrade only when
|
|
139
|
+
* `downgrade_savings > lost_cache_savings`).
|
|
140
|
+
*
|
|
141
|
+
* `cache.expected_reads` is the number of same-model cache reads the run
|
|
142
|
+
* would plausibly realize at the CURRENT model (rounds − 1 on multi-round
|
|
143
|
+
* runs, 0 on one-shot paths like the low-impact fast-path). With caching
|
|
144
|
+
* disabled the lost term is 0 and any cheaper model wins.
|
|
145
|
+
*/
|
|
146
|
+
export function downgrade_coupling(
|
|
147
|
+
provider: string,
|
|
148
|
+
current_model: string,
|
|
149
|
+
suggested_model: string,
|
|
150
|
+
input_tokens: number,
|
|
151
|
+
max_output_tokens: number,
|
|
152
|
+
cache: {
|
|
153
|
+
enabled: boolean;
|
|
154
|
+
cacheable_prefix_tokens: number;
|
|
155
|
+
expected_reads: number;
|
|
156
|
+
},
|
|
157
|
+
table: PriceTable,
|
|
158
|
+
): DowngradeCoupling {
|
|
159
|
+
const cur = estimate_cost(provider, current_model, input_tokens, max_output_tokens, table);
|
|
160
|
+
const sug = estimate_cost(provider, suggested_model, input_tokens, max_output_tokens, table);
|
|
161
|
+
const downgrade_savings_usd =
|
|
162
|
+
cur.input_usd + cur.output_usd - (sug.input_usd + sug.output_usd);
|
|
163
|
+
let lost_cache_savings_usd = 0;
|
|
164
|
+
if (cache.enabled && cache.expected_reads > 0 && cache.cacheable_prefix_tokens > 0) {
|
|
165
|
+
const cur_rates = lookup(table, provider, current_model);
|
|
166
|
+
if (cur_rates !== null) {
|
|
167
|
+
lost_cache_savings_usd =
|
|
168
|
+
(cache.cacheable_prefix_tokens / 1_000_000) *
|
|
169
|
+
cur_rates.input_per_1m_usd *
|
|
170
|
+
(1 - CACHE_READ_MULTIPLIER) *
|
|
171
|
+
cache.expected_reads;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
downgrade_savings_usd,
|
|
176
|
+
lost_cache_savings_usd,
|
|
177
|
+
net_positive: downgrade_savings_usd > lost_cache_savings_usd,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
122
181
|
/**
|
|
123
182
|
* Realized cost of a call given OBSERVED cache-token counts (from
|
|
124
183
|
* `CouncilResponse.usage`). Deliberately separate from `estimate_cost`, which
|
|
@@ -59,6 +59,37 @@ export interface StanceTallyResult {
|
|
|
59
59
|
const _STANCE_RE =
|
|
60
60
|
/STANCE:\s*(.+?)\s*\|\s*CONFIDENCE:\s*(high|med(?:ium)?|low)\s*\|\s*DEALBREAKER:\s*(yes|no)/gi;
|
|
61
61
|
|
|
62
|
+
|
|
63
|
+
/** Run the strict stance grammar; return the LAST match or null. */
|
|
64
|
+
function _last_stance_match(text: string): RegExpExecArray | null {
|
|
65
|
+
_STANCE_RE.lastIndex = 0;
|
|
66
|
+
let last: RegExpExecArray | null = null;
|
|
67
|
+
let m: RegExpExecArray | null;
|
|
68
|
+
while ((m = _STANCE_RE.exec(text)) !== null) {
|
|
69
|
+
last = m;
|
|
70
|
+
}
|
|
71
|
+
return last;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Normalize the two cosmetic defect classes the lenient pass forgives, on
|
|
76
|
+
* lines that mention STANCE only: markdown emphasis characters and comma /
|
|
77
|
+
* semicolon field separators. Everything else is left byte-identical.
|
|
78
|
+
*/
|
|
79
|
+
export function _normalize_stance_cosmetics(text: string): string {
|
|
80
|
+
return text
|
|
81
|
+
.split('\n')
|
|
82
|
+
.map((line) => {
|
|
83
|
+
if (!/stance/i.test(line)) {
|
|
84
|
+
return line;
|
|
85
|
+
}
|
|
86
|
+
let out = line.replace(/[*_`]/g, '');
|
|
87
|
+
out = out.replace(/\s*[;,]\s*(?=(?:CONFIDENCE|DEALBREAKER)\b)/gi, ' | ');
|
|
88
|
+
return out;
|
|
89
|
+
})
|
|
90
|
+
.join('\n');
|
|
91
|
+
}
|
|
92
|
+
|
|
62
93
|
/**
|
|
63
94
|
* Parse the member's stance line from their reply text. Returns the LAST stance
|
|
64
95
|
* line if several appear (the final, most-authoritative one), or `null` when no
|
|
@@ -66,11 +97,17 @@ const _STANCE_RE =
|
|
|
66
97
|
* infers a stance from the surrounding prose.
|
|
67
98
|
*/
|
|
68
99
|
export function parse_stance_line(text: string): Omit<StanceLine, 'member'> | null {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
100
|
+
let last = _last_stance_match(text);
|
|
101
|
+
if (last === null) {
|
|
102
|
+
// Lenient fallback (A3 repair-tightening): a stance whose only defect
|
|
103
|
+
// is cosmetic — markdown emphasis around the field names (`**STANCE:**`),
|
|
104
|
+
// or `,`/`;` used where the contract says `|` — is NOT "genuinely
|
|
105
|
+
// unparseable" and must not burn a repair call. Normalize those two
|
|
106
|
+
// defect classes on stance-bearing lines only, then re-run the SAME
|
|
107
|
+
// strict grammar. Anything the strict grammar still rejects (missing
|
|
108
|
+
// fields, invalid enum values, no STANCE line at all) stays a repair
|
|
109
|
+
// marker — leniency never invents a stance from prose.
|
|
110
|
+
last = _last_stance_match(_normalize_stance_cosmetics(text));
|
|
74
111
|
}
|
|
75
112
|
if (last === null) {
|
|
76
113
|
return null;
|