@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
@@ -11,6 +11,7 @@ import * as path from "path";
11
11
  import {
12
12
  buildRepoCompanyMap,
13
13
  resolveCompanyForCwd,
14
+ resolveCompanyForSkill,
14
15
  } from "./company-resolver.js";
15
16
 
16
17
  const INDIGO = "cmp_01INDIGO";
@@ -132,5 +133,55 @@ describe("buildRepoCompanyMap + resolveCompanyForCwd", () => {
132
133
  hqRoot = setupHqRoot(":\n not: [valid: yaml");
133
134
  const map = await buildRepoCompanyMap(hqRoot);
134
135
  expect(map.entries).toHaveLength(0);
136
+ expect(map.bySlug.size).toBe(0);
137
+ });
138
+ });
139
+
140
+ describe("resolveCompanyForSkill", () => {
141
+ let hqRoot: string;
142
+ afterEach(() => {
143
+ if (hqRoot) fs.rmSync(hqRoot, { recursive: true, force: true });
144
+ });
145
+
146
+ it("attributes a company-namespaced skill to its company, regardless of cwd", async () => {
147
+ hqRoot = setupHqRoot();
148
+ const map = await buildRepoCompanyMap(hqRoot);
149
+ // The slug map is populated from the manifest keys with a cloud_uid.
150
+ expect(resolveCompanyForSkill("indigo:signals", map)).toBe(INDIGO);
151
+ expect(resolveCompanyForSkill("hpo:some-skill", map)).toBe(HPO);
152
+ // Multi-segment skill name: only the leading slug is the company.
153
+ expect(resolveCompanyForSkill("indigo:hq-dev-thread", map)).toBe(INDIGO);
154
+ });
155
+
156
+ it("returns undefined for a core (un-namespaced) skill", async () => {
157
+ hqRoot = setupHqRoot();
158
+ const map = await buildRepoCompanyMap(hqRoot);
159
+ expect(resolveCompanyForSkill("startwork", map)).toBeUndefined();
160
+ expect(resolveCompanyForSkill("review", map)).toBeUndefined();
161
+ });
162
+
163
+ it("returns undefined for a non-company namespace (personal/pack) and a leading colon", async () => {
164
+ hqRoot = setupHqRoot();
165
+ const map = await buildRepoCompanyMap(hqRoot);
166
+ // `personal` has no cloud_uid → not in the slug map.
167
+ expect(resolveCompanyForSkill("personal:startwork", map)).toBeUndefined();
168
+ expect(
169
+ resolveCompanyForSkill("hq-pack-engineering:review", map),
170
+ ).toBeUndefined();
171
+ expect(resolveCompanyForSkill(":oops", map)).toBeUndefined();
172
+ });
173
+
174
+ it("returns undefined for a slug whose company is not cloud-backed", async () => {
175
+ hqRoot = setupHqRoot();
176
+ const map = await buildRepoCompanyMap(hqRoot);
177
+ // `liverecover` is in the manifest but carries no cloud_uid.
178
+ expect(resolveCompanyForSkill("liverecover:foo", map)).toBeUndefined();
179
+ });
180
+
181
+ it("handles a missing/undefined skill safely", async () => {
182
+ hqRoot = setupHqRoot();
183
+ const map = await buildRepoCompanyMap(hqRoot);
184
+ expect(resolveCompanyForSkill(undefined, map)).toBeUndefined();
185
+ expect(resolveCompanyForSkill("", map)).toBeUndefined();
135
186
  });
136
187
  });
@@ -54,6 +54,14 @@ import yaml from "js-yaml";
54
54
  export interface RepoCompanyMap {
55
55
  /** Absolute, normalized repo root → owning company `cmp_*` uid. */
56
56
  entries: Array<{ repoRoot: string; companyUid: string }>;
57
+ /**
58
+ * Company SLUG → owning company `cmp_*` uid, from the manifest keys. Backs
59
+ * skill-based attribution: a company skill is invoked as `<slug>:<skill>`
60
+ * (mirroring `companies/<slug>/skills/`), so its usage belongs to the company
61
+ * named by that slug regardless of the cwd it ran in. Manifest keys are unique,
62
+ * so a slug resolves to exactly one cloud-backed company here.
63
+ */
64
+ bySlug: Map<string, string>;
57
65
  }
