vigiles 14.0.0 → 14.1.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.
@@ -1,3 +1,41 @@
1
+ /**
2
+ * rule-routing.ts — the deterministic (no-model) State-B routing PREVIEW.
3
+ *
4
+ * `rule-inventory.ts` answers a narrow question ("which prose lines name an
5
+ * off-the-shelf lint rule, and is it enabled?"). This goes one honest step
6
+ * further: it SEGMENTS the whole instruction file into atomic rules
7
+ * ({@link segmentInstructions}) and routes each one into the class that a real
8
+ * enforcement path would take — WITHOUT running a model:
9
+ *
10
+ * reuse → the rule text names an off-the-shelf lint rule ({@link INTENT_MAP})
11
+ * → mechanism: flip one config line. The "narrow list we compile
12
+ * very well" — everything else is honestly labelled, not force-fit.
13
+ * hook → an ACTION rule a linter can't see (git push, rm -rf, "before you
14
+ * commit") → mechanism: a pre-commit / PreToolUse hook.
15
+ * meta → an agent-instruction, not a code rule ("read X first", "tell the
16
+ * user", "you are …") → mechanism: stays prose. Split out of
17
+ * `unrouted` so it never reads as "compilable but hard" (it isn't).
18
+ * semantic → a judgment call ("readable", "single responsibility") no checker
19
+ * can honestly decide → mechanism: stays prose.
20
+ * unrouted → looks like a code rule but matched no off-the-shelf rule: HARD to
21
+ * codify → mechanism `synthesize`: the opt-in SYNTHESIS tier (a
22
+ * skill on your subscription) MIGHT write a custom checker, gated —
23
+ * but it is NOT guaranteed (the gate may abstain). This is the
24
+ * bucket audit must present clearly as "hard", never as done.
25
+ *
26
+ * NB "compile" is NOT used here — `vigiles compile` is the unrelated spec→markdown
27
+ * verb. Synthesis is its own opt-in tier; the mechanism value is `synthesize`.
28
+ *
29
+ * HONESTY BY CONSTRUCTION: the deterministic tier NEVER claims a rule is
30
+ * "synthesizable" — deciding that a custom rule can be written (and gating it)
31
+ * is exactly the work the opt-in model tier does. `unrouted` means "hard — a
32
+ * synthesis skill may try", never a promise; `meta`/`semantic` mean "not an
33
+ * enforceable code rule at all" (a different, honest kind of no).
34
+ *
35
+ * Pure, deterministic, dependency-free. Reuses `rule-inventory`'s hardened
36
+ * whole-token matcher + `INTENT_MAP`, and `segment`'s Tier-A segmenter.
37
+ */
38
+ import { type SkippedBullet } from "./segment.js";
1
39
  import { type LinterName } from "./rule-inventory.js";
2
40
  import type { RuleCatalog } from "./core/rule-catalog.js";
3
41
  /** How a routed rule would be enforced (a MECHANISM ladder, not a 1-10 score). */
@@ -29,10 +67,19 @@ export interface RoutedRule {
29
67
  readonly source?: "marker" | "heuristic";
30
68
  }
31
69
  export interface RuleRouting {
32
- /** How many atomic rules were routed (after the confidence filter). */
70
+ /** How many CONFIDENT atomic rules were routed (high or rescued). */
33
71
  readonly segmented: number;
34
72
  readonly counts: Record<RuleCategory, number>;
73
+ /** The CONFIDENT tier — cleared the precision bar; these are the routed rules. */
35
74
  readonly rules: readonly RoutedRule[];
75
+ /** The POSSIBLE tier — rule-ish bullets (medium confidence) that did NOT clear
76
+ * the bar, still classified so a human can review + promote them. Detection is
77
+ * precision-first, so this is where a declarative rule ("Every X must Y") that
78
+ * the confident tier misses shows up. See `research/rule-compiler-design.md` §2. */
79
+ readonly possible: readonly RoutedRule[];
80
+ /** Bullets the segmenter decided were NOT rules, each with a reason — so the
81
+ * report is honest about what it set aside (§3). */
82
+ readonly skipped: readonly SkippedBullet[];
36
83
  }
