@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
@@ -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,7 +817,8 @@ 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.
816
- for (const act of ctx.actions) act();
820
+ ctx.actions = buildRescueActions(ctx);
821
+ applyClassifiedActions(ctx, backupDir);
817
822
 
818
823
  // --- Back up preserve-subpaths to a mktemp shuttle ---
819
824
  const shuttle = path.join(tmpdir, "preserve");
@@ -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,13 +1094,18 @@ 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.
1098
- actions: Array<() => void>;
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.
1108
+ actions: RescueAction[];
1099
1109
  counts: {
1100
1110
  userOnly: number;
1101
1111
  unchanged: number;
@@ -1112,6 +1122,189 @@ interface WalkCtx {
1112
1122
  out: (s: string) => void;
1113
1123
  }
1114
1124
 
1125
+ interface RescueAction {
1126
+ label: string;
1127
+ affectedRels: string[];
1128
+ run: () => void;
1129
+ }
1130
+
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
+ }
1170
+ }
1171
+
1172
+ function applyClassifiedActions(ctx: WalkCtx, backupDir: string): void {
1173
+ const completed: RescueAction[] = [];
1174
+ for (const action of ctx.actions) {
1175
+ try {
1176
+ action.run();
1177
+ completed.push(action);
1178
+ } catch (err) {
1179
+ const restored = rollbackCompletedActions(ctx, completed, backupDir);
1180
+ const manifest = writeApplyFailureManifest(
1181
+ ctx,
1182
+ backupDir,
1183
+ action,
1184
+ completed,
1185
+ restored,
1186
+ err,
1187
+ );
1188
+ ctx.out("\n");
1189
+ ctx.out(`error: rescue apply failed during '${action.label}'.\n`);
1190
+ if (restored.length > 0) {
1191
+ ctx.out(`==> Rolled back ${restored.length} prior action path(s) from the safety snapshot.\n`);
1192
+ }
1193
+ if (manifest) {
1194
+ ctx.out(`==> Recovery manifest: ${manifest}\n`);
1195
+ }
1196
+ throw err;
1197
+ }
1198
+ }
1199
+ }
1200
+
1201
+ function rollbackCompletedActions(
1202
+ ctx: WalkCtx,
1203
+ completed: RescueAction[],
1204
+ backupDir: string,
1205
+ ): string[] {
1206
+ if (!backupDir || !isDir(backupDir)) return [];
1207
+
1208
+ const rels: string[] = [];
1209
+ const seen = new Set<string>();
1210
+ for (let i = completed.length - 1; i >= 0; i--) {
1211
+ const action = completed[i];
1212
+ for (let j = action.affectedRels.length - 1; j >= 0; j--) {
1213
+ const rel = action.affectedRels[j];
1214
+ if (rel && !seen.has(rel)) {
1215
+ seen.add(rel);
1216
+ rels.push(rel);
1217
+ }
1218
+ }
1219
+ }
1220
+
1221
+ const restored: string[] = [];
1222
+ for (const rel of rels) {
1223
+ const snapshotPath = path.join(backupDir, rel);
1224
+ if (!lexists(snapshotPath)) continue;
1225
+ const dest = path.join(ctx.hqRoot, rel);
1226
+ try {
1227
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
1228
+ fs.rmSync(dest, { recursive: true, force: true });
1229
+ cpATo(snapshotPath, dest);
1230
+ restored.push(rel);
1231
+ } catch {
1232
+ // Recovery manifest below records any path that could not be restored.
1233
+ }
1234
+ }
1235
+ return restored;
1236
+ }
1237
+
1238
+ function writeApplyFailureManifest(
1239
+ ctx: WalkCtx,
1240
+ backupDir: string,
1241
+ failedAction: RescueAction,
1242
+ completed: RescueAction[],
1243
+ restored: string[],
1244
+ err: unknown,
1245
+ ): string | null {
1246
+ const message = err instanceof Error ? err.message : String(err);
1247
+ const completedLines =
1248
+ completed.length === 0
1249
+ ? [" (none)"]
1250
+ : completed.map((action) => {
1251
+ const paths = action.affectedRels.length > 0 ? action.affectedRels.join(", ") : "(no filesystem path)";
1252
+ return ` - ${action.label}: ${paths}`;
1253
+ });
1254
+ const restoredSet = new Set(restored);
1255
+ const unrestored = completed
1256
+ .flatMap((action) => action.affectedRels)
1257
+ .filter((rel, idx, arr) => rel && arr.indexOf(rel) === idx && !restoredSet.has(rel));
1258
+
1259
+ const lines: string[] = [];
1260
+ lines.push("# HQ rescue apply recovery", "");
1261
+ lines.push(`Created: ${ctx.runTs}`);
1262
+ lines.push(`HQ root: ${ctx.hqRoot}`);
1263
+ lines.push(`Source: ${ctx.cfg.sourceRepo}@${ctx.cfg.ref} (${ctx.srcSha})`);
1264
+ lines.push(`Failed action: ${failedAction.label}`);
1265
+ lines.push(`Error: ${message}`);
1266
+ lines.push("");
1267
+ lines.push("## Completed Before Failure");
1268
+ lines.push(...completedLines);
1269
+ lines.push("");
1270
+ lines.push("## Failed Action Paths");
1271
+ if (failedAction.affectedRels.length > 0) {
1272
+ for (const rel of failedAction.affectedRels) lines.push(` - ${rel}`);
1273
+ } else {
1274
+ lines.push(" (no filesystem path)");
1275
+ }
1276
+ lines.push("");
1277
+ lines.push("## Rollback");
1278
+ if (restored.length > 0) {
1279
+ lines.push("Restored from the pre-op safety snapshot:");
1280
+ for (const rel of restored) lines.push(` - ${rel}`);
1281
+ } else {
1282
+ lines.push("No completed action paths were restored automatically.");
1283
+ }
1284
+ if (unrestored.length > 0) {
1285
+ lines.push("", "Completed action paths still requiring review:");
1286
+ for (const rel of unrestored) lines.push(` - ${rel}`);
1287
+ }
1288
+ if (backupDir && isDir(backupDir)) {
1289
+ lines.push("");
1290
+ lines.push("Manual restore examples:");
1291
+ lines.push(` cp "${backupDir}/<relpath>" "${ctx.hqRoot}/<relpath>"`);
1292
+ lines.push(` rsync -a "${backupDir}/" "${ctx.hqRoot}/"`);
1293
+ } else {
1294
+ lines.push("", "No pre-op safety snapshot was available for automatic restore.");
1295
+ }
1296
+
1297
+ const manifestDir = path.join(ctx.hqRoot, ".hq", "rescue-recovery");
1298
+ const manifestPath = path.join(manifestDir, `rescue-apply-failure-${ctx.runTs}.md`);
1299
+ try {
1300
+ fs.mkdirSync(manifestDir, { recursive: true });
1301
+ fs.writeFileSync(manifestPath, lines.join("\n") + "\n");
1302
+ return manifestPath;
1303
+ } catch {
1304
+ return null;
1305
+ }
1306
+ }
1307
+
1115
1308
  // --- Rescue-target mapping ---
