@event4u/agent-config 9.34.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 (112) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +64 -77
  3. package/CONTRIBUTING.md +23 -14
  4. package/dist/agent-src/commands/analyze/reference-repo.md +135 -42
  5. package/dist/agent-src/commands/roadmap/next.md +37 -5
  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/ui-audit-gate.md +35 -0
  10. package/dist/agent-src/scripts/roadmap_gates.ts +119 -4
  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/skills/testing-anti-patterns/SKILL.md +25 -0
  20. package/dist/agent-src/templates/AGENTS.md +1 -1
  21. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  22. package/dist/agent-src/templates/scripts/memory_lookup.ts +1 -2
  23. package/dist/agent-src/templates/scripts/memory_signal.ts +2 -4
  24. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
  25. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
  26. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
  27. package/dist/cli/registry.js +2 -2
  28. package/dist/cli/registry.js.map +1 -1
  29. package/dist/cli-delegate/{chunk-YPAWXXXR.js → chunk-55RI5UXK.js} +17 -0
  30. package/dist/cli-delegate/{chunk-UVKNIJH2.js → chunk-JP53LNVT.js} +12 -0
  31. package/dist/cli-delegate/{chunk-736JCWLM.js → chunk-M2DPGFZ4.js} +10 -0
  32. package/dist/cli-delegate/{chunk-MA4GFWDY.js → chunk-NR7HSN3P.js} +2 -2
  33. package/dist/cli-delegate/{chunk-REMEZSVP.js → chunk-SFGARMAB.js} +13 -3
  34. package/dist/cli-delegate/{chunk-DS6BLDMG.js → chunk-U7CMAIOC.js} +7 -2
  35. package/dist/cli-delegate/cmd_conformance.js +4 -4
  36. package/dist/cli-delegate/cmd_doctor.js +3 -3
  37. package/dist/cli-delegate/cmd_export.js +3 -3
  38. package/dist/cli-delegate/cmd_handoff.js +1 -1
  39. package/dist/cli-delegate/cmd_migrate.js +1 -1
  40. package/dist/cli-delegate/cmd_prune.js +3 -3
  41. package/dist/cli-delegate/cmd_refresh.js +1 -1
  42. package/dist/cli-delegate/cmd_session_recycle.js +8 -1
  43. package/dist/cli-delegate/cmd_settings_migrate.js +3 -3
  44. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  45. package/dist/cli-delegate/cmd_sync.js +3 -3
  46. package/dist/cli-delegate/cmd_uninstall.js +3 -3
  47. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  48. package/dist/cli-delegate/cmd_validate.js +3 -3
  49. package/dist/discovery/deprecation-report.md +1 -1
  50. package/dist/discovery/discovery-manifest.json +14 -14
  51. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  52. package/dist/discovery/discovery-manifest.summary.md +1 -1
  53. package/dist/discovery/dormancy-report.md +1 -1
  54. package/dist/discovery/orphan-report.md +1 -1
  55. package/dist/discovery/packs.json +2 -2
  56. package/dist/discovery/trust-report.md +1 -1
  57. package/dist/discovery/workspaces.json +2 -2
  58. package/dist/hooks/dispatch.js +789 -401
  59. package/dist/install/install.mjs +21 -4
  60. package/dist/mcp/registry-manifest.json +2 -2
  61. package/dist/mcp/server.json +2 -2
  62. package/dist/mcp/server.mjs +99 -7
  63. package/dist/router.json +1 -1
  64. package/dist/server/schemas/settings.js +6 -0
  65. package/dist/server/schemas/settings.js.map +1 -1
  66. package/docs/contracts/settings-classes.md +5 -2
  67. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +46 -4
  68. package/package.json +2 -2
  69. package/src/agent-src/scripts/roadmap_gates.ts +119 -4
  70. package/src/agent-src/templates/AGENTS.md +1 -1
  71. package/src/agent-src/templates/scripts/memory_lookup.ts +1 -2
  72. package/src/agent-src/templates/scripts/memory_signal.ts +2 -4
  73. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
  74. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
  75. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
  76. package/src/config/agent-settings.template.yml +43 -2
  77. package/src/config/gate-coverage.yml +12 -0
  78. package/src/config/quorum-attendance-budget.json +10 -2
  79. package/src/scripts/_cli/cmd_doctor.ts +32 -0
  80. package/src/scripts/_cli/cmd_migrate.ts +26 -3
  81. package/src/scripts/_cli/cmd_refresh.ts +29 -0
  82. package/src/scripts/_cli/cmd_session_recycle.ts +40 -2
  83. package/src/scripts/_dispatch.bash +6 -2
  84. package/src/scripts/_lib/hook_settings.ts +79 -0
  85. package/src/scripts/_lib/session_register.ts +67 -1
  86. package/src/scripts/_lib/ui_surface.ts +97 -0
  87. package/src/scripts/ai_council/config.ts +60 -0
  88. package/src/scripts/ai_council/events_log.ts +103 -4
  89. package/src/scripts/ai_council/quorum.ts +83 -6
  90. package/src/scripts/capture_skill_catalogue.ts +483 -0
  91. package/src/scripts/council_cli.ts +36 -1
  92. package/src/scripts/hook_manifest.yaml +18 -3
  93. package/src/scripts/hooks/concern_registry.ts +2 -0
  94. package/src/scripts/hooks/design_slop_hook.ts +1 -1
  95. package/src/scripts/hooks/turn_end_gate_hook.ts +172 -11
  96. package/src/scripts/hooks/ui_route_nudge_hook.ts +250 -0
  97. package/src/scripts/lint_framework_leakage.ts +195 -28
  98. package/src/scripts/lint_framework_leakage_allowlist.json +94 -86
  99. package/src/scripts/lint_roadmap_complexity.ts +60 -0
  100. package/src/scripts/lint_rule_skill_pack_reach.ts +263 -0
  101. package/src/scripts/memory_lookup.ts +1 -2
  102. package/src/scripts/report_consultation_rate.ts +358 -0
  103. package/src/scripts/report_skill_activation.ts +5 -1
  104. package/src/scripts/schemas/command.schema.json +4 -0
  105. package/src/scripts/schemas/skill.schema.json +4 -0
  106. package/src/scripts/session_register_hook.ts +238 -18
  107. package/src/scripts/sessions_cli.ts +377 -12
  108. package/src/server/schemas/settings.ts +12 -0
  109. /package/dist/agent-src/skills/design-system-capture/{reference → references}/design-system-json.md +0 -0
  110. /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/ev-fixtures.md +0 -0
  111. /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/odds-and-bonus.md +0 -0
  112. /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
