vigiles 38.1.0 → 38.2.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.
@@ -86,7 +86,11 @@ exports.claudeCodeDialect = {
86
86
  unit: "chars",
87
87
  limit: 40000,
88
88
  onExceed: "warns",
89
- capturedFrom: "claude-code /doctor large-file warning; the rules dir measured 2026-09-16 in a consumer repo",
89
+ // The CLI's warning scales with the model: per file max(40 000, window ×
90
+ // 0.05 × chars-per-token), all files max(120 000, that). 40 000 is its
91
+ // floor (a 200K-token window), kept as the budget on purpose: the weight is
92
+ // re-read on every request whatever the window. Read from the 2.1.296 binary.
93
+ capturedFrom: "claude-code 2.1.296 large-file warning, floor of max(40000, window*0.05*charsPerToken); the rules dir measured 2026-09-16 in a consumer repo",
90
94
  },
91
95
  // The capability table — what each event CARRIES and HONOURS. Nine of the 31
92
96
  // events, each with its basis; see ./event-capability.ts. The three flat lists
package/dist/cli-main.js CHANGED
@@ -1777,6 +1777,7 @@ async function runLint(restArgs, flags, excludes, config) {
1777
1777
  const instructionWeight = (0, instruction_weight_ratchet_js_1.checkInstructionWeightRatchet)({
1778
1778
  root: frame.root,
1779
1779
  severity: (0, types_js_1.ruleSeverity)(config?.rules?.["instruction-weight"]),
1780
+ max: (0, types_js_1.ruleOptions)(config?.rules?.["instruction-weight"])?.max,
1780
1781
  update: flags.includes("--update-baseline"),
1781
1782
  silent,
1782
1783
  measure: () => lintRoots.map((abs) => [
@@ -39,6 +39,7 @@ const keyfiles_js_1 = require("./keyfiles.js");
39
39
  const linters_js_1 = require("./linters.js");
40
40
  const tool_contract_js_1 = require("./tool-contract.js");
41
41
  const effects_js_1 = require("./effects.js");
42
+ const instruction_remedies_js_1 = require("./instruction-remedies.js");
42
43
  const dialect_js_1 = require("./dialect.js");
43
44
  // vigiles's default compile target when a spec names none and no dialect is
44
45
  // injected — a product convention (vigiles emits CLAUDE.md by default), not a
@@ -474,9 +475,10 @@ function sectionBudget(sections, specFile, limits) {
474
475
  .map(([name, body]) => ({
475
476
  type: "section-too-large",
476
477
  path: specFile,
477
- message: `section ${JSON.stringify(name)} is ${String(body.length)} characters ` +
478
- `(budget ${String(limits.sectionChars)}). The line guard cannot see this — long lines are ` +
479
- `the normal shape of compiled prose — and every character is loaded on every request.`,
478
+ message: `section ${JSON.stringify(name)} is ${n(body.length)} characters ` +
479
+ `(budget ${n(limits.sectionChars)}). The line guard cannot see this — long lines are ` +
480
+ `the normal shape of compiled prose — and every character is loaded on every request.\n` +
481
+ instruction_remedies_js_1.MOVE_OUT_OF_ALWAYS_LOADED,
480
482
  }));
481
483
  }
482
484
  /** The whole Rules section, as one sum. */
@@ -493,7 +495,8 @@ function rulesBudget(input, specFile, limits) {
493
495
  `(budget ${n(limits.rulesChars)}). Render a rule shorter instead of deleting it: ` +
494
496
  `enforce(rule, why, { render: "id" }) when a linter already holds it, or ` +
495
497
  `guidance(statement, { details: file(...) }) with the long text in that file — ` +
496
- `the rule stays in the spec, where lint and audit read it.`,
498
+ `the rule stays in the spec, where lint and audit read it.\n` +
499
+ instruction_remedies_js_1.MOVE_OUT_OF_ALWAYS_LOADED,
497
500
  },
498
501
  ];
499
502
  }
@@ -149,7 +149,11 @@ export declare const vigilesConfigSchema: z.ZodObject<{
149
149
  "hook-block-ineffective": z.ZodDefault<z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "error" | "warn", boolean | 0 | 1 | 2 | "error" | "warn" | "off">>>;
150
150
  "hook-matcher": z.ZodDefault<z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "error" | "warn", boolean | 0 | 1 | 2 | "error" | "warn" | "off">>>;
151
151
  "doc-refs": z.ZodDefault<z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "error" | "warn", boolean | 0 | 1 | 2 | "error" | "warn" | "off">>>;
152
- "instruction-weight": z.ZodDefault<z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "error" | "warn", boolean | 0 | 1 | 2 | "error" | "warn" | "off">>>;
152
+ "instruction-weight": z.ZodDefault<z.ZodUnion<readonly [z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "error" | "warn", boolean | 0 | 1 | 2 | "error" | "warn" | "off">>, z.ZodTuple<[z.ZodLiteral<"warn">, z.ZodObject<{
153
+ max: z.ZodOptional<z.ZodNumber>;
154
+ }, z.core.$strict>], null>, z.ZodTuple<[z.ZodLiteral<"error">, z.ZodObject<{
155
+ max: z.ZodOptional<z.ZodNumber>;
156
+ }, z.core.$strict>], null>]>>;
153
157
  "deprecated-key-files": z.ZodDefault<z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "error" | "warn", boolean | 0 | 1 | 2 | "error" | "warn" | "off">>>;
154
158
  }, z.core.$strict>>;
155
159
  files: z.ZodDefault<z.ZodArray<z.ZodString>>;
@@ -122,6 +122,17 @@ const testCoverageConfig = zod_1.z
122
122
  testExtension: zod_1.z.string().optional(),
123
123
  })
124
124
  .strict();
125
+ /**
126
+ * Options for `instruction-weight`. `max` is an opt-in ABSOLUTE ceiling on the
127
+ * always-loaded total, in the harness's own unit — independent of the ratchet's
128
+ * baseline. A positive integer: `0` would fail every repo that has an
129
+ * instruction file, and `"40000"` is a string the author meant as a number.
130
+ */
131
+ const instructionWeightConfig = zod_1.z
132
+ .object({
133
+ max: zod_1.z.number().int().positive().optional(),
134
+ })
135
+ .strict();
125
136
  /**
126
137
  * Thresholds for `compile-budget`, in characters. Each one left out keeps its
127
138
  * built-in default; `0` turns that one budget off.
@@ -181,7 +192,7 @@ const rulesSchema = zod_1.z
181
192
  "hook-block-ineffective": severitySchema.default("warn"),
182
193
  "hook-matcher": severitySchema.default("warn"),
183
194
  "doc-refs": severitySchema.default(false),
184
- "instruction-weight": severitySchema.default("error"),
195
+ "instruction-weight": withOptions(instructionWeightConfig).default("error"),
185
196
  "deprecated-key-files": severitySchema.default("warn"),
186
197
  })
187
198
  .strict();
@@ -491,7 +502,7 @@ function formatConfigIssues(issues) {
491
502
  if (issue.code === "unrecognized_keys")
492
503
  return unknownKeyLines(issue);
493
504
  if (issue.code === "invalid_union")
494
- return [badValueLine(issue)];
505
+ return unionLines(issue);
495
506
  return [
496
507
  `.vigilesrc.json: ${pathLabel(issue.path ?? [])} — ${issue.message ?? "invalid"}.`,
497
508
  ];
@@ -515,6 +526,28 @@ function unknownKeyLines(issue) {
515
526
  return `.vigilesrc.json: unknown key "${key}" in ${where}. Known: ${listCandidates(known)}.`;
516
527
  });
517
528
  }
529
+ /**
530
+ * A failed union: the one line listing what the key accepts — UNLESS a branch
531
+ * got past the union's own position and failed INSIDE its options
532
+ * (`["error", {"max": 1.5}]` matches the tuple's shape and fails at index 1).
533
+ * That inner issue is the problem the author has; "not one of the accepted
534
+ * values" would send them to re-read a list that was never wrong. Zod already
535
+ * collapses an inner failure of a non-type kind (`max: 0` → "Too small"); a type
536
+ * failure (`1.5`, `"4"`) is the case it leaves as a bare union.
537
+ *
538
+ * "Inside" means a path of two or more: index 0 of a `[severity, options]`
539
+ * tuple is the severity itself, and a wrong severity (`["errr", {}]`) is still
540
+ * the accepted-values line. Both tuple branches (`warn` / `error`) report the
541
+ * same inner issue, so it is de-duplicated.
542
+ */
543
+ function unionLines(issue) {
544
+ const here = issue.path ?? [];
545
+ const inner = (issue.errors ?? [])
546
+ .flat()
547
+ .filter((e) => (e.path ?? []).length >= 2 && e.code !== "invalid_union")
548
+ .map((e) => `.vigilesrc.json: ${pathLabel([...here, ...(e.path ?? [])])} — ${e.message ?? "invalid"}.`);
549
+ return inner.length === 0 ? [badValueLine(issue)] : [...new Set(inner)];
550
+ }
518
551
  /** ONE line for a failed union — never one per branch. */
519
552
  function badValueLine(issue) {
520
553
  const accepted = acceptedValues(issue);
@@ -127,12 +127,38 @@ export type RatchetVerdict = {
127
127
  readonly changes: readonly FileChange[];
128
128
  };
129
129
  export declare function compareToBaseline(weight: InstructionWeight, recorded: BaselineEntry | undefined): RatchetVerdict;
130
+ /** One file's size, for naming where the weight is. */
131
+ export interface FileWeight {
132
+ readonly path: string;
133
+ readonly size: number;
134
+ }
135
+ /**
136
+ * A total above the opt-in absolute ceiling (`["error", { max }]`).
137
+ *
138
+ * Decided on the same number the ratchet records — `committedTotal`, the SCORED
139
+ * files only — and in the same unit, so the two can never disagree about what
140
+ * the weight is. It does not look at the baseline at all: that independence is
141
+ * what makes it a ceiling and not a second ratchet.
142
+ */
143
+ export interface CeilingVerdict {
144
+ readonly total: number;
145
+ readonly max: number;
146
+ readonly unit: BaselineEntry["unit"];
147
+ /** The scored files, heaviest first — where the cut has to come from. */
148
+ readonly largest: readonly FileWeight[];
149
+ }
150
+ /** `undefined` when the weight is at or under `max`, or when no ceiling is set. */
151
+ export declare function checkCeiling(weight: InstructionWeight, max: number | undefined): CeilingVerdict | undefined;
130
152
  /** Replace the measured bundles' entries; `null` removes one. Others are kept. */
131
153
  export declare function recordEntries(previous: InstructionBaseline | undefined, measured: readonly (readonly [string, BaselineEntry | null])[]): InstructionBaseline;
132
154
  /** Stable text: sorted keys, two-space indent, trailing newline, no timestamp. */
133
155
  export declare function serializeBaseline(baseline: InstructionBaseline): string;
134
156
  /** The one-paragraph message a verdict prints as, prefixed by its bundle. */
135
157
  export declare function formatVerdict(verdict: RatchetVerdict, at: string): string;
158
+ /** The message for a total over the ceiling, prefixed by its bundle. */
159
+ export declare function formatCeiling(verdict: CeilingVerdict, at: string): string;
160
+ /** Why `--update-baseline` wrote nothing: the ceiling it would have been recorded above. */
161
+ export declare function formatRefusal(verdict: CeilingVerdict, at: string): string;
136
162
  /** The compile-time one-liner: weight now, against the baseline when there is one. */
137
163
  export declare function formatWeightLine(verdict: RatchetVerdict): string;
138
164
  export {};
@@ -4,9 +4,12 @@ exports.INSTRUCTION_WEIGHT_MEASURE = exports.INSTRUCTION_BASELINE_VERSION = expo
4
4
  exports.parseInstructionBaseline = parseInstructionBaseline;
5
5
  exports.entryFor = entryFor;
6
6
  exports.compareToBaseline = compareToBaseline;
7
+ exports.checkCeiling = checkCeiling;
7
8
  exports.recordEntries = recordEntries;
8
9
  exports.serializeBaseline = serializeBaseline;
9
10
  exports.formatVerdict = formatVerdict;
11
+ exports.formatCeiling = formatCeiling;
12
+ exports.formatRefusal = formatRefusal;
10
13
  exports.formatWeightLine = formatWeightLine;
11
14
  /**
12
15
  * The instruction-weight RATCHET: the always-loaded weight may not grow past
@@ -46,6 +49,7 @@ exports.formatWeightLine = formatWeightLine;
46
49
  * is the caller's (`src/instruction-weight-ratchet.ts`).
47
50
  */
48
51
  const zod_1 = require("zod");
52
+ const instruction_remedies_js_1 = require("./instruction-remedies.js");
49
53
  /** Where the baseline lives, relative to the directory `.vigilesrc.json` is read from. */
50
54
  exports.INSTRUCTION_BASELINE_FILE = ".vigiles/instruction-weight.json";
51
55
  /** The command that writes the baseline — named once, quoted by every message. */
@@ -153,6 +157,25 @@ function compareToBaseline(weight, recorded) {
153
157
  changes: changesBetween(recorded.files, current.files),
154
158
  };
155
159
  }
160
+ /** How many of the heaviest files a ceiling message names. */
161
+ const CEILING_NAMED_FILES = 5;
162
+ /** `undefined` when the weight is at or under `max`, or when no ceiling is set. */
163
+ function checkCeiling(weight, max) {
164
+ if (max === undefined)
165
+ return undefined;
166
+ const { total, unit, files } = entryFor(weight);
167
+ if (total <= max)
168
+ return undefined;
169
+ return {
170
+ total,
171
+ max,
172
+ unit,
173
+ largest: Object.entries(files)
174
+ .map(([path, size]) => ({ path, size }))
175
+ .sort((a, b) => b.size - a.size || a.path.localeCompare(b.path))
176
+ .slice(0, CEILING_NAMED_FILES),
177
+ };
178
+ }
156
179
  /** Replace the measured bundles' entries; `null` removes one. Others are kept. */
157
180
  function recordEntries(previous, measured) {
158
181
  const removed = new Set(measured.filter(([, e]) => e === null).map(([at]) => at));
@@ -199,12 +222,33 @@ function formatVerdict(verdict, at) {
199
222
  : "";
200
223
  return verdict.kind === "grew"
201
224
  ? `${where}always-loaded instructions grew ${head} over the recorded baseline.${moved} ` +
202
- `If the growth is intended, record it with \`${exports.UPDATE_BASELINE_COMMAND}\` — the diff of ${exports.INSTRUCTION_BASELINE_FILE} is the review.`
225
+ `If the growth is intended, record it with \`${exports.UPDATE_BASELINE_COMMAND}\` — the diff of ${exports.INSTRUCTION_BASELINE_FILE} is the review. If it is not:\n${instruction_remedies_js_1.MOVE_OUT_OF_ALWAYS_LOADED}`
203
226
  : `${where}always-loaded instructions shrank ${head}; the baseline still allows the old weight.${moved} ` +
204
227
  `Lock the cut in with \`${exports.UPDATE_BASELINE_COMMAND}\`, or the headroom can be regrown without a finding.`;
205
228
  }
206
229
  }
207
230
  }
