vigiles 33.1.0 → 33.3.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 (66) hide show
  1. package/README.md +3 -2
  2. package/dist/adapter-conformance.js +14 -10
  3. package/dist/adapter.d.ts +3 -1
  4. package/dist/adapters/claude-code/layout.js +3 -0
  5. package/dist/adapters/claude-code/output-style.d.ts +3 -0
  6. package/dist/adapters/claude-code/output-style.js +81 -0
  7. package/dist/adapters/claude-code/replies.d.ts +14 -0
  8. package/dist/adapters/claude-code/replies.js +78 -0
  9. package/dist/adapters/claude-code/runtime.js +70 -0
  10. package/dist/adapters/claude-code/skill-reachability.d.ts +42 -21
  11. package/dist/adapters/claude-code/skill-reachability.js +91 -28
  12. package/dist/audit-report.d.ts +5 -0
  13. package/dist/audit-report.js +29 -20
  14. package/dist/audit-report.template.html +25 -25
  15. package/dist/audit-score.js +2 -2
  16. package/dist/check.d.ts +20 -0
  17. package/dist/check.js +87 -2
  18. package/dist/cli-main.js +249 -30
  19. package/dist/core/adapter.d.ts +7 -0
  20. package/dist/core/config-schema.d.ts +9 -0
  21. package/dist/core/config-schema.js +1 -0
  22. package/dist/core/eval-driver.d.ts +10 -0
  23. package/dist/core/frontmatter-read.d.ts +31 -0
  24. package/dist/core/frontmatter-read.js +4 -2
  25. package/dist/core/harness-driver.d.ts +2 -0
  26. package/dist/core/install-reader.js +15 -0
  27. package/dist/core/layout.d.ts +17 -0
  28. package/dist/core/layout.js +17 -0
  29. package/dist/core/output-style.d.ts +84 -0
  30. package/dist/core/output-style.js +90 -0
  31. package/dist/core/rule-meta.d.ts +1 -1
  32. package/dist/core/rule-meta.js +8 -0
  33. package/dist/core/run-env.d.ts +50 -0
  34. package/dist/core/run-env.js +51 -0
  35. package/dist/core/runtime.d.ts +44 -0
  36. package/dist/core/skill-link-target.d.ts +20 -0
  37. package/dist/core/skill-link-target.js +33 -0
  38. package/dist/core/types.d.ts +2 -0
  39. package/dist/coverage-evidence.d.ts +42 -0
  40. package/dist/coverage-evidence.js +39 -0
  41. package/dist/eval.d.ts +65 -42
  42. package/dist/eval.js +116 -120
  43. package/dist/harness-test.d.ts +65 -1
  44. package/dist/harness-test.js +114 -22
  45. package/dist/optimize.js +1 -5
  46. package/dist/output-style-arms.d.ts +27 -0
  47. package/dist/output-style-arms.js +51 -0
  48. package/dist/plugin-declaration.d.ts +6 -6
  49. package/dist/plugin-declaration.js +6 -6
  50. package/dist/scan-files.js +1 -1
  51. package/dist/scan.d.ts +6 -0
  52. package/dist/scan.js +14 -4
  53. package/dist/score-core.d.ts +3 -0
  54. package/dist/score-core.js +5 -1
  55. package/dist/setup-plan.d.ts +50 -10
  56. package/dist/setup-plan.js +103 -68
  57. package/dist/skill-links.d.ts +183 -0
  58. package/dist/skill-links.js +255 -0
  59. package/dist/test-coverage-files.d.ts +3 -1
  60. package/dist/test-coverage-files.js +20 -6
  61. package/dist/test-coverage.d.ts +18 -1
  62. package/dist/test-coverage.js +101 -15
  63. package/dist/test.d.ts +3 -1
  64. package/dist/test.js +8 -2
  65. package/dist/vigilesrc.schema.json +134 -0
  66. package/package.json +1 -1
package/README.md CHANGED
@@ -235,8 +235,9 @@ The **hooks** keep it honest in-loop — nudging the agent to tag a linter-rule
235
235
 
236
236
  - **Both lint and test** by default; scope with `--lint` / `--test`.
237
237
  - **Already have a CLAUDE.md / AGENTS.md, skills, or subagents? `audit` and `lint` read them as-is** — nothing is moved or rewritten. For the structural rules that want a typed spec, `init` sets one up **non-destructively** (`eject` undoes it).
