vigiles 23.0.0 → 25.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -12,8 +12,12 @@
12
12
  */
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.specLoadFailureReason = specLoadFailureReason;
15
+ exports.lintTotals = lintTotals;
16
+ exports.discoverNestedBundles = discoverNestedBundles;
15
17
  const node_fs_1 = require("node:fs");
16
18
  const node_path_1 = require("node:path");
19
+ const minimatch_1 = require("minimatch");
20
+ const repo_path_js_1 = require("./core/repo-path.js");
17
21
  const node_child_process_1 = require("node:child_process");
18
22
  const glob_1 = require("glob");
19
23
  const generate_types_js_1 = require("./core/generate-types.js");
@@ -285,11 +289,14 @@ function printWarnings(specFile, warnings) {
285
289
  /** Compile a generator-skill spec from source → SKILL.md. Returns validity. */
286
290
  function compileGeneratorSkillToFile(specPath, source) {
287
291
  const outputPath = specPath.replace(/\.spec\.ts$/, "");
288
- const { markdown, errors } = (0, compile_generator_js_1.compileGeneratorSkill)(source, {
292
+ const { artifact, errors } = (0, compile_generator_js_1.compileGeneratorSkill)(source, {
289
293
  basePath: process.cwd(),
290
294
  specFile: specPath,
291
295
  });
292
- (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(process.cwd(), outputPath), markdown);
296
+ // Written only when the compile is clean: `artifact` is null otherwise, and
297
+ // `writeArtifact` takes nothing else.
298
+ if (artifact)
299
+ writeArtifact(outputPath, artifact);
293
300
  if (errors.length === 0) {
294
301
  console.log(`\n✓ ${specPath} → ${outputPath} (generator skill)`);
295
302
  return true;
@@ -390,14 +397,17 @@ function writeInstructionMirrors(primaryOutput, harnesses) {
390
397
  /** Compile a declarative SkillSpec → SKILL.md. */
391
398
  function compileSkillToFile(spec, specPath, dialect) {
392
399
  const outputPath = specPath.replace(/\.spec\.ts$/, "");
393
- const { markdown, errors, warnings } = (0, compile_js_1.compileSkill)(spec, {
400
+ const { artifact, errors, warnings } = (0, compile_js_1.compileSkill)(spec, {
394
401
  basePath: process.cwd(),
395
402
  specFile: specPath,
396
403
  // The SKILL.md frontmatter profile comes from the resolved harness — a Codex
397
404
  // repo gets a minimal (name + description) SKILL.md; CC gets the full set.
398
405
  dialect,
399
406
  });
400
- (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(process.cwd(), outputPath), markdown);
407
+ // Written only when the compile is clean: `artifact` is null otherwise, and
408
+ // `writeArtifact` takes nothing else.
409
+ if (artifact)
410
+ writeArtifact(outputPath, artifact);
401
411
  if (errors.length === 0) {
402
412
  console.log(`\n✓ ${specPath} → ${outputPath}`);
403
413
  printWarnings(specPath, warnings);
@@ -411,12 +421,15 @@ function compileSkillToFile(spec, specPath, dialect) {
411
421
  /** Compile a subagent spec → agents/<name>.md (with its result-contract section). */
412
422
  function compileAgentToFile(spec, specPath, dialect) {
413
423
  const outputPath = specPath.replace(/\.spec\.ts$/, "");
414
- const { markdown, errors, warnings } = (0, compile_js_1.compileAgent)(spec, {
424
+ const { artifact, errors, warnings } = (0, compile_js_1.compileAgent)(spec, {
415
425
  basePath: process.cwd(),
416
426
  specFile: specPath,
417
427
  dialect,
418
428
  });
419
- (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(process.cwd(), outputPath), markdown);
429
+ // Written only when the compile is clean: `artifact` is null otherwise, and
430
+ // `writeArtifact` takes nothing else.
431
+ if (artifact)
432
+ writeArtifact(outputPath, artifact);
420
433
  if (errors.length === 0) {
421
434
  console.log(`\n✓ ${specPath} → ${outputPath}`);
422
435
  printWarnings(specPath, warnings);
@@ -434,11 +447,14 @@ function compileAgentToFile(spec, specPath, dialect) {
434
447
  */
435
448
  function compileRailwayToFile(spec, specPath, knownAgents) {
436
449
  const outputPath = specPath.replace(/\.spec\.ts$/, "");
437
- const { markdown, errors } = (0, compile_js_1.compileRailway)(spec, {
450
+ const { artifact, errors } = (0, compile_js_1.compileRailway)(spec, {
438
451
  specFile: specPath,
439
452
  knownAgents,
440
453
  });
441
- (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(process.cwd(), outputPath), markdown);
454
+ // Written only when the compile is clean: `artifact` is null otherwise, and
455
+ // `writeArtifact` takes nothing else.
456
+ if (artifact)
457
+ writeArtifact(outputPath, artifact);
442
458
  if (errors.length === 0) {
443
459
  console.log(`\n✓ ${specPath} → ${outputPath}`);
444
460
  return true;
@@ -801,6 +817,55 @@ async function verifyMarkdownMcpRefs(files, silent) {
801
817
  return errors;
802
818
  }
803
819
  /** Exit codes: 0 clean, 1 warnings only, 2 hard errors. */
820
+ /**
821
+ * The run's totals, derived from the report itself.
822
+ *
823
+ * 🔴 ONE SOURCE, because the two numbers disagreeing IS the bug (#183). The
824
+ * human-readable log had no total, and counting its `⚠` lines gave a different
825
+ * number from the JSON — 21 against 88 on a real repo — because some checks print
826
+ * one line per finding and others one line carrying a count. Both numbers were
827
+ * right and nothing said why they differed, so "vigiles reports 21 warnings" and
828
+ * "88 warnings" were equally defensible readings of one run.
829
+ *
830
+ * Counted GENERICALLY off the `*Issues` / `*Errors` / count keys rather than a
831
+ * hand-maintained list, so a rule added later is included by existing, not by
832
+ * somebody remembering. `orphanCount` and `duplicatePairs` are named explicitly
833
+ * only because they predate the `*Issues` convention (#181).
834
+ */
835
+ function lintTotals(report) {
836
+ let errors = 0;
837
+ let findings = 0;
838
+ for (const [key, value] of Object.entries(report)) {
839
+ if (typeof value !== "number" || value === 0)
840
+ continue;
841
+ if (key === "files")
842
+ continue;
843
+ // Informational counters: not findings, they describe the corpus.
844
+ if (key === "inlineRules" ||
845
+ key === "frontmatterRules" ||
846
+ key === "coverageEnabled" ||
847
+ key === "coverageDocumented" ||
848
+ key === "strengthenSuggestions")
849
+ continue;
850
+ if (key.endsWith("Errors")) {
851
+ errors += value;
852
+ findings += value;
853
+ continue;
854
+ }
855
+ // `*Issues` counts EVERY finding of that rule; when the rule is at "error"
856
+ // the same findings are also in `*Errors`, so they must not be counted twice.
857
+ if (key.endsWith("Issues")) {
858
+ const paired = report[`${key.slice(0, -"Issues".length)}Errors`];
859
+ findings += paired && paired > 0 ? 0 : value;
860
+ continue;
861
+ }
862
+ if (key === "orphanCount" ||
863
+ key === "duplicatePairs" ||
864
+ key === "untestedSurfaces")
865
+ findings += value;
866
+ }
867
+ return { findings, errors, warnings: findings - errors };
868
+ }
804
869
  function lintExitCode(report) {
805
870
  if (report.hashErrors > 0 ||
806
871
  report.validationErrors > 0 ||
@@ -835,9 +900,15 @@ function lintExitCode(report) {
835
900
  // it to "error", so it belongs in the hard tier with every other explicit
836
901
  // error — it used to sit at exit 1 because it fired unasked and could not be
837
902
  // turned off.
838
- report.docRefErrors > 0)
903
+ report.docRefErrors > 0 ||
904
+ report.specRefErrors > 0)
839
905
  return 2;
840
- if (report.duplicatePairs > 0 || report.orphanCount > 0)
906
+ // Tierable now: a `warn` orphan/duplicate finding is reported and does NOT
907
+ // change the exit code. Both used to feed the exit directly, which is what
908
+ // made them the only untierable findings in the tool.
909
+ if (report.orphanCount > 0 && report.orphanSeverity === "error")
910
+ return 1;
911
+ if (report.duplicatePairs > 0 && report.duplicateSeverity === "error")
841
912
  return 1;
842
913
  // Guidance counts are informational, not failures
843
914
  return 0;
@@ -1085,6 +1156,169 @@ function sharedDirsRootFor(scanTarget) {
1085
1156
  const underCwd = rel === "" || (!rel.startsWith("..") && !(0, node_path_1.isAbsolute)(rel));
1086
1157
  return underCwd ? cwd : target;
1087
1158
  }
1159
+ /**
1160
+ * Nested plugin bundles under a lint root — a directory that is itself a harness
1161
+ * (its own `.claude-plugin/plugin.json`, or its own skills dir) and is NOT the
1162
+ * root being linted.
1163
+ *
1164
+ * 🔴 WHY THIS EXISTS. Every per-surface check reads ONE root, so in a monorepo
1165
+ * holding `skills/` plus `plugins/ * /skills/` the nested skills were never scored
1166
+ * and nothing said so. Measured on a fixture: 4 skills over the description
1167
+ * budget, `lint .` reported 2, exit 0 — a repo reads that as green-with-2 while
1168
+ * the other 2 carry the same defect (#185). The failure is silent, which is the
1169
+ * shape this repo treats as worse than a loud one.
1170
+ *
1171
+ * Deliberately shallow (one level under a container dir): deep recursion would
1172
+ * sweep vendored corpora — this repo's own `test/dogfood/` holds real pinned
1173
+ * third-party plugins — and scoring someone else's vendored plugin as if it were
1174
+ * yours is the false-positive that gets a gate switched off.
1175
+ */
1176
+ function discoverNestedBundles(root, exclude = []) {
1177
+ const out = [];
1178
+ const skip = new Set([
1179
+ "node_modules",
1180
+ ".git",
1181
+ "dist",
1182
+ "coverage",
1183
+ ".vigiles",
1184
+ ]);
1185
+ const isBundle = (dir) => (0, node_fs_1.existsSync)((0, node_path_1.join)(dir, ".claude-plugin", "plugin.json")) ||
1186
+ (0, node_fs_1.existsSync)((0, node_path_1.join)(dir, "skills"));
1187
+ const excluded = (rel) => exclude.some((pattern) => rel === pattern ||
1188
+ rel.startsWith(`${pattern}/`) ||
1189
+ (0, minimatch_1.minimatch)(rel, pattern) ||
1190
+ (0, minimatch_1.minimatch)(rel, `${pattern}/**`));
1191
+ let entries;
1192
+ try {
1193
+ entries = (0, node_fs_1.readdirSync)(root, { withFileTypes: true })
1194
+ .filter((e) => e.isDirectory() && !skip.has(e.name) && !e.name.startsWith("."))
1195
+ .map((e) => e.name);
1196
+ }
1197
+ catch {
1198
+ return out;
1199
+ }
1200
+ for (const name of entries) {
1201
+ const dir = (0, node_path_1.join)(root, name);
1202
+ if (excluded(name))
1203
+ continue;
1204
+ // A container (`plugins/`) holds bundles; a bundle may also sit directly.
1205
+ if (isBundle(dir)) {
1206
+ out.push(dir);
1207
+ continue;
1208
+ }
1209
+ let inner;
1210
+ try {
1211
+ inner = (0, node_fs_1.readdirSync)(dir, { withFileTypes: true })
1212
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
1213
+ .map((e) => e.name);
1214
+ }
1215
+ catch {
1216
+ continue;
1217
+ }
1218
+ for (const child of inner) {
1219
+ const sub = (0, node_path_1.join)(dir, child);
1220
+ if (excluded(`${name}/${child}`))
1221
+ continue;
1222
+ if (isBundle(sub))
1223
+ out.push(sub);
1224
+ }
1225
+ }
1226
+ return out.sort();
1227
+ }
1228
+ /**
1229
+ * Run one per-surface check over EVERY root and sum its counters.
1230
+ *
1231
+ * The checks all share `(config, silent, adapter, root)` and return a small
1232
+ * record of numbers, so one wrapper covers all twenty rather than twenty edits —
1233
+ * and a check added later is swept in by using it, not by remembering to.
1234
+ */
1235
+ function overBundles(fn, config, silent, adapter, roots) {
1236
+ const [first, ...rest] = roots;
1237
+ const total = { ...fn(config, silent, adapter, first) };
1238
+ for (const root of rest) {
1239
+ const next = fn(config, silent, adapter, root);
1240
+ for (const key of Object.keys(next))
1241
+ total[key] =
1242
+ (total[key] ?? 0) + (next[key] ?? 0);
1243
+ }
1244
+ return total;
1245
+ }
1246
+ /**
1247
+ * Re-derive a compiled artifact's references from its SPEC, and report the dead
1248
+ * ones — the half of #173 that deleting the write-on-error did not close.
1249
+ *
1250
+ * 🔴 THE HOLE. `lint` verifies the integrity HASH, which answers "is this file
1251
+ * still what the spec compiled to" and says nothing about whether the things it
1252
+ * NAMES still exist. So a `CLAUDE.md` committed while its refs were live stays
1253
+ * green forever after the referenced file is deleted: `compile` errors, `lint`
1254
+ * prints "hash valid — All compiled files intact" and exits 0. Reproduced:
1255
+ *
1256
+ * $ vigiles compile CLAUDE.md.spec.ts
1257
+ * ✗ [stale-file] File not found: "docs/guide.md"
1258
+ * $ vigiles lint .
1259
+ * ✓ CLAUDE.md — hash valid # exit 0
1260
+ *
1261
+ * The reporter named this residue himself when filing #173 and I deferred it;
1262
+ * it is the gap between what `README.md` promises of `lint` ("the CI gate …
1263
+ * broken refs") and what it checked. A hash is an integrity claim, not a
1264
+ * reference claim, and the two were being read as one.
1265
+ *
1266
+ * Cost is bounded: only specs whose compiled target actually EXISTS are loaded,
1267
+ * so a repo with no specs does no extra work at all.
1268
+ */
1269
+ async function checkSpecRefs(config, silent, dialect) {
1270
+ const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["spec-refs"]) ?? "error";
1271
+ if (!sev)
1272
+ return { issues: 0, errors: 0 };
1273
+ const found = [];
1274
+ for (const specPath of findSpecs()) {
1275
+ const target = specPath.replace(/\.spec\.ts$/, "");
1276
+ if (!(0, node_fs_1.existsSync)(target))
1277
+ continue; // never compiled — `compile` reports it
1278
+ const spec = await loadSpec(specPath);
1279
+ if (!spec || spec._specType !== "claude")
1280
+ continue;
1281
+ try {
1282
+ const { errors } = (0, compile_js_1.compileClaude)(spec, {
1283
+ basePath: process.cwd(),
1284
+ specFile: specPath,
1285
+ dialect,
1286
+ maxRules: config?.maxRules,
1287
+ maxTokens: config?.maxTokens,
1288
+ maxSectionLines: config?.maxSectionLines,
1289
+ catalogOnly: config?.catalogOnly,
1290
+ linters: config?.linters,
1291
+ });
1292
+ for (const e of errors)
1293
+ found.push(`${target}: ${e.message} (from ${specPath})`);
1294
+ }
1295
+ catch {
1296
+ // A spec that will not load is `compile`'s finding, not this one's —
1297
+ // reporting it here would double-report and blame the wrong command.
1298
+ continue;
1299
+ }
1300
+ }
1301
+ if (found.length > 0 && !silent) {
1302
+ console.log("\nSpec reference check:\n");
1303
+ for (const msg of found) {
1304
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${msg}`);
1305
+ ghAnnotate(sev === "error" ? "error" : "warning", msg);
1306
+ }
1307
+ }
1308
+ return { issues: found.length, errors: sev === "error" ? found.length : 0 };
1309
+ }
1310
+ /**
1311
+ * Write a compiled artifact. Accepts ONLY a {@link StampedMarkdown}, so a body
1312
+ * that failed to compile cannot reach the disk — there is no stamp to pass.
1313
+ *
1314
+ * This replaces four copies of `writeFileSync(path, markdown)` that ran BEFORE
1315
+ * their error check (#173, reproduced in the skill/subagent/railway/generator
1316
+ * compilers after the CLAUDE.md one was fixed). Guarding four call sites would
1317
+ * have left the fifth writable; the type leaves nothing to remember.
1318
+ */
1319
+ function writeArtifact(outputPath, artifact) {
1320
+ (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(process.cwd(), outputPath), artifact);
1321
+ }
1088
1322
  async function runLint(restArgs, flags, config) {
1089
1323
  const summary = flags.includes("--summary");
1090
1324
  const json = flags.includes("--json");
@@ -1117,6 +1351,30 @@ async function runLint(restArgs, flags, config) {
1117
1351
  configHarness: (0, adapter_registry_js_1.normalizeHarnessList)(config?.harness),
1118
1352
  });
1119
1353
  const adapter = lintSelection.adapter;
1354
+ // 🔴 WHICH ROOTS GET SCORED, and saying so either way (#185).
1355
+ //
1356
+ // Every per-surface check reads ONE root, so a monorepo with `skills/` plus
1357
+ // `plugins/*/skills/` scored only the first and said nothing — measured at 2
1358
+ // findings reported against 4 real ones, exit 0. A skipped surface that is not
1359
+ // announced reads as a clean surface.
1360
+ //
1361
+ // Default stays ROOT-ONLY on purpose: descending by default would start
1362
+ // scoring vendored third-party corpora (this repo's own `test/dogfood/` holds
1363
+ // pinned real plugins), and scoring someone else's plugin as if it were yours
1364
+ // is the false positive that gets a gate turned off. So the DEFAULT fixes the
1365
+ // SILENCE, and `bundles: "all"` fixes the COVERAGE — one exit code over the
1366
+ // whole repo, which is what a CI gate needs.
1367
+ const nestedBundles = discoverNestedBundles(scanRoot, config?.exclude ?? []);
1368
+ const scoreAll = flags.includes("--bundles=all") || config?.bundles === "all";
1369
+ const lintRoots = scoreAll ? [scanRoot, ...nestedBundles] : [scanRoot];
1370
+ if (!silent && nestedBundles.length > 0) {
1371
+ const rel = nestedBundles.map((b) => (0, node_path_1.relative)(scanRoot, b) || b);
1372
+ console.log(scoreAll
1373
+ ? `\nScoring ${String(lintRoots.length)} bundles: the root + ${rel.join(", ")}`
1374
+ : `\n⚠ ${String(nestedBundles.length)} nested bundle(s) discovered but NOT scored: ${rel.join(", ")}\n` +
1375
+ ` Their skills/agents/hooks are not in the counters below. Add \`"bundles": "all"\` to ` +
1376
+ `.vigilesrc.json (or pass --bundles=all) to score them in this run.`);
1377
+ }
1120
1378
  // Discover the compiled files whose integrity is verified. Include the active
1121
1379
  // harness's subagent dir (dogfood E2): a compiled `agents/<name>.md` carries a
1122
1380
  // vigiles hash, but the default glob only matched CLAUDE/AGENTS/SKILL, so a
@@ -1170,7 +1428,14 @@ async function runLint(restArgs, flags, config) {
1170
1428
  // declares the block; its `include` defaults to docs/ (research/ etc. are
1171
1429
  // opted into explicitly). `enforce("vigiles/orphan-docs")` in a spec only
1172
1430
  // validates the rule NAME — the block is what drives the scan.
1173
- const orphansCfg = config?.orphans;
1431
+ // 🔴 SEVERITY IS READ, not just the block's presence (#181). `orphan-docs` had
1432
+ // a RULE_META entry and a documented severity, and nothing ever read it: both
1433
+ // `"warn"` and `"off"` still exited 1, so a repo could only choose between an
1434
+ // always-blocking check and deleting the `orphans` block. `warn` now reports
1435
+ // without touching the exit code and `false`/`"off"` skips the scan, exactly
1436
+ // like every other rule.
1437
+ const orphanSeverity = (0, types_js_1.ruleSeverity)(config?.rules?.["orphan-docs"]) ?? "warn";
1438
+ const orphansCfg = orphanSeverity ? config?.orphans : undefined;
1174
1439
  if (!silent)
1175
1440
  console.log("\nOrphan docs check:\n");
1176
1441
  let orphanReport = {
@@ -1201,72 +1466,75 @@ async function runLint(restArgs, flags, config) {
1201
1466
  // 7b. Untested-surface check — skills/agents/hooks shipping without a test or
1202
1467
  // eval. Warning by default (a nudge, exit 0); set rules.untested-{skill,agent,
1203
1468
  // hook} to "error" to gate CI. See src/test-coverage.ts and docs/rules/.
1204
- const untested = checkUntestedSurfaces(config, silent, adapter, scanRoot);
1469
+ // A compiled artifact's refs, re-derived from its spec — the hash says the file
1470
+ // is unchanged, not that what it names still exists (#173).
1471
+ const specRefs = await checkSpecRefs(config, silent, adapter.dialect);
1472
+ const untested = overBundles(checkUntestedSurfaces, config, silent, adapter, lintRoots);
1205
1473
  // 7c. Subagent tool-contract check — cross-reference each subagent's `tools:`
1206
1474
  // rail against the harness catalog (the moat). n/a on a harness with no
1207
1475
  // subagents. Off by default unless a severity is configured; warning surfaces
1208
1476
  // a typo/never-available tool, error gates CI.
1209
- const toolContract = checkSubagentToolContracts(config, silent, adapter, scanRoot);
1477
+ const toolContract = overBundles(checkSubagentToolContracts, config, silent, adapter, lintRoots);
1210
1478
  // 7d. Hook-event check — a hook registered under an event the harness doesn't
1211
1479
  // define never fires. High-precision (close typos only). Off unless configured.
1212
- const hookEvents = checkHookEvents(config, silent, adapter, scanRoot);
1480
+ const hookEvents = overBundles(checkHookEvents, config, silent, adapter, lintRoots);
1213
1481
  // 7e. Subagent-frontmatter check — a subagent missing required frontmatter
1214
1482
  // (name + description) won't register. n/a on a harness with no subagents.
1215
- const frontmatter = checkFrontmatterSchema(config, silent, adapter, scanRoot);
1483
+ const frontmatter = overBundles(checkFrontmatterSchema, config, silent, adapter, lintRoots);
1216
1484
  // 7f. MCP-config check — a declared MCP server with no command/url can't start.
1217
- const mcpConfig = checkMcpConfig(config, silent, adapter, scanRoot);
1485
+ const mcpConfig = overBundles(checkMcpConfig, config, silent, adapter, lintRoots);
1218
1486
  // 7g. Skill-frontmatter — RECOMMEND explicit name/description on skills (a
1219
1487
  // reliable trigger surface). Best-practice nudge; skills load without it.
1220
- const skillFm = checkSkillFrontmatter(config, silent, adapter, scanRoot);
1488
+ const skillFm = overBundles(checkSkillFrontmatter, config, silent, adapter, lintRoots);
1221
1489
  // 7h. MCP tool-resolution — an `mcp__server__tool` in a contract whose server
1222
1490
  // the plugin doesn't declare can't resolve (the MCP half of the tool moat).
1223
- const mcpToolResolves = checkMcpToolResolves(config, silent, adapter, scanRoot);
1491
+ const mcpToolResolves = overBundles(checkMcpToolResolves, config, silent, adapter, lintRoots);
1224
1492
  // 7i. Hook-script existence — a hook command referencing a missing script file
1225
1493
  // never runs (matches Anthropic's own `claude plugin validate`).
1226
- const hookScripts = checkHookScriptExists(config, silent, adapter, scanRoot);
1494
+ const hookScripts = overBundles(checkHookScriptExists, config, silent, adapter, lintRoots);
1227
1495
  // 7j. Disallowed-tools — a `disallowedTools:` block-list typo blocks nothing
1228
1496
  // (the deny-side mirror of subagent-tool-contract; close-typo only).
1229
- const disallowedTools = checkDisallowedTools(config, silent, adapter, scanRoot);
1497
+ const disallowedTools = overBundles(checkDisallowedTools, config, silent, adapter, lintRoots);
1230
1498
  // 7k. Description-overlap — two model-invocable skills with near-identical
1231
1499
  // descriptions collide in the selector (deterministic NCD precision proxy).
1232
- const descriptionOverlap = checkDescriptionOverlap(config, silent, adapter, scanRoot);
1500
+ const descriptionOverlap = overBundles(checkDescriptionOverlap, config, silent, adapter, lintRoots);
1233
1501
  // 7k². Skill-description-budget — a model-invocable skill whose description is
1234
1502
  // so long the trigger signal is buried (heuristic proxy; degrades recall +
1235
1503
  // precision). Generous 500-char budget; warn-tier, never gates.
1236
- const descriptionBudget = checkDescriptionBudget(config, silent, adapter, scanRoot);
1504
+ const descriptionBudget = overBundles(checkDescriptionBudget, config, silent, adapter, lintRoots);
1237
1505
  // 7l. Frontmatter-valid — a `---` block that isn't valid YAML (warn; js-yaml is
1238
1506
  // stricter than some loaders, so verify before enforcing).
1239
- const frontmatterValid = checkFrontmatterValid(config, silent, adapter, scanRoot);
1507
+ const frontmatterValid = overBundles(checkFrontmatterValid, config, silent, adapter, lintRoots);
1240
1508
  // 7m. MCP hook-target — a `type: mcp_tool` hook action that's incomplete or
1241
1509
  // targets an undeclared server (the moat applied to the hook surface).
1242
- const mcpHookTargets = checkMcpHookTargets(config, silent, adapter, scanRoot);
1510
+ const mcpHookTargets = overBundles(checkMcpHookTargets, config, silent, adapter, lintRoots);
1243
1511
  // 7n. Prefer-compiled-hooks — ONE discovery nudge (not per-hook) toward
1244
1512
  // compiled `vigiles/hook` artifacts when hand-written hooks ship. Recommendation.
1245
- const preferCompiledHooks = checkPreferCompiledHooks(config, silent, adapter, scanRoot);
1513
+ const preferCompiledHooks = overBundles(checkPreferCompiledHooks, config, silent, adapter, lintRoots);
1246
1514
  // 7o. Lethal-trifecta — a unit (subagent / model-invocable skill) whose tools
1247
1515
  // hold all three legs (read-private + ingest-untrusted + exfiltrate) is a
1248
1516
  // prompt-injection exfil path (Rule of Two). Capability SET-intersection.
1249
- const lethalTrifecta = checkLethalTrifecta(config, silent, adapter, scanRoot);
1517
+ const lethalTrifecta = overBundles(checkLethalTrifecta, config, silent, adapter, lintRoots);
1250
1518
  // 7p. Skill-resource — a SKILL.md body referencing a bundled file that doesn't
1251
1519
  // exist on disk under the skill dir (the agent gets nothing). FP-safe.
1252
- const skillResources = checkSkillResourceResolves(config, silent, adapter, scanRoot);
1520
+ const skillResources = overBundles(checkSkillResourceResolves, config, silent, adapter, lintRoots);
1253
1521
  // 7q. Skill-missing-fence — a SKILL.md opening with `name:`/`description:` but no
1254
1522
  // `---` fence loads as plain body (invisible — no name/description/trigger).
1255
- const skillFence = checkSkillMissingFence(config, silent, adapter, scanRoot);
1523
+ const skillFence = overBundles(checkSkillMissingFence, config, silent, adapter, lintRoots);
1256
1524
  // 7r. Plugin-dir-layout — functional surface dirs (skills/agents/commands) nested
1257
1525
  // inside the `.claude-plugin/` manifest dir where the harness can't see them.
1258
- const pluginLayout = checkPluginDirLayout(config, silent, adapter, scanRoot);
1526
+ const pluginLayout = overBundles(checkPluginDirLayout, config, silent, adapter, lintRoots);
1259
1527
  // 7s. Delegation-trifecta — a lethal trifecta that emerges across a delegation
1260
1528
  // edge (a subagent's own ∪ delegated-to capability) though no single unit trips it.
1261
- const delegationTrifecta = checkDelegationTrifecta(config, silent, adapter, scanRoot);
1529
+ const delegationTrifecta = overBundles(checkDelegationTrifecta, config, silent, adapter, lintRoots);
1262
1530
  // 7t. Hook-block-ineffective — a hook that looks like it blocks but silently
1263
1531
  // doesn't (block decision on a non-blocking event, or the legacy `decision`
1264
1532
  // field on a permission-gated event). The #1 verified hook pain (#19009).
1265
- const hookBlock = checkHookBlockIneffective(config, silent, adapter, scanRoot);
1533
+ const hookBlock = overBundles(checkHookBlockIneffective, config, silent, adapter, lintRoots);
1266
1534
  // 7u. Hook-matcher — a hook `matcher` that doesn't fire as written (tool-name
1267
1535
  // typo, an uncompilable or unreachable MCP pattern, one too narrow for real
1268
1536
  // server naming, or an undeclared MCP server).
1269
- const hookMatcher = checkHookMatcher(config, silent, adapter, scanRoot);
1537
+ const hookMatcher = overBundles(checkHookMatcher, config, silent, adapter, lintRoots);
1270
1538
  // 8. Validate vigiles builder calls inside markdown code blocks — the
1271
1539
  // `doc-refs` rule, DEFAULT OFF. Illustrative blocks opt out via
1272
1540
  // `<!-- vigiles:ignore -->` (single block) or `<!-- vigiles:ignore-file -->`
@@ -1342,13 +1610,17 @@ async function runLint(restArgs, flags, config) {
1342
1610
  inlineRules,
1343
1611
  frontmatterErrors,
1344
1612
  frontmatterRules,
1613
+ specRefIssues: specRefs.issues,
1614
+ specRefErrors: specRefs.errors,
1345
1615
  duplicatePairs: dups.pairCount,
1616
+ duplicateSeverity: (0, types_js_1.ruleSeverity)(config?.rules?.["duplicate-rules"]) ?? "warn",
1346
1617
  coverageEnabled: coverage.enabled,
1347
1618
  coverageDocumented: coverage.documented,
1348
1619
  strengthenSuggestions: guidanceCount,
1349
1620
  integrityErrors,
1350
1621
  coverageErrors,
1351
1622
  orphanCount: orphanReport.orphans.length,
1623
+ orphanSeverity: orphanSeverity,
1352
1624
  untestedSurfaces: untested.untested,
1353
1625
  untestedErrors: untested.errors,
1354
1626
  toolContractIssues: toolContract.issues,
@@ -1399,13 +1671,41 @@ async function runLint(restArgs, flags, config) {
1399
1671
  mcpRefErrors,
1400
1672
  files,
1401
1673
  };
1674
+ // The totals both surfaces quote, computed ONCE (#183). Attaching them to the
1675
+ // report is what makes the log line and `--json` incapable of disagreeing —
1676
+ // the previous gap was not a wrong number, it was two right numbers with
1677
+ // nothing explaining the difference.
1678
+ const totals = lintTotals(report);
1679
+ const reported = { ...report, totals };
1680
+ // `--json-out=<file>` writes the JSON to disk while stdout keeps the
1681
+ // human-readable run — one scan, both artefacts (#182). A CI job needed both
1682
+ // (the log is what a human opens; the JSON is what the PR comment is built
1683
+ // from) and had to scan the repo TWICE to get them, which is the same work
1684
+ // done twice and grows with the corpus.
1685
+ const jsonOutFlag = flags.find((f) => f.startsWith("--json-out="));
1686
+ if (jsonOutFlag) {
1687
+ const dest = (0, node_path_1.resolve)(jsonOutFlag.slice("--json-out=".length));
1688
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(dest), { recursive: true });
1689
+ (0, node_fs_1.writeFileSync)(dest, `${JSON.stringify(reported, null, 2)}\n`);
1690
+ if (!silent)
1691
+ console.log(`\n✓ JSON report written to ${dest}`);
1692
+ }
1402
1693
  if (summary) {
1403
- printLintSummary(report);
1694
+ printLintSummary(reported);
1404
1695
  }
1405
1696
  else if (json) {
1406
- console.log(JSON.stringify(report, null, 2));
1697
+ console.log(JSON.stringify(reported, null, 2));
1407
1698
  }
1408
- return report;
1699
+ else {
1700
+ // The one number a reader can quote. Counting `⚠` lines gives a DIFFERENT
1701
+ // number, because some checks print one line per finding and others one line
1702
+ // carrying a count — so the log now states the finding total outright rather
1703
+ // than leaving the reader to infer it from line shapes.
1704
+ const code = lintExitCode(reported);
1705
+ console.log(`\n${String(totals.findings)} finding(s): ${String(totals.errors)} error, ` +
1706
+ `${String(totals.warnings)} warning — exit ${String(code)}`);
1707
+ }
1708
+ return reported;
1409
1709
  }
1410
1710
  /** Single-line lint summary for SessionStart hooks — minimal token cost. */
1411
1711
  function printLintSummary(report) {
@@ -5947,14 +6247,12 @@ async function runHookProgramCommand(file) {
5947
6247
  function ensureReportGitignored(cwd, entries) {
5948
6248
  if (entries.length === 0)
5949
6249
  return;
5950
- // An `--out` outside the repo produced entries like
5951
- // `../../../../private/tmp/x/vigiles-report.json`, which ignore NOTHING
5952
- // .gitignore does not reach outside its own tree and accumulate one dead
5953
- // block per output path. Worse in principle than in practice: `audit` is
5954
- // documented as a read-only report, and this made it edit a tracked file for
5955
- // no benefit at all. Inside the repo the write is expected and documented.
5956
- if (entries.some((e) => e.startsWith("..") || (0, node_path_1.isAbsolute)(e)))
5957
- return;
6250
+ // 🔴 THE GUARD THAT USED TO BE HERE IS GONE, and its absence is the point.
6251
+ // It checked at the write site that no entry escaped the repo (#176.8) which
6252
+ // worked, and left the bug writable: the next caller to build an entry list
6253
+ // still got a bare `string[]`. `RepoRelativePath` moves the check into the
6254
+ // TYPE, so an escaping path cannot be handed to this function at all. One
6255
+ // place mints them (`repoRelative`), and it returns null instead.
5958
6256
  const gi = (0, node_path_1.resolve)(cwd, ".gitignore");
5959
6257
  try {
5960
6258
  if (!(0, node_fs_1.existsSync)(gi)) {
@@ -6860,10 +7158,17 @@ async function main() {
6860
7158
  // backslashes (`relative()` yields `reports\x` on Windows, which would
6861
7159
  // never match `reports/x`).
6862
7160
  if (wroteReports.length > 0) {
6863
- const rel = wroteReports.map((f) => {
6864
- const r = (0, node_path_1.relative)(process.cwd(), (0, node_path_1.resolve)(outDir, f)) || f;
6865
- return node_path_1.sep === "/" ? r : r.split(node_path_1.sep).join("/");
6866
- });
7161
+ // Only paths that PROVABLY sit inside the repo can be minted, so an
7162
+ // `--out` pointing elsewhere yields nothing to write rather than a
7163
+ // dead `../../..` entry.
7164
+ const rel = wroteReports
7165
+ .map((f) => (0, repo_path_js_1.repoRelative)(process.cwd(), (0, node_path_1.resolve)(outDir, f), {
7166
+ relative: node_path_1.relative,
7167
+ resolve: node_path_1.resolve,
7168
+ isAbsolute: node_path_1.isAbsolute,
7169
+ sep: node_path_1.sep,
7170
+ }))
7171
+ .filter((p) => p !== null);
6867
7172
  ensureReportGitignored(process.cwd(), rel);
6868
7173
  }
6869
7174
  // A shareable deep-link for a public GitHub repo: the in-browser demo
@@ -25,6 +25,7 @@
25
25
  * Gate references (`cmd`/`file`/`project`) are collected and verified, so the
26
26
  * cross-referencing moat works on generators too (literal args only).
27
27
  */
28
+ import { type StampedMarkdown } from "./compile.js";
28
29
  export interface GeneratorError {
29
30
  type: "stale-file" | "stale-command";
30
31
  message: string;
@@ -34,6 +35,11 @@ export interface CompileGeneratorResult {
34
35
  errors: GeneratorError[];
35
36
  }
36
37
  export interface CompileGeneratorSkillResult {
38
+ /**
39
+ * The stamped artifact — present ONLY when `errors` is empty. `null` is what
40
+ * makes a failed compile unwritable: `writeArtifact` accepts nothing else.
41
+ */
42
+ artifact: StampedMarkdown | null;
37
43
  markdown: string;
38
44
  errors: GeneratorError[];
39
45
  }
@@ -278,6 +278,9 @@ function compileGeneratorSkill(source, options = {}) {
278
278
  !genArg.body) {
279
279
  return {
280
280
  markdown: "",
281
+ // No stamp for a spec that did not compile — the error branch has nothing
282
+ // to write, which is the whole point of the field.
283
+ artifact: null,
281
284
  errors: [
282
285
  {
283
286
  type: "stale-command",
@@ -300,7 +303,7 @@ function compileGeneratorSkill(source, options = {}) {
300
303
  }
301
304
  fm.push("", "---");
302
305
  const content = `${fm.join("\n")}\n\n${body.trim()}\n`;
303
- return { markdown: (0, compile_js_1.addHash)(content, specFile), errors };
306
+ return { ...(0, compile_js_1.seal)(content, errors, specFile), errors };
304
307
  }
305
308
  /**
306
309
  * Compile a generator's SOURCE text to SKILL.md markdown + verified-ref