vigiles 38.0.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.
Files changed (39) hide show
  1. package/dist/adapters/claude-code/dialect.js +5 -1
  2. package/dist/audit-report.d.ts +8 -0
  3. package/dist/audit-report.js +12 -3
  4. package/dist/cli-main.d.ts +7 -0
  5. package/dist/cli-main.js +53 -10
  6. package/dist/core/CLAUDE.md.spec.js +0 -4
  7. package/dist/core/compile.js +15 -9
  8. package/dist/core/config-schema.d.ts +6 -1
  9. package/dist/core/config-schema.js +36 -2
  10. package/dist/core/instruction-baseline.d.ts +26 -0
  11. package/dist/core/instruction-baseline.js +45 -1
  12. package/dist/core/instruction-remedies.d.ts +13 -0
  13. package/dist/core/instruction-remedies.js +24 -0
  14. package/dist/core/instruction-weight.d.ts +6 -1
  15. package/dist/core/keyfiles-source.d.ts +9 -0
  16. package/dist/core/keyfiles-source.js +88 -0
  17. package/dist/core/keyfiles.d.ts +64 -0
  18. package/dist/core/keyfiles.js +93 -0
  19. package/dist/core/orphans.js +1 -1
  20. package/dist/core/rule-meta.js +9 -0
  21. package/dist/core/session.d.ts +7 -0
  22. package/dist/core/session.js +8 -0
  23. package/dist/core/spec.d.ts +14 -1
  24. package/dist/core/types.d.ts +26 -1
  25. package/dist/deprecated-key-files.d.ts +35 -0
  26. package/dist/deprecated-key-files.js +62 -0
  27. package/dist/instruction-weight-ratchet.d.ts +17 -4
  28. package/dist/instruction-weight-ratchet.js +71 -22
  29. package/dist/research-index.d.ts +6 -6
  30. package/dist/research-index.js +6 -6
  31. package/dist/scan.d.ts +21 -0
  32. package/dist/scan.js +29 -2
  33. package/dist/setup-plan.d.ts +1 -1
  34. package/dist/setup-plan.js +3 -0
  35. package/dist/vigilesrc.schema.json +86 -0
  36. package/hooks/pre-edit.sh +1 -1
  37. package/package.json +1 -1
  38. package/skills/adopt-spec/SKILL.md +7 -12
  39. package/skills/edit-spec/SKILL.md +32 -25
@@ -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
@@ -21,6 +21,7 @@ import type { PluginScore } from "./score-core.js";
21
21
  import type { RuleInventoryItem } from "./rule-inventory.js";
22
22
  import type { RuleRouting } from "./rule-routing.js";
23
23
  import type { EvidenceCounts } from "./coverage-evidence.js";
24
+ import type { DeprecatedKeyFilesFinding } from "./deprecated-key-files.js";
24
25
  /**
25
26
  * The current schema version. Bump only on a BREAKING change to the shape.
26
27
  * v2 (2026-07-15): the rule-map `mechanism` enum value for an unrouted rule
@@ -131,6 +132,13 @@ export interface AuditReport {
131
132
  * version unchanged.
132
133
  */
133
134
  readonly brokenReferences?: readonly string[];
