vigiles 37.0.0 → 38.0.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.
@@ -24,6 +24,8 @@ interface LintReport {
24
24
  frontmatterRules: number;
25
25
  specRefIssues: number;
26
26
  specRefErrors: number;
27
+ /** `compile-budget` findings at "error" (reported by `lint` only then). */
28
+ compileBudgetErrors: number;
27
29
  duplicatePairs: number;
28
30
  /** Severity of `duplicate-instructions` — carried so the exit code can tier it. */
29
31
  duplicateSeverity: RuleSeverity;
package/dist/cli-main.js CHANGED
@@ -353,6 +353,7 @@ async function compileClaudeToFile(spec, specPath, config, dialect) {
353
353
  maxSectionLines: config.maxSectionLines,
354
354
  catalogOnly: config.catalogOnly,
355
355
  linters: config.linters,
356
+ ...compileBudgetOptions(config),
356
357
  });
357
358
  const primaryOutput = specPath.replace(/\.spec\.ts$/, "");
358
359
  // Budget findings print BEFORE the pass/fail line and never change the exit
@@ -992,7 +993,8 @@ function lintExitCode(report) {
992
993
  // error — it used to sit at exit 1 because it fired unasked and could not be
993
994
  // turned off.
994
995
  report.docRefErrors > 0 ||
995
- report.specRefErrors > 0)
996
+ report.specRefErrors > 0 ||
997
+ report.compileBudgetErrors > 0)
996
998
  return 2;
997
999
  // Tierable now: a `warn` orphan/duplicate finding is reported and does NOT
998
1000
  // change the exit code. Both used to feed the exit directly, which is what
