vigiles 29.1.0 → 30.0.1

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 (122) hide show
  1. package/dist/adapter-conformance.d.ts +1 -1
  2. package/dist/adapter-conformance.js +106 -25
  3. package/dist/adapter-registry.d.ts +61 -14
  4. package/dist/adapter-registry.js +78 -10
  5. package/dist/adapter.d.ts +23 -2
  6. package/dist/adapter.js +13 -1
  7. package/dist/adapters/claude-code/adapter.d.ts +32 -2
  8. package/dist/adapters/claude-code/adapter.js +44 -23
  9. package/dist/adapters/claude-code/agent-runtime.js +3 -1
  10. package/dist/adapters/claude-code/dialect.js +87 -21
  11. package/dist/adapters/claude-code/effect-region.js +3 -1
  12. package/dist/adapters/claude-code/hook-protocol.js +16 -0
  13. package/dist/adapters/claude-code/instruction-chain.d.ts +25 -0
  14. package/dist/adapters/claude-code/instruction-chain.js +626 -0
  15. package/dist/adapters/claude-code/layout.d.ts +2 -2
  16. package/dist/adapters/claude-code/layout.js +42 -8
  17. package/dist/adapters/claude-code/model-access.d.ts +41 -0
  18. package/dist/adapters/claude-code/model-access.js +46 -0
  19. package/dist/adapters/claude-code/skill-reachability.d.ts +125 -0
  20. package/dist/adapters/claude-code/skill-reachability.js +111 -0
  21. package/dist/adapters/claude-code/skill-runtime.js +3 -1
  22. package/dist/adapters/codex/adapter.d.ts +39 -2
  23. package/dist/adapters/codex/adapter.js +29 -29
  24. package/dist/adapters/codex/dialect.js +11 -6
  25. package/dist/adapters/codex/eval.d.ts +10 -0
  26. package/dist/adapters/codex/eval.js +48 -1
  27. package/dist/adapters/codex/hook-protocol.d.ts +2 -1
  28. package/dist/adapters/codex/hook-protocol.js +10 -0
  29. package/dist/adapters/codex/instruction-chain.d.ts +40 -0
  30. package/dist/adapters/codex/instruction-chain.js +105 -0
  31. package/dist/adapters/codex/layout.d.ts +1 -1
  32. package/dist/adapters/codex/layout.js +41 -14
  33. package/dist/adapters/opencode/adapter.d.ts +33 -2
  34. package/dist/adapters/opencode/adapter.js +36 -36
  35. package/dist/adapters/opencode/dialect.js +2 -2
  36. package/dist/adapters/opencode/instruction-chain.d.ts +37 -0
  37. package/dist/adapters/opencode/instruction-chain.js +70 -0
  38. package/dist/adapters/opencode/layout.d.ts +19 -0
  39. package/dist/adapters/opencode/layout.js +34 -15
  40. package/dist/adoptability.d.ts +31 -1
  41. package/dist/adoptability.js +57 -0
  42. package/dist/cli-main.js +185 -102
  43. package/dist/core/adapter.d.ts +213 -61
  44. package/dist/core/compile.d.ts +2 -2
  45. package/dist/core/compile.js +57 -46
  46. package/dist/core/compose.d.ts +5 -3
  47. package/dist/core/compose.js +5 -3
  48. package/dist/core/config-schema.d.ts +14 -2
  49. package/dist/core/config-schema.js +20 -7
  50. package/dist/core/dialect.d.ts +54 -12
  51. package/dist/core/dialect.js +56 -0
  52. package/dist/core/eval-driver.d.ts +194 -0
  53. package/dist/core/eval-driver.js +3 -0
  54. package/dist/core/frontmatter-read.d.ts +10 -0
  55. package/dist/core/frontmatter-read.js +30 -3
  56. package/dist/core/guards.js +3 -1
  57. package/dist/core/hook-program.d.ts +27 -2
  58. package/dist/core/hook-program.js +29 -24
  59. package/dist/core/hook-protocol.d.ts +54 -0
  60. package/dist/core/install-reader.d.ts +18 -0
  61. package/dist/core/install-reader.js +88 -0
  62. package/dist/core/instruction-chain.d.ts +444 -0
  63. package/dist/core/instruction-chain.js +292 -0
  64. package/dist/core/instruction-weight.d.ts +96 -14
  65. package/dist/core/instruction-weight.js +65 -30
  66. package/dist/core/layout.d.ts +220 -33
  67. package/dist/core/layout.js +115 -1
  68. package/dist/core/lethal-trifecta.d.ts +12 -7
  69. package/dist/core/lethal-trifecta.js +13 -13
  70. package/dist/core/live-driver.d.ts +137 -0
  71. package/dist/core/live-driver.js +14 -0
  72. package/dist/core/markdown.d.ts +23 -0
  73. package/dist/core/markdown.js +77 -28
  74. package/dist/core/orphans.js +9 -7
  75. package/dist/core/settings-codec.d.ts +17 -0
  76. package/dist/core/settings-codec.js +56 -0
  77. package/dist/core/surface-discovery.d.ts +2 -2
  78. package/dist/core/surface-discovery.js +24 -12
  79. package/dist/core/surface-scopes.d.ts +26 -6
  80. package/dist/core/surface-scopes.js +52 -11
  81. package/dist/core/validate.js +16 -3
  82. package/dist/coverage-artifact.d.ts +3 -2
  83. package/dist/coverage-artifact.js +6 -5
  84. package/dist/eval-cache.d.ts +6 -1
  85. package/dist/eval-cache.js +11 -1
  86. package/dist/eval.d.ts +16 -108
  87. package/dist/eval.js +36 -2
  88. package/dist/harness-test.d.ts +3 -63
  89. package/dist/hook-install.d.ts +12 -1
  90. package/dist/hook-install.js +12 -1
  91. package/dist/hook-runtime.js +4 -2
  92. package/dist/hook-state-store.js +3 -1
  93. package/dist/local-files-tracked.d.ts +17 -0
  94. package/dist/local-files-tracked.js +70 -0
  95. package/dist/local-files.d.ts +62 -0
  96. package/dist/local-files.js +183 -0
  97. package/dist/observe.d.ts +3 -2
  98. package/dist/observe.js +7 -6
  99. package/dist/plugin-loader.d.ts +1 -1
  100. package/dist/plugin-loader.js +43 -36
  101. package/dist/scan-behavioral.d.ts +34 -25
  102. package/dist/scan-behavioral.js +122 -58
  103. package/dist/scan-core.js +37 -18
  104. package/dist/scan-files.d.ts +1 -1
  105. package/dist/scan-files.js +53 -33
  106. package/dist/scan-trigger-suggest.d.ts +0 -21
  107. package/dist/scan-trigger-suggest.js +0 -23
  108. package/dist/scan.d.ts +4 -4
  109. package/dist/scan.js +120 -84
  110. package/dist/skill-harness.d.ts +21 -5
  111. package/dist/skill-harness.js +29 -11
  112. package/dist/surface-discovery-fs.d.ts +2 -0
  113. package/dist/surface-discovery-fs.js +108 -6
  114. package/dist/test-coverage-files.js +24 -17
  115. package/dist/test-coverage.d.ts +9 -3
  116. package/dist/test-coverage.js +32 -22
  117. package/dist/verify-plugin-guards.js +1 -1
  118. package/package.json +1 -1
  119. package/dist/skill-reachability.d.ts +0 -68
  120. package/dist/skill-reachability.js +0 -205
  121. /package/dist/{dialect-drift.d.ts → adapters/claude-code/dialect-drift.d.ts} +0 -0
  122. /package/dist/{dialect-drift.js → adapters/claude-code/dialect-drift.js} +0 -0
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isEventFiring = isEventFiring;
4
+ /**
5
+ * Narrows the DRIVER, which an inline `d.firing.kind === "event"` does not:
6
+ * TypeScript narrows the discriminated PROPERTY, so the driver itself stays
7
+ * `HarnessLiveDriver` and passing it where an {@link EventFiringDriver} is
8
+ * wanted is still an error (measured — probe P2 in the design doc). The
9
+ * negative arm keeps its `caveat`, which is what the n/a note prints.
10
+ */
11
+ function isEventFiring(d) {
12
+ return d.firing.kind === "event";
13
+ }
14
+ //# sourceMappingURL=live-driver.js.map
@@ -10,6 +10,29 @@
10
10
  * indices line up 1:1 with the caller's line array.
