@event4u/agent-config 9.2.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.
Files changed (93) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +111 -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/fix/route.md +49 -0
  7. package/dist/agent-src/commands/fix.md +4 -2
  8. package/dist/agent-src/commands/tdd/green.md +32 -0
  9. package/dist/agent-src/commands/tdd/red.md +33 -0
  10. package/dist/agent-src/commands/tdd/refactor.md +31 -0
  11. package/dist/agent-src/commands/tdd.md +50 -0
  12. package/dist/agent-src/commands/team.md +4 -0
  13. package/dist/agent-src/contexts/communication/rules-auto/guidelines-mechanics.md +1 -0
  14. package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +81 -2
  15. package/dist/agent-src/contexts/model-recommendations.md +6 -0
  16. package/dist/agent-src/rules/architecture.md +7 -0
  17. package/dist/agent-src/rules/token-efficiency.md +7 -0
  18. package/dist/agent-src/skills/adversarial-review/SKILL.md +5 -0
  19. package/dist/agent-src/skills/ai-council/SKILL.md +3 -0
  20. package/dist/agent-src/skills/build-buy-partner/SKILL.md +1 -0
  21. package/dist/agent-src/skills/decision-record/SKILL.md +35 -1
  22. package/dist/agent-src/skills/decision-record/evals/triggers.json +82 -12
  23. package/dist/agent-src/skills/decision-record/references/weighted-matrix.md +119 -0
  24. package/dist/agent-src/skills/evaluate-llm-feature/SKILL.md +142 -0
  25. package/dist/agent-src/skills/fe-design/SKILL.md +1 -0
  26. package/dist/agent-src/skills/judge-code-quality/SKILL.md +1 -0
  27. package/dist/agent-src/skills/judge-synthesis/SKILL.md +1 -0
  28. package/dist/agent-src/skills/learning-tutor/SKILL.md +215 -0
  29. package/dist/agent-src/skills/persona-improvement/SKILL.md +81 -0
  30. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +10 -0
  31. package/dist/agent-src/skills/rice-prioritization/SKILL.md +1 -0
  32. package/dist/agent-src/skills/stakeholder-tradeoff/SKILL.md +3 -1
  33. package/dist/agent-src/skills/subagent-orchestration/SKILL.md +14 -24
  34. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  35. package/dist/discovery/deprecation-report.md +1 -1
  36. package/dist/discovery/discovery-manifest.json +253 -30
  37. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  38. package/dist/discovery/discovery-manifest.summary.md +4 -4
  39. package/dist/discovery/orphan-report.md +1 -1
  40. package/dist/discovery/packs.json +19 -10
  41. package/dist/discovery/trust-report.md +3 -3
  42. package/dist/discovery/workspaces.json +15 -6
  43. package/dist/mcp/registry-manifest.json +3 -3
  44. package/docs/CLAIMS.md +16 -2
  45. package/docs/announcements/2026-07-honest-cost-sweep-launch.md +84 -0
  46. package/docs/anti-reskin-gate.md +60 -0
  47. package/docs/architecture.md +3 -3
  48. package/docs/catalog.md +303 -278
  49. package/docs/command-flows.md +10 -3
  50. package/docs/comparison.yaml +10 -0
  51. package/docs/contracts/adoption-signal-floor.md +17 -0
  52. package/docs/contracts/ai-council-config.md +25 -0
  53. package/docs/contracts/command-clusters.md +2 -1
  54. package/docs/contracts/mcp-tool-inventory.md +25 -22
  55. package/docs/contracts/tier-3-contrib-plugin.md +12 -4
  56. package/docs/decisions/ADR-122-adversarial-verification-council.md +1 -1
  57. package/docs/development.md +10 -1
  58. package/docs/featured-skills.md +2 -2
  59. package/docs/getting-started-by-role.md +1 -1
  60. package/docs/getting-started.md +1 -1
  61. package/docs/governance-advantage.md +1 -1
  62. package/docs/guidelines/component-oriented-and-oop-development.md +181 -0
  63. package/docs/proof.md +10 -6
  64. package/docs/self-review-gate.md +14 -0
  65. package/docs/setup/per-ide/antigravity.md +8 -3
  66. package/docs/us-vs-the-category.md +38 -0
  67. package/docs/wedge/production-validator/README.md +24 -0
  68. package/docs/wedge/production-validator/first-run-check.sh +42 -0
  69. package/package.json +3 -2
  70. package/src/scripts/_lib/catalog_score.ts +64 -0
  71. package/src/scripts/_lib/shingle_similarity.ts +146 -0
  72. package/src/scripts/ai_council/clients.ts +50 -1
  73. package/src/scripts/ai_council/config.ts +28 -3
  74. package/src/scripts/ai_council/low_impact.ts +16 -0
  75. package/src/scripts/ai_council/necessity.ts +11 -0
  76. package/src/scripts/ai_council/orchestrator.ts +78 -26
  77. package/src/scripts/ai_council/pricing.ts +59 -0
  78. package/src/scripts/ai_council/prompts.ts +7 -0
  79. package/src/scripts/ai_council/stance_tally.ts +42 -5
  80. package/src/scripts/build_catalog_index.ts +188 -0
  81. package/src/scripts/council_cli.ts +56 -1
  82. package/src/scripts/generate_index.ts +6 -3
  83. package/src/scripts/hook_manifest.yaml +17 -6
  84. package/src/scripts/install-hooks.sh +32 -17
  85. package/src/scripts/lint_originality.ts +464 -0
  86. package/src/scripts/mcp_server/catalog_tools.ts +114 -0
  87. package/src/scripts/mcp_server/consumer_tool_catalog.json +46 -0
  88. package/src/scripts/pr_url_reminder_hook.ts +177 -0
  89. package/src/scripts/schemas/flow.schema.json +17 -0
  90. package/src/scripts/schemas/skill.schema.json +166 -32
  91. package/src/scripts/self_review_gate.test.ts +48 -0
  92. package/src/scripts/self_review_gate.ts +73 -5
  93. package/src/scripts/validate_flow_teams.ts +127 -0
