vigiles 27.1.6 → 27.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.
@@ -19,6 +19,7 @@ const compile_js_1 = require("./core/compile.js");
19
19
  const spec_js_1 = require("./core/spec.js");
20
20
  const plugin_loader_js_1 = require("./plugin-loader.js");
21
21
  const vocabulary_consistency_js_1 = require("./core/vocabulary-consistency.js");
22
+ const event_capability_js_1 = require("./core/event-capability.js");
22
23
  /** Check an adapter against the port contracts; returns the (possibly empty) failure list. */
23
24
  function checkAdapterConformance(adapter) {
24
25
  const failures = [];
@@ -89,7 +90,18 @@ function checkAdapterConformance(adapter) {
89
90
  // deliver an inject hook?" a tested contract — the gap that let Codex's
90
91
  // inject support sit unverified in prose. Empty would mean the harness
91
92
  // can't inject context from a hook at all; every harness we support can.
92
- need(adapter.hookProtocol.injectableEvents.length > 0, "hookProtocol.injectableEvents is empty — a shell-hook harness must declare the events that honor additionalContext injection (or it can't deliver an inject/nudge hook)");
93
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- the legacy list is the FALLBACK for an adapter with no capability table, and the thing checked for drift below
94
+ const declaredInject = adapter.hookProtocol.injectableEvents;
95
+ need((0, event_capability_js_1.injectableEventsOf)(adapter.dialect, declaredInject).length > 0, "hookProtocol.injectableEvents is empty — a shell-hook harness must declare the events that honor additionalContext injection (or it can't deliver an inject/nudge hook)");
96
+ // …and when an adapter declares BOTH, they must agree. Without this the
97
+ // table silently COVERS FOR a broken list: an adapter could ship
98
+ // `injectableEvents: []` and still pass, because the effective answer came
99
+ // from the dialect. Two sources that disagree are worse than one, and this
100
+ // is the assertion that keeps the deprecation honest rather than lossy.
101
+ if (adapter.dialect.eventCapabilities) {
102
+ const derived = [...(0, event_capability_js_1.injectableEventsOf)(adapter.dialect, [])].sort();
103
+ need(derived.join("|") === [...declaredInject].sort().join("|"), `hookProtocol.injectableEvents disagrees with dialect.eventCapabilities — the list says [${[...declaredInject].sort().join(", ")}], the table says [${derived.join(", ")}]; they describe the same fact and must match`);
104
+ }
93
105
  portNames.push(["hookProtocol", adapter.hookProtocol.name]);
94
106
  }
95
107
  }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.claudeCodeDialect = exports.claudeCodeSideEffectingTools = exports.claudeCodeBuiltinAgentTools = void 0;
4
+ const event_capability_js_1 = require("./event-capability.js");
4
5
  const vocabulary_js_1 = require("./vocabulary.js");
