vigiles 33.2.0 → 34.0.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.
@@ -10,7 +10,7 @@
10
10
  * on a prompt. See docs/agent-setup.md.
11
11
  */
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
- exports.SHIPPED_SKILLS = exports.NUDGE_RULES = exports.WORKFLOW_RULES = exports.STRUCTURAL_RULES = void 0;
13
+ exports.SHIPPED_SKILLS = exports.NUDGE_RULES = exports.WORKFLOW_RULES = void 0;
14
14
  exports.parseSetupArgs = parseSetupArgs;
15
15
  exports.defaultPlan = defaultPlan;
16
16
  exports.mergeProjectConfig = mergeProjectConfig;
@@ -18,9 +18,15 @@ exports.shouldPrompt = shouldPrompt;
18
18
  exports.collectSetupAnswers = collectSetupAnswers;
19
19
  exports.gateOnlyInvitation = gateOnlyInvitation;
20
20
  exports.planPluginInstall = planPluginInstall;
21
+ exports.shouldRunGlobalInstall = shouldRunGlobalInstall;
21
22
  exports.codexPluginHooks = codexPluginHooks;
22
23
  exports.applyCodexPluginHooks = applyCodexPluginHooks;
24
+ exports.adoptionState = adoptionState;
25
+ exports.rerunScope = rerunScope;
26
+ exports.missingVigilesDevDep = missingVigilesDevDep;
27
+ exports.fullSetupFollowUps = fullSetupFollowUps;
23
28
  exports.resolvePlan = resolvePlan;
29
+ const presets_js_1 = require("./core/presets.js");
24
30
  function flagValue(args, prefix) {
25
31
  return args.find((a) => a.startsWith(prefix))?.slice(prefix.length);
26
32
  }
@@ -47,6 +53,7 @@ function parseSetupArgs(args) {
47
53
  gha: args.includes("--no-gha") ? false : undefined,
48
54
  plugin: args.includes("--no-plugin") ? false : undefined,
49
55
  ciOnly: args.includes("--ci-only"),
56
+ full: args.includes("--full"),
50
57
  };
51
58
  }
