@event4u/agent-config 9.3.0 → 9.5.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 (74) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +92 -0
  3. package/CONTRIBUTING.md +14 -0
  4. package/README.md +12 -4
  5. package/dist/agent-src/commands/contribution-precheck.md +100 -0
  6. package/dist/agent-src/commands/team.md +4 -0
  7. package/dist/agent-src/contexts/model-recommendations.md +6 -0
  8. package/dist/agent-src/rules/token-efficiency.md +7 -0
  9. package/dist/agent-src/skills/adversarial-review/SKILL.md +5 -0
  10. package/dist/agent-src/skills/ai-council/SKILL.md +3 -0
  11. package/dist/agent-src/skills/build-buy-partner/SKILL.md +1 -0
  12. package/dist/agent-src/skills/decision-record/SKILL.md +35 -1
  13. package/dist/agent-src/skills/decision-record/evals/triggers.json +82 -12
  14. package/dist/agent-src/skills/decision-record/references/weighted-matrix.md +119 -0
  15. package/dist/agent-src/skills/evaluate-llm-feature/SKILL.md +142 -0
  16. package/dist/agent-src/skills/judge-synthesis/SKILL.md +1 -0
  17. package/dist/agent-src/skills/learning-tutor/SKILL.md +215 -0
  18. package/dist/agent-src/skills/rice-prioritization/SKILL.md +1 -0
  19. package/dist/agent-src/skills/stakeholder-tradeoff/SKILL.md +3 -1
  20. package/dist/agent-src/skills/subagent-orchestration/SKILL.md +2 -1
  21. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  22. package/dist/discovery/deprecation-report.md +1 -1
  23. package/dist/discovery/discovery-manifest.json +94 -24
  24. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  25. package/dist/discovery/discovery-manifest.summary.md +4 -4
  26. package/dist/discovery/orphan-report.md +1 -1
  27. package/dist/discovery/packs.json +13 -10
  28. package/dist/discovery/trust-report.md +3 -3
  29. package/dist/discovery/workspaces.json +9 -6
  30. package/dist/mcp/registry-manifest.json +3 -3
  31. package/docs/CLAIMS.md +23 -2
  32. package/docs/announcements/2026-07-honest-cost-sweep-launch.md +84 -0
  33. package/docs/anti-reskin-gate.md +60 -0
  34. package/docs/architecture.md +2 -2
  35. package/docs/benchmark.md +40 -0
  36. package/docs/catalog.md +303 -278
  37. package/docs/command-flows.md +2 -1
  38. package/docs/comparison.yaml +10 -0
  39. package/docs/contracts/adoption-signal-floor.md +17 -0
  40. package/docs/contracts/ai-council-config.md +25 -0
  41. package/docs/contracts/mcp-tool-inventory.md +25 -22
  42. package/docs/contracts/tier-3-contrib-plugin.md +12 -4
  43. package/docs/featured-skills.md +2 -2
  44. package/docs/getting-started-by-role.md +1 -1
  45. package/docs/getting-started.md +1 -1
  46. package/docs/governance-advantage.md +1 -1
  47. package/docs/proof.md +11 -6
  48. package/docs/self-review-gate.md +14 -0
  49. package/docs/setup/per-ide/antigravity.md +8 -3
  50. package/docs/us-vs-the-category.md +38 -0
  51. package/docs/wedge/production-validator/README.md +24 -0
  52. package/docs/wedge/production-validator/first-run-check.sh +42 -0
  53. package/package.json +1 -1
  54. package/src/scripts/_lib/catalog_score.ts +64 -0
  55. package/src/scripts/_lib/shingle_similarity.ts +146 -0
  56. package/src/scripts/ai_council/clients.ts +50 -1
  57. package/src/scripts/ai_council/config.ts +28 -3
  58. package/src/scripts/ai_council/low_impact.ts +16 -0
  59. package/src/scripts/ai_council/necessity.ts +11 -0
  60. package/src/scripts/ai_council/orchestrator.ts +78 -26
  61. package/src/scripts/ai_council/pricing.ts +59 -0
  62. package/src/scripts/ai_council/stance_tally.ts +42 -5
  63. package/src/scripts/bench_defect_finding.ts +348 -0
  64. package/src/scripts/build_catalog_index.ts +188 -0
  65. package/src/scripts/council_cli.ts +56 -1
  66. package/src/scripts/generate_index.ts +6 -3
  67. package/src/scripts/lint_originality.ts +464 -0
  68. package/src/scripts/mcp_server/catalog_tools.ts +114 -0
  69. package/src/scripts/mcp_server/consumer_tool_catalog.json +46 -0
  70. package/src/scripts/schemas/flow.schema.json +17 -0
  71. package/src/scripts/schemas/skill.schema.json +166 -32
  72. package/src/scripts/self_review_gate.test.ts +48 -0
  73. package/src/scripts/self_review_gate.ts +73 -5
  74. 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: user_prompt,
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
- const auto_apply = _get(d, 'auto_apply', false);
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
- return { enabled: Boolean(enabled), auto_apply: Boolean(auto_apply) };
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
- const md_auto = _get(md_block, 'auto_apply', false);
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
- let current_user_prompt = question.user_prompt;
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 base_prompt = round_idx === 0 ? question.user_prompt : current_user_prompt;
496
- const prompt_for_round =
498
+ const suffix_for_round =
497
499
  is_final && stance_tally
498
- ? `${base_prompt}\n\n---\n\n${STANCE_LINE_CONTRACT}`
499
- : base_prompt;
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
- current_user_prompt = _augment_for_next_round(
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 = member.ask(
630
- _system_prompt_for_member(member),
631
- question.user_prompt,
632
- question.max_tokens,
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 = member.ask(
706
- _system_prompt_for_member(member),
707
- question.user_prompt,
708
- question.max_tokens,
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 original_prompt;
848
+ return '';
812
849
  }
813
850
  const prior_block = blocks.join('\n\n');
814
851
  return (
815
- `${original_prompt}\n\n` +
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 original_prompt;
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
- `${original_prompt}\n\n` +
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
- current_user_prompt = _augment_for_debate_round(
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
- _STANCE_RE.lastIndex = 0;
70
- let last: RegExpExecArray | null = null;
71
- let m: RegExpExecArray | null;
72
- while ((m = _STANCE_RE.exec(text)) !== null) {
73
- last = m;
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;