@@ -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
@@ -88,6 +88,13 @@ correctness lens AND a shipping-risk lens:
88
88
  Is the blast radius bigger than the title implies?
89
89
  5. Reviewer fatigue — is anything in the diff that a tired reviewer
90
90
  would rubber-stamp but should not?
91
+ 6. Reuse & object-oriented shape — does the diff reinvent a component
92
+ or abstraction the codebase likely already provides (should compose
93
+ / reuse instead), and where it adds structure, is the OO shape
94
+ (encapsulation, composition over inheritance) justified by
95
+ complexity it removes rather than speculative abstraction? Judge in
96
+ the codebase's own paradigm — do not push classes onto functional /
97
+ pipeline code; smallest-change still wins over OO advocacy.
91
98
  End with: APPROVE / REQUEST_CHANGES / REJECT, one sentence why, and
92
99
  the single highest-leverage change the PR author should make before
93
100
  merge.`;
@@ -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;
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Build the lazy-catalog index — `dist/catalog-index-v1.json`.
4
+ *
5
+ * A queryable metadata index over every skill, persona, and domain command:
6
+ * `{ id, cls, name, description, tags, path }` — **metadata only, no bodies**.
7
+ * Bodies load lazily from disk at `catalog_load` time (the stdio server has the
8
+ * repo underneath it, unlike the upstream Hermes plugin which embeds bodies).
9
+ *
10
+ * Feeds the `catalog_search / _inspect / _load` MCP tools (built as discovery
11
+ * stubs; activation deferred behind a quality A/B — see
12
+ * road-to-persona-library-harvest.md Phase 2). Deterministic output (entries
13
+ * sorted by (cls, id), stable keys) so the artifact diffs cleanly. `--check`
14
+ * fails on drift instead of writing (CI mode), mirroring the
15
+ * build_mcp_registry_manifest convention.
16
+ */
17
+ import * as fs from 'node:fs';
18
+ import * as path from 'node:path';
19
+ import { fileURLToPath, pathToFileURL } from 'node:url';
20
+
21
+ import { parse as parseYaml } from 'yaml';
22
+
23
+ const _HERE = fileURLToPath(import.meta.url);
24
+ export const ROOT = path.resolve(path.dirname(_HERE), '..', '..');
25
+ // The `-v1` namespace tracks `schema_version` below (per lint_versioned_cache):
26
+ // bump both together on a shape change so any stale cached copy self-invalidates
27
+ // by name rather than being read as the new shape.
28
+ export const OUT = path.join(ROOT, 'dist', 'catalog-index-v1.json');
29
+
30
+ export type CatalogClass = 'skill' | 'persona' | 'command';
31
+
32
+ export interface CatalogIndexEntry {
33
+ /** Globally unique handle: `<cls>:<slug>`. */
34
+ id: string;
35
+ cls: CatalogClass;
36
+ name: string;
37
+ description: string;
38
+ /** Domain / packs / tier — for filtering and search-text weighting. */
39
+ tags: string[];
40
+ /** Repo-relative path to the body (resolved by catalog_load). */
41
+ path: string;
42
+ }
43
+
44
+ const SKILLS_DIR = path.join(ROOT, 'src', 'skills');
45
+ const PERSONAS_DIR = path.join(ROOT, 'src', 'agent-src', 'personas');
46
+ const DOMAINS_DIR = path.join(ROOT, 'src', 'domains');
47
+
48
+ function _isDir(p: string): boolean {
49
+ try { return fs.statSync(p).isDirectory(); } catch { return false; }
50
+ }
51
+ function _isFile(p: string): boolean {
52
+ try { return fs.statSync(p).isFile(); } catch { return false; }
53
+ }
54
+ function _rel(abs: string): string {
55
+ return path.relative(ROOT, abs).split(path.sep).join('/');
56
+ }
57
+
58
+ function _frontmatter(abs: string): Record<string, unknown> {
59
+ const text = fs.readFileSync(abs, 'utf-8');
60
+ const m = /^---\n([\s\S]*?)\n---/.exec(text);
61
+ if (!m || m.index !== 0) return {};
62
+ try {
63
+ const fm = parseYaml(m[1] as string, { version: '1.1' });
64
+ return fm !== null && typeof fm === 'object' && !Array.isArray(fm) ? (fm as Record<string, unknown>) : {};
65
+ } catch {
66
+ return {};
67
+ }
68
+ }
69
+
70
+ function _str(v: unknown): string {
71
+ return typeof v === 'string' ? v.trim() : v == null ? '' : String(v);
72
+ }
73
+ function _list(v: unknown): string[] {
74
+ if (Array.isArray(v)) return v.map((x) => _str(x)).filter((s) => s.length > 0);
75
+ const s = _str(v);
76
+ return s ? [s] : [];
77
+ }
78
+
79
+ function _walkLeaf(dir: string, leaf: string, out: string[]): void {
80
+ let entries: fs.Dirent[];
81
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
82
+ for (const ent of entries) {
83
+ const full = path.join(dir, ent.name);
84
+ if (ent.isDirectory() || (ent.isSymbolicLink() && _isDir(full))) _walkLeaf(full, leaf, out);
85
+ else if (ent.name === leaf) out.push(full);
86
+ }
87
+ }
88
+
89
+ export function buildEntries(): CatalogIndexEntry[] {
90
+ const out: CatalogIndexEntry[] = [];
91
+
92
+ // Skills
93
+ if (_isDir(SKILLS_DIR)) {
94
+ for (const slug of fs.readdirSync(SKILLS_DIR).sort()) {
95
+ const abs = path.join(SKILLS_DIR, slug, 'SKILL.md');
96
+ if (!_isFile(abs)) continue;
97
+ const fm = _frontmatter(abs);
98
+ out.push({
99
+ id: `skill:${slug}`,
100
+ cls: 'skill',
101
+ name: _str(fm['name']) || slug,
102
+ description: _str(fm['description']),
103
+ tags: [_str(fm['domain']), ..._list(fm['packs'])].filter((s) => s.length > 0),
104
+ path: _rel(abs),
105
+ });
106
+ }
107
+ }
108
+
109
+ // Personas (top-level *.md, skip _template/advisors and underscore-prefixed)
110
+ if (_isDir(PERSONAS_DIR)) {
111
+ for (const name of fs.readdirSync(PERSONAS_DIR).sort()) {
112
+ if (!name.endsWith('.md') || name.startsWith('_')) continue;
113
+ const abs = path.join(PERSONAS_DIR, name);
114
+ if (!_isFile(abs)) continue;
115
+ const fm = _frontmatter(abs);
116
+ const id = _str(fm['id']) || name.replace(/\.md$/, '');
117
+ out.push({
118
+ id: `persona:${id}`,
119
+ cls: 'persona',
120
+ name: _str(fm['role']) || id,
121
+ description: _str(fm['description']),
122
+ tags: [_str(fm['tier']), _str(fm['mode'])].filter((s) => s.length > 0),
123
+ path: _rel(abs),
124
+ });
125
+ }
126
+ }
127
+
128
+ // Domain commands
129
+ const cmds: string[] = [];
130
+ _walkLeaf(DOMAINS_DIR, 'command.md', cmds);
131
+ for (const abs of cmds.sort()) {
132
+ const fm = _frontmatter(abs);
133
+ // logical id = <pack>/<subpath> under src/domains, without /command.md
134
+ const relFromDomains = path.relative(DOMAINS_DIR, path.dirname(abs)).split(path.sep).join('/');
135
+ const name = _str(fm['name']) || relFromDomains.split('/').pop() || relFromDomains;
136
+ out.push({
137
+ id: `command:${relFromDomains}`,
138
+ cls: 'command',
139
+ name,
140
+ description: _str(fm['description']) || _str(fm['intent']),
141
+ tags: [_str(fm['pack']), _str(fm['tier']), _str(fm['visibility'])].filter((s) => s.length > 0),
142
+ path: _rel(abs),
143
+ });
144
+ }
145
+
146
+ // Deterministic order: (cls, id).
147
+ out.sort((a, b) => (a.cls < b.cls ? -1 : a.cls > b.cls ? 1 : a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
148
+ return out;
149
+ }
150
+
151
+ export function serialize(entries: CatalogIndexEntry[]): string {
152
+ return JSON.stringify({ schema_version: 1, count: entries.length, entries }, null, 2) + '\n';
153
+ }
154
+
155
+ export function main(argv?: string[]): number {
156
+ const args = argv ?? process.argv.slice(2);
157
+ const check = args.includes('--check');
158
+ const quiet = args.includes('--quiet');
159
+ const entries = buildEntries();
160
+ const rendered = serialize(entries);
161
+
162
+ if (check) {
163
+ const current = _isFile(OUT) ? fs.readFileSync(OUT, 'utf-8') : '';
164
+ if (current !== rendered) {
165
+ process.stderr.write(`build_catalog_index: DRIFT — ${_rel(OUT)} is stale; run build_catalog_index to regenerate.\n`);
166
+ return 1;
167
+ }
168
+ if (!quiet) process.stdout.write(`build_catalog_index: OK — ${entries.length} entries, index up to date.\n`);
169
+ return 0;
170
+ }
171
+
172
+ fs.mkdirSync(path.dirname(OUT), { recursive: true });
173
+ fs.writeFileSync(OUT, rendered, 'utf-8');
174
+ if (!quiet) process.stdout.write(`build_catalog_index: wrote ${entries.length} entries → ${_rel(OUT)}\n`);
175
+ return 0;
176
+ }
177
+
178
+ function _isCliEntry(): boolean {
179
+ if (process.argv[1] === undefined) return false;
180
+ if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) return true;
181
+ try {
182
+ return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(path.resolve(process.argv[1]));
183
+ } catch { return false; }
184
+ }
185
+
186
+ if (_isCliEntry()) {
187
+ process.exit(main());
188
+ }
@@ -98,7 +98,9 @@ import {
98
98
  } from './ai_council/orchestrator.js';
99
99
  import {
100
100
  type PriceTable,
101
+ downgrade_coupling,
101
102
  estimate_cost,
103
+ estimate_input_tokens,
102
104
  load_prices,
103
105
  prices_file_for,
104
106
  } from './ai_council/pricing.js';
@@ -1605,7 +1607,8 @@ function _necessity_gate(opts: {
1605
1607
  function _resolve_model_downgrade(ai_cfg: Dict, lens: string): [boolean, boolean] {
1606
1608
  const md_block = (ai_cfg['model_downgrade'] as Dict) || {};
1607
1609
  let enabled = md_block['enabled'] === undefined ? true : Boolean(_pyBool(md_block['enabled']));
1608
- let auto_apply = md_block['auto_apply'] === undefined ? false : Boolean(_pyBool(md_block['auto_apply']));
1610
+ // A3: auto-downgrade is the default; `auto_apply: false` is the opt-out.
1611
+ let auto_apply = md_block['auto_apply'] === undefined ? true : Boolean(_pyBool(md_block['auto_apply']));
1609
1612
  const overrides = ((ai_cfg['lens_overrides'] as Dict) || {})['model_downgrade'] || {};
1610
1613
  const lens_override = _isDict(overrides) ? (overrides as Dict)[lens] : null;
1611
1614
  if (_isDict(lens_override)) {
@@ -1622,6 +1625,10 @@ function _size_fit_gate(opts: {
1622
1625
  members: ExternalAIClient[];
1623
1626
  ai_cfg: Dict;
1624
1627
  stdout?: ((s: string) => void) | null;
1628
+ /** Planned rounds for the run; expected same-model cache reads = rounds − 1. */
1629
+ rounds?: number;
1630
+ /** Price table for the A1↔A3 cache-coupling gate; null → coupling skipped. */
1631
+ price_table?: PriceTable | null;
1625
1632
  }): Array<[string, SizeFitVerdict, boolean]> {
1626
1633
  const out = opts.stdout ?? _stdout;
1627
1634
  const [enabled, auto_apply] = _resolve_model_downgrade(opts.ai_cfg, opts.lens);
@@ -1629,8 +1636,25 @@ function _size_fit_gate(opts: {
1629
1636
  if (!enabled) {
1630
1637
  return decisions;
1631
1638
  }
1639
+ const md_block = (opts.ai_cfg['model_downgrade'] as Dict) || {};
1640
+ const tier_override = (md_block['model_tier_override'] as Dict) || {};
1641
+ const cache_enabled = opts.ai_cfg['prompt_cache'] !== false;
1642
+ const expected_reads = Math.max(0, (opts.rounds ?? 1) - 1);
1632
1643
  const members_cfg = (opts.ai_cfg['members'] as Dict) || {};
1633
1644
  for (const member of opts.members) {
1645
+ // Per-run escape hatch: a member pinned in model_tier_override gets
1646
+ // exactly that model — classifier and coupling both skipped.
1647
+ const pinned = tier_override[member.name];
1648
+ if (typeof pinned === 'string' && pinned.length > 0) {
1649
+ if (pinned !== member.model) {
1650
+ out(
1651
+ `council:size-fit · ${member.name} · model_tier_override ` +
1652
+ `\`${member.model}\` → \`${pinned}\` (operator pin)\n`,
1653
+ );
1654
+ member.model = pinned;
1655
+ }
1656
+ continue;
1657
+ }
1634
1658
  const member_cfg = (members_cfg[member.name] as Dict) || {};