238
- - Adds `vigiles` to `devDependencies`; installs the Claude Code plugin (skills + hooks) via the marketplace — globally, never vendored.
239
- - Declares that plugin in your repo's `.claude/settings.json` (`extraKnownMarketplaces` + `enabledPlugins`, merged into whatever is already there). This is a **reference, not content** — nothing is vendored, and it does **not** install the plugin for a teammate: an external-source plugin declared project-level [doesn't load until each person installs it](https://code.claude.com/docs/en/discover-plugins#configure-team-marketplaces). What it buys is that Claude Code **prompts** them with the install command, instead of a fresh clone silently having the npm package and none of its skills.
238
+ - Adds `vigiles` to `devDependencies` and **links its skills into your repo**: one relative symlink per skill, `.claude/skills/<name>` → `node_modules/vigiles/skills/<name>` (`.agents/skills/` for Codex). Commit them — every clone and every fresh container then has the skills as soon as `npm install` has run. A skill of yours with the same name is never replaced; `init` names the one you didn't get.
239
+ - Installs the Claude Code plugin via the marketplace — globally, never vendored — because it carries the **hooks**. That install is per machine; its copy of the skills is listed as `vigiles:<name>` next to the linked `<name>` (same files).
240
+ - Declares that plugin in your repo's `.claude/settings.json` (`extraKnownMarketplaces` + `enabledPlugins`, merged into whatever is already there). This is a **reference, not content**, and it does **not** install the plugin for a teammate: an external-source plugin declared project-level [doesn't load until each person installs it](https://code.claude.com/docs/en/discover-plugins#configure-team-marketplaces). What it buys is that Claude Code **prompts** them with the install command for the hooks.
240
241
  - Wires CI as a `zernie/vigiles@v1` workflow (needs only read + PR-comment permissions) that posts a sticky PR comment + a `valid` output.
241
242
 
242
243
  Targets Claude Code and Codex out of the box, or [your own harness](docs/authoring-an-adapter.md). Prefer to write tests yourself? JS **or** TS (`*.harness.{mjs,ts}`) — run with `npx vigiles test`.
@@ -21,6 +21,19 @@ const event_capability_js_1 = require("./core/event-capability.js");
21
21
  const dialect_js_1 = require("./core/dialect.js");
22
22
  const layout_js_1 = require("./core/layout.js");
23
23
  const tmp_root_js_1 = require("./core/tmp-root.js");
24
+ /** Every optional path a layout names, with the field it sits in. */
25
+ function optionalPaths(layout) {
26
+ return [
27
+ ["rulesDir", layout.rulesDir],
28
+ ["outputStyles.dir", layout.outputStyles?.dir],
29
+ ["hookScriptsDir", layout.hookScriptsDir],
30
+ ["hooksConventionPath", layout.hooksConventionPath],
31
+ ["userSurfaceRoot", layout.userSurfaceRoot],
32
+ ["surfaces.skill", layout.surfaces.skill],
33
+ ["surfaces.agent", layout.surfaces.agent],
34
+ ["surfaces.command", layout.surfaces.command],
35
+ ];
36
+ }
24
37
  /** Check an adapter against the port contracts; returns the (possibly empty) failure list. */
25
38
  function checkAdapterConformance(adapter) {
26
39
  const failures = [];
@@ -76,16 +89,7 @@ function checkAdapterConformance(adapter) {
76
89
  // second spelling of absent" rule is a test for every optional path. An
77
90
  // absent key already means "this harness has no such thing"; `""` would be a
78
91
  // second one, and every reader would have to remember to test for both.
79
- const optionalPaths = [
80
- ["rulesDir", adapter.layout.rulesDir],
81
- ["hookScriptsDir", adapter.layout.hookScriptsDir],
82
- ["hooksConventionPath", adapter.layout.hooksConventionPath],
83
- ["userSurfaceRoot", adapter.layout.userSurfaceRoot],
84
- ["surfaces.skill", adapter.layout.surfaces.skill],
85
- ["surfaces.agent", adapter.layout.surfaces.agent],
86
- ["surfaces.command", adapter.layout.surfaces.command],
87
- ];
88
- for (const [field, value] of optionalPaths)
92
+ for (const [field, value] of optionalPaths(adapter.layout))
89
93
  need(value === undefined || value.length > 0, `layout.${field} is "" — absence is spelled by omitting the key, never by an empty string`);
90
94
  // A7: `hooksConventionPath` names a FILE, and every reader treats it as one
91
95
  // (dirname it, parse it, round-trip it). `opencodeLayout` used to name the
package/dist/adapter.d.ts CHANGED
@@ -18,6 +18,8 @@
18
18
  export type { HarnessAdapter, AdapterCapabilities } from "./core/adapter.js";
19
19
  export type { HarnessDialect } from "./core/dialect.js";
20
20
  export type { PluginLayout, SurfaceDirs, SurfaceKind } from "./core/layout.js";
21
+ export type { OutputStyle, OutputStyleRules } from "./core/output-style.js";
22
+ export type { ModelRequest } from "./core/harness-driver.js";
21
23
  export { executableSourceDirs, materializePrefix, surfaceDirs, } from "./core/layout.js";
22
24
  /**
23
25
  * The instruction-chain vocabulary `PluginLayout.instructionChain` speaks.
@@ -39,7 +41,7 @@ export { EMPTY_CHAIN } from "./core/instruction-chain.js";
39
41
  */
40
42
  export type { SettingsCodec } from "./core/settings-codec.js";
41
43
  export { jsonSettingsCodec, tomlSettingsCodec } from "./core/settings-codec.js";
42
- export type { HarnessRuntime } from "./core/runtime.js";
44
+ export type { HarnessRuntime, RunEnvPolicy } from "./core/runtime.js";
43
45
  export type { HookProtocol } from "./core/hook-protocol.js";
44
46
  export type { ModelMock } from "./core/model-mock.js";
45
47
  /**
@@ -15,6 +15,7 @@ const layout_js_1 = require("../../core/layout.js");
15
15
  const instruction_chain_js_1 = require("../../core/instruction-chain.js");
16
16
  const settings_codec_js_1 = require("../../core/settings-codec.js");
17
17
  const instruction_chain_js_2 = require("./instruction-chain.js");
18
+ const output_style_js_1 = require("./output-style.js");
18
19
  exports.claudeCodeLayout = {
19
20
  name: "claude-code",
20
21
  manifestPath: ".claude-plugin/plugin.json",
@@ -30,6 +31,8 @@ exports.claudeCodeLayout = {
30
31
  userSurfaceRoot: ".claude",
31
32
  // `.claude/rules/*.md` — path-scoped project instructions (see PluginLayout).
32
33
  rulesDir: "rules",
34
+ // `.claude/output-styles/**/*.md` — see output-style.ts for how a style is named and selected.
35
+ outputStyles: output_style_js_1.claudeCodeOutputStyles,
33
36
  // `.claude/settings.local.json` — the per-machine layer, gitignored by
34
37
  // `init`. Declared because only this adapter knows the word; Codex and
35
38
  // OpenCode name none, so none is synthesized for them.
@@ -0,0 +1,3 @@
1
+ import type { OutputStyleRules } from "../../core/output-style.js";
2
+ export declare const claudeCodeOutputStyles: OutputStyleRules;
3
+ //# sourceMappingURL=output-style.d.ts.map
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.claudeCodeOutputStyles = void 0;
4
+ /**
5
+ * How Claude Code keeps output styles and switches one on. Every rule here was
6
+ * measured on Claude Code 2.1.291; the tests beside this file hold the cases.
7
+ */
8
+ const node_path_1 = require("node:path");
9
+ // `yaml` is the lazy loader: this module hangs off the layout, which is on the
10
+ // hook path, so a top-level `js-yaml` import would load into every hook decision.
11
+ const frontmatter_read_js_1 = require("../../core/frontmatter-read.js");
12
+ /** Claude Code puts this line, then the body, into the first user message. */
13
+ const HEADER = "# Output Style: ";
14
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
15
+ /** YAML as Claude Code reads it: the core schema, and a repeated key keeps the last value. */
16
+ const parse = (block) => {
17
+ try {
18
+ const { CORE_SCHEMA, load } = (0, frontmatter_read_js_1.yaml)();
19
+ const value = load(block, { schema: CORE_SCHEMA, json: true });
20
+ if (value === null || value === undefined)
21
+ return {};
22
+ return isRecord(value) ? value : null;
23
+ }
24
+ catch (e) {
25
+ if (e instanceof (0, frontmatter_read_js_1.yaml)().YAMLException)
26
+ return null;
27
+ throw e;
28
+ }
29
+ };
30
+ /** `name: Status: Block` is not YAML, but Claude Code takes the rest of the line. */
31
+ const quoteColonValues = (block) => block
32
+ .split("\n")
33
+ .map((line) => {
34
+ const m = /^([A-Za-z0-9_-]+): (.*)$/.exec(line.replace(/\r$/, ""));
35
+ if (m === null)
36
+ return line;
37
+ const [, key = "", value = ""] = m;
38
+ const needsQuotes = /: |:$/.test(value) && !/^["'[{|>]/.test(value);
39
+ return needsQuotes ? `${key}: ${JSON.stringify(value)}` : line;
40
+ })
41
+ .join("\n");
42
+ const readBlock = (block) => parse(block) ?? parse(quoteColonValues(block));
43
+ /** A declared name if there is one, else the file name; null when unknowable. */
44
+ const nameFrom = (text, stem) => {
45
+ const { block } = (0, frontmatter_read_js_1.readFrontmatter)(text);
46
+ if (block === null)
47
+ return stem;
48
+ const data = readBlock(block);
49
+ if (data === null)
50
+ return null;
51
+ const name = data["name"];
52
+ if (typeof name === "string")
53
+ return name === "" ? stem : name;
54
+ if (typeof name === "number" || typeof name === "boolean")
55
+ return String(name);
56
+ return name === null || name === undefined ? stem : null;
57
+ };
58
+ const squash = (s) => s.replace(/\s+/g, " ").trim();
59
+ const read = (path, text) => ({
60
+ path,
61
+ name: nameFrom(text, (0, node_path_1.basename)(path).replace(/\.md$/, "")),
62
+ body: (0, frontmatter_read_js_1.frontmatterBody)(text).trim(),
63
+ });
64
+ /** The header and the whole body, in the system prompt or in any message. */
65
+ const reached = (style, request) => {
66
+ if (style.name === null)
67
+ return false;
68
+ const probe = squash(`${HEADER}${style.name}\n${style.body}`);
69
+ return [request.system, ...request.messages.map((m) => m.text)].some((text) => squash(text).includes(probe));
70
+ };
71
+ exports.claudeCodeOutputStyles = {
72
+ dir: "output-styles",
73
+ isStyleFile: (pathInDir) => pathInDir.endsWith(".md"),
74
+ read,
75
+ select: (name) => ({ outputStyle: name }),
76
+ // A user-wide `outputStyle` in `~/.claude/settings.json` loads in every
77
+ // project; the project's own `default` wins over it (measured on 2.1.292).
78
+ selectNone: { outputStyle: "default" },
79
+ reached,
80
+ };
81
+ //# sourceMappingURL=output-style.js.map
@@ -0,0 +1,14 @@
1
+ /** Whether the model answered at all: any `assistant` event, subagents included. */
2
+ export declare function reachedModel(streamJson: string): boolean;
3
+ /**
4
+ * The main agent's replies, one per message that ended a turn. A message
5
+ * streamed as several events (one per content block) is joined under its
6
+ * `message.id`. Left out: a subagent's messages (a non-null
7
+ * `parent_tool_use_id`); a message that also calls a tool, whose text is
8
+ * narration on the way to the call (measured on Claude Code 2.1.292: the
9
+ * narration and the `tool_use` share one id); a message with no text.
10
+ * `undefined` when the output is not a stream, so a check can say the run
11
+ * was not streamed instead of reading "no replies".
12
+ */
13
+ export declare function parseReplies(streamJson: string): readonly string[] | undefined;
14
+ //# sourceMappingURL=replies.d.ts.map
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.reachedModel = reachedModel;
4
+ exports.parseReplies = parseReplies;
5
+ /**
6
+ * Every reply the agent ended a turn with, in order — read off Claude Code's
7
+ * `--output-format stream-json`. The `result` event carries only the LAST one,
8
+ * so a reply a Stop hook made the agent follow up on was invisible to a test,
9
+ * and with it anything that reply printed (a second status footer, measured on
10
+ * Claude Code 2.1.291: two `assistant` events, one `result` holding the second).
11
+ */
12
+ const zod_1 = require("zod");
13
+ /** The parts of an `assistant` stream line this reads; the rest is ignored. */
14
+ const AssistantLineSchema = zod_1.z.object({
15
+ type: zod_1.z.literal("assistant"),
16
+ parent_tool_use_id: zod_1.z.string().nullish(),
17
+ message: zod_1.z.object({
18
+ id: zod_1.z.string().optional(),
19
+ content: zod_1.z.array(zod_1.z.unknown()),
20
+ }),
21
+ });
22
+ const TextBlock = zod_1.z.object({ type: zod_1.z.literal("text"), text: zod_1.z.string() });
23
+ const textOf = (content) => content
24
+ .map((block) => TextBlock.safeParse(block))
25
+ .flatMap((r) => (r.success ? [r.data.text] : []))
26
+ .join("");
27
+ const parseJson = (line) => {
28
+ try {
29
+ return JSON.parse(line);
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ };
35
+ const parseLine = (line) => {
36
+ const r = AssistantLineSchema.safeParse(parseJson(line));
37
+ return r.success ? r.data : null;
38
+ };
39
+ /** A stream-json run opens with a `system` event; plain `json` output has none. */
40
+ const StreamStartSchema = zod_1.z.object({ type: zod_1.z.literal("system") });
41
+ const isStream = (lines) => lines.some((l) => StreamStartSchema.safeParse(parseJson(l)).success ||
42
+ parseLine(l) !== null);
43
+ const ToolUseBlock = zod_1.z.object({ type: zod_1.z.literal("tool_use") });
44
+ /** Whether the model answered at all: any `assistant` event, subagents included. */
45
+ function reachedModel(streamJson) {
46
+ return streamJson.split("\n").some((l) => parseLine(l) !== null);
47
+ }
48
+ /**
49
+ * The main agent's replies, one per message that ended a turn. A message
50
+ * streamed as several events (one per content block) is joined under its
51
+ * `message.id`. Left out: a subagent's messages (a non-null
52
+ * `parent_tool_use_id`); a message that also calls a tool, whose text is
53
+ * narration on the way to the call (measured on Claude Code 2.1.292: the
54
+ * narration and the `tool_use` share one id); a message with no text.
55
+ * `undefined` when the output is not a stream, so a check can say the run
56
+ * was not streamed instead of reading "no replies".
57
+ */
58
+ function parseReplies(streamJson) {
59
+ const lines = streamJson.split("\n");
60
+ if (!isStream(lines))
61
+ return undefined;
62
+ const events = lines.map(parseLine).flatMap((e, i) => e !== null && (e.parent_tool_use_id ?? null) === null
63
+ ? [
64
+ {
65
+ id: e.message.id ?? `#${String(i)}`,
66
+ text: textOf(e.message.content),
67
+ callsTool: e.message.content.some((b) => ToolUseBlock.safeParse(b).success),
68
+ },
69
+ ]
70
+ : []);
71
+ const ids = [...new Set(events.map((e) => e.id))];
72
+ return ids
73
+ .map((id) => events.filter((e) => e.id === id))
74
+ .filter((parts) => !parts.some((e) => e.callsTool))
75
+ .map((parts) => parts.map((e) => e.text).join(""))
76
+ .filter((text) => text !== "");
77
+ }
78
+ //# sourceMappingURL=replies.js.map
@@ -2,6 +2,75 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.claudeCodeRuntime = void 0;
4
4
  exports.mockModelEnv = mockModelEnv;
