@indigoai-us/hq-cloud 6.15.0 → 6.15.2

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