@indigoai-us/hq-cloud 6.14.49 → 6.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/.github/workflows/unreleased-commits-nag.yml +256 -0
  2. package/dist/bin/sync-runner-watch-loop.d.ts +12 -0
  3. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  4. package/dist/bin/sync-runner-watch-loop.js +153 -25
  5. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  6. package/dist/bin/sync-runner-watch-routes.d.ts +17 -0
  7. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
  8. package/dist/bin/sync-runner-watch-routes.js +11 -0
  9. package/dist/bin/sync-runner-watch-routes.js.map +1 -1
  10. package/dist/bin/sync-runner-watch-routes.test.d.ts +2 -0
  11. package/dist/bin/sync-runner-watch-routes.test.d.ts.map +1 -0
  12. package/dist/bin/sync-runner-watch-routes.test.js +51 -0
  13. package/dist/bin/sync-runner-watch-routes.test.js.map +1 -0
  14. package/dist/bin/sync-runner.d.ts +11 -12
  15. package/dist/bin/sync-runner.d.ts.map +1 -1
  16. package/dist/bin/sync-runner.js.map +1 -1
  17. package/dist/bin/sync-runner.test.js +173 -52
  18. package/dist/bin/sync-runner.test.js.map +1 -1
  19. package/dist/cli/reindex-knowledge.d.ts +7 -0
  20. package/dist/cli/reindex-knowledge.d.ts.map +1 -0
  21. package/dist/cli/reindex-knowledge.js +389 -0
  22. package/dist/cli/reindex-knowledge.js.map +1 -0
  23. package/dist/cli/reindex-knowledge.test.d.ts +12 -0
  24. package/dist/cli/reindex-knowledge.test.d.ts.map +1 -0
  25. package/dist/cli/reindex-knowledge.test.js +260 -0
  26. package/dist/cli/reindex-knowledge.test.js.map +1 -0
  27. package/dist/cli/reindex.d.ts.map +1 -1
  28. package/dist/cli/reindex.js +8 -0
  29. package/dist/cli/reindex.js.map +1 -1
  30. package/dist/cli/rescue-classify-ordering.test.js +8 -2
  31. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  32. package/dist/cli/rescue-core.d.ts +85 -0
  33. package/dist/cli/rescue-core.d.ts.map +1 -1
  34. package/dist/cli/rescue-core.js +390 -1
  35. package/dist/cli/rescue-core.js.map +1 -1
  36. package/dist/cli/rescue-settings-reconcile.test.d.ts +2 -0
  37. package/dist/cli/rescue-settings-reconcile.test.d.ts.map +1 -0
  38. package/dist/cli/rescue-settings-reconcile.test.js +523 -0
  39. package/dist/cli/rescue-settings-reconcile.test.js.map +1 -0
  40. package/dist/index.d.ts +1 -1
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +1 -1
  43. package/dist/index.js.map +1 -1
  44. package/dist/journal.d.ts.map +1 -1
  45. package/dist/journal.js +81 -51
  46. package/dist/journal.js.map +1 -1
  47. package/dist/journal.test.js +11 -19
  48. package/dist/journal.test.js.map +1 -1
  49. package/dist/sync/event-sync.d.ts +8 -25
  50. package/dist/sync/event-sync.d.ts.map +1 -1
  51. package/dist/sync/event-sync.js +23 -48
  52. package/dist/sync/event-sync.js.map +1 -1
  53. package/dist/sync/event-sync.test.js +6 -32
  54. package/dist/sync/event-sync.test.js.map +1 -1
  55. package/dist/sync/feature-flags.d.ts +20 -117
  56. package/dist/sync/feature-flags.d.ts.map +1 -1
  57. package/dist/sync/feature-flags.js +24 -147
  58. package/dist/sync/feature-flags.js.map +1 -1
  59. package/dist/sync/feature-flags.test.js +15 -61
  60. package/dist/sync/feature-flags.test.js.map +1 -1
  61. package/dist/sync/index.d.ts +1 -1
  62. package/dist/sync/index.d.ts.map +1 -1
  63. package/dist/sync/index.js +1 -1
  64. package/dist/sync/index.js.map +1 -1
  65. package/dist/sync/lease-client.d.ts +67 -0
  66. package/dist/sync/lease-client.d.ts.map +1 -0
  67. package/dist/sync/lease-client.js +158 -0
  68. package/dist/sync/lease-client.js.map +1 -0
  69. package/dist/sync/lease-client.test.d.ts +2 -0
  70. package/dist/sync/lease-client.test.d.ts.map +1 -0
  71. package/dist/sync/lease-client.test.js +119 -0
  72. package/dist/sync/lease-client.test.js.map +1 -0
  73. package/dist/sync/push-receiver.d.ts +8 -19
  74. package/dist/sync/push-receiver.d.ts.map +1 -1
  75. package/dist/sync/push-receiver.js +6 -50
  76. package/dist/sync/push-receiver.js.map +1 -1
  77. package/dist/sync/push-receiver.test.js +6 -57
  78. package/dist/sync/push-receiver.test.js.map +1 -1
  79. package/dist/sync/realtime-rollout.d.ts +85 -0
  80. package/dist/sync/realtime-rollout.d.ts.map +1 -0
  81. package/dist/sync/realtime-rollout.js +206 -0
  82. package/dist/sync/realtime-rollout.js.map +1 -0
  83. package/dist/sync/realtime-rollout.test.d.ts +2 -0
  84. package/dist/sync/realtime-rollout.test.d.ts.map +1 -0
  85. package/dist/sync/realtime-rollout.test.js +71 -0
  86. package/dist/sync/realtime-rollout.test.js.map +1 -0
  87. package/dist/sync/state-store.d.ts +40 -0
  88. package/dist/sync/state-store.d.ts.map +1 -0
  89. package/dist/sync/state-store.js +680 -0
  90. package/dist/sync/state-store.js.map +1 -0
  91. package/dist/sync/state-store.test.d.ts +2 -0
  92. package/dist/sync/state-store.test.d.ts.map +1 -0
  93. package/dist/sync/state-store.test.js +159 -0
  94. package/dist/sync/state-store.test.js.map +1 -0
  95. package/dist/types.d.ts +12 -0
  96. package/dist/types.d.ts.map +1 -1
  97. package/package.json +1 -1
  98. package/src/bin/sync-runner-watch-loop.ts +176 -32
  99. package/src/bin/sync-runner-watch-routes.test.ts +71 -0
  100. package/src/bin/sync-runner-watch-routes.ts +27 -0
  101. package/src/bin/sync-runner.test.ts +188 -53
  102. package/src/bin/sync-runner.ts +11 -12
  103. package/src/cli/reindex-knowledge.test.ts +307 -0
  104. package/src/cli/reindex-knowledge.ts +450 -0
  105. package/src/cli/reindex.ts +9 -0
  106. package/src/cli/rescue-classify-ordering.test.ts +9 -3
  107. package/src/cli/rescue-core.ts +459 -1
  108. package/src/cli/rescue-settings-reconcile.test.ts +637 -0
  109. package/src/index.ts +2 -4
  110. package/src/journal.test.ts +19 -21
  111. package/src/journal.ts +87 -54
  112. package/src/sync/event-sync.test.ts +9 -44
  113. package/src/sync/event-sync.ts +26 -52
  114. package/src/sync/feature-flags.test.ts +22 -72
  115. package/src/sync/feature-flags.ts +32 -199
  116. package/src/sync/index.ts +2 -4
  117. package/src/sync/lease-client.test.ts +128 -0
  118. package/src/sync/lease-client.ts +207 -0
  119. package/src/sync/push-receiver.test.ts +6 -58
  120. package/src/sync/push-receiver.ts +9 -74
  121. package/src/sync/realtime-rollout.test.ts +86 -0
  122. package/src/sync/realtime-rollout.ts +262 -0
  123. package/src/sync/state-store.test.ts +194 -0
  124. package/src/sync/state-store.ts +727 -0
  125. package/src/types.ts +14 -0
  126. package/test/e2e/sync/transient-company-leg.test.ts +8 -4
