vigiles 27.3.0 → 29.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.
Files changed (59) hide show
  1. package/README.md +1 -1
  2. package/dist/adapter-registry.d.ts +39 -0
  3. package/dist/adapter-registry.js +45 -0
  4. package/dist/adapter.d.ts +8 -0
  5. package/dist/adapter.js +10 -1
  6. package/dist/adapters/claude-code/adapter.js +9 -2
  7. package/dist/adapters/claude-code/layout.d.ts +5 -0
  8. package/dist/adapters/claude-code/plugin-loader.d.ts +10 -1
  9. package/dist/adapters/claude-code/plugin-loader.js +10 -1
  10. package/dist/adapters/codex/adapter.js +7 -2
  11. package/dist/adapters/codex/layout.d.ts +51 -5
  12. package/dist/adapters/codex/layout.js +13 -4
  13. package/dist/adapters/opencode/adapter.js +6 -0
  14. package/dist/audit-report.template.html +1 -1
  15. package/dist/audit-score.d.ts +7 -0
  16. package/dist/audit-score.js +49 -2
  17. package/dist/cli-main.js +162 -39
  18. package/dist/core/adapter.d.ts +45 -1
  19. package/dist/core/compile.js +11 -1
  20. package/dist/core/config-schema.d.ts +244 -0
  21. package/dist/core/config-schema.js +452 -0
  22. package/dist/core/hook-program.d.ts +43 -0
  23. package/dist/core/hook-program.js +32 -0
  24. package/dist/core/refs.js +10 -1
  25. package/dist/core/surface-discovery.d.ts +270 -0
  26. package/dist/core/surface-discovery.js +425 -0
  27. package/dist/core/surface-scopes.d.ts +38 -1
  28. package/dist/core/surface-scopes.js +73 -1
  29. package/dist/core/symbols.d.ts +24 -2
  30. package/dist/core/symbols.js +66 -18
  31. package/dist/core/types.d.ts +36 -107
  32. package/dist/core/validate.d.ts +46 -18
  33. package/dist/core/validate.js +98 -172
  34. package/dist/exclude.d.ts +20 -0
  35. package/dist/exclude.js +11 -1
  36. package/dist/harness-test.js +3 -3
  37. package/dist/hook-install.d.ts +53 -0
  38. package/dist/hook-install.js +60 -0
  39. package/dist/hook-runtime.d.ts +2 -2
  40. package/dist/hook-runtime.js +82 -63
  41. package/dist/layout-registry.d.ts +14 -0
  42. package/dist/layout-registry.js +40 -0
  43. package/dist/load-hook.d.ts +1 -1
  44. package/dist/load-hook.js +2 -2
  45. package/dist/plugin-loader.d.ts +49 -1
  46. package/dist/plugin-loader.js +120 -14
  47. package/dist/run-hook.js +17 -1
  48. package/dist/scan-core.d.ts +19 -0
  49. package/dist/scan-core.js +30 -0
  50. package/dist/scan-files.js +15 -5
  51. package/dist/scan.d.ts +63 -0
  52. package/dist/scan.js +68 -12
  53. package/dist/score-core.js +8 -0
  54. package/dist/setup-plan.d.ts +2 -1
  55. package/dist/setup-plan.js +7 -2
  56. package/dist/surface-discovery-fs.d.ts +12 -0
  57. package/dist/surface-discovery-fs.js +108 -0
  58. package/dist/vigilesrc.schema.json +1689 -0
  59. package/package.json +10 -6