135
+ /**
136
+ * Instruction specs still using the deprecated `keyFiles` (the
137
+ * `deprecated-key-files` rule's findings: path, entry count, compiled
138
+ * characters). Advisory — never scored. Present only when at least one spec
139
+ * uses the field. Additive/optional — schema version unchanged.
140
+ */
141
+ readonly deprecatedKeyFiles?: readonly DeprecatedKeyFilesFinding[];
134
142
  /**
135
143
  * The adoption preview — "what would vigiles catch in your repo?" Present only
136
144
  * when the model-gated tier ran (behind consent); a deterministic read omits it.
@@ -74,6 +74,17 @@ function inventoryOf(report) {
74
74
  function outputStylesField(n) {
75
75
  return n === undefined || n === 0 ? {} : { outputStyles: n };
76
76
  }
77
+ /** The scan's concrete finding lists, each present only when non-empty. */
78
+ function scanFindingLists(report) {
79
+ return {
80
+ ...(report.danglingRefs.length
81
+ ? { brokenReferences: report.danglingRefs }
82
+ : {}),
83
+ ...(report.deprecatedKeyFiles
84
+ ? { deprecatedKeyFiles: report.deprecatedKeyFiles }
85
+ : {}),
86
+ };
87
+ }
77
88
  /**
78
89
  * Assemble the versioned {@link AuditReport} from a scan report — pure, no clock.
79
90
  * The CLI attaches `meta.generatedAt` when it writes the JSON artifact; the
@@ -97,9 +108,7 @@ function buildAuditReport(report, opts) {
97
108
  verdict,
98
109
  recommendations,
99
110
  inventory: inventoryOf(report),
100
- ...(report.danglingRefs.length
101
- ? { brokenReferences: report.danglingRefs }
102
- : {}),
111
+ ...scanFindingLists(report),
103
112
  ...(adoptable ? { adoptable } : {}),
104
113
  ...(opts.observations ? { observations: opts.observations } : {}),
105
114
  ...(opts.rulesInventory && opts.rulesInventory.length
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * vigiles CLI — verify your agent harness is real, and prove it works.
3
3
  *
4
+ * This is the VERB barrel: what each verb does is defined here and nowhere
5
+ * else. `src/cli.ts` loads it lazily for everything that is not a hook
6
+ * decision (those go to `src/hook-runtime.ts`), so a top-level import added
7
+ * here is paid by every verb but never by a gated tool call.
8
+ *
4
9
  * The verbs and their one-liners live in ONE place, `COMMAND_HELP` + `HELP_GROUPS`
5
10
  * near the bottom of this file, and `--help` prints from that table. A second list
6
11
  * here would be a copy that rots — this docblock WAS that copy: it named four
@@ -81,6 +86,8 @@ interface LintReport {
81
86
  hookMatcherErrors: number;
82
87
  instructionWeightIssues: number;
83
88
  instructionWeightErrors: number;
89
+ deprecatedKeyFilesIssues: number;
90
+ deprecatedKeyFilesErrors: number;
84
91
  docRefErrors: number;
85
92
  symbolRefErrors: number;
86
93
  mcpRefErrors: number;
package/dist/cli-main.js CHANGED
@@ -2,6 +2,11 @@
2
2
  /**
3
3
  * vigiles CLI — verify your agent harness is real, and prove it works.
4
4
  *
5
+ * This is the VERB barrel: what each verb does is defined here and nowhere
6
+ * else. `src/cli.ts` loads it lazily for everything that is not a hook
7
+ * decision (those go to `src/hook-runtime.ts`), so a top-level import added
8
+ * here is paid by every verb but never by a gated tool call.
9
+ *
5
10
  * The verbs and their one-liners live in ONE place, `COMMAND_HELP` + `HELP_GROUPS`
6
11
  * near the bottom of this file, and `--help` prints from that table. A second list
7
12
  * here would be a copy that rots — this docblock WAS that copy: it named four
@@ -22,6 +27,7 @@ const repo_path_js_1 = require("./core/repo-path.js");
22
27
  const node_child_process_1 = require("node:child_process");
23
28
  const glob_1 = require("glob");
24
29
  const exclude_js_1 = require("./exclude.js");
30
+ const glob_ignore_js_1 = require("./core/glob-ignore.js");
25
31
  const generate_types_js_1 = require("./core/generate-types.js");
26
32
  const generate_harness_js_1 = require("./core/generate-harness.js");
27
33
  const capability_diff_js_1 = require("./core/capability-diff.js");
@@ -96,6 +102,7 @@ const adopt_js_1 = require("./core/adopt.js");
96
102
  const coverage_js_1 = require("./core/coverage.js");
97
103
  const orphans_js_1 = require("./core/orphans.js");
98
104
  const doc_refs_js_1 = require("./core/doc-refs.js");
105
+ const deprecated_key_files_js_1 = require("./deprecated-key-files.js");
99
106
  const hook_runtime_js_1 = require("./hook-runtime.js");
100
107
  // ---------------------------------------------------------------------------
101
108
  // Constants
@@ -126,6 +133,32 @@ function findSpecs(excludes, pattern) {
126
133
  cwd: process.cwd(),
127
134
  });
128
135
  }
136
+ /**
137
+ * The `deprecated-key-files` detector's I/O: every instruction spec under
138
+ * `root` (the glob `compile` uses), read from disk. Lint and audit both call
139
+ * this, so the two cannot disagree. `node_modules` is never read — someone
140
+ * else's spec is not this repository's to migrate.
141
+ */
142
+ function specsUsingKeyFiles(root, ignore) {
143
+ return (0, deprecated_key_files_js_1.findDeprecatedKeyFiles)({
144
+ specPaths: (0, glob_1.globSync)("**/*.md.spec.ts", {
145
+ cwd: root,
146
+ dot: true,
147
+ nodir: true,
148
+ posix: true,
149
+ ignore: (0, glob_ignore_js_1.withIgnored)(["**/node_modules/**"], ignore),
150
+ }),
151
+ read: (path) => (0, node_fs_1.readFileSync)((0, node_path_1.join)(root, path), "utf-8"),
152
+ });
153
+ }
154
+ /** `audit --single` names ONE harness: more than one directory is a contradiction (exit 2). */
155
+ function refuseSingleOverMany(single, dirs) {
156
+ if (!single || dirs.length <= 1)
157
+ return;
158
+ console.error(`--single audits ONE directory as one harness, but ${String(dirs.length)} were given. ` +
159
+ `Drop --single for a leaderboard, or pass a single directory.`);
160
+ process.exit(2);
161
+ }
129
162
  /**
130
163
  * Why the last `loadSpec()` returned null.
131
164
  *
@@ -986,6 +1019,7 @@ function lintExitCode(report) {
986
1019
  report.hookBlockErrors > 0 ||
987
1020
  report.hookMatcherErrors > 0 ||
988
1021
  report.instructionWeightErrors > 0 ||
1022
+ report.deprecatedKeyFilesErrors > 0 ||
989
1023
  report.symbolRefErrors > 0 ||
990
1024
  report.mcpRefErrors > 0 ||
991
1025
  // `doc-refs` is opt-in and this counter is only non-zero when the user set
@@ -1743,6 +1777,7 @@ async function runLint(restArgs, flags, excludes, config) {
1743
1777
  const instructionWeight = (0, instruction_weight_ratchet_js_1.checkInstructionWeightRatchet)({
1744
1778
  root: frame.root,
1745
1779
  severity: (0, types_js_1.ruleSeverity)(config?.rules?.["instruction-weight"]),
1780
+ max: (0, types_js_1.ruleOptions)(config?.rules?.["instruction-weight"])?.max,
1746
1781
  update: flags.includes("--update-baseline"),
1747
1782
  silent,
1748
1783
  measure: () => lintRoots.map((abs) => [
@@ -1753,6 +1788,17 @@ async function runLint(restArgs, flags, excludes, config) {
1753
1788
  ghAnnotate(level, message);
1754
1789
  },
1755
1790
  });
1791
+ // 7w. Deprecated-key-files — an instruction spec still using `keyFiles`. Once
1792
+ // over the repository, like the ratchet: a nested bundle's spec would
1793
+ // otherwise be reported again by every bundle that encloses it.
1794
+ const deprecatedKeyFiles = (0, deprecated_key_files_js_1.checkDeprecatedKeyFiles)({
1795
+ severity: (0, types_js_1.ruleSeverity)(config?.rules?.["deprecated-key-files"]),
1796
+ find: () => specsUsingKeyFiles(process.cwd(), excludes.globIgnore),
1797
+ silent,
1798
+ annotate: (level, message, path) => {
1799
+ ghAnnotate(level, message, frame.repo((0, node_path_1.resolve)(process.cwd(), path)));
1800
+ },
1801
+ });
1756
1802
  // 8. Validate vigiles builder calls inside markdown code blocks — the
1757
1803
  // `doc-refs` rule, DEFAULT OFF. Illustrative blocks opt out via
1758
1804
  // `<!-- vigiles:ignore -->` (single block) or `<!-- vigiles:ignore-file -->`
@@ -1884,6 +1930,8 @@ async function runLint(restArgs, flags, excludes, config) {
1884
1930
  hookMatcherErrors: hookMatcher.errors,
1885
1931
  instructionWeightIssues: instructionWeight.issues,
1886
1932
  instructionWeightErrors: instructionWeight.errors,
1933
+ deprecatedKeyFilesIssues: deprecatedKeyFiles.issues,
1934
+ deprecatedKeyFilesErrors: deprecatedKeyFiles.errors,
1887
1935
  // Only the "error" tier gates. At "warn" the findings are printed and
1888
1936
  // annotated, and the exit code is untouched — same contract as every other
1889
1937
  // opt-in rule here.
@@ -2516,6 +2564,9 @@ export default instructionFile({${targetLine}
2516
2564
  // Prose sections become ## headings in the compiled output.
2517
2565
  // Do not add # or ## headers inside sections.
2518
2566
  // positioning: "What this project does and why.",
2567
+ // A file a decision depends on: name it where the decision is, with
2568
+ // file() inside a prose section — verified to exist at compile time. No
2569
+ // file map: the agent finds files itself, and a map loads on every request.
2519
2570
 
2520
2571
  // This section is included in the compiled output to help agents
2521
2572
  // understand how to work with specs. Remove it once your team is familiar.
@@ -2528,11 +2579,6 @@ export default instructionFile({${targetLine}
2528
2579
  // "npm test": "Run all tests",
2529
2580
  },
2530
2581
 
2531
- keyFiles: {
2532
- // File paths are verified to exist at compile time.
2533
- // "src/index.ts": "Main entry point",
2534
- },
2535
-
2536
2582
  rules: {
2537
2583
  // enforce() — backed by a linter rule, verified to exist AND be enabled:
2538
2584
  // "no-console": enforce("eslint/no-console", "Use structured logger."),
@@ -7298,11 +7344,7 @@ async function main() {
7298
7344
  // contradiction. Refuse it (exit 2 = "could not do what you asked") rather
7299
7345
  // than auditing the first and dropping the rest — silently honouring half
7300
7346
  // an argument list is the same defect class as the ignored `--out` above.
7301
- if (single && dirs.length > 1) {
7302
- console.error(`--single audits ONE directory as one harness, but ${String(dirs.length)} were given. ` +
7303
- `Drop --single for a leaderboard, or pass a single directory.`);
7304
- process.exit(2);
7305
- }
7347
+ refuseSingleOverMany(single, dirs);
7306
7348
  const targets = !single && market && market.onDisk.length > 0
7307
7349
  ? [...market.onDisk]
7308
7350
  : dirs;
@@ -7392,6 +7434,7 @@ async function main() {
7392
7434
  // (#281): cwd, unless the audited dir is somebody else's repository.
7393
7435
  const frame = (0, frame_js_1.frameFor)(process.cwd(), root);
7394
7436
  const report = (0, scan_js_1.scanPlugin)(targets[0], adapter.layout, adapter.dialect, {
7437
+ deprecatedKeyFiles: specsUsingKeyFiles(root, excludes.globIgnore),
7395
7438
  sharedDirs: config.sharedDirs,
7396
7439
  sharedDirsRoot: frame.root,
7397
7440
  // `.vigilesrc.json#exclude` reaches surface DISCOVERY, not just the
@@ -15,10 +15,6 @@ exports.default = (0, spec_js_1.instructionFile)({
15
15
  sections: {
16
16
  scope: `Working in \`src/core/\`? This is the harness-AGNOSTIC domain (spec, compile, linters, the lint/audit detectors). The root \`CLAUDE.md\` holds the full positioning + rule set — read it first. Two invariants live closest to this code: the core must not import an adapter (\`core ⊄ adapter\`, eslint-enforced) and must not hard-code a Claude Code literal (read it from the injected layout/dialect). This file adds the rule for ADDING or CHANGING a detector.`,
17
17
  },
18
- keyFiles: {
19
- "src/core/rule-meta.ts": "The RuleMeta registry — every rule's decidability bucket + severity + detector, the single source the detector-meta rule enforces.",
20
- "src/core/types.ts": "RulesConfig — the rule-name keys the registry is keyed on.",
21
- },
22
18
  rules: {
23
19
  "detector-meta": (0, spec_js_1.guidance)("A deterministic DETECTOR here is one half of a RULE — and a rule is not done until it is DECLARED. Three things move together (sibling of one-detector-no-drift + rules-docs-in-sync): (1) the pure detector function (shared by `lint` AND `audit`, never reimplemented per surface; read the layout/dialect, never a CC literal); (2) its entry in `src/core/rule-meta.ts` — the `Record<RuleName, RuleMeta>` won't typecheck without it — declaring its DECIDABILITY BUCKET (structural-closed = a type could prevent it / external-decidable = needs the world, error-capable / heuristic-behavioral = warn-or-measure-only), surface, defaultSeverity, the detector name, and any upstreamPrevention; (3) its `docs/rules/<name>.md` (the coverage test binds the registry to the docs by an EXACT set match, so a missing meta or doc fails CI). The bucket is the CEILING, not a preference — a heuristic proxy may NEVER default to `error` (it cries wolf); a structural/external fact MAY, once proven FP-safe. Before writing a new detector, CLASSIFY the defect into a bucket — that decides whether it can ever gate. The full model + the prose behind the buckets is the root `lint-rule-calibration` rule and `research/enforcement-model.md`."),
24
20
  },
@@ -35,9 +35,11 @@ const integrity_js_1 = require("./integrity.js");
35
35
  const markdown_js_1 = require("./markdown.js");
36
36
  const symbols_js_1 = require("./symbols.js");
37
37
  const skill_normalize_js_1 = require("./skill-normalize.js");
38
+ const keyfiles_js_1 = require("./keyfiles.js");
38
39
  const linters_js_1 = require("./linters.js");
39
40
  const tool_contract_js_1 = require("./tool-contract.js");
40
41
  const effects_js_1 = require("./effects.js");
42
+ const instruction_remedies_js_1 = require("./instruction-remedies.js");
41
43
  const dialect_js_1 = require("./dialect.js");
42
44
  // vigiles's default compile target when a spec names none and no dialect is
43
45
  // injected — a product convention (vigiles emits CLAUDE.md by default), not a
@@ -473,9 +475,10 @@ function sectionBudget(sections, specFile, limits) {
473
475
  .map(([name, body]) => ({
474
476
  type: "section-too-large",
475
477
  path: specFile,
476
- message: `section ${JSON.stringify(name)} is ${String(body.length)} characters ` +
477
- `(budget ${String(limits.sectionChars)}). The line guard cannot see this — long lines are ` +
478
- `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,
479
482
  }));
480
483
  }
481
484
  /** The whole Rules section, as one sum. */
