@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
package/dist/index.js CHANGED
@@ -2,57 +2,76 @@
2
2
  import {
3
3
  bindRepository,
4
4
  bindRepositoryWithClient,
5
- ensureEffectivePostCommitHook,
6
- ensureEffectivePostRewriteHook,
7
- ensurePostCommitHookAtPath,
8
- ensurePostRewriteHookAtPath,
9
- inspectEffectivePostCommitHook,
10
- inspectEffectivePostRewriteHook,
11
- postCommitHookBlock,
12
- postRewriteHookBlock,
5
+ ensureEffectiveGitHook,
6
+ ensureGitHookAtPath,
7
+ externalHookRemedy,
8
+ gitHooksMode,
9
+ hasCurrentHookBlock,
10
+ inspectEffectiveGitHook,
11
+ isLegacyOwnedGlobalHook,
12
+ isOwnedStandalonePreCommit,
13
+ kickDaemonEnsure,
13
14
  printJson,
15
+ projectGitHookTarget,
16
+ projectHooksDir,
14
17
  registerSkillCommands,
18
+ resolveEffectiveGitHook,
15
19
  resolveRepositoryBinding,
16
- uninstallPostCommitHookAtPath,
17
- uninstallPostRewriteHookAtPath,
18
- uninstallProjectPostCommitHook,
19
- uninstallProjectPostRewriteHook
20
- } from "./chunk-LGGMJYY4.js";
20
+ uninstallGitHookAtPath,
21
+ uninstallProjectGitHook
22
+ } from "./chunk-WUXFLE7X.js";
23
+ import {
24
+ POST_COMMIT_GRACE_MS,
25
+ clearHooksWired,
26
+ inspectPostCommitFiring,
27
+ recordHooksWired
28
+ } from "./chunk-FXF4JWK2.js";
21
29
  import {
22
30
  requireDurableIngestAcknowledgement
23
- } from "./chunk-FKXFUUWJ.js";
31
+ } from "./chunk-TYAHC25F.js";
24
32
  import {
25
33
  checkAffectedDecisions,
26
34
  daemonOrDirectGet,
27
35
  formatDecisionsWarning
28
- } from "./chunk-5GYQ4J3D.js";
36
+ } from "./chunk-UKUAAAXE.js";
29
37
  import {
30
38
  fetchFeedbackCapability
31
- } from "./chunk-5AFRMOPX.js";
39
+ } from "./chunk-OH3EZXYX.js";
40
+ import {
41
+ isUnattended,
42
+ unattendedEnv
43
+ } from "./chunk-OYHYCK4K.js";
32
44
  import {
33
45
  JOURNAL_DIR,
34
46
  SESSIONS_DIR,
35
47
  bucketStats,
48
+ drainResumeOffset,
49
+ envSlug,
36
50
  listBuckets,
37
51
  listFlushing,
38
52
  pendingJournalStats,
39
- readMovesFromPath
40
- } from "./chunk-FBAYCS3X.js";
53
+ readMovesFromPath,
54
+ removeDrainCheckpoint,
55
+ rotationIdentity,
56
+ sweepOrphanedDrainProgress,
57
+ writeDrainCheckpoint
58
+ } from "./chunk-DXJQR4QI.js";
41
59
  import {
60
+ MS_PER_SECOND,
61
+ deliveryBacklogState,
62
+ deliveryBacklogSummary,
63
+ formatPendingBacklog,
42
64
  formatStatusline,
43
65
  formatTeammates,
44
66
  repositoryBindingDiagnosticLabel
45
- } from "./chunk-WX52U7Z2.js";
67
+ } from "./chunk-ZU563PQI.js";
46
68
  import {
47
69
  bold,
48
70
  boundedHealthError,
49
71
  color,
50
72
  colorForArea,
51
- dim,
52
- stripAnsi
53
- } from "./chunk-BTNNEH2W.js";
54
- import {
55
73
  decisionIngestionStatus,
74
+ dim,
56
75
  fetchAndCacheCollectScope,
57
76
  isCliErrorResponse,
58
77
  isDecisionIdRequest,
@@ -66,10 +85,12 @@ import {
66
85
  isMoveIngestRequest,
67
86
  isRepoActiveForCapture,
68
87
  isValidRepoSyncId,
88
+ repoActiveFlag,
69
89
  repoSyncId,
70
90
  repositoryBindingState,
71
- setRepoActive
72
- } from "./chunk-M6RIJVRE.js";
91
+ setRepoActive,
92
+ stripAnsi
93
+ } from "./chunk-DSWT6BCC.js";
73
94
  import {
74
95
  CliManagementResponseError,
75
96
  HttpError,
@@ -79,14 +100,16 @@ import {
79
100
  getPinnedClient,
80
101
  getPinnedManagementClient,
81
102
  getSiteUrl,
103
+ getSiteUrlForEnvironment,
82
104
  getTokenExpiresAt,
83
105
  isSessionEnded,
84
106
  refreshToken,
85
107
  setStoredToken,
86
108
  stripControlChars,
87
109
  terminalSafeLine
88
- } from "./chunk-PCVGBE5B.js";
110
+ } from "./chunk-G2VMA2QO.js";
89
111
  import {
112
+ MANAGED_GIT_HOOK_NAMES,
90
113
  assertOwnedHookRuntime,
91
114
  atomicWrite,
92
115
  binFile,
@@ -96,14 +119,16 @@ import {
96
119
  entryHasCommand,
97
120
  hookCommandResolutions,
98
121
  hookRuntimePaths,
122
+ inspectGitHookEntrypoint,
99
123
  inspectHookRuntime,
100
124
  inspectHookRuntimeResolutions,
101
125
  inspectHookRuntimeResolutions2,
102
126
  makeRegistration,
127
+ managedHookBlock,
128
+ packageRoot,
103
129
  packageVersion,
104
130
  performStatus,
105
131
  performStatus2,
106
- pinnedHookCommand,
107
132
  pinnedNpxArgs,
108
133
  projectRoot,
109
134
  readSettings,
@@ -111,9 +136,10 @@ import {
111
136
  registerCursorCommands,
112
137
  removeHookRuntime,
113
138
  stableHookCommand,
139
+ stableNodePath,
114
140
  stageHookRuntime,
115
141
  stripCommand
116
- } from "./chunk-26P57LAR.js";
142
+ } from "./chunk-AXQQMX2U.js";
117
143
  import {
118
144
  canonicalGitRoot,
119
145
  canonicalRepositoryPath,
@@ -122,7 +148,7 @@ import {
122
148
  import {
123
149
  daemonIsLive,
124
150
  daemonRequest
125
- } from "./chunk-KFXF2V5G.js";
151
+ } from "./chunk-OTHKG76J.js";
126
152
  import {
127
153
  REFRESH_TOKEN_PATH,
128
154
  TOKEN_FILE_PATH,
@@ -135,11 +161,12 @@ import {
135
161
  requestWorkosDeviceAuthorization,
136
162
  resolveAuthCredential,
137
163
  syncDirectory,
138
- withFileLock
139
- } from "./chunk-67VNLLBN.js";
164
+ withFileLock,
165
+ withFileLockSync
166
+ } from "./chunk-2GP5UFV7.js";
140
167
 
141
168
  // src/index.ts
142
- import { readFileSync as readFileSync9 } from "fs";
169
+ import { readFileSync as readFileSync10 } from "fs";
143
170
  import { dirname as dirname6, resolve as resolve4 } from "path";
144
171
  import { fileURLToPath as fileURLToPath2 } from "url";
145
172
  import { Command } from "commander";
@@ -749,18 +776,18 @@ function parseAuthBrokerConfig(value) {
749
776
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
750
777
  throw new Error("broker config must be an object");
751
778
  }
752
- const record2 = value;
753
- if (typeof record2.authorization_server !== "string" || !record2.authorization_server.trim()) {
779
+ const record3 = value;
780
+ if (typeof record3.authorization_server !== "string" || !record3.authorization_server.trim()) {
754
781
  throw new Error("authorization_server is required");
755
782
  }
756
- if (typeof record2.client_id !== "string" || !record2.client_id.trim()) {
783
+ if (typeof record3.client_id !== "string" || !record3.client_id.trim()) {
757
784
  throw new Error("client_id is required");
758
785
  }
759
- if (!Array.isArray(record2.default_scopes) || !record2.default_scopes.every((scope) => typeof scope === "string" && scope.length > 0)) {
786
+ if (!Array.isArray(record3.default_scopes) || !record3.default_scopes.every((scope) => typeof scope === "string" && scope.length > 0)) {
760
787
  throw new Error("default_scopes must be a string array");
761
788
  }
762
- const authorizationServer = parseHttpUrl(record2.authorization_server, "authorization_server");
763
- const rawEndpoint = record2.authorization_endpoint === void 0 ? DEFAULT_WORKOS_AUTHORIZE_URL : record2.authorization_endpoint;
789
+ const authorizationServer = parseHttpUrl(record3.authorization_server, "authorization_server");
790
+ const rawEndpoint = record3.authorization_endpoint === void 0 ? DEFAULT_WORKOS_AUTHORIZE_URL : record3.authorization_endpoint;
764
791
  if (typeof rawEndpoint !== "string" || !rawEndpoint.trim()) {
765
792
  throw new Error("authorization_endpoint must be a URL");
766
793
  }
@@ -771,8 +798,8 @@ function parseAuthBrokerConfig(value) {
771
798
  return {
772
799
  authorization_server: authorizationServer.toString(),
773
800
  authorization_endpoint: authorizationEndpoint.toString(),
774
- client_id: record2.client_id.trim(),
775
- default_scopes: [...record2.default_scopes]
801
+ client_id: record3.client_id.trim(),
802
+ default_scopes: [...record3.default_scopes]
776
803
  };
777
804
  }
778
805
  async function fetchAuthBrokerConfig(siteUrl, fetchImpl = fetch) {
@@ -1335,8 +1362,8 @@ import {
1335
1362
  writeFileSync
1336
1363
  } from "fs";
1337
1364
  import { homedir } from "os";
1338
- import { dirname, join, resolve } from "path";
1339
- import { Option } from "commander";
1365
+ import { join, resolve } from "path";
1366
+ import { Argument, Option } from "commander";
1340
1367
  var PRE_COMMIT = { hookName: "pre-commit", binName: "prim-pre-commit" };
1341
1368
  var POST_COMMIT = { hookName: "post-commit", binName: "prim-post-commit" };
1342
1369
  var POST_REWRITE = { hookName: "post-rewrite", binName: "prim-post-rewrite" };
@@ -1352,84 +1379,15 @@ var PRIM_BLOCK_START = blockMarkers(PRE_COMMIT).start;
1352
1379
  var PRIM_BLOCK_END = blockMarkers(PRE_COMMIT).end;
1353
1380
  var PRIM_MANAGED_MARK = "prim-managed-hook";
1354
1381
  var PRIM_CREATED_MARK = "prim-created-hook";
1355
- function hookShim(binName) {
1356
- return `{ ${pinnedHookCommand(binName)}; } || true`;
1357
- }
1358
- function gatedShim(binName) {
1359
- return `if [ "$(git config --get prim.active 2>/dev/null)" = "true" ]; then
1360
- ${hookShim(binName)}
1361
- fi`;
1362
- }
1363
- function dotGitScript(spec) {
1364
- return `#!/bin/sh
1365
- # prim ${spec.hookName} hook \u2014 installed by: prim hooks install (${PRIM_MANAGED_MARK})
1366
-
1367
- ${hookShim(spec.binName)}
1368
- `;
1369
- }
1370
- function isOwnedStandalonePreCommit(content) {
1371
- if (content === "#!/bin/sh\nprim-pre-commit\n") return true;
1372
- const prefix = `#!/bin/sh
1373
- # prim pre-commit hook \u2014 installed by: prim hooks install (${PRIM_MANAGED_MARK})
1374
-
1375
- `;
1376
- const suffix = "; } || true\n";
1377
- if (!content.startsWith(prefix) || !content.endsWith(suffix)) return false;
1378
- const body = content.slice(prefix.length, -suffix.length);
1379
- if (!body.startsWith("{ ") || /[\r\n]/u.test(body)) return false;
1380
- return commandMatchesBin(body.slice(2), PRE_COMMIT.binName);
1381
- }
1382
- function huskyBlock(spec) {
1383
- if (spec.hookName === POST_COMMIT.hookName) return postCommitHookBlock();
1384
- if (spec.hookName === POST_REWRITE.hookName) return postRewriteHookBlock();
1385
- const { start, end } = blockMarkers(spec);
1386
- return `${start}
1387
- ${hookShim(spec.binName)}
1388
- ${end}`;
1389
- }
1390
- function gatedBlock(spec) {
1391
- if (spec.hookName === POST_COMMIT.hookName) return postCommitHookBlock();
1392
- if (spec.hookName === POST_REWRITE.hookName) return postRewriteHookBlock();
1393
- const { start, end } = blockMarkers(spec);
1394
- return `${start}
1395
- ${gatedShim(spec.binName)}
1396
- ${end}`;
1397
- }
1398
- function primBlockRange(existing, spec) {
1399
- const { start, end } = blockMarkers(spec);
1400
- const starts = existing.split(start).length - 1;
1401
- const ends = existing.split(end).length - 1;
1402
- if (starts === 0 && ends === 0) return null;
1403
- const from = existing.indexOf(start);
1404
- const endAt = existing.indexOf(end);
1405
- if (starts !== 1 || ends !== 1 || endAt < from) {
1406
- throw new Error(`malformed Prim hook markers for ${spec.hookName}`);
1407
- }
1408
- return { start: from, through: endAt + end.length };
1409
- }
1410
- function mergePrimBlock(hookPath, block, spec) {
1411
- if (existsSync2(hookPath)) {
1412
- const existing = readFileSync2(hookPath, "utf-8");
1413
- const range = primBlockRange(existing, spec);
1414
- if (range) {
1415
- const refreshed = existing.slice(0, range.start) + block + existing.slice(range.through);
1416
- if (refreshed === existing) return false;
1417
- writeFileSync(hookPath, refreshed, { mode: 493 });
1418
- return true;
1419
- }
1420
- if (containsPrimHook(existing, spec.binName)) return false;
1421
- const separator = existing.endsWith("\n") ? "\n" : "\n\n";
1422
- writeFileSync(hookPath, `${existing}${separator}${block}
1423
- `, { mode: 493 });
1382
+ function stageGitHookRuntime() {
1383
+ try {
1384
+ stageHookRuntime();
1424
1385
  return true;
1386
+ } catch (error) {
1387
+ const detail = error instanceof Error ? error.message : String(error);
1388
+ console.error(`[prim] git hooks stay inert until the hook runtime is staged: ${detail}`);
1389
+ return false;
1425
1390
  }
1426
- mkdirSync(dirname(hookPath), { recursive: true });
1427
- writeFileSync(hookPath, `#!/bin/sh
1428
- # ${PRIM_CREATED_MARK}
1429
-
1430
- ${block}
1431
- `, { mode: 493 });
1432
- return true;
1433
1391
  }
1434
1392
  function getGitRoot() {
1435
1393
  const root = gitToplevel();
@@ -1456,52 +1414,6 @@ function detectHusky(gitRoot) {
1456
1414
  }
1457
1415
  return false;
1458
1416
  }
1459
- function containsPrimHook(content, binName = PRE_COMMIT.binName) {
1460
- return content.includes(binName);
1461
- }
1462
- function installToHusky(gitRoot, spec = PRE_COMMIT) {
1463
- const hookPath = resolve(gitRoot, ".husky", spec.hookName);
1464
- const existed = existsSync2(hookPath);
1465
- const wrote = mergePrimBlock(hookPath, huskyBlock(spec), spec);
1466
- if (!wrote) {
1467
- console.log(`Prim ${spec.hookName} hook is already installed in .husky/${spec.hookName}.`);
1468
- } else if (existed) {
1469
- console.log(`Appended prim hook block to .husky/${spec.hookName}.`);
1470
- } else {
1471
- console.log(`Created .husky/${spec.hookName} with prim hook block.`);
1472
- }
1473
- }
1474
- function installToDotGit(gitRoot, spec = PRE_COMMIT) {
1475
- const hooksDir = projectHooksDir(gitRoot);
1476
- const hookPath = resolve(hooksDir, spec.hookName);
1477
- if (!existsSync2(hooksDir)) {
1478
- mkdirSync(hooksDir, { recursive: true });
1479
- }
1480
- if (existsSync2(hookPath)) {
1481
- const existing = readFileSync2(hookPath, "utf-8");
1482
- if (containsPrimHook(existing, spec.binName)) {
1483
- console.log(`Prim ${spec.hookName} hook is already installed at ${hookPath}.`);
1484
- return;
1485
- }
1486
- console.log(`A ${spec.hookName} hook already exists at ${hookPath}.`);
1487
- console.log("To replace it, run: prim hooks uninstall && prim hooks install");
1488
- return;
1489
- }
1490
- writeFileSync(hookPath, dotGitScript(spec), { mode: 493 });
1491
- console.log(`Installed ${spec.hookName} hook at ${hookPath}`);
1492
- }
1493
- function projectHooksDir(gitRoot) {
1494
- const commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
1495
- cwd: gitRoot,
1496
- encoding: "utf-8",
1497
- stdio: ["ignore", "pipe", "ignore"],
1498
- timeout: GIT_TIMEOUT_MS
1499
- }).trim();
1500
- if (commonDir === "") {
1501
- throw new Error("git returned an empty common directory");
1502
- }
1503
- return resolve(gitRoot, commonDir, "hooks");
1504
- }
1505
1417
  var PRIM_GIT_HOOKS_DIR = join(primConfigDirectory(), "git-hooks");
1506
1418
  function expandTilde(p) {
1507
1419
  return p.startsWith("~/") ? join(homedir(), p.slice(2)) : p;
@@ -1540,21 +1452,17 @@ var PASSTHROUGH_HOOKS = [
1540
1452
  // with a query protocol, not a lifecycle event, so a bare exec stub is wrong.)
1541
1453
  ];
1542
1454
  function globalHookScript(spec) {
1543
- const chainExit = spec.hookName === PRE_COMMIT.hookName ? "|| exit $?" : "|| true";
1544
- const managedBlock = spec.hookName === POST_COMMIT.hookName ? postCommitHookBlock() : spec.hookName === POST_REWRITE.hookName ? postRewriteHookBlock() : void 0;
1545
- const invocation = managedBlock ?? gatedShim(spec.binName);
1546
- const managedRepoGuard = managedBlock ? ` && ! grep -Fq '${blockMarkers(spec).start.slice(0, -3)}'">>>" "$repo_hook" 2>/dev/null` : "";
1547
- const beforeComments = managedBlock ? `${invocation}
1548
- ` : "";
1549
- const afterComments = managedBlock ? "" : `${invocation}
1550
- `;
1455
+ const preCommit = spec.hookName === PRE_COMMIT.hookName;
1456
+ const chainExit = preCommit ? "|| exit $?" : "|| true";
1457
+ const managedRepoGuard = preCommit ? "" : ` && ! grep -Fq '${blockMarkers(spec).start.slice(0, -3)}'">>>" "$repo_hook" 2>/dev/null`;
1551
1458
  return `#!/bin/sh
1552
- ${beforeComments}# prim global ${spec.hookName} hook (core.hooksPath) \u2014 managed by prim; do not edit.
1459
+ ${managedHookBlock(spec.hookName)}
1460
+ # prim global ${spec.hookName} hook (core.hooksPath) \u2014 managed by prim; do not edit.
1553
1461
  # Install/uninstall: prim hooks install|uninstall --scope user
1554
1462
  # Runs prim only where activated \u2014 'prim enable' (this repo) or
1555
1463
  # 'git config --global prim.active true' (every repo). Chains to the repo's own
1556
1464
  # hook regardless, so inactive repos are unaffected.
1557
- ${afterComments}common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || exit 0
1465
+ common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || exit 0
1558
1466
  repo_hook="$common_dir/hooks/${spec.hookName}"
1559
1467
  if [ -x "$repo_hook" ] && ! grep -q '${PRIM_MANAGED_MARK}' "$repo_hook" 2>/dev/null${managedRepoGuard}; then
1560
1468
  "$repo_hook" "$@" ${chainExit}
@@ -1579,30 +1487,12 @@ function expectedOwnedHookContent(hookName) {
1579
1487
  }
1580
1488
  return null;
1581
1489
  }
1582
- var PINNED_PACKAGE_VERSION_RE = /@primitive\.ai\/prim@[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/gu;
1583
- function normalizeOwnedGlobalHook(content, hookName) {
1584
- if (hookName === PRE_COMMIT.hookName) {
1585
- const lines = content.split("\n");
1586
- const invocationIndexes = lines.flatMap(
1587
- (line2, index) => line2.startsWith("{ ") && line2.endsWith("; } || true") ? [index] : []
1588
- );
1589
- if (invocationIndexes.length !== 1) return null;
1590
- const invocationIndex = invocationIndexes[0];
1591
- const line = lines[invocationIndex];
1592
- const command = line.slice(2, -"; } || true".length);
1593
- if (!commandMatchesBin(command, PRE_COMMIT.binName)) return null;
1594
- lines[invocationIndex] = "{ <recognized Prim pre-commit invocation>; } || true";
1595
- return lines.join("\n");
1596
- }
1597
- return content.replace(PINNED_PACKAGE_VERSION_RE, "@primitive.ai/prim@<version>");
1598
- }
1599
1490
  function isExpectedOwnedGlobalHook(content, hookName) {
1600
1491
  const expected = expectedOwnedHookContent(hookName);
1601
1492
  if (expected === null) return false;
1602
1493
  if (content === expected) return true;
1603
- const normalized = normalizeOwnedGlobalHook(content, hookName);
1604
- const normalizedExpected = normalizeOwnedGlobalHook(expected, hookName);
1605
- return normalized !== null && normalized === normalizedExpected;
1494
+ const managed = HOOKS.find((spec) => spec.hookName === hookName);
1495
+ return managed !== void 0 && isLegacyOwnedGlobalHook(content, managed.hookName);
1606
1496
  }
1607
1497
  function lstatIfPresent(path) {
1608
1498
  try {
@@ -1637,6 +1527,12 @@ function assertOwnedHooksDirectorySafeToRemove() {
1637
1527
  return entries;
1638
1528
  }
1639
1529
  function writeOwnHooks() {
1530
+ if (inspectGitHookEntrypoint() !== "ready") {
1531
+ console.error(
1532
+ `[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.`
1533
+ );
1534
+ return false;
1535
+ }
1640
1536
  if (!existsSync2(PRIM_GIT_HOOKS_DIR)) {
1641
1537
  mkdirSync(PRIM_GIT_HOOKS_DIR, { recursive: true });
1642
1538
  }
@@ -1648,47 +1544,17 @@ function writeOwnHooks() {
1648
1544
  for (const name of PASSTHROUGH_HOOKS) {
1649
1545
  writeFileSync(resolve(PRIM_GIT_HOOKS_DIR, name), passThroughScript(name), { mode: 493 });
1650
1546
  }
1547
+ return true;
1651
1548
  }
1652
1549
  function refreshOwnedGlobalHooks() {
1653
1550
  if (!isOurHooksDir(gitConfigGet("--global"))) return false;
1654
- writeOwnHooks();
1655
- return true;
1551
+ return writeOwnHooks();
1656
1552
  }
1657
1553
  function appendPrimBlock(hookPath, spec) {
1658
- if (spec.hookName === POST_COMMIT.hookName) {
1659
- ensurePostCommitHookAtPath(hookPath);
1660
- return;
1661
- }
1662
- if (spec.hookName === POST_REWRITE.hookName) {
1663
- ensurePostRewriteHookAtPath(hookPath);
1664
- return;
1665
- }
1666
- mergePrimBlock(hookPath, gatedBlock(spec), spec);
1554
+ ensureGitHookAtPath(spec.hookName, hookPath);
1667
1555
  }
1668
1556
  function stripPrimBlock(hookPath, spec) {
1669
- if (spec.hookName === POST_COMMIT.hookName) {
1670
- uninstallPostCommitHookAtPath(hookPath);
1671
- return;
1672
- }
1673
- if (spec.hookName === POST_REWRITE.hookName) {
1674
- uninstallPostRewriteHookAtPath(hookPath);
1675
- return;
1676
- }
1677
- if (!existsSync2(hookPath)) return;
1678
- const existing = readFileSync2(hookPath, "utf-8");
1679
- const primCreated = existing.includes(PRIM_CREATED_MARK);
1680
- const range = primBlockRange(existing, spec);
1681
- if (!range) return;
1682
- const out = (existing.slice(0, range.start) + existing.slice(range.through)).replace(
1683
- /\n{2,}$/,
1684
- "\n"
1685
- );
1686
- const remainder = out.replaceAll("#!/bin/sh", "").replaceAll(`# ${PRIM_CREATED_MARK}`, "").trim();
1687
- if (primCreated && remainder === "") {
1688
- unlinkSync(hookPath);
1689
- return;
1690
- }
1691
- writeFileSync(hookPath, out, { mode: 493 });
1557
+ uninstallGitHookAtPath(spec.hookName, hookPath);
1692
1558
  }
1693
1559
  function planPreCommitRemoval(hookPath, options) {
1694
1560
  if (!existsSync2(hookPath)) return { path: hookPath, action: "missing" };
@@ -1696,7 +1562,8 @@ function planPreCommitRemoval(hookPath, options) {
1696
1562
  if (options.allowOwnedStandalone && isOwnedStandalonePreCommit(existing)) {
1697
1563
  return { path: hookPath, action: "remove-owned-file" };
1698
1564
  }
1699
- if (primBlockRange(existing, PRE_COMMIT)) {
1565
+ const { start, end } = blockMarkers(PRE_COMMIT);
1566
+ if (existing.includes(start) || existing.includes(end)) {
1700
1567
  return { path: hookPath, action: "strip-block" };
1701
1568
  }
1702
1569
  if (existing.includes(PRE_COMMIT.binName) || existing.includes(PRIM_MANAGED_MARK) || existing.includes(PRIM_CREATED_MARK)) {
@@ -1706,79 +1573,172 @@ function planPreCommitRemoval(hookPath, options) {
1706
1573
  }
1707
1574
  return { path: hookPath, action: "leave-foreign" };
1708
1575
  }
1709
- function applyPreCommitRemoval(plan) {
1710
- if (plan.action === "remove-owned-file") {
1711
- unlinkSync(plan.path);
1712
- console.log(`Removed pre-commit hook at ${plan.path}`);
1713
- } else if (plan.action === "strip-block") {
1714
- stripPrimBlock(plan.path, PRE_COMMIT);
1715
- console.log(`Removed the Prim pre-commit block from ${plan.path}.`);
1716
- } else if (plan.action === "leave-foreign") {
1576
+ function applyPreCommitRemoval(plan, husky) {
1577
+ if (plan.action === "missing") return;
1578
+ if (plan.action === "leave-foreign") {
1717
1579
  console.log(`Left pre-commit hook at ${plan.path} untouched (not a Prim hook).`);
1580
+ return;
1718
1581
  }
1582
+ const result = uninstallGitHookAtPath(PRE_COMMIT.hookName, plan.path, { husky });
1583
+ console.log(
1584
+ result.removedFile ? `Removed pre-commit hook at ${plan.path}` : `Removed the Prim pre-commit block from ${plan.path}.`
1585
+ );
1719
1586
  }
1720
1587
  function uninstallProjectHooks(gitRoot) {
1721
- const plans = [
1722
- planPreCommitRemoval(resolve(projectHooksDir(gitRoot), PRE_COMMIT.hookName), {
1588
+ const configured = projectGitHookTarget(PRE_COMMIT.hookName, gitRoot);
1589
+ const destinations = [
1590
+ {
1591
+ path: resolve(projectHooksDir(gitRoot), PRE_COMMIT.hookName),
1592
+ husky: false,
1723
1593
  allowOwnedStandalone: true
1724
- }),
1725
- planPreCommitRemoval(resolve(gitRoot, ".husky", PRE_COMMIT.hookName), {
1594
+ },
1595
+ {
1596
+ path: resolve(gitRoot, ".husky", PRE_COMMIT.hookName),
1597
+ husky: true,
1726
1598
  allowOwnedStandalone: false
1727
- })
1728
- ];
1729
- for (const plan of plans) applyPreCommitRemoval(plan);
1730
- for (const [hookName, uninstall] of [
1731
- [POST_COMMIT.hookName, uninstallProjectPostCommitHook],
1732
- [POST_REWRITE.hookName, uninstallProjectPostRewriteHook]
1733
- ]) {
1734
- const result = uninstall(gitRoot);
1735
- if (!result.changed) {
1736
- console.log(`No Prim ${hookName} block found at ${result.path}.`);
1599
+ },
1600
+ // A shared hooks dir is never stripped on this repository's behalf.
1601
+ ...configured.location === "external" ? [] : [
1602
+ {
1603
+ path: configured.hookPath,
1604
+ husky: configured.kind === "husky_v9",
1605
+ allowOwnedStandalone: false
1606
+ }
1607
+ ]
1608
+ ].filter(
1609
+ (destination, index, all) => all.findIndex((other) => other.path === destination.path) === index
1610
+ );
1611
+ const plans = destinations.map(({ path, husky, allowOwnedStandalone }) => ({
1612
+ plan: planPreCommitRemoval(path, { allowOwnedStandalone }),
1613
+ husky
1614
+ }));
1615
+ for (const { plan, husky } of plans) applyPreCommitRemoval(plan, husky);
1616
+ for (const spec of [POST_COMMIT, POST_REWRITE]) {
1617
+ const result = uninstallProjectGitHook(spec.hookName, gitRoot);
1618
+ if (result.skipped === "external") {
1619
+ console.log(
1620
+ `Left ${result.path} alone: it is outside this repository, and other repositories may run it.`
1621
+ );
1622
+ } else if (!result.changed) {
1623
+ console.log(`No Prim ${spec.hookName} block found at ${result.path}.`);
1737
1624
  } else if (result.removedFile) {
1738
- console.log(`Removed Prim-created ${hookName} hook at ${result.path}.`);
1625
+ console.log(`Removed Prim-created ${spec.hookName} hook at ${result.path}.`);
1739
1626
  } else {
1740
- console.log(`Removed the Prim ${hookName} block from ${result.path}.`);
1627
+ console.log(`Removed the Prim ${spec.hookName} block from ${result.path}.`);
1741
1628
  }
1742
1629
  }
1743
1630
  }
1744
- function installGlobalHooks(opts = {}) {
1631
+ function globalHooksPathIsPrims() {
1632
+ return isOurHooksDir(gitConfigGet("--global"));
1633
+ }
1634
+ var EXIT_GLOBAL_HOOKS_NOT_INSTALLED = 3;
1635
+ var INSTALLED_OUTCOMES = /* @__PURE__ */ new Set([
1636
+ "installed",
1637
+ "refreshed",
1638
+ "added",
1639
+ "present"
1640
+ ]);
1641
+ function planGlobalHooks(opts = {}) {
1745
1642
  const global = gitConfigGet("--global");
1746
- if (global === "") {
1747
- const system = gitConfigGet("--system");
1748
- if (system !== "" && !isOurHooksDir(system)) {
1749
- if (!opts.force) {
1643
+ if (gitHooksMode({ global: true }) === "manual") return { action: "manual", global };
1644
+ if (isOurHooksDir(global)) return { action: "refresh" };
1645
+ if (global !== "") {
1646
+ const dir = expandTilde(global);
1647
+ return HOOKS.every((spec) => hasCurrentHookBlock(spec.hookName, resolve(dir, spec.hookName))) ? { action: "present_in_dir", global } : { action: "add_to_dir", global };
1648
+ }
1649
+ const system = gitConfigGet("--system");
1650
+ if (system !== "" && !isOurHooksDir(system)) {
1651
+ return opts.force ? { action: "set_pointer", overridesSystem: system } : { action: "system_declined", system };
1652
+ }
1653
+ return { action: "set_pointer" };
1654
+ }
1655
+ function planIsMachineWide(plan) {
1656
+ return plan.action === "set_pointer" || plan.action === "add_to_dir";
1657
+ }
1658
+ function installGlobalHooks(opts = {}) {
1659
+ const plan = planGlobalHooks(opts);
1660
+ switch (plan.action) {
1661
+ case "manual":
1662
+ console.error(
1663
+ 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>`."
1664
+ );
1665
+ return "manual";
1666
+ case "system_declined":
1667
+ console.error(
1668
+ `[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.`
1669
+ );
1670
+ return "system_declined";
1671
+ case "refresh":
1672
+ stageGitHookRuntime();
1673
+ if (!writeOwnHooks()) return "runtime_missing";
1674
+ console.log(
1675
+ `Prim global git hooks already active (${PRIM_GIT_HOOKS_DIR}); refreshed scripts.`
1676
+ );
1677
+ return "refreshed";
1678
+ case "present_in_dir":
1679
+ if (!hookRuntimeReady()) {
1750
1680
  console.error(
1751
- `[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\`.`
1681
+ `[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.`
1752
1682
  );
1753
- return false;
1683
+ return "runtime_missing";
1754
1684
  }
1755
- console.error(
1756
- `[prim] --force: overriding system core.hooksPath ${system}; its hooks will no longer fire (prim chains only to .git/hooks).`
1757
- );
1685
+ console.log(`Prim hooks already present in core.hooksPath dir ${plan.global}.`);
1686
+ return "present";
1687
+ case "set_pointer":
1688
+ case "add_to_dir":
1689
+ break;
1690
+ }
1691
+ if (!hookRuntimeReady()) {
1692
+ console.error(
1693
+ `[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.`
1694
+ );
1695
+ return "runtime_missing";
1696
+ }
1697
+ if (!opts.machineWide) {
1698
+ console.error(
1699
+ 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.`
1700
+ );
1701
+ return "not_requested";
1702
+ }
1703
+ stageGitHookRuntime();
1704
+ if (plan.action === "add_to_dir") {
1705
+ const dir = expandTilde(plan.global);
1706
+ for (const spec of HOOKS) {
1707
+ appendPrimBlock(resolve(dir, spec.hookName), spec);
1758
1708
  }
1759
- writeOwnHooks();
1760
- execFileSync("git", ["config", "--global", "core.hooksPath", PRIM_GIT_HOOKS_DIR], {
1761
- stdio: ["ignore", "ignore", "pipe"],
1762
- timeout: GIT_TIMEOUT_MS
1763
- });
1764
1709
  console.log(
1765
- `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.`
1710
+ `Added prim hooks into existing core.hooksPath dir ${plan.global} (pointer unchanged).`
1766
1711
  );
1767
- return true;
1712
+ return "added";
1768
1713
  }
1769
- if (isOurHooksDir(global)) {
1770
- writeOwnHooks();
1771
- console.log(`Prim global git hooks already active (${PRIM_GIT_HOOKS_DIR}); refreshed scripts.`);
1772
- return true;
1773
- }
1774
- const dir = expandTilde(global);
1775
- for (const spec of HOOKS) {
1776
- appendPrimBlock(resolve(dir, spec.hookName), spec);
1714
+ if (plan.overridesSystem !== void 0) {
1715
+ console.error(
1716
+ `[prim] --force: overriding system core.hooksPath ${plan.overridesSystem}; its hooks will no longer fire (prim chains only to .git/hooks).`
1717
+ );
1777
1718
  }
1719
+ if (!writeOwnHooks()) return "runtime_missing";
1720
+ execFileSync("git", ["config", "--global", "core.hooksPath", PRIM_GIT_HOOKS_DIR], {
1721
+ stdio: ["ignore", "ignore", "pipe"],
1722
+ timeout: GIT_TIMEOUT_MS
1723
+ });
1778
1724
  console.log(
1779
- `Appended prim hooks into existing core.hooksPath dir ${global} (pointer unchanged).`
1725
+ `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.`
1726
+ );
1727
+ return "installed";
1728
+ }
1729
+ function hookRuntimeReady() {
1730
+ if (inspectGitHookEntrypoint() === "ready") return true;
1731
+ stageGitHookRuntime();
1732
+ return inspectGitHookEntrypoint() === "ready";
1733
+ }
1734
+ async function consentToMachineWideHooks(plan, flag, nonInteractive) {
1735
+ if (!planIsMachineWide(plan) || !hookRuntimeReady()) return false;
1736
+ if (flag) return true;
1737
+ if (nonInteractive || !process.stdin.isTTY) return false;
1738
+ return askConfirmation(
1739
+ 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?`,
1740
+ process.stderr
1780
1741
  );
1781
- return true;
1782
1742
  }
1783
1743
  function uninstallGlobalHooks() {
1784
1744
  const global = gitConfigGet("--global");
@@ -1802,26 +1762,75 @@ function uninstallGlobalHooks() {
1802
1762
  }
1803
1763
  console.log("No prim global git hooks found.");
1804
1764
  }
1765
+ function reportInstall(result, gitRoot) {
1766
+ const { hookName, path } = result;
1767
+ switch (result.outcome) {
1768
+ case "created":
1769
+ console.log(`Installed prim ${hookName} hook at ${path}.`);
1770
+ return;
1771
+ case "updated":
1772
+ console.log(`Updated prim ${hookName} hook at ${path}.`);
1773
+ return;
1774
+ case "unchanged":
1775
+ console.log(`Already current: prim ${hookName} hook at ${path}.`);
1776
+ return;
1777
+ case "external":
1778
+ console.error(
1779
+ `[prim] ${hookName}: Git runs this repository's hooks from ${path}, outside the repository, so prim left it alone. ${externalHookRemedy(hookName, gitRoot)}.`
1780
+ );
1781
+ return;
1782
+ case "kept":
1783
+ console.error(
1784
+ `[prim] ${hookName}: kept the working pre-v1 prim block at ${path}, outside the repository; it still captures. To upgrade it, ${externalHookRemedy(hookName, gitRoot)}.`
1785
+ );
1786
+ return;
1787
+ case "runtime_missing":
1788
+ console.error(
1789
+ `[prim] ${hookName}: kept the existing prim hook at ${path}; it still works, and its replacement would stay inert until the hook runtime is staged.`
1790
+ );
1791
+ return;
1792
+ default:
1793
+ console.error(`[prim] ${hookName}: left ${path} as it was (${result.outcome}).`);
1794
+ }
1795
+ }
1805
1796
  function installHooks(gitRoot, target) {
1806
- if (target === "husky") {
1807
- installToHusky(gitRoot, PRE_COMMIT);
1808
- } else {
1809
- installToDotGit(gitRoot, PRE_COMMIT);
1797
+ let changed = false;
1798
+ if (target !== "effective") {
1799
+ const effectiveDir = resolveEffectiveGitHook(PRE_COMMIT.hookName, gitRoot).hooksDir;
1800
+ const chosenDir = target === "husky" ? resolve(gitRoot, ".husky") : projectHooksDir(gitRoot);
1801
+ if (resolve(effectiveDir) !== chosenDir && resolve(effectiveDir, "..") !== chosenDir) {
1802
+ console.error(
1803
+ `[prim] note: Git runs this repository's hooks from ${effectiveDir}; hooks in ${chosenDir} will not fire unless that directory chains to them.`
1804
+ );
1805
+ }
1810
1806
  }
1811
- const postCommit = ensureEffectivePostCommitHook(gitRoot);
1812
- console.log(
1813
- `${postCommit.changed ? "Installed" : "Refreshed"} effective post-commit hook at ${postCommit.path}.`
1814
- );
1815
- try {
1816
- const postRewrite = ensureEffectivePostRewriteHook(gitRoot);
1817
- console.log(
1818
- `${postRewrite.changed ? "Installed" : "Refreshed"} effective post-rewrite hook at ${postRewrite.path}.`
1819
- );
1820
- } catch (error) {
1821
- const detail = error instanceof Error ? error.message : String(error);
1822
- console.error(`[prim] post-rewrite hook coverage is degraded: ${detail}`);
1807
+ for (const spec of HOOKS) {
1808
+ try {
1809
+ const result = target === "effective" ? ensureEffectiveGitHook(spec.hookName, gitRoot, { context: "explicit" }) : ensureGitHookAtPath(
1810
+ spec.hookName,
1811
+ target === "husky" ? resolve(gitRoot, ".husky", spec.hookName) : resolve(projectHooksDir(gitRoot), spec.hookName),
1812
+ { husky: target === "husky" }
1813
+ );
1814
+ reportInstall(result, gitRoot);
1815
+ if (spec === POST_COMMIT) changed ||= result.changed;
1816
+ if (spec === POST_COMMIT && result.outcome === "external") process.exitCode = 1;
1817
+ } catch (error) {
1818
+ if (spec === POST_COMMIT) throw error;
1819
+ const detail = error instanceof Error ? error.message : String(error);
1820
+ console.error(
1821
+ `[prim] ${spec.hookName} hook coverage is degraded: ${detail}. To wire it by hand, run \`prim hooks snippet ${spec.hookName}\`.`
1822
+ );
1823
+ }
1824
+ }
1825
+ if (repoActiveFlag(gitRoot) === "true") {
1826
+ recordHooksWired(gitRoot, { onlyIfAbsent: !changed });
1823
1827
  }
1824
1828
  }
1829
+ function printManualNote(gitRoot) {
1830
+ console.error(
1831
+ `[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.`
1832
+ );
1833
+ }
1825
1834
  function registerHooksCommands(program2) {
1826
1835
  const hooks = program2.command("hooks").description("Manage git hooks");
1827
1836
  hooks.command("install").description(
@@ -1834,20 +1843,47 @@ function registerHooksCommands(program2) {
1834
1843
  ).addOption(
1835
1844
  new Option(
1836
1845
  "--scope <scope>",
1837
- "project (default, this repo) or user (a global core.hooksPath capturing every repo)"
1846
+ "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)"
1838
1847
  ).choices(["project", "user"])
1839
- ).option("--force", "with --scope user, override a system-level core.hooksPath").action(
1848
+ ).option("--force", "with --scope user, override a system-level core.hooksPath").option(
1849
+ "--global-hooks-path",
1850
+ "with --scope user, change git's global hooks so every repository runs prim's hooks"
1851
+ ).action(
1840
1852
  async (opts, command) => {
1853
+ const globals = command.optsWithGlobals();
1854
+ const nonInteractive = isNonInteractive(globals);
1841
1855
  if (opts.scope === "user") {
1842
- installGlobalHooks({ force: opts.force });
1856
+ const plan = planGlobalHooks({ force: opts.force });
1857
+ const outcome = installGlobalHooks({
1858
+ force: opts.force,
1859
+ machineWide: await consentToMachineWideHooks(
1860
+ plan,
1861
+ opts.globalHooksPath,
1862
+ nonInteractive
1863
+ )
1864
+ });
1865
+ if (outcome === "runtime_missing") {
1866
+ process.exitCode = 1;
1867
+ } else if (opts.globalHooksPath && !INSTALLED_OUTCOMES.has(outcome)) {
1868
+ process.exitCode = EXIT_GLOBAL_HOOKS_NOT_INSTALLED;
1869
+ }
1870
+ return;
1871
+ }
1872
+ if (opts.globalHooksPath) {
1873
+ console.error("[prim] --global-hooks-path applies only with --scope user");
1874
+ process.exitCode = 2;
1843
1875
  return;
1844
1876
  }
1845
- const globals = command.optsWithGlobals();
1846
- const nonInteractive = isNonInteractive(globals);
1847
1877
  const gitRoot = getGitRoot();
1878
+ if (gitHooksMode({ cwd: gitRoot }) === "manual") {
1879
+ printManualNote(gitRoot);
1880
+ return;
1881
+ }
1882
+ stageGitHookRuntime();
1848
1883
  if (opts.target === "husky") return installHooks(gitRoot, "husky");
1849
1884
  if (opts.target === "git-hooks") return installHooks(gitRoot, "git-hooks");
1850
- if (detectHusky(gitRoot)) {
1885
+ const effectiveDir = resolveEffectiveGitHook(PRE_COMMIT.hookName, gitRoot).hooksDir;
1886
+ if (resolve(effectiveDir) === projectHooksDir(gitRoot) && detectHusky(gitRoot)) {
1851
1887
  if (globals.yes) return installHooks(gitRoot, "husky");
1852
1888
  if (nonInteractive) {
1853
1889
  throw new Error(
@@ -1865,10 +1901,25 @@ function registerHooksCommands(program2) {
1865
1901
  } else {
1866
1902
  console.log("Falling back to .git/hooks install.");
1867
1903
  }
1904
+ return installHooks(gitRoot, "git-hooks");
1868
1905
  }
1869
- installHooks(gitRoot, "git-hooks");
1906
+ installHooks(gitRoot, "effective");
1870
1907
  }
1871
1908
  );
1909
+ 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) => {
1910
+ process.stdout.write(`${managedHookBlock(hook)}
1911
+ `);
1912
+ let entrypoint = "<prim config>/prim-git-hook-v1";
1913
+ try {
1914
+ entrypoint = hookRuntimePaths().gitHookEntrypoint;
1915
+ } catch {
1916
+ }
1917
+ process.stderr.write(
1918
+ `[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." : ""}
1919
+ [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.
1920
+ `
1921
+ );
1922
+ });
1872
1923
  hooks.command("uninstall").description(
1873
1924
  "Remove the prim git hooks (.git/hooks, or the global core.hooksPath with --scope user)"
1874
1925
  ).addOption(
@@ -1888,6 +1939,49 @@ function registerHooksCommands(program2) {
1888
1939
  // src/commands/activation.ts
1889
1940
  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?";
1890
1941
  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.";
1942
+ function wireRepositoryHooks(root) {
1943
+ stageGitHookRuntime();
1944
+ refreshOwnedGlobalHooks();
1945
+ const paths = {};
1946
+ let manual = false;
1947
+ let changed = false;
1948
+ for (const hookName of MANAGED_GIT_HOOK_NAMES) {
1949
+ try {
1950
+ const result = ensureEffectiveGitHook(hookName, root, { context: "explicit" });
1951
+ if (result.outcome === "external") {
1952
+ throw new Error(
1953
+ `Git runs this repository's ${hookName} hook from ${result.path}, outside the repository; ${externalHookRemedy(hookName, root)}`
1954
+ );
1955
+ }
1956
+ if (result.outcome === "kept") {
1957
+ process.stderr.write(
1958
+ `[prim] kept the working pre-v1 ${hookName} hook at ${result.path}, outside the repository; to upgrade it, ${externalHookRemedy(hookName, root)}
1959
+ `
1960
+ );
1961
+ }
1962
+ if (result.outcome === "runtime_missing") {
1963
+ process.stderr.write(
1964
+ `[prim] kept the working pre-v1 ${hookName} hook at ${result.path}; it is replaced once the hook runtime is staged
1965
+ `
1966
+ );
1967
+ }
1968
+ paths[hookName] = result.path;
1969
+ manual ||= result.outcome === "manual";
1970
+ if (hookName === "post-commit") changed ||= result.changed;
1971
+ } catch (error) {
1972
+ if (hookName === "post-commit") throw error;
1973
+ const detail = error instanceof Error ? error.message : String(error);
1974
+ process.stderr.write(`[prim] ${hookName} hook coverage is degraded: ${detail}
1975
+ `);
1976
+ }
1977
+ }
1978
+ if (manual) {
1979
+ process.stderr.write(
1980
+ "[prim] prim.gitHooks=manual: left hook files untouched; wire them with `prim hooks snippet <hook>`\n"
1981
+ );
1982
+ }
1983
+ return { paths, changed };
1984
+ }
1891
1985
  async function maybeConnectRepository(root, globals) {
1892
1986
  if (isNonInteractive(globals)) return void 0;
1893
1987
  const approved = Boolean(globals.yes) || await askConfirmation(CONNECT_PROMPT, process.stderr);
@@ -1907,6 +2001,13 @@ async function maybeConnectRepository(root, globals) {
1907
2001
  }
1908
2002
  return void 0;
1909
2003
  }
2004
+ function hookPathFields(paths) {
2005
+ return {
2006
+ ...paths["pre-commit"] ? { preCommitHook: paths["pre-commit"] } : {},
2007
+ ...paths["post-commit"] ? { postCommitHook: paths["post-commit"] } : {},
2008
+ ...paths["post-rewrite"] ? { postRewriteHook: paths["post-rewrite"] } : {}
2009
+ };
2010
+ }
1910
2011
  async function applyActivation(active, globals = {}) {
1911
2012
  const root = gitToplevel();
1912
2013
  if (!root) {
@@ -1919,18 +2020,11 @@ async function applyActivation(active, globals = {}) {
1919
2020
  let phase = active ? "post-commit hook coverage" : "local deactivation";
1920
2021
  try {
1921
2022
  let binding;
1922
- let postCommitHook;
1923
- let postRewriteHook;
2023
+ let hookPaths = {};
2024
+ let hooksChanged = false;
2025
+ const wasActive = repoActiveFlag(root) === "true";
1924
2026
  if (active) {
1925
- refreshOwnedGlobalHooks();
1926
- postCommitHook = ensureEffectivePostCommitHook(root).path;
1927
- try {
1928
- postRewriteHook = ensureEffectivePostRewriteHook(root).path;
1929
- } catch (error) {
1930
- const detail = error instanceof Error ? error.message : String(error);
1931
- process.stderr.write(`[prim] post-rewrite hook coverage is degraded: ${detail}
1932
- `);
1933
- }
2027
+ ({ paths: hookPaths, changed: hooksChanged } = wireRepositoryHooks(root));
1934
2028
  phase = "GitHub repo connection";
1935
2029
  binding = await bindRepository(root);
1936
2030
  }
@@ -1944,8 +2038,7 @@ async function applyActivation(active, globals = {}) {
1944
2038
  repo: root,
1945
2039
  bindingStatus: binding.status,
1946
2040
  repositoryFullName: binding.repositoryFullName,
1947
- ...postCommitHook ? { postCommitHook } : {},
1948
- ...postRewriteHook ? { postRewriteHook } : {}
2041
+ ...hookPathFields(hookPaths)
1949
2042
  });
1950
2043
  process.exitCode = 1;
1951
2044
  return;
@@ -1960,6 +2053,10 @@ async function applyActivation(active, globals = {}) {
1960
2053
  }
1961
2054
  phase = "local activation";
1962
2055
  setRepoActive(root, active);
2056
+ if (!active) clearHooksWired(root);
2057
+ if (active) {
2058
+ recordHooksWired(root, { onlyIfAbsent: wasActive && !hooksChanged });
2059
+ }
1963
2060
  await daemonRequest("statusline_invalidate", {}, { timeoutMs: 250 });
1964
2061
  process.stderr.write(`[prim] prim ${active ? "enabled" : "disabled"} in ${root}
1965
2062
  `);
@@ -1971,8 +2068,7 @@ async function applyActivation(active, globals = {}) {
1971
2068
  repositoryFullName: binding.repositoryFullName,
1972
2069
  ...binding.status === "connected" ? { repoSyncId: binding.repoSyncId } : {}
1973
2070
  } : {},
1974
- ...postCommitHook ? { postCommitHook } : {},
1975
- ...postRewriteHook ? { postRewriteHook } : {}
2071
+ ...hookPathFields(hookPaths)
1976
2072
  });
1977
2073
  } catch (err) {
1978
2074
  const detail = err instanceof Error ? err.message : String(err);
@@ -2177,7 +2273,7 @@ ${line("project", result.project)}`);
2177
2273
 
2178
2274
  // src/commands/daemon.ts
2179
2275
  import { spawn as spawn2 } from "child_process";
2180
- import { closeSync, existsSync as existsSync4, mkdirSync as mkdirSync3, openSync, readFileSync as readFileSync4, unlinkSync as unlinkSync2 } from "fs";
2276
+ import { closeSync as closeSync2, existsSync as existsSync4, mkdirSync as mkdirSync3, openSync as openSync2, readFileSync as readFileSync4, unlinkSync as unlinkSync2 } from "fs";
2181
2277
  import { join as join5 } from "path";
2182
2278
  import { Option as Option2 } from "commander";
2183
2279
 
@@ -2250,12 +2346,12 @@ async function runLatestDaemonBootstrap(ensureCurrent, options = {}) {
2250
2346
  import { spawnSync } from "child_process";
2251
2347
  import { createHash as createHash2, randomBytes as randomBytes3 } from "crypto";
2252
2348
  import {
2253
- constants,
2349
+ constants as constants2,
2254
2350
  accessSync,
2255
2351
  chmodSync,
2256
2352
  copyFileSync,
2257
2353
  existsSync as existsSync3,
2258
- lstatSync as lstatSync2,
2354
+ lstatSync as lstatSync3,
2259
2355
  mkdirSync as mkdirSync2,
2260
2356
  mkdtempSync,
2261
2357
  readFileSync as readFileSync3,
@@ -2264,16 +2360,53 @@ import {
2264
2360
  realpathSync,
2265
2361
  renameSync,
2266
2362
  rmSync,
2363
+ statSync,
2267
2364
  symlinkSync,
2268
2365
  writeFileSync as writeFileSync2
2269
2366
  } from "fs";
2270
2367
  import { homedir as homedir3 } from "os";
2271
- import { basename, dirname as dirname2, isAbsolute, join as join4, resolve as resolve2 } from "path";
2368
+ import { basename, dirname, isAbsolute, join as join4, resolve as resolve2 } from "path";
2369
+
2370
+ // src/lib/bounded-file.ts
2371
+ import { constants, closeSync, fstatSync, lstatSync as lstatSync2, openSync, readSync } from "fs";
2372
+ function readBoundedRegularFile(path, maxBytes) {
2373
+ let fd;
2374
+ try {
2375
+ const linked = lstatSync2(path);
2376
+ if (!linked.isFile() || linked.size > maxBytes) return void 0;
2377
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
2378
+ const opened = fstatSync(fd);
2379
+ if (!opened.isFile() || opened.dev !== linked.dev || opened.ino !== linked.ino || opened.size > maxBytes) {
2380
+ return void 0;
2381
+ }
2382
+ const buffer = Buffer.allocUnsafe(opened.size + 1);
2383
+ let length = 0;
2384
+ while (length < buffer.length) {
2385
+ const count = readSync(fd, buffer, length, buffer.length - length, length);
2386
+ if (count === 0) break;
2387
+ length += count;
2388
+ }
2389
+ if (length > opened.size) return void 0;
2390
+ return { text: buffer.subarray(0, length).toString("utf8"), ownerUid: opened.uid };
2391
+ } catch {
2392
+ return void 0;
2393
+ } finally {
2394
+ if (fd !== void 0) {
2395
+ try {
2396
+ closeSync(fd);
2397
+ } catch {
2398
+ }
2399
+ }
2400
+ }
2401
+ }
2272
2402
 
2273
2403
  // src/daemon/env-binding.ts
2274
2404
  function normalizeApiUrl(url) {
2275
2405
  return url.trim().replace(/\/+$/, "");
2276
2406
  }
2407
+ function apiUrlsMatch(a, b) {
2408
+ return normalizeApiUrl(a) === normalizeApiUrl(b);
2409
+ }
2277
2410
 
2278
2411
  // src/daemon/launchd.ts
2279
2412
  var LAUNCHD_LABEL = "ai.getprimitive.prim-daemon";
@@ -2292,6 +2425,11 @@ var SERVICE_NOT_FOUND = 113;
2292
2425
  var TRANSITION_IN_PROGRESS = 5;
2293
2426
  var OUTPUT_LIMIT = 4096;
2294
2427
  var DAEMON_DISABLED_CONTENT = "disabled by `prim daemon stop`\n";
2428
+ var LAUNCHER_MAX_BYTES = 64 * 1024;
2429
+ var PLIST_MAX_BYTES = 64 * 1024;
2430
+ var DRIFT_HEAL_MARKER = "daemon-drift-heal.json";
2431
+ var DRIFT_HEAL_MARKER_TEMPORARY_RE = /^daemon-drift-heal\.json\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.tmp$/u;
2432
+ var DRIFT_HEAL_CLAIM_OWNER = "owner.json";
2295
2433
  var LaunchctlError = class extends Error {
2296
2434
  status;
2297
2435
  timedOut;
@@ -2331,13 +2469,15 @@ function daemonControlPaths(options = {}) {
2331
2469
  return {
2332
2470
  launcher: join4(configDir, `prim-daemon-launcher-v${LAUNCHER_SCHEMA_VERSION}`),
2333
2471
  disabledMarker: join4(configDir, "daemon.disabled"),
2472
+ driftHealMarker: join4(configDir, DRIFT_HEAL_MARKER),
2473
+ driftHealClaim: join4(configDir, "daemon-drift-heal.lock"),
2334
2474
  lifecycleLockDir: join4(configDir, "daemon.lifecycle.lock"),
2335
2475
  legacyDisabledMarker: legacy.disabledMarker,
2336
2476
  legacyLifecycleLockDir: legacy.lifecycleLockDir
2337
2477
  };
2338
2478
  }
2339
2479
  function findPackageVersion(entryFile) {
2340
- let dir = dirname2(resolve2(entryFile));
2480
+ let dir = dirname(resolve2(entryFile));
2341
2481
  for (let depth = 0; depth < 6; depth++) {
2342
2482
  const packagePath = join4(dir, "package.json");
2343
2483
  if (existsSync3(packagePath)) {
@@ -2349,7 +2489,7 @@ function findPackageVersion(entryFile) {
2349
2489
  } catch {
2350
2490
  }
2351
2491
  }
2352
- const parent = dirname2(dir);
2492
+ const parent = dirname(dir);
2353
2493
  if (parent === dir) break;
2354
2494
  dir = parent;
2355
2495
  }
@@ -2391,7 +2531,7 @@ function readOwnedRuntimeManifest(releaseDir) {
2391
2531
  }
2392
2532
  const manifest = parsed;
2393
2533
  if (manifest.schemaVersion !== RUNTIME_SCHEMA_VERSION || compareSemver(manifest.version, manifest.version) !== 0 || !isAbsolute(manifest.nodePath) || /[\r\n]/u.test(manifest.nodePath) || manifest.daemonFile !== "prim-daemon-server" || !/^[0-9a-f]{64}$/u.test(manifest.daemonSha256) || new Date(manifest.stagedAt).toISOString() !== manifest.stagedAt || raw !== `${JSON.stringify(manifest, null, 2)}
2394
- ` || !lstatSync2(daemonPath).isFile() || sha256File(daemonPath) !== manifest.daemonSha256) {
2534
+ ` || !lstatSync3(daemonPath).isFile() || sha256File(daemonPath) !== manifest.daemonSha256) {
2395
2535
  return null;
2396
2536
  }
2397
2537
  const entries = readdirSync2(releaseDir).sort();
@@ -2400,8 +2540,17 @@ function readOwnedRuntimeManifest(releaseDir) {
2400
2540
  return null;
2401
2541
  }
2402
2542
  }
2543
+ function sameNode(recorded, desired) {
2544
+ if (recorded === desired) return true;
2545
+ try {
2546
+ const target = realpathSync(desired);
2547
+ return recorded !== target && realpathSync(recorded) === target;
2548
+ } catch {
2549
+ return false;
2550
+ }
2551
+ }
2403
2552
  function sameRuntime(current, desired, daemonPath) {
2404
- if (!current || current.version !== desired.version || current.nodePath !== desired.nodePath || current.daemonSha256 !== desired.daemonSha256) {
2553
+ if (!current || current.version !== desired.version || !sameNode(current.nodePath, desired.nodePath) || current.daemonSha256 !== desired.daemonSha256) {
2405
2554
  return false;
2406
2555
  }
2407
2556
  try {
@@ -2429,7 +2578,7 @@ function stageRuntime(options = {}) {
2429
2578
  if (!daemonSource || !existsSync3(daemonSource)) {
2430
2579
  throw new Error("cannot stage runtime: prim-daemon-server bundle is unavailable");
2431
2580
  }
2432
- const nodePath = resolve2(options.nodePath ?? process.execPath);
2581
+ const nodePath = resolve2(options.nodePath ?? stableNodePath());
2433
2582
  const version = options.version ?? findPackageVersion(daemonSource);
2434
2583
  const desired = {
2435
2584
  schemaVersion: RUNTIME_SCHEMA_VERSION,
@@ -2528,13 +2677,30 @@ function assertRegularFile(path, label) {
2528
2677
  }
2529
2678
  function lstatIfPresent2(path) {
2530
2679
  try {
2531
- return lstatSync2(path);
2680
+ return lstatSync3(path);
2532
2681
  } catch (error) {
2533
2682
  const code = error.code;
2534
2683
  if (code === "ENOENT" || code === "ENOTDIR") return void 0;
2535
2684
  throw error;
2536
2685
  }
2537
2686
  }
2687
+ function driftHealMarkerTemporaries(configDir) {
2688
+ try {
2689
+ return readdirSync2(configDir).filter((entry) => DRIFT_HEAL_MARKER_TEMPORARY_RE.test(entry)).map((entry) => join4(configDir, entry));
2690
+ } catch (error) {
2691
+ const code = error.code;
2692
+ if (code === "ENOENT" || code === "ENOTDIR") return [];
2693
+ throw error;
2694
+ }
2695
+ }
2696
+ function assertDriftHealClaim(path) {
2697
+ const claim = lstatIfPresent2(path);
2698
+ if (!claim) return;
2699
+ if (!claim.isDirectory() || readdirSync2(path).some((entry) => entry !== DRIFT_HEAL_CLAIM_OWNER)) {
2700
+ throw new Error(`refusing to remove unrecognized daemon drift-heal claim at ${path}`);
2701
+ }
2702
+ assertRegularFile(join4(path, DRIFT_HEAL_CLAIM_OWNER), "daemon drift-heal claim owner");
2703
+ }
2538
2704
  function assertExactFile(path, expected, label) {
2539
2705
  if (!lstatIfPresent2(path)) return;
2540
2706
  assertRegularFile(path, label);
@@ -2573,17 +2739,16 @@ function assertOwnedDaemonRuntime(options) {
2573
2739
  );
2574
2740
  }
2575
2741
  assertRegularFile(logPath, "daemon log");
2742
+ assertRegularFile(control.driftHealMarker, "daemon drift-heal marker");
2743
+ for (const temporary of driftHealMarkerTemporaries(configDir)) {
2744
+ assertRegularFile(temporary, "daemon drift-heal marker temporary");
2745
+ }
2746
+ assertDriftHealClaim(control.driftHealClaim);
2576
2747
  assertRegularFile(join4(configDir, "daemon-health.json"), "daemon health snapshot");
2577
2748
  assertRegularFile(join4(configDir, "client_instance_id"), "daemon client instance id");
2578
2749
  if (service && lstatIfPresent2(service.plistPath)) {
2579
2750
  assertRegularFile(service.plistPath, "launchd property list");
2580
- const expected = generateLaunchAgentPlist({
2581
- launcherPath: control.launcher,
2582
- logPath,
2583
- workingDirectory: options.homeDir ?? homedir3(),
2584
- label: options.label
2585
- });
2586
- if (readFileSync3(service.plistPath, "utf8") !== expected) {
2751
+ if (readFileSync3(service.plistPath, "utf8") !== ownedLaunchAgentPlist(options, logPath)) {
2587
2752
  throw new Error(
2588
2753
  `refusing to remove unrecognized launchd property list at ${service.plistPath}`
2589
2754
  );
@@ -2660,6 +2825,9 @@ async function removeDaemonRuntime(options = {}) {
2660
2825
  control.launcher,
2661
2826
  control.disabledMarker,
2662
2827
  control.legacyDisabledMarker,
2828
+ control.driftHealMarker,
2829
+ ...driftHealMarkerTemporaries(configDir),
2830
+ control.driftHealClaim,
2663
2831
  ...service ? [service.plistPath] : [],
2664
2832
  logPath,
2665
2833
  join4(configDir, "daemon-health.json"),
@@ -2671,7 +2839,7 @@ async function removeDaemonRuntime(options = {}) {
2671
2839
  return withDaemonLifecycleLock(async () => {
2672
2840
  assertOwnedDaemonRuntime(options);
2673
2841
  for (const path of ownedArtifacts.filter((candidate) => candidate !== runtime.runtimeDir)) {
2674
- rmSync(path, { force: true });
2842
+ rmSync(path, { force: true, recursive: path === control.driftHealClaim });
2675
2843
  }
2676
2844
  if (lstatIfPresent2(runtime.runtimeDir)) {
2677
2845
  const quarantined = `${runtime.runtimeDir}.uninstall-${String(process.pid)}-${randomBytes3(8).toString("hex")}`;
@@ -2705,9 +2873,22 @@ ${config.configDir ? `export PRIM_CONFIG_DIR=${shellQuote(config.configDir)}
2705
2873
  `
2706
2874
  };
2707
2875
  }
2876
+ function ownedLaunchAgentPlist(options, logPath) {
2877
+ return generateLaunchAgentPlist({
2878
+ launcherPath: daemonControlPaths(options).launcher,
2879
+ logPath,
2880
+ workingDirectory: options.homeDir ?? homedir3(),
2881
+ label: options.label
2882
+ });
2883
+ }
2708
2884
  function readDaemonLauncher(path) {
2885
+ return readDaemonLauncherFile(path)?.config ?? null;
2886
+ }
2887
+ function readDaemonLauncherFile(path) {
2709
2888
  try {
2710
- const content = readFileSync3(path, "utf8");
2889
+ const file = readBoundedRegularFile(path, LAUNCHER_MAX_BYTES);
2890
+ if (!file) return null;
2891
+ const content = file.text;
2711
2892
  const encoded = /^# prim-daemon-launcher: ([A-Za-z0-9_-]+)$/mu.exec(content)?.[1];
2712
2893
  if (!encoded) return null;
2713
2894
  const value = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
@@ -2722,7 +2903,7 @@ function readDaemonLauncher(path) {
2722
2903
  ...typeof value.configDir === "string" ? { configDir: value.configDir } : {}
2723
2904
  };
2724
2905
  const expected = generateDaemonLauncher(config);
2725
- return expected.revision === value.revision && expected.content === content ? config : null;
2906
+ return expected.revision === value.revision && expected.content === content ? { config, ownerUid: file.ownerUid } : null;
2726
2907
  } catch {
2727
2908
  return null;
2728
2909
  }
@@ -2730,9 +2911,9 @@ function readDaemonLauncher(path) {
2730
2911
  function readUsableDaemonConfig(path) {
2731
2912
  const config = readDaemonLauncher(path);
2732
2913
  if (!config) return null;
2733
- const manifest = readRuntimeManifest(join4(dirname2(config.daemonPath), "manifest.json"));
2914
+ const manifest = readRuntimeManifest(join4(dirname(config.daemonPath), "manifest.json"));
2734
2915
  try {
2735
- accessSync(config.nodePath, constants.X_OK);
2916
+ accessSync(config.nodePath, constants2.X_OK);
2736
2917
  return manifest?.version === config.runtimeVersion && manifest.nodePath === config.nodePath && manifest.daemonSha256 === sha256File(config.daemonPath) ? config : null;
2737
2918
  } catch {
2738
2919
  return null;
@@ -2824,7 +3005,7 @@ function atomicWrite2(path, content, mode, validate) {
2824
3005
  }
2825
3006
  } catch {
2826
3007
  }
2827
- mkdirSync2(dirname2(path), { recursive: true, mode: RUNTIME_DIR_MODE });
3008
+ mkdirSync2(dirname(path), { recursive: true, mode: RUNTIME_DIR_MODE });
2828
3009
  const temp = `${path}.tmp-${process.pid}-${Date.now()}`;
2829
3010
  writeFileSync2(temp, content, { encoding: "utf8", mode, flag: "wx" });
2830
3011
  try {
@@ -2860,7 +3041,7 @@ function canonicalProspectivePath(path) {
2860
3041
  const suffix = [];
2861
3042
  let ancestor = resolve2(path);
2862
3043
  while (!existsSync3(ancestor)) {
2863
- const parent = dirname2(ancestor);
3044
+ const parent = dirname(ancestor);
2864
3045
  if (parent === ancestor) return resolve2(path);
2865
3046
  suffix.unshift(basename(ancestor));
2866
3047
  ancestor = parent;
@@ -3076,6 +3257,68 @@ function setDaemonExplicitlyDisabled(disabled, options = {}) {
3076
3257
  }
3077
3258
  atomicWrite2(paths.disabledMarker, content, RUNTIME_FILE_MODE);
3078
3259
  }
3260
+ function launcherRunnable(config) {
3261
+ try {
3262
+ accessSync(config.nodePath, constants2.X_OK);
3263
+ return statSync(config.daemonPath).isFile();
3264
+ } catch {
3265
+ return false;
3266
+ }
3267
+ }
3268
+ function selectedDaemonLauncher(options = {}) {
3269
+ const file = readDaemonLauncherFile(daemonControlPaths(options).launcher);
3270
+ if (!file) return null;
3271
+ const { config, ownerUid } = file;
3272
+ return {
3273
+ runtimeVersion: config.runtimeVersion,
3274
+ ...config.apiUrl ? { apiUrl: config.apiUrl } : {},
3275
+ ownerUid,
3276
+ runnable: launcherRunnable(config)
3277
+ };
3278
+ }
3279
+ var XML_ENTITIES = {
3280
+ "&amp;": "&",
3281
+ "&lt;": "<",
3282
+ "&gt;": ">",
3283
+ "&quot;": '"',
3284
+ "&apos;": "'"
3285
+ };
3286
+ var XML_ENTITY_RE = /&(?:amp|lt|gt|quot|apos);/gu;
3287
+ var PLIST_LABEL_KEY = "<key>Label</key>";
3288
+ var PLIST_PROGRAM_KEY = "<key>ProgramArguments</key>";
3289
+ var PLIST_LABEL_RE = /<key>Label<\/key>\s*<string>([^<]*)<\/string>/u;
3290
+ var PLIST_PROGRAM_RE = /<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]*)<\/string>/u;
3291
+ function xmlUnescape(value) {
3292
+ if (value.replace(XML_ENTITY_RE, "").includes("&")) return void 0;
3293
+ return value.replace(XML_ENTITY_RE, (entity) => XML_ENTITIES[entity] ?? entity);
3294
+ }
3295
+ function launchAgentProgram(plist) {
3296
+ if (plist.split(PLIST_LABEL_KEY).length !== 2 || plist.split(PLIST_PROGRAM_KEY).length !== 2) {
3297
+ return void 0;
3298
+ }
3299
+ const label = PLIST_LABEL_RE.exec(plist)?.[1];
3300
+ const program2 = PLIST_PROGRAM_RE.exec(plist)?.[1];
3301
+ if (label === void 0 || program2 === void 0) return void 0;
3302
+ const decodedLabel = xmlUnescape(label);
3303
+ const decodedProgram = xmlUnescape(program2);
3304
+ return decodedLabel === void 0 || decodedProgram === void 0 ? void 0 : { label: decodedLabel, program: decodedProgram };
3305
+ }
3306
+ function launchAgentRunsConfigRoot(options = {}) {
3307
+ try {
3308
+ const service = launchdPaths(options);
3309
+ const plist = readBoundedRegularFile(service.plistPath, PLIST_MAX_BYTES);
3310
+ const agent = plist ? launchAgentProgram(plist.text) : void 0;
3311
+ return agent?.label === (options.label ?? LAUNCHD_LABEL) && agent.program === daemonControlPaths(options).launcher;
3312
+ } catch {
3313
+ return false;
3314
+ }
3315
+ }
3316
+ function daemonDriftHealMarkerPath(options = {}) {
3317
+ return daemonControlPaths(options).driftHealMarker;
3318
+ }
3319
+ function daemonDriftHealClaimPath(options = {}) {
3320
+ return daemonControlPaths(options).driftHealClaim;
3321
+ }
3079
3322
  async function ensureMacDaemon(options = {}) {
3080
3323
  return withDaemonLifecycleLock(() => ensureMacDaemonLocked(options), options);
3081
3324
  }
@@ -3138,7 +3381,7 @@ async function ensureMacDaemonLocked(options) {
3138
3381
  };
3139
3382
  }
3140
3383
  const servicePaths = launchdPaths(options);
3141
- mkdirSync2(dirname2(servicePaths.logPath), { recursive: true, mode: RUNTIME_DIR_MODE });
3384
+ mkdirSync2(dirname(servicePaths.logPath), { recursive: true, mode: RUNTIME_DIR_MODE });
3142
3385
  writeFileSync2(servicePaths.logPath, "", { mode: RUNTIME_FILE_MODE, flag: "a" });
3143
3386
  chmodSync(servicePaths.logPath, RUNTIME_FILE_MODE);
3144
3387
  const launcher = generateDaemonLauncher(daemonConfig);
@@ -3277,16 +3520,20 @@ var STATUS_PROBE_TIMEOUT_MS = 500;
3277
3520
  var READY_TIMEOUT_MS = 5e3;
3278
3521
  var READY_POLL_MS2 = 100;
3279
3522
  var READY_PROBE_TIMEOUT_MS2 = 250;
3280
- var HEALTHY_TIMEOUT_MS = 3e4;
3281
- var HEALTHY_POLL_MS = 250;
3523
+ var READY_SNAPSHOT_TIMEOUT_MS = 3e4;
3524
+ var READY_SNAPSHOT_POLL_MS = 250;
3282
3525
  var EXIT_OK3 = 0;
3283
3526
  var EXIT_NOT_RUNNING = 2;
3284
3527
  var EXIT_BOOTING = 3;
3285
- function formatDaemonLifecycleMessage(message, decisionIngestion) {
3286
- return `${message} \xB7 Decision ingestion ${decisionIngestion}`;
3528
+ var VERIFIED_PREFIX = "[prim] \u2713 ";
3529
+ function formatDaemonLifecycleMessage(message, decisionIngestion, snapshot, now = Date.now()) {
3530
+ const backlog = deliveryBacklogSummary(snapshot?.ingestion, now);
3531
+ const state = deliveryBacklogState(snapshot?.ingestion);
3532
+ const verdict = (state === "failing" || state === "retained") && message.startsWith(VERIFIED_PREFIX) ? `[prim] \u26A0 ${message.slice(VERIFIED_PREFIX.length)}` : message;
3533
+ return `${verdict} \xB7 Decision ingestion ${decisionIngestion}${backlog ? ` \xB7 ${backlog}` : ""}`;
3287
3534
  }
3288
- function formatCurrentDaemonLifecycleMessage(message) {
3289
- return formatDaemonLifecycleMessage(message, decisionIngestionStatus(process.cwd()));
3535
+ function formatCurrentDaemonLifecycleMessage(message, snapshot) {
3536
+ return formatDaemonLifecycleMessage(message, decisionIngestionStatus(process.cwd()), snapshot);
3290
3537
  }
3291
3538
  function daemonDegradedReason(snapshot) {
3292
3539
  if (!snapshot || snapshot.healthy !== false) return void 0;
@@ -3337,7 +3584,7 @@ function openDaemonLog(configDir = CONFIG_DIR) {
3337
3584
  if (!existsSync4(configDir)) {
3338
3585
  mkdirSync3(configDir, { recursive: true, mode: CONFIG_DIR_MODE });
3339
3586
  }
3340
- return openSync(join5(configDir, "daemon.log"), "a", LOG_FILE_MODE);
3587
+ return openSync2(join5(configDir, "daemon.log"), "a", LOG_FILE_MODE);
3341
3588
  }
3342
3589
  async function waitForReady() {
3343
3590
  const deadline = Date.now() + READY_TIMEOUT_MS;
@@ -3349,17 +3596,23 @@ async function waitForReady() {
3349
3596
  }
3350
3597
  return daemonIsLive(READY_PROBE_TIMEOUT_MS2);
3351
3598
  }
3352
- async function waitForHealthySnapshot(expectedVersion) {
3353
- const deadline = Date.now() + HEALTHY_TIMEOUT_MS;
3599
+ function requestStatusSnapshot() {
3600
+ return daemonRequest(
3601
+ "status_snapshot",
3602
+ {},
3603
+ { timeoutMs: STATUS_PROBE_TIMEOUT_MS }
3604
+ );
3605
+ }
3606
+ async function waitForReadySnapshot(expectedVersion, options = {}) {
3607
+ const request = options.requestSnapshot ?? requestStatusSnapshot;
3608
+ const wait = options.sleep ?? sleep;
3609
+ const nowMs = options.nowMs ?? Date.now;
3610
+ const deadline = nowMs() + READY_SNAPSHOT_TIMEOUT_MS;
3354
3611
  let snapshot = null;
3355
- while (Date.now() < deadline) {
3356
- snapshot = await daemonRequest(
3357
- "status_snapshot",
3358
- {},
3359
- { timeoutMs: STATUS_PROBE_TIMEOUT_MS }
3360
- );
3361
- if (snapshot?.healthy === true && snapshot.version === expectedVersion) return snapshot;
3362
- await sleep(HEALTHY_POLL_MS);
3612
+ while (nowMs() < deadline) {
3613
+ snapshot = await request();
3614
+ if (daemonStartIsReady(true, snapshot, expectedVersion)) return snapshot;
3615
+ await wait(READY_SNAPSHOT_POLL_MS);
3363
3616
  }
3364
3617
  return snapshot;
3365
3618
  }
@@ -3372,11 +3625,20 @@ async function verifiedPid(existing) {
3372
3625
  );
3373
3626
  return snapshot?.pid === existing.pid ? snapshot : null;
3374
3627
  }
3375
- function daemonStartIsHealthy(serviceReady, snapshot, expectedVersion) {
3376
- return serviceReady && snapshot?.healthy === true && expectedVersion !== void 0 && snapshot.version === expectedVersion;
3628
+ function daemonStartIsReady(serviceReady, snapshot, expectedVersion) {
3629
+ if (!serviceReady || !snapshot || expectedVersion === void 0) return false;
3630
+ if (snapshot.version !== expectedVersion || snapshot.needsReauth === true) return false;
3631
+ if (snapshot.healthy === true) return true;
3632
+ return snapshot.heartbeat?.healthy === true && snapshot.ingestion?.healthy === false;
3633
+ }
3634
+ function daemonBacklogFields(snapshot) {
3635
+ const state = deliveryBacklogState(snapshot?.ingestion);
3636
+ if (!state) return {};
3637
+ if (state === "draining") return { draining: true, ingestion: snapshot?.ingestion };
3638
+ return state === "retained" ? { draining: false, deliveryRetained: true, ingestion: snapshot?.ingestion } : { draining: false, deliveryFailing: true, ingestion: snapshot?.ingestion };
3377
3639
  }
3378
- function daemonStartHealthFields(healthy, snapshot) {
3379
- if (healthy) return {};
3640
+ function daemonStartHealthFields(ready, snapshot) {
3641
+ if (ready) return daemonBacklogFields(snapshot);
3380
3642
  return {
3381
3643
  state: "degraded",
3382
3644
  needsReauth: snapshot?.needsReauth === true,
@@ -3384,16 +3646,28 @@ function daemonStartHealthFields(healthy, snapshot) {
3384
3646
  ingestion: snapshot?.ingestion ?? null
3385
3647
  };
3386
3648
  }
3649
+ function daemonStartFailureReason(snapshot, expectedVersion) {
3650
+ if (snapshot && !snapshot.needsReauth && expectedVersion !== void 0 && snapshot.version !== expectedVersion) {
3651
+ return `daemon reports ${snapshot.version ? `v${snapshot.version}` : "no version"}, expected v${expectedVersion}`;
3652
+ }
3653
+ return daemonDegradedReason(snapshot);
3654
+ }
3387
3655
  async function detachedDaemonStart(opts) {
3388
3656
  const existing = readPidfile();
3389
3657
  if (existing?.alive) {
3390
3658
  const snapshot = await verifiedPid(existing);
3391
3659
  if (snapshot) {
3392
3660
  process.stderr.write(
3393
- `${formatCurrentDaemonLifecycleMessage(`[prim] daemon already running (pid=${existing.pid})`)}
3661
+ `${formatCurrentDaemonLifecycleMessage(`[prim] daemon already running (pid=${existing.pid})`, snapshot)}
3394
3662
  `
3395
3663
  );
3396
- console.log(JSON.stringify({ started: false, pid: existing.pid }, null, 2));
3664
+ console.log(
3665
+ JSON.stringify(
3666
+ { started: false, pid: existing.pid, ...daemonBacklogFields(snapshot) },
3667
+ null,
3668
+ 2
3669
+ )
3670
+ );
3397
3671
  return;
3398
3672
  }
3399
3673
  process.stderr.write(
@@ -3442,16 +3716,19 @@ async function detachedDaemonStart(opts) {
3442
3716
  });
3443
3717
  child.unref();
3444
3718
  if (logFd !== void 0) {
3445
- closeSync(logFd);
3719
+ closeSync2(logFd);
3446
3720
  }
3447
3721
  const live = await waitForReady();
3448
3722
  if (live) {
3449
3723
  const after = readPidfile();
3724
+ const snapshot = await requestStatusSnapshot();
3450
3725
  process.stderr.write(
3451
- `${formatCurrentDaemonLifecycleMessage(`[prim] \u2713 daemon started (pid=${after?.pid ?? "?"}, socket=${SOCK_PATH})`)}
3726
+ `${formatCurrentDaemonLifecycleMessage(`[prim] \u2713 daemon started (pid=${after?.pid ?? "?"}, socket=${SOCK_PATH})`, snapshot)}
3452
3727
  `
3453
3728
  );
3454
- console.log(JSON.stringify({ started: true, pid: after?.pid }, null, 2));
3729
+ console.log(
3730
+ JSON.stringify({ started: true, pid: after?.pid, ...daemonBacklogFields(snapshot) }, null, 2)
3731
+ );
3455
3732
  return;
3456
3733
  }
3457
3734
  process.stderr.write(
@@ -3574,19 +3851,25 @@ async function macDaemonStart(forceRestart = false) {
3574
3851
  const result = await ensureMacDaemon({ explicitlyStarted: true, forceRestart });
3575
3852
  const serviceReady = result.state === "running";
3576
3853
  const expectedVersion = result.runtime?.manifest.version;
3577
- const snapshot = serviceReady && expectedVersion ? await waitForHealthySnapshot(expectedVersion) : null;
3578
- const healthy = daemonStartIsHealthy(serviceReady, snapshot, expectedVersion);
3579
- if (healthy) {
3854
+ const snapshot = serviceReady && expectedVersion ? await waitForReadySnapshot(expectedVersion) : null;
3855
+ const ready = daemonStartIsReady(serviceReady, snapshot, expectedVersion);
3856
+ if (ready) {
3580
3857
  const verb = result.action === "none" ? "already running" : "started";
3581
3858
  const servicePid = result.service.loaded ? result.service.pid : void 0;
3582
3859
  process.stderr.write(
3583
- `${formatCurrentDaemonLifecycleMessage(`[prim] \u2713 daemon ${verb} under launchd (pid=${snapshot?.pid ?? servicePid ?? "?"})`)}
3860
+ `${formatCurrentDaemonLifecycleMessage(`[prim] \u2713 daemon ${verb} under launchd (pid=${snapshot?.pid ?? servicePid ?? "?"})`, snapshot)}
3584
3861
  `
3585
3862
  );
3863
+ } else if (!(serviceReady && expectedVersion)) {
3864
+ process.stderr.write(
3865
+ `[prim] \u2717 launchd did not converge on the desired daemon (${result.action}) (see ${LOG_PATH})
3866
+ `
3867
+ );
3868
+ if (!process.exitCode) process.exitCode = EXIT_NOT_RUNNING;
3586
3869
  } else {
3587
- const reason = daemonDegradedReason(snapshot);
3870
+ const reason = daemonStartFailureReason(snapshot, expectedVersion);
3588
3871
  process.stderr.write(
3589
- `[prim] \u2717 launchd daemon did not reach healthy heartbeat + ingestion state${reason ? ` \xB7 ${reason}` : ""} (see ${LOG_PATH})
3872
+ `[prim] \u2717 launchd daemon did not reach a healthy heartbeat on the expected version${reason ? ` \xB7 ${reason}` : ""} (see ${LOG_PATH})
3590
3873
  `
3591
3874
  );
3592
3875
  if (!process.exitCode) process.exitCode = EXIT_NOT_RUNNING;
@@ -3594,14 +3877,20 @@ async function macDaemonStart(forceRestart = false) {
3594
3877
  console.log(
3595
3878
  JSON.stringify(
3596
3879
  {
3597
- started: healthy,
3880
+ started: ready,
3598
3881
  supervised: true,
3599
3882
  action: result.action,
3600
3883
  pid: snapshot?.pid ?? (result.service.loaded ? result.service.pid : void 0),
3601
3884
  loaded: result.service.loaded,
3602
3885
  responding: result.responding,
3603
- healthy,
3604
- ...daemonStartHealthFields(healthy, snapshot),
3886
+ // `healthy` keeps meaning "start succeeded" (always equal to `started`
3887
+ // and the exit code) so existing consumers stay correct. It is not the
3888
+ // daemon's own health: `daemon status --json` stays degraded while a
3889
+ // backlog drains, is held back, or fails to deliver, which start
3890
+ // reports additively via `draining`, `deliveryRetained`,
3891
+ // `deliveryFailing`, and `ingestion`.
3892
+ healthy: ready,
3893
+ ...daemonStartHealthFields(ready, snapshot),
3605
3894
  version: snapshot?.version,
3606
3895
  expectedVersion
3607
3896
  },
@@ -3905,11 +4194,12 @@ async function daemonEnsure() {
3905
4194
  return { disabled };
3906
4195
  }
3907
4196
  const result = await ensureMacDaemon();
4197
+ const snapshot = result.state === "running" ? await requestStatusSnapshot() : null;
3908
4198
  if (result.state === "disabled") {
3909
4199
  process.stderr.write("[prim] daemon remains explicitly disabled\n");
3910
4200
  } else if (result.state === "running") {
3911
4201
  process.stderr.write(
3912
- `${formatCurrentDaemonLifecycleMessage(`[prim] \u2713 daemon ensured under launchd (${result.action})`)}
4202
+ `${formatCurrentDaemonLifecycleMessage(`[prim] \u2713 daemon ensured under launchd (${result.action})`, snapshot)}
3913
4203
  `
3914
4204
  );
3915
4205
  } else {
@@ -3923,7 +4213,8 @@ async function daemonEnsure() {
3923
4213
  ensured: result.state === "running",
3924
4214
  disabled: result.state === "disabled",
3925
4215
  supervised: true,
3926
- action: result.action
4216
+ action: result.action,
4217
+ ...daemonBacklogFields(snapshot)
3927
4218
  },
3928
4219
  null,
3929
4220
  2
@@ -4958,11 +5249,11 @@ function asRecord(value, path) {
4958
5249
  }
4959
5250
  return value;
4960
5251
  }
4961
- function assertKeys(record2, path, required, optional = []) {
5252
+ function assertKeys(record3, path, required, optional = []) {
4962
5253
  const allowed = /* @__PURE__ */ new Set([...required, ...optional]);
4963
- const missing = required.find((key) => !Object.hasOwn(record2, key));
5254
+ const missing = required.find((key) => !Object.hasOwn(record3, key));
4964
5255
  if (missing !== void 0) contractError(path, `is missing ${missing}`);
4965
- const extra = Object.keys(record2).find((key) => !allowed.has(key));
5256
+ const extra = Object.keys(record3).find((key) => !allowed.has(key));
4966
5257
  if (extra !== void 0) contractError(path, `contains unexpected field ${extra}`);
4967
5258
  }
4968
5259
  function asString(value, path, options) {
@@ -4992,97 +5283,97 @@ function asSha(value, path) {
4992
5283
  if (!SHA_RE.test(sha)) contractError(path, "must be a canonical lowercase Git commit SHA");
4993
5284
  return sha;
4994
5285
  }
4995
- function optionalString(record2, key, path, max) {
4996
- if (!Object.hasOwn(record2, key)) return void 0;
4997
- return asString(record2[key], `${path}.${key}`, { max, nonempty: true });
5286
+ function optionalString(record3, key, path, max) {
5287
+ if (!Object.hasOwn(record3, key)) return void 0;
5288
+ return asString(record3[key], `${path}.${key}`, { max, nonempty: true });
4998
5289
  }
4999
5290
  function parseProposal(value, path) {
5000
- const record2 = asRecord(value, path);
5291
+ const record3 = asRecord(value, path);
5001
5292
  assertKeys(
5002
- record2,
5293
+ record3,
5003
5294
  path,
5004
5295
  ["_id", "repoFullName", "deadCommitSha", "status", "signals", "lastEvaluatedAt"],
5005
5296
  ["proposedSha", "proposedTier", "nextEvaluateAt"]
5006
5297
  );
5007
- const repoFullName = asString(record2.repoFullName, `${path}.repoFullName`, {
5298
+ const repoFullName = asString(record3.repoFullName, `${path}.repoFullName`, {
5008
5299
  max: 200,
5009
5300
  nonempty: true
5010
5301
  });
5011
5302
  if (!REPO_FULL_NAME_RE.test(repoFullName)) {
5012
5303
  contractError(`${path}.repoFullName`, "must be canonical owner/repository syntax");
5013
5304
  }
5014
- const status = record2.status;
5305
+ const status = record3.status;
5015
5306
  if (status !== "proposed" && status !== "confirmed" && status !== "verification_failed") {
5016
5307
  contractError(`${path}.status`, "is not a review-visible proposal status");
5017
5308
  }
5018
- if (!Array.isArray(record2.signals) || record2.signals.length > MAX_SIGNALS) {
5309
+ if (!Array.isArray(record3.signals) || record3.signals.length > MAX_SIGNALS) {
5019
5310
  contractError(`${path}.signals`, "must be a bounded array");
5020
5311
  }
5021
- const signals = record2.signals.map(
5312
+ const signals = record3.signals.map(
5022
5313
  (signal, index) => asString(signal, `${path}.signals[${String(index)}]`, {
5023
5314
  max: MAX_SIGNAL_LENGTH,
5024
5315
  nonempty: true
5025
5316
  })
5026
5317
  );
5027
- const proposedSha = Object.hasOwn(record2, "proposedSha") ? asSha(record2.proposedSha, `${path}.proposedSha`) : void 0;
5318
+ const proposedSha = Object.hasOwn(record3, "proposedSha") ? asSha(record3.proposedSha, `${path}.proposedSha`) : void 0;
5028
5319
  let proposedTier;
5029
- if (Object.hasOwn(record2, "proposedTier")) {
5030
- if (record2.proposedTier !== "exact_patch_body" && record2.proposedTier !== "exact_file_set") {
5320
+ if (Object.hasOwn(record3, "proposedTier")) {
5321
+ if (record3.proposedTier !== "exact_patch_body" && record3.proposedTier !== "exact_file_set") {
5031
5322
  contractError(`${path}.proposedTier`, "is not a supported match tier");
5032
5323
  }
5033
- proposedTier = record2.proposedTier;
5324
+ proposedTier = record3.proposedTier;
5034
5325
  }
5035
- const nextEvaluateAt = Object.hasOwn(record2, "nextEvaluateAt") ? asTimestamp(record2.nextEvaluateAt, `${path}.nextEvaluateAt`) : void 0;
5326
+ const nextEvaluateAt = Object.hasOwn(record3, "nextEvaluateAt") ? asTimestamp(record3.nextEvaluateAt, `${path}.nextEvaluateAt`) : void 0;
5036
5327
  return {
5037
- _id: asString(record2._id, `${path}._id`, { max: 256, nonempty: true }),
5328
+ _id: asString(record3._id, `${path}._id`, { max: 256, nonempty: true }),
5038
5329
  repoFullName,
5039
- deadCommitSha: asSha(record2.deadCommitSha, `${path}.deadCommitSha`),
5330
+ deadCommitSha: asSha(record3.deadCommitSha, `${path}.deadCommitSha`),
5040
5331
  status,
5041
5332
  proposedSha,
5042
5333
  proposedTier,
5043
5334
  signals,
5044
- lastEvaluatedAt: asTimestamp(record2.lastEvaluatedAt, `${path}.lastEvaluatedAt`),
5335
+ lastEvaluatedAt: asTimestamp(record3.lastEvaluatedAt, `${path}.lastEvaluatedAt`),
5045
5336
  nextEvaluateAt
5046
5337
  };
5047
5338
  }
5048
5339
  function parseDecision(value, path) {
5049
- const record2 = asRecord(value, path);
5050
- assertKeys(record2, path, ["_id", "intent", "status", "intentTruncated"], ["shortId"]);
5051
- const status = record2.status;
5340
+ const record3 = asRecord(value, path);
5341
+ assertKeys(record3, path, ["_id", "intent", "status", "intentTruncated"], ["shortId"]);
5342
+ const status = record3.status;
5052
5343
  if (status !== "provisional" && status !== "abandoned") {
5053
5344
  contractError(`${path}.status`, "is not a repairable decision status");
5054
5345
  }
5055
5346
  return {
5056
- _id: asString(record2._id, `${path}._id`, { max: 256, nonempty: true }),
5057
- shortId: optionalString(record2, "shortId", path, 128),
5058
- intent: asString(record2.intent, `${path}.intent`, { max: MAX_INTENT_LENGTH }),
5347
+ _id: asString(record3._id, `${path}._id`, { max: 256, nonempty: true }),
5348
+ shortId: optionalString(record3, "shortId", path, 128),
5349
+ intent: asString(record3.intent, `${path}.intent`, { max: MAX_INTENT_LENGTH }),
5059
5350
  status,
5060
- intentTruncated: asBoolean(record2.intentTruncated, `${path}.intentTruncated`)
5351
+ intentTruncated: asBoolean(record3.intentTruncated, `${path}.intentTruncated`)
5061
5352
  };
5062
5353
  }
5063
5354
  function parseListItem(value, path) {
5064
- const record2 = asRecord(value, path);
5065
- assertKeys(record2, path, [
5355
+ const record3 = asRecord(value, path);
5356
+ assertKeys(record3, path, [
5066
5357
  "proposal",
5067
5358
  "decisions",
5068
5359
  "affectedDecisionCount",
5069
5360
  "decisionsTruncated",
5070
5361
  "reviewToken"
5071
5362
  ]);
5072
- if (!Array.isArray(record2.decisions) || record2.decisions.length > MAX_REPAIR_DECISIONS_FOR_CONFIRM) {
5363
+ if (!Array.isArray(record3.decisions) || record3.decisions.length > MAX_REPAIR_DECISIONS_FOR_CONFIRM) {
5073
5364
  contractError(`${path}.decisions`, "must be a bounded array");
5074
5365
  }
5075
- const decisions = record2.decisions.map(
5366
+ const decisions = record3.decisions.map(
5076
5367
  (decision, index) => parseDecision(decision, `${path}.decisions[${String(index)}]`)
5077
5368
  );
5078
- const proposal = parseProposal(record2.proposal, `${path}.proposal`);
5079
- const decisionsTruncated = asBoolean(record2.decisionsTruncated, `${path}.decisionsTruncated`);
5080
- const affectedDecisionCount = record2.affectedDecisionCount === null ? null : asCount(record2.affectedDecisionCount, `${path}.affectedDecisionCount`);
5369
+ const proposal = parseProposal(record3.proposal, `${path}.proposal`);
5370
+ const decisionsTruncated = asBoolean(record3.decisionsTruncated, `${path}.decisionsTruncated`);
5371
+ const affectedDecisionCount = record3.affectedDecisionCount === null ? null : asCount(record3.affectedDecisionCount, `${path}.affectedDecisionCount`);
5081
5372
  let reviewToken;
5082
- if (record2.reviewToken === null) {
5373
+ if (record3.reviewToken === null) {
5083
5374
  reviewToken = null;
5084
5375
  } else {
5085
- reviewToken = asString(record2.reviewToken, `${path}.reviewToken`, {
5376
+ reviewToken = asString(record3.reviewToken, `${path}.reviewToken`, {
5086
5377
  max: 64,
5087
5378
  nonempty: true
5088
5379
  });
@@ -5115,19 +5406,19 @@ function parseListItem(value, path) {
5115
5406
  }
5116
5407
  function parseListPage(value, pageNumber) {
5117
5408
  const path = `page ${String(pageNumber)}`;
5118
- const record2 = asRecord(value, path);
5119
- assertKeys(record2, path, ["repairs", "isDone", "nextCursor", "truncated"]);
5120
- if (!Array.isArray(record2.repairs) || record2.repairs.length > MAX_REPAIRS_PER_PAGE) {
5409
+ const record3 = asRecord(value, path);
5410
+ assertKeys(record3, path, ["repairs", "isDone", "nextCursor", "truncated"]);
5411
+ if (!Array.isArray(record3.repairs) || record3.repairs.length > MAX_REPAIRS_PER_PAGE) {
5121
5412
  contractError(`${path}.repairs`, "must be a bounded array");
5122
5413
  }
5123
- const isDone = asBoolean(record2.isDone, `${path}.isDone`);
5124
- const truncated = asBoolean(record2.truncated, `${path}.truncated`);
5414
+ const isDone = asBoolean(record3.isDone, `${path}.isDone`);
5415
+ const truncated = asBoolean(record3.truncated, `${path}.truncated`);
5125
5416
  if (truncated === isDone) contractError(path, "has inconsistent isDone/truncated flags");
5126
5417
  let nextCursor;
5127
- if (record2.nextCursor === null) {
5418
+ if (record3.nextCursor === null) {
5128
5419
  nextCursor = null;
5129
5420
  } else {
5130
- nextCursor = asString(record2.nextCursor, `${path}.nextCursor`, {
5421
+ nextCursor = asString(record3.nextCursor, `${path}.nextCursor`, {
5131
5422
  max: MAX_CURSOR_LENGTH,
5132
5423
  nonempty: true
5133
5424
  });
@@ -5135,7 +5426,7 @@ function parseListPage(value, pageNumber) {
5135
5426
  if (isDone && nextCursor !== null) contractError(path, "is done but includes a next cursor");
5136
5427
  if (!isDone && nextCursor === null) contractError(path, "is incomplete without a next cursor");
5137
5428
  return {
5138
- repairs: record2.repairs.map(
5429
+ repairs: record3.repairs.map(
5139
5430
  (item, index) => parseListItem(item, `${path}.repairs[${String(index)}]`)
5140
5431
  ),
5141
5432
  isDone,
@@ -5213,23 +5504,23 @@ function normalizedResolutionInput(proposalId, expectedProposedSha) {
5213
5504
  return { proposalId, expectedProposedSha: expectedProposedSha.toLowerCase() };
5214
5505
  }
5215
5506
  function parseResolutionOutcome(value, action) {
5216
- const record2 = asRecord(value, "resolution response");
5507
+ const record3 = asRecord(value, "resolution response");
5217
5508
  const allowed = action === "confirm" ? ["confirmed", "already_applied", "disabled", "no_op", "stale"] : ["rejected", "already_applied", "no_op", "stale"];
5218
- if (record2.status === "backoff") {
5219
- assertKeys(record2, "resolution response", ["status", "retryAt"]);
5509
+ if (record3.status === "backoff") {
5510
+ assertKeys(record3, "resolution response", ["status", "retryAt"]);
5220
5511
  if (action !== "confirm") {
5221
5512
  contractError("resolution response.status", "backoff is impossible for reject");
5222
5513
  }
5223
5514
  return {
5224
5515
  status: "backoff",
5225
- retryAt: asTimestamp(record2.retryAt, "resolution response.retryAt")
5516
+ retryAt: asTimestamp(record3.retryAt, "resolution response.retryAt")
5226
5517
  };
5227
5518
  }
5228
- assertKeys(record2, "resolution response", ["status"]);
5229
- if (typeof record2.status !== "string" || !allowed.includes(record2.status)) {
5519
+ assertKeys(record3, "resolution response", ["status"]);
5520
+ if (typeof record3.status !== "string" || !allowed.includes(record3.status)) {
5230
5521
  contractError("resolution response.status", "is not a supported outcome");
5231
5522
  }
5232
- return { status: record2.status };
5523
+ return { status: record3.status };
5233
5524
  }
5234
5525
  function normalizedReviewToken(value) {
5235
5526
  if (value === void 0 || !REVIEW_TOKEN_INPUT_RE.test(value)) {
@@ -5240,18 +5531,18 @@ function normalizedReviewToken(value) {
5240
5531
  return value.toLowerCase();
5241
5532
  }
5242
5533
  function parseConflictOutcome(value, action) {
5243
- const record2 = asRecord(value, "resolution conflict response");
5244
- assertKeys(record2, "resolution conflict response", ["status"]);
5534
+ const record3 = asRecord(value, "resolution conflict response");
5535
+ assertKeys(record3, "resolution conflict response", ["status"]);
5245
5536
  if (action !== "confirm") {
5246
5537
  contractError(
5247
5538
  "resolution conflict response.status",
5248
5539
  "a review conflict is impossible for reject"
5249
5540
  );
5250
5541
  }
5251
- if (record2.status !== "review_too_large" && record2.status !== "stale_review") {
5542
+ if (record3.status !== "review_too_large" && record3.status !== "stale_review") {
5252
5543
  contractError("resolution conflict response.status", "is not a supported 409 outcome");
5253
5544
  }
5254
- return { status: record2.status };
5545
+ return { status: record3.status };
5255
5546
  }
5256
5547
  async function resolveRepair(proposalId, expectedProposedSha, action, expectedReviewToken, deps = defaultDeps6) {
5257
5548
  const normalized = normalizedResolutionInput(proposalId, expectedProposedSha);
@@ -6306,6 +6597,7 @@ function registerDecisionsCommands(program2) {
6306
6597
 
6307
6598
  // src/commands/doctor.ts
6308
6599
  import { existsSync as existsSync6 } from "fs";
6600
+ import { Option as Option4 } from "commander";
6309
6601
 
6310
6602
  // src/protocol/decision-lifecycle.ts
6311
6603
  var DECISION_LIFECYCLE_PROTOCOL_VERSION = 2;
@@ -6330,28 +6622,35 @@ function parseOrganizationBinding(value) {
6330
6622
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
6331
6623
  return { state: "server_contract_unavailable" };
6332
6624
  }
6333
- const record2 = value;
6334
- if (record2.authenticated !== true || record2.organizationBindingVersion !== ORGANIZATION_BINDING_VERSION || !isCaptureAuthorityKind(record2.captureAuthorityKind)) {
6625
+ const record3 = value;
6626
+ if (record3.authenticated !== true || record3.organizationBindingVersion !== ORGANIZATION_BINDING_VERSION || !isCaptureAuthorityKind(record3.captureAuthorityKind)) {
6335
6627
  return { state: "server_contract_unavailable" };
6336
6628
  }
6337
- if (record2.organizationId === null && record2.workosOrganizationId === null) {
6629
+ if (record3.organizationId === null && record3.workosOrganizationId === null) {
6338
6630
  return { state: "no_current_organization" };
6339
6631
  }
6340
- if (safeOrganizationId(record2.organizationId) && record2.workosOrganizationId === null) {
6632
+ if (safeOrganizationId(record3.organizationId) && record3.workosOrganizationId === null) {
6341
6633
  return { state: "organization_identity_unreconciled" };
6342
6634
  }
6343
- if (!safeOrganizationId(record2.organizationId) || !safeOrganizationId(record2.workosOrganizationId)) {
6635
+ if (!safeOrganizationId(record3.organizationId) || !safeOrganizationId(record3.workosOrganizationId)) {
6344
6636
  return { state: "server_contract_unavailable" };
6345
6637
  }
6346
6638
  return {
6347
6639
  state: "current",
6348
6640
  binding: {
6349
- captureAuthorityKind: record2.captureAuthorityKind,
6350
- organizationId: record2.organizationId,
6351
- workosOrganizationId: record2.workosOrganizationId
6641
+ captureAuthorityKind: record3.captureAuthorityKind,
6642
+ organizationId: record3.organizationId,
6643
+ workosOrganizationId: record3.workosOrganizationId
6352
6644
  }
6353
6645
  };
6354
6646
  }
6647
+ function summarizeRetainedBuckets(retained) {
6648
+ const counts = /* @__PURE__ */ new Map();
6649
+ for (const item of retained) {
6650
+ counts.set(item.reason, (counts.get(item.reason) ?? 0) + 1);
6651
+ }
6652
+ return [...counts].sort(([left], [right]) => left.localeCompare(right)).map(([reason, count]) => `${reason}:${String(count)}`).join(", ");
6653
+ }
6355
6654
  function classifyJournalBuckets(buckets, binding) {
6356
6655
  const deliverableBuckets = /* @__PURE__ */ new Set();
6357
6656
  const retainedBuckets = [];
@@ -6423,7 +6722,7 @@ async function inspectJournalDelivery(buckets, options = {}) {
6423
6722
  }
6424
6723
 
6425
6724
  // src/commands/hermes-install.ts
6426
- import { existsSync as existsSync5, readFileSync as readFileSync5, rmSync as rmSync2, statSync } from "fs";
6725
+ import { existsSync as existsSync5, readFileSync as readFileSync5, rmSync as rmSync2, statSync as statSync2 } from "fs";
6427
6726
  import { homedir as homedir4 } from "os";
6428
6727
  import { join as join6 } from "path";
6429
6728
  import { Document, isMap, isSeq, parseAllDocuments, stringify, visit } from "yaml";
@@ -6831,7 +7130,7 @@ function readConfigSnapshot(path) {
6831
7130
  if (!existsSync5(path)) return { exists: false, mode: CONFIG_MODE, raw: "" };
6832
7131
  return {
6833
7132
  exists: true,
6834
- mode: statSync(path).mode & 511,
7133
+ mode: statSync2(path).mode & 511,
6835
7134
  raw: readFileSync5(path, "utf8")
6836
7135
  };
6837
7136
  }
@@ -6973,7 +7272,6 @@ function registerHermesCommands(program2) {
6973
7272
  // src/commands/doctor.ts
6974
7273
  var DAEMON_PROBE_TIMEOUT_MS = 500;
6975
7274
  var CONNECTIVITY_TIMEOUT_MS = 3e3;
6976
- var MS_PER_SECOND = 1e3;
6977
7275
  var STALE_PENDING_MS = 3e4;
6978
7276
  var EXIT_UNHEALTHY = 1;
6979
7277
  function classifyDoctor(checks) {
@@ -7095,8 +7393,19 @@ function classifyDaemonHealth(snapshot, options = {}) {
7095
7393
  };
7096
7394
  }
7097
7395
  if (!snapshot.ingestion?.healthy) {
7098
- const pending = snapshot.ingestion?.pendingCount ?? 0;
7099
- const pendingLabel = snapshot.ingestion?.pendingSampled ? `at least ${String(pending)}` : String(pending);
7396
+ if (options.backlogExpected && deliveryBacklogState(snapshot.ingestion) === "draining") {
7397
+ const ingestionStatus2 = options.ingestionStatus ?? decisionIngestionStatus(process.cwd());
7398
+ const live = options.backlog;
7399
+ const progress = live && live.pendingCount === 0 && live.pendingSampled !== true ? "backlog drained; daemon health refreshes on its next sweep" : deliveryBacklogSummary(snapshot.ingestion, options.now, live);
7400
+ return {
7401
+ name: "daemon",
7402
+ status: "warn",
7403
+ detail: `supervised and live${snapshot.version ? ` \xB7 v${snapshot.version}` : ""} \xB7 Decision ingestion ${ingestionStatus2} \xB7 ${progress}`
7404
+ };
7405
+ }
7406
+ const counts = options.backlog ?? snapshot.ingestion;
7407
+ const pending = counts?.pendingCount ?? 0;
7408
+ const pendingLabel = counts?.pendingSampled ? `at least ${String(pending)}` : String(pending);
7100
7409
  return {
7101
7410
  name: "daemon",
7102
7411
  status: "fail",
@@ -7113,17 +7422,23 @@ function classifyDaemonHealth(snapshot, options = {}) {
7113
7422
  detail: `supervised and healthy${snapshot.version ? ` \xB7 v${snapshot.version}` : ""} \xB7 Decision ingestion ${ingestionStatus}`
7114
7423
  };
7115
7424
  }
7116
- async function checkDaemon() {
7425
+ async function probeDelivery() {
7426
+ const stats = pendingJournalStats();
7427
+ const now = Date.now();
7428
+ const disabled = daemonExplicitlyDisabled();
7429
+ const expectedVersion = packageVersion();
7117
7430
  let service;
7118
7431
  if (process.platform === "darwin") {
7119
7432
  try {
7120
7433
  service = getLaunchdService();
7121
7434
  } catch (error) {
7122
- const detail = boundedHealthError(error instanceof Error ? error.message : String(error));
7123
7435
  return {
7124
- name: "daemon",
7125
- status: "fail",
7126
- detail: `launchd status unavailable${detail ? `: ${detail}` : ""}`
7436
+ snapshot: null,
7437
+ launchdError: error instanceof Error ? error.message : String(error),
7438
+ disabled,
7439
+ expectedVersion,
7440
+ stats,
7441
+ now
7127
7442
  };
7128
7443
  }
7129
7444
  }
@@ -7132,53 +7447,74 @@ async function checkDaemon() {
7132
7447
  { callerEnv: getSiteUrl() },
7133
7448
  { timeoutMs: DAEMON_PROBE_TIMEOUT_MS }
7134
7449
  );
7135
- return classifyDaemonHealth(snapshot, {
7136
- disabled: daemonExplicitlyDisabled(),
7137
- service,
7138
- expectedVersion: packageVersion()
7450
+ return { snapshot, service, disabled, expectedVersion, stats, now };
7451
+ }
7452
+ function daemonCheckVouchesForDrain(check) {
7453
+ return check.name === "daemon" && check.status !== "fail";
7454
+ }
7455
+ function classifyDelivery(probe, options = {}) {
7456
+ const { stats, now } = probe;
7457
+ const launchdDetail = probe.launchdError === void 0 ? void 0 : boundedHealthError(probe.launchdError);
7458
+ const daemon = probe.launchdError === void 0 ? classifyDaemonHealth(probe.snapshot, {
7459
+ disabled: probe.disabled,
7460
+ service: probe.service,
7461
+ ingestionStatus: probe.ingestionStatus,
7462
+ expectedVersion: probe.expectedVersion,
7463
+ backlogExpected: options.backlogExpected,
7464
+ backlog: {
7465
+ pendingCount: stats.pendingCount,
7466
+ pendingSampled: stats.sampled,
7467
+ oldestPendingAt: stats.oldestPendingAt
7468
+ },
7469
+ now
7470
+ }) : {
7471
+ name: "daemon",
7472
+ status: "fail",
7473
+ detail: `launchd status unavailable${launchdDetail ? `: ${launchdDetail}` : ""}`
7474
+ };
7475
+ const journal = classifyJournal(stats, now, {
7476
+ backlogExpected: options.backlogExpected === true && daemonCheckVouchesForDrain(daemon)
7139
7477
  });
7478
+ return [daemon, journal, classifyStranded(stats)];
7140
7479
  }
7141
- function checkJournal() {
7142
- const stats = pendingJournalStats();
7480
+ function classifyJournal(stats, now, options = {}) {
7143
7481
  const pending = stats.pendingCount;
7144
7482
  const pendingLabel = stats.sampled ? `at least ${String(pending)}` : String(pending);
7483
+ const backlog = (detail) => options.backlogExpected ? {
7484
+ name: "journal",
7485
+ status: "warn",
7486
+ detail: `${formatPendingBacklog(
7487
+ {
7488
+ pendingCount: pending,
7489
+ pendingSampled: stats.sampled,
7490
+ oldestPendingAt: stats.oldestPendingAt
7491
+ },
7492
+ now
7493
+ )} \u2014 draining in the background`
7494
+ } : { name: "journal", status: "fail", detail };
7145
7495
  if (pending === 0) {
7146
7496
  if (stats.sampled) {
7147
- return {
7148
- name: "journal",
7149
- status: "fail",
7150
- detail: "bounded journal sample could not prove the queue is empty"
7151
- };
7497
+ return backlog("bounded journal sample could not prove the queue is empty");
7152
7498
  }
7153
7499
  return { name: "journal", status: "ok", detail: "no pending moves" };
7154
7500
  }
7155
7501
  if (stats.oldestPendingAt === void 0) {
7156
- return {
7157
- name: "journal",
7158
- status: "fail",
7159
- detail: `${pendingLabel} pending with no readable capture timestamp`
7160
- };
7502
+ const detail = `${pendingLabel} pending with no readable capture timestamp`;
7503
+ return stats.sampled ? backlog(detail) : { name: "journal", status: "fail", detail };
7161
7504
  }
7162
- const oldestMs = Date.now() - stats.oldestPendingAt;
7505
+ const oldestMs = now - stats.oldestPendingAt;
7163
7506
  const oldestS = Math.round(oldestMs / MS_PER_SECOND);
7164
7507
  if (oldestMs > STALE_PENDING_MS) {
7165
- return {
7166
- name: "journal",
7167
- status: "fail",
7168
- detail: `${pendingLabel} pending, oldest observed ${String(oldestS)}s \u2014 30s delivery SLA missed`
7169
- };
7508
+ return backlog(
7509
+ `${pendingLabel} pending, oldest observed ${String(oldestS)}s \u2014 30s delivery SLA missed`
7510
+ );
7170
7511
  }
7171
7512
  if (stats.sampled) {
7172
- return {
7173
- name: "journal",
7174
- status: "fail",
7175
- detail: `${pendingLabel} pending in bounded sample; 30s delivery SLA cannot be proven`
7176
- };
7513
+ return backlog(`${pendingLabel} pending in bounded sample; 30s delivery SLA cannot be proven`);
7177
7514
  }
7178
7515
  return { name: "journal", status: "ok", detail: `${String(pending)} pending, draining` };
7179
7516
  }
7180
- function checkStranded() {
7181
- const stats = pendingJournalStats();
7517
+ function classifyStranded(stats) {
7182
7518
  if (stats.strandedFileCount === 0 && !stats.strandedSampled) {
7183
7519
  return { name: "stranded", status: "ok", detail: "none" };
7184
7520
  }
@@ -7204,15 +7540,10 @@ function classifyJournalOrganization(bucketCount, retainedBuckets) {
7204
7540
  detail: "all pending buckets match the active credential"
7205
7541
  };
7206
7542
  }
7207
- const reasonCounts = /* @__PURE__ */ new Map();
7208
- for (const item of retainedBuckets) {
7209
- reasonCounts.set(item.reason, (reasonCounts.get(item.reason) ?? 0) + 1);
7210
- }
7211
- const reasons = [...reasonCounts].sort(([left], [right]) => left.localeCompare(right)).map(([reason, count]) => `${reason}:${String(count)}`).join(", ");
7212
7543
  return {
7213
7544
  name: "journal-org",
7214
7545
  status: "fail",
7215
- detail: `${String(retainedBuckets.length)} bucket(s) retained (${reasons})`
7546
+ detail: `${String(retainedBuckets.length)} bucket(s) retained (${summarizeRetainedBuckets(retainedBuckets)})`
7216
7547
  };
7217
7548
  }
7218
7549
  async function checkJournalOrganization() {
@@ -7282,7 +7613,26 @@ async function checkRepositoryBinding() {
7282
7613
  };
7283
7614
  }
7284
7615
  }
7285
- function classifyManagedHook(hookName, inspection) {
7616
+ function managedHookRemedy(inspection) {
7617
+ switch (inspection.reason) {
7618
+ case "missing":
7619
+ case "missing_block":
7620
+ case "stale_block":
7621
+ case "legacy_block":
7622
+ case "unreachable_block":
7623
+ case "misplaced_block":
7624
+ if (inspection.location === "prim") return "run `prim enable` to refresh prim's global hooks";
7625
+ if (inspection.location === "external") {
7626
+ return `outside this repository \u2014 ${externalHookRemedy(inspection.hookName, inspection.gitRoot)}`;
7627
+ }
7628
+ return "run `prim hooks install`";
7629
+ case "entrypoint_missing":
7630
+ return "run `prim enable` to stage the hook runtime";
7631
+ default:
7632
+ return void 0;
7633
+ }
7634
+ }
7635
+ function classifyManagedHook(hookName, inspection, firing) {
7286
7636
  if (inspection.covered) {
7287
7637
  return {
7288
7638
  name: hookName,
@@ -7290,15 +7640,78 @@ function classifyManagedHook(hookName, inspection) {
7290
7640
  detail: `effective and executable \xB7 ${inspection.kind} \xB7 ${inspection.hookPath}`
7291
7641
  };
7292
7642
  }
7643
+ const reason = inspection.reason ?? "uncovered";
7644
+ if (inspection.mode === "manual") {
7645
+ if (hookName === "post-commit" && firing?.state === "fired") {
7646
+ return {
7647
+ name: hookName,
7648
+ status: "ok",
7649
+ detail: `manual (prim.gitHooks=manual) \xB7 ran for the latest commit \xB7 ${inspection.hookPath}`
7650
+ };
7651
+ }
7652
+ return {
7653
+ name: hookName,
7654
+ status: "warn",
7655
+ detail: `manual (prim.gitHooks=manual) \xB7 ${reason} \xB7 wire with \`prim hooks snippet ${hookName}\` \xB7 ${inspection.hookPath}`
7656
+ };
7657
+ }
7658
+ const remedy = managedHookRemedy(inspection);
7659
+ const stillCaptures = reason === "legacy_block" || reason === "misplaced_block";
7293
7660
  return {
7294
7661
  name: hookName,
7295
- status: "fail",
7296
- detail: `${inspection.reason ?? "uncovered"} \xB7 ${inspection.hookPath}`
7662
+ status: hookName === "pre-commit" || stillCaptures ? "warn" : "fail",
7663
+ detail: `${reason}${remedy ? ` \xB7 ${remedy}` : ""} \xB7 ${inspection.hookPath}`
7297
7664
  };
7298
7665
  }
7299
- function checkManagedHook(hookName, inspect) {
7666
+ function minutesAgo(at, now) {
7667
+ const minutes = Math.max(0, Math.round((now - at) / 6e4));
7668
+ return minutes === 0 ? "just now" : `${String(minutes)}m ago`;
7669
+ }
7670
+ function classifyHookFiring(firing, now = Date.now()) {
7671
+ const name = "hook-fired";
7672
+ switch (firing.state) {
7673
+ case "inactive":
7674
+ return { name, status: "ok", detail: "repository not active \u2014 no post-commit run expected" };
7675
+ case "unverified":
7676
+ return {
7677
+ name,
7678
+ status: "ok",
7679
+ detail: "no local commit since the hooks were wired \u2014 the next commit is checked"
7680
+ };
7681
+ case "pending":
7682
+ return {
7683
+ name,
7684
+ status: "ok",
7685
+ detail: `latest commit is under ${String(POST_COMMIT_GRACE_MS / 1e3)}s old \u2014 capture may still be starting`
7686
+ };
7687
+ case "fired":
7688
+ return {
7689
+ name,
7690
+ status: "ok",
7691
+ detail: `post-commit reached prim for the latest commit (${minutesAgo(firing.firedAt, now)})`
7692
+ };
7693
+ case "not_firing":
7694
+ return {
7695
+ name,
7696
+ status: "fail",
7697
+ 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`
7698
+ };
7699
+ }
7700
+ }
7701
+ function inspectFiringHere() {
7300
7702
  try {
7301
- return classifyManagedHook(hookName, inspect());
7703
+ const root = gitToplevel();
7704
+ return root ? inspectPostCommitFiring(root) : void 0;
7705
+ } catch {
7706
+ return void 0;
7707
+ }
7708
+ }
7709
+ function checkHookFiring(firing) {
7710
+ return firing ? classifyHookFiring(firing) : { name: "hook-fired", status: "warn", detail: "not a git repository" };
7711
+ }
7712
+ function checkManagedHook(hookName, firing) {
7713
+ try {
7714
+ return classifyManagedHook(hookName, inspectEffectiveGitHook(hookName), firing);
7302
7715
  } catch (error) {
7303
7716
  const detail = error instanceof Error ? error.message : String(error);
7304
7717
  return {
@@ -7617,36 +8030,39 @@ async function checkFeedbackCapability() {
7617
8030
  return { name: "feedback-api", status: "warn", detail: message.slice(0, 80) };
7618
8031
  }
7619
8032
  }
7620
- function refreshOwnedGlobalHooksForHealth() {
7621
- try {
7622
- refreshOwnedGlobalHooks();
7623
- } catch {
7624
- }
7625
- }
7626
- async function collectChecks() {
7627
- refreshOwnedGlobalHooksForHealth();
8033
+ async function independentChecks() {
7628
8034
  const backend = await checkBackend();
7629
- return [
7630
- checkAuth(),
7631
- await checkDaemon(),
7632
- checkJournal(),
7633
- checkStranded(),
7634
- await checkJournalOrganization(),
7635
- checkFeedbackHooks(),
7636
- ...checkAgentHooks(),
7637
- checkHookRuntime(),
7638
- await checkRepositoryBinding(),
7639
- checkManagedHook("post-commit", inspectEffectivePostCommitHook),
7640
- checkManagedHook("post-rewrite", inspectEffectivePostRewriteHook),
7641
- ...backend,
7642
- await checkFeedbackCapability()
7643
- ];
8035
+ const firing = inspectFiringHere();
8036
+ return {
8037
+ before: [checkAuth()],
8038
+ after: [
8039
+ await checkJournalOrganization(),
8040
+ checkFeedbackHooks(),
8041
+ ...checkAgentHooks(),
8042
+ checkHookRuntime(),
8043
+ await checkRepositoryBinding(),
8044
+ checkManagedHook("pre-commit"),
8045
+ checkManagedHook("post-commit", firing),
8046
+ checkManagedHook("post-rewrite"),
8047
+ checkHookFiring(firing),
8048
+ ...backend,
8049
+ await checkFeedbackCapability()
8050
+ ]
8051
+ };
8052
+ }
8053
+ var DEFAULT_DOCTOR_PROBES = {
8054
+ delivery: probeDelivery,
8055
+ independent: independentChecks
8056
+ };
8057
+ async function collectChecks(options, probes) {
8058
+ const { before, after } = await probes.independent();
8059
+ return [...before, ...classifyDelivery(await probes.delivery(), options), ...after];
7644
8060
  }
7645
8061
  function icon(status) {
7646
8062
  return status === "ok" ? "\u2713" : status === "warn" ? "\u26A0" : "\u2717";
7647
8063
  }
7648
- async function runDoctor() {
7649
- const checks = await collectChecks();
8064
+ async function runDoctor(options, probes) {
8065
+ const checks = await collectChecks(options, probes);
7650
8066
  const { json, exitCode } = classifyDoctor(checks);
7651
8067
  const headline = json.status === "ok" ? "\u2713 healthy" : json.status === "warn" ? "\u26A0 degraded" : "\u2717 unhealthy";
7652
8068
  process.stderr.write(`[prim] doctor: ${headline}
@@ -7660,11 +8076,14 @@ async function runDoctor() {
7660
8076
  process.exitCode = exitCode;
7661
8077
  }
7662
8078
  }
7663
- function registerDoctorCommands(program2) {
8079
+ function registerDoctorCommands(program2, dependencies = {}) {
7664
8080
  program2.command("doctor").description(
7665
8081
  "Check capture and feedback health end to end (auth, supervisor, delivery, server)"
7666
- ).action(async () => {
7667
- await runDoctor();
8082
+ ).addOption(new Option4("--expect-backlog").hideHelp()).action(async (opts) => {
8083
+ await runDoctor(
8084
+ { backlogExpected: opts.expectBacklog === true },
8085
+ dependencies.probes ?? DEFAULT_DOCTOR_PROBES
8086
+ );
7668
8087
  });
7669
8088
  }
7670
8089
 
@@ -7673,30 +8092,30 @@ import { existsSync as existsSync8, mkdirSync as mkdirSync5, unlinkSync as unlin
7673
8092
  import { join as join9 } from "path";
7674
8093
 
7675
8094
  // src/flusher.ts
7676
- import { createReadStream, renameSync as renameSync3, unlinkSync as unlinkSync3 } from "fs";
7677
- import { dirname as dirname4, join as join8 } from "path";
8095
+ import { createReadStream, renameSync as renameSync3, statSync as statSync3, unlinkSync as unlinkSync3 } from "fs";
8096
+ import { dirname as dirname3, join as join8 } from "path";
7678
8097
 
7679
8098
  // src/dead-letter.ts
7680
8099
  import { createHash as createHash3, randomBytes as randomBytes4 } from "crypto";
7681
8100
  import {
7682
- constants as constants2,
8101
+ constants as constants3,
7683
8102
  chmodSync as chmodSync2,
7684
- closeSync as closeSync2,
8103
+ closeSync as closeSync3,
7685
8104
  existsSync as existsSync7,
7686
8105
  fchmodSync,
7687
8106
  fsyncSync,
7688
8107
  mkdirSync as mkdirSync4,
7689
- openSync as openSync2,
8108
+ openSync as openSync3,
7690
8109
  renameSync as renameSync2,
7691
8110
  rmSync as rmSync3,
7692
8111
  writeFileSync as writeFileSync3
7693
8112
  } from "fs";
7694
- import { dirname as dirname3, join as join7 } from "path";
8113
+ import { dirname as dirname2, join as join7 } from "path";
7695
8114
  var DIRECTORY_MODE = 448;
7696
8115
  var FILE_MODE = 384;
7697
8116
  var DEAD_LETTER_DIRNAME = "dead-letter";
7698
8117
  function deadLetterDirectoryForRotation(flushingPath) {
7699
- return join7(dirname3(flushingPath), DEAD_LETTER_DIRNAME);
8118
+ return join7(dirname2(flushingPath), DEAD_LETTER_DIRNAME);
7700
8119
  }
7701
8120
  function deadLetterPathForRawLine(flushingPath, rawLine) {
7702
8121
  const quarantineId = createHash3("sha256").update(rawLine).digest("hex");
@@ -7706,10 +8125,10 @@ function ensureDeadLetterDirectory(flushingPath, persistDirectory) {
7706
8125
  const directory = deadLetterDirectoryForRotation(flushingPath);
7707
8126
  mkdirSync4(directory, { recursive: true, mode: DIRECTORY_MODE });
7708
8127
  chmodSync2(directory, DIRECTORY_MODE);
7709
- persistDirectory(dirname3(directory));
8128
+ persistDirectory(dirname2(directory));
7710
8129
  return directory;
7711
8130
  }
7712
- function persistDeadLetter(directory, path, quarantineId, record2, persistDirectory) {
8131
+ function persistDeadLetter(directory, path, quarantineId, record3, persistDirectory) {
7713
8132
  if (existsSync7(path)) {
7714
8133
  persistDirectory(directory);
7715
8134
  return;
@@ -7718,16 +8137,16 @@ function persistDeadLetter(directory, path, quarantineId, record2, persistDirect
7718
8137
  directory,
7719
8138
  `.${quarantineId}.${String(process.pid)}.${randomBytes4(8).toString("hex")}.tmp`
7720
8139
  );
7721
- const flags = constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY | constants2.O_NOFOLLOW;
8140
+ const flags = constants3.O_CREAT | constants3.O_EXCL | constants3.O_WRONLY | constants3.O_NOFOLLOW;
7722
8141
  try {
7723
- const fd = openSync2(temporaryPath, flags, FILE_MODE);
8142
+ const fd = openSync3(temporaryPath, flags, FILE_MODE);
7724
8143
  try {
7725
8144
  fchmodSync(fd, FILE_MODE);
7726
- writeFileSync3(fd, `${JSON.stringify(record2)}
8145
+ writeFileSync3(fd, `${JSON.stringify(record3)}
7727
8146
  `, "utf8");
7728
8147
  fsyncSync(fd);
7729
8148
  } finally {
7730
- closeSync2(fd);
8149
+ closeSync3(fd);
7731
8150
  }
7732
8151
  renameSync2(temporaryPath, path);
7733
8152
  persistDirectory(directory);
@@ -7739,22 +8158,22 @@ function quarantineMove(flushingPath, move, reason, now = Date.now, options = {}
7739
8158
  const persistDirectory = options.syncDirectory ?? syncDirectory;
7740
8159
  const directory = ensureDeadLetterDirectory(flushingPath, persistDirectory);
7741
8160
  const serializedMove = JSON.stringify(move);
7742
- const record2 = {
8161
+ const record3 = {
7743
8162
  version: 1,
7744
8163
  quarantineId: createHash3("sha256").update(serializedMove).digest("hex"),
7745
8164
  quarantinedAt: now(),
7746
8165
  reason,
7747
8166
  move
7748
8167
  };
7749
- const path = join7(directory, `${record2.quarantineId}.json`);
7750
- persistDeadLetter(directory, path, record2.quarantineId, record2, persistDirectory);
7751
- return record2;
8168
+ const path = join7(directory, `${record3.quarantineId}.json`);
8169
+ persistDeadLetter(directory, path, record3.quarantineId, record3, persistDirectory);
8170
+ return record3;
7752
8171
  }
7753
8172
  function quarantineRawLine(flushingPath, rawLine, now = Date.now, options = {}) {
7754
8173
  const persistDirectory = options.syncDirectory ?? syncDirectory;
7755
8174
  const directory = ensureDeadLetterDirectory(flushingPath, persistDirectory);
7756
8175
  const bytes = Buffer.from(rawLine);
7757
- const record2 = {
8176
+ const record3 = {
7758
8177
  version: 1,
7759
8178
  recordKind: "raw_line_v1",
7760
8179
  quarantineId: createHash3("sha256").update(bytes).digest("hex"),
@@ -7767,18 +8186,22 @@ function quarantineRawLine(flushingPath, rawLine, now = Date.now, options = {})
7767
8186
  persistDeadLetter(
7768
8187
  directory,
7769
8188
  deadLetterPathForRawLine(flushingPath, bytes),
7770
- record2.quarantineId,
7771
- record2,
8189
+ record3.quarantineId,
8190
+ record3,
7772
8191
  persistDirectory
7773
8192
  );
7774
- return record2;
8193
+ return record3;
7775
8194
  }
7776
8195
 
7777
8196
  // src/flusher.ts
7778
8197
  var BATCH_SIZE = 500;
8198
+ var BATCH_MAX_BYTES = 1048576;
7779
8199
  var HTTP_TIMEOUT_MS = 1e4;
7780
8200
  var OPPORTUNISTIC_FLUSH_AFTER_MS = 6e4;
7781
8201
  var ORPHAN_QUARANTINE_MS = 6e4;
8202
+ function exceedsBatchBytes(count, bytes, nextBytes, maxBytes) {
8203
+ return count > 0 && bytes + nextBytes > maxBytes;
8204
+ }
7782
8205
  function addDrainCounts(left, right) {
7783
8206
  return {
7784
8207
  flushed: left.flushed + right.flushed,
@@ -7789,8 +8212,8 @@ function collectionScopeRoots(batch) {
7789
8212
  return batch.flatMap((move) => {
7790
8213
  const env = move.env;
7791
8214
  if (typeof env !== "object" || env === null || Array.isArray(env)) return [];
7792
- const record2 = env;
7793
- const root = typeof record2.gitRoot === "string" ? record2.gitRoot : record2.cwd;
8215
+ const record3 = env;
8216
+ const root = typeof record3.gitRoot === "string" ? record3.gitRoot : record3.cwd;
7794
8217
  return typeof root === "string" && root.length > 0 ? [root] : [];
7795
8218
  });
7796
8219
  }
@@ -7798,8 +8221,8 @@ function isVersionedInvalidMoveDisposition(body) {
7798
8221
  if (typeof body !== "object" || body === null || Array.isArray(body)) {
7799
8222
  return false;
7800
8223
  }
7801
- const record2 = body;
7802
- return Object.keys(record2).length === 2 && record2.error === "invalid_move" && record2.errorVersion === 1;
8224
+ const record3 = body;
8225
+ return Object.keys(record3).length === 2 && record3.error === "invalid_move" && record3.errorVersion === 1;
7803
8226
  }
7804
8227
  function deadLetterReason(error) {
7805
8228
  if (error instanceof InvalidJournalEnvelopeError) {
@@ -7828,23 +8251,25 @@ function mayBeBatchSizeFailure(error) {
7828
8251
  }
7829
8252
  return error instanceof Error && error.name === "TimeoutError";
7830
8253
  }
7831
- async function* rawJournalLines(input) {
8254
+ async function* rawJournalLines(input, offset) {
7832
8255
  let pending = [];
7833
8256
  let pendingBytes = 0;
8257
+ let end = offset;
7834
8258
  for await (const value of input) {
7835
8259
  const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
7836
8260
  let start = 0;
7837
8261
  let newline = chunk.indexOf(10, start);
7838
8262
  while (newline !== -1) {
7839
8263
  const tail = chunk.subarray(start, newline + 1);
7840
- if (pending.length === 0) {
7841
- yield tail;
7842
- } else {
8264
+ let line = tail;
8265
+ if (pending.length > 0) {
7843
8266
  pending.push(tail);
7844
- yield Buffer.concat(pending, pendingBytes + tail.length);
8267
+ line = Buffer.concat(pending, pendingBytes + tail.length);
7845
8268
  pending = [];
7846
8269
  pendingBytes = 0;
7847
8270
  }
8271
+ end += line.length;
8272
+ yield { bytes: line, end };
7848
8273
  start = newline + 1;
7849
8274
  newline = chunk.indexOf(10, start);
7850
8275
  }
@@ -7855,18 +8280,43 @@ async function* rawJournalLines(input) {
7855
8280
  }
7856
8281
  }
7857
8282
  if (pendingBytes > 0) {
7858
- yield pending.length === 1 ? pending[0] : Buffer.concat(pending, pendingBytes);
8283
+ const bytes = pending.length === 1 ? pending[0] : Buffer.concat(pending, pendingBytes);
8284
+ yield { bytes, end: end + pendingBytes };
7859
8285
  }
7860
8286
  }
8287
+ var drainProgressWarned = false;
7861
8288
  async function drainFlushingPath(flushingPath, client, binding, options = {}) {
8289
+ const rotation = rotationIdentity(statSync3(flushingPath));
8290
+ const resumeAt = drainResumeOffset(flushingPath, rotation);
8291
+ const recordProgress = (end) => {
8292
+ if (end === void 0 || end >= rotation.size) {
8293
+ return;
8294
+ }
8295
+ try {
8296
+ writeDrainCheckpoint(flushingPath, { v: 2, offset: end, ...rotation });
8297
+ drainProgressWarned = false;
8298
+ } catch (error) {
8299
+ if (!drainProgressWarned) {
8300
+ drainProgressWarned = true;
8301
+ process.stderr.write(
8302
+ `[prim] could not record drain progress: ${error instanceof Error ? error.message : String(error)}
8303
+ `
8304
+ );
8305
+ }
8306
+ }
8307
+ };
8308
+ const reportProgress = (delta) => {
8309
+ options.onProgress?.(delta);
8310
+ return delta;
8311
+ };
7862
8312
  const postBatch = async (batch2) => {
8313
+ const moves = batch2.map((entry) => entry.move);
7863
8314
  try {
7864
- const request = buildOrganizationBoundMoveRequest(batch2, binding);
8315
+ const request = buildOrganizationBoundMoveRequest(moves, binding);
7865
8316
  const response = await client.post("/api/cli/moves/ingest", request, {
7866
8317
  signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
7867
8318
  });
7868
- requireDurableIngestAcknowledgement(response, batch2.length, collectionScopeRoots(batch2));
7869
- return { flushed: batch2.length, quarantined: 0 };
8319
+ requireDurableIngestAcknowledgement(response, moves.length, collectionScopeRoots(moves));
7870
8320
  } catch (error) {
7871
8321
  const reason = deadLetterReason(error);
7872
8322
  if (!(reason || mayBeBatchSizeFailure(error))) {
@@ -7881,33 +8331,43 @@ async function drainFlushingPath(flushingPath, client, binding, options = {}) {
7881
8331
  if (!reason) {
7882
8332
  throw error;
7883
8333
  }
7884
- const [move] = batch2;
8334
+ const [entry] = batch2;
7885
8335
  const quarantined = quarantineMove(
7886
8336
  flushingPath,
7887
- move,
8337
+ entry.move,
7888
8338
  reason,
7889
8339
  Date.now,
7890
8340
  options.deadLetterPersistence
7891
8341
  );
8342
+ recordProgress(entry.end);
7892
8343
  process.stderr.write(
7893
8344
  `[prim] quarantined rejected move ${quarantined.quarantineId.slice(0, 12)} (${reason})
7894
8345
  `
7895
8346
  );
7896
- return { flushed: 0, quarantined: 1 };
8347
+ return reportProgress({ flushed: 0, quarantined: 1 });
7897
8348
  }
8349
+ recordProgress(batch2.at(-1)?.end);
8350
+ return reportProgress({ flushed: batch2.length, quarantined: 0 });
7898
8351
  };
7899
- const input = createReadStream(flushingPath);
8352
+ const input = createReadStream(flushingPath, { start: resumeAt });
7900
8353
  const decoder = new TextDecoder("utf-8", { fatal: true });
7901
8354
  let batch = [];
8355
+ let batchBytes = 0;
7902
8356
  let counts = { flushed: 0, quarantined: 0 };
8357
+ const postPending = async () => {
8358
+ counts = addDrainCounts(counts, await postBatch(batch));
8359
+ batch = [];
8360
+ batchBytes = 0;
8361
+ };
7903
8362
  try {
7904
- for await (const rawLine of rawJournalLines(input)) {
8363
+ for await (const { bytes: rawLine, end } of rawJournalLines(input, resumeAt)) {
7905
8364
  if (rawLine.length === 1 && rawLine[0] === 10 || rawLine.length === 2 && rawLine[0] === 13 && rawLine[1] === 10) {
7906
8365
  continue;
7907
8366
  }
8367
+ let move;
7908
8368
  try {
7909
8369
  const line = decoder.decode(rawLine);
7910
- batch.push(JSON.parse(line));
8370
+ move = JSON.parse(line);
7911
8371
  } catch {
7912
8372
  const quarantined = quarantineRawLine(
7913
8373
  flushingPath,
@@ -7919,24 +8379,35 @@ async function drainFlushingPath(flushingPath, client, binding, options = {}) {
7919
8379
  `[prim] quarantined invalid journal line ${quarantined.quarantineId.slice(0, 12)} (invalid_move)
7920
8380
  `
7921
8381
  );
7922
- counts.quarantined += 1;
8382
+ counts = addDrainCounts(counts, reportProgress({ flushed: 0, quarantined: 1 }));
8383
+ if (batch.length === 0) {
8384
+ recordProgress(end);
8385
+ }
7923
8386
  continue;
7924
8387
  }
7925
- if (batch.length === BATCH_SIZE) {
7926
- counts = addDrainCounts(counts, await postBatch(batch));
7927
- batch = [];
8388
+ if (exceedsBatchBytes(batch.length, batchBytes, rawLine.length, BATCH_MAX_BYTES)) {
8389
+ await postPending();
8390
+ }
8391
+ batch.push({ move, end });
8392
+ batchBytes += rawLine.length;
8393
+ if (batch.length >= BATCH_SIZE) {
8394
+ await postPending();
7928
8395
  }
7929
8396
  }
7930
8397
  if (batch.length > 0) {
7931
- counts = addDrainCounts(counts, await postBatch(batch));
8398
+ await postPending();
7932
8399
  }
7933
8400
  } finally {
7934
8401
  input.destroy();
7935
8402
  }
7936
8403
  unlinkSync3(flushingPath);
8404
+ try {
8405
+ removeDrainCheckpoint(flushingPath);
8406
+ } catch {
8407
+ }
7937
8408
  return counts;
7938
8409
  }
7939
- async function drainPath(path, client, binding) {
8410
+ async function drainPath(path, client, binding, onProgress) {
7940
8411
  const tmpPath = `${path}.flushing.${String(Date.now())}.${String(process.pid)}`;
7941
8412
  try {
7942
8413
  renameSync3(path, tmpPath);
@@ -7946,7 +8417,19 @@ async function drainPath(path, client, binding) {
7946
8417
  }
7947
8418
  throw err;
7948
8419
  }
7949
- return drainFlushingPath(tmpPath, client, binding);
8420
+ return drainFlushingPath(tmpPath, client, binding, { onProgress });
8421
+ }
8422
+ async function settleDrain(drain) {
8423
+ let progress = { flushed: 0, quarantined: 0 };
8424
+ try {
8425
+ return {
8426
+ counts: await drain((delta) => {
8427
+ progress = addDrainCounts(progress, delta);
8428
+ })
8429
+ };
8430
+ } catch (error) {
8431
+ return { counts: progress, failure: { error } };
8432
+ }
7950
8433
  }
7951
8434
  function selectRecoverable(files, now, opts = {}) {
7952
8435
  const quarantineMs = opts.quarantineMs ?? ORPHAN_QUARANTINE_MS;
@@ -7963,7 +8446,8 @@ async function recoverOrphans(candidates, options) {
7963
8446
  flushed: 0,
7964
8447
  quarantined: 0,
7965
8448
  errors: [],
7966
- failedBuckets: /* @__PURE__ */ new Set()
8449
+ failedBuckets: /* @__PURE__ */ new Set(),
8450
+ failedDrainFlushed: 0
7967
8451
  };
7968
8452
  const recoverable = selectRecoverable(candidates, options.now ?? Date.now(), {
7969
8453
  ownerPid: options.ownerPid ?? process.pid,
@@ -7974,28 +8458,41 @@ async function recoverOrphans(candidates, options) {
7974
8458
  if (summary.failedBuckets.has(file.bucket)) {
7975
8459
  continue;
7976
8460
  }
7977
- try {
7978
- const counts = await drain(file.path);
7979
- summary.flushed += counts.flushed;
7980
- summary.quarantined += counts.quarantined;
7981
- } catch (err) {
7982
- summary.errors.push(err);
8461
+ const { counts, failure: failure4 } = await settleDrain((onProgress) => drain(file.path, onProgress));
8462
+ summary.flushed += counts.flushed;
8463
+ summary.quarantined += counts.quarantined;
8464
+ if (failure4) {
8465
+ summary.failedDrainFlushed += counts.flushed;
8466
+ summary.errors.push(failure4.error);
7983
8467
  summary.failedBuckets.add(file.bucket);
7984
8468
  }
7985
8469
  }
7986
8470
  return summary;
7987
8471
  }
7988
8472
  var FlushError = class extends Error {
8473
+ /** Moves the whole sweep acknowledged, across every drain it ran. */
7989
8474
  flushed;
7990
8475
  quarantined;
7991
- constructor(cause, flushed, quarantined) {
8476
+ /** Buckets the failed sweep held back without sending, as on success. */
8477
+ retained;
8478
+ /**
8479
+ * Of `flushed`, the Moves acknowledged by the drains that failed, before
8480
+ * they failed. Only this shows the failure itself is advancing: `flushed`
8481
+ * also counts other buckets' drains, so a bucket whose drain fails every
8482
+ * time would read as progress while a sibling bucket kept delivering.
8483
+ */
8484
+ failedDrainFlushed;
8485
+ constructor(cause, flushed, quarantined, retained = [], failedDrainFlushed = 0) {
7992
8486
  super(cause instanceof Error ? cause.message : String(cause), { cause });
7993
8487
  this.name = "FlushError";
7994
8488
  this.flushed = flushed;
7995
8489
  this.quarantined = quarantined;
8490
+ this.retained = retained;
8491
+ this.failedDrainFlushed = failedDrainFlushed;
7996
8492
  }
7997
8493
  };
7998
8494
  async function flushOnce() {
8495
+ sweepOrphanedDrainProgress();
7999
8496
  const orphanCandidates = listFlushing({ sampleBytes: 0 });
8000
8497
  const liveBuckets = listBuckets();
8001
8498
  if (orphanCandidates.length === 0 && liveBuckets.length === 0) {
@@ -8012,10 +8509,11 @@ async function flushOnce() {
8012
8509
  const binding = inspection.binding;
8013
8510
  const recovered = await recoverOrphans(
8014
8511
  orphanCandidates.filter((file) => inspection.deliverableBuckets.has(file.bucket)),
8015
- { drain: (path) => drainFlushingPath(path, client, binding) }
8512
+ { drain: (path, onProgress) => drainFlushingPath(path, client, binding, { onProgress }) }
8016
8513
  );
8017
8514
  let total = recovered.flushed;
8018
8515
  let quarantined = recovered.quarantined;
8516
+ let failedDrainFlushed = recovered.failedDrainFlushed;
8019
8517
  const errors = recovered.errors;
8020
8518
  for (const { bucket, path } of liveBuckets) {
8021
8519
  if (!inspection.deliverableBuckets.has(bucket)) {
@@ -8024,21 +8522,29 @@ async function flushOnce() {
8024
8522
  if (recovered.failedBuckets.has(bucket)) {
8025
8523
  continue;
8026
8524
  }
8027
- try {
8028
- const counts = await drainPath(path, client, binding);
8029
- total += counts.flushed;
8030
- quarantined += counts.quarantined;
8031
- } catch (err) {
8032
- errors.push(err);
8525
+ const { counts, failure: failure4 } = await settleDrain(
8526
+ (onProgress) => drainPath(path, client, binding, onProgress)
8527
+ );
8528
+ total += counts.flushed;
8529
+ quarantined += counts.quarantined;
8530
+ if (failure4) {
8531
+ failedDrainFlushed += counts.flushed;
8532
+ errors.push(failure4.error);
8033
8533
  }
8034
8534
  }
8035
8535
  if (errors.length > 0) {
8036
- throw new FlushError(errors[0], total, quarantined);
8536
+ throw new FlushError(
8537
+ errors[0],
8538
+ total,
8539
+ quarantined,
8540
+ inspection.retainedBuckets,
8541
+ failedDrainFlushed
8542
+ );
8037
8543
  }
8038
8544
  return inspection.retainedBuckets.length > 0 ? { flushed: total, quarantined, retained: inspection.retainedBuckets } : { flushed: total, quarantined };
8039
8545
  }
8040
8546
  var flushInFlight;
8041
- var FLUSH_LOCK_PATH = join8(dirname4(JOURNAL_DIR), ".flush.lock");
8547
+ var FLUSH_LOCK_PATH = join8(dirname3(JOURNAL_DIR), ".flush.lock");
8042
8548
  var FLUSH_LOCK_TIMEOUT_MS = 250;
8043
8549
  function isFlushLockContended(error) {
8044
8550
  return error instanceof Error && error.message.startsWith("timed out waiting for file lock");
@@ -8063,18 +8569,16 @@ function flush() {
8063
8569
  return attempt;
8064
8570
  }
8065
8571
  function shouldFlushPending(stats, now, thresholdMs = OPPORTUNISTIC_FLUSH_AFTER_MS) {
8066
- if (stats.sampled) {
8572
+ if (stats.sampled || stats.strandedFileCount > 0 && stats.strandedCount === 0) {
8067
8573
  return true;
8068
8574
  }
8069
8575
  return stats.pendingCount > 0 && (stats.oldestPendingAt === void 0 || now - stats.oldestPendingAt > thresholdMs);
8070
8576
  }
8071
- async function flushIfNeeded() {
8577
+ function journalNeedsFlush(now = Date.now()) {
8072
8578
  try {
8073
- const stats = pendingJournalStats();
8074
- if (shouldFlushPending(stats, Date.now())) {
8075
- await flush();
8076
- }
8579
+ return shouldFlushPending(pendingJournalStats(), now);
8077
8580
  } catch {
8581
+ return false;
8078
8582
  }
8079
8583
  }
8080
8584
 
@@ -8133,7 +8637,7 @@ function registerMovesCommands(program2) {
8133
8637
  }
8134
8638
  if (stranded.length > 0) {
8135
8639
  const moveCount = stranded.reduce((n, f) => n + f.lineCount, 0);
8136
- const byteCount = stranded.reduce((n, f) => n + f.sizeBytes, 0);
8640
+ const byteCount = stranded.reduce((n, f) => n + f.pendingBytes, 0);
8137
8641
  const sampled = stranded.some((file) => file.sampled);
8138
8642
  const qualifier = sampled ? "at least " : "";
8139
8643
  console.log(
@@ -8144,7 +8648,7 @@ function registerMovesCommands(program2) {
8144
8648
  const owner = f.pid === void 0 ? "no pid" : `pid ${String(f.pid)}`;
8145
8649
  const count = `${String(f.lineCount)}${f.sampled ? "+" : ""}`;
8146
8650
  console.log(
8147
- ` ${f.bucket.padEnd(BUCKET_COL_WIDTH)} ${count.padStart(5)} stranded, ${String(f.sizeBytes).padStart(8)} bytes, ${String(ageS)}s ago (${owner})`
8651
+ ` ${f.bucket.padEnd(BUCKET_COL_WIDTH)} ${count.padStart(5)} stranded, ${String(f.pendingBytes).padStart(8)} bytes, ${String(ageS)}s ago (${owner})`
8148
8652
  );
8149
8653
  }
8150
8654
  }
@@ -8342,6 +8846,20 @@ import { existsSync as existsSync10, readFileSync as readFileSync7 } from "fs";
8342
8846
  import { join as join11 } from "path";
8343
8847
  var EXIT_INCOMPLETE = 1;
8344
8848
  var EXIT_USAGE3 = 2;
8849
+ var SETUP_ORCHESTRATOR_ENV = "PRIM_SETUP_ORCHESTRATOR";
8850
+ var SETUP_DAEMON_DRAINS_ENV = "PRIM_SETUP_DAEMON_DRAINS";
8851
+ function setupStepSpawnOptions(capture, env = process.env, options = {}) {
8852
+ return {
8853
+ env: {
8854
+ ...env,
8855
+ [SETUP_ORCHESTRATOR_ENV]: "1",
8856
+ // Unset rather than inherited when this setup starts no daemon.
8857
+ [SETUP_DAEMON_DRAINS_ENV]: options.startsDaemon === true ? "1" : void 0
8858
+ },
8859
+ stdio: capture ? ["inherit", "pipe", "ignore"] : "inherit",
8860
+ encoding: "utf-8"
8861
+ };
8862
+ }
8345
8863
  var SESSION_LABELS = {
8346
8864
  claude: "Claude Code integration",
8347
8865
  codex: "Codex integration",
@@ -8374,12 +8892,16 @@ function planSetupSteps(opts) {
8374
8892
  required: true
8375
8893
  });
8376
8894
  }
8377
- steps.push({
8378
- key: "hooks",
8379
- label: "Git hooks",
8380
- args: ["hooks", "install", ...scopeArgs],
8381
- required: true
8382
- });
8895
+ if (opts.scope === "project") {
8896
+ steps.push({ key: "hooks", label: "Git hooks", args: ["hooks", "install"], required: true });
8897
+ } else if (opts.globalHooksPath) {
8898
+ steps.push({
8899
+ key: "hooks",
8900
+ label: "Git hooks (global)",
8901
+ args: ["hooks", "install", "--scope", "user", "--global-hooks-path"],
8902
+ required: true
8903
+ });
8904
+ }
8383
8905
  const skillArgs = ["skill", "install", "--agent", opts.agent, ...scopeArgs];
8384
8906
  steps.push({ key: "skill", label: "Agent skill", args: skillArgs, required: true });
8385
8907
  steps.push({
@@ -8392,7 +8914,7 @@ function planSetupSteps(opts) {
8392
8914
  steps.push({
8393
8915
  key: "health",
8394
8916
  label: "Capture health",
8395
- args: ["doctor"],
8917
+ args: ["doctor", "--expect-backlog"],
8396
8918
  required: true
8397
8919
  });
8398
8920
  }
@@ -8410,7 +8932,44 @@ function planCleanupUninstalls(agent, conflicts) {
8410
8932
  if (conflicts.includes(CONFLICT_SKILL)) steps.push(["skill", "uninstall", "--agent", agent]);
8411
8933
  return steps;
8412
8934
  }
8413
- function detectProjectConflicts(agent, run) {
8935
+ function preCommitRunsPrim(content) {
8936
+ return content.includes("# >>> prim pre-commit hook >>>") || content.includes("prim-pre-commit");
8937
+ }
8938
+ function projectHooksConflict(globalHooksActive, preCommit) {
8939
+ return globalHooksActive && preCommit !== void 0 && preCommitRunsPrim(preCommit);
8940
+ }
8941
+ function enableWiresRepository(root) {
8942
+ if (!root) return void 0;
8943
+ try {
8944
+ if (gitHooksMode({ cwd: root }) === "manual") return void 0;
8945
+ return resolveEffectiveGitHook("post-commit", root).location !== "external";
8946
+ } catch {
8947
+ return void 0;
8948
+ }
8949
+ }
8950
+ function setupGitHooksNote(plan, wiresThisRepository) {
8951
+ if (wiresThisRepository === true && plan.action === "add_to_dir") {
8952
+ 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`;
8953
+ }
8954
+ if (wiresThisRepository === true && plan.action === "system_declined") {
8955
+ 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`;
8956
+ }
8957
+ switch (plan.action) {
8958
+ case "refresh":
8959
+ return "git hooks \xB7 prim's global hooks stay active; `prim enable` refreshes them";
8960
+ case "present_in_dir":
8961
+ return `git hooks \xB7 prim's hooks are already in your global hooks dir ${plan.global}`;
8962
+ case "add_to_dir":
8963
+ 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`;
8964
+ case "system_declined":
8965
+ 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)`;
8966
+ case "manual":
8967
+ return "git hooks \xB7 prim.gitHooks=manual: prim writes no hook files; wire them with `prim hooks snippet <hook>`";
8968
+ default:
8969
+ return "git hooks \xB7 wired per repository by `prim enable`; git's global hooks are untouched (opt in with --global-hooks-path)";
8970
+ }
8971
+ }
8972
+ function detectProjectConflicts(agent, run, globalHooksActive) {
8414
8973
  const conflicts = [];
8415
8974
  if (agent !== "hermes") {
8416
8975
  try {
@@ -8419,14 +8978,14 @@ function detectProjectConflicts(agent, run) {
8419
8978
  } catch {
8420
8979
  }
8421
8980
  }
8981
+ let preCommit;
8422
8982
  try {
8423
8983
  const root = gitToplevel();
8424
- const preCommit = root && join11(root, ".git", "hooks", "pre-commit");
8425
- if (preCommit && existsSync10(preCommit) && readFileSync7(preCommit, "utf-8").includes("prim-pre-commit")) {
8426
- conflicts.push(CONFLICT_HOOKS);
8427
- }
8984
+ const path = root && join11(root, ".git", "hooks", "pre-commit");
8985
+ if (path && existsSync10(path)) preCommit = readFileSync7(path, "utf-8");
8428
8986
  } catch {
8429
8987
  }
8988
+ if (projectHooksConflict(globalHooksActive, preCommit)) conflicts.push(CONFLICT_HOOKS);
8430
8989
  try {
8431
8990
  const parsed = JSON.parse(
8432
8991
  run(["skill", "status", "--agent", agent, "--json"], true).stdout || "{}"
@@ -8466,8 +9025,11 @@ function registerSetupCommand(program2, dependencies = {}) {
8466
9025
  "Install everything in one shot (auth, session + git hooks, daemon, skill, welcome)"
8467
9026
  ).option("--agent <agent>", "claude, codex, cursor, or hermes (auto-detected when omitted)").option(
8468
9027
  "--scope <scope>",
8469
- "user (default \u2014 install once for every repo) or project (this repo only)",
9028
+ "user (default \u2014 agent integration and skill for every repo; git hooks per repo via enable) or project (this repo only)",
8470
9029
  "user"
9030
+ ).option(
9031
+ "--global-hooks-path",
9032
+ "with the default user scope, also change git's global hooks so prim's hooks run in every repository"
8471
9033
  ).option(
8472
9034
  "--migrate",
8473
9035
  "with the default user scope, remove any project-scoped prim config in this repo (else just warn)"
@@ -8487,15 +9049,21 @@ function registerSetupCommand(program2, dependencies = {}) {
8487
9049
  (dependencies.exit ?? process.exit)(EXIT_USAGE3);
8488
9050
  return;
8489
9051
  }
9052
+ if (opts.globalHooksPath && opts.scope !== "user") {
9053
+ process.stderr.write("[prim] --global-hooks-path applies only with --scope user\n");
9054
+ (dependencies.exit ?? process.exit)(EXIT_USAGE3);
9055
+ return;
9056
+ }
8490
9057
  const agent = agentInput;
8491
9058
  const scope = opts.scope;
8492
9059
  const self = process.argv[1];
8493
9060
  const globals = program2.optsWithGlobals();
8494
9061
  const run = dependencies.run ?? ((args, capture = false) => {
8495
- const r = spawnSync2(process.execPath, [self, ...args], {
8496
- stdio: capture ? ["inherit", "pipe", "ignore"] : "inherit",
8497
- encoding: "utf-8"
8498
- });
9062
+ const r = spawnSync2(
9063
+ process.execPath,
9064
+ [self, ...args],
9065
+ setupStepSpawnOptions(capture, process.env, { startsDaemon: opts.daemon })
9066
+ );
8499
9067
  return { code: r.status ?? 1, stdout: capture ? r.stdout ?? "" : "" };
8500
9068
  });
8501
9069
  const results = {};
@@ -8537,16 +9105,24 @@ function registerSetupCommand(program2, dependencies = {}) {
8537
9105
  note("pre-authorize \xB7 writing prim allow-rule (user scope)\u2026");
8538
9106
  results.preauth = run(["claude", "preauth", "--scope", "user"]).code === 0 ? "ok" : "skipped";
8539
9107
  }
8540
- const setupSteps = planSetupSteps({ agent, daemon: opts.daemon, scope });
9108
+ const setupSteps = planSetupSteps({
9109
+ agent,
9110
+ daemon: opts.daemon,
9111
+ scope,
9112
+ globalHooksPath: opts.globalHooksPath === true
9113
+ });
8541
9114
  for (const step of setupSteps.filter(
8542
9115
  (candidate) => candidate.key !== "enable" && candidate.key !== "health"
8543
9116
  )) {
8544
9117
  note(`${step.label} \xB7 installing\u2026`);
8545
9118
  const { code } = run(step.args);
8546
- results[step.key] = code === 0 ? "ok" : step.required ? "failed" : "skipped";
9119
+ results[step.key] = code === 0 ? "ok" : code === EXIT_GLOBAL_HOOKS_NOT_INSTALLED && step.key === "hooks" ? "skipped" : step.required ? "failed" : "skipped";
9120
+ }
9121
+ if (scope === "user" && !opts.globalHooksPath) {
9122
+ note(setupGitHooksNote(planGlobalHooks(), enableWiresRepository(gitToplevel())));
8547
9123
  }
8548
9124
  if (scope === "user") {
8549
- const conflicts = detectProjectConflicts(agent, run);
9125
+ const conflicts = detectProjectConflicts(agent, run, globalHooksPathIsPrims());
8550
9126
  if (conflicts.length > 0 && opts.migrate) {
8551
9127
  note(`migrate \xB7 removing project-scoped config (${conflicts.join(", ")})\u2026`);
8552
9128
  const ok = planCleanupUninstalls(agent, conflicts).map((args) => run(args).code === 0).every(Boolean);
@@ -8587,12 +9163,12 @@ function registerSetupCommand(program2, dependencies = {}) {
8587
9163
 
8588
9164
  // src/commands/statusline.ts
8589
9165
  import { readFileSync as readFileSync8 } from "fs";
8590
- import { dirname as dirname5, resolve as resolve3 } from "path";
9166
+ import { dirname as dirname4, resolve as resolve3 } from "path";
8591
9167
  import { fileURLToPath } from "url";
8592
9168
  var STATUSLINE_TIMEOUT_MS = 200;
8593
9169
  function readPackageVersion() {
8594
9170
  try {
8595
- const here = dirname5(fileURLToPath(import.meta.url));
9171
+ const here = dirname4(fileURLToPath(import.meta.url));
8596
9172
  const candidates = [
8597
9173
  // Schema-v2 runtimes staged a manifest beside this standalone bundle.
8598
9174
  // Keep resolving it while those legacy launchers transition to schema v3.
@@ -9016,28 +9592,233 @@ function registerWelcomeCommand(program2, deps = { getClient }) {
9016
9592
  });
9017
9593
  }
9018
9594
 
9595
+ // src/daemon/drift-heal.ts
9596
+ var ATTEMPT_MARKER_MODE = 384;
9597
+ var ATTEMPT_MARKER_MAX_BYTES = 1024;
9598
+ var PATH_SEPARATORS = /[\\/]/u;
9599
+ var DAEMON_DRIFT_HEAL_ENV = "PRIM_DAEMON_DRIFT_HEAL";
9600
+ var DAEMON_DRIFT_HEAL_RETRY_MS = 60 * 60 * 1e3;
9601
+ function readAttempt(path) {
9602
+ const file = readBoundedRegularFile(path, ATTEMPT_MARKER_MAX_BYTES);
9603
+ if (!file) return null;
9604
+ try {
9605
+ const value = JSON.parse(file.text);
9606
+ if (typeof value !== "object" || value === null) return null;
9607
+ const attempt = value;
9608
+ return typeof attempt.attemptedAt === "number" && Number.isFinite(attempt.attemptedAt) && typeof attempt.fromVersion === "string" && typeof attempt.toVersion === "string" ? attempt : null;
9609
+ } catch {
9610
+ return null;
9611
+ }
9612
+ }
9613
+ function claimHeld() {
9614
+ throw new Error("another command holds the daemon drift-heal claim");
9615
+ }
9616
+ function claimAttempt(markerPath2, attempt) {
9617
+ const previous = readAttempt(markerPath2);
9618
+ const elapsed = previous ? attempt.attemptedAt - previous.attemptedAt : Number.POSITIVE_INFINITY;
9619
+ if (elapsed >= 0 && elapsed < DAEMON_DRIFT_HEAL_RETRY_MS) return false;
9620
+ atomicWriteFile(markerPath2, `${JSON.stringify(attempt)}
9621
+ `, { mode: ATTEMPT_MARKER_MODE });
9622
+ return true;
9623
+ }
9624
+ function isInstalledPackage(root) {
9625
+ return root?.split(PATH_SEPARATORS).includes("node_modules") === true;
9626
+ }
9627
+ function needsHeal(launcher, toVersion) {
9628
+ const order = compareSemver(launcher.runtimeVersion, toVersion);
9629
+ return order === -1 || order === 0 && !launcher.runnable;
9630
+ }
9631
+ function startDriftHeal(options) {
9632
+ if ((options.platform ?? process.platform) !== "darwin") return false;
9633
+ const env = options.env ?? process.env;
9634
+ if (env[DAEMON_DRIFT_HEAL_ENV] === "0") return false;
9635
+ const root = options.packageRoot === void 0 ? packageRoot() : options.packageRoot;
9636
+ if (!isInstalledPackage(root)) return false;
9637
+ const euid = options.euid ?? process.geteuid?.();
9638
+ if (euid === void 0 || euid === 0) return false;
9639
+ const paths = { env, homeDir: options.homeDir };
9640
+ if (daemonExplicitlyDisabled(paths)) return false;
9641
+ const launcher = (options.selectedLauncher ?? (() => selectedDaemonLauncher(paths)))();
9642
+ if (!launcher || launcher.ownerUid !== euid) return false;
9643
+ const toVersion = options.cliVersion === void 0 ? packageVersion() : options.cliVersion;
9644
+ if (!(toVersion && needsHeal(launcher, toVersion))) return false;
9645
+ if (!apiUrlsMatch(
9646
+ getSiteUrlForEnvironment(launcher.apiUrl),
9647
+ getSiteUrlForEnvironment(env.PRIM_API_URL)
9648
+ )) {
9649
+ return false;
9650
+ }
9651
+ const runsConfigRoot = options.launchAgentRunsConfigRoot ?? (() => launchAgentRunsConfigRoot(paths));
9652
+ if (!runsConfigRoot()) return false;
9653
+ const primEntry = options.primEntry === void 0 ? binFile("prim") : options.primEntry;
9654
+ if (!primEntry) return false;
9655
+ const attempt = {
9656
+ attemptedAt: (options.nowMs ?? Date.now)(),
9657
+ fromVersion: launcher.runtimeVersion,
9658
+ toVersion
9659
+ };
9660
+ const claimed = withFileLockSync(
9661
+ daemonDriftHealClaimPath(paths),
9662
+ () => claimAttempt(daemonDriftHealMarkerPath(paths), attempt),
9663
+ { timeoutMs: 0, sleep: claimHeld }
9664
+ );
9665
+ if (!claimed) return false;
9666
+ return kickDaemonEnsure({
9667
+ primEntry,
9668
+ nodeEntry: options.nodeEntry,
9669
+ spawnProcess: options.spawnProcess,
9670
+ latestBootstrap: false
9671
+ });
9672
+ }
9673
+ function healDaemonDrift(options = {}) {
9674
+ try {
9675
+ return startDriftHeal(options);
9676
+ } catch {
9677
+ return false;
9678
+ }
9679
+ }
9680
+
9681
+ // src/lib/background-flush.ts
9682
+ import { spawn as spawn3 } from "child_process";
9683
+ import { readFileSync as readFileSync9 } from "fs";
9684
+
9685
+ // src/daemon/health.ts
9686
+ import { chmodSync as chmodSync3, existsSync as existsSync11, mkdirSync as mkdirSync7, renameSync as renameSync4, writeFileSync as writeFileSync6 } from "fs";
9687
+ import { dirname as dirname5, join as join12 } from "path";
9688
+ var HEARTBEAT_FRESH_MS = 9e4;
9689
+ var DAEMON_HEALTH_PATH = join12(primConfigDirectory(), "daemon-health.json");
9690
+
9691
+ // src/lib/background-flush.ts
9692
+ function record2(value) {
9693
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
9694
+ }
9695
+ function daemonOwnsDrain(options = {}) {
9696
+ try {
9697
+ const expectedVersion = options.expectedVersion === void 0 ? packageVersion() : options.expectedVersion;
9698
+ if (!expectedVersion) return false;
9699
+ const health = record2(
9700
+ JSON.parse(readFileSync9(options.healthPath ?? DAEMON_HEALTH_PATH, "utf8"))
9701
+ );
9702
+ const heartbeat = record2(health?.heartbeat);
9703
+ const ingestion = record2(health?.ingestion);
9704
+ if (!(health && heartbeat && ingestion)) return false;
9705
+ if (health.schemaVersion !== 1 || health.version !== expectedVersion) return false;
9706
+ const daemonSiteUrl = health.siteUrl;
9707
+ if (typeof daemonSiteUrl !== "string" || envSlug(daemonSiteUrl) !== envSlug(options.siteUrl ?? getSiteUrl())) {
9708
+ return false;
9709
+ }
9710
+ if (health.needsReauth === true) return false;
9711
+ const pid = health.pid;
9712
+ if (typeof pid !== "number" || !(options.isAlive ?? processIsAlive)(pid)) return false;
9713
+ const lastSuccessAt = heartbeat.lastSuccessAt;
9714
+ const now = options.now ?? Date.now();
9715
+ return heartbeat.consecutiveFailures === 0 && typeof lastSuccessAt === "number" && lastSuccessAt <= now && now - lastSuccessAt < HEARTBEAT_FRESH_MS && ingestion.consecutiveFailures === 0;
9716
+ } catch {
9717
+ return false;
9718
+ }
9719
+ }
9720
+ function startBackgroundFlush(options = {}) {
9721
+ try {
9722
+ if ((options.daemonOwnsDrain ?? daemonOwnsDrain)()) return false;
9723
+ if (!(options.needsFlush ?? journalNeedsFlush)()) return false;
9724
+ const primEntry = options.primEntry === void 0 ? binFile("prim") : options.primEntry;
9725
+ if (!primEntry) return false;
9726
+ const child = (options.spawnProcess ?? spawn3)(
9727
+ options.nodeEntry ?? process.execPath,
9728
+ [primEntry, "moves", "flush"],
9729
+ // Ignored stdio matters as much as detaching: a child holding inherited
9730
+ // pipes would keep a caller that captures them (spawnSync) waiting.
9731
+ // windowsHide keeps the detached child from opening a console window.
9732
+ // The child is unattended even when its parent is not: `doctor` or
9733
+ // `statusline` must not reach the daemon drift heal through its drain.
9734
+ { detached: true, stdio: "ignore", windowsHide: true, env: unattendedEnv() }
9735
+ );
9736
+ child.once("error", () => {
9737
+ });
9738
+ child.unref();
9739
+ return true;
9740
+ } catch {
9741
+ return false;
9742
+ }
9743
+ }
9744
+
9019
9745
  // src/lib/startup-background.ts
9020
9746
  var ROOT_OPTIONS = /* @__PURE__ */ new Set(["-y", "--yes", "--non-interactive"]);
9747
+ var HELP_OPTIONS = /* @__PURE__ */ new Set(["-h", "--help"]);
9748
+ var DAEMON_HEAL_COMMANDS = /* @__PURE__ */ new Map([
9749
+ // Credentials; `auth login` is how a person returns to a daemon held for re-auth.
9750
+ ["auth", /* @__PURE__ */ new Set(["api-keys", "login", "set-token", "status"])],
9751
+ // Reading and curating the decision graph: the everyday attended surface.
9752
+ [
9753
+ "decisions",
9754
+ /* @__PURE__ */ new Set([
9755
+ "cascade",
9756
+ "check",
9757
+ "confirm",
9758
+ "create",
9759
+ "demote",
9760
+ "link",
9761
+ "promote",
9762
+ "publish",
9763
+ "ratify",
9764
+ "recent",
9765
+ "repairs",
9766
+ "rescope",
9767
+ "restore",
9768
+ "show",
9769
+ "supersede",
9770
+ "unlink",
9771
+ "withdraw"
9772
+ ])
9773
+ ],
9774
+ ["reconcile", "any"],
9775
+ // Repository connection, activation, and journal plumbing a person steers.
9776
+ // Hook-side `moves flush` drains are unattended and never reach this table.
9777
+ ["github", /* @__PURE__ */ new Set(["connect"])],
9778
+ ["enable", "any"],
9779
+ ["moves", /* @__PURE__ */ new Set(["bind", "drop", "flush", "status", "tail"])],
9780
+ ["session", /* @__PURE__ */ new Set(["drop", "list", "start"])],
9781
+ // Installing or inspecting an integration, never removing one.
9782
+ ["claude", /* @__PURE__ */ new Set(["install", "preauth", "status"])],
9783
+ ["codex", /* @__PURE__ */ new Set(["install", "status"])],
9784
+ ["cursor", /* @__PURE__ */ new Set(["install", "status"])],
9785
+ ["hermes", /* @__PURE__ */ new Set(["install", "status"])],
9786
+ ["hooks", /* @__PURE__ */ new Set(["install"])],
9787
+ ["skill", /* @__PURE__ */ new Set(["install", "status"])],
9788
+ ["welcome", "any"]
9789
+ ]);
9021
9790
  function commandArguments(argv) {
9022
9791
  let index = 0;
9023
9792
  while (ROOT_OPTIONS.has(argv[index] ?? "")) index += 1;
9024
9793
  return argv.slice(index);
9025
9794
  }
9795
+ function mayHealDaemon(argv, env) {
9796
+ if (isUnattended(env) || env[SETUP_ORCHESTRATOR_ENV] === "1") return false;
9797
+ const args = commandArguments(argv);
9798
+ if (args.some((arg) => HELP_OPTIONS.has(arg))) return false;
9799
+ const [command, subcommand] = args;
9800
+ const allowed = command === void 0 ? void 0 : DAEMON_HEAL_COMMANDS.get(command);
9801
+ if (allowed === void 0) return false;
9802
+ return allowed === "any" || subcommand !== void 0 && allowed.has(subcommand);
9803
+ }
9026
9804
  function runStartupBackgroundWork(argv, env, work) {
9027
- const [command, subcommand] = commandArguments(argv);
9805
+ const args = commandArguments(argv);
9806
+ const [command, subcommand] = args;
9028
9807
  const uninstall = command === "uninstall" || env[UNINSTALL_ORCHESTRATOR_ENV] === "1";
9029
9808
  if (uninstall) return;
9030
9809
  work.notify();
9031
- if (command !== "moves" || subcommand !== "flush") work.flush().catch(() => {
9032
- });
9810
+ const setupDaemonDrains = command === "setup" && !args.includes("--no-daemon") || env[SETUP_DAEMON_DRAINS_ENV] === "1";
9811
+ if (!setupDaemonDrains && (command !== "moves" || subcommand !== "flush")) work.flush();
9812
+ if (mayHealDaemon(argv, env)) work.healDaemon();
9033
9813
  }
9034
9814
 
9035
9815
  // src/index.ts
9036
9816
  var __dirname = dirname6(fileURLToPath2(import.meta.url));
9037
- var pkg = JSON.parse(readFileSync9(resolve4(__dirname, "../package.json"), "utf-8"));
9817
+ var pkg = JSON.parse(readFileSync10(resolve4(__dirname, "../package.json"), "utf-8"));
9038
9818
  runStartupBackgroundWork(process.argv.slice(2), process.env, {
9039
9819
  notify: () => updateNotifier({ pkg }).notify(),
9040
- flush: () => flushIfNeeded()
9820
+ flush: () => startBackgroundFlush(),
9821
+ healDaemon: () => healDaemonDrift()
9041
9822
  });
9042
9823
  var program = new Command();
9043
9824
  program.name("prim").description("CLI for Primitive's decision graph").version(pkg.version).option("-y, --yes", "auto-confirm prompts").option(