@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
@@ -616,6 +616,7 @@ function doRescue(cfg, env, out, err, setTmp) {
616
616
  unchangedList,
617
617
  appendLog,
618
618
  out,
619
+ decisions: [],
619
620
  actions: [],
620
621
  };
621
622
  // --- Pre-operation safety snapshot (BEFORE any destructive op) ---
@@ -645,16 +646,19 @@ function doRescue(cfg, env, out, err, setTmp) {
645
646
  out(` snapshot complete (restore any file: cp "${backupDir}/<relpath>" "${hqRoot}/<relpath>")\n`);
646
647
  }
647
648
  // --- Walk + classify (PHASE 1: read-only) ---
648
- // Classification mutates nothing on disk: every destructive op (delete,
649
- // rescue-move, conflict-quarantine, symlink-drop) is recorded as a deferred
650
- // closure in `ctx.actions` and executed later (PHASE 2). This is the
649
+ // Classification mutates nothing on disk: every outcome is recorded as a
650
+ // typed RescueDecision. Destructive ops (delete, rescue-move,
651
+ // conflict-quarantine, symlink-drop) are built from those decisions and
652
+ // executed later (PHASE 2). This is the
651
653
  // ordering-safety invariant: if classification throws partway through the
652
654
  // wipe set (e.g. an unreadable entry, a future unhandled file shape), it
653
- // aborts HERE — before a single file has been touched instead of leaving a
654
- // half-applied wipe like the pre-port bash classifier did (DEV-1767:
655
+ // aborts HERE — before a single file has been touched or even queued as a
656
+ // filesystem closure — instead of leaving a half-applied wipe like the
657
+ // pre-port bash classifier did (DEV-1767:
655
658
  // `Error: Path is a directory` on a nested dir-symlink, thrown after ~10 core
656
- // files were already deleted). Dry-run runs this exact same pass and then
657
- // returns, so the `--check` plan can never miss a condition the live pass hits.
659
+ // files were already deleted). Dry-run renders each typed decision inline as
660
+ // the walk records it, preserving the legacy progress/fault timing while
661
+ // still sharing the same classify path as the live pass.
658
662
  out("\n");
