@indigoai-us/hq-cloud 6.14.49 → 6.15.0

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 (126) hide show
  1. package/.github/workflows/unreleased-commits-nag.yml +256 -0
  2. package/dist/bin/sync-runner-watch-loop.d.ts +12 -0
  3. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  4. package/dist/bin/sync-runner-watch-loop.js +153 -25
  5. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  6. package/dist/bin/sync-runner-watch-routes.d.ts +17 -0
  7. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
  8. package/dist/bin/sync-runner-watch-routes.js +11 -0
  9. package/dist/bin/sync-runner-watch-routes.js.map +1 -1
  10. package/dist/bin/sync-runner-watch-routes.test.d.ts +2 -0
  11. package/dist/bin/sync-runner-watch-routes.test.d.ts.map +1 -0
  12. package/dist/bin/sync-runner-watch-routes.test.js +51 -0
  13. package/dist/bin/sync-runner-watch-routes.test.js.map +1 -0
  14. package/dist/bin/sync-runner.d.ts +11 -12
  15. package/dist/bin/sync-runner.d.ts.map +1 -1
  16. package/dist/bin/sync-runner.js.map +1 -1
  17. package/dist/bin/sync-runner.test.js +173 -52
  18. package/dist/bin/sync-runner.test.js.map +1 -1
  19. package/dist/cli/reindex-knowledge.d.ts +7 -0
  20. package/dist/cli/reindex-knowledge.d.ts.map +1 -0
  21. package/dist/cli/reindex-knowledge.js +389 -0
  22. package/dist/cli/reindex-knowledge.js.map +1 -0
  23. package/dist/cli/reindex-knowledge.test.d.ts +12 -0
  24. package/dist/cli/reindex-knowledge.test.d.ts.map +1 -0
  25. package/dist/cli/reindex-knowledge.test.js +260 -0
  26. package/dist/cli/reindex-knowledge.test.js.map +1 -0
  27. package/dist/cli/reindex.d.ts.map +1 -1
  28. package/dist/cli/reindex.js +8 -0
  29. package/dist/cli/reindex.js.map +1 -1
  30. package/dist/cli/rescue-classify-ordering.test.js +8 -2
  31. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  32. package/dist/cli/rescue-core.d.ts +85 -0
  33. package/dist/cli/rescue-core.d.ts.map +1 -1
  34. package/dist/cli/rescue-core.js +390 -1
  35. package/dist/cli/rescue-core.js.map +1 -1
  36. package/dist/cli/rescue-settings-reconcile.test.d.ts +2 -0
  37. package/dist/cli/rescue-settings-reconcile.test.d.ts.map +1 -0
  38. package/dist/cli/rescue-settings-reconcile.test.js +523 -0
  39. package/dist/cli/rescue-settings-reconcile.test.js.map +1 -0
  40. package/dist/index.d.ts +1 -1
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +1 -1
  43. package/dist/index.js.map +1 -1
  44. package/dist/journal.d.ts.map +1 -1
  45. package/dist/journal.js +81 -51
  46. package/dist/journal.js.map +1 -1
  47. package/dist/journal.test.js +11 -19
  48. package/dist/journal.test.js.map +1 -1
  49. package/dist/sync/event-sync.d.ts +8 -25
  50. package/dist/sync/event-sync.d.ts.map +1 -1
  51. package/dist/sync/event-sync.js +23 -48
  52. package/dist/sync/event-sync.js.map +1 -1
  53. package/dist/sync/event-sync.test.js +6 -32
  54. package/dist/sync/event-sync.test.js.map +1 -1
  55. package/dist/sync/feature-flags.d.ts +20 -117
  56. package/dist/sync/feature-flags.d.ts.map +1 -1
  57. package/dist/sync/feature-flags.js +24 -147
  58. package/dist/sync/feature-flags.js.map +1 -1
  59. package/dist/sync/feature-flags.test.js +15 -61
  60. package/dist/sync/feature-flags.test.js.map +1 -1
  61. package/dist/sync/index.d.ts +1 -1
  62. package/dist/sync/index.d.ts.map +1 -1
  63. package/dist/sync/index.js +1 -1
  64. package/dist/sync/index.js.map +1 -1
  65. package/dist/sync/lease-client.d.ts +67 -0
  66. package/dist/sync/lease-client.d.ts.map +1 -0
  67. package/dist/sync/lease-client.js +158 -0
  68. package/dist/sync/lease-client.js.map +1 -0
  69. package/dist/sync/lease-client.test.d.ts +2 -0
  70. package/dist/sync/lease-client.test.d.ts.map +1 -0
  71. package/dist/sync/lease-client.test.js +119 -0
  72. package/dist/sync/lease-client.test.js.map +1 -0
  73. package/dist/sync/push-receiver.d.ts +8 -19
  74. package/dist/sync/push-receiver.d.ts.map +1 -1
  75. package/dist/sync/push-receiver.js +6 -50
  76. package/dist/sync/push-receiver.js.map +1 -1
  77. package/dist/sync/push-receiver.test.js +6 -57
  78. package/dist/sync/push-receiver.test.js.map +1 -1
  79. package/dist/sync/realtime-rollout.d.ts +85 -0
  80. package/dist/sync/realtime-rollout.d.ts.map +1 -0
  81. package/dist/sync/realtime-rollout.js +206 -0
  82. package/dist/sync/realtime-rollout.js.map +1 -0
  83. package/dist/sync/realtime-rollout.test.d.ts +2 -0
  84. package/dist/sync/realtime-rollout.test.d.ts.map +1 -0
  85. package/dist/sync/realtime-rollout.test.js +71 -0
  86. package/dist/sync/realtime-rollout.test.js.map +1 -0
  87. package/dist/sync/state-store.d.ts +40 -0
  88. package/dist/sync/state-store.d.ts.map +1 -0
  89. package/dist/sync/state-store.js +680 -0
  90. package/dist/sync/state-store.js.map +1 -0
  91. package/dist/sync/state-store.test.d.ts +2 -0
  92. package/dist/sync/state-store.test.d.ts.map +1 -0
  93. package/dist/sync/state-store.test.js +159 -0
  94. package/dist/sync/state-store.test.js.map +1 -0
  95. package/dist/types.d.ts +12 -0
  96. package/dist/types.d.ts.map +1 -1
  97. package/package.json +1 -1
  98. package/src/bin/sync-runner-watch-loop.ts +176 -32
  99. package/src/bin/sync-runner-watch-routes.test.ts +71 -0
  100. package/src/bin/sync-runner-watch-routes.ts +27 -0
  101. package/src/bin/sync-runner.test.ts +188 -53
  102. package/src/bin/sync-runner.ts +11 -12
  103. package/src/cli/reindex-knowledge.test.ts +307 -0
  104. package/src/cli/reindex-knowledge.ts +450 -0
  105. package/src/cli/reindex.ts +9 -0
  106. package/src/cli/rescue-classify-ordering.test.ts +9 -3
  107. package/src/cli/rescue-core.ts +459 -1
  108. package/src/cli/rescue-settings-reconcile.test.ts +637 -0
  109. package/src/index.ts +2 -4
  110. package/src/journal.test.ts +19 -21
  111. package/src/journal.ts +87 -54
  112. package/src/sync/event-sync.test.ts +9 -44
  113. package/src/sync/event-sync.ts +26 -52
  114. package/src/sync/feature-flags.test.ts +22 -72
  115. package/src/sync/feature-flags.ts +32 -199
  116. package/src/sync/index.ts +2 -4
  117. package/src/sync/lease-client.test.ts +128 -0
  118. package/src/sync/lease-client.ts +207 -0
  119. package/src/sync/push-receiver.test.ts +6 -58
  120. package/src/sync/push-receiver.ts +9 -74
  121. package/src/sync/realtime-rollout.test.ts +86 -0
  122. package/src/sync/realtime-rollout.ts +262 -0
  123. package/src/sync/state-store.test.ts +194 -0
  124. package/src/sync/state-store.ts +727 -0
  125. package/src/types.ts +14 -0
  126. package/test/e2e/sync/transient-company-leg.test.ts +8 -4
