vigiles 12.6.0 → 12.8.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.
@@ -0,0 +1,454 @@
1
+ "use strict";
2
+ /**
3
+ * segment.ts — Tier-A deterministic (no-model) segmenter.
4
+ *
5
+ * Splits a CLAUDE.md / AGENTS.md into ATOMIC candidate rules with provenance.
6
+ * Pure, deterministic, strict TS, no external deps.
7
+ *
8
+ * Design bias: PRECISION over recall. A missed rule costs a row; a garbage
9
+ * atom costs credibility. When in doubt we UNDER-split and REJECT.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.segmentInstructions = segmentInstructions;
13
+ // --- Heuristic vocabulary --------------------------------------------------
14
+ /** Imperative/prohibitive head the candidate must START with (form cue). */
15
+ const FORM_HEAD = /^(?:use|avoid|prefer|never|always|don'?t|do not|no\s+\S|must|should|keep|run|write|add|remove|only)\b/i;
16
+ /** Rule-ish heading gate for prose-under-heading candidacy. */
17
+ const RULE_HEADING = /rules?|conventions?|style|guidelines?|standards?|do(?:n'?ts?)?s?|never|always|must|require/i;
18
+ /** Declarative subjects — these signal a statement, not an instruction. */
19
+ const DECLARATION = /^(?:this|these|those|it|we|our|there)\b/i;
20
+ /** Line consisting only of a bare URL. */
21
+ const URL_ONLY = /^<?https?:\/\/\S+>?$/;
22
+ /** Line consisting only of a markdown link. */
23
+ const LINK_ONLY = /^\[[^\]]*\]\([^)]*\)$/;
24
+ /** Verb-ish lexicon (secondary shape signal). Kept curated for precision. */
25
+ const VERBS = new Set([
26
+ "use",
27
+ "uses",
28
+ "using",
29
+ "used",
30
+ "avoid",
31
+ "avoids",
32
+ "prefer",
33
+ "prefers",
34
+ "run",
35
+ "runs",
36
+ "write",
37
+ "writes",
38
+ "writing",
39
+ "add",
40
+ "adds",
41
+ "remove",
42
+ "removes",
43
+ "keep",
44
+ "keeps",
45
+ "import",
46
+ "imports",
47
+ "importing",
48
+ "split",
49
+ "splits",
50
+ "push",
51
+ "pushes",
52
+ "commit",
53
+ "commits",
54
+ "test",
55
+ "tests",
56
+ "call",
57
+ "calls",
58
+ "set",
59
+ "sets",
60
+ "make",
61
+ "makes",
62
+ "create",
63
+ "creates",
64
+ "delete",
65
+ "deletes",
66
+ "update",
67
+ "updates",
68
+ "check",
69
+ "checks",
70
+ "ensure",
71
+ "ensures",
72
+ "document",
73
+ "documents",
74
+ "follow",
75
+ "follows",
76
+ "handle",
77
+ "handles",
78
+ "return",
79
+ "returns",
80
+ "throw",
81
+ "throws",
82
+ "catch",
83
+ "log",
84
+ "logs",
85
+ "prefix",
86
+ "name",
87
+ "names",
88
+ "store",
89
+ "stores",
90
+ "read",
91
+ "reads",
92
+ "save",
93
+ "saves",
94
+ "wrap",
95
+ "wraps",
96
+ "escape",
97
+ "escapes",
98
+ "match",
99
+ "matches",
100
+ "filter",
101
+ "filters",
102
+ "merge",
103
+ "merges",
104
+ "be",
105
+ "is",
106
+ "are",
107
+ "have",
108
+ "has",
109
+ "may",
110
+ "should",
111
+ "must",
112
+ "pin",
113
+ "pins",
114
+ "lint",
115
+ "format",
116
+ "formats",
117
+ "sort",
118
+ "group",
119
+ "groups",
120
+ "export",
121
+ "exports",
122
+ "mock",
123
+ "stub",
124
+ "assert",
125
+ "validate",
126
+ "validates",
127
+ "sanitize",
128
+ "encode",
129
+ "decode",
130
+ "hash",
131
+ "sign",
132
+ "verify",
133
+ "verifies",
134
+ "expose",
135
+ "hide",
136
+ "close",
137
+ "open",
138
+ "load",
139
+ "loads",
140
+ "fetch",
141
+ "fetches",
142
+ "render",
143
+ "renders",
144
+ "mount",
145
+ "bind",
146
+ "inject",
147
+ "register",
148
+ "resolve",
149
+ "reject",
150
+ "await",
151
+ "apply",
152
+ "applies",
153
+ "bump",
154
+ "tag",
155
+ "branch",
156
+ "rebase",
157
+ "squash",
158
+ "enforce",
159
+ "enforces",
160
+ "define",
161
+ "defines",
162
+ "declare",
163
+ "place",
164
+ "put",
165
+ "prefer",
166
+ ]);
167
+ // --- Offset / line utilities ----------------------------------------------
168
+ function computeLineOffsets(lines) {
169
+ const offsets = new Array(lines.length);
170
+ let acc = 0;
171
+ for (let i = 0; i < lines.length; i++) {
172
+ offsets[i] = acc;
173
+ acc += lines[i].length + 1; // +1 for the '\n' consumed by split
174
+ }
175
+ return offsets;
176
+ }
177
+ function offsetToLine(lineOffsets, off) {
178
+ // 1-based line number containing char offset `off`.
179
+ let lo = 0;
180
+ let hi = lineOffsets.length - 1;
181
+ let ans = 0;
182
+ while (lo <= hi) {
183
+ const mid = (lo + hi) >> 1;
184
+ if (lineOffsets[mid] <= off) {
185
+ ans = mid;
186
+ lo = mid + 1;
187
+ }
188
+ else {
189
+ hi = mid - 1;
190
+ }
191
+ }
192
+ return ans + 1;
193
+ }
194
+ function normalize(s) {
195
+ return s.replace(/\s+/g, " ").trim();
196
+ }
197
+ function hasVerbish(text) {
198
+ const tokens = text
199
+ .toLowerCase()
200
+ .replace(/`[^`]*`/g, " ") // drop inline code spans
201
+ .split(/[^a-z']+/)
202
+ .filter(Boolean);
203
+ for (const t of tokens) {
204
+ if (VERBS.has(t))
205
+ return true;
206
+ }
207
+ return false;
208
+ }
209
+ function isLinkOnly(text) {
210
+ const t = text.trim();
211
+ return URL_ONLY.test(t) || LINK_ONLY.test(t);
212
+ }
213
+ /**
214
+ * Score the 3 cues. Returns confidence or null (reject).
215
+ * - form: starts with an imperative/prohibitive head (or "No X").
216
+ * - context: is a bullet OR sits under a rule-ish heading.
217
+ * - shape: 15–300 chars, has a verb-ish token, not link-only, not a declaration.
218
+ */
219
+ function gate(text, isBullet, underRuleHeading) {
220
+ const t = text.trim();
221
+ const form = FORM_HEAD.test(t);
222
+ const context = isBullet || underRuleHeading;
223
+ const shape = t.length >= 15 &&
224
+ t.length <= 300 &&
225
+ hasVerbish(t) &&
226
+ !isLinkOnly(t) &&
227
+ !DECLARATION.test(t);
228
+ const cues = (form ? 1 : 0) + (context ? 1 : 0) + (shape ? 1 : 0);
229
+ if (cues >= 3)
230
+ return "high";
231
+ if (cues === 2)
232
+ return "medium";
233
+ return null;
234
+ }
235
+ // --- Atomicity split -------------------------------------------------------
236
+ /** Never split when an exception clause carries polarity/meaning. */
237
+ const HAS_EXCEPT = /\bexcept\b/i;
238
+ function trimSpan(src, span) {
239
+ let { start, end } = span;
240
+ while (start < end && /\s/.test(src[start]))
241
+ start++;
242
+ while (end > start && /\s/.test(src[end - 1]))
243
+ end--;
244
+ return { start, end };
245
+ }
246
+ /**
247
+ * Try to split a single-line bullet's content span on ';' or sentence
248
+ * boundaries. Returns the resulting spans ONLY IF there is >1 and every
249
+ * piece independently passes the gate; otherwise returns [whole].
250
+ */
251
+ function atomize(src, contentSpan, isBullet, underRuleHeading) {
252
+ const whole = trimSpan(src, contentSpan);
253
+ const wholeText = src.slice(whole.start, whole.end);
254
+ if (HAS_EXCEPT.test(wholeText))
255
+ return [whole];
256
+ // Candidate cut points: ';' and sentence terminators followed by a capital.
257
+ const cuts = [];
258
+ for (let i = whole.start; i < whole.end; i++) {
259
+ const c = src[i];
260
+ if (c === ";") {
261
+ cuts.push(i + 1);
262
+ }
263
+ else if (c === "." || c === "!" || c === "?") {
264
+ // sentence boundary: terminator + whitespace + capital letter
265
+ const rest = src.slice(i + 1, whole.end);
266
+ const m = /^\s+[A-Z]/.exec(rest);
267
+ if (m)
268
+ cuts.push(i + 1);
269
+ }
270
+ }
271
+ if (cuts.length === 0)
272
+ return [whole];
273
+ const bounds = [whole.start, ...cuts, whole.end];
274
+ const pieces = [];
275
+ for (let i = 0; i < bounds.length - 1; i++) {
276
+ const piece = trimSpan(src, { start: bounds[i], end: bounds[i + 1] });
277
+ // strip a leading semicolon left by the cut
278
+ while (piece.start < piece.end &&
279
+ (src[piece.start] === ";" || /\s/.test(src[piece.start]))) {
280
+ piece.start++;
281
+ }
282
+ if (piece.start >= piece.end)
283
+ return [whole];
284
+ pieces.push(piece);
285
+ }
286
+ // Both/all halves must independently pass the gate, else keep whole.
287
+ for (const p of pieces) {
288
+ const text = normalize(src.slice(p.start, p.end));
289
+ if (gate(text, isBullet, underRuleHeading) === null)
290
+ return [whole];
291
+ }
292
+ return pieces.length > 1 ? pieces : [whole];
293
+ }
294
+ // --- Emission --------------------------------------------------------------
295
+ function emitFromSpan(src, lineOffsets, file, span, confidence) {
296
+ const exactQuote = src.slice(span.start, span.end);
297
+ return {
298
+ text: normalize(exactQuote),
299
+ file,
300
+ lineStart: offsetToLine(lineOffsets, span.start),
301
+ lineEnd: offsetToLine(lineOffsets, span.end - 1),
302
+ exactQuote,
303
+ confidence,
304
+ };
305
+ }
306
+ // --- Scanner ---------------------------------------------------------------
307
+ const LIST_ITEM = /^(\s*)([-*+])(\s+)(.*)$/;
308
+ const HEADING = /^(#{1,6})\s+(.*)$/;
309
+ const FENCE = /^\s*(```|~~~)/;
310
+ const TABLE_LINE = /^\s*\|/;
311
+ /**
312
+ * Split a CLAUDE.md / AGENTS.md into atomic candidate rules with provenance.
313
+ *
314
+ * Deterministic Tier-A heuristic. Code fences and tables are excluded from
315
+ * candidacy. Candidate units are (a) list items with attached continuation
316
+ * lines and (b) sentences of paragraphs under a rule-ish heading.
317
+ */
318
+ function segmentInstructions(markdown, file) {
319
+ const lines = markdown.split("\n");
320
+ const lineOffsets = computeLineOffsets(lines);
321
+ const out = [];
322
+ let inFence = false;
323
+ let currentHeadingIsRuleish = false;
324
+ let i = 0;
325
+ const lineSpan = (a, b) => ({
326
+ start: lineOffsets[a],
327
+ end: lineOffsets[b] + lines[b].length,
328
+ });
329
+ while (i < lines.length) {
330
+ const line = lines[i];
331
+ // Code fences: toggle and skip everything inside (incl. the fence lines).
332
+ if (FENCE.test(line)) {
333
+ inFence = !inFence;
334
+ i++;
335
+ continue;
336
+ }
337
+ if (inFence) {
338
+ i++;
339
+ continue;
340
+ }
341
+ // Headings: update rule-ish context, not a candidate.
342
+ const h = HEADING.exec(line);
343
+ if (h) {
344
+ currentHeadingIsRuleish = RULE_HEADING.test(h[2]);
345
+ i++;
346
+ continue;
347
+ }
348
+ // Tables: excluded from candidacy.
349
+ if (TABLE_LINE.test(line)) {
350
+ i++;
351
+ continue;
352
+ }
353
+ // List items (with attached continuation lines).
354
+ const li = LIST_ITEM.exec(line);
355
+ if (li) {
356
+ const markerIndent = li[1].length;
357
+ const contentCol = li[1].length + li[2].length + li[3].length;
358
+ const startLine = i;
359
+ // Gather continuation lines: deeper-indented, non-blank, not a new
360
+ // list marker, not a heading, not a fence.
361
+ let endLine = i;
362
+ let j = i + 1;
363
+ while (j < lines.length) {
364
+ const cand = lines[j];
365
+ if (cand.trim() === "")
366
+ break;
367
+ if (FENCE.test(cand))
368
+ break;
369
+ if (HEADING.test(cand))
370
+ break;
371
+ const indent = cand.length - cand.trimStart().length;
372
+ if (indent <= markerIndent)
373
+ break;
374
+ if (LIST_ITEM.test(cand))
375
+ break; // nested/sibling bullet => separate candidate
376
+ endLine = j;
377
+ j++;
378
+ }
379
+ const multiLine = endLine > startLine;
380
+ const contentStart = lineOffsets[startLine] + contentCol;
381
+ const contentEnd = lineOffsets[endLine] + lines[endLine].length;
382
+ const contentSpan = { start: contentStart, end: contentEnd };
383
+ const wholeText = normalize(markdown.slice(contentStart, contentEnd));
384
+ const conf = gate(wholeText, true, currentHeadingIsRuleish);
385
+ if (conf !== null) {
386
+ // Only attempt splitting for single-line items (keeps offsets exact).
387
+ const spans = multiLine
388
+ ? [trimSpan(markdown, contentSpan)]
389
+ : atomize(markdown, contentSpan, true, currentHeadingIsRuleish);
390
+ if (spans.length === 1) {
391
+ // Emit whole item; exactQuote is the full source span incl. marker.
392
+ out.push(emitFromSpan(markdown, lineOffsets, file, lineSpan(startLine, endLine), conf));
393
+ }
394
+ else {
395
+ for (const s of spans) {
396
+ const text = normalize(markdown.slice(s.start, s.end));
397
+ const c = gate(text, true, currentHeadingIsRuleish);
398
+ if (c !== null)
399
+ out.push(emitFromSpan(markdown, lineOffsets, file, s, c));
400
+ }
401
+ }
402
+ }
403
+ i = endLine + 1;
404
+ continue;
405
+ }
406
+ // Paragraph block: accumulate until blank / heading / list / fence / table.
407
+ if (line.trim() !== "") {
408
+ const startLine = i;
409
+ let endLine = i;
410
+ let j = i + 1;
411
+ while (j < lines.length) {
412
+ const cand = lines[j];
413
+ if (cand.trim() === "")
414
+ break;
415
+ if (FENCE.test(cand))
416
+ break;
417
+ if (HEADING.test(cand))
418
+ break;
419
+ if (LIST_ITEM.test(cand))
420
+ break;
421
+ if (TABLE_LINE.test(cand))
422
+ break;
423
+ endLine = j;
424
+ j++;
425
+ }
426
+ // Prose is only a candidate under a rule-ish heading.
427
+ if (currentHeadingIsRuleish) {
428
+ const paraStart = lineOffsets[startLine];
429
+ const paraEnd = lineOffsets[endLine] + lines[endLine].length;
430
+ const paraText = markdown.slice(paraStart, paraEnd);
431
+ // Sentence spans preserving absolute offsets.
432
+ const re = /[^.!?]+[.!?]+(\s|$)|[^.!?]+$/g;
433
+ let m;
434
+ while ((m = re.exec(paraText)) !== null) {
435
+ const s = trimSpan(markdown, {
436
+ start: paraStart + m.index,
437
+ end: paraStart + m.index + m[0].length,
438
+ });
439
+ if (s.start >= s.end)
440
+ continue;
441
+ const text = normalize(markdown.slice(s.start, s.end));
442
+ const c = gate(text, false, true);
443
+ if (c !== null)
444
+ out.push(emitFromSpan(markdown, lineOffsets, file, s, c));
445
+ }
446
+ }
447
+ i = endLine + 1;
448
+ continue;
449
+ }
450
+ i++;
451
+ }
452
+ return out;
453
+ }
454
+ //# sourceMappingURL=segment.js.map
@@ -86,6 +86,22 @@ function discoverSkills(basePath, ignore, layout) {
86
86
  ignored: content.includes(IGNORE_MARKER),
87
87
  });
