vigiles 15.1.0 → 15.2.1

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.
@@ -17,7 +17,7 @@
17
17
  * third `malformed` track for a worker that didn't honor its contract (no block,
18
18
  * bad JSON, or a shape that doesn't match the declared schema).
19
19
  */
20
- import type { OutputContract } from "../../core/spec.js";
20
+ import type { OutputContract, OutputFieldType } from "../../core/spec.js";
21
21
  /** The outcome of parsing a worker's result block. */
22
22
  export type ParsedAgentResult<S = Record<string, unknown>, E = Record<string, unknown>> = {
23
23
  readonly kind: "ok";
@@ -29,6 +29,15 @@ export type ParsedAgentResult<S = Record<string, unknown>, E = Record<string, un
29
29
  readonly kind: "malformed";
30
30
  readonly reason: string;
31
31
  };
32
+ /**
33
+ * Validate a parsed object against a contract track; null when it conforms.
34
+ *
35
+ * Exported because the EXPERIMENTAL emit channel (`src/experimental-emit.ts`)
36
+ * validates the SAME `OutputContract` on a different delivery. One contract, two
37
+ * deliveries, one validator — a second copy would drift, and the two rails
38
+ * disagreeing about what satisfies a contract is the worst outcome available.
39
+ */
40
+ export declare function shapeError(obj: Record<string, unknown>, shape: Readonly<Record<string, OutputFieldType>>): string | null;
32
41
  /**
33
42
  * Parse the last `vigiles:ok` / `vigiles:err` block from a worker's output.
34
43
  *
@@ -19,10 +19,15 @@
19
19
  * bad JSON, or a shape that doesn't match the declared schema).
20
20
  */
21
21
  Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.shapeError = shapeError;
22
23
  exports.parseAgentResult = parseAgentResult;
23
24
  // Capture every vigiles:ok / vigiles:err fenced block; the LAST one is the
24
25
  // worker's final answer (earlier ones may be illustrative in its reasoning).
25
- const BLOCK_RE = /```vigiles:(ok|err)[ \t]*\r?\n([\s\S]*?)```/g;
26
+ // The CLOSING fence must own its line: a ``` inside a JSON string value sits
27
+ // mid-line, so it no longer terminates the block (measured 2026-08-13 — a
28
+ // contract field carrying a code snippet made every such answer `malformed`
29
+ // even though the model's JSON was valid).
30
+ const BLOCK_RE = /^[ \t]*```vigiles:(ok|err)[ \t]*\r?\n([\s\S]*?)\r?\n^[ \t]*```[ \t]*$/gm;
26
31
  /** Does a runtime value match a declared field type? */