@@ -531,6 +531,26 @@ function doRescue(
531
531
  // fallback, then fail later at overlay. Stop before classification/allocation.
532
532
  requireWorkingRsync(env, err);
533
533
 
534
+ // --- Capture the pre-rescue .claude/settings.json (BEFORE the wipe) ---
535
+ // This is `ours` for the post-overlay settings reconcile. It must be read now
536
+ // because the overlay-safe classification deletes/overwrites it before
537
+ // reconcileClaudeSettings runs. `null` when absent (fresh install / narrow
538
+ // mode that excludes .claude).
539
+ const claudeSettingsRel = ".claude/settings.json";
540
+ const claudeSettingsInScope =
541
+ cfg.narrowPaths.length === 0 || cfg.narrowPaths.includes(".claude");
542
+ let preRescueSettingsJson: string | null = null;
543
+ if (claudeSettingsInScope) {
544
+ const p = path.join(hqRoot, claudeSettingsRel);
545
+ if (isFileFollow(p)) {
546
+ try {
547
+ preRescueSettingsJson = fs.readFileSync(p, "utf-8");
548
+ } catch {
549
+ preRescueSettingsJson = null;
550
+ }
551
+ }
552
+ }
553
+
534
554
  // --- Read prior sync-point metadata (BEFORE the wipe) ---
535
555
  let prevSyncSha = "";
536
556
  let prevSyncSource = "";
@@ -1050,6 +1070,22 @@ function doRescue(
1050
1070
  }
1051
1071
  }
