@event4u/agent-config 12.1.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 (105) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +151 -0
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/cost/report.md +23 -0
  5. package/dist/agent-src/contexts/execution/subagent-routing.md +148 -33
  6. package/dist/agent-src/contexts/subagent-configuration.md +69 -7
  7. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  8. package/dist/agent-src/rules/design-fidelity.md +1 -1
  9. package/dist/agent-src/rules/design-review-after-ui-write.md +10 -5
  10. package/dist/agent-src/rules/evaluator-independence.md +27 -5
  11. package/dist/agent-src/rules/git-history-discipline.md +5 -1
  12. package/dist/agent-src/rules/missing-skill-recovery.md +89 -0
  13. package/dist/agent-src/rules/ui-audit-gate.md +9 -4
  14. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
  15. package/dist/agent-src/scripts/update_roadmap_progress.ts +17 -2
  16. package/dist/agent-src/skills/authz-review/SKILL.md +4 -0
  17. package/dist/agent-src/skills/design-system-capture/SKILL.md +4 -0
  18. package/dist/agent-src/skills/design-system-capture/references/snapshot-preference-order.md +77 -0
  19. package/dist/agent-src/skills/merge-conflicts/SKILL.md +4 -0
  20. package/dist/agent-src/skills/prompt-optimizer/SKILL.md +17 -1
  21. package/dist/agent-src/skills/roadmap-writing/SKILL.md +4 -4
  22. package/dist/agent-src/skills/systematic-debugging/SKILL.md +4 -0
  23. package/dist/agent-src/skills/threat-modeling/SKILL.md +3 -0
  24. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  25. package/dist/cli-delegate/{chunk-O52B4F5S.js → chunk-3B72LSZL.js} +1 -1
  26. package/dist/cli-delegate/{chunk-55WZN5VF.js → chunk-EVTY7ISR.js} +2 -2
  27. package/dist/cli-delegate/{chunk-K34QTSD5.js → chunk-N7FEST6D.js} +17 -17
  28. package/dist/cli-delegate/{chunk-LKZ5DOYW.js → chunk-XURJ4AQC.js} +11 -3
  29. package/dist/cli-delegate/cmd_conformance.js +2 -2
  30. package/dist/cli-delegate/cmd_doctor.js +2 -2
  31. package/dist/cli-delegate/cmd_export.js +2 -2
  32. package/dist/cli-delegate/cmd_handoff.js +1 -1
  33. package/dist/cli-delegate/cmd_prune.js +2 -2
  34. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  35. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  36. package/dist/cli-delegate/cmd_sync.js +2 -2
  37. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  38. package/dist/cli-delegate/cmd_validate.js +2 -2
  39. package/dist/discovery/deprecation-report.md +1 -1
  40. package/dist/discovery/discovery-manifest.json +43 -22
  41. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  42. package/dist/discovery/discovery-manifest.summary.md +3 -3
  43. package/dist/discovery/dormancy-report.md +1 -1
  44. package/dist/discovery/orphan-report.md +1 -1
  45. package/dist/discovery/packs.json +6 -5
  46. package/dist/discovery/trust-report.md +2 -2
  47. package/dist/discovery/workspaces.json +5 -4
  48. package/dist/hooks/dispatch.js +936 -346
  49. package/dist/install/install.mjs +12 -4
  50. package/dist/mcp/registry-manifest.json +3 -3
  51. package/dist/mcp/server.json +2 -2
  52. package/dist/mcp/server.mjs +1192 -160
  53. package/dist/router.json +1 -1
  54. package/dist/server/schemas/settings.js +2 -2
  55. package/dist/server/schemas/settings.js.map +1 -1
  56. package/docs/guidelines/design-fidelity-mechanics.md +21 -0
  57. package/docs/guidelines/design-handover-extraction.md +87 -0
  58. package/docs/guidelines/prompt-templates.md +24 -1
  59. package/package.json +1 -1
  60. package/src/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
  61. package/src/agent-src/scripts/update_roadmap_progress.ts +17 -2
  62. package/src/config/evaluator-budgets.json +5 -4
  63. package/src/config/gate-coverage.yml +33 -11
  64. package/src/config/gate-violation-baselines.json +9 -3
  65. package/src/config/hook-token-budget.json +8 -1
  66. package/src/scripts/_lib/bench_ab_complexity.ts +439 -0
  67. package/src/scripts/_lib/bench_ab_safety_tier.ts +142 -0
  68. package/src/scripts/_lib/bench_ab_scoring_v2.ts +28 -3
  69. package/src/scripts/_lib/bench_ab_search_adherence.ts +423 -0
  70. package/src/scripts/_lib/bench_ab_size_claim.ts +206 -0
  71. package/src/scripts/_lib/bench_ab_workspace.ts +111 -0
  72. package/src/scripts/_lib/skill_catalogue.ts +258 -5
  73. package/src/scripts/_lib/tier_budget_routing.ts +31 -331
  74. package/src/scripts/ai_council/clients.ts +21 -21
  75. package/src/scripts/bench_ab_v2_complexity.ts +252 -0
  76. package/src/scripts/bench_ab_v2_run.ts +3 -42
  77. package/src/scripts/bench_ab_v2_safety.ts +296 -0
  78. package/src/scripts/bench_ab_v2_search.ts +343 -0
  79. package/src/scripts/bench_ab_v2_stats.ts +191 -0
  80. package/src/scripts/build_archive_index.ts +383 -0
  81. package/src/scripts/capture_skill_catalogue.ts +107 -2
  82. package/src/scripts/check_source_size_budget.ts +428 -0
  83. package/src/scripts/check_suppression_hygiene.ts +5 -1
  84. package/src/scripts/cost/backfill_rates.mjs +298 -0
  85. package/src/scripts/cost/budget.mjs +12 -32
  86. package/src/scripts/cost_summary.ts +25 -0
  87. package/src/scripts/external_sources_denylist.json +1 -0
  88. package/src/scripts/hook_manifest.yaml +28 -2
  89. package/src/scripts/hooks/concern_registry.ts +2 -0
  90. package/src/scripts/hooks/skill_route_hook.ts +226 -0
  91. package/src/scripts/hooks/ui_route_nudge_hook.ts +6 -3
  92. package/src/scripts/lint_design_antipattern_parity.ts +40 -6
  93. package/src/scripts/lint_harvest_provenance.ts +35 -1
  94. package/src/scripts/lint_rule_skill_pack_reach.ts +43 -0
  95. package/src/scripts/lint_skill_router_head.ts +21 -0
  96. package/src/scripts/mcp_server/consumer_tool_catalog.json +26 -3
  97. package/src/scripts/mcp_server/tool_catalog_source.ts +6 -24
  98. package/src/scripts/mcp_server/tools.ts +87 -0
  99. package/src/scripts/release.ts +5 -5
  100. package/src/scripts/routing_doctor.ts +50 -13
  101. package/src/scripts/schemas/command.schema.json +1 -1
  102. package/src/scripts/schemas/skill.schema.json +18 -1
  103. package/src/scripts/trigger_coverage.ts +224 -3
  104. package/src/server/schemas/settings.ts +2 -2
  105. package/src/config/budget-routing.json +0 -7
