@indigoai-us/hq-cloud 6.3.0 → 6.3.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.
@@ -3643,3 +3643,249 @@ describe("runRunnerWithLoop — operation lock", () => {
3643
3643
  expect(held.command).toBe("sync");
3644
3644
  });
3645
3645
  });
3646
+
3647
+ // ---------------------------------------------------------------------------
3648
+ // Phase 3 — event-driven publish + pull wiring (US-017/018/019)
3649
+ // ---------------------------------------------------------------------------
3650
+ //
3651
+ // Exercises the rollout-gated bring-up through the `startEventSync` /
3652
+ // `getIdTokenClaims` seams: gate-off is byte-identical to today (the seam is
3653
+ // never consulted), gate-on passes the wiring the right identity + sync
3654
+ // bridge, and the publish leg fires only AFTER a SUCCESSFUL targeted push
3655
+ // pass (events must never announce bytes that are not in S3 yet).
3656
+
3657
+ interface BatchWatcherStub extends WatcherSurface {
3658
+ emit(changedRelPath?: string, batch?: { paths: Map<string, string> }): void;
3659
+ disposed: boolean;
3660
+ }
3661
+
3662
+ function makeBatchWatcherStub(): BatchWatcherStub {
3663
+ const listeners = new Set<
3664
+ (p?: string, b?: { paths: Map<string, string> }) => void
3665
+ >();
3666
+ return {
3667
+ disposed: false,
3668
+ onChange(listener) {
3669
+ listeners.add(listener);
3670
+ return () => listeners.delete(listener);
3671
+ },
3672
+ start() {},
3673
+ stop() {},
3674
+ dispose() {
3675
+ this.disposed = true;
3676
+ listeners.clear();
3677
+ },
3678
+ emit(changedRelPath?: string, batch?: { paths: Map<string, string> }) {
3679
+ for (const l of [...listeners]) l(changedRelPath, batch);
3680
+ },
3681
+ };
3682
+ }
3683
+
3684
+ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", () => {
3685
+ const ENROLLED_CLAIMS = { email: "hassaan@getindigo.ai" };
3686
+ const UNENROLLED_CLAIMS = { email: "someone@getindigo.ai" };
3687
+
3688
+ function runLoop(opts: {
3689
+ claims: { email?: string } | null;
3690
+ startEventSync?: ReturnType<typeof vi.fn>;
3691
+ watcher?: BatchWatcherStub;
3692
+ runPass?: ReturnType<typeof vi.fn>;
3693
+ }) {
3694
+ const watcher = opts.watcher ?? makeBatchWatcherStub();
3695
+ const runPass = opts.runPass ?? vi.fn().mockResolvedValue(0);
3696
+ const clock = new FakeClock();
3697
+ let triggerShutdown = () => {};
3698
+ const loop = runRunnerWithLoop(
3699
+ ["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
3700
+ {
3701
+ runPass,
3702
+ clock,
3703
+ createWatcher: () => watcher,
3704
+ sleep: () => new Promise<void>(() => {}),
3705
+ onShutdownSignal: (handler) => {
3706
+ triggerShutdown = handler;
3707
+ return () => {};
3708
+ },
3709
+ getIdTokenClaims: () => opts.claims as never,
3710
+ getAccessToken: async () => "jwt-test",
3711
+ startEventSync: (opts.startEventSync ??
3712
+ vi.fn().mockResolvedValue(null)) as never,
3713
+ },
3714
+ );
3715
+ return { loop, watcher, runPass, clock, shutdown: () => triggerShutdown() };
3716
+ }
3717
+
3718
+ async function microtasks(n = 6) {
3719
+ for (let i = 0; i < n; i++) await Promise.resolve();
3720
+ }
3721
+
3722
+ it("gate OFF (unenrolled email): the event-sync seam is never consulted", async () => {
3723
+ const startEventSync = vi.fn().mockResolvedValue(null);
3724
+ const { loop, shutdown } = runLoop({
3725
+ claims: UNENROLLED_CLAIMS,
3726
+ startEventSync,
3727
+ });
3728
+ await microtasks();
3729
+ shutdown();
3730
+ await loop;
3731
+ expect(startEventSync).not.toHaveBeenCalled();
3732
+ });
3733
+
3734
+ it("gate OFF (no cached claims): the event-sync seam is never consulted", async () => {
3735
+ const startEventSync = vi.fn().mockResolvedValue(null);
3736
+ const { loop, shutdown } = runLoop({ claims: null, startEventSync });
3737
+ await microtasks();
3738
+ shutdown();
3739
+ await loop;
3740
+ expect(startEventSync).not.toHaveBeenCalled();
3741
+ });
3742
+
3743
+ it("gate ON (enrolled email): wiring receives root, api url, auth + a sync bridge", async () => {
3744
+ const startEventSync = vi.fn().mockResolvedValue(null);
3745
+ const { loop, shutdown } = runLoop({
3746
+ claims: ENROLLED_CLAIMS,
3747
+ startEventSync,
3748
+ });
3749
+ await microtasks();
3750
+ shutdown();
3751
+ await loop;
3752
+ expect(startEventSync).toHaveBeenCalledTimes(1);
3753
+ const arg = startEventSync.mock.calls[0][0];
3754
+ expect(arg.hqRoot).toBe("/tmp/hq");
3755
+ expect(arg.apiUrl).toContain("https://");
3756
+ expect(typeof arg.deviceId).toBe("string");
3757
+ expect(arg.deviceId.length).toBeGreaterThan(0);
3758
+ expect(typeof arg.resolveTenantId).toBe("function");
3759
+ expect(typeof arg.syncFn).toBe("function");
3760
+ });
3761
+
3762
+ it("publishes the batch ONLY after a successful targeted push pass", async () => {
3763
+ const publishBatch = vi.fn();
3764
+ const startEventSync = vi.fn().mockResolvedValue({
3765
+ publishBatch,
3766
+ receiver: { start: async () => {}, dispose: async () => {}, connected: true },
3767
+ ownDeviceId: "dev-test",
3768
+ dispose: vi.fn().mockResolvedValue(undefined),
3769
+ });
3770
+ const runPass = vi.fn().mockResolvedValue(0);
3771
+ const { loop, watcher, clock, shutdown } = runLoop({
3772
+ claims: ENROLLED_CLAIMS,
3773
+ startEventSync,
3774
+ runPass,
3775
+ });
3776
+ await microtasks(); // initial poll pass + async event-sync bring-up
3777
+ runPass.mockClear();
3778
+
3779
+ const batch = {
3780
+ paths: new Map([["/tmp/hq/companies/indigo/a.md", "companies/indigo/a.md"]]),
3781
+ };
3782
+ watcher.emit("companies/indigo/a.md", batch);
3783
+ clock.advance(0);
3784
+ await microtasks();
3785
+
3786
+ // Targeted push ran and succeeded → batch published.
3787
+ expect(runPass).toHaveBeenCalled();
3788
+ expect(publishBatch).toHaveBeenCalledTimes(1);
3789
+ expect(publishBatch.mock.calls[0][0]).toBe(batch);
3790
+
3791
+ shutdown();
3792
+ await loop;
3793
+ });
3794
+
3795
+ it("publishes NOTHING when the targeted push pass fails", async () => {
3796
+ const publishBatch = vi.fn();
3797
+ const startEventSync = vi.fn().mockResolvedValue({
3798
+ publishBatch,
3799
+ receiver: { start: async () => {}, dispose: async () => {}, connected: true },
3800
+ ownDeviceId: "dev-test",
3801
+ dispose: vi.fn().mockResolvedValue(undefined),
3802
+ });
3803
+ // Initial poll pass succeeds; the targeted pass fails.
3804
+ const runPass = vi
3805
+ .fn()
3806
+ .mockResolvedValueOnce(0)
3807
+ .mockResolvedValue(1);
3808
+ const { loop, watcher, clock, shutdown } = runLoop({
3809
+ claims: ENROLLED_CLAIMS,
3810
+ startEventSync,
3811
+ runPass,
3812
+ });
3813
+ await microtasks();
3814
+
3815
+ watcher.emit(
3816
+ "companies/indigo/a.md",
3817
+ { paths: new Map([["/tmp/hq/companies/indigo/a.md", "companies/indigo/a.md"]]) },
3818
+ );
3819
+ clock.advance(0);
3820
+ await microtasks();
3821
+
3822
+ expect(publishBatch).not.toHaveBeenCalled();
3823
+ shutdown();
3824
+ await loop;
3825
+ });
3826
+
3827
+ it("falls back to a synthesized single-path batch when the watcher emits a bare path", async () => {
3828
+ const publishBatch = vi.fn();
3829
+ const startEventSync = vi.fn().mockResolvedValue({
3830
+ publishBatch,
3831
+ receiver: { start: async () => {}, dispose: async () => {}, connected: true },
3832
+ ownDeviceId: "dev-test",
3833
+ dispose: vi.fn().mockResolvedValue(undefined),
3834
+ });
3835
+ const { loop, watcher, clock, shutdown } = runLoop({
3836
+ claims: ENROLLED_CLAIMS,
3837
+ startEventSync,
3838
+ });
3839
+ await microtasks();
3840
+
3841
+ watcher.emit("companies/indigo/b.md"); // no batch
3842
+ clock.advance(0);
3843
+ await microtasks();
3844
+
3845
+ expect(publishBatch).toHaveBeenCalledTimes(1);
3846
+ const synthesized = publishBatch.mock.calls[0][0] as {
3847
+ paths: Map<string, string>;
3848
+ };
3849
+ expect([...synthesized.paths.values()]).toEqual(["companies/indigo/b.md"]);
3850
+
3851
+ shutdown();
3852
+ await loop;
3853
+ });
3854
+
3855
+ it("disposes the event-sync handles on shutdown", async () => {
3856
+ const dispose = vi.fn().mockResolvedValue(undefined);
3857
+ const startEventSync = vi.fn().mockResolvedValue({
3858
+ publishBatch: vi.fn(),
3859
+ receiver: { start: async () => {}, dispose: async () => {}, connected: true },
3860
+ ownDeviceId: "dev-test",
3861
+ dispose,
3862
+ });
3863
+ const { loop, shutdown } = runLoop({
3864
+ claims: ENROLLED_CLAIMS,
3865
+ startEventSync,
3866
+ });
3867
+ await microtasks();
3868
+ shutdown();
3869
+ await loop;
3870
+ expect(dispose).toHaveBeenCalled();
3871
+ });
3872
+
3873
+ it("env override HQ_SYNC_EVENT_SYNC=0 keeps the seam dormant for the enrolled account", async () => {
3874
+ const prev = process.env.HQ_SYNC_EVENT_SYNC;
3875
+ process.env.HQ_SYNC_EVENT_SYNC = "0";
3876
+ try {
3877
+ const startEventSync = vi.fn().mockResolvedValue(null);
3878
+ const { loop, shutdown } = runLoop({
3879
+ claims: ENROLLED_CLAIMS,
3880
+ startEventSync,
3881
+ });
3882
+ await microtasks();
3883
+ shutdown();
3884
+ await loop;
3885
+ expect(startEventSync).not.toHaveBeenCalled();
3886
+ } finally {
3887
+ if (prev === undefined) delete process.env.HQ_SYNC_EVENT_SYNC;
3888
+ else process.env.HQ_SYNC_EVENT_SYNC = prev;
3889
+ }
3890
+ });
3891
+ });
@@ -112,12 +112,19 @@ import {
112
112
  WatchPushDriver,
113
113
  systemClock,
114
114
  type Clock,
115
+ type TreeChangeBatch,
115
116
  } from "../watcher.js";