5
+ /**
6
+ * What a child `claude` run may inherit from its caller. The runners apply it
7
+ * through `src/core/run-env.ts`; this object is the only place the names live.
8
+ *
9
+ * `keep` — auth and backend selection only, as an allowlist. It replaced a
10
+ * `CLAUDE_*` prefix pass-through, which carried the parent session's identity
11
+ * into every scrubbed run. Measured 2026-10-07 on 2.1.292: a child started with
12
+ * the parent's session variables ran as the parent session, and a host-brokered
13
+ * child (`ANTHROPIC_BASE_URL`) authenticated with NO `CLAUDE_*` variable at all.
14
+ * Every `CLAUDE_*` name below is one the prefix used to carry and that selects
15
+ * or supplies credentials (each is referenced by the 2.1.292 binary).
16
+ * `CLAUDE_CONFIG_DIR` is left out on purpose: it points the child at the real
17
+ * config directory (settings, hooks, sessions, tasks) that the throwaway HOME
18
+ * exists to hide.
19
+ *
20
+ * `sessionIdentity` — the parent session. The first six were observed in a live
21
+ * session's env on 2026-10-07; the rest are session-identifying names present
22
+ * in the 2.1.292 binary, removed by name rather than by a measured effect.
23
+ */
24
+ const claudeCodeRunEnv = {
25
+ keep: [
26
+ // Anthropic API auth + endpoint.
27
+ "ANTHROPIC_API_KEY",
28
+ "ANTHROPIC_BASE_URL",
29
+ "ANTHROPIC_AUTH_TOKEN",
30
+ "ANTHROPIC_MODEL",
31
+ // Carried over from the earlier list; not referenced by the 2.1.292 binary.
32
+ "ANTHROPIC_API_URL",
33
+ "ANTHROPIC_DEFAULT_HEADERS",
34
+ // Subscription token (`claude setup-token`) and client certificates.
35
+ "CLAUDE_CODE_OAUTH_TOKEN",
36
+ "CLAUDE_CODE_API_KEY_HELPER_TTL_MS",
37
+ "CLAUDE_CODE_CLIENT_CERT",
38
+ "CLAUDE_CODE_CLIENT_KEY",
39
+ "CLAUDE_CODE_CLIENT_KEY_PASSPHRASE",
40
+ // Cloud backends: the switch, the auth skip, and region/profile — never
41
+ // the secret-shaped AWS_* access keys.
42
+ "CLAUDE_CODE_USE_BEDROCK",
43
+ "CLAUDE_CODE_USE_VERTEX",
44
+ "CLAUDE_CODE_USE_FOUNDRY",
45
+ "CLAUDE_CODE_SKIP_BEDROCK_AUTH",
46
+ "CLAUDE_CODE_SKIP_VERTEX_AUTH",
47
+ "CLAUDE_CODE_SKIP_FOUNDRY_AUTH",
48
+ "AWS_REGION",
49
+ "AWS_DEFAULT_REGION",
50
+ "AWS_PROFILE",
51
+ "CLOUD_ML_REGION",
52
+ "GOOGLE_CLOUD_PROJECT",
53
+ "GOOGLE_APPLICATION_CREDENTIALS",
54
+ ],
55
+ keepHomeFiles: [".claude/.credentials.json"],
56
+ sessionIdentity: [
57
+ "CLAUDE_CODE_SESSION_ID",
58
+ "CLAUDE_CODE_REMOTE_SESSION_ID",
59
+ "CLAUDE_CODE_CHILD_SESSION",
60
+ "CLAUDE_CODE_MESSAGING_SOCKET",
61
+ "CLAUDE_CODE_MESSAGING_TOKEN",
62
+ "CLAUDE_SESSION_INGRESS_TOKEN_FILE",
63
+ "CLAUDE_SESSION_ID",
64
+ "CLAUDE_CODE_SESSION_ACCESS_TOKEN",
65
+ "CLAUDE_CODE_REMOTE_SESSION_UUID",
66
+ "CLAUDE_CODE_HOST_SESSION_ID",
67
+ "CLAUDE_CODE_CLOUD_SESSION_ID",
68
+ "CLAUDE_CODE_BRIDGE_SESSION_ID",
69
+ "CLAUDE_RUNNER_SESSION_ID",
70
+ "CLAUDE_RUNNER_SESSION_UUID",
71
+ "CLAUDE_PID",
72
+ ],
73
+ };
5
74
  exports.claudeCodeRuntime = {
6
75
  name: "claude-code",
7
76
  agentBinary: "claude",
@@ -34,6 +103,7 @@ exports.claudeCodeRuntime = {
34
103
  const m = /(\d+)\.(\d+)\.\d+/.exec(raw);
35
104
  return m ? `${m[1]}.${m[2]}` : raw.trim();
36
105
  },
106
+ runEnv: claudeCodeRunEnv,
37
107
  };