@@ -0,0 +1,383 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Build a machine-readable index over the roadmap archive.
4
+ *
5
+ * "Has this already been tried, closed, or refuted?" is answered today by
6
+ * walking `agents/roadmaps/archive/` — 500 top-level files. This emits
7
+ * `INDEX.md` (human) and `index.json` (machine) beside them so the question
8
+ * costs one read instead of a walk.
9
+ *
10
+ * ## What is and is not extracted
11
+ *
12
+ * Every field is derived by a deterministic rule from the file's own bytes.
13
+ * There are NO model-written summaries: a verdict the frontmatter does not
14
+ * carry is emitted as `null` in JSON and `not extractable` in the table, so a
15
+ * reader can tell a missing verdict from an invented one. That is the
16
+ * roadmap's non-goal, enforced here rather than promised.
17
+ *
18
+ * `disposition` is the one derived field, and it is derived from the checkbox
19
+ * tally alone — including acceptance-criteria boxes, which are checkboxes in
20
+ * the same file and are not distinguished. It records how the file LOOKS at
21
+ * archival, never why it was archived:
22
+ *
23
+ * | tally | disposition |
24
+ * |---|---|
25
+ * | no checkbox anywhere | `not-extractable` |
26
+ * | any `[ ]` | `archived-with-open-steps` |
27
+ * | any `[-]`, none open | `closed-with-cancellations` |
28
+ * | any `[~]`, none open/cancelled | `completed-with-deferrals` |
29
+ * | otherwise | `completed` |
30
+ *
31
+ * ## Drift
32
+ *
33
+ * `--check` regenerates in memory and compares byte-for-byte against the two
34
+ * committed artefacts, following the shape `compile_router --check` already
35
+ * uses. A generated artefact nobody re-derives goes stale in one merge.
36
+ *
37
+ * The output carries no timestamp on purpose — a clock in a generated file
38
+ * makes every `--check` run a false red. The generation time lives in git
39
+ * history, the same convention `agents/roadmaps-progress.md` states.
40
+ *
41
+ * Reuses `validate_frontmatter`'s reader; this file adds no second parser.
42
+ */
43
+ import * as fs from 'node:fs';
44
+ import * as path from 'node:path';
45
+ import process from 'node:process';
46
+ import { fileURLToPath, pathToFileURL } from 'node:url';
47
+
48
+ import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
49
+ import { parse_frontmatter } from './validate_frontmatter.js';
50
+
51
+ const _HERE = fileURLToPath(import.meta.url);
52
+ export const ROOT = path.resolve(path.dirname(_HERE), '..', '..');
53
+
54
+ const ARCHIVE_REL = 'agents/roadmaps/archive';
55
+ const INDEX_MD = 'INDEX.md';
56
+ const INDEX_JSON = 'index.json';
57
+
58
+ /** The generated artefacts themselves are never indexed. */
59
+ const SELF: ReadonlySet<string> = new Set([INDEX_MD, INDEX_JSON]);
60
+
61
+ /** `- [ ]` / `* [x]` / `- [~]` / `- [-]`, the four glyphs the roadmaps use. */
62
+ const CHECKBOX_RE = /^[ \t]*[-*][ \t]+\[([ xX~-])\][ \t]/;
63
+
64
+ /** `## Phase 3 — …` or `### Phase 0: …`. Two heading levels are in use. */
65
+ const PHASE_RE = /^#{2,3}[ \t]+Phase\b/;
66
+
67
+ /** The first level-1 heading, used as the title when frontmatter has none. */
68
+ const H1_RE = /^#[ \t]+(.+?)[ \t]*$/;
69
+
70
+ export type Disposition =
71
+ | 'completed'
72
+ | 'completed-with-deferrals'
73
+ | 'closed-with-cancellations'
74
+ | 'archived-with-open-steps'
75
+ | 'not-extractable';
76
+
77
+ export interface StepTally {
78
+ open: number;
79
+ done: number;
80
+ deferred: number;
81
+ cancelled: number;
82
+ }
83
+
84
+ export interface ArchiveEntry {
85
+ slug: string;
86
+ title: string | null;
87
+ disposition: Disposition;
88
+ phases: number;
89
+ steps: StepTally;
90
+ /** Frontmatter `verdict:`, verbatim. `null` = the file carries none. */
91
+ verdict: string | null;
92
+ /** Frontmatter `status:`, verbatim. Authoring status, not a closure. */
93
+ status: string | null;
94
+ }
95
+
96
+ /** Frontmatter value → string, or `null` for absent / non-scalar. */
97
+ function _scalar(value: unknown): string | null {
98
+ if (typeof value === 'string') {
99
+ const t = value.trim();
100
+ return t === '' ? null : t;
101
+ }
102
+ if (typeof value === 'number' || typeof value === 'boolean') {
103
+ return String(value);
104
+ }
105
+ return null;
106
+ }
107
+
108
+ /**
109
+ * Frontmatter, or `{}` when the file has none or carries a malformed block.
110
+ *
111
+ * A 500-file corpus assembled over months contains frontmatter the strict
112
+ * subset parser rejects; that is a property of the archive, not an error in
113
+ * this pass. Such a file still gets an entry — with its frontmatter-derived
114
+ * fields absent, which is exactly what "not extractable" means.
115
+ */
116
+ function _frontmatter(text: string): Record<string, unknown> {
117
+ try {
118
+ const [data] = parse_frontmatter(text);
119
+ return data ?? {};
120
+ } catch {
121
+ return {};
122
+ }
123
+ }
124
+
125
+ /** Body lines, i.e. everything after a frontmatter block if one is present. */
126
+ function _bodyLines(text: string): string[] {
127
+ const lines = text.split('\n');
128
+ if (lines[0]?.trimEnd() !== '---') {
129
+ return lines;
130
+ }
131
+ for (let i = 1; i < lines.length; i += 1) {
132
+ if (lines[i]?.trimEnd() === '---') {
133
+ return lines.slice(i + 1);
134
+ }
135
+ }
136
+ return lines;
137
+ }
138
+
139
+ export function tally(bodyLines: readonly string[]): StepTally {
140
+ const t: StepTally = { open: 0, done: 0, deferred: 0, cancelled: 0 };
141
+ for (const line of bodyLines) {
142
+ const m = CHECKBOX_RE.exec(line);
143
+ if (m === null) {
144
+ continue;
145
+ }
146
+ switch (m[1]) {
147
+ case ' ':
148
+ t.open += 1;
149
+ break;
150
+ case '~':
151
+ t.deferred += 1;
152
+ break;
153
+ case '-':
154
+ t.cancelled += 1;
155
+ break;
156
+ default:
157
+ t.done += 1;
158
+ }
159
+ }
160
+ return t;
161
+ }
162
+
163
+ export function disposition(t: StepTally): Disposition {
164
+ if (t.open + t.done + t.deferred + t.cancelled === 0) {
165
+ return 'not-extractable';
166
+ }
167
+ if (t.open > 0) {
168
+ return 'archived-with-open-steps';
169
+ }
170
+ if (t.cancelled > 0) {
171
+ return 'closed-with-cancellations';
172
+ }
173
+ if (t.deferred > 0) {
174
+ return 'completed-with-deferrals';
175
+ }
176
+ return 'completed';
177
+ }
178
+
179
+ export function buildEntry(slug: string, text: string): ArchiveEntry {
180
+ const fm = _frontmatter(text);
181
+ const body = _bodyLines(text);
182
+
183
+ let title = _scalar(fm['title']);
184
+ if (title === null) {
185
+ for (const line of body) {
186
+ const m = H1_RE.exec(line);
187
+ if (m !== null) {
188
+ title = (m[1] ?? '').trim() || null;
189
+ break;
190
+ }
191
+ }
192
+ }
193
+
194
+ const steps = tally(body);
195
+ return {
196
+ slug,
197
+ title,
198
+ disposition: disposition(steps),
199
+ phases: body.filter((l) => PHASE_RE.test(l)).length,
200
+ steps,
201
+ verdict: _scalar(fm['verdict']),
202
+ status: _scalar(fm['status']),
203
+ };
204
+ }
205
+
206
+ /** Top-level `*.md` under the archive, sorted, excluding the generated pair. */
207
+ export function archiveFiles(dir: string): string[] {
208
+ let entries: fs.Dirent[];
209
+ try {
210
+ entries = fs.readdirSync(dir, { withFileTypes: true });
211
+ } catch {
212
+ return [];
213
+ }
214
+ return entries
215
+ .filter((e) => !e.isDirectory() && e.name.endsWith('.md') && !SELF.has(e.name))
216
+ .map((e) => e.name)
217
+ .sort();
218
+ }
219
+
220
+ export function buildIndex(dir: string): ArchiveEntry[] {
221
+ return archiveFiles(dir).map((name) =>
222
+ buildEntry(name.replace(/\.md$/, ''), fs.readFileSync(path.join(dir, name), 'utf8')),
223
+ );
224
+ }
225
+
226
+ /** Table-cell text: pipes escaped, newlines flattened, never empty. */
227
+ function _cell(value: string | null): string {
228
+ if (value === null) {
229
+ return '_not extractable_';
230
+ }
231
+ return value.replace(/\|/g, '\\|').replace(/\s+/g, ' ').trim();
232
+ }
233
+
234
+ export function renderJson(entries: readonly ArchiveEntry[]): string {
235
+ return (
236
+ JSON.stringify(
237
+ {
238
+ generated_by: 'src/scripts/build_archive_index.ts',
239
+ source: ARCHIVE_REL,
240
+ count: entries.length,
241
+ entries,
242
+ },
243
+ null,
244
+ 2,
245
+ ) + '\n'
246
+ );
247
+ }
248
+
249
+ export function renderMarkdown(entries: readonly ArchiveEntry[]): string {
250
+ const byDisposition = new Map<Disposition, number>();
251
+ for (const e of entries) {
252
+ byDisposition.set(e.disposition, (byDisposition.get(e.disposition) ?? 0) + 1);
253
+ }
254
+ const summary = [...byDisposition.entries()]
255
+ .sort((a, b) => (a[0] < b[0] ? -1 : 1))
256
+ .map(([d, n]) => `${d} ${String(n)}`)
257
+ .join(' · ');
258
+
259
+ const out: string[] = [
260
+ '# Archive index',
261
+ '',
262
+ '> Auto-generated — do not edit. Regenerate with `task build-archive-index`;',
263
+ '> `task check-archive-index` fails on drift. Generation time lives in git',
264
+ '> history, not in this file (a clock here would make every drift check a',
265
+ '> false red).',
266
+ '',
267
+ `**${String(entries.length)} archived roadmaps** · ${summary}`,
268
+ '',
269
+ 'Every column is extracted deterministically from the file itself. No',
270
+ 'summary here is model-written: a verdict the frontmatter does not carry',
271
+ 'reads _not extractable_ rather than being inferred. `Disposition` is',
272
+ 'derived from the checkbox tally alone (acceptance-criteria boxes',
273
+ 'included) and records how the file looks, never why it was archived —',
274
+ 'so for anything the index marks _not extractable_, open the file.',
275
+ '',
276
+ '**Limits worth knowing before you rely on this.** Only a handful of',
277
+ 'archived roadmaps carry a frontmatter `verdict:`, so the index answers',
278
+ '_tried?_ and _closed?_ and sends you to the file for _why_. The measured',
279
+ 'saving is 82 % fewer files opened on a pre-registered question set — and',
280
+ 'below roughly five candidate files it is a wash, so a single narrow',
281
+ 'question is cheaper answered directly. Reading, method and full',
282
+ 'distribution: [`archive-index-saving`](../../evidence/analysis/archive-index-saving.md).',
283
+ '',
284
+ '| Roadmap | Title | Disposition | Phases | Steps (done/total) | Verdict |',
285
+ '|---|---|---|---:|---|---|',
286
+ ];
287
+
288
+ for (const e of entries) {
289
+ const total = e.steps.open + e.steps.done + e.steps.deferred + e.steps.cancelled;
290
+ const steps = total === 0 ? '—' : `${String(e.steps.done)}/${String(total)}`;
291
+ out.push(
292
+ `| [\`${e.slug}\`](${e.slug}.md) | ${_cell(e.title)} | ${e.disposition} | ` +
293
+ `${String(e.phases)} | ${steps} | ${_cell(e.verdict)} |`,
294
+ );
295
+ }
296
+ out.push('');
297
+ return out.join('\n');
298
+ }
299
+
300
+ function _read(p: string): string | null {
301
+ try {
302
+ return fs.readFileSync(p, 'utf8');
303
+ } catch {
304
+ return null;
305
+ }
306
+ }
307
+
308
+ export function main(argv: readonly string[]): number {
309
+ const quiet = argv.includes('--quiet');
310
+ const dir = path.join(ROOT, ARCHIVE_REL);
311
+
312
+ const entries = buildIndex(dir);
313
+ try {
314
+ reportScanned({
315
+ gate: 'build_archive_index',
316
+ scanned: entries.length,
317
+ units: 'archived roadmap(s)',
318
+ roots: [ARCHIVE_REL],
319
+ });
320
+ } catch (err) {
321
+ if (err instanceof DeadScopeError) {
322
+ process.stderr.write(`${err.message}\n`);
323
+ return 1;
324
+ }
325
+ throw err;
326
+ }
327
+
328
+ const mdPath = path.join(dir, INDEX_MD);
329
+ const jsonPath = path.join(dir, INDEX_JSON);
330
+ const md = renderMarkdown(entries);
331
+ const json = renderJson(entries);
332
+
333
+ if (argv.includes('--check')) {
334
+ const stale = [
335
+ _read(mdPath) === md ? null : `${ARCHIVE_REL}/${INDEX_MD}`,
336
+ _read(jsonPath) === json ? null : `${ARCHIVE_REL}/${INDEX_JSON}`,
337
+ ].filter((v): v is string => v !== null);
338
+ if (stale.length > 0) {
339
+ process.stderr.write(
340
+ `❌ archive index out of date (${stale.join(', ')}) — run \`task build-archive-index\`\n`,
341
+ );
342
+ return 1;
343
+ }
344
+ if (!quiet) {
345
+ process.stdout.write('✅ archive index is up to date\n');
346
+ }
347
+ return 0;
348
+ }
349
+
350
+ fs.writeFileSync(mdPath, md, 'utf8');
351
+ fs.writeFileSync(jsonPath, json, 'utf8');
352
+ if (!quiet) {
353
+ process.stdout.write(
354
+ `✅ archive index — ${String(entries.length)} roadmap(s) → ` +
355
+ `${ARCHIVE_REL}/{${INDEX_MD},${INDEX_JSON}}\n`,
356
+ );
357
+ }
358
+ return 0;
359
+ }
360
+
361
+ function _isCliEntry(): boolean {
362
+ if (process.argv[1] === undefined) {
363
+ return false;
364
+ }
365
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
366
+ if (import.meta.url === argvUrl) {
367
+ return true;
368
+ }
369
+ // A symlinked invocation (an installed projection, or macOS /var →
370
+ // /private/var temp dirs) makes the raw URLs differ: import.meta.url is
371
+ // the resolved real path while argv[1] keeps the symlink path.
372
+ try {
373
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
374
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
375
+ return here === argv;
376
+ } catch {
377
+ return false;
378
+ }
379
+ }
380
+
381
+ if (_isCliEntry() || process.argv[1] === _HERE) {
382
+ process.exit(main(process.argv.slice(2)));
383
+ }
@@ -75,16 +75,24 @@ import {
75
75
  OBSERVATION_LOG,
76
76
  analyzeSelector,
77
77
  buildHostEventRecord,
78
+ buildNoTruncationRecord,
78
79
  buildObservationRecord,
80
+ classifyHostProjection,
79
81
  formatPerHostVerdicts,
82
+ formatProjectionModes,
80
83
  formatReport,
81
84
  knownHostLimits,
82
85
  measureCatalogueVolume,
83
86
  parseHostBudgetEvent,
84
87
  readObservationLog,
85
88
  readProjectedCatalogue,
89
+ type HostProjectionRow,
90
+ type ProjectionMode,
91
+ type ProjectionModeCounts,
86
92
  type SelectorReport,
87
93
  } from './_lib/skill_catalogue.js';
