vigiles 2.4.0 → 2.6.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.
@@ -1,7 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.resolveHarness = exports.loadPlugin = exports.scriptModel = void 0;
3
+ exports.sandboxAvailable = exports.specTrusted = exports.decideSandbox = exports.resolveHarness = exports.loadPlugin = exports.scriptModel = void 0;
4
4
  exports.parseToolCalls = parseToolCalls;
5
+ exports.parseResultEvent = parseResultEvent;
6
+ exports.parseOutput = parseOutput;
7
+ exports.parseHooks = parseHooks;
8
+ exports.buildClaudeArgs = buildClaudeArgs;
5
9
  exports.claudeAvailable = claudeAvailable;
6
10
  exports.runHarnessTest = runHarnessTest;
7
11
  /**
@@ -28,10 +32,11 @@ exports.runHarnessTest = runHarnessTest;
28
32
  * The "steps" are the scripted model turns — their real home is deterministic
29
33
  * harness testing, not production enforcement.
30
34
  *
31
- * Note: the simple mock drives the Bash tool and Stop hooks reliably; the
32
- * Edit/Write tools are gated in headless mode and don't fire via the mock —
33
- * drive file actions through Bash, or use the real-model eval tier (`eval.ts`)
34
- * for Edit/Write hooks.
35
+ * Note: the mock drives Bash and Stop hooks, and — verified on claude 2.1.169 —
36
+ * the Edit/Write tools too (allowlisted past the permission prompt), so their
37
+ * PreToolUse/PostToolUse hooks fire in this tier. The events the mock can't
38
+ * trigger (PreCompact / Notification / SessionEnd / SubagentStop) belong to the
39
+ * `runHook` unit tier.
35
40
  */
36
41
  const node_child_process_1 = require("node:child_process");
37
42
  const node_fs_1 = require("node:fs");
@@ -39,11 +44,16 @@ const node_os_1 = require("node:os");
39
44
  const node_path_1 = require("node:path");
40
45
  const mock_model_js_1 = require("./mock-model.js");
41
46
  const plugin_loader_js_1 = require("./plugin-loader.js");
47
+ const sandbox_js_1 = require("./sandbox.js");
42
48
  var mock_model_js_2 = require("./mock-model.js");
43
49
  Object.defineProperty(exports, "scriptModel", { enumerable: true, get: function () { return mock_model_js_2.scriptModel; } });
44
50
  var plugin_loader_js_2 = require("./plugin-loader.js");
45
51
  Object.defineProperty(exports, "loadPlugin", { enumerable: true, get: function () { return plugin_loader_js_2.loadPlugin; } });
46
52
  Object.defineProperty(exports, "resolveHarness", { enumerable: true, get: function () { return plugin_loader_js_2.resolveHarness; } });
53
+ var sandbox_js_2 = require("./sandbox.js");
54
+ Object.defineProperty(exports, "decideSandbox", { enumerable: true, get: function () { return sandbox_js_2.decideSandbox; } });
55
+ Object.defineProperty(exports, "specTrusted", { enumerable: true, get: function () { return sandbox_js_2.specTrusted; } });
56
+ Object.defineProperty(exports, "sandboxAvailable", { enumerable: true, get: function () { return sandbox_js_2.sandboxAvailable; } });
47
57
  function contentText(content) {
48
58
  if (typeof content === "string")
49
59
  return content;
@@ -101,6 +111,92 @@ function parseToolCalls(streamJson) {
101
111
  isError: results.get(u.id)?.isError ?? false,
102
112
  }));
103
113
  }
