@indigoai-us/hq-cloud 6.5.0 → 6.7.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 (52) hide show
  1. package/dist/bin/sync-runner.d.ts +4 -35
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +14 -104
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +19 -0
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/sync-scope.test.js +67 -0
  8. package/dist/cli/sync-scope.test.js.map +1 -1
  9. package/dist/cli/sync.d.ts +19 -0
  10. package/dist/cli/sync.d.ts.map +1 -1
  11. package/dist/cli/sync.js +62 -19
  12. package/dist/cli/sync.js.map +1 -1
  13. package/dist/cognito-auth.d.ts +27 -0
  14. package/dist/cognito-auth.d.ts.map +1 -1
  15. package/dist/cognito-auth.js +97 -0
  16. package/dist/cognito-auth.js.map +1 -1
  17. package/dist/index.d.ts +6 -4
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +8 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/machine-auth.test.d.ts +14 -0
  22. package/dist/machine-auth.test.d.ts.map +1 -0
  23. package/dist/machine-auth.test.js +216 -0
  24. package/dist/machine-auth.test.js.map +1 -0
  25. package/dist/s3.d.ts.map +1 -1
  26. package/dist/s3.js +15 -5
  27. package/dist/s3.js.map +1 -1
  28. package/dist/s3.test.js +71 -2
  29. package/dist/s3.test.js.map +1 -1
  30. package/dist/scope-shrink.d.ts +70 -7
  31. package/dist/scope-shrink.d.ts.map +1 -1
  32. package/dist/scope-shrink.js +102 -23
  33. package/dist/scope-shrink.js.map +1 -1
  34. package/dist/scope-shrink.test.js +63 -0
  35. package/dist/scope-shrink.test.js.map +1 -1
  36. package/dist/sync/pull-scope.d.ts +50 -0
  37. package/dist/sync/pull-scope.d.ts.map +1 -0
  38. package/dist/sync/pull-scope.js +129 -0
  39. package/dist/sync/pull-scope.js.map +1 -0
  40. package/package.json +2 -2
  41. package/src/bin/sync-runner.test.ts +23 -0
  42. package/src/bin/sync-runner.ts +19 -116
  43. package/src/cli/sync-scope.test.ts +84 -0
  44. package/src/cli/sync.ts +90 -17
  45. package/src/cognito-auth.ts +159 -0
  46. package/src/index.ts +21 -1
  47. package/src/machine-auth.test.ts +279 -0
  48. package/src/s3.test.ts +91 -1
  49. package/src/s3.ts +15 -5
  50. package/src/scope-shrink.test.ts +71 -0
  51. package/src/scope-shrink.ts +164 -20
  52. package/src/sync/pull-scope.ts +161 -0
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Unit tests for the machine-identity auth mode in cognito-auth.ts.
3
+ *
4
+ * Machine identities (company agents) carry long-lived Cognito creds at
5
+ * ~/.hq-agent/machine-creds.json (HQ_MACHINE_CREDS_FILE override) and mint
6
+ * sessions via USER_PASSWORD_AUTH on demand — no browser, no refresh token.
7
+ * The contract under test:
8
+ * - detection: creds file present + well-formed → machine mode
9
+ * - minting: BOTH tokens cached with correct field semantics
10
+ * - re-mint on expiry; cache hit when valid
11
+ * - getValidAccessToken short-circuits into machine mode (never browser)
12
+ */
13
+
14
+ import * as fs from "fs";
15
+ import * as os from "os";
16
+ import * as path from "path";
17
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
18
+
19
+ let originalHome: string | undefined;
20
+ let originalCredsEnv: string | undefined;
21
+ let tmpHome: string;
22
+
23
+ beforeEach(() => {
24
+ originalHome = process.env.HOME;
25
+ originalCredsEnv = process.env.HQ_MACHINE_CREDS_FILE;
26
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "hq-machine-auth-test-"));
27
+ process.env.HOME = tmpHome;
28
+ delete process.env.HQ_MACHINE_CREDS_FILE;
29
+ vi.resetModules();
30
+ });
31
+
32
+ afterEach(() => {
33
+ if (originalHome === undefined) delete process.env.HOME;
34
+ else process.env.HOME = originalHome;
35
+ if (originalCredsEnv === undefined) delete process.env.HQ_MACHINE_CREDS_FILE;
36
+ else process.env.HQ_MACHINE_CREDS_FILE = originalCredsEnv;
37
+ fs.rmSync(tmpHome, { recursive: true, force: true });
38
+ vi.unstubAllGlobals();
39
+ vi.restoreAllMocks();
40
+ });
41
+
42
+ async function importModule() {
43
+ return await import("./cognito-auth.js");
44
+ }
45
+
46
+ const CONFIG = {
47
+ region: "us-east-1",
48
+ userPoolDomain: "vault-indigo-hq-prod",
49
+ clientId: "test-client-id",
50
+ };
51
+
52
+ function writeCreds(
53
+ creds: unknown = { username: "machine-agt_01TEST", secret: "s3cret" },
54
+ ): string {
55
+ const dir = path.join(tmpHome, ".hq-agent");
56
+ fs.mkdirSync(dir, { recursive: true });
57
+ const file = path.join(dir, "machine-creds.json");
58
+ fs.writeFileSync(file, JSON.stringify(creds));
59
+ return file;
60
+ }
61
+
62
+ /** Build a fake JWT whose payload decodes to the given claims. */
63
+ function fakeJwt(claims: Record<string, unknown>): string {
64
+ const enc = (o: unknown) =>
65
+ Buffer.from(JSON.stringify(o)).toString("base64url");
66
+ return `${enc({ alg: "RS256", kid: "k" })}.${enc(claims)}.sig`;
67
+ }
68
+
69
+ function stubMintFetch(
70
+ overrides: Partial<{
71
+ AccessToken: string;
72
+ IdToken: string;
73
+ RefreshToken: string;
74
+ ExpiresIn: number;
75
+ }> = {},
76
+ ) {
77
+ const calls: Array<{ url: string; init: RequestInit }> = [];
78
+ const fetchMock = vi.fn(async (url: string | URL, init?: RequestInit) => {
79
+ calls.push({ url: String(url), init: init ?? {} });
80
+ return new Response(
81
+ JSON.stringify({
82
+ AuthenticationResult: {
83
+ AccessToken:
84
+ overrides.AccessToken ??
85
+ fakeJwt({ token_use: "access", client_id: CONFIG.clientId }),
86
+ IdToken: overrides.IdToken ?? fakeJwt({ token_use: "id" }),
87
+ RefreshToken: overrides.RefreshToken,
88
+ ExpiresIn: overrides.ExpiresIn ?? 3600,
89
+ },
90
+ }),
91
+ { status: 200 },
92
+ );
93
+ });
94
+ vi.stubGlobal("fetch", fetchMock);
95
+ return { fetchMock, calls };
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Detection
100
+ // ---------------------------------------------------------------------------
101
+
102
+ describe("machine identity detection", () => {
103
+ it("is off when no creds file exists", async () => {
104
+ const { isMachineIdentity, loadMachineCreds } = await importModule();
105
+ expect(loadMachineCreds()).toBeNull();
106
+ expect(isMachineIdentity()).toBe(false);
107
+ });
108
+
109
+ it("detects creds at the default ~/.hq-agent path", async () => {
110
+ writeCreds();
111
+ const { isMachineIdentity, loadMachineCreds } = await importModule();
112
+ expect(loadMachineCreds()).toEqual({
113
+ username: "machine-agt_01TEST",
114
+ secret: "s3cret",
115
+ });
116
+ expect(isMachineIdentity()).toBe(true);
117
+ });
118
+
119
+ it("honors HQ_MACHINE_CREDS_FILE override", async () => {
120
+ const custom = path.join(tmpHome, "elsewhere.json");
121
+ fs.writeFileSync(
122
+ custom,
123
+ JSON.stringify({ username: "machine-agt_X", secret: "y" }),
124
+ );
125
+ process.env.HQ_MACHINE_CREDS_FILE = custom;
126
+ const { loadMachineCreds, machineCredsFilePath } = await importModule();
127
+ expect(machineCredsFilePath()).toBe(custom);
128
+ expect(loadMachineCreds()?.username).toBe("machine-agt_X");
129
+ });
130
+
131
+ it("rejects malformed creds (wrong username prefix, missing secret, bad JSON)", async () => {
132
+ const { loadMachineCreds } = await importModule();
133
+ writeCreds({ username: "stefan@example.com", secret: "x" });
134
+ expect(loadMachineCreds()).toBeNull();
135
+ writeCreds({ username: "machine-agt_01TEST" });
136
+ expect(loadMachineCreds()).toBeNull();
137
+ fs.writeFileSync(path.join(tmpHome, ".hq-agent", "machine-creds.json"), "{nope");
138
+ expect(loadMachineCreds()).toBeNull();
139
+ });
140
+ });
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Minting
144
+ // ---------------------------------------------------------------------------
145
+
146
+ describe("mintMachineTokens", () => {
147
+ it("mints via USER_PASSWORD_AUTH and caches both tokens with correct fields", async () => {
148
+ writeCreds();
149
+ const { calls } = stubMintFetch({ RefreshToken: "rt" });
150
+ const { mintMachineTokens, loadCachedTokens } = await importModule();
151
+
152
+ const tokens = await mintMachineTokens(CONFIG);
153
+
154
+ // Request shape: Cognito IDP InitiateAuth with the machine creds.
155
+ expect(calls).toHaveLength(1);
156
+ expect(calls[0].url).toBe("https://cognito-idp.us-east-1.amazonaws.com/");
157
+ const body = JSON.parse(String(calls[0].init.body));
158
+ expect(body).toMatchObject({
159
+ AuthFlow: "USER_PASSWORD_AUTH",
160
+ ClientId: CONFIG.clientId,
161
+ AuthParameters: { USERNAME: "machine-agt_01TEST", PASSWORD: "s3cret" },
162
+ });
163
+
164
+ // Field semantics: access token in accessToken, id token in idToken.
165
+ const accessClaims = JSON.parse(
166
+ Buffer.from(tokens.accessToken.split(".")[1], "base64url").toString(),
167
+ );
168
+ const idClaims = JSON.parse(
169
+ Buffer.from(tokens.idToken.split(".")[1], "base64url").toString(),
170
+ );
171
+ expect(accessClaims.token_use).toBe("access");
172
+ expect(idClaims.token_use).toBe("id");
173
+ expect(tokens.refreshToken).toBe("rt");
174
+ expect(typeof tokens.expiresAt).toBe("number");
175
+
176
+ // Persisted to the shared cache file.
177
+ expect(loadCachedTokens()).toEqual(tokens);
178
+ });
179
+
180
+ it("throws CognitoAuthError on auth failure", async () => {
181
+ writeCreds();
182
+ vi.stubGlobal(
183
+ "fetch",
184
+ vi.fn(async () =>
185
+ new Response(
186
+ JSON.stringify({ __type: "NotAuthorizedException", message: "nope" }),
187
+ { status: 400 },
188
+ ),
189
+ ),
190
+ );
191
+ const { mintMachineTokens, CognitoAuthError } = await importModule();
192
+ await expect(mintMachineTokens(CONFIG)).rejects.toBeInstanceOf(
193
+ CognitoAuthError,
194
+ );
195
+ });
196
+
197
+ it("throws when no creds are present", async () => {
198
+ const { mintMachineTokens, CognitoAuthError } = await importModule();
199
+ await expect(mintMachineTokens(CONFIG)).rejects.toBeInstanceOf(
200
+ CognitoAuthError,
201
+ );
202
+ });
203
+ });
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // getValidMachineTokens — cache vs re-mint
207
+ // ---------------------------------------------------------------------------
208
+
209
+ describe("getValidMachineTokens", () => {
210
+ it("returns the cache when valid without touching the network", async () => {
211
+ writeCreds();
212
+ const { fetchMock } = stubMintFetch();
213
+ const { saveCachedTokens, getValidMachineTokens } = await importModule();
214
+ const cached = {
215
+ accessToken: fakeJwt({ token_use: "access", client_id: CONFIG.clientId }),
216
+ idToken: fakeJwt({ token_use: "id" }),
217
+ refreshToken: "",
218
+ expiresAt: Date.now() + 30 * 60 * 1000,
219
+ tokenType: "Bearer" as const,
220
+ };
221
+ saveCachedTokens(cached);
222
+
223
+ const tokens = await getValidMachineTokens(CONFIG);
224
+ expect(tokens).toEqual(cached);
225
+ expect(fetchMock).not.toHaveBeenCalled();
226
+ });
227
+
228
+ it("re-mints when the cache is expiring", async () => {
229
+ writeCreds();
230
+ const { fetchMock } = stubMintFetch();
231
+ const { saveCachedTokens, getValidMachineTokens } = await importModule();
232
+ saveCachedTokens({
233
+ accessToken: fakeJwt({ token_use: "access", client_id: CONFIG.clientId }),
234
+ idToken: fakeJwt({ token_use: "id" }),
235
+ refreshToken: "",
236
+ expiresAt: Date.now() + 10 * 1000,
237
+ tokenType: "Bearer",
238
+ });
239
+
240
+ await getValidMachineTokens(CONFIG);
241
+ expect(fetchMock).toHaveBeenCalledTimes(1);
242
+ });
243
+
244
+ it("re-mints when the cached token targets a different app client", async () => {
245
+ writeCreds();
246
+ const { fetchMock } = stubMintFetch();
247
+ const { saveCachedTokens, getValidMachineTokens } = await importModule();
248
+ saveCachedTokens({
249
+ accessToken: fakeJwt({ token_use: "access", client_id: "other-client" }),
250
+ idToken: fakeJwt({ token_use: "id" }),
251
+ refreshToken: "",
252
+ expiresAt: Date.now() + 30 * 60 * 1000,
253
+ tokenType: "Bearer",
254
+ });
255
+
256
+ await getValidMachineTokens(CONFIG);
257
+ expect(fetchMock).toHaveBeenCalledTimes(1);
258
+ });
259
+ });
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // getValidAccessToken — machine-mode short circuit
263
+ // ---------------------------------------------------------------------------
264
+
265
+ describe("getValidAccessToken in machine mode", () => {
266
+ it("mints via machine creds instead of refreshing or opening a browser", async () => {
267
+ writeCreds();
268
+ const { calls } = stubMintFetch();
269
+ const { getValidAccessToken } = await importModule();
270
+
271
+ const token = await getValidAccessToken(CONFIG, { interactive: false });
272
+
273
+ expect(calls).toHaveLength(1);
274
+ const claims = JSON.parse(
275
+ Buffer.from(token.split(".")[1], "base64url").toString(),
276
+ );
277
+ expect(claims.token_use).toBe("access");
278
+ });
279
+ });
package/src/s3.test.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * `Metadata`, so the listing's HEAD fan-out had nothing to attribute.
7
7
  */
8
8
 
9
- import { describe, it, expect, beforeEach, vi } from "vitest";
9
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
10
10
  import * as fs from "fs";
11
11
  import * as os from "os";
12
12
  import * as path from "path";
@@ -83,6 +83,7 @@ import {
83
83
  uploadFile,
84
84
  uploadSymlink,
85
85
  toPosixKey,
86
+ primeUploads,
86
87
  downloadFile,
87
88
  listRemoteFiles,
88
89
  SYMLINK_BODY_PREFIX,
@@ -92,6 +93,12 @@ import {
92
93
  FILE_MTIME_META_KEY,
93
94
  FILE_BTIME_META_KEY,
94
95
  } from "./s3.js";
96
+ import {
97
+ setObjectIOFactory,
98
+ presignObjectIOFactory,
99
+ type PresignTransportClient,
100
+ } from "./object-io.js";
101
+ import type { PresignResultRow } from "./vault-client.js";
95
102
  import type { EntityContext } from "./types.js";
96
103
 
97
104
  function makeCtx(): EntityContext {
@@ -158,6 +165,89 @@ describe("backslash key normalization (Windows client → POSIX S3 key)", () =>
158
165
  });
159
166
  });
