@indigoai-us/hq-cloud 6.14.1 → 6.14.3

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 (175) 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 +85 -12
  27. package/dist/cli/reindex.js.map +1 -1
  28. package/dist/cli/reindex.test.js +95 -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 +5 -3
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +5 -2
  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/personal-vault.d.ts +29 -1
  78. package/dist/personal-vault.d.ts.map +1 -1
  79. package/dist/personal-vault.js +48 -2
  80. package/dist/personal-vault.js.map +1 -1
  81. package/dist/personal-vault.test.js +44 -1
  82. package/dist/personal-vault.test.js.map +1 -1
  83. package/dist/s3.d.ts +1 -0
  84. package/dist/s3.d.ts.map +1 -1
  85. package/dist/s3.js +75 -2
  86. package/dist/s3.js.map +1 -1
  87. package/dist/s3.test.js +56 -1
  88. package/dist/s3.test.js.map +1 -1
  89. package/dist/scope-shrink.d.ts +12 -0
  90. package/dist/scope-shrink.d.ts.map +1 -1
  91. package/dist/scope-shrink.js +12 -0
  92. package/dist/scope-shrink.js.map +1 -1
  93. package/dist/scope-shrink.test.js +31 -0
  94. package/dist/scope-shrink.test.js.map +1 -1
  95. package/dist/skill-telemetry.d.ts +41 -0
  96. package/dist/skill-telemetry.d.ts.map +1 -1
  97. package/dist/skill-telemetry.js +111 -7
  98. package/dist/skill-telemetry.js.map +1 -1
  99. package/dist/skill-telemetry.test.js +187 -5
  100. package/dist/skill-telemetry.test.js.map +1 -1
  101. package/dist/sync/event-sync.d.ts +3 -0
  102. package/dist/sync/event-sync.d.ts.map +1 -1
  103. package/dist/sync/event-sync.js +23 -0
  104. package/dist/sync/event-sync.js.map +1 -1
  105. package/dist/sync/pull-scope.d.ts +21 -0
  106. package/dist/sync/pull-scope.d.ts.map +1 -1
  107. package/dist/sync/pull-scope.js +46 -13
  108. package/dist/sync/pull-scope.js.map +1 -1
  109. package/dist/telemetry-events.d.ts +31 -0
  110. package/dist/telemetry-events.d.ts.map +1 -0
  111. package/dist/telemetry-events.js +140 -0
  112. package/dist/telemetry-events.js.map +1 -0
  113. package/dist/telemetry-events.test.d.ts +2 -0
  114. package/dist/telemetry-events.test.d.ts.map +1 -0
  115. package/dist/telemetry-events.test.js +73 -0
  116. package/dist/telemetry-events.test.js.map +1 -0
  117. package/dist/telemetry.js +1 -1
  118. package/dist/telemetry.js.map +1 -1
  119. package/dist/vault-client.d.ts +38 -5
  120. package/dist/vault-client.d.ts.map +1 -1
  121. package/dist/vault-client.js +69 -6
  122. package/dist/vault-client.js.map +1 -1
  123. package/dist/vault-client.test.js +31 -1
  124. package/dist/vault-client.test.js.map +1 -1
  125. package/dist/watcher.d.ts +7 -0
  126. package/dist/watcher.d.ts.map +1 -1
  127. package/dist/watcher.js +49 -1
  128. package/dist/watcher.js.map +1 -1
  129. package/dist/watcher.test.js +49 -0
  130. package/dist/watcher.test.js.map +1 -1
  131. package/package.json +1 -1
  132. package/pnpm-workspace.yaml +2 -0
  133. package/scripts/presign-transport-e2e.mjs +2 -2
  134. package/src/bin/sync-runner-company.ts +65 -1
  135. package/src/bin/sync-runner-watch-loop.ts +24 -8
  136. package/src/bin/sync-runner.test.ts +194 -16
  137. package/src/bin/sync-runner.ts +209 -11
  138. package/src/cli/accept.ts +27 -0
  139. package/src/cli/invite.ts +31 -0
  140. package/src/cli/promote.ts +31 -0
  141. package/src/cli/reindex.test.ts +113 -13
  142. package/src/cli/reindex.ts +123 -23
  143. package/src/cli/share.test.ts +139 -2
  144. package/src/cli/share.ts +48 -2
  145. package/src/cli/sync-scope.test.ts +90 -0
  146. package/src/cli/sync.test.ts +154 -0
  147. package/src/cli/sync.ts +224 -5
  148. package/src/company-resolver.test.ts +51 -0
  149. package/src/company-resolver.ts +40 -5
  150. package/src/ignore.test.ts +18 -1
  151. package/src/ignore.ts +24 -2
  152. package/src/index.ts +24 -1
  153. package/src/journal.ts +12 -0
  154. package/src/lib/exit-codes.ts +24 -0
  155. package/src/object-io.test.ts +66 -1
  156. package/src/object-io.ts +51 -14
  157. package/src/operation-lock.test.ts +33 -0
  158. package/src/operation-lock.ts +46 -11
  159. package/src/personal-vault.test.ts +57 -0
  160. package/src/personal-vault.ts +48 -2
  161. package/src/s3.test.ts +87 -0
  162. package/src/s3.ts +93 -2
  163. package/src/scope-shrink.test.ts +32 -0
  164. package/src/scope-shrink.ts +12 -0
  165. package/src/skill-telemetry.test.ts +247 -4
  166. package/src/skill-telemetry.ts +134 -6
  167. package/src/sync/event-sync.ts +37 -0
  168. package/src/sync/pull-scope.ts +62 -13
  169. package/src/telemetry-events.test.ts +88 -0
  170. package/src/telemetry-events.ts +205 -0
  171. package/src/telemetry.ts +1 -1
  172. package/src/vault-client.test.ts +39 -1
  173. package/src/vault-client.ts +115 -14
  174. package/src/watcher.test.ts +70 -0
  175. package/src/watcher.ts +68 -0
