@event4u/agent-config 13.0.0 → 14.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 (118) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +160 -333
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/evals/roadmap.json +8 -0
  5. package/dist/agent-src/commands/fix/ci.md +39 -1
  6. package/dist/agent-src/commands/pr/create.md +65 -4
  7. package/dist/agent-src/commands/roadmap/next.md +28 -5
  8. package/dist/agent-src/commands/work.md +16 -0
  9. package/dist/agent-src/contexts/contracts/command-suggestion-flow.md +47 -0
  10. package/dist/agent-src/contexts/execution/contract-decision-sheet.md +94 -0
  11. package/dist/agent-src/contexts/execution/plan-confidence-gate.md +30 -0
  12. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +21 -8
  13. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +55 -8
  14. package/dist/agent-src/contexts/execution/subagent-response-contract.md +42 -0
  15. package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +29 -0
  16. package/dist/agent-src/rules/command-suggestion-policy.md +33 -0
  17. package/dist/agent-src/rules/design-review-after-ui-write.md +25 -8
  18. package/dist/agent-src/rules/ui-audit-gate.md +25 -8
  19. package/dist/agent-src/scripts/gate_execute.ts +395 -0
  20. package/dist/agent-src/scripts/resume_probe.ts +422 -0
  21. package/dist/agent-src/scripts/roadmap_gates.ts +138 -4
  22. package/dist/agent-src/scripts/update_roadmap_progress.ts +53 -2
  23. package/dist/agent-src/skills/ai-council/SKILL.md +61 -0
  24. package/dist/agent-src/skills/experiment-loop/SKILL.md +116 -0
  25. package/dist/agent-src/skills/experiment-loop/evals/output-schema.yml +27 -0
  26. package/dist/agent-src/skills/experiment-loop/evals/triggers.json +17 -0
  27. package/dist/agent-src/skills/experiment-loop/references/pivot-ladder.md +54 -0
  28. package/dist/agent-src/skills/experiment-loop/references/protocol.md +61 -0
  29. package/dist/agent-src/skills/experiment-loop/references/register-format.md +66 -0
  30. package/dist/agent-src/skills/roadmap-management/SKILL.md +2 -2
  31. package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
  32. package/dist/agent-src/skills/subagent-orchestration/schemas/team-review-status.json +8 -3
  33. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  34. package/dist/agent-src/templates/roadmaps.md +43 -3
  35. package/dist/cli/registry.js +1 -0
  36. package/dist/cli/registry.js.map +1 -1
  37. package/dist/cli-delegate/{chunk-N7FEST6D.js → chunk-7PZ4FSZB.js} +351 -174
  38. package/dist/cli-delegate/{chunk-3B72LSZL.js → chunk-F3YUF7SA.js} +1 -1
  39. package/dist/cli-delegate/cmd_conformance.js +224 -104
  40. package/dist/cli-delegate/cmd_doctor.js +2 -2
  41. package/dist/cli-delegate/cmd_handoff.js +1 -1
  42. package/dist/discovery/deprecation-report.md +1 -1
  43. package/dist/discovery/discovery-manifest.json +40 -18
  44. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  45. package/dist/discovery/discovery-manifest.summary.md +3 -3
  46. package/dist/discovery/dormancy-report.md +1 -1
  47. package/dist/discovery/orphan-report.md +1 -1
  48. package/dist/discovery/packs.json +6 -5
  49. package/dist/discovery/trust-report.md +2 -2
  50. package/dist/discovery/workspaces.json +5 -4
  51. package/dist/hooks/dispatch.js +2018 -1320
  52. package/dist/mcp/registry-manifest.json +3 -3
  53. package/dist/mcp/server.json +2 -2
  54. package/dist/mcp/server.mjs +1652 -892
  55. package/dist/router.json +1 -1
  56. package/dist/scripts/condense.js +1 -1
  57. package/dist/scripts/condense.js.map +1 -1
  58. package/dist/ui/assets/{index-B0aMJnuz.js → index-C1pBRm1f.js} +2 -2
  59. package/dist/ui/assets/index-C1pBRm1f.js.map +1 -0
  60. package/dist/ui/index.html +1 -1
  61. package/package.json +1 -1
  62. package/src/agent-src/scripts/gate_execute.ts +395 -0
  63. package/src/agent-src/scripts/resume_probe.ts +422 -0
  64. package/src/agent-src/scripts/roadmap_gates.ts +138 -4
  65. package/src/agent-src/scripts/update_roadmap_progress.ts +53 -2
  66. package/src/config/ci-local-parity.yml +13 -0
  67. package/src/config/evaluator-budgets.json +4 -4
  68. package/src/config/gate-coverage.yml +111 -5
  69. package/src/config/gate-violation-baselines.json +2 -2
  70. package/src/config/rule-activation-census.json +41 -0
  71. package/src/config/rule-stub-ceilings.json +52 -0
  72. package/src/scripts/_dispatch.bash +4 -0
  73. package/src/scripts/_lib/evaluator_contract.ts +159 -0
  74. package/src/scripts/_lib/gh_transient.ts +80 -0
  75. package/src/scripts/_lib/release_material.ts +16 -5
  76. package/src/scripts/_lib/review_independence.ts +111 -0
  77. package/src/scripts/_lib/session_register.ts +14 -0
  78. package/src/scripts/_lib/skill_catalogue.ts +20 -0
  79. package/src/scripts/_lib/skill_catalogue_series.ts +411 -0
  80. package/src/scripts/_lib/turn_end_refusals.ts +482 -0
  81. package/src/scripts/ai_council/_py_json.ts +96 -0
  82. package/src/scripts/ai_council/cli_call_budget.ts +357 -0
  83. package/src/scripts/ai_council/clients.ts +80 -143
  84. package/src/scripts/ai_council/config.ts +25 -11
  85. package/src/scripts/ai_council/probe_store.ts +196 -0
  86. package/src/scripts/ai_council/qualification.ts +425 -0
  87. package/src/scripts/ai_council/qualification_wiring.ts +243 -0
  88. package/src/scripts/ai_council/quorum_wiring.ts +211 -0
  89. package/src/scripts/ai_team/team_dispatch.ts +40 -6
  90. package/src/scripts/capture_skill_catalogue.ts +264 -4
  91. package/src/scripts/check_evaluator_schema.ts +249 -0
  92. package/src/scripts/check_pr_ci_current.ts +409 -0
  93. package/src/scripts/check_review_schema.ts +195 -0
  94. package/src/scripts/check_rule_activation_census.ts +451 -0
  95. package/src/scripts/check_rule_stub_ceiling.ts +1128 -0
  96. package/src/scripts/condense.ts +1 -1
  97. package/src/scripts/council_cli.ts +141 -191
  98. package/src/scripts/dispatch_r2_reviewer.ts +8 -1
  99. package/src/scripts/hook_manifest.yaml +72 -5
  100. package/src/scripts/hooks/concern_registry.ts +4 -0
  101. package/src/scripts/hooks/dispatch_hook.ts +43 -1
  102. package/src/scripts/hooks/interruption_ledger_hook.ts +256 -0
  103. package/src/scripts/hooks/ship_diff_volume_hook.ts +180 -0
  104. package/src/scripts/hooks/turn_end_gate_hook.ts +119 -26
  105. package/src/scripts/hooks_doctor.ts +112 -0
  106. package/src/scripts/interruption_report.ts +443 -0
  107. package/src/scripts/lint_evidence_artifacts.ts +359 -0
  108. package/src/scripts/lint_roadmap_blockers.ts +65 -0
  109. package/src/scripts/lint_roadmap_complexity.ts +7 -1
  110. package/src/scripts/release.ts +38 -53
  111. package/src/scripts/rule_activation_census.ts +292 -0
  112. package/src/scripts/schemas/evaluator-output.schema.json +60 -0
  113. package/src/scripts/schemas/review-findings.schema.json +31 -0
  114. package/src/scripts/self_review_gate.ts +6 -0
  115. package/src/scripts/session_register_hook.ts +30 -1
  116. package/src/scripts/sync_pr_branch.ts +271 -0
  117. package/src/scripts/test_council_qualification.ts +213 -0
  118. package/dist/ui/assets/index-B0aMJnuz.js.map +0 -1