116
117
  import {
117
118
  NoopPushReceiver,
118
119
  type PushReceiver,
119
120
  type SyncEngineFn,
120
121
  } from "../sync/push-receiver.js";
122
+ import {
123
+ resolveEventSync,
124
+ startEventSync as defaultStartEventSync,
125
+ type EventSyncHandles,
126
+ type StartEventSyncOptions,
127
+ } from "../sync/event-sync.js";
121
128
  import {
122
129
  PERSONAL_VAULT_JOURNAL_SLUG,
123
130
  migratePersonalVaultJournal,
@@ -1829,6 +1836,27 @@ export interface RunnerLoopDeps {
1829
1836
  syncFn: SyncEngineFn;
1830
1837
  hqRoot: string;
1831
1838
  }) => PushReceiver;
1839
+ /**
1840
+ * Phase 3 (US-017/US-018/US-019): factory for the event-driven publish +
1841
+ * pull wiring, consulted only when `--event-push` is on AND the rollout
1842
+ * gate ({@link resolveEventSync}) passes for the signed-in account.
1843
+ * Defaults to the real {@link defaultStartEventSync}. Tests inject a stub
1844
+ * to assert gate behavior without network/AWS.
1845
+ */
1846
+ startEventSync?: (
1847
+ opts: StartEventSyncOptions,
1848
+ ) => Promise<EventSyncHandles | null>;
1849
+ /**
1850
+ * Identity-claims source for the Phase 3 rollout gate (the loop has no
1851
+ * RunnerDeps; mirror of RunnerDeps.getIdTokenClaims). Defaults to reading
1852
+ * the cached Cognito idToken.
1853
+ */
1854
+ getIdTokenClaims?: () => IdTokenClaims | null;
1855
+ /**
1856
+ * Access-token source for the Phase 3 vault API calls (publish transport +
1857
+ * subscribe). Defaults to {@link getValidAccessToken} non-interactive.
1858
+ */
1859
+ getAccessToken?: () => Promise<string>;
1832
1860
  }