package/src/index.ts CHANGED
@@ -152,7 +152,11 @@ export type {
152
152
  // the SAME effective scope the menubar runner does, instead of defaulting every
153
153
  // CLI pull to `syncMode: "all"` (the seed of the all→shared scope-shrink wedge,
154
154
  // DEV-1768).
155
- export { resolvePullScope, readPinnedPrefixes } from "./sync/pull-scope.js";
155
+ export {
156
+ resolvePullScope,
157
+ readPinnedPrefixes,
158
+ SESSIONS_SCOPE_PREFIX,
159
+ } from "./sync/pull-scope.js";
156
160
  export type { PullScope, PullScopeClient } from "./sync/pull-scope.js";
157
161
 
158
162
  // Personal-vault scope helpers — shared between hq-sync-runner and `hq sync`
@@ -160,9 +164,11 @@ export {
160
164
  PERSONAL_VAULT_EXCLUDED_TOP_LEVEL,
161
165
  PERSONAL_VAULT_COMPANY_EXCLUDED_SLUGS,
162
166
  CONTINUITY_POINTER_REL,
167
+ SESSION_LOGS_SYNC_REL,
163
168
  computePersonalVaultPaths,
164
169
  computePersonalCompanySubdirs,
165
170
  computeContinuityPointerPaths,
171
+ computeSessionLogsSyncPaths,
166
172
  } from "./personal-vault.js";
167
173
  export type { PersonalVaultOptions } from "./personal-vault.js";
168
174
 
@@ -200,6 +206,9 @@ export type {
200
206
  TelemetryOptInResponse,
201
207
  UsageBatch,
202
208
  UsageIngestResult,
209
+ RawTelemetryEventInput,
210
+ TelemetryEventsBatch,
211
+ TelemetryEventsIngestResult,
203
212
  SkillInvocationBatch,
204
213
  SkillInvocationIngestResult,
205
214
  // US-004 — browse-vs-sync membership sync-mode + ACL surface
@@ -219,6 +228,20 @@ export type {
219
228
  TelemetryClientSurface,
220
229
  } from "./telemetry.js";
221
230
 
231
+ // hq-cloud ACTION telemetry (`/v1/telemetry/events`). Unlike token/skill
232
+ // collectors, these sync/CLI events are not gated by `/v1/usage` opt-in.
233
+ export {
234
+ agentUidFromClaims,
235
+ emitCloudTelemetry,
236
+ } from "./telemetry-events.js";
237
+ export type {
238
+ CloudTelemetryClient,
239
+ EmitCloudTelemetryOptions,
240
+ TelemetryClaims,
241
+ TelemetryEvent,
242
+ TelemetryProperties,
243
+ } from "./telemetry-events.js";
244
+
222
245
  // Skill-invocation telemetry collector (`/v1/skill-invocations`). Reads the
223
246
  // same Claude Code session logs as the token collector but extracts which
224
247
  // skill / slash-command was invoked. Independent cursor; same opt-in gate.
package/src/journal.ts CHANGED
@@ -404,6 +404,18 @@ export function hashSymlinkTarget(target: string): string {
404
404
  .digest("hex");
405
405
  }
406
406
 
407
+ /**
408
+ * Record a per-file journal entry after a transfer.
409
+ *
410
+ * `direction` (`"up"` = pushed / locally authored, `"down"` = pulled) is what
411
+ * lets scope-shrink tell your own work apart from a mirror of someone else's.
412
+ * It underpins the US-006 push-only sessions contract: a session transcript
413
+ * this machine authored and pushed is stamped `direction:"up"`, so even though
414
+ * `sessions/` is excluded from every pull scope, `buildScopeShrinkPlan` skips
415
+ * `direction:"up"` entries and never orphans it. (Sessions fetched on demand
416
+ * via `hq files get` are pulled — `direction:"down"` — but ride the pin union
417
+ * in the caller's inclusion prefixSet, so they are likewise never pruned.)
418
+ */
407
419
  export function updateEntry(
408
420
  journal: SyncJournal,
409
421
  relativePath: string,
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Runner exit code for a partial sync pass: the pass ran to completion but one
3
+ * or more per-file transfers threw (`errors.length > 0` in the all-complete
4
+ * aggregate). Deterministic per-file failures — an oversized object on a
5
+ * pre-skip runner, a presign denial, an expired token late in a long pass, a
6
+ * corrupt doubly-scoped vault key — all land here.
7
+ *
8
+ * A one-shot `hq sync` exits with this code so humans and scripts see the
9
+ * failure. The watch loop must NOT exit on it: auto-sync runs unattended, and
10
+ * a watcher that dies on every partial pass gets hot-respawned by the menubar
11
+ * supervisor, which Sentry-captures each death as "auto-sync watcher exited
12
+ * unexpectedly (code=Some(2))". Because the watcher typically runs healthily
13
+ * for minutes before the failing pass, the supervisor's fast-crash-loop
14
+ * dampening never engages and EVERY death ships an event — the July 2026
15
+ * #hq-sentry-alerts flood (~1,700 events across 60+ machines in 5 days).
16
+ * Instead the loop logs, stays alive, and re-attempts on the next tick; the
17
+ * per-file errors still surface through the ndjson `error` events and the
18
+ * `partial: true` all-complete aggregate.
19
+ *
20
+ * Kept off the other codes in use by the runner: 0 ok, 1 hard error, 17
21
+ * op-locked (`OPERATION_LOCKED_EXIT`), 75 transient network
22
+ * (`TRANSIENT_NETWORK_EXIT`).
23
+ */
24
+ export const PARTIAL_SYNC_EXIT = 2;
@@ -234,13 +234,78 @@ describe("PresignObjectIO.putObject", () => {
234
234
  // per-path skip (scopeExcludedSet) + the "N paths skipped — outside your
235
235
  // granted access" aggregate, instead of a fatal `type:"error"` that exits
236
236
  // the runner non-zero and floods Sentry. This is the PUT/GET symmetry that
237
- // fixes the fleet-wide presign-put-denied crash-loop.
237
+ // fixes the presign-put-denied crash-loop across user machines.
238
238
  expect((caught as Error).name).toBe("Forbidden");
239
239
  expect(isAccessDenied(caught)).toBe(true);
240
240
  // Denied at mint time — the byte-moving PUT is never attempted.
241
241
  expect(fetchMock).not.toHaveBeenCalled();
242
242
  });
243
243
 
244
+ it("US-006 AC5: a transcript-consent denial (FILES_PRESIGN_TRANSCRIPT_CONSENT_REQUIRED) is skip-with-log, not fatal", async () => {
245
+ const { vault, setPresign } = makeVault();
246
+ // The US-005 consent code ends `_REQUIRED`, not `_FORBIDDEN`, so the plain
247
+ // suffix test alone would MISS it and let it fall through to the fatal
248
+ // generic Error — the recurring partial-failure this AC5 fix prevents.
249
+ setPresign([
250
+ {
251
+ key: "sessions/prs_1/2026-07-11T10-00-00.json",
252
+ op: "get",
253
+ error: "transcript consent required",
254
+ code: "FILES_PRESIGN_TRANSCRIPT_CONSENT_REQUIRED",
255
+ },
256
+ ]);
257
+ const io = new PresignObjectIO(vault, COMPANY);
258
+ // Monkeypatch console.warn (vitest's console interception can shadow a spy)
259
+ // to assert the REAL skip log fires.
260
+ const warnings: string[] = [];
261
+ const originalWarn = console.warn;
262
+ console.warn = (...args: unknown[]) => {
263
+ warnings.push(args.map(String).join(" "));
264
+ };
265
+ let caught: unknown;
266
+ try {
267
+ await io
268
+ .getObject("sessions/prs_1/2026-07-11T10-00-00.json")
269
+ .catch((e) => {
270
+ caught = e;
271
+ });
272
+ } finally {
273
+ console.warn = originalWarn;
274
+ }
275
+ // Same shape a denied GET already throws → isAccessDenied → the per-key
276
+ // scope-excluded skip, NOT a fatal `type:"error"` that poisons the run.
277
+ expect((caught as Error).name).toBe("Forbidden");
278
+ expect(isAccessDenied(caught)).toBe(true);
279
+ // A one-line skip log fires so operators can see WHY the key was skipped.
280
+ expect(
281
+ warnings.some((w) =>
282
+ w.includes("FILES_PRESIGN_TRANSCRIPT_CONSENT_REQUIRED"),
283
+ ),
284
+ ).toBe(true);
285
+ });
286
+
287
+ it("US-006 AC5: a sessions structural-ACL denial (FILES_PRESIGN_SESSIONS_FORBIDDEN) is skip-with-log", async () => {
288
+ const { vault, setPresign } = makeVault();
289
+ // A spoofed/foreign `sessions/{uid}/` prefix the hq-pro structural gate
290
+ // refuses to presign. Ends `_FORBIDDEN`, so the suffix test already covers
291
+ // it — this pins that coverage as a regression contract for US-006.
292
+ setPresign([
293
+ {
294
+ key: "sessions/prs_other/y.json",
295
+ op: "get",
296
+ error: "not your session prefix",
297
+ code: "FILES_PRESIGN_SESSIONS_FORBIDDEN",
298
+ },
299
+ ]);
300
+ const io = new PresignObjectIO(vault, COMPANY);
301
+ let caught: unknown;
302
+ await io.getObject("sessions/prs_other/y.json").catch((e) => {
303
+ caught = e;
304
+ });
305
+ expect((caught as Error).name).toBe("Forbidden");
306
+ expect(isAccessDenied(caught)).toBe(true);
307
+ });
308
+
244
309
  it("a non-authorization presign failure stays a generic (fatal) error — not silently skipped", async () => {
245
310
  const { vault, setPresign } = makeVault();
246
311
  setPresign([
package/src/object-io.ts CHANGED
@@ -344,6 +344,35 @@ function metaFromHeaders(headers: Headers): Record<string, string> {
344
344
  return meta;
345
345
  }
346
346
 
347
+ /**
348
+ * Per-key presign denial codes that are EXPECTED and must be reclassified as
349
+ * skip-with-log — throwing the `name: "Forbidden"` shape (see
350
+ * {@link accessDeniedError}) so `isAccessDenied` in sync.ts / share.ts routes
351
+ * them into the silent per-key scope-excluded skip, never a FATAL sync error.
352
+ *
353
+ * Two families qualify:
354
+ * 1. Any `*_FORBIDDEN` code — an authorization/ACL denial. Covers
355
+ * FILES_PRESIGN_FORBIDDEN (no write on a shared prefix), FILES_RAW_FORBIDDEN,
356
+ * and — the structural sessions-ACL rejection this US-006 phase relies on —
357
+ * FILES_PRESIGN_SESSIONS_FORBIDDEN (a spoofed/foreign `sessions/{uid}/`
358
+ * prefix the server refuses to presign).
359
+ * 2. FILES_PRESIGN_TRANSCRIPT_CONSENT_REQUIRED — the US-005 transcript-consent
360
+ * denial. It ends `_REQUIRED`, not `_FORBIDDEN`, so the suffix test alone
361
+ * would MISS it and let it fall through to the fatal generic Error below,
362
+ * re-introducing exactly the recurring partial-failure that poisons every
363
+ * subsequent sync run (US-006 AC5).
364
+ */
365
+ const SKIP_WITH_LOG_DENIAL_CODES: ReadonlySet<string> = new Set([
366
+ "FILES_PRESIGN_TRANSCRIPT_CONSENT_REQUIRED",
367
+ ]);
368
+
369
+ function isSkipWithLogDenialCode(code: unknown): code is string {
370
+ return (
371
+ typeof code === "string" &&
372
+ (code.endsWith("_FORBIDDEN") || SKIP_WITH_LOG_DENIAL_CODES.has(code))
373
+ );
374
+ }
375
+
347
376
  function firstRowOrThrow(
348
377
  results: PresignResultRow[],
349
378
  key: string,
@@ -354,22 +383,30 @@ function firstRowOrThrow(
354
383
  throw new Error(`presign ${op} returned no row for ${key}`);
355
384
  }
356
385
  if (row.error || !row.url) {
357
- // An authorization denial (server `*_FORBIDDEN` code — e.g.
358
- // FILES_PRESIGN_FORBIDDEN when the caller lacks write on a shared prefix,
359
- // or FILES_RAW_FORBIDDEN) must throw the SAME `name: "Forbidden"` shape the
360
- // GET/HEAD path uses (see {@link accessDeniedError}) so it routes through
361
- // the `isAccessDenied` skip in sync.ts / share.ts. Without this, a denied
362
- // PUT presign fell to the generic Error below `isAccessDenied` returned
363
- // false and the per-file ACL skip the GET path handles gracefully became
364
- // a FATAL sync error: the crash-loop + Sentry flood behind the fleet-wide
365
- // "presign put denied" incident (members pushing net-new keys to a
366
- // company-wide-read prefix under private-by-default). The denial is
367
- // expected; only the fatal handling of it was the bug.
368
- if (typeof row.code === "string" && row.code.endsWith("_FORBIDDEN")) {
386
+ // An expected per-key denial (see {@link isSkipWithLogDenialCode}) must
387
+ // throw the SAME `name: "Forbidden"` shape the GET/HEAD path uses so it
388
+ // routes through the `isAccessDenied` skip in sync.ts / share.ts. Without
389
+ // this, a denied presign fell to the generic Error below — `isAccessDenied`
390
+ // returned false and the per-file ACL skip the GET path handles gracefully
391
+ // became a FATAL sync error: the crash-loop + Sentry flood behind the
392
+ // widespread "presign put denied" incident (members pushing net-new keys to
393
+ // a company-wide-read prefix under private-by-default), and for the
394
+ // sessions/consent codes a recurring partial-failure that poisoned every
395
+ // subsequent sync run (US-006 AC5). The denial is expected; only the fatal
396
+ // handling of it was the bug. Log a one-line skip so the reason is visible
397
+ // without the run failing.
398
+ if (isSkipWithLogDenialCode(row.code)) {
399
+ // Shared presign layer: this fires for sync legs AND for non-sync callers
400
+ // (hq files get, signals/sources internals) where the denial surfaces as a
401
+ // user-facing error, not a continued sync. State only what THIS layer did
402
+ // (denied → skipped this row) and let the caller own any "sync continues"
403
+ // / error framing — don't assert an outcome the caller may not take.
404
+ console.warn(
405
+ `presign ${op} denied for '${key}' (${row.code}) — skipped`,
406
+ );
369
407
  throw accessDeniedError(
370
408
  key,
371
- `no write access to '${key}' (server ${op} denied: ${row.code}) — ` +
372
- `ask an admin to grant write on this shared prefix`,
409
+ `no ${op} access to '${key}' (server ${op} denied: ${row.code})`,
373
410
  );
374
411
  }
375
412
  throw new Error(
@@ -73,6 +73,39 @@ describe("operation-lock", () => {
73
73
  expect(lockPathFor(rootA + path.sep)).toBe(a);
74
74
  });
75
75
 
76
+ it("scopes partition the lock: same root, different scope → different file, no mutual block", () => {
77
+ // feedback_ed98d810: reindex must not be starved by a long-lived sync/watch
78
+ // holder. Distinct scopes hash to distinct lock files and never block each
79
+ // other, while the same scope stays a real cross-process mutex.
80
+ const opPath = lockPathFor(rootA); // default "operation" scope
81
+ const reindexPath = lockPathFor(rootA, "reindex");
82
+ expect(reindexPath).not.toBe(opPath);
83
+ expect(path.basename(reindexPath).startsWith("reindex-")).toBe(true);
84
+ expect(path.basename(opPath).startsWith("operation-")).toBe(true);
85
+
86
+ let opHandle: ReturnType<typeof acquireOperationLock> | undefined;
87
+ let reindexHandle: ReturnType<typeof acquireOperationLock> | undefined;
88
+ try {
89
+ // A live "operation" holder (as a watch-mode runner keeps) does NOT block
90
+ // a "reindex"-scope acquire, even with no wait.
91
+ opHandle = acquireOperationLock(rootA, "sync");
92
+ expect(() => {
93
+ reindexHandle = acquireOperationLock(rootA, "reindex", {
94
+ scope: "reindex",
95
+ wait: false,
96
+ });
97
+ }).not.toThrow();
98
+ expect(reindexHandle && fs.existsSync(reindexHandle.path)).toBe(true);
99
+ // ...but a second acquire on the SAME "reindex" scope is still refused.
100
+ expect(() =>
101
+ acquireOperationLock(rootA, "reindex", { scope: "reindex", wait: false }),
102
+ ).toThrowError(OperationLockedError);
103
+ } finally {
104
+ reindexHandle?.release();
105
+ opHandle?.release();
106
+ }
107
+ });
108
+
76
109
  it("F14: symlink aliases share the same root lock", () => {
77
110
  const aliasParent = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rootA-link-"));
78
111
  const aliasRoot = path.join(aliasParent, "alias");
@@ -3,10 +3,19 @@
3
3
  * (`sync`, `rescue`, `reindex`).
4
4
  *
5
5
  * Contract:
6
- * - At most ONE of sync / rescue / reindex runs at a time **per HQ root**.
7
- * The lock is shared across all three (keyed only by the root, not the
8
- * command), so e.g. a rescue refuses while a sync holds it. Different HQ
9
- * roots are fully independent — they hash to different lock files.
6
+ * - `sync` and `rescue` share ONE per-root lock (the "operation" scope, keyed
7
+ * only by the root, not the command), so at most one of them runs at a time
8
+ * and e.g. a rescue refuses while a sync holds it. Different HQ roots are
9
+ * fully independent — they hash to different lock files.
10
+ * - `reindex` uses a SEPARATE per-root scope ("reindex"), so it is guarded
11
+ * against other reindexes but is NOT blocked by a held sync/rescue lock.
12
+ * This is deliberate: a watch-mode `hq-sync-runner` holds the "operation"
13
+ * lock across its entire lifetime, and a shared lock would starve a
14
+ * standalone `hq reindex` indefinitely (feedback_ed98d810). reindex only
15
+ * READS the source trees to rebuild derived artifacts (skill wrappers,
16
+ * overlay mirrors, the workers registry) and is idempotent + re-run after
17
+ * every sync pass, so decoupling it from the sync writer lock is safe.
18
+ * See {@link lockPathFor} for how the scope partitions the lock file.
10
19
  * - The push watcher / watch+event-push runner is EXEMPT: it never calls in
11
20
  * here, so it neither takes the lock nor is blocked by it (its targeted
12
21
  * in-process push passes are likewise lock-free).
@@ -202,6 +211,13 @@ export interface AcquireOptions {
202
211
  * (or a no-op) to redirect/silence the status line.
203
212
  */
204
213
  onWaitStart?: (holder: LockInfo, attempted: string) => void;
214
+ /**
215
+ * Lock scope — partitions the per-root mutex into independent lock files (see
216
+ * {@link lockPathFor}). Defaults to {@link DEFAULT_LOCK_SCOPE} ("operation"),
217
+ * which `sync`/`rescue` share. `reindex` passes "reindex" so it is guarded
218
+ * against other reindexes without being blocked by a held sync/rescue lock.
219
+ */
220
+ scope?: string;
205
221
  }
206
222
 
207
223
  interface ResolvedWaitConfig {
@@ -280,11 +296,26 @@ function canonicalRoot(hqRoot: string): string {
280
296
  }
281
297
  }
282
298
 
283
- /** Absolute lock path for a given HQ root. Exported for tests. */
284
- export function lockPathFor(hqRoot: string): string {
299
+ /**
300
+ * Default lock scope. `sync` and `rescue` share this one so they stay mutually
301
+ * exclusive with each other, keyed only by the root.
302
+ */
303
+ export const DEFAULT_LOCK_SCOPE = "operation";
304
+
305
+ /**
306
+ * Absolute lock path for a given HQ root and `scope`. Exported for tests.
307
+ *
308
+ * The `scope` prefixes the lock filename so callers can partition the mutex:
309
+ * `sync`/`rescue` use {@link DEFAULT_LOCK_SCOPE} ("operation"); `reindex` uses
310
+ * its own "reindex" scope so a long-lived watch-mode sync-runner (which holds
311
+ * the "operation" lock across its whole lifetime) can never starve a standalone
312
+ * `hq reindex`. Different scopes hash to different lock files and never block
313
+ * one another; the same scope is a real cross-process mutex.
314
+ */
315
+ export function lockPathFor(hqRoot: string, scope: string = DEFAULT_LOCK_SCOPE): string {
285
316
  const canon = canonicalRoot(hqRoot);
286
317
  const key = crypto.createHash("sha1").update(canon).digest("hex").slice(0, 16);
287
- return path.join(stateDir(), "locks", `operation-${key}.lock`);
318
+ return path.join(stateDir(), "locks", `${scope}-${key}.lock`);
288
319
  }
289
320
 
290
321
  /**
@@ -425,8 +456,12 @@ export function rethrowLockCreateError(err: unknown, lockDir: string): never {
425
456
  }
426
457
 
427
458
  /** Build the lock payload + ensure the locks dir exists. */
428
- function prepareLock(hqRoot: string, command: string): { p: string; info: LockInfo; payload: string } {
429
- const p = lockPathFor(hqRoot);
459
+ function prepareLock(
460
+ hqRoot: string,
461
+ command: string,
462
+ scope: string = DEFAULT_LOCK_SCOPE,
463
+ ): { p: string; info: LockInfo; payload: string } {
464
+ const p = lockPathFor(hqRoot, scope);
430
465
  const dir = path.dirname(p);
431
466
  try {
432
467
  fs.mkdirSync(dir, { recursive: true });
@@ -565,7 +600,7 @@ export function acquireOperationLock(
565
600
  ): LockHandle {
566
601
  if (process.env.HQ_DISABLE_OP_LOCK === "1") return disabledHandle(hqRoot, command);
567
602
 
568
- const { p, info, payload } = prepareLock(hqRoot, command);
603
+ const { p, info, payload } = prepareLock(hqRoot, command, opts.scope);
569
604
  const cfg = resolveWaitConfig(opts);
570
605
  const deadline = cfg.timeoutMs === null ? null : Date.now() + cfg.timeoutMs;
571
606
  let announced = false;
@@ -596,7 +631,7 @@ export async function acquireOperationLockAsync(
596
631
  ): Promise<LockHandle> {
597
632
  if (process.env.HQ_DISABLE_OP_LOCK === "1") return disabledHandle(hqRoot, command);
598
633
 
599
- const { p, info, payload } = prepareLock(hqRoot, command);
634
+ const { p, info, payload } = prepareLock(hqRoot, command, opts.scope);
600
635
  const cfg = resolveWaitConfig(opts);
601
636
  const deadline = cfg.timeoutMs === null ? null : Date.now() + cfg.timeoutMs;
602
637
  let announced = false;
@@ -25,6 +25,8 @@ import {
25
25
  CONTINUITY_POINTER_REL,
26
26
  computeAgencySyncPaths,
27
27
  AGENCY_SYNC_REL,
28
+ computeSessionLogsSyncPaths,
29
+ SESSION_LOGS_SYNC_REL,
28
30
  PERSONAL_VAULT_COMPANY_EXCLUDED_SLUGS,
29
31
  PERSONAL_VAULT_EXCLUDED_TOP_LEVEL,
30
32
  } from "./personal-vault.js";
@@ -628,3 +630,58 @@ describe("personal-vault: agency-workspace carve-out", () => {
628
630
  );
629
631
  });
630
632
  });
633
+
634
+ // ─────────────────────────────────────────────────────────────────────────
635
+ // Session-logs carve-out (workspace/.session-logs) — reindex-captured Claude
636
+ // Code transcripts pierce the workspace/ exclusion and round-trip to the vault.
637
+ // ─────────────────────────────────────────────────────────────────────────
638
+ describe("personal-vault: session-logs carve-out", () => {
639
+ let hqRoot: string;
640
+ beforeEach(() => {
641
+ hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sesslog-test-"));
642
+ });
643
+ afterEach(() => {
644
+ fs.rmSync(hqRoot, { recursive: true, force: true });
645
+ });
646
+ const relAll = (abs: string[]): string[] =>
647
+ abs.map((p) => path.relative(hqRoot, p)).sort();
648
+
649
+ it("constant: SESSION_LOGS_SYNC_REL is workspace/.session-logs", () => {
650
+ expect(SESSION_LOGS_SYNC_REL).toBe("workspace/.session-logs");
651
+ });
652
+
653
+ it("includes workspace/.session-logs when it exists", () => {
654
+ fs.mkdirSync(path.join(hqRoot, "workspace", ".session-logs", "tool-results"), {
655
+ recursive: true,
656
+ });
657
+ expect(relAll(computeSessionLogsSyncPaths(hqRoot))).toEqual([
658
+ path.join("workspace", ".session-logs"),
659
+ ]);
660
+ });
661
+
662
+ it("empty when workspace/.session-logs is absent (fail-soft)", () => {
663
+ fs.mkdirSync(path.join(hqRoot, "workspace"), { recursive: true });
664
+ expect(computeSessionLogsSyncPaths(hqRoot)).toEqual([]);
665
+ });
666
+
667
+ it("composes into computePersonalVaultPaths without broadening workspace/", () => {
668
+ fs.mkdirSync(path.join(hqRoot, "workspace", ".session-logs"), { recursive: true });
669
+ fs.writeFileSync(
670
+ path.join(hqRoot, "workspace", ".session-logs", "session.jsonl"),
671
+ "l\n",
672
+ );
673
+ // A sibling under workspace/ that must STAY local.
674
+ fs.mkdirSync(path.join(hqRoot, "workspace", "locks"), { recursive: true });
675
+ fs.writeFileSync(path.join(hqRoot, "workspace", "locks", "x.lock"), "1");
676
+ fs.mkdirSync(path.join(hqRoot, ".claude"), { recursive: true });
677
+
678
+ const out = relAll(computePersonalVaultPaths(hqRoot));
679
+ expect(out).toContain(path.join("workspace", ".session-logs"));
680
+ expect(out).toContain(".claude");
681
+ // The carve-out is exactly the .session-logs subtree — no other workspace/
682
+ // sibling is pulled in.
683
+ expect(out.some((p) => p.startsWith(path.join("workspace", "locks")))).toBe(
684
+ false,
685
+ );
686
+ });
687
+ });
@@ -26,7 +26,12 @@
26
26
  * the routing source-of-truth.)
27
27
  * - `repos/`, `workspace/`: per user directive — heavy local-only
28
28
  * content (cloned remotes, session threads) that has no business in
29
- * the personal vault.
29
+ * the personal vault. Three explicit sub-paths pierce this exclusion and
30
+ * DO round-trip: `workspace/threads/handoff.json` (+ its active thread) via
31
+ * {@link computeContinuityPointerPaths}, `workspace/agency/` via
32
+ * {@link computeAgencySyncPaths}, and `workspace/.session-logs/` (the
33
+ * reindex-captured Claude Code transcripts) via
34
+ * {@link computeSessionLogsSyncPaths}.
30
35
  *
31
36
  * Note: `core/`, `data/`, and `personal/` were previously excluded but are
32
37
  * INCLUDED as of user directive 2026-05-13. `core/` ships the hq-core
@@ -134,7 +139,15 @@ export function computePersonalVaultPaths(
134
139
  );
135
140
  const continuity = computeContinuityPointerPaths(hqRoot);
136
141
  const agency = computeAgencySyncPaths(hqRoot);
137
- return [...topLevel, ...manifest, ...companySubdirs, ...continuity, ...agency];
142
+ const sessionLogs = computeSessionLogsSyncPaths(hqRoot);
143
+ return [
144
+ ...topLevel,
145
+ ...manifest,
146
+ ...companySubdirs,
147
+ ...continuity,
148
+ ...agency,
149
+ ...sessionLogs,
150
+ ];
138
151
  }
139
152
 
140
153
  /**
@@ -261,6 +274,39 @@ export function computeAgencySyncPaths(hqRoot: string): string[] {
261
274
  return [];
262
275
  }
263
276
 
277
+ /**
278
+ * Fixed relative path (forward-slash, hq-root-relative) of the session-logs
279
+ * subtree. See {@link computeSessionLogsSyncPaths}.
280
+ */
281
+ export const SESSION_LOGS_SYNC_REL = "workspace/.session-logs";
282
+
283
+ /**
284
+ * Compute the absolute path of the session-logs carve-out:
285
+ * `workspace/.session-logs/` (the whole subtree).
286
+ *
287
+ * `hq reindex` copies this HQ root's Claude Code session transcripts here (see
288
+ * reindex's copySessionLogs). Like the agency carve-out above, this pierces the
289
+ * `workspace/` top-level exclusion for ONE specific subtree so those raw
290
+ * transcripts round-trip to the personal vault / S3 — the operator's explicit
291
+ * directive is that the session logs travel with the vault. The rest of
292
+ * `workspace/` (session scratch, locks, reports, worktrees) stays machine-local.
293
+ *
294
+ * Returns the directory path when it exists; share()'s per-file walk handles
295
+ * recursion, and the nested personal-vault exclusions still apply to files
296
+ * inside it. Fail-soft: a missing or unreadable `workspace/.session-logs/`
297
+ * returns []. Callers tolerate empty arrays — same contract as the manifest,
298
+ * continuity, and agency special-cases.
299
+ */
300
+ export function computeSessionLogsSyncPaths(hqRoot: string): string[] {
301
+ const sessionLogsDir = path.join(hqRoot, "workspace", ".session-logs");
302
+ try {
303
+ if (fs.statSync(sessionLogsDir).isDirectory()) return [sessionLogsDir];
304
+ } catch {
305
+ // No session logs captured on this machine yet — nothing to carry.
306
+ }
307
+ return [];
308
+ }
309
+
264
310
  /**
265
311
  * Discover `companies/{slug}/` subdirs that should sync to the personal
266
312
  * bucket as a fallback for companies the operator has not designated as
package/src/s3.test.ts CHANGED
@@ -93,6 +93,7 @@ import {
93
93
  decodeSymlinkMetadataValue,
94
94
  FILE_MTIME_META_KEY,
95
95
  FILE_BTIME_META_KEY,
96
+ validateVaultUploadKey,
96
97
  } from "./s3.js";
97
98
  import {
98
99
  setObjectIOFactory,
@@ -1384,3 +1385,89 @@ describe("downloadFile", () => {
1384
1385
  expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(true);
1385
1386
  });
1386
1387
  });
1388
+
1389
+ describe("validateVaultUploadKey — direct-S3 key poisoning guard (incident 2026-07-11)", () => {
1390
+ // The direct-S3 STS transport bypasses hq-pro's server-side validateObjectKey
1391
+ // (files-acl.ts), so uploadFile/uploadSymlink must enforce the SAME rules
1392
+ // before the PUT. Incident: a stale doubled local tree
1393
+ // (companies/indigo/companies/indigo/…) pushed 25 literal `companies/…` keys
1394
+ // into a company vault; the presign download API then rejected every one
1395
+ // (INVALID_KEY_COMPANIES_SCOPED), erroring the whole company sync fleet-wide.
1396
+ let tmpFile: string;
1397
+
1398
+ function makePersonalCtx(): EntityContext {
1399
+ return { ...makeCtx(), uid: "prs_TEST" };
1400
+ }
1401
+
1402
+ beforeEach(() => {
1403
+ sentCommands.length = 0;
1404
+ tmpFile = path.join(
1405
+ os.tmpdir(),
1406
+ `s3-keyguard-test-${Date.now()}-${Math.random()}.md`,
1407
+ );
1408
+ fs.writeFileSync(tmpFile, "hello");
1409
+ });
1410
+
1411
+ afterEach(() => {
1412
+ try {
1413
+ fs.unlinkSync(tmpFile);
1414
+ } catch {
1415
+ // already gone
1416
+ }
1417
+ });
1418
+
1419
+ it("validateVaultUploadKey rejects companies/-prefixed keys in company scope with the server's error code", () => {
1420
+ expect(() =>
1421
+ validateVaultUploadKey("companies/indigo/knowledge/x.md", "company"),
1422
+ ).toThrowError(
1423
+ expect.objectContaining({ code: "INVALID_KEY_COMPANIES_SCOPED" }),
1424
+ );
1425
+ });
1426
+
1427
+ it("validateVaultUploadKey allows companies/-prefixed keys in personal scope (local non-cloud companies)", () => {
1428
+ expect(() =>
1429
+ validateVaultUploadKey("companies/indigo/knowledge/x.md", "personal"),
1430
+ ).not.toThrow();
1431
+ });
1432
+
1433
+ it.each([
1434
+ ["", "INVALID_KEY_EMPTY"],
1435
+ ["/leading.md", "INVALID_KEY_LEADING_SLASH"],
1436
+ ["folder/", "INVALID_KEY_TRAILING_SLASH"],
1437
+ ["a//b.md", "INVALID_KEY_DOUBLE_SLASH"],
1438
+ ["a/../b.md", "INVALID_KEY_DOT_COMPONENT"],
1439
+ ["a/./b.md", "INVALID_KEY_DOT_COMPONENT"],
1440
+ ["bad\x00key.md", "INVALID_KEY_CONTROL_CHARS"],
1441
+ ])(
1442
+ "validateVaultUploadKey mirrors the server rule set: %j → %s",
1443
+ (key, code) => {
1444
+ expect(() => validateVaultUploadKey(key, "company")).toThrowError(
1445
+ expect.objectContaining({ code }),
1446
+ );
1447
+ expect(() => validateVaultUploadKey(key, "personal")).toThrowError(
1448
+ expect.objectContaining({ code }),
1449
+ );
1450
+ },
1451
+ );
1452
+
1453
+ it("uploadFile refuses to PUT a companies/-prefixed key into a company (cmp_) vault — no S3 command is sent", async () => {
1454
+ await expect(
1455
+ uploadFile(makeCtx(), tmpFile, "companies/indigo/knowledge/x.md"),
1456
+ ).rejects.toMatchObject({ code: "INVALID_KEY_COMPANIES_SCOPED" });
1457
+ expect(sentCommands.length).toBe(0);
1458
+ });
1459
+
1460
+ it("uploadSymlink refuses to PUT a companies/-prefixed key into a company (cmp_) vault — no S3 command is sent", async () => {
1461
+ await expect(
1462
+ uploadSymlink(makeCtx(), "../target.md", "companies/indigo/link.md"),
1463
+ ).rejects.toMatchObject({ code: "INVALID_KEY_COMPANIES_SCOPED" });
1464
+ expect(sentCommands.length).toBe(0);
1465
+ });
1466
+
1467
+ it("uploadFile still allows companies/-prefixed keys for a personal (prs_) vault", async () => {
1468
+ await expect(
1469
+ uploadFile(makePersonalCtx(), tmpFile, "companies/indigo/knowledge/x.md"),
1470
+ ).resolves.toBeDefined();
1471
+ expect(sentCommands.length).toBeGreaterThan(0);
1472
+ });
1473
+ });