@indigoai-us/hq-cloud 6.11.6 → 6.11.7

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 (46) hide show
  1. package/dist/bin/sync-runner.d.ts.map +1 -1
  2. package/dist/bin/sync-runner.js +1 -0
  3. package/dist/bin/sync-runner.js.map +1 -1
  4. package/dist/bin/sync-runner.test.js +1 -0
  5. package/dist/bin/sync-runner.test.js.map +1 -1
  6. package/dist/cli/share.d.ts +23 -0
  7. package/dist/cli/share.d.ts.map +1 -1
  8. package/dist/cli/share.js +54 -0
  9. package/dist/cli/share.js.map +1 -1
  10. package/dist/cli/share.test.js +142 -0
  11. package/dist/cli/share.test.js.map +1 -1
  12. package/dist/cli/sync.d.ts +16 -0
  13. package/dist/cli/sync.d.ts.map +1 -1
  14. package/dist/cli/sync.js +1 -62
  15. package/dist/cli/sync.js.map +1 -1
  16. package/dist/cli/tombstones.d.ts +43 -0
  17. package/dist/cli/tombstones.d.ts.map +1 -0
  18. package/dist/cli/tombstones.js +78 -0
  19. package/dist/cli/tombstones.js.map +1 -0
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/personal-vault.d.ts +36 -0
  25. package/dist/personal-vault.d.ts.map +1 -1
  26. package/dist/personal-vault.js +89 -1
  27. package/dist/personal-vault.js.map +1 -1
  28. package/dist/personal-vault.test.js +143 -1
  29. package/dist/personal-vault.test.js.map +1 -1
  30. package/dist/watcher.d.ts.map +1 -1
  31. package/dist/watcher.js +22 -1
  32. package/dist/watcher.js.map +1 -1
  33. package/dist/watcher.test.js +29 -0
  34. package/dist/watcher.test.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/bin/sync-runner.test.ts +1 -0
  37. package/src/bin/sync-runner.ts +1 -0
  38. package/src/cli/share.test.ts +169 -0
  39. package/src/cli/share.ts +81 -0
  40. package/src/cli/sync.ts +21 -88
  41. package/src/cli/tombstones.ts +106 -0
  42. package/src/index.ts +2 -0
  43. package/src/personal-vault.test.ts +175 -0
  44. package/src/personal-vault.ts +86 -1
  45. package/src/watcher.test.ts +41 -0
  46. package/src/watcher.ts +24 -1
@@ -16,8 +16,10 @@ import * as fs from "fs";
16
16
  import * as os from "os";
17
17
  import * as path from "path";
18
18
  import {
19
+ computeContinuityPointerPaths,
19
20
  computePersonalCompanySubdirs,
20
21
  computePersonalVaultPaths,
22
+ CONTINUITY_POINTER_REL,
21
23
  PERSONAL_VAULT_COMPANY_EXCLUDED_SLUGS,
22
24
  PERSONAL_VAULT_EXCLUDED_TOP_LEVEL,
23
25
  } from "./personal-vault.js";
@@ -347,3 +349,176 @@ describe("personal-vault helpers", () => {
347
349
  ).toBe(false);
348
350
  });
349
351
  });
