@event4u/agent-config 12.0.0 → 12.1.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 (89) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +122 -0
  3. package/agents/templates/.ai-council.yml.example +46 -3
  4. package/dist/agent-src/contexts/model-recommendations.md +6 -4
  5. package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +1 -0
  6. package/dist/agent-src/scripts/roadmap_gates.ts +30 -2
  7. package/dist/agent-src/scripts/update_roadmap_progress.ts +32 -1
  8. package/dist/agent-src/skills/roadmap-writing/SKILL.md +7 -7
  9. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  10. package/dist/agent-src/templates/roadmaps.md +41 -3
  11. package/dist/cli/registry.js +1 -0
  12. package/dist/cli/registry.js.map +1 -1
  13. package/dist/cli-delegate/chunk-K34QTSD5.js +3895 -0
  14. package/dist/cli-delegate/{chunk-L565YFXH.js → chunk-LKZ5DOYW.js} +557 -297
  15. package/dist/cli-delegate/{chunk-NN4KW4HZ.js → chunk-O52B4F5S.js} +123 -2338
  16. package/dist/cli-delegate/cmd_conformance.js +4 -4
  17. package/dist/cli-delegate/cmd_doctor.js +2 -2
  18. package/dist/cli-delegate/cmd_export.js +1 -1
  19. package/dist/cli-delegate/cmd_handoff.js +1 -1
  20. package/dist/cli-delegate/cmd_prune.js +1 -1
  21. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  22. package/dist/cli-delegate/cmd_sync.js +1 -1
  23. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  24. package/dist/cli-delegate/cmd_validate.js +1 -1
  25. package/dist/discovery/deprecation-report.md +1 -1
  26. package/dist/discovery/discovery-manifest.json +4 -4
  27. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  28. package/dist/discovery/discovery-manifest.summary.md +1 -1
  29. package/dist/discovery/dormancy-report.md +1 -1
  30. package/dist/discovery/orphan-report.md +1 -1
  31. package/dist/discovery/packs.json +2 -2
  32. package/dist/discovery/trust-report.md +1 -1
  33. package/dist/discovery/workspaces.json +2 -2
  34. package/dist/hooks/dispatch.js +2574 -1080
  35. package/dist/install/install.mjs +744 -482
  36. package/dist/mcp/registry-manifest.json +2 -2
  37. package/dist/mcp/server.json +2 -2
  38. package/dist/mcp/server.mjs +113 -10
  39. package/dist/router.json +1 -1
  40. package/dist/scripts/_lib/model_tier.js +9 -0
  41. package/dist/scripts/_lib/model_tier.js.map +1 -1
  42. package/dist/scripts/condense.js +50 -0
  43. package/dist/scripts/condense.js.map +1 -1
  44. package/docs/MIGRATION.md +20 -1
  45. package/package.json +1 -1
  46. package/src/agent-src/scripts/roadmap_gates.ts +30 -2
  47. package/src/agent-src/scripts/update_roadmap_progress.ts +32 -1
  48. package/src/config/evaluator-budgets.json +4 -4
  49. package/src/config/gate-coverage.yml +39 -9
  50. package/src/config/gate-violation-baselines.json +6 -0
  51. package/src/config/hook-token-budget.json +7 -1
  52. package/src/config/preamble-payload-budget.json +28 -0
  53. package/src/scripts/_dispatch.bash +10 -0
  54. package/src/scripts/_lib/git_common_dir.ts +173 -0
  55. package/src/scripts/_lib/model_tier.ts +9 -0
  56. package/src/scripts/_lib/release_highlights.ts +108 -7
  57. package/src/scripts/_lib/skill_catalogue.ts +862 -0
  58. package/src/scripts/ai_council/clients.ts +182 -3
  59. package/src/scripts/ai_council/config.ts +16 -1
  60. package/src/scripts/ai_council/low_impact.ts +14 -1
  61. package/src/scripts/ai_council/orchestrator.ts +14 -1
  62. package/src/scripts/ai_council/transport_resolver.ts +32 -2
  63. package/src/scripts/capture_skill_catalogue.ts +185 -328
  64. package/src/scripts/check_branch_freshness.ts +7 -1
  65. package/src/scripts/check_release_published.ts +6 -2
  66. package/src/scripts/check_release_trunk_sync.ts +18 -2
  67. package/src/scripts/condense.ts +50 -1
  68. package/src/scripts/dispatch_r2_reviewer.ts +15 -5
  69. package/src/scripts/evidence_report.ts +15 -4
  70. package/src/scripts/hook_manifest.yaml +47 -13
  71. package/src/scripts/hooks/concern_registry.ts +2 -0
  72. package/src/scripts/hooks/orchestration_record_hook.ts +21 -0
  73. package/src/scripts/hooks/tool_result_bytes_hook.ts +219 -0
  74. package/src/scripts/hot_context_hook.ts +13 -1
  75. package/src/scripts/install.ts +129 -0
  76. package/src/scripts/janitor.ts +77 -2
  77. package/src/scripts/lint_plan_risk_register.ts +22 -9
  78. package/src/scripts/lint_roadmap_blockers.ts +109 -6
  79. package/src/scripts/lint_scheduled_deprecations.ts +738 -0
  80. package/src/scripts/migration_status.ts +17 -1
  81. package/src/scripts/probe_review_binding_drift.ts +731 -0
  82. package/src/scripts/release.ts +95 -1
  83. package/src/scripts/schemas/command.schema.json +2 -2
  84. package/src/scripts/schemas/skill.schema.json +2 -1
  85. package/src/scripts/schemas/subagent.schema.json +2 -2
  86. package/src/scripts/session_eol_report.ts +81 -0
  87. package/src/scripts/workspace_doctor.ts +464 -0
  88. package/src/scripts/worktree_cleanup_check.ts +2 -1
  89. package/dist/cli-delegate/chunk-AYKQGIBG.js +0 -1594
