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

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 (41) hide show
  1. package/README.md +57 -12
  2. package/SKILL.md +5 -2
  3. package/dist/{chunk-UKUAAAXE.js → chunk-27ADT6FQ.js} +2 -2
  4. package/dist/{chunk-OTHKG76J.js → chunk-35FMRWJH.js} +1 -1
  5. package/dist/{chunk-DXJQR4QI.js → chunk-4GHANPWQ.js} +2 -2
  6. package/dist/{chunk-DCJN6LZH.js → chunk-7KABYYWG.js} +747 -430
  7. package/dist/{chunk-LFFLHSQO.js → chunk-CU7RW7XU.js} +63 -6
  8. package/dist/{chunk-H4UNYFUC.js → chunk-DHOQGHKQ.js} +184 -1
  9. package/dist/{chunk-2GP5UFV7.js → chunk-DWRSEC34.js} +5 -3
  10. package/dist/{chunk-36TWVNBE.js → chunk-FVDBULGZ.js} +9 -11
  11. package/dist/{chunk-G2VMA2QO.js → chunk-LY6NJ7KL.js} +6 -4
  12. package/dist/{chunk-ZB4F75SZ.js → chunk-MXVCP33E.js} +5 -1
  13. package/dist/{chunk-TYAHC25F.js → chunk-QCBB5HFX.js} +1 -1
  14. package/dist/{chunk-C3VAA5CG.js → chunk-RK4ZS3HG.js} +2 -2
  15. package/dist/{chunk-ZU563PQI.js → chunk-ROLYLXJS.js} +2 -2
  16. package/dist/{chunk-OH3EZXYX.js → chunk-UO2U6IKF.js} +1 -1
  17. package/dist/{chunk-K7D2EYBS.js → chunk-URLLSALP.js} +2 -2
  18. package/dist/chunk-XEDJ5N3M.js +177 -0
  19. package/dist/{chunk-X64HTHUF.js → chunk-Y5W4TZM7.js} +202 -10
  20. package/dist/{chunk-ICM2XCXS.js → chunk-YYE2NLWX.js} +2 -2
  21. package/dist/{chunk-DSWT6BCC.js → chunk-Z6NPNLOT.js} +825 -665
  22. package/dist/daemon/server.js +836 -540
  23. package/dist/hook-runtime/daemon/server.js +836 -540
  24. package/dist/hook-runtime/hooks/post-commit.js +978 -566
  25. package/dist/hook-runtime/hooks/post-rewrite.js +836 -540
  26. package/dist/hook-runtime/hooks/post-tool-use.js +1215 -796
  27. package/dist/hook-runtime/hooks/pre-tool-use.js +2100 -1741
  28. package/dist/hook-runtime/hooks/prim-hook.js +1312 -861
  29. package/dist/hook-runtime/hooks/session-end.js +232 -134
  30. package/dist/hook-runtime/hooks/session-start.js +1799 -935
  31. package/dist/hook-runtime/index.js +3628 -2461
  32. package/dist/hooks/post-commit.js +14 -10
  33. package/dist/hooks/post-rewrite.js +10 -10
  34. package/dist/hooks/post-tool-use.js +20 -20
  35. package/dist/hooks/pre-commit.js +4 -4
  36. package/dist/hooks/pre-tool-use.js +29 -17
  37. package/dist/hooks/prim-hook.js +22 -18
  38. package/dist/hooks/session-end.js +11 -5
  39. package/dist/hooks/session-start.js +46 -28
  40. package/dist/index.js +623 -390
  41. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -2,34 +2,42 @@
2
2
  import {
3
3
  bindRepository,
4
4
  bindRepositoryWithClient,
5
- ensureEffectivePostCommitHook,
6
- ensureEffectivePostRewriteHook,
7
- ensurePostCommitHookAtPath,
8
- ensurePostRewriteHookAtPath,
9
- inspectEffectivePostCommitHook,
10
- inspectEffectivePostRewriteHook,
5
+ ensureEffectiveGitHook,
6
+ ensureGitHookAtPath,
7
+ externalHookRemedy,
8
+ gitHooksMode,
9
+ hasCurrentHookBlock,
10
+ inspectEffectiveGitHook,
11
+ isLegacyOwnedGlobalHook,
12
+ isOwnedStandalonePreCommit,
11
13
  kickDaemonEnsure,
12
- postCommitHookBlock,
13
- postRewriteHookBlock,
14
14
  printJson,
15
+ projectGitHookTarget,
16
+ projectHooksDir,
15
17
  registerSkillCommands,
18
+ resolveEffectiveGitHook,
16
19
  resolveRepositoryBinding,
17
- uninstallPostCommitHookAtPath,
18
- uninstallPostRewriteHookAtPath,
19
- uninstallProjectPostCommitHook,
20
- uninstallProjectPostRewriteHook
21
- } from "./chunk-DCJN6LZH.js";
20
+ uninstallGitHookAtPath,
21
+ uninstallProjectGitHook
22
+ } from "./chunk-7KABYYWG.js";
22
23
  import {
23
24
  requireDurableIngestAcknowledgement
24
- } from "./chunk-TYAHC25F.js";
25
+ } from "./chunk-QCBB5HFX.js";
25
26
  import {
26
27
  checkAffectedDecisions,
27
28
  daemonOrDirectGet,
28
29
  formatDecisionsWarning
29
- } from "./chunk-UKUAAAXE.js";
30
+ } from "./chunk-27ADT6FQ.js";
30
31
  import {
31
32
  fetchFeedbackCapability
32
- } from "./chunk-OH3EZXYX.js";
33
+ } from "./chunk-UO2U6IKF.js";
34
+ import {
35
+ POST_COMMIT_GRACE_MS,
36
+ clearHooksWired,
37
+ inspectPostCommitFiring,
38
+ lastConversationCaptured,
39
+ recordHooksWired
40
+ } from "./chunk-XEDJ5N3M.js";
33
41
  import {
34
42
  isUnattended,
35
43
  unattendedEnv
@@ -48,7 +56,7 @@ import {
48
56
  rotationIdentity,
49
57
  sweepOrphanedDrainProgress,
50
58
  writeDrainCheckpoint
51
- } from "./chunk-DXJQR4QI.js";
59
+ } from "./chunk-4GHANPWQ.js";
52
60
  import {
53
61
  MS_PER_SECOND,
54
62
  deliveryBacklogState,
@@ -57,13 +65,12 @@ import {
57
65
  formatStatusline,
58
66
  formatTeammates,
59
67
  repositoryBindingDiagnosticLabel
60
- } from "./chunk-ZU563PQI.js";
68
+ } from "./chunk-ROLYLXJS.js";
61
69
  import {
62
70
  bold,
63
71
  boundedHealthError,
64
72
  color,
65
73
  colorForArea,
66
- decisionIngestionStatus,
67
74
  dim,
68
75
  fetchAndCacheCollectScope,
69
76
  isCliErrorResponse,
@@ -76,13 +83,8 @@ import {
76
83
  isGitHubInstallIntentStartResponse,
77
84
  isGitHubInstallIntentStatusResponse,
78
85
  isMoveIngestRequest,
79
- isRepoActiveForCapture,
80
- isValidRepoSyncId,
81
- repoSyncId,
82
- repositoryBindingState,
83
- setRepoActive,
84
86
  stripAnsi
85
- } from "./chunk-DSWT6BCC.js";
87
+ } from "./chunk-Z6NPNLOT.js";
86
88
  import {
87
89
  CliManagementResponseError,
88
90
  HttpError,
@@ -99,8 +101,9 @@ import {
99
101
  setStoredToken,
100
102
  stripControlChars,
101
103
  terminalSafeLine
102
- } from "./chunk-G2VMA2QO.js";
104
+ } from "./chunk-LY6NJ7KL.js";
103
105
  import {
106
+ MANAGED_GIT_HOOK_NAMES,
104
107
  assertOwnedHookRuntime,
105
108
  atomicWrite,
106
109
  binFile,
@@ -110,15 +113,16 @@ import {
110
113
  entryHasCommand,
111
114
  hookCommandResolutions,
112
115
  hookRuntimePaths,
116
+ inspectGitHookEntrypoint,
113
117
  inspectHookRuntime,
114
118
  inspectHookRuntimeResolutions,
115
119
  inspectHookRuntimeResolutions2,
116
120
  makeRegistration,
121
+ managedHookBlock,
117
122
  packageRoot,
118
123
  packageVersion,
119
124
  performStatus,
120
125
  performStatus2,
121
- pinnedHookCommand,
122
126
  pinnedNpxArgs,
123
127
  projectRoot,
124
128
  readSettings,
@@ -129,16 +133,24 @@ import {
129
133
  stableNodePath,
130
134
  stageHookRuntime,
131
135
  stripCommand
132
- } from "./chunk-X64HTHUF.js";
136
+ } from "./chunk-Y5W4TZM7.js";
133
137
  import {
134
138
  canonicalGitRoot,
135
139
  canonicalRepositoryPath,
136
- gitToplevel
137
- } from "./chunk-H4UNYFUC.js";
140
+ decisionIngestionStatus,
141
+ gitToplevel,
142
+ isRepoActiveForCapture,
143
+ isValidRepoSyncId,
144
+ mainWorktreeRoot,
145
+ repoActiveFlag,
146
+ repoSyncId,
147
+ repositoryBindingState,
148
+ setRepoActive
149
+ } from "./chunk-DHOQGHKQ.js";
138
150
  import {
139
151
  daemonIsLive,
140
152
  daemonRequest
141
- } from "./chunk-OTHKG76J.js";
153
+ } from "./chunk-35FMRWJH.js";
142
154
  import {
143
155
  REFRESH_TOKEN_PATH,
144
156
  TOKEN_FILE_PATH,
@@ -153,11 +165,11 @@ import {
153
165
  syncDirectory,
154
166
  withFileLock,
155
167
  withFileLockSync
156
- } from "./chunk-2GP5UFV7.js";
168
+ } from "./chunk-DWRSEC34.js";
157
169
 
158
170
  // src/index.ts
159
171
  import { readFileSync as readFileSync10 } from "fs";
160
- import { dirname as dirname7, resolve as resolve4 } from "path";
172
+ import { dirname as dirname6, resolve as resolve4 } from "path";
161
173
  import { fileURLToPath as fileURLToPath2 } from "url";
162
174
  import { Command } from "commander";
163
175
  import updateNotifier from "update-notifier";
@@ -1352,8 +1364,8 @@ import {
1352
1364
  writeFileSync
1353
1365
  } from "fs";
1354
1366
  import { homedir } from "os";
1355
- import { dirname, join, resolve } from "path";
1356
- import { Option } from "commander";
1367
+ import { join, resolve } from "path";
1368
+ import { Argument, Option } from "commander";
1357
1369
  var PRE_COMMIT = { hookName: "pre-commit", binName: "prim-pre-commit" };
1358
1370
  var POST_COMMIT = { hookName: "post-commit", binName: "prim-post-commit" };
1359
1371
  var POST_REWRITE = { hookName: "post-rewrite", binName: "prim-post-rewrite" };
@@ -1369,84 +1381,15 @@ var PRIM_BLOCK_START = blockMarkers(PRE_COMMIT).start;
1369
1381
  var PRIM_BLOCK_END = blockMarkers(PRE_COMMIT).end;
1370
1382
  var PRIM_MANAGED_MARK = "prim-managed-hook";
1371
1383
  var PRIM_CREATED_MARK = "prim-created-hook";
