@indigoai-us/hq-cloud 6.15.16 → 6.15.17

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.
@@ -80,6 +80,9 @@ export type RescueDecision = {
80
80
  } | {
81
81
  kind: "user-edit-overwrite-safe";
82
82
  rel: string;
83
+ } | {
84
+ kind: "user-edit-keep-local";
85
+ rel: string;
83
86
  } | {
84
87
  kind: "user-edit-conflict";
85
88
  rel: string;
@@ -1 +1 @@
1
- {"version":3,"file":"rescue-core.d.ts","sourceRoot":"","sources":["../../src/cli/rescue-core.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"rescue-core.d.ts","sourceRoot":"","sources":["../../src/cli/rescue-core.ts"],"names":[],"mappings":"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"}
@@ -21,8 +21,10 @@
21
21
  * upstream HEAD, leave in place + protect from the overlay so
22
22
  * its mtime survives (Layer 1).
23
23
  * USER-EDIT -> rescue into personal/ (or diff-append for .claude/CLAUDE.md),
24
- * quarantine into .hq-conflicts/ (conflict class), or silently
25
- * overwrite (overwrite-safe class).
24
+ * quarantine into .hq-conflicts/ (conflict class), silently
25
+ * overwrite (overwrite-safe class), or keep the local file and
26
+ * protect it from the overlay (keep-local class: root-level
27
+ * ignore files — see LOCAL_IGNORE_FILES).
26
28
  */
27
29
  import { spawnSync } from "child_process";
28
30
  import * as fs from "fs";
@@ -196,7 +198,9 @@ function utcStamp(d, sep) {
196
198
  const h = p(d.getUTCHours());
197
199
  const m = p(d.getUTCMinutes());
198
200
  const s = p(d.getUTCSeconds());
199
- return sep === "colon" ? `${date}T${h}:${m}:${s}Z` : `${date}T${h}-${m}-${s}Z`;
201
+ return sep === "colon"
202
+ ? `${date}T${h}:${m}:${s}Z`
203
+ : `${date}T${h}-${m}-${s}Z`;
200
204
  }
201
205
  /**
202
206
  * Parse the rescue argv exactly as the bash `while case` loop did, including
@@ -494,10 +498,10 @@ function doRescue(cfg, env, out, err, setTmp) {
494
498
  const yqAvailable = hasCmd("yq", env);
495
499
  if (isFileFollow(coreYaml) && yqAvailable) {
496
500
  const yq = (expr) => run("yq", ["-r", expr, coreYaml], { env }).stdout.trim();
497
- prevSyncSha = yq(".replaced_from_source.last_sync_sha // .replaced_from_staging.last_sync_sha // \"\"");
498
- prevSyncSource = yq(".replaced_from_source.source // .replaced_from_staging.source // \"\"");
499
- prevSyncRef = yq(".replaced_from_source.ref // .replaced_from_staging.ref // \"\"");
500
- prevSyncAt = yq(".replaced_from_source.last_sync_at // .replaced_from_staging.last_sync_at // \"\"");
501
+ prevSyncSha = yq('.replaced_from_source.last_sync_sha // .replaced_from_staging.last_sync_sha // ""');
502
+ prevSyncSource = yq('.replaced_from_source.source // .replaced_from_staging.source // ""');
503
+ prevSyncRef = yq('.replaced_from_source.ref // .replaced_from_staging.ref // ""');
504
+ prevSyncAt = yq('.replaced_from_source.last_sync_at // .replaced_from_staging.last_sync_at // ""');
501
505
  }
502
506
  // Caller override (--floor-sha) wins only when the on-disk stamp is empty.
503
507
  if (!prevSyncSha && cfg.floorShaOverride) {
@@ -522,7 +526,9 @@ function doRescue(cfg, env, out, err, setTmp) {
522
526
  }
523
527
  else {
524
528
  out("==> Mode: preserve-list (default)\n");
525
- const extra = cfg.extraPreserve.length ? `, ${cfg.extraPreserve.join(" ")}` : "";
529
+ const extra = cfg.extraPreserve.length
530
+ ? `, ${cfg.extraPreserve.join(" ")}`
531
+ : "";
526
532
  out(`==> Preserved: .git, companies (except companies/_template), personal, workspace, repos, worktrees, .worktrees, .sst-sandbox-home, .tmp-codex-asar, .github, .leak-scan, .hq-sync-journal.json, .hq, .hq-conflicts, .local${extra}\n`);
527
533
  }
528
534
  if (cfg.preserveSubpaths.length !== 0) {
@@ -615,7 +621,10 @@ function doRescue(cfg, env, out, err, setTmp) {
615
621
  err("error: clone failed\n");
616
622
  throw new ExitError(5);
617
623
  }
618
- const checkoutResult = run("git", ["checkout", cfg.ref], { cwd: srcDir, env });
624
+ const checkoutResult = run("git", ["checkout", cfg.ref], {
625
+ cwd: srcDir,
626
+ env,
627
+ });
619
628
  if (checkoutResult.status !== 0) {
620
629
  reportGitFailure(checkoutResult);
621
630
  err(`error: could not check out ref '${cfg.ref}' from ${cfg.sourceRepo}\n`);
@@ -634,7 +643,10 @@ function doRescue(cfg, env, out, err, setTmp) {
634
643
  err("error: clone failed\n");
635
644
  throw new ExitError(5);
636
645
  }
637
- const checkoutResult = run("git", ["checkout", cfg.ref], { cwd: srcDir, env });
646
+ const checkoutResult = run("git", ["checkout", cfg.ref], {
647
+ cwd: srcDir,
648
+ env,
649
+ });
638
650
  if (checkoutResult.status !== 0) {
639
651
  reportGitFailure(checkoutResult);
640
652
  err(`error: could not check out ref '${cfg.ref}' from ${cfg.sourceRepo}\n`);
@@ -642,7 +654,10 @@ function doRescue(cfg, env, out, err, setTmp) {
642
654
  }
643
655
  }
644
656
  }
645
- const srcSha = run("git", ["rev-parse", "HEAD"], { cwd: srcDir, env }).stdout.trim();
657
+ const srcSha = run("git", ["rev-parse", "HEAD"], {
658
+ cwd: srcDir,
659
+ env,
660
+ }).stdout.trim();
646
661
  // Committer timestamp of the source SHA — the DETERMINISTIC stamp value for
647
662
  // core/core.yaml's `last_sync_at`. Stamping wall-clock "now" made every
648
663
  // rescue run on every machine produce a byte-different core.yaml for the
@@ -660,7 +675,8 @@ function doRescue(cfg, env, out, err, setTmp) {
660
675
  let historyFloor = "";
661
676
  let baselineMode = "head_compare";
662
677
  if (cfg.historyCheck && prevSyncSha && prevSyncSource === cfg.sourceRepo) {
663
- if (run("git", ["cat-file", "-e", prevSyncSha], { cwd: srcDir, env }).status === 0) {
678
+ if (run("git", ["cat-file", "-e", prevSyncSha], { cwd: srcDir, env })
679
+ .status === 0) {
664
680
  historyFloor = prevSyncSha;
665
681
  baselineMode = "history_floor";
666
682
  }
@@ -744,6 +760,7 @@ function doRescue(cfg, env, out, err, setTmp) {
744
760
  userEdit: 0,
745
761
  userConflict: 0,
746
762
  userOverwrite: 0,
763
+ userKeptLocal: 0,
747
764
  claudeDiffAppend: 0,
748
765
  symlinkDropped: 0,
749
766
  cloudSymlinkReconciled: 0,
@@ -801,6 +818,7 @@ function doRescue(cfg, env, out, err, setTmp) {
801
818
  out(` user-edit (rescue / diff-append): ${counts.userEdit} files\n`);
802
819
  out(` user-edit (conflict quarantine): ${counts.userConflict} files\n`);
803
820
  out(` user-edit (overwrite-safe): ${counts.userOverwrite} files\n`);
821
+ out(` user-edit (kept local): ${counts.userKeptLocal} files\n`);
804
822
  out(` cloud-symlink reconciled: ${counts.cloudSymlinkReconciled} files\n`);
805
823
  out(` drift reconciled (== upstream): ${counts.driftReconciled} files\n`);
806
824
  out(` reindex symlinks dropped: ${counts.symlinkDropped} entries\n`);
@@ -883,7 +901,10 @@ function doRescue(cfg, env, out, err, setTmp) {
883
901
  let backupDir = "";
884
902
  let mutationStarted = false;
885
903
  const hasDestructivePaths = ctx.actions.some((action) => action.affectedRels.length > 0);
886
- if (cfg.doBackup && !cfg.dryRun && wipeToplevel.length !== 0 && hasDestructivePaths) {
904
+ if (cfg.doBackup &&
905
+ !cfg.dryRun &&
906
+ wipeToplevel.length !== 0 &&
907
+ hasDestructivePaths) {
887
908
  const prePlan = pruneManagedBackups(cfg, hqRoot, out, false);
888
909
  const unresolved = prePlan.protected.filter((x) => x.reason === "recovery-required-no-later-good");
889
910
  if (unresolved.length >= 2) {
@@ -936,7 +957,7 @@ function doRescue(cfg, env, out, err, setTmp) {
936
957
  }
937
958
  }
938
959
  out("\n");
939
- out(`==> Walk complete (user-only: ${counts.userOnly}, unchanged: ${counts.unchanged}, user-edit-rescued: ${counts.userEdit}, conflict-quarantined: ${counts.userConflict}, overwrite-safe: ${counts.userOverwrite}, cloud-symlink-reconciled: ${counts.cloudSymlinkReconciled}, symlinks-dropped: ${counts.symlinkDropped})\n`);
960
+ out(`==> Walk complete (user-only: ${counts.userOnly}, unchanged: ${counts.unchanged}, user-edit-rescued: ${counts.userEdit}, conflict-quarantined: ${counts.userConflict}, overwrite-safe: ${counts.userOverwrite}, kept-local: ${counts.userKeptLocal}, cloud-symlink-reconciled: ${counts.cloudSymlinkReconciled}, symlinks-dropped: ${counts.symlinkDropped})\n`);
940
961
  // --- Overlay source onto HQ root ---
941
962
  out("==> Overlaying source onto HQ root ...\n");
942
963
  // `unchangedList` is only populated during applyClassifiedActions above, so
@@ -984,7 +1005,14 @@ function doRescue(cfg, env, out, err, setTmp) {
984
1005
  // (vs the last-sync floor) into settings.local.json so the effective merged
985
1006
  // settings are preserved. No-op in narrow mode that excludes .claude.
986
1007
  if (claudeSettingsInScope) {
987
- reconcileClaudeSettings({ hqRoot, srcDir, env, historyFloor, baselineMode, preserveSubpaths: cfg.preserveSubpaths }, preRescueSettingsJson, out, err);
1008
+ reconcileClaudeSettings({
1009
+ hqRoot,
1010
+ srcDir,
1011
+ env,
1012
+ historyFloor,
1013
+ baselineMode,
1014
+ preserveSubpaths: cfg.preserveSubpaths,
1015
+ }, preRescueSettingsJson, out, err);
988
1016
  }
989
1017
  // --- Restore executable bit on shipped shebang scripts (defense-in-depth) ---
990
1018
  // rsync -a already propagated each file's upstream git tree mode; this guards
@@ -1002,7 +1030,9 @@ function doRescue(cfg, env, out, err, setTmp) {
1002
1030
  // unparseable commit timestamp skips the stamp entirely — a nondeterministic
1003
1031
  // stamp is worse than no stamp (it re-creates cross-machine conflict mirrors).
1004
1032
  const srcCommitDate = new Date(srcCommitIsoRaw);
1005
- if (cfg.narrowPaths.length === 0 && isFileFollow(coreYaml) && Number.isNaN(srcCommitDate.getTime())) {
1033
+ if (cfg.narrowPaths.length === 0 &&
1034
+ isFileFollow(coreYaml) &&
1035
+ Number.isNaN(srcCommitDate.getTime())) {
1006
1036
  err(` WARN: could not parse source commit timestamp (${JSON.stringify(srcCommitIsoRaw)}); skipping core/core.yaml stamp to keep it deterministic\n`);
1007
1037
  }
1008
1038
  if (cfg.narrowPaths.length === 0 &&
@@ -1090,6 +1120,7 @@ function doRescue(cfg, env, out, err, setTmp) {
1090
1120
  out(` user-edits (rescued): ${counts.userEdit} files\n`);
1091
1121
  out(` user-edits (conflict quarantine): ${counts.userConflict} files\n`);
1092
1122
  out(` user-edits (overwrite-safe): ${counts.userOverwrite} files\n`);
1123
+ out(` user-edit (kept local): ${counts.userKeptLocal} files\n`);
1093
1124
  out(` cloud-symlink reconciled: ${counts.cloudSymlinkReconciled} files\n`);
1094
1125
  out(` drift reconciled (== upstream): ${counts.driftReconciled} files\n`);
1095
1126
  out(` reindex symlinks dropped: ${counts.symlinkDropped} entries\n`);
@@ -1124,6 +1155,7 @@ function doRescue(cfg, env, out, err, setTmp) {
1124
1155
  lines.push(` user-edit (rescued into personal/): ${counts.userEdit}`);
1125
1156
  lines.push(` user-edit (conflict quarantine): ${counts.userConflict}`);
1126
1157
  lines.push(` user-edit (overwrite-safe): ${counts.userOverwrite}`);
1158
+ lines.push(` user-edit (kept local, ignore file): ${counts.userKeptLocal}`);
1127
1159
  lines.push(` cloud-symlink reconciled: ${counts.cloudSymlinkReconciled}`);
1128
1160
  lines.push(` drift reconciled (== upstream HEAD): ${counts.driftReconciled}`);
1129
1161
  lines.push(` reindex symlinks dropped: ${counts.symlinkDropped}`);
@@ -1284,7 +1316,9 @@ function writeApplyFailureManifest(ctx, backupDir, failedAction, completed, rest
1284
1316
  const completedLines = completed.length === 0
1285
1317
  ? [" (none)"]
1286
1318
  : completed.map((action) => {
1287
- const paths = action.affectedRels.length > 0 ? action.affectedRels.join(", ") : "(no filesystem path)";
1319
+ const paths = action.affectedRels.length > 0
1320
+ ? action.affectedRels.join(", ")
1321
+ : "(no filesystem path)";
1288
1322
  return ` - ${action.label}: ${paths}`;
1289
1323
  });
1290
1324
  const restoredSet = new Set(restored);
@@ -1369,6 +1403,28 @@ function isConflictClass(rel) {
1369
1403
  rel.startsWith(".obsidian/") ||
1370
1404
  rel === "MIGRATION.md");
1371
1405
  }
1406
+ /**
1407
+ * Root-level exclusion lists an operator tunes for their own machine
1408
+ * (`/workspace/`, large-media globs, local scratch dirs). They ship with a
1409
+ * release, so the generic classifier used to treat an edited one as scaffold
1410
+ * drift: archive it into `personal/` and lay the release copy down. On a real
1411
+ * HQ root that silently reverted the operator's rules, and the git mirror and
1412
+ * sync engine went straight back to walking tens of GB they had been told to
1413
+ * skip. Once edited, the local file wins outright — see KEEP-LOCAL in
1414
+ * classifyOne. Root-anchored on purpose: a nested `core/.gitignore` is
1415
+ * scaffold, not machine-local tuning, and stays on the normal rescue path.
1416
+ */
1417
+ const LOCAL_IGNORE_FILES = new Set([
1418
+ ".gitignore",
1419
+ ".hqignore",
1420
+ ".hqsyncignore",
1421
+ ".hqinclude",
1422
+ ".claudeignore",
1423
+ ".ignore",
1424
+ ]);
1425
+ function isLocalIgnoreFile(rel) {
1426
+ return LOCAL_IGNORE_FILES.has(rel);
1427
+ }
1372
1428
  function isOverwriteSafe(rel) {
1373
1429
  return (rel === "AGENTS.md" ||
1374
1430
  rel === "USER-GUIDE.md" ||
@@ -1453,12 +1509,20 @@ function isCloudFlattenedSymlinkEquiv(ctx, rel, localPath) {
1453
1509
  if (!localTarget)
1454
1510
  return false;
1455
1511
  const ref = ctx.baselineMode === "history_floor" ? ctx.historyFloor : "HEAD";
1456
- const lsTree = run("git", ["ls-tree", ref, "--", rel], { cwd: ctx.srcDir, env: ctx.env }).stdout;
1512
+ const lsTree = run("git", ["ls-tree", ref, "--", rel], {
1513
+ cwd: ctx.srcDir,
1514
+ env: ctx.env,
1515
+ }).stdout;
1457
1516
  const mode = lsTree.trim().split(/\s+/)[0] ?? "";
1458
1517
  if (mode !== "120000")
1459
1518
  return false;
1460
- const showRef = ctx.baselineMode === "history_floor" ? `${ctx.historyFloor}:${rel}` : `HEAD:${rel}`;
1461
- const upstreamTarget = run("git", ["show", showRef], { cwd: ctx.srcDir, env: ctx.env }).stdout;
1519
+ const showRef = ctx.baselineMode === "history_floor"
1520
+ ? `${ctx.historyFloor}:${rel}`
1521
+ : `HEAD:${rel}`;
1522
+ const upstreamTarget = run("git", ["show", showRef], {
1523
+ cwd: ctx.srcDir,
1524
+ env: ctx.env,
1525
+ }).stdout;
1462
1526
  if (!upstreamTarget)
1463
1527
  return false;
1464
1528
  return localTarget === upstreamTarget;
@@ -1841,12 +1905,16 @@ export function reconcileClaudeSettings(ctx, preRescueSettingsJson, out, err) {
1841
1905
  }
1842
1906
  // Additions drift: base = realBase when available, else theirs (conservative
1843
1907
  // head_compare fallback — never loses a customization). See design note.
1844
- const additionsBase = isPlainObject(realBase) ? realBase : theirs;
1908
+ const additionsBase = isPlainObject(realBase)
1909
+ ? realBase
1910
+ : theirs;
1845
1911
  const drift = settingsDrift(ours, additionsBase);
1846
1912
  if (isPlainObject(drift) && Object.keys(drift).length > 0) {
1847
1913
  const existingLocal = jsonParseOrNull((() => {
1848
1914
  try {
1849
- return isFileFollow(localPath) ? fs.readFileSync(localPath, "utf-8") : null;
1915
+ return isFileFollow(localPath)
1916
+ ? fs.readFileSync(localPath, "utf-8")
1917
+ : null;
1850
1918
  }
1851
1919
  catch {
1852
1920
  return null;
@@ -2005,7 +2073,9 @@ function rescueOne(ctx, rel) {
2005
2073
  dest = `${dest}.drift-${Math.floor(Date.now() / 1000)}-${process.pid}`;
2006
2074
  }
2007
2075
  fs.renameSync(localPath, dest);
2008
- const destRel = dest.startsWith(ctx.hqRoot + "/") ? dest.slice(ctx.hqRoot.length + 1) : dest;
2076
+ const destRel = dest.startsWith(ctx.hqRoot + "/")
2077
+ ? dest.slice(ctx.hqRoot.length + 1)
2078
+ : dest;
2009
2079
  ctx.out(` rescued: ${rel} -> ${destRel}\n`);
2010
2080
  ctx.appendLog(`rescued\t${rel}\t-> ${destRel}\n`);
2011
2081
  }
@@ -2020,7 +2090,9 @@ function conflictOne(ctx, rel) {
2020
2090
  dest = `${dest}.drift-${Math.floor(Date.now() / 1000)}-${process.pid}`;
2021
2091
  }
2022
2092
  fs.renameSync(localPath, dest);
2023
- const destRel = dest.startsWith(ctx.hqRoot + "/") ? dest.slice(ctx.hqRoot.length + 1) : dest;
2093
+ const destRel = dest.startsWith(ctx.hqRoot + "/")
2094
+ ? dest.slice(ctx.hqRoot.length + 1)
2095
+ : dest;
2024
2096
  ctx.out(` conflicted: ${rel} -> ${destRel}\n`);
2025
2097
  ctx.appendLog(`conflicted\t${rel}\t-> ${destRel}\n`);
2026
2098
  }
@@ -2036,7 +2108,8 @@ function classifyOne(ctx, rel) {
2036
2108
  const srcPath = path.join(ctx.srcDir, rel);
2037
2109
  // Test-only fault seam: prove the classify-before-delete ordering invariant
2038
2110
  // by forcing a classifier throw at a chosen entry. Never set in production.
2039
- if (ctx.env.HQ_RESCUE_FAULT_AT_REL && rel === ctx.env.HQ_RESCUE_FAULT_AT_REL) {
2111
+ if (ctx.env.HQ_RESCUE_FAULT_AT_REL &&
2112
+ rel === ctx.env.HQ_RESCUE_FAULT_AT_REL) {
2040
2113
  throw new Error(`rescue classifier fault injected at ${rel} (HQ_RESCUE_FAULT_AT_REL)`);
2041
2114
  }
2042
2115
  if (isUnderPreserve(cfg, rel))
@@ -2053,6 +2126,14 @@ function classifyOne(ctx, rel) {
2053
2126
  // Symlinks (mid-tree).
2054
2127
  const lst = lstatOrNull(localPath);
2055
2128
  if (lst && lst.isSymbolicLink()) {
2129
+ // A root ignore file pointed at a dotfiles repo or shared config is by
2130
+ // construction the operator's own — the release ships regular files here,
2131
+ // never symlinks. The generic arm below would `skip` it without recording
2132
+ // an exclusion, and `rsync -a` replaces a receiver symlink with the
2133
+ // source's regular file, so the local rules would still be lost.
2134
+ if (isLocalIgnoreFile(rel)) {
2135
+ return { kind: "user-edit-keep-local", rel };
2136
+ }
2056
2137
  const target = masterSyncSymlinkTarget(localPath);
2057
2138
  if (target !== null) {
2058
2139
  return { kind: "drop-reindex-symlink", rel, target };
@@ -2088,6 +2169,12 @@ function classifyOne(ctx, rel) {
2088
2169
  }
2089
2170
  // Path is/was in upstream. Determine if user edited it.
2090
2171
  let userEdited = 0;
2172
+ // Set only by the floor comparison below — the one arm that proves the local
2173
+ // bytes diverged from the baseline the operator last synced, rather than
2174
+ // merely differing from HEAD. KEEP-LOCAL requires this evidence: without it,
2175
+ // an untouched file from an older release (head_compare mode) or one upstream
2176
+ // deleted would be pinned locally forever instead of tracking the release.
2177
+ let editProvenAgainstFloor = false;
2091
2178
  if (ctx.baselineMode === "history_floor" && inFloor === 1) {
2092
2179
  const localSha = ctx.localShas?.get(rel) ??
2093
2180
  run("git", ["hash-object", localPath], { env: ctx.env }).stdout.trim();
@@ -2096,8 +2183,10 @@ function classifyOne(ctx, rel) {
2096
2183
  cwd: ctx.srcDir,
2097
2184
  env: ctx.env,
2098
2185
  }).stdout.trim();
2099
- if (!localSha || !floorSha || localSha !== floorSha)
2186
+ if (!localSha || !floorSha || localSha !== floorSha) {
2100
2187
  userEdited = 1;
2188
+ editProvenAgainstFloor = true;
2189
+ }
2101
2190
  }
2102
2191
  else if (inHead === 1) {
2103
2192
  if (!bytesEqual(localPath, srcPath))
@@ -2116,6 +2205,11 @@ function classifyOne(ctx, rel) {
2116
2205
  return { kind: "drift-reconciled", rel };
2117
2206
  }
2118
2207
  if (userEdited === 1) {
2208
+ // KEEP-LOCAL: an edited root ignore file is machine-local tuning. Leave it
2209
+ // untouched and keep the overlay off it; the release copy never wins.
2210
+ if (editProvenAgainstFloor && isLocalIgnoreFile(rel)) {
2211
+ return { kind: "user-edit-keep-local", rel };
2212
+ }
2119
2213
  if (rel === ".claude/CLAUDE.md") {
2120
2214
  return { kind: "user-edit-diff-append", rel };
2121
2215
  }
@@ -2163,6 +2257,10 @@ function renderDryRunDecision(ctx, decision) {
2163
2257
  ctx.out(` user-edit (overwrite-safe): ${decision.rel} -> upstream wins (no copy preserved)\n`);
2164
2258
  ctx.counts.userOverwrite += 1;
2165
2259
  break;
2260
+ case "user-edit-keep-local":
2261
+ ctx.out(` user-edit (keep local): ${decision.rel} -> local wins (machine-local ignore file; overlay skipped)\n`);
2262
+ ctx.counts.userKeptLocal += 1;
2263
+ break;
2166
2264
  case "user-edit-conflict":
2167
2265
  ctx.out(` user-edit (conflict): ${decision.rel} -> .hq-conflicts/rescue-${ctx.runTs}/${decision.rel}\n`);
2168
2266
  ctx.counts.userConflict += 1;
@@ -2235,6 +2333,17 @@ function buildRescueActions(ctx) {
2235
2333
  ctx.appendLog(`overwritten\t${decision.rel}\t(overwrite-safe; upstream wins, no copy preserved)\n`);
2236
2334
  });
2237
2335
  break;
2336
+ case "user-edit-keep-local":
2337
+ // No affected paths: nothing is moved or deleted. Listing the rel in
2338
+ // `unchangedList` makes it an `--exclude-from` entry for the overlay
2339
+ // rsync, so the release copy is never laid over the local one.
2340
+ add(`keep local: ${decision.rel}`, [], () => {
2341
+ fs.appendFileSync(ctx.unchangedList, `/${decision.rel}\n`);
2342
+ ctx.counts.userKeptLocal += 1;
2343
+ ctx.out(` kept local: ${decision.rel} (machine-local ignore file; your edits win, overlay skipped)\n`);
2344
+ ctx.appendLog(`kept-local\t${decision.rel}\t(machine-local ignore file; user edit wins, overlay excluded)\n`);
2345
+ });
2346
+ break;
2238
2347
  case "user-edit-conflict":
2239
2348
  add(`conflict quarantine: ${decision.rel}`, [decision.rel], () => conflictOne(ctx, decision.rel));
2240
2349
  break;
@@ -2375,15 +2484,31 @@ function walkAndProcess(ctx, rootRel) {
2375
2484
  return;
2376
2485
  // companies/_template — wholesale-replace.
2377
2486
  if (rootRel === "companies/_template") {
2378
- recordDecision(ctx, { kind: "wholesale-replace-template", rel: "companies/_template" });
2487
+ recordDecision(ctx, {
2488
+ kind: "wholesale-replace-template",
2489
+ rel: "companies/_template",
2490
+ });
2379
2491
  return;
2380
2492
  }
2381
2493
  const lst = lstatOrNull(rootAbs);
2382
2494
  // Top-level symlink.
2383
2495
  if (lst && lst.isSymbolicLink()) {
2496
+ // A root ignore file pointed at a dotfiles repo or shared config is by
2497
+ // construction the operator's own — the release ships regular files here,
2498
+ // never symlinks. Falling through to the plain `return` below records no
2499
+ // exclusion, and the overlay's `rsync -a` then replaces the receiver
2500
+ // symlink with the source's regular file, losing the local rules.
2501
+ if (isLocalIgnoreFile(rootRel)) {
2502
+ recordDecision(ctx, { kind: "user-edit-keep-local", rel: rootRel });
2503
+ return;
2504
+ }
2384
2505
  const target = masterSyncSymlinkTarget(rootAbs);
2385
2506
  if (target !== null) {
2386
- recordDecision(ctx, { kind: "drop-reindex-symlink", rel: rootRel, target });
2507
+ recordDecision(ctx, {
2508
+ kind: "drop-reindex-symlink",
2509
+ rel: rootRel,
2510
+ target,
2511
+ });
2387
2512
  }
2388
2513
  return;
2389
2514
  }
@@ -2445,17 +2570,28 @@ function findFilesAndSymlinks(rootAbs, hqRoot) {
2445
2570
  // --- restore file mtimes from git commit history ---
2446
2571
  function restoreMtimesFromGit(srcDir, env, out) {
2447
2572
  // Deepen a shallow clone so per-file commit times are real.
2448
- const isShallow = run("git", ["rev-parse", "--is-shallow-repository"], { cwd: srcDir, env }).stdout.trim() ===
2449
- "true";
2573
+ const isShallow = run("git", ["rev-parse", "--is-shallow-repository"], {
2574
+ cwd: srcDir,
2575
+ env,
2576
+ }).stdout.trim() === "true";
2450
2577
  if (isShallow) {
2451
2578
  out("==> Deepening clone for per-file mtime history (blob:none) ...\n");
2452
- const r = run("git", ["fetch", "--unshallow", "--filter=blob:none"], { cwd: srcDir, env });
2579
+ const r = run("git", ["fetch", "--unshallow", "--filter=blob:none"], {
2580
+ cwd: srcDir,
2581
+ env,
2582
+ });
2453
2583
  if (r.status !== 0) {
2454
2584
  out(" (unshallow failed; mtimes fall back to release tip-commit time)\n");
2455
2585
  }
2456
2586
  }
2457
2587
  out("==> Restoring file mtimes from git commit history ...\n");
2458
- const log = run("git", ["log", "--no-renames", "--pretty=format:C:%ct", "--name-only", "--diff-filter=ACMR"], { cwd: srcDir, env });
2588
+ const log = run("git", [
2589
+ "log",
2590
+ "--no-renames",
2591
+ "--pretty=format:C:%ct",
2592
+ "--name-only",
2593
+ "--diff-filter=ACMR",
2594
+ ], { cwd: srcDir, env });
2459
2595
  if (log.status !== 0)
2460
2596
  return;
2461
2597
  let ts = "";
@@ -2490,9 +2626,22 @@ function allocateSnapshotDir(backupRoot, runTs, rootHash, runId) {
2490
2626
  }
2491
2627
  function snapshotManifest(status, hqRoot, runId, cfg, sourceSha, includedRoots) {
2492
2628
  const now = new Date().toISOString();
2493
- return { schemaVersion: 1, status, canonicalHqRoot: hqRoot, rootHash: canonicalRootHash(hqRoot), runId,
2494
- creatorPid: process.pid, creatorStartedAt: now, source: cfg.sourceRepo, ref: cfg.ref, sourceSha,
2495
- createdAt: now, includedRoots, fileCount: 0, byteCount: 0 };
2629
+ return {
2630
+ schemaVersion: 1,
2631
+ status,
2632
+ canonicalHqRoot: hqRoot,
2633
+ rootHash: canonicalRootHash(hqRoot),
2634
+ runId,
2635
+ creatorPid: process.pid,
2636
+ creatorStartedAt: now,
2637
+ source: cfg.sourceRepo,
2638
+ ref: cfg.ref,
2639
+ sourceSha,
2640
+ createdAt: now,
2641
+ includedRoots,
2642
+ fileCount: 0,
2643
+ byteCount: 0,
2644
+ };
2496
2645
  }
2497
2646
  function readSnapshotManifest(backupDir) {
2498
2647
  try {
@@ -2514,8 +2663,13 @@ function updateSnapshotStatus(backupDir, status, completedAt) {
2514
2663
  if (!manifest)
2515
2664
  return;
2516
2665
  const stats = estimateFiltered(backupDir, "");
2517
- writeSnapshotManifest(backupDir, { ...manifest, status, fileCount: stats.files, byteCount: stats.bytes,
2518
- ...(completedAt ? { completedAt: new Date().toISOString() } : {}) });
2666
+ writeSnapshotManifest(backupDir, {
2667
+ ...manifest,
2668
+ status,
2669
+ fileCount: stats.files,
2670
+ byteCount: stats.bytes,
2671
+ ...(completedAt ? { completedAt: new Date().toISOString() } : {}),
2672
+ });
2519
2673
  }
2520
2674
  function pruneManagedBackups(cfg, hqRoot, out, apply) {
2521
2675
  const inventory = inventoryManagedSnapshots(cfg.backupRoot, hqRoot, (m) => {
@@ -2526,15 +2680,25 @@ function pruneManagedBackups(cfg, hqRoot, out, apply) {
2526
2680
  catch {
2527
2681
  return false;
2528
2682
  }
2529
- }, { keepGood: Number(cfg.backupRetentionCount), maxAgeDays: Number(cfg.backupRetentionDays) });
2530
- const plan = { candidates: inventory.candidates, protected: inventory.protected, unmanaged: inventory.unmanaged };
2683
+ }, {
2684
+ keepGood: Number(cfg.backupRetentionCount),
2685
+ maxAgeDays: Number(cfg.backupRetentionDays),
2686
+ });
2687
+ const plan = {
2688
+ candidates: inventory.candidates,
2689
+ protected: inventory.protected,
2690
+ unmanaged: inventory.unmanaged,
2691
+ };
2531
2692
  out(`==> Backup retention dry-run: ${plan.candidates.length} candidate(s), ${plan.protected.length} protected, ${plan.unmanaged.length} unmanaged.\n`);
2532
2693
  for (const item of plan.candidates)
2533
2694
  out(` candidate ${item.entry.name}: ${item.reason}\n`);
2534
2695
  for (const item of plan.protected)
2535
2696
  out(` protected ${item.entry.name}: ${item.reason}\n`);
2536
2697
  if (apply && plan.candidates.length > 0) {
2537
- const result = applyBackupPrune(cfg.backupRoot, hqRoot, plan, { keepGood: Number(cfg.backupRetentionCount), maxAgeDays: Number(cfg.backupRetentionDays) });
2698
+ const result = applyBackupPrune(cfg.backupRoot, hqRoot, plan, {
2699
+ keepGood: Number(cfg.backupRetentionCount),
2700
+ maxAgeDays: Number(cfg.backupRetentionDays),
2701
+ });
2538
2702
  for (const name of result.deleted)
2539
2703
  out(` deleted snapshot: ${name}\n`);
2540
2704
  for (const failure of result.failures)
@@ -2614,7 +2778,9 @@ function countFiles(dir) {
2614
2778
  * "$SHUTTLE/$id"`, an explicit destination name).
2615
2779
  */
2616
2780
  function cpA(src, destDir, asName) {
2617
- const dest = asName ? path.join(destDir, asName) : path.join(destDir, path.basename(src));
2781
+ const dest = asName
2782
+ ? path.join(destDir, asName)
2783
+ : path.join(destDir, path.basename(src));
2618
2784
  cpATo(src, dest);
2619
2785
  }
2620
2786
  /** `cp -a <src> <dest>` with an explicit destination path. */