@indigoai-us/hq-cloud 6.14.6 → 6.14.8

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 (97) hide show
  1. package/dist/backup-prune.d.ts +68 -0
  2. package/dist/backup-prune.d.ts.map +1 -0
  3. package/dist/backup-prune.js +196 -0
  4. package/dist/backup-prune.js.map +1 -0
  5. package/dist/backup-prune.test.d.ts +2 -0
  6. package/dist/backup-prune.test.d.ts.map +1 -0
  7. package/dist/backup-prune.test.js +89 -0
  8. package/dist/backup-prune.test.js.map +1 -0
  9. package/dist/bin/backup-prune-runner.d.ts +3 -0
  10. package/dist/bin/backup-prune-runner.d.ts.map +1 -0
  11. package/dist/bin/backup-prune-runner.js +50 -0
  12. package/dist/bin/backup-prune-runner.js.map +1 -0
  13. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  14. package/dist/bin/sync-runner-watch-loop.js +28 -0
  15. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  16. package/dist/bin/sync-runner.d.ts +2 -0
  17. package/dist/bin/sync-runner.d.ts.map +1 -1
  18. package/dist/bin/sync-runner.js.map +1 -1
  19. package/dist/cli/reindex.d.ts.map +1 -1
  20. package/dist/cli/reindex.js +5 -3
  21. package/dist/cli/reindex.js.map +1 -1
  22. package/dist/cli/rescue-classify-ordering.test.js +105 -0
  23. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  24. package/dist/cli/rescue-core.d.ts +7 -0
  25. package/dist/cli/rescue-core.d.ts.map +1 -1
  26. package/dist/cli/rescue-core.js +509 -282
  27. package/dist/cli/rescue-core.js.map +1 -1
  28. package/dist/cli/rescue-snapshot.d.ts +14 -0
  29. package/dist/cli/rescue-snapshot.d.ts.map +1 -0
  30. package/dist/cli/rescue-snapshot.js +39 -0
  31. package/dist/cli/rescue-snapshot.js.map +1 -0
  32. package/dist/cli/rescue-snapshot.test.d.ts +2 -0
  33. package/dist/cli/rescue-snapshot.test.d.ts.map +1 -0
  34. package/dist/cli/rescue-snapshot.test.js +46 -0
  35. package/dist/cli/rescue-snapshot.test.js.map +1 -0
  36. package/dist/cli/rescue.test.js +24 -0
  37. package/dist/cli/rescue.test.js.map +1 -1
  38. package/dist/cli/share.d.ts.map +1 -1
  39. package/dist/cli/share.js +12 -11
  40. package/dist/cli/share.js.map +1 -1
  41. package/dist/cli/sync.d.ts.map +1 -1
  42. package/dist/cli/sync.js +41 -6
  43. package/dist/cli/sync.js.map +1 -1
  44. package/dist/cli/sync.test.js +51 -0
  45. package/dist/cli/sync.test.js.map +1 -1
  46. package/dist/local-path-codec.d.ts +19 -0
  47. package/dist/local-path-codec.d.ts.map +1 -0
  48. package/dist/local-path-codec.js +69 -0
  49. package/dist/local-path-codec.js.map +1 -0
  50. package/dist/object-io.d.ts +19 -2
  51. package/dist/object-io.d.ts.map +1 -1
  52. package/dist/object-io.js +168 -32
  53. package/dist/object-io.js.map +1 -1
  54. package/dist/object-io.test.js +157 -1
  55. package/dist/object-io.test.js.map +1 -1
  56. package/dist/s3.d.ts +8 -0
  57. package/dist/s3.d.ts.map +1 -1
  58. package/dist/s3.js +52 -22
  59. package/dist/s3.js.map +1 -1
  60. package/dist/scope-shrink.d.ts.map +1 -1
  61. package/dist/scope-shrink.js +4 -3
  62. package/dist/scope-shrink.js.map +1 -1
  63. package/dist/signals/get.test.js +21 -1
  64. package/dist/signals/get.test.js.map +1 -1
  65. package/dist/signals/list.test.js +21 -1
  66. package/dist/signals/list.test.js.map +1 -1
  67. package/dist/sources/get.test.js +21 -1
  68. package/dist/sources/get.test.js.map +1 -1
  69. package/dist/sources/list.test.js +21 -1
  70. package/dist/sources/list.test.js.map +1 -1
  71. package/package.json +3 -2
  72. package/scripts/presign-transport-e2e.mjs +57 -10
  73. package/scripts/vault-rebaseline.sh +49 -1
  74. package/scripts/vault-rescue.sh +42 -1
  75. package/src/backup-prune.test.ts +98 -0
  76. package/src/backup-prune.ts +182 -0
  77. package/src/bin/backup-prune-runner.ts +33 -0
  78. package/src/bin/sync-runner-watch-loop.ts +18 -0
  79. package/src/bin/sync-runner.ts +2 -0
  80. package/src/cli/reindex.ts +5 -3
  81. package/src/cli/rescue-classify-ordering.test.ts +121 -0
  82. package/src/cli/rescue-core.ts +311 -89
  83. package/src/cli/rescue-snapshot.test.ts +57 -0
  84. package/src/cli/rescue-snapshot.ts +51 -0
  85. package/src/cli/rescue.test.ts +34 -0
  86. package/src/cli/share.ts +12 -11
  87. package/src/cli/sync.test.ts +62 -0
  88. package/src/cli/sync.ts +51 -7
  89. package/src/local-path-codec.ts +95 -0
  90. package/src/object-io.test.ts +175 -0
  91. package/src/object-io.ts +213 -32
  92. package/src/s3.ts +61 -12
  93. package/src/scope-shrink.ts +4 -3
  94. package/src/signals/get.test.ts +26 -2
  95. package/src/signals/list.test.ts +26 -2
  96. package/src/sources/get.test.ts +26 -2
  97. package/src/sources/list.test.ts +26 -2
