@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,293 @@
1
+ /**
2
+ * Expected information gain per cost — step 8.4.
3
+ *
4
+ * `road-to-inbox-harvest-2026-08-e-council-topology-evidence` step 8.4: *"Score
5
+ * optional next calls by expected information gain per cost, deterministic and
6
+ * inspectable to start"*, verified by *"the score is reproducible from the
7
+ * recorded inputs"*.
8
+ *
9
+ * ## Every feature is one the tree already computes
10
+ *
11
+ * The gain terms are read from {@link DisagreementSignal}
12
+ * (`disagreement_signal.ts:134`), which 6.1 already established as **zero-cost
13
+ * and structural** — every input arrives from a verdict the run has already
14
+ * paid for. No new similarity measure, no new threshold, no model call to
15
+ * decide whether to make a model call. Forking a second feature set here would
16
+ * be the same defect 1A.1 forbids for the question hash: two answers to one
17
+ * question, and the second is the one nobody updates.
18
+ *
19
+ * ## "Deterministic and inspectable" is enforced, not promised
20
+ *
21
+ * - {@link scoreNextCall} is pure: same record in, same bytes out.
22
+ * - Terms are summed in a FIXED declared order, so floating-point association
23
+ * cannot vary with input ordering.
24
+ * - The result carries every term's raw value, weight and contribution, so the
25
+ * number can be re-derived by hand from what is printed.
26
+ * - The published figures are rounded to {@link SCORE_PRECISION} decimals, so a
27
+ * record round-tripped through JSON reproduces bit-identically.
28
+ * - {@link reproduceScore} is the verify clause made executable: recompute from
29
+ * a record's own inputs and compare.
30
+ *
31
+ * ## What an UNAVAILABLE component does, and why it is not zero
32
+ *
33
+ * A component the run could not observe is dropped from BOTH the numerator and
34
+ * the weight denominator. Treating it as 0 would read "not measured" as
35
+ * "measured, and it showed agreement" — the same NOT-RUN-is-not-a-null failure
36
+ * this roadmap records elsewhere. `basisComponents` reports how many terms the
37
+ * gain actually rests on, so a gain over one component is distinguishable from
38
+ * a gain over five, and {@link scoreNextCall} refuses to produce a gain at all
39
+ * when nothing is available.
40
+ *
41
+ * ## Honest scope
42
+ *
43
+ * This scores; it does not decide. 8.5 — stopping when the next call has low
44
+ * expected value — is explicitly gated on Phase 2 evidence
45
+ * (`blocker: phase-2-benchmark-cost`), and the weights below are **declared
46
+ * priors, not fitted values**: nothing has been benchmarked against them.
47
+ * `revisit-if`: the Phase 2 benchmark shows a term that does not predict a
48
+ * changed finding, or shows one that does and is absent here.
49
+ */
50
+ import type { DisagreementSignal, SignalComponent } from './disagreement_signal.js';
51
+
52
+ /** Decimals every published figure is rounded to. Makes JSON round-trip exact. */
53
+ export const SCORE_PRECISION = 6;
54
+
55
+ /**
56
+ * The gain terms, in the FIXED order they are summed.
57
+ *
58
+ * `direction` says how the raw component relates to expected gain:
59
+ * `'rising'` — the component rises with DISAGREEMENT, so it is used as-is;
60
+ * `'falling'` — it rises with AGREEMENT, so `1 - value` is the gain.
61
+ *
62
+ * `weight` is a declared prior. The two divergence terms carry the most because
63
+ * an unresolved split is the one state where another call can still change the
64
+ * verdict; `selfSimilarity` is inverted and weighted low because high
65
+ * self-similarity is exhaustion, which 6.2 already treats as a stop condition
66
+ * rather than as a gain signal.
67
+ */
68
+ export const GAIN_TERMS: readonly {
69
+ readonly key: keyof DisagreementSignal & string;
70
+ readonly weight: number;
71
+ readonly direction: 'rising' | 'falling';
72
+ readonly why: string;
73
+ }[] = [
74
+ {
75
+ key: 'stanceDivergence',
76
+ weight: 0.30,
77
+ direction: 'rising',
78
+ why: 'an unresolved split is the one state where another call can still change the verdict',
79
+ },
80
+ {
81
+ key: 'contradictionCount',
82
+ weight: 0.25,
83
+ direction: 'rising',
84
+ why: 'an explicit disagree vote names a specific thing a rebuttal can settle',
85
+ },
86
+ {
87
+ key: 'rankUncertainty',
88
+ weight: 0.20,
89
+ direction: 'rising',
90
+ why: 'tied findings mean the ranking has not resolved and one more input may break it',
91
+ },
92
+ {
93
+ key: 'confidenceSpread',
94
+ weight: 0.15,
95
+ direction: 'rising',
96
+ why: 'a wide confidence range means the members disagree about how sure to be, not only about what',
97
+ },
98
+ {
99
+ key: 'findingOverlap',
100
+ weight: 0.07,
101
+ direction: 'falling',
102
+ why: 'high overlap means the members are already covering the same ground',
103
+ },
104
+ {
105
+ key: 'selfSimilarity',
106
+ weight: 0.03,
107
+ direction: 'falling',
108
+ why: 'high self-similarity is exhaustion, which 6.2 handles as a stop rather than as a gain',
109
+ },
110
+ ];
111
+
112
+ /** `contradictionCount` is a count, not 0..1; this is its saturation point. */
113
+ export const CONTRADICTION_SATURATION = 5;
114
+
115
+ /** The cost half. Both figures come from the caller's own estimate. */
116
+ export interface CallCost {
117
+ /** Provider calls the option would issue. Must be > 0 to be scorable. */
118
+ readonly calls: number;
119
+ /** Estimated spend in USD. */
120
+ readonly costUsd: number;
121
+ }
122
+
123
+ /** One scorable option, exactly as recorded. */
124
+ export interface NextCallInputs {
125
+ /** Stable identifier for the option — `cross-exam:c-001`, `round-3`, … */
126
+ readonly optionId: string;
127
+ readonly signal: DisagreementSignal;
128
+ /** Open adversarial triggers. Rising with gain: an open objection is unfinished argument. */
129
+ readonly unresolvedAdversarialTriggers: number;
130
+ readonly cost: CallCost;
131
+ }
132
+
133
+ /** One term's full arithmetic, so the total can be re-derived by hand. */
134
+ export interface TermBreakdown {
135
+ readonly key: string;
136
+ readonly available: boolean;
137
+ /** The component's raw value, or `null` when unavailable. */
138
+ readonly raw: number | null;
139
+ /** Why it is unavailable, verbatim from the signal. `null` when available. */
140
+ readonly reason: string | null;
141
+ /** Raw mapped into 0..1 gain space (direction applied, count saturated). */
142
+ readonly normalised: number | null;
143
+ readonly weight: number;
144
+ /** `normalised * weight`, or 0 when unavailable. */
145
+ readonly contribution: number;
146
+ }
147
+
148
+ export interface NextCallScore {
149
+ readonly optionId: string;
150
+ /** 0..1 expected gain, or `null` when NO component was observable. */
151
+ readonly gain: number | null;
152
+ /** How many terms the gain rests on. 0..6. */
153
+ readonly basisComponents: number;
154
+ /** Sum of the weights of the available terms — the gain's denominator. */
155
+ readonly weightUsed: number;
156
+ /** `gain / costUsd`, or `null` when gain is null or cost is not positive. */
157
+ readonly gainPerCost: number | null;
158
+ /** `gain / calls`, or `null` on the same conditions. */
159
+ readonly gainPerCall: number | null;
160
+ readonly cost: CallCost;
161
+ readonly terms: readonly TermBreakdown[];
162
+ /** The trigger bonus applied to the gain, already included in it. */
163
+ readonly triggerBonus: number;
164
+ }
165
+
166
+ /** Round to {@link SCORE_PRECISION}. `-0` normalised to `0` so JSON round-trips. */
167
+ export function roundScore(v: number): number {
168
+ const f = 10 ** SCORE_PRECISION;
169
+ const r = Math.round(v * f) / f;
170
+ return Object.is(r, -0) ? 0 : r;
171
+ }
172
+
173
+ function _normalise(key: string, c: SignalComponent, direction: 'rising' | 'falling'): number {
174
+ const raw = (c as { value: number }).value;
175
+ // `contradictionCount` is the one count-valued component; everything else
176
+ // is already 0..1 by its own contract.
177
+ const scaled = key === 'contradictionCount' ? Math.min(1, raw / CONTRADICTION_SATURATION) : Math.min(1, Math.max(0, raw));
178
+ return direction === 'rising' ? scaled : 1 - scaled;
179
+ }
180
+
181
+ /**
182
+ * Score one option. Pure — no clock, no randomness, no file, no network.
183
+ *
184
+ * The trigger bonus is additive and capped: an unresolved adversarial trigger
185
+ * raises expected gain because an open objection is unfinished argument (6.2's
186
+ * own reasoning), but it may not by itself drive the gain to 1 — that would let
187
+ * one open trigger outvote every measured component.
188
+ */
189
+ export function scoreNextCall(inputs: NextCallInputs): NextCallScore {
190
+ const terms: TermBreakdown[] = [];
191
+ let weighted = 0;
192
+ let weightUsed = 0;
193
+ let basisComponents = 0;
194
+
195
+ for (const t of GAIN_TERMS) {
196
+ const c = inputs.signal[t.key] as SignalComponent;
197
+ if (c !== undefined && c.available) {
198
+ const normalised = _normalise(t.key, c, t.direction);
199
+ const contribution = normalised * t.weight;
200
+ weighted += contribution;
201
+ weightUsed += t.weight;
202
+ basisComponents += 1;
203
+ terms.push({
204
+ key: t.key,
205
+ available: true,
206
+ raw: roundScore(c.value),
207
+ reason: null,
208
+ normalised: roundScore(normalised),
209
+ weight: t.weight,
210
+ contribution: roundScore(contribution),
211
+ });
212
+ } else {
213
+ terms.push({
214
+ key: t.key,
215
+ available: false,
216
+ raw: null,
217
+ reason: c === undefined ? 'component-absent' : c.reason,
218
+ normalised: null,
219
+ weight: t.weight,
220
+ contribution: 0,
221
+ });
222
+ }
223
+ }
224
+
225
+ const triggerBonus = roundScore(Math.min(0.2, 0.1 * Math.max(0, inputs.unresolvedAdversarialTriggers)));
226
+ const gain =
227
+ basisComponents === 0 ? null : roundScore(Math.min(1, weighted / weightUsed + triggerBonus));
228
+
229
+ const scorable = gain !== null;
230
+ return {
231
+ optionId: inputs.optionId,
232
+ gain,
233
+ basisComponents,
234
+ weightUsed: roundScore(weightUsed),
235
+ gainPerCost:
236
+ scorable && inputs.cost.costUsd > 0 ? roundScore((gain as number) / inputs.cost.costUsd) : null,
237
+ gainPerCall: scorable && inputs.cost.calls > 0 ? roundScore((gain as number) / inputs.cost.calls) : null,
238
+ cost: inputs.cost,
239
+ terms,
240
+ triggerBonus,
241
+ };
242
+ }
243
+
244
+ /** Inputs plus the score they produced — the thing 8.4 calls "the recorded inputs". */
245
+ export interface NextCallRecord {
246
+ readonly inputs: NextCallInputs;
247
+ readonly score: NextCallScore;
248
+ }
249
+
250
+ export function recordNextCall(inputs: NextCallInputs): NextCallRecord {
251
+ return { inputs, score: scoreNextCall(inputs) };
252
+ }
253
+
254
+ /**
255
+ * The verify clause, executable: recompute the score from the record's OWN
256
+ * inputs and report whether it matches byte-for-byte under JSON.
257
+ */
258
+ export function reproduceScore(record: NextCallRecord): { reproduced: boolean; recomputed: NextCallScore } {
259
+ const recomputed = scoreNextCall(record.inputs);
260
+ return { reproduced: JSON.stringify(recomputed) === JSON.stringify(record.score), recomputed };
261
+ }
262
+
263
+ /** Rank options by gain per USD, then by gain per call, then by id. Deterministic. */
264
+ export function rankByGainPerCost(scores: readonly NextCallScore[]): NextCallScore[] {
265
+ return [...scores].sort(
266
+ (a, b) =>
267
+ (b.gainPerCost ?? -1) - (a.gainPerCost ?? -1) ||
268
+ (b.gainPerCall ?? -1) - (a.gainPerCall ?? -1) ||
269
+ (a.optionId < b.optionId ? -1 : a.optionId > b.optionId ? 1 : 0),
270
+ );
271
+ }
272
+
273
+ /** Inspectable rendering — every term's arithmetic, so the total is re-derivable by hand. */
274
+ export function renderNextCallScore(s: NextCallScore): string {
275
+ const lines = [`option ${s.optionId}`];
276
+ for (const t of s.terms) {
277
+ lines.push(
278
+ t.available
279
+ ? ` ${t.key.padEnd(20)} raw ${String(t.raw).padEnd(10)} × w ${t.weight.toFixed(2)} = ${String(t.contribution)}`
280
+ : ` ${t.key.padEnd(20)} unavailable (${t.reason ?? '—'}) — excluded from numerator AND denominator`,
281
+ );
282
+ }
283
+ lines.push(
284
+ ` trigger bonus ${String(s.triggerBonus)}`,
285
+ ` weight used ${String(s.weightUsed)} over ${String(s.basisComponents)} component(s)`,
286
+ ` gain ${s.gain === null ? 'null (nothing observable)' : String(s.gain)}`,
287
+ ` cost ${String(s.cost.calls)} call(s), $${s.cost.costUsd.toFixed(4)}`,
288
+ ` gain per USD ${s.gainPerCost === null ? 'null' : String(s.gainPerCost)}`,
289
+ ` gain per call ${s.gainPerCall === null ? 'null' : String(s.gainPerCall)}`,
290
+ '',
291
+ );
292
+ return lines.join('\n');
293
+ }
@@ -0,0 +1,385 @@
1
+ /**
2
+ * Corpus assembler for the provider-recognition leakage bench.
3
+ *
4
+ * `road-to-inbox-harvest-2026-08-e-council-topology-evidence`,
5
+ * `blocker: leakage-bench-needs-assembler-and-design-forks`, todo item 1:
6
+ * `collectGuesses` and `scoreRecognition` in `provider_leakage_bench.ts` both
7
+ * take `items: readonly LeakageItem[]` as an INJECTED parameter, and nothing in
8
+ * this tree ever produced those items from real council output. That gap — not
9
+ * quota, and not the un-committability of the corpus — is what kept the bench
10
+ * at NOT RUN. This module closes it and nothing else.
11
+ *
12
+ * It reads a directory of council response records — the JSON shape
13
+ * `{ responses: [{ provider, model, text, error, ... }] }` written per council
14
+ * run — and emits one `LeakageItem` per usable response body, with the response
15
+ * author as ground truth.
16
+ *
17
+ * ── Why the directory is a parameter, not an import ─────────────────────────
18
+ * `session.ts` binds `RESPONSES_DIR` at module load against the running
19
+ * checkout's root (`session.ts:70-73`). Importing it would couple this loader
20
+ * to one root and make it untestable over a fixture tree — and the same
21
+ * root-binding is part of the retention defect this corpus is the subject of.
22
+ * So the directory arrives as `opts.responsesDir` and this module imports
23
+ * nothing from `session.ts`.
24
+ *
25
+ * ── What this module does NOT do ────────────────────────────────────────────
26
+ * It does NOT anonymise. The bodies in `LeakageCorpus.items` are RAW: whatever
27
+ * the provider wrote, including any self-identification in the prose. An
28
+ * anonymisation pass is a DECLARED OPEN DESIGN FORK in the blocker above and is
29
+ * not settled here, so `assembleLeakageCorpus` MUST NOT be fed to a live rater
30
+ * until that fork is settled — a rater shown a raw body may be reading a
31
+ * signature rather than a style, and the recognition rate would then measure
32
+ * the absence of anonymisation rather than its failure.
33
+ *
34
+ * The `anonymise` seam below exists so the settled fork has somewhere to land
35
+ * without changing this module's shape. Its default is `IDENTITY_ANONYMISE`,
36
+ * which returns its input unchanged — deliberately, and tested for it, so that
37
+ * "the default anonymiser" can never be mistaken for anonymisation having
38
+ * happened.
39
+ *
40
+ * It also does NOT decide, score, prompt, or dispatch. No CLI, no `main()`, no
41
+ * transport, no provider calls.
42
+ *
43
+ * ── Why nothing here deletes ────────────────────────────────────────────────
44
+ * This module contains no write, no `unlink` and no `rm`, and must never gain
45
+ * one. The corpus IS the measurement subject: pruning it destroys the
46
+ * instrument, and the over-retention of these bodies is itself a recorded
47
+ * finding rather than a mess to tidy. Retention is therefore REPORTED — both
48
+ * partitions, counted per family in the census — and enforced by nobody here.
49
+ * The caller chooses which partition it is willing to measure over.
50
+ */
51
+ import * as crypto from 'node:crypto';
52
+ import * as fs from 'node:fs';
53
+ import * as path from 'node:path';
54
+
55
+ import type { LeakageItem } from './provider_leakage_bench.js';
56
+
57
+ /**
58
+ * The synthetic fixture's basename. Its own body states that "a live runner
59
+ * must refuse this file"; that refusal is implemented here rather than
60
+ * documented, because a recognition rate over hand-written fixture prose
61
+ * describes the fixture author and not a model.
62
+ */
63
+ export const SYNTHETIC_FIXTURE_BASENAME = 'smoke-items.json';
64
+
65
+ /** Any path segment containing this marker is the synthetic bench fixture dir. */
66
+ export const SYNTHETIC_PATH_MARKER = 'council-provider-leakage';
67
+
68
+ /** Mirrors the declared council retention window. Reported, never applied. */
69
+ export const DEFAULT_RETENTION_DAYS = 7;
70
+
71
+ const MS_PER_DAY = 86_400_000;
72
+
73
+ /** Thrown when the assembler is pointed at data that cannot measure leakage. */
74
+ export class SyntheticCorpusRefusal extends Error {
75
+ readonly source: string;
76
+ constructor(source: string, why: string) {
77
+ super(`refusing synthetic corpus data: ${source} — ${why}`);
78
+ this.name = 'SyntheticCorpusRefusal';
79
+ this.source = source;
80
+ }
81
+ }
82
+
83
+ /** Machine-readable exclusion buckets. A dropped body always names one. */
84
+ export type ExclusionReason =
85
+ /** Not a `.json` file (the tree also holds `.md` notes and stray dirs). */
86
+ | 'not-json-file'
87
+ /** File exists but is not parseable JSON. */
88
+ | 'unparseable-json'
89
+ /** Parsed, but carries no top-level `responses` array. */
90
+ | 'no-responses-array'
91
+ /** A `responses` entry that is not an object. */
92
+ | 'not-an-object'
93
+ /** Entry has a non-falsy `error` — a failed call has no body to rate. */
94
+ | 'response-carried-error'
95
+ /** Entry has no non-empty `provider`, so there is no ground truth. */
96
+ | 'missing-provider'
97
+ /** Entry has no non-empty `text`, so there is nothing to show a rater. */
98
+ | 'empty-text';
99
+
100
+ /** One thing that did not become an item, and why. Never a silent drop. */
101
+ export interface ExcludedRecord {
102
+ /** Source path relative to `responsesDir`, POSIX-separated. */
103
+ readonly source: string;
104
+ /** Index within `responses`, or `null` for a whole-file exclusion. */
105
+ readonly index: number | null;
106
+ readonly reason: ExclusionReason;
107
+ /** One clause of detail, safe to print beside the reason. */
108
+ readonly detail: string;
109
+ }
110
+
111
+ /**
112
+ * A corpus item: a `LeakageItem` plus the provenance the SCORER may see and the
113
+ * rater may not.
114
+ */
115
+ export interface CorpusItem extends LeakageItem {
116
+ /** Source path relative to `responsesDir`, POSIX-separated. */
117
+ readonly source_file: string;
118
+ readonly response_index: number;
119
+ /** Source file mtime in epoch ms — the only retention input. */
120
+ readonly mtime_ms: number;
121
+ /** Whether `mtime_ms` falls inside `retention_days` of `now`. Reported only. */
122
+ readonly within_retention: boolean;
123
+ }
124
+
125
+ /** Per-family counts. Keys are whatever providers were actually present. */
126
+ export type FamilyCounts = Readonly<Record<string, number>>;
127
+
128
+ export interface CorpusCensus {
129
+ readonly files_scanned: number;
130
+ readonly files_excluded: number;
131
+ readonly responses_seen: number;
132
+ readonly items_kept: number;
133
+ readonly items_excluded: number;
134
+ /** Echoed so a published census is self-describing. */
135
+ readonly retention_days: number;
136
+ readonly now_ms: number;
137
+ /** Kept items whose source mtime is inside the TTL, counted per family. */
138
+ readonly within_retention: FamilyCounts;
139
+ /** Kept items whose source mtime is older than the TTL, per family. */
140
+ readonly over_retention: FamilyCounts;
141
+ readonly within_retention_total: number;
142
+ readonly over_retention_total: number;
143
+ }
144
+
145
+ export interface LeakageCorpus {
146
+ readonly items: readonly CorpusItem[];
147
+ /** Sorted distinct families actually present. Never a hardcoded list. */
148
+ readonly families: readonly string[];
149
+ readonly excluded: readonly ExcludedRecord[];
150
+ readonly census: CorpusCensus;
151
+ }
152
+
153
+ export interface AssembleOptions {
154
+ /** Directory of council response records. Walked recursively. */
155
+ readonly responsesDir: string;
156
+ /** Epoch ms treated as "now" for the retention partition. */
157
+ readonly now?: number;
158
+ readonly retentionDays?: number;
159
+ /**
160
+ * The anonymisation seam. Defaults to `IDENTITY_ANONYMISE`, i.e. NO
161
+ * anonymisation — see the header. Supplying one does not make this module
162
+ * an anonymiser; it makes the fork's decision injectable.
163
+ */
164
+ readonly anonymise?: (text: string) => string;
165
+ }
166
+
167
+ /** The default `anonymise`: returns its argument unchanged. Not anonymisation. */
168
+ export const IDENTITY_ANONYMISE = (text: string): string => text;
169
+
170
+ /**
171
+ * Assemble a leakage corpus from a directory of council response records.
172
+ *
173
+ * Throws `SyntheticCorpusRefusal` — never excludes-and-continues — when the
174
+ * directory or any file in it is synthetic bench data. The blocker's wording is
175
+ * that a live runner must REFUSE it, and an exclusion is not a refusal: it
176
+ * would let a run proceed over a silently smaller corpus.
177
+ */
178
+ export function assembleLeakageCorpus(opts: AssembleOptions): LeakageCorpus {
179
+ const now = opts.now ?? Date.now();
180
+ const retentionDays = opts.retentionDays ?? DEFAULT_RETENTION_DAYS;
181
+ const anonymise = opts.anonymise ?? IDENTITY_ANONYMISE;
182
+ const root = opts.responsesDir;
183
+
184
+ if (_posix(path.resolve(root)).includes(SYNTHETIC_PATH_MARKER)) {
185
+ throw new SyntheticCorpusRefusal(
186
+ root,
187
+ `the directory path contains "${SYNTHETIC_PATH_MARKER}", the synthetic bench fixture location`,
188
+ );
189
+ }
190
+
191
+ const excluded: ExcludedRecord[] = [];
192
+ const items: CorpusItem[] = [];
193
+ let filesScanned = 0;
194
+ let filesExcluded = 0;
195
+ let responsesSeen = 0;
196
+
197
+ // Sorted relative paths: two runs over the same tree emit byte-identical
198
+ // output, which a bench whose result gets published has to be able to claim.
199
+ for (const rel of _walkSorted(root)) {
200
+ const abs = path.join(root, rel);
201
+ const base = path.basename(rel);
202
+
203
+ if (base === SYNTHETIC_FIXTURE_BASENAME) {
204
+ throw new SyntheticCorpusRefusal(
205
+ rel,
206
+ `basename is the synthetic fixture "${SYNTHETIC_FIXTURE_BASENAME}", whose own body states a live runner must refuse it`,
207
+ );
208
+ }
209
+ if (!base.endsWith('.json')) {
210
+ excluded.push({ source: rel, index: null, reason: 'not-json-file', detail: 'no .json extension' });
211
+ filesExcluded += 1;
212
+ continue;
213
+ }
214
+
215
+ filesScanned += 1;
216
+ let parsed: unknown;
217
+ try {
218
+ parsed = JSON.parse(fs.readFileSync(abs, 'utf8'));
219
+ } catch (err) {
220
+ excluded.push({
221
+ source: rel,
222
+ index: null,
223
+ reason: 'unparseable-json',
224
+ detail: err instanceof Error ? err.message : String(err),
225
+ });
226
+ filesExcluded += 1;
227
+ continue;
228
+ }
229
+
230
+ // A truthy top-level `synthetic` key is the fixture's self-declaration.
231
+ // Checked after the basename guard so a renamed fixture is still caught.
232
+ if (_isRecord(parsed) && Boolean(parsed['synthetic'])) {
233
+ throw new SyntheticCorpusRefusal(rel, 'the record carries a truthy top-level "synthetic" key');
234
+ }
235
+
236
+ const responses = _isRecord(parsed) ? parsed['responses'] : undefined;
237
+ if (!Array.isArray(responses)) {
238
+ excluded.push({
239
+ source: rel,
240
+ index: null,
241
+ reason: 'no-responses-array',
242
+ detail: 'no top-level `responses` array',
243
+ });
244
+ filesExcluded += 1;
245
+ continue;
246
+ }
247
+
248
+ const mtimeMs = fs.statSync(abs).mtimeMs;
249
+ const withinRetention = now - mtimeMs <= retentionDays * MS_PER_DAY;
250
+
251
+ for (let i = 0; i < responses.length; i += 1) {
252
+ responsesSeen += 1;
253
+ const entry: unknown = responses[i];
254
+ if (!_isRecord(entry)) {
255
+ excluded.push({ source: rel, index: i, reason: 'not-an-object', detail: `entry is ${typeof entry}` });
256
+ continue;
257
+ }
258
+ const error = entry['error'];
259
+ if (Boolean(error)) {
260
+ excluded.push({
261
+ source: rel,
262
+ index: i,
263
+ reason: 'response-carried-error',
264
+ detail: typeof error === 'string' ? error.slice(0, 120) : 'non-falsy error field',
265
+ });
266
+ continue;
267
+ }
268
+ const provider = _nonEmptyString(entry['provider']);
269
+ if (provider === null) {
270
+ excluded.push({ source: rel, index: i, reason: 'missing-provider', detail: 'no non-empty provider' });
271
+ continue;
272
+ }
273
+ const text = _nonEmptyString(entry['text']);
274
+ if (text === null) {
275
+ excluded.push({ source: rel, index: i, reason: 'empty-text', detail: 'no non-empty text' });
276
+ continue;
277
+ }
278
+ items.push({
279
+ id: leakageItemId(rel, i),
280
+ text: anonymise(text),
281
+ true_family: provider,
282
+ source_file: rel,
283
+ response_index: i,
284
+ mtime_ms: mtimeMs,
285
+ within_retention: withinRetention,
286
+ });
287
+ }
288
+ }
289
+
290
+ const within: Record<string, number> = {};
291
+ const over: Record<string, number> = {};
292
+ for (const item of items) {
293
+ const bucket = item.within_retention ? within : over;
294
+ bucket[item.true_family] = (bucket[item.true_family] ?? 0) + 1;
295
+ }
296
+
297
+ return {
298
+ items,
299
+ families: [...new Set(items.map((i) => i.true_family))].sort(),
300
+ excluded,
301
+ census: {
302
+ files_scanned: filesScanned,
303
+ files_excluded: filesExcluded,
304
+ responses_seen: responsesSeen,
305
+ items_kept: items.length,
306
+ items_excluded: excluded.filter((e) => e.index !== null).length,
307
+ retention_days: retentionDays,
308
+ now_ms: now,
309
+ within_retention: within,
310
+ over_retention: over,
311
+ within_retention_total: _sum(within),
312
+ over_retention_total: _sum(over),
313
+ },
314
+ };
315
+ }
316
+
317
+ /**
318
+ * Stable, collision-free, OPAQUE item id.
319
+ *
320
+ * The id is a digest of the source path plus the response index — NOT the
321
+ * basename — and that is load-bearing rather than stylistic: real filenames
322
+ * under `agents/runtime/council/responses/` include
323
+ * `anthropic-design-skills-integration.json` and several `claude-*.json`, so a
324
+ * basename-derived id would hand the rater the ground truth in the item's own
325
+ * identifier and the bench would measure reading, not recognition. The id must
326
+ * never embed the provider name, or any part of a path that might.
327
+ *
328
+ * Deterministic (a digest, not a counter) so two runs over the same tree
329
+ * produce the same ids, and index-suffixed so two bodies in one file cannot
330
+ * collide.
331
+ */
332
+ export function leakageItemId(relPath: string, index: number): string {
333
+ const digest = crypto.createHash('sha1').update(_posix(relPath)).digest('hex').slice(0, 12);
334
+ return `item-${digest}-${String(index)}`;
335
+ }
336
+
337
+ /** Recursive file walk, returning `root`-relative POSIX paths in sorted order. */
338
+ function _walkSorted(root: string): string[] {
339
+ const out: string[] = [];
340
+ const visit = (dirRel: string): void => {
341
+ let entries: fs.Dirent[];
342
+ try {
343
+ entries = fs.readdirSync(path.join(root, dirRel), { withFileTypes: true });
344
+ } catch {
345
+ return;
346
+ }
347
+ const names = entries.map((e) => e.name).sort();
348
+ for (const name of names) {
349
+ const rel = dirRel === '' ? name : `${dirRel}/${name}`;
350
+ const abs = path.join(root, rel);
351
+ let stat: fs.Stats;
352
+ try {
353
+ stat = fs.statSync(abs);
354
+ } catch {
355
+ continue;
356
+ }
357
+ if (stat.isDirectory()) {
358
+ // Real records nest: `<slug>.json/` directories hold per-round
359
+ // files. Recursing keeps those bodies eligible; the relative
360
+ // path keeps their ids distinct.
361
+ visit(rel);
362
+ } else if (stat.isFile()) {
363
+ out.push(rel);
364
+ }
365
+ }
366
+ };
367
+ visit('');
368
+ return out.sort();
369
+ }
370
+
371
+ function _posix(p: string): string {
372
+ return p.split(path.sep).join('/');
373
+ }
374
+
375
+ function _isRecord(v: unknown): v is Record<string, unknown> {
376
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
377
+ }
378
+
379
+ function _nonEmptyString(v: unknown): string | null {
380
+ return typeof v === 'string' && v.trim() !== '' ? v : null;
381
+ }
382
+
383
+ function _sum(counts: Readonly<Record<string, number>>): number {
384
+ return Object.values(counts).reduce((a, b) => a + b, 0);
385
+ }