@event4u/agent-config 9.35.0 → 10.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 (117) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +95 -142
  3. package/agents/templates/.ai-council.yml.example +31 -51
  4. package/dist/agent-src/commands/analyze/conformance.md +15 -4
  5. package/dist/agent-src/commands/analyze/reference-repo.md +135 -42
  6. package/dist/agent-src/contexts/augment-infrastructure.md +1 -1
  7. package/dist/agent-src/rules/design-review-after-ui-write.md +53 -6
  8. package/dist/agent-src/rules/improve-before-implement.md +1 -1
  9. package/dist/agent-src/rules/session-canary.md +28 -0
  10. package/dist/agent-src/rules/ui-audit-gate.md +35 -0
  11. package/dist/agent-src/skills/design-system-capture/SKILL.md +2 -2
  12. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +1 -1
  13. package/dist/agent-src/skills/fe-design/SKILL.md +84 -12
  14. package/dist/agent-src/skills/fe-design/evals/triggers.json +11 -3
  15. package/dist/agent-src/skills/prediction-pool-optimizer/SKILL.md +5 -5
  16. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
  17. package/dist/agent-src/skills/skill-writing/SKILL.md +24 -0
  18. package/dist/agent-src/skills/subagent-orchestration/prompts/README.md +29 -0
  19. package/dist/agent-src/templates/AGENTS.md +1 -1
  20. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  21. package/dist/cli-delegate/{chunk-2TGMGLBL.js → chunk-2H5SE7DU.js} +35 -5
  22. package/dist/cli-delegate/{chunk-DS6BLDMG.js → chunk-3JUTT26I.js} +8 -3
  23. package/dist/cli-delegate/{chunk-ZAXH3ZWU.js → chunk-HJSKTMIF.js} +14 -13
  24. package/dist/cli-delegate/{chunk-QEEB4LHO.js → chunk-LRXJK6GN.js} +1 -1
  25. package/dist/cli-delegate/{chunk-736JCWLM.js → chunk-M2DPGFZ4.js} +10 -0
  26. package/dist/cli-delegate/{chunk-H6F2CQFI.js → chunk-NX5AXSLD.js} +3 -3
  27. package/dist/cli-delegate/{chunk-AG47LAGQ.js → chunk-TVGXH7VA.js} +1 -1
  28. package/dist/cli-delegate/{chunk-55RI5UXK.js → chunk-XT2YTJCZ.js} +22 -18
  29. package/dist/cli-delegate/{chunk-JEJABGKB.js → chunk-XTC5ARB6.js} +16 -12
  30. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  31. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  32. package/dist/cli-delegate/cmd_conformance.js +1038 -197
  33. package/dist/cli-delegate/cmd_doctor.js +4 -4
  34. package/dist/cli-delegate/cmd_explain.js +2 -2
  35. package/dist/cli-delegate/cmd_export.js +4 -4
  36. package/dist/cli-delegate/cmd_handoff.js +3 -3
  37. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  38. package/dist/cli-delegate/cmd_migrate.js +2 -2
  39. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  40. package/dist/cli-delegate/cmd_prune.js +4 -4
  41. package/dist/cli-delegate/cmd_refresh.js +1 -1
  42. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  43. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  44. package/dist/cli-delegate/cmd_session_recycle.js +9 -2
  45. package/dist/cli-delegate/cmd_settings_get.js +1 -1
  46. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  47. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  48. package/dist/cli-delegate/cmd_sync.js +4 -4
  49. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  50. package/dist/cli-delegate/cmd_update.js +1 -1
  51. package/dist/cli-delegate/cmd_upgrade.js +2 -2
  52. package/dist/cli-delegate/cmd_validate.js +4 -4
  53. package/dist/cli-delegate/cmd_versions.js +1 -1
  54. package/dist/discovery/deprecation-report.md +1 -1
  55. package/dist/discovery/discovery-manifest.json +14 -14
  56. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  57. package/dist/discovery/discovery-manifest.summary.md +1 -1
  58. package/dist/discovery/dormancy-report.md +1 -1
  59. package/dist/discovery/orphan-report.md +1 -1
  60. package/dist/discovery/packs.json +2 -2
  61. package/dist/discovery/trust-report.md +1 -1
  62. package/dist/discovery/workspaces.json +2 -2
  63. package/dist/hooks/dispatch.js +654 -411
  64. package/dist/install/install.mjs +39 -29
  65. package/dist/mcp/registry-manifest.json +1 -1
  66. package/dist/mcp/server.json +2 -2
  67. package/dist/mcp/server.mjs +1580 -617
  68. package/dist/router.json +1 -1
  69. package/dist/scripts/_lib/agent_settings.js +39 -25
  70. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  71. package/dist/scripts/condense.js +15 -2
  72. package/dist/scripts/condense.js.map +1 -1
  73. package/dist/server/routes/wizard.js +23 -3
  74. package/dist/server/routes/wizard.js.map +1 -1
  75. package/dist/server/schemas/settings.js +11 -6
  76. package/dist/server/schemas/settings.js.map +1 -1
  77. package/docs/contracts/settings-classes.md +16 -9
  78. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +46 -4
  79. package/package.json +2 -2
  80. package/src/agent-src/templates/AGENTS.md +1 -1
  81. package/src/config/agent-settings.template.yml +38 -36
  82. package/src/config/ci-local-parity.yml +44 -0
  83. package/src/config/gate-coverage.yml +12 -0
  84. package/src/scripts/_cli/cmd_doctor.ts +32 -0
  85. package/src/scripts/_cli/cmd_migrate.ts +26 -3
  86. package/src/scripts/_cli/cmd_refresh.ts +29 -0
  87. package/src/scripts/_cli/cmd_session_recycle.ts +40 -2
  88. package/src/scripts/_lib/agent_settings.ts +39 -25
  89. package/src/scripts/_lib/environment_detector.ts +52 -1
  90. package/src/scripts/_lib/hook_settings.ts +79 -0
  91. package/src/scripts/_lib/ui_surface.ts +97 -0
  92. package/src/scripts/ai_council/config.ts +62 -24
  93. package/src/scripts/before_complete_hook.ts +39 -0
  94. package/src/scripts/capture_skill_catalogue.ts +483 -0
  95. package/src/scripts/check_bridge_derivation.ts +89 -3
  96. package/src/scripts/check_ci_local_parity.ts +74 -0
  97. package/src/scripts/check_references.ts +15 -1
  98. package/src/scripts/condense.ts +16 -2
  99. package/src/scripts/conformance_scan.ts +200 -9
  100. package/src/scripts/council_cli.ts +95 -3
  101. package/src/scripts/hook_manifest.yaml +30 -9
  102. package/src/scripts/hooks/block_no_verify.ts +82 -9
  103. package/src/scripts/hooks/concern_registry.ts +2 -0
  104. package/src/scripts/hooks/design_slop_hook.ts +1 -1
  105. package/src/scripts/hooks/turn_end_gate_hook.ts +138 -105
  106. package/src/scripts/hooks/ui_route_nudge_hook.ts +250 -0
  107. package/src/scripts/lint_roadmap_complexity.ts +60 -0
  108. package/src/scripts/lint_rule_skill_pack_reach.ts +263 -0
  109. package/src/scripts/probe_promissory_closing.ts +180 -0
  110. package/src/scripts/probe_session_canary.ts +180 -0
  111. package/src/scripts/report_consultation_rate.ts +358 -0
  112. package/src/scripts/report_skill_activation.ts +5 -1
  113. package/src/server/schemas/settings.ts +14 -13
  114. /package/dist/agent-src/skills/design-system-capture/{reference → references}/design-system-json.md +0 -0
  115. /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/ev-fixtures.md +0 -0
  116. /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/odds-and-bonus.md +0 -0
  117. /package/dist/agent-src/skills/react-shadcn-ui/{reference → references}/registry.md +0 -0