231
+ /** The message for a total over the ceiling, prefixed by its bundle. */
232
+ function formatCeiling(verdict, at) {
233
+ const where = at === "." ? "" : `${at}: `;
234
+ const biggest = verdict.largest
235
+ .map((f) => `${f.path} ${n(f.size)}`)
236
+ .join(", ");
237
+ return (`${where}always-loaded instructions are ${n(verdict.total)} ${verdict.unit}, ` +
238
+ `max ${n(verdict.max)} — ${n(verdict.total - verdict.max)} over. ` +
239
+ `Largest: ${biggest}.\n` +
240
+ `${instruction_remedies_js_1.MOVE_OUT_OF_ALWAYS_LOADED}\n` +
241
+ `Moving text to another always-loaded file does not help, ` +
242
+ `and re-recording the baseline does not lift the ceiling.`);
243
+ }
244
+ /** Why `--update-baseline` wrote nothing: the ceiling it would have been recorded above. */
245
+ function formatRefusal(verdict, at) {
246
+ const where = at === "." ? "" : `${at}: `;
247
+ return (`${where}refusing to record ${exports.INSTRUCTION_BASELINE_FILE}: ` +
248
+ `${n(verdict.total)} ${verdict.unit} is over the configured max ${n(verdict.max)} ` +
249
+ `(${n(verdict.total - verdict.max)} over). A baseline above the ceiling would bypass it — ` +
250
+ `get under it first, then run \`${exports.UPDATE_BASELINE_COMMAND}\` again.\n${instruction_remedies_js_1.MOVE_OUT_OF_ALWAYS_LOADED}`);
251
+ }
208
252
  /** The compile-time one-liner: weight now, against the baseline when there is one. */
