vigiles 15.0.2 → 15.0.3

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.
Files changed (82) hide show
  1. package/dist/adapters/claude-code/layout.js +3 -0
  2. package/dist/adapters/claude-code/run-scripts.d.ts +21 -9
  3. package/dist/adapters/claude-code/run-scripts.js +33 -22
  4. package/dist/adapters/codex/driver.js +3 -1
  5. package/dist/adapters/codex/eval.js +5 -0
  6. package/dist/audit-score.js +32 -6
  7. package/dist/check-count.d.ts +62 -3
  8. package/dist/check-count.js +123 -5
  9. package/dist/check.d.ts +55 -0
  10. package/dist/check.js +91 -0
  11. package/dist/cli.js +456 -54
  12. package/dist/core/bash-effects.d.ts +41 -0
  13. package/dist/core/bash-effects.js +278 -0
  14. package/dist/core/foreign-runner.d.ts +185 -0
  15. package/dist/core/foreign-runner.js +228 -0
  16. package/dist/core/harness-driver.d.ts +18 -1
  17. package/dist/core/hook-program.d.ts +319 -20
  18. package/dist/core/hook-program.js +743 -49
  19. package/dist/core/layout.d.ts +13 -0
  20. package/dist/core/lethal-trifecta.d.ts +93 -0
  21. package/dist/core/lethal-trifecta.js +409 -65
  22. package/dist/core/markdown.d.ts +32 -0
  23. package/dist/core/markdown.js +36 -0
  24. package/dist/core/merge-conflict.d.ts +50 -0
  25. package/dist/core/merge-conflict.js +84 -0
  26. package/dist/core/test-file-ext.d.ts +80 -0
  27. package/dist/core/test-file-ext.js +90 -0
  28. package/dist/core/types.d.ts +11 -0
  29. package/dist/coverage-artifact.d.ts +383 -0
  30. package/dist/coverage-artifact.js +586 -0
  31. package/dist/coverage-evidence.d.ts +87 -79
  32. package/dist/coverage-evidence.js +212 -211
  33. package/dist/coverage-probe.d.ts +125 -0
  34. package/dist/coverage-probe.js +700 -0
  35. package/dist/doc-commands.d.ts +119 -0
  36. package/dist/doc-commands.js +158 -0
  37. package/dist/eval-lock.d.ts +9 -0
  38. package/dist/eval-lock.js +14 -2
  39. package/dist/eval.js +28 -0
  40. package/dist/fs-walk.d.ts +80 -0
  41. package/dist/fs-walk.js +148 -0
  42. package/dist/harness-assert.js +42 -3
  43. package/dist/harness-test.js +31 -2
  44. package/dist/judge.js +4 -0
  45. package/dist/leaderboard.d.ts +16 -1
  46. package/dist/leaderboard.js +19 -2
  47. package/dist/load-hook.js +6 -1
  48. package/dist/mock-model.d.ts +58 -4
  49. package/dist/mock-model.js +133 -5
  50. package/dist/observe.d.ts +1 -1
  51. package/dist/observe.js +44 -1
  52. package/dist/plugin-loader.js +52 -13
  53. package/dist/run-hook.d.ts +58 -0
  54. package/dist/run-hook.js +70 -0
  55. package/dist/run-script.js +75 -0
  56. package/dist/scaffold-test.d.ts +3 -0
  57. package/dist/scaffold-test.js +10 -5
  58. package/dist/scan-behavioral.js +4 -0
  59. package/dist/scan-core.d.ts +22 -0
  60. package/dist/scan-core.js +46 -14
  61. package/dist/scan-files.js +19 -2
  62. package/dist/scan.d.ts +22 -5
  63. package/dist/scan.js +67 -13
  64. package/dist/skill-contract.d.ts +46 -0
  65. package/dist/skill-contract.js +201 -0
  66. package/dist/skill-refs.d.ts +67 -0
  67. package/dist/skill-refs.js +117 -0
  68. package/dist/test-coverage-files.d.ts +7 -1
  69. package/dist/test-coverage-files.js +66 -55
  70. package/dist/test-coverage.d.ts +155 -19
  71. package/dist/test-coverage.js +430 -91
  72. package/dist/testing.d.ts +8 -2
  73. package/dist/testing.js +19 -1
  74. package/dist/trigger-containment.d.ts +85 -0
  75. package/dist/trigger-containment.js +125 -0
  76. package/dist/ts-runner-caps.d.ts +16 -0
  77. package/dist/ts-runner-caps.js +43 -0
  78. package/dist/unit.d.ts +2 -2
  79. package/dist/unit.js +2 -1
  80. package/hooks/eval-lock-nudge.sh +14 -6
  81. package/package.json +2 -2
  82. package/skills/test-harness/SKILL.md +68 -2