52
59
  /** The non-interactive defaults: both pillars, CI, and the plugin. `scaffoldSpecs`
@@ -65,39 +72,6 @@ function defaultPlan(strict = false) {
65
72
  force: false,
66
73
  };
67
74
  }
68
- /**
69
- * Pure config-merge for what `vigiles init` writes to `.vigilesrc.json`: record
70
- * the `harness` if absent, add strict rule severities if `--strict`, NEVER
71
- * clobber an existing key. Returns the merged config, or `null` when nothing
72
- * changed (so the IO layer skips the write). The IO (read/parse/write + the
73
- * malformed-file guard) stays in cli.ts.
74
- */
75
- /**
76
- * The structural rules `init` gates BY DEFAULT (severity `error`, so a broken
77
- * surface fails `vigiles lint`). Every one is HIGH-PRECISION / FP-safe — it fires
78
- * only on a genuine defect (a never-available/typo'd tool, a subagent missing
79
- * `name`/`description`, a typo'd hook event, a dead hook script, a broken MCP
80
- * ref, two skills that collide in the selector) — so a well-formed plugin stays
81
- * green and catching real breakage out of the box never cries wolf.
82
- *
83
- * Deliberately EXCLUDES `require-instructions-spec` and the workflow-forcing rules:
84
- * those make a CLEAN repo fail (you simply haven't written the spec/test yet), so
85
- * they stay opt-in under `--strict` (progressive adoption — see
86
- * `STRICT_EXTRA_RULES`).
87
- *
88
- * This is the **`structural`** rule group (see research/install-enforcement-dx.md).
89
- */
90
- exports.STRUCTURAL_RULES = [
91
- "subagent-tool-contract",
92
- "subagent-frontmatter",
93
- "hook-events",
94
- "hook-script-exists",
95
- "mcp-config",
96
- "mcp-tool-resolves",
97
- "mcp-hook-target-resolves",
98
- "disallowed-tools-contract",
99
- "description-overlap",
100
- ];
101
75
  /**
102
76
  * The **`workflow`** group — the WORKFLOW-FORCING / opinionated tier `--strict`
103
77
  * gates, which a clean repo can still fail because you haven't done the work yet:
@@ -146,41 +120,53 @@ exports.NUDGE_RULES = [
146
120
  // than config. See docs/rules/doc-refs.md.
147
121
  "doc-refs",
148
122
  ];
123
+ /**
124
+ * Pure config-merge for what `vigiles init` writes to `.vigilesrc.json`: record
125
+ * the harnesses if absent, gate the structural rules by EXTENDING the
126
+ * `vigiles:recommended` preset (never by writing each rule), add the workflow
127
+ * rules on `--strict`, and NEVER clobber an existing key or severity. Returns
128
+ * the merged config, or `null` when nothing changed (so the IO layer skips the
129
+ * write). The IO (read/parse/write + the malformed-file guard) stays in cli.ts.
130
+ *
131
+ * 🔴 THE PRESET, NOT NINE LINES (#338). Written lines are a snapshot of the
132
+ * structural group on the day `init` ran, so a repo adopted earlier never got a
133
+ * rule added to the group later; the preset is resolved by the loader on every
134
+ * run (`src/core/presets.ts`).
135
+ *
136
+ * `--report-only` is the exception and stays EXPLICIT: it promises that nothing
137
+ * fails CI, and extending a preset that later gains a rule at `error` would
138
+ * break that promise on an upgrade. So it writes the same rules at `warn`.
139
+ */
149
140
  function mergeProjectConfig(existing, opts) {
150
- const config = { ...existing };
151
- let changed = false;
152
141
  // The NESTED key (#240). `init` writes the declaration with no roots — a repo
153
142
  // whose surfaces sit where its harness reads them needs none, and a root is a
154
- // fact only the owner knows. Writing the flat `harness`/`surfaceRoots` pair
155
- // here would emit a config the loader now REFUSES, which is why this one line
156
- // moved with the shape even though the rest of `init` did not.
157
- if (config.harnesses === undefined) {
158
- config.harnesses = Object.fromEntries(opts.harnesses.map((h) => [h, {}]));
159
- changed = true;
160
- }
161
- // The rule gate belongs to the LINT layer — a test-only setup records the
162
- // harness but writes no rules (honoring the positive-flag contract that
163
- // `--test` selects only the test pillar).
164
- if (opts.lint !== false) {
165
- // Gate the FP-safe `structural` group by default; `--strict` adds the
166
- // `workflow` group on top. `--report-only` is the orthogonal severity dial —
167
- // it writes the SAME rule set at "warn" (nothing fails CI; the
168
- // migration/observe mode). Never clobber a severity the user already set —
169
- // only fill the undefined ones.
170
- const severity = opts.reportOnly ? "warn" : "error";
171
- const gate = opts.strict
172
- ? [...exports.STRUCTURAL_RULES, ...exports.WORKFLOW_RULES]
173
- : [...exports.STRUCTURAL_RULES];
174
- const rules = { ...config.rules };
175
- for (const r of gate) {
176
- if (rules[r] === undefined) {
177
- rules[r] = severity;
178
- changed = true;
179
- }
180
- }
181
- config.rules = rules;
182
- }
183
- return changed ? config : null;
143
+ // fact only the owner knows.
144
+ const harnesses = existing.harnesses === undefined
145
+ ? { harnesses: Object.fromEntries(opts.harnesses.map((h) => [h, {}])) }
146
+ : {};
147
+ const gate = opts.lint === false ? {} : lintGate(existing, opts);
148
+ const added = { ...harnesses, ...gate };
149
+ return Object.keys(added).length === 0 ? null : { ...existing, ...added };
150
+ }
151
+ /**
152
+ * The lint-layer keys `init` adds: `extends` and/or the `rules` it fills.
153
+ * Only the keys that CHANGE are returned, so an already-satisfied config
154
+ * yields `{}` and is not rewritten.
155
+ */
156
+ function lintGate(existing, opts) {
157
+ const workflow = opts.strict ? exports.WORKFLOW_RULES : [];
158
+ const explicit = opts.reportOnly
159
+ ? [...presets_js_1.RECOMMENDED_RULES, ...workflow]
160
+ : workflow;
161
+ const severity = opts.reportOnly ? "warn" : "error";
162
+ const before = (existing.rules ?? {});
163
+ // Never clobber a severity the user already set — only fill the undefined ones.
164
+ const filled = Object.fromEntries(explicit.filter((r) => before[r] === undefined).map((r) => [r, severity]));
165
+ const rules = Object.keys(filled).length === 0 ? {} : { rules: { ...before, ...filled } };
166
+ const preset = opts.reportOnly || existing.extends !== undefined
167
+ ? {}
168
+ : { extends: presets_js_1.RECOMMENDED_PRESET };
169
+ return { ...preset, ...rules };
184
170
  }
