@primitive.ai/prim 0.1.0-alpha.93 → 0.1.0-alpha.94

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/README.md CHANGED
@@ -75,7 +75,7 @@ Install the Primitive CLI and activate passive decision capture for this reposit
75
75
  ```
76
76
 
77
77
  That single command runs the whole install and current-repository activation in one
78
- process (auth → session hooks → daemon → git hooks → skill → welcome). The detailed
78
+ process (auth → session hooks → daemon → skill → activate this repository and wire its git hooks → welcome). The detailed
79
79
  step-by-step the agent follows — and the manual fallback — live in
80
80
  [`setup.md`](./setup.md).
81
81
 
@@ -89,12 +89,17 @@ prim setup --agent cursor # Same, for Cursor IDE and Cursor CLI
89
89
  prim setup --agent codex # Same, for OpenAI Codex
90
90
  prim setup --agent hermes # Same, for Hermes Agent (global-only config)
91
91
  prim setup --no-daemon # Stop it and persistently opt out of supervised delivery
92
+ prim setup --global-hooks-path # Also route every repository's git hooks through prim
92
93
  ```
93
94
 
94
- Orchestrates auth → session hooks → supervised daemon → capture-health gate → git hooks → skill → welcome,
95
+ Orchestrates auth → session hooks → supervised daemon → git hooks (`--scope project`, or `--global-hooks-path`) → skill → activate this repo (which wires its git hooks) → capture-health gate → welcome,
95
96
  re-running each underlying command so every step behaves exactly as if run by
96
97
  hand (including the browser login). Idempotent — safe to re-run.
97
98
 
99
+ Setup wires the current repository's git hooks and never changes git's global
100
+ config on its own: a global `core.hooksPath` reroutes every repository on the
101
+ machine. Pass `--global-hooks-path` to opt in; `prim --yes setup` does not.
102
+
98
103
  ### Uninstall
99
104
 
100
105
  ```bash
@@ -321,15 +326,52 @@ authorizes one retry for the named decision.
321
326
  ### Hooks
322
327
 
323
328
  ```bash
