@indigoai-us/hq-cloud 6.11.12 → 6.11.13

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 (107) hide show
  1. package/dist/bin/sync-runner-company.d.ts +35 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -0
  3. package/dist/bin/sync-runner-company.js +290 -0
  4. package/dist/bin/sync-runner-company.js.map +1 -0
  5. package/dist/bin/sync-runner-events.d.ts +12 -0
  6. package/dist/bin/sync-runner-events.d.ts.map +1 -0
  7. package/dist/bin/sync-runner-events.js +12 -0
  8. package/dist/bin/sync-runner-events.js.map +1 -0
  9. package/dist/bin/sync-runner-planning.d.ts +53 -0
  10. package/dist/bin/sync-runner-planning.d.ts.map +1 -0
  11. package/dist/bin/sync-runner-planning.js +59 -0
  12. package/dist/bin/sync-runner-planning.js.map +1 -0
  13. package/dist/bin/sync-runner-rollup.d.ts +24 -0
  14. package/dist/bin/sync-runner-rollup.d.ts.map +1 -0
  15. package/dist/bin/sync-runner-rollup.js +46 -0
  16. package/dist/bin/sync-runner-rollup.js.map +1 -0
  17. package/dist/bin/sync-runner-telemetry.d.ts +5 -0
  18. package/dist/bin/sync-runner-telemetry.d.ts.map +1 -0
  19. package/dist/bin/sync-runner-telemetry.js +5 -0
  20. package/dist/bin/sync-runner-telemetry.js.map +1 -0
  21. package/dist/bin/sync-runner-watch-loop.d.ts +17 -0
  22. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -0
  23. package/dist/bin/sync-runner-watch-loop.js +372 -0
  24. package/dist/bin/sync-runner-watch-loop.js.map +1 -0
  25. package/dist/bin/sync-runner-watch-routes.d.ts +25 -0
  26. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -0
  27. package/dist/bin/sync-runner-watch-routes.js +74 -0
  28. package/dist/bin/sync-runner-watch-routes.js.map +1 -0
  29. package/dist/bin/sync-runner.d.ts +3 -54
  30. package/dist/bin/sync-runner.d.ts.map +1 -1
  31. package/dist/bin/sync-runner.js +73 -1154
  32. package/dist/bin/sync-runner.js.map +1 -1
  33. package/dist/cli/reindex.d.ts.map +1 -1
  34. package/dist/cli/reindex.js +34 -17
  35. package/dist/cli/reindex.js.map +1 -1
  36. package/dist/cli/reindex.test.js +39 -5
  37. package/dist/cli/reindex.test.js.map +1 -1
  38. package/dist/cli/rescue-classify-ordering.test.js +17 -0
  39. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  40. package/dist/cli/rescue-core.d.ts +45 -0
  41. package/dist/cli/rescue-core.d.ts.map +1 -1
  42. package/dist/cli/rescue-core.js +197 -170
  43. package/dist/cli/rescue-core.js.map +1 -1
  44. package/dist/cli/share.d.ts.map +1 -1
  45. package/dist/cli/share.js +224 -676
  46. package/dist/cli/share.js.map +1 -1
  47. package/dist/cli/sync.d.ts.map +1 -1
  48. package/dist/cli/sync.js +399 -726
  49. package/dist/cli/sync.js.map +1 -1
  50. package/dist/cli/sync.test.js +20 -0
  51. package/dist/cli/sync.test.js.map +1 -1
  52. package/dist/daemon-worker.d.ts +2 -2
  53. package/dist/daemon-worker.js +3 -3
  54. package/dist/daemon-worker.js.map +1 -1
  55. package/dist/object-io.js +1 -1
  56. package/dist/object-io.js.map +1 -1
  57. package/dist/remote-pull.d.ts +2 -2
  58. package/dist/remote-pull.d.ts.map +1 -1
  59. package/dist/remote-pull.js +23 -3
  60. package/dist/remote-pull.js.map +1 -1
  61. package/dist/remote-pull.test.js +24 -2
  62. package/dist/remote-pull.test.js.map +1 -1
  63. package/dist/sync/push-receiver.d.ts +6 -0
  64. package/dist/sync/push-receiver.d.ts.map +1 -1
  65. package/dist/sync/push-receiver.js +32 -2
  66. package/dist/sync/push-receiver.js.map +1 -1
  67. package/dist/sync/push-receiver.test.js +31 -0
  68. package/dist/sync/push-receiver.test.js.map +1 -1
  69. package/dist/sync-core.d.ts +27 -0
  70. package/dist/sync-core.d.ts.map +1 -0
  71. package/dist/sync-core.js +54 -0
  72. package/dist/sync-core.js.map +1 -0
  73. package/dist/vault-client.d.ts.map +1 -1
  74. package/dist/vault-client.js +284 -36
  75. package/dist/vault-client.js.map +1 -1
  76. package/dist/vault-client.test.js +59 -0
  77. package/dist/vault-client.test.js.map +1 -1
  78. package/dist/watcher.d.ts +2 -20
  79. package/dist/watcher.d.ts.map +1 -1
  80. package/dist/watcher.js +3 -113
  81. package/dist/watcher.js.map +1 -1
  82. package/package.json +1 -1
  83. package/src/bin/sync-runner-company.ts +350 -0
  84. package/src/bin/sync-runner-events.ts +25 -0
  85. package/src/bin/sync-runner-planning.ts +121 -0
  86. package/src/bin/sync-runner-rollup.ts +72 -0
  87. package/src/bin/sync-runner-telemetry.ts +8 -0
  88. package/src/bin/sync-runner-watch-loop.ts +443 -0
  89. package/src/bin/sync-runner-watch-routes.ts +86 -0
  90. package/src/bin/sync-runner.ts +96 -1253
  91. package/src/cli/reindex.test.ts +41 -3
  92. package/src/cli/reindex.ts +35 -19
  93. package/src/cli/rescue-classify-ordering.test.ts +20 -0
  94. package/src/cli/rescue-core.ts +252 -176
  95. package/src/cli/share.ts +363 -705
  96. package/src/cli/sync.test.ts +25 -0
  97. package/src/cli/sync.ts +612 -802
  98. package/src/daemon-worker.ts +3 -3
  99. package/src/object-io.ts +1 -1
  100. package/src/remote-pull.test.ts +30 -1
  101. package/src/remote-pull.ts +29 -4
  102. package/src/sync/push-receiver.test.ts +35 -0
  103. package/src/sync/push-receiver.ts +41 -2
  104. package/src/sync-core.ts +58 -0
  105. package/src/vault-client.test.ts +74 -0
  106. package/src/vault-client.ts +395 -43
  107. package/src/watcher.ts +6 -141
