@indigoai-us/hq-cloud 6.14.17 → 6.14.19

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 (124) hide show
  1. package/.github/workflows/ci.yml +35 -0
  2. package/dist/active-company.d.ts +43 -0
  3. package/dist/active-company.d.ts.map +1 -0
  4. package/dist/active-company.js +132 -0
  5. package/dist/active-company.js.map +1 -0
  6. package/dist/active-company.test.d.ts +2 -0
  7. package/dist/active-company.test.d.ts.map +1 -0
  8. package/dist/active-company.test.js +149 -0
  9. package/dist/active-company.test.js.map +1 -0
  10. package/dist/bin/sync-runner-company.d.ts +7 -0
  11. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  12. package/dist/bin/sync-runner-company.js +21 -1
  13. package/dist/bin/sync-runner-company.js.map +1 -1
  14. package/dist/bin/sync-runner-planning.d.ts +27 -1
  15. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  16. package/dist/bin/sync-runner-planning.js +33 -4
  17. package/dist/bin/sync-runner-planning.js.map +1 -1
  18. package/dist/bin/sync-runner-planning.test.d.ts +2 -0
  19. package/dist/bin/sync-runner-planning.test.d.ts.map +1 -0
  20. package/dist/bin/sync-runner-planning.test.js +115 -0
  21. package/dist/bin/sync-runner-planning.test.js.map +1 -0
  22. package/dist/bin/sync-runner-watch-loop.d.ts +10 -2
  23. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  24. package/dist/bin/sync-runner-watch-loop.js +201 -25
  25. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  26. package/dist/bin/sync-runner-watch-routes.d.ts +1 -1
  27. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
  28. package/dist/bin/sync-runner-watch-routes.js +14 -2
  29. package/dist/bin/sync-runner-watch-routes.js.map +1 -1
  30. package/dist/bin/sync-runner.d.ts +39 -9
  31. package/dist/bin/sync-runner.d.ts.map +1 -1
  32. package/dist/bin/sync-runner.js +114 -20
  33. package/dist/bin/sync-runner.js.map +1 -1
  34. package/dist/bin/sync-runner.test.js +852 -8
  35. package/dist/bin/sync-runner.test.js.map +1 -1
  36. package/dist/cli/rescue-core.js +27 -1
  37. package/dist/cli/rescue-core.js.map +1 -1
  38. package/dist/cli/rescue-drop-dir-symlink.test.d.ts +2 -0
  39. package/dist/cli/rescue-drop-dir-symlink.test.d.ts.map +1 -0
  40. package/dist/cli/rescue-drop-dir-symlink.test.js +206 -0
  41. package/dist/cli/rescue-drop-dir-symlink.test.js.map +1 -0
  42. package/dist/cli/share.d.ts +15 -0
  43. package/dist/cli/share.d.ts.map +1 -1
  44. package/dist/cli/share.js +100 -55
  45. package/dist/cli/share.js.map +1 -1
  46. package/dist/cli/share.test.js +119 -6
  47. package/dist/cli/share.test.js.map +1 -1
  48. package/dist/cli/sync.d.ts.map +1 -1
  49. package/dist/cli/sync.js +19 -25
  50. package/dist/cli/sync.js.map +1 -1
  51. package/dist/cli/sync.test.js +121 -0
  52. package/dist/cli/sync.test.js.map +1 -1
  53. package/dist/journal.d.ts +3 -1
  54. package/dist/journal.d.ts.map +1 -1
  55. package/dist/journal.js +13 -2
  56. package/dist/journal.js.map +1 -1
  57. package/dist/journal.test.js +26 -1
  58. package/dist/journal.test.js.map +1 -1
  59. package/dist/manifest-reconcile.d.ts +118 -5
  60. package/dist/manifest-reconcile.d.ts.map +1 -1
  61. package/dist/manifest-reconcile.js +319 -62
  62. package/dist/manifest-reconcile.js.map +1 -1
  63. package/dist/manifest-reconcile.test.js +824 -2
  64. package/dist/manifest-reconcile.test.js.map +1 -1
  65. package/dist/personal-vault.d.ts +12 -0
  66. package/dist/personal-vault.d.ts.map +1 -1
  67. package/dist/personal-vault.js +20 -0
  68. package/dist/personal-vault.js.map +1 -1
  69. package/dist/qmd-reindex.d.ts +88 -11
  70. package/dist/qmd-reindex.d.ts.map +1 -1
  71. package/dist/qmd-reindex.js +354 -65
  72. package/dist/qmd-reindex.js.map +1 -1
  73. package/dist/qmd-reindex.test.d.ts +4 -0
  74. package/dist/qmd-reindex.test.d.ts.map +1 -1
  75. package/dist/qmd-reindex.test.js +285 -3
  76. package/dist/qmd-reindex.test.js.map +1 -1
  77. package/dist/s3.d.ts +13 -0
  78. package/dist/s3.d.ts.map +1 -1
  79. package/dist/s3.js +34 -1
  80. package/dist/s3.js.map +1 -1
  81. package/dist/s3.test.js +60 -1
  82. package/dist/s3.test.js.map +1 -1
  83. package/dist/telemetry.d.ts +22 -0
  84. package/dist/telemetry.d.ts.map +1 -1
  85. package/dist/telemetry.js +79 -0
  86. package/dist/telemetry.js.map +1 -1
  87. package/dist/telemetry.test.js +117 -1
  88. package/dist/telemetry.test.js.map +1 -1
  89. package/dist/watcher.d.ts +26 -1
  90. package/dist/watcher.d.ts.map +1 -1
  91. package/dist/watcher.js +75 -12
  92. package/dist/watcher.js.map +1 -1
  93. package/package.json +1 -1
  94. package/pnpm-workspace.yaml +1 -1
  95. package/src/active-company.test.ts +188 -0
  96. package/src/active-company.ts +168 -0
  97. package/src/bin/sync-runner-company.ts +29 -1
  98. package/src/bin/sync-runner-planning.test.ts +131 -0
  99. package/src/bin/sync-runner-planning.ts +60 -7
  100. package/src/bin/sync-runner-watch-loop.ts +322 -32
  101. package/src/bin/sync-runner-watch-routes.ts +14 -1
  102. package/src/bin/sync-runner.test.ts +1021 -16
  103. package/src/bin/sync-runner.ts +179 -28
  104. package/src/cli/rescue-core.ts +26 -1
  105. package/src/cli/rescue-drop-dir-symlink.test.ts +224 -0
  106. package/src/cli/share.test.ts +150 -6
  107. package/src/cli/share.ts +143 -48
  108. package/src/cli/sync.test.ts +150 -1
  109. package/src/cli/sync.ts +25 -29
  110. package/src/journal.test.ts +45 -0
  111. package/src/journal.ts +18 -1
  112. package/src/manifest-reconcile.test.ts +1019 -3
  113. package/src/manifest-reconcile.ts +424 -66
  114. package/src/personal-vault.ts +23 -0
  115. package/src/qmd-reindex.test.ts +324 -4
  116. package/src/qmd-reindex.ts +414 -76
  117. package/src/s3.test.ts +79 -0
  118. package/src/s3.ts +48 -1
  119. package/src/telemetry.test.ts +128 -0
  120. package/src/telemetry.ts +80 -0
  121. package/src/watcher.ts +111 -14
  122. package/test/e2e/watcher-real-chokidar.test.ts +62 -2
  123. package/test/e2e/watcher-recursive-backend.test.ts +67 -1
  124. package/test/joiner-manifest-reconcile.integration.test.ts +283 -0