@@ -0,0 +1,483 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * capture_skill_catalogue — make the skill-catalogue delivery defect countable.
4
+ *
5
+ * THE HOST BOUNDARY, STATED FIRST BECAUSE IT DECIDES THE SHAPE OF THIS TOOL.
6
+ * The round-6 census asked for a `session_start` concern that logs the injected
7
+ * catalogue block. A concern cannot do that. The dispatcher envelope carries
8
+ * `session_id`, `source`, `cwd`, `transcript_path` — the injected catalogue is
9
+ * part of the request's system payload, and `preamble_byte_census.ts` already
10
+ * verified independently that no local transcript or file carries it. A hook
11
+ * that "captures the catalogue" would therefore be capturing something else and
12
+ * reporting it under that name, which is worse than not measuring.
13
+ *
14
+ * So the capture is deliberately two-sided, and each side is labelled with what
15
+ * it actually is:
16
+ *
17
+ * PROJECTION (deterministic) — what the projected skill tree offers the host:
18
+ * per entry, whether a `description:` exists, how long it is, which
19
+ * frontmatter keys it carries, and its position in the sorted catalogue. This
20
+ * is the host's INPUT and it is fully file-measurable.
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.
26
+ *
27
+ * The join of the two answers the question the census could not: not "how many
28
+ * are bare" but WHICH PROPERTY separates bare from described. That is the
29
+ * selector, and it is what a delivery fix has to act on. A positional
30
+ * head-N hypothesis is one candidate among several and this tool is built to
31
+ * falsify it rather than assume it — the first observation recorded against
32
+ * this repo showed entries far past position 40 carrying full descriptions
33
+ * while an earlier one was bare, which no head-N budget explains.
34
+ *
35
+ * 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.
40
+ *
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).
43
+ */
44
+
45
+ import * as fs from 'node:fs';
46
+ import * as path from 'node:path';
47
+ import { fileURLToPath } from 'node:url';
48
+
49
+ const _HERE = fileURLToPath(import.meta.url);
50
+ const REPO = path.resolve(path.dirname(_HERE), '..', '..');
51
+
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';
96
+ }
97
+
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);
104
+ }
105
+
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]!);
116
+ }
117
+ return keys;
118
+ }
119
+
120
+ /**
121
+ * The declared description with surrounding quotes stripped, or "".
122
+ *
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.
128
+ */
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
+ }
149
+
150
+ return rest.replace(/^["']|["']$/g, '');
151
+ }
152
+ return '';
153
+ }
154
+
155
+ /**
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.
159
+ */
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
+ }
185
+
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;
194
+ }
195
+
196
+ /**
197
+ * Join the projection against an observation and report which property, if
198
+ * any, separates the bare entries from the described ones.
199
+ *
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.
205
+ */
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,
232
+ };
233
+
234
+ if (bare.length === 0 || described.length === 0) {
235
+ return {
236
+ ...base,
237
+ candidates: [],
238
+ verdict: 'insufficient-observation' as const,
239
+ };
240
+ }
241
+
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
+ });
301
+ }
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[];
323
+ }
324
+
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
+ };
341
+ }
342
+
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.',
359
+ );
360
+ return lines.join('\n');
361
+ }
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}`);
367
+ }
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
+
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}`);
381
+ }
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;
387
+ }
388
+ throw new Error(
389
+ `no catalogue root found — tried ${DEFAULT_CATALOGUE_ROOTS.join(', ')} under ${REPO}`,
390
+ );
391
+ }
392
+
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]!;
397
+ }
398
+
399
+ function main(): number {
400
+ const root = resolveCatalogueRoot(argValue('--catalogue-root'));
401
+ const projected = readProjectedCatalogue(root);
402
+
403
+ if (projected.length === 0) {
404
+ process.stderr.write(
405
+ `❌ catalogue root ${root} holds no SKILL.md entries — a scan that found nothing is not a clean result\n`,
406
+ );
407
+ return 1;
408
+ }
409
+
410
+ const observedPath = argValue('--observed');
411
+ if (!observedPath) {
412
+ // Projection-only mode: the deterministic half, for a baseline snapshot.
413
+ const withDescription = projected.filter((e) => e.hasDescription).length;
414
+ const payload = {
415
+ catalogue_root: path.relative(REPO, root) || root,
416
+ entries_total: projected.length,
417
+ declares_description: withDescription,
418
+ declares_none: projected.length - withDescription,
419
+ };
420
+ if (process.argv.includes('--json')) {
421
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
422
+ } else {
423
+ process.stdout.write(
424
+ `catalogue root: ${payload.catalogue_root}\nentries: ${payload.entries_total}\ndeclares a description: ${payload.declares_description}\ndeclares none: ${payload.declares_none}\n\nThis is the PROJECTION half only — what the host is offered. Which entries actually\narrived bare is a live-session observation; pass it with --observed <file>.\n`,
425
+ );
426
+ }
427
+ return 0;
428
+ }
429
+
430
+ const readNames = (p: string): string[] =>
431
+ fs
432
+ .readFileSync(p, 'utf-8')
433
+ .split('\n')
434
+ .map((l) => l.trim())
435
+ .filter((l) => l.length > 0 && !l.startsWith('#'));
436
+
437
+ const observedBare = readNames(observedPath);
438
+
439
+ // A PARTIAL observation must supply both lists. Without `--described` the
440
+ // analyzer treats every non-bare entry as observed-described, which is only
441
+ // true of a whole-catalogue read; on a partial one it would silently invent
442
+ // hundreds of observations nobody made.
443
+ const describedPath = argValue('--described');
444
+ const observedDescribed = describedPath ? readNames(describedPath) : undefined;
445
+
446
+ const report: SelectorReport = {
447
+ catalogueRoot: path.relative(REPO, root) || root,
448
+ ...analyzeSelector(projected, observedBare, observedDescribed),
449
+ };
450
+
451
+ if (process.argv.includes('--json')) {
452
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
453
+ } else {
454
+ process.stdout.write(`${formatReport(report)}\n`);
455
+ }
456
+
457
+ const host = argValue('--host') ?? 'unknown';
458
+ const stampedAt = argValue('--observed-at');
459
+ if (process.argv.includes('--record')) {
460
+ if (!stampedAt) {
461
+ process.stderr.write('❌ --record requires --observed-at <ISO date>\n');
462
+ return 1;
463
+ }
464
+ const logPath = path.join(REPO, OBSERVATION_LOG);
465
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
466
+ fs.appendFileSync(
467
+ logPath,
468
+ `${JSON.stringify(buildObservationRecord(report, host, stampedAt))}\n`,
469
+ );
470
+ process.stdout.write(`\nrecorded → ${OBSERVATION_LOG}\n`);
471
+ }
472
+
473
+ return 0;
474
+ }
475
+
476
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(_HERE)) {
477
+ try {
478
+ process.exit(main());
479
+ } catch (error) {
480
+ process.stderr.write(`❌ ${(error as Error).message}\n`);
481
+ process.exit(1);
482
+ }
483
+ }
@@ -16,6 +16,11 @@
16
16
  * changed entry means the tree on disk did not derive from the current