11
11
  */
12
12
  export declare function fencedLineFlags(src: string): boolean[];
13
+ /**
14
+ * The document's PROSE, as CommonMark sees it: one entry per source line that
15
+ * carries author text, with fenced code, indented code, raw HTML blocks, inline
16
+ * code spans and inline HTML removed, and blank lines dropped.
17
+ *
18
+ * 🔴 WHY A CALLER MUST NOT DO THIS BY SPLITTING LINES. The module header already
19
+ * records that the naive `inFence = !inFence` toggle was copy-pasted into five
20
+ * detectors and is wrong on nested or unbalanced fences. The same applies to
21
+ * every other part of "what is the author actually saying here": a `<!-- -->`
22
+ * spanning three lines, a four-backtick block containing a bare three-backtick
23
+ * line, an indented continuation. The parser has all of it.
24
+ *
25
+ * What it also does, and what a hand-written reader gets wrong quietly: block
26
+ * parsing strips the leading INDENT, normalises CRLF, and drops trailing
27
+ * whitespace and trailing blank lines — so `@AGENTS.md`, ` @AGENTS.md`,
28
+ * `@AGENTS.md ` and a CRLF-terminated copy all arrive as the same string
29
+ * (measured against this repo's markdown-it, 2026-09-21).
30
+ *
31
+ * ⚠️ THE ONE THING IT DOES NOT NORMALISE IS A UTF-8 BOM: markdown-it hands back
32
+ * `"\uFEFF@AGENTS.md"` for a BOM-prefixed first line, so this strips it. That is
33
+ * a measurement, not a precaution.
34
+ */
35
+ export declare function proseLines(src: string): readonly string[];
13
36
  /** One fenced code block: its BODY (delimiters excluded) and where the body starts. */
