@indigoai-us/hq-cloud 6.11.8 → 6.11.10

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 (95) hide show
  1. package/dist/bin/sync-runner.d.ts.map +1 -1
  2. package/dist/bin/sync-runner.js +4 -0
  3. package/dist/bin/sync-runner.js.map +1 -1
  4. package/dist/bin/sync-runner.test.js +72 -0
  5. package/dist/bin/sync-runner.test.js.map +1 -1
  6. package/dist/cli/reindex.test.js +44 -0
  7. package/dist/cli/reindex.test.js.map +1 -1
  8. package/dist/cli/rescue-classify-ordering.test.js +25 -0
  9. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  10. package/dist/company-resolver.d.ts +77 -0
  11. package/dist/company-resolver.d.ts.map +1 -0
  12. package/dist/company-resolver.js +124 -0
  13. package/dist/company-resolver.js.map +1 -0
  14. package/dist/company-resolver.test.d.ts +7 -0
  15. package/dist/company-resolver.test.d.ts.map +1 -0
  16. package/dist/company-resolver.test.js +120 -0
  17. package/dist/company-resolver.test.js.map +1 -0
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/personal-vault.d.ts +24 -0
  22. package/dist/personal-vault.d.ts.map +1 -1
  23. package/dist/personal-vault.js +36 -1
  24. package/dist/personal-vault.js.map +1 -1
  25. package/dist/personal-vault.test.js +46 -1
  26. package/dist/personal-vault.test.js.map +1 -1
  27. package/dist/skill-telemetry.d.ts.map +1 -1
  28. package/dist/skill-telemetry.js +22 -3
  29. package/dist/skill-telemetry.js.map +1 -1
  30. package/dist/skill-telemetry.test.js +101 -1
  31. package/dist/skill-telemetry.test.js.map +1 -1
  32. package/dist/sync/event-sync.test.js +18 -0
  33. package/dist/sync/event-sync.test.js.map +1 -1
  34. package/dist/sync/feature-flags.test.js +37 -4
  35. package/dist/sync/feature-flags.test.js.map +1 -1
  36. package/dist/sync/index.d.ts +1 -1
  37. package/dist/sync/index.d.ts.map +1 -1
  38. package/dist/sync/index.js.map +1 -1
  39. package/dist/sync/logger.test.js +1 -0
  40. package/dist/sync/logger.test.js.map +1 -1
  41. package/dist/sync/metrics.test.js +1 -0
  42. package/dist/sync/metrics.test.js.map +1 -1
  43. package/dist/sync/pull-scope.d.ts +1 -0
  44. package/dist/sync/pull-scope.d.ts.map +1 -1
  45. package/dist/sync/pull-scope.js +26 -0
  46. package/dist/sync/pull-scope.js.map +1 -1
  47. package/dist/sync/push-event.d.ts +23 -11
  48. package/dist/sync/push-event.d.ts.map +1 -1
  49. package/dist/sync/push-event.js +15 -8
  50. package/dist/sync/push-event.js.map +1 -1
  51. package/dist/sync/push-event.test.js +39 -3
  52. package/dist/sync/push-event.test.js.map +1 -1
  53. package/dist/sync/push-receiver.test.js +1 -0
  54. package/dist/sync/push-receiver.test.js.map +1 -1
  55. package/dist/telemetry.d.ts +18 -1
  56. package/dist/telemetry.d.ts.map +1 -1
  57. package/dist/telemetry.js +28 -2
  58. package/dist/telemetry.js.map +1 -1
  59. package/dist/telemetry.test.js +93 -1
  60. package/dist/telemetry.test.js.map +1 -1
  61. package/dist/vault-client.d.ts +4 -2
  62. package/dist/vault-client.d.ts.map +1 -1
  63. package/dist/vault-client.js.map +1 -1
  64. package/dist/watcher.d.ts.map +1 -1
  65. package/dist/watcher.js +25 -9
  66. package/dist/watcher.js.map +1 -1
  67. package/dist/watcher.test.js +65 -1
  68. package/dist/watcher.test.js.map +1 -1
  69. package/package.json +1 -1
  70. package/src/bin/sync-runner.test.ts +90 -0
  71. package/src/bin/sync-runner.ts +4 -0
  72. package/src/cli/reindex.test.ts +53 -0
  73. package/src/cli/rescue-classify-ordering.test.ts +28 -0
  74. package/src/company-resolver.test.ts +136 -0
  75. package/src/company-resolver.ts +147 -0
  76. package/src/index.ts +1 -0
  77. package/src/personal-vault.test.ts +56 -0
  78. package/src/personal-vault.ts +36 -1
  79. package/src/skill-telemetry.test.ts +126 -1
  80. package/src/skill-telemetry.ts +26 -3
  81. package/src/sync/event-sync.test.ts +21 -0
  82. package/src/sync/feature-flags.test.ts +40 -4
  83. package/src/sync/index.ts +5 -1
  84. package/src/sync/logger.test.ts +1 -0
  85. package/src/sync/metrics.test.ts +1 -0
  86. package/src/sync/pull-scope.ts +26 -1
  87. package/src/sync/push-event.test.ts +45 -3
  88. package/src/sync/push-event.ts +28 -12
  89. package/src/sync/push-receiver.test.ts +1 -0
  90. package/src/telemetry.test.ts +118 -1
  91. package/src/telemetry.ts +50 -2
  92. package/src/vault-client.ts +4 -2
  93. package/src/watcher.test.ts +81 -0
  94. package/src/watcher.ts +27 -9
  95. package/test/e2e/sync/cross-tenant-isolation.test.ts +2 -0