@@ -694,6 +694,7 @@ function doRescue(
694
694
  unchangedList,
695
695
  appendLog,
696
696
  out,
697
+ decisions: [],
697
698
  actions: [],
698
699
  };
699
700
 
@@ -728,16 +729,19 @@ function doRescue(
728
729
  }
729
730
 
730
731
  // --- Walk + classify (PHASE 1: read-only) ---
731
- // Classification mutates nothing on disk: every destructive op (delete,
732
- // rescue-move, conflict-quarantine, symlink-drop) is recorded as a deferred
733
- // closure in `ctx.actions` and executed later (PHASE 2). This is the
732
+ // Classification mutates nothing on disk: every outcome is recorded as a
733
+ // typed RescueDecision. Destructive ops (delete, rescue-move,
734
+ // conflict-quarantine, symlink-drop) are built from those decisions and
735
+ // executed later (PHASE 2). This is the
734
736
  // ordering-safety invariant: if classification throws partway through the
735
737
  // wipe set (e.g. an unreadable entry, a future unhandled file shape), it
736
- // aborts HERE — before a single file has been touched instead of leaving a
737
- // half-applied wipe like the pre-port bash classifier did (DEV-1767:
738
+ // aborts HERE — before a single file has been touched or even queued as a
739
+ // filesystem closure — instead of leaving a half-applied wipe like the
740
+ // pre-port bash classifier did (DEV-1767:
738
741
  // `Error: Path is a directory` on a nested dir-symlink, thrown after ~10 core
739
- // files were already deleted). Dry-run runs this exact same pass and then
740
- // returns, so the `--check` plan can never miss a condition the live pass hits.
742
+ // files were already deleted). Dry-run renders each typed decision inline as
743
+ // the walk records it, preserving the legacy progress/fault timing while
744
+ // still sharing the same classify path as the live pass.
741
745
  out("\n");
