@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,481 +0,0 @@
1
- /**
2
- * Scope-shrink detection + classification + clean removal (US-005).
3
- *
4
- * Implements the "hybrid scope-change contract" decided in US-000 Task 3
5
- * (see companies/indigo/projects/hq-sync-browse-vs-sync/references.md):
6
- *
7
- * - Compare the current pull's `prefixSet` against the last `PullRecord`'s
8
- * `prefixSet` for the same company. Files in the journal covered by the
9
- * previous scope but NOT covered by the new scope are **orphans**.
10
- * - Classify each orphan **clean** (safe to silently delete) or **dirty**
11
- * (locally modified — sacred, never silently delete).
12
- * - The remote-pull caller drives:
13
- * * default mode: abort the leg if any dirty orphan exists;
14
- * * `--force-scope-shrink`: continue, leave dirty files on disk,
15
- * tombstone their journal entries.
16
- *
17
- * The pure-detection layer here intentionally does NOT touch disk for the
18
- * tombstone write — that lives in `journal.ts`. It DOES touch disk for the
19
- * orphan classification (hash + stat) because cleanliness is a function of
20
- * the file's current on-disk state vs the journal.
21
- *
22
- * PUSH-ONLY PREFIXES ARE NEVER PRUNED HERE (US-006). Session transcripts under
23
- * `sessions/{personUid}/...` are push-only: pushed into the vault, then
24
- * subtracted from every pull scope (incl. `all`/owner) via
25
- * `resolvePullScope().excludePrefixes`. That exclude set is applied ONLY to the
26
- * download filter (`computePullPlan`) and is DELIBERATELY NOT passed into this
27
- * module's `currentPrefixSet`. So a session file that was authored locally rides
28
- * the `direction:"up"` skip below, and one materialized on demand (`hq files
29
- * get`) rides the pin union already folded into the caller's inclusion
30
- * `prefixSet` — either way it is never pulled AND never orphaned. Feeding the
31
- * exclude set into `currentPrefixSet` would prune exactly those files; callers
32
- * must not.
33
- */
34
-
35
- import * as fs from "fs";
36
- import * as path from "path";
37
- import type {
38
- JournalEntry,
39
- PullRecord,
40
- SyncJournal,
41
- } from "./types.js";
42
- import { hashFile, tombstoneEntry } from "./journal.js";
43
- import { localPathForVaultKey } from "./local-path-codec.js";
44
- import {
45
- isCoveredByAny,
46
- type ScopePrefixInput,
47
- } from "./prefix-coalesce.js";
48
-
49
- export interface OrphanClassification {
50
- /** Relative path (journal key). */
51
- path: string;
52
- /** Journal entry as of last sync. */
53
- entry: JournalEntry;
54
- /** True iff the local file is provably unchanged since last sync. */
55
- clean: boolean;
56
- /** Why we called it dirty — surfaced in the abort error for operators. */
57
- dirtyReason?:
58
- | "modified-after-sync"
59
- | "hash-mismatch"
60
- | "stat-error";
61
- }
62
-
63
- export interface ScopeShrinkPlan {
64
- /** Set of files covered by `lastPrefixSet` but not by `currentPrefixSet`. */
65
- orphans: OrphanClassification[];
66
- /** Subset of `orphans` with `clean === true`. */
67
- clean: OrphanClassification[];
68
- /** Subset of `orphans` with `clean === false`. */
69
- dirty: OrphanClassification[];
70
- /** True iff at least one orphan was found. */
71
- scopeChangeDetected: boolean;
72
- }
73
-
74
- export interface BuildScopeShrinkPlanInput {
75
- journal: SyncJournal;
76
- hqRoot: string;
77
- /** Coalesced prefixes used by the LAST pull for this company. */
78
- lastPrefixSet: readonly ScopePrefixInput[];
79
- /** Coalesced prefixes the CURRENT pull will use. */
80
- currentPrefixSet: readonly ScopePrefixInput[];
81
- /**
82
- * The caller's own Cognito `sub`. When set, a file the caller authored
83
- * (`entry.createdBySub === callerSub`) is NEVER orphaned by a scope shrink —
84
- * regardless of mode. This is the core of the authorship contract: sync mode
85
- * governs whether you mirror *other people's* files; it must never disown
86
- * your own work. Owners hold their whole vault by role-bypass, so without
87
- * this guard a `shared`/`custom` scope would treat their own un-granted
88
- * content as "someone else's file I happen to see" and prune it.
89
- */
90
- callerSub?: string;
91
- /**
92
- * When `true`, an orphan whose authorship is unknown (`createdBySub`
93
- * undefined — a legacy entry predating author stamping, or an object
94
- * uploaded without author metadata) is also retained rather than pruned.
95
- * The automatic background pull sets this so a routine sync never makes a
96
- * destructive guess about pre-stamp content; the explicit `hq sync narrow`
97
- * ritual (which carries its own confirmation + dirty gate) leaves it off so
98
- * a deliberately-confirmed narrow can still reclaim legacy files.
99
- */
100
- protectUnknownAuthors?: boolean;
101
- }
102
-
103
- /**
104
- * Build a scope-shrink plan: find orphans, classify each clean/dirty.
105
- * Pure given the journal + filesystem state — no network, no journal
106
- * mutation.
107
- *
108
- * **Tombstone-aware:** journal entries that already carry a `removedAt`
109
- * marker are skipped — they represent a prior scope-shrink prune and must
110
- * not be re-flagged as orphans on each subsequent pull (that's the whole
111
- * point of the tombstone retention window).
112
- *
113
- * **Direction-aware:** only `direction: "down"` entries (and pre-ETag
114
- * legacy entries without an explicit direction marker) participate in
115
- * shrink detection. Push-only files (`direction: "up"`) represent local
116
- * authorship — they aren't in scope-as-pulled, so a scope change doesn't
117
- * orphan them.
118
- */
119
- export function buildScopeShrinkPlan(
120
- input: BuildScopeShrinkPlanInput,
121
- ): ScopeShrinkPlan {
122
- const { journal, hqRoot, lastPrefixSet, currentPrefixSet } = input;
123
- const { callerSub, protectUnknownAuthors } = input;
124
- const orphans: OrphanClassification[] = [];
125
-
126
- for (const [relPath, entry] of Object.entries(journal.files)) {
127
- if (entry.removedAt) continue; // tombstone — already pruned
128
- if (entry.direction !== "down") continue;
129
- if (!isCoveredByAny(relPath, lastPrefixSet)) continue;
130
- if (isCoveredByAny(relPath, currentPrefixSet)) continue;
131
- // Authorship guard: sync mode decides whether you mirror OTHER people's
132
- // files — it must never disown your own. A file the caller authored is
133
- // sacred and never orphaned, even out of the current prefix scope. When
134
- // `protectUnknownAuthors` is set (the automatic pull path), a legacy
135
- // entry with no recorded author is also retained — a routine background
136
- // sync should never make a destructive guess about pre-stamp content.
137
- if (callerSub && entry.createdBySub === callerSub) continue;
138
- if (protectUnknownAuthors && entry.createdBySub === undefined) continue;
139
- orphans.push(classifyOrphan(relPath, entry, hqRoot));
140
- }
141
-
142
- const clean = orphans.filter((o) => o.clean);
143
- const dirty = orphans.filter((o) => !o.clean);
144
- return {
145
- orphans,
146
- clean,
147
- dirty,
148
- scopeChangeDetected: orphans.length > 0,
149
- };
150
- }
151
-
152
- /**
153
- * Classify a single orphan:
154
- *
155
- * - **Clean** when:
156
- * * the local file is missing (already removed by the user — harmless), OR
157
- * * `sha256(localFile) === entry.hash` AND `stat.mtime ≤ entry.syncedAt`.
158
- * - **Dirty** otherwise.
159
- *
160
- * Symlinks: we don't re-hash with the symlink-target convention here —
161
- * the safer default is to treat any symlink whose lstat exists but whose
162
- * target hash doesn't match `entry.hash` (via `hashFile` reading the
163
- * target) as dirty. In practice symlinks materialize through the same
164
- * code path as files; a stale-target symlink is correctly flagged dirty
165
- * and the operator-facing message points at the path either way.
166
- */
167
- function classifyOrphan(
168
- relPath: string,
169
- entry: JournalEntry,
170
- hqRoot: string,
171
- ): OrphanClassification {
172
- const absPath = localPathForVaultKey(hqRoot, relPath);
173
- let stat: fs.Stats;
174
- try {
175
- stat = fs.lstatSync(absPath);
176
- } catch (err) {
177
- const code = (err as NodeJS.ErrnoException).code;
178
- if (code === "ENOENT") {
179
- return { path: relPath, entry, clean: true };
180
- }
181
- return {
182
- path: relPath,
183
- entry,
184
- clean: false,
185
- dirtyReason: "stat-error",
186
- };
187
- }
188
-
189
- // mtime guard: a local edit moves mtime past syncedAt. We use ≤ because
190
- // a download stamps syncedAt at the close of the write; mtime is set by
191
- // the OS before that, so an unmodified pulled file has mtime ≤ syncedAt.
192
- const mtimeMs = stat.mtimeMs;
193
- const syncedAtMs = Date.parse(entry.syncedAt);
194
- if (!Number.isNaN(syncedAtMs) && mtimeMs > syncedAtMs + 1000) {
195
- // 1s grace for filesystem clock jitter.
196
- return {
197
- path: relPath,
198
- entry,
199
- clean: false,
200
- dirtyReason: "modified-after-sync",
201
- };
202
- }
203
-
204
- // Hash check — final word. If the content matches the journaled hash,
205
- // the file is provably what the last pull left there.
206
- let actualHash: string;
207
- try {
208
- actualHash = hashFile(absPath);
209
- } catch {
210
- return {
211
- path: relPath,
212
- entry,
213
- clean: false,
214
- dirtyReason: "stat-error",
215
- };
216
- }
217
- if (actualHash !== entry.hash) {
218
- return {
219
- path: relPath,
220
- entry,
221
- clean: false,
222
- dirtyReason: "hash-mismatch",
223
- };
224
- }
225
- return { path: relPath, entry, clean: true };
226
- }
227
-
228
- /**
229
- * Where a scope-shrink error is going to be rendered, so the structured error
230
- * can carry advice that is ACTUALLY FOLLOWABLE from that entry point.
231
- *
232
- * - `"cli"` — a human at a terminal running `hq sync pull|now`. They can
233
- * re-run with `--force-scope-shrink` or run the guided
234
- * `hq sync narrow --apply` ritual.
235
- * - `"runner"` — the menubar's `hq-sync-runner`. It accepts NO such flag
236
- * (DEV-1768 fix #2: the old "pass --force-scope-shrink" advice
237
- * was impossible to follow from here), so the only followable
238
- * action is to open a terminal and run `hq sync narrow --apply`.
239
- * In practice the runner pulls with `scopeShrinkPolicy:
240
- * "auto-recover"` and never throws this — but the context keeps
241
- * the message honest if it ever surfaces.
242
- * - `"engine"` — unknown/library caller; generic advice.
243
- */
244
- export type ScopeShrinkAdviceContext = "cli" | "runner" | "engine";
245
-
246
- /** Followable next-step advice for a blocked scope shrink, per entry point. */
247
- function scopeShrinkAdvice(ctx: ScopeShrinkAdviceContext): string {
248
- switch (ctx) {
249
- case "cli":
250
- return (
251
- "Re-run with `--force-scope-shrink` to disown them now (dirty files " +
252
- "are KEPT on disk, only un-tracked from sync), or run " +
253
- "`hq sync narrow --apply` to migrate with a confirmation prompt."
254
- );
255
- case "runner":
256
- return (
257
- "The menubar sync cannot take this flag — open a terminal and run " +
258
- "`hq sync narrow --apply` to migrate this membership (you confirm the " +
259
- "file list), or `hq sync now --force-scope-shrink` once to proceed " +
260
- "(dirty files are KEPT on disk, only un-tracked)."
261
- );
262
- default:
263
- return (
264
- "Run `hq sync narrow --apply` to migrate with confirmation, or pass " +
265
- "`forceScopeShrink` (dirty files are kept on disk, only un-tracked)."
266
- );
267
- }
268
- }
269
-
270
- /**
271
- * Structured error thrown when the engine refuses to proceed because a scope
272
- * shrink would orphan dirty files. The CLI catches this and renders the
273
- * operator-facing message; the engine never prints directly.
274
- *
275
- * `adviceContext` makes the message FOLLOWABLE from each entry point — the old
276
- * fixed "pass --force-scope-shrink" line was impossible to act on from the
277
- * menubar runner, which rejects that flag (DEV-1768 fix #2).
278
- */
279
- export class ScopeShrinkBlockedError extends Error {
280
- readonly code = "SCOPE_SHRINK_BLOCKED";
281
- constructor(
282
- public readonly companyUid: string,
283
- public readonly fromMode: PullRecord["syncMode"] | "unknown",
284
- public readonly toMode: PullRecord["syncMode"],
285
- public readonly dirty: OrphanClassification[],
286
- public readonly clean: OrphanClassification[],
287
- public readonly adviceContext: ScopeShrinkAdviceContext = "engine",
288
- ) {
289
- super(
290
- `Sync scope shrank for ${companyUid} (${fromMode} → ${toMode}); ` +
291
- `${dirty.length} locally-modified file(s) outside the new scope ` +
292
- `would be un-tracked from sync. ${scopeShrinkAdvice(adviceContext)}`,
293
- );
294
- this.name = "ScopeShrinkBlockedError";
295
- }
296
- }
297
-
298
- /**
299
- * Structured error thrown when an AUTOMATIC scope shrink would prune more
300
- * CLEAN local files than the configured safety cap in a single pull. This is
301
- * the bulk-delete guard: a routine background sync should never silently
302
- * delete a large local tree — whether from a deliberate-but-abrupt first
303
- * narrow, a server bug returning an unexpectedly small grant set, or a
304
- * mis-resolved scope. The operator runs the explicit `hq sync narrow` ritual
305
- * (which has its own confirmation + dirty gate) or passes `--force-scope-shrink`
306
- * to proceed. The engine never deletes anything when it throws this.
307
- */
308
- export class ScopeShrinkLargePruneError extends Error {
309
- readonly code = "SCOPE_SHRINK_LARGE_PRUNE";
310
- constructor(
311
- public readonly companyUid: string,
312
- public readonly toMode: PullRecord["syncMode"],
313
- public readonly cleanCount: number,
314
- public readonly cap: number,
315
- public readonly adviceContext: ScopeShrinkAdviceContext = "engine",
316
- ) {
317
- super(
318
- `Refusing to auto-move ${cleanCount} local file(s) for ${companyUid} ` +
319
- `(${toMode} scope) in one sync — exceeds the safety cap of ${cap}. ` +
320
- `Raise HQ_SYNC_MAX_AUTO_PRUNE, or ${scopeShrinkAdvice(adviceContext)}`,
321
- );
322
- this.name = "ScopeShrinkLargePruneError";
323
- }
324
- }
325
-
326
- /**
327
- * Disposition for CLEAN orphans (files provably unchanged since the last sync)
328
- * that fall outside the new scope:
329
- *
330
- * - `"delete"` — `unlink` the local file. The legacy behavior; reserved
331
- * for the explicit `hq sync narrow --apply` ritual, which
332
- * already confirms the file list with the operator.
333
- * - `"quarantine"` — MOVE the file into `quarantineRoot` instead of deleting
334
- * it, so it stays recoverable. The conservative default
335
- * for the automatic pull path: a background sync must
336
- * never silently PURGE local files (DEV-1768 fix #3).
337
- */
338
- export type CleanOrphanDisposition = "delete" | "quarantine";
339
-
340
- export interface ApplyScopeShrinkInput {
341
- journal: SyncJournal;
342
- plan: ScopeShrinkPlan;
343
- hqRoot: string;
344
- /**
345
- * When `true`, dirty files are LEFT ON DISK and their journal entries are
346
- * tombstoned anyway. When `false` (default), the caller should have
347
- * already aborted on dirty orphans — this function still tombstones any
348
- * dirty entries handed to it, on the assumption the caller knows what
349
- * it's doing.
350
- */
351
- forceScopeShrink: boolean;
352
- reason?: "scope_shrink" | "narrow_apply" | "manual";
353
- /**
354
- * How to dispose of CLEAN orphans. Defaults to `"delete"` so existing
355
- * callers (and the confirmed `narrow --apply` ritual) keep their behavior;
356
- * the automatic pull path passes `"quarantine"`.
357
- */
358
- cleanDisposition?: CleanOrphanDisposition;
359
- /**
360
- * Absolute directory clean orphans are relocated into when
361
- * `cleanDisposition === "quarantine"`. Each orphan moves to
362
- * `<quarantineRoot>/<orphan.path>` (parent dirs created). REQUIRED when
363
- * quarantining; if absent, the function falls back to `"delete"` so it can
364
- * never get stuck unable to make progress.
365
- */
366
- quarantineRoot?: string;
367
- }
368
-
369
- export interface ApplyScopeShrinkResult {
370
- /** Clean orphans `unlink`ed from disk (only when disposition is `delete`). */
371
- cleanRemoved: number;
372
- /** Clean orphans MOVED to quarantine (only when disposition is `quarantine`). */
373
- cleanQuarantined: number;
374
- /** Dirty orphans tombstoned in the journal (file LEFT on disk). */
375
- dirtyTombstoned: number;
376
- /** Named paths deleted — for explicit, non-silent operator reporting. */
377
- removedPaths: string[];
378
- /** Named paths moved to quarantine — for explicit reporting. */
379
- quarantinedPaths: string[];
380
- /** Named dirty paths un-tracked but KEPT on disk — for explicit reporting. */
381
- dirtyKeptPaths: string[];
382
- /** Absolute quarantine directory used (when anything was quarantined). */
383
- quarantineRoot?: string;
384
- }
385
-
386
- /**
387
- * Move a clean orphan from the working tree into the quarantine tree,
388
- * preserving its relative path. Same-device `rename` first (cheap, atomic);
389
- * cross-device falls back to copy+unlink. A missing source is a no-op (the
390
- * user already removed it — harmless). Returns true iff the file was relocated
391
- * (or was already absent), false only on an unexpected error the caller should
392
- * surface.
393
- */
394
- function quarantineOrphan(
395
- srcAbs: string,
396
- destAbs: string,
397
- ): void {
398
- fs.mkdirSync(path.dirname(destAbs), { recursive: true });
399
- try {
400
- fs.renameSync(srcAbs, destAbs);
401
- } catch (err) {
402
- const code = (err as NodeJS.ErrnoException).code;
403
- if (code === "ENOENT") return; // source already gone — nothing to move
404
- if (code === "EXDEV") {
405
- // Cross-device move: copy then unlink. cpSync handles files + symlinks.
406
- fs.cpSync(srcAbs, destAbs, { recursive: true, verbatimSymlinks: true });
407
- fs.rmSync(srcAbs, { recursive: true, force: true });
408
- return;
409
- }
410
- throw err;
411
- }
412
- }
413
-
414
- /**
415
- * Apply a scope-shrink plan: dispose of clean orphans (delete OR quarantine)
416
- * + tombstone their journal entries. With `forceScopeShrink: true`, dirty
417
- * orphans are PRESERVED on disk and only their journal entries are tombstoned
418
- * (so they stop being re-flagged on every pull — the idempotent recovery seam).
419
- *
420
- * Returns counts AND named paths so the caller can report exactly what moved /
421
- * was un-tracked — never a silent purge (DEV-1768 fix #3).
422
- */
423
- export function applyScopeShrink(
424
- input: ApplyScopeShrinkInput,
425
- ): ApplyScopeShrinkResult {
426
- const { journal, plan, hqRoot, forceScopeShrink } = input;
427
- const reason = input.reason ?? "scope_shrink";
428
- // Quarantine only when explicitly asked AND a destination is provided;
429
- // otherwise fall back to delete so we always make progress.
430
- const quarantining =
431
- input.cleanDisposition === "quarantine" && !!input.quarantineRoot;
432
- let cleanRemoved = 0;
433
- let cleanQuarantined = 0;
434
- let dirtyTombstoned = 0;
435
- const removedPaths: string[] = [];
436
- const quarantinedPaths: string[] = [];
437
- const dirtyKeptPaths: string[] = [];
438
-
439
- for (const orphan of plan.clean) {
440
- const absPath = localPathForVaultKey(hqRoot, orphan.path);
441
- if (quarantining) {
442
- const destAbs = localPathForVaultKey(input.quarantineRoot!, orphan.path);
443
- quarantineOrphan(absPath, destAbs);
444
- tombstoneEntry(journal, orphan.path, reason);
445
- cleanQuarantined++;
446
- quarantinedPaths.push(orphan.path);
447
- } else {
448
- try {
449
- fs.unlinkSync(absPath);
450
- } catch (err) {
451
- const code = (err as NodeJS.ErrnoException).code;
452
- if (code !== "ENOENT") throw err; // missing-on-disk is fine; anything else escalates
453
- }
454
- tombstoneEntry(journal, orphan.path, reason);
455
- cleanRemoved++;
456
- removedPaths.push(orphan.path);
457
- }
458
- }
459
-
460
- if (forceScopeShrink) {
461
- for (const orphan of plan.dirty) {
462
- // Do NOT delete the file — keep dirty content on disk, prune only the
463
- // journal entry so it stops being re-flagged as an orphan on each pull.
464
- tombstoneEntry(journal, orphan.path, reason);
465
- dirtyTombstoned++;
466
- dirtyKeptPaths.push(orphan.path);
467
- }
468
- }
469
-
470
- return {
471
- cleanRemoved,
472
- cleanQuarantined,
473
- dirtyTombstoned,
474
- removedPaths,
475
- quarantinedPaths,
476
- dirtyKeptPaths,
477
- ...(quarantining && quarantinedPaths.length > 0
478
- ? { quarantineRoot: input.quarantineRoot }
479
- : {}),
480
- };
481
- }