@@ -91,12 +91,17 @@ AWS_ARGS=()
91
91
  [ -n "$AWS_PROFILE_OVERRIDE" ] && AWS_ARGS+=(--profile "$AWS_PROFILE_OVERRIDE")
92
92
  AWS_ARGS+=(--region "$AWS_REGION_OVERRIDE")
93
93
 
94
+ command -v jq >/dev/null || {
95
+ echo "error: jq is required to preserve existing S3 metadata during hq-mode repair" >&2
96
+ exit 2
97
+ }
98
+
94
99
  # Tmp dirs.
95
100
  BASELINE="$(mktemp -d -t hq-rebaseline-baseline-XXXXXX)"
96
101
  STAGING="$(mktemp -d -t hq-rebaseline-stage-XXXXXX)"
97
102
 
98
103
  cleanup() {
99
- local rc=$?
104
+ local rc="${1:-$?}"
100
105
  if [ "$KEEP_TMP" = "1" ]; then
101
106
  echo "==> --keep-tmp set; preserving $BASELINE and $STAGING" >&2
102
107
  else
@@ -212,6 +217,48 @@ SYNC_ARGS=("s3" "sync" "$STAGING/" "s3://$BUCKET/" "--size-only")
212
217
 
213
218
  aws "${AWS_ARGS[@]}" "${SYNC_ARGS[@]}"
214
219
 
220
+ # `aws s3 sync` carries bytes but does not derive hq-cloud's per-file hq-mode
221
+ # metadata. Re-upload ONLY regular objects that lack a valid mode, taking the
222
+ # permission bits from this freshly cloned release tree (the canonical git
223
+ # manifest). This is the one-time repair for legacy objects: a fresh pull can
224
+ # now restore shipped hooks/scripts from metadata rather than guessing from
225
+ # content. Existing user metadata is retained, including created-by fields.
226
+ n_mode_backfilled=0
227
+ MODE_META_DIR="$(mktemp -d -t hq-rebaseline-mode-meta-XXXXXX)"
228
+ cleanup_mode_meta() { rm -rf "${MODE_META_DIR:-}"; }
229
+ trap 'rc=$?; cleanup_mode_meta; cleanup "$rc"' EXIT
230
+ mode_index=0
231
+ while IFS= read -r -d '' src; do
232
+ rel="${src#"$STAGING/"}"
233
+ # Symlink records intentionally have no hq-mode (their mode is OS-owned).
234
+ grep -Fqx -- "$rel" "$SYMLIST" && continue
235
+
236
+ mode="$(stat -c '%a' "$src")"
237
+ head_json="$MODE_META_DIR/$mode_index.head.json"
238
+ metadata_json="$MODE_META_DIR/$mode_index.metadata.json"
239
+ mode_index=$((mode_index + 1))
240
+ aws "${AWS_ARGS[@]}" s3api head-object \
241
+ --bucket "$BUCKET" --key "$rel" --output json > "$head_json"
242
+ existing_mode="$(jq -r '.Metadata["hq-mode"] // empty' "$head_json")"
243
+ if [[ "$existing_mode" =~ ^[0-7]{1,4}$ ]]; then
244
+ continue
245
+ fi
246
+
247
+ jq -c --arg mode "$mode" \
248
+ '(.Metadata // {}) + {"hq-mode": $mode}' \
249
+ "$head_json" > "$metadata_json"
250
+ content_type="$(jq -r '.ContentType // empty' "$head_json")"
251
+ if [ "$DRY_RUN" = "1" ]; then
252
+ echo " would backfill hq-mode=$mode: $rel" >&2
253
+ else
254
+ PUT_ARGS=(s3api put-object --bucket "$BUCKET" --key "$rel" --body "$src" --metadata "file://$metadata_json")
255
+ [ -n "$content_type" ] && PUT_ARGS+=(--content-type "$content_type")
256
+ aws "${AWS_ARGS[@]}" "${PUT_ARGS[@]}" >/dev/null
257
+ echo " backfilled hq-mode=$mode: $rel" >&2
258
+ fi
259
+ n_mode_backfilled=$((n_mode_backfilled + 1))
260
+ done < <(find "$STAGING" -type f -print0)
261
+
215
262
  # Stamp the hq-symlink-target metadata header on every marker so the menubar
216
263
  # pull materializes them as real symlinks (apps/hq-cloud/src/s3.ts:466-468
217
264
  # prefers header detection; body sniff is fallback).
@@ -271,5 +318,6 @@ echo " version: $VERSION ($SOURCE_REPO@$REF)" >&2
271
318
  echo " pushed: $n_files files, $n_syms symlinks (gross — preserved excluded)" >&2
272
319
  echo " user-state preserved: $n_preserved files" >&2
273
320
  echo " stamped: $n_stamped" >&2
321
+ echo " hq-mode backfilled: $n_mode_backfilled" >&2
274
322
  echo " deleted: $n_deleted (v14 cruft)" >&2
275
323
  exit 0
@@ -120,6 +120,11 @@ AWS_ARGS=()
120
120
  [ -n "$AWS_PROFILE_OVERRIDE" ] && AWS_ARGS+=(--profile "$AWS_PROFILE_OVERRIDE")
121
121
  AWS_ARGS+=(--region "$AWS_REGION_OVERRIDE")
122
122
 
123
+ command -v jq >/dev/null || {
124
+ echo "error: jq is required to preserve existing S3 metadata during hq-mode repair" >&2
125
+ exit 2
126
+ }
127
+
123
128
  # Tmp dir setup.
124
129
  if [ -n "$TMP_DIR_OVERRIDE" ]; then
125
130
  TMP_DIR="$TMP_DIR_OVERRIDE"
@@ -201,8 +206,9 @@ echo "==> [3/3] push $TMP_DIR/ -> s3://$BUCKET/" >&2
201
206
  # per apps/hq-cloud/src/s3.ts:417). Skip a minimal denylist that should
202
207
  # never round-trip through S3.
203
208
  STAGING="$(mktemp -d -t hq-vault-rescue-stage-XXXXXX)"
209
+ MODE_META_DIR="$(mktemp -d -t hq-vault-rescue-mode-meta-XXXXXX)"
204
210
  add_staging_cleanup() {
205
- rm -rf "${STAGING:-}"
211
+ rm -rf "${STAGING:-}" "${MODE_META_DIR:-}"
206
212
  }
207
213
  # Chain cleanup: existing trap removes TMP_DIR; we also need STAGING gone.
208
214
  trap '{ add_staging_cleanup; cleanup; }' EXIT
@@ -261,6 +267,40 @@ echo " staged files=$n_files symlinks=$n_symlinks skipped=$n_skipped" >&2
261
267
  echo " aws s3 sync $STAGING/ -> s3://$BUCKET/ --size-only --delete" >&2
262
268
  aws "${AWS_ARGS[@]}" s3 sync "$STAGING/" "s3://$BUCKET/" --size-only --delete
263
269
 
270
+ # The AWS CLI moves bytes without hq-cloud's hq-mode metadata. Re-upload only
271
+ # regular objects that need the one-time repair, preserving existing S3 user
272
+ # metadata. For release-shipped files the rescue overlay supplied these modes
273
+ # from the trusted hq-core git tree, so a fresh pull restores hooks/scripts
274
+ # from canonical metadata instead of trying to infer executability from bytes.
275
+ n_mode_backfilled=0
276
+ mode_index=0
277
+ while IFS= read -r -d '' src; do
278
+ rel="${src#"$STAGING/"}"
279
+ # Symlink records intentionally have no hq-mode (their mode is OS-owned).
280
+ grep -Fqx -- "$rel" "$SYMLINK_LIST" && continue
281
+
282
+ mode="$(stat -c '%a' "$src")"
283
+ head_json="$MODE_META_DIR/$mode_index.head.json"
284
+ metadata_json="$MODE_META_DIR/$mode_index.metadata.json"
285
+ mode_index=$((mode_index + 1))
286
+ aws "${AWS_ARGS[@]}" s3api head-object \
287
+ --bucket "$BUCKET" --key "$rel" --output json > "$head_json"
288
+ existing_mode="$(jq -r '.Metadata["hq-mode"] // empty' "$head_json")"
289
+ if [[ "$existing_mode" =~ ^[0-7]{1,4}$ ]]; then
290
+ continue
291
+ fi
292
+
293
+ jq -c --arg mode "$mode" \
294
+ '(.Metadata // {}) + {"hq-mode": $mode}' \
295
+ "$head_json" > "$metadata_json"
296
+ content_type="$(jq -r '.ContentType // empty' "$head_json")"
297
+ PUT_ARGS=(s3api put-object --bucket "$BUCKET" --key "$rel" --body "$src" --metadata "file://$metadata_json")
298
+ [ -n "$content_type" ] && PUT_ARGS+=(--content-type "$content_type")
299
+ aws "${AWS_ARGS[@]}" "${PUT_ARGS[@]}" >/dev/null
300
+ echo " backfilled hq-mode=$mode: $rel" >&2
301
+ n_mode_backfilled=$((n_mode_backfilled + 1))
302
+ done < <(find "$STAGING" -type f -print0)
303
+
264
304
  # Stamp the hq-symlink metadata header on every symlink-marker object so
265
305
  # the menubar pull materializes them as real symlinks (apps/hq-cloud/src/
266
306
  # s3.ts:466-468 prefers header detection; body-sniff is fallback).
@@ -288,4 +328,5 @@ echo " version: $VERSION ($SOURCE_REPO@$REF)" >&2
288
328
  echo " files: $n_files" >&2
289
329
  echo " symlinks: $n_symlinks" >&2
290
330
  echo " stamped: $n_stamped" >&2
331
+ echo " hq-mode backfilled: $n_mode_backfilled" >&2
291
332
  echo " skipped: $n_skipped" >&2
@@ -0,0 +1,98 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import {
6
+ applyBackupPrune,
7
+ canonicalRootHash,
8
+ inventoryManagedSnapshots,
9
+ planBackupPrune,
10
+ type BackupEntry,
11
+ type SnapshotStatus,
12
+ } from "./backup-prune.js";
13
+
14
+ const DAY = 86_400_000;
15
+ const now = Date.UTC(2026, 6, 15, 12);
16
+ const rootA = "/tmp/hq-a";
17
+ const rootB = "/tmp/hq-b";
18
+ function entry(name: string, age: number, status: SnapshotStatus, root = rootA): BackupEntry {
19
+ const at = new Date(now - age * DAY).toISOString();
20
+ return { name, createdMs: now - age * DAY, completedMs: now - age * DAY, bytes: 1,
21
+ manifest: { schemaVersion: 1, status, canonicalHqRoot: root, rootHash: canonicalRootHash(root), runId: name,
22
+ creatorPid: 1, creatorStartedAt: at, source: "source", ref: "main", sourceSha: "sha", createdAt: at,
23
+ completedAt: at, includedRoots: ["core"], fileCount: 1, byteCount: 1 } };
24
+ }
25
+ const names = (x: ReturnType<typeof planBackupPrune>) => x.candidates.map((c) => c.entry.name).sort();
26
+ const protectedNames = (x: ReturnType<typeof planBackupPrune>) => x.protected.map((p) => `${p.entry.name}:${p.reason}`).sort();
27
+
28
+ describe("planBackupPrune", () => {
29
+ it("keeps the newest two known-good snapshots per canonical root", () => {
30
+ const p = planBackupPrune([entry("a1", 0, "completed-good"), entry("a2", 1, "completed-good"), entry("a3", 2, "completed-good"), entry("a4", 3, "completed-good"), entry("b1", 0, "completed-good", rootB)], now);
31
+ expect(names(p)).toEqual(["a3", "a4"]);
32
+ expect(protectedNames(p)).toContain("a1:newest-good");
33
+ expect(protectedNames(p)).toContain("b1:newest-good");
34
+ });
35
+
36
+ it("never selects the sole, old known-good snapshot", () => {
37
+ const p = planBackupPrune([entry("only", 30, "completed-good")], now);
38
+ expect(names(p)).toEqual([]);
39
+ expect(protectedNames(p)).toEqual(["only:newest-good"]);
40
+ });
41
+
42
+ it("retains copying and unsuperseded recovery evidence", () => {
43
+ const p = planBackupPrune([entry("copy", 40, "copying"), entry("recovery", 40, "recovery-required")], now);
44
+ expect(names(p)).toEqual([]);
45
+ expect(protectedNames(p)).toContain("copy:copying-not-superseded");
46
+ expect(protectedNames(p)).toContain("recovery:recovery-required-no-later-good");
47
+ });
48
+
49
+ it("requires a later good run before pruning recovery-required snapshots", () => {
50
+ const p = planBackupPrune([entry("recovery-old", 30, "recovery-required"), entry("recovery-new", 2, "recovery-required"), entry("good", 1, "completed-good")], now);
51
+ expect(names(p)).toEqual(["recovery-old"]);
52
+ expect(protectedNames(p)).toContain("recovery-new:newest-recovery-required-grace");
53
+ });
54
+
55
+ it("only prunes failed-pre-mutation snapshots after a newer ready/good snapshot", () => {
56
+ const noSuccess = planBackupPrune([entry("failure", 20, "failed-pre-mutation")], now);
57
+ expect(names(noSuccess)).toEqual([]);
58
+ const p = planBackupPrune([entry("failure-new", 1, "failed-pre-mutation"), entry("failure-old", 2, "failed-pre-mutation"), entry("ready", 0, "ready")], now);
59
+ expect(names(p)).toEqual(["failure-old"]);
60
+ });
61
+
62
+ it("clamps malformed retention so the newest good is still protected", () => {
63
+ const p = planBackupPrune([entry("one", 30, "completed-good"), entry("two", 31, "completed-good"), entry("three", 32, "completed-good")], now, { keepGood: 0, maxAgeDays: Number.NaN });
64
+ expect(names(p)).toEqual(["three"]);
65
+ expect(protectedNames(p)).toContain("one:newest-good");
66
+ });
67
+ });
68
+
69
+ describe("managed snapshot filesystem boundary", () => {
70
+ it("does not select prefix matches, mcp, symlinks, malformed metadata, or changed candidates", () => {
71
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-prune-root-"));
72
+ const backup = fs.mkdtempSync(path.join(os.tmpdir(), "hq-prune-backup-"));
73
+ try {
74
+ const make = (name: string, age: number) => {
75
+ const dir = path.join(backup, name); fs.mkdirSync(dir);
76
+ const at = new Date(now - age * DAY).toISOString();
77
+ fs.writeFileSync(path.join(dir, "SNAPSHOT.json"), JSON.stringify({ ...entry(name, age, "completed-good", root).manifest, runId: name, createdAt: at, completedAt: at }));
78
+ return dir;
79
+ };
80
+ const good1 = make("pre-update-2026-07-15T12-00-00Z-" + canonicalRootHash(root) + "-one", 0);
81
+ const good2 = make("pre-update-2026-07-14T12-00-00Z-" + canonicalRootHash(root) + "-two", 1);
82
+ const old = make("pre-update-2026-07-13T12-00-00Z-" + canonicalRootHash(root) + "-three", 2);
83
+ fs.mkdirSync(path.join(backup, "mcp"));
84
+ fs.mkdirSync(path.join(backup, "pre-update-not-managed"));
85
+ fs.symlinkSync(good1, path.join(backup, "pre-update-2026-07-12T12-00-00Z-" + canonicalRootHash(root) + "-symlink"));
86
+ const inventory = inventoryManagedSnapshots(backup, root);
87
+ expect(inventory.candidates.map((x) => x.entry.name)).toEqual([path.basename(old)]);
88
+ expect(inventory.unmanaged.map((x) => x.name)).toEqual(expect.arrayContaining(["mcp", "pre-update-not-managed"]));
89
+ // A changed manifest invalidates the dry-run identity and cannot be removed.
90
+ fs.writeFileSync(path.join(old, "SNAPSHOT.json"), JSON.stringify({ ...entry("replacement", 2, "completed-good", root).manifest }));
91
+ const result = applyBackupPrune(backup, root, inventory);
92
+ expect(result.deleted).toEqual([]);
93
+ expect(fs.existsSync(old)).toBe(true);
94
+ expect(fs.existsSync(path.join(backup, "mcp"))).toBe(true);
95
+ expect(fs.existsSync(good2)).toBe(true);
96
+ } finally { fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(backup, { recursive: true, force: true }); }
97
+ });
98
+ });
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Safety-first inventory and retention policy for HQ rescue snapshots.
3
+ *
4
+ * This file deliberately separates policy from filesystem access. In
5
+ * particular, `planBackupPrune` cannot remove, create, or mutate anything.
6
+ */
7
+ import * as crypto from "crypto";
8
+ import * as fs from "fs";
9
+ import * as path from "path";
10
+
11
+ export const SNAPSHOT_SCHEMA_VERSION = 1;
12
+ export const SNAPSHOT_FILE = "SNAPSHOT.json";
13
+ export const MANAGED_SNAPSHOT_RE = /^pre-update-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z-[a-f0-9]{16}-[A-Za-z0-9_-]+$/;
14
+ export type SnapshotStatus = "copying" | "ready" | "completed-good" | "failed-pre-mutation" | "recovery-required";
15
+
16
+ export interface SnapshotManifest {
17
+ schemaVersion: 1;
18
+ status: SnapshotStatus;
19
+ canonicalHqRoot: string;
20
+ rootHash: string;
21
+ runId: string;
22
+ creatorPid: number;
23
+ creatorStartedAt: string;
24
+ source: string;
25
+ ref: string;
26
+ sourceSha: string;
27
+ createdAt: string;
28
+ completedAt?: string;
29
+ includedRoots: string[];
30
+ fileCount: number;
31
+ byteCount: number;
32
+ }
33
+
34
+ export interface BackupEntry {
35
+ name: string;
36
+ manifest: SnapshotManifest;
37
+ createdMs: number;
38
+ completedMs: number;
39
+ bytes: number;
40
+ identity?: { dev: number; ino: number };
41
+ copyingOwnerLive?: boolean;
42
+ }
43
+ export interface ProtectedEntry { entry: BackupEntry; reason: string }
44
+ export interface DeletionCandidate { entry: BackupEntry; reason: string }
45
+ export interface BackupPrunePlan { candidates: DeletionCandidate[]; protected: ProtectedEntry[]; unmanaged: { name: string; reason: string }[] }
46
+ export interface RetentionConfig { keepGood: number; keepFailedPreMutation: number; maxAgeDays: number }
47
+
48
+ export const DEFAULT_RETENTION: RetentionConfig = { keepGood: 2, keepFailedPreMutation: 1, maxAgeDays: 7 };
49
+ const day = 24 * 60 * 60 * 1000;
50
+
51
+ export function canonicalRootHash(root: string): string {
52
+ return crypto.createHash("sha256").update(root).digest("hex").slice(0, 16);
53
+ }
54
+
55
+ export function safeRetention(input: Partial<RetentionConfig> = {}): RetentionConfig {
56
+ const integer = (value: unknown, fallback: number, minimum: number) =>
57
+ typeof value === "number" && Number.isInteger(value) && value >= minimum ? value : fallback;
58
+ return {
59
+ keepGood: integer(input.keepGood, DEFAULT_RETENTION.keepGood, 1),
60
+ keepFailedPreMutation: integer(input.keepFailedPreMutation, DEFAULT_RETENTION.keepFailedPreMutation, 1),
61
+ maxAgeDays: integer(input.maxAgeDays, DEFAULT_RETENTION.maxAgeDays, 0),
62
+ };
63
+ }
64
+
65
+ const newestFirst = (a: BackupEntry, b: BackupEntry) => b.completedMs - a.completedMs || b.createdMs - a.createdMs || b.name.localeCompare(a.name);
66
+
67
+ /** Every managed entry receives either a retention reason or a deletion reason. */
68
+ export function planBackupPrune(entries: BackupEntry[], nowMs: number, input: Partial<RetentionConfig> = {}): BackupPrunePlan {
69
+ const cfg = safeRetention(input);
70
+ const candidates: DeletionCandidate[] = [];
71
+ const protectedEntries: ProtectedEntry[] = [];
72
+ const byRoot = new Map<string, BackupEntry[]>();
73
+ for (const entry of entries) {
74
+ const group = byRoot.get(entry.manifest.canonicalHqRoot) ?? [];
75
+ group.push(entry);
76
+ byRoot.set(entry.manifest.canonicalHqRoot, group);
77
+ }
78
+ for (const group of byRoot.values()) {
79
+ const good = group.filter((e) => e.manifest.status === "completed-good").sort(newestFirst);
80
+ const readyOrGood = group.filter((e) => e.manifest.status === "ready" || e.manifest.status === "completed-good").sort(newestFirst);
81
+ const laterGood = (e: BackupEntry) => good.some((g) => g.completedMs > e.completedMs || (g.completedMs === e.completedMs && g.name > e.name));
82
+ const newestRecovery = group.filter((e) => e.manifest.status === "recovery-required").sort(newestFirst)[0];
83
+ for (const entry of group) {
84
+ const status = entry.manifest.status;
85
+ if (status === "copying") {
86
+ protectedEntries.push({ entry, reason: entry.copyingOwnerLive ? "copying-owner-live" : "copying-not-superseded" });
87
+ continue;
88
+ }
89
+ if (status === "completed-good") {
90
+ const rank = good.indexOf(entry);
91
+ if (rank < cfg.keepGood) {
92
+ protectedEntries.push({ entry, reason: rank === 0 ? "newest-good" : "keep-newest-good" });
93
+ } else if (nowMs - entry.completedMs >= cfg.maxAgeDays * day) {
94
+ candidates.push({ entry, reason: "completed-good-age-and-rank" });
95
+ } else {
96
+ candidates.push({ entry, reason: "completed-good-rank" });
97
+ }
98
+ continue;
99
+ }
100
+ if (status === "failed-pre-mutation") {
101
+ const failures = group.filter((e) => e.manifest.status === status).sort(newestFirst);
102
+ const rank = failures.indexOf(entry);
103
+ const superseded = readyOrGood.some((other) => newestFirst(other, entry) < 0);
104
+ if (rank < cfg.keepFailedPreMutation || !superseded) protectedEntries.push({ entry, reason: !superseded ? "failed-pre-mutation-not-superseded" : "keep-failed-pre-mutation" });
105
+ else candidates.push({ entry, reason: "failed-pre-mutation-superseded" });
106
+ continue;
107
+ }
108
+ // A recovery-required copy is recovery evidence until a later known-good run.
109
+ if (!laterGood(entry)) {
110
+ protectedEntries.push({ entry, reason: "recovery-required-no-later-good" });
111
+ } else if (entry === newestRecovery && nowMs - entry.completedMs < cfg.maxAgeDays * day) {
112
+ protectedEntries.push({ entry, reason: "newest-recovery-required-grace" });
113
+ } else if (nowMs - entry.completedMs >= cfg.maxAgeDays * day || entry !== newestRecovery) {
114
+ candidates.push({ entry, reason: "recovery-required-superseded" });
115
+ } else {
116
+ protectedEntries.push({ entry, reason: "recovery-required-grace" });
117
+ }
118
+ }
119
+ }
120
+ return { candidates, protected: protectedEntries, unmanaged: [] };
121
+ }
122
+
123
+ function validManifest(value: unknown, canonicalRoot: string): value is SnapshotManifest {
124
+ const x = value as Partial<SnapshotManifest>;
125
+ return !!x && x.schemaVersion === SNAPSHOT_SCHEMA_VERSION && typeof x.canonicalHqRoot === "string" &&
126
+ x.canonicalHqRoot === canonicalRoot && x.rootHash === canonicalRootHash(canonicalRoot) &&
127
+ typeof x.runId === "string" && /^[A-Za-z0-9_-]+$/.test(x.runId) &&
128
+ ["copying", "ready", "completed-good", "failed-pre-mutation", "recovery-required"].includes(x.status ?? "") &&
129
+ typeof x.createdAt === "string" && Array.isArray(x.includedRoots) && typeof x.fileCount === "number" && typeof x.byteCount === "number";
130
+ }
131
+
132
+ export function inventoryManagedSnapshots(backupRoot: string, canonicalRoot: string, isCopyingOwnerLive: (m: SnapshotManifest) => boolean = () => false, retention: Partial<RetentionConfig> = {}): BackupPrunePlan & { entries: BackupEntry[] } {
133
+ const entries: BackupEntry[] = [];
134
+ const unmanaged: { name: string; reason: string }[] = [];
135
+ let names: string[] = [];
136
+ try { names = fs.readdirSync(backupRoot); } catch { return { entries, candidates: [], protected: [], unmanaged: [{ name: backupRoot, reason: "backup-root-unreadable" }] }; }
137
+ for (const name of names.sort()) {
138
+ // Never descend into categories such as mcp/; only direct, exact names qualify.
139
+ if (!MANAGED_SNAPSHOT_RE.test(name)) { unmanaged.push({ name, reason: "not-managed-snapshot-name" }); continue; }
140
+ const abs = path.join(backupRoot, name);
141
+ let st: fs.Stats;
142
+ try { st = fs.lstatSync(abs); } catch { unmanaged.push({ name, reason: "lstat-failed" }); continue; }
143
+ if (!st.isDirectory() || st.isSymbolicLink()) { unmanaged.push({ name, reason: "not-real-directory" }); continue; }
144
+ let manifest: unknown;
145
+ try { manifest = JSON.parse(fs.readFileSync(path.join(abs, SNAPSHOT_FILE), "utf8")); } catch { unmanaged.push({ name, reason: "missing-or-invalid-metadata" }); continue; }
146
+ if (!validManifest(manifest, canonicalRoot)) { unmanaged.push({ name, reason: "metadata-does-not-match-canonical-root" }); continue; }
147
+ const m = manifest;
148
+ const createdMs = Date.parse(m.createdAt);
149
+ const completedMs = Date.parse(m.completedAt ?? m.createdAt);
150
+ if (!Number.isFinite(createdMs) || !Number.isFinite(completedMs)) { unmanaged.push({ name, reason: "invalid-metadata-timestamp" }); continue; }
151
+ entries.push({ name, manifest: m, createdMs, completedMs, bytes: m.byteCount, identity: { dev: st.dev, ino: st.ino }, copyingOwnerLive: m.status === "copying" && isCopyingOwnerLive(m) });
152
+ }
153
+ const plan = planBackupPrune(entries, Date.now(), retention);
154
+ return { entries, ...plan, unmanaged: [...unmanaged, ...plan.unmanaged] };
155
+ }
156
+
157
+ /** Revalidates strict identity then renames before recursively removing it. */
158
+ export function applyBackupPrune(backupRoot: string, canonicalRoot: string, plan: BackupPrunePlan, retention: Partial<RetentionConfig> = {}): { deleted: string[]; failures: string[] } {
159
+ const deleted: string[] = [], failures: string[] = [];
160
+ const lockPath = path.join(backupRoot, ".hq-backup-prune.lock");
161
+ let lockFd: number | undefined;
162
+ try { lockFd = fs.openSync(lockPath, "wx"); fs.writeSync(lockFd, `${process.pid}\n`); }
163
+ catch { return { deleted, failures: ["backup-root-lock-busy-or-unwritable"] }; }
164
+ try {
165
+ // This is deliberately after acquiring the backup-root lock and immediately
166
+ // before rename: a dry-run plan is never trusted across a filesystem race.
167
+ const fresh = inventoryManagedSnapshots(backupRoot, canonicalRoot, () => false, retention);
168
+ const freshByName = new Map(fresh.entries.map((e) => [e.name, e]));
169
+ const stillCandidates = new Set(fresh.candidates.map((e) => e.entry.name));
170
+ for (const candidate of plan.candidates) {
171
+ const current = freshByName.get(candidate.entry.name);
172
+ if (!current || !stillCandidates.has(candidate.entry.name) || current.manifest.runId !== candidate.entry.manifest.runId || current.identity?.dev !== candidate.entry.identity?.dev || current.identity?.ino !== candidate.entry.identity?.ino || current.manifest.status === "copying") { failures.push(`${candidate.entry.name}: changed-or-in-use`); continue; }
173
+ const from = path.join(backupRoot, current.name);
174
+ const tombstone = path.join(backupRoot, `.deleting-${current.manifest.runId}`);
175
+ try { fs.renameSync(from, tombstone); fs.rmSync(tombstone, { recursive: true, force: false }); deleted.push(current.name); }
176
+ catch (error) { failures.push(`${current.name}: ${error instanceof Error ? error.message : String(error)}`); }
177
+ }
178
+ } finally {
179
+ try { if (lockFd !== undefined) fs.closeSync(lockFd); fs.unlinkSync(lockPath); } catch { /* retained errors are safer than a delete race */ }
180
+ }
181
+ return { deleted, failures };
182
+ }
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ /** Explicit, local-only retention command. Defaults to dry-run. */
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ import { applyBackupPrune, inventoryManagedSnapshots } from "../backup-prune.js";
6
+ import { acquireOperationLock, OperationLockedError } from "../operation-lock.js";
7
+
8
+ const args = process.argv.slice(2);
9
+ const value = (flag: string) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined; };
10
+ const rootArg = value("--hq-root");
11
+ const backupArg = value("--backup-dir");
12
+ const mode = value("--mode") ?? "dry-run";
13
+ if (!rootArg || !["dry-run", "apply"].includes(mode)) {
14
+ process.stderr.write("usage: hq-backup-prune --hq-root DIR [--backup-dir DIR] [--mode dry-run|apply]\n");
15
+ process.exit(2);
16
+ }
17
+ const root = fs.realpathSync(rootArg);
18
+ const backupRoot = backupArg ? path.resolve(backupArg) : path.join(process.env.HOME ?? "", ".hq", "backups");
19
+ let lock;
20
+ try { lock = acquireOperationLock(root, "backup-prune", { timeoutSec: 0, onWaitStart: () => undefined }); }
21
+ catch (error) {
22
+ if (error instanceof OperationLockedError) { process.stderr.write("hq-backup-prune: skipped-in-use\n"); process.exit(0); }
23
+ throw error;
24
+ }
25
+ try {
26
+ const inventory = inventoryManagedSnapshots(backupRoot, root, (m) => { try { process.kill(m.creatorPid, 0); return true; } catch { return false; } });
27
+ const plan = { candidates: inventory.candidates, protected: inventory.protected, unmanaged: inventory.unmanaged };
28
+ process.stdout.write(JSON.stringify({ mode, candidates: plan.candidates.map((x) => ({ name: x.entry.name, rootHash: x.entry.manifest.rootHash, status: x.entry.manifest.status, bytes: x.entry.bytes, reason: x.reason })), protected: plan.protected.map((x) => ({ name: x.entry.name, reason: x.reason })), unmanaged: plan.unmanaged }) + "\n");
29
+ if (mode === "apply") {
30
+ const result = applyBackupPrune(backupRoot, root, plan);
31
+ if (result.failures.length) { process.stderr.write(result.failures.join("\n") + "\n"); process.exitCode = 1; }
32
+ }
33
+ } finally { lock.release(); }
@@ -1,4 +1,5 @@
1
1
  import * as path from "path";
