@vegastack/vegafactory 0.19.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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +417 -0
  3. package/dist/dashboard-artifact.json +21240 -0
  4. package/dist/index.js +22752 -0
  5. package/dist/run-wrapper.js +377 -0
  6. package/package.json +46 -0
  7. package/skill/dev-architect/SKILL.md +106 -0
  8. package/skill/dev-architect/agents/openai.yaml +4 -0
  9. package/skill/dev-architect/references/ai-agents.md +96 -0
  10. package/skill/dev-architect/references/conventions.md +113 -0
  11. package/skill/dev-architect/references/data.md +89 -0
  12. package/skill/dev-architect/references/infra.md +99 -0
  13. package/skill/dev-architect/references/mobile.md +75 -0
  14. package/skill/dev-architect/references/pinned-facts.md +133 -0
  15. package/skill/dev-architect/references/principles.md +117 -0
  16. package/skill/dev-architect/references/security.md +90 -0
  17. package/skill/dev-architect/references/stack.md +38 -0
  18. package/skill/dev-architect/references/web.md +102 -0
  19. package/skill/dev-architect/refresh/REFRESH.md +31 -0
  20. package/skill/dev-architect/refresh/sources.json +239 -0
  21. package/skill/dev-chronicle/SKILL.md +52 -0
  22. package/skill/dev-chronicle/agents/openai.yaml +4 -0
  23. package/skill/dev-chronicle/references/conventions.md +113 -0
  24. package/skill/dev-chronicle/references/styles.md +75 -0
  25. package/skill/dev-chronicle/refresh/REFRESH.md +3 -0
  26. package/skill/dev-chronicle/refresh/sources.json +6 -0
  27. package/skill/dev-debug/SKILL.md +43 -0
  28. package/skill/dev-debug/agents/openai.yaml +4 -0
  29. package/skill/dev-debug/references/conventions.md +113 -0
  30. package/skill/dev-debug/references/loop-ladder.md +20 -0
  31. package/skill/dev-debug/refresh/REFRESH.md +3 -0
  32. package/skill/dev-debug/refresh/sources.json +6 -0
  33. package/skill/dev-implement/SKILL.md +81 -0
  34. package/skill/dev-implement/agents/openai.yaml +4 -0
  35. package/skill/dev-implement/assets/workflows/implement-children.js +12 -0
  36. package/skill/dev-implement/references/ask-route.md +80 -0
  37. package/skill/dev-implement/references/changelog-and-chronicle.md +21 -0
  38. package/skill/dev-implement/references/conventions.md +113 -0
  39. package/skill/dev-implement/references/ledger-and-resume.md +52 -0
  40. package/skill/dev-implement/references/parallel-children.md +35 -0
  41. package/skill/dev-implement/references/worktrees.md +63 -0
  42. package/skill/dev-implement/refresh/REFRESH.md +3 -0
  43. package/skill/dev-implement/refresh/sources.json +6 -0
  44. package/skill/dev-implement/scripts/children.mjs +479 -0
  45. package/skill/dev-implement/scripts/effective-policy.mjs +694 -0
  46. package/skill/dev-implement/scripts/evidence-check.mjs +107 -0
  47. package/skill/dev-implement/scripts/evidence-upload.mjs +181 -0
  48. package/skill/dev-implement/scripts/learning.mjs +41 -0
  49. package/skill/dev-implement/scripts/lib/approval.mjs +1182 -0
  50. package/skill/dev-implement/scripts/lib/gh.mjs +101 -0
  51. package/skill/dev-implement/scripts/preflight.mjs +153 -0
  52. package/skill/dev-implement/scripts/questions.mjs +381 -0
  53. package/skill/dev-implement/scripts/reclaim.mjs +116 -0
  54. package/skill/dev-implement/scripts/recovery.mjs +213 -0
  55. package/skill/dev-implement/scripts/worktree.mjs +991 -0
  56. package/skill/dev-intake/SKILL.md +80 -0
  57. package/skill/dev-intake/agents/openai.yaml +4 -0
  58. package/skill/dev-intake/references/ask-route.md +80 -0
  59. package/skill/dev-intake/references/brief-template.md +89 -0
  60. package/skill/dev-intake/references/conventions.md +113 -0
  61. package/skill/dev-intake/refresh/REFRESH.md +3 -0
  62. package/skill/dev-intake/refresh/sources.json +6 -0
  63. package/skill/dev-intake/scripts/brief-lint.mjs +87 -0
  64. package/skill/dev-intake/scripts/lib/approval.mjs +1182 -0
  65. package/skill/dev-intake/scripts/questions.mjs +381 -0
  66. package/skill/dev-plan/SKILL.md +54 -0
  67. package/skill/dev-plan/agents/openai.yaml +4 -0
  68. package/skill/dev-plan/references/ask-route.md +80 -0
  69. package/skill/dev-plan/references/conventions.md +113 -0
  70. package/skill/dev-plan/references/plan-format.md +82 -0
  71. package/skill/dev-plan/refresh/REFRESH.md +3 -0
  72. package/skill/dev-plan/refresh/sources.json +6 -0
  73. package/skill/dev-plan/scripts/lib/approval.mjs +1182 -0
  74. package/skill/dev-plan/scripts/plan-lint.mjs +319 -0
  75. package/skill/dev-plan/scripts/questions.mjs +381 -0
  76. package/skill/dev-review/SKILL.md +76 -0
  77. package/skill/dev-review/agents/openai.yaml +4 -0
  78. package/skill/dev-review/assets/review-known-patterns.md.template +32 -0
  79. package/skill/dev-review/references/conventions.md +113 -0
  80. package/skill/dev-review/references/cross-agent.md +39 -0
  81. package/skill/dev-review/references/dispatch-prompts.md +121 -0
  82. package/skill/dev-review/references/security-axis.md +45 -0
  83. package/skill/dev-review/refresh/REFRESH.md +5 -0
  84. package/skill/dev-review/refresh/sources.json +6 -0
  85. package/skill/dev-setup/SKILL.md +113 -0
  86. package/skill/dev-setup/agents/openai.yaml +4 -0
  87. package/skill/dev-setup/assets/agents-section.md.template +24 -0
  88. package/skill/dev-setup/assets/dev-profile.md.template +102 -0
  89. package/skill/dev-setup/assets/factory-board.yml.template +162 -0
  90. package/skill/dev-setup/assets/hooks/decision-nudge.mjs +30 -0
  91. package/skill/dev-setup/assets/hooks/prompt-skill-mention.mjs +29 -0
  92. package/skill/dev-setup/assets/hooks/session-end.mjs +14 -0
  93. package/skill/dev-setup/assets/hooks/session-start.mjs +178 -0
  94. package/skill/dev-setup/assets/hooks/ship-guard.mjs +738 -0
  95. package/skill/dev-setup/assets/hooks/skill-activated.mjs +47 -0
  96. package/skill/dev-setup/assets/hooks/stop-heartbeat.mjs +14 -0
  97. package/skill/dev-setup/assets/workflows/implement-children.js +12 -0
  98. package/skill/dev-setup/references/ask-route.md +80 -0
  99. package/skill/dev-setup/references/conventions.md +113 -0
  100. package/skill/dev-setup/references/github-app.md +286 -0
  101. package/skill/dev-setup/references/harness-facts.md +169 -0
  102. package/skill/dev-setup/references/stack-playbooks.md +124 -0
  103. package/skill/dev-setup/refresh/REFRESH.md +13 -0
  104. package/skill/dev-setup/refresh/sources.json +602 -0
  105. package/skill/dev-setup/scripts/effective-policy.mjs +694 -0
  106. package/skill/dev-setup/scripts/questions.mjs +381 -0
  107. package/skill/dev-setup/scripts/ship-policy.mjs +195 -0
  108. package/skill/dev-ship/SKILL.md +56 -0
  109. package/skill/dev-ship/agents/openai.yaml +4 -0
  110. package/skill/dev-ship/references/conventions.md +113 -0
  111. package/skill/dev-ship/references/runbook.md +72 -0
  112. package/skill/dev-ship/refresh/REFRESH.md +3 -0
  113. package/skill/dev-ship/refresh/sources.json +6 -0
  114. package/skill/dev-ship/scripts/lib/approval.mjs +1182 -0
  115. package/skill/dev-ship/scripts/ship-gate.mjs +485 -0
  116. package/skill/dev-status/SKILL.md +56 -0
  117. package/skill/dev-status/agents/openai.yaml +4 -0
  118. package/skill/dev-status/references/conventions.md +113 -0
  119. package/skill/dev-status/refresh/REFRESH.md +3 -0
  120. package/skill/dev-status/refresh/sources.json +6 -0
  121. package/skill/dev-status/scripts/effective-policy.mjs +694 -0
  122. package/skill/dev-status/scripts/status.mjs +311 -0
  123. package/skill/skill-maintainer/SKILL.md +82 -0
  124. package/skill/skill-maintainer/agents/openai.yaml +4 -0
  125. package/skill/skill-maintainer/references/release-ops.md +61 -0
  126. package/skill/skill-maintainer/references/standards.md +137 -0
  127. package/skill/skill-maintainer/refresh/REFRESH.md +40 -0
  128. package/skill/skill-maintainer/refresh/sources.json +103 -0
  129. package/skill/skill-scan/SKILL.md +59 -0
  130. package/skill/skill-scan/agents/openai.yaml +4 -0
  131. package/skill/skill-scan/refresh/REFRESH.md +18 -0
  132. package/skill/skill-scan/refresh/sources.json +133 -0
  133. package/skill/skill-scan/scripts/lib/skillspector.mjs +226 -0
  134. package/skill/skill-scan/scripts/skill-scan.mjs +1002 -0
  135. package/skill/skillify/SKILL.md +108 -0
  136. package/skill/skillify/agents/openai.yaml +4 -0
  137. package/skill/skillify/assets/templates/README.md.template +26 -0
  138. package/skill/skillify/assets/templates/REFRESH.md.template +16 -0
  139. package/skill/skillify/assets/templates/SKILL.md.template +27 -0
  140. package/skill/skillify/assets/templates/evals.json.template +12 -0
  141. package/skill/skillify/assets/templates/openai.yaml.template +4 -0
  142. package/skill/skillify/assets/templates/skill.test.ts.template +27 -0
  143. package/skill/skillify/assets/templates/sources.json.template +6 -0
  144. package/skill/skillify/assets/templates/trigger-queries.json.template +1 -0
  145. package/skill/skillify/references/authoring.md +103 -0
  146. package/skill/skillify/references/eval-playbook.md +112 -0
  147. package/skill/skillify/refresh/REFRESH.md +23 -0
  148. package/skill/skillify/refresh/sources.json +6 -0
  149. package/skill/skillify/scripts/scaffold-skill.mjs +334 -0
  150. package/skill/skillify/scripts/trigger-check.mjs +209 -0
  151. package/skill/vegafactory-setup/SKILL.md +77 -0
  152. package/skill/vegafactory-setup/agents/openai.yaml +4 -0
  153. package/skill/vegafactory-setup/assets/control-room/boards.md.template +17 -0
  154. package/skill/vegafactory-setup/assets/control-room/decisions.md.template +5 -0
  155. package/skill/vegafactory-setup/assets/control-room/group.md.template +44 -0
  156. package/skill/vegafactory-setup/assets/control-room/onboarding/dispatcher-box.md.template +86 -0
  157. package/skill/vegafactory-setup/assets/control-room/onboarding/new-repo.md.template +13 -0
  158. package/skill/vegafactory-setup/assets/control-room/onboarding/new-teammate.md.template +12 -0
  159. package/skill/vegafactory-setup/assets/control-room/org.md.template +60 -0
  160. package/skill/vegafactory-setup/assets/control-room/people.csv.template +2 -0
  161. package/skill/vegafactory-setup/assets/control-room/repos.md.template +7 -0
  162. package/skill/vegafactory-setup/assets/control-room/rules/CODEOWNERS.template +6 -0
  163. package/skill/vegafactory-setup/assets/control-room/rules/README.md.template +9 -0
  164. package/skill/vegafactory-setup/assets/control-room/rules/stats-privacy.md.template +44 -0
  165. package/skill/vegafactory-setup/assets/control-room/templates/README.md.template +9 -0
  166. package/skill/vegafactory-setup/references/control-room.md +149 -0
  167. package/skill/vegafactory-setup/references/conventions.md +113 -0
  168. package/skill/vegafactory-setup/refresh/REFRESH.md +5 -0
  169. package/skill/vegafactory-setup/refresh/sources.json +6 -0
  170. package/skill-integrity.json +252 -0