185
171
  /**
186
172
  * Whether to drop into interactive prompts: a human at a TTY who passed neither
@@ -225,7 +211,7 @@ async function collectSetupAnswers(ask) {
225
211
  }
226
212
  const pillars = (await ask("Set up which pillars? [both/lint/test] (both): ", "both")).toLowerCase();
227
213
  const gha = isYesAnswer(await ask("Wire CI (GitHub Action)? [Y/n]: ", "y"));
228
- const plugin = isYesAnswer(await ask("Install the Claude Code plugin (hooks + skills)? [Y/n]: ", "y"));
214
+ const plugin = isYesAnswer(await ask("Install vigiles's skills + hooks for your agent (skills linked into the repo, hooks via the Claude Code plugin)? [Y/n]: ", "y"));
229
215
  // Structural gating (broken tools/hooks/MCP/collisions) is always on. This asks
230
216
  // about the WORKFLOW tier — a spec per file + a test per surface — which a clean
231
217
  // repo can fail just for not having done the work yet, so it's the recommended
@@ -287,16 +273,8 @@ function gateOnlyInvitation(plan) {
287
273
  const gateOnly = !plan.plugin && !plan.scaffoldSpecs;
288
274
  if (!gateOnly)
289
275
  return null;
290
- return "→ Want your agent to maintain this + measure whether your skills fire? Run `npx vigiles init` and choose 'full' (installs the skills). Optional — the gate above already works.";
276
+ return "→ Want your agent to maintain this + measure whether your skills fire? Run `npx vigiles init --full` and choose 'full' (installs the skills). Optional — the gate above already works.";
291
277
  }
292
- /** Per-harness install plan. `hasClaude` gates the auto-run `claude plugin` CLI
293
- * (else the same two steps are printed as `/plugin` slash commands).
294
- *
295
- * Both methods install GLOBALLY, never into the repo: Claude through its plugin
296
- * marketplace (~/.claude/plugins/), Codex through the cross-agent `skills` CLI
297
- * with `-g -y` (the global store ~/.agents/skills/, which Codex reads). Codex
298
- * gets the skills but NOT hooks — Codex hook wiring (.codex/config.toml [hooks])
299
- * is not automated yet. */
300
278
  /**
301
279
  * The SHIPPED consumer skills (the `skills/` dir, published via `plugin.json`) —
302
280
  * the ONLY ones a user's install should get. The cross-agent `skills` CLI would
@@ -314,70 +292,111 @@ exports.SHIPPED_SKILLS = [
314
292
  "strengthen",
315
293
  "test-harness",
316
294
  ];
295
+ /**
296
+ * Per-harness install plan. `hasClaude` gates the auto-run `claude plugin` CLI
297
+ * (else the same two steps are printed as `/plugin` slash commands).
298
+ *
299
+ * TWO CARRIERS, by what each one reaches. The SKILLS are linked into the repo
300
+ * (`linkSkills`), so every clone and container has them after `npm install`. The
301
+ * GLOBAL installs reach one machine: Claude Code's plugin (~/.claude/plugins/)
302
+ * is still the only carrier of vigiles's Claude Code HOOKS, so it always runs;
303
+ * Codex's `skills` CLI install (~/.agents/skills/) carries skills only, so it
304
+ * runs only when the links could not be made (no `package.json` to install
305
+ * into). Codex's nudge hooks are written into `.codex/config.toml` directly.
306
+ *
307
+ * THE CODEX GLOBAL COMMAND. The cross-agent `skills` CLI with `-g -y` installs
308
+ * to the global store ~/.agents/skills/ (NOT the repo, and NOT ~/.codex/ —
309
+ * verified against the real CLI). It is scoped to the SHIPPED skills with `-s`
310
+ * — without it the `skills` CLI installs EVERY SKILL.md in the repo, leaking
311
+ * the contributor-only .claude/skills/ into the user's global store
312
+ * (#dogfood-I2; confirmed — the CLI's `-l` list shows 17 skills for this repo,
313
+ * incl. .claude/skills). The `-s` parser is SPACE-separated (it consumes
314
+ * consecutive non-dash args), NOT comma-separated — a comma list is read as one
315
+ * literal skill name, matches nothing, and the install exits 1 (verified against
316
+ * the real `skills@1.5.20` arg parser).
317
+ */
317
318
  function planPluginInstall(harnesses, opts) {
318
- return harnesses.map((harness) => {
319
- if (harness === "claude") {
320
- return {
321
- harness,
322
- commands: opts.hasClaude
323
- ? [
324
- "claude plugin marketplace add zernie/vigiles",
325
- "claude plugin install vigiles@vigiles",
326
- ]
327
- : [],
328
- successMessage: "✓ Installed the vigiles plugin (hooks + skills) into ~/.claude/plugins/",
329
- manualSteps: [
330
- "/plugin marketplace add zernie/vigiles",
331
- "/plugin install vigiles@vigiles",
332
- ],
333
- notes: [
334
- "Installs globally to ~/.claude/plugins/ — nothing is added to your repo.",
335
- ],
336
- vendors: false,
337
- };
338
- }
339
- if (harness === "codex") {
340
- // The cross-agent `skills` CLI with `-g -y` installs to the global store
341
- // ~/.agents/skills/ (NOT the repo, and NOT ~/.codex/ — verified against
342
- // the real CLI). Skills install globally; the proactive NUDGE hooks
343
- // (eval-lock + refs) are wired into the repo's .codex/config.toml by
344
- // `init` (see codexPluginHooks / wireCodexHooks) — Codex config is
345
- // repo-committed, so that's the idiomatic place.
346
- // Scope to the SHIPPED skills with `-s` — without it the `skills` CLI
347
- // installs EVERY SKILL.md in the repo, leaking the contributor-only
348
- // .claude/skills/ into the user's global store (#dogfood-I2; confirmed —
349
- // the CLI's `-l` list shows 17 skills for this repo, incl. .claude/skills).
350
- // The `-s` parser is SPACE-separated (it consumes consecutive non-dash
351
- // args), NOT comma-separated — a comma list is read as one literal skill
352
- // name, matches nothing, and the install exits 1 (verified against the
353
- // real `skills@1.5.20` arg parser).
354
- const skillScope = `-s ${exports.SHIPPED_SKILLS.join(" ")}`;
355
- return {
356
- harness,
357
- commands: [
358
- `npx --yes skills add zernie/vigiles -a codex ${skillScope} -g -y`,
359
- ],
360
- successMessage: "✓ Installed the vigiles skills into ~/.agents/skills/ (global, not vendored)",
361
- manualSteps: [
362
- `npx skills add zernie/vigiles -a codex ${skillScope} -g -y`,
363
- ],
364
- notes: [
365
- "Codex reads AGENTS.md directly; the skills install globally to ~/.agents/skills/ (not the repo).",
366
- "The eval-lock + refs NUDGE hooks are wired into .codex/config.toml (repo-committed, the Codex norm).",
367
- "Still manual on Codex: the SessionStart lint summary + compile-on-edit/pre-edit guards (no harness-neutral entrypoint yet).",
368
- ],
369
- vendors: true,
370
- };
371
- }
319
+ return harnesses.map((harness) => ({
320
+ ...globalInstallFor(harness, opts),
321
+ ...skillCarriers(harness),
322
+ }));
323
+ }
324
+ /**
325
+ * How the skills and the hooks travel for one harness. Linking is on only where
326
+ * the vendor documents following a symlinked skill folder (quoted in
327
+ * `./skill-links.ts`); the global install "carries hooks" only for Claude Code,
328
+ * whose plugin is where its hooks live.
329
+ */
330
+ function skillCarriers(harness) {
331
+ if (harness === "claude")
332
+ return { linkSkills: true, globalInstallCarriesHooks: true };
333
+ // eslint-disable-next-line local/no-harness-names -- init's per-harness install plan is keyed by harness name, like the global install beside it
334
+ if (harness === "codex")
335
+ return { linkSkills: true, globalInstallCarriesHooks: false };
336
+ return { linkSkills: false, globalInstallCarriesHooks: false };
337
+ }
338
+ /** The global install half of {@link planPluginInstall}, per harness. */
339
+ function globalInstallFor(harness, opts) {
340
+ if (harness === "claude") {
372
341
  return {
373
342
  harness,
374
- commands: [],
375
- successMessage: "",
376
- manualSteps: [],
377
- notes: [`No plugin install path for harness '${harness}'.`],
343
+ commands: opts.hasClaude
344
+ ? [
345
+ "claude plugin marketplace add zernie/vigiles",
346
+ "claude plugin install vigiles@vigiles",
347
+ ]
348
+ : [],
349
+ successMessage: "✓ Installed the vigiles plugin (hooks + skills) into ~/.claude/plugins/",
350
+ manualSteps: [
351
+ "/plugin marketplace add zernie/vigiles",
352
+ "/plugin install vigiles@vigiles",
353
+ ],
354
+ notes: [
355
+ "The plugin installs globally to ~/.claude/plugins/ and carries the hooks (and a namespaced copy of the skills, listed as vigiles:<name>) on this machine only.",
356
+ ],
378
357
  vendors: false,
379
358
  };
380
- });
359
+ }
360
+ if (harness === "codex") {
361
+ const skillScope = `-s ${exports.SHIPPED_SKILLS.join(" ")}`;
362
+ return {
363
+ harness,
364
+ commands: [
365
+ `npx --yes skills add zernie/vigiles -a codex ${skillScope} -g -y`,
366
+ ],
367
+ successMessage: "✓ Installed the vigiles skills into ~/.agents/skills/ (global, not vendored)",
368
+ manualSteps: [
369
+ `npx skills add zernie/vigiles -a codex ${skillScope} -g -y`,
370
+ ],
371
+ notes: [
372
+ "Codex reads AGENTS.md directly; the skills install globally to ~/.agents/skills/ (not the repo).",
373
+ "The eval-lock + refs NUDGE hooks are wired into .codex/config.toml (repo-committed, the Codex norm).",
374
+ "Still manual on Codex: the SessionStart lint summary + compile-on-edit/pre-edit guards (no harness-neutral entrypoint yet).",
375
+ ],
376
+ vendors: true,
377
+ };
378
+ }
379
+ return {
380
+ harness,
381
+ commands: [],
382
+ successMessage: "",
383
+ manualSteps: [],
384
+ notes: [`No plugin install path for harness '${harness}'.`],
385
+ vendors: false,
386
+ };
387
+ }
388
+ /**
389
+ * Run a plan's GLOBAL install, given whether the skills were linked into the
390
+ * repo? Skip it only when linked skills make it redundant — when it carries
391
+ * nothing but skills. Running it anyway would list every skill twice (Codex:
392
+ * "If two skills share the same `name`, Codex doesn't merge them; both can
393
+ * appear in skill selectors"). Claude Code's plugin carries the hooks, so it
394
+ * always runs; its skills are namespaced `vigiles:<name>` and load beside the
395
+ * linked `<name>` (a known, documented duplicate until the hooks move into the
396
+ * repo too).
397
+ */
398
+ function shouldRunGlobalInstall(plan, skillsLinked) {
399
+ return plan.globalInstallCarriesHooks || !skillsLinked;
381
400
  }
