@event4u/agent-config 9.35.0 → 9.36.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 (78) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +61 -165
  3. package/dist/agent-src/commands/analyze/reference-repo.md +135 -42
  4. package/dist/agent-src/contexts/augment-infrastructure.md +1 -1
  5. package/dist/agent-src/rules/design-review-after-ui-write.md +53 -6
  6. package/dist/agent-src/rules/improve-before-implement.md +1 -1
  7. package/dist/agent-src/rules/ui-audit-gate.md +35 -0
  8. package/dist/agent-src/skills/design-system-capture/SKILL.md +2 -2
  9. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +1 -1
  10. package/dist/agent-src/skills/fe-design/SKILL.md +84 -12
  11. package/dist/agent-src/skills/fe-design/evals/triggers.json +11 -3
  12. package/dist/agent-src/skills/prediction-pool-optimizer/SKILL.md +5 -5
  13. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
  14. package/dist/agent-src/skills/skill-writing/SKILL.md +24 -0
  15. package/dist/agent-src/skills/subagent-orchestration/prompts/README.md +29 -0
  16. package/dist/agent-src/templates/AGENTS.md +1 -1
  17. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  18. package/dist/cli-delegate/{chunk-ZAXH3ZWU.js → chunk-JP53LNVT.js} +9 -0
  19. package/dist/cli-delegate/{chunk-736JCWLM.js → chunk-M2DPGFZ4.js} +10 -0
  20. package/dist/cli-delegate/{chunk-H6F2CQFI.js → chunk-NR7HSN3P.js} +2 -2
  21. package/dist/cli-delegate/{chunk-2TGMGLBL.js → chunk-SFGARMAB.js} +11 -1
  22. package/dist/cli-delegate/{chunk-DS6BLDMG.js → chunk-U7CMAIOC.js} +7 -2
  23. package/dist/cli-delegate/cmd_conformance.js +3 -3
  24. package/dist/cli-delegate/cmd_doctor.js +2 -2
  25. package/dist/cli-delegate/cmd_export.js +3 -3
  26. package/dist/cli-delegate/cmd_migrate.js +1 -1
  27. package/dist/cli-delegate/cmd_prune.js +3 -3
  28. package/dist/cli-delegate/cmd_refresh.js +1 -1
  29. package/dist/cli-delegate/cmd_session_recycle.js +8 -1
  30. package/dist/cli-delegate/cmd_settings_migrate.js +3 -3
  31. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  32. package/dist/cli-delegate/cmd_sync.js +3 -3
  33. package/dist/cli-delegate/cmd_uninstall.js +3 -3
  34. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  35. package/dist/cli-delegate/cmd_validate.js +3 -3
  36. package/dist/discovery/deprecation-report.md +1 -1
  37. package/dist/discovery/discovery-manifest.json +12 -12
  38. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  39. package/dist/discovery/discovery-manifest.summary.md +1 -1
  40. package/dist/discovery/dormancy-report.md +1 -1
  41. package/dist/discovery/orphan-report.md +1 -1
  42. package/dist/discovery/packs.json +2 -2
  43. package/dist/discovery/trust-report.md +1 -1
  44. package/dist/discovery/workspaces.json +2 -2
  45. package/dist/hooks/dispatch.js +530 -323
  46. package/dist/install/install.mjs +18 -4
  47. package/dist/mcp/registry-manifest.json +1 -1
  48. package/dist/mcp/server.json +2 -2
  49. package/dist/mcp/server.mjs +25 -5
  50. package/dist/router.json +1 -1
  51. package/dist/server/schemas/settings.js +5 -0
  52. package/dist/server/schemas/settings.js.map +1 -1
  53. package/docs/contracts/settings-classes.md +4 -2
  54. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +46 -4
  55. package/package.json +2 -2
  56. package/src/agent-src/templates/AGENTS.md +1 -1
  57. package/src/config/agent-settings.template.yml +32 -0
  58. package/src/config/gate-coverage.yml +12 -0
  59. package/src/scripts/_cli/cmd_doctor.ts +32 -0
  60. package/src/scripts/_cli/cmd_migrate.ts +26 -3
  61. package/src/scripts/_cli/cmd_refresh.ts +29 -0
  62. package/src/scripts/_cli/cmd_session_recycle.ts +40 -2
  63. package/src/scripts/_lib/hook_settings.ts +79 -0
  64. package/src/scripts/_lib/ui_surface.ts +97 -0
  65. package/src/scripts/capture_skill_catalogue.ts +483 -0
  66. package/src/scripts/hook_manifest.yaml +18 -3
  67. package/src/scripts/hooks/concern_registry.ts +2 -0
  68. package/src/scripts/hooks/design_slop_hook.ts +1 -1
  69. package/src/scripts/hooks/ui_route_nudge_hook.ts +250 -0
  70. package/src/scripts/lint_roadmap_complexity.ts +60 -0
  71. package/src/scripts/lint_rule_skill_pack_reach.ts +263 -0
  72. package/src/scripts/report_consultation_rate.ts +358 -0
  73. package/src/scripts/report_skill_activation.ts +5 -1
  74. package/src/server/schemas/settings.ts +9 -0
  75. /package/dist/agent-src/skills/design-system-capture/{reference → references}/design-system-json.md +0 -0
  76. /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/ev-fixtures.md +0 -0
  77. /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/odds-and-bonus.md +0 -0
  78. /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
+ }
@@ -152,6 +152,21 @@ concerns:
152
152
  args: []
153
153
  fail_closed: false
154
154
  severity: advisory