114
+ /**
115
+ * The terminal `result` event — present in BOTH `--output-format` shapes (a
116
+ * `{type:"result", …}` line in stream-json, the single object in `json`), or
117
+ * null. The seam for the final answer + turn count without parsing twice.
118
+ */
119
+ function parseResultEvent(stdout) {
120
+ for (const line of stdout.split("\n")) {
121
+ if (!line.trim())
122
+ continue;
123
+ let evt;
124
+ try {
125
+ evt = JSON.parse(line);
126
+ }
127
+ catch {
128
+ continue;
129
+ }
130
+ if (evt.type === "result")
131
+ return evt;
132
+ }
133
+ return null;
134
+ }
135
+ /** The agent's final answer text from a transcript / result object, or "". */
136
+ function parseOutput(stdout) {
137
+ const result = parseResultEvent(stdout)?.result;
138
+ return typeof result === "string" ? result : "";
139
+ }
140
+ /**
141
+ * The hooks that fired, recorded from the CLI's `hook_response` stream events
142
+ * (`--output-format stream-json`). Each carries the hook name/event, its exit
143
+ * code, and whether it blocked — the honest record vs. inferring from marker
144
+ * files. Returns [] for the non-stream `json` output (no per-hook events).
145
+ */
146
+ function toHookFire(evt) {
147
+ const exitCode = typeof evt.exit_code === "number" ? evt.exit_code : undefined;
148
+ return {
149
+ name: typeof evt.hook_name === "string" ? evt.hook_name : "",
150
+ event: typeof evt.hook_event === "string" ? evt.hook_event : "",
151
+ exitCode,
152
+ blocked: evt.outcome === "error" || (exitCode !== undefined && exitCode !== 0),
153
+ output: typeof evt.output === "string" ? evt.output : "",
154
+ };
155
+ }
156
+ function parseHooks(stdout) {
157
+ const hooks = [];
158
+ for (const line of stdout.split("\n")) {
159
+ if (!line.trim())
160
+ continue;
161
+ let evt;
162
+ try {
163
+ evt = JSON.parse(line);
164
+ }
165
+ catch {
166
+ continue;
167
+ }
168
+ if (evt.type === "system" && evt.subtype === "hook_response") {
169
+ hooks.push(toHookFire(evt));
170
+ }
171
+ }
172
+ return hooks;
173
+ }
174
+ /**
175
+ * The `claude` CLI argv for a harness run (shared by the direct and sandboxed
176
+ * paths). `ANTHROPIC_BASE_URL` is set by the caller's environment / wrapper, not
177
+ * here. Pure, so the arg shape is unit-tested.
178
+ */
179
+ function buildClaudeArgs(spec, hasSettings) {
180
+ const tools = spec.allowedTools ?? ["Read", "Edit", "Write", "Bash"];
181
+ return [
182
+ "-p",
183
+ spec.prompt ?? "go",
184
+ ...(spec.transcript
185
+ ? ["--output-format", "stream-json", "--verbose"]
186
+ : ["--output-format", "json"]),
187
+ "--model",
188
+ "claude-sonnet-4-5",
189
+ ...(spec.pluginDir !== undefined
190
+ ? ["--plugin-dir", (0, node_path_1.resolve)(spec.pluginDir)]
191
+ : []),
192
+ ...(hasSettings ? ["--settings", "settings.json"] : []),
193
+ "--allowedTools",
194
+ ...tools,
195
+ ];
196
+ }
197
+ /* v8 ignore start -- spawns the real claude CLI + filesystem; exercised by the
198
+ claude-backed suite, excluded from the deterministic coverage gate (the parse
199
+ helpers above carry the testable logic). */
104
200
  /** Whether the `claude` CLI is available — harness tests need it. */
