@event4u/agent-config 12.0.0 → 13.0.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 (148) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +273 -0
  3. package/README.md +1 -1
  4. package/agents/templates/.ai-council.yml.example +46 -3
  5. package/dist/agent-src/commands/cost/report.md +23 -0
  6. package/dist/agent-src/contexts/execution/subagent-routing.md +148 -33
  7. package/dist/agent-src/contexts/model-recommendations.md +6 -4
  8. package/dist/agent-src/contexts/subagent-configuration.md +69 -7
  9. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  10. package/dist/agent-src/rules/design-fidelity.md +1 -1
  11. package/dist/agent-src/rules/design-review-after-ui-write.md +10 -5
  12. package/dist/agent-src/rules/evaluator-independence.md +27 -5
  13. package/dist/agent-src/rules/git-history-discipline.md +5 -1
  14. package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +1 -0
  15. package/dist/agent-src/rules/missing-skill-recovery.md +89 -0
  16. package/dist/agent-src/rules/ui-audit-gate.md +9 -4
  17. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
  18. package/dist/agent-src/scripts/roadmap_gates.ts +30 -2
  19. package/dist/agent-src/scripts/update_roadmap_progress.ts +47 -1
  20. package/dist/agent-src/skills/authz-review/SKILL.md +4 -0
  21. package/dist/agent-src/skills/design-system-capture/SKILL.md +4 -0
  22. package/dist/agent-src/skills/design-system-capture/references/snapshot-preference-order.md +77 -0
  23. package/dist/agent-src/skills/merge-conflicts/SKILL.md +4 -0
  24. package/dist/agent-src/skills/prompt-optimizer/SKILL.md +17 -1
  25. package/dist/agent-src/skills/roadmap-writing/SKILL.md +11 -11
  26. package/dist/agent-src/skills/systematic-debugging/SKILL.md +4 -0
  27. package/dist/agent-src/skills/threat-modeling/SKILL.md +3 -0
  28. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  29. package/dist/agent-src/templates/roadmaps.md +41 -3
  30. package/dist/cli/registry.js +1 -0
  31. package/dist/cli/registry.js.map +1 -1
  32. package/dist/cli-delegate/{chunk-NN4KW4HZ.js → chunk-3B72LSZL.js} +123 -2338
  33. package/dist/cli-delegate/{chunk-55WZN5VF.js → chunk-EVTY7ISR.js} +2 -2
  34. package/dist/cli-delegate/chunk-N7FEST6D.js +3895 -0
  35. package/dist/cli-delegate/{chunk-L565YFXH.js → chunk-XURJ4AQC.js} +566 -298
  36. package/dist/cli-delegate/cmd_conformance.js +4 -4
  37. package/dist/cli-delegate/cmd_doctor.js +2 -2
  38. package/dist/cli-delegate/cmd_export.js +2 -2
  39. package/dist/cli-delegate/cmd_handoff.js +1 -1
  40. package/dist/cli-delegate/cmd_prune.js +2 -2
  41. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  42. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  43. package/dist/cli-delegate/cmd_sync.js +2 -2
  44. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  45. package/dist/cli-delegate/cmd_validate.js +2 -2
  46. package/dist/discovery/deprecation-report.md +1 -1
  47. package/dist/discovery/discovery-manifest.json +44 -23
  48. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  49. package/dist/discovery/discovery-manifest.summary.md +3 -3
  50. package/dist/discovery/dormancy-report.md +1 -1
  51. package/dist/discovery/orphan-report.md +1 -1
  52. package/dist/discovery/packs.json +6 -5
  53. package/dist/discovery/trust-report.md +2 -2
  54. package/dist/discovery/workspaces.json +5 -4
  55. package/dist/hooks/dispatch.js +10625 -8541
  56. package/dist/install/install.mjs +754 -484
  57. package/dist/mcp/registry-manifest.json +3 -3
  58. package/dist/mcp/server.json +2 -2
  59. package/dist/mcp/server.mjs +1302 -167
  60. package/dist/router.json +1 -1
  61. package/dist/scripts/_lib/model_tier.js +9 -0
  62. package/dist/scripts/_lib/model_tier.js.map +1 -1
  63. package/dist/scripts/condense.js +50 -0
  64. package/dist/scripts/condense.js.map +1 -1
  65. package/dist/server/schemas/settings.js +2 -2
  66. package/dist/server/schemas/settings.js.map +1 -1
  67. package/docs/MIGRATION.md +20 -1
  68. package/docs/guidelines/design-fidelity-mechanics.md +21 -0
  69. package/docs/guidelines/design-handover-extraction.md +87 -0
  70. package/docs/guidelines/prompt-templates.md +24 -1
  71. package/package.json +1 -1
  72. package/src/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
  73. package/src/agent-src/scripts/roadmap_gates.ts +30 -2
  74. package/src/agent-src/scripts/update_roadmap_progress.ts +47 -1
  75. package/src/config/evaluator-budgets.json +9 -8
  76. package/src/config/gate-coverage.yml +63 -11
  77. package/src/config/gate-violation-baselines.json +12 -0
  78. package/src/config/hook-token-budget.json +15 -2
  79. package/src/config/preamble-payload-budget.json +28 -0
  80. package/src/scripts/_dispatch.bash +10 -0
  81. package/src/scripts/_lib/bench_ab_complexity.ts +439 -0
  82. package/src/scripts/_lib/bench_ab_safety_tier.ts +142 -0
  83. package/src/scripts/_lib/bench_ab_scoring_v2.ts +28 -3
  84. package/src/scripts/_lib/bench_ab_search_adherence.ts +423 -0
  85. package/src/scripts/_lib/bench_ab_size_claim.ts +206 -0
  86. package/src/scripts/_lib/bench_ab_workspace.ts +111 -0
  87. package/src/scripts/_lib/git_common_dir.ts +173 -0
  88. package/src/scripts/_lib/model_tier.ts +9 -0
  89. package/src/scripts/_lib/release_highlights.ts +108 -7
  90. package/src/scripts/_lib/skill_catalogue.ts +1115 -0
  91. package/src/scripts/_lib/tier_budget_routing.ts +31 -331
  92. package/src/scripts/ai_council/clients.ts +197 -18
  93. package/src/scripts/ai_council/config.ts +16 -1
  94. package/src/scripts/ai_council/low_impact.ts +14 -1
  95. package/src/scripts/ai_council/orchestrator.ts +14 -1
  96. package/src/scripts/ai_council/transport_resolver.ts +32 -2
  97. package/src/scripts/bench_ab_v2_complexity.ts +252 -0
  98. package/src/scripts/bench_ab_v2_run.ts +3 -42
  99. package/src/scripts/bench_ab_v2_safety.ts +296 -0
  100. package/src/scripts/bench_ab_v2_search.ts +343 -0
  101. package/src/scripts/bench_ab_v2_stats.ts +191 -0
  102. package/src/scripts/build_archive_index.ts +383 -0
  103. package/src/scripts/capture_skill_catalogue.ts +279 -317
  104. package/src/scripts/check_branch_freshness.ts +7 -1
  105. package/src/scripts/check_release_published.ts +6 -2
  106. package/src/scripts/check_release_trunk_sync.ts +18 -2
  107. package/src/scripts/check_source_size_budget.ts +428 -0
  108. package/src/scripts/check_suppression_hygiene.ts +5 -1
  109. package/src/scripts/condense.ts +50 -1
  110. package/src/scripts/cost/backfill_rates.mjs +298 -0
  111. package/src/scripts/cost/budget.mjs +12 -32
  112. package/src/scripts/cost_summary.ts +25 -0
  113. package/src/scripts/dispatch_r2_reviewer.ts +15 -5
  114. package/src/scripts/evidence_report.ts +15 -4
  115. package/src/scripts/external_sources_denylist.json +1 -0
  116. package/src/scripts/hook_manifest.yaml +75 -15
  117. package/src/scripts/hooks/concern_registry.ts +4 -0
  118. package/src/scripts/hooks/orchestration_record_hook.ts +21 -0
  119. package/src/scripts/hooks/skill_route_hook.ts +226 -0
  120. package/src/scripts/hooks/tool_result_bytes_hook.ts +219 -0
  121. package/src/scripts/hooks/ui_route_nudge_hook.ts +6 -3
  122. package/src/scripts/hot_context_hook.ts +13 -1
  123. package/src/scripts/install.ts +129 -0
  124. package/src/scripts/janitor.ts +77 -2
  125. package/src/scripts/lint_design_antipattern_parity.ts +40 -6
  126. package/src/scripts/lint_harvest_provenance.ts +35 -1
  127. package/src/scripts/lint_plan_risk_register.ts +22 -9
  128. package/src/scripts/lint_roadmap_blockers.ts +109 -6
  129. package/src/scripts/lint_rule_skill_pack_reach.ts +43 -0
  130. package/src/scripts/lint_scheduled_deprecations.ts +738 -0
  131. package/src/scripts/lint_skill_router_head.ts +21 -0
  132. package/src/scripts/mcp_server/consumer_tool_catalog.json +26 -3
  133. package/src/scripts/mcp_server/tool_catalog_source.ts +6 -24
  134. package/src/scripts/mcp_server/tools.ts +87 -0
  135. package/src/scripts/migration_status.ts +17 -1
  136. package/src/scripts/probe_review_binding_drift.ts +731 -0
  137. package/src/scripts/release.ts +100 -6
  138. package/src/scripts/routing_doctor.ts +50 -13
  139. package/src/scripts/schemas/command.schema.json +3 -3
  140. package/src/scripts/schemas/skill.schema.json +20 -2
  141. package/src/scripts/schemas/subagent.schema.json +2 -2
  142. package/src/scripts/session_eol_report.ts +81 -0
  143. package/src/scripts/trigger_coverage.ts +224 -3
  144. package/src/scripts/workspace_doctor.ts +464 -0
  145. package/src/scripts/worktree_cleanup_check.ts +2 -1
  146. package/src/server/schemas/settings.ts +2 -2
  147. package/dist/cli-delegate/chunk-AYKQGIBG.js +0 -1594
  148. package/src/config/budget-routing.json +0 -7