@@ -126,7 +126,8 @@ export function computePersonalVaultPaths(
126
126
  ? computePersonalCompanySubdirs(hqRoot, opts.teamSyncedSlugs)
127
127
  : [];
128
128
  const continuity = computeContinuityPointerPaths(hqRoot);
129
- return [...topLevel, ...manifest, ...companySubdirs, ...continuity];
129
+ const agency = computeAgencySyncPaths(hqRoot);
130
+ return [...topLevel, ...manifest, ...companySubdirs, ...continuity, ...agency];
130
131
  }
131
132
 
132
133
  /**
@@ -213,6 +214,40 @@ export function computeContinuityPointerPaths(hqRoot: string): string[] {
213
214
  return out;
214
215
  }
215
216
 
217
+ /**
218
+ * Fixed relative path (forward-slash, hq-root-relative) of the agency
219
+ * workspace subtree. See {@link computeAgencySyncPaths}.
220
+ */
221
+ export const AGENCY_SYNC_REL = "workspace/agency";
222
+
223
+ /**
224
+ * Compute the absolute path of the agency-workspace carve-out:
225
+ * `workspace/agency/` (the whole subtree).
226
+ *
227
+ * Like the session-continuity pointer above, this pierces the `workspace/`
228
+ * top-level exclusion for ONE specific subtree. hq-pack-agency teams keep
229
+ * their cross-session chat.jsonl inboxes and roster/state under
230
+ * `workspace/agency/<company>/<team>/`; that state must travel across machines
231
+ * so an agency team picked up on machine B sees the same conversation and
232
+ * roster it had on machine A. The rest of `workspace/` (session scratch,
233
+ * locks, reports, full thread history) stays machine-local.
234
+ *
235
+ * Returns the directory path when it exists; share()'s per-file walk handles
236
+ * recursion, and the nested personal-vault exclusions still apply to files
237
+ * inside it (e.g. a stray `node_modules/` or `.env`). Fail-soft: a missing or
238
+ * unreadable `workspace/agency/` returns []. Callers tolerate empty arrays —
239
+ * same contract as the manifest + continuity special-cases.
240
+ */
241
+ export function computeAgencySyncPaths(hqRoot: string): string[] {
242
+ const agencyDir = path.join(hqRoot, "workspace", "agency");
243
+ try {
244
+ if (fs.statSync(agencyDir).isDirectory()) return [agencyDir];
245
+ } catch {
246
+ // No agency workspace on this machine — nothing to carry.
247
+ }
248
+ return [];
249
+ }
250
+
216
251
  /**
217
252
  * Discover `companies/{slug}/` subdirs that should sync to the personal
218
253
  * bucket as a fallback for companies the operator has not designated as
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, vi } from "vitest";
2
2
  import { promises as fs } from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
@@ -923,3 +923,128 @@ describe("collectAndSendSkillTelemetry — hqRoot scoping", () => {
923
923
  await fs.rm(tmp, { recursive: true, force: true });
924
924
  });
925
925
  });
926
+
927
+ // ── companyUid edge attribution (US-002) ────────────────────────────────────────
928
+
929
+ describe("collectAndSendSkillTelemetry — companyUid attribution", () => {
930
+ const COMPANY_UID = "cmp_01INDIGOSKILL";
931
+
932
+ function stubClient(captured: SkillInvocationBatch[]) {
933
+ return {
934
+ async getTelemetryOptIn() {
935
+ return { enabled: true, updatedAt: null };
936
+ },
937
+ async postSkillInvocations(batch: SkillInvocationBatch) {
938
+ captured.push(batch);
939
+ return { ok: true, written: batch.events.length, skipped: [] };
940
+ },
941
+ };
942
+ }
943
+
944
+ function row(obj: Record<string, unknown>): string {
945
+ return JSON.stringify(obj);
946
+ }
947
+
948
+ /** Write a manifest mapping `repos/private/hq-cloud` → COMPANY_UID under hqRoot. */
949
+ async function writeManifest(hqRoot: string): Promise<void> {
950
+ await fs.mkdir(path.join(hqRoot, "companies"), { recursive: true });
951
+ await fs.writeFile(
952
+ path.join(hqRoot, "companies", "manifest.yaml"),
953
+ `companies:\n indigo:\n repos:\n - repos/private/hq-cloud\n cloud_uid: ${COMPANY_UID}\n`,
954
+ "utf-8",
955
+ );
956
+ }
957
+
958
+ it("stamps companyUid when cwd is inside a company repo, omits it otherwise", async () => {
959
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-co-"));
960
+ const hqRoot = tmp; // real dir so the manifest's repo path resolves under it
961
+ await writeManifest(hqRoot);
962
+ const projects = path.join(tmp, "projects");
963
+ const dir = path.join(projects, "-p");
964
+ await fs.mkdir(dir, { recursive: true });
965
+
966
+ const inRepo = path.join(hqRoot, "repos/private/hq-cloud");
967
+ const atRoot = hqRoot; // inside scope, but not inside any mapped repo
968
+ await fs.writeFile(
969
+ path.join(dir, "s.jsonl"),
970
+ [
971
+ // cwd inside the company repo → attributed.
972
+ row({ type: "user", sessionId: "s1", timestamp: "2026-06-10T10:00:00Z", cwd: inRepo, uuid: "u1", message: { role: "user", content: "<command-name>/deploy</command-name>" } }),
973
+ // cwd at hqRoot (passes scope) but maps to no repo → unattributed.
974
+ row({ type: "assistant", sessionId: "s1", timestamp: "2026-06-10T10:01:00Z", cwd: atRoot, message: { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "Skill", input: { skill: "indigo:hello-world" } }] } }),
975
+ ].join("\n") + "\n",
976
+ "utf-8",
977
+ );
978
+
979
+ const captured: SkillInvocationBatch[] = [];
980
+ const result = await collectAndSendSkillTelemetry({
981
+ client: stubClient(captured),
982
+ machineId: "m",
983
+ installerVersion: "t",
984
+ hqRoot,
985
+ claudeProjectsRoot: projects,
986
+ codexSessionsRoot: path.join(tmp, "codex"),
987
+ cursorPath: path.join(tmp, "cursor.json"),
988
+ });
989
+
990
+ expect(result.eventsSent).toBe(2);
991
+ const events = captured.flatMap((b) => b.events);
992
+ const bySkill = new Map(events.map((e) => [e.skill, e]));
993
+ expect(bySkill.get("deploy")!.companyUid).toBe(COMPANY_UID);
994
+ expect("companyUid" in bySkill.get("indigo:hello-world")!).toBe(false);
995
+ // Never the reserved sentinel.
996
+ for (const e of events) expect(e.companyUid).not.toBe("unattributed");
997
+
998
+ await fs.rm(tmp, { recursive: true, force: true });
999
+ });
1000
+
1001
+ it("parses the manifest once per run (cache), not per event", async () => {
1002
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-cache-"));
1003
+ const hqRoot = tmp;
1004
+ await writeManifest(hqRoot);
1005
+ const projects = path.join(tmp, "projects");
1006
+ const dir = path.join(projects, "-p");
1007
+ await fs.mkdir(dir, { recursive: true });
1008
+ const inRepo = path.join(hqRoot, "repos/private/hq-cloud");
1009
+ await fs.writeFile(
1010
+ path.join(dir, "s.jsonl"),
1011
+ Array.from({ length: 4 }, (_, i) =>
1012
+ row({ type: "user", sessionId: "s1", timestamp: `2026-06-10T10:0${i}:00Z`, cwd: inRepo, uuid: `u${i}`, message: { role: "user", content: "<command-name>/deploy</command-name>" } }),
1013
+ ).join("\n") + "\n",
1014
+ "utf-8",
1015
+ );
1016
+
1017
+ const manifestPath = path.join(hqRoot, "companies", "manifest.yaml");
1018
+ const realReadFile = fs.readFile;
1019
+ let manifestReads = 0;
1020
+ const spy = vi
1021
+ .spyOn(fs, "readFile")
1022
+ .mockImplementation((async (p: Parameters<typeof realReadFile>[0], ...rest: unknown[]) => {
1023
+ if (typeof p === "string" && p === manifestPath) manifestReads++;
1024
+ // @ts-expect-error pass-through to the real implementation
1025
+ return realReadFile(p, ...rest);
1026
+ }) as typeof realReadFile);
1027
+
1028
+ const captured: SkillInvocationBatch[] = [];
1029
+ try {
1030
+ await collectAndSendSkillTelemetry({
1031
+ client: stubClient(captured),
1032
+ machineId: "m",
1033
+ installerVersion: "t",
1034
+ hqRoot,
1035
+ claudeProjectsRoot: projects,
1036
+ codexSessionsRoot: path.join(tmp, "codex"),
1037
+ cursorPath: path.join(tmp, "cursor.json"),
1038
+ });
1039
+ } finally {
1040
+ spy.mockRestore();
1041
+ }
1042
+
1043
+ expect(manifestReads).toBe(1);
1044
+ const events = captured.flatMap((b) => b.events);
1045
+ expect(events).toHaveLength(4);
1046
+ for (const e of events) expect(e.companyUid).toBe(COMPANY_UID);
1047
+
1048
+ await fs.rm(tmp, { recursive: true, force: true });
1049
+ });
1050
+ });
@@ -59,6 +59,11 @@ import { promises as fs } from "node:fs";
59
59
  import * as os from "node:os";