38
108
  /**
39
109
  * Build the spawn env that points the agent CLI at the mock model: the caller's
@@ -4,10 +4,15 @@
4
4
  * vigiles publishes six user-facing skills (`SHIPPED_SKILLS`) — the teaching
5
5
  * surface. `test-harness` alone answers "which testing tier do I want?", the
6
6
  * question this project's docs are otherwise organized around. They reach the
7
- * agent through the GLOBAL plugin install (`vigiles init`, or
8
- * `claude plugin marketplace add zernie/vigiles` + `claude plugin install
9
- * vigiles@vigiles`), which lands in `~/.claude/plugins/` — deliberately never
10
- * vendored into the repo (`docs/agent-setup.md`).
7
+ * agent two ways (`docs/agent-setup.md`):
8
+ *
9
+ * - the LINKS `vigiles init` commits into `.claude/skills/` (one relative
10
+ * symlink per skill into `node_modules/vigiles/skills/`, `src/skill-links.ts`)
11
+ * — a fact about the REPOSITORY, so every clone and container has them once
12
+ * `npm install` has run;
13
+ * - the GLOBAL plugin install (`claude plugin install vigiles@vigiles`, into
14
+ * `~/.claude/plugins/`) — a fact about ONE machine, namespaced
15
+ * `vigiles:<name>`.
11
16
  *
12
17
  * The failure this module makes loud: `npm install vigiles` ALSO puts those
13
18
  * skills on disk, at `node_modules/vigiles/skills/` (they are in package.json's
@@ -46,9 +51,10 @@
46
51
  * Shape follows `./dialect-drift.ts`: pure parsers + a best-effort local read
47
52
  * that NEVER throws + a formatter that returns null when there is nothing to
48
53
  * say, so `vigiles audit` can print it without a new verb, flag, or failure mode.
49
- * It is ADVISORY — it never touches the audit score, because reachability is a
50
- * property of the machine (is the plugin installed?), not of the repo, and a
51
- * score that moved between a laptop and CI for identical source would be a lie.
54
+ * It is ADVISORY — it never touches the audit score, because reachability is
55
+ * partly a property of the machine (is the plugin installed? has `npm install`
56
+ * run?), and a score that moved between a laptop and CI for identical source
57
+ * would be a lie.
52
58
  */