382
401
  /**
383
402
  * vigiles's proactive nudges, wired into a Codex repo's `.codex/config.toml`.
@@ -436,6 +455,87 @@ function applyCodexPluginHooks(existing) {
436
455
  }
437
456
  return { ...existing, hooks };
438
457
  }
458
+ /** {@link AdoptionState} from the two disk facts that decide it. */
459
+ function adoptionState(facts) {
460
+ if (facts.hasConfig)
461
+ return { kind: "adopted", evidence: ".vigilesrc.json" };
462
+ const [spec] = facts.specs;
463
+ return spec === undefined
464
+ ? { kind: "fresh" }
465
+ : { kind: "adopted", evidence: spec };
466
+ }
467
+ /**
468
+ * Which setup a run performs: the full one, or the minimal re-run (#338).
469
+ *
470
+ * MINIMAL only when the repo is adopted AND the run named nothing that asks for
471
+ * more. A flag that selects or widens the setup (`--full`, `--strict`,
472
+ * `--ci-only`, `--report-only`, `--force`, `--target=`, a positive `--lint` /
473
+ * `--test`) is an explicit request and keeps today's behaviour; the narrowing
474
+ * flags (`--no-*`, `--harness=`, `--yes`) do not, because they can only make a
475
+ * run do less.
476
+ */
477
+ function rerunScope(parsed, state) {
478
+ if (state.kind === "fresh")
479
+ return "full";
480
+ const asksForMore = parsed.full ||
481
+ parsed.strict ||
482
+ parsed.ciOnly ||
483
+ parsed.reportOnly ||
484
+ parsed.force ||
485
+ parsed.target !== undefined ||
486
+ parsed.lint === true ||
487
+ parsed.test === true;
488
+ return asksForMore ? "full" : "minimal";
489
+ }
490
+ /**
491
+ * The `devDependencies` a minimal re-run writes, or null to leave package.json
492
+ * alone. It ADDS vigiles when no dependency field declares it and never edits an
493
+ * existing declaration: rewriting `^33.3.0` to `^33` loosens a pin the owner
494
+ * chose, and moving it between fields is a decision, not a missing piece.
495
+ */
496
+ function missingVigilesDevDep(pkg, spec) {
497
+ const declared = pkg.dependencies?.vigiles !== undefined ||
498
+ pkg.devDependencies?.vigiles !== undefined;
499
+ return declared ? null : { ...pkg.devDependencies, vigiles: spec };
500
+ }
501
+ /**
502
+ * The lines a minimal re-run prints about what it did NOT do: each thing a full
503
+ * setup would add here, then the flag that does it. Empty when nothing is
504
+ * missing, so a fully set-up repo is not told to run anything.
505
+ */
506
+ function fullSetupFollowUps(gap) {
507
+ const configLine = {
508
+ missing: `write .vigilesrc.json with your harnesses and "extends": "${presets_js_1.RECOMMENDED_PRESET}" (structural rules fail CI)`,
509
+ "no-preset": `add "extends": "${presets_js_1.RECOMMENDED_PRESET}" to .vigilesrc.json (structural rules fail CI)`,
510
+ "has-preset": null,
511
+ };
512
+ const items = [
513
+ gap.workflow
514
+ ? "create .github/workflows/vigiles.yml (a CI workflow — new CI minutes)"
515
+ : null,
516
+ configLine[gap.config],
517
+ gap.harnessTest
518
+ ? "scaffold vigiles.harness.mjs (a starter harness test)"
519
+ : null,
520
+ gap.unspecced.length > 0
521
+ ? `write specs for ${String(gap.unspecced.length)} hand-written file(s): ${gap.unspecced.join(", ")}`
522
+ : null,
523
+ gap.generated
524
+ ? "generate .vigiles/generated.d.ts and .vigiles/schema.json"
525
+ : null,
526
+ ...gap.installs,
527
+ gap.devDep === null
528
+ ? null
529
+ : `set the vigiles devDependency to ${gap.devDep.to} (yours: ${gap.devDep.from})`,
530
+ ].filter((l) => l !== null);
531
+ return items.length === 0
532
+ ? []
533
+ : [
534
+ "A full setup would also:",
535
+ ...items.map((l) => ` - ${l}`),
536
+ "Run `npx vigiles init --full` for that (flags such as --no-gha still apply).",
537
+ ];
538
+ }
439
539
  /**
440
540
  * Resolve the final plan: defaults, then flags, then interactive answers (each
441
541
  * layer overrides the previous only where it has an opinion). `--target` pins a
@@ -0,0 +1,183 @@
1
+ /** The package whose skills are linked. */
2
+ export declare const VIGILES_PACKAGE = "vigiles";
3
+ /** What occupies `<skills home>/<name>` today, as the IO edge observed it. */
4
+ export type SkillEntry = {
5
+ readonly kind: "missing";
6
+ } | {
7
+ readonly kind: "link";
8
+ /** The link's stored target, exactly as `readlink` returns it. */
9
+ readonly target: string;
10
+ /** Where it resolves, or null when it dangles. */
11
+ readonly resolvesTo: string | null;
12
+ } | {
13
+ readonly kind: "directory";
14
+ } | {
15
+ readonly kind: "file";
16
+ };
17
+ /** What `init` should do about one skill. */
18
+ export type SkillLinkDecision = {
19
+ readonly name: string;
20
+ /** `replace`: an earlier `init`'s link that now points at the wrong place. */
21
+ readonly action: "create" | "replace";
22
+ readonly target: string;
23
+ } | {
24
+ readonly name: string;
25
+ readonly action: "keep";
26
+ } | {
27
+ readonly name: string;
28
+ readonly action: "refuse";
29
+ readonly reason: string;
30
+ };
31
+ /** The link `init` wants for one skill. */
32
+ export interface WantedLink {
33
+ /** The relative target to write (and to recognise on a re-run). */
34
+ readonly target: string;
35
+ /** The real path of the shipped skill, or null while it is not installed. */
36
+ readonly real: string | null;
37
+ }
38
+ /**
39
+ * Decide one entry. A link with OUR target is ours even while it dangles — that
40
+ * is exactly how a committed link looks in a clone that has not run
41
+ * `npm install` yet, and calling it foreign would make re-running `init` noisy
42
+ * in precisely the place it is meant to help.
43
+ */
44
+ export declare function decideSkillLink(name: string, entry: SkillEntry, want: WantedLink): SkillLinkDecision;
45
+ /** Where the package is, as the project spells it. */
46
+ export type PackageSite =
47
+ /** Installed: `<ancestor>/node_modules/vigiles`, inside the repository. */
48
+ {
49
+ readonly kind: "installed";
50
+ readonly dir: string;
51
+ }
52
+ /** Not installed yet: where npm puts the devDependency `init` declares. */
53
+ | {
54
+ readonly kind: "expected";
55
+ readonly dir: string;
56
+ }
57
+ /**
58
+ * Not installed, and the project is a member of a workspace: npm and yarn
59
+ * hoist its dependencies to the workspace ROOT's `node_modules`, pnpm keeps
60
+ * them in the member's own, and a hoist can be refused by a version
61
+ * conflict. Which one happens is decided by the install, so no link is made
62
+ * before it — a guessed link can dangle for good.
63
+ */
64
+ | {
65
+ readonly kind: "unsure";
66
+ readonly reason: string;
67
+ };
68
+ /** What the walk may ask of the filesystem. */
69
+ export interface PackageProbe {
70
+ /** Is `dir` an installed vigiles package (its `package.json` names it)? */
71
+ readonly isPackage: (dir: string) => boolean;
72
+ /** Is `dir` the repository root (it holds `.git`)? The walk stops there. */
73
+ readonly isRepoRoot: (dir: string) => boolean;
74
+ /** Is `dir` a workspace root (`workspaces` in package.json, or pnpm-workspace.yaml)? */
75
+ readonly isWorkspaceRoot: (dir: string) => boolean;
76
+ }
77
+ /**
78
+ * Node's own lookup — the first `node_modules/vigiles` up the tree — bounded by
79
+ * the repository root, because a committed link that leaves the repository is a
80
+ * link only this machine can follow.
81
+ */
82
+ export declare function locatePackage(project: string, probe: PackageProbe): PackageSite;
83
+ /**
84
+ * Is the real path `path` the project's own real path or somewhere under it?
85
+ * Both must be physical (symlink-free), or a link could pass for a child.
86
+ */
87
+ export declare function isWithinProject(project: string, path: string): boolean;
88
+ /** The facts that decide whether linking makes sense at all here. */
89
+ export interface LinkFacts {
90
+ /** The project IS vigiles — its skills are the plugin's own `skills/`. */
91
+ readonly isVigilesItself: boolean;
92
+ /** `package.json` declares vigiles (so `npm install` will put it in node_modules). */
93
+ readonly dependsOnVigiles: boolean;
94
+ /** The package already resolves from the project. */
95
+ readonly installed: boolean;
96
+ }
97
+ /**
98
+ * Why NOT to link, or null to go ahead. A link into a `node_modules/vigiles`
99
+ * that no install will ever create is a dangling link committed to the repo — a
100
+ * worse state than no link, because it looks like the job was done.
101
+ */
102
+ export declare function linkPrecondition(facts: LinkFacts): string | null;
103
+ /** Everything {@link planSkillLinks} needs, observed by the IO edge. */
104
+ export interface SkillLinkInput {
105
+ /** The skills the package ships. */
106
+ readonly names: readonly string[];
107
+ readonly site: Exclude<PackageSite, {
108
+ kind: "unsure";
109
+ }>;
110
+ /**
111
+ * The skills home as it PHYSICALLY exists (`.claude` may itself be a link).
112
+ * A relative link target resolves against the directory that holds the link.
113
+ */
114
+ readonly physicalHome: string;
115
+ readonly entries: (name: string) => SkillEntry;
116
+ /** The real path of the installed skill, or null. */
117
+ readonly realOf: (name: string) => string | null;
118
+ }
119
+ /** The decisions, and whether the links will dangle until `npm install`. */
120
+ export interface SkillLinkPlan {
121
+ readonly pending: boolean;
122
+ readonly decisions: readonly SkillLinkDecision[];
123
+ }
124
+ /**
125
+ * A link target as it is committed: `/` separators on every OS. `relative()` on
126
+ * Windows yields `..\\..\\node_modules\\…`, and a clone on Linux or macOS
127
+ * reads those backslashes as ordinary filename characters, so the link dangles.
128
+ */
129
+ export declare function portableTarget(target: string, separator?: string): string;
130
+ export declare function planSkillLinks(input: SkillLinkInput): SkillLinkPlan;
131
+ /** The three filesystem calls that write a link; the IO edge passes the real ones. */
132
+ export interface LinkIo {
133
+ readonly symlink: (target: string, path: string) => void;
134
+ readonly rename: (from: string, to: string) => void;
135
+ readonly unlink: (path: string) => void;
136
+ }
137
+ /** Where a replacement link is built before it takes the old one's place. */
138
+ export declare function relinkTempPath(entry: string): string;
139
+ /**
140
+ * Write the link `decision` asks for. A `replace` never leaves a gap: the new
141
+ * link is built under a temporary name beside the old one and renamed over it,
142
+ * which is atomic. Removing the old link first would lose it for good when the
143
+ * new one cannot be created (a Windows account without the symlink privilege,
144
+ * a full disk) — a skill that worked yesterday gone, and only a warning to show
145
+ * for it. Any failure leaves the old link in place and throws.
146
+ */
147
+ export declare function writeSkillLink(io: LinkIo, entry: string, target: string, action: "create" | "replace"): void;
148
+ /** Why a `symlink` call failed, with the fix when there is a known one. */
149
+ export declare function linkFailureReason(code: string, message: string): string;
150
+ /** One skill's result after the IO ran. */
151
+ export type SkillLinkResult = {
152
+ readonly name: string;
153
+ readonly status: "created" | "relinked" | "present";
154
+ } | {
155
+ readonly name: string;
156
+ readonly status: "skipped";
157
+ readonly reason: string;
158
+ };
159
+ /** What `init` did about the skills for one harness. */
160
+ export type SkillLinkOutcome = {
161
+ readonly kind: "linked";
162
+ /** Repo-relative skills home, e.g. `.claude/skills`. */
163
+ readonly home: string;
164
+ /**
165
+ * The same home with every symlink resolved, repo-relative with `/`.
166
+ * Git refuses a pathspec that runs through a symlink, so the commit hint
167
+ * names this one.
168
+ */
169
+ readonly physicalHome: string;
170
+ readonly pending: boolean;
171
+ readonly results: readonly SkillLinkResult[];
172
+ } | {
173
+ readonly kind: "not-linked";
174
+ readonly reason: string;
175
+ };
176
+ /** At least one shipped skill now reaches the agent through a link. */
177
+ export declare function skillLinksUsable(outcome: SkillLinkOutcome): boolean;
178
+ /**
179
+ * The lines `init` prints. Skipped skills are named one by one: the user loses
180
+ * that skill, and the reason is theirs to act on.
181
+ */
182
+ export declare function formatSkillLinks(outcome: SkillLinkOutcome): readonly string[];
183
+ //# sourceMappingURL=skill-links.d.ts.map