324
- prim hooks install # Install git hooks (pre-commit decision check + post-commit capture)
325
- prim hooks uninstall # Remove the prim git hooks
329
+ prim hooks install # Wire the pre-commit, post-commit, and post-rewrite hooks
330
+ prim hooks install --scope user --global-hooks-path # Route every repository through prim (exits 3, with the reason on STDERR, if it installs nothing; 1 if prim's hook runtime is not staged)
331
+ prim hooks snippet post-commit # Print the block that wires one hook, to place by hand
332
+ prim hooks uninstall # Remove prim's blocks (and any hook file prim created)
326
333
  ```
327
334
 
328
335
  The pre-commit hook checks staged files against the live decision graph
329
- (warn-only — it never blocks the commit). The post-commit hook records each
330
- commit as a capture boundary for classification. Supports
331
- [Husky](https://typicode.github.io/husky/) — `prim hooks install` detects Husky
332
- and offers to install into `.husky/`.
336
+ (warn-only — it never blocks the commit). The post-commit and post-rewrite hooks
337
+ record each commit and each amend/rebase as capture boundaries for
338
+ classification. All three act only in repos where prim is active (`prim enable`).
339
+ Supports [Husky](https://typicode.github.io/husky/) — `prim hooks install`
340
+ detects Husky and offers to install into `.husky/`.
341
+
342
+ Each hook file gets one short block, inserted right after the shebang so it runs
343
+ before any `exit` or `exec`. The block names no version and no machine path: it
344
+ runs prim's staged entrypoint (`~/.config/prim/prim-git-hook-v1`) and does
345
+ nothing on a machine without prim, so a tracked `.husky/*` file never changes
346
+ across prim releases. `prim enable` and `prim hooks install` write it once and
347
+ never move or rewrite a current block, reformatted or not. Agent session hooks
348
+ only repair Git-private hook files under `.git` and prim's own hooks dir, and
349
+ never add the pre-commit check. prim never edits a hooks dir outside the
350
+ repository, such as a global `core.hooksPath` every repository shares, unless
351
+ you run `prim hooks install --scope user --global-hooks-path` or answer yes at
352
+ its terminal prompt. In a Husky v8 hook the block goes
353
+ after the `husky.sh` line, so it runs once and honors `HUSKY=0`. `prim doctor`
354
+ only reports; it never rewrites a hook.
355
+
356
+ `prim doctor` also checks evidence rather than file contents: each post-commit
357
+ run that reaches prim leaves a stamp in the checkout's git dir, and doctor's
358
+ `hook-fired` check fails when a local commit made since the hooks were wired
359
+ never reached prim. That catches an `exit` before prim's block, hand wiring
360
+ that broke, or a git client whose environment cannot find prim. The check
361
+ applies in manual mode too: once a repository is enabled, doctor expects your
362
+ own wiring to reach prim. `prim disable` ends the expectation, so commits made
363
+ while disabled are never judged. Turning prim off with `git config prim.active
364
+ false` directly ends it too, once an agent session starts while it is off; if
365
+ doctor still flags a commit made while it was off, run `prim disable` then
366
+ `prim enable`.
367
+
368
+ To wire the hooks yourself (lefthook, a custom Husky layout, …), run
369
+ `git config prim.gitHooks manual` (or `--global`) first: prim then never writes
370
+ a hook file. That setting is the only signal prim honors. It does not guess
371
+ from a hook that already mentions prim, so without it, `prim enable` adds its
372
+ block. Paste the block from `prim hooks snippet <hook>`, or have your hook
373
+ manager run `~/.config/prim/prim-git-hook-v1 <hook> "$@"` (post-rewrite also
374
+ needs Git's stdin).
333
375
 
334
376
  ### Presence statusline
335
377
 
package/SKILL.md CHANGED
@@ -223,7 +223,7 @@ With the daemon running (`npx --yes @primitive.ai/prim daemon start`), `npx --ye
223
223
 
224
224
  ## The git hooks
225
225
 
226
- `npx --yes @primitive.ai/prim hooks install` installs two git hooks:
226
+ `npx --yes @primitive.ai/prim hooks install` installs three git hooks:
227
227
 
228
228
  ```
229
229
  npx --yes @primitive.ai/prim hooks install # auto-detects Husky and prompts
@@ -234,8 +234,11 @@ npx --yes @primitive.ai/prim hooks uninstall
234
234
 
235
235
  - **pre-commit** -- checks staged files against the live decision graph and prints any active decisions that reference them to stderr. It is **warn-only**: failures (auth, network, backend) or matches never block the commit; a successful `git commit` doesn't prove the check ran clean. When the check can't complete it says so ("not verified" / "truncated") rather than implying all-clear.
236
236
  - **post-commit** -- records each commit as a capture boundary so the server can classify the surrounding work into decisions. It never blocks and runs in the background.
237
+ - **post-rewrite** -- records amend/rebase rewrites the same way, in the background.
237
238
 
238
- Under `CI=1` (or with `--non-interactive`), `hooks install` fails fast in a Husky repo unless `--yes` or `--target` is set; the error names both escapes. `hooks uninstall` only removes the `.git/hooks` copies — if a hook was installed into `.husky/`, remove the prim block from that file manually. To suppress the hooks for one commit, use `git commit --no-verify`.
239
+ All three act only where the repo is active (`prim enable`). Each hook file gets one version-stable block right after its shebang; `prim enable` and `hooks install` write it once and never rewrite a current block, while session hooks only repair Git-private files under `.git` (and never add the pre-commit check). prim never edits a hooks dir outside the repository, such as a shared global `core.hooksPath`: `hooks install --scope user` changes git's global hooks only with `--global-hooks-path` or a yes typed at a terminal. That changes every repository, so never pass the flag for the user without asking, and know that `--yes` does not count. `prim doctor`'s `hook-fired` check fails if a local commit since the hooks were wired never reached prim (in manual mode too, once the repo is enabled): surface it, since capture is silently missing. If the user wires the hooks themselves, they must set `git config prim.gitHooks manual` (prim does not infer hand wiring from what a hook mentions); prim then never edits their hook files; `npx --yes @primitive.ai/prim hooks snippet <hook>` prints the block to place.
240
+
241
+ Under `CI=1` (or with `--non-interactive`), `hooks install` fails fast in a Husky repo that Husky has not yet configured, unless `--yes` or `--target` is set; the error names both escapes. `hooks uninstall` removes prim's blocks from `.git/hooks` and `.husky/` alike and leaves the rest of each hook untouched. To suppress the hooks for one commit, use `git commit --no-verify`.
239
242
 
240
243
  These git hooks are separate from the **session hooks** (`claude install` / `cursor install` / `codex install` / `hermes install`) that drive in-session capture (and Conflict Gates, when enabled).
241
244
 
@@ -127,6 +127,13 @@ function pinnedHookCommand(bin, args = "") {
127
127
  }
128
128
  var STABLE_HOOK_ARGUMENTS_RE = /^[-A-Za-z0-9_ ]*$/u;
129
129
  var STABLE_HOOK_BIN_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/u;
130
+ var PRIM_CONFIG_SHELL_RESOLVER = [
131
+ 'prim_absolute() { case "$1" in [/]* ) ;; *) return 1 ;; esac; case "$1" in [[:space:]]*|*[[:space:]]|*[/][/]*|*[/].[/]*|*[/]..[/]*|*[/].|*[/]..|?*[/]) return 1 ;; esac; }; ',
132
+ 'prim_config=${PRIM_CONFIG_DIR:-}; if ! prim_absolute "$prim_config"; then prim_config=${XDG_CONFIG_HOME:-}; if prim_absolute "$prim_config"; then ',
133
+ 'case "$prim_config" in /) prim_config=/prim ;; *) prim_config="$prim_config/prim" ;; esac; ',
134
+ 'else prim_home=${HOME:-}; prim_absolute "$prim_home" || exit 78; case "$prim_home" in /) prim_config=/.config/prim ;; *) prim_config="$prim_home/.config/prim" ;; esac; ',
135
+ "fi; fi; "
136
+ ].join("");
130
137
  function stableHookCommand(bin, args = "") {
131
138
  if (!STABLE_HOOK_BIN_RE.test(bin) || !STABLE_HOOK_ARGUMENTS_RE.test(args)) {
132
139
  throw new Error("invalid stable hook command");
@@ -136,11 +143,7 @@ function stableHookCommand(bin, args = "") {
136
143
  // it as inert data during the rolling window so an older uninstall or
137
144
  // reinstall can remove the stable entry without invoking npm or PATH.
138
145
  `prim_legacy_reader='-p ${PKG_NAME}@stable prim-shim.sh ${bin} '; `,
139
- 'prim_absolute() { case "$1" in [/]* ) ;; *) return 1 ;; esac; case "$1" in [[:space:]]*|*[[:space:]]|*[/][/]*|*[/].[/]*|*[/]..[/]*|*[/].|*[/]..|?*[/]) return 1 ;; esac; }; ',
140
- 'prim_config=${PRIM_CONFIG_DIR:-}; if ! prim_absolute "$prim_config"; then prim_config=${XDG_CONFIG_HOME:-}; if prim_absolute "$prim_config"; then ',
141
- 'case "$prim_config" in /) prim_config=/prim ;; *) prim_config="$prim_config/prim" ;; esac; ',
142
- 'else prim_home=${HOME:-}; prim_absolute "$prim_home" || exit 78; case "$prim_home" in /) prim_config=/.config/prim ;; *) prim_config="$prim_home/.config/prim" ;; esac; ',
143
- "fi; fi; ",
146
+ PRIM_CONFIG_SHELL_RESOLVER,
144
147
  `case "$prim_config" in /) prim_launcher=/${STABLE_HOOK_LAUNCHER_NAME} ;; *) prim_launcher="$prim_config/${STABLE_HOOK_LAUNCHER_NAME}" ;; esac; `,
145
148
  'exec "$prim_launcher" "$@"'
146
149
  ].join("");
@@ -220,6 +223,146 @@ import {
220
223
  writeFileSync
221
224
  } from "fs";
222
225
  import { delimiter, dirname as dirname2, isAbsolute as isAbsolute2, join as join2, resolve } from "path";
226
+
227
+ // src/lib/git-hook-contract.ts
228
+ var MANAGED_GIT_HOOK_NAMES = ["pre-commit", "post-commit", "post-rewrite"];
229
+ var GIT_HOOK_ENTRYPOINT_NAME = "prim-git-hook-v1";
230
+ var GIT_HOOK_CONTRACT_VERSION = 1;
231
+ var CONTRACT_LINE_PREFIX = "# prim git hook v";
232
+ var CONTRACT_LINE_RE = /^# prim git hook v([0-9]+)\b/mu;
233
+ function blockMarkers(hook) {
234
+ return { start: `# >>> prim ${hook} hook >>>`, end: `# <<< prim ${hook} hook <<<` };
235
+ }
236
+ function entrypointCommand(hook) {
237
+ const script = [
238
+ PRIM_CONFIG_SHELL_RESOLVER,
239
+ `case "$prim_config" in /) prim_entry=/${GIT_HOOK_ENTRYPOINT_NAME} ;; `,
240
+ `*) prim_entry="$prim_config/${GIT_HOOK_ENTRYPOINT_NAME}" ;; esac; `,
241
+ '[ -x "$prim_entry" ] || exit 0; exec "$prim_entry" "$@"'
242
+ ].join("");
243
+ return `/bin/sh -c ${shellQuote(script)} ${GIT_HOOK_ENTRYPOINT_NAME} ${hook} "$@"`;
244
+ }
245
+ function managedHookBlock(hook) {
246
+ const { start, end } = blockMarkers(hook);
247
+ const header = `${CONTRACT_LINE_PREFIX}${GIT_HOOK_CONTRACT_VERSION}: a no-op unless prim is installed and enabled here (see \`prim hooks snippet ${hook}\`).`;
248
+ if (hook !== "post-rewrite") {
249
+ return `${start}
250
+ ${header}
251
+ # shellcheck disable=SC2016
252
+ ${entrypointCommand(hook)} || :
253
+ ${end}`;
254
+ }
255
+ return `${start}
256
+ ${header}
257
+ prim_rewrite_stdin=$(mktemp 2>/dev/null) || prim_rewrite_stdin=
258
+ if test -n "\${prim_rewrite_stdin}" && cat >"\${prim_rewrite_stdin}"; then
259
+ # shellcheck disable=SC2016
260
+ ${entrypointCommand(hook)} <"\${prim_rewrite_stdin}" || :
261
+ exec <"\${prim_rewrite_stdin}"
262
+ fi
263
+ if test -n "\${prim_rewrite_stdin}"; then rm -f "\${prim_rewrite_stdin}"; fi
264
+ unset prim_rewrite_stdin
265
+ ${end}`;
266
+ }
267
+ var OPEN_WORDS = /* @__PURE__ */ new Set(["then", "do", "else", "{", "(", "&&", "||", "|"]);
268
+ function canonicalHookBlock(text) {
269
+ const tokens = [];
270
+ let word = "";
271
+ let quote;
272
+ const separate = () => {
273
+ const last = tokens.at(-1);
274
+ if (last !== void 0 && last !== ";" && !OPEN_WORDS.has(last)) tokens.push(";");
275
+ };
276
+ const flush = () => {
277
+ if (word === "") return;
278
+ const last = tokens.at(-1);
279
+ if (last !== void 0 && /^[0-9]*(?:<|>|>>|<&|>&)$/u.test(last))
280
+ tokens[tokens.length - 1] += word;
281
+ else tokens.push(word);
282
+ word = "";
283
+ };
284
+ for (let index = 0; index < text.length; index += 1) {
285
+ const char = text[index];
286
+ if (quote) {
287
+ word += char;
288
+ if (char === quote) quote = void 0;
289
+ continue;
290
+ }
291
+ if (char === "'" || char === '"') {
292
+ quote = char;
293
+ word += char;
294
+ } else if (char === "#" && word === "") {
295
+ const end = text.indexOf("\n", index);
296
+ const comment = text.slice(index, end === -1 ? text.length : end);
297
+ tokens.push(comment.replace(/\s+/gu, " ").trim());
298
+ index = (end === -1 ? text.length : end) - 1;
299
+ } else if (char === "\n" || char === ";") {
300
+ flush();
301
+ if (char === ";" && text[index + 1] === ";") {
302
+ tokens.push(";;");
303
+ index += 1;
304
+ } else {
305
+ separate();
306
+ }
307
+ } else if (/\s/u.test(char)) {
308
+ flush();
309
+ } else {
310
+ word += char;
311
+ }
312
+ }
313
+ flush();
314
+ while (tokens.at(-1) === ";") tokens.pop();
315
+ return tokens.join("\0");
316
+ }
317
+ function hookBlockContractVersion(block) {
318
+ const match = CONTRACT_LINE_RE.exec(block);
319
+ return match?.[1] ? Number(match[1]) : void 0;
320
+ }
321
+ var GIT_HOOK_ENTRYPOINT_CONTENT = `#!/bin/sh
322
+ # prim git hook entrypoint v1 \u2014 managed by prim; do not edit.
323
+ # Run by the "# >>> prim <hook> hook >>>" block in a Git hook file. Acts only
324
+ # where prim is active (git config prim.active true) and never fails the hook.
325
+ case "$0" in */*) prim_dir=\${0%/*} ;; *) exit 0 ;; esac
326
+ prim_launcher="$prim_dir/${STABLE_HOOK_LAUNCHER_NAME}"
327
+ [ -x "$prim_launcher" ] || exit 0
328
+ [ "$(git config --get prim.active 2>/dev/null)" = "true" ] || exit 0
329
+ prim_hook=\${1-}
330
+ if [ "$#" -gt 0 ]; then shift; fi
331
+ case "$prim_hook" in
332
+ pre-commit)
333
+ "$prim_launcher" prim-pre-commit "$@" || :
334
+ ;;
335
+ post-commit)
336
+ prim_sha=$(git rev-parse --verify HEAD 2>/dev/null) || exit 0
337
+ case "$prim_sha" in *[!0-9a-f]* | "") exit 0 ;; esac
338
+ case "\${#prim_sha}" in 40 | 64) ;; *) exit 0 ;; esac
339
+ prim_branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null) || prim_branch=
340
+ prim_observed=$(mktemp "\${TMPDIR:-/tmp}/prim-post-commit-observed.XXXXXXXX" 2>/dev/null) || prim_observed=
341
+ (
342
+ trap '' HUP
343
+ trap 'if [ -n "$prim_observed" ]; then rm -f "$prim_observed"; fi' 0
344
+ PRIM_COMMIT_SHA=$prim_sha PRIM_COMMIT_BRANCH=$prim_branch PRIM_COMMIT_OBSERVED_FILE=$prim_observed "$prim_launcher" prim-post-commit
345
+ ) </dev/null >/dev/null 2>&1 &
346
+ ;;
347
+ post-rewrite)
348
+ case "\${1-}" in amend | rebase) prim_source=$1 ;; *) exit 0 ;; esac
349
+ prim_pairs=$(mktemp "\${TMPDIR:-/tmp}/prim-post-rewrite-pairs.XXXXXXXX" 2>/dev/null) || exit 0
350
+ if ! chmod 600 "$prim_pairs" 2>/dev/null || ! cat >"$prim_pairs"; then
351
+ rm -f "$prim_pairs"
352
+ exit 0
353
+ fi
354
+ prim_branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null) || prim_branch=
355
+ (
356
+ trap '' HUP
357
+ trap 'rm -f "$prim_pairs"' 0
358
+ PRIM_REWRITE_SOURCE=$prim_source PRIM_REWRITE_BRANCH=$prim_branch PRIM_REWRITE_PAIRS_FILE=$prim_pairs "$prim_launcher" prim-post-rewrite
359
+ ) </dev/null >/dev/null 2>&1 &
360
+ ;;
361
+ esac
362
+ exit 0
363
+ `;
364
+
365
+ // src/lib/hook-runtime.ts
223
366
  var HOOK_RUNTIME_SCHEMA_VERSION = 1;