+ }
@@ -76,7 +76,7 @@ import { AuthCache, select_solo_member } from './ai_council/solo_dispatch.js';
76
76
  import { InvalidModeError, resolve_global_mode } from './ai_council/modes.js';
77
77
  import { resolveMemberTransport } from './ai_council/transport_resolver.js';
78
78
  import { absentReasonFromCliFailure, classifyCliFailure, type AbsentReason } from './ai_council/transport_resolver.js';
79
- import { evaluateQuorum, type QuorumResult } from './ai_council/quorum.js';
79
+ import { evaluateQuorum, SOLO_FLOOR_MIN_PRESENT, type QuorumResult } from './ai_council/quorum.js';
80
80
  import {
81
81
  appendEvent,
82
82
  appendQuorumEvent,
@@ -525,6 +525,12 @@ function _synthesize_ai_council_block(cfg: CouncilConfig): Dict {
525
525
  // configured `quorum: <k>`. Additive key — callers that never read
526
526
  // it observe no change.
527
527
  quorum: cfg.quorum,
528
+ // Forwarded for the same reason `quorum` above had to be: a key that
529
+ // `_build_config` validates and this block drops is a key the loader
530
+ // enforces and the runtime never sees. That defect shipped once here
531
+ // already; repeating it for the shadow floor would silently pin every
532
+ // `floor_would_hold` to the default no matter what the operator set.
533
+ quorum_min_present: cfg.quorum_min_present,
528
534
  necessity_classifier: {
529
535
  enabled: cfg.necessity_classifier.enabled,
530
536
  mode: cfg.necessity_classifier.mode,
@@ -647,6 +653,25 @@ function _quorum_setting_from(ai: Dict): QuorumSetting {
647
653
  return 'majority';
648
654
  }
649
655
 
656
+ /**
657
+ * `ai['quorum_min_present']` with the same fail-soft posture as
658
+ * `_quorum_setting_from`: the loader has already validated it on the normal
659
+ * path, and a hand-built dict that bypasses the loader falls back to the
660
+ * ADR-224 default rather than throwing.
661
+ *
662
+ * Failing soft costs nothing on the safety side here, and less than it does
663
+ * for `quorum`: this value feeds a counterfactual boolean that no gate reads,
664
+ * so the worst outcome of a wrong fallback is one mis-recorded telemetry line,
665
+ * never a pass that concluded when it should not have.
666
+ */
667
+ function _quorum_min_present_from(ai: Dict): number {
668
+ const raw = ai['quorum_min_present'];
669
+ if (typeof raw === 'number' && Number.isInteger(raw) && raw >= 1) {
670
+ return raw;
671
+ }
672
+ return SOLO_FLOOR_MIN_PRESENT;
673
+ }
674
+
650
675
  /**
651
676
  * Emit one `quorum_result` line for a resolved quorum.
652
677
  *
@@ -674,6 +699,13 @@ function _emitQuorumEvent(
674
699
  configuredTotal?: number;
675
700
  lens?: string;
676
701
  invocation?: string;
702
+ /**
703
+ * The ADR-224 shadow floor for this pass. `gateClass` is deliberately
704
+ * NOT in this context object: no CLI path declares itself gate-class,
705
+ * so a parameter here would be one no caller ever sets, and its
706
+ * absence is what the `false` on every line honestly records.
707
+ */
708
+ minPresent?: number;
677
709
  },
678
710
  ): void {
679
711
  appendQuorumEvent({
@@ -684,6 +716,7 @@ function _emitQuorumEvent(
684
716
  dispatch: ctx.dispatch ?? 'full',
685
717
  configuredTotal: ctx.configuredTotal ?? quorum.total,
686
718
  result: quorum,
719
+ ...(ctx.minPresent !== undefined ? { minPresent: ctx.minPresent } : {}),
687
720
  absent: absent.map(
688
721
  (a): QuorumAbsence => ({
689
722
  member: String(a['member'] ?? ''),
@@ -1009,6 +1042,7 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
1009
1042
  // reach this line and neither is a pass.
1010
1043
  _emitQuorumEvent('pre_run', quorum_out.result, absent_entries, {
1011
1044
  command: opts.command ?? 'run',
1045
+ minPresent: _quorum_min_present_from(ai),
1012
1046
  });
1013
1047
  }
1014
1048
  if (members.length === 0) {
@@ -2646,6 +2680,7 @@ function cmd_run(
2646
2680
  // and the solo-conclusion rate cannot make the one distinction it
2647
2681
  // exists for.
2648
2682
  dispatch: dispatch_shape,
2683
+ minPresent: _quorum_min_present_from(ai_cfg),
2649
2684
  ...(configured_total !== null ? { configuredTotal: configured_total } : {}),
2650
2685
  lens: question.mode,
2651
2686
  invocation: String(_getattr(args, 'invocation', 'agent')),
@@ -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 };