@@ -97,6 +97,14 @@ export interface CommandView {
97
97
  * line happens to be side-effecting for an unrelated reason (`grep -c x
98
98
  * notes/S.md 2>/dev/null`) matches both and gets blocked. To gate WRITES to a
99
99
  * directory, use {@link writesTo}; conflating the two is the trap.
100
+ *
101
+ * OVER-INCLUSIVE ON PURPOSE, AND THE OPPOSITE WAY ROUND FROM {@link
102
+ * PathView.under}. This is a DENYLIST primitive — every caller spells
103
+ * `touches(secret) ? deny() : allow()` — so a miss is an ALLOW, not silence. An
104
+ * unprovable answer is therefore reported as a MATCH: a token that could name
105
+ * something under the prefix under some root or some `cd` matches, and the gate
106
+ * blocks. See {@link prefixVerdict} for the three answers and {@link
107
+ * matchesPrefix} for the two biases.
100
108
  */
101
109
  touches(prefixes: readonly string[]): boolean;
102
110
  /**
@@ -130,7 +138,36 @@ export interface CommandView {
130
138
  */
131
139
  pipesToShell(): boolean;
132
140
  }
133
- export declare function commandView(raw: string): CommandView;
141
+ /**
142
+ * Trailing separators removed — EXCEPT the one that is the path itself.
143
+ *
144
+ * 🔴 THE CARVE-OUT IS THE RULE, NOT AN EDGE CASE, and skipping it fails
145
+ * SILENTLY. Strip every trailing separator and the POSIX root `"/"` becomes
146
+ * `""`, the Windows drive root `"C:/"` becomes `"C:"` — and `isAbsoluteRef`
147
+ * reads a bare drive letter as RELATIVE. Nothing throws: an allowlist prefix of
148
+ * `"C:/"` simply stops matching, so a confinement gate denies every path and a
149
+ * react hook goes quiet, both looking like a correct decision.
150
+ *
151
+ * This lives here, in one place, because the same normalisation is applied to
152
+ * two different things — the PREFIX being matched against, and the project ROOT
153
+ * a path is resolved against (`run-hook.ts` imports it for the latter). Round 28
154
+ * fixed the root and left the prefix; round 29 found the prefix. Two copies of a
155
+ * rule is how the second one survives the fix to the first.
156
+ */
157
+ export declare function trimTrailingSeparators(path: string): string;
158
+ /**
159
+ * An AST-backed view of a Bash command.
160
+ *
161
+ * @param raw - the command line as the tool event carried it.
162
+ * @param root - the project root repo-relative prefixes resolve against, from
163
+ * the runtime ({@link projectRootOf}: `$CLAUDE_PROJECT_DIR`, else the event's
164
+ * own `cwd`, never `process.cwd()`). Omitting it does NOT disarm the denylist
165
+ * matchers — without a root an absolute token is `"undecidable"` rather than
166
+ * placeable, and {@link CommandView.touches} treats that as a match. It does
167
+ * cost precision: with a root, `/somewhere-else/notes/x` is provably not this
168
+ * repo's `notes`; without one it can only be over-blocked.
169
+ */
170
+ export declare function commandView(raw: string, root?: string): CommandView;
134
171
  /**
135
172
  * The typed event a Bash gate decides over. Generic over the declared context
136
173
  * `needs` (`N`) so `e.ctx` exposes ONLY the facts the hook declared — reading an
@@ -160,13 +197,24 @@ export declare const tool: (name: string) => {
160
197
  tool: string;
161
198
  };
162
199
  export declare function defineHook<const N extends readonly NeedSpec[] = readonly []>(p: HookProgram<N>): HookProgram<N>;
163
- /** Build the typed event from a raw PreToolUse event, then decide. */
200
+ /**
201
+ * Build the typed event from a raw PreToolUse event, then decide.
202
+ *
203
+ * `root` is the project root repo-relative prefixes in the hook resolve against,
204
+ * threaded exactly as {@link decideFileGate} threads it: omitted, it falls back
205
+ * to the event's OWN `cwd` (a payload field, so this stays pure and reads no
206
+ * environment); the CLI passes {@link projectRootOf}, which prefers
207
+ * `$CLAUDE_PROJECT_DIR`. Until 2026-08-12 no root reached here at all, and the
208
+ * denylist matchers on {@link CommandView} were bypassable by spelling a path
209
+ * absolutely — see the block above {@link PrefixVerdict} for the measurement.
210
+ */
164
211
  export declare function decideProgram<N extends readonly NeedSpec[]>(program: HookProgram<N>, rawEvent: {
165
212
  tool_name?: string;
166
213
  tool_input?: {
167
214
  command?: unknown;
168
215
  };
169
- }, ctx?: Record<string, string | boolean>): Decision;
216
+ cwd?: unknown;
217
+ }, ctx?: Record<string, string | boolean>, root?: string | undefined): Decision;
170
218
  /** Map a Decision to the CC hook exit code — the protocol the author never writes. */