@@ -0,0 +1,411 @@
1
+ /**
2
+ * skill_catalogue_series — the SERIES layer over the observation log.
3
+ *
4
+ * `skill_catalogue` answers "what did this one observation measure". This
5
+ * module answers the three questions that need MORE than one record: is a
6
+ * host's series stale, how far is the corpus from the bar it is filling
7
+ * toward, and does the host's delivery diverge from the disk the runtime
8
+ * ranker reads.
9
+ *
10
+ * Split out of `skill_catalogue.ts` when that file crossed the 1,500-line
11
+ * source ceiling by four lines. Extraction rather than a raised baseline is
12
+ * the ratchet's own doctrine — and the seam is a real one rather than a
13
+ * convenient cut: nothing here is imported by `install.ts`, so the
14
+ * deploy-time warning path keeps exactly the surface it had.
15
+ *
16
+ * The two laws of the parent module hold here without restatement, and both
17
+ * are load-bearing in this file specifically:
18
+ *
19
+ * A ZERO IS NEVER INFERRED FROM SILENCE. A host that publishes no
20
+ * per-entry list is SKIPPED by the join, never scored 0 — its empty
21
+ * `bare_names` records that nothing was enumerated, not that nothing was
22
+ * bare. An unparseable date reads as DUE, never as fresh.
23
+ *
24
+ * NOTHING IS POOLED ACROSS HOSTS. Cadence is per host and the D-4 headline
25
+ * is per host, for the reason `formatPerHostVerdicts` states: two hosts
26
+ * truncate by different mechanisms, so one number over both describes
27
+ * neither.
28
+ *
29
+ * PRIVACY BY CONSTRUCTION is inherited: every type here holds skill names,
30
+ * integers, host labels and closed enums. None can hold free-form content.
31
+ */
32
+
33
+ import {
34
+ type HostProjectionRow,
35
+ type ObservationRecord,
36
+ type ObservationSource,
37
+ type ProjectionMode,
38
+ headlineRecordPerHost,
39
+ observationSourceOf,
40
+ truncationModeOf,
41
+ } from './skill_catalogue.js';
42
+
43
+ /* ------------------------------------------------------------------ *
44
+ * The cadence — is an observation due, and how far is the corpus?
45
+ * ------------------------------------------------------------------ */
46
+
47
+ /**
48
+ * Days after which one host's series is stale and an observation is due.
49
+ *
50
+ * A **stated default, not a measured optimum** — said plainly rather than
51
+ * implying a derivation it does not have. Nothing in the corpus measures how
52
+ * fast a host's truncation behaviour changes; what is known is that it changes
53
+ * at all (the codex drop moved 393 → 401 → 402 → 330 across four readings, the
54
+ * last of them because OUR projection scope changed, not the host).
55
+ *
56
+ * *Revisit-if:* two consecutive rounds a week apart record identical figures on
57
+ * every host (too frequent — widen it), or a host limit is observed to move
58
+ * inside one interval (too slow — narrow it). Either falsifies the number, not
59
+ * the obligation to keep the series current.
60
+ */
61
+ export const OBSERVATION_CADENCE_DAYS = 7;
62
+
63
+ /**
64
+ * The volume and host bars the corpus is filling toward.
65
+ *
66
+ * **Quoted, never invented here.** Both come from the parent blocker's own
67
+ * wording — "≥ 20 observations across ≥ 2 hosts" — recorded in
68
+ * `agents/evidence/investigations/skill-catalogue-codex-truncation.md` § 1 and
69
+ * § 5 and in the blocker that still carries it. This module reports progress
70
+ * against them; it does not get to move them.
71
+ */
72
+ export const OBSERVATION_VOLUME_BAR = 20;
73
+ export const OBSERVATION_HOST_BAR = 2;
74
+
75
+ /** Whole days from `fromISO` to `toISO`; negative when `toISO` is earlier. */
76
+ function wholeDaysBetween(fromISO: string, toISO: string): number {
77
+ const from = Date.parse(`${fromISO}T00:00:00Z`);
78
+ const to = Date.parse(`${toISO}T00:00:00Z`);
79
+ if (Number.isNaN(from) || Number.isNaN(to)) return Number.NaN;
80
+ return Math.round((to - from) / 86_400_000);
81
+ }
82
+
83
+ /** One host's standing in the series. Counts only; no free-form field. */
84
+ export interface CadenceRow {
85
+ host: string;
86
+ /** The source the host's own latest record came from. */
87
+ source: ObservationSource;
88
+ observations: number;
89
+ lastObservedAt: string;
90
+ /** Whole days since that record; `NaN` when its stamp is unparseable. */
91
+ daysSince: number;
92
+ due: boolean;
93
+ /**
94
+ * Observations of this host carrying NO `projection_mode`. Not a defect to
95
+ * backfill — those readings were taken without asking, and relabelling one
96
+ * afterwards would invent a measurement. They are simply not comparable
97
+ * across modes, so the count is published rather than hidden.
98
+ */
99
+ unscoped: number;
100
+ }
101
+
102
+ /**
103
+ * Per-host cadence, never pooled — the same reason `formatPerHostVerdicts`
104
+ * refuses to pool: two hosts truncate by different mechanisms, so "the corpus
105
+ * is fresh" is not a statement either host makes on its own.
106
+ *
107
+ * A host with no record at all cannot appear here: this reads the log, and a
108
+ * host that was never observed has nothing to be stale. The volume bar below
109
+ * is what surfaces that gap.
110
+ */
111
+ export function cadenceStatus(
112
+ records: readonly ObservationRecord[],
113
+ todayISO: string,
114
+ ): CadenceRow[] {
115
+ const byHost = new Map<string, ObservationRecord[]>();
116
+ for (const record of records) {
117
+ const bucket = byHost.get(record.host) ?? [];
118
+ bucket.push(record);
119
+ byHost.set(record.host, bucket);
120
+ }
121
+ const headline = headlineRecordPerHost(records);
122
+
123
+ const rows: CadenceRow[] = [];
124
+ for (const host of [...byHost.keys()].sort()) {
125
+ const bucket = byHost.get(host)!;
126
+ const latest = headline.get(host)!;
127
+ const daysSince = wholeDaysBetween(latest.observed_at, todayISO);
128
+ rows.push({
129
+ host,
130
+ source: observationSourceOf(latest),
131
+ observations: bucket.length,
132
+ lastObservedAt: latest.observed_at,
133
+ daysSince,
134
+ // An unparseable stamp reads as DUE, never as fresh: a broken date
135
+ // and a current one must not look alike, which is the same
136
+ // zero-from-silence law the host-event parser follows.
137
+ due: Number.isNaN(daysSince) || daysSince >= OBSERVATION_CADENCE_DAYS,
138
+ unscoped: bucket.filter((r) => r.projection_mode === undefined).length,
139
+ });
140
+ }
141
+ return rows;
142
+ }
143
+
144
+ /**
145
+ * The cadence as an operator report: who is due, and how far the corpus is from
146
+ * the bar it is filling toward.
147
+ *
148
+ * Prints the exact next command per host rather than describing it. The codex
149
+ * side is deterministic and scriptable; the claude side is self-report and only
150
+ * an agent reading its own context can produce it — the asymmetry is stated
151
+ * here because a report that hid it would read as "both are automatable".
152
+ */
153
+ export function formatCadenceStatus(
154
+ rows: readonly CadenceRow[],
155
+ todayISO: string,
156
+ ): string {
157
+ const lines: string[] = [];
158
+ lines.push(`cadence: one observation per host every ${OBSERVATION_CADENCE_DAYS} day(s) · today ${todayISO}`);
159
+ lines.push('');
160
+
161
+ if (rows.length === 0) {
162
+ lines.push('no observations recorded yet — every host is due.');
163
+ }
164
+
165
+ for (const row of rows) {
166
+ const age = Number.isNaN(row.daysSince)
167
+ ? `stamp unparseable (${row.lastObservedAt})`
168
+ : `${row.daysSince} day(s) ago`;
169
+ lines.push(
170
+ `${row.due ? '⚠️ ' : '✅'} ${row.host}: ${row.observations} observation(s) · ` +
171
+ `latest ${row.lastObservedAt} (${age}) · source ${row.source}`,
172
+ );
173
+ if (row.unscoped > 0) {
174
+ lines.push(
175
+ ` ${row.unscoped} of them carry no projection scope — not comparable across modes, and not backfillable.`,
176
+ );
177
+ }
178
+ }
179
+
180
+ const total = rows.reduce((sum, row) => sum + row.observations, 0);
181
+ lines.push('');
182
+ lines.push(
183
+ `volume: ${total}/${OBSERVATION_VOLUME_BAR} observation(s) across ${rows.length}/${OBSERVATION_HOST_BAR} host(s) — ` +
184
+ (total >= OBSERVATION_VOLUME_BAR && rows.length >= OBSERVATION_HOST_BAR
185
+ ? 'both bars met.'
186
+ : 'the bar the parent blocker states, quoted not invented.'),
187
+ );
188
+ return lines.join('\n');
189
+ }
190
+
191
+ /** Whether a round may carry a `--projection-mode`, and why not when it may not. */
192
+ export interface ScopeFlagDecision {
193
+ /** The measured mode, or `null` when none may be claimed. */
194
+ mode: ProjectionMode | null;
195
+ /** One line stating what was measured, or why no mode is claimable. */
196
+ reason: string;
197
+ }
198
+
199
+ /**
200
+ * Decide whether a capture round may name a projection mode.
201
+ *
202
+ * Exported and pure BECAUSE it is the load-bearing correction: the cadence
203
+ * output first printed `--projection-mode <scoped|legacy-all>` as a
204
+ * placeholder, and a placeholder invites the operator to pick — on a root
205
+ * matching neither count, either pick is the relabelling
206
+ * `ObservationRecord.projection_mode` forbids. A decision nothing pins is a
207
+ * decision a later edit regresses silently, so both omit-with-a-reason branches
208
+ * are fixtured.
209
+ *
210
+ * `rootExists: false` and `indeterminate` are deliberately distinct reasons.
211
+ * They produce the same output — no flag — from different facts, and collapsing
212
+ * them would report "this install's scope is unmeasurable" for a host that is
213
+ * simply not installed.
214
+ */
215
+ export function scopeFlagDecision(
216
+ root: string,
217
+ rootExists: boolean,
218
+ row: HostProjectionRow | null,
219
+ ): ScopeFlagDecision {
220
+ if (!rootExists || row === null) {
221
+ return {
222
+ mode: null,
223
+ reason: `${root} is not installed here, so the scope of the observed install cannot be measured from this machine.`,
224
+ };
225
+ }
226
+ if (row.matches === 'indeterminate') {
227
+ return {
228
+ mode: null,
229
+ reason:
230
+ `${root} holds ${row.installedSkills} skills, which matches neither projection count — ` +
231
+ 'another suite, a plugin, or a stale install all produce that. Recording a mode here would label a reading nobody took.',
232
+ };
233
+ }
234
+ return {
235
+ mode: row.matches,
236
+ reason: `scope measured off ${root} (${row.installedSkills} skills).`,
237
+ };
238
+ }
239
+
240
+ /* ------------------------------------------------------------------ *
241
+ * D-4 — host truth against disk truth: pointable but bare.
242
+ * ------------------------------------------------------------------ */
243
+
244
+ /** One per-entry observation joined against the ranker's on-disk catalogue. */
245
+ export interface PointableBareRow {
246
+ host: string;
247
+ observedAt: string;
248
+ bareTotal: number;
249
+ /** Bare entries the ranker can still name — the D-4 divergence. */
250
+ pointableBare: number;
251
+ /** Bare entries absent from the ranker's catalogue; it cannot name them. */
252
+ unpointableBare: number;
253
+ pointableNames: string[];
254
+ unpointableNames: string[];
255
+ }
256
+
257
+ /**
258
+ * Intersect the bare names of every per-entry observation with the catalogue the
259
+ * runtime ranker reads.
260
+ *
261
+ * The defect this counts (D-4): `skill-route` ranks the on-disk tree, so a skill
262
+ * the host truncated is still rankable and still pointable — and the pointer
263
+ * then names a skill whose description the model never received. That is worse
264
+ * than silence, because the pointer reads as a delivered capability.
265
+ *
266
+ * **Only `per-entry` records are joined, and skipping the others is the point.**
267
+ * A `budget-strip-and-drop` host publishes no per-entry list at all, so its
268
+ * `bare_names` is empty because nothing was enumerated — not because nothing was
269
+ * bare. Emitting a row of 0 for it would be a zero inferred from silence, which
270
+ * this module's header forbids in the one place it would be easiest to do.
271
+ *
272
+ * A row of 0 on a per-entry record IS a legitimate answer: it means every entry
273
+ * the host degraded is also absent from the ranker's tree, so the ranker cannot
274
+ * point at one.
275
+ */
276
+ /** What one join pass produced, including what it refused to read. */
277
+ export interface PointableBareJoin {
278
+ rows: PointableBareRow[];
279
+ /** Records skipped because their host publishes no per-entry list. */
280
+ skippedNonPerEntry: number;
281
+ /**
282
+ * Per-entry records skipped because `bare_names` was not an array.
283
+ *
284
+ * `readObservationLog` produces records by an unchecked `JSON.parse … as`,
285
+ * and the log is append-only with more than one producer, so a malformed
286
+ * line is a real state. Counting it separately keeps it from disappearing
287
+ * into the same silence as a legitimately empty join — the sibling reducer
288
+ * `knownHostLimits` guards its own field for the same reason.
289
+ */
290
+ skippedMalformed: number;
291
+ }
292
+
293
+ export function joinPointableBare(
294
+ records: readonly ObservationRecord[],
295
+ rankerCatalogueNames: readonly string[],
296
+ ): PointableBareJoin {
297
+ const rankable = new Set(rankerCatalogueNames);
298
+ const rows: PointableBareRow[] = [];
299
+ let skippedNonPerEntry = 0;
300
+ let skippedMalformed = 0;
301
+ for (const record of records) {
302
+ if (truncationModeOf(record) !== 'per-entry') {
303
+ skippedNonPerEntry += 1;
304
+ continue;
305
+ }
306
+ if (!Array.isArray(record.bare_names)) {
307
+ skippedMalformed += 1;
308
+ continue;
309
+ }
310
+ const pointable = record.bare_names.filter((name) => rankable.has(name));
311
+ const unpointable = record.bare_names.filter((name) => !rankable.has(name));
312
+ rows.push({
313
+ host: record.host,
314
+ observedAt: record.observed_at,
315
+ bareTotal: record.bare_names.length,
316
+ pointableBare: pointable.length,
317
+ unpointableBare: unpointable.length,
318
+ pointableNames: pointable,
319
+ unpointableNames: unpointable,
320
+ });
321
+ }
322
+ return { rows, skippedNonPerEntry, skippedMalformed };
323
+ }
324
+
325
+ /**
326
+ * The latest joined row per host, by `observedAt`.
327
+ *
328
+ * The headline is stated PER HOST and off the latest row, never as one maximum
329
+ * across the corpus. A pooled max lets a superseded observation supply the
330
+ * number while the current one reads 0, with no host or date attached to it —
331
+ * which is the exact failure `_supersedes` was introduced for (two reducers
332
+ * broke a same-date tie in opposite directions and printed two drop counts for
333
+ * one host on one day) and which `formatPerHostVerdicts` refuses on the stated
334
+ * grounds that two hosts truncate by different mechanisms.
335
+ */
336
+ export function latestPointableBarePerHost(
337
+ rows: readonly PointableBareRow[],
338
+ ): Map<string, PointableBareRow> {
339
+ const out = new Map<string, PointableBareRow>();
340
+ for (const row of rows) {
341
+ const held = out.get(row.host);
342
+ if (held === undefined || row.observedAt >= held.observedAt) out.set(row.host, row);
343
+ }
344
+ return out;
345
+ }
346
+
347
+ /** The join as an operator report. Zero is printed as a result, not as absence. */
348
+ export function formatPointableBare(
349
+ join: PointableBareJoin,
350
+ catalogueRoot: string,
351
+ catalogueSize: number,
352
+ ): string {
353
+ const { rows, skippedNonPerEntry, skippedMalformed } = join;
354
+ const lines: string[] = [];
355
+ lines.push(`ranker catalogue: ${catalogueRoot} (${catalogueSize} entries)`);
356
+ lines.push('');
357
+
358
+ if (rows.length === 0) {
359
+ lines.push(
360
+ 'no per-entry observation to join. This is NOT a count of zero: only a host that',
361
+ 'enumerates which entries arrived bare can be joined, and none has been recorded.',
362
+ );
363
+ }
364
+
365
+ for (const row of rows) {
366
+ lines.push(
367
+ `${row.host} ${row.observedAt}: ${row.pointableBare} pointable-but-bare of ${row.bareTotal} bare`,
368
+ );
369
+ if (row.pointableBare > 0) {
370
+ lines.push(` the ranker can name: ${row.pointableNames.join(', ')}`);
371
+ }
372
+ if (row.unpointableBare > 0) {
373
+ lines.push(
374
+ ` bare and NOT in the ranker's catalogue (${row.unpointableBare}): ${row.unpointableNames.join(', ')}`,
375
+ );
376
+ }
377
+ }
378
+
379
+ if (skippedNonPerEntry > 0) {
380
+ lines.push('');
381
+ lines.push(
382
+ `skipped ${skippedNonPerEntry} observation(s) whose host publishes no per-entry list —`,
383
+ 'their empty `bare_names` records that nothing was enumerated, not that nothing was bare.',
384
+ );
385
+ }
386
+ if (skippedMalformed > 0) {
387
+ lines.push('');
388
+ lines.push(
389
+ `⚠️ skipped ${skippedMalformed} per-entry record(s) carrying no \`bare_names\` array —`,
390
+ 'malformed log line(s). Reported rather than absorbed: a skipped record and a joined',
391
+ 'record that found nothing must not look alike.',
392
+ );
393
+ }
394
+
395
+ lines.push('');
396
+ if (rows.length === 0) {
397
+ lines.push('D-4 divergence: unmeasured.');
398
+ return lines.join('\n');
399
+ }
400
+ lines.push('D-4 divergence, per host, off that host\'s LATEST joined observation:');
401
+ for (const [host, row] of [...latestPointableBarePerHost(rows)].sort((a, b) =>
402
+ a[0].localeCompare(b[0]),
403
+ )) {
404
+ lines.push(
405
+ row.pointableBare > 0
406
+ ? ` ${host} (${row.observedAt}): ${row.pointableBare} skill(s) the ranker may point at while the model never received their description.`
407
+ : ` ${host} (${row.observedAt}): 0 — the ranker points at nothing this host degraded.`,
408
+ );
409
+ }
410
+ return lines.join('\n');
411
+ }