vigiles 4.0.2 → 5.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
@@ -21,9 +21,9 @@ const types_js_1 = require("./core/types.js");
21
21
  const test_coverage_js_1 = require("./test-coverage.js");
22
22
  const scan_js_1 = require("./scan.js");
23
23
  const adapter_registry_js_1 = require("./adapter-registry.js");
24
+ const skill_harness_js_1 = require("./skill-harness.js");
24
25
  const leaderboard_js_1 = require("./leaderboard.js");
25
26
  const compile_js_1 = require("./core/compile.js");
26
- const dialect_js_1 = require("./adapters/claude-code/dialect.js");
27
27
  const proofs_js_1 = require("./core/proofs.js");
28
28
  const inline_js_1 = require("./core/inline.js");
29
29
  const frontmatter_js_1 = require("./core/frontmatter.js");
@@ -32,6 +32,7 @@ const compose_js_1 = require("./core/compose.js");
32
32
  const compile_generator_js_1 = require("./core/compile-generator.js");
33
33
  const action_gate_js_1 = require("./action-gate.js");
34
34
  const agent_runtime_js_1 = require("./adapters/claude-code/agent-runtime.js");
35
+ const tool_intercept_js_1 = require("./tool-intercept.js");
35
36
  const refs_js_1 = require("./core/refs.js");
36
37
  const mcp_js_1 = require("./core/mcp.js");
37
38
  const skill_runtime_js_1 = require("./adapters/claude-code/skill-runtime.js");
@@ -140,12 +141,12 @@ function compileGeneratorSkillToFile(specPath, source) {
140
141
  return false;
141
142
  }
142
143
  /** Compile a ClaudeSpec → its primary + any additional targets. */