171
219
  export declare function decisionExitCode(d: Decision): number;
172
220
  /**
@@ -270,10 +318,105 @@ export declare function verifyHookStamp(source: string, stamp: SHA256Hash): bool
270
318
  /** An AST-free view of a file path — the matching primitive for file tools. */
271
319
  export interface PathView {
272
320
  readonly raw: string;
273
- /** True iff the path sits under at least one allowed prefix (e.g. "src/**"). */
321
+ /**
322
+ * True iff the path sits under at least one allowed prefix (e.g. "src/**").
323
+ *
324
+ * A prefix is matched in ITS OWN spelling: a repo-relative prefix (`"src"`,
325
+ * `"migratsiya/papers/"`) is compared against the path made repo-relative, an
326
+ * absolute prefix (`"/etc"`) against the path made absolute. Which of those is
327
+ * available depends on the project root — see {@link pathView}.
328
+ */
274
329
  under(prefixes: readonly string[]): boolean;
330
+ /**
331
+ * The path as a repo-relative reference, or `undefined` when this view cannot
332
+ * produce one — an absolute path with no known root, or a path that resolves
333
+ * OUTSIDE the root (`/etc/passwd`, a sibling checkout). Exposed because a hook
334
+ * that wants to report or re-match the path needs the same answer `under`
335
+ * used, and `raw` alone does not carry it.
336
+ */
337
+ readonly rel: string | undefined;
275
338
  }