14
37
  export interface FencedBlock {
15
38
  /** The block's contents, without the opening/closing fence lines. */
@@ -1,31 +1,13 @@
1
1
  "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
2
  Object.defineProperty(exports, "__esModule", { value: true });
6
3
  exports.fencedLineFlags = fencedLineFlags;
4
+ exports.proseLines = proseLines;
7
5
  exports.fencedCodeBlocks = fencedCodeBlocks;
8
6
  exports.markdownRefs = markdownRefs;
9
- /**
10
- * vigiles — the ONE markdown-structure helper.
11
- *
12
- * Every place that needs to know "is this source line inside a fenced code
13
- * block?" routes through here, so no detector hand-rolls a fence toggle again.
14
- * The naive `inFence = !inFence` toggle (copy-pasted across five detectors
15
- * before this) is WRONG on nested / unbalanced fences: a 4-backtick block
16
- * containing a bare ``` line mis-toggles, and a `##` inside the block leaks out
17
- * as a real heading (demonstrated against `adopt`'s block splitter). Backed by
18
- * markdown-it (CommonMark) — the same "use the real parser, not regex"
19
- * discipline this repo already applies to Bash (mvdan-sh), code (ast-grep),
20
- * YAML (js-yaml), and TOML (@iarna/toml). Markdown was the one structured format
21
- * still parsed by hand.
22
- *
23
- * Node-free by construction: markdown-it is pure JS with no node builtins, so
24
- * this bundles clean in the browser scan engine (reached via skill-resources).
25
- */
26
- const markdown_it_1 = __importDefault(require("markdown-it"));
7
+ const MarkdownIt = () => require("markdown-it");
27
8
  // One reusable parser; parse() is stateless across calls.
28
- const md = new markdown_it_1.default();
9
+ let mdCached = null;
10
+ const md = () => (mdCached ??= new (MarkdownIt())());
29
11
  /**
30
12
  * A SECOND parser, used only by {@link markdownRefs}, with link handling turned
31
13
  * down to "report exactly what the author wrote":
@@ -40,9 +22,16 @@ const md = new markdown_it_1.default();
40
22
  * turn "a destination this tool declines to resolve" into "no destination at
41
23
  * all". Skipping by scheme is the caller's job and it already does it.
42
24
  */
43
- const mdRefs = new markdown_it_1.default();
44
- mdRefs.normalizeLink = (url) => url;
45
- mdRefs.validateLink = () => true;
25
+ let mdRefsCached = null;
26
+ const mdRefs = () => {
27
+ if (mdRefsCached === null) {
28
+ const parser = new (MarkdownIt())();
29
+ parser.normalizeLink = (url) => url;
30
+ parser.validateLink = () => true;
31
+ mdRefsCached = parser;
32
+ }
33
+ return mdRefsCached;
34
+ };
46
35
  /**
47
36
  * A boolean per source line (0-based): `true` when the line lies inside a fenced
48
37
  * code block (` ``` ` or `~~~`), the delimiter lines included — matching the
@@ -57,7 +46,7 @@ mdRefs.validateLink = () => true;
57
46
  function fencedLineFlags(src) {
58
47
  const lineCount = src.split("\n").length;
59
48
  const flags = new Array(lineCount).fill(false);
60
- for (const tok of md.parse(src, {})) {
49
+ for (const tok of md().parse(src, {})) {
61
50
  // Only real ``` / ~~~ fences (a block-level token); markdown-it's `map` is
62
51
  // a [start, end) 0-based line range covering the delimiters + body.
63
52
  if (tok.type !== "fence" || tok.map === null)
@@ -68,6 +57,66 @@ function fencedLineFlags(src) {
68
57
  }
69
58
  return flags;
70
59
  }
60
+ /**
61
+ * A THIRD parser, with `html: true`, used only by {@link proseLines}.
62
+ *
63
+ * The default `md` above has HTML disabled, so `<!-- maintainer note -->` comes
64
+ * back as ordinary paragraph TEXT rather than as an `html_block` token — which
65
+ * would put an author's commented-out line into the prose a caller is reading.
66
+ * Measured: with the default parser, `"<!-- note -->\n\n@AGENTS.md"` yields two
67
+ * prose lines; with this one, one. Enabling html on the shared parser would
68
+ * change what every existing caller sees, so this is a sibling rather than a
69
+ * setting, exactly as `mdRefs` is.
70
+ */
71
+ let mdProseCached = null;
72
+ const mdProse = () => (mdProseCached ??= new (MarkdownIt())({ html: true }));
73
+ /**
74
+ * The document's PROSE, as CommonMark sees it: one entry per source line that
75
+ * carries author text, with fenced code, indented code, raw HTML blocks, inline
76
+ * code spans and inline HTML removed, and blank lines dropped.
77
+ *
78
+ * 🔴 WHY A CALLER MUST NOT DO THIS BY SPLITTING LINES. The module header already
79
+ * records that the naive `inFence = !inFence` toggle was copy-pasted into five
80
+ * detectors and is wrong on nested or unbalanced fences. The same applies to
81
+ * every other part of "what is the author actually saying here": a `<!-- -->`
82
+ * spanning three lines, a four-backtick block containing a bare three-backtick
83
+ * line, an indented continuation. The parser has all of it.
84
+ *
85
+ * What it also does, and what a hand-written reader gets wrong quietly: block
86
+ * parsing strips the leading INDENT, normalises CRLF, and drops trailing
87
+ * whitespace and trailing blank lines — so `@AGENTS.md`, ` @AGENTS.md`,
88
+ * `@AGENTS.md ` and a CRLF-terminated copy all arrive as the same string
89
+ * (measured against this repo's markdown-it, 2026-09-21).
90
+ *
91
+ * ⚠️ THE ONE THING IT DOES NOT NORMALISE IS A UTF-8 BOM: markdown-it hands back
92
+ * `"\uFEFF@AGENTS.md"` for a BOM-prefixed first line, so this strips it. That is
93
+ * a measurement, not a precaution.
94
+ */
95
+ function proseLines(src) {
96
+ const out = [];
97
+ for (const tok of mdProse().parse(src, {})) {
98
+ if (tok.type !== "inline")
99
+ continue;
100
+ let line = "";
101
+ for (const child of tok.children ?? []) {
102
+ if (child.type === "softbreak" || child.type === "hardbreak") {
103
+ out.push(line);
104
+ line = "";
105
+ continue;
106
+ }
107
+ // A code span and an inline `<!-- -->` are not the author's prose; the
108
+ // corpus that motivated this is full of `@dataclass`-shaped text inside
109
+ // them, and none of it names a file.
110
+ if (child.type === "code_inline" || child.type === "html_inline")
111
+ continue;
112
+ line += child.content;
113
+ }
114
+ out.push(line);
115
+ }
116
+ return out
117
+ .map((line) => line.replace(/^\uFEFF/, ""))
118
+ .filter((line) => line !== "");
119
+ }
71
120
  /**
72
121
  * Every fenced code block in `src`, in document order — the same parser
73
122
  * {@link fencedLineFlags} uses, for callers that want the CONTENTS rather than a
@@ -94,7 +143,7 @@ function fencedLineFlags(src) {
94
143
  */
95
144
  function fencedCodeBlocks(src) {
96
145
  const out = [];
97
- for (const tok of md.parse(src, {})) {
146
+ for (const tok of md().parse(src, {})) {
98
147
  if (tok.type !== "fence" || tok.map === null)
99
148
  continue;
100
149
  // `content` is the body only; `map[0]` is the OPENING delimiter's 0-based
@@ -146,7 +195,7 @@ function markdownRefs(src) {
146
195
  // lines of a real corpus are that.
147
196
  if (fenced[i] || (!line.includes("`") && !line.includes("](")))
148
197
  continue;
149
- for (const tok of mdRefs.parseInline(line, {})) {
198
+ for (const tok of mdRefs().parseInline(line, {})) {
150
199
  refsInInline(tok.children ?? [], i + 1, out);
151
200
  }
152
201
  }
@@ -40,8 +40,8 @@ const SKILL_FILE = "SKILL.md";
40
40
  /**
41
41
  * Files the HARNESS loads directly — its instruction file
42
42
  * (`layout.instructionFile`, e.g. `CLAUDE.md` / `AGENTS.md`), a skill
43
- * (`SKILL.md`), a subagent (`<agentDir>/*.md`), or a slash command
44
- * (`<commandDir>/*.md`) — are load-bearing by their NAME/LOCATION, not because
43
+ * (`SKILL.md`), a subagent (`<surfaces.agent>/*.md`), or a slash command
44
+ * (`<surfaces.command>/*.md`) — are load-bearing by their NAME/LOCATION, not because
45
45
  * another `.md` links to them. They are categorically NOT docs, so they are
46
46
  * never orphans even when `orphans.include` broadens to the whole repo.
47
47
  *
@@ -61,16 +61,18 @@ function isHarnessLoadedFile(path, layouts) {
61
61
  if (base === layout.instructionFile)
62
62
  return true;
63
63
  // Subagent / slash-command surfaces live at a REAL surface root — the repo
64
- // root, the user-surface root (e.g. `.claude/`), or the materialize root —
65
- // NOT any nested dir that merely shares the name. A doc under `docs/prompts/`
66
- // is documentation, not Codex's `prompts` command surface.
64
+ // root or the user-surface root (e.g. `.claude/`) — NOT any nested dir that
65
+ // merely shares the name. A doc under `docs/prompts/` is documentation, not
66
+ // Codex's `prompts` command surface. (This listed the user-surface root and
67
+ // the materialize root separately; they are one field now, so the set it
68
+ // built is unchanged and can no longer hold two different values.)
67
69
  const roots = [
68
70
  "",
69
- ...[layout.userSurfaceRoot, layout.materializeRoot]
71
+ ...[layout.userSurfaceRoot]
70
72
  .filter((r) => !!r)
71
73
  .map((r) => `${r}/`),
72
74
  ];
73
- for (const dir of [layout.agentDir, layout.commandDir]) {
75
+ for (const dir of [layout.surfaces.agent, layout.surfaces.command]) {
74
76
  if (dir && roots.some((r) => norm.startsWith(`${r}${dir}/`))) {
75
77
  return true;
76
78
  }
@@ -0,0 +1,17 @@
1
+ export interface SettingsCodec {
2
+ /**
3
+ * For messages and the generated JSON Schema only. The core never branches
4
+ * on it — that is the whole reason this is a codec and not an enum, so a
5
+ * branch on this field would put the defect straight back.
6
+ */
7
+ readonly label: string;
8
+ /** Throws on malformed text; every caller today already catches. */
9
+ parse(text: string): Record<string, unknown>;
10
+ /** The inverse, with the trailing newline the harness's own tooling writes. */
11
+ render(value: Record<string, unknown>): string;
12
+ }
13
+ /** JSON settings (`.claude/settings.json`, `opencode.json`). */
14
+ export declare const jsonSettingsCodec: SettingsCodec;
15
+ /** TOML settings (Codex's `.codex/config.toml`). */
16
+ export declare const tomlSettingsCodec: SettingsCodec;
17
+ //# sourceMappingURL=settings-codec.d.ts.map
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.tomlSettingsCodec = exports.jsonSettingsCodec = void 0;
4
+ /**
5
+ * `SettingsCodec` — the BYTES-TO-VALUE half of a harness's settings file, and
6
+ * the two encodings vigiles ships.
7
+ *
8
+ * 🔴 IT REPLACES A TWO-VALUED ENUM IN THE CORE. `PluginLayout.settingsFormat`
9
+ * was `"json" | "toml"`, and nine call sites branched on it: six about the
10
+ * ENCODING (parse a manifest, parse a settings file, serialize a merged
11
+ * config) and three about the hooks-entry SHAPE, which is a different question
12
+ * entirely and now lives on `HookProtocol`. Every one of those branches was a
13
+ * per-value `if`, so a third-party adapter whose settings are YAML could
14
+ * declare nothing the core would honour: the type had exactly two inhabitants
15
+ * and they were both ours.
16
+ *
17
+ * A codec closes that by construction — there is no enum left to be outside
18
+ * of. The grep that checks it is in the design: `'"json" | "toml"'` over
19
+ * `src/` must return only the adapter files that NAME their encoding, never a
20
+ * core module branching on one.
21
+ *
22
+ * ⚠️ WHAT A CODEC IS NOT: it does not know what the value MEANS. Whether a
23
+ * hooks entry nests `{matcher, hooks: [{type, command}]}` or is flat
24
+ * `{matcher, command}` is the harness's SHAPE, and it is `HookProtocol`'s —
25
+ * see `registration` there. Reading and writing an encoding, and reading and
26
+ * writing a shape, were the two halves `settingsFormat` was standing in for.
27
+ */
28
+ /**
29
+ * 🔴 `@iarna/toml` IS REQUIRED LAZILY, AND THAT IS A MEASUREMENT, NOT A STYLE.
30
+ * The same wrapper exists in `hook-install.ts` and `core/hook-program.ts` with
31
+ * the reason recorded: a top-level import puts the TOML parser into the graph
32
+ * of every hook decision, because the hook runtime reaches those modules —
33
+ * measured 2026-09-08 at 56 ms per spawn. A codec that every `PluginLayout`
34
+ * now carries is reached from strictly MORE places than either of them, so a
35
+ * top-level import here would undo that fix and widen it. `tsc` lowers these
36
+ * to CommonJS, so the `require` genuinely does not run until a codec method is
37
+ * called, and a layout that is only READ (the common case) costs nothing.
38
+ */
39
+ const toml = () => require("@iarna/toml");
40
+ /** JSON settings (`.claude/settings.json`, `opencode.json`). */
41
+ exports.jsonSettingsCodec = {
42
+ label: "json",
43
+ parse: (text) => JSON.parse(text),
44
+ render: (value) => JSON.stringify(value, null, 2) + "\n",
45
+ };
46
+ /** TOML settings (Codex's `.codex/config.toml`). */
47
+ exports.tomlSettingsCodec = {
48
+ label: "toml",
49
+ parse: (text) => toml().parse(text),
50
+ // `trimEnd` before the newline: `stringifyToml` already ends with one, and
51
+ // two would be a diff on every write against a file the harness itself wrote.
52
+ render: (value) => toml()
53
+ .stringify(value)
54
+ .trimEnd() + "\n",
55
+ };
56
+ //# sourceMappingURL=settings-codec.js.map
@@ -124,8 +124,8 @@ export declare function discoverSurfaces(paths: readonly string[]): readonly Dis
124
124
  * (as `codexLayout` just did, `.codex/skills` → `.agents/skills`) moves its claim
125
125
  * with it and the two cannot drift.
126
126
  *
127
- * ⚠️ `materializeRoot` goes through {@link located} because `codexLayout` sets it
128
- * to `""` and a directory prefix of `""` is meaningless. It is DEFENCE IN DEPTH,
127
+ * ⚠️ The materialize prefix goes through {@link located} because `codexLayout`
128
+ * has none (`""`) and a directory prefix of `""` is meaningless. It is DEFENCE IN DEPTH,
129
129
  * not the load-bearing guard, and the difference was MEASURED rather than
130
130
  * assumed: removing this filter alone leaves `layoutClaims(codexLayout,
131
131
  * "src/index.ts")` at `false`, because the boundary form `path.startsWith(`${d}/`)`
@@ -168,8 +168,8 @@ function located(dir) {
168
168
  * (as `codexLayout` just did, `.codex/skills` → `.agents/skills`) moves its claim
169
169
  * with it and the two cannot drift.
170
170
  *
171
- * ⚠️ `materializeRoot` goes through {@link located} because `codexLayout` sets it
172
- * to `""` and a directory prefix of `""` is meaningless. It is DEFENCE IN DEPTH,
171
+ * ⚠️ The materialize prefix goes through {@link located} because `codexLayout`
172
+ * has none (`""`) and a directory prefix of `""` is meaningless. It is DEFENCE IN DEPTH,
173
173
  * not the load-bearing guard, and the difference was MEASURED rather than
174
174
  * assumed: removing this filter alone leaves `layoutClaims(codexLayout,
175
175
  * "src/index.ts")` at `false`, because the boundary form `path.startsWith(`${d}/`)`
@@ -186,9 +186,9 @@ function layoutLocations(layout) {
186
186
  if (d !== null)
187
187
  dirs.add(d);
188
188
  };
189
- add(located(layout.materializeRoot));
189
+ add(located((0, layout_js_1.materializePrefix)(layout)));
190
190
  add(user);
191
- for (const s of layout.surfaceDirs) {
191
+ for (const s of (0, layout_js_1.surfaceDirs)(layout)) {
192
192
  add(located(s));
193
193
  if (user !== null)
194
194
  add(located(`${user}/${s}`));
@@ -204,6 +204,11 @@ function layoutLocations(layout) {
204
204
  layout.settingsPath,
205
205
  layout.hooksConventionPath,
206
206
  ]) {
207
+ // `hooksConventionPath` is optional now — a harness whose hooks are code
208
+ // modules has none. Absent contributes no dir and no file, which is what
209
+ // the old `""` sentinel was filtered into below anyway.
210
+ if (p === undefined)
211
+ continue;
207
212
  const at = p.lastIndexOf("/");
208
213
  if (at > 0)
209
214
  add(p.slice(0, at));
@@ -216,7 +221,7 @@ function layoutLocations(layout) {
216
221
  layout.manifestPath,
217
222
  layout.settingsPath,
218
223
  layout.hooksConventionPath,
219
- ].filter((f) => f !== ""),
224
+ ].filter((f) => f !== undefined && f !== ""),
220
225
  };
221
226
  }
222
227
  /**
@@ -269,7 +274,7 @@ function declaredRootClaims(layout, roots, path) {
269
274
  function declaredRootDirs(layout, roots) {
270
275
  const out = [];
271
276
  for (const r of roots)
272
- for (const surface of layout.surfaceDirs) {
277
+ for (const surface of (0, layout_js_1.surfaceDirs)(layout)) {
273
278
  const dir = located(`${r}/${surface}`);
274
279
  if (dir !== null && !out.includes(dir))
275
280
  out.push(dir);
@@ -365,7 +370,10 @@ function plural(kind, n) {
365
370
  * reader to move their skills somewhere no harness reads.
366
371
  */
367
372
  function knownHomes(layouts, kind) {
368
- const dirOf = (l) => ({ skill: l.skillDir, agent: l.agentDir, command: l.commandDir })[kind];
373
+ // `DiscoveredKind` and `SurfaceKind` are the same three words; the cast is the
374
+ // one place they are related, and it is here rather than in the port because a
375
+ // discovered kind is a fact about a PATH the domain found, not about a layout.
376
+ const dirOf = (l) => l.surfaces[kind];
369
377
  const homes = new Set();
370
378
  for (const l of layouts) {
371
379
  const dir = located(dirOf(l));
@@ -410,15 +418,19 @@ function unclaimedSurfaceFindings(paths, layouts, declared) {
410
418
  for (const scope of declared ?? [])
411
419
  if (scope.roots.length > 0)
412
420
  claimers.push((path) => declaredRootClaims(scope.layout, scope.roots, path));
421
+ // The worked EXAMPLE config below names a harness, and it used to name a
422
+ // FIXED one — `claude-code`, spelled into the core, in a diagnostic shown to
423
+ // a repo that may target something else entirely. `layouts` was already in
424
+ // scope and already knew which harness the audit ran under; the example now
425
+ // reads the name off it. Empty `layouts` yields a placeholder rather than an
426
+ // invented name: an example that cannot be copied is better than one that
427
+ // names the wrong harness.
428
+ const exampleHarness = layouts[0]?.name ?? "<harness>";
413
429
  return unclaimedDirs(unclaimedSurfaces(discoverSurfaces(paths), claimers)).map((d) => ({
414
430
  ...d,
415
431
  message: `${d.dir}/ holds ${plural(d.kind, d.count)} that no harness vigiles knows about reads, ` +
416
432
  `so none of it is in this grade. Three ways out: keep it where it is and say so in ` +
417
- // A worked EXAMPLE config in a diagnostic, and real debt: it names a
418
- // harness the repo being audited may not even target, while `layouts` is
419
- // already in scope here and knows which one it does.
420
- // eslint-disable-next-line local/no-harness-names -- example config text
421
- `.vigilesrc.json (\`{"harnesses":{"claude-code":{"roots":["${d.root === "" ? "." : d.root}"]}}}\` ` +
433
+ `.vigilesrc.json (\`{"harnesses":{"${exampleHarness}":{"roots":["${d.root === "" ? "." : d.root}"]}}}\` ` +
422
434
  `— see docs/configuration.md), audit it on its own ` +
423
435
  `(\`vigiles audit ${d.root === "" ? "." : d.root}\`), or move it somewhere a harness ` +
424
436
  `loads from (${knownHomes(layouts, d.kind)
@@ -5,7 +5,7 @@
5
5
  * 🔴 THIS EXISTS BECAUSE THE LOADER USED TO CHOOSE. It read the repo-root
6
6
  * `skills/` **or** the project-level `.claude/skills/` — never both — and
7
7
  * materialized whichever it picked under the SAME canonical
8
- * `<materializeRoot>/<surface>/…` key. Two real files, one key: the loser was
8
+ * `<materializePrefix>/<surface>/…` key. Two real files, one key: the loser was
9
9
  * never read, and the winner's content sat under the loser's name. Measured
10
10
  * 2026-08-18 on `nyldn/claude-octopus` (pinned corpus): **50 skill names exist in
11
11
  * both `skills/` and `.claude/skills/`, and all 50 pairs differ** — the `.claude/`
@@ -31,7 +31,7 @@
31
31
  * and call THIS for the decision, so the pair that this repo has repeatedly been
32
32
  * bitten by fixing on one side only cannot disagree about scoping.
33
33
  */
34
- import type { PluginLayout } from "./layout.js";
34
+ import { type PluginLayout } from "./layout.js";
35
35
  /**
36
36
  * One discovery level, and the prefix its files are materialized under.
37
37
  *
@@ -60,6 +60,20 @@ export interface SurfaceScope {
60
60
  export type SurfaceSource = {
61
61
  readonly kind: "single-skill";
62
62
  readonly skillName: string;
63
+ /**
64
+ * The layout's skill dir, CARRIED rather than re-read by the consumer.
65
+ *
66
+ * 🔴 IT USED TO BE RE-DERIVED AT BOTH CONSUMERS, AND THE BRANCH THAT
67
+ * FOLLOWED WAS UNREACHABLE. `layout.surfaces.skill` is optional, so each
68
+ * of `plugin-loader.ts` and `scan-files.ts` narrowed it again and wrote a
69
+ * `return` for the `undefined` case — a case this variant is never
70
+ * constructed in, since the test below is the condition for making one.
71
+ * Two dead returns, in two engines that must agree, plus a comment in each
72
+ * explaining why the code beneath it cannot run. Carrying the value is the
73
+ * same construction the rest of this port uses: keep one copy, where it is
74
+ * KNOWN, instead of re-deriving it where it is not.
75
+ */
76
+ readonly skillDir: string;
63
77
  } | {
64
78
  readonly kind: "scopes";
65
79
  readonly scopes: readonly SurfaceScope[];
@@ -110,7 +124,7 @@ export declare function normalizeSurfaceRoots(roots: readonly string[] | undefin
110
124
  * Classify the target and list every scope to read, HIGHEST-PRECEDENCE FIRST.
111
125
  *
112
126
  * Precedence decides only one thing: which scope keeps the canonical
113
- * `<materializeRoot>/…` key. The project scope takes it, because that key IS
127
+ * `<materializePrefix>/…` key. The project scope takes it, because that key IS
114
128
  * where a project skill lives — `.claude/skills/deploy/SKILL.md` is loaded from
115
129
  * exactly that path and answers to `/deploy`. A plugin scope keeps its own real
116
130
  * location (`skills/deploy/SKILL.md`), which is likewise where the harness reads
@@ -118,12 +132,18 @@ export declare function normalizeSurfaceRoots(roots: readonly string[] | undefin
118
132
  *
119
133
  * 🔴 THE COLLISION IS STRUCTURAL, NOT CHECKED. Only the FIRST scope is relocated;
120
134
  * every later one keeps `base` as its prefix. Since the first scope is the only
121
- * one that can produce a `<materializeRoot>/…` key, and every other prefix is a
135
+ * one that can produce a `<materializePrefix>/…` key, and every other prefix is a
122
136
  * distinct real directory, two scopes cannot mint the same key — there is no
123
137
  * ordering, no "if already taken", and no last-write-wins to get wrong.
124
138
  * {@link assertDistinctScopeKeys} is the LOUD backstop for a future
125
- * `PluginLayout` that breaks the premise (e.g. one naming `.claude` as BOTH its
126
- * `materializeRoot` and a second scope's base).
139
+ * `PluginLayout` that breaks the premise (e.g. one naming `.claude` as BOTH the
140
+ * materialize prefix and a second scope's base).
141
+ *
142
+ * ⚠️ HALF OF WHAT IT GUARDED IS NOW UNREPRESENTABLE. The premise used to have
143
+ * two ways to break: a declared root colliding with the prefix, and a layout
144
+ * whose `materializeRoot` differed from its `userSurfaceRoot` at all. The second
145
+ * field is gone — the prefix IS `userSurfaceRoot ?? ""` — so only the first
146
+ * remains, and this backstop is kept for it.
127
147
  */
128
148
  export declare function surfaceSource(layout: PluginLayout, probe: SurfaceProbe): SurfaceSource;
129
149
  /** The `LoadedPlugin.files` key for one file under one scope. */
@@ -5,6 +5,40 @@ exports.surfaceSource = surfaceSource;
5
5
  exports.scopeKey = scopeKey;
6
6
  exports.assertDistinctScopeKeys = assertDistinctScopeKeys;
7
7
  exports.multiScopeWarning = multiScopeWarning;
8
+ /**
9
+ * WHERE a repo's model surfaces (skills/agents/commands) live — and the key each
10
+ * one is materialized under.
11
+ *
12
+ * 🔴 THIS EXISTS BECAUSE THE LOADER USED TO CHOOSE. It read the repo-root
13
+ * `skills/` **or** the project-level `.claude/skills/` — never both — and
14
+ * materialized whichever it picked under the SAME canonical
15
+ * `<materializePrefix>/<surface>/…` key. Two real files, one key: the loser was
16
+ * never read, and the winner's content sat under the loser's name. Measured
17
+ * 2026-08-18 on `nyldn/claude-octopus` (pinned corpus): **50 skill names exist in
18
+ * both `skills/` and `.claude/skills/`, and all 50 pairs differ** — the `.claude/`
19
+ * copies carry multi-line unquoted `description:` blocks that a strict YAML
20
+ * loader rejects. vigiles reported those fifty skills as clean without ever
21
+ * having opened the files it named.
22
+ *
23
+ * The vendor settles it — Claude Code loads BOTH, in two namespaces:
24
+ *
25
+ * > Plugin skills use a `plugin-name:skill-name` namespace, so they can't
26
+ * > conflict with other levels.
27
+ * > For example, `my-plugin/skills/deploy/SKILL.md` becomes `/my-plugin:deploy`
28
+ * > and loads alongside a `deploy` skill in your project's `.claude/skills/`.
29
+ * > — https://code.claude.com/docs/en/skills § "Where skills live"
30
+ *
31
+ * So "pick one" was never a tie-break to get right; it was a question that has no
32
+ * answer, asked because the key shape forced one. The fix removes the question:
33
+ * every scope present is read, and **a scope's key prefix is derived from the
34
+ * scope, not from a winner**, so two files can no longer claim one key.
35
+ *
36
+ * Node-free and IO-free on purpose: the disk loader (`src/plugin-loader.ts`) and
37
+ * the browser file-map twin (`src/scan-files.ts`) each probe their own storage
38
+ * and call THIS for the decision, so the pair that this repo has repeatedly been
39
+ * bitten by fixing on one side only cannot disagree about scoping.
40
+ */
41
+ const layout_js_1 = require("./layout.js");
8
42
  /**
9
43
  * The repo owner's `.vigilesrc.json#harnesses["<name>"].roots`, normalized — or
10
44
  * dropped. (The flat top-level `surfaceRoots` key this once read was removed in
@@ -44,7 +78,7 @@ function normalizeSurfaceRoots(roots) {
44
78
  * Classify the target and list every scope to read, HIGHEST-PRECEDENCE FIRST.
45
79
  *
46
80
  * Precedence decides only one thing: which scope keeps the canonical
47
- * `<materializeRoot>/…` key. The project scope takes it, because that key IS
81
+ * `<materializePrefix>/…` key. The project scope takes it, because that key IS
48
82
  * where a project skill lives — `.claude/skills/deploy/SKILL.md` is loaded from
49
83
  * exactly that path and answers to `/deploy`. A plugin scope keeps its own real
50
84
  * location (`skills/deploy/SKILL.md`), which is likewise where the harness reads
@@ -52,29 +86,36 @@ function normalizeSurfaceRoots(roots) {
52
86
  *
53
87
  * 🔴 THE COLLISION IS STRUCTURAL, NOT CHECKED. Only the FIRST scope is relocated;
54
88
  * every later one keeps `base` as its prefix. Since the first scope is the only
55
- * one that can produce a `<materializeRoot>/…` key, and every other prefix is a
89
+ * one that can produce a `<materializePrefix>/…` key, and every other prefix is a
56
90
  * distinct real directory, two scopes cannot mint the same key — there is no
57
91
  * ordering, no "if already taken", and no last-write-wins to get wrong.
58
92
  * {@link assertDistinctScopeKeys} is the LOUD backstop for a future
59
- * `PluginLayout` that breaks the premise (e.g. one naming `.claude` as BOTH its
60
- * `materializeRoot` and a second scope's base).
93
+ * `PluginLayout` that breaks the premise (e.g. one naming `.claude` as BOTH the
94
+ * materialize prefix and a second scope's base).
95
+ *
96
+ * ⚠️ HALF OF WHAT IT GUARDED IS NOW UNREPRESENTABLE. The premise used to have
97
+ * two ways to break: a declared root colliding with the prefix, and a layout
98
+ * whose `materializeRoot` differed from its `userSurfaceRoot` at all. The second
99
+ * field is gone — the prefix IS `userSurfaceRoot ?? ""` — so only the first
100
+ * remains, and this backstop is kept for it.
61
101
  */
62
102
  function surfaceSource(layout, probe) {
63
- if (layout.skillDir && probe.hasRootSkillFile) {
64
- return { kind: "single-skill", skillName: probe.skillName };
103
+ const skillDir = layout.surfaces.skill;
104
+ if (skillDir !== undefined && probe.hasRootSkillFile) {
105
+ return { kind: "single-skill", skillName: probe.skillName, skillDir };
65
106
  }
66
107
  const scopes = [];
67
108
  if (layout.userSurfaceRoot !== undefined && probe.userHasLoadable) {
68
109
  scopes.push({
69
110
  base: layout.userSurfaceRoot,
70
- materializeUnder: layout.materializeRoot,
111
+ materializeUnder: (0, layout_js_1.materializePrefix)(layout),
71
112
  label: "project",
72
113
  });
73
114
  }
74
115
  if (probe.rootHasLoadable || probe.isPluginShaped) {
75
116
  scopes.push({
76
117
  base: "",
77
- materializeUnder: scopes.length === 0 ? layout.materializeRoot : "",
118
+ materializeUnder: scopes.length === 0 ? (0, layout_js_1.materializePrefix)(layout) : "",
78
119
  label: "plugin",
79
120
  });
80
121
  }
@@ -84,7 +125,7 @@ function surfaceSource(layout, probe) {
84
125
  if (scopes.length === 0 && layout.userSurfaceRoot !== undefined) {
85
126
  scopes.push({
86
127
  base: layout.userSurfaceRoot,
87
- materializeUnder: layout.materializeRoot,
128
+ materializeUnder: (0, layout_js_1.materializePrefix)(layout),
88
129
  label: "project",
89
130
  });
90
131
  }
@@ -93,7 +134,7 @@ function surfaceSource(layout, probe) {
93
134
  // declaration says WHERE, the layout still says what a surface is and how it
94
135
  // is read. A root already serving as a scope's base is skipped (declaring
95
136
  // `.claude` to Claude Code is a no-op, not a second reading of one tree), and
96
- // so is one equal to `materializeRoot` — that key belongs to the scope holding
137
+ // so is one equal to the materialize prefix — that key belongs to the scope holding
97
138
  // it, and two scopes minting one prefix is what {@link assertDistinctScopeKeys}
98
139
  // exists to refuse. Each keeps its OWN base as the key prefix, like a non-first
99
140
  // plugin scope, so nothing is relocated on top of anything.
@@ -107,7 +148,7 @@ function surfaceSource(layout, probe) {
107
148
  // `plugin-loader.test.ts` ("an EMPTY declared root does not cancel the project
108
149
  // scope"), which asserts the full key list both before and after filling it.
109
150
  for (const base of probe.declaredRoots ?? []) {
110
- if (base === layout.materializeRoot)
151
+ if (base === (0, layout_js_1.materializePrefix)(layout))
111
152
  continue;
112
153
  if (scopes.some((sc) => sc.base === base))
113
154
  continue;
@@ -30,6 +30,7 @@ const cosmiconfig_1 = require("cosmiconfig");
30
30
  // sources — it failed every suite that touches `loadConfig`. A deferral that
31
31
  // only works in one of the two worlds the code runs in is not one line of
32
32
  // hygiene, it is a second module system.
33
+ const dialect_js_1 = require("./dialect.js");
33
34
  const config_schema_js_1 = require("./config-schema.js");
34
35
  // ---------------------------------------------------------------------------
35
36
  // Constants & regex
@@ -44,7 +45,12 @@ const CHECKBOX_RE = /^- \[([ xX])\]\s+(.+)$/;
44
45
  // The instruction filenames vigiles recognizes when no dialect is injected — a
45
46
  // validator-level default, not a harness dialect (the concrete dialects live in
46
47
  // the adapters; an injected ValidateOptions.dialect overrides this).
47
- const INSTRUCTION_FILES = ["CLAUDE.md", "AGENTS.md"];
48
+ //
49
+ // 🔴 READ FROM `core/dialect.ts`, NOT RESTATED HERE, and NOT derived from the
50
+ // adapter registry: `core ⊄ adapter`. The header on
51
+ // `DEFAULT_INSTRUCTION_TARGETS` carries the measurement behind that refusal and
52
+ // names the test that keeps this list and the registry in agreement instead.
53
+ const INSTRUCTION_FILES = dialect_js_1.DEFAULT_INSTRUCTION_TARGETS;
48
54
  // The default instruction file to validate when no config names one.
49
55
  const DEFAULT_FILES = [INSTRUCTION_FILES[0]];
50
56
  /**
@@ -130,8 +136,15 @@ function loadConfig(searchFrom, { onInvalid = "throw" } = {}) {
130
136
  const problems = [];
131
137
  if (typeof raw === "object" && !Array.isArray(raw)) {
132
138
  const present = config_schema_js_1.REPLACED_KEYS.filter((k) => k.key in raw);
133
- if (present.length > 0)
134
- problems.push((0, config_schema_js_1.replacedKeyMessage)(present));
139
+ if (present.length > 0) {
140
+ // The names the user's OWN removed `harness` key held, so the worked
141
+ // example in the message shows their migration rather than a fixed pair
142
+ // of names spelled into the core. A non-string entry is dropped: this is
143
+ // a config we have already refused, so the message must survive garbage.
144
+ const legacy = raw.harness;
145
+ const names = (Array.isArray(legacy) ? legacy : legacy === undefined ? [] : [legacy]).filter((n) => typeof n === "string" && n.length > 0);
146
+ problems.push((0, config_schema_js_1.replacedKeyMessage)(present, names));
147
+ }
135
148
  }
136
149
  if (problems.length === 0) {
137
150
  const parsed = config_schema_js_1.vigilesConfigSchema.safeParse(raw);
@@ -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),