94
+ import { scoped_projection_stats } from './_lib/scoped_projection.js';
95
+ import { iter_skills } from './update_counts.js';
88
96
 
89
97
  // Re-exported so the CLI module stays the one public name for this tool.
90
98
  export * from './_lib/skill_catalogue.js';
@@ -114,6 +122,30 @@ function argValue(flag: string): string | null {
114
122
  return process.argv[index + 1]!;
115
123
  }
116
124
 
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]!);
130
+ }
131
+ return out;
132
+ }
133
+
134
+ /**
135
+ * `--projection-mode <scoped|legacy-all>`, validated against the closed enum.
136
+ *
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.
141
+ */
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
+ }
148
+
117
149
  /** `~` expanded against the current user's home. */
118
150
  function expandHome(p: string): string {
119
151
  return p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : p;
@@ -151,6 +183,47 @@ function runLimitsMode(): number {
151
183
  return 0;
152
184
  }
153
185
 
186
+ /**
187
+ * `--projection-modes` — the two modes side by side, as a MEASUREMENT.
188
+ *
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.
198
+ */
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,
206
+ };
207
+
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));
217
+ }
218
+
219
+ if (process.argv.includes('--json')) {
220
+ process.stdout.write(`${JSON.stringify({ counts, hosts: rows }, null, 2)}\n`);
221
+ return 0;
222
+ }
223
+ process.stdout.write(`${formatProjectionModes(counts, rows)}\n`);
224
+ return 0;
225
+ }
226
+
154
227
  /** `--volume <host-root>` — the projection half for one host, byte-accurate. */
