@event4u/agent-config 14.13.0 → 14.14.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 (101) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +116 -387
  5. package/dist/agent-src/commands/analyze/inbox.md +36 -0
  6. package/dist/agent-src/rules/code-comment-discipline.md +6 -0
  7. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +6 -6
  8. package/dist/agent-src/scripts/guarded_baseline.ts +22 -7
  9. package/dist/agent-src/scripts/stubs_due.ts +118 -2
  10. package/dist/agent-src/scripts/update_roadmap_progress.ts +8 -8
  11. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  12. package/dist/cli-delegate/cmd_conformance.js +38 -1
  13. package/dist/discovery/deprecation-report.md +1 -1
  14. package/dist/discovery/discovery-manifest.json +4 -4
  15. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  16. package/dist/discovery/discovery-manifest.summary.md +1 -1
  17. package/dist/discovery/dormancy-report.md +1 -1
  18. package/dist/discovery/orphan-report.md +1 -1
  19. package/dist/discovery/packs.json +2 -2
  20. package/dist/discovery/trust-report.md +1 -1
  21. package/dist/discovery/workspaces.json +2 -2
  22. package/dist/hooks/dispatch.js +1438 -711
  23. package/dist/mcp/registry-manifest.json +2 -2
  24. package/dist/mcp/server.json +2 -2
  25. package/dist/mcp/server.mjs +112 -17
  26. package/docs/guidelines/agent-infra/guarded-baseline.md +1 -1
  27. package/docs/guidelines/code-clarity.md +18 -0
  28. package/package.json +1 -1
  29. package/src/agent-src/scripts/archive_completed_roadmaps.ts +6 -6
  30. package/src/agent-src/scripts/guarded_baseline.ts +22 -7
  31. package/src/agent-src/scripts/stubs_due.ts +118 -2
  32. package/src/agent-src/scripts/update_roadmap_progress.ts +8 -8
  33. package/src/config/estate-count-budget.json +20 -1
  34. package/src/config/gate-coverage.yml +150 -0
  35. package/src/config/gate-violation-baselines.json +8 -3
  36. package/src/config/preamble-payload-budget.json +19 -11
  37. package/src/config/rule-stub-ceilings.json +14 -7
  38. package/src/scripts/_dispatch.bash +0 -4
  39. package/src/scripts/_lib/activation_ladder.ts +47 -3
  40. package/src/scripts/_lib/activation_receipt_producer.ts +341 -0
  41. package/src/scripts/_lib/adr_frontmatter.ts +26 -0
  42. package/src/scripts/_lib/candidate_pair_delta.ts +349 -0
  43. package/src/scripts/_lib/carrier_status.ts +118 -0
  44. package/src/scripts/_lib/cascade_stage_enumeration.ts +92 -0
  45. package/src/scripts/_lib/corpus_manifest.ts +726 -0
  46. package/src/scripts/_lib/delivery_arm_experiment.ts +18 -2
  47. package/src/scripts/_lib/estate_carrier_cases.ts +72 -0
  48. package/src/scripts/_lib/evaluation_cascade.ts +452 -0
  49. package/src/scripts/_lib/evolution_roi.ts +562 -0
  50. package/src/scripts/_lib/harness_evolution_guards.ts +10 -0
  51. package/src/scripts/_lib/lexical_shortlist.ts +215 -0
  52. package/src/scripts/_lib/llm_candidate_proposer.ts +461 -0
  53. package/src/scripts/_lib/llm_proposer_transport.ts +153 -0
  54. package/src/scripts/_lib/pathology_archive.ts +393 -0
  55. package/src/scripts/_lib/promotion_capability.ts +315 -0
  56. package/src/scripts/_lib/promotion_evidence.ts +414 -0
  57. package/src/scripts/_lib/promotion_review.ts +294 -0
  58. package/src/scripts/_lib/prompt_shape.ts +92 -0
  59. package/src/scripts/_lib/release_highlights.ts +70 -1
  60. package/src/scripts/_lib/release_material.ts +125 -2
  61. package/src/scripts/_lib/routing_corpus.ts +307 -0
  62. package/src/scripts/_lib/routing_index_input.ts +104 -0
  63. package/src/scripts/_lib/rule_injection.ts +22 -1
  64. package/src/scripts/_lib/semantic_noop.ts +167 -0
  65. package/src/scripts/activation_receipt.ts +258 -0
  66. package/src/scripts/ai_council/cross_exam.ts +128 -0
  67. package/src/scripts/ai_council/explain_route.ts +157 -0
  68. package/src/scripts/ai_council/information_gain.ts +293 -0
  69. package/src/scripts/ai_council/leakage_corpus.ts +385 -0
  70. package/src/scripts/ai_council/leakage_patterns.ts +453 -0
  71. package/src/scripts/ai_council/minority_retention.ts +171 -0
  72. package/src/scripts/ai_council/recouncil_savings.ts +299 -0
  73. package/src/scripts/ai_council/replay_route.ts +223 -0
  74. package/src/scripts/ai_council/reviewer_assignment.ts +279 -0
  75. package/src/scripts/ai_council/routing_training_row.ts +197 -0
  76. package/src/scripts/ai_council/synthesis_strategy.ts +159 -0
  77. package/src/scripts/check_adr_frontmatter.ts +4 -9
  78. package/src/scripts/check_estate_count.ts +205 -24
  79. package/src/scripts/check_release_highlights.ts +96 -13
  80. package/src/scripts/check_roadmap_trackable.ts +17 -2
  81. package/src/scripts/corpus_manifest.ts +235 -0
  82. package/src/scripts/evolution_lab.ts +181 -8
  83. package/src/scripts/hook_manifest.json +1 -1
  84. package/src/scripts/hook_manifest.yaml +28 -6
  85. package/src/scripts/hooks/comment_discipline_hook.ts +146 -0
  86. package/src/scripts/hooks/concern_registry.ts +2 -0
  87. package/src/scripts/hooks/turn_end_gate_hook.ts +9 -1
  88. package/src/scripts/language_mirror_hook.ts +14 -2
  89. package/src/scripts/lint_carrier_integrity.ts +820 -0
  90. package/src/scripts/lint_code_comments.ts +643 -0
  91. package/src/scripts/lint_plan_risk_register.ts +39 -19
  92. package/src/scripts/lint_promotion_paths.ts +779 -0
  93. package/src/scripts/llm_propose.ts +256 -0
  94. package/src/scripts/measure_delivery_sets.ts +361 -0
  95. package/src/scripts/measure_routing_signal.ts +307 -0
  96. package/src/scripts/model_rule_injection.ts +19 -3
  97. package/src/scripts/probe_council_retention.ts +495 -0
  98. package/src/scripts/release.ts +21 -37
  99. package/src/scripts/release_drill.ts +143 -0
  100. package/src/scripts/release_env.ts +35 -0
  101. package/src/scripts/release_publication.ts +84 -0
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * `activation_receipt` — observe how far a shipped artefact climbed the
4
+ * activation ladder, and append the receipt to the audit ledger.
5
+ *
6
+ * `road-to-governed-evidence-production` step 1.1's PRODUCTION CALLER. The
7
+ * producer library (`_lib/activation_receipt_producer.ts`) holds the discipline;
8
+ * this file holds the three real observations and the write. It exists because
9
+ * a library nothing calls has no coverage — the same defect the roadmap kept
10
+ * open on `assertCheapestFirst`. **Corrected 2026-09-01:** that one is now half
11
+ * closed. `assertCheapestFirst` has two production callers in
12
+ * `_lib/llm_candidate_proposer.ts` (`:417`, `:446`), so it no longer polices a
13
+ * population of zero; what it still does not police is a SPENT population,
14
+ * which is what AC-3 stays open on.
15
+ *
16
+ * ## What it observes, and from where
17
+ *
18
+ * | Rung | Source | What is read |
19
+ * |---|---|---|
20
+ * | `eligible` | `source-tree` | the authored artefact under `src/` |
21
+ * | `selected` | `discovery-manifest` | `dist/discovery/discovery-manifest.json`, `install.default` |
22
+ * | `projected` | `host-projection` | the host tree under `.claude/` |
23
+ *
24
+ * `delivered`, `visible` and `adhered` have no admitted source and are therefore
25
+ * ABSENT from every receipt this writes — which the ladder reads as `unknown`
26
+ * and keeps out of every rate's denominator. That is the honest state of
27
+ * coverage today, not a placeholder to be filled with a guess.
28
+ *
29
+ * ## Costs and boundaries
30
+ *
31
+ * Zero model calls, no network, no subprocess: three `statSync` calls and one
32
+ * JSON read. Admissible under the `metered-backend-park` blocker, which forbids
33
+ * a live model harness rather than a filesystem read. Claims:
34
+ * `docs/contracts/activation-receipt-trust-boundary.md`.
35
+ */
36
+ import * as fs from 'node:fs';
37
+ import * as path from 'node:path';
38
+ import * as crypto from 'node:crypto';
39
+ import { fileURLToPath } from 'node:url';
40
+
41
+ import {
42
+ appendActivationLine,
43
+ buildActivationLine,
44
+ buildActivationReceipt,
45
+ observeProjection,
46
+ observeSelection,
47
+ observeSourceTree,
48
+ type RungObservation,
49
+ } from './_lib/activation_receipt_producer.js';
50
+ import { LADDER_RUNGS, firstStall, type ActivationReceipt } from './_lib/activation_ladder.js';
51
+
52
+ const _HERE = fileURLToPath(import.meta.url);
53
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
54
+
55
+ export const EXIT_OK = 0;
56
+ export const EXIT_USAGE = 2;
57
+
58
+ const USAGE = `usage: activation_receipt --rule <id> | --skill <id> [options]
59
+
60
+ --rule ID a rule artefact, e.g. commit-policy
61
+ --skill ID a skill artefact, e.g. code-review
62
+ --host DIR host projection root (default: .claude)
63
+ --root DIR repository root (default: this checkout)
64
+ --dry-run observe and print; do NOT append to the audit ledger
65
+
66
+ Observes the eligible / selected / projected rungs and appends one
67
+ audit-log-v1 line carrying an \`activation\` object. Zero model calls.
68
+ `;
69
+
70
+ /** Where the artefact lives, per kind, in each of the two trees. */
71
+ interface ArtefactPaths {
72
+ readonly id: string;
73
+ readonly sourceRel: string;
74
+ readonly hostRel: string;
75
+ }
76
+
77
+ export function artefactPaths(kind: 'rule' | 'skill', id: string): ArtefactPaths {
78
+ return kind === 'rule'
79
+ ? { id, sourceRel: path.join('rules', `${id}.md`), hostRel: path.join('rules', `${id}.md`) }
80
+ : {
81
+ id,
82
+ sourceRel: path.join('skills', id, 'SKILL.md'),
83
+ hostRel: path.join('skills', id, 'SKILL.md'),
84
+ };
85
+ }
86
+
87
+ /**
88
+ * The ids the discovery manifest admits into the DEFAULT install.
89
+ *
90
+ * Returns an EMPTY set when the manifest is missing or unreadable, and
91
+ * `observeSelection` reads an empty set as "no manifest" rather than as "the
92
+ * manifest selected nothing" — the absent/negative split again, at the one place
93
+ * a build artefact can legitimately be missing.
94
+ */
95
+ export function selectedIdsFromManifest(manifestPath: string): Set<string> {
96
+ let doc: unknown;
97
+ try {
98
+ doc = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
99
+ } catch {
100
+ return new Set();
101
+ }
102
+ const artefacts = (doc as { artefacts?: unknown }).artefacts;
103
+ if (!Array.isArray(artefacts)) return new Set();
104
+ const out = new Set<string>();
105
+ for (const a of artefacts) {
106
+ const rec = a as { path?: unknown; install?: { default?: unknown } };
107
+ if (typeof rec.path !== 'string') continue;
108
+ if (rec.install?.default !== true) continue;
109
+ out.add(artefactIdFromPath(rec.path));
110
+ }
111
+ return out;
112
+ }
113
+
114
+ /**
115
+ * The artefact id a manifest path names.
116
+ *
117
+ * `src/rules/commit-policy.md` -> `commit-policy`;
118
+ * `src/skills/code-review/SKILL.md` -> `code-review`. Any other path yields its
119
+ * basename without extension, which is not an id anything will match — a
120
+ * deliberate non-match rather than a guess.
121
+ */
122
+ export function artefactIdFromPath(p: string): string {
123
+ const parts = p.split('/');
124
+ const last = parts[parts.length - 1] ?? '';
125
+ if (last === 'SKILL.md') return parts[parts.length - 2] ?? '';
126
+ return last.replace(/\.md$/, '');
127
+ }
128
+
129
+ export interface ObserveResult {
130
+ readonly receipt: ActivationReceipt | null;
131
+ readonly errors: readonly string[];
132
+ readonly observations: readonly RungObservation[];
133
+ }
134
+
135
+ /** The three observations, in ladder order. Pure apart from the reads. */
136
+ export function observeArtefact(
137
+ root: string,
138
+ hostRoot: string,
139
+ kind: 'rule' | 'skill',
140
+ id: string,
141
+ ): ObserveResult {
142
+ const p = artefactPaths(kind, id);
143
+ const observations: RungObservation[] = [];
144
+ const eligible = observeSourceTree(path.join(root, 'src'), p.sourceRel);
145
+ if (eligible !== undefined) observations.push(eligible);
146
+ const selected = observeSelection(
147
+ selectedIdsFromManifest(path.join(root, 'dist', 'discovery', 'discovery-manifest.json')),
148
+ id,
149
+ );
150
+ if (selected !== undefined) observations.push(selected);
151
+ const projected = observeProjection(hostRoot, p.hostRel);
152
+ if (projected !== undefined) observations.push(projected);
153
+
154
+ const { receipt, errors } = buildActivationReceipt(id, observations);
155
+ return { receipt, errors, observations };
156
+ }
157
+
158
+ interface Flags {
159
+ kind?: 'rule' | 'skill';
160
+ id?: string;
161
+ host?: string;
162
+ root?: string;
163
+ dryRun: boolean;
164
+ }
165
+
166
+ function parse(argv: readonly string[]): Flags | string {
167
+ const f: Flags = { dryRun: false };
168
+ for (let i = 0; i < argv.length; i += 1) {
169
+ const a = argv[i];
170
+ if (a === '--dry-run') {
171
+ f.dryRun = true;
172
+ continue;
173
+ }
174
+ const v = argv[i + 1];
175
+ if (v === undefined || v.startsWith('--')) return `${a} requires a value`;
176
+ i += 1;
177
+ if (a === '--rule') {
178
+ f.kind = 'rule';
179
+ f.id = v;
180
+ } else if (a === '--skill') {
181
+ f.kind = 'skill';
182
+ f.id = v;
183
+ } else if (a === '--host') {
184
+ f.host = v;
185
+ } else if (a === '--root') {
186
+ f.root = v;
187
+ } else {
188
+ return `unknown option '${a}'`;
189
+ }
190
+ }
191
+ return f;
192
+ }
193
+
194
+ export function main(argv?: string[]): number {
195
+ const args = argv ?? process.argv.slice(2);
196
+ if (args.length === 0 || args.includes('-h') || args.includes('--help')) {
197
+ process.stdout.write(USAGE);
198
+ return args.length === 0 ? EXIT_USAGE : EXIT_OK;
199
+ }
200
+ const parsed = parse(args);
201
+ if (typeof parsed === 'string') {
202
+ process.stderr.write(`activation_receipt: error: ${parsed}\n${USAGE}`);
203
+ return EXIT_USAGE;
204
+ }
205
+ if (parsed.kind === undefined || parsed.id === undefined || parsed.id === '') {
206
+ process.stderr.write(`activation_receipt: error: one of --rule or --skill is required\n${USAGE}`);
207
+ return EXIT_USAGE;
208
+ }
209
+ const root = parsed.root ?? REPO_ROOT;
210
+ const hostRoot = parsed.host ?? path.join(root, '.claude');
211
+
212
+ const { receipt, errors } = observeArtefact(root, hostRoot, parsed.kind, parsed.id);
213
+ if (receipt === null) {
214
+ for (const e of errors) process.stderr.write(`activation_receipt: refused — ${e}\n`);
215
+ return 1;
216
+ }
217
+
218
+ const stall = firstStall(receipt);
219
+ const observedCount = Object.keys(receipt.rungs).length;
220
+ process.stdout.write(
221
+ `activation_receipt · ${receipt.artefact} · observed ${observedCount}/${LADDER_RUNGS.length} rung(s) · ` +
222
+ (stall === null
223
+ ? 'climbed every observed rung · family=none\n'
224
+ : `stalled at ${stall.stage} · family=${stall.family}\n`),
225
+ );
226
+
227
+ const ts = new Date().toISOString();
228
+ const { line, errors: lineErrors } = buildActivationLine({
229
+ artefact: receipt.artefact,
230
+ rungs: receipt.rungs,
231
+ ts,
232
+ id: crypto.randomUUID(),
233
+ });
234
+ if (line === null) {
235
+ for (const e of lineErrors) process.stderr.write(`activation_receipt: refused — ${e}\n`);
236
+ return 1;
237
+ }
238
+ if (parsed.dryRun) {
239
+ process.stdout.write(`${JSON.stringify(line)}\n`);
240
+ return EXIT_OK;
241
+ }
242
+ const file = appendActivationLine(root, line, ts);
243
+ process.stdout.write(`activation_receipt · appended to ${path.relative(root, file)}\n`);
244
+ return EXIT_OK;
245
+ }
246
+
247
+ function _isCliEntry(): boolean {
248
+ try {
249
+ if (process.argv[1] === undefined) return false;
250
+ return fs.realpathSync(_HERE) === fs.realpathSync(path.resolve(process.argv[1]));
251
+ } catch {
252
+ return false;
253
+ }
254
+ }
255
+
256
+ if (_isCliEntry()) {
257
+ process.exit(main());
258
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Targeted cross-examination — step 8.1.
3
+ *
4
+ * `road-to-inbox-harvest-2026-08-e-council-topology-evidence` step 8.1: *"Select
5
+ * a disputed finding, a conflicting pair, or a correct-looking minority claim,
6
+ * and ask focused rebuttal questions"*, verified by *"the cross-exam prompt
7
+ * names the exact disputed claim"*.
8
+ *
9
+ * ## "Names the exact claim" is a byte-level obligation, not a summary
10
+ *
11
+ * The failure this prevents is a composer that paraphrases: *"one reviewer
12
+ * questioned the index approach"* is a description of a dispute and gives the
13
+ * cross-examined model nothing to rebut. The prompt therefore carries the
14
+ * claim's **verbatim text**, unmodified and untruncated, and
15
+ * {@link crossExamNamesClaim} is the predicate that decides it — a substring
16
+ * check on the original string, which is the only form of "exact" that survives
17
+ * a later edit to the prompt's wording.
18
+ *
19
+ * ## The claim is untrusted, and is fenced
20
+ *
21
+ * A disputed claim is model output. It reaches the prompt through
22
+ * `wrapUntrustedBlocks` under a nonce, exactly as `build_peer_review_user_prompt`
23
+ * already does (`prompts.ts:932-942`) — a claim that could close its own fence
24
+ * could inject instructions into the cross-exam. Fencing does not modify the
25
+ * payload, which is what lets it coexist with the verbatim obligation above.
26
+ *
27
+ * ## Selection is deterministic and ordered, and the order is argued
28
+ *
29
+ * `conflicting-pair` outranks `disputed-finding` outranks `minority-claim`. A
30
+ * conflicting pair is the only kind where **at least one side is definitely
31
+ * wrong**, so the rebuttal has the highest information density; a lone disputed
32
+ * finding may resolve to "both are partly right"; a correct-looking minority
33
+ * claim is last because the majority may simply be right and the call buys the
34
+ * least. Ties break on the claim id, so selection is a pure function of input.
35
+ *
36
+ * Pure and offline: composes a prompt, never dispatches one.
37
+ */
38
+ import { wrapUntrustedBlocks } from '../_lib/untrusted_content.js';
39
+
40
+ /** What kind of thing is being cross-examined. Priority order, strongest first. */
41
+ export const CROSS_EXAM_KINDS = ['conflicting-pair', 'disputed-finding', 'minority-claim'] as const;
42
+ export type CrossExamKind = (typeof CROSS_EXAM_KINDS)[number];
43
+
44
+ /** One candidate for cross-examination. */
45
+ export interface DisputedClaim {
46
+ readonly id: string;
47
+ readonly kind: CrossExamKind;
48
+ /** Neutral label of whoever asserted it (`Response-A`), never a provider name. */
49
+ readonly assertedBy: string;
50
+ /** THE CLAIM, VERBATIM. Never summarised, never truncated. */
51
+ readonly claim: string;
52
+ /** Neutral label of whoever disputes it, when there is one. */
53
+ readonly disputedBy?: string;
54
+ /** The opposing text, verbatim, when there is one. */
55
+ readonly counterClaim?: string;
56
+ }
57
+
58
+ /**
59
+ * The focused questions. Fixed and shared across kinds on purpose: a per-kind
60
+ * question set is a second thing to keep in sync with the neutrality contract,
61
+ * and every question below is answerable about any of the three kinds.
62
+ */
63
+ export const CROSS_EXAM_QUESTIONS: readonly string[] = [
64
+ 'Is the claim above true as stated? Answer yes, no, or "true only under a condition" and name the condition.',
65
+ 'What specific evidence would settle it? Name a file, a measurement, or a test — not a further opinion.',
66
+ 'If it is false, what is the smallest correction that makes it true?',
67
+ 'What would you have to believe for the opposing position to be correct?',
68
+ ];
69
+
70
+ const PREAMBLE = `You are cross-examining ONE specific claim from a council deliberation.
71
+ Do not review the artefact again, do not restate the debate, and do not
72
+ comment on any claim other than the one quoted below. Your entire reply
73
+ answers the numbered questions about that claim.`;
74
+
75
+ /**
76
+ * Pick the claim to cross-examine, or `null`.
77
+ *
78
+ * Deterministic: kind priority first, then claim id.
79
+ */
80
+ export function selectCrossExamTarget(candidates: readonly DisputedClaim[]): DisputedClaim | null {
81
+ if (candidates.length === 0) return null;
82
+ const ranked = [...candidates].sort((a, b) => {
83
+ const ka = CROSS_EXAM_KINDS.indexOf(a.kind);
84
+ const kb = CROSS_EXAM_KINDS.indexOf(b.kind);
85
+ return ka - kb || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
86
+ });
87
+ return ranked[0] as DisputedClaim;
88
+ }
89
+
90
+ /** Compose the cross-exam prompt. The claim text is carried verbatim. */
91
+ export function buildCrossExamPrompt(target: DisputedClaim, opts: { readonly nonce?: string } = {}): string {
92
+ const blocks = [{ heading: `### The claim under examination (asserted by ${target.assertedBy})`, content: target.claim }];
93
+ if (target.counterClaim !== undefined) {
94
+ blocks.push({
95
+ heading: `### The opposing position (asserted by ${target.disputedBy ?? 'another reviewer'})`,
96
+ content: target.counterClaim,
97
+ });
98
+ }
99
+ const fenced = wrapUntrustedBlocks(blocks, opts.nonce === undefined ? {} : { nonce: opts.nonce });
100
+ const questions = CROSS_EXAM_QUESTIONS.map((q, i) => `${String(i + 1)}. ${q}`).join('\n');
101
+ return [
102
+ PREAMBLE,
103
+ '',
104
+ `Kind: ${target.kind} · claim id: ${target.id}`,
105
+ '',
106
+ fenced,
107
+ '',
108
+ '---',
109
+ '',
110
+ 'Answer these, in order:',
111
+ '',
112
+ questions,
113
+ '',
114
+ ].join('\n');
115
+ }
116
+
117
+ /**
118
+ * Does this prompt name the exact claim?
119
+ *
120
+ * A plain substring check on the ORIGINAL string. Deliberately not a similarity
121
+ * score: "exact" that tolerates a threshold is not exact, and the paraphrase is
122
+ * the failure mode. Both sides of a pair must be present when a pair was given.
123
+ */
124
+ export function crossExamNamesClaim(prompt: string, target: DisputedClaim): boolean {
125
+ if (!prompt.includes(target.claim)) return false;
126
+ if (target.counterClaim !== undefined && !prompt.includes(target.counterClaim)) return false;
127
+ return true;
128
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Free explain mode — step 12.2.
3
+ *
4
+ * `road-to-inbox-harvest-2026-08-e-council-topology-evidence` step 12.2: *"Add
5
+ * a free explain mode: why task-side orchestration resolved to council, which
6
+ * topology would run, estimated spend and calls, evidence source — with no paid
7
+ * model call to explain routing"*, verified by *"explain mode issues zero
8
+ * provider calls"*.
9
+ *
10
+ * ## Free is the load-bearing word, and it is structural
11
+ *
12
+ * This module imports **no client, no transport and no dispatch**. It reads
13
+ * `explainLadder` (`_lib/judgment_ladder.ts:499`, itself regex-only) and
14
+ * `estimate_cost` (`pricing.ts:121`, arithmetic over a price table). There is
15
+ * nothing here to make a call WITH, which is a stronger guarantee than a
16
+ * documented promise not to — and the test asserts the import graph rather than
17
+ * trusting this paragraph.
18
+ *
19
+ * ## Three of the four fields are answerable today; one is not
20
+ *
21
+ * 1. **why it resolved to council** — `explainLadder`'s full per-rung trail,
22
+ * including rungs that were evaluated-and-rejected with the detector's own
23
+ * reason and rungs the short-circuit never reached. Free.
24
+ * 2. **which topology would run** — NOT ANSWERABLE. 7.2 is open, no selector
25
+ * exists, and naming a topology here would be a guess dressed as an
26
+ * explanation. Reported as {@link TOPOLOGY_UNAVAILABLE}, never omitted:
27
+ * a field silently missing from an explanation reads as "not applicable".
28
+ * 3. **estimated spend and calls** — arithmetic over the configured members,
29
+ * the round count and the price table, with the subscription-seat
30
+ * distinction `pricing.ts` already draws: a non-billable seat contributes
31
+ * calls but **zero** dollars, and the estimate says so per member rather
32
+ * than folding it into a total.
33
+ * 4. **evidence source** — which artefact, if any, licenses the routing.
34
+ * `none` is a legal and common value today and is rendered as such.
35
+ *
36
+ * Pure and offline.
37
+ */
38
+ import { explainLadder } from '../_lib/judgment_ladder.js';
39
+ import type { LadderExplanation, LadderInputs } from '../_lib/judgment_ladder.js';
40
+ import { estimate_cost, estimate_input_tokens } from './pricing.js';
41
+ import type { PriceTable } from './pricing.js';
42
+
43
+ /** The exact string the topology field carries while no selector exists. */
44
+ export const TOPOLOGY_UNAVAILABLE = 'unavailable — no topology selector exists (step 7.2 open)';
45
+
46
+ /** One member, as the estimate needs it. */
47
+ export interface EstimateMember {
48
+ readonly name: string;
49
+ readonly provider: string;
50
+ readonly model: string;
51
+ /**
52
+ * `false` for a vendor-official CLI seat running under the user's
53
+ * subscription auth — it contributes CALLS but no dollars, per
54
+ * `pricing.ts` § billable-aware aggregation.
55
+ */
56
+ readonly billable: boolean;
57
+ }
58
+
59
+ export interface SpendEstimate {
60
+ readonly calls: number;
61
+ readonly usd: number;
62
+ readonly perMember: readonly {
63
+ readonly name: string;
64
+ readonly calls: number;
65
+ readonly usd: number;
66
+ readonly billable: boolean;
67
+ }[];
68
+ /** Members whose calls are real but whose spend is zero. Named, not hidden. */
69
+ readonly nonBillableMembers: readonly string[];
70
+ }
71
+
72
+ export interface RouteExplanation {
73
+ /** Field 1 — free, from the regex-only resolver. */
74
+ readonly ladder: LadderExplanation;
75
+ /** Field 2 — always {@link TOPOLOGY_UNAVAILABLE} today. Present, never omitted. */
76
+ readonly topology: string;
77
+ /** Field 3 — arithmetic, no call. */
78
+ readonly spend: SpendEstimate;
79
+ /** Field 4 — the artefact licensing the route, or `'none'`. */
80
+ readonly evidenceSource: string;
81
+ }
82
+
83
+ /**
84
+ * Estimate calls and spend without issuing one.
85
+ *
86
+ * `calls` counts every member on every round; `usd` counts only the billable
87
+ * ones. Reporting a subscription seat's answer at API rates is the defect
88
+ * `pricing.ts` records finding on 2026-08-27, and this keeps the two figures
89
+ * separate rather than reproducing it.
90
+ */
91
+ export function estimateSpend(
92
+ members: readonly EstimateMember[],
93
+ rounds: number,
94
+ questionText: string,
95
+ maxOutputTokens: number,
96
+ table: PriceTable,
97
+ ): SpendEstimate {
98
+ const inputTokens = estimate_input_tokens(questionText);
99
+ const perMember = members.map((m) => {
100
+ const calls = Math.max(0, rounds);
101
+ if (!m.billable) return { name: m.name, calls, usd: 0, billable: false };
102
+ const c = estimate_cost(m.provider, m.model, inputTokens, maxOutputTokens, table);
103
+ return { name: m.name, calls, usd: (c.input_usd + c.output_usd) * calls, billable: true };
104
+ });
105
+ return {
106
+ calls: perMember.reduce((a, m) => a + m.calls, 0),
107
+ usd: perMember.reduce((a, m) => a + m.usd, 0),
108
+ perMember,
109
+ nonBillableMembers: perMember.filter((m) => !m.billable).map((m) => m.name),
110
+ };
111
+ }
112
+
113
+ /** Build the whole explanation. No call is issued and none can be. */
114
+ export function explainRoute(
115
+ ladderInputs: LadderInputs,
116
+ members: readonly EstimateMember[],
117
+ rounds: number,
118
+ questionText: string,
119
+ maxOutputTokens: number,
120
+ table: PriceTable,
121
+ evidenceSource: string = 'none',
122
+ ): RouteExplanation {
123
+ return {
124
+ ladder: explainLadder(ladderInputs),
125
+ topology: TOPOLOGY_UNAVAILABLE,
126
+ spend: estimateSpend(members, rounds, questionText, maxOutputTokens, table),
127
+ evidenceSource,
128
+ };
129
+ }
130
+
131
+ /** Human-readable rendering. Every one of the four fields appears, including the unavailable one. */
132
+ export function renderRouteExplanation(e: RouteExplanation): string {
133
+ const lines = [
134
+ 'council: explain (free — no provider call was issued)',
135
+ '',
136
+ ` resolution rung ${String(e.ladder.result.rung)} / ${e.ladder.result.verdict}`,
137
+ ` because ${e.ladder.result.reason}`,
138
+ ' ladder trail',
139
+ ];
140
+ for (const r of e.ladder.trail) {
141
+ lines.push(` rung ${String(r.rung)} → ${r.resolves_to.padEnd(8)} ${r.status.padEnd(12)} ${r.reason}`);
142
+ }
143
+ if (e.ladder.no_spawn_reason !== undefined) lines.push(` no spawn ${e.ladder.no_spawn_reason}`);
144
+ lines.push(
145
+ '',
146
+ ` topology ${e.topology}`,
147
+ ` estimated ${String(e.spend.calls)} call(s), $${e.spend.usd.toFixed(4)}`,
148
+ );
149
+ for (const m of e.spend.perMember) {
150
+ lines.push(
151
+ ` ${m.name.padEnd(14)} ${String(m.calls)} call(s), $${m.usd.toFixed(4)}` +
152
+ (m.billable ? '' : ' (subscription seat — calls are real, spend is not)'),
153
+ );
154
+ }
155
+ lines.push(` evidence ${e.evidenceSource}`, '');
156
+ return lines.join('\n');
157
+ }