@indigoai-us/hq-cloud 6.14.0 → 6.14.2

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 (167) hide show
  1. package/dist/bin/sync-runner-company.d.ts +4 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  3. package/dist/bin/sync-runner-company.js +50 -1
  4. package/dist/bin/sync-runner-company.js.map +1 -1
  5. package/dist/bin/sync-runner-watch-loop.d.ts +3 -2
  6. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  7. package/dist/bin/sync-runner-watch-loop.js +21 -7
  8. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  9. package/dist/bin/sync-runner.d.ts +6 -2
  10. package/dist/bin/sync-runner.d.ts.map +1 -1
  11. package/dist/bin/sync-runner.js +162 -11
  12. package/dist/bin/sync-runner.js.map +1 -1
  13. package/dist/bin/sync-runner.test.js +155 -17
  14. package/dist/bin/sync-runner.test.js.map +1 -1
  15. package/dist/cli/accept.d.ts.map +1 -1
  16. package/dist/cli/accept.js +29 -0
  17. package/dist/cli/accept.js.map +1 -1
  18. package/dist/cli/invite.d.ts.map +1 -1
  19. package/dist/cli/invite.js +33 -0
  20. package/dist/cli/invite.js.map +1 -1
  21. package/dist/cli/promote.d.ts.map +1 -1
  22. package/dist/cli/promote.js +33 -0
  23. package/dist/cli/promote.js.map +1 -1
  24. package/dist/cli/reindex.d.ts +23 -11
  25. package/dist/cli/reindex.d.ts.map +1 -1
  26. package/dist/cli/reindex.js +39 -10
  27. package/dist/cli/reindex.js.map +1 -1
  28. package/dist/cli/reindex.test.js +40 -13
  29. package/dist/cli/reindex.test.js.map +1 -1
  30. package/dist/cli/share.js +47 -4
  31. package/dist/cli/share.js.map +1 -1
  32. package/dist/cli/share.test.js +123 -2
  33. package/dist/cli/share.test.js.map +1 -1
  34. package/dist/cli/sync-scope.test.js +84 -0
  35. package/dist/cli/sync-scope.test.js.map +1 -1
  36. package/dist/cli/sync.d.ts +44 -3
  37. package/dist/cli/sync.d.ts.map +1 -1
  38. package/dist/cli/sync.js +156 -7
  39. package/dist/cli/sync.js.map +1 -1
  40. package/dist/cli/sync.test.js +133 -0
  41. package/dist/cli/sync.test.js.map +1 -1
  42. package/dist/company-resolver.d.ts +21 -0
  43. package/dist/company-resolver.d.ts.map +1 -1
  44. package/dist/company-resolver.js +31 -6
  45. package/dist/company-resolver.js.map +1 -1
  46. package/dist/company-resolver.test.js +44 -1
  47. package/dist/company-resolver.test.js.map +1 -1
  48. package/dist/ignore.d.ts +10 -0
  49. package/dist/ignore.d.ts.map +1 -1
  50. package/dist/ignore.js +23 -2
  51. package/dist/ignore.js.map +1 -1
  52. package/dist/ignore.test.js +13 -1
  53. package/dist/ignore.test.js.map +1 -1
  54. package/dist/index.d.ts +4 -2
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +4 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/journal.d.ts +12 -0
  59. package/dist/journal.d.ts.map +1 -1
  60. package/dist/journal.js +12 -0
  61. package/dist/journal.js.map +1 -1
  62. package/dist/lib/exit-codes.d.ts +25 -0
  63. package/dist/lib/exit-codes.d.ts.map +1 -0
  64. package/dist/lib/exit-codes.js +25 -0
  65. package/dist/lib/exit-codes.js.map +1 -0
  66. package/dist/object-io.d.ts.map +1 -1
  67. package/dist/object-io.js +45 -14
  68. package/dist/object-io.js.map +1 -1
  69. package/dist/object-io.test.js +61 -1
  70. package/dist/object-io.test.js.map +1 -1
  71. package/dist/operation-lock.d.ts +36 -6
  72. package/dist/operation-lock.d.ts.map +1 -1
  73. package/dist/operation-lock.js +34 -11
  74. package/dist/operation-lock.js.map +1 -1
  75. package/dist/operation-lock.test.js +30 -0
  76. package/dist/operation-lock.test.js.map +1 -1
  77. package/dist/s3.d.ts +1 -0
  78. package/dist/s3.d.ts.map +1 -1
  79. package/dist/s3.js +75 -2
  80. package/dist/s3.js.map +1 -1
  81. package/dist/s3.test.js +56 -1
  82. package/dist/s3.test.js.map +1 -1
  83. package/dist/scope-shrink.d.ts +12 -0
  84. package/dist/scope-shrink.d.ts.map +1 -1
  85. package/dist/scope-shrink.js +12 -0
  86. package/dist/scope-shrink.js.map +1 -1
  87. package/dist/scope-shrink.test.js +31 -0
  88. package/dist/scope-shrink.test.js.map +1 -1
  89. package/dist/skill-telemetry.d.ts +41 -0
  90. package/dist/skill-telemetry.d.ts.map +1 -1
  91. package/dist/skill-telemetry.js +111 -7
  92. package/dist/skill-telemetry.js.map +1 -1
  93. package/dist/skill-telemetry.test.js +187 -5
  94. package/dist/skill-telemetry.test.js.map +1 -1
  95. package/dist/sync/event-sync.d.ts +3 -0
  96. package/dist/sync/event-sync.d.ts.map +1 -1
  97. package/dist/sync/event-sync.js +23 -0
  98. package/dist/sync/event-sync.js.map +1 -1
  99. package/dist/sync/pull-scope.d.ts +21 -0
  100. package/dist/sync/pull-scope.d.ts.map +1 -1
  101. package/dist/sync/pull-scope.js +46 -13
  102. package/dist/sync/pull-scope.js.map +1 -1
  103. package/dist/telemetry-events.d.ts +31 -0
  104. package/dist/telemetry-events.d.ts.map +1 -0
  105. package/dist/telemetry-events.js +140 -0
  106. package/dist/telemetry-events.js.map +1 -0
  107. package/dist/telemetry-events.test.d.ts +2 -0
  108. package/dist/telemetry-events.test.d.ts.map +1 -0
  109. package/dist/telemetry-events.test.js +73 -0
  110. package/dist/telemetry-events.test.js.map +1 -0
  111. package/dist/telemetry.js +1 -1
  112. package/dist/telemetry.js.map +1 -1
  113. package/dist/vault-client.d.ts +38 -5
  114. package/dist/vault-client.d.ts.map +1 -1
  115. package/dist/vault-client.js +69 -6
  116. package/dist/vault-client.js.map +1 -1
  117. package/dist/vault-client.test.js +31 -1
  118. package/dist/vault-client.test.js.map +1 -1
  119. package/dist/watcher.d.ts +7 -0
  120. package/dist/watcher.d.ts.map +1 -1
  121. package/dist/watcher.js +53 -1
  122. package/dist/watcher.js.map +1 -1
  123. package/dist/watcher.test.js +57 -0
  124. package/dist/watcher.test.js.map +1 -1
  125. package/package.json +1 -1
  126. package/pnpm-workspace.yaml +2 -0
  127. package/scripts/presign-transport-e2e.mjs +2 -2
  128. package/src/bin/sync-runner-company.ts +65 -1
  129. package/src/bin/sync-runner-watch-loop.ts +24 -8
  130. package/src/bin/sync-runner.test.ts +194 -16
  131. package/src/bin/sync-runner.ts +209 -11
  132. package/src/cli/accept.ts +27 -0
  133. package/src/cli/invite.ts +31 -0
  134. package/src/cli/promote.ts +31 -0
  135. package/src/cli/reindex.test.ts +44 -13
  136. package/src/cli/reindex.ts +74 -21
  137. package/src/cli/share.test.ts +139 -2
  138. package/src/cli/share.ts +48 -2
  139. package/src/cli/sync-scope.test.ts +90 -0
  140. package/src/cli/sync.test.ts +154 -0
  141. package/src/cli/sync.ts +224 -5
  142. package/src/company-resolver.test.ts +51 -0
  143. package/src/company-resolver.ts +40 -5
  144. package/src/ignore.test.ts +18 -1
  145. package/src/ignore.ts +24 -2
  146. package/src/index.ts +22 -1
  147. package/src/journal.ts +12 -0
  148. package/src/lib/exit-codes.ts +24 -0
  149. package/src/object-io.test.ts +66 -1
  150. package/src/object-io.ts +51 -14
  151. package/src/operation-lock.test.ts +33 -0
  152. package/src/operation-lock.ts +46 -11
  153. package/src/s3.test.ts +87 -0
  154. package/src/s3.ts +93 -2
  155. package/src/scope-shrink.test.ts +32 -0
  156. package/src/scope-shrink.ts +12 -0
  157. package/src/skill-telemetry.test.ts +247 -4
  158. package/src/skill-telemetry.ts +134 -6
  159. package/src/sync/event-sync.ts +37 -0
  160. package/src/sync/pull-scope.ts +62 -13
  161. package/src/telemetry-events.test.ts +88 -0
  162. package/src/telemetry-events.ts +205 -0
  163. package/src/telemetry.ts +1 -1
  164. package/src/vault-client.test.ts +39 -1
  165. package/src/vault-client.ts +115 -14
  166. package/src/watcher.test.ts +82 -0
  167. package/src/watcher.ts +69 -0