53
59
  import type { InstallReader } from "../../core/adapter.js";
54
60
  /** The plugin id `claude plugin install` records — `<plugin>@<marketplace>`. */
@@ -61,7 +67,7 @@ export declare const VIGILES_PLUGIN_ID = "vigiles@vigiles";
61
67
  export type ReachabilitySource =
62
68
  /** `~/.claude/plugins/installed_plugins.json` — what `claude plugin install` writes. */
63
69
  "global-plugin"
64
- /** The skills vendored into the repo's own `.claude/skills/` (standalone config, always loaded). */
70
+ /** The repo's own `.claude/skills/<name>/SKILL.md` resolves — a link `init` made, or a copy. */
65
71
  | "repo-skills";
66
72
  /** The advisory: can the agent see vigiles's skills from this repo? */
67
73
  export interface SkillReachability {
@@ -82,6 +88,18 @@ export interface SkillReachability {
82
88
  * nothing stranded if they are wired) or when the package isn't installed yet.
83
89
  */
84
90
  readonly strandedSkills: readonly string[];
91
+ /**
92
+ * Shipped skills whose `.claude/skills/<name>` entry is a LINK that does not
93
+ * resolve: the links came with `git clone`, the package they point into did
94
+ * not. The fix is `npm install`, not re-linking.
95
+ */
96
+ readonly danglingLinks: readonly string[];
97
+ /**
98
+ * Skills the package ships that the repo has not linked while it HAS linked
99
+ * others — the state after an upgrade adds a skill. Empty unless at least one
100
+ * shipped skill resolves in the repo.
101
+ */
102
+ readonly unlinkedSkills: readonly string[];
85
103
  }
