@vibe-agent-toolkit/cli 0.1.41-rc.8 → 0.1.42-rc.1

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 (76) hide show
  1. package/dist/bin.js +11 -1
  2. package/dist/bin.js.map +1 -1
  3. package/dist/commands/audit/distributed-tree.d.ts +172 -0
  4. package/dist/commands/audit/distributed-tree.d.ts.map +1 -0
  5. package/dist/commands/audit/distributed-tree.js +319 -0
  6. package/dist/commands/audit/distributed-tree.js.map +1 -0
  7. package/dist/commands/audit.d.ts +35 -0
  8. package/dist/commands/audit.d.ts.map +1 -1
  9. package/dist/commands/audit.js +312 -93
  10. package/dist/commands/audit.js.map +1 -1
  11. package/dist/commands/build.d.ts.map +1 -1
  12. package/dist/commands/build.js +16 -4
  13. package/dist/commands/build.js.map +1 -1
  14. package/dist/commands/claude/marketplace/git-publish.d.ts.map +1 -1
  15. package/dist/commands/claude/marketplace/git-publish.js +21 -3
  16. package/dist/commands/claude/marketplace/git-publish.js.map +1 -1
  17. package/dist/commands/claude/marketplace/validate.d.ts +31 -3
  18. package/dist/commands/claude/marketplace/validate.d.ts.map +1 -1
  19. package/dist/commands/claude/marketplace/validate.js +105 -12
  20. package/dist/commands/claude/marketplace/validate.js.map +1 -1
  21. package/dist/commands/claude/plugin/build.d.ts +9 -2
  22. package/dist/commands/claude/plugin/build.d.ts.map +1 -1
  23. package/dist/commands/claude/plugin/build.js +90 -8
  24. package/dist/commands/claude/plugin/build.js.map +1 -1
  25. package/dist/commands/claude/plugin/plugin-files.d.ts.map +1 -1
  26. package/dist/commands/claude/plugin/plugin-files.js +6 -2
  27. package/dist/commands/claude/plugin/plugin-files.js.map +1 -1
  28. package/dist/commands/claude/plugin/tree-copy.d.ts +40 -0
  29. package/dist/commands/claude/plugin/tree-copy.d.ts.map +1 -1
  30. package/dist/commands/claude/plugin/tree-copy.js +100 -4
  31. package/dist/commands/claude/plugin/tree-copy.js.map +1 -1
  32. package/dist/commands/doctor.js +1 -1
  33. package/dist/commands/doctor.js.map +1 -1
  34. package/dist/commands/positional-args.d.ts +36 -0
  35. package/dist/commands/positional-args.d.ts.map +1 -0
  36. package/dist/commands/positional-args.js +52 -0
  37. package/dist/commands/positional-args.js.map +1 -0
  38. package/dist/commands/skill/review-checklist.d.ts.map +1 -1
  39. package/dist/commands/skill/review-checklist.js +5 -0
  40. package/dist/commands/skill/review-checklist.js.map +1 -1
  41. package/dist/commands/skills/build.d.ts +258 -9
  42. package/dist/commands/skills/build.d.ts.map +1 -1
  43. package/dist/commands/skills/build.js +698 -111
  44. package/dist/commands/skills/build.js.map +1 -1
  45. package/dist/commands/skills/scope-guard.d.ts +64 -0
  46. package/dist/commands/skills/scope-guard.d.ts.map +1 -0
  47. package/dist/commands/skills/scope-guard.js +86 -0
  48. package/dist/commands/skills/scope-guard.js.map +1 -0
  49. package/dist/commands/skills/validate-command.d.ts.map +1 -1
  50. package/dist/commands/skills/validate-command.js +19 -1
  51. package/dist/commands/skills/validate-command.js.map +1 -1
  52. package/dist/commands/skills/validate.d.ts.map +1 -1
  53. package/dist/commands/skills/validate.js +37 -7
  54. package/dist/commands/skills/validate.js.map +1 -1
  55. package/dist/commands/validate.d.ts.map +1 -1
  56. package/dist/commands/validate.js +18 -4
  57. package/dist/commands/validate.js.map +1 -1
  58. package/dist/commands/verify.d.ts +77 -7
  59. package/dist/commands/verify.d.ts.map +1 -1
  60. package/dist/commands/verify.js +254 -57
  61. package/dist/commands/verify.js.map +1 -1
  62. package/dist/utils/issue-rendering.d.ts +42 -0
  63. package/dist/utils/issue-rendering.d.ts.map +1 -1
  64. package/dist/utils/issue-rendering.js +53 -0
  65. package/dist/utils/issue-rendering.js.map +1 -1
  66. package/dist/utils/issue-severity.d.ts +51 -0
  67. package/dist/utils/issue-severity.d.ts.map +1 -0
  68. package/dist/utils/issue-severity.js +63 -0
  69. package/dist/utils/issue-severity.js.map +1 -0
  70. package/dist/version.d.ts +22 -2
  71. package/dist/version.d.ts.map +1 -1
  72. package/dist/version.js +31 -2
  73. package/dist/version.js.map +1 -1
  74. package/docs/index.md +12 -0
  75. package/docs/skills.md +16 -2
  76. package/package.json +15 -12
@@ -7,20 +7,34 @@
7
7
  * validates, and packages into dist/skills/<name>/.
8
8
  */
9
9
  import { existsSync } from 'node:fs';
10
- import { rm } from 'node:fs/promises';
10
+ import { mkdir, mkdtemp, rename, rm } from 'node:fs/promises';
11
11
  import { allowUnusedIssues, calculateValidationStatus, countBySeverity, createAllowUsageLedger, } from '@vibe-agent-toolkit/agent-schema';
12
12
  import { packageSkills, packagingConfigToPackageOptions, skillNameToFsPath, validateSkillForPackaging, } from '@vibe-agent-toolkit/agent-skills';
13
- import { safePath } from '@vibe-agent-toolkit/utils';
13
+ import { safePath, toForwardSlash } from '@vibe-agent-toolkit/utils';
14
14
  import { Command } from 'commander';
15
15
  import * as yaml from 'yaml';
16
- import { handleCommandError, handleValidationGateFailure } from '../../utils/command-error.js';
16
+ import { handleCommandError } from '../../utils/command-error.js';
17
17
  import { loadConfig } from '../../utils/config-loader.js';
18
- import { collectPostBuildIssues, formatIssueLines, formatIssueSetHeading, formatRunIssueLines, issuesToRenderAtVerbosity, sumSeverityCounts, } from '../../utils/issue-rendering.js';
18
+ import { collectPostBuildIssues, countCollapsedFindings, formatCollapsedFindingsHint, formatIssueLines, formatIssueSetHeading, formatPackagedFileCount, formatRunIssueLines, issuesToRenderAtVerbosity, sumSeverityCounts, } from '../../utils/issue-rendering.js';
19
19
  import { requireProjectRoot } from '../../utils/project-root-policy.js';
20
20
  import { collectDeclaredEvalSuites, mergeSkillPackagingConfig } from '../../utils/skill-packaging-config.js';
21
21
  import { applyConfigVerdicts } from '../../utils/verdict-helpers.js';
22
22
  import { filterSkillsByName, setupCommandContext, writeYamlHeader, } from './command-helpers.js';
23
+ import { rejectUnscopablePath } from './scope-guard.js';
23
24
  import { discoverSkillsFromConfig } from './skill-discovery.js';