5
6
  /**
6
7
  * The Claude Code built-in subagent tool catalog as a `const` tuple, so a typed
@@ -61,6 +62,35 @@ exports.claudeCodeDialect = {
61
62
  "Notification",
62
63
  "PreCompact",
63
64
  ],
65
+ // What Claude Code loads unasked, and what it does with too much of it.
66
+ //
67
+ // WARNS, does not truncate: `/doctor` reports "Large file will impact
68
+ // performance" and the instructions still reach the model. So being over
69
+ // budget here costs money and attention — not rules. (Codex is the opposite;
70
+ // see its dialect, and see why `onExceed` is reported at all.)
71
+ //
72
+ // 🔴 `alwaysLoaded` IS THE POINT, and `.claude/rules/**` is in it on a
73
+ // MEASUREMENT, not a doc: a consumer repo moved 225 837 characters out of
74
+ // CLAUDE.md into that directory and the request cost did not move, because
75
+ // the harness loads it either way. A per-file check would have called that
76
+ // split a success.
77
+ instructionBudget: {
78
+ unit: "chars",
79
+ limit: 40000,
80
+ onExceed: "warns",
81
+ capturedFrom: "claude-code /doctor large-file warning; .claude/rules/** measured 2026-09-16 in a consumer repo",
82
+ alwaysLoaded: [
83
+ "CLAUDE.md",
84
+ "CLAUDE.local.md",
85
+ ".claude/CLAUDE.md",
86
+ ".claude/rules/**",
87
+ ],
88
+ },
89
+ // The capability table — what each event CARRIES and HONOURS. Nine of the 31
90
+ // events, each with its basis; see ./event-capability.ts. The three flat lists
91
+ // above are kept for now and are ASSERTED against this table in
92
+ // event-capability.test.ts, so there is one source rather than two truths.
93
+ eventCapabilities: event_capability_js_1.claudeCodeEventCapabilities,
64
94
  // PreToolUse is the one event whose deny needs the structured
65
95
  // `hookSpecificOutput.permissionDecision:"deny"`; the legacy top-level
66
96
  // `decision` field is ignored there.
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Claude Code's hook-event capability table — what each event carries and what
3
+ * it honours. The CAPTURE half of `core/event-capability.ts`; see that module
4
+ * for why the table is partial and why `unknown` is fail-open.
5
+ *
6
+ * NINE of the vendor's 31 events are recorded here, and the other 22 are absent
7
+ * ON PURPOSE. These nine are the ones a capability is actually KNOWN for —
8
+ * either documented by the vendor or measured against a real binary — and every
9
+ * row below carries which. The 22 absent ones are not "no capabilities"; they
10
+ * are "we have not looked", and the classifier says so rather than guessing.
11
+ *
12
+ * Adding a row is a claim about somebody else's product, so it needs a basis in
13
+ * the comment beside it: `doc` (the vendor documents it) or `measured <date> on
14
+ * <version>` (we ran it). "Probably the same as its sibling event" is not a
15
+ * basis — `SubagentStop` is in here on the vendor's own wording, not on its
16
+ * resemblance to `Stop`.
17
+ */
18
+ import type { EventCapabilityTable } from "../../core/event-capability.js";
19
+ export declare const claudeCodeEventCapabilities: EventCapabilityTable;
20
+ //# sourceMappingURL=event-capability.d.ts.map
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.claudeCodeEventCapabilities = void 0;
4
+ exports.claudeCodeEventCapabilities = {
5
+ capturedFrom: "code.claude.com/docs/en/hooks (claude-code 2.1.273) + inject/veto measured 2026-09-16 headless",
6
+ events: {
7
+ // doc: SessionStart context is prepended to the session. MEASURED 2026-09-16:
8
+ // inject lands; exit 2 is ignored entirely (it is in noEffectHookEvents).
9
+ SessionStart: {
10
+ carries: "session",
11
+ honours: ["inject"],
12
+ matcher: false,
13
+ denyShape: "exit-code",
14
+ },
15
+ // doc: exit 2 blocks the prompt and erases it; stdout becomes context.
16
+ UserPromptSubmit: {
17
+ carries: "prompt",
18
+ honours: ["veto", "inject"],
19
+ matcher: false,
20
+ denyShape: "exit-code",
21
+ },
22
+ // doc: the one event whose deny needs `hookSpecificOutput.permissionDecision`
23
+ // (the legacy top-level `decision` is ignored), and the one that can `ask`.
24
+ // MEASURED 2026-09-16: additionalContext also lands here — the reason this
25
+ // table exists at all, since `injectableEvents` had said otherwise on an
26
+ // assumption nobody had run.
27
+ PreToolUse: {
28
+ carries: "tool",
29
+ honours: ["veto", "ask", "inject"],
30
+ matcher: true,
31
+ denyShape: "permission-decision",
32
+ },
33
+ // doc: exit 2 feeds stderr back to the MODEL but the tool has already run —
34
+ // feedback, never a veto. This distinction is why `hook-block-ineffective`
35
+ // does not flag PostToolUse and would cry wolf if it did.
36
+ PostToolUse: {
37
+ carries: "tool",
38
+ honours: ["feedback", "inject"],
39
+ matcher: true,
40
+ denyShape: "exit-code",
41
+ },
42
+ // doc: exit 2 blocks the agent from stopping (the gate-until-tests-pass
43
+ // shape). MEASURED 2026-09-16: inject lands too.
44
+ Stop: {
45
+ carries: "stop",
46
+ honours: ["veto", "inject"],
47
+ matcher: false,
48
+ denyShape: "exit-code",
49
+ },
50
+ // doc: same block semantics as Stop, for a subagent. Inject NOT claimed —
51
+ // it was not measured, and Stop's behaviour is not evidence about it.
52
+ SubagentStop: {
53
+ carries: "stop",
54
+ honours: ["veto"],
55
+ matcher: false,
56
+ denyShape: "exit-code",
57
+ },
58
+ // doc + noEffectHookEvents: a block decision here is silently ignored
59
+ // ENTIRELY — no veto AND no model feedback. A hook on these three is inert
60
+ // whatever it returns, which is exactly what `honours: []` says.
61
+ SessionEnd: {
62
+ carries: "none",
63
+ honours: [],
64
+ matcher: false,
65
+ denyShape: "exit-code",
66
+ },
67
+ Notification: {
68
+ carries: "none",
69
+ honours: [],
70
+ matcher: false,
71
+ denyShape: "exit-code",
72
+ },
73
+ PreCompact: {
74
+ carries: "none",
75
+ honours: [],
76
+ matcher: false,
77
+ denyShape: "exit-code",
78
+ },
79
+ },
80
+ };
81
+ //# sourceMappingURL=event-capability.js.map
@@ -11,9 +11,32 @@ exports.claudeCodeHookProtocol = {
11
11
  // the agent — a stronger stop than a per-call deny, and a documented one.
12
12
  haltsTurnField: "continue",
13
13
  // Events that honor `hookSpecificOutput.additionalContext` (developer-context
14
- // injection). Covers vigiles's shipped inject hooks: the SessionStart lint
15
- // summary and the PostToolUse refs / eval-lock nudges.
16
- injectableEvents: ["SessionStart", "UserPromptSubmit", "PostToolUse"],
14
+ // injection). Covers vigiles's shipped inject hooks (the SessionStart lint
15
+ // summary, the PostToolUse refs / eval-lock nudges) AND the two events added
16
+ // 2026-09-15 after a MEASUREMENT contradicted the assumption below.
17
+ //
18
+ // `PreToolUse` and `Stop` were absent because the port's doc-comment asserted
19
+ // that "a few (Stop, SubagentStop, PreCompact) carry no context on either"
20
+ // harness. That was never measured for Claude Code. Measured on 2.1.273 by
21
+ // emitting `additionalContext` from a hook on each event and reading the
22
+ // model's OWN request back (`requestContains` over a real `runHarnessTest`
23
+ // run, not the hook's stdout): both events DELIVER. The prior list was
24
+ // therefore a self-inflicted gate — the runtime refused to emit a shape the
25
+ // harness would have honored, and the four affected react hooks read as
26
+ // "undeliverable by vocabulary" when they were undeliverable by our choice.
27
+ //
28
+ // HONEST SCOPE: measured HEADLESS (`claude -p`, which is what runHarnessTest
29
+ // drives). Interactive is unverified, and for the `ask` channel the two
30
+ // plausibly differ (headless has nobody to ask) — but `ask` is a GATE
31
+ // channel, not this inject one, so it does not bear on this list.
32
+ // `SubagentStop`/`PreCompact` stay out: not measured, so not claimed.
33
+ injectableEvents: [
34
+ "SessionStart",
35
+ "UserPromptSubmit",
36
+ "PreToolUse",
37
+ "PostToolUse",
38
+ "Stop",
39
+ ],
17
40
  // The `if` field: a permission-rule pattern deciding whether the hook is spawned
18
41
  // at all. See ./hook-condition.ts — without it a conditional guard was reported
19
42
  // as blocking every disaster in the battery.
@@ -21,6 +21,25 @@ exports.codexDialect = {
21
21
  "UserPromptSubmit",
22
22
  "Stop",
23
23
  ],
24
+ // 🔴 TRUNCATES, SILENTLY — the asymmetry that makes `onExceed` worth carrying.
25
+ // Codex's own source: "Maximum number of bytes of the documentation that will
26
+ // be embedded. Larger files are *silently truncated*" (openai/codex#7138,
27
+ // CLOSED AS NOT PLANNED — standing behaviour, not a bug in flight). Default
28
+ // `project_doc_max_bytes` is 32 * 1024. Over budget on Claude Code costs
29
+ // money; over budget here means some of your rules DO NOT EXIST for the model
30
+ // and nothing in the session says which.
31
+ //
32
+ // BYTES, not chars: the two diverge on any non-ASCII instruction file, and
33
+ // this is the unit Codex actually counts.
34
+ instructionBudget: {
35
+ unit: "bytes",
36
+ limit: 32768,
37
+ onExceed: "truncates",
38
+ capturedFrom: "codex config project_doc_max_bytes default 32 * 1024; truncation quoted in openai/codex#7138",
39
+ // Read root-to-leaf and concatenated, so a nested AGENTS.md pays into the
40
+ // same budget — the sum is what gets cut, not the individual file.
41
+ alwaysLoaded: ["AGENTS.md", "**/AGENTS.md"],
42
+ },
24
43
  instructionTargets: ["AGENTS.md"],
25
44
  pluginRootToken: "${PLUGIN_ROOT}",
26
45
  // Codex SKILL.md frontmatter is name + description ONLY — the CC-only keys
package/dist/cli-main.js CHANGED
@@ -16,6 +16,7 @@ exports.discoverNestedBundles = discoverNestedBundles;
16
16
  exports.handleHookRuntime = handleHookRuntime;
17
17
  exports.main = main;
18
18
  const node_fs_1 = require("node:fs");
19
+ const event_capability_js_1 = require("./core/event-capability.js");
19
20
  const node_path_1 = require("node:path");
20
21
  const repo_path_js_1 = require("./core/repo-path.js");
21
22
  const node_child_process_1 = require("node:child_process");
@@ -323,7 +324,7 @@ function compileGeneratorSkillToFile(specPath, source) {
323
324
  if (artifact)
324
325
  writeArtifact(outputPath, artifact);
325
326
  if (errors.length === 0) {
326
- console.log(`\n✓ ${specPath} → ${outputPath} (generator skill)`);
327
+ console.log(`\n✓ ${specPath} → ${outputPath} (generator skill${artifact ? `, ${formatArtifactSize(artifact)}` : ""})`);
327
328
  return true;
328
329
  }
329
330
  console.log(`\n✗ ${specPath} — ${String(errors.length)} error(s)`);
@@ -334,7 +335,7 @@ function compileGeneratorSkillToFile(specPath, source) {
334
335
  /** Compile a ClaudeSpec → its primary + any additional targets. */
335
336
  function compileClaudeToFile(spec, specPath, config, dialect) {
336
337
  const basePath = process.cwd();
337
- const { markdown, errors, linterResults, targets } = (0, compile_js_1.compileClaude)(spec, {
338
+ const { markdown, errors, warnings, linterResults, targets } = (0, compile_js_1.compileClaude)(spec, {
338
339
  basePath,
339
340
  specFile: specPath,
340
341
  dialect,
@@ -345,6 +346,16 @@ function compileClaudeToFile(spec, specPath, config, dialect) {
345
346
  linters: config.linters,
346
347
  });
347
348
  const primaryOutput = specPath.replace(/\.spec\.ts$/, "");
349
+ // Budget findings print BEFORE the pass/fail line and never change the exit
350
+ // code. A number nobody prints cannot be acted on — the same reason the
351
+ // compiled size is printed at all — and an instruction file grows one
352
+ // unremarkable entry at a time, so the warning has to arrive at the moment
353
+ // the entry is added rather than at some later audit.
354
+ if (warnings.length > 0) {
355
+ console.log(`\nℹ ${specPath} — ${String(warnings.length)} budget warning(s)`);
356
+ for (const w of warnings)
357
+ console.log(` ${w.message}`);
358
+ }
348
359
  if (errors.length > 0) {
349
360
  console.log(`\n✗ ${specPath} — ${String(errors.length)} error(s)`);
350
361
  printErrors(specPath, errors);
@@ -376,7 +387,7 @@ function compileClaudeToFile(spec, specPath, config, dialect) {
376
387
  outputNames.push(targetPath);
377
388
  }
378
389
  const linterCount = linterResults.filter((r) => r.exists).length;
379
- console.log(`\n✓ ${specPath} → ${outputNames.join(", ")}`);
390
+ console.log(`\n✓ ${specPath} → ${outputNames.join(", ")} (${formatArtifactSize(markdown)})`);
380
391
  console.log(` ${String(Object.keys(spec.rules).length)} rules (${String(linterCount)} linter-verified)`);
381
392
  return true;
382
393
  }
@@ -419,6 +430,44 @@ function writeInstructionMirrors(primaryOutput, harnesses) {
419
430
  console.log(` ↳ mirrored ${primaryName} → ${target} (byte-identical)`);
420
431
  }
421
432
  }
433
+ /**
434
+ * The size of a compiled artifact, printed beside every `✓` on STDOUT.
435
+ *
436
+ * WHY STDOUT: every human line `compile` emits already goes there (the `✓`
437
+ * lines, `Compilation complete`), and stderr in this CLI is the ERROR channel.
438
+ * An informational number in the error stream reads as a problem in CI logs and
439
+ * is killed by `2>/dev/null`. This is part of the operation's RESULT, like the
440
+ * output path beside it — not a diagnostic about a failure.
441
+ *
442
+ * WHY BYTES AND CHARS, AND WHY THE TOKEN COUNT IS ONLY AN ESTIMATE. These are
443
+ * the units the HARNESSES themselves measure, and both are exact:
444
+ *
445
+ * - Claude Code warns per FILE CHARS (`/doctor`: "Large file will impact
446
+ * performance"), and skips a file past a hard size;
447
+ * - Codex caps AGENTS.md by BYTES (`project_doc_max_bytes`) and TRUNCATES —
448
+ * instructions past that byte simply do not exist for the model.
449
+ *
450
+ * Neither gates on tokens, so a real tokenizer would buy precision in a unit
451
+ * nothing decides on. It is also not available: Anthropic publishes no local
452
+ * tokenizer for current Claude models (the supported count is a NETWORK call
453
+ * with an API key, which a zero-config offline compile must not need), while
454
+ * OpenAI's is local — so tokenizing would make us precise for one harness and
455
+ * blind for the other, and the two numbers would stop being comparable.
456
+ *
457
+ * `estimateTokens` is `length / 4`, calibrated for ASCII English; it undercounts
458
+ * Cyrillic and CJK substantially. It is labelled `est.` for that reason and must
459
+ * never become the number a rule gates on — see the instruction-weight rule.
460
+ */
461
+ function formatArtifactSize(markdown) {
462
+ const chars = markdown.length;
463
+ const bytes = Buffer.byteLength(markdown, "utf8");
464
+ const group = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
465
+ const kTokens = Math.round((0, compile_js_1.estimateTokens)(markdown) / 100) / 10;
466
+ const size = bytes === chars
467
+ ? `${group(chars)} chars`
468
+ : `${group(chars)} chars / ${group(bytes)} bytes`;
469
+ return `${size} · ~${String(kTokens)}k tokens est.`;
470
+ }
422
471
  /** Compile a declarative SkillSpec → SKILL.md. */
423
472
  function compileSkillToFile(spec, specPath, dialect) {
424
473
  const outputPath = specPath.replace(/\.spec\.ts$/, "");
@@ -434,7 +483,7 @@ function compileSkillToFile(spec, specPath, dialect) {
434
483
  if (artifact)
435
484
  writeArtifact(outputPath, artifact);
436
485
  if (errors.length === 0) {
437
- console.log(`\n✓ ${specPath} → ${outputPath}`);
486
+ console.log(`\n✓ ${specPath} → ${outputPath}${artifact ? ` (${formatArtifactSize(artifact)})` : ""}`);
438
487
  printWarnings(specPath, warnings);
439
488
  return true;
440
489
  }
@@ -456,7 +505,7 @@ function compileAgentToFile(spec, specPath, dialect) {
456
505
  if (artifact)
457
506
  writeArtifact(outputPath, artifact);
458
507
  if (errors.length === 0) {
459
- console.log(`\n✓ ${specPath} → ${outputPath}`);
508
+ console.log(`\n✓ ${specPath} → ${outputPath}${artifact ? ` (${formatArtifactSize(artifact)})` : ""}`);
460
509
  printWarnings(specPath, warnings);
461
510
  return true;
462
511
  }
@@ -481,7 +530,7 @@ function compileRailwayToFile(spec, specPath, knownAgents) {
481
530
  if (artifact)
482
531
  writeArtifact(outputPath, artifact);
483
532
  if (errors.length === 0) {
484
- console.log(`\n✓ ${specPath} → ${outputPath}`);
533
+ console.log(`\n✓ ${specPath} → ${outputPath}${artifact ? ` (${formatArtifactSize(artifact)})` : ""}`);
485
534
  return true;
486
535
  }
487
536
  console.log(`\n✗ ${specPath} — ${String(errors.length)} error(s)`);
@@ -5730,7 +5779,9 @@ async function installHookFile(file, adapter, registeredProviders = []) {
5730
5779
  // cross-harness and never warns.
5731
5780
  const role = (0, hook_program_js_1.dispatchKind)(program);
5732
5781
  const event = typeof program.on === "string" ? program.on : "";
5733
- const injectable = adapter.hookProtocol?.injectableEvents ?? [];
5782
+ const injectable = (0, event_capability_js_1.injectableEventsOf)(adapter.dialect,
5783
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- the legacy list is the FALLBACK an adapter without a capability table still relies on; reading it here is the point
5784
+ adapter.hookProtocol?.injectableEvents);
5734
5785
  const matcher = (0, hook_program_js_1.hookRouting)(program).matcher;
5735
5786
  let warning;
5736
5787
  // A react on an event this harness does NOT inject can still call `notice()`,
@@ -25,6 +25,7 @@ exports.adoptToSpec = adoptToSpec;
25
25
  exports.adoptMarkdown = adoptMarkdown;
26
26
  exports.adoptSkill = adoptSkill;
27
27
  exports.adoptAgent = adoptAgent;
28
+ const compile_js_1 = require("./compile.js");
28
29
  const integrity_js_1 = require("./integrity.js");
29
30
  const frontmatter_read_js_1 = require("./frontmatter-read.js");
30
31
  const spec_js_1 = require("./spec.js");
@@ -195,8 +196,15 @@ function renderSpecSource(spec, exists) {
195
196
  const targetLine = spec.target !== "CLAUDE.md"
196
197
  ? `\n target: ${JSON.stringify(spec.target)},`
197
198
  : "";
199
+ // The raised guard is rendered WITH the debt spelled out. A bare number reads
200
+ // as a considered setting; the comment says it was accepted as-is and by how
201
+ // much, so the next author sees a debt rather than a decision.
202
+ const over = (spec.maxSectionLines ?? 0) - compile_js_1.DEFAULT_MAX_SECTION_LINES;
198
203
  const maxLine = spec.maxSectionLines !== undefined
199
- ? `\n maxSectionLines: ${String(spec.maxSectionLines)},`
204
+ ? `\n // Adopted as-is: the longest section is ${String(spec.maxSectionLines)} lines, ` +
205
+ `${String(over)} over the ${String(compile_js_1.DEFAULT_MAX_SECTION_LINES)}-line budget.` +
206
+ `\n // Lower this as you split that section up; it is a debt, not a setting.` +
207
+ `\n maxSectionLines: ${String(spec.maxSectionLines)},`
200
208
  : "";
201
209
  const adopted = [];
202
210
  const entries = Object.entries(spec.sections)
@@ -269,14 +277,24 @@ function adoptToSpec(markdown, target) {
269
277
  const sections = {};
270
278
  for (const { key, content } of ordered)
271
279
  sections[key] = content;
272
- // A faithful section can legitimately be long; lift the 200-line guard above
273
- // the longest one so adoption never trips it (only when actually needed, so a
274
- // normal spec stays free of the override).
280
+ // A faithful section can legitimately be long, and adoption must not fail on
281
+ // prose the user already has — so the 200-line guard is raised to EXACTLY the
282
+ // longest section, never above it.
283
+ //
284
+ // IT USED TO BE `longest + 50`, and that headroom is the bug this block exists
285
+ // to name: the guard was disarmed hardest for the population that needs it
286
+ // most — someone adopting a spec over a file that is already oversized — and
287
+ // the next fifty lines of growth were pre-approved, silently, by the tool
288
+ // meant to notice them. Raising to `longest` keeps adoption green on the file
289
+ // as it stands and makes the very next line that grows trip the gate. The
290
+ // override is rendered with a comment stating how far above the budget it
291
+ // sits, so the debt is visible in the spec rather than inferable from a number
292
+ // nobody reads.
275
293
  const longest = ordered.reduce((n, s) => Math.max(n, s.content.split("\n").length), 0);
276
294
  return {
277
295
  target,
278
296
  sections,
279
- maxSectionLines: longest > 190 ? longest + 50 : undefined,
297
+ maxSectionLines: longest > compile_js_1.DEFAULT_MAX_SECTION_LINES ? longest : undefined,
280
298
  tier: synthesizedHeading || ordered.length === 0 ? "raw" : "structured",
281
299
  };
282
300
  }
@@ -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";
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";
60
60
  message: string;
61
61
  path?: string;
62
62
  }
@@ -69,6 +69,13 @@ export declare function validateGlobRef(pattern: string, basePath: string): Comp
69
69
  export interface CompileClaudeResult {
70
70
  markdown: string;
71
71
  errors: CompileError[];
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`.
77
+ */
78
+ warnings: CompileError[];
72
79
  linterResults: LinterCheckResult[];
73
80
  /** Estimated token count of compiled output (~4 chars/token). */
74
81
  tokens: number;
@@ -76,6 +83,10 @@ export interface CompileClaudeResult {
76
83
  targets: string[];
77
84
  }
78
85
  export interface CompileClaudeOptions {
86
+ /** Per-entry `keyFiles`/`commands` character budget (0 disables). */
87
+ maxEntryChars?: number;
88
+ /** Per-section character budget, beside the line budget (0 disables). */
89
+ maxSectionChars?: number;
79
90
  basePath?: string;
80
91
  specFile?: string;
81
92
  /** Injected harness dialect; its instructionTargets[0] is the default target. */
@@ -97,6 +108,34 @@ export interface CompileClaudeOptions {
97
108
  /** Per-linter verification mode: true (full), "catalog-only", or false (skip). */
98
109
  linterModes?: Record<string, boolean | "catalog-only">;
99
110
  }
111
+ /**
112
+ * The generous per-section line guard. Exported because `adopt` raises it to
113
+ * exactly the longest adopted section and must name the same number rather than
114
+ * keep a second copy that drifts.
115
+ */
116
+ export declare const DEFAULT_MAX_SECTION_LINES = 200;
117
+ /**
118
+ * Per-ENTRY budget for a `keyFiles` / `commands` description, in characters.
119
+ *
120
+ * WHY AN ENTRY AND NOT THE SECTION. The list is append-only in practice: every
121
+ * session adds a row and none removes one, so the section total says "too big"
122
+ * long after the point where a reader could act on it, and it names no
123
+ * offender. A per-entry budget names the row. 200 is deliberately loose — an
124
+ * entry is a POINTER ("what is this file for"), and anything that needs a
125
+ * paragraph has a better home in that file's own header, where it is read when
126
+ * someone opens the file rather than on every request.
127
+ */
128
+ export declare const DEFAULT_MAX_ENTRY_CHARS = 200;
129
+ /**
130
+ * Per-section budget in CHARACTERS, beside the line budget.
131
+ *
132
+ * The line guard alone is measured to be useless on the shape that actually
133
+ * bites: this repo's own `Positioning` section was 24 lines and 20 416
134
+ * characters, passing a 200-LINE gate with two orders of magnitude to spare.
135
+ * Long lines are the normal shape of compiled prose, so lines do not measure
136
+ * cost — characters do, because that is what the harness loads.
137
+ */
138
+ export declare const DEFAULT_MAX_SECTION_CHARS = 15000;
100
139
  /**
101
140
  * Compile a ClaudeSpec into markdown.
102
141
  *
@@ -3,6 +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
7
  exports.computeHash = computeHash;
7
8
  exports.addHash = addHash;
8
9
  exports.seal = seal;
@@ -318,7 +319,79 @@ function compileRule(id, rule) {
318
319
  // generous (don't-cry-wolf): real prose sections are short, so this only trips on
319
320
  // an egregious dump (a whole essay pasted into one section / prose``).
320
321
  // Override per spec with `maxSectionLines`; `maxTokens` is the global backstop.
321
- const DEFAULT_MAX_SECTION_LINES = 200;
322
+ /**
323
+ * The generous per-section line guard. Exported because `adopt` raises it to
324
+ * exactly the longest adopted section and must name the same number rather than
325
+ * keep a second copy that drifts.
326
+ */
327
+ exports.DEFAULT_MAX_SECTION_LINES = 200;
328
+ /**
329
+ * Per-ENTRY budget for a `keyFiles` / `commands` description, in characters.
330
+ *
331
+ * WHY AN ENTRY AND NOT THE SECTION. The list is append-only in practice: every
332
+ * session adds a row and none removes one, so the section total says "too big"
333
+ * long after the point where a reader could act on it, and it names no
334
+ * offender. A per-entry budget names the row. 200 is deliberately loose — an
335
+ * entry is a POINTER ("what is this file for"), and anything that needs a
336
+ * paragraph has a better home in that file's own header, where it is read when
337
+ * someone opens the file rather than on every request.
338
+ */
339
+ exports.DEFAULT_MAX_ENTRY_CHARS = 200;
340
+ /**
341
+ * Per-section budget in CHARACTERS, beside the line budget.
342
+ *
343
+ * The line guard alone is measured to be useless on the shape that actually
344
+ * bites: this repo's own `Positioning` section was 24 lines and 20 416
345
+ * characters, passing a 200-LINE gate with two orders of magnitude to spare.
346
+ * Long lines are the normal shape of compiled prose, so lines do not measure
347
+ * cost — characters do, because that is what the harness loads.
348
+ */
349
+ exports.DEFAULT_MAX_SECTION_CHARS = 15000;
350
+ /**
351
+ * Budget findings for the two append-only maps and for section size. WARNINGS
352
+ * by construction (`lint-rule-calibration`: severity tracks confidence, and a
353
+ * gate that fails every real repo on day one is switched off on day two — this
354
+ * repo's own corpus opens at 26 over-long entries).
355
+ */
356
+ function checkContentBudgets(spec, specFile, maxEntryChars, maxSectionChars) {
357
+ const warns = [];
358
+ const entries = [
359
+ ["keyFiles", spec.keyFiles],
360
+ ["commands", spec.commands],
361
+ ];
362
+ for (const [field, map] of entries) {
363
+ if (!map || maxEntryChars <= 0)
364
+ continue;
365
+ for (const [key, description] of Object.entries(map)) {
366
+ if (description.length <= maxEntryChars)
367
+ continue;
368
+ warns.push({
369
+ type: "entry-too-long",
370
+ path: specFile,
371
+ message: `${field}[${JSON.stringify(key)}] description is ${String(description.length)} ` +
372
+ `characters (budget ${String(maxEntryChars)}). An entry is a pointer — say what the ` +
373
+ `file is for in one line and move the explanation into its own header, which is read ` +
374
+ `when someone opens it rather than on every request.`,
375
+ });
376
+ }
377
+ }
378
+ if (spec.sections && maxSectionChars > 0) {
379
+ for (const [name, body] of Object.entries(spec.sections)) {
380
+ // Fragment-valued sections are assembled later; only plain prose is
381
+ // measurable here, and it is the shape that grows.
382
+ if (typeof body !== "string" || body.length <= maxSectionChars)
383
+ continue;
384
+ warns.push({
385
+ type: "section-too-large",
386
+ path: specFile,
387
+ message: `section ${JSON.stringify(name)} is ${String(body.length)} characters ` +
388
+ `(budget ${String(maxSectionChars)}). The line guard cannot see this — long lines are ` +
389
+ `the normal shape of compiled prose — and every character is loaded on every request.`,
390
+ });
391
+ }
392
+ }
393
+ return warns;
394
+ }
322
395
  // A key-files entry is a POINTER, not an essay: the prose about why a file is
323
396
  // shaped the way it is belongs in that file's own header, where it is read when
324
397
  // the file is opened. Calibrated against this repo on 2026-09-08 — 285 entries,
@@ -343,7 +416,7 @@ function validateSectionContent(name, text, maxSectionLines) {
343
416
  break;
344
417
  }
345
418
  }
346
- const max = maxSectionLines ?? DEFAULT_MAX_SECTION_LINES;
419
+ const max = maxSectionLines ?? exports.DEFAULT_MAX_SECTION_LINES;
347
420
  if (contentLines.length > max) {
348
421
  errors.push({
349
422
  type: "section-too-long",
@@ -546,6 +619,7 @@ function compileClaude(spec, options = {}) {
546
619
  return {
547
620
  markdown,
548
621
  errors,
622
+ warnings: checkContentBudgets(spec, specFile, options.maxEntryChars ?? exports.DEFAULT_MAX_ENTRY_CHARS, options.maxSectionChars ?? exports.DEFAULT_MAX_SECTION_CHARS),
549
623
  linterResults: rules.linterResults,
550
624
  tokens,
551
625
  targets: allTargets,