@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,348 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Defect-finding benchmark runner (road-to-team-mode Phase 5, Steps 2-4).
4
+ *
5
+ * Three arms over the pre-registered corpus (internal/bench/corpora/defect-finding.yaml):
6
+ * (a) self-review — single strong model (Anthropic) + the adversarial-review frame
7
+ * (b) team — codex exec (subscription; reviewer pinned via --codex-model)
8
+ * (c) council — the neutral breadth arm: Anthropic + OpenAI independent reviews, union
9
+ *
10
+ * Every arm gets the SAME strict output contract so scoring is fair + objective:
11
+ * a review must end with `VERDICT: <n>` and one `DEFECT: <file>:<line> — <why>`
12
+ * line per defect. Recall + false-positives are then scored DETERMINISTICALLY
13
+ * against the corpus ground truth (no LLM judge for the PRIMARY metric — the
14
+ * blind rubric judge is the SECONDARY metric and is deferred this pass).
15
+ *
16
+ * Spend: arm (a) + arm (c) are billable API calls; arm (b) is the ChatGPT
17
+ * subscription (non-billable through this process). `--max-usd` is a HARD cap:
18
+ * the run aborts before a call that would cross it. `--dry-run` makes zero calls.
19
+ *
20
+ * Exit 0 ok · 1 aborted-on-cap / error · 2 usage.
21
+ */
22
+ import { execFileSync } from 'node:child_process';
23
+ import * as fs from 'node:fs';
24
+ import * as path from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ import { load as yamlLoad } from 'js-yaml';
28
+
29
+ import {
30
+ AnthropicClient,
31
+ OpenAIClient,
32
+ load_anthropic_key,
33
+ load_openai_key,
34
+ } from './ai_council/clients.js';
35
+
36
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
37
+ const ROOT = path.resolve(HERE, '..', '..');
38
+ const CORPUS = path.join(ROOT, 'internal', 'bench', 'corpora', 'defect-finding.yaml');
39
+ const OUT_JSON = path.join(ROOT, 'internal', 'bench', 'reports', 'defect-finding.json');
40
+ const OUT_MD = path.join(ROOT, 'internal', 'bench', 'reports', 'defect-finding.md');
41
+
42
+ const OUTPUT_CONTRACT =
43
+ '\n\nReport STRICTLY in this format, nothing after it:\n' +
44
+ 'DEFECT: <file>:<line> — <one-line why> (one line per real defect; omit if none)\n' +
45
+ 'VERDICT: <integer count of defects>';
46
+
47
+ interface Fixture {
48
+ id: string;
49
+ defect_class: string;
50
+ prompt: string;
51
+ ground_truth: string;
52
+ rubric: string;
53
+ negative_control?: boolean;
54
+ }
55
+
56
+ type ArmId = 'self-review' | 'team' | 'council';
57
+
58
+ interface ArmResult {
59
+ arm: ArmId;
60
+ text: string;
61
+ calls: number;
62
+ input_tokens: number;
63
+ output_tokens: number;
64
+ usd: number;
65
+ ms: number;
66
+ error: string | null;
67
+ }
68
+
69
+ /** Ground-truth FILENAME for a fixture, or null for a control. Each planted
70
+ * fixture touches exactly ONE unique file, so "a DEFECT line names this file"
71
+ * is the honest, line-number-robust recall signal (diff line numbering is
72
+ * ambiguous and models cite lines inconsistently — matching the exact
73
+ * file:line token systematically under-counts real finds). */
74
+ function groundTruthFile(gt: string): string | null {
75
+ if (/^\s*NONE\b/i.test(gt)) return null;
76
+ const head = gt.split('—')[0] ?? gt;
77
+ const m = /([\w./-]+\.\w+):[\d,]+/.exec(head);
78
+ return m ? (m[1] as string) : null;
79
+ }
80
+ /** Basename of a path token (find.ts from src/search/find.ts). */
81
+ function basename(p: string): string { return p.split('/').pop() ?? p; }
82
+
83
+ /** Parse the DEFECT lines an arm emitted (file:line tokens it claims). */
84
+ function parseClaimedDefects(text: string): { count: number; files: string[] } {
85
+ const files: string[] = [];
86
+ for (const line of text.split('\n')) {
87
+ // DEFECT: <file>[:line] — ... ; capture the file token (line optional).
88
+ const m = /^\s*DEFECT:\s*([\w./-]+\.\w+)/.exec(line);
89
+ if (m) files.push(m[1] as string);
90
+ }
91
+ const vm = /VERDICT:\s*(\d+)/i.exec(text);
92
+ const count = vm ? Number(vm[1]) : files.length;
93
+ return { count, files };
94
+ }
95
+
96
+ /** Deterministic recall/FP for one arm on one fixture. */
97
+ function score(
98
+ fx: Fixture,
99
+ arm: ArmResult,
100
+ ): { recall_hit: boolean; false_positive: boolean; claimed: number } {
101
+ const gtFile = groundTruthFile(fx.ground_truth);
102
+ const claimed = parseClaimedDefects(arm.text);
103
+ if (gtFile === null || fx.negative_control === true) {
104
+ // Control: any claimed defect is a false positive.
105
+ return { recall_hit: false, false_positive: claimed.count > 0, claimed: claimed.count };
106
+ }
107
+ // Recall: a DEFECT line names the planted file (by basename — robust to the
108
+ // model prefixing or omitting the dir). Line-agnostic on purpose.
109
+ const target = basename(gtFile);
110
+ const hit = claimed.files.some((f) => basename(f) === target) || arm.text.includes(target);
111
+ return { recall_hit: hit, false_positive: false, claimed: claimed.count };
112
+ }
113
+
114
+ function usd(inTok: number, outTok: number, inRate: number, outRate: number): number {
115
+ return (inTok / 1_000_000) * inRate + (outTok / 1_000_000) * outRate;
116
+ }
117
+
118
+ // Conservative published-tier rates (per 1M tokens) for the estimate + tally.
119
+ const RATES = {
120
+ anthropic: { in: 3, out: 15 }, // claude-sonnet class
121
+ openai: { in: 2.5, out: 10 }, // gpt-4o class
122
+ };
123
+
124
+ interface Budget {
125
+ cap: number;
126
+ spent: number;
127
+ }
128
+
129
+ function guard(b: Budget, next: number): void {
130
+ if (b.cap > 0 && b.spent + next > b.cap) {
131
+ throw new Error(`hard cap $${b.cap} would be crossed (spent $${b.spent.toFixed(4)}, next ~$${next.toFixed(4)})`);
132
+ }
133
+ }
134
+
135
+ function armSelfReview(fx: Fixture, key: string, b: Budget): ArmResult {
136
+ const t0 = Date.now();
137
+ guard(b, 0.05);
138
+ const client = new AnthropicClient({ api_key: key });
139
+ const sys =
140
+ 'You are a single-model adversarial code reviewer (Attack-Defend-Revise). ' +
141
+ 'Find real defects in the diff; do not invent problems in correct code.';
142
+ const r = client.ask(sys, fx.prompt + OUTPUT_CONTRACT, 1024);
143
+ const cost = usd(r.input_tokens, r.output_tokens, RATES.anthropic.in, RATES.anthropic.out);
144
+ b.spent += cost;
145
+ return {
146
+ arm: 'self-review', text: r.text, calls: 1,
147
+ input_tokens: r.input_tokens, output_tokens: r.output_tokens,
148
+ usd: cost, ms: Date.now() - t0, error: r.error,
149
+ };
150
+ }
151
+
152
+ function armTeam(fx: Fixture, model: string, _b: Budget): ArmResult {
153
+ const t0 = Date.now();
154
+ // codex = ChatGPT subscription: non-billable through this process. `--json`
155
+ // streams events; the review is the LAST agent_message item's text (the
156
+ // interactive mode without --json blocks/hangs — must use --json here).
157
+ let text = '';
158
+ let error: string | null = null;
159
+ try {
160
+ const raw = execFileSync('codex', ['exec', '--json', '-m', model, fx.prompt + OUTPUT_CONTRACT], {
161
+ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout: 180_000,
162
+ });
163
+ for (const line of raw.split('\n')) {
164
+ const s = line.trim();
165
+ if (!s.startsWith('{')) continue;
166
+ try {
167
+ const ev = JSON.parse(s) as { item?: { type?: string; text?: string } };
168
+ if (ev.item?.type === 'agent_message' && typeof ev.item.text === 'string') {
169
+ text = ev.item.text; // keep the last agent_message
170
+ }
171
+ } catch { /* skip non-JSON / partial lines */ }
172
+ }
173
+ if (!text) error = 'no agent_message in codex --json stream';
174
+ } catch (e) {
175
+ error = e instanceof Error ? e.message.slice(0, 300) : String(e);
176
+ }
177
+ return { arm: 'team', text, calls: 1, input_tokens: 0, output_tokens: 0, usd: 0, ms: Date.now() - t0, error };
178
+ }
179
+
180
+ function armCouncil(fx: Fixture, aKey: string, oKey: string, b: Budget): ArmResult {
181
+ const t0 = Date.now();
182
+ const sys =
183
+ 'You are a neutral code reviewer. Review ONLY the supplied diff for real ' +
184
+ 'defects; never invent problems in correct code.';
185
+ guard(b, 0.05);
186
+ const a = new AnthropicClient({ api_key: aKey }).ask(sys, fx.prompt + OUTPUT_CONTRACT, 1024);
187
+ let cost = usd(a.input_tokens, a.output_tokens, RATES.anthropic.in, RATES.anthropic.out);
188
+ b.spent += cost;
189
+ guard(b, 0.05);
190
+ const o = new OpenAIClient({ api_key: oKey }).ask(sys, fx.prompt + OUTPUT_CONTRACT, 1024);
191
+ const oc = usd(o.input_tokens, o.output_tokens, RATES.openai.in, RATES.openai.out);
192
+ b.spent += oc;
193
+ cost += oc;
194
+ // Council = breadth: union the two independent reviews.
195
+ const text = `# reviewer A\n${a.text}\n\n# reviewer B\n${o.text}`;
196
+ return {
197
+ arm: 'council', text, calls: 2,
198
+ input_tokens: a.input_tokens + o.input_tokens,
199
+ output_tokens: a.output_tokens + o.output_tokens,
200
+ usd: cost, ms: Date.now() - t0, error: a.error ?? o.error,
201
+ };
202
+ }
203
+
204
+ const CORRECTNESS = new Set(['logic-bug', 'off-by-one', 'race']);
205
+ const DESIGN = new Set(['missing-empty-state', 'security-smell']);
206
+
207
+ export function main(argv: string[]): 0 | 1 | 2 {
208
+ const dryRun = argv.includes('--dry-run');
209
+ const capIdx = argv.indexOf('--max-usd');
210
+ const cap = capIdx >= 0 ? Number(argv[capIdx + 1]) : 1.2;
211
+ const modelIdx = argv.indexOf('--codex-model');
212
+ const codexModel = (modelIdx >= 0 ? argv[modelIdx + 1] : undefined) ?? 'gpt-5.5';
213
+
214
+ const doc = yamlLoad(fs.readFileSync(CORPUS, 'utf8')) as { fixtures: Fixture[] };
215
+ const fixtures = doc.fixtures;
216
+
217
+ if (dryRun) {
218
+ const planted = fixtures.filter((f) => !f.negative_control).length;
219
+ process.stdout.write(
220
+ `defect-finding (dry-run — no calls):\n` +
221
+ ` fixtures: ${fixtures.length} (${planted} planted, ${fixtures.length - planted} control)\n` +
222
+ ` arms: self-review (anthropic, ${fixtures.length} calls) · team (codex ${codexModel}, ${fixtures.length} calls, subscription) · council (anthropic+openai, ${fixtures.length * 2} calls)\n` +
223
+ ` billable estimate: arm a ~$${(fixtures.length * 0.02).toFixed(2)} + arm c ~$${(fixtures.length * 0.04).toFixed(2)} ≈ $${(fixtures.length * 0.06).toFixed(2)} (hard cap $${cap})\n` +
224
+ ` arm b (codex): 0 billable (ChatGPT subscription quota)\n`,
225
+ );
226
+ return 0;
227
+ }
228
+
229
+ const aKey = load_anthropic_key();
230
+ const oKey = load_openai_key();
231
+ const budget: Budget = { cap, spent: 0 };
232
+ const rows: Array<{ fx: Fixture; arms: Record<ArmId, ArmResult> }> = [];
233
+
234
+ try {
235
+ for (const fx of fixtures) {
236
+ process.stdout.write(`· ${fx.id} (${fx.defect_class})\n`);
237
+ const arms = {
238
+ 'self-review': armSelfReview(fx, aKey, budget),
239
+ team: armTeam(fx, codexModel, budget),
240
+ council: armCouncil(fx, aKey, oKey, budget),
241
+ } as Record<ArmId, ArmResult>;
242
+ rows.push({ fx, arms });
243
+ }
244
+ } catch (e) {
245
+ process.stderr.write(`ABORTED: ${e instanceof Error ? e.message : String(e)}\n`);
246
+ // fall through to write a partial report
247
+ }
248
+
249
+ // ── aggregate ──
250
+ const ARMS: ArmId[] = ['self-review', 'team', 'council'];
251
+ const agg: Record<string, unknown> = {};
252
+ const perArm: Record<ArmId, { recall: number; planted: number; fp: number; usd: number; ms: number; calls: number; byClass: Record<string, { hit: number; n: number }> }> =
253
+ Object.fromEntries(ARMS.map((a) => [a, { recall: 0, planted: 0, fp: 0, usd: 0, ms: 0, calls: 0, byClass: {} }])) as never;
254
+
255
+ const detail: unknown[] = [];
256
+ for (const { fx, arms } of rows) {
257
+ const d: Record<string, unknown> = { id: fx.id, defect_class: fx.defect_class, control: !!fx.negative_control };
258
+ for (const a of ARMS) {
259
+ const ar = arms[a];
260
+ const sc = score(fx, ar);
261
+ const pa = perArm[a];
262
+ pa.usd += ar.usd; pa.ms += ar.ms; pa.calls += ar.calls;
263
+ if (!fx.negative_control) {
264
+ pa.planted += 1;
265
+ if (sc.recall_hit) pa.recall += 1;
266
+ const c = (pa.byClass[fx.defect_class] ??= { hit: 0, n: 0 });
267
+ c.n += 1; if (sc.recall_hit) c.hit += 1;
268
+ } else if (sc.false_positive) {
269
+ pa.fp += 1;
270
+ }
271
+ d[a] = { recall_hit: sc.recall_hit, false_positive: sc.false_positive, claimed: sc.claimed, usd: Number(ar.usd.toFixed(4)), ms: ar.ms, error: ar.error, review: ar.text.slice(0, 1200) };
272
+ }
273
+ detail.push(d);
274
+ }
275
+
276
+ const recallRate = (a: ArmId): number => (perArm[a].planted ? perArm[a].recall / perArm[a].planted : 0);
277
+ const classRecall = (a: ArmId, classes: Set<string>): { hit: number; n: number } => {
278
+ let hit = 0, n = 0;
279
+ for (const [cls, c] of Object.entries(perArm[a].byClass)) if (classes.has(cls)) { hit += c.hit; n += c.n; }
280
+ return { hit, n };
281
+ };
282
+ const rr = (x: { hit: number; n: number }): number => (x.n ? x.hit / x.n : 0);
283
+
284
+ // Hypotheses (pre-registered thresholds).
285
+ const aCorr = classRecall('self-review', CORRECTNESS), bCorr = classRecall('team', CORRECTNESS);
286
+ const bDes = classRecall('team', DESIGN), cDes = classRecall('council', DESIGN);
287
+ const h1 = rr(bCorr) - rr(aCorr); // team - self on correctness; predict >= +0.20
288
+ const h2 = Math.abs(rr(cDes) - rr(bDes)); // council vs team on design; predict within 0.10
289
+ const h3ok = ARMS.every((a) => perArm[a].fp <= 1);
290
+
291
+ agg['preregistered'] = {
292
+ roadmap: 'road-to-team-mode Phase 5', authored: '2026-07-20',
293
+ primary: ['recall', 'false_positives', 'cost/time/calls'],
294
+ secondary: 'blind rubric judge — DEFERRED this pass (primary recall is deterministic)',
295
+ hypotheses: { H1: 'team>self on correctness, Δrecall>=+0.20', H2: 'council≈team on design, within 0.10', H3: 'FP<=1/arm on controls' },
296
+ codex_model: codexModel, models_cache_fetched: '2026-07-20T12:21Z',
297
+ };
298
+ agg['arms'] = Object.fromEntries(ARMS.map((a) => [a, {
299
+ recall_rate: Number(recallRate(a).toFixed(3)), recall: perArm[a].recall, planted: perArm[a].planted,
300
+ false_positives: perArm[a].fp, usd: Number(perArm[a].usd.toFixed(4)), ms: perArm[a].ms, calls: perArm[a].calls,
301
+ correctness_recall: Number(rr(classRecall(a, CORRECTNESS)).toFixed(3)),
302
+ design_recall: Number(rr(classRecall(a, DESIGN)).toFixed(3)),
303
+ }]));
304
+ const nullish = Math.abs(h1) <= 0.1 && h2 <= 0.1;
305
+ agg['verdict'] = {
306
+ H1_team_minus_self_correctness: Number(h1.toFixed(3)), H1_met: h1 >= 0.2,
307
+ H2_council_vs_team_design_absdelta: Number(h2.toFixed(3)), H2_met: h2 <= 0.1,
308
+ H3_fp_within_bound: h3ok,
309
+ honest_null: nullish,
310
+ disposition: nullish
311
+ ? 'HONEST NULL — arms indistinguishable within pre-registered thresholds; no lift claim binds.'
312
+ : h1 >= 0.2
313
+ ? 'LIFT on correctness-class — H1 met; a team-lift CLAIM may bind (maintainer decision).'
314
+ : 'MIXED — see per-class rates; no blanket lift claim.',
315
+ };
316
+ agg['total_billable_usd'] = Number((perArm['self-review'].usd + perArm.council.usd).toFixed(4));
317
+ agg['detail'] = detail;
318
+
319
+ fs.mkdirSync(path.dirname(OUT_JSON), { recursive: true });
320
+ fs.writeFileSync(OUT_JSON, JSON.stringify(agg, null, 2) + '\n');
321
+
322
+ const v = agg['verdict'] as Record<string, unknown>;
323
+ const md = [
324
+ '# Defect-finding benchmark — results (road-to-team-mode Phase 5)',
325
+ '', `> Run 2026-07-20. Codex reviewer: \`${codexModel}\`. Primary metric = deterministic`,
326
+ '> recall against corpus ground truth; blind rubric judge (secondary) deferred.',
327
+ '', '## Per-arm', '', '| arm | recall | correctness | design | false-pos | calls | $ |', '|---|--:|--:|--:|--:|--:|--:|',
328
+ ...ARMS.map((a) => {
329
+ const x = (agg['arms'] as Record<string, Record<string, number>>)[a] as Record<string, number>;
330
+ return `| ${a} | ${x.recall_rate} | ${x.correctness_recall} | ${x.design_recall} | ${x.false_positives} | ${x.calls} | ${x.usd} |`;
331
+ }),
332
+ '', '## Verdict', '',
333
+ `- H1 (team − self, correctness recall Δ): **${v.H1_team_minus_self_correctness}** — met: ${v.H1_met}`,
334
+ `- H2 (council vs team, design recall |Δ|): **${v.H2_council_vs_team_design_absdelta}** — met: ${v.H2_met}`,
335
+ `- H3 (false positives ≤ 1/arm): ${v.H3_fp_within_bound}`,
336
+ '', `**Disposition:** ${v.disposition}`,
337
+ '', `Total billable: $${agg['total_billable_usd']} (arm b codex = subscription, $0 billable).`,
338
+ '',
339
+ ].join('\n');
340
+ fs.writeFileSync(OUT_MD, md);
341
+ process.stdout.write(`\nwrote ${path.relative(ROOT, OUT_JSON)} + ${path.relative(ROOT, OUT_MD)}\n`);
342
+ process.stdout.write(`spent ~$${budget.spent.toFixed(4)} (cap $${cap})\n`);
343
+ return 0;
344
+ }
345
+
346
+ if (fs.existsSync(process.argv[1] ?? '') && import.meta.url === `file://${process.argv[1]}`) {
347
+ process.exit(main(process.argv.slice(2)));
348
+ }
@@ -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
  '',