2
+ import { inventoryManagedSnapshots } from "../backup-prune.js";
2
3
  import { VaultClient } from "../index.js";
3
4
  import { pickCanonicalPersonEntity } from "../vault-client.js";
4
5
  import {
@@ -98,6 +99,15 @@ export async function runWatchLoop(
98
99
  const eventPush = argv.includes("--event-push");
99
100
  const companiesMode = argv.includes("--companies");
100
101
  const hqRoot = parsed.hqRoot;
102
+ // Delayed and daily: this must never be attached to the 15s poll cadence.
103
+ // Stage 0 remains dry-run only; the standalone hq-backup-prune command is
104
+ // the explicitly-gated apply surface.
105
+ let nextBackupPruneAt = Date.now() + 60_000;
106
+ const runBackupPrune = deps.runBackupPrune ?? (async (root: string) => {
107
+ const backupRoot = path.join(process.env.HOME ?? "", ".hq", "backups");
108
+ const plan = inventoryManagedSnapshots(backupRoot, root, (m) => { try { process.kill(m.creatorPid, 0); return true; } catch { return false; } });
109
+ process.stderr.write(`hq-sync-runner: backup retention dry-run — ${plan.candidates.length} candidate(s), ${plan.protected.length} protected\n`);
110
+ });
101
111
 
102
112
  const passArgv = argv.filter((a, i) => {
103
113
  if (a === "--watch") return false;
@@ -510,6 +520,14 @@ export async function runWatchLoop(
510
520
  } else if (result !== 0) {
511
521
  return result;
512
522
  }
523
+ if (Date.now() >= nextBackupPruneAt) {
524
+ // Advance before invoking: a failed/unavailable backstop cannot become
525
+ // an every-tick retry loop. It is diagnostic only and never restarts
526
+ // the sync daemon.
527
+ nextBackupPruneAt = Date.now() + 24 * 60 * 60 * 1000;
528
+ try { await runBackupPrune(hqRoot); }
529
+ catch (error) { process.stderr.write(`hq-sync-runner: backup retention dry-run failed — ${error instanceof Error ? error.message : String(error)}\n`); }
530
+ }
513
531
  await Promise.race([sleep(pollMs), stoppedSignal]);
514
532
  }
515
533
  return 0;
@@ -1649,6 +1649,8 @@ export interface RunnerLoopDeps {
1649
1649
  * `deps` and forwards just the argv to `runRunner`.
1650
1650
  */
1651
1651
  runPass?: (passArgv: string[]) => Promise<number>;
1652
+ /** Stage-0 backup retention backstop: a dry-run only, never a sync-tick action. */
1653
+ runBackupPrune?: (hqRoot: string) => Promise<void>;
1652
1654
  /**
1653
1655
  * Clock seam for the event-push watcher's debounce window. Defaults to
1654
1656
  * {@link systemClock}; tests inject a `FakeClock` to advance the window
@@ -32,6 +32,7 @@ import {
32
32
  type CloudTelemetryClient,
33
33
  type TelemetryClaims,
34
34
  } from "../telemetry-events.js";
35
+ import { decodeLocalVaultSegment, encodeLocalVaultSegment } from "../local-path-codec.js";
35
36
 
36
37
  export interface ReindexOptions {
37
38
  /** HQ root to operate on. Defaults to process.cwd(). */
@@ -565,7 +566,7 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
565
566
  if (skillName.startsWith("_")) continue;
566
567
  if (!isFile(path.join(skillPath, "SKILL.md"))) continue;
567
568
 
568
- const wrapperName = `${ns}:${skillName}`;
569
+ const wrapperName = encodeLocalVaultSegment(`${ns}:${skillName}`);
569
570
  const wrapper = path.join(root, ".claude", "skills", wrapperName);
570
571
  expectedWrappers.add(wrapperName);
571
572
 
@@ -641,13 +642,14 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
641
642
  const entryPath = path.join(skillsDir, entry);
642
643
  const lst = lstatOrNull(entryPath);
643
644
  if (!lst) continue; // accept broken symlinks (lstat still succeeds)
644
- if (!entry.includes(":")) continue; // not a namespaced wrapper
645
+ const canonicalEntry = decodeLocalVaultSegment(entry);
646
+ if (!canonicalEntry.includes(":")) continue; // not a namespaced wrapper
645
647
  if (expectedWrappers.has(entry)) continue; // maintained this run
646
648
 
647
649
  // A wrapper is "managed" only when its namespace prefix matches the
648
650
  // namespace encoded in its symlink target — this distinguishes
649
651
  // script-produced wrappers from hand-authored composite wrappers.
650
- const ns = entry.slice(0, entry.indexOf(":"));
652
+ const ns = canonicalEntry.slice(0, canonicalEntry.indexOf(":"));
651
653
  const matchTarget = (t: string | null, p: string): boolean => {
652
654
  if (!t) return false;
653
655
  if (t.startsWith(`${p}/personal/skills/`)) return ns === "personal";