vigiles 30.0.0 → 30.0.2

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.
@@ -40,6 +40,7 @@ const node_fs_1 = require("node:fs");
40
40
  const node_path_1 = require("node:path");
41
41
  const effects_js_1 = require("../../core/effects.js");
42
42
  const dialect_js_1 = require("./dialect.js");
43
+ const local_files_js_1 = require("../../local-files.js");
43
44
  const effect_region_js_1 = require("./effect-region.js");
44
45
  const agent_tools_js_1 = require("./agent-tools.js");
45
46
  Object.defineProperty(exports, "parseAgentTools", { enumerable: true, get: function () { return agent_tools_js_1.parseAgentTools; } });
@@ -90,7 +91,7 @@ function decidePreToolUse(allowed, tool) {
90
91
  // STACK: push on dispatch, pop on SubagentStop (back to the parent), gate on the
91
92
  // stack TOP. Counterexample the flat model fails and the stack model passes:
92
93
  // Open(writer); Open(writer); Stop; Call(Bash).
93
- const ACTIVE_PATH = ".vigiles/active-agent.json";
94
+ const ACTIVE_PATH = `${local_files_js_1.VIGILES_DIR}/${local_files_js_1.ACTIVE_AGENT_FILE}`;
94
95
  /**
95
96
  * Read the active-agent stack (oldest → newest; the dispatched subagent chain).
96
97
  * Back-compat: a legacy single-slot `{ agent: string }` marker reads as a one-frame
@@ -122,6 +123,7 @@ function writeActiveStack(cwd, stack) {
122
123
  return;
123
124
  }
124
125
  (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(p), { recursive: true });
126
+ (0, local_files_js_1.ensureLocalFilesIgnored)((0, node_path_1.dirname)(p));
125
127
  (0, node_fs_1.writeFileSync)(p, JSON.stringify({ stack }) + "\n");
126
128
  }
127
129
  /**
@@ -12,11 +12,13 @@ exports.hasEffectBoundary = hasEffectBoundary;
12
12
  */
13
13
  const node_fs_1 = require("node:fs");
14
14
  const node_path_1 = require("node:path");
15
- const EFFECT_ACTIVE_PATH = ".vigiles/effect-active.json";
15
+ const local_files_js_1 = require("../../local-files.js");
16
+ const EFFECT_ACTIVE_PATH = `${local_files_js_1.VIGILES_DIR}/${local_files_js_1.EFFECT_ACTIVE_FILE}`;
16
17
  /** Record that the agent has entered an effect boundary. */
17
18
  function setEffectActive(cwd) {
18
19
  const p = (0, node_path_1.resolve)(cwd, EFFECT_ACTIVE_PATH);
19
20
  (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(p), { recursive: true });
21
+ (0, local_files_js_1.ensureLocalFilesIgnored)((0, node_path_1.dirname)(p));
20
22
  (0, node_fs_1.writeFileSync)(p, JSON.stringify({ active: true }) + "\n");
21
23
  }
22
24
  /** Clear the effect-active marker (the agent exited the effect boundary). */
@@ -37,7 +37,19 @@ export interface ScriptRunResult {
37
37
  */
38
38
  export declare const SKIP_EXIT_CODE = 77;
39
39
  /**
40
- * Classify one script's run from its exit code and its reported check count.
40
+ * What the runner's load hook saw of one script (`harness-resolve-hooks.mts`).
41
+ *
42
+ * `marked` — the hook saw the script's module as an ES module and planted a
43
+ * marker import at the top of it. `linked` — that marker evaluated, which ESM
44
+ * only does once the WHOLE import graph was found, parsed and linked.
45
+ */
46
+ export interface LoadEvidence {
47
+ readonly marked: boolean;
48
+ readonly linked: boolean;
49
+ }
50
+ /**
51
+ * Classify one script's run from its exit code, its reported check count and
52
+ * what the load hook saw.
41
53
  *
42
54
  * 🔴 THE FOURTH STATE, AND WHY. Exit codes answer "did it fail?", never "did it
43
55
  * do anything?". Measured 2026-08-08: a file whose whole body is
@@ -60,8 +72,36 @@ export declare const SKIP_EXIT_CODE = 77;
60
72
  * none of it says zero. This is the `undefined`-vs-`[]` distinction
61
73
  * `assertNoWrite` already draws: "nobody looked" must not read as "nothing
62
74
  * happened".
75
+ *
76
+ * 🔴 DID IT LOAD? A non-zero exit from a script whose module was marked and
77
+ * never linked is did-not-load: `"skip"` with the loader's exit code, which
78
+ * keeps its coverage (`fail` RETRACTS it, see `runsFromResults`). A module that
79
+ * never linked executed no assertion — the same "did not run" as a missing
80
+ * `claude`, arriving through a different door. MEASURED, twice on 2026-08-20: a
81
+ * container restore left an old `node_modules`, every harness in a consumer repo
82
+ * died on `Named export 'recordCheck' not found`, and its ledger fell from 48
83
+ * records to 34 and from 47 to 33 for surfaces nothing had touched. The run is
84
+ * still red: `cli-main.ts` fails on any skip the author did not declare.
85
+ *
86
+ * This used to be decided by grepping the child's OUTPUT for loader phrases,
87
+ * and that was wrong in the dangerous direction (#243): a harness that printed
88
+ * a hook transcript containing "Cannot find module", then failed an assertion,
89
+ * was read as did-not-load and kept its coverage. Output is no longer an input.
90
+ * Anything without a marker (CommonJS, an entry the hook never saw) can never
91
+ * claim did-not-load — it is a `fail`, the side that retracts rather than hides.
92
+ * A syntax error in the script itself also never links, so it too is
93
+ * did-not-load; that is the owner's call, not an accident.
94
+ */
95
+ export declare function statusFor(code: number, checks: number | undefined, load?: LoadEvidence): ScriptStatus;
96
+ /**
97
+ * Did this script fail to LOAD — as opposed to declaring a skip (exit 77)?
98
+ * The one predicate behind both the CLI's "never ran" failure and `--min`.
99
+ *
100
+ * Deliberately NOT a fifth `ScriptStatus`: coverage retraction reads the status
101
+ * as a bare string (`coverage-artifact.ts`), so a new member would start
102
+ * retracting silently with no type error.
63
103
  */
64
- export declare function statusFor(code: number, checks: number | undefined, output?: string): ScriptStatus;
104
+ export declare function loadFailed(r: ScriptRunResult): boolean;
65
105
  /** Filename extensions accepted for harness/eval scripts (JS and TS). */
66
106
  export declare const SCRIPT_EXTS: readonly ["mjs", "cjs", "js", "mts", "cts", "ts"];
67
107
  /** Glob suffix matching every accepted script extension, e.g. `harness`. */
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.canRunTypeScript = exports.detectNodeCaps = exports.SCRIPT_EXTS = exports.SKIP_EXIT_CODE = void 0;
4
4
  exports.statusFor = statusFor;
5
+ exports.loadFailed = loadFailed;
5
6
  exports.scriptGlob = scriptGlob;
6
7
  exports.interpreterArgs = interpreterArgs;
7
8
  exports.discoverScripts = discoverScripts;
@@ -36,7 +37,8 @@ const check_count_js_1 = require("../../check-count.js");
36
37
  */
37
38
  exports.SKIP_EXIT_CODE = 77;
38
39
  /**
39
- * Classify one script's run from its exit code and its reported check count.
40
+ * Classify one script's run from its exit code, its reported check count and
41
+ * what the load hook saw.
40
42
  *
41
43
  * 🔴 THE FOURTH STATE, AND WHY. Exit codes answer "did it fail?", never "did it
42
44
  * do anything?". Measured 2026-08-08: a file whose whole body is
@@ -59,74 +61,47 @@ exports.SKIP_EXIT_CODE = 77;
59
61
  * none of it says zero. This is the `undefined`-vs-`[]` distinction
60
62
  * `assertNoWrite` already draws: "nobody looked" must not read as "nothing
61
63
  * happened".
64
+ *
65
+ * 🔴 DID IT LOAD? A non-zero exit from a script whose module was marked and
66
+ * never linked is did-not-load: `"skip"` with the loader's exit code, which
67
+ * keeps its coverage (`fail` RETRACTS it, see `runsFromResults`). A module that
68
+ * never linked executed no assertion — the same "did not run" as a missing
69
+ * `claude`, arriving through a different door. MEASURED, twice on 2026-08-20: a
70
+ * container restore left an old `node_modules`, every harness in a consumer repo
71
+ * died on `Named export 'recordCheck' not found`, and its ledger fell from 48
72
+ * records to 34 and from 47 to 33 for surfaces nothing had touched. The run is
73
+ * still red: `cli-main.ts` fails on any skip the author did not declare.
74
+ *
75
+ * This used to be decided by grepping the child's OUTPUT for loader phrases,
76
+ * and that was wrong in the dangerous direction (#243): a harness that printed
77
+ * a hook transcript containing "Cannot find module", then failed an assertion,
78
+ * was read as did-not-load and kept its coverage. Output is no longer an input.
79
+ * Anything without a marker (CommonJS, an entry the hook never saw) can never
80
+ * claim did-not-load — it is a `fail`, the side that retracts rather than hides.
81
+ * A syntax error in the script itself also never links, so it too is
82
+ * did-not-load; that is the owner's call, not an accident.
62
83
  */
63
- function statusFor(code, checks, output) {
84
+ function statusFor(code, checks, load) {
64
85
  if (code === exports.SKIP_EXIT_CODE)
65
86
  return "skip";
66
- // 🔴 `checks === undefined` GUARDS THE TEXT MATCH, and it is the load-bearing
67
- // half. A script that REPORTED a count executed: the counter is written by an
68
- // exit handler that exists only once the module was linked and run
69
- // (`check-count.ts`), so the count is a STRUCTURAL fact about the child, while
70
- // `didNotLoad` is a guess about its text.
71
- //
72
- // MEASURED: `statusFor(1, 3, "<hook stderr: Cannot find module …>\nAssertionError")`
73
- // returned `"skip"` and the run exited 0 — a harness that ran, recorded three
74
- // checks and FAILED an assertion, reported as skipped. That is not an exotic
75
- // input: vigiles harnesses drive hooks and print their transcripts, so a
76
- // loader phrase in the output is ordinary EVIDENCE about the thing under test,
77
- // not a diagnosis of the harness. Watching the child from outside cannot tell
78
- // those apart. The count can, and it was already in hand.
79
- if (code !== 0)
80
- return checks === undefined && didNotLoad(output) ? "skip" : "fail";
87
+ if (code !== 0) {
88
+ // A reported count is written by an exit handler that exists only once the
89
+ // module ran (`check-count.ts`), so it overrules a missing marker file.
90
+ const neverLinked = load?.marked === true && !load.linked && checks === undefined;
91
+ return neverLinked ? "skip" : "fail";
92
+ }
81
93
  return checks === 0 ? "vacuous" : "pass";
82
94
  }
83
95
  /**
84
- * Did the script fail to LOAD, rather than fail?
85
- *
86
- * 🔴 The distinction is not cosmetic, because `fail` RETRACTS coverage while
87
- * `skip` does not (see the table on `runsFromResults`). The rule for `fail` —
88
- * "it ran and proved nothing, so it must not keep yesterday's green record
89
- * alive" — is right, and it does not describe a file the runtime never
90
- * evaluated. A module that cannot resolve executed no assertion; it is the same
91
- * "did not run" as a missing `claude` CLI, arriving through a different door.
92
- *
93
- * MEASURED, twice in one session on 2026-08-20: a container restore left an old
94
- * `node_modules` behind, every harness in the consumer repo died on `Named export
95
- * 'recordCheck' not found`, and the ledger dropped from 48 records to 34 and from
96
- * 47 to 33. Nothing about those surfaces had changed — the machine had.
97
- *
98
- * ⚠️ This does NOT make a broken environment quiet: a script classified here
99
- * fails the run by default (`cli-main.ts`, right after `anyFailed`), because a
100
- * skip the AUTHOR never declared is not a skip. What this classification buys is
101
- * only that a machine problem may not delete a measurement taken on a machine
102
- * that worked.
96
+ * Did this script fail to LOAD — as opposed to declaring a skip (exit 77)?
97
+ * The one predicate behind both the CLI's "never ran" failure and `--min`.
103
98
  *
104
- * 🔴 That default is new, and the sentence it replaces was false. It read
105
- * «`--no-skip` — which this repo's own CI passes — still fails the run».
106
- * Measured: `--no-skip` appears ZERO times under `.github/`, `package.json`,
107
- * `scripts/` and `.claude/`; CI passes `--min=14` and nothing else. The
108
- * safety net the non-fatal classification leaned on was never strung.
109
- *
110
- * Deliberately literal, and only the loader's own vocabulary: these strings come
111
- * from Node's module resolution, not from user code. A test that legitimately
112
- * asserts on one of them exits 0 or asserts, and never reaches here.
99
+ * Deliberately NOT a fifth `ScriptStatus`: coverage retraction reads the status
100
+ * as a bare string (`coverage-artifact.ts`), so a new member would start
101
+ * retracting silently with no type error.
113
102
  */
114
- function didNotLoad(output) {
115
- if (output === undefined || output === "")
116
- return false;
117
- return (output.includes("ERR_MODULE_NOT_FOUND") ||
118
- output.includes("Cannot find package") ||
119
- output.includes("Cannot find module") ||
120
- /SyntaxError: Named export '[^']*' not found/.test(output) ||
121
- // Same event, ESM spelling. Node phrases a missing named export one way for
122
- // a CommonJS target and another for an ES module, and only the first was
123
- // listed — so the 2026-08-20 class below still RETRACTED coverage whenever
124
- // the dependency happened to be ESM. Measured on Node 22:
125
- // CJS: SyntaxError: Named export 'recordCheck' not found. The requested module …
126
- // ESM: SyntaxError: The requested module './x.mjs' does not provide an export named 'recordCheck'
127
- /SyntaxError: The requested module '[^']*' does not provide an export named/.test(output) ||
128
- output.includes("ERR_UNSUPPORTED_DIR_IMPORT") ||
129
- output.includes("ERR_PACKAGE_PATH_NOT_EXPORTED"));
103
+ function loadFailed(r) {
104
+ return r.status === "skip" && r.code !== exports.SKIP_EXIT_CODE;
130
105
  }
131
106
  /** Filename extensions accepted for harness/eval scripts (JS and TS). */
132
107
  exports.SCRIPT_EXTS = ["mjs", "cjs", "js", "mts", "cts", "ts"];
@@ -240,6 +215,23 @@ function readCheckReport(path) {
240
215
  return undefined;
241
216
  }
242
217
  }
218
+ /**
219
+ * What the load hook left behind for one child. A missing or malformed
220
+ * `seenFile` means the hook never saw the script, so `marked` is false and the
221
+ * run can never be read as did-not-load.
222
+ */
223
+ function readLoadEvidence(seenFile, loadedFile) {
224
+ return { marked: readMarked(seenFile), linked: (0, node_fs_1.existsSync)(loadedFile) };
225
+ }
226
+ function readMarked(seenFile) {
227
+ try {
228
+ const seen = JSON.parse((0, node_fs_1.readFileSync)(seenFile, "utf8"));
229
+ return seen.marked === true;
230
+ }
231
+ catch {
232
+ return false;
233
+ }
234
+ }
243
235
  /**
244
236
  * Run each script as `node <file>` (or `node <entry> <file>`, see
245
237
  * {@link RunScriptsOptions}), inheriting stdio so the script's own report
@@ -293,6 +285,8 @@ async function runScripts(files, cwd, env = {}, opts = {}) {
293
285
  return;
294
286
  }
295
287
  const countFile = (0, node_path_1.join)(countDir, `${String(i)}.count`);
288
+ const seenFile = (0, node_path_1.join)(countDir, `${String(i)}.seen`);
289
+ const loadedFile = (0, node_path_1.join)(countDir, `${String(i)}.loaded`);
296
290
  // Resolve a harness's bare `vigiles` import from the CLI's OWN install, so
297
291
  // running the gate does not require installing the package into the
298
292
  // project — which, in a repo that already has a package.json, drags in the
@@ -301,7 +295,21 @@ async function runScripts(files, cwd, env = {}, opts = {}) {
301
295
  // fails, so a locally installed copy still wins.
302
296
  const selfRoot = (0, node_path_1.resolve)(__dirname, "..", "..", "..");
303
297
  const hook = (0, node_url_1.pathToFileURL)((0, node_path_1.join)(selfRoot, "dist", "harness-resolve-hooks.mjs")).href;
304
- const child = (0, node_child_process_1.spawn)("node", ["--import", hookImport(hook), ...argv], {
298
+ // 🔴 OUR HOOK IS REGISTERED BEFORE `--import tsx`, and the order is
299
+ // load-bearing. Hooks chain last-in-first-out, so this makes tsx the
300
+ // OUTER hook: it transpiles the source we already marked, and its source
301
+ // map describes that source. The other order was measured wrong: tsx
302
+ // emits each module on ONE line with an inline map, a prefix added after
303
+ // it shifts every generated column, and a stack frame on line 2 was
304
+ // reported on line 3 (Node 20.20 and 22.22). The format we see from the
305
+ // inner position is the same (`module` in a `type: module` scope,
306
+ // `commonjs` otherwise — also measured).
307
+ const probe = {
308
+ entryURL: scriptURL(cwd, file),
309
+ loadedFile,
310
+ seenFile,
311
+ };
312
+ const child = (0, node_child_process_1.spawn)("node", ["--import", hookImport(hook, probe), ...argv], {
305
313
  cwd,
306
314
  stdio: ["ignore", "pipe", "pipe"],
307
315
  env: {
@@ -321,7 +329,7 @@ async function runScripts(files, cwd, env = {}, opts = {}) {
321
329
  resolveRun({
322
330
  file,
323
331
  code,
324
- status: statusFor(code, report?.checks, output),
332
+ status: statusFor(code, report?.checks, readLoadEvidence(seenFile, loadedFile)),
325
333
  checks: report?.checks,
326
334
  ...(report ? { surfaces: report.surfaces } : {}),
327
335
  });
@@ -436,13 +444,30 @@ function formatScriptSummary(results) {
436
444
  return lines.join("\n");
437
445
  }
438
446
  /**
439
- * A `--import` argument that registers the resolver hook without a temp file:
447
+ * A `--import` argument that registers the harness hooks without a temp file:
440
448
  * a data: URL calling `module.register`. Inline because writing a shim into the
441
449
  * user's tree to run their tests would be a side effect the runner has no
442
- * business having.
450
+ * business having. The per-child probe rides in `data`, not the environment,
451
+ * so a process the script spawns does not inherit it.
443
452
  */
444
- function hookImport(hookHref) {
445
- const src = `import {register} from "node:module";register(${JSON.stringify(hookHref)});`;
453
+ function hookImport(hookHref, probe) {
454
+ const src = `import {register} from "node:module";register(${JSON.stringify(hookHref)},{data:${JSON.stringify(probe)}});`;
446
455
  return `data:text/javascript,${encodeURIComponent(src)}`;
447
456
  }
457
+ /**
458
+ * The URL Node will load the SCRIPT under — for `eval` too, where the process
459
+ * entry is `eval-entry.js` and the script is what it imports. ESM resolution
460
+ * realpaths file URLs, so a symlinked path must be realpathed here or the hook
461
+ * would never recognise it (which is safe — no marker, no did-not-load claim —
462
+ * but blind).
463
+ */
464
+ function scriptURL(cwd, file) {
465
+ const abs = (0, node_path_1.resolve)(cwd, file);
466
+ try {
467
+ return (0, node_url_1.pathToFileURL)((0, node_fs_1.realpathSync)(abs)).href;
468
+ }
469
+ catch {
470
+ return (0, node_url_1.pathToFileURL)(abs).href;
471
+ }
472
+ }
448
473
  //# sourceMappingURL=run-scripts.js.map
@@ -38,6 +38,7 @@ const node_fs_1 = require("node:fs");
38
38
  const node_path_1 = require("node:path");
39
39
  const effects_js_1 = require("../../core/effects.js");
40
40
  const dialect_js_1 = require("./dialect.js");
41
+ const local_files_js_1 = require("../../local-files.js");
41
42
  const STEP_RE = /^###\s+Step\s+(\d+)/;
42
43
  const GATE_CMD_RE = /<!--\s*vigiles:gate\s+"([^"]*)"(?:\s+retry:(\d+))?\s*-->/;
43
44
  const GATE_FILE_RE = /<!--\s*vigiles:gate\s+file:(\S+)\s*-->/;
@@ -235,11 +236,12 @@ function runSkillGates(gates, cwd) {
235
236
  // (Claude Code hooks don't surface the active skill, so we record it). Wiring
236
237
  // `skill-start` to fire automatically is the integration step; the decision
237
238
  // logic below is harness-agnostic and fully testable.
238
- const ACTIVE_PATH = ".vigiles/active-skill.json";
239
+ const ACTIVE_PATH = `${local_files_js_1.VIGILES_DIR}/${local_files_js_1.ACTIVE_SKILL_FILE}`;
239
240
  /** Record the skill the agent is currently executing. */
240
241
  function setActiveSkill(cwd, skillPath) {
241
242
  const p = (0, node_path_1.resolve)(cwd, ACTIVE_PATH);
242
243
  (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(p), { recursive: true });
244
+ (0, local_files_js_1.ensureLocalFilesIgnored)((0, node_path_1.dirname)(p));
243
245
  (0, node_fs_1.writeFileSync)(p, JSON.stringify({ skill: skillPath }) + "\n");
244
246
  }
245
247
  /** Clear the active-skill marker (the skill finished). */
package/dist/cli-main.js CHANGED
@@ -38,6 +38,8 @@ const surface_discovery_js_1 = require("./core/surface-discovery.js");
38
38
  const scan_trigger_suggest_js_1 = require("./scan-trigger-suggest.js");
39
39
  const install_reader_js_1 = require("./core/install-reader.js");
40
40
  const plugin_declaration_js_1 = require("./plugin-declaration.js");
41
+ const local_files_tracked_js_1 = require("./local-files-tracked.js");
42
+ const local_files_js_1 = require("./local-files.js");
41
43
  const scan_behavioral_js_1 = require("./scan-behavioral.js");
42
44
  const adapter_registry_js_1 = require("./adapter-registry.js");
43
45
  const skill_harness_js_1 = require("./skill-harness.js");
@@ -4914,6 +4916,21 @@ function gitHead(cwd) {
4914
4916
  }
4915
4917
  async function handleRunScripts(kind, args, restArgs, excludes) {
4916
4918
  const cwd = process.cwd();
4919
+ // The ignore file keeps NEW copies of `.vigiles/` local files out of git, but
4920
+ // cannot untrack one a repo already committed. Said here, on the CLI, because
4921
+ // it spawns `git` — never from a hook runtime — and said FIRST, before any
4922
+ // early return: a repo whose harness was removed still carries the stale
4923
+ // committed artifact, and "no files found" must not swallow the warning
4924
+ // (Codex review on #274).
4925
+ //
4926
+ // The ignore file is brought up to date FIRST, and only where `.vigiles/`
4927
+ // already exists: the writers below would otherwise make their edit after the
4928
+ // check, so a tracked ignore file dirtied by this very run went unreported on
4929
+ // the one run a user may ever do (Codex review on #275).
4930
+ const vigilesDir = (0, node_path_1.join)(cwd, local_files_js_1.VIGILES_DIR);
4931
+ if ((0, node_fs_1.existsSync)(vigilesDir))
4932
+ (0, local_files_js_1.ensureLocalFilesIgnored)(vigilesDir);
4933
+ (0, local_files_tracked_js_1.warnTrackedLocalFiles)(cwd);
4917
4934
  // Harness/eval scripts may be authored in JS or TS (see run-scripts.ts).
4918
4935
  const defaultGlob = (0, run_scripts_js_1.scriptGlob)(kind === "test" ? "harness" : "eval");
4919
4936
  // The eval LOCK flags (`--check`/`--update`) are resolved BEFORE file discovery
@@ -4929,9 +4946,13 @@ async function handleRunScripts(kind, args, restArgs, excludes) {
4929
4946
  // A script under an excluded path is not DISCOVERED (a vendored corpus's own
4930
4947
  // harness must not run as ours), but a script you NAME still runs (#192).
4931
4948
  const files = (0, run_scripts_js_1.discoverScripts)(restArgs.map((p) => noteExplicitOverride(excludes, p, "running")), defaultGlob, cwd, excludes.ignore);
4932
- // `--min=N`: a CI gate asserts at least N scripts actually RAN — so a bad path,
4933
- // a renamed file, or a glob that matched nothing fails LOUD instead of passing
4934
- // green with zero evals executed. Default 0 (off) keeps local runs ergonomic.
4949
+ // `--min=N`: a CI gate asserts at least N scripts actually LOADED — so a bad
4950
+ // path, a renamed file, a glob that matched nothing, or a file that matched and
4951
+ // never linked fails LOUD instead of passing green with nothing executed.
4952
+ // Checked twice: here against files MATCHED (cheap, before anything runs), and
4953
+ // after the run against scripts that LOADED — a matched file that could not be
4954
+ // evaluated is not a script that ran (#243). A declared skip (exit 77) did
4955
+ // load, so it counts. Default 0 (off) keeps local runs ergonomic.
4935
4956
  const minFlag = args.find((a) => a.startsWith("--min="));
4936
4957
  const minRequired = minFlag
4937
4958
  ? Math.max(0, Number.parseInt(minFlag.split("=")[1] ?? "", 10) || 0)
@@ -5035,6 +5056,12 @@ async function handleRunScripts(kind, args, restArgs, excludes) {
5035
5056
  // saw — the same shape as the flight-recorder ledger it already appends to.
5036
5057
  recordRunCoverage(cwd, results, kind, harnessFlagFrom(args));
5037
5058
  console.log("\n" + (0, run_scripts_js_1.formatScriptSummary)(results));
5059
+ const loadedCount = results.filter((r) => !(0, run_scripts_js_1.loadFailed)(r)).length;
5060
+ const belowFloor = loadedCount < minRequired;
5061
+ if (belowFloor) {
5062
+ console.error(`\n✗ vigiles ${kind}: --min=${String(minRequired)} but only ${String(loadedCount)} of ` +
5063
+ `${String(files.length)} matched ${kind} file(s) loaded.`);
5064
+ }
5038
5065
  if ((0, run_scripts_js_1.anyFailed)(results))
5039
5066
  process.exit(1);
5040
5067
  // 🔴 A SKIP THE AUTHOR NEVER DECLARED IS NOT A SKIP — and the discriminator was
@@ -5042,20 +5069,23 @@ async function handleRunScripts(kind, args, restArgs, excludes) {
5042
5069
  // the runtime could not evaluate exits with whatever the loader gave it, 1 in
5043
5070
  // practice. Both are classified `"skip"` so that neither RETRACTS coverage —
5044
5071
  // which is right, a file that did not run proved nothing either way — but only
5045
- // the declared one is a reason to stay green.
5072
+ // the declared one is a reason to stay green. Which is which is decided by the
5073
+ // runner's load hook (a marker import that evaluates only once the module
5074
+ // graph linked), not by reading the child's output — see `statusFor`.
5046
5075
  //
5047
5076
  // Reported as #243: `vigiles test .` printed a resolver stack over a `⊘`, said
5048
5077
  // `0 passed, 1 skipped`, and exited 0. Downstream a consumer's README shipped
5049
5078
  // that exact command as its first setup step, so a new reader's suite silently
5050
5079
  // never ran. `--no-skip` would have caught it and is not the default; `--min=1`
5051
- // does not, because it counts files MATCHED, not scripts executed.
5080
+ // did not either, because it counted files MATCHED — it now also counts the
5081
+ // scripts that loaded (above).
5052
5082
  //
5053
5083
  // This is deliberately NOT a fifth `ScriptStatus`. Coverage retraction reads the
5054
5084
  // status as a bare STRING (`executedScripts`, `coverage-artifact.ts`, whose
5055
5085
  // parameter is typed `string`), so a new member would start retracting silently
5056
5086
  // with no type error — breaking the one property the classification exists to
5057
5087
  // protect.
5058
- const notEvaluated = results.filter((r) => r.status === "skip" && r.code !== run_scripts_js_1.SKIP_EXIT_CODE);
5088
+ const notEvaluated = results.filter(run_scripts_js_1.loadFailed);
5059
5089
  if (notEvaluated.length > 0) {
5060
5090
  console.error(`\n✗ vigiles ${kind}: ${String(notEvaluated.length)} script(s) never ran — the runtime could not load them:\n` +
5061
5091
  notEvaluated
@@ -5066,6 +5096,8 @@ async function handleRunScripts(kind, args, restArgs, excludes) {
5066
5096
  `a declared skip stays green.`);
5067
5097
  process.exit(1);
5068
5098
  }
5099
+ if (belowFloor)
5100
+ process.exit(1);
5069
5101
  // `--no-skip`: in a context that ASSERTS the capability is present (a CI job),
5070
5102
  // a skipped tier is untested surface — fail loudly instead of passing green.
5071
5103
  if (args.includes("--no-skip") && results.some((r) => r.status === "skip")) {
@@ -41,6 +41,7 @@ exports.runGuardHook = runGuardHook;
41
41
  const node_fs_1 = require("node:fs");
42
42
  const node_path_1 = require("node:path");
43
43
  const arg_match_js_1 = require("../arg-match.js");
44
+ const local_files_js_1 = require("../local-files.js");
44
45
  /** Ergonomic builders for the closed vocabulary. */
45
46
  exports.guard = {
46
47
  block: (target, reason) => ({
@@ -237,7 +238,7 @@ function parseGuards(json) {
237
238
  // Runtime IO — load the guard set, read/append the session ledger
238
239
  // ---------------------------------------------------------------------------
239
240
  const GUARDS_FILE = ".vigiles/guards.json";
240
- const LEDGER_FILE = ".vigiles/guard-ledger.json";
241
+ const LEDGER_FILE = `${local_files_js_1.VIGILES_DIR}/${local_files_js_1.GUARD_LEDGER_FILE}`;
241
242
  /** Load the declared guard set from `.vigiles/guards.json` (absent → none). */
242
243
  function loadGuards(cwd) {
243
244
  const p = (0, node_path_1.resolve)(cwd, GUARDS_FILE);
@@ -273,6 +274,7 @@ function recordGuardCall(cwd, event) {
273
274
  const calls = readGuardLedger(cwd);
274
275
  calls.push({ tool: event.tool, input: event.input });
275
276
  (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(p), { recursive: true });
277
+ (0, local_files_js_1.ensureLocalFilesIgnored)((0, node_path_1.dirname)(p));
276
278
  (0, node_fs_1.writeFileSync)(p, JSON.stringify({ calls }, null, 2));
277
279
  }
278
280
  /** Parse a PreToolUse event JSON (the hook's stdin) into a {@link ToolEvent}. */
@@ -1,9 +1,10 @@
1
1
  import type { ProbeOrigin, SurfaceProbe } from "./check-count.js";
2
+ import { COVERAGE_ARTIFACT_FILE } from "./local-files.js";
2
3
  import type { Surface, SurfaceKind } from "./test-coverage.js";
3
4
  /** Bumped when the record shape changes in a non-additive way. */
4
5
  export declare const COVERAGE_ARTIFACT_VERSION = 1;
5
- /** The artifact filename under `.vigiles/`. */
6
- export declare const COVERAGE_ARTIFACT_FILE = "coverage.json";
6
+ /** The artifact filename under `.vigiles/` — defined on the one local-files list. */
7
+ export { COVERAGE_ARTIFACT_FILE };
7
8
  /**
8
9
  * Which runner produced a record. Mirrors the discovery split in
9
10
  * `test-coverage.ts`: `vigiles test` runs `*.harness.*` (free, every push),
@@ -58,10 +58,10 @@ const node_crypto_1 = require("node:crypto");
58
58
  const node_fs_1 = require("node:fs");
59
59
  const node_path_1 = require("node:path");
60
60
  const coverage_evidence_js_1 = require("./coverage-evidence.js");
61
+ const local_files_js_1 = require("./local-files.js");
62
+ Object.defineProperty(exports, "COVERAGE_ARTIFACT_FILE", { enumerable: true, get: function () { return local_files_js_1.COVERAGE_ARTIFACT_FILE; } });
61
63
  /** Bumped when the record shape changes in a non-additive way. */
62
64
  exports.COVERAGE_ARTIFACT_VERSION = 1;
63
- /** The artifact filename under `.vigiles/`. */
64
- exports.COVERAGE_ARTIFACT_FILE = "coverage.json";
65
65
  /**
66
66
  * Every {@link RunAttribution}, as data — the artifact's validator reads it, so
67
67
  * the accepted set and the type cannot drift apart into a record shape the
@@ -576,7 +576,7 @@ function mergeRuns(previous, next, executed) {
576
576
  }
577
577
  /** Read the artifact, or `undefined` when there is none / it is not one. */
578
578
  function readCoverageArtifact(root) {
579
- const file = (0, node_path_1.resolve)(root, ".vigiles", exports.COVERAGE_ARTIFACT_FILE);
579
+ const file = (0, node_path_1.resolve)(root, local_files_js_1.VIGILES_DIR, local_files_js_1.COVERAGE_ARTIFACT_FILE);
580
580
  if (!(0, node_fs_1.existsSync)(file))
581
581
  return undefined;
582
582
  let value;
@@ -618,9 +618,10 @@ function isCoverageRun(value) {
618
618
  /** Write the artifact. Best-effort: recording must never fail a green run. */
619
619
  function writeCoverageArtifact(root, artifact) {
620
620
  try {
621
- const dir = (0, node_path_1.resolve)(root, ".vigiles");
621
+ const dir = (0, node_path_1.resolve)(root, local_files_js_1.VIGILES_DIR);
622
622
  (0, node_fs_1.mkdirSync)(dir, { recursive: true });
623
- (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(dir, exports.COVERAGE_ARTIFACT_FILE), JSON.stringify(artifact, null, 2) + "\n");
623
+ (0, local_files_js_1.ensureLocalFilesIgnored)(dir);
624
+ (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(dir, local_files_js_1.COVERAGE_ARTIFACT_FILE), JSON.stringify(artifact, null, 2) + "\n");
624
625
  }
625
626
  catch {
626
627
  /* best-effort — a scratch-dir failure is not a test failure */
@@ -74,7 +74,12 @@ export declare function cacheKey(input: CacheKeyInput): SHA256Hash;
74
74
  * gate must surface, not mask. The message tells you how to recover.
75
75
  */
76
76
  export declare function readCache(dir: string, key: SHA256Hash): CacheRecord | null;
77
- /** Write a cached record by key (creating the cache dir as needed). */
77
+ /**
78
+ * Write a cached record by key (creating the cache dir as needed). When `dir` is
79
+ * the default `.vigiles/eval-cache`, the recorded runs are per-checkout, so the
80
+ * `.vigiles/.gitignore` is kept current; a `cacheDir` the spec chose elsewhere is
81
+ * the author's to place and is left alone.
82
+ */
78
83
  export declare function writeCache(dir: string, key: SHA256Hash, record: CacheRecord): void;
79
84
  /** Snapshot the text files under `cwd` as `relativePath → contents` (bounded). */
80
85
  export declare function snapshotDir(cwd: string): Record<string, string>;
@@ -28,6 +28,7 @@ exports.hashDir = hashDir;
28
28
  const node_fs_1 = require("node:fs");
29
29
  const node_path_1 = require("node:path");
30
30
  const hash_js_1 = require("./core/hash.js");
31
+ const local_files_js_1 = require("./local-files.js");
31
32
  const MAX_SNAPSHOT_FILE_BYTES = 1024 * 1024;
32
33
  const SKIP_DIRS = new Set(["node_modules", ".git"]);
33
34
  /**
@@ -113,9 +114,18 @@ function readCache(dir, key) {
113
114
  throw new Error(`eval cache: corrupt record ${path} (invalid JSON) — delete it or clear the cache dir`);
114
115
  }
115
116
  }
116
- /** Write a cached record by key (creating the cache dir as needed). */
117
+ /**
118
+ * Write a cached record by key (creating the cache dir as needed). When `dir` is
119
+ * the default `.vigiles/eval-cache`, the recorded runs are per-checkout, so the
120
+ * `.vigiles/.gitignore` is kept current; a `cacheDir` the spec chose elsewhere is
121
+ * the author's to place and is left alone.
122
+ */
117
123
  function writeCache(dir, key, record) {
118
124
  (0, node_fs_1.mkdirSync)(dir, { recursive: true });
125
+ if ((0, node_path_1.basename)(dir) === local_files_js_1.EVAL_CACHE_DIR &&
126
+ (0, node_path_1.basename)((0, node_path_1.dirname)(dir)) === local_files_js_1.VIGILES_DIR) {
127
+ (0, local_files_js_1.ensureLocalFilesIgnored)((0, node_path_1.dirname)(dir));
128
+ }
119
129
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(dir, `${key}.json`), JSON.stringify(record));
120
130
  }
121
131
  /** Snapshot the text files under `cwd` as `relativePath → contents` (bounded). */
package/dist/eval.js CHANGED
@@ -79,6 +79,7 @@ const proofs_js_1 = require("./core/proofs.js");
79
79
  const model_access_js_1 = require("./adapters/claude-code/model-access.js");
80
80
  const harness_test_js_1 = require("./harness-test.js");
81
81
  const eval_cache_js_1 = require("./eval-cache.js");
82
+ const local_files_js_1 = require("./local-files.js");
82
83
  const eval_lock_js_1 = require("./eval-lock.js");
83
84
  const stats_js_1 = require("./stats.js");
84
85
  const check_count_js_1 = require("./check-count.js");
@@ -1331,7 +1332,7 @@ async function runEvalWith(spec, runner) {
1331
1332
  tools: spec.allowedTools ?? ["Read", "Edit", "Write", "Bash"],
1332
1333
  timeoutMs: spec.timeoutMs ?? 240000,
1333
1334
  cache: spec.cache ?? "off",
1334
- cacheDir: spec.cacheDir ?? (0, node_path_1.resolve)(process.cwd(), ".vigiles", "eval-cache"),
1335
+ cacheDir: spec.cacheDir ?? (0, node_path_1.resolve)(process.cwd(), local_files_js_1.VIGILES_DIR, local_files_js_1.EVAL_CACHE_DIR),
1335
1336
  };
1336
1337
  if (cfg.cache !== "off" && !isDatedModel(cfg.model)) {
1337
1338
  warnFloatingModel(cfg.model);
@@ -8,6 +8,27 @@ type Resolved = {
8
8
  shortCircuit?: boolean;
9
9
  };
10
10
  type NextResolve = (specifier: string, context: ResolveContext) => Resolved | Promise<Resolved>;
11
+ type LoadContext = {
12
+ format?: string | null;
13
+ conditions: string[];
14
+ };
15
+ type Loaded = {
16
+ format?: string | null;
17
+ source?: string | ArrayBuffer | ArrayBufferView | null;
18
+ shortCircuit?: boolean;
19
+ };
20
+ type NextLoad = (url: string, context: LoadContext) => Loaded | Promise<Loaded>;
21
+ /** What the runner hands this hook for ONE child. See `run-scripts.ts`. */
22
+ export interface LoadProbe {
23
+ /** The script's own module URL (realpath), whatever process entry runs it. */
24
+ readonly entryURL: string;
25
+ /** Written (empty) by the marker module when it evaluates: the graph linked. */
26
+ readonly loadedFile: string;
27
+ /** Written with `{ format, marked }` when this hook sees the script's module. */
28
+ readonly seenFile: string;
29
+ }
30
+ export declare function initialize(data: LoadProbe | undefined): void;
11
31
  export declare function resolve(specifier: string, context: ResolveContext, nextResolve: NextResolve): Promise<Resolved>;
32
+ export declare function load(url: string, context: LoadContext, nextLoad: NextLoad): Promise<Loaded>;
12
33
  export {};
13
34
  //# sourceMappingURL=harness-resolve-hooks.d.mts.map
@@ -1,17 +1,47 @@
1
1
  /**
2
- * Module-resolution hook for HARNESS scripts (`vigiles test` / `vigiles eval`):
3
- * make a bare `vigiles` import resolve to the CLI's OWN installation.
2
+ * Module-customization hooks for HARNESS scripts (`vigiles test` / `vigiles eval`).
3
+ * Two jobs, one registration:
4
4
  *
5
- * The rescue itself — why it exists, what it refuses to touch — lives in
6
- * `./self-resolve.mjs`, because the spec host registers the same branch from
7
- * `./spec-hooks.mjs` and two copies of it is exactly the divergence that put
8
- * `test` and `compile` on different answers to the same question.
5
+ * 1. RESOLVE — make a bare `vigiles` import resolve to the CLI's OWN
6
+ * installation. The rescue itself — why it exists, what it refuses to touch —
7
+ * lives in `./self-resolve.mjs`, because the spec host registers the same
8
+ * branch from `./spec-hooks.mjs` and two copies of it is exactly the
9
+ * divergence that put `test` and `compile` on different answers to the same
10
+ * question. What stays here is the protocol: try normal resolution first, and
11
+ * only consider the rescue on the way out of the failure.
9
12
  *
10
- * What stays here is the hook PROTOCOL: try normal resolution first, and only
11
- * consider the rescue on the way out of the failure.
13
+ * 2. LOAD — tell the runner whether the script LOADED (#243). ESM links the
14
+ * whole import graph before evaluating any of it, and evaluates a module's
15
+ * imports in source order. So a marker import placed FIRST in the script's
16
+ * module evaluates if and only if every module in the graph was found, parsed
17
+ * and linked — and before any dependency or the script body runs. The marker
18
+ * writes a file; the runner reads it. No output text is read, so a harness
19
+ * that prints a loader error as EVIDENCE about the thing it tests (a hook
20
+ * transcript, say) can no longer be mistaken for one that never ran.
21
+ *
22
+ * The probe paths arrive through `register(…, { data })` → `initialize`, NOT the
23
+ * environment, so a process the harness spawns cannot inherit them and write
24
+ * over its parent's answer.
12
25
  */
26
+ import { writeFileSync } from "node:fs";
13
27
  import { resolveSelfSpecifier } from "./self-resolve.mjs";
28
+ const MARKER_URL = "vigiles-internal:loaded";
29
+ /**
30
+ * Formats the marker can be planted in. 🔴 CommonJS is deliberately absent: it
31
+ * has no link phase to mark (a `require` resolves while the body runs), so a CJS
32
+ * script carries no marker and a non-zero exit from it is conservatively a
33
+ * `fail` — which retracts coverage rather than hiding a real failure.
34
+ * `module-typescript` is Node's own type stripping (22.6+), which strips in
35
+ * place and so keeps positions.
36
+ */
37
+ const MARKABLE = new Set(["module", "module-typescript"]);
38
+ let probe;
39
+ export function initialize(data) {
40
+ probe = data;
41
+ }
14
42
  export async function resolve(specifier, context, nextResolve) {
43
+ if (probe !== undefined && specifier === MARKER_URL)
44
+ return { url: MARKER_URL, shortCircuit: true };
15
45
  try {
16
46
  return await nextResolve(specifier, context);
17
47
  }
@@ -24,4 +54,42 @@ export async function resolve(specifier, context, nextResolve) {
24
54
  return rescued;
25
55
  }
26
56
  }
57
+ export async function load(url, context, nextLoad) {
58
+ if (probe !== undefined && url === MARKER_URL)
59
+ return {
60
+ format: "module",
61
+ shortCircuit: true,
62
+ source: `import { writeFileSync } from "node:fs"; writeFileSync(${JSON.stringify(probe.loadedFile)}, "");`,
63
+ };
64
+ const loaded = await nextLoad(url, context);
65
+ if (probe === undefined || url !== probe.entryURL)
66
+ return loaded;
67
+ const marked = MARKABLE.has(String(loaded.format));
68
+ // Written BEFORE the graph links, so its presence proves the hook saw the
69
+ // script. Without it a missing `loadedFile` would mean nothing: a script this
70
+ // hook never saw (a loader that rewrote its URL, say) is never did-not-load.
71
+ writeFileSync(probe.seenFile, JSON.stringify({ format: loaded.format, marked }));
72
+ if (!marked)
73
+ return loaded;
74
+ return { ...loaded, source: withMarker(sourceText(loaded.source)) };
75
+ }
76
+ function sourceText(source) {
77
+ if (typeof source === "string")
78
+ return source;
79
+ // `String(uint8array)` gives "1,2,3", so decode explicitly.
80
+ return new TextDecoder().decode(source ?? new Uint8Array());
81
+ }
82
+ /**
83
+ * Put the marker import on the file's FIRST line — after a shebang, which must
84
+ * stay first — so no line number moves; only columns on that one line do.
85
+ */
86
+ function withMarker(src) {
87
+ const mark = `import ${JSON.stringify(MARKER_URL)};`;
88
+ if (!src.startsWith("#!"))
89
+ return mark + src;
90
+ const nl = src.indexOf("\n");
91
+ if (nl === -1)
92
+ return `${src}\n${mark}`;
93
+ return src.slice(0, nl + 1) + mark + src.slice(nl + 1);
94
+ }
27
95
  //# sourceMappingURL=harness-resolve-hooks.mjs.map
@@ -50,6 +50,7 @@ const hook_install_js_1 = require("./hook-install.js");
50
50
  const hook_providers_js_1 = require("./core/hook-providers.js");
51
51
  const hook_state_store_js_1 = require("./hook-state-store.js");
52
52
  const observe_js_1 = require("./observe.js");
53
+ const local_files_js_1 = require("./local-files.js");
53
54
  const load_hook_js_1 = require("./load-hook.js");
54
55
  const event_capability_js_1 = require("./core/event-capability.js");
55
56
  /**
@@ -168,8 +169,9 @@ async function loadProviderRegistry(root) {
168
169
  /** Append an observe-mode record to `.vigiles/hook-observations.jsonl` (best-effort). */
169
170
  function recordObservation(file, on, would, reason, root) {
170
171
  try {
171
- const dir = (0, node_path_1.resolve)(root, ".vigiles");
172
+ const dir = (0, node_path_1.resolve)(root, local_files_js_1.VIGILES_DIR);
172
173
  (0, node_fs_1.mkdirSync)(dir, { recursive: true });
174
+ (0, local_files_js_1.ensureLocalFilesIgnored)(dir);
173
175
  const line = JSON.stringify({
174
176
  ts: new Date().toISOString(),
175
177
  hook: file,
@@ -177,7 +179,7 @@ function recordObservation(file, on, would, reason, root) {
177
179
  would,
178
180
  reason,
179
181
  }) + "\n";
180
- (0, node_fs_1.appendFileSync)((0, node_path_1.resolve)(dir, "hook-observations.jsonl"), line);
182
+ (0, node_fs_1.appendFileSync)((0, node_path_1.resolve)(dir, local_files_js_1.HOOK_OBSERVATIONS_FILE), line);
181
183
  }
182
184
  catch {
183
185
  /* recording is best-effort — never let it break a live session */
@@ -57,6 +57,7 @@ const node_path_1 = require("node:path");
57
57
  const hash_js_1 = require("./core/hash.js");
58
58
  const hook_state_js_1 = require("./core/hook-state.js");
59
59
  const hook_install_js_1 = require("./hook-install.js");
60
+ const local_files_js_1 = require("./local-files.js");
60
61
  /**
61
62
  * The directory a hook's recorded facts live in — the SCOPE of `state()`/`record()`.
62
63
  *
@@ -78,7 +79,7 @@ function hookStateDir(file, cwd = process.cwd()) {
78
79
  const dir = (0, node_path_1.dirname)((0, node_path_1.resolve)(cwd, file));
79
80
  const rel = (0, node_path_1.relative)(cwd, dir);
80
81
  const inside = rel !== "" && !rel.startsWith("..") && !(0, node_path_1.isAbsolute)(rel);
81
- return (0, node_path_1.resolve)(cwd, ".vigiles/state", inside ? rel : `external-${(0, hash_js_1.sha256short)(dir)}`);
82
+ return (0, node_path_1.resolve)(cwd, local_files_js_1.VIGILES_DIR, local_files_js_1.HOOK_STATE_DIR, inside ? rel : `external-${(0, hash_js_1.sha256short)(dir)}`);
82
83
  }
83
84
  /** Read one recorded fact for a hook, or `null` if it was never recorded. */
84
85
  function readHookState(file, key, cwd = process.cwd()) {
@@ -112,6 +113,7 @@ function writeHookState(file, w, opts = {}) {
112
113
  by: (0, hook_install_js_1.normalizeHookRef)(file, cwd),
113
114
  };
114
115
  (0, node_fs_1.mkdirSync)(dir, { recursive: true });
116
+ (0, local_files_js_1.ensureLocalFilesIgnored)((0, node_path_1.resolve)(cwd, local_files_js_1.VIGILES_DIR));
115
117
  const tmp = `${target}.${String(process.pid)}.tmp`;
116
118
  (0, node_fs_1.writeFileSync)(tmp, JSON.stringify(entry, null, 2) + "\n");
117
119
  (0, node_fs_1.renameSync)(tmp, target);
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The per-checkout files under `<root>/.vigiles/` that git tracks, as
3
+ * repo-relative paths from `root`. Empty when nothing is tracked, when `root` is
4
+ * not in a git work tree, or when `git` is missing — silence is the answer to
5
+ * every "cannot tell".
6
+ */
7
+ export declare function trackedLocalFiles(root: string): string[];
8
+ /**
9
+ * Does the COMMITTED `.vigiles/.gitignore` (the one at `HEAD`) lack entries
10
+ * vigiles needs — i.e. will vigiles' additions show up as a change to a tracked
11
+ * file until someone commits them.
12
+ *
13
+ * The question is asked of `HEAD`, not of the worktree's dirtiness, and that
14
+ * is the point (Codex review on #275, third pass). "The file is modified" was
15
+ * the earlier predicate; it could not tell vigiles' additions from the owner's
16
+ * own unrelated edit, and then advised committing work in progress. Reading the
17
+ * committed copy answers only for vigiles' entries, staged or not, and says
18
+ * nothing once they are committed, whatever else the owner is editing.
19
+ * `HEAD:./path`, not `HEAD:path`: without `./` git resolves the path from the
20
+ * REPOSITORY root, so a package nested in a larger worktree read the wrong file
21
+ * — or none — and stayed silent (Codex review on #275, gitrevisions(7)).
22
+ * Silent on every "cannot tell": not in a repo, no `HEAD`, file not committed.
23
+ */
24
+ export declare function committedIgnoreFileLacksEntries(root: string): boolean;
25
+ /**
26
+ * The one-line warning for {@link trackedLocalFiles}, or `null` when there is
27
+ * nothing to say. Files inside a tracked local DIRECTORY (`state/`,
28
+ * `eval-cache/`) are named by that directory, so one line stays one line and the
29
+ * command it prints untracks all of them.
30
+ */
31
+ export declare function formatTrackedLocalFiles(tracked: readonly string[], committedLacksEntries?: boolean): string | null;
32
+ /** Print the warning for `root` to stderr when there is one. CLI-only. */
33
+ export declare function warnTrackedLocalFiles(root: string): void;
34
+ //# sourceMappingURL=local-files-tracked.d.ts.map
@@ -0,0 +1,112 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.trackedLocalFiles = trackedLocalFiles;
4
+ exports.committedIgnoreFileLacksEntries = committedIgnoreFileLacksEntries;
5
+ exports.formatTrackedLocalFiles = formatTrackedLocalFiles;
6
+ exports.warnTrackedLocalFiles = warnTrackedLocalFiles;
7
+ /**
8
+ * The half `.gitignore` cannot do: tell the owner when a per-checkout file under
9
+ * `.vigiles/` is ALREADY tracked.
10
+ *
11
+ * `ensureLocalFilesIgnored` (`local-files.ts`) keeps new copies out of git, but
12
+ * an ignore rule does not untrack a file git already follows — a repo that
13
+ * committed `.vigiles/coverage.json` before this existed keeps committing every
14
+ * rewrite of it. Only the owner can fix that (`git rm --cached`), and changing
15
+ * the index is theirs to decide, so this reports and never acts.
16
+ *
17
+ * CLI-only, by construction: it spawns `git`, and a hook decision must never pay
18
+ * for a child process. That is why it is a separate module from the ignore half,
19
+ * which hook runtimes import.
20
+ */
21
+ const node_child_process_1 = require("node:child_process");
22
+ const node_path_1 = require("node:path");
23
+ const local_files_js_1 = require("./local-files.js");
24
+ /** `.vigiles/.gitignore`, repo-relative. */
25
+ const IGNORE_FILE = node_path_1.posix.join(local_files_js_1.VIGILES_DIR, local_files_js_1.LOCAL_GITIGNORE_FILE);
26
+ /**
27
+ * The per-checkout files under `<root>/.vigiles/` that git tracks, as
28
+ * repo-relative paths from `root`. Empty when nothing is tracked, when `root` is
29
+ * not in a git work tree, or when `git` is missing — silence is the answer to
30
+ * every "cannot tell".
31
+ */
32
+ function trackedLocalFiles(root) {
33
+ const specs = local_files_js_1.LOCAL_FILES.map((f) => node_path_1.posix.join(local_files_js_1.VIGILES_DIR, f.name));
34
+ try {
35
+ const r = (0, node_child_process_1.spawnSync)("git", ["ls-files", "-z", "--", ...specs], {
36
+ cwd: root,
37
+ encoding: "utf-8",
38
+ stdio: ["ignore", "pipe", "ignore"],
39
+ });
40
+ if (r.status !== 0 || typeof r.stdout !== "string")
41
+ return [];
42
+ return r.stdout.split("\0").filter((p) => p !== "");
43
+ }
44
+ catch {
45
+ return [];
46
+ }
47
+ }
48
+ /**
49
+ * Does the COMMITTED `.vigiles/.gitignore` (the one at `HEAD`) lack entries
50
+ * vigiles needs — i.e. will vigiles' additions show up as a change to a tracked
51
+ * file until someone commits them.
52
+ *
53
+ * The question is asked of `HEAD`, not of the worktree's dirtiness, and that
54
+ * is the point (Codex review on #275, third pass). "The file is modified" was
55
+ * the earlier predicate; it could not tell vigiles' additions from the owner's
56
+ * own unrelated edit, and then advised committing work in progress. Reading the
57
+ * committed copy answers only for vigiles' entries, staged or not, and says
58
+ * nothing once they are committed, whatever else the owner is editing.
59
+ * `HEAD:./path`, not `HEAD:path`: without `./` git resolves the path from the
60
+ * REPOSITORY root, so a package nested in a larger worktree read the wrong file
61
+ * — or none — and stayed silent (Codex review on #275, gitrevisions(7)).
62
+ * Silent on every "cannot tell": not in a repo, no `HEAD`, file not committed.
63
+ */
64
+ function committedIgnoreFileLacksEntries(root) {
65
+ try {
66
+ const r = (0, node_child_process_1.spawnSync)("git", ["show", `HEAD:./${IGNORE_FILE}`], {
67
+ cwd: root,
68
+ encoding: "utf-8",
69
+ stdio: ["ignore", "pipe", "ignore"],
70
+ });
71
+ if (r.status !== 0 || typeof r.stdout !== "string")
72
+ return false;
73
+ return (0, local_files_js_1.entriesNotInEffect)(r.stdout).length > 0;
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ /**
80
+ * The one-line warning for {@link trackedLocalFiles}, or `null` when there is
81
+ * nothing to say. Files inside a tracked local DIRECTORY (`state/`,
82
+ * `eval-cache/`) are named by that directory, so one line stays one line and the
83
+ * command it prints untracks all of them.
84
+ */
85
+ function formatTrackedLocalFiles(tracked, committedLacksEntries = false) {
86
+ const lines = [];
87
+ if (tracked.length > 0) {
88
+ // `.vigiles/state/.claude/hooks/x.json` → `.vigiles/state`: the list entry.
89
+ const entries = [
90
+ ...new Set(tracked.map((p) => p.split("/").slice(0, 2).join("/"))),
91
+ ];
92
+ const one = entries.length === 1;
93
+ lines.push(`⚠ ${entries.join(", ")} ${one ? "is" : "are"} tracked by git, but ` +
94
+ `${local_files_js_1.VIGILES_DIR}/ local files describe one checkout and would credit a ` +
95
+ `machine where nothing ran. .gitignore does not untrack a tracked file; ` +
96
+ `untrack ${one ? "it" : "them"} once: git rm -r --cached ${entries.join(" ")}`);
97
+ }
98
+ // Not "untrack it": the repo may keep its own rules there, and untracking
99
+ // would take them away from everyone else.
100
+ if (committedLacksEntries)
101
+ lines.push(`⚠ ${IGNORE_FILE} is tracked by git and its committed version lacks ` +
102
+ `vigiles' local-file entries, which vigiles adds to your copy. Commit ` +
103
+ `those lines once; entries are appended only when missing.`);
104
+ return lines.length > 0 ? lines.join("\n") : null;
105
+ }
106
+ /** Print the warning for `root` to stderr when there is one. CLI-only. */
107
+ function warnTrackedLocalFiles(root) {
108
+ const line = formatTrackedLocalFiles(trackedLocalFiles(root), committedIgnoreFileLacksEntries(root));
109
+ if (line)
110
+ process.stderr.write(line + "\n");
111
+ }
112
+ //# sourceMappingURL=local-files-tracked.js.map
@@ -0,0 +1,71 @@
1
+ /** vigiles's own directory in a project. */
2
+ export declare const VIGILES_DIR = ".vigiles";
3
+ /** The execution tier of coverage (`coverage-artifact.ts`). */
4
+ export declare const COVERAGE_ARTIFACT_FILE = "coverage.json";
5
+ /** The flight-recorder ledger (`observe.ts`). */
6
+ export declare const LEDGER_FILE = "runs.jsonl";
7
+ /** Compiled hooks' named state, `record()`/`state()` (`hook-state-store.ts`). */
8
+ export declare const HOOK_STATE_DIR = "state";
9
+ /** The skill in progress (`adapters/claude-code/skill-runtime.ts`). */
10
+ export declare const ACTIVE_SKILL_FILE = "active-skill.json";
11
+ /** The subagent stack in progress (`adapters/claude-code/agent-runtime.ts`). */
12
+ export declare const ACTIVE_AGENT_FILE = "active-agent.json";
13
+ /** Inside-an-effect-boundary marker (`adapters/claude-code/effect-region.ts`). */
14
+ export declare const EFFECT_ACTIVE_FILE = "effect-active.json";
15
+ /** The calls a guard allowed this session (`core/guards.ts`). */
16
+ export declare const GUARD_LEDGER_FILE = "guard-ledger.json";
17
+ /** What an `observe`-mode hook would have blocked (`hook-runtime.ts`). */
18
+ export declare const HOOK_OBSERVATIONS_FILE = "hook-observations.jsonl";
19
+ /** Recorded model runs for eval replay, the default `cacheDir` (`eval.ts`). */
20
+ export declare const EVAL_CACHE_DIR = "eval-cache";
21
+ /** One per-checkout path under `.vigiles/`. */
22
+ export interface LocalFile {
23
+ /** The name directly under `.vigiles/`. */
24
+ readonly name: string;
25
+ /** A directory (its whole subtree is local) rather than a single file. */
26
+ readonly dir: boolean;
27
+ }
28
+ /** Every per-checkout path vigiles writes under `.vigiles/`. The one list. */
29
+ export declare const LOCAL_FILES: readonly LocalFile[];
30
+ /**
31
+ * The names under `.vigiles/` that ARE the project's and must stay committable.
32
+ * Not used at runtime — it is the other half of the classification the guard in
33
+ * `local-files.test.ts` holds the source to, and the list that test checks is
34
+ * NOT ignored.
35
+ */
36
+ export declare const COMMITTED_PATHS: readonly string[];
37
+ /** The header comment on a `.vigiles/.gitignore` vigiles creates. */
38
+ /** The ignore file vigiles keeps inside `.vigiles/`. One spelling for every reader. */
39
+ export declare const LOCAL_GITIGNORE_FILE = ".gitignore";
40
+ export declare const LOCAL_GITIGNORE_HEADER: readonly string[];
41
+ /** The ignore lines, in order: every local path, then the file itself. */
42
+ export declare function localIgnoreEntries(): string[];
43
+ /**
44
+ * Keep `<vigilesDir>/.gitignore` listing every local path. Call it where a
45
+ * local file is written — not from `vigiles init`, which a project may never
46
+ * run, while the write always happens.
47
+ *
48
+ * - Absent → created with {@link LOCAL_GITIGNORE_HEADER} and every entry.
49
+ * - Every entry IN EFFECT → not touched (no rewrite, no mtime change).
50
+ * - Otherwise the entries not in effect are appended and nothing is removed
51
+ * or reordered. Appending is enough because git's last matching rule wins.
52
+ *
53
+ * "In effect" is judged the way git reads the file, not by text membership:
54
+ * lines in order, a later negation cancels an earlier `/entry` (see
55
+ * {@link inEffect}), and leading whitespace is part of the pattern (only
56
+ * trailing whitespace is dropped).
57
+ * Both cases were measured with `git check-ignore`: `/coverage.json` followed
58
+ * by `!/coverage.json`, and ` /coverage.json`, each leave the file NOT
59
+ * ignored while a trimmed set would call the entry present.
60
+ *
61
+ * Costs one read on the hot path. Best-effort: any fs error is swallowed —
62
+ * keeping git tidy must never break a hook, a test run or an audit.
63
+ */
64
+ /**
65
+ * The entries a `.vigiles/.gitignore` with this content does NOT put in effect.
66
+ * One reader for both questions — "what to append" ({@link ensureLocalFilesIgnored})
67
+ * and "does the committed copy already carry them" (the CLI's tracked-file check).
68
+ */
69
+ export declare function entriesNotInEffect(content: string): string[];
70
+ export declare function ensureLocalFilesIgnored(vigilesDir: string): void;
71
+ //# sourceMappingURL=local-files.d.ts.map
@@ -0,0 +1,204 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LOCAL_GITIGNORE_HEADER = exports.LOCAL_GITIGNORE_FILE = exports.COMMITTED_PATHS = exports.LOCAL_FILES = exports.EVAL_CACHE_DIR = exports.HOOK_OBSERVATIONS_FILE = exports.GUARD_LEDGER_FILE = exports.EFFECT_ACTIVE_FILE = exports.ACTIVE_AGENT_FILE = exports.ACTIVE_SKILL_FILE = exports.HOOK_STATE_DIR = exports.LEDGER_FILE = exports.COVERAGE_ARTIFACT_FILE = exports.VIGILES_DIR = void 0;
4
+ exports.localIgnoreEntries = localIgnoreEntries;
5
+ exports.entriesNotInEffect = entriesNotInEffect;
6
+ exports.ensureLocalFilesIgnored = ensureLocalFilesIgnored;
7
+ /**
8
+ * The files vigiles writes into a project's `.vigiles/` that describe ONE
9
+ * checkout on ONE machine — and the `.gitignore` that keeps them out of git.
10
+ *
11
+ * `.vigiles/` holds two kinds of file. Some are the project's own and are
12
+ * committed: hook sources and their stamps (`hooks/`), eval locks
13
+ * (`eval-locks/`), providers, the eval baseline, spec sidecars. The rest are a
14
+ * record of what happened HERE — which run exercised which surface, which skill
15
+ * is active right now, when a throttled hook last spoke — and committing one
16
+ * transplants that record onto a machine where none of it happened. A committed
17
+ * `coverage.json` credits coverage nobody ran; a committed `state/` silences a
18
+ * teammate's hook for a window they never saw; a committed `guard-ledger.json`
19
+ * satisfies a `requireBefore` guard with a prerequisite someone else ran.
20
+ *
21
+ * ## Why this file exists
22
+ *
23
+ * The rule "do not commit these" lived as one sentence in
24
+ * `docs/rules/untested-skill.md`, and vigiles honoured it only in its OWN repo's
25
+ * root `.gitignore`. Consumers committed them anyway: two repos carried
26
+ * `.vigiles/coverage.json` for weeks, one built a `merge=ours` git driver to
27
+ * paper over the conflicts it caused, and the other shipped `coverage.json` and
28
+ * `runs.jsonl` inside its npm tarball. A rule that exists only as prose enforces
29
+ * nothing, so the writer now enforces it where the write happens.
30
+ *
31
+ * ## The mechanism: a `.gitignore` INSIDE `.vigiles/`
32
+ *
33
+ * Tools that keep local state in a project leave the project's root
34
+ * `.gitignore` alone and drop one inside their own directory — `ruff` writes
35
+ * `.ruff_cache/.gitignore` containing `*`. `*` is wrong here, because the same
36
+ * directory holds committed files, so the file lists the local paths instead,
37
+ * each anchored with a leading `/` so it cannot match a same-named file deeper
38
+ * down. It also lists ITSELF, like ruff's `*` covers its own file: it then
39
+ * leaves no footprint in the project's history and never churns on upgrade.
40
+ *
41
+ * `.gitignore` does not untrack a file that is already tracked. That half is
42
+ * `local-files-tracked.ts`, which runs from the CLI only — it spawns `git`, and
43
+ * nothing here may, because {@link ensureLocalFilesIgnored} is called from hook
44
+ * runtimes on the decision path.
45
+ *
46
+ * ## One list, and the constants live IN it
47
+ *
48
+ * Every writer imports its file name from here rather than spelling it, so a
49
+ * name cannot drift from the ignore entry. The constants were defined in their
50
+ * writer modules before; the direction is inverted on purpose. With the list
51
+ * importing from the writers, each writer would import this module back (for
52
+ * the ensure call), a cycle in which a top-level list reads a `const` still in
53
+ * its temporal dead zone — and a hook deciding a `Bash` call would load
54
+ * `eval.ts` and `coverage-artifact.ts` just to learn two file names. This module
55
+ * imports nothing from the package, so every writer can depend on it for free.
56
+ * `local-files.test.ts` guards the other direction: every `.vigiles/` path the
57
+ * source spells must be either on this list or on {@link COMMITTED_PATHS}.
58
+ *
59
+ * Node-only (it reads and writes a file).
60
+ */
61
+ const node_fs_1 = require("node:fs");
62
+ const node_path_1 = require("node:path");
63
+ /** vigiles's own directory in a project. */
64
+ exports.VIGILES_DIR = ".vigiles";
65
+ /** The execution tier of coverage (`coverage-artifact.ts`). */
66
+ exports.COVERAGE_ARTIFACT_FILE = "coverage.json";
67
+ /** The flight-recorder ledger (`observe.ts`). */
68
+ exports.LEDGER_FILE = "runs.jsonl";
69
+ /** Compiled hooks' named state, `record()`/`state()` (`hook-state-store.ts`). */
70
+ exports.HOOK_STATE_DIR = "state";
71
+ /** The skill in progress (`adapters/claude-code/skill-runtime.ts`). */
72
+ exports.ACTIVE_SKILL_FILE = "active-skill.json";
73
+ /** The subagent stack in progress (`adapters/claude-code/agent-runtime.ts`). */
74
+ exports.ACTIVE_AGENT_FILE = "active-agent.json";
75
+ /** Inside-an-effect-boundary marker (`adapters/claude-code/effect-region.ts`). */
76
+ exports.EFFECT_ACTIVE_FILE = "effect-active.json";
77
+ /** The calls a guard allowed this session (`core/guards.ts`). */
78
+ exports.GUARD_LEDGER_FILE = "guard-ledger.json";
79
+ /** What an `observe`-mode hook would have blocked (`hook-runtime.ts`). */
80
+ exports.HOOK_OBSERVATIONS_FILE = "hook-observations.jsonl";
81
+ /** Recorded model runs for eval replay, the default `cacheDir` (`eval.ts`). */
82
+ exports.EVAL_CACHE_DIR = "eval-cache";
83
+ /** Every per-checkout path vigiles writes under `.vigiles/`. The one list. */
84
+ exports.LOCAL_FILES = [
85
+ { name: exports.COVERAGE_ARTIFACT_FILE, dir: false },
86
+ { name: exports.LEDGER_FILE, dir: false },
87
+ { name: exports.HOOK_STATE_DIR, dir: true },
88
+ { name: exports.ACTIVE_SKILL_FILE, dir: false },
89
+ { name: exports.ACTIVE_AGENT_FILE, dir: false },
90
+ { name: exports.EFFECT_ACTIVE_FILE, dir: false },
91
+ { name: exports.GUARD_LEDGER_FILE, dir: false },
92
+ { name: exports.HOOK_OBSERVATIONS_FILE, dir: false },
93
+ { name: exports.EVAL_CACHE_DIR, dir: true },
94
+ ];
95
+ /**
96
+ * The names under `.vigiles/` that ARE the project's and must stay committable.
97
+ * Not used at runtime — it is the other half of the classification the guard in
98
+ * `local-files.test.ts` holds the source to, and the list that test checks is
99
+ * NOT ignored.
100
+ */
101
+ exports.COMMITTED_PATHS = [
102
+ "hooks", // hook sources + their stamps (`hook-install.ts`)
103
+ "providers", // registered hook-context providers
104
+ "eval-locks", // committed eval staleness stamps (`eval-lock.ts`)
105
+ "eval-baseline.json", // the committed regression baseline (`eval-baseline.ts`)
106
+ "generated.d.ts", // linter-rule types for specs (`vigiles init` / `generate types`)
107
+ "schema.json", // YAML-LSP frontmatter schema (`vigiles init`)
108
+ "guards.json", // the declared guard set (`core/guards.ts`)
109
+ "action-gates.json", // declared action gates (`action-gate.ts`)
110
+ ];
111
+ /** The header comment on a `.vigiles/.gitignore` vigiles creates. */
112
+ /** The ignore file vigiles keeps inside `.vigiles/`. One spelling for every reader. */
113
+ exports.LOCAL_GITIGNORE_FILE = ".gitignore";
114
+ exports.LOCAL_GITIGNORE_HEADER = [
115
+ "# Written by vigiles. These files describe one checkout on one machine and are",
116
+ "# never committed. Everything else in .vigiles/ is the project's: commit it.",
117
+ ];
118
+ /** The ignore lines, in order: every local path, then the file itself. */
119
+ function localIgnoreEntries() {
120
+ return [
121
+ ...exports.LOCAL_FILES.map((f) => `/${f.name}${f.dir ? "/" : ""}`),
122
+ `/${exports.LOCAL_GITIGNORE_FILE}`,
123
+ ];
124
+ }
125
+ /**
126
+ * Is `entry` ignoring its path after git reads `lines` top to bottom.
127
+ *
128
+ * Conservative on purpose: ANY negation after the entry's last occurrence counts
129
+ * as cancelling it. Git re-includes on `!/coverage.json`, but equally on
130
+ * `!coverage.json` or `!*.json` (last matching pattern wins), and matching
131
+ * gitignore globs here would be a second, partial implementation of git. The
132
+ * cost of being conservative is one extra append after an unrelated negation;
133
+ * the next call finds the entries last and leaves the file alone.
134
+ */
135
+ function inEffect(lines, entry) {
136
+ let on = false;
137
+ for (const line of lines) {
138
+ if (line === entry)
139
+ on = true;
140
+ else if (line.startsWith("!"))
141
+ on = false;
142
+ }
143
+ return on;
144
+ }
145
+ /**
146
+ * Keep `<vigilesDir>/.gitignore` listing every local path. Call it where a
147
+ * local file is written — not from `vigiles init`, which a project may never
148
+ * run, while the write always happens.
149
+ *
150
+ * - Absent → created with {@link LOCAL_GITIGNORE_HEADER} and every entry.
151
+ * - Every entry IN EFFECT → not touched (no rewrite, no mtime change).
152
+ * - Otherwise the entries not in effect are appended and nothing is removed
153
+ * or reordered. Appending is enough because git's last matching rule wins.
154
+ *
155
+ * "In effect" is judged the way git reads the file, not by text membership:
156
+ * lines in order, a later negation cancels an earlier `/entry` (see
157
+ * {@link inEffect}), and leading whitespace is part of the pattern (only
158
+ * trailing whitespace is dropped).
159
+ * Both cases were measured with `git check-ignore`: `/coverage.json` followed
160
+ * by `!/coverage.json`, and ` /coverage.json`, each leave the file NOT
161
+ * ignored while a trimmed set would call the entry present.
162
+ *
163
+ * Costs one read on the hot path. Best-effort: any fs error is swallowed —
164
+ * keeping git tidy must never break a hook, a test run or an audit.
165
+ */
166
+ /**
167
+ * The entries a `.vigiles/.gitignore` with this content does NOT put in effect.
168
+ * One reader for both questions — "what to append" ({@link ensureLocalFilesIgnored})
169
+ * and "does the committed copy already carry them" (the CLI's tracked-file check).
170
+ */
171
+ function entriesNotInEffect(content) {
172
+ const lines = content.split(/\r?\n/).map((l) => l.replace(/\s+$/, ""));
173
+ return localIgnoreEntries().filter((e) => !inEffect(lines, e));
174
+ }
175
+ function ensureLocalFilesIgnored(vigilesDir) {
176
+ try {
177
+ const file = (0, node_path_1.resolve)(vigilesDir, exports.LOCAL_GITIGNORE_FILE);
178
+ const entries = localIgnoreEntries();
179
+ let current;
180
+ try {
181
+ current = (0, node_fs_1.readFileSync)(file, "utf-8");
182
+ }
183
+ catch {
184
+ current = undefined;
185
+ }
186
+ if (current === undefined) {
187
+ (0, node_fs_1.mkdirSync)(vigilesDir, { recursive: true });
188
+ (0, node_fs_1.writeFileSync)(file, [...exports.LOCAL_GITIGNORE_HEADER, ...entries, ""].join("\n"));
189
+ return;
190
+ }
191
+ const missing = entriesNotInEffect(current);
192
+ if (missing.length === 0)
193
+ return;
194
+ const header = current.split(/\r?\n/).includes(exports.LOCAL_GITIGNORE_HEADER[0])
195
+ ? []
196
+ : exports.LOCAL_GITIGNORE_HEADER;
197
+ const sep = current === "" || current.endsWith("\n") ? "" : "\n";
198
+ (0, node_fs_1.appendFileSync)(file, sep + [...header, ...missing, ""].join("\n"));
199
+ }
200
+ catch {
201
+ /* best-effort — an unwritable .vigiles/ is not a failure of anything */
202
+ }
203
+ }
204
+ //# sourceMappingURL=local-files.js.map
package/dist/observe.d.ts CHANGED
@@ -1,7 +1,8 @@
1
+ import { LEDGER_FILE } from "./local-files.js";
1
2
  /** Bumped when the record shape changes in a non-additive way. */
2
3
  export declare const OBSERVE_VERSION = 1;
3
- /** The ledger filename under the `.vigiles/` directory. */
4
- export declare const LEDGER_FILE = "runs.jsonl";
4
+ /** The ledger filename under the `.vigiles/` directory — defined on the one local-files list. */
5
+ export { LEDGER_FILE };
5
6
  /** Fields every record carries; `v`/`ts` are stamped by the writer, not the caller. */
6
7
  export interface ObservationBase {
7
8
  /** schema version (`OBSERVE_VERSION`) */
package/dist/observe.js CHANGED
@@ -24,10 +24,10 @@ exports.formatLedgerSummary = formatLedgerSummary;
24
24
  */
25
25
  const node_fs_1 = require("node:fs");
26
26
  const node_path_1 = require("node:path");
27
+ const local_files_js_1 = require("./local-files.js");
28
+ Object.defineProperty(exports, "LEDGER_FILE", { enumerable: true, get: function () { return local_files_js_1.LEDGER_FILE; } });
27
29
  /** Bumped when the record shape changes in a non-additive way. */
28
30
  exports.OBSERVE_VERSION = 1;
29
- /** The ledger filename under the `.vigiles/` directory. */
30
- exports.LEDGER_FILE = "runs.jsonl";
31
31
  /** Serialize one record to a single JSONL line (trailing newline included). */
32
32
  function formatObservation(record) {
33
33
  return JSON.stringify(record) + "\n";
@@ -39,14 +39,15 @@ function formatObservation(record) {
39
39
  */
40
40
  function appendObservation(input, cwd = process.cwd()) {
41
41
  try {
42
- const dir = (0, node_path_1.resolve)(cwd, ".vigiles");
42
+ const dir = (0, node_path_1.resolve)(cwd, local_files_js_1.VIGILES_DIR);
43
43
  (0, node_fs_1.mkdirSync)(dir, { recursive: true });
44
+ (0, local_files_js_1.ensureLocalFilesIgnored)(dir);
44
45
  const record = {
45
46
  v: exports.OBSERVE_VERSION,
46
47
  ts: new Date().toISOString(),
47
48
  ...input,
48
49
  };
49
- (0, node_fs_1.appendFileSync)((0, node_path_1.resolve)(dir, exports.LEDGER_FILE), formatObservation(record));
50
+ (0, node_fs_1.appendFileSync)((0, node_path_1.resolve)(dir, local_files_js_1.LEDGER_FILE), formatObservation(record));
50
51
  }
51
52
  catch {
52
53
  /* best-effort — recording is never allowed to break a session */
@@ -59,7 +60,7 @@ function appendObservation(input, cwd = process.cwd()) {
59
60
  function readObservations(cwd = process.cwd()) {
60
61
  let raw;
61
62
  try {
62
- raw = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(cwd, ".vigiles", exports.LEDGER_FILE), "utf8");
63
+ raw = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(cwd, local_files_js_1.VIGILES_DIR, local_files_js_1.LEDGER_FILE), "utf8");
63
64
  }
64
65
  catch {
65
66
  return [];
@@ -129,7 +130,7 @@ function formatLedgerSummary(records, committedLocks) {
129
130
  if (records.length === 0)
130
131
  return "";
131
132
  const lines = [
132
- `Flight recorder — ${records.length} record${records.length === 1 ? "" : "s"} in .vigiles/${exports.LEDGER_FILE}`,
133
+ `Flight recorder — ${records.length} record${records.length === 1 ? "" : "s"} in .vigiles/${local_files_js_1.LEDGER_FILE}`,
133
134
  ];
134
135
  const counts = new Map();
135
136
  for (const r of records)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "30.0.0",
3
+ "version": "30.0.2",
4
4
  "description": "Audit, test and measure the harness your AI agent runs on — grade your CLAUDE.md / AGENTS.md, skills, subagents and hooks, run them against a scripted model, and measure whether they actually fire.",
5
5
  "keywords": [
6
6
  "claude-code",