1635
1659
  const ladder = (member_cfg['model_ladder'] as string[]) || [];
1636
1660
  if (ladder.length === 0) {
@@ -1639,6 +1663,35 @@ function _size_fit_gate(opts: {
1639
1663
  const verdict = classify_size_fit(opts.prompt, member.model, ladder, opts.lens);
1640
1664
  let applied = false;
1641
1665
  if (!verdict.fit && verdict.suggested_model) {
1666
+ // A1↔A3 coupling: a downgraded member misses the model-scoped
1667
+ // prompt cache — downgrade only when the model saving beats the
1668
+ // forfeited cache reads. No price table → conservative skip is
1669
+ // wrong for the cost goal, so coupling only gates when computable.
1670
+ if (opts.price_table) {
1671
+ const prefix_tokens = estimate_input_tokens(opts.prompt);
1672
+ const coupling = downgrade_coupling(
1673
+ member.name,
1674
+ member.model,
1675
+ verdict.suggested_model,
1676
+ prefix_tokens,
1677
+ 4096,
1678
+ {
1679
+ enabled: cache_enabled,
1680
+ cacheable_prefix_tokens: prefix_tokens,
1681
+ expected_reads,
1682
+ },
1683
+ opts.price_table,
1684
+ );
1685
+ if (!coupling.net_positive) {
1686
+ out(
1687
+ `council:size-fit · ${member.name} · downgrade skipped — ` +
1688
+ `lost cache savings $${coupling.lost_cache_savings_usd.toFixed(4)} ` +
1689
+ `≥ model savings $${coupling.downgrade_savings_usd.toFixed(4)}\n`,
1690
+ );
1691
+ decisions.push([member.name, verdict, false]);
1692
+ continue;
1693
+ }
1694
+ }
1642
1695
  if (auto_apply) {
1643
1696
  out(
1644
1697
  `council:size-fit · ${member.name} · auto-downgrade ` +
@@ -1845,6 +1898,8 @@ function cmd_run(
1845
1898
  lens: question.mode,
1846
1899
  members,
1847
1900
  ai_cfg,
1901
+ rounds: _getattr(args, 'rounds', 1) as number,
1902
+ price_table: table,
1848
1903
  });
1849
1904
  const project = detect_project_context(REPO_ROOT);
1850
1905
  const billable = members.filter((m) => _getattr(m, 'billable', true));
@@ -232,7 +232,10 @@ export function _collect_skills(): Entry[] {
232
232
  kind: 'skill',
233
233
  name,
234
234
  description: _truncate(fm['description'] ?? ''),
235
- extra: '',
235
+ // Provenance: `official` unless the skill declares
236
+ // `provenance: community` (external contribution — credited in
237
+ // CREDITS.md; the catalog column makes the credit visible).
238
+ extra: fm['provenance'] ?? 'official',
236
239
  path: _relativeToPosix(skillMd, ROOT),
237
240
  });
238
241
  }
@@ -372,7 +375,7 @@ export function _render_index(
372
375
  '',
373
376
  `## Skills (${skills.length})`,
374
377
  '',
375
- _render_table(skills, ['kind', 'name', 'extra', 'description'], '../'),
378
+ _render_table(skills, ['kind', 'name', 'source', 'description'], '../'),
376
379
  '',
377
380
  `## Rules (${rules.length})`,
378
381
  '',
@@ -428,7 +431,7 @@ export function _render_catalog(
428
431
  '> (`./scripts-run src/scripts/domain_soundness_status`). A disclaimer',
429
432
  '> floor bounds liability, not correctness — see [the proof page](proof.md).',
430
433
  '',
431
- _render_table(skills, ['kind', 'name', 'extra', 'description'], '../', _to_shipped_path),
434
+ _render_table(skills, ['kind', 'name', 'source', 'description'], '../', _to_shipped_path),
432
435
  '',
433
436
  `## Rules (${publicRules.length})`,
434
437
  '',