276
- export declare function pathView(raw: string): PathView;
339
+ /**
340
+ * A view of a file path, matched against prefixes by {@link PathView.under}.
341
+ *
342
+ * 🔴 `root` IS NOT OPTIONAL IN PRACTICE — WITHOUT IT THIS IS DEAD FOR EVERY REAL
343
+ * EVENT. Claude Code's Edit/Write/MultiEdit tools always send an ABSOLUTE
344
+ * `file_path`, and every hook in the wild writes repo-relative prefixes
345
+ * (`under(["migratsiya/papers/"])`). Before the root existed, the two could
346
+ * never meet: the comparison ran `"/home/u/repo/migratsiya/papers/x.tex"`
347
+ * against `"migratsiya/papers"` and returned `false` for all input, forever.
348
+ * MEASURED 2026-08-12 on the live runtime with one compiled react hook and two
349
+ * spellings of the same file — the relative one printed its checklist, the
350
+ * absolute one exited 0 in silence. Three shipped hooks in a consumer repo were
351
+ * dead by it, all three compiled *specifically to replace hooks that were dead*,
352
+ * and none of the tests noticed because the harness helper built relative paths.
353
+ *
354
+ * ## Which way a miss errs, and why it is not the same answer as `touches`
355
+ *
356
+ * `under` is the ALLOWLIST/COVERAGE primitive: its callers spell
357
+ * `under(P) ? allow() : deny()` (confinement) and `under(P) ? notice() : nothing()`
358
+ * (a nudge). For both, an unprovable answer must be `false` — a confinement gate
359
+ * denies (fails closed) and a nudge stays quiet. So:
360
+ *
361
+ * - path resolves OUTSIDE the root → `false` for every relative prefix. Not a
362
+ * bias, the truth: a repo-relative prefix names nothing in another checkout.
363
+ * This is the {@link resolveRef} lesson — the suffix match it replaced handed
364
+ * `/home/other-project/package.json` a grant meant for this repo's.
365
+ * - NO root known and the path is absolute → `false` for every relative prefix.
366
+ * Here we genuinely cannot tell, and `false` is the choice: silence, never a
367
+ * false grant. It is also the pre-fix behaviour, so nothing regresses.
368
+ *
369
+ * ⚠️ THE ASYMMETRY INVERTS FOR A DENYLIST, AND `under` DOES NOT SERVE THAT CASE.
370
+ * A gate written `under(["secrets"]) ? deny() : allow()` reads an unprovable
371
+ * `false` as ALLOW — a false grant. The Bash-side counterpart {@link
372
+ * CommandView.touches} exists precisely for that direction and takes the
373
+ * opposite bias from the SAME rule ({@link prefixVerdict}), which is why the
374
+ * resolver {@link matchesPrefix} makes each caller name its own. `PathView` has
375
+ * no such counterpart; a file-side denylist inherits the allowlist bias.
376
+ * Reported, not papered over.
377
+ *
378
+ * @param raw - the `file_path` the tool event carried, any spelling.
379
+ * @param root - the project root relative prefixes resolve against. Comes from
380
+ * the runtime (`$CLAUDE_PROJECT_DIR`, else the event's own `cwd`), never from
381
+ * `process.cwd()` — under a git worktree the process's cwd is a DIFFERENT
382
+ * checkout from the one the harness resolved the hook out of, and that exact
383
+ * mismatch already wedged a repo once.
384
+ */
385
+ export declare function pathView(raw: string, root?: string): PathView;
386
+ /**
387
+ * The project root a compiled hook's repo-relative prefixes resolve against.
388
+ *
389
+ * Two sources, in order, and `process.cwd()` is deliberately NOT one of them:
390
+ *
391
+ * 1. **`$CLAUDE_PROJECT_DIR`** — what the harness itself resolved the hook's own
392
+ * path against (`.claude/settings.json` spells the command
393
+ * `"$CLAUDE_PROJECT_DIR/.claude/hooks/x.hook.ts"`), so it is the same root the
394
+ * hook was loaded from by construction.
395
+ * 2. **the event's `cwd`** — Claude Code puts the session's working directory in
396
+ * every hook payload. A fallback, not a peer: it is where the session is, and
397
+ * the two coincide in the ordinary case.
398
+ *
399
+ * `process.cwd()` is excluded because under a git worktree it can be a different
400
+ * checkout than the one the harness is driving, and a root from the wrong
401
+ * checkout turns every repo-relative prefix into a non-match — the same silent
402
+ * death this whole function exists to end. When NEITHER source is present the
403
+ * answer is `undefined` and the miss errs toward silence (see {@link pathView}).
404
+ */
405
+ export declare function projectRootOf(event: {
406
+ readonly cwd?: unknown;
407
+ }, env?: Readonly<Record<string, string | undefined>>): string | undefined;
408
+ /**
409
+ * The one case {@link pathView} cannot answer and must not answer silently: an
410
+ * ABSOLUTE `file_path` with no project root in sight. Every repo-relative prefix
411
+ * in the hook returns `false`, so a gate waves everything through and a nudge
412
+ * never fires — indistinguishable, from the outside, from a hook with nothing to
413
+ * say. Returns the warning line, or `undefined` when there is nothing wrong.
414
+ *
415
+ * Narrow on purpose: a RELATIVE `file_path` is decidable without a root, so it
416
+ * warns about nothing. That matters because a react hook's `notice` also goes to
417
+ * stderr, and a warning on every event would read as the hook firing.
418
+ */
419
+ export declare function undecidablePathWarning(filePath: unknown, root: string | undefined): string | undefined;
277
420
  /** The event a file-tool gate decides over (Edit/Write/Read carry `file_path`). */