1052
1072
 
1073
+ // --- Reconcile the generated .claude/settings.json ---
1074
+ // Runs AFTER the overlay (which re-laid the release template, wiring in any
1075
+ // newly-released hook registrations) and AFTER the preserve-subpath restore
1076
+ // (which brought back the user's .claude/settings.local.json). Re-composes the
1077
+ // machine env.PATH onto the release baseline and relocates genuine user drift
1078
+ // (vs the last-sync floor) into settings.local.json so the effective merged
1079
+ // settings are preserved. No-op in narrow mode that excludes .claude.
1080
+ if (claudeSettingsInScope) {
1081
+ reconcileClaudeSettings(
1082
+ { hqRoot, srcDir, env, historyFloor, baselineMode, preserveSubpaths: cfg.preserveSubpaths },
1083
+ preRescueSettingsJson,
1084
+ out,
1085
+ err,
1086
+ );
1087
+ }
1088
+
1053
1089
  // --- Restore executable bit on shipped shebang scripts (defense-in-depth) ---
1054
1090
  // rsync -a already propagated each file's upstream git tree mode; this guards
1055
1091
  // the case where upstream itself committed an executable script as 0644 -- a
@@ -1548,7 +1584,17 @@ function isOverwriteSafe(rel: string): boolean {
1548
1584
  rel === "AGENTS.md" ||
1549
1585
  rel === "USER-GUIDE.md" ||
1550
1586
  rel === "core/docs/hq/USER-GUIDE.md" ||
1551
- rel === "core/policies/_digest.md"
1587
+ rel === "core/policies/_digest.md" ||
1588
+ // `.claude/settings.json` is a machine-local *generated* file (setup.sh
1589
+ // step 3b composes env.PATH into the release template). It always differs
1590
+ // from every release blob because of that PATH, so the generic classifier
1591
+ // would rescue it into personal/ and lay a PATH-less template on top —
1592
+ // dropping the machine PATH and (depending on drift/history state) failing
1593
+ // to wire in newly-released hook registrations. Instead let the overlay
1594
+ // always re-lay the release template (no personal/ copy), then
1595
+ // reconcileClaudeSettings() re-composes env.PATH and relocates genuine user
1596
+ // drift into settings.local.json. See reconcileClaudeSettings.
1597
+ rel === ".claude/settings.json"
1552
1598
  );
1553
1599
  }
1554
1600
 
@@ -1713,6 +1759,418 @@ function diffAppendClaudeMd(ctx: WalkCtx): void {
1713
1759
  ctx.counts.claudeDiffAppend += 1;
1714
1760
  }
1715
1761
 