209
253
  function formatWeightLine(verdict) {
210
254
  switch (verdict.kind) {
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Where always-loaded text can go instead of being deleted.
3
+ *
4
+ * Every finding that says "too much is loaded on every request" — the weight
5
+ * ceiling, the ratchet's growth, the refusal to record a heavier baseline, a
6
+ * section or the whole Rules section over its `compile-budget` — ends with
7
+ * this list. Without it the one move the message offers is "cut content", and
8
+ * the reader deletes rules that still matter instead of moving each one to the
9
+ * carrier that loads it only when it applies. The order is the order to try: the cheapest carrier that
10
+ * still fires at the right moment first, deletion last.
11
+ */
12
+ export declare const MOVE_OUT_OF_ALWAYS_LOADED: string;
13
+ //# sourceMappingURL=instruction-remedies.d.ts.map
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MOVE_OUT_OF_ALWAYS_LOADED = void 0;
4
+ /**
5
+ * Where always-loaded text can go instead of being deleted.
6
+ *
7
+ * Every finding that says "too much is loaded on every request" — the weight
8
+ * ceiling, the ratchet's growth, the refusal to record a heavier baseline, a
9
+ * section or the whole Rules section over its `compile-budget` — ends with
10
+ * this list. Without it the one move the message offers is "cut content", and
11
+ * the reader deletes rules that still matter instead of moving each one to the
12
+ * carrier that loads it only when it applies. The order is the order to try: the cheapest carrier that
13
+ * still fires at the right moment first, deletion last.
14
+ */
15
+ exports.MOVE_OUT_OF_ALWAYS_LOADED = [
16
+ "Move what does not apply to every request:",
17
+ " - a rule for one folder → that folder's nested CLAUDE.md or AGENTS.md (loads only there)",
18
+ " - a step tied to one action → a hook",
19
+ " - a procedure for one kind of task → a skill",
20
+ " - a fact a machine can check → a lint rule or a test, then delete the prose",
21
+ " - measurements and history → docs, linked, not loaded",
22
+ " - text another file already says → delete it",
23
+ ].join("\n");
24
+ //# sourceMappingURL=instruction-remedies.js.map
@@ -41,7 +41,12 @@ import type { InstructionChain, InstructionRole, InstructionScope } from "./inst
41
41
  export interface InstructionBudget {
42
42
  /** Claude Code counts characters; Codex counts bytes. Never tokens. */
43
43
  readonly unit: "chars" | "bytes";
44
- /** The harness's own threshold, in `unit`. */
44
+ /**
45
+ * The budget, in `unit`. For Codex it is the harness's own cut-off. For Claude
46
+ * Code it is the harness's FLOOR: the CLI warns per file at 5% of the model's
47
+ * context window in characters (never below 40 000) and on the sum at 120 000
48
+ * or that per-file limit, so on a 1M-token model it warns far later than this.
49
+ */
45
50
  readonly limit: number;
46
51
  /**
47
52
  * `warns` — the instructions still reach the model (Claude Code).
@@ -55,6 +55,19 @@ export interface CoverageThresholds {
55
55
  /** Min % of npm scripts documented in spec commands. */
56
56
  scripts?: number;
57
57
  }
58
+ /**
59
+ * Options for the `instruction-weight` rule: `["error", { max: 40000 }]`.
60
+ */
61
+ export interface InstructionWeightConfig {
62
+ /**
63
+ * An opt-in ABSOLUTE ceiling on the always-loaded instruction weight, in the
64
+ * harness's own unit (characters for Claude Code, bytes for Codex). A total
65
+ * above it is a finding at the rule's severity regardless of the baseline, and
66
+ * `vigiles lint --update-baseline` refuses to record a baseline above it.
67
+ * Omitted → only the ratchet applies. A positive integer.
68
+ */
69
+ max?: number;
70
+ }
58
71
  /**
59
72
  * Options for the orphan-docs check. The PRESENCE of this block in
60
73
  * `.vigilesrc.json` OPTS THE REPO IN — the scan is off unless declared,
@@ -140,8 +153,12 @@ export interface RulesConfig {
140
153
  * repo's own past, not a fixed budget. No baseline file → a one-line note,
141
154
  * never a finding, so turning this on fails no repository on day one.
142
155
  * `vigiles lint --update-baseline` records it. Default: "error".
156
+ *
157
+ * `["error", { max: 40000 }]` adds an opt-in absolute ceiling on top of the
158
+ * ratchet: a total above `max` is a finding whatever the baseline says, and
159
+ * the baseline cannot be re-recorded above it.
143
160
  */
144
- "instruction-weight"?: RuleSeverity;
161
+ "instruction-weight"?: RuleWithOptions<InstructionWeightConfig>;
145
162
  /**
146
163
  * An instruction spec still uses the deprecated `keyFiles` field — a flat
147
164
  * path → description list compiled into the file loaded on every request.
@@ -24,23 +24,35 @@ export interface RatchetLine {
24
24
  }
25
25
  /**
26
26
  * Pure: the lines a run prints, given what was measured and what is on disk.
27
+ * `max` is the opt-in absolute ceiling; a bundle over it adds a finding of its
28
+ * own, whatever the baseline says (including when there is none, or it is
29
+ * unreadable).
27
30
  *
28
31
  * A bundle with no entry is a nudge while the repo has NO baseline file (it has
29
32
  * not opted in, and a fresh repo must not fail on day one) — but a FINDING once
30
33
  * the file exists: a bundle that appeared after the baseline was recorded is
31
34
  * weight nobody signed off on.
32
35
  */
33
- export declare function ratchetLines(onDisk: BaselineOnDisk, measured: readonly MeasuredBundle[]): readonly RatchetLine[];
34
- /** Run the ratchet for `lint`: print, annotate, count. */
35
- export declare function checkInstructionWeightRatchet(opts: {
36
+ export declare function ratchetLines(onDisk: BaselineOnDisk, measured: readonly MeasuredBundle[], max?: number): readonly RatchetLine[];
37
+ /**
38
+ * The bundles `--update-baseline` must not record: each one over the ceiling,
39
+ * as the refusal text. Empty when there is no ceiling or nothing exceeds it.
40
+ */
41
+ export declare function updateRefusals(measured: readonly MeasuredBundle[], max: number | undefined): readonly string[];
42
+ /** The options {@link checkInstructionWeightRatchet} takes. */
43
+ interface RatchetOptions {
36
44
  readonly root: string;
37
45
  readonly severity: RuleSeverity;
46
+ /** The opt-in absolute ceiling (`["error", { max }]`); `undefined` = ratchet only. */
47
+ readonly max?: number;
38
48
  readonly update: boolean;
39
49
  readonly silent: boolean;
40
50
  /** Called only when the rule is on or an update was asked for. */
41
51
  readonly measure: () => readonly MeasuredBundle[];
42
52
  readonly annotate: (level: "error" | "warning", message: string) => void;
43
- }): {
53
+ }
54
+ /** Run the ratchet for `lint`: print, annotate, count. */
55
+ export declare function checkInstructionWeightRatchet(opts: RatchetOptions): {
44
56
  issues: number;
45
57
  errors: number;
46
58
  };
@@ -50,4 +62,5 @@ export declare function checkInstructionWeightRatchet(opts: {
50
62
  * `lint` stays the single gate. `null` when there is nothing to weigh.
51
63
  */
52
64
  export declare function compileWeightLine(root: string, weight: InstructionWeight | null): string | null;
65
+ export {};
53
66
  //# sourceMappingURL=instruction-weight-ratchet.d.ts.map
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.readInstructionBaseline = readInstructionBaseline;
4
4
  exports.writeInstructionBaseline = writeInstructionBaseline;
5
5
  exports.ratchetLines = ratchetLines;
6
+ exports.updateRefusals = updateRefusals;
6
7
  exports.checkInstructionWeightRatchet = checkInstructionWeightRatchet;
7
8
  exports.compileWeightLine = compileWeightLine;
8
9
  /**
@@ -41,13 +42,29 @@ function writeInstructionBaseline(root, onDisk, measured) {
41
42
  }
42
43
  /**
43
44
  * Pure: the lines a run prints, given what was measured and what is on disk.
45
+ * `max` is the opt-in absolute ceiling; a bundle over it adds a finding of its
46
+ * own, whatever the baseline says (including when there is none, or it is
47
+ * unreadable).
44
48
  *
45
49
  * A bundle with no entry is a nudge while the repo has NO baseline file (it has
46
50
  * not opted in, and a fresh repo must not fail on day one) — but a FINDING once
47
51
  * the file exists: a bundle that appeared after the baseline was recorded is
48
52
  * weight nobody signed off on.
49
53
  */
50
- function ratchetLines(onDisk, measured) {
54
+ function ratchetLines(onDisk, measured, max) {
55
+ return [
56
+ ...baselineLines(onDisk, measured),
57
+ ...measured.flatMap(([at, weight]) => ceilingLine(at, weight, max)),
58
+ ];
59
+ }
60
+ /** One bundle's ceiling finding, or nothing when it is at or under `max`. */
61
+ function ceilingLine(at, weight, max) {
62
+ const over = weight === null ? undefined : (0, instruction_baseline_js_1.checkCeiling)(weight, max);
63
+ return over === undefined
64
+ ? []
65
+ : [{ at, status: "finding", text: (0, instruction_baseline_js_1.formatCeiling)(over, at) }];
66
+ }
67
+ function baselineLines(onDisk, measured) {
51
68
  if (onDisk.kind === "invalid") {
52
69
  return [
53
70
  {
@@ -77,41 +94,73 @@ function ratchetLines(onDisk, measured) {
77
94
  return [{ at, status, text: `${(0, instruction_baseline_js_1.formatVerdict)(verdict, at)}${unfollowed}` }];
78
95
  });
79
96
  }
97
+ /**
98
+ * The bundles `--update-baseline` must not record: each one over the ceiling,
99
+ * as the refusal text. Empty when there is no ceiling or nothing exceeds it.
100
+ */
101
+ function updateRefusals(measured, max) {
102
+ return measured.flatMap(([at, weight]) => {
103
+ const over = weight === null ? undefined : (0, instruction_baseline_js_1.checkCeiling)(weight, max);
104
+ return over === undefined ? [] : [(0, instruction_baseline_js_1.formatRefusal)(over, at)];
105
+ });
106
+ }
107
+ /**
108
+ * What `lint` compares against: the file as it is, or — under `--update-baseline`
109
+ * and when nothing is over the ceiling — the file just written. A baseline above
110
+ * the ceiling would bypass it (re-recording is how a ratchet gets loosened), so
111
+ * then nothing is written and the refusals are returned for the caller to fail on.
112
+ */
113
+ function recordOrRead(opts, measured) {
114
+ const before = readInstructionBaseline(opts.root);
115
+ const refusals = opts.update ? updateRefusals(measured, opts.max) : [];
116
+ const record = opts.update && refusals.length === 0;
117
+ return {
118
+ refusals,
119
+ onDisk: record
120
+ ? {
121
+ kind: "ok",
122
+ baseline: writeInstructionBaseline(opts.root, before, measured),
123
+ }
124
+ : before,
125
+ };
126
+ }
127
+ /** The human-readable report: what was recorded, then one line per bundle. */
128
+ function printReport(lines, severity, recorded) {
129
+ console.log("\nInstruction-weight ratchet:\n");
130
+ if (recorded)
131
+ console.log(` ✓ recorded ${instruction_baseline_js_1.INSTRUCTION_BASELINE_FILE}`);
132
+ const marks = {
133
+ finding: severity === "error" ? "✗" : "⚠",
134
+ ok: "✓",
135
+ note: "ℹ",
136
+ };
137
+ lines.forEach((l) => {
138
+ console.log(` ${marks[l.status]} ${l.text}`);
139
+ });
140
+ }
80
141
  /** Run the ratchet for `lint`: print, annotate, count. */
81
142
  function checkInstructionWeightRatchet(opts) {
82
143
  const { severity, update, silent } = opts;
83
144
  if (!severity && !update)
84
145
  return { issues: 0, errors: 0 };
85
146
  const measured = opts.measure();
86
- const before = readInstructionBaseline(opts.root);
87
- const onDisk = update
88
- ? {
89
- kind: "ok",
90
- baseline: writeInstructionBaseline(opts.root, before, measured),
91
- }
92
- : before;
93
- const lines = severity ? ratchetLines(onDisk, measured) : [];
147
+ const { onDisk, refusals } = recordOrRead(opts, measured);
148
+ refusals.forEach((message) => {
149
+ console.error(`✗ ${message}`);
150
+ opts.annotate("error", message);
151
+ });
152
+ const lines = severity ? ratchetLines(onDisk, measured, opts.max) : [];
94
153
  const findings = lines.filter((l) => l.status === "finding");
95
154
  if (!silent && (update || lines.length > 0)) {
96
- console.log("\nInstruction-weight ratchet:\n");
97
- if (update)
98
- console.log(` ✓ recorded ${instruction_baseline_js_1.INSTRUCTION_BASELINE_FILE}`);
99
- const marks = {
100
- finding: severity === "error" ? "✗" : "⚠",
101
- ok: "✓",
102
- note: "ℹ",
103
- };
104
- lines.forEach((l) => {
105
- console.log(` ${marks[l.status]} ${l.text}`);
106
- });
155
+ printReport(lines, severity, update && refusals.length === 0);
107
156
  }
108
157
  const level = severity === "error" ? "error" : "warning";
109
158
  findings.forEach((l) => {
110
159
  opts.annotate(level, l.text);
111
160
  });
112
161
  return {
113
- issues: findings.length,
114
- errors: severity === "error" ? findings.length : 0,
162
+ issues: findings.length + refusals.length,
163
+ errors: (severity === "error" ? findings.length : 0) + refusals.length,
115
164
  };
116
165
  }
117
166
  /**
@@ -1751,36 +1751,86 @@
1751
1751
  "instruction-weight": {
1752
1752
  "anyOf": [
1753
1753
  {
1754
- "type": "string",
1755
- "const": "warn"
1756
- },
1757
- {
1758
- "type": "string",
1759
- "const": "error"
1760
- },
1761
- {
1762
- "type": "boolean",
1763
- "const": false
1764
- },
1765
- {
1766
- "type": "string",
1767
- "const": "off"
1768
- },
1769
- {
1770
- "type": "number",
1771
- "const": 0
1772
- },
1773
- {
1774
- "type": "number",
1775
- "const": 1
1754
+ "anyOf": [
1755
+ {
1756
+ "type": "string",
1757
+ "const": "warn"
1758
+ },
1759
+ {
1760
+ "type": "string",
1761
+ "const": "error"
1762
+ },
1763
+ {
1764
+ "type": "boolean",
1765
+ "const": false
1766
+ },
1767
+ {
1768
+ "type": "string",
1769
+ "const": "off"
1770
+ },
1771
+ {
1772
+ "type": "number",
1773
+ "const": 0
1774
+ },
1775
+ {
1776
+ "type": "number",
1777
+ "const": 1
1778
+ },
1779
+ {
1780
+ "type": "number",
1781
+ "const": 2
1782
+ },
1783
+ {
1784
+ "type": "boolean",
1785
+ "const": true
1786
+ }
1787
+ ]
1776
1788
  },
1777
1789
  {
1778
- "type": "number",
1779
- "const": 2
1790
+ "type": "array",
1791
+ "prefixItems": [
1792
+ {
1793
+ "type": "string",
1794
+ "const": "warn"
1795
+ },
1796
+ {
1797
+ "type": "object",
1798
+ "properties": {
1799
+ "max": {
1800
+ "type": "integer",
1801
+ "exclusiveMinimum": 0,
1802
+ "maximum": 9007199254740991
1803
+ }
1804
+ },
1805
+ "additionalProperties": false
1806
+ }
1807
+ ],
1808
+ "items": false,
1809
+ "minItems": 2,
1810
+ "maxItems": 2
1780
1811
  },
1781
1812
  {
1782
- "type": "boolean",
1783
- "const": true
1813
+ "type": "array",
1814
+ "prefixItems": [
1815
+ {
1816
+ "type": "string",
1817
+ "const": "error"
1818
+ },
1819
+ {
1820
+ "type": "object",
1821
+ "properties": {
1822
+ "max": {
1823
+ "type": "integer",
1824
+ "exclusiveMinimum": 0,
1825
+ "maximum": 9007199254740991
1826
+ }
1827
+ },
1828
+ "additionalProperties": false
1829
+ }
1830
+ ],
1831
+ "items": false,
1832
+ "minItems": 2,
1833
+ "maxItems": 2
1784
1834
  }
1785
1835
  ]
1786
1836
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "38.1.0",
3
+ "version": "38.2.0",
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",