352
+
353
+ // ─────────────────────────────────────────────────────────────────────────
354
+ // DEV-1778 — session-continuity pointer carve-out.
355
+ //
356
+ // `workspace/` is in PERSONAL_VAULT_EXCLUDED_TOP_LEVEL (machine-local by
357
+ // design). The ONE exception is the session pointer
358
+ // `workspace/threads/handoff.json` + the single thread file it references via
359
+ // `thread_path`, so a `/handoff` on one machine reaches a second machine.
360
+ // Mirrors the companies/manifest.yaml special-case: a narrow file-level
361
+ // re-include that never broadens the rest of workspace/.
362
+ // ─────────────────────────────────────────────────────────────────────────
363
+ describe("personal-vault: continuity-pointer carve-out", () => {
364
+ let hqRoot: string;
365
+
366
+ beforeEach(() => {
367
+ hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-cont-test-"));
368
+ });
369
+
370
+ afterEach(() => {
371
+ fs.rmSync(hqRoot, { recursive: true, force: true });
372
+ });
373
+
374
+ /** Helper: relative-paths-sorted projection of an absolute-path list. */
375
+ function rel(abs: string[]): string[] {
376
+ return abs.map((p) => path.relative(hqRoot, p)).sort();
377
+ }
378
+
379
+ const THREADS = path.join("workspace", "threads");
380
+
381
+ /** Write workspace/threads/handoff.json with the given object. */
382
+ function writeHandoff(obj: unknown, raw?: string): void {
383
+ const dir = path.join(hqRoot, THREADS);
384
+ fs.mkdirSync(dir, { recursive: true });
385
+ fs.writeFileSync(
386
+ path.join(dir, "handoff.json"),
387
+ raw !== undefined ? raw : JSON.stringify(obj),
388
+ );
389
+ }
390
+
391
+ /** Write a thread file under workspace/threads/ and return its rel path. */
392
+ function writeThread(name: string): string {
393
+ const dir = path.join(hqRoot, THREADS);
394
+ fs.mkdirSync(dir, { recursive: true });
395
+ fs.writeFileSync(path.join(dir, name), "{}");
396
+ return path.join(THREADS, name).split(path.sep).join("/");
397
+ }
398
+
399
+ it("constant: CONTINUITY_POINTER_REL is the canonical pointer path", () => {
400
+ expect(CONTINUITY_POINTER_REL).toBe("workspace/threads/handoff.json");
401
+ });
402
+
403
+ it("includes handoff.json + the thread file it points to", () => {
404
+ const threadRel = writeThread("T-20260617-1200-demo.json");
405
+ writeHandoff({ thread_path: threadRel });
406
+
407
+ expect(rel(computeContinuityPointerPaths(hqRoot))).toEqual(
408
+ [
409
+ path.join(THREADS, "T-20260617-1200-demo.json"),
410
+ path.join(THREADS, "handoff.json"),
411
+ ].sort(),
412
+ );
413
+ });
414
+
415
+ it("carve-out composes into computePersonalVaultPaths", () => {
416
+ const threadRel = writeThread("T-20260617-1200-demo.json");
417
+ writeHandoff({ thread_path: threadRel });
418
+ fs.mkdirSync(path.join(hqRoot, ".claude"));
419
+
420
+ const out = rel(computePersonalVaultPaths(hqRoot));
421
+ expect(out).toContain(path.join(THREADS, "handoff.json"));
422
+ expect(out).toContain(path.join(THREADS, "T-20260617-1200-demo.json"));
423
+ // Sibling top-level dirs still travel; the rest of workspace/ does not.
424
+ expect(out).toContain(".claude");
425
+ });
426
+
427
+ it("does NOT broaden the rest of workspace/ (siblings/other threads stay local)", () => {
428
+ const threadRel = writeThread("T-active.json");
429
+ writeThread("T-old.json"); // an inactive thread — must NOT sync
430
+ writeHandoff({ thread_path: threadRel });
431
+ // Unrelated workspace litter that must stay machine-local.
432
+ fs.mkdirSync(path.join(hqRoot, "workspace", "locks"), { recursive: true });
433
+ fs.writeFileSync(path.join(hqRoot, "workspace", "locks", "x.lock"), "1");
434
+ fs.writeFileSync(path.join(hqRoot, "workspace", "threads", "INDEX.md"), "#");
435
+
436
+ const out = rel(computePersonalVaultPaths(hqRoot));
437
+ expect(out).toContain(path.join(THREADS, "handoff.json"));
438
+ expect(out).toContain(path.join(THREADS, "T-active.json"));
439
+ expect(out).not.toContain(path.join(THREADS, "T-old.json"));
440
+ expect(out).not.toContain(path.join(THREADS, "INDEX.md"));
441
+ expect(out.some((p) => p.startsWith(path.join("workspace", "locks")))).toBe(
442
+ false,
443
+ );
444
+ });
445
+
446
+ it("handoff.json present but thread_path absent → only the pointer", () => {
447
+ writeHandoff({ message: "no pointer field" });
448
+ expect(rel(computeContinuityPointerPaths(hqRoot))).toEqual([
449
+ path.join(THREADS, "handoff.json"),
450
+ ]);
451
+ });
452
+
453
+ it("handoff.json points to a non-existent thread file → only the pointer", () => {
454
+ writeHandoff({ thread_path: "workspace/threads/T-ghost.json" });
455
+ expect(rel(computeContinuityPointerPaths(hqRoot))).toEqual([
456
+ path.join(THREADS, "handoff.json"),
457
+ ]);
458
+ });
459
+
460
+ it("no handoff.json at all → empty (nothing to carry)", () => {
461
+ fs.mkdirSync(path.join(hqRoot, THREADS), { recursive: true });
462
+ expect(computeContinuityPointerPaths(hqRoot)).toEqual([]);
463
+ });
464
+
465
+ it("malformed handoff.json → fail-soft to pointer only (no throw)", () => {
466
+ writeHandoff(null, "{ this is : not json ]");
467
+ expect(() => computeContinuityPointerPaths(hqRoot)).not.toThrow();
468
+ expect(rel(computeContinuityPointerPaths(hqRoot))).toEqual([
469
+ path.join(THREADS, "handoff.json"),
470
+ ]);
471
+ });
472
+
473
+ // ── Security: thread_path must never escape workspace/threads/ ──────────
474
+ it("rejects an absolute thread_path (only the pointer is included)", () => {
475
+ // Put a real file at the absolute target so the ONLY thing rejecting it
476
+ // is the containment guard, not a missing-file fallthrough.
477
+ const evil = path.join(hqRoot, "secret.txt");
478
+ fs.writeFileSync(evil, "top secret");
479
+ writeHandoff({ thread_path: evil });
480
+ expect(rel(computeContinuityPointerPaths(hqRoot))).toEqual([
481
+ path.join(THREADS, "handoff.json"),
482
+ ]);
483
+ });
484
+
485
+ it("rejects a traversal thread_path (../../.env) — no smuggling out of threads/", () => {
486
+ fs.writeFileSync(path.join(hqRoot, ".env"), "SECRET=1");
487
+ writeHandoff({ thread_path: "workspace/threads/../../.env" });
488
+ const out = rel(computeContinuityPointerPaths(hqRoot));
489
+ expect(out).toEqual([path.join(THREADS, "handoff.json")]);
490
+ expect(out.some((p) => p.endsWith(".env"))).toBe(false);
491
+ });
492
+
493
+ it("rejects a thread_path under workspace/ but outside threads/", () => {
494
+ fs.mkdirSync(path.join(hqRoot, "workspace", "reports"), {
495
+ recursive: true,
496
+ });
497
+ fs.writeFileSync(
498
+ path.join(hqRoot, "workspace", "reports", "secret.md"),
499
+ "x",
500
+ );
501
+ writeHandoff({ thread_path: "workspace/reports/secret.md" });
502
+ expect(rel(computeContinuityPointerPaths(hqRoot))).toEqual([
503
+ path.join(THREADS, "handoff.json"),
504
+ ]);
505
+ });
506
+
507
+ it("rejects a symlink that escapes threads/ (realpath containment)", () => {
508
+ // A thread_path that names an in-threads symlink whose target is OUTSIDE
509
+ // threads/ must be rejected by the realpath re-check.
510
+ fs.writeFileSync(path.join(hqRoot, "outside.txt"), "secret");
511
+ fs.mkdirSync(path.join(hqRoot, THREADS), { recursive: true });
512
+ const link = path.join(hqRoot, THREADS, "escape.json");
513
+ try {
514
+ fs.symlinkSync(path.join(hqRoot, "outside.txt"), link);
515
+ } catch {
516
+ // Platform without symlink support — skip the assertion gracefully.
517
+ return;
518
+ }
519
+ writeHandoff({ thread_path: "workspace/threads/escape.json" });
520
+ expect(rel(computeContinuityPointerPaths(hqRoot))).toEqual([
521
+ path.join(THREADS, "handoff.json"),
522
+ ]);
523
+ });
524
+ });
@@ -125,7 +125,92 @@ export function computePersonalVaultPaths(
125
125
  const companySubdirs = opts.includeLocalCompanies === true
126
126
  ? computePersonalCompanySubdirs(hqRoot, opts.teamSyncedSlugs)
127
127
  : [];
128
- return [...topLevel, ...manifest, ...companySubdirs];
128
+ const continuity = computeContinuityPointerPaths(hqRoot);
129
+ return [...topLevel, ...manifest, ...companySubdirs, ...continuity];
130
+ }
131
+
132
+ /**
133
+ * Fixed relative path (forward-slash, hq-root-relative) of the session
134
+ * continuity pointer. The continuity pointer is the ONE file under the
135
+ * otherwise machine-local `workspace/` that must travel across machines:
136
+ * `/handoff` writes it on machine A so a fresh session on machine B can
137
+ * resume via `/startwork`. See {@link computeContinuityPointerPaths}.
138
+ */
139
+ export const CONTINUITY_POINTER_REL = "workspace/threads/handoff.json";
140
+
141
+ /**
142
+ * Compute the absolute paths of the session-continuity pointer carve-out:
143
+ * `workspace/threads/handoff.json` plus the single thread file it points to.
144
+ *
145
+ * `workspace/` is in {@link PERSONAL_VAULT_EXCLUDED_TOP_LEVEL} — it is
146
+ * machine-local by design (session scratch, locks, reports, the full thread
147
+ * history). The continuity pointer is the one exception: without it a
148
+ * `/handoff` on one machine never reaches a second machine, so the session
149
+ * pointer doesn't follow the user even though the durable output already
150
+ * syncs. This carve-out pierces the exclusion for EXACTLY two files and
151
+ * nothing else, mirroring the `companies/manifest.yaml` special-case above
152
+ * (a single file pushed back in despite its parent dir being excluded).
153
+ *
154
+ * The "active thread file" is not a fixed name — it is resolved from
155
+ * `handoff.json.thread_path` (the pointer the finalize script writes). We
156
+ * read + parse `handoff.json`, then include `thread_path` ONLY when it is a
157
+ * relative path that resolves to an existing regular file strictly within
158
+ * `<hqRoot>/workspace/threads/`. This containment check is a hard security
159
+ * boundary: a malformed or tampered `handoff.json` must never be able to
160
+ * smuggle an arbitrary file (e.g. `../../.env`, an absolute path, or a
161
+ * symlink escaping the threads dir) into the personal vault.
162
+ *
163
+ * Fail-soft throughout: a missing/unreadable/malformed `handoff.json`, or an
164
+ * out-of-bounds / missing `thread_path`, silently degrades to "include
165
+ * whatever is valid" (handoff.json alone, or `[]`). Callers tolerate empty
166
+ * arrays — same contract as the manifest special-case.
167
+ */
168
+ export function computeContinuityPointerPaths(hqRoot: string): string[] {
169
+ const out: string[] = [];
170
+ const threadsDir = path.join(hqRoot, "workspace", "threads");
171
+ const handoffPath = path.join(threadsDir, "handoff.json");
172
+
173
+ let raw: string;
174
+ try {
175
+ if (!fs.statSync(handoffPath).isFile()) return out;
176
+ raw = fs.readFileSync(handoffPath, "utf8");
177
+ } catch {
178
+ // No pointer on this machine yet (or unreadable) — nothing to carry.
179
+ return out;
180
+ }
181
+ out.push(handoffPath);
182
+
183
+ // Resolve the active thread file from the pointer's `thread_path`. Any
184
+ // failure leaves the pointer itself in `out` and skips the thread body —
185
+ // the next handoff (or a peer's push) re-converges it.
186
+ let threadPath: unknown;
187
+ try {
188
+ threadPath = (JSON.parse(raw) as { thread_path?: unknown })?.thread_path;
189
+ } catch {
190
+ return out;
191
+ }
192
+ if (typeof threadPath !== "string" || threadPath.length === 0) return out;
193
+
194
+ // Containment: the resolved file must live strictly inside the threads dir.
195
+ // Reject absolute paths, traversal, and symlink escapes. thread_path is
196
+ // hq-root-relative as written by handoff-finalize.sh, so resolve against
197
+ // hqRoot and re-check the realpath is still under the threads dir.
198
+ if (path.isAbsolute(threadPath)) return out;
199
+ const resolvedThreads = path.resolve(threadsDir);
200
+ const candidate = path.resolve(hqRoot, threadPath);
201
+ const withinThreads = (p: string): boolean =>
202
+ p === resolvedThreads || p.startsWith(resolvedThreads + path.sep);
203
+ if (!withinThreads(candidate)) return out;
204
+ try {
205
+ const real = fs.realpathSync(candidate);
206
+ if (!withinThreads(real)) return out;
207
+ if (!fs.statSync(real).isFile()) return out;
208
+ } catch {
209
+ // Pointer references a thread file that doesn't exist here — skip it.
210
+ return out;
211
+ }
212
+ out.push(candidate);
213
+ return out;
129
214
  }