1116
1309
  function mapRescueTarget(rel: string): string {
1117
1310
  if (rel === ".claude/CLAUDE.md") return "personal/CLAUDE.md";
@@ -1143,16 +1336,16 @@ function isOverwriteSafe(rel: string): boolean {
1143
1336
  );
1144
1337
  }
1145
1338
 
1146
- function isMasterSyncSymlink(localPath: string): boolean {
1339
+ function masterSyncSymlinkTarget(localPath: string): string | null {
1147
1340
  const st = lstatOrNull(localPath);
1148
- if (!st || !st.isSymbolicLink()) return false;
1341
+ if (!st || !st.isSymbolicLink()) return null;
1149
1342
  let tgt = "";
1150
1343
  try {
1151
1344
  tgt = fs.readlinkSync(localPath);
1152
1345
  } catch {
1153
- return false;
1346
+ return null;
1154
1347
  }
1155
- return tgt.includes("/personal/") || tgt.startsWith("personal/");
1348
+ return tgt.includes("/personal/") || tgt.startsWith("personal/") ? tgt : null;
1156
1349
  }
1157
1350
 
1158
1351
  function isUnderPreserve(cfg: Config, rel: string): boolean {
@@ -1405,14 +1598,13 @@ function conflictOne(ctx: WalkCtx, rel: string): void {
1405
1598
  ctx.appendLog(`conflicted\t${rel}\t-> ${destRel}\n`);
1406
1599
  }
1407
1600
 
1408
- // --- classify + act on one file (the per-file workhorse) ---
1601
+ // --- classify one file (the per-file workhorse) ---
1409
1602
  //
1410
- // Read-only by contract: this records intent (counts + dry-run plan lines +
1411
- // deferred `ctx.actions` closures) but never mutates the filesystem itself. The
1412
- // closures are run later by the PHASE 2 apply loop, only once the whole wipe
1413
- // set has classified without throwing. Keep it that way — a stray `fs.rmSync`
1414
- // here re-opens the half-applied-wipe hole this split closed (DEV-1767).
1415
- 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 {
1416
1608
  const { cfg } = ctx;
1417
1609
  const localPath = path.join(ctx.hqRoot, rel);
1418
1610
  const srcPath = path.join(ctx.srcDir, rel);
@@ -1423,50 +1615,27 @@ function processOne(ctx: WalkCtx, rel: string): void {
1423
1615
  throw new Error(`rescue classifier fault injected at ${rel} (HQ_RESCUE_FAULT_AT_REL)`);
1424
1616
  }
1425
1617
 
1426
- if (isUnderPreserve(cfg, rel)) return;
1618
+ if (isUnderPreserve(cfg, rel)) return { kind: "skip", rel };
1427
1619
 
1428
1620
  // Conflict-resolution artifacts (`<name>.conflict-<ts>-<peer>.<ext>`).
1429
1621
  const base = rel.includes("/") ? rel.slice(rel.lastIndexOf("/") + 1) : rel;
1430
1622
  if (/\.conflict-/.test(base)) {
1431
- if (cfg.dryRun) {
1432
- ctx.out(` drop conflict artifact: ${rel}\n`);
1433
- } else {
1434
- ctx.actions.push(() => fs.rmSync(localPath, { force: true }));
1435
- }
1436
- return;
1623
+ return { kind: "drop-conflict-artifact", rel };
1437
1624
  }
1438
1625
 
1439
1626
  // Script-managed files: core/core.yaml + legacy core.yaml.
1440
1627
  if (rel === "core/core.yaml" || rel === "core.yaml") {
1441
- if (cfg.dryRun) {
1442
- ctx.out(` skip script-managed (rewrites at stamp step): ${rel}\n`);
1443
- } else {
1444
- ctx.actions.push(() => fs.rmSync(localPath, { force: true }));
1445
- }
1446
- return;
1628
+ return { kind: "drop-script-managed", rel };
1447
1629
  }
1448
1630
 
1449
1631
  // Symlinks (mid-tree).
1450
1632
  const lst = lstatOrNull(localPath);
1451
1633
  if (lst && lst.isSymbolicLink()) {
1452
- if (isMasterSyncSymlink(localPath)) {
1453
- ctx.counts.symlinkDropped += 1;
1454
- if (cfg.dryRun) {
1455
- let tgt = "";
1456
- try {
1457
- tgt = fs.readlinkSync(localPath);
1458
- } catch {
1459
- /* ignore */
1460
- }
1461
- ctx.out(` drop reindex symlink: ${rel} -> ${tgt}\n`);
1462
- } else {
1463
- ctx.actions.push(() => {
1464
- fs.rmSync(localPath, { force: true });
1465
- ctx.appendLog(`symlink-dropped\t${rel}\t(reindex regenerable)\n`);
1466
- });
1467
- }
1634
+ const target = masterSyncSymlinkTarget(localPath);
1635
+ if (target !== null) {
1636
+ return { kind: "drop-reindex-symlink", rel, target };
1468
1637
  }
1469
- return;
1638
+ return { kind: "skip", rel };
1470
1639
  }
1471
1640
 
1472
1641
  // Is path in upstream HEAD?
@@ -1497,9 +1666,7 @@ function processOne(ctx: WalkCtx, rel: string): void {
1497
1666
 
1498
1667
  // USER-ONLY: unknown to upstream (HEAD AND floor both lack it).
1499
1668
  if (inHead === 0 && inFloor === 0) {
1500
- ctx.counts.userOnly += 1;
1501
- if (cfg.dryRun) ctx.out(` user-only (leave in place): ${rel}\n`);
1502
- return;
1669
+ return { kind: "user-only", rel };
1503
1670
  }
1504
1671
 
1505
1672
  // Path is/was in upstream. Determine if user edited it.
@@ -1524,95 +1691,186 @@ function processOne(ctx: WalkCtx, rel: string): void {
1524
1691
 
1525
1692
  // Cloud-update reclassification.
1526
1693
  if (userEdited === 1 && isCloudFlattenedSymlinkEquiv(ctx, rel, localPath)) {
1527
- ctx.counts.cloudSymlinkReconciled += 1;
1528
- if (cfg.dryRun) {
1529
- ctx.out(
1530
- ` cloud-symlink reconciled (unchanged): ${rel} (hq-symlink: marker matches upstream target)\n`,
1531
- );
1532
- } else {
1533
- ctx.actions.push(() => {
1534
- fs.rmSync(localPath, { force: true });
1535
- ctx.appendLog(
1536
- `cloud-symlink-reconciled\t${rel}\t(hq-symlink: marker matches upstream target; overlay re-lays symlink)\n`,
1537
- );
1538
- });
1539
- }
1540
- return;
1694
+ return { kind: "cloud-symlink-reconciled", rel };
1541
1695
  }
1542
1696
 
1543
1697
  // Convergence guard: drifted from floor but identical to upstream HEAD.
1544
1698
  if (userEdited === 1 && inHead === 1 && bytesEqual(localPath, srcPath)) {
1545
- ctx.counts.driftReconciled += 1;
1546
- if (cfg.dryRun) {
1547
- ctx.out(` drift reconciled (identical to upstream HEAD; no rescue): ${rel}\n`);
1548
- } else {
1549
- ctx.actions.push(() => {
1550
- fs.rmSync(localPath, { force: true });
1551
- ctx.appendLog(
1552
- `drift-reconciled\t${rel}\t(identical to upstream HEAD; drifted from floor only — no personal/ copy)\n`,
1553
- );
1554
- });
1555
- }
1556
- return;
1699
+ return { kind: "drift-reconciled", rel };
1557
1700
  }
1558
1701
 
1559
1702
  if (userEdited === 1) {
1560
- if (cfg.dryRun) {
1561
- if (rel === ".claude/CLAUDE.md") {
1562
- ctx.out(` user-edit (diff-append): ${rel} -> personal/CLAUDE.md\n`);
1563
- ctx.counts.userEdit += 1;
1564
- } else if (isOverwriteSafe(rel)) {
1565
- ctx.out(` user-edit (overwrite-safe): ${rel} -> upstream wins (no copy preserved)\n`);
1566
- ctx.counts.userOverwrite += 1;
1567
- } else if (isConflictClass(rel)) {
1568
- ctx.out(` user-edit (conflict): ${rel} -> .hq-conflicts/rescue-${ctx.runTs}/${rel}\n`);
1569
- ctx.counts.userConflict += 1;
1570
- } else {
1571
- ctx.out(` user-edit (rescue): ${rel} -> ${mapRescueTarget(rel)}\n`);
1572
- ctx.counts.userEdit += 1;
1573
- }
1574
- } else {
1575
- if (rel === ".claude/CLAUDE.md") {
1576
- ctx.actions.push(() => rescueOne(ctx, rel));
1577
- } else if (isOverwriteSafe(rel)) {
1578
- ctx.actions.push(() => {
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 }),
1791
+ );
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], () => {
1579
1827
  fs.rmSync(localPath, { force: true });
1580
1828
  ctx.counts.userOverwrite += 1;
1581
- 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
+ );
1582
1832
  });
1583
- } else if (isConflictClass(rel)) {
1584
- ctx.actions.push(() => conflictOne(ctx, rel));
1585
- } else {
1586
- ctx.actions.push(() => rescueOne(ctx, rel));
1587
- }
1588
- }
1589
- } else {
1590
- // user_edited=0: matches floor baseline. Split on byte-equality to HEAD.
1591
- ctx.counts.unchanged += 1;
1592
- if (bytesEqual(localPath, path.join(ctx.srcDir, rel))) {
1593
- if (cfg.dryRun) {
1594
- ctx.out(` unchanged (preserved in place): ${rel}\n`);
1595
- } else {
1596
- ctx.actions.push(() => {
1597
- fs.appendFileSync(ctx.unchangedList, `/${rel}\n`);
1598
- ctx.appendLog(`unchanged\t${rel}\t(identical to upstream; left in place, mtime preserved)\n`);
1833
+ break;
1834
+ case "user-edit-conflict":
1835
+ add(`conflict quarantine: ${decision.rel}`, [decision.rel], () =>
1836
+ conflictOne(ctx, decision.rel),
1837
+ );
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
+ );
1599
1848
  });
1600
- }
1601
- } else {
1602
- if (cfg.dryRun) {
1603
- ctx.out(` unchanged (delete + replace): ${rel}\n`);
1604
- } else {
1605
- ctx.actions.push(() => {
1849
+ break;
1850
+ case "unchanged-delete":
1851
+ add(`delete unchanged: ${decision.rel}`, [decision.rel], () => {
1606
1852
  fs.rmSync(localPath, { force: true });
1607
- 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
+ );
1608
1856
  });
1609
- }
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;
1610
1866
  }
1611
1867
  }
1868
+
1869
+ return actions;
1612
1870
  }
1613
1871
 
1614
1872
  // --- walk a wipe-set root ---
1615
- // 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
1616
1874
  // instead of ~3 `git` spawns per file. Process-startup overhead (~15-20ms per
1617
1875
  // spawn) dominates the per-file path, so on a large wipe set this turns a
1618
1876
  // minutes-long classify into a seconds-long one. Behaviour is identical: the
@@ -1620,10 +1878,10 @@ function processOne(ctx: WalkCtx, rel: string): void {
1620
1878
  // rel not represented in a map falls back to its original per-file spawn.
1621
1879
  //
1622
1880
  // Mirrors walkAndProcess's enumeration so the batched set matches what actually
1623
- // reaches processOne: it skips `companies/_template` (wholesale-replaced, never
1881
+ // reaches classifyOne: it skips `companies/_template` (wholesale-replaced, never
1624
1882
  // classified per-file) and top-level symlinks (dropped, never classified).
1625
1883
  function precomputeGitMaps(ctx: WalkCtx, wipeToplevel: string[]): void {
1626
- const allRels: string[] = []; // every rel that reaches processOne (files + symlinks)
1884
+ const allRels: string[] = []; // every rel that reaches classifyOne (files + symlinks)
1627
1885
  const fileRels: string[] = []; // subset that are regular files (hashable)
1628
1886
  const fileAbs: string[] = []; // parallel to fileRels
1629
1887
 
@@ -1639,7 +1897,7 @@ function precomputeGitMaps(ctx: WalkCtx, wipeToplevel: string[]): void {
1639
1897
  : [];
1640
1898
  for (const rel of rels) {
1641
1899
  // A path containing a newline can't survive line-delimited batch stdin;
1642
- // 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.
1643
1901
  if (rel.includes("\n")) continue;
1644
1902
  allRels.push(rel);
1645
1903
  const st = lstatOrNull(path.join(ctx.hqRoot, rel));
@@ -1650,7 +1908,7 @@ function precomputeGitMaps(ctx: WalkCtx, wipeToplevel: string[]): void {
1650
1908
  }
1651
1909
  }
1652
1910
 
1653
- // In head_compare mode processOne never consults these maps (it compares
1911
+ // In head_compare mode classifyOne never consults these maps (it compares
1654
1912
  // bytes against the upstream working tree directly), so skip both git passes.
1655
1913
  if (ctx.baselineMode !== "history_floor") {
1656
1914
  ctx.floorShas = new Map();
@@ -1687,7 +1945,7 @@ function precomputeGitMaps(ctx: WalkCtx, wipeToplevel: string[]): void {
1687
1945
  ctx.floorShas = floorShas;
1688
1946
 
1689
1947
  // Pass 2: local blob SHA, but ONLY for regular files that are in the floor.
1690
- // processOne reads localShas exclusively in the `inFloor === 1` branch, so a
1948
+ // classifyOne reads localShas exclusively in the `inFloor === 1` branch, so a
1691
1949
  // file absent from the floor (e.g. the transient `.claude/worktrees/` trees,
1692
1950
  // which classify as user-only) never needs a hash — and hashing it would mean
1693
1951
  // reading its bytes for nothing. Restricting the hash set to floor members is
@@ -1718,22 +1976,12 @@ function precomputeGitMaps(ctx: WalkCtx, wipeToplevel: string[]): void {
1718
1976
  }
1719
1977
 
1720
1978
  function walkAndProcess(ctx: WalkCtx, rootRel: string): void {
1721
- const { cfg } = ctx;
1722
1979
  const rootAbs = path.join(ctx.hqRoot, rootRel);
1723
1980
  if (!lexists(rootAbs)) return;
1724
1981
 
1725
1982
  // companies/_template — wholesale-replace.
1726
1983
  if (rootRel === "companies/_template") {
1727
- if (cfg.dryRun) {
1728
- ctx.out(" wholesale-replace: companies/_template (template carve-out)\n");
1729
- } else {
1730
- ctx.actions.push(() =>
1731
- fs.rmSync(path.join(ctx.hqRoot, "companies", "_template"), {
1732
- recursive: true,
1733
- force: true,
1734
- }),
1735
- );
1736
- }
1984
+ recordDecision(ctx, { kind: "wholesale-replace-template", rel: "companies/_template" });
1737
1985
  return;
1738
1986
  }
1739
1987
 
@@ -1741,35 +1989,22 @@ function walkAndProcess(ctx: WalkCtx, rootRel: string): void {
1741
1989
 
1742
1990
  // Top-level symlink.
1743
1991
  if (lst && lst.isSymbolicLink()) {
1744
- if (isMasterSyncSymlink(rootAbs)) {
1745
- ctx.counts.symlinkDropped += 1;
1746
- if (cfg.dryRun) {
1747
- let tgt = "";
1748
- try {
1749
- tgt = fs.readlinkSync(rootAbs);
1750
- } catch {
1751
- /* ignore */
1752
- }
1753
- ctx.out(` drop reindex symlink: ${rootRel} -> ${tgt}\n`);
1754
- } else {
1755
- ctx.actions.push(() => {
1756
- fs.rmSync(rootAbs, { force: true });
1757
- ctx.appendLog(`symlink-dropped\t${rootRel}\t(reindex regenerable)\n`);
1758
- });
1759
- }
1992
+ const target = masterSyncSymlinkTarget(rootAbs);
1993
+ if (target !== null) {
1994
+ recordDecision(ctx, { kind: "drop-reindex-symlink", rel: rootRel, target });
1760
1995
  }
1761
1996
  return;
1762
1997
  }
1763
1998
 
1764
1999
  // Top-level regular file.
1765
2000
  if (lst && lst.isFile()) {
1766
- processOne(ctx, rootRel);
2001
+ recordDecision(ctx, classifyOne(ctx, rootRel));
1767
2002
  return;
1768
2003
  }
1769
2004
 
1770
2005
  // Top-level directory: recursive walk, pruning node_modules + nested .git.
1771
2006
  for (const rel of findFilesAndSymlinks(rootAbs, ctx.hqRoot)) {
1772
- processOne(ctx, rel);
2007
+ recordDecision(ctx, classifyOne(ctx, rel));
1773
2008
  }
1774
2009
  }
1775
2010