1372
- function hookShim(binName) {
1373
- return `{ ${pinnedHookCommand(binName)}; } || true`;
1374
- }
1375
- function gatedShim(binName) {
1376
- return `if [ "$(git config --get prim.active 2>/dev/null)" = "true" ]; then
1377
- ${hookShim(binName)}
1378
- fi`;
1379
- }
1380
- function dotGitScript(spec) {
1381
- return `#!/bin/sh
1382
- # prim ${spec.hookName} hook \u2014 installed by: prim hooks install (${PRIM_MANAGED_MARK})
1383
-
1384
- ${hookShim(spec.binName)}
1385
- `;
1386
- }
1387
- function isOwnedStandalonePreCommit(content) {
1388
- if (content === "#!/bin/sh\nprim-pre-commit\n") return true;
1389
- const prefix = `#!/bin/sh
1390
- # prim pre-commit hook \u2014 installed by: prim hooks install (${PRIM_MANAGED_MARK})
1391
-
1392
- `;
1393
- const suffix = "; } || true\n";
1394
- if (!content.startsWith(prefix) || !content.endsWith(suffix)) return false;
1395
- const body = content.slice(prefix.length, -suffix.length);
1396
- if (!body.startsWith("{ ") || /[\r\n]/u.test(body)) return false;
1397
- return commandMatchesBin(body.slice(2), PRE_COMMIT.binName);
1398
- }
1399
- function huskyBlock(spec) {
1400
- if (spec.hookName === POST_COMMIT.hookName) return postCommitHookBlock();
1401
- if (spec.hookName === POST_REWRITE.hookName) return postRewriteHookBlock();
1402
- const { start, end } = blockMarkers(spec);
1403
- return `${start}
1404
- ${hookShim(spec.binName)}
1405
- ${end}`;
1406
- }
1407
- function gatedBlock(spec) {
1408
- if (spec.hookName === POST_COMMIT.hookName) return postCommitHookBlock();
1409
- if (spec.hookName === POST_REWRITE.hookName) return postRewriteHookBlock();
1410
- const { start, end } = blockMarkers(spec);
1411
- return `${start}
1412
- ${gatedShim(spec.binName)}
1413
- ${end}`;
1414
- }
1415
- function primBlockRange(existing, spec) {
1416
- const { start, end } = blockMarkers(spec);
1417
- const starts = existing.split(start).length - 1;
1418
- const ends = existing.split(end).length - 1;
1419
- if (starts === 0 && ends === 0) return null;
1420
- const from = existing.indexOf(start);
1421
- const endAt = existing.indexOf(end);
1422
- if (starts !== 1 || ends !== 1 || endAt < from) {
1423
- throw new Error(`malformed Prim hook markers for ${spec.hookName}`);
1424
- }
1425
- return { start: from, through: endAt + end.length };
1426
- }
1427
- function mergePrimBlock(hookPath, block, spec) {
1428
- if (existsSync2(hookPath)) {
1429
- const existing = readFileSync2(hookPath, "utf-8");
1430
- const range = primBlockRange(existing, spec);
1431
- if (range) {
1432
- const refreshed = existing.slice(0, range.start) + block + existing.slice(range.through);
1433
- if (refreshed === existing) return false;
1434
- writeFileSync(hookPath, refreshed, { mode: 493 });
1435
- return true;
1436
- }
1437
- if (containsPrimHook(existing, spec.binName)) return false;
1438
- const separator = existing.endsWith("\n") ? "\n" : "\n\n";
1439
- writeFileSync(hookPath, `${existing}${separator}${block}
1440
- `, { mode: 493 });
1384
+ function stageGitHookRuntime() {
1385
+ try {
1386
+ stageHookRuntime();
1441
1387
  return true;
1388
+ } catch (error) {
1389
+ const detail = error instanceof Error ? error.message : String(error);
1390
+ console.error(`[prim] git hooks stay inert until the hook runtime is staged: ${detail}`);
1391
+ return false;
1442
1392
  }
1443
- mkdirSync(dirname(hookPath), { recursive: true });
1444
- writeFileSync(hookPath, `#!/bin/sh
1445
- # ${PRIM_CREATED_MARK}
1446
-
1447
- ${block}
1448
- `, { mode: 493 });
1449
- return true;
1450
1393
  }
1451
1394
  function getGitRoot() {
1452
1395
  const root = gitToplevel();
@@ -1473,52 +1416,6 @@ function detectHusky(gitRoot) {
1473
1416
  }
1474
1417
  return false;
1475
1418
  }
1476
- function containsPrimHook(content, binName = PRE_COMMIT.binName) {
1477
- return content.includes(binName);
1478
- }
1479
- function installToHusky(gitRoot, spec = PRE_COMMIT) {
1480
- const hookPath = resolve(gitRoot, ".husky", spec.hookName);
1481
- const existed = existsSync2(hookPath);
1482
- const wrote = mergePrimBlock(hookPath, huskyBlock(spec), spec);
1483
- if (!wrote) {
1484
- console.log(`Prim ${spec.hookName} hook is already installed in .husky/${spec.hookName}.`);
1485
- } else if (existed) {
1486
- console.log(`Appended prim hook block to .husky/${spec.hookName}.`);
1487
- } else {
1488
- console.log(`Created .husky/${spec.hookName} with prim hook block.`);
1489
- }
1490
- }
1491
- function installToDotGit(gitRoot, spec = PRE_COMMIT) {
1492
- const hooksDir = projectHooksDir(gitRoot);
1493
- const hookPath = resolve(hooksDir, spec.hookName);
1494
- if (!existsSync2(hooksDir)) {
1495
- mkdirSync(hooksDir, { recursive: true });
1496
- }
1497
- if (existsSync2(hookPath)) {
1498
- const existing = readFileSync2(hookPath, "utf-8");
1499
- if (containsPrimHook(existing, spec.binName)) {
1500
- console.log(`Prim ${spec.hookName} hook is already installed at ${hookPath}.`);
1501
- return;
1502
- }
1503
- console.log(`A ${spec.hookName} hook already exists at ${hookPath}.`);
1504
- console.log("To replace it, run: prim hooks uninstall && prim hooks install");
1505
- return;
1506
- }
1507
- writeFileSync(hookPath, dotGitScript(spec), { mode: 493 });
1508
- console.log(`Installed ${spec.hookName} hook at ${hookPath}`);
1509
- }
1510
- function projectHooksDir(gitRoot) {
1511
- const commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
1512
- cwd: gitRoot,
1513
- encoding: "utf-8",
1514
- stdio: ["ignore", "pipe", "ignore"],
1515
- timeout: GIT_TIMEOUT_MS
1516
- }).trim();
1517
- if (commonDir === "") {
1518
- throw new Error("git returned an empty common directory");
1519
- }
1520
- return resolve(gitRoot, commonDir, "hooks");
1521
- }
1522
1419
  var PRIM_GIT_HOOKS_DIR = join(primConfigDirectory(), "git-hooks");
1523
1420
  function expandTilde(p) {
1524
1421
  return p.startsWith("~/") ? join(homedir(), p.slice(2)) : p;
@@ -1557,21 +1454,17 @@ var PASSTHROUGH_HOOKS = [
1557
1454
  // with a query protocol, not a lifecycle event, so a bare exec stub is wrong.)
1558
1455
  ];
1559
1456
  function globalHookScript(spec) {
1560
- const chainExit = spec.hookName === PRE_COMMIT.hookName ? "|| exit $?" : "|| true";
1561
- const managedBlock = spec.hookName === POST_COMMIT.hookName ? postCommitHookBlock() : spec.hookName === POST_REWRITE.hookName ? postRewriteHookBlock() : void 0;
1562
- const invocation = managedBlock ?? gatedShim(spec.binName);
1563
- const managedRepoGuard = managedBlock ? ` && ! grep -Fq '${blockMarkers(spec).start.slice(0, -3)}'">>>" "$repo_hook" 2>/dev/null` : "";
1564
- const beforeComments = managedBlock ? `${invocation}
1565
- ` : "";
1566
- const afterComments = managedBlock ? "" : `${invocation}
1567
- `;
1457
+ const preCommit = spec.hookName === PRE_COMMIT.hookName;
1458
+ const chainExit = preCommit ? "|| exit $?" : "|| true";
1459
+ const managedRepoGuard = preCommit ? "" : ` && ! grep -Fq '${blockMarkers(spec).start.slice(0, -3)}'">>>" "$repo_hook" 2>/dev/null`;
1568
1460
  return `#!/bin/sh
1569
- ${beforeComments}# prim global ${spec.hookName} hook (core.hooksPath) \u2014 managed by prim; do not edit.
1461
+ ${managedHookBlock(spec.hookName)}
1462
+ # prim global ${spec.hookName} hook (core.hooksPath) \u2014 managed by prim; do not edit.
1570
1463
  # Install/uninstall: prim hooks install|uninstall --scope user
1571
1464
  # Runs prim only where activated \u2014 'prim enable' (this repo) or
1572
1465
  # 'git config --global prim.active true' (every repo). Chains to the repo's own
1573
1466
  # hook regardless, so inactive repos are unaffected.
1574
- ${afterComments}common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || exit 0
1467
+ common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || exit 0
1575
1468
  repo_hook="$common_dir/hooks/${spec.hookName}"
1576
1469
  if [ -x "$repo_hook" ] && ! grep -q '${PRIM_MANAGED_MARK}' "$repo_hook" 2>/dev/null${managedRepoGuard}; then
1577
1470
  "$repo_hook" "$@" ${chainExit}
@@ -1596,30 +1489,12 @@ function expectedOwnedHookContent(hookName) {
1596
1489
  }
1597
1490
  return null;
1598
1491
  }
1599
- var PINNED_PACKAGE_VERSION_RE = /@primitive\.ai\/prim@[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/gu;
1600
- function normalizeOwnedGlobalHook(content, hookName) {
1601
- if (hookName === PRE_COMMIT.hookName) {
1602
- const lines = content.split("\n");
1603
- const invocationIndexes = lines.flatMap(
1604
- (line2, index) => line2.startsWith("{ ") && line2.endsWith("; } || true") ? [index] : []
1605
- );
1606
- if (invocationIndexes.length !== 1) return null;
1607
- const invocationIndex = invocationIndexes[0];
1608
- const line = lines[invocationIndex];
1609
- const command = line.slice(2, -"; } || true".length);
1610
- if (!commandMatchesBin(command, PRE_COMMIT.binName)) return null;
1611
- lines[invocationIndex] = "{ <recognized Prim pre-commit invocation>; } || true";
1612
- return lines.join("\n");
1613
- }
1614
- return content.replace(PINNED_PACKAGE_VERSION_RE, "@primitive.ai/prim@<version>");
1615
- }
1616
1492
  function isExpectedOwnedGlobalHook(content, hookName) {
1617
1493
  const expected = expectedOwnedHookContent(hookName);
1618
1494
  if (expected === null) return false;
1619
1495
  if (content === expected) return true;
1620
- const normalized = normalizeOwnedGlobalHook(content, hookName);
1621
- const normalizedExpected = normalizeOwnedGlobalHook(expected, hookName);
1622
- return normalized !== null && normalized === normalizedExpected;
1496
+ const managed = HOOKS.find((spec) => spec.hookName === hookName);
1497
+ return managed !== void 0 && isLegacyOwnedGlobalHook(content, managed.hookName);
1623
1498
  }
1624
1499
  function lstatIfPresent(path) {
1625
1500
  try {
@@ -1654,6 +1529,12 @@ function assertOwnedHooksDirectorySafeToRemove() {
1654
1529
  return entries;
1655
1530
  }
1656
1531
  function writeOwnHooks() {
1532
+ if (inspectGitHookEntrypoint() !== "ready") {
1533
+ console.error(
1534
+ `[prim] did not write prim's global hooks in ${PRIM_GIT_HOOKS_DIR}: the hook runtime they run is not staged yet. Run \`prim setup\` (or an agent install such as \`prim claude install\`) first, then retry.`
1535
+ );
1536
+ return false;
1537
+ }
1657
1538
  if (!existsSync2(PRIM_GIT_HOOKS_DIR)) {
1658
1539
  mkdirSync(PRIM_GIT_HOOKS_DIR, { recursive: true });
1659
1540
  }
