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.
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.defineReact = exports.nothing = exports.notice = exports.run = exports.defineInject = exports.inject = exports.tools = exports.HookCompileError = exports.tool = exports.ask = exports.deny = exports.allow = void 0;
3
+ exports.nothing = exports.notice = exports.run = exports.inject = exports.tools = exports.HookCompileError = exports.tool = exports.ask = exports.deny = exports.allow = void 0;
4
+ exports.matchesTool = matchesTool;
5
+ exports.invalidToolPatterns = invalidToolPatterns;
4
6
  exports.gateAction = gateAction;
5
7
  exports.trimTrailingSeparators = trimTrailingSeparators;
6
8
  exports.commandView = commandView;
@@ -24,9 +26,13 @@ exports.definePromptGate = definePromptGate;
24
26
  exports.decidePromptGate = decidePromptGate;
25
27
  exports.defineStopGate = defineStopGate;
26
28
  exports.decideStopGate = decideStopGate;
29
+ exports.defineInject = defineInject;
27
30
  exports.runInject = runInject;
31
+ exports.injectionOf = injectionOf;
28
32
  exports.responseView = responseView;
33
+ exports.defineReact = defineReact;
29
34
  exports.runReact = runReact;
35
+ exports.outcomeWrites = outcomeWrites;
30
36
  exports.rememberHookSource = rememberHookSource;
31
37
  exports.hookSource = hookSource;
32
38
  exports.runHookProgram = runHookProgram;
@@ -69,6 +75,69 @@ const toml_1 = require("@iarna/toml");
69
75
  const hook_events_js_1 = require("./hook-events.js");
70
76
  const merge_conflict_js_1 = require("./merge-conflict.js");
71
77
  const hook_providers_js_1 = require("./hook-providers.js");