@@ -492,7 +495,8 @@ function rulesBudget(input, specFile, limits) {
492
495
  `(budget ${n(limits.rulesChars)}). Render a rule shorter instead of deleting it: ` +
493
496
  `enforce(rule, why, { render: "id" }) when a linter already holds it, or ` +
494
497
  `guidance(statement, { details: file(...) }) with the long text in that file — ` +
495
- `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,
496
500
  },
497
501
  ];
498
502
  }
@@ -564,12 +568,12 @@ async function compileSectionsSection(spec, basePath, maxSectionLines) {
564
568
  return { lines, errors };
565
569
  }
566
570
  function compileKeyFilesSection(spec, basePath) {
567
- if (!spec.keyFiles)
571
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- keyFiles is deprecated, not removed: it still compiles until the next major
572
+ const keyFiles = spec.keyFiles;
573
+ if (!keyFiles)
568
574
  return { lines: [], errors: [] };
569
- const lines = ["## Key Files", ""];
570
575
  const errors = [];
571
- for (const [filePath, desc] of Object.entries(spec.keyFiles)) {
572
- lines.push(`- \`${filePath}\` — ${desc}`);
576
+ for (const [filePath, desc] of Object.entries(keyFiles)) {
573
577
  const err = validateFileRef(filePath, basePath);
574
578
  if (err)
575
579
  errors.push(err);
@@ -580,7 +584,9 @@ function compileKeyFilesSection(spec, basePath) {
580
584
  });