@@ -1665,47 +1546,17 @@ function writeOwnHooks() {
1665
1546
  for (const name of PASSTHROUGH_HOOKS) {
1666
1547
  writeFileSync(resolve(PRIM_GIT_HOOKS_DIR, name), passThroughScript(name), { mode: 493 });
1667
1548
  }
1549
+ return true;
1668
1550
  }
1669
1551
  function refreshOwnedGlobalHooks() {
1670
1552
  if (!isOurHooksDir(gitConfigGet("--global"))) return false;
1671
- writeOwnHooks();
1672
- return true;
1553
+ return writeOwnHooks();
1673
1554
  }
1674
1555
  function appendPrimBlock(hookPath, spec) {
1675
- if (spec.hookName === POST_COMMIT.hookName) {
1676
- ensurePostCommitHookAtPath(hookPath);
1677
- return;
1678
- }
1679
- if (spec.hookName === POST_REWRITE.hookName) {
1680
- ensurePostRewriteHookAtPath(hookPath);
1681
- return;
1682
- }
1683
- mergePrimBlock(hookPath, gatedBlock(spec), spec);
1556
+ ensureGitHookAtPath(spec.hookName, hookPath);
1684
1557
  }
1685
1558
  function stripPrimBlock(hookPath, spec) {
1686
- if (spec.hookName === POST_COMMIT.hookName) {
1687
- uninstallPostCommitHookAtPath(hookPath);
1688
- return;
1689
- }
1690
- if (spec.hookName === POST_REWRITE.hookName) {
1691
- uninstallPostRewriteHookAtPath(hookPath);
1692
- return;
1693
- }
1694
- if (!existsSync2(hookPath)) return;
1695
- const existing = readFileSync2(hookPath, "utf-8");
1696
- const primCreated = existing.includes(PRIM_CREATED_MARK);
1697
- const range = primBlockRange(existing, spec);
1698
- if (!range) return;
1699
- const out = (existing.slice(0, range.start) + existing.slice(range.through)).replace(
1700
- /\n{2,}$/,
1701
- "\n"
1702
- );
1703
- const remainder = out.replaceAll("#!/bin/sh", "").replaceAll(`# ${PRIM_CREATED_MARK}`, "").trim();
1704
- if (primCreated && remainder === "") {
1705
- unlinkSync(hookPath);
1706
- return;
1707
- }
1708
- writeFileSync(hookPath, out, { mode: 493 });
1559
+ uninstallGitHookAtPath(spec.hookName, hookPath);
1709
1560
  }