@@ -0,0 +1,1115 @@
1
+ /**
2
+ * skill_catalogue — the pure measurement library behind `capture_skill_catalogue`.
3
+ *
4
+ * Split out of the CLI for one concrete reason: `install.ts` needs the
5
+ * deploy-time truncation warning, `install.ts` is an esbuild bundle entry, and
6
+ * a bundled module carrying a top-level `process.exit()` runs that exit when a
7
+ * CONSUMER loads the installer — the entry guard cannot tell it apart, because
8
+ * inside a bundle the bundle IS `process.argv[1]`. So everything importable
9
+ * lives here, with no CLI entry and no top-level exit, and the CLI re-exports
10
+ * it.
11
+ *
12
+ * capture_skill_catalogue — make the skill-catalogue delivery defect countable.
13
+ *
14
+ * THE HOST BOUNDARY, STATED FIRST BECAUSE IT DECIDES THE SHAPE OF THIS TOOL.
15
+ * The round-6 census asked for a `session_start` concern that logs the injected
16
+ * catalogue block. A concern cannot do that. The dispatcher envelope carries
17
+ * `session_id`, `source`, `cwd`, `transcript_path` — the injected catalogue is
18
+ * part of the request's system payload, and `preamble_byte_census.ts` already
19
+ * verified independently that no local transcript or file carries it. A hook
20
+ * that "captures the catalogue" would therefore be capturing something else and
21
+ * reporting it under that name, which is worse than not measuring.
22
+ *
23
+ * So the capture is deliberately two-sided, and each side is labelled with what
24
+ * it actually is:
25
+ *
26
+ * PROJECTION (deterministic) — what the projected skill tree offers the host:
27
+ * per entry, whether a `description:` exists, how long it is, which
28
+ * frontmatter keys it carries, and its position in the sorted catalogue. This
29
+ * is the host's INPUT and it is fully file-measurable.
30
+ *
31
+ * OBSERVATION — which entries actually arrived, and how many did not. This
32
+ * side has TWO sources and every record says which one it came from:
33
+ *
34
+ * `self-report` — an agent reading its own context lists the bare names.
35
+ * Only the agent can see this. It is not enforcement and nothing here
36
+ * pretends otherwise. This is the claude-shaped path.
37
+ *
38
+ * `host-event` — the host publishes its own truncation on a machine
39
+ * channel. `codex exec --json` emits an `item.completed` error event whose
40
+ * message states how many entries it dropped, so the count is READ, never
41
+ * transcribed. This is the codex-shaped path and it is deterministic.
42
+ *
43
+ * Hosts do not truncate the same way, so a record also carries a
44
+ * `truncation_mode`: `per-entry` (some entries arrive bare, others full —
45
+ * claude) versus `budget-strip-and-drop` (every description stripped, then N
46
+ * entries dropped wholesale — codex). Pooling the two into one verdict would
47
+ * average two different mechanisms into a number describing neither, so the
48
+ * report is always per host.
49
+ *
50
+ * The join of the two answers the question the census could not: not "how many
51
+ * are bare" but WHICH PROPERTY separates bare from described. That is the
52
+ * selector, and it is what a delivery fix has to act on. A positional
53
+ * head-N hypothesis is one candidate among several and this tool is built to
54
+ * falsify it rather than assume it — the first observation recorded against
55
+ * this repo showed entries far past position 40 carrying full descriptions
56
+ * while an earlier one was bare, which no head-N budget explains.
57
+ *
58
+ * PRIVACY BY CONSTRUCTION. An observation record carries skill names, integer
59
+ * counts, a host label and two closed enums. The record type has no field able
60
+ * to hold prompt text, file bodies, paths outside the catalogue, or user
61
+ * content — the same shape `domain-safety-pii` § Surface 2 requires of a log
62
+ * event. The codex path reads a host message and keeps only the integer in it;
63
+ * the message text itself never reaches a record. Do not widen it with a
64
+ * free-form field.
65
+ *
66
+ * A ZERO IS NEVER INFERRED FROM SILENCE. If the host's budget event is absent
67
+ * or unparseable — reworded upstream, or removed — the capture fails loudly
68
+ * rather than recording `dropped: 0`, because a broken parser and a fixed
69
+ * defect would otherwise look identical. Recording a genuine no-truncation run
70
+ * takes an explicit `--assert-no-truncation`.
71
+ *
72
+ * Exit codes: 0 report produced · 1 usage/IO error, an empty catalogue root
73
+ * (a catalogue scan that found nothing must never read as a clean result), or
74
+ * an unreadable host observation.
75
+ */
76
+
77
+ import * as fs from 'node:fs';
78
+ import * as path from 'node:path';
79
+
80
+ /** Catalogue roots tried in order; the first that exists wins. */
81
+ export const DEFAULT_CATALOGUE_ROOTS = ['.claude/skills', 'src/skills'] as const;
82
+
83
+ /**
84
+ * First existing catalogue root under `workspaceRoot`, or `null`.
85
+ *
86
+ * `.claude/skills` before `src/skills` because a CONSUMER install carries the
87
+ * former and only a maintainer checkout carries the latter. Shared by the
88
+ * `skill-route` concern and the `suggest_skill_for_task` MCP handler on
89
+ * purpose: two resolvers over one catalogue is how a ranker and the tool that
90
+ * exposes it start ranking different trees.
91
+ */
92
+ export function resolveSkillsRoot(workspaceRoot: string): string | null {
93
+ for (const candidate of DEFAULT_CATALOGUE_ROOTS) {
94
+ const abs = path.join(workspaceRoot, candidate);
95
+ try {
96
+ if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) return abs;
97
+ } catch {
98
+ // Unreadable candidate is not a match; try the next.
99
+ }
100
+ }
101
+ return null;
102
+ }
103
+
104
+ /** Where observations accumulate. Append-only, one JSON object per line. */
105
+ export const OBSERVATION_LOG = path.join(
106
+ 'agents',
107
+ 'evidence',
108
+ 'metrics',
109
+ 'skill-catalogue.jsonl',
110
+ );
111
+
112
+ export interface CatalogueEntry {
113
+ /** Skill directory name — the identifier the host lists. */
114
+ name: string;
115
+ /** 1-based position in the alphabetically sorted catalogue. */
116
+ position: number;
117
+ /** Whether the projected SKILL.md declares a `description:`. */
118
+ hasDescription: boolean;
119
+ /** Character length of the declared description; 0 when absent. */
120
+ descriptionLength: number;
121
+ /**
122
+ * The declared description itself, folded. In-memory only — this is the
123
+ * projection half, which is file content the host is handed, and it is
124
+ * what makes a byte-accurate volume measurement possible. It is NEVER
125
+ * copied into an `ObservationRecord`; see the privacy paragraph.
126
+ */
127
+ description: string;
128
+ /** Top-level frontmatter keys the entry declares. */
129
+ frontmatterKeys: string[];
130
+ }
131
+
132
+ export interface SelectorCandidate {
133
+ /** Stable identifier for the property under test. */
134
+ id: string;
135
+ /** Human-readable statement of what the property is. */
136
+ describes: string;
137
+ /** True when the property's values are disjoint across the two groups. */
138
+ separates: boolean;
139
+ /** Why it does or does not separate, in one line. */
140
+ detail: string;
141
+ }
142
+
143
+ export interface SelectorReport {
144
+ catalogueRoot: string;
145
+ entriesTotal: number;
146
+ observedBare: string[];
147
+ observedDescribed: string[];
148
+ /** Observed names that the projection does not know. */
149
+ unknownObserved: string[];
150
+ candidates: SelectorCandidate[];
151
+ verdict: 'selector-found' | 'no-selector' | 'insufficient-observation';
152
+ }
153
+
154
+ /** Frontmatter block between the leading `---` fences, or "" when absent. */
155
+ function frontmatterOf(content: string): string {
156
+ if (!content.startsWith('---')) return '';
157
+ const end = content.indexOf('\n---', 3);
158
+ if (end === -1) return '';
159
+ return content.slice(3, end);
160
+ }
161
+
162
+ /**
163
+ * Top-level keys of a frontmatter block. Deliberately shallow: a nested key is
164
+ * not a property the host's catalogue selector could plausibly read, and a full
165
+ * YAML parse would drag a dependency into a measurement tool.
166
+ */
167
+ function topLevelKeys(frontmatter: string): string[] {
168
+ const keys: string[] = [];
169
+ for (const line of frontmatter.split('\n')) {
170
+ const match = /^([A-Za-z_][A-Za-z0-9_-]*):/.exec(line);
171
+ if (match) keys.push(match[1]!);
172
+ }
173
+ return keys;
174
+ }
175
+
176
+ /**
177
+ * The declared description with surrounding quotes stripped, or "".
178
+ *
179
+ * A block scalar (`description: >-` / `|`) puts the text on the FOLLOWING
180
+ * lines, so taking the remainder of the header line would yield the
181
+ * two-character indicator and report `descriptionLength: 2`. That would feed
182
+ * the length-based selector candidate noise, and a length threshold could be
183
+ * reported or refuted on it. Block scalars are therefore folded properly.
184
+ */
185
+ function descriptionOf(frontmatter: string): string {
186
+ const lines = frontmatter.split('\n');
187
+ for (let i = 0; i < lines.length; i += 1) {
188
+ const match = /^description:\s*(.*)$/.exec(lines[i]!);
189
+ if (!match) continue;
190
+ const rest = match[1]!.trim();
191
+
192
+ if (/^[|>][-+]?\d*$/.test(rest)) {
193
+ const body: string[] = [];
194
+ for (let j = i + 1; j < lines.length; j += 1) {
195
+ const line = lines[j]!;
196
+ if (line.trim() === '') {
197
+ body.push('');
198
+ continue;
199
+ }
200
+ if (!/^\s/.test(line)) break; // dedented, so the block ended
201
+ body.push(line.trim());
202
+ }
203
+ return body.join(' ').trim();
204
+ }
205
+
206
+ return rest.replace(/^["']|["']$/g, '');
207
+ }
208
+ return '';
209
+ }
210
+
211
+ /**
212
+ * Read every `<root>/<name>/SKILL.md` into a catalogue entry, sorted by name —
213
+ * the order a host listing follows and therefore the order a positional
214
+ * hypothesis is measured against.
215
+ */
216
+ export function readProjectedCatalogue(root: string): CatalogueEntry[] {
217
+ // Membership is decided by "does <name>/SKILL.md resolve", never by
218
+ // `Dirent.isDirectory()`. The host-facing projection is a tree of SYMLINKS
219
+ // into `dist/agent-src/skills/`, and a Dirent for a symlink reports
220
+ // `isDirectory() === false` — that filter silently read 47 of 289 entries
221
+ // and reported the result as a complete catalogue. `existsSync` follows
222
+ // the link, so the scan sees what the host sees.
223
+ const names = fs
224
+ .readdirSync(root)
225
+ .filter((n) => fs.existsSync(path.join(root, n, 'SKILL.md')))
226
+ .sort();
227
+
228
+ return names.map((name, index) => {
229
+ const content = fs.readFileSync(path.join(root, name, 'SKILL.md'), 'utf-8');
230
+ const frontmatter = frontmatterOf(content);
231
+ const description = descriptionOf(frontmatter);
232
+ return {
233
+ name,
234
+ position: index + 1,
235
+ hasDescription: description.length > 0,
236
+ descriptionLength: description.length,
237
+ description,
238
+ frontmatterKeys: topLevelKeys(frontmatter),
239
+ };
240
+ });
241
+ }
242
+
243
+ /** True when two number sets occupy disjoint ranges in either direction. */
244
+ function rangesAreDisjoint(a: number[], b: number[]): boolean {
245
+ if (a.length === 0 || b.length === 0) return false;
246
+ const maxA = Math.max(...a);
247
+ const minA = Math.min(...a);
248
+ const maxB = Math.max(...b);
249
+ const minB = Math.min(...b);
250
+ return maxA < minB || maxB < minA;
251
+ }
252
+
253
+ /**
254
+ * Join the projection against an observation and report which property, if
255
+ * any, separates the bare entries from the described ones.
256
+ *
257
+ * `observedBare` is the list of entries a live session saw without a
258
+ * description. Everything else in the projection is treated as observed
259
+ * described, which is the honest reading of a whole-catalogue observation —
260
+ * a partial observation would need its own described list, so the caller
261
+ * passes one when it has it.
262
+ */
263
+ export function analyzeSelector(
264
+ projected: CatalogueEntry[],
265
+ observedBare: readonly string[],
266
+ observedDescribed?: readonly string[],
267
+ ): Omit<SelectorReport, 'catalogueRoot'> {
268
+ const known = new Map(projected.map((e) => [e.name, e]));
269
+ const bare = observedBare.filter((n) => known.has(n));
270
+ const describedInput =
271
+ observedDescribed ?? projected.map((e) => e.name).filter((n) => !bare.includes(n));
272
+ const described = describedInput.filter((n) => known.has(n));
273
+
274
+ // Both self-reported lists are validated, not only the bare one: a typo in
275
+ // --described silently shrank the described set and weakened the analysis,
276
+ // while the same typo in --observed was surfaced.
277
+ const unknownObserved = [
278
+ ...new Set([
279
+ ...observedBare.filter((n) => !known.has(n)),
280
+ ...(observedDescribed ?? []).filter((n) => !known.has(n)),
281
+ ]),
282
+ ].sort();
283
+
284
+ const base = {
285
+ entriesTotal: projected.length,
286
+ observedBare: [...bare].sort(),
287
+ observedDescribed: [...described].sort(),
288
+ unknownObserved,
289
+ };
290
+
291
+ if (bare.length === 0 || described.length === 0) {
292
+ return {
293
+ ...base,
294
+ candidates: [],
295
+ verdict: 'insufficient-observation' as const,
296
+ };
297
+ }
298
+
299
+ const bareEntries = bare.map((n) => known.get(n)!);
300
+ const describedEntries = described.map((n) => known.get(n)!);
301
+ const candidates: SelectorCandidate[] = [];
302
+
303
+ // 1. The projection itself — does the entry even declare a description?
304
+ const bareWithDesc = bareEntries.filter((e) => e.hasDescription).length;
305
+ const describedWithoutDesc = describedEntries.filter((e) => !e.hasDescription).length;
306
+ candidates.push({
307
+ id: 'declares-description',
308
+ describes: 'the projected SKILL.md declares a `description:`',
309
+ separates: bareWithDesc === 0 && describedWithoutDesc === 0,
310
+ detail: `${bareWithDesc}/${bareEntries.length} bare entries declare one; ${describedWithoutDesc}/${describedEntries.length} described entries do not`,
311
+ });
312
+
313
+ // 2. The head-N hypothesis the census proposed — falsifiable here.
314
+ const barePositions = bareEntries.map((e) => e.position);
315
+ const describedPositions = describedEntries.map((e) => e.position);
316
+ const positional = Math.max(...describedPositions) < Math.min(...barePositions);
317
+ candidates.push({
318
+ id: 'positional-head',
319
+ describes: 'every described entry sorts before every bare entry (a head-N budget)',
320
+ separates: positional,
321
+ detail: positional
322
+ ? `described top out at #${Math.max(...describedPositions)}, bare start at #${Math.min(...barePositions)}`
323
+ : `described reach #${Math.max(...describedPositions)} while bare start at #${Math.min(...barePositions)} — the ranges overlap, so no head-N cut explains this`,
324
+ });
325
+
326
+ // 3. Description length as a budget proxy.
327
+ const lengthSeparates = rangesAreDisjoint(
328
+ bareEntries.map((e) => e.descriptionLength),
329
+ describedEntries.map((e) => e.descriptionLength),
330
+ );
331
+ candidates.push({
332
+ id: 'description-length',
333
+ describes: 'description length ranges do not overlap between the groups',
334
+ separates: lengthSeparates,
335
+ detail: lengthSeparates
336
+ ? 'a length threshold separates the two groups'
337
+ : 'length ranges overlap',
338
+ });
339
+
340
+ // 4. Any frontmatter key whose presence tracks the split exactly.
341
+ const allKeys = new Set<string>();
342
+ for (const entry of projected) for (const key of entry.frontmatterKeys) allKeys.add(key);
343
+ for (const key of [...allKeys].sort()) {
344
+ const bareHas = bareEntries.filter((e) => e.frontmatterKeys.includes(key)).length;
345
+ const describedHas = describedEntries.filter((e) =>
346
+ e.frontmatterKeys.includes(key),
347
+ ).length;
348
+ const separates =
349
+ (bareHas === 0 && describedHas === describedEntries.length) ||
350
+ (describedHas === 0 && bareHas === bareEntries.length);
351
+ if (!separates) continue;
352
+ candidates.push({
353
+ id: `frontmatter:${key}`,
354
+ describes: `presence of the \`${key}:\` frontmatter key`,
355
+ separates: true,
356
+ detail: `bare ${bareHas}/${bareEntries.length}, described ${describedHas}/${describedEntries.length}`,
357
+ });
358
+ }
359
+
360
+ return {
361
+ ...base,
362
+ candidates,
363
+ verdict: candidates.some((c) => c.separates)
364
+ ? ('selector-found' as const)
365
+ : ('no-selector' as const),
366
+ };
367
+ }
368
+
369
+ /**
370
+ * How a host truncates. The two known mechanisms are not variants of one
371
+ * number, so a record that does not say which one it measured cannot be
372
+ * compared against one that measured the other.
373
+ *
374
+ * `per-entry` — some entries arrive with their description, others arrive as
375
+ * bare names. Which is which is the selector question `analyzeSelector` asks.
376
+ *
377
+ * `budget-strip-and-drop` — the host strips EVERY description, then drops N
378
+ * entries from the list entirely. There is no per-entry selector to find; the
379
+ * quantity that matters is N.
380
+ *
381
+ * `none` — the host truncated in NO way on this run: every entry arrived and
382
+ * every description survived. It is a mode a record must be able to state
383
+ * explicitly, because the alternative is leaving the field absent, and absent
384
+ * reads as `per-entry` — a mode this run did not exercise. A `none` record is
385
+ * the only shape that can say "the same host that strips today did not strip
386
+ * here" without inventing a mechanism.
387
+ */
388
+ export type TruncationMode = 'per-entry' | 'budget-strip-and-drop' | 'none';
389
+
390
+ /** Where the observation half came from. See the header. */
391
+ export type ObservationSource = 'self-report' | 'host-event';
392
+
393
+ /**
394
+ * The projection mode the observed install was deployed under.
395
+ *
396
+ * A closed enum, never a free-form label: the record type's privacy contract
397
+ * is that no field can hold arbitrary text, and this one is no exception.
398
+ */
399
+ export type ProjectionMode = 'scoped' | 'legacy-all';
400
+
401
+ /** One append-only observation record. No field can hold free-form content. */
402
+ export interface ObservationRecord {
403
+ schema: 1;
404
+ observed_at: string;
405
+ host: string;
406
+ entries_total: number;
407
+ bare_count: number;
408
+ described_count: number;
409
+ bare_names: string[];
410
+ verdict: SelectorReport['verdict'];
411
+ separating_candidates: string[];
412
+ /**
413
+ * Absent on a schema-1 record written before hosts were distinguished.
414
+ * Absent reads as `per-entry`, which is what those records measured — see
415
+ * `truncationModeOf`. Never default it to the codex mode: that would
416
+ * retroactively relabel an observation nobody took.
417
+ */
418
+ truncation_mode?: TruncationMode;
419
+ /** Absent on records written before the two sources were distinguished. */
420
+ observation_source?: ObservationSource;
421
+ /**
422
+ * Skills projected at this observation — the ONLY population the drop
423
+ * tracks. `entries_total` counts every artefact, and a controlled probe
424
+ * (2026-08-15) moved the host's dropped count by 0 when 60 commands were
425
+ * added and by +53 when 60 skills were. Comparing artefact totals would
426
+ * fire on command growth that causes no truncation at all.
427
+ */
428
+ projected_skill_count?: number;
429
+ /**
430
+ * Projection mode the observed install was deployed under.
431
+ *
432
+ * Absent on every record written before the mode was captured, and absence
433
+ * is NOT `legacy-all`: those observations were taken without asking, so
434
+ * defaulting them either way would relabel a reading nobody took. A
435
+ * consumer comparing modes must skip a record that carries none.
436
+ */
437
+ projection_mode?: ProjectionMode;
438
+ /**
439
+ * Entries the host dropped wholesale, read from the host's own event.
440
+ * Only ever present on a `budget-strip-and-drop` record — a `per-entry`
441
+ * host publishes no such count, and inventing one is the failure the
442
+ * header's zero-is-never-inferred paragraph forbids.
443
+ */
444
+ dropped_count?: number;
445
+ }
446
+
447
+ /** The mode a record measured; absent reads as the pre-codex `per-entry`. */
448
+ export function truncationModeOf(record: ObservationRecord): TruncationMode {
449
+ return record.truncation_mode ?? 'per-entry';
450
+ }
451
+
452
+ /** The source a record came from; absent reads as the pre-codex self-report. */
453
+ export function observationSourceOf(record: ObservationRecord): ObservationSource {
454
+ return record.observation_source ?? 'self-report';
455
+ }
456
+
457
+ export function buildObservationRecord(
458
+ report: SelectorReport,
459
+ host: string,
460
+ observedAt: string,
461
+ ): ObservationRecord {
462
+ return {
463
+ schema: 1,
464
+ observed_at: observedAt,
465
+ host,
466
+ entries_total: report.entriesTotal,
467
+ bare_count: report.observedBare.length,
468
+ described_count: report.observedDescribed.length,
469
+ bare_names: report.observedBare,
470
+ verdict: report.verdict,
471
+ separating_candidates: report.candidates.filter((c) => c.separates).map((c) => c.id),
472
+ truncation_mode: 'per-entry',
473
+ observation_source: 'self-report',
474
+ };
475
+ }
476
+
477
+ /* ------------------------------------------------------------------ *
478
+ * The host-event path — codex publishes its own truncation.
479
+ * ------------------------------------------------------------------ */
480
+
481
+ /** What a host's own budget event states. Integers only, by construction. */
482
+ export interface HostBudgetEvent {
483
+ /** Entries the host dropped from the model-visible list. */
484
+ droppedCount: number;
485
+ /** Whether the host also stripped descriptions from what survived. */
486
+ descriptionsStripped: boolean;
487
+ }
488
+
489
+ /**
490
+ * Read a host budget event out of a `codex exec --json` stream.
491
+ *
492
+ * Parses the STRUCTURED event, never the human-readable warning line: the
493
+ * pretty line is presentation and may be reworded or dropped without notice,
494
+ * while the JSON channel is the host's machine contract. Observed shape
495
+ * (2026-08-15, codex CLI):
496
+ *
497
+ * {"type":"item.completed","item":{"id":"item_0","type":"error",
498
+ * "message":"Exceeded skills context budget. All skill descriptions were
499
+ * removed and 393 additional skills were not included in the
500
+ * model-visible skills list."}}
501
+ *
502
+ * Returns `null` when no such event is present. `null` means UNKNOWN, never
503
+ * zero — the caller decides whether the run genuinely did not truncate, and
504
+ * says so explicitly. A line that is not JSON is skipped rather than fatal:
505
+ * the stream is interleaved with the CLI's own plain-text chatter.
506
+ */
507
+ export function parseHostBudgetEvent(stream: string): HostBudgetEvent | null {
508
+ for (const line of stream.split('\n')) {
509
+ const trimmed = line.trim();
510
+ if (!trimmed.startsWith('{')) continue;
511
+
512
+ let parsed: unknown;
513
+ try {
514
+ parsed = JSON.parse(trimmed);
515
+ } catch {
516
+ continue;
517
+ }
518
+
519
+ const item = (parsed as { item?: { type?: unknown; message?: unknown } }).item;
520
+ if (item === undefined || item === null || item.type !== 'error') continue;
521
+ const message = typeof item.message === 'string' ? item.message : '';
522
+ if (!/skills context budget/i.test(message)) continue;
523
+
524
+ const dropped = /(\d+)\s+additional skills were not included/i.exec(message);
525
+ if (!dropped) {
526
+ // The event fired and its shape changed. Reporting this as "no
527
+ // truncation" would turn an upstream rewording into a clean bill
528
+ // of health, so it is an unknown like any other.
529
+ return null;
530
+ }
531
+
532
+ return {
533
+ droppedCount: Number.parseInt(dropped[1]!, 10),
534
+ descriptionsStripped: /descriptions were removed/i.test(message),
535
+ };
536
+ }
537
+ return null;
538
+ }
539
+
540
+ /**
541
+ * An observation built from the host's own event rather than from a self-report.
542
+ *
543
+ * `entriesOffered` is the deterministic projection half — how many artefacts
544
+ * the host was given. `bare_count` is the whole surviving set when the host
545
+ * stripped every description, which is exactly what the codex message states;
546
+ * there is no per-entry list to record, so `bare_names` stays empty rather
547
+ * than being filled with a guess.
548
+ */
549
+ export function buildHostEventRecord(
550
+ host: string,
551
+ observedAt: string,
552
+ entriesOffered: number,
553
+ event: HostBudgetEvent,
554
+ projectedSkillCount?: number,
555
+ projectionMode?: ProjectionMode,
556
+ ): ObservationRecord {
557
+ return {
558
+ schema: 1,
559
+ observed_at: observedAt,
560
+ host,
561
+ // OUR projection, not the host's denominator. Measured 2026-08-15 they
562
+ // are not the same number and there is no way to read the host's:
563
+ // `~/.codex` projects 297 skills + 200 commands = 497 by this tool's
564
+ // count, while the host reported dropping 393 — and a controlled +60
565
+ // commands moved that by ZERO. Whatever codex counts, it is not this.
566
+ // So `entries_total - dropped_count` is NOT a delivered count, and
567
+ // nothing here computes one: a subtraction across two different
568
+ // denominators produces a confident number describing neither.
569
+ entries_total: entriesOffered,
570
+ bare_count: 0,
571
+ described_count: 0,
572
+ bare_names: [],
573
+ // There is nothing to separate: the host did not choose per entry, it
574
+ // stripped all of them. `no-selector` would read as "we looked and
575
+ // found none", which is not what happened.
576
+ verdict: 'insufficient-observation',
577
+ separating_candidates: [],
578
+ truncation_mode: 'budget-strip-and-drop',
579
+ observation_source: 'host-event',
580
+ dropped_count: event.droppedCount,
581
+ ...(projectedSkillCount === undefined ? {} : { projected_skill_count: projectedSkillCount }),
582
+ ...(projectionMode === undefined ? {} : { projection_mode: projectionMode }),
583
+ };
584
+ }
585
+
586
+ /**
587
+ * A run on which the host published NO budget event and the caller asserted
588
+ * that this is a genuine no-truncation observation.
589
+ *
590
+ * The assertion is the caller's and the record says so by carrying
591
+ * `observation_source: 'host-event'` together with `truncation_mode: 'none'`:
592
+ * the host's channel was read, and what it contained was nothing. That pairing
593
+ * is what distinguishes this from a broken parser, which the CLI refuses to
594
+ * record at all without the explicit flag.
595
+ *
596
+ * `dropped_count: 0` is written rather than omitted, and it is safe: every
597
+ * consumer of a dropped count filters on `budget-strip-and-drop`, so a `none`
598
+ * record can never become a measured limit or a deploy-time warning.
599
+ */
600
+ export function buildNoTruncationRecord(
601
+ host: string,
602
+ observedAt: string,
603
+ entriesOffered: number,
604
+ projectedSkillCount?: number,
605
+ projectionMode?: ProjectionMode,
606
+ ): ObservationRecord {
607
+ return {
608
+ schema: 1,
609
+ observed_at: observedAt,
610
+ host,
611
+ entries_total: entriesOffered,
612
+ // BOTH counts stay 0, and neither is a claim that nothing arrived
613
+ // described. The host published no per-entry breakdown at all — it
614
+ // published nothing, which is what `truncation_mode: 'none'` records.
615
+ // Writing `described_count: entriesOffered` would look like a
616
+ // measurement and be an inference; 0 here means "not counted", exactly
617
+ // as it does on the strip-and-drop record beside it.
618
+ bare_count: 0,
619
+ described_count: 0,
620
+ bare_names: [],
621
+ // Nothing arrived bare, so no property separated bare from described —
622
+ // which is what `no-selector` states. Unlike the strip-and-drop case,
623
+ // here the label is literally true rather than merely available.
624
+ verdict: 'no-selector',
625
+ separating_candidates: [],
626
+ truncation_mode: 'none',
627
+ observation_source: 'host-event',
628
+ dropped_count: 0,
629
+ ...(projectedSkillCount === undefined ? {} : { projected_skill_count: projectedSkillCount }),
630
+ ...(projectionMode === undefined ? {} : { projection_mode: projectionMode }),
631
+ };
632
+ }
633
+
634
+ /* ------------------------------------------------------------------ *
635
+ * The projection half, per host — volume, not just entry count.
636
+ * ------------------------------------------------------------------ */
637
+
638
+ /** What one host root is offered. Every number is file-measured. */
639
+ export interface CatalogueVolume {
640
+ host: string;
641
+ root: string;
642
+ skillEntries: number;
643
+ commandEntries: number;
644
+ /** Everything the host lists — the number its budget is spent on. */
645
+ artefacts: number;
646
+ /** Bytes of declared `description:` text across the skill entries. */
647
+ descriptionBytes: number;
648
+ }
649
+
650
+ /** Every `*.md` under a root, at any depth. */
651
+ function markdownFilesUnder(root: string): string[] {
652
+ if (!fs.existsSync(root)) return [];
653
+ const out: string[] = [];
654
+ const walk = (dir: string): void => {
655
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
656
+ const full = path.join(dir, entry.name);
657
+ if (fs.statSync(full).isDirectory()) walk(full);
658
+ else if (entry.name.endsWith('.md')) out.push(full);
659
+ }
660
+ };
661
+ walk(root);
662
+ return out;
663
+ }
664
+
665
+ /**
666
+ * Measure what a host root actually offers.
667
+ *
668
+ * `root` is the host's own config directory (`~/.codex`, `~/.claude`), not a
669
+ * skills directory — the budget is spent on the whole catalogue, and counting
670
+ * skills alone would understate it by the command set.
671
+ */
672
+ export function measureCatalogueVolume(host: string, root: string): CatalogueVolume {
673
+ const skillsRoot = path.join(root, 'skills');
674
+ const skills = fs.existsSync(skillsRoot) ? readProjectedCatalogue(skillsRoot) : [];
675
+ const commandEntries = markdownFilesUnder(path.join(root, 'commands')).length;
676
+ return {
677
+ host,
678
+ root,
679
+ skillEntries: skills.length,
680
+ commandEntries,
681
+ artefacts: skills.length + commandEntries,
682
+ descriptionBytes: skills.reduce((sum, e) => sum + Buffer.byteLength(e.description), 0),
683
+ };
684
+ }
685
+
686
+ /* ------------------------------------------------------------------ *
687
+ * Projection modes — what a host would be offered under `scoped`
688
+ * versus under `legacy-all`, side by side.
689
+ * ------------------------------------------------------------------ */
690
+
691
+ /**
692
+ * The two SKILL counts a package projects, plus the pack closure behind the
693
+ * smaller one.
694
+ *
695
+ * SKILLS, not artefacts, and the distinction is load-bearing rather than
696
+ * pedantic: a controlled probe on 2026-08-15 moved the measured host's dropped
697
+ * count by 0 when 60 commands were added and by +53 when 60 skills were, so a
698
+ * threshold read off artefact totals would fire on growth that causes no
699
+ * truncation at all.
700
+ *
701
+ * Both numbers come from ONE walk in the caller, partitioned by the
702
+ * installer's own prune predicate, so `scoped + prunedUnderScoped ===
703
+ * legacyAll` holds by construction rather than by two counters agreeing.
704
+ */
705
+ export interface ProjectionModeCounts {
706
+ /** Skills a default `projection.mode: scoped` install deploys. */
707
+ scoped: number;
708
+ /** Every skill in the catalogue — what `legacy-all` deploys. */
709
+ legacyAll: number;
710
+ /** `legacyAll - scoped`; the reduction `scoped` buys. */
711
+ prunedUnderScoped: number;
712
+ /** Pack ids active by default under `scoped`, sorted. */
713
+ activePacks: string[];
714
+ }
715
+
716
+ /**
717
+ * Which mode an installed host root is consistent with.
718
+ *
719
+ * `indeterminate` is a real answer and the common one. A host root is not
720
+ * required to hold exactly what this package projects — another suite, a
721
+ * plugin, or a stale install all put skills there — so a count matching
722
+ * neither number says the root was not installed from this tree at this
723
+ * revision, never that the install is broken. And when the two modes yield the
724
+ * same number (no pack-tagged skills), NO root can discriminate them, so every
725
+ * row is `indeterminate` by construction rather than by measurement.
726
+ */
727
+ export type ProjectionModeMatch = 'scoped' | 'legacy-all' | 'indeterminate';
728
+
729
+ /** One host's installed skill count, read against the two mode counts. */
730
+ export interface HostProjectionRow {
731
+ host: string;
732
+ root: string;
733
+ /** Skills present in the host root right now — file-measured. */
734
+ installedSkills: number;
735
+ matches: ProjectionModeMatch;
736
+ }
737
+
738
+ /**
739
+ * Classify one measured host root against the package's two mode counts.
740
+ *
741
+ * Deliberately an equality test and nothing cleverer. A nearest-neighbour rule
742
+ * would label every root with whichever number happens to be closer, which
743
+ * reports a mode for a root that carries neither — the confident-number
744
+ * failure this module's header forbids twice over.
745
+ */
746
+ export function classifyHostProjection(
747
+ volume: CatalogueVolume,
748
+ counts: ProjectionModeCounts,
749
+ ): HostProjectionRow {
750
+ const ambiguous = counts.scoped === counts.legacyAll;
751
+ let matches: ProjectionModeMatch = 'indeterminate';
752
+ if (!ambiguous) {
753
+ if (volume.skillEntries === counts.scoped) matches = 'scoped';
754
+ else if (volume.skillEntries === counts.legacyAll) matches = 'legacy-all';
755
+ }
756
+ return {
757
+ host: volume.host,
758
+ root: volume.root,
759
+ installedSkills: volume.skillEntries,
760
+ matches,
761
+ };
762
+ }
763
+
764
+ /** The side-by-side report. Measurement only — it flips no default. */
765
+ export function formatProjectionModes(
766
+ counts: ProjectionModeCounts,
767
+ rows: readonly HostProjectionRow[],
768
+ ): string {
769
+ const lines = [
770
+ 'projected SKILL counts by projection mode (package side, one walk):',
771
+ ` scoped: ${counts.scoped}`,
772
+ ` legacy-all: ${counts.legacyAll}`,
773
+ ` pruned under scoped: ${counts.prunedUnderScoped}`,
774
+ ` active packs under scoped: ${counts.activePacks.length}`,
775
+ '',
776
+ ];
777
+ if (rows.length === 0) {
778
+ lines.push(
779
+ 'No host root measured. Pass --host-root <dir> (repeatable) to read what',
780
+ 'each host currently holds against the two numbers above.',
781
+ );
782
+ return lines.join('\n');
783
+ }
784
+ lines.push('installed host roots:');
785
+ for (const row of [...rows].sort((a, b) => a.host.localeCompare(b.host))) {
786
+ lines.push(` ${row.host}: ${row.installedSkills} skills — consistent with ${row.matches}`);
787
+ }
788
+ lines.push(
789
+ '',
790
+ 'This is the PROJECTION half only, and it is a measurement: no default was',
791
+ 'read, changed, or recommended here. `indeterminate` means the root carries',
792
+ 'neither count — another suite, a plugin, or a stale install all produce it.',
793
+ 'No host limit is extrapolated from another host, and no delivered or',
794
+ 'survivor count is computed anywhere.',
795
+ );
796
+ return lines.join('\n');
797
+ }
798
+
799
+ /* ------------------------------------------------------------------ *
800
+ * Known limits — read off recorded observations, never invented.
801
+ * ------------------------------------------------------------------ */
802
+
803
+ /**
804
+ * A truncation this estate has actually OBSERVED for one host.
805
+ *
806
+ * Deliberately not "the host's limit". Two of its three numbers would have to
807
+ * come from the host to call it that, and only one does. What is recorded is
808
+ * the honest pair: at a projection of `projectedVolume`, the host said it
809
+ * dropped `droppedEntries`. No delivered count is derived — see
810
+ * `buildHostEventRecord` for why the subtraction is invalid.
811
+ *
812
+ * Built only from a `budget-strip-and-drop` observation, because only that
813
+ * mode publishes a count. A `per-entry` host reports which entries arrived
814
+ * bare, which is a selector fact and not a truncation quantity, so such a host
815
+ * yields nothing here and produces no warning. That asymmetry is the point:
816
+ * an unmeasured host gets no invented number.
817
+ */
818
+ export interface KnownHostLimit {
819
+ host: string;
820
+ /** Entries the host itself said it dropped. */
821
+ droppedEntries: number;
822
+ /** What THIS tool projected for that host at that observation. */
823
+ projectedVolume: number;
824
+ /**
825
+ * SKILLS projected at that observation — the comparable quantity.
826
+ * Absent on a record written before the skill-only count existed, and
827
+ * absence is load-bearing: without it the observation is not comparable
828
+ * and yields no eligibility (see `migrationEligibility`).
829
+ */
830
+ projectedSkills: number | null;
831
+ observedAt: string;
832
+ }
833
+
834
+ /**
835
+ * Is `candidate` the record a host's headline should quote, given `incumbent`?
836
+ *
837
+ * Shared by `knownHostLimits` and `formatPerHostVerdicts` because they used to
838
+ * disagree, and the disagreement was visible in one run: two arms of the same
839
+ * experiment recorded on the same date made `--limits` print `dropped 402` in
840
+ * the verdict block and `dropped 330` in the measured-truncations block, for
841
+ * one host on one day. Two reducers over one log must not break a tie in
842
+ * opposite directions.
843
+ *
844
+ * Later date always wins. On an EQUAL date the larger drop wins, which is also
845
+ * the conservative direction this module already takes elsewhere: over-warning
846
+ * about truncation is safe, under-warning is the failure. It is also what keeps
847
+ * a `scoped` arm from being quoted at a `legacy-all` install — the scoped arm
848
+ * is by construction the smaller number.
849
+ */
850
+ function _supersedes(candidate: ObservationRecord, incumbent: ObservationRecord): boolean {
851
+ if (candidate.observed_at !== incumbent.observed_at) {
852
+ return candidate.observed_at > incumbent.observed_at;
853
+ }
854
+ return (candidate.dropped_count ?? 0) > (incumbent.dropped_count ?? 0);
855
+ }
856
+
857
+ /** The record whose truncation a host's headline quotes, per `_supersedes`. */
858
+ export function headlineRecordPerHost(
859
+ records: readonly ObservationRecord[],
860
+ ): Map<string, ObservationRecord> {
861
+ const out = new Map<string, ObservationRecord>();
862
+ for (const record of records) {
863
+ const previous = out.get(record.host);
864
+ if (previous === undefined || _supersedes(record, previous)) {
865
+ out.set(record.host, record);
866
+ }
867
+ }
868
+ return out;
869
+ }
870
+
871
+ /** The most recent observed truncation per host. */
872
+ export function knownHostLimits(records: readonly ObservationRecord[]): Map<string, KnownHostLimit> {
873
+ const out = new Map<string, KnownHostLimit>();
874
+ const chosen = new Map<string, ObservationRecord>();
875
+ for (const record of records) {
876
+ if (truncationModeOf(record) !== 'budget-strip-and-drop') continue;
877
+ if (typeof record.dropped_count !== 'number') continue;
878
+ if (record.dropped_count <= 0) continue;
879
+ const previous = chosen.get(record.host);
880
+ if (previous !== undefined && !_supersedes(record, previous)) continue;
881
+ chosen.set(record.host, record);
882
+ out.set(record.host, {
883
+ host: record.host,
884
+ droppedEntries: record.dropped_count,
885
+ projectedVolume: record.entries_total,
886
+ projectedSkills: record.projected_skill_count ?? null,
887
+ observedAt: record.observed_at,
888
+ });
889
+ }
890
+ return out;
891
+ }
892
+
893
+ /**
894
+ * The deploy-time warning, or `null` when this deploy is smaller than the one
895
+ * that was measured truncating — or when nothing has been measured at all.
896
+ *
897
+ * Warns, never blocks: over-shipping is the safe direction and a consumer who
898
+ * accepts truncation is making a legitimate choice. Naming the command is the
899
+ * point — a number with no way to reproduce it is noise.
900
+ */
901
+ export function catalogueLimitWarning(
902
+ volume: CatalogueVolume,
903
+ limit: KnownHostLimit | undefined,
904
+ ): string | null {
905
+ if (limit === undefined) return null;
906
+ // SKILLS, not artefacts. The council's correction, and it rests on this
907
+ // estate's own probe: +60 commands moved the host's dropped count by 0.
908
+ // An artefact-total comparison would warn on command growth that truncates
909
+ // nothing. An observation with no skill count is not comparable, so it
910
+ // yields no warning rather than a guess.
911
+ if (limit.projectedSkills === null) return null;
912
+ if (volume.skillEntries < limit.projectedSkills) return null;
913
+ return (
914
+ `${volume.host}: deploying ${volume.skillEntries} skills. ` +
915
+ `This host reported dropping ${limit.droppedEntries} entries from the ` +
916
+ `model-visible list when last measured (${limit.observedAt}, at ` +
917
+ `${limit.projectedSkills} skills) — that much never reaches the model. ` +
918
+ 'Explain: `agent-config exec capture_skill_catalogue --limits`'
919
+ );
920
+ }
921
+
922
+ /* ------------------------------------------------------------------ *
923
+ * Migration eligibility — AI council, 2026-08-15, 2/2 converged.
924
+ * ------------------------------------------------------------------ */
925
+
926
+ /**
927
+ * Why an install is (not) eligible to be ASKED about scoped projection.
928
+ *
929
+ * Eligibility is deliberately separate from whether a prompt can be shown.
930
+ * The council's refinement: an install can qualify while the session is
931
+ * non-interactive, and collapsing the two would make "not asked because CI"
932
+ * indistinguishable from "not asked because it does not qualify".
933
+ */
934
+ export interface MigrationEligibility {
935
+ eligible: boolean;
936
+ /** Machine-readable reason, always set — including on the eligible path. */
937
+ reason:
938
+ | 'eligible'
939
+ | 'no-observation-for-host'
940
+ | 'observation-not-comparable'
941
+ | 'no-truncation-observed'
942
+ | 'already-scoped'
943
+ | 'below-observed-skill-volume';
944
+ /** Populated only when eligible, for the message. */
945
+ droppedEntries?: number;
946
+ observedAt?: string;
947
+ }
948
+
949
+ /**
950
+ * Is this install worth asking about?
951
+ *
952
+ * `currentSkillCount` is the SKILL population only, and that is the whole
953
+ * correction the council made to the original predicate. A controlled probe
954
+ * (2026-08-15) moved the host's dropped count by 0 when 60 command files were
955
+ * added and by +53 when 60 skills were, so a comparison over total artefacts
956
+ * would fire on command growth that truncates nothing.
957
+ *
958
+ * An observation with no recorded skill count is NOT comparable — the two
959
+ * numbers would be counted by different rules — and returns
960
+ * `observation-not-comparable` rather than being coerced into a verdict.
961
+ * Never extrapolate from another host.
962
+ */
963
+ export function migrationEligibility(
964
+ host: string,
965
+ resolvedMode: 'scoped' | 'legacy-all',
966
+ currentSkillCount: number,
967
+ limits: ReadonlyMap<string, KnownHostLimit>,
968
+ ): MigrationEligibility {
969
+ if (resolvedMode === 'scoped') return { eligible: false, reason: 'already-scoped' };
970
+ const limit = limits.get(host);
971
+ if (limit === undefined) return { eligible: false, reason: 'no-observation-for-host' };
972
+ if (limit.projectedSkills === null) return { eligible: false, reason: 'observation-not-comparable' };
973
+ if (limit.droppedEntries <= 0) return { eligible: false, reason: 'no-truncation-observed' };
974
+ if (currentSkillCount < limit.projectedSkills) {
975
+ return { eligible: false, reason: 'below-observed-skill-volume' };
976
+ }
977
+ return {
978
+ eligible: true,
979
+ reason: 'eligible',
980
+ droppedEntries: limit.droppedEntries,
981
+ observedAt: limit.observedAt,
982
+ };
983
+ }
984
+
985
+ /**
986
+ * The prompt body. Returns lines; WRITES NOTHING, by contract.
987
+ *
988
+ * `projection.mode` is settings class C, so `settings:set` refuses it by
989
+ * construction and only a human edit or the GUI write route may set it. The
990
+ * message therefore never offers a CLI write — suggesting one would send the
991
+ * reader to a command guaranteed to reject them, which the council caught in
992
+ * the first draft of this text.
993
+ */
994
+ export function migrationPromptLines(
995
+ host: string,
996
+ eligibility: MigrationEligibility,
997
+ settingsPath: string,
998
+ ): string[] {
999
+ return [
1000
+ `${host}: ${eligibility.droppedEntries} catalogue entries never reach the model.`,
1001
+ `Measured on this machine (${eligibility.observedAt}). This install is on \`legacy-all\`,`,
1002
+ 'which ships everything and lets the host drop what does not fit — silently.',
1003
+ '',
1004
+ 'Scoping the projection to your active packs is the alternative. Nothing here',
1005
+ 'changes it for you: `projection.mode` is yours to set, by hand or in the GUI.',
1006
+ '',
1007
+ ` edit ${settingsPath}`,
1008
+ ' projection:',
1009
+ ' mode: scoped',
1010
+ '',
1011
+ ' or agent-config config (Settings → projection)',
1012
+ '',
1013
+ 'Keeping `legacy-all` is a legitimate choice — this is a notification, not a gate.',
1014
+ ];
1015
+ }
1016
+
1017
+ /** Read the append-only observation log; a missing or broken line is skipped. */
1018
+ export function readObservationLog(logPath: string): ObservationRecord[] {
1019
+ if (!fs.existsSync(logPath)) return [];
1020
+ const out: ObservationRecord[] = [];
1021
+ for (const line of fs.readFileSync(logPath, 'utf-8').split('\n')) {
1022
+ const trimmed = line.trim();
1023
+ if (trimmed.length === 0) continue;
1024
+ try {
1025
+ out.push(JSON.parse(trimmed) as ObservationRecord);
1026
+ } catch {
1027
+ continue;
1028
+ }
1029
+ }
1030
+ return out;
1031
+ }
1032
+
1033
+ /**
1034
+ * Per-host verdicts — never one pooled verdict.
1035
+ *
1036
+ * Pooling a `per-entry` observation with a `budget-strip-and-drop` one would
1037
+ * report a selector verdict over a host that has no per-entry selector, which
1038
+ * is Risk 1 of the roadmap that asked for this field.
1039
+ */
1040
+ export function formatPerHostVerdicts(records: readonly ObservationRecord[]): string {
1041
+ if (records.length === 0) return 'no observations recorded yet.';
1042
+ const byHost = new Map<string, ObservationRecord[]>();
1043
+ for (const record of records) {
1044
+ const bucket = byHost.get(record.host) ?? [];
1045
+ bucket.push(record);
1046
+ byHost.set(record.host, bucket);
1047
+ }
1048
+
1049
+ const headline = headlineRecordPerHost(records);
1050
+
1051
+ const lines: string[] = [];
1052
+ for (const host of [...byHost.keys()].sort()) {
1053
+ const bucket = byHost.get(host)!;
1054
+ // One selector for both reducers — see `_supersedes`. The previous
1055
+ // `>=` reduce here kept the FIRST of two same-date rows while
1056
+ // `knownHostLimits` kept the LAST, so one run printed two different
1057
+ // drop counts for one host on one day.
1058
+ const latest = headline.get(host)!;
1059
+ const mode = truncationModeOf(latest);
1060
+ lines.push(
1061
+ `${host}: ${bucket.length} observation(s) · mode ${mode} · ` +
1062
+ `source ${observationSourceOf(latest)} · latest ${latest.observed_at}`,
1063
+ );
1064
+ lines.push(
1065
+ mode === 'budget-strip-and-drop'
1066
+ ? ` offered ${latest.entries_total}, dropped ${latest.dropped_count ?? 'unknown'} — ` +
1067
+ 'no per-entry selector exists on this host; the quantity is the budget.'
1068
+ : ` verdict ${latest.verdict} · ${latest.bare_count} bare of ${latest.entries_total}`,
1069
+ );
1070
+ }
1071
+
1072
+ const modes = new Set([...headline.values()].map((r) => truncationModeOf(r)));
1073
+ lines.push('');
1074
+ lines.push(
1075
+ modes.size > 1
1076
+ ? `hosts: ${byHost.size} · truncation modes DIFFER (${[...modes].sort().join(', ')}) — ` +
1077
+ 'a pooled verdict across them would describe neither host.'
1078
+ : `hosts: ${byHost.size} · all observed hosts truncate the same way (${[...modes][0] ?? 'none'}).`,
1079
+ );
1080
+ return lines.join('\n');
1081
+ }
1082
+
1083
+ export function formatReport(report: SelectorReport): string {
1084
+ const lines: string[] = [];
1085
+ lines.push(`catalogue root: ${report.catalogueRoot}`);
1086
+ lines.push(`entries: ${report.entriesTotal}`);
1087
+ lines.push(
1088
+ `observed: ${report.observedBare.length} bare · ${report.observedDescribed.length} described (self-reported)`,
1089
+ );
1090
+ if (report.unknownObserved.length > 0) {
1091
+ lines.push(
1092
+ `⚠️ ${report.unknownObserved.length} observed name(s) absent from the projection: ${report.unknownObserved.join(', ')}`,
1093
+ );
1094
+ }
1095
+ if (report.verdict === 'insufficient-observation') {
1096
+ lines.push('');
1097
+ lines.push(
1098
+ 'verdict: insufficient-observation — an observation needs at least one bare AND one described entry to separate anything.',
1099
+ );
1100
+ return lines.join('\n');
1101
+ }
1102
+ lines.push('');
1103
+ lines.push('selector candidates:');
1104
+ for (const candidate of report.candidates) {
1105
+ lines.push(` ${candidate.separates ? '✅' : '❌'} ${candidate.id} — ${candidate.describes}`);
1106
+ lines.push(` ${candidate.detail}`);
1107
+ }
1108
+ lines.push('');
1109
+ lines.push(
1110
+ report.verdict === 'selector-found'
1111
+ ? 'verdict: selector-found — a delivery fix can act on the separating property above.'
1112
+ : 'verdict: no-selector — no measured property separates the groups; the selector is host-internal on this evidence. Publish the null rather than guessing a fix.',
1113
+ );
1114
+ return lines.join('\n');
1115
+ }