86
104
  /**
87
105
  * Does the global registry record a live install of the vigiles plugin? Pure over
@@ -101,25 +119,28 @@ export declare function hasEnabledPlugin(settingsJson: string): boolean;
101
119
  * reader performs degrades to "not found".
102
120
  *
103
121
  * 🔴 IT READS THROUGH A REPO-BOUND {@link InstallReader}, NOT `node:fs`. Two of
104
- * its five reads are of files NO adapter claims (`package.json`, vigiles's own
122
+ * its reads are of files NO adapter claims (`package.json`, vigiles's own
105
123
  * package under `node_modules`), so the domain performs those and passes the
106
124
  * ANSWER; the rest go through a reader that refuses any path this adapter does
107
- * not claim. That is why this module no longer imports `node:fs` at all.
125
+ * not claim. That is why this module does not import `node:fs` at all.
108
126
  *
109
- * ⚠️ ONE MEASURED CHANGE, NAMED BECAUSE IT IS A CHANGE: the repo-vendored check
110
- * used to ENUMERATE `.claude/skills/` and compare directory names. Enumeration
111
- * is precisely what the reader exists to prevent, and it is not needed here —
112
- * the names being looked for are a fixed list — so each shipped skill is probed
113
- * for its own `SKILL.md` instead. The narrow difference: a directory named after
114
- * a shipped skill but holding no `SKILL.md` used to count as reachable and no
115
- * longer does. It was never loadable by the agent, which is the question asked.
127
+ * Each shipped skill is probed for its own `SKILL.md` rather than by listing
128
+ * `.claude/skills/` — enumeration is what the reader exists to prevent, and the
129
+ * names are known: the installed package's when it is installed, else the
130
+ * shipped list. A directory named after a shipped skill but holding no
131
+ * `SKILL.md` is not reachable; it was never loadable by the agent.
116
132
  */
117
133
  export declare function checkSkillReachability(read: InstallReader): SkillReachability | null;
118
134
  /**
119
- * The warning for an un-wired repo, or null when there is nothing to say
120
- * (reachable, or the check did not apply). Names the skill the user most likely
121
- * went looking for, says where the copies are stranded when they exist, and ends
122
- * on a command that fixes it.
135
+ * The advisory, or null when there is nothing to say. Most specific state
136
+ * first, each ending on the command that fixes it:
137
+ *
138
+ * 1. links that dangle — `npm install`;
139
+ * 2. nothing reaches the agent — link them (per repo), or install the plugin
140
+ * (per machine);
141
+ * 3. only this machine's plugin reaches it — a fresh clone or container will
142
+ * not have them. Not a failure; said in one line;
143
+ * 4. some linked, some not (an upgrade added one) — link the rest.
123
144
  */
124
145
  export declare function formatSkillReachability(r: SkillReachability | null): string | null;
125
146
  //# sourceMappingURL=skill-reachability.d.ts.map
@@ -6,6 +6,9 @@ exports.hasEnabledPlugin = hasEnabledPlugin;
6
6
  exports.checkSkillReachability = checkSkillReachability;
