@event4u/agent-config 14.13.0 → 14.14.1

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 (138) 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 +138 -376
  5. package/README.md +1 -1
  6. package/dist/agent-src/commands/analyze/inbox.md +36 -0
  7. package/dist/agent-src/commands/pr/create.md +16 -1
  8. package/dist/agent-src/contexts/execution/terminal-states.md +25 -4
  9. package/dist/agent-src/rules/code-comment-discipline.md +6 -0
  10. package/dist/agent-src/rules/missing-skill-recovery.md +2 -2
  11. package/dist/agent-src/rules/token-budget-discipline.md +2 -2
  12. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +6 -6
  13. package/dist/agent-src/scripts/guarded_baseline.ts +22 -7
  14. package/dist/agent-src/scripts/stubs_due.ts +118 -2
  15. package/dist/agent-src/scripts/update_roadmap_progress.ts +8 -8
  16. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  17. package/dist/cli/registry.js +1 -0
  18. package/dist/cli-delegate/{chunk-ILMUPLGW.js → chunk-7RXIKF62.js} +159 -86
  19. package/dist/cli-delegate/cmd_conformance.js +39 -2
  20. package/dist/cli-delegate/cmd_doctor.js +1 -1
  21. package/dist/discovery/deprecation-report.md +1 -1
  22. package/dist/discovery/discovery-manifest.json +7 -7
  23. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  24. package/dist/discovery/discovery-manifest.summary.md +1 -1
  25. package/dist/discovery/dormancy-report.md +1 -1
  26. package/dist/discovery/orphan-report.md +1 -1
  27. package/dist/discovery/packs.json +2 -2
  28. package/dist/discovery/trust-report.md +1 -1
  29. package/dist/discovery/workspaces.json +2 -2
  30. package/dist/hooks/dispatch.js +1696 -812
  31. package/dist/mcp/registry-manifest.json +2 -2
  32. package/dist/mcp/server.json +2 -2
  33. package/dist/mcp/server.mjs +297 -115
  34. package/docs/guidelines/agent-infra/guarded-baseline.md +1 -1
  35. package/docs/guidelines/code-clarity.md +18 -0
  36. package/package.json +1 -1
  37. package/src/agent-src/scripts/archive_completed_roadmaps.ts +6 -6
  38. package/src/agent-src/scripts/guarded_baseline.ts +22 -7
  39. package/src/agent-src/scripts/stubs_due.ts +118 -2
  40. package/src/agent-src/scripts/update_roadmap_progress.ts +8 -8
  41. package/src/config/assurance-capability-registry.json +1 -1
  42. package/src/config/estate-count-budget.json +20 -1
  43. package/src/config/evaluator-budgets.json +4 -4
  44. package/src/config/gate-coverage.yml +211 -1
  45. package/src/config/gate-reachability-exemptions.json +1 -0
  46. package/src/config/gate-violation-baselines.json +9 -3
  47. package/src/config/preamble-payload-budget.json +19 -11
  48. package/src/config/rule-stub-ceilings.json +14 -7
  49. package/src/config/web-launch-readiness.json +4 -2
  50. package/src/scripts/_cli/_doctor_emit.ts +71 -0
  51. package/src/scripts/_cli/_doctor_strict.ts +163 -0
  52. package/src/scripts/_cli/cmd_doctor.ts +16 -37
  53. package/src/scripts/_dispatch.bash +11 -4
  54. package/src/scripts/_lib/activation_ladder.ts +47 -3
  55. package/src/scripts/_lib/activation_receipt_producer.ts +341 -0
  56. package/src/scripts/_lib/adr_frontmatter.ts +26 -0
  57. package/src/scripts/_lib/branch_convergence.ts +186 -0
  58. package/src/scripts/_lib/candidate_pair_delta.ts +349 -0
  59. package/src/scripts/_lib/carrier_status.ts +118 -0
  60. package/src/scripts/_lib/cascade_stage_enumeration.ts +92 -0
  61. package/src/scripts/_lib/context_observation.ts +132 -0
  62. package/src/scripts/_lib/continuation_ladder.ts +188 -0
  63. package/src/scripts/_lib/corpus_manifest.ts +726 -0
  64. package/src/scripts/_lib/delivery_arm_experiment.ts +18 -2
  65. package/src/scripts/_lib/estate_carrier_cases.ts +72 -0
  66. package/src/scripts/_lib/evaluation_cascade.ts +452 -0
  67. package/src/scripts/_lib/evolution_roi.ts +562 -0
  68. package/src/scripts/_lib/experiment_binding.ts +95 -0
  69. package/src/scripts/_lib/harness_evolution_guards.ts +10 -0
  70. package/src/scripts/_lib/ignored_blocker.ts +8 -1
  71. package/src/scripts/_lib/lexical_shortlist.ts +215 -0
  72. package/src/scripts/_lib/llm_candidate_proposer.ts +461 -0
  73. package/src/scripts/_lib/llm_proposer_transport.ts +153 -0
  74. package/src/scripts/_lib/outcome_envelope.ts +6 -1
  75. package/src/scripts/_lib/outcome_vocabularies.ts +135 -5
  76. package/src/scripts/_lib/pathology_archive.ts +393 -0
  77. package/src/scripts/_lib/promotion_capability.ts +315 -0
  78. package/src/scripts/_lib/promotion_evidence.ts +414 -0
  79. package/src/scripts/_lib/promotion_review.ts +294 -0
  80. package/src/scripts/_lib/prompt_shape.ts +92 -0
  81. package/src/scripts/_lib/release_highlights.ts +79 -1
  82. package/src/scripts/_lib/release_material.ts +125 -2
  83. package/src/scripts/_lib/repeated_failure.ts +19 -2
  84. package/src/scripts/_lib/routing_corpus.ts +307 -0
  85. package/src/scripts/_lib/routing_index_input.ts +104 -0
  86. package/src/scripts/_lib/rule_injection.ts +22 -1
  87. package/src/scripts/_lib/runtime_journal.ts +11 -4
  88. package/src/scripts/_lib/semantic_noop.ts +167 -0
  89. package/src/scripts/activation_receipt.ts +258 -0
  90. package/src/scripts/ai_council/cross_exam.ts +128 -0
  91. package/src/scripts/ai_council/explain_route.ts +157 -0
  92. package/src/scripts/ai_council/information_gain.ts +293 -0
  93. package/src/scripts/ai_council/leakage_corpus.ts +385 -0
  94. package/src/scripts/ai_council/leakage_patterns.ts +453 -0
  95. package/src/scripts/ai_council/minority_retention.ts +171 -0
  96. package/src/scripts/ai_council/recouncil_savings.ts +299 -0
  97. package/src/scripts/ai_council/replay_route.ts +223 -0
  98. package/src/scripts/ai_council/reviewer_assignment.ts +279 -0
  99. package/src/scripts/ai_council/routing_training_row.ts +197 -0
  100. package/src/scripts/ai_council/synthesis_strategy.ts +159 -0
  101. package/src/scripts/check_adr_frontmatter.ts +4 -9
  102. package/src/scripts/check_agent_artifact_location.ts +337 -0
  103. package/src/scripts/check_artefact_count_messaging.ts +74 -8
  104. package/src/scripts/check_enforcement_coverage.ts +40 -1
  105. package/src/scripts/check_estate_count.ts +205 -24
  106. package/src/scripts/check_release_highlights.ts +113 -14
  107. package/src/scripts/check_roadmap_trackable.ts +17 -2
  108. package/src/scripts/check_web_launch_readiness.ts +310 -20
  109. package/src/scripts/corpus_manifest.ts +235 -0
  110. package/src/scripts/evolution_lab.ts +234 -9
  111. package/src/scripts/generate_capability_matrix.ts +16 -9
  112. package/src/scripts/grade_target_readiness.ts +221 -2
  113. package/src/scripts/hook_effect_doctor.ts +47 -4
  114. package/src/scripts/hook_manifest.json +1 -1
  115. package/src/scripts/hook_manifest.yaml +28 -6
  116. package/src/scripts/hooks/comment_discipline_hook.ts +146 -0
  117. package/src/scripts/hooks/concern_registry.ts +2 -0
  118. package/src/scripts/hooks/run_continuation_hook.ts +78 -88
  119. package/src/scripts/hooks/session_eol_hook.ts +12 -1
  120. package/src/scripts/hooks/turn_end_gate_hook.ts +9 -1
  121. package/src/scripts/install-hooks.sh +18 -0
  122. package/src/scripts/language_mirror_hook.ts +14 -2
  123. package/src/scripts/lint_carrier_integrity.ts +820 -0
  124. package/src/scripts/lint_code_comments.ts +748 -0
  125. package/src/scripts/lint_plan_risk_register.ts +39 -19
  126. package/src/scripts/lint_promotion_paths.ts +779 -0
  127. package/src/scripts/llm_propose.ts +256 -0
  128. package/src/scripts/measure_delivery_sets.ts +361 -0
  129. package/src/scripts/measure_routing_signal.ts +307 -0
  130. package/src/scripts/model_rule_injection.ts +19 -3
  131. package/src/scripts/probe_council_retention.ts +495 -0
  132. package/src/scripts/release.ts +21 -37
  133. package/src/scripts/release_drill.ts +199 -0
  134. package/src/scripts/release_env.ts +35 -0
  135. package/src/scripts/release_publication.ts +142 -0
  136. package/src/scripts/roadmap_context.ts +15 -0
  137. package/src/scripts/skill_scout.ts +633 -0
  138. package/src/scripts/sync_pr_branch.ts +521 -42
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Reviewer assignment under a budget `k`, with provider diversity preserved.
3
+ *
4
+ * `road-to-inbox-harvest-2026-08-e-council-topology-evidence` steps 8.2 and 8.3.
5
+ *
6
+ * ## What the shipped path does today, measured before anything was designed
7
+ *
8
+ * `orchestrator.ts:1509-1560` gives **every** reviewer **every** other member's
9
+ * answer: N reviewers × (N−1) candidates = **N(N−1) reviewed pairs**, batched
10
+ * into N provider calls. The pair count is the quantity that grows
11
+ * quadratically and the one that drives prompt size, latency and — once the
12
+ * cross-exam of 8.1 makes pairs individually addressable — call count. At N=8
13
+ * that is 56 pairs; at N=16 it is 240.
14
+ *
15
+ * ## The construction, and why it is a circulant
16
+ *
17
+ * Order the members so provider families interleave, then let the candidate at
18
+ * position `i` be reviewed by positions `i+1 … i+k` (mod N). This gives, for
19
+ * free and without search:
20
+ *
21
+ * - **no self-review** — the offsets start at 1 and stop before N;
22
+ * - **exact balance** — every candidate is reviewed exactly `k` times and
23
+ * every reviewer reviews exactly `k` candidates, so no member's context
24
+ * window carries more than another's;
25
+ * - **O(N×k) pairs** — `N·k`, against the unconditional `N(N−1)`;
26
+ * - **determinism** — a permutation seeded from the question text, replayable
27
+ * from the artefact, never `Math.random` and never `Date`, which is the
28
+ * same discipline `orchestrator.ts:1533-1543` already states for its own
29
+ * shuffle.
30
+ *
31
+ * Interleaving alone does NOT guarantee 8.3 when families are unequal (six of
32
+ * one family and two of another interleaves to `A B A B A A A A`, whose tail is
33
+ * all one family), so a **repair pass** follows: any candidate whose reviewer
34
+ * set is entirely same-family is fixed by a 2-swap with another candidate. A
35
+ * swap exchanges one reviewer between two candidates, so every load count is
36
+ * unchanged by construction — balance survives the repair.
37
+ *
38
+ * ## Honest scope
39
+ *
40
+ * This is the assignment; **nothing calls it yet**. `k = N−1` reproduces the
41
+ * shipped all-pairs behaviour exactly, which is what makes it a safe default
42
+ * and what lets the comparison below be arithmetic rather than a claim about a
43
+ * change nobody has made.
44
+ *
45
+ * Under the engine's current one-advisor-per-provider invariant
46
+ * (`chairman.ts:16-18`) every member is its own family, so the diversity
47
+ * constraint is **vacuously satisfied in production today**. It is implemented
48
+ * and tested against multi-member-per-family sets anyway, because Phase 9's
49
+ * advisor fan-out is what would produce them and the cheapest moment to have
50
+ * the property is before it can be violated.
51
+ *
52
+ * Pure and offline: no provider call, no file read.
53
+ */
54
+ import { deterministic_shuffle_indices } from './blind_review.js';
55
+
56
+ /** A council member, as assignment needs it. */
57
+ export interface ReviewMember {
58
+ /** Member name — the identity used in the assignment maps. */
59
+ readonly name: string;
60
+ /** Provider family (`anthropic`, `openai`, …). Diversity is defined over this. */
61
+ readonly family: string;
62
+ }
63
+
64
+ /** Candidate name → the reviewers assigned to it, in deterministic order. */
65
+ export type Assignment = ReadonlyMap<string, readonly string[]>;
66
+
67
+ export interface AssignmentResult {
68
+ readonly assignment: Assignment;
69
+ /** Reviewed pairs — the quantity 8.2 compares against both curves. */
70
+ readonly pairs: number;
71
+ /** Reviewers per candidate actually used, i.e. `min(k, N-1)`. */
72
+ readonly effectiveK: number;
73
+ /** How many 2-swaps the diversity repair performed. */
74
+ readonly diversityRepairs: number;
75
+ /**
76
+ * Candidates left with an all-same-family reviewer set.
77
+ *
78
+ * "Available" in 8.3 has to mean available UNDER THE BALANCE CONSTRAINT,
79
+ * not merely "a member of another family exists". Six members of family A
80
+ * and two of family B at `k = 1` gives eight candidates and only two
81
+ * B-reviewer slots, so four A candidates MUST be reviewed within their own
82
+ * family — no assignment avoids it. {@link diversityCeiling} is that bound
83
+ * made computable, and this list is the shortfall it predicts.
84
+ */
85
+ readonly unrepairable: readonly string[];
86
+ }
87
+
88
+ /**
89
+ * The most candidates any balanced assignment can make diverse at this `(members, k)`.
90
+ *
91
+ * A candidate of family `F` needs a reviewer from outside `F`; there are
92
+ * `n - |F|` such members and each reviews exactly `k` candidates, so family `F`
93
+ * can place at most `min(|F|, k * (n - |F|))` diverse candidates. Summed over
94
+ * families and capped at `n`.
95
+ *
96
+ * An UPPER BOUND, and stated as one: reviewer capacity is shared across
97
+ * families, so a contrived family profile could in principle fall short of it.
98
+ * Measured over 7 family profiles × every `k` in `1..n-1`, the greedy repair in
99
+ * {@link assignReviewers} attains it in every case, and the test sweeps a wider
100
+ * set — so the bound is treated as tight where it has been checked and as a
101
+ * bound everywhere else.
102
+ */
103
+ export function diversityCeiling(members: readonly ReviewMember[], k: number): number {
104
+ const n = members.length;
105
+ const eff = Math.max(0, Math.min(k, n - 1));
106
+ const sizes = new Map<string, number>();
107
+ for (const m of members) sizes.set(m.family, (sizes.get(m.family) ?? 0) + 1);
108
+ let total = 0;
109
+ for (const size of sizes.values()) total += Math.min(size, eff * (n - size));
110
+ return Math.min(total, n);
111
+ }
112
+
113
+ /** Both curves 8.2 names, at one `(N, k)`. Pure arithmetic. */
114
+ export function costCurves(n: number, k: number): {
115
+ n: number;
116
+ k: number;
117
+ pairs_quadratic: number;
118
+ pairs_linear: number;
119
+ ratio: number;
120
+ } {
121
+ const eff = Math.max(0, Math.min(k, n - 1));
122
+ const quad = n * (n - 1);
123
+ const lin = n * eff;
124
+ return { n, k: eff, pairs_quadratic: quad, pairs_linear: lin, ratio: quad === 0 ? 1 : lin / quad };
125
+ }
126
+
127
+ /**
128
+ * Interleave members so that consecutive positions come from different families
129
+ * wherever the family sizes allow it: repeatedly take one member from the
130
+ * currently largest remaining family, preferring one different from the last
131
+ * pick.
132
+ *
133
+ * TIES BREAK ON INPUT ORDER, NOT ON FAMILY NAME, and the difference is
134
+ * load-bearing rather than stylistic. Under the engine's one-advisor-per-
135
+ * provider invariant every family has size 1, so every comparison is a tie — a
136
+ * name tie-break would then sort the whole council alphabetically and DISCARD
137
+ * the seeded permutation entirely, making `assignReviewers` produce the same
138
+ * assignment for every seed. Measured: the seed-sensitivity test reds under the
139
+ * name tie-break at N=8. Bucket iteration order is the (already permuted) input
140
+ * order and `Array.prototype.sort` is stable, so input-order ties keep the
141
+ * result a pure function of the input while letting the seed through.
142
+ */
143
+ export function interleaveByFamily(members: readonly ReviewMember[]): ReviewMember[] {
144
+ const buckets = new Map<string, ReviewMember[]>();
145
+ for (const m of members) {
146
+ const b = buckets.get(m.family);
147
+ if (b) b.push(m);
148
+ else buckets.set(m.family, [m]);
149
+ }
150
+ const out: ReviewMember[] = [];
151
+ let lastFamily = '';
152
+ while (out.length < members.length) {
153
+ const eligible = [...buckets.entries()].filter(([, v]) => v.length > 0);
154
+ // Prefer a family different from the previous pick; fall back to any.
155
+ const pool = eligible.filter(([f]) => f !== lastFamily);
156
+ const pick = (pool.length > 0 ? pool : eligible).sort((a, b) => b[1].length - a[1].length)[0];
157
+ if (pick === undefined) break;
158
+ out.push((pick[1] as ReviewMember[]).shift() as ReviewMember);
159
+ lastFamily = pick[0] as string;
160
+ }
161
+ return out;
162
+ }
163
+
164
+ /** Is this candidate reviewed by at least one different family? */
165
+ function _diverse(candidate: ReviewMember, reviewers: readonly string[], byName: ReadonlyMap<string, ReviewMember>): boolean {
166
+ return reviewers.some((r) => (byName.get(r) as ReviewMember).family !== candidate.family);
167
+ }
168
+
169
+ /**
170
+ * Assign `k` reviewers per candidate: balanced, self-review-free, diverse where
171
+ * a cross-family reviewer exists, deterministic in `seed`.
172
+ *
173
+ * `k >= n-1` reproduces the shipped all-pairs behaviour exactly.
174
+ */
175
+ export function assignReviewers(
176
+ members: readonly ReviewMember[],
177
+ k: number,
178
+ seed: string,
179
+ ): AssignmentResult {
180
+ const n = members.length;
181
+ const effectiveK = Math.max(0, Math.min(k, n - 1));
182
+ const byName = new Map(members.map((m) => [m.name, m]));
183
+ if (n < 2 || effectiveK === 0) {
184
+ return {
185
+ assignment: new Map(members.map((m) => [m.name, [] as string[]])),
186
+ pairs: 0,
187
+ effectiveK: 0,
188
+ diversityRepairs: 0,
189
+ unrepairable: [],
190
+ };
191
+ }
192
+
193
+ // Seeded permutation first, then the family interleave over it: the seed
194
+ // decides WHICH member sits where within a family, the interleave decides
195
+ // that consecutive positions differ in family.
196
+ const perm = deterministic_shuffle_indices(seed, n);
197
+ const ordered = interleaveByFamily(perm.map((i) => members[i] as ReviewMember));
198
+
199
+ const assignment = new Map<string, string[]>();
200
+ for (let i = 0; i < n; i++) {
201
+ const candidate = ordered[i] as ReviewMember;
202
+ const reviewers: string[] = [];
203
+ for (let d = 1; d <= effectiveK; d++) {
204
+ reviewers.push((ordered[(i + d) % n] as ReviewMember).name);
205
+ }
206
+ assignment.set(candidate.name, reviewers);
207
+ }
208
+
209
+ // ── diversity repair ────────────────────────────────────────────────────
210
+ // A 2-swap exchanges one reviewer between two candidates, so every
211
+ // reviewer's load and every candidate's count are invariant.
212
+ let diversityRepairs = 0;
213
+ const unrepairable: string[] = [];
214
+ const familyCounts = new Map<string, number>();
215
+ for (const m of members) familyCounts.set(m.family, (familyCounts.get(m.family) ?? 0) + 1);
216
+
217
+ for (const candidate of ordered) {
218
+ const mine = assignment.get(candidate.name) as string[];
219
+ if (_diverse(candidate, mine, byName)) continue;
220
+ // Does a cross-family reviewer even exist for this candidate?
221
+ if ((familyCounts.get(candidate.family) ?? 0) === n) {
222
+ unrepairable.push(candidate.name);
223
+ continue;
224
+ }
225
+ let fixed = false;
226
+ for (const other of ordered) {
227
+ if (other.name === candidate.name || fixed) continue;
228
+ const theirs = assignment.get(other.name) as string[];
229
+ for (const give of mine) {
230
+ for (const take of theirs) {
231
+ if (fixed) break;
232
+ if (take === candidate.name || give === other.name) continue; // no self-review
233
+ if (theirs.includes(give) || mine.includes(take)) continue; // no duplicate reviewer
234
+ const takeM = byName.get(take) as ReviewMember;
235
+ if (takeM.family === candidate.family) continue; // does not help
236
+ const theirsAfter = theirs.map((r) => (r === take ? give : r));
237
+ if (!_diverse(other, theirsAfter, byName)) continue; // must not break them
238
+ mine[mine.indexOf(give)] = take;
239
+ assignment.set(other.name, theirsAfter);
240
+ diversityRepairs++;
241
+ fixed = true;
242
+ }
243
+ }
244
+ }
245
+ if (!fixed) unrepairable.push(candidate.name);
246
+ }
247
+
248
+ let pairs = 0;
249
+ for (const v of assignment.values()) pairs += v.length;
250
+ return { assignment, pairs, effectiveK, diversityRepairs, unrepairable };
251
+ }
252
+
253
+ /** Reviewer → how many candidates it reviews. The balance figure. */
254
+ export function reviewerLoads(assignment: Assignment): Map<string, number> {
255
+ const loads = new Map<string, number>();
256
+ for (const reviewers of assignment.values()) {
257
+ for (const r of reviewers) loads.set(r, (loads.get(r) ?? 0) + 1);
258
+ }
259
+ return loads;
260
+ }
261
+
262
+ /** Candidates reviewed only by their own family, given the members. */
263
+ export function nonDiverseCandidates(assignment: Assignment, members: readonly ReviewMember[]): string[] {
264
+ const byName = new Map(members.map((m) => [m.name, m]));
265
+ const out: string[] = [];
266
+ for (const [name, reviewers] of assignment) {
267
+ const c = byName.get(name);
268
+ if (c === undefined || reviewers.length === 0) continue;
269
+ if (!_diverse(c, reviewers, byName)) out.push(name);
270
+ }
271
+ return out.sort();
272
+ }
273
+
274
+ /** Was a cross-family reviewer available for this candidate at all? */
275
+ export function crossFamilyAvailable(candidate: string, members: readonly ReviewMember[]): boolean {
276
+ const c = members.find((m) => m.name === candidate);
277
+ if (c === undefined) return false;
278
+ return members.some((m) => m.name !== candidate && m.family !== c.family);
279
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * The offline routing-training row schema — step 11.1.
3
+ *
4
+ * `road-to-inbox-harvest-2026-08-e-council-topology-evidence` step 11.1:
5
+ * *"Collect offline training rows from benchmark and dogfood evidence only,
6
+ * without requiring raw private prompt content"*, verified by *"the row schema
7
+ * has no field capable of holding prompt text"*.
8
+ *
9
+ * ## PII-exclusion by construction, not by scrubbing
10
+ *
11
+ * The privacy property is a property of the TYPE, not of a sanitisation pass
12
+ * that can fail: **no field accepts free text**. Every field is an integer, a
13
+ * boolean, or an enum whose legal values are a declared closed set. A row that
14
+ * cannot hold a sentence has no scrubber to forget to run — the same principle
15
+ * `domain-safety-pii` § Surface 2 applies to logs and
16
+ * `artifact-engagement-recording` applies to telemetry.
17
+ *
18
+ * There is deliberately no `payload`, `notes`, `extra`, `context`,
19
+ * `promptText`, `Record<string, unknown>` or `unknown`-typed field, and
20
+ * {@link auditRowSchema} rejects one if a later edit adds it.
21
+ *
22
+ * ## Two layers, because one is not enough
23
+ *
24
+ * 1. {@link ROW_FIELDS} is the declared manifest — name plus kind plus, for
25
+ * an enum, the closed value set. {@link auditRowSchema} walks a row
26
+ * against it and refuses any field the manifest does not declare, any
27
+ * value outside a declared enum, and any string-shaped value at all.
28
+ * 2. The interface itself is checked from the OUTSIDE by
29
+ * `tests/scripts/ai_council/routing_training_row.test.ts`, which greps the
30
+ * module source for a bare `string` / `any` / `unknown` /
31
+ * `Record<string, …>` field declaration. The manifest alone cannot see a
32
+ * field somebody adds to the interface and forgets to declare.
33
+ *
34
+ * ## Honest scope — the step is NOT closed by this file
35
+ *
36
+ * This is the schema. **No rows have been collected**, and the benchmark half
37
+ * of the evidence 11.1 names does not exist: `blocker: phase-2-benchmark-cost`
38
+ * records that `topology_bench_manifest.ts` `main()` only `--emit`s JSON and
39
+ * contains no provider dispatch. The verify clause is discharged; the step's
40
+ * collection half is not, which is why 11.1 stays unchecked.
41
+ *
42
+ * Pure and offline: type declarations, one manifest, one validator.
43
+ */
44
+ import { COUNCIL_TOPOLOGIES } from './topology_vocabulary.js';
45
+ import type { CouncilTopology } from './topology_vocabulary.js';
46
+ import type { ImpactClass } from './necessity.js';
47
+
48
+ /** Impact classes, as a runtime closed set. Mirrors `necessity.ts:545-550`. */
49
+ export const IMPACT_CLASSES = Object.freeze([
50
+ 'trivial',
51
+ 'low_impact',
52
+ 'medium_impact',
53
+ 'high_impact',
54
+ 'user_required',
55
+ ] as const);
56
+
57
+ /** Where the row came from. 11.1 admits exactly these two and no third. */
58
+ export const EVIDENCE_SOURCES = Object.freeze(['benchmark', 'dogfood'] as const);
59
+ export type EvidenceSource = (typeof EVIDENCE_SOURCES)[number];
60
+
61
+ /** Bucketed magnitudes. Buckets, never raw sizes — a raw byte count of a prompt is a weak fingerprint. */
62
+ export const MAGNITUDE_BUCKETS = Object.freeze(['xs', 's', 'm', 'l', 'xl'] as const);
63
+ export type MagnitudeBucket = (typeof MAGNITUDE_BUCKETS)[number];
64
+
65
+ /** What the run did to the verdict. The label a challenger would learn against. */
66
+ export const ROW_OUTCOMES = Object.freeze(['verdict-changed', 'verdict-unchanged', 'no-verdict'] as const);
67
+ export type RowOutcome = (typeof ROW_OUTCOMES)[number];
68
+
69
+ /**
70
+ * One training row.
71
+ *
72
+ * EVERY field is an integer, a boolean, or one of the closed enums above.
73
+ * Adding a `string`-typed field here is the violation this schema exists to
74
+ * make impossible, and the test file greps for exactly that.
75
+ */
76
+ export interface RoutingTrainingRow {
77
+ readonly evidenceSource: EvidenceSource;
78
+ readonly topology: CouncilTopology;
79
+ readonly impactClass: ImpactClass;
80
+ readonly artifactSizeBucket: MagnitudeBucket;
81
+ readonly initialDisagreementBucket: MagnitudeBucket;
82
+ readonly latencyBucket: MagnitudeBucket;
83
+ readonly outcome: RowOutcome;
84
+ readonly memberCount: number;
85
+ readonly providerFamilyCount: number;
86
+ readonly roundsConfigured: number;
87
+ readonly roundsCompleted: number;
88
+ readonly priorRunFreshnessDays: number;
89
+ readonly estimatedCalls: number;
90
+ readonly observedCalls: number;
91
+ readonly estimatedCostCents: number;
92
+ readonly observedCostCents: number;
93
+ readonly stoppedEarly: boolean;
94
+ readonly minorityRetained: boolean;
95
+ }
96
+
97
+ /** A declared field. `enum` carries its closed value set; nothing else may be a string. */
98
+ export type FieldKind = 'integer' | 'boolean' | 'enum';
99
+
100
+ export interface RowField {
101
+ readonly name: keyof RoutingTrainingRow & string;
102
+ readonly kind: FieldKind;
103
+ /** Present iff `kind === 'enum'`. The ONLY legal string values in a row. */
104
+ readonly values?: readonly string[];
105
+ }
106
+
107
+ /** The manifest. Order is the serialisation order; the set is the schema. */
108
+ export const ROW_FIELDS: readonly RowField[] = Object.freeze([
109
+ { name: 'evidenceSource', kind: 'enum', values: EVIDENCE_SOURCES },
110
+ { name: 'topology', kind: 'enum', values: COUNCIL_TOPOLOGIES },
111
+ { name: 'impactClass', kind: 'enum', values: IMPACT_CLASSES },
112
+ { name: 'artifactSizeBucket', kind: 'enum', values: MAGNITUDE_BUCKETS },
113
+ { name: 'initialDisagreementBucket', kind: 'enum', values: MAGNITUDE_BUCKETS },
114
+ { name: 'latencyBucket', kind: 'enum', values: MAGNITUDE_BUCKETS },
115
+ { name: 'outcome', kind: 'enum', values: ROW_OUTCOMES },
116
+ { name: 'memberCount', kind: 'integer' },
117
+ { name: 'providerFamilyCount', kind: 'integer' },
118
+ { name: 'roundsConfigured', kind: 'integer' },
119
+ { name: 'roundsCompleted', kind: 'integer' },
120
+ { name: 'priorRunFreshnessDays', kind: 'integer' },
121
+ { name: 'estimatedCalls', kind: 'integer' },
122
+ { name: 'observedCalls', kind: 'integer' },
123
+ { name: 'estimatedCostCents', kind: 'integer' },
124
+ { name: 'observedCostCents', kind: 'integer' },
125
+ { name: 'stoppedEarly', kind: 'boolean' },
126
+ { name: 'minorityRetained', kind: 'boolean' },
127
+ ] as const);
128
+
129
+ /**
130
+ * The longest a legal enum value may be. A closed set whose members are all
131
+ * short is not a text field; a "closed set" containing a paragraph is one
132
+ * wearing a disguise, and this is the cheapest thing that tells them apart.
133
+ */
134
+ export const MAX_ENUM_VALUE_LENGTH = 40;
135
+
136
+ /** Every reason a row or the schema itself is rejected. Empty means clean. */
137
+ export function auditRowSchema(row: Record<string, unknown>): string[] {
138
+ const problems: string[] = [];
139
+ const declared = new Map(ROW_FIELDS.map((f) => [f.name as string, f]));
140
+
141
+ for (const f of ROW_FIELDS) {
142
+ if (f.kind === 'enum') {
143
+ if (f.values === undefined || f.values.length === 0) {
144
+ problems.push(`field \`${f.name}\` is an enum with no declared value set — that is a free-text field`);
145
+ continue;
146
+ }
147
+ for (const v of f.values) {
148
+ if (v.length > MAX_ENUM_VALUE_LENGTH) {
149
+ problems.push(
150
+ `field \`${f.name}\` declares the value \`${v.slice(0, 20)}…\` (${String(v.length)} chars), ` +
151
+ `over the ${String(MAX_ENUM_VALUE_LENGTH)}-char enum limit — a closed set of paragraphs is a text field`,
152
+ );
153
+ }
154
+ }
155
+ } else if (f.values !== undefined) {
156
+ problems.push(`field \`${f.name}\` is \`${f.kind}\` but declares an enum value set`);
157
+ }
158
+ }
159
+
160
+ for (const [key, value] of Object.entries(row)) {
161
+ const f = declared.get(key);
162
+ if (f === undefined) {
163
+ problems.push(`row carries \`${key}\`, which the schema does not declare — undeclared fields are rejected`);
164
+ continue;
165
+ }
166
+ if (f.kind === 'integer') {
167
+ if (typeof value !== 'number' || !Number.isInteger(value)) {
168
+ problems.push(`field \`${key}\` must be an integer, got ${typeof value}`);
169
+ }
170
+ } else if (f.kind === 'boolean') {
171
+ if (typeof value !== 'boolean') problems.push(`field \`${key}\` must be a boolean, got ${typeof value}`);
172
+ } else if (typeof value !== 'string' || !(f.values as readonly string[]).includes(value)) {
173
+ problems.push(
174
+ `field \`${key}\` must be one of ${(f.values as readonly string[]).join(' | ')} — ` +
175
+ 'an arbitrary string is exactly the prompt-text carrier this schema forbids',
176
+ );
177
+ }
178
+ }
179
+
180
+ for (const f of ROW_FIELDS) {
181
+ if (!Object.prototype.hasOwnProperty.call(row, f.name)) {
182
+ problems.push(`row is missing declared field \`${f.name}\``);
183
+ }
184
+ }
185
+ return problems;
186
+ }
187
+
188
+ /** Serialise a validated row. Throws rather than emitting a row that failed the audit. */
189
+ export function serialiseRow(row: RoutingTrainingRow): string {
190
+ const problems = auditRowSchema(row as unknown as Record<string, unknown>);
191
+ if (problems.length > 0) {
192
+ throw new Error(`routing training row rejected:\n - ${problems.join('\n - ')}`);
193
+ }
194
+ const ordered: Record<string, unknown> = {};
195
+ for (const f of ROW_FIELDS) ordered[f.name] = (row as unknown as Record<string, unknown>)[f.name];
196
+ return JSON.stringify(ordered);
197
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * One synthesis-strategy interface behind the five candidates — step 5.1.
3
+ *
4
+ * `road-to-inbox-harvest-2026-08-e-council-topology-evidence` step 5.1: *"One
5
+ * synthesis-strategy interface behind the candidates (host convener, dedicated
6
+ * external judge, strongest configured model, top-ranked member, dual synthesis
7
+ * + adjudication) — no user-facing mode proliferation"*, verified by *"the
8
+ * user-facing surface gains no new mode names"*.
9
+ *
10
+ * ## The whole point is that this is INTERNAL
11
+ *
12
+ * Five candidates, one interface, and **zero** new flags, choices or synthesis
13
+ * mode names. Every strategy resolves from configuration the engine already
14
+ * reads — `ai_council.chairman.mode` (`host` | `member` | `auto`), the
15
+ * configured member name, and the optional `members.<name>.tier` — so a
16
+ * consumer's surface is byte-identical before and after this module. That is
17
+ * 12.4's rule ("consumer surfaces request capabilities, never topology names")
18
+ * applied to synthesis, and the test file asserts it against the CLI's own
19
+ * option tables rather than trusting this paragraph.
20
+ *
21
+ * ## Selection is NOT reimplemented
22
+ *
23
+ * Every strategy delegates to `select_chairman` (`chairman.ts:42`). A second
24
+ * selection path would be two answers to "who chairs", and the second is the one
25
+ * nobody updates — the same defect 1A.1 forbids for the question hash. What this
26
+ * module adds is a NAME for what the existing selection is doing in each
27
+ * configuration, so Phase 5's showdown has something to compare.
28
+ *
29
+ * ## One candidate is deliberately unreachable, and saying so is the honest part
30
+ *
31
+ * `dual_adjudicated` (dual synthesis plus adjudication) cannot be selected from
32
+ * today's configuration: nothing in `ai_council.chairman` expresses "run two
33
+ * synthesisers and adjudicate". Reaching it needs a new input — which is exactly
34
+ * what 5.1's verify clause forbids adding to the user surface. It is therefore
35
+ * declared in the vocabulary, reported as `reachable: false`, and left
36
+ * unselectable rather than smuggled in behind a flag.
37
+ *
38
+ * Pure: resolution and description only. The billable dispatch stays in
39
+ * `council_cli.ts` `_maybe_run_chairman`, exactly where it already lives.
40
+ */
41
+ import { select_chairman } from './chairman.js';
42
+ import type { ChairmanCandidate, ChairmanSelection } from './chairman.js';
43
+
44
+ /** The five candidates 5.1 enumerates, in the order it enumerates them. */
45
+ export const SYNTHESIS_STRATEGIES = Object.freeze([
46
+ 'host_convener',
47
+ 'external_judge',
48
+ 'strongest_model',
49
+ 'top_ranked_member',
50
+ 'dual_adjudicated',
51
+ ] as const);
52
+
53
+ export type SynthesisStrategyId = (typeof SYNTHESIS_STRATEGIES)[number];
54
+
55
+ /** Declared arity, so an append reds the closure assertion in the test. */
56
+ export const SYNTHESIS_STRATEGY_ARITY = 5;
57
+
58
+ /** Exactly the configuration the engine ALREADY reads. No new key appears here. */
59
+ export interface SynthesisContext {
60
+ /** `ai_council.chairman.mode` — `host` | `member` | `auto`. */
61
+ readonly chairmanMode: string;
62
+ /** `ai_council.chairman.member` when the mode is `member`. */
63
+ readonly configuredMember: string | null;
64
+ /** Member names that produced a real response this session. */
65
+ readonly deliberated: ReadonlySet<string>;
66
+ /** Enabled members in config order, with their optional `tier`. */
67
+ readonly candidates: readonly ChairmanCandidate[];
68
+ }
69
+
70
+ /** The ONE interface all five candidates sit behind. */
71
+ export interface SynthesisStrategy {
72
+ readonly id: SynthesisStrategyId;
73
+ /** Internal description. Never rendered on a user-facing surface. */
74
+ readonly describe: string;
75
+ /** Can today's configuration reach it at all? */
76
+ readonly reachable: boolean;
77
+ /** Extra billable calls beyond the deliberation, at this strategy. */
78
+ readonly extraCalls: number;
79
+ /** Delegates to `select_chairman` — never a second selection path. */
80
+ readonly resolve: (ctx: SynthesisContext) => ChairmanSelection;
81
+ }
82
+
83
+ const _viaChairman = (ctx: SynthesisContext): ChairmanSelection =>
84
+ select_chairman(ctx.chairmanMode, ctx.configuredMember, ctx.deliberated, ctx.candidates);
85
+
86
+ export const STRATEGIES: Readonly<Record<SynthesisStrategyId, SynthesisStrategy>> = Object.freeze({
87
+ host_convener: {
88
+ id: 'host_convener',
89
+ describe: 'the host that framed the artefact writes the synthesis — today default, zero extra calls',
90
+ reachable: true,
91
+ extraCalls: 0,
92
+ resolve: _viaChairman,
93
+ },
94
+ external_judge: {
95
+ id: 'external_judge',
96
+ describe: 'a named non-deliberating member synthesises — chairman.mode=member',
97
+ reachable: true,
98
+ extraCalls: 1,
99
+ resolve: _viaChairman,
100
+ },
101
+ strongest_model: {
102
+ id: 'strongest_model',
103
+ describe: 'the highest-tier non-deliberating member synthesises — chairman.mode=auto with tiers configured',
104
+ reachable: true,
105
+ extraCalls: 1,
106
+ resolve: _viaChairman,
107
+ },
108
+ top_ranked_member: {
109
+ id: 'top_ranked_member',
110
+ describe:
111
+ 'the first non-deliberating member in config order synthesises — chairman.mode=auto with no tiers, ' +
112
+ 'where config order is the only ranking the engine trusts',
113
+ reachable: true,
114
+ extraCalls: 1,
115
+ resolve: _viaChairman,
116
+ },
117
+ dual_adjudicated: {
118
+ id: 'dual_adjudicated',
119
+ describe:
120
+ 'two synthesisers plus an adjudication pass — NOT reachable from today’s configuration; ' +
121
+ 'reaching it needs a new input, which 5.1’s verify clause forbids adding to the user surface',
122
+ reachable: false,
123
+ extraCalls: 3,
124
+ resolve: _viaChairman,
125
+ },
126
+ });
127
+
128
+ /**
129
+ * Which strategy today's configuration IS, named.
130
+ *
131
+ * Reads only `chairmanMode` and whether any candidate carries a tier — both
132
+ * already-read inputs. Returns `null` for a mode the config loader would have
133
+ * rejected, rather than guessing a strategy for an unknown value.
134
+ */
135
+ export function resolveSynthesisStrategy(ctx: SynthesisContext): SynthesisStrategy | null {
136
+ if (ctx.chairmanMode === 'host') return STRATEGIES.host_convener;
137
+ if (ctx.chairmanMode === 'member') return STRATEGIES.external_judge;
138
+ if (ctx.chairmanMode === 'auto') {
139
+ const tiered = ctx.candidates.some((c) => c.tier !== null && c.tier !== undefined);
140
+ return tiered ? STRATEGIES.strongest_model : STRATEGIES.top_ranked_member;
141
+ }
142
+ return null;
143
+ }
144
+
145
+ /** Strategies a consumer can actually land on today. `dual_adjudicated` is excluded. */
146
+ export function reachableStrategies(): SynthesisStrategyId[] {
147
+ return SYNTHESIS_STRATEGIES.filter((id) => STRATEGIES[id].reachable);
148
+ }
149
+
150
+ /**
151
+ * The config keys this module reads — asserted by the test to contain no key
152
+ * the engine did not already read, so "no new user-facing input" is checkable
153
+ * rather than promised.
154
+ */
155
+ export const CONFIG_KEYS_READ: readonly string[] = Object.freeze([
156
+ 'ai_council.chairman.mode',
157
+ 'ai_council.chairman.member',
158
+ 'ai_council.members.<name>.tier',
159
+ ]);
@@ -43,7 +43,9 @@ import {
43
43
  AGENTIC_MODES,
44
44
  DISCOVERY_STATES,
45
45
  EVIDENCE_STRENGTHS,
46
+ PROTECTED_DIMENSIONS,
46
47
  PROVENANCE_KINDS,
48
+ REOPEN_POLICIES,
47
49
  evidenceOf,
48
50
  provenanceOf,
49
51
  readAdrFrontmatter,
@@ -87,16 +89,9 @@ const REQUIRED = ['adr', 'status', 'date', 'decision'] as const;
87
89
  * the same words. This validator therefore checks the VALUE when present and
88
90
  * never requires the KEY.
89
91
  */
90
- const ALLOWED_REOPEN_POLICY = new Set(['directional', 'owner', 'unclassified']);
92
+ const ALLOWED_REOPEN_POLICY = new Set<string>(REOPEN_POLICIES);
91
93
 
92
- const ALLOWED_PROTECTED_DIMENSIONS = new Set([
93
- 'purpose',
94
- 'security_floor',
95
- 'privacy_floor',
96
- 'external_commitment',
97
- 'governance',
98
- 'none',
99
- ]);
94
+ const ALLOWED_PROTECTED_DIMENSIONS = new Set<string>(PROTECTED_DIMENSIONS);
100
95
 
101
96
  /** Split an inline `[a, b]` list or a folded multi-value string into members. */
102
97
  export function split_dimensions(raw: string): string[] {