vigiles 36.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.
Files changed (44) hide show
  1. package/dist/cli-commands.d.ts +1 -1
  2. package/dist/cli-commands.js +1 -0
  3. package/dist/cli-main.d.ts +2 -0
  4. package/dist/cli-main.js +84 -15
  5. package/dist/cli.js +51 -0
  6. package/dist/core/compile.d.ts +36 -6
  7. package/dist/core/compile.js +181 -49
  8. package/dist/core/config-schema.d.ts +9 -0
  9. package/dist/core/config-schema.js +15 -0
  10. package/dist/core/eval-driver.d.ts +9 -1
  11. package/dist/core/eval-spec-parse.d.ts +14 -0
  12. package/dist/core/eval-spec-parse.js +168 -0
  13. package/dist/core/markdown.d.ts +17 -0
  14. package/dist/core/markdown.js +57 -0
  15. package/dist/core/presets.d.ts +1 -1
  16. package/dist/core/presets.js +1 -0
  17. package/dist/core/rule-meta.js +12 -0
  18. package/dist/core/run-env.d.ts +46 -1
  19. package/dist/core/run-env.js +59 -0
  20. package/dist/core/spec.d.ts +45 -2
  21. package/dist/core/spec.js +16 -2
  22. package/dist/core/stub-rules.d.ts +199 -0
  23. package/dist/core/stub-rules.js +397 -0
  24. package/dist/core/types.d.ts +26 -0
  25. package/dist/eval-cache.d.ts +19 -1
  26. package/dist/eval-cache.js +1 -1
  27. package/dist/eval-entry.d.ts +28 -0
  28. package/dist/eval-entry.js +63 -10
  29. package/dist/eval-surface.d.ts +2 -0
  30. package/dist/eval.d.ts +80 -32
  31. package/dist/eval.js +293 -91
  32. package/dist/harness-test.d.ts +1 -1
  33. package/dist/services.d.ts +1 -1
  34. package/dist/services.js +1 -1
  35. package/dist/stub.d.ts +57 -0
  36. package/dist/stub.js +103 -0
  37. package/dist/test.d.ts +4 -1
  38. package/dist/test.js +8 -4
  39. package/dist/tool-stub.d.ts +24 -29
  40. package/dist/tool-stub.js +63 -74
  41. package/dist/vigilesrc.schema.json +94 -0
  42. package/package.json +1 -1
  43. package/skills/test-harness/references/observing-a-run.md +13 -12
  44. package/skills/test-harness/references/writing-tests.md +1 -0
@@ -13,7 +13,7 @@
13
13
  /** Human-facing verbs (printed in help; typed by a human/agent/CI). */
14
14
  export declare const VERBS: readonly ["init", "compile", "eject", "lint", "test", "eval", "audit", "generate", "hook-runtime"];
15
15
  /** Runtime entrypoint kinds under `vigiles hook-runtime <kind>` (emitted, not typed). */
16
- export declare const HOOK_RUNTIME_KINDS: readonly ["run-program", "agent", "agent-start", "agent-done", "skill", "skill-tool", "skill-start", "skill-done", "run-skill", "intercept-tool", "guard", "action", "refs", "eval-lock-nudge", "effect-enter", "effect-exit"];
16
+ export declare const HOOK_RUNTIME_KINDS: readonly ["run-program", "agent", "agent-start", "agent-done", "skill", "skill-tool", "skill-start", "skill-done", "run-skill", "intercept-tool", "stub", "guard", "action", "refs", "eval-lock-nudge", "effect-enter", "effect-exit"];
17
17
  export type Verb = (typeof VERBS)[number];
18
18
  export type HookRuntimeKind = (typeof HOOK_RUNTIME_KINDS)[number];
19
19
  //# sourceMappingURL=cli-commands.d.ts.map
@@ -37,6 +37,7 @@ exports.HOOK_RUNTIME_KINDS = [
37
37
  "skill-done",
38
38
  "run-skill",
39
39
  "intercept-tool",
40
+ "stub",
40
41
  "guard",
41
42
  "action",
42
43
  "refs",
@@ -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
@@ -27,6 +27,7 @@ const generate_harness_js_1 = require("./core/generate-harness.js");
27
27
  const capability_diff_js_1 = require("./core/capability-diff.js");
28
28
  const validate_js_1 = require("./core/validate.js");
29
29
  const eval_lock_js_1 = require("./eval-lock.js");
30
+ const eval_entry_js_1 = require("./eval-entry.js");
30
31
  const zod_1 = require("zod");
31
32
  const cli_flags_js_1 = require("./cli-flags.js");
32
33
  const cli_commands_js_1 = require("./cli-commands.js");
@@ -352,6 +353,7 @@ async function compileClaudeToFile(spec, specPath, config, dialect) {
352
353
  maxSectionLines: config.maxSectionLines,
353
354
  catalogOnly: config.catalogOnly,
354
355
  linters: config.linters,
356
+ ...compileBudgetOptions(config),
355
357
  });
356
358
  const primaryOutput = specPath.replace(/\.spec\.ts$/, "");
