vigiles 15.4.1 → 16.0.1

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.
@@ -51,8 +51,31 @@ export interface ClaudeCodeToolVocabulary extends ToolVocabulary {
51
51
  export declare function agent<const P extends AuthoredPurity | undefined = undefined>(spec: AgentSpecInput<P, ClaudeCodeToolVocabulary>): AgentSpec;
52
52
  /**
53
53
  * Define a Claude Code skill with the purity floor enforced AT COMPILE TIME
54
- * against the Claude Code tool catalog. Identical to the core `skill()` at
55
- * runtime; the typed `tools` constraint is the only difference.
54
+ * against the Claude Code tool catalog. Identical to the core
55
+ * `experimental_skill()` at runtime; the typed `tools` constraint is the only
56
+ * difference.
57
+ *
58
+ * Carries the same `.input` / `.step` helpers as the core builder, and it must:
59
+ * those two stopped being standalone exports when the helper vocabulary moved
60
+ * onto the skill builder, so an author who picked this door would otherwise have
61
+ * no way to reach them. Before the move they came from `vigiles/spec` — a second
62
+ * import for one skill, which is the asymmetry this closes rather than a cost it
63
+ * introduces.
64
+ *
65
+ * @experimental
66
+ */
67
+ declare function skillSpec<const P extends AuthoredPurity | undefined = undefined>(spec: SkillSpecInput<P, ClaudeCodeToolVocabulary>): SkillSpec;
68
+ /**
69
+ * @experimental
56
70
  */
57
- export declare function experimental_skill<const P extends AuthoredPurity | undefined = undefined>(spec: SkillSpecInput<P, ClaudeCodeToolVocabulary>): SkillSpec;
71
+ export declare const experimental_skill: typeof skillSpec & {
72
+ input: (name: string, hint: string, opts?: {
73
+ required?: boolean;
74
+ }) => import("../../core/spec.js").SkillInput;
75
+ step: (instr: string | import("../../core/spec.js").InstructionFragment[], opts?: {
76
+ gate?: import("../../core/spec.js").Gate;
77
+ retry?: number;
78
+ }) => import("../../core/spec.js").SkillStep;
79
+ };
80
+ export {};
58
81
  //# sourceMappingURL=typed-spec.d.ts.map
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.experimental_skill = void 0;
3
4
  exports.agent = agent;
4
- exports.experimental_skill = experimental_skill;
5
5
  /**
6
6
  * Typed Claude Code authoring surface — the compile-time half of the purity
7
7
  * contract, bound to the Claude Code tool vocabulary.
@@ -46,10 +46,27 @@ function agent(spec) {
46
46
  }
47
47
  /**
48
48
  * Define a Claude Code skill with the purity floor enforced AT COMPILE TIME
49
- * against the Claude Code tool catalog. Identical to the core `skill()` at
50
- * runtime; the typed `tools` constraint is the only difference.
49
+ * against the Claude Code tool catalog. Identical to the core
50
+ * `experimental_skill()` at runtime; the typed `tools` constraint is the only
51
+ * difference.
52
+ *
53
+ * Carries the same `.input` / `.step` helpers as the core builder, and it must:
54
+ * those two stopped being standalone exports when the helper vocabulary moved
55
+ * onto the skill builder, so an author who picked this door would otherwise have
56
+ * no way to reach them. Before the move they came from `vigiles/spec` — a second
57
+ * import for one skill, which is the asymmetry this closes rather than a cost it
58
+ * introduces.
59
+ *
60
+ * @experimental
51
61
  */
52
- function experimental_skill(spec) {
62
+ function skillSpec(spec) {
53
63
  return (0, spec_js_1.experimental_skill)(spec);
54
64
  }
65
+ /**
66
+ * @experimental
67
+ */
68
+ exports.experimental_skill = Object.assign(skillSpec, {
69
+ input: spec_js_1.experimental_skill.input,
70
+ step: spec_js_1.experimental_skill.step,
71
+ });
55
72
  //# sourceMappingURL=typed-spec.js.map
package/dist/cli.js CHANGED
@@ -1356,12 +1356,16 @@ function vigilesDepSpec() {
1356
1356
  const major = parseInt(getVersion(), 10);
1357
1357
  return Number.isFinite(major) && major > 0 ? `^${String(major)}` : "latest";
1358
1358
  }
1359
- /** True when the file's first line carries a vigiles integrity hash (i.e. it
1360
- * is a compiled artifact we own, safe to overwrite — not hand-written prose). */
1359
+ /** True when the file carries a vigiles integrity hash (i.e. it is a compiled artifact we own,
1360
+ * safe to overwrite — not hand-written prose).
1361
+ *
1362
+ * 🔴 This used to read the FIRST LINE only. A compiled SKILL.md carries the header after its
1363
+ * frontmatter, so the first line is `---` and this returned false — vigiles would have treated
1364
+ * its own compiled skill as hand-written prose and refused to overwrite it. The whole-file read
1365
+ * is the cost of not encoding the header's position in a fourth place. */
1361
1366
  function targetHasHash(absPath) {
1362
1367
  try {
1363
- const first = (0, node_fs_1.readFileSync)(absPath, "utf-8").split("\n", 1)[0];
1364
- return first.includes("vigiles:sha256");
1368
+ return (0, integrity_js_1.findIntegrityHeader)((0, node_fs_1.readFileSync)(absPath, "utf-8")) !== null;
1365
1369
  }
1366
1370
  catch {
1367
1371
  return false;
@@ -205,11 +205,18 @@ function adoptMarkdown(markdown, target) {
205
205
  // frontmatter-read.ts but matches past the closing fence.
206
206
  const FRONTMATTER_CONSUME_RE = /^\uFEFF?(?:<!--[\s\S]*?-->\s*)?---\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n?/;
207
207
  function splitFrontmatterBody(markdown) {
208
- const fm = (0, frontmatter_read_js_1.readFrontmatter)(markdown);
208
+ // Strip the integrity header FIRST, wherever it sits. FRONTMATTER_CONSUME_RE above knows
209
+ // only the pre-2026-08-17 placement (a comment BEFORE the frontmatter); once the header
210
+ // moved below the frontmatter it landed inside what this function calls "the body", so a
211
+ // round-trip adopt \u2192 compile \u2192 re-adopt grew a stamp into the spec's body on every pass.
212
+ // Delegating keeps ONE site that knows where the header can be \u2014 which is the entire point
213
+ // of findIntegrityHeader, and this caller is the one the move missed.
214
+ const source = (0, integrity_js_1.findIntegrityHeader)(markdown)?.withoutHeader ?? markdown;
215
+ const fm = (0, frontmatter_read_js_1.readFrontmatter)(source);
209
216
  if (fm.block === null)
210
- return { fm, body: markdown.replace(/^\uFEFF/, "") };
211
- const m = FRONTMATTER_CONSUME_RE.exec(markdown);
212
- return { fm, body: m ? markdown.slice(m[0].length) : markdown };
217
+ return { fm, body: source.replace(/^\uFEFF/, "") };
218
+ const m = FRONTMATTER_CONSUME_RE.exec(source);
219
+ return { fm, body: m ? source.slice(m[0].length) : source };
213
220
  }
214
221
  /** The first non-empty, non-heading paragraph — the CC fallback for a skill's
215
222
  * description when its frontmatter omits one (name←dir, description←first ¶). */
@@ -27,6 +27,7 @@ const node_fs_1 = require("node:fs");
27
27
  const glob_1 = require("glob");
28
28
  const node_path_1 = require("node:path");
29
29
  const hash_js_1 = require("./hash.js");
30
+ const integrity_js_1 = require("./integrity.js");
30
31
  const markdown_js_1 = require("./markdown.js");
31
32
  const symbols_js_1 = require("./symbols.js");
32
33
  const linters_js_1 = require("./linters.js");
@@ -39,27 +40,42 @@ const DEFAULT_TARGET = "CLAUDE.md";
39
40
  // ---------------------------------------------------------------------------
40
41
  // Hash utilities
41
42
  // ---------------------------------------------------------------------------
42
- const HASH_RE = /^<!-- vigiles:sha256:([a-f0-9]+) compiled from (.+) -->\r?\n\r?\n?/;
43
+ // The header's position and format now live in ONE place — `./integrity.js`. The local
44
+ // `HASH_RE` that used to sit here was one of four independent copies of "the header is the first
45
+ // line", and that duplication is what let the placement invariant drift unnoticed (see the block
46
+ // comment on FRONTMATTER_RE in integrity.ts).
43
47
  /** @internal Compute SHA-256 hash of content (excluding any existing hash line). */
44
48
  function computeHash(content) {
45
- const body = content.replace(HASH_RE, "");
46
- return (0, hash_js_1.sha256short)(body);
49
+ return (0, hash_js_1.sha256short)((0, integrity_js_1.findIntegrityHeader)(content)?.withoutHeader ?? content);
47
50
  }
48
51
  /** @internal Prepend a hash comment to compiled content. */
49
52
  function addHash(content, specFile) {
50
- const hash = computeHash(content);
51
- return `<!-- vigiles:sha256:${hash} compiled from ${specFile} -->\n\n${content}`;
53
+ // 🔴 THE LAST GATE BEFORE A COMPILED FILE IS WRITTEN. Every compile path returns through here
54
+ // (four call sites), which makes it the one place a whole class of defect can be stopped.
55
+ //
56
+ // `[object Object]` in output means a spec passed an object where the API takes a string, and
57
+ // JS stringified it instead of complaining — types cannot stop this for a user's spec, because
58
+ // `vigiles compile` runs `.spec.ts` through tsx: transpiled, types erased, never checked.
59
+ // Observed 2026-08-17 from `input({ name, description })`, which shipped
60
+ // `argument-hint: <[object Object]>` into a SKILL.md with no error anywhere.
61
+ //
62
+ // Hard error, not a warning: the string never appears legitimately (measured — zero
63
+ // occurrences across every `.md` in this repository), and a file carrying it is broken in a
64
+ // way its author cannot see by reading the spec.
65
+ if (content.includes("[object Object]")) {
66
+ throw new Error(`Compiled output for ${specFile} contains "[object Object]" — a spec value was an object ` +
67
+ `where a string was expected, and JavaScript stringified it. Check the arguments to ` +
68
+ `input()/file()/cmd() and friends: they take strings, not option objects.`);
69
+ }
70
+ return (0, integrity_js_1.placeIntegrityHeader)(content, computeHash(content), specFile);
52
71
  }
53
72
  /** @internal Check if a file's hash matches its content. Returns null if no hash found. */
54
73
  function verifyHash(content) {
55
- const match = content.match(HASH_RE);
56
- if (!match)
74
+ const found = (0, integrity_js_1.findIntegrityHeader)(content);
75
+ if (!found)
57
76
  return null;
58
- const expectedHash = match[1];
59
- const specFile = match[2];
60
- const body = content.replace(HASH_RE, "");
61
- const actualHash = (0, hash_js_1.sha256short)(body);
62
- return { valid: actualHash === expectedHash, specFile };
77
+ const actualHash = (0, hash_js_1.sha256short)(found.withoutHeader);
78
+ return { valid: actualHash === found.hash, specFile: found.specFile };
63
79
  }
64
80
  // ---------------------------------------------------------------------------
65
81
  // Token estimation
@@ -1077,10 +1093,11 @@ function adoptDiff(filePath, spec, basePath, dialect) {
1077
1093
  compiledContent = markdown;
1078
1094
  }
1079
1095
  // Simple line-based diff
1080
- const currentLines = currentContent.replace(HASH_RE, "").split("\n");
1081
- const compiledLines = (compiledContent ?? "")
1082
- .replace(HASH_RE, "")
1083
- .split("\n");
1096
+ const currentLines = ((0, integrity_js_1.findIntegrityHeader)(currentContent)?.withoutHeader ?? currentContent).split("\n");
1097
+ const compiledLines = (() => {
1098
+ const c = compiledContent ?? "";
1099
+ return ((0, integrity_js_1.findIntegrityHeader)(c)?.withoutHeader ?? c).split("\n");
1100
+ })();
1084
1101
  const currentSet = new Set(currentLines);
1085
1102
  const compiledSet = new Set(compiledLines);
1086
1103
  const addedLines = currentLines.filter((l) => l.trim() && !compiledSet.has(l));
@@ -17,6 +17,7 @@ exports.computeLinterRuleCoverage = computeLinterRuleCoverage;
17
17
  exports.checkCoverage = checkCoverage;
18
18
  exports.formatCoverageReport = formatCoverageReport;
19
19
  const node_fs_1 = require("node:fs");
20
+ const integrity_js_1 = require("./integrity.js");
20
21
  const node_path_1 = require("node:path");
21
22
  const glob_1 = require("glob");
22
23
  const compile_js_1 = require("./compile.js");
@@ -57,11 +58,13 @@ function collectDocumentedCommands(basePath, specs) {
57
58
  const fullPath = (0, node_path_1.resolve)(basePath, mdFile);
58
59
  try {
59
60
  const content = (0, node_fs_1.readFileSync)(fullPath, "utf-8");
60
- const specMatch = content.match(/^<!-- vigiles:sha256:[a-f0-9]+ compiled from (.+) -->/);
61
- if (!specMatch)
61
+ // Ask integrity.ts where the header is — a compiled SKILL.md carries it AFTER its
62
+ // frontmatter, so an `^`-anchored match here silently skipped every skill.
63
+ const found = (0, integrity_js_1.findIntegrityHeader)(content);
64
+ if (!found)
62
65
  continue;
63
66
  // Try to load the spec's compiled JS from dist/
64
- const specFile = specMatch[1];
67
+ const specFile = found.specFile;
65
68
  const jsPath = (0, node_path_1.resolve)(basePath, "dist", specFile.replace(/\.ts$/, ".js"));
66
69
  if ((0, node_fs_1.existsSync)(jsPath)) {
67
70
  try {
@@ -24,8 +24,12 @@ const js_yaml_1 = require("js-yaml");
24
24
  // Frontmatter is the very first thing in the file. Anchoring at the start — not
25
25
  // `(?:^|\n)` — means a `---` horizontal rule in the BODY is never mistaken for
26
26
  // frontmatter (which matters for the malformed-YAML verdict). A leading BOM is
27
- // stripped first; an optional leading HTML comment is allowed too vigiles
28
- // stamps a compiled file with `<!-- vigiles:sha256:… -->` before the `---`.
27
+ // stripped first; an optional leading HTML comment is allowed too, which is what
28
+ // lets this reader still parse files compiled BEFORE 2026-08-17, when vigiles put
29
+ // the `<!-- vigiles:sha256:… -->` stamp above the `---` and thereby hid the
30
+ // frontmatter from every stricter reader. Since then the stamp goes BELOW the
31
+ // frontmatter (placeIntegrityHeader), so this branch is backward compatibility,
32
+ // not a description of what the compiler emits today.
29
33
  const BLOCK_RE = /^\uFEFF?(?:<!--[\s\S]*?-->\s*)?---\r?\n([\s\S]*?)\r?\n---/;
30
34
  /** A YAML block-scalar indicator: `>`/`|` with optional chomp (`+`/`-`) + indent digit. */
31
35
  const BLOCK_SCALAR_RE = /^[|>][+-]?\d*$/;
@@ -17,6 +17,30 @@
17
17
  * - "Are committed compiled files actually fresh?"
18
18
  * → CI runs `vigiles compile` then `git diff --exit-code`
19
19
  */
20
+ /**
21
+ * Split off a leading frontmatter block. `head` is `""` when the file has none, so a caller can
22
+ * concatenate `head + body` unconditionally and get the original content back.
23
+ */
24
+ export declare function splitFrontmatter(content: string): {
25
+ head: string;
26
+ body: string;
27
+ };
28
+ /**
29
+ * Locate the integrity header wherever it legitimately sits — before the frontmatter (files
30
+ * compiled before 2026-08-17) or after it (files compiled since). Returns the hash, the spec
31
+ * path, and the content with the header removed and everything else intact.
32
+ *
33
+ * This is the ONLY place that knows where the header can be. Matching `/^<!-- vigiles:sha256/`
34
+ * by hand elsewhere is the bug this function exists to make unnecessary.
35
+ */
36
+ export declare function findIntegrityHeader(content: string): {
37
+ hash: string;
38
+ specFile: string;
39
+ /** `content` minus the header, frontmatter still in place. */
40
+ withoutHeader: string;
41
+ } | null;
42
+ /** Render the header in its correct position for this content. */
43
+ export declare function placeIntegrityHeader(content: string, hash: string, specFile: string): string;
20
44
  export interface IntegrityResult {
21
45
  intact: boolean;
22
46
  reason?: string;
@@ -20,22 +20,93 @@
20
20
  */
21
21
  Object.defineProperty(exports, "__esModule", { value: true });
22
22
  exports.REQUIRE_INSTRUCTIONS_SPEC_DISABLE = void 0;
23
+ exports.splitFrontmatter = splitFrontmatter;
24
+ exports.findIntegrityHeader = findIntegrityHeader;
25
+ exports.placeIntegrityHeader = placeIntegrityHeader;
23
26
  exports.checkIntegrity = checkIntegrity;
24
27
  exports.parseIntegrityHeader = parseIntegrityHeader;
25
28
  exports.ejectMarkdown = ejectMarkdown;
26
29
  const hash_js_1 = require("./hash.js");
27
30
  const HASH_LINE_RE = /^<!-- vigiles:sha256:([a-f0-9]+) compiled from (.+) -->\r?\n\r?\n?/;
31
+ /**
32
+ * A YAML frontmatter block at the very start of a file: `---\n … \n---\n`.
33
+ *
34
+ * 🔴 THIS EXISTS BECAUSE THE INVARIANT WAS STATED IN THIS FILE AND VIOLATED IN ANOTHER.
35
+ * `ejectMarkdown` (bottom of this module) has documented since it was written that a compiled
36
+ * SKILL.md begins with frontmatter which MUST stay in first position, or "the harness would lose
37
+ * the skill's name/description/tools". `addHash` in compile.ts prepended the integrity header to
38
+ * every compiled file unconditionally — including those same skills.
39
+ *
40
+ * Measured 2026-08-17 on five real skills: after compiling, a reader anchored to `^---` finds NO
41
+ * frontmatter at all. The header was the ENTIRE delta — body byte-identical, section order
42
+ * untouched — so one misplaced line was the whole reason a compiled skill could not be adopted.
43
+ *
44
+ * The header now goes AFTER the frontmatter when there is one. Readers must ask this module where
45
+ * the header is rather than anchoring their own regex at `^`: four call sites had independently
46
+ * encoded "first line", and that duplication is what let the invariant drift unnoticed.
47
+ */
48
+ const FRONTMATTER_RE = /^---\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n/;
49
+ /** Same header, matched where it sits below a frontmatter block (blank line allowed). */
50
+ const HASH_LINE_IN_BODY_RE = /^\s*<!-- vigiles:sha256:([a-f0-9]+) compiled from (.+) -->\r?\n\r?\n?/;
51
+ /**
52
+ * Split off a leading frontmatter block. `head` is `""` when the file has none, so a caller can
53
+ * concatenate `head + body` unconditionally and get the original content back.
54
+ */
55
+ function splitFrontmatter(content) {
56
+ const m = FRONTMATTER_RE.exec(content);
57
+ return m
58
+ ? { head: m[0], body: content.slice(m[0].length) }
59
+ : { head: "", body: content };
60
+ }
61
+ /**
62
+ * Locate the integrity header wherever it legitimately sits — before the frontmatter (files
63
+ * compiled before 2026-08-17) or after it (files compiled since). Returns the hash, the spec
64
+ * path, and the content with the header removed and everything else intact.
65
+ *
66
+ * This is the ONLY place that knows where the header can be. Matching `/^<!-- vigiles:sha256/`
67
+ * by hand elsewhere is the bug this function exists to make unnecessary.
68
+ */
69
+ function findIntegrityHeader(content) {
70
+ const { head, body } = splitFrontmatter(content);
71
+ // After the frontmatter — the current placement. The leading `\s*` is load-bearing: the header
72
+ // is written one blank line below the closing `---` for readability, so an anchor at position 0
73
+ // of the body misses it. A test caught exactly that before it shipped.
74
+ const inBody = body.match(HASH_LINE_IN_BODY_RE);
75
+ if (inBody) {
76
+ return {
77
+ hash: inBody[1],
78
+ specFile: inBody[2],
79
+ withoutHeader: head + body.replace(HASH_LINE_IN_BODY_RE, ""),
80
+ };
81
+ }
82
+ // Or at the very top, which is what a file compiled before the fix looks like. Such a file has
83
+ // no leading frontmatter by definition — the header displaced it — so `head` is empty here.
84
+ const atTop = content.match(HASH_LINE_RE);
85
+ if (!atTop)
86
+ return null;
87
+ return {
88
+ hash: atTop[1],
89
+ specFile: atTop[2],
90
+ withoutHeader: content.replace(HASH_LINE_RE, ""),
91
+ };
92
+ }
93
+ /** Render the header in its correct position for this content. */
94
+ function placeIntegrityHeader(content, hash, specFile) {
95
+ const stamp = `<!-- vigiles:sha256:${hash} compiled from ${specFile} -->`;
96
+ const { head, body } = splitFrontmatter(content);
97
+ return head ? `${head}\n${stamp}\n\n${body}` : `${stamp}\n\n${body}`;
98
+ }
28
99
  /**
29
100
  * Check whether the compiled markdown's SHA-256 hash matches its body.
30
101
  * Files without a hash header are treated as hand-written (intact).
31
102
  */
32
103
  function checkIntegrity(content) {
33
- const match = content.match(HASH_LINE_RE);
34
- if (!match) {
104
+ const found = findIntegrityHeader(content);
105
+ if (!found) {
35
106
  return { intact: true, reason: "No hash header (hand-written file)" };
36
107
  }
37
- const expectedHash = match[1];
38
- const body = content.replace(HASH_LINE_RE, "");
108
+ const expectedHash = found.hash;
109
+ const body = found.withoutHeader;
39
110
  if ((0, hash_js_1.sha256short)(body) !== expectedHash) {
40
111
  return {
41
112
  intact: false,
@@ -53,10 +124,10 @@ exports.REQUIRE_INSTRUCTIONS_SPEC_DISABLE = "<!-- vigiles-disable require-instru
53
124
  * header; `null` when the file is plain markdown (no header).
54
125
  */
55
126
  function parseIntegrityHeader(content) {
56
- const match = content.match(HASH_LINE_RE);
57
- if (!match)
127
+ const found = findIntegrityHeader(content);
128
+ if (!found)
58
129
  return null;
59
- return { specFile: match[2], body: content.replace(HASH_LINE_RE, "") };
130
+ return { specFile: found.specFile, body: found.withoutHeader };
60
131
  }
61
132
  /**
62
133
  * "Eject" a compiled instruction file to plain, hand-owned markdown: strip the
@@ -399,12 +399,21 @@ export interface SkillStep {
399
399
  /** Max attempts to satisfy the gate before the step fails (default 1). */
400
400
  readonly retry?: number;
401
401
  }
402
- /** Declare a skill input (compiles to argument-hint + an Arguments entry). */
403
- export declare function input(name: string, hint: string, opts?: {
402
+ /**
403
+ * Declare a skill input (compiles to argument-hint + an Arguments entry).
404
+ *
405
+ * Deliberately NOT a standalone export — reached as `experimental_skill.input`.
406
+ * The reason is on `experimental_skill` below.
407
+ */
408
+ declare function input(name: string, hint: string, opts?: {
404
409
  required?: boolean;
405
410
  }): SkillInput;
406
- /** Declare a gated pipeline step. */
407
- export declare function step(instr: string | InstructionFragment[], opts?: {
411
+ /**
412
+ * Declare a gated pipeline step.
413
+ *
414
+ * Deliberately NOT a standalone export — reached as `experimental_skill.step`.
415
+ */
416
+ declare function step(instr: string | InstructionFragment[], opts?: {
408
417
  gate?: Gate;
409
418
  retry?: number;
410
419
  }): SkillStep;
@@ -504,9 +513,32 @@ export type SkillSpecInput<P extends AuthoredPurity | undefined, V extends ToolV
504
513
  * DIFFERENT function — a `Check<Trace>` taking an id string, asking whether a
505
514
  * skill fired. This one authors a skill; that one observes one.
506
515
  *
516
+ * Its helper vocabulary hangs off it — `experimental_skill.input(…)` and
517
+ * `experimental_skill.step(…)` — rather than being exported beside it. Both are
518
+ * used ONLY by skill specs (measured: zero uses in agent/claude specs, against
519
+ * `cmd`/`file`/`ref`/`result`, which are shared and therefore stay top-level).
520
+ * Hanging them here makes the experimental marking STRUCTURAL for the whole
521
+ * family: you cannot reach `input()` without naming `experimental_skill` first.
522
+ * The prefix convention alone could not do that — it is a habit, and it had
523
+ * already leaked once when `skill()` shipped stable-named against its own docs.
524
+ *
525
+ * Honest limit: `const { input } = experimental_skill` strips the marker again
526
+ * inside one file. What the shape actually guarantees is narrower and still
527
+ * worth having — an unmarked name never crosses the package boundary.
528
+ *
529
+ * `Object.assign` rather than `export namespace`: the latter is banned by this
530
+ * repo's own lint (`no-namespace: error`, inherited from strict-type-checked).
531
+ *
532
+ * @experimental
533
+ */
534
+ declare function skillSpec<const P extends AuthoredPurity | undefined = undefined, V extends ToolVocabulary = OpenToolVocabulary>(spec: SkillSpecInput<P, V>): SkillSpec;
535
+ /**
507
536
  * @experimental
508
537
  */
509
- export declare function experimental_skill<const P extends AuthoredPurity | undefined = undefined, V extends ToolVocabulary = OpenToolVocabulary>(spec: SkillSpecInput<P, V>): SkillSpec;
538
+ export declare const experimental_skill: typeof skillSpec & {
539
+ input: typeof input;
540
+ step: typeof step;
541
+ };
510
542
  /**
511
543
  * A subagent definition (compiles to `agents/<name>.md`). Unlike a skill —
512
544
  * reference material the model reads on activation — a subagent is a *delegated
package/dist/core/spec.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * guidance() — prose only, no mechanical enforcement
11
11
  */
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
- exports.BUILTIN_LINTERS = void 0;
13
+ exports.experimental_skill = exports.BUILTIN_LINTERS = void 0;
14
14
  exports.enforce = enforce;
15
15
  exports.guidance = guidance;
16
16
  exports.guard = guard;
@@ -24,9 +24,6 @@ exports.instructions = instructions;
24
24
  exports.experimental_effect = experimental_effect;
25
25
  exports.claude = claude;
26
26
  exports.project = project;
27
- exports.input = input;
28
- exports.step = step;
29
- exports.experimental_skill = experimental_skill;
30
27
  exports.agent = agent;
31
28
  exports.result = result;
32
29
  exports.delegate = delegate;
@@ -222,11 +219,39 @@ function claude(spec) {
222
219
  function project(role) {
223
220
  return { _ref: "role", role };
224
221
  }
225
- /** Declare a skill input (compiles to argument-hint + an Arguments entry). */
222
+ /**
223
+ * Declare a skill input (compiles to argument-hint + an Arguments entry).
224
+ *
225
+ * Deliberately NOT a standalone export — reached as `experimental_skill.input`.
226
+ * The reason is on `experimental_skill` below.
227
+ */
226
228
  function input(name, hint, opts = {}) {
229
+ // 🔴 The types say `string`, and for a USER's spec nothing enforces that: `vigiles compile`
230
+ // loads `.spec.ts` through tsx, which transpiles and erases types without checking them.
231
+ // (vigiles's OWN specs are cross-checked by a separate `tsc --noEmit` over a generated
232
+ // registry — that safety net does not travel to the people compiling their own specs.)
233
+ //
234
+ // Measured 2026-08-17: calling `input({ name, description })` — the object form a reader
235
+ // reasonably guesses — compiled with NO error and wrote this into the shipped SKILL.md:
236
+ //
237
+ // argument-hint: <[object Object]>
238
+ // - `$1` **[object Object]** — undefined
239
+ //
240
+ // A wrong call became a wrong file, silently. Refusing here makes the mistake loud at the
241
+ // moment it is made, and the message carries the real signature because "expected string"
242
+ // alone does not tell the caller what to write instead.
243
+ const bad = (v) => typeof v !== "string" || v.trim() === "";
244
+ if (bad(name) || bad(hint)) {
245
+ throw new TypeError(`input() takes two strings: input(name, hint, opts?) — e.g. input("pattern", "regex to search for").\n` +
246
+ ` got name=${JSON.stringify(name)}, hint=${JSON.stringify(hint)}`);
247
+ }
227
248
  return { name, hint, required: opts.required };
228
249
  }
229
- /** Declare a gated pipeline step. */
250
+ /**
251
+ * Declare a gated pipeline step.
252
+ *
253
+ * Deliberately NOT a standalone export — reached as `experimental_skill.step`.
254
+ */
230
255
  function step(instr, opts = {}) {
231
256
  return { do: instr, gate: opts.gate, retry: opts.retry };
232
257
  }
@@ -245,11 +270,31 @@ function step(instr, opts = {}) {
245
270
  * DIFFERENT function — a `Check<Trace>` taking an id string, asking whether a
246
271
  * skill fired. This one authors a skill; that one observes one.
247
272
  *
273
+ * Its helper vocabulary hangs off it — `experimental_skill.input(…)` and
274
+ * `experimental_skill.step(…)` — rather than being exported beside it. Both are
275
+ * used ONLY by skill specs (measured: zero uses in agent/claude specs, against
276
+ * `cmd`/`file`/`ref`/`result`, which are shared and therefore stay top-level).
277
+ * Hanging them here makes the experimental marking STRUCTURAL for the whole
278
+ * family: you cannot reach `input()` without naming `experimental_skill` first.
279
+ * The prefix convention alone could not do that — it is a habit, and it had
280
+ * already leaked once when `skill()` shipped stable-named against its own docs.
281
+ *
282
+ * Honest limit: `const { input } = experimental_skill` strips the marker again
283
+ * inside one file. What the shape actually guarantees is narrower and still
284
+ * worth having — an unmarked name never crosses the package boundary.
285
+ *
286
+ * `Object.assign` rather than `export namespace`: the latter is banned by this
287
+ * repo's own lint (`no-namespace: error`, inherited from strict-type-checked).
288
+ *
248
289
  * @experimental
249
290
  */
250
- function experimental_skill(spec) {
291
+ function skillSpec(spec) {
251
292
  return { _specType: "skill", ...spec };
252
293
  }
294
+ /**
295
+ * @experimental
296
+ */
297
+ exports.experimental_skill = Object.assign(skillSpec, { input, step });
253
298
  /**
254
299
  * Define a subagent specification (compiles to `agents/<name>.md`).
255
300
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "15.4.1",
3
+ "version": "16.0.1",
4
4
  "description": "Audit, test and measure the harness your AI agent runs on — grade your CLAUDE.md / AGENTS.md, skills, subagents and hooks, run them against a scripted model, and measure whether they actually fire.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -89,6 +89,7 @@
89
89
  "test:types": "npm run build && tsc --noEmit -p test/types/tsconfig.json",
90
90
  "api:report": "npm run build && node scripts/api-extractor.mjs --local",
91
91
  "api:check": "npm run build && node scripts/api-extractor.mjs",
92
+ "check": "node scripts/check.mjs",
92
93
  "docs:check": "npm run build && node scripts/check-doc-imports.mjs . docs README.md",
93
94
  "exports:check": "npm run build && node scripts/check-export-prefixes.mjs .",
94
95
  "experimental:check": "npm run api:check && node scripts/check-experimental-naming.mjs",
@@ -98,6 +99,7 @@
98
99
  "@eslint/js": "^10.0.1",
99
100
  "@jackchuka/mdschema": "^0.12.8",
100
101
  "@microsoft/api-extractor": "^7.58.9",
102
+ "@semantic-release/commit-analyzer": "^13.0.1",
101
103
  "@types/js-yaml": "^4.0.9",
102
104
  "@types/markdown-it": "^14.1.2",
103
105
  "@types/minimatch": "^5.1.2",
@@ -105,6 +107,7 @@
105
107
  "@typescript-eslint/eslint-plugin": "^8.58.0",
106
108
  "@typescript-eslint/parser": "^8.58.0",
107
109
  "@vitest/coverage-v8": "^4.1.8",
110
+ "conventional-changelog-conventionalcommits": "^10.3.0",
108
111
  "eslint": "^10.1.0",
109
112
  "eslint-import-resolver-typescript": "^4.4.5",
110
113
  "eslint-plugin-boundaries": "^6.0.2",