vigiles 15.0.3 → 15.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +108 -10
- package/dist/core/bash-effects.d.ts +20 -0
- package/dist/core/bash-effects.js +41 -1
- package/dist/core/hook-program.d.ts +167 -28
- package/dist/core/hook-program.js +251 -37
- package/dist/core/hook-providers.d.ts +22 -5
- package/dist/core/hook-providers.js +13 -1
- package/dist/core/hook-state.d.ts +307 -0
- package/dist/core/hook-state.js +349 -0
- package/dist/core/sidecar.d.ts +16 -0
- package/dist/core/sidecar.js +23 -8
- package/dist/hook.d.ts +3 -1
- package/dist/hook.js +18 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -59,6 +59,7 @@ const merge_conflict_js_1 = require("./core/merge-conflict.js");
|
|
|
59
59
|
const hook_install_js_1 = require("./hook-install.js");
|
|
60
60
|
const hook_providers_js_1 = require("./core/hook-providers.js");
|
|
61
61
|
const toml_1 = require("@iarna/toml");
|
|
62
|
+
const hash_js_1 = require("./core/hash.js");
|
|
62
63
|
const agent_runtime_js_1 = require("./adapters/claude-code/agent-runtime.js");
|
|
63
64
|
const observe_js_1 = require("./observe.js");
|
|
64
65
|
const effect_region_js_1 = require("./adapters/claude-code/effect-region.js");
|
|
@@ -5016,6 +5017,84 @@ async function compileProviders() {
|
|
|
5016
5017
|
function hookStampPath(file) {
|
|
5017
5018
|
return (0, node_path_1.resolve)(process.cwd(), ".vigiles/hooks", (0, node_path_1.basename)(file) + ".json");
|
|
5018
5019
|
}
|
|
5020
|
+
/**
|
|
5021
|
+
* The directory a hook's recorded facts live in — the SCOPE of `state()`/`record()`.
|
|
5022
|
+
*
|
|
5023
|
+
* Derived from the hook's own location and never from anything the hook said, so
|
|
5024
|
+
* a key cannot address another owner's store: hooks shipped in the same directory
|
|
5025
|
+
* share their facts (the requirement — one hook records, another reads), a
|
|
5026
|
+
* vendored plugin's hooks get their own. The layout MIRRORS the hook's directory
|
|
5027
|
+
* rather than slugging it, which keeps it injective and lets a human debugging a
|
|
5028
|
+
* hook find the fact by walking the path they already know:
|
|
5029
|
+
*
|
|
5030
|
+
* .claude/hooks/calendar-sync-record.hook.ts
|
|
5031
|
+
* → .vigiles/state/.claude/hooks/calendar.synced.json
|
|
5032
|
+
*
|
|
5033
|
+
* A hook outside the project (an absolute path elsewhere) falls back to a hash of
|
|
5034
|
+
* its directory: still stable and still isolated, just not readable — which is the
|
|
5035
|
+
* right trade for a case that should not happen in a project's own harness.
|
|
5036
|
+
*/
|
|
5037
|
+
function hookStateDir(file) {
|
|
5038
|
+
const dir = (0, node_path_1.dirname)((0, node_path_1.resolve)(process.cwd(), file));
|
|
5039
|
+
const rel = (0, node_path_1.relative)(process.cwd(), dir);
|
|
5040
|
+
const inside = rel !== "" && !rel.startsWith("..") && !(0, node_path_1.isAbsolute)(rel);
|
|
5041
|
+
return (0, node_path_1.resolve)(process.cwd(), ".vigiles/state", inside ? rel : `external-${(0, hash_js_1.sha256short)(dir)}`);
|
|
5042
|
+
}
|
|
5043
|
+
/** Read one recorded fact for a hook, or `null` if it was never recorded. */
|
|
5044
|
+
function readHookState(file, key) {
|
|
5045
|
+
try {
|
|
5046
|
+
const raw = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(hookStateDir(file), key + ".json"), "utf-8");
|
|
5047
|
+
const parsed = JSON.parse(raw);
|
|
5048
|
+
return typeof parsed.value === "string" && typeof parsed.at === "string"
|
|
5049
|
+
? parsed
|
|
5050
|
+
: null;
|
|
5051
|
+
}
|
|
5052
|
+
catch {
|
|
5053
|
+
// Never recorded, unreadable, or corrupt — all "no fact", which `stateFact`
|
|
5054
|
+
// turns into an infinite age, so the reading hook SPEAKS. Failing toward
|
|
5055
|
+
// noise is the whole point; a store problem must never look like freshness.
|
|
5056
|
+
return null;
|
|
5057
|
+
}
|
|
5058
|
+
}
|
|
5059
|
+
/**
|
|
5060
|
+
* Record one fact. Atomic: written to a temp file in the same directory and
|
|
5061
|
+
* `rename()`d over, so a concurrent reader sees the whole old entry or the whole
|
|
5062
|
+
* new one — never one write's value with another's timestamp. Distinct keys are
|
|
5063
|
+
* distinct files and never interact at all.
|
|
5064
|
+
*/
|
|
5065
|
+
function writeHookState(file, w) {
|
|
5066
|
+
const dir = hookStateDir(file);
|
|
5067
|
+
const target = (0, node_path_1.resolve)(dir, w.name + ".json");
|
|
5068
|
+
const entry = {
|
|
5069
|
+
value: w.value,
|
|
5070
|
+
at: new Date().toISOString(),
|
|
5071
|
+
by: (0, hook_install_js_1.normalizeHookRef)(file),
|
|
5072
|
+
};
|
|
5073
|
+
(0, node_fs_1.mkdirSync)(dir, { recursive: true });
|
|
5074
|
+
const tmp = `${target}.${String(process.pid)}.tmp`;
|
|
5075
|
+
(0, node_fs_1.writeFileSync)(tmp, JSON.stringify(entry, null, 2) + "\n");
|
|
5076
|
+
(0, node_fs_1.renameSync)(tmp, target);
|
|
5077
|
+
}
|
|
5078
|
+
/**
|
|
5079
|
+
* Perform the state writes a hook declared, after its output has been emitted.
|
|
5080
|
+
* A refused write (a hand-built record object with a key `record()` would have
|
|
5081
|
+
* thrown on) is announced — silence here would be a hook that believes it
|
|
5082
|
+
* remembered something.
|
|
5083
|
+
*/
|
|
5084
|
+
function applyHookWrites(file, outcome) {
|
|
5085
|
+
const { ok, refused } = (0, hook_program_js_1.outcomeWrites)(outcome);
|
|
5086
|
+
for (const name of refused) {
|
|
5087
|
+
console.error(`vigiles: refused to record ${name} from ${file} — not a valid state key.`);
|
|
5088
|
+
}
|
|
5089
|
+
for (const w of ok) {
|
|
5090
|
+
try {
|
|
5091
|
+
writeHookState(file, w);
|
|
5092
|
+
}
|
|
5093
|
+
catch (e) {
|
|
5094
|
+
console.error(`vigiles: could not record ${w.name} from ${file}: ${String(e)}`);
|
|
5095
|
+
}
|
|
5096
|
+
}
|
|
5097
|
+
}
|
|
5019
5098
|
/**
|
|
5020
5099
|
* Compile ONE typed hook program (authored against `vigiles/hook`) and MERGE it
|
|
5021
5100
|
* into the active harness's native config — the hook half of `vigiles compile`
|
|
@@ -5168,7 +5247,7 @@ async function installHooks(hookFiles, harnessFlag, configHarness) {
|
|
|
5168
5247
|
* that can't resolve yields its default (never throws). The pure registry +
|
|
5169
5248
|
* decision logic live in core/hook-providers.ts — this only injects the real IO.
|
|
5170
5249
|
*/
|
|
5171
|
-
async function gatherHookContext(program) {
|
|
5250
|
+
async function gatherHookContext(program, file) {
|
|
5172
5251
|
const needs = (0, hook_program_js_1.hookNeeds)(program);
|
|
5173
5252
|
if (needs.length === 0)
|
|
5174
5253
|
return {};
|
|
@@ -5185,6 +5264,10 @@ async function gatherHookContext(program) {
|
|
|
5185
5264
|
cwd: process.cwd(),
|
|
5186
5265
|
platform: process.platform,
|
|
5187
5266
|
isCI,
|
|
5267
|
+
// The namespace is bound HERE, from the hook's own path — core never sees
|
|
5268
|
+
// it, so no key a hook can spell reaches another owner's store.
|
|
5269
|
+
readState: (key) => readHookState(file, key),
|
|
5270
|
+
now: Date.now(),
|
|
5188
5271
|
}, registry);
|
|
5189
5272
|
}
|
|
5190
5273
|
/**
|
|
@@ -5498,13 +5581,30 @@ async function runHookProgramCommand(file) {
|
|
|
5498
5581
|
verifyStampOrRefuse(file, event);
|
|
5499
5582
|
switch ((0, hook_program_js_1.dispatchKind)(program)) {
|
|
5500
5583
|
case "inject": {
|
|
5501
|
-
const
|
|
5502
|
-
|
|
5584
|
+
const ctx = await gatherHookContext(program, file);
|
|
5585
|
+
const injection = (0, hook_program_js_1.injectionOf)(program, event, ctx);
|
|
5586
|
+
process.stdout.write(JSON.stringify({
|
|
5587
|
+
hookSpecificOutput: {
|
|
5588
|
+
hookEventName: program.on,
|
|
5589
|
+
additionalContext: injection.context,
|
|
5590
|
+
},
|
|
5591
|
+
}) + "\n");
|
|
5592
|
+
// Writes land AFTER the output is emitted: a hook that recorded "I spoke"
|
|
5593
|
+
// must not have recorded it if emitting threw.
|
|
5594
|
+
applyHookWrites(file, {
|
|
5595
|
+
kind: "injection",
|
|
5596
|
+
context: injection.context,
|
|
5597
|
+
records: injection.records,
|
|
5598
|
+
});
|
|
5503
5599
|
return;
|
|
5504
5600
|
}
|
|
5505
5601
|
case "react": {
|
|
5602
|
+
const ctx = await gatherHookContext(program, file);
|
|
5506
5603
|
warnIfPathUndecidable(event, projectRoot);
|
|
5507
|
-
const reaction = (0, hook_program_js_1.runReact)(program, event, projectRoot);
|
|
5604
|
+
const reaction = (0, hook_program_js_1.runReact)(program, event, ctx, projectRoot);
|
|
5605
|
+
if (reaction.kind === "notice")
|
|
5606
|
+
console.error(reaction.message);
|
|
5607
|
+
applyHookWrites(file, { kind: "reaction", reaction });
|
|
5508
5608
|
if (reaction.kind === "run") {
|
|
5509
5609
|
const { spawnSync } = require("node:child_process");
|
|
5510
5610
|
const res = spawnSync(reaction.command, {
|
|
@@ -5513,18 +5613,16 @@ async function runHookProgramCommand(file) {
|
|
|
5513
5613
|
});
|
|
5514
5614
|
process.exit(res.status ?? 0);
|
|
5515
5615
|
}
|
|
5516
|
-
if (reaction.kind === "notice")
|
|
5517
|
-
console.error(reaction.message);
|
|
5518
5616
|
return;
|
|
5519
5617
|
}
|
|
5520
5618
|
case "file-gate": {
|
|
5521
|
-
const ctx = await gatherHookContext(program);
|
|
5619
|
+
const ctx = await gatherHookContext(program, file);
|
|
5522
5620
|
warnIfPathUndecidable(event, projectRoot);
|
|
5523
5621
|
emitGate((0, hook_program_js_1.decideFileGate)(program, event, ctx, projectRoot), program.on, (0, hook_program_js_1.hookMode)(program), file);
|
|
5524
5622
|
return;
|
|
5525
5623
|
}
|
|
5526
5624
|
case "bash-gate": {
|
|
5527
|
-
const ctx = await gatherHookContext(program);
|
|
5625
|
+
const ctx = await gatherHookContext(program, file);
|
|
5528
5626
|
// The same `projectRoot` the file gates get: without it every
|
|
5529
5627
|
// repo-relative prefix in a DENYLIST matcher (`touches`/`writesTo`) is
|
|
5530
5628
|
// matched by over-blocking alone, and with it an absolute token is placed
|
|
@@ -5534,12 +5632,12 @@ async function runHookProgramCommand(file) {
|
|
|
5534
5632
|
return;
|
|
5535
5633
|
}
|
|
5536
5634
|
case "prompt-gate": {
|
|
5537
|
-
const ctx = await gatherHookContext(program);
|
|
5635
|
+
const ctx = await gatherHookContext(program, file);
|
|
5538
5636
|
emitGate((0, hook_program_js_1.decidePromptGate)(program, event, ctx), program.on, (0, hook_program_js_1.hookMode)(program), file);
|
|
5539
5637
|
return;
|
|
5540
5638
|
}
|
|
5541
5639
|
case "stop-gate": {
|
|
5542
|
-
const ctx = await gatherHookContext(program);
|
|
5640
|
+
const ctx = await gatherHookContext(program, file);
|
|
5543
5641
|
emitGate((0, hook_program_js_1.decideStopGate)(program, event, ctx), program.on, (0, hook_program_js_1.hookMode)(program), file);
|
|
5544
5642
|
return;
|
|
5545
5643
|
}
|
|
@@ -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
|
|
@@ -34,6 +34,47 @@ import { type SHA256Hash } from "./hash.js";
|
|
|
34
34
|
import type { HarnessDialect } from "./dialect.js";
|
|
35
35
|
import type { HookProtocol } from "./hook-protocol.js";
|
|
36
36
|
import { type ProviderName, type NeedSpec, type HookCtx } from "./hook-providers.js";
|
|
37
|
+
import { type StateFact, type StateWrite } from "./hook-state.js";
|
|
38
|
+
/**
|
|
39
|
+
* The erased runtime shape of a gathered context. Author-facing types keep the
|
|
40
|
+
* precise `HookCtx<N>`; the decode functions re-narrow and cast.
|
|
41
|
+
*/
|
|
42
|
+
type RawCtx = Record<string, string | boolean | StateFact>;
|
|
43
|
+
/**
|
|
44
|
+
* Does `name` match a hook's declared tool list, under the SAME semantics as the
|
|
45
|
+
* matcher the compiler emits for it?
|
|
46
|
+
*
|
|
47
|
+
* 🔴 IT DID NOT, AND THE DISAGREEMENT WAS SILENT. `hookRouting` joins a react's
|
|
48
|
+
* tools with `|` and emits that as the harness matcher, and a Claude Code matcher
|
|
49
|
+
* is a REGEX — `Edit|Write|MultiEdit` only works because it is one. The runtime
|
|
50
|
+
* meanwhile compared with `Array.includes`, i.e. exact string equality. So a hook
|
|
51
|
+
* declaring a tool FAMILY compiled fine, was wired up fine, was routed to by the
|
|
52
|
+
* harness fine, and was then dropped by vigiles' own filter without a word.
|
|
53
|
+
*
|
|
54
|
+
* MEASURED 2026-08-12 against the real runtime, before the fix:
|
|
55
|
+
*
|
|
56
|
+
* $ echo '{"tool_name":"mcp__4f54037d-0499__list_events",…}' \
|
|
57
|
+
* | vigiles hook-runtime run-program mcp-family.hook.mjs
|
|
58
|
+
* exit=0 # silence — react() never ran
|
|
59
|
+
* $ echo '{"tool_name":"mcp__.*",…}' | …
|
|
60
|
+
* FIRED on mcp__.* # fires only for a tool LITERALLY named "mcp__.*"
|
|
61
|
+
*
|
|
62
|
+
* That is the false-confidence class this whole subsystem exists to eliminate,
|
|
63
|
+
* living inside the subsystem. The live evidence that the harness really does
|
|
64
|
+
* route these: the knowledge base has shipped `"matcher": "mcp__.*"` in
|
|
65
|
+
* `.claude/settings.json` for months and its stamp file was last written the
|
|
66
|
+
* morning this was measured. The MCP server's id changes per session, so an exact
|
|
67
|
+
* list cannot be written down — a family matcher is the only correct spelling.
|
|
68
|
+
*
|
|
69
|
+
* Anchored `^(…)$` so a pattern cannot match a longer tool name by accident, and
|
|
70
|
+
* identical to `includes` for ordinary names, which contain no metacharacters.
|
|
71
|
+
* An unparseable pattern is rejected at COMPILE ({@link invalidToolPatterns}), so
|
|
72
|
+
* the fallback here is unreachable in a compiled hook and exists only so that a
|
|
73
|
+
* hand-constructed one degrades to exact matching rather than throwing mid-event.
|
|
74
|
+
*/
|
|
75
|
+
export declare function matchesTool(tools: readonly string[], name: string): boolean;
|
|
76
|
+
/** Tool patterns that are not valid regexes — rejected at compile, see {@link matchesTool}. */
|
|
77
|
+
export declare function invalidToolPatterns(tools: readonly string[]): string[];
|
|
37
78
|
export type Decision = {
|
|
38
79
|
readonly kind: "allow";
|
|
39
80
|
} | {
|
|
@@ -128,8 +169,33 @@ export interface CommandView {
|
|
|
128
169
|
*
|
|
129
170
|
* Deletion is a different question and is deliberately NOT reported here —
|
|
130
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.
|
|
131
175
|
*/
|
|
132
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[];
|
|
133
199
|
/**
|
|
134
200
|
* True iff the command pipes into a BARE shell interpreter (`curl … | sh`,
|
|
135
201
|
* `… | bash -s`) — the remote-code-execution shape. High-signal: a shell leaf
|
|
@@ -214,7 +280,7 @@ export declare function decideProgram<N extends readonly NeedSpec[]>(program: Ho
|
|
|
214
280
|
command?: unknown;
|
|
215
281
|
};
|
|
216
282
|
cwd?: unknown;
|
|
217
|
-
}, ctx?:
|
|
283
|
+
}, ctx?: RawCtx, root?: string | undefined): Decision;
|
|
218
284
|
/** Map a Decision to the CC hook exit code — the protocol the author never writes. */
|
|
219
285
|
export declare function decisionExitCode(d: Decision): number;
|
|
220
286
|
/**
|
|
@@ -277,7 +343,7 @@ export interface CompileHookOptions {
|
|
|
277
343
|
* (they never trust this type). Author-facing types keep the precise `N`.
|
|
278
344
|
*/
|
|
279
345
|
type ErasedNeeds = readonly any[];
|
|
280
|
-
export type AnyHook = HookProgram<ErasedNeeds> | FileGateHook<ErasedNeeds> | PromptGateHook<ErasedNeeds> | StopGateHook<ErasedNeeds> | InjectHook | ReactHook
|
|
346
|
+
export type AnyHook = HookProgram<ErasedNeeds> | FileGateHook<ErasedNeeds> | PromptGateHook<ErasedNeeds> | StopGateHook<ErasedNeeds> | InjectHook<ErasedNeeds> | ReactHook<ErasedNeeds>;
|
|
281
347
|
/**
|
|
282
348
|
* The runtime dispatch shapes. A bare {@link HookProgram} (no `role`) is a Bash
|
|
283
349
|
* command gate; the role-keyed hooks carry their own shape. The runtime uses this
|
|
@@ -456,7 +522,7 @@ export declare function decideFileGate<N extends readonly NeedSpec[]>(hook: File
|
|
|
456
522
|
file_path?: unknown;
|
|
457
523
|
};
|
|
458
524
|
cwd?: unknown;
|
|
459
|
-
}, ctx?:
|
|
525
|
+
}, ctx?: RawCtx, root?: string | undefined): Decision;
|
|
460
526
|
/** The event a UserPromptSubmit gate decides over — it sees the prompt TEXT. */
|
|
461
527
|
export interface PromptEvent<N extends readonly NeedSpec[] = readonly ProviderName[]> {
|
|
462
528
|
readonly event: string;
|
|
@@ -480,7 +546,7 @@ export declare function definePromptGate<const N extends readonly NeedSpec[] = r
|
|
|
480
546
|
/** Run a prompt gate against a raw UserPromptSubmit event (reads `prompt`). */
|
|
481
547
|
export declare function decidePromptGate<N extends readonly NeedSpec[]>(hook: PromptGateHook<N>, raw: {
|
|
482
548
|
prompt?: unknown;
|
|
483
|
-
}, ctx?:
|
|
549
|
+
}, ctx?: RawCtx): Decision;
|
|
484
550
|
/**
|
|
485
551
|
* The event a Stop gate decides over. `deny` BLOCKS the agent from ending its
|
|
486
552
|
* turn (the reason is surfaced to the agent — e.g. "tests are red, keep going").
|
|
@@ -511,38 +577,65 @@ export declare function defineStopGate<const N extends readonly NeedSpec[] = rea
|
|
|
511
577
|
/** Run a Stop gate against a raw Stop/SubagentStop event (reads `stop_hook_active`). */
|
|
512
578
|
export declare function decideStopGate<N extends readonly NeedSpec[]>(hook: StopGateHook<N>, raw: {
|
|
513
579
|
stop_hook_active?: unknown;
|
|
514
|
-
}, ctx?:
|
|
580
|
+
}, ctx?: RawCtx): Decision;
|
|
515
581
|
/** The output of an inject hook — text to add to context. NO allow/deny exists here. */
|
|
516
582
|
export interface Injection {
|
|
517
583
|
readonly kind: "inject";
|
|
518
584
|
readonly context: string;
|
|
585
|
+
/** Facts to record — a DECLARATION; the trusted runtime performs the write. */
|
|
586
|
+
readonly records: readonly StateWrite[];
|
|
519
587
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
588
|
+
/**
|
|
589
|
+
* Context to add, plus any facts that just became true:
|
|
590
|
+
* `inject(text, record("calendar.nagged"))`.
|
|
591
|
+
*
|
|
592
|
+
* The writes are trailing arguments on every output builder, so there is one rule
|
|
593
|
+
* to learn rather than a per-role spelling — and a hook that records nothing is
|
|
594
|
+
* written exactly as it was before.
|
|
595
|
+
*/
|
|
596
|
+
export declare const inject: (context: string, ...records: readonly StateWrite[]) => Injection;
|
|
597
|
+
/**
|
|
598
|
+
* The event an inject hook produces from (no tool — SessionStart/UserPromptSubmit).
|
|
599
|
+
* Generic over its declared `needs`, exactly like the gate events.
|
|
600
|
+
*/
|
|
601
|
+
export interface SessionEvent<N extends readonly NeedSpec[] = readonly ProviderName[]> {
|
|
523
602
|
readonly event: string;
|
|
524
603
|
readonly source: string;
|
|
604
|
+
/** Host-gathered, DECLARED facts — built-ins, inline providers, and `state()` reads. */
|
|
605
|
+
readonly ctx: HookCtx<N>;
|
|
525
606
|
}
|
|
526
|
-
export interface InjectHook {
|
|
607
|
+
export interface InjectHook<N extends readonly NeedSpec[] = readonly ProviderName[]> {
|
|
527
608
|
readonly role: "inject";
|
|
528
609
|
readonly on: string;
|
|
610
|
+
/**
|
|
611
|
+
* Declared context providers the trusted runtime gathers into `e.ctx`.
|
|
612
|
+
*
|
|
613
|
+
* Injects and reacts could not declare `needs` at all until state landed, for
|
|
614
|
+
* no reason anyone had recorded — and a fact a hook cannot read is not a
|
|
615
|
+
* feature. `needs` is now uniform across every role.
|
|
616
|
+
*/
|
|
617
|
+
readonly needs?: N;
|
|
529
618
|
/** Produces context to add. Its return type (Injection) has no `deny` — by design. */
|
|
530
|
-
readonly produce: (e: SessionEvent) => Injection;
|
|
619
|
+
readonly produce: (e: SessionEvent<N>) => Injection;
|
|
531
620
|
}
|
|
532
|
-
export declare
|
|
621
|
+
export declare function defineInject<const N extends readonly NeedSpec[] = readonly []>(p: Omit<InjectHook<N>, "role">): InjectHook<N>;
|
|
533
622
|
/**
|
|
534
623
|
* Run an inject hook → the CC JSON the author never hand-writes. The compiler
|
|
535
624
|
* targets `additionalContext` (the RIGHT field for this event), so the
|
|
536
625
|
* wrong-JSON-field pain can't occur.
|
|
537
626
|
*/
|
|
538
|
-
export declare function runInject(hook: InjectHook
|
|
627
|
+
export declare function runInject<N extends readonly NeedSpec[]>(hook: InjectHook<N>, raw: {
|
|
539
628
|
source?: string;
|
|
540
|
-
}): {
|
|
629
|
+
}, ctx?: RawCtx): {
|
|
541
630
|
hookSpecificOutput: {
|
|
542
631
|
hookEventName: string;
|
|
543
632
|
additionalContext: string;
|
|
544
633
|
};
|
|
545
634
|
};
|
|
635
|
+
/** The full {@link Injection} — the runtime needs its `records`, which the CC JSON drops. */
|
|
636
|
+
export declare function injectionOf<N extends readonly NeedSpec[]>(hook: InjectHook<N>, raw: {
|
|
637
|
+
source?: string;
|
|
638
|
+
}, ctx?: RawCtx): Injection;
|
|
546
639
|
/**
|
|
547
640
|
* A view of a tool's RESPONSE (PostToolUse) — the matching primitive a react hook
|
|
548
641
|
* reasons over (e.g. capture/notify only when a command FAILED). The author never
|
|
@@ -561,54 +654,88 @@ export interface ResponseView {
|
|
|
561
654
|
}
|
|
562
655
|
export declare function responseView(raw: unknown): ResponseView;
|
|
563
656
|
/** The event a react hook reacts over — the tool, the file path, AND its response. */
|
|
564
|
-
export interface ReactEvent {
|
|
657
|
+
export interface ReactEvent<N extends readonly NeedSpec[] = readonly ProviderName[]> {
|
|
565
658
|
readonly event: string;
|
|
566
659
|
readonly tool: string;
|
|
567
660
|
readonly path: PathView;
|
|
568
661
|
/** The tool's response (PostToolUse) — react only on an error, capture output, … */
|
|
569
662
|
readonly response: ResponseView;
|
|
663
|
+
/** Host-gathered, DECLARED facts — built-ins, inline providers, and `state()` reads. */
|
|
664
|
+
readonly ctx: HookCtx<N>;
|
|
570
665
|
}
|
|
571
666
|
export interface RunReaction {
|
|
572
667
|
readonly kind: "run";
|
|
573
668
|
readonly command: string;
|
|
574
669
|
readonly effect: BashEffect;
|
|
670
|
+
readonly records: readonly StateWrite[];
|
|
575
671
|
}
|
|
576
672
|
export type Reaction = RunReaction | {
|
|
577
673
|
readonly kind: "notice";
|
|
578
674
|
readonly message: string;
|
|
675
|
+
readonly records: readonly StateWrite[];
|
|
579
676
|
} | {
|
|
580
677
|
readonly kind: "none";
|
|
678
|
+
readonly records: readonly StateWrite[];
|
|
581
679
|
};
|
|
582
|
-
/**
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
680
|
+
/**
|
|
681
|
+
* Run a command in reaction — its effect is classified AT CONSTRUCTION
|
|
682
|
+
* (audit/diff-able). Trailing arguments record facts.
|
|
683
|
+
*
|
|
684
|
+
* ⚠️ `run()` is for invoking a real TOOL. Using it to write a stamp
|
|
685
|
+
* (`run("date +%s > .claude/.stamp")`) was the only way to remember anything
|
|
686
|
+
* before `record()` existed; it is now the wrong tool — it spends a subprocess
|
|
687
|
+
* and a shell on a variable assignment, and it classifies as side-effecting.
|
|
688
|
+
*/
|
|
689
|
+
export declare const run: (command: string, ...records: readonly StateWrite[]) => RunReaction;
|
|
690
|
+
/** Surface a non-blocking note (no execution). Trailing arguments record facts. */
|
|
691
|
+
export declare const notice: (message: string, ...records: readonly StateWrite[]) => Reaction;
|
|
692
|
+
/**
|
|
693
|
+
* Take no action. Trailing arguments still record facts — `nothing(record("x"))`
|
|
694
|
+
* is the shape of a hook whose entire job is to WITNESS that something happened
|
|
695
|
+
* (an MCP call, a deploy) so a different hook can read it later.
|
|
696
|
+
*/
|
|
697
|
+
export declare const nothing: (...records: readonly StateWrite[]) => Reaction;
|
|
698
|
+
export interface ReactHook<N extends readonly NeedSpec[] = readonly ProviderName[]> {
|
|
589
699
|
readonly role: "react";
|
|
590
700
|
readonly on: string;
|
|
591
|
-
|
|
701
|
+
/**
|
|
702
|
+
* Which tools to react to. OMIT IT for an event that carries no tool at all
|
|
703
|
+
* (`Stop`, `SubagentStop`, `SessionEnd`), exactly as inject and the prompt/stop
|
|
704
|
+
* gates already do — `hookRouting` then emits no matcher.
|
|
705
|
+
*
|
|
706
|
+
* 🔴 IT USED TO BE REQUIRED, WHICH MADE EVERY TOOL-LESS REACT DEAD. `runReact`
|
|
707
|
+
* gates on `tool_name`; a `Stop` event carries none, so the name defaulted to
|
|
708
|
+
* `""`, matched nothing, and the hook returned `nothing()` forever. MEASURED
|
|
709
|
+
* 2026-08-12 against the real runtime: a `Stop` react printed nothing and
|
|
710
|
+
* exited 0 — indistinguishable from a hook that decided to stay quiet, which
|
|
711
|
+
* is the whole reason advisory hooks die unnoticed. Three of the seven hooks
|
|
712
|
+
* this feature was built for are `Stop` nudges.
|
|
713
|
+
*/
|
|
714
|
+
readonly match?: {
|
|
592
715
|
readonly tools: readonly string[];
|
|
593
716
|
};
|
|
717
|
+
/** Declared context providers the trusted runtime gathers into `e.ctx`. */
|
|
718
|
+
readonly needs?: N;
|
|
594
719
|
/** Reacts to a tool that already ran. Returns a Reaction — NO `deny` exists here. */
|
|
595
|
-
readonly react: (e: ReactEvent) => Reaction;
|
|
720
|
+
readonly react: (e: ReactEvent<N>) => Reaction;
|
|
596
721
|
}
|
|
597
|
-
export declare
|
|
722
|
+
export declare function defineReact<const N extends readonly NeedSpec[] = readonly []>(p: Omit<ReactHook<N>, "role">): ReactHook<N>;
|
|
598
723
|
/**
|
|
599
724
|
* Run a react hook against a raw PostToolUse event → the (classified) Reaction.
|
|
600
725
|
*
|
|
601
726
|
* `root` behaves exactly as in {@link decideFileGate}: the event's own `cwd` by
|
|
602
|
-
* default, the CLI's {@link projectRootOf} when the runtime supplies one.
|
|
727
|
+
* default, the CLI's {@link projectRootOf} when the runtime supplies one. It
|
|
728
|
+
* trails `ctx` so the argument order matches {@link decideProgram} and
|
|
729
|
+
* {@link decideFileGate} — every decode function reads `(hook, raw, ctx, root)`.
|
|
603
730
|
*/
|
|
604
|
-
export declare function runReact(hook: ReactHook
|
|
731
|
+
export declare function runReact<N extends readonly NeedSpec[]>(hook: ReactHook<N>, raw: {
|
|
605
732
|
tool_name?: string;
|
|
606
733
|
tool_input?: {
|
|
607
734
|
file_path?: unknown;
|
|
608
735
|
};
|
|
609
736
|
tool_response?: unknown;
|
|
610
737
|
cwd?: unknown;
|
|
611
|
-
}, root?: string | undefined): Reaction;
|
|
738
|
+
}, ctx?: RawCtx, root?: string | undefined): Reaction;
|
|
612
739
|
/** The raw event fields the decode functions read (the union across roles). */
|
|
613
740
|
export interface RawHookEvent {
|
|
614
741
|
readonly tool_name?: string;
|
|
@@ -638,10 +765,22 @@ export type HookProgramOutcome = {
|
|
|
638
765
|
} | {
|
|
639
766
|
readonly kind: "injection";
|
|
640
767
|
readonly context: string;
|
|
768
|
+
readonly records: readonly StateWrite[];
|
|
641
769
|
} | {
|
|
642
770
|
readonly kind: "reaction";
|
|
643
771
|
readonly reaction: Reaction;
|
|
644
772
|
};
|
|
773
|
+
/**
|
|
774
|
+
* The state writes an outcome declares, filtered to the ones the runtime may
|
|
775
|
+
* actually perform. A gate's `Decision` carries none — deliberately: a gate is
|
|
776
|
+
* the role that must be trustworthy and runs on every tool call, so it READS
|
|
777
|
+
* state (via `needs`) and never writes it. Adding a write there later is easy;
|
|
778
|
+
* removing one would not be.
|
|
779
|
+
*/
|
|
780
|
+
export declare function outcomeWrites(outcome: HookProgramOutcome): {
|
|
781
|
+
readonly ok: readonly StateWrite[];
|
|
782
|
+
readonly refused: readonly string[];
|
|
783
|
+
};
|
|
645
784
|
/** Record where a hook program was loaded from. Called by `loadHook` ONLY. */
|
|
646
785
|
export declare function rememberHookSource(hook: AnyHook, file: string): void;
|
|
647
786
|
/** The file a hook was loaded from, or `undefined` for one built in-process. */
|
|
@@ -652,7 +791,7 @@ export declare function hookSource(hook: AnyHook): string | undefined;
|
|
|
652
791
|
* (effect-classified) `Reaction`. Pure — no subprocess, no model. The ergonomic
|
|
653
792
|
* base for testing a compiled hook (see `assertHookDenies` / `assertHookAllows`).
|
|
654
793
|
*/
|
|
655
|
-
export declare function runHookProgram(hook: AnyHook, event: RawHookEvent, ctx?:
|
|
794
|
+
export declare function runHookProgram(hook: AnyHook, event: RawHookEvent, ctx?: RawCtx, root?: string | undefined): HookProgramOutcome;
|
|
656
795
|
/**
|
|
657
796
|
* The repository a wedged hook belongs to, as the runtime sees it. Supplied by
|
|
658
797
|
* the caller because core takes no `node:path` and reads no disk — and because
|