742
746
  if (wipeToplevel.length === 0) {
743
747
  out("==> Wipe set is empty; nothing to process or overlay.\n");
@@ -813,6 +817,7 @@ function doRescue(
813
817
  // Actions run in classification (walk) order — the same order the old
814
818
  // interleaved walk mutated in — so per-file output and on-disk results are
815
819
  // unchanged versus before the two-phase split.
820
+ ctx.actions = buildRescueActions(ctx);
816
821
  applyClassifiedActions(ctx, backupDir);
817
822
 
818
823
  // --- Back up preserve-subpaths to a mktemp shuttle ---
@@ -1076,7 +1081,7 @@ interface WalkCtx {
1076
1081
  historyFloor: string;
1077
1082
  baselineMode: "history_floor" | "head_compare";
1078
1083
  // Batched git results, precomputed once before the classify walk so
1079
- // processOne does map lookups instead of spawning git ~3x per file. On a
1084
+ // classifyOne does map lookups instead of spawning git ~3x per file. On a
1080
1085
  // large wipe set, process-startup overhead dominates and this is the
1081
1086
  // difference between minutes and seconds. A rel absent from a map falls back
1082
1087
  // to a per-file spawn (safety for paths that can't be batched line-delimited,
@@ -1089,12 +1094,17 @@ interface WalkCtx {
1089
1094
  floorShas?: Map<string, string | null>;
1090
1095
  runTs: string;
1091
1096
  srcSha: string;
1092
- // Deferred destructive ops, collected during the (read-only) classification
1093
- // walk and executed only AFTER the entire wipe set has been classified
1094
- // without error (PHASE 2 in doRescue). This is what makes a classifier crash
1095
- // fail-safe — a throw mid-classify aborts before a single file is mutated, so
1096
- // the wipe is never left half-applied (DEV-1767). Dry-run never fills this (it
1097
- // returns after classification), so live + dry-run share one classify path.
1097
+ // Read-only classifier output, collected in walk order. Dry-run rendering is
1098
+ // emitted inline as each decision is recorded; live filesystem action
1099
+ // construction is derived from this same list only after the full classify
1100
+ // walk completes.
1101
+ decisions: RescueDecision[];
1102
+ // Deferred destructive ops, built from RescueDecision only AFTER the entire
1103
+ // wipe set has been classified without error (PHASE 2 in doRescue). This is
1104
+ // what makes a classifier crash fail-safe — a throw mid-classify aborts before
1105
+ // a single file is mutated, so the wipe is never left half-applied
1106
+ // (DEV-1767). Dry-run never fills this (it returns after classification), so
1107
+ // live + dry-run share one classify path.
1098
1108
  actions: RescueAction[];
1099
1109
  counts: {
1100
1110
  userOnly: number;
@@ -1118,13 +1128,45 @@ interface RescueAction {
1118
1128
  run: () => void;
1119
1129
  }
1120
1130
 
1121
- function queueAction(
1122
- ctx: WalkCtx,
1123
- label: string,
1124
- affectedRels: string[],
1125
- runAction: () => void,
1126
- ): void {
1127
- ctx.actions.push({ label, affectedRels, run: runAction });
1131
+ export type RescueDecision =
1132
+ | { kind: "skip"; rel: string }
1133
+ | { kind: "drop-conflict-artifact"; rel: string }
1134
+ | { kind: "drop-script-managed"; rel: string }
1135
+ | { kind: "drop-reindex-symlink"; rel: string; target: string }
1136
+ | { kind: "user-only"; rel: string }
1137
+ | { kind: "cloud-symlink-reconciled"; rel: string }
1138
+ | { kind: "drift-reconciled"; rel: string }
1139
+ | { kind: "user-edit-diff-append"; rel: string }
1140
+ | { kind: "user-edit-overwrite-safe"; rel: string }
1141
+ | { kind: "user-edit-conflict"; rel: string }
1142
+ | { kind: "user-edit-rescue"; rel: string; target: string }
1143
+ | { kind: "unchanged-preserve"; rel: string }
1144
+ | { kind: "unchanged-delete"; rel: string }
1145
+ | { kind: "wholesale-replace-template"; rel: "companies/_template" };
1146
+
1147
+ function recordDecision(ctx: WalkCtx, decision: RescueDecision): void {
1148
+ ctx.decisions.push(decision);
1149
+ if (ctx.cfg.dryRun) renderDryRunDecision(ctx, decision);
1150
+ switch (decision.kind) {
1151
+ case "drop-reindex-symlink":
1152
+ ctx.counts.symlinkDropped += 1;
1153
+ return;
1154
+ case "user-only":
1155
+ ctx.counts.userOnly += 1;
1156
+ return;
1157
+ case "cloud-symlink-reconciled":
1158
+ ctx.counts.cloudSymlinkReconciled += 1;
1159
+ return;
1160
+ case "drift-reconciled":
1161
+ ctx.counts.driftReconciled += 1;
1162
+ return;
1163
+ case "unchanged-preserve":
1164
+ case "unchanged-delete":
1165
+ ctx.counts.unchanged += 1;
1166
+ return;
1167
+ default:
1168
+ return;
1169
+ }
1128
1170
  }
1129
1171
 
1130
1172
  function applyClassifiedActions(ctx: WalkCtx, backupDir: string): void {
@@ -1294,16 +1336,16 @@ function isOverwriteSafe(rel: string): boolean {
1294
1336
  );
1295
1337
  }
1296
1338
 
1297
- function isMasterSyncSymlink(localPath: string): boolean {
1339
+ function masterSyncSymlinkTarget(localPath: string): string | null {
1298
1340
  const st = lstatOrNull(localPath);
1299
- if (!st || !st.isSymbolicLink()) return false;
1341
+ if (!st || !st.isSymbolicLink()) return null;
1300
1342
  let tgt = "";
1301
1343
  try {
1302
1344
  tgt = fs.readlinkSync(localPath);
1303
1345
  } catch {
1304
- return false;
1346
+ return null;
1305
1347
  }
1306
- return tgt.includes("/personal/") || tgt.startsWith("personal/");
1348
+ return tgt.includes("/personal/") || tgt.startsWith("personal/") ? tgt : null;
1307
1349
  }
1308
1350
 
1309
1351
  function isUnderPreserve(cfg: Config, rel: string): boolean {
@@ -1556,14 +1598,13 @@ function conflictOne(ctx: WalkCtx, rel: string): void {
1556
1598
  ctx.appendLog(`conflicted\t${rel}\t-> ${destRel}\n`);
1557
1599
  }
1558
1600
 
1559
- // --- classify + act on one file (the per-file workhorse) ---
1601
+ // --- classify one file (the per-file workhorse) ---
1560
1602
  //
1561
- // Read-only by contract: this records intent (counts + dry-run plan lines +
1562
- // deferred `ctx.actions` closures) but never mutates the filesystem itself. The
1563
- // closures are run later by the PHASE 2 apply loop, only once the whole wipe
1564
- // set has classified without throwing. Keep it that way — a stray `fs.rmSync`
1565
- // here re-opens the half-applied-wipe hole this split closed (DEV-1767).
1566
- function processOne(ctx: WalkCtx, rel: string): void {
1603
+ // Read-only by contract: this returns a RescueDecision only. It does not render
1604
+ // dry-run text, update action queues, or mutate the filesystem. Dry-run text is
1605
+ // rendered by recordDecision, and PHASE 2 action building happens only once the
1606
+ // whole wipe set has classified without throwing. Keep it that way.
1607
+ function classifyOne(ctx: WalkCtx, rel: string): RescueDecision {
1567
1608
  const { cfg } = ctx;
1568
1609
  const localPath = path.join(ctx.hqRoot, rel);
1569
1610
  const srcPath = path.join(ctx.srcDir, rel);
@@ -1574,54 +1615,27 @@ function processOne(ctx: WalkCtx, rel: string): void {
1574
1615
  throw new Error(`rescue classifier fault injected at ${rel} (HQ_RESCUE_FAULT_AT_REL)`);
1575
1616
  }
1576
1617
 
1577
- if (isUnderPreserve(cfg, rel)) return;
1618
+ if (isUnderPreserve(cfg, rel)) return { kind: "skip", rel };
1578
1619
 
1579
1620
  // Conflict-resolution artifacts (`<name>.conflict-<ts>-<peer>.<ext>`).
1580
1621
  const base = rel.includes("/") ? rel.slice(rel.lastIndexOf("/") + 1) : rel;
1581
1622
  if (/\.conflict-/.test(base)) {
1582
- if (cfg.dryRun) {
1583
- ctx.out(` drop conflict artifact: ${rel}\n`);
1584
- } else {
1585
- queueAction(ctx, `drop conflict artifact: ${rel}`, [rel], () =>
1586
- fs.rmSync(localPath, { force: true }),
1587
- );
1588
- }
1589
- return;
1623
+ return { kind: "drop-conflict-artifact", rel };
1590
1624
  }
1591
1625
 
1592
1626
  // Script-managed files: core/core.yaml + legacy core.yaml.
1593
1627
  if (rel === "core/core.yaml" || rel === "core.yaml") {
1594
- if (cfg.dryRun) {
1595
- ctx.out(` skip script-managed (rewrites at stamp step): ${rel}\n`);
1596
- } else {
1597
- queueAction(ctx, `drop script-managed: ${rel}`, [rel], () =>
1598
- fs.rmSync(localPath, { force: true }),
1599
- );
1600
- }
1601
- return;
1628
+ return { kind: "drop-script-managed", rel };
1602
1629
  }
1603
1630
 
1604
1631
  // Symlinks (mid-tree).
1605
1632
  const lst = lstatOrNull(localPath);
1606
1633
  if (lst && lst.isSymbolicLink()) {
1607
- if (isMasterSyncSymlink(localPath)) {
1608
- ctx.counts.symlinkDropped += 1;
1609
- if (cfg.dryRun) {
1610
- let tgt = "";
1611
- try {
1612
- tgt = fs.readlinkSync(localPath);
1613
- } catch {
1614
- /* ignore */
1615
- }
1616
- ctx.out(` drop reindex symlink: ${rel} -> ${tgt}\n`);
1617
- } else {
1618
- queueAction(ctx, `drop reindex symlink: ${rel}`, [rel], () => {
1619
- fs.rmSync(localPath, { force: true });
1620
- ctx.appendLog(`symlink-dropped\t${rel}\t(reindex regenerable)\n`);
1621
- });
1622
- }
1634
+ const target = masterSyncSymlinkTarget(localPath);
1635
+ if (target !== null) {
1636
+ return { kind: "drop-reindex-symlink", rel, target };
1623
1637
  }
1624
- return;
1638
+ return { kind: "skip", rel };
1625
1639
  }
1626
1640
 
1627
1641
  // Is path in upstream HEAD?
@@ -1652,9 +1666,7 @@ function processOne(ctx: WalkCtx, rel: string): void {
1652
1666
 
1653
1667
  // USER-ONLY: unknown to upstream (HEAD AND floor both lack it).
1654
1668
  if (inHead === 0 && inFloor === 0) {
1655
- ctx.counts.userOnly += 1;
1656
- if (cfg.dryRun) ctx.out(` user-only (leave in place): ${rel}\n`);
1657
- return;
1669
+ return { kind: "user-only", rel };
1658
1670
  }
1659
1671
 
1660
1672
  // Path is/was in upstream. Determine if user edited it.
@@ -1679,99 +1691,186 @@ function processOne(ctx: WalkCtx, rel: string): void {
1679
1691
 
1680
1692
  // Cloud-update reclassification.
1681
1693
  if (userEdited === 1 && isCloudFlattenedSymlinkEquiv(ctx, rel, localPath)) {
1682
- ctx.counts.cloudSymlinkReconciled += 1;
1683
- if (cfg.dryRun) {
1684
- ctx.out(
1685
- ` cloud-symlink reconciled (unchanged): ${rel} (hq-symlink: marker matches upstream target)\n`,
1686
- );
1687
- } else {
1688
- queueAction(ctx, `cloud-symlink reconciled: ${rel}`, [rel], () => {
1689
- fs.rmSync(localPath, { force: true });
1690
- ctx.appendLog(
1691
- `cloud-symlink-reconciled\t${rel}\t(hq-symlink: marker matches upstream target; overlay re-lays symlink)\n`,
1692
- );
1693
- });
1694
- }
1695
- return;
1694
+ return { kind: "cloud-symlink-reconciled", rel };
1696
1695
  }
1697
1696
 
1698
1697
  // Convergence guard: drifted from floor but identical to upstream HEAD.
1699
1698
  if (userEdited === 1 && inHead === 1 && bytesEqual(localPath, srcPath)) {
1700
- ctx.counts.driftReconciled += 1;
1701
- if (cfg.dryRun) {
1702
- ctx.out(` drift reconciled (identical to upstream HEAD; no rescue): ${rel}\n`);
1703
- } else {
1704
- queueAction(ctx, `drift reconciled: ${rel}`, [rel], () => {
1705
- fs.rmSync(localPath, { force: true });
1706
- ctx.appendLog(
1707
- `drift-reconciled\t${rel}\t(identical to upstream HEAD; drifted from floor only — no personal/ copy)\n`,
1708
- );
1709
- });
1710
- }
1711
- return;
1699
+ return { kind: "drift-reconciled", rel };
1712
1700
  }
1713
1701
 
1714
1702
  if (userEdited === 1) {
1715
- if (cfg.dryRun) {
1716
- if (rel === ".claude/CLAUDE.md") {
1717
- ctx.out(` user-edit (diff-append): ${rel} -> personal/CLAUDE.md\n`);
1718
- ctx.counts.userEdit += 1;
1719
- } else if (isOverwriteSafe(rel)) {
1720
- ctx.out(` user-edit (overwrite-safe): ${rel} -> upstream wins (no copy preserved)\n`);
1721
- ctx.counts.userOverwrite += 1;
1722
- } else if (isConflictClass(rel)) {
1723
- ctx.out(` user-edit (conflict): ${rel} -> .hq-conflicts/rescue-${ctx.runTs}/${rel}\n`);
1724
- ctx.counts.userConflict += 1;
1725
- } else {
1726
- ctx.out(` user-edit (rescue): ${rel} -> ${mapRescueTarget(rel)}\n`);
1727
- ctx.counts.userEdit += 1;
1728
- }
1729
- } else {
1730
- if (rel === ".claude/CLAUDE.md") {
1731
- queueAction(ctx, `rescue diff-append: ${rel}`, [rel], () =>
1732
- rescueOne(ctx, rel),
1703
+ if (rel === ".claude/CLAUDE.md") {
1704
+ return { kind: "user-edit-diff-append", rel };
1705
+ }
1706
+ if (isOverwriteSafe(rel)) {
1707
+ return { kind: "user-edit-overwrite-safe", rel };
1708
+ }
1709
+ if (isConflictClass(rel)) {
1710
+ return { kind: "user-edit-conflict", rel };
1711
+ }
1712
+ return { kind: "user-edit-rescue", rel, target: mapRescueTarget(rel) };
1713
+ }
1714
+
1715
+ // user_edited=0: matches floor baseline. Split on byte-equality to HEAD.
1716
+ if (bytesEqual(localPath, path.join(ctx.srcDir, rel))) {
1717
+ return { kind: "unchanged-preserve", rel };
1718
+ }
1719
+ return { kind: "unchanged-delete", rel };
1720
+ }
1721
+
1722
+ function renderDryRunDecision(ctx: WalkCtx, decision: RescueDecision): void {
1723
+ switch (decision.kind) {
1724
+ case "skip":
1725
+ break;
1726
+ case "drop-conflict-artifact":
1727
+ ctx.out(` drop conflict artifact: ${decision.rel}\n`);
1728
+ break;
1729
+ case "drop-script-managed":
1730
+ ctx.out(` skip script-managed (rewrites at stamp step): ${decision.rel}\n`);
1731
+ break;
1732
+ case "drop-reindex-symlink":
1733
+ ctx.out(` drop reindex symlink: ${decision.rel} -> ${decision.target}\n`);
1734
+ break;
1735
+ case "user-only":
1736
+ ctx.out(` user-only (leave in place): ${decision.rel}\n`);
1737
+ break;
1738
+ case "cloud-symlink-reconciled":
1739
+ ctx.out(
1740
+ ` cloud-symlink reconciled (unchanged): ${decision.rel} (hq-symlink: marker matches upstream target)\n`,
1741
+ );
1742
+ break;
1743
+ case "drift-reconciled":
1744
+ ctx.out(` drift reconciled (identical to upstream HEAD; no rescue): ${decision.rel}\n`);
1745
+ break;
1746
+ case "user-edit-diff-append":
1747
+ ctx.out(` user-edit (diff-append): ${decision.rel} -> personal/CLAUDE.md\n`);
1748
+ ctx.counts.userEdit += 1;
1749
+ break;
1750
+ case "user-edit-overwrite-safe":
1751
+ ctx.out(` user-edit (overwrite-safe): ${decision.rel} -> upstream wins (no copy preserved)\n`);
1752
+ ctx.counts.userOverwrite += 1;
1753
+ break;
1754
+ case "user-edit-conflict":
1755
+ ctx.out(
1756
+ ` user-edit (conflict): ${decision.rel} -> .hq-conflicts/rescue-${ctx.runTs}/${decision.rel}\n`,
1757
+ );
1758
+ ctx.counts.userConflict += 1;
1759
+ break;
1760
+ case "user-edit-rescue":
1761
+ ctx.out(` user-edit (rescue): ${decision.rel} -> ${decision.target}\n`);
1762
+ ctx.counts.userEdit += 1;
1763
+ break;
1764
+ case "unchanged-preserve":
1765
+ ctx.out(` unchanged (preserved in place): ${decision.rel}\n`);
1766
+ break;
1767
+ case "unchanged-delete":
1768
+ ctx.out(` unchanged (delete + replace): ${decision.rel}\n`);
1769
+ break;
1770
+ case "wholesale-replace-template":
1771
+ ctx.out(" wholesale-replace: companies/_template (template carve-out)\n");
1772
+ break;
1773
+ }
1774
+ }
1775
+
1776
+ function buildRescueActions(ctx: WalkCtx): RescueAction[] {
1777
+ const actions: RescueAction[] = [];
1778
+ const add = (label: string, affectedRels: string[], runAction: () => void) => {
1779
+ actions.push({ label, affectedRels, run: runAction });
1780
+ };
1781
+
1782
+ for (const decision of ctx.decisions) {
1783
+ const localPath = path.join(ctx.hqRoot, decision.rel);
1784
+ switch (decision.kind) {
1785
+ case "skip":
1786
+ case "user-only":
1787
+ break;
1788
+ case "drop-conflict-artifact":
1789
+ add(`drop conflict artifact: ${decision.rel}`, [decision.rel], () =>
1790
+ fs.rmSync(localPath, { force: true }),
1733
1791
  );
1734
- } else if (isOverwriteSafe(rel)) {
1735
- queueAction(ctx, `overwrite-safe: ${rel}`, [rel], () => {
1792
+ break;
1793
+ case "drop-script-managed":
1794
+ add(`drop script-managed: ${decision.rel}`, [decision.rel], () =>
1795
+ fs.rmSync(localPath, { force: true }),
1796
+ );
1797
+ break;
1798
+ case "drop-reindex-symlink":
1799
+ add(`drop reindex symlink: ${decision.rel}`, [decision.rel], () => {
1800
+ fs.rmSync(localPath, { force: true });
1801
+ ctx.appendLog(`symlink-dropped\t${decision.rel}\t(reindex regenerable)\n`);
1802
+ });
1803
+ break;
1804
+ case "cloud-symlink-reconciled":
1805
+ add(`cloud-symlink reconciled: ${decision.rel}`, [decision.rel], () => {
1806
+ fs.rmSync(localPath, { force: true });
1807
+ ctx.appendLog(
1808
+ `cloud-symlink-reconciled\t${decision.rel}\t(hq-symlink: marker matches upstream target; overlay re-lays symlink)\n`,
1809
+ );
1810
+ });
1811
+ break;
1812
+ case "drift-reconciled":
1813
+ add(`drift reconciled: ${decision.rel}`, [decision.rel], () => {
1814
+ fs.rmSync(localPath, { force: true });
1815
+ ctx.appendLog(
1816
+ `drift-reconciled\t${decision.rel}\t(identical to upstream HEAD; drifted from floor only — no personal/ copy)\n`,
1817
+ );
1818
+ });
1819
+ break;
1820
+ case "user-edit-diff-append":
1821
+ add(`rescue diff-append: ${decision.rel}`, [decision.rel], () =>
1822
+ rescueOne(ctx, decision.rel),
1823
+ );
1824
+ break;
1825
+ case "user-edit-overwrite-safe":
1826
+ add(`overwrite-safe: ${decision.rel}`, [decision.rel], () => {
1736
1827
  fs.rmSync(localPath, { force: true });
1737
1828
  ctx.counts.userOverwrite += 1;
1738
- ctx.appendLog(`overwritten\t${rel}\t(overwrite-safe; upstream wins, no copy preserved)\n`);
1829
+ ctx.appendLog(
1830
+ `overwritten\t${decision.rel}\t(overwrite-safe; upstream wins, no copy preserved)\n`,
1831
+ );
1739
1832
  });
1740
- } else if (isConflictClass(rel)) {
1741
- queueAction(ctx, `conflict quarantine: ${rel}`, [rel], () =>
1742
- conflictOne(ctx, rel),
1833
+ break;
1834
+ case "user-edit-conflict":
1835
+ add(`conflict quarantine: ${decision.rel}`, [decision.rel], () =>
1836
+ conflictOne(ctx, decision.rel),
1743
1837
  );
1744
- } else {
1745
- queueAction(ctx, `rescue: ${rel}`, [rel], () => rescueOne(ctx, rel));
1746
- }
1747
- }
1748
- } else {
1749
- // user_edited=0: matches floor baseline. Split on byte-equality to HEAD.
1750
- ctx.counts.unchanged += 1;
1751
- if (bytesEqual(localPath, path.join(ctx.srcDir, rel))) {
1752
- if (cfg.dryRun) {
1753
- ctx.out(` unchanged (preserved in place): ${rel}\n`);
1754
- } else {
1755
- queueAction(ctx, `preserve unchanged: ${rel}`, [], () => {
1756
- fs.appendFileSync(ctx.unchangedList, `/${rel}\n`);
1757
- ctx.appendLog(`unchanged\t${rel}\t(identical to upstream; left in place, mtime preserved)\n`);
1838
+ break;
1839
+ case "user-edit-rescue":
1840
+ add(`rescue: ${decision.rel}`, [decision.rel], () => rescueOne(ctx, decision.rel));
1841
+ break;
1842
+ case "unchanged-preserve":
1843
+ add(`preserve unchanged: ${decision.rel}`, [], () => {
1844
+ fs.appendFileSync(ctx.unchangedList, `/${decision.rel}\n`);
1845
+ ctx.appendLog(
1846
+ `unchanged\t${decision.rel}\t(identical to upstream; left in place, mtime preserved)\n`,
1847
+ );
1758
1848
  });
1759
- }
1760
- } else {
1761
- if (cfg.dryRun) {
1762
- ctx.out(` unchanged (delete + replace): ${rel}\n`);
1763
- } else {
1764
- queueAction(ctx, `delete unchanged: ${rel}`, [rel], () => {
1849
+ break;
1850
+ case "unchanged-delete":
1851
+ add(`delete unchanged: ${decision.rel}`, [decision.rel], () => {
1765
1852
  fs.rmSync(localPath, { force: true });
1766
- ctx.appendLog(`deleted\t${rel}\t(unchanged vs baseline; re-laid by overlay if still upstream)\n`);
1853
+ ctx.appendLog(
1854
+ `deleted\t${decision.rel}\t(unchanged vs baseline; re-laid by overlay if still upstream)\n`,
1855
+ );
1767
1856
  });
1768
- }
1857
+ break;
1858
+ case "wholesale-replace-template":
1859
+ add("wholesale-replace: companies/_template", [decision.rel], () =>
1860
+ fs.rmSync(path.join(ctx.hqRoot, "companies", "_template"), {
1861
+ recursive: true,
1862
+ force: true,
1863
+ }),
1864
+ );
1865
+ break;
1769
1866
  }
1770
1867
  }
1868
+
1869
+ return actions;
1771
1870
  }
1772
1871
 
1773
1872
  // --- walk a wipe-set root ---
1774
- // Precompute the per-file git facts processOne needs, in two batched passes
1873
+ // Precompute the per-file git facts classifyOne needs, in two batched passes
1775
1874
  // instead of ~3 `git` spawns per file. Process-startup overhead (~15-20ms per
1776
1875
  // spawn) dominates the per-file path, so on a large wipe set this turns a
1777
1876
  // minutes-long classify into a seconds-long one. Behaviour is identical: the
@@ -1779,10 +1878,10 @@ function processOne(ctx: WalkCtx, rel: string): void {
1779
1878
  // rel not represented in a map falls back to its original per-file spawn.
1780
1879
  //
1781
1880
  // Mirrors walkAndProcess's enumeration so the batched set matches what actually
1782
- // reaches processOne: it skips `companies/_template` (wholesale-replaced, never
1881
+ // reaches classifyOne: it skips `companies/_template` (wholesale-replaced, never
1783
1882
  // classified per-file) and top-level symlinks (dropped, never classified).
1784
1883
  function precomputeGitMaps(ctx: WalkCtx, wipeToplevel: string[]): void {
1785
- const allRels: string[] = []; // every rel that reaches processOne (files + symlinks)
1884
+ const allRels: string[] = []; // every rel that reaches classifyOne (files + symlinks)
1786
1885
  const fileRels: string[] = []; // subset that are regular files (hashable)
1787
1886
  const fileAbs: string[] = []; // parallel to fileRels
1788
1887
 
@@ -1798,7 +1897,7 @@ function precomputeGitMaps(ctx: WalkCtx, wipeToplevel: string[]): void {
1798
1897
  : [];
1799
1898
  for (const rel of rels) {
1800
1899
  // A path containing a newline can't survive line-delimited batch stdin;
1801
- // leave it out of the maps and let processOne spawn per-file for it.
1900
+ // leave it out of the maps and let classifyOne spawn per-file for it.
1802
1901
  if (rel.includes("\n")) continue;
1803
1902
  allRels.push(rel);
1804
1903
  const st = lstatOrNull(path.join(ctx.hqRoot, rel));
@@ -1809,7 +1908,7 @@ function precomputeGitMaps(ctx: WalkCtx, wipeToplevel: string[]): void {
1809
1908
  }
1810
1909
  }
1811
1910
 
1812
- // In head_compare mode processOne never consults these maps (it compares
1911
+ // In head_compare mode classifyOne never consults these maps (it compares
1813
1912
  // bytes against the upstream working tree directly), so skip both git passes.
1814
1913
  if (ctx.baselineMode !== "history_floor") {
1815
1914
  ctx.floorShas = new Map();
@@ -1846,7 +1945,7 @@ function precomputeGitMaps(ctx: WalkCtx, wipeToplevel: string[]): void {
1846
1945
  ctx.floorShas = floorShas;
1847
1946
 
1848
1947
  // Pass 2: local blob SHA, but ONLY for regular files that are in the floor.
1849
- // processOne reads localShas exclusively in the `inFloor === 1` branch, so a
1948
+ // classifyOne reads localShas exclusively in the `inFloor === 1` branch, so a
1850
1949
  // file absent from the floor (e.g. the transient `.claude/worktrees/` trees,
1851
1950
  // which classify as user-only) never needs a hash — and hashing it would mean
1852
1951
  // reading its bytes for nothing. Restricting the hash set to floor members is
@@ -1877,22 +1976,12 @@ function precomputeGitMaps(ctx: WalkCtx, wipeToplevel: string[]): void {
1877
1976
  }
1878
1977
 
1879
1978
  function walkAndProcess(ctx: WalkCtx, rootRel: string): void {
1880
- const { cfg } = ctx;
1881
1979
  const rootAbs = path.join(ctx.hqRoot, rootRel);
1882
1980
  if (!lexists(rootAbs)) return;
1883
1981
 
1884
1982
  // companies/_template — wholesale-replace.
1885
1983
  if (rootRel === "companies/_template") {
1886
- if (cfg.dryRun) {
1887
- ctx.out(" wholesale-replace: companies/_template (template carve-out)\n");
1888
- } else {
1889
- queueAction(ctx, "wholesale-replace: companies/_template", [rootRel], () =>
1890
- fs.rmSync(path.join(ctx.hqRoot, "companies", "_template"), {
1891
- recursive: true,
1892
- force: true,
1893
- }),
1894
- );
1895
- }
1984
+ recordDecision(ctx, { kind: "wholesale-replace-template", rel: "companies/_template" });
1896
1985
  return;
1897
1986
  }
1898
1987
 
@@ -1900,35 +1989,22 @@ function walkAndProcess(ctx: WalkCtx, rootRel: string): void {
1900
1989
 
1901
1990
  // Top-level symlink.
1902
1991
  if (lst && lst.isSymbolicLink()) {
1903
- if (isMasterSyncSymlink(rootAbs)) {
1904
- ctx.counts.symlinkDropped += 1;
1905
- if (cfg.dryRun) {
1906
- let tgt = "";
1907
- try {
1908
- tgt = fs.readlinkSync(rootAbs);
1909
- } catch {
1910
- /* ignore */
1911
- }
1912
- ctx.out(` drop reindex symlink: ${rootRel} -> ${tgt}\n`);
1913
- } else {
1914
- queueAction(ctx, `drop reindex symlink: ${rootRel}`, [rootRel], () => {
1915
- fs.rmSync(rootAbs, { force: true });
1916
- ctx.appendLog(`symlink-dropped\t${rootRel}\t(reindex regenerable)\n`);
1917
- });
1918
- }
1992
+ const target = masterSyncSymlinkTarget(rootAbs);
1993
+ if (target !== null) {
1994
+ recordDecision(ctx, { kind: "drop-reindex-symlink", rel: rootRel, target });
1919
1995
  }
1920
1996
  return;
1921
1997
  }
1922
1998
 
1923
1999
  // Top-level regular file.
1924
2000
  if (lst && lst.isFile()) {
1925
- processOne(ctx, rootRel);
2001
+ recordDecision(ctx, classifyOne(ctx, rootRel));
1926
2002
  return;
1927
2003
  }
1928
2004
 
1929
2005
  // Top-level directory: recursive walk, pruning node_modules + nested .git.
1930
2006
  for (const rel of findFilesAndSymlinks(rootAbs, ctx.hqRoot)) {
1931
- processOne(ctx, rel);
2007
+ recordDecision(ctx, classifyOne(ctx, rel));
1932
2008
  }
1933
2009
  }
1934
2010