@indigoai-us/hq-cloud 6.14.50 → 6.15.1

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 (340) hide show
  1. package/dist/bin/sync-mutation.d.ts +14 -0
  2. package/dist/bin/sync-mutation.d.ts.map +1 -0
  3. package/dist/bin/sync-mutation.js +60 -0
  4. package/dist/bin/sync-mutation.js.map +1 -0
  5. package/dist/bin/sync-mutation.test.d.ts +2 -0
  6. package/dist/bin/sync-mutation.test.d.ts.map +1 -0
  7. package/dist/bin/sync-mutation.test.js +64 -0
  8. package/dist/bin/sync-mutation.test.js.map +1 -0
  9. package/dist/bin/sync-runner-company.d.ts +8 -0
  10. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  11. package/dist/bin/sync-runner-company.js +16 -0
  12. package/dist/bin/sync-runner-company.js.map +1 -1
  13. package/dist/bin/sync-runner-company.test.d.ts +2 -0
  14. package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
  15. package/dist/bin/sync-runner-company.test.js +36 -0
  16. package/dist/bin/sync-runner-company.test.js.map +1 -0
  17. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  18. package/dist/bin/sync-runner-watch-loop.js +98 -8
  19. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  20. package/dist/bin/sync-runner.d.ts +17 -0
  21. package/dist/bin/sync-runner.d.ts.map +1 -1
  22. package/dist/bin/sync-runner.js.map +1 -1
  23. package/dist/bin/sync-runner.test.js +109 -0
  24. package/dist/bin/sync-runner.test.js.map +1 -1
  25. package/dist/cli/conflict-recovery.test.d.ts +2 -0
  26. package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
  27. package/dist/cli/conflict-recovery.test.js +201 -0
  28. package/dist/cli/conflict-recovery.test.js.map +1 -0
  29. package/dist/cli/conflict.d.ts +60 -0
  30. package/dist/cli/conflict.d.ts.map +1 -1
  31. package/dist/cli/conflict.js +333 -0
  32. package/dist/cli/conflict.js.map +1 -1
  33. package/dist/cli/reindex-knowledge.d.ts +7 -0
  34. package/dist/cli/reindex-knowledge.d.ts.map +1 -0
  35. package/dist/cli/reindex-knowledge.js +389 -0
  36. package/dist/cli/reindex-knowledge.js.map +1 -0
  37. package/dist/cli/reindex-knowledge.test.d.ts +12 -0
  38. package/dist/cli/reindex-knowledge.test.d.ts.map +1 -0
  39. package/dist/cli/reindex-knowledge.test.js +260 -0
  40. package/dist/cli/reindex-knowledge.test.js.map +1 -0
  41. package/dist/cli/reindex.d.ts.map +1 -1
  42. package/dist/cli/reindex.js +8 -0
  43. package/dist/cli/reindex.js.map +1 -1
  44. package/dist/cli/rescue-settings-reconcile.test.js +47 -0
  45. package/dist/cli/rescue-settings-reconcile.test.js.map +1 -1
  46. package/dist/cli/sync.d.ts +27 -0
  47. package/dist/cli/sync.d.ts.map +1 -1
  48. package/dist/cli/sync.js +52 -0
  49. package/dist/cli/sync.js.map +1 -1
  50. package/dist/cli/sync.test.js +31 -1
  51. package/dist/cli/sync.test.js.map +1 -1
  52. package/dist/index.d.ts +2 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +1 -0
  55. package/dist/index.js.map +1 -1
  56. package/dist/skill-telemetry.d.ts +6 -0
  57. package/dist/skill-telemetry.d.ts.map +1 -1
  58. package/dist/skill-telemetry.js +14 -2
  59. package/dist/skill-telemetry.js.map +1 -1
  60. package/dist/skill-telemetry.test.js +79 -0
  61. package/dist/skill-telemetry.test.js.map +1 -1
  62. package/dist/sync/candidate-uploader.d.ts +88 -0
  63. package/dist/sync/candidate-uploader.d.ts.map +1 -0
  64. package/dist/sync/candidate-uploader.js +212 -0
  65. package/dist/sync/candidate-uploader.js.map +1 -0
  66. package/dist/sync/candidate-uploader.test.d.ts +2 -0
  67. package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
  68. package/dist/sync/candidate-uploader.test.js +132 -0
  69. package/dist/sync/candidate-uploader.test.js.map +1 -0
  70. package/dist/sync/delta-client.d.ts +73 -0
  71. package/dist/sync/delta-client.d.ts.map +1 -0
  72. package/dist/sync/delta-client.js +201 -0
  73. package/dist/sync/delta-client.js.map +1 -0
  74. package/dist/sync/delta-client.test.d.ts +2 -0
  75. package/dist/sync/delta-client.test.d.ts.map +1 -0
  76. package/dist/sync/delta-client.test.js +97 -0
  77. package/dist/sync/delta-client.test.js.map +1 -0
  78. package/dist/sync/durable-apply.d.ts +76 -0
  79. package/dist/sync/durable-apply.d.ts.map +1 -0
  80. package/dist/sync/durable-apply.js +530 -0
  81. package/dist/sync/durable-apply.js.map +1 -0
  82. package/dist/sync/durable-apply.test.d.ts +2 -0
  83. package/dist/sync/durable-apply.test.d.ts.map +1 -0
  84. package/dist/sync/durable-apply.test.js +180 -0
  85. package/dist/sync/durable-apply.test.js.map +1 -0
  86. package/dist/sync/event-sync.d.ts +33 -1
  87. package/dist/sync/event-sync.d.ts.map +1 -1
  88. package/dist/sync/event-sync.js +149 -1
  89. package/dist/sync/event-sync.js.map +1 -1
  90. package/dist/sync/event-sync.test.js +142 -1
  91. package/dist/sync/event-sync.test.js.map +1 -1
  92. package/dist/sync/index.d.ts +2 -0
  93. package/dist/sync/index.d.ts.map +1 -1
  94. package/dist/sync/index.js +1 -0
  95. package/dist/sync/index.js.map +1 -1
  96. package/dist/sync/multipart-uploader.d.ts +99 -0
  97. package/dist/sync/multipart-uploader.d.ts.map +1 -0
  98. package/dist/sync/multipart-uploader.js +447 -0
  99. package/dist/sync/multipart-uploader.js.map +1 -0
  100. package/dist/sync/multipart-uploader.test.d.ts +2 -0
  101. package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
  102. package/dist/sync/multipart-uploader.test.js +119 -0
  103. package/dist/sync/multipart-uploader.test.js.map +1 -0
  104. package/dist/sync/mutation-client.d.ts +82 -0
  105. package/dist/sync/mutation-client.d.ts.map +1 -0
  106. package/dist/sync/mutation-client.js +221 -0
  107. package/dist/sync/mutation-client.js.map +1 -0
  108. package/dist/sync/mutation-client.test.d.ts +2 -0
  109. package/dist/sync/mutation-client.test.d.ts.map +1 -0
  110. package/dist/sync/mutation-client.test.js +51 -0
  111. package/dist/sync/mutation-client.test.js.map +1 -0
  112. package/dist/sync/push-receiver.d.ts +45 -0
  113. package/dist/sync/push-receiver.d.ts.map +1 -1
  114. package/dist/sync/push-receiver.js +101 -0
  115. package/dist/sync/push-receiver.js.map +1 -1
  116. package/dist/sync/push-receiver.test.js +54 -2
  117. package/dist/sync/push-receiver.test.js.map +1 -1
  118. package/dist/sync/scope-inventory-client.d.ts +69 -0
  119. package/dist/sync/scope-inventory-client.d.ts.map +1 -0
  120. package/dist/sync/scope-inventory-client.js +210 -0
  121. package/dist/sync/scope-inventory-client.js.map +1 -0
  122. package/dist/sync/scope-inventory-client.test.d.ts +2 -0
  123. package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
  124. package/dist/sync/scope-inventory-client.test.js +94 -0
  125. package/dist/sync/scope-inventory-client.test.js.map +1 -0
  126. package/dist/sync/snapshot-client.d.ts +98 -0
  127. package/dist/sync/snapshot-client.d.ts.map +1 -0
  128. package/dist/sync/snapshot-client.js +402 -0
  129. package/dist/sync/snapshot-client.js.map +1 -0
  130. package/dist/sync/snapshot-client.test.d.ts +2 -0
  131. package/dist/sync/snapshot-client.test.d.ts.map +1 -0
  132. package/dist/sync/snapshot-client.test.js +169 -0
  133. package/dist/sync/snapshot-client.test.js.map +1 -0
  134. package/dist/sync/uploader-finalization.d.ts +97 -0
  135. package/dist/sync/uploader-finalization.d.ts.map +1 -0
  136. package/dist/sync/uploader-finalization.js +273 -0
  137. package/dist/sync/uploader-finalization.js.map +1 -0
  138. package/dist/sync/uploader-finalization.test.d.ts +2 -0
  139. package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
  140. package/dist/sync/uploader-finalization.test.js +92 -0
  141. package/dist/sync/uploader-finalization.test.js.map +1 -0
  142. package/dist/telemetry.d.ts +11 -1
  143. package/dist/telemetry.d.ts.map +1 -1
  144. package/dist/telemetry.js +21 -2
  145. package/dist/telemetry.js.map +1 -1
  146. package/dist/telemetry.test.js +80 -0
  147. package/dist/telemetry.test.js.map +1 -1
  148. package/package.json +6 -1
  149. package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
  150. package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
  151. package/.github/workflows/ci.yml +0 -84
  152. package/.github/workflows/publish.yml +0 -56
  153. package/.github/workflows/unreleased-commits-nag.yml +0 -256
  154. package/eslint.config.js +0 -67
  155. package/pnpm-workspace.yaml +0 -2
  156. package/scripts/presign-transport-e2e.mjs +0 -250
  157. package/scripts/vault-rebaseline.sh +0 -323
  158. package/scripts/vault-rescue.sh +0 -332
  159. package/src/active-company.test.ts +0 -188
  160. package/src/active-company.ts +0 -168
  161. package/src/agent-codex-instructions.test.ts +0 -332
  162. package/src/agent-codex-instructions.ts +0 -309
  163. package/src/auth.ts +0 -146
  164. package/src/backup-prune.test.ts +0 -98
  165. package/src/backup-prune.ts +0 -182
  166. package/src/bin/backup-prune-runner.ts +0 -33
  167. package/src/bin/rescue-runner.ts +0 -25
  168. package/src/bin/sync-runner-company.ts +0 -695
  169. package/src/bin/sync-runner-events.test.ts +0 -143
  170. package/src/bin/sync-runner-events.ts +0 -55
  171. package/src/bin/sync-runner-planning.test.ts +0 -311
  172. package/src/bin/sync-runner-planning.ts +0 -258
  173. package/src/bin/sync-runner-rollup.test.ts +0 -37
  174. package/src/bin/sync-runner-rollup.ts +0 -97
  175. package/src/bin/sync-runner-telemetry.ts +0 -15
  176. package/src/bin/sync-runner-watch-loop.ts +0 -1235
  177. package/src/bin/sync-runner-watch-routes.test.ts +0 -71
  178. package/src/bin/sync-runner-watch-routes.ts +0 -184
  179. package/src/bin/sync-runner.test.ts +0 -8767
  180. package/src/bin/sync-runner.ts +0 -2190
  181. package/src/cli/accept.ts +0 -124
  182. package/src/cli/conflict.ts +0 -119
  183. package/src/cli/doctor.test.ts +0 -581
  184. package/src/cli/doctor.ts +0 -642
  185. package/src/cli/index.ts +0 -49
  186. package/src/cli/invite.test.ts +0 -250
  187. package/src/cli/invite.ts +0 -214
  188. package/src/cli/promote.ts +0 -157
  189. package/src/cli/reindex.test.ts +0 -957
  190. package/src/cli/reindex.ts +0 -970
  191. package/src/cli/rescue-classify-ordering.test.ts +0 -548
  192. package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
  193. package/src/cli/rescue-core.ts +0 -3011
  194. package/src/cli/rescue-drift-reconcile.test.ts +0 -179
  195. package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
  196. package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
  197. package/src/cli/rescue-hq-root-guard.test.ts +0 -232
  198. package/src/cli/rescue-journal-reconcile.test.ts +0 -215
  199. package/src/cli/rescue-mtime-preserve.test.ts +0 -203
  200. package/src/cli/rescue-settings-reconcile.test.ts +0 -583
  201. package/src/cli/rescue-snapshot.test.ts +0 -57
  202. package/src/cli/rescue-snapshot.ts +0 -51
  203. package/src/cli/rescue.reindex.test.ts +0 -63
  204. package/src/cli/rescue.test.ts +0 -131
  205. package/src/cli/rescue.ts +0 -182
  206. package/src/cli/share.test.ts +0 -7843
  207. package/src/cli/share.ts +0 -3663
  208. package/src/cli/sync-scope.test.ts +0 -652
  209. package/src/cli/sync.test.ts +0 -5207
  210. package/src/cli/sync.ts +0 -3470
  211. package/src/cli/tombstones.ts +0 -106
  212. package/src/cli/watch-event-push-conflict.test.ts +0 -234
  213. package/src/client-info.test.ts +0 -214
  214. package/src/client-info.ts +0 -121
  215. package/src/cognito-auth.test.ts +0 -712
  216. package/src/cognito-auth.ts +0 -1422
  217. package/src/company-resolver.test.ts +0 -618
  218. package/src/company-resolver.ts +0 -521
  219. package/src/context.test.ts +0 -583
  220. package/src/context.ts +0 -378
  221. package/src/daemon-worker.ts +0 -26
  222. package/src/daemon.ts +0 -99
  223. package/src/entity-resolver.test.ts +0 -315
  224. package/src/entity-resolver.ts +0 -180
  225. package/src/ignore.test.ts +0 -466
  226. package/src/ignore.ts +0 -469
  227. package/src/index.ts +0 -439
  228. package/src/journal.test.ts +0 -968
  229. package/src/journal.ts +0 -765
  230. package/src/lib/cloud-authoritative.test.ts +0 -45
  231. package/src/lib/cloud-authoritative.ts +0 -59
  232. package/src/lib/conflict-file.ts +0 -86
  233. package/src/lib/conflict-index.ts +0 -289
  234. package/src/lib/conflict.test.ts +0 -348
  235. package/src/lib/describe-error.test.ts +0 -100
  236. package/src/lib/describe-error.ts +0 -58
  237. package/src/lib/exit-codes.ts +0 -24
  238. package/src/lib/machine-id.test.ts +0 -231
  239. package/src/lib/machine-id.ts +0 -175
  240. package/src/lib/net-errors.test.ts +0 -65
  241. package/src/lib/net-errors.ts +0 -86
  242. package/src/lib/readlink-safe.test.ts +0 -43
  243. package/src/lib/readlink-safe.ts +0 -29
  244. package/src/local-path-codec.test.ts +0 -138
  245. package/src/local-path-codec.ts +0 -161
  246. package/src/machine-auth.test.ts +0 -1323
  247. package/src/manifest-reconcile.test.ts +0 -1123
  248. package/src/manifest-reconcile.ts +0 -518
  249. package/src/object-io.test.ts +0 -1221
  250. package/src/object-io.ts +0 -1306
  251. package/src/operation-lock.test.ts +0 -484
  252. package/src/operation-lock.ts +0 -680
  253. package/src/outcome-telemetry.test.ts +0 -498
  254. package/src/outcome-telemetry.ts +0 -639
  255. package/src/personal-vault-exclusions.test.ts +0 -308
  256. package/src/personal-vault-exclusions.ts +0 -354
  257. package/src/personal-vault.test.ts +0 -756
  258. package/src/personal-vault.ts +0 -496
  259. package/src/prefix-coalesce.test.ts +0 -240
  260. package/src/prefix-coalesce.ts +0 -273
  261. package/src/public-surface.test.ts +0 -117
  262. package/src/qmd-reindex.test.ts +0 -877
  263. package/src/qmd-reindex.ts +0 -842
  264. package/src/read-only-state-dir.test.ts +0 -188
  265. package/src/remote-pull.test.ts +0 -1130
  266. package/src/remote-pull.ts +0 -618
  267. package/src/s3.symlink-materialize.test.ts +0 -492
  268. package/src/s3.test.ts +0 -1789
  269. package/src/s3.ts +0 -1532
  270. package/src/schemas/signal-types.test.ts +0 -82
  271. package/src/schemas/signal-types.ts +0 -38
  272. package/src/schemas/source-channels.test.ts +0 -82
  273. package/src/schemas/source-channels.ts +0 -53
  274. package/src/scope-shrink.test.ts +0 -633
  275. package/src/scope-shrink.ts +0 -481
  276. package/src/signals/get.test.ts +0 -310
  277. package/src/signals/get.ts +0 -75
  278. package/src/signals/internals.ts +0 -195
  279. package/src/signals/list.test.ts +0 -420
  280. package/src/signals/list.ts +0 -79
  281. package/src/signals/parse.ts +0 -8
  282. package/src/signals/types.ts +0 -91
  283. package/src/skill-telemetry.test.ts +0 -1825
  284. package/src/skill-telemetry.ts +0 -1439
  285. package/src/sources/get.test.ts +0 -293
  286. package/src/sources/get.ts +0 -66
  287. package/src/sources/internals.ts +0 -198
  288. package/src/sources/list.test.ts +0 -402
  289. package/src/sources/list.ts +0 -84
  290. package/src/sources/parse.ts +0 -43
  291. package/src/sources/types.ts +0 -84
  292. package/src/sync/event-sync.test.ts +0 -594
  293. package/src/sync/event-sync.ts +0 -545
  294. package/src/sync/feature-flags.test.ts +0 -378
  295. package/src/sync/feature-flags.ts +0 -62
  296. package/src/sync/index.ts +0 -76
  297. package/src/sync/lease-client.test.ts +0 -128
  298. package/src/sync/lease-client.ts +0 -207
  299. package/src/sync/logger.test.ts +0 -242
  300. package/src/sync/logger.ts +0 -79
  301. package/src/sync/metrics.test.ts +0 -462
  302. package/src/sync/metrics.ts +0 -213
  303. package/src/sync/pull-scope.ts +0 -265
  304. package/src/sync/push-event.test.ts +0 -266
  305. package/src/sync/push-event.ts +0 -224
  306. package/src/sync/push-receiver.test.ts +0 -566
  307. package/src/sync/push-receiver.ts +0 -1048
  308. package/src/sync/push-transport.ts +0 -231
  309. package/src/sync/realtime-rollout.test.ts +0 -86
  310. package/src/sync/realtime-rollout.ts +0 -262
  311. package/src/sync/state-store.test.ts +0 -194
  312. package/src/sync/state-store.ts +0 -727
  313. package/src/sync-core.ts +0 -58
  314. package/src/sync-progress.test.ts +0 -94
  315. package/src/sync-progress.ts +0 -140
  316. package/src/telemetry-events.test.ts +0 -88
  317. package/src/telemetry-events.ts +0 -205
  318. package/src/telemetry.test.ts +0 -1280
  319. package/src/telemetry.ts +0 -1109
  320. package/src/types.ts +0 -314
  321. package/src/vault-client.test.ts +0 -1380
  322. package/src/vault-client.ts +0 -1694
  323. package/src/version.ts +0 -24
  324. package/src/watch-roots.test.ts +0 -278
  325. package/src/watch-roots.ts +0 -162
  326. package/src/watcher-event-gate.test.ts +0 -212
  327. package/src/watcher.test.ts +0 -1079
  328. package/src/watcher.ts +0 -1741
  329. package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
  330. package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
  331. package/test/e2e/sync/transient-company-leg.test.ts +0 -384
  332. package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
  333. package/test/e2e/watcher-real-chokidar.test.ts +0 -165
  334. package/test/e2e/watcher-recursive-backend.test.ts +0 -181
  335. package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
  336. package/test/invite-flow.integration.test.ts +0 -244
  337. package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
  338. package/test/share-sync.integration.test.ts +0 -213
  339. package/tsconfig.json +0 -19
  340. package/vitest.config.ts +0 -22