78
+ const hook_state_js_1 = require("./hook-state.js");
79
+ /**
80
+ * Does `name` match a hook's declared tool list, under the SAME semantics as the
81
+ * matcher the compiler emits for it?
82
+ *
83
+ * 🔴 IT DID NOT, AND THE DISAGREEMENT WAS SILENT. `hookRouting` joins a react's
84
+ * tools with `|` and emits that as the harness matcher, and a Claude Code matcher
85
+ * is a REGEX — `Edit|Write|MultiEdit` only works because it is one. The runtime
86
+ * meanwhile compared with `Array.includes`, i.e. exact string equality. So a hook
87
+ * declaring a tool FAMILY compiled fine, was wired up fine, was routed to by the
88
+ * harness fine, and was then dropped by vigiles' own filter without a word.
89
+ *
90
+ * MEASURED 2026-08-12 against the real runtime, before the fix:
91
+ *
92
+ * $ echo '{"tool_name":"mcp__4f54037d-0499__list_events",…}' \
93
+ * | vigiles hook-runtime run-program mcp-family.hook.mjs
94
+ * exit=0 # silence — react() never ran
95
+ * $ echo '{"tool_name":"mcp__.*",…}' | …
96
+ * FIRED on mcp__.* # fires only for a tool LITERALLY named "mcp__.*"
97
+ *
98
+ * That is the false-confidence class this whole subsystem exists to eliminate,
99
+ * living inside the subsystem. The live evidence that the harness really does
100
+ * route these: the knowledge base has shipped `"matcher": "mcp__.*"` in
101
+ * `.claude/settings.json` for months and its stamp file was last written the
102
+ * morning this was measured. The MCP server's id changes per session, so an exact
103
+ * list cannot be written down — a family matcher is the only correct spelling.
104
+ *
105
+ * Anchored `^(…)$` so a pattern cannot match a longer tool name by accident, and
106
+ * identical to `includes` for ordinary names, which contain no metacharacters.
107
+ * An unparseable pattern is rejected at COMPILE ({@link invalidToolPatterns}), so
108
+ * the fallback here is unreachable in a compiled hook and exists only so that a
109
+ * hand-constructed one degrades to exact matching rather than throwing mid-event.
110
+ */
111
+ function matchesTool(tools, name) {
112
+ // An EMPTY list matches NOTHING. Found by a mutation that was meant to disable
113
+ // tool-less reacts and didn't: with no tools the joined pattern is `^()$`,
114
+ // which matches the EMPTY STRING — and a tool-less event's name is the empty
115
+ // string. Without this line `tools()` would quietly be a catch-all on exactly
116
+ // the events where a react has no tool to check. Declaring nothing must mean
117
+ // nothing, not everything.
118
+ if (tools.length === 0)
119
+ return false;
120
+ if (tools.includes(name))
121
+ return true;
122
+ try {
123
+ return new RegExp(`^(${tools.join("|")})$`).test(name);
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ }
129
+ /** Tool patterns that are not valid regexes — rejected at compile, see {@link matchesTool}. */
130
+ function invalidToolPatterns(tools) {
131
+ return tools.filter((t) => {
132
+ try {
133
+ new RegExp(`^(${t})$`);
134
+ return false;
135
+ }
136
+ catch {
137
+ return true;
138
+ }
139
+ });
140
+ }
72
141
  const allow = () => ({ kind: "allow" });
73
142
  exports.allow = allow;
74
143
  const deny = (reason) => ({ kind: "deny", reason });
@@ -371,6 +440,18 @@ function writeTargetsOf(leaf) {
371
440
  return [];
372
441
  }
373
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);
374
455
  /**
375
456
  * An AST-backed view of a Bash command.
376
457
  *
@@ -388,10 +469,20 @@ function commandView(raw, root) {
388
469
  // The operation-normalized leaves carry the redirections (and quote-unwrapped,
389
470
  // wrapper-resolved argv) that `writesTo` needs; `leafCommands` cannot see them.
390
471
  const normalized = (0, bash_effects_js_1.leafCommandsNormalized)(raw);
391
- 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.
392
478
  ...leaf.redirects.flatMap((r) => r.writes && r.target !== null ? [r.target] : []),
393
- ...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)),
394
482
  ]);
483
+ const matchedWriteTargets = (prefixes) => [
484
+ ...new Set(allWriteTargets.filter((t) => prefixes.some((p) => matchesPrefix(prefixVerdict(t, p, root), "match")))),
485
+ ];
395
486
  return {
396
487
  raw,
397
488
  runs(program, opts) {
@@ -432,7 +523,13 @@ function commandView(raw, root) {
432
523
  ? [tok, tok.slice(tok.indexOf("=") + 1)]
433
524
  : [tok])
434
525
  .some((tok) => prefixes.some((p) => matchesPrefix(prefixVerdict(tok, p, root), "match"))),
435
- 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,
436
533
  pipesToShell: () => leaves.some(isBareShellLeaf),
437
534
  };
438
535
  }
@@ -523,6 +620,9 @@ function hookRouting(hook) {
523
620
  hook.role === "prompt-gate" ||
524
621
  hook.role === "stop-gate")
525
622
  return { on: hook.on };
623
+ // A react MAY also be tool-less (Stop/SessionEnd) — same shape, same reason.
624
+ if (hook.match === undefined)
625
+ return { on: hook.on };
526
626
  return { on: hook.on, matcher: hook.match.tools.join("|") };
527
627
  }
528
628
  return { on: hook.on, matcher: hook.match.tool };
@@ -569,6 +669,16 @@ function compileHookProgram(source, hook, opts = {}) {
569
669
  throw new HookCompileError(`hook program uses capabilities outside \`${ALLOWED_IMPORT}\`: ${violations.join(", ")} — only the sanctioned API is allowed (capability = API surface).`);
570
670
  }
571
671
  const { on, matcher: rawMatcher } = hookRouting(hook);
672
+ // A tool pattern that is not a valid regex cannot match under the semantics the
673
+ // emitted matcher is read with, so it would compile to a hook that never fires.
674
+ // Reject it here rather than let `matchesTool` fall back silently.
675
+ if ("match" in hook && hook.match !== undefined && "tools" in hook.match) {
676
+ const bad = invalidToolPatterns(hook.match.tools);
677
+ if (bad.length > 0) {
678
+ throw new HookCompileError(`invalid tool matcher pattern(s): ${bad.join(", ")} — a tool matcher is a ` +
679
+ `regex (that is why "Edit|Write" works), so it must parse as one.`);
680
+ }
681
+ }
572
682
  // A hook registered under an event the harness never fires is dead — reject it.
573
683
  if (opts.dialect) {
574
684
  const issues = (0, hook_events_js_1.verifyHookEvents)([on], opts.dialect);
@@ -809,7 +919,8 @@ function defineFileGate(p) {
809
919
  */