581
585
  }
582
586
  }
583
- return { lines: [lines.join("\n")], errors };
587
+ // The one renderer the `deprecated-key-files` rule measures with, so the
588
+ // size a finding reports is the size of what this emits.
589
+ return { lines: [(0, keyfiles_js_1.renderKeyFiles)(keyFiles)], errors };
584
590
  }
585
591
  function compileCommandsSection(spec, basePath) {
586
592
  if (!spec.commands)
@@ -149,7 +149,12 @@ 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>]>>;
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">>>;
153
158
  }, z.core.$strict>>;
154
159
  files: z.ZodDefault<z.ZodArray<z.ZodString>>;
155
160
  maxRules: z.ZodOptional<z.ZodNumber>;
@@ -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,8 @@ 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"),
196
+ "deprecated-key-files": severitySchema.default("warn"),
185
197
  })
186
198
  .strict();
187
199
  /** The rule names, for the "did you mean" on an unknown one. */
@@ -490,7 +502,7 @@ function formatConfigIssues(issues) {
490
502
  if (issue.code === "unrecognized_keys")
491
503
  return unknownKeyLines(issue);
492
504
  if (issue.code === "invalid_union")
493
- return [badValueLine(issue)];
505
+ return unionLines(issue);
494
506
  return [
495
507
  `.vigilesrc.json: ${pathLabel(issue.path ?? [])} — ${issue.message ?? "invalid"}.`,
496
508
  ];
@@ -514,6 +526,28 @@ function unknownKeyLines(issue) {
514
526
  return `.vigilesrc.json: unknown key "${key}" in ${where}. Known: ${listCandidates(known)}.`;
515
527
  });
516
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
+ }
517
551
  /** ONE line for a failed union — never one per branch. */
518
552
  function badValueLine(issue) {
519
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).
@@ -0,0 +1,9 @@
1
+ import { type KeyFilesUsage } from "./keyfiles.js";
2
+ /**
3
+ * The `keyFiles` usage in one spec's source, or `null` when the spec does not
4
+ * use the field. The first `keyFiles:` property in any object literal counts —
5
+ * an instruction spec has one builder call, and nothing else in a spec is
6
+ * called that.
7
+ */
8
+ export declare function findKeyFilesInSpecSource(source: string): KeyFilesUsage | null;
9
+ //# sourceMappingURL=keyfiles-source.d.ts.map