@event4u/agent-config 12.0.0 → 13.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +273 -0
  3. package/README.md +1 -1
  4. package/agents/templates/.ai-council.yml.example +46 -3
  5. package/dist/agent-src/commands/cost/report.md +23 -0
  6. package/dist/agent-src/contexts/execution/subagent-routing.md +148 -33
  7. package/dist/agent-src/contexts/model-recommendations.md +6 -4
  8. package/dist/agent-src/contexts/subagent-configuration.md +69 -7
  9. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  10. package/dist/agent-src/rules/design-fidelity.md +1 -1
  11. package/dist/agent-src/rules/design-review-after-ui-write.md +10 -5
  12. package/dist/agent-src/rules/evaluator-independence.md +27 -5
  13. package/dist/agent-src/rules/git-history-discipline.md +5 -1
  14. package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +1 -0
  15. package/dist/agent-src/rules/missing-skill-recovery.md +89 -0
  16. package/dist/agent-src/rules/ui-audit-gate.md +9 -4
  17. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
  18. package/dist/agent-src/scripts/roadmap_gates.ts +30 -2
  19. package/dist/agent-src/scripts/update_roadmap_progress.ts +47 -1
  20. package/dist/agent-src/skills/authz-review/SKILL.md +4 -0
  21. package/dist/agent-src/skills/design-system-capture/SKILL.md +4 -0
  22. package/dist/agent-src/skills/design-system-capture/references/snapshot-preference-order.md +77 -0
  23. package/dist/agent-src/skills/merge-conflicts/SKILL.md +4 -0
  24. package/dist/agent-src/skills/prompt-optimizer/SKILL.md +17 -1
  25. package/dist/agent-src/skills/roadmap-writing/SKILL.md +11 -11
  26. package/dist/agent-src/skills/systematic-debugging/SKILL.md +4 -0
  27. package/dist/agent-src/skills/threat-modeling/SKILL.md +3 -0
  28. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  29. package/dist/agent-src/templates/roadmaps.md +41 -3
  30. package/dist/cli/registry.js +1 -0
  31. package/dist/cli/registry.js.map +1 -1
  32. package/dist/cli-delegate/{chunk-NN4KW4HZ.js → chunk-3B72LSZL.js} +123 -2338
  33. package/dist/cli-delegate/{chunk-55WZN5VF.js → chunk-EVTY7ISR.js} +2 -2
  34. package/dist/cli-delegate/chunk-N7FEST6D.js +3895 -0
  35. package/dist/cli-delegate/{chunk-L565YFXH.js → chunk-XURJ4AQC.js} +566 -298
  36. package/dist/cli-delegate/cmd_conformance.js +4 -4
  37. package/dist/cli-delegate/cmd_doctor.js +2 -2
  38. package/dist/cli-delegate/cmd_export.js +2 -2
  39. package/dist/cli-delegate/cmd_handoff.js +1 -1
  40. package/dist/cli-delegate/cmd_prune.js +2 -2
  41. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  42. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  43. package/dist/cli-delegate/cmd_sync.js +2 -2
  44. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  45. package/dist/cli-delegate/cmd_validate.js +2 -2
  46. package/dist/discovery/deprecation-report.md +1 -1
  47. package/dist/discovery/discovery-manifest.json +44 -23
  48. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  49. package/dist/discovery/discovery-manifest.summary.md +3 -3
  50. package/dist/discovery/dormancy-report.md +1 -1
  51. package/dist/discovery/orphan-report.md +1 -1
  52. package/dist/discovery/packs.json +6 -5
  53. package/dist/discovery/trust-report.md +2 -2
  54. package/dist/discovery/workspaces.json +5 -4
  55. package/dist/hooks/dispatch.js +10625 -8541
  56. package/dist/install/install.mjs +754 -484
  57. package/dist/mcp/registry-manifest.json +3 -3
  58. package/dist/mcp/server.json +2 -2
  59. package/dist/mcp/server.mjs +1302 -167
  60. package/dist/router.json +1 -1
  61. package/dist/scripts/_lib/model_tier.js +9 -0
  62. package/dist/scripts/_lib/model_tier.js.map +1 -1
  63. package/dist/scripts/condense.js +50 -0
  64. package/dist/scripts/condense.js.map +1 -1
  65. package/dist/server/schemas/settings.js +2 -2
  66. package/dist/server/schemas/settings.js.map +1 -1
  67. package/docs/MIGRATION.md +20 -1
  68. package/docs/guidelines/design-fidelity-mechanics.md +21 -0
  69. package/docs/guidelines/design-handover-extraction.md +87 -0
  70. package/docs/guidelines/prompt-templates.md +24 -1
  71. package/package.json +1 -1
  72. package/src/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
  73. package/src/agent-src/scripts/roadmap_gates.ts +30 -2
  74. package/src/agent-src/scripts/update_roadmap_progress.ts +47 -1
  75. package/src/config/evaluator-budgets.json +9 -8
  76. package/src/config/gate-coverage.yml +63 -11
  77. package/src/config/gate-violation-baselines.json +12 -0
  78. package/src/config/hook-token-budget.json +15 -2
  79. package/src/config/preamble-payload-budget.json +28 -0
  80. package/src/scripts/_dispatch.bash +10 -0
  81. package/src/scripts/_lib/bench_ab_complexity.ts +439 -0
  82. package/src/scripts/_lib/bench_ab_safety_tier.ts +142 -0
  83. package/src/scripts/_lib/bench_ab_scoring_v2.ts +28 -3
  84. package/src/scripts/_lib/bench_ab_search_adherence.ts +423 -0
  85. package/src/scripts/_lib/bench_ab_size_claim.ts +206 -0
  86. package/src/scripts/_lib/bench_ab_workspace.ts +111 -0
  87. package/src/scripts/_lib/git_common_dir.ts +173 -0
  88. package/src/scripts/_lib/model_tier.ts +9 -0
  89. package/src/scripts/_lib/release_highlights.ts +108 -7
  90. package/src/scripts/_lib/skill_catalogue.ts +1115 -0
  91. package/src/scripts/_lib/tier_budget_routing.ts +31 -331
  92. package/src/scripts/ai_council/clients.ts +197 -18
  93. package/src/scripts/ai_council/config.ts +16 -1
  94. package/src/scripts/ai_council/low_impact.ts +14 -1
  95. package/src/scripts/ai_council/orchestrator.ts +14 -1
  96. package/src/scripts/ai_council/transport_resolver.ts +32 -2
  97. package/src/scripts/bench_ab_v2_complexity.ts +252 -0
  98. package/src/scripts/bench_ab_v2_run.ts +3 -42
  99. package/src/scripts/bench_ab_v2_safety.ts +296 -0
  100. package/src/scripts/bench_ab_v2_search.ts +343 -0
  101. package/src/scripts/bench_ab_v2_stats.ts +191 -0
  102. package/src/scripts/build_archive_index.ts +383 -0
  103. package/src/scripts/capture_skill_catalogue.ts +279 -317
  104. package/src/scripts/check_branch_freshness.ts +7 -1
  105. package/src/scripts/check_release_published.ts +6 -2
  106. package/src/scripts/check_release_trunk_sync.ts +18 -2
  107. package/src/scripts/check_source_size_budget.ts +428 -0
  108. package/src/scripts/check_suppression_hygiene.ts +5 -1
  109. package/src/scripts/condense.ts +50 -1
  110. package/src/scripts/cost/backfill_rates.mjs +298 -0
  111. package/src/scripts/cost/budget.mjs +12 -32
  112. package/src/scripts/cost_summary.ts +25 -0
  113. package/src/scripts/dispatch_r2_reviewer.ts +15 -5
  114. package/src/scripts/evidence_report.ts +15 -4
  115. package/src/scripts/external_sources_denylist.json +1 -0
  116. package/src/scripts/hook_manifest.yaml +75 -15
  117. package/src/scripts/hooks/concern_registry.ts +4 -0
  118. package/src/scripts/hooks/orchestration_record_hook.ts +21 -0
  119. package/src/scripts/hooks/skill_route_hook.ts +226 -0
  120. package/src/scripts/hooks/tool_result_bytes_hook.ts +219 -0
  121. package/src/scripts/hooks/ui_route_nudge_hook.ts +6 -3
  122. package/src/scripts/hot_context_hook.ts +13 -1
  123. package/src/scripts/install.ts +129 -0
  124. package/src/scripts/janitor.ts +77 -2
  125. package/src/scripts/lint_design_antipattern_parity.ts +40 -6
  126. package/src/scripts/lint_harvest_provenance.ts +35 -1
  127. package/src/scripts/lint_plan_risk_register.ts +22 -9
  128. package/src/scripts/lint_roadmap_blockers.ts +109 -6
  129. package/src/scripts/lint_rule_skill_pack_reach.ts +43 -0
  130. package/src/scripts/lint_scheduled_deprecations.ts +738 -0
  131. package/src/scripts/lint_skill_router_head.ts +21 -0
  132. package/src/scripts/mcp_server/consumer_tool_catalog.json +26 -3
  133. package/src/scripts/mcp_server/tool_catalog_source.ts +6 -24
  134. package/src/scripts/mcp_server/tools.ts +87 -0
  135. package/src/scripts/migration_status.ts +17 -1
  136. package/src/scripts/probe_review_binding_drift.ts +731 -0
  137. package/src/scripts/release.ts +100 -6
  138. package/src/scripts/routing_doctor.ts +50 -13
  139. package/src/scripts/schemas/command.schema.json +3 -3
  140. package/src/scripts/schemas/skill.schema.json +20 -2
  141. package/src/scripts/schemas/subagent.schema.json +2 -2
  142. package/src/scripts/session_eol_report.ts +81 -0
  143. package/src/scripts/trigger_coverage.ts +224 -3
  144. package/src/scripts/workspace_doctor.ts +464 -0
  145. package/src/scripts/worktree_cleanup_check.ts +2 -1
  146. package/src/server/schemas/settings.ts +2 -2
  147. package/dist/cli-delegate/chunk-AYKQGIBG.js +0 -1594
  148. package/src/config/budget-routing.json +0 -7