143
- function compileClaudeToFile(spec, specPath, config) {
144
+ function compileClaudeToFile(spec, specPath, config, dialect) {
144
145
  const basePath = process.cwd();
145
146
  const { markdown, errors, linterResults, targets } = (0, compile_js_1.compileClaude)(spec, {
146
147
  basePath,
147
148
  specFile: specPath,
148
- dialect: dialect_js_1.claudeCodeDialect,
149
+ dialect,
149
150
  maxRules: config.maxRules,
150
151
  maxTokens: config.maxTokens,
151
152
  maxSectionLines: config.maxSectionLines,
@@ -175,15 +176,54 @@ function compileClaudeToFile(spec, specPath, config) {
175
176
  console.log(` ${String(Object.keys(spec.rules).length)} rules (${String(linterCount)} linter-verified)`);
176
177
  return true;
177
178
  }
179
+ /**
180
+ * Branch 3 of the mirror story (research/multi-harness-compile.md): when a repo
181
+ * declares ≥2 harnesses and nothing else fans out the instruction file, write a
182
+ * byte-identical copy to each other harness's instruction file (e.g. CLAUDE.md →
183
+ * AGENTS.md). A copy — not a symlink — because it works everywhere and carries
184
+ * the source's embedded integrity hash by construction, so a hand-edit of the
185
+ * mirror trips the existing `integrity` check. Never fights a sync tool or
186
+ * clobbers a target that owns its own spec.
187
+ */
188
+ function writeInstructionMirrors(primaryOutput, harnesses) {
189
+ if (harnesses.length < 2)
190
+ return;
191
+ const cwd = process.cwd();
192
+ // A sync tool (Ruler/rulesync) owns fan-out — don't fight it.
193
+ if ((0, compose_js_1.detectSyncTools)(cwd).length > 0)
194
+ return;
195
+ const primaryName = (0, node_path_1.basename)(primaryOutput);
196
+ const primaryAbs = (0, node_path_1.resolve)(cwd, primaryOutput);
197
+ if (!(0, node_fs_1.existsSync)(primaryAbs))
198
+ return;
199
+ const content = (0, node_fs_1.readFileSync)(primaryAbs, "utf-8");
200
+ for (const name of harnesses) {
201
+ const adapter = (0, adapter_registry_js_1.getAdapter)(name);
202
+ if (!adapter)
203
+ continue;
204
+ const target = adapter.layout.instructionFile;
205
+ if (target === primaryName)
206
+ continue; // the file we just compiled
207
+ // Never clobber a target that has its own spec (a genuinely separate file).
208
+ if ((0, node_fs_1.existsSync)((0, node_path_1.resolve)(cwd, `${target}.spec.ts`)))
209
+ continue;
210
+ const targetAbs = (0, node_path_1.resolve)(cwd, target);
211
+ if ((0, node_fs_1.existsSync)(targetAbs) && (0, node_fs_1.readFileSync)(targetAbs, "utf-8") === content) {
212
+ continue; // already byte-identical
213
+ }
214
+ (0, node_fs_1.writeFileSync)(targetAbs, content);
215
+ console.log(` ↳ mirrored ${primaryName} → ${target} (byte-identical)`);
216
+ }
217
+ }
178
218
  /** Compile a declarative SkillSpec → SKILL.md. */
179
- function compileSkillToFile(spec, specPath) {
219
+ function compileSkillToFile(spec, specPath, dialect) {
180
220
  const outputPath = specPath.replace(/\.spec\.ts$/, "");
181
221
  const { markdown, errors } = (0, compile_js_1.compileSkill)(spec, {
182
222
  basePath: process.cwd(),
183
223
  specFile: specPath,
184
- // Pick the SKILL.md frontmatter profile from the detected harness — a Codex
224
+ // The SKILL.md frontmatter profile comes from the resolved harness — a Codex
185
225
  // repo gets a minimal (name + description) SKILL.md; CC gets the full set.
186
- dialect: (0, adapter_registry_js_1.detectAdapter)(process.cwd()).dialect,
226
+ dialect,
187
227
  });
188
228
  (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(process.cwd(), outputPath), markdown);
189
229
  if (errors.length === 0) {
@@ -195,12 +235,12 @@ function compileSkillToFile(spec, specPath) {
195
235
  return false;
196
236
  }
197
237
  /** Compile a subagent spec → agents/<name>.md (with its result-contract section). */
198
- function compileAgentToFile(spec, specPath) {
238
+ function compileAgentToFile(spec, specPath, dialect) {
199
239
  const outputPath = specPath.replace(/\.spec\.ts$/, "");
200
240
  const { markdown, errors } = (0, compile_js_1.compileAgent)(spec, {
201
241
  basePath: process.cwd(),
202
242
  specFile: specPath,
203
- dialect: (0, adapter_registry_js_1.detectAdapter)(process.cwd()).dialect,
243
+ dialect,
204
244
  });
205
245
  (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(process.cwd(), outputPath), markdown);
206
246
  if (errors.length === 0) {
@@ -241,8 +281,21 @@ async function collectAgentNames() {
241
281
  }
242
282
  return names;
243
283
  }
244
- async function compile(specPaths, config) {
284
+ async function compile(specPaths, config, opts = {}) {
245
285
  let allValid = true;
286
+ // Parse the declared harness set ONCE (alias-normalized) and feed both the
287
+ // dialect pick and the mirror from it — no re-parsing, no cwd-sniffing in the
288
+ // helpers. A loud notice (never a silent guess) on a multi-harness or
289
+ // ambiguous-detection pick.
290
+ const declaredHarnesses = (0, adapter_registry_js_1.normalizeHarnessList)(config.harness);
291
+ const selection = (0, adapter_registry_js_1.resolveHarnessSelection)({
292
+ root: process.cwd(),
293
+ flag: opts.harnessFlag,
294
+ configHarness: declaredHarnesses,
295
+ });
296
+ if (selection.kind === "notice")
297
+ console.log(`⚠ ${selection.notice}`);
298
+ const dialect = selection.adapter.dialect;
246
299
  // Resolved lazily on the first railway spec — every delegate() target is
247
300
  // checked against the agents defined anywhere in the project.
248
301
  let knownAgents = null;
@@ -262,15 +315,36 @@ async function compile(specPaths, config) {
262
315
  continue;
263
316
  }
264
317
  if (spec._specType === "claude") {
265
- if (!compileClaudeToFile(spec, specPath, config))
318
+ // Spec-target disambiguation: a CLAUDE.md.spec.ts is a claude-code file, an
319
+ // AGENTS.md.spec.ts a codex one — the strongest dialect signal for THIS
320
+ // spec. The flag still overrides; absent one, the spec's own target wins
321
+ // over config/detect. (Skill/agent targets don't name a harness, so they
322
+ // keep the run-level dialect.)
323
+ const targetFile = (0, node_path_1.basename)(specPath).replace(/\.spec\.ts$/, "");
324
+ const specDialect = opts.harnessFlag === undefined
325
+ ? ((0, adapter_registry_js_1.adapterForInstructionFile)(targetFile)?.dialect ?? dialect)
326
+ : dialect;
327
+ if (compileClaudeToFile(spec, specPath, config, specDialect)) {
328
+ writeInstructionMirrors(specPath.replace(/\.spec\.ts$/, ""), declaredHarnesses);
329
+ }
330
+ else {
266
331
  allValid = false;
332
+ }
267
333
  }
268
334
  else if (spec._specType === "skill") {
269
- if (!compileSkillToFile(spec, specPath))
335
+ // Cross-harness verify: flag CC-only frontmatter a declared minimal-profile
336
+ // harness (Codex/OpenCode) would silently drop.
337
+ const forHarnesses = declaredHarnesses.length > 0
338
+ ? declaredHarnesses
339
+ : [selection.adapter.name];
340
+ for (const w of (0, skill_harness_js_1.skillFrontmatterDropWarnings)(spec, forHarnesses)) {
341
+ console.log(`⚠ ${w}`);
342
+ }
343
+ if (!compileSkillToFile(spec, specPath, dialect))
270
344
  allValid = false;
271
345
  }
272
346
  else if (spec._specType === "agent") {
273
- if (!compileAgentToFile(spec, specPath))
347
+ if (!compileAgentToFile(spec, specPath, dialect))
274
348
  allValid = false;
275
349
  }
276
350
  else if (spec._specType === "railway") {
@@ -836,8 +910,8 @@ async function runLint(restArgs, flags, config) {
836
910
  }
837
911
  }
838
912
  // 7b. Untested-surface check — skills/agents/hooks shipping without a test or
839
- // eval. Warning by default (a nudge, exit 0); set rules.untested-surface to
840
- // "error" to gate CI. See src/test-coverage.ts and docs/rules/untested-surface.md.
913
+ // eval. Warning by default (a nudge, exit 0); set rules.untested-{skill,agent,
914
+ // hook} to "error" to gate CI. See src/test-coverage.ts and docs/rules/.
841
915
  const untested = checkUntestedSurfaces(config, silent);
842
916
  // 8. Validate vigiles builder calls inside markdown code blocks. Default
843
917
  // is to validate every ref; illustrative blocks opt out via
@@ -851,6 +925,16 @@ async function runLint(restArgs, flags, config) {
851
925
  console.log(` ${line}`);
852
926
  }
853
927
  }
928
+ // Per-line GitHub annotations for each broken doc ref — each carries file+line,
929
+ // so GitHub renders it INLINE on the PR diff (not just in the summary blob).
930
+ // Previously this check reported to stdout only; the inline/spec checks already
931
+ // annotate per-line, so this closes the gap that left doc-ref findings invisible
932
+ // on the PR. CI-only (isGitHubActions); skipped under --json/--summary.
933
+ if (isGitHubActions() && !silent) {
934
+ for (const e of docRefReport.errors) {
935
+ ghAnnotate("error", `${e.kind}("${e.value}") — ${e.message}`, e.file, e.line);
936
+ }
937
+ }
854
938
  // 9. Verify code-shaped symbol references live (see src/refs.ts).
855
939
  const symbolRefErrors = verifyMarkdownSymbols(files, silent);
856
940
  // 10. Verify `vigiles:mcp server#tool` marks against live MCP servers
@@ -1735,16 +1819,44 @@ async function setup(args) {
1735
1819
  console.log("\n Non-markdown agent configs detected. Use a sync tool to convert:");
1736
1820
  console.log(" npm install -D rule-porter");
1737
1821
  }
1738
- // Strict config.
1739
- if (strict) {
1740
- const configPath = (0, node_path_1.resolve)(process.cwd(), ".vigilesrc.json");
1741
- if (!(0, node_fs_1.existsSync)(configPath)) {
1742
- (0, node_fs_1.writeFileSync)(configPath, JSON.stringify({ rules: { "require-spec": "error", "require-skill-spec": "error" } }, null, 2) + "\n");
1743
- console.log("✓ Created .vigilesrc.json with strict rules");
1744
- written.push(".vigilesrc.json");
1822
+ // Project config — record the harness(es) so compile/lint select the dialect
1823
+ // deterministically (no cwd sniffing), plus strict rule severities on --strict.
1824
+ writeProjectConfig({ harnesses, strict, written });
1825
+ printSetupSummary({ plan, strict, targets, needsMigration, written });
1826
+ }
1827
+ /** Canonical, de-duplicated harness list → a config value (string when one). */
1828
+ function harnessConfigValue(harnesses) {
1829
+ const canon = [...new Set(harnesses.map(adapter_registry_js_1.normalizeHarnessName))];
1830
+ return canon.length === 1 ? canon[0] : canon;
1831
+ }
1832
+ /**
1833
+ * Merge the resolved harness(es) (and strict rule severities) into
1834
+ * `.vigilesrc.json` without clobbering existing keys — an existing `harness`
1835
+ * stays, a missing one is added, a malformed file is left untouched.
1836
+ */
1837
+ function writeProjectConfig(opts) {
1838
+ const configPath = (0, node_path_1.resolve)(process.cwd(), ".vigilesrc.json");
1839
+ const existed = (0, node_fs_1.existsSync)(configPath);
1840
+ let existing = {};
1841
+ if (existed) {
1842
+ try {
1843
+ existing = JSON.parse((0, node_fs_1.readFileSync)(configPath, "utf-8"));
1844
+ }
1845
+ catch {
1846
+ return; // user-owned malformed config — never clobber it
1745
1847
  }
1746
1848
  }
1747
- printSetupSummary({ plan, strict, targets, needsMigration, written });
1849
+ const merged = (0, setup_plan_js_1.mergeProjectConfig)(existing, {
1850
+ harness: harnessConfigValue(opts.harnesses),
1851
+ strict: opts.strict,
1852
+ });
1853
+ if (!merged)
1854
+ return;
1855
+ (0, node_fs_1.writeFileSync)(configPath, JSON.stringify(merged, null, 2) + "\n");
1856
+ console.log(`✓ ${existed ? "Updated" : "Created"} .vigilesrc.json`);
1857
+ if (!opts.written.includes(".vigilesrc.json")) {
1858
+ opts.written.push(".vigilesrc.json");
1859
+ }
1748
1860
  }
1749
1861
  // ---------------------------------------------------------------------------
1750
1862
  // Strengthen: guidance() → enforce() suggestions
@@ -1776,29 +1888,47 @@ function checkIntegrityForFiles(files, severity, silent) {
1776
1888
  return severity === "error" ? errorCount : 0;
1777
1889
  }
1778
1890
  /**
1779
- * Apply the `untested-surface` rule: find skills/agents/hooks with no test or
1780
- * eval (see src/test-coverage.ts). Returns the raw untested count plus the
1781
- * severity-gated error count"warn" prints but never fails CI (errors=0),
1782
- * "error" fails (exit 2), mirroring the integrity check.
1891
+ * Apply the per-kind `untested-skill` / `untested-agent` / `untested-hook` rules:
1892
+ * find skills/agents/hooks with no test or eval (see src/test-coverage.ts). Each
1893
+ * kind is gated by its OWN rule severity a kind set to `false` is not scanned;
1894
+ * "warn" prints but never fails CI; "error" fails (exit 2). Returns the raw
1895
+ * untested count plus the severity-gated error count.
1783
1896
  */
1784
1897
  function checkUntestedSurfaces(config, silent) {
1785
- const severity = (0, types_js_1.ruleSeverity)(config?.rules["untested-surface"]);
1786
- if (!severity)
1898
+ const rules = config?.rules;
1899
+ const skillSev = (0, types_js_1.ruleSeverity)(rules?.["untested-skill"]);
1900
+ const agentSev = (0, types_js_1.ruleSeverity)(rules?.["untested-agent"]);
1901
+ const hookSev = (0, types_js_1.ruleSeverity)(rules?.["untested-hook"]);
1902
+ if (!skillSev && !agentSev && !hookSev)
1787
1903
  return { untested: 0, errors: 0 };
1788
- const opts = (0, types_js_1.ruleOptions)(config?.rules["untested-surface"]);
1789
- const report = (0, test_coverage_js_1.findUntestedSurfaces)({ basePath: process.cwd(), ...opts });
1904
+ const sevFor = (kind) => kind === "skill" ? skillSev : kind === "agent" ? agentSev : hookSev;
1905
+ // Test-discovery options (testGlobs/exclude) are shared; merge them from
1906
+ // whichever of the three rules carries them.
1907
+ const opts = {
1908
+ ...(0, types_js_1.ruleOptions)(rules?.["untested-skill"]),
1909
+ ...(0, types_js_1.ruleOptions)(rules?.["untested-agent"]),
1910
+ ...(0, types_js_1.ruleOptions)(rules?.["untested-hook"]),
1911
+ };
1912
+ const report = (0, test_coverage_js_1.findUntestedSurfaces)({
1913
+ basePath: process.cwd(),
1914
+ skills: skillSev !== false,
1915
+ agents: agentSev !== false,
1916
+ hooks: hookSev !== false,
1917
+ testGlobs: opts.testGlobs,
1918
+ exclude: opts.exclude,
1919
+ });
1790
1920
  if (!silent) {
1791
1921
  console.log("\nUntested surfaces:\n");
1792
1922
  for (const line of (0, test_coverage_js_1.formatUntestedReport)(report).split("\n")) {
1793
1923
  console.log(` ${line}`);
1794
1924
  }
1795
1925
  for (const s of report.untested) {
1796
- ghAnnotate(severity === "error" ? "error" : "warning", `${s.kind} ${s.path} ships without a test or eval`, s.path);
1926
+ ghAnnotate(sevFor(s.kind) === "error" ? "error" : "warning", `${s.kind} ${s.path} ships without a test or eval`, s.path);
1797
1927
  }
1798
1928
  }
1799
1929
  return {
1800
1930
  untested: report.untested.length,
1801
- errors: severity === "error" ? report.untested.length : 0,
1931
+ errors: report.untested.filter((s) => sevFor(s.kind) === "error").length,
1802
1932
  };
1803
1933
  }
1804
1934
  /**
@@ -1980,6 +2110,18 @@ function handleRunScripts(kind, args, restArgs) {
1980
2110
  // Harness/eval scripts may be authored in JS or TS (see run-scripts.ts).
1981
2111
  const defaultGlob = (0, run_scripts_js_1.scriptGlob)(kind === "test" ? "harness" : "eval");
1982
2112
  const files = (0, run_scripts_js_1.discoverScripts)(restArgs, defaultGlob, cwd);
2113
+ // `--min=N`: a CI gate asserts at least N scripts actually RAN — so a bad path,
2114
+ // a renamed file, or a glob that matched nothing fails LOUD instead of passing
2115
+ // green with zero evals executed. Default 0 (off) keeps local runs ergonomic.
2116
+ const minFlag = args.find((a) => a.startsWith("--min="));
2117
+ const minRequired = minFlag
2118
+ ? Math.max(0, Number.parseInt(minFlag.split("=")[1] ?? "", 10) || 0)
2119
+ : 0;
2120
+ if (files.length < minRequired) {
2121
+ console.error(`✗ vigiles ${kind}: --min=${String(minRequired)} but only ${String(files.length)} ${kind} file(s) matched — ` +
2122
+ "evals never executed (check the paths/globs, or that the run was reached).");
2123
+ process.exit(1);
2124
+ }
1983
2125
  if (files.length === 0) {
1984
2126
  console.log(`No ${defaultGlob} files found.`);
1985
2127
  return;
@@ -1990,6 +2132,10 @@ function handleRunScripts(kind, args, restArgs) {
1990
2132
  if (kind === "test" && !(0, harness_test_js_1.claudeAvailable)()) {
1991
2133
  console.log("ℹ `claude` CLI not found — unit-tier tests run; tests that need it report SKIPPED.\n");
1992
2134
  }
2135
+ // `--trials=N` (a run knob: cost/precision, doesn't change WHAT is measured) is
2136
+ // forwarded to scripts via env. The MODEL is deliberately NOT a CLI/env knob —
2137
+ // it's part of the measurement definition, so it belongs in the spec
2138
+ // (`model` / `minModel`), version-controlled, not a hidden override.
1993
2139
  const trialsFlag = args.find((a) => a.startsWith("--trials="));
1994
2140
  const env = {};
1995
2141
  if (trialsFlag)
@@ -2016,7 +2162,7 @@ function printUsage(command) {
2016
2162
  console.log(" vigiles compile [files...] Compile .spec.ts → .md");
2017
2163
  console.log(" vigiles lint [files...] Verify references, find gaps in instruction files");
2018
2164
  console.log(" vigiles test [files...] Run *.harness.mjs deterministic harness tests");
2019
- console.log(" vigiles eval [files...] Run *.eval.mjs real-model harness evals (--trials=N)");
2165
+ console.log(" vigiles eval [files...] Run *.eval.mjs real-model harness evals (--trials=N, --min=N, --no-skip)");
2020
2166
  console.log("");
2021
2167
  console.log("Examples:");
2022
2168
  console.log(" vigiles init Auto-detect project, create specs, wire CI");
@@ -2130,7 +2276,7 @@ function skillStartCommand(target) {
2130
2276
  * PreToolUse-hook entrypoint: enforce the active subagent's allowed-tools
2131
2277
  * contract. Reads the tool event on stdin, parses the active agent's compiled
2132
2278
  * `.md` tool rail, and blocks (exit 2 + reason on stderr) any tool outside it —
2133
- * the deterministic boundary `tools:` alone can't provide (Claude Code #54898).
2279
+ * the deterministic boundary `tools:` alone can't provide (Claude Code #4740/#21460, SDK #172).
2134
2280
  */
2135
2281
  function agentHookCommand() {
2136
2282
  let raw = "";
@@ -2155,6 +2301,30 @@ function agentHookCommand() {
2155
2301
  process.exit(2);
2156
2302
  }
2157
2303
  }
2304
+ /**
2305
+ * `vigiles intercept-tool-hook` — the PreToolUse interception hook for the
2306
+ * tool-call spy. Reads the intercept list from `VIGILES_INTERCEPT_TOOLS`, decides
2307
+ * whether the called tool should be intercepted, and if so denies the real
2308
+ * execution (exit 2) with a block message — the call is intercepted (prevented),
2309
+ * NOT executed. Allowing (return) lets the tool run for real. The model still
2310
+ * emits the `tool_use`, so its arguments land in the Trace for `toolWith` /
2311
+ * `notTool` to assert on. See src/tool-intercept.ts.
2312
+ */
2313
+ function interceptToolHookCommand() {
2314
+ let raw = "";
2315
+ try {
2316
+ raw = (0, node_fs_1.readFileSync)(0, "utf-8");
2317
+ }
2318
+ catch {
2319
+ /* no stdin */
2320
+ }
2321
+ const intercepts = (0, tool_intercept_js_1.parseIntercepts)(process.env[tool_intercept_js_1.INTERCEPT_TOOLS_ENV] ?? "");
2322
+ const decision = (0, tool_intercept_js_1.interceptHookDecision)(raw, intercepts);
2323
+ if (decision.intercept) {
2324
+ console.error(decision.denyReason);
2325
+ process.exit(2);
2326
+ }
2327
+ }
2158
2328
  /** Mark a subagent active so the PreToolUse hook enforces its tool contract. */
2159
2329
  function agentStartCommand(target) {
2160
2330
  if (!target) {
@@ -2188,6 +2358,9 @@ function handleSkillCommand(command, restArgs) {
2188
2358
  case "agent-hook":
2189
2359
  agentHookCommand();
2190
2360
  return true;
2361
+ case "intercept-tool-hook":
2362
+ interceptToolHookCommand();
2363
+ return true;
2191
2364
  case "action-hook":
2192
2365
  actionHookCommand();
2193
2366
  return true;
@@ -2367,7 +2540,10 @@ async function main() {
2367
2540
  console.log("Run `vigiles init` to create one.");
2368
2541
  process.exit(0);
2369
2542
  }
2370
- const valid = await compile(specs, config);
2543
+ const harnessFlag = args
2544
+ .find((a) => a.startsWith("--harness="))
2545
+ ?.slice("--harness=".length);
2546
+ const valid = await compile(specs, config, { harnessFlag });
2371
2547
  console.log("");
2372
2548
  if (valid) {
2373
2549
  console.log("Compilation complete.");
@@ -69,16 +69,13 @@ export interface OrphansConfig {
69
69
  */
70
70
  exclude?: readonly string[];
71
71
  }
72
- /** Options for the untested-surface check. */
72
+ /**
73
+ * Shared options for the per-kind untested-* rules (`untested-skill` /
74
+ * `untested-agent` / `untested-hook`). Which kinds are scanned is controlled by
75
+ * each rule's severity (set a rule to `false` to skip that kind), so only the
76
+ * test-discovery knobs live here.
77
+ */
73
78
  export interface TestCoverageConfig {
74
- /** Scan skills. Default true. */
75
- skills?: boolean;
76
- /** Scan subagents. Default true. */
77
- agents?: boolean;
78
- /** Scan hook scripts referenced from plugin.json / settings.json. Default true. */
79
- hooks?: boolean;
80
- /** Require a test for user-invoked (disable-model-invocation) skills. Default false. */
81
- includeUserInvokedSkills?: boolean;
82
79
  /** Globs of test files that count as coverage. */
83
80
  testGlobs?: readonly string[];
84
81
  /** Extra ignore globs. */
@@ -87,14 +84,22 @@ export interface TestCoverageConfig {
87
84
  export interface RulesConfig {
88
85
  /** Require .spec.ts for CLAUDE.md / AGENTS.md. Default: "warn". */
89
86
  "require-spec"?: RuleSeverity;
90
- /** Require .spec.ts for SKILL.md files. Default: false. */
87
+ /**
88
+ * @deprecated Skills are legitimately hand-written; use `untested-skill`
89
+ * ("every skill ships with a test/eval") instead. Default: false (off). The
90
+ * check still runs if you set this explicitly.
91
+ */
91
92
  "require-skill-spec"?: RuleSeverity;
92
93
  /** Detect hand-edits to compiled markdown via SHA-256 hash. Default: "warn". */
93
94
  integrity?: RuleSeverity;
94
95
  /** Enforce minimum spec coverage thresholds. Default: false. ESLint-style: ["warn", { scripts: 50 }]. */
95
96
  coverage?: RuleWithOptions<CoverageThresholds>;
96
- /** Flag skills/agents/hooks with no test or eval. Default: "warn". */
97
- "untested-surface"?: RuleWithOptions<TestCoverageConfig>;
97
+ /** Flag a skill (SKILL.md) that ships with no test or eval. Default: "warn". */
98
+ "untested-skill"?: RuleWithOptions<TestCoverageConfig>;
99
+ /** Flag a subagent (agents/*.md) that ships with no test or eval. Default: "warn". */
100
+ "untested-agent"?: RuleWithOptions<TestCoverageConfig>;
101
+ /** Flag a hook script that ships with no test or eval. Default: "warn". */
102
+ "untested-hook"?: RuleWithOptions<TestCoverageConfig>;
98
103
  /**
99
104
  * Nudge (or block) when an instruction file has code-shaped references that
100
105
  * aren't expressed as vigiles marks (so the lint can't verify them), or a
@@ -127,6 +132,16 @@ export interface VigilesConfig {
127
132
  }>;
128
133
  /** Orphan-docs check configuration. Include/exclude globs, tsconfig-style. */
129
134
  orphans?: OrphansConfig;
135
+ /**
136
+ * The harness(es) this repo targets — selects the compile dialect / skill
137
+ * frontmatter profile / instruction-file shape, instead of sniffing the cwd.
138
+ * A single name (`"codex"`) for the common single-harness repo, or an array
139
+ * (`["claude-code", "codex"]`) declaring the supported set. Written by
140
+ * `vigiles init`. Omitted → the CLI auto-detects (backwards-compatible).
141
+ * Canonical adapter names; `"claude"` is accepted as an alias for
142
+ * `"claude-code"`. See research/multi-harness-compile.md.
143
+ */
144
+ harness?: string | string[];
130
145
  }
131
146
  /** Valid marker types for rule detection. */
132
147
  export type MarkerType = "headings" | "checkboxes";
@@ -32,10 +32,19 @@ const INSTRUCTION_FILES = ["CLAUDE.md", "AGENTS.md"];
32
32
  const DEFAULT_FILES = [INSTRUCTION_FILES[0]];
33
33
  const DEFAULT_RULES = {
34
34
  "require-spec": "warn",
35
- "require-skill-spec": "warn",
35
+ // DEPRECATED — default OFF. Skills are legitimately hand-written (Level 0/1),
36
+ // so requiring a .spec.ts per SKILL.md was the wrong constraint and only added
37
+ // noise (it also nagged about vendored/fixture/bench skills). Use the
38
+ // `untested-*` rules instead — "every skill/agent/hook ships with a test or
39
+ // eval" is the coverage that matters. The implementation is kept: setting
40
+ // `require-skill-spec` explicitly still works for anyone who wants it.
41
+ "require-skill-spec": false,
36
42
  integrity: "warn",
37
43
  coverage: false,
38
- "untested-surface": "warn",
44
+ // Per-kind surface-coverage: a skill/agent/hook must ship with a test or eval.
45
+ "untested-skill": "warn",
46
+ "untested-agent": "warn",
47
+ "untested-hook": "warn",
39
48
  "unmarked-refs": "warn",
40
49
  };
41
50
  const DEFAULT_CONFIG = {
@@ -175,7 +184,9 @@ function validate(content, { ruleMarkers, rules: rulesConfig, filePath, dialect
175
184
  }
176
185
  }
177
186
  }
178
- // --- require-skill-spec (SKILL.md) ---
187
+ // --- require-skill-spec (SKILL.md) — DEPRECATED but still honored when a
188
+ // user sets it explicitly, so reading the deprecated key here is intentional.
189
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
179
190
  const skillSeverity = activeRules["require-skill-spec"];
180
191
  if (skillSeverity && isSkill && !disableComment.test(content)) {
181
192
  const specPath = filePath + ".spec.ts";
@@ -11,6 +11,29 @@ export interface CacheKeyInput {
11
11
  readonly files: Record<string, string>;
12
12
  /** The resolved `.claude/settings.json` for the arm (or undefined). */
13
13
  readonly settings: unknown;
14
+ /**
15
+ * Per-run env that affects model behaviour (e.g. `VIGILES_INTERCEPT_TOOLS`).
16
+ * Keyed because two intercept configs that share tool names — so produce
17
+ * identical merged `settings` — still differ in their `when`/`denyReason`, which
18
+ * lives only in the env. Omit when there's no model-affecting env.
19
+ */
20
+ readonly env?: Record<string, string>;
21
+ /**
22
+ * Content digest of a natively-installed plugin dir (`--plugin-dir`), or
23
+ * undefined when there is none. Folded in so editing a skill INSIDE the dir
24
+ * invalidates the entry — a path-only key would false-replay, since the dir's
25
+ * files are NOT in `files` (that holds only the materialized fixture / `plugin`
26
+ * arm, not a native install). See {@link hashDir}.
27
+ */
28
+ readonly pluginDirHash?: string;
29
+ /**
30
+ * The harness BINARY version (e.g. `claude --version`). The harness evolves
31
+ * fast — a CLI upgrade changes the system prompt + tool definitions, which steer
32
+ * behaviour as much as the model does — so a cached result must invalidate when
33
+ * the binary changes, or a replay silently serves a result from a different
34
+ * harness. Resolved once per run; omit when unknown (then it doesn't partition).
35
+ */
36
+ readonly harnessVersion?: string;
14
37
  /** Which trial this is — distinct trials are distinct samples, cached apart. */
15
38
  readonly trialIndex: number;
16
39
  }
@@ -20,9 +43,23 @@ export interface CacheRecord {
20
43
  /** Text files present in the cwd after the run (relative path → contents). */
21
44
  readonly files: Record<string, string>;
22
45
  }
46
+ /**
47
+ * Cache record-format version, SALTED into every key (Jest `CACHE_VERSION` /
48
+ * webpack `cache.version` pattern). Bump when the `CacheRecord` shape — or how a
49
+ * record is produced in a way the key can't otherwise see — changes, so old
50
+ * entries become *unreachable* rather than deserializing into a stale shape (no
51
+ * brittle read-time version gate needed). A major bump means orphaned files on
52
+ * disk; reclaim them by deleting the cache dir.
53
+ */
54
+ export declare const CACHE_FORMAT_VERSION = 2;
23
55
  /** Deterministic content hash of the key inputs (order-independent). */
24
56
  export declare function cacheKey(input: CacheKeyInput): SHA256Hash;
25
- /** Read a cached record by key, or null on miss / unreadable / malformed. */
57
+ /**
58
+ * Read a cached record by key. A MISS (no file) returns `null` — normal, the run
59
+ * proceeds. A CORRUPT record (file present but not valid JSON) **throws** instead
60
+ * of silently degrading to a re-run: a broken cassette is a real failure the CI
61
+ * gate must surface, not mask. The message tells you how to recover.
62
+ */
26
63
  export declare function readCache(dir: string, key: SHA256Hash): CacheRecord | null;
27
64
  /** Write a cached record by key (creating the cache dir as needed). */
28
65
  export declare function writeCache(dir: string, key: SHA256Hash, record: CacheRecord): void;
@@ -30,4 +67,15 @@ export declare function writeCache(dir: string, key: SHA256Hash, record: CacheRe
30
67
  export declare function snapshotDir(cwd: string): Record<string, string>;
31
68
  /** Restore a snapshot into `cwd`, recreating directories as needed. */
32
69
  export declare function restoreDir(cwd: string, files: Record<string, string>): void;
70
+ /**
71
+ * Content digest of a directory: a lexicographically-sorted list of
72
+ * `relativePath:contentHash` for every file, hashed to one value. Editing,
73
+ * adding, removing, or moving any file changes the digest. It hashes file
74
+ * CONTENT (not mtime — CI checkouts reset mtimes, the classic stale-cache
75
+ * anti-pattern) and includes the relative path (so a rename invalidates and two
76
+ * files can't swap contents undetected). A flat sorted list, NOT a Merkle tree —
77
+ * sufficient at plugin-dir scale; the tree's incremental-recompute payoff isn't
78
+ * worth the complexity here (cf. Bazel/Turborepo hash content per file).
79
+ */
80
+ export declare function hashDir(dir: string): SHA256Hash;
33
81
  //# sourceMappingURL=eval-cache.d.ts.map