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
|
@@ -130,6 +130,15 @@ export declare const vigilesConfigSchema: z.ZodObject<{
|
|
|
130
130
|
"disallowed-tools-contract": z.ZodDefault<z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "error" | "warn", boolean | 0 | 1 | 2 | "error" | "warn" | "off">>>;
|
|
131
131
|
"description-overlap": z.ZodDefault<z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "error" | "warn", boolean | 0 | 1 | 2 | "error" | "warn" | "off">>>;
|
|
132
132
|
"skill-description-budget": z.ZodDefault<z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "error" | "warn", boolean | 0 | 1 | 2 | "error" | "warn" | "off">>>;
|
|
133
|
+
"compile-budget": z.ZodDefault<z.ZodUnion<readonly [z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "error" | "warn", boolean | 0 | 1 | 2 | "error" | "warn" | "off">>, z.ZodTuple<[z.ZodLiteral<"warn">, z.ZodObject<{
|
|
134
|
+
maxEntryChars: z.ZodOptional<z.ZodNumber>;
|
|
135
|
+
maxSectionChars: z.ZodOptional<z.ZodNumber>;
|
|
136
|
+
maxRulesChars: z.ZodOptional<z.ZodNumber>;
|
|
137
|
+
}, z.core.$strict>], null>, z.ZodTuple<[z.ZodLiteral<"error">, z.ZodObject<{
|
|
138
|
+
maxEntryChars: z.ZodOptional<z.ZodNumber>;
|
|
139
|
+
maxSectionChars: z.ZodOptional<z.ZodNumber>;
|
|
140
|
+
maxRulesChars: z.ZodOptional<z.ZodNumber>;
|
|
141
|
+
}, z.core.$strict>], null>]>>;
|
|
133
142
|
"frontmatter-valid": z.ZodDefault<z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "error" | "warn", boolean | 0 | 1 | 2 | "error" | "warn" | "off">>>;
|
|
134
143
|
"mcp-hook-target-resolves": z.ZodDefault<z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "error" | "warn", boolean | 0 | 1 | 2 | "error" | "warn" | "off">>>;
|
|
135
144
|
"lethal-trifecta": z.ZodDefault<z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "error" | "warn", boolean | 0 | 1 | 2 | "error" | "warn" | "off">>>;
|
|
@@ -122,6 +122,20 @@ const testCoverageConfig = zod_1.z
|
|
|
122
122
|
testExtension: zod_1.z.string().optional(),
|
|
123
123
|
})
|
|
124
124
|
.strict();
|
|
125
|
+
/**
|
|
126
|
+
* Thresholds for `compile-budget`, in characters. Each one left out keeps its
|
|
127
|
+
* built-in default; `0` turns that one budget off.
|
|
128
|
+
*/
|
|
129
|
+
const compileBudgetThresholds = zod_1.z
|
|
130
|
+
.object({
|
|
131
|
+
/** One `commands` description. */
|
|
132
|
+
maxEntryChars: zod_1.z.number().optional(),
|
|
133
|
+
/** One named prose section. */
|
|
134
|
+
maxSectionChars: zod_1.z.number().optional(),
|
|
135
|
+
/** The whole rendered Rules section. */
|
|
136
|
+
maxRulesChars: zod_1.z.number().optional(),
|
|
137
|
+
})
|
|
138
|
+
.strict();
|
|
125
139
|
/**
|
|
126
140
|
* Every validation rule, with its shipped default severity.
|
|
127
141
|
*
|
|
@@ -156,6 +170,7 @@ const rulesSchema = zod_1.z
|
|
|
156
170
|
"disallowed-tools-contract": severitySchema.default("warn"),
|
|
157
171
|
"description-overlap": severitySchema.default("warn"),
|
|
158
172
|
"skill-description-budget": severitySchema.default("warn"),
|
|
173
|
+
"compile-budget": withOptions(compileBudgetThresholds).default("warn"),
|
|
159
174
|
"frontmatter-valid": severitySchema.default("warn"),
|
|
160
175
|
"mcp-hook-target-resolves": severitySchema.default("warn"),
|
|
161
176
|
"lethal-trifecta": severitySchema.default("warn"),
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* runners re-export these names, so every existing import keeps resolving.
|
|
19
19
|
*/
|
|
20
20
|
import type { ToolCall, HookFire, ModelRequest } from "./harness-driver.js";
|
|
21
|
+
import type { StubCall } from "./stub-rules.js";
|
|
21
22
|
/** Per-run resource use, parsed from the terminal `result` event (0 when absent). */
|
|
22
23
|
export interface EvalUsage {
|
|
23
24
|
/** `total_cost_usd` reported by the harness binary. */
|
|
@@ -99,6 +100,13 @@ export interface Trace {
|
|
|
99
100
|
* Empty unless the stream was captured / the harness emits subagent events.
|
|
100
101
|
*/
|
|
101
102
|
readonly subagents?: readonly SubagentTrace[];
|
|
103
|
+
/**
|
|
104
|
+
* Every invocation of a tool stub during the run, in order — including calls a
|
|
105
|
+
* script the agent ran made itself (`execFile(gh, …)`), which never appear as
|
|
106
|
+
* a tool call. Each says which rule answered it, or that none did. Absent when
|
|
107
|
+
* the run had no stubs (eval tier: `stubs` on the spec).
|
|
108
|
+
*/
|
|
109
|
+
readonly stubCalls?: readonly StubCall[];
|
|
102
110
|
/** Final contents of a file under the working dir, or null if absent. */
|
|
103
111
|
file(path: string): string | null;
|
|
104
112
|
}
|
|
@@ -137,7 +145,7 @@ export interface AgentRunArgs {
|
|
|
137
145
|
* When true, `env` is the COMPLETE spawn environment (an ephemeral run env from
|
|
138
146
|
* `ephemeralRunEnv`) — the runner does NOT prepend `process.env`, so the
|
|
139
147
|
* real `$HOME` / secrets are scrubbed. Default false: `env` is an overlay over
|
|
140
|
-
* `process.env` (the byte-identical-to-today path). Set only by `
|
|
148
|
+
* `process.env` (the byte-identical-to-today path). Set only by an ephemeral `env`.
|
|
141
149
|
*/
|
|
142
150
|
readonly replaceEnv?: boolean;
|
|
143
151
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ToolStub } from "./stub-rules.js";
|
|
2
|
+
import { type RunEnv } from "./run-env.js";
|
|
3
|
+
/**
|
|
4
|
+
* Parse an eval spec's `stubs`. Absent → `[]`. Throws, naming the field, on the
|
|
5
|
+
* old shape, a malformed rule, a stateful RegExp, or two stubs of one name.
|
|
6
|
+
*/
|
|
7
|
+
export declare function parseToolStubs(raw: unknown, caller: string): readonly ToolStub[];
|
|
8
|
+
/**
|
|
9
|
+
* Parse an eval spec's `env`, including its seeded HOME: every seed path is
|
|
10
|
+
* HOME-relative, stays inside HOME, and does not overwrite one of the harness's
|
|
11
|
+
* own auth files (`keepHomeFiles`). Throws before any trial on a bad value.
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseRunEnv(raw: unknown, caller: string, keepHomeFiles: readonly string[]): RunEnv;
|
|
14
|
+
//# sourceMappingURL=eval-spec-parse.d.ts.map
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseToolStubs = parseToolStubs;
|
|
4
|
+
exports.parseRunEnv = parseRunEnv;
|
|
5
|
+
/**
|
|
6
|
+
* The eval spec boundary for `stubs` and `env` — parsed ONCE, before anything is
|
|
7
|
+
* packaged or spent, into the plain data the runner then trusts.
|
|
8
|
+
*
|
|
9
|
+
* An eval file is often plain JavaScript, so no type checks its literal. A shape
|
|
10
|
+
* the runner would silently mis-read must fail here, naming the field and the
|
|
11
|
+
* rewrite. Two such shapes are the reason this module exists:
|
|
12
|
+
*
|
|
13
|
+
* - the old argv-blind stub `{ name, stdout }`, which printed one answer for
|
|
14
|
+
* every invocation (a fabricated observation for every command nobody
|
|
15
|
+
* anticipated);
|
|
16
|
+
* - a spec with no `env`, which used to inherit your HOME and environment by
|
|
17
|
+
* default — so a comparison measured the author's machine.
|
|
18
|
+
*
|
|
19
|
+
* zod lives HERE and not in `stub-rules.ts`: the stub process reads its rules
|
|
20
|
+
* once per `gh` call and must not pay for a schema library; the rules file it
|
|
21
|
+
* reads is this parse's output, written by vigiles.
|
|
22
|
+
*/
|
|
23
|
+
const zod_1 = require("zod");
|
|
24
|
+
const stub_rules_js_1 = require("./stub-rules.js");
|
|
25
|
+
const run_env_js_1 = require("./run-env.js");
|
|
26
|
+
/** `stubs[0].rules[1].argv` from a zod issue path. */
|
|
27
|
+
function pathText(root, path) {
|
|
28
|
+
return path.reduce((acc, k) => typeof k === "number" ? `${acc}[${String(k)}]` : `${acc}.${String(k)}`, root);
|
|
29
|
+
}
|
|
30
|
+
function refuse(caller, root, error) {
|
|
31
|
+
const lines = error.issues.map((i) => `${caller}: ${pathText(root, i.path)}: ${i.message}`);
|
|
32
|
+
throw new Error(lines.join("\n"));
|
|
33
|
+
}
|
|
34
|
+
// --- stubs ---------------------------------------------------------------------
|
|
35
|
+
const regexToken = zod_1.z.instanceof(RegExp).refine((r) => !r.global && !r.sticky, {
|
|
36
|
+
message: "a RegExp token may not carry the flags g and y — test() would carry lastIndex from one call to the next",
|
|
37
|
+
});
|
|
38
|
+
const token = zod_1.z.union([zod_1.z.string(), regexToken]);
|
|
39
|
+
const restToken = zod_1.z
|
|
40
|
+
.strictObject({ kind: zod_1.z.literal("rest") })
|
|
41
|
+
.transform(() => stub_rules_js_1.ARGV_REST);
|
|
42
|
+
/** A plain object (not an array, not a RegExp), readable by key. */
|
|
43
|
+
const isObj = (v) => typeof v === "object" &&
|
|
44
|
+
v !== null &&
|
|
45
|
+
!Array.isArray(v) &&
|
|
46
|
+
!(v instanceof RegExp);
|
|
47
|
+
const isRestToken = (t) => isObj(t) && t.kind === "rest";
|
|
48
|
+
const argvPattern = zod_1.z
|
|
49
|
+
.array(zod_1.z.union([token, restToken]))
|
|
50
|
+
.refine((p) => p.slice(0, -1).every((t) => !isRestToken(t)), {
|
|
51
|
+
message: "experimental_stub.rest may appear only as the last token",
|
|
52
|
+
});
|
|
53
|
+
const answerFields = {
|
|
54
|
+
stdout: zod_1.z.string().optional(),
|
|
55
|
+
stderr: zod_1.z.string().optional(),
|
|
56
|
+
exitCode: zod_1.z.number().int().min(0).max(255).optional(),
|
|
57
|
+
};
|
|
58
|
+
const reply = zod_1.z.discriminatedUnion("kind", [
|
|
59
|
+
zod_1.z.strictObject({ kind: zod_1.z.literal("always"), ...answerFields }),
|
|
60
|
+
zod_1.z.strictObject({
|
|
61
|
+
kind: zod_1.z.literal("inOrder"),
|
|
62
|
+
answers: zod_1.z
|
|
63
|
+
.array(zod_1.z.strictObject(answerFields))
|
|
64
|
+
.min(1, { message: "an inOrder reply needs at least one answer" }),
|
|
65
|
+
}),
|
|
66
|
+
]);
|
|
67
|
+
const rule = zod_1.z
|
|
68
|
+
.strictObject({
|
|
69
|
+
argv: argvPattern,
|
|
70
|
+
contains: zod_1.z.array(token).optional(),
|
|
71
|
+
reply,
|
|
72
|
+
})
|
|
73
|
+
.refine((r) => r.contains === undefined || isRestToken(r.argv.at(-1)), {
|
|
74
|
+
message: "`contains` matches tokens after the positional prefix, so it needs a trailing experimental_stub.rest in `argv`",
|
|
75
|
+
path: ["contains"],
|
|
76
|
+
});
|
|
77
|
+
const stub = zod_1.z.strictObject({
|
|
78
|
+
name: zod_1.z
|
|
79
|
+
.string()
|
|
80
|
+
.min(1)
|
|
81
|
+
.refine((n) => !/[/\\]/.test(n) && n !== "." && n !== "..", {
|
|
82
|
+
message: "a bare binary name, as it is looked up on PATH (no path separator)",
|
|
83
|
+
}),
|
|
84
|
+
rules: zod_1.z.array(rule).min(1, {
|
|
85
|
+
message: "a stub needs at least one rule — one per command the agent runs",
|
|
86
|
+
}),
|
|
87
|
+
});
|
|
88
|
+
/** Is this the removed `{ name, stdout?, stderr?, exitCode? }` shape? */
|
|
89
|
+
const isOldShape = (s) => isObj(s) &&
|
|
90
|
+
!("rules" in s) &&
|
|
91
|
+
["stdout", "stderr", "exitCode"].some((k) => k in s);
|
|
92
|
+
function oldShapeMessage(caller, i, s) {
|
|
93
|
+
const name = typeof s.name === "string" ? s.name : "<tool>";
|
|
94
|
+
const stdout = typeof s.stdout === "string" ? JSON.stringify(s.stdout) : '"…"';
|
|
95
|
+
return (`${caller}: stubs[${String(i)}] (${JSON.stringify(name)}) is the old shape \`{ name, stdout }\`, which printed one answer for EVERY invocation — ` +
|
|
96
|
+
`a command nobody scripted got that answer as if the tool had said it. A stub is now a list of rules, one per command the agent runs:\n` +
|
|
97
|
+
` experimental_stub(${JSON.stringify(name)}, [\n` +
|
|
98
|
+
` { argv: ["<command>", experimental_stub.rest], reply: { kind: "always", stdout: ${stdout} } },\n` +
|
|
99
|
+
` // one rule per further command; an invocation no rule answers fails the run\n` +
|
|
100
|
+
` ])\n` +
|
|
101
|
+
` The argv a run used is printed when it goes unanswered, so the first run tells you which rules to add.`);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Parse an eval spec's `stubs`. Absent → `[]`. Throws, naming the field, on the
|
|
105
|
+
* old shape, a malformed rule, a stateful RegExp, or two stubs of one name.
|
|
106
|
+
*/
|
|
107
|
+
function parseToolStubs(raw, caller) {
|
|
108
|
+
if (raw === undefined)
|
|
109
|
+
return [];
|
|
110
|
+
const items = Array.isArray(raw) ? raw : [];
|
|
111
|
+
const old = items.findIndex(isOldShape);
|
|
112
|
+
const oldItem = items[old];
|
|
113
|
+
if (isOldShape(oldItem))
|
|
114
|
+
throw new Error(oldShapeMessage(caller, old, oldItem));
|
|
115
|
+
const parsed = zod_1.z.array(stub).safeParse(raw);
|
|
116
|
+
if (!parsed.success)
|
|
117
|
+
refuse(caller, "stubs", parsed.error);
|
|
118
|
+
const names = parsed.data.map((s) => s.name);
|
|
119
|
+
const dup = names.find((n, i) => names.indexOf(n) !== i);
|
|
120
|
+
if (dup !== undefined)
|
|
121
|
+
throw new Error(`${caller}: two stubs named ${JSON.stringify(dup)} — one binary has one stub; put both rule lists in it.`);
|
|
122
|
+
// The zod output has the shape; `toToolStub` gives it the type, without a cast.
|
|
123
|
+
return parsed.data.map(stub_rules_js_1.toToolStub);
|
|
124
|
+
}
|
|
125
|
+
// --- env -----------------------------------------------------------------------
|
|
126
|
+
const homeSeed = zod_1.z.strictObject({
|
|
127
|
+
kind: zod_1.z.literal("files"),
|
|
128
|
+
files: zod_1.z.record(zod_1.z.string(), zod_1.z.string()),
|
|
129
|
+
});
|
|
130
|
+
const runEnv = zod_1.z.discriminatedUnion("kind", [
|
|
131
|
+
zod_1.z.strictObject({ kind: zod_1.z.literal("ephemeral"), home: homeSeed.optional() }),
|
|
132
|
+
zod_1.z.strictObject({
|
|
133
|
+
kind: zod_1.z.literal("inherit"),
|
|
134
|
+
reason: zod_1.z.string().refine((r) => r.trim() !== "", {
|
|
135
|
+
message: "say why this run must see your real HOME and environment",
|
|
136
|
+
}),
|
|
137
|
+
}),
|
|
138
|
+
]);
|
|
139
|
+
function envRequiredMessage(caller) {
|
|
140
|
+
return (`${caller}: \`env\` is required — say where each trial runs:\n` +
|
|
141
|
+
` env: { kind: "ephemeral" } a throwaway HOME and a scrubbed environment; only the harness's own auth passes\n` +
|
|
142
|
+
` env: { kind: "inherit", reason: "…" } your real HOME and environment; the reason is printed with the report\n` +
|
|
143
|
+
` There is no default. Inheriting measures your machine (your config, your tokens), and an ephemeral run\n` +
|
|
144
|
+
` fails on its first trial if your auth is not where the harness expects it — so the choice is yours to make.`);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Parse an eval spec's `env`, including its seeded HOME: every seed path is
|
|
148
|
+
* HOME-relative, stays inside HOME, and does not overwrite one of the harness's
|
|
149
|
+
* own auth files (`keepHomeFiles`). Throws before any trial on a bad value.
|
|
150
|
+
*/
|
|
151
|
+
function parseRunEnv(raw, caller, keepHomeFiles) {
|
|
152
|
+
if (raw === undefined)
|
|
153
|
+
throw new Error(envRequiredMessage(caller));
|
|
154
|
+
if (isObj(raw) && raw.kind === "inherit" && "home" in raw)
|
|
155
|
+
throw new Error(`${caller}: env.home: \`home\` exists only on an ephemeral env: seeding would write into your real HOME. ` +
|
|
156
|
+
`Use env: { kind: "ephemeral", home: … }.`);
|
|
157
|
+
const parsed = runEnv.safeParse(raw);
|
|
158
|
+
if (!parsed.success)
|
|
159
|
+
refuse(caller, "env", parsed.error);
|
|
160
|
+
const env = parsed.data;
|
|
161
|
+
if (env.kind === "ephemeral" && env.home !== undefined) {
|
|
162
|
+
const plan = (0, run_env_js_1.planHome)(env.home, keepHomeFiles);
|
|
163
|
+
if (plan.kind === "refused")
|
|
164
|
+
throw new Error(`${caller}: env.home.files: ${plan.reason}`);
|
|
165
|
+
}
|
|
166
|
+
return env;
|
|
167
|
+
}
|
|
168
|
+
//# sourceMappingURL=eval-spec-parse.js.map
|
package/dist/core/markdown.d.ts
CHANGED
|
@@ -118,4 +118,21 @@ export interface MarkdownRef {
|
|
|
118
118
|
* which is not recovered — the regexes this replaces did not recover it either.
|
|
119
119
|
*/
|
|
120
120
|
export declare function markdownRefs(src: string): MarkdownRef[];
|
|
121
|
+
/**
|
|
122
|
+
* The fragment ids a markdown file defines — what `file.md#<id>` can land on.
|
|
123
|
+
*
|
|
124
|
+
* Two sources, because both are how real files are linked into:
|
|
125
|
+
*
|
|
126
|
+
* - every HEADING, slugged the way GitHub slugs it (lowercase; drop everything
|
|
127
|
+
* that is not a letter, number, space, `-` or `_`; spaces to `-`; a repeated
|
|
128
|
+
* slug gets `-1`, `-2`, …), with the heading's text taken from the parser's
|
|
129
|
+
* inline token, so emphasis and code spans do not leak their markers in;
|
|
130
|
+
* - every explicit `id="…"` / `name="…"` on raw HTML (`<a id="why"></a>`), which
|
|
131
|
+
* is how a file in a non-Latin language gives its sections stable ASCII ids.
|
|
132
|
+
*
|
|
133
|
+
* Other renderers slug differently (GitLab, mkdocs). A link that resolves on
|
|
134
|
+
* GitHub and nowhere else is still a link this accepts; one that resolves
|
|
135
|
+
* NOWHERE — a renamed heading — is what it exists to catch.
|
|
136
|
+
*/
|
|
137
|
+
export declare function markdownAnchors(src: string): ReadonlySet<string>;
|
|
121
138
|
//# sourceMappingURL=markdown.d.ts.map
|
package/dist/core/markdown.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.fencedLineFlags = fencedLineFlags;
|
|
|
4
4
|
exports.proseLines = proseLines;
|
|
5
5
|
exports.fencedCodeBlocks = fencedCodeBlocks;
|
|
6
6
|
exports.markdownRefs = markdownRefs;
|
|
7
|
+
exports.markdownAnchors = markdownAnchors;
|
|
7
8
|
const MarkdownIt = () => require("markdown-it");
|
|
8
9
|
// One reusable parser; parse() is stateless across calls.
|
|
9
10
|
let mdCached = null;
|
|
@@ -233,4 +234,60 @@ function refsInInline(children, line, out) {
|
|
|
233
234
|
out.push({ kind: "code", value: child.content, line });
|
|
234
235
|
}
|
|
235
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* The fragment ids a markdown file defines — what `file.md#<id>` can land on.
|
|
239
|
+
*
|
|
240
|
+
* Two sources, because both are how real files are linked into:
|
|
241
|
+
*
|
|
242
|
+
* - every HEADING, slugged the way GitHub slugs it (lowercase; drop everything
|
|
243
|
+
* that is not a letter, number, space, `-` or `_`; spaces to `-`; a repeated
|
|
244
|
+
* slug gets `-1`, `-2`, …), with the heading's text taken from the parser's
|
|
245
|
+
* inline token, so emphasis and code spans do not leak their markers in;
|
|
246
|
+
* - every explicit `id="…"` / `name="…"` on raw HTML (`<a id="why"></a>`), which
|
|
247
|
+
* is how a file in a non-Latin language gives its sections stable ASCII ids.
|
|
248
|
+
*
|
|
249
|
+
* Other renderers slug differently (GitLab, mkdocs). A link that resolves on
|
|
250
|
+
* GitHub and nowhere else is still a link this accepts; one that resolves
|
|
251
|
+
* NOWHERE — a renamed heading — is what it exists to catch.
|
|
252
|
+
*/
|
|
253
|
+
function markdownAnchors(src) {
|
|
254
|
+
// The html-enabled parser: with the shared one, `<a id="x">` is paragraph
|
|
255
|
+
// text and its id would be invisible.
|
|
256
|
+
const tokens = mdProse().parse(src, {});
|
|
257
|
+
const headings = tokens.flatMap((t, i) => t.type === "heading_open" ? [headingSlug(tokens[i + 1])] : []);
|
|
258
|
+
const explicit = tokens
|
|
259
|
+
.flatMap(htmlOf)
|
|
260
|
+
.flatMap((h) => [...h.matchAll(HTML_ID)].map((m) => m[1]));
|
|
261
|
+
return new Set([...numberRepeats(headings), ...explicit]);
|
|
262
|
+
}
|
|
263
|
+
/** An explicit `id="…"` / `name="…"` on raw HTML. */
|
|
264
|
+
const HTML_ID = /\b(?:id|name)\s*=\s*["']([^"']+)["']/g;
|
|
265
|
+
/** A heading's GitHub slug before repeats are numbered, from its inline token. */
|
|
266
|
+
function headingSlug(inline) {
|
|
267
|
+
return (inline?.children ?? [])
|
|
268
|
+
.filter((c) => c.type === "text" || c.type === "code_inline")
|
|
269
|
+
.map((c) => c.content)
|
|
270
|
+
.join("")
|
|
271
|
+
.toLowerCase()
|
|
272
|
+
.replace(/[^\p{L}\p{N}\s_-]/gu, "")
|
|
273
|
+
.trim()
|
|
274
|
+
.replace(/\s/g, "-");
|
|
275
|
+
}
|
|
276
|
+
/** GitHub's numbering of a repeated slug: `x`, `x-1`, `x-2`, … */
|
|
277
|
+
function numberRepeats(bases) {
|
|
278
|
+
return bases.map((base, i) => {
|
|
279
|
+
const k = bases.slice(0, i).filter((b) => b === base).length;
|
|
280
|
+
return k === 0 ? base : `${base}-${String(k)}`;
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
/** The raw HTML a token carries: a block, an inline, or an inline run's HTML. */
|
|
284
|
+
function htmlOf(t) {
|
|
285
|
+
if (t.type === "html_block" || t.type === "html_inline")
|
|
286
|
+
return [t.content];
|
|
287
|
+
if (t.type !== "inline")
|
|
288
|
+
return [];
|
|
289
|
+
return (t.children ?? [])
|
|
290
|
+
.filter((c) => c.type === "html_inline")
|
|
291
|
+
.map((c) => c.content);
|
|
292
|
+
}
|
|
236
293
|
//# sourceMappingURL=markdown.js.map
|
package/dist/core/presets.d.ts
CHANGED
|
@@ -30,7 +30,7 @@ import type { RulesConfig } from "./types.js";
|
|
|
30
30
|
* `untested-*`): a clean repo fails those until the work is done, so they stay
|
|
31
31
|
* an explicit opt-in (`init --strict`).
|
|
32
32
|
*/
|
|
33
|
-
export declare const RECOMMENDED_RULES: readonly ["subagent-tool-contract", "subagent-frontmatter", "hook-events", "hook-script-exists", "mcp-config", "mcp-tool-resolves", "mcp-hook-target-resolves", "disallowed-tools-contract", "description-overlap"];
|
|
33
|
+
export declare const RECOMMENDED_RULES: readonly ["subagent-tool-contract", "subagent-frontmatter", "hook-events", "hook-script-exists", "mcp-config", "mcp-tool-resolves", "mcp-hook-target-resolves", "disallowed-tools-contract", "description-overlap", "compile-budget"];
|
|
34
34
|
/** The preset names `extends` accepts. One today; the tuple is the enum. */
|
|
35
35
|
export declare const PRESET_NAMES: readonly ["vigiles:recommended"];
|
|
36
36
|
/** A preset name `extends` accepts. */
|
package/dist/core/presets.js
CHANGED
|
@@ -24,6 +24,7 @@ exports.RECOMMENDED_RULES = [
|
|
|
24
24
|
"mcp-hook-target-resolves",
|
|
25
25
|
"disallowed-tools-contract",
|
|
26
26
|
"description-overlap",
|
|
27
|
+
"compile-budget",
|
|
27
28
|
];
|
|
28
29
|
/** The preset names `extends` accepts. One today; the tuple is the enum. */
|
|
29
30
|
exports.PRESET_NAMES = ["vigiles:recommended"];
|
package/dist/core/rule-meta.js
CHANGED
|
@@ -226,6 +226,18 @@ exports.RULE_META = {
|
|
|
226
226
|
summary: "The always-loaded instruction weight did not move off its committed baseline.",
|
|
227
227
|
detector: "compareToBaseline",
|
|
228
228
|
},
|
|
229
|
+
"compile-budget": {
|
|
230
|
+
id: "compile-budget",
|
|
231
|
+
// Exact once a threshold is chosen: a length or a count read off the spec
|
|
232
|
+
// and its rendered output. The threshold is the judgment: the default stays
|
|
233
|
+
// `warn`, and `vigiles:recommended` sets `error`.
|
|
234
|
+
bucket: "structural-closed",
|
|
235
|
+
surface: ["instruction"],
|
|
236
|
+
defaultSeverity: "warn",
|
|
237
|
+
summary: "A compiled instruction file stays within its size budgets (command entries, sections, the Rules total).",
|
|
238
|
+
detector: "checkContentBudgets",
|
|
239
|
+
upstreamPrevention: "compile measures the spec before writing; a render policy (enforce render, guidance details) meets a budget without deleting a rule",
|
|
240
|
+
},
|
|
229
241
|
"skill-description-budget": {
|
|
230
242
|
id: "skill-description-budget",
|
|
231
243
|
bucket: "heuristic-behavioral",
|
package/dist/core/run-env.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* - SCRUBBED (`scrubbedRunEnv`): a throwaway HOME/TMPDIR, the OS essentials, the
|
|
8
8
|
* harness's declared auth, and the caller's own named extras. Everything else
|
|
9
|
-
* is dropped. The eval tier's `
|
|
9
|
+
* is dropped. The eval tier's `env: { kind: "ephemeral" }` and the harness-tier preflight of
|
|
10
10
|
* `experimental_outputStyleArms` use it.
|
|
11
11
|
* - INHERITED (`withoutSessionIdentity`): the caller's env as-is, minus the
|
|
12
12
|
* harness's declared session identity. The default spawn of both tiers.
|
|
@@ -46,5 +46,50 @@ export declare function scrubbedRunEnv(base: EnvLike, opts: {
|
|
|
46
46
|
* a plain spawn env. Without a policy nothing is removed.
|
|
47
47
|
*/
|
|
48
48
|
export declare function withoutSessionIdentity(base: EnvLike, policy?: RunEnvPolicy): Readonly<Record<string, string>>;
|
|
49
|
+
/** Files to write under a throwaway HOME: HOME-relative POSIX path → contents. */
|
|
50
|
+
export type HomeFiles = Readonly<Record<string, string>>;
|
|
51
|
+
/**
|
|
52
|
+
* Where a throwaway HOME's starting contents come from. A FILE map, so an empty
|
|
53
|
+
* directory cannot be expressed — Claude Code's startup housekeeping removes
|
|
54
|
+
* empty task directories, and a seed that could hold one would lose it.
|
|
55
|
+
*/
|
|
56
|
+
export interface HomeSeed {
|
|
57
|
+
readonly kind: "files";
|
|
58
|
+
readonly files: HomeFiles;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Where a run executes. Two cases, and `home` exists only on `ephemeral`: there
|
|
62
|
+
* is no way to write "seed my real HOME".
|
|
63
|
+
*
|
|
64
|
+
* - `ephemeral` — a throwaway HOME (seeded from `home`, then given the
|
|
65
|
+
* harness's own auth files) and a scrubbed environment.
|
|
66
|
+
* - `inherit` — your real HOME and environment, minus the parent session's
|
|
67
|
+
* identity. `reason` says why, and is printed with the report.
|
|
68
|
+
*/
|
|
69
|
+
export type RunEnv = {
|
|
70
|
+
readonly kind: "ephemeral";
|
|
71
|
+
readonly home?: HomeSeed;
|
|
72
|
+
} | {
|
|
73
|
+
readonly kind: "inherit";
|
|
74
|
+
readonly reason: string;
|
|
75
|
+
};
|
|
76
|
+
/** One file a seed writes, HOME-relative. */
|
|
77
|
+
export interface HomeFile {
|
|
78
|
+
readonly path: string;
|
|
79
|
+
readonly contents: string;
|
|
80
|
+
}
|
|
81
|
+
/** The files a seed writes, sorted by path — or why the seed is refused. */
|
|
82
|
+
export type HomePlan = {
|
|
83
|
+
readonly kind: "ok";
|
|
84
|
+
readonly files: readonly HomeFile[];
|
|
85
|
+
} | {
|
|
86
|
+
readonly kind: "refused";
|
|
87
|
+
readonly reason: string;
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Validate a seed and list the files it writes. Pure. `keepHomeFiles` are the
|
|
91
|
+
* harness's auth files (`RunEnvPolicy.keepHomeFiles`), copied in after the seed.
|
|
92
|
+
*/
|
|
93
|
+
export declare function planHome(seed: HomeSeed | undefined, keepHomeFiles: readonly string[]): HomePlan;
|
|
49
94
|
export {};
|
|
50
95
|
//# sourceMappingURL=run-env.d.ts.map
|
package/dist/core/run-env.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.OS_ESSENTIAL_ENV = void 0;
|
|
4
4
|
exports.scrubbedRunEnv = scrubbedRunEnv;
|
|
5
5
|
exports.withoutSessionIdentity = withoutSessionIdentity;
|
|
6
|
+
exports.planHome = planHome;
|
|
6
7
|
/**
|
|
7
8
|
* What ANY spawned process needs to function, whatever the harness: resolve
|
|
8
9
|
* binaries (`PATH`) and keep the locale and terminal (`LANG`, `LC_*`, `TERM`).
|
|
@@ -48,4 +49,62 @@ function withoutSessionIdentity(base, policy) {
|
|
|
48
49
|
function defined(base, keep) {
|
|
49
50
|
return Object.fromEntries(Object.entries(base).filter((e) => e[1] !== undefined && keep(e[0])));
|
|
50
51
|
}
|
|
52
|
+
/** Why `path` cannot be a HOME-relative seed path, or undefined when it can. */
|
|
53
|
+
function seedPathProblem(path, keepHomeFiles) {
|
|
54
|
+
if (path === "")
|
|
55
|
+
return "an empty path";
|
|
56
|
+
if (path.startsWith("/") || path.includes("\\"))
|
|
57
|
+
return `${JSON.stringify(path)} is not a HOME-relative POSIX path`;
|
|
58
|
+
const parts = path.split("/");
|
|
59
|
+
if (parts.some((p) => p === "" || p === "." || p === ".."))
|
|
60
|
+
return `${JSON.stringify(path)} must stay inside HOME and be spelled plainly (no "", ".", or ".." segments)`;
|
|
61
|
+
if (keepHomeFiles.includes(path))
|
|
62
|
+
return `${JSON.stringify(path)} is the harness's own auth file, which the run copies in itself; a seed may not replace it`;
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The pairs that cannot both exist: one path written as a FILE while another
|
|
67
|
+
* needs it to be a DIRECTORY (`a` and `a/b`). Over the seed's paths and the
|
|
68
|
+
* harness's auth files, as a seed file standing on an auth file's parent
|
|
69
|
+
* (`.claude`) would fail the same way. Auth files among themselves are the
|
|
70
|
+
* harness's own business. Without this the run fails mid-trial with a raw
|
|
71
|
+
* `EEXIST`/`ENOTDIR`, after the trial's directories were built.
|
|
72
|
+
*/
|
|
73
|
+
function seedConflicts(paths, keepHomeFiles) {
|
|
74
|
+
const isSeed = (p) => paths.includes(p);
|
|
75
|
+
const label = (p) => isSeed(p)
|
|
76
|
+
? JSON.stringify(p)
|
|
77
|
+
: `${JSON.stringify(p)} (the harness's own auth file)`;
|
|
78
|
+
const all = [...paths, ...keepHomeFiles];
|
|
79
|
+
return all.flatMap((file) => all
|
|
80
|
+
.filter((under) => under.startsWith(`${file}/`))
|
|
81
|
+
.filter((under) => isSeed(file) || isSeed(under))
|
|
82
|
+
.map((under) => `${label(file)} is written as a file but ${label(under)} needs it to be a directory`));
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Validate a seed and list the files it writes. Pure. `keepHomeFiles` are the
|
|
86
|
+
* harness's auth files (`RunEnvPolicy.keepHomeFiles`), copied in after the seed.
|
|
87
|
+
*/
|
|
88
|
+
function planHome(seed, keepHomeFiles) {
|
|
89
|
+
if (seed === undefined)
|
|
90
|
+
return { kind: "ok", files: [] };
|
|
91
|
+
const paths = Object.keys(seed.files).sort();
|
|
92
|
+
if (paths.length === 0)
|
|
93
|
+
return {
|
|
94
|
+
kind: "refused",
|
|
95
|
+
reason: "a files seed with no files — omit `home` for an empty HOME",
|
|
96
|
+
};
|
|
97
|
+
const problems = paths.flatMap((p) => {
|
|
98
|
+
const why = seedPathProblem(p, keepHomeFiles);
|
|
99
|
+
return why === undefined ? [] : [why];
|
|
100
|
+
});
|
|
101
|
+
// Conflicts are read over well-formed paths only: a "" or "/x" would pair with everything.
|
|
102
|
+
const refusals = problems.length > 0 ? problems : seedConflicts(paths, keepHomeFiles);
|
|
103
|
+
if (refusals.length > 0)
|
|
104
|
+
return { kind: "refused", reason: refusals.join("; ") };
|
|
105
|
+
return {
|
|
106
|
+
kind: "ok",
|
|
107
|
+
files: paths.map((path) => ({ path, contents: seed.files[path] ?? "" })),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
51
110
|
//# sourceMappingURL=run-env.js.map
|
package/dist/core/spec.d.ts
CHANGED
|
@@ -94,6 +94,30 @@ export interface OpenToolVocabulary extends ToolVocabulary {
|
|
|
94
94
|
* untyped surface accepts any tools.
|
|
95
95
|
*/
|
|
96
96
|
export type AllowedAt<P extends AuthoredPurity | undefined, V extends ToolVocabulary> = P extends "pure" ? V["readOnly"] : P extends "bounded" ? V["bounded"] : string;
|
|
97
|
+
/**
|
|
98
|
+
* Where a rule's long text lives when the compiled file carries a short form —
|
|
99
|
+
* a `file()` reference (verified to exist, like every other one) and an
|
|
100
|
+
* optional anchor inside it (verified to be a heading or an explicit
|
|
101
|
+
* `<a id="…">` in that file). Rendered as a `[Details](path#anchor)` link.
|
|
102
|
+
*/
|
|
103
|
+
export interface RuleDetails {
|
|
104
|
+
readonly file: FileRef;
|
|
105
|
+
readonly anchor?: string;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* How much of an `enforce()` rule the compiled file carries (#355).
|
|
109
|
+
*
|
|
110
|
+
* - `"full"` (default) — the rule id and the whole `why`, as always.
|
|
111
|
+
* - `"line"` — the rule id and the first sentence of `why`.
|
|
112
|
+
* - `"id"` — the rule id only: the linter already holds the rule, so the prose
|
|
113
|
+
* is a second copy that costs tokens on every request and can drift.
|
|
114
|
+
*
|
|
115
|
+
* Whatever is rendered, the rule stays whole in the SPEC — lint, coverage, the
|
|
116
|
+
* audit rule map and `enforce-rules-format` read the spec, not the markdown —
|
|
117
|
+
* and the integrity hash is taken over the RENDERED file, so it stays a
|
|
118
|
+
* function of what the model actually sees.
|
|
119
|
+
*/
|
|
120
|
+
export type RuleRender = "full" | "line" | "id";
|
|
97
121
|
/** A rule delegated to an external tool (linter, ast-grep, dependency-cruiser, etc.) or to a vigiles-internal check. */
|
|
98
122
|
export interface EnforceRule {
|
|
99
123
|
readonly _kind: "enforce";
|
|
@@ -101,12 +125,29 @@ export interface EnforceRule {
|
|
|
101
125
|
readonly why: string;
|
|
102
126
|
/** Skip verification for this rule. Default: true (verify). */
|
|
103
127
|
readonly verify: boolean;
|
|
128
|
+
/** How much of the rule the compiled file carries. Default `"full"`. */
|
|
129
|
+
readonly render?: RuleRender;
|
|
130
|
+
/** Where the long text lives; rendered as a verified link. */
|
|
131
|
+
readonly details?: RuleDetails;
|
|
104
132
|
}
|
|
105
133
|
/** A guidance-only rule (prose, no enforcement). */
|
|
106
134
|
export interface GuidanceRule {
|
|
107
135
|
readonly _kind: "guidance";
|
|
108
136
|
readonly text: string;
|
|
137
|
+
/** Where the long text lives; rendered as a verified link after `text`. */
|
|
138
|
+
readonly details?: RuleDetails;
|
|
109
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* The `details` half of a rule's options. An `anchor` is only meaningful
|
|
142
|
+
* inside a `details` file, so the type refuses one without the other.
|
|
143
|
+
*/
|
|
144
|
+
export type RuleDetailsOptions = {
|
|
145
|
+
readonly details: FileRef;
|
|
146
|
+
readonly anchor?: string;
|
|
147
|
+
} | {
|
|
148
|
+
readonly details?: undefined;
|
|
149
|
+
readonly anchor?: never;
|
|
150
|
+
};
|
|
110
151
|
/** A reactive rule: runs a command when watched files change. */
|
|
111
152
|
export interface GuardRule {
|
|
112
153
|
readonly _kind: "guard";
|
|
@@ -130,13 +171,15 @@ export type Rule = EnforceRule | GuidanceRule | GuardRule;
|
|
|
130
171
|
*/
|
|
131
172
|
export declare function enforce(ref: NoInfer<StrictLinterRule> | VigilesRef, why: string, options?: {
|
|
132
173
|
verify?: boolean;
|
|
133
|
-
|
|
174
|
+
render?: RuleRender;
|
|
175
|
+
} & RuleDetailsOptions): EnforceRule;
|
|
134
176
|
/**
|
|
135
177
|
* Declare a guidance-only rule.
|
|
136
178
|
*
|
|
137
179
|
* guidance("Google unfamiliar APIs before implementing.")
|
|
180
|
+
* guidance("Short statement.", { details: file("docs/rules/x.md"), anchor: "why" })
|
|
138
181
|
*/
|
|
139
|
-
export declare function guidance(text: string): GuidanceRule;
|
|
182
|
+
export declare function guidance(text: string, options?: RuleDetailsOptions): GuidanceRule;
|
|
140
183
|
/**
|
|
141
184
|
* Declare a reactive guard: runs a command when watched files change.
|
|
142
185
|
*
|
package/dist/core/spec.js
CHANGED
|
@@ -55,6 +55,13 @@ exports.BUILTIN_LINTERS = [
|
|
|
55
55
|
"golangci-lint",
|
|
56
56
|
"cedar",
|
|
57
57
|
];
|
|
58
|
+
const detailsOf = (o) => {
|
|
59
|
+
if (!o?.details)
|
|
60
|
+
return undefined;
|
|
61
|
+
return o.anchor === undefined
|
|
62
|
+
? { file: o.details }
|
|
63
|
+
: { file: o.details, anchor: o.anchor };
|
|
64
|
+
};
|
|
58
65
|
// ---------------------------------------------------------------------------
|
|
59
66
|
// Builder functions
|
|
60
67
|
// ---------------------------------------------------------------------------
|
|
@@ -72,21 +79,28 @@ exports.BUILTIN_LINTERS = [
|
|
|
72
79
|
* enforce("vigiles/orphan-docs", "No docs without spec references.")
|
|
73
80
|
*/
|
|
74
81
|
function enforce(ref, why, options) {
|
|
82
|
+
const details = detailsOf(options);
|
|
75
83
|
return {
|
|
76
84
|
_kind: "enforce",
|
|
77
85
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
78
86
|
linterRule: ref,
|
|
79
87
|
why,
|
|
80
88
|
verify: options?.verify ?? true,
|
|
89
|
+
...(options?.render === undefined ? {} : { render: options.render }),
|
|
90
|
+
...(details ? { details } : {}),
|
|
81
91
|
};
|
|
82
92
|
}
|
|
83
93
|
/**
|
|
84
94
|
* Declare a guidance-only rule.
|
|
85
95
|
*
|
|
86
96
|
* guidance("Google unfamiliar APIs before implementing.")
|
|
97
|
+
* guidance("Short statement.", { details: file("docs/rules/x.md"), anchor: "why" })
|
|
87
98
|
*/
|
|
88
|
-
function guidance(text) {
|
|
89
|
-
|
|
99
|
+
function guidance(text, options) {
|
|
100
|
+
const details = detailsOf(options);
|
|
101
|
+
return details
|
|
102
|
+
? { _kind: "guidance", text, details }
|
|
103
|
+
: { _kind: "guidance", text };
|
|
90
104
|
}
|
|
91
105
|
/**
|
|
92
106
|
* Declare a reactive guard: runs a command when watched files change.
|