@@ -19,10 +19,24 @@
19
19
  * frontmatter keys it carries, and its position in the sorted catalogue. This
20
20
  * is the host's INPUT and it is fully file-measurable.
21
21
  *
22
- * OBSERVATION (self-reported) — which entries actually arrived as bare names
23
- * in a live session. Only the agent reading its own context can see this, so
24
- * it is supplied as a file and recorded AS a self-report. It is not
25
- * enforcement and nothing here pretends otherwise.
22
+ * OBSERVATION — which entries actually arrived, and how many did not. This
23
+ * side has TWO sources and every record says which one it came from:
24
+ *
25
+ * `self-report` — an agent reading its own context lists the bare names.
26
+ * Only the agent can see this. It is not enforcement and nothing here
27
+ * pretends otherwise. This is the claude-shaped path.
28
+ *
29
+ * `host-event` — the host publishes its own truncation on a machine
30
+ * channel. `codex exec --json` emits an `item.completed` error event whose
31
+ * message states how many entries it dropped, so the count is READ, never
32
+ * transcribed. This is the codex-shaped path and it is deterministic.
33
+ *
34
+ * Hosts do not truncate the same way, so a record also carries a
35
+ * `truncation_mode`: `per-entry` (some entries arrive bare, others full —
36
+ * claude) versus `budget-strip-and-drop` (every description stripped, then N
37
+ * entries dropped wholesale — codex). Pooling the two into one verdict would
38
+ * average two different mechanisms into a number describing neither, so the
39
+ * report is always per host.
26
40
  *