105
201
  function claudeAvailable() {
106
202
  try {
@@ -150,8 +246,20 @@ function spawnClaude(args, cwd, baseUrl, timeoutMs) {
150
246
  /**
151
247
  * Run the real `claude` CLI against a scripted mock model, with the given
152
248
  * fixture and settings (hooks). Deterministic — same script, same result.
249
+ *
250
+ * Safe by default: an external `plugin` / `pluginDir` brings in untrusted
251
+ * third-party hooks and is confined under bubblewrap (`spec.sandbox`, default
252
+ * `"auto"`); if no sandbox is available the run REFUSES rather than executing
253
+ * unconfined. See `src/sandbox.ts`.
153
254
  */
154
255
  async function runHarnessTest(spec) {
256
+ const decision = (0, sandbox_js_1.decideSandbox)({
257
+ trusted: (0, sandbox_js_1.specTrusted)(spec),
258
+ mode: spec.sandbox ?? "auto",
259
+ available: (0, sandbox_js_1.sandboxAvailable)(),
260
+ });
261
+ if (decision.action === "throw")
262
+ throw new Error(decision.reason);
155
263
  const cwd = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-harness-"));
156
264
  const { files, settings } = (0, plugin_loader_js_1.resolveHarness)({
157
265
  plugin: spec.plugin,
@@ -159,43 +267,45 @@ async function runHarnessTest(spec) {
159
267
  files: spec.files,
160
268
  });
161
269
  writeFixture(cwd, files, settings);
270
+ const args = buildClaudeArgs(spec, settings !== undefined);
271
+ const timeoutMs = spec.timeoutMs ?? 60000;
272
+ const build = (out, turns, modelRequests) => ({
273
+ exitCode: out.code,
274
+ stdout: out.stdout,
275
+ stderr: out.stderr ?? "",
276
+ cwd,
277
+ turns,
278
+ toolCalls: parseToolCalls(out.stdout),
279
+ hooks: parseHooks(out.stdout),
280
+ output: parseOutput(out.stdout),
281
+ modelRequests,
282
+ file: (p) => {
283
+ const f = (0, node_path_1.resolve)(cwd, p);
284
+ return (0, node_fs_1.existsSync)(f) ? (0, node_fs_1.readFileSync)(f, "utf-8") : null;
285
+ },
286
+ cleanup: () => {
287
+ (0, node_fs_1.rmSync)(cwd, { recursive: true, force: true });
288
+ },
289
+ });
290
+ // Confined path: the mock is co-launched inside the sandbox's netns.
291
+ if (decision.action === "sandbox") {
292
+ const out = await (0, sandbox_js_1.runSandboxed)({
293
+ cwd,
294
+ claudeArgs: args,
295
+ script: spec.model,
296
+ timeoutMs,
297
+ });
298
+ return build(out, out.requests.length, out.requests);
299
+ }
300
+ // Direct path: mock runs in this process; claude reaches it over localhost.
162
301
  const mock = await (0, mock_model_js_1.startMock)(spec.model);
163
302
  try {
164
- const tools = spec.allowedTools ?? ["Read", "Edit", "Write", "Bash"];
165
- const args = [
166
- "-p",
167
- spec.prompt ?? "go",
168
- ...(spec.transcript
169
- ? ["--output-format", "stream-json", "--verbose"]
170
- : ["--output-format", "json"]),
171
- "--model",
172
- "claude-sonnet-4-5",
173
- ...(spec.pluginDir !== undefined
174
- ? ["--plugin-dir", (0, node_path_1.resolve)(spec.pluginDir)]
175
- : []),
176
- ...(settings !== undefined ? ["--settings", "settings.json"] : []),
177
- "--allowedTools",
178
- ...tools,
179
- ];
180
- const out = await spawnClaude(args, cwd, mock.url, spec.timeoutMs ?? 60000);
181
- return {
182
- exitCode: out.code,
183
- stdout: out.stdout,
184
- stderr: out.stderr,
185
- cwd,
186
- turns: mock.count,
187
- toolCalls: parseToolCalls(out.stdout),
188
- file: (p) => {
189
- const f = (0, node_path_1.resolve)(cwd, p);
190
- return (0, node_fs_1.existsSync)(f) ? (0, node_fs_1.readFileSync)(f, "utf-8") : null;
191
- },
192
- cleanup: () => {
193
- (0, node_fs_1.rmSync)(cwd, { recursive: true, force: true });
194
- },
195
- };
303
+ const out = await spawnClaude(args, cwd, mock.url, timeoutMs);
304
+ return build(out, mock.count, [...mock.requests]);
196
305
  }
197
306
  finally {
198
307
  mock.close();
199
308
  }
200
309
  }
310
+ /* v8 ignore stop */
201
311
  //# sourceMappingURL=harness-test.js.map
package/dist/judge.js CHANGED
@@ -36,6 +36,7 @@ function firstJsonObject(s) {
36
36
  return null;
37
37
  }
38
38
  }
39
+ /* v8 ignore start -- spawns the real claude CLI; parseJudgeOutput holds the logic */
39
40
  /** Grade `output` against `rubric` with a model. Synchronous (for `measure`). */
40
41
  function judge(opts) {
41
42
  const threshold = opts.threshold ?? 0.5;
@@ -65,6 +66,7 @@ function judge(opts) {
65
66
  }
66
67
  return parseJudgeOutput(res.stdout ?? "", threshold);
67
68
  }
69
+ /* v8 ignore stop */
68
70
  /**
69
71
  * Parse a verdict out of the grader's stdout — pure, so the parsing is testable
70
72
  * without a model. Handles `claude --output-format json` (text wrapped in a
package/dist/linters.d.ts CHANGED
@@ -24,6 +24,12 @@ export interface DetectedLinter {
24
24
  }
25
25
  /** @internal */ export declare function extractLinterName(enforcedBy: string): string;
26
26
  /** @internal */ export declare function extractRuleName(enforcedBy: string): string | null;
27
+ /**
28
+ * Levenshtein distance for short-string typo detection. Rule names are
29
+ * short so edit distance is more appropriate than NCD (which is tuned
30
+ * for longer texts).
31
+ */
32
+ export declare function editDistance(a: string, b: string): number;
27
33
  /** @internal */ export declare function clearCedarCache(): void;
28
34
  /**
29
35
  * Check a single linter rule reference (e.g., "eslint/no-console").
package/dist/linters.js CHANGED
@@ -13,6 +13,7 @@
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.extractLinterName = extractLinterName;
15
15
  exports.extractRuleName = extractRuleName;
16
+ exports.editDistance = editDistance;
16
17
  exports.clearCedarCache = clearCedarCache;
17
18
  exports.checkLinterRule = checkLinterRule;
18
19
  const node_fs_1 = require("node:fs");
@@ -0,0 +1,9 @@
1
+ /**
2
+ * `vigiles/linting` — Pillar 1 entry point: the **linting layer** for instruction
3
+ * files. Re-exports the spec builders/types and the compiler under one
4
+ * concern-named import. The granular paths (`vigiles/spec`, `vigiles/compile`)
5
+ * keep working; this just groups them so the import name matches the pillar.
6
+ */
7
+ export * from "./spec.js";
8
+ export * from "./compile.js";
9
+ //# sourceMappingURL=linting.d.ts.map
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ /**
18
+ * `vigiles/linting` — Pillar 1 entry point: the **linting layer** for instruction
19
+ * files. Re-exports the spec builders/types and the compiler under one
20
+ * concern-named import. The granular paths (`vigiles/spec`, `vigiles/compile`)
21
+ * keep working; this just groups them so the import name matches the pillar.
22
+ */
23
+ __exportStar(require("./spec.js"), exports);
24
+ __exportStar(require("./compile.js"), exports);
25
+ //# sourceMappingURL=linting.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=mock-entry.d.ts.map
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * vigiles — the in-sandbox mock entry.
5
+ *
6
+ * Run as a subprocess INSIDE the bubblewrap network namespace (see
7
+ * `src/sandbox.ts`), so the scripted mock lives on the sandbox's isolated
8
+ * loopback — reachable by the confined `claude`, unreachable from outside.
9
+ * Reads the model script from a file, streams each captured request to an ndjson
10
+ * file the parent reads back (for `trace.modelRequests`), and writes its chosen
11
+ * port so the wrapper can point `ANTHROPIC_BASE_URL` at it.
12
+ *
13
+ * node mock-entry.js <scriptFile> <requestsFile> <portFile>
14
+ *
15
+ * Not unit-tested directly (it's a daemon driven only through a live sandbox);
16
+ * exercised end-to-end by the bwrap-backed integration test.
17
+ */
18
+ const node_fs_1 = require("node:fs");
19
+ const mock_model_js_1 = require("./mock-model.js");
20
+ void (async () => {
21
+ const [scriptFile, requestsFile, portFile] = process.argv.slice(2);
22
+ if (!scriptFile || !requestsFile || !portFile) {
23
+ process.stderr.write("mock-entry: scriptFile requestsFile portFile\n");
24
+ process.exit(2);
25
+ }
26
+ const turns = JSON.parse((0, node_fs_1.readFileSync)(scriptFile, "utf-8"));
27
+ const handle = await (0, mock_model_js_1.startMock)(turns, {
28
+ onRequest: (req) => {
29
+ (0, node_fs_1.appendFileSync)(requestsFile, JSON.stringify(req) + "\n");
30
+ },
31
+ });
32
+ // Signal readiness last: the wrapper waits for a non-empty port file.
33
+ (0, node_fs_1.writeFileSync)(portFile, new URL(handle.url).port);
34
+ // Stay alive until the wrapper kills us once `claude` has finished.
35
+ })();
36
+ //# sourceMappingURL=mock-entry.js.map
@@ -14,12 +14,38 @@ export interface TurnInfo {
14
14
  readonly stream: boolean;
15
15
  readonly hasToolResult: boolean;
16
16
  }
17
+ /**
18
+ * One `/v1/messages` request the mock received, flattened to text for
19
+ * assertions. This is the seam that lets a harness test prove what reached the
20
+ * model — a SessionStart hook's injected `additionalContext`, or a slash
21
+ * command's expansion — not just that a hook fired.
22
+ */
23
+ export interface ModelRequest {
24
+ /** The system prompt, flattened to text (string or text-block array). */
25
+ readonly system: string;
26
+ /** The conversation messages, each flattened to `{ role, text }`. */
27
+ readonly messages: readonly {
28
+ readonly role: string;
29
+ readonly text: string;
30
+ }[];
31
+ }
17
32
  export interface MockHandle {
18
33
  readonly url: string;
19
34
  close(): void;
20
35
  /** Number of model turns served so far. */
21
36
  readonly count: number;
37
+ /** Every `/v1/messages` request the mock received, in order. */
38
+ readonly requests: readonly ModelRequest[];
22
39
  }
40
+ /**
41
+ * Extract a {@link ModelRequest} from a request body — the `system` prompt and
42
+ * `messages`, each flattened to text. Pure and exported so the capture logic is
43
+ * testable without the HTTP server.
44
+ */
45
+ export declare function extractRequest(body: {
46
+ system?: unknown;
47
+ messages?: unknown;
48
+ }): ModelRequest;
23
49
  /**
24
50
  * Start the scripted mock on a free port. Each `/v1/messages` POST consumes the
25
51
  * next turn (the last turn repeats if the client asks for more). Resolves to a
@@ -27,5 +53,8 @@ export interface MockHandle {
27
53
  */
28
54
  export declare function startMock(script: readonly ModelTurn[], opts?: {
29
55
  onTurn?: (info: TurnInfo) => void;
56
+ /** Called with each `/v1/messages` request as it arrives — used by the
57
+ * in-sandbox mock entry to stream requests to a file for the parent. */
58
+ onRequest?: (req: ModelRequest) => void;
30
59
  }): Promise<MockHandle>;
31
60
  //# sourceMappingURL=mock-model.d.ts.map
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.scriptModel = scriptModel;
7
+ exports.extractRequest = extractRequest;
7
8
  exports.startMock = startMock;
8
9
  /**
9
10
  * vigiles — a scripted, deterministic Anthropic Messages API mock.
@@ -127,6 +128,38 @@ function jsonTurn(res, turn, model) {
127
128
  usage: { input_tokens: 10, output_tokens: 5 },
128
129
  }));
129
130
  }
131
+ /** Flatten Anthropic content (string, or an array of text/other blocks) to text. */
132
+ function flattenContent(content) {
133
+ if (typeof content === "string")
134
+ return content;
135
+ if (!Array.isArray(content))
136
+ return "";
137
+ return content
138
+ .map((b) => {
139
+ if (typeof b === "string")
140
+ return b;
141
+ const t = b.text;
142
+ return typeof t === "string" ? t : "";
143
+ })
144
+ .join("");
145
+ }
146
+ /**
147
+ * Extract a {@link ModelRequest} from a request body — the `system` prompt and
148
+ * `messages`, each flattened to text. Pure and exported so the capture logic is
149
+ * testable without the HTTP server.
150
+ */
151
+ function extractRequest(body) {
152
+ const messages = Array.isArray(body.messages)
153
+ ? body.messages.map((m) => {
154
+ const msg = m;
155
+ return {
156
+ role: typeof msg.role === "string" ? msg.role : "",
157
+ text: flattenContent(msg.content),
158
+ };
159
+ })
160
+ : [];
161
+ return { system: flattenContent(body.system), messages };
162
+ }
130
163
  /**
131
164
  * Start the scripted mock on a free port. Each `/v1/messages` POST consumes the
132
165
  * next turn (the last turn repeats if the client asks for more). Resolves to a
@@ -134,6 +167,7 @@ function jsonTurn(res, turn, model) {
134
167
  */
135
168
  function startMock(script, opts = {}) {
136
169
  let i = 0;
170
+ const requests = [];
137
171
  const server = node_http_1.default.createServer((req, res) => {
138
172
  let body = "";
139
173
  req.on("data", (c) => (body += c));
@@ -158,6 +192,9 @@ function startMock(script, opts = {}) {
158
192
  res.end(JSON.stringify({ input_tokens: 10 }));
159
193
  return;
160
194
  }
195
+ const request = extractRequest(reqBody);
196
+ requests.push(request);
197
+ opts.onRequest?.(request);
161
198
  const last = JSON.stringify(reqBody.messages?.at(-1)?.content ?? "");
162
199
  opts.onTurn?.({
163
200
  n: i,
@@ -182,6 +219,9 @@ function startMock(script, opts = {}) {
182
219
  get count() {
183
220
  return i;
184
221
  },
222
+ get requests() {
223
+ return requests;
224
+ },
185
225
  });
186
226
  });
187
227
  });
@@ -26,16 +26,19 @@ exports.resolveHarness = resolveHarness;
26
26
  const node_fs_1 = require("node:fs");
27
27
  const node_path_1 = require("node:path");
28
28
  const MAX_SKILL_FILE_BYTES = 256 * 1024;
29
- /** Read and return the `.hooks` field of a JSON file, or undefined on any error. */
30
- function readHooksFile(path) {
29
+ /** Parse a JSON file, or null on any error (missing / malformed). */
30
+ function safeReadJson(path) {
31
31
  try {
32
- return JSON.parse((0, node_fs_1.readFileSync)(path, "utf-8"))
33
- .hooks;
32
+ return JSON.parse((0, node_fs_1.readFileSync)(path, "utf-8"));
34
33
  }
35
34
  catch {
36
- return undefined;
35
+ return null;
37
36
  }
38
37
  }
38
+ /** Read and return the `.hooks` field of a JSON file, or undefined on any error. */
39
+ function readHooksFile(path) {
40
+ return safeReadJson(path)?.hooks;
41
+ }
39
42
  /**
40
43
  * Read the hooks block, handling the real-world plugin layouts:
41
44
  * 1. inline `hooks` object in .claude-plugin/plugin.json,
@@ -44,9 +47,10 @@ function readHooksFile(path) {
44
47
  * 4. a plain repo's `.claude/settings.json`.
45
48
  */
46
49
  function readHooks(root) {
47
- const manifestPath = (0, node_path_1.join)(root, ".claude-plugin", "plugin.json");
48
- if ((0, node_fs_1.existsSync)(manifestPath)) {
49
- const m = JSON.parse((0, node_fs_1.readFileSync)(manifestPath, "utf-8"));
50
+ // A malformed plugin.json must not crash the loader — fall through to the
51
+ // other layouts (safeReadJson returns null on a parse error).
52
+ const m = safeReadJson((0, node_path_1.join)(root, ".claude-plugin", "plugin.json"));
53
+ if (m) {
50
54
  if (typeof m.hooks === "string")
51
55
  return readHooksFile((0, node_path_1.join)(root, m.hooks));
52
56
  if (m.hooks !== undefined)
@@ -134,6 +138,12 @@ function pluginWarnings(root, counts, hooks, files) {
134
138
  if (hasMcp(root)) {
135
139
  warnings.push(`plugin declares MCP server(s) (mcpServers / .mcp.json) — the loader does not wire MCP; bring the server up yourself if your test needs it.`);
136
140
  }
141
+ const dangling = danglingRefs(root);
142
+ if (dangling.length) {
143
+ const shown = dangling.slice(0, 5).join(", ");
144
+ const more = dangling.length > 5 ? `, … (+${String(dangling.length - 5)})` : "";
145
+ warnings.push(`plugin references ${String(dangling.length)} intra-plugin file(s) that don't exist (broken path / partial vendor): ${shown}${more}`);
146
+ }
137
147
  if (!hooks && Object.keys(files).length === 0) {
138
148
  warnings.push(`nothing was loaded (no hooks, CLAUDE.md, skills, agents, or commands) — the deterministic harness would run an effectively empty machine.`);
139
149
  }
@@ -143,16 +153,40 @@ function pluginWarnings(root, counts, hooks, files) {
143
153
  function hasMcp(root) {
144
154
  if ((0, node_fs_1.existsSync)((0, node_path_1.join)(root, ".mcp.json")))
145
155
  return true;
146
- const manifestPath = (0, node_path_1.join)(root, ".claude-plugin", "plugin.json");
147
- if (!(0, node_fs_1.existsSync)(manifestPath))
148
- return false;
149
- try {
150
- const m = JSON.parse((0, node_fs_1.readFileSync)(manifestPath, "utf-8"));
151
- return m.mcpServers !== undefined;
152
- }
153
- catch {
154
- return false;
156
+ return (safeReadJson((0, node_path_1.join)(root, ".claude-plugin", "plugin.json"))?.mcpServers !==
157
+ undefined);
158
+ }
159
+ // A plugin-relative path reference to a file under a standard surface dir, with a
160
+ // known extension e.g. a hook script that `cat`s `skills/using-superpowers/SKILL.md`.
161
+ const INTRA_REF_RE = /(?:skills|hooks|commands|agents)\/[A-Za-z0-9._/-]+\.(?:md|sh|cmd|mjs|cjs|js|ts|py|rb|txt|json)/g;
162
+ /**
163
+ * Intra-plugin file references that don't resolve — the partial-vendor / broken-
164
+ * path class (e.g. obra/superpowers' `SessionStart` reads
165
+ * `skills/using-superpowers/SKILL.md`, which a sliced vendor snapshot omits). We
166
+ * scan the plugin's own text files under the surface dirs (hooks scripts
167
+ * included — those aren't materialized into `files`) for root-relative path refs
168
+ * and report the ones missing on disk. A static check that would have caught a
169
+ * bug the dogfood hit twice. Best-effort: a warning, not an error.
170
+ */
171
+ function danglingRefs(root) {
172
+ const missing = new Set();
173
+ const seen = new Set();
174
+ for (const surface of ["hooks", "skills", "agents", "commands"]) {
175
+ const dir = (0, node_path_1.join)(root, surface);
176
+ if (!(0, node_fs_1.existsSync)(dir) || !(0, node_fs_1.statSync)(dir).isDirectory())
177
+ continue;
178
+ for (const content of Object.values(readTree(dir, root))) {
179
+ for (const m of content.matchAll(INTRA_REF_RE)) {
180
+ const ref = m[0];
181
+ if (seen.has(ref))
182
+ continue;
183
+ seen.add(ref);
184
+ if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(root, ref)))
185
+ missing.add(ref);
186
+ }
187
+ }
155
188
  }
189
+ return [...missing];
156
190
  }
157
191
  /**
158
192
  * Merge a loaded plugin's settings with inline settings. Inline wins; when both