25
+ /**
26
+ * What a mis-scoped `vat skills build` used to do.
27
+ *
28
+ * The twin of the `vat skills validate` hole, and strictly the worse of the two:
29
+ * a mistyped path printed "No skills configuration found — nothing to build" and
30
+ * exited **0**, so a release pipeline whose build step named the wrong directory
31
+ * published having built nothing, with a green tick. Measured on a project whose
32
+ * bare `vat skills build` finds a skill: `vat skills build nope` exited 0.
33
+ */
34
+ const SCOPE_SUBJECT = {
35
+ command: 'vat skills build',
36
+ silentSuccess: 'nothing to build',
37
+ };
24
38
  export function createBuildCommand() {
25
39
  const command = new Command('build');
26
40
  command
@@ -30,7 +44,10 @@ export function createBuildCommand() {
30
44
  .option('--dry-run', 'Preview build without creating files')
31
45
  .option('-v, --verbose', 'Show every individual finding, not just the errors')
32
46
  .option('--debug', 'Enable debug logging')
33
- .action(buildCommand)
47
+ .action(async (pathArg, options) => {
48
+ rejectUnscopablePath(SCOPE_SUBJECT, pathArg);
49
+ await buildCommand(pathArg, options);
50
+ })
34
51
  .addHelpText('after', `
35
52
  Description:
36
53
  Discovers SKILL.md files using include/exclude globs from the skills
@@ -68,39 +85,87 @@ Output:
68
85
  YAML summary -> stdout (for programmatic parsing)
69
86
  Build progress -> stderr (for human reading)
70
87
 
71
- On stderr, every findings heading names the whole set and its severity
72
- breakdown, and errors are always printed in full beneath it. Warnings and
73
- info findings stay collapsed into that heading unless --verbose, because
74
- they are the high-cardinality ones (one adopter skill carries 348
75
- LINK_DROPPED_BY_DEPTH warnings alone). The stdout YAML is NOT affected by
76
- --verbose: it carries every finding at every verbosity.
88
+ On stderr, every findings heading names its skill, the whole set and its
89
+ severity breakdown, and errors are always printed in full beneath it.
90
+ Warnings and info findings stay collapsed into that heading unless
91
+ --verbose, because they are the high-cardinality ones (one adopter skill
92
+ carries 348 LINK_DROPPED_BY_DEPTH warnings alone); one line at the end of
93
+ the run names how many were collapsed.
94
+
95
+ --verbose changes the stderr report ONLY. The stdout YAML is identical at
96
+ either verbosity: every row carries its full issues array (code, message,
97
+ location, fix) whether or not the human report printed it, because filtering
98
+ a machine-readable document by a human report's verbosity breaks the
99
+ consumers that parse it.
77
100
 
78
- skills: one row per skill that produced a bundle
101
+ skills: one row per skill whose bundle EXISTS on disk — empty
102
+ whenever outputCommitted is false
103
+ skillsStaged: the same rows for a run that built its bundles and then
104
+ aborted the promotion. Deliberately a different key, and
105
+ deliberately carrying no outputPath: nothing was written, so
106
+ there is no path to publish. A consumer that installs,
107
+ symlinks or checksums what a build produced reads skills[]
108
+ and correctly finds nothing.
79
109
  failedSkills: one row per skill that could not be packaged AT ALL (no
80
110
  bundle exists for it); each carries name, error and an
81
111
  issueCounts of one error
112
+ validationFailedSkills / skillsFailedValidation:
113
+ the THIRD failure mode — skills rejected by the PRE-build
114
+ source validation, so packaging was never attempted for
115
+ them. Distinct from failedSkills (packaging was attempted
116
+ and threw) because the fix is different: these carry their
117
+ own issueCounts, and the findings behind them are on stderr.
82
118
  skillsWithErrors / skillsWithErrorNames:
83
- the OTHER failure mode — skills that packaged fine and then
119
+ the FOURTH failure mode — skills that packaged fine and then
84
120
  emitted post-build validation errors. They are counted in
85
- skillsBuilt, not skillsFailed, so read BOTH before concluding
86
- a run was clean: the exit code follows this one too.
121
+ skillsBuilt, not skillsFailed, so read ALL of these before
122
+ concluding a run was clean: the exit code follows this one too.
87
123
  runIssueCounts: findings that belong to the run rather than to any one
88
124
  skill (ALLOW_UNUSED)
89
125
  issueCounts: the run total, which reconciles against the rows above:
90
126
 
91
127
  issueCounts = sum(skills[].issueCounts)
128
+ + sum(skillsStaged[].issueCounts)
92
129
  + sum(failedSkills[].issueCounts)
130
+ + sum(validationFailedSkills[].issueCounts)
93
131
  + runIssueCounts
94
132
 
95
133
  Every error, warning and info in the header total is
96
- therefore attributable to a row you can point at.
134
+ therefore attributable to a row you can point at. (Exactly
135
+ one of skills / skillsStaged is ever non-empty, so no
136
+ packaged bundle is counted twice.)
137
+ outputCommitted:
138
+ whether dist/skills was REPLACED by this run. A build writes
139
+ into a staging directory and promotes it only if the whole
140
+ run is clean, so 'false' means nothing on disk changed and
141
+ the previous dist/skills (if any) is exactly as it was — and
142
+ the packaged rows are published as skillsStaged, without an
143
+ outputPath, because none was written.
144
+ promotionError: present ONLY when the promotion/discard step itself failed
145
+ (EACCES, ENOSPC, or the ENOTEMPTY a concurrent build in the
146
+ same dist/ produces). Its text names what is on disk and the
147
+ single 'mv' that recovers it, because the parked path carries
148
+ a random suffix nobody can reconstruct. Read outputCommitted
149
+ beside it: a promotion that landed the tree and then failed to
150
+ clean up still committed the output.
97
151
 
98
152
  Exit Codes:
99
153
  0 - All skills built successfully (or dry-run preview)
100
- 1 - One or more skills emitted validation errors, or could not be packaged
101
- at all (a skill that fails to package does NOT abort the others; the
102
- rest still build and the failures are listed under failedSkills)
103
- 2 - System error (config invalid, directory not found)
154
+ 1 - One or more skills failed pre-build validation, emitted post-build
155
+ validation errors, or could not be packaged at all. No single skill
156
+ aborts the run: every failure of every kind is collected and reported
157
+ in ONE pass, so one build cycle surfaces all the work. Because the run
158
+ failed, outputCommitted is false and dist/skills was left untouched.
159
+ 2 - System error (config invalid, directory not found), a [path] argument
160
+ this command cannot read a config from, or a failure on the promotion
161
+ path — see promotionError. On a promotion failure the YAML document is
162
+ still written; it is the only report that says where the output went.
163
+
164
+ A [path] naming a directory that does not exist, is not a directory, or holds
165
+ no vibe-agent-toolkit.config.yaml is REFUSED with exit 2. It previously
166
+ printed "No skills configuration found — nothing to build" and exited 0, so a
167
+ pipeline whose build step named the wrong directory shipped having built
168
+ nothing, and reported success.
104
169
 
105
170
  Requirements:
106
171
  projectRoot: required (errors if no vibe-agent-toolkit.config.yaml or .git/ ancestor)
@@ -142,13 +207,20 @@ export function formatPreBuildIssueReport(validationResult, verbose) {
142
207
  return lines;
143
208
  }
144
209
  /**
145
- * Render post-build integrity issues, each prefixed by its OWN resolved severity.
210
+ * Render ONE skill's post-build integrity issues, each prefixed by its OWN
211
+ * resolved severity.
146
212
  *
147
213
  * Reads BOTH post-build channels (see `collectPostBuildIssues`) so a build that
148
214
  * failed purely on the built-output validation still shows the findings that
149
215
  * failed it, and the heading names the set's actual severity mix rather than
150
216
  * calling every set by its worst member.
151
217
  *
218
+ * The heading NAMES THE SKILL, matching `vat claude plugin build`'s per-skill
219
+ * heading, because this line is printed in the outcome pass — the validation pass
220
+ * has already emitted every `Building skill: <name>` banner, so at scale (92
221
+ * banners, then 86 outcome blocks) an unnamed heading sits under an unrelated
222
+ * skill's banner and is read as that skill's findings.
223
+ *
152
224
  * The heading is unconditional and counts the WHOLE set; `verbose` decides only
153
225
  * which findings get a block beneath it (see `issuesToRenderAtVerbosity`). This
154
226
  * loop printing every occurrence is what made a 90-skill build emit 6,552 stderr
@@ -157,27 +229,38 @@ export function formatPreBuildIssueReport(validationResult, verbose) {
157
229
  * still renders its heading: collapsing that too would turn a warning-carrying
158
230
  * build into silence, which is the reassuring direction this module warns about.
159
231
  *
232
+ * The trailing colon is what varies: it introduces the blocks below, so a heading
233
+ * with nothing beneath it does not print one. A colon promising a list that the
234
+ * current verbosity will not print is the defect this half fixes; the other half
235
+ * is the run-level `--verbose` hint (see `formatCollapsedFindingsHint`).
236
+ *
160
237
  * Pure: returns the lines instead of writing them, so the whole rendered set is
161
238
  * assertable without capturing a stream.
162
239
  */
163
- export function formatPostBuildIssueReport(result, verbose) {
240
+ export function formatPostBuildIssueReport(skillName, result, verbose) {
164
241
  const issues = collectPostBuildIssues(result);
165
242
  if (issues.length === 0)
166
243
  return [];
167
- const lines = [` ${formatIssueSetHeading(issues, 'post-build')}:`];
168
- for (const issue of issuesToRenderAtVerbosity(issues, verbose)) {
244
+ const rendered = issuesToRenderAtVerbosity(issues, verbose);
245
+ const heading = ` ${skillName}: ${formatIssueSetHeading(issues, 'post-build')}`;
246
+ const lines = [rendered.length === 0 ? heading : `${heading}:`];
247
+ for (const issue of rendered) {
169
248
  lines.push(...formatIssueLines(issue, ' '));
170
249
  }
171
250
  return lines;
172
251
  }
173
252
  /**
174
- * Log post-build integrity issues to stderr (the human stream; stdout is
175
- * reserved for the YAML summary).
253
+ * Log one skill's post-build integrity issues to stderr (the human stream;
254
+ * stdout is reserved for the YAML summary).
255
+ *
256
+ * Returns how many findings this verbosity collapsed, so the run can print ONE
257
+ * hint naming the total rather than one per skill.
176
258
  */
177
- function logPostBuildIssues(result, logger, verbose) {
178
- for (const line of formatPostBuildIssueReport(result, verbose)) {
259
+ function logPostBuildIssues(skillName, result, logger, verbose) {
260
+ for (const line of formatPostBuildIssueReport(skillName, result, verbose)) {
179
261
  logger.info(line);
180
262
  }
263
+ return countCollapsedFindings(collectPostBuildIssues(result), verbose);
181
264
  }
182
265
  /**
183
266
  * Display allowed issues for context
@@ -190,7 +273,22 @@ function displayIgnoredErrors(validationResult, logger) {
190
273
  }
191
274
  }
192
275
  }
193
- async function validateSkillOrExit(input) {
276
+ /**
277
+ * Run the PRE-build source validation for one skill and REPORT the verdict —
278
+ * never act on it.
279
+ *
280
+ * This used to `process.exit(1)` from inside the per-skill loop, so a run named
281
+ * the first bad skill and nothing else. Measured on a 90-skill adopter: 3 of the
282
+ * 28 errors and 1 of the 6 broken skills, which is six full build cycles to
283
+ * discover the work. Returning the failure lets {@link runSkillBuild} collect
284
+ * every one of them and fail once, at the end, with the whole list.
285
+ *
286
+ * The returned counts come from `allErrors` — the full EMITTED set including
287
+ * warnings and info (its name lies; see `PackagingValidationResult`) — so the
288
+ * row this becomes carries the same three-valued distribution every other row
289
+ * publishes, rather than a flat "one error" stand-in.
290
+ */
291
+ async function validateSkillBeforeBuild(input) {
194
292
  const { skillName, sourcePath, packagingConfig, logger, locationRoot, allowLedger, projectSkills, verbose } = input;
195
293
  logger.debug(` Validating skill: ${skillName}`);
196
294
  // The run's ledger, not this call's: an allow entry scoped to a SOURCE
@@ -206,20 +304,18 @@ async function validateSkillOrExit(input) {
206
304
  if (validationResult.ignoredErrors.length > 0) {
207
305
  logger.debug(` ${validationResult.ignoredErrors.length} issue(s) allowed by config`);
208
306
  }
209
- return;
307
+ return undefined;
210
308
  }
211
- // Validation failed - display every emitted finding and exit
309
+ // Validation failed — display every emitted finding, then hand the verdict
310
+ // back. No `Build aborted` line: the run continues, and saying otherwise here
311
+ // would contradict the 89 skills that go on to build beneath this message.
212
312
  logger.error(`\nSkill validation failed: ${skillName}`);
213
313
  logger.error(` Source: ${sourcePath}`);
214
314
  for (const line of formatPreBuildIssueReport(validationResult, verbose)) {
215
315
  logger.error(line);
216
316
  }
217
317
  displayIgnoredErrors(validationResult, logger);
218
- logger.error(`\n Build aborted due to validation errors`);
219
- // Every finding above went to STDERR. Exiting here without the stdout summary
220
- // is what made `vat skills build | jq .status` return an empty document
221
- // alongside exit 1 — the one case the help text documents exit 1 for.
222
- handleValidationGateFailure(skillName, validationResult.allErrors);
318
+ return { name: skillName, issueCounts: countBySeverity(validationResult.allErrors) };
223
319
  }
224
320
  /**
225
321
  * Output dry-run results
@@ -279,18 +375,39 @@ const failureIssueCounts = () => ({ errors: 1, warnings: 0, info: 0 });
279
375
  *
280
376
  * The header total reconciles against the document, by construction:
281
377
  *
282
- * issueCounts === Σ skills[].issueCounts
378
+ * issueCounts === Σ (skills[] ∪ skillsStaged[])[].issueCounts
283
379
  * + Σ failedSkills[].issueCounts
380
+ * + Σ validationFailedSkills[].issueCounts
284
381
  * + runIssueCounts
285
382
  *
383
+ * `skills` and `skillsStaged` are the same population under two names, and
384
+ * exactly one of them is ever non-empty (see the comment on `skills` below), so
385
+ * summing both is summing each packaged bundle once.
386
+ *
286
387
  * Every addend therefore has a ROW a reader can point at. Counting the failures
287
388
  * only in the header — which is how this first shipped — reproduced, one command
288
389
  * over, the exact defect `vat skills validate` was fixed for: a header reporting
289
390
  * more than its rows summed to (there, 1814 warnings against 1800), leaving a
290
391
  * consumer to hand-count a list to find out what the difference was. `--help`
291
392
  * states this identity; changing it means changing that text too.
393
+ *
394
+ * Each packaged row carries its `issues` as well as its counts. It did not, for
395
+ * a while: the findings were collected here and then dropped at the publish
396
+ * step, so the document offered a count with no `code`, no location and no fix
397
+ * string at ANY verbosity — an adopter run published 67 warnings and zero
398
+ * findings. That is acute for the four detectors this lane added: their output
399
+ * existed only on stderr, where no CI consumer reads it. Full findings on the
400
+ * rows is the shape `vat audit` and `vat skills validate --verbose` already
401
+ * publish, and it is verbosity-INDEPENDENT here for the reason stated in
402
+ * `issuesToRenderAtVerbosity`: filtering a machine-readable document by a human
403
+ * report's verbosity silently breaks the consumers that parse it.
404
+ *
405
+ * Takes the whole {@link SkillBuildRun} rather than a positional list of its
406
+ * parts: the run has four populations now, and a positional call site can
407
+ * transpose two same-shaped lists in silence.
292
408
  */
293
- export function buildYamlSummary(results, failures, duration, runIssues, skillsWithErrors) {
409
+ export function buildYamlSummary(run, duration) {
410
+ const { results, failures, runIssues, skillsWithErrors, validationFailures, outputCommitted } = run;
294
411
  const perSkill = results.map(({ name, result }) => {
295
412
  const issues = collectPostBuildIssues(result);
296
413
  return {
@@ -307,6 +424,14 @@ export function buildYamlSummary(results, failures, duration, runIssues, skillsW
307
424
  // every `path` and `location` in a report is expressed relative to"). Making this
308
425
  // one field relative ahead of that decision picks the anchor by accident and
309
426
  // leaves the document in two coordinate systems.
427
+ //
428
+ // Do not read the rest of the report as clean because the failure MESSAGES
429
+ // are: those were scrubbed of absolute project paths and are confirmed clean,
430
+ // but the SUCCESS-path fields were not. This one and the plugin lane's `dir:`
431
+ // (5 places in the measured report — see `pluginBuildCommand` in
432
+ // ../claude/plugin/build.ts) are the two that still publish `$HOME` into CI
433
+ // logs, and they wait on the same `root:` decision — fix them as a pair, or
434
+ // the two lanes end up anchored differently.
310
435
  outputPath: result.outputPath,
311
436
  filesPackaged: result.files.dependencies.length + 1,
312
437
  issueCounts: countBySeverity(issues),
@@ -328,18 +453,55 @@ export function buildYamlSummary(results, failures, duration, runIssues, skillsW
328
453
  error: message,
329
454
  issueCounts: failureIssueCounts(),
330
455
  }));
456
+ // The THIRD population, and the one an adopter meets first: a skill the
457
+ // PRE-build source validation rejected, so packaging was never attempted. It
458
+ // gets its own rows rather than joining either list above, because neither
459
+ // answers the question this one raises — `failedSkills` says packaging was
460
+ // attempted and threw, `skills` says a bundle exists. And unlike a throw, this
461
+ // failure HAS a severity distribution of its own, so it publishes the real
462
+ // counts instead of the flat `failureIssueCounts()` stand-in.
463
+ //
464
+ // KNOWN, DELIBERATELY NOT FIXED — these rows carry no `issues` key, so the
465
+ // findings that just failed the build are COUNTED here and never NAMED. On a
466
+ // real adopter monorepo build, all 28 build-blocking errors were counted but
467
+ // never named — 551 of 1707 findings were named overall. A CI consumer reading
468
+ // stdout gets a number it cannot act on; the findings themselves exist only on
469
+ // stderr.
470
+ //
471
+ // Stated explicitly, because a reader will otherwise assume it is covered: the
472
+ // sibling defect on the `skills` rows — their `issues` array dropped at the
473
+ // publish seam, see the comment on `buildYamlSummary` — was fixed, and that fix
474
+ // does NOT cover this row type. This one is not a publish-seam drop: the
475
+ // findings never reach here at all. `SkillValidationFailure` is `{ name,
476
+ // issueCounts }`, so fixing it means widening that type back at the pre-build
477
+ // validation lane, not adding a key at this call.
478
+ const validationFailedSkills = validationFailures.map(({ name, issueCounts }) => ({
479
+ name,
480
+ issueCounts,
481
+ }));
331
482
  return {
332
- status: failures.length > 0
483
+ // `promotionError` gates too: a run whose bundles all validated cleanly and
484
+ // whose promotion then threw has no issue to derive a status from, so
485
+ // deriving it from the issue channels alone would publish `success` beside an
486
+ // exit code of 2 and a `dist/skills` nobody can vouch for.
487
+ status: failures.length > 0 || validationFailures.length > 0 || run.promotionError !== undefined
333
488
  ? 'error'
334
489
  : calculateValidationStatus([...allIssues, ...runIssues]),
335
490
  issueCounts: sumSeverityCounts([
336
491
  ...perSkill.map((s) => s.issueCounts),
337
492
  ...failedSkills.map((s) => s.issueCounts),
493
+ ...validationFailedSkills.map((s) => s.issueCounts),
338
494
  runIssueCounts,
339
495
  ]),
340
496
  runIssueCounts,
341
497
  skillsBuilt: results.length,
342
498
  skillsFailed: failures.length,
499
+ skillsFailedValidation: validationFailures.length,
500
+ // Whether dist/skills was actually REPLACED. Carried through from the run
501
+ // rather than re-derived here: two definitions of "did this build change
502
+ // anything" is exactly how a report ends up contradicting the disk.
503
+ outputCommitted,
504
+ ...(run.promotionError === undefined ? {} : { promotionError: run.promotionError }),
343
505
  // The OTHER meaning of "failed", published because the exit code follows
344
506
  // THIS one and nothing in the document did. `skillsFailed` counts skills
345
507
  // that could not be packaged at all; a skill that packaged fine and then
@@ -351,13 +513,33 @@ export function buildYamlSummary(results, failures, duration, runIssues, skillsW
351
513
  // `skills` lists what exists on disk. A failed skill is published in its own
352
514
  // list rather than here, because every field of this shape (outputPath,
353
515
  // filesPackaged) would have to be invented for a bundle that was not written.
354
- skills: perSkill.map(({ name, outputPath, filesPackaged, issueCounts }) => ({
355
- name,
356
- outputPath,
357
- filesPackaged,
358
- issueCounts,
359
- })),
516
+ //
517
+ // That invariant did not survive `outputCommitted: false`, which is the OTHER
518
+ // way a row can name a path nothing was written to: the bundles are built into
519
+ // staging and the promotion is then aborted, so a failed 86-skill run published
520
+ // 86 `dist/skills/<name>` paths of which 85 did not exist (verified with
521
+ // `fs.existsSync` over every row). A CI step reading `skills[].outputPath` to
522
+ // install, symlink or checksum got 85 dead paths, with no signal but a sibling
523
+ // boolean it was not told to read.
524
+ //
525
+ // So the rows MOVE when the swap did not happen, rather than losing a field:
526
+ // a consumer iterating `skills[]` sees the empty list its documented meaning
527
+ // ("what exists on disk") demands, while `skillsStaged[]` — a name that
528
+ // promises nothing about the disk — keeps the findings and the counts the
529
+ // header identity is summed from. Those rows publish no `outputPath` at all,
530
+ // because there is no honest value for it: the staging path has been deleted
531
+ // and the final path was never written.
532
+ skills: outputCommitted ? perSkill : [],
533
+ skillsStaged: outputCommitted
534
+ ? []
535
+ : perSkill.map(({ name, filesPackaged, issueCounts, issues }) => ({
536
+ name,
537
+ filesPackaged,
538
+ issueCounts,
539
+ issues,
540
+ })),
360
541
  failedSkills,
542
+ validationFailedSkills,
361
543
  runIssues: [...runIssues],
362
544
  duration: `${duration}ms`,
363
545
  };
@@ -365,46 +547,360 @@ export function buildYamlSummary(results, failures, duration, runIssues, skillsW
365
547
  /**
366
548
  * Output build results
367
549
  */
368
- function outputBuildYaml(results, failures, duration, runIssues, skillsWithErrors) {
369
- const summary = buildYamlSummary(results, failures, duration, runIssues, skillsWithErrors);
370
- const { status, skillsBuilt, skillsFailed, issueCounts, runIssueCounts, skills, failedSkills, duration: durationText, } = summary;
371
- // In the header, beside the other two counts: this is the number the exit code
550
+ function outputBuildYaml(run, duration) {
551
+ const summary = buildYamlSummary(run, duration);
552
+ const { status, skillsBuilt, skillsFailed, skillsFailedValidation, issueCounts, runIssueCounts, skills, skillsStaged, failedSkills, validationFailedSkills, outputCommitted, duration: durationText, } = summary;
553
+ // In the header, beside the other counts: these are the numbers the exit code
372
554
  // actually follows, so a reader who stops at the header is not misled by it.
555
+ // `outputCommitted` rides up here too — a reader who sees exit 1 needs to know
556
+ // whether their dist/skills still exists before they do anything else.
373
557
  writeYamlHeader({
374
558
  status,
375
559
  skillsBuilt,
376
560
  skillsFailed,
561
+ skillsFailedValidation,
377
562
  skillsWithErrors: summary.skillsWithErrors.length,
563
+ outputCommitted,
378
564
  });
379
565
  process.stdout.write(yaml.stringify({
380
566
  issueCounts,
381
567
  runIssueCounts,
568
+ // Published in the BODY, not the header: `writeYamlHeader` writes raw
569
+ // `key: value` lines, and this text is multi-line by design (what is on
570
+ // disk, and the `mv` that recovers it). Emitting it there would produce a
571
+ // document that does not parse — the one failure mode a report about a
572
+ // failed build must not add. `status: error` and `outputCommitted` are
573
+ // already in the header, so a reader who stops there is not misled.
574
+ ...(summary.promotionError === undefined ? {} : { promotionError: summary.promotionError }),
382
575
  skills,
576
+ // Always published, even empty, for the same reason `failedSkills` is: an
577
+ // absent key reads as "this run had no such concept", and a consumer that
578
+ // has to distinguish absent from empty will get it wrong.
579
+ skillsStaged,
383
580
  failedSkills,
581
+ validationFailedSkills,
384
582
  skillsWithErrorNames: summary.skillsWithErrors,
385
583
  runIssues: summary.runIssues,
386
584
  }, { indent: 2, lineWidth: 0, aliasDuplicateObjects: false }));
387
585
  process.stdout.write(`duration: ${durationText}\n`);
388
586
  }
587
+ /** How the human stream names the output tree. One spelling, one place. */
588
+ const DIST_SKILLS_LABEL = 'dist/skills';
589
+ /** The tree a successful build promotes its staged bundles into. */
590
+ function distSkillsDir(cwd) {
591
+ return safePath.resolve(cwd, 'dist', 'skills');
592
+ }
389
593
  /**
390
- * Clean stale skill output directories before building.
391
- * Full build: clear entire dist/skills/. Single skill: clear just that skill's dir.
594
+ * Where a skill's bundle lives once a build has earned the swap.
595
+ *
596
+ * THE one definition, shared by the progress line, the published `outputPath`
597
+ * and the staging promotion — so the path a reader is told about is by
598
+ * construction the path the swap lands on.
392
599
  */
393
- async function cleanStaleSkillOutputs(cwd, skillName) {
394
- if (skillName) {
395
- const singleSkillDir = safePath.resolve(cwd, 'dist', 'skills', skillNameToFsPath(skillName));
396
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from validated option
397
- if (existsSync(singleSkillDir)) {
398
- await rm(singleSkillDir, { recursive: true, force: true });
399
- }
600
+ function finalOutputPath(cwd, skillName) {
601
+ return safePath.join(distSkillsDir(cwd), skillNameToFsPath(skillName));
602
+ }
603
+ /**
604
+ * Rewrite `value` when it names `from` or something inside it; otherwise
605
+ * `undefined`, so a caller can fall through to the next candidate base.
606
+ *
607
+ * The separator in the prefix test is load-bearing: `beginStagedBuild` parks the
608
+ * previous output at `${root}.previous`, a SIBLING whose string starts with the
609
+ * staging root. A bare `startsWith` would rewrite it into the final tree and
610
+ * report a finding against a path that never held the file.
611
+ */
612
+ function replacePathPrefix(value, from, to) {
613
+ if (value === from)
614
+ return to;
615
+ return value.startsWith(`${from}/`) ? `${to}${value.slice(from.length)}` : undefined;
616
+ }
617
+ /**
618
+ * Map any path anchored on the run's staging root onto the tree the swap lands
619
+ * on — the ONE re-anchoring, applied to every path a result publishes.
620
+ *
621
+ * Staging is transient in BOTH outcomes: `dist/.vat-skills-<rand>` is renamed
622
+ * away on success and deleted on failure, and the `mkdtemp` suffix means a
623
+ * reader cannot even reconstruct it. Any path that escapes this mapping is
624
+ * therefore unopenable by the time anyone reads it — which is what the published
625
+ * `outputPath` was fixed for, while the per-finding `location` strings kept
626
+ * leaking it (`Location: dist/.vat-skills-uxxJfu/demo/SKILL.md`, observed on a
627
+ * real adopter and on a two-skill fixture). One mapper, applied at one seam, is
628
+ * what keeps the two from drifting apart again.
629
+ *
630
+ * Both spellings are handled because the two carriers use different coordinate
631
+ * systems: `outputPath` is absolute (see `buildYamlSummary`), while a finding's
632
+ * `location` is relative to the project root the validator anchored on. The
633
+ * mapping preserves whichever it was given — re-basing a location here would put
634
+ * the report into two coordinate systems, the very thing `locationRoot` exists
635
+ * to prevent.
636
+ */
637
+ function createStagingPathMapper(cwd, stagingRoot) {
638
+ const absoluteFrom = toForwardSlash(stagingRoot);
639
+ const absoluteTo = distSkillsDir(cwd);
640
+ const relativeFrom = toForwardSlash(safePath.relative(cwd, stagingRoot));
641
+ const relativeTo = toForwardSlash(safePath.relative(cwd, absoluteTo));
642
+ return (value) => {
643
+ const forward = toForwardSlash(value);
644
+ return (replacePathPrefix(forward, absoluteFrom, absoluteTo)
645
+ ?? replacePathPrefix(forward, relativeFrom, relativeTo)
646
+ ?? value);
647
+ };
648
+ }
649
+ /** Re-anchor the `location` of every issue that names a staged path. */
650
+ function reanchorIssueLocations(issues, mapPath) {
651
+ return issues.map((issue) => {
652
+ if (issue.location === undefined)
653
+ return issue;
654
+ const location = mapPath(issue.location);
655
+ return location === issue.location ? issue : { ...issue, location };
656
+ });
657
+ }
658
+ /**
659
+ * Re-anchor everything ONE skill's result says about where things are.
660
+ *
661
+ * Both post-build channels are rewritten, not just the one the summary reads:
662
+ * `collectPostBuildIssues` merges them and either can carry a staged location
663
+ * (the built-output validation runs against the staged `SKILL.md` itself), so a
664
+ * mapper applied to one of them leaves the report half-anchored.
665
+ *
666
+ * The `postBuildIssues` half is a live invariant with NO live producer today, and
667
+ * the distinction matters to anyone changing it. Every issue on that channel is
668
+ * anchored either on the bundle (`checkUnreferencedFiles`,
669
+ * `checkBrokenPackagedLinks`, `detectPackagedAgentInstructionFiles`,
670
+ * `checkPackagedTestInput` — all `relative(outputPath, …)`) or on the SOURCE
671
+ * project root (`droppedGlobMatchesToIssues`, `walkerExclusionsToIssues`,
672
+ * `deferredAssetsToIssues`, `FILENAME_COLLISION` — all `relative(projectRoot, …)`,
673
+ * where `projectRoot` is discovered from the skill's own source path). Neither
674
+ * spelling contains the staging prefix, so deleting this branch changes no
675
+ * fixture in the repo — which is exactly why it is unit-tested directly rather
676
+ * than through a build. Do not delete it on the strength of a green suite: the
677
+ * moment any producer anchors on `outputPath` ABSOLUTELY, this is the only thing
678
+ * standing between a report and a path its reader cannot open.
679
+ *
680
+ * Exported for that unit test.
681
+ */
682
+ export function reanchorStagedResult(result, mapPath) {
683
+ const reanchored = { ...result, outputPath: mapPath(result.outputPath) };
684
+ if (result.postBuildIssues) {
685
+ reanchored.postBuildIssues = reanchorIssueLocations(result.postBuildIssues, mapPath);
686
+ }
687
+ if (result.postBuildValidation) {
688
+ reanchored.postBuildValidation = {
689
+ ...result.postBuildValidation,
690
+ allErrors: reanchorIssueLocations(result.postBuildValidation.allErrors, mapPath),
691
+ };
692
+ }
693
+ return reanchored;
694
+ }
695
+ /**
696
+ * Move the previous output aside and open a staging root for this run.
697
+ *
698
+ * Three properties the placement is load-bearing for:
699
+ *
700
+ * 1. **Same filesystem.** The staging root is a SIBLING of `dist/skills` under
701
+ * `dist/`, so promotion is a `rename` — atomic and free — never a
702
+ * cross-device copy of a tree that can be tens of thousands of files.
703
+ * 2. **Invisible to the run.** `createProjectRegistry` crawls the project for
704
+ * `**\/*.md` and excludes `**\/dist/**`, so nothing staged here can enter the
705
+ * registry the packager resolves links against. The leading dot is a second
706
+ * belt: a `files:` glob like `dist/**` does not descend into dot-directories.
707
+ * 3. **The final path is ABSENT while the build runs.** The previous tree is
708
+ * renamed away before the first bundle is written, which is exactly what the
709
+ * delete-up-front flow guaranteed — so no lane can read a half-replaced
710
+ * `dist/skills`, and a stale bundle cannot leak into the new output.
711
+ *
712
+ * `mkdtemp` rather than a fixed name: two builds in one `dist/` must not share a
713
+ * staging root, and a leftover root from a killed run must not be adopted.
714
+ *
715
+ * KNOWN WINDOW, deliberately not closed here: a run killed between the park and
716
+ * the commit/abort (Ctrl-C on a multi-minute build, a CI timeout, a crash) leaves
717
+ * `dist/skills` absent and the previous output parked at
718
+ * `dist/.vat-skills-<rand>.previous`. That is no worse than the delete-up-front
719
+ * flow this replaces — there, the same interrupt lost the tree outright — and the
720
+ * output is recoverable with a single `mv`. Auto-recovering it on the next run is
721
+ * NOT safe as long as `mkdtemp` allows concurrent builds in one `dist/`: a sweep
722
+ * cannot tell a dead run's parked tree from a live run's, and adopting the wrong
723
+ * one would restore stale bundles over fresh output. Closing this needs a lock on
724
+ * `dist/`, not a heuristic.
725
+ *
726
+ * That window covers KILLS only. A promotion that throws — EACCES, ENOSPC, or the
727
+ * ENOTEMPTY a concurrent build produces with no injection at all — is deterministic,
728
+ * needs no signal, and IS handled: see {@link BuildStaging.recover} and
729
+ * {@link settleStaging}.
730
+ *
731
+ * Exported for tests, which drive the primitives directly. Injecting a promotion
732
+ * failure through a whole `runSkillBuild` would mean racing the filesystem;
733
+ * holding the staging handle lets a test create the exact on-disk state
734
+ * (a target reoccupied between the park and the promotion) that produces one.
735
+ */
736
+ export async function beginStagedBuild(cwd, onlySkill) {
737
+ const distDir = safePath.resolve(cwd, 'dist');
738
+ const skillsDir = safePath.join(distDir, 'skills');
739
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from cwd
740
+ await mkdir(distDir, { recursive: true });
741
+ const root = toForwardSlash(await mkdtemp(safePath.join(distDir, '.vat-skills-')));
742
+ // `--skill <name>` rebuilds ONE bundle, so the all-or-nothing guarantee is
743
+ // scoped to that bundle: its siblings under dist/skills are neither replaced
744
+ // nor set aside, exactly as the delete-just-that-directory flow behaved.
745
+ const subPath = onlySkill === undefined ? undefined : skillNameToFsPath(onlySkill);
746
+ const promoteFrom = subPath === undefined ? root : safePath.join(root, subPath);
747
+ const promoteTo = subPath === undefined ? skillsDir : safePath.join(skillsDir, subPath);
748
+ // `skillNameToFsPath` yields ONE path segment, so the parent is always known
749
+ // without a dirname call.
750
+ const promoteToParent = subPath === undefined ? distDir : skillsDir;
751
+ const parked = `${root}.previous`;
752
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from cwd
753
+ const hadPreviousOutput = existsSync(promoteTo);
754
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from cwd
755
+ if (hadPreviousOutput)
756
+ await rename(promoteTo, parked);
757
+ // Flipped the instant this run's bundles reach `promoteTo`, so a later failure
758
+ // in the SAME call (the parked-tree cleanup, the staging-shell removal) is not
759
+ // mistaken for "the output never landed". Without it, a `commit()` that renamed
760
+ // successfully and then failed to delete the parked copy would report
761
+ // `outputCommitted: false` about a `dist/skills` that holds the new output.
762
+ let promoted = false;
763
+ return {
764
+ root,
765
+ hadPreviousOutput,
766
+ parkedPath: parked,
767
+ promoteLabel: subPath === undefined
768
+ ? DIST_SKILLS_LABEL
769
+ : `${DIST_SKILLS_LABEL}/${subPath}`,
770
+ promoted: () => promoted,
771
+ commit: async () => {
772
+ // Guarded because in single-skill mode the staged bundle is a SUBPATH that
773
+ // exists only if that one skill actually built. (In full-build mode the
774
+ // staging root always exists, so a run that built nothing promotes an
775
+ // empty dist/skills — an accurate statement of "this build produced no
776
+ // bundles", where the old delete-up-front flow left the path absent.)
777
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from cwd
778
+ if (existsSync(promoteFrom)) {
779
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from cwd
780
+ await mkdir(promoteToParent, { recursive: true });
781
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from cwd
782
+ await rename(promoteFrom, promoteTo);
783
+ }
784
+ promoted = true;
785
+ await rm(parked, { recursive: true, force: true });
786
+ // A no-op in full-build mode (the rename above consumed it); in
787
+ // single-skill mode this is the now-empty staging shell.
788
+ await rm(root, { recursive: true, force: true });
789
+ },
790
+ abort: async () => {
791
+ await rm(root, { recursive: true, force: true });
792
+ if (!hadPreviousOutput)
793
+ return;
794
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from cwd
795
+ await mkdir(promoteToParent, { recursive: true });
796
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from cwd
797
+ await rename(parked, promoteTo);
798
+ },
799
+ recover: async () => {
800
+ const residue = [];
801
+ let restoredPrevious = false;
802
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from cwd
803
+ const targetFree = !existsSync(promoteTo);
804
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from cwd
805
+ if (hadPreviousOutput && existsSync(parked)) {
806
+ if (targetFree) {
807
+ try {
808
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from cwd
809
+ await mkdir(promoteToParent, { recursive: true });
810
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from cwd
811
+ await rename(parked, promoteTo);
812
+ restoredPrevious = true;
813
+ }
814
+ catch {
815
+ // The repair is best-effort by construction: it runs because the
816
+ // filesystem already refused something. A throw here must not replace
817
+ // the original diagnosis, so the parked path is reported as residue
818
+ // and the caller still names the real cause.
819
+ residue.push(parked);
820
+ }
821
+ }
822
+ else {
823
+ residue.push(parked);
824
+ }
825
+ }
826
+ // This run's own staging root is always safe to drop: it holds output this
827
+ // run produced and can rebuild. Leaving it is what accumulated a complete
828
+ // copy of the build output in `dist/` on every failed promotion.
829
+ try {
830
+ await rm(root, { recursive: true, force: true });
831
+ }
832
+ catch {
833
+ residue.push(root);
834
+ }
835
+ return { restoredPrevious, residue };
836
+ },
837
+ };
838
+ }
839
+ /**
840
+ * Promote or discard the staged tree, and never leave the operator without an
841
+ * answer when that fails.
842
+ *
843
+ * `commit()` / `abort()` used to be called bare. Any throw on that path — the
844
+ * promotion `rename`, the restore `rename`, either cleanup — propagated to
845
+ * `buildCommand`'s catch, which exits 2 WITHOUT emitting the YAML document. The
846
+ * measured result: `dist/skills` absent, the user's previous output orphaned at
847
+ * `dist/.vat-skills-<rand>.previous` under a name the `mkdtemp` suffix makes
848
+ * unguessable, and not one byte of the report whose `outputCommitted` field
849
+ * `--help` tells an operator to read when they see a non-zero exit.
850
+ *
851
+ * So: repair what can be repaired, then return a message rather than throwing —
852
+ * the caller publishes the document first and exits 2 after.
853
+ *
854
+ * `outputCommitted` comes from {@link BuildStaging.promoted}, not from
855
+ * `!runFailed`: a `commit()` that renamed the tree into place and then failed to
856
+ * delete the parked copy DID replace `dist/skills`, and saying otherwise would
857
+ * put the report back into contradiction with the disk — the one thing this whole
858
+ * summary exists to prevent.
859
+ */
860
+ export async function settleStaging(staging, runFailed, logger) {
861
+ try {
862
+ await (runFailed ? staging.abort() : staging.commit());
863
+ }
864
+ catch (error) {
865
+ const recovery = await staging.recover();
866
+ return {
867
+ outputCommitted: staging.promoted(),
868
+ promotionError: describePromotionFailure(staging, error, recovery),
869
+ };
870
+ }
871
+ if (runFailed) {
872
+ logger.error(staging.hadPreviousOutput
873
+ ? `\n Nothing was replaced — the previous ${staging.promoteLabel} is intact`
874
+ : `\n Nothing was written — ${staging.promoteLabel} does not exist`);
875
+ }
876
+ return { outputCommitted: !runFailed };
877
+ }
878
+ /**
879
+ * State the failure, then state what is on disk and how to get back.
880
+ *
881
+ * Every branch names an absolute path the operator can act on. A promotion
882
+ * failure is the one moment where "your previous output is at <path>" is the
883
+ * whole remedy, and the path is unreconstructable without being told.
884
+ */
885
+ function describePromotionFailure(staging, error, recovery) {
886
+ const cause = error instanceof Error ? error.message : String(error);
887
+ const lines = [`Build output promotion failed: ${cause}`];
888
+ if (staging.promoted()) {
889
+ lines.push(` ${staging.promoteLabel} DOES hold this run's output — the failure was in the cleanup that follows.`);
890
+ }
891
+ else if (recovery.restoredPrevious) {
892
+ lines.push(` The previous ${staging.promoteLabel} has been restored; nothing this run built was kept.`);
893
+ }
894
+ else if (staging.hadPreviousOutput) {
895
+ lines.push(` The previous ${staging.promoteLabel} is parked at ${staging.parkedPath}`, ` Recover it with: mv ${staging.parkedPath} <your ${staging.promoteLabel}>`);
400
896
  }
401
897
  else {
402
- const allSkillsDir = safePath.resolve(cwd, 'dist', 'skills');
403
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from cwd
404
- if (existsSync(allSkillsDir)) {
405
- await rm(allSkillsDir, { recursive: true, force: true });
406
- }
898
+ lines.push(` ${staging.promoteLabel} was never written, and there was no previous output to lose.`);
899
+ }
900
+ for (const path of recovery.residue) {
901
+ lines.push(` Left on disk (this run could not remove it): ${path}`);
407
902
  }
903
+ return lines.join('\n');
408
904
  }
409
905
  /**
410
906
  * Validate, package, and drain — the whole span of ONE `vat build` invocation.
@@ -422,17 +918,27 @@ async function cleanStaleSkillOutputs(cwd, skillName) {
422
918
  * nothing"; only the union can. Hence one ledger, drained once, here.
423
919
  *
424
920
  * Extracted from the command body so the span is testable without driving
425
- * `process.exit` — the drain seam is the thing worth asserting.
921
+ * `process.exit` — the drain seam is the thing worth asserting. Nothing in here
922
+ * exits: every failure of every kind is COLLECTED and returned, so one run
923
+ * surfaces all the work rather than the first item of it.
426
924
  */
427
- export async function runSkillBuild(specs, cwd, logger, projectSkills, verbose = false) {
925
+ export async function runSkillBuild(input) {
926
+ const { specs, cwd, logger, projectSkills, onlySkill, verbose } = input;
428
927
  const allowLedger = createAllowUsageLedger();
429
- // Validate all skills before building
430
- for (const { skill, packagingConfig } of specs) {
431
- const outputDir = safePath.resolve(cwd, 'dist', 'skills', skillNameToFsPath(skill.name));
928
+ const staging = await beginStagedBuild(cwd, onlySkill);
929
+ // Validate every skill before building ANY of them, and keep going past the
930
+ // ones that fail: a rejected source is a finding to report, not a reason to
931
+ // stop looking. The path logged is the FINAL one — it is where the bundle will
932
+ // live if this run earns the swap, and the staging path is an implementation
933
+ // detail no reader should have to decode.
934
+ const buildable = [];
935
+ const validationFailures = [];
936
+ for (const spec of specs) {
937
+ const { skill, packagingConfig } = spec;
432
938
  logger.info(`\nBuilding skill: ${skill.name}`);
433
939
  logger.info(` Source: ${skill.sourcePath}`);
434
- logger.info(` Output: ${outputDir}`);
435
- await validateSkillOrExit({
940
+ logger.info(` Output: ${finalOutputPath(cwd, skill.name)}`);
941
+ const failure = await validateSkillBeforeBuild({
436
942
  skillName: skill.name,
437
943
  sourcePath: skill.sourcePath,
438
944
  packagingConfig,
@@ -442,44 +948,68 @@ export async function runSkillBuild(specs, cwd, logger, projectSkills, verbose =
442
948
  projectSkills,
443
949
  verbose,
444
950
  });
951
+ if (failure) {
952
+ validationFailures.push(failure);
953
+ continue;
954
+ }
955
+ buildable.push(spec);
445
956
  }
446
- // Build all skills with a shared registry.
957
+ // Build the skills that qualified, with a shared registry.
447
958
  //
448
959
  // `projectSkills` is the run's declared eval suites — the whole project's, not
449
960
  // `specs`'. `--skill x` narrows what gets BUILT; it never narrows what counts as
450
961
  // test input, because an excluded skill's suite is still an answer key that must
451
962
  // not ship inside x's bundle.
452
- const buildSpecs = specs.map(({ skill, packagingConfig }) => ({
963
+ const buildSpecs = buildable.map(({ skill, packagingConfig }) => ({
453
964
  skillPath: skill.sourcePath,
454
965
  options: packagingConfigToPackageOptions(packagingConfig, {
455
966
  skillPath: skill.sourcePath,
456
- outputPath: safePath.resolve(cwd, 'dist', 'skills', skillNameToFsPath(skill.name)),
967
+ // Into staging, not dist/skills: see `beginStagedBuild`.
968
+ outputPath: safePath.join(staging.root, skillNameToFsPath(skill.name)),
457
969
  }, projectSkills),
458
970
  }));
459
971
  const outcomes = await packageSkills(buildSpecs, cwd, allowLedger);
460
972
  const results = [];
461
973
  const skillsWithErrors = [];
462
974
  const failures = [];
463
- for (const [i, spec] of specs.entries()) {
975
+ const mapStagedPath = createStagingPathMapper(cwd, staging.root);
976
+ let collapsedFindings = 0;
977
+ for (const [i, spec] of buildable.entries()) {
464
978
  const outcome = outcomes[i];
465
979
  if (!outcome)
466
980
  continue;
981
+ const skillName = spec.skill.name;
467
982
  if (outcome.status === 'failed') {
468
- // No `Built N files` line here: nothing was built, and claiming a count
469
- // for an absent bundle is the misreport this branch exists to avoid.
470
- logger.error(`\nBuild failed for skill: ${spec.skill.name}`);
983
+ // No file-count line here: nothing was built, and claiming a count for an
984
+ // absent bundle is the misreport this branch exists to avoid.
985
+ logger.error(`\nBuild failed for skill: ${skillName}`);
471
986
  logger.error(` ${outcome.error.message}`);
472
- failures.push({ name: spec.skill.name, message: outcome.error.message });
987
+ failures.push({ name: skillName, message: outcome.error.message });
473
988
  continue;
474
989
  }
475
- const { result } = outcome;
476
- logger.info(` Built ${result.files.dependencies.length + 1} files`);
477
- logPostBuildIssues(result, logger, verbose);
990
+ // Re-anchored BEFORE anything reads it — the report below and the published
991
+ // row both. The bundle was written under staging, and staging is transient
992
+ // in both outcomes, so any path that survives this call unmapped is a path
993
+ // its reader cannot open. See `createStagingPathMapper`.
994
+ const result = reanchorStagedResult(outcome.result, mapStagedPath);
995
+ // Named, because this line is printed in a SECOND pass: the validation pass
996
+ // above emits every `Building skill: <name>` banner first, so at scale (92
997
+ // banners, then 86 outcomes) an unnamed count line sits under an unrelated
998
+ // skill's banner and reads as that skill's result.
999
+ logger.info(` ${skillName}: built ${formatPackagedFileCount(result)}`);
1000
+ collapsedFindings += logPostBuildIssues(skillName, result, logger, verbose);
478
1001
  if (result.hasErrors) {
479
- skillsWithErrors.push(spec.skill.name);
1002
+ skillsWithErrors.push(skillName);
480
1003
  }
481
- results.push({ name: spec.skill.name, result });
1004
+ results.push({ name: skillName, result });
482
1005
  }
1006
+ // ONE hint for the run, after every skill has reported — the shape `vat audit`
1007
+ // already uses. Per skill it would repeat 86 times on the adopter run that
1008
+ // motivated it; omitted entirely (which is how this shipped) the collapsed
1009
+ // block is a heading with nothing under it and no way to learn there is more.
1010
+ const collapsedHint = formatCollapsedFindingsHint(collapsedFindings, 'build');
1011
+ if (collapsedHint !== undefined)
1012
+ logger.info(collapsedHint);
483
1013
  // Drain point: every skill and every lane has now contributed, so this is the
484
1014
  // first place the run can honestly say an entry matched nothing. A skill that
485
1015
  // threw still contributed the matches it made before throwing — which is why
@@ -492,7 +1022,60 @@ export async function runSkillBuild(specs, cwd, logger, projectSkills, verbose =
492
1022
  // ALLOW_UNUSED. That is a warning, and `runHasErrors` gates on `error` only,
493
1023
  // so it cannot fail a build on its own — and the run is already exiting 1 on
494
1024
  // the failure the operator actually needs to fix.
495
- return { results, runIssues: allowUnusedIssues(allowLedger), skillsWithErrors, failures };
1025
+ const runIssues = allowUnusedIssues(allowLedger);
1026
+ // ONE predicate, evaluated once, for BOTH "does dist/skills get replaced" and
1027
+ // (via `outputCommitted`) "what does the command exit with". Two definitions
1028
+ // of "this run failed" is how a report ends up disagreeing with the disk and
1029
+ // with the exit code — the failure mode this whole summary exists to prevent.
1030
+ // A run-level ALLOW_UNUSED is a `warning`, so it is not in here: a warning must
1031
+ // never discard artifacts that were produced correctly.
1032
+ const runFailed = failures.length > 0
1033
+ || validationFailures.length > 0
1034
+ || skillsWithErrors.length > 0
1035
+ || runIssues.some((i) => i.severity === 'error');
1036
+ const { outputCommitted, promotionError } = await settleStaging(staging, runFailed, logger);
1037
+ return {
1038
+ results,
1039
+ runIssues,
1040
+ skillsWithErrors,
1041
+ failures,
1042
+ validationFailures,
1043
+ outputCommitted,
1044
+ ...(promotionError === undefined ? {} : { promotionError }),
1045
+ };
1046
+ }
1047
+ /**
1048
+ * Name every way this run failed, on stderr, one section per population.
1049
+ *
1050
+ * All four sections print — a run can fail in several ways at once, and the
1051
+ * whole point of collecting rather than aborting is that ONE build cycle shows
1052
+ * an adopter all of the work. A section that stopped at the first population
1053
+ * would put the fail-fast defect back one level up.
1054
+ */
1055
+ function logRunFailures(run, logger) {
1056
+ const { failures, skillsWithErrors, validationFailures, runIssues } = run;
1057
+ if (validationFailures.length > 0) {
1058
+ logger.error(`\nBuild failed: ${validationFailures.length} skill(s) failed pre-build validation`);
1059
+ for (const { name } of validationFailures)
1060
+ logger.error(` - ${name}`);
1061
+ }
1062
+ if (failures.length > 0) {
1063
+ logger.error(`\nBuild failed: ${failures.length} skill(s) could not be packaged at all`);
1064
+ for (const { name, message } of failures) {
1065
+ logger.error(` - ${name}: ${message.split('\n')[0] ?? message}`);
1066
+ }
1067
+ }
1068
+ if (skillsWithErrors.length > 0) {
1069
+ logger.error(`\nBuild failed: ${skillsWithErrors.length} skill(s) emitted post-build validation errors`);
1070
+ for (const name of skillsWithErrors)
1071
+ logger.error(` - ${name}`);
1072
+ }
1073
+ // A run-level finding belongs to no skill, so the loops above cannot carry it.
1074
+ // Only `error` severity gates: ALLOW_UNUSED ships as a `warning`, and a
1075
+ // warning must never abort a build that produced its artifacts.
1076
+ if (runIssues.some((i) => i.severity === 'error')) {
1077
+ logger.error(`\nBuild failed: run-level validation errors (project config, not any one skill)`);
1078
+ }
496
1079
  }
497
1080
  async function buildCommand(pathArg, options) {
498
1081
  const { logger, cwd, startTime } = setupCommandContext(pathArg, options.debug);
@@ -518,10 +1101,12 @@ async function buildCommand(pathArg, options) {
518
1101
  // Filter by skill name if specified
519
1102
  const skillsToBuild = filterSkillsByName(discoveredSkills, options.skill);
520
1103
  logger.info(`Found ${skillsToBuild.length} skill(s) to build`);
521
- if (!options.dryRun) {
522
- await cleanStaleSkillOutputs(cwd, options.skill);
523
- }
524
- // Handle dry-run mode
1104
+ // Handle dry-run mode. Nothing has touched `dist/` at this point — the
1105
+ // staging directory and the swap both live inside `runSkillBuild`, which a
1106
+ // dry run never reaches, so "preview without creating files" is now true of
1107
+ // the OUTPUT TREE as well as of the bundles. (The old flow deleted
1108
+ // `dist/skills` before this branch and relied on an `if (!options.dryRun)`
1109
+ // guard three lines up to stay honest.)
525
1110
  if (options.dryRun) {
526
1111
  const duration = Date.now() - startTime;
527
1112
  performDryRun(skillsToBuild, duration, logger);
@@ -534,36 +1119,38 @@ async function buildCommand(pathArg, options) {
534
1119
  // Assembled ONCE, from the UNFILTERED discovery: `--skill x` narrows the build,
535
1120
  // not the set of files that count as some skill's declared test input.
536
1121
  const projectSkills = collectDeclaredEvalSuites(skillsConfig, discoveredSkills);
537
- const { results, runIssues, skillsWithErrors, failures } = await runSkillBuild(buildSpecs, cwd, logger, projectSkills, options.verbose === true);
1122
+ const run = await runSkillBuild({
1123
+ specs: buildSpecs,
1124
+ cwd,
1125
+ logger,
1126
+ projectSkills,
1127
+ onlySkill: options.skill,
1128
+ verbose: options.verbose === true,
1129
+ });
538
1130
  const duration = Date.now() - startTime;
539
1131
  // Output YAML results
540
- outputBuildYaml(results, failures, duration, runIssues, skillsWithErrors);
541
- for (const line of formatRunIssueLines(runIssues)) {
1132
+ outputBuildYaml(run, duration);
1133
+ for (const line of formatRunIssueLines(run.runIssues)) {
542
1134
  logger.info(line);
543
1135
  }
544
- if (failures.length > 0) {
545
- logger.error(`\nBuild failed: ${failures.length} skill(s) could not be packaged at all`);
546
- for (const { name, message } of failures) {
547
- logger.error(` - ${name}: ${message.split('\n')[0] ?? message}`);
548
- }
549
- }
550
- if (skillsWithErrors.length > 0) {
551
- logger.error(`\nBuild failed: ${skillsWithErrors.length} skill(s) emitted post-build validation errors`);
552
- for (const name of skillsWithErrors) {
553
- logger.error(` - ${name}`);
554
- }
555
- }
556
- // A run-level finding belongs to no skill, so the loop above cannot carry
557
- // it. Only `error` severity gates: ALLOW_UNUSED ships as a `warning`, and a
558
- // warning must never abort a build that produced its artifacts.
559
- const runHasErrors = runIssues.some((i) => i.severity === 'error');
560
- if (runHasErrors) {
561
- logger.error(`\nBuild failed: run-level validation errors (project config, not any one skill)`);
1136
+ logRunFailures(run, logger);
1137
+ // A promotion failure is a SYSTEM error (2), not a validation one (1): the
1138
+ // build's verdict on the skills is not what went wrong, and a CI script has
1139
+ // to be able to tell "your skills are broken" from "the filesystem refused
1140
+ // and dist/skills is in a state someone must look at". Reported AFTER the
1141
+ // document is written, which is the whole reason it rides on the run rather
1142
+ // than being thrown from inside it.
1143
+ if (run.promotionError !== undefined) {
1144
+ logger.error(`\n${run.promotionError}`);
1145
+ process.exit(2);
562
1146
  }
563
- if (skillsWithErrors.length > 0 || runHasErrors || failures.length > 0) {
1147
+ // Gated on the SAME fact that decided whether the swap happened, not on a
1148
+ // second copy of the predicate: the exit code and `outputCommitted` can then
1149
+ // never disagree about whether this run succeeded.
1150
+ if (!run.outputCommitted) {
564
1151
  process.exit(1);
565
1152
  }
566
- logger.info(`\nBuilt ${results.length} skill(s) successfully`);
1153
+ logger.info(`\nBuilt ${run.results.length} skill(s) successfully`);
567
1154
  process.exit(0);
568
1155
  }
569
1156
  catch (error) {