155
+ # road-to-frontend-skill-application Phase 4 — OPTIONAL PreToolUse UI-route
156
+ # nudge. On a Write/Edit to a UI surface with no design consultation latched
157
+ # this session, WARNS (exit 2) naming the route; a read or search touching a
158
+ # design surface latches consultation and silences it. Anti-loop: at most 2
159
+ # nudges per session. It does NOT read the two UI rules — the UI-surface
160
+ # decision comes from _lib/ui_surface.ts — so it runs parallel to them rather
161
+ # than consuming their triggers, and it is a nudge, never a block, so
162
+ # "enforced_by: none" on those rules stays honest.
163
+ # Default-OFF (hooks.ui_route_nudge.enabled).
164
+ # fail_closed: false.
165
+ ui-route-nudge:
166
+ script: src/scripts/hooks/ui_route_nudge_hook.ts
167
+ args: []
168
+ fail_closed: false
169
+ severity: advisory
155
170
  # ADR-124 Phase 4 — OPTIONAL PreToolUse code-graph nudge. When a code-graph
156
171
  # source is present and the agent is about to search/read source, WARNS
157
172
  # (exit 2) once per session to query the graph first (or rebuild if stale,
@@ -528,7 +543,7 @@ platforms:
528
543
  session_start: [chat-history, hot-context, handoff-context, session-canary, council-availability, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness, surface-probe, session-register]
529
544
  session_end: [chat-history, memory-learn, session-register]
530
545
  stop: [chat-history, hot-context, verify-before-complete, self-repair, session-register]
531
- pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, code-graph-nudge, reread-guard]
546
+ pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, ui-route-nudge, code-graph-nudge, reread-guard]
532
547
  post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record, edit-shape]
533
548
 
534
549
  claude:
@@ -536,7 +551,7 @@ platforms:
536
551
  session_end: [chat-history, memory-learn, session-register]
537
552
  stop: [chat-history, hot-context, verify-before-complete, team-review-gate, end-review-nudge, turn-end-gate, self-repair, session-register, session-eol]
538
553
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, delegation-nudge, git-authorization, session-canary, self-repair, session-register]
539
- pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, code-graph-nudge, reread-guard]
554
+ pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, ui-route-nudge, code-graph-nudge, reread-guard]
540
555
  post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record, edit-shape, language-mirror]
541
556
  # round-5 § 6.1 — the ONLY binding on this slot in the tree. `pre_compact`
542
557
  # sets a pin-lost marker; the `post_tool_use` entry above re-emits the pin
@@ -575,7 +590,7 @@ platforms:
575
590
  # still costs the dispatch, and its entire output is thrown away.
576
591
  stop: [chat-history, hot-context, verify-before-complete, self-repair, session-register]
577
592
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, git-authorization, session-canary, self-repair, session-register]
578
- pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, code-graph-nudge, reread-guard]
593
+ pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, ui-route-nudge, code-graph-nudge, reread-guard]
579
594
  post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record, edit-shape]
580
595
 
581
596
  # Phase 7.5 — Cursor. `.cursor/hooks.json` (project) is read by the
@@ -54,6 +54,7 @@ import { main as blockConfigWeakeningMain } from './block_config_weakening.js';
54
54
  import { main as rtkWrapMain } from './rtk_wrap_hook.js';
55
55
  import { main as designSlopMain } from './design_slop_hook.js';
56
56
  import { main as codeGraphNudgeMain } from './code_graph_nudge_hook.js';
57
+ import { main as uiRouteNudgeMain } from './ui_route_nudge_hook.js';
57
58
  import { main as orchestrationRecordMain } from './orchestration_record_hook.js';
58
59
  import { main as delegationNudgeMain } from './delegation_nudge_hook.js';
59
60
  import { main as endReviewNudgeMain } from './end_review_nudge_hook.js';
@@ -97,6 +98,7 @@ export const CONCERN_REGISTRY: Readonly<Record<string, ConcernMain>> = {
97
98
  'src/scripts/hooks/rtk_wrap_hook.ts': rtkWrapMain as ConcernMain,
98
99
  'src/scripts/hooks/design_slop_hook.ts': designSlopMain as ConcernMain,
99
100
  'src/scripts/hooks/code_graph_nudge_hook.ts': codeGraphNudgeMain as ConcernMain,
101
+ 'src/scripts/hooks/ui_route_nudge_hook.ts': uiRouteNudgeMain as ConcernMain,
100
102
  'src/scripts/hooks/orchestration_record_hook.ts': orchestrationRecordMain as ConcernMain,
101
103
  'src/scripts/hooks/delegation_nudge_hook.ts': delegationNudgeMain as ConcernMain,
102
104
  'src/scripts/hooks/end_review_nudge_hook.ts': endReviewNudgeMain as ConcernMain,
@@ -23,6 +23,7 @@ import fs from "node:fs";
23
23
  import path from "node:path";
24
24
  import { fileURLToPath, pathToFileURL } from "node:url";
25
25
  import { type DesignContext } from "../design_slop_rules.js";
26
+ import { UI_EXT } from "../_lib/ui_surface.js";
26
27
  import { loadDesignContext, scanFile } from "../lint_design_slop.js";
27
28
  import { readHookStdin } from "./hook_stdin.js";
28
29
 
@@ -30,7 +31,6 @@ const SETTINGS_FILE = ".agent-settings.yml";
30
31
  const EXIT_ALLOW = 0;
31
32
  const EXIT_WARN = 2;
32
33
  const DEGRADE_AFTER = 3; // surfaces of the same file::rule signature before going silent
33
- const UI_EXT = /\.(html|htm|css|scss|sass|less|vue|svelte|astro|jsx|tsx)$/i;
34
34
 
35
35
  type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
36
36
  type JsonObject = { [k: string]: JsonValue };