130
215
 
131
216
  /**
@@ -263,6 +263,47 @@ describe("US-002: createWatchPathFilter — personal-vault exclusions", () => {
263
263
  expect(personal(path.join(ROOT, "repos/private/foo/file.ts"))).toBe(false);
264
264
  });
265
265
 
266
+ // ── DEV-1778 continuity-pointer carve-out ───────────────────────────────
267
+ it("DOES emit for workspace/threads/handoff.json (the continuity pointer)", () => {
268
+ expect(personal(path.join(ROOT, "workspace/threads/handoff.json"))).toBe(
269
+ true,
270
+ );
271
+ });
272
+
273
+ it("still does NOT emit for other workspace/ files (carve-out is pointer-only)", () => {
274
+ // The pointer is the ONLY re-include — sibling threads, the journal, and
275
+ // unrelated workspace litter stay machine-local.
276
+ expect(personal(path.join(ROOT, "workspace/threads/T-old.json"))).toBe(
277
+ false,
278
+ );
279
+ expect(personal(path.join(ROOT, "workspace/threads/INDEX.md"))).toBe(false);
280
+ expect(personal(path.join(ROOT, "workspace/locks/x.lock"))).toBe(false);
281
+ // The pointer queried as a directory is not an emit target either.
282
+ expect(
283
+ personal(path.join(ROOT, "workspace/threads/handoff.json"), true),
284
+ ).toBe(false);
285
+ });
286
+
287
+ it("allows chokidar to DESCEND through the pointer's ancestor dirs (dir probe)", () => {
288
+ // Without this the Linux chokidar backend prunes `workspace/` before ever
289
+ // reaching the leaf. Ancestor dirs return true ONLY when queried as dirs.
290
+ expect(personal(path.join(ROOT, "workspace"), true)).toBe(true);
291
+ expect(personal(path.join(ROOT, "workspace/threads"), true)).toBe(true);
292
+ // A non-ancestor dir under workspace/ is still pruned.
293
+ expect(personal(path.join(ROOT, "workspace/locks"), true)).toBe(false);
294
+ // Ancestor dirs queried as FILES are not emit targets.
295
+ expect(personal(path.join(ROOT, "workspace"), false)).toBe(false);
296
+ });
297
+
298
+ it("does not apply the carve-out in non-personal mode", () => {
299
+ // In non-personal mode the excluded-top-level layer never runs, so the
300
+ // carve-out is irrelevant; handoff.json passes via the ordinary ignore
301
+ // stack like any other tracked file (no special-casing needed).
302
+ expect(
303
+ nonPersonal(path.join(ROOT, "workspace/threads/handoff.json")),
304
+ ).toBe(true);
305
+ });
306
+
266
307
  it("DOES emit for an included top-level personal path in personalMode", () => {
267
308
  expect(personal(path.join(ROOT, "personal/notes.md"))).toBe(true);
268
309
  expect(personal(path.join(ROOT, "core/policies/x.md"))).toBe(true);
package/src/watcher.ts CHANGED
@@ -19,7 +19,10 @@ import { readJournal, writeJournal, hashFile, updateEntry } from "./journal.js";
19
19
  import { uploadFile, deleteRemoteFile, toPosixKey } from "./s3.js";
20
20
  import type { UploadAuthor } from "./s3.js";
21
21
  import { isPersonalVaultExcluded } from "./personal-vault-exclusions.js";
22
- import { PERSONAL_VAULT_EXCLUDED_TOP_LEVEL } from "./personal-vault.js";
22
+ import {
23
+ CONTINUITY_POINTER_REL,
24
+ PERSONAL_VAULT_EXCLUDED_TOP_LEVEL,
25
+ } from "./personal-vault.js";
23
26
  import type { PushEvent } from "./sync/push-event.js";
24
27
  import type { PushTransport } from "./sync/push-transport.js";
25
28
  import type { EventDrivenPushFlagProvider } from "./sync/feature-flags.js";
@@ -498,6 +501,26 @@ export function createWatchPathFilter(
498
501
  if (!ignoreFilter(absolutePath, isDir)) return false;
499
502
 
500
503
  if (personalMode) {
504
+ // Continuity-pointer carve-out: the session pointer lives under the
505
+ // otherwise-excluded `workspace/`, so it must re-include BEFORE the
506
+ // top-level bucket rejection below. Two cases:
507
+ // (a) the pointer FILE itself → emit. Waking on the pointer is
508
+ // sufficient: `/handoff` rewrites handoff.json AFTER its
509
+ // (immutable) thread file, and the resulting personal push
510
+ // re-enumerates via computePersonalVaultPaths, which sweeps the
511
+ // pointer + its active thread file together.
512
+ // (b) an ANCESTOR DIR of the pointer (`workspace`, `workspace/threads`)
513
+ // queried as a directory → allow descent. The Linux chokidar
514
+ // backend prunes a subtree the instant its dir probe says "ignore",
515
+ // so without this it would prune `workspace/` before ever reaching
516
+ // the leaf (the same ancestor-descent subtlety `.hqinclude` handles
517
+ // via its ancestor matcher). The native recursive backend ignores
518
+ // descent decisions and re-checks each path at event time, so this
519
+ // only matters for chokidar but is harmless everywhere.
520
+ // See computeContinuityPointerPaths for the matching push-side carve-out.
521
+ if (rel === CONTINUITY_POINTER_REL && !isDir) return true;
522
+ if (isDir && CONTINUITY_POINTER_REL.startsWith(rel + "/")) return true;
523
+
501
524
  // Layer 3: excluded top-level buckets (.git/companies/repos/workspace).
502
525
  const topLevel = rel.split("/")[0];
503
526
  if (excludedTopLevel.has(topLevel)) return false;