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.
- package/dist/adapters/claude-code/agent-result.d.ts +10 -1
- package/dist/adapters/claude-code/agent-result.js +14 -2
- package/dist/core/bash-effects.d.ts +20 -0
- package/dist/core/bash-effects.js +41 -1
- package/dist/core/hook-program.d.ts +25 -0
- package/dist/core/hook-program.js +90 -6
- package/dist/core/sidecar.d.ts +16 -0
- package/dist/core/sidecar.js +23 -8
- package/dist/eval.js +17 -1
- package/dist/experimental-emit.d.ts +163 -0
- package/dist/experimental-emit.js +243 -0
- package/dist/experimental.d.ts +9 -2
- package/dist/experimental.js +13 -3
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|
package/dist/core/sidecar.d.ts
CHANGED
|
@@ -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
|
package/dist/core/sidecar.js
CHANGED
|
@@ -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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
package/dist/experimental.d.ts
CHANGED
|
@@ -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
|
|
14
|
-
*
|
|
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
|
package/dist/experimental.js
CHANGED
|
@@ -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
|
|
17
|
-
*
|
|
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
|
|
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",
|