@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +61 -165
- package/dist/agent-src/commands/analyze/reference-repo.md +135 -42
- package/dist/agent-src/contexts/augment-infrastructure.md +1 -1
- package/dist/agent-src/rules/design-review-after-ui-write.md +53 -6
- package/dist/agent-src/rules/improve-before-implement.md +1 -1
- package/dist/agent-src/rules/ui-audit-gate.md +35 -0
- package/dist/agent-src/skills/design-system-capture/SKILL.md +2 -2
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +1 -1
- package/dist/agent-src/skills/fe-design/SKILL.md +84 -12
- package/dist/agent-src/skills/fe-design/evals/triggers.json +11 -3
- package/dist/agent-src/skills/prediction-pool-optimizer/SKILL.md +5 -5
- package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
- package/dist/agent-src/skills/skill-writing/SKILL.md +24 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/README.md +29 -0
- package/dist/agent-src/templates/AGENTS.md +1 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/{chunk-ZAXH3ZWU.js → chunk-JP53LNVT.js} +9 -0
- package/dist/cli-delegate/{chunk-736JCWLM.js → chunk-M2DPGFZ4.js} +10 -0
- package/dist/cli-delegate/{chunk-H6F2CQFI.js → chunk-NR7HSN3P.js} +2 -2
- package/dist/cli-delegate/{chunk-2TGMGLBL.js → chunk-SFGARMAB.js} +11 -1
- package/dist/cli-delegate/{chunk-DS6BLDMG.js → chunk-U7CMAIOC.js} +7 -2
- package/dist/cli-delegate/cmd_conformance.js +3 -3
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_export.js +3 -3
- package/dist/cli-delegate/cmd_migrate.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +3 -3
- package/dist/cli-delegate/cmd_refresh.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +8 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +3 -3
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +3 -3
- package/dist/cli-delegate/cmd_uninstall.js +3 -3
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +3 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +12 -12
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +530 -323
- package/dist/install/install.mjs +18 -4
- package/dist/mcp/registry-manifest.json +1 -1
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +25 -5
- package/dist/router.json +1 -1
- package/dist/server/schemas/settings.js +5 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/contracts/settings-classes.md +4 -2
- package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +46 -4
- package/package.json +2 -2
- package/src/agent-src/templates/AGENTS.md +1 -1
- package/src/config/agent-settings.template.yml +32 -0
- package/src/config/gate-coverage.yml +12 -0
- package/src/scripts/_cli/cmd_doctor.ts +32 -0
- package/src/scripts/_cli/cmd_migrate.ts +26 -3
- package/src/scripts/_cli/cmd_refresh.ts +29 -0
- package/src/scripts/_cli/cmd_session_recycle.ts +40 -2
- package/src/scripts/_lib/hook_settings.ts +79 -0
- package/src/scripts/_lib/ui_surface.ts +97 -0
- package/src/scripts/capture_skill_catalogue.ts +483 -0
- package/src/scripts/hook_manifest.yaml +18 -3
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/design_slop_hook.ts +1 -1
- package/src/scripts/hooks/ui_route_nudge_hook.ts +250 -0
- package/src/scripts/lint_roadmap_complexity.ts +60 -0
- package/src/scripts/lint_rule_skill_pack_reach.ts +263 -0
- package/src/scripts/report_consultation_rate.ts +358 -0
- package/src/scripts/report_skill_activation.ts +5 -1
- package/src/server/schemas/settings.ts +9 -0
- /package/dist/agent-src/skills/design-system-capture/{reference → references}/design-system-json.md +0 -0
- /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/ev-fixtures.md +0 -0
- /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/odds-and-bonus.md +0 -0
- /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 };
|