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.
- package/dist/cli-commands.d.ts +1 -1
- package/dist/cli-commands.js +1 -0
- package/dist/cli-main.d.ts +2 -0
- package/dist/cli-main.js +84 -15
- package/dist/cli.js +51 -0
- package/dist/core/compile.d.ts +36 -6
- package/dist/core/compile.js +181 -49
- package/dist/core/config-schema.d.ts +9 -0
- package/dist/core/config-schema.js +15 -0
- package/dist/core/eval-driver.d.ts +9 -1
- package/dist/core/eval-spec-parse.d.ts +14 -0
- package/dist/core/eval-spec-parse.js +168 -0
- package/dist/core/markdown.d.ts +17 -0
- package/dist/core/markdown.js +57 -0
- package/dist/core/presets.d.ts +1 -1
- package/dist/core/presets.js +1 -0
- package/dist/core/rule-meta.js +12 -0
- package/dist/core/run-env.d.ts +46 -1
- package/dist/core/run-env.js +59 -0
- package/dist/core/spec.d.ts +45 -2
- package/dist/core/spec.js +16 -2
- package/dist/core/stub-rules.d.ts +199 -0
- package/dist/core/stub-rules.js +397 -0
- package/dist/core/types.d.ts +26 -0
- package/dist/eval-cache.d.ts +19 -1
- package/dist/eval-cache.js +1 -1
- package/dist/eval-entry.d.ts +28 -0
- package/dist/eval-entry.js +63 -10
- package/dist/eval-surface.d.ts +2 -0
- package/dist/eval.d.ts +80 -32
- package/dist/eval.js +293 -91
- package/dist/harness-test.d.ts +1 -1
- package/dist/services.d.ts +1 -1
- package/dist/services.js +1 -1
- package/dist/stub.d.ts +57 -0
- package/dist/stub.js +103 -0
- package/dist/test.d.ts +4 -1
- package/dist/test.js +8 -4
- package/dist/tool-stub.d.ts +24 -29
- package/dist/tool-stub.js +63 -74
- package/dist/vigilesrc.schema.json +94 -0
- package/package.json +1 -1
- package/skills/test-harness/references/observing-a-run.md +13 -12
- package/skills/test-harness/references/writing-tests.md +1 -0
package/dist/cli-commands.d.ts
CHANGED
|
@@ -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
|
package/dist/cli-commands.js
CHANGED
package/dist/cli-main.d.ts
CHANGED
|
@@ -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
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
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
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|
package/dist/core/compile.d.ts
CHANGED
|
@@ -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
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* channel discipline as
|
|
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
|
-
/**
|
|
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
|
*
|
package/dist/core/compile.js
CHANGED
|
@@ -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
|
-
|
|
344
|
+
...enforceWhy(rule),
|
|
313
345
|
].join("\n");
|
|
314
|
-
case "guidance":
|
|
315
|
-
|
|
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
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
-
|
|
638
|
-
|
|
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,
|