@indigoai-us/hq-cloud 6.1.0 → 6.2.1

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 (61) hide show
  1. package/dist/bin/sync-runner.d.ts.map +1 -1
  2. package/dist/bin/sync-runner.js +18 -0
  3. package/dist/bin/sync-runner.js.map +1 -1
  4. package/dist/cli/index.d.ts +2 -2
  5. package/dist/cli/index.d.ts.map +1 -1
  6. package/dist/cli/index.js +2 -2
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/cli/reindex.d.ts +4 -11
  9. package/dist/cli/reindex.d.ts.map +1 -1
  10. package/dist/cli/reindex.js +336 -30
  11. package/dist/cli/reindex.js.map +1 -1
  12. package/dist/cli/reindex.test.d.ts +3 -3
  13. package/dist/cli/reindex.test.js +36 -11
  14. package/dist/cli/reindex.test.js.map +1 -1
  15. package/dist/cli/rescue-core.d.ts +36 -0
  16. package/dist/cli/rescue-core.d.ts.map +1 -0
  17. package/dist/cli/rescue-core.js +1589 -0
  18. package/dist/cli/rescue-core.js.map +1 -0
  19. package/dist/cli/rescue-drift-reconcile.test.js +33 -10
  20. package/dist/cli/rescue-drift-reconcile.test.js.map +1 -1
  21. package/dist/cli/rescue-journal-reconcile.test.d.ts +2 -0
  22. package/dist/cli/rescue-journal-reconcile.test.d.ts.map +1 -0
  23. package/dist/cli/rescue-journal-reconcile.test.js +135 -0
  24. package/dist/cli/rescue-journal-reconcile.test.js.map +1 -0
  25. package/dist/cli/rescue-mtime-preserve.test.js +36 -12
  26. package/dist/cli/rescue-mtime-preserve.test.js.map +1 -1
  27. package/dist/cli/rescue.d.ts +4 -10
  28. package/dist/cli/rescue.d.ts.map +1 -1
  29. package/dist/cli/rescue.js +14 -37
  30. package/dist/cli/rescue.js.map +1 -1
  31. package/dist/cli/rescue.reindex.test.js +9 -8
  32. package/dist/cli/rescue.reindex.test.js.map +1 -1
  33. package/dist/cli/rescue.test.js +1 -10
  34. package/dist/cli/rescue.test.js.map +1 -1
  35. package/dist/index.d.ts +2 -2
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +2 -2
  38. package/dist/index.js.map +1 -1
  39. package/dist/lib/conflict-index.d.ts +40 -0
  40. package/dist/lib/conflict-index.d.ts.map +1 -1
  41. package/dist/lib/conflict-index.js +121 -0
  42. package/dist/lib/conflict-index.js.map +1 -1
  43. package/dist/lib/conflict.test.js +145 -1
  44. package/dist/lib/conflict.test.js.map +1 -1
  45. package/package.json +1 -1
  46. package/src/bin/sync-runner.ts +18 -0
  47. package/src/cli/index.ts +2 -2
  48. package/src/cli/reindex.test.ts +45 -12
  49. package/src/cli/reindex.ts +345 -36
  50. package/src/cli/rescue-core.ts +1719 -0
  51. package/src/cli/rescue-drift-reconcile.test.ts +33 -12
  52. package/src/cli/rescue-journal-reconcile.test.ts +156 -0
  53. package/src/cli/rescue-mtime-preserve.test.ts +36 -15
  54. package/src/cli/rescue.reindex.test.ts +9 -8
  55. package/src/cli/rescue.test.ts +1 -11
  56. package/src/cli/rescue.ts +15 -40
  57. package/src/index.ts +2 -2
  58. package/src/lib/conflict-index.ts +146 -0
  59. package/src/lib/conflict.test.ts +171 -0
  60. package/scripts/reindex.sh +0 -318
  61. package/scripts/replace-rescue.sh +0 -1522
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Integration test for the rescue convergence guard in scripts/replace-rescue.sh.
2
+ * Integration test for the rescue convergence guard in src/cli/rescue-core.ts.
3
3
  *
4
4
  * Regression for the false `.drift-*` litter: in `history_floor` mode the
5
5
  * rescue overlay flags a scaffold file as USER-EDIT whenever its blob differs
@@ -10,17 +10,16 @@
10
10
  * `.drift-<ts>-<pid>` copy. The guard reclassifies "drifted from floor but
11
11
  * identical to upstream HEAD" as UNCHANGED (no rescue).
12
12
  *
