@primitive.ai/prim 0.1.0-alpha.92 → 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.
Files changed (42) hide show
  1. package/README.md +84 -10
  2. package/SKILL.md +5 -2
  3. package/dist/{chunk-67VNLLBN.js → chunk-2GP5UFV7.js} +1 -0
  4. package/dist/{chunk-26P57LAR.js → chunk-AXQQMX2U.js} +203 -9
  5. package/dist/{chunk-M6RIJVRE.js → chunk-DSWT6BCC.js} +78 -4
  6. package/dist/{chunk-FBAYCS3X.js → chunk-DXJQR4QI.js} +150 -38
  7. package/dist/chunk-FXF4JWK2.js +162 -0
  8. package/dist/{chunk-PCVGBE5B.js → chunk-G2VMA2QO.js} +2 -1
  9. package/dist/{chunk-NAHHFSLH.js → chunk-ICM2XCXS.js} +2 -2
  10. package/dist/{chunk-5HRR4AOR.js → chunk-IMK4JKTA.js} +1 -1
  11. package/dist/{chunk-OAAP2MY6.js → chunk-K7D2EYBS.js} +2 -2
  12. package/dist/{chunk-ADUTW66W.js → chunk-O7YAW7G6.js} +6 -6
  13. package/dist/{chunk-5AFRMOPX.js → chunk-OH3EZXYX.js} +1 -1
  14. package/dist/{chunk-KFXF2V5G.js → chunk-OTHKG76J.js} +1 -1
  15. package/dist/chunk-OYHYCK4K.js +13 -0
  16. package/dist/{chunk-FKXFUUWJ.js → chunk-TYAHC25F.js} +1 -1
  17. package/dist/{chunk-5GYQ4J3D.js → chunk-UKUAAAXE.js} +2 -2
  18. package/dist/{chunk-LGGMJYY4.js → chunk-WUXFLE7X.js} +720 -369
  19. package/dist/{chunk-WX52U7Z2.js → chunk-ZU563PQI.js} +58 -2
  20. package/dist/daemon/server.js +372 -123
  21. package/dist/hook-runtime/daemon/server.js +372 -123
  22. package/dist/hook-runtime/hooks/post-commit.js +192 -58
  23. package/dist/hook-runtime/hooks/post-rewrite.js +58 -40
  24. package/dist/hook-runtime/hooks/post-tool-use.js +150 -67
  25. package/dist/hook-runtime/hooks/pre-tool-use.js +87 -15
  26. package/dist/hook-runtime/hooks/prim-hook.js +197 -107
  27. package/dist/hook-runtime/hooks/session-end.js +53 -5
  28. package/dist/hook-runtime/hooks/session-start.js +880 -280
  29. package/dist/hook-runtime/index.js +4008 -2438
  30. package/dist/hook-runtime/statusline-main.js +23 -1
  31. package/dist/hooks/post-commit.js +15 -8
  32. package/dist/hooks/post-rewrite.js +11 -8
  33. package/dist/hooks/post-tool-use.js +13 -15
  34. package/dist/hooks/pre-commit.js +4 -4
  35. package/dist/hooks/pre-tool-use.js +10 -12
  36. package/dist/hooks/prim-hook.js +17 -14
  37. package/dist/hooks/session-end.js +4 -4
  38. package/dist/hooks/session-start.js +35 -45
  39. package/dist/index.js +1401 -620
  40. package/dist/statusline-main.js +23 -1
  41. package/package.json +1 -1
  42. package/dist/chunk-BTNNEH2W.js +0 -79
@@ -1,26 +1,37 @@
1
+ import {
2
+ unattendedEnv
3
+ } from "./chunk-OYHYCK4K.js";
1
4
  import {
2
5
  isValidRepoSyncId,
3
6
  setRepoSyncId,
4
7
  setRepositoryBindingState
5
- } from "./chunk-M6RIJVRE.js";
8
+ } from "./chunk-DSWT6BCC.js";
6
9
  import {
7
10
  HttpError,
8
11
  getClient
9
- } from "./chunk-PCVGBE5B.js";
12
+ } from "./chunk-G2VMA2QO.js";
10
13
  import {
14
+ GIT_HOOK_CONTRACT_VERSION,
11
15
  binFile,
16
+ blockMarkers,
17
+ canonicalHookBlock,
12
18
  compareSemver,
13
19
  cursorConfigDirectory,
20
+ hookBlockContractVersion,
21
+ inspectGitHookEntrypoint,
22
+ managedHookBlock,
23
+ pinnedHookCommand,
14
24
  pinnedNpxCommand
15
- } from "./chunk-26P57LAR.js";
25
+ } from "./chunk-AXQQMX2U.js";
16
26
  import {
17
27
  gitToplevel,
18
28
  githubRepositoryFullName
19
29
  } from "./chunk-H4UNYFUC.js";
20
30
  import {
21
31
  atomicWriteFile,
32
+ primConfigDirectory,
22
33
  withFileLock
23
- } from "./chunk-67VNLLBN.js";
34
+ } from "./chunk-2GP5UFV7.js";
24
35
 
25
36
  // src/lib/bin-cache.ts
26
37
  import { chmodSync, mkdirSync } from "fs";
@@ -557,8 +568,8 @@ function applyBlock(existing, block, eol) {
557
568
  return existing.slice(0, b) + block + existing.slice(e + SKILL_END.length);
558
569
  }
559
570
  if (existing.length === 0) return `${block}${eol}`;
560
- const sep2 = existing.endsWith(eol) ? "" : eol;
561
- return `${existing}${sep2}${block}${eol}`;
571
+ const sep3 = existing.endsWith(eol) ? "" : eol;
572
+ return `${existing}${sep3}${block}${eol}`;
562
573
  }
563
574
  function removeBlock(existing) {
564
575
  const b = existing.indexOf(SKILL_BEGIN);
@@ -698,60 +709,28 @@ function registerSkillCommands(program) {
698
709
  });
699
710
  }
700
711
 
701
- // src/lib/post-commit-hook.ts
702
- import { execFileSync } from "child_process";
703
- import { createHash as createHash3, randomUUID } from "crypto";
704
- import {
705
- constants as constants3,
706
- closeSync as closeSync3,
707
- existsSync as existsSync3,
708
- fchmodSync,
709
- fsyncSync,
710
- lstatSync as lstatSync3,
711
- mkdirSync as mkdirSync3,
712
- openSync as openSync3,
713
- readFileSync as readFileSync3,
714
- renameSync,
715
- unlinkSync as unlinkSync2,
716
- writeFileSync
717
- } from "fs";
718
- import { basename, dirname as dirname3, isAbsolute as isAbsolute2, resolve as resolve3 } from "path";
719
- var PRIM_POST_COMMIT_BLOCK_START = "# >>> prim post-commit hook >>>";
720
- var PRIM_POST_COMMIT_BLOCK_END = "# <<< prim post-commit hook <<<";
721
- var PRIM_CREATED_MARK = "prim-created-post-commit-hook";
722
- var PRIM_POST_REWRITE_BLOCK_START = "# >>> prim post-rewrite hook >>>";
723
- var PRIM_POST_REWRITE_BLOCK_END = "# <<< prim post-rewrite hook <<<";
724
- var PRIM_POST_REWRITE_CREATED_MARK = "prim-created-post-rewrite-hook";
725
- var LEGACY_PRIM_CREATED_MARK = "prim-created-hook";
726
- var LEGACY_PRIM_OWNED_HEADER = "# prim post-commit hook \u2014 installed by: prim hooks install (prim-managed-hook)";
727
- var LEGACY_GLOBAL_OWNED_HEADER = "# prim global post-commit hook (core.hooksPath) \u2014 managed by prim; do not edit.";
728
- var MAX_HOOK_BYTES = 1048576;
729
- var GIT_TIMEOUT_MS = 1e3;
730
- var SUPPORTED_SHEBANGS = /* @__PURE__ */ new Set([
731
- "#!/bin/sh",
732
- "#!/usr/bin/env sh",
733
- "#!/bin/bash",
734
- "#!/usr/bin/env bash",
735
- "#!/bin/zsh",
736
- "#!/usr/bin/env zsh"
737
- ]);
738
- var HUSKY_V9_DISPATCHERS = /* @__PURE__ */ new Set([
739
- '#!/usr/bin/env sh\n. "${0%/*}/h"',
740
- '#!/usr/bin/env sh\n. "$(dirname "$0")/h"'
741
- ]);
742
- var HUSKY_DIRECT_DISPATCHER = '#!/bin/sh\nexec sh "$(dirname "$0")/../post-commit"\n';
743
- var HUSKY_V9_RUNTIME_HASHES = /* @__PURE__ */ new Set([
744
- "d6aaa6f3f7c11008c525e649161df4a93152f1e55d0887fa1d7b8d622735d380",
745
- "b02cb91cab7125a9e906be723a1b85297fc435a8307d429ae845546b4ee0ecde",
746
- "9db5d129c4eb822a773157d98cd8fb8f1b156b85e3193117fefe527ca6d45e91",
747
- "3b2cb21335e544b4ebce658ca47af4a69d2c77026cc4d3f8b2227c34d062b207",
748
- "548990e1c11285694096184993d907b258d41235461a7ca2551e3b53ff9d3a38",
749
- "c029a943acea5d6e2ddbaa271a1fb22c2da55d56b5c6fb59ca804ac9a4018708",
750
- "ae3413a8fe2b39372de48bdc9691f42055f089c0c7529834b2fb07a131585b6a",
751
- "70200b200ca709b0622784f93839a5b2872333a917a09afddefd7dc2d8cdc680"
752
- ]);
753
- function currentPostCommitBlock() {
754
- return `${PRIM_POST_COMMIT_BLOCK_START}
712
+ // src/lib/git-hook-legacy.ts
713
+ import { isAbsolute as isAbsolute2 } from "path";
714
+ var PRIM_MANAGED_MARK = "prim-managed-hook";
715
+ var PRE_COMMIT = { hookName: "pre-commit", binName: "prim-pre-commit" };
716
+ var POST_COMMIT = { hookName: "post-commit", binName: "prim-post-commit" };
717
+ var POST_REWRITE = { hookName: "post-rewrite", binName: "prim-post-rewrite" };
718
+ function blockMarkers2(spec) {
719
+ return {
720
+ start: `# >>> prim ${spec.hookName} hook >>>`,
721
+ end: `# <<< prim ${spec.hookName} hook <<<`
722
+ };
723
+ }
724
+ function hookShim(binName) {
725
+ return `{ ${pinnedHookCommand(binName)}; } || true`;
726
+ }
727
+ function gatedShim(binName) {
728
+ return `if [ "$(git config --get prim.active 2>/dev/null)" = "true" ]; then
729
+ ${hookShim(binName)}
730
+ fi`;
731
+ }
732
+ function legacyPostCommitBlock() {
733
+ return `# >>> prim post-commit hook >>>
755
734
  if [ "$(git config --get prim.active 2>/dev/null)" = "true" ]; then
756
735
  prim_commit_sha=$(git rev-parse --verify HEAD 2>/dev/null) || prim_commit_sha=
757
736
  case "$prim_commit_sha" in *[!0-9a-f]*|"") prim_commit_sha= ;; esac
@@ -780,13 +759,10 @@ if [ "$(git config --get prim.active 2>/dev/null)" = "true" ]; then
780
759
  fi
781
760
  unset prim_commit_sha
782
761
  fi
783
- ${PRIM_POST_COMMIT_BLOCK_END}`;
762
+ # <<< prim post-commit hook <<<`;
784
763
  }