7
7
  exports.formatSkillReachability = formatSkillReachability;
8
8
  const setup_plan_js_1 = require("../../setup-plan.js");
9
+ const layout_js_1 = require("../../core/layout.js");
10
+ const skill_link_target_js_1 = require("../../core/skill-link-target.js");
11
+ const layout_js_2 = require("./layout.js");
9
12
  /** The plugin id `claude plugin install` records — `<plugin>@<marketplace>`. */
10
13
  exports.VIGILES_PLUGIN_ID = "vigiles@vigiles";
11
14
  /**
@@ -36,6 +39,10 @@ function hasEnabledPlugin(settingsJson) {
36
39
  return false;
37
40
  }
38
41
  }
42
+ /** Where this harness keeps a repo's skills — the layout's answer, not a literal. */
43
+ const SKILLS_HOME = (0, layout_js_1.skillsHome)(layout_js_2.claudeCodeLayout) ?? ".claude/skills";
44
+ /** The per-repo fix, printed wherever the repo itself lacks the links. */
45
+ const LINK_FIX = "npx vigiles init — links each skill into .claude/skills/ (relative links into node_modules; commit them), and leaves any skill you already have alone";
39
46
  /**
40
47
  * Best-effort, read-local reachability check for `vigiles audit`. Returns null
41
48
  * when the question does not apply — the repo does not depend on vigiles, or IS
@@ -43,54 +50,110 @@ function hasEnabledPlugin(settingsJson) {
43
50
  * reader performs degrades to "not found".
44
51
  *
45
52
  * 🔴 IT READS THROUGH A REPO-BOUND {@link InstallReader}, NOT `node:fs`. Two of
46
- * its five reads are of files NO adapter claims (`package.json`, vigiles's own
53
+ * its reads are of files NO adapter claims (`package.json`, vigiles's own
47
54
  * package under `node_modules`), so the domain performs those and passes the
48
55
  * ANSWER; the rest go through a reader that refuses any path this adapter does
49
- * not claim. That is why this module no longer imports `node:fs` at all.
56
+ * not claim. That is why this module does not import `node:fs` at all.
50
57
  *
51
- * ⚠️ ONE MEASURED CHANGE, NAMED BECAUSE IT IS A CHANGE: the repo-vendored check
52
- * used to ENUMERATE `.claude/skills/` and compare directory names. Enumeration
53
- * is precisely what the reader exists to prevent, and it is not needed here —
54
- * the names being looked for are a fixed list — so each shipped skill is probed
55
- * for its own `SKILL.md` instead. The narrow difference: a directory named after
56
- * a shipped skill but holding no `SKILL.md` used to count as reachable and no
57
- * longer does. It was never loadable by the agent, which is the question asked.
58
+ * Each shipped skill is probed for its own `SKILL.md` rather than by listing
59
+ * `.claude/skills/` — enumeration is what the reader exists to prevent, and the
60
+ * names are known: the installed package's when it is installed, else the
61
+ * shipped list. A directory named after a shipped skill but holding no
62
+ * `SKILL.md` is not reachable; it was never loadable by the agent.
58
63
  */
59
64
  function checkSkillReachability(read) {
60
65
  if (!read.repoDependsOnVigiles)
61
66
  return null;
62
- const sources = [];
63
- const installed = read.home(".claude/plugins/installed_plugins.json");
64
- if (installed !== null && hasGlobalPluginInstall(installed))
65
- sources.push("global-plugin");
66
- // Vendored copies: only vigiles's OWN skill names count. A repo with 38
67
- // unrelated skills in `.claude/skills/` is still un-wired.
68
- if (setup_plan_js_1.SHIPPED_SKILLS.some((s) => read.repo(`.claude/skills/${s}/SKILL.md`)))
69
- sources.push("repo-skills");
67
+ const shipped = read.vendoredSkillNames.length > 0
68
+ ? read.vendoredSkillNames
69
+ : setup_plan_js_1.SHIPPED_SKILLS;
70
+ const { inRepo, dangling } = repoSkillState(read, shipped);
71
+ const sources = reachabilitySources(read, inRepo);
70
72
  const reachable = sources.length > 0;
71
73
  // A project declaration is NOT a source — it makes Claude Code prompt for an
72
74
  // install, it does not perform one. Only meaningful while unreachable.
73
75
  const settings = read.repo(".claude/settings.json");
74
- const declaredNotInstalled = !reachable && settings !== null && hasEnabledPlugin(settings);
75
76
  const vendored = new Set(read.vendoredSkillNames);
76
77
  return {
77
78
  reachable,
78
79
  sources,
79
- declaredNotInstalled,
80
+ declaredNotInstalled: !reachable && settings !== null && hasEnabledPlugin(settings),
80
81
  strandedSkills: reachable
81
82
  ? []
82
83
  : setup_plan_js_1.SHIPPED_SKILLS.filter((s) => vendored.has(s)),
84
+ danglingLinks: dangling,
85
+ unlinkedSkills: inRepo.length > 0
86
+ ? shipped.filter((s) => !inRepo.includes(s) && !dangling.includes(s))
87
+ : [],
88
+ };
89
+ }
90
+ /** Which shipped skills resolve in the repo, and which are links that dangle. */
91
+ function repoSkillState(read, shipped) {
92
+ const resolves = (s) => read.repo(`${SKILLS_HOME}/${s}/SKILL.md`) !== null;
93
+ return {
94
+ inRepo: shipped.filter(resolves),
95
+ dangling: shipped.filter((s) => !resolves(s) && isOurLink(read.repoLink(`${SKILLS_HOME}/${s}`), s)),
83
96
  };
84
97
  }