88
88
  }
89
+ // Single-skill-directory target: a bare `SKILL.md` AT the base (the dir you
90
+ // pointed lint/audit at). The globs above only match `<skillDir>/*/SKILL.md`
91
+ // NESTED under the base, so without this the untested-skill check would silently
92
+ // vanish for exactly the single-skill target that scoping now supports.
93
+ const rootSkill = (0, node_path_1.join)(basePath, "SKILL.md");
94
+ if ((0, node_fs_1.existsSync)(rootSkill)) {
95
+ const name = (0, node_path_1.basename)(basePath);
96
+ const content = read(rootSkill);
97
+ out.push({
98
+ kind: "skill",
99
+ path: "SKILL.md",
100
+ name,
101
+ tokens: [`${layout.skillDir}/${name}`, `:${name}`],
102
+ ignored: content.includes(IGNORE_MARKER),
103
+ });
104
+ }
89
105
  return out;
90
106
  }
91
107
  function discoverAgents(basePath, ignore, layout) {
@@ -171,7 +187,13 @@ function discoverTests(basePath, globs, ignore) {
171
187
  /** Colocated: a test inside a skill dir, or a name-prefixed sibling of an agent/hook. */
172
188
  function isColocated(surface, testPath) {
173
189
  if (surface.kind === "skill") {
174
- return testPath.startsWith(`${(0, node_path_1.dirname)(surface.path)}/`);
190
+ const dir = (0, node_path_1.dirname)(surface.path);
191
+ // A root `SKILL.md` (single-skill-dir target) lives at ".", so any TOP-LEVEL
192
+ // test is colocated — globSync returns those without a "./" prefix, which a
193
+ // bare `startsWith("./")` would miss (false "untested").
194
+ return dir === "."
195
+ ? (0, node_path_1.dirname)(testPath) === "."
196
+ : testPath.startsWith(`${dir}/`);
175
197
  }
176
198
  return ((0, node_path_1.dirname)(testPath) === (0, node_path_1.dirname)(surface.path) &&
177
199
  (0, node_path_1.basename)(testPath).startsWith(`${surface.name}.`));
@@ -223,10 +245,14 @@ function findUntestedSurfaces(options = {}) {
223
245
  }
224
246
  /** Suggested colocated test path for an untested surface (shown in the warning). */
225
247
  function suggestedTestPath(surface) {
248
+ // A root skill lives at ".", so drop the "./" prefix — the suggested path then
249
+ // matches what globSync actually discovers at the top level.
250
+ const dir = (0, node_path_1.dirname)(surface.path);
251
+ const prefix = dir === "." ? "" : `${dir}/`;
226
252
  if (surface.kind === "skill") {
227
- return `${(0, node_path_1.dirname)(surface.path)}/${surface.name}.eval.mjs`;
253
+ return `${prefix}${surface.name}.eval.mjs`;
228
254
  }
229
- return `${(0, node_path_1.dirname)(surface.path)}/${surface.name}.harness.mjs`;
255
+ return `${prefix}${surface.name}.harness.mjs`;
230
256
  }
231
257
  /** Format an untested-surface report as human-readable text. */
232
258
  function formatUntestedReport(report) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "12.6.0",
3
+ "version": "12.8.0",
4
4
  "description": "Lint & test the harness your AI agent runs on — verify the references in your CLAUDE.md / AGENTS.md and test that your hooks and skills actually work.",
5
5
  "keywords": [
6
6
  "claude-code",