155
228
  function runVolumeMode(rootArg: string): number {
156
229
  const root = expandHome(rootArg);
@@ -213,8 +286,32 @@ function runHostEventMode(source: string): number {
213
286
  return 1;
214
287
  }
215
288
  process.stdout.write(
216
- `host: ${host}\noffered: ${volume.artefacts}\ndropped: 0 (asserted — no budget event in the stream)\n`,
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',
217
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
+ }
218
315
  return 0;
219
316
  }
220
317
 
@@ -238,7 +335,14 @@ function runHostEventMode(source: string): number {
238
335
  }
239
336
  const logPath = path.join(REPO, OBSERVATION_LOG);
240
337
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
241
- const record = buildHostEventRecord(host, stampedAt, volume.artefacts, event, volume.skillEntries);
338
+ const record = buildHostEventRecord(
339
+ host,
340
+ stampedAt,
341
+ volume.artefacts,
342
+ event,
343
+ volume.skillEntries,
344
+ argProjectionMode(),
345
+ );
242
346
  fs.appendFileSync(logPath, `${JSON.stringify(record)}\n`);
243
347
  process.stdout.write(`recorded → ${OBSERVATION_LOG}\n`);
244
348
  }
@@ -247,6 +351,7 @@ function runHostEventMode(source: string): number {
247
351
 
248
352
  function main(): number {
249
353
  if (process.argv.includes('--limits')) return runLimitsMode();
354
+ if (process.argv.includes('--projection-modes')) return runProjectionModesMode();
250
355
 
251
356
  const volumeRoot = argValue('--volume');
252
357
  if (volumeRoot) return runVolumeMode(volumeRoot);