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
@@ -450,12 +450,12 @@ function evaluated(r, firingMeasured) {
450
450
  score: null,
451
451
  weight: 1,
452
452
  advisory: true,
453
- findings: ["no surface whose firing could be measured"],
453
+ findings: ["no surface a real model could be measured on"],
454
454
  };
455
455
  }
456
456
  const unevaluated = r.unevaluated ?? evaluable;
457
457
  const evalCovered = evaluable - unevaluated;
458
- const gap = `${String(unevaluated)} ${pluralizeLabel(unevaluated, "surface(s) whose firing was never measured")}`;
458
+ const gap = `${String(unevaluated)} ${pluralizeLabel(unevaluated, "surface(s) never measured with a real model")}`;
459
459
  // Nothing on disk measures firing AND nothing ran this session → the question
460
460
  // was never asked. Say that, and name the command — do NOT score it a 0.
461
461
  if (evalCovered === 0 && !firingMeasured) {
package/dist/check.d.ts CHANGED
@@ -137,6 +137,26 @@ export declare function onlyTools(allowed: readonly string[]): Check<Trace>;
137
137
  export declare function skill(id: string): Check<Trace>;
138
138
  /** The agent's final output contains a substring / matches a RegExp. */
139
139
  export declare function output(matcher: string | RegExp): Check<Trace>;
140
+ /**
141
+ * EVERY reply of the run contains a substring / matches a RegExp — for a rule
142
+ * about each reply ("end with a status block"), where `output` sees only the
143
+ * last one. A run with no replies fails: there is nothing the rule held for.
144
+ *
145
+ * @experimental The matcher may grow a function form, and a check may come to
146
+ * be attached to a spec rule (vigiles#323).
147
+ */
148
+ export declare function experimental_eachReply(matcher: string | Readonly<RegExp>): Readonly<Check<Trace>>;
149
+ /**
150
+ * How many replies of the run match, within bounds — `{ max: 1 }` is "at most
151
+ * one status block for one user message", which a Stop hook that makes the
152
+ * agent reply again can break while `output` still shows one.
153
+ *
154
+ * @experimental See {@link experimental_eachReply}.
155
+ */
156
+ export declare function experimental_replyCount(matcher: string | Readonly<RegExp>, opts: {
157
+ readonly min?: number;
158
+ readonly max?: number;
159
+ }): Readonly<Check<Trace>>;
140
160
  /** A hook fired for this event (e.g. `"PreToolUse"`, `"Stop"`). */
141
161
  export declare function hookFired(event: string): Check<Trace>;
142
162
  /**
package/dist/check.js CHANGED
@@ -8,6 +8,8 @@ exports.notTool = notTool;
8
8
  exports.onlyTools = onlyTools;
9
9
  exports.skill = skill;
10
10
  exports.output = output;
11
+ exports.experimental_eachReply = experimental_eachReply;
12
+ exports.experimental_replyCount = experimental_replyCount;
11
13
  exports.hookFired = hookFired;
12
14
  exports.received = received;
13
15
  exports.turns = turns;
@@ -75,6 +77,29 @@ function truncate(s, n = 120) {
75
77
  const flat = s.replace(/\s+/g, " ").trim();
76
78
  return flat.length > n ? `${flat.slice(0, n)}…` : flat;
77
79
  }
80
+ /**
81
+ * Does `text` match? A RegExp is copied first: with `g` / `y`, `test` advances
82
+ * `lastIndex` on the object it is called on, so reusing the caller's object
83
+ * would miss on its second reply or its second trial.
84
+ */
85
+ const matches = (matcher, text) => typeof matcher === "string"
86
+ ? text.includes(matcher)
87
+ : new RegExp(matcher).test(text);
88
+ /** A matcher as the report prints it: the string, or the RegExp literal. */
89
+ const show = (matcher) => typeof matcher === "string" ? matcher : `/${matcher.source}/${matcher.flags}`;
90
+ /** Why a reply check cannot run: the trace carries no replies to read. */
91
+ const NO_REPLIES = "the run has no `replies` (the harness does not tell replies apart, or the run was not streamed — pass `transcript: true`)";
92
+ /** Why a `{ min, max }` bound cannot mean anything, or null when it can. */
93
+ function boundsProblem(opts) {
94
+ const given = [opts.min, opts.max].filter((n) => n !== undefined);
95
+ if (given.length === 0)
96
+ return "give `min`, `max` or both";
97
+ if (given.some((n) => !Number.isInteger(n) || n < 0))
98
+ return "bounds are whole numbers, 0 or more";
99
+ if (opts.min !== undefined && opts.max !== undefined && opts.min > opts.max)
100
+ return `min ${String(opts.min)} is above max ${String(opts.max)}`;
101
+ return null;
102
+ }
78
103
  // ---------------------------------------------------------------------------
79
104
  // Trace checks (an agent run — runHarness / runEval)
80
105
  // ---------------------------------------------------------------------------
@@ -248,7 +273,7 @@ function output(matcher) {
248
273
  return {
249
274
  kind: "output",
250
275
  eval: (t) => {
251
- const pass = isRe ? matcher.test(t.output) : t.output.includes(matcher);
276
+ const pass = matches(matcher, t.output);
252
277
  return pass
253
278
  ? ok(`output matched ${String(matcher)}`)
254
279
  : no(`expected output to ${isRe ? "match" : "contain"} ${String(matcher)}; got "${truncate(t.output) || "(empty)"}"`);
@@ -256,6 +281,66 @@ function output(matcher) {
256
281
  toJSON: () => ({ kind: "output", matcher: String(matcher), regex: isRe }),
257
282
  };
258
283
  }
284
+ /**
285
+ * EVERY reply of the run contains a substring / matches a RegExp — for a rule
286
+ * about each reply ("end with a status block"), where `output` sees only the
287
+ * last one. A run with no replies fails: there is nothing the rule held for.
288
+ *
289
+ * @experimental The matcher may grow a function form, and a check may come to
290
+ * be attached to a spec rule (vigiles#323).
291
+ */
292
+ function experimental_eachReply(matcher) {
293
+ return {
294
+ kind: "eachReply",
295
+ eval: (t) => {
296
+ if (t.replies === undefined)
297
+ return no(NO_REPLIES);
298
+ if (t.replies.length === 0)
299
+ return no("the run wrote no replies");
300
+ const miss = t.replies.findIndex((r) => !matches(matcher, r));
301
+ return miss === -1
302
+ ? ok(`all ${String(t.replies.length)} replies matched ${show(matcher)}`)
303
+ : no(`reply ${String(miss + 1)} of ${String(t.replies.length)} did not match ${show(matcher)}: "${truncate(t.replies[miss] ?? "") || "(empty)"}"`);
304
+ },
305
+ toJSON: () => ({
306
+ kind: "eachReply",
307
+ matcher: show(matcher),
308
+ regex: typeof matcher !== "string",
309
+ }),
310
+ };
311
+ }
312
+ /**
313
+ * How many replies of the run match, within bounds — `{ max: 1 }` is "at most
314
+ * one status block for one user message", which a Stop hook that makes the
315
+ * agent reply again can break while `output` still shows one.
316
+ *
317
+ * @experimental See {@link experimental_eachReply}.
318
+ */
319
+ function experimental_replyCount(matcher, opts) {
320
+ const problem = boundsProblem(opts);
321
+ if (problem !== null)
322
+ throw new Error(`replyCount: ${problem}`);
323
+ return {
324
+ kind: "replyCount",
325
+ eval: (t) => {
326
+ if (t.replies === undefined)
327
+ return no(NO_REPLIES);
328
+ const n = t.replies.filter((r) => matches(matcher, r)).length;
329
+ const pass = (opts.min === undefined || n >= opts.min) &&
330
+ (opts.max === undefined || n <= opts.max);
331
+ const bound = `min ${String(opts.min ?? "-")}, max ${String(opts.max ?? "-")}`;
332
+ const says = `${String(n)} of ${String(t.replies.length)} replies matched ${show(matcher)} (${bound})`;
333
+ return pass ? ok(says) : no(`expected ${bound}; ${says}`);
334
+ },
335
+ toJSON: () => ({
336
+ kind: "replyCount",
337
+ matcher: show(matcher),
338
+ regex: typeof matcher !== "string",
339
+ min: opts.min,
340
+ max: opts.max,
341
+ }),
342
+ };
343
+ }
259
344
  /** A hook fired for this event (e.g. `"PreToolUse"`, `"Stop"`). */
260
345
  function hookFired(event) {
261
346
  return {
@@ -284,7 +369,7 @@ function received(matcher) {
284
369
  const text = t.modelRequests
285
370
  .map((r) => `${r.system} ${r.messages.map((m) => m.text).join(" ")}`)
286
371
  .join(" ");
287
- const pass = isRe ? matcher.test(text) : text.includes(matcher);
372
+ const pass = matches(matcher, text);
288
373
  return pass
289
374
  ? ok(`the model received ${String(matcher)}`)
290
375
  : no(`expected the model to receive ${String(matcher)} (a slash-command expansion or injected context); ${t.modelRequests.length === 0 ? "no requests captured (eval tier captures none — use runHarness)" : `got "${truncate(text)}"`}`);
package/dist/cli-main.js CHANGED
@@ -39,11 +39,13 @@ const frame_js_1 = require("./core/frame.js");
39
39
  const surface_discovery_js_1 = require("./core/surface-discovery.js");
40
40
  const scan_trigger_suggest_js_1 = require("./scan-trigger-suggest.js");
41
41
  const install_reader_js_1 = require("./core/install-reader.js");
42
+ const skill_links_js_1 = require("./skill-links.js");
42
43
  const plugin_declaration_js_1 = require("./plugin-declaration.js");
43
44
  const local_files_tracked_js_1 = require("./local-files-tracked.js");
44
45
  const local_files_js_1 = require("./local-files.js");
45
46
  const scan_behavioral_js_1 = require("./scan-behavioral.js");
46
47
  const adapter_registry_js_1 = require("./adapter-registry.js");
48
+ const layout_js_1 = require("./core/layout.js");
47
49
  const skill_harness_js_1 = require("./skill-harness.js");
48
50
  const leaderboard_js_1 = require("./leaderboard.js");
49
51
  const optimize_js_1 = require("./optimize.js");
@@ -3177,29 +3179,226 @@ function reportInstall(plan, outcome) {
3177
3179
  }
3178
3180
  /**
3179
3181
  * Install vigiles's skills/hooks for the chosen harness(es) via the per-harness
3180
- * `planPluginInstall` decision — Claude Code through the GLOBAL plugin
3181
- * marketplace (nothing vendored into the repo), Codex via AGENTS.md-direct (no
3182
- * global store). The decision is pure and unit-tested; this is the thin IO.
3182
+ * `planPluginInstall` decision. Two carriers: the SKILLS are linked into the
3183
+ * repo (`linkVigilesSkills`), so every clone and container has them after
3184
+ * `npm install`; the GLOBAL install runs where it carries something the links
3185
+ * cannot (`shouldRunGlobalInstall` — Claude Code's plugin carries the hooks).
3186
+ * The decisions are pure and unit-tested; this is the thin IO.
3187
+ *
3188
+ * Returns the links it created or relinked, for the commit hint.
3183
3189
  */
3184
3190
  function installPlugins(harnesses) {
3185
3191
  const { execSync: exec } = require("node:child_process");
3186
3192
  const plans = (0, setup_plan_js_1.planPluginInstall)(harnesses, {
3187
3193
  hasClaude: harnesses.includes("claude") && harnessBinaryPresent("claude"),
3188
3194
  });
3189
- for (const plan of plans) {
3195
+ const written = plans.flatMap((plan) => {
3190
3196
  console.log("");
3191
- reportInstall(plan, runInstall(plan, exec));
3192
- }
3197
+ const home = plan.linkSkills ? harnessSkillsHome(plan.harness) : null;
3198
+ const outcome = home === null ? null : linkVigilesSkills(home, process.cwd());
3199
+ if (outcome !== null)
3200
+ (0, skill_links_js_1.formatSkillLinks)(outcome).forEach((l) => {
3201
+ console.log(l);
3202
+ });
3203
+ const linked = outcome !== null && (0, skill_links_js_1.skillLinksUsable)(outcome);
3204
+ if ((0, setup_plan_js_1.shouldRunGlobalInstall)(plan, linked))
3205
+ reportInstall(plan, runInstall(plan, exec));
3206
+ else
3207
+ console.log(` No global ${plan.harness} skills install — the linked skills replace it, in every clone.`);
3208
+ return outcome === null ? [] : changedLinks(outcome);
3209
+ });
3193
3210
  // Claude Code gets its hooks from the global marketplace plugin; Codex has no
3194
3211
  // global store, so wire vigiles's proactive nudge hooks into the repo's
3195
3212
  // .codex/config.toml directly (the idiomatic, repo-committed place).
3196
3213
  if (harnesses.includes("codex"))
3197
3214
  wireCodexHooks();
3198
3215
  // Claude Code additionally gets a committed DECLARATION, so a collaborator who
3199
- // clones and never runs `init` is told the project wants this plugin instead
3200
- // of hitting the silence that costs a day. It does not install anything.
3216
+ // clones and never runs `init` is told the project wants this plugin (its
3217
+ // hooks) instead of silence. It does not install anything.
3201
3218
  if (harnesses.includes("claude"))
3202
3219
  declareVigilesPlugin();
3220
+ return written;
3221
+ }
3222
+ /** The repo-relative skills home of the harness `init` calls `name`, or null. */
3223
+ function harnessSkillsHome(name) {
3224
+ const adapter = (0, adapter_registry_js_1.getAdapter)(name);
3225
+ return adapter === undefined ? null : (0, layout_js_1.skillsHome)(adapter.layout);
3226
+ }
3227
+ /** The repo-relative paths of the links this run created or rewrote — the ones git sees change. */
3228
+ function changedLinks(outcome) {
3229
+ return outcome.kind === "linked"
3230
+ ? outcome.results
3231
+ .filter((r) => r.status === "created" || r.status === "relinked")
3232
+ .map((r) => `${outcome.physicalHome}/${r.name}`)
3233
+ : [];
3234
+ }
3235
+ /** The manifest file name the skill-linking code reads. */
3236
+ const PACKAGE_JSON = "package.json";
3237
+ /** One top-level field of `<dir>/package.json`, or undefined. Never throws. */
3238
+ function packageField(dir, field) {
3239
+ try {
3240
+ const pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(dir, PACKAGE_JSON), "utf-8"));
3241
+ return typeof pkg === "object" && pkg !== null
3242
+ ? Object.entries(pkg).find(([k]) => k === field)?.[1]
3243
+ : undefined;
3244
+ }
3245
+ catch {
3246
+ return undefined;
3247
+ }
3248
+ }
3249
+ /** `package.json`'s `name`, or null. Never throws. */
3250
+ function packageNameAt(dir) {
3251
+ const name = packageField(dir, "name");
3252
+ return typeof name === "string" ? name : null;
3253
+ }
3254
+ /** A thrown value's message. */
3255
+ function errorText(e) {
3256
+ return e instanceof Error ? e.message : String(e);
3257
+ }
3258
+ /** A path's real location, or null when it does not resolve. Never throws. */
3259
+ function realOrNull(path) {
3260
+ try {
3261
+ return (0, node_fs_1.realpathSync)(path);
3262
+ }
3263
+ catch {
3264
+ return null;
3265
+ }
3266
+ }
3267
+ /** What occupies `path`, for `planSkillLinks`. */
3268
+ function observeSkillEntry(path) {
3269
+ const st = (0, node_fs_1.lstatSync)(path, { throwIfNoEntry: false });
3270
+ if (st === undefined)
3271
+ return { kind: "missing" };
3272
+ if (st.isSymbolicLink())
3273
+ return {
3274
+ kind: "link",
3275
+ target: (0, node_fs_1.readlinkSync)(path),
3276
+ resolvesTo: realOrNull(path),
3277
+ };
3278
+ return st.isDirectory() ? { kind: "directory" } : { kind: "file" };
3279
+ }
3280
+ const LINK_IO = {
3281
+ // "dir" matters only on Windows, where a directory link must say so.
3282
+ symlink: (target, path) => {
3283
+ (0, node_fs_1.symlinkSync)(target, path, "dir");
3284
+ },
3285
+ rename: node_fs_1.renameSync,
3286
+ unlink: node_fs_1.unlinkSync,
3287
+ };
3288
+ /** Carry out one decision. A failed `symlink` is reported, never thrown. */
3289
+ function applySkillLink(decision, absHome) {
3290
+ switch (decision.action) {
3291
+ case "keep":
3292
+ return { name: decision.name, status: "present" };
3293
+ case "refuse":
3294
+ return {
3295
+ name: decision.name,
3296
+ status: "skipped",
3297
+ reason: decision.reason,
3298
+ };
3299
+ case "create":
3300
+ case "replace":
3301
+ try {
3302
+ // Only a link `decideSkillLink` proved ours is ever replaced.
3303
+ (0, skill_links_js_1.writeSkillLink)(LINK_IO, (0, node_path_1.join)(absHome, decision.name), decision.target, decision.action);
3304
+ return {
3305
+ name: decision.name,
3306
+ status: decision.action === "replace" ? "relinked" : "created",
3307
+ };
3308
+ }
3309
+ catch (e) {
3310
+ const code = e instanceof Error && "code" in e && typeof e.code === "string"
3311
+ ? e.code
3312
+ : "";
3313
+ return {
3314
+ name: decision.name,
3315
+ status: "skipped",
3316
+ reason: (0, skill_links_js_1.linkFailureReason)(code, errorText(e)),
3317
+ };
3318
+ }
3319
+ }
3320
+ }
3321
+ /**
3322
+ * Why `init` must not write under `absHome`, or null when it may. `.claude` or
3323
+ * its `skills` can be a link to a dotfiles directory outside the repo; creating
3324
+ * the home would follow it and put links where no commit of this repo reaches.
3325
+ * What `mkdir -p` would write through is the nearest part of the path that
3326
+ * already exists, so that is the part judged.
3327
+ */
3328
+ function homeEscape(project, home, absHome) {
3329
+ const nearest = (p) => (0, node_fs_1.lstatSync)(p, { throwIfNoEntry: false }) === undefined
3330
+ ? nearest((0, node_path_1.dirname)(p))
3331
+ : p;
3332
+ const anchor = nearest(absHome);
3333
+ const real = realOrNull(anchor);
3334
+ if (real === null)
3335
+ return `${(0, node_path_1.relative)(project, anchor)} is a link that does not resolve, so ${home} cannot be created`;
3336
+ return (0, skill_links_js_1.isWithinProject)(project, real)
3337
+ ? null
3338
+ : `${home} leads to ${real}, outside the repository, and a link written there would not travel with a clone`;
3339
+ }
3340
+ /** The outcome when `init` makes no links, with the reason it prints. */
3341
+ function notLinked(reason) {
3342
+ return { kind: "not-linked", reason };
3343
+ }
3344
+ /**
3345
+ * Link vigiles's shipped skills into `home` (repo-relative) — the IO half of
3346
+ * `src/skill-links.ts`, which decides everything: where the package is, what
3347
+ * each entry becomes, and what the report says.
3348
+ */
3349
+ function linkVigilesSkills(home, cwd) {
3350
+ // Physical, so the relative target computed against the (physical) skills
3351
+ // home is right even when the checkout sits under a symlink (macOS /tmp).
3352
+ const project = (0, node_fs_1.realpathSync)(cwd);
3353
+ const site = (0, skill_links_js_1.locatePackage)(project, {
3354
+ isPackage: (dir) => packageNameAt(dir) === skill_links_js_1.VIGILES_PACKAGE,
3355
+ isRepoRoot: (dir) => (0, node_fs_1.existsSync)((0, node_path_1.join)(dir, ".git")),
3356
+ isWorkspaceRoot: (dir) => (0, node_fs_1.existsSync)((0, node_path_1.join)(dir, "pnpm-workspace.yaml")) ||
3357
+ packageField(dir, "workspaces") !== undefined,
3358
+ });
3359
+ const pkgText = (0, node_fs_1.existsSync)((0, node_path_1.join)(project, PACKAGE_JSON))
3360
+ ? (0, node_fs_1.readFileSync)((0, node_path_1.join)(project, PACKAGE_JSON), "utf-8")
3361
+ : null;
3362
+ const blocked = (0, skill_links_js_1.linkPrecondition)({
3363
+ isVigilesItself: packageNameAt(project) === skill_links_js_1.VIGILES_PACKAGE,
3364
+ dependsOnVigiles: pkgText !== null && (0, install_reader_js_1.declaresVigilesDependency)(pkgText),
3365
+ installed: site.kind === "installed",
3366
+ });
3367
+ if (blocked !== null)
3368
+ return notLinked(blocked);
3369
+ if (site.kind === "unsure")
3370
+ return notLinked(site.reason);
3371
+ // Names from the installed package when there is one, else from this CLI's
3372
+ // own — the version `init` just declared as the devDependency.
3373
+ const source = site.kind === "installed" ? site.dir : selfRoot();
3374
+ const names = setup_plan_js_1.SHIPPED_SKILLS.filter((n) => (0, node_fs_1.existsSync)((0, node_path_1.join)(source, "skills", n, "SKILL.md")));
3375
+ if (names.length === 0)
3376
+ return notLinked(`no shipped skills found under ${(0, node_path_1.join)(source, "skills")}`);
3377
+ const absHome = (0, node_path_1.resolve)(project, home);
3378
+ const escape = homeEscape(project, home, absHome);
3379
+ if (escape !== null)
3380
+ return notLinked(escape);
3381
+ try {
3382
+ (0, node_fs_1.mkdirSync)(absHome, { recursive: true });
3383
+ }
3384
+ catch (e) {
3385
+ return notLinked(`cannot create ${home}: ${errorText(e)}`);
3386
+ }
3387
+ const physicalHome = (0, node_fs_1.realpathSync)(absHome);
3388
+ const plan = (0, skill_links_js_1.planSkillLinks)({
3389
+ names,
3390
+ site,
3391
+ physicalHome,
3392
+ entries: (n) => observeSkillEntry((0, node_path_1.join)(absHome, n)),
3393
+ realOf: (n) => realOrNull((0, node_path_1.join)(site.dir, "skills", n)),
3394
+ });
3395
+ return {
3396
+ kind: "linked",
3397
+ home,
3398
+ physicalHome: (0, skill_links_js_1.portableTarget)((0, node_path_1.relative)(project, physicalHome)),
3399
+ pending: plan.pending,
3400
+ results: plan.decisions.map((d) => applySkillLink(d, absHome)),
3401
+ };
3203
3402
  }
3204
3403
  /**
3205
3404
  * Write the project-level plugin declaration into `.claude/settings.json`.
@@ -3208,8 +3407,9 @@ function installPlugins(harnesses) {
3208
3407
  * collaborator: an external-source plugin declared project-level does not load
3209
3408
  * until each person installs it on their own machine (the boundary is
3210
3409
  * deliberate — plugins run arbitrary code with the user's privileges). What it
3211
- * buys is that Claude Code then PROMPTS them with the install command, instead
3212
- * of the silence a fresh clone gets today. Silent absence → a prompt.
3410
+ * buys is that Claude Code then PROMPTS them with the install command — for the
3411
+ * HOOKS, which only the plugin carries. The skills reach them through the
3412
+ * committed links (`linkVigilesSkills`), with or without the plugin.
3213
3413
  *
3214
3414
  * MERGES, never clobbers: this file holds the user's hooks, permissions and
3215
3415
  * other plugins. The merge rules are the pure, unit-tested
@@ -3249,8 +3449,9 @@ function declareVigilesPlugin() {
3249
3449
  (0, node_fs_1.writeFileSync)(path, JSON.stringify(edit.settings, null, 2) + "\n");
3250
3450
  console.log("✓ Declared the vigiles plugin in .claude/settings.json (commit it)\n" +
3251
3451
  " This does NOT install it for anyone else — Claude Code will PROMPT a\n" +
3252
- " collaborator to run `claude plugin install vigiles@vigiles`, instead of\n" +
3253
- " the silence a fresh clone gets today.\n" +
3452
+ " collaborator to run `claude plugin install vigiles@vigiles`, which is how\n" +
3453
+ " they get the hooks. The skills don't wait for it: they come with the\n" +
3454
+ " linked .claude/skills/ entries.\n" +
3254
3455
  " Nothing is vendored: the entry is a reference; the plugin still lives in\n" +
3255
3456
  " the global cache, one copy shared across your repos.");
3256
3457
  }
@@ -3460,10 +3661,9 @@ async function setup(args) {
3460
3661
  console.log("");
3461
3662
  written.push(...wireGha(plan, harnesses));
3462
3663
  }
3463
- // Plugin/skill install — per-harness (Claude marketplace / Codex direct).
3464
- if (plan.plugin) {
3465
- installPlugins(harnesses);
3466
- }
3664
+ // Skills linked into the repo + the per-harness global install (Claude
3665
+ // marketplace for the hooks / Codex only when nothing could be linked).
3666
+ const linkedSkills = plan.plugin ? installPlugins(harnesses) : [];
3467
3667
  // Agent-specific guidance.
3468
3668
  if (targets.includes("AGENTS.md")) {
3469
3669
  console.log("\n Codex / Copilot reads AGENTS.md directly — no hooks needed.");
@@ -3483,7 +3683,14 @@ async function setup(args) {
3483
3683
  lint: plan.lint,
3484
3684
  written,
3485
3685
  });
3486
- printSetupSummary({ plan, strict, targets, adopted, written });
3686
+ printSetupSummary({
3687
+ plan,
3688
+ strict,
3689
+ targets,
3690
+ adopted,
3691
+ // The skill links the plugin step created, so the commit hint names them.
3692
+ written: [...written, ...linkedSkills],
3693
+ });
3487
3694
  // Surface the OTHER mode so both directions are discoverable — the two branches
3488
3695
  // are mutually exclusive (a run is either gate-only or full):
3489
3696
  // • a gate-only setup → a ONE-LINE nudge to graduate to the full layer;
@@ -3595,6 +3802,18 @@ function harnessLayoutFor(root, config, flag) {
3595
3802
  return adapter_registry_js_1.defaultAdapter.layout;
3596
3803
  }
3597
3804
  }
3805
+ /**
3806
+ * The rule that gates each surface kind, one row per kind. `satisfies` makes a
3807
+ * missing row a compile error: the nested ternary this replaced ended in the
3808
+ * hook rule, so a kind it did not name was gated by `untested-hook` and still
3809
+ * compiled.
3810
+ */
3811
+ const UNTESTED_RULE = {
3812
+ skill: "untested-skill",
3813
+ agent: "untested-subagent",
3814
+ hook: "untested-hook",
3815
+ "output-style": "untested-output-style",
3816
+ };
3598
3817
  /**
3599
3818
  * The `untested-*` rules AS THE DETECTOR TAKES THEM: which kinds this repo
3600
3819
  * enabled, and the discovery options merged from whichever of the three rules
@@ -3611,21 +3830,21 @@ function harnessLayoutFor(root, config, flag) {
3611
3830
  */
3612
3831
  function untestedRules(config) {
3613
3832
  const rules = config?.rules;
3614
- const skillSev = (0, types_js_1.ruleSeverity)(rules?.["untested-skill"]);
3615
- const agentSev = (0, types_js_1.ruleSeverity)(rules?.["untested-subagent"]);
3616
- const hookSev = (0, types_js_1.ruleSeverity)(rules?.["untested-hook"]);
3617
- const opts = {
3618
- ...(0, types_js_1.ruleOptions)(rules?.["untested-skill"]),
3619
- ...(0, types_js_1.ruleOptions)(rules?.["untested-subagent"]),
3620
- ...(0, types_js_1.ruleOptions)(rules?.["untested-hook"]),
3621
- };
3833
+ const severity = (kind) => (0, types_js_1.ruleSeverity)(rules?.[UNTESTED_RULE[kind]]);
3834
+ const names = Object.values(UNTESTED_RULE);
3835
+ const opts = names.reduce((acc, rule) => ({
3836
+ ...acc,
3837
+ ...(0, types_js_1.ruleOptions)(rules?.[rule]),
3838
+ }), {});
3839
+ const on = (kind) => severity(kind) !== false;
3622
3840
  return {
3623
- severity: (kind) => kind === "skill" ? skillSev : kind === "agent" ? agentSev : hookSev,
3624
- anyEnabled: skillSev !== false || agentSev !== false || hookSev !== false,
3841
+ severity,
3842
+ anyEnabled: names.some((rule) => (0, types_js_1.ruleSeverity)(rules?.[rule]) !== false),
3625
3843
  options: {
3626
- skills: skillSev !== false,
3627
- agents: agentSev !== false,
3628
- hooks: hookSev !== false,
3844
+ skills: on("skill"),
3845
+ agents: on("agent"),
3846
+ hooks: on("hook"),
3847
+ outputStyles: on("output-style"),
3629
3848
  include: opts.include,
3630
3849
  exclude: opts.exclude,
3631
3850
  // Without this the `testExtension` documented on TestCoverageOptions was a
@@ -182,6 +182,13 @@ export interface InstallReader {
182
182
  * outside its own surface.
183
183
  */
184
184
  readonly repo: (repoRelative: string) => string | null;
185
+ /**
186
+ * The stored target of a repo SYMLINK (what `readlink` returns), or null when
187
+ * the path is not a link, is missing, or is NOT a path this adapter claims.
188
+ * It answers the one question `repo` cannot: a committed link whose target is
189
+ * not installed yet reads as null through `repo`, exactly like no link at all.
190
+ */
191
+ readonly repoLink: (repoRelative: string) => string | null;
185
192
  /**
186
193
  * A file under the user's HOME, or null. This is a read of the MACHINE, never
187
194
  * of the repository — the plugin-install record lives there — and everything
@@ -109,6 +109,15 @@ export declare const vigilesConfigSchema: z.ZodObject<{
109
109
  exclude: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<readonly string[], string | string[]>>>;
110
110
  testExtension: z.ZodOptional<z.ZodString>;
111
111
  }, z.core.$strict>], null>]>>;
112
+ "untested-output-style": z.ZodDefault<z.ZodUnion<readonly [z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "warn" | "error", boolean | 0 | 1 | 2 | "warn" | "error" | "off">>, z.ZodTuple<[z.ZodLiteral<"warn">, z.ZodObject<{
113
+ include: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<readonly string[], string | string[]>>>;
114
+ exclude: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<readonly string[], string | string[]>>>;
115
+ testExtension: z.ZodOptional<z.ZodString>;
116
+ }, z.core.$strict>], null>, z.ZodTuple<[z.ZodLiteral<"error">, z.ZodObject<{
117
+ include: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<readonly string[], string | string[]>>>;
118
+ exclude: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<readonly string[], string | string[]>>>;
119
+ testExtension: z.ZodOptional<z.ZodString>;
120
+ }, z.core.$strict>], null>]>>;
112
121
  "unmarked-refs": z.ZodDefault<z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "warn" | "error", boolean | 0 | 1 | 2 | "warn" | "error" | "off">>>;
113
122
  "subagent-tool-contract": z.ZodDefault<z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "warn" | "error", boolean | 0 | 1 | 2 | "warn" | "error" | "off">>>;
114
123
  "hook-events": z.ZodDefault<z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "warn" | "error", boolean | 0 | 1 | 2 | "warn" | "error" | "off">>>;
@@ -141,6 +141,7 @@ const rulesSchema = zod_1.z
141
141
  "untested-skill": withOptions(testCoverageConfig).default("warn"),
142
142
  "untested-subagent": withOptions(testCoverageConfig).default("warn"),
143
143
  "untested-hook": withOptions(testCoverageConfig).default("warn"),
144
+ "untested-output-style": withOptions(testCoverageConfig).default("warn"),
144
145
  "unmarked-refs": severitySchema.default("warn"),
145
146
  "subagent-tool-contract": severitySchema.default("warn"),
146
147
  "hook-events": severitySchema.default("warn"),
@@ -71,6 +71,14 @@ export interface Trace {
71
71
  readonly hooks: readonly HookFire[];
72
72
  /** The agent's final answer text (the terminal `result` event), or "". */
73
73
  readonly output: string;
74
+ /**
75
+ * Every reply the agent ended a turn with, in order — the `output` is only
76
+ * the last. A run a Stop hook continued has two, and anything the first one
77
+ * printed (a status footer) shows only here. Text the agent writes before a
78
+ * tool call is narration, not a reply. Absent when the harness's run output
79
+ * does not tell replies apart, or the run was not streamed (`transcript: true`).
80
+ */
81
+ readonly replies?: readonly string[];
74
82
  /**
75
83
  * The requests the model received, captured by the scripted mock — each with
76
84
  * its `system` prompt and `messages`, flattened to text. Lets a test assert
@@ -153,6 +161,8 @@ export interface ParsedModelRun {
153
161
  readonly toolCalls: ToolCall[];
154
162
  readonly hooks: HookFire[];
155
163
  readonly subagents: SubagentTrace[];
164
+ /** Every reply, in order; absent when the harness cannot tell them apart. */
165
+ readonly replies?: readonly string[];
156
166
  readonly usage: EvalUsage;
157
167
  }
158
168
  export type ModelOutputParser = (out: RunOut) => ParsedModelRun;
@@ -1,3 +1,34 @@
1
+ /**
2
+ * Lenient frontmatter reader — ONE reader for the SKILL.md / subagent `---` block,
3
+ * shared by `scan` and the PreToolUse rail (`agent-runtime`). It is deliberately
4
+ * fault-tolerant: it audits arbitrary third-party files, so it must never throw
5
+ * and must salvage the few scalar/list fields it needs even from a block that
6
+ * isn't valid YAML.
7
+ *
8
+ * The strategy is "real parser, with a safety net": try `js-yaml` (so block
9
+ * scalars, quoted/multi-line values, and flow arrays parse correctly for free);
10
+ * if the block isn't valid YAML, fall back to a regex salvage of the requested
11
+ * field and record `malformed: true` (the signal the `frontmatter-valid` rule
12
+ * reports). A single bad line therefore never blanks out a whole file's metadata.
13
+ *
14
+ * This replaces three divergent hand-parsers (the old `readField` in scan.ts and
15
+ * the regex parse in agent-runtime.ts); `core/frontmatter.ts` is a DIFFERENT
16
+ * concern (the Level-1 `vigiles:` rule block) and is untouched.
17
+ */
18
+ /**
19
+ * 🔴 `js-yaml` IS REQUIRED LAZILY, AND THAT IS A MEASUREMENT, NOT A STYLE. The
20
+ * same wrapper is in `core/settings-codec.ts` and `core/markdown.ts` with the
21
+ * reason recorded: a top-level import puts the parser into the module graph of
22
+ * every HOOK DECISION, because the hook runtime reaches the adapter registry
23
+ * and from there a layout. This module became reachable that way on 2026-09-21
24
+ * through `PluginLayout.instructionChain` (a rule's `paths:` frontmatter is
25
+ * what decides whether it loads at launch) — measured by
26
+ * `src/hook-runtime-graph.test.ts`: 37 modules became 71 with an eager import.
27
+ * `tsc` lowers this to CommonJS, so the `require` does not run until a
28
+ * frontmatter block is actually parsed, and {@link frontmatterBody}, which needs
29
+ * no YAML at all, never triggers it.
30
+ */
31
+ export declare const yaml: () => typeof import("js-yaml");
1
32
  export interface FrontmatterRead {
2
33
  /** Parsed mapping when the block is valid YAML, else null (malformed or scalar). */
3
34
  readonly data: Record<string, unknown> | null;
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.yaml = void 0;
3
4
  exports.frontmatterBody = frontmatterBody;
4
5
  exports.readFrontmatter = readFrontmatter;
5
6
  exports.frontmatterScalar = frontmatterScalar;
@@ -35,6 +36,7 @@ exports.frontmatterList = frontmatterList;
35
36
  * no YAML at all, never triggers it.
36
37
  */
37
38
  const yaml = () => require("js-yaml");
39
+ exports.yaml = yaml;
38
40
  // Frontmatter is the very first thing in the file. Anchoring at the start — not
39
41
  // `(?:^|\n)` — means a `---` horizontal rule in the BODY is never mistaken for
40
42
  // frontmatter (which matters for the malformed-YAML verdict). A leading BOM is
@@ -67,7 +69,7 @@ function readFrontmatter(markdown) {
67
69
  return { data: null, block: null, malformed: false };
68
70
  const block = m[1];
69
71
  try {
70
- const parsed = yaml().load(block);
72
+ const parsed = (0, exports.yaml)().load(block);
71
73
  if (parsed !== null &&
72
74
  typeof parsed === "object" &&
73
75
  !Array.isArray(parsed)) {
@@ -82,7 +84,7 @@ function readFrontmatter(markdown) {
82
84
  return { data: null, block, malformed: false };
83
85
  }
84
86
  catch (e) {
85
- if (e instanceof yaml().YAMLException)
87
+ if (e instanceof (0, exports.yaml)().YAMLException)
86
88
  return { data: null, block, malformed: true };
87
89
  throw e;
88
90
  }