357
359
  // Budget findings print BEFORE the pass/fail line and never change the exit
@@ -991,7 +993,8 @@ function lintExitCode(report) {
991
993
  // error — it used to sit at exit 1 because it fired unasked and could not be
992
994
  // turned off.
993
995
  report.docRefErrors > 0 ||
994
- report.specRefErrors > 0)
996
+ report.specRefErrors > 0 ||
997
+ report.compileBudgetErrors > 0)
995
998
  return 2;
996
999
  // Tierable now: a `warn` orphan/duplicate finding is reported and does NOT
997
1000
  // change the exit code. Both used to feed the exit directly, which is what
@@ -1362,9 +1365,13 @@ function overBundles(fn, run, bundles) {
1362
1365
  */
1363
1366
  async function checkSpecRefs(excludes, config, silent, dialect) {
1364
1367
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["spec-refs"]) ?? "error";
1365
- if (!sev)
1366
- return { issues: 0, errors: 0 };
1367
- 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 = [];
1368
1375
  let knownAgents;
1369
1376
  for (const specPath of findSpecs(excludes)) {
1370
1377
  const target = specPath.replace(/\.spec\.ts$/, "");
@@ -1378,9 +1385,11 @@ async function checkSpecRefs(excludes, config, silent, dialect) {
1378
1385
  // collected lazily so a repo with no railway spec never pays for the walk.
1379
1386
  if (spec._specType === "railway")
1380
1387
  knownAgents ??= await collectAgentNames(excludes);
1381
- const errors = await specCompileErrors(spec, specPath, dialect, config, knownAgents ?? []);
1382
- for (const e of errors)
1383
- found.push(`${target}: ${e.message} (from ${specPath})`);
1388
+ derived.push({
1389
+ target,
1390
+ specPath,
1391
+ findings: await specCompileErrors(spec, specPath, dialect, config, knownAgents ?? []),
1392
+ });
1384
1393
  }
1385
1394
  catch {
1386
1395
  // A spec that will not load is `compile`'s finding, not this one's —
@@ -1388,6 +1397,9 @@ async function checkSpecRefs(excludes, config, silent, dialect) {
1388
1397
  continue;
1389
1398
  }
1390
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);
1391
1403
  if (found.length > 0 && !silent) {
1392
1404
  console.log("\nSpec reference check:\n");
1393
1405
  for (const msg of found) {
@@ -1395,7 +1407,18 @@ async function checkSpecRefs(excludes, config, silent, dialect) {
1395
1407
  ghAnnotate(sev === "error" ? "error" : "warning", msg);
1396
1408
  }
1397
1409
  }
1398
- 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
+ };
1399
1422
  }
1400
1423
  /**
1401
1424
  * The ONE spec-type dispatcher, in the only shape both directions can share:
@@ -1419,8 +1442,8 @@ async function checkSpecRefs(excludes, config, silent, dialect) {
1419
1442
  async function specCompileErrors(spec, specPath, dialect, config, knownAgents) {
1420
1443
  const basePath = process.cwd();
1421
1444
  switch (spec._specType) {
1422
- case "claude":
1423
- return (await (0, compile_js_1.compileClaude)(spec, {
1445
+ case "claude": {
1446
+ const r = await (0, compile_js_1.compileClaude)(spec, {
1424
1447
  basePath,
1425
1448
  specFile: specPath,
1426
1449
  dialect,
@@ -1429,19 +1452,52 @@ async function specCompileErrors(spec, specPath, dialect, config, knownAgents) {
1429
1452
  maxSectionLines: config?.maxSectionLines,
1430
1453
  catalogOnly: config?.catalogOnly,
1431
1454
  linters: config?.linters,
1432
- })).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
+ }
1433
1464
  case "skill":
1434
- 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
+ };
1435
1469
  case "agent":
1436
- 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
+ };
1437
1474
  case "railway":
1438
- 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
+ };
1439
1480
  default:
1440
1481
  // A `pipeline` spec compiles through its underlying railway, so it has no
1441
1482
  // artifact of its own to re-derive refs for.
1442
- return [];
1483
+ return { errors: [], budget: [] };
1443
1484
  }
1444
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
+ }
1445
1501
  /**
1446
1502
  * Write a compiled artifact. Accepts ONLY a {@link StampedMarkdown}, so a body
1447
1503
  * that failed to compile cannot reach the disk — there is no stamp to pass.
@@ -1774,6 +1830,7 @@ async function runLint(restArgs, flags, excludes, config) {
1774
1830
  frontmatterRules,
1775
1831
  specRefIssues: specRefs.issues,
1776
1832
  specRefErrors: specRefs.errors,
1833
+ compileBudgetErrors: specRefs.budgetErrors,
1777
1834
  duplicatePairs: dups.pairCount,
1778
1835
  duplicateSeverity: (0, types_js_1.ruleSeverity)(config?.rules?.["duplicate-rules"]) ?? "warn",
1779
1836
  coverageEnabled: coverage.enabled,
@@ -5572,6 +5629,18 @@ async function handleRunScripts(kind, args, restArgs, excludes) {
5572
5629
  console.error(`\n✗ vigiles ${kind}: --min=${String(minRequired)} but only ${String(loadedCount)} of ` +
5573
5630
  `${String(files.length)} matched ${kind} file(s) loaded.`);
5574
5631
  }
5632
+ // An eval whose stubs left a call unanswered measured an answer nobody wrote.
5633
+ // That is not a failed assertion but a non-measurement, and it must fail the
5634
+ // command even when the file's own `assert` never looks (eval-entry.ts). The
5635
+ // file says so with its own exit code, so this names the cause from that code
5636
+ // and not from the verb's usage-error 2.
5637
+ const unanswered = results.filter((r) => r.code === eval_entry_js_1.UNANSWERED_STUB_EXIT_CODE);
5638
+ if (kind === "eval" && unanswered.length > 0) {
5639
+ console.error(`\n✗ vigiles eval: unanswered stub call(s) in ${unanswered.map((r) => r.file).join(", ")} — not a usage error. ` +
5640
+ `The calls are listed above; add a rule for each, and no lock was written. ` +
5641
+ `(Exit ${String(eval_entry_js_1.UNANSWERED_STUB_VERB_EXIT)}; the file itself exited ${String(eval_entry_js_1.UNANSWERED_STUB_EXIT_CODE)}.)`);
5642
+ process.exit(eval_entry_js_1.UNANSWERED_STUB_VERB_EXIT);
5643
+ }
5575
5644
  if ((0, run_scripts_js_1.anyFailed)(results))
5576
5645
  process.exit(1);
5577
5646
  // 🔴 A SKIP THE AUTHOR NEVER DECLARED IS NOT A SKIP — and the discriminator was
package/dist/cli.js CHANGED
@@ -65,6 +65,53 @@ function die(e) {
65
65
  }
66
66
  process.exit(2);
67
67
  }
68
+ /**
69
+ * Any failure on the stub path — a log the agent's shell cannot append to, a
70
+ * rules file that does not parse — is a MISS to the caller: the same neutral
71
+ * line and code as a call no rule answers. A vigiles error line or stack would
72
+ * tell the model it is in a test, and a call that could not be logged must never
73
+ * read as answered. `VIGILES_DEBUG=1` shows the cause.
74
+ */
75
+ async function stubAnswer(root, name, argv) {
76
+ const { runStub, unsupportedLine, UNANSWERED_EXIT_CODE } = await import("./core/stub-rules.js");
77
+ const { readFileSync, appendFileSync, existsSync } = await import("node:fs");
78
+ try {
79
+ return runStub(root, name, argv, {
80
+ readFile: (p) => (existsSync(p) ? readFileSync(p, "utf8") : null),
81
+ appendFile: (p, text) => {
82
+ appendFileSync(p, text);
83
+ },
84
+ });
85
+ }
86
+ catch (e) {
87
+ if (process.env.VIGILES_DEBUG)
88
+ console.error(e instanceof Error && e.stack ? e.stack : String(e));
89
+ return {
90
+ stdout: "",
91
+ stderr: unsupportedLine(name),
92
+ exitCode: UNANSWERED_EXIT_CODE,
93
+ };
94
+ }
95
+ }
96
+ /**
97
+ * A TOOL STUB's invocation — the eval tier writes a `gh`/`git` shim onto PATH
98
+ * that execs `hook-runtime stub <root> <name> <argv…>`, once per call the agent
99
+ * (or a script it runs) makes. Same cost argument as `run-program`, so the same
100
+ * kind of fast path: the pure decision (`core/stub-rules.js`) and `node:fs`,
101
+ * nothing else — no barrel, no zod. The argv is taken RAW: the stubbed tool's
102
+ * own `--flags` are its argv. It never reads stdin: a caller that leaves the
103
+ * pipe open (Node's `execFile` does) would hang until its own timeout.
104
+ */
105
+ async function runStubCommand(raw) {
106
+ const [root, name, ...argv] = raw;
107
+ if (root === undefined || name === undefined) {
108
+ console.error("vigiles hook-runtime stub: emitted into a tool-stub shim by the eval tier, never typed by hand.");
109
+ process.exit(2);
110
+ }
111
+ const r = await stubAnswer(root, name, argv);
112
+ // Exit only once both streams have flushed: a pipe write is async on macOS.
113
+ process.stdout.write(r.stdout, () => process.stderr.write(r.stderr, () => process.exit(r.exitCode)));
114
+ }
68
115
  async function dispatch() {
69
116
  // Parsed EXACTLY as `main()` parses it — `args.slice(1)` with `--flags`
70
117
  // dropped — so the fast path and the barrel agree on which argument is the
@@ -86,6 +133,10 @@ async function dispatch() {
86
133
  await runHookProgramCommand(rest[1]);
87
134
  return;
88
135
  }
136
+ if (command === "hook-runtime" && kind === "stub") {
137
+ await runStubCommand(args.slice(2));
138
+ return;
139
+ }
89
140
  const { main } = require("./cli-main.js");
90
141
  await main();
91
142
  }
@@ -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,