@indigoai-us/hq-cloud 5.47.2 → 5.48.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.
@@ -0,0 +1,1400 @@
1
+ #!/usr/bin/env bash
2
+ # replace-rescue.sh
3
+ #
4
+ # Renamed from `replace-from-staging-rescue.sh` in v0.1.104 once the script
5
+ # became channel-agnostic (`--source` + `--ref` drive both the @indigo
6
+ # "Update to Staging" flow and the prod "Update to vX.Y.Z" flow). Old name
7
+ # kept as a backup search path in `hq_core_staging::resolve_rescue_script`
8
+ # so a stale resource dir mid-rebuild doesn't break dev. The persisted
9
+ # `core/core.yaml` stamp key also got renamed (`replaced_from_staging` ->
10
+ # `replaced_from_source`); reads honour both, writes use the new key and
11
+ # `del` the old.
12
+ #
13
+ # Variant of replace-from-staging.sh (the original clobbering version,
14
+ # kept upstream for non-HQ-Sync callers) that:
15
+ #
16
+ # 1. Does NOT require the destination to be a git repo. The `.git/` check is
17
+ # dropped and git status reporting at the end is replaced with a plain
18
+ # file-count summary. (git is still required to fetch the source repo.)
19
+ #
20
+ # 2. Rescues drifts instead of clobbering them. Before the wipe, every file
21
+ # in the wipe set that DIFFERS from staging (or exists only locally) is
22
+ # MOVED into `personal/` so it survives as a layered override.
23
+ #
24
+ # Drift detection — three-way classification (v0.1.104+):
25
+ #
26
+ # For every regular file under each wipe-set top-level entry, the walk
27
+ # classifies it as one of:
28
+ #
29
+ # A. USER-ONLY — path not in upstream HEAD AND not in the last-sync
30
+ # tree (per `replaced_from_source.last_sync_sha`). The
31
+ # user created this file and upstream has no opinion
32
+ # about it. ACTION: leave in place. The rsync overlay
33
+ # is run without --delete so user-only files survive.
34
+ #
35
+ # B. UNCHANGED — local file is byte-identical to the version at the
36
+ # last-sync SHA (or, when no stamp is available, to
37
+ # the current upstream HEAD). User didn't touch it
38
+ # since last sync. ACTION: delete locally; the overlay
39
+ # writes the fresh upstream version on top.
40
+ #
41
+ # C. USER-EDIT — local file differs from the last-sync version (or
42
+ # from HEAD in head-compare fallback mode). The user
43
+ # authored a change after the last sync. ACTION:
44
+ # rescue per the mapping below.
45
+ #
46
+ # Rescue mapping for USER-EDIT files:
47
+ #
48
+ # a. `.claude/CLAUDE.md` -> DIFF-APPEND additions to `personal/CLAUDE.md`
49
+ # (whole-file move was too disruptive — most
50
+ # users only add sections, so we extract the
51
+ # lines present locally but not in baseline
52
+ # and append them under a timestamped marker.)
53
+ # b. `.claude/<rest>` -> `personal/<rest>` (mkdir -p personal/<top-of-rest>/)
54
+ # c. `core/<rest>` -> `personal/<rest>` (mkdir -p personal/<top-of-rest>/)
55
+ # d. `<rest>` (root file) -> `personal/<rest>` (mkdir -p personal/<top-of-rest>/)
56
+ #
57
+ # Conflict-class USER-EDITs (bypass the personal/ rescue and quarantine in
58
+ # `.hq-conflicts/rescue-<RUN_TS>/<rel>` instead — see is_conflict_class):
59
+ #
60
+ # - `.agents/` — agent runtime state, not a portable overlay
61
+ # - `.codex/` — codex per-machine cache/state
62
+ # - `.obsidian/` — vault UI state (workspace.json, graph.json)
63
+ # - `MIGRATION.md` (root) — always re-shipped by overlay; a divergent local
64
+ # copy means an upgrade snag worth reviewing,
65
+ # not a customization to preserve in personal/.
66
+ #
67
+ # Overwrite-safe USER-EDITs (silently overwritten by the overlay — no
68
+ # rescue, no conflict, no copy preserved — see is_overwrite_safe):
69
+ #
70
+ # - `AGENTS.md` (root)
71
+ # - `USER-GUIDE.md` (root, pre-v15 layout)
72
+ # - `core/docs/hq/USER-GUIDE.md`
73
+ # - `core/policies/_digest.md` (rebuilt by `build-policy-digest.sh`)
74
+ #
75
+ # These paths are either always re-shipped by upstream or auto-regenerated
76
+ # by a script on the next Stop hook. A drifted local copy is just stale,
77
+ # not a customization — saving it under personal/ or .hq-conflicts/ just
78
+ # accumulates noise.
79
+ #
80
+ # Symlinks generated by `master-sync.sh` (targets resolving into
81
+ # `personal/`) are DELETED during the walk rather than preserved. They
82
+ # rebuild deterministically on the next Stop-hook pass — keeping the old
83
+ # link risks shadowing the upstream file the overlay writes at the same
84
+ # path.
85
+ #
86
+ # --cloud-update flag (off by default): the rescue walk recognizes files
87
+ # whose content is `hq-symlink:<target>` as the cloud-flattened
88
+ # serialization of an upstream symlink. hq-sync stores symlinks in S3
89
+ # as small text files with this magic prefix because S3 has no symlink
90
+ # semantics; a vault snapshot pulled via `aws s3 sync` has these in
91
+ # place of symlinks. When the local marker's target matches the upstream
92
+ # symlink's target, the file is reclassified UNCHANGED (deleted; the
93
+ # overlay re-lays the real symlink). Targets that disagree fall through
94
+ # to normal user-edit routing. ONLY enable when --hq-root points at a
95
+ # vault mirror, not a real local HQ tree.
96
+ #
97
+ # If the rescue destination already exists, the moved file is suffixed
98
+ # with `.drift-<unix-ts>` so we never silently overwrite a prior override.
99
+ #
100
+ # Two operating modes (same as the parent script):
101
+ #
102
+ # - Default ("preserve-list"): wipes every top-level entry except a hardcoded
103
+ # preserve set (`.git`, `companies/` except `companies/_template/`,
104
+ # `personal/`, `workspace/`, `repos/`, `.github/`, `.leak-scan/`,
105
+ # `.hq-sync-journal.json`, `.hq/`, `.hq-conflicts/`),
106
+ # plus any paths passed via --preserve.
107
+ #
108
+ # `repos/` is always preserved: it holds user-owned git checkouts
109
+ # (`repos/public/` + `repos/private/`) whose `.git/` directories would
110
+ # shatter under a file-by-file rescue. hq-core never ships a `repos/`
111
+ # tree, so the overlay can't restore it — the only safe handling is
112
+ # to leave it alone entirely.
113
+ #
114
+ # - `--paths`: wipes ONLY the explicit comma-separated list of top-level
115
+ # entries and overlays only those.
116
+ #
117
+ # `--preserve-subpath <rel>` (repeatable) carves out individual files INSIDE
118
+ # the wipe set, copied to a mktemp shuttle pre-wipe and restored post-overlay.
119
+ #
120
+ # Baseline mode — what counts as "user edit":
121
+ #
122
+ # * `history_floor` (default, used when `replaced_from_source.last_sync_sha`
123
+ # is stamped AND reachable in the clone): a file is a USER-EDIT iff its
124
+ # `git hash-object` SHA differs from the blob SHA at that path at the
125
+ # stamped commit. One `git rev-parse <floor>:<rel>` per file — cheap.
126
+ # This is strictly more accurate than the v0.1.103 history-walk index:
127
+ # the floor is the exact point the user diverged from, so no spurious
128
+ # "matches some past staging blob" coincidences mask real edits.
129
+ #
130
+ # * `head_compare` (fallback when no stamp is set OR `--no-history-check`
131
+ # is passed): a file is a USER-EDIT iff `cmp -s` against the upstream
132
+ # HEAD copy disagrees. Loses the ability to distinguish
133
+ # "upstream-removed since last sync" from "user-added new file" — both
134
+ # look like "in local, not in HEAD". Safe default for first-ever runs.
135
+ #
136
+ # Cost: full-history clone with `--filter=blob:none` (~15 MB for
137
+ # hq-core-staging vs. ~5 MB shallow). Per-file blob SHA comparisons are
138
+ # microseconds each. The big v0.1.103 perf footgun (391k-file node_modules
139
+ # walks) is dead now that `repos/` is preserved and node_modules + nested
140
+ # .git are pruned from the walk.
141
+ #
142
+ # Sync-point provenance — `core/core.yaml`:
143
+ #
144
+ # On a successful default-mode (full-replace) run, the script records the
145
+ # source commit it synced to under `core/core.yaml`'s
146
+ # `replaced_from_source:` key (source / ref / last_sync_sha / last_sync_at).
147
+ # On the NEXT run, this is read before the clone and — if the source repo
148
+ # matches and the SHA is reachable in the new clone — used as the
149
+ # **history floor**: the index walks `git log <last_sync_sha>` instead of
150
+ # `git log --all`, scoping it to "blobs the source knew about at our last
151
+ # sync point". A local file whose content matches one of those blobs is
152
+ # provably lag (user had it via prior sync). A local file whose content
153
+ # only matches blobs from AFTER the last sync — i.e. blobs the user
154
+ # couldn't have copied from a sync — is treated as user-authored even if
155
+ # it happens to coincide with a recent source commit.
156
+ #
157
+ # Backwards compat: pre-v0.1.104 runs stamped under the old key
158
+ # `replaced_from_staging`. The read block tries the new key first and
159
+ # falls back to the old one so a user's history-floor benefit survives
160
+ # the rename. The write block stamps the new key AND deletes the old
161
+ # one in the same yq pass so post-migration only one stamp exists.
162
+ #
163
+ # Usage:
164
+ # replace-rescue.sh [--ref REF] [--source OWNER/REPO]
165
+ # [--paths PATH1,PATH2,...]
166
+ # [--preserve PATH]...
167
+ # [--preserve-subpath REL]...
168
+ # [--hq-root DIR]
169
+ # [--no-history-check]
170
+ # [--no-backup] [--backup-dir DIR]
171
+ # [--cloud-update]
172
+ # [--dry-run] [--yes]
173
+ #
174
+ # Defaults:
175
+ # --ref main
176
+ # --source indigoai-us/hq-core-staging
177
+ # --hq-root <script>/../../.. (assumes script lives at personal/skills/<skill>/)
178
+ #
179
+ # Requires: git (for source clone + hash-object), rsync, cmp, awk, grep, sort.
180
+
181
+ set -euo pipefail
182
+
183
+ REF="main"
184
+ SOURCE_REPO="indigoai-us/hq-core-staging"
185
+ DRY_RUN=0
186
+ ASSUME_YES=0
187
+ # Cloud-update mode (--cloud-update). When ON, the rescue walk recognizes
188
+ # files whose content is `hq-symlink:<target>` as the cloud-flattened
189
+ # serialization of an upstream symlink — hq-sync stores symlinks in S3
190
+ # as small text files with this magic prefix because S3 has no symlink
191
+ # semantics. If upstream at this path is a symlink whose target matches
192
+ # the local file's payload, the file is reclassified as UNCHANGED (just
193
+ # deleted; the overlay re-lays the real symlink). Mismatches fall through
194
+ # to normal user-edit routing. OFF by default for safety — only meaningful
195
+ # when --hq-root points at a vault snapshot (e.g. an `aws s3 sync` mirror),
196
+ # not a real local HQ tree where symlinks are real.
197
+ CLOUD_UPDATE=0
198
+ EXTRA_PRESERVE=()
199
+ PRESERVE_SUBPATHS=()
200
+ NARROW_PATHS_CSV=""
201
+ HQ_ROOT_OVERRIDE=""
202
+ HISTORY_CHECK=1
203
+ # Caller-supplied history-floor SHA. When set (40-char hex), overrides
204
+ # the value read from `core/core.yaml`'s `replaced_from_source.last_sync_sha`
205
+ # stamp. Use case: a user whose `core/core.yaml` is unstamped (pre-rescue
206
+ # install — e.g. existing v14.0.0 → v14.2.1 prod upgrade) should still
207
+ # get `history_floor` mode rather than the `head_compare` fallback, which
208
+ # misclassifies every upstream change since their installed version as a
209
+ # USER-EDIT and shoves it into `personal/`. The hq-sync caller resolves
210
+ # `v<installed-hqVersion>` against `--source` via the GitHub API and
211
+ # passes it here so the rescue baselines against the user's actual
212
+ # installed tree, not "any blob in main's history."
213
+ FLOOR_SHA_OVERRIDE=""
214
+
215
+ # Pre-operation safety snapshot (v0.4.1+). The rescue walk MOVES USER-EDIT
216
+ # files into personal/ and DELETES UNCHANGED ones before the overlay. A
217
+ # misclassification (head_compare fallback on an unstamped install, a binary
218
+ # USER-EDIT, an interrupted run) would otherwise be unrecoverable — there was
219
+ # previously NO backup taken by the destructive code itself, only a manual
220
+ # step documented in MIGRATION.md that users skipped. We now snapshot the
221
+ # wipe set to ~/.hq/backups/pre-update-<ts>/ + write a RECOVERY.md manifest
222
+ # BEFORE any destructive op. Default ON; opt out with --no-backup or
223
+ # HQ_RESCUE_NO_BACKUP=1. Override location with --backup-dir / HQ_BACKUP_DIR.
224
+ # Old snapshots older than HQ_BACKUP_RETENTION_DAYS (default 7) are pruned
225
+ # after a successful run — this is the auto-cleanup MIGRATION.md promised.
226
+ DO_BACKUP=1
227
+ [ "${HQ_RESCUE_NO_BACKUP:-0}" = "1" ] && DO_BACKUP=0
228
+ BACKUP_ROOT="${HQ_BACKUP_DIR:-$HOME/.hq/backups}"
229
+ BACKUP_RETENTION_DAYS="${HQ_BACKUP_RETENTION_DAYS:-7}"
230
+ BACKUP_DIR="" # resolved at snapshot time (BACKUP_ROOT/pre-update-<RUN_TS>)
231
+
232
+ # Paths that are ALWAYS preserved across the wipe+overlay, regardless of
233
+ # mode or user flags. Each entry is shuttled to a mktemp area pre-wipe and
234
+ # restored post-overlay (same mechanism as --preserve-subpath), AND skipped
235
+ # by drift detection so the rescue scan never touches them.
236
+ #
237
+ # Why core/packages and packages: both hold user-curated packs (hq-pack-*)
238
+ # resolved through the npm/hq-cli install path; staging may also ship under
239
+ # `core/packages` with different content. Without this carve-out a
240
+ # full-replace would clobber the local pack tree.
241
+ # Why .claude/state: runtime session state — `.claude/state/active-session-*`
242
+ # changes every session; shuttling preserves it across the overlay.
243
+ # Why core/workers/registry.yaml: generated locally by
244
+ # `core/scripts/generate-workers-registry.sh` from the union of core +
245
+ # personal + installed-pack workers. Its content is a function of the
246
+ # user's install state, not the upstream tree, so an overlay that
247
+ # replaces it produces a registry that doesn't match what's on disk
248
+ # (and `master-sync` regenerates it on the next Stop hook anyway).
249
+ # Shuttling preserves the up-to-date local version across the rescue.
250
+ # Why .claude/settings.local.json: per-user local settings (denied paths,
251
+ # env vars, allow-listed tool patterns) authored by the user via the CLI
252
+ # or hand-edits. Never shipped from upstream — it has no upstream version
253
+ # at all. Subjecting it to drift detection would just rescue it into
254
+ # personal/ every run, which is busywork. Shuttle preserves the local
255
+ # version across the overlay so the rescue is a no-op for this file.
256
+ CARVE_OUT_PATHS=( "core/packages" "packages" ".claude/state" "core/workers/registry.yaml" ".claude/settings.local.json" )
257
+
258
+ usage() {
259
+ sed -n '2,55p' "$0"
260
+ exit 1
261
+ }
262
+
263
+ while [ $# -gt 0 ]; do
264
+ case "$1" in
265
+ --ref) REF="$2"; shift 2 ;;
266
+ --source) SOURCE_REPO="$2"; shift 2 ;;
267
+ --preserve) EXTRA_PRESERVE+=("$2"); shift 2 ;;
268
+ --preserve-subpath) PRESERVE_SUBPATHS+=("$2"); shift 2 ;;
269
+ --paths) NARROW_PATHS_CSV="$2"; shift 2 ;;
270
+ --hq-root) HQ_ROOT_OVERRIDE="$2"; shift 2 ;;
271
+ --no-history-check) HISTORY_CHECK=0; shift ;;
272
+ --floor-sha)
273
+ FLOOR_SHA_OVERRIDE="$2"
274
+ # Fail fast on obviously bad input (the rescue is destructive — better
275
+ # to abort here than silently fall through to head_compare halfway in).
276
+ if ! printf '%s' "$FLOOR_SHA_OVERRIDE" | grep -qE '^[0-9a-f]{40}$'; then
277
+ echo "error: --floor-sha must be a 40-char lowercase hex SHA, got: $FLOOR_SHA_OVERRIDE" >&2
278
+ exit 2
279
+ fi
280
+ shift 2 ;;
281
+ --no-backup) DO_BACKUP=0; shift ;;
282
+ --backup-dir) BACKUP_ROOT="$2"; shift 2 ;;
283
+ --dry-run) DRY_RUN=1; shift ;;
284
+ --cloud-update) CLOUD_UPDATE=1; shift ;;
285
+ --yes|-y) ASSUME_YES=1; shift ;;
286
+ -h|--help) usage ;;
287
+ *) echo "unknown arg: $1" >&2; usage ;;
288
+ esac
289
+ done
290
+
291
+ for p in "${EXTRA_PRESERVE[@]+"${EXTRA_PRESERVE[@]}"}"; do
292
+ case "$p" in
293
+ .git|companies|personal|workspace|repos|.github|.leak-scan|.hq-sync-journal.json|.hq|.hq-conflicts|.git/|companies/|personal/|workspace/|repos/|.github/|.leak-scan/|.hq/|.hq-conflicts/)
294
+ echo "error: --preserve $p is redundant ('$p' is always preserved). Remove the flag." >&2
295
+ exit 2
296
+ ;;
297
+ esac
298
+ done
299
+
300
+ for sp in "${PRESERVE_SUBPATHS[@]+"${PRESERVE_SUBPATHS[@]}"}"; do
301
+ case "$sp" in
302
+ /*|*..*)
303
+ echo "error: --preserve-subpath $sp must be a relative path with no '..' segments." >&2
304
+ exit 2
305
+ ;;
306
+ esac
307
+ done
308
+
309
+ # Append the always-preserved carve-outs to PRESERVE_SUBPATHS so the same
310
+ # backup/restore code path handles them. Dedup against any user-supplied
311
+ # entries (defensive — user may also have passed them explicitly).
312
+ for cp in "${CARVE_OUT_PATHS[@]}"; do
313
+ already=0
314
+ for sp in "${PRESERVE_SUBPATHS[@]+"${PRESERVE_SUBPATHS[@]}"}"; do
315
+ if [ "$sp" = "$cp" ]; then already=1; break; fi
316
+ done
317
+ [ "$already" = "1" ] || PRESERVE_SUBPATHS+=("$cp")
318
+ done
319
+
320
+ NARROW_PATHS=()
321
+ if [ -n "$NARROW_PATHS_CSV" ]; then
322
+ IFS=',' read -r -a _NARROW_RAW <<< "$NARROW_PATHS_CSV"
323
+ for raw in "${_NARROW_RAW[@]}"; do
324
+ name="${raw#"${raw%%[![:space:]]*}"}"
325
+ name="${name%"${name##*[![:space:]]}"}"
326
+ if [ -z "$name" ]; then continue; fi
327
+ case "$name" in
328
+ */*|..|.)
329
+ echo "error: --paths entry '$name' must be a single top-level name (no slashes, no '.' or '..')." >&2
330
+ exit 2
331
+ ;;
332
+ esac
333
+ NARROW_PATHS+=("$name")
334
+ done
335
+ if [ "${#NARROW_PATHS[@]}" -eq 0 ]; then
336
+ echo "error: --paths was passed but resolved to an empty list." >&2
337
+ exit 2
338
+ fi
339
+ if [ "${#EXTRA_PRESERVE[@]}" -ne 0 ]; then
340
+ echo "error: --paths and --preserve are mutually exclusive. Use --preserve-subpath for sub-paths inside the listed top-level entries." >&2
341
+ exit 2
342
+ fi
343
+ fi
344
+
345
+ # --- Resolve HQ root (no .git requirement) -----------------------------------
346
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
347
+ if [ -n "$HQ_ROOT_OVERRIDE" ]; then
348
+ HQ_ROOT="$(cd "$HQ_ROOT_OVERRIDE" && pwd)"
349
+ else
350
+ HQ_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
351
+ fi
352
+
353
+ # Sanity: look for `companies/` and `personal/` (not `.git/`). Drift rescue
354
+ # needs personal/ to exist as the override target.
355
+ if [ ! -d "$HQ_ROOT/companies" ] || [ ! -d "$HQ_ROOT/personal" ]; then
356
+ echo "error: $HQ_ROOT does not look like an HQ root (missing companies/ or personal/). Aborting." >&2
357
+ echo " pass --hq-root <dir> if the script is not at personal/skills/<skill>/." >&2
358
+ exit 3
359
+ fi
360
+
361
+ # Per-run timestamp marker. Used in the CLAUDE.md diff-append header and
362
+ # as the suffix on collision-renamed rescue destinations. The v0.1.103
363
+ # `.hq-conflicts/rescue-<ts>/` fallback bucket is gone — every rescue
364
+ # now lands under `personal/`, mkdir-ing the parent dir if missing.
365
+ RUN_TS="$(date -u +%Y-%m-%dT%H-%M-%SZ)"
366
+
367
+ # --- Read prior sync-point metadata (must happen BEFORE the wipe) -----------
368
+ # If the user has run this script before in default mode, core/core.yaml
369
+ # carries the source SHA we last synced to. We use that SHA later (after
370
+ # the clone) as the history-floor: scope the index walk to commits reachable
371
+ # from <last_sync_sha> instead of all branches. Only honored when the
372
+ # previously-recorded source matches the current --source.
373
+ #
374
+ # Try the new key (`replaced_from_source`) first, fall back to the old
375
+ # (`replaced_from_staging`) so a user whose last sync ran on v0.1.103-or-
376
+ # earlier still gets the history-floor benefit on the next run. The yq
377
+ # `//` alternative operator returns the left side when non-null, else the
378
+ # right; the trailing `// ""` collapses null to empty string so the bash
379
+ # checks below stay simple.
380
+ PREV_SYNC_SHA=""
381
+ PREV_SYNC_SOURCE=""
382
+ PREV_SYNC_REF=""
383
+ PREV_SYNC_AT=""
384
+ if [ -f "$HQ_ROOT/core/core.yaml" ] && command -v yq >/dev/null 2>&1; then
385
+ PREV_SYNC_SHA="$(yq -r '.replaced_from_source.last_sync_sha // .replaced_from_staging.last_sync_sha // ""' "$HQ_ROOT/core/core.yaml" 2>/dev/null || true)"
386
+ PREV_SYNC_SOURCE="$(yq -r '.replaced_from_source.source // .replaced_from_staging.source // ""' "$HQ_ROOT/core/core.yaml" 2>/dev/null || true)"
387
+ PREV_SYNC_REF="$(yq -r '.replaced_from_source.ref // .replaced_from_staging.ref // ""' "$HQ_ROOT/core/core.yaml" 2>/dev/null || true)"
388
+ PREV_SYNC_AT="$(yq -r '.replaced_from_source.last_sync_at // .replaced_from_staging.last_sync_at // ""' "$HQ_ROOT/core/core.yaml" 2>/dev/null || true)"
389
+ fi
390
+
391
+ # Caller override (--floor-sha) wins when the on-disk stamp is empty.
392
+ # Set PREV_SYNC_SOURCE to the current --source so the source-match check
393
+ # below ("only honor the floor when the previously-recorded source
394
+ # matches the current --source") trivially passes — the caller has
395
+ # already verified the SHA resolves to a commit in `--source`.
396
+ #
397
+ # We intentionally do NOT overwrite a non-empty stamp: a real prior-sync
398
+ # SHA is strictly more accurate than a caller's "best guess" floor
399
+ # derived from the installed `hqVersion` tag (the user may have run a
400
+ # rescue since the version stamp was written, advancing the floor).
401
+ if [ -z "$PREV_SYNC_SHA" ] && [ -n "$FLOOR_SHA_OVERRIDE" ]; then
402
+ PREV_SYNC_SHA="$FLOOR_SHA_OVERRIDE"
403
+ PREV_SYNC_SOURCE="$SOURCE_REPO"
404
+ PREV_SYNC_REF="(caller-supplied floor)"
405
+ PREV_SYNC_AT="(unstamped install)"
406
+ fi
407
+
408
+ echo "==> HQ root: $HQ_ROOT"
409
+ echo "==> Source: https://github.com/$SOURCE_REPO @ $REF"
410
+ if [ -n "$PREV_SYNC_SHA" ]; then
411
+ if [ "$PREV_SYNC_SOURCE" = "$SOURCE_REPO" ]; then
412
+ echo "==> Prior sync: $PREV_SYNC_SHA from $PREV_SYNC_SOURCE@$PREV_SYNC_REF ($PREV_SYNC_AT) — will use as history floor"
413
+ else
414
+ echo "==> Prior sync: $PREV_SYNC_SHA from $PREV_SYNC_SOURCE (different source — ignoring as floor)"
415
+ fi
416
+ fi
417
+ if [ "${#NARROW_PATHS[@]}" -ne 0 ]; then
418
+ echo "==> Mode: narrow (--paths)"
419
+ echo "==> Wipe set: ${NARROW_PATHS[*]}"
420
+ else
421
+ echo "==> Mode: preserve-list (default)"
422
+ echo "==> Preserved: .git, companies (except companies/_template), personal, workspace, repos, .github, .leak-scan, .hq-sync-journal.json, .hq, .hq-conflicts${EXTRA_PRESERVE[*]+, ${EXTRA_PRESERVE[*]}}"
423
+ fi
424
+ if [ "${#PRESERVE_SUBPATHS[@]}" -ne 0 ]; then
425
+ echo "==> Preserved subpaths (backed up + restored across the overlay):"
426
+ for sp in "${PRESERVE_SUBPATHS[@]}"; do
427
+ # Mark the always-on carve-outs so it's obvious they're not from --preserve-subpath.
428
+ is_carve=0
429
+ for cp in "${CARVE_OUT_PATHS[@]}"; do
430
+ if [ "$cp" = "$sp" ]; then is_carve=1; break; fi
431
+ done
432
+ if [ "$is_carve" = "1" ]; then
433
+ echo " - $sp (always-on carve-out)"
434
+ else
435
+ echo " - $sp"
436
+ fi
437
+ done
438
+ fi
439
+ echo "==> Drift policy: rescue user-edited files to personal/; route .agents|.codex|.obsidian|MIGRATION.md user-edits to .hq-conflicts/rescue-$RUN_TS/; silently overwrite AGENTS.md|USER-GUIDE.md|core/policies/_digest.md (regenerable); leave user-only files untouched; drop master-sync symlinks (regenerated by master-sync.sh)"
440
+ if [ "$CLOUD_UPDATE" = "1" ]; then
441
+ echo "==> Cloud-update mode: ON (recognize \`hq-symlink:<target>\` flat files as upstream-symlink-equivalent — reconciled as UNCHANGED)"
442
+ fi
443
+ if [ "$HISTORY_CHECK" = "1" ]; then
444
+ echo "==> History gate: ON (skip drift if local matches any past staging blob at that path)"
445
+ else
446
+ echo "==> History gate: OFF (--no-history-check; every diff rescued)"
447
+ fi
448
+ if [ "$DO_BACKUP" = "1" ]; then
449
+ echo "==> Safety backup: ON -> $BACKUP_ROOT/pre-update-$RUN_TS (retention ${BACKUP_RETENTION_DAYS}d)"
450
+ else
451
+ echo "==> Safety backup: OFF (--no-backup / HQ_RESCUE_NO_BACKUP=1)"
452
+ fi
453
+ [ "$DRY_RUN" = "1" ] && echo "==> DRY RUN (no destructive operations will run)"
454
+
455
+ if [ "$ASSUME_YES" != "1" ] && [ "$DRY_RUN" != "1" ]; then
456
+ _backup_line=" * NO pre-op backup (--no-backup),"
457
+ [ "$DO_BACKUP" = "1" ] && _backup_line=" * snapshot the wipe set to $BACKUP_ROOT/pre-update-$RUN_TS first,"
458
+ _cloud_line=""
459
+ if [ "$CLOUD_UPDATE" = "1" ]; then
460
+ _cloud_line="\n * reconcile \`hq-symlink:<target>\` flat files against upstream symlinks (cloud-update mode),"
461
+ fi
462
+ printf "\nThis will:\n%s\n * rescue user-edited files (vs the last sync) into personal/,\n * route user-edited .agents/.codex/.obsidian/MIGRATION.md into .hq-conflicts/rescue-%s/ for review,\n * silently overwrite AGENTS.md / USER-GUIDE.md / core/policies/_digest.md (regenerable from upstream or master-sync — no copy preserved),%b\n * drop master-sync-generated symlinks (regenerated by master-sync.sh on next Stop hook),\n * leave user-only files (not in upstream) untouched,\n * delete upstream files unchanged since last sync, then unpack %s@%s on top.\nType 'yes' to proceed: " "$_backup_line" "$RUN_TS" "$_cloud_line" "$SOURCE_REPO" "$REF"
463
+ read -r confirm
464
+ [ "$confirm" = "yes" ] || { echo "Aborted."; exit 4; }
465
+ fi
466
+
467
+ TMPDIR="$(mktemp -d -t hq-replace-rescue-XXXXXX)"
468
+ trap 'rm -rf "$TMPDIR"' EXIT
469
+
470
+ # Append-only record of every file the walk MOVES (rescue) or DELETES
471
+ # (unchanged). Folded into the snapshot's RECOVERY.md manifest after the run
472
+ # so a user can see exactly what changed and restore any single file.
473
+ RESCUE_LOG="$TMPDIR/rescue-actions.log"
474
+ : > "$RESCUE_LOG"
475
+
476
+ # Build the clone URL. If GH_TOKEN is set in the environment, inject it as
477
+ # the basic-auth user so `git clone` can access private staging repos
478
+ # without an interactive credential prompt. This is the form the GitHub
479
+ # docs recommend for token-based git over HTTPS:
480
+ # https://x-access-token:<token>@github.com/<owner>/<repo>.git
481
+ # The hq-sync Tauri caller resolves GH_TOKEN via `gh auth token` (same
482
+ # path the existing drift-classifier uses) before spawning this script.
483
+ if [ -n "${GH_TOKEN:-}" ]; then
484
+ CLONE_URL="https://x-access-token:${GH_TOKEN}@github.com/$SOURCE_REPO.git"
485
+ CLONE_URL_DISPLAY="https://x-access-token:***@github.com/$SOURCE_REPO.git"
486
+ else
487
+ CLONE_URL="https://github.com/$SOURCE_REPO.git"
488
+ CLONE_URL_DISPLAY="$CLONE_URL"
489
+ fi
490
+
491
+ echo ""
492
+ if [ "$HISTORY_CHECK" = "1" ]; then
493
+ # Full commit/tree history (needed for the path → all-time-SHA index) but
494
+ # lazy blob fetching. We never need blob contents for the index — only
495
+ # blob SHAs from `git log --raw` — so blobs are never lazy-fetched in
496
+ # practice. Checkout of HEAD does fetch HEAD-tree blobs, which we need
497
+ # anyway for the cmp-based current-state comparison.
498
+ echo "==> Cloning $CLONE_URL_DISPLAY @$REF (full history, blob:none filter) ..."
499
+ git clone --filter=blob:none "$CLONE_URL" "$TMPDIR/src" >/dev/null 2>&1 || {
500
+ echo "error: clone failed" >&2; exit 5
501
+ }
502
+ (cd "$TMPDIR/src" && git checkout "$REF" >/dev/null 2>&1) || {
503
+ echo "error: could not check out ref '$REF' from $SOURCE_REPO" >&2
504
+ exit 5
505
+ }
506
+ else
507
+ echo "==> Cloning $CLONE_URL_DISPLAY @$REF (shallow) ..."
508
+ git clone --depth 1 --branch "$REF" "$CLONE_URL" "$TMPDIR/src" >/dev/null 2>&1 || {
509
+ echo " (shallow branch clone failed; trying full clone + checkout)"
510
+ git clone "$CLONE_URL" "$TMPDIR/src" >/dev/null
511
+ (cd "$TMPDIR/src" && git checkout "$REF" >/dev/null 2>&1) || {
512
+ echo "error: could not check out ref '$REF' from $SOURCE_REPO" >&2
513
+ exit 5
514
+ }
515
+ }
516
+ fi
517
+
518
+ SRC_SHA="$(cd "$TMPDIR/src" && git rev-parse HEAD)"
519
+ echo "==> Source SHA: $SRC_SHA"
520
+
521
+ # --- Resolve the history floor (last-sync SHA reachable in clone?) ----------
522
+ #
523
+ # The v0.1.104 algorithm drops the path → all-time-SHA index in favour of
524
+ # per-file `git rev-parse <floor>:<rel>` comparisons. The floor is just the
525
+ # stamped `replaced_from_source.last_sync_sha`, validated to be reachable
526
+ # in this clone. When reachable, BASELINE_MODE=history_floor; otherwise
527
+ # BASELINE_MODE=head_compare (cmp local vs upstream HEAD).
528
+ #
529
+ # Reachability check uses `git cat-file -e` because commits + trees are
530
+ # always fully fetched even under --filter=blob:none. The floor SHA's
531
+ # tree is what we'll consult per file; blobs at that tree may be lazy
532
+ # but `git rev-parse <floor>:<rel>` only needs the tree, not the blob
533
+ # contents.
534
+ HISTORY_FLOOR=""
535
+ BASELINE_MODE="head_compare"
536
+ if [ "$HISTORY_CHECK" = "1" ] && [ -n "$PREV_SYNC_SHA" ] && [ "$PREV_SYNC_SOURCE" = "$SOURCE_REPO" ]; then
537
+ if (cd "$TMPDIR/src" && git cat-file -e "$PREV_SYNC_SHA" 2>/dev/null); then
538
+ HISTORY_FLOOR="$PREV_SYNC_SHA"
539
+ BASELINE_MODE="history_floor"
540
+ else
541
+ echo " prior-sync SHA $PREV_SYNC_SHA not reachable in clone (rebased/dropped?); falling back to head_compare"
542
+ fi
543
+ fi
544
+ if [ "$BASELINE_MODE" = "history_floor" ]; then
545
+ echo "==> Baseline: $HISTORY_FLOOR (last-sync floor reachable)"
546
+ else
547
+ echo "==> Baseline: HEAD compare (no usable last-sync stamp; first-ever run or stamp mismatch)"
548
+ fi
549
+
550
+ # --- Build wipe/overlay arg sets per mode ------------------------------------
551
+ PRUNE_ARGS=()
552
+ RSYNC_EXCLUDES=()
553
+
554
+ if [ "${#NARROW_PATHS[@]}" -ne 0 ]; then
555
+ for n in "${NARROW_PATHS[@]}"; do
556
+ RSYNC_EXCLUDES+=( --include="/$n" )
557
+ RSYNC_EXCLUDES+=( --include="/$n/***" )
558
+ done
559
+ RSYNC_EXCLUDES+=( --exclude='/*' )
560
+ else
561
+ PRUNE_ARGS=( -not -name .git -not -name companies -not -name personal -not -name workspace -not -name repos -not -name .github -not -name .leak-scan -not -name .hq-sync-journal.json -not -name .hq -not -name .hq-conflicts )
562
+ for p in "${EXTRA_PRESERVE[@]+"${EXTRA_PRESERVE[@]}"}"; do
563
+ PRUNE_ARGS+=( -not -name "$p" )
564
+ done
565
+ RSYNC_EXCLUDES=(
566
+ --exclude=.git
567
+ --exclude=personal
568
+ --exclude=workspace
569
+ --exclude=repos
570
+ --exclude=.github
571
+ --exclude=.leak-scan
572
+ --exclude=.hq-sync-journal.json
573
+ --exclude=.hq
574
+ --exclude=.hq-conflicts
575
+ --include='/companies/'
576
+ --include='/companies/_template/***'
577
+ --exclude='/companies/*'
578
+ )
579
+ for p in "${EXTRA_PRESERVE[@]+"${EXTRA_PRESERVE[@]}"}"; do
580
+ RSYNC_EXCLUDES+=( --exclude="$p" )
581
+ done
582
+ fi
583
+
584
+ # --- Compute the wipe-set roots (top-level entries that will be deleted) -----
585
+ WIPE_TOPLEVEL=()
586
+ if [ "${#NARROW_PATHS[@]}" -ne 0 ]; then
587
+ for n in "${NARROW_PATHS[@]}"; do
588
+ [ -e "$HQ_ROOT/$n" ] && WIPE_TOPLEVEL+=("$n")
589
+ done
590
+ else
591
+ while IFS= read -r line; do
592
+ rel="${line#./}"
593
+ WIPE_TOPLEVEL+=("$rel")
594
+ done < <( cd "$HQ_ROOT" && find . -mindepth 1 -maxdepth 1 "${PRUNE_ARGS[@]}" -print )
595
+ # companies/_template carve-out (wiped in default mode)
596
+ [ -d "$HQ_ROOT/companies/_template" ] && WIPE_TOPLEVEL+=("companies/_template")
597
+ fi
598
+
599
+ # --- Drift detection + rescue (v0.1.104 algorithm) --------------------------
600
+ #
601
+ # For each file under each wipe-set top-level entry, three-way classify
602
+ # as USER-ONLY / UNCHANGED / USER-EDIT and act:
603
+ #
604
+ # USER-ONLY -> leave in place (skip — neither rescued nor deleted).
605
+ # The rsync overlay below runs WITHOUT --delete, so any
606
+ # file we leave alone survives the operation cleanly.
607
+ # UNCHANGED -> rm -f the local copy. Overlay re-creates from source.
608
+ # USER-EDIT -> rescue (mv to personal/, or diff-append for CLAUDE.md),
609
+ # then the rm is implicit (rescue_one mv'd it).
610
+ #
611
+ # Counters surfaced in the post-run summary.
612
+ COUNT_USER_ONLY=0
613
+ COUNT_UNCHANGED=0
614
+ COUNT_USER_EDIT=0
615
+ COUNT_USER_CONFLICT=0
616
+ COUNT_USER_OVERWRITE=0
617
+ COUNT_CLAUDE_DIFF_APPEND=0
618
+ COUNT_SYMLINK_DROPPED=0
619
+ COUNT_CLOUD_SYMLINK_RECONCILED=0
620
+
621
+ # True if $rel is under any always-preserved subpath. Drift detection
622
+ # skips these — they're shuttled out, the wipe+overlay runs, then they're
623
+ # restored unchanged. Detecting them as drifts would just move them to
624
+ # personal/ and then leave a phantom-restored copy back at their original
625
+ # location.
626
+ is_under_preserve() {
627
+ local rel="$1" sp
628
+ for sp in "${PRESERVE_SUBPATHS[@]+"${PRESERVE_SUBPATHS[@]}"}"; do
629
+ case "$rel" in
630
+ "$sp"|"$sp"/*) return 0 ;;
631
+ esac
632
+ done
633
+ return 1
634
+ }
635
+
636
+ # Map a USER-EDIT path to its personal/ rescue target. Always lands under
637
+ # personal/ — no fallback bucket. Strips `.claude/` or `core/` prefix so
638
+ # `.claude/policies/foo.md` -> `personal/policies/foo.md`, etc. Caller
639
+ # `mkdir -p "$(dirname dest)"` to ensure the parent exists.
640
+ map_rescue_target() {
641
+ local rel="$1"
642
+ if [ "$rel" = ".claude/CLAUDE.md" ]; then
643
+ echo "personal/CLAUDE.md"
644
+ return
645
+ fi
646
+ local rest=""
647
+ case "$rel" in
648
+ .claude/*) rest="${rel#.claude/}" ;;
649
+ core/*) rest="${rel#core/}" ;;
650
+ *) rest="$rel" ;;
651
+ esac
652
+ echo "personal/$rest"
653
+ }
654
+
655
+ # Diff-append the user's additions in .claude/CLAUDE.md to personal/CLAUDE.md.
656
+ # "Additions" are lines present locally but not in baseline (the last-sync
657
+ # version, or current HEAD when no floor). Removed/modified lines are NOT
658
+ # preserved by this algorithm — users rarely delete from CLAUDE.md and the
659
+ # complexity of a true three-way merge isn't worth it in v1. If the user
660
+ # did delete or modify lines they'll need to manually reconcile against
661
+ # the new upstream version.
662
+ #
663
+ # Header marker (`<!-- drift-append ... -->`) timestamps the run + records
664
+ # the source SHA so a user reading personal/CLAUDE.md can audit when each
665
+ # block landed.
666
+ diff_append_claude_md() {
667
+ local local_file="$HQ_ROOT/.claude/CLAUDE.md"
668
+ local personal_file="$HQ_ROOT/personal/CLAUDE.md"
669
+ local baseline_file
670
+ baseline_file="$(mktemp -t claude-md-baseline.XXXXXX)"
671
+
672
+ # Try the floor first, then HEAD. Either provides a "what the user
673
+ # started from" snapshot.
674
+ if [ "$BASELINE_MODE" = "history_floor" ]; then
675
+ (cd "$TMPDIR/src" && git show "$HISTORY_FLOOR:.claude/CLAUDE.md" > "$baseline_file" 2>/dev/null) || :
676
+ fi
677
+ if [ ! -s "$baseline_file" ] && [ -f "$TMPDIR/src/.claude/CLAUDE.md" ]; then
678
+ cp "$TMPDIR/src/.claude/CLAUDE.md" "$baseline_file"
679
+ fi
680
+
681
+ mkdir -p "$HQ_ROOT/personal"
682
+
683
+ if [ ! -s "$baseline_file" ]; then
684
+ # No baseline at all — record the whole local file as a drift block
685
+ # under a marker noting the absence of a baseline.
686
+ rm -f "$baseline_file"
687
+ {
688
+ [ -s "$personal_file" ] && printf '\n'
689
+ printf '<!-- drift-append from .claude/CLAUDE.md @ %s (source %s; no baseline available) -->\n' \
690
+ "$RUN_TS" "$SRC_SHA"
691
+ cat "$local_file"
692
+ } >> "$personal_file"
693
+ echo " diff-appended (full local, no baseline): .claude/CLAUDE.md -> personal/CLAUDE.md"
694
+ COUNT_CLAUDE_DIFF_APPEND=$((COUNT_CLAUDE_DIFF_APPEND + 1))
695
+ return
696
+ fi
697
+
698
+ # Extract additions via `diff -u`. The sed strips the `+`/`+++` prefixes
699
+ # and preserves blank-line additions. Lost: file-header (`+++ local`)
700
+ # which we filter explicitly.
701
+ local additions_file
702
+ additions_file="$(mktemp -t claude-md-additions.XXXXXX)"
703
+ diff -u "$baseline_file" "$local_file" 2>/dev/null \
704
+ | sed -n '/^+++/!{ /^+/{ s/^+//; p } }' \
705
+ > "$additions_file" || :
706
+ rm -f "$baseline_file"
707
+
708
+ if [ ! -s "$additions_file" ]; then
709
+ rm -f "$additions_file"
710
+ echo " no user edits to .claude/CLAUDE.md (skipped diff-append)"
711
+ return
712
+ fi
713
+
714
+ {
715
+ [ -s "$personal_file" ] && printf '\n'
716
+ printf '<!-- drift-append from .claude/CLAUDE.md @ %s (source %s) -->\n' \
717
+ "$RUN_TS" "$SRC_SHA"
718
+ cat "$additions_file"
719
+ } >> "$personal_file"
720
+ rm -f "$additions_file"
721
+ echo " diff-appended: .claude/CLAUDE.md additions -> personal/CLAUDE.md"
722
+ COUNT_CLAUDE_DIFF_APPEND=$((COUNT_CLAUDE_DIFF_APPEND + 1))
723
+ }
724
+
725
+ # Rescue a single USER-EDIT file. Special-cases .claude/CLAUDE.md to
726
+ # diff-append. All other paths land under personal/<rest>, mkdir-ing the
727
+ # parent dir as needed. Collision suffix: .drift-<unix-ts>-<pid>.
728
+ # Implicitly removes the original local path (CLAUDE.md is rm'd after
729
+ # the diff-append; others are mv'd off).
730
+ rescue_one() {
731
+ local rel="$1"
732
+ local local_path="$HQ_ROOT/$rel"
733
+ COUNT_USER_EDIT=$((COUNT_USER_EDIT + 1))
734
+
735
+ if [ "$rel" = ".claude/CLAUDE.md" ]; then
736
+ diff_append_claude_md
737
+ rm -f "$local_path"
738
+ printf 'rescued\t%s\t-> personal/CLAUDE.md (diff-append)\n' "$rel" >> "$RESCUE_LOG" 2>/dev/null || true
739
+ return
740
+ fi
741
+
742
+ local target
743
+ target="$(map_rescue_target "$rel")"
744
+ local dest="$HQ_ROOT/$target"
745
+ mkdir -p "$(dirname "$dest")"
746
+ if [ -e "$dest" ]; then
747
+ dest="${dest}.drift-$(date +%s)-$$"
748
+ fi
749
+ mv "$local_path" "$dest"
750
+ echo " rescued: $rel -> ${dest#"$HQ_ROOT/"}"
751
+ printf 'rescued\t%s\t-> %s\n' "$rel" "${dest#"$HQ_ROOT/"}" >> "$RESCUE_LOG" 2>/dev/null || true
752
+ }
753
+
754
+ # Paths whose USER-EDIT outcome lands in .hq-conflicts/ rather than
755
+ # personal/. These are runtime-managed dirs (IDE/agent state) and the
756
+ # legacy root MIGRATION.md — they aren't user-overlay material the way
757
+ # personal/ files are:
758
+ #
759
+ # .agents/ — agent runtime artifacts (regenerated; not a customization)
760
+ # .codex/ — codex runtime/cache (per-machine; not portable as overlay)
761
+ # .obsidian/ — vault UI state (workspace.json, graph.json — local-only)
762
+ # MIGRATION.md — release migration doc; always re-shipped by overlay,
763
+ # a divergent local copy means an upgrade snag worth
764
+ # reviewing, not a customization to preserve under
765
+ # personal/.
766
+ #
767
+ # Routing them to .hq-conflicts/rescue-<RUN_TS>/<rel> keeps them quarantined
768
+ # under a timestamped bucket the user can inspect and discard, without
769
+ # polluting the personal/ overlay tree (where master-sync would then try
770
+ # to re-mirror them into core/).
771
+ is_conflict_class() {
772
+ local rel="$1"
773
+ case "$rel" in
774
+ .agents|.agents/*) return 0 ;;
775
+ .codex|.codex/*) return 0 ;;
776
+ .obsidian|.obsidian/*) return 0 ;;
777
+ MIGRATION.md) return 0 ;;
778
+ esac
779
+ return 1
780
+ }
781
+
782
+ # Move a USER-EDIT file into the .hq-conflicts/rescue-<RUN_TS>/ quarantine
783
+ # bucket. Parallel to rescue_one() but never touches personal/.
784
+ conflict_one() {
785
+ local rel="$1"
786
+ local local_path="$HQ_ROOT/$rel"
787
+ COUNT_USER_CONFLICT=$((COUNT_USER_CONFLICT + 1))
788
+
789
+ local target=".hq-conflicts/rescue-$RUN_TS/$rel"
790
+ local dest="$HQ_ROOT/$target"
791
+ mkdir -p "$(dirname "$dest")"
792
+ if [ -e "$dest" ]; then
793
+ dest="${dest}.drift-$(date +%s)-$$"
794
+ fi
795
+ mv "$local_path" "$dest"
796
+ echo " conflicted: $rel -> ${dest#"$HQ_ROOT/"}"
797
+ printf 'conflicted\t%s\t-> %s\n' "$rel" "${dest#"$HQ_ROOT/"}" >> "$RESCUE_LOG" 2>/dev/null || true
798
+ }
799
+
800
+ # Paths whose USER-EDIT is SILENTLY OVERWRITTEN by the overlay — no rescue
801
+ # to personal/, no quarantine to .hq-conflicts/. The local copy is either
802
+ # auto-regenerated by another script or always re-shipped by upstream, so a
803
+ # divergent local version is just stale and the upstream version should
804
+ # land cleanly. This is the third user-edit outcome (alongside rescue +
805
+ # conflict) — semantically equivalent to UNCHANGED, but reached even when
806
+ # the bytes differ.
807
+ #
808
+ # AGENTS.md — root alias; always re-shipped by upstream.
809
+ # Locally a symlink to .claude/CLAUDE.md in
810
+ # a healthy HQ; vault snapshots that flatten
811
+ # to a file always read as drifted.
812
+ # USER-GUIDE.md — pre-v15 root copy; always re-shipped.
813
+ # core/docs/hq/USER-GUIDE.md — current release doc; always re-shipped.
814
+ # core/policies/_digest.md — generated by `core/scripts/build-policy-digest.sh`
815
+ # from the union of policy files; rebuilt on
816
+ # the next Stop-hook master-sync pass.
817
+ # Same rationale as core/workers/registry.yaml,
818
+ # minus the carve-out (digest is small + cheap
819
+ # to regenerate, so just let the overlay win).
820
+ is_overwrite_safe() {
821
+ local rel="$1"
822
+ case "$rel" in
823
+ AGENTS.md) return 0 ;;
824
+ USER-GUIDE.md) return 0 ;;
825
+ core/docs/hq/USER-GUIDE.md) return 0 ;;
826
+ core/policies/_digest.md) return 0 ;;
827
+ esac
828
+ return 1
829
+ }
830
+
831
+ # Cloud-flattened symlink detection (only meaningful with --cloud-update).
832
+ # hq-sync serializes symlinks to S3 as small text files whose entire content
833
+ # is `hq-symlink:<target>` (no newline; <target> is the same string the
834
+ # symlink would resolve to). A vault snapshot pulled via `aws s3 sync` has
835
+ # these in place of symlinks. When --cloud-update is on AND the upstream
836
+ # blob at $rel is a symlink AND the local file is an `hq-symlink:<target>`
837
+ # marker AND <target> matches the upstream symlink's target, the file is
838
+ # semantically equivalent to upstream — not a user edit — so we route it
839
+ # through the UNCHANGED branch (delete; the overlay re-lays the real
840
+ # symlink). Returns 0 (reconciled / equivalent), 1 (not equivalent / not
841
+ # a marker / upstream is not a symlink).
842
+ #
843
+ # Tradeoffs:
844
+ # - Read of local file's first line; cheap for tiny markers.
845
+ # - Two git calls per candidate (ls-tree mode check + symlink-target
846
+ # fetch); only triggered for files already flagged user-edited under
847
+ # --cloud-update, so the hot path is unaffected.
848
+ # - Strict: target must match exactly. A drifted marker (`hq-symlink:`
849
+ # pointing somewhere the upstream symlink no longer points) is still
850
+ # treated as a user-edit and routed normally — never silently
851
+ # overwritten.
852
+ is_cloud_flattened_symlink_equiv() {
853
+ local rel="$1"
854
+ local local_path="$2"
855
+ [ "$CLOUD_UPDATE" = "1" ] || return 1
856
+ [ -f "$local_path" ] || return 1
857
+
858
+ # File must START with the magic marker. `hq-symlink:` is 11 bytes; we
859
+ # read exactly 11 to compare. Anything else (binary blob, longer doc,
860
+ # different prefix) bails out cheaply.
861
+ local head_bytes
862
+ head_bytes="$(head -c 11 "$local_path" 2>/dev/null || true)"
863
+ [ "$head_bytes" = "hq-symlink:" ] || return 1
864
+
865
+ # Extract the target the marker points to. Strip the prefix; tolerate
866
+ # an optional trailing newline (some serializers add one, some don't).
867
+ local local_target
868
+ local_target="$(sed -n '1p' "$local_path" 2>/dev/null | sed 's/^hq-symlink://' | tr -d '\n')"
869
+ [ -n "$local_target" ] || return 1
870
+
871
+ # Upstream blob at $rel must be a symlink (mode 120000). Look up the
872
+ # tree entry mode at the floor commit (history_floor mode) or via the
873
+ # checked-out source tree (head_compare fallback).
874
+ local mode
875
+ if [ "$BASELINE_MODE" = "history_floor" ]; then
876
+ mode="$(cd "$TMPDIR/src" && git ls-tree "$HISTORY_FLOOR" -- "$rel" 2>/dev/null | awk '{print $1}')"
877
+ else
878
+ mode="$(cd "$TMPDIR/src" && git ls-tree HEAD -- "$rel" 2>/dev/null | awk '{print $1}')"
879
+ fi
880
+ [ "$mode" = "120000" ] || return 1
881
+
882
+ # Read upstream symlink's target. For a symlink blob, `git show` returns
883
+ # the target path as plain text (no newline).
884
+ local upstream_target
885
+ if [ "$BASELINE_MODE" = "history_floor" ]; then
886
+ upstream_target="$(cd "$TMPDIR/src" && git show "$HISTORY_FLOOR:$rel" 2>/dev/null)"
887
+ else
888
+ upstream_target="$(cd "$TMPDIR/src" && git show "HEAD:$rel" 2>/dev/null)"
889
+ fi
890
+ [ -n "$upstream_target" ] || return 1
891
+
892
+ # Equivalent iff the targets match byte-for-byte.
893
+ [ "$local_target" = "$upstream_target" ]
894
+ }
895
+
896
+ # True when $local_path is a symlink generated by master-sync.sh — those
897
+ # point into personal/ (e.g. `core/<type>/<entry>` -> `../../personal/...`,
898
+ # `.claude/skills/<ns>:<entry>` -> `../../../personal/skills/<entry>/...`,
899
+ # `.claude/commands/<ns>/<entry>.md` -> `../../../personal/skills/<entry>/SKILL.md`).
900
+ # They are deterministically rebuilt on the next master-sync.sh run, so the
901
+ # safe move during rescue is to delete rather than preserve — a leftover
902
+ # symlink can shadow an overlaid-from-upstream real file at the same path.
903
+ is_master_sync_symlink() {
904
+ local local_path="$1"
905
+ [ -L "$local_path" ] || return 1
906
+ local tgt
907
+ tgt="$(readlink "$local_path" 2>/dev/null || true)"
908
+ case "$tgt" in
909
+ */personal/*|personal/*) return 0 ;;
910
+ esac
911
+ return 1
912
+ }
913
+
914
+ # Classify + act on one file. The per-file workhorse of the new algorithm.
915
+ process_one() {
916
+ local rel="$1"
917
+ local local_path="$HQ_ROOT/$rel"
918
+ local src_path="$TMPDIR/src/$rel"
919
+
920
+ # Always-preserved paths bypass drift detection entirely (shuttle owns
921
+ # them across the overlay).
922
+ if is_under_preserve "$rel"; then
923
+ return 0
924
+ fi
925
+
926
+ # Conflict-resolution artifacts (HQ-Sync renames divergent local files
927
+ # to `<name>.conflict-<ts>-<peer>.<ext>`). Never authored, never in
928
+ # upstream — rm them so the wipe consumes them rather than dragging
929
+ # them to personal/.
930
+ case "${rel##*/}" in
931
+ *.conflict-*)
932
+ if [ "$DRY_RUN" = "1" ]; then
933
+ echo " drop conflict artifact: $rel"
934
+ else
935
+ rm -f "$local_path"
936
+ fi
937
+ return 0
938
+ ;;
939
+ esac
940
+
941
+ # Script-managed files: core/core.yaml (v14+) and core.yaml (pre-v14
942
+ # legacy layout). The script reads the prior stamp at the start of the
943
+ # run and rewrites a fresh stamp at the end (see the yq/python block
944
+ # below). Subjecting these to drift detection just creates noise: the
945
+ # local file always differs from the floor because we wrote a new
946
+ # stamp last run, but that's not a user edit — it's our own output.
947
+ # Skip rescue, just delete; the overlay restores the fresh upstream
948
+ # version, then the stamp step updates it with the new sync point.
949
+ case "$rel" in
950
+ core/core.yaml|core.yaml)
951
+ if [ "$DRY_RUN" = "1" ]; then
952
+ echo " skip script-managed (rewrites at stamp step): $rel"
953
+ else
954
+ rm -f "$local_path"
955
+ fi
956
+ return 0
957
+ ;;
958
+ esac
959
+
960
+ # Symlinks (mid-tree). master-sync.sh deterministically rebuilds the
961
+ # personal/-targeted ones on its next Stop-hook pass, so the safe move
962
+ # here is to delete them — a leftover symlink at a path the overlay
963
+ # also writes to creates an ambiguous "is this the symlink or the
964
+ # overlaid file?" state on disk. Non-master-sync symlinks (user-created,
965
+ # absolute targets, or targets outside personal/) are left alone.
966
+ if [ -L "$local_path" ]; then
967
+ if is_master_sync_symlink "$local_path"; then
968
+ COUNT_SYMLINK_DROPPED=$((COUNT_SYMLINK_DROPPED + 1))
969
+ if [ "$DRY_RUN" = "1" ]; then
970
+ echo " drop master-sync symlink: $rel -> $(readlink "$local_path" 2>/dev/null || true)"
971
+ else
972
+ rm -f "$local_path"
973
+ printf 'symlink-dropped\t%s\t(master-sync regenerable)\n' "$rel" >> "$RESCUE_LOG" 2>/dev/null || true
974
+ fi
975
+ fi
976
+ return 0
977
+ fi
978
+
979
+ # Is path in upstream HEAD?
980
+ local in_head=0
981
+ [ -e "$src_path" ] && in_head=1
982
+
983
+ # Is path in last-sync floor (if we have one)?
984
+ local in_floor=0
985
+ if [ "$BASELINE_MODE" = "history_floor" ]; then
986
+ if (cd "$TMPDIR/src" && git cat-file -e "$HISTORY_FLOOR:$rel" 2>/dev/null); then
987
+ in_floor=1
988
+ fi
989
+ fi
990
+
991
+ # USER-ONLY: path is unknown to upstream (HEAD AND floor both lack it).
992
+ # The user created this — leave it alone. The overlay (no --delete)
993
+ # doesn't touch files outside its source manifest, so the file survives.
994
+ if [ "$in_head" = "0" ] && [ "$in_floor" = "0" ]; then
995
+ COUNT_USER_ONLY=$((COUNT_USER_ONLY + 1))
996
+ if [ "$DRY_RUN" = "1" ]; then
997
+ echo " user-only (leave in place): $rel"
998
+ fi
999
+ return 0
1000
+ fi
1001
+
1002
+ # Path is/was in upstream. Determine if user edited it.
1003
+ local user_edited=0
1004
+ if [ "$BASELINE_MODE" = "history_floor" ] && [ "$in_floor" = "1" ]; then
1005
+ # Compare local blob SHA against the blob at floor:rel.
1006
+ local local_sha baseline_sha
1007
+ local_sha="$(git hash-object "$local_path" 2>/dev/null || true)"
1008
+ baseline_sha="$(cd "$TMPDIR/src" && git rev-parse "$HISTORY_FLOOR:$rel" 2>/dev/null || true)"
1009
+ if [ -z "$local_sha" ] || [ -z "$baseline_sha" ] || [ "$local_sha" != "$baseline_sha" ]; then
1010
+ user_edited=1
1011
+ fi
1012
+ elif [ "$in_head" = "1" ]; then
1013
+ # head_compare fallback: cmp against current upstream copy.
1014
+ cmp -s "$local_path" "$src_path" || user_edited=1
1015
+ else
1016
+ # in_floor=1 but in_head=0: upstream removed the file since last
1017
+ # sync. Without a floor compare we can't tell if the user touched
1018
+ # it; conservative default is to treat as USER-EDIT and rescue.
1019
+ user_edited=1
1020
+ fi
1021
+
1022
+ # Cloud-update mode: before routing as a user-edit, check if the local
1023
+ # bytes are actually the cloud-flattened serialization of the same
1024
+ # upstream symlink. If so, this isn't an edit — it's a transport
1025
+ # artifact. Reclassify as UNCHANGED so the overlay rewrites the real
1026
+ # symlink without polluting personal/ or .hq-conflicts/.
1027
+ if [ "$user_edited" = "1" ] && is_cloud_flattened_symlink_equiv "$rel" "$local_path"; then
1028
+ COUNT_CLOUD_SYMLINK_RECONCILED=$((COUNT_CLOUD_SYMLINK_RECONCILED + 1))
1029
+ if [ "$DRY_RUN" = "1" ]; then
1030
+ echo " cloud-symlink reconciled (unchanged): $rel (hq-symlink: marker matches upstream target)"
1031
+ else
1032
+ rm -f "$local_path"
1033
+ printf 'cloud-symlink-reconciled\t%s\t(hq-symlink: marker matches upstream target; overlay re-lays symlink)\n' "$rel" >> "$RESCUE_LOG" 2>/dev/null || true
1034
+ fi
1035
+ return 0
1036
+ fi
1037
+
1038
+ if [ "$user_edited" = "1" ]; then
1039
+ if [ "$DRY_RUN" = "1" ]; then
1040
+ if [ "$rel" = ".claude/CLAUDE.md" ]; then
1041
+ echo " user-edit (diff-append): $rel -> personal/CLAUDE.md"
1042
+ COUNT_USER_EDIT=$((COUNT_USER_EDIT + 1))
1043
+ elif is_overwrite_safe "$rel"; then
1044
+ echo " user-edit (overwrite-safe): $rel -> upstream wins (no copy preserved)"
1045
+ COUNT_USER_OVERWRITE=$((COUNT_USER_OVERWRITE + 1))
1046
+ elif is_conflict_class "$rel"; then
1047
+ echo " user-edit (conflict): $rel -> .hq-conflicts/rescue-$RUN_TS/$rel"
1048
+ COUNT_USER_CONFLICT=$((COUNT_USER_CONFLICT + 1))
1049
+ else
1050
+ echo " user-edit (rescue): $rel -> $(map_rescue_target "$rel")"
1051
+ COUNT_USER_EDIT=$((COUNT_USER_EDIT + 1))
1052
+ fi
1053
+ else
1054
+ if [ "$rel" = ".claude/CLAUDE.md" ]; then
1055
+ rescue_one "$rel"
1056
+ elif is_overwrite_safe "$rel"; then
1057
+ rm -f "$local_path"
1058
+ COUNT_USER_OVERWRITE=$((COUNT_USER_OVERWRITE + 1))
1059
+ printf 'overwritten\t%s\t(overwrite-safe; upstream wins, no copy preserved)\n' "$rel" >> "$RESCUE_LOG" 2>/dev/null || true
1060
+ elif is_conflict_class "$rel"; then
1061
+ conflict_one "$rel"
1062
+ else
1063
+ rescue_one "$rel"
1064
+ fi
1065
+ fi
1066
+ else
1067
+ # UNCHANGED — overlay will replace cleanly.
1068
+ COUNT_UNCHANGED=$((COUNT_UNCHANGED + 1))
1069
+ if [ "$DRY_RUN" = "1" ]; then
1070
+ echo " unchanged (delete + replace): $rel"
1071
+ else
1072
+ rm -f "$local_path"
1073
+ printf 'deleted\t%s\t(unchanged vs baseline; re-created by overlay)\n' "$rel" >> "$RESCUE_LOG" 2>/dev/null || true
1074
+ fi
1075
+ fi
1076
+ }
1077
+
1078
+ # Walk a wipe-set root, processing each file. companies/_template is
1079
+ # special-cased: always wholesale-replace (it's a template, never
1080
+ # user-authored), so we just rm it here and let the overlay re-create.
1081
+ walk_and_process() {
1082
+ local root_rel="$1"
1083
+ local root_abs="$HQ_ROOT/$root_rel"
1084
+ [ -e "$root_abs" ] || [ -L "$root_abs" ] || return 0
1085
+
1086
+ # companies/_template — wholesale-replace.
1087
+ if [ "$root_rel" = "companies/_template" ]; then
1088
+ if [ "$DRY_RUN" = "1" ]; then
1089
+ echo " wholesale-replace: companies/_template (template carve-out)"
1090
+ else
1091
+ rm -rf "$HQ_ROOT/companies/_template"
1092
+ fi
1093
+ return 0
1094
+ fi
1095
+
1096
+ # Top-level symlinks (AGENTS.md, MIGRATION.md, etc.). Most are static
1097
+ # convenience aliases the overlay overwrites cleanly (rsync -a preserves
1098
+ # link semantics from source). Master-sync personal-overlay symlinks at
1099
+ # the top level — rare but possible — are dropped here so the overlay
1100
+ # can land a real file at the same path without ambiguity; master-sync
1101
+ # regenerates the link on its next Stop-hook pass.
1102
+ if [ -L "$root_abs" ]; then
1103
+ if is_master_sync_symlink "$root_abs"; then
1104
+ COUNT_SYMLINK_DROPPED=$((COUNT_SYMLINK_DROPPED + 1))
1105
+ if [ "$DRY_RUN" = "1" ]; then
1106
+ echo " drop master-sync symlink: $root_rel -> $(readlink "$root_abs" 2>/dev/null || true)"
1107
+ else
1108
+ rm -f "$root_abs"
1109
+ printf 'symlink-dropped\t%s\t(master-sync regenerable)\n' "$root_rel" >> "$RESCUE_LOG" 2>/dev/null || true
1110
+ fi
1111
+ fi
1112
+ return 0
1113
+ fi
1114
+
1115
+ # Top-level regular file.
1116
+ if [ -f "$root_abs" ]; then
1117
+ process_one "$root_rel"
1118
+ return
1119
+ fi
1120
+
1121
+ # Top-level directory: walk recursively, pruning node_modules + nested
1122
+ # .git (v0.1.103 perf + correctness guard). Symlinks are surfaced via
1123
+ # `-type l` so master-sync-generated links (targets resolving into
1124
+ # personal/) can be dropped — see process_one + is_master_sync_symlink.
1125
+ # Under -P (the default), find does not descend INTO directory symlinks,
1126
+ # which keeps the walk bounded even when those links exist.
1127
+ while IFS= read -r -d '' f; do
1128
+ local rel="${f#"$HQ_ROOT/"}"
1129
+ process_one "$rel"
1130
+ done < <(find "$root_abs" \( -type d \( -name node_modules -o -name .git \) -prune \) -o \( \( -type f -o -type l \) -print0 \))
1131
+ }
1132
+
1133
+ # Prune pre-update-* snapshots older than the retention window. Best-effort:
1134
+ # a failure here never aborts the run (the snapshot we just took is what
1135
+ # matters). This is the auto-cleanup MIGRATION.md promised but never shipped.
1136
+ prune_old_backups() {
1137
+ [ -d "$BACKUP_ROOT" ] || return 0
1138
+ case "$BACKUP_RETENTION_DAYS" in ''|*[!0-9]*) return 0 ;; esac
1139
+ [ "$BACKUP_RETENTION_DAYS" -gt 0 ] || return 0
1140
+ find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'pre-update-*' -mtime +"$BACKUP_RETENTION_DAYS" -print 2>/dev/null \
1141
+ | while IFS= read -r old; do
1142
+ echo " pruned old snapshot (> ${BACKUP_RETENTION_DAYS}d): ${old##*/}"
1143
+ rm -rf "$old" 2>/dev/null || true
1144
+ done
1145
+ }
1146
+
1147
+ # --- Pre-operation safety snapshot (BEFORE any destructive op) --------------
1148
+ # Copy every wipe-set top-level entry to ~/.hq/backups/pre-update-<ts>/ so a
1149
+ # misclassification or interrupted rescue is fully recoverable. Only the wipe
1150
+ # set is snapshotted — repos/ (often ~GBs), personal/, companies/, workspace/
1151
+ # are preserved by the overlay and never at risk, so copying them would be
1152
+ # wasteful and slow. node_modules/ + nested .git/ are excluded for the same
1153
+ # reason. Skipped in dry-run (nothing gets destroyed) and when --no-backup.
1154
+ if [ "$DO_BACKUP" = "1" ] && [ "$DRY_RUN" != "1" ] && [ "${#WIPE_TOPLEVEL[@]}" -ne 0 ]; then
1155
+ BACKUP_DIR="$BACKUP_ROOT/pre-update-$RUN_TS"
1156
+ echo ""
1157
+ echo "==> Safety snapshot -> $BACKUP_DIR"
1158
+ mkdir -p "$BACKUP_DIR"
1159
+ for root_rel in "${WIPE_TOPLEVEL[@]}"; do
1160
+ src_abs="$HQ_ROOT/$root_rel"
1161
+ [ -e "$src_abs" ] || continue
1162
+ parent_rel="$(dirname "$root_rel")"
1163
+ mkdir -p "$BACKUP_DIR/$parent_rel"
1164
+ if command -v rsync >/dev/null 2>&1; then
1165
+ rsync -a --exclude='node_modules/' --exclude='.git/' "$src_abs" "$BACKUP_DIR/$parent_rel/" 2>/dev/null \
1166
+ || cp -a "$src_abs" "$BACKUP_DIR/$parent_rel/" 2>/dev/null || true
1167
+ else
1168
+ cp -a "$src_abs" "$BACKUP_DIR/$parent_rel/" 2>/dev/null || true
1169
+ fi
1170
+ done
1171
+ echo " snapshot complete (restore any file: cp \"$BACKUP_DIR/<relpath>\" \"$HQ_ROOT/<relpath>\")"
1172
+ fi
1173
+
1174
+ echo ""
1175
+ if [ "${#WIPE_TOPLEVEL[@]}" -eq 0 ]; then
1176
+ echo "==> Wipe set is empty; nothing to process or overlay."
1177
+ else
1178
+ echo "==> Walking wipe set, classifying vs. $SOURCE_REPO@$REF ..."
1179
+ for root_rel in "${WIPE_TOPLEVEL[@]}"; do
1180
+ walk_and_process "$root_rel"
1181
+ done
1182
+ fi
1183
+
1184
+ if [ "$DRY_RUN" = "1" ]; then
1185
+ echo ""
1186
+ echo "==> DRY RUN classification summary:"
1187
+ echo " user-only (leave in place): $COUNT_USER_ONLY files"
1188
+ echo " unchanged (delete + replace): $COUNT_UNCHANGED files"
1189
+ echo " user-edit (rescue / diff-append): $COUNT_USER_EDIT files"
1190
+ echo " user-edit (conflict quarantine): $COUNT_USER_CONFLICT files"
1191
+ echo " user-edit (overwrite-safe): $COUNT_USER_OVERWRITE files"
1192
+ echo " cloud-symlink reconciled: $COUNT_CLOUD_SYMLINK_RECONCILED files"
1193
+ echo " master-sync symlinks dropped: $COUNT_SYMLINK_DROPPED entries"
1194
+ if [ "$COUNT_CLAUDE_DIFF_APPEND" -gt 0 ]; then
1195
+ echo " of which .claude/CLAUDE.md would diff-append to personal/CLAUDE.md"
1196
+ fi
1197
+ if [ "$COUNT_USER_CONFLICT" -gt 0 ]; then
1198
+ echo " conflict bucket: .hq-conflicts/rescue-$RUN_TS/"
1199
+ fi
1200
+ if [ "${#PRESERVE_SUBPATHS[@]}" -ne 0 ]; then
1201
+ echo ""
1202
+ echo "==> DRY RUN: would back up + restore these sub-paths across the overlay:"
1203
+ for sp in "${PRESERVE_SUBPATHS[@]}"; do
1204
+ if [ -e "$HQ_ROOT/$sp" ]; then
1205
+ echo " ~ ./$sp (present — will survive)"
1206
+ else
1207
+ echo " ~ ./$sp (not present locally — no-op)"
1208
+ fi
1209
+ done
1210
+ fi
1211
+ echo ""
1212
+ echo "==> DRY RUN: would copy these top-level entries from source on overlay:"
1213
+ if [ "${#NARROW_PATHS[@]}" -ne 0 ]; then
1214
+ for n in "${NARROW_PATHS[@]}"; do
1215
+ if [ -e "$TMPDIR/src/$n" ]; then
1216
+ echo " + ./$n"
1217
+ fi
1218
+ done
1219
+ else
1220
+ ( cd "$TMPDIR/src" && find . -mindepth 1 -maxdepth 1 "${PRUNE_ARGS[@]}" | sed 's|^\./| + |' )
1221
+ if [ -d "$TMPDIR/src/companies/_template" ]; then
1222
+ echo " + ./companies/_template (carve-out from $SOURCE_REPO@$REF)"
1223
+ fi
1224
+ fi
1225
+ echo ""
1226
+ echo "==> DRY RUN complete. No filesystem changes made."
1227
+ exit 0
1228
+ fi
1229
+
1230
+ # --- Back up preserve-subpaths to a mktemp shuttle ---------------------------
1231
+ SHUTTLE="$TMPDIR/preserve"
1232
+ mkdir -p "$SHUTTLE"
1233
+ : > "$TMPDIR/preserve.map"
1234
+ shuttle_id=0
1235
+ for sp in "${PRESERVE_SUBPATHS[@]+"${PRESERVE_SUBPATHS[@]}"}"; do
1236
+ src="$HQ_ROOT/$sp"
1237
+ if [ -e "$src" ]; then
1238
+ shuttle_id=$((shuttle_id + 1))
1239
+ cp -a "$src" "$SHUTTLE/$shuttle_id"
1240
+ printf '%s\t%s\n' "$shuttle_id" "$sp" >> "$TMPDIR/preserve.map"
1241
+ echo "==> Backed up $sp -> shuttle/$shuttle_id"
1242
+ fi
1243
+ done
1244
+
1245
+ echo ""
1246
+ echo "==> Walk complete (user-only: $COUNT_USER_ONLY, unchanged-deleted: $COUNT_UNCHANGED, user-edit-rescued: $COUNT_USER_EDIT, conflict-quarantined: $COUNT_USER_CONFLICT, overwrite-safe: $COUNT_USER_OVERWRITE, cloud-symlink-reconciled: $COUNT_CLOUD_SYMLINK_RECONCILED, symlinks-dropped: $COUNT_SYMLINK_DROPPED)"
1247
+ # Note: v0.1.103-and-earlier did a wholesale `rm -rf` of every wipe-set
1248
+ # top-level entry here. The new walk_and_process does per-file deletion
1249
+ # (only files that exist in upstream + are unchanged are deleted; user-only
1250
+ # files survive). The wholesale wipe step is intentionally gone — keep
1251
+ # the no-delete semantics of the rsync overlay below to preserve files
1252
+ # the walk left alone.
1253
+
1254
+ echo "==> Overlaying source onto HQ root ..."
1255
+ rsync -a "${RSYNC_EXCLUDES[@]}" "$TMPDIR/src/" "$HQ_ROOT/"
1256
+
1257
+ if [ -s "$TMPDIR/preserve.map" ]; then
1258
+ echo "==> Restoring preserved sub-paths ..."
1259
+ while IFS=$'\t' read -r id relpath; do
1260
+ dest="$HQ_ROOT/$relpath"
1261
+ mkdir -p "$(dirname "$dest")"
1262
+ if [ -d "$SHUTTLE/$id" ]; then
1263
+ rm -rf "$dest"
1264
+ cp -a "$SHUTTLE/$id" "$dest"
1265
+ else
1266
+ cp -a "$SHUTTLE/$id" "$dest"
1267
+ fi
1268
+ echo " restored $relpath"
1269
+ done < "$TMPDIR/preserve.map"
1270
+ fi
1271
+
1272
+ # --- Stamp sync-point provenance into core/core.yaml ------------------------
1273
+ # Default mode only — in narrow mode we only overlaid a subset of top-level
1274
+ # entries, so claiming "the HQ root is at <sha>" would be misleading. yq
1275
+ # is preferred (clean in-place edit, preserves comments mediocrely but well
1276
+ # enough). Falls back to a python3+PyYAML one-liner if yq is missing.
1277
+ if [ "${#NARROW_PATHS[@]}" -eq 0 ] && [ -f "$HQ_ROOT/core/core.yaml" ]; then
1278
+ NOW_UTC="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
1279
+ if command -v yq >/dev/null 2>&1; then
1280
+ # Write the new key AND delete the old (pre-v0.1.104) one in the same
1281
+ # pass so post-migration the file holds only the new stamp. `del(...)`
1282
+ # is a no-op when the key is absent, so this is safe for fresh installs.
1283
+ SHA="$SRC_SHA" SOURCE="$SOURCE_REPO" THE_REF="$REF" AT="$NOW_UTC" \
1284
+ yq -i '
1285
+ .replaced_from_source.source = strenv(SOURCE) |
1286
+ .replaced_from_source.ref = strenv(THE_REF) |
1287
+ .replaced_from_source.last_sync_sha = strenv(SHA) |
1288
+ .replaced_from_source.last_sync_at = strenv(AT) |
1289
+ del(.replaced_from_staging)
1290
+ ' "$HQ_ROOT/core/core.yaml"
1291
+ echo "==> Stamped core/core.yaml: replaced_from_source.last_sync_sha=$SRC_SHA"
1292
+ elif command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' >/dev/null 2>&1; then
1293
+ SHA="$SRC_SHA" SOURCE="$SOURCE_REPO" THE_REF="$REF" AT="$NOW_UTC" CORE="$HQ_ROOT/core/core.yaml" \
1294
+ python3 -c '
1295
+ import os, yaml
1296
+ path = os.environ["CORE"]
1297
+ try:
1298
+ with open(path) as f:
1299
+ d = yaml.safe_load(f) or {}
1300
+ except FileNotFoundError:
1301
+ d = {}
1302
+ d["replaced_from_source"] = {
1303
+ "source": os.environ["SOURCE"],
1304
+ "ref": os.environ["THE_REF"],
1305
+ "last_sync_sha": os.environ["SHA"],
1306
+ "last_sync_at": os.environ["AT"],
1307
+ }
1308
+ # Drop the pre-v0.1.104 key on migration. .pop with a default is a no-op
1309
+ # when the key is absent.
1310
+ d.pop("replaced_from_staging", None)
1311
+ with open(path, "w") as f:
1312
+ yaml.safe_dump(d, f, default_flow_style=False, sort_keys=False)
1313
+ '
1314
+ echo "==> Stamped core/core.yaml: replaced_from_source.last_sync_sha=$SRC_SHA"
1315
+ else
1316
+ echo " WARN: neither yq nor python3+PyYAML available — skipping core/core.yaml stamp" >&2
1317
+ fi
1318
+ fi
1319
+
1320
+ echo ""
1321
+ echo "==> File count summary:"
1322
+ for root_rel in "${WIPE_TOPLEVEL[@]}"; do
1323
+ if [ -e "$HQ_ROOT/$root_rel" ]; then
1324
+ n_files="$(find "$HQ_ROOT/$root_rel" -type f 2>/dev/null | wc -l | tr -d ' ')"
1325
+ echo " $root_rel: $n_files files"
1326
+ fi
1327
+ done
1328
+ echo ""
1329
+ echo "==> Classification:"
1330
+ echo " user-only (left in place): $COUNT_USER_ONLY files"
1331
+ echo " unchanged (deleted + overlaid): $COUNT_UNCHANGED files"
1332
+ echo " user-edits (rescued): $COUNT_USER_EDIT files"
1333
+ echo " user-edits (conflict quarantine): $COUNT_USER_CONFLICT files"
1334
+ echo " user-edits (overwrite-safe): $COUNT_USER_OVERWRITE files"
1335
+ echo " cloud-symlink reconciled: $COUNT_CLOUD_SYMLINK_RECONCILED files"
1336
+ echo " master-sync symlinks dropped: $COUNT_SYMLINK_DROPPED entries"
1337
+ if [ "$COUNT_CLAUDE_DIFF_APPEND" -gt 0 ]; then
1338
+ echo " of which .claude/CLAUDE.md diff-appended to personal/CLAUDE.md"
1339
+ fi
1340
+ if [ "$COUNT_USER_CONFLICT" -gt 0 ]; then
1341
+ echo " conflict bucket: .hq-conflicts/rescue-$RUN_TS/"
1342
+ fi
1343
+ if [ "$BASELINE_MODE" = "history_floor" ]; then
1344
+ echo " baseline: last-sync floor $HISTORY_FLOOR"
1345
+ else
1346
+ echo " baseline: upstream HEAD (no stamp; first run / floor unreachable)"
1347
+ fi
1348
+
1349
+ # --- Write recovery manifest into the snapshot + prune old snapshots --------
1350
+ if [ "$DO_BACKUP" = "1" ] && [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ]; then
1351
+ {
1352
+ echo "# HQ pre-update safety snapshot"
1353
+ echo ""
1354
+ echo "Created: $RUN_TS"
1355
+ echo "HQ root: $HQ_ROOT"
1356
+ echo "Source: $SOURCE_REPO@$REF ($SRC_SHA)"
1357
+ if [ "$BASELINE_MODE" = "history_floor" ]; then
1358
+ echo "Baseline: history_floor ($HISTORY_FLOOR)"
1359
+ else
1360
+ echo "Baseline: head_compare (no last-sync stamp; first run or floor unreachable)"
1361
+ fi
1362
+ echo ""
1363
+ echo "## What the rescue did"
1364
+ echo " user-only (left in place): $COUNT_USER_ONLY"
1365
+ echo " unchanged (deleted + re-overlaid): $COUNT_UNCHANGED"
1366
+ echo " user-edit (rescued into personal/): $COUNT_USER_EDIT"
1367
+ echo " user-edit (conflict quarantine): $COUNT_USER_CONFLICT"
1368
+ echo " user-edit (overwrite-safe): $COUNT_USER_OVERWRITE"
1369
+ echo " cloud-symlink reconciled: $COUNT_CLOUD_SYMLINK_RECONCILED"
1370
+ echo " master-sync symlinks dropped: $COUNT_SYMLINK_DROPPED"
1371
+ if [ "$COUNT_USER_CONFLICT" -gt 0 ]; then
1372
+ echo " conflict bucket: .hq-conflicts/rescue-$RUN_TS/"
1373
+ fi
1374
+ echo ""
1375
+ echo "## Moved / deleted files (tab-separated: action, path, detail)"
1376
+ if [ -s "$RESCUE_LOG" ]; then
1377
+ cat "$RESCUE_LOG"
1378
+ else
1379
+ echo " (no files were moved or deleted)"
1380
+ fi
1381
+ echo ""
1382
+ echo "## Restore"
1383
+ echo "This directory holds the wipe set exactly as it was before the update."
1384
+ echo "Restore a single file:"
1385
+ echo " cp \"$BACKUP_DIR/<relpath>\" \"$HQ_ROOT/<relpath>\""
1386
+ echo "Restore everything (overwrites current scaffold — use with care):"
1387
+ echo " rsync -a \"$BACKUP_DIR/\" \"$HQ_ROOT/\""
1388
+ echo ""
1389
+ echo "Auto-pruned after $BACKUP_RETENTION_DAYS days (HQ_BACKUP_RETENTION_DAYS)."
1390
+ } > "$BACKUP_DIR/RECOVERY.md" 2>/dev/null || true
1391
+ echo ""
1392
+ echo "==> Pre-update snapshot + recovery manifest: $BACKUP_DIR"
1393
+ prune_old_backups
1394
+ fi
1395
+
1396
+ echo ""
1397
+ echo "==> Done. Source: $SOURCE_REPO@$REF ($SRC_SHA)"
1398
+ echo " User-edited files were rescued under personal/ (see scan output above)."
1399
+ echo " User-only files (created by you, unknown to upstream) were left untouched."
1400
+ [ "$DO_BACKUP" = "1" ] && [ -n "$BACKUP_DIR" ] && echo " A full pre-update snapshot is at $BACKUP_DIR (RECOVERY.md explains restore)."