@@ -26,9 +26,73 @@ import path from 'path';
2626
2727const REPO_ROOT = path . resolve ( __dirname , '../../..' ) ;
2828const SKILLS_DIR = path . resolve ( REPO_ROOT , 'skills' ) ;
29+ /** The file a child of the skills root must CARRY to be a skill (`:main` admits on it). */
30+ const ENTRYPOINT_FILE = 'SKILL.md' ;
2931const README = path . resolve ( SKILLS_DIR , 'README.md' ) ;
3032const GUIDE = path . resolve ( REPO_ROOT , 'content/docs/ai/skills-reference.mdx' ) ;
3133
34+ /**
35+ * The population this script READS, declared for `scripts/pm/dispatch-gates.mjs`
36+ * — spelled as a LITERAL array because the hint extractor reads source TEXT (a
37+ * declaration computed from `SKILLS_DIR` would build no hint at all;
38+ * `scripts/check-watch-hint-literal.mjs` holds that spelling for every declarer
39+ * in the tree).
40+ *
41+ * ## What was declared before, and why it under-matched
42+ *
43+ * The only path literal this module body spelled was the GUIDE path,
44+ * `content/docs/ai/skills-reference.mdx` — an OUTPUT. So `check:skill-docs`
45+ * declared its generated artifact and not one of its inputs: a card editing a
46+ * skill entrypoint — the file whose frontmatter is the entire catalog — was
47+ * never told that this gate reads it, and `--check` reds in CI on drift the
48+ * derivation could have predicted.
49+ *
50+ * The inputs were not forgotten, they were unspellable AS ONE HINT: `SKILLS_DIR`
51+ * is built with `path.resolve(REPO_ROOT, 'skills')`, and a single-segment
52+ * literal is refused by the extractor as too generic to be a path population.
53+ *
54+ * ## Why NOT the subtree spelling, which is the idiom's usual escape
55+ *
56+ * `scripts/pm/bare-root-worklist.mjs` carries the measured triage for exactly
57+ * this root and refuses the wholesale hint: this script's population is 12 of
58+ * the 50 files tracked under it (24%), so `skills/**` would name this gate for
59+ * 38 files it never opens. That is the REFUSE-WIDE trade, and the worklist
60+ * prices a false wholesale hint as the costlier error.
61+ *
62+ * What the recorded refusal turned on is that no SINGLE spelling reaches both
63+ * halves: `:221` readdirs the root and admits a child only if it carries a
64+ * SKILL.md, while the twelfth file is the root README this script WRITES, which
65+ * sits outside every skill directory. Two literals reach both, and reach
66+ * nothing else — 12 of 12, precise AND complete — which is the declaration
67+ * below and why the row's verdict is withdrawn rather than the declaration.
68+ */
69+ const DECLARED_WATCH_HINTS = [ 'skills/*/SKILL.md' , 'skills/README.md' ] ;
70+
71+ /**
72+ * The declaration above, held against the constants this script really reads
73+ * from. A hand-written path that agreed with the read only on the day it was
74+ * typed is the drift this idiom replaces, so the check derives what it compares
75+ * against from `SKILLS_DIR` and `README` — move either and this throws here, in
76+ * this file, instead of going quiet in a dispatch brief.
77+ */
78+ function assertWatchHintsDeclareTheReadSurface ( ) : void {
79+ const rel = ( abs : string ) => path . relative ( REPO_ROOT , abs ) . split ( path . sep ) . join ( '/' ) ;
80+ const expected = [ `${ rel ( SKILLS_DIR ) } /*/${ ENTRYPOINT_FILE } ` , rel ( README ) ] ;
81+ if ( DECLARED_WATCH_HINTS . join ( '|' ) !== expected . join ( '|' ) ) {
82+ throw new Error (
83+ `build-skill-docs: the declared watch-hint population ${ JSON . stringify ( DECLARED_WATCH_HINTS ) } no longer ` +
84+ `names what this script reads (${ JSON . stringify ( expected ) } ) — update the declaration, as a LITERAL array.` ,
85+ ) ;
86+ }
87+ // …and it stays NARROW. A hint that reached the bare root would be the
88+ // wholesale claim the worklist refused, arriving through a reword.
89+ if ( DECLARED_WATCH_HINTS . some ( ( h ) => h . endsWith ( '/**' ) ) ) {
90+ throw new Error (
91+ `build-skill-docs: the declaration must stay narrower than the root: ${ JSON . stringify ( DECLARED_WATCH_HINTS ) } ` ,
92+ ) ;
93+ }
94+ }
95+
3296// Marker comments delimit the generated region. MDX does not support HTML
3397// comments (`<!-- -->`) — it needs `{/* */}` — so the syntax is per file type.
3498type CommentStyle = 'html' | 'mdx' ;
@@ -78,7 +142,7 @@ interface Skill {
78142}
79143
80144function parseFrontmatter ( name : string , label : string ) : Skill {
81- const file = path . resolve ( SKILLS_DIR , name , 'SKILL.md' ) ;
145+ const file = path . resolve ( SKILLS_DIR , name , ENTRYPOINT_FILE ) ;
82146 const raw = fs . readFileSync ( file , 'utf-8' ) ;
83147 const parts = raw . split ( / ^ - - - \s * $ / m) ;
84148 if ( parts . length < 3 ) throw new Error ( `${ name } : no YAML frontmatter` ) ;
@@ -216,10 +280,12 @@ function spliceBlock(file: string, block: string, style: CommentStyle): string {
216280function main ( ) {
217281 const check = process . argv . includes ( '--check' ) ;
218282
283+ assertWatchHintsDeclareTheReadSurface ( ) ;
284+
219285 // Catalog ⇄ DISPLAY must be in lockstep.
220286 const onDisk = fs
221287 . readdirSync ( SKILLS_DIR )
222- . filter ( ( d ) => d . startsWith ( 'objectstack-' ) && fs . existsSync ( path . resolve ( SKILLS_DIR , d , 'SKILL.md' ) ) ) ;
288+ . filter ( ( d ) => d . startsWith ( 'objectstack-' ) && fs . existsSync ( path . resolve ( SKILLS_DIR , d , ENTRYPOINT_FILE ) ) ) ;
223289 const configured = new Set ( DISPLAY . map ( ( d ) => d . name ) ) ;
224290 const missing = onDisk . filter ( ( d ) => ! configured . has ( d ) ) ;
225291 const extra = DISPLAY . filter ( ( d ) => ! onDisk . includes ( d . name ) ) . map ( ( d ) => d . name ) ;
0 commit comments