1833
1861
 
1834
1862
  /**
@@ -1843,7 +1871,9 @@ export interface RunnerLoopDeps {
1843
1871
  * relative path so the loop targets just that company.
1844
1872
  */
1845
1873
  export interface WatcherSurface {
1846
- onChange(listener: (changedRelPath?: string) => void): () => void;
1874
+ onChange(
1875
+ listener: (changedRelPath?: string, batch?: TreeChangeBatch) => void,
1876
+ ): () => void;
1847
1877
  start(): void;
1848
1878
  stop(): void;
1849
1879
  dispose(): void;
@@ -1999,12 +2029,17 @@ export async function runRunnerWithLoop(
1999
2029
  let driver: WatchPushDriver | null = null;
2000
2030
  let detachSignal: (() => void) | null = null;
2001
2031
  let lastChangedRel: string | null = null;
2032
+ let lastBatch: TreeChangeBatch | null = null;
2002
2033
  // ---- pull-on-event receiver (Phase 2, US-009) ------------------------
2003
2034
  // Started after the watcher, disposed before the watcher (mirror of the
2004
2035
  // PushTransport ordering). Dormant by default: the default factory returns
2005
2036
  // a NoopPushReceiver, and even a real receiver stays dormant unless the
2006
2037
  // per-tenant feature flag is on AND a queue URL is provisioned server-side.
2007
2038
  let receiver: PushReceiver | null = null;
2039
+ // ---- event-driven publish + pull (Phase 3, US-017/018/019) ------------
2040
+ // Brought up asynchronously after the watcher when the rollout gate
2041
+ // passes; null until ready (and stays null on startup failure → poll-only).
2042
+ let eventSync: EventSyncHandles | null = null;
2008
2043
 
2009
2044
  if (eventPush) {
2010
2045
  const clock = deps.clock ?? systemClock;
@@ -2032,12 +2067,28 @@ export async function runRunnerWithLoop(
2032
2067
  push: async () => {
2033
2068
  if (stopped) return;
2034
2069
  const rel = lastChangedRel;
2070
+ // Snapshot the settled batch BEFORE the await: a change landing
2071
+ // mid-pass overwrites lastBatch for the NEXT pass, and this pass
2072
+ // must only announce what it actually pushed.
2073
+ const batchForPublish = lastBatch;
2074
+ lastBatch = null;
2035
2075
  const route = rel
2036
2076
  ? routeChangeToTarget(rel)
2037
2077
  : { kind: "personal" as const };
2038
2078
  if (!route) return;
2039
2079
  const targetedArgv = buildTargetedPushArgv(route, passArgv);
2040
- await runGuarded(() => runPass(targetedArgv));
2080
+ const result = await runGuarded(() => runPass(targetedArgv));
2081
+ // Phase 3 (US-017): publish PushEvents only AFTER the targeted push
2082
+ // pass succeeded — an event must never announce bytes that are not
2083
+ // in S3 yet. A skipped pass (guard held) or a failed pass publishes
2084
+ // nothing; the cadence poll covers the miss. Fall back to a
2085
+ // single-path batch when the watcher emitted a bare path signal.
2086
+ if (result === 0 && eventSync) {
2087
+ const batch: TreeChangeBatch | null =
2088
+ batchForPublish ??
2089
+ (rel ? { paths: new Map([[path.join(hqRoot, rel), rel]]) } : null);
2090
+ if (batch) eventSync.publishBatch(batch);
2091
+ }
2041
2092
  },
2042
2093
  });
2043
2094
 
@@ -2046,9 +2097,10 @@ export async function runRunnerWithLoop(
2046
2097
  // still serialized behind any in-flight pass. A path-aware watcher passes
2047
2098
  // the changed relative path so the push targets just its owning company;
2048
2099
  // the bare-signal TreeWatcher leaves it null → personal-vault route.
2049
- watcher.onChange((changedRelPath) => {
2100
+ watcher.onChange((changedRelPath, batch) => {
2050
2101
  if (stopped) return;
2051
2102
  lastChangedRel = changedRelPath ?? null;
2103
+ lastBatch = batch ?? null;
2052
2104
  driver?.notifyChange();
2053
2105
  });
2054
2106
  watcher.start();
@@ -2077,6 +2129,60 @@ export async function runRunnerWithLoop(
2077
2129
  // start also keeps the poll loop's microtask timing identical to the
2078
2130
  // pre-US-009 wiring.)
2079
2131
  void Promise.resolve(receiver.start()).catch(() => undefined);
2132
+
2133
+ // ---- Phase 3: event-driven publish + pull (US-017/018/019) ----------
2134
+ // Gated to enrolled accounts (resolveEventSync — exact-email allowlist +
2135
+ // HQ_SYNC_EVENT_SYNC override). Brought up asynchronously so a slow
2136
+ // subscribe/vend can't delay the first poll pass; until (and unless) the
2137
+ // handles resolve, behavior is byte-identical to the gate-off path.
2138
+ const getClaims = deps.getIdTokenClaims ?? defaultGetIdTokenClaims;
2139
+ const email = getClaims()?.email;
2140
+ if (resolveEventSync(email, process.env.HQ_SYNC_EVENT_SYNC)) {
2141
+ const getAccessToken =
2142
+ deps.getAccessToken ??
2143
+ (() => getValidAccessToken(DEFAULT_COGNITO, { interactive: false }));
2144
+ const startES = deps.startEventSync ?? defaultStartEventSync;
2145
+ // Entirely async + caught: NOTHING in the Phase 3 bring-up (device-id
2146
+ // persistence, tenant resolution, subscribe) may crash or delay the
2147
+ // daemon — any failure degrades to poll-only.
2148
+ void (async () => {
2149
+ const handles = await startES({
2150
+ hqRoot,
2151
+ apiUrl: DEFAULT_VAULT_API_URL,
2152
+ authToken: getAccessToken,
2153
+ deviceId: getOrCreateMachineId(hqRoot),
2154
+ // The server rejects publishes whose originTenantId mismatches the
2155
+ // JWT principal, so resolve the SAME canonical person uid the vault
2156
+ // API derives from this token.
2157
+ resolveTenantId: async () => {
2158
+ const client = new VaultClient({
2159
+ apiUrl: DEFAULT_VAULT_API_URL,
2160
+ authToken: getAccessToken,
2161
+ region: DEFAULT_COGNITO.region,
2162
+ });
2163
+ const persons = await client.entity.listByType("person");
2164
+ const pick = pickCanonicalPersonEntity(persons);
2165
+ if (!pick?.uid) {
2166
+ throw new Error("no canonical person entity for this account");
2167
+ }
2168
+ return pick.uid;
2169
+ },
2170
+ syncFn: receiverSyncFn,
2171
+ log: (m) => process.stderr.write(`${m}\n`),
2172
+ });
2173
+ if (!handles) return;
2174
+ if (stopped) {
2175
+ // Shutdown raced the async bring-up — tear straight down.
2176
+ void handles.dispose();
2177
+ return;
2178
+ }
2179
+ eventSync = handles;
2180
+ })().catch((err) => {
2181
+ process.stderr.write(
2182
+ `event-sync: wiring failed, continuing poll-only: ${describeError(err)}\n`,
2183
+ );
2184
+ });
2185
+ }
2080
2186
  }
2081
2187
 
2082
2188
  // ---- clean shutdown --------------------------------------------------
@@ -2101,6 +2207,14 @@ export async function runRunnerWithLoop(
2101
2207
  } catch {
2102
2208
  /* ignore */
2103
2209
  }
2210
+ // Phase 3 wiring (publish transport + live receiver) — torn down with
2211
+ // the same fire-and-forget posture as the Phase 2 receiver above.
2212
+ try {
2213
+ void eventSync?.dispose();
2214
+ } catch {
2215
+ /* ignore */
2216
+ }
2217
+ eventSync = null;
2104
2218
  try {
2105
2219
  driver?.dispose();
2106
2220
  } catch {
@@ -384,9 +384,22 @@ function doRescue(
384
384
  hqRoot = fs.realpathSync(process.cwd());
385
385
  }
386
386
 
387
- if (!isDir(path.join(hqRoot, "companies")) || !isDir(path.join(hqRoot, "personal"))) {
387
+ // HQ-root sanity gate — guards against wiping a non-HQ directory. `companies/`
388
+ // is the stable anchor (present and preserved on every HQ install), confirmed
389
+ // by at least one core scaffold marker. We accept ANY of `.claude/`, `core/`,
390
+ // or `personal/` because the layout has drifted across releases: v14.0.0 ships
391
+ // NEITHER `personal/` (added v15) NOR `core/` (the v15 scaffold home) — it only
392
+ // has `.claude/`. Requiring `personal/` here left v14.0.0 users with no upgrade
393
+ // path (rescue aborted; DEV-1741). `.claude/` exists on every version v14→v15,
394
+ // so it admits the full upgrade range while still rejecting a bare directory.
395
+ const hasHqMarker =
396
+ isDir(path.join(hqRoot, ".claude")) ||
397
+ isDir(path.join(hqRoot, "core")) ||
398
+ isDir(path.join(hqRoot, "personal"));
399
+ if (!isDir(path.join(hqRoot, "companies")) || !hasHqMarker) {
388
400
  err(
389
- `error: ${hqRoot} does not look like an HQ root (missing companies/ or personal/). Aborting.\n`,
401
+ `error: ${hqRoot} does not look like an HQ root ` +
402
+ `(need companies/ plus one of .claude/, core/, or personal/). Aborting.\n`,
390
403
  );
391
404
  err(" pass --hq-root <dir> if the script is not at personal/skills/<skill>/.\n");
392
405
  throw new ExitError(3);
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Regression test for the rescue HQ-root sanity gate in src/cli/rescue-core.ts.
3
+ *
4
+ * Bug (DEV-1741): the gate required BOTH `companies/` AND `personal/`. The
5
+ * `personal/` directory was only introduced in v15 — a faithful v14.0.0 install
6
+ * ships NEITHER `personal/` NOR `core/` (its scaffold lived in top-level dirs
7
+ * like `scripts/`, `workers/`, `knowledge/`, plus `.claude/`). So every v14.0.0
8
+ * user hit `error: ... missing companies/ or personal/. Aborting.` (exit 3) and
9
+ * had no supported upgrade path to v15.
10
+ *
11
+ * The gate now anchors on `companies/` plus ANY ONE of `.claude/`, `core/`, or
12
+ * `personal/`. `.claude/` exists on every release from v14 through v15, so the
13
+ * relaxed gate admits the full upgrade range while still rejecting a directory
14
+ * that is not an HQ root at all.
15
+ *
16
+ * Like the sibling drift test, the rescue clones its source from GitHub, so we
17
+ * shim `git clone` to a local fixture repo. All runs are `--dry-run` (no
18
+ * destructive ops, no backup) and we assert on the gate's behavior (exit code +
19
+ * message), not the full overlay.
20
+ */
21
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
22
+ import { execFileSync } from "child_process";
23
+ import * as fs from "fs";
24
+ import * as os from "os";
25
+ import * as path from "path";
26
+ import { runRescue } from "./rescue-core.js";
27
+
28
+ function hasGit(): boolean {
29
+ try {
30
+ execFileSync("git", ["--version"], { stdio: "ignore" });
31
+ return true;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ const gitAvailable = hasGit();
38
+
39
+ /** Run the rescue in-process, capturing its stdout/stderr. */
40
+ function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
41
+ let stdout = "";
42
+ let stderr = "";
43
+ const origOut = process.stdout.write.bind(process.stdout);
44
+ const origErr = process.stderr.write.bind(process.stderr);
45
+ process.stdout.write = ((chunk: unknown) => {
46
+ stdout += String(chunk);
47
+ return true;
48
+ }) as typeof process.stdout.write;
49
+ process.stderr.write = ((chunk: unknown) => {
50
+ stderr += String(chunk);
51
+ return true;
52
+ }) as typeof process.stderr.write;
53
+ let status: number;
54
+ try {
55
+ status = runRescue(argv, { env }).status;
56
+ } finally {
57
+ process.stdout.write = origOut;
58
+ process.stderr.write = origErr;
59
+ }
60
+ return { status, stdout, stderr };
61
+ }
62
+
63
+ describe.skipIf(!gitAvailable)("rescue HQ-root sanity gate", () => {
64
+ let workDir: string;
65
+ let upstream: string;
66
+ let shimDir: string;
67
+ let floorSha: string;
68
+ let env: NodeJS.ProcessEnv;
69
+
70
+ const git = (cwd: string, ...args: string[]) =>
71
+ execFileSync("git", args, {
72
+ cwd,
73
+ stdio: ["ignore", "pipe", "pipe"],
74
+ env: {
75
+ ...process.env,
76
+ GIT_AUTHOR_NAME: "t",
77
+ GIT_AUTHOR_EMAIL: "t@t",
78
+ GIT_COMMITTER_NAME: "t",
79
+ GIT_COMMITTER_EMAIL: "t@t",
80
+ },
81
+ })
82
+ .toString()
83
+ .trim();
84
+
85
+ beforeAll(() => {
86
+ workDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rescue-gate-"));
87
+
88
+ // --- Minimal local "upstream" repo (one core file, floor + HEAD). ---
89
+ upstream = path.join(workDir, "upstream");
90
+ fs.mkdirSync(path.join(upstream, "core"), { recursive: true });
91
+ git(workDir, "init", "-b", "main", "upstream");
92
+ fs.writeFileSync(path.join(upstream, "core/x.md"), "v1\n");
93
+ git(upstream, "add", "-A");
94
+ git(upstream, "commit", "-m", "floor");
95
+ floorSha = git(upstream, "rev-parse", "HEAD");
96
+ fs.writeFileSync(path.join(upstream, "core/x.md"), "v2\n");
97
+ git(upstream, "add", "-A");
98
+ git(upstream, "commit", "-m", "head");
99
+
100
+ // --- git shim: rewrite `git clone <github-url> dest` to the fixture. ---
101
+ const realGit =
102
+ execFileSync("bash", ["-lc", "command -v git"]).toString().trim() || "/usr/bin/git";
103
+ shimDir = path.join(workDir, "shim");
104
+ fs.mkdirSync(shimDir, { recursive: true });
105
+ const shim = `#!/usr/bin/env bash
106
+ if [ "$1" = "clone" ]; then
107
+ args=()
108
+ for a in "$@"; do
109
+ case "$a" in
110
+ https://github.com/*) a=${JSON.stringify(upstream)} ;;
111
+ esac
112
+ args+=("$a")
113
+ done
114
+ exec ${JSON.stringify(realGit)} "\${args[@]}"
115
+ fi
116
+ exec ${JSON.stringify(realGit)} "$@"
117
+ `;
118
+ fs.writeFileSync(path.join(shimDir, "git"), shim, { mode: 0o755 });
119
+ env = { ...process.env, PATH: `${shimDir}:${process.env.PATH ?? ""}` };
120
+ }, 60_000); // git init + commits can exceed vitest's 10s default under parallel load
121
+
122
+ afterAll(() => {
123
+ if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
124
+ });
125
+
126
+ function makeRoot(name: string, dirs: string[]): string {
127
+ const root = path.join(workDir, name);
128
+ for (const d of dirs) fs.mkdirSync(path.join(root, d), { recursive: true });
129
+ return root;
130
+ }
131
+
132
+ function rescueDry(hqRoot: string) {
133
+ return runRescueCapture(
134
+ [
135
+ "--hq-root", hqRoot,
136
+ "--source", "test/repo",
137
+ "--ref", "main",
138
+ "--floor-sha", floorSha,
139
+ "--dry-run",
140
+ "--yes",
141
+ "--no-backup",
142
+ ],
143
+ env,
144
+ );
145
+ }
146
+
147
+ it("admits a faithful v14.0.0 root (companies/ + .claude/, NO personal/, NO core/)", () => {
148
+ // Exactly the shape that aborted before the fix.
149
+ const root = makeRoot("v14", ["companies", ".claude", "repos", "workspace"]);
150
+ expect(fs.existsSync(path.join(root, "personal"))).toBe(false);
151
+ expect(fs.existsSync(path.join(root, "core"))).toBe(false);
152
+
153
+ const r = rescueDry(root);
154
+ const out = `${r.stdout}\n${r.stderr}`;
155
+ // Must get PAST the gate — not the exit-3 abort.
156
+ expect(r.status, out).not.toBe(3);
157
+ expect(out).not.toContain("does not look like an HQ root");
158
+ expect(r.status, out).toBe(0);
159
+ });
160
+
161
+ it("admits a v15 root (companies/ + core/ + personal/)", () => {
162
+ const root = makeRoot("v15", ["companies", ".claude", "core", "personal", "repos", "workspace"]);
163
+ const r = rescueDry(root);
164
+ const out = `${r.stdout}\n${r.stderr}`;
165
+ expect(r.status, out).toBe(0);
166
+ expect(out).not.toContain("does not look like an HQ root");
167
+ });
168
+
169
+ it("admits a core-only root (companies/ + core/, no .claude/ or personal/)", () => {
170
+ const root = makeRoot("coreonly", ["companies", "core"]);
171
+ const r = rescueDry(root);
172
+ const out = `${r.stdout}\n${r.stderr}`;
173
+ expect(r.status, out).not.toBe(3);
174
+ expect(out).not.toContain("does not look like an HQ root");
175
+ });
176
+
177
+ it("still rejects a non-HQ directory (companies/ only — no scaffold marker)", () => {
178
+ const root = makeRoot("bare", ["companies"]);
179
+ const r = rescueDry(root);
180
+ const out = `${r.stdout}\n${r.stderr}`;
181
+ expect(r.status, out).toBe(3);
182
+ expect(out).toContain("does not look like an HQ root");
183
+ expect(out).toContain("need companies/ plus one of .claude/, core/, or personal/");
184
+ });
185
+
186
+ it("still rejects a directory with a marker but no companies/", () => {
187
+ const root = makeRoot("nocompanies", [".claude", "core", "personal"]);
188
+ const r = rescueDry(root);
189
+ const out = `${r.stdout}\n${r.stderr}`;
190
+ expect(r.status, out).toBe(3);
191
+ expect(out).toContain("does not look like an HQ root");
192
+ });
193
+ });