60
60
  import * as path from "node:path";
61
61
 
62
+ import {
63
+ buildRepoCompanyMap,
64
+ resolveCompanyForCwd,
65
+ type RepoCompanyMap,
66
+ } from "./company-resolver.js";
62
67
  import type {
63
68
  SkillInvocationBatch,
64
69
  SkillInvocationIngestResult,
@@ -533,8 +538,15 @@ export function extractCodexSkillToolEvents(
533
538
  ];
534
539
  }
535
540
 
536
- /** Shape the event for the wire. Drops raw args unless explicitly enabled. */
537
- function toWireRow(ev: SkillEvent): Record<string, unknown> {
541
+ /** Shape the event for the wire. Drops raw args unless explicitly enabled.
542
+ *
543
+ * `companyUid` (US-002): the caller resolves the event's `cwd` → owning company
544
+ * (`resolveCompanyForCwd`) and passes that `cmp_*` uid here. It is on the
545
+ * server's KEEP allowlist (`apps/hq-pro/src/vault-service/handlers/
546
+ * skill-invocations.ts`). When undefined (cwd maps to no company repo) the
547
+ * field is OMITTED — the server treats absence as unattributed/personal. The
548
+ * reserved value `unattributed` is never produced. */
549
+ function toWireRow(ev: SkillEvent, companyUid?: string): Record<string, unknown> {
538
550
  const row: Record<string, unknown> = {
539
551
  skill: ev.skill,
540
552
  source: ev.source,
@@ -544,6 +556,7 @@ function toWireRow(ev: SkillEvent): Record<string, unknown> {
544
556
  if (ev.timestamp !== undefined) row.timestamp = ev.timestamp;
545
557
  if (ev.uuid !== undefined) row.uuid = ev.uuid;
546
558
  if (ev.cwd !== undefined) row.cwd = ev.cwd;
559
+ if (companyUid !== undefined) row.companyUid = companyUid;
547
560
  // INCLUDE_ARGS_PREVIEW is intentionally a compile-time constant `false`;
548
561
  // the guarded branch documents the (currently disabled) egress path.
549
562
  if (INCLUDE_ARGS_PREVIEW) {
@@ -643,6 +656,13 @@ export async function collectAndSendSkillTelemetry(
643
656
  const normalizePath = (p: string): string => (p.length > 1 ? p.replace(/\/+$/, "") : p);
644
657
  const scopeCwd = opts.hqRoot !== undefined ? normalizePath(opts.hqRoot) : undefined;
645
658
 
659
+ // Company attribution (US-002): parse the manifest ONCE per run and reuse the
660
+ // repo-path→companyUid map for every event below. No per-event manifest read.
661
+ // When `hqRoot` is omitted the map is empty → every event stays unattributed.
662
+ const repoCompanyMap: RepoCompanyMap = opts.hqRoot
663
+ ? await buildRepoCompanyMap(opts.hqRoot)
664
+ : { entries: [] };
665
+
646
666
  // 1. Opt-in check — reuse the same gate as token telemetry.
647
667
  let enabled: boolean;
648
668
  let optInSource: CollectSkillTelemetryResult["optInSource"];
@@ -788,7 +808,10 @@ export async function collectAndSendSkillTelemetry(
788
808
  continue;
789
809
  }
790
810
  }
791
- sourced.push({ row: toWireRow(ev), filePath, endOffset });
811
+ // Resolve cwd → owning company (cmp_* uid) from the per-run map.
812
+ // Unresolved → undefined → companyUid omitted (unattributed/personal).
813
+ const companyUid = resolveCompanyForCwd(ev.cwd, repoCompanyMap);
814
+ sourced.push({ row: toWireRow(ev, companyUid), filePath, endOffset });
792
815
  fileScans[filePath].eventCount++;
793
816
  }
794
817
  }
@@ -376,6 +376,7 @@ describe("createRefreshingSqsClient", () => {
376
376
  function pushEvent(overrides: Partial<PushEvent> = {}): PushEvent {
377
377
  return {
378
378
  relativePath: "companies/indigo/docs/a.md",
379
+ kind: "upsert",
379
380
  contentHash: `sha256:${"a".repeat(64)}`,
380
381
  mtime: "2026-06-10T12:00:00.000Z",
381
382
  originDeviceId: "peer-device",
@@ -469,6 +470,25 @@ describe("startEventSync", () => {
469
470
  true,
470
471
  );
471
472
 
473
+ // Peer delete tombstone → bridged into syncFn. The runner handles safety by
474
+ // running a targeted pull; it does not unlink directly from this payload.
475
+ await waitForPoll();
476
+ deliverNext!(
477
+ JSON.stringify({
478
+ relativePath: "companies/indigo/deleted.md",
479
+ kind: "delete",
480
+ originDeviceId: "peer-device",
481
+ originTenantId: "prs_tenant",
482
+ sequenceNumber: 3,
483
+ eventTimestamp: "2026-06-10T12:01:00.000Z",
484
+ } satisfies PushEvent),
485
+ );
486
+ await vi.waitFor(() => expect(syncCalls).toHaveLength(2));
487
+ expect(syncCalls[1]).toMatchObject({
488
+ kind: "delete",
489
+ relativePath: "companies/indigo/deleted.md",
490
+ });
491
+
472
492
  await handles!.dispose();
473
493
  });
474
494
 
@@ -522,6 +542,7 @@ describe("startEventSync", () => {
522
542
  await vi.waitFor(() => expect(published).toHaveLength(1));
523
543
  const ev = published[0];
524
544
  expect(ev.relativePath).toBe("docs/self.ts");
545
+ expect(ev.kind).toBe("upsert");
525
546
  expect(ev.originDeviceId).toBe("this-device");
526
547
  expect(ev.originTenantId).toBe("prs_tenant");
527
548
  // Wall-clock sequence numbers (restart-safe monotonicity).
@@ -49,6 +49,7 @@ import type { TreeChangeBatch } from "../watcher.js";
49
49
  function fakePushEvent(overrides: Partial<PushEvent> = {}): PushEvent {
50
50
  return {
51
51
  relativePath: "personal/notes/a.md",
52
+ kind: "upsert",
52
53
  contentHash: `sha256:${"a".repeat(64)}`,
53
54
  mtime: "2026-05-21T12:00:00.000Z",
54
55
  originDeviceId: "device-1",
@@ -178,6 +179,34 @@ describe("HttpPushTransport — POST /sync/push", () => {
178
179
  expect(decodePushEvent(init.body)).toEqual(event);
179
180
  });
180
181
 
182
+ it("POSTs a delete tombstone without contentHash or mtime", async () => {
183
+ const { fetch, calls } = makeFetch({ ok: true, status: 200 });
184
+ const transport = new HttpPushTransport({
185
+ apiUrl: "https://vault-api.example.com/",
186
+ authToken: "tok-123",
187
+ fetchImpl: fetch,
188
+ });
189
+ const event = fakePushEvent({
190
+ kind: "delete",
191
+ contentHash: undefined,
192
+ mtime: undefined,
193
+ });
194
+
195
+ await transport.publish(event);
196
+
197
+ expect(calls).toHaveLength(1);
198
+ const body = (calls[0].init as { body: string }).body;
199
+ expect(JSON.parse(body)).toEqual({
200
+ relativePath: "personal/notes/a.md",
201
+ kind: "delete",
202
+ originDeviceId: "device-1",
203
+ originTenantId: "indigo",
204
+ sequenceNumber: 0,
205
+ eventTimestamp: "2026-05-21T12:00:01.000Z",
206
+ });
207
+ expect(decodePushEvent(body)).toEqual(JSON.parse(body));
208
+ });
209
+
181
210
  it("resolves the auth token per-request via an async getter (self-heals across refresh)", async () => {
182
211
  const { fetch, calls } = makeFetch();
183
212
  let n = 0;
@@ -287,6 +316,7 @@ describe("PushEventEmitter — emit, gating, failure handling", () => {
287
316
  expect(published).toHaveLength(1);
288
317
  const e = published[0];
289
318
  expect(e.relativePath).toBe(REL);
319
+ expect(e.kind).toBe("upsert");
290
320
  expect(e.originTenantId).toBe("indigo");
291
321
  expect(e.originDeviceId).toBe("dev-1");
292
322
  const expectedHex = createHash("sha256").update("hello world").digest("hex");
@@ -350,7 +380,7 @@ describe("PushEventEmitter — emit, gating, failure handling", () => {
350
380
  expect(onError.mock.calls[0][1]).toMatchObject({ relativePath: REL });
351
381
  });
352
382
 
353
- it("a missing file (hash/stat race) is surfaced, other paths still ship", async () => {
383
+ it("a missing file emits a delete tombstone, and other paths still ship", async () => {
354
384
  const published: PushEvent[] = [];
355
385
  const onError = vi.fn();
356
386
  const emitter = new PushEventEmitter({
@@ -362,9 +392,15 @@ describe("PushEventEmitter — emit, gating, failure handling", () => {
362
392
  });
363
393
  // REL exists; "ghost.md" does not.
364
394
  await emitter.emitForBatch(batchFor(REL, "ghost.md"));
365
- expect(published.map((e) => e.relativePath)).toEqual([REL]);
366
- expect(onError).toHaveBeenCalledTimes(1);
367
- expect(onError.mock.calls[0][1]).toMatchObject({ relativePath: "ghost.md" });
395
+ expect(published.map((e) => e.relativePath).sort()).toEqual([
396
+ REL,
397
+ "ghost.md",
398
+ ].sort());
399
+ const tombstone = published.find((e) => e.relativePath === "ghost.md");
400
+ expect(tombstone).toMatchObject({ kind: "delete" });
401
+ expect(tombstone).not.toHaveProperty("contentHash");
402
+ expect(tombstone).not.toHaveProperty("mtime");
403
+ expect(onError).not.toHaveBeenCalled();
368
404
  });
369
405
 
370
406
  it("end-to-end: emit → HttpPushTransport POST with a valid schema (mocked fetch)", async () => {
package/src/sync/index.ts CHANGED
@@ -13,7 +13,11 @@ export {
13
13
  encodePushEvent,
14
14
  decodePushEvent,
15
15
  } from "./push-event.js";
16
- export type { PushEvent, PushEventDecodeIssue } from "./push-event.js";
16
+ export type {
17
+ PushEvent,
18
+ PushEventInput,
19
+ PushEventDecodeIssue,
20
+ } from "./push-event.js";
17
21
 
18
22
  export { NoopPushTransport, HttpPushTransport } from "./push-transport.js";
19
23
  export type {
@@ -70,6 +70,7 @@ async function until(predicate: () => boolean, timeoutMs = 1000): Promise<void>
70
70
  function makeEvent(overrides: Partial<PushEvent> = {}): PushEvent {
71
71
  return {
72
72
  relativePath: "companies/indigo/notes.md",
73
+ kind: "upsert",
73
74
  contentHash:
74
75
  "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
75
76
  mtime: "2026-05-21T12:34:56.000Z",
@@ -82,6 +82,7 @@ const QUEUE_URL =
82
82
  function makeEvent(overrides: Partial<PushEvent> = {}): PushEvent {
83
83
  return {
84
84
  relativePath: "companies/indigo/notes.md",
85
+ kind: "upsert",
85
86
  contentHash:
86
87
  "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
87
88
  mtime: "2026-05-21T12:34:56.000Z",
@@ -35,7 +35,7 @@ import type {
35
35
  */
36
36
  export interface PullScopeClient {
37
37
  listMyMemberships(): Promise<
38
- Array<{ companyUid: string; membershipKey: string }>
38
+ Array<{ companyUid: string; membershipKey: string; role?: string }>
39
39
  >;
40
40
  getMembershipSyncConfig?: (
41
41
  membershipId: string,
@@ -82,6 +82,31 @@ export async function resolvePullScope(
82
82
  const memberships = await client.listMyMemberships();
83
83
  const m = memberships.find((x) => x.companyUid === companyUid);
84
84
  if (!m) return { syncMode: "all" };
85
+ // OWNER role-bypass (feedback_67bdb8a4 / feedback_a46c3b37). An owner's
86
+ // effective vault access is the WHOLE company by role — the data plane
87
+ // resolves owner to `admin` on every key (`resolveEffectivePermission`'s
88
+ // role bypass; presign's `viewOthersFiles` is unconditional for owners).
89
+ // But the scope resolution below derives `shared` scope from
90
+ // `listMyExplicitGrants`, and that endpoint DELIBERATELY EXCLUDES the
91
+ // owner/admin role bypass — it answers "what is explicitly shared with
92
+ // me", not "what can I touch by role" (see hq-pro files-grants handler).
93
+ // An owner who owns by role (not by explicit grant) therefore resolves to
94
+ // an empty/sparse prefixSet, so the push uploads NOTHING (every path is
95
+ // filtered out by `wrapFilterWithScope`) and any in-role write — including
96
+ // a brand-new top-level project prefix the owner just created — is rejected
97
+ // by the server as `403 SCOPE_EXCEEDS_PARENT`. An owner has no meaningful
98
+ // "shared subset": they own everything, so their effective sync scope IS
99
+ // the full vault. Resolve them to `all` directly — restoring the pre-
100
+ // pull-scope-refactor behavior (owners always journaled the whole company).
101
+ // This is checked BEFORE the sync-config fetch so it holds even if a stray
102
+ // `shared` row exists for the owner. A `custom` choice would also be
103
+ // overridden here, which is intentional: an owner cannot be scoped below
104
+ // their role on the sync path without the server rejecting their own
105
+ // writes. Non-owner roles fall through to the grant-based resolution
106
+ // below — a member/guest's explicit grants ARE their full footprint, and a
107
+ // (conditionally bypassed) admin stays grant-scoped so a company that set
108
+ // `admin.viewOthersFiles=false` keeps its admins narrowed.
109
+ if (m.role === "owner") return { syncMode: "all" };
85
110
  // Agent memberships (`agt_…#cmp_…`) belong to an agent identity that owns NO
86
111
  // person entity. hq-pro's per-membership sync-config endpoint sits behind an
87
112
  // up-front person-gate, so an agent caller is rejected with
@@ -19,12 +19,14 @@ import {
19
19
  decodePushEvent,
20
20
  encodePushEvent,
21
21
  type PushEvent,
22
+ type PushEventInput,
22
23
  } from "../../src/sync/index.js";
23
24
 
24
25
  // A canonical, valid PushEvent. All other tests derive from this fixture so
25
26
  // any single field mutation can't accidentally pass for the wrong reason.
26
27
  const validFixture: PushEvent = {
27
28
  relativePath: "docs/architecture/overview.md",
29
+ kind: "upsert",
28
30
  contentHash:
29
31
  "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
30
32
  mtime: "2026-05-18T12:34:56.789Z",
@@ -34,6 +36,15 @@ const validFixture: PushEvent = {
34
36
  eventTimestamp: "2026-05-18T12:35:00.000Z",
35
37
  };
36
38
 
39
+ const deleteFixture: PushEvent = {
40
+ relativePath: "docs/architecture/overview.md",
41
+ kind: "delete",
42
+ originDeviceId: "device-laptop-a",
43
+ originTenantId: "tenant-indigo",
44
+ sequenceNumber: 43,
45
+ eventTimestamp: "2026-05-18T12:36:00.000Z",
46
+ };
47
+
37
48
  describe("PushEvent encode/decode", () => {
38
49
  // ── Acceptance #1: round-trip ──────────────────────────────────────────
39
50
  it("round-trips a known-good fixture through encode → decode", () => {
@@ -55,6 +66,21 @@ describe("PushEvent encode/decode", () => {
55
66
  expect(fromString).toEqual(validFixture);
56
67
  });
57
68
 
69
+ it("round-trips a delete tombstone without contentHash or mtime", () => {
70
+ const encoded = encodePushEvent(deleteFixture);
71
+ expect(JSON.parse(encoded)).toEqual(deleteFixture);
72
+ expect(decodePushEvent(encoded)).toEqual(deleteFixture);
73
+ });
74
+
75
+ it("defaults an absent kind to upsert for backward compatibility", () => {
76
+ const legacy = { ...validFixture } as PushEventInput;
77
+ delete legacy.kind;
78
+
79
+ const decoded = decodePushEvent(legacy);
80
+ expect(decoded).toEqual(validFixture);
81
+ expect(JSON.parse(encodePushEvent(legacy))).toEqual(validFixture);
82
+ });
83
+
58
84
  // ── Acceptance #2: unknown fields dropped ──────────────────────────────
59
85
  it("drops unknown extra fields silently (does not throw)", () => {
60
86
  const withExtras = {
@@ -80,8 +106,6 @@ describe("PushEvent encode/decode", () => {
80
106
  // ── Acceptance #3: missing required fields throw typed error ───────────
81
107
  it.each([
82
108
  "relativePath",
83
- "contentHash",
84
- "mtime",
85
109
  "originDeviceId",
86
110
  "originTenantId",
87
111
  "sequenceNumber",
@@ -106,6 +130,24 @@ describe("PushEvent encode/decode", () => {
106
130
  expect(error.issues.some((issue) => issue.path.includes(field))).toBe(true);
107
131
  });
108
132
 
133
+ it("rejects an upsert without contentHash and mtime via the schema refinement", () => {
134
+ let caught: unknown;
135
+ try {
136
+ decodePushEvent({
137
+ ...validFixture,
138
+ contentHash: undefined,
139
+ mtime: undefined,
140
+ });
141
+ } catch (err) {
142
+ caught = err;
143
+ }
144
+
145
+ expect(caught).toBeInstanceOf(PushEventDecodeError);
146
+ const error = caught as PushEventDecodeError;
147
+ expect(error.stage).toBe("schema-validation");
148
+ expect(error.issues.some((issue) => issue.message === "upsert requires contentHash and mtime")).toBe(true);
149
+ });
150
+
109
151
  it("PushEventDecodeError carries the underlying zod issues array", () => {
110
152
  // Multiple missing fields → multiple issues, all reachable via `.issues`.
111
153
  const sparse = { relativePath: "x.md" } as unknown;
@@ -117,7 +159,7 @@ describe("PushEvent encode/decode", () => {
117
159
  }
118
160
  expect(caught).toBeInstanceOf(PushEventDecodeError);
119
161
  const error = caught as PushEventDecodeError;
120
- expect(error.issues.length).toBeGreaterThanOrEqual(6);
162
+ expect(error.issues.length).toBeGreaterThanOrEqual(4);
121
163
  });
122
164
 
123
165
  // ── Supporting wire-contract invariants ────────────────────────────────
@@ -2,17 +2,20 @@
2
2
  * PushEvent — the wire-shared payload exchanged between every link in the
3
3
  * event-driven-hq-cloud-sync pipeline.
4
4
  *
5
- * Producers (the watcher) emit one PushEvent per local content change.
5
+ * Producers (the watcher) emit one PushEvent per local content change or
6
+ * delete tombstone.
6
7
  * Consumers (the push endpoint / receiver / coalescer) decode the payload,
7
8
  * validate it, and act on it. Because the same shape crosses a network
8
9
  * boundary, it has its own dedicated module.
9
10
  *
10
11
  * Conventions
11
12
  * ───────────
12
- * - `contentHash` is `sha256:<64-lowercase-hex>`. The `<algorithm>:<hex>`
13
- * prefix lets a future hash migration ship without breaking the wire
14
- * format consumers can branch on the prefix and fall back to refusing
15
- * unknown algorithms.
13
+ * - `kind` is `"upsert"` for content changes and `"delete"` for delete
14
+ * tombstones. Missing `kind` defaults to `"upsert"` for older producers.
15
+ * - `contentHash` is required for upserts and absent for delete tombstones.
16
+ * It is `sha256:<64-lowercase-hex>`. The `<algorithm>:<hex>` prefix lets a
17
+ * future hash migration ship without breaking the wire format — consumers
18
+ * can branch on the prefix and fall back to refusing unknown algorithms.
16
19
  * - `mtime` and `eventTimestamp` are strict ISO-8601 datetime strings.
17
20
  * `mtime` is the filesystem modification time of the source file at the
18
21
  * moment of capture; `eventTimestamp` is when the watcher emitted the
@@ -59,15 +62,18 @@ export const PushEventSchema = z
59
62
  relativePath: z
60
63
  .string()
61
64
  .min(1, "relativePath must be a non-empty string"),
65
+ kind: z.enum(["upsert", "delete"]).optional().default("upsert"),
62
66
  contentHash: z
63
67
  .string()
64
68
  .regex(
65
69
  CONTENT_HASH_PATTERN,
66
70
  "contentHash must match `sha256:<64 lowercase hex>`",
67
- ),
71
+ )
72
+ .optional(),
68
73
  mtime: z
69
74
  .string()
70
- .regex(ISO8601_DATETIME_PATTERN, "mtime must be an ISO-8601 datetime"),
75
+ .regex(ISO8601_DATETIME_PATTERN, "mtime must be an ISO-8601 datetime")
76
+ .optional(),
71
77
  originDeviceId: z
72
78
  .string()
73
79
  .min(1, "originDeviceId must be a non-empty string"),
@@ -91,13 +97,23 @@ export const PushEventSchema = z
91
97
  })
92
98
  // `.strip()` is the zod 4 default; called explicitly here so the intent is
93
99
  // obvious to future readers. Unknown keys MUST NOT throw — see module JSDoc.
94
- .strip();
100
+ .strip()
101
+ .refine(
102
+ (e) => e.kind === "delete" || (e.contentHash != null && e.mtime != null),
103
+ { message: "upsert requires contentHash and mtime" },
104
+ );
105
+
106
+ /**
107
+ * The canonical decoded PushEvent type. `kind` is always present after decode;
108
+ * `contentHash` and `mtime` are present for upserts and absent for deletes.
109
+ */
110
+ export type PushEvent = z.output<typeof PushEventSchema>;
95
111
 
96
112
  /**
97
- * The canonical PushEvent type. All fields are required; producers and
98
- * consumers share this exact shape.
113
+ * Input accepted by the schema. Kept public so encode callers can hand in
114
+ * legacy upsert payloads where `kind` is absent.
99
115
  */
100
- export type PushEvent = z.infer<typeof PushEventSchema>;
116
+ export type PushEventInput = z.input<typeof PushEventSchema>;
101
117
 
102
118
  // ─── Errors ────────────────────────────────────────────────────────────────
103
119
 
@@ -145,7 +161,7 @@ export class PushEventDecodeError extends Error {
145
161
  * Extra keys on `event` are dropped — the returned JSON string contains
146
162
  * only the declared PushEvent fields.
147
163
  */
148
- export function encodePushEvent(event: PushEvent): string {
164
+ export function encodePushEvent(event: PushEventInput): string {
149
165
  const parsed = PushEventSchema.safeParse(event);
150
166
  if (!parsed.success) {
151
167
  throw new PushEventDecodeError(
@@ -45,6 +45,7 @@ const QUEUE_URL = "https://sqs.us-east-1.amazonaws.com/123456789012/sync-push-in
45
45
  function makeEvent(overrides: Partial<PushEvent> = {}): PushEvent {
46
46
  return {
47
47
  relativePath: "companies/indigo/notes.md",
48
+ kind: "upsert",
48
49
  contentHash:
49
50
  "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
50
51
  mtime: "2026-05-21T12:34:56.000Z",