vigiles 16.0.0 → 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.
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
package/dist/core/spec.js CHANGED
@@ -226,6 +226,25 @@ function project(role) {
226
226
  * The reason is on `experimental_skill` below.
227
227
  */
228
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
+ }
229
248
  return { name, hint, required: opts.required };
230
249
  }
231
250
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "16.0.0",
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",