17
17
  * source (hand-edit, stale generation, or a nondeterministic generator).
18
18
  *
19
+ * Both legs are scoped to the roots this checkout's generator actually writes:
20
+ * a root whose owning tool is deactivated in `agents/.agent-tools.yml` has no
21
+ * regeneration path, so nothing found there is drift it could repair. Skipped
22
+ * roots are named on stderr, never dropped silently.
23
+ *
19
24
  * Run AFTER `task generate-tools` (CI: the sync-consistency job). Exit 1 on
20
25
  * the first drifted artefact, naming it.
21
26
  */
@@ -26,6 +31,7 @@ import * as path from 'node:path';
26
31
  import { fileURLToPath } from 'node:url';
27
32
 
28
33
  import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
34
+ import { active_tools_at } from './condense.js';
29
35
 
30
36
  const _HERE = fileURLToPath(import.meta.url);
31
37
  const REPO_ROOT = path.join(path.dirname(_HERE), '..', '..');
@@ -50,6 +56,59 @@ export const BRIDGE_ROOTS: readonly string[] = [
50
56
  /** Repo-relative prefixes a bridge symlink may resolve into. */
51
57
  const DERIVATION_SOURCES = ['dist/agent-src', 'src', 'AGENTS.md'] as const;
52
58
 
59
+ /**
60
+ * Bridge root → the tool id whose activation makes the generator maintain it.
61
+ *
62
+ * A root ABSENT from this map is maintained unconditionally, and `.augment` is
63
+ * the one that matters: `project_to_augment()` carries no tool gate, so mapping
64
+ * it to `augment` would silence the audit on a tree that is regenerated on
65
+ * every run — a strict weakening. Absent-means-unconditional keeps that the
66
+ * default, so a future bridge root has to opt IN to being skippable.
67
+ */
68
+ const ROOT_TOOL_ID: Readonly<Record<string, string>> = {
69
+ '.claude/rules': 'claude-code',
70
+ '.claude/skills': 'claude-code',
71
+ '.claude/agents': 'claude-code',
72
+ '.claude/personas': 'claude-code',
73
+ '.claude/user-types': 'claude-code',
74
+ '.cursor/rules': 'cursor',
75
+ '.cursor/commands': 'cursor',
76
+ '.windsurf/rules': 'windsurf',
77
+ '.windsurf/workflows': 'windsurf',
78
+ '.windsurfrules': 'windsurf',
79
+ '.clinerules': 'cline',
80
+ 'GEMINI.md': 'gemini',
81
+ };
82
+
83
+ /**
84
+ * Split `roots` into the ones this checkout's generator maintains and the ones
85
+ * it does not, given the active tool set (`null` = all tools active).
86
+ *
87
+ * Auditing a root whose owning tool is inactive is not a drift check: the
88
+ * generator never writes it, so whatever sits there is an unmaintained fossil
89
+ * with no regeneration path. Reporting it leaves hand-deletion as the only
90
+ * compliant action, which is exactly what `check_rule_projection_integrity`
91
+ * rejected as "not a gate" after the same masked-config shape broke the 9.27.0
92
+ * release. This one broke 9.36.0 the same way: `agents/.agent-tools.yml` is
93
+ * committed with all eight tools but carries `skip-worktree` locally and reads
94
+ * `tools: []`, so `.claude/skills/` had not been regenerated since 2026-07-05
95
+ * and still held symlinks to a directory renamed since.
96
+ */
97
+ export function partition_roots(
98
+ roots: readonly string[],
99
+ active: ReadonlySet<string> | null,
100
+ ): { audited: string[]; skipped: string[] } {
101
+ if (active === null) return { audited: [...roots], skipped: [] };
102
+ const audited: string[] = [];
103
+ const skipped: string[] = [];
104
+ for (const r of roots) {
105
+ const tool = ROOT_TOOL_ID[r];
106
+ if (tool === undefined || active.has(tool)) audited.push(r);
107
+ else skipped.push(r);
108
+ }
109
+ return { audited, skipped };
110
+ }
111
+
53
112
  function _walk(p: string, out: string[]): void {
54
113
  const st = fs.lstatSync(p);
55
114
  if (st.isSymbolicLink() || st.isFile()) {
@@ -132,7 +191,22 @@ export function validate_symlinks(root: string, repoRoot: string): string[] {
132
191
 
133
192
  export function run(repoRoot: string): string[] {
134
193
  const errors: string[] = [];
135
- const roots = BRIDGE_ROOTS.map((r) => path.join(repoRoot, r));
194
+ const { audited, skipped } = partition_roots(BRIDGE_ROOTS, active_tools_at(repoRoot));
195
+
196
+ // LOUD, never a silent narrowing: the audited surface just shrank, and a
197
+ // reader who cannot see which roots dropped out cannot distinguish this run
198
+ // from a full one — that indistinguishability is the false green
199
+ // `_lib/scan_scope.ts` exists to prevent.
200
+ if (skipped.length > 0) {
201
+ process.stderr.write(
202
+ `⚠️ check_bridge_derivation: ${skipped.length} bridge root(s) NOT audited — ` +
203
+ `agents/.agent-tools.yml deactivates the owning tool, so the generator does ` +
204
+ `not write them in this checkout and their contents cannot be attributed as ` +
205
+ `drift: ${skipped.join(', ')}. CI activates all eight tools.\n`,
206
+ );
207
+ }
208
+
209
+ const roots = audited.map((r) => path.join(repoRoot, r));
136
210
 
137
211
  const before: Record<string, Record<string, string>> = {};
138
212
  for (const r of roots) before[r] = snapshot_tree(r);
@@ -141,11 +215,17 @@ export function run(repoRoot: string): string[] {
141
215
  // there is nothing to diff and nothing to validate, and the check reports a
142
216
  // fixpoint it never tested. Thrown, not returned — `errors` is the drift
143
217
  // channel, and a dead scope is not drift.
218
+ //
219
+ // No `allowEmpty` twin of the sibling gate's is needed here, and that is a
220
+ // property of the map rather than an omission: `.augment` is maintained
221
+ // unconditionally, so `audited` is never empty and zero entries always means
222
+ // a genuinely dead surface (no `dist/agent-src/` at all), not a deactivated
223
+ // tool. Map `.augment` to a tool and this reasoning stops holding.
144
224
  assertScanned({
145
225
  gate: 'check_bridge_derivation',
146
226
  scanned: Object.values(before).reduce((n, snap) => n + Object.keys(snap).length, 0),
147
227
  units: 'bridge entries',
148
- roots: BRIDGE_ROOTS,
228
+ roots: audited,
149
229
  });
150
230
 
151
231
  // Regenerate in a child process so MODULE_STATE resolves exactly as the
@@ -188,6 +268,12 @@ if (isMain) {
188
268
  for (const e of errors) process.stderr.write(`❌ ${e}\n`);
189
269
  process.exit(1);
190
270
  }
191
- process.stdout.write('✅ bridge trees derive from src/ (symlink targets valid, regeneration is a fixpoint)\n');
271
+ // Name the count rather than "bridge trees": with a tool deactivated the
272
+ // green covers fewer roots than the reader would assume from a bare tick.
273
+ const n = partition_roots(BRIDGE_ROOTS, active_tools_at(root)).audited.length;
274
+ process.stdout.write(
275
+ `✅ ${n}/${BRIDGE_ROOTS.length} bridge roots derive from src/ ` +
276
+ '(symlink targets valid, regeneration is a fixpoint)\n',
277
+ );
192
278
  process.exit(0);
193
279
  }