1710
1561
  function planPreCommitRemoval(hookPath, options) {
1711
1562
  if (!existsSync2(hookPath)) return { path: hookPath, action: "missing" };
@@ -1713,7 +1564,8 @@ function planPreCommitRemoval(hookPath, options) {
1713
1564
  if (options.allowOwnedStandalone && isOwnedStandalonePreCommit(existing)) {
1714
1565
  return { path: hookPath, action: "remove-owned-file" };
1715
1566
  }
1716
- if (primBlockRange(existing, PRE_COMMIT)) {
1567
+ const { start, end } = blockMarkers(PRE_COMMIT);
1568
+ if (existing.includes(start) || existing.includes(end)) {
1717
1569
  return { path: hookPath, action: "strip-block" };
1718
1570
  }
1719
1571
  if (existing.includes(PRE_COMMIT.binName) || existing.includes(PRIM_MANAGED_MARK) || existing.includes(PRIM_CREATED_MARK)) {
@@ -1723,79 +1575,172 @@ function planPreCommitRemoval(hookPath, options) {
1723
1575
  }
1724
1576
  return { path: hookPath, action: "leave-foreign" };
1725
1577
  }
1726
- function applyPreCommitRemoval(plan) {
1727
- if (plan.action === "remove-owned-file") {
1728
- unlinkSync(plan.path);
1729
- console.log(`Removed pre-commit hook at ${plan.path}`);
1730
- } else if (plan.action === "strip-block") {
1731
- stripPrimBlock(plan.path, PRE_COMMIT);
1732
- console.log(`Removed the Prim pre-commit block from ${plan.path}.`);
1733
- } else if (plan.action === "leave-foreign") {
1578
+ function applyPreCommitRemoval(plan, husky) {
1579
+ if (plan.action === "missing") return;
1580
+ if (plan.action === "leave-foreign") {
1734
1581
  console.log(`Left pre-commit hook at ${plan.path} untouched (not a Prim hook).`);
1582
+ return;
1735
1583
  }
1584
+ const result = uninstallGitHookAtPath(PRE_COMMIT.hookName, plan.path, { husky });
1585
+ console.log(
1586
+ result.removedFile ? `Removed pre-commit hook at ${plan.path}` : `Removed the Prim pre-commit block from ${plan.path}.`
1587
+ );
1736
1588
  }
1737
1589
  function uninstallProjectHooks(gitRoot) {
1738
- const plans = [
1739
- planPreCommitRemoval(resolve(projectHooksDir(gitRoot), PRE_COMMIT.hookName), {
1590
+ const configured = projectGitHookTarget(PRE_COMMIT.hookName, gitRoot);
1591
+ const destinations = [
1592
+ {
1593
+ path: resolve(projectHooksDir(gitRoot), PRE_COMMIT.hookName),
1594
+ husky: false,
1740
1595
  allowOwnedStandalone: true
1741
- }),
1742
- planPreCommitRemoval(resolve(gitRoot, ".husky", PRE_COMMIT.hookName), {
1596
+ },
1597
+ {
1598
+ path: resolve(gitRoot, ".husky", PRE_COMMIT.hookName),
1599
+ husky: true,
1743
1600
  allowOwnedStandalone: false
1744
- })
1745
- ];
1746
- for (const plan of plans) applyPreCommitRemoval(plan);
1747
- for (const [hookName, uninstall] of [
1748
- [POST_COMMIT.hookName, uninstallProjectPostCommitHook],
1749
- [POST_REWRITE.hookName, uninstallProjectPostRewriteHook]
1750
- ]) {
1751
- const result = uninstall(gitRoot);
1752
- if (!result.changed) {
1753
- console.log(`No Prim ${hookName} block found at ${result.path}.`);
1601
+ },
1602
+ // A shared hooks dir is never stripped on this repository's behalf.
1603
+ ...configured.location === "external" ? [] : [
1604
+ {
1605
+ path: configured.hookPath,
1606
+ husky: configured.kind === "husky_v9",
1607
+ allowOwnedStandalone: false
1608
+ }
1609
+ ]
1610
+ ].filter(
1611
+ (destination, index, all) => all.findIndex((other) => other.path === destination.path) === index
1612
+ );
1613
+ const plans = destinations.map(({ path, husky, allowOwnedStandalone }) => ({
1614
+ plan: planPreCommitRemoval(path, { allowOwnedStandalone }),
1615
+ husky
1616
+ }));
1617
+ for (const { plan, husky } of plans) applyPreCommitRemoval(plan, husky);
1618
+ for (const spec of [POST_COMMIT, POST_REWRITE]) {
1619
+ const result = uninstallProjectGitHook(spec.hookName, gitRoot);
1620
+ if (result.skipped === "external") {
1621
+ console.log(
1622
+ `Left ${result.path} alone: it is outside this repository, and other repositories may run it.`
1623
+ );
1624
+ } else if (!result.changed) {
1625
+ console.log(`No Prim ${spec.hookName} block found at ${result.path}.`);
1754
1626
  } else if (result.removedFile) {
1755
- console.log(`Removed Prim-created ${hookName} hook at ${result.path}.`);
1627
+ console.log(`Removed Prim-created ${spec.hookName} hook at ${result.path}.`);
1756
1628
  } else {
1757
- console.log(`Removed the Prim ${hookName} block from ${result.path}.`);
1629
+ console.log(`Removed the Prim ${spec.hookName} block from ${result.path}.`);
1758
1630
  }
1759
1631
  }
1760
1632
  }
1761
- function installGlobalHooks(opts = {}) {
1633
+ function globalHooksPathIsPrims() {
1634
+ return isOurHooksDir(gitConfigGet("--global"));
1635
+ }
1636
+ var EXIT_GLOBAL_HOOKS_NOT_INSTALLED = 3;
1637
+ var INSTALLED_OUTCOMES = /* @__PURE__ */ new Set([
1638
+ "installed",
1639
+ "refreshed",
1640
+ "added",
1641
+ "present"
1642
+ ]);
1643
+ function planGlobalHooks(opts = {}) {
1762
1644
  const global = gitConfigGet("--global");
1763
- if (global === "") {
1764
- const system = gitConfigGet("--system");
1765
- if (system !== "" && !isOurHooksDir(system)) {
1766
- if (!opts.force) {
1645
+ if (gitHooksMode({ global: true }) === "manual") return { action: "manual", global };
1646
+ if (isOurHooksDir(global)) return { action: "refresh" };
1647
+ if (global !== "") {
1648
+ const dir = expandTilde(global);
1649
+ return HOOKS.every((spec) => hasCurrentHookBlock(spec.hookName, resolve(dir, spec.hookName))) ? { action: "present_in_dir", global } : { action: "add_to_dir", global };
1650
+ }
1651
+ const system = gitConfigGet("--system");
1652
+ if (system !== "" && !isOurHooksDir(system)) {
1653
+ return opts.force ? { action: "set_pointer", overridesSystem: system } : { action: "system_declined", system };
1654
+ }
1655
+ return { action: "set_pointer" };
1656
+ }
1657
+ function planIsMachineWide(plan) {
1658
+ return plan.action === "set_pointer" || plan.action === "add_to_dir";
1659
+ }
1660
+ function installGlobalHooks(opts = {}) {
1661
+ const plan = planGlobalHooks(opts);
1662
+ switch (plan.action) {
1663
+ case "manual":
1664
+ console.error(
1665
+ isOurHooksDir(plan.global) ? `[prim] prim.gitHooks=manual: left hook files alone, but prim's global hooks in ${PRIM_GIT_HOOKS_DIR} are still active. Run \`prim hooks uninstall --scope user\` to stop them.` : "[prim] prim.gitHooks=manual: left hook files and core.hooksPath untouched. Wire prim yourself with `prim hooks snippet <hook>`."
1666
+ );
1667
+ return "manual";
1668
+ case "system_declined":
1669
+ console.error(
1670
+ `[prim] system core.hooksPath is set to ${plan.system}; a --global set would override it, and prim chains only to .git/hooks (not a system dir), so those hooks would stop firing. Skipped. Either place \`prim hooks snippet <hook>\` in ${plan.system} yourself, or override it with \`prim hooks install --scope user --global-hooks-path --force\`. It changes every repository's hooks: an agent must ask the user before running it.`
1671
+ );
1672
+ return "system_declined";
1673
+ case "refresh":
1674
+ stageGitHookRuntime();
1675
+ if (!writeOwnHooks()) return "runtime_missing";
1676
+ console.log(
1677
+ `Prim global git hooks already active (${PRIM_GIT_HOOKS_DIR}); refreshed scripts.`
1678
+ );
1679
+ return "refreshed";
1680
+ case "present_in_dir":
1681
+ if (!hookRuntimeReady()) {
1767
1682
  console.error(
1768
- `[prim] system core.hooksPath is set to ${system}; a --global set would override it, and prim chains only to .git/hooks (not a system dir), so those hooks would stop firing. Skipping \u2014 re-run with --force to override, or run per-repo \`prim hooks install\`.`
1683
+ `[prim] prim's hooks are in ${plan.global}, but the hook runtime they run is not staged, so they do nothing yet. Run \`prim setup\` (or an agent install such as \`prim claude install\`), then retry.`
1769
1684
  );
1770
- return false;
1685
+ return "runtime_missing";
1771
1686
  }
1772
- console.error(
1773
- `[prim] --force: overriding system core.hooksPath ${system}; its hooks will no longer fire (prim chains only to .git/hooks).`
1774
- );
1687
+ console.log(`Prim hooks already present in core.hooksPath dir ${plan.global}.`);
1688
+ return "present";
1689
+ case "set_pointer":
1690
+ case "add_to_dir":
1691
+ break;
1692
+ }
1693
+ if (!hookRuntimeReady()) {
1694
+ console.error(
1695
+ `[prim] did not install prim's global hooks: the hook runtime they run is not staged yet. Run \`prim setup\` (or an agent install such as \`prim claude install\`) first, then retry.`
1696
+ );
1697
+ return "runtime_missing";
1698
+ }
1699
+ if (!opts.machineWide) {
1700
+ console.error(
1701
+ plan.action === "set_pointer" ? plan.overridesSystem !== void 0 ? `[prim] Left git's global hooks alone: setting core.hooksPath would override the system core.hooksPath ${plan.overridesSystem} (its hooks stop firing) and route every repository's hooks through prim. To do that, run \`prim hooks install --scope user --global-hooks-path --force\`. It changes every repository's hooks: an agent must ask the user before running it.` : "[prim] Left git's global hooks alone: setting core.hooksPath would route every repository's hooks through prim. `prim enable` wires each repository you activate. To route every repository through prim instead, run `prim hooks install --scope user --global-hooks-path`. It changes every repository's hooks: an agent must ask the user before running it." : `[prim] Left the hooks in ${plan.global} alone: every repository runs them, so prim edits them only with consent. Repositories that run hooks from there lack prim's hooks until you run \`prim hooks install --scope user --global-hooks-path\`. It changes every repository's hooks: an agent must ask the user before running it.`
1702
+ );
1703
+ return "not_requested";
1704
+ }
1705
+ stageGitHookRuntime();
1706
+ if (plan.action === "add_to_dir") {
1707
+ const dir = expandTilde(plan.global);
1708
+ for (const spec of HOOKS) {
1709
+ appendPrimBlock(resolve(dir, spec.hookName), spec);
1775
1710
  }
1776
- writeOwnHooks();
1777
- execFileSync("git", ["config", "--global", "core.hooksPath", PRIM_GIT_HOOKS_DIR], {
1778
- stdio: ["ignore", "ignore", "pipe"],
1779
- timeout: GIT_TIMEOUT_MS
1780
- });
1781
1711
  console.log(
1782
- `Installed prim global git hooks; set core.hooksPath to ${PRIM_GIT_HOOKS_DIR}. Repos are opt-in: run \`prim enable\` in each repo to capture, or \`git config --global prim.active true\` for all.`
1712
+ `Added prim hooks into existing core.hooksPath dir ${plan.global} (pointer unchanged).`
1783
1713
  );
1784
- return true;
1714
+ return "added";
1785
1715
  }
1786
- if (isOurHooksDir(global)) {
1787
- writeOwnHooks();
1788
- console.log(`Prim global git hooks already active (${PRIM_GIT_HOOKS_DIR}); refreshed scripts.`);
1789
- return true;
1790
- }
1791
- const dir = expandTilde(global);
1792
- for (const spec of HOOKS) {
1793
- appendPrimBlock(resolve(dir, spec.hookName), spec);
1716
+ if (plan.overridesSystem !== void 0) {
1717
+ console.error(
1718
+ `[prim] --force: overriding system core.hooksPath ${plan.overridesSystem}; its hooks will no longer fire (prim chains only to .git/hooks).`
1719
+ );
1794
1720
  }
1721
+ if (!writeOwnHooks()) return "runtime_missing";
1722
+ execFileSync("git", ["config", "--global", "core.hooksPath", PRIM_GIT_HOOKS_DIR], {
1723
+ stdio: ["ignore", "ignore", "pipe"],
1724
+ timeout: GIT_TIMEOUT_MS
1725
+ });
1795
1726
  console.log(
1796
- `Appended prim hooks into existing core.hooksPath dir ${global} (pointer unchanged).`
1727
+ `Installed prim global git hooks; set core.hooksPath to ${PRIM_GIT_HOOKS_DIR}. Repos are opt-in: run \`prim enable\` in each repo to capture, or \`git config --global prim.active true\` for all.`
1728
+ );
1729
+ return "installed";
1730
+ }
1731
+ function hookRuntimeReady() {
1732
+ if (inspectGitHookEntrypoint() === "ready") return true;
1733
+ stageGitHookRuntime();
1734
+ return inspectGitHookEntrypoint() === "ready";
1735
+ }
1736
+ async function consentToMachineWideHooks(plan, flag, nonInteractive) {
1737
+ if (!planIsMachineWide(plan) || !hookRuntimeReady()) return false;
1738
+ if (flag) return true;
1739
+ if (nonInteractive || !process.stdin.isTTY) return false;
1740
+ return askConfirmation(
1741
+ plan.action === "set_pointer" ? plan.overridesSystem !== void 0 ? `Set git's global core.hooksPath so every repository's hooks run through prim? This overrides the system core.hooksPath ${plan.overridesSystem}, whose hooks stop firing.` : "Set git's global core.hooksPath so every repository's hooks run through prim?" : `Add prim's hooks to ${plan.action === "add_to_dir" ? plan.global : "the global hooks dir"}, which every repository runs?`,
1742
+ process.stderr
1797
1743
  );
1798
- return true;
1799
1744
  }
1800
1745
  function uninstallGlobalHooks() {
1801
1746
  const global = gitConfigGet("--global");
@@ -1819,26 +1764,75 @@ function uninstallGlobalHooks() {
1819
1764
  }
1820
1765
  console.log("No prim global git hooks found.");
1821
1766
  }
1767
+ function reportInstall(result, gitRoot) {
1768
+ const { hookName, path } = result;
1769
+ switch (result.outcome) {
1770
+ case "created":
1771
+ console.log(`Installed prim ${hookName} hook at ${path}.`);
1772
+ return;
1773
+ case "updated":
1774
+ console.log(`Updated prim ${hookName} hook at ${path}.`);
1775
+ return;
1776
+ case "unchanged":
1777
+ console.log(`Already current: prim ${hookName} hook at ${path}.`);
1778
+ return;
1779
+ case "external":
1780
+ console.error(
1781
+ `[prim] ${hookName}: Git runs this repository's hooks from ${path}, outside the repository, so prim left it alone. ${externalHookRemedy(hookName, gitRoot)}.`
1782
+ );
1783
+ return;
1784
+ case "kept":
1785
+ console.error(
1786
+ `[prim] ${hookName}: kept the working pre-v1 prim block at ${path}, outside the repository; it still captures. To upgrade it, ${externalHookRemedy(hookName, gitRoot)}.`
1787
+ );
1788
+ return;
1789
+ case "runtime_missing":
1790
+ console.error(
1791
+ `[prim] ${hookName}: kept the existing prim hook at ${path}; it still works, and its replacement would stay inert until the hook runtime is staged.`
1792
+ );
1793
+ return;
1794
+ default:
1795
+ console.error(`[prim] ${hookName}: left ${path} as it was (${result.outcome}).`);
1796
+ }
1797
+ }
1822
1798
  function installHooks(gitRoot, target) {
1823
- if (target === "husky") {
1824
- installToHusky(gitRoot, PRE_COMMIT);
1825
- } else {
1826
- installToDotGit(gitRoot, PRE_COMMIT);
1799
+ let changed = false;
1800
+ if (target !== "effective") {
1801
+ const effectiveDir = resolveEffectiveGitHook(PRE_COMMIT.hookName, gitRoot).hooksDir;
1802
+ const chosenDir = target === "husky" ? resolve(gitRoot, ".husky") : projectHooksDir(gitRoot);
1803
+ if (resolve(effectiveDir) !== chosenDir && resolve(effectiveDir, "..") !== chosenDir) {
1804
+ console.error(
1805
+ `[prim] note: Git runs this repository's hooks from ${effectiveDir}; hooks in ${chosenDir} will not fire unless that directory chains to them.`
1806
+ );
1807
+ }
1827
1808
  }
1828
- const postCommit = ensureEffectivePostCommitHook(gitRoot);
1829
- console.log(
1830
- `${postCommit.changed ? "Installed" : "Refreshed"} effective post-commit hook at ${postCommit.path}.`
1831
- );
1832
- try {
1833
- const postRewrite = ensureEffectivePostRewriteHook(gitRoot);
1834
- console.log(
1835
- `${postRewrite.changed ? "Installed" : "Refreshed"} effective post-rewrite hook at ${postRewrite.path}.`
1836
- );
1837
- } catch (error) {
1838
- const detail = error instanceof Error ? error.message : String(error);
1839
- console.error(`[prim] post-rewrite hook coverage is degraded: ${detail}`);
1809
+ for (const spec of HOOKS) {
1810
+ try {
1811
+ const result = target === "effective" ? ensureEffectiveGitHook(spec.hookName, gitRoot, { context: "explicit" }) : ensureGitHookAtPath(
1812
+ spec.hookName,
1813
+ target === "husky" ? resolve(gitRoot, ".husky", spec.hookName) : resolve(projectHooksDir(gitRoot), spec.hookName),
1814
+ { husky: target === "husky" }
1815
+ );
1816
+ reportInstall(result, gitRoot);
1817
+ if (spec === POST_COMMIT) changed ||= result.changed;
1818
+ if (spec === POST_COMMIT && result.outcome === "external") process.exitCode = 1;
1819
+ } catch (error) {
1820
+ if (spec === POST_COMMIT) throw error;
1821
+ const detail = error instanceof Error ? error.message : String(error);
1822
+ console.error(
1823
+ `[prim] ${spec.hookName} hook coverage is degraded: ${detail}. To wire it by hand, run \`prim hooks snippet ${spec.hookName}\`.`
1824
+ );
1825
+ }
1826
+ }
1827
+ if (repoActiveFlag(gitRoot) === "true") {
1828
+ recordHooksWired(gitRoot, { onlyIfAbsent: !changed });
1840
1829
  }
1841
1830
  }
1831
+ function printManualNote(gitRoot) {
1832
+ console.error(
1833
+ `[prim] prim.gitHooks=manual: left the hook files in ${gitRoot} untouched. Wire each hook with \`prim hooks snippet <hook>\`; \`prim doctor\` reports what it finds.`
1834
+ );
1835
+ }
1842
1836
  function registerHooksCommands(program2) {
1843
1837
  const hooks = program2.command("hooks").description("Manage git hooks");
1844
1838
  hooks.command("install").description(
@@ -1851,20 +1845,47 @@ function registerHooksCommands(program2) {
1851
1845
  ).addOption(
1852
1846
  new Option(
1853
1847
  "--scope <scope>",
1854
- "project (default, this repo) or user (a global core.hooksPath capturing every repo)"
1848
+ "project (default, this repo) or user (prim's global hooks; needs --global-hooks-path or a yes at a terminal to change git's global config)"
1855
1849
  ).choices(["project", "user"])
1856
- ).option("--force", "with --scope user, override a system-level core.hooksPath").action(
1850
+ ).option("--force", "with --scope user, override a system-level core.hooksPath").option(
1851
+ "--global-hooks-path",
1852
+ "with --scope user, change git's global hooks so every repository runs prim's hooks"
1853
+ ).action(
1857
1854
  async (opts, command) => {
1855
+ const globals = command.optsWithGlobals();
1856
+ const nonInteractive = isNonInteractive(globals);
1858
1857
  if (opts.scope === "user") {
1859
- installGlobalHooks({ force: opts.force });
1858
+ const plan = planGlobalHooks({ force: opts.force });
1859
+ const outcome = installGlobalHooks({
1860
+ force: opts.force,
1861
+ machineWide: await consentToMachineWideHooks(
1862
+ plan,
1863
+ opts.globalHooksPath,
1864
+ nonInteractive
1865
+ )
1866
+ });
1867
+ if (outcome === "runtime_missing") {
1868
+ process.exitCode = 1;
1869
+ } else if (opts.globalHooksPath && !INSTALLED_OUTCOMES.has(outcome)) {
1870
+ process.exitCode = EXIT_GLOBAL_HOOKS_NOT_INSTALLED;
1871
+ }
1872
+ return;
1873
+ }
1874
+ if (opts.globalHooksPath) {
1875
+ console.error("[prim] --global-hooks-path applies only with --scope user");
1876
+ process.exitCode = 2;
1860
1877
  return;
1861
1878
  }
1862
- const globals = command.optsWithGlobals();
1863
- const nonInteractive = isNonInteractive(globals);
1864
1879
  const gitRoot = getGitRoot();
1880
+ if (gitHooksMode({ cwd: gitRoot }) === "manual") {
1881
+ printManualNote(gitRoot);
1882
+ return;
1883
+ }
1884
+ stageGitHookRuntime();
1865
1885
  if (opts.target === "husky") return installHooks(gitRoot, "husky");
1866
1886
  if (opts.target === "git-hooks") return installHooks(gitRoot, "git-hooks");
1867
- if (detectHusky(gitRoot)) {
1887
+ const effectiveDir = resolveEffectiveGitHook(PRE_COMMIT.hookName, gitRoot).hooksDir;
1888
+ if (resolve(effectiveDir) === projectHooksDir(gitRoot) && detectHusky(gitRoot)) {
1868
1889
  if (globals.yes) return installHooks(gitRoot, "husky");
1869
1890
  if (nonInteractive) {
1870
1891
  throw new Error(
@@ -1882,10 +1903,25 @@ function registerHooksCommands(program2) {
1882
1903
  } else {
1883
1904
  console.log("Falling back to .git/hooks install.");
1884
1905
  }
1906
+ return installHooks(gitRoot, "git-hooks");
1885
1907
  }
1886
- installHooks(gitRoot, "git-hooks");
1908
+ installHooks(gitRoot, "effective");
1887
1909
  }
1888
1910
  );
1911
+ hooks.command("snippet").description("Print the block that wires one prim git hook (STDOUT), to place by hand").addArgument(new Argument("<hook>", "git hook").choices(MANAGED_GIT_HOOK_NAMES)).action((hook) => {
1912
+ process.stdout.write(`${managedHookBlock(hook)}
1913
+ `);
1914
+ let entrypoint = "<prim config>/prim-git-hook-v1";
1915
+ try {
1916
+ entrypoint = hookRuntimePaths().gitHookEntrypoint;
1917
+ } catch {
1918
+ }
1919
+ process.stderr.write(
1920
+ `[prim] Place this block right after the shebang of your ${hook} hook (in a Husky v8 hook, right after the husky.sh line), before any exit or exec. It runs prim's ${hook} step where prim is installed and enabled, and does nothing elsewhere.${hook === "post-rewrite" ? " It re-arms stdin, so commands after it still read Git's rewrite pairs." : ""}
1921
+ [prim] From a hook manager, run \`${entrypoint} ${hook} "$@"\`${hook === "post-rewrite" ? " with Git's stdin" : ""} instead. Set \`git config prim.gitHooks manual\` so prim leaves your hook files alone.
1922
+ `
1923
+ );
1924
+ });
1889
1925
  hooks.command("uninstall").description(
1890
1926
  "Remove the prim git hooks (.git/hooks, or the global core.hooksPath with --scope user)"
1891
1927
  ).addOption(
@@ -1905,6 +1941,49 @@ function registerHooksCommands(program2) {
1905
1941
  // src/commands/activation.ts
1906
1942
  var CONNECT_PROMPT = "[prim] GitHub repo connection is required to enable repository-specific file attribution, Conflict Gate verification, and commit correlation. Start the GitHub App connection now?";
1907
1943
  var GITHUB_CONNECTION_REQUIRED = "GitHub repo connection is required before using Primitive in this repository. It enables repository-specific file attribution, Conflict Gate verification, and commit correlation. Run `prim github connect` to complete it.";
1944
+ function wireRepositoryHooks(root) {
1945
+ stageGitHookRuntime();
1946
+ refreshOwnedGlobalHooks();
1947
+ const paths = {};
1948
+ let manual = false;
1949
+ let changed = false;
1950
+ for (const hookName of MANAGED_GIT_HOOK_NAMES) {
1951
+ try {
1952
+ const result = ensureEffectiveGitHook(hookName, root, { context: "explicit" });
1953
+ if (result.outcome === "external") {
1954
+ throw new Error(
1955
+ `Git runs this repository's ${hookName} hook from ${result.path}, outside the repository; ${externalHookRemedy(hookName, root)}`
1956
+ );
1957
+ }
1958
+ if (result.outcome === "kept") {
1959
+ process.stderr.write(
1960
+ `[prim] kept the working pre-v1 ${hookName} hook at ${result.path}, outside the repository; to upgrade it, ${externalHookRemedy(hookName, root)}
1961
+ `
1962
+ );
1963
+ }
1964
+ if (result.outcome === "runtime_missing") {
1965
+ process.stderr.write(
1966
+ `[prim] kept the working pre-v1 ${hookName} hook at ${result.path}; it is replaced once the hook runtime is staged
1967
+ `
1968
+ );
1969
+ }
1970
+ paths[hookName] = result.path;
1971
+ manual ||= result.outcome === "manual";
1972
+ if (hookName === "post-commit") changed ||= result.changed;
1973
+ } catch (error) {
1974
+ if (hookName === "post-commit") throw error;
1975
+ const detail = error instanceof Error ? error.message : String(error);
1976
+ process.stderr.write(`[prim] ${hookName} hook coverage is degraded: ${detail}
1977
+ `);
1978
+ }
1979
+ }
1980
+ if (manual) {
1981
+ process.stderr.write(
1982
+ "[prim] prim.gitHooks=manual: left hook files untouched; wire them with `prim hooks snippet <hook>`\n"
1983
+ );
1984
+ }
1985
+ return { paths, changed };
1986
+ }
1908
1987
  async function maybeConnectRepository(root, globals) {
1909
1988
  if (isNonInteractive(globals)) return void 0;
1910
1989
  const approved = Boolean(globals.yes) || await askConfirmation(CONNECT_PROMPT, process.stderr);
@@ -1924,6 +2003,13 @@ async function maybeConnectRepository(root, globals) {
1924
2003
  }
1925
2004
  return void 0;
1926
2005
  }
2006
+ function hookPathFields(paths) {
2007
+ return {
2008
+ ...paths["pre-commit"] ? { preCommitHook: paths["pre-commit"] } : {},
2009
+ ...paths["post-commit"] ? { postCommitHook: paths["post-commit"] } : {},
2010
+ ...paths["post-rewrite"] ? { postRewriteHook: paths["post-rewrite"] } : {}
2011
+ };
2012
+ }
1927
2013
  async function applyActivation(active, globals = {}) {
1928
2014
  const root = gitToplevel();
1929
2015
  if (!root) {
@@ -1936,18 +2022,11 @@ async function applyActivation(active, globals = {}) {
1936
2022
  let phase = active ? "post-commit hook coverage" : "local deactivation";
1937
2023
  try {
1938
2024
  let binding;
1939
- let postCommitHook;
1940
- let postRewriteHook;
2025
+ let hookPaths = {};
2026
+ let hooksChanged = false;
2027
+ const wasActive = repoActiveFlag(root) === "true";
1941
2028
  if (active) {
1942
- refreshOwnedGlobalHooks();
1943
- postCommitHook = ensureEffectivePostCommitHook(root).path;
1944
- try {
1945
- postRewriteHook = ensureEffectivePostRewriteHook(root).path;
1946
- } catch (error) {
1947
- const detail = error instanceof Error ? error.message : String(error);
1948
- process.stderr.write(`[prim] post-rewrite hook coverage is degraded: ${detail}
1949
- `);
1950
- }
2029
+ ({ paths: hookPaths, changed: hooksChanged } = wireRepositoryHooks(root));
1951
2030
  phase = "GitHub repo connection";
1952
2031
  binding = await bindRepository(root);
1953
2032
  }
@@ -1961,8 +2040,7 @@ async function applyActivation(active, globals = {}) {
1961
2040
  repo: root,
1962
2041
  bindingStatus: binding.status,
1963
2042
  repositoryFullName: binding.repositoryFullName,
1964
- ...postCommitHook ? { postCommitHook } : {},
1965
- ...postRewriteHook ? { postRewriteHook } : {}
2043
+ ...hookPathFields(hookPaths)
1966
2044
  });
1967
2045
  process.exitCode = 1;
1968
2046
  return;
@@ -1977,6 +2055,10 @@ async function applyActivation(active, globals = {}) {
1977
2055
  }
1978
2056
  phase = "local activation";
1979
2057
  setRepoActive(root, active);
2058
+ if (!active) clearHooksWired(root);
2059
+ if (active) {
2060
+ recordHooksWired(root, { onlyIfAbsent: wasActive && !hooksChanged });
2061
+ }
1980
2062
  await daemonRequest("statusline_invalidate", {}, { timeoutMs: 250 });
1981
2063
  process.stderr.write(`[prim] prim ${active ? "enabled" : "disabled"} in ${root}
1982
2064
  `);
@@ -1988,8 +2070,7 @@ async function applyActivation(active, globals = {}) {
1988
2070
  repositoryFullName: binding.repositoryFullName,
1989
2071
  ...binding.status === "connected" ? { repoSyncId: binding.repoSyncId } : {}
1990
2072
  } : {},
1991
- ...postCommitHook ? { postCommitHook } : {},
1992
- ...postRewriteHook ? { postRewriteHook } : {}
2073
+ ...hookPathFields(hookPaths)
1993
2074
  });
1994
2075
  } catch (err) {
1995
2076
  const detail = err instanceof Error ? err.message : String(err);
@@ -2142,22 +2223,32 @@ function performStatus3() {
2142
2223
  };
2143
2224
  return { user: statusFor(USER_SCOPE_PATH), project: statusFor(projectScopePath()) };
2144
2225
  }
2226
+ function uncoveredWorktreeHooks(status) {
2227
+ if (status.user.present || status.project.present) return null;
2228
+ const main = mainWorktreeRoot();
2229
+ if (main === null) return null;
2230
+ const path = join2(main, ".codex", "hooks.json");
2231
+ return hasAnyHookRegistration(readSettings(path)) ? path : null;
2232
+ }
2233
+ function uncoveredWorktreeAdvice(path) {
2234
+ return `prim Codex hooks in ${path} don't load in this worktree \u2014 run \`prim codex install\`, then \`prim codex uninstall --scope project\` in that checkout`;
2235
+ }
2145
2236
  function resolveScope(input) {
2146
- if (input === void 0 || input === "project") {
2147
- return "project";
2148
- }
2149
- if (input === "user") {
2237
+ if (input === void 0 || input === "user") {
2150
2238
  return "user";
2151
2239
  }
2240
+ if (input === "project") {
2241
+ return "project";
2242
+ }
2152
2243
  console.error(`[prim] unknown --scope "${input}" (expected: user or project)`);
2153
2244
  process.exit(1);
2154
2245
  }
2155
2246
  var TRUST_NOTICE = "[prim] Codex requires hook trust: run `/hooks` in Codex to review and trust these hooks (or start Codex with --dangerously-bypass-hook-trust). Until trusted, the hooks will not fire.";
2156
2247
  function registerCodexCommands(program2) {
2157
2248
  const codex = program2.command("codex").description("Manage the prim Codex integration (capture, gate, ingest, presence)");
2158
- codex.command("install").description("Register the prim hooks in Codex's hooks.json (project scope by default)").option(
2249
+ codex.command("install").description("Register the prim hooks in Codex's hooks.json (user scope by default)").option(
2159
2250
  "--scope <scope>",
2160
- "project (default, the repo's .codex/hooks.json) or user (~/.codex/hooks.json)"
2251
+ "user (default, ~/.codex/hooks.json) or project (this checkout's .codex/hooks.json)"
2161
2252
  ).option("--force", "Replace any drifted prim hook entries").action((opts) => {
2162
2253
  const scope = resolveScope(opts.scope);
2163
2254
  const result = performInstall(scope, opts.force ?? false);
@@ -2171,7 +2262,7 @@ function registerCodexCommands(program2) {
2171
2262
  });
2172
2263
  codex.command("uninstall").description("Remove all prim hooks from Codex's hooks.json").option(
2173
2264
  "--scope <scope>",
2174
- "project (default, the repo's .codex/hooks.json) or user (~/.codex/hooks.json)"
2265
+ "user (default, ~/.codex/hooks.json) or project (this checkout's .codex/hooks.json)"
2175
2266
  ).action((opts) => {
2176
2267
  const scope = resolveScope(opts.scope);
2177
2268
  const result = performUninstall(scope);
@@ -2188,6 +2279,9 @@ function registerCodexCommands(program2) {
2188
2279
  const line = (label, s) => `[prim] ${label}: gate ${mark(s.gate)} \xB7 capture ${mark(s.capture)} (${s.path})`;
2189
2280
  console.error(`${line("user", result.user)}
2190
2281
  ${line("project", result.project)}`);
2282
+ const uncovered = uncoveredWorktreeHooks(result);
2283
+ if (uncovered !== null)
2284
+ console.error(`[prim] warning: ${uncoveredWorktreeAdvice(uncovered)}`);
2191
2285
  console.log(JSON.stringify(result, null, JSON_INDENT));
2192
2286
  });
2193
2287
  }
@@ -2286,7 +2380,7 @@ import {
2286
2380
  writeFileSync as writeFileSync2
2287
2381
  } from "fs";
2288
2382
  import { homedir as homedir3 } from "os";
2289
- import { basename, dirname as dirname2, isAbsolute, join as join4, resolve as resolve2 } from "path";
2383
+ import { basename, dirname, isAbsolute, join as join4, resolve as resolve2 } from "path";
2290
2384
 
2291
2385
  // src/lib/bounded-file.ts
2292
2386
  import { constants, closeSync, fstatSync, lstatSync as lstatSync2, openSync, readSync } from "fs";
@@ -2398,7 +2492,7 @@ function daemonControlPaths(options = {}) {
2398
2492
  };
2399
2493
  }
2400
2494
  function findPackageVersion(entryFile) {
2401
- let dir = dirname2(resolve2(entryFile));
2495
+ let dir = dirname(resolve2(entryFile));
2402
2496
  for (let depth = 0; depth < 6; depth++) {
2403
2497
  const packagePath = join4(dir, "package.json");
2404
2498
  if (existsSync3(packagePath)) {
@@ -2410,7 +2504,7 @@ function findPackageVersion(entryFile) {
2410
2504
  } catch {
2411
2505
  }
2412
2506
  }
2413
- const parent = dirname2(dir);
2507
+ const parent = dirname(dir);
2414
2508
  if (parent === dir) break;
2415
2509
  dir = parent;
2416
2510
  }
@@ -2832,7 +2926,7 @@ function readDaemonLauncherFile(path) {
2832
2926
  function readUsableDaemonConfig(path) {
2833
2927
  const config = readDaemonLauncher(path);
2834
2928
  if (!config) return null;
2835
- const manifest = readRuntimeManifest(join4(dirname2(config.daemonPath), "manifest.json"));
2929
+ const manifest = readRuntimeManifest(join4(dirname(config.daemonPath), "manifest.json"));
2836
2930
  try {
2837
2931
  accessSync(config.nodePath, constants2.X_OK);
2838
2932
  return manifest?.version === config.runtimeVersion && manifest.nodePath === config.nodePath && manifest.daemonSha256 === sha256File(config.daemonPath) ? config : null;
@@ -2926,7 +3020,7 @@ function atomicWrite2(path, content, mode, validate) {
2926
3020
  }
2927
3021
  } catch {
2928
3022
  }
2929
- mkdirSync2(dirname2(path), { recursive: true, mode: RUNTIME_DIR_MODE });
3023
+ mkdirSync2(dirname(path), { recursive: true, mode: RUNTIME_DIR_MODE });
2930
3024
  const temp = `${path}.tmp-${process.pid}-${Date.now()}`;
2931
3025
  writeFileSync2(temp, content, { encoding: "utf8", mode, flag: "wx" });
2932
3026
  try {
@@ -2962,7 +3056,7 @@ function canonicalProspectivePath(path) {
2962
3056
  const suffix = [];
2963
3057
  let ancestor = resolve2(path);
2964
3058
  while (!existsSync3(ancestor)) {
2965
- const parent = dirname2(ancestor);
3059
+ const parent = dirname(ancestor);
2966
3060
  if (parent === ancestor) return resolve2(path);
2967
3061
  suffix.unshift(basename(ancestor));
2968
3062
  ancestor = parent;
@@ -3302,7 +3396,7 @@ async function ensureMacDaemonLocked(options) {
3302
3396
  };
3303
3397
  }
3304
3398
  const servicePaths = launchdPaths(options);
3305
- mkdirSync2(dirname2(servicePaths.logPath), { recursive: true, mode: RUNTIME_DIR_MODE });
3399
+ mkdirSync2(dirname(servicePaths.logPath), { recursive: true, mode: RUNTIME_DIR_MODE });
3306
3400
  writeFileSync2(servicePaths.logPath, "", { mode: RUNTIME_FILE_MODE, flag: "a" });
3307
3401
  chmodSync(servicePaths.logPath, RUNTIME_FILE_MODE);
3308
3402
  const launcher = generateDaemonLauncher(daemonConfig);
@@ -4800,8 +4894,6 @@ var DECISION_NOT_FOUND_MESSAGE = "Decision not found";
4800
4894
  var REPLACEMENT_NOT_FOUND_MESSAGE = "Replacement decision not found";
4801
4895
  var SELF_REPLACEMENT_MESSAGE = "A decision cannot supersede itself";
4802
4896
  var OLD_SERVER_NOT_FOUND_MESSAGE = "Not found";
4803
- var FEATURE_DISABLED_MESSAGE = "feature_disabled";
4804
- var FEATURE_CHECK_UNAVAILABLE_MESSAGE = "feature_check_unavailable";
4805
4897
  var STAGE_NAME = "(?:draft|provisional|adopted|superseded|abandoned)";
4806
4898
  var IMMUTABLE_MESSAGE = new RegExp(
4807
4899
  `^An ${STAGE_NAME} decision is immutable \u2014 supersede it to change it$`,
@@ -4878,12 +4970,6 @@ function classifyHttpError2(error) {
4878
4970
  if (error.status === 403 && message === NOT_AUTHOR_MESSAGE) {
4879
4971
  return failure2("not_author", DECISION_LIFECYCLE_EXIT.rejected, error.status);
4880
4972
  }
4881
- if (error.status === 403 && message === FEATURE_DISABLED_MESSAGE) {
4882
- return failure2("feature_disabled", DECISION_LIFECYCLE_EXIT.rejected, error.status);
4883
- }
4884
- if (error.status === 503 && message === FEATURE_CHECK_UNAVAILABLE_MESSAGE) {
4885
- return failure2("feature_check_unavailable", DECISION_LIFECYCLE_EXIT.server, error.status);
4886
- }
4887
4973
  if (error.status === 409 && message === AMBIGUOUS_MESSAGE) {
4888
4974
  return failure2("ambiguous_identifier", DECISION_LIFECYCLE_EXIT.rejected, error.status);
4889
4975
  }
@@ -4943,10 +5029,6 @@ function formatFailureHuman(operation, result) {
4943
5029
  return `[prim] ${verb} rejected: Decision not found.`;
4944
5030
  case "replacement_not_found":
4945
5031
  return "[prim] supersede rejected: replacement Decision not found.";
4946
- case "feature_disabled":
4947
- return `[prim] ${verb} rejected: manual lifecycle actions are not enabled for this account.`;
4948
- case "feature_check_unavailable":
4949
- return `[prim] ${verb} unavailable: the server could not verify feature availability; retry shortly.`;
4950
5032
  case "unsupported_server":
4951
5033
  return `[prim] ${verb} unavailable: this Primitive server does not support this Decision lifecycle operation; upgrade the server before retrying.`;
4952
5034
  case "rejected":
@@ -7534,7 +7616,26 @@ async function checkRepositoryBinding() {
7534
7616
  };
7535
7617
  }
7536
7618
  }
7537
- function classifyManagedHook(hookName, inspection) {
7619
+ function managedHookRemedy(inspection) {
7620
+ switch (inspection.reason) {
7621
+ case "missing":
7622
+ case "missing_block":
7623
+ case "stale_block":
7624
+ case "legacy_block":
7625
+ case "unreachable_block":
7626
+ case "misplaced_block":
7627
+ if (inspection.location === "prim") return "run `prim enable` to refresh prim's global hooks";
7628
+ if (inspection.location === "external") {
7629
+ return `outside this repository \u2014 ${externalHookRemedy(inspection.hookName, inspection.gitRoot)}`;
7630
+ }
7631
+ return "run `prim hooks install`";
7632
+ case "entrypoint_missing":
7633
+ return "run `prim enable` to stage the hook runtime";
7634
+ default:
7635
+ return void 0;
7636
+ }
7637
+ }
7638
+ function classifyManagedHook(hookName, inspection, firing) {
7538
7639
  if (inspection.covered) {
7539
7640
  return {
7540
7641
  name: hookName,
@@ -7542,15 +7643,78 @@ function classifyManagedHook(hookName, inspection) {
7542
7643
  detail: `effective and executable \xB7 ${inspection.kind} \xB7 ${inspection.hookPath}`
7543
7644
  };
7544
7645
  }
7646
+ const reason = inspection.reason ?? "uncovered";
7647
+ if (inspection.mode === "manual") {
7648
+ if (hookName === "post-commit" && firing?.state === "fired") {
7649
+ return {
7650
+ name: hookName,
7651
+ status: "ok",
7652
+ detail: `manual (prim.gitHooks=manual) \xB7 ran for the latest commit \xB7 ${inspection.hookPath}`
7653
+ };
7654
+ }
7655
+ return {
7656
+ name: hookName,
7657
+ status: "warn",
7658
+ detail: `manual (prim.gitHooks=manual) \xB7 ${reason} \xB7 wire with \`prim hooks snippet ${hookName}\` \xB7 ${inspection.hookPath}`
7659
+ };
7660
+ }
7661
+ const remedy = managedHookRemedy(inspection);
7662
+ const stillCaptures = reason === "legacy_block" || reason === "misplaced_block";
7545
7663
  return {
7546
7664
  name: hookName,
7547
- status: "fail",
7548
- detail: `${inspection.reason ?? "uncovered"} \xB7 ${inspection.hookPath}`
7665
+ status: hookName === "pre-commit" || stillCaptures ? "warn" : "fail",
7666
+ detail: `${reason}${remedy ? ` \xB7 ${remedy}` : ""} \xB7 ${inspection.hookPath}`
7549
7667
  };
7550
7668
  }
7551
- function checkManagedHook(hookName, inspect) {
7669
+ function minutesAgo(at, now) {
7670
+ const minutes = Math.max(0, Math.round((now - at) / 6e4));
7671
+ return minutes === 0 ? "just now" : `${String(minutes)}m ago`;
7672
+ }
7673
+ function classifyHookFiring(firing, now = Date.now()) {
7674
+ const name = "hook-fired";
7675
+ switch (firing.state) {
7676
+ case "inactive":
7677
+ return { name, status: "ok", detail: "repository not active \u2014 no post-commit run expected" };
7678
+ case "unverified":
7679
+ return {
7680
+ name,
7681
+ status: "ok",
7682
+ detail: "no local commit since the hooks were wired \u2014 the next commit is checked"
7683
+ };
7684
+ case "pending":
7685
+ return {
7686
+ name,
7687
+ status: "ok",
7688
+ detail: `latest commit is under ${String(POST_COMMIT_GRACE_MS / 1e3)}s old \u2014 capture may still be starting`
7689
+ };
7690
+ case "fired":
7691
+ return {
7692
+ name,
7693
+ status: "ok",
7694
+ detail: `post-commit reached prim for the latest commit (${minutesAgo(firing.firedAt, now)})`
7695
+ };
7696
+ case "not_firing":
7697
+ return {
7698
+ name,
7699
+ status: "fail",
7700
+ detail: `post-commit never reached prim for the commit made ${minutesAgo(firing.commitAt, now)}${firing.firedAt === void 0 ? "" : ` (last run ${minutesAgo(firing.firedAt, now)})`} \xB7 likely an exit/exec before prim's block, hooks turned off for that commit (HUSKY=0, a core.hooksPath override), or a git client without prim's environment \xB7 fix the cause, then commit to re-check \xB7 if prim was off for that commit (set with \`git config prim.active\` directly), run \`prim disable\` then \`prim enable\` to restart the check`
7701
+ };
7702
+ }
7703
+ }
7704
+ function inspectFiringHere() {
7705
+ try {
7706
+ const root = gitToplevel();
7707
+ return root ? inspectPostCommitFiring(root) : void 0;
7708
+ } catch {
7709
+ return void 0;
7710
+ }
7711
+ }
7712
+ function checkHookFiring(firing) {
7713
+ return firing ? classifyHookFiring(firing) : { name: "hook-fired", status: "warn", detail: "not a git repository" };
7714
+ }
7715
+ function checkManagedHook(hookName, firing) {
7552
7716
  try {
7553
- return classifyManagedHook(hookName, inspect());
7717
+ return classifyManagedHook(hookName, inspectEffectiveGitHook(hookName), firing);
7554
7718
  } catch (error) {
7555
7719
  const detail = error instanceof Error ? error.message : String(error);
7556
7720
  return {
@@ -7587,9 +7751,16 @@ function classifyClaudeHooks(statuses) {
7587
7751
  }
7588
7752
  return { name: "feedback-hooks", status: "ok", detail: "complete Claude lifecycle ready" };
7589
7753
  }
7590
- function classifyCodexHooks(statuses) {
7754
+ function classifyCodexHooks(statuses, uncoveredWorktree = null, capturedAt) {
7591
7755
  const installed = statuses.filter((status) => status.present);
7592
7756
  if (installed.length === 0) {
7757
+ if (uncoveredWorktree !== null) {
7758
+ return {
7759
+ name: "codex-hooks",
7760
+ status: "warn",
7761
+ detail: uncoveredWorktreeAdvice(uncoveredWorktree)
7762
+ };
7763
+ }
7593
7764
  return { name: "codex-hooks", status: "ok", detail: "not installed" };
7594
7765
  }
7595
7766
  if (installed.some((status) => !status.complete)) {
@@ -7601,8 +7772,8 @@ function classifyCodexHooks(statuses) {
7601
7772
  }
7602
7773
  return {
7603
7774
  name: "codex-hooks",
7604
- status: "warn",
7605
- detail: "installed; Codex trust is not machine-readable \u2014 verify with `/hooks`"
7775
+ status: capturedAt === void 0 ? "warn" : "ok",
7776
+ detail: capturedAt === void 0 ? "installed; conversation capture unverified; Codex trust unknown \u2014 verify with `/hooks`" : `installed; conversation capture observed ${new Date(capturedAt).toISOString()}; current trust unknown`
7606
7777
  };
7607
7778
  }
7608
7779
  function classifyCursorHooks(statuses) {
@@ -7654,7 +7825,13 @@ function checkAgentHooks() {
7654
7825
  const checks = [];
7655
7826
  try {
7656
7827
  const status = performStatus3();
7657
- checks.push(classifyCodexHooks([status.project, status.user]));
7828
+ checks.push(
7829
+ classifyCodexHooks(
7830
+ [status.project, status.user],
7831
+ uncoveredWorktreeHooks(status),
7832
+ lastConversationCaptured(process.cwd(), "codex")
7833
+ )
7834
+ );
7658
7835
  } catch (error) {
7659
7836
  const detail = boundedHealthError(error instanceof Error ? error.message : String(error));
7660
7837
  checks.push({
@@ -7806,7 +7983,7 @@ function parseMovesStatus(value) {
7806
7983
  }
7807
7984
  const status = value;
7808
7985
  const nullableNumber = (candidate) => candidate === null || typeof candidate === "number" && Number.isFinite(candidate);
7809
- if (status.captureState !== "enabled" && status.captureState !== "disabled" || !nullableNumber(status.latestIngestAt) || !nullableNumber(status.latestClassificationAt) || !nullableNumber(status.highWaterMark) || typeof status.pendingSessionCount !== "number" || !Number.isInteger(status.pendingSessionCount) || status.pendingSessionCount < 0 || typeof status.sampled !== "boolean") {
7986
+ if (status.captureState !== "enabled" || !nullableNumber(status.latestIngestAt) || !nullableNumber(status.latestClassificationAt) || !nullableNumber(status.highWaterMark) || typeof status.pendingSessionCount !== "number" || !Number.isInteger(status.pendingSessionCount) || status.pendingSessionCount < 0 || typeof status.sampled !== "boolean") {
7810
7987
  throw new Error("moves status returned an invalid response");
7811
7988
  }
7812
7989
  if (status.oldestPendingAt !== void 0 && !nullableNumber(status.oldestPendingAt) || status.oldestPendingAgeMs !== void 0 && !nullableNumber(status.oldestPendingAgeMs) || typeof status.oldestPendingAgeMs === "number" && status.oldestPendingAgeMs < 0 || status.pendingCommitCorrelationCount !== void 0 && (!Number.isInteger(status.pendingCommitCorrelationCount) || status.pendingCommitCorrelationCount < 0)) {
@@ -7815,10 +7992,10 @@ function parseMovesStatus(value) {
7815
7992
  return status;
7816
7993
  }
7817
7994
  function classifyMovesStatus(status) {
7818
- const capture = status.captureState === "enabled" ? { name: "capture", status: "ok", detail: "enabled; ingest endpoint durable" } : {
7995
+ const capture = {
7819
7996
  name: "capture",
7820
- status: "fail",
7821
- detail: "disabled for the current organization; local Moves are retained"
7997
+ status: "ok",
7998
+ detail: "enabled; ingest endpoint durable"
7822
7999
  };
7823
8000
  const correlation = status.pendingCommitCorrelationCount === void 0 ? void 0 : status.pendingCommitCorrelationCount > 0 ? {
7824
8001
  name: "commit-correlation",
@@ -7869,15 +8046,9 @@ async function checkFeedbackCapability() {
7869
8046
  return { name: "feedback-api", status: "warn", detail: message.slice(0, 80) };
7870
8047
  }
7871
8048
  }
7872
- function refreshOwnedGlobalHooksForHealth() {
7873
- try {
7874
- refreshOwnedGlobalHooks();
7875
- } catch {
7876
- }
7877
- }
7878
8049
  async function independentChecks() {
7879
- refreshOwnedGlobalHooksForHealth();
7880
8050
  const backend = await checkBackend();
8051
+ const firing = inspectFiringHere();
7881
8052
  return {
7882
8053
  before: [checkAuth()],
7883
8054
  after: [
@@ -7886,8 +8057,10 @@ async function independentChecks() {
7886
8057
  ...checkAgentHooks(),
7887
8058
  checkHookRuntime(),
7888
8059
  await checkRepositoryBinding(),
7889
- checkManagedHook("post-commit", inspectEffectivePostCommitHook),
7890
- checkManagedHook("post-rewrite", inspectEffectivePostRewriteHook),
8060
+ checkManagedHook("pre-commit"),
8061
+ checkManagedHook("post-commit", firing),
8062
+ checkManagedHook("post-rewrite"),
8063
+ checkHookFiring(firing),
7891
8064
  ...backend,
7892
8065
  await checkFeedbackCapability()
7893
8066
  ]
@@ -7936,7 +8109,7 @@ import { join as join9 } from "path";
7936
8109
 
7937
8110
  // src/flusher.ts
7938
8111
  import { createReadStream, renameSync as renameSync3, statSync as statSync3, unlinkSync as unlinkSync3 } from "fs";
7939
- import { dirname as dirname4, join as join8 } from "path";
8112
+ import { dirname as dirname3, join as join8 } from "path";
7940
8113
 
7941
8114
  // src/dead-letter.ts
7942
8115
  import { createHash as createHash3, randomBytes as randomBytes4 } from "crypto";
@@ -7953,12 +8126,12 @@ import {
7953
8126
  rmSync as rmSync3,
7954
8127
  writeFileSync as writeFileSync3
7955
8128
  } from "fs";
7956
- import { dirname as dirname3, join as join7 } from "path";
8129
+ import { dirname as dirname2, join as join7 } from "path";
7957
8130
  var DIRECTORY_MODE = 448;
7958
8131
  var FILE_MODE = 384;
7959
8132
  var DEAD_LETTER_DIRNAME = "dead-letter";
7960
8133
  function deadLetterDirectoryForRotation(flushingPath) {
7961
- return join7(dirname3(flushingPath), DEAD_LETTER_DIRNAME);
8134
+ return join7(dirname2(flushingPath), DEAD_LETTER_DIRNAME);
7962
8135
  }
7963
8136
  function deadLetterPathForRawLine(flushingPath, rawLine) {
7964
8137
  const quarantineId = createHash3("sha256").update(rawLine).digest("hex");
@@ -7968,7 +8141,7 @@ function ensureDeadLetterDirectory(flushingPath, persistDirectory) {
7968
8141
  const directory = deadLetterDirectoryForRotation(flushingPath);
7969
8142
  mkdirSync4(directory, { recursive: true, mode: DIRECTORY_MODE });
7970
8143
  chmodSync2(directory, DIRECTORY_MODE);
7971
- persistDirectory(dirname3(directory));
8144
+ persistDirectory(dirname2(directory));
7972
8145
  return directory;
7973
8146
  }
7974
8147
  function persistDeadLetter(directory, path, quarantineId, record3, persistDirectory) {
@@ -8387,7 +8560,7 @@ async function flushOnce() {
8387
8560
  return inspection.retainedBuckets.length > 0 ? { flushed: total, quarantined, retained: inspection.retainedBuckets } : { flushed: total, quarantined };
8388
8561
  }
8389
8562
  var flushInFlight;
8390
- var FLUSH_LOCK_PATH = join8(dirname4(JOURNAL_DIR), ".flush.lock");
8563
+ var FLUSH_LOCK_PATH = join8(dirname3(JOURNAL_DIR), ".flush.lock");
8391
8564
  var FLUSH_LOCK_TIMEOUT_MS = 250;
8392
8565
  function isFlushLockContended(error) {
8393
8566
  return error instanceof Error && error.message.startsWith("timed out waiting for file lock");
@@ -8711,7 +8884,7 @@ var SESSION_LABELS = {
8711
8884
  };
8712
8885
  function planSetupSteps(opts) {
8713
8886
  const scopeArgs = opts.scope === "user" ? ["--scope", "user"] : [];
8714
- const sessionArgs = opts.agent === "hermes" ? [opts.agent, "install"] : [opts.agent, "install", ...scopeArgs];
8887
+ const sessionArgs = opts.agent === "hermes" ? [opts.agent, "install"] : [opts.agent, "install", "--scope", opts.scope];
8715
8888
  const steps = [
8716
8889
  {
8717
8890
  key: "session",
@@ -8735,12 +8908,16 @@ function planSetupSteps(opts) {
8735
8908
  required: true
8736
8909
  });
8737
8910
  }
8738
- steps.push({
8739
- key: "hooks",
8740
- label: "Git hooks",
8741
- args: ["hooks", "install", ...scopeArgs],
8742
- required: true
8743
- });
8911
+ if (opts.scope === "project") {
8912
+ steps.push({ key: "hooks", label: "Git hooks", args: ["hooks", "install"], required: true });
8913
+ } else if (opts.globalHooksPath) {
8914
+ steps.push({
8915
+ key: "hooks",
8916
+ label: "Git hooks (global)",
8917
+ args: ["hooks", "install", "--scope", "user", "--global-hooks-path"],
8918
+ required: true
8919
+ });
8920
+ }
8744
8921
  const skillArgs = ["skill", "install", "--agent", opts.agent, ...scopeArgs];
8745
8922
  steps.push({ key: "skill", label: "Agent skill", args: skillArgs, required: true });
8746
8923
  steps.push({
@@ -8771,7 +8948,44 @@ function planCleanupUninstalls(agent, conflicts) {
8771
8948
  if (conflicts.includes(CONFLICT_SKILL)) steps.push(["skill", "uninstall", "--agent", agent]);
8772
8949
  return steps;
8773
8950
  }
8774
- function detectProjectConflicts(agent, run) {
8951
+ function preCommitRunsPrim(content) {
8952
+ return content.includes("# >>> prim pre-commit hook >>>") || content.includes("prim-pre-commit");
8953
+ }
8954
+ function projectHooksConflict(globalHooksActive, preCommit) {
8955
+ return globalHooksActive && preCommit !== void 0 && preCommitRunsPrim(preCommit);
8956
+ }
8957
+ function enableWiresRepository(root) {
8958
+ if (!root) return void 0;
8959
+ try {
8960
+ if (gitHooksMode({ cwd: root }) === "manual") return void 0;
8961
+ return resolveEffectiveGitHook("post-commit", root).location !== "external";
8962
+ } catch {
8963
+ return void 0;
8964
+ }
8965
+ }
8966
+ function setupGitHooksNote(plan, wiresThisRepository) {
8967
+ if (wiresThisRepository === true && plan.action === "add_to_dir") {
8968
+ return `git hooks \xB7 Git runs this repository's hooks from inside it, so \`prim enable\` wires it; other repositories may run ${plan.global}, which prim edits only with --global-hooks-path, after asking the user`;
8969
+ }
8970
+ if (wiresThisRepository === true && plan.action === "system_declined") {
8971
+ return `git hooks \xB7 Git runs this repository's hooks from inside it, so \`prim enable\` wires it; other repositories may run the system hooks dir ${plan.system}, which prim never edits`;
8972
+ }
8973
+ switch (plan.action) {
8974
+ case "refresh":
8975
+ return "git hooks \xB7 prim's global hooks stay active; `prim enable` refreshes them";
8976
+ case "present_in_dir":
8977
+ return `git hooks \xB7 prim's hooks are already in your global hooks dir ${plan.global}`;
8978
+ case "add_to_dir":
8979
+ return `git hooks \xB7 your global core.hooksPath (${plan.global}) runs every repository's hooks, so \`prim enable\` cannot wire this repository without it; add prim there with --global-hooks-path, after asking the user`;
8980
+ case "system_declined":
8981
+ return `git hooks \xB7 the system core.hooksPath (${plan.system}) runs every repository's hooks, so \`prim enable\` cannot wire this repository: place \`prim hooks snippet <hook>\` there, or, after asking the user, run \`prim hooks install --scope user --global-hooks-path --force\` (its hooks stop firing)`;
8982
+ case "manual":
8983
+ return "git hooks \xB7 prim.gitHooks=manual: prim writes no hook files; wire them with `prim hooks snippet <hook>`";
8984
+ default:
8985
+ return "git hooks \xB7 wired per repository by `prim enable`; git's global hooks are untouched (opt in with --global-hooks-path)";
8986
+ }
8987
+ }
8988
+ function detectProjectConflicts(agent, run, globalHooksActive) {
8775
8989
  const conflicts = [];
8776
8990
  if (agent !== "hermes") {
8777
8991
  try {
@@ -8780,14 +8994,14 @@ function detectProjectConflicts(agent, run) {
8780
8994
  } catch {
8781
8995
  }
8782
8996
  }
8997
+ let preCommit;
8783
8998
  try {
8784
8999
  const root = gitToplevel();
8785
- const preCommit = root && join11(root, ".git", "hooks", "pre-commit");
8786
- if (preCommit && existsSync10(preCommit) && readFileSync7(preCommit, "utf-8").includes("prim-pre-commit")) {
8787
- conflicts.push(CONFLICT_HOOKS);
8788
- }
9000
+ const path = root && join11(root, ".git", "hooks", "pre-commit");
9001
+ if (path && existsSync10(path)) preCommit = readFileSync7(path, "utf-8");
8789
9002
  } catch {
8790
9003
  }
9004
+ if (projectHooksConflict(globalHooksActive, preCommit)) conflicts.push(CONFLICT_HOOKS);
8791
9005
  try {
8792
9006
  const parsed = JSON.parse(
8793
9007
  run(["skill", "status", "--agent", agent, "--json"], true).stdout || "{}"
@@ -8827,8 +9041,11 @@ function registerSetupCommand(program2, dependencies = {}) {
8827
9041
  "Install everything in one shot (auth, session + git hooks, daemon, skill, welcome)"
8828
9042
  ).option("--agent <agent>", "claude, codex, cursor, or hermes (auto-detected when omitted)").option(
8829
9043
  "--scope <scope>",
8830
- "user (default \u2014 install once for every repo) or project (this repo only)",
9044
+ "user (default \u2014 agent integration and skill for every repo; git hooks per repo via enable) or project (this repo only)",
8831
9045
  "user"
9046
+ ).option(
9047
+ "--global-hooks-path",
9048
+ "with the default user scope, also change git's global hooks so prim's hooks run in every repository"
8832
9049
  ).option(
8833
9050
  "--migrate",
8834
9051
  "with the default user scope, remove any project-scoped prim config in this repo (else just warn)"
@@ -8848,6 +9065,11 @@ function registerSetupCommand(program2, dependencies = {}) {
8848
9065
  (dependencies.exit ?? process.exit)(EXIT_USAGE3);
8849
9066
  return;
8850
9067
  }
9068
+ if (opts.globalHooksPath && opts.scope !== "user") {
9069
+ process.stderr.write("[prim] --global-hooks-path applies only with --scope user\n");
9070
+ (dependencies.exit ?? process.exit)(EXIT_USAGE3);
9071
+ return;
9072
+ }
8851
9073
  const agent = agentInput;
8852
9074
  const scope = opts.scope;
8853
9075
  const self = process.argv[1];
@@ -8899,16 +9121,24 @@ function registerSetupCommand(program2, dependencies = {}) {
8899
9121
  note("pre-authorize \xB7 writing prim allow-rule (user scope)\u2026");
8900
9122
  results.preauth = run(["claude", "preauth", "--scope", "user"]).code === 0 ? "ok" : "skipped";
8901
9123
  }
8902
- const setupSteps = planSetupSteps({ agent, daemon: opts.daemon, scope });
9124
+ const setupSteps = planSetupSteps({
9125
+ agent,
9126
+ daemon: opts.daemon,
9127
+ scope,
9128
+ globalHooksPath: opts.globalHooksPath === true
9129
+ });
8903
9130
  for (const step of setupSteps.filter(
8904
9131
  (candidate) => candidate.key !== "enable" && candidate.key !== "health"
8905
9132
  )) {
8906
9133
  note(`${step.label} \xB7 installing\u2026`);
8907
9134
  const { code } = run(step.args);
8908
- results[step.key] = code === 0 ? "ok" : step.required ? "failed" : "skipped";
9135
+ results[step.key] = code === 0 ? "ok" : code === EXIT_GLOBAL_HOOKS_NOT_INSTALLED && step.key === "hooks" ? "skipped" : step.required ? "failed" : "skipped";
9136
+ }
9137
+ if (scope === "user" && !opts.globalHooksPath) {
9138
+ note(setupGitHooksNote(planGlobalHooks(), enableWiresRepository(gitToplevel())));
8909
9139
  }
8910
9140
  if (scope === "user") {
8911
- const conflicts = detectProjectConflicts(agent, run);
9141
+ const conflicts = detectProjectConflicts(agent, run, globalHooksPathIsPrims());
8912
9142
  if (conflicts.length > 0 && opts.migrate) {
8913
9143
  note(`migrate \xB7 removing project-scoped config (${conflicts.join(", ")})\u2026`);
8914
9144
  const ok = planCleanupUninstalls(agent, conflicts).map((args) => run(args).code === 0).every(Boolean);
@@ -8949,12 +9179,12 @@ function registerSetupCommand(program2, dependencies = {}) {
8949
9179
 
8950
9180
  // src/commands/statusline.ts
8951
9181
  import { readFileSync as readFileSync8 } from "fs";
8952
- import { dirname as dirname5, resolve as resolve3 } from "path";
9182
+ import { dirname as dirname4, resolve as resolve3 } from "path";
8953
9183
  import { fileURLToPath } from "url";
8954
9184
  var STATUSLINE_TIMEOUT_MS = 200;
8955
9185
  function readPackageVersion() {
8956
9186
  try {
8957
- const here = dirname5(fileURLToPath(import.meta.url));
9187
+ const here = dirname4(fileURLToPath(import.meta.url));
8958
9188
  const candidates = [
8959
9189
  // Schema-v2 runtimes staged a manifest beside this standalone bundle.
8960
9190
  // Keep resolving it while those legacy launchers transition to schema v3.
@@ -9438,14 +9668,17 @@ function startDriftHeal(options) {
9438
9668
  if (!runsConfigRoot()) return false;
9439
9669
  const primEntry = options.primEntry === void 0 ? binFile("prim") : options.primEntry;
9440
9670
  if (!primEntry) return false;
9441
- const attempt = {
9442
- attemptedAt: (options.nowMs ?? Date.now)(),
9443
- fromVersion: launcher.runtimeVersion,
9444
- toVersion
9445
- };
9446
9671
  const claimed = withFileLockSync(
9447
9672
  daemonDriftHealClaimPath(paths),
9448
- () => claimAttempt(daemonDriftHealMarkerPath(paths), attempt),
9673
+ () => (
9674
+ // Sample after acquiring the claim: an earlier timestamp from a delayed
9675
+ // command must not make the previous winner look like a clock rollback.
9676
+ claimAttempt(daemonDriftHealMarkerPath(paths), {
9677
+ attemptedAt: (options.nowMs ?? Date.now)(),
9678
+ fromVersion: launcher.runtimeVersion,
9679
+ toVersion
9680
+ })
9681
+ ),
9449
9682
  { timeoutMs: 0, sleep: claimHeld }
9450
9683
  );
9451
9684
  if (!claimed) return false;
@@ -9470,7 +9703,7 @@ import { readFileSync as readFileSync9 } from "fs";
9470
9703
 
9471
9704
  // src/daemon/health.ts
9472
9705
  import { chmodSync as chmodSync3, existsSync as existsSync11, mkdirSync as mkdirSync7, renameSync as renameSync4, writeFileSync as writeFileSync6 } from "fs";
9473
- import { dirname as dirname6, join as join12 } from "path";
9706
+ import { dirname as dirname5, join as join12 } from "path";
9474
9707
  var HEARTBEAT_FRESH_MS = 9e4;
9475
9708
  var DAEMON_HEALTH_PATH = join12(primConfigDirectory(), "daemon-health.json");
9476
9709
 
@@ -9599,7 +9832,7 @@ function runStartupBackgroundWork(argv, env, work) {
9599
9832
  }
9600
9833
 
9601
9834
  // src/index.ts
9602
- var __dirname = dirname7(fileURLToPath2(import.meta.url));
9835
+ var __dirname = dirname6(fileURLToPath2(import.meta.url));
9603
9836
  var pkg = JSON.parse(readFileSync10(resolve4(__dirname, "../package.json"), "utf-8"));
9604
9837
  runStartupBackgroundWork(process.argv.slice(2), process.env, {
9605
9838
  notify: () => updateNotifier({ pkg }).notify(),