278
421
  export interface FileToolEvent<N extends readonly NeedSpec[] = readonly ProviderName[]> {
279
422
  readonly event: string;
@@ -298,13 +441,22 @@ export declare const tools: (...names: string[]) => {
298
441
  tools: string[];
299
442
  };
300
443
  export declare function defineFileGate<const N extends readonly NeedSpec[] = readonly []>(p: Omit<FileGateHook<N>, "role">): FileGateHook<N>;
301
- /** Run a file-tool gate against a raw PreToolUse event (reads `file_path`). */
444
+ /**
445
+ * Run a file-tool gate against a raw PreToolUse event (reads `file_path`).
446
+ *
447
+ * `root` is the project root repo-relative prefixes resolve against; omitted, it
448
+ * falls back to the event's OWN `cwd` (a payload field — this stays pure and
449
+ * reads no environment). The CLI passes {@link projectRootOf} instead, which
450
+ * prefers `$CLAUDE_PROJECT_DIR`. Without either, an absolute `file_path` matches
451
+ * no relative prefix — see {@link pathView} for why that direction was chosen.
452
+ */
302
453
  export declare function decideFileGate<N extends readonly NeedSpec[]>(hook: FileGateHook<N>, raw: {
303
454
  tool_name?: string;
304
455
  tool_input?: {
305
456
  file_path?: unknown;
306
457
  };
307
- }, ctx?: Record<string, string | boolean>): Decision;
458
+ cwd?: unknown;
459
+ }, ctx?: Record<string, string | boolean>, root?: string | undefined): Decision;
308
460
  /** The event a UserPromptSubmit gate decides over — it sees the prompt TEXT. */
309
461
  export interface PromptEvent<N extends readonly NeedSpec[] = readonly ProviderName[]> {
310
462
  readonly event: string;
@@ -443,14 +595,20 @@ export interface ReactHook {
443
595
  readonly react: (e: ReactEvent) => Reaction;
444
596
  }
445
597
  export declare const defineReact: (p: Omit<ReactHook, "role">) => ReactHook;
446
- /** Run a react hook against a raw PostToolUse event → the (classified) Reaction. */
598
+ /**
599
+ * Run a react hook against a raw PostToolUse event → the (classified) Reaction.
600
+ *
601
+ * `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.
603
+ */
447
604
  export declare function runReact(hook: ReactHook, raw: {
448
605
  tool_name?: string;
449
606
  tool_input?: {
450
607
  file_path?: unknown;
451
608
  };
452
609
  tool_response?: unknown;
453
- }): Reaction;
610
+ cwd?: unknown;
611
+ }, root?: string | undefined): Reaction;
454
612
  /** The raw event fields the decode functions read (the union across roles). */