810
920
  function decideFileGate(hook, raw, ctx = {}, root = typeof raw.cwd === "string" ? raw.cwd : undefined) {
811
921
  const t = raw.tool_name ?? "";
812
- if (!hook.match.tools.includes(t))
922
+ // Same matcher semantics as the emitted settings block — see {@link matchesTool}.
923
+ if (!matchesTool(hook.match.tools, t))
813
924
  return (0, exports.allow)();
814
925
  const fp = typeof raw.tool_input?.file_path === "string"
815
926
  ? raw.tool_input.file_path
@@ -844,23 +955,30 @@ function decideStopGate(hook, raw, ctx = {}) {
844
955
  ctx: ctx,
845
956
  });
846
957
  }
847
- const inject = (context) => ({
958
+ /**
959
+ * Context to add, plus any facts that just became true:
960
+ * `inject(text, record("calendar.nagged"))`.
961
+ *
962
+ * The writes are trailing arguments on every output builder, so there is one rule
963
+ * to learn rather than a per-role spelling — and a hook that records nothing is
964
+ * written exactly as it was before.
965
+ */
966
+ const inject = (context, ...records) => ({
848
967
  kind: "inject",
849
968
  context,
969
+ records,
850
970
  });
851
971
  exports.inject = inject;
852
- const defineInject = (p) => ({
853
- role: "inject",
854
- ...p,
855
- });
856
- exports.defineInject = defineInject;
972
+ function defineInject(p) {
973
+ return { role: "inject", ...p };
974
+ }
857
975
  /**
858
976
  * Run an inject hook → the CC JSON the author never hand-writes. The compiler
859
977
  * targets `additionalContext` (the RIGHT field for this event), so the
860
978
  * wrong-JSON-field pain can't occur.
861
979
  */
862
- function runInject(hook, raw) {
863
- const out = hook.produce({ event: hook.on, source: raw.source ?? "startup" });
980
+ function runInject(hook, raw, ctx = {}) {
981
+ const out = injectionOf(hook, raw, ctx);
864
982
  return {
865
983
  hookSpecificOutput: {
866
984
  hookEventName: hook.on,
@@ -868,6 +986,14 @@ function runInject(hook, raw) {
868
986
  },
869
987
  };
870
988
  }
989
+ /** The full {@link Injection} — the runtime needs its `records`, which the CC JSON drops. */
990
+ function injectionOf(hook, raw, ctx = {}) {
991
+ return hook.produce({
992
+ event: hook.on,
993
+ source: raw.source ?? "startup",
994
+ ctx: ctx,
995
+ });
996
+ }
871
997
  /** Normalize an arbitrary tool_response payload to text (object → JSON). */
872
998
  function responseText(raw) {
873
999
  if (typeof raw === "string")
@@ -888,36 +1014,55 @@ function responseView(raw) {
888
1014
  contains: (needle) => text.includes(needle),
889
1015
  };
890
1016
  }
891
- /** Run a command in reaction — its effect is classified AT CONSTRUCTION (audit/diff-able). */
892
- const run = (command) => ({
1017
+ /**
1018
+ * Run a command in reaction — its effect is classified AT CONSTRUCTION
1019
+ * (audit/diff-able). Trailing arguments record facts.
1020
+ *
1021
+ * ⚠️ `run()` is for invoking a real TOOL. Using it to write a stamp
1022
+ * (`run("date +%s > .claude/.stamp")`) was the only way to remember anything
1023
+ * before `record()` existed; it is now the wrong tool — it spends a subprocess
1024
+ * and a shell on a variable assignment, and it classifies as side-effecting.
1025
+ */
1026
+ const run = (command, ...records) => ({
893
1027
  kind: "run",
894
1028
  command,
895
1029
  effect: (0, bash_effects_js_1.classifyBashCommand)(command),
1030
+ records,
896
1031
  });
897
1032
  exports.run = run;
898
- /** Surface a non-blocking note (no execution). */
899
- const notice = (message) => ({
1033
+ /** Surface a non-blocking note (no execution). Trailing arguments record facts. */
1034
+ const notice = (message, ...records) => ({
900
1035
  kind: "notice",
901
1036
  message,
1037
+ records,
902
1038
  });
903
1039
  exports.notice = notice;
904
- /** Do nothing. */
905
- const nothing = () => ({ kind: "none" });
906
- exports.nothing = nothing;
907
- const defineReact = (p) => ({
908
- role: "react",
909
- ...p,
1040
+ /**
1041
+ * Take no action. Trailing arguments still record facts — `nothing(record("x"))`
1042
+ * is the shape of a hook whose entire job is to WITNESS that something happened
1043
+ * (an MCP call, a deploy) so a different hook can read it later.
1044
+ */
1045
+ const nothing = (...records) => ({
1046
+ kind: "none",
1047
+ records,
910
1048
  });
911
- exports.defineReact = defineReact;
1049
+ exports.nothing = nothing;
1050
+ function defineReact(p) {
1051
+ return { role: "react", ...p };
1052
+ }
912
1053
  /**
913
1054
  * Run a react hook against a raw PostToolUse event → the (classified) Reaction.
914
1055
  *
915
1056
  * `root` behaves exactly as in {@link decideFileGate}: the event's own `cwd` by
916
- * default, the CLI's {@link projectRootOf} when the runtime supplies one.
1057
+ * default, the CLI's {@link projectRootOf} when the runtime supplies one. It
1058
+ * trails `ctx` so the argument order matches {@link decideProgram} and
1059
+ * {@link decideFileGate} — every decode function reads `(hook, raw, ctx, root)`.
917
1060
  */
918
- function runReact(hook, raw, root = typeof raw.cwd === "string" ? raw.cwd : undefined) {
1061
+ function runReact(hook, raw, ctx = {}, root = typeof raw.cwd === "string" ? raw.cwd : undefined) {
919
1062
  const t = raw.tool_name ?? "";
920
- if (!hook.match.tools.includes(t))
1063
+ // No `match` → a tool-less event (Stop/SessionEnd): there is nothing to filter
1064
+ // on, and filtering on the empty string is how these hooks used to die.
1065
+ if (hook.match !== undefined && !matchesTool(hook.match.tools, t))
921
1066
  return (0, exports.nothing)();
922
1067
  const fp = typeof raw.tool_input?.file_path === "string"
923
1068
  ? raw.tool_input.file_path
@@ -927,8 +1072,23 @@ function runReact(hook, raw, root = typeof raw.cwd === "string" ? raw.cwd : unde
927
1072
  tool: t,
928
1073
  path: pathView(fp, root),
929
1074
  response: responseView(raw.tool_response),
1075
+ ctx: ctx,
930
1076
  });
931
1077
  }
1078
+ /**
1079
+ * The state writes an outcome declares, filtered to the ones the runtime may
1080
+ * actually perform. A gate's `Decision` carries none — deliberately: a gate is
1081
+ * the role that must be trustworthy and runs on every tool call, so it READS
1082
+ * state (via `needs`) and never writes it. Adding a write there later is easy;
1083
+ * removing one would not be.
1084
+ */
1085
+ function outcomeWrites(outcome) {
1086
+ if (outcome.kind === "injection")
1087
+ return (0, hook_state_js_1.admissibleWrites)(outcome.records);
1088
+ if (outcome.kind === "reaction")
1089
+ return (0, hook_state_js_1.admissibleWrites)(outcome.reaction.records);
1090
+ return (0, hook_state_js_1.admissibleWrites)([]);
1091
+ }
932
1092
  /**
933
1093
  * The file a hook program was LOADED from — the one coverage attribution in this
934
1094
  * codebase that needs no parsing at all.
@@ -989,16 +1149,14 @@ function runHookProgram(hook, event, ctx = {}, root = event.cwd) {
989
1149
  kind: "decision",
990
1150
  decision: decideStopGate(hook, event, ctx),
991
1151
  };
992
- case "inject":
993
- return {
994
- kind: "injection",
995
- context: runInject(hook, event).hookSpecificOutput
996
- .additionalContext,
997
- };
1152
+ case "inject": {
1153
+ const out = injectionOf(hook, event, ctx);
1154
+ return { kind: "injection", context: out.context, records: out.records };
1155
+ }
998
1156
  case "react":
999
1157
  return {
1000
1158
  kind: "reaction",
1001
- reaction: runReact(hook, event, root),
1159
+ reaction: runReact(hook, event, ctx, root),
1002
1160
  };
1003
1161
  default:
1004
1162
  return (0, hash_js_1.assertNever)(kind);
@@ -1027,6 +1185,48 @@ function runHookProgram(hook, event, ctx = {}, root = event.cwd) {
1027
1185
  function isAbsoluteRef(ref) {
1028
1186
  return ref.startsWith("/") || /^[A-Za-z]:\//.test(ref);
1029
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
+ };
1030
1230
  /**
1031
1231
  * Resolve a path reference against a root, without node:path (core stays
1032
1232
  * dependency-free). Mirrors `resolve(root, ref)`: an absolute ref wins, a
@@ -1048,12 +1248,27 @@ function resolveRef(root, ref) {
1048
1248
  const joined = isAbsoluteRef(r)
1049
1249
  ? r
1050
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);
1051
1265
  const out = [];
1052
1266
  for (const seg of joined.split("/")) {
1053
1267
  if (seg === "" || seg === ".")
1054
1268
  continue;
1055
1269
  if (seg === "..") {
1056
- out.pop();
1270
+ if (out.length > floor)
1271
+ out.pop();
1057
1272
  continue;
1058
1273
  }
1059
1274
  out.push(seg);
@@ -1071,8 +1286,7 @@ function resolveRef(root, ref) {
1071
1286
  // stutter, so preserving the pair unconditionally made it stop resolving
1072
1287
  // against a POSIX root and an allowlist gate denied a valid edit. Semantics
1073
1288
  // belong to the filesystem, and only the root knows which one that is.
1074
- const unc = joined.startsWith("//") &&
1075
- (WINDOWS_ROOT.test(root) || root.startsWith("//"));
1289
+ const unc = joined.startsWith("//") && namesWindowsFs(root);
1076
1290
  const leader = unc ? "//" : joined.startsWith("/") ? "/" : "";
1077
1291
  return leader + out.join("/");
1078
1292
  }
@@ -1,3 +1,4 @@
1
+ import { type StateEntry, type StateFact, type StateNeed } from "./hook-state.js";
1
2
  /** The closed set of built-in facts a gate may declare via `needs`, with value types. */
2
3
  export interface ProviderResults {
3
4
  /** The current git branch, or "" outside a git repo / on an unborn HEAD. */
@@ -27,8 +28,16 @@ export interface InlineProvider<Name extends string = string> {
27
28
  readonly run: string;
28
29
  readonly dangerous: boolean;
29
30
  }
30
- /** A `needs` entry — a built-in name, an inline `provide`/`dangerously`, or a `provider()` ref. */
31
- export type NeedSpec = ProviderName | InlineProvider | RegisteredRef;
31
+ /**
32
+ * A `needs` entry — a built-in name, an inline `provide`/`dangerously`, a
33
+ * `provider()` ref, or a `state()` read of a fact some hook recorded.
34
+ *
35
+ * `state()` rides this union rather than getting its own accessor so that ALL of
36
+ * a hook's external inputs stay in one declared list: the dependency is auditable
37
+ * from outside the hook, and reading an undeclared one is a `tsc` error. See the
38
+ * design note in `hook-state.ts`.
39
+ */
40
+ export type NeedSpec = ProviderName | InlineProvider | RegisteredRef | StateNeed;
32
41
  /**
33
42
  * Declare an INLINE read-only fact: `provide("k8sCtx", "kubectl config current-context")`.
34
43
  * The command MUST be provably read-only (compile rejects it otherwise — use
@@ -71,8 +80,8 @@ export interface RegisteredRef<Name extends string = string> {
71
80
  export declare const provider: <const Name extends string>(name: Name) => RegisteredRef<Name>;
72
81
  /** name → its registered provider; the runtime resolves a `provider(name)` ref against this. */
73
82
  export type ProviderRegistry = Record<string, RegisteredProvider>;
74
- type NeedName<E extends NeedSpec> = E extends ProviderName ? E : E extends InlineProvider<infer Nm> ? Nm : E extends RegisteredRef<infer Rn> ? Rn : never;
75
- type NeedValue<E extends NeedSpec> = E extends ProviderName ? ProviderResults[E] : string;
83
+ type NeedName<E extends NeedSpec> = E extends ProviderName ? E : E extends InlineProvider<infer Nm> ? Nm : E extends RegisteredRef<infer Rn> ? Rn : E extends StateNeed<infer Sn> ? Sn : never;
84
+ type NeedValue<E extends NeedSpec> = E extends ProviderName ? ProviderResults[E] : E extends StateNeed ? StateFact : string;
76
85
  /**
77
86
  * The typed `e.ctx` for a hook that declared `needs: N` — ONLY the declared facts
78
87
  * are present (built-in name → its typed value, inline → string), so reading an
@@ -95,6 +104,14 @@ export interface ProviderIO {
95
104
  readonly platform: NodeJS.Platform;
96
105
  /** Whether the process is on a CI server (the CLI injects `ci-info`'s verdict). */
97
106
  readonly isCI: boolean;
107
+ /**
108
+ * Read one recorded fact from THIS hook's state namespace, or `null` if it was
109
+ * never recorded. The namespace is resolved by the caller, so a key can never
110
+ * address another owner's store — see `hook-state.ts`.
111
+ */
112
+ readonly readState: (key: string) => StateEntry | null;
113
+ /** Epoch milliseconds, injected so fact ages are pinnable in a test. */
114
+ readonly now: number;
98
115
  }
99
116
  interface ProviderDef<K extends ProviderName> {
100
117
  /**
@@ -133,6 +150,6 @@ export declare function unsafeProvider(def: RegisteredProvider): boolean;
133
150
  * throws. Pure over the injected `io` (CLI passes a real execSync; tests a fake)
134
151
  * and the `registry` (the loaded `.vigiles/providers/`, for `provider()` refs).
135
152
  */
136
- export declare function gatherContext(needs: readonly NeedSpec[], io: ProviderIO, registry?: ProviderRegistry): Record<string, string | boolean>;
153
+ export declare function gatherContext(needs: readonly NeedSpec[], io: ProviderIO, registry?: ProviderRegistry): Record<string, string | boolean | StateFact>;
137
154
  export {};
138
155
  //# sourceMappingURL=hook-providers.d.ts.map
@@ -26,6 +26,7 @@ exports.gatherContext = gatherContext;
26
26
  * testable with a fake exec and core depends on no child_process.
27
27
  */
28
28
  const bash_effects_js_1 = require("./bash-effects.js");
29
+ const hook_state_js_1 = require("./hook-state.js");
29
30
  /**
30
31
  * Declare an INLINE read-only fact: `provide("k8sCtx", "kubectl config current-context")`.
31
32
  * The command MUST be provably read-only (compile rejects it otherwise — use
@@ -105,6 +106,11 @@ function unknownProviders(needs, registeredNames = []) {
105
106
  for (const n of needs) {
106
107
  if (isInline(n))
107
108
  continue;
109
+ // A `state()` key is self-defining: it resolves to "never recorded" until
110
+ // some hook records it, which is a legitimate steady state (the very first
111
+ // run of every throttled hook), not a dangling reference.
112
+ if ((0, hook_state_js_1.isStateNeed)(n))
113
+ continue;
108
114
  if (isRef(n)) {
109
115
  if (!registered.has(n.name))
110
116
  out.push(n.name);
@@ -141,7 +147,13 @@ function unsafeProvider(def) {
141
147
  function gatherContext(needs, io, registry = {}) {
142
148
  const ctx = {};
143
149
  for (const need of needs) {
144
- if (isInline(need))
150
+ if ((0, hook_state_js_1.isStateNeed)(need)) {
151
+ // The one need that reaches no subprocess: the trusted runtime hands the
152
+ // stored entry (or null) straight in, and the fact view does the clamping
153
+ // every shell stamp-reader used to re-implement by hand.
154
+ ctx[need.name] = (0, hook_state_js_1.stateFact)(io.readState(need.name), io.now);
155
+ }
156
+ else if (isInline(need))
145
157
  ctx[need.name] = tryExec(io, need.run);
146
158
  else if (isRef(need)) {
147
159
  const def = registry[need.name];