785
- function postCommitHookBlock() {
786
- return currentPostCommitBlock();
787
- }
788
- function postRewriteHookBlock() {
789
- return `${PRIM_POST_REWRITE_BLOCK_START}
764
+ function legacyPostRewriteBlock() {
765
+ return `# >>> prim post-rewrite hook >>>
790
766
  if [ "$(git config --get prim.active 2>/dev/null)" = "true" ]; then
791
767
  case "$1" in amend|rebase) prim_rewrite_source="$1" ;; *) prim_rewrite_source= ;; esac
792
768
  if [ -n "$prim_rewrite_source" ]; then
@@ -825,22 +801,227 @@ if [ "$(git config --get prim.active 2>/dev/null)" = "true" ]; then
825
801
  fi
826
802
  unset prim_rewrite_source
827
803
  fi
828
- ${PRIM_POST_REWRITE_BLOCK_END}`;
829
- }
830
- var POST_COMMIT_MANAGED_HOOK = {
831
- hookName: "post-commit",
832
- blockStart: PRIM_POST_COMMIT_BLOCK_START,
833
- blockEnd: PRIM_POST_COMMIT_BLOCK_END,
834
- createdMark: PRIM_CREATED_MARK,
835
- block: postCommitHookBlock
836
- };
837
- var POST_REWRITE_MANAGED_HOOK = {
838
- hookName: "post-rewrite",
839
- blockStart: PRIM_POST_REWRITE_BLOCK_START,
840
- blockEnd: PRIM_POST_REWRITE_BLOCK_END,
841
- createdMark: PRIM_POST_REWRITE_CREATED_MARK,
842
- block: postRewriteHookBlock
804
+ # <<< prim post-rewrite hook <<<`;
805
+ }
806
+ function legacyGlobalHookScript(spec) {
807
+ const chainExit = spec.hookName === PRE_COMMIT.hookName ? "|| exit $?" : "|| true";
808
+ const managedBlock = spec.hookName === POST_COMMIT.hookName ? legacyPostCommitBlock() : spec.hookName === POST_REWRITE.hookName ? legacyPostRewriteBlock() : void 0;
809
+ const invocation = managedBlock ?? gatedShim(spec.binName);
810
+ const managedRepoGuard = managedBlock ? ` && ! grep -Fq '${blockMarkers2(spec).start.slice(0, -3)}'">>>" "$repo_hook" 2>/dev/null` : "";
811
+ const beforeComments = managedBlock ? `${invocation}
812
+ ` : "";
813
+ const afterComments = managedBlock ? "" : `${invocation}
814
+ `;
815
+ return `#!/bin/sh
816
+ ${beforeComments}# prim global ${spec.hookName} hook (core.hooksPath) \u2014 managed by prim; do not edit.
817
+ # Install/uninstall: prim hooks install|uninstall --scope user
818
+ # Runs prim only where activated \u2014 'prim enable' (this repo) or
819
+ # 'git config --global prim.active true' (every repo). Chains to the repo's own
820
+ # hook regardless, so inactive repos are unaffected.
821
+ ${afterComments}common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || exit 0
822
+ repo_hook="$common_dir/hooks/${spec.hookName}"
823
+ if [ -x "$repo_hook" ] && ! grep -q '${PRIM_MANAGED_MARK}' "$repo_hook" 2>/dev/null${managedRepoGuard}; then
824
+ "$repo_hook" "$@" ${chainExit}
825
+ fi
826
+ exit 0
827
+ `;
828
+ }
829
+ var PRE_COMMIT_GATE = 'if [ "$(git config --get prim.active 2>/dev/null)" = "true" ]; then';
830
+ function preCommitLadder(fallback, failSoft) {
831
+ const soft = failSoft ? " || true" : "";
832
+ return `if command -v prim-pre-commit >/dev/null 2>&1; then
833
+ prim-pre-commit${soft}
834
+ elif [ -f "./node_modules/.bin/prim-pre-commit" ]; then
835
+ ./node_modules/.bin/prim-pre-commit${soft}
836
+ else
837
+ ${fallback} 2>/dev/null || true
838
+ fi`;
839
+ }
840
+ var LADDER_ALPHA_2 = preCommitLadder("npx --yes @primitive.ai/prim pre-commit-hook", false);
841
+ var LADDER_ALPHA_9 = preCommitLadder("npx --yes -p @primitive.ai/prim prim-pre-commit", false);
842
+ var LADDER_ALPHA_35 = preCommitLadder("npx --yes -p @primitive.ai/prim prim-pre-commit", true);
843
+ var PINNED_NPX_PRE_COMMIT = String.raw`npx --yes(?: --ignore-scripts)? -p @primitive\.ai/prim@[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)? prim-pre-commit`;
844
+ var PINNED_PRE_COMMIT_RE = new RegExp(
845
+ String.raw`^\{ (?:${PINNED_NPX_PRE_COMMIT}|if \[ -x (?<node>'.*') \] && \[ -f (?<entry>'.*') \]; then \k<node> \k<entry>; else ${PINNED_NPX_PRE_COMMIT}; fi); \} \|\| true$`,
846
+ "u"
847
+ );
848
+ var SHELL_QUOTED_RE = /^'(?:[^']|'"'"')*'$/u;
849
+ function decodePinnedPath(token) {
850
+ if (!token || !SHELL_QUOTED_RE.test(token)) return void 0;
851
+ const value = token.slice(1, -1).replaceAll(`'"'"'`, "'");
852
+ if (!isAbsolute2(value) || value.includes("\0") || value.includes("\n") || value.includes("\r")) {
853
+ return void 0;
854
+ }
855
+ return value;
856
+ }
857
+ function isPinnedPreCommitInvocation(line) {
858
+ const match = PINNED_PRE_COMMIT_RE.exec(line);
859
+ if (!match) return false;
860
+ if (match.groups?.node === void 0) return true;
861
+ return Boolean(
862
+ decodePinnedPath(match.groups.node) && decodePinnedPath(match.groups.entry)?.endsWith("/dist/hooks/pre-commit.js")
863
+ );
864
+ }
865
+ function isLegacyPreCommitBody(body) {
866
+ if (body === LADDER_ALPHA_2 || body === LADDER_ALPHA_9 || body === LADDER_ALPHA_35) return true;
867
+ if (isPinnedPreCommitInvocation(body)) return true;
868
+ const prefix = `${PRE_COMMIT_GATE}
869
+ `;
870
+ if (!body.startsWith(prefix) || !body.endsWith("\nfi")) return false;
871
+ const inner = body.slice(prefix.length, -"\nfi".length);
872
+ return inner === LADDER_ALPHA_35 || isPinnedPreCommitInvocation(inner);
873
+ }
874
+ function isLegacyOwnedPreCommitScript(content) {
875
+ const original = (ladder) => `#!/bin/sh
876
+ # prim pre-commit hook \u2014 auto-syncs affected specs on commit
877
+ # Installed by: prim hooks install
878
+
879
+ # Find the nearest node_modules/.bin with prim, or use npx
880
+ ${ladder}
881
+ `;
882
+ if (content === original(LADDER_ALPHA_2) || content === original(LADDER_ALPHA_9)) return true;
883
+ if (content === `#!/bin/sh
884
+ # prim pre-commit hook \u2014 installed by: prim hooks install
885
+
886
+ ${LADDER_ALPHA_9}
887
+ `) {
888
+ return true;
889
+ }
890
+ const prefix = `#!/bin/sh
891
+ # prim pre-commit hook \u2014 installed by: prim hooks install (${PRIM_MANAGED_MARK})
892
+
893
+ `;
894
+ if (!content.startsWith(prefix) || !content.endsWith("\n")) return false;
895
+ const body = content.slice(prefix.length, -1);
896
+ return body === LADDER_ALPHA_35 || isPinnedPreCommitInvocation(body);
897
+ }
898
+ var PINNED_PACKAGE_VERSION_RE = /@primitive\.ai\/prim@[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/gu;
899
+ function normalizeOwnedGlobalHook(content, hookName) {
900
+ if (hookName === PRE_COMMIT.hookName) {
901
+ const lines = content.split("\n");
902
+ const invocationIndexes = lines.flatMap(
903
+ (line, index) => line.startsWith("{ ") && line.endsWith("; } || true") ? [index] : []
904
+ );
905
+ if (invocationIndexes.length !== 1) return null;
906
+ const invocationIndex = invocationIndexes[0];
907
+ if (!isPinnedPreCommitInvocation(lines[invocationIndex])) return null;
908
+ lines[invocationIndex] = "{ <recognized Prim pre-commit invocation>; } || true";
909
+ return lines.join("\n");
910
+ }
911
+ return content.replace(PINNED_PACKAGE_VERSION_RE, "@primitive.ai/prim@<version>");
912
+ }
913
+ function isLegacyOwnedGlobalHook(content, hookName) {
914
+ const spec = [PRE_COMMIT, POST_COMMIT, POST_REWRITE].find((s) => s.hookName === hookName);
915
+ if (!spec) return false;
916
+ const expected = legacyGlobalHookScript(spec);
917
+ if (content === expected) return true;
918
+ if (spec === PRE_COMMIT && content === expected.replace(gatedShim(spec.binName), `${PRE_COMMIT_GATE}
919
+ ${LADDER_ALPHA_35}
920
+ fi`)) {
921
+ return true;
922
+ }
923
+ const normalized = normalizeOwnedGlobalHook(content, hookName);
924
+ return normalized !== null && normalized === normalizeOwnedGlobalHook(expected, hookName);
925
+ }
926
+ function legacyInlineHookBlock(hookName) {
927
+ return hookName === "post-commit" ? legacyPostCommitBlock() : legacyPostRewriteBlock();
928
+ }
929
+ function isRecognizedLegacyBlock(hookName, block) {
930
+ if (hookName === "pre-commit") {
931
+ const { start, end } = blockMarkers2(PRE_COMMIT);
932
+ const prefix = `${start}
933
+ `;
934
+ const suffix = `
935
+ ${end}`;
936
+ if (!block.startsWith(prefix) || !block.endsWith(suffix)) return false;
937
+ return isLegacyPreCommitBody(block.slice(prefix.length, -suffix.length));
938
+ }
939
+ const expected = legacyInlineHookBlock(hookName);
940
+ const normalize = (text) => text.replace(PINNED_PACKAGE_VERSION_RE, "@primitive.ai/prim@<version>");
941
+ return normalize(block) === normalize(expected);
942
+ }
943
+
944
+ // src/lib/git-hooks.ts
945
+ import { execFileSync } from "child_process";
946
+ import { createHash as createHash3, randomUUID } from "crypto";
947
+ import {
948
+ constants as constants3,
949
+ closeSync as closeSync3,
950
+ existsSync as existsSync3,
951
+ fchmodSync,
952
+ fsyncSync,
953
+ lstatSync as lstatSync3,
954
+ mkdirSync as mkdirSync3,
955
+ openSync as openSync3,
956
+ readFileSync as readFileSync3,
957
+ realpathSync as realpathSync2,
958
+ renameSync,
959
+ unlinkSync as unlinkSync2,
960
+ writeFileSync
961
+ } from "fs";
962
+ import { basename, dirname as dirname3, isAbsolute as isAbsolute3, join as join4, relative as relative2, resolve as resolve3, sep as sep2 } from "path";
963
+ var LEGACY_PRIM_CREATED_MARK = "prim-created-hook";
964
+ var LEGACY_PRIM_OWNED_HEADER = "# prim post-commit hook \u2014 installed by: prim hooks install (prim-managed-hook)";
965
+ var LEGACY_GLOBAL_OWNED_HEADER = "# prim global post-commit hook (core.hooksPath) \u2014 managed by prim; do not edit.";
966
+ var MAX_HOOK_BYTES = 1048576;
967
+ var GIT_TIMEOUT_MS = 1e3;
968
+ var SUPPORTED_SHEBANGS = /* @__PURE__ */ new Set([
969
+ "#!/bin/sh",
970
+ "#!/usr/bin/env sh",
971
+ "#!/bin/bash",
972
+ "#!/usr/bin/env bash",
973
+ "#!/bin/zsh",
974
+ "#!/usr/bin/env zsh"
975
+ ]);
976
+ var HUSKY_V9_DISPATCHERS = /* @__PURE__ */ new Set([
977
+ '#!/usr/bin/env sh\n. "${0%/*}/h"',
978
+ '#!/usr/bin/env sh\n. "$(dirname "$0")/h"'
979
+ ]);
980
+ var HUSKY_DIRECT_DISPATCHER = '#!/bin/sh\nexec sh "$(dirname "$0")/../post-commit"\n';
981
+ var HUSKY_V9_RUNTIME_HASHES = /* @__PURE__ */ new Set([
982
+ "d6aaa6f3f7c11008c525e649161df4a93152f1e55d0887fa1d7b8d622735d380",
983
+ "b02cb91cab7125a9e906be723a1b85297fc435a8307d429ae845546b4ee0ecde",
984
+ "9db5d129c4eb822a773157d98cd8fb8f1b156b85e3193117fefe527ca6d45e91",
985
+ "3b2cb21335e544b4ebce658ca47af4a69d2c77026cc4d3f8b2227c34d062b207",
986
+ "548990e1c11285694096184993d907b258d41235461a7ca2551e3b53ff9d3a38",
987
+ "c029a943acea5d6e2ddbaa271a1fb22c2da55d56b5c6fb59ca804ac9a4018708",
988
+ "ae3413a8fe2b39372de48bdc9691f42055f089c0c7529834b2fb07a131585b6a",
989
+ "70200b200ca709b0622784f93839a5b2872333a917a09afddefd7dc2d8cdc680"
990
+ ]);
991
+ function managedHookSpecFor(hookName) {
992
+ const { start, end } = blockMarkers(hookName);
993
+ return {
994
+ hookName,
995
+ blockStart: start,
996
+ blockEnd: end,
997
+ createdMark: `prim-created-${hookName}-hook`
998
+ };
999
+ }
1000
+ var MANAGED_HOOK_SPECS = {
1001
+ "pre-commit": managedHookSpecFor("pre-commit"),
1002
+ "post-commit": managedHookSpecFor("post-commit"),
1003
+ "post-rewrite": managedHookSpecFor("post-rewrite")
843
1004
  };
1005
+ function managedHookSpec(hookName) {
1006
+ return MANAGED_HOOK_SPECS[hookName];
1007
+ }
1008
+ function gitHooksMode(options = {}) {
1009
+ try {
1010
+ const value = execFileSync(
1011
+ "git",
1012
+ ["config", ...options.global ? ["--global"] : [], "--get", "prim.gitHooks"],
1013
+ {
1014
+ cwd: options.cwd,
1015
+ encoding: "utf8",
1016
+ stdio: ["ignore", "pipe", "ignore"],
1017
+ timeout: GIT_TIMEOUT_MS
1018
+ }
1019
+ ).trim();
1020
+ return value === "manual" ? "manual" : "auto";
1021
+ } catch {
1022
+ return "auto";
1023
+ }
1024
+ }
844
1025
  function legacyFloatingInvocation() {
845
1026
  return `if command -v prim-post-commit >/dev/null 2>&1; then
846
1027
  prim-post-commit || true
@@ -857,15 +1038,6 @@ fi
857
1038
  `);
858
1039
  }