@@ -0,0 +1,637 @@
1
+ /**
2
+ * Regression tests for the `.claude/settings.json` reconcile in rescue-core.ts.
3
+ *
4
+ * `.claude/settings.json` is a machine-local, *generated* file: setup.sh step 3b
5
+ * composes the machine's env.PATH into the release's PATH-less template. On an
6
+ * existing install it therefore always differs from every release blob, so the
7
+ * old rescue rescued it into personal/ and laid a PATH-less template on top —
8
+ * dropping the machine PATH and (per drift/history state) failing to wire in a
9
+ * newly-released hook registration even though the hook FILE synced in.
10
+ *
11
+ * The fix re-composes env.PATH onto the release baseline and relocates genuine
12
+ * user drift (vs the last-sync floor) into settings.local.json. These tests
13
+ * cover the pure drift/merge helpers and reproduce the real field case with a
14
+ * real (non-dry-run) overlay against a local fixture "release" repo.
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 {
22
+ runRescue,
23
+ settingsDrift,
24
+ deepMergeSettings,
25
+ applyArrayRemovals,
26
+ jsonDeepEqual,
27
+ } from "./rescue-core.js";
28
+
29
+ // ── Unit: pure drift / merge helpers ────────────────────────────────────────
30
+
31
+ describe("settingsDrift", () => {
32
+ it("excludes env.PATH (machine-local) from drift", () => {
33
+ const ours = { env: { PATH: "/machine/bin:/usr/bin", MAX: "1" } };
34
+ const base = { env: { MAX: "1" } };
35
+ // Only PATH differs, and PATH is excluded -> no drift.
36
+ expect(settingsDrift(ours, base)).toBeUndefined();
37
+ });
38
+
39
+ it("captures an added scalar and a changed scalar", () => {
40
+ const ours = { model: "opus", newKey: "x" };
41
+ const base = { model: "sonnet" };
42
+ expect(settingsDrift(ours, base)).toEqual({ model: "opus", newKey: "x" });
43
+ });
44
+
45
+ it("captures only the extra array elements (union-friendly)", () => {
46
+ const ours = { permissions: { allow: ["Bash(git:*)", "Bash(mytool:*)"] } };
47
+ const base = { permissions: { allow: ["Bash(git:*)"] } };
48
+ expect(settingsDrift(ours, base)).toEqual({
49
+ permissions: { allow: ["Bash(mytool:*)"] },
50
+ });
51
+ });
52
+
53
+ it("returns undefined when ours equals base", () => {
54
+ const v = { a: 1, b: [1, 2], c: { d: "e" } };
55
+ expect(settingsDrift(v, structuredClone(v))).toBeUndefined();
56
+ });
57
+
58
+ it("treats a wholly-new subtree as drift but still honors env.PATH exclusion", () => {
59
+ const ours = { env: { PATH: "/machine/bin" } };
60
+ const base = {};
61
+ // env is new, but its only member is the excluded PATH -> no drift.
62
+ expect(settingsDrift(ours, base)).toBeUndefined();
63
+
64
+ const ours2 = { env: { PATH: "/machine/bin", HQ_FOO: "1" } };
65
+ expect(settingsDrift(ours2, {})).toEqual({ env: { HQ_FOO: "1" } });
66
+ });
67
+ });
68
+
69
+ describe("deepMergeSettings", () => {
70
+ it("unions arrays without duplicates and adds absent scalars", () => {
71
+ const target = { permissions: { allow: ["Bash(git:*)"] } };
72
+ const patch = { permissions: { allow: ["Bash(git:*)", "Bash(mytool:*)"] }, model: "opus" };
73
+ deepMergeSettings(target, patch);
74
+ expect(target.permissions.allow).toEqual(["Bash(git:*)", "Bash(mytool:*)"]);
75
+ expect((target as { model?: string }).model).toBe("opus"); // absent -> added
76
+ });
77
+
78
+ it("does NOT overwrite a scalar already present in the target (Local wins)", () => {
79
+ // Project drift `model: opus` must not clobber the higher-precedence Local
80
+ // `model: sonnet` — the effective config must remain sonnet.
81
+ const target = { model: "sonnet" };
82
+ deepMergeSettings(target, { model: "opus" });
83
+ expect(target.model).toBe("sonnet");
84
+ });
85
+
86
+ it("preserves an existing NESTED Local scalar while adding a sibling", () => {
87
+ // Local env.HQ_FOO must survive; only the genuinely-new HQ_BAR is introduced.
88
+ const target = { env: { HQ_FOO: "1" } };
89
+ deepMergeSettings(target, { env: { HQ_FOO: "2", HQ_BAR: "3" } });
90
+ expect(target.env).toEqual({ HQ_FOO: "1", HQ_BAR: "3" });
91
+ });
92
+
93
+ it("preserves existing Local array elements and never drops a sibling key", () => {
94
+ // The existing allow entry and the untouched deny key must both survive; the
95
+ // drift permission is appended, not substituted.
96
+ const target = { permissions: { allow: ["Bash(git:*)"], deny: ["Read(~/.ssh/**)"] } };
97
+ deepMergeSettings(target, { permissions: { allow: ["Bash(mytool:*)"] } });
98
+ expect(target.permissions.allow).toEqual(["Bash(git:*)", "Bash(mytool:*)"]);
99
+ expect(target.permissions.deny).toEqual(["Read(~/.ssh/**)"]); // untouched sibling
100
+ });
101
+
102
+ it("keeps a Local value when the drift has the same key with a different type", () => {
103
+ // Local scalar must not be replaced by a Project array/object of the same key.
104
+ const target: Record<string, unknown> = { hooks: "local-wins" };
105
+ deepMergeSettings(target as { [k: string]: never }, {
106
+ hooks: { PreToolUse: [] },
107
+ } as unknown as { [k: string]: never });
108
+ expect(target.hooks).toBe("local-wins");
109
+ });
110
+
111
+ it("leaves keys unique to Local completely untouched", () => {
112
+ const target = { onlyLocal: { a: 1 }, model: "sonnet" };
113
+ deepMergeSettings(target, { permissions: { allow: ["X"] } });
114
+ expect(target.onlyLocal).toEqual({ a: 1 });
115
+ expect(target.model).toBe("sonnet");
116
+ });
117
+
118
+ it("is idempotent on re-merge (no duplicate array entries)", () => {
119
+ const target = { permissions: { allow: ["Bash(mytool:*)"] } };
120
+ deepMergeSettings(target, { permissions: { allow: ["Bash(mytool:*)"] } });
121
+ expect(target.permissions.allow).toEqual(["Bash(mytool:*)"]);
122
+ });
123
+ });
124
+
125
+ describe("applyArrayRemovals", () => {
126
+ it("removes an element the user deleted (present in base, absent in ours)", () => {
127
+ const theirs = { permissions: { allow: ["Bash(git:*)", "Bash(rm:*)"] } };
128
+ const ours = { permissions: { allow: ["Bash(git:*)"] } }; // user removed rm
129
+ const base = { permissions: { allow: ["Bash(git:*)", "Bash(rm:*)"] } };
130
+ applyArrayRemovals(theirs, ours, base);
131
+ expect(theirs.permissions.allow).toEqual(["Bash(git:*)"]);
132
+ });
133
+
134
+ it("leaves release-only additions intact (base lacked them)", () => {
135
+ const theirs = { permissions: { allow: ["Bash(git:*)", "Bash(new:*)"] } };
136
+ const ours = { permissions: { allow: ["Bash(git:*)"] } };
137
+ const base = { permissions: { allow: ["Bash(git:*)"] } }; // base never had new
138
+ applyArrayRemovals(theirs, ours, base);
139
+ // "Bash(new:*)" is a release addition, not a user removal — keep it.
140
+ expect(theirs.permissions.allow).toEqual(["Bash(git:*)", "Bash(new:*)"]);
141
+ });
142
+ });
143
+
144
+ describe("jsonDeepEqual", () => {
145
+ it("compares nested structures", () => {
146
+ expect(jsonDeepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] })).toBe(true);
147
+ expect(jsonDeepEqual({ a: [1] }, { a: [2] })).toBe(false);
148
+ });
149
+ });
150
+
151
+ // ── Integration: real overlay reproduces the field case ─────────────────────
152
+
153
+ function hasBin(bin: string, args: string[] = ["--version"]): boolean {
154
+ try {
155
+ execFileSync(bin, args, { stdio: "ignore" });
156
+ return true;
157
+ } catch {
158
+ return false;
159
+ }
160
+ }
161
+ const canRun = hasBin("git") && hasBin("rsync");
162
+
163
+ function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
164
+ let stdout = "";
165
+ let stderr = "";
166
+ const origOut = process.stdout.write.bind(process.stdout);
167
+ const origErr = process.stderr.write.bind(process.stderr);
168
+ process.stdout.write = ((chunk: unknown) => {
169
+ stdout += String(chunk);
170
+ return true;
171
+ }) as typeof process.stdout.write;
172
+ process.stderr.write = ((chunk: unknown) => {
173
+ stderr += String(chunk);
174
+ return true;
175
+ }) as typeof process.stderr.write;
176
+ let status: number;
177
+ try {
178
+ status = runRescue(argv, { env }).status;
179
+ } finally {
180
+ process.stdout.write = origOut;
181
+ process.stderr.write = origErr;
182
+ }
183
+ return { status, stdout, stderr };
184
+ }
185
+
186
+ // A faithful copy of core/scripts/compose-settings-path.sh: prepend managed
187
+ // toolchain bin dirs that exist on disk but are missing from the base PATH,
188
+ // else pass the base through unchanged.
189
+ const COMPOSER = `#!/usr/bin/env bash
190
+ set -euo pipefail
191
+ BASE="\${1:-$PATH}"
192
+ TOOLCHAIN="\${HQ_TOOLCHAIN_DIR:-$HOME/Library/Application Support/Indigo HQ/toolchain}"
193
+ path_contains() { case ":$1:" in *":$2:"*) return 0 ;; *) return 1 ;; esac; }
194
+ COMPOSED="$BASE"
195
+ for dir in "$TOOLCHAIN/git/bin" "$TOOLCHAIN/npm-global/bin" "$TOOLCHAIN/node/bin"; do
196
+ if [[ -d "$dir" ]] && ! path_contains "$COMPOSED" "$dir"; then
197
+ COMPOSED="$dir:$COMPOSED"
198
+ fi
199
+ done
200
+ printf '%s\\n' "$COMPOSED"
201
+ `;
202
+
203
+ interface TestSettings {
204
+ model?: string;
205
+ permissions: { allow: string[]; deny: string[] };
206
+ env: Record<string, string>;
207
+ hooks: { PreToolUse: { hooks: { type?: string; command: string }[] }[] };
208
+ }
209
+
210
+ describe.skipIf(!canRun)("rescue .claude/settings.json reconcile (real overlay)", () => {
211
+ let workDir: string;
212
+ let upstream: string;
213
+ let shimDir: string;
214
+ let floorSha: string;
215
+ let env: NodeJS.ProcessEnv;
216
+
217
+ const MACHINE_PATH = "/opt/machine/bin:/usr/bin:/bin";
218
+ const NEW_HOOK_CMD =
219
+ 'bash "$CLAUDE_PROJECT_DIR/.claude/hooks/new-hook.sh"';
220
+
221
+ const git = (cwd: string, ...args: string[]) =>
222
+ execFileSync("git", args, {
223
+ cwd,
224
+ stdio: ["ignore", "pipe", "pipe"],
225
+ env: {
226
+ ...process.env,
227
+ GIT_AUTHOR_NAME: "t",
228
+ GIT_AUTHOR_EMAIL: "t@t",
229
+ GIT_COMMITTER_NAME: "t",
230
+ GIT_COMMITTER_EMAIL: "t@t",
231
+ },
232
+ })
233
+ .toString()
234
+ .trim();
235
+
236
+ // Release template as of the floor: PATH-less, with a baseline permission and
237
+ // a pre-existing hook, but WITHOUT the new-hook registration.
238
+ const floorSettings: TestSettings = {
239
+ model: "claude-opus-4-8",
240
+ permissions: { allow: ["Bash(git:*)"], deny: [] as string[] },
241
+ env: { MAX_THINKING_TOKENS: "31999" },
242
+ hooks: {
243
+ PreToolUse: [
244
+ { hooks: [{ type: "command", command: 'bash "$CLAUDE_PROJECT_DIR/.claude/hooks/old-hook.sh"' }] },
245
+ ],
246
+ },
247
+ };
248
+
249
+ // New release (HEAD): adds the new-hook FILE + its REGISTRATION. Still no PATH.
250
+ const headSettings: TestSettings = {
251
+ model: "claude-opus-4-8",
252
+ permissions: { allow: ["Bash(git:*)"], deny: [] as string[] },
253
+ env: { MAX_THINKING_TOKENS: "31999" },
254
+ hooks: {
255
+ PreToolUse: [
256
+ { hooks: [{ type: "command", command: 'bash "$CLAUDE_PROJECT_DIR/.claude/hooks/old-hook.sh"' }] },
257
+ { hooks: [{ type: "command", command: NEW_HOOK_CMD }] },
258
+ ],
259
+ },
260
+ };
261
+
262
+ beforeAll(() => {
263
+ workDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rescue-settings-"));
264
+
265
+ // --- Fixture "release" repo: floor commit, then a HEAD commit that adds the
266
+ // new hook file + registration. ---
267
+ upstream = path.join(workDir, "upstream");
268
+ fs.mkdirSync(path.join(upstream, ".claude", "hooks"), { recursive: true });
269
+ fs.mkdirSync(path.join(upstream, "core", "scripts"), { recursive: true });
270
+ git(workDir, "init", "-b", "main", "upstream");
271
+
272
+ fs.writeFileSync(
273
+ path.join(upstream, ".claude", "settings.json"),
274
+ JSON.stringify(floorSettings, null, 2) + "\n",
275
+ );
276
+ fs.writeFileSync(path.join(upstream, ".claude", "hooks", "old-hook.sh"), "#!/usr/bin/env bash\n");
277
+ fs.writeFileSync(path.join(upstream, "core", "scripts", "compose-settings-path.sh"), COMPOSER, { mode: 0o755 });
278
+ fs.writeFileSync(path.join(upstream, "core", "keep.md"), "same\n");
279
+ git(upstream, "add", "-A");
280
+ git(upstream, "commit", "-m", "floor");
281
+ floorSha = git(upstream, "rev-parse", "HEAD");
282
+
283
+ // HEAD: new hook file + registration land in the release.
284
+ fs.writeFileSync(
285
+ path.join(upstream, ".claude", "settings.json"),
286
+ JSON.stringify(headSettings, null, 2) + "\n",
287
+ );
288
+ fs.writeFileSync(path.join(upstream, ".claude", "hooks", "new-hook.sh"), "#!/usr/bin/env bash\n# new\n");
289
+ git(upstream, "add", "-A");
290
+ git(upstream, "commit", "-m", "add new-hook");
291
+
292
+ // --- git shim: rewrite `git clone <github-url> dest` to the local fixture. ---
293
+ const realGit =
294
+ execFileSync("bash", ["-c", "command -v git"]).toString().trim() || "/usr/bin/git";
295
+ shimDir = path.join(workDir, "shim");
296
+ fs.mkdirSync(shimDir, { recursive: true });
297
+ const shim = `#!/usr/bin/env bash
298
+ if [ "$1" = "clone" ]; then
299
+ args=()
300
+ for a in "$@"; do
301
+ case "$a" in
302
+ https://github.com/*) a=${JSON.stringify(upstream)} ;;
303
+ esac
304
+ args+=("$a")
305
+ done
306
+ exec ${JSON.stringify(realGit)} "\${args[@]}"
307
+ fi
308
+ exec ${JSON.stringify(realGit)} "$@"
309
+ `;
310
+ fs.writeFileSync(path.join(shimDir, "git"), shim, { mode: 0o755 });
311
+
312
+ env = { ...process.env, PATH: `${shimDir}:${process.env.PATH ?? ""}` };
313
+ }, 120_000);
314
+
315
+ afterAll(() => {
316
+ if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
317
+ });
318
+
319
+ let installSeq = 0;
320
+ /**
321
+ * Build a fresh fake install (an existing HQ root) with the given on-disk
322
+ * settings.json (`ours`) and settings.local.json (`local`). The hook FILE the
323
+ * earlier release added is present but unwired (mirrors the field box). A
324
+ * composer script is on the local tree (the overlay refreshes it anyway).
325
+ */
326
+ function makeInstall(
327
+ ours: TestSettings,
328
+ local: Record<string, unknown>,
329
+ ): string {
330
+ installSeq += 1;
331
+ const root = path.join(workDir, `hq-${installSeq}`);
332
+ fs.mkdirSync(path.join(root, ".claude", "hooks"), { recursive: true });
333
+ fs.mkdirSync(path.join(root, "core", "scripts"), { recursive: true });
334
+ fs.mkdirSync(path.join(root, "personal"), { recursive: true });
335
+ fs.mkdirSync(path.join(root, "companies"), { recursive: true });
336
+ fs.writeFileSync(
337
+ path.join(root, ".claude", "settings.json"),
338
+ JSON.stringify(ours, null, 2) + "\n",
339
+ );
340
+ fs.writeFileSync(path.join(root, ".claude", "hooks", "old-hook.sh"), "#!/usr/bin/env bash\n");
341
+ fs.writeFileSync(
342
+ path.join(root, ".claude", "settings.local.json"),
343
+ JSON.stringify(local, null, 2) + "\n",
344
+ );
345
+ fs.writeFileSync(path.join(root, "core", "scripts", "compose-settings-path.sh"), COMPOSER, { mode: 0o755 });
346
+ fs.writeFileSync(path.join(root, "core", "keep.md"), "same\n");
347
+ return root;
348
+ }
349
+
350
+ const rescueArgs = (root: string, floor?: string) => {
351
+ const a = ["--hq-root", root, "--source", "test/repo", "--ref", "main", "--yes", "--no-backup"];
352
+ if (floor) a.push("--floor-sha", floor);
353
+ else a.push("--no-history-check");
354
+ return a;
355
+ };
356
+
357
+ const readSettings = (root: string) =>
358
+ JSON.parse(fs.readFileSync(path.join(root, ".claude", "settings.json"), "utf-8")) as TestSettings;
359
+ const readLocal = (root: string) =>
360
+ JSON.parse(fs.readFileSync(path.join(root, ".claude", "settings.local.json"), "utf-8")) as {
361
+ permissions?: { allow?: string[] };
362
+ env?: Record<string, string>;
363
+ };
364
+ const cmdsOf = (s: TestSettings) => s.hooks.PreToolUse.flatMap((g) => g.hooks.map((h) => h.command));
365
+
366
+ it("wires in the new hook registration, preserves machine PATH, and relocates drift", () => {
367
+ // ours = floor template + machine env.PATH + a user-drift permission, and
368
+ // (crucially) WITHOUT the new-hook registration.
369
+ const ours = structuredClone(floorSettings);
370
+ ours.env.PATH = MACHINE_PATH;
371
+ ours.permissions.allow.push("Bash(mytool:*)");
372
+ const root = makeInstall(ours, { env: { HQ_FOO: "1" } });
373
+
374
+ const r = runRescueCapture(rescueArgs(root, floorSha), env);
375
+ const out = `${r.stdout}\n${r.stderr}`;
376
+ expect(r.status, out).toBe(0);
377
+ expect(out).toContain(`Baseline: ${floorSha}`);
378
+ expect(out).toContain("Reconciled .claude/settings.json to release baseline");
379
+
380
+ const settings = readSettings(root);
381
+ const local = readLocal(root);
382
+
383
+ // (1) The newly-released hook registration is now present in settings.json,
384
+ // and the hook FILE landed too.
385
+ expect(cmdsOf(settings)).toContain(NEW_HOOK_CMD);
386
+ expect(fs.existsSync(path.join(root, ".claude", "hooks", "new-hook.sh"))).toBe(true);
387
+
388
+ // (3) Machine env.PATH is preserved/composed — never dropped, never the
389
+ // release's (the release ships none), never hardcoded.
390
+ expect(settings.env.PATH).toBe(MACHINE_PATH);
391
+
392
+ // (2) The user-drift permission moved OUT of settings.json (back to the
393
+ // shipped baseline) and INTO settings.local.json — not lost.
394
+ expect(settings.permissions.allow).toEqual(["Bash(git:*)"]);
395
+ expect(local.permissions?.allow).toContain("Bash(mytool:*)");
396
+ // The pre-existing local override survives.
397
+ expect(local.env?.HQ_FOO).toBe("1");
398
+
399
+ // Effective merged settings (Project ⊕ Local, union of permissions) still
400
+ // grant the user's tool.
401
+ const effectiveAllow = new Set([
402
+ ...settings.permissions.allow,
403
+ ...(local.permissions?.allow ?? []),
404
+ ]);
405
+ expect(effectiveAllow.has("Bash(mytool:*)")).toBe(true);
406
+ expect(effectiveAllow.has("Bash(git:*)")).toBe(true);
407
+
408
+ // No junk personal/ copy of settings.json is minted.
409
+ expect(fs.existsSync(path.join(root, "personal", ".claude", "settings.json"))).toBe(false);
410
+ }, 120_000);
411
+
412
+ it("appends relocated drift to an existing settings.local.json array without overwriting it", () => {
413
+ // The user's settings.local.json ALREADY customizes permissions.allow and an
414
+ // env var. Rescue relocates a NEW drift permission but must not overwrite the
415
+ // existing local entries — they must all survive.
416
+ const ours = structuredClone(floorSettings);
417
+ ours.env.PATH = MACHINE_PATH;
418
+ ours.permissions.allow.push("Bash(mytool:*)"); // drift to relocate
419
+ const root = makeInstall(ours, {
420
+ permissions: { allow: ["Bash(existing:*)"] },
421
+ env: { HQ_FOO: "1" },
422
+ });
423
+
424
+ const r = runRescueCapture(rescueArgs(root, floorSha), env);
425
+ expect(r.status, `${r.stdout}\n${r.stderr}`).toBe(0);
426
+
427
+ const local = readLocal(root);
428
+ // Existing local entry preserved; drift appended (order preserved, no dup).
429
+ expect(local.permissions?.allow).toEqual(["Bash(existing:*)", "Bash(mytool:*)"]);
430
+ // Existing local env override untouched.
431
+ expect(local.env?.HQ_FOO).toBe("1");
432
+ }, 120_000);
433
+
434
+ it("relocates a user-added permission even with no reachable floor (head_compare fallback)", () => {
435
+ // No --floor-sha, --no-history-check -> head_compare mode (base = release
436
+ // HEAD). A user-added permission absent from the release must still be
437
+ // preserved, the new hook registration must still wire in, and env.PATH must
438
+ // still be composed.
439
+ const ours = structuredClone(floorSettings);
440
+ ours.env.PATH = MACHINE_PATH;
441
+ ours.permissions.allow.push("Bash(mytool:*)");
442
+ const root = makeInstall(ours, {});
443
+
444
+ const r = runRescueCapture(rescueArgs(root), env);
445
+ const out = `${r.stdout}\n${r.stderr}`;
446
+ expect(r.status, out).toBe(0);
447
+ expect(out).toContain("HEAD compare");
448
+ expect(out).toContain("Reconciled .claude/settings.json to release baseline");
449
+
450
+ const settings = readSettings(root);
451
+ const local = readLocal(root);
452
+
453
+ expect(cmdsOf(settings)).toContain(NEW_HOOK_CMD); // release baseline wins
454
+ expect(settings.env.PATH).toBe(MACHINE_PATH); // machine PATH composed
455
+ // The user-added permission the release never had is relocated, not lost.
456
+ expect(settings.permissions.allow).toEqual(["Bash(git:*)"]);
457
+ expect(local.permissions?.allow).toContain("Bash(mytool:*)");
458
+ expect(fs.existsSync(path.join(root, "personal", ".claude", "settings.json"))).toBe(false);
459
+ }, 120_000);
460
+
461
+ it("composes env.PATH through the release composer (toolchain dirs prepended, not hardcoded)", () => {
462
+ // ours has a machine PATH but is MISSING the managed toolchain dirs. The
463
+ // reconcile must run them through compose-settings-path.sh so the toolchain
464
+ // is prepended — proving env.PATH is genuinely COMPOSED (deliverable 3),
465
+ // not merely copied and not hardcoded from any release blob.
466
+ const toolchain = path.join(workDir, `toolchain-${installSeq + 1}`);
467
+ for (const d of ["node/bin", "npm-global/bin", "git/bin"]) {
468
+ fs.mkdirSync(path.join(toolchain, d), { recursive: true });
469
+ }
470
+ const ours = structuredClone(floorSettings);
471
+ ours.env.PATH = MACHINE_PATH; // no toolchain dirs present
472
+ const root = makeInstall(ours, {});
473
+
474
+ const r = runRescueCapture(rescueArgs(root, floorSha), {
475
+ ...env,
476
+ HQ_TOOLCHAIN_DIR: toolchain,
477
+ });
478
+ const out = `${r.stdout}\n${r.stderr}`;
479
+ expect(r.status, out).toBe(0);
480
+
481
+ const composed = readSettings(root).env.PATH;
482
+ // installer ordering: node, npm-global, git prepended ahead of the base.
483
+ expect(composed).toBe(
484
+ `${toolchain}/node/bin:${toolchain}/npm-global/bin:${toolchain}/git/bin:${MACHINE_PATH}`,
485
+ );
486
+ // The machine base is still present (never dropped).
487
+ expect(composed.endsWith(MACHINE_PATH)).toBe(true);
488
+ }, 120_000);
489
+
490
+ it("persists a user's array removal against the release baseline (thread 1)", () => {
491
+ // ours REMOVES the shipped "Bash(git:*)" permission. A union Local overlay
492
+ // cannot express a removal, so the reconcile must strip it from the Project
493
+ // baseline (theirs) instead — otherwise the release re-introduces it.
494
+ const ours = structuredClone(floorSettings);
495
+ ours.env.PATH = MACHINE_PATH;
496
+ ours.permissions.allow = []; // removed Bash(git:*)
497
+ const root = makeInstall(ours, {});
498
+
499
+ const r = runRescueCapture(rescueArgs(root, floorSha), env);
500
+ expect(r.status, `${r.stdout}\n${r.stderr}`).toBe(0);
501
+
502
+ const settings = readSettings(root);
503
+ // The deliberately-removed permission stays gone, and the new hook still lands.
504
+ expect(settings.permissions.allow).toEqual([]);
505
+ expect(cmdsOf(settings)).toContain(NEW_HOOK_CMD);
506
+ }, 120_000);
507
+
508
+ it("keeps the original settings.json when settings.local.json is unwritable (thread 3)", () => {
509
+ // ours has a drift permission to relocate; make settings.local.json a
510
+ // DIRECTORY so the relocation write fails. The reconcile must then preserve
511
+ // ours rather than overwrite it with the drift-less release baseline.
512
+ const ours = structuredClone(floorSettings);
513
+ ours.env.PATH = MACHINE_PATH;
514
+ ours.permissions.allow.push("Bash(mytool:*)");
515
+ const root = makeInstall(ours, {});
516
+ // Replace the local file with a directory (write will EISDIR). It is a
517
+ // carve-out, so the shuttle restores it as a directory across the overlay.
518
+ fs.rmSync(path.join(root, ".claude", "settings.local.json"));
519
+ fs.mkdirSync(path.join(root, ".claude", "settings.local.json"));
520
+
521
+ const r = runRescueCapture(rescueArgs(root, floorSha), env);
522
+ const out = `${r.stdout}\n${r.stderr}`;
523
+ expect(r.status, out).toBe(0);
524
+ expect(out).toContain("settings update not applied");
525
+
526
+ // ours is preserved verbatim — the drift permission is NOT lost.
527
+ const settings = readSettings(root);
528
+ expect(settings.permissions.allow).toContain("Bash(mytool:*)");
529
+ expect(settings.env.PATH).toBe(MACHINE_PATH);
530
+ }, 120_000);
531
+
532
+ it("backs up a malformed pre-rescue settings.json instead of destroying it (thread 5)", () => {
533
+ // Pre-rescue settings.json is invalid JSON. The overlay replaces it, so the
534
+ // reconcile must preserve the raw bytes under personal/ and still leave a
535
+ // working (composed) settings.json.
536
+ const root = makeInstall(structuredClone(floorSettings), {});
537
+ fs.writeFileSync(path.join(root, ".claude", "settings.json"), "{ this is : not json ");
538
+
539
+ const r = runRescueCapture(rescueArgs(root, floorSha), env);
540
+ const out = `${r.stdout}\n${r.stderr}`;
541
+ expect(r.status, out).toBe(0);
542
+
543
+ // Raw malformed content preserved under personal/.
544
+ const backup = path.join(root, "personal", ".claude", "settings.json");
545
+ expect(fs.existsSync(backup)).toBe(true);
546
+ expect(fs.readFileSync(backup, "utf-8")).toBe("{ this is : not json ");
547
+ // settings.json is valid again (release baseline + composed PATH + new hook).
548
+ const settings = readSettings(root);
549
+ expect(cmdsOf(settings)).toContain(NEW_HOOK_CMD);
550
+ expect(typeof settings.env.PATH).toBe("string");
551
+ }, 120_000);
552
+
553
+ it("honors an explicit --preserve-subpath of settings.json (thread 6)", () => {
554
+ // The caller asked to keep settings.json; the reconcile must not rewrite it,
555
+ // and the (preserved) old file — without the new registration — stays as-is.
556
+ const ours = structuredClone(floorSettings);
557
+ ours.env.PATH = MACHINE_PATH;
558
+ const root = makeInstall(ours, {});
559
+
560
+ const r = runRescueCapture(
561
+ [...rescueArgs(root, floorSha), "--preserve-subpath", ".claude/settings.json"],
562
+ env,
563
+ );
564
+ const out = `${r.stdout}\n${r.stderr}`;
565
+ expect(r.status, out).toBe(0);
566
+ expect(out).toContain("explicitly preserved");
567
+
568
+ const settings = readSettings(root);
569
+ // Preserved verbatim: no reconcile, so the new registration did NOT land.
570
+ expect(cmdsOf(settings)).not.toContain(NEW_HOOK_CMD);
571
+ expect(settings.env.PATH).toBe(MACHINE_PATH);
572
+ }, 120_000);
573
+
574
+ it("restores the machine settings.json when a release drops the template (thread 4)", () => {
575
+ // A dedicated fixture whose HEAD DELETES .claude/settings.json (floor had
576
+ // it). Overwrite-safe deletes ours; the overlay lays nothing; the reconcile
577
+ // must restore ours so PATH + customizations survive under --no-backup.
578
+ const up2 = path.join(workDir, "upstream-drop");
579
+ fs.mkdirSync(path.join(up2, ".claude"), { recursive: true });
580
+ fs.mkdirSync(path.join(up2, "core", "scripts"), { recursive: true });
581
+ git(workDir, "init", "-b", "main", "upstream-drop");
582
+ fs.writeFileSync(
583
+ path.join(up2, ".claude", "settings.json"),
584
+ JSON.stringify(floorSettings, null, 2) + "\n",
585
+ );
586
+ fs.writeFileSync(path.join(up2, "core", "scripts", "compose-settings-path.sh"), COMPOSER, { mode: 0o755 });
587
+ fs.writeFileSync(path.join(up2, "core", "keep.md"), "same\n");
588
+ git(up2, "add", "-A");
589
+ git(up2, "commit", "-m", "floor with settings");
590
+ const floor2 = git(up2, "rev-parse", "HEAD");
591
+ // HEAD removes the template.
592
+ fs.rmSync(path.join(up2, ".claude", "settings.json"));
593
+ git(up2, "add", "-A");
594
+ git(up2, "commit", "-m", "drop settings.json");
595
+
596
+ // A shim that points clones at up2 instead of the shared upstream.
597
+ const realGit =
598
+ execFileSync("bash", ["-c", "command -v git"]).toString().trim() || "/usr/bin/git";
599
+ const shim2 = path.join(workDir, "shim-drop");
600
+ fs.mkdirSync(shim2, { recursive: true });
601
+ fs.writeFileSync(
602
+ path.join(shim2, "git"),
603
+ `#!/usr/bin/env bash
604
+ if [ "$1" = "clone" ]; then
605
+ args=()
606
+ for a in "$@"; do
607
+ case "$a" in
608
+ https://github.com/*) a=${JSON.stringify(up2)} ;;
609
+ esac
610
+ args+=("$a")
611
+ done
612
+ exec ${JSON.stringify(realGit)} "\${args[@]}"
613
+ fi
614
+ exec ${JSON.stringify(realGit)} "$@"
615
+ `,
616
+ { mode: 0o755 },
617
+ );
618
+
619
+ const ours = structuredClone(floorSettings);
620
+ ours.env.PATH = MACHINE_PATH;
621
+ ours.permissions.allow.push("Bash(mytool:*)");
622
+ const root = makeInstall(ours, {});
623
+
624
+ const r = runRescueCapture(rescueArgs(root, floor2), {
625
+ ...env,
626
+ PATH: `${shim2}:${process.env.PATH ?? ""}`,
627
+ });
628
+ const out = `${r.stdout}\n${r.stderr}`;
629
+ expect(r.status, out).toBe(0);
630
+ expect(out).toContain("Release no longer ships .claude/settings.json");
631
+
632
+ // ours restored — machine PATH and the user's customization survive.
633
+ const settings = readSettings(root);
634
+ expect(settings.env.PATH).toBe(MACHINE_PATH);
635
+ expect(settings.permissions.allow).toContain("Bash(mytool:*)");
636
+ }, 120_000);
637
+ });
package/src/index.ts CHANGED
@@ -412,11 +412,9 @@ export {
412
412
  decodePushEvent,
413
413
  NoopPushTransport,
414
414
  HttpPushTransport,
415
- FEATURE_FLAG_TENANTS_ENV_VAR,
416
- FEATURE_FLAG_LEGACY_GLOBAL_ENV_VAR,
417
- EnvTenantListFlagProvider,
415
+ EVENT_SYNC_LOCAL_DISABLE_ENV_VAR,
416
+ isEventSyncLocallyDisabled,
418
417
  StaticFlagProvider,
419
- defaultFlagProvider,
420
418
  } from "./sync/index.js";
421
419
  export type {
422
420
  PushEvent,