package/src/cli/accept.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  VaultPermissionDeniedError,
15
15
  } from "../vault-client.js";
16
16
  import type { Membership } from "../vault-client.js";
17
+ import { emitCloudTelemetry } from "../telemetry-events.js";
17
18
 
18
19
  export interface AcceptOptions {
19
20
  /** Raw token or magic link (hq://accept/<token>) */
@@ -78,8 +79,26 @@ export async function accept(options: AcceptOptions): Promise<AcceptResult> {
78
79
  }
79
80
  }
80
81
 
82
+ void emitCloudTelemetry(client, {
83
+ eventName: "membership_accept",
84
+ source: "hq-cli",
85
+ companyUid: membership.companyUid,
86
+ properties: {
87
+ role: membership.role,
88
+ result: "success",
89
+ },
90
+ });
91
+
81
92
  return { membership, companySlug };
82
93
  } catch (err) {
94
+ void emitCloudTelemetry(client, {
95
+ eventName: "membership_accept",
96
+ source: "hq-cli",
97
+ properties: {
98
+ result: "failure",
99
+ errorCode: classifyAcceptError(err),
100
+ },
101
+ });
83
102
  if (err instanceof VaultAuthError) {
84
103
  throw new Error("Authentication failed — run `hq auth` to refresh your session");
85
104
  }
@@ -95,3 +114,11 @@ export async function accept(options: AcceptOptions): Promise<AcceptResult> {
95
114
  throw err;
96
115
  }
97
116
  }