859
1040
  var PINNED_INVOCATION_RE = /^\{ if \[ -x (?<node>'.*') \] && \[ -f (?<entry>'.*') \]; then \k<node> \k<entry>; else npx --yes -p @primitive\.ai\/prim@(?<version>[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?) prim-post-commit; fi; \} \|\| true$/u;
860
- var SHELL_QUOTED_RE = /^'(?:[^']|'"'"')*'$/u;
861
- function decodePinnedPath(token) {
862
- if (!token || !SHELL_QUOTED_RE.test(token)) return void 0;
863
- const value = token.slice(1, -1).replaceAll(`'"'"'`, "'");
864
- if (!isAbsolute2(value) || value.includes("\0") || value.includes("\n") || value.includes("\r")) {
865
- return void 0;
866
- }
867
- return value;
868
- }
869
1041
  function isPinnedInvocation(value) {
870
1042
  const match = PINNED_INVOCATION_RE.exec(value);
871
1043
  const node = decodePinnedPath(match?.groups?.node);
@@ -898,48 +1070,149 @@ function isKnownLegacyGlobalGate(value) {
898
1070
  }
899
1071
  function createdScaffold(spec) {
900
1072
  return Buffer.from(`#!/bin/sh
901
- ${spec.block()}
1073
+ ${managedHookBlock(spec.hookName)}
902
1074
  # ${spec.createdMark}
903
1075
  `);
904
1076
  }
1077
+ function oldCreatedPrefixes(spec) {
1078
+ return [
1079
+ Buffer.from(`#!/bin/sh
1080
+ # ${spec.createdMark}
1081
+
1082
+ `),
1083
+ Buffer.from(`#!/bin/sh
1084
+ # ${LEGACY_PRIM_CREATED_MARK}
1085
+
1086
+ `)
1087
+ ];
1088
+ }
1089
+ function isOwnedStandalonePreCommit(content) {
1090
+ return content === "#!/bin/sh\nprim-pre-commit\n" || isLegacyOwnedPreCommitScript(content);
1091
+ }
1092
+ function realpathLoose(path) {
1093
+ const missing = [];
1094
+ let current = resolve3(path);
1095
+ for (; ; ) {
1096
+ try {
1097
+ return join4(realpathSync2.native(current), ...missing);
1098
+ } catch {
1099
+ const parent = dirname3(current);
1100
+ if (parent === current) return resolve3(path);
1101
+ missing.unshift(basename(current));
1102
+ current = parent;
1103
+ }
1104
+ }
1105
+ }
1106
+ function isWithin(path, directory) {
1107
+ const rel = relative2(directory, path);
1108
+ return rel === "" || !isAbsolute3(rel) && rel !== ".." && !rel.startsWith(`..${sep2}`);
1109
+ }
1110
+ function primGitHooksDirectory() {
1111
+ try {
1112
+ return join4(primConfigDirectory(), "git-hooks");
1113
+ } catch {
1114
+ return void 0;
1115
+ }
1116
+ }
1117
+ function otherWorktreeRoots(gitRoot) {
1118
+ try {
1119
+ const self = realpathLoose(gitRoot);
1120
+ return execFileSync("git", ["worktree", "list", "--porcelain"], {
1121
+ cwd: gitRoot,
1122
+ encoding: "utf8",
1123
+ stdio: ["ignore", "pipe", "ignore"],
1124
+ timeout: GIT_TIMEOUT_MS
1125
+ }).split("\n").flatMap((line) => line.startsWith("worktree ") ? [realpathLoose(line.slice(9))] : []).filter((root) => root !== self);
1126
+ } catch {
1127
+ return [];
1128
+ }
1129
+ }
1130
+ function hookLocation(hookPath, dirs) {
1131
+ const path = realpathLoose(hookPath);
1132
+ if (isWithin(path, realpathLoose(dirs.gitDir)) || isWithin(path, realpathLoose(dirs.commonDir))) {
1133
+ return "repository";
1134
+ }
1135
+ const primDir = primGitHooksDirectory();
1136
+ if (primDir !== void 0 && isWithin(path, realpathLoose(primDir))) return "prim";
1137
+ const root = realpathLoose(dirs.gitRoot);
1138
+ if (!isWithin(path, root)) return "external";
1139
+ const nested = otherWorktreeRoots(dirs.gitRoot).filter((other) => isWithin(other, root));
1140
+ return nested.some((other) => isWithin(path, other)) ? "external" : "worktree";
1141
+ }
1142
+ function hooksPathScope(cwd) {
1143
+ try {
1144
+ const scope = execFileSync("git", ["config", "--show-scope", "--get", "core.hooksPath"], {
1145
+ cwd,
1146
+ encoding: "utf8",
1147
+ stdio: ["ignore", "pipe", "ignore"],
1148
+ timeout: GIT_TIMEOUT_MS
1149
+ }).split(" ")[0];
1150
+ return scope === "local" || scope === "worktree" || scope === "global" || scope === "system" || scope === "command" ? scope : void 0;
1151
+ } catch {
1152
+ return void 0;
1153
+ }
1154
+ }
1155
+ function externalHookRemedy(hookName, cwd) {
1156
+ const snippet = `place \`prim hooks snippet ${hookName}\` there yourself`;
1157
+ switch (hooksPathScope(cwd)) {
1158
+ case "global":
1159
+ return `your global core.hooksPath runs every repository's hooks: ${snippet}, or have prim add it with \`prim hooks install --scope user --global-hooks-path\` (it changes every repository's hooks: an agent must ask the user first)`;
1160
+ case "system":
1161
+ return `the system core.hooksPath is shared by every user on this machine: ${snippet}, or ask its owner`;
1162
+ case "local":
1163
+ case "worktree":
1164
+ return `this repository's core.hooksPath points outside it: ${snippet}, or point core.hooksPath inside the repository`;
1165
+ default:
1166
+ return snippet;
1167
+ }
1168
+ }
905
1169
  function safeGitPath(root, value) {
906
1170
  if (value.length === 0 || value !== value.trim() || value.includes("\0") || value.includes("\n") || value.includes("\r")) {
907
1171
  throw new Error("Git returned an unsafe hooks path");
908
1172
  }
909
- return isAbsolute2(value) ? resolve3(value) : resolve3(root, value);
1173
+ return isAbsolute3(value) ? resolve3(value) : resolve3(root, value);
910
1174
  }
911
- function resolveEffectiveManagedHook(spec, cwd = process.cwd()) {
1175
+ function resolveEffectiveGitHook(hookName, cwd = process.cwd()) {
912
1176
  const gitRoot = gitToplevel(cwd);
913
1177
  if (!gitRoot) throw new Error("not a git repository");
914
- const value = execFileSync("git", ["rev-parse", "--git-path", "hooks"], {
915
- cwd: gitRoot,
916
- encoding: "utf8",
917
- stdio: ["ignore", "pipe", "ignore"],
918
- timeout: GIT_TIMEOUT_MS
919
- }).replace(/\r?\n$/u, "");
920
- const hooksDir = safeGitPath(gitRoot, value);
1178
+ const lines = execFileSync(
1179
+ "git",
1180
+ ["rev-parse", "--git-path", "hooks", "--absolute-git-dir", "--git-common-dir"],
1181
+ {
1182
+ cwd: gitRoot,
1183
+ encoding: "utf8",
1184
+ stdio: ["ignore", "pipe", "ignore"],
1185
+ timeout: GIT_TIMEOUT_MS
1186
+ }
1187
+ ).replace(/\r?\n$/u, "").split(/\r?\n/u);
1188
+ if (lines.length !== 3) throw new Error("Git returned unexpected repository paths");
1189
+ const [hooksValue, gitDirValue, commonDirValue] = lines;
1190
+ const hooksDir = safeGitPath(gitRoot, hooksValue);
1191
+ const dirs = {
1192
+ gitRoot,
1193
+ gitDir: safeGitPath(gitRoot, gitDirValue),
1194
+ commonDir: safeGitPath(gitRoot, commonDirValue)
1195
+ };
921
1196
  if (basename(hooksDir) === "_" && basename(dirname3(hooksDir)) === ".husky") {
1197
+ const hookPath2 = resolve3(dirname3(hooksDir), hookName);
922
1198
  return {
923
1199
  gitRoot,
924
1200
  hooksDir,
925
- hookPath: resolve3(dirname3(hooksDir), spec.hookName),
1201
+ hookPath: hookPath2,
926
1202
  kind: "husky_v9",
927
- dispatcherPath: resolve3(hooksDir, spec.hookName)
1203
+ dispatcherPath: resolve3(hooksDir, hookName),
1204
+ location: hookLocation(hookPath2, dirs)
928
1205
  };
929
1206
  }
1207
+ const hookPath = resolve3(hooksDir, hookName);
930
1208
  return {
931
1209
  gitRoot,
932
1210
  hooksDir,
933
- hookPath: resolve3(hooksDir, spec.hookName),
934
- kind: "direct"
1211
+ hookPath,
1212
+ kind: "direct",
1213
+ location: hookLocation(hookPath, dirs)
935
1214
  };
936
1215
  }
937
- function resolveEffectivePostCommitHook(cwd = process.cwd()) {
938
- return resolveEffectiveManagedHook(POST_COMMIT_MANAGED_HOOK, cwd);
939
- }
940
- function resolveEffectivePostRewriteHook(cwd = process.cwd()) {
941
- return resolveEffectiveManagedHook(POST_REWRITE_MANAGED_HOOK, cwd);
942
- }
943
1216
  function assertSafeFile(path, spec) {
944
1217
  if (!existsSync3(path)) return void 0;
945
1218
  const stat = lstatSync3(path);
@@ -1021,16 +1294,107 @@ function blockRange(content, spec) {
1021
1294
  }
1022
1295
  const start = starts[0];
1023
1296
  const end = ends[0] + endMarker.length;
1024
- const current = Buffer.from(spec.block());
1025
- return content.subarray(start, end).equals(current) ? { kind: "current", start, end } : { kind: "stale", start, end };
1297
+ const text = content.subarray(start, end).toString("utf8");
1298
+ if (canonicalHookBlock(text) === canonicalHookBlock(managedHookBlock(spec.hookName))) {
1299
+ return { kind: "current", start, end };
1300
+ }
1301
+ const version = hookBlockContractVersion(text);
1302
+ if (version !== void 0 && version > GIT_HOOK_CONTRACT_VERSION) {
1303
+ return { kind: "newer", start, end };
1304
+ }
1305
+ return { kind: "stale", start, end };
1306
+ }
1307
+ var HUSKY_V8_SOURCE_RE = /^\.[ \t]+"\$\(dirname(?:[ \t]+--)?[ \t]+"\$0"\)\/_\/husky\.sh"[ \t]*$/mu;
1308
+ function blockInsertionPoint(content, allowShebangless, spec, huskyV8) {
1309
+ const shebangEnd = shellInsertionPoint(content, allowShebangless, spec);
1310
+ if (!huskyV8) return shebangEnd;
1311
+ const rest = content.subarray(shebangEnd).toString("utf8");
1312
+ const match = HUSKY_V8_SOURCE_RE.exec(rest);
1313
+ if (!match) return shebangEnd;
1314
+ const lineEnd = match.index + match[0].length;
1315
+ const afterLine = rest[lineEnd] === "\n" ? lineEnd + 1 : lineEnd;
1316
+ return shebangEnd + Buffer.byteLength(rest.slice(0, afterLine), "utf8");
1317
+ }
1318
+ var HUSKY_SPEC_MAJOR_RE = /^\s*[~^]?=?\s*v?([0-9]+)(?:\.(?:[0-9]+|[xX*])){0,2}(?:-[0-9A-Za-z.-]+)?\s*$/u;
1319
+ function declaredHuskyMajor(dir) {
1320
+ for (let current = resolve3(dir); ; ) {
1321
+ const manifest = join4(current, "package.json");
1322
+ if (existsSync3(manifest)) {
1323
+ try {
1324
+ if (lstatSync3(manifest).size > MAX_HOOK_BYTES) return void 0;
1325
+ const parsed = JSON.parse(readFileSync3(manifest, "utf8"));
1326
+ const range = parsed.devDependencies?.husky ?? parsed.dependencies?.husky;
1327
+ const major = typeof range === "string" ? HUSKY_SPEC_MAJOR_RE.exec(range)?.[1] : void 0;
1328
+ return major === void 0 ? void 0 : Number(major);
1329
+ } catch {
1330
+ return void 0;
1331
+ }
1332
+ }
1333
+ const parent = dirname3(current);
1334
+ if (parent === current || existsSync3(join4(current, ".git"))) return void 0;
1335
+ current = parent;
1336
+ }
1337
+ }
1338
+ function isHuskyV8Hook(hookPath) {
1339
+ const runtime = join4(dirname3(hookPath), "_", "husky.sh");
1340
+ try {
1341
+ const stat = lstatSync3(runtime);
1342
+ return stat.isFile() && stat.size <= MAX_HOOK_BYTES && readFileSync3(runtime, "utf8").includes("husky_skip_init");
1343
+ } catch (error) {
1344
+ if (errorCode(error) !== "ENOENT") return false;
1345
+ }
1346
+ const major = declaredHuskyMajor(dirname3(dirname3(hookPath)));
1347
+ return major !== void 0 && major <= 8;
1348
+ }
1349
+ var TERMINAL_LINE_RE = /^(?:exit|return)(?:\s|;|$)/u;
1350
+ var REDIRECT_OPERATOR_RE = /^[0-9]*(?:<<?|>>?|<&|>&|<>|>\|)$/u;
1351
+ var REDIRECT_WORD_RE = /^[0-9]*(?:<|>)/u;
1352
+ function isReplacingExec(line) {
1353
+ if (!/^exec(?:\s|$)/u.test(line)) return false;
1354
+ const words = line.slice(4).trim().split(/\s+/u).filter(Boolean);
1355
+ for (let index = 0; index < words.length; index += 1) {
1356
+ const word = words[index];
1357
+ if (word.startsWith("#")) break;
1358
+ if (REDIRECT_OPERATOR_RE.test(word)) {
1359
+ index += 1;
1360
+ continue;
1361
+ }
1362
+ if (!REDIRECT_WORD_RE.test(word)) return true;
1363
+ }
1364
+ return false;
1365
+ }
1366
+ function terminatesBefore(content, from, offset) {
1367
+ const lines = content.subarray(from, offset).toString("utf8").split("\n");
1368
+ return lines.some((line) => TERMINAL_LINE_RE.test(line) || isReplacingExec(line));
1369
+ }
1370
+ function mayTerminateBefore(content, from, offset) {
1371
+ return content.subarray(from, offset).toString("utf8").split(/\n|;|&&|\|\||\||[{()]|\b(?:then|do|else)\b/u).some((part) => {
1372
+ const command = part.trim();
1373
+ return TERMINAL_LINE_RE.test(command) || isReplacingExec(command);
1374
+ });
1026
1375
  }
1027
- function mergedContent(existing, allowShebangless, spec) {
1028
- const block = Buffer.from(spec.block());
1029
- if (!existing) {
1376
+ function withoutRange(content, start, end) {
1377
+ const suffix = content.subarray(end);
1378
+ return Buffer.concat([
1379
+ content.subarray(0, start),
1380
+ suffix.at(0) === 10 ? suffix.subarray(1) : suffix
1381
+ ]);
1382
+ }
1383
+ function insertBlock(content, at, spec) {
1384
+ return Buffer.concat([
1385
+ content.subarray(0, at),
1386
+ Buffer.from(managedHookBlock(spec.hookName)),
1387
+ Buffer.from("\n"),
1388
+ content.subarray(at)
1389
+ ]);
1390
+ }
1391
+ function migratedLegacyContent(existing, allowShebangless, spec, huskyV8) {
1392
+ const text = existing.toString("utf8");
1393
+ if (spec.hookName === "pre-commit" && isOwnedStandalonePreCommit(text)) {
1030
1394
  return createdScaffold(spec);
1031
1395
  }
1032
- const range = blockRange(existing, spec);
1033
- if (spec.hookName === POST_COMMIT_MANAGED_HOOK.hookName && range.kind === "absent" && existing.toString("utf8").startsWith(`#!/bin/sh
1396
+ if (spec.hookName !== "post-commit") return void 0;
1397
+ if (text.startsWith(`#!/bin/sh
1034
1398
  ${LEGACY_GLOBAL_OWNED_HEADER}
1035
1399
  `)) {
1036
1400
  const gateAt = existing.indexOf(Buffer.from('if [ "$(git config --get prim.active'));
@@ -1045,15 +1409,13 @@ ${LEGACY_GLOBAL_OWNED_HEADER}
1045
1409
  existing.subarray(0, gateAt),
1046
1410
  existing.subarray(chainAt)
1047
1411
  ]);
1048
- const shebangEnd2 = shellInsertionPoint(withoutLegacyGate, allowShebangless, spec);
1049
- return Buffer.concat([
1050
- withoutLegacyGate.subarray(0, shebangEnd2),
1051
- block,
1052
- Buffer.from("\n"),
1053
- withoutLegacyGate.subarray(shebangEnd2)
1054
- ]);
1412
+ return insertBlock(
1413
+ withoutLegacyGate,
1414
+ blockInsertionPoint(withoutLegacyGate, allowShebangless, spec, huskyV8),
1415
+ spec
1416
+ );
1055
1417
  }
1056
- if (spec.hookName === POST_COMMIT_MANAGED_HOOK.hookName && range.kind === "absent" && existing.toString("utf8").startsWith(`#!/bin/sh
1418
+ if (text.startsWith(`#!/bin/sh
1057
1419
  ${LEGACY_PRIM_OWNED_HEADER}
1058
1420
  `)) {
1059
1421
  const prefixLength = legacyProjectPrefixLength(existing);
@@ -1062,42 +1424,59 @@ ${LEGACY_PRIM_OWNED_HEADER}
1062
1424
  }
1063
1425
  return Buffer.concat([createdScaffold(spec), existing.subarray(prefixLength)]);
1064
1426
  }
1065
- if (range.kind !== "absent") {
1066
- const oldCreatedPrefixes = [
1067
- Buffer.from(`#!/bin/sh
1068
- # ${spec.createdMark}
1069
-
1070
- `),
1071
- ...spec.hookName === POST_COMMIT_MANAGED_HOOK.hookName ? [Buffer.from(`#!/bin/sh
1072
- # ${LEGACY_PRIM_CREATED_MARK}
1073
-
1074
- `)] : []
1075
- ];
1076
- const oldCreated = oldCreatedPrefixes.some(
1427
+ return void 0;
1428
+ }
1429
+ function mergedContent(existing, allowShebangless, spec, relocate, huskyV8) {
1430
+ if (!existing) return { content: createdScaffold(spec), migrates: false };
1431
+ const range = blockRange(existing, spec);
1432
+ if (range.kind === "absent") {
1433
+ const migrated = migratedLegacyContent(existing, allowShebangless, spec, huskyV8);
1434
+ if (migrated) return { content: migrated, migrates: true };
1435
+ } else if (range.kind !== "newer") {
1436
+ const oldCreated = oldCreatedPrefixes(spec).some(
1077
1437
  (prefix) => range.start === prefix.length && existing.subarray(0, prefix.length).equals(prefix)
1078
1438
  );
1079
1439
  if (oldCreated) {
1080
1440
  const suffix = existing.subarray(range.end);
1081
1441
  const tail = suffix.at(0) === 10 ? suffix.subarray(1) : suffix;
1082
- return Buffer.concat([createdScaffold(spec), tail]);
1442
+ return {
1443
+ content: Buffer.concat([createdScaffold(spec), tail]),
1444
+ migrates: range.kind === "stale"
1445
+ };
1083
1446
  }
1084
1447
  }
1085
1448
  const shebangEnd = shellInsertionPoint(existing, allowShebangless, spec);
1086
- if (range.kind === "current" && range.start === shebangEnd) return existing;
1087
- let withoutBlock = existing;
1088
- if (range.kind !== "absent") {
1089
- const suffix = existing.subarray(range.end);
1090
- withoutBlock = Buffer.concat([
1091
- existing.subarray(0, range.start),
1092
- range.start === shebangEnd && suffix.at(0) === 10 ? suffix.subarray(1) : suffix
1093
- ]);
1449
+ const insertion = blockInsertionPoint(existing, allowShebangless, spec, huskyV8);
1450
+ switch (range.kind) {
1451
+ case "newer":
1452
+ return { content: existing, migrates: false };
1453
+ case "absent":
1454
+ return { content: insertBlock(existing, insertion, spec), migrates: false };
1455
+ case "current":
1456
+ case "stale": {
1457
+ const misplaced = terminatesBefore(existing, shebangEnd, range.start) || range.start < insertion;
1458
+ if (relocate && misplaced) {
1459
+ const without = withoutRange(existing, range.start, range.end);
1460
+ return {
1461
+ content: insertBlock(
1462
+ without,
1463
+ blockInsertionPoint(without, allowShebangless, spec, huskyV8),
1464
+ spec
1465
+ ),
1466
+ migrates: range.kind === "stale"
1467
+ };
1468
+ }
1469
+ if (range.kind === "current") return { content: existing, migrates: false };
1470
+ return {
1471
+ content: Buffer.concat([
1472
+ existing.subarray(0, range.start),
1473
+ Buffer.from(managedHookBlock(spec.hookName)),
1474
+ existing.subarray(range.end)
1475
+ ]),
1476
+ migrates: true
1477
+ };
1478
+ }
1094
1479
  }
1095
- return Buffer.concat([
1096
- withoutBlock.subarray(0, shebangEnd),
1097
- block,
1098
- Buffer.from("\n"),
1099
- withoutBlock.subarray(shebangEnd)
1100
- ]);
1101
1480
  }
1102
1481
  function errorCode(error) {
1103
1482
  return typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
@@ -1157,137 +1536,143 @@ function unlinkHookUnchanged(path, expected, stat, spec) {
1157
1536
  assertTargetUnchanged(path, expected, spec, stat);
1158
1537
  unlinkSync2(path);
1159
1538
  }
1160
- function ensureEffectivePostCommitHook(cwd = process.cwd()) {
1161
- const target = resolveEffectivePostCommitHook(cwd);
1162
- return ensureTarget(target, POST_COMMIT_MANAGED_HOOK);
1163
- }
1164
- function ensureEffectivePostRewriteHook(cwd = process.cwd()) {
1165
- const target = resolveEffectivePostRewriteHook(cwd);
1166
- return ensureTarget(target, POST_REWRITE_MANAGED_HOOK);
1539
+ function workingLegacyBlock(content, target, spec) {
1540
+ try {
1541
+ const range = blockRange(content, spec);
1542
+ if (range.kind !== "stale") return false;
1543
+ const block = content.subarray(range.start, range.end).toString("utf8");
1544
+ const from = shellInsertionPoint(content, target.kind === "husky_v9", spec);
1545
+ return isRecognizedLegacyBlock(spec.hookName, block) && !mayTerminateBefore(content, from, range.start);
1546
+ } catch {
1547
+ return false;
1548
+ }
1167
1549
  }
1168
- function ensureTarget(target, spec, initialContent) {
1550
+ var AMBIENT_LOCATIONS = ["repository", "prim"];
1551
+ var EXPLICIT_LOCATIONS = ["repository", "prim", "worktree"];
1552
+ function ensureTarget(target, spec, policy) {
1553
+ const result = { hookName: spec.hookName, path: target.hookPath, kind: target.kind };
1554
+ const skip = (outcome) => ({
1555
+ ...result,
1556
+ changed: false,
1557
+ outcome
1558
+ });
1169
1559
  assertUsableHuskyDispatcher(target, spec);
1170
1560
  const stat = assertSafeFile(target.hookPath, spec);
1171
1561
  if (stat && target.kind === "direct" && (stat.mode & 64) === 0) {
1172
1562
  throw new Error(`existing ${spec.hookName} hook is not executable: ${target.hookPath}`);
1173
1563
  }
1174
1564
  const existing = stat ? readHookFile(target.hookPath) : void 0;
1175
- const next = !existing && initialContent !== void 0 ? Buffer.from(initialContent) : mergedContent(existing, target.kind === "husky_v9", spec);
1176
- if (!existing && initialContent !== void 0) {
1177
- const shebangEnd = shellInsertionPoint(next, target.kind === "husky_v9", spec);
1178
- const range = blockRange(next, spec);
1179
- if (range.kind !== "current" || range.start !== shebangEnd) {
1180
- throw new Error(`invalid initial Prim ${spec.hookName} scaffold`);
1181
- }
1182
- }
1183
- const contentChanged = !existing || !next.equals(existing);
1184
- if (contentChanged) {
1185
- rewriteHookAtomically(target.hookPath, next, existing, spec, stat);
1565
+ if (policy.repairOnly && (!existing || blockRange(existing, spec).kind === "absent")) {
1566
+ return skip("skipped");
1567
+ }
1568
+ const huskyV8 = isHuskyV8Hook(target.hookPath);
1569
+ const next = mergedContent(existing, target.kind === "husky_v9", spec, policy.relocate, huskyV8);
1570
+ if (existing && next.content.equals(existing)) return skip("unchanged");
1571
+ if (!policy.writable(target.location)) {
1572
+ if (target.location === "worktree") return skip("deferred");
1573
+ return skip(existing && workingLegacyBlock(existing, target, spec) ? "kept" : "external");
1574
+ }
1575
+ if (next.migrates && inspectGitHookEntrypoint() !== "ready") return skip("runtime_missing");
1576
+ rewriteHookAtomically(target.hookPath, next.content, existing, spec, stat);
1577
+ return { ...result, changed: true, outcome: existing ? "updated" : "created" };
1578
+ }
1579
+ function ensureEffectiveGitHook(hookName, cwd = process.cwd(), options = {}) {
1580
+ const target = resolveEffectiveGitHook(hookName, cwd);
1581
+ if (gitHooksMode({ cwd: target.gitRoot }) === "manual") {
1582
+ return {
1583
+ hookName,
1584
+ path: target.hookPath,
1585
+ kind: target.kind,
1586
+ changed: false,
1587
+ outcome: "manual"
1588
+ };
1186
1589
  }
1187
- return { path: target.hookPath, changed: contentChanged || !stat, kind: target.kind };
1188
- }
1189
- function ensurePostCommitHookAtPath(hookPath, initialContent) {
1190
- const absolute = resolve3(hookPath);
1191
- const result = ensureTarget(
1192
- {
1193
- gitRoot: dirname3(dirname3(absolute)),
1194
- hooksDir: dirname3(absolute),
1195
- hookPath: absolute,
1196
- kind: "direct"
1197
- },
1198
- POST_COMMIT_MANAGED_HOOK,
1199
- initialContent
1200
- );
1201
- return { ...result, kind: "direct" };
1590
+ const explicit = (options.context ?? "explicit") === "explicit";
1591
+ const allowed = explicit ? EXPLICIT_LOCATIONS : AMBIENT_LOCATIONS;
1592
+ return ensureTarget(target, managedHookSpec(hookName), {
1593
+ writable: (location) => allowed.includes(location),
1594
+ relocate: explicit,
1595
+ repairOnly: options.repairOnly === true
1596
+ });
1202
1597
  }
1203
- function ensurePostRewriteHookAtPath(hookPath, initialContent) {
1598
+ function explicitTarget(hookPath, options) {
1204
1599
  const absolute = resolve3(hookPath);
1205
- const result = ensureTarget(
1206
- {
1207
- gitRoot: dirname3(dirname3(absolute)),
1208
- hooksDir: dirname3(absolute),
1209
- hookPath: absolute,
1210
- kind: "direct"
1211
- },
1212
- POST_REWRITE_MANAGED_HOOK,
1213
- initialContent
1214
- );
1215
- return { ...result, kind: "direct" };
1600
+ return {
1601
+ gitRoot: dirname3(dirname3(absolute)),
1602
+ hooksDir: dirname3(absolute),
1603
+ hookPath: absolute,
1604
+ // A `.husky/<hook>` file is run by Husky's dispatcher via `sh`, so it needs
1605
+ // neither a shebang nor the executable bit.
1606
+ kind: options.husky ? "husky_v9" : "direct",
1607
+ // The caller chose this exact file (a --target, or a consented global dir).
1608
+ location: "external"
1609
+ };
1216
1610
  }
1217
- function inspectEffectivePostCommitHook(cwd = process.cwd()) {
1218
- return inspectEffectiveManagedHook(POST_COMMIT_MANAGED_HOOK, cwd);
1611
+ function hasCurrentHookBlock(hookName, hookPath) {
1612
+ try {
1613
+ const kind = blockRange(readHookFile(hookPath), managedHookSpec(hookName)).kind;
1614
+ return kind === "current" || kind === "newer";
1615
+ } catch {
1616
+ return false;
1617
+ }
1219
1618
  }
1220
- function inspectEffectivePostRewriteHook(cwd = process.cwd()) {
1221
- return inspectEffectiveManagedHook(POST_REWRITE_MANAGED_HOOK, cwd);
1619
+ function ensureGitHookAtPath(hookName, hookPath, options = {}) {
1620
+ return ensureTarget(explicitTarget(hookPath, options), managedHookSpec(hookName), {
1621
+ writable: () => true,
1622
+ relocate: true,
1623
+ repairOnly: false
1624
+ });
1222
1625
  }
1223
- function inspectEffectiveManagedHook(spec, cwd) {
1224
- const target = resolveEffectiveManagedHook(spec, cwd);
1626
+ function inspectEffectiveGitHook(hookName, cwd = process.cwd()) {
1627
+ const spec = managedHookSpec(hookName);
1628
+ const target = resolveEffectiveGitHook(hookName, cwd);
1629
+ const base = {
1630
+ ...target,
1631
+ hookName,
1632
+ mode: gitHooksMode({ cwd: target.gitRoot }),
1633
+ entrypoint: inspectGitHookEntrypoint()
1634
+ };
1635
+ const uncovered = (reason2, executable2 = false) => ({ ...base, covered: false, executable: executable2, current: false, reason: reason2 });
1225
1636
  try {
1226
1637
  assertUsableHuskyDispatcher(target, spec);
1227
1638
  } catch (error) {
1228
1639
  const missing = error instanceof Error && (error.message.includes("missing or not executable") || error.message.includes("unsafe"));
1229
- return {
1230
- ...target,
1231
- covered: false,
1232
- executable: false,
1233
- current: false,
1234
- reason: missing ? "husky_dispatcher_missing" : "husky_dispatcher_invalid"
1235
- };
1640
+ return uncovered(missing ? "husky_dispatcher_missing" : "husky_dispatcher_invalid");
1236
1641
  }
1237
1642
  let stat;
1238
1643
  try {
1239
1644
  stat = assertSafeFile(target.hookPath, spec);
1240
1645
  } catch {
1241
- return {
1242
- ...target,
1243
- covered: false,
1244
- executable: false,
1245
- current: false,
1246
- reason: "unsafe_target"
1247
- };
1248
- }
1249
- if (!stat) {
1250
- return {
1251
- ...target,
1252
- covered: false,
1253
- executable: false,
1254
- current: false,
1255
- reason: "missing"
1256
- };
1646
+ return uncovered("unsafe_target");
1257
1647
  }
1648
+ if (!stat) return uncovered("missing");
1258
1649
  const executable = target.kind === "husky_v9" || (stat.mode & 64) !== 0;
1650
+ let range;
1651
+ let content;
1652
+ let insertion;
1259
1653
  try {
1260
- const content = readHookFile(target.hookPath);
1261
- const shebangEnd = shellInsertionPoint(content, target.kind === "husky_v9", spec);
1262
- const range = blockRange(content, spec);
1263
- const positioned = range.kind !== "absent" && range.start === shebangEnd;
1264
- const current = range.kind === "current" && positioned;
1265
- return {
1266
- ...target,
1267
- covered: current && executable,
1268
- executable,
1269
- current,
1270
- reason: range.kind === "current" && !positioned ? "unreachable_block" : !current ? "stale_block" : executable ? void 0 : "not_executable"
1271
- };
1654
+ content = readHookFile(target.hookPath);
1655
+ insertion = shellInsertionPoint(content, target.kind === "husky_v9", spec);
1656
+ range = blockRange(content, spec);
1272
1657
  } catch (error) {
1273
1658
  const message = error instanceof Error ? error.message : "";
1274
- const reason = message.includes("binary") ? "binary" : message.includes("unsupported") ? "unsupported_interpreter" : "malformed_markers";
1275
- return {
1276
- ...target,
1277
- covered: false,
1278
- executable,
1279
- current: false,
1280
- reason
1281
- };
1659
+ return uncovered(
1660
+ message.includes("binary") ? "binary" : message.includes("unsupported") ? "unsupported_interpreter" : "malformed_markers",
1661
+ executable
1662
+ );
1282
1663
  }
1283
- }
1284
- function uninstallEffectivePostCommitHook(cwd = process.cwd()) {
1285
- const target = resolveEffectivePostCommitHook(cwd);
1286
- return uninstallTarget(target, POST_COMMIT_MANAGED_HOOK);
1287
- }
1288
- function uninstallEffectivePostRewriteHook(cwd = process.cwd()) {
1289
- const target = resolveEffectivePostRewriteHook(cwd);
1290
- return uninstallTarget(target, POST_REWRITE_MANAGED_HOOK);
1664
+ const huskyV8 = isHuskyV8Hook(target.hookPath);
1665
+ const preferred = blockInsertionPoint(content, target.kind === "husky_v9", spec, huskyV8);
1666
+ const current = range.kind === "current" || range.kind === "newer";
1667
+ const legacy = range.kind === "stale" && workingLegacyBlock(content, target, spec);
1668
+ const reason = range.kind === "absent" ? "missing_block" : range.kind === "stale" && !legacy ? "stale_block" : terminatesBefore(content, insertion, range.start) ? "unreachable_block" : !executable ? "not_executable" : legacy ? "legacy_block" : base.entrypoint !== "ready" ? "entrypoint_missing" : range.start < preferred ? "misplaced_block" : void 0;
1669
+ return {
1670
+ ...base,
1671
+ covered: reason === void 0,
1672
+ executable,
1673
+ current,
1674
+ ...reason ? { reason } : {}
1675
+ };
1291
1676
  }
1292
1677
  function projectHooksPathIsConfigured(gitRoot) {
1293
1678
  for (const scope of ["--worktree", "--local"]) {
@@ -1304,45 +1689,53 @@ function projectHooksPathIsConfigured(gitRoot) {
1304
1689
  }
1305
1690
  return false;
1306
1691
  }
1307
- function uninstallProjectPostCommitHook(cwd = process.cwd()) {
1308
- const gitRoot = gitToplevel(cwd);
1309
- if (!gitRoot) throw new Error("not a git repository");
1310
- if (projectHooksPathIsConfigured(gitRoot)) {
1311
- return uninstallEffectivePostCommitHook(gitRoot);
1692
+ function uninstallProjectGitHook(hookName, cwd = process.cwd()) {
1693
+ const target = projectGitHookTarget(hookName, cwd);
1694
+ if (target.location === "external") {
1695
+ return { path: target.hookPath, changed: false, removedFile: false, skipped: "external" };
1312
1696
  }
1313
- const commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
1314
- cwd: gitRoot,
1315
- encoding: "utf8",
1316
- stdio: ["ignore", "pipe", "ignore"],
1317
- timeout: GIT_TIMEOUT_MS
1318
- }).replace(/\r?\n$/u, "");
1319
- return uninstallPostCommitHookAtPath(
1320
- resolve3(safeGitPath(gitRoot, commonDir), "hooks", "post-commit")
1321
- );
1697
+ return uninstallTarget(target, managedHookSpec(hookName));
1322
1698
  }
1323
- function uninstallProjectPostRewriteHook(cwd = process.cwd()) {
1699
+ function projectGitHookTarget(hookName, cwd = process.cwd()) {
1324
1700
  const gitRoot = gitToplevel(cwd);
1325
1701
  if (!gitRoot) throw new Error("not a git repository");
1326
- if (projectHooksPathIsConfigured(gitRoot)) {
1327
- return uninstallEffectivePostRewriteHook(gitRoot);
1328
- }
1702
+ if (projectHooksPathIsConfigured(gitRoot)) return resolveEffectiveGitHook(hookName, gitRoot);
1703
+ return {
1704
+ ...explicitTarget(resolve3(projectHooksDir(gitRoot), hookName), {}),
1705
+ gitRoot,
1706
+ location: "repository"
1707
+ };
1708
+ }
1709
+ function projectHooksDir(gitRoot) {
1329
1710
  const commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
1330
1711
  cwd: gitRoot,
1331
1712
  encoding: "utf8",
1332
1713
  stdio: ["ignore", "pipe", "ignore"],
1333
1714
  timeout: GIT_TIMEOUT_MS
1334
1715
  }).replace(/\r?\n$/u, "");
1335
- return uninstallPostRewriteHookAtPath(
1336
- resolve3(safeGitPath(gitRoot, commonDir), "hooks", POST_REWRITE_MANAGED_HOOK.hookName)
1337
- );
1716
+ return resolve3(safeGitPath(gitRoot, commonDir), "hooks");
1717
+ }
1718
+ function uninstallGitHookAtPath(hookName, hookPath, options = {}) {
1719
+ return uninstallTarget(explicitTarget(hookPath, options), managedHookSpec(hookName));
1338
1720
  }
1339
1721
  function uninstallTarget(target, spec) {
1722
+ const unchanged = { path: target.hookPath, changed: false, removedFile: false };
1340
1723
  const stat = assertSafeFile(target.hookPath, spec);
1341
- if (!stat) return { path: target.hookPath, changed: false, removedFile: false };
1724
+ if (!stat) return unchanged;
1342
1725
  const existing = readHookFile(target.hookPath);
1726
+ const remove = () => {
1727
+ unlinkHookUnchanged(target.hookPath, existing, stat, spec);
1728
+ return { path: target.hookPath, changed: true, removedFile: true };
1729
+ };
1730
+ const rewrite = (next) => {
1731
+ rewriteHookAtomically(target.hookPath, next, existing, spec, stat);
1732
+ return { path: target.hookPath, changed: true, removedFile: false };
1733
+ };
1343
1734
  const range = blockRange(existing, spec);
1344
1735
  if (range.kind === "absent") {
1345
- if (spec.hookName === POST_COMMIT_MANAGED_HOOK.hookName && existing.toString("utf8").startsWith(`#!/bin/sh
1736
+ const text = existing.toString("utf8");
1737
+ if (spec.hookName === "pre-commit" && isOwnedStandalonePreCommit(text)) return remove();
1738
+ if (spec.hookName === "post-commit" && text.startsWith(`#!/bin/sh
1346
1739
  ${LEGACY_PRIM_OWNED_HEADER}
1347
1740
  `)) {
1348
1741
  const prefixLength = legacyProjectPrefixLength(existing);
@@ -1350,20 +1743,9 @@ ${LEGACY_PRIM_OWNED_HEADER}
1350
1743
  throw new Error("unrecognized legacy Prim post-commit invocation");
1351
1744
  }
1352
1745
  const tail = existing.subarray(prefixLength);
1353
- if (tail.length === 0) {
1354
- unlinkHookUnchanged(target.hookPath, existing, stat, spec);
1355
- return { path: target.hookPath, changed: true, removedFile: true };
1356
- }
1357
- rewriteHookAtomically(
1358
- target.hookPath,
1359
- Buffer.concat([Buffer.from("#!/bin/sh\n"), tail]),
1360
- existing,
1361
- spec,
1362
- stat
1363
- );
1364
- return { path: target.hookPath, changed: true, removedFile: false };
1746
+ return tail.length === 0 ? remove() : rewrite(Buffer.concat([Buffer.from("#!/bin/sh\n"), tail]));
1365
1747
  }
1366
- return { path: target.hookPath, changed: false, removedFile: false };
1748
+ return unchanged;
1367
1749
  }
1368
1750
  const shebangEnd = shellInsertionPoint(existing, target.kind === "husky_v9", spec);
1369
1751
  const suffix = existing.subarray(range.end);
@@ -1372,77 +1754,16 @@ ${LEGACY_PRIM_OWNED_HEADER}
1372
1754
  `);
1373
1755
  if (range.start === shebangEnd && suffix.subarray(0, createdSuffix.length).equals(createdSuffix)) {
1374
1756
  const tail = suffix.subarray(createdSuffix.length);
1375
- if (tail.length === 0) {
1376
- unlinkHookUnchanged(target.hookPath, existing, stat, spec);
1377
- return { path: target.hookPath, changed: true, removedFile: true };
1378
- }
1379
- rewriteHookAtomically(
1380
- target.hookPath,
1381
- Buffer.concat([existing.subarray(0, shebangEnd), tail]),
1382
- existing,
1383
- spec,
1384
- stat
1385
- );
1386
- return { path: target.hookPath, changed: true, removedFile: false };
1757
+ return tail.length === 0 ? remove() : rewrite(Buffer.concat([existing.subarray(0, shebangEnd), tail]));
1387
1758
  }
1388
- const createdPrefixes = [
1389
- Buffer.from(`#!/bin/sh
1390
- # ${spec.createdMark}
1391
-
1392
- `),
1393
- ...spec.hookName === POST_COMMIT_MANAGED_HOOK.hookName ? [Buffer.from(`#!/bin/sh
1394
- # ${LEGACY_PRIM_CREATED_MARK}
1395
-
1396
- `)] : []
1397
- ];
1398
- const createdPrefix = createdPrefixes.find(
1759
+ const createdPrefix = oldCreatedPrefixes(spec).find(
1399
1760
  (prefix) => range.start === prefix.length && existing.subarray(0, prefix.length).equals(prefix)
1400
1761
  );
1401
1762
  if (createdPrefix) {
1402
1763
  const tail = suffix.at(0) === 10 ? suffix.subarray(1) : suffix;
1403
- if (tail.length === 0) {
1404
- unlinkHookUnchanged(target.hookPath, existing, stat, spec);
1405
- return { path: target.hookPath, changed: true, removedFile: true };
1406
- }
1407
- rewriteHookAtomically(
1408
- target.hookPath,
1409
- Buffer.concat([Buffer.from("#!/bin/sh\n"), tail]),
1410
- existing,
1411
- spec,
1412
- stat
1413
- );
1414
- return { path: target.hookPath, changed: true, removedFile: false };
1764
+ return tail.length === 0 ? remove() : rewrite(Buffer.concat([Buffer.from("#!/bin/sh\n"), tail]));
1415
1765
  }
1416
- const next = Buffer.concat([
1417
- existing.subarray(0, range.start),
1418
- range.start === shebangEnd && suffix.at(0) === 10 ? suffix.subarray(1) : suffix
1419
- ]);
1420
- rewriteHookAtomically(target.hookPath, next, existing, spec, stat);
1421
- return { path: target.hookPath, changed: true, removedFile: false };
1422
- }
1423
- function uninstallPostCommitHookAtPath(hookPath) {
1424
- const absolute = resolve3(hookPath);
1425
- return uninstallTarget(
1426
- {
1427
- gitRoot: dirname3(dirname3(absolute)),
1428
- hooksDir: dirname3(absolute),
1429
- hookPath: absolute,
1430
- kind: "direct"
1431
- },
1432
- POST_COMMIT_MANAGED_HOOK
1433
- );
1434
- }
1435
- function uninstallPostRewriteHookAtPath(hookPath) {
1436
- const absolute = resolve3(hookPath);
1437
- return uninstallTarget(
1438
- {
1439
- gitRoot: dirname3(dirname3(absolute)),
1440
- hooksDir: dirname3(absolute),
1441
- hookPath: absolute,
1442
- kind: "direct"
1443
- },
1444
- POST_REWRITE_MANAGED_HOOK
1445
- );
1766
+ return rewrite(withoutRange(existing, range.start, range.end));
1446
1767
  }
1447
1768
 
1448
1769
  // src/lib/repository-binding.ts
@@ -1504,20 +1825,49 @@ async function bindRepository(root, options) {
1504
1825
  return bindRepositoryWithClient(root, getClient(), options);
1505
1826
  }
1506
1827
 
1828
+ // src/daemon/self-heal.ts
1829
+ import { spawn } from "child_process";
1830
+ function kickDaemonEnsure(options = {}) {
1831
+ const primEntry = options.primEntry === void 0 ? binFile("prim") : options.primEntry;
1832
+ if (!primEntry) {
1833
+ return false;
1834
+ }
1835
+ try {
1836
+ const args = [primEntry, "daemon", "ensure"];
1837
+ if (options.latestBootstrap ?? (options.platform ?? process.platform) === "darwin") {
1838
+ args.push("--latest-bootstrap");
1839
+ }
1840
+ const child = (options.spawnProcess ?? spawn)(
1841
+ options.nodeEntry ?? process.execPath,
1842
+ args,
1843
+ // Unattended: neither SessionStart's repair nor a drift heal is a person
1844
+ // running prim, so the child (and its descendants) never heal in turn.
1845
+ { detached: true, stdio: "ignore", env: unattendedEnv() }
1846
+ );
1847
+ child.once("error", () => {
1848
+ });
1849
+ child.unref();
1850
+ return true;
1851
+ } catch {
1852
+ return false;
1853
+ }
1854
+ }
1855
+
1507
1856
  export {
1508
1857
  warmBinCache,
1509
- postCommitHookBlock,
1510
- postRewriteHookBlock,
1511
- ensureEffectivePostCommitHook,
1512
- ensureEffectivePostRewriteHook,
1513
- ensurePostCommitHookAtPath,
1514
- ensurePostRewriteHookAtPath,
1515
- inspectEffectivePostCommitHook,
1516
- inspectEffectivePostRewriteHook,
1517
- uninstallProjectPostCommitHook,
1518
- uninstallProjectPostRewriteHook,
1519
- uninstallPostCommitHookAtPath,
1520
- uninstallPostRewriteHookAtPath,
1858
+ isLegacyOwnedGlobalHook,
1859
+ gitHooksMode,
1860
+ isOwnedStandalonePreCommit,
1861
+ externalHookRemedy,
1862
+ resolveEffectiveGitHook,
1863
+ ensureEffectiveGitHook,
1864
+ hasCurrentHookBlock,
1865
+ ensureGitHookAtPath,
1866
+ inspectEffectiveGitHook,
1867
+ uninstallProjectGitHook,
1868
+ projectGitHookTarget,
1869
+ projectHooksDir,
1870
+ uninstallGitHookAtPath,
1521
1871
  resolveRepositoryBinding,
1522
1872
  bindRepositoryWithClient,
1523
1873
  bindRepository,
@@ -1525,5 +1875,6 @@ export {
1525
1875
  refreshClaudePlugins,
1526
1876
  hasUsableCodexGuidance,
1527
1877
  hasUsableCursorSkill,
1528
- registerSkillCommands
1878
+ registerSkillCommands,
1879
+ kickDaemonEnsure
1529
1880
  };