@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +273 -0
- package/README.md +1 -1
- package/agents/templates/.ai-council.yml.example +46 -3
- package/dist/agent-src/commands/cost/report.md +23 -0
- package/dist/agent-src/contexts/execution/subagent-routing.md +148 -33
- package/dist/agent-src/contexts/model-recommendations.md +6 -4
- package/dist/agent-src/contexts/subagent-configuration.md +69 -7
- package/dist/agent-src/rules/autonomous-execution.md +1 -1
- package/dist/agent-src/rules/design-fidelity.md +1 -1
- package/dist/agent-src/rules/design-review-after-ui-write.md +10 -5
- package/dist/agent-src/rules/evaluator-independence.md +27 -5
- package/dist/agent-src/rules/git-history-discipline.md +5 -1
- package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +1 -0
- package/dist/agent-src/rules/missing-skill-recovery.md +89 -0
- package/dist/agent-src/rules/ui-audit-gate.md +9 -4
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +30 -2
- package/dist/agent-src/scripts/update_roadmap_progress.ts +47 -1
- package/dist/agent-src/skills/authz-review/SKILL.md +4 -0
- package/dist/agent-src/skills/design-system-capture/SKILL.md +4 -0
- package/dist/agent-src/skills/design-system-capture/references/snapshot-preference-order.md +77 -0
- package/dist/agent-src/skills/merge-conflicts/SKILL.md +4 -0
- package/dist/agent-src/skills/prompt-optimizer/SKILL.md +17 -1
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +11 -11
- package/dist/agent-src/skills/systematic-debugging/SKILL.md +4 -0
- package/dist/agent-src/skills/threat-modeling/SKILL.md +3 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +41 -3
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-NN4KW4HZ.js → chunk-3B72LSZL.js} +123 -2338
- package/dist/cli-delegate/{chunk-55WZN5VF.js → chunk-EVTY7ISR.js} +2 -2
- package/dist/cli-delegate/chunk-N7FEST6D.js +3895 -0
- package/dist/cli-delegate/{chunk-L565YFXH.js → chunk-XURJ4AQC.js} +566 -298
- package/dist/cli-delegate/cmd_conformance.js +4 -4
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +44 -23
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +6 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +5 -4
- package/dist/hooks/dispatch.js +10625 -8541
- package/dist/install/install.mjs +754 -484
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1302 -167
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/model_tier.js +9 -0
- package/dist/scripts/_lib/model_tier.js.map +1 -1
- package/dist/scripts/condense.js +50 -0
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/schemas/settings.js +2 -2
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/MIGRATION.md +20 -1
- package/docs/guidelines/design-fidelity-mechanics.md +21 -0
- package/docs/guidelines/design-handover-extraction.md +87 -0
- package/docs/guidelines/prompt-templates.md +24 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
- package/src/agent-src/scripts/roadmap_gates.ts +30 -2
- package/src/agent-src/scripts/update_roadmap_progress.ts +47 -1
- package/src/config/evaluator-budgets.json +9 -8
- package/src/config/gate-coverage.yml +63 -11
- package/src/config/gate-violation-baselines.json +12 -0
- package/src/config/hook-token-budget.json +15 -2
- package/src/config/preamble-payload-budget.json +28 -0
- package/src/scripts/_dispatch.bash +10 -0
- package/src/scripts/_lib/bench_ab_complexity.ts +439 -0
- package/src/scripts/_lib/bench_ab_safety_tier.ts +142 -0
- package/src/scripts/_lib/bench_ab_scoring_v2.ts +28 -3
- package/src/scripts/_lib/bench_ab_search_adherence.ts +423 -0
- package/src/scripts/_lib/bench_ab_size_claim.ts +206 -0
- package/src/scripts/_lib/bench_ab_workspace.ts +111 -0
- package/src/scripts/_lib/git_common_dir.ts +173 -0
- package/src/scripts/_lib/model_tier.ts +9 -0
- package/src/scripts/_lib/release_highlights.ts +108 -7
- package/src/scripts/_lib/skill_catalogue.ts +1115 -0
- package/src/scripts/_lib/tier_budget_routing.ts +31 -331
- package/src/scripts/ai_council/clients.ts +197 -18
- package/src/scripts/ai_council/config.ts +16 -1
- package/src/scripts/ai_council/low_impact.ts +14 -1
- package/src/scripts/ai_council/orchestrator.ts +14 -1
- package/src/scripts/ai_council/transport_resolver.ts +32 -2
- package/src/scripts/bench_ab_v2_complexity.ts +252 -0
- package/src/scripts/bench_ab_v2_run.ts +3 -42
- package/src/scripts/bench_ab_v2_safety.ts +296 -0
- package/src/scripts/bench_ab_v2_search.ts +343 -0
- package/src/scripts/bench_ab_v2_stats.ts +191 -0
- package/src/scripts/build_archive_index.ts +383 -0
- package/src/scripts/capture_skill_catalogue.ts +279 -317
- package/src/scripts/check_branch_freshness.ts +7 -1
- package/src/scripts/check_release_published.ts +6 -2
- package/src/scripts/check_release_trunk_sync.ts +18 -2
- package/src/scripts/check_source_size_budget.ts +428 -0
- package/src/scripts/check_suppression_hygiene.ts +5 -1
- package/src/scripts/condense.ts +50 -1
- package/src/scripts/cost/backfill_rates.mjs +298 -0
- package/src/scripts/cost/budget.mjs +12 -32
- package/src/scripts/cost_summary.ts +25 -0
- package/src/scripts/dispatch_r2_reviewer.ts +15 -5
- package/src/scripts/evidence_report.ts +15 -4
- package/src/scripts/external_sources_denylist.json +1 -0
- package/src/scripts/hook_manifest.yaml +75 -15
- package/src/scripts/hooks/concern_registry.ts +4 -0
- package/src/scripts/hooks/orchestration_record_hook.ts +21 -0
- package/src/scripts/hooks/skill_route_hook.ts +226 -0
- package/src/scripts/hooks/tool_result_bytes_hook.ts +219 -0
- package/src/scripts/hooks/ui_route_nudge_hook.ts +6 -3
- package/src/scripts/hot_context_hook.ts +13 -1
- package/src/scripts/install.ts +129 -0
- package/src/scripts/janitor.ts +77 -2
- package/src/scripts/lint_design_antipattern_parity.ts +40 -6
- package/src/scripts/lint_harvest_provenance.ts +35 -1
- package/src/scripts/lint_plan_risk_register.ts +22 -9
- package/src/scripts/lint_roadmap_blockers.ts +109 -6
- package/src/scripts/lint_rule_skill_pack_reach.ts +43 -0
- package/src/scripts/lint_scheduled_deprecations.ts +738 -0
- package/src/scripts/lint_skill_router_head.ts +21 -0
- package/src/scripts/mcp_server/consumer_tool_catalog.json +26 -3
- package/src/scripts/mcp_server/tool_catalog_source.ts +6 -24
- package/src/scripts/mcp_server/tools.ts +87 -0
- package/src/scripts/migration_status.ts +17 -1
- package/src/scripts/probe_review_binding_drift.ts +731 -0
- package/src/scripts/release.ts +100 -6
- package/src/scripts/routing_doctor.ts +50 -13
- package/src/scripts/schemas/command.schema.json +3 -3
- package/src/scripts/schemas/skill.schema.json +20 -2
- package/src/scripts/schemas/subagent.schema.json +2 -2
- package/src/scripts/session_eol_report.ts +81 -0
- package/src/scripts/trigger_coverage.ts +224 -3
- package/src/scripts/workspace_doctor.ts +464 -0
- package/src/scripts/worktree_cleanup_check.ts +2 -1
- package/src/server/schemas/settings.ts +2 -2
- package/dist/cli-delegate/chunk-AYKQGIBG.js +0 -1594
- 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
|
+
}
|