117
+
118
+ function classifyAcceptError(err: unknown): string {
119
+ if (err instanceof VaultAuthError) return "auth";
120
+ if (err instanceof VaultConflictError) return "conflict";
121
+ if (err instanceof VaultNotFoundError) return "not_found";
122
+ if (err instanceof VaultPermissionDeniedError) return "permission";
123
+ return "error";
124
+ }
package/src/cli/invite.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  VaultConflictError,
15
15
  } from "../vault-client.js";
16
16
  import type { MembershipRole, Membership } from "../vault-client.js";
17
+ import { emitCloudTelemetry } from "../telemetry-events.js";
17
18
 
18
19
  export interface InviteOptions {
19
20
  /** Target — email address or person slug/uid */
@@ -83,6 +84,17 @@ export async function invite(options: InviteOptions): Promise<InviteResult> {
83
84
  });
84
85
 
85
86
  const magicLink = `hq://accept/${result.inviteToken}`;
87
+ void emitCloudTelemetry(client, {
88
+ eventName: "membership_invite",
89
+ source: "hq-cli",
90
+ companyUid,
91
+ properties: {
92
+ role,
93
+ result: "success",
94
+ targetType: isEmail ? "email" : "person",
95
+ allowedPrefixCount: allowedPrefixes?.length ?? 0,
96
+ },
97
+ });
86
98
 
87
99
  return {
88
100
  inviteToken: result.inviteToken,
@@ -90,6 +102,18 @@ export async function invite(options: InviteOptions): Promise<InviteResult> {
90
102
  membership: result.membership,
91
103
  };
92
104
  } catch (err) {
105
+ void emitCloudTelemetry(client, {
106
+ eventName: "membership_invite",
107
+ source: "hq-cli",
108
+ companyUid,
109
+ properties: {
110
+ role,
111
+ result: "failure",
112
+ errorCode: classifyInviteError(err),
113
+ targetType: isEmail ? "email" : "person",
114
+ allowedPrefixCount: allowedPrefixes?.length ?? 0,
115
+ },
116
+ });
93
117
  if (err instanceof VaultAuthError) {
94
118
  throw new Error("Authentication failed — run `hq auth` to refresh your session");
95
119
  }
@@ -103,6 +127,13 @@ export async function invite(options: InviteOptions): Promise<InviteResult> {
103
127
  }
104
128
  }
105
129
 
130
+ function classifyInviteError(err: unknown): string {
131
+ if (err instanceof VaultAuthError) return "auth";
132
+ if (err instanceof VaultPermissionDeniedError) return "permission";
133
+ if (err instanceof VaultConflictError) return "conflict";
134
+ return "error";
135
+ }
136
+
106
137
  /**
107
138
  * List pending invites for a company.
108
139
  */
@@ -13,6 +13,7 @@ import {
13
13
  VaultConflictError,
14
14
  } from "../vault-client.js";
15
15
  import type { MembershipRole, Membership } from "../vault-client.js";
16
+ import { emitCloudTelemetry } from "../telemetry-events.js";
16
17
 
17
18
  export interface PromoteOptions {
18
19
  /** Person slug or UID of the member to promote */
@@ -66,8 +67,30 @@ export async function promote(options: PromoteOptions): Promise<PromoteResult> {
66
67
  companyUid,
67
68
  });
68
69
 
70
+ void emitCloudTelemetry(client, {
71
+ eventName: "membership_promote",
72
+ source: "hq-cli",
73
+ companyUid,
74
+ properties: {
75
+ role: newRole,
76
+ result: "success",
77
+ allowedPrefixCount: allowedPrefixes?.length ?? 0,
78
+ },
79
+ });
80
+
69
81
  return { membership };
70
82
  } catch (err) {
83
+ void emitCloudTelemetry(client, {
84
+ eventName: "membership_promote",
85
+ source: "hq-cli",
86
+ companyUid,
87
+ properties: {
88
+ role: newRole,
89
+ result: "failure",
90
+ errorCode: classifyPromoteError(err),
91
+ allowedPrefixCount: allowedPrefixes?.length ?? 0,
92
+ },
93
+ });
71
94
  if (err instanceof VaultAuthError) {
72
95
  throw new Error("Authentication failed — run `hq auth` to refresh your session");
73
96
  }
@@ -84,6 +107,14 @@ export async function promote(options: PromoteOptions): Promise<PromoteResult> {
84
107
  }
85
108
  }
86
109
 
110
+ function classifyPromoteError(err: unknown): string {
111
+ if (err instanceof VaultAuthError) return "auth";
112
+ if (err instanceof VaultPermissionDeniedError) return "permission";
113
+ if (err instanceof VaultNotFoundError) return "not_found";
114
+ if (err instanceof VaultConflictError) return "conflict";
115
+ return "error";
116
+ }
117
+
87
118
  // ---------------------------------------------------------------------------
88
119
  // Helpers
89
120
  // ---------------------------------------------------------------------------
@@ -389,17 +389,46 @@ describe("reindex", () => {
389
389
  expect(fs.readFileSync(path.join(link, "note.md"), "utf-8")).toBe("owned by personal\n");
390
390
  });
391
391
 