27
41
  * The join of the two answers the question the census could not: not "how many
28
42
  * are bare" but WHICH PROPERTY separates bare from described. That is the
@@ -33,370 +47,318 @@
33
47
  * while an earlier one was bare, which no head-N budget explains.
34
48
  *
35
49
  * PRIVACY BY CONSTRUCTION. An observation record carries skill names, integer
36
- * counts and a host label. The record type has no field able to hold prompt
37
- * text, file bodies, paths outside the catalogue, or user content — the same
38
- * shape `domain-safety-pii` § Surface 2 requires of a log event. Do not widen
39
- * it with a free-form field.
50
+ * counts, a host label and two closed enums. The record type has no field able
51
+ * to hold prompt text, file bodies, paths outside the catalogue, or user
52
+ * content — the same shape `domain-safety-pii` § Surface 2 requires of a log
53
+ * event. The codex path reads a host message and keeps only the integer in it;
54
+ * the message text itself never reaches a record. Do not widen it with a
55
+ * free-form field.
56
+ *
57
+ * A ZERO IS NEVER INFERRED FROM SILENCE. If the host's budget event is absent
58
+ * or unparseable — reworded upstream, or removed — the capture fails loudly
59
+ * rather than recording `dropped: 0`, because a broken parser and a fixed
60
+ * defect would otherwise look identical. Recording a genuine no-truncation run
61
+ * takes an explicit `--assert-no-truncation`.
40
62
  *
41
- * Exit codes: 0 report produced · 1 usage/IO error or an empty catalogue root
42
- * (a catalogue scan that found nothing must never read as a clean result).
63
+ * Exit codes: 0 report produced · 1 usage/IO error, an empty catalogue root
64
+ * (a catalogue scan that found nothing must never read as a clean result), or
65
+ * an unreadable host observation.
43
66
  */
44
67
 
45
68
  import * as fs from 'node:fs';
69
+ import * as os from 'node:os';
46
70
  import * as path from 'node:path';
47
71
  import { fileURLToPath } from 'node:url';
48
72
 
73
+ import {
74
+ DEFAULT_CATALOGUE_ROOTS,
75
+ OBSERVATION_LOG,
76
+ analyzeSelector,
77
+ buildHostEventRecord,
78
+ buildNoTruncationRecord,
79
+ buildObservationRecord,
80
+ classifyHostProjection,
81
+ formatPerHostVerdicts,
82
+ formatProjectionModes,
83
+ formatReport,
84
+ knownHostLimits,
85
+ measureCatalogueVolume,
86
+ parseHostBudgetEvent,
87
+ readObservationLog,
88
+ readProjectedCatalogue,
89
+ type HostProjectionRow,
90
+ type ProjectionMode,
91
+ type ProjectionModeCounts,
92
+ type SelectorReport,
93
+ } from './_lib/skill_catalogue.js';
94
+ import { scoped_projection_stats } from './_lib/scoped_projection.js';
95
+ import { iter_skills } from './update_counts.js';
96
+
97
+ // Re-exported so the CLI module stays the one public name for this tool.
98
+ export * from './_lib/skill_catalogue.js';
99
+
49
100
  const _HERE = fileURLToPath(import.meta.url);
50
101
  const REPO = path.resolve(path.dirname(_HERE), '..', '..');
51
102
 