@@ -2008,6 +2008,11 @@ describe("share", () => {
2008
2008
  });
2009
2009
 
2010
2010
  expect(result.filesDeleted).toBe(1);
2011
+ expect(result.pathResults).toContainEqual({
2012
+ path: "knowledge",
2013
+ status: "accepted",
2014
+ operation: "delete",
2015
+ });
2011
2016
  expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "knowledge");
2012
2017
  const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2013
2018
  expect(journal.files["knowledge"]).toBeUndefined();
@@ -2198,6 +2203,44 @@ describe("share", () => {
2198
2203
  );
2199
2204
  });
2200
2205
 
2206
+ it("personalMode excludes generated core mirror symlinks but keeps canonical core files", async () => {
2207
+ const packagePolicy = path.join(
2208
+ tmpDir,
2209
+ "core",
2210
+ "packages",
2211
+ "hq-pack-example",
2212
+ "policies",
2213
+ "generated.md",
2214
+ );
2215
+ const generatedMirror = path.join(tmpDir, "core", "policies", "generated.md");
2216
+ const canonicalCoreFile = path.join(tmpDir, "core", "policies", "shipped.md");
2217
+ fs.mkdirSync(path.dirname(packagePolicy), { recursive: true });
2218
+ fs.mkdirSync(path.dirname(generatedMirror), { recursive: true });
2219
+ fs.writeFileSync(packagePolicy, "generated package policy\n");
2220
+ fs.symlinkSync(packagePolicy, generatedMirror);
2221
+ fs.writeFileSync(canonicalCoreFile, "shipped core policy\n");
2222
+
2223
+ const result = await share({
2224
+ paths: [path.join(tmpDir, "core")],
2225
+ entityContext: makeEntityContext({
2226
+ uid: "prs_01PERSONALTEST",
2227
+ slug: "__hq_personal_vault__",
2228
+ bucketName: "hq-vault-prs-01PERSONALTEST",
2229
+ }),
2230
+ hqRoot: tmpDir,
2231
+ personalMode: true,
2232
+ journalSlug: "personal-generated-core-mirror",
2233
+ });
2234
+
2235
+ expect(result.filesExcludedByPolicy).toBe(1);
2236
+ expect(result.filesUploaded).toBe(2);
2237
+ expect(vi.mocked(uploadSymlink)).not.toHaveBeenCalled();
2238
+ expect(vi.mocked(uploadFile).mock.calls.map((call) => call[2]).sort()).toEqual([
2239
+ "core/packages/hq-pack-example/policies/generated.md",
2240
+ "core/policies/shipped.md",
2241
+ ]);
2242
+ });
2243
+
2201
2244
  it(
2202
2245
  "personal-vault override does NOT apply to company vaults: `owned-only` on cmp_ entity still " +
2203
2246
  "refuses to tombstone a direction:'down' entry (multi-user curation intact)",
@@ -3033,7 +3076,7 @@ describe("share", () => {
3033
3076
  });
3034
3077
  });