160
167
 
168
+ describe("backslash key normalization on the PRESIGNED (GA) transport", () => {
169
+ // The tests above assert normalization on the S3-SDK transport (sentCommands
170
+ // capture SDK PutObjectCommands). As of 6.5.0 the presigned-URL transport is
171
+ // GA for every company vault (cmp_*) — that's the path a real Windows client
172
+ // like Ridge's now takes. These lock the SAME write-side invariant on that
173
+ // transport: a backslash key is canonicalized to POSIX before the server is
174
+ // ever asked to presign it, so a non-POSIX vault key can never be minted.
175
+ let tmpFile: string;
176
+ let presignCalls: Array<{ op?: string; keys: Array<{ key?: string }> }>;
177
+
178
+ function presignKeysFor(op: string): string[] {
179
+ return presignCalls
180
+ .filter((c) => c.op === op)
181
+ .flatMap((c) => c.keys.map((k) => k.key ?? ""));
182
+ }
183
+
184
+ beforeEach(() => {
185
+ tmpFile = path.join(
186
+ os.tmpdir(),
187
+ `s3-presign-backslash-${Date.now()}-${Math.random()}.md`,
188
+ );
189
+ fs.writeFileSync(tmpFile, "hello");
190
+
191
+ presignCalls = [];
192
+ const vault: PresignTransportClient = {
193
+ // Echo each requested key back as a usable URL so putObject's PUT
194
+ // resolves; record the op + keys so we can assert what was signed.
195
+ presign: async (input) => {
196
+ presignCalls.push({ op: input.op, keys: input.keys as Array<{ key?: string }> });
197
+ const results: PresignResultRow[] = input.keys.map((k) => ({
198
+ key: (k as { key: string }).key,
199
+ op: input.op ?? "put",
200
+ url: `https://s3.test/${(k as { key: string }).key}`,
201
+ }));
202
+ return { results, expiresAt: "2099-01-01T00:00:00.000Z" };
203
+ },
204
+ listFiles: async () => ({ objects: [], cursor: null, truncated: false }),
205
+ };
206
+ setObjectIOFactory(presignObjectIOFactory(vault));
207
+ // putObject moves bytes over the presigned URL via global fetch.
208
+ vi.stubGlobal(
209
+ "fetch",
210
+ vi.fn(async () => new Response(null, { status: 200, headers: { etag: '"e"' } })),
211
+ );
212
+ });
213
+
214
+ afterEach(() => {
215
+ setObjectIOFactory(null);
216
+ vi.unstubAllGlobals();
217
+ });
218
+
219
+ it("uploadFile presigns + PUTs a POSIX key for a Windows-style backslash path", async () => {
220
+ await uploadFile(makeCtx(), tmpFile, "knowledge\\books-eoi.md");
221
+ expect(presignKeysFor("put")).toContain("knowledge/books-eoi.md");
222
+ // The malformed backslash key is never presigned (so never PUT).
223
+ expect(presignKeysFor("put")).not.toContain("knowledge\\books-eoi.md");
224
+ });
225
+
226
+ it("uploadSymlink presigns a POSIX key for a Windows-style backslash path", async () => {
227
+ await uploadSymlink(makeCtx(), "../target.md", "data\\boots-accounts.json");
228
+ expect(presignKeysFor("put")).toContain("data/boots-accounts.json");
229
+ expect(presignKeysFor("put")).not.toContain("data\\boots-accounts.json");
230
+ });
231
+
232
+ it("primeUploads canonicalizes keys so the primed PUT URL matches the upload lookup", async () => {
233
+ // Prime then upload the SAME Windows-origin key. Both must key the cache
234
+ // under the POSIX form: prime signs the POSIX PUT URL, and uploadFile's
235
+ // hasPrimedPut(toPosixKey(...)) reuses it instead of re-presigning — proving
236
+ // the primed fast-path can't strand a backslash key (and can't store one).
237
+ await primeUploads(makeCtx(), [
238
+ { key: "knowledge\\books-eoi.md", localPath: tmpFile, isSymlink: false },
239
+ ]);
240
+ // prime("get") + prime("put") both went out under the POSIX key only.
241
+ expect(presignKeysFor("get")).toEqual(["knowledge/books-eoi.md"]);
242
+ expect(presignKeysFor("put")).toEqual(["knowledge/books-eoi.md"]);
243
+
244
+ const putCountAfterPrime = presignKeysFor("put").length;
245
+ await uploadFile(makeCtx(), tmpFile, "knowledge\\books-eoi.md");
246
+ // No NEW put presign: uploadFile reused the primed POSIX URL (cache hit).
247
+ expect(presignKeysFor("put").length).toBe(putCountAfterPrime);
248
+ });
249
+ });
250
+
161
251
  describe("uploadFile", () => {
162
252
  let tmpFile: string;
163
253
 
package/src/s3.ts CHANGED
@@ -339,9 +339,14 @@ export async function primeUploads(
339
339
  if (!io.prime || items.length === 0) return;
340
340
 
341
341
  // Prime GET first so each item's created-at HEAD reuses a cached URL.
342
+ // Canonicalize to POSIX here (one-canonical-form, matching the uploadFile /
343
+ // uploadSymlink boundary): a Windows-origin backslash key must cache under
344
+ // the SAME key uploadFile later looks up via hasPrimedPut(toPosixKey(...)),
345
+ // or the primed URL silently misses and the upload re-presigns. It also
346
+ // keeps the created-at HEAD pointed at the real (POSIX) object.
342
347
  await io.prime(
343
348
  "get",
344
- items.map((i) => ({ key: i.key })),
349
+ items.map((i) => ({ key: toPosixKey(i.key) })),
345
350
  );
346
351
 
347
352
  // Build per-key PUT metadata with the SAME builders the upload path uses,
@@ -356,10 +361,15 @@ export async function primeUploads(
356
361
  const worker = async (): Promise<void> => {
357
362
  while (next < items.length) {
358
363
  const it = items[next++];
359
- const createdAt = await resolveCreatedAt(io, it.key, it.author);
364
+ // Same boundary guardrail as uploadFile/uploadSymlink: prime under the
365
+ // canonical POSIX key so the cached PUT URL is keyed identically to the
366
+ // hasPrimedPut/putObject lookup, and a backslash key can never be primed
367
+ // (let alone stored) as a non-POSIX vault key.
368
+ const key = toPosixKey(it.key);
369
+ const createdAt = await resolveCreatedAt(io, key, it.author);
360
370
  if (it.isSymlink) {
361
371
  putKeys.push({
362
- key: it.key,
372
+ key,
363
373
  contentType: "application/octet-stream",
364
374
  metadata: {
365
375
  [SYMLINK_TARGET_META_KEY]: SYMLINK_MARKER_META_VALUE,
@@ -374,8 +384,8 @@ export async function primeUploads(
374
384
  // raced rm / EPERM — leave stamps off (receiver umask default).
375
385
  }
376
386
  putKeys.push({
377
- key: it.key,
378
- contentType: getMimeType(it.key),
387
+ key,
388
+ contentType: getMimeType(key),
379
389
  metadata: {
380
390
  ...(it.author ? buildAuthorMetadata(it.author, createdAt) : {}),
381
391
  ...modeTime,
@@ -498,6 +498,52 @@ describe("applyScopeShrink", () => {
498
498
  journal.files["companies/indigo/scratch/clean.md"]?.removedReason,
499
499
  ).toBe("narrow_apply");
500
500
  });
501
+
502
+ // DEV-1768 fix #3: clean orphans are MOVED to quarantine (recoverable),
503
+ // not unlinked, when `cleanDisposition: "quarantine"`.
504
+ it("quarantines clean orphans (moves, not deletes) and reports named paths", () => {
505
+ const rel = "companies/indigo/scratch/clean.md";
506
+ const abs = path.join(hqRoot, rel);
507
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
508
+ fs.writeFileSync(abs, "clean");
509
+ const past = Date.now() - 60_000;
510
+ fs.utimesSync(abs, past / 1000, past / 1000);
511
+
512
+ const quarantineRoot = path.join(hqRoot, ".hq", "scope-quarantine", "indigo");
513
+ const journal: SyncJournal = {
514
+ ...emptyJournal(),
515
+ files: {
516
+ [rel]: { hash: sha256("clean"), size: 5, syncedAt: new Date().toISOString(), direction: "down" },
517
+ },
518
+ };
519
+ const plan = buildScopeShrinkPlan({
520
+ journal,
521
+ hqRoot,
522
+ lastPrefixSet: ["companies/indigo/"],
523
+ currentPrefixSet: ["companies/indigo/meetings/"],
524
+ });
525
+ const result = applyScopeShrink({
526
+ journal,
527
+ plan,
528
+ hqRoot,
529
+ forceScopeShrink: false,
530
+ cleanDisposition: "quarantine",
531
+ quarantineRoot,
532
+ });
533
+
534
+ expect(result.cleanQuarantined).toBe(1);
535
+ expect(result.cleanRemoved).toBe(0);
536
+ expect(result.quarantinedPaths).toEqual([rel]);
537
+ expect(result.removedPaths).toEqual([]);
538
+ expect(result.quarantineRoot).toBe(quarantineRoot);
539
+ // Moved, not deleted: gone from the working tree, present + intact in quarantine.
540
+ expect(fs.existsSync(abs)).toBe(false);
541
+ const quarantined = path.join(quarantineRoot, rel);
542
+ expect(fs.existsSync(quarantined)).toBe(true);
543
+ expect(fs.readFileSync(quarantined, "utf-8")).toBe("clean");
544
+ // Journal entry tombstoned so it stops being re-flagged (idempotent).
545
+ expect(journal.files[rel]?.removedAt).toBeTruthy();
546
+ });
501
547
  });
502
548
 
503
549
  describe("ScopeShrinkBlockedError", () => {
@@ -527,4 +573,29 @@ describe("ScopeShrinkBlockedError", () => {
527
573
  expect(err.dirty).toHaveLength(1);
528
574
  expect(err.name).toBe("ScopeShrinkBlockedError");
529
575
  });
576
+
577
+ // DEV-1768 fix #2: advice must be FOLLOWABLE from the entry point that throws.
578
+ // The old message always said "pass --force-scope-shrink", which the menubar
579
+ // runner cannot accept.
580
+ const mk = (ctx?: "cli" | "runner" | "engine") =>
581
+ new ScopeShrinkBlockedError("cmp_x", "all", "shared", [], [], ctx);
582
+
583
+ it("runner-context advice points at a terminal command, NOT an impossible flag", () => {
584
+ const msg = mk("runner").message;
585
+ // The followable path from the menubar is a terminal command.
586
+ expect(msg).toContain("hq sync narrow --apply");
587
+ // It must NOT imply the flag is acceptable to the menubar itself.
588
+ expect(msg).toContain("menubar sync cannot take this flag");
589
+ });
590
+
591
+ it("cli-context advice offers the flag the CLI actually accepts", () => {
592
+ const msg = mk("cli").message;
593
+ expect(msg).toContain("--force-scope-shrink");
594
+ expect(msg).toContain("hq sync narrow --apply");
595
+ });
596
+
597
+ it("defaults to the engine/library advice when no context is given", () => {
598
+ expect(mk().adviceContext).toBe("engine");
599
+ expect(mk().message).toContain("hq sync narrow --apply");
600
+ });
530
601
  });