392
- // ── operation lock (mutual exclusion with sync/rescue) ──────────────────
392
+ // ── operation lock: reindex uses a SEPARATE scope from sync/rescue ──────
393
+
394
+ // Regression for feedback_ed98d810 (the "hq reindex indefinite hang"): when
395
+ // a watch-mode hq-sync-runner holds the per-root "operation" lock for its
396
+ // whole lifetime, a shared lock starved `hq reindex` forever. reindex now
397
+ // takes its own "reindex" scope, so it must run to completion even while the
398
+ // sync/watch "operation" lock is held by a LIVE holder.
399
+ it("completes while the sync/watch operation lock is held (no starvation)", () => {
400
+ // Simulate the long-lived watch runner holding the "operation"-scope lock.
401
+ // pid = this process → the lock probes it as ALIVE (a live same-PID holder
402
+ // is treated as busy, never reclaimed), so it is a genuine live holder.
403
+ const syncLock = lockPathFor(root); // default "operation" scope
404
+ fs.mkdirSync(path.dirname(syncLock), { recursive: true });
405
+ fs.writeFileSync(
406
+ syncLock,
407
+ JSON.stringify({
408
+ pid: process.pid,
409
+ command: "sync",
410
+ startedAt: new Date(0).toISOString(),
411
+ hqRoot: root,
412
+ }),
413
+ );
414
+ // Even with a zero-wait bound (never block), reindex acquires its OWN scope
415
+ // and finishes — proving the operation lock no longer gates it.
416
+ const { status } = reindex({ repoRoot: root, lockTimeoutSec: 0 });
417
+ expect(status).toBe(0);
418
+ expect(fs.existsSync(path.join(root, ".claude/skills"))).toBe(true);
419
+ // The sync/watch holder is untouched.
420
+ expect(fs.existsSync(syncLock)).toBe(true);
421
+ });
393
422
 