27
32
  function fieldMatches(value, type) {
28
33
  switch (type) {
@@ -36,7 +41,14 @@ function fieldMatches(value, type) {
36
41
  return Array.isArray(value) && value.every((v) => typeof v === "string");
37
42
  }
38
43
  }
39
- /** Validate a parsed object against a contract track; null when it conforms. */
44
+ /**
45
+ * Validate a parsed object against a contract track; null when it conforms.
46
+ *
47
+ * Exported because the EXPERIMENTAL emit channel (`src/experimental-emit.ts`)
48
+ * validates the SAME `OutputContract` on a different delivery. One contract, two
49
+ * deliveries, one validator — a second copy would drift, and the two rails
50
+ * disagreeing about what satisfies a contract is the worst outcome available.
51
+ */
40
52
  function shapeError(obj, shape) {
41
53
  for (const [field, type] of Object.entries(shape)) {
42
54
  if (!(field in obj))
@@ -112,6 +112,26 @@ export interface NormalizedLeaf {
112
112
  * {@link LeafRedirect}. Empty for a command with no redirection.
113
113
  */
114
114
  readonly redirects: readonly LeafRedirect[];
115
+ /**
116
+ * The directory a chdir WRAPPER moved this leaf into before exec'ing it —
117
+ * `env -C dir`, `env --chdir=dir`, `sudo -D dir` — or `null` when there was
118
+ * none. Nested wrappers accumulate (`sudo -D a env -C b cmd` → `a/b`).
119
+ *
120
+ * The parser already READ this token in order to skip past it, then threw the
121
+ * value away — so every relative operand of the wrapped command resolved
122
+ * against the wrong directory for every consumer. Same shape as the
123
+ * redirection targets the leaf used to drop: the parser knew, the leaf did
124
+ * not carry it. `git -C` is deliberately NOT here — `git` is not a wrapper
125
+ * (it does not exec the rest of its argv as a command).
126
+ *
127
+ * ⚠️ ONE LEAF'S OWN WRAPPER, NOT A CWD MODEL. A directory changed by a
128
+ * PRECEDING statement (`cd x && …`, `pushd`, a subshell) is not reported:
129
+ * connectors do not survive leaf extraction, and `cd x; cmd` writes into the
130
+ * OLD directory when the `cd` fails — that is a model with failure semantics,
131
+ * not a field. A dynamic value (`env -C "$DIR" …`) is not resolvable and the
132
+ * whole leaf is already unnormalizable in that case.
133
+ */
134
+ readonly chdir: string | null;
115
135
  }
116
136
  /**
117
137
  * Extract every simple command as a {@link NormalizedLeaf} — the operation-level
@@ -617,6 +617,38 @@ const WRAPPER_VALUE_OPTS = {
617
617
  ]),
618
618
  nohup: new Set(),
619
619
  };
620
+ /**
621
+ * Per-wrapper options whose value is a DIRECTORY the wrapper chdirs into before
622
+ * exec'ing the command. A subset of {@link WRAPPER_VALUE_OPTS}, and keyed by head
623
+ * for a reason: `-C` is `--chdir` for `env` but `--close-from` (a file
624
+ * descriptor) for `sudo`, whose chdir is `-D`. One shared set would read a
625
+ * number as a directory.
626
+ */
627
+ const WRAPPER_CHDIR_OPTS = {
628
+ env: new Set(["-C", "--chdir"]),
629
+ sudo: new Set(["-D", "--chdir"]),
630
+ };
631
+ /**
632
+ * The directory carried by an option WORD, when that word is one of this
633
+ * wrapper's chdir options — covering both spellings, since `--chdir=dir` is a
634
+ * single token that never reaches the separate-value table.
635
+ */
636
+ function chdirValue(word, next, chdirOpts) {
637
+ if (chdirOpts.has(word))
638
+ return next; // `-C dir`, `--chdir dir`
639
+ const eq = word.indexOf("=");
640
+ return eq > 0 && chdirOpts.has(word.slice(0, eq))
641
+ ? word.slice(eq + 1) // `--chdir=dir`
642
+ : undefined;
643
+ }
644
+ /** Layer a wrapper's chdir onto the one an outer wrapper already applied. */
645
+ function nestChdir(outer, inner) {
646
+ if (inner === undefined || inner === "")
647
+ return outer;
648
+ if (outer === null || inner.startsWith("/") || /^[A-Za-z]:\//.test(inner))
649
+ return inner;
650
+ return `${outer.replace(/\/+$/, "")}/${inner}`;
651
+ }
620
652
  /** Count of leading NON-option positionals a wrapper consumes before the command (timeout DURATION). */
621
653
  const WRAPPER_SKIP_POSITIONALS = {
622
654
  timeout: 1,
@@ -640,12 +672,14 @@ function splitAssignmentWord(word) {
640
672
  */
641
673
  function stripWrappers(argv) {
642
674
  const envAssigns = new Map();
675
+ let chdir = null;
643
676
  let cur = argv;
644
677
  for (let guard = 0; guard < 8; guard++) {
645
678
  const head = cur[0];
646
679
  if (head === undefined || !WRAPPER_HEADS.has(head))
647
680
  break;
648
681
  const valueOpts = WRAPPER_VALUE_OPTS[head] ?? new Set();
682
+ const chdirOpts = WRAPPER_CHDIR_OPTS[head] ?? new Set();
649
683
  let positionalsToSkip = WRAPPER_SKIP_POSITIONALS[head] ?? 0;
650
684
  let i = 1; // start after the wrapper head
651
685
  let ended = false;
@@ -659,6 +693,11 @@ function stripWrappers(argv) {
659
693
  break;
660
694
  }
661
695
  if (a.length > 1 && a.startsWith("-")) {
696
+ // The chdir value is CAPTURED before it is skipped. It was always read
697
+ // here — reading it is how the loop knows to skip past it — and then
698
+ // discarded, so every relative operand of the wrapped command resolved
699
+ // against the wrong directory downstream.
700
+ chdir = nestChdir(chdir, chdirValue(a, cur[i + 1], chdirOpts));
662
701
  if (valueOpts.has(a))
663
702
  i++; // skip this option's separate value too
664
703
  continue;
@@ -682,7 +721,7 @@ function stripWrappers(argv) {
682
721
  break; // no progress → stop
683
722
  cur = next;
684
723
  }
685
- return { argv: cur, envAssigns };
724
+ return { argv: cur, envAssigns, chdir };
686
725
  }
687
726
  /**
688
727
  * Extract every simple command as a {@link NormalizedLeaf} — the operation-level
@@ -1056,6 +1095,7 @@ function normalizeCallExpr(node, redirs) {
1056
1095
  assigns,
1057
1096
  hasAssign: (...names) => names.some((n) => assigns.has(n)),
1058
1097
  redirects: normalizeRedirects(redirs),
1098
+ chdir: stripped.chdir,
1059
1099
  };
1060
1100
  }
1061
1101
  //# sourceMappingURL=bash-effects.js.map
@@ -169,8 +169,33 @@ export interface CommandView {
169
169
  *
170
170
  * Deletion is a different question and is deliberately NOT reported here —
171
171
  * pair with `runs("rm")` if a gate cares about removal too.
172
+ *
173
+ * Exactly `writeTargets(prefixes).length > 0`, and implemented as that — reach
174
+ * for {@link writeTargets} when the gate needs to know WHICH file.
172
175
  */
173
176
  writesTo(prefixes: readonly string[]): boolean;
177
+ /**
178
+ * The write targets of this command that fall under one of the prefixes — the
179
+ * "WHICH file is written" counterpart to {@link writesTo}. Same two AST-backed
180
+ * sources (redirection targets + file-writing programs' argv positions), same
181
+ * denylist bias (an undecidable placement is INCLUDED).
182
+ *
183
+ * Spelling is as-written after normalization — quote-unwrapped,
184
+ * `$HOME`-canonicalized, and resolved against the leaf's own chdir wrapper
185
+ * (`env -C dir sed -i x` reports `dir/x`) — in order of appearance, exact
186
+ * duplicates collapsed. Filter by basename/suffix; never re-match the prefixes
187
+ * by hand, because hand-rolled prefix matching is the exact source of the
188
+ * trailing-slash, absolute-path and root-blindness defects this vocabulary
189
+ * exists to remove.
190
+ *
191
+ * An empty array ⇔ `writesTo(prefixes) === false`, so the natural
192
+ * `writeTargets(P).some(pred)` needs no emptiness check to behave correctly.
193
+ *
194
+ * `prefixes` is REQUIRED and there is no unfiltered overload: the raw list
195
+ * does not cross the API boundary, because a consumer holding it has to
196
+ * re-implement the matching that {@link prefixVerdict} exists to own.
197
+ */
198
+ writeTargets(prefixes: readonly string[]): readonly string[];
174
199
  /**
175
200
  * True iff the command pipes into a BARE shell interpreter (`curl … | sh`,
176
201
  * `… | bash -s`) — the remote-code-execution shape. High-signal: a shell leaf
@@ -440,6 +440,18 @@ function writeTargetsOf(leaf) {
440
440
  return [];
441
441
  }
442
442
  }
443
+ /**
444
+ * A write target as it lands on disk, given the chdir wrapper the leaf ran under
445
+ * (`env -C migratsiya sed -i s/a/b/ papers/x.tex` writes `migratsiya/papers/x.tex`).
446
+ *
447
+ * Absolute targets and `~`-rooted ones already name their directory and are
448
+ * returned untouched. The join itself is {@link resolveRef} rather than a fresh
449
+ * `a + "/" + b`, because two functions normalising the same string differently is
450
+ * the defect class this file keeps finding.
451
+ */
452
+ const underChdir = (target, chdir) => chdir === null || target === "" || target.startsWith("~")
453
+ ? target
454
+ : resolveRef(chdir, target);
443
455
  /**
444
456
  * An AST-backed view of a Bash command.
445
457
  *
@@ -457,10 +469,20 @@ function commandView(raw, root) {
457
469
  // The operation-normalized leaves carry the redirections (and quote-unwrapped,
458
470
  // wrapper-resolved argv) that `writesTo` needs; `leafCommands` cannot see them.
459
471
  const normalized = (0, bash_effects_js_1.leafCommandsNormalized)(raw);
460
- const writeTargets = normalized.flatMap((leaf) => [
472
+ const allWriteTargets = normalized.flatMap((leaf) => [
473
+ // 🔴 A REDIRECTION IS NOT JOINED ONTO THE LEAF'S CHDIR, AND THAT IS THE
474
+ // SHELL'S RULE, NOT A SHORTCUT. `env -C dir cmd > out.txt` opens `out.txt`
475
+ // in the SHELL's directory — the redirection happens before `env` ever runs
476
+ // and `-C` only moves the process `env` execs. Joining here would report a
477
+ // file the command never writes.
461
478
  ...leaf.redirects.flatMap((r) => r.writes && r.target !== null ? [r.target] : []),
462
- ...writeTargetsOf(leaf),
479
+ // The wrapped program's own operands DO resolve against it — see
480
+ // `NormalizedLeaf.chdir` for why the value was being read and discarded.
481
+ ...writeTargetsOf(leaf).map((t) => underChdir(t, leaf.chdir)),
463
482
  ]);
483
+ const matchedWriteTargets = (prefixes) => [
484
+ ...new Set(allWriteTargets.filter((t) => prefixes.some((p) => matchesPrefix(prefixVerdict(t, p, root), "match")))),
485
+ ];
464
486
  return {
465
487
  raw,
466
488
  runs(program, opts) {
@@ -501,7 +523,13 @@ function commandView(raw, root) {
501
523
  ? [tok, tok.slice(tok.indexOf("=") + 1)]
502
524
  : [tok])
503
525
  .some((tok) => prefixes.some((p) => matchesPrefix(prefixVerdict(tok, p, root), "match"))),
504
- writesTo: (prefixes) => writeTargets.some((t) => prefixes.some((p) => matchesPrefix(prefixVerdict(t, p, root), "match"))),
526
+ // DERIVED, not a second implementation of the same rule. The boolean stays
527
+ // because `writesTo(secrets) ? deny() : allow()` is the common gate shape,
528
+ // but it is a PROJECTION of the list — one code path, so the two can never
529
+ // drift the way `runs()` and `writesTo` did (one reads raw leaves, the other
530
+ // normalized ones, and a gate built on both had a silent hole).
531
+ writesTo: (prefixes) => matchedWriteTargets(prefixes).length > 0,
532
+ writeTargets: matchedWriteTargets,
505
533
  pipesToShell: () => leaves.some(isBareShellLeaf),
506
534
  };
507
535
  }
@@ -1157,6 +1185,48 @@ function runHookProgram(hook, event, ctx = {}, root = event.cwd) {
1157
1185
  function isAbsoluteRef(ref) {
1158
1186
  return ref.startsWith("/") || /^[A-Za-z]:\//.test(ref);
1159
1187
  }
1188
+ /**
1189
+ * Does this ROOT name a Windows filesystem? One predicate, because the two
1190
+ * questions that ask it — how many segments `..` may not pop through, and
1191
+ * whether a `//` leader is a share or a stutter — must never disagree about the
1192
+ * same root. `//x` is caught by the second arm: too short to be a share, but
1193
+ * still not something to read as POSIX.
1194
+ */
1195
+ const namesWindowsFs = (root) => WINDOWS_ROOT.test(root) || root.startsWith("//");
1196
+ /**
1197
+ * How many leading segments of a resolved path ARE its root — the floor a `..`
1198
+ * must not pop through. `0` on POSIX, `1` for a drive (`C:`), `2` for a UNC
1199
+ * share (`//server/share`), and 2/4 for the `//?/` spellings of those two.
1200
+ *
1201
+ * 🔴 A `//` LEADER IS THE ROOT'S ANSWER, NEVER THE OPERAND'S — the round-37
1202
+ * lesson at a third site (after the case fold in {@link caseInsensitiveFs} and
1203
+ * the UNC leader in {@link resolveRef}). Read from the string alone,
1204
+ * `//repo/a/../src/x.ts` looks share-rooted, so a count taken off the operand
1205
+ * would guard `repo/a` and stop `..` collapsing at all — under a POSIX root that
1206
+ * doubled slash is a stutter, not a share. The `//` forms are therefore gated on
1207
+ * the root, exactly as the leader is.
1208
+ *
1209
+ * ⚠️ A DRIVE LETTER IS THE ONE THING THAT NAMES A WINDOWS FILESYSTEM BY ITSELF,
1210
+ * and that is not a second rule — {@link isAtOrUnder} already says it about a
1211
+ * base (`WINDOWS_ROOT.test(rawBase)`), because `C:/x` has no POSIX reading the
1212
+ * way `//x` does. It earns its place at a real call site rather than in the
1213
+ * abstract: {@link absoluteSpelling} resolves a ROOTLESS absolute path against
1214
+ * the literal `"/"`, so gating the drive on the root too would leave
1215
+ * `C:/../repo/x` still losing its drive right there — the sibling call site a
1216
+ * fix written only where the defect was found would have missed.
1217
+ *
1218
+ * The count itself is read off {@link WINDOWS_ROOT} rather than re-derived,
1219
+ * because that regex already IS this file's single answer to "what is a Windows
1220
+ * root"; the two forms and the `//?/` spelling are enumerated there, once.
1221
+ */
1222
+ const rootSegmentCount = (joined, root) => {
1223
+ const matched = WINDOWS_ROOT.exec(joined)?.[0];
1224
+ if (matched === undefined)
1225
+ return 0; // POSIX, or relative: no root inside `out`
1226
+ if (/^[/\\]/.test(matched) && !namesWindowsFs(root))
1227
+ return 0; // stutter, not share
1228
+ return matched.split(/[/\\]+/).filter((s) => s !== "").length;
1229
+ };
1160
1230
  /**
1161
1231
  * Resolve a path reference against a root, without node:path (core stays
1162
1232
  * dependency-free). Mirrors `resolve(root, ref)`: an absolute ref wins, a
@@ -1178,12 +1248,27 @@ function resolveRef(root, ref) {
1178
1248
  const joined = isAbsoluteRef(r)
1179
1249
  ? r
1180
1250
  : `${slashes(root).replace(/\/+$/, "")}/${r}`;
1251
+ // 🔴 `..` CLAMPS AT THE ROOT, IT DOES NOT EAT IT. A real filesystem holds
1252
+ // still at the top: on Windows `C:/..` is `C:/`, on POSIX `/..` is `/`. An
1253
+ // unconditional `out.pop()` popped the DRIVE LETTER out of `C:/../repo/src/x`,
1254
+ // leaving the relative-looking `repo/src/x` — which then failed to resolve
1255
+ // against `C:/repo`, so a gate stopped recognising a path naming its own
1256
+ // repository. A UNC share went one worse: two `..` ate `share` and then
1257
+ // `server`.
1258
+ //
1259
+ // ⚠️ POSIX WAS ALREADY CORRECT BY ACCIDENT, and the accident is worth naming
1260
+ // so nobody "simplifies" it back: its leader `/` is held OUTSIDE `out` (see
1261
+ // the leader below), so popping an empty array is already the clamp. The
1262
+ // Windows forms broke precisely because their root lives INSIDE `out` —
1263
+ // exactly the segments the loop treats as ordinary directories.
1264
+ const floor = rootSegmentCount(joined, root);
1181
1265
  const out = [];
1182
1266
  for (const seg of joined.split("/")) {
1183
1267
  if (seg === "" || seg === ".")
1184
1268
  continue;
1185
1269
  if (seg === "..") {
1186
- out.pop();
1270
+ if (out.length > floor)
1271
+ out.pop();
1187
1272
  continue;
1188
1273
  }
1189
1274
  out.push(seg);
@@ -1201,8 +1286,7 @@ function resolveRef(root, ref) {
1201
1286
  // stutter, so preserving the pair unconditionally made it stop resolving
1202
1287
  // against a POSIX root and an allowlist gate denied a valid edit. Semantics
1203
1288
  // belong to the filesystem, and only the root knows which one that is.
1204
- const unc = joined.startsWith("//") &&
1205
- (WINDOWS_ROOT.test(root) || root.startsWith("//"));
1289
+ const unc = joined.startsWith("//") && namesWindowsFs(root);
1206
1290
  const leader = unc ? "//" : joined.startsWith("/") ? "/" : "";
1207
1291
  return leader + out.join("/");
1208
1292
  }
@@ -30,6 +30,22 @@ export declare function computePerFileHashes(inputFiles: string[], basePath: str
30
30
  * Recurses so that nested targets (e.g. `.github/copilot-instructions.md`,
31
31
  * which lives at `.vigiles/.github/copilot-instructions.md.inputs.json`)
32
32
  * are not silently skipped.
33
+ *
34
+ * 🔴 A DIRECTORY SYMLINK IS NOT DESCENDED INTO — this walk classified entries
35
+ * with a bare `statSync().isDirectory()`, which FOLLOWS a link, so a link inside
36
+ * `.vigiles/` pointing at a directory OUTSIDE it was walked (manifests from a
37
+ * foreign tree entering the audit under reconstructed target names), and a cycle
38
+ * — `.vigiles/loop -> .vigiles`, or a link to any ancestor — re-walked the same
39
+ * tree once per lap. Measured 2026-08-13 by reverting this call: ONE manifest was
40
+ * reported FORTY-ONE times, as `Real.md`, `loop/Real.md`, `loop/loop/Real.md` …
41
+ * until the kernel's link limit stopped it. Unlike the loader's walk it did not
42
+ * throw, and that is not a mitigation: the `catch` below swallowed the `ELOOP` and
43
+ * turned a crash into a silently multiplied audit. Third instance of the class in
44
+ * this repo; the decision is {@link entryOf} in `fs-walk.ts`, which carries the
45
+ * full rationale: a symlinked FILE is still read (it cannot recurse, and a
46
+ * manifest reached through one is a real manifest), termination is by
47
+ * construction rather than a visited-set, and an unreadable entry is `"skip"`
48
+ * rather than a throw — which is exactly what the `try`/`continue` here did.
33
49
  */
34
50
  export declare function iterateSidecars(basePath: string, fn: (target: string, manifest: SidecarManifest) => void): void;
35
51
  //# sourceMappingURL=sidecar.d.ts.map
@@ -18,6 +18,7 @@ exports.computePerFileHashes = computePerFileHashes;
18
18
  exports.iterateSidecars = iterateSidecars;
19
19
  const node_fs_1 = require("node:fs");
20
20
  const node_path_1 = require("node:path");
21
+ const fs_walk_js_1 = require("../fs-walk.js");
21
22
  const hash_js_1 = require("./hash.js");
22
23
  const SIDECAR_ROOT = ".vigiles";
23
24
  const MANIFEST_SUFFIX = ".inputs.json";
@@ -60,6 +61,22 @@ function computePerFileHashes(inputFiles, basePath) {
60
61
  * Recurses so that nested targets (e.g. `.github/copilot-instructions.md`,
61
62
  * which lives at `.vigiles/.github/copilot-instructions.md.inputs.json`)
62
63
  * are not silently skipped.
64
+ *
65
+ * 🔴 A DIRECTORY SYMLINK IS NOT DESCENDED INTO — this walk classified entries
66
+ * with a bare `statSync().isDirectory()`, which FOLLOWS a link, so a link inside
67
+ * `.vigiles/` pointing at a directory OUTSIDE it was walked (manifests from a
68
+ * foreign tree entering the audit under reconstructed target names), and a cycle
69
+ * — `.vigiles/loop -> .vigiles`, or a link to any ancestor — re-walked the same
70
+ * tree once per lap. Measured 2026-08-13 by reverting this call: ONE manifest was
71
+ * reported FORTY-ONE times, as `Real.md`, `loop/Real.md`, `loop/loop/Real.md` …
72
+ * until the kernel's link limit stopped it. Unlike the loader's walk it did not
73
+ * throw, and that is not a mitigation: the `catch` below swallowed the `ELOOP` and
74
+ * turned a crash into a silently multiplied audit. Third instance of the class in
75
+ * this repo; the decision is {@link entryOf} in `fs-walk.ts`, which carries the
76
+ * full rationale: a symlinked FILE is still read (it cannot recurse, and a
77
+ * manifest reached through one is a real manifest), termination is by
78
+ * construction rather than a visited-set, and an unreadable entry is `"skip"`
79
+ * rather than a throw — which is exactly what the `try`/`continue` here did.
63
80
  */
64
81
  function iterateSidecars(basePath, fn) {
65
82
  const root = (0, node_path_1.resolve)(basePath, SIDECAR_ROOT);
@@ -77,17 +94,15 @@ function walk(dir, root, basePath, fn) {
77
94
  }
78
95
  for (const entry of entries) {
79
96
  const fullPath = (0, node_path_1.resolve)(dir, entry);
80
- let isDir;
81
- try {
82
- isDir = (0, node_fs_1.statSync)(fullPath).isDirectory();
83
- }
84
- catch {
85
- continue;
86
- }
87
- if (isDir) {
97
+ const { kind } = (0, fs_walk_js_1.entryOf)(fullPath);
98
+ if (kind === "dir") {
88
99
  walk(fullPath, root, basePath, fn);
89
100
  continue;
90
101
  }
102
+ // "skip" covers what the old bare `statSync` handled by throwing (a dangling
103
+ // link, an unreadable entry) AND the symlinked directory it silently followed.
104
+ if (kind === "skip")
105
+ continue;
91
106
  if (!entry.endsWith(MANIFEST_SUFFIX))
92
107
  continue;
93
108
  // Reconstruct the target name from the path relative to .vigiles/
package/dist/eval.js CHANGED
@@ -981,7 +981,23 @@ async function executeTrial(spec, arm, trialIndex, runner, cfg) {
981
981
  }
982
982
  // A signal in the captured streams that the model call was rate-limited /
983
983
  // overloaded — worth a backoff + retry rather than counting as a real sample.
984
- const RATE_LIMIT_RE = /rate.?limit|\b429\b|overloaded|too many requests/i;
984
+ //
985
+ // 🔴 The separator is `[ -]?`, NOT `.?`, and that is load-bearing. Claude Code's
986
+ // stream-json emits an INFORMATIONAL `{"type":"rate_limit_event","rate_limit_info":
987
+ // {"status":"allowed",…}}` line on EVERY run (captured verbatim in
988
+ // examples/experimental-emit/records/rate-limit-event.json). `rate.?limit` matched
989
+ // `rate_limit_event`, because `.` matches `_` — so every trial looked rate-limited,
990
+ // every trial was retried `retries + 1` = 4 times, and only the LAST attempt's cost
991
+ // reached `maxCostUsd`. Measured 2026-08-13: 2 trials → 8 model runs, a budget cap
992
+ // of $0.60 crossed at roughly $3, and the run reported one trial. `[ -]?` cannot
993
+ // match `_`, so the telemetry line is no longer a match; a REAL limit still is,
994
+ // because the API reports it as `rate_limit_error` / 429 / "overloaded".
995
+ //
996
+ // Known boundary, stated rather than hidden: a `rate_limit_event` whose `status`
997
+ // is a rejection is no longer a match either. It never was one on its merits — the
998
+ // old pattern fired on the event's NAME regardless of status, so status-aware
999
+ // detection has never existed here.
1000
+ const RATE_LIMIT_RE = /rate[ -]?limit(?:ed)?\b|rate_limit_error|\b429\b|overloaded|too many requests/i;
985
1001
  /** Whether a run's captured output looks like a rate-limit / overload. Pure. */
986
1002
  function isRateLimited(out) {
987
1003
  return RATE_LIMIT_RE.test(`${out.stderr ?? ""}\n${out.stdout}`);
@@ -0,0 +1,163 @@
1
+ /**
2
+ * ⚠️ EXPERIMENTAL — the EMIT delivery for a typed result: the skill CALLS a tool
3
+ * carrying its outcome instead of ENDING its turn with a fenced block.
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * A typed `output` (an `OutputContract`) is valid only on a forked skill: compile
8
+ * hard-errors `output-without-fork` on the other 31, because an inline skill is
9
+ * spliced into the conversation, has no call→return boundary, and therefore has
10
+ * no return value to type (`research/spec-syntax-and-railway-scope.md`).
11
+ *
12
+ * A TOOL CALL needs no return boundary. The skill does not return the structure —
13
+ * it EMITS it, mid-conversation, and the call lands in `Trace.toolCalls`. So the
14
+ * objection that grounds the exclusion does not apply to this delivery. Same
15
+ * `OutputContract`; a different way of getting it out.
16
+ *
17
+ * ## What is UNPROVEN (why the `experimental_` prefix is on every runtime export)
18
+ *
19
+ * 1. **N=8, one skill, one model.** Measured 2026-08-13 against `paper-status`
20
+ * (unforked; `allowed-tools: Bash, Read, Grep, Glob`) on sonnet: 8 runs, 8
21
+ * emits, all on the `ok` track, all parsing against the contract, none
22
+ * repeated. That answers "does it land at all" and nothing about the rate —
23
+ * 8 of 8 bounds the true failure rate at about 31%, which is not evidence of
24
+ * reliability. Raw arguments + the free re-scorer:
25
+ * `examples/experimental-emit/`.
26
+ * 2. **Nobody depends on it.** Neither `compileSkill` nor `compileAgent` emits
27
+ * this instruction; the caller pastes `.instruction` into a skill body and
28
+ * serves `.tool` from their own MCP server by hand. There is no compile-time
29
+ * path, so no skill in any corpus is typed by it yet.
30
+ * 3. **The transport is not part of the contract.** MCP is how the tool reached
31
+ * the model in the measurement. Whether a plugin can hand the model a tool
32
+ * WITHOUT a separate server process is untested, and the answer changes what
33
+ * this surface should look like.
34
+ * 4. **The runtime does not enforce `required`.** Measured: Claude Code accepted a
35
+ * call omitting three declared-required fields (raw proof in `mine`,
36
+ * `vigiles/repro/output-contract-2026-08-13/mcp-arm/schema-probe-emitted.jsonl`).
37
+ * `inputSchema` is DESCRIPTION for the model, not a runtime gate — which is
38
+ * exactly why `experimental_parseEmitted` re-validates on the receiving side
39
+ * and why "the API validates it for you" must not be claimed.
40
+ *
41
+ * ## What would have to be true to drop the prefix
42
+ *
43
+ * - A rate, not an existence proof: ≥30 trials across ≥2 unforked skills and ≥2
44
+ * models, with the emit-landing rate reported and its failure modes named.
45
+ * - One consumer inside vigiles that compiles the instruction from the spec, so
46
+ * the tool name and the shape cannot drift apart by hand.
47
+ * - A measured answer to (3) — plugin-served tool vs external MCP server — since
48
+ * an in-process tool would remove the standing-up cost this surface assumes.
49
+ *
50
+ * Until then: not covered by the stability guarantee, may change or be removed
51
+ * without a major bump. See `docs/../STABILITY.md` and `src/experimental.ts`.
52
+ *
53
+ * @experimental
54
+ * @module
55
+ */
56
+ import type { OutputContract } from "./core/spec.js";
57
+ import type { ToolCall } from "./core/harness-driver.js";
58
+ import { type ParsedAgentResult } from "./adapters/claude-code/agent-result.js";
59
+ /** A JSON-Schema fragment for one declared field. */
60
+ export type EmitFieldSchema = {
61
+ readonly type: "string";
62
+ } | {
63
+ readonly type: "number";
64
+ } | {
65
+ readonly type: "boolean";
66
+ } | {
67
+ readonly type: "array";
68
+ readonly items: {
69
+ readonly type: "string";
70
+ };
71
+ };
72
+ /** The `track` discriminator's schema — the only enum this surface emits. */
73
+ export interface EmitTrackSchema {
74
+ readonly type: "string";
75
+ readonly enum: readonly ["ok", "err"];
76
+ }
77
+ /** Anything that can sit under `properties`: a field, the discriminator, a track. */
78
+ export type EmitPropertySchema = EmitFieldSchema | EmitTrackSchema | EmitObjectSchema;
79
+ /** A JSON-Schema object node — one track's payload, or the whole argument. */
80
+ export interface EmitObjectSchema {
81
+ readonly type: "object";
82
+ readonly properties: Readonly<Record<string, EmitPropertySchema>>;
83
+ readonly required: readonly string[];
84
+ readonly additionalProperties: false;
85
+ }
86
+ /** An MCP tool definition, in the shape a `tools/list` response carries. */
87
+ export interface EmitToolDefinition {
88
+ readonly name: string;
89
+ readonly description: string;
90
+ readonly inputSchema: EmitObjectSchema;
91
+ }
92
+ /** What `experimental_emitTool` hands back: the tool, and the prose that asks for it. */
93
+ export interface ExperimentalEmitTool {
94
+ /** Serve this from your MCP server's `tools/list`. */
95
+ readonly tool: EmitToolDefinition;
96
+ /**
97
+ * Markdown fragment for the skill body. The SAME contract rendered for the
98
+ * model — kept next to the schema so the two cannot drift when hand-wired.
99
+ */
100
+ readonly instruction: string;
101
+ }
102
+ /**
103
+ * ⚠️ EXPERIMENTAL. Derive an emit TOOL from an `OutputContract` — the same
104
+ * contract the fork rail renders as a `vigiles:ok` / `vigiles:err` fenced block.
105
+ *
106
+ * const emit = experimental_emitTool(contract);
107
+ * // emit.tool → serve from your MCP server
108
+ * // emit.instruction → paste into the (unforked) skill's body
109
+ *
110
+ * The two tracks are NESTED (`{ track, ok? , err? }`), not flattened into one bag
111
+ * of fields. That is deliberate: a flat union cannot say which fields are required
112
+ * on which track, so "success fields mixed with error fields" would be a
113
+ * well-formed call. Nested, it is not expressible.
114
+ *
115
+ * 🔴 `required` in the returned schema is DESCRIPTION, not enforcement — measured,
116
+ * see the module header (4). Validate what arrives with
117
+ * `experimental_parseEmitted`.
118
+ *
119
+ * @experimental
120
+ */
121
+ export declare function experimental_emitTool(contract: OutputContract, options?: {
122
+ readonly name?: string;
123
+ }): ExperimentalEmitTool;
124
+ /**
125
+ * ⚠️ EXPERIMENTAL. Read the emitted result out of a run's tool calls, validated
126
+ * against the contract. Pure — returns the same `ParsedAgentResult` vocabulary the
127
+ * fenced rail's `parseAgentResult` returns, so an eval `measure` can use it as a
128
+ * metric and the assertion below can wrap it, without one dual-purpose function.
129
+ *
130
+ * Accepts `Trace["toolCalls"]`, `SubagentTrace["toolCalls"]` or an eval
131
+ * `ctx.toolCalls`. Names are matched bare (`emit_result`) or MCP-prefixed
132
+ * (`mcp__<server>__emit_result`).
133
+ *
134
+ * Differs from the fenced rail in ONE deliberate way: **more than one call is
135
+ * `malformed`, not last-one-wins.** The fenced parser takes the LAST block because
136
+ * an earlier block may be illustrative reasoning; a tool call is an action, never
137
+ * illustrative, so "exactly once" is checkable here and is not checkable there.
138
+ *
139
+ * @experimental
140
+ */
141
+ export declare function experimental_parseEmitted(toolCalls: readonly ToolCall[], contract: OutputContract, options?: {
142
+ readonly name?: string;
143
+ }): ParsedAgentResult;
144
+ /**
145
+ * ⚠️ EXPERIMENTAL. Assert the run emitted a SUCCESS result, and return its value —
146
+ * the emit-channel counterpart of `assertAgentOk`, for a skill that has no return
147
+ * value to assert on.
148
+ *
149
+ * Throws on a missing emit, a repeated emit, an error track, or a payload that
150
+ * does not match the contract. The failure message names every tool the run DID
151
+ * call, because "the skill never emitted" and "the skill emitted the wrong shape"
152
+ * are different bugs and the tool list separates them at a glance.
153
+ *
154
+ * The error track is reachable through `experimental_parseEmitted`; a matching
155
+ * `…EmittedErr` is deliberately NOT shipped while the surface is this young —
156
+ * three exports is the whole prototype.
157
+ *
158
+ * @experimental
159
+ */
160
+ export declare function experimental_assertEmittedOk(toolCalls: readonly ToolCall[], contract: OutputContract, options?: {
161
+ readonly name?: string;
162
+ }): Record<string, unknown>;
163
+ //# sourceMappingURL=experimental-emit.d.ts.map
@@ -0,0 +1,243 @@
1
+ "use strict";
2
+ /**
3
+ * ⚠️ EXPERIMENTAL — the EMIT delivery for a typed result: the skill CALLS a tool
4
+ * carrying its outcome instead of ENDING its turn with a fenced block.
5
+ *
6
+ * ## Why this exists
7
+ *
8
+ * A typed `output` (an `OutputContract`) is valid only on a forked skill: compile
9
+ * hard-errors `output-without-fork` on the other 31, because an inline skill is
10
+ * spliced into the conversation, has no call→return boundary, and therefore has
11
+ * no return value to type (`research/spec-syntax-and-railway-scope.md`).
12
+ *
13
+ * A TOOL CALL needs no return boundary. The skill does not return the structure —
14
+ * it EMITS it, mid-conversation, and the call lands in `Trace.toolCalls`. So the
15
+ * objection that grounds the exclusion does not apply to this delivery. Same
16
+ * `OutputContract`; a different way of getting it out.
17
+ *
18
+ * ## What is UNPROVEN (why the `experimental_` prefix is on every runtime export)
19
+ *
20
+ * 1. **N=8, one skill, one model.** Measured 2026-08-13 against `paper-status`
21
+ * (unforked; `allowed-tools: Bash, Read, Grep, Glob`) on sonnet: 8 runs, 8
22
+ * emits, all on the `ok` track, all parsing against the contract, none
23
+ * repeated. That answers "does it land at all" and nothing about the rate —
24
+ * 8 of 8 bounds the true failure rate at about 31%, which is not evidence of
25
+ * reliability. Raw arguments + the free re-scorer:
26
+ * `examples/experimental-emit/`.
27
+ * 2. **Nobody depends on it.** Neither `compileSkill` nor `compileAgent` emits
28
+ * this instruction; the caller pastes `.instruction` into a skill body and
29
+ * serves `.tool` from their own MCP server by hand. There is no compile-time
30
+ * path, so no skill in any corpus is typed by it yet.
31
+ * 3. **The transport is not part of the contract.** MCP is how the tool reached
32
+ * the model in the measurement. Whether a plugin can hand the model a tool
33
+ * WITHOUT a separate server process is untested, and the answer changes what
34
+ * this surface should look like.
35
+ * 4. **The runtime does not enforce `required`.** Measured: Claude Code accepted a
36
+ * call omitting three declared-required fields (raw proof in `mine`,
37
+ * `vigiles/repro/output-contract-2026-08-13/mcp-arm/schema-probe-emitted.jsonl`).
38
+ * `inputSchema` is DESCRIPTION for the model, not a runtime gate — which is
39
+ * exactly why `experimental_parseEmitted` re-validates on the receiving side
40
+ * and why "the API validates it for you" must not be claimed.
41
+ *
42
+ * ## What would have to be true to drop the prefix
43
+ *
44
+ * - A rate, not an existence proof: ≥30 trials across ≥2 unforked skills and ≥2
45
+ * models, with the emit-landing rate reported and its failure modes named.
46
+ * - One consumer inside vigiles that compiles the instruction from the spec, so
47
+ * the tool name and the shape cannot drift apart by hand.
48
+ * - A measured answer to (3) — plugin-served tool vs external MCP server — since
49
+ * an in-process tool would remove the standing-up cost this surface assumes.
50
+ *
51
+ * Until then: not covered by the stability guarantee, may change or be removed
52
+ * without a major bump. See `docs/../STABILITY.md` and `src/experimental.ts`.
53
+ *
54
+ * @experimental
55
+ * @module
56
+ */
57
+ Object.defineProperty(exports, "__esModule", { value: true });
58
+ exports.experimental_emitTool = experimental_emitTool;
59
+ exports.experimental_parseEmitted = experimental_parseEmitted;
60
+ exports.experimental_assertEmittedOk = experimental_assertEmittedOk;
61
+ const agent_result_js_1 = require("./adapters/claude-code/agent-result.js");
62
+ /** The default tool name, when `options.name` is not given. */
63
+ const DEFAULT_EMIT_TOOL = "emit_result";
64
+ function fieldSchema(type) {
65
+ switch (type) {
66
+ case "string":
67
+ return { type: "string" };
68
+ case "number":
69
+ return { type: "number" };
70
+ case "boolean":
71
+ return { type: "boolean" };
72
+ case "string[]":
73
+ return { type: "array", items: { type: "string" } };
74
+ }
75
+ }
76
+ function trackSchema(shape) {
77
+ const properties = {};
78
+ for (const [field, type] of Object.entries(shape)) {
79
+ properties[field] = fieldSchema(type);
80
+ }
81
+ return {
82
+ type: "object",
83
+ properties,
84
+ required: Object.keys(shape),
85
+ additionalProperties: false,
86
+ };
87
+ }
88
+ /** Render a declared shape the way the fenced contract renders it, for the prose half. */
89
+ function renderShape(shape) {
90
+ const fields = Object.entries(shape)
91
+ .map(([k, t]) => `"${k}": ${t}`)
92
+ .join(", ");
93
+ return fields ? `{ ${fields} }` : "{}";
94
+ }
95
+ /**
96
+ * ⚠️ EXPERIMENTAL. Derive an emit TOOL from an `OutputContract` — the same
97
+ * contract the fork rail renders as a `vigiles:ok` / `vigiles:err` fenced block.
98
+ *
99
+ * const emit = experimental_emitTool(contract);
100
+ * // emit.tool → serve from your MCP server
101
+ * // emit.instruction → paste into the (unforked) skill's body
102
+ *
103
+ * The two tracks are NESTED (`{ track, ok? , err? }`), not flattened into one bag
104
+ * of fields. That is deliberate: a flat union cannot say which fields are required
105
+ * on which track, so "success fields mixed with error fields" would be a
106
+ * well-formed call. Nested, it is not expressible.
107
+ *
108
+ * 🔴 `required` in the returned schema is DESCRIPTION, not enforcement — measured,
109
+ * see the module header (4). Validate what arrives with
110
+ * `experimental_parseEmitted`.
111
+ *
112
+ * @experimental
113
+ */
114
+ function experimental_emitTool(contract, options = {}) {
115
+ const name = options.name ?? DEFAULT_EMIT_TOOL;
116
+ const tool = {
117
+ name,
118
+ description: "Emit this task's structured result. Call this exactly once. Set " +
119
+ '`track` to "ok" and fill `ok` on success, or "err" and fill `err` on ' +
120
+ "failure. Do not call it twice and do not fill both tracks.",
121
+ inputSchema: {
122
+ type: "object",
123
+ properties: {
124
+ track: { type: "string", enum: ["ok", "err"] },
125
+ ok: trackSchema(contract.ok),
126
+ err: trackSchema(contract.err),
127
+ },
128
+ required: ["track"],
129
+ additionalProperties: false,
130
+ },
131
+ };
132
+ const instruction = [
133
+ "## Output contract",
134
+ "",
135
+ `Emit your result by calling the \`${name}\` tool exactly once, at the point`,
136
+ "you have the answer. Do not print it as a code block; the call IS the result.",
137
+ "",
138
+ `On success: \`track: "ok"\`, with \`ok\` =`,
139
+ "",
140
+ "```json",
141
+ renderShape(contract.ok),
142
+ "```",
143
+ "",
144
+ `On failure: \`track: "err"\`, with \`err\` =`,
145
+ "",
146
+ "```json",
147
+ renderShape(contract.err),
148
+ "```",
149
+ ].join("\n");
150
+ return { tool, instruction };
151
+ }
152
+ /** Does this observed tool name refer to `name` (bare, or MCP-prefixed)? */
153
+ function isEmitCall(observed, name) {
154
+ return observed === name || observed.endsWith(`__${name}`);
155
+ }
156
+ /**
157
+ * ⚠️ EXPERIMENTAL. Read the emitted result out of a run's tool calls, validated
158
+ * against the contract. Pure — returns the same `ParsedAgentResult` vocabulary the
159
+ * fenced rail's `parseAgentResult` returns, so an eval `measure` can use it as a
160
+ * metric and the assertion below can wrap it, without one dual-purpose function.
161
+ *
162
+ * Accepts `Trace["toolCalls"]`, `SubagentTrace["toolCalls"]` or an eval
163
+ * `ctx.toolCalls`. Names are matched bare (`emit_result`) or MCP-prefixed
164
+ * (`mcp__<server>__emit_result`).
165
+ *
166
+ * Differs from the fenced rail in ONE deliberate way: **more than one call is
167
+ * `malformed`, not last-one-wins.** The fenced parser takes the LAST block because
168
+ * an earlier block may be illustrative reasoning; a tool call is an action, never
169
+ * illustrative, so "exactly once" is checkable here and is not checkable there.
170
+ *
171
+ * @experimental
172
+ */
173
+ function experimental_parseEmitted(toolCalls, contract, options = {}) {
174
+ const name = options.name ?? DEFAULT_EMIT_TOOL;
175
+ const calls = toolCalls.filter((c) => isEmitCall(c.name, name));
176
+ if (calls.length === 0) {
177
+ return { kind: "malformed", reason: `no \`${name}\` tool call in the run` };
178
+ }
179
+ if (calls.length > 1) {
180
+ return {
181
+ kind: "malformed",
182
+ reason: `\`${name}\` was called ${String(calls.length)} times; the contract is exactly once`,
183
+ };
184
+ }
185
+ const input = calls[0].input;
186
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
187
+ return {
188
+ kind: "malformed",
189
+ reason: `\`${name}\` call carried no object argument`,
190
+ };
191
+ }
192
+ const args = input;
193
+ const track = args.track;
194
+ if (track !== "ok" && track !== "err") {
195
+ return {
196
+ kind: "malformed",
197
+ reason: `\`${name}\` call has no \`track\` of "ok" or "err"`,
198
+ };
199
+ }
200
+ const payload = args[track];
201
+ if (typeof payload !== "object" ||
202
+ payload === null ||
203
+ Array.isArray(payload)) {
204
+ return {
205
+ kind: "malformed",
206
+ reason: `\`${name}\` call declared track "${track}" but carried no \`${track}\` object`,
207
+ };
208
+ }
209
+ const obj = payload;
210
+ const bad = (0, agent_result_js_1.shapeError)(obj, track === "ok" ? contract.ok : contract.err);
211
+ if (bad)
212
+ return { kind: "malformed", reason: `${track} payload: ${bad}` };
213
+ return track === "ok"
214
+ ? { kind: "ok", value: obj }
215
+ : { kind: "err", error: obj };
216
+ }
217
+ /**
218
+ * ⚠️ EXPERIMENTAL. Assert the run emitted a SUCCESS result, and return its value —
219
+ * the emit-channel counterpart of `assertAgentOk`, for a skill that has no return
220
+ * value to assert on.
221
+ *
222
+ * Throws on a missing emit, a repeated emit, an error track, or a payload that
223
+ * does not match the contract. The failure message names every tool the run DID
224
+ * call, because "the skill never emitted" and "the skill emitted the wrong shape"
225
+ * are different bugs and the tool list separates them at a glance.
226
+ *
227
+ * The error track is reachable through `experimental_parseEmitted`; a matching
228
+ * `…EmittedErr` is deliberately NOT shipped while the surface is this young —
229
+ * three exports is the whole prototype.
230
+ *
231
+ * @experimental
232
+ */
233
+ function experimental_assertEmittedOk(toolCalls, contract, options = {}) {
234
+ const r = experimental_parseEmitted(toolCalls, contract, options);
235
+ if (r.kind === "ok")
236
+ return r.value;
237
+ const why = r.kind === "err"
238
+ ? `it emitted an error result: ${JSON.stringify(r.error)}`
239
+ : r.reason;
240
+ const observed = toolCalls.map((c) => c.name).join(", ") || "none";
241
+ throw new Error(`expected an emitted success result, but ${why} (tools called: ${observed})`);
242
+ }
243
+ //# sourceMappingURL=experimental-emit.js.map
@@ -10,8 +10,14 @@
10
10
  * NOT covered by the stability guarantee (STABILITY.md): the shape may change or
11
11
  * be removed WITHOUT a major-version bump. Do not depend on it in production.
12
12
  *
13
- * Current contents — the R3 disposable-service tier (real side-effect testing;
14
- * see docs/measuring-skills.md § Experimental and src/services.ts).
13
+ * Current contents:
14
+ * - the R3 disposable-service tier (real side-effect testing; see
15
+ * docs/measuring-skills.md § Experimental and src/services.ts);
16
+ * - the EMIT delivery for a typed result (`src/experimental-emit.ts`) — a skill
17
+ * that CALLS a tool with its outcome instead of ending its turn with a fenced
18
+ * block, which is how an UNFORKED skill can carry an `OutputContract` at all.
19
+ * Read that module's header before using it: it lists, by number, what is
20
+ * unproven and what would have to be true to drop the prefix.
15
21
  *
16
22
  * ⚠️ SAFETY: R3 runs a model-driven skill FOR REAL. The disposable container is
17
23
  * the ONLY isolation vigiles provides — it does not confine the skill's filesystem
@@ -24,4 +30,5 @@
24
30
  */
25
31
  export { experimental_startServices, experimental_withServices, type ServiceSpec, type ServiceReady, type ServiceReset, type ServiceHandle, type ServiceSession, type ContainerRuntime, } from "./services.js";
26
32
  export { experimental_dockerRuntime, makeDockerRuntime, type DockerExec, type NetProbe, } from "./services-docker.js";
33
+ export { experimental_emitTool, experimental_parseEmitted, experimental_assertEmittedOk, type EmitFieldSchema, type EmitObjectSchema, type EmitPropertySchema, type EmitTrackSchema, type EmitToolDefinition, type ExperimentalEmitTool, } from "./experimental-emit.js";
27
34
  //# sourceMappingURL=experimental.d.ts.map
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.makeDockerRuntime = exports.experimental_dockerRuntime = exports.experimental_withServices = exports.experimental_startServices = void 0;
3
+ exports.experimental_assertEmittedOk = exports.experimental_parseEmitted = exports.experimental_emitTool = exports.makeDockerRuntime = exports.experimental_dockerRuntime = exports.experimental_withServices = exports.experimental_startServices = void 0;
4
4
  /**
5
5
  * `vigiles/experimental` — ⚠️ EXPERIMENTAL, UNSTABLE public surface.
6
6
  *
@@ -13,8 +13,14 @@ exports.makeDockerRuntime = exports.experimental_dockerRuntime = exports.experim
13
13
  * NOT covered by the stability guarantee (STABILITY.md): the shape may change or
14
14
  * be removed WITHOUT a major-version bump. Do not depend on it in production.
15
15
  *
16
- * Current contents — the R3 disposable-service tier (real side-effect testing;
17
- * see docs/measuring-skills.md § Experimental and src/services.ts).
16
+ * Current contents:
17
+ * - the R3 disposable-service tier (real side-effect testing; see
18
+ * docs/measuring-skills.md § Experimental and src/services.ts);
19
+ * - the EMIT delivery for a typed result (`src/experimental-emit.ts`) — a skill
20
+ * that CALLS a tool with its outcome instead of ending its turn with a fenced
21
+ * block, which is how an UNFORKED skill can carry an `OutputContract` at all.
22
+ * Read that module's header before using it: it lists, by number, what is
23
+ * unproven and what would have to be true to drop the prefix.
18
24
  *
19
25
  * ⚠️ SAFETY: R3 runs a model-driven skill FOR REAL. The disposable container is
20
26
  * the ONLY isolation vigiles provides — it does not confine the skill's filesystem
@@ -31,4 +37,8 @@ Object.defineProperty(exports, "experimental_withServices", { enumerable: true,
31
37
  var services_docker_js_1 = require("./services-docker.js");
32
38
  Object.defineProperty(exports, "experimental_dockerRuntime", { enumerable: true, get: function () { return services_docker_js_1.experimental_dockerRuntime; } });
33
39
  Object.defineProperty(exports, "makeDockerRuntime", { enumerable: true, get: function () { return services_docker_js_1.makeDockerRuntime; } });
40
+ var experimental_emit_js_1 = require("./experimental-emit.js");
41
+ Object.defineProperty(exports, "experimental_emitTool", { enumerable: true, get: function () { return experimental_emit_js_1.experimental_emitTool; } });
42
+ Object.defineProperty(exports, "experimental_parseEmitted", { enumerable: true, get: function () { return experimental_emit_js_1.experimental_parseEmitted; } });
43
+ Object.defineProperty(exports, "experimental_assertEmittedOk", { enumerable: true, get: function () { return experimental_emit_js_1.experimental_assertEmittedOk; } });
34
44
  //# sourceMappingURL=experimental.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "15.1.0",
3
+ "version": "15.2.1",
4
4
  "description": "Audit, test and measure the harness your AI agent runs on — grade your CLAUDE.md / AGENTS.md, skills, subagents and hooks, run them against a scripted model, and measure whether they actually fire.",
5
5
  "keywords": [
6
6
  "claude-code",