@@ -0,0 +1,862 @@
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
+ /** Where observations accumulate. Append-only, one JSON object per line. */
84
+ export const OBSERVATION_LOG = path.join(
85
+ 'agents',
86
+ 'evidence',
87
+ 'metrics',
88
+ 'skill-catalogue.jsonl',
89
+ );
90
+
91
+ export interface CatalogueEntry {
92
+ /** Skill directory name — the identifier the host lists. */
93
+ name: string;
94
+ /** 1-based position in the alphabetically sorted catalogue. */
95
+ position: number;
96
+ /** Whether the projected SKILL.md declares a `description:`. */
97
+ hasDescription: boolean;
98
+ /** Character length of the declared description; 0 when absent. */
99
+ descriptionLength: number;
100
+ /**
101
+ * The declared description itself, folded. In-memory only — this is the
102
+ * projection half, which is file content the host is handed, and it is
103
+ * what makes a byte-accurate volume measurement possible. It is NEVER
104
+ * copied into an `ObservationRecord`; see the privacy paragraph.
105
+ */
106
+ description: string;
107
+ /** Top-level frontmatter keys the entry declares. */
108
+ frontmatterKeys: string[];
109
+ }
110
+
111
+ export interface SelectorCandidate {
112
+ /** Stable identifier for the property under test. */
113
+ id: string;
114
+ /** Human-readable statement of what the property is. */
115
+ describes: string;
116
+ /** True when the property's values are disjoint across the two groups. */
117
+ separates: boolean;
118
+ /** Why it does or does not separate, in one line. */
119
+ detail: string;
120
+ }
121
+
122
+ export interface SelectorReport {
123
+ catalogueRoot: string;
124
+ entriesTotal: number;
125
+ observedBare: string[];
126
+ observedDescribed: string[];
127
+ /** Observed names that the projection does not know. */
128
+ unknownObserved: string[];
129
+ candidates: SelectorCandidate[];
130
+ verdict: 'selector-found' | 'no-selector' | 'insufficient-observation';
131
+ }
132
+
133
+ /** Frontmatter block between the leading `---` fences, or "" when absent. */
134
+ function frontmatterOf(content: string): string {
135
+ if (!content.startsWith('---')) return '';
136
+ const end = content.indexOf('\n---', 3);
137
+ if (end === -1) return '';
138
+ return content.slice(3, end);
139
+ }
140
+
141
+ /**
142
+ * Top-level keys of a frontmatter block. Deliberately shallow: a nested key is
143
+ * not a property the host's catalogue selector could plausibly read, and a full
144
+ * YAML parse would drag a dependency into a measurement tool.
145
+ */
146
+ function topLevelKeys(frontmatter: string): string[] {
147
+ const keys: string[] = [];
148
+ for (const line of frontmatter.split('\n')) {
149
+ const match = /^([A-Za-z_][A-Za-z0-9_-]*):/.exec(line);
150
+ if (match) keys.push(match[1]!);
151
+ }
152
+ return keys;
153
+ }
154
+
155
+ /**
156
+ * The declared description with surrounding quotes stripped, or "".
157
+ *
158
+ * A block scalar (`description: >-` / `|`) puts the text on the FOLLOWING
159
+ * lines, so taking the remainder of the header line would yield the
160
+ * two-character indicator and report `descriptionLength: 2`. That would feed
161
+ * the length-based selector candidate noise, and a length threshold could be
162
+ * reported or refuted on it. Block scalars are therefore folded properly.
163
+ */
164
+ function descriptionOf(frontmatter: string): string {
165
+ const lines = frontmatter.split('\n');
166
+ for (let i = 0; i < lines.length; i += 1) {
167
+ const match = /^description:\s*(.*)$/.exec(lines[i]!);
168
+ if (!match) continue;
169
+ const rest = match[1]!.trim();
170
+
171
+ if (/^[|>][-+]?\d*$/.test(rest)) {
172
+ const body: string[] = [];
173
+ for (let j = i + 1; j < lines.length; j += 1) {
174
+ const line = lines[j]!;
175
+ if (line.trim() === '') {
176
+ body.push('');
177
+ continue;
178
+ }
179
+ if (!/^\s/.test(line)) break; // dedented, so the block ended
180
+ body.push(line.trim());
181
+ }
182
+ return body.join(' ').trim();
183
+ }
184
+
185
+ return rest.replace(/^["']|["']$/g, '');
186
+ }
187
+ return '';
188
+ }
189
+
190
+ /**
191
+ * Read every `<root>/<name>/SKILL.md` into a catalogue entry, sorted by name —
192
+ * the order a host listing follows and therefore the order a positional
193
+ * hypothesis is measured against.
194
+ */
195
+ export function readProjectedCatalogue(root: string): CatalogueEntry[] {
196
+ // Membership is decided by "does <name>/SKILL.md resolve", never by
197
+ // `Dirent.isDirectory()`. The host-facing projection is a tree of SYMLINKS
198
+ // into `dist/agent-src/skills/`, and a Dirent for a symlink reports
199
+ // `isDirectory() === false` — that filter silently read 47 of 289 entries
200
+ // and reported the result as a complete catalogue. `existsSync` follows
201
+ // the link, so the scan sees what the host sees.
202
+ const names = fs
203
+ .readdirSync(root)
204
+ .filter((n) => fs.existsSync(path.join(root, n, 'SKILL.md')))
205
+ .sort();
206
+
207
+ return names.map((name, index) => {
208
+ const content = fs.readFileSync(path.join(root, name, 'SKILL.md'), 'utf-8');
209
+ const frontmatter = frontmatterOf(content);
210
+ const description = descriptionOf(frontmatter);
211
+ return {
212
+ name,
213
+ position: index + 1,
214
+ hasDescription: description.length > 0,
215
+ descriptionLength: description.length,
216
+ description,
217
+ frontmatterKeys: topLevelKeys(frontmatter),
218
+ };
219
+ });
220
+ }
221
+
222
+ /** True when two number sets occupy disjoint ranges in either direction. */
223
+ function rangesAreDisjoint(a: number[], b: number[]): boolean {
224
+ if (a.length === 0 || b.length === 0) return false;
225
+ const maxA = Math.max(...a);
226
+ const minA = Math.min(...a);
227
+ const maxB = Math.max(...b);
228
+ const minB = Math.min(...b);
229
+ return maxA < minB || maxB < minA;
230
+ }
231
+
232
+ /**
233
+ * Join the projection against an observation and report which property, if
234
+ * any, separates the bare entries from the described ones.
235
+ *
236
+ * `observedBare` is the list of entries a live session saw without a
237
+ * description. Everything else in the projection is treated as observed
238
+ * described, which is the honest reading of a whole-catalogue observation —
239
+ * a partial observation would need its own described list, so the caller
240
+ * passes one when it has it.
241
+ */
242
+ export function analyzeSelector(
243
+ projected: CatalogueEntry[],
244
+ observedBare: readonly string[],
245
+ observedDescribed?: readonly string[],
246
+ ): Omit<SelectorReport, 'catalogueRoot'> {
247
+ const known = new Map(projected.map((e) => [e.name, e]));
248
+ const bare = observedBare.filter((n) => known.has(n));
249
+ const describedInput =
250
+ observedDescribed ?? projected.map((e) => e.name).filter((n) => !bare.includes(n));
251
+ const described = describedInput.filter((n) => known.has(n));
252
+
253
+ // Both self-reported lists are validated, not only the bare one: a typo in
254
+ // --described silently shrank the described set and weakened the analysis,
255
+ // while the same typo in --observed was surfaced.
256
+ const unknownObserved = [
257
+ ...new Set([
258
+ ...observedBare.filter((n) => !known.has(n)),
259
+ ...(observedDescribed ?? []).filter((n) => !known.has(n)),
260
+ ]),
261
+ ].sort();
262
+
263
+ const base = {
264
+ entriesTotal: projected.length,
265
+ observedBare: [...bare].sort(),
266
+ observedDescribed: [...described].sort(),
267
+ unknownObserved,
268
+ };
269
+
270
+ if (bare.length === 0 || described.length === 0) {
271
+ return {
272
+ ...base,
273
+ candidates: [],
274
+ verdict: 'insufficient-observation' as const,
275
+ };
276
+ }
277
+
278
+ const bareEntries = bare.map((n) => known.get(n)!);
279
+ const describedEntries = described.map((n) => known.get(n)!);
280
+ const candidates: SelectorCandidate[] = [];
281
+
282
+ // 1. The projection itself — does the entry even declare a description?
283
+ const bareWithDesc = bareEntries.filter((e) => e.hasDescription).length;
284
+ const describedWithoutDesc = describedEntries.filter((e) => !e.hasDescription).length;
285
+ candidates.push({
286
+ id: 'declares-description',
287
+ describes: 'the projected SKILL.md declares a `description:`',
288
+ separates: bareWithDesc === 0 && describedWithoutDesc === 0,
289
+ detail: `${bareWithDesc}/${bareEntries.length} bare entries declare one; ${describedWithoutDesc}/${describedEntries.length} described entries do not`,
290
+ });
291
+
292
+ // 2. The head-N hypothesis the census proposed — falsifiable here.
293
+ const barePositions = bareEntries.map((e) => e.position);
294
+ const describedPositions = describedEntries.map((e) => e.position);
295
+ const positional = Math.max(...describedPositions) < Math.min(...barePositions);
296
+ candidates.push({
297
+ id: 'positional-head',
298
+ describes: 'every described entry sorts before every bare entry (a head-N budget)',
299
+ separates: positional,
300
+ detail: positional
301
+ ? `described top out at #${Math.max(...describedPositions)}, bare start at #${Math.min(...barePositions)}`
302
+ : `described reach #${Math.max(...describedPositions)} while bare start at #${Math.min(...barePositions)} — the ranges overlap, so no head-N cut explains this`,
303
+ });
304
+
305
+ // 3. Description length as a budget proxy.
306
+ const lengthSeparates = rangesAreDisjoint(
307
+ bareEntries.map((e) => e.descriptionLength),
308
+ describedEntries.map((e) => e.descriptionLength),
309
+ );
310
+ candidates.push({
311
+ id: 'description-length',
312
+ describes: 'description length ranges do not overlap between the groups',
313
+ separates: lengthSeparates,
314
+ detail: lengthSeparates
315
+ ? 'a length threshold separates the two groups'
316
+ : 'length ranges overlap',
317
+ });
318
+
319
+ // 4. Any frontmatter key whose presence tracks the split exactly.
320
+ const allKeys = new Set<string>();
321
+ for (const entry of projected) for (const key of entry.frontmatterKeys) allKeys.add(key);
322
+ for (const key of [...allKeys].sort()) {
323
+ const bareHas = bareEntries.filter((e) => e.frontmatterKeys.includes(key)).length;
324
+ const describedHas = describedEntries.filter((e) =>
325
+ e.frontmatterKeys.includes(key),
326
+ ).length;
327
+ const separates =
328
+ (bareHas === 0 && describedHas === describedEntries.length) ||
329
+ (describedHas === 0 && bareHas === bareEntries.length);
330
+ if (!separates) continue;
331
+ candidates.push({
332
+ id: `frontmatter:${key}`,
333
+ describes: `presence of the \`${key}:\` frontmatter key`,
334
+ separates: true,
335
+ detail: `bare ${bareHas}/${bareEntries.length}, described ${describedHas}/${describedEntries.length}`,
336
+ });
337
+ }
338
+
339
+ return {
340
+ ...base,
341
+ candidates,
342
+ verdict: candidates.some((c) => c.separates)
343
+ ? ('selector-found' as const)
344
+ : ('no-selector' as const),
345
+ };
346
+ }
347
+
348
+ /**
349
+ * How a host truncates. The two known mechanisms are not variants of one
350
+ * number, so a record that does not say which one it measured cannot be
351
+ * compared against one that measured the other.
352
+ *
353
+ * `per-entry` — some entries arrive with their description, others arrive as
354
+ * bare names. Which is which is the selector question `analyzeSelector` asks.
355
+ *
356
+ * `budget-strip-and-drop` — the host strips EVERY description, then drops N
357
+ * entries from the list entirely. There is no per-entry selector to find; the
358
+ * quantity that matters is N.
359
+ */
360
+ export type TruncationMode = 'per-entry' | 'budget-strip-and-drop';
361
+
362
+ /** Where the observation half came from. See the header. */
363
+ export type ObservationSource = 'self-report' | 'host-event';
364
+
365
+ /** One append-only observation record. No field can hold free-form content. */
366
+ export interface ObservationRecord {
367
+ schema: 1;
368
+ observed_at: string;
369
+ host: string;
370
+ entries_total: number;
371
+ bare_count: number;
372
+ described_count: number;
373
+ bare_names: string[];
374
+ verdict: SelectorReport['verdict'];
375
+ separating_candidates: string[];
376
+ /**
377
+ * Absent on a schema-1 record written before hosts were distinguished.
378
+ * Absent reads as `per-entry`, which is what those records measured — see
379
+ * `truncationModeOf`. Never default it to the codex mode: that would
380
+ * retroactively relabel an observation nobody took.
381
+ */
382
+ truncation_mode?: TruncationMode;
383
+ /** Absent on records written before the two sources were distinguished. */
384
+ observation_source?: ObservationSource;
385
+ /**
386
+ * Skills projected at this observation — the ONLY population the drop
387
+ * tracks. `entries_total` counts every artefact, and a controlled probe
388
+ * (2026-08-15) moved the host's dropped count by 0 when 60 commands were
389
+ * added and by +53 when 60 skills were. Comparing artefact totals would
390
+ * fire on command growth that causes no truncation at all.
391
+ */
392
+ projected_skill_count?: number;
393
+ /**
394
+ * Entries the host dropped wholesale, read from the host's own event.
395
+ * Only ever present on a `budget-strip-and-drop` record — a `per-entry`
396
+ * host publishes no such count, and inventing one is the failure the
397
+ * header's zero-is-never-inferred paragraph forbids.
398
+ */
399
+ dropped_count?: number;
400
+ }
401
+
402
+ /** The mode a record measured; absent reads as the pre-codex `per-entry`. */
403
+ export function truncationModeOf(record: ObservationRecord): TruncationMode {
404
+ return record.truncation_mode ?? 'per-entry';
405
+ }
406
+
407
+ /** The source a record came from; absent reads as the pre-codex self-report. */
408
+ export function observationSourceOf(record: ObservationRecord): ObservationSource {
409
+ return record.observation_source ?? 'self-report';
410
+ }
411
+
412
+ export function buildObservationRecord(
413
+ report: SelectorReport,
414
+ host: string,
415
+ observedAt: string,
416
+ ): ObservationRecord {
417
+ return {
418
+ schema: 1,
419
+ observed_at: observedAt,
420
+ host,
421
+ entries_total: report.entriesTotal,
422
+ bare_count: report.observedBare.length,
423
+ described_count: report.observedDescribed.length,
424
+ bare_names: report.observedBare,
425
+ verdict: report.verdict,
426
+ separating_candidates: report.candidates.filter((c) => c.separates).map((c) => c.id),
427
+ truncation_mode: 'per-entry',
428
+ observation_source: 'self-report',
429
+ };
430
+ }
431
+
432
+ /* ------------------------------------------------------------------ *
433
+ * The host-event path — codex publishes its own truncation.
434
+ * ------------------------------------------------------------------ */
435
+
436
+ /** What a host's own budget event states. Integers only, by construction. */
437
+ export interface HostBudgetEvent {
438
+ /** Entries the host dropped from the model-visible list. */
439
+ droppedCount: number;
440
+ /** Whether the host also stripped descriptions from what survived. */
441
+ descriptionsStripped: boolean;
442
+ }
443
+
444
+ /**
445
+ * Read a host budget event out of a `codex exec --json` stream.
446
+ *
447
+ * Parses the STRUCTURED event, never the human-readable warning line: the
448
+ * pretty line is presentation and may be reworded or dropped without notice,
449
+ * while the JSON channel is the host's machine contract. Observed shape
450
+ * (2026-08-15, codex CLI):
451
+ *
452
+ * {"type":"item.completed","item":{"id":"item_0","type":"error",
453
+ * "message":"Exceeded skills context budget. All skill descriptions were
454
+ * removed and 393 additional skills were not included in the
455
+ * model-visible skills list."}}
456
+ *
457
+ * Returns `null` when no such event is present. `null` means UNKNOWN, never
458
+ * zero — the caller decides whether the run genuinely did not truncate, and
459
+ * says so explicitly. A line that is not JSON is skipped rather than fatal:
460
+ * the stream is interleaved with the CLI's own plain-text chatter.
461
+ */
462
+ export function parseHostBudgetEvent(stream: string): HostBudgetEvent | null {
463
+ for (const line of stream.split('\n')) {
464
+ const trimmed = line.trim();
465
+ if (!trimmed.startsWith('{')) continue;
466
+
467
+ let parsed: unknown;
468
+ try {
469
+ parsed = JSON.parse(trimmed);
470
+ } catch {
471
+ continue;
472
+ }
473
+
474
+ const item = (parsed as { item?: { type?: unknown; message?: unknown } }).item;
475
+ if (item === undefined || item === null || item.type !== 'error') continue;
476
+ const message = typeof item.message === 'string' ? item.message : '';
477
+ if (!/skills context budget/i.test(message)) continue;
478
+
479
+ const dropped = /(\d+)\s+additional skills were not included/i.exec(message);
480
+ if (!dropped) {
481
+ // The event fired and its shape changed. Reporting this as "no
482
+ // truncation" would turn an upstream rewording into a clean bill
483
+ // of health, so it is an unknown like any other.
484
+ return null;
485
+ }
486
+
487
+ return {
488
+ droppedCount: Number.parseInt(dropped[1]!, 10),
489
+ descriptionsStripped: /descriptions were removed/i.test(message),
490
+ };
491
+ }
492
+ return null;
493
+ }
494
+
495
+ /**
496
+ * An observation built from the host's own event rather than from a self-report.
497
+ *
498
+ * `entriesOffered` is the deterministic projection half — how many artefacts
499
+ * the host was given. `bare_count` is the whole surviving set when the host
500
+ * stripped every description, which is exactly what the codex message states;
501
+ * there is no per-entry list to record, so `bare_names` stays empty rather
502
+ * than being filled with a guess.
503
+ */
504
+ export function buildHostEventRecord(
505
+ host: string,
506
+ observedAt: string,
507
+ entriesOffered: number,
508
+ event: HostBudgetEvent,
509
+ projectedSkillCount?: number,
510
+ ): ObservationRecord {
511
+ return {
512
+ schema: 1,
513
+ observed_at: observedAt,
514
+ host,
515
+ // OUR projection, not the host's denominator. Measured 2026-08-15 they
516
+ // are not the same number and there is no way to read the host's:
517
+ // `~/.codex` projects 297 skills + 200 commands = 497 by this tool's
518
+ // count, while the host reported dropping 393 — and a controlled +60
519
+ // commands moved that by ZERO. Whatever codex counts, it is not this.
520
+ // So `entries_total - dropped_count` is NOT a delivered count, and
521
+ // nothing here computes one: a subtraction across two different
522
+ // denominators produces a confident number describing neither.
523
+ entries_total: entriesOffered,
524
+ bare_count: 0,
525
+ described_count: 0,
526
+ bare_names: [],
527
+ // There is nothing to separate: the host did not choose per entry, it
528
+ // stripped all of them. `no-selector` would read as "we looked and
529
+ // found none", which is not what happened.
530
+ verdict: 'insufficient-observation',
531
+ separating_candidates: [],
532
+ truncation_mode: 'budget-strip-and-drop',
533
+ observation_source: 'host-event',
534
+ dropped_count: event.droppedCount,
535
+ ...(projectedSkillCount === undefined ? {} : { projected_skill_count: projectedSkillCount }),
536
+ };
537
+ }
538
+
539
+ /* ------------------------------------------------------------------ *
540
+ * The projection half, per host — volume, not just entry count.
541
+ * ------------------------------------------------------------------ */
542
+
543
+ /** What one host root is offered. Every number is file-measured. */
544
+ export interface CatalogueVolume {
545
+ host: string;
546
+ root: string;
547
+ skillEntries: number;
548
+ commandEntries: number;
549
+ /** Everything the host lists — the number its budget is spent on. */
550
+ artefacts: number;
551
+ /** Bytes of declared `description:` text across the skill entries. */
552
+ descriptionBytes: number;
553
+ }
554
+
555
+ /** Every `*.md` under a root, at any depth. */
556
+ function markdownFilesUnder(root: string): string[] {
557
+ if (!fs.existsSync(root)) return [];
558
+ const out: string[] = [];
559
+ const walk = (dir: string): void => {
560
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
561
+ const full = path.join(dir, entry.name);
562
+ if (fs.statSync(full).isDirectory()) walk(full);
563
+ else if (entry.name.endsWith('.md')) out.push(full);
564
+ }
565
+ };
566
+ walk(root);
567
+ return out;
568
+ }
569
+
570
+ /**
571
+ * Measure what a host root actually offers.
572
+ *
573
+ * `root` is the host's own config directory (`~/.codex`, `~/.claude`), not a
574
+ * skills directory — the budget is spent on the whole catalogue, and counting
575
+ * skills alone would understate it by the command set.
576
+ */
577
+ export function measureCatalogueVolume(host: string, root: string): CatalogueVolume {
578
+ const skillsRoot = path.join(root, 'skills');
579
+ const skills = fs.existsSync(skillsRoot) ? readProjectedCatalogue(skillsRoot) : [];
580
+ const commandEntries = markdownFilesUnder(path.join(root, 'commands')).length;
581
+ return {
582
+ host,
583
+ root,
584
+ skillEntries: skills.length,
585
+ commandEntries,
586
+ artefacts: skills.length + commandEntries,
587
+ descriptionBytes: skills.reduce((sum, e) => sum + Buffer.byteLength(e.description), 0),
588
+ };
589
+ }
590
+
591
+ /* ------------------------------------------------------------------ *
592
+ * Known limits — read off recorded observations, never invented.
593
+ * ------------------------------------------------------------------ */
594
+
595
+ /**
596
+ * A truncation this estate has actually OBSERVED for one host.
597
+ *
598
+ * Deliberately not "the host's limit". Two of its three numbers would have to
599
+ * come from the host to call it that, and only one does. What is recorded is
600
+ * the honest pair: at a projection of `projectedVolume`, the host said it
601
+ * dropped `droppedEntries`. No delivered count is derived — see
602
+ * `buildHostEventRecord` for why the subtraction is invalid.
603
+ *
604
+ * Built only from a `budget-strip-and-drop` observation, because only that
605
+ * mode publishes a count. A `per-entry` host reports which entries arrived
606
+ * bare, which is a selector fact and not a truncation quantity, so such a host
607
+ * yields nothing here and produces no warning. That asymmetry is the point:
608
+ * an unmeasured host gets no invented number.
609
+ */
610
+ export interface KnownHostLimit {
611
+ host: string;
612
+ /** Entries the host itself said it dropped. */
613
+ droppedEntries: number;
614
+ /** What THIS tool projected for that host at that observation. */
615
+ projectedVolume: number;
616
+ /**
617
+ * SKILLS projected at that observation — the comparable quantity.
618
+ * Absent on a record written before the skill-only count existed, and
619
+ * absence is load-bearing: without it the observation is not comparable
620
+ * and yields no eligibility (see `migrationEligibility`).
621
+ */
622
+ projectedSkills: number | null;
623
+ observedAt: string;
624
+ }
625
+
626
+ /** The most recent observed truncation per host. */
627
+ export function knownHostLimits(records: readonly ObservationRecord[]): Map<string, KnownHostLimit> {
628
+ const out = new Map<string, KnownHostLimit>();
629
+ for (const record of records) {
630
+ if (truncationModeOf(record) !== 'budget-strip-and-drop') continue;
631
+ if (typeof record.dropped_count !== 'number') continue;
632
+ if (record.dropped_count <= 0) continue;
633
+ const previous = out.get(record.host);
634
+ if (previous !== undefined && previous.observedAt > record.observed_at) continue;
635
+ out.set(record.host, {
636
+ host: record.host,
637
+ droppedEntries: record.dropped_count,
638
+ projectedVolume: record.entries_total,
639
+ projectedSkills: record.projected_skill_count ?? null,
640
+ observedAt: record.observed_at,
641
+ });
642
+ }
643
+ return out;
644
+ }
645
+
646
+ /**
647
+ * The deploy-time warning, or `null` when this deploy is smaller than the one
648
+ * that was measured truncating — or when nothing has been measured at all.
649
+ *
650
+ * Warns, never blocks: over-shipping is the safe direction and a consumer who
651
+ * accepts truncation is making a legitimate choice. Naming the command is the
652
+ * point — a number with no way to reproduce it is noise.
653
+ */
654
+ export function catalogueLimitWarning(
655
+ volume: CatalogueVolume,
656
+ limit: KnownHostLimit | undefined,
657
+ ): string | null {
658
+ if (limit === undefined) return null;
659
+ // SKILLS, not artefacts. The council's correction, and it rests on this
660
+ // estate's own probe: +60 commands moved the host's dropped count by 0.
661
+ // An artefact-total comparison would warn on command growth that truncates
662
+ // nothing. An observation with no skill count is not comparable, so it
663
+ // yields no warning rather than a guess.
664
+ if (limit.projectedSkills === null) return null;
665
+ if (volume.skillEntries < limit.projectedSkills) return null;
666
+ return (
667
+ `${volume.host}: deploying ${volume.skillEntries} skills. ` +
668
+ `This host reported dropping ${limit.droppedEntries} entries from the ` +
669
+ `model-visible list when last measured (${limit.observedAt}, at ` +
670
+ `${limit.projectedSkills} skills) — that much never reaches the model. ` +
671
+ 'Explain: `agent-config exec capture_skill_catalogue --limits`'
672
+ );
673
+ }
674
+
675
+ /* ------------------------------------------------------------------ *
676
+ * Migration eligibility — AI council, 2026-08-15, 2/2 converged.
677
+ * ------------------------------------------------------------------ */
678
+
679
+ /**
680
+ * Why an install is (not) eligible to be ASKED about scoped projection.
681
+ *
682
+ * Eligibility is deliberately separate from whether a prompt can be shown.
683
+ * The council's refinement: an install can qualify while the session is
684
+ * non-interactive, and collapsing the two would make "not asked because CI"
685
+ * indistinguishable from "not asked because it does not qualify".
686
+ */
687
+ export interface MigrationEligibility {
688
+ eligible: boolean;
689
+ /** Machine-readable reason, always set — including on the eligible path. */
690
+ reason:
691
+ | 'eligible'
692
+ | 'no-observation-for-host'
693
+ | 'observation-not-comparable'
694
+ | 'no-truncation-observed'
695
+ | 'already-scoped'
696
+ | 'below-observed-skill-volume';
697
+ /** Populated only when eligible, for the message. */
698
+ droppedEntries?: number;
699
+ observedAt?: string;
700
+ }
701
+
702
+ /**
703
+ * Is this install worth asking about?
704
+ *
705
+ * `currentSkillCount` is the SKILL population only, and that is the whole
706
+ * correction the council made to the original predicate. A controlled probe
707
+ * (2026-08-15) moved the host's dropped count by 0 when 60 command files were
708
+ * added and by +53 when 60 skills were, so a comparison over total artefacts
709
+ * would fire on command growth that truncates nothing.
710
+ *
711
+ * An observation with no recorded skill count is NOT comparable — the two
712
+ * numbers would be counted by different rules — and returns
713
+ * `observation-not-comparable` rather than being coerced into a verdict.
714
+ * Never extrapolate from another host.
715
+ */
716
+ export function migrationEligibility(
717
+ host: string,
718
+ resolvedMode: 'scoped' | 'legacy-all',
719
+ currentSkillCount: number,
720
+ limits: ReadonlyMap<string, KnownHostLimit>,
721
+ ): MigrationEligibility {
722
+ if (resolvedMode === 'scoped') return { eligible: false, reason: 'already-scoped' };
723
+ const limit = limits.get(host);
724
+ if (limit === undefined) return { eligible: false, reason: 'no-observation-for-host' };
725
+ if (limit.projectedSkills === null) return { eligible: false, reason: 'observation-not-comparable' };
726
+ if (limit.droppedEntries <= 0) return { eligible: false, reason: 'no-truncation-observed' };
727
+ if (currentSkillCount < limit.projectedSkills) {
728
+ return { eligible: false, reason: 'below-observed-skill-volume' };
729
+ }
730
+ return {
731
+ eligible: true,
732
+ reason: 'eligible',
733
+ droppedEntries: limit.droppedEntries,
734
+ observedAt: limit.observedAt,
735
+ };
736
+ }
737
+
738
+ /**
739
+ * The prompt body. Returns lines; WRITES NOTHING, by contract.
740
+ *
741
+ * `projection.mode` is settings class C, so `settings:set` refuses it by
742
+ * construction and only a human edit or the GUI write route may set it. The
743
+ * message therefore never offers a CLI write — suggesting one would send the
744
+ * reader to a command guaranteed to reject them, which the council caught in
745
+ * the first draft of this text.
746
+ */
747
+ export function migrationPromptLines(
748
+ host: string,
749
+ eligibility: MigrationEligibility,
750
+ settingsPath: string,
751
+ ): string[] {
752
+ return [
753
+ `${host}: ${eligibility.droppedEntries} catalogue entries never reach the model.`,
754
+ `Measured on this machine (${eligibility.observedAt}). This install is on \`legacy-all\`,`,
755
+ 'which ships everything and lets the host drop what does not fit — silently.',
756
+ '',
757
+ 'Scoping the projection to your active packs is the alternative. Nothing here',
758
+ 'changes it for you: `projection.mode` is yours to set, by hand or in the GUI.',
759
+ '',
760
+ ` edit ${settingsPath}`,
761
+ ' projection:',
762
+ ' mode: scoped',
763
+ '',
764
+ ' or agent-config config (Settings → projection)',
765
+ '',
766
+ 'Keeping `legacy-all` is a legitimate choice — this is a notification, not a gate.',
767
+ ];
768
+ }
769
+
770
+ /** Read the append-only observation log; a missing or broken line is skipped. */
771
+ export function readObservationLog(logPath: string): ObservationRecord[] {
772
+ if (!fs.existsSync(logPath)) return [];
773
+ const out: ObservationRecord[] = [];
774
+ for (const line of fs.readFileSync(logPath, 'utf-8').split('\n')) {
775
+ const trimmed = line.trim();
776
+ if (trimmed.length === 0) continue;
777
+ try {
778
+ out.push(JSON.parse(trimmed) as ObservationRecord);
779
+ } catch {
780
+ continue;
781
+ }
782
+ }
783
+ return out;
784
+ }
785
+
786
+ /**
787
+ * Per-host verdicts — never one pooled verdict.
788
+ *
789
+ * Pooling a `per-entry` observation with a `budget-strip-and-drop` one would
790
+ * report a selector verdict over a host that has no per-entry selector, which
791
+ * is Risk 1 of the roadmap that asked for this field.
792
+ */
793
+ export function formatPerHostVerdicts(records: readonly ObservationRecord[]): string {
794
+ if (records.length === 0) return 'no observations recorded yet.';
795
+ const byHost = new Map<string, ObservationRecord[]>();
796
+ for (const record of records) {
797
+ const bucket = byHost.get(record.host) ?? [];
798
+ bucket.push(record);
799
+ byHost.set(record.host, bucket);
800
+ }
801
+
802
+ const lines: string[] = [];
803
+ for (const host of [...byHost.keys()].sort()) {
804
+ const bucket = byHost.get(host)!;
805
+ const latest = bucket.reduce((a, b) => (a.observed_at >= b.observed_at ? a : b));
806
+ const mode = truncationModeOf(latest);
807
+ lines.push(
808
+ `${host}: ${bucket.length} observation(s) · mode ${mode} · ` +
809
+ `source ${observationSourceOf(latest)} · latest ${latest.observed_at}`,
810
+ );
811
+ lines.push(
812
+ mode === 'budget-strip-and-drop'
813
+ ? ` offered ${latest.entries_total}, dropped ${latest.dropped_count ?? 'unknown'} — ` +
814
+ 'no per-entry selector exists on this host; the quantity is the budget.'
815
+ : ` verdict ${latest.verdict} · ${latest.bare_count} bare of ${latest.entries_total}`,
816
+ );
817
+ }
818
+
819
+ const modes = new Set([...byHost.values()].map((b) => truncationModeOf(b[b.length - 1]!)));
820
+ lines.push('');
821
+ lines.push(
822
+ modes.size > 1
823
+ ? `hosts: ${byHost.size} · truncation modes DIFFER (${[...modes].sort().join(', ')}) — ` +
824
+ 'a pooled verdict across them would describe neither host.'
825
+ : `hosts: ${byHost.size} · all observed hosts truncate the same way (${[...modes][0] ?? 'none'}).`,
826
+ );
827
+ return lines.join('\n');
828
+ }
829
+
830
+ export function formatReport(report: SelectorReport): string {
831
+ const lines: string[] = [];
832
+ lines.push(`catalogue root: ${report.catalogueRoot}`);
833
+ lines.push(`entries: ${report.entriesTotal}`);
834
+ lines.push(
835
+ `observed: ${report.observedBare.length} bare · ${report.observedDescribed.length} described (self-reported)`,
836
+ );
837
+ if (report.unknownObserved.length > 0) {
838
+ lines.push(
839
+ `⚠️ ${report.unknownObserved.length} observed name(s) absent from the projection: ${report.unknownObserved.join(', ')}`,
840
+ );
841
+ }
842
+ if (report.verdict === 'insufficient-observation') {
843
+ lines.push('');
844
+ lines.push(
845
+ 'verdict: insufficient-observation — an observation needs at least one bare AND one described entry to separate anything.',
846
+ );
847
+ return lines.join('\n');
848
+ }
849
+ lines.push('');
850
+ lines.push('selector candidates:');
851
+ for (const candidate of report.candidates) {
852
+ lines.push(` ${candidate.separates ? '✅' : '❌'} ${candidate.id} — ${candidate.describes}`);
853
+ lines.push(` ${candidate.detail}`);
854
+ }
855
+ lines.push('');
856
+ lines.push(
857
+ report.verdict === 'selector-found'
858
+ ? 'verdict: selector-found — a delivery fix can act on the separating property above.'
859
+ : '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.',
860
+ );
861
+ return lines.join('\n');
862
+ }