394
- it("refuses (OPERATION_LOCKED_EXIT) when another op holds this root's lock (lockTimeoutSec:0)", () => {
395
- // A live holder in another process (pid 1) with lockTimeoutSec:0 reindex
396
- // refuses immediately and does no work. (The new default is to WAIT; the
397
- // wait-then-acquire path is covered in operation-lock.test.ts.)
398
- const lp = lockPathFor(root);
423
+ it("refuses (OPERATION_LOCKED_EXIT) when another reindex holds the reindex-scope lock (lockTimeoutSec:0)", () => {
424
+ // reindex-vs-reindex is still mutually exclusive. A live holder on the
425
+ // "reindex" scope with lockTimeoutSec:0 reindex refuses immediately and
426
+ // does no work. (The wait-then-acquire path is in operation-lock.test.ts.)
427
+ const lp = lockPathFor(root, "reindex");
399
428
  fs.mkdirSync(path.dirname(lp), { recursive: true });
400
429
  fs.writeFileSync(
401
430
  lp,
402
- JSON.stringify({ pid: 1, command: "sync", startedAt: new Date(0).toISOString(), hqRoot: root }),
431
+ JSON.stringify({ pid: 1, command: "reindex", startedAt: new Date(0).toISOString(), hqRoot: root }),
403
432
  );
404
433
  const before = fs.existsSync(path.join(root, ".claude/skills"));
405
434
  const { status } = reindex({ repoRoot: root, lockTimeoutSec: 0 });
@@ -408,22 +437,24 @@ describe("reindex", () => {
408
437
  expect(fs.existsSync(path.join(root, ".claude/skills"))).toBe(before);
409
438
  });
410
439
 
411
- it("skipLock bypasses the lock (internal sync/rescue caller path)", () => {
412
- const lp = lockPathFor(root);
440
+ it("skipLock bypasses the reindex lock (internal sync/rescue caller path)", () => {
441
+ const lp = lockPathFor(root, "reindex");
413
442
  fs.mkdirSync(path.dirname(lp), { recursive: true });
414
443
  fs.writeFileSync(
415
444
  lp,
416
- JSON.stringify({ pid: 1, command: "sync", startedAt: new Date(0).toISOString(), hqRoot: root }),
445
+ JSON.stringify({ pid: 1, command: "reindex", startedAt: new Date(0).toISOString(), hqRoot: root }),
417
446
  );
418
- // Even with a live holder on record, the internal caller (which already
419
- // holds the lock) runs to completion.
447
+ // Even with a live reindex holder on record, the internal caller (which
448
+ // serializes reindex itself at the tail of a pass) runs to completion.
420
449
  const { status } = reindex({ repoRoot: root, skipLock: true });
421
450
  expect(status).toBe(0);
422
451
  expect(fs.existsSync(path.join(root, ".claude/skills"))).toBe(true);
423
452
  });
424
453
 
425
- it("releases the lock after a normal run (no leftover lock file)", () => {
454
+ it("releases the reindex-scope lock after a normal run (no leftover lock file)", () => {
426
455
  reindex({ repoRoot: root });
456
+ expect(fs.existsSync(lockPathFor(root, "reindex"))).toBe(false);
457
+ // ...and never created the sync/rescue "operation" lock.
427
458
  expect(fs.existsSync(lockPathFor(root))).toBe(false);
428
459
  });
429
460
 
@@ -25,29 +25,45 @@ import {
25
25
  OPERATION_LOCK_UNWRITABLE_EXIT,
26
26
  type LockHandle,
27
27
  } from "../operation-lock.js";
28
+ import {
29
+ emitCloudTelemetry,
30
+ type CloudTelemetryClient,
31
+ type TelemetryClaims,
32
+ } from "../telemetry-events.js";
28
33
 
29
34
  export interface ReindexOptions {
30
35
  /** HQ root to operate on. Defaults to process.cwd(). */
31
36
  repoRoot?: string;
32
37
  /**
33
- * Skip the per-root operation lock. Internal callers (`sync()` / `rescue()`)
34
- * already hold the lock for this root and pass `true` so reindex doesn't try
35
- * to re-acquire it and refuse against their own live PID. Standalone callers
36
- * (`hq reindex`, the reindex hook) leave it falsy so reindex is mutually
37
- * exclusive with a running sync/rescue.
38
+ * Skip reindex's own per-root "reindex"-scope lock. Internal callers
39
+ * (`sync()` / `rescue()`) run reindex inline at the tail of a pass they
40
+ * already serialize, and pass `true` so a standalone `hq reindex` holding the
41
+ * reindex lock never blocks the sync/rescue tail (reindex is idempotent and
42
+ * re-run every pass, so a benign overlap self-heals). Standalone callers
43
+ * (`hq reindex`, the reindex hook) leave it falsy so concurrent reindexes are
44
+ * mutually exclusive.
38
45
  */
39
46
  skipLock?: boolean;
40
47
  /**
41
- * Bounded wait (seconds) for the per-root operation lock when a sync/rescue
42
- * is already running. `0` → refuse immediately; omitted → inherit
48
+ * Bounded wait (seconds) for the per-root "reindex"-scope lock when another
49
+ * reindex is already running. `0` → refuse immediately; omitted → inherit
43
50
  * `HQ_OP_LOCK_TIMEOUT` / infinite wait. Ignored when `skipLock` is set.
44
51
  *
45
- * NB: standalone `hq reindex` waits by default, which is what a human running
46
- * it wants. The hq-core reindex HOOK (Stop / PostToolUse) should set a small
47
- * `HQ_OP_LOCK_TIMEOUT` (or `0`) so a hook fired mid-sync never blocks the
48
- * interactive agent indefinitely.
52
+ * NB: reindex uses a lock scope SEPARATE from sync/rescue's "operation" lock,
53
+ * so a long-lived watch-mode sync-runner (which holds the operation lock for
54
+ * its whole lifetime) can never starve a standalone `hq reindex`
55
+ * (feedback_ed98d810). Standalone `hq reindex` waits by default, which is what
56
+ * a human running it wants; the hq-core reindex HOOK (Stop / PostToolUse)
57
+ * should set a small `HQ_OP_LOCK_TIMEOUT` (or `0`) so a hook fired while
58
+ * another reindex runs never blocks the interactive agent indefinitely.
49
59
  */
50
60
  lockTimeoutSec?: number;
61
+ /** Optional hq-pro ACTION telemetry sink. Omitted in tests/standalone hooks. */
62
+ telemetryClient?: CloudTelemetryClient | null;
63
+ telemetryClaims?: TelemetryClaims | null;
64
+ telemetrySessionId?: string;
65
+ telemetryCompanyUid?: string;
66
+ changedCount?: number;
51
67
  }
52
68
 
53
69
  export interface ReindexResult {
@@ -202,23 +218,32 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
202
218
  const rawRoot = opts.repoRoot ?? process.cwd();
203
219
  if (!isDir(rawRoot)) {
204
220
  warn(`reindex: REPO_ROOT '${rawRoot}' is not a directory`);
205
- return { status: 1 };
221
+ const result = { status: 1 };
222
+ emitReindexTelemetry(opts, result, "not_attempted");
223
+ return result;
206
224
  }
207
225
  const root = path.resolve(rawRoot);
208
226
 
209
- // Acquire the per-root operation lock unless an internal caller (sync/rescue,
210
- // which already hold it) opted out. A live holder refuse fast with the
211
- // holder's command + PID. The whole body runs inside the try so the lock is
212
- // released on every exit path (the process-level signal/exit hooks are the
213
- // crash backstop).
227
+ // Acquire the per-root "reindex"-scope lock unless an internal caller
228
+ // (sync/rescue) opted out. This scope is SEPARATE from sync/rescue's
229
+ // "operation" lock, so a long-lived watch-mode sync-runner holding the
230
+ // operation lock never starves us (feedback_ed98d810) we only contend with
231
+ // other reindexes. A live holder → refuse fast with the holder's command +
232
+ // PID. The whole body runs inside the try so the lock is released on every
233
+ // exit path (the process-level signal/exit hooks are the crash backstop).
214
234
  let opLock: LockHandle | null = null;
215
235
  if (!opts.skipLock) {
216
236
  try {
217
- opLock = acquireOperationLock(root, "reindex", { timeoutSec: opts.lockTimeoutSec });
237
+ opLock = acquireOperationLock(root, "reindex", {
238
+ scope: "reindex",
239
+ timeoutSec: opts.lockTimeoutSec,
240
+ });
218
241
  } catch (err) {
219
242
  if (err instanceof OperationLockedError) {
220
243
  warn(err.message);
221
- return { status: OPERATION_LOCKED_EXIT };
244
+ const result = { status: OPERATION_LOCKED_EXIT };
245
+ emitReindexTelemetry(opts, result, "locked");
246
+ return result;
222
247
  }
223
248
  if (err instanceof OperationLockUnwritableError) {
224
249
  // The lock dir isn't writable (e.g. macOS EPERM / Linux EACCES on a
@@ -227,7 +252,9 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
227
252
  // and exit cleanly instead of crashing with a raw `EPERM ... open`
228
253
  // (HQ-CLI-2).
229
254
  warn(err.message);
230
- return { status: OPERATION_LOCK_UNWRITABLE_EXIT };
255
+ const result = { status: OPERATION_LOCK_UNWRITABLE_EXIT };
256
+ emitReindexTelemetry(opts, result, "unwritable");
257
+ return result;
231
258
  }
232
259
  throw err;
233
260
  }
@@ -548,8 +575,34 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
548
575
  );
549
576
  }
550
577
 
551
- return { status: 0 };
578
+ const result = { status: 0 };
579
+ emitReindexTelemetry(opts, result, opts.skipLock ? "skipped" : "acquired");
580
+ return result;
552
581
  } finally {
553
582
  opLock?.release();
554
583
  }
555
584
  }
585
+
586
+ function emitReindexTelemetry(
587
+ opts: ReindexOptions,
588
+ result: ReindexResult,
589
+ lockResult: string,
590
+ ): void {
591
+ void emitCloudTelemetry(
592
+ opts.telemetryClient,
593
+ {
594
+ eventName: "reindex_run",
595
+ source: "hq-reindex",
596
+ ...(opts.telemetryCompanyUid ? { companyUid: opts.telemetryCompanyUid } : {}),
597
+ ...(opts.telemetrySessionId ? { sessionId: opts.telemetrySessionId } : {}),
598
+ properties: {
599
+ result: result.status === 0 ? "completed" : "failed",
600
+ statusCode: result.status,
601
+ lockResult,
602
+ changedCount: opts.changedCount ?? 0,
603
+ skipLockState: opts.skipLock === true ? "skipped" : "acquired",
604
+ },
605
+ },
606
+ { claims: opts.telemetryClaims },
607
+ );
608
+ }
@@ -1265,8 +1265,8 @@ describe("share", () => {
1265
1265
  });
1266
1266
 
1267
1267
  it("a file over the size limit is a benign skip-size-limit event, NEVER an error (HQ-SYNC-4 exit-2 flood)", async () => {
1268
- // Regression for the fleet-wide "auto-sync watcher exited unexpectedly
1269
- // (code=Some(2))" flood (HQ-SYNC-4, ~93k events across 60+ machines). An
1268
+ // Regression for the "auto-sync watcher exited unexpectedly
1269
+ // (code=Some(2))" flood (HQ-SYNC-4, ~93k events across 60+ user machines). An
1270
1270
  // oversized file is a permanent, benign skip — it exceeds the 50 MB cap on
1271
1271
  // EVERY pass, forever. Emitting it as `type: "error"` pushed it into the
1272
1272
  // runner's `errors[]`, so the pass returned exit 2, which the menubar
@@ -4992,3 +4992,140 @@ describe("currency-gated: journal version 2 fixtures", () => {
4992
4992
  });
4993
4993
  });
4994
4994
  });
4995
+
4996
+ describe("scope-invalid key hardening on push (doubled companies/ tree — incident 2026-07-11)", () => {
4997
+ let tmpDir: string;
4998
+ let stateDir: string;
4999
+
5000
+ beforeEach(() => {
5001
+ clearContextCache();
5002
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-share-poison-test-"));
5003
+ stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-state-poison-test-"));
5004
+ process.env.HQ_STATE_DIR = stateDir;
5005
+ setupFetchMock();
5006
+ // Earlier describes in this file don't all clear call history; reset it
5007
+ // here so uploaded-key assertions below are order-independent.
5008
+ vi.mocked(uploadFile).mockClear();
5009
+ vi.mocked(uploadSymlink).mockClear();
5010
+ vi.mocked(headRemoteFile).mockClear();
5011
+ vi.mocked(deleteRemoteFile).mockClear();
5012
+ vi.mocked(uploadFile).mockResolvedValue({ etag: '"upload-etag"' });
5013
+ vi.mocked(uploadSymlink).mockResolvedValue({ etag: '"upload-symlink-etag"' });
5014
+ vi.mocked(headRemoteFile).mockResolvedValue(null);
5015
+ });
5016
+
5017
+ afterEach(() => {
5018
+ vi.unstubAllGlobals();
5019
+ vi.clearAllMocks();
5020
+ vi.mocked(uploadFile).mockResolvedValue({ etag: '"upload-etag"' });
5021
+ vi.mocked(uploadSymlink).mockResolvedValue({ etag: '"upload-symlink-etag"' });
5022
+ vi.mocked(headRemoteFile).mockResolvedValue(null);
5023
+ fs.rmSync(tmpDir, { recursive: true, force: true });
5024
+ fs.rmSync(stateDir, { recursive: true, force: true });
5025
+ delete process.env.HQ_STATE_DIR;
5026
+ });
5027
+
5028
+ it("refuses to upload doubled-tree companies/ keys in company mode — clear per-key warning, never an error", async () => {
5029
+ // The incident writer: a stale doubled local tree
5030
+ // companies/acme/companies/acme/… would push literal `companies/…` keys
5031
+ // via the direct-S3 STS transport, poisoning the bucket with keys the
5032
+ // presign download API rejects (INVALID_KEY_COMPANIES_SCOPED) for every
5033
+ // other machine. The push planner must refuse them up front.
5034
+ const companyRoot = path.join(tmpDir, "companies", "acme");
5035
+ const doubled = path.join(companyRoot, "companies", "acme", "knowledge");
5036
+ fs.mkdirSync(doubled, { recursive: true });
5037
+ fs.writeFileSync(path.join(doubled, "poison.md"), "poison");
5038
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
5039
+ fs.writeFileSync(path.join(companyRoot, "docs", "good.md"), "good");
5040
+
5041
+ const events: Array<{ type?: string; path?: string }> = [];
5042
+ const result = await share({
5043
+ paths: [companyRoot],
5044
+ company: "acme",
5045
+ vaultConfig: mockConfig,
5046
+ hqRoot: tmpDir,
5047
+ onEvent: (e) => events.push(e as { type?: string }),
5048
+ });
5049
+
5050
+ expect(result.aborted).toBe(false);
5051
+ expect(result.filesUploaded).toBe(1);
5052
+ const uploadedKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
5053
+ expect(uploadedKeys).toEqual(["docs/good.md"]);
5054
+
5055
+ // Per-key warning on the existing event channel, never type:"error"
5056
+ // (an error event flips the runner pass to exit 2 — the flood class).
5057
+ const skipEvents = events.filter((e) => e.type === "skip-invalid-scoped-key");
5058
+ expect(skipEvents.map((e) => e.path)).toEqual([
5059
+ "companies/acme/knowledge/poison.md",
5060
+ ]);
5061
+ expect(events.some((e) => e.type === "error")).toBe(false);
5062
+ });
5063
+
5064
+ it("tombstones (journal drop, no remote HEAD/DELETE) a companies/ journal key in company mode instead of re-uploading", async () => {
5065
+ // Delete-propagation on a poisoned journal entry: HEAD/DeleteObject on a
5066
+ // companies/-prefixed key would be rejected by the presign transport's
5067
+ // server validator, so the delete plan must route it straight to
5068
+ // toTombstone — journal entry drained, no remote call, no re-upload.
5069
+ const companyRoot = path.join(tmpDir, "companies", "acme");
5070
+ fs.mkdirSync(companyRoot, { recursive: true });
5071
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
5072
+ fs.writeFileSync(
5073
+ journalPath,
5074
+ JSON.stringify({
5075
+ version: "1",
5076
+ lastSync: new Date().toISOString(),
5077
+ files: {
5078
+ "companies/acme/knowledge/poison.md": {
5079
+ hash: "poison-hash",
5080
+ size: 6,
5081
+ syncedAt: new Date().toISOString(),
5082
+ direction: "up",
5083
+ remoteEtag: "poison-etag",
5084
+ },
5085
+ },
5086
+ }),
5087
+ );
5088
+
5089
+ const result = await share({
5090
+ paths: [companyRoot],
5091
+ company: "acme",
5092
+ vaultConfig: mockConfig,
5093
+ hqRoot: tmpDir,
5094
+ skipUnchanged: true,
5095
+ propagateDeletes: true,
5096
+ propagateDeletePolicy: "currency-gated",
5097
+ });
5098
+
5099
+ expect(result.aborted).toBe(false);
5100
+ // Tombstoned: journal entry dropped without any remote HEAD or DELETE.
5101
+ expect(result.filesTombstoned).toBe(1);
5102
+ expect(result.filesDeleted).toBe(0);
5103
+ expect(headRemoteFile).not.toHaveBeenCalled();
5104
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
5105
+ expect(uploadFile).not.toHaveBeenCalled();
5106
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
5107
+ expect(journal.files["companies/acme/knowledge/poison.md"]).toBeUndefined();
5108
+ });
5109
+
5110
+ it("personal-mode push still uploads companies/{slug}/ keys (local non-cloud companies are legitimate)", async () => {
5111
+ const localCo = path.join(tmpDir, "companies", "localco");
5112
+ fs.mkdirSync(localCo, { recursive: true });
5113
+ fs.writeFileSync(path.join(localCo, "notes.md"), "n");
5114
+
5115
+ const events: Array<{ type?: string }> = [];
5116
+ const result = await share({
5117
+ paths: [localCo],
5118
+ company: "acme",
5119
+ vaultConfig: mockConfig,
5120
+ hqRoot: tmpDir,
5121
+ personalMode: true,
5122
+ journalSlug: "personal-vault",
5123
+ onEvent: (e) => events.push(e as { type?: string }),
5124
+ });
5125
+
5126
+ expect(result.aborted).toBe(false);
5127
+ const uploadedKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
5128
+ expect(uploadedKeys).toContain("companies/localco/notes.md");
5129
+ expect(events.filter((e) => e.type === "skip-invalid-scoped-key")).toEqual([]);
5130
+ });
5131
+ });
package/src/cli/share.ts CHANGED
@@ -1047,12 +1047,34 @@ function createShareCounters(): ShareCounters {
1047
1047
  }
1048
1048
 
1049
1049
  async function buildSharePlans(run: PushRunContext): Promise<SharePlans> {
1050
- const filesToShare = collectFiles(
1050
+ const collected = collectFiles(
1051
1051
  run.paths,
1052
1052
  run.hqRoot,
1053
1053
  run.syncRoot,
1054
1054
  run.shouldSync,
1055
1055
  );
1056
+ // Scope-invalid key filter (incident 2026-07-11). In company mode the sync
1057
+ // root IS the company folder, so a local entry whose vault key starts with
1058
+ // `companies/` can only come from a stale doubled local tree
1059
+ // (companies/{slug}/companies/{slug}/…). Uploading it poisons the bucket
1060
+ // with keys the server validator rejects (INVALID_KEY_COMPANIES_SCOPED) —
1061
+ // the direct-S3 STS transport bypasses server validation. Refuse them here
1062
+ // with a per-key warning; uploadFile/uploadSymlink's validateVaultUploadKey
1063
+ // is the belt-and-suspenders backstop. The pull leg's journal-tombstone
1064
+ // path then CLEANS the doubled tree (local delete + journal drop) instead
1065
+ // of this leg ever re-uploading it. Personal vaults legitimately carry
1066
+ // `companies/{slug}/…` keys, so this only applies in company mode.
1067
+ const filesToShare =
1068
+ run.options.personalMode === true
1069
+ ? collected
1070
+ : collected.filter((entry) => {
1071
+ if (!entry.relativePath.startsWith("companies/")) return true;
1072
+ run.emit({
1073
+ type: "skip-invalid-scoped-key",
1074
+ path: entry.relativePath,
1075
+ });
1076
+ return false;
1077
+ });
1056
1078
  const pushPlan = computePushPlan(
1057
1079
  filesToShare,
1058
1080
  run.journal,
@@ -1069,6 +1091,7 @@ async function buildSharePlans(run: PushRunContext): Promise<SharePlans> {
1069
1091
  run.shouldSync,
1070
1092
  run.propagateDeletePolicy,
1071
1093
  run.ctx,
1094
+ run.options.personalMode !== true,
1072
1095
  )
1073
1096
  : { toDelete: [], toTombstone: [], refusedStale: [] };
1074
1097
  const decommissionPlan =
@@ -1139,7 +1162,7 @@ async function executeUploads(
1139
1162
  // Emitting it as `type: "error"` pushed it into the runner's `errors[]`,
1140
1163
  // so every watch pass returned exit 2 and the menubar reported an
1141
1164
  // "auto-sync watcher exited unexpectedly (code=Some(2))" crash on every
1142
- // tick (the fleet-wide HQ-SYNC-4 flood). Surface it for visibility, but
1165
+ // tick (the HQ-SYNC-4 flood across user machines). Surface it for visibility, but
1143
1166
  // do not let it flip the pass to a non-zero exit.
1144
1167
  let bytes = 0;
1145
1168
  try {
@@ -1676,6 +1699,10 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
1676
1699
  ` Sample refused paths: ${event.samplePaths.slice(0, 5).join(", ")}${event.samplePaths.length > 5 ? ", …" : ""}\n` +
1677
1700
  ` To proceed anyway: re-run with HQ_SYNC_DELETE_BULK_OVERRIDE=1 (or propagateDeletePolicy:"all").`,
1678
1701
  );
1702
+ } else if (event.type === "skip-invalid-scoped-key") {
1703
+ console.warn(
1704
+ ` ! ${event.path} skipped — 'companies/'-prefixed key is invalid in a company-scoped vault (stale doubled local tree? remove the inner companies/<slug> copy)`,
1705
+ );
1679
1706
  } else if (event.type === "ignore-excluded") {
1680
1707
  // The "not silent" surface: an ignore rule dropped content that doesn't
1681
1708
  // look like expected build/VCS/cache noise, so it never reached the vault.
@@ -2178,6 +2205,11 @@ async function computeDeletePlan(
2178
2205
  shouldSync: (filePath: string, isDir?: boolean) => boolean,
2179
2206
  policy: "currency-gated" | "owned-only" | "all",
2180
2207
  ctx: EntityContext,
2208
+ // True for a COMPANY-scoped push (personalMode !== true): the sync root is
2209
+ // the company folder and journal keys are bucket-relative, so a literal
2210
+ // `companies/…` key is scope-invalid (see the poisoned-key branch below).
2211
+ // Personal-vault pushes legitimately journal `companies/{slug}/…` keys.
2212
+ companyScoped: boolean = true,
2181
2213
  ): Promise<DeletePlan> {
2182
2214
  const plan: DeletePlan = { toDelete: [], toTombstone: [], refusedStale: [] };
2183
2215
  if (scopeRoots.length === 0) return plan;
@@ -2249,6 +2281,20 @@ async function computeDeletePlan(
2249
2281
  continue;
2250
2282
  }
2251
2283
 
2284
+ // Scope-invalid journal keys (incident 2026-07-11): in a COMPANY-scoped
2285
+ // context, a journal entry at a literal `companies/…` key records a
2286
+ // doubled-tree poisoning upload. HEAD/DeleteObject on such a key via the
2287
+ // presign transport is rejected by the server validator
2288
+ // (INVALID_KEY_COMPANIES_SCOPED) and would error the push, so route it
2289
+ // straight to `toTombstone` (journal drop, no remote call) — the local
2290
+ // journal entry drains; server-side cleanup of any poisoned object is an
2291
+ // operator action. Personal-vault pushes (personalMode) carry legitimate
2292
+ // `companies/{slug}/…` keys and are unaffected (companyScoped=false).
2293
+ if (companyScoped && relativeKey.startsWith("companies/")) {
2294
+ plan.toTombstone.push(relativeKey);
2295
+ continue;
2296
+ }
2297
+
2252
2298
  if (!shouldSync(localPath, false) && !shouldSync(localPath, true)) continue;
2253
2299
  // Ephemeral artifacts (conflict mirrors) never propagate-delete via the
2254
2300
  // normal path — see EPHEMERAL_PATH_PATTERN doc. NOTE: this is a no-op