@@ -0,0 +1,1002 @@
1
+ #!/usr/bin/env node
2
+ // The skill-scan guard: scans the project's agent skills with NVIDIA SkillSpector and
3
+ // blocks on any unsuppressed HIGH/CRITICAL finding. Facts block; heuristics warn.
4
+ // The scanner's own exit code (0 for score <= 50) is never the verdict — an
5
+ // aggregate score is distorted by meta-content, individual findings are not.
6
+ // Self-contained (ships with skill-scan; no cross-skill imports, no dependencies).
7
+ //
8
+ // Exit codes: 0 pass (or skipped) · 1 pass-with-warnings · 2 blocked.
9
+ // Usage: node skill-scan.mjs [--root <path>] [--dev-md <path>] [--baseline <path>]
10
+ // [--llm] [--json]
11
+
12
+ import { execFileSync } from 'node:child_process';
13
+ import { createHash } from 'node:crypto';
14
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { basename, join, resolve } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import {
19
+ defaultRun,
20
+ latestRelease,
21
+ locateSkillspector,
22
+ provisionSkillspector,
23
+ readVersion,
24
+ } from './lib/skillspector.mjs';
25
+
26
+ // The clause every suppression must carry, mirroring the "Still flag if:"
27
+ // requirement on .vegastack/review-known-patterns.md entries: a suppression
28
+ // without a stated re-trigger condition is a blind spot, not a decision.
29
+ const CLAUSE = /still flag if:/i;
30
+ // stdio mode for a discarded fd, hoisted out of quote-adjacency: SkillSpector reads the
31
+ // bare word beside its own closing quote as a removal cue and fails closed on the whole
32
+ // file (skill-maintainer's standards.md, known behaviours). Same value, same behaviour.
33
+ const DISCARD = 'ignore';
34
+
35
+ // SkillSpector's exact default when `skillspector baseline` writes a file
36
+ // without --reason. Committing one of those suppresses every current finding at
37
+ // once. Exact equality is a FACT and blocks; the looser phrase match below is a
38
+ // heuristic and only warns — conventions' guard doctrine is that regex judgement
39
+ // never blocks.
40
+ const PLACEHOLDER_EXACT = 'Accepted finding (auto-generated baseline)';
41
+ const PLACEHOLDER_LIKE = /auto-generated baseline/i;
42
+
43
+ function reasonErrors(entry, label, requireClause) {
44
+ const errors = [];
45
+ const warns = [];
46
+ const reason = typeof entry.reason === 'string' ? entry.reason.trim() : '';
47
+ if (!reason) {
48
+ errors.push(`${label}: missing reason — every suppression states why the pattern is structural here`);
49
+ return { errors, warns };
50
+ }
51
+ if (reason === PLACEHOLDER_EXACT) {
52
+ errors.push(`${label}: the scanner's default reason, unedited — write why this pattern is structural here`);
53
+ } else if (PLACEHOLDER_LIKE.test(reason)) {
54
+ warns.push(`${label}: reason mentions an auto-generated baseline ("${reason}") — check it was actually written, not adapted from the default`);
55
+ }
56
+ if (requireClause && !CLAUSE.test(reason)) {
57
+ errors.push(`${label}: reason has no "Still flag if:" clause — a suppression without a re-trigger condition is a blind spot`);
58
+ }
59
+ return { errors, warns };
60
+ }
61
+
62
+ // Returns { rules, fingerprints, errors }. Never throws: unreadable content comes
63
+ // back as an error so the caller can block on it like any other fact.
64
+ export function parseBaseline(text) {
65
+ let data;
66
+ try {
67
+ data = JSON.parse(text);
68
+ } catch (error) {
69
+ return { rules: [], fingerprints: [], errors: [`baseline is not valid JSON: ${error.message}`], warns: [] };
70
+ }
71
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
72
+ return { rules: [], fingerprints: [], errors: ['baseline must be a JSON object'], warns: [] };
73
+ }
74
+
75
+ const errors = [];
76
+ const warns = [];
77
+ const rawRules = Array.isArray(data.rules) ? data.rules : [];
78
+ const rawFingerprints = Array.isArray(data.fingerprints) ? data.fingerprints : [];
79
+ if (data.rules !== undefined && !Array.isArray(data.rules)) errors.push('baseline "rules" must be an array');
80
+ if (data.fingerprints !== undefined && !Array.isArray(data.fingerprints)) errors.push('baseline "fingerprints" must be an array');
81
+
82
+ const rules = [];
83
+ rawRules.forEach((raw, index) => {
84
+ const label = `rule ${index + 1}`;
85
+ if (!raw || typeof raw !== 'object') {
86
+ errors.push(`${label}: not an object`);
87
+ return;
88
+ }
89
+ // SkillSpector normalizes `id`/`rule_id` and `path`/`file` to one field each
90
+ // (`path=raw.get("path") or raw.get("file")`). Missing the `file` alias would
91
+ // both reject a valid baseline AND let `{"file": "*"}` past the wildcard
92
+ // check below into a scanner that honours it.
93
+ const id = raw.id ?? raw.rule_id;
94
+ const path = raw.path ?? raw.file;
95
+ const matchers = { id, path, message: raw.message };
96
+ const present = Object.entries(matchers).filter(([, value]) => value !== undefined);
97
+ if (present.length === 0) {
98
+ errors.push(`${label}: no matcher (id, path, or message) — a rule with no matcher suppresses every finding`);
99
+ }
100
+ // Matchers must be LITERAL. Chasing wildcard shapes is an arms race that
101
+ // was lost at the first attempt: `*` was rejected and `?*` silenced every
102
+ // finding just the same, as do `*.md`, `[a-z]*` and `*SKILL*`. The rule
103
+ // this project already states — "scope a rule as narrowly as its cause" —
104
+ // is mechanically checkable only as "name the thing". A project that wants
105
+ // two files writes two rules, which is the more reviewable artifact anyway.
106
+ for (const [field, value] of present) {
107
+ if (typeof value !== 'string' || !value.trim()) {
108
+ errors.push(`${label}: "${field}" must be a non-empty string, got ${JSON.stringify(value)}`);
109
+ continue;
110
+ }
111
+ const glob = value.match(/[*?[\]]/);
112
+ if (glob) {
113
+ errors.push(`${label}: "${field}" contains the glob character "${glob[0]}" ("${value}") — matchers must be literal so a rule cannot silence more than the cause it names; write one rule per file`);
114
+ }
115
+ }
116
+ const reasoned = reasonErrors(raw, label, true);
117
+ errors.push(...reasoned.errors);
118
+ warns.push(...reasoned.warns);
119
+ rules.push({ id, path, message: raw.message, reason: raw.reason });
120
+ });
121
+
122
+ // The scanner rejects a v2 baseline that carries fingerprints without pinning
123
+ // the version they were computed against, and it does so per invocation — so
124
+ // catching it here turns twelve confusing "no readable report" failures into
125
+ // one sentence naming the actual problem.
126
+ if (rawFingerprints.length > 0 && !data.scanner_version) {
127
+ errors.push('a v2 baseline with fingerprints must set "scanner_version" (the scanner rejects it otherwise)');
128
+ }
129
+
130
+ // Fingerprints get the same reason discipline minus the clause: they are
131
+ // content-hashed, so editing the surrounding file re-triggers the finding on
132
+ // its own — the re-trigger condition a rule has to state in prose. This check
133
+ // catches the common accident, committing `skillspector baseline` output
134
+ // verbatim, since that writes every finding as a fingerprint carrying the
135
+ // default reason. It does NOT stop someone passing `--reason` with a clause
136
+ // in it: a deliberate mass-suppression is caught by review of the diff and by
137
+ // the suppression counts in the report, not by this guard.
138
+ rawFingerprints.forEach((raw, index) => {
139
+ const label = `fingerprint ${index + 1}`;
140
+ if (!raw || typeof raw !== 'object') {
141
+ errors.push(`${label}: not an object`);
142
+ return;
143
+ }
144
+ const reasoned = reasonErrors(raw, label, false);
145
+ errors.push(...reasoned.errors);
146
+ warns.push(...reasoned.warns);
147
+ });
148
+
149
+ // `coverage:` accepts a COMPLETENESS signal, not a finding. The scanner's own
150
+ // baseline cannot express this: it suppresses findings only. Without it, a
151
+ // skill shipping ordinary JavaScript blocks forever — SkillSpector's shell
152
+ // parser reads a template literal in assignment position as backtick command
153
+ // substitution and degrades. Same discipline as a rule: name the skill AND the
154
+ // file, say why, and say what would make it a real signal again.
155
+ const rawCoverage = Array.isArray(data.coverage) ? data.coverage : [];
156
+ if (data.coverage !== undefined && !Array.isArray(data.coverage)) {
157
+ errors.push('baseline "coverage" must be an array');
158
+ }
159
+ const coverage = [];
160
+ rawCoverage.forEach((raw, index) => {
161
+ const label = `coverage ${index + 1}`;
162
+ if (!raw || typeof raw !== 'object') {
163
+ errors.push(`${label}: not an object`);
164
+ return;
165
+ }
166
+ // Content-bound, like a fingerprint. Without this an acceptance outlives the
167
+ // file it was written about: the reason stays on the page while the content
168
+ // it describes changes underneath, and every "Still flag if:" clause becomes
169
+ // decorative because nothing re-triggers the adjudication.
170
+ if (typeof raw.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(raw.sha256.trim())) {
171
+ errors.push(`${label}: "sha256" must be the 64-hex digest of the accepted file — an acceptance that is not content-bound never expires`);
172
+ }
173
+ for (const field of ['skill', 'file']) {
174
+ const value = raw[field];
175
+ if (typeof value !== 'string' || !value.trim()) {
176
+ errors.push(`${label}: "${field}" must be a non-empty string — a coverage acceptance names exactly one file in one skill`);
177
+ } else if (/[*?[\]]/.test(value)) {
178
+ errors.push(`${label}: "${field}" contains a glob character ("${value}") — coverage acceptances are literal, like rules`);
179
+ }
180
+ }
181
+ const reasoned = reasonErrors(raw, label, true);
182
+ errors.push(...reasoned.errors);
183
+ warns.push(...reasoned.warns);
184
+ coverage.push({ skill: raw.skill, file: raw.file, sha256: raw.sha256, reason: raw.reason });
185
+ });
186
+
187
+ return { rules, fingerprints: rawFingerprints, coverage, errors, warns, scannerVersion: data.scanner_version ?? null };
188
+ }
189
+
190
+ // Absolute paths, sorted, of the skill directories under `root`. A directory is
191
+ // a skill iff it holds a SKILL.md. The root itself counts when it holds one;
192
+ // otherwise children AND grandchildren are examined — two levels, matching the
193
+ // authored layout's own cap (`skills/<name>/` and `skills/<group>/<name>/`), so
194
+ // pointing the knob at a grouped tree scans it instead of silently finding
195
+ // nothing. Dot-prefixed entries are skipped: a crashed scaffolder's
196
+ // `.name.scaffold-XXXX` leftover must never read as a skill. Symlinked
197
+ // directories are not followed — a scanner that traverses out of its root scans
198
+ // something other than what it reports on.
199
+ function childDirectories(dir) {
200
+ try {
201
+ return readdirSync(dir, { withFileTypes: true })
202
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
203
+ .map((entry) => join(dir, entry.name));
204
+ } catch {
205
+ return [];
206
+ }
207
+ }
208
+
209
+ export function discoverSkills(root) {
210
+ if (!root || !existsSync(root)) return [];
211
+ const absolute = resolve(root);
212
+ if (existsSync(join(absolute, 'SKILL.md'))) return [absolute];
213
+
214
+ const found = [];
215
+ for (const child of childDirectories(absolute)) {
216
+ if (existsSync(join(child, 'SKILL.md'))) {
217
+ found.push(child);
218
+ continue;
219
+ }
220
+ // One level deeper, for the grouped authored layout (`<root>/<group>/<skill>/`).
221
+ // Without this a grouped tree scans as ZERO skills while reporting success on
222
+ // whatever else it found — coverage silently lost, which is the whole defect
223
+ // class this guard exists to stop.
224
+ for (const grandchild of childDirectories(child)) {
225
+ if (existsSync(join(grandchild, 'SKILL.md'))) found.push(grandchild);
226
+ }
227
+ }
228
+ return found.sort();
229
+ }
230
+
231
+ // Anything that LOOKS like a skill but discovery did not scan. Defined as the
232
+ // difference between a full walk and `discoverSkills`, rather than as a list of
233
+ // known-bad shapes — so it stays correct by construction when discovery changes.
234
+ // It catches skills nested deeper than the layout allows, dot-prefixed
235
+ // directories, and symlinked directories that are or contain a skill.
236
+ //
237
+ // The walk is NOT depth-capped. A cap is a cliff: an earlier version stopped at
238
+ // depth 4, and a deliberately malicious skill at depth 5 was then scanned by
239
+ // nobody and flagged by nobody — the exact silent-coverage-loss this function
240
+ // exists to prevent, reintroduced one level down. Real directories cannot cycle
241
+ // and symlinks are never descended, so the walk terminates. TWO things can stop it early, and BOTH are reported rather than
242
+ // swallowed: the visit budget, and a directory it cannot read (EACCES, or a
243
+ // path past PATH_MAX). An earlier version caught the read failure and gave up
244
+ // silently, so a skill hidden behind a `chmod 000` directory was flagged by
245
+ // nobody while the run reported success — the same quiet give-up this whole
246
+ // function exists to prevent, one level down.
247
+ const WALK_BUDGET = 50_000;
248
+
249
+ function deepSkillDirs(dir, state) {
250
+ if (state.exhausted) return;
251
+ let entries;
252
+ try {
253
+ entries = readdirSync(dir, { withFileTypes: true });
254
+ } catch (error) {
255
+ // Not silence: an unreadable directory is unverified coverage.
256
+ state.unreadable.add(`${dir} (${error.code ?? error.message})`);
257
+ return;
258
+ }
259
+ for (const entry of entries) {
260
+ if (state.visited++ > WALK_BUDGET) {
261
+ state.exhausted = true;
262
+ return;
263
+ }
264
+ const child = join(dir, entry.name);
265
+ if (entry.isSymbolicLink()) {
266
+ // Never descended — a scanner that walks out of its root reports on
267
+ // something it was not pointed at. Flagged when the target is, or holds,
268
+ // a skill, so it is refused rather than dropped.
269
+ if (existsSync(join(child, 'SKILL.md'))) {
270
+ state.seen.add(child);
271
+ continue;
272
+ }
273
+ for (const nested of childDirectories(child)) {
274
+ if (existsSync(join(nested, 'SKILL.md'))) {
275
+ state.seen.add(child);
276
+ break;
277
+ }
278
+ }
279
+ continue;
280
+ }
281
+ if (!entry.isDirectory()) continue;
282
+ if (existsSync(join(child, 'SKILL.md'))) state.seen.add(child);
283
+ deepSkillDirs(child, state);
284
+ }
285
+ }
286
+
287
+ // Returns { unscannable, exhausted }. `exhausted` means the walk hit its budget
288
+ // and coverage could NOT be verified — the caller blocks on it.
289
+ export function findUnscannable(root) {
290
+ if (!root || !existsSync(root)) return { unscannable: [], exhausted: false };
291
+ const absolute = resolve(root);
292
+ const scanned = new Set(discoverSkills(absolute));
293
+ const state = { seen: new Set(), visited: 0, exhausted: false, unreadable: new Set() };
294
+ deepSkillDirs(absolute, state);
295
+ return {
296
+ unscannable: [...state.seen].filter((dir) => !scanned.has(dir)).sort(),
297
+ exhausted: state.exhausted,
298
+ unreadable: [...state.unreadable].sort(),
299
+ };
300
+ }
301
+
302
+ // The severities that stop a push. Deliberately NOT the aggregate risk score:
303
+ // a score is inflated by unresolvable-path artifacts in meta-content and
304
+ // deflated by suppressing unrelated findings, so it answers a question nobody
305
+ // asked. Individual findings are what a reviewer triages.
306
+ const BLOCKING = new Set(['HIGH', 'CRITICAL']);
307
+ // The one finding id that is a COMPLETENESS signal wearing a finding's clothes —
308
+ // the scanner's own text for it is "Referenced artifact was not completely
309
+ // inspected". A `coverage:` entry naming that file accepts it, because it is the
310
+ // same phenomenon the coverage section exists for; every other id must go
311
+ // through `rules` or `fingerprints`, which bind to content.
312
+ const COVERAGE_CLASS_RULE = 'AE1';
313
+ // Everything the scanner is known to emit. A severity outside this set is
314
+ // upstream drift, and drift must fail CLOSED: silently sorting an unrecognised
315
+ // severity under the blocking bar and then calling it "MEDIUM/LOW" would be a
316
+ // false success dressed as a summary line.
317
+ const KNOWN_SEVERITIES = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO']);
318
+
319
+ const INSTALL_HINT = 'install it with `uv tool install git+https://github.com/NVIDIA/skillspector.git`';
320
+
321
+ // Pure evaluation over gathered facts — unit tests drive this directly.
322
+ export function evaluateScan(facts) {
323
+ const blocks = [];
324
+ const warns = [];
325
+ const {
326
+ binaryMissing,
327
+ rootMissing,
328
+ baselineMissing,
329
+ baselineErrors = [],
330
+ skills = [],
331
+ scanErrors = [],
332
+ skillspector = {},
333
+ baselinePin = {},
334
+ } = facts;
335
+
336
+ // Environment failures first: when the scanner never ran, a finding list is
337
+ // not evidence of anything, and the real cause must read before the noise.
338
+ if (binaryMissing) {
339
+ blocks.push(
340
+ `the \`skillspector\` binary could not be found — no install channel (uv, brew, pipx) reports it and it is not on PATH — ${INSTALL_HINT}; or, if it runs through a wrapper or container, point VSK_SKILLSPECTOR at that executable; or set skill-scan: none if this project has no skills`,
341
+ );
342
+ }
343
+ // An update that could not happen is a note, never a block: the scan ran on
344
+ // the copy that was already installed, which is exactly the documented
345
+ // fallback. Sanitized — this text comes from a package manager.
346
+ if (skillspector.action === 'failed' && skillspector.message) {
347
+ warns.push(`skillspector update failed, scanned with the installed copy instead — ${safe(skillspector.message)}`);
348
+ }
349
+ // A fingerprint is a content hash tied to the scanner that produced it, so a
350
+ // version change can stop it matching and quietly un-suppress its finding.
351
+ // Warn, never block: the finding coming back IS the loud outcome, and the pin
352
+ // is never moved automatically — that would assert a suppression still holds
353
+ // for a scanner nobody has run.
354
+ if (baselinePin.fingerprints > 0 && baselinePin.scannerVersion && skillspector.version && baselinePin.scannerVersion !== skillspector.version) {
355
+ warns.push(
356
+ `baseline pins scanner_version ${safe(baselinePin.scannerVersion)} for ${baselinePin.fingerprints} fingerprint(s) but skillspector ${safe(skillspector.version)} ran — re-verify those suppressions and move the pin deliberately, never automatically`,
357
+ );
358
+ }
359
+
360
+ if (rootMissing) {
361
+ blocks.push(`scan root "${rootMissing}" does not exist — build it first if it is a build output, or correct dev.md's skill-scan: knob`);
362
+ }
363
+ for (const error of baselineErrors) {
364
+ blocks.push(`baseline: ${error}`);
365
+ }
366
+ for (const { skill, message } of scanErrors) {
367
+ blocks.push(`${skill}: the scan did not produce a readable report (${message}) — an unscanned skill is not a clean skill`);
368
+ }
369
+ for (const dir of facts.unreadableDirs ?? []) {
370
+ blocks.push(`${dir} could not be read, so coverage under it is unverified — a skill hidden there would be reported by nobody`);
371
+ }
372
+ if (facts.coverageExhausted) {
373
+ blocks.push('the scan root is too large to verify coverage — the walk hit its budget, so an unscanned skill could be hiding in it; point --root at a narrower directory');
374
+ }
375
+ for (const path of facts.unscannable ?? []) {
376
+ blocks.push(`${path} holds a SKILL.md but was not scanned — nested deeper than the layout allows, dot-prefixed, or behind a symlink discovery will not follow out of the scan root. Move it into place, or scan it directly with --root`);
377
+ }
378
+
379
+ if (blocks.length > 0) return { blocks, warns };
380
+
381
+ if (skills.length === 0) {
382
+ blocks.push('no skills found under the scan root — a root with nothing in it is a misconfigured knob, not a clean result');
383
+ return { blocks, warns };
384
+ }
385
+
386
+ for (const entry of skills) {
387
+ // The ONLY short-circuit: a failed execution means no field of this report
388
+ // can be trusted. Every other coverage problem still yields real findings,
389
+ // and suppressing them behind the coverage block would tell the operator
390
+ // less than the guard actually knows.
391
+ if (!entry.executionSuccessful) {
392
+ blocks.push(`${entry.name}: the scan did not complete (execution_successful: false) — a partial score is not a verdict`);
393
+ continue;
394
+ }
395
+ // A degraded run reports a HIGHER score than a clean one (a failed analyzer
396
+ // leaves its findings unfiltered), so "no blocking finding" from a degraded
397
+ // scan proves nothing. But `status: "partial"` on its own is the NORMAL
398
+ // result for documentation-heavy skills — it is what unresolved path-like
399
+ // references produce — so blocking on it would block every scan forever,
400
+ // the same trap as gating on the aggregate score. Block only on the signals
401
+ // that mean work did not happen.
402
+ const { status, limitations, entirelyUninspected, partiallyInspected } = entry.completeness ?? {};
403
+
404
+ // A coverage acceptance clears the degraded/partly-read signals for a skill
405
+ // ONLY when it names every file the scanner said it could not finish. Accept
406
+ // one file and leave another unread, and the skill still blocks — otherwise
407
+ // an acceptance written for a known cause would silently cover an unknown one.
408
+ const forThisSkill = (facts.coverageAccepted ?? []).filter((c) => c.skill === entry.name);
409
+ for (const c of forThisSkill) {
410
+ if (c.actualSha256 && c.actualSha256 !== c.sha256) {
411
+ blocks.push(`${entry.name}: ${c.file} changed since its coverage acceptance was written (baseline ${c.sha256.slice(0, 12)}…, on disk ${c.actualSha256.slice(0, 12)}…) — re-adjudicate it rather than carrying the old reasoning forward`);
412
+ } else if (!c.actualSha256) {
413
+ blocks.push(`${entry.name}: ${c.file} has a coverage acceptance but could not be read to verify it — an acceptance for a file that is not there accepts nothing`);
414
+ }
415
+ }
416
+ const acceptedFiles = new Set(
417
+ forThisSkill.filter((c) => c.actualSha256 && c.actualSha256 === c.sha256).map((c) => c.file),
418
+ );
419
+ const unaccounted = (entry.partialPaths ?? []).filter((path) => !acceptedFiles.has(path));
420
+ const coverageClassAccepted = new Set();
421
+ const coverageAccepted =
422
+ acceptedFiles.size > 0 && (entry.partialPaths ?? []).length > 0 && unaccounted.length === 0;
423
+ if (status && status !== 'complete' && status !== 'partial') {
424
+ blocks.push(`${entry.name}: the scan reported completeness "${status}" — only "complete" or "partial" is a result you can act on`);
425
+ continue;
426
+ }
427
+ if (limitations?.length && !coverageAccepted) {
428
+ const detail = unaccounted.length ? ` in ${unaccounted.join(', ')}` : '';
429
+ blocks.push(`${entry.name}: an analyzer did not finish${detail} (${limitations.join('; ')}) — a degraded scan scores HIGHER than a clean one, so its silence proves nothing`);
430
+ }
431
+ if (entirelyUninspected > 0) {
432
+ blocks.push(`${entry.name}: ${entirelyUninspected} file(s) were never inspected — an unread file is not a clean file`);
433
+ }
434
+ // Distinct from `status: "partial"`, which every healthy scan here reports.
435
+ // Measured across all twelve skills, `partially_inspected_files` is 0 on a
436
+ // healthy run, so this blocks only genuinely truncated coverage.
437
+ if (partiallyInspected > 0 && !coverageAccepted) {
438
+ const where = unaccounted.length ? `: ${unaccounted.join(', ')}` : '';
439
+ blocks.push(`${entry.name}: ${partiallyInspected} file(s) were only partly inspected${where} — the unread remainder is exactly where something would hide`);
440
+ }
441
+ // A scan that read nothing reports "complete" with zero findings, which is
442
+ // indistinguishable from a clean skill. Reachable with a symlinked or
443
+ // unreadable SKILL.md: the scanner sees no bytes and says so by counting
444
+ // them, which is the only place this shows up.
445
+ if (entry.completeness?.fullyInspected === 0) {
446
+ blocks.push(`${entry.name}: the scanner inspected 0 files — an empty read is not a clean result (unreadable or symlinked content?)`);
447
+ }
448
+ for (const issue of entry.issues ?? []) {
449
+ const severity = String(issue.severity).toUpperCase();
450
+ const at = issue.line == null ? issue.file : `${issue.file}:${issue.line}`;
451
+ if (!KNOWN_SEVERITIES.has(severity)) {
452
+ blocks.push(`${entry.name}: unrecognised severity "${issue.severity}" for ${issue.id} at ${at} — refusing to rank an unknown severity below the bar`);
453
+ continue;
454
+ }
455
+ if (!BLOCKING.has(severity)) continue;
456
+ if (issue.id === COVERAGE_CLASS_RULE && acceptedFiles.has(issue.file)) {
457
+ coverageClassAccepted.add(issue.file);
458
+ continue;
459
+ }
460
+ blocks.push(`${entry.name}: ${issue.severity} ${issue.id} at ${at} — fix it, or add a justified baseline rule on the operator's word`);
461
+ }
462
+
463
+ // AFTER the issue loop: an accepted AE1 is only known here. Emitting this
464
+ // earlier meant an accepted HIGH finding vanished with no block and no
465
+ // warning — a suppression nobody could see is indistinguishable from a
466
+ // finding that never existed.
467
+ if (coverageAccepted || coverageClassAccepted.size > 0) {
468
+ const files = [...new Set([...(coverageAccepted ? acceptedFiles : []), ...coverageClassAccepted])].sort();
469
+ const suppressedHigh = coverageClassAccepted.size > 0 ? ` — including HIGH ${COVERAGE_CLASS_RULE} finding(s)` : '';
470
+ warns.push(`${entry.name}: reduced coverage accepted by the baseline for ${files.join(', ')}${suppressedHigh} — the scan of those files is incomplete by acknowledged cause`);
471
+ }
472
+ }
473
+
474
+ for (const warning of facts.baselineWarns ?? []) warns.push(`baseline: ${warning}`);
475
+ if (baselineMissing) {
476
+ warns.push('no baseline file — every finding counts, including ones previously adjudicated as structural');
477
+ }
478
+ const suppressed = skills.reduce((total, entry) => total + (entry.suppressedCount ?? 0), 0);
479
+ if (suppressed > 0) {
480
+ // A bare count hides what was silenced. Ten LOW suppressions and ten HIGH
481
+ // ones are very different facts about a baseline, and the second is the one
482
+ // worth reading before trusting a green run.
483
+ const bySeverity = {};
484
+ for (const entry of skills) {
485
+ for (const item of entry.suppressed ?? []) {
486
+ const key = String(item?.severity ?? 'UNKNOWN').toUpperCase();
487
+ bySeverity[key] = (bySeverity[key] ?? 0) + 1;
488
+ }
489
+ }
490
+ const breakdown = Object.entries(bySeverity)
491
+ .sort()
492
+ .map(([severity, count]) => `${count} ${severity}`)
493
+ .join(', ');
494
+ warns.push(
495
+ `${suppressed} finding(s) suppressed by the baseline${breakdown ? ` (${breakdown})` : ''} — read it when a result surprises you`,
496
+ );
497
+ }
498
+ const belowBar = skills.reduce(
499
+ (total, entry) =>
500
+ total +
501
+ (entry.issues ?? []).filter((i) => {
502
+ const severity = String(i.severity).toUpperCase();
503
+ return KNOWN_SEVERITIES.has(severity) && !BLOCKING.has(severity);
504
+ }).length,
505
+ 0,
506
+ );
507
+ if (belowBar > 0) {
508
+ warns.push(`${belowBar} MEDIUM/LOW finding(s) below the blocking bar — the security axis triages these`);
509
+ }
510
+
511
+ return { blocks, warns };
512
+ }
513
+
514
+ // The dev.md knob naming the directory to scan. `none` or absent means this
515
+ // project authors no skills — the guard skips rather than erroring, so callers
516
+ // run one unconditional command instead of honouring a rule written in prose.
517
+ // Conventional home for the project's suppressions, beside dev.md — one fewer
518
+ // knob, and it means the documented one-command invocation actually applies them.
519
+ export const DEFAULT_BASELINE = '.vegastack/skillspector-baseline.json';
520
+
521
+ // Tolerates the shapes a hand-edited profile actually takes — indented under a
522
+ // heading, or written as a list item. A knob the guard cannot see reads as
523
+ // absent, and absent silently disables the gate, so the match is deliberately
524
+ // forgiving about layout and strict about the value.
525
+ const KNOB_LINE = /^[ \t]*(?:[-*+][ \t]+)?skill-scan:[ \t]*(\S+)/gm;
526
+
527
+ // Every `skill-scan:` value the profile declares. Tolerating indentation and
528
+ // list bullets means a prose EXAMPLE can also match — and with first-match-wins
529
+ // an example of `skill-scan: none` sitting above the real knob silently
530
+ // disabled the gate. The caller blocks when these disagree rather than picking
531
+ // one; guessing which line the author meant is exactly the judgement a guard
532
+ // must not make.
533
+ export function scanRootDeclarations(devMdText) {
534
+ return [...String(devMdText ?? '').matchAll(KNOB_LINE)].map((match) => match[1]);
535
+ }
536
+
537
+ // The sibling knob governing SkillSpector itself: off | notify | auto, absent
538
+ // reading as `auto` so an existing profile inherits the default without an
539
+ // edit. Same tolerant layout matching and same conflict discipline as
540
+ // `skill-scan:` — a knob the guard cannot see reads as absent, and absent must
541
+ // not silently mean something different from what the author wrote.
542
+ //
543
+ // Named for the tool, not for our guard: `skill-scan:` is our machinery,
544
+ // `skillspector-update:` is the third-party binary (operator's rule, 01-09-2026).
545
+ const UPDATE_KNOB_LINE = /^[ \t]*(?:[-*+][ \t]+)?skillspector-update:[ \t]*(\S+)/gm;
546
+
547
+ export const UPDATE_MODES = new Set(['off', 'notify', 'auto']);
548
+
549
+ // Every declared value, unvalidated. The caller refuses a conflict or an
550
+ // unrecognised value rather than picking one: guessing which mode the author
551
+ // meant is exactly the judgement a guard must not make.
552
+ export function updateModeDeclarations(devMdText) {
553
+ return [...String(devMdText ?? '').matchAll(UPDATE_KNOB_LINE)].map((match) => match[1]);
554
+ }
555
+
556
+ export function resolveUpdateMode(devMdText) {
557
+ return updateModeDeclarations(devMdText)[0] ?? 'auto';
558
+ }
559
+
560
+ export function resolveScanRoot(devMdText) {
561
+ const value = scanRootDeclarations(devMdText)[0];
562
+ if (!value || value === 'none') return null;
563
+ return value;
564
+ }
565
+
566
+ // Findings carry file names and rule ids that originate in SCANNED content, and
567
+ // this guard's output is read in a terminal. Strip C0/C1 controls (ANSI escapes
568
+ // included) so a crafted path cannot repaint or forge lines of the report.
569
+ function safe(text) {
570
+ // eslint-disable-next-line no-control-regex
571
+ return String(text).replace(/[\u0000-\u001f\u007f-\u009f]/g, '?');
572
+ }
573
+
574
+ function normalizeIssue(raw) {
575
+ const location = raw.location ?? {};
576
+ return {
577
+ id: safe(raw.id ?? raw.rule_id ?? raw.finding_id ?? 'UNKNOWN'),
578
+ severity: safe(raw.severity ?? 'UNKNOWN'),
579
+ file: safe(location.file ?? raw.file ?? '(unknown file)'),
580
+ line: location.start_line ?? null,
581
+ };
582
+ }
583
+
584
+ // Impure: shells out to the scanner, once per skill. `--baseline` is rejected
585
+ // together with `--recursive` ("scan each sub-skill with its own baseline"), so
586
+ // the loop is the supported path, not an optimization we passed up.
587
+ export function gatherFacts({ root, baselinePath, llm, binary: binaryOverride }) {
588
+ // VSK_SKILLSPECTOR is a TEST SEAM (stubs the scanner in unit tests), mirroring
589
+ // ship-gate.mjs's VSK_GH. `binaryOverride` is the absolute path the CLI
590
+ // resolved through the tool's own install channel; a bare PATH lookup is the
591
+ // last resort, not the first.
592
+ //
593
+ // Locating deliberately happens in the CLI and NOT here: gatherFacts is
594
+ // driven directly by unit tests, and probing uv/brew/pipx from inside it
595
+ // would make the suite shell out to whatever is installed on the machine.
596
+ const binary = process.env.VSK_SKILLSPECTOR || binaryOverride || 'skillspector';
597
+ const base = {
598
+ binaryMissing: false,
599
+ rootMissing: null,
600
+ baselineMissing: !baselinePath,
601
+ baselineErrors: [],
602
+ skills: [],
603
+ scanErrors: [],
604
+ };
605
+
606
+ if (!root || !existsSync(root)) return { ...base, rootMissing: root ?? '(unset)' };
607
+
608
+ const baselineUsable = Boolean(baselinePath) && existsSync(baselinePath);
609
+ if (baselinePath && !baselineUsable) base.baselineMissing = true;
610
+ if (baselineUsable) {
611
+ const parsed = parseBaseline(readFileSync(baselinePath, 'utf8'));
612
+ base.baselineErrors = parsed.errors;
613
+ base.baselineWarns = parsed.warns;
614
+ base.coverageAccepted = parsed.coverage;
615
+ // Only fingerprints are version-coupled: they are content hashes the
616
+ // scanner computed, so a different scanner may stop matching them. Rules
617
+ // bind to id+path and survive an upgrade untouched.
618
+ base.baselinePin = { scannerVersion: parsed.scannerVersion, fingerprints: parsed.fingerprints.length };
619
+ }
620
+ // Short-circuit: with a bad baseline nothing the scan reports is trustworthy —
621
+ // suppressions may not apply — and the scanner would reject the file once per
622
+ // skill anyway. Block on the real reason instead of after N wasted invocations.
623
+ if (base.baselineErrors.length > 0) return base;
624
+
625
+ // Paths here are attacker-chosen directory names in a third-party tree, and
626
+ // they are printed verbatim in block lines.
627
+ // Hash each accepted file as it is on disk now, so a changed file drops its
628
+ // acceptance and blocks until it is re-adjudicated.
629
+ base.coverageAccepted = (base.coverageAccepted ?? []).map((entry) => {
630
+ const skillDir = discoverSkills(root).find((dir) => basename(dir) === entry.skill);
631
+ let actual = null;
632
+ if (skillDir) {
633
+ try {
634
+ actual = createHash('sha256').update(readFileSync(join(skillDir, entry.file))).digest('hex');
635
+ } catch {
636
+ actual = null;
637
+ }
638
+ }
639
+ return { ...entry, actualSha256: actual };
640
+ });
641
+
642
+ const coverage = findUnscannable(root);
643
+ base.unscannable = coverage.unscannable.map(safe);
644
+ base.coverageExhausted = coverage.exhausted;
645
+ base.unreadableDirs = coverage.unreadable.map(safe);
646
+
647
+ const outDir = mkdtempSync(join(tmpdir(), 'vsk-skill-scan-'));
648
+ const discovered = discoverSkills(root);
649
+ // Two skills can share a basename across groups; the report must say which is
650
+ // which, so an ambiguous name is qualified with its parent directory.
651
+ const basenameCounts = {};
652
+ for (const dir of discovered) basenameCounts[basename(dir)] = (basenameCounts[basename(dir)] ?? 0) + 1;
653
+
654
+ for (const [index, dir] of discovered.entries()) {
655
+ const bare = basename(dir);
656
+ // Sanitized: this comes from a DIRECTORY NAME on disk, which in a
657
+ // third-party skill tree is attacker-chosen, and it is printed to a terminal
658
+ // and embedded in every block line.
659
+ const name = safe(basenameCounts[bare] > 1 ? `${basename(resolve(dir, '..'))}/${bare}` : bare);
660
+ // Indexed, not named: two-level discovery makes duplicate basenames possible
661
+ // (`<root>/a/foo/` and `<root>/b/foo/`), and a shared report path would let
662
+ // one skill's result stand in for another's — a wrong verdict that looks
663
+ // exactly like a right one. `index` is unique per run by construction.
664
+ const reportPath = join(outDir, `${index}.json`);
665
+ const args = ['scan', dir, '--format', 'json', '--output', reportPath];
666
+ if (!llm) args.push('--no-llm');
667
+ if (baselineUsable) args.push('--baseline', baselinePath);
668
+
669
+ try {
670
+ // `env` is passed explicitly, as ship-gate.mjs does: under Bun a mutated
671
+ // process.env is NOT inherited by execFileSync children, so the seam and
672
+ // any scanner configuration (SKILLSPECTOR_PROVIDER, etc.) would be lost.
673
+ execFileSync(binary, args, {
674
+ stdio: [DISCARD, 'pipe', 'pipe'],
675
+ env: { ...process.env },
676
+ // A hung or runaway scanner must fail the gate, not hold it open forever.
677
+ timeout: Number(process.env.VSK_SKILLSPECTOR_TIMEOUT_MS) || 300_000,
678
+ maxBuffer: 64 * 1024 * 1024,
679
+ });
680
+ } catch (error) {
681
+ // ENOENT means the binary itself is absent — a fact about the environment,
682
+ // not about any skill, and it stops the whole run.
683
+ if (error.code === 'ENOENT') return { ...base, binaryMissing: true };
684
+ // Any other non-zero exit is expected: the scanner exits 1 whenever the
685
+ // score exceeds 50, which says nothing about whether a finding blocks.
686
+ // The report is the evidence; only its absence is a failure.
687
+ }
688
+
689
+ let report;
690
+ try {
691
+ report = JSON.parse(readFileSync(reportPath, 'utf8'));
692
+ } catch (error) {
693
+ base.scanErrors.push({ skill: name, message: safe(error.message) });
694
+ continue;
695
+ }
696
+ // A report whose shape we do not recognise must fail loudly. Reading a
697
+ // missing `issues` key as "no findings" is the exact false-success this
698
+ // guard exists to prevent, and the scanner is upstream software on a fast
699
+ // cadence — a renamed key would otherwise turn every skill green.
700
+ if (!report || typeof report !== 'object' || Array.isArray(report) || !Array.isArray(report.issues)) {
701
+ base.scanErrors.push({
702
+ skill: name,
703
+ message: 'report has no "issues" array — unrecognised shape, refusing to read it as "no findings"',
704
+ });
705
+ continue;
706
+ }
707
+
708
+ const assessment = report.risk_assessment ?? {};
709
+ const completeness = report.analysis_completeness ?? {};
710
+ base.skills.push({
711
+ name,
712
+ score: assessment.score ?? null,
713
+ severity: assessment.severity ?? 'UNKNOWN',
714
+ executionSuccessful: report.execution_successful !== false,
715
+ suppressedCount: report.suppressed_count ?? 0,
716
+ // The scanner derives status from: "failed" when a ledger exception was
717
+ // fatal, else "partial" when anything was left uninspected or an analyzer
718
+ // reported a limitation, else "complete". `limitations` is the signal that
719
+ // an ANALYZER did not finish — distinct from the reference-resolution
720
+ // exceptions that make a healthy scan of documentation-heavy skills
721
+ // "partial". See the degradation rules in evaluateScan.
722
+ // The files the scanner itself says it could not finish reading, excluding
723
+ // `reference_unresolved` — that one is a path citation, not lost coverage,
724
+ // and it is already reported as an AE1 finding.
725
+ partialPaths: [
726
+ ...new Set(
727
+ (completeness.ledger_exceptions ?? [])
728
+ .filter((e) => e?.outcome === 'partial' && e?.reason_code !== 'reference_unresolved' && e?.path)
729
+ .map((e) => safe(e.path)),
730
+ ),
731
+ ].sort(),
732
+ completeness: {
733
+ status: completeness.status ?? 'unknown',
734
+ // Sanitized: analyzer messages are printed in block lines and can carry
735
+ // text derived from the scanned content.
736
+ limitations: (Array.isArray(completeness.limitations) ? completeness.limitations : []).map(safe),
737
+ entirelyUninspected: completeness.entirely_uninspected_files ?? 0,
738
+ partiallyInspected: completeness.partially_inspected_files ?? 0,
739
+ fullyInspected: completeness.fully_inspected_files ?? 0,
740
+ coveragePercent: completeness.coverage_percent ?? null,
741
+ },
742
+ // The scanner's own list of what the baseline silenced. The Security axis
743
+ // is told to judge whether each suppression was scoped to its cause, which
744
+ // it cannot do from a count — and this evidence is right here in the report.
745
+ suppressed: Array.isArray(report.suppressed) ? report.suppressed : [],
746
+ issues: (report.issues ?? []).map(normalizeIssue),
747
+ });
748
+ }
749
+
750
+ return base;
751
+ }
752
+
753
+ // The provisioning sequence, extracted so it is testable: the CLI passes the
754
+ // real commands, unit tests pass fakes. Returns the `skillspector` report block.
755
+ //
756
+ // `mode` is a MACHINE policy, never a per-scan-root one — see the CLI, which
757
+ // reads it from the profile even when --root chose what to scan.
758
+ export async function provisionForRun({
759
+ mode,
760
+ locate,
761
+ provision,
762
+ versionOf,
763
+ pathVisible,
764
+ fetchLatest,
765
+ }) {
766
+ const state = {
767
+ mode,
768
+ channel: null,
769
+ path: null,
770
+ version: null,
771
+ action: 'none',
772
+ before: null,
773
+ after: null,
774
+ changed: [],
775
+ message: '',
776
+ available: null,
777
+ resolvedOutsidePath: false,
778
+ };
779
+
780
+ let located = locate();
781
+ const result = provision({ mode, located });
782
+ state.action = result.action;
783
+ state.changed = result.changed;
784
+ state.message = result.message;
785
+ state.before = result.before;
786
+ state.after = result.after;
787
+ // An install lands somewhere only a fresh probe knows about.
788
+ if (result.action === 'installed') located = locate();
789
+
790
+ if (located) {
791
+ state.channel = located.channel;
792
+ state.path = located.path;
793
+ state.version = versionOf(located.path);
794
+ // A fresh install has no "before", so its "after" is simply the version now
795
+ // installed — otherwise the report says "(unchanged)" about a tool that was
796
+ // not there a moment ago.
797
+ if (state.action === 'installed') state.after = state.version;
798
+ // Located through its channel while a bare PATH lookup cannot see it. This
799
+ // is reported, never warned about: it is the ordinary case this feature
800
+ // exists to serve, and making it a warn would pin the exit code at 1
801
+ // forever for exactly the setup that motivated the work.
802
+ state.resolvedOutsidePath = !pathVisible();
803
+ }
804
+
805
+ if (mode === 'notify') state.available = await fetchLatest();
806
+ return state;
807
+ }
808
+
809
+ const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
810
+ if (invokedDirectly) {
811
+ const argv = process.argv.slice(2);
812
+ const get = (flag) => {
813
+ const index = argv.indexOf(flag);
814
+ return index === -1 ? undefined : argv[index + 1];
815
+ };
816
+ const json = argv.includes('--json');
817
+ // Forces this one run to leave the machine untouched, whatever the knob says.
818
+ const noProvision = argv.includes('--no-provision');
819
+ const devMdPath = get('--dev-md') || '.vegastack/dev.md';
820
+
821
+ let root = get('--root');
822
+ const explicitRoot = Boolean(root);
823
+ let skipped = false;
824
+ let outcome = { blocks: [], warns: [] };
825
+ let facts = { skills: [] };
826
+ let baselinePath = get('--baseline') ?? null;
827
+ let updateMode = 'auto';
828
+ let binary;
829
+ // Warns raised before the scan runs. `outcome` is REASSIGNED by evaluateScan,
830
+ // so anything pushed onto it beforehand would be silently discarded.
831
+ const preWarns = [];
832
+ const skillspector = {
833
+ mode: 'auto',
834
+ channel: null,
835
+ path: null,
836
+ version: null,
837
+ action: 'none',
838
+ before: null,
839
+ after: null,
840
+ changed: [],
841
+ message: '',
842
+ available: null,
843
+ resolvedOutsidePath: false,
844
+ };
845
+
846
+ // The update mode is read from the profile ALWAYS, including for --root runs.
847
+ // --root chooses what to scan; it never decides whether this machine may be
848
+ // written to. Skipping this is how `skillspector-update: off` got ignored on
849
+ // exactly the invocation this skill's README documents for vetting a stranger's skill.
850
+ {
851
+ let profileForMode = null;
852
+ try {
853
+ profileForMode = readFileSync(devMdPath, 'utf8');
854
+ } catch {
855
+ // An explicit --root may legitimately run outside any project.
856
+ }
857
+ if (profileForMode !== null) {
858
+ const declared = [...new Set(updateModeDeclarations(profileForMode))];
859
+ const unusable = declared.length > 1 || declared.some((value) => !UPDATE_MODES.has(value));
860
+ // A profile we cannot read unambiguously must not authorise writing to
861
+ // the machine. The non---root path below turns the same conditions into
862
+ // blocks; here the run continues, but touching nothing.
863
+ updateMode = unusable ? 'off' : resolveUpdateMode(profileForMode);
864
+ if (unusable && explicitRoot) {
865
+ preWarns.push(
866
+ `${devMdPath} does not give skillspector-update a single recognised value — this run left the machine untouched`,
867
+ );
868
+ }
869
+ }
870
+ }
871
+
872
+ if (!explicitRoot) {
873
+ // "Could not read the profile" and "the profile says none" are different
874
+ // answers. Collapsing them let the guard report a clean skip from any
875
+ // directory that simply has no dev.md — a gate that silently disables
876
+ // itself when run from the wrong cwd.
877
+ let devMd = null;
878
+ try {
879
+ devMd = readFileSync(devMdPath, 'utf8');
880
+ } catch (error) {
881
+ outcome.blocks.push(`cannot read ${devMdPath} (${error.code ?? error.message}) — pass --dev-md <path>, or --root to scan a directory directly`);
882
+ }
883
+ if (devMd !== null) {
884
+ const declared = [...new Set(scanRootDeclarations(devMd))];
885
+ if (declared.length > 1) {
886
+ outcome.blocks.push(
887
+ `${devMdPath} gives skill-scan conflicting values (${declared.join(', ')}) — an example line above the real knob would otherwise silently decide the gate; leave exactly one`,
888
+ );
889
+ }
890
+ const updateDeclared = [...new Set(updateModeDeclarations(devMd))];
891
+ if (updateDeclared.length > 1) {
892
+ outcome.blocks.push(
893
+ `${devMdPath} gives skillspector-update conflicting values (${updateDeclared.join(', ')}) — leave exactly one`,
894
+ );
895
+ }
896
+ const unknown = updateDeclared.filter((value) => !UPDATE_MODES.has(value));
897
+ if (unknown.length > 0) {
898
+ // Defaulting an unrecognised value to `auto` would silently install
899
+ // software because of a typo. The guard refuses instead.
900
+ outcome.blocks.push(
901
+ `${devMdPath} sets skillspector-update to ${unknown.join(', ')} — expected one of off, notify, auto`,
902
+ );
903
+ }
904
+ updateMode = resolveUpdateMode(devMd);
905
+
906
+ root = resolveScanRoot(devMd);
907
+ skipped = root === null && declared.length <= 1;
908
+ // The project's own suppressions apply to the project's own skills. They
909
+ // are NOT inherited by an ad-hoc `--root` scan of someone else's skill,
910
+ // where a rule written for our content could silence a real finding in
911
+ // theirs.
912
+ if (!skipped && !baselinePath && existsSync(DEFAULT_BASELINE)) baselinePath = DEFAULT_BASELINE;
913
+ }
914
+ }
915
+
916
+ if (!skipped && outcome.blocks.length === 0) {
917
+ skillspector.mode = noProvision ? 'off' : updateMode;
918
+
919
+ // VSK_SKILLSPECTOR is the test seam, and it means "this exact binary" — so
920
+ // it suppresses locating AND provisioning. Without that, every CLI test
921
+ // would shell out to whatever uv/brew happen to hold on the machine running
922
+ // the suite, and a unit suite that installs software is not a unit suite.
923
+ if (!process.env.VSK_SKILLSPECTOR) {
924
+ Object.assign(
925
+ skillspector,
926
+ await provisionForRun({
927
+ mode: skillspector.mode,
928
+ locate: () => locateSkillspector(),
929
+ provision: ({ mode, located }) => provisionSkillspector({ mode, located }),
930
+ versionOf: (path) => readVersion({ path }),
931
+ pathVisible: () => defaultRun('skillspector', ['--version']).ok,
932
+ fetchLatest: () => latestRelease(),
933
+ }),
934
+ );
935
+ binary = skillspector.path ?? undefined;
936
+ }
937
+
938
+ // An uncaught throw would leave node exiting 1 — which in this guard's own
939
+ // scheme reads as "pass with warnings". A crash is not a pass.
940
+ try {
941
+ facts = gatherFacts({ root, baselinePath, llm: argv.includes('--llm'), binary });
942
+ facts.skillspector = skillspector;
943
+ outcome = evaluateScan(facts);
944
+ } catch (error) {
945
+ facts = { skills: [], skillspector };
946
+ outcome = { blocks: [`the scan failed unexpectedly: ${error.message}`], warns: [] };
947
+ }
948
+ }
949
+
950
+ outcome = { ...outcome, warns: [...preWarns, ...outcome.warns] };
951
+ const ok = outcome.blocks.length === 0;
952
+ if (json) {
953
+ const envelope = JSON.stringify({
954
+ guard: 'skill-scan',
955
+ ok,
956
+ skipped,
957
+ skillspector,
958
+ ...outcome,
959
+ // The full normalized issue list, not a count: dev-review's Security axis
960
+ // is told to read the source at each finding's file:line and to judge
961
+ // whether a suppression was scoped to its cause. A count makes both
962
+ // impossible, and this report is the axis's input.
963
+ skills: facts.skills.map(({ name, score, severity, suppressedCount, suppressed, completeness, issues }) => ({
964
+ name, score, severity, suppressedCount, suppressed, completeness, findings: issues.length, issues,
965
+ })),
966
+ }, null, 2);
967
+ // console.log writes asynchronously when stdout is a pipe. A warning-status
968
+ // process can otherwise exit after the platform pipe buffer (64 KiB on macOS)
969
+ // accepts only a prefix, leaving machine consumers with truncated JSON.
970
+ writeFileSync(1, `${envelope}\n`);
971
+ } else if (skipped) {
972
+ console.log(`skill-scan: skipped — ${devMdPath} names no scan root (skill-scan: none or absent)`);
973
+ } else if (facts.skills.length === 0 && outcome.blocks.length > 0) {
974
+ console.log('skill-scan: BLOCKED');
975
+ for (const b of outcome.blocks) console.log(` block: ${b}`);
976
+ } else {
977
+ // The version/dependency change reads BEFORE the findings: after an
978
+ // upgrade, new findings are the tool having learned something, not the diff
979
+ // having broken something, and an operator who cannot see that debugs the
980
+ // wrong thing.
981
+ if (skillspector.action === 'installed' || skillspector.action === 'upgraded') {
982
+ const span = skillspector.before === skillspector.after
983
+ ? `version ${skillspector.version ?? 'unknown'} (unchanged)`
984
+ : `version ${skillspector.before ?? 'none'} → ${skillspector.after ?? skillspector.version ?? 'unknown'}`;
985
+ console.log(`skill-scan: skillspector ${skillspector.action} via ${skillspector.channel ?? 'uv'} — ${span}`);
986
+ for (const line of skillspector.changed) console.log(` ${line}`);
987
+ }
988
+ if (skillspector.action === 'failed') {
989
+ console.log(`skill-scan: skillspector update failed, continuing with the installed copy — ${skillspector.message}`);
990
+ }
991
+ if (skillspector.available && skillspector.available !== skillspector.version) {
992
+ console.log(`skill-scan: skillspector ${skillspector.available} is available (installed: ${skillspector.version ?? 'unknown'})`);
993
+ }
994
+ console.log(`skill-scan: ${ok ? (outcome.warns.length ? 'pass with warnings' : 'pass') : 'BLOCKED'}`);
995
+ for (const entry of facts.skills) {
996
+ console.log(` ${entry.name}: score ${entry.score} ${entry.severity} — ${entry.issues.length} finding(s)`);
997
+ }
998
+ for (const b of outcome.blocks) console.log(` block: ${b}`);
999
+ for (const w of outcome.warns) console.log(` warn: ${w}`);
1000
+ }
1001
+ process.exit(ok ? (outcome.warns.length > 0 ? 1 : 0) : 2);
1002
+ }