@indigoai-us/hq-cloud 6.11.11 → 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 (220) 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 +5 -54
  30. package/dist/bin/sync-runner.d.ts.map +1 -1
  31. package/dist/bin/sync-runner.js +76 -978
  32. package/dist/bin/sync-runner.js.map +1 -1
  33. package/dist/bin/sync-runner.test.js +265 -11
  34. package/dist/bin/sync-runner.test.js.map +1 -1
  35. package/dist/cli/reindex.d.ts.map +1 -1
  36. package/dist/cli/reindex.js +34 -17
  37. package/dist/cli/reindex.js.map +1 -1
  38. package/dist/cli/reindex.test.js +39 -5
  39. package/dist/cli/reindex.test.js.map +1 -1
  40. package/dist/cli/rescue-classify-ordering.test.js +75 -0
  41. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  42. package/dist/cli/rescue-core.d.ts +45 -0
  43. package/dist/cli/rescue-core.d.ts.map +1 -1
  44. package/dist/cli/rescue-core.js +320 -170
  45. package/dist/cli/rescue-core.js.map +1 -1
  46. package/dist/cli/share.d.ts +2 -1
  47. package/dist/cli/share.d.ts.map +1 -1
  48. package/dist/cli/share.js +276 -660
  49. package/dist/cli/share.js.map +1 -1
  50. package/dist/cli/share.test.js +30 -0
  51. package/dist/cli/share.test.js.map +1 -1
  52. package/dist/cli/sync.d.ts +28 -1
  53. package/dist/cli/sync.d.ts.map +1 -1
  54. package/dist/cli/sync.js +541 -748
  55. package/dist/cli/sync.js.map +1 -1
  56. package/dist/cli/sync.test.js +382 -1
  57. package/dist/cli/sync.test.js.map +1 -1
  58. package/dist/cognito-auth.d.ts.map +1 -1
  59. package/dist/cognito-auth.js +55 -10
  60. package/dist/cognito-auth.js.map +1 -1
  61. package/dist/cognito-auth.test.js +61 -0
  62. package/dist/cognito-auth.test.js.map +1 -1
  63. package/dist/daemon-worker.d.ts +2 -2
  64. package/dist/daemon-worker.js +3 -3
  65. package/dist/daemon-worker.js.map +1 -1
  66. package/dist/index.d.ts +2 -1
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +1 -1
  69. package/dist/index.js.map +1 -1
  70. package/dist/journal.d.ts.map +1 -1
  71. package/dist/journal.js +93 -6
  72. package/dist/journal.js.map +1 -1
  73. package/dist/journal.test.js +59 -0
  74. package/dist/journal.test.js.map +1 -1
  75. package/dist/machine-auth.test.js +60 -2
  76. package/dist/machine-auth.test.js.map +1 -1
  77. package/dist/object-io.d.ts +37 -1
  78. package/dist/object-io.d.ts.map +1 -1
  79. package/dist/object-io.js +149 -30
  80. package/dist/object-io.js.map +1 -1
  81. package/dist/object-io.test.js +121 -0
  82. package/dist/object-io.test.js.map +1 -1
  83. package/dist/operation-lock.d.ts +8 -8
  84. package/dist/operation-lock.d.ts.map +1 -1
  85. package/dist/operation-lock.js +99 -32
  86. package/dist/operation-lock.js.map +1 -1
  87. package/dist/operation-lock.test.js +51 -4
  88. package/dist/operation-lock.test.js.map +1 -1
  89. package/dist/personal-vault.d.ts.map +1 -1
  90. package/dist/personal-vault.js +8 -2
  91. package/dist/personal-vault.js.map +1 -1
  92. package/dist/personal-vault.test.js +34 -0
  93. package/dist/personal-vault.test.js.map +1 -1
  94. package/dist/prefix-coalesce.d.ts +20 -9
  95. package/dist/prefix-coalesce.d.ts.map +1 -1
  96. package/dist/prefix-coalesce.js +124 -28
  97. package/dist/prefix-coalesce.js.map +1 -1
  98. package/dist/prefix-coalesce.test.js +57 -2
  99. package/dist/prefix-coalesce.test.js.map +1 -1
  100. package/dist/remote-pull.d.ts +8 -3
  101. package/dist/remote-pull.d.ts.map +1 -1
  102. package/dist/remote-pull.js +85 -16
  103. package/dist/remote-pull.js.map +1 -1
  104. package/dist/remote-pull.test.js +213 -2
  105. package/dist/remote-pull.test.js.map +1 -1
  106. package/dist/s3.d.ts +2 -0
  107. package/dist/s3.d.ts.map +1 -1
  108. package/dist/s3.js +197 -116
  109. package/dist/s3.js.map +1 -1
  110. package/dist/s3.test.js +109 -0
  111. package/dist/s3.test.js.map +1 -1
  112. package/dist/scope-shrink.d.ts +3 -2
  113. package/dist/scope-shrink.d.ts.map +1 -1
  114. package/dist/scope-shrink.js +1 -1
  115. package/dist/scope-shrink.js.map +1 -1
  116. package/dist/skill-telemetry.d.ts +1 -1
  117. package/dist/skill-telemetry.d.ts.map +1 -1
  118. package/dist/skill-telemetry.js +69 -9
  119. package/dist/skill-telemetry.js.map +1 -1
  120. package/dist/skill-telemetry.test.js +86 -0
  121. package/dist/skill-telemetry.test.js.map +1 -1
  122. package/dist/sync/event-sync.d.ts +6 -0
  123. package/dist/sync/event-sync.d.ts.map +1 -1
  124. package/dist/sync/event-sync.js +34 -1
  125. package/dist/sync/event-sync.js.map +1 -1
  126. package/dist/sync/event-sync.test.js +73 -0
  127. package/dist/sync/event-sync.test.js.map +1 -1
  128. package/dist/sync/metrics.d.ts +17 -1
  129. package/dist/sync/metrics.d.ts.map +1 -1
  130. package/dist/sync/metrics.js +32 -1
  131. package/dist/sync/metrics.js.map +1 -1
  132. package/dist/sync/metrics.test.js +74 -1
  133. package/dist/sync/metrics.test.js.map +1 -1
  134. package/dist/sync/pull-scope.d.ts.map +1 -1
  135. package/dist/sync/pull-scope.js +15 -7
  136. package/dist/sync/pull-scope.js.map +1 -1
  137. package/dist/sync/push-receiver.d.ts +12 -5
  138. package/dist/sync/push-receiver.d.ts.map +1 -1
  139. package/dist/sync/push-receiver.js +45 -17
  140. package/dist/sync/push-receiver.js.map +1 -1
  141. package/dist/sync/push-receiver.test.js +67 -1
  142. package/dist/sync/push-receiver.test.js.map +1 -1
  143. package/dist/sync-core.d.ts +27 -0
  144. package/dist/sync-core.d.ts.map +1 -0
  145. package/dist/sync-core.js +54 -0
  146. package/dist/sync-core.js.map +1 -0
  147. package/dist/telemetry.d.ts +1 -1
  148. package/dist/telemetry.d.ts.map +1 -1
  149. package/dist/telemetry.js +59 -6
  150. package/dist/telemetry.js.map +1 -1
  151. package/dist/telemetry.test.js +74 -0
  152. package/dist/telemetry.test.js.map +1 -1
  153. package/dist/types.d.ts +8 -0
  154. package/dist/types.d.ts.map +1 -1
  155. package/dist/vault-client.d.ts.map +1 -1
  156. package/dist/vault-client.js +284 -36
  157. package/dist/vault-client.js.map +1 -1
  158. package/dist/vault-client.test.js +59 -0
  159. package/dist/vault-client.test.js.map +1 -1
  160. package/dist/watcher.d.ts +38 -20
  161. package/dist/watcher.d.ts.map +1 -1
  162. package/dist/watcher.js +155 -143
  163. package/dist/watcher.js.map +1 -1
  164. package/dist/watcher.test.js +103 -0
  165. package/dist/watcher.test.js.map +1 -1
  166. package/package.json +1 -1
  167. package/src/bin/sync-runner-company.ts +350 -0
  168. package/src/bin/sync-runner-events.ts +25 -0
  169. package/src/bin/sync-runner-planning.ts +121 -0
  170. package/src/bin/sync-runner-rollup.ts +72 -0
  171. package/src/bin/sync-runner-telemetry.ts +8 -0
  172. package/src/bin/sync-runner-watch-loop.ts +443 -0
  173. package/src/bin/sync-runner-watch-routes.ts +86 -0
  174. package/src/bin/sync-runner.test.ts +298 -11
  175. package/src/bin/sync-runner.ts +99 -1054
  176. package/src/cli/reindex.test.ts +41 -3
  177. package/src/cli/reindex.ts +35 -19
  178. package/src/cli/rescue-classify-ordering.test.ts +81 -0
  179. package/src/cli/rescue-core.ts +400 -165
  180. package/src/cli/share.test.ts +38 -0
  181. package/src/cli/share.ts +420 -693
  182. package/src/cli/sync.test.ts +460 -1
  183. package/src/cli/sync.ts +788 -825
  184. package/src/cognito-auth.test.ts +77 -0
  185. package/src/cognito-auth.ts +73 -11
  186. package/src/daemon-worker.ts +3 -3
  187. package/src/index.ts +8 -0
  188. package/src/journal.test.ts +72 -0
  189. package/src/journal.ts +95 -8
  190. package/src/machine-auth.test.ts +64 -2
  191. package/src/object-io.test.ts +142 -0
  192. package/src/object-io.ts +183 -31
  193. package/src/operation-lock.test.ts +63 -4
  194. package/src/operation-lock.ts +99 -31
  195. package/src/personal-vault.test.ts +42 -0
  196. package/src/personal-vault.ts +8 -2
  197. package/src/prefix-coalesce.test.ts +71 -1
  198. package/src/prefix-coalesce.ts +155 -30
  199. package/src/remote-pull.test.ts +235 -1
  200. package/src/remote-pull.ts +106 -18
  201. package/src/s3.test.ts +126 -0
  202. package/src/s3.ts +237 -122
  203. package/src/scope-shrink.ts +6 -3
  204. package/src/skill-telemetry.test.ts +109 -0
  205. package/src/skill-telemetry.ts +82 -14
  206. package/src/sync/event-sync.test.ts +75 -0
  207. package/src/sync/event-sync.ts +54 -1
  208. package/src/sync/metrics.test.ts +81 -0
  209. package/src/sync/metrics.ts +59 -4
  210. package/src/sync/pull-scope.ts +23 -7
  211. package/src/sync/push-receiver.test.ts +73 -1
  212. package/src/sync/push-receiver.ts +56 -20
  213. package/src/sync-core.ts +58 -0
  214. package/src/telemetry.test.ts +85 -0
  215. package/src/telemetry.ts +69 -6
  216. package/src/types.ts +8 -0
  217. package/src/vault-client.test.ts +74 -0
  218. package/src/vault-client.ts +395 -43
  219. package/src/watcher.test.ts +117 -0
  220. package/src/watcher.ts +215 -174