1762
+ // --- .claude/settings.json reconcile (recompose env.PATH + relocate drift) ---
1763
+
1764
+ type JsonValue =
1765
+ | string
1766
+ | number
1767
+ | boolean
1768
+ | null
1769
+ | JsonValue[]
1770
+ | { [k: string]: JsonValue };
1771
+
1772
+ function isPlainObject(v: unknown): v is { [k: string]: JsonValue } {
1773
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1774
+ }
1775
+
1776
+ /** Structural deep equality for JSON values (stable, order-sensitive for objects). */
1777
+ export function jsonDeepEqual(a: JsonValue, b: JsonValue): boolean {
1778
+ if (a === b) return true;
1779
+ if (Array.isArray(a) && Array.isArray(b)) {
1780
+ if (a.length !== b.length) return false;
1781
+ return a.every((x, i) => jsonDeepEqual(x, b[i]!));
1782
+ }
1783
+ if (isPlainObject(a) && isPlainObject(b)) {
1784
+ const ak = Object.keys(a);
1785
+ const bk = Object.keys(b);
1786
+ if (ak.length !== bk.length) return false;
1787
+ return ak.every((k) => k in b && jsonDeepEqual(a[k]!, b[k]!));
1788
+ }
1789
+ return false;
1790
+ }
1791
+
1792
+ function jsonParseOrNull(text: string | null | undefined): JsonValue | null {
1793
+ if (text == null) return null;
1794
+ try {
1795
+ return JSON.parse(text) as JsonValue;
1796
+ } catch {
1797
+ return null;
1798
+ }
1799
+ }
1800
+
1801
+ /**
1802
+ * Compute the drift of `ours` relative to `base` (the three-way "ours − base").
1803
+ * Returns a partial object holding only the values `ours` added or changed
1804
+ * versus `base`, suitable for merging into settings.local.json so Claude Code's
1805
+ * higher-precedence Local scope re-supplies them on top of the release baseline.
1806
+ *
1807
+ * - object: recurse per key; a key absent from `base` is drift wholesale.
1808
+ * - array: drift = the elements present in `ours` but not deep-contained in
1809
+ * `base`. This matches Claude Code's union merge for permissions/hooks, so a
1810
+ * relocated allow/deny entry unions back into the effective settings.
1811
+ * - scalar / type-mismatch: drift = ours's value when not deep-equal.
1812
+ *
1813
+ * `excludePath` names dotted key paths that are NEVER drift (env.PATH — a
1814
+ * machine-local value re-composed onto the release baseline, not relocated).
1815
+ * Returns `undefined` when there is no drift at this node.
1816
+ */
1817
+ export function settingsDrift(
1818
+ ours: JsonValue,
1819
+ base: JsonValue | undefined,
1820
+ excludePath: string[] = ["env.PATH"],
1821
+ pathPrefix = "",
1822
+ ): JsonValue | undefined {
1823
+ const here = pathPrefix;
1824
+ if (excludePath.includes(here)) return undefined;
1825
+
1826
+ if (base === undefined) {
1827
+ // Whole subtree is new in ours — but still honor exclusions nested under it.
1828
+ if (isPlainObject(ours)) {
1829
+ const out: { [k: string]: JsonValue } = {};
1830
+ let any = false;
1831
+ for (const k of Object.keys(ours)) {
1832
+ const child = settingsDrift(ours[k]!, undefined, excludePath, here ? `${here}.${k}` : k);
1833
+ if (child !== undefined) {
1834
+ out[k] = child;
1835
+ any = true;
1836
+ }
1837
+ }
1838
+ return any ? out : undefined;
1839
+ }
1840
+ return ours;
1841
+ }
1842
+
1843
+ if (isPlainObject(ours) && isPlainObject(base)) {
1844
+ const out: { [k: string]: JsonValue } = {};
1845
+ let any = false;
1846
+ for (const k of Object.keys(ours)) {
1847
+ const child = settingsDrift(
1848
+ ours[k]!,
1849
+ k in base ? base[k]! : undefined,
1850
+ excludePath,
1851
+ here ? `${here}.${k}` : k,
1852
+ );
1853
+ if (child !== undefined) {
1854
+ out[k] = child;
1855
+ any = true;
1856
+ }
1857
+ }
1858
+ return any ? out : undefined;
1859
+ }
1860
+
1861
+ if (Array.isArray(ours) && Array.isArray(base)) {
1862
+ const extra = ours.filter((el) => !base.some((b) => jsonDeepEqual(el, b)));
1863
+ return extra.length > 0 ? extra : undefined;
1864
+ }
1865
+
1866
+ return jsonDeepEqual(ours, base) ? undefined : ours;
1867
+ }
1868
+
1869
+ /**
1870
+ * Deep-merge `patch` into `target` in place and return it. Objects merge
1871
+ * per-key; arrays union (append patch elements not already deep-present, so a
1872
+ * re-run is idempotent and no duplicate permission/hook entries accumulate);
1873
+ * scalars are added ONLY when the key is absent from `target`. A scalar the
1874
+ * user already set in Local is the higher-precedence value and MUST NOT be
1875
+ * clobbered by the lower-precedence Project drift (e.g. Project `model: opus` +
1876
+ * Local `model: sonnet` must stay `sonnet` after rescue). Mirrors how Claude
1877
+ * Code's Local scope overrides/unions over Project.
1878
+ */
1879
+ export function deepMergeSettings(
1880
+ target: { [k: string]: JsonValue },
1881
+ patch: { [k: string]: JsonValue },
1882
+ ): { [k: string]: JsonValue } {
1883
+ for (const k of Object.keys(patch)) {
1884
+ const pv = patch[k]!;
1885
+ const tv = target[k];
1886
+ if (Array.isArray(pv) && Array.isArray(tv)) {
1887
+ for (const el of pv) {
1888
+ if (!tv.some((x) => jsonDeepEqual(x, el))) tv.push(el);
1889
+ }
1890
+ } else if (isPlainObject(pv) && isPlainObject(tv)) {
1891
+ deepMergeSettings(tv, pv);
1892
+ } else if (!(k in target)) {
1893
+ // Key absent from Local — safe to introduce the relocated drift value.
1894
+ target[k] = pv;
1895
+ }
1896
+ // else: Local already defines this scalar (or a differently-typed value);
1897
+ // it is the higher-precedence override and is left untouched.
1898
+ }
1899
+ return target;
1900
+ }
1901
+
1902
+ /**
1903
+ * Apply the user's array REMOVALS (elements present in `base` but absent from
1904
+ * `ours`) to the corresponding arrays in `theirs`, in place. A union-style Local
1905
+ * overlay cannot express a removal, so a permission/hook a user deliberately
1906
+ * deleted from the generated settings.json would otherwise be restored by the
1907
+ * release baseline and never removable. Filtering `theirs` — the Project file we
1908
+ * now own — is the only representation of that intent. Recurses through objects;
1909
+ * only acts when all three nodes are arrays (or objects to recurse). Requires a
1910
+ * REAL floor base: never call with base = theirs (HEAD), or release-only entries
1911
+ * would be mistaken for removals.
1912
+ */
1913
+ export function applyArrayRemovals(
1914
+ theirs: { [k: string]: JsonValue },
1915
+ ours: { [k: string]: JsonValue },
1916
+ base: { [k: string]: JsonValue },
1917
+ ): void {
1918
+ for (const k of Object.keys(base)) {
1919
+ const bv = base[k];
1920
+ const ov = ours[k];
1921
+ const tv = theirs[k];
1922
+ if (Array.isArray(bv) && Array.isArray(ov) && Array.isArray(tv)) {
1923
+ const removed = bv.filter((b) => !ov.some((o) => jsonDeepEqual(o, b)));
1924
+ if (removed.length > 0) {
1925
+ theirs[k] = tv.filter((t) => !removed.some((r) => jsonDeepEqual(r, t)));
1926
+ }
1927
+ } else if (isPlainObject(bv) && isPlainObject(ov) && isPlainObject(tv)) {
1928
+ applyArrayRemovals(tv, ov, bv);
1929
+ }
1930
+ }
1931
+ }
1932
+
1933
+ /**
1934
+ * Re-compose the machine env.PATH for the freshly-overlaid release template.
1935
+ * Runs the SAME composer setup.sh uses (core/scripts/compose-settings-path.sh)
1936
+ * with the machine's own PATH as the base — preferring the pre-rescue snapshot
1937
+ * so a working PATH is never degraded by a possibly-truncated rescue-process
1938
+ * PATH — so the value is always machine-specific and never a hardcoded release
1939
+ * PATH (the release template ships none). Prefers the release clone's composer,
1940
+ * falls back to the overlaid one; if neither runs, carries `basePath` verbatim.
1941
+ */
1942
+ function composeSettingsPath(
1943
+ srcDir: string,
1944
+ hqRoot: string,
1945
+ basePath: string,
1946
+ env: NodeJS.ProcessEnv,
1947
+ ): string {
1948
+ const candidates = [
1949
+ path.join(srcDir, "core", "scripts", "compose-settings-path.sh"),
1950
+ path.join(hqRoot, "core", "scripts", "compose-settings-path.sh"),
1951
+ ];
1952
+ for (const script of candidates) {
1953
+ if (!isFileFollow(script)) continue;
1954
+ const r = run("bash", [script, basePath], { env });
1955
+ const composed = r.stdout.replace(/\n+$/, "");
1956
+ if (r.status === 0 && composed) return composed;
1957
+ }
1958
+ return basePath;
1959
+ }
1960
+
1961
+ interface ReconcileCtx {
1962
+ hqRoot: string;
1963
+ srcDir: string;
1964
+ env: NodeJS.ProcessEnv;
1965
+ historyFloor: string;
1966
+ baselineMode: "history_floor" | "head_compare";
1967
+ /** cfg.preserveSubpaths — used to honor an explicit `--preserve-subpath` of
1968
+ * settings.json (or the whole `.claude` tree). */
1969
+ preserveSubpaths: string[];
1970
+ }
1971
+
1972
+ /** True when `.claude/settings.json` is covered by an EXPLICIT preserve subpath
1973
+ * (the caller asked rescue to keep it, so the reconcile must not rewrite it).
1974
+ * The always-on carve-outs never cover settings.json, so any match here is a
1975
+ * deliberate caller request. */
1976
+ function settingsExplicitlyPreserved(preserveSubpaths: string[]): boolean {
1977
+ const rel = ".claude/settings.json";
1978
+ for (const sp of preserveSubpaths) {
1979
+ const s = sp.replace(/\/$/, "");
1980
+ if (s === rel || s === ".claude" || rel.startsWith(s + "/")) return true;
1981
+ }
1982
+ return false;
1983
+ }
1984
+
1985
+ /**
1986
+ * Post-overlay reconcile for the machine-local, generated `.claude/settings.json`.
1987
+ *
1988
+ * At entry the overlay has already re-laid the release template (theirs) over
1989
+ * settings.json, and the user's settings.local.json has been restored from the
1990
+ * preserve shuttle. This function:
1991
+ * 1. re-composes env.PATH onto theirs (machine-specific; deliverable 3), and
1992
+ * 2. extracts ours's drift versus the last-sync floor (base) — excluding
1993
+ * env.PATH — and merges it into settings.local.json (deliverable 2),
1994
+ * so the EFFECTIVE merged settings (Project ⊕ Local, per Claude Code's rules)
1995
+ * are preserved while the Project base returns to the shipped composition with
1996
+ * any newly-released hook registrations wired in (deliverable 1).
1997
+ *
1998
+ * Best-effort and non-fatal: unreadable/invalid JSON degrades to a
1999
+ * PATH-recompose-only pass (never worse than the old behavior), matching
2000
+ * rescue's posture elsewhere.
2001
+ */
2002
+ export function reconcileClaudeSettings(
2003
+ ctx: ReconcileCtx,
2004
+ preRescueSettingsJson: string | null,
2005
+ out: (s: string) => void,
2006
+ err: (s: string) => void,
2007
+ ): void {
2008
+ const { hqRoot, srcDir, env, historyFloor, baselineMode, preserveSubpaths } = ctx;
2009
+ const settingsPath = path.join(hqRoot, ".claude", "settings.json");
2010
+ const localPath = path.join(hqRoot, ".claude", "settings.local.json");
2011
+
2012
+ // The caller explicitly asked to keep settings.json (via `--preserve-subpath
2013
+ // .claude/settings.json` or `--preserve-subpath .claude`). The shuttle already
2014
+ // restored their file; honor that and do not rewrite it (nor land new
2015
+ // registrations — that is the point of an explicit preserve).
2016
+ if (settingsExplicitlyPreserved(preserveSubpaths)) {
2017
+ out("==> Skipped .claude/settings.json reconcile (explicitly preserved by --preserve-subpath)\n");
2018
+ return;
2019
+ }
2020
+
2021
+ const overlaidTemplateExists = isFileFollow(settingsPath);
2022
+
2023
+ if (!overlaidTemplateExists) {
2024
+ // The release no longer ships .claude/settings.json, but the overwrite-safe
2025
+ // classification already deleted the local file. Restore the pre-rescue copy
2026
+ // so the machine PATH and every user customization survive (critical under
2027
+ // --no-backup, where no snapshot exists to recover from).
2028
+ if (preRescueSettingsJson !== null) {
2029
+ try {
2030
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
2031
+ fs.writeFileSync(settingsPath, preRescueSettingsJson);
2032
+ out(
2033
+ "==> Release no longer ships .claude/settings.json; restored your existing settings.json (no reconcile).\n",
2034
+ );
2035
+ } catch {
2036
+ err(" WARN: could not restore pre-rescue .claude/settings.json after the release dropped the template\n");
2037
+ }
2038
+ }
2039
+ return;
2040
+ }
2041
+
2042
+ const theirsRaw = (() => {
2043
+ try {
2044
+ return fs.readFileSync(settingsPath, "utf-8");
2045
+ } catch {
2046
+ return null;
2047
+ }
2048
+ })();
2049
+ const theirs = jsonParseOrNull(theirsRaw);
2050
+ if (!isPlainObject(theirs)) {
2051
+ err(" WARN: overlaid .claude/settings.json is not valid JSON; skipping settings reconcile\n");
2052
+ return;
2053
+ }
2054
+
2055
+ const ours = jsonParseOrNull(preRescueSettingsJson);
2056
+ const oursIsValid = isPlainObject(ours);
2057
+
2058
+ // The pre-rescue settings.json existed but was not parseable, and the overlay
2059
+ // already replaced it. Preserve the raw bytes under personal/ (the same place
2060
+ // a user-edit rescue would have put them) so a temporarily-malformed file is
2061
+ // never destroyed by a --no-backup run. Then continue with a PATH-recompose
2062
+ // pass so the machine still works.
2063
+ if (preRescueSettingsJson !== null && !oursIsValid) {
2064
+ const backup = path.join(hqRoot, "personal", ".claude", "settings.json");
2065
+ try {
2066
+ fs.mkdirSync(path.dirname(backup), { recursive: true });
2067
+ fs.writeFileSync(backup, preRescueSettingsJson);
2068
+ err(
2069
+ " WARN: pre-rescue .claude/settings.json was not valid JSON; preserved a copy at personal/.claude/settings.json\n",
2070
+ );
2071
+ } catch {
2072
+ err(" WARN: pre-rescue .claude/settings.json was not valid JSON and could not be backed up\n");
2073
+ }
2074
+ }
2075
+
2076
+ const oursEnvPath =
2077
+ oursIsValid && isPlainObject(ours.env) && typeof ours.env.PATH === "string"
2078
+ ? ours.env.PATH
2079
+ : undefined;
2080
+
2081
+ // 1) Re-compose env.PATH onto the release baseline. Base = the machine's own
2082
+ // prior PATH snapshot when present, else this process's PATH.
2083
+ const basePath = oursEnvPath ?? env.PATH ?? "";
2084
+ const composedPath = composeSettingsPath(srcDir, hqRoot, basePath, env);
2085
+ if (composedPath) {
2086
+ const envObj = isPlainObject(theirs.env) ? theirs.env : (theirs.env = {});
2087
+ envObj.PATH = composedPath;
2088
+ }
2089
+
2090
+ // 2) Relocate genuine user drift (ours vs floor baseline) into settings.local,
2091
+ // and apply user array removals to the Project baseline.
2092
+ let driftKeys = 0;
2093
+ let relocationFailed = false;
2094
+ if (oursIsValid) {
2095
+ // realBase = settings.json at the last-sync floor (only trustworthy in
2096
+ // history_floor mode). Removals require it; additions fall back to theirs.
2097
+ let realBase: JsonValue | undefined;
2098
+ if (baselineMode === "history_floor" && historyFloor) {
2099
+ const show = run("git", ["show", `${historyFloor}:.claude/settings.json`], {
2100
+ cwd: srcDir,
2101
+ env,
2102
+ });
2103
+ if (show.status === 0) realBase = jsonParseOrNull(show.stdout);
2104
+ }
2105
+ // Removals: only with a REAL floor base (never theirs — release-only entries
2106
+ // would look like removals).
2107
+ if (isPlainObject(realBase)) {
2108
+ applyArrayRemovals(theirs, ours, realBase);
2109
+ }
2110
+ // Additions drift: base = realBase when available, else theirs (conservative
2111
+ // head_compare fallback — never loses a customization). See design note.
2112
+ const additionsBase: JsonValue = isPlainObject(realBase) ? realBase : theirs;
2113
+ const drift = settingsDrift(ours, additionsBase);
2114
+ if (isPlainObject(drift) && Object.keys(drift).length > 0) {
2115
+ const existingLocal = jsonParseOrNull((() => {
2116
+ try {
2117
+ return isFileFollow(localPath) ? fs.readFileSync(localPath, "utf-8") : null;
2118
+ } catch {
2119
+ return null;
2120
+ }
2121
+ })());
2122
+ const localObj = isPlainObject(existingLocal) ? existingLocal : {};
2123
+ deepMergeSettings(localObj, drift);
2124
+ driftKeys = Object.keys(drift).length;
2125
+ try {
2126
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
2127
+ fs.writeFileSync(localPath, JSON.stringify(localObj, null, 2) + "\n");
2128
+ } catch {
2129
+ relocationFailed = true;
2130
+ err(" WARN: could not write .claude/settings.local.json during settings reconcile\n");
2131
+ }
2132
+ }
2133
+ }
2134
+
2135
+ // If drift relocation into settings.local.json failed, DO NOT overwrite
2136
+ // settings.json with the release baseline — that would drop the drift with no
2137
+ // on-disk copy under --no-backup. Restore the pre-rescue file so nothing is
2138
+ // lost; the update simply isn't applied to settings this run (the user can
2139
+ // retry once settings.local.json is writable).
2140
+ if (relocationFailed) {
2141
+ if (preRescueSettingsJson !== null) {
2142
+ try {
2143
+ fs.writeFileSync(settingsPath, preRescueSettingsJson);
2144
+ } catch {
2145
+ err(" WARN: could not restore pre-rescue .claude/settings.json after a failed drift relocation\n");
2146
+ }
2147
+ }
2148
+ err(
2149
+ " WARN: kept your original .claude/settings.json (settings update not applied — settings.local.json was unwritable)\n",
2150
+ );
2151
+ return;
2152
+ }
2153
+
2154
+ // Write the re-composed release baseline back to settings.json.
2155
+ try {
2156
+ fs.writeFileSync(settingsPath, JSON.stringify(theirs, null, 2) + "\n");
2157
+ } catch {
2158
+ err(" WARN: could not write re-composed .claude/settings.json during reconcile\n");
2159
+ return;
2160
+ }
2161
+
2162
+ if (driftKeys > 0) {
2163
+ out(
2164
+ `==> Reconciled .claude/settings.json to release baseline (env.PATH re-composed; ` +
2165
+ `relocated ${driftKeys} drift key(s) -> .claude/settings.local.json)\n`,
2166
+ );
2167
+ } else {
2168
+ out(
2169
+ "==> Reconciled .claude/settings.json to release baseline (env.PATH re-composed; no local drift to relocate)\n",
2170
+ );
2171
+ }
2172
+ }
2173
+
1716
2174
  /**
1717
2175
  * Defense-in-depth for executable scaffold scripts. `rescue` rebuilds the tree
1718
2176
  * with `git clone` + `rsync -a`, faithfully propagating each file's git tree