13
- * The script clones its source from GitHub, so we shim `git clone` to clone a
13
+ * The rescue clones its source from GitHub, so we shim `git clone` to clone a
14
14
  * local fixture repo instead. Everything runs under --dry-run (no destructive
15
- * ops, no backup) and we assert the classification the script reports.
15
+ * ops, no backup) and we assert the classification the rescue reports.
16
16
  */
17
17
  import { describe, it, expect, beforeAll, afterAll } from "vitest";
18
- import { execFileSync, spawnSync } from "child_process";
18
+ import { execFileSync } from "child_process";
19
19
  import * as fs from "fs";
20
20
  import * as os from "os";
21
21
  import * as path from "path";
22
-
23
- const RESCUE_SCRIPT = path.resolve(process.cwd(), "scripts/replace-rescue.sh");
22
+ import { runRescue } from "./rescue-core.js";
24
23
 
25
24
  function hasGit(): boolean {
26
25
  try {
@@ -33,6 +32,30 @@ function hasGit(): boolean {
33
32
 
34
33
  const gitAvailable = hasGit();
35
34
 
35
+ /** Run the rescue in-process, capturing its stdout/stderr. */
36
+ function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
37
+ let stdout = "";
38
+ let stderr = "";
39
+ const origOut = process.stdout.write.bind(process.stdout);
40
+ const origErr = process.stderr.write.bind(process.stderr);
41
+ process.stdout.write = ((chunk: unknown) => {
42
+ stdout += String(chunk);
43
+ return true;
44
+ }) as typeof process.stdout.write;
45
+ process.stderr.write = ((chunk: unknown) => {
46
+ stderr += String(chunk);
47
+ return true;
48
+ }) as typeof process.stderr.write;
49
+ let status: number;
50
+ try {
51
+ status = runRescue(argv, { env }).status;
52
+ } finally {
53
+ process.stdout.write = origOut;
54
+ process.stderr.write = origErr;
55
+ }
56
+ return { status, stdout, stderr };
57
+ }
58
+
36
59
  describe.skipIf(!gitAvailable)("rescue drift convergence guard", () => {
37
60
  let workDir: string;
38
61
  let upstream: string;
@@ -118,11 +141,9 @@ exec ${JSON.stringify(realGit)} "$@"
118
141
  if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
119
142
  });
120
143
 
121
- function runRescue() {
122
- return spawnSync(
123
- "bash",
144
+ function runRescueDry() {
145
+ return runRescueCapture(
124
146
  [
125
- RESCUE_SCRIPT,
126
147
  "--hq-root", hqRoot,
127
148
  "--source", "test/repo",
128
149
  "--ref", "main",
@@ -131,12 +152,12 @@ exec ${JSON.stringify(realGit)} "$@"
131
152
  "--yes",
132
153
  "--no-backup",
133
154
  ],
134
- { env, encoding: "utf-8" },
155
+ env,
135
156
  );
136
157
  }
137
158
 
138
159
  it("reconciles a floor-drifted file that is identical to upstream HEAD (no rescue)", () => {
139
- const r = runRescue();
160
+ const r = runRescueDry();
140
161
  const out = `${r.stdout}\n${r.stderr}`;
141
162
  // Sanity: ran in history_floor mode against our floor.
142
163
  expect(r.status, out).toBe(0);
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Regression for the rescue → sync-journal stale-baseline bug.
3
+ *
4
+ * Root cause: the rescue overlay rewrites scaffold files from upstream but
5
+ * never re-stamps the personal-vault sync journal. The journal keeps the
6
+ * PRE-rescue hash, so the next sync reads localHash != journal.hash ("local
7
+ * changed") and — when the vault also moved — mints a false `.conflict-*`
8
+ * mirror. `runRescue` now re-stamps the journal baseline for the files the
9
+ * overlay actually re-laid (scoped to rsync `-i` itemize output, so a user's
10
+ * pending edit is never touched).
11
+ *
12
+ * This test seeds a journal whose entry for an overlaid file carries the OLD
13
+ * hash, runs a real (non-dry-run) rescue, and asserts the entry was re-stamped
14
+ * to the freshly laid-down content — i.e. `localChanged` would now be false.
15
+ */
16
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
17
+ import { execFileSync } from "child_process";
18
+ import * as fs from "fs";
19
+ import * as os from "os";
20
+ import * as path from "path";
21
+ import { runRescue } from "./rescue-core.js";
22
+ import {
23
+ hashFile,
24
+ readJournal,
25
+ writeJournal,
26
+ PERSONAL_VAULT_JOURNAL_SLUG,
27
+ } from "../journal.js";
28
+
29
+ function hasGit(): boolean {
30
+ try {
31
+ execFileSync("git", ["--version"], { stdio: "ignore" });
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+ const gitAvailable = hasGit();
38
+
39
+ function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
40
+ let stdout = "";
41
+ const origOut = process.stdout.write.bind(process.stdout);
42
+ const origErr = process.stderr.write.bind(process.stderr);
43
+ process.stdout.write = ((c: unknown) => ((stdout += String(c)), true)) as typeof process.stdout.write;
44
+ process.stderr.write = (() => true) as typeof process.stderr.write;
45
+ let status: number;
46
+ try {
47
+ status = runRescue(argv, { env }).status;
48
+ } finally {
49
+ process.stdout.write = origOut;
50
+ process.stderr.write = origErr;
51
+ }
52
+ return { status, stdout };
53
+ }
54
+
55
+ describe.skipIf(!gitAvailable)("rescue re-stamps sync-journal baseline for re-laid files", () => {
56
+ let workDir: string, upstream: string, hqRoot: string, stateDir: string, floorSha: string;
57
+ let env: NodeJS.ProcessEnv;
58
+ let savedStateDir: string | undefined;
59
+
60
+ const git = (cwd: string, ...args: string[]) =>
61
+ execFileSync("git", args, {
62
+ cwd,
63
+ stdio: ["ignore", "pipe", "pipe"],
64
+ env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" },
65
+ }).toString().trim();
66
+
67
+ beforeAll(() => {
68
+ workDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rescue-journal-"));
69
+
70
+ // upstream: floor (doc.md=v1), then HEAD advances doc.md -> v2.
71
+ upstream = path.join(workDir, "upstream");
72
+ fs.mkdirSync(path.join(upstream, "core"), { recursive: true });
73
+ git(workDir, "init", "-b", "main", "upstream");
74
+ fs.writeFileSync(path.join(upstream, "core/doc.md"), "v1\n");
75
+ git(upstream, "add", "-A");
76
+ git(upstream, "commit", "-m", "floor");
77
+ floorSha = git(upstream, "rev-parse", "HEAD");
78
+ fs.writeFileSync(path.join(upstream, "core/doc.md"), "v2\n");
79
+ git(upstream, "add", "-A");
80
+ git(upstream, "commit", "-m", "head");
81
+
82
+ // local HQ root: doc.md == floor (v1) -> rescue overlays it to HEAD (v2).
83
+ hqRoot = path.join(workDir, "hq");
84
+ fs.mkdirSync(path.join(hqRoot, "core"), { recursive: true });
85
+ fs.mkdirSync(path.join(hqRoot, "personal"), { recursive: true });
86
+ fs.mkdirSync(path.join(hqRoot, "companies"), { recursive: true });
87
+ const docAbs = path.join(hqRoot, "core/doc.md");
88
+ fs.writeFileSync(docAbs, "v1\n");
89
+
90
+ // state dir + seeded personal-vault journal carrying the STALE (v1) hash.
91
+ stateDir = path.join(workDir, "state");
92
+ fs.mkdirSync(stateDir, { recursive: true });
93
+ savedStateDir = process.env.HQ_STATE_DIR;
94
+ process.env.HQ_STATE_DIR = stateDir; // getStateDir() reads process.env
95
+ const staleHash = hashFile(docAbs); // hash of v1
96
+ writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, {
97
+ version: "2",
98
+ lastSync: new Date(0).toISOString(),
99
+ files: {
100
+ "core/doc.md": {
101
+ hash: staleHash,
102
+ size: fs.statSync(docAbs).size,
103
+ syncedAt: new Date(0).toISOString(),
104
+ direction: "down",
105
+ remoteEtag: "seed-etag",
106
+ mtimeMs: fs.statSync(docAbs).mtimeMs,
107
+ },
108
+ },
109
+ pulls: [],
110
+ } as never);
111
+
112
+ // git shim: redirect `git clone <github-url>` to the local fixture.
113
+ const realGit = execFileSync("bash", ["-lc", "command -v git"]).toString().trim() || "/usr/bin/git";
114
+ const shimDir = path.join(workDir, "shim");
115
+ fs.mkdirSync(shimDir, { recursive: true });
116
+ fs.writeFileSync(
117
+ path.join(shimDir, "git"),
118
+ `#!/usr/bin/env bash
119
+ if [ "$1" = "clone" ]; then
120
+ args=(); for a in "$@"; do case "$a" in https://github.com/*) a=${JSON.stringify(upstream)} ;; esac; args+=("$a"); done
121
+ exec ${JSON.stringify(realGit)} "\${args[@]}"
122
+ fi
123
+ exec ${JSON.stringify(realGit)} "$@"
124
+ `,
125
+ { mode: 0o755 },
126
+ );
127
+ env = { ...process.env, PATH: `${shimDir}:${process.env.PATH ?? ""}`, HQ_STATE_DIR: stateDir };
128
+ });
129
+
130
+ afterAll(() => {
131
+ if (savedStateDir === undefined) delete process.env.HQ_STATE_DIR;
132
+ else process.env.HQ_STATE_DIR = savedStateDir;
133
+ if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
134
+ });
135
+
136
+ it("re-stamps the journal so the overlaid file is no longer seen as locally changed", () => {
137
+ const docAbs = path.join(hqRoot, "core/doc.md");
138
+ const staleHash = readJournal(PERSONAL_VAULT_JOURNAL_SLUG).files["core/doc.md"].hash;
139
+
140
+ const r = runRescueCapture(
141
+ ["--hq-root", hqRoot, "--source", "test/repo", "--ref", "main", "--floor-sha", floorSha, "--yes", "--no-backup"],
142
+ env,
143
+ );
144
+ expect(r.status, r.stdout).toBe(0);
145
+
146
+ // overlay actually re-laid the file (v1 -> v2)
147
+ expect(fs.readFileSync(docAbs, "utf-8")).toBe("v2\n");
148
+
149
+ // journal entry was re-stamped to the NEW content's hash
150
+ const entry = readJournal(PERSONAL_VAULT_JOURNAL_SLUG).files["core/doc.md"];
151
+ expect(entry.hash).toBe(hashFile(docAbs)); // == hash(v2): localChanged would be FALSE
152
+ expect(entry.hash).not.toBe(staleHash); // proves it changed from the stale v1 baseline
153
+ expect(entry.remoteEtag).toBe("seed-etag"); // remote side untouched -> clean push/converge, not conflict
154
+ expect(r.stdout).toContain("Reconciled sync-journal baseline");
155
+ });
156
+ });
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Integration regression for the rescue mtime-preservation fix in
3
- * scripts/replace-rescue.sh.
3
+ * src/cli/rescue-core.ts.
4
4
  *
5
5
  * The bug: rescue rebuilds the tree by `git clone` + `rsync -a`. A git
6
6
  * checkout stamps every file with clone-time (git stores no per-file mtimes),
@@ -9,24 +9,23 @@
9
9
  * this: hundreds of files sharing one mtime, mtime == ctime == birth).
10
10
  *
11
11
  * The fix has three parts, all asserted below:
12
- * - Layer 1: UNCHANGED files are left in place; the --checksum overlay skips
13
- * them, so their existing mtime survives.
12
+ * - Layer 1: UNCHANGED files are left in place; the overlay excludes them, so
13
+ * their existing mtime survives.
14
14
  * - Layer 2: files the overlay DOES write (upstream-changed, brand-new) get
15
15
  * the git committer-date of their last-modifying commit, not wall-clock.
16
16
  * - Layer 3: full history is used (history_floor mode clones full history;
17
17
  * the shallow path unshallows) so per-file commit times are real.
18
18
  *
19
- * The script clones from GitHub, so we shim `git clone` to a local fixture
19
+ * The rescue clones from GitHub, so we shim `git clone` to a local fixture
20
20
  * (same technique as rescue-drift-reconcile.test.ts) and run for real
21
21
  * (non-dry-run, --no-backup) so the overlay actually lays files down.
22
22
  */
23
23
  import { describe, it, expect, beforeAll, afterAll } from "vitest";
24
- import { execFileSync, spawnSync } from "child_process";
24
+ import { execFileSync } from "child_process";
25
25
  import * as fs from "fs";
26
26
  import * as os from "os";
27
27
  import * as path from "path";
28
-
29
- const RESCUE_SCRIPT = path.resolve(process.cwd(), "scripts/replace-rescue.sh");
28
+ import { runRescue } from "./rescue-core.js";
30
29
 
31
30
  function has(bin: string, ...args: string[]): boolean {
32
31
  try {
@@ -37,15 +36,39 @@ function has(bin: string, ...args: string[]): boolean {
37
36
  }
38
37
  }
39
38
 
40
- // Needs git (fixture + clone), rsync (overlay), and perl (the utime pass).
41
- const toolsAvailable =
42
- has("git", "--version") && has("rsync", "--version") && has("perl", "-e", "1");
39
+ // Needs git (fixture + clone) and rsync (overlay). The mtime restore is now
40
+ // native (fs.utimesSync), so perl is no longer required.
41
+ const toolsAvailable = has("git", "--version") && has("rsync", "--version");
43
42
 
44
43
  // Fixed commit epochs so assertions are exact and machine-independent.
45
44
  const FLOOR_EPOCH = 1577836800; // 2020-01-01T00:00:00Z
46
45
  const HEAD_EPOCH = 1609459200; // 2021-01-01T00:00:00Z
47
46
  const KEEP_PRESET_EPOCH = 1546300800; // 2019-01-01T00:00:00Z (local, pre-rescue)
48
47
 
48
+ /** Run the rescue in-process, capturing its stdout/stderr. */
49
+ function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
50
+ let stdout = "";
51
+ let stderr = "";
52
+ const origOut = process.stdout.write.bind(process.stdout);
53
+ const origErr = process.stderr.write.bind(process.stderr);
54
+ process.stdout.write = ((chunk: unknown) => {
55
+ stdout += String(chunk);
56
+ return true;
57
+ }) as typeof process.stdout.write;
58
+ process.stderr.write = ((chunk: unknown) => {
59
+ stderr += String(chunk);
60
+ return true;
61
+ }) as typeof process.stderr.write;
62
+ let status: number;
63
+ try {
64
+ status = runRescue(argv, { env }).status;
65
+ } finally {
66
+ process.stdout.write = origOut;
67
+ process.stderr.write = origErr;
68
+ }
69
+ return { status, stdout, stderr };
70
+ }
71
+
49
72
  describe.skipIf(!toolsAvailable)("rescue preserves mtimes (no clone-time flattening)", () => {
50
73
  let workDir: string;
51
74
  let upstream: string;
@@ -129,10 +152,8 @@ exec ${JSON.stringify(realGit)} "$@"
129
152
  env = { ...process.env, PATH: `${shimDir}:${process.env.PATH ?? ""}` };
130
153
 
131
154
  // --- run the real rescue (non-dry-run) ----------------------------------
132
- const r = spawnSync(
133
- "bash",
155
+ const r = runRescueCapture(
134
156
  [
135
- RESCUE_SCRIPT,
136
157
  "--hq-root", hqRoot,
137
158
  "--source", "test/repo",
138
159
  "--ref", "main",
@@ -140,9 +161,9 @@ exec ${JSON.stringify(realGit)} "$@"
140
161
  "--yes",
141
162
  "--no-backup",
142
163
  ],
143
- { env, encoding: "utf-8" },
164
+ env,
144
165
  );
145
- // Surface script output on failure for debuggability.
166
+ // Surface output on failure for debuggability.
146
167
  if (r.status !== 0) {
147
168
  throw new Error(`rescue failed (${r.status}):\n${r.stdout}\n${r.stderr}`);
148
169
  }
@@ -3,26 +3,27 @@
3
3
  * rescue so the generated skill wrappers / personal mirrors / workers registry
4
4
  * are refreshed once core/ has been re-laid-down.
5
5
  *
6
- * child_process is mocked so the real replace-rescue.sh never runs, and
7
- * ./reindex.js is mocked to a spy so we assert the call without spawning.
6
+ * The rescue algorithm (./rescue-core.js) is mocked so no real clone/overlay
7
+ * runs, and ./reindex.js is mocked to a spy so we assert the call without
8
+ * touching disk.
8
9
  */
9
10
  import { describe, it, expect, vi, beforeEach } from "vitest";
10
11
 
11
- vi.mock("child_process", () => ({
12
- spawnSync: vi.fn(() => ({ status: 0 })),
12
+ vi.mock("./rescue-core.js", () => ({
13
+ runRescue: vi.fn(() => ({ status: 0 })),
13
14
  }));
14
15
  vi.mock("./reindex.js", () => ({
15
16
  reindex: vi.fn(() => ({ status: 0 })),
16
17
  }));
17
18
 
18
- import { spawnSync } from "child_process";
19
+ import { runRescue } from "./rescue-core.js";
19
20
  import { reindex } from "./reindex.js";
20
21
  import { rescue } from "./rescue.js";
21
22
 
22
23
  describe("rescue → reindex", () => {
23
24
  beforeEach(() => {
24
25
  vi.clearAllMocks();
25
- (spawnSync as unknown as ReturnType<typeof vi.fn>).mockReturnValue({ status: 0 });
26
+ (runRescue as unknown as ReturnType<typeof vi.fn>).mockReturnValue({ status: 0 });
26
27
  });
27
28
 
28
29
  it("refreshes via reindex after a successful rescue", () => {
@@ -37,8 +38,8 @@ describe("rescue → reindex", () => {
37
38
  expect(reindex).not.toHaveBeenCalled();
38
39
  });
39
40
 
40
- it("does NOT run reindex when the rescue script fails", () => {
41
- (spawnSync as unknown as ReturnType<typeof vi.fn>).mockReturnValueOnce({ status: 1 });
41
+ it("does NOT run reindex when the rescue fails", () => {
42
+ (runRescue as unknown as ReturnType<typeof vi.fn>).mockReturnValueOnce({ status: 1 });
42
43
  const r = rescue({ hqRoot: "/tmp/hq", assumeYes: true });
43
44
  expect(r.status).toBe(1);
44
45
  expect(reindex).not.toHaveBeenCalled();
@@ -1,15 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { buildRescueArgs, rescueScriptPath } from "./rescue.js";
3
-
4
- describe("rescueScriptPath", () => {
5
- it("resolves to the bundled script at the package root", () => {
6
- const p = rescueScriptPath();
7
- expect(p.endsWith("scripts/replace-rescue.sh")).toBe(true);
8
- // From dist/cli/rescue.js the package root is two levels up; ensure we
9
- // don't accidentally point inside dist/.
10
- expect(p).not.toContain("/dist/scripts/");
11
- });
12
- });
2
+ import { buildRescueArgs } from "./rescue.js";
13
3
 
14
4
  describe("buildRescueArgs", () => {
15
5
  it("emits no args for an empty option set (script defaults apply)", () => {
package/src/cli/rescue.ts CHANGED
@@ -2,34 +2,18 @@
2
2
  * hq rescue — re-sync a local HQ tree to an upstream hq-core release (or a
3
3
  * staging branch) WITHOUT destroying the user's local edits ("drift").
4
4
  *
5
- * The implementation lives in scripts/replace-rescue.sh, shipped with this
6
- * package. This module resolves that script relative to the package (dist/cli
7
- * package root) and execs it against the caller's HQ root. The script was
8
- * historically bundled inside the HQ Sync menubar app; it now lives here so a
9
- * single copy is maintained and BOTH consumers the menubar app (which
10
- * bundles this package's copy) and `@indigoai-us/hq-cli`'s `hq rescue`
11
- * command — drive the exact same rescue logic.
5
+ * The implementation is native TypeScript in ./rescue-core.ts (the former
6
+ * scripts/replace-rescue.sh, ported and removed). This module owns the public
7
+ * option surface + flag mapping and the post-rescue reindex wiring; the heavy
8
+ * classify/overlay/stamp algorithm lives in rescue-core. BOTH consumers the
9
+ * HQ Sync menubar app (which spawns `hq-rescue` from this package) and
10
+ * `@indigoai-us/hq-cli`'s `hq rescue` command — drive the exact same logic.
12
11
  *
13
- * The script is channel-agnostic: `--source <repo>` + `--ref <tag|branch>`
14
- * select prod vs staging, and `--floor-sha` pins the three-way history floor.
15
- * See scripts/replace-rescue.sh for the full classify/overlay/stamp algorithm.
12
+ * Channel-agnostic: `--source <repo>` + `--ref <tag|branch>` select prod vs
13
+ * staging, and `--floor-sha` pins the three-way history floor.
16
14
  */
17
- import { spawnSync } from "child_process";
18
- import { fileURLToPath } from "url";
19
- import path from "path";
20
15
  import { reindex } from "./reindex.js";
21
-
22
- const __filename = fileURLToPath(import.meta.url);
23
- const __dirname = path.dirname(__filename);
24
-
25
- /**
26
- * Absolute path to the bundled replace-rescue.sh. From the compiled module at
27
- * dist/cli/rescue.js, the package root is two levels up; the script lives at
28
- * <package-root>/scripts/replace-rescue.sh.
29
- */
30
- export function rescueScriptPath(): string {
31
- return path.resolve(__dirname, "..", "..", "scripts", "replace-rescue.sh");
32
- }
16
+ import { runRescue } from "./rescue-core.js";
33
17
 
34
18
  export interface RescueOptions {
35
19
  /** HQ root to operate on. Passed as `--hq-root`. Defaults to the script's
@@ -101,25 +85,16 @@ export function buildRescueArgs(opts: RescueOptions = {}): string[] {
101
85
  }
102
86
 
103
87
  /**
104
- * Run the rescue script against an HQ root. Synchronous — the script is
105
- * long-running (clone + scan) and callers want the exit status + live output.
106
- * stdout/stderr from the script are inherited so the user sees the scan log
107
- * and the confirmation prompt (unless `assumeYes` is set).
88
+ * Run the rescue against an HQ root. Synchronous — the work is long-running
89
+ * (clone + scan + overlay) and callers want the exit status + live output.
90
+ * stdout/stderr are written directly so the user sees the scan log and the
91
+ * confirmation prompt (unless `assumeYes` is set).
108
92
  */
109
93
  export function rescue(opts: RescueOptions = {}): RescueResult {
110
- const script = rescueScriptPath();
111
94
  const args = buildRescueArgs(opts);
112
- const env = { ...process.env };
95
+ const env: NodeJS.ProcessEnv = { ...process.env };
113
96
  if (opts.ghToken) env.GH_TOKEN = opts.ghToken;
114
- const res = spawnSync("bash", [script, ...args], {
115
- stdio: "inherit",
116
- env,
117
- });
118
- if (res.error) {
119
- process.stderr.write(`rescue: failed to run ${script}: ${res.error.message}\n`);
120
- return { status: 1 };
121
- }
122
- const status = res.status ?? 1;
97
+ const { status } = runRescue(args, { env });
123
98
  // A successful, non-dry-run rescue re-lays-down core/, so refresh the
124
99
  // generated skill wrappers, personal-overlay mirrors, and workers registry.
125
100
  // Best-effort + idempotent — never overrides the rescue's own exit status.
package/src/index.ts CHANGED
@@ -213,11 +213,11 @@ export { promote } from "./cli/index.js";
213
213
  export type { PromoteOptions, PromoteResult } from "./cli/index.js";
214
214
 
215
215
  // Skill/personal-overlay mirroring + workers-registry regen (`hq reindex`).
216
- export { reindex, reindexScriptPath } from "./cli/index.js";
216
+ export { reindex } from "./cli/index.js";
217
217
  export type { ReindexOptions, ReindexResult } from "./cli/index.js";
218
218
 
219
219
  // Drift-preserving HQ-core re-sync — shared by the HQ Sync app and `hq rescue`.
220
- export { rescue, rescueScriptPath, buildRescueArgs } from "./cli/index.js";
220
+ export { rescue, buildRescueArgs } from "./cli/index.js";
221
221
  export type { RescueOptions, RescueResult } from "./cli/index.js";
222
222
 
223
223
  export type {
@@ -125,3 +125,149 @@ export function removeConflictEntry(hqRoot: string, id: string): void {
125
125
  if (filtered.length === index.conflicts.length) return;
126
126
  writeConflictIndex(hqRoot, { version: index.version, conflicts: filtered });
127
127
  }
128
+
129
+ /** Summary of what a {@link pruneConflictIndex} pass reclaimed. */
130
+ export interface PruneConflictIndexResult {
131
+ /** Rows dropped because their `.conflict-*` mirror no longer exists. */
132
+ prunedOrphans: number;
133
+ /** Rows dropped because the original file and its mirror are byte-identical. */
134
+ prunedIdentical: number;
135
+ /** Byte-identical mirror files deleted from disk during the pass. */
136
+ removedMirrors: number;
137
+ /** Rows kept (genuine divergence, or unprovable — fail-safe retained). */
138
+ kept: number;
139
+ }
140
+
141
+ /**
142
+ * Byte-identity check used by the prune pass. Symlink-aware: two symlinks are
143
+ * identical iff their link targets match (mirroring how the detector hashes a
144
+ * symlink record — `hashSymlinkTarget`, not the followed content). A symlink
145
+ * compared against a regular file is never identical. Regular files reject on a
146
+ * size mismatch first (cheap), then compare full bytes.
147
+ *
148
+ * Throws if either side can't be read — callers treat that as "unprovable" and
149
+ * keep the entry rather than risk dropping a real conflict.
150
+ */
151
+ function entryFilesAreIdentical(
152
+ aPath: string,
153
+ aStat: fs.Stats,
154
+ bPath: string,
155
+ bStat: fs.Stats,
156
+ ): boolean {
157
+ const aLink = aStat.isSymbolicLink();
158
+ const bLink = bStat.isSymbolicLink();
159
+ if (aLink || bLink) {
160
+ if (aLink !== bLink) return false;
161
+ return fs.readlinkSync(aPath) === fs.readlinkSync(bPath);
162
+ }
163
+ if (aStat.size !== bStat.size) return false;
164
+ return fs.readFileSync(aPath).equals(fs.readFileSync(bPath));
165
+ }
166
+
167
+ /**
168
+ * Garbage-collect the conflict index so it self-heals instead of growing
169
+ * without bound. Before this pass existed the ledger only ever shrank when a
170
+ * human resolved a conflict via `/resolve-conflicts`; every false positive and
171
+ * every orphaned row lingered forever, so the index over-reported the real
172
+ * pending-conflict count (the menubar's journal-derived count stayed correct,
173
+ * but `.hq-conflicts/index.json` did not).
174
+ *
175
+ * Two classes of entry are dropped — both provably not a pending conflict:
176
+ *
177
+ * 1. **Orphaned** — the `.conflict-*` mirror file is gone from disk. The
178
+ * mirror is the only artifact a human resolves against; once it's missing
179
+ * the row can never be acted on, so it's pure litter (the "missing-cloud"
180
+ * rows operators reported climbing into the dozens).
181
+ *
182
+ * 2. **Byte-identical false positives** — the original file and its conflict
183
+ * mirror both still exist and are byte-for-byte identical. The mirror
184
+ * holds the remote bytes captured at detection time, so identical bytes
185
+ * mean there was never a real divergence (this is exactly the manual
186
+ * safe-purge operators have been doing by hand). The stale mirror file is
187
+ * deleted and the row dropped.
188
+ *
189
+ * Conservative by construction — an entry is kept whenever it might be a real
190
+ * conflict: the original file is missing (a genuine local-delete-vs-remote
191
+ * divergence), the bytes differ, or either side can't be read. The index file
192
+ * is only rewritten when at least one row is actually dropped, so a clean
193
+ * ledger keeps its mtime untouched.
194
+ */
195
+ export function pruneConflictIndex(hqRoot: string): PruneConflictIndexResult {
196
+ const result: PruneConflictIndexResult = {
197
+ prunedOrphans: 0,
198
+ prunedIdentical: 0,
199
+ removedMirrors: 0,
200
+ kept: 0,
201
+ };
202
+
203
+ const index = readConflictIndex(hqRoot);
204
+ if (index.conflicts.length === 0) return result;
205
+
206
+ const kept: ConflictIndexEntry[] = [];
207
+ const mirrorsToRemove: string[] = [];
208
+
209
+ for (const entry of index.conflicts) {
210
+ const mirrorAbs = path.join(hqRoot, entry.conflictPath);
211
+ const originalAbs = path.join(hqRoot, entry.originalPath);
212
+
213
+ let mirrorStat: fs.Stats;
214
+ try {
215
+ mirrorStat = fs.lstatSync(mirrorAbs);
216
+ } catch {
217
+ // Mirror is gone — orphaned row, drop it. Nothing on disk to clean up.
218
+ result.prunedOrphans++;
219
+ continue;
220
+ }
221
+
222
+ // Both sides must exist to compare. A missing original is a real
223
+ // local-delete divergence — keep it for the human to resolve.
224
+ let originalStat: fs.Stats;
225
+ try {
226
+ originalStat = fs.lstatSync(originalAbs);
227
+ } catch {
228
+ kept.push(entry);
229
+ result.kept++;
230
+ continue;
231
+ }
232
+
233
+ let identical: boolean;
234
+ try {
235
+ identical = entryFilesAreIdentical(
236
+ originalAbs,
237
+ originalStat,
238
+ mirrorAbs,
239
+ mirrorStat,
240
+ );
241
+ } catch {
242
+ // Couldn't read one side — fail safe, keep the row.
243
+ kept.push(entry);
244
+ result.kept++;
245
+ continue;
246
+ }
247
+
248
+ if (identical) {
249
+ result.prunedIdentical++;
250
+ mirrorsToRemove.push(mirrorAbs);
251
+ } else {
252
+ kept.push(entry);
253
+ result.kept++;
254
+ }
255
+ }
256
+
257
+ // No row dropped → leave the file (and its mtime) untouched.
258
+ if (kept.length === index.conflicts.length) return result;
259
+
260
+ // Delete the byte-identical mirror litter. Best-effort: a leftover identical
261
+ // mirror is cosmetic, never corrupting, so a failed unlink doesn't abort.
262
+ for (const mirrorAbs of mirrorsToRemove) {
263
+ try {
264
+ fs.rmSync(mirrorAbs, { force: true });
265
+ result.removedMirrors++;
266
+ } catch {
267
+ /* best-effort cleanup */
268
+ }
269
+ }
270
+
271
+ writeConflictIndex(hqRoot, { version: index.version, conflicts: kept });
272
+ return result;
273
+ }