85
98
  /**
86
- * The warning for an un-wired repo, or null when there is nothing to say
87
- * (reachable, or the check did not apply). Names the skill the user most likely
88
- * went looking for, says where the copies are stranded when they exist, and ends
89
- * on a command that fixes it.
99
+ * A link `vigiles init` wrote (into `node_modules/vigiles/skills/<name>`). A
100
+ * dangling link that only shares a shipped skill's NAME is the user's own, and
101
+ * "run npm install" would be advice about the wrong thing.
102
+ */
103
+ function isOurLink(target, name) {
104
+ return target !== null && (0, skill_link_target_js_1.isVigilesSkillTarget)(target, name);
105
+ }
106
+ /** The sources that resolved, in check order. */
107
+ function reachabilitySources(read, inRepo) {
108
+ const installed = read.home(".claude/plugins/installed_plugins.json");
109
+ return [
110
+ ...(installed !== null && hasGlobalPluginInstall(installed)
111
+ ? ["global-plugin"]
112
+ : []),
113
+ // Only vigiles's OWN skill names count. A repo with 38 unrelated skills in
114
+ // `.claude/skills/` is still un-wired.
115
+ ...(inRepo.length > 0 ? ["repo-skills"] : []),
116
+ ];
117
+ }
118
+ /**
119
+ * The advisory, or null when there is nothing to say. Most specific state
120
+ * first, each ending on the command that fixes it:
121
+ *
122
+ * 1. links that dangle — `npm install`;
123
+ * 2. nothing reaches the agent — link them (per repo), or install the plugin
124
+ * (per machine);
125
+ * 3. only this machine's plugin reaches it — a fresh clone or container will
126
+ * not have them. Not a failure; said in one line;
127
+ * 4. some linked, some not (an upgrade added one) — link the rest.
90
128
  */
91
129
  function formatSkillReachability(r) {
92
- if (!r || r.reachable)
130
+ if (!r)
93
131
  return null;
132
+ if (r.danglingLinks.length > 0)
133
+ return formatDangling(r);
134
+ if (!r.reachable)
135
+ return formatUnreachable(r);
136
+ if (!r.sources.includes("repo-skills"))
137
+ return (`ℹ vigiles's skills reach your agent only through this machine's plugin ` +
138
+ `install (~/.claude/plugins/), so a fresh clone or container won't have ` +
139
+ `them.\n Fix (per repo): ${LINK_FIX}`);
140
+ if (r.unlinkedSkills.length > 0)
141
+ return (`⚠ ${String(r.unlinkedSkills.length)} of vigiles's skills aren't linked ` +
142
+ `into ${SKILLS_HOME}/: ${r.unlinkedSkills.join(", ")}.\n Fix: ${LINK_FIX}`);
143
+ return null;
144
+ }
145
+ /** Links that came with the clone, into a package that did not. */
146
+ function formatDangling(r) {
147
+ const plugin = r.sources.includes("global-plugin")
148
+ ? " This machine's plugin install still provides them as vigiles:<name>."
149
+ : "";
150
+ return (`⚠ ${String(r.danglingLinks.length)} of vigiles's skills are linked into ` +
151
+ `${SKILLS_HOME}/ but the links don't resolve here — node_modules/vigiles ` +
152
+ `isn't installed.${plugin}\n` +
153
+ ` Fix: npm install (a session that started before it may need /reload-skills or a restart)`);
154
+ }
155
+ /** Nothing reaches the agent: the per-repo fix first, the per-machine one second. */
156
+ function formatUnreachable(r) {
94
157
  const stranded = r.strandedSkills.length > 0
95
158
  ? ` ${String(r.strandedSkills.length)} of them are sitting in ` +
96
159
  `node_modules/vigiles/skills/, which the agent never scans.`
@@ -100,12 +163,12 @@ function formatSkillReachability(r) {
100
163
  `project declaration doesn't install it — Claude Code loads an ` +
101
164
  `external-source plugin only once each collaborator installs it on their ` +
102
165
  `own machine.`
103
- : `This repo depends on vigiles, but its plugin isn't installed.`;
166
+ : `This repo depends on vigiles, but it does not link vigiles's skills and the plugin isn't installed here.`;
104
167
  return (`⚠ vigiles's skills are NOT reachable by your agent here. ${why} So the ` +
105
168
  `shipped skills (${setup_plan_js_1.SHIPPED_SKILLS.join(", ")}) can't be selected — ` +
106
169
  `including test-harness, which picks the testing tier for you.${stranded}\n` +
107
- ` Fix (per machine): claude plugin marketplace add zernie/vigiles && ` +
108
- `claude plugin install ${exports.VIGILES_PLUGIN_ID}\n` +
109
- ` (or run \`vigiles init\`, which does both)`);
170
+ ` Fix (per repo, reaches every clone): ${LINK_FIX}\n` +
171
+ ` Or per machine: claude plugin marketplace add zernie/vigiles && ` +
172
+ `claude plugin install ${exports.VIGILES_PLUGIN_ID}`);
110
173
  }
111
174
  //# sourceMappingURL=skill-reachability.js.map