455
613
  export interface RawHookEvent {
456
614
  readonly tool_name?: string;
@@ -466,6 +624,12 @@ export interface RawHookEvent {
466
624
  readonly prompt?: string;
467
625
  /** Stop / SubagentStop (stop-gate) — the prior-block loop guard. */
468
626
  readonly stop_hook_active?: boolean;
627
+ /**
628
+ * The session's working directory, which Claude Code puts in EVERY hook
629
+ * payload. The fallback project root repo-relative path prefixes resolve
630
+ * against when the runtime does not pass one — see {@link projectRootOf}.
631
+ */
632
+ readonly cwd?: string;
469
633
  }
470
634
  /** The normalized outcome of running a hook program — discriminated by role. */
471
635
  export type HookProgramOutcome = {
@@ -478,26 +642,161 @@ export type HookProgramOutcome = {
478
642
  readonly kind: "reaction";
479
643
  readonly reaction: Reaction;
480
644
  };
645
+ /** Record where a hook program was loaded from. Called by `loadHook` ONLY. */
646
+ export declare function rememberHookSource(hook: AnyHook, file: string): void;
647
+ /** The file a hook was loaded from, or `undefined` for one built in-process. */
648
+ export declare function hookSource(hook: AnyHook): string | undefined;
481
649
  /**
482
650
  * Evaluate a compiled hook against a raw event, in-process, dispatching by role:
483
651
  * a gate → its `Decision`, an inject → the injected context text, a react → its
484
652
  * (effect-classified) `Reaction`. Pure — no subprocess, no model. The ergonomic
485
653
  * base for testing a compiled hook (see `assertHookDenies` / `assertHookAllows`).
486
654
  */
487
- export declare function runHookProgram(hook: AnyHook, event: RawHookEvent, ctx?: Record<string, string | boolean>): HookProgramOutcome;
655
+ export declare function runHookProgram(hook: AnyHook, event: RawHookEvent, ctx?: Record<string, string | boolean>, root?: string | undefined): HookProgramOutcome;
656
+ /**
657
+ * The repository a wedged hook belongs to, as the runtime sees it. Supplied by
658
+ * the caller because core takes no `node:path` and reads no disk — and because
659
+ * the hook's own path does NOT determine it (see {@link isLoadPathRepairEvent}).
660
+ */
661
+ export interface HookRepoPaths {
662
+ /**
663
+ * Absolute root that relative references resolve against. Must be the SAME
664
+ * root the runtime reads the hook and its stamp with, or the write accepted
665
+ * here is not the file the runtime reads.
666
+ */
667
+ readonly root: string;
668
+ /**
669
+ * The actual files whose breakage can wedge THIS hook's module resolution,
670
+ * already resolved by the caller: the `package.json` at every ancestor of the
671
+ * hook, plus the repo's `.vigilesrc.json`. Node walks that chain, so a
672
+ * monorepo package's own `package.json` is on it and a sibling checkout's is
673
+ * not.
674
+ */
675
+ readonly loadPathFiles: readonly string[];
676
+ }
488
677
  /**
489
- * True when this event IS the author repairing the hook — the only thing a
490
- * stale-stamp refusal must let through, or the repo wedges (see the note above):
678
+ * True when this event IS the author repairing the hook — a FILE WRITE, and only
679
+ * a file write.
680
+ *
681
+ * 🔴 THIS USED TO ADMIT A BASH COMMAND, AND THAT WAS THE WHOLE PROBLEM. The
682
+ * escape accepted `vigiles compile …`, and recognising a trusted ACTION from an
683
+ * untrusted STRING produced five findings in a row, each fix correct and each
684
+ * followed by another: `.some()` over the leaves → the operand (`compile
685
+ * /tmp/payload.spec.ts`, which `loadSpec` imports) → the executable path
686
+ * (`/tmp/vigiles compile`) → the working directory (`cd /tmp/evil && vigiles
687
+ * compile`). The degrees of freedom in a command string are unbounded — argv,
688
+ * cwd, PATH, `node_modules` resolution, a hostile `.vigilesrc.json`, a shell
689
+ * function — so no amount of parsing closes the class. Two more constraints
690
+ * predict two more findings.
691
+ *
692
+ * ## Why the command was not needed at all — measured, not argued
693
+ *
694
+ * MEASURED 2026-08-12 against the real runtime, on a stale-stamp fixture:
695
+ * writing `{}` into the stamp sidecar (or deleting it) makes
696
+ * `verifyStampOrRefuse` return early, so the hook LOADS AND ENFORCES again —
697
+ * `ls` passed and `git push --force` was still denied with the hook's own reason.
698
+ * The repo is unwedged by a file write, and the gate is back on duty; the author
699
+ * then runs `vigiles compile` through the NORMAL gate, needing no escape at all.
700
+ *
701
+ * MEASURED the same day on the load-wedge fixture: with `package.json` holding
702
+ * conflict markers, `vigiles compile <hook>` exits 1 —
703
+ * `Cannot load hook …: Invalid package config` — because compile has to load the
704
+ * hook through the same broken resolver the runtime just failed on. The command
705
+ * the escape existed to permit CANNOT repair that wedge. It was pure liability.
706
+ *
707
+ * So the Bash escape kept only a git whitelist — and a later round measured that
708
+ * those run `.git/hooks/*` too and deleted them as well. NO command is an escape
709
+ * now; the repair is what it always should have been: a write
710
+ * ({@link isLoadPathRepairEvent}).
711
+ *
712
+ * ## What is accepted here
713
+ *
714
+ * A write whose target is the hook's own source, or its stamp sidecar
715
+ * (`.vigiles/hooks/<basename>.json`). Both are repo-owned paths derived from the
716
+ * file the runtime was invoked with, never from the event.
717
+ *
718
+ * 🔴 SCOPE IS NOT THE SAME QUESTION AS EXECUTION. A previous round defended a
719
+ * suffix comparison here on the ground that a write executes nothing — true, and
720
+ * beside the point: `/home/another-project/.claude/hooks/guard.hook.mjs` ends
721
+ * the same way as this repo's hook, so a wedge in one checkout granted a write
722
+ * into another. Both sides now resolve against `repoRoot` and compare whole
723
+ * ({@link resolveRef}); the absolute-vs-relative tolerance that suffix matching
724
+ * was reached for is what resolution gives you properly.
725
+ *
726
+ * This grants nothing new: a Bash gate never gated file tools in the first place
727
+ * (the 2026-08-10 incident was fixed by hand-editing JSON while every Bash
728
+ * command was refused), and a file gate already let the hook's own source be
729
+ * rewritten, which is strictly more powerful than clearing its stamp.
730
+ */
731
+ export declare function isStampRepairEvent(event: RawHookEvent, hookFile: string, repoRoot: string): boolean;
732
+ /**
733
+ * True when this event is a load-path REPAIR — a file write, and only a file
734
+ * write, to one of the files whose breakage takes the runtime down.
735
+ *
736
+ * 🔴 THE GIT ESCAPE USED TO LIVE HERE AND IT EXECUTED REPO CODE. `git merge
737
+ * --abort` · `git rebase --abort` · `git checkout -- <path>` were admitted on the
738
+ * reasoning that they "only move the tree to states git already holds; none
739
+ * executes a line of repo code". MEASURED against git 2.43.0 with hooks installed
740
+ * in `.git/hooks/`, and the reasoning is false for ALL THREE:
741
+ *
742
+ * git checkout -- f.txt → post-checkout (args `<sha> <sha> 0`)
743
+ * git merge --abort → reference-transaction (prepared, committed)
744
+ * git rebase --abort → reference-transaction (×4) + post-checkout
745
+ *
746
+ * `reference-transaction` fires on ANY ref update, and every one of these updates
747
+ * a ref. `.git/hooks/*` is writable by exactly the actor this door assumes —
748
+ * whoever could wedge the repo in the first place — so the whitelist was an
749
+ * arbitrary-execution path standing open precisely while the gate enforced
750
+ * nothing. Same shape as the `vigiles compile` escape one layer down: a command
751
+ * believed inert because of what it MEANS rather than what it DOES.
752
+ *
753
+ * ## Why the replacement is a write, not a safer command
754
+ *
755
+ * `git -c core.hooksPath=/nonexistent …` does suppress all three (measured: no
756
+ * hook ran for any of them). It was rejected anyway. It puts free-form structure
757
+ * back into the accepted string — the exact thing five findings on the compile
758
+ * door came from — and it buys nothing, because MEASURED on the load-wedge
759
+ * fixture a plain file write already restores the gate to ENFORCING:
760
+ *
761
+ * wedged : `ls` refused
762
+ * after Edit : `ls` allowed, `git push --force` DENIED by the hook's own reason
763
+ *
764
+ * You do not need to finish recovering, only to stop being wedged. Once the hook
765
+ * loads, the gate decides normally and `git merge --abort` is an ordinary allowed
766
+ * command — through the gate, not around it.
767
+ *
768
+ * ## What is accepted
769
+ *
770
+ * A write to the hook's own source, its stamp sidecar, or one of
771
+ * {@link HARNESS_CONFIG_FILES}. That set is COMPLETE for a compiled hook rather
772
+ * than a guess: `checkHookImports` rejects every import but `vigiles/hook`, so the
773
+ * load path is the hook file plus the config that resolves that specifier —
774
+ * nothing else can break it.
775
+ *
776
+ * A file write executes nothing, which is the property no command on this door
777
+ * ever had.
778
+ *
779
+ * ## …IN THIS REPOSITORY, which is a separate question
491
780
  *
492
- * - a Bash command that invokes `vigiles compile` (however it's launched —
493
- * `npx vigiles compile`, `pnpm exec vigiles compile`, `./node_modules/.bin/vigiles compile`),
494
- * which is what regenerates the stamp; or
495
- * - an edit/write whose target IS this hook's own source file, so a FILE gate
496
- * over the repo can still be fixed by editing the hook again.
781
+ * "A write executes nothing" answers the EXECUTION objection and says nothing
782
+ * about SCOPE. Under the suffix comparison this used to do, a write to
783
+ * `/home/another-project/package.json` was accepted while THIS repo was wedged —
784
+ * a file that cannot repair the failure, in a checkout the wedged session has no
785
+ * business touching. Every candidate is now resolved against
786
+ * {@link HookRepoPaths.root} and compared whole against the paths the runtime
787
+ * itself derived ({@link HookRepoPaths.loadPathFiles}, plus this root's
788
+ * {@link HARNESS_CONFIG_FILES} so the floor case can never be argued away).
497
789
  *
498
- * AST-backed (`leafCommandsNormalized`), so it sees the invocation through a
499
- * compound command or a wrapper, exactly like every other matcher here.
790
+ * ⚠️ THE HOOK'S OWN PATH DOES NOT GIVE THE ROOT, so the caller passes it. A hook
791
+ * lives at `.claude/hooks/…`, `.vigiles/hooks/…`, a plugin subdirectory or the
792
+ * repo root itself; there is no fixed depth to walk up, and finding a `.git`
793
+ * marker would mean reading disk, which core does not do. Nor would a
794
+ * git-derived root be RIGHT: `verifyStampOrRefuse` reads the hook and its stamp
795
+ * sidecar via `process.cwd()`, so any other root would accept writes to files
796
+ * the runtime does not read — repairs that do not repair. The root is therefore
797
+ * the one the rest of the runtime already uses, and it is passed in rather than
798
+ * invented here.
500
799
  */
501
- export declare function isStampRepairEvent(event: RawHookEvent, hookFile: string): boolean;
800
+ export declare function isLoadPathRepairEvent(event: RawHookEvent, hookFile: string, repo: HookRepoPaths): boolean;
502
801
  export {};
503
802
  //# sourceMappingURL=hook-program.d.ts.map