vigiles 33.2.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.
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;
@@ -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
@@ -30,6 +30,18 @@ function readOrNull(absolute) {
30
30
  return null;
31
31
  }
32
32
  }
33
+ /** A symlink's stored target, or null when it is not a link. Never throws. */
34
+ function linkOrNull(absolute) {
35
+ try {
36
+ return (0, node_fs_1.lstatSync)(absolute, { throwIfNoEntry: false })?.isSymbolicLink() ===
37
+ true
38
+ ? (0, node_fs_1.readlinkSync)(absolute)
39
+ : null;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
33
45
  /** Directory names directly under `dir`, or [] when it is not readable. */
34
46
  function dirNames(dir) {
35
47
  try {
@@ -80,6 +92,9 @@ function buildInstallReader(adapter, root, opts = {}) {
80
92
  repo: (repoRelative) => adapter.claims(repoRelative)
81
93
  ? readOrNull((0, node_path_1.resolve)(root, repoRelative))
82
94
  : null,
95
+ repoLink: (repoRelative) => adapter.claims(repoRelative)
96
+ ? linkOrNull((0, node_path_1.resolve)(root, repoRelative))
97
+ : null,
83
98
  home: (homeRelative) => readOrNull((0, node_path_1.join)(home, homeRelative)),
84
99
  repoDependsOnVigiles: pkgJson !== null && declaresVigilesDependency(pkgJson),
85
100
  vendoredSkillNames: dirNames((0, node_path_1.join)(root, "node_modules", "vigiles", "skills")),
@@ -241,6 +241,16 @@ export declare function executableSourceDirs(layout: PluginLayout): readonly str
241
241
  * written a layout where they differed.
242
242
  */
243
243
  export declare function materializePrefix(layout: PluginLayout): string;
244
+ /**
245
+ * Where a REPOSITORY keeps its skills for this harness, repo-relative — the
246
+ * skill surface under {@link materializePrefix}: `.claude/skills` on Claude
247
+ * Code, `.agents/skills` on Codex. `null` when the layout has no skill surface.
248
+ *
249
+ * `vigiles init` links the package's shipped skills here, so it must be the
250
+ * directory the harness actually scans for a project; deriving it from the two
251
+ * fields that already say so keeps a third spelling from appearing.
252
+ */
253
+ export declare function skillsHome(layout: Pick<PluginLayout, "surfaces" | "userSurfaceRoot">): string | null;
244
254
  /**
245
255
  * How DEEP a harness reads its {@link PluginLayout.agentDir} — the one statement
246
256
  * of that rule, as a RegExp source fragment matching the part of a path AFTER
@@ -4,6 +4,7 @@ exports.RULE_FILE_LEAF_RE = exports.AGENT_FILE_LEAF_RE = exports.SURFACE_KINDS =
4
4
  exports.surfaceDirs = surfaceDirs;
5
5
  exports.executableSourceDirs = executableSourceDirs;
6
6
  exports.materializePrefix = materializePrefix;
7
+ exports.skillsHome = skillsHome;
7
8
  exports.rulesHome = rulesHome;
8
9
  exports.ruleFileRe = ruleFileRe;
9
10
  exports.agentSurfaceName = agentSurfaceName;
@@ -46,6 +47,22 @@ function executableSourceDirs(layout) {
46
47
  function materializePrefix(layout) {
47
48
  return layout.userSurfaceRoot ?? "";
48
49
  }
50
+ /**
51
+ * Where a REPOSITORY keeps its skills for this harness, repo-relative — the
52
+ * skill surface under {@link materializePrefix}: `.claude/skills` on Claude
53
+ * Code, `.agents/skills` on Codex. `null` when the layout has no skill surface.
54
+ *
55
+ * `vigiles init` links the package's shipped skills here, so it must be the
56
+ * directory the harness actually scans for a project; deriving it from the two
57
+ * fields that already say so keeps a third spelling from appearing.
58
+ */
59
+ function skillsHome(layout) {
60
+ const skill = layout.surfaces.skill;
61
+ if (skill === undefined || skill === "")
62
+ return null;
63
+ const root = layout.userSurfaceRoot;
64
+ return root === undefined || root === "" ? skill : `${root}/${skill}`;
65
+ }
49
66
  /**
50
67
  * How DEEP a harness reads its {@link PluginLayout.agentDir} — the one statement
51
68
  * of that rule, as a RegExp source fragment matching the part of a path AFTER
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The environment a child agent run inherits — the one place the runners turn
3
+ * a caller's `process.env` into a spawn env.
4
+ *
5
+ * Two shapes, both driven by the harness's declared {@link RunEnvPolicy}:
6
+ *
7
+ * - SCRUBBED (`scrubbedRunEnv`): a throwaway HOME/TMPDIR, the OS essentials, the
8
+ * harness's declared auth, and the caller's own named extras. Everything else
9
+ * is dropped. The eval tier's `ephemeralEnv` and the harness-tier preflight of
10
+ * `experimental_outputStyleArms` use it.
11
+ * - INHERITED (`withoutSessionIdentity`): the caller's env as-is, minus the
12
+ * harness's declared session identity. The default spawn of both tiers.
13
+ *
14
+ * In both, the parent session's identity never reaches the child. The core
15
+ * names no harness: which variables are auth and which are identity is the
16
+ * adapter's declaration.
17
+ *
18
+ * Pure — no fs, no spawn.
19
+ */
20
+ import type { RunEnvPolicy } from "./runtime.js";
21
+ type EnvLike = Readonly<Record<string, string | undefined>>;
22
+ /**
23
+ * What ANY spawned process needs to function, whatever the harness: resolve
24
+ * binaries (`PATH`) and keep the locale and terminal (`LANG`, `LC_*`, `TERM`).
25
+ * Mirrors what `bwrapArgs` sets back after `--clearenv` in `src/sandbox.ts`.
26
+ */
27
+ export declare const OS_ESSENTIAL_ENV: readonly string[];
28
+ /**
29
+ * Build a SCRUBBED run environment: a NEW env with `HOME`/`TMPDIR` set to the
30
+ * throwaway `opts.home`, the {@link OS_ESSENTIAL_ENV}, the harness's
31
+ * `policy.keep`, and the caller's `opts.allow`, taken from `base`. Everything
32
+ * else is dropped, and `policy.sessionIdentity` is dropped even when listed in
33
+ * `keep` or `allow`.
34
+ *
35
+ * Without a policy only the essentials pass: right for a run against a scripted
36
+ * mock, which needs no auth.
37
+ */
38
+ export declare function scrubbedRunEnv(base: EnvLike, opts: {
39
+ readonly home: string;
40
+ readonly policy?: RunEnvPolicy;
41
+ readonly allow?: readonly string[];
42
+ }): Readonly<Record<string, string>>;
43
+ /**
44
+ * The caller's env with the harness's declared session identity removed — the
45
+ * INHERITED shape. Unset (`undefined`) values are dropped too, so the result is
46
+ * a plain spawn env. Without a policy nothing is removed.
47
+ */
48
+ export declare function withoutSessionIdentity(base: EnvLike, policy?: RunEnvPolicy): Readonly<Record<string, string>>;
49
+ export {};
50
+ //# sourceMappingURL=run-env.d.ts.map
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OS_ESSENTIAL_ENV = void 0;
4
+ exports.scrubbedRunEnv = scrubbedRunEnv;
5
+ exports.withoutSessionIdentity = withoutSessionIdentity;
6
+ /**
7
+ * What ANY spawned process needs to function, whatever the harness: resolve
8
+ * binaries (`PATH`) and keep the locale and terminal (`LANG`, `LC_*`, `TERM`).
9
+ * Mirrors what `bwrapArgs` sets back after `--clearenv` in `src/sandbox.ts`.
10
+ */
11
+ exports.OS_ESSENTIAL_ENV = ["PATH", "LANG", "TERM"];
12
+ const OS_ESSENTIAL_PREFIXES = ["LC_"];
13
+ /**
14
+ * Build a SCRUBBED run environment: a NEW env with `HOME`/`TMPDIR` set to the
15
+ * throwaway `opts.home`, the {@link OS_ESSENTIAL_ENV}, the harness's
16
+ * `policy.keep`, and the caller's `opts.allow`, taken from `base`. Everything
17
+ * else is dropped, and `policy.sessionIdentity` is dropped even when listed in
18
+ * `keep` or `allow`.
19
+ *
20
+ * Without a policy only the essentials pass: right for a run against a scripted
21
+ * mock, which needs no auth.
22
+ */
23
+ function scrubbedRunEnv(base, opts) {
24
+ const identity = new Set(opts.policy?.sessionIdentity ?? []);
25
+ const exact = new Set([
26
+ ...exports.OS_ESSENTIAL_ENV,
27
+ ...(opts.policy?.keep ?? []),
28
+ ...(opts.allow ?? []),
29
+ ]);
30
+ const passes = (k) => !identity.has(k) &&
31
+ (exact.has(k) || OS_ESSENTIAL_PREFIXES.some((p) => k.startsWith(p)));
32
+ return {
33
+ ...defined(base, passes),
34
+ // Last, so they win over anything passed through.
35
+ HOME: opts.home,
36
+ TMPDIR: opts.home,
37
+ };
38
+ }
39
+ /**
40
+ * The caller's env with the harness's declared session identity removed — the
41
+ * INHERITED shape. Unset (`undefined`) values are dropped too, so the result is
42
+ * a plain spawn env. Without a policy nothing is removed.
43
+ */
44
+ function withoutSessionIdentity(base, policy) {
45
+ const identity = new Set(policy?.sessionIdentity ?? []);
46
+ return defined(base, (k) => !identity.has(k));
47
+ }
48
+ function defined(base, keep) {
49
+ return Object.fromEntries(Object.entries(base).filter((e) => e[1] !== undefined && keep(e[0])));
50
+ }
51
+ //# sourceMappingURL=run-env.js.map
@@ -54,5 +54,49 @@ export interface HarnessRuntime {
54
54
  * caller resolves the raw string via `agentBinary --version`); unit-testable.
55
55
  */
56
56
  versionKey(raw: string): string;
57
+ /**
58
+ * Which of the caller's environment a child run may inherit — see
59
+ * {@link RunEnvPolicy}. Absent: a scrubbed run inherits no harness variable,
60
+ * and nothing is removed from an inherited env.
61
+ */
62
+ readonly runEnv?: RunEnvPolicy;
63
+ }
64
+ /**
65
+ * Which of the caller's environment a CHILD run of this harness may inherit.
66
+ *
67
+ * A run spawned from inside a live session of the same harness (an eval started
68
+ * by an agent, a harness test run from a hook) inherits that session's process
69
+ * environment, and a harness marks its own session there. Measured on Claude
70
+ * Code 2.1.292 (2026-10-07): a child `claude -p` started with the parent's
71
+ * session variables ran AS the parent session — a task it created landed in the
72
+ * parent's live task list. Which variables are identity and which are auth is a
73
+ * fact about one harness, so the adapter declares it and the runners apply it
74
+ * (`src/core/run-env.ts`) without spelling a single name.
75
+ *
76
+ * Optional, so an adapter written before this field still type-checks. An
77
+ * adapter without one gets the strict reading: a scrubbed run inherits no
78
+ * harness variable at all, and an inherited one has nothing removed.
79
+ */
80
+ export interface RunEnvPolicy {
81
+ /**
82
+ * Exact names a SCRUBBED run (fresh HOME, cleared env) inherits: the
83
+ * harness's auth and backend selection, nothing else. An allowlist on purpose —
84
+ * a harness's session variables are open-ended (the Claude Code 2.1.292
85
+ * binary references several dozen `*SESSION*` / `*SOCKET*` / `*TOKEN*` names),
86
+ * so a prefix with a few exceptions would keep passing whichever ones nobody
87
+ * listed.
88
+ */
89
+ readonly keep: readonly string[];
90
+ /**
91
+ * Auth FILES under HOME (relative paths) that a scrubbed run's throwaway HOME
92
+ * is seeded with, by copy. The file half of `keep`.
93
+ */
94
+ readonly keepHomeFiles: readonly string[];
95
+ /**
96
+ * The PARENT session's identity: names no child run ever inherits — not a
97
+ * scrubbed run, and not a run that otherwise inherits the whole caller env.
98
+ * Wins over `keep` and over any name a caller allows.
99
+ */
100
+ readonly sessionIdentity: readonly string[];
57
101
  }
58
102
  //# sourceMappingURL=runtime.d.ts.map
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Is a symlink's stored target one that `vigiles init` writes — a path ending
3
+ * in `node_modules/vigiles/skills/<name>`, for the SAME skill name?
4
+ *
5
+ * Two readers ask, and must agree, so the answer lives here once:
6
+ *
7
+ * - `init` (`../skill-links.ts`): an existing link of this shape that points
8
+ * somewhere other than where `init` would link now was made by an earlier
9
+ * `init` (for example before a workspace install hoisted the package), so it
10
+ * is replaced instead of being refused as the user's;
11
+ * - `audit` (`../adapters/claude-code/skill-reachability.ts`): only a dangling
12
+ * link of this shape is "vigiles's link, run npm install". A dangling link
13
+ * that merely shares a skill's NAME is the user's, and the advice would send
14
+ * them to fix the wrong thing.
15
+ *
16
+ * The test is on path SEGMENTS (either separator, so a link written on Windows
17
+ * reads the same), not on a substring: `my-node_modules/vigiles/...` is not it.
18
+ */
19
+ export declare function isVigilesSkillTarget(target: string, name: string): boolean;
20
+ //# sourceMappingURL=skill-link-target.d.ts.map
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isVigilesSkillTarget = isVigilesSkillTarget;
4
+ /**
5
+ * Is a symlink's stored target one that `vigiles init` writes — a path ending
6
+ * in `node_modules/vigiles/skills/<name>`, for the SAME skill name?
7
+ *
8
+ * Two readers ask, and must agree, so the answer lives here once:
9
+ *
10
+ * - `init` (`../skill-links.ts`): an existing link of this shape that points
11
+ * somewhere other than where `init` would link now was made by an earlier
12
+ * `init` (for example before a workspace install hoisted the package), so it
13
+ * is replaced instead of being refused as the user's;
14
+ * - `audit` (`../adapters/claude-code/skill-reachability.ts`): only a dangling
15
+ * link of this shape is "vigiles's link, run npm install". A dangling link
16
+ * that merely shares a skill's NAME is the user's, and the advice would send
17
+ * them to fix the wrong thing.
18
+ *
19
+ * The test is on path SEGMENTS (either separator, so a link written on Windows
20
+ * reads the same), not on a substring: `my-node_modules/vigiles/...` is not it.
21
+ */
22
+ function isVigilesSkillTarget(target, name) {
23
+ const tail = target
24
+ .split(/[\\/]+/)
25
+ .filter((s) => s !== "")
26
+ .slice(-4);
27
+ return (tail.length === 4 &&
28
+ tail[0] === "node_modules" &&
29
+ tail[1] === "vigiles" &&
30
+ tail[2] === "skills" &&
31
+ tail[3] === name);
32
+ }
33
+ //# sourceMappingURL=skill-link-target.js.map