659
663
  if (wipeToplevel.length === 0) {
660
664
  out("==> Wipe set is empty; nothing to process or overlay.\n");
@@ -734,6 +738,7 @@ function doRescue(cfg, env, out, err, setTmp) {
734
738
  // Actions run in classification (walk) order — the same order the old
735
739
  // interleaved walk mutated in — so per-file output and on-disk results are
736
740
  // unchanged versus before the two-phase split.
741
+ ctx.actions = buildRescueActions(ctx);
737
742
  applyClassifiedActions(ctx, backupDir);
738
743
  // --- Back up preserve-subpaths to a mktemp shuttle ---
739
744
  const shuttle = path.join(tmpdir, "preserve");
@@ -965,8 +970,30 @@ function doRescue(cfg, env, out, err, setTmp) {
965
970
  }
966
971
  return { status: 0 };
967
972
  }
968
- function queueAction(ctx, label, affectedRels, runAction) {
969
- ctx.actions.push({ label, affectedRels, run: runAction });
973
+ function recordDecision(ctx, decision) {
974
+ ctx.decisions.push(decision);
975
+ if (ctx.cfg.dryRun)
976
+ renderDryRunDecision(ctx, decision);
977
+ switch (decision.kind) {
978
+ case "drop-reindex-symlink":
979
+ ctx.counts.symlinkDropped += 1;
980
+ return;
981
+ case "user-only":
982
+ ctx.counts.userOnly += 1;
983
+ return;
984
+ case "cloud-symlink-reconciled":
985
+ ctx.counts.cloudSymlinkReconciled += 1;
986
+ return;
987
+ case "drift-reconciled":
988
+ ctx.counts.driftReconciled += 1;
989
+ return;
990
+ case "unchanged-preserve":
991
+ case "unchanged-delete":
992
+ ctx.counts.unchanged += 1;
993
+ return;
994
+ default:
995
+ return;
996
+ }
970
997
  }
971
998
  function applyClassifiedActions(ctx, backupDir) {
972
999
  const completed = [];
@@ -1117,18 +1144,18 @@ function isOverwriteSafe(rel) {
1117
1144
  rel === "core/docs/hq/USER-GUIDE.md" ||
1118
1145
  rel === "core/policies/_digest.md");
1119
1146
  }
1120
- function isMasterSyncSymlink(localPath) {
1147
+ function masterSyncSymlinkTarget(localPath) {
1121
1148
  const st = lstatOrNull(localPath);
1122
1149
  if (!st || !st.isSymbolicLink())
1123
- return false;
1150
+ return null;
1124
1151
  let tgt = "";
1125
1152
  try {
1126
1153
  tgt = fs.readlinkSync(localPath);
1127
1154
  }
1128
1155
  catch {
1129
- return false;
1156
+ return null;
1130
1157
  }
1131
- return tgt.includes("/personal/") || tgt.startsWith("personal/");
1158
+ return tgt.includes("/personal/") || tgt.startsWith("personal/") ? tgt : null;
1132
1159
  }
1133
1160
  function isUnderPreserve(cfg, rel) {
1134
1161
  for (const sp of cfg.preserveSubpaths) {
@@ -1380,14 +1407,13 @@ function conflictOne(ctx, rel) {
1380
1407
  ctx.out(` conflicted: ${rel} -> ${destRel}\n`);
1381
1408
  ctx.appendLog(`conflicted\t${rel}\t-> ${destRel}\n`);
1382
1409
  }
1383
- // --- classify + act on one file (the per-file workhorse) ---
1410
+ // --- classify one file (the per-file workhorse) ---
1384
1411
  //
1385
- // Read-only by contract: this records intent (counts + dry-run plan lines +
1386
- // deferred `ctx.actions` closures) but never mutates the filesystem itself. The
1387
- // closures are run later by the PHASE 2 apply loop, only once the whole wipe
1388
- // set has classified without throwing. Keep it that way — a stray `fs.rmSync`
1389
- // here re-opens the half-applied-wipe hole this split closed (DEV-1767).
1390
- function processOne(ctx, rel) {
1412
+ // Read-only by contract: this returns a RescueDecision only. It does not render
1413
+ // dry-run text, update action queues, or mutate the filesystem. Dry-run text is
1414
+ // rendered by recordDecision, and PHASE 2 action building happens only once the
1415
+ // whole wipe set has classified without throwing. Keep it that way.
1416
+ function classifyOne(ctx, rel) {
1391
1417
  const { cfg } = ctx;
1392
1418
  const localPath = path.join(ctx.hqRoot, rel);
1393
1419
  const srcPath = path.join(ctx.srcDir, rel);
@@ -1397,51 +1423,24 @@ function processOne(ctx, rel) {
1397
1423
  throw new Error(`rescue classifier fault injected at ${rel} (HQ_RESCUE_FAULT_AT_REL)`);
1398
1424
  }
1399
1425
  if (isUnderPreserve(cfg, rel))
1400
- return;
1426
+ return { kind: "skip", rel };
1401
1427
  // Conflict-resolution artifacts (`<name>.conflict-<ts>-<peer>.<ext>`).
1402
1428
  const base = rel.includes("/") ? rel.slice(rel.lastIndexOf("/") + 1) : rel;
1403
1429
  if (/\.conflict-/.test(base)) {
1404
- if (cfg.dryRun) {
1405
- ctx.out(` drop conflict artifact: ${rel}\n`);
1406
- }
1407
- else {
1408
- queueAction(ctx, `drop conflict artifact: ${rel}`, [rel], () => fs.rmSync(localPath, { force: true }));
1409
- }
1410
- return;
1430
+ return { kind: "drop-conflict-artifact", rel };
1411
1431
  }
1412
1432
  // Script-managed files: core/core.yaml + legacy core.yaml.
1413
1433
  if (rel === "core/core.yaml" || rel === "core.yaml") {
1414
- if (cfg.dryRun) {
1415
- ctx.out(` skip script-managed (rewrites at stamp step): ${rel}\n`);
1416
- }
1417
- else {
1418
- queueAction(ctx, `drop script-managed: ${rel}`, [rel], () => fs.rmSync(localPath, { force: true }));
1419
- }
1420
- return;
1434
+ return { kind: "drop-script-managed", rel };
1421
1435
  }
1422
1436
  // Symlinks (mid-tree).
1423
1437
  const lst = lstatOrNull(localPath);
1424
1438
  if (lst && lst.isSymbolicLink()) {
1425
- if (isMasterSyncSymlink(localPath)) {
1426
- ctx.counts.symlinkDropped += 1;
1427
- if (cfg.dryRun) {
1428
- let tgt = "";
1429
- try {
1430
- tgt = fs.readlinkSync(localPath);
1431
- }
1432
- catch {
1433
- /* ignore */
1434
- }
1435
- ctx.out(` drop reindex symlink: ${rel} -> ${tgt}\n`);
1436
- }
1437
- else {
1438
- queueAction(ctx, `drop reindex symlink: ${rel}`, [rel], () => {
1439
- fs.rmSync(localPath, { force: true });
1440
- ctx.appendLog(`symlink-dropped\t${rel}\t(reindex regenerable)\n`);
1441
- });
1442
- }
1439
+ const target = masterSyncSymlinkTarget(localPath);
1440
+ if (target !== null) {
1441
+ return { kind: "drop-reindex-symlink", rel, target };
1443
1442
  }
1444
- return;
1443
+ return { kind: "skip", rel };
1445
1444
  }
1446
1445
  // Is path in upstream HEAD?
1447
1446
  const inHead = existsFollow(srcPath) ? 1 : 0;
@@ -1468,10 +1467,7 @@ function processOne(ctx, rel) {
1468
1467
  }
1469
1468
  // USER-ONLY: unknown to upstream (HEAD AND floor both lack it).
1470
1469
  if (inHead === 0 && inFloor === 0) {
1471
- ctx.counts.userOnly += 1;
1472
- if (cfg.dryRun)
1473
- ctx.out(` user-only (leave in place): ${rel}\n`);
1474
- return;
1470
+ return { kind: "user-only", rel };
1475
1471
  }
1476
1472
  // Path is/was in upstream. Determine if user edited it.
1477
1473
  let userEdited = 0;
@@ -1496,99 +1492,154 @@ function processOne(ctx, rel) {
1496
1492
  }
1497
1493
  // Cloud-update reclassification.
1498
1494
  if (userEdited === 1 && isCloudFlattenedSymlinkEquiv(ctx, rel, localPath)) {
1499
- ctx.counts.cloudSymlinkReconciled += 1;
1500
- if (cfg.dryRun) {
1501
- ctx.out(` cloud-symlink reconciled (unchanged): ${rel} (hq-symlink: marker matches upstream target)\n`);
1502
- }
1503
- else {
1504
- queueAction(ctx, `cloud-symlink reconciled: ${rel}`, [rel], () => {
1505
- fs.rmSync(localPath, { force: true });
1506
- ctx.appendLog(`cloud-symlink-reconciled\t${rel}\t(hq-symlink: marker matches upstream target; overlay re-lays symlink)\n`);
1507
- });
1508
- }
1509
- return;
1495
+ return { kind: "cloud-symlink-reconciled", rel };
1510
1496
  }
1511
1497
  // Convergence guard: drifted from floor but identical to upstream HEAD.
1512
1498
  if (userEdited === 1 && inHead === 1 && bytesEqual(localPath, srcPath)) {
1513
- ctx.counts.driftReconciled += 1;
1514
- if (cfg.dryRun) {
1515
- ctx.out(` drift reconciled (identical to upstream HEAD; no rescue): ${rel}\n`);
1516
- }
1517
- else {
1518
- queueAction(ctx, `drift reconciled: ${rel}`, [rel], () => {
1519
- fs.rmSync(localPath, { force: true });
1520
- ctx.appendLog(`drift-reconciled\t${rel}\t(identical to upstream HEAD; drifted from floor only — no personal/ copy)\n`);
1521
- });
1522
- }
1523
- return;
1499
+ return { kind: "drift-reconciled", rel };
1524
1500
  }
1525
1501
  if (userEdited === 1) {
1526
- if (cfg.dryRun) {
1527
- if (rel === ".claude/CLAUDE.md") {
1528
- ctx.out(` user-edit (diff-append): ${rel} -> personal/CLAUDE.md\n`);
1529
- ctx.counts.userEdit += 1;
1530
- }
1531
- else if (isOverwriteSafe(rel)) {
1532
- ctx.out(` user-edit (overwrite-safe): ${rel} -> upstream wins (no copy preserved)\n`);
1533
- ctx.counts.userOverwrite += 1;
1534
- }
1535
- else if (isConflictClass(rel)) {
1536
- ctx.out(` user-edit (conflict): ${rel} -> .hq-conflicts/rescue-${ctx.runTs}/${rel}\n`);
1537
- ctx.counts.userConflict += 1;
1538
- }
1539
- else {
1540
- ctx.out(` user-edit (rescue): ${rel} -> ${mapRescueTarget(rel)}\n`);
1541
- ctx.counts.userEdit += 1;
1542
- }
1502
+ if (rel === ".claude/CLAUDE.md") {
1503
+ return { kind: "user-edit-diff-append", rel };
1543
1504
  }
1544
- else {
1545
- if (rel === ".claude/CLAUDE.md") {
1546
- queueAction(ctx, `rescue diff-append: ${rel}`, [rel], () => rescueOne(ctx, rel));
1547
- }
1548
- else if (isOverwriteSafe(rel)) {
1549
- queueAction(ctx, `overwrite-safe: ${rel}`, [rel], () => {
1505
+ if (isOverwriteSafe(rel)) {
1506
+ return { kind: "user-edit-overwrite-safe", rel };
1507
+ }
1508
+ if (isConflictClass(rel)) {
1509
+ return { kind: "user-edit-conflict", rel };
1510
+ }
1511
+ return { kind: "user-edit-rescue", rel, target: mapRescueTarget(rel) };
1512
+ }
1513
+ // user_edited=0: matches floor baseline. Split on byte-equality to HEAD.
1514
+ if (bytesEqual(localPath, path.join(ctx.srcDir, rel))) {
1515
+ return { kind: "unchanged-preserve", rel };
1516
+ }
1517
+ return { kind: "unchanged-delete", rel };
1518
+ }
1519
+ function renderDryRunDecision(ctx, decision) {
1520
+ switch (decision.kind) {
1521
+ case "skip":
1522
+ break;
1523
+ case "drop-conflict-artifact":
1524
+ ctx.out(` drop conflict artifact: ${decision.rel}\n`);
1525
+ break;
1526
+ case "drop-script-managed":
1527
+ ctx.out(` skip script-managed (rewrites at stamp step): ${decision.rel}\n`);
1528
+ break;
1529
+ case "drop-reindex-symlink":
1530
+ ctx.out(` drop reindex symlink: ${decision.rel} -> ${decision.target}\n`);
1531
+ break;
1532
+ case "user-only":
1533
+ ctx.out(` user-only (leave in place): ${decision.rel}\n`);
1534
+ break;
1535
+ case "cloud-symlink-reconciled":
1536
+ ctx.out(` cloud-symlink reconciled (unchanged): ${decision.rel} (hq-symlink: marker matches upstream target)\n`);
1537
+ break;
1538
+ case "drift-reconciled":
1539
+ ctx.out(` drift reconciled (identical to upstream HEAD; no rescue): ${decision.rel}\n`);
1540
+ break;
1541
+ case "user-edit-diff-append":
1542
+ ctx.out(` user-edit (diff-append): ${decision.rel} -> personal/CLAUDE.md\n`);
1543
+ ctx.counts.userEdit += 1;
1544
+ break;
1545
+ case "user-edit-overwrite-safe":
1546
+ ctx.out(` user-edit (overwrite-safe): ${decision.rel} -> upstream wins (no copy preserved)\n`);
1547
+ ctx.counts.userOverwrite += 1;
1548
+ break;
1549
+ case "user-edit-conflict":
1550
+ ctx.out(` user-edit (conflict): ${decision.rel} -> .hq-conflicts/rescue-${ctx.runTs}/${decision.rel}\n`);
1551
+ ctx.counts.userConflict += 1;
1552
+ break;
1553
+ case "user-edit-rescue":
1554
+ ctx.out(` user-edit (rescue): ${decision.rel} -> ${decision.target}\n`);
1555
+ ctx.counts.userEdit += 1;
1556
+ break;
1557
+ case "unchanged-preserve":
1558
+ ctx.out(` unchanged (preserved in place): ${decision.rel}\n`);
1559
+ break;
1560
+ case "unchanged-delete":
1561
+ ctx.out(` unchanged (delete + replace): ${decision.rel}\n`);
1562
+ break;
1563
+ case "wholesale-replace-template":
1564
+ ctx.out(" wholesale-replace: companies/_template (template carve-out)\n");
1565
+ break;
1566
+ }
1567
+ }
1568
+ function buildRescueActions(ctx) {
1569
+ const actions = [];
1570
+ const add = (label, affectedRels, runAction) => {
1571
+ actions.push({ label, affectedRels, run: runAction });
1572
+ };
1573
+ for (const decision of ctx.decisions) {
1574
+ const localPath = path.join(ctx.hqRoot, decision.rel);
1575
+ switch (decision.kind) {
1576
+ case "skip":
1577
+ case "user-only":
1578
+ break;
1579
+ case "drop-conflict-artifact":
1580
+ add(`drop conflict artifact: ${decision.rel}`, [decision.rel], () => fs.rmSync(localPath, { force: true }));
1581
+ break;
1582
+ case "drop-script-managed":
1583
+ add(`drop script-managed: ${decision.rel}`, [decision.rel], () => fs.rmSync(localPath, { force: true }));
1584
+ break;
1585
+ case "drop-reindex-symlink":
1586
+ add(`drop reindex symlink: ${decision.rel}`, [decision.rel], () => {
1587
+ fs.rmSync(localPath, { force: true });
1588
+ ctx.appendLog(`symlink-dropped\t${decision.rel}\t(reindex regenerable)\n`);
1589
+ });
1590
+ break;
1591
+ case "cloud-symlink-reconciled":
1592
+ add(`cloud-symlink reconciled: ${decision.rel}`, [decision.rel], () => {
1593
+ fs.rmSync(localPath, { force: true });
1594
+ ctx.appendLog(`cloud-symlink-reconciled\t${decision.rel}\t(hq-symlink: marker matches upstream target; overlay re-lays symlink)\n`);
1595
+ });
1596
+ break;
1597
+ case "drift-reconciled":
1598
+ add(`drift reconciled: ${decision.rel}`, [decision.rel], () => {
1599
+ fs.rmSync(localPath, { force: true });
1600
+ ctx.appendLog(`drift-reconciled\t${decision.rel}\t(identical to upstream HEAD; drifted from floor only — no personal/ copy)\n`);
1601
+ });
1602
+ break;
1603
+ case "user-edit-diff-append":
1604
+ add(`rescue diff-append: ${decision.rel}`, [decision.rel], () => rescueOne(ctx, decision.rel));
1605
+ break;
1606
+ case "user-edit-overwrite-safe":
1607
+ add(`overwrite-safe: ${decision.rel}`, [decision.rel], () => {
1550
1608
  fs.rmSync(localPath, { force: true });
1551
1609
  ctx.counts.userOverwrite += 1;
1552
- ctx.appendLog(`overwritten\t${rel}\t(overwrite-safe; upstream wins, no copy preserved)\n`);
1610
+ ctx.appendLog(`overwritten\t${decision.rel}\t(overwrite-safe; upstream wins, no copy preserved)\n`);
1553
1611
  });
1554
- }
1555
- else if (isConflictClass(rel)) {
1556
- queueAction(ctx, `conflict quarantine: ${rel}`, [rel], () => conflictOne(ctx, rel));
1557
- }
1558
- else {
1559
- queueAction(ctx, `rescue: ${rel}`, [rel], () => rescueOne(ctx, rel));
1560
- }
1561
- }
1562
- }
1563
- else {
1564
- // user_edited=0: matches floor baseline. Split on byte-equality to HEAD.
1565
- ctx.counts.unchanged += 1;
1566
- if (bytesEqual(localPath, path.join(ctx.srcDir, rel))) {
1567
- if (cfg.dryRun) {
1568
- ctx.out(` unchanged (preserved in place): ${rel}\n`);
1569
- }
1570
- else {
1571
- queueAction(ctx, `preserve unchanged: ${rel}`, [], () => {
1572
- fs.appendFileSync(ctx.unchangedList, `/${rel}\n`);
1573
- ctx.appendLog(`unchanged\t${rel}\t(identical to upstream; left in place, mtime preserved)\n`);
1612
+ break;
1613
+ case "user-edit-conflict":
1614
+ add(`conflict quarantine: ${decision.rel}`, [decision.rel], () => conflictOne(ctx, decision.rel));
1615
+ break;
1616
+ case "user-edit-rescue":
1617
+ add(`rescue: ${decision.rel}`, [decision.rel], () => rescueOne(ctx, decision.rel));
1618
+ break;
1619
+ case "unchanged-preserve":
1620
+ add(`preserve unchanged: ${decision.rel}`, [], () => {
1621
+ fs.appendFileSync(ctx.unchangedList, `/${decision.rel}\n`);
1622
+ ctx.appendLog(`unchanged\t${decision.rel}\t(identical to upstream; left in place, mtime preserved)\n`);
1574
1623
  });
1575
- }
1576
- }
1577
- else {
1578
- if (cfg.dryRun) {
1579
- ctx.out(` unchanged (delete + replace): ${rel}\n`);
1580
- }
1581
- else {
1582
- queueAction(ctx, `delete unchanged: ${rel}`, [rel], () => {
1624
+ break;
1625
+ case "unchanged-delete":
1626
+ add(`delete unchanged: ${decision.rel}`, [decision.rel], () => {
1583
1627
  fs.rmSync(localPath, { force: true });
1584
- ctx.appendLog(`deleted\t${rel}\t(unchanged vs baseline; re-laid by overlay if still upstream)\n`);
1628
+ ctx.appendLog(`deleted\t${decision.rel}\t(unchanged vs baseline; re-laid by overlay if still upstream)\n`);
1585
1629
  });
1586
- }
1630
+ break;
1631
+ case "wholesale-replace-template":
1632
+ add("wholesale-replace: companies/_template", [decision.rel], () => fs.rmSync(path.join(ctx.hqRoot, "companies", "_template"), {
1633
+ recursive: true,
1634
+ force: true,
1635
+ }));
1636
+ break;
1587
1637
  }
1588
1638
  }
1639
+ return actions;
1589
1640
  }
1590
1641
  // --- walk a wipe-set root ---
1591
- // Precompute the per-file git facts processOne needs, in two batched passes
1642
+ // Precompute the per-file git facts classifyOne needs, in two batched passes
1592
1643
  // instead of ~3 `git` spawns per file. Process-startup overhead (~15-20ms per
1593
1644
  // spawn) dominates the per-file path, so on a large wipe set this turns a
1594
1645
  // minutes-long classify into a seconds-long one. Behaviour is identical: the
@@ -1596,10 +1647,10 @@ function processOne(ctx, rel) {
1596
1647
  // rel not represented in a map falls back to its original per-file spawn.
1597
1648
  //
1598
1649
  // Mirrors walkAndProcess's enumeration so the batched set matches what actually
1599
- // reaches processOne: it skips `companies/_template` (wholesale-replaced, never
1650
+ // reaches classifyOne: it skips `companies/_template` (wholesale-replaced, never
1600
1651
  // classified per-file) and top-level symlinks (dropped, never classified).
1601
1652
  function precomputeGitMaps(ctx, wipeToplevel) {
1602
- const allRels = []; // every rel that reaches processOne (files + symlinks)
1653
+ const allRels = []; // every rel that reaches classifyOne (files + symlinks)
1603
1654
  const fileRels = []; // subset that are regular files (hashable)
1604
1655
  const fileAbs = []; // parallel to fileRels
1605
1656
  for (const rootRel of wipeToplevel) {
@@ -1616,7 +1667,7 @@ function precomputeGitMaps(ctx, wipeToplevel) {
1616
1667
  : [];
1617
1668
  for (const rel of rels) {
1618
1669
  // A path containing a newline can't survive line-delimited batch stdin;
1619
- // leave it out of the maps and let processOne spawn per-file for it.
1670
+ // leave it out of the maps and let classifyOne spawn per-file for it.
1620
1671
  if (rel.includes("\n"))
1621
1672
  continue;
1622
1673
  allRels.push(rel);
@@ -1627,7 +1678,7 @@ function precomputeGitMaps(ctx, wipeToplevel) {
1627
1678
  }
1628
1679
  }
1629
1680
  }
1630
- // In head_compare mode processOne never consults these maps (it compares
1681
+ // In head_compare mode classifyOne never consults these maps (it compares
1631
1682
  // bytes against the upstream working tree directly), so skip both git passes.
1632
1683
  if (ctx.baselineMode !== "history_floor") {
1633
1684
  ctx.floorShas = new Map();
@@ -1663,7 +1714,7 @@ function precomputeGitMaps(ctx, wipeToplevel) {
1663
1714
  }
1664
1715
  ctx.floorShas = floorShas;
1665
1716
  // Pass 2: local blob SHA, but ONLY for regular files that are in the floor.
1666
- // processOne reads localShas exclusively in the `inFloor === 1` branch, so a
1717
+ // classifyOne reads localShas exclusively in the `inFloor === 1` branch, so a
1667
1718
  // file absent from the floor (e.g. the transient `.claude/worktrees/` trees,
1668
1719
  // which classify as user-only) never needs a hash — and hashing it would mean
1669
1720
  // reading its bytes for nothing. Restricting the hash set to floor members is
@@ -1694,55 +1745,31 @@ function precomputeGitMaps(ctx, wipeToplevel) {
1694
1745
  ctx.localShas = localShas;
1695
1746
  }
1696
1747
  function walkAndProcess(ctx, rootRel) {
1697
- const { cfg } = ctx;
1698
1748
  const rootAbs = path.join(ctx.hqRoot, rootRel);
1699
1749
  if (!lexists(rootAbs))
1700
1750
  return;
1701
1751
  // companies/_template — wholesale-replace.
1702
1752
  if (rootRel === "companies/_template") {
1703
- if (cfg.dryRun) {
1704
- ctx.out(" wholesale-replace: companies/_template (template carve-out)\n");
1705
- }
1706
- else {
1707
- queueAction(ctx, "wholesale-replace: companies/_template", [rootRel], () => fs.rmSync(path.join(ctx.hqRoot, "companies", "_template"), {
1708
- recursive: true,
1709
- force: true,
1710
- }));
1711
- }
1753
+ recordDecision(ctx, { kind: "wholesale-replace-template", rel: "companies/_template" });
1712
1754
  return;
1713
1755
  }
1714
1756
  const lst = lstatOrNull(rootAbs);
1715
1757
  // Top-level symlink.
1716
1758
  if (lst && lst.isSymbolicLink()) {
1717
- if (isMasterSyncSymlink(rootAbs)) {
1718
- ctx.counts.symlinkDropped += 1;
1719
- if (cfg.dryRun) {
1720
- let tgt = "";
1721
- try {
1722
- tgt = fs.readlinkSync(rootAbs);
1723
- }
1724
- catch {
1725
- /* ignore */
1726
- }
1727
- ctx.out(` drop reindex symlink: ${rootRel} -> ${tgt}\n`);
1728
- }
1729
- else {
1730
- queueAction(ctx, `drop reindex symlink: ${rootRel}`, [rootRel], () => {
1731
- fs.rmSync(rootAbs, { force: true });
1732
- ctx.appendLog(`symlink-dropped\t${rootRel}\t(reindex regenerable)\n`);
1733
- });
1734
- }
1759
+ const target = masterSyncSymlinkTarget(rootAbs);
1760
+ if (target !== null) {
1761
+ recordDecision(ctx, { kind: "drop-reindex-symlink", rel: rootRel, target });
1735
1762
  }
1736
1763
  return;
1737
1764
  }
1738
1765
  // Top-level regular file.
1739
1766
  if (lst && lst.isFile()) {
1740
- processOne(ctx, rootRel);
1767
+ recordDecision(ctx, classifyOne(ctx, rootRel));
1741
1768
  return;
1742
1769
  }
1743
1770
  // Top-level directory: recursive walk, pruning node_modules + nested .git.
1744
1771
  for (const rel of findFilesAndSymlinks(rootAbs, ctx.hqRoot)) {
1745
- processOne(ctx, rel);
1772
+ recordDecision(ctx, classifyOne(ctx, rel));
1746
1773
  }
1747
1774
  }
1748
1775
  /**