37
84
  export interface RouteOptions {
38
85
  /**
@@ -49,6 +49,14 @@ const MECHANISM = {
49
49
  semantic: "prose",
50
50
  unrouted: "synthesize",
51
51
  };
52
+ /**
53
+ * A deontic/norm signal ANYWHERE in a bullet — the marker of a rule the imperative
54
+ * gate missed because the norm isn't at the head ("every function MUST have a
55
+ * docstring", "public APIs SHOULD stay stable"). Used to keep the POSSIBLE review
56
+ * tier to genuine rule-candidates instead of arbitrary unparsed prose. Deliberately
57
+ * narrow (modal verbs only) so it doesn't re-admit the noise it exists to exclude.
58
+ */
59
+ const NORM_SIGNAL = /\b(?:must(?:n't)?|should(?:n't)?|shall|never|always|avoids?|require[sd]?|forbidden|disallow(?:ed)?|prohibited|banned?|prefers?|do not|don't)\b/i;
52
60
  /**
53
61
  * ACTION-rule cues — things a linter never sees (git, filesystem, shell,
54
62
  * process). A hook is the right gate, not a lint rule. Widened to the article's
@@ -221,27 +229,54 @@ function namedRuleTokens(text) {
221
229
  }
222
230
  return out;
223
231
  }
224
- /**
225
- * Route one atomic rule. Order matters: an ACTION cue (git push) wins over a
226
- * rule-name mention ("never commit console.log" is a hook, not a lint rule); a
227
- * META agent-instruction is pulled out before reuse so it isn't mismatched to a
228
- * rule; the DYNAMIC catalog (if present) and the static `INTENT_MAP` both feed
229
- * `reuse`; reuse wins over a soft semantic cue.
230
- */
232
+ /** Combine two hits that a doc-token resolves to (a cross-linter id collision).
233
+ * enabled OR-s — a "**Enforced by:** X" claim is satisfied if ANY linter has X
234
+ * on, so we never cry "documented but OFF" when one linter enforces it — and
235
+ * provenance follows the enforcing linter. */
236
+ function combineHits(a, b) {
237
+ if (a.enabled === b.enabled)
238
+ return { enabled: a.enabled, linter: a.linter };
239
+ return a.enabled ? a : b; // exactly one is on → it wins (enabled OR-s to true)
240
+ }
241
+ /** Build the doc-token → hit lookup from a (possibly polyglot) rule list. A rule
242
+ * is matchable by its id AND, for Pylint, its numeric code. A bare id CAN collide
243
+ * across linters (`no-else-return` is in both ESLint and Pylint) → combine
244
+ * conservatively. A numeric code is unique to its linter, so it never collides
245
+ * and KEEPS its own (linter, enabled) — a doc naming the Pylint code `R1705`
246
+ * still surfaces "documented but OFF" even when the symbol is enabled in ESLint. */
247
+ function buildCatalogLookup(rules) {
248
+ const map = new Map();
249
+ const put = (key, hit) => {
250
+ const prev = map.get(key);
251
+ map.set(key, prev ? combineHits(prev, hit) : hit);
252
+ };
253
+ for (const r of rules) {
254
+ const hit = { enabled: r.enabled, linter: r.linter };
255
+ put(r.id, hit);
256
+ if (r.code)
257
+ put(r.code, hit);
258
+ }
259
+ return map;
260
+ }
231
261
  function classify(text, catalog) {
232
262
  if (HOOK_CUES.some((re) => re.test(text)))
233
263
  return { category: "hook" };
234
264
  if (META_CUES.some((re) => re.test(text)))
235
265
  return { category: "meta" };
236
266
  // Dynamic catalog: a bullet that NAMES one of the repo's real rules → reuse,
237
- // carrying whether it's currently enabled (a disabled hit = the "documented but
238
- // OFF" nudge). Own-repo only — catalog is present only when the linter was
239
- // enumerated with consent.
267
+ // carrying its linter + whether it's currently enabled (a disabled hit = the
268
+ // "documented but OFF" nudge). Own-repo only — catalog is present only when the
269
+ // linter was enumerated with consent.
240
270
  if (catalog) {
241
271
  for (const tok of namedRuleTokens(text)) {
242
- const enabled = catalog.get(tok);
243
- if (enabled !== undefined)
244
- return { category: "reuse", rule: tok, enabled };
272
+ const hit = catalog.get(tok);
273
+ if (hit !== undefined)
274
+ return {
275
+ category: "reuse",
276
+ rule: tok,
277
+ enabled: hit.enabled,
278
+ linter: hit.linter,
279
+ };
245
280
  }
246
281
  }
247
282
  for (const m of rule_inventory_js_1.INTENT_MAP) {
@@ -266,11 +301,15 @@ const GUARD_RE = /^\*\*Guard:\*\*/;
266
301
  const GUIDANCE_RE = /^\*\*Guidance only\*\*/;
267
302
  const MARK_HEADING = /^(#{2,6})\s+(.*)$/;
268
303
  const RULE_ID_SHAPE = /^@?[a-z][a-z0-9._/-]*$/;
304
+ // Pylint's numeric alias (C0116, W9006) — the catalog advertises these as
305
+ // matchable, so a marker using one must parse as a rule id, not a prose claim.
306
+ const PYLINT_CODE_SHAPE = /^[A-Z]\d+$/;
269
307
  /** Does this `**Enforced by:**` value parse as a lint-rule id (vs a prose claim
270
308
  * like "CI" or "the linter")? A hand-written marker is a CLAIM — only a rule-id
271
309
  * shape is treated as a real reuse rule. */
272
310
  function looksLikeRuleId(s) {
273
- return s.length >= 3 && RULE_ID_SHAPE.test(s.trim());
311
+ const t = s.trim();
312
+ return (t.length >= 3 && RULE_ID_SHAPE.test(t)) || PYLINT_CODE_SHAPE.test(t);
274
313
  }
275
314
  /**
276
315
  * Extract rules from EXPLICIT structured markers (`**Enforced by:** \`rule\``,
@@ -304,11 +343,13 @@ function extractMarkedRules(text, file, catalog) {
304
343
  if (em) {
305
344
  if (!looksLikeRuleId(em[1]))
306
345
  break; // a prose claim, not a rule id
307
- const enabled = catalog?.get(em[1].trim());
346
+ const hit = catalog?.get(em[1].trim());
308
347
  marked = {
309
348
  category: "reuse",
310
349
  rule: em[1].trim(),
311
- ...(enabled !== undefined ? { enabled } : {}),
350
+ ...(hit !== undefined
351
+ ? { enabled: hit.enabled, linter: hit.linter }
352
+ : {}),
312
353
  };
313
354
  break;
314
355
  }
@@ -347,6 +388,7 @@ function extractMarkedRules(text, file, catalog) {
347
388
  mechanism: MECHANISM[marked.category],
348
389
  source: "marker",
349
390
  ...(marked.rule ? { rule: marked.rule } : {}),
391
+ ...(marked.linter ? { linter: marked.linter } : {}),
350
392
  ...(marked.enabled !== undefined ? { enabled: marked.enabled } : {}),
351
393
  });
352
394
  }
@@ -360,7 +402,7 @@ function extractMarkedRules(text, file, catalog) {
360
402
  function routeRules(instructionText, file, options = {}) {
361
403
  const minConfidence = options.minConfidence ?? "high";
362
404
  const catalog = options.availableRules
363
- ? new Map(options.availableRules.rules.map((r) => [r.id, r.enabled]))
405
+ ? buildCatalogLookup(options.availableRules.rules)
364
406
  : undefined;
365
407
  // A MEDIUM segment that NAMES a rule the repo's catalog actually has is
366
408
  // enforceable — the catalog is ground truth, so it's higher-precision than the
@@ -381,15 +423,17 @@ function routeRules(instructionText, file, options = {}) {
381
423
  // syntax. Fixes construct-prohibitions with no verb ("No bare except clauses")
382
424
  // that score medium and would otherwise drop before classify() reuses them.
383
425
  const matchesIntentMap = (text) => rule_inventory_js_1.INTENT_MAP.some((m) => m.keywords.some((kw) => (0, rule_inventory_js_1.matchesWholeToken)(text, kw)));
384
- // S0/S1 pre-pass: explicit markers are definitive and are CONSUMED (their body
385
- // lines are skipped) so the heuristic segmenter can't double-count them.
386
- const marked = extractMarkedRules(instructionText, file, catalog);
387
- const segments = (0, segment_js_1.segmentInstructions)(instructionText, file, marked.skip).filter((s) => minConfidence === "medium" ||
388
- s.confidence === "high" ||
389
- namesCatalogRule(s.text) ||
390
- matchesPatternRule(s.text) ||
391
- matchesIntentMap(s.text));
392
- const heuristicRules = segments.map((s) => {
426
+ // A segment is CONFIDENT if it's high, rescued by the catalog/pattern/intent, or
427
+ // the caller opted into medium. Everything else the segmenter emitted is a
428
+ // POSSIBLE rule (medium, unrescued) — surfaced for review, not routed as fact.
429
+ // A RESCUE — the text NAMES/matches a real rule (catalog / restricted-syntax /
430
+ // intent). This promotes even a gate-rejected bullet to confident, because it
431
+ // provably maps to an off-the-shelf rule; independent of the medium opt-in.
432
+ const isRescued = (text) => namesCatalogRule(text) ||
433
+ matchesPatternRule(text) ||
434
+ matchesIntentMap(text);
435
+ const isConfident = (s) => minConfidence === "medium" || s.confidence === "high" || isRescued(s.text);
436
+ const toRouted = (s) => {
393
437
  const c = classify(s.text, catalog);
394
438
  return {
395
439
  text: s.text,
@@ -405,7 +449,58 @@ function routeRules(instructionText, file, options = {}) {
405
449
  ...(c.linter ? { linter: c.linter } : {}),
406
450
  ...(c.enabled !== undefined ? { enabled: c.enabled } : {}),
407
451
  };
452
+ };
453
+ // S0/S1 pre-pass: explicit markers are definitive and are CONSUMED (their body
454
+ // lines are skipped) so the heuristic segmenter can't double-count them.
455
+ const marked = extractMarkedRules(instructionText, file, catalog);
456
+ const { segments, skipped: rawSkipped } = (0, segment_js_1.segmentInstructions)(instructionText, file, marked.skip);
457
+ // A bullet the gate rejected as `no-signal` is a rule CANDIDATE only if it
458
+ // carries a deontic/norm signal (a modal like must/should/never/avoid) — that
459
+ // keeps the POSSIBLE review tier to genuine recall-misses ("every function must
460
+ // have a docstring") instead of flooding it with prose ("README.md documents
461
+ // v2"). A no-signal bullet WITHOUT a norm signal is confidently not a rule, so
462
+ // it stays SKIPPED alongside the index/description/section rejects. A folded
463
+ // candidate that NAMES/matches a real rule is still rescued to CONFIDENT.
464
+ const asCandidate = (s) => ({
465
+ text: s.text,
466
+ exactQuote: s.text,
467
+ file: s.file,
468
+ lineStart: s.lineStart,
469
+ lineEnd: s.lineEnd,
470
+ confidence: "medium",
408
471
  });
472
+ // Real SEGMENTS route by the full confident check (incl. the medium opt-in).
473
+ // Gate-rejected `no-signal` bullets are folded back in as candidates, but they
474
+ // are promoted to confident ONLY by a real RESCUE — NEVER by the blanket medium
475
+ // opt-in, which must not resurrect bullets the gate explicitly rejected.
476
+ const noSignal = rawSkipped.filter((s) => s.reason === "no-signal");
477
+ const folds = noSignal.map(asCandidate);
478
+ const heuristicRules = [
479
+ ...segments.filter(isConfident),
480
+ ...folds.filter((s) => isRescued(s.text)),
481
+ ].map(toRouted);
482
+ // The non-confident leftovers split by the norm signal: a rule-ish bullet
483
+ // (carries a deontic modal) is a genuine recall-miss → POSSIBLE (review); the
484
+ // rest is prose → SKIPPED with a `no-signal` reason (visible, not dropped).
485
+ const leftover = [
486
+ ...segments.filter((s) => !isConfident(s)),
487
+ ...folds.filter((s) => !isRescued(s.text)),
488
+ ];
489
+ const possible = leftover
490
+ .filter((s) => NORM_SIGNAL.test(s.text))
491
+ .map(toRouted);
492
+ const skipped = [
493
+ ...rawSkipped.filter((s) => s.reason !== "no-signal"),
494
+ ...leftover
495
+ .filter((s) => !NORM_SIGNAL.test(s.text))
496
+ .map((s) => ({
497
+ text: s.text,
498
+ file: s.file,
499
+ lineStart: s.lineStart,
500
+ lineEnd: s.lineEnd,
501
+ reason: "no-signal",
502
+ })),
503
+ ];
409
504
  // Marker rules first (definitive), then the heuristic residue.
410
505
  const rules = [...marked.rules, ...heuristicRules];
411
506
  const counts = {
@@ -417,7 +512,7 @@ function routeRules(instructionText, file, options = {}) {
417
512
  };
418
513
  for (const r of rules)
419
514
  counts[r.category]++;
420
- return { segmented: rules.length, counts, rules };
515
+ return { segmented: rules.length, counts, rules, possible, skipped };
421
516
  }
422
517
  /**
423
518
  * Merge per-file routings into one. Each instruction source is routed SEPARATELY
@@ -434,13 +529,17 @@ function mergeRoutings(routings) {
434
529
  unrouted: 0,
435
530
  };
436
531
  const rules = [];
532
+ const possible = [];
533
+ const skipped = [];
437
534
  let segmented = 0;
438
535
  for (const r of routings) {
439
536
  segmented += r.segmented;
440
537
  rules.push(...r.rules);
538
+ possible.push(...r.possible);
539
+ skipped.push(...r.skipped);
441
540
  for (const k of Object.keys(counts))
442
541
  counts[k] += r.counts[k];
443
542
  }
444
- return { segmented, counts, rules };
543
+ return { segmented, counts, rules, possible, skipped };
445
544
  }
446
545
  //# sourceMappingURL=rule-routing.js.map
package/dist/segment.d.ts CHANGED
@@ -22,6 +22,28 @@ export interface SegmentedRule {
22
22
  /** 3/3 cues => "high"; 2/3 => "medium". (Rejected candidates are never emitted.) */
23
23
  confidence: "high" | "medium";
24
24
  }
25
+ /** Why the segmenter decided a bullet is NOT a rule (the transparency signal —
26
+ * see `research/rule-compiler-design.md` §3). `index`/`description`/`no-signal`
27
+ * come from the gate; `section` means it sits under a non-rule heading
28
+ * (Setup / Commands / Key Files / Architecture …). */
29
+ export type RejectReason = "index" | "description" | "no-signal" | "section";
30
+ /** A BULLET the segmenter saw but did NOT treat as a rule, with the reason — so
31
+ * the audit report can be honest about what it set aside (a heuristic misses
32
+ * declarative rules; showing skips lets a human eyeball a wrong drop). Bounded to
33
+ * list items on purpose; rejected paragraph prose is not reported (too noisy). */
34
+ export interface SkippedBullet {
35
+ readonly text: string;
36
+ readonly file: string | undefined;
37
+ readonly lineStart: number;
38
+ readonly lineEnd: number;
39
+ readonly reason: RejectReason;
40
+ }
41
+ /** The segmenter's full output: the confident/medium candidate rules PLUS the
42
+ * bullets it rejected (with reasons), so nothing is silently dropped. */
43
+ export interface SegmentResult {
44
+ readonly segments: SegmentedRule[];
45
+ readonly skipped: SkippedBullet[];
46
+ }
25
47
  /**
26
48
  * Split a CLAUDE.md / AGENTS.md into atomic candidate rules with provenance.
27
49
  *
@@ -29,5 +51,5 @@ export interface SegmentedRule {
29
51
  * candidacy. Candidate units are (a) list items with attached continuation
30
52
  * lines and (b) sentences of paragraphs under a rule-ish heading.
31
53
  */
32
- export declare function segmentInstructions(markdown: string, file?: string, skipLines?: ReadonlySet<number>): SegmentedRule[];
54
+ export declare function segmentInstructions(markdown: string, file?: string, skipLines?: ReadonlySet<number>): SegmentResult;
33
55
  //# sourceMappingURL=segment.d.ts.map
package/dist/segment.js CHANGED
@@ -310,8 +310,13 @@ function isLinkOnly(text) {
310
310
  const t = text.trim();
311
311
  return URL_ONLY.test(t) || LINK_ONLY.test(t);
312
312
  }
313
+ /** The accept/reject view of a gate result, for sites that only need the split
314
+ * (atomize, the sub-span loop) and don't care about the reason. */
315
+ function confidenceOf(g) {
316
+ return "confidence" in g ? g.confidence : null;
317
+ }
313
318
  /**
314
- * Score the 3 cues. Returns confidence or null (reject).
319
+ * Score the 3 cues. Returns a confidence OR a reject reason.
315
320
  * - form: starts with an imperative/prohibitive head (or "No X").
316
321
  * - context: is a bullet OR sits under a rule-ish heading.
317
322
  * - shape: 15–300 chars, has a verb-ish token, not link-only, not a declaration.
@@ -322,18 +327,18 @@ function gate(text, isBullet, underRuleHeading) {
322
327
  // `a.ts → b.ts`, `dir/x — …`, `Label: path`) — the corpus's dominant false
323
328
  // positive. No cue count can rescue it.
324
329
  if (looksLikeIndexEntry(t))
325
- return null;
330
+ return { reject: "index" };
326
331
  // Reject a DESCRIPTION-led sentence (`` `Foo` class in `x` executes … ``) — an
327
332
  // architecture/index sentence, not a rule (the dogfood's #1 false positive).
328
333
  if (looksLikeDescription(t))
329
- return null;
334
+ return { reject: "description" };
330
335
  const context = isBullet || underRuleHeading;
331
336
  // RULE-NAME cue: a bullet/section line that NAMES an off-the-shelf rule is a
332
337
  // strong signal it's enforceable, even without an imperative verb — promote it
333
338
  // to high so the high-only default doesn't drop it (recovers rule-naming
334
339
  // bullets like "No floating promises (`@ts.../no-floating-promises`)").
335
340
  if (context && RULE_NAME_IN_CODE.test(t))
336
- return "high";
341
+ return { confidence: "high" };
337
342
  // The form/declaration cues see the text with leading decoration stripped, so
338
343
  // `- **Never** …` reads as imperative and `**We** …` still reads declarative.
339
344
  const head = stripLeadDecoration(t);
@@ -345,10 +350,10 @@ function gate(text, isBullet, underRuleHeading) {
345
350
  !DECLARATION.test(head);
346
351
  const cues = (form ? 1 : 0) + (context ? 1 : 0) + (shape ? 1 : 0);
347
352
  if (cues >= 3)
348
- return "high";
353
+ return { confidence: "high" };
349
354
  if (cues === 2)
350
- return "medium";
351
- return null;
355
+ return { confidence: "medium" };
356
+ return { reject: "no-signal" };
352
357
  }
353
358
  // --- Atomicity split -------------------------------------------------------
354
359
  /** Never split when an exception clause carries polarity/meaning. */
@@ -404,7 +409,7 @@ function atomize(src, contentSpan, isBullet, underRuleHeading) {
404
409
  // Both/all halves must independently pass the gate, else keep whole.
405
410
  for (const p of pieces) {
406
411
  const text = normalize(src.slice(p.start, p.end));
407
- if (gate(text, isBullet, underRuleHeading) === null)
412
+ if (confidenceOf(gate(text, isBullet, underRuleHeading)) === null)
408
413
  return [whole];
409
414
  }
410
415
  return pieces.length > 1 ? pieces : [whole];
@@ -439,6 +444,7 @@ function segmentInstructions(markdown, file, skipLines) {
439
444
  const lines = markdown.split("\n");
440
445
  const lineOffsets = computeLineOffsets(lines);
441
446
  const out = [];
447
+ const skipped = [];
442
448
  let inFence = false;
443
449
  let currentHeadingIsRuleish = false;
444
450
  let currentHeadingIsAntiContext = false;
@@ -513,7 +519,8 @@ function segmentInstructions(markdown, file, skipLines) {
513
519
  const contentEnd = lineOffsets[endLine] + lines[endLine].length;
514
520
  const contentSpan = { start: contentStart, end: contentEnd };
515
521
  const wholeText = normalize(markdown.slice(contentStart, contentEnd));
516
- const conf = gate(wholeText, true, currentHeadingIsRuleish);
522
+ const g = gate(wholeText, true, currentHeadingIsRuleish);
523
+ const conf = confidenceOf(g);
517
524
  // Reject bullets under an anti-context heading (Commands/Setup/Key Files/
518
525
  // Architecture/…) — the corpus's dominant false-positive locus.
519
526
  if (conf !== null && !currentHeadingIsAntiContext) {
@@ -528,12 +535,27 @@ function segmentInstructions(markdown, file, skipLines) {
528
535
  else {
529
536
  for (const s of spans) {
530
537
  const text = normalize(markdown.slice(s.start, s.end));
531
- const c = gate(text, true, currentHeadingIsRuleish);
538
+ const c = confidenceOf(gate(text, true, currentHeadingIsRuleish));
532
539
  if (c !== null)
533
540
  out.push(emitFromSpan(markdown, lineOffsets, file, s, c));
534
541
  }
535
542
  }
536
543
  }
544
+ else {
545
+ // This bullet was NOT treated as a rule — record it + why, so the audit
546
+ // report can be honest about what it set aside (transparency, §3). A
547
+ // rejection under an anti-context heading is a "section" skip; otherwise
548
+ // it's the gate's own reason.
549
+ skipped.push({
550
+ text: wholeText,
551
+ file,
552
+ lineStart: offsetToLine(lineOffsets, contentStart),
553
+ lineEnd: offsetToLine(lineOffsets, contentEnd - 1),
554
+ reason: currentHeadingIsAntiContext || "confidence" in g
555
+ ? "section"
556
+ : g.reject,
557
+ });
558
+ }
537
559
  i = endLine + 1;
538
560
  continue;
539
561
  }
@@ -573,7 +595,7 @@ function segmentInstructions(markdown, file, skipLines) {
573
595
  if (s.start >= s.end)
574
596
  continue;
575
597
  const text = normalize(markdown.slice(s.start, s.end));
576
- const c = gate(text, false, true);
598
+ const c = confidenceOf(gate(text, false, true));
577
599
  if (c !== null)
578
600
  out.push(emitFromSpan(markdown, lineOffsets, file, s, c));
579
601
  }
@@ -583,6 +605,6 @@ function segmentInstructions(markdown, file, skipLines) {
583
605
  }
584
606
  i++;
585
607
  }
586
- return out;
608
+ return { segments: out, skipped };
587
609
  }
588
610
  //# sourceMappingURL=segment.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "14.0.0",
3
+ "version": "14.1.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",