52
- /** Catalogue roots tried in order; the first that exists wins. */
53
- export const DEFAULT_CATALOGUE_ROOTS = ['.claude/skills', 'src/skills'] as const;
54
-
55
- /** Where observations accumulate. Append-only, one JSON object per line. */
56
- export const OBSERVATION_LOG = path.join(
57
- 'agents',
58
- 'evidence',
59
- 'metrics',
60
- 'skill-catalogue.jsonl',
61
- );
62
-
63
- export interface CatalogueEntry {
64
- /** Skill directory name — the identifier the host lists. */
65
- name: string;
66
- /** 1-based position in the alphabetically sorted catalogue. */
67
- position: number;
68
- /** Whether the projected SKILL.md declares a `description:`. */
69
- hasDescription: boolean;
70
- /** Character length of the declared description; 0 when absent. */
71
- descriptionLength: number;
72
- /** Top-level frontmatter keys the entry declares. */
73
- frontmatterKeys: string[];
74
- }
75
-
76
- export interface SelectorCandidate {
77
- /** Stable identifier for the property under test. */
78
- id: string;
79
- /** Human-readable statement of what the property is. */
80
- describes: string;
81
- /** True when the property's values are disjoint across the two groups. */
82
- separates: boolean;
83
- /** Why it does or does not separate, in one line. */
84
- detail: string;
85
- }
86
-
87
- export interface SelectorReport {
88
- catalogueRoot: string;
89
- entriesTotal: number;
90
- observedBare: string[];
91
- observedDescribed: string[];
92
- /** Observed names that the projection does not know. */
93
- unknownObserved: string[];
94
- candidates: SelectorCandidate[];
95
- verdict: 'selector-found' | 'no-selector' | 'insufficient-observation';
103
+ function resolveCatalogueRoot(explicit: string | null): string {
104
+ if (explicit) {
105
+ if (!fs.existsSync(explicit)) {
106
+ throw new Error(`catalogue root does not exist: ${explicit}`);
107
+ }
108
+ return explicit;
109
+ }
110
+ for (const candidate of DEFAULT_CATALOGUE_ROOTS) {
111
+ const abs = path.join(REPO, candidate);
112
+ if (fs.existsSync(abs)) return abs;
113
+ }
114
+ throw new Error(
115
+ `no catalogue root found — tried ${DEFAULT_CATALOGUE_ROOTS.join(', ')} under ${REPO}`,
116
+ );
96
117
  }
97
118
 
98
- /** Frontmatter block between the leading `---` fences, or "" when absent. */
99
- function frontmatterOf(content: string): string {
100
- if (!content.startsWith('---')) return '';
101
- const end = content.indexOf('\n---', 3);
102
- if (end === -1) return '';
103
- return content.slice(3, end);
119
+ function argValue(flag: string): string | null {
120
+ const index = process.argv.indexOf(flag);
121
+ if (index === -1 || index + 1 >= process.argv.length) return null;
122
+ return process.argv[index + 1]!;
104
123
  }
105
124
 
106
- /**
107
- * Top-level keys of a frontmatter block. Deliberately shallow: a nested key is
108
- * not a property the host's catalogue selector could plausibly read, and a full
109
- * YAML parse would drag a dependency into a measurement tool.
110
- */
111
- function topLevelKeys(frontmatter: string): string[] {
112
- const keys: string[] = [];
113
- for (const line of frontmatter.split('\n')) {
114
- const match = /^([A-Za-z_][A-Za-z0-9_-]*):/.exec(line);
115
- if (match) keys.push(match[1]!);
125
+ /** Every occurrence of a repeatable flag, in argv order. */
126
+ function argValues(flag: string): string[] {
127
+ const out: string[] = [];
128
+ for (let i = 0; i < process.argv.length - 1; i += 1) {
129
+ if (process.argv[i] === flag) out.push(process.argv[i + 1]!);
116
130
  }
117
- return keys;
131
+ return out;
118
132
  }
119
133
 
120
134
  /**
121
- * The declared description with surrounding quotes stripped, or "".
135
+ * `--projection-mode <scoped|legacy-all>`, validated against the closed enum.
122
136
  *
123
- * A block scalar (`description: >-` / `|`) puts the text on the FOLLOWING
124
- * lines, so taking the remainder of the header line would yield the
125
- * two-character indicator and report `descriptionLength: 2`. That would feed
126
- * the length-based selector candidate noise, and a length threshold could be
127
- * reported or refuted on it. Block scalars are therefore folded properly.
137
+ * Returns `undefined` when the flag is absent — which the record schema reads
138
+ * as "not captured", never as `legacy-all`. An unrecognised value throws
139
+ * rather than falling back: a typo silently recorded as the wrong mode is the
140
+ * failure this whole module refuses elsewhere.
128
141
  */
129
- function descriptionOf(frontmatter: string): string {
130
- const lines = frontmatter.split('\n');
131
- for (let i = 0; i < lines.length; i += 1) {
132
- const match = /^description:\s*(.*)$/.exec(lines[i]!);
133
- if (!match) continue;
134
- const rest = match[1]!.trim();
135
-
136
- if (/^[|>][-+]?\d*$/.test(rest)) {
137
- const body: string[] = [];
138
- for (let j = i + 1; j < lines.length; j += 1) {
139
- const line = lines[j]!;
140
- if (line.trim() === '') {
141
- body.push('');
142
- continue;
143
- }
144
- if (!/^\s/.test(line)) break; // dedented, so the block ended
145
- body.push(line.trim());
146
- }
147
- return body.join(' ').trim();
148
- }
142
+ function argProjectionMode(): ProjectionMode | undefined {
143
+ const raw = argValue('--projection-mode');
144
+ if (raw === null) return undefined;
145
+ if (raw === 'scoped' || raw === 'legacy-all') return raw;
146
+ throw new Error(`--projection-mode must be scoped or legacy-all, got: ${raw}`);
147
+ }
149
148
 
150
- return rest.replace(/^["']|["']$/g, '');
151
- }
152
- return '';
149
+ /** `~` expanded against the current user's home. */
150
+ function expandHome(p: string): string {
151
+ return p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : p;
153
152
  }
154
153
 
155
154
  /**
156
- * Read every `<root>/<name>/SKILL.md` into a catalogue entry, sorted by name —
157
- * the order a host listing follows and therefore the order a positional
158
- * hypothesis is measured against.
155
+ * `--limits` — publish the per-host picture: what each host was measured to
156
+ * deliver, and whether the observed hosts truncate the same way.
159
157
  */
160
- export function readProjectedCatalogue(root: string): CatalogueEntry[] {
161
- // Membership is decided by "does <name>/SKILL.md resolve", never by
162
- // `Dirent.isDirectory()`. The host-facing projection is a tree of SYMLINKS
163
- // into `dist/agent-src/skills/`, and a Dirent for a symlink reports
164
- // `isDirectory() === false` — that filter silently read 47 of 289 entries
165
- // and reported the result as a complete catalogue. `existsSync` follows
166
- // the link, so the scan sees what the host sees.
167
- const names = fs
168
- .readdirSync(root)
169
- .filter((n) => fs.existsSync(path.join(root, n, 'SKILL.md')))
170
- .sort();
171
-
172
- return names.map((name, index) => {
173
- const content = fs.readFileSync(path.join(root, name, 'SKILL.md'), 'utf-8');
174
- const frontmatter = frontmatterOf(content);
175
- const description = descriptionOf(frontmatter);
176
- return {
177
- name,
178
- position: index + 1,
179
- hasDescription: description.length > 0,
180
- descriptionLength: description.length,
181
- frontmatterKeys: topLevelKeys(frontmatter),
182
- };
183
- });
184
- }
158
+ function runLimitsMode(): number {
159
+ const records = readObservationLog(path.join(REPO, OBSERVATION_LOG));
160
+ const limits = knownHostLimits(records);
185
161
 
186
- /** True when two number sets occupy disjoint ranges in either direction. */
187
- function rangesAreDisjoint(a: number[], b: number[]): boolean {
188
- if (a.length === 0 || b.length === 0) return false;
189
- const maxA = Math.max(...a);
190
- const minA = Math.min(...a);
191
- const maxB = Math.max(...b);
192
- const minB = Math.min(...b);
193
- return maxA < minB || maxB < minA;
162
+ if (process.argv.includes('--json')) {
163
+ process.stdout.write(
164
+ `${JSON.stringify({ hosts: [...limits.values()], observations: records.length }, null, 2)}\n`,
165
+ );
166
+ return 0;
167
+ }
168
+
169
+ process.stdout.write(`${formatPerHostVerdicts(records)}\n\n`);
170
+ if (limits.size === 0) {
171
+ process.stdout.write(
172
+ 'measured truncations: none.\nOnly a host that publishes its own dropped count yields one; a\nself-reported observation states which entries arrived bare, which is a\nselector question, not a ceiling.\n',
173
+ );
174
+ return 0;
175
+ }
176
+ process.stdout.write('measured truncations:\n');
177
+ for (const limit of [...limits.values()].sort((a, b) => a.host.localeCompare(b.host))) {
178
+ process.stdout.write(
179
+ ` ${limit.host}: dropped ${limit.droppedEntries} entries on ${limit.observedAt}, ` +
180
+ `at a projection of ${limit.projectedVolume}\n`,
181
+ );
182
+ }
183
+ return 0;
194
184
  }
195
185
 
196
186
  /**
197
- * Join the projection against an observation and report which property, if
198
- * any, separates the bare entries from the described ones.
187
+ * `--projection-modes` — the two modes side by side, as a MEASUREMENT.
199
188
  *
200
- * `observedBare` is the list of entries a live session saw without a
201
- * description. Everything else in the projection is treated as observed
202
- * described, which is the honest reading of a whole-catalogue observation —
203
- * a partial observation would need its own described list, so the caller
204
- * passes one when it has it.
189
+ * It reads no setting, writes no setting, and recommends no default. The
190
+ * scoped-projection default and its migration notice are owned by a different
191
+ * roadmap and were decided on 2026-08-15; this mode exists so the size of the
192
+ * difference is a published number instead of an intuition, and it deliberately
193
+ * offers no flag that would change anything.
194
+ *
195
+ * `--host-root` is repeatable, so several installed hosts are read against the
196
+ * same pair of numbers in one run. Each host is reported on its own row: no
197
+ * host's count is ever used to say anything about another's.
205
198
  */
206
- export function analyzeSelector(
207
- projected: CatalogueEntry[],
208
- observedBare: readonly string[],
209
- observedDescribed?: readonly string[],
210
- ): Omit<SelectorReport, 'catalogueRoot'> {
211
- const known = new Map(projected.map((e) => [e.name, e]));
212
- const bare = observedBare.filter((n) => known.has(n));
213
- const describedInput =
214
- observedDescribed ?? projected.map((e) => e.name).filter((n) => !bare.includes(n));
215
- const described = describedInput.filter((n) => known.has(n));
216
-
217
- // Both self-reported lists are validated, not only the bare one: a typo in
218
- // --described silently shrank the described set and weakened the analysis,
219
- // while the same typo in --observed was surfaced.
220
- const unknownObserved = [
221
- ...new Set([
222
- ...observedBare.filter((n) => !known.has(n)),
223
- ...(observedDescribed ?? []).filter((n) => !known.has(n)),
224
- ]),
225
- ].sort();
226
-
227
- const base = {
228
- entriesTotal: projected.length,
229
- observedBare: [...bare].sort(),
230
- observedDescribed: [...described].sort(),
231
- unknownObserved,
199
+ function runProjectionModesMode(): number {
200
+ const stats = scoped_projection_stats(REPO, iter_skills());
201
+ const counts: ProjectionModeCounts = {
202
+ scoped: stats.projected,
203
+ legacyAll: stats.total,
204
+ prunedUnderScoped: stats.pruned,
205
+ activePacks: stats.active_packs,
232
206
  };
233
207
 
234
- if (bare.length === 0 || described.length === 0) {
235
- return {
236
- ...base,
237
- candidates: [],
238
- verdict: 'insufficient-observation' as const,
239
- };
208
+ const rows: HostProjectionRow[] = [];
209
+ for (const rootArg of argValues('--host-root')) {
210
+ const root = expandHome(rootArg);
211
+ if (!fs.existsSync(root)) {
212
+ process.stderr.write(`❌ host root does not exist: ${root}\n`);
213
+ return 1;
214
+ }
215
+ const host = path.basename(root).replace(/^\./, '');
216
+ rows.push(classifyHostProjection(measureCatalogueVolume(host, root), counts));
240
217
  }
241
218
 
242
- const bareEntries = bare.map((n) => known.get(n)!);
243
- const describedEntries = described.map((n) => known.get(n)!);
244
- const candidates: SelectorCandidate[] = [];
245
-
246
- // 1. The projection itself — does the entry even declare a description?
247
- const bareWithDesc = bareEntries.filter((e) => e.hasDescription).length;
248
- const describedWithoutDesc = describedEntries.filter((e) => !e.hasDescription).length;
249
- candidates.push({
250
- id: 'declares-description',
251
- describes: 'the projected SKILL.md declares a `description:`',
252
- separates: bareWithDesc === 0 && describedWithoutDesc === 0,
253
- detail: `${bareWithDesc}/${bareEntries.length} bare entries declare one; ${describedWithoutDesc}/${describedEntries.length} described entries do not`,
254
- });
255
-
256
- // 2. The head-N hypothesis the census proposed — falsifiable here.
257
- const barePositions = bareEntries.map((e) => e.position);
258
- const describedPositions = describedEntries.map((e) => e.position);
259
- const positional = Math.max(...describedPositions) < Math.min(...barePositions);
260
- candidates.push({
261
- id: 'positional-head',
262
- describes: 'every described entry sorts before every bare entry (a head-N budget)',
263
- separates: positional,
264
- detail: positional
265
- ? `described top out at #${Math.max(...describedPositions)}, bare start at #${Math.min(...barePositions)}`
266
- : `described reach #${Math.max(...describedPositions)} while bare start at #${Math.min(...barePositions)} — the ranges overlap, so no head-N cut explains this`,
267
- });
268
-
269
- // 3. Description length as a budget proxy.
270
- const lengthSeparates = rangesAreDisjoint(
271
- bareEntries.map((e) => e.descriptionLength),
272
- describedEntries.map((e) => e.descriptionLength),
273
- );
274
- candidates.push({
275
- id: 'description-length',
276
- describes: 'description length ranges do not overlap between the groups',
277
- separates: lengthSeparates,
278
- detail: lengthSeparates
279
- ? 'a length threshold separates the two groups'
280
- : 'length ranges overlap',
281
- });
282
-
283
- // 4. Any frontmatter key whose presence tracks the split exactly.
284
- const allKeys = new Set<string>();
285
- for (const entry of projected) for (const key of entry.frontmatterKeys) allKeys.add(key);
286
- for (const key of [...allKeys].sort()) {
287
- const bareHas = bareEntries.filter((e) => e.frontmatterKeys.includes(key)).length;
288
- const describedHas = describedEntries.filter((e) =>
289
- e.frontmatterKeys.includes(key),
290
- ).length;
291
- const separates =
292
- (bareHas === 0 && describedHas === describedEntries.length) ||
293
- (describedHas === 0 && bareHas === bareEntries.length);
294
- if (!separates) continue;
295
- candidates.push({
296
- id: `frontmatter:${key}`,
297
- describes: `presence of the \`${key}:\` frontmatter key`,
298
- separates: true,
299
- detail: `bare ${bareHas}/${bareEntries.length}, described ${describedHas}/${describedEntries.length}`,
300
- });
219
+ if (process.argv.includes('--json')) {
220
+ process.stdout.write(`${JSON.stringify({ counts, hosts: rows }, null, 2)}\n`);
221
+ return 0;
301
222
  }
302
-
303
- return {
304
- ...base,
305
- candidates,
306
- verdict: candidates.some((c) => c.separates)
307
- ? ('selector-found' as const)
308
- : ('no-selector' as const),
309
- };
310
- }
311
-
312
- /** One append-only observation record. No field can hold free-form content. */
313
- export interface ObservationRecord {
314
- schema: 1;
315
- observed_at: string;
316
- host: string;
317
- entries_total: number;
318
- bare_count: number;
319
- described_count: number;
320
- bare_names: string[];
321
- verdict: SelectorReport['verdict'];
322
- separating_candidates: string[];
223
+ process.stdout.write(`${formatProjectionModes(counts, rows)}\n`);
224
+ return 0;
323
225
  }
324
226
 
325
- export function buildObservationRecord(
326
- report: SelectorReport,
327
- host: string,
328
- observedAt: string,
329
- ): ObservationRecord {
330
- return {
331
- schema: 1,
332
- observed_at: observedAt,
333
- host,
334
- entries_total: report.entriesTotal,
335
- bare_count: report.observedBare.length,
336
- described_count: report.observedDescribed.length,
337
- bare_names: report.observedBare,
338
- verdict: report.verdict,
339
- separating_candidates: report.candidates.filter((c) => c.separates).map((c) => c.id),
340
- };
227
+ /** `--volume <host-root>` — the projection half for one host, byte-accurate. */
228
+ function runVolumeMode(rootArg: string): number {
229
+ const root = expandHome(rootArg);
230
+ if (!fs.existsSync(root)) {
231
+ process.stderr.write(`❌ host root does not exist: ${root}\n`);
232
+ return 1;
233
+ }
234
+ const volume = measureCatalogueVolume(argValue('--host') ?? path.basename(root).replace(/^\./, ''), root);
235
+ if (process.argv.includes('--json')) {
236
+ process.stdout.write(`${JSON.stringify(volume, null, 2)}\n`);
237
+ return 0;
238
+ }
239
+ process.stdout.write(
240
+ `host: ${volume.host}\nroot: ${volume.root}\n` +
241
+ `skill entries: ${volume.skillEntries}\ncommand entries: ${volume.commandEntries}\n` +
242
+ `artefacts offered: ${volume.artefacts}\n` +
243
+ `description payload: ${volume.descriptionBytes} bytes\n\n` +
244
+ 'This is the PROJECTION half — what the host is offered, not what it delivered.\n',
245
+ );
246
+ return 0;
341
247
  }
342
248
 
343
- export function formatReport(report: SelectorReport): string {
344
- const lines: string[] = [];
345
- lines.push(`catalogue root: ${report.catalogueRoot}`);
346
- lines.push(`entries: ${report.entriesTotal}`);
347
- lines.push(
348
- `observed: ${report.observedBare.length} bare · ${report.observedDescribed.length} described (self-reported)`,
349
- );
350
- if (report.unknownObserved.length > 0) {
351
- lines.push(
352
- `⚠️ ${report.unknownObserved.length} observed name(s) absent from the projection: ${report.unknownObserved.join(', ')}`,
353
- );
354
- }
355
- if (report.verdict === 'insufficient-observation') {
356
- lines.push('');
357
- lines.push(
358
- 'verdict: insufficient-observation — an observation needs at least one bare AND one described entry to separate anything.',
249
+ /**
250
+ * `--host-event <file|->` — record what the host itself published.
251
+ *
252
+ * The deterministic path. `-` reads stdin, so the capture composes directly:
253
+ * codex exec --json --skip-git-repo-check "reply with exactly: OK" \
254
+ * | capture_skill_catalogue --host-event - --host codex --host-root ~/.codex
255
+ */
256
+ function runHostEventMode(source: string): number {
257
+ const stream =
258
+ source === '-'
259
+ ? fs.readFileSync(0, 'utf-8')
260
+ : fs.readFileSync(expandHome(source), 'utf-8');
261
+
262
+ const hostRootArg = argValue('--host-root');
263
+ if (!hostRootArg) {
264
+ process.stderr.write(
265
+ '❌ --host-event requires --host-root <dir> — the dropped count is only\n' +
266
+ ' meaningful against the number of artefacts the host was offered.\n',
359
267
  );
360
- return lines.join('\n');
268
+ return 1;
361
269
  }
362
- lines.push('');
363
- lines.push('selector candidates:');
364
- for (const candidate of report.candidates) {
365
- lines.push(` ${candidate.separates ? '✅' : '❌'} ${candidate.id} — ${candidate.describes}`);
366
- lines.push(` ${candidate.detail}`);
270
+ const host = argValue('--host') ?? 'unknown';
271
+ const volume = measureCatalogueVolume(host, expandHome(hostRootArg));
272
+ if (volume.artefacts === 0) {
273
+ process.stderr.write(`❌ host root ${volume.root} offers no catalogue artefacts\n`);
274
+ return 1;
367
275
  }
368
- lines.push('');
369
- lines.push(
370
- report.verdict === 'selector-found'
371
- ? 'verdict: selector-found — a delivery fix can act on the separating property above.'
372
- : '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.',
373
- );
374
- return lines.join('\n');
375
- }
376
276
 
377
- function resolveCatalogueRoot(explicit: string | null): string {
378
- if (explicit) {
379
- if (!fs.existsSync(explicit)) {
380
- throw new Error(`catalogue root does not exist: ${explicit}`);
277
+ const event = parseHostBudgetEvent(stream);
278
+ if (event === null) {
279
+ if (!process.argv.includes('--assert-no-truncation')) {
280
+ process.stderr.write(
281
+ '❌ no parseable skills-budget event in this stream.\n' +
282
+ ' That is UNKNOWN, not zero: a reworded or removed host message looks\n' +
283
+ ' exactly like a fixed defect. If this run genuinely did not truncate,\n' +
284
+ ' say so with --assert-no-truncation.\n',
285
+ );
286
+ return 1;
381
287
  }
382
- return explicit;
383
- }
384
- for (const candidate of DEFAULT_CATALOGUE_ROOTS) {
385
- const abs = path.join(REPO, candidate);
386
- if (fs.existsSync(abs)) return abs;
288
+ process.stdout.write(
289
+ `host: ${host}\noffered: ${volume.artefacts} (${volume.skillEntries} skills + ${volume.commandEntries} commands)\n` +
290
+ `dropped: 0 (asserted — no budget event in the stream)\n` +
291
+ 'descriptions stripped: no — the host published nothing to strip.\n',
292
+ );
293
+ if (process.argv.includes('--record')) {
294
+ const stampedAt = argValue('--observed-at');
295
+ if (!stampedAt) {
296
+ process.stderr.write('❌ --record requires --observed-at <ISO date>\n');
297
+ return 1;
298
+ }
299
+ const logPath = path.join(REPO, OBSERVATION_LOG);
300
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
301
+ fs.appendFileSync(
302
+ logPath,
303
+ `${JSON.stringify(
304
+ buildNoTruncationRecord(
305
+ host,
306
+ stampedAt,
307
+ volume.artefacts,
308
+ volume.skillEntries,
309
+ argProjectionMode(),
310
+ ),
311
+ )}\n`,
312
+ );
313
+ process.stdout.write(`recorded → ${OBSERVATION_LOG}\n`);
314
+ }
315
+ return 0;
387
316
  }
388
- throw new Error(
389
- `no catalogue root found — tried ${DEFAULT_CATALOGUE_ROOTS.join(', ')} under ${REPO}`,
317
+
318
+ process.stdout.write(
319
+ `host: ${host}\nroot: ${volume.root}\n` +
320
+ `artefacts offered: ${volume.artefacts} (${volume.skillEntries} skills + ${volume.commandEntries} commands)\n` +
321
+ `description payload: ${volume.descriptionBytes} bytes\n` +
322
+ `dropped by the host: ${event.droppedCount}\n` +
323
+ `descriptions stripped: ${event.descriptionsStripped ? 'yes — all of them' : 'no'}\n\n` +
324
+ 'Source: host-event (read from the host\'s own JSON channel, not self-reported).\n' +
325
+ 'No delivered count is printed: the offered figure above is THIS tool\'s\n' +
326
+ 'projection and the dropped figure is the host\'s, and a controlled probe\n' +
327
+ 'showed they do not share a denominator. Subtracting them would invent a number.\n',
390
328
  );
391
- }
392
329
 
393
- function argValue(flag: string): string | null {
394
- const index = process.argv.indexOf(flag);
395
- if (index === -1 || index + 1 >= process.argv.length) return null;
396
- return process.argv[index + 1]!;
330
+ if (process.argv.includes('--record')) {
331
+ const stampedAt = argValue('--observed-at');
332
+ if (!stampedAt) {
333
+ process.stderr.write('❌ --record requires --observed-at <ISO date>\n');
334
+ return 1;
335
+ }
336
+ const logPath = path.join(REPO, OBSERVATION_LOG);
337
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
338
+ const record = buildHostEventRecord(
339
+ host,
340
+ stampedAt,
341
+ volume.artefacts,
342
+ event,
343
+ volume.skillEntries,
344
+ argProjectionMode(),
345
+ );
346
+ fs.appendFileSync(logPath, `${JSON.stringify(record)}\n`);
347
+ process.stdout.write(`recorded → ${OBSERVATION_LOG}\n`);
348
+ }
349
+ return 0;
397
350
  }
398
351
 
399
352
  function main(): number {
353
+ if (process.argv.includes('--limits')) return runLimitsMode();
354
+ if (process.argv.includes('--projection-modes')) return runProjectionModesMode();
355
+
356
+ const volumeRoot = argValue('--volume');
357
+ if (volumeRoot) return runVolumeMode(volumeRoot);
358
+
359
+ const hostEvent = argValue('--host-event');
360
+ if (hostEvent) return runHostEventMode(hostEvent);
361
+
400
362
  const root = resolveCatalogueRoot(argValue('--catalogue-root'));
401
363
  const projected = readProjectedCatalogue(root);
402
364