@@ -1,970 +0,0 @@
1
- /**
2
- * hq reindex — surfaces namespaced skills as Claude Code skill wrappers under
3
- * .claude/skills/<ns>:<skill>/ (one symlink per source file), surfaces
4
- * namespaced slash commands as .claude/commands/<ns>:<cmd>.md symlinks (one per
5
- * source <ns>/commands/<cmd>.md file), prunes orphan wrappers + command links +
6
- * legacy command symlinks (plus a migration cleanup of retired
7
- * personal/{knowledge,policies,workers,settings} mirror symlinks in core/<type>/
8
- * — that overlay is now read directly from personal/, not mirrored), captures
9
- * this HQ tree's coding-harness session logs (Claude Code / Codex / Grok) into
10
- * workspace/.session-logs/<harness>/, and regenerates the workers registry.
11
- *
12
- * The logic historically lived in a bundled bash script (scripts/reindex.sh)
13
- * that this module exec'd via `bash`. It is now implemented natively in
14
- * TypeScript so the package ships a single, testable code path with no runtime
15
- * dependency on a shipped shell script. Behaviour is preserved exactly: the
16
- * same skill wrappers, personal-overlay mirrors, legacy-symlink cleanup, and
17
- * idempotent re-runs as before. Historically the script ran directly as a
18
- * Claude Code hook inside hq-core (named "master-sync"); the hq-core hook
19
- * remains a thin shim over `hq reindex`.
20
- */
21
- import { spawnSync } from "child_process";
22
- import * as fs from "fs";
23
- import * as os from "os";
24
- import * as path from "path";
25
- import { readlinkOrNull } from "../lib/readlink-safe.js";
26
- import {
27
- acquireOperationLock,
28
- OperationLockedError,
29
- OperationLockUnwritableError,
30
- OPERATION_LOCKED_EXIT,
31
- OPERATION_LOCK_UNWRITABLE_EXIT,
32
- type LockHandle,
33
- } from "../operation-lock.js";
34
- import {
35
- emitCloudTelemetry,
36
- type CloudTelemetryClient,
37
- type TelemetryClaims,
38
- } from "../telemetry-events.js";
39
- import { decodeLocalVaultSegment, encodeLocalVaultSegment } from "../local-path-codec.js";
40
-
41
- export interface ReindexOptions {
42
- /** HQ root to operate on. Defaults to process.cwd(). */
43
- repoRoot?: string;
44
- /**
45
- * Skip reindex's own per-root "reindex"-scope lock. Internal callers
46
- * (`sync()` / `rescue()`) run reindex inline at the tail of a pass they
47
- * already serialize, and pass `true` so a standalone `hq reindex` holding the
48
- * reindex lock never blocks the sync/rescue tail (reindex is idempotent and
49
- * re-run every pass, so a benign overlap self-heals). Standalone callers
50
- * (`hq reindex`, the reindex hook) leave it falsy so concurrent reindexes are
51
- * mutually exclusive.
52
- */
53
- skipLock?: boolean;
54
- /**
55
- * Bounded wait (seconds) for the per-root "reindex"-scope lock when another
56
- * reindex is already running. `0` → refuse immediately; omitted → inherit
57
- * `HQ_OP_LOCK_TIMEOUT` / infinite wait. Ignored when `skipLock` is set.
58
- *
59
- * NB: reindex uses a lock scope SEPARATE from sync/rescue's "operation" lock,
60
- * so a long-lived watch-mode sync-runner (which holds the operation lock for
61
- * its whole lifetime) can never starve a standalone `hq reindex`
62
- * (feedback_ed98d810). Standalone `hq reindex` waits by default, which is what
63
- * a human running it wants; the hq-core reindex HOOK (Stop / PostToolUse)
64
- * should set a small `HQ_OP_LOCK_TIMEOUT` (or `0`) so a hook fired while
65
- * another reindex runs never blocks the interactive agent indefinitely.
66
- */
67
- lockTimeoutSec?: number;
68
- /** Optional hq-pro ACTION telemetry sink. Omitted in tests/standalone hooks. */
69
- telemetryClient?: CloudTelemetryClient | null;
70
- telemetryClaims?: TelemetryClaims | null;
71
- telemetrySessionId?: string;
72
- telemetryCompanyUid?: string;
73
- changedCount?: number;
74
- }
75
-
76
- export interface ReindexResult {
77
- /** 0 = success; 1 = invalid repo root (mirrors the old script's exit codes). */
78
- status: number;
79
- }
80
-
81
- // --- small fs helpers (each mirrors a bash test operator) -------------------
82
-
83
- /** `[ -d "$p" ]` — exists and is a directory (follows symlinks). */
84
- function isDir(p: string): boolean {
85
- try {
86
- return fs.statSync(p).isDirectory();
87
- } catch {
88
- return false;
89
- }
90
- }
91
-
92
- /** `[ -f "$p" ]` — exists and is a regular file (follows symlinks). */
93
- function isFile(p: string): boolean {
94
- try {
95
- return fs.statSync(p).isFile();
96
- } catch {
97
- return false;
98
- }
99
- }
100
-
101
- /** `[ -e "$p" ]` — exists, dereferencing symlinks (a broken symlink is false). */
102
- function existsFollow(p: string): boolean {
103
- try {
104
- fs.statSync(p);
105
- return true;
106
- } catch {
107
- return false;
108
- }
109
- }
110
-
111
- /** `[ -x "$p" ]` — exists and is executable. */
112
- function isExecutable(p: string): boolean {
113
- try {
114
- fs.accessSync(p, fs.constants.X_OK);
115
- return true;
116
- } catch {
117
- return false;
118
- }
119
- }
120
-
121
- /** lstat without throwing (null when the path doesn't exist). */
122
- function lstatOrNull(p: string): fs.Stats | null {
123
- try {
124
- return fs.lstatSync(p);
125
- } catch {
126
- return null;
127
- }
128
- }
129
-
130
- /**
131
- * Entries of `dir` matching a bash `dir/*` glob: dotfiles excluded (no
132
- * dotglob), sorted lexicographically (bash glob order). Missing dir → [].
133
- */
134
- function globEntries(dir: string): string[] {
135
- let names: string[];
136
- try {
137
- names = fs.readdirSync(dir);
138
- } catch {
139
- return [];
140
- }
141
- return names.filter((n) => !n.startsWith(".")).sort();
142
- }
143
-
144
- function warn(msg: string): void {
145
- process.stderr.write(`${msg}\n`);
146
- }
147
-
148
- // Create a directory (recursively), tolerating platform-specific mkdir
149
- // failures instead of aborting the whole reindex. The classic case (HQ-B0) is
150
- // a `<ns>:<skill>` wrapper whose ':' is a reserved drive/ADS separator on
151
- // Windows, where mkdirSync throws ENOENT — but any single mkdir failure (a
152
- // stray file in the slot, a permission hiccup) should degrade gracefully and
153
- // let the rest of the reindex proceed. Returns true on success; on failure it
154
- // logs a clear, actionable warning and returns false so the caller can skip
155
- // just that piece. PR #98 guarded only the wrapper mkdir; this guards every
156
- // mkdir site so no single one can crash `hq reindex`.
157
- function safeMkdir(dir: string, label: string): boolean {
158
- try {
159
- fs.mkdirSync(dir, { recursive: true });
160
- return true;
161
- } catch (err) {
162
- const code = (err as NodeJS.ErrnoException).code;
163
- warn(
164
- `reindex: could not create ${label} '${dir}' ` +
165
- `(${code ?? "error"}: ${(err as Error).message}); skipping. On Windows ` +
166
- `this is usually an illegal path character (e.g. a ':' in a '<ns>:<skill>' ` +
167
- `wrapper name).`,
168
- );
169
- return false;
170
- }
171
- }
172
-
173
- // Bridge a source entry into a wrapper, tolerating platform-specific failures
174
- // instead of aborting the whole reindex — the bridging sibling of safeMkdir.
175
- // A symlink is preferred: it is nearly free and keeps the wrapper in lock-step
176
- // with edits to the source. The classic failure (HQ-CLI-4) is Windows WITHOUT
177
- // Developer Mode (or an elevated shell), where symlink creation is a privileged
178
- // operation and `fs.symlinkSync` throws EPERM (Win32 ERROR_PRIVILEGE_NOT_HELD).
179
- //
180
- // Historically we just skipped that entry, which on Windows left the wrapper
181
- // directory EMPTY — so the bridged skill/command never surfaced as a slash
182
- // command at all (bridging silently never worked on such machines). Instead of
183
- // skipping, fall back to an NTFS hardlink (unprivileged on the same volume, and
184
- // still inode-shared with the source) and then, only if that also fails, to a
185
- // plain copy. That is exactly the manual `New-Item -ItemType HardLink` workaround
186
- // users resort to, made automatic.
187
- //
188
- // `absSource` is the real (absolute) path of the source entry — needed because a
189
- // hardlink/copy references the source directly, not via the relative symlink
190
- // target. reindex is idempotent and re-run on hooks, so a wrapper left empty by
191
- // an older build self-heals on the next run: the missing entry is (re)bridged
192
- // here. On a machine that later gains symlink privilege, an entry already bridged
193
- // as a hardlink/copy is left in place (the caller treats a non-symlink as
194
- // pre-existing) — it still works, just isn't upgraded to a symlink. Returns true
195
- // if the entry was bridged by any method, false only if all of them failed.
196
- function safeBridgeLink(
197
- relativeTarget: string,
198
- linkPath: string,
199
- absSource: string,
200
- label: string,
201
- ): boolean {
202
- try {
203
- fs.symlinkSync(relativeTarget, linkPath);
204
- return true;
205
- } catch (symlinkErr) {
206
- const symlinkCode = (symlinkErr as NodeJS.ErrnoException).code;
207
- // Fallback 1: hardlink. Unprivileged on Windows/NTFS and stays inode-shared
208
- // with the source, so in-place edits still propagate. Files only — hardlinking
209
- // a directory fails, so a directory entry falls through to the copy below.
210
- try {
211
- fs.linkSync(absSource, linkPath);
212
- return true;
213
- } catch {
214
- /* not supported here (e.g. directory, cross-volume) — try a copy */
215
- }
216
- // Fallback 2: plain copy (handles files and directories). A point-in-time
217
- // snapshot — the last resort when neither a symlink nor a hardlink is possible.
218
- try {
219
- fs.cpSync(absSource, linkPath, { recursive: true, force: true });
220
- return true;
221
- } catch (copyErr) {
222
- const copyCode = (copyErr as NodeJS.ErrnoException).code;
223
- warn(
224
- `reindex: could not bridge ${label} '${linkPath}' -> '${relativeTarget}' ` +
225
- `(symlink ${symlinkCode ?? "error"}: ${(symlinkErr as Error).message}; ` +
226
- `copy ${copyCode ?? "error"}: ${(copyErr as Error).message}); skipping. ` +
227
- `On Windows, creating symlinks needs Developer Mode (Settings -> Privacy ` +
228
- `& security -> For developers) or an elevated shell.`,
229
- );
230
- return false;
231
- }
232
- }
233
- }
234
-
235
- // Capture the raw session transcripts of every supported coding harness for
236
- // this HQ tree into `workspace/.session-logs/<harness>/`, so they live inside
237
- // the HQ tree (where sync/search can see them) on each reindex.
238
- //
239
- // Scope — "this HQ tree" = the HQ root cwd OR any working directory UNDER it
240
- // (worktrees, repo subdirs). Harnesses key their logs by cwd differently, so
241
- // each helper re-derives the same scope in its own encoding.
242
- //
243
- // Contract (per the feature request), applied by every helper:
244
- // - copies ALL matching files/folders into `.session-logs/<harness>/…`,
245
- // - OVERWRITES existing dest files, and
246
- // - NEVER deletes dest entries that lack a source counterpart — a one-way
247
- // overlay, not a mirror, so anything already parked in `.session-logs`
248
- // survives even after a harness rotates or prunes its own store.
249
- //
250
- // Best-effort throughout: any failure warns to stderr and is swallowed so it
251
- // can never fail the reindex. Each harness is independent — one failing must
252
- // not stop the others. Home dirs honor CLAUDE_CONFIG_DIR / CODEX_HOME /
253
- // GROK_HOME, else ~/.claude, ~/.codex, ~/.grok.
254
- function copySessionLogs(root: string): void {
255
- const destBase = path.join(root, "workspace", ".session-logs");
256
- for (const capture of [
257
- copyClaudeSessionLogs,
258
- copyCodexSessionLogs,
259
- copyGrokSessionLogs,
260
- ]) {
261
- try {
262
- capture(root, destBase);
263
- } catch (err) {
264
- warn(`reindex: session-log capture step failed (${(err as Error).message}); skipping.`);
265
- }
266
- }
267
- }
268
-
269
- // Candidate spellings of the HQ root to match harness-recorded cwds against:
270
- // the lexical root AND its realpath. Harnesses key logs by the cwd the user
271
- // actually ran in, which may reach the same tree through a symlink or a
272
- // case-different spelling (macOS volumes are case-insensitive); realpath
273
- // canonicalizes both. Best-effort: realpath may throw if root is gone.
274
- // De-duplicated, lexical root first (stable, preferred dest naming).
275
- function hqRootCandidates(root: string): string[] {
276
- const out = [root];
277
- try {
278
- const real = fs.realpathSync.native(root);
279
- if (real && real !== root) out.push(real);
280
- } catch {
281
- // best-effort — a missing/unreadable root just means no realpath candidate
282
- }
283
- return out;
284
- }
285
-
286
- /** `fs.statSync` without throwing (follows symlinks; null when missing). */
287
- function statOrNull(p: string): fs.Stats | null {
288
- try {
289
- return fs.statSync(p);
290
- } catch {
291
- return null;
292
- }
293
- }
294
-
295
- // Overlay-copy a whole directory tree: mkdir dest, then recursive+force cpSync
296
- // (overwrite existing, never prune dest-only entries). Best-effort — warns and
297
- // returns on any failure. Shared by the Claude + Grok per-cwd-folder captures.
298
- function copyTreeOverlay(src: string, dest: string, label: string): void {
299
- if (!safeMkdir(dest, `${label} directory`)) return;
300
- try {
301
- fs.cpSync(src, dest, { recursive: true, force: true });
302
- } catch (err) {
303
- warn(
304
- `reindex: could not copy ${label} from '${src}' to '${dest}' ` +
305
- `(${(err as NodeJS.ErrnoException).code ?? "error"}: ${(err as Error).message}); skipping.`,
306
- );
307
- }
308
- }
309
-
310
- // Claude Code: `<claude-config>/projects/<encoded-cwd>/…`, where the cwd is
311
- // encoded by replacing every non-alphanumeric char with '-'. Because that map
312
- // is per-character, a descendant cwd's dir name is exactly the root's encoded
313
- // name + '-' + <encoded-subpath> — so "this HQ tree" is the exact-root dir OR
314
- // any dir whose name starts with `<slug>-`. Copy each into `.session-logs/claude/`.
315
- function copyClaudeSessionLogs(root: string, destBase: string): void {
316
- const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
317
- const projectsDir = path.join(configDir, "projects");
318
- if (!isDir(projectsDir)) return;
319
-
320
- const slugs = [...new Set(hqRootCandidates(root).map((r) => r.replace(/[^a-zA-Z0-9]/g, "-")))];
321
- for (const entry of globEntries(projectsDir)) {
322
- if (!slugs.some((slug) => entry === slug || entry.startsWith(`${slug}-`))) continue;
323
- const src = path.join(projectsDir, entry);
324
- if (!isDir(src)) continue;
325
- copyTreeOverlay(src, path.join(destBase, "claude", entry), "claude session logs");
326
- }
327
- }
328
-
329
- // Grok: `<grok-home>/sessions/<url-encoded-cwd>/<session-uuid>/…`, where the cwd
330
- // is URL-encoded (encodeURIComponent: '/' -> '%2F'). A descendant cwd's key is
331
- // the root's key + '%2F' + <encoded-subpath>, so match the exact-root key OR
332
- // that prefix. Copy each matching cwd folder into `.session-logs/grok/`.
333
- function copyGrokSessionLogs(root: string, destBase: string): void {
334
- const grokHome = process.env.GROK_HOME || path.join(os.homedir(), ".grok");
335
- const sessionsDir = path.join(grokHome, "sessions");
336
- if (!isDir(sessionsDir)) return;
337
-
338
- const keys = [...new Set(hqRootCandidates(root).map((r) => encodeURIComponent(r)))];
339
- for (const entry of globEntries(sessionsDir)) {
340
- if (!keys.some((key) => entry === key || entry.startsWith(`${key}%2F`))) continue;
341
- const src = path.join(sessionsDir, entry);
342
- if (!isDir(src)) continue;
343
- copyTreeOverlay(src, path.join(destBase, "grok", entry), "grok session logs");
344
- }
345
- }
346
-
347
- // Codex: `<codex-home>/sessions/YYYY/MM/DD/rollout-*.jsonl` — a DATE tree, not
348
- // per-project. The working dir is only recorded inside each rollout's first
349
- // line (`session_meta`, at `payload.cwd`). Walk the tree and capture a rollout
350
- // iff its recorded cwd is the HQ root or under it, mirroring the date-relative
351
- // path under `.session-logs/codex/`. A rollout already copied and unchanged is
352
- // skipped by mtime, so a steady-state reindex only classifies newly-written
353
- // rollouts — the full-tree cwd scan is a one-time first-run cost.
354
- function copyCodexSessionLogs(root: string, destBase: string): void {
355
- const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
356
- const sessionsDir = path.join(codexHome, "sessions");
357
- if (!isDir(sessionsDir)) return;
358
-
359
- const destDir = path.join(destBase, "codex");
360
- const candidates = hqRootCandidates(root);
361
- const prefixes = candidates.map((r) => (r.endsWith(path.sep) ? r : r + path.sep));
362
-
363
- for (const abs of walkFiles(sessionsDir)) {
364
- if (!abs.endsWith(".jsonl")) continue;
365
- const srcStat = statOrNull(abs);
366
- if (!srcStat) continue;
367
-
368
- const dest = path.join(destDir, path.relative(sessionsDir, abs));
369
- // Cheap skip: already captured and unchanged since (dest mtime is the copy
370
- // time, so it stays >= src mtime until the rollout is appended to again).
371
- const destStat = statOrNull(dest);
372
- if (destStat && destStat.mtimeMs >= srcStat.mtimeMs) continue;
373
-
374
- const cwd = readCodexRolloutCwd(abs);
375
- if (cwd === null) continue;
376
- if (!candidates.includes(cwd) && !prefixes.some((prefix) => cwd.startsWith(prefix))) continue;
377
-
378
- if (!safeMkdir(path.dirname(dest), "codex session-log dir")) continue;
379
- try {
380
- fs.copyFileSync(abs, dest);
381
- } catch (err) {
382
- warn(
383
- `reindex: could not copy codex session log '${abs}' -> '${dest}' ` +
384
- `(${(err as NodeJS.ErrnoException).code ?? "error"}: ${(err as Error).message}); skipping.`,
385
- );
386
- }
387
- }
388
- }
389
-
390
- // Collect every regular file under `dir` (absolute paths) in depth-first order.
391
- // Symlinks are not followed (lstat gate) so a stray link can't escape the tree
392
- // or loop. Missing/unreadable dirs yield []. Best-effort — mirrors
393
- // globEntries' tolerance.
394
- function walkFiles(dir: string): string[] {
395
- const out: string[] = [];
396
- const stack: Array<{ names: string[]; index: number; dir: string }> = [];
397
- const enterDirectory = (currentDir: string): void => {
398
- try {
399
- stack.push({ names: fs.readdirSync(currentDir), index: 0, dir: currentDir });
400
- } catch {
401
- // Missing and unreadable children are ignored just like the recursive
402
- // walker did when its nested call returned an empty list.
403
- }
404
- };
405
-
406
- enterDirectory(dir);
407
- while (stack.length > 0) {
408
- const frame = stack[stack.length - 1];
409
- if (frame.index >= frame.names.length) {
410
- stack.pop();
411
- continue;
412
- }
413
- const name = frame.names[frame.index++];
414
- const abs = path.join(frame.dir, name);
415
- const st = lstatOrNull(abs);
416
- if (!st) continue;
417
- if (st.isDirectory()) enterDirectory(abs);
418
- else if (st.isFile()) out.push(abs);
419
- }
420
- return out;
421
- }
422
-
423
- // Extract `payload.cwd` (Codex ≥ the session_meta format; falls back to a
424
- // top-level `cwd`) from a rollout's first line, reading only that line. Codex
425
- // records cwd early, before the large `base_instructions` blob, but the line
426
- // can still be tens of KB — so read up to a generous cap and stop at the first
427
- // newline. Returns null on any parse/read failure (the rollout is then skipped).
428
- function readCodexRolloutCwd(file: string): string | null {
429
- const line = readFirstLine(file, 4 * 1024 * 1024);
430
- if (line === null) return null;
431
- try {
432
- const obj = JSON.parse(line) as { cwd?: unknown; payload?: { cwd?: unknown } };
433
- const cwd = obj?.payload?.cwd ?? obj?.cwd;
434
- return typeof cwd === "string" ? cwd : null;
435
- } catch {
436
- return null;
437
- }
438
- }
439
-
440
- // Read the first line of `file` (up to `capBytes`), returning it WITHOUT the
441
- // trailing newline, or null on error / empty. Byte-accumulates and decodes once
442
- // so a multibyte char split across a read boundary can't corrupt the result.
443
- function readFirstLine(file: string, capBytes: number): string | null {
444
- let fd: number | null = null;
445
- try {
446
- fd = fs.openSync(file, "r");
447
- const chunks: Buffer[] = [];
448
- const chunk = Buffer.allocUnsafe(65536);
449
- let total = 0;
450
- while (total < capBytes) {
451
- const n = fs.readSync(fd, chunk, 0, chunk.length, total);
452
- if (n <= 0) break;
453
- const nl = chunk.indexOf(0x0a, 0);
454
- if (nl !== -1 && nl < n) {
455
- chunks.push(Buffer.from(chunk.subarray(0, nl)));
456
- return Buffer.concat(chunks).toString("utf8");
457
- }
458
- chunks.push(Buffer.from(chunk.subarray(0, n)));
459
- total += n;
460
- }
461
- return chunks.length ? Buffer.concat(chunks).toString("utf8") : null;
462
- } catch {
463
- return null;
464
- } finally {
465
- if (fd !== null) {
466
- try {
467
- fs.closeSync(fd);
468
- } catch {
469
- /* best-effort */
470
- }
471
- }
472
- }
473
- }
474
-
475
- // --- legacy `.claude/commands/<ns>/<skill>.md` symlink matcher --------------
476
- // Mirrors the bash `case` patterns; `*` matches any chars (including `/`).
477
- function isLegacyCommandTarget(t: string): boolean {
478
- return (
479
- /^\.\.\/\.\.\/\.\.\/companies\/.*\/skills\/.*\/SKILL\.md$/.test(t) ||
480
- /^\.\.\/\.\.\/\.\.\/core\/skills\/.*\/SKILL\.md$/.test(t) ||
481
- /^\.\.\/\.\.\/\.\.\/personal\/skills\/.*\/SKILL\.md$/.test(t) ||
482
- /^\.\.\/\.\.\/\.\.\/core\/packages\/.*\/skills\/.*\/SKILL\.md$/.test(t)
483
- );
484
- }
485
-
486
- /**
487
- * Run reindex against an HQ root. Synchronous — the work is cheap and
488
- * idempotent, and callers (the hook shim, sync/rescue, tests) want the exit
489
- * status. Diagnostics are written to stderr so the caller's stdout stays clean
490
- * (hooks must not emit stdout that the agent interprets).
491
- */
492
- export function reindex(opts: ReindexOptions = {}): ReindexResult {
493
- const rawRoot = opts.repoRoot ?? process.cwd();
494
- if (!isDir(rawRoot)) {
495
- warn(`reindex: REPO_ROOT '${rawRoot}' is not a directory`);
496
- const result = { status: 1 };
497
- emitReindexTelemetry(opts, result, "not_attempted");
498
- return result;
499
- }
500
- const root = path.resolve(rawRoot);
501
-
502
- // Acquire the per-root "reindex"-scope lock unless an internal caller
503
- // (sync/rescue) opted out. This scope is SEPARATE from sync/rescue's
504
- // "operation" lock, so a long-lived watch-mode sync-runner holding the
505
- // operation lock never starves us (feedback_ed98d810) — we only contend with
506
- // other reindexes. A live holder → refuse fast with the holder's command +
507
- // PID. The whole body runs inside the try so the lock is released on every
508
- // exit path (the process-level signal/exit hooks are the crash backstop).
509
- let opLock: LockHandle | null = null;
510
- if (!opts.skipLock) {
511
- try {
512
- opLock = acquireOperationLock(root, "reindex", {
513
- scope: "reindex",
514
- timeoutSec: opts.lockTimeoutSec,
515
- });
516
- } catch (err) {
517
- if (err instanceof OperationLockedError) {
518
- warn(err.message);
519
- const result = { status: OPERATION_LOCKED_EXIT };
520
- emitReindexTelemetry(opts, result, "locked");
521
- return result;
522
- }
523
- if (err instanceof OperationLockUnwritableError) {
524
- // The lock dir isn't writable (e.g. macOS EPERM / Linux EACCES on a
525
- // root-owned or restricted ~/.hq). This is an environmental problem on
526
- // the user's machine, not an HQ fault — surface the actionable guidance
527
- // and exit cleanly instead of crashing with a raw `EPERM ... open`
528
- // (HQ-CLI-2).
529
- warn(err.message);
530
- const result = { status: OPERATION_LOCK_UNWRITABLE_EXIT };
531
- emitReindexTelemetry(opts, result, "unwritable");
532
- return result;
533
- }
534
- throw err;
535
- }
536
- }
537
- try {
538
-
539
- // Overlay/skill-wrapper mirroring is best-effort and, on a normal synced HQ,
540
- // legitimately no-ops for MANY entries (a real core file already sits where a
541
- // symlink would go, or a link already points elsewhere). Emitting one stderr
542
- // line PER such entry floods the runner's Sentry breadcrumb ring (100 entries)
543
- // and evicts the real per-company sync error, making code-2 syncs impossible to
544
- // triage (HQ-SYNC-2 / HQ-SYNC-WEB-6). Collapse those per-file lines into a
545
- // single end-of-run summary; keep per-file detail behind HQ_REINDEX_VERBOSE=1.
546
- const verbose = process.env.HQ_REINDEX_VERBOSE === "1";
547
- let skippedNonSymlink = 0;
548
- let skippedPointsElsewhere = 0;
549
-
550
- // Best-effort: each wrapper mkdir below is recursive and re-creates this
551
- // parent as needed, so a failure here is non-fatal — warn and carry on so
552
- // personal-overlay mirroring and registry regeneration still run.
553
- safeMkdir(path.join(root, ".claude", "skills"), ".claude/skills directory");
554
-
555
- // --- Build (namespace, src_rel) pairs -------------------------------------
556
- const pairs: { ns: string; srcRel: string }[] = [];
557
- const addNs = (ns: string, srcRel: string): void => {
558
- if (isDir(path.join(root, srcRel))) pairs.push({ ns, srcRel });
559
- };
560
-
561
- for (const slug of globEntries(path.join(root, "companies"))) {
562
- if (isDir(path.join(root, "companies", slug))) {
563
- addNs(slug, `companies/${slug}/skills`);
564
- }
565
- }
566
- addNs("core", "core/skills");
567
- addNs("personal", "personal/skills");
568
- for (const pack of globEntries(path.join(root, "core", "packages"))) {
569
- if (isDir(path.join(root, "core", "packages", pack))) {
570
- addNs(pack, `core/packages/${pack}/skills`);
571
- }
572
- }
573
-
574
- // --- Build (namespace, src_rel) pairs for slash commands ------------------
575
- // Mirrors the skill namespaces but reads from <ns>/commands/ instead of
576
- // <ns>/skills/. Built independently so a namespace with commands but no
577
- // skills (or vice-versa) is handled on its own.
578
- const cmdPairs: { ns: string; srcRel: string }[] = [];
579
- const addCmdNs = (ns: string, srcRel: string): void => {
580
- if (isDir(path.join(root, srcRel))) cmdPairs.push({ ns, srcRel });
581
- };
582
- for (const slug of globEntries(path.join(root, "companies"))) {
583
- if (isDir(path.join(root, "companies", slug))) {
584
- addCmdNs(slug, `companies/${slug}/commands`);
585
- }
586
- }
587
- addCmdNs("core", "core/commands");
588
- addCmdNs("personal", "personal/commands");
589
- for (const pack of globEntries(path.join(root, "core", "packages"))) {
590
- if (isDir(path.join(root, "core", "packages", pack))) {
591
- addCmdNs(pack, `core/packages/${pack}/commands`);
592
- }
593
- }
594
-
595
- // --- Cleanup pass A: drop legacy .claude/commands/<ns>/<skill>.md links ----
596
- const commandsDir = path.join(root, ".claude", "commands");
597
- if (isDir(commandsDir)) {
598
- for (const nsName of globEntries(commandsDir)) {
599
- const cmdNsDir = path.join(commandsDir, nsName);
600
- // Only real directories (bash `*/`); folder-level symlinks are left for
601
- // a human to resolve. lstat().isDirectory() is false for a symlink, so
602
- // this single check covers both bash guards.
603
- const st = lstatOrNull(cmdNsDir);
604
- if (!st || !st.isDirectory()) continue;
605
-
606
- for (const f of globEntries(cmdNsDir)) {
607
- if (!f.endsWith(".md")) continue;
608
- const fp = path.join(cmdNsDir, f);
609
- const fst = lstatOrNull(fp);
610
- if (!fst || !fst.isSymbolicLink()) continue;
611
- const target = readlinkOrNull(fp);
612
- if (target && isLegacyCommandTarget(target)) {
613
- try {
614
- fs.rmSync(fp);
615
- } catch {
616
- /* best-effort */
617
- }
618
- }
619
- }
620
-
621
- // rmdir if empty; ignore "directory not empty".
622
- try {
623
- fs.rmdirSync(cmdNsDir);
624
- } catch {
625
- /* not empty / gone — fine */
626
- }
627
- }
628
- }
629
-
630
- // --- Skill wrapper creation -----------------------------------------------
631
- const expectedWrappers = new Set<string>();
632
- const seen = new Set<string>();
633
-
634
- for (const { ns, srcRel } of pairs) {
635
- // First writer for a namespace wins.
636
- if (seen.has(ns)) {
637
- warn(
638
- `reindex: namespace '${ns}' already claimed by an earlier source; skipping ${srcRel}`,
639
- );
640
- continue;
641
- }
642
- seen.add(ns);
643
-
644
- const srcAbs = path.join(root, srcRel);
645
- for (const skillName of globEntries(srcAbs)) {
646
- const skillPath = path.join(srcAbs, skillName);
647
- if (!isDir(skillPath)) continue;
648
- // Skip `_shared`, `_template`, etc. (dotfiles already excluded by glob).
649
- if (skillName.startsWith("_")) continue;
650
- if (!isFile(path.join(skillPath, "SKILL.md"))) continue;
651
-
652
- const wrapperName = encodeLocalVaultSegment(`${ns}:${skillName}`);
653
- const wrapper = path.join(root, ".claude", "skills", wrapperName);
654
- expectedWrappers.add(wrapperName);
655
-
656
- const wst = lstatOrNull(wrapper);
657
- // If something non-directory, non-symlink occupies the slot, bail.
658
- if (wst && !wst.isSymbolicLink() && !wst.isDirectory()) {
659
- warn(`reindex: ${wrapper} exists and is not a directory; skipping`);
660
- continue;
661
- }
662
- // If it's a symlink (legacy directory-symlink form), replace it with a
663
- // real directory of per-file symlinks.
664
- if (wst && wst.isSymbolicLink()) {
665
- try {
666
- fs.rmSync(wrapper);
667
- } catch {
668
- /* best-effort */
669
- }
670
- }
671
- // Namespaced wrappers embed a ':' in the directory name (`<ns>:<skill>`),
672
- // which is a reserved drive/ADS separator on Windows where mkdir fails
673
- // with ENOENT (HQ-B0). Skip just this wrapper instead of aborting the
674
- // whole reindex; the skill's source folder is untouched.
675
- if (!safeMkdir(wrapper, `skill wrapper '${wrapperName}'`)) continue;
676
-
677
- // Symlink every (non-hidden) entry in the source skill folder. The
678
- // wrapper lives three levels below REPO_ROOT.
679
- for (const entry of globEntries(skillPath)) {
680
- const entryPath = path.join(skillPath, entry);
681
- if (!existsFollow(entryPath)) continue;
682
- const linkPath = path.join(wrapper, entry);
683
- const relativeTarget = `../../../${srcRel}/${skillName}/${entry}`;
684
-
685
- const lst = lstatOrNull(linkPath);
686
- if (lst && lst.isSymbolicLink()) {
687
- const current = readlinkOrNull(linkPath);
688
- if (current === relativeTarget) continue;
689
- skippedPointsElsewhere++;
690
- if (verbose) {
691
- warn(
692
- `reindex: .claude/skills/${wrapperName}/${entry} already points to '${current}' (expected '${relativeTarget}'); leaving alone`,
693
- );
694
- }
695
- continue;
696
- } else if (lst) {
697
- skippedNonSymlink++;
698
- if (verbose) {
699
- warn(`reindex: ${linkPath} already exists and is not a symlink; skipping`);
700
- }
701
- continue;
702
- }
703
-
704
- safeBridgeLink(relativeTarget, linkPath, entryPath, `.claude/skills/${wrapperName}/${entry}`);
705
- }
706
-
707
- // Prune symlinks in the wrapper whose source entry no longer exists.
708
- for (const entry of globEntries(wrapper)) {
709
- const linkPath = path.join(wrapper, entry);
710
- const lst = lstatOrNull(linkPath);
711
- if (!lst || !lst.isSymbolicLink()) continue;
712
- if (existsFollow(linkPath)) continue;
713
- try {
714
- fs.rmSync(linkPath);
715
- } catch {
716
- /* best-effort */
717
- }
718
- }
719
- }
720
- }
721
-
722
- // --- Cleanup pass B: drop orphan <ns>:<skill> wrappers --------------------
723
- const skillsDir = path.join(root, ".claude", "skills");
724
- for (const entry of globEntries(skillsDir)) {
725
- const entryPath = path.join(skillsDir, entry);
726
- const lst = lstatOrNull(entryPath);
727
- if (!lst) continue; // accept broken symlinks (lstat still succeeds)
728
- const canonicalEntry = decodeLocalVaultSegment(entry);
729
- if (!canonicalEntry.includes(":")) continue; // not a namespaced wrapper
730
- if (expectedWrappers.has(entry)) continue; // maintained this run
731
-
732
- // A wrapper is "managed" only when its namespace prefix matches the
733
- // namespace encoded in its symlink target — this distinguishes
734
- // script-produced wrappers from hand-authored composite wrappers.
735
- const ns = canonicalEntry.slice(0, canonicalEntry.indexOf(":"));
736
- const matchTarget = (t: string | null, p: string): boolean => {
737
- if (!t) return false;
738
- if (t.startsWith(`${p}/personal/skills/`)) return ns === "personal";
739
- if (t.startsWith(`${p}/core/skills/`)) return ns === "core";
740
- if (t.startsWith(`${p}/companies/${ns}/skills/`)) return true;
741
- if (t.startsWith(`${p}/core/packages/${ns}/skills/`)) return true;
742
- return false;
743
- };
744
-
745
- let isManaged = false;
746
- if (lst.isSymbolicLink()) {
747
- // (a) directory-style symlink wrapper (older shape): 2-level target.
748
- isManaged = matchTarget(readlinkOrNull(entryPath), "../..");
749
- } else if (lst.isDirectory()) {
750
- // (b) real directory of per-file symlinks (current shape): 3-level.
751
- for (const f of globEntries(entryPath)) {
752
- const fp = path.join(entryPath, f);
753
- const fst = lstatOrNull(fp);
754
- if (!fst || !fst.isSymbolicLink()) continue;
755
- if (matchTarget(readlinkOrNull(fp), "../../..")) {
756
- isManaged = true;
757
- break;
758
- }
759
- }
760
- }
761
- if (!isManaged) continue;
762
-
763
- // Managed-namespace wrapper with no corresponding live source → drop.
764
- try {
765
- if (lst.isSymbolicLink()) {
766
- fs.rmSync(entryPath);
767
- } else if (lst.isDirectory()) {
768
- fs.rmSync(entryPath, { recursive: true, force: true });
769
- }
770
- } catch {
771
- /* best-effort */
772
- }
773
- }
774
-
775
- // --- Slash-command surfacing ----------------------------------------------
776
- // Parallel to skills: mirror each namespace's commands/<cmd>.md into
777
- // .claude/commands/<ns>:<cmd>.md, so a company/core/personal/pack command
778
- // shows up as /<ns>:<cmd> with the same <ns>:<name> naming as skills. A slash
779
- // command is a single .md file (not a folder), so each wrapper is ONE symlink
780
- // two levels below REPO_ROOT — no per-file wrapper directory.
781
- const expectedCommands = new Set<string>();
782
- const seenCmd = new Set<string>();
783
- // Lazy: only materialize .claude/commands when a source command exists, so a
784
- // tree with no commands never gains an empty directory. The orphan-prune pass
785
- // below still runs against whatever already exists.
786
- if (cmdPairs.length > 0) {
787
- safeMkdir(commandsDir, ".claude/commands directory");
788
- }
789
- for (const { ns, srcRel } of cmdPairs) {
790
- // First writer for a namespace wins (matches the skill dedup).
791
- if (seenCmd.has(ns)) {
792
- warn(
793
- `reindex: command namespace '${ns}' already claimed by an earlier source; skipping ${srcRel}`,
794
- );
795
- continue;
796
- }
797
- seenCmd.add(ns);
798
-
799
- const srcAbs = path.join(root, srcRel);
800
- for (const entry of globEntries(srcAbs)) {
801
- // Skip `_`-prefixed include partials (dotfiles already excluded by glob)
802
- // and anything that isn't a regular `.md` command file.
803
- if (entry.startsWith("_")) continue;
804
- if (!entry.endsWith(".md")) continue;
805
- if (!isFile(path.join(srcAbs, entry))) continue;
806
-
807
- const wrapperName = encodeLocalVaultSegment(`${ns}:${entry}`);
808
- const linkPath = path.join(commandsDir, wrapperName);
809
- const relativeTarget = `../../${srcRel}/${entry}`;
810
- expectedCommands.add(wrapperName);
811
-
812
- const lst = lstatOrNull(linkPath);
813
- if (lst && lst.isSymbolicLink()) {
814
- const current = readlinkOrNull(linkPath);
815
- if (current === relativeTarget) continue;
816
- skippedPointsElsewhere++;
817
- if (verbose) {
818
- warn(
819
- `reindex: .claude/commands/${wrapperName} already points to '${current}' (expected '${relativeTarget}'); leaving alone`,
820
- );
821
- }
822
- continue;
823
- } else if (lst) {
824
- skippedNonSymlink++;
825
- if (verbose) {
826
- warn(`reindex: ${linkPath} already exists and is not a symlink; skipping`);
827
- }
828
- continue;
829
- }
830
-
831
- safeBridgeLink(relativeTarget, linkPath, path.join(srcAbs, entry), `.claude/commands/${wrapperName}`);
832
- }
833
- }
834
-
835
- // --- Cleanup: drop orphan <ns>:<cmd>.md command links ---------------------
836
- // Symmetric to skill Cleanup pass B, adapted to the flat one-symlink-per-file
837
- // shape (`../../` target, no wrapper directory). Only prunes symlinks reindex
838
- // owns: a managed namespace whose target resolves into that namespace's
839
- // commands/. Real files (even under a managed namespace) and composite links
840
- // under an unmanaged namespace are left for a human to resolve.
841
- for (const entry of globEntries(commandsDir)) {
842
- const entryPath = path.join(commandsDir, entry);
843
- const lst = lstatOrNull(entryPath);
844
- if (!lst) continue; // accept broken symlinks (lstat still succeeds)
845
- if (!lst.isSymbolicLink()) continue; // real files / legacy subdirs → not ours
846
- const canonicalEntry = decodeLocalVaultSegment(entry);
847
- if (!canonicalEntry.endsWith(".md")) continue;
848
- if (!canonicalEntry.includes(":")) continue; // not a namespaced command
849
- if (expectedCommands.has(entry)) continue; // maintained this run
850
-
851
- const ns = canonicalEntry.slice(0, canonicalEntry.indexOf(":"));
852
- const cmdName = canonicalEntry.slice(canonicalEntry.indexOf(":") + 1);
853
- const target = readlinkOrNull(entryPath);
854
- // A reindex-created link points at EXACTLY ../../<managed-base>/commands/
855
- // <cmdName> — its basename equals the decoded command name. Cross-check the
856
- // full expected target (not just the namespace-dir prefix) so a hand-authored
857
- // alias such as `core:alias.md -> ../../core/commands/demo.md` (basename
858
- // 'demo.md' != 'alias.md', and with no core/commands/alias.md source) is
859
- // preserved rather than pruned as if reindex owned it.
860
- const managedTargets: string[] = [];
861
- if (ns === "personal") managedTargets.push(`../../personal/commands/${cmdName}`);
862
- if (ns === "core") managedTargets.push(`../../core/commands/${cmdName}`);
863
- managedTargets.push(`../../companies/${ns}/commands/${cmdName}`);
864
- managedTargets.push(`../../core/packages/${ns}/commands/${cmdName}`);
865
- if (target === null || !managedTargets.includes(target)) continue;
866
-
867
- // Managed command link with no corresponding live source → drop.
868
- try {
869
- fs.rmSync(entryPath);
870
- } catch {
871
- /* best-effort */
872
- }
873
- }
874
-
875
- // --- Personal-overlay mirroring: RETIRED + migration cleanup ---------------
876
- // personal/{knowledge,policies,workers,settings} is now read DIRECTLY by the
877
- // HQ tooling (the inject-policy-on-trigger hook reads personal/policies, the
878
- // workers-registry generator walks personal/workers, qmd-reindex-after-sync
879
- // registers a personal/knowledge collection, and repo-run-registry reads
880
- // personal/settings/orchestrator.yaml). personal is the SOLE read source for
881
- // the personal overlay, so reindex no longer SYMLINKS personal/<type>/<entry>
882
- // into core/<type>/<entry> — a mirror would now double-surface every entry
883
- // (read once directly, once via the link).
884
- //
885
- // Migration cleanup: prune any mirror symlink a PRIOR release created —
886
- // core/<type>/<entry> whose target is EXACTLY `../../personal/<type>/<entry>`.
887
- // That exact-target discriminator (policy wrapper-sync-orphan-cleanup-cross-
888
- // check) means a real release-shipped core file (not a symlink) or a human-
889
- // authored symlink pointing elsewhere is never touched. Unlike the old
890
- // "Cleanup pass C", this prunes the link WHETHER OR NOT its personal source
891
- // still exists: the mirror is retired, so every managed link is now stale.
892
- // Kept for a few releases so existing trees self-heal on the next reindex.
893
- for (const type of ["knowledge", "policies", "workers", "settings"]) {
894
- const coreDir = path.join(root, "core", type);
895
- for (const entry of globEntries(coreDir)) {
896
- const linkPath = path.join(coreDir, entry);
897
- const lst = lstatOrNull(linkPath);
898
- if (!lst || !lst.isSymbolicLink()) continue; // real file/dir → not ours
899
- if (readlinkOrNull(linkPath) !== `../../personal/${type}/${entry}`) continue; // not a managed mirror link
900
- // Managed mirror symlink from a prior release → prune (mirror is retired).
901
- try {
902
- fs.rmSync(linkPath);
903
- } catch {
904
- /* best-effort */
905
- }
906
- }
907
- }
908
-
909
- // --- Session-log capture --------------------------------------------------
910
- // Overlay this root's Claude Code session logs into workspace/.session-logs
911
- // (copy-all, overwrite, never-prune). Best-effort; never fails the reindex.
912
- copySessionLogs(root);
913
-
914
- // --- Workers registry regeneration ----------------------------------------
915
- // Source of truth: each worker.yaml. The registry is a derived index — the
916
- // generator (when present in the operated-on tree) keeps it in sync.
917
- // Best-effort and idempotent; never fails the reindex.
918
- const genScript = path.join(root, "core", "scripts", "generate-workers-registry.sh");
919
- if (isExecutable(genScript)) {
920
- // Route the generator's stdout+stderr to our stderr (`>&2 2>&1`).
921
- spawnSync("bash", [genScript], { stdio: ["ignore", 2, 2] });
922
- }
923
-
924
- // One-line summary in place of the per-file overlay-skip spam (see the comment
925
- // at the top of the try). Only emitted when something was skipped, so a clean
926
- // run stays silent and the breadcrumb ring is free for real sync errors.
927
- if (skippedNonSymlink > 0 || skippedPointsElsewhere > 0) {
928
- const parts: string[] = [];
929
- if (skippedNonSymlink > 0) {
930
- parts.push(`${skippedNonSymlink} pre-existing non-symlink ${skippedNonSymlink === 1 ? "entry" : "entries"}`);
931
- }
932
- if (skippedPointsElsewhere > 0) {
933
- parts.push(`${skippedPointsElsewhere} ${skippedPointsElsewhere === 1 ? "entry" : "entries"} already pointing elsewhere`);
934
- }
935
- warn(
936
- `reindex: left ${parts.join(" + ")} untouched (overlay mirror is best-effort; set HQ_REINDEX_VERBOSE=1 for per-file detail)`,
937
- );
938
- }
939
-
940
- const result = { status: 0 };
941
- emitReindexTelemetry(opts, result, opts.skipLock ? "skipped" : "acquired");
942
- return result;
943
- } finally {
944
- opLock?.release();
945
- }
946
- }
947
-
948
- function emitReindexTelemetry(
949
- opts: ReindexOptions,
950
- result: ReindexResult,
951
- lockResult: string,
952
- ): void {
953
- void emitCloudTelemetry(
954
- opts.telemetryClient,
955
- {
956
- eventName: "reindex_run",
957
- source: "hq-reindex",
958
- ...(opts.telemetryCompanyUid ? { companyUid: opts.telemetryCompanyUid } : {}),
959
- ...(opts.telemetrySessionId ? { sessionId: opts.telemetrySessionId } : {}),
960
- properties: {
961
- result: result.status === 0 ? "completed" : "failed",
962
- statusCode: result.status,
963
- lockResult,
964
- changedCount: opts.changedCount ?? 0,
965
- skipLockState: opts.skipLock === true ? "skipped" : "acquired",
966
- },
967
- },
968
- { claims: opts.telemetryClaims },
969
- );
970
- }