@@ -1363,9 +1365,13 @@ function overBundles(fn, run, bundles) {
1363
1365
  */
1364
1366
  async function checkSpecRefs(excludes, config, silent, dialect) {
1365
1367
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["spec-refs"]) ?? "error";
1366
- if (!sev)
1367
- return { issues: 0, errors: 0 };
1368
- const found = [];
1368
+ // `compile-budget` is reported here only at "error": at its default it is a
1369
+ // compile-time warning, and `lint` printed nothing about size before the rule
1370
+ // had a name — the default output does not change.
1371
+ const budgetGate = (0, types_js_1.ruleSeverity)(config?.rules?.["compile-budget"]) === "error";
1372
+ if (!sev && !budgetGate)
1373
+ return { issues: 0, errors: 0, budgetErrors: 0 };
1374
+ const derived = [];
1369
1375
  let knownAgents;
1370
1376
  for (const specPath of findSpecs(excludes)) {
1371
1377
  const target = specPath.replace(/\.spec\.ts$/, "");
@@ -1379,9 +1385,11 @@ async function checkSpecRefs(excludes, config, silent, dialect) {
1379
1385
  // collected lazily so a repo with no railway spec never pays for the walk.
1380
1386
  if (spec._specType === "railway")
1381
1387
  knownAgents ??= await collectAgentNames(excludes);
1382
- const errors = await specCompileErrors(spec, specPath, dialect, config, knownAgents ?? []);
1383
- for (const e of errors)
1384
- found.push(`${target}: ${e.message} (from ${specPath})`);
1388
+ derived.push({
1389
+ target,
1390
+ specPath,
1391
+ findings: await specCompileErrors(spec, specPath, dialect, config, knownAgents ?? []),
1392
+ });
1385
1393
  }
1386
1394
  catch {
1387
1395
  // A spec that will not load is `compile`'s finding, not this one's —
@@ -1389,6 +1397,9 @@ async function checkSpecRefs(excludes, config, silent, dialect) {
1389
1397
  continue;
1390
1398
  }
1391
1399
  }
1400
+ const messages = (pick) => derived.flatMap((d) => pick(d.findings).map((e) => `${d.target}: ${e.message} (from ${d.specPath})`));
1401
+ const found = sev ? messages((f) => f.errors) : [];
1402
+ const overBudget = messages((f) => f.budget);
1392
1403
  if (found.length > 0 && !silent) {
1393
1404
  console.log("\nSpec reference check:\n");
1394
1405
  for (const msg of found) {
@@ -1396,7 +1407,18 @@ async function checkSpecRefs(excludes, config, silent, dialect) {
1396
1407
  ghAnnotate(sev === "error" ? "error" : "warning", msg);
1397
1408
  }
1398
1409
  }
1399
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
1410
+ if (overBudget.length > 0 && !silent) {
1411
+ console.log("\nCompile budget check (compile-budget):\n");
1412
+ overBudget.forEach((msg) => {
1413
+ console.log(` ✗ ${msg}`);
1414
+ ghAnnotate("error", `compile-budget: ${msg}`);
1415
+ });
1416
+ }
1417
+ return {
1418
+ issues: found.length,
1419
+ errors: sev === "error" ? found.length : 0,
1420
+ budgetErrors: overBudget.length,
1421
+ };
1400
1422
  }
1401
1423
  /**
1402
1424
  * The ONE spec-type dispatcher, in the only shape both directions can share:
@@ -1420,8 +1442,8 @@ async function checkSpecRefs(excludes, config, silent, dialect) {
1420
1442
  async function specCompileErrors(spec, specPath, dialect, config, knownAgents) {
1421
1443
  const basePath = process.cwd();
1422
1444
  switch (spec._specType) {
1423
- case "claude":
1424
- return (await (0, compile_js_1.compileClaude)(spec, {
1445
+ case "claude": {
1446
+ const r = await (0, compile_js_1.compileClaude)(spec, {
1425
1447
  basePath,
1426
1448
  specFile: specPath,
1427
1449
  dialect,
@@ -1430,19 +1452,52 @@ async function specCompileErrors(spec, specPath, dialect, config, knownAgents) {
1430
1452
  maxSectionLines: config?.maxSectionLines,
1431
1453
  catalogOnly: config?.catalogOnly,
1432
1454
  linters: config?.linters,
1433
- })).errors;
1455
+ ...compileBudgetOptions(config),
1456
+ });
1457
+ // A size finding is `compile-budget`'s, not a dead reference: split by
1458
+ // IDENTITY against the compiler's own list, never by the type's name.
1459
+ return {
1460
+ errors: r.errors.filter((e) => !r.budgetFindings.includes(e)),
1461
+ budget: r.errors.filter((e) => r.budgetFindings.includes(e)),
1462
+ };
1463
+ }
1434
1464
  case "skill":
1435
- return (await (0, compile_js_1.compileSkill)(spec, { basePath, specFile: specPath, dialect })).errors;
1465
+ return {
1466
+ errors: (await (0, compile_js_1.compileSkill)(spec, { basePath, specFile: specPath, dialect })).errors,
1467
+ budget: [],
1468
+ };
1436
1469
  case "agent":
1437
- return (await (0, compile_js_1.compileAgent)(spec, { basePath, specFile: specPath, dialect })).errors;
1470
+ return {
1471
+ errors: (await (0, compile_js_1.compileAgent)(spec, { basePath, specFile: specPath, dialect })).errors,
1472
+ budget: [],
1473
+ };
1438
1474
  case "railway":
1439
- return (0, compile_js_1.compileRailway)(spec, { specFile: specPath, knownAgents }).errors;
1475
+ return {
1476
+ errors: (0, compile_js_1.compileRailway)(spec, { specFile: specPath, knownAgents })
1477
+ .errors,
1478
+ budget: [],
1479
+ };
1440
1480
  default:
1441
1481
  // A `pipeline` spec compiles through its underlying railway, so it has no
1442
1482
  // artifact of its own to re-derive refs for.
1443
- return [];
1483
+ return { errors: [], budget: [] };
1444
1484
  }
1445
1485
  }
1486
+ /**
1487
+ * The `compile-budget` rule, as `compileClaude` options: its severity and any
1488
+ * thresholds the config set. The ONE translation, so `compile` (which writes)
1489
+ * and `lint` (which re-derives) can never measure against different numbers.
1490
+ */
1491
+ function compileBudgetOptions(config) {
1492
+ const rule = config?.rules?.["compile-budget"];
1493
+ // No config at all (a programmatic caller, a missing file) is the default.
1494
+ if (rule === undefined)
1495
+ return { budgetSeverity: "warn" };
1496
+ return {
1497
+ budgetSeverity: (0, types_js_1.ruleSeverity)(rule),
1498
+ ...(0, types_js_1.ruleOptions)(rule),
1499
+ };
1500
+ }
1446
1501
  /**
1447
1502
  * Write a compiled artifact. Accepts ONLY a {@link StampedMarkdown}, so a body
1448
1503
  * that failed to compile cannot reach the disk — there is no stamp to pass.
@@ -1775,6 +1830,7 @@ async function runLint(restArgs, flags, excludes, config) {
1775
1830
  frontmatterRules,
1776
1831
  specRefIssues: specRefs.issues,
1777
1832
  specRefErrors: specRefs.errors,
1833
+ compileBudgetErrors: specRefs.budgetErrors,
1778
1834
  duplicatePairs: dups.pairCount,
1779
1835
  duplicateSeverity: (0, types_js_1.ruleSeverity)(config?.rules?.["duplicate-rules"]) ?? "warn",
1780
1836
  coverageEnabled: coverage.enabled,
@@ -56,7 +56,7 @@ export declare function verifyHash(content: string): {
56
56
  */
57
57
  /** @internal */ export declare function estimateTokens(text: string): number;
58
58
  export interface CompileError {
59
- type: "stale-file" | "stale-command" | "stale-ref" | "invalid-rule" | "budget-exceeded" | "section-too-long" | "section-has-header" | "reserved-section-key" | "spec-name-mismatch" | "unknown-tool" | "invalid-railway" | "purity-violation" | "output-without-fork" | "effect-in-skill" | "inline-code-too-long" | "entry-too-long" | "section-too-large";
59
+ type: "stale-file" | "stale-command" | "stale-ref" | "invalid-rule" | "budget-exceeded" | "section-too-long" | "section-has-header" | "reserved-section-key" | "spec-name-mismatch" | "unknown-tool" | "invalid-railway" | "purity-violation" | "output-without-fork" | "effect-in-skill" | "inline-code-too-long" | "entry-too-long" | "section-too-large" | "rules-too-large";
60
60
  message: string;
61
61
  path?: string;
62
62
  }
@@ -70,12 +70,21 @@ export interface CompileClaudeResult {
70
70
  markdown: string;
71
71
  errors: CompileError[];
72
72
  /**
73
- * Budget findings — never errors. An over-long `keyFiles` description or a
74
- * bloated section does not break the harness, it makes every request more
75
- * expensive, so it must not stop a faithful adoption from compiling. Same
76
- * channel discipline as `checkInlineCode`.
73
+ * Budget findings at the rule's DEFAULT severity (`compile-budget: "warn"`).
74
+ * An over-long `keyFiles` description or a bloated section does not break
75
+ * the harness, it makes every request more expensive, so by default it must
76
+ * not stop a faithful adoption from compiling. Same channel discipline as
77
+ * `checkInlineCode`. Empty when the rule is at `"error"` (the findings are in
78
+ * `errors` then) or off.
77
79
  */
78
80
  warnings: CompileError[];
81
+ /**
82
+ * Every budget finding, whatever the severity — the SAME objects that were
83
+ * placed in `warnings` or `errors`. `lint` re-derives a spec's references
84
+ * through this compiler and must not report a size finding as a dead
85
+ * reference, so it tells the two apart by identity here, never by type name.
86
+ */
87
+ budgetFindings: CompileError[];
79
88
  linterResults: LinterCheckResult[];
80
89
  /** Estimated token count of compiled output (~4 chars/token). */
81
90
  tokens: number;
@@ -83,10 +92,21 @@ export interface CompileClaudeResult {
83
92
  targets: string[];
84
93
  }
85
94
  export interface CompileClaudeOptions {
86
- /** Per-entry `keyFiles`/`commands` character budget (0 disables). */
95
+ /**
96
+ * The `compile-budget` rule's severity. `"warn"` (the default) reports the
97
+ * per-entry and per-section budgets as warnings and evaluates the Rules total
98
+ * only when a threshold for it is passed; `"error"` (what
99
+ * `vigiles:recommended` sets) evaluates every budget, with defaults, as an
100
+ * error; `false` reports none (an explicit `maxTokens` still fails, as it
101
+ * always has — a separate check).
102
+ */
103
+ budgetSeverity?: "warn" | "error" | false;
104
+ /** Per-entry `commands` character budget (0 disables). */
87
105
  maxEntryChars?: number;
88
106
  /** Per-section character budget, beside the line budget (0 disables). */
89
107
  maxSectionChars?: number;
108
+ /** Character budget for the whole rendered Rules section (0 disables). */
109
+ maxRulesChars?: number;
90
110
  basePath?: string;
91
111
  specFile?: string;
92
112
  /** Injected harness dialect; its instructionTargets[0] is the default target. */
@@ -136,6 +156,16 @@ export declare const DEFAULT_MAX_ENTRY_CHARS = 200;
136
156
  * cost — characters do, because that is what the harness loads.
137
157
  */
138
158
  export declare const DEFAULT_MAX_SECTION_CHARS = 15000;
159
+ /**
160
+ * The Rules section, as one budget. Under `compile-budget: "error"` only.
161
+ *
162
+ * WHY THE SECTION AND NOT EACH RULE. A per-rule length cap was built and
163
+ * removed within an hour: it opened on 40 of 53 rules, because a rule's length
164
+ * is not where the cost is — the SUM is, and it is loaded on every request.
165
+ * 20,000 characters is half of Claude Code's 40,000-character instruction
166
+ * budget, which leaves the other half for everything else the file carries.
167
+ */
168
+ export declare const DEFAULT_MAX_RULES_CHARS = 20000;
139
169
  /**
140
170
  * Compile a ClaudeSpec into markdown.
141
171
  *
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.DEFAULT_MAX_SECTION_CHARS = exports.DEFAULT_MAX_ENTRY_CHARS = exports.DEFAULT_MAX_SECTION_LINES = void 0;
6
+ exports.DEFAULT_MAX_RULES_CHARS = exports.DEFAULT_MAX_SECTION_CHARS = exports.DEFAULT_MAX_ENTRY_CHARS = exports.DEFAULT_MAX_SECTION_LINES = void 0;
7
7
  exports.computeHash = computeHash;
8
8
  exports.addHash = addHash;
9
9
  exports.seal = seal;
@@ -299,6 +299,38 @@ function renderFragment(fragment) {
299
299
  // ---------------------------------------------------------------------------
300
300
  // Compile CLAUDE.md spec → markdown
301
301
  // ---------------------------------------------------------------------------
302
+ /** `[Details](path#anchor)` for a rule's long text. */
303
+ function detailsLink(details) {
304
+ const anchor = details.anchor === undefined ? "" : `#${details.anchor}`;
305
+ return `[Details](${details.file.path}${anchor})`;
306
+ }
307
+ /**
308
+ * The first sentence of `text`: up to the first `.`, `!` or `?` that closes a
309
+ * word (no space before it) and is followed by the end of the text or by
310
+ * whitespace and a capital, digit, quote, backtick or bracket. So a mark inside
311
+ * the sentence — "instead of ! assertions", "e.g. into helpers" — does not end
312
+ * it. A `why` with no sentence end is returned whole — truncating mid-sentence
313
+ * would render a claim nobody wrote.
314
+ */
315
+ function firstSentence(text) {
316
+ const m = /^[\s\S]*?\S[.!?](?=$|\s+[A-Z0-9"'`(*[])/.exec(text.trim());
317
+ return m ? m[0] : text.trim();
318
+ }
319
+ /** The `**Why:**` line(s) of an enforce rule under its render policy. */
320
+ function enforceWhy(rule) {
321
+ const link = rule.details ? ` ${detailsLink(rule.details)}` : "";
322
+ const render = rule.render ?? "full";
323
+ switch (render) {
324
+ case "full":
325
+ return [`**Why:** ${rule.why}${link}`];
326
+ case "line":
327
+ return [`**Why:** ${firstSentence(rule.why)}${link}`];
328
+ case "id":
329
+ return rule.details ? [detailsLink(rule.details)] : [];
330
+ default:
331
+ return (0, hash_js_1.assertNever)(render);
332
+ }
333
+ }
302
334
  function compileRule(id, rule) {
303
335
  const title = id
304
336
  .replace(/[-_]/g, " ")
@@ -309,10 +341,16 @@ function compileRule(id, rule) {
309
341
  `### ${title}`,
310
342
  "",
311
343
  `**Enforced by:** \`${rule.linterRule}\``,
312
- `**Why:** ${rule.why}`,
344
+ ...enforceWhy(rule),
313
345
  ].join("\n");
314
- case "guidance":
315
- return [`### ${title}`, "", `**Guidance only** — ${rule.text}`].join("\n");
346
+ case "guidance": {
347
+ const link = rule.details ? ` ${detailsLink(rule.details)}` : "";
348
+ return [
349
+ `### ${title}`,
350
+ "",
351
+ `**Guidance only** — ${rule.text}${link}`,
352
+ ].join("\n");
353
+ }
316
354
  case "guard": {
317
355
  const patterns = Array.isArray(rule.watch)
318
356
  ? rule.watch.join("`, `")
@@ -364,49 +402,99 @@ exports.DEFAULT_MAX_ENTRY_CHARS = 200;
364
402
  */
365
403
  exports.DEFAULT_MAX_SECTION_CHARS = 15000;
366
404
  /**
367
- * Budget findings for the two append-only maps and for section size. WARNINGS
368
- * by construction (`lint-rule-calibration`: severity tracks confidence, and a
369
- * gate that fails every real repo on day one is switched off on day two — this
370
- * repo's own corpus opens at 26 over-long entries).
405
+ * The Rules section, as one budget. Under `compile-budget: "error"` only.
406
+ *
407
+ * WHY THE SECTION AND NOT EACH RULE. A per-rule length cap was built and
408
+ * removed within an hour: it opened on 40 of 53 rules, because a rule's length
409
+ * is not where the cost is — the SUM is, and it is loaded on every request.
410
+ * 20,000 characters is half of Claude Code's 40,000-character instruction
411
+ * budget, which leaves the other half for everything else the file carries.
371
412
  */
372
- function checkContentBudgets(spec, specFile, maxEntryChars, maxSectionChars) {
373
- const warns = [];
374
- const entries = [
375
- ["keyFiles", spec.keyFiles],
376
- ["commands", spec.commands],
413
+ exports.DEFAULT_MAX_RULES_CHARS = 20000;
414
+ /**
415
+ * The thresholds this compile measures against.
416
+ *
417
+ * The Rules total has no default at `"warn"`: a repo that does not extend
418
+ * `vigiles:recommended` and leaves the rule at its default sees exactly the
419
+ * warnings `compile` printed before the rule had a name. At `"error"` (what the
420
+ * preset sets) every budget applies with its default.
421
+ *
422
+ * 🔴 WHAT THIS RULE DOES NOT MEASURE, ON PURPOSE — each has one owner already:
423
+ * - the whole always-loaded size, in the harness's unit (Claude Code chars,
424
+ * Codex bytes) — `instruction-weight` and its `max`, which sums the whole
425
+ * chain, so moving text into a rules file does not fool it;
426
+ * - `keyFiles` — deprecated (`deprecated-key-files`); budgeting a section the
427
+ * reader is told to delete would be a second, contradicting instruction.
428
+ */
429
+ function resolveBudgetLimits(options, severity) {
430
+ const strict = severity === "error";
431
+ return {
432
+ entryChars: options.maxEntryChars ?? exports.DEFAULT_MAX_ENTRY_CHARS,
433
+ sectionChars: options.maxSectionChars ?? exports.DEFAULT_MAX_SECTION_CHARS,
434
+ rulesChars: options.maxRulesChars ?? (strict ? exports.DEFAULT_MAX_RULES_CHARS : 0),
435
+ };
436
+ }
437
+ const n = (x) => x.toLocaleString("en-US");
438
+ /**
439
+ * Budget findings for `commands` entries, section size and the Rules total.
440
+ * Severity is applied by the caller: the same finding is a warning at the
441
+ * rule's default and an error under `vigiles:recommended`.
442
+ */
443
+ function checkContentBudgets(input, specFile, limits) {
444
+ return [
445
+ ...entryBudget("commands", input.spec.commands, specFile, limits),
446
+ ...sectionBudget(input.spec.sections, specFile, limits),
447
+ ...rulesBudget(input, specFile, limits),
448
+ ];
449
+ }
450
+ /** One finding per `commands` description over the entry budget. */
451
+ function entryBudget(field, map, specFile, limits) {
452
+ if (!map || limits.entryChars <= 0)
453
+ return [];
454
+ return Object.entries(map)
455
+ .filter(([, description]) => description.length > limits.entryChars)
456
+ .map(([key, description]) => ({
457
+ type: "entry-too-long",
458
+ path: specFile,
459
+ message: `${field}[${JSON.stringify(key)}] description is ${String(description.length)} ` +
460
+ `characters (budget ${String(limits.entryChars)}). An entry is a pointer — say what the ` +
461
+ `command does in one line and move the explanation into the script's own header or ` +
462
+ `--help, which is read when someone runs it rather than on every request.`,
463
+ }));
464
+ }
465
+ /** One finding per plain-prose section over the section budget. */
466
+ function sectionBudget(sections, specFile, limits) {
467
+ if (!sections || limits.sectionChars <= 0)
468
+ return [];
469
+ // Fragment-valued sections are assembled later; only plain prose is
470
+ // measurable here, and it is the shape that grows.
471
+ return Object.entries(sections)
472
+ .filter((pair) => typeof pair[1] === "string" && pair[1].length > limits.sectionChars)
473
+ .map(([name, body]) => ({
474
+ type: "section-too-large",
475
+ 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.`,
479
+ }));
480
+ }
481
+ /** The whole Rules section, as one sum. */
482
+ function rulesBudget(input, specFile, limits) {
483
+ const rulesChars = input.renderedRules.length;
484
+ if (limits.rulesChars <= 0 || rulesChars <= limits.rulesChars)
485
+ return [];
486
+ return [
487
+ {
488
+ type: "rules-too-large",
489
+ path: specFile,
490
+ message: `the Rules section is ${n(rulesChars)} characters for ` +
491
+ `${String(Object.keys(input.spec.rules).length)} rules ` +
492
+ `(budget ${n(limits.rulesChars)}). Render a rule shorter instead of deleting it: ` +
493
+ `enforce(rule, why, { render: "id" }) when a linter already holds it, or ` +
494
+ `guidance(statement, { details: file(...) }) with the long text in that file — ` +
495
+ `the rule stays in the spec, where lint and audit read it.`,
496
+ },
377
497
  ];
378
- for (const [field, map] of entries) {
379
- if (!map || maxEntryChars <= 0)
380
- continue;
381
- for (const [key, description] of Object.entries(map)) {
382
- if (description.length <= maxEntryChars)
383
- continue;
384
- warns.push({
385
- type: "entry-too-long",
386
- path: specFile,
387
- message: `${field}[${JSON.stringify(key)}] description is ${String(description.length)} ` +
388
- `characters (budget ${String(maxEntryChars)}). An entry is a pointer — say what the ` +
389
- `file is for in one line and move the explanation into its own header, which is read ` +
390
- `when someone opens it rather than on every request.`,
391
- });
392
- }
393
- }
394
- if (spec.sections && maxSectionChars > 0) {
395
- for (const [name, body] of Object.entries(spec.sections)) {
396
- // Fragment-valued sections are assembled later; only plain prose is
397
- // measurable here, and it is the shape that grows.
398
- if (typeof body !== "string" || body.length <= maxSectionChars)
399
- continue;
400
- warns.push({
401
- type: "section-too-large",
402
- path: specFile,
403
- message: `section ${JSON.stringify(name)} is ${String(body.length)} characters ` +
404
- `(budget ${String(maxSectionChars)}). The line guard cannot see this — long lines are ` +
405
- `the normal shape of compiled prose — and every character is loaded on every request.`,
406
- });
407
- }
408
- }
409
- return warns;
410
498
  }
411
499
  // A key-files entry is a POINTER, not an essay: the prose about why a file is
412
500
  // shaped the way it is belongs in that file's own header, where it is read when
@@ -524,6 +612,29 @@ function shouldVerifyRule(rule, options) {
524
612
  return false;
525
613
  return true;
526
614
  }
615
+ /**
616
+ * A rule's `details` must land somewhere: the file exists (the same check as
617
+ * every `file()`), and its anchor, if any, is a heading or an explicit id in
618
+ * that file. A short rule whose long text 404s is the rule deleted, with a
619
+ * link standing where it was.
620
+ */
621
+ function validateRuleDetails(details, basePath) {
622
+ const missing = validateFileRef(details.file.path, basePath);
623
+ if (missing)
624
+ return [missing];
625
+ if (details.anchor === undefined)
626
+ return [];
627
+ const text = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(basePath, details.file.path), "utf-8");
628
+ if ((0, markdown_js_1.markdownAnchors)(text).has(details.anchor))
629
+ return [];
630
+ return [
631
+ {
632
+ type: "stale-ref",
633
+ message: `Anchor "#${details.anchor}" not found in "${details.file.path}" (no heading slugs to it and no <a id> defines it)`,
634
+ path: `${details.file.path}#${details.anchor}`,
635
+ },
636
+ ];
637
+ }
527
638
  function compileRulesSection(spec, basePath, options) {
528
639
  const ruleEntries = Object.entries(spec.rules);
529
640
  if (ruleEntries.length === 0) {
@@ -564,7 +675,14 @@ function compileRulesSection(spec, basePath, options) {
564
675
  }
565
676
  }
566
677
  }
567
- return { lines: [ruleLines.join("\n")], errors, linterResults };
678
+ const detailsErrors = ruleEntries.flatMap(([, rule]) => rule._kind !== "guard" && rule.details
679
+ ? validateRuleDetails(rule.details, basePath)
680
+ : []);
681
+ return {
682
+ lines: [ruleLines.join("\n")],
683
+ errors: [...errors, ...detailsErrors],
684
+ linterResults,
685
+ };
568
686
  }
569
687
  // ---------------------------------------------------------------------------
570
688
  // compileClaude
@@ -622,7 +740,9 @@ async function compileClaude(spec, options = {}) {
622
740
  errors.push(...prose.errors, ...keyFiles.errors, ...commands.errors, ...rules.errors);
623
741
  const body = sections.join("\n\n") + "\n";
624
742
  const tokens = estimateTokens(body);
625
- // Per-spec maxTokens takes precedence, then compile options
743
+ // Per-spec maxTokens takes precedence, then compile options. An explicit
744
+ // ceiling is an error at any `compile-budget` severity, as it always was:
745
+ // the author typed a number and asked for it to hold.
626
746
  const maxTokens = spec.maxTokens ?? options.maxTokens;
627
747
  if (maxTokens && tokens > maxTokens) {
628
748
  errors.push({
@@ -632,10 +752,22 @@ async function compileClaude(spec, options = {}) {
632
752
  }
633
753
  const markdown = addHash(body, specFile);
634
754
  const allTargets = Array.isArray(targets) ? targets : [targets];
755
+ const severity = options.budgetSeverity ?? "warn";
756
+ const budgetFindings = severity === false
757
+ ? []
758
+ : [
759
+ ...checkContentBudgets({
760
+ spec,
761
+ renderedRules: rules.lines.join("\n\n"),
762
+ }, specFile, resolveBudgetLimits(options, severity)),
763
+ ];
635
764
  return {
636
765
  markdown,
637
- errors,
638
- warnings: checkContentBudgets(spec, specFile, options.maxEntryChars ?? exports.DEFAULT_MAX_ENTRY_CHARS, options.maxSectionChars ?? exports.DEFAULT_MAX_SECTION_CHARS),
766
+ // The SAME objects as `budgetFindings`, so `lint` can tell them apart by
767
+ // identity.
768
+ errors: severity === "error" ? [...errors, ...budgetFindings] : errors,
769
+ warnings: severity === "warn" ? budgetFindings : [],
770
+ budgetFindings,
639
771
  linterResults: rules.linterResults,
640
772
  tokens,
641
773
  targets: allTargets,
@@ -130,6 +130,15 @@ export declare const vigilesConfigSchema: z.ZodObject<{
130
130
  "disallowed-tools-contract": 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">>>;
131
131
  "description-overlap": 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">>>;
132
132
  "skill-description-budget": 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">>>;
133
+ "compile-budget": 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<{
134
+ maxEntryChars: z.ZodOptional<z.ZodNumber>;
135
+ maxSectionChars: z.ZodOptional<z.ZodNumber>;
136
+ maxRulesChars: z.ZodOptional<z.ZodNumber>;
137
+ }, z.core.$strict>], null>, z.ZodTuple<[z.ZodLiteral<"error">, z.ZodObject<{
138
+ maxEntryChars: z.ZodOptional<z.ZodNumber>;
139
+ maxSectionChars: z.ZodOptional<z.ZodNumber>;
140
+ maxRulesChars: z.ZodOptional<z.ZodNumber>;
141
+ }, z.core.$strict>], null>]>>;
133
142
  "frontmatter-valid": 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">>>;
134
143
  "mcp-hook-target-resolves": 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">>>;
135
144
  "lethal-trifecta": 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">>>;
@@ -122,6 +122,20 @@ const testCoverageConfig = zod_1.z
122
122
  testExtension: zod_1.z.string().optional(),
123
123
  })
124
124
  .strict();
125
+ /**
126
+ * Thresholds for `compile-budget`, in characters. Each one left out keeps its
127
+ * built-in default; `0` turns that one budget off.
128
+ */
129
+ const compileBudgetThresholds = zod_1.z
130
+ .object({
131
+ /** One `commands` description. */
132
+ maxEntryChars: zod_1.z.number().optional(),
133
+ /** One named prose section. */
134
+ maxSectionChars: zod_1.z.number().optional(),
135
+ /** The whole rendered Rules section. */
136
+ maxRulesChars: zod_1.z.number().optional(),
137
+ })
138
+ .strict();
125
139
  /**
126
140
  * Every validation rule, with its shipped default severity.
127
141
  *
@@ -156,6 +170,7 @@ const rulesSchema = zod_1.z
156
170
  "disallowed-tools-contract": severitySchema.default("warn"),
157
171
  "description-overlap": severitySchema.default("warn"),
158
172
  "skill-description-budget": severitySchema.default("warn"),
173
+ "compile-budget": withOptions(compileBudgetThresholds).default("warn"),
159
174
  "frontmatter-valid": severitySchema.default("warn"),
160
175
  "mcp-hook-target-resolves": severitySchema.default("warn"),
161
176
  "lethal-trifecta": severitySchema.default("warn"),
@@ -118,4 +118,21 @@ export interface MarkdownRef {
118
118
  * which is not recovered — the regexes this replaces did not recover it either.
119
119
  */
120
120
  export declare function markdownRefs(src: string): MarkdownRef[];
121
+ /**
122
+ * The fragment ids a markdown file defines — what `file.md#<id>` can land on.
123
+ *
124
+ * Two sources, because both are how real files are linked into:
125
+ *
126
+ * - every HEADING, slugged the way GitHub slugs it (lowercase; drop everything
127
+ * that is not a letter, number, space, `-` or `_`; spaces to `-`; a repeated
128
+ * slug gets `-1`, `-2`, …), with the heading's text taken from the parser's
129
+ * inline token, so emphasis and code spans do not leak their markers in;
130
+ * - every explicit `id="…"` / `name="…"` on raw HTML (`<a id="why"></a>`), which
131
+ * is how a file in a non-Latin language gives its sections stable ASCII ids.
132
+ *
133
+ * Other renderers slug differently (GitLab, mkdocs). A link that resolves on
134
+ * GitHub and nowhere else is still a link this accepts; one that resolves
135
+ * NOWHERE — a renamed heading — is what it exists to catch.
136
+ */
137
+ export declare function markdownAnchors(src: string): ReadonlySet<string>;
121
138
  //# sourceMappingURL=markdown.d.ts.map
@@ -4,6 +4,7 @@ exports.fencedLineFlags = fencedLineFlags;
4
4
  exports.proseLines = proseLines;
5
5
  exports.fencedCodeBlocks = fencedCodeBlocks;
6
6
  exports.markdownRefs = markdownRefs;
7
+ exports.markdownAnchors = markdownAnchors;
7
8
  const MarkdownIt = () => require("markdown-it");
8
9
  // One reusable parser; parse() is stateless across calls.
9
10
  let mdCached = null;
@@ -233,4 +234,60 @@ function refsInInline(children, line, out) {
233
234
  out.push({ kind: "code", value: child.content, line });
234
235
  }
235
236
  }
237
+ /**
238
+ * The fragment ids a markdown file defines — what `file.md#<id>` can land on.
239
+ *
240
+ * Two sources, because both are how real files are linked into:
241
+ *
242
+ * - every HEADING, slugged the way GitHub slugs it (lowercase; drop everything
243
+ * that is not a letter, number, space, `-` or `_`; spaces to `-`; a repeated
244
+ * slug gets `-1`, `-2`, …), with the heading's text taken from the parser's
245
+ * inline token, so emphasis and code spans do not leak their markers in;
246
+ * - every explicit `id="…"` / `name="…"` on raw HTML (`<a id="why"></a>`), which
247
+ * is how a file in a non-Latin language gives its sections stable ASCII ids.
248
+ *
249
+ * Other renderers slug differently (GitLab, mkdocs). A link that resolves on
250
+ * GitHub and nowhere else is still a link this accepts; one that resolves
251
+ * NOWHERE — a renamed heading — is what it exists to catch.
252
+ */
253
+ function markdownAnchors(src) {
254
+ // The html-enabled parser: with the shared one, `<a id="x">` is paragraph
255
+ // text and its id would be invisible.
256
+ const tokens = mdProse().parse(src, {});
257
+ const headings = tokens.flatMap((t, i) => t.type === "heading_open" ? [headingSlug(tokens[i + 1])] : []);
258
+ const explicit = tokens
259
+ .flatMap(htmlOf)
260
+ .flatMap((h) => [...h.matchAll(HTML_ID)].map((m) => m[1]));
261
+ return new Set([...numberRepeats(headings), ...explicit]);
262
+ }
263
+ /** An explicit `id="…"` / `name="…"` on raw HTML. */
264
+ const HTML_ID = /\b(?:id|name)\s*=\s*["']([^"']+)["']/g;
265
+ /** A heading's GitHub slug before repeats are numbered, from its inline token. */
266
+ function headingSlug(inline) {
267
+ return (inline?.children ?? [])
268
+ .filter((c) => c.type === "text" || c.type === "code_inline")
269
+ .map((c) => c.content)
270
+ .join("")
271
+ .toLowerCase()
272
+ .replace(/[^\p{L}\p{N}\s_-]/gu, "")
273
+ .trim()
274
+ .replace(/\s/g, "-");
275
+ }
276
+ /** GitHub's numbering of a repeated slug: `x`, `x-1`, `x-2`, … */
277
+ function numberRepeats(bases) {
278
+ return bases.map((base, i) => {
279
+ const k = bases.slice(0, i).filter((b) => b === base).length;
280
+ return k === 0 ? base : `${base}-${String(k)}`;
281
+ });
282
+ }
283
+ /** The raw HTML a token carries: a block, an inline, or an inline run's HTML. */
284
+ function htmlOf(t) {
285
+ if (t.type === "html_block" || t.type === "html_inline")
286
+ return [t.content];
287
+ if (t.type !== "inline")
288
+ return [];
289
+ return (t.children ?? [])
290
+ .filter((c) => c.type === "html_inline")
291
+ .map((c) => c.content);
292
+ }
236
293
  //# sourceMappingURL=markdown.js.map
@@ -30,7 +30,7 @@ import type { RulesConfig } from "./types.js";
30
30
  * `untested-*`): a clean repo fails those until the work is done, so they stay
31
31
  * an explicit opt-in (`init --strict`).
32
32
  */
33
- export declare const RECOMMENDED_RULES: readonly ["subagent-tool-contract", "subagent-frontmatter", "hook-events", "hook-script-exists", "mcp-config", "mcp-tool-resolves", "mcp-hook-target-resolves", "disallowed-tools-contract", "description-overlap"];
33
+ export declare const RECOMMENDED_RULES: readonly ["subagent-tool-contract", "subagent-frontmatter", "hook-events", "hook-script-exists", "mcp-config", "mcp-tool-resolves", "mcp-hook-target-resolves", "disallowed-tools-contract", "description-overlap", "compile-budget"];
34
34
  /** The preset names `extends` accepts. One today; the tuple is the enum. */
35
35
  export declare const PRESET_NAMES: readonly ["vigiles:recommended"];
36
36
  /** A preset name `extends` accepts. */
@@ -24,6 +24,7 @@ exports.RECOMMENDED_RULES = [
24
24
  "mcp-hook-target-resolves",
25
25
  "disallowed-tools-contract",
26
26
  "description-overlap",
27
+ "compile-budget",
27
28
  ];
28
29
  /** The preset names `extends` accepts. One today; the tuple is the enum. */
29
30
  exports.PRESET_NAMES = ["vigiles:recommended"];
@@ -226,6 +226,18 @@ exports.RULE_META = {
226
226
  summary: "The always-loaded instruction weight did not move off its committed baseline.",
227
227
  detector: "compareToBaseline",
228
228
  },
229
+ "compile-budget": {
230
+ id: "compile-budget",
231
+ // Exact once a threshold is chosen: a length or a count read off the spec
232
+ // and its rendered output. The threshold is the judgment: the default stays
233
+ // `warn`, and `vigiles:recommended` sets `error`.
234
+ bucket: "structural-closed",
235
+ surface: ["instruction"],
236
+ defaultSeverity: "warn",
237
+ summary: "A compiled instruction file stays within its size budgets (command entries, sections, the Rules total).",
238
+ detector: "checkContentBudgets",
239
+ upstreamPrevention: "compile measures the spec before writing; a render policy (enforce render, guidance details) meets a budget without deleting a rule",
240
+ },
229
241
  "skill-description-budget": {
230
242
  id: "skill-description-budget",
231
243
  bucket: "heuristic-behavioral",
@@ -94,6 +94,30 @@ export interface OpenToolVocabulary extends ToolVocabulary {
94
94
  * untyped surface accepts any tools.
95
95
  */
96
96
  export type AllowedAt<P extends AuthoredPurity | undefined, V extends ToolVocabulary> = P extends "pure" ? V["readOnly"] : P extends "bounded" ? V["bounded"] : string;
97
+ /**
98
+ * Where a rule's long text lives when the compiled file carries a short form —
99
+ * a `file()` reference (verified to exist, like every other one) and an
100
+ * optional anchor inside it (verified to be a heading or an explicit
101
+ * `<a id="…">` in that file). Rendered as a `[Details](path#anchor)` link.
102
+ */
103
+ export interface RuleDetails {
104
+ readonly file: FileRef;
105
+ readonly anchor?: string;
106
+ }
107
+ /**
108
+ * How much of an `enforce()` rule the compiled file carries (#355).
109
+ *
110
+ * - `"full"` (default) — the rule id and the whole `why`, as always.
111
+ * - `"line"` — the rule id and the first sentence of `why`.
112
+ * - `"id"` — the rule id only: the linter already holds the rule, so the prose
113
+ * is a second copy that costs tokens on every request and can drift.
114
+ *
115
+ * Whatever is rendered, the rule stays whole in the SPEC — lint, coverage, the
116
+ * audit rule map and `enforce-rules-format` read the spec, not the markdown —
117
+ * and the integrity hash is taken over the RENDERED file, so it stays a
118
+ * function of what the model actually sees.
119
+ */
120
+ export type RuleRender = "full" | "line" | "id";
97
121
  /** A rule delegated to an external tool (linter, ast-grep, dependency-cruiser, etc.) or to a vigiles-internal check. */
98
122
  export interface EnforceRule {
99
123
  readonly _kind: "enforce";
@@ -101,12 +125,29 @@ export interface EnforceRule {
101
125
  readonly why: string;
102
126
  /** Skip verification for this rule. Default: true (verify). */
103
127
  readonly verify: boolean;
128
+ /** How much of the rule the compiled file carries. Default `"full"`. */
129
+ readonly render?: RuleRender;
130
+ /** Where the long text lives; rendered as a verified link. */
131
+ readonly details?: RuleDetails;
104
132
  }
105
133
  /** A guidance-only rule (prose, no enforcement). */
106
134
  export interface GuidanceRule {
107
135
  readonly _kind: "guidance";
108
136
  readonly text: string;
137
+ /** Where the long text lives; rendered as a verified link after `text`. */
138
+ readonly details?: RuleDetails;
109
139
  }
140
+ /**
141
+ * The `details` half of a rule's options. An `anchor` is only meaningful
142
+ * inside a `details` file, so the type refuses one without the other.
143
+ */
144
+ export type RuleDetailsOptions = {
145
+ readonly details: FileRef;
146
+ readonly anchor?: string;
147
+ } | {
148
+ readonly details?: undefined;
149
+ readonly anchor?: never;
150
+ };
110
151
  /** A reactive rule: runs a command when watched files change. */
111
152
  export interface GuardRule {
112
153
  readonly _kind: "guard";
@@ -130,13 +171,15 @@ export type Rule = EnforceRule | GuidanceRule | GuardRule;
130
171
  */
131
172
  export declare function enforce(ref: NoInfer<StrictLinterRule> | VigilesRef, why: string, options?: {
132
173
  verify?: boolean;
133
- }): EnforceRule;
174
+ render?: RuleRender;
175
+ } & RuleDetailsOptions): EnforceRule;
134
176
  /**
135
177
  * Declare a guidance-only rule.
136
178
  *
137
179
  * guidance("Google unfamiliar APIs before implementing.")
180
+ * guidance("Short statement.", { details: file("docs/rules/x.md"), anchor: "why" })
138
181
  */
139
- export declare function guidance(text: string): GuidanceRule;
182
+ export declare function guidance(text: string, options?: RuleDetailsOptions): GuidanceRule;
140
183
  /**
141
184
  * Declare a reactive guard: runs a command when watched files change.
142
185
  *
package/dist/core/spec.js CHANGED
@@ -55,6 +55,13 @@ exports.BUILTIN_LINTERS = [
55
55
  "golangci-lint",
56
56
  "cedar",
57
57
  ];
58
+ const detailsOf = (o) => {
59
+ if (!o?.details)
60
+ return undefined;
61
+ return o.anchor === undefined
62
+ ? { file: o.details }
63
+ : { file: o.details, anchor: o.anchor };
64
+ };
58
65
  // ---------------------------------------------------------------------------
59
66
  // Builder functions
60
67
  // ---------------------------------------------------------------------------
@@ -72,21 +79,28 @@ exports.BUILTIN_LINTERS = [
72
79
  * enforce("vigiles/orphan-docs", "No docs without spec references.")
73
80
  */
74
81
  function enforce(ref, why, options) {
82
+ const details = detailsOf(options);
75
83
  return {
76
84
  _kind: "enforce",
77
85
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
78
86
  linterRule: ref,
79
87
  why,
80
88
  verify: options?.verify ?? true,
89
+ ...(options?.render === undefined ? {} : { render: options.render }),
90
+ ...(details ? { details } : {}),
81
91
  };
82
92
  }
83
93
  /**
84
94
  * Declare a guidance-only rule.
85
95
  *
86
96
  * guidance("Google unfamiliar APIs before implementing.")
97
+ * guidance("Short statement.", { details: file("docs/rules/x.md"), anchor: "why" })
87
98
  */
88
- function guidance(text) {
89
- return { _kind: "guidance", text };
99
+ function guidance(text, options) {
100
+ const details = detailsOf(options);
101
+ return details
102
+ ? { _kind: "guidance", text, details }
103
+ : { _kind: "guidance", text };
90
104
  }
91
105
  /**
92
106
  * Declare a reactive guard: runs a command when watched files change.
@@ -99,6 +99,18 @@ export interface TestCoverageConfig {
99
99
  */
100
100
  testExtension?: string;
101
101
  }
102
+ /**
103
+ * Thresholds for the `compile-budget` rule, in characters. A key left out
104
+ * keeps its default; `0` turns that budget off.
105
+ */
106
+ export interface CompileBudgetConfig {
107
+ /** One `commands` description. Default 200. */
108
+ maxEntryChars?: number;
109
+ /** One named prose section. Default 15,000. */
110
+ maxSectionChars?: number;
111
+ /** The whole rendered Rules section. Default 20,000 (at "error"). */
112
+ maxRulesChars?: number;
113
+ }
102
114
  export interface RulesConfig {
103
115
  /**
104
116
  * Opt-in: a doc in a configured dir (default `docs/`) that no other `.md`
@@ -271,6 +283,20 @@ export interface RulesConfig {
271
283
  * `scan` (descriptionBudgetIssues).
272
284
  */
273
285
  "skill-description-budget"?: RuleSeverity;
286
+ /**
287
+ * Size budgets on a COMPILED instruction file (`compile` from a `.spec.ts`):
288
+ * one `commands` entry, one prose section, and the whole Rules section. The
289
+ * whole always-loaded size is `instruction-weight`'s; `keyFiles` is
290
+ * deprecated, so neither is measured here.
291
+ *
292
+ * Default "warn", which is exactly the behaviour before this rule had a name:
293
+ * the per-entry and per-section budgets print as warnings and never stop a
294
+ * compile; the Rules total is not evaluated unless a threshold is configured.
295
+ * "error" (set by `vigiles:recommended`) makes every budget fail `compile` and
296
+ * `lint`. Options are thresholds — see {@link CompileBudgetConfig}. See
297
+ * docs/rules/compile-budget.md.
298
+ */
299
+ "compile-budget"?: RuleWithOptions<CompileBudgetConfig>;
274
300
  /**
275
301
  * Flag a skill/agent whose `---` frontmatter block EXISTS but isn't valid YAML
276
302
  * — fields may not parse as intended. CAVEAT: a real YAML parser (js-yaml) is
@@ -1294,6 +1294,100 @@
1294
1294
  }
1295
1295
  ]
1296
1296
  },
1297
+ "compile-budget": {
1298
+ "anyOf": [
1299
+ {
1300
+ "anyOf": [
1301
+ {
1302
+ "type": "string",
1303
+ "const": "warn"
1304
+ },
1305
+ {
1306
+ "type": "string",
1307
+ "const": "error"
1308
+ },
1309
+ {
1310
+ "type": "boolean",
1311
+ "const": false
1312
+ },
1313
+ {
1314
+ "type": "string",
1315
+ "const": "off"
1316
+ },
1317
+ {
1318
+ "type": "number",
1319
+ "const": 0
1320
+ },
1321
+ {
1322
+ "type": "number",
1323
+ "const": 1
1324
+ },
1325
+ {
1326
+ "type": "number",
1327
+ "const": 2
1328
+ },
1329
+ {
1330
+ "type": "boolean",
1331
+ "const": true
1332
+ }
1333
+ ]
1334
+ },
1335
+ {
1336
+ "type": "array",
1337
+ "prefixItems": [
1338
+ {
1339
+ "type": "string",
1340
+ "const": "warn"
1341
+ },
1342
+ {
1343
+ "type": "object",
1344
+ "properties": {
1345
+ "maxEntryChars": {
1346
+ "type": "number"
1347
+ },
1348
+ "maxSectionChars": {
1349
+ "type": "number"
1350
+ },
1351
+ "maxRulesChars": {
1352
+ "type": "number"
1353
+ }
1354
+ },
1355
+ "additionalProperties": false
1356
+ }
1357
+ ],
1358
+ "items": false,
1359
+ "minItems": 2,
1360
+ "maxItems": 2
1361
+ },
1362
+ {
1363
+ "type": "array",
1364
+ "prefixItems": [
1365
+ {
1366
+ "type": "string",
1367
+ "const": "error"
1368
+ },
1369
+ {
1370
+ "type": "object",
1371
+ "properties": {
1372
+ "maxEntryChars": {
1373
+ "type": "number"
1374
+ },
1375
+ "maxSectionChars": {
1376
+ "type": "number"
1377
+ },
1378
+ "maxRulesChars": {
1379
+ "type": "number"
1380
+ }
1381
+ },
1382
+ "additionalProperties": false
1383
+ }
1384
+ ],
1385
+ "items": false,
1386
+ "minItems": 2,
1387
+ "maxItems": 2
1388
+ }
1389
+ ]
1390
+ },
1297
1391
  "frontmatter-valid": {
1298
1392
  "anyOf": [
1299
1393
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "37.0.0",
3
+ "version": "38.0.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",