58
66
 
59
67
  interface ManifestCompany {
@@ -87,23 +95,28 @@ export async function buildRepoCompanyMap(hqRoot: string): Promise<RepoCompanyMa
87
95
  const raw = await fs.readFile(manifestPath, "utf-8");
88
96
  const parsed = yaml.load(raw);
89
97
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
90
- return { entries: [] };
98
+ return { entries: [], bySlug: new Map() };
91
99
  }
92
100
  doc = parsed as ManifestShape;
93
101
  } catch {
94
102
  // Missing / unreadable / unparseable — no attribution this run.
95
- return { entries: [] };
103
+ return { entries: [], bySlug: new Map() };
96
104
  }
97
105
 
98
106
  const companies = doc.companies;
99
- if (!companies || typeof companies !== "object") return { entries: [] };
107
+ if (!companies || typeof companies !== "object") {
108
+ return { entries: [], bySlug: new Map() };
109
+ }
100
110
 
101
111
  const entries: Array<{ repoRoot: string; companyUid: string }> = [];
102
- for (const company of Object.values(companies)) {
112
+ const bySlug = new Map<string, string>();
113
+ for (const [slug, company] of Object.entries(companies)) {
103
114
  if (!company || typeof company !== "object") continue;
104
115
  const cloudUid = company.cloud_uid;
105
116
  // Only cloud-backed companies (with a cmp_* uid) attribute events.
106
117
  if (typeof cloudUid !== "string" || !cloudUid.startsWith("cmp_")) continue;
118
+ // Slug → uid for skill-based attribution (a `<slug>:<skill>` invocation).
119
+ if (typeof slug === "string" && slug.length > 0) bySlug.set(slug, cloudUid);
107
120
  const repos = company.repos;
108
121
  if (!Array.isArray(repos)) continue;
109
122
  for (const repo of repos) {
@@ -118,7 +131,29 @@ export async function buildRepoCompanyMap(hqRoot: string): Promise<RepoCompanyMa
118
131
  // Longest repoRoot first so the first containment match is the most specific
119
132
  // (handles nested repo layouts where one repo path prefixes another).
120
133
  entries.sort((a, b) => b.repoRoot.length - a.repoRoot.length);
121
- return { entries };
134
+ return { entries, bySlug };
135
+ }
136
+
137
+ /**
138
+ * Resolve a skill INVOCATION to its owning company's `cmp_*` uid, or `undefined`
139
+ * when the skill is not a cloud-backed company's skill.
140
+ *
141
+ * A company skill is invoked as `<slug>:<skill>` (the namespaced form of
142
+ * `companies/<slug>/skills/<skill>`); the leading `<slug>` names the owning
143
+ * company. We look that slug up in the manifest's slug→uid map, so a company
144
+ * skill attributes to ITS company no matter which cwd it ran in — this is what
145
+ * surfaces company-skill usage in the company console. A core / `personal:` /
146
+ * `hq-pack-*:` / `anthropic-skills:` skill has no matching manifest company, so
147
+ * it resolves to `undefined` (the cwd resolver remains the fallback).
148
+ */
149
+ export function resolveCompanyForSkill(
150
+ skill: string | undefined,
151
+ map: RepoCompanyMap,
152
+ ): string | undefined {
153
+ if (typeof skill !== "string") return undefined;
154
+ const idx = skill.indexOf(":");
155
+ if (idx <= 0) return undefined; // un-namespaced (core) skill
156
+ return map.bySlug.get(skill.slice(0, idx));
122
157
  }
123
158
 
124
159
  /**
@@ -29,6 +29,8 @@ describe("isExpectedIgnore", () => {
29
29
  expect(isExpectedIgnore("x/.env")).toBe(true);
30
30
  expect(isExpectedIgnore("a/b/.hq-sync.pid")).toBe(true);
31
31
  expect(isExpectedIgnore("y/foo.pyc")).toBe(true);
32
+ // Cloud-only skill hero images (US-014) are an expected by-design exclusion.
33
+ expect(isExpectedIgnore(".claude/skills/foo/.skill-media/hero.png")).toBe(true);
32
34
  });
33
35
 
34
36
  it("keeps nested repos/workspace and ordinary content noteworthy", () => {
@@ -138,7 +140,7 @@ describe("createIgnoreFilter", () => {
138
140
 
139
141
  it("v12–v14 layout (no core/core.yaml): root core.yaml IS the manifest and MUST sync", () => {
140
142
  // Regression for DEV-1729 / the ~98 "no core.yaml in vault, version
141
- // undetectable" fleet users. On v12–v14 roots there is NO `core/core.yaml`
143
+ // undetectable" sync users. On v12–v14 roots there is NO `core/core.yaml`
142
144
  // — the ROOT `core.yaml` is the scaffold/version manifest. The old
143
145
  // unconditional `/core.yaml` exclusion silently dropped it (while
144
146
  // computePersonalVaultPaths still enumerated it), so the vault never
@@ -181,6 +183,21 @@ describe("createIgnoreFilter", () => {
181
183
  expect(shouldSync(path.join(hqRoot, ".claude/skills/foo/SKILL.md"))).toBe(true);
182
184
  });
183
185
 
186
+ it("permissive mode: .skill-media/ (cloud-only skill images) is ignored (US-014)", () => {
187
+ const shouldSync = createIgnoreFilter(hqRoot);
188
+ // Skill hero images are cloud-only — excluded from push AND pull at any depth.
189
+ expect(
190
+ shouldSync(path.join(hqRoot, ".claude/skills/lead-qualifier/.skill-media/hero.png")),
191
+ ).toBe(false);
192
+ expect(
193
+ shouldSync(path.join(hqRoot, "companies/indigo/skills/foo/.skill-media/cover.jpg")),
194
+ ).toBe(false);
195
+ expect(shouldSync(path.join(hqRoot, "skills/bar/.skill-media"), true)).toBe(false);
196
+ // The skill's SKILL.md and other content still sync — the rule is scoped to
197
+ // the media dir only, never the skill.
198
+ expect(shouldSync(path.join(hqRoot, ".claude/skills/lead-qualifier/SKILL.md"))).toBe(true);
199
+ });
200
+
184
201
  it("permissive mode: .hq-* internal state is ignored, .hqignore family still sync", () => {
185
202
  const shouldSync = createIgnoreFilter(hqRoot);
186
203
  // Internal state files that must never round-trip through the bucket.
package/src/ignore.ts CHANGED
@@ -18,6 +18,16 @@
18
18
  * three exclusion layers still subtract on top — so even allowlisted
19
19
  * subtrees won't push `node_modules/` or `.env`. Privacy-by-default for
20
20
  * HQ trees that contain mixed personal + shareable data.
21
+ *
22
+ * SESSIONS ARE PUSH-ONLY, NOT PUSH-IGNORED (US-006). The company-vault
23
+ * `sessions/{personUid}/...` prefix (session transcripts) is intentionally NOT
24
+ * added to `DEFAULT_IGNORES`: push-only means it MUST still upload on the push
25
+ * walk. Its never-AUTO-pull semantics are enforced ONE layer over, in the pull
26
+ * scope resolver — `resolvePullScope().excludePrefixes` (src/sync/pull-scope.ts)
27
+ * subtracts `sessions/` from the effective pull scope in EVERY mode (including
28
+ * `all`), and `computePullPlan` classifies those keys `skip-out-of-scope`.
29
+ * Ignoring `sessions/` HERE would wrongly block the upload too, so the split is
30
+ * deliberate: this module governs the push walk; pull-scope governs never-pull.
21
31
  */
22
32
 
23
33
  import * as fs from "fs";
@@ -93,7 +103,7 @@ export const DEFAULT_IGNORES = [
93
103
  // `core.yaml` is a stale per-machine duplicate that must not round-trip; on
94
104
  // v12–v14 roots there is NO `core/core.yaml` and the ROOT `core.yaml` IS the
95
105
  // scaffold/version manifest, which MUST sync (else the vault has no version
96
- // marker and the file is undetectable fleet-side).
106
+ // marker and the file is undetectable across user installs).
97
107
  // hq modules manifest — local module-resolution state, never synced.
98
108
  "modules/modules.yaml",
99
109
  // per-company identity file — written locally on first sync, never round-tripped.
@@ -130,6 +140,16 @@ export const DEFAULT_IGNORES = [
130
140
  "**/.claude/settings.json",
131
141
  "**/.claude/settings.local.json",
132
142
 
143
+ // Skill hero images (skills-first-class US-014) — cloud-only artifacts stored
144
+ // under a skill's `.skill-media/` dir (e.g. `skills/<name>/.skill-media/hero.png`).
145
+ // They live in the vault/DB and are surfaced by the console, but must NEVER
146
+ // round-trip through the file sync onto agent machines: agents run the skill
147
+ // from SKILL.md and have no use for the image, and pulling it would litter
148
+ // every skill dir with binary blobs. Anchored `**/` so it matches at any depth
149
+ // (hqRoot skills, company skills, embedded knowledge repos) — excludes them
150
+ // from BOTH push and pull. Mirrors the `**/.claude/worktrees/` anchor shape.
151
+ "**/.skill-media/",
152
+
133
153
  // HQ repos directory (managed separately, not synced). Root-anchored so
134
154
  // only hqRoot/repos/ is excluded; bare `repos/` would match any nested
135
155
  // directory named repos (for example companies/<co>/knowledge/repos/),
@@ -247,6 +267,8 @@ export const EXPECTED_IGNORE_SEGMENTS = new Set([
247
267
  ".tmp",
248
268
  ".hq",
249
269
  ".claude",
270
+ // Cloud-only skill hero images (US-014) — never synced onto agent machines.
271
+ ".skill-media",
250
272
  ]);
251
273
 
252
274
  /**
@@ -310,7 +332,7 @@ export function createIgnoreFilter(
310
332
  // stale/duplicate per-machine marker, never the authoritative manifest.
311
333
  // - v12–v14 (core/core.yaml absent): do NOT exclude root `core.yaml` — it
312
334
  // IS the manifest and must round-trip, else the file is undetectable
313
- // fleet-side (the ~98 "no core.yaml in vault" users were all old-layout).
335
+ // across user installs (the ~98 "no core.yaml in vault" users were all old-layout).
314
336
  // Root-anchored (`/core.yaml`) so this never affects `core/core.yaml`.
315
337
  if (fs.existsSync(path.join(hqRoot, "core", "core.yaml"))) {
316
338
  ig.add("/core.yaml");
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`
@@ -200,6 +204,9 @@ export type {
200
204
  TelemetryOptInResponse,
201
205
  UsageBatch,
202
206
  UsageIngestResult,
207
+ RawTelemetryEventInput,
208
+ TelemetryEventsBatch,
209
+ TelemetryEventsIngestResult,
203
210
  SkillInvocationBatch,
204
211
  SkillInvocationIngestResult,
205
212
  // US-004 — browse-vs-sync membership sync-mode + ACL surface
@@ -219,6 +226,20 @@ export type {
219
226
  TelemetryClientSurface,
220
227
  } from "./telemetry.js";
221
228
 
229
+ // hq-cloud ACTION telemetry (`/v1/telemetry/events`). Unlike token/skill
230
+ // collectors, these sync/CLI events are not gated by `/v1/usage` opt-in.
231
+ export {
232
+ agentUidFromClaims,
233
+ emitCloudTelemetry,
234
+ } from "./telemetry-events.js";
235
+ export type {
236
+ CloudTelemetryClient,
237
+ EmitCloudTelemetryOptions,
238
+ TelemetryClaims,
239
+ TelemetryEvent,
240
+ TelemetryProperties,
241
+ } from "./telemetry-events.js";
242
+
222
243
  // Skill-invocation telemetry collector (`/v1/skill-invocations`). Reads the
223
244
  // same Claude Code session logs as the token collector but extracts which
224
245
  // 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;