@@ -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,8 +738,8 @@ 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.
737
- for (const act of ctx.actions)
738
- act();
741
+ ctx.actions = buildRescueActions(ctx);
742
+ applyClassifiedActions(ctx, backupDir);
739
743
  // --- Back up preserve-subpaths to a mktemp shuttle ---
740
744
  const shuttle = path.join(tmpdir, "preserve");
741
745
  fs.mkdirSync(shuttle, { recursive: true });
@@ -966,6 +970,152 @@ function doRescue(cfg, env, out, err, setTmp) {
966
970
  }
967
971
  return { status: 0 };
968
972
  }
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
+ }
997
+ }
998
+ function applyClassifiedActions(ctx, backupDir) {
999
+ const completed = [];
1000
+ for (const action of ctx.actions) {
1001
+ try {
1002
+ action.run();
1003
+ completed.push(action);
1004
+ }
1005
+ catch (err) {
1006
+ const restored = rollbackCompletedActions(ctx, completed, backupDir);
1007
+ const manifest = writeApplyFailureManifest(ctx, backupDir, action, completed, restored, err);
1008
+ ctx.out("\n");
1009
+ ctx.out(`error: rescue apply failed during '${action.label}'.\n`);
1010
+ if (restored.length > 0) {
1011
+ ctx.out(`==> Rolled back ${restored.length} prior action path(s) from the safety snapshot.\n`);
1012
+ }
1013
+ if (manifest) {
1014
+ ctx.out(`==> Recovery manifest: ${manifest}\n`);
1015
+ }
1016
+ throw err;
1017
+ }
1018
+ }
1019
+ }
1020
+ function rollbackCompletedActions(ctx, completed, backupDir) {
1021
+ if (!backupDir || !isDir(backupDir))
1022
+ return [];
1023
+ const rels = [];
1024
+ const seen = new Set();
1025
+ for (let i = completed.length - 1; i >= 0; i--) {
1026
+ const action = completed[i];
1027
+ for (let j = action.affectedRels.length - 1; j >= 0; j--) {
1028
+ const rel = action.affectedRels[j];
1029
+ if (rel && !seen.has(rel)) {
1030
+ seen.add(rel);
1031
+ rels.push(rel);
1032
+ }
1033
+ }
1034
+ }
1035
+ const restored = [];
1036
+ for (const rel of rels) {
1037
+ const snapshotPath = path.join(backupDir, rel);
1038
+ if (!lexists(snapshotPath))
1039
+ continue;
1040
+ const dest = path.join(ctx.hqRoot, rel);
1041
+ try {
1042
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
1043
+ fs.rmSync(dest, { recursive: true, force: true });
1044
+ cpATo(snapshotPath, dest);
1045
+ restored.push(rel);
1046
+ }
1047
+ catch {
1048
+ // Recovery manifest below records any path that could not be restored.
1049
+ }
1050
+ }
1051
+ return restored;
1052
+ }
1053
+ function writeApplyFailureManifest(ctx, backupDir, failedAction, completed, restored, err) {
1054
+ const message = err instanceof Error ? err.message : String(err);
1055
+ const completedLines = completed.length === 0
1056
+ ? [" (none)"]
1057
+ : completed.map((action) => {
1058
+ const paths = action.affectedRels.length > 0 ? action.affectedRels.join(", ") : "(no filesystem path)";
1059
+ return ` - ${action.label}: ${paths}`;
1060
+ });
1061
+ const restoredSet = new Set(restored);
1062
+ const unrestored = completed
1063
+ .flatMap((action) => action.affectedRels)
1064
+ .filter((rel, idx, arr) => rel && arr.indexOf(rel) === idx && !restoredSet.has(rel));
1065
+ const lines = [];
1066
+ lines.push("# HQ rescue apply recovery", "");
1067
+ lines.push(`Created: ${ctx.runTs}`);
1068
+ lines.push(`HQ root: ${ctx.hqRoot}`);
1069
+ lines.push(`Source: ${ctx.cfg.sourceRepo}@${ctx.cfg.ref} (${ctx.srcSha})`);
1070
+ lines.push(`Failed action: ${failedAction.label}`);
1071
+ lines.push(`Error: ${message}`);
1072
+ lines.push("");
1073
+ lines.push("## Completed Before Failure");
1074
+ lines.push(...completedLines);
1075
+ lines.push("");
1076
+ lines.push("## Failed Action Paths");
1077
+ if (failedAction.affectedRels.length > 0) {
1078
+ for (const rel of failedAction.affectedRels)
1079
+ lines.push(` - ${rel}`);
1080
+ }
1081
+ else {
1082
+ lines.push(" (no filesystem path)");
1083
+ }
1084
+ lines.push("");
1085
+ lines.push("## Rollback");
1086
+ if (restored.length > 0) {
1087
+ lines.push("Restored from the pre-op safety snapshot:");
1088
+ for (const rel of restored)
1089
+ lines.push(` - ${rel}`);
1090
+ }
1091
+ else {
1092
+ lines.push("No completed action paths were restored automatically.");
1093
+ }
1094
+ if (unrestored.length > 0) {
1095
+ lines.push("", "Completed action paths still requiring review:");
1096
+ for (const rel of unrestored)
1097
+ lines.push(` - ${rel}`);
1098
+ }
1099
+ if (backupDir && isDir(backupDir)) {
1100
+ lines.push("");
1101
+ lines.push("Manual restore examples:");
1102
+ lines.push(` cp "${backupDir}/<relpath>" "${ctx.hqRoot}/<relpath>"`);
1103
+ lines.push(` rsync -a "${backupDir}/" "${ctx.hqRoot}/"`);
1104
+ }
1105
+ else {
1106
+ lines.push("", "No pre-op safety snapshot was available for automatic restore.");
1107
+ }
1108
+ const manifestDir = path.join(ctx.hqRoot, ".hq", "rescue-recovery");
1109
+ const manifestPath = path.join(manifestDir, `rescue-apply-failure-${ctx.runTs}.md`);
1110
+ try {
1111
+ fs.mkdirSync(manifestDir, { recursive: true });
1112
+ fs.writeFileSync(manifestPath, lines.join("\n") + "\n");
1113
+ return manifestPath;
1114
+ }
1115
+ catch {
1116
+ return null;
1117
+ }
1118
+ }
969
1119
  // --- Rescue-target mapping ---