224
367
  var DIRECTORY_MODE = 448;
225
368
  var DATA_FILE_MODE = 384;
@@ -263,6 +406,7 @@ function hookRuntimePaths(options = {}) {
263
406
  return {
264
407
  configDir,
265
408
  launcher: join2(configDir, STABLE_HOOK_LAUNCHER_NAME),
409
+ gitHookEntrypoint: join2(configDir, GIT_HOOK_ENTRYPOINT_NAME),
266
410
  runtimeDir,
267
411
  releasesDir: join2(runtimeDir, "releases"),
268
412
  current: join2(runtimeDir, "current"),
@@ -636,6 +780,13 @@ function stageHookRuntime(options = {}) {
636
780
  mode: LAUNCHER_MODE
637
781
  });
638
782
  }
783
+ const entrypointCurrent = gitHookEntrypointCurrent(paths);
784
+ if (!entrypointCurrent) {
785
+ atomicWriteFile(paths.gitHookEntrypoint, GIT_HOOK_ENTRYPOINT_CONTENT, {
786
+ ensureParent: true,
787
+ mode: LAUNCHER_MODE
788
+ });
789
+ }
639
790
  if (!selectorCurrent) {
640
791
  syncDirectory(paths.releasesDir);
641
792
  atomicWriteFile(paths.current, `${finalRelease.name}
@@ -652,13 +803,38 @@ function stageHookRuntime(options = {}) {
652
803
  now
653
804
  );
654
805
  return {
655
- changed: !launcherCurrent || !selectorCurrent,
806
+ changed: !launcherCurrent || !entrypointCurrent || !selectorCurrent,
656
807
  releaseDir: finalRelease.dir,
657
808
  manifest: finalRelease.manifest,
658
809
  paths
659
810
  };
660
811
  });
661
812
  }
813
+ function gitHookEntrypointCurrent(paths) {
814
+ try {
815
+ return readFileSync2(paths.gitHookEntrypoint, "utf8") === GIT_HOOK_ENTRYPOINT_CONTENT;
816
+ } catch {
817
+ return false;
818
+ }
819
+ }
820
+ function inspectGitHookEntrypoint(options = {}) {
821
+ let paths;
822
+ try {
823
+ paths = hookRuntimePaths(options);
824
+ } catch {
825
+ return "invalid";
826
+ }
827
+ try {
828
+ const entrypoint = lstatIfPresent(paths.gitHookEntrypoint);
829
+ if (!entrypoint) return "missing";
830
+ if (!entrypoint.isFile() || (Number(entrypoint.mode) & 64) === 0 || !gitHookEntrypointCurrent(paths) || readFileSync2(paths.launcher, "utf8") !== STABLE_HOOK_LAUNCHER_CONTENT || !lstatIfPresent(paths.current)?.isFile()) {
831
+ return "invalid";
832
+ }
833
+ return "ready";
834
+ } catch {
835
+ return "invalid";
836
+ }
837
+ }
662
838
  function lstatIfPresent(path) {
663
839
  try {
664
840
  return lstatSync(path);
@@ -669,6 +845,12 @@ function lstatIfPresent(path) {
669
845
  }
670
846
  }
671
847
  function assertOwnedHookRuntime(paths) {
848
+ const entrypoint = lstatIfPresent(paths.gitHookEntrypoint);
849
+ if (entrypoint && (!entrypoint.isFile() || !gitHookEntrypointCurrent(paths))) {
850
+ throw new Error(
851
+ `refusing to remove unrecognized Git hook entrypoint at ${paths.gitHookEntrypoint}`
852
+ );
853
+ }
672
854
  const launcher = lstatIfPresent(paths.launcher);
673
855
  if (launcher) {
674
856
  if (!launcher.isFile() || readFileSync2(paths.launcher, "utf8") !== STABLE_HOOK_LAUNCHER_CONTENT) {
@@ -708,15 +890,18 @@ function assertOwnedHookRuntime(paths) {
708
890
  function removeHookRuntime(options = {}) {
709
891
  const paths = hookRuntimePaths(options);
710
892
  const hadLauncher = lstatIfPresent(paths.launcher) !== void 0;
893
+ const hadEntrypoint = lstatIfPresent(paths.gitHookEntrypoint) !== void 0;
711
894
  const hadRuntime = lstatIfPresent(paths.runtimeDir) !== void 0;
712
- if (!hadLauncher && !hadRuntime) return { changed: false, paths };
895
+ if (!hadLauncher && !hadEntrypoint && !hadRuntime) return { changed: false, paths };
713
896
  assertOwnedHookRuntime(paths);
714
897
  if (!hadRuntime) {
898
+ rmSync(paths.gitHookEntrypoint, { force: true });
715
899
  rmSync(paths.launcher, { force: true });
716
- return { changed: hadLauncher, paths };
900
+ return { changed: true, paths };
717
901
  }
718
902
  withFileLockSync(paths.selectionLock, () => {
719
903
  assertOwnedHookRuntime(paths);
904
+ if (lstatIfPresent(paths.gitHookEntrypoint)) rmSync(paths.gitHookEntrypoint, { force: true });
720
905
  if (lstatIfPresent(paths.launcher)) rmSync(paths.launcher, { force: true });
721
906
  const quarantined = `${paths.runtimeDir}.uninstall-${String(process.pid)}-${randomBytes(8).toString("hex")}`;
722
907
  renameSync(paths.runtimeDir, quarantined);
@@ -1467,10 +1652,17 @@ export {
1467
1652
  stableHookCommand,
1468
1653
  commandMatchesBin,
1469
1654
  hookCommandResolutions,
1655
+ MANAGED_GIT_HOOK_NAMES,
1656
+ GIT_HOOK_CONTRACT_VERSION,
1657
+ blockMarkers,
1658
+ managedHookBlock,
1659
+ canonicalHookBlock,
1660
+ hookBlockContractVersion,
1470
1661
  hookRuntimePaths,
1471
1662
  inspectHookRuntime,
1472
1663
  stableNodePath,
1473
1664
  stageHookRuntime,
1665
+ inspectGitHookEntrypoint,
1474
1666
  assertOwnedHookRuntime,
1475
1667
  removeHookRuntime,
1476
1668
  projectRoot,
@@ -0,0 +1,162 @@
1
+ import {
2
+ repoActiveFlag
3
+ } from "./chunk-DSWT6BCC.js";
4
+ import {
5
+ atomicWriteFile
6
+ } from "./chunk-2GP5UFV7.js";
7
+
8
+ // src/lib/commit-heartbeat.ts
9
+ import { execFileSync } from "child_process";
10
+ import { mkdirSync, readFileSync, readdirSync, unlinkSync } from "fs";
11
+ import { dirname, join, resolve } from "path";
12
+ var FIRED_DIR = "prim/post-commit-fired";
13
+ var WIRED_PATH = "prim/git-hooks-wired";
14
+ var GIT_TIMEOUT_MS = 1e3;
15
+ var MAX_STAMP_BYTES = 1024;
16
+ var REFLOG_SCAN_LIMIT = 50;
17
+ var MAX_FIRED_STAMPS = 256;
18
+ var PRUNE_SLACK = 64;
19
+ var FULL_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u;
20
+ var REFLOG_LINE_RE = /^([0-9a-f]{40}|[0-9a-f]{64})\tHEAD@\{([0-9]+)\}\t(.*)$/u;
21
+ var COMMIT_SUBJECT_RE = /^commit(?: \([^)]*\))?:/u;
22
+ var REFLOG_RESOLUTION_MS = 1e3;
23
+ var POST_COMMIT_GRACE_MS = 6e4;
24
+ function commonPath(cwd, relative) {
25
+ const value = execFileSync("git", ["rev-parse", "--git-common-dir"], {
26
+ cwd,
27
+ encoding: "utf8",
28
+ stdio: ["ignore", "pipe", "ignore"],
29
+ timeout: GIT_TIMEOUT_MS
30
+ }).replace(/\r?\n$/u, "");
31
+ if (value === "" || /[\0\r\n]/u.test(value)) throw new Error("Git returned an unsafe path");
32
+ return join(resolve(cwd, value), relative);
33
+ }
34
+ function gitPath(cwd, relative) {
35
+ const value = execFileSync("git", ["rev-parse", "--git-path", relative], {
36
+ cwd,
37
+ encoding: "utf8",
38
+ stdio: ["ignore", "pipe", "ignore"],
39
+ timeout: GIT_TIMEOUT_MS
40
+ }).replace(/\r?\n$/u, "");
41
+ if (value === "" || /[\0\r\n]/u.test(value)) throw new Error("Git returned an unsafe path");
42
+ return resolve(cwd, value);
43
+ }
44
+ function ensurePrivateParent(path) {
45
+ mkdirSync(dirname(dirname(path)), { recursive: true, mode: 448 });
46
+ mkdirSync(dirname(path), { recursive: true, mode: 448 });
47
+ }
48
+ function writeStamp(path, at) {
49
+ ensurePrivateParent(path);
50
+ atomicWriteFile(path, `${JSON.stringify({ at })}
51
+ `, { mode: 384 });
52
+ }
53
+ function readStampAt(path) {
54
+ try {
55
+ const raw = readFileSync(path, "utf8");
56
+ if (raw.length > MAX_STAMP_BYTES) return void 0;
57
+ const parsed = JSON.parse(raw);
58
+ return typeof parsed.at === "number" && Number.isSafeInteger(parsed.at) && parsed.at > 0 ? parsed.at : void 0;
59
+ } catch {
60
+ return void 0;
61
+ }
62
+ }
63
+ function readFiredStamps(cwd) {
64
+ try {
65
+ const dir = gitPath(cwd, FIRED_DIR);
66
+ return readdirSync(dir).flatMap((sha) => {
67
+ if (!FULL_SHA_RE.test(sha)) return [];
68
+ const at = readStampAt(join(dir, sha));
69
+ return at === void 0 ? [] : [{ sha, at }];
70
+ });
71
+ } catch {
72
+ return [];
73
+ }
74
+ }
75
+ function recordPostCommitFired(cwd, sha, now = Date.now()) {
76
+ if (!sha || !FULL_SHA_RE.test(sha)) return;
77
+ try {
78
+ const dir = gitPath(cwd, FIRED_DIR);
79
+ writeStamp(join(dir, sha), now);
80
+ const names = readdirSync(dir).filter((name) => FULL_SHA_RE.test(name));
81
+ if (names.length <= MAX_FIRED_STAMPS + PRUNE_SLACK) return;
82
+ const stale = names.map((name) => ({ name, at: readStampAt(join(dir, name)) ?? 0 })).sort((left, right) => right.at - left.at || left.name.localeCompare(right.name)).slice(MAX_FIRED_STAMPS);
83
+ for (const { name } of stale) unlinkSync(join(dir, name));
84
+ } catch {
85
+ }
86
+ }
87
+ function recordHooksWired(cwd, options = {}) {
88
+ try {
89
+ const path = commonPath(cwd, WIRED_PATH);
90
+ if (options.onlyIfAbsent && readStampAt(path) !== void 0) return;
91
+ writeStamp(path, options.now ?? Date.now());
92
+ } catch {
93
+ }
94
+ }
95
+ function clearHooksWired(cwd) {
96
+ try {
97
+ unlinkSync(commonPath(cwd, WIRED_PATH));
98
+ } catch {
99
+ }
100
+ }
101
+ function latestLocalCommit(cwd) {
102
+ let output;
103
+ try {
104
+ output = execFileSync(
105
+ "git",
106
+ [
107
+ "log",
108
+ "-g",
109
+ "--no-show-signature",
110
+ "-n",
111
+ String(REFLOG_SCAN_LIMIT),
112
+ "--date=unix",
113
+ "--format=%H%x09%gd%x09%gs",
114
+ "HEAD"
115
+ ],
116
+ { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: GIT_TIMEOUT_MS }
117
+ );
118
+ } catch {
119
+ return void 0;
120
+ }
121
+ for (const line of output.split("\n")) {
122
+ const match = REFLOG_LINE_RE.exec(line);
123
+ if (match?.[1] && match[2] && COMMIT_SUBJECT_RE.test(match[3] ?? "")) {
124
+ return { sha: match[1], at: Number(match[2]) * 1e3 };
125
+ }
126
+ }
127
+ return void 0;
128
+ }
129
+ function inspectPostCommitFiring(cwd, now = Date.now()) {
130
+ if (repoActiveFlag(cwd) !== "true") return { state: "inactive" };
131
+ const commit = latestLocalCommit(cwd);
132
+ if (!commit) return { state: "unverified" };
133
+ const stamps = readFiredStamps(cwd);
134
+ const own = stamps.find((stamp) => stamp.sha === commit.sha);
135
+ if (own) return { state: "fired", commitAt: commit.at, firedAt: own.at };
136
+ if (now - commit.at < POST_COMMIT_GRACE_MS) return { state: "pending", commitAt: commit.at };
137
+ const lastFiredAt = stamps.reduce(
138
+ (latest, stamp) => latest === void 0 || stamp.at > latest ? stamp.at : latest,
139
+ void 0
140
+ );
141
+ let wiredAt;
142
+ try {
143
+ wiredAt = readStampAt(commonPath(cwd, WIRED_PATH));
144
+ } catch {
145
+ wiredAt = void 0;
146
+ }
147
+ if (wiredAt === void 0) return { state: "unverified" };
148
+ const firedSinceWired = lastFiredAt !== void 0 && lastFiredAt >= wiredAt;
149
+ const judged = commit.at > wiredAt || firedSinceWired && commit.at + REFLOG_RESOLUTION_MS > lastFiredAt;
150
+ if (judged) {
151
+ return lastFiredAt === void 0 ? { state: "not_firing", commitAt: commit.at } : { state: "not_firing", commitAt: commit.at, firedAt: lastFiredAt };
152
+ }
153
+ return { state: "unverified" };
154
+ }
155
+
156
+ export {
157
+ POST_COMMIT_GRACE_MS,
158
+ recordPostCommitFired,
159
+ recordHooksWired,
160
+ clearHooksWired,
161
+ inspectPostCommitFiring
162
+ };
@@ -2,7 +2,7 @@ import {
2
2
  cursorHooksPath,
3
3
  hasExactCursorHandler,
4
4
  readCursorHooks
5
- } from "./chunk-X64HTHUF.js";
5
+ } from "./chunk-AXQQMX2U.js";
6
6
  import {
7
7
  resolveRepositoryContext
8
8
  } from "./chunk-H4UNYFUC.js";
@@ -12,7 +12,7 @@ import {
12
12
  } from "./chunk-G2VMA2QO.js";
13
13
  import {
14
14
  packageVersion
15
- } from "./chunk-X64HTHUF.js";
15
+ } from "./chunk-AXQQMX2U.js";
16
16
  import {
17
17
  gitToplevel
18
18
  } from "./chunk-H4UNYFUC.js";