3035
3078
 
3036
- it("currency-gated: refuses delete for legacy journal entry with no recorded remoteEtag", async () => {
3079
+ it("daemon-stopped deletion has no watcher intent and is safely refused", async () => {
3037
3080
  const companyRoot = path.join(tmpDir, "companies", "acme");
3038
3081
  fs.mkdirSync(companyRoot, { recursive: true });
3039
3082
  // Journal entry from before remoteEtag tracking — no etag to compare
@@ -3078,6 +3121,12 @@ describe("share", () => {
3078
3121
  reason: "missing-delete-intent",
3079
3122
  });
3080
3123
  expect(result.filesRefusedStale).toBe(1);
3124
+ expect(result.pathResults).toContainEqual({
3125
+ path: "legacy-no-etag.md",
3126
+ status: "refused",
3127
+ operation: "delete",
3128
+ reason: "missing-delete-intent",
3129
+ });
3081
3130
  });
3082
3131
 
3083
3132
  it("currency-gated: real-world /update-hq scenario — direction:'down' delete propagates when current", async () => {
@@ -3351,6 +3400,58 @@ describe("share", () => {
3351
3400
  // passing in the allowed top-level directories — share() trusts its
3352
3401
  // `paths` input.
3353
3402
 
3403
+ it("plans an intent-backed personal delete from an explicit missing scope root", async () => {
3404
+ const key = "deleted-top-level.md";
3405
+ const journalSlug = "personal-scoped-delete";
3406
+ const journalPath = path.join(
3407
+ stateDir,
3408
+ `sync-journal.${journalSlug}.json`,
3409
+ );
3410
+ fs.writeFileSync(
3411
+ journalPath,
3412
+ JSON.stringify({
3413
+ version: "2",
3414
+ lastSync: new Date().toISOString(),
3415
+ pulls: [],
3416
+ files: {
3417
+ [key]: {
3418
+ hash: "captured-hash",
3419
+ size: 8,
3420
+ syncedAt: new Date().toISOString(),
3421
+ direction: "up",
3422
+ remoteEtag: "captured-etag",
3423
+ kind: "file",
3424
+ localDeleteIntent: deleteIntent(
3425
+ "captured-etag",
3426
+ "captured-hash",
3427
+ ),
3428
+ },
3429
+ },
3430
+ }),
3431
+ );
3432
+
3433
+ const result = await share({
3434
+ // computePersonalVaultPaths() has no path to contribute after this
3435
+ // top-level file is gone. The watcher-captured root is the sole scope.
3436
+ paths: [],
3437
+ deleteScopeRoots: [key],
3438
+ company: "acme",
3439
+ vaultConfig: mockConfig,
3440
+ hqRoot: tmpDir,
3441
+ personalMode: true,
3442
+ journalSlug,
3443
+ skipUnchanged: true,
3444
+ propagateDeletes: true,
3445
+ propagateDeletePolicy: "owned-only",
3446
+ });
3447
+
3448
+ expect(result.filesDeleted).toBe(1);
3449
+ expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), key);
3450
+ expect(
3451
+ JSON.parse(fs.readFileSync(journalPath, "utf8")).files[key],
3452
+ ).toBeUndefined();
3453
+ });
3454
+
3354
3455
  // ── Decommission prefixes ──────────────────────────────────────────────────
3355
3456
  //
3356
3457
  // `decommissionPrefixes` is the "company was promoted to its own team
@@ -4059,10 +4160,12 @@ describe("share", () => {
4059
4160
  total,
4060
4161
  missing,
4061
4162
  direction = "up" as "up" | "down",
4163
+ withIntents = true,
4062
4164
  }: {
4063
4165
  total: number;
4064
4166
  missing: number;
4065
4167
  direction?: "up" | "down";
4168
+ withIntents?: boolean;
4066
4169
  }): { companyRoot: string; journalPath: string } {
4067
4170
  const companyRoot = path.join(tmpDir, "companies", "acme");
4068
4171
  fs.mkdirSync(companyRoot, { recursive: true });
@@ -4075,7 +4178,7 @@ describe("share", () => {
4075
4178
  direction: "up" | "down";
4076
4179
  remoteEtag: string;
4077
4180
  kind: "file";
4078
- localDeleteIntent: ReturnType<typeof deleteIntent>;
4181
+ localDeleteIntent?: ReturnType<typeof deleteIntent>;
4079
4182
  }
4080
4183
  > = {};
4081
4184
  for (let i = 0; i < total; i++) {
@@ -4087,7 +4190,9 @@ describe("share", () => {
4087
4190
  direction,
4088
4191
  remoteEtag: `etag-${i}`,
4089
4192
  kind: "file",
4090
- localDeleteIntent: deleteIntent(`etag-${i}`, "h"),
4193
+ ...(withIntents
4194
+ ? { localDeleteIntent: deleteIntent(`etag-${i}`, "h") }
4195
+ : {}),
4091
4196
  };
4092
4197
  if (i >= missing) {
4093
4198
  fs.writeFileSync(path.join(companyRoot, key), "hi");
@@ -4110,7 +4215,11 @@ describe("share", () => {
4110
4215
  });
4111
4216
 
4112
4217
  it("trips at the 11/100 boundary (11% > 10%, abs >= 10): refuses every delete and emits bulk-asymmetry event", async () => {
4113
- const { companyRoot, journalPath } = setupJournal({ total: 100, missing: 11 });
4218
+ const { companyRoot, journalPath } = setupJournal({
4219
+ total: 100,
4220
+ missing: 11,
4221
+ withIntents: false,
4222
+ });
4114
4223
  // currency-gated: remote HEAD would match every journal etag — guard
4115
4224
  // must trip BEFORE any HEAD round-trip occurs.
4116
4225
  vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => ({
@@ -4166,6 +4275,33 @@ describe("share", () => {
4166
4275
  expect(perKeyRefusals.length).toBe(11);
4167
4276
  });
4168
4277
 
4278
+ it("intent-backed 11/100 subtree disappearance bypasses the breaker and deletes every covered path", async () => {
4279
+ const { companyRoot } = setupJournal({ total: 100, missing: 11 });
4280
+ vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => ({
4281
+ etag: `etag-${parseInt(key.replace(/[^0-9]/g, ""), 10)}`,
4282
+ lastModified: new Date(),
4283
+ size: 5,
4284
+ }));
4285
+ const events: Array<{ type: string }> = [];
4286
+
4287
+ const result = await share({
4288
+ paths: [companyRoot],
4289
+ company: "acme",
4290
+ vaultConfig: mockConfig,
4291
+ hqRoot: tmpDir,
4292
+ skipUnchanged: true,
4293
+ propagateDeletes: true,
4294
+ propagateDeletePolicy: "currency-gated",
4295
+ onEvent: (e) => events.push(e as { type: string }),
4296
+ });
4297
+
4298
+ expect(result.filesDeleted).toBe(11);
4299
+ expect(deleteRemoteFile).toHaveBeenCalledTimes(11);
4300
+ expect(
4301
+ events.find((e) => e.type === "delete-refused-bulk-asymmetry"),
4302
+ ).toBeUndefined();
4303
+ });
4304
+
4169
4305
  it("does NOT trip at 9/100 (below ratio): all candidates delete normally", async () => {
4170
4306
  const { companyRoot } = setupJournal({ total: 100, missing: 9 });
4171
4307
  vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => ({
@@ -4232,7 +4368,11 @@ describe("share", () => {
4232
4368
  });
4233
4369
 
4234
4370
  it("fully-empty mirror (100/100) trips the guard — the canonical mass-delete failure mode", async () => {
4235
- const { companyRoot, journalPath } = setupJournal({ total: 50, missing: 50 });
4371
+ const { companyRoot, journalPath } = setupJournal({
4372
+ total: 50,
4373
+ missing: 50,
4374
+ withIntents: false,
4375
+ });
4236
4376
  vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => ({
4237
4377
  etag: `etag-${parseInt(key.replace(/[^0-9]/g, ""), 10)}`,
4238
4378
  lastModified: new Date(),
@@ -4288,7 +4428,11 @@ describe("share", () => {
4288
4428
  });
4289
4429
 
4290
4430
  it("owned-only policy also goes through the guard: 11/100 missing direction:'up' entries refused", async () => {
4291
- const { companyRoot, journalPath } = setupJournal({ total: 100, missing: 11 });
4431
+ const { companyRoot, journalPath } = setupJournal({
4432
+ total: 100,
4433
+ missing: 11,
4434
+ withIntents: false,
4435
+ });
4292
4436
 
4293
4437
  const events: Array<{ type: string }> = [];
4294
4438
  const result = await share({
package/src/cli/share.ts CHANGED
@@ -41,7 +41,10 @@ import {
41
41
  wrapFilterWithPersonalVaultDefaults,
42
42
  type PersonalVaultExclusion,
43
43
  } from "../personal-vault-exclusions.js";
44
- import { isPersonalVaultDeletionExcluded } from "../personal-vault.js";
44
+ import {
45
+ isGeneratedCoreMirrorKey,
46
+ isPersonalVaultDeletionExcluded,
47
+ } from "../personal-vault.js";
45
48
  import { resolveConflict } from "./conflict.js";
46
49
  import type { ConflictStrategy } from "./conflict.js";
47
50
  import type { SyncProgressEvent } from "./sync.js";
@@ -590,6 +593,12 @@ export interface ShareOptions {
590
593
  * next push to erase it.
591
594
  */
592
595
  propagateDeletePolicy?: "currency-gated" | "owned-only" | "all";
596
+ /**
597
+ * Explicit vault-relative delete roots captured by a live watcher before a
598
+ * scoped push. These roots do not need to exist locally; delete execution
599
+ * still requires a current version-bound intent (or server tombstone).
600
+ */
601
+ deleteScopeRoots?: string[];
593
602
  /**
594
603
  * Hq-root-relative key prefixes whose journal entries should be
595
604
  * unconditionally decommissioned from the remote bucket and journal,
@@ -753,9 +762,27 @@ export interface ShareResult {
753
762
  * way to runner/UI consumers.
754
763
  */
755
764
  conflictPaths: string[];
765
+ /** Uncapped per-path delete outcomes used by scoped watcher publication. */
766
+ pathResults?: SharePathResult[];
756
767
  aborted: boolean;
757
768
  }
758
769
 
770
+ export type ShareDeleteRefusalReason =
771
+ | "stale-etag"
772
+ | "legacy-no-etag"
773
+ | "bulk-asymmetry"
774
+ | "divergent-local"
775
+ | "missing-delete-intent"
776
+ | "intent-changed"
777
+ | "transfer-error";
778
+
779
+ export interface SharePathResult {
780
+ path: string;
781
+ status: "accepted" | "refused";
782
+ operation: "delete" | "tombstone";
783
+ reason?: ShareDeleteRefusalReason;
784
+ }
785
+
759
786
  /**
760
787
  * A conditional-write fence rejection — the SDK's 412 (`name:
761
788
  * "PreconditionFailed"`) or the presigned transport's mirror of it. Means
@@ -838,6 +865,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
838
865
  const counters = createShareCounters();
839
866
  const filesRefusedStalePaths: string[] = [];
840
867
  const conflictPaths: string[] = [];
868
+ const pathResults: SharePathResult[] = [];
841
869
 
842
870
  const plans = await buildSharePlans(run);
843
871
  const uploadResult = await executeUploads(
@@ -852,6 +880,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
852
880
  counters,
853
881
  filesRefusedStalePaths,
854
882
  uploadResult.abortFlightConflictPaths,
883
+ pathResults,
855
884
  true,
856
885
  );
857
886
  }
@@ -862,11 +891,19 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
862
891
  plans.decommissionPlan,
863
892
  counters,
864
893
  filesRefusedStalePaths,
894
+ pathResults,
865
895
  );
866
896
  finalizeShareJournal(run);
867
897
  throwUploadWorkerErrors(uploadResult.workerErrors);
868
898
 
869
- return buildShareResult(run, counters, filesRefusedStalePaths, conflictPaths, false);
899
+ return buildShareResult(
900
+ run,
901
+ counters,
902
+ filesRefusedStalePaths,
903
+ conflictPaths,
904
+ pathResults,
905
+ false,
906
+ );
870
907
  }
871
908
 
872
909
  type DeletePolicy = NonNullable<ShareOptions["propagateDeletePolicy"]>;
@@ -1070,7 +1107,27 @@ async function buildSharePlans(run: PushRunContext): Promise<SharePlans> {
1070
1107
  // `companies/{slug}/…` keys, so this only applies in company mode.
1071
1108
  const filesToShare =
1072
1109
  run.options.personalMode === true
1073
- ? collected
1110
+ ? collected.filter((entry) => {
1111
+ // `core/<type>` symlinks are generated projections of canonical
1112
+ // personal/package content. Reindex or package wiring owns their
1113
+ // lifecycle, so uploading them creates remote objects that a later
1114
+ // pull can only materialize temporarily before reindex removes them.
1115
+ // Keep ordinary core files eligible: only a symlink is derived.
1116
+ const generatedCoreMirror =
1117
+ entry.kind === "symlink" && isGeneratedCoreMirrorKey(entry.relativePath)
1118
+ if (!generatedCoreMirror) return true;
1119
+
1120
+ // This is a type-aware companion to the path-only personal-vault
1121
+ // default filters. `collectFiles` is the first seam that knows the
1122
+ // entry is a symlink, so record it here for the same summary/result
1123
+ // accounting used by the static exclusions.
1124
+ if (!run.excludedSet.has(entry.relativePath)) {
1125
+ run.excludedSet.add(entry.relativePath);
1126
+ run.excludedById["generated-core-mirror"] =
1127
+ (run.excludedById["generated-core-mirror"] ?? 0) + 1;
1128
+ }
1129
+ return false;
1130
+ })
1074
1131
  : collected.filter((entry) => {
1075
1132
  if (!entry.relativePath.startsWith("companies/")) return true;
1076
1133
  run.emit({
@@ -1085,7 +1142,12 @@ async function buildSharePlans(run: PushRunContext): Promise<SharePlans> {
1085
1142
  run.skipUnchanged === true,
1086
1143
  );
1087
1144
  const deleteScopeRoots = run.propagateDeletes === true
1088
- ? resolveDeleteScopeRoots(run.paths, run.hqRoot, run.syncRoot)
1145
+ ? resolveDeleteScopeRoots(
1146
+ run.paths,
1147
+ run.hqRoot,
1148
+ run.syncRoot,
1149
+ run.options.deleteScopeRoots,
1150
+ )
1089
1151
  : [];
1090
1152
  const deletePlan: DeletePlan = run.propagateDeletes === true
1091
1153
  ? await computeDeletePlan(
@@ -1522,6 +1584,7 @@ async function executeDeletes(
1522
1584
  decommissionPlan: string[],
1523
1585
  counters: ShareCounters,
1524
1586
  filesRefusedStalePaths: string[],
1587
+ pathResults: SharePathResult[],
1525
1588
  ): Promise<void> {
1526
1589
  const deleteItems = [
1527
1590
  ...deletePlan.toDelete.map((item) => ({ ...item, decommission: false })),
@@ -1543,6 +1606,9 @@ async function executeDeletes(
1543
1606
  entry!.localDeleteIntent!.version !== item.intentVersion ||
1544
1607
  !isLocallyAbsent(path.join(run.syncRoot, relativePath)))
1545
1608
  ) {
1609
+ if (entry?.localDeleteIntent) {
1610
+ delete entry.localDeleteIntent;
1611
+ }
1546
1612
  counters.filesRefusedStale++;
1547
1613
  if (filesRefusedStalePaths.length < REFUSED_STALE_PATH_CAP) {
1548
1614
  filesRefusedStalePaths.push(relativePath);
@@ -1554,6 +1620,12 @@ async function executeDeletes(
1554
1620
  remoteEtag: "<not-checked>",
1555
1621
  reason: "intent-changed",
1556
1622
  });
1623
+ pathResults.push({
1624
+ path: relativePath,
1625
+ status: "refused",
1626
+ operation: "delete",
1627
+ reason: "intent-changed",
1628
+ });
1557
1629
  continue;
1558
1630
  }
1559
1631
  const size = entry?.size ?? 0;
@@ -1566,6 +1638,11 @@ async function executeDeletes(
1566
1638
  bytes: size,
1567
1639
  deleted: true,
1568
1640
  });
1641
+ pathResults.push({
1642
+ path: relativePath,
1643
+ status: "accepted",
1644
+ operation: "delete",
1645
+ });
1569
1646
  } catch (err) {
1570
1647
  if (err instanceof VaultAuthError) throw err;
1571
1648
  run.emit({
@@ -1573,6 +1650,12 @@ async function executeDeletes(
1573
1650
  path: relativePath,
1574
1651
  message: err instanceof Error ? err.message : String(err),
1575
1652
  });
1653
+ pathResults.push({
1654
+ path: relativePath,
1655
+ status: "refused",
1656
+ operation: "delete",
1657
+ reason: "transfer-error",
1658
+ });
1576
1659
  }
1577
1660
  }
1578
1661
  for (const relativePath of deletePlan.toTombstone) {
@@ -1585,6 +1668,11 @@ async function executeDeletes(
1585
1668
  deleted: true,
1586
1669
  message: "tombstone (remote already 404)",
1587
1670
  });
1671
+ pathResults.push({
1672
+ path: relativePath,
1673
+ status: "accepted",
1674
+ operation: "tombstone",
1675
+ });
1588
1676
  }
1589
1677
  const decommissionedSet =
1590
1678
  decommissionPlan.length > 0 ? new Set(decommissionPlan) : null;
@@ -1601,6 +1689,12 @@ async function executeDeletes(
1601
1689
  remoteEtag: refused.remoteEtag,
1602
1690
  reason: refused.reason,
1603
1691
  });
1692
+ pathResults.push({
1693
+ path: refused.key,
1694
+ status: "refused",
1695
+ operation: "delete",
1696
+ reason: refused.reason,
1697
+ });
1604
1698
  }
1605
1699
  }
1606
1700
 
@@ -1669,6 +1763,7 @@ function buildShareResult(
1669
1763
  counters: ShareCounters,
1670
1764
  filesRefusedStalePaths: string[],
1671
1765
  conflictPaths: string[],
1766
+ pathResults: SharePathResult[],
1672
1767
  aborted: boolean,
1673
1768
  ): ShareResult {
1674
1769
  return {
@@ -1684,6 +1779,7 @@ function buildShareResult(
1684
1779
  filesExcludedByScope: run.scopeExcludedSet.size,
1685
1780
  filesExcludedByIgnore: run.ignoreExcludedSet.size,
1686
1781
  conflictPaths,
1782
+ pathResults,
1687
1783
  aborted,
1688
1784
  };
1689
1785
  }
@@ -2043,8 +2139,26 @@ function resolveDeleteScopeRoots(
2043
2139
  paths: string[],
2044
2140
  hqRoot: string,
2045
2141
  syncRoot: string,
2142
+ explicitRoots: readonly string[] = [],
2046
2143
  ): string[] {
2047
2144
  const prefixes = new Set<string>();
2145
+ for (const root of explicitRoots) {
2146
+ const normalized = root
2147
+ .split("\\")
2148
+ .join("/")
2149
+ .replace(/^\.\/+/, "")
2150
+ .replace(/\/+$/, "");
2151
+ if (
2152
+ normalized === "" ||
2153
+ normalized === "." ||
2154
+ path.posix.isAbsolute(normalized) ||
2155
+ normalized === ".." ||
2156
+ normalized.startsWith("../")
2157
+ ) {
2158
+ continue;
2159
+ }
2160
+ prefixes.add(normalized);
2161
+ }
2048
2162
  for (const p of paths) {
2049
2163
  const absolutePath = path.isAbsolute(p) ? p : path.resolve(hqRoot, p);
2050
2164
  if (!fs.existsSync(absolutePath)) continue;
@@ -2296,23 +2410,19 @@ async function computeDeletePlan(
2296
2410
  // and the journal-mutation buckets are already settled before any I/O.
2297
2411
  type HeadCandidate = { key: string; journalEtag: string; intentVersion: 1 };
2298
2412
  const headCandidates: HeadCandidate[] = [];
2299
- // Litter drain bucket — kept separate from `plan.toDelete` so the
2300
- // bulk-asymmetry breaker (which moves toDelete + headCandidates into
2301
- // `refusedStale` when it trips) can't sweep these out. See
2413
+ // Litter drain bucket — kept separate from normal delete candidates so
2414
+ // the bulk-asymmetry accounting cannot sweep these in. See
2302
2415
  // `isVaultLitterArtifact` for the patterns and rationale: by construction
2303
2416
  // these aren't user content losses, so a high ratio of litter must not
2304
2417
  // refuse the drain — that's the whole point of the bypass. Merged into
2305
2418
  // `plan.toDelete` after the breaker check.
2306
2419
  const litterToDelete: string[] = [];
2307
- // Bulk-asymmetry tracking: count every in-scope journal entry (denominator)
2308
- // and every entry that would have been a delete-candidate before the guard
2309
- // (numerator). Numerator = headCandidates + owned-only/all toDelete picks +
2310
- // legacy-no-etag refusals. We do NOT count "ENOENT but ignore-filtered" or
2311
- // "ENOENT but ephemeral" — those drop out of the plan entirely on their own
2312
- // and don't reflect mirror-loss intent — and litter drains (see above),
2313
- // which are intentional ratchet-cleanup, not mass-delete intent.
2420
+ // Current watcher intents are affirmative evidence of an observed delete,
2421
+ // so only intent-less disappearances contribute to the mirror-loss breaker.
2422
+ // An unmounted/wiped tree produces no intents and still trips it.
2314
2423
  let inScopeJournalEntries = 0;
2315
- let bulkCandidatePicks = 0;
2424
+ let intentlessDisappearances = 0;
2425
+ const intentlessKeys = new Set<string>();
2316
2426
 
2317
2427
  for (const [relativeKey, entry] of Object.entries(journal.files)) {
2318
2428
  const inScope = scopeRoots.some(
@@ -2399,6 +2509,13 @@ async function computeDeletePlan(
2399
2509
  }
2400
2510
 
2401
2511
  if (!hasCurrentLocalDeleteIntent(entry)) {
2512
+ if (
2513
+ policy === "currency-gated" ||
2514
+ (policy === "owned-only" && entry.direction === "up")
2515
+ ) {
2516
+ intentlessDisappearances++;
2517
+ intentlessKeys.add(relativeKey);
2518
+ }
2402
2519
  plan.refusedStale.push({
2403
2520
  key: relativeKey,
2404
2521
  journalEtag: entry.remoteEtag ?? "<missing-delete-intent>",
@@ -2417,14 +2534,8 @@ async function computeDeletePlan(
2417
2534
  });
2418
2535
  continue;
2419
2536
  }
2420
- bulkCandidatePicks++;
2421
2537
  if (policy === "owned-only") {
2422
2538
  if (entry.direction !== "up") {
2423
- // Not a delete candidate under owned-only, but it WAS missing
2424
- // locally. Don't count it for the bulk guard — direction:'down'
2425
- // entries that vanish locally are silently ignored by this policy
2426
- // anyway, so they don't represent intent to mass-delete.
2427
- bulkCandidatePicks--;
2428
2539
  continue;
2429
2540
  }
2430
2541
  plan.toDelete.push({
@@ -2457,42 +2568,26 @@ async function computeDeletePlan(
2457
2568
  if (
2458
2569
  policy !== "all" &&
2459
2570
  !isBulkAsymmetryOverride() &&
2460
- bulkCandidatePicks >= BULK_ASYMMETRY_MIN_ABS &&
2571
+ intentlessDisappearances >= BULK_ASYMMETRY_MIN_ABS &&
2461
2572
  inScopeJournalEntries > 0 &&
2462
- bulkCandidatePicks / inScopeJournalEntries >= BULK_ASYMMETRY_RATIO
2573
+ intentlessDisappearances / inScopeJournalEntries >= BULK_ASYMMETRY_RATIO
2463
2574
  ) {
2464
- // Move every staged candidate (both already-bucketed toDelete from
2465
- // owned-only and queued headCandidates from currency-gated) into
2466
- // refusedStale with reason "bulk-asymmetry". Journal is not mutated;
2467
- // no DeleteObject is issued.
2468
2575
  const samplePaths: string[] = [];
2469
- const pushRefused = (key: string): void => {
2470
- plan.refusedStale.push({
2471
- key,
2472
- journalEtag: "<bulk-asymmetry>",
2473
- remoteEtag: "<not-checked>",
2474
- reason: "bulk-asymmetry",
2475
- });
2576
+ for (const refused of plan.refusedStale) {
2577
+ if (!intentlessKeys.has(refused.key)) continue;
2578
+ refused.journalEtag = "<bulk-asymmetry>";
2579
+ refused.remoteEtag = "<not-checked>";
2580
+ refused.reason = "bulk-asymmetry";
2476
2581
  if (samplePaths.length < BULK_ASYMMETRY_SAMPLE_CAP) {
2477
- samplePaths.push(key);
2582
+ samplePaths.push(refused.key);
2478
2583
  }
2479
- };
2480
- for (const item of plan.toDelete) pushRefused(item.key);
2481
- for (const c of headCandidates) pushRefused(c.key);
2482
- plan.toDelete = [];
2584
+ }
2483
2585
  plan.bulkAsymmetry = {
2484
- candidates: bulkCandidatePicks,
2586
+ candidates: intentlessDisappearances,
2485
2587
  inScope: inScopeJournalEntries,
2486
- ratio: bulkCandidatePicks / inScopeJournalEntries,
2588
+ ratio: intentlessDisappearances / inScopeJournalEntries,
2487
2589
  samplePaths,
2488
2590
  };
2489
- // Litter still drains even when the breaker trips — see `litterToDelete`
2490
- // declaration. The breaker protects against corrupt-mirror mass-deletes
2491
- // of user content; litter cleanup is orthogonal and should never be
2492
- // refused for the same reason new-litter producer-side exclusions
2493
- // shouldn't block it.
2494
- plan.toDelete.push(...litterToDelete.map((key) => ({ key, intentVersion: null })));
2495
- return plan;
2496
2591
  }
2497
2592
 
2498
2593
  // Stage 2: bounded-parallel HEAD pass. Promise.all over chunks of size