970
1120
  function mapRescueTarget(rel) {
971
1121
  if (rel === ".claude/CLAUDE.md")
@@ -994,18 +1144,18 @@ function isOverwriteSafe(rel) {
994
1144
  rel === "core/docs/hq/USER-GUIDE.md" ||
995
1145
  rel === "core/policies/_digest.md");
996
1146
  }
997
- function isMasterSyncSymlink(localPath) {
1147
+ function masterSyncSymlinkTarget(localPath) {
998
1148
  const st = lstatOrNull(localPath);
999
1149
  if (!st || !st.isSymbolicLink())
1000
- return false;
1150
+ return null;
1001
1151
  let tgt = "";
1002
1152
  try {
1003
1153
  tgt = fs.readlinkSync(localPath);
1004
1154
  }
1005
1155
  catch {
1006
- return false;
1156
+ return null;
1007
1157
  }
1008
- return tgt.includes("/personal/") || tgt.startsWith("personal/");
1158
+ return tgt.includes("/personal/") || tgt.startsWith("personal/") ? tgt : null;
1009
1159
  }
1010
1160
  function isUnderPreserve(cfg, rel) {
1011
1161
  for (const sp of cfg.preserveSubpaths) {
@@ -1257,14 +1407,13 @@ function conflictOne(ctx, rel) {
1257
1407
  ctx.out(` conflicted: ${rel} -> ${destRel}\n`);
1258
1408
  ctx.appendLog(`conflicted\t${rel}\t-> ${destRel}\n`);
1259
1409
  }
1260
- // --- classify + act on one file (the per-file workhorse) ---
1410
+ // --- classify one file (the per-file workhorse) ---
1261
1411
  //
1262
- // Read-only by contract: this records intent (counts + dry-run plan lines +
1263
- // deferred `ctx.actions` closures) but never mutates the filesystem itself. The
1264
- // closures are run later by the PHASE 2 apply loop, only once the whole wipe
1265
- // set has classified without throwing. Keep it that way — a stray `fs.rmSync`
1266
- // here re-opens the half-applied-wipe hole this split closed (DEV-1767).
1267
- 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) {
1268
1417
  const { cfg } = ctx;
1269
1418
  const localPath = path.join(ctx.hqRoot, rel);
1270
1419
  const srcPath = path.join(ctx.srcDir, rel);
@@ -1274,51 +1423,24 @@ function processOne(ctx, rel) {
1274
1423
  throw new Error(`rescue classifier fault injected at ${rel} (HQ_RESCUE_FAULT_AT_REL)`);
1275
1424
  }
1276
1425
  if (isUnderPreserve(cfg, rel))
1277
- return;
1426
+ return { kind: "skip", rel };
1278
1427
  // Conflict-resolution artifacts (`<name>.conflict-<ts>-<peer>.<ext>`).
1279
1428
  const base = rel.includes("/") ? rel.slice(rel.lastIndexOf("/") + 1) : rel;
1280
1429
  if (/\.conflict-/.test(base)) {
1281
- if (cfg.dryRun) {
1282
- ctx.out(` drop conflict artifact: ${rel}\n`);
1283
- }
1284
- else {
1285
- ctx.actions.push(() => fs.rmSync(localPath, { force: true }));
1286
- }
1287
- return;
1430
+ return { kind: "drop-conflict-artifact", rel };
1288
1431
  }
1289
1432
  // Script-managed files: core/core.yaml + legacy core.yaml.
1290
1433
  if (rel === "core/core.yaml" || rel === "core.yaml") {
1291
- if (cfg.dryRun) {
1292
- ctx.out(` skip script-managed (rewrites at stamp step): ${rel}\n`);
1293
- }
1294
- else {
1295
- ctx.actions.push(() => fs.rmSync(localPath, { force: true }));
1296
- }
1297
- return;
1434
+ return { kind: "drop-script-managed", rel };
1298
1435
  }
1299
1436
  // Symlinks (mid-tree).
1300
1437
  const lst = lstatOrNull(localPath);
1301
1438
  if (lst && lst.isSymbolicLink()) {
1302
- if (isMasterSyncSymlink(localPath)) {
1303
- ctx.counts.symlinkDropped += 1;
1304
- if (cfg.dryRun) {
1305
- let tgt = "";
1306
- try {
1307
- tgt = fs.readlinkSync(localPath);
1308
- }
1309
- catch {
1310
- /* ignore */
1311
- }
1312
- ctx.out(` drop reindex symlink: ${rel} -> ${tgt}\n`);
1313
- }
1314
- else {
1315
- ctx.actions.push(() => {
1316
- fs.rmSync(localPath, { force: true });
1317
- ctx.appendLog(`symlink-dropped\t${rel}\t(reindex regenerable)\n`);
1318
- });
1319
- }
1439
+ const target = masterSyncSymlinkTarget(localPath);
1440
+ if (target !== null) {
1441
+ return { kind: "drop-reindex-symlink", rel, target };
1320
1442
  }
1321
- return;
1443
+ return { kind: "skip", rel };
1322
1444
  }
1323
1445
  // Is path in upstream HEAD?
1324
1446
  const inHead = existsFollow(srcPath) ? 1 : 0;
@@ -1345,10 +1467,7 @@ function processOne(ctx, rel) {
1345
1467
  }
1346
1468
  // USER-ONLY: unknown to upstream (HEAD AND floor both lack it).
1347
1469
  if (inHead === 0 && inFloor === 0) {
1348
- ctx.counts.userOnly += 1;
1349
- if (cfg.dryRun)
1350
- ctx.out(` user-only (leave in place): ${rel}\n`);
1351
- return;
1470
+ return { kind: "user-only", rel };
1352
1471
  }
1353
1472
  // Path is/was in upstream. Determine if user edited it.
1354
1473
  let userEdited = 0;
@@ -1373,99 +1492,154 @@ function processOne(ctx, rel) {
1373
1492
  }
1374
1493
  // Cloud-update reclassification.
1375
1494
  if (userEdited === 1 && isCloudFlattenedSymlinkEquiv(ctx, rel, localPath)) {
1376
- ctx.counts.cloudSymlinkReconciled += 1;
1377
- if (cfg.dryRun) {
1378
- ctx.out(` cloud-symlink reconciled (unchanged): ${rel} (hq-symlink: marker matches upstream target)\n`);
1379
- }
1380
- else {
1381
- ctx.actions.push(() => {
1382
- fs.rmSync(localPath, { force: true });
1383
- ctx.appendLog(`cloud-symlink-reconciled\t${rel}\t(hq-symlink: marker matches upstream target; overlay re-lays symlink)\n`);
1384
- });
1385
- }
1386
- return;
1495
+ return { kind: "cloud-symlink-reconciled", rel };
1387
1496
  }
1388
1497
  // Convergence guard: drifted from floor but identical to upstream HEAD.
1389
1498
  if (userEdited === 1 && inHead === 1 && bytesEqual(localPath, srcPath)) {
1390
- ctx.counts.driftReconciled += 1;
1391
- if (cfg.dryRun) {
1392
- ctx.out(` drift reconciled (identical to upstream HEAD; no rescue): ${rel}\n`);
1393
- }
1394
- else {
1395
- ctx.actions.push(() => {
1396
- fs.rmSync(localPath, { force: true });
1397
- ctx.appendLog(`drift-reconciled\t${rel}\t(identical to upstream HEAD; drifted from floor only — no personal/ copy)\n`);
1398
- });
1399
- }
1400
- return;
1499
+ return { kind: "drift-reconciled", rel };
1401
1500
  }
1402
1501
  if (userEdited === 1) {
1403
- if (cfg.dryRun) {
1404
- if (rel === ".claude/CLAUDE.md") {
1405
- ctx.out(` user-edit (diff-append): ${rel} -> personal/CLAUDE.md\n`);
1406
- ctx.counts.userEdit += 1;
1407
- }
1408
- else if (isOverwriteSafe(rel)) {
1409
- ctx.out(` user-edit (overwrite-safe): ${rel} -> upstream wins (no copy preserved)\n`);
1410
- ctx.counts.userOverwrite += 1;
1411
- }
1412
- else if (isConflictClass(rel)) {
1413
- ctx.out(` user-edit (conflict): ${rel} -> .hq-conflicts/rescue-${ctx.runTs}/${rel}\n`);
1414
- ctx.counts.userConflict += 1;
1415
- }
1416
- else {
1417
- ctx.out(` user-edit (rescue): ${rel} -> ${mapRescueTarget(rel)}\n`);
1418
- ctx.counts.userEdit += 1;
1419
- }
1502
+ if (rel === ".claude/CLAUDE.md") {
1503
+ return { kind: "user-edit-diff-append", rel };
1420
1504
  }
1421
- else {
1422
- if (rel === ".claude/CLAUDE.md") {
1423
- ctx.actions.push(() => rescueOne(ctx, rel));
1424
- }
1425
- else if (isOverwriteSafe(rel)) {
1426
- ctx.actions.push(() => {
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], () => {
1427
1608
  fs.rmSync(localPath, { force: true });
1428
1609
  ctx.counts.userOverwrite += 1;
1429
- 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`);
1430
1611
  });
1431
- }
1432
- else if (isConflictClass(rel)) {
1433
- ctx.actions.push(() => conflictOne(ctx, rel));
1434
- }
1435
- else {
1436
- ctx.actions.push(() => rescueOne(ctx, rel));
1437
- }
1438
- }
1439
- }
1440
- else {
1441
- // user_edited=0: matches floor baseline. Split on byte-equality to HEAD.
1442
- ctx.counts.unchanged += 1;
1443
- if (bytesEqual(localPath, path.join(ctx.srcDir, rel))) {
1444
- if (cfg.dryRun) {
1445
- ctx.out(` unchanged (preserved in place): ${rel}\n`);
1446
- }
1447
- else {
1448
- ctx.actions.push(() => {
1449
- fs.appendFileSync(ctx.unchangedList, `/${rel}\n`);
1450
- 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`);
1451
1623
  });
1452
- }
1453
- }
1454
- else {
1455
- if (cfg.dryRun) {
1456
- ctx.out(` unchanged (delete + replace): ${rel}\n`);
1457
- }
1458
- else {
1459
- ctx.actions.push(() => {
1624
+ break;
1625
+ case "unchanged-delete":
1626
+ add(`delete unchanged: ${decision.rel}`, [decision.rel], () => {
1460
1627
  fs.rmSync(localPath, { force: true });
1461
- 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`);
1462
1629
  });
1463
- }
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;
1464
1637
  }
1465
1638
  }
1639
+ return actions;
1466
1640
  }
1467
1641
  // --- walk a wipe-set root ---
1468
- // 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
1469
1643
  // instead of ~3 `git` spawns per file. Process-startup overhead (~15-20ms per
1470
1644
  // spawn) dominates the per-file path, so on a large wipe set this turns a
1471
1645
  // minutes-long classify into a seconds-long one. Behaviour is identical: the
@@ -1473,10 +1647,10 @@ function processOne(ctx, rel) {
1473
1647
  // rel not represented in a map falls back to its original per-file spawn.
1474
1648
  //
1475
1649
  // Mirrors walkAndProcess's enumeration so the batched set matches what actually
1476
- // reaches processOne: it skips `companies/_template` (wholesale-replaced, never
1650
+ // reaches classifyOne: it skips `companies/_template` (wholesale-replaced, never
1477
1651
  // classified per-file) and top-level symlinks (dropped, never classified).
1478
1652
  function precomputeGitMaps(ctx, wipeToplevel) {
1479
- const allRels = []; // every rel that reaches processOne (files + symlinks)
1653
+ const allRels = []; // every rel that reaches classifyOne (files + symlinks)
1480
1654
  const fileRels = []; // subset that are regular files (hashable)
1481
1655
  const fileAbs = []; // parallel to fileRels
1482
1656
  for (const rootRel of wipeToplevel) {
@@ -1493,7 +1667,7 @@ function precomputeGitMaps(ctx, wipeToplevel) {
1493
1667
  : [];
1494
1668
  for (const rel of rels) {
1495
1669
  // A path containing a newline can't survive line-delimited batch stdin;
1496
- // 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.
1497
1671
  if (rel.includes("\n"))
1498
1672
  continue;
1499
1673
  allRels.push(rel);
@@ -1504,7 +1678,7 @@ function precomputeGitMaps(ctx, wipeToplevel) {
1504
1678
  }
1505
1679
  }
1506
1680
  }
1507
- // In head_compare mode processOne never consults these maps (it compares
1681
+ // In head_compare mode classifyOne never consults these maps (it compares
1508
1682
  // bytes against the upstream working tree directly), so skip both git passes.
1509
1683
  if (ctx.baselineMode !== "history_floor") {
1510
1684
  ctx.floorShas = new Map();
@@ -1540,7 +1714,7 @@ function precomputeGitMaps(ctx, wipeToplevel) {
1540
1714
  }
1541
1715
  ctx.floorShas = floorShas;
1542
1716
  // Pass 2: local blob SHA, but ONLY for regular files that are in the floor.
1543
- // processOne reads localShas exclusively in the `inFloor === 1` branch, so a
1717
+ // classifyOne reads localShas exclusively in the `inFloor === 1` branch, so a
1544
1718
  // file absent from the floor (e.g. the transient `.claude/worktrees/` trees,
1545
1719
  // which classify as user-only) never needs a hash — and hashing it would mean
1546
1720
  // reading its bytes for nothing. Restricting the hash set to floor members is
@@ -1571,55 +1745,31 @@ function precomputeGitMaps(ctx, wipeToplevel) {
1571
1745
  ctx.localShas = localShas;
1572
1746
  }
1573
1747
  function walkAndProcess(ctx, rootRel) {
1574
- const { cfg } = ctx;
1575
1748
  const rootAbs = path.join(ctx.hqRoot, rootRel);
1576
1749
  if (!lexists(rootAbs))
1577
1750
  return;
1578
1751
  // companies/_template — wholesale-replace.
1579
1752
  if (rootRel === "companies/_template") {
1580
- if (cfg.dryRun) {
1581
- ctx.out(" wholesale-replace: companies/_template (template carve-out)\n");
1582
- }
1583
- else {
1584
- ctx.actions.push(() => fs.rmSync(path.join(ctx.hqRoot, "companies", "_template"), {
1585
- recursive: true,
1586
- force: true,
1587
- }));
1588
- }
1753
+ recordDecision(ctx, { kind: "wholesale-replace-template", rel: "companies/_template" });
1589
1754
  return;
1590
1755
  }
1591
1756
  const lst = lstatOrNull(rootAbs);
1592
1757
  // Top-level symlink.
1593
1758
  if (lst && lst.isSymbolicLink()) {
1594
- if (isMasterSyncSymlink(rootAbs)) {
1595
- ctx.counts.symlinkDropped += 1;
1596
- if (cfg.dryRun) {
1597
- let tgt = "";
1598
- try {
1599
- tgt = fs.readlinkSync(rootAbs);
1600
- }
1601
- catch {
1602
- /* ignore */
1603
- }
1604
- ctx.out(` drop reindex symlink: ${rootRel} -> ${tgt}\n`);
1605
- }
1606
- else {
1607
- ctx.actions.push(() => {
1608
- fs.rmSync(rootAbs, { force: true });
1609
- ctx.appendLog(`symlink-dropped\t${rootRel}\t(reindex regenerable)\n`);
1610
- });
1611
- }
1759
+ const target = masterSyncSymlinkTarget(rootAbs);
1760
+ if (target !== null) {
1761
+ recordDecision(ctx, { kind: "drop-reindex-symlink", rel: rootRel, target });
1612
1762
  }
1613
1763
  return;
1614
1764
  }
1615
1765
  // Top-level regular file.
1616
1766
  if (lst && lst.isFile()) {
1617
- processOne(ctx, rootRel);
1767
+ recordDecision(ctx, classifyOne(ctx, rootRel));
1618
1768
  return;
1619
1769
  }
1620
1770
  // Top-level directory: recursive walk, pruning node_modules + nested .git.
1621
1771
  for (const rel of findFilesAndSymlinks(rootAbs, ctx.hqRoot)) {
1622
- processOne(ctx, rel);
1772
+ recordDecision(ctx, classifyOne(ctx, rel));
1623
1773
  }
1624
1774
  }
1625
1775
  /**