@@ -75,8 +75,8 @@ function injectableEventsFor(root) {
75
75
  */
76
76
  exports.loadHookProgram = load_hook_js_1.loadHook;
77
77
  /** Load a registered provider (`.vigiles/providers/<name>`) → its definition. */
78
- async function loadProvider(file) {
79
- const abs = (0, node_path_1.resolve)(process.cwd(), file);
78
+ async function loadProvider(file, root = process.cwd()) {
79
+ const abs = (0, node_path_1.resolve)(root, file);
80
80
  const { pathToFileURL } = require("node:url");
81
81
  let mod;
82
82
  try {
@@ -95,8 +95,8 @@ async function loadProvider(file) {
95
95
  return def;
96
96
  }
97
97
  /** Path of the tamper-evident stamp sidecar for a hook file. */
98
- function hookStampPath(file) {
99
- return (0, node_path_1.resolve)(process.cwd(), ".vigiles/hooks", (0, node_path_1.basename)(file) + ".json");
98
+ function hookStampPath(file, root = process.cwd()) {
99
+ return (0, node_path_1.resolve)(root, ".vigiles/hooks", (0, node_path_1.basename)(file) + ".json");
100
100
  }
101
101
  /**
102
102
  * Perform the state writes a hook declared, after its output has been emitted.
@@ -104,14 +104,14 @@ function hookStampPath(file) {
104
104
  * thrown on) is announced — silence here would be a hook that believes it
105
105
  * remembered something.
106
106
  */
107
- function applyHookWrites(file, outcome) {
107
+ function applyHookWrites(file, outcome, root) {
108
108
  const { ok, refused } = (0, hook_program_js_1.outcomeWrites)(outcome);
109
109
  for (const name of refused) {
110
110
  console.error(`vigiles: refused to record ${name} from ${file} — not a valid state key.`);
111
111
  }
112
112
  for (const w of ok) {
113
113
  try {
114
- (0, hook_state_store_js_1.writeHookState)(file, w);
114
+ (0, hook_state_store_js_1.writeHookState)(file, w, { cwd: root });
115
115
  }
116
116
  catch (e) {
117
117
  console.error(`vigiles: could not record ${w.name} from ${file}: ${String(e)}`);
@@ -124,13 +124,13 @@ function applyHookWrites(file, outcome) {
124
124
  * that can't resolve yields its default (never throws). The pure registry +
125
125
  * decision logic live in core/hook-providers.ts — this only injects the real IO.
126
126
  */
127
- async function gatherHookContext(program, file) {
127
+ async function gatherHookContext(program, file, root) {
128
128
  const needs = (0, hook_program_js_1.hookNeeds)(program);
129
129
  if (needs.length === 0)
130
130
  return {};
131
131
  // Only load the registered-provider registry if a provider() ref is declared.
132
132
  const hasRef = needs.some((n) => typeof n !== "string" && n.kind === "provider-ref");
133
- const registry = hasRef ? await loadProviderRegistry() : {};
133
+ const registry = hasRef ? await loadProviderRegistry(root) : {};
134
134
  const { execSync } = require("node:child_process");
135
135
  const { isCI } = require("ci-info");
136
136
  return (0, hook_providers_js_1.gatherContext)(needs, {
@@ -138,12 +138,12 @@ async function gatherHookContext(program, file) {
138
138
  encoding: "utf-8",
139
139
  stdio: ["ignore", "pipe", "ignore"],
140
140
  }),
141
- cwd: process.cwd(),
141
+ cwd: root,
142
142
  platform: process.platform,
143
143
  isCI,
144
144
  // The namespace is bound HERE, from the hook's own path — core never sees
145
145
  // it, so no key a hook can spell reaches another owner's store.
146
- readState: (key) => (0, hook_state_store_js_1.readHookState)(file, key),
146
+ readState: (key) => (0, hook_state_store_js_1.readHookState)(file, key, root),
147
147
  now: Date.now(),
148
148
  }, registry);
149
149
  }
@@ -152,11 +152,11 @@ async function gatherHookContext(program, file) {
152
152
  * for `provider()` ref resolution. A bad/unloadable provider file is skipped (the
153
153
  * ref then yields its default ""), never crashes a live session.
154
154
  */
155
- async function loadProviderRegistry() {
155
+ async function loadProviderRegistry(root) {
156
156
  const registry = {};
157
- for (const file of (0, hook_install_js_1.discoverProviderFiles)(process.cwd())) {
157
+ for (const file of (0, hook_install_js_1.discoverProviderFiles)(root)) {
158
158
  try {
159
- const def = await loadProvider(file);
159
+ const def = await loadProvider(file, root);
160
160
  registry[def.name] = def;
161
161
  }
162
162
  catch {
@@ -166,9 +166,9 @@ async function loadProviderRegistry() {
166
166
  return registry;
167
167
  }
168
168
  /** Append an observe-mode record to `.vigiles/hook-observations.jsonl` (best-effort). */
169
- function recordObservation(file, on, would, reason) {
169
+ function recordObservation(file, on, would, reason, root) {
170
170
  try {
171
- const dir = (0, node_path_1.resolve)(process.cwd(), ".vigiles");
171
+ const dir = (0, node_path_1.resolve)(root, ".vigiles");
172
172
  (0, node_fs_1.mkdirSync)(dir, { recursive: true });
173
173
  const line = JSON.stringify({
174
174
  ts: new Date().toISOString(),
@@ -189,7 +189,7 @@ function recordObservation(file, on, would, reason) {
189
189
  * shadow/rollout path. Harness-neutral — exit 2 / exit 0 are identical on Claude
190
190
  * Code and Codex; the record is vigiles-local.
191
191
  */
192
- function emitGate(decision, on, mode, file) {
192
+ function emitGate(decision, on, mode, file, root) {
193
193
  const action = (0, hook_program_js_1.gateAction)(decision, mode);
194
194
  switch (action.kind) {
195
195
  case "block":
@@ -200,7 +200,7 @@ function emitGate(decision, on, mode, file) {
200
200
  mode: "enforce",
201
201
  rule: file,
202
202
  reason: action.reason,
203
- });
203
+ }, root);
204
204
  console.error(action.reason);
205
205
  process.exit(2);
206
206
  return;
@@ -212,7 +212,7 @@ function emitGate(decision, on, mode, file) {
212
212
  mode: "enforce",
213
213
  rule: file,
214
214
  reason: action.reason,
215
- });
215
+ }, root);
216
216
  process.stdout.write(JSON.stringify({
217
217
  hookSpecificOutput: {
218
218
  hookEventName: on,
@@ -229,8 +229,8 @@ function emitGate(decision, on, mode, file) {
229
229
  mode: "observe",
230
230
  rule: file,
231
231
  reason: action.reason,
232
- });
233
- recordObservation(file, on, action.would, action.reason);
232
+ }, root);
233
+ recordObservation(file, on, action.would, action.reason, root);
234
234
  console.error(`⚠ [vigiles observe] ${on}: would ${action.would} — ${action.reason}`);
235
235
  return; // exit 0 — observe never blocks
236
236
  case "allow":
@@ -247,9 +247,9 @@ function emitGate(decision, on, mode, file) {
247
247
  * observed wedge came from a `package.json` the author was not thinking about at
248
248
  * the time — it had merge-conflict markers in it, nothing to do with hooks.
249
249
  */
250
- function hookLoadPathFiles(hookFile) {
250
+ function hookLoadPathFiles(hookFile, root) {
251
251
  const files = [];
252
- let dir = (0, node_path_1.dirname)((0, node_path_1.resolve)(process.cwd(), hookFile));
252
+ let dir = (0, node_path_1.dirname)((0, node_path_1.resolve)(root, hookFile));
253
253
  for (;;) {
254
254
  const pkg = (0, node_path_1.resolve)(dir, "package.json");
255
255
  files.push(pkg);
@@ -275,15 +275,15 @@ function hookLoadPathFiles(hookFile) {
275
275
  break;
276
276
  dir = up;
277
277
  }
278
- files.push((0, node_path_1.resolve)(process.cwd(), ".vigilesrc.json"));
278
+ files.push((0, node_path_1.resolve)(root, ".vigilesrc.json"));
279
279
  return files;
280
280
  }
281
281
  /**
282
282
  * The conflicted files on this hook's load path, if any — the difference between
283
283
  * "your hook is broken" and "your repo is mid-merge and the hook is collateral".
284
284
  */
285
- function conflictedLoadPathFiles(hookFile) {
286
- return hookLoadPathFiles(hookFile)
285
+ function conflictedLoadPathFiles(hookFile, root) {
286
+ return hookLoadPathFiles(hookFile, root)
287
287
  .filter((p) => {
288
288
  try {
289
289
  return ((0, node_fs_1.existsSync)(p) && (0, merge_conflict_js_1.hasMergeConflictMarkers)((0, node_fs_1.readFileSync)(p, "utf-8")));
@@ -292,7 +292,7 @@ function conflictedLoadPathFiles(hookFile) {
292
292
  return false; // unreadable is a different problem; don't guess about it
293
293
  }
294
294
  })
295
- .map((p) => (0, node_path_1.relative)(process.cwd(), p) || p);
295
+ .map((p) => (0, node_path_1.relative)(root, p) || p);
296
296
  }
297
297
  /**
298
298
  * Print the loud stderr banner that accompanies a REPAIR-only pass-through, and
@@ -321,14 +321,15 @@ function announceRepairEscape(file, why) {
321
321
  * `.claude/settings.json` to unwire the gate. Observed 2026-08-03.
322
322
  */
323
323
  function verifyStampOrRefuse(file, event) {
324
- const stampPath = hookStampPath(file);
324
+ const { root } = event;
325
+ const stampPath = hookStampPath(file, root);
325
326
  if (!(0, node_fs_1.existsSync)(stampPath))
326
327
  return;
327
328
  try {
328
329
  const { stamp } = JSON.parse((0, node_fs_1.readFileSync)(stampPath, "utf-8"));
329
- const source = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(process.cwd(), file), "utf-8");
330
+ const source = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(root, file), "utf-8");
330
331
  if (stamp && !(0, hook_program_js_1.verifyHookStamp)(source, stamp)) {
331
- if ((0, hook_program_js_1.isStampRepairEvent)(event, file, process.cwd())) {
332
+ if ((0, hook_program_js_1.isStampRepairEvent)(event, file, root)) {
332
333
  announceRepairEscape(file, "does not match its compiled stamp");
333
334
  return;
334
335
  }
@@ -388,21 +389,39 @@ async function runHookProgramCommand(file) {
388
389
  catch {
389
390
  /* no stdin */
390
391
  }
391
- let event = {};
392
+ let payload = {};
392
393
  try {
393
- event = JSON.parse(raw);
394
+ payload = JSON.parse(raw);
394
395
  }
395
396
  catch {
396
397
  /* malformed → empty event */
397
398
  }
398
- // The root repo-relative path prefixes resolve against. `$CLAUDE_PROJECT_DIR`
399
- // first (the same root the harness resolved THIS hook's own path against),
400
- // then the payload's `cwd`; never `process.cwd()`, which under a git worktree
401
- // can be a different checkout. See `projectRootOf`.
402
- const projectRoot = (0, hook_program_js_1.projectRootOf)(event, process.env);
399
+ // 🔴 THE ROOT IS RESOLVED ONCE, HERE, AND RIDES ON THE EVENT. Everything below
400
+ // reads `event.root`; nothing recomputes it and nothing is handed a root
401
+ // beside an event it might disagree with. That disagreement is the defect
402
+ // this shape exists to prevent — the stamp sidecar, the hook's own source,
403
+ // the state store, the ledger and the provider registry each used to resolve
404
+ // against `process.cwd()` while the decision layer resolved against the
405
+ // payload, so under a worktree the tamper check did not misfire, it did not
406
+ // run at all.
407
+ //
408
+ // This is the only `process.cwd()` on the runtime's own execution path, and
409
+ // it is the documented last resort for a payload that declares no root. The
410
+ // two others in this file are back-compat defaults on exported helpers
411
+ // (`loadProvider`, `hookStampPath`) for callers outside the runtime; the
412
+ // runtime itself always passes a root and never takes them.
413
+ const event = (0, hook_program_js_1.resolveHookEvent)(payload, process.env, process.cwd());
414
+ const root = event.root;
415
+ // ⚠️ THE DECISION LAYER MUST NOT SEE THE FALLBACK, and this is not a detail.
416
+ // `pathView` treats an undefined root as "I cannot place this path" and errs
417
+ // toward SILENCE. Handing it `process.cwd()` instead would turn that silence
418
+ // into confident decisions measured against a directory nobody declared —
419
+ // quietly widening what gates fire on. IO paths need a usable root; verdicts
420
+ // need an honest one, and they are not the same question.
421
+ const declaredRoot = event.rootDeclared ? event.root : undefined;
403
422
  let program;
404
423
  try {
405
- program = await (0, exports.loadHookProgram)(file);
424
+ program = await (0, exports.loadHookProgram)(file, root);
406
425
  }
407
426
  catch (err) {
408
427
  // A LOAD failure is a fact about the harness, not a verdict about the
@@ -430,7 +449,7 @@ async function runHookProgramCommand(file) {
430
449
  // not go through PreToolUse(Bash)).
431
450
  // Everything else stays BLOCKED, and the escapes are whitelists of commands
432
451
  // that are WRITES — see `isLoadPathRepairEvent` for why no command is one.
433
- const conflicted = conflictedLoadPathFiles(file);
452
+ const conflicted = conflictedLoadPathFiles(file, root);
434
453
  // 🔴 THE THROWN MESSAGE IS THE ONLY THING THAT NAMES THE REAL CAUSE when the
435
454
  // merge-conflict heuristic above does not fire. Without it this said just
436
455
  // "cannot be loaded" — a diagnosis that sends the reader looking in the wrong
@@ -444,13 +463,13 @@ async function runHookProgramCommand(file) {
444
463
  `may be fine)`
445
464
  : `cannot be loaded — ${thrown}`;
446
465
  if ((0, hook_program_js_1.isLoadPathRepairEvent)(event, file, {
447
- // The root the REST of this runtime already uses: `hookStampPath` and
448
- // `verifyStampOrRefuse` read the hook and its sidecar via `process.cwd()`,
449
- // so a repair accepted against any other root would name a file the
450
- // runtime never reads. The hook's own path cannot supply it (a hook sits
451
- // at any depth, and a `.git` probe would be a disk read core does not do).
452
- root: process.cwd(),
453
- loadPathFiles: hookLoadPathFiles(file),
466
+ // The root the REST of this runtime already uses — now the PROJECT's,
467
+ // not the process's. A repair accepted against any other root would name
468
+ // a file the runtime never reads. The hook's own path cannot supply it (a
469
+ // hook sits at any depth, and a `.git` probe would be a disk read core
470
+ // does not do), so it is passed in.
471
+ root,
472
+ loadPathFiles: hookLoadPathFiles(file, root),
454
473
  })) {
455
474
  announceRepairEscape(file, cause);
456
475
  return;
@@ -466,7 +485,7 @@ async function runHookProgramCommand(file) {
466
485
  `${file}, ${merge_conflict_js_1.HARNESS_CONFIG_FILES.join(", ")} is broken — those writes are ` +
467
486
  `allowed even while this refuses, and a Bash gate never gated file tools ` +
468
487
  `at all. The hook then loads and the gate decides normally again.\n` +
469
- `vigiles: those paths resolve under ${process.cwd()} — plus any ancestor ` +
488
+ `vigiles: those paths resolve under ${root} — plus any ancestor ` +
470
489
  `\`package.json\` Node actually reads, so whatever is named above as the ` +
471
490
  `cause is writable. A path in a DIFFERENT checkout is refused: it cannot ` +
472
491
  `repair this failure.\n` +
@@ -494,7 +513,7 @@ async function runHookProgramCommand(file) {
494
513
  verifyStampOrRefuse(file, event);
495
514
  switch ((0, hook_program_js_1.dispatchKind)(program)) {
496
515
  case "inject": {
497
- const ctx = await gatherHookContext(program, file);
516
+ const ctx = await gatherHookContext(program, file, root);
498
517
  const injection = (0, hook_program_js_1.injectionOf)(program, event, ctx);
499
518
  process.stdout.write(JSON.stringify({
500
519
  hookSpecificOutput: {
@@ -508,20 +527,20 @@ async function runHookProgramCommand(file) {
508
527
  kind: "injection",
509
528
  context: injection.context,
510
529
  records: injection.records,
511
- });
530
+ }, root);
512
531
  return;
513
532
  }
514
533
  case "react": {
515
- const ctx = await gatherHookContext(program, file);
516
- warnIfPathUndecidable(event, projectRoot);
517
- const reaction = (0, hook_program_js_1.runReact)(program, event, ctx, projectRoot);
534
+ const ctx = await gatherHookContext(program, file, root);
535
+ warnIfPathUndecidable(event, declaredRoot);
536
+ const reaction = (0, hook_program_js_1.runReact)(program, event, ctx, declaredRoot);
518
537
  // A notice has to REACH someone. stderr at exit 0 goes to the debug log
519
538
  // and nothing else (the host's docs are explicit: "Claude never sees it"),
520
539
  // and a react always exits 0 because its type has no `deny` — so stderr
521
540
  // alone delivered nowhere. Emit the same `additionalContext` shape the
522
541
  // shipped refs/eval-lock nudges use, gated on the ACTIVE adapter's
523
542
  // `injectableEvents` so this is per-harness fact, not a CC literal.
524
- const injectable = injectableEventsFor(projectRoot ?? process.cwd());
543
+ const injectable = injectableEventsFor(root);
525
544
  const delivery = (0, hook_program_js_1.noticeDelivery)(reaction, program.on, injectable);
526
545
  if (delivery.kind === "inject") {
527
546
  process.stdout.write(JSON.stringify({
@@ -537,7 +556,7 @@ async function runHookProgramCommand(file) {
537
556
  // Removing it would break existing consumers to gain nothing.
538
557
  if (reaction.kind === "notice")
539
558
  console.error(reaction.message);
540
- applyHookWrites(file, { kind: "reaction", reaction });
559
+ applyHookWrites(file, { kind: "reaction", reaction }, root);
541
560
  if (reaction.kind === "run") {
542
561
  const { spawnSync } = require("node:child_process");
543
562
  const res = spawnSync(reaction.command, {
@@ -549,29 +568,29 @@ async function runHookProgramCommand(file) {
549
568
  return;
550
569
  }
551
570
  case "file-gate": {
552
- const ctx = await gatherHookContext(program, file);
553
- warnIfPathUndecidable(event, projectRoot);
554
- emitGate((0, hook_program_js_1.decideFileGate)(program, event, ctx, projectRoot), program.on, (0, hook_program_js_1.hookMode)(program), file);
571
+ const ctx = await gatherHookContext(program, file, root);
572
+ warnIfPathUndecidable(event, declaredRoot);
573
+ emitGate((0, hook_program_js_1.decideFileGate)(program, event, ctx, declaredRoot), program.on, (0, hook_program_js_1.hookMode)(program), file, root);
555
574
  return;
556
575
  }
557
576
  case "bash-gate": {
558
- const ctx = await gatherHookContext(program, file);
559
- // The same `projectRoot` the file gates get: without it every
577
+ const ctx = await gatherHookContext(program, file, root);
578
+ // The same `declaredRoot` the file gates get: without it every
560
579
  // repo-relative prefix in a DENYLIST matcher (`touches`/`writesTo`) is
561
580
  // matched by over-blocking alone, and with it an absolute token is placed
562
581
  // exactly. Measured bypass this closes: `sed -i s/a/b/ <abs>/paper.tex`
563
582
  // exited 0 against a guard that blocked the relative spelling.
564
- emitGate((0, hook_program_js_1.decideProgram)(program, event, ctx, projectRoot), program.on, (0, hook_program_js_1.hookMode)(program), file);
583
+ emitGate((0, hook_program_js_1.decideProgram)(program, event, ctx, declaredRoot), program.on, (0, hook_program_js_1.hookMode)(program), file, root);
565
584
  return;
566
585
  }
567
586
  case "prompt-gate": {
568
- const ctx = await gatherHookContext(program, file);
569
- emitGate((0, hook_program_js_1.decidePromptGate)(program, event, ctx), program.on, (0, hook_program_js_1.hookMode)(program), file);
587
+ const ctx = await gatherHookContext(program, file, root);
588
+ emitGate((0, hook_program_js_1.decidePromptGate)(program, event, ctx), program.on, (0, hook_program_js_1.hookMode)(program), file, root);
570
589
  return;
571
590
  }
572
591
  case "stop-gate": {
573
- const ctx = await gatherHookContext(program, file);
574
- emitGate((0, hook_program_js_1.decideStopGate)(program, event, ctx), program.on, (0, hook_program_js_1.hookMode)(program), file);
592
+ const ctx = await gatherHookContext(program, file, root);
593
+ emitGate((0, hook_program_js_1.decideStopGate)(program, event, ctx), program.on, (0, hook_program_js_1.hookMode)(program), file, root);
575
594
  return;
576
595
  }
577
596
  }
@@ -0,0 +1,14 @@
1
+ import type { PluginLayout } from "./core/layout.js";
2
+ /** Every SHIPPED harness layout, in registry order. */
3
+ export declare const REGISTERED_LAYOUTS: readonly PluginLayout[];
4
+ /**
5
+ * "Is this repo-relative path read by some harness vigiles knows about?" — one
6
+ * predicate per registered layout, the input to `unclaimedSurfaces`.
7
+ *
8
+ * Note it is the layouts of every REGISTERED harness, not of the DETECTED one.
9
+ * A repo carrying both `.claude/skills` and `.agents/skills` has each half
10
+ * claimed by a different harness and neither is a finding; a repo whose skills
11
+ * sit under `.ai/` has them claimed by nobody, and that is the finding (#240).
12
+ */
13
+ export declare const registeredClaims: ReadonlyArray<(path: string) => boolean>;
14
+ //# sourceMappingURL=layout-registry.d.ts.map
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registeredClaims = exports.REGISTERED_LAYOUTS = void 0;
4
+ /**
5
+ * The registered harnesses' LAYOUTS, as pure data — the claim side of surface
6
+ * discovery, available where the adapter bundle is not.
7
+ *
8
+ * 🔴 WHY THIS IS NOT JUST `ADAPTERS.map(a => a.layout)`. `src/adapter-registry.ts`
9
+ * imports the full `HarnessAdapter` bundles, and an adapter's `detect(root)` does
10
+ * real filesystem work — so that module pulls `node:fs` in. `src/scan-files.ts`
11
+ * is the BROWSER-SAFE twin of the scan ("NO filesystem, NO child_process, NO disk
12
+ * I/O at all") and needs exactly one thing from the registry: which paths a
13
+ * shipped harness reads. A `PluginLayout` is a plain object of strings, so this
14
+ * list is importable from both sides.
15
+ *
16
+ * The drift that split lists invite is CHECKED, not hoped for:
17
+ * `src/layout-registry.test.ts` asserts this list is exactly the layouts the
18
+ * `ADAPTERS` registry carries, and that every adapter's `claims` agrees with
19
+ * `layoutClaims` over its own layout — so adding a harness to one registry and
20
+ * not the other fails a test instead of quietly halving discovery.
21
+ */
22
+ const layout_js_1 = require("./adapters/claude-code/layout.js");
23
+ const layout_js_2 = require("./adapters/codex/layout.js");
24
+ const surface_discovery_js_1 = require("./core/surface-discovery.js");
25
+ /** Every SHIPPED harness layout, in registry order. */
26
+ exports.REGISTERED_LAYOUTS = [
27
+ layout_js_1.claudeCodeLayout,
28
+ layout_js_2.codexLayout,
29
+ ];
30
+ /**
31
+ * "Is this repo-relative path read by some harness vigiles knows about?" — one
32
+ * predicate per registered layout, the input to `unclaimedSurfaces`.
33
+ *
34
+ * Note it is the layouts of every REGISTERED harness, not of the DETECTED one.
35
+ * A repo carrying both `.claude/skills` and `.agents/skills` has each half
36
+ * claimed by a different harness and neither is a finding; a repo whose skills
37
+ * sit under `.ai/` has them claimed by nobody, and that is the finding (#240).
38
+ */
39
+ exports.registeredClaims = exports.REGISTERED_LAYOUTS.map((l) => (path) => (0, surface_discovery_js_1.layoutClaims)(l, path));
40
+ //# sourceMappingURL=layout-registry.js.map
@@ -20,5 +20,5 @@ import { type AnyHook } from "./core/hook-program.js";
20
20
  * @throws {HookCompileError} when the file can't be imported, or has no
21
21
  * default-exported hook program.
22
22
  */
23
- export declare function loadHook(file: string): Promise<AnyHook>;
23
+ export declare function loadHook(file: string, root?: string): Promise<AnyHook>;
24
24
  //# sourceMappingURL=load-hook.d.ts.map
package/dist/load-hook.js CHANGED
@@ -39,8 +39,8 @@ const hook_program_js_1 = require("./core/hook-program.js");
39
39
  * @throws {HookCompileError} when the file can't be imported, or has no
40
40
  * default-exported hook program.
41
41
  */
42
- async function loadHook(file) {
43
- const abs = (0, node_path_1.resolve)(process.cwd(), file);
42
+ async function loadHook(file, root = process.cwd()) {
43
+ const abs = (0, node_path_1.resolve)(root, file);
44
44
  let mod;
45
45
  try {
46
46
  mod = (await import((0, node_url_1.pathToFileURL)(abs).href));
@@ -1,4 +1,5 @@
1
1
  import type { PluginLayout } from "./core/layout.js";
2
+ import type { ExcludeSet } from "./exclude.js";
2
3
  export interface LoadedPlugin {
3
4
  /** A `.claude/settings.json`-shaped object with hooks resolved. */
4
5
  readonly settings: {
@@ -29,8 +30,55 @@ export interface LoadedPlugin {
29
30
  * the files (CLAUDE.md + skills + agents + commands) to write into the test
30
31
  * sandbox, and `warnings` for surfaces the deterministic tier can't drive. Merge
31
32
  * `settings` with any inline settings and spread `files` into the fixture.
33
+ *
34
+ * `surfaceRoots` is this function's parameter name for what the repo owner
35
+ * writes as `.vigilesrc.json#harnesses["<name>"].roots` — extra repo-relative
36
+ * bases to read `<base>/<surfaceDir>/…` from, for a repo that keeps its skills
37
+ * somewhere no harness reads by default. The config key is nested UNDER a
38
+ * harness name precisely so a root cannot be declared without saying whose
39
+ * layout reads it; this parameter receives one harness's slice of that, which
40
+ * is why it is still a bare list here. `excludes` still wins over it:
41
+ * both the per-tree check in {@link materializeSurfaces} and `readTree` drop an
42
+ * excluded path whatever declared it, so a root that is declared AND excluded is
43
+ * read exactly as if it had never been declared.
32
44
  */
33
- export declare function loadPlugin(pluginPath: string, layout: PluginLayout): LoadedPlugin;
45
+ export declare function loadPlugin(pluginPath: string, layout: PluginLayout, excludes?: ExcludeSet, surfaceRoots?: readonly string[]): LoadedPlugin;
46
+ /**
47
+ * ONE declared harness for {@link loadPlugins}: the layout to read the repo
48
+ * under, and the extra roots declared for THAT harness.
49
+ */
50
+ export interface HarnessLoad {
51
+ readonly layout: PluginLayout;
52
+ readonly roots?: readonly string[];
53
+ }
54
+ /**
55
+ * Load the repo once PER DECLARED HARNESS and merge the results into one
56
+ * `LoadedPlugin` (#240).
57
+ *
58
+ * 🔴 THE MERGE IS WHY THIS EXISTS, AND THE DEDUPLICATION IS ITS WHOLE CONTRACT.
59
+ * A repo that declares two harnesses is a repo whose grade must cover both — the
60
+ * flat `harness` array could not do that, because whichever name sat first
61
+ * decided the ONE layout everything was read under: measured on a repo with
62
+ * `AGENTS.md` + `.ai/skills/alpha/SKILL.md`, Claude-Code-first graded the skill
63
+ * and reported 0 chars of always-loaded instructions, Codex-first read
64
+ * `AGENTS.md` and reported no skill at all. Neither order produced both halves.
65
+ *
66
+ * ⚠️ AND THE OBVIOUS FIX HAS AN OBVIOUS SECOND BUG: a repo honest enough to say
67
+ * one tree serves both tools would then have that tree read twice and every
68
+ * skill in it counted twice, so declaring the truth would lower the grade. So the
69
+ * merge keys on the REAL ON-DISK PATH (`sources`), not on the materialized key:
70
+ * the first harness to claim a file keeps it, later ones skip it, and the counts
71
+ * are of files rather than of claims. Keying on the materialized key would not
72
+ * do — two layouts can reach one file under two different keys (`.ai/.agents`
73
+ * + `skills` and `.ai` + `.agents/skills` are the same directory), and that is
74
+ * exactly the case an honest dual declaration produces.
75
+ *
76
+ * Settings come from the FIRST harness that yields any, and the file-map merge
77
+ * is first-wins for the same reason: the primary harness is the one whose
78
+ * dialect the report is rendered in, so its reading of a shared path is the one
79
+ * the rest of the report is consistent with.
80
+ */
81
+ export declare function loadPlugins(pluginPath: string, harnesses: readonly HarnessLoad[], excludes?: ExcludeSet): LoadedPlugin;
34
82
  export declare function danglingRefs(root: string, layout: PluginLayout): string[];
35
83
  /**
36
84
  * Resolve the effective harness for a test/eval (arm): load the plugin if given,