vigiles 36.0.0 → 37.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.
@@ -13,7 +13,7 @@
13
13
  /** Human-facing verbs (printed in help; typed by a human/agent/CI). */
14
14
  export declare const VERBS: readonly ["init", "compile", "eject", "lint", "test", "eval", "audit", "generate", "hook-runtime"];
15
15
  /** Runtime entrypoint kinds under `vigiles hook-runtime <kind>` (emitted, not typed). */
16
- export declare const HOOK_RUNTIME_KINDS: readonly ["run-program", "agent", "agent-start", "agent-done", "skill", "skill-tool", "skill-start", "skill-done", "run-skill", "intercept-tool", "guard", "action", "refs", "eval-lock-nudge", "effect-enter", "effect-exit"];
16
+ export declare const HOOK_RUNTIME_KINDS: readonly ["run-program", "agent", "agent-start", "agent-done", "skill", "skill-tool", "skill-start", "skill-done", "run-skill", "intercept-tool", "stub", "guard", "action", "refs", "eval-lock-nudge", "effect-enter", "effect-exit"];
17
17
  export type Verb = (typeof VERBS)[number];
18
18
  export type HookRuntimeKind = (typeof HOOK_RUNTIME_KINDS)[number];
19
19
  //# sourceMappingURL=cli-commands.d.ts.map
@@ -37,6 +37,7 @@ exports.HOOK_RUNTIME_KINDS = [
37
37
  "skill-done",
38
38
  "run-skill",
39
39
  "intercept-tool",
40
+ "stub",
40
41
  "guard",
41
42
  "action",
42
43
  "refs",
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");
@@ -5572,6 +5573,18 @@ async function handleRunScripts(kind, args, restArgs, excludes) {
5572
5573
  console.error(`\n✗ vigiles ${kind}: --min=${String(minRequired)} but only ${String(loadedCount)} of ` +
5573
5574
  `${String(files.length)} matched ${kind} file(s) loaded.`);
5574
5575
  }
5576
+ // An eval whose stubs left a call unanswered measured an answer nobody wrote.
5577
+ // That is not a failed assertion but a non-measurement, and it must fail the
5578
+ // command even when the file's own `assert` never looks (eval-entry.ts). The
5579
+ // file says so with its own exit code, so this names the cause from that code
5580
+ // and not from the verb's usage-error 2.
5581
+ const unanswered = results.filter((r) => r.code === eval_entry_js_1.UNANSWERED_STUB_EXIT_CODE);
5582
+ if (kind === "eval" && unanswered.length > 0) {
5583
+ console.error(`\n✗ vigiles eval: unanswered stub call(s) in ${unanswered.map((r) => r.file).join(", ")} — not a usage error. ` +
5584
+ `The calls are listed above; add a rule for each, and no lock was written. ` +
5585
+ `(Exit ${String(eval_entry_js_1.UNANSWERED_STUB_VERB_EXIT)}; the file itself exited ${String(eval_entry_js_1.UNANSWERED_STUB_EXIT_CODE)}.)`);
5586
+ process.exit(eval_entry_js_1.UNANSWERED_STUB_VERB_EXIT);
5587
+ }
5575
5588
  if ((0, run_scripts_js_1.anyFailed)(results))
5576
5589
  process.exit(1);
5577
5590
  // 🔴 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
  }
@@ -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 `ephemeralEnv`.
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
@@ -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 `ephemeralEnv` and the harness-tier preflight of
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
@@ -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
@@ -0,0 +1,199 @@
1
+ /**
2
+ * A tool stub that answers PER INVOCATION — the pure core.
3
+ *
4
+ * A stub shadows a binary on PATH (`gh`, `git`, `psql`) so a run that calls it
5
+ * gets a recorded answer instead of the real service. The earlier stub printed
6
+ * ONE answer for every argv. That is a test double answering by default, and a
7
+ * default answer is a fabricated observation: a stub set up to answer
8
+ * `gh issue create` with an issue URL also answered a script's
9
+ * `gh api repos/o/r/issues?…` with that URL, the script failed to parse it as
10
+ * JSON, and a model that read the stub's source stopped trusting it.
11
+ *
12
+ * So a stub is a non-empty list of RULES over the argv TOKEN ARRAY. Each rule has
13
+ * a positional pattern (an exact string or a RegExp per token, and an explicit
14
+ * trailing `rest` for "any further tokens"), an optional order-free `contains`
15
+ * for flags, and a reply. The first matching rule answers. An argv no rule
16
+ * matches is UNANSWERED: the stub exits non-zero, the call is logged, and the
17
+ * run fails — the same choice `script-overrun.ts` made for a scripted model that
18
+ * runs past its script.
19
+ *
20
+ * Argv is a list, not a line: joining it loses token boundaries (`--title "a b"`
21
+ * is one token), and a RegExp over the joined line matches across them.
22
+ *
23
+ * Everything here is pure and JSON-serialisable, because the same rules are
24
+ * read by the stub process (one per invocation), folded into the eval cache key
25
+ * and the lock, and printed in failure messages. No zod here on purpose: this
26
+ * module is on the stub process's path, which runs once per `gh` call.
27
+ */
28
+ /** "Any number of further tokens, including none." Only valid as the LAST token. */
29
+ export interface ArgvRest {
30
+ readonly kind: "rest";
31
+ }
32
+ /** The `rest` marker. One frozen value, so a pattern carries no free-form object. */
33
+ export declare const ARGV_REST: ArgvRest;
34
+ /** One positional token: exact string, or a RegExp tested against that one token. */
35
+ export type ArgvToken = string | Readonly<RegExp>;
36
+ /**
37
+ * A positional pattern: token i matches argv token i. Without a trailing
38
+ * {@link ARGV_REST} the argv must have EXACTLY this many tokens, so a read rule
39
+ * `["api", /^repos\//]` does not also answer `api repos/… -f title=x`.
40
+ */
41
+ export type ArgvPattern = readonly ArgvToken[] | readonly [...ArgvToken[], ArgvRest];
42
+ /** What the fake binary does when a rule answers. */
43
+ export interface StubAnswer {
44
+ /** Printed to stdout. Default "". */
45
+ readonly stdout?: string;
46
+ /** Printed to stderr. Default none. */
47
+ readonly stderr?: string;
48
+ /** Exit code, 0–255. Default 0. */
49
+ readonly exitCode?: number;
50
+ }
51
+ /**
52
+ * `always`: every matching call gets the answer. `inOrder`: the k-th matching
53
+ * call gets `answers[k-1]`, and a call past the end is UNANSWERED — repetition
54
+ * is something a rule says, never a default.
55
+ */
56
+ export type StubReply = ({
57
+ readonly kind: "always";
58
+ } & StubAnswer) | {
59
+ readonly kind: "inOrder";
60
+ readonly answers: readonly [StubAnswer, ...StubAnswer[]];
61
+ };
62
+ /** One rule: an argv pattern, optional order-free tokens, and a reply. */
63
+ export interface StubRule {
64
+ readonly argv: ArgvPattern;
65
+ /**
66
+ * Tokens that must each match some argv token AFTER the positional prefix, in
67
+ * any order — for flags a model writes in any order (`--repo o/r` or `-R o/r`).
68
+ * Needs a trailing `rest` in `argv` (without it nothing follows the prefix).
69
+ */
70
+ readonly contains?: readonly ArgvToken[];
71
+ readonly reply: StubReply;
72
+ }
73
+ /** A stub for one binary: its name on PATH and its rules, first match wins. */
74
+ export interface ToolStub {
75
+ readonly name: string;
76
+ readonly rules: readonly [StubRule, ...StubRule[]];
77
+ }
78
+ /**
79
+ * What happened to one invocation. Three cases, because the two ways a call goes
80
+ * unanswered need different fixes: no rule matched (add a rule), or a rule
81
+ * matched but its `inOrder` answers ran out (add an answer).
82
+ */
83
+ export type StubOutcome = {
84
+ readonly kind: "answered";
85
+ /** 0-based rule index. */
86
+ readonly rule: number;
87
+ /** 0-based answer index (always 0 for an `always` reply). */
88
+ readonly answer: number;
89
+ } | {
90
+ readonly kind: "no-rule";
91
+ } | {
92
+ readonly kind: "exhausted";
93
+ readonly rule: number;
94
+ }
95
+ /**
96
+ * The stub printed its neutral miss line but left no log line (its directory
97
+ * was not writable from the agent's shell). Never read from a log: the eval
98
+ * derives it from the agent's tool results.
99
+ */
100
+ | {
101
+ readonly kind: "unlogged";
102
+ };
103
+ /** One recorded invocation of a stub. */
104
+ export interface StubCall {
105
+ readonly tool: string;
106
+ readonly argv: readonly string[];
107
+ readonly outcome: StubOutcome;
108
+ }
109
+ /** Does `rule` match `argv`? Positional prefix, then `contains` over the tail. Pure, total. */
110
+ export declare function matchArgv(rule: Pick<StubRule, "argv" | "contains">, argv: readonly string[]): boolean;
111
+ /** How many times each rule of `tool` has already answered, from the log so far. */
112
+ export declare function priorAnswers(calls: readonly StubCall[], tool: string): ReadonlyMap<number, number>;
113
+ /** The decision for one invocation: (rules, answers so far, argv) → outcome. Pure, total. */
114
+ export declare function decideInvocation(stub: ToolStub, prior: ReadonlyMap<number, number>, argv: readonly string[]): StubOutcome;
115
+ /** Whether an outcome left the call without an answer. */
116
+ export declare function isUnanswered(o: StubOutcome): boolean;
117
+ /** The answer an `answered` outcome refers to; `undefined` for an unanswered one. */
118
+ export declare function answerFor(stub: ToolStub, o: StubOutcome): StubAnswer | undefined;
119
+ /**
120
+ * Serialise a stub to JSON. A RegExp becomes `{kind:"re",source,flags}` —
121
+ * `JSON.stringify(/x/)` is `{}`, which would make two different rules hash the
122
+ * same in the cache key and the lock.
123
+ */
124
+ export declare function encodeStub(stub: ToolStub): string;
125
+ /** A typed {@link ToolStub} from a value already known to have its shape. */
126
+ export declare function toToolStub(v: unknown): ToolStub;
127
+ /**
128
+ * Read back what {@link encodeStub} wrote. The input is vigiles' own output,
129
+ * written after the spec was parsed, so a bad shape is an internal error.
130
+ */
131
+ export declare function decodeStub(json: string): ToolStub;
132
+ /** One log line for one call (no trailing newline). */
133
+ export declare function stubLogLine(call: StubCall): string;
134
+ /** Read the JSONL log the stub processes appended. Blank lines are skipped. */
135
+ export declare function parseStubLog(jsonl: string): readonly StubCall[];
136
+ /** A pattern the way a rule is written: `["issue", "create", experimental_stub.rest]`. */
137
+ export declare function describePattern(p: readonly (ArgvToken | ArgvRest)[]): string;
138
+ /**
139
+ * The failure message for a run with unanswered calls, addressed to the AUTHOR
140
+ * (the model only ever sees a neutral one-liner). Each distinct argv once, with
141
+ * a count; the rules it was tried against; and a rule for that exact command to
142
+ * add. It never suggests a catch-all: answering everything is the defect this
143
+ * exists to stop. `undefined` when every call was answered.
144
+ */
145
+ export declare function unansweredMessage(calls: readonly StubCall[], stubs: readonly ToolStub[]): string | undefined;
146
+ /**
147
+ * The exit code of an unanswered call. Not 127: that tells the caller "not
148
+ * installed", and a model changes plans for the wrong reason.
149
+ */
150
+ export declare const UNANSWERED_EXIT_CODE = 97;
151
+ /**
152
+ * What an unanswered call prints to stderr — the only thing the MODEL sees. One
153
+ * fixed line: no argv, no rules, no "add a rule". Instructions here would tell
154
+ * the model it is in a test and what the author will do, and the rest of the
155
+ * trial would measure a reaction to that. The diagnostic goes to the author,
156
+ * from the log, through {@link unansweredMessage}.
157
+ */
158
+ export declare function unsupportedLine(name: string): string;
159
+ /**
160
+ * The misses a stub could not log, found from the agent's side: every
161
+ * occurrence of a stubbed tool's neutral miss line ({@link unsupportedLine}) in
162
+ * `texts` (the agent's tool results) beyond the unanswered calls the log already
163
+ * holds for that tool. The line is vigiles' own marker, not a guess about what
164
+ * the agent ran, so this is a count of a thing we printed. A miss the log has
165
+ * is counted once — by the log. Pure.
166
+ */
167
+ export declare function unloggedMisses(texts: readonly string[], logged: readonly StubCall[], names: readonly string[]): readonly StubCall[];
168
+ /** Where a stub root keeps its files (`name` picks the per-stub ones). Pure path arithmetic. */
169
+ export declare function stubPaths(root: string, name?: string): {
170
+ readonly binDir: string;
171
+ readonly rulesDir: string;
172
+ readonly rules: string;
173
+ readonly log: string;
174
+ readonly bin: string;
175
+ };
176
+ /** The two file operations one invocation needs — injected, so the decision stays testable. */
177
+ export interface StubIo {
178
+ /** File contents, or null when the file does not exist. */
179
+ readonly readFile: (path: string) => string | null;
180
+ /** Append text; one call per log line (`O_APPEND`, one write). */
181
+ readonly appendFile: (path: string, text: string) => void;
182
+ }
183
+ /** What the stub process prints and how it exits. */
184
+ export interface StubProcessResult {
185
+ readonly stdout: string;
186
+ readonly stderr: string;
187
+ readonly exitCode: number;
188
+ }
189
+ /**
190
+ * One invocation: read the rules and the log so far, decide, append one log
191
+ * line, return what to print. It never reads stdin: a caller that leaves the
192
+ * pipe open (Node's `execFile` does) would block until its own timeout.
193
+ *
194
+ * Concurrency, said plainly: two simultaneous calls of the same `inOrder` rule
195
+ * can both read "answered k times" and both get answer k. One line per write
196
+ * keeps the log itself intact.
197
+ */
198
+ export declare function runStub(root: string, name: string, argv: readonly string[], io: StubIo): StubProcessResult;
199
+ //# sourceMappingURL=stub-rules.d.ts.map