vigiles 33.3.0 → 35.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/action.yml CHANGED
@@ -19,9 +19,15 @@ inputs:
19
19
  required: false
20
20
  default: ""
21
21
  version:
22
- description: "Version of the vigiles npm package to run (e.g. '1', '1.2.3', 'latest'). Use 'local' to run the action's own checkout — for dogfooding via `uses: ./` in this repo's CI."
22
+ description: >
23
+ Version of the vigiles npm package to run (e.g. '34', '34.1.2', 'latest').
24
+ Empty (the default) runs the vigiles THIS repo declares in package.json:
25
+ the installed copy when node_modules has it, else the version in
26
+ package-lock.json, else the declared range — and 'latest' only when the
27
+ repo does not declare vigiles. Use 'local' to run the action's own
28
+ checkout — for dogfooding via `uses: ./` in this repo's CI.
23
29
  required: false
24
- default: "latest"
30
+ default: ""
25
31
  max-rules:
26
32
  description: "Maximum number of rules per spec file (maps to the CLI's --max-rules)."
27
33
  required: false
@@ -109,17 +115,48 @@ runs:
109
115
  fi
110
116
 
111
117
  # Resolve the runner. 'local' runs the action's own build (CI
112
- # dogfood via `uses: ./`); any other value runs the published npm
113
- # package at that version, so the Action reuses the same tested
114
- # artifact users get from `npx vigiles`.
115
- version="${VIGILES_VERSION:-latest}"
118
+ # dogfood via `uses: ./`); an explicit version runs the published npm
119
+ # package at that version. EMPTY (the default) runs the vigiles this
120
+ # repo declares, so CI and a local `npx vigiles` are the same version
121
+ # rather than two pins that drift: the installed copy, else the
122
+ # lockfile's version, else the declared range; `latest` only when the
123
+ # repo does not declare vigiles at all.
124
+ version="${VIGILES_VERSION:-}"
116
125
  if [[ "$version" == "local" ]]; then
117
126
  runner=(node "${VIGILES_ACTION_PATH}/dist/cli.js")
118
- else
127
+ from="the action's own checkout"
128
+ elif [[ -n "$version" ]]; then
119
129
  runner=(npx --yes "vigiles@${version}")
130
+ from="the version input"
131
+ else
132
+ declared="$(node -e '
133
+ const fs = require("fs");
134
+ const read = (f) => { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return null; } };
135
+ const pkg = read("package.json") ?? {};
136
+ const spec = pkg.devDependencies?.vigiles ?? pkg.dependencies?.vigiles;
137
+ if (typeof spec !== "string") { console.log("none"); process.exit(0); }
138
+ if (fs.existsSync("node_modules/.bin/vigiles")) { console.log("installed"); process.exit(0); }
139
+ const lock = read("package-lock.json") ?? {};
140
+ const locked = lock.packages?.["node_modules/vigiles"]?.version ?? lock.dependencies?.vigiles?.version;
141
+ console.log(typeof locked === "string" ? "lock " + locked : "range " + spec);
142
+ ' 2>/dev/null || echo none)"
143
+ case "$declared" in
144
+ installed)
145
+ runner=(npx --no-install vigiles)
146
+ from="this repo's installed vigiles" ;;
147
+ "lock "*)
148
+ runner=(npx --yes "vigiles@${declared#lock }")
149
+ from="package-lock.json" ;;
150
+ "range "*)
151
+ runner=(npx --yes "vigiles@${declared#range }")
152
+ from="the range package.json declares" ;;
153
+ *)
154
+ runner=(npx --yes "vigiles@latest")
155
+ from="latest (this repo does not declare vigiles)" ;;
156
+ esac
120
157
  fi
121
158
 
122
- echo "vigiles: ${runner[*]} ${args[*]}"
159
+ echo "vigiles: ${runner[*]} ${args[*]} (version from ${from})"
123
160
 
124
161
  # Run, streaming to the log AND capturing for the summary/comment.
125
162
  out="$(mktemp)"
@@ -75,7 +75,8 @@ async function startCodexMockHandle(script) {
75
75
  }));
76
76
  },
77
77
  get count() {
78
- return mock.requests.length;
78
+ // Turns SERVED: a request past the end of the script gets a 400.
79
+ return Math.min(mock.requests.length, script.length);
79
80
  },
80
81
  close: () => mock.close(),
81
82
  };
@@ -42,8 +42,9 @@ export declare function parseResponsesRequest(body: string): {
42
42
  };
43
43
  /**
44
44
  * Start the scripted Codex Responses mock on a free port. Each
45
- * `POST /v1/responses` consumes the next scripted turn (the last turn repeats if
46
- * codex asks for more); HEAD and any other path get `{}`. Resolves to a handle
45
+ * `POST /v1/responses` consumes the next scripted turn; one past the end of the
46
+ * script is a 400, never a copy of the last turn (#340); HEAD and any other path
47
+ * get `{}`. Resolves to a handle
47
48
  * with the base `url`, the `port`, the recorded `requests`, and `close()`.
48
49
  */
49
50
  export declare function startCodexMock(script: readonly CodexTurn[], opts?: {
@@ -22,6 +22,7 @@ exports.startCodexMock = startCodexMock;
22
22
  * `src/adapters/codex/runtime.ts` (`codexMockArgs`/`codexMockEnv`).
23
23
  */
24
24
  const node_http_1 = __importDefault(require("node:http"));
25
+ const script_overrun_js_1 = require("../../core/script-overrun.js");
25
26
  const MSG_ID = "msg_mock_1";
26
27
  const RESP_ID = "resp_mock_1";
27
28
  /**
@@ -183,8 +184,9 @@ function parseResponsesRequest(body) {
183
184
  }
184
185
  /**
185
186
  * Start the scripted Codex Responses mock on a free port. Each
186
- * `POST /v1/responses` consumes the next scripted turn (the last turn repeats if
187
- * codex asks for more); HEAD and any other path get `{}`. Resolves to a handle
187
+ * `POST /v1/responses` consumes the next scripted turn; one past the end of the
188
+ * script is a 400, never a copy of the last turn (#340); HEAD and any other path
189
+ * get `{}`. Resolves to a handle
188
190
  * with the base `url`, the `port`, the recorded `requests`, and `close()`.
189
191
  */
190
192
  function startCodexMock(script, opts = {}) {
@@ -204,7 +206,17 @@ function startCodexMock(script, opts = {}) {
204
206
  const parsed = parseResponsesRequest(body);
205
207
  requests.push(parsed);
206
208
  opts.onRequest?.(parsed);
207
- const turn = script[Math.min(i, script.length - 1)] ?? { text: "" };
209
+ const turn = script[i];
210
+ if (turn === undefined) {
211
+ const message = (0, script_overrun_js_1.scriptOverrunMessage)({
212
+ scripted: script.length,
213
+ request: script.length + 1,
214
+ lastMessage: parsed.prompt,
215
+ });
216
+ res.writeHead(400, { "content-type": "application/json" });
217
+ res.end(JSON.stringify({ error: { type: "invalid_request_error", message } }));
218
+ return;
219
+ }
208
220
  i++;
209
221
  const model = parsed.model || "gpt-5-codex";
210
222
  res.writeHead(200, {
@@ -75,6 +75,7 @@ exports.COMMAND_FLAGS = {
75
75
  "--no-gha",
76
76
  "--no-plugin",
77
77
  "--ci-only",
78
+ "--full",
78
79
  ],
79
80
  compile: [],
80
81
  eject: ["--keep-spec"],
package/dist/cli-main.js CHANGED
@@ -27,6 +27,7 @@ const generate_harness_js_1 = require("./core/generate-harness.js");
27
27
  const capability_diff_js_1 = require("./core/capability-diff.js");
28
28
  const validate_js_1 = require("./core/validate.js");
29
29
  const eval_lock_js_1 = require("./eval-lock.js");
30
+ const zod_1 = require("zod");
30
31
  const cli_flags_js_1 = require("./cli-flags.js");
31
32
  const cli_commands_js_1 = require("./cli-commands.js");
32
33
  const cli_flag_check_js_1 = require("./cli-flag-check.js");
@@ -2636,6 +2637,9 @@ function vigilesWorkflow(plan, harnesses, hasPackageJson) {
2636
2637
  - uses: actions/setup-node@v4
2637
2638
  with:
2638
2639
  node-version: "20"
2640
+ # No \`version:\` on purpose: the Action runs the vigiles your
2641
+ # package.json declares, so CI and a local \`npx vigiles\` cannot
2642
+ # drift onto two different pins.
2639
2643
  - uses: zernie/vigiles@v1
2640
2644
  `
2641
2645
  : "";
@@ -2667,17 +2671,21 @@ function vigilesWorkflow(plan, harnesses, hasPackageJson) {
2667
2671
  eval-check:
2668
2672
  # Eval staleness gate — real-model evals run LOCALLY on your subscription
2669
2673
  # (\`npx vigiles eval --update\`, which commits a lock); this job VERIFIES those
2670
- # committed results against the current inputs with NO model call. It stays a
2671
- # green no-op until you commit your first lock. See docs/harness-testing.md.
2674
+ # committed results against the current inputs with NO model call.
2675
+ # \`--min=0\` is deliberate: a repo with no eval file yet matches nothing, and
2676
+ # \`vigiles eval --check\` fails on an empty match by default (a gate that
2677
+ # verified nothing must not read as a pass). With \`--min=0\` this job is green
2678
+ # until the first \`*.eval.*\` file exists; from then on it is red until that
2679
+ # eval's lock is committed. Raise the floor (\`--min=N\`) once you want
2680
+ # a vanished eval file to fail it. See docs/harness-testing.md.
2672
2681
  runs-on: ubuntu-latest
2673
2682
  steps:
2674
2683
  - uses: actions/checkout@v4
2675
2684
  - uses: actions/setup-node@v4
2676
2685
  with:
2677
2686
  node-version: "20"
2678
- - uses: zernie/vigiles@v1
2679
- with:
2680
- command: eval-check
2687
+ - run: npm install
2688
+ - run: npx vigiles eval --check --min=0
2681
2689
  `
2682
2690
  : "";
2683
2691
  // No selectable jobs (e.g. `--no-lint --no-test`, or `--test` on a repo with no
@@ -3036,6 +3044,9 @@ function redirectSyncToolTargets(cwd, targets) {
3036
3044
  `staling the integrity hash.`);
3037
3045
  return out;
3038
3046
  }
3047
+ /** The two files `init`'s lint layer generates under `.vigiles/`. */
3048
+ const GENERATED_TYPES = ".vigiles/generated.d.ts";
3049
+ const GENERATED_SCHEMA = ".vigiles/schema.json";
3039
3050
  /** Pillar 1 — specs + types + schema + compile. Scaffolds a spec for every
3040
3051
  * instruction file (so `--lint` always delivers a spec), but never compiles
3041
3052
  * OVER a hand-written file — that is left to the adopt-spec skill. */
@@ -3081,7 +3092,7 @@ async function setupPillar1(detected, targetValue, harnesses) {
3081
3092
  const outDir = (0, node_path_1.resolve)(cwd, ".vigiles");
3082
3093
  if (!(0, node_fs_1.existsSync)(outDir))
3083
3094
  (0, node_fs_1.mkdirSync)(outDir, { recursive: true });
3084
- (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(cwd, ".vigiles/generated.d.ts"), typesResult.dts);
3095
+ (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(cwd, GENERATED_TYPES), typesResult.dts);
3085
3096
  for (const l of typesResult.linters) {
3086
3097
  console.log(` ${l.linter}: ${String(l.rules.length)} rules`);
3087
3098
  }
@@ -3089,14 +3100,14 @@ async function setupPillar1(detected, targetValue, harnesses) {
3089
3100
  console.log(` npm scripts: ${String(typesResult.scripts.length)}`);
3090
3101
  }
3091
3102
  console.log("✓ Generated .vigiles/generated.d.ts");
3092
- written.push(".vigiles/generated.d.ts");
3103
+ written.push(GENERATED_TYPES);
3093
3104
  const schemaResult = (0, generate_schema_js_1.generateSchema)({
3094
3105
  basePath: cwd,
3095
3106
  linters: (0, validate_js_1.loadConfig)().linters,
3096
3107
  });
3097
- (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(cwd, ".vigiles/schema.json"), schemaResult.json);
3108
+ (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(cwd, GENERATED_SCHEMA), schemaResult.json);
3098
3109
  console.log("✓ Generated .vigiles/schema.json (YAML-LSP frontmatter schema)");
3099
- written.push(".vigiles/schema.json");
3110
+ written.push(GENERATED_SCHEMA);
3100
3111
  // Compile — but NEVER overwrite an existing hand-written file during `init`:
3101
3112
  // we compile only GREENFIELD targets (the file doesn't exist yet) and targets
3102
3113
  // we already manage (carry our hash). An ADOPTED file is left untouched — the
@@ -3609,8 +3620,24 @@ function printSetupSummary(opts) {
3609
3620
  console.log(`\n Commit:\n git add ${files.join(" ")} && git commit -m "Add vigiles"`);
3610
3621
  }
3611
3622
  }
3623
+ /** The project config file `init` reads and writes. */
3624
+ const VIGILESRC = ".vigilesrc.json";
3612
3625
  async function setup(args) {
3613
3626
  const parsed = (0, setup_plan_js_1.parseSetupArgs)(args);
3627
+ // An already-adopted repo gets the MINIMAL re-run unless a flag asks for more
3628
+ // (#338): re-running `init` to pick up one new piece must not also wire CI,
3629
+ // widen the rules, scaffold files or rewrite a pin. Decided from disk, before
3630
+ // any prompt — the minimal run asks nothing.
3631
+ const state = (0, setup_plan_js_1.adoptionState)({
3632
+ hasConfig: (0, node_fs_1.existsSync)((0, node_path_1.resolve)(process.cwd(), VIGILESRC)),
3633
+ specs: detectProject()
3634
+ .instructionFiles.filter((f) => f.hasSpec)
3635
+ .map((f) => `${f.path}.spec.ts`),
3636
+ });
3637
+ if ((0, setup_plan_js_1.rerunScope)(parsed, state) === "minimal" && state.kind === "adopted") {
3638
+ minimalRerun(parsed, state);
3639
+ return;
3640
+ }
3614
3641
  // Plan: defaults → flags → interactive prompts (only a human at a TTY).
3615
3642
  let plan = (0, setup_plan_js_1.resolvePlan)(parsed);
3616
3643
  const prompted = (0, setup_plan_js_1.shouldPrompt)(parsed, process.stdin.isTTY ?? false);
@@ -3707,6 +3734,152 @@ async function setup(args) {
3707
3734
  console.log("\nℹ Ran the standard setup. Already have a harness, or not a JS/Python repo, and want only the CI integrity gate (nothing installed)? Re-run `npx vigiles init --ci-only`.");
3708
3735
  }
3709
3736
  }
3737
+ /**
3738
+ * The minimal re-run on an adopted repo (#338): add what is missing AND safe and
3739
+ * local — the skill links and a missing devDependency — then say what a full
3740
+ * setup would add and the flag that does it. It writes NOTHING else: no CI
3741
+ * workflow, no `.vigilesrc.json` edit, no harness/schema scaffold, no spec for a
3742
+ * hand-written file, no edit to an existing version pin, no global install.
3743
+ * The decisions are pure (`setup-plan.ts`); this is the IO.
3744
+ */
3745
+ function minimalRerun(parsed, state) {
3746
+ const plan = (0, setup_plan_js_1.resolvePlan)(parsed);
3747
+ const detected = detectProject();
3748
+ const harnesses = resolveHarnesses(parsed, detected, (0, adapter_registry_js_1.declaredHarnessNames)((0, validate_js_1.loadConfig)().harnesses));
3749
+ console.log(`vigiles is already set up here (found ${state.evidence}) — adding only what is missing.\n`);
3750
+ printDetection(detected, harnesses);
3751
+ const links = plan.plugin ? linkSkillsOnly(harnesses) : [];
3752
+ const devDep = plan.lint || plan.test ? addMissingVigilesDevDep() : [];
3753
+ const written = [...links, ...devDep];
3754
+ if (written.length === 0)
3755
+ console.log("\nNothing was missing.");
3756
+ const followUps = (0, setup_plan_js_1.fullSetupFollowUps)(fullSetupGap(plan, detected, harnesses));
3757
+ if (followUps.length > 0)
3758
+ console.log(`\n${followUps.join("\n")}`);
3759
+ if (written.length > 0)
3760
+ console.log(`\n Commit:\n git add ${written.join(" ")} && git commit -m "Update vigiles"`);
3761
+ }
3762
+ /**
3763
+ * Link the shipped skills for each harness that links them — the safe, local
3764
+ * half of `installPlugins`, without the global install or config wiring.
3765
+ * Returns the links created or rewritten.
3766
+ */
3767
+ function linkSkillsOnly(harnesses) {
3768
+ return (0, setup_plan_js_1.planPluginInstall)(harnesses, { hasClaude: false }).flatMap((plan) => {
3769
+ const home = plan.linkSkills ? harnessSkillsHome(plan.harness) : null;
3770
+ if (home === null)
3771
+ return [];
3772
+ const outcome = linkVigilesSkills(home, process.cwd());
3773
+ console.log("");
3774
+ (0, skill_links_js_1.formatSkillLinks)(outcome).forEach((l) => {
3775
+ console.log(l);
3776
+ });
3777
+ return changedLinks(outcome);
3778
+ });
3779
+ }
3780
+ /** Add vigiles to `devDependencies` only when nothing declares it. */
3781
+ function addMissingVigilesDevDep() {
3782
+ const pkgPath = (0, node_path_1.resolve)(process.cwd(), PACKAGE_JSON);
3783
+ if (!(0, node_fs_1.existsSync)(pkgPath))
3784
+ return [];
3785
+ const pkg = readPackageJson(pkgPath);
3786
+ if (pkg === null || pkg.name === "vigiles")
3787
+ return [];
3788
+ const spec = vigilesDepSpec();
3789
+ const devDependencies = (0, setup_plan_js_1.missingVigilesDevDep)(pkg, spec);
3790
+ if (devDependencies === null)
3791
+ return [];
3792
+ (0, node_fs_1.writeFileSync)(pkgPath, JSON.stringify({ ...pkg, devDependencies }, null, 2) + "\n");
3793
+ console.log(`\n✓ Added vigiles@${spec} to devDependencies — run \`npm install\` to fetch it`);
3794
+ return [PACKAGE_JSON];
3795
+ }
3796
+ /**
3797
+ * The fields of package.json the dependency decisions read. LOOSE on purpose:
3798
+ * every other key is carried through untouched, because the result is written
3799
+ * back and a re-run must not drop a field it never looked at.
3800
+ */
3801
+ const packageDepsSchema = zod_1.z.looseObject({
3802
+ name: zod_1.z.string().optional(),
3803
+ dependencies: zod_1.z.record(zod_1.z.string(), zod_1.z.string()).optional(),
3804
+ devDependencies: zod_1.z.record(zod_1.z.string(), zod_1.z.string()).optional(),
3805
+ });
3806
+ /** package.json parsed for its dependency fields; null when unreadable. */
3807
+ function readPackageJson(path) {
3808
+ try {
3809
+ const parsed = packageDepsSchema.safeParse(JSON.parse((0, node_fs_1.readFileSync)(path, "utf-8")));
3810
+ return parsed.success ? parsed.data : null;
3811
+ }
3812
+ catch {
3813
+ return null;
3814
+ }
3815
+ }
3816
+ /**
3817
+ * What a full setup would add here, read from disk — the input to the
3818
+ * follow-up lines. It mirrors the full path's own conditions (the plan's
3819
+ * layers, the existence checks those steps make), so the list names what
3820
+ * `--full` would actually do rather than everything it could.
3821
+ */
3822
+ function fullSetupGap(plan, detected, harnesses) {
3823
+ const cwd = process.cwd();
3824
+ const has = (rel) => (0, node_fs_1.existsSync)((0, node_path_1.resolve)(cwd, rel));
3825
+ const config = readProjectConfig((0, node_path_1.resolve)(cwd, VIGILESRC));
3826
+ const pillar1 = plan.lint && plan.scaffoldSpecs;
3827
+ const unspecced = pillar1
3828
+ ? [
3829
+ ...determineTargets(detected, undefined, [...harnesses]),
3830
+ ...discoverAdoptableSurfaces(cwd),
3831
+ ].filter((t, i, all) => all.indexOf(t) === i && !has(`${t}.spec.ts`))
3832
+ : [];
3833
+ const pkg = has(PACKAGE_JSON)
3834
+ ? readPackageJson((0, node_path_1.resolve)(cwd, PACKAGE_JSON))
3835
+ : null;
3836
+ const declared = pkg?.devDependencies?.vigiles ?? pkg?.dependencies?.vigiles ?? null;
3837
+ const spec = vigilesDepSpec();
3838
+ return {
3839
+ workflow: plan.gha && !has(".github/workflows/vigiles.yml"),
3840
+ config: configGap(config, plan.lint),
3841
+ harnessTest: plan.test && !has("vigiles.harness.mjs"),
3842
+ unspecced,
3843
+ generated: pillar1 && !(has(GENERATED_TYPES) && has(GENERATED_SCHEMA)),
3844
+ installs: plan.plugin ? fullInstallLines(harnesses) : [],
3845
+ devDep: declared !== null && declared !== spec && pkg?.name !== "vigiles"
3846
+ ? { from: declared, to: spec }
3847
+ : null,
3848
+ };
3849
+ }
3850
+ /** Where `.vigilesrc.json` stands against the preset a full setup writes. */
3851
+ function configGap(config, lint) {
3852
+ if (config === null)
3853
+ return "missing";
3854
+ return config.extends === undefined && lint ? "no-preset" : "has-preset";
3855
+ }
3856
+ /** `.vigilesrc.json` as a plain object, or null when absent or unreadable. */
3857
+ function readProjectConfig(path) {
3858
+ if (!(0, node_fs_1.existsSync)(path))
3859
+ return null;
3860
+ try {
3861
+ const raw = JSON.parse((0, node_fs_1.readFileSync)(path, "utf-8"));
3862
+ return typeof raw === "object" && raw !== null && !Array.isArray(raw)
3863
+ ? { ...raw }
3864
+ : null;
3865
+ }
3866
+ catch {
3867
+ return null;
3868
+ }
3869
+ }
3870
+ /** The per-machine installs and config wiring the full plugin step would run. */
3871
+ function fullInstallLines(harnesses) {
3872
+ return (0, setup_plan_js_1.planPluginInstall)(harnesses, { hasClaude: true }).flatMap((plan) => [
3873
+ ...(plan.globalInstallCarriesHooks && plan.commands.length > 0
3874
+ ? [
3875
+ `install the ${plan.harness} plugin for this machine (${plan.commands.join(" && ")}) and declare it in the project settings`,
3876
+ ]
3877
+ : []),
3878
+ ...(plan.vendors
3879
+ ? [`wire vigiles's nudge hooks into the ${plan.harness} config`]
3880
+ : []),
3881
+ ]);
3882
+ }
3710
3883
  /** Canonical, de-duplicated harness names — the keys `harnesses` gets. */
3711
3884
  function harnessConfigKeys(harnesses) {
3712
3885
  return [...new Set(harnesses.map(adapter_registry_js_1.normalizeHarnessName))];
@@ -5026,10 +5199,11 @@ async function promptYesNo(question) {
5026
5199
  * Resolve the eval LOCK env from the `eval` flags. `--update` records each named
5027
5200
  * eval's report to a committed `.vigiles/eval-locks/<name>.lock.json` (run locally
5028
5201
  * on your subscription); `--check` (CI) verifies the committed result against the
5029
- * current inputs WITHOUT a model call. `--check` is a green NO-OP until the first
5030
- * lock is committed (smooth adoption). Returns the env to thread, or `"skip"` to
5031
- * exit green now. `--check`+`--update` together is a usage error (exit 2). The
5032
- * behavior epoch comes from `.vigilesrc.json` `eval.apiVersion` (committed).
5202
+ * current inputs WITHOUT a model call. `--check` with no lock committed at all is
5203
+ * a FAILURE, not a green no-op ({@link refuseEvalCheckWithoutLocks}, #197): a gate
5204
+ * that has verified nothing must not read as one that passed.
5205
+ * `--check`+`--update` together is a usage error (exit 2). The behavior epoch
5206
+ * comes from `.vigilesrc.json` `eval.apiVersion` (committed).
5033
5207
  */
5034
5208
  function resolveEvalLockEnv(args) {
5035
5209
  const wantCheck = args.includes("--check");
@@ -5038,13 +5212,6 @@ function resolveEvalLockEnv(args) {
5038
5212
  console.error("vigiles eval: --check and --update are mutually exclusive (one verifies, one records).");
5039
5213
  process.exit(2);
5040
5214
  }
5041
- if (wantCheck &&
5042
- !(0, eval_lock_js_1.anyLocksCommitted)((0, node_path_1.resolve)(process.cwd(), eval_lock_js_1.DEFAULT_LOCK_DIR))) {
5043
- console.log("ℹ vigiles eval --check: no committed eval locks found — nothing to verify.\n" +
5044
- " Run `vigiles eval --update` locally (on your subscription) and commit the\n" +
5045
- " lock to enable the CI staleness gate.");
5046
- return "skip";
5047
- }
5048
5215
  const env = {};
5049
5216
  if (wantCheck)
5050
5217
  env.VIGILES_EVAL_LOCK = "check";
@@ -5057,6 +5224,22 @@ function resolveEvalLockEnv(args) {
5057
5224
  }
5058
5225
  return env;
5059
5226
  }
5227
+ /**
5228
+ * `eval --check` over eval files when no lock is committed anywhere: every one of
5229
+ * them has no recorded result to compare the current inputs against, so the gate
5230
+ * would verify nothing and pass. It used to return green BEFORE discovery, which
5231
+ * also bypassed the empty-match failure. Once any lock exists, a file without one
5232
+ * already fails as "lock missing" in the eval itself. Exits 1; returns otherwise.
5233
+ */
5234
+ function refuseEvalCheckWithoutLocks(args, evalCount) {
5235
+ if (!args.includes("--check"))
5236
+ return;
5237
+ if ((0, eval_lock_js_1.anyLocksCommitted)((0, node_path_1.resolve)(process.cwd(), eval_lock_js_1.DEFAULT_LOCK_DIR)))
5238
+ return;
5239
+ console.error(`✗ vigiles eval --check: ${String(evalCount)} eval file(s) have no recorded result — no eval lock is committed, so there is nothing to verify the current inputs against.\n` +
5240
+ " Run `vigiles eval --update` locally (on your subscription) and commit the lock(s).");
5241
+ process.exit(1);
5242
+ }
5060
5243
  /**
5061
5244
  * Turn a completed run into `.vigiles/coverage.json` — the composition root for
5062
5245
  * the execution tier of coverage (`coverage-artifact.ts`).
@@ -5231,6 +5414,31 @@ function gitHead(cwd) {
5231
5414
  return "";
5232
5415
  }
5233
5416
  }
5417
+ /**
5418
+ * Why a `vigiles test` / `vigiles eval` run matched no file — the message names
5419
+ * what was looked for, so a stale path, a wrong glob and an empty default
5420
+ * discovery each say so. A DIRECTORY is the one stale-looking target whose cause
5421
+ * we actually know, so it is said instead of the three guesses: `vigiles test .`
5422
+ * used to reach `spawn("node", ["."])` and surface Node's module-resolution stack.
5423
+ */
5424
+ function emptyMatchMessage(kind, targets, defaultGlob) {
5425
+ const dirs = targets.filter((a) => (0, node_fs_1.lstatSync)(a, { throwIfNoEntry: false })?.isDirectory() === true);
5426
+ if (dirs.length > 0) {
5427
+ return (`✗ vigiles ${kind}: ${dirs.join(", ")} ${dirs.length === 1 ? "is a directory" : "are directories"} — ` +
5428
+ `pass a file, or a glob like "${defaultGlob}".\n` +
5429
+ ` A directory is not a script; nothing ran.`);
5430
+ }
5431
+ const expected = ` If an empty match is expected here, say so with --min=0.`;
5432
+ if (targets.length === 0) {
5433
+ return (`✗ vigiles ${kind}: NOTHING matched the default glob ${defaultGlob}\n` +
5434
+ ` Nothing ran. An empty run is not a pass: a renamed or moved file looks like this.\n` +
5435
+ expected);
5436
+ }
5437
+ return (`✗ vigiles ${kind}: ${String(targets.length)} target(s) given and NOTHING matched — ` +
5438
+ `${targets.join(", ")}\n` +
5439
+ ` Nothing ran. A stale path, a wrong glob, or a moved file all look like this.\n` +
5440
+ expected);
5441
+ }
5234
5442
  async function handleRunScripts(kind, args, restArgs, excludes) {
5235
5443
  const cwd = process.cwd();
5236
5444
  // The ignore file keeps NEW copies of `.vigiles/` local files out of git, but
@@ -5251,14 +5459,10 @@ async function handleRunScripts(kind, args, restArgs, excludes) {
5251
5459
  // Harness/eval scripts may be authored in JS or TS (see run-scripts.ts).
5252
5460
  const defaultGlob = (0, run_scripts_js_1.scriptGlob)(kind === "test" ? "harness" : "eval");
5253
5461
  // The eval LOCK flags (`--check`/`--update`) are resolved BEFORE file discovery
5254
- // so mutual-exclusion + the cold-start no-op are honored regardless of file
5255
- // count. Returns the env to thread to scripts, or `"skip"` to exit green now.
5462
+ // so mutual-exclusion (exit 2) is honored regardless of file count.
5256
5463
  let lockEnv = {};
5257
5464
  if (kind === "eval") {
5258
- const r = resolveEvalLockEnv(args);
5259
- if (r === "skip")
5260
- return;
5261
- lockEnv = r;
5465
+ lockEnv = resolveEvalLockEnv(args);
5262
5466
  }
5263
5467
  // A script under an excluded path is not DISCOVERED (a vendored corpus's own
5264
5468
  // harness must not run as ours), but a script you NAME still runs (#192).
@@ -5280,44 +5484,33 @@ async function handleRunScripts(kind, args, restArgs, excludes) {
5280
5484
  process.exit(1);
5281
5485
  }
5282
5486
  if (files.length === 0) {
5283
- // 🔴 ASKING FOR SOMETHING AND GETTING NOTHING IS A FAILURE; FINDING NOTHING IS NOT.
5284
- // The two cases were collapsed into one silent exit 0, and the collapse cost a real
5285
- // repository three days of green CI verifying zero files: a named step ran
5487
+ // 🔴 A RUN THAT MATCHES NO FILE IS A FAILURE, NAMED OR NOT.
5488
+ // An empty match used to be one silent exit 0, and it cost a real repository three
5489
+ // days of green CI verifying zero files: a named step ran
5286
5490
  // `vigiles test .claude/pipeline/skills.harness.mjs` after that file had been split
5287
5491
  // into one-per-skill, printed "No **/*.harness.* files found" and passed, right next
5288
- // to a step that was red for the same root cause.
5289
- //
5290
- // They are different states. A POSITIONAL argument is a claim that something is there —
5291
- // when nothing matches it, the path is stale, the glob is wrong, or the run never
5292
- // reached its target, and every one of those is a defect. Bare discovery finding
5293
- // nothing is just an empty repository, which is a legitimate place to stand and must
5294
- // stay quiet.
5492
+ // to a step that was red for the same root cause. The first repair failed only the
5493
+ // NAMED case and kept bare discovery quiet ("an empty repository is a legitimate
5494
+ // place to stand") — and bare `vigiles test` is the line `init` writes into CI, so
5495
+ // the same green no-op was one rename away from the default setup (#197).
5295
5496
  //
5296
- // This is the default the field settled on: Jest and Vitest FAIL on no tests found and
5297
- // make you opt in with `--passWithNoTests`; pytest exits 5. `--min=0` remains the
5298
- // explicit opt-out here, so no new flag is introduced by this change.
5299
- // `minFlag`, not `minRequired`: 0 is both the DEFAULT and the explicit opt-out, so the
5300
- // VALUE cannot tell them apart — only the flag's presence can. (Caught by a control:
5301
- // the first version read `minRequired === 0` and made `--min=0` do nothing.)
5302
- if (restArgs.length > 0 && minFlag === undefined) {
5303
- // A DIRECTORY is the one stale-looking target whose cause we actually know,
5304
- // so say it instead of listing the three guesses. `vigiles test .` used to
5305
- // reach `spawn("node", ["."])` and surface Node's module-resolution stack;
5306
- // the generic message above would now be true but unhelpful.
5307
- const dirs = restArgs.filter((a) => (0, node_fs_1.lstatSync)(a, { throwIfNoEntry: false })?.isDirectory() === true);
5308
- console.error(dirs.length > 0
5309
- ? `✗ vigiles ${kind}: ${dirs.join(", ")} ${dirs.length === 1 ? "is a directory" : "are directories"} — ` +
5310
- `pass a file, or a glob like "${defaultGlob}".\n` +
5311
- ` A directory is not a script; nothing ran.`
5312
- : `✗ vigiles ${kind}: ${String(restArgs.length)} target(s) given and NOTHING matched — ` +
5313
- `${restArgs.join(", ")}\n` +
5314
- ` Nothing ran. A stale path, a wrong glob, or a moved file all look like this.\n` +
5315
- ` If an empty match is expected here, say so with --min=0.`);
5497
+ // This is the default the field settled on: Jest and Vitest FAIL on no tests found
5498
+ // and make you opt in with `--passWithNoTests`; pytest exits 5. Here there is no
5499
+ // such flag (a person who wants an empty run does not call the command), and
5500
+ // `--min=0` — the existing floor, spelled as zero — is the only way to say an empty
5501
+ // match is expected. `minFlag`, not `minRequired`: 0 is both the DEFAULT and that
5502
+ // explicit opt-out, so the VALUE cannot tell them apart — only the flag's presence
5503
+ // can. (Caught by a control: the first version read `minRequired === 0` and made
5504
+ // `--min=0` do nothing.)
5505
+ if (minFlag === undefined) {
5506
+ console.error(emptyMatchMessage(kind, restArgs, defaultGlob));
5316
5507
  process.exit(1);
5317
5508
  }
5318
5509
  console.log(`No ${defaultGlob} files found.`);
5319
5510
  return;
5320
5511
  }
5512
+ if (kind === "eval")
5513
+ refuseEvalCheckWithoutLocks(args, files.length);
5321
5514
  // Consent gate for a bare `vigiles eval`: it runs the REAL model on your
5322
5515
  // subscription, and a no-target run discovered the whole tree — so never fan out
5323
5516
  // over an unbounded glob without explicit intent. Mirrors audit's read-vs-run
@@ -5446,6 +5639,10 @@ function capabilitiesOfReport(report, dialect) {
5446
5639
  const COMMAND_HELP = {
5447
5640
  init: {
5448
5641
  usage: " vigiles init [flags] Set up this repo — specs, plugin, and CI.",
5642
+ detail: [
5643
+ " Already set up? A re-run only adds what is missing (skill links, the devDependency);",
5644
+ " --full run the whole setup again",
5645
+ ],
5449
5646
  },
5450
5647
  compile: { usage: " vigiles compile [files...] Compile .spec.ts → .md" },
5451
5648
  eject: {