@indigoai-us/hq-cloud 6.14.17 → 6.14.19

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 (124) hide show
  1. package/.github/workflows/ci.yml +35 -0
  2. package/dist/active-company.d.ts +43 -0
  3. package/dist/active-company.d.ts.map +1 -0
  4. package/dist/active-company.js +132 -0
  5. package/dist/active-company.js.map +1 -0
  6. package/dist/active-company.test.d.ts +2 -0
  7. package/dist/active-company.test.d.ts.map +1 -0
  8. package/dist/active-company.test.js +149 -0
  9. package/dist/active-company.test.js.map +1 -0
  10. package/dist/bin/sync-runner-company.d.ts +7 -0
  11. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  12. package/dist/bin/sync-runner-company.js +21 -1
  13. package/dist/bin/sync-runner-company.js.map +1 -1
  14. package/dist/bin/sync-runner-planning.d.ts +27 -1
  15. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  16. package/dist/bin/sync-runner-planning.js +33 -4
  17. package/dist/bin/sync-runner-planning.js.map +1 -1
  18. package/dist/bin/sync-runner-planning.test.d.ts +2 -0
  19. package/dist/bin/sync-runner-planning.test.d.ts.map +1 -0
  20. package/dist/bin/sync-runner-planning.test.js +115 -0
  21. package/dist/bin/sync-runner-planning.test.js.map +1 -0
  22. package/dist/bin/sync-runner-watch-loop.d.ts +10 -2
  23. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  24. package/dist/bin/sync-runner-watch-loop.js +201 -25
  25. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  26. package/dist/bin/sync-runner-watch-routes.d.ts +1 -1
  27. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
  28. package/dist/bin/sync-runner-watch-routes.js +14 -2
  29. package/dist/bin/sync-runner-watch-routes.js.map +1 -1
  30. package/dist/bin/sync-runner.d.ts +39 -9
  31. package/dist/bin/sync-runner.d.ts.map +1 -1
  32. package/dist/bin/sync-runner.js +114 -20
  33. package/dist/bin/sync-runner.js.map +1 -1
  34. package/dist/bin/sync-runner.test.js +852 -8
  35. package/dist/bin/sync-runner.test.js.map +1 -1
  36. package/dist/cli/rescue-core.js +27 -1
  37. package/dist/cli/rescue-core.js.map +1 -1
  38. package/dist/cli/rescue-drop-dir-symlink.test.d.ts +2 -0
  39. package/dist/cli/rescue-drop-dir-symlink.test.d.ts.map +1 -0
  40. package/dist/cli/rescue-drop-dir-symlink.test.js +206 -0
  41. package/dist/cli/rescue-drop-dir-symlink.test.js.map +1 -0
  42. package/dist/cli/share.d.ts +15 -0
  43. package/dist/cli/share.d.ts.map +1 -1
  44. package/dist/cli/share.js +100 -55
  45. package/dist/cli/share.js.map +1 -1
  46. package/dist/cli/share.test.js +119 -6
  47. package/dist/cli/share.test.js.map +1 -1
  48. package/dist/cli/sync.d.ts.map +1 -1
  49. package/dist/cli/sync.js +19 -25
  50. package/dist/cli/sync.js.map +1 -1
  51. package/dist/cli/sync.test.js +121 -0
  52. package/dist/cli/sync.test.js.map +1 -1
  53. package/dist/journal.d.ts +3 -1
  54. package/dist/journal.d.ts.map +1 -1
  55. package/dist/journal.js +13 -2
  56. package/dist/journal.js.map +1 -1
  57. package/dist/journal.test.js +26 -1
  58. package/dist/journal.test.js.map +1 -1
  59. package/dist/manifest-reconcile.d.ts +118 -5
  60. package/dist/manifest-reconcile.d.ts.map +1 -1
  61. package/dist/manifest-reconcile.js +319 -62
  62. package/dist/manifest-reconcile.js.map +1 -1
  63. package/dist/manifest-reconcile.test.js +824 -2
  64. package/dist/manifest-reconcile.test.js.map +1 -1
  65. package/dist/personal-vault.d.ts +12 -0
  66. package/dist/personal-vault.d.ts.map +1 -1
  67. package/dist/personal-vault.js +20 -0
  68. package/dist/personal-vault.js.map +1 -1
  69. package/dist/qmd-reindex.d.ts +88 -11
  70. package/dist/qmd-reindex.d.ts.map +1 -1
  71. package/dist/qmd-reindex.js +354 -65
  72. package/dist/qmd-reindex.js.map +1 -1
  73. package/dist/qmd-reindex.test.d.ts +4 -0
  74. package/dist/qmd-reindex.test.d.ts.map +1 -1
  75. package/dist/qmd-reindex.test.js +285 -3
  76. package/dist/qmd-reindex.test.js.map +1 -1
  77. package/dist/s3.d.ts +13 -0
  78. package/dist/s3.d.ts.map +1 -1
  79. package/dist/s3.js +34 -1
  80. package/dist/s3.js.map +1 -1
  81. package/dist/s3.test.js +60 -1
  82. package/dist/s3.test.js.map +1 -1
  83. package/dist/telemetry.d.ts +22 -0
  84. package/dist/telemetry.d.ts.map +1 -1
  85. package/dist/telemetry.js +79 -0
  86. package/dist/telemetry.js.map +1 -1
  87. package/dist/telemetry.test.js +117 -1
  88. package/dist/telemetry.test.js.map +1 -1
  89. package/dist/watcher.d.ts +26 -1
  90. package/dist/watcher.d.ts.map +1 -1
  91. package/dist/watcher.js +75 -12
  92. package/dist/watcher.js.map +1 -1
  93. package/package.json +1 -1
  94. package/pnpm-workspace.yaml +1 -1
  95. package/src/active-company.test.ts +188 -0
  96. package/src/active-company.ts +168 -0
  97. package/src/bin/sync-runner-company.ts +29 -1
  98. package/src/bin/sync-runner-planning.test.ts +131 -0
  99. package/src/bin/sync-runner-planning.ts +60 -7
  100. package/src/bin/sync-runner-watch-loop.ts +322 -32
  101. package/src/bin/sync-runner-watch-routes.ts +14 -1
  102. package/src/bin/sync-runner.test.ts +1021 -16
  103. package/src/bin/sync-runner.ts +179 -28
  104. package/src/cli/rescue-core.ts +26 -1
  105. package/src/cli/rescue-drop-dir-symlink.test.ts +224 -0
  106. package/src/cli/share.test.ts +150 -6
  107. package/src/cli/share.ts +143 -48
  108. package/src/cli/sync.test.ts +150 -1
  109. package/src/cli/sync.ts +25 -29
  110. package/src/journal.test.ts +45 -0
  111. package/src/journal.ts +18 -1
  112. package/src/manifest-reconcile.test.ts +1019 -3
  113. package/src/manifest-reconcile.ts +424 -66
  114. package/src/personal-vault.ts +23 -0
  115. package/src/qmd-reindex.test.ts +324 -4
  116. package/src/qmd-reindex.ts +414 -76
  117. package/src/s3.test.ts +79 -0
  118. package/src/s3.ts +48 -1
  119. package/src/telemetry.test.ts +128 -0
  120. package/src/telemetry.ts +80 -0
  121. package/src/watcher.ts +111 -14
  122. package/test/e2e/watcher-real-chokidar.test.ts +62 -2
  123. package/test/e2e/watcher-recursive-backend.test.ts +67 -1
  124. package/test/joiner-manifest-reconcile.integration.test.ts +283 -0
package/src/s3.ts CHANGED
@@ -321,6 +321,53 @@ function removeTempPath(tempPath: string): void {
321
321
  }
322
322
  }
323
323
 
324
+ export interface CreateStagedSymlinkOps {
325
+ platform: NodeJS.Platform;
326
+ symlink(target: string, linkPath: string, type?: fs.symlink.Type): void;
327
+ statIsDirectory(absPath: string): boolean | undefined;
328
+ }
329
+
330
+ const DEFAULT_CREATE_STAGED_SYMLINK_OPS: CreateStagedSymlinkOps = {
331
+ platform: process.platform,
332
+ symlink: (target, linkPath, type) =>
333
+ fs.symlinkSync(target, linkPath, type),
334
+ statIsDirectory: (absPath) => {
335
+ try {
336
+ return fs.statSync(absPath).isDirectory();
337
+ } catch {
338
+ return undefined;
339
+ }
340
+ },
341
+ };
342
+
343
+ /**
344
+ * Create a fully-staged symlink without requiring Windows Developer Mode.
345
+ * NTFS symlinks fail with EPERM without SeCreateSymbolicLinkPrivilege, while
346
+ * directory junctions need no privilege but require an absolute target. HQ
347
+ * vault links are directory overlays, so missing targets default to junctions.
348
+ * See Sentry HQ-DESKTOP-3M.
349
+ */
350
+ export function createStagedSymlink(
351
+ target: string,
352
+ linkPath: string,
353
+ ops: CreateStagedSymlinkOps = DEFAULT_CREATE_STAGED_SYMLINK_OPS,
354
+ ): void {
355
+ if (ops.platform !== "win32") {
356
+ ops.symlink(target, linkPath);
357
+ return;
358
+ }
359
+
360
+ const absTarget = path.isAbsolute(target)
361
+ ? target
362
+ : path.resolve(path.dirname(linkPath), target);
363
+ if (ops.statIsDirectory(absTarget) === false) {
364
+ ops.symlink(target, linkPath, "file");
365
+ return;
366
+ }
367
+
368
+ ops.symlink(absTarget, linkPath, "junction");
369
+ }
370
+
324
371
  export interface ReplaceStagedPathOps {
325
372
  lstat(path: string): fs.Stats;
326
373
  rename(from: string, to: string): void;
@@ -1053,7 +1100,7 @@ export async function downloadFile(
1053
1100
 
1054
1101
  const tempPath = downloadTempPath(localPath);
1055
1102
  try {
1056
- fs.symlinkSync(symlinkTarget, tempPath);
1103
+ createStagedSymlink(symlinkTarget, tempPath);
1057
1104
  options.beforeReplace?.();
1058
1105
  replaceStagedPath(tempPath, localPath);
1059
1106
  } catch (err) {
@@ -16,6 +16,7 @@ import * as path from "path";
16
16
  import {
17
17
  collectAndSendTelemetry,
18
18
  sanitizeRow,
19
+ extractServices,
19
20
  type CollectTelemetryOptions,
20
21
  type TelemetryClientSurface,
21
22
  } from "./telemetry.js";
@@ -203,6 +204,133 @@ describe("sanitizeRow", () => {
203
204
  });
204
205
  });
205
206
 
207
+ // ── extractServices + sanitizeRow services emission (US-007) ─────────────────
208
+
209
+ // An assistant row that invoked the Slack + Figma MCP servers. The tool_use
210
+ // block `input` deliberately carries a URL + secret so the redaction assertions
211
+ // have something dangerous to prove is dropped.
212
+ const MCP_ROW = JSON.stringify({
213
+ type: "assistant",
214
+ timestamp: "2026-04-25T10:00:02Z",
215
+ sessionId: "s1",
216
+ uuid: "u3",
217
+ message: {
218
+ role: "assistant",
219
+ model: "claude-opus",
220
+ usage: { input_tokens: 10, output_tokens: 5 },
221
+ content: [
222
+ { type: "text", text: "calling tools" },
223
+ {
224
+ type: "tool_use",
225
+ id: "t1",
226
+ name: "mcp__slack__slack_send_message",
227
+ input: { channel: "#secret-ops", token: "xoxb-SECRET-TOKEN", text: "hi" },
228
+ },
229
+ {
230
+ type: "tool_use",
231
+ id: "t2",
232
+ name: "mcp__slack__slack_read_channel",
233
+ input: { url: "https://hooks.slack.com/leak", channel: "C123" },
234
+ },
235
+ {
236
+ type: "tool_use",
237
+ id: "t3",
238
+ name: "mcp__figma__get_screenshot",
239
+ input: { fileKey: "https://figma.com/file/PRIVATE" },
240
+ },
241
+ // A non-MCP builtin tool must NOT count as a service.
242
+ { type: "tool_use", id: "t4", name: "Bash", input: { command: "rm -rf /" } },
243
+ ],
244
+ id: "msg_3",
245
+ },
246
+ });
247
+
248
+ describe("extractServices (US-007)", () => {
249
+ it("counts MCP servers by name from tool_use block names", () => {
250
+ const services = extractServices(JSON.parse(MCP_ROW).message);
251
+ // Slack invoked twice, Figma once; Bash (builtin) excluded.
252
+ expect(services).toEqual({ slack: 2, figma: 1 });
253
+ });
254
+
255
+ it("returns undefined when the row touched no MCP server", () => {
256
+ expect(extractServices(JSON.parse(ASST_ROW).message)).toBeUndefined();
257
+ expect(extractServices(JSON.parse(USER_ROW).message)).toBeUndefined();
258
+ expect(extractServices(undefined)).toBeUndefined();
259
+ expect(extractServices({ content: "not an array" })).toBeUndefined();
260
+ });
261
+
262
+ it("preserves multi-underscore server names (mcp__claude_ai_Slack__…)", () => {
263
+ const msg = {
264
+ content: [
265
+ { type: "tool_use", id: "a", name: "mcp__claude_ai_Slack__slack_send_message", input: {} },
266
+ { type: "tool_use", id: "b", name: "mcp__claude_ai_Figma__get_screenshot", input: {} },
267
+ ],
268
+ };
269
+ // The server segment is everything between the first and the LAST `__`.
270
+ expect(extractServices(msg)).toEqual({
271
+ claude_ai_Slack: 1,
272
+ claude_ai_Figma: 1,
273
+ });
274
+ });
275
+
276
+ it("preserves a double-underscore in the server name (mcp__foo__bar__run)", () => {
277
+ const msg = {
278
+ content: [
279
+ { type: "tool_use", id: "a", name: "mcp__foo__bar__run", input: {} },
280
+ ],
281
+ };
282
+ // Delimiter is the LAST `__`, so server = `foo__bar`, tool = `run`.
283
+ expect(extractServices(msg)).toEqual({ "foo__bar": 1 });
284
+ });
285
+
286
+ it("counts servers whose names collide with Object.prototype members", () => {
287
+ const msg = {
288
+ content: [
289
+ { type: "tool_use", id: "a", name: "mcp__constructor__x", input: {} },
290
+ { type: "tool_use", id: "b", name: "mcp__constructor__y", input: {} },
291
+ { type: "tool_use", id: "c", name: "mcp__toString__z", input: {} },
292
+ ],
293
+ };
294
+ // A plain-object counter would read these off the prototype and produce a
295
+ // non-numeric count; the null-prototype map keeps them honest integers.
296
+ const out = extractServices(msg)!;
297
+ expect(out).toEqual({ constructor: 2, toString: 1 });
298
+ expect(typeof out.constructor).toBe("number");
299
+ });
300
+
301
+ // SECURITY-CRITICAL: names + counts ONLY. No args / URLs / payloads / secrets.
302
+ it("emits NAMES + COUNTS only — no args, URLs, payloads, or secrets", () => {
303
+ const out = sanitizeRow(JSON.parse(MCP_ROW))!;
304
+ expect(out.services).toEqual({ slack: 2, figma: 1 });
305
+
306
+ // The full serialized wire row must contain NONE of the dangerous input data.
307
+ const serialized = JSON.stringify(out);
308
+ expect(serialized).not.toContain("xoxb-SECRET-TOKEN");
309
+ expect(serialized).not.toContain("hooks.slack.com");
310
+ expect(serialized).not.toContain("figma.com/file/PRIVATE");
311
+ expect(serialized).not.toContain("#secret-ops");
312
+ expect(serialized).not.toContain("rm -rf");
313
+ // No tool names or input keys leaked — only the server names + counts.
314
+ expect(serialized).not.toContain("slack_send_message");
315
+ expect(serialized).not.toContain("get_screenshot");
316
+ expect(serialized).not.toContain("channel");
317
+ expect(serialized).not.toContain("token");
318
+ expect(serialized).not.toContain("url");
319
+
320
+ // The services VALUE is a map of string → number (counts) — never objects
321
+ // or strings that could smuggle structured data.
322
+ for (const [name, count] of Object.entries(out.services as Record<string, unknown>)) {
323
+ expect(typeof name).toBe("string");
324
+ expect(typeof count).toBe("number");
325
+ }
326
+ });
327
+
328
+ it("omits services entirely for a row with no MCP calls (backward-compatible)", () => {
329
+ const out = sanitizeRow(JSON.parse(ASST_ROW))!;
330
+ expect("services" in out).toBe(false);
331
+ });
332
+ });
333
+
206
334
  // ── collectAndSendTelemetry ───────────────────────────────────────────────────
207
335
 
208
336
  describe("collectAndSendTelemetry", () => {
package/src/telemetry.ts CHANGED
@@ -152,6 +152,73 @@ const KEEP_TOP_LEVEL = [
152
152
  "userType",
153
153
  ] as const;
154
154
 
155
+ /**
156
+ * MCP tool_use blocks name their tool `mcp__<server>__<tool>` in a Claude Code
157
+ * session row's `message.content[]`. This recovers the SERVER segment only —
158
+ * the server/connector name (e.g. `slack`, `figma`). The tool segment and the
159
+ * block's `input` (args/URLs/payloads) are deliberately NEVER read.
160
+ *
161
+ * The `__` (double underscore) is the server↔tool delimiter, so the server is
162
+ * everything between the `mcp__` prefix and the LAST `__`. Using `lastIndexOf`
163
+ * (rather than a first-`__` split or a single-underscore-only regex) preserves
164
+ * legal underscores in server names — `_foo`, `foo_`, and `foo__bar` all parse
165
+ * correctly instead of being truncated or dropped. Returns `undefined` for a
166
+ * name that is not an `mcp__<server>__<tool>` with a non-empty server segment.
167
+ */
168
+ const MCP_PREFIX = "mcp__";
169
+ function mcpServerName(toolName: string): string | undefined {
170
+ if (!toolName.startsWith(MCP_PREFIX)) return undefined;
171
+ const rest = toolName.slice(MCP_PREFIX.length);
172
+ const delim = rest.lastIndexOf("__");
173
+ if (delim <= 0) return undefined; // no tool segment, or empty server
174
+ return rest.slice(0, delim);
175
+ }
176
+
177
+ /**
178
+ * SECURITY-CRITICAL (owner-telemetry-v2 US-007). Extract the MCP servers /
179
+ * connectors / integrations a session row touched as a NAMES-AND-COUNTS-ONLY map
180
+ * `{ <serverName>: <count> }` — e.g. `{ slack: 2, figma: 1 }`.
181
+ *
182
+ * This mirrors the `hasArgs` redaction precedent in skill-telemetry.ts: we look
183
+ * ONLY at the tool-use block's `name` (to recover the server segment of
184
+ * `mcp__<server>__<tool>`) and count occurrences. We NEVER read the block's
185
+ * `input` (which carries the args/URLs/payloads), never emit the tool name, and
186
+ * never emit any argument text. So no args, URLs, payloads, or secrets can reach
187
+ * the wire — only a service name and how many times it was invoked in this row.
188
+ *
189
+ * Returns `undefined` when the row touched no MCP server, so the caller omits the
190
+ * `services` field entirely (backward-compatible: old-shaped rows carry nothing
191
+ * new, and the server treats absence as "no services").
192
+ */
193
+ export function extractServices(
194
+ message: unknown,
195
+ ): Record<string, number> | undefined {
196
+ if (!message || typeof message !== "object" || Array.isArray(message)) {
197
+ return undefined;
198
+ }
199
+ const content = (message as Record<string, unknown>).content;
200
+ if (!Array.isArray(content)) return undefined;
201
+
202
+ // Null-prototype map: server names come from untrusted session content and can
203
+ // legally be `constructor`, `toString`, etc. A plain `{}` would read those off
204
+ // Object.prototype, so `counts[server] ?? 0` would yield a function and the
205
+ // increment would produce a non-numeric count — poisoning the whole row (the
206
+ // backend then drops it as `invalid-services`, losing its token/session data).
207
+ const counts: Record<string, number> = Object.create(null);
208
+ for (const blk of content as unknown[]) {
209
+ if (!blk || typeof blk !== "object" || Array.isArray(blk)) continue;
210
+ const b = blk as Record<string, unknown>;
211
+ // Only tool_use blocks name an MCP tool. We read `name` and NOTHING ELSE —
212
+ // the block's `input` (args/URLs/payloads) is never touched.
213
+ if (b.type !== "tool_use" || typeof b.name !== "string") continue;
214
+ const server = mcpServerName(b.name);
215
+ if (!server) continue;
216
+ counts[server] = (counts[server] ?? 0) + 1;
217
+ }
218
+
219
+ return Object.keys(counts).length > 0 ? counts : undefined;
220
+ }
221
+
155
222
  /**
156
223
  * Build an outgoing event row matching the server's KEEP allowlist.
157
224
  *
@@ -162,6 +229,11 @@ const KEEP_TOP_LEVEL = [
162
229
  * camelCase top-level fields. The original `message` object — which
163
230
  * carries prompt/response text, thinking, and tool data — is dropped.
164
231
  *
232
+ * `services` (US-007): the MCP servers / connectors / integrations this row
233
+ * touched, as a names-and-counts-only map (see `extractServices`). NAMES + COUNTS
234
+ * ONLY — never args/URLs/payloads/secrets, matching the `hasArgs` precedent.
235
+ * Omitted when the row touched no MCP server (the server treats absence as none).
236
+ *
165
237
  * `companyUid` (US-002): when the caller has resolved the row's `cwd` to an
166
238
  * owning company (`resolveCompanyForCwd`), it passes that `cmp_*` uid here and
167
239
  * it is stamped on the wire row. It is on the server's KEEP allowlist
@@ -213,6 +285,14 @@ export function sanitizeRow(
213
285
  }
214
286
  }
215
287
 
288
+ // services/integrations touched (US-007). Names + counts only — extracted from
289
+ // the message's tool_use block NAMES, never their inputs. Omitted when none, so
290
+ // the wire row is unchanged for rows that touched no MCP server.
291
+ const services = extractServices(message);
292
+ if (services !== undefined) {
293
+ out.services = services;
294
+ }
295
+
216
296
  return out;
217
297
  }
218
298
 
package/src/watcher.ts CHANGED
@@ -263,6 +263,32 @@ interface WatchBackend {
263
263
  close(): void;
264
264
  }
265
265
 
266
+ export type TreeChangeKind =
267
+ | "add"
268
+ | "change"
269
+ | "unlink"
270
+ | "addDir"
271
+ | "unlinkDir";
272
+
273
+ /** Exact journal revision observed when a live watcher reports a deletion. */
274
+ export interface LocalDeleteSnapshot {
275
+ journalSlug: string;
276
+ journalPath: string;
277
+ /** Exact vault-relative root authorized by the originating watcher event. */
278
+ deleteScopeRoot?: string;
279
+ absolutePath: string;
280
+ remoteEtag: string;
281
+ localHash: string;
282
+ localKind: "file" | "symlink";
283
+ }
284
+
285
+ export interface TreeChange {
286
+ kind: TreeChangeKind;
287
+ deleteSnapshots?: LocalDeleteSnapshot[];
288
+ }
289
+
290
+ type BackendChangeKind = TreeChangeKind | "rename";
291
+
266
292
  /**
267
293
  * Platforms where Node's `fs.watch(dir, { recursive: true })` is natively
268
294
  * supported by a SINGLE OS handle: macOS (FSEvents) and Windows
@@ -277,7 +303,7 @@ function supportsRecursiveWatch(): boolean {
277
303
  function startChokidarTreeWatch(
278
304
  hqRoot: string,
279
305
  shouldEmit: WatchPathFilter,
280
- onEvent: (absolutePath: string) => void,
306
+ onEvent: (absolutePath: string, kind: BackendChangeKind) => void,
281
307
  onError: (err: unknown) => void,
282
308
  ): WatchBackend {
283
309
  const cw = watch(hqRoot, {
@@ -291,11 +317,11 @@ function startChokidarTreeWatch(
291
317
  pollInterval: 100,
292
318
  },
293
319
  });
294
- cw.on("add", onEvent)
295
- .on("change", onEvent)
296
- .on("unlink", onEvent)
297
- .on("addDir", onEvent)
298
- .on("unlinkDir", onEvent)
320
+ cw.on("add", (p) => onEvent(p, "add"))
321
+ .on("change", (p) => onEvent(p, "change"))
322
+ .on("unlink", (p) => onEvent(p, "unlink"))
323
+ .on("addDir", (p) => onEvent(p, "addDir"))
324
+ .on("unlinkDir", (p) => onEvent(p, "unlinkDir"))
299
325
  .on("error", onError);
300
326
  return {
301
327
  close: () => {
@@ -327,7 +353,7 @@ function startChokidarTreeWatch(
327
353
  function startTreeWatch(
328
354
  hqRoot: string,
329
355
  shouldEmit: WatchPathFilter,
330
- onEvent: (absolutePath: string) => void,
356
+ onEvent: (absolutePath: string, kind: BackendChangeKind) => void,
331
357
  onError: (err: unknown) => void,
332
358
  ): WatchBackend {
333
359
  if (supportsRecursiveWatch()) {
@@ -335,14 +361,17 @@ function startTreeWatch(
335
361
  const native = fs.watch(
336
362
  hqRoot,
337
363
  { recursive: true, persistent: true },
338
- (_eventType, filename) => {
364
+ (eventType, filename) => {
339
365
  // `filename` is relative to hqRoot (or null/empty if the platform
340
366
  // couldn't provide it — nothing actionable then). `String()` coerces
341
367
  // both the string and (older @types/node) Buffer shapes.
342
368
  if (filename == null) return;
343
369
  const rel = String(filename);
344
370
  if (rel === "") return;
345
- onEvent(path.resolve(hqRoot, rel));
371
+ onEvent(
372
+ path.resolve(hqRoot, rel),
373
+ eventType === "change" ? "change" : "rename",
374
+ );
346
375
  },
347
376
  );
348
377
  let closed = false;
@@ -534,6 +563,10 @@ export interface TreeWatcherOptions {
534
563
  maxPendingBytes?: number;
535
564
  /** Backlog overflow signal. Defaults to a console warning. */
536
565
  onBacklogOverflow?: (info: TreeWatcherBacklogOverflow) => void;
566
+ captureLocalDeleteSnapshots?: (
567
+ relativePath: string,
568
+ kind: "unlink" | "unlinkDir",
569
+ ) => LocalDeleteSnapshot[];
537
570
  }
538
571
 
539
572
  /**
@@ -556,6 +589,8 @@ export interface TreeWatcherOptions {
556
589
  export interface TreeChangeBatch {
557
590
  /** Map of absolutePath → relativePath for every path in the settled burst. */
558
591
  paths: Map<string, string>;
592
+ /** Per-path watcher event and the captured pre-delete journal revision. */
593
+ changes?: Map<string, TreeChange>;
559
594
  /** True when path detail was dropped after the watcher backlog cap was hit. */
560
595
  overflowed?: boolean;
561
596
  /** Count of paths dropped from the detailed batch after overflow. */
@@ -587,11 +622,14 @@ export class TreeWatcher {
587
622
  private readonly maxPendingPaths: number;
588
623
  private readonly maxPendingBytes: number;
589
624
  private readonly onBacklogOverflow: (info: TreeWatcherBacklogOverflow) => void;
625
+ private readonly captureLocalDeleteSnapshots?: TreeWatcherOptions["captureLocalDeleteSnapshots"];
590
626
  private backend: WatchBackend | null = null;
591
627
  private timer: unknown = null;
592
628
  private listeners = new Set<TreeChangeListener>();
593
629
  /** Paths accumulated for the current (in-flight) debounce window. */
594
630
  private pending = new Map<string, string>();
631
+ private pendingChanges = new Map<string, TreeChange>();
632
+ private knownKinds = new Map<string, "file" | "directory">();
595
633
  private pendingBytes = 0;
596
634
  private overflowed = false;
597
635
  private overflowLogged = false;
@@ -620,6 +658,7 @@ export class TreeWatcher {
620
658
  `TreeWatcher backlog cap exceeded; dropping ${info.droppedPaths} path(s) until the next resync`,
621
659
  );
622
660
  });
661
+ this.captureLocalDeleteSnapshots = opts.captureLocalDeleteSnapshots;
623
662
  }
624
663
 
625
664
  /**
@@ -648,12 +687,13 @@ export class TreeWatcher {
648
687
  this.backend = startTreeWatch(
649
688
  this.hqRoot,
650
689
  this.shouldEmit,
651
- (absolutePath) => this.handleEvent(absolutePath),
690
+ (absolutePath, kind) => this.handleEvent(absolutePath, kind),
652
691
  (err) => {
653
692
  console.error("TreeWatcher error:", err);
654
693
  this.signalBackendResync();
655
694
  },
656
695
  );
696
+ this.seedKnownKinds();
657
697
  }
658
698
 
659
699
  private signalBackendResync(): void {
@@ -668,13 +708,32 @@ export class TreeWatcher {
668
708
  * events route through here too — and for the recursive backend this is the
669
709
  * ONLY place filtering happens, so out-of-scope paths are dropped here.
670
710
  */
671
- handleEvent(absolutePath: string): void {
711
+ handleEvent(
712
+ absolutePath: string,
713
+ backendKind: BackendChangeKind = "change",
714
+ ): void {
672
715
  if (this.disposed) return;
716
+ const abs = path.resolve(absolutePath);
717
+ let kind: TreeChangeKind;
718
+ if (backendKind === "rename") {
719
+ try {
720
+ kind = fs.lstatSync(abs).isDirectory() ? "addDir" : "add";
721
+ } catch (err: unknown) {
722
+ const code =
723
+ err && typeof err === "object" && "code" in err
724
+ ? (err as { code?: string }).code
725
+ : undefined;
726
+ if (code !== "ENOENT") return;
727
+ kind = this.knownKinds.get(abs) === "directory" ? "unlinkDir" : "unlink";
728
+ }
729
+ } else {
730
+ kind = backendKind;
731
+ }
732
+ const isDir = kind === "addDir" || kind === "unlinkDir";
673
733
  // The recursive backend does no descent-time filtering, so this is the
674
734
  // authoritative emit gate; the chokidar backend's `ignored` pre-filters
675
735
  // but a slipped-through (or synthetic test) path is re-checked here too.
676
- if (!this.shouldEmit(absolutePath, false)) return;
677
- const abs = path.resolve(absolutePath);
736
+ if (!this.shouldEmit(abs, isDir)) return;
678
737
  const rel = path.relative(this.hqRoot, abs).split(path.sep).join("/");
679
738
  if (!this.pending.has(abs)) {
680
739
  const entryBytes = estimatePendingEntryBytes(abs, rel);
@@ -689,9 +748,43 @@ export class TreeWatcher {
689
748
  this.pendingBytes += entryBytes;
690
749
  }
691
750
  this.pending.set(abs, rel);
751
+ if (kind === "unlink" || kind === "unlinkDir") {
752
+ const deleteSnapshots =
753
+ this.captureLocalDeleteSnapshots?.(rel, kind) ?? [];
754
+ this.pendingChanges.set(abs, {
755
+ kind,
756
+ ...(deleteSnapshots.length > 0 ? { deleteSnapshots } : {}),
757
+ });
758
+ this.knownKinds.delete(abs);
759
+ } else {
760
+ this.pendingChanges.set(abs, { kind });
761
+ this.knownKinds.set(abs, isDir ? "directory" : "file");
762
+ }
692
763
  this.arm();
693
764
  }
694
765
 
766
+ private seedKnownKinds(): void {
767
+ const visit = (dir: string): void => {
768
+ let entries: fs.Dirent[];
769
+ try {
770
+ entries = fs.readdirSync(dir, { withFileTypes: true });
771
+ } catch {
772
+ return;
773
+ }
774
+ for (const entry of entries) {
775
+ const absolutePath = path.join(dir, entry.name);
776
+ if (entry.isDirectory()) {
777
+ if (!this.shouldEmit(absolutePath, true)) continue;
778
+ this.knownKinds.set(path.resolve(absolutePath), "directory");
779
+ visit(absolutePath);
780
+ } else if (this.shouldEmit(absolutePath, false)) {
781
+ this.knownKinds.set(path.resolve(absolutePath), "file");
782
+ }
783
+ }
784
+ };
785
+ visit(this.hqRoot);
786
+ }
787
+
695
788
  private recordBacklogOverflow(entryBytes: number): void {
696
789
  this.overflowed = true;
697
790
  this.droppedPaths += 1;
@@ -727,7 +820,10 @@ export class TreeWatcher {
727
820
  if (this.disposed) return;
728
821
  // Snapshot + clear the accumulated paths so the next window starts fresh
729
822
  // even if a listener re-enters synchronously.
730
- const batch: TreeChangeBatch = { paths: new Map(this.pending) };
823
+ const batch: TreeChangeBatch = {
824
+ paths: new Map(this.pending),
825
+ changes: new Map(this.pendingChanges),
826
+ };
731
827
  if (this.overflowed) {
732
828
  batch.overflowed = true;
733
829
  batch.droppedPaths = this.droppedPaths;
@@ -749,6 +845,7 @@ export class TreeWatcher {
749
845
 
750
846
  private clearPending(): void {
751
847
  this.pending.clear();
848
+ this.pendingChanges.clear();
752
849
  this.pendingBytes = 0;
753
850
  this.overflowed = false;
754
851
  this.overflowLogged = false;
@@ -18,11 +18,11 @@
18
18
  * pinning the exact regression so it can't silently return.
19
19
  */
20
20
 
21
- import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
21
+ import { mkdtemp, mkdir, rm, unlink, writeFile } from "node:fs/promises";
22
22
  import { tmpdir } from "node:os";
23
23
  import path from "node:path";
24
24
 
25
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
25
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
26
26
 
27
27
  import { TreeWatcher } from "../../src/watcher.js";
28
28
 
@@ -66,6 +66,66 @@ function waitForEmit(w: TreeWatcher, ms: number): Promise<boolean> {
66
66
  }
67
67
 
68
68
  describe("event-push watcher -- real chokidar scope (regression for 5.26.0)", () => {
69
+ it(
70
+ "preserves unlink classification and the captured pre-delete revision",
71
+ async () => {
72
+ const localPath = path.join(
73
+ hqRoot,
74
+ "companies",
75
+ "acme",
76
+ "knowledge",
77
+ "delete-me.md",
78
+ );
79
+ await writeFile(localPath, "# synced\n");
80
+ const capture = vi.fn(() => [
81
+ {
82
+ journalSlug: "acme",
83
+ journalPath: "knowledge/delete-me.md",
84
+ deleteScopeRoot: "knowledge/delete-me.md",
85
+ absolutePath: localPath,
86
+ remoteEtag: "etag-before-delete",
87
+ localHash: "hash-before-delete",
88
+ localKind: "file" as const,
89
+ },
90
+ ]);
91
+ watcher = new TreeWatcher({
92
+ hqRoot,
93
+ debounceMs: 250,
94
+ personalMode: false,
95
+ captureLocalDeleteSnapshots: capture,
96
+ });
97
+ watcher.start();
98
+ await new Promise((r) => setTimeout(r, 600));
99
+
100
+ const emitted = new Promise<Parameters<Parameters<TreeWatcher["onChange"]>[0]>[1]>(
101
+ (resolve) => {
102
+ const off = watcher!.onChange((_rel, batch) => {
103
+ off();
104
+ resolve(batch);
105
+ });
106
+ },
107
+ );
108
+ await unlink(localPath);
109
+ const batch = await emitted;
110
+ const change = batch?.changes?.get(localPath);
111
+
112
+ expect(capture).toHaveBeenCalledWith(
113
+ "companies/acme/knowledge/delete-me.md",
114
+ "unlink",
115
+ );
116
+ expect(change).toMatchObject({
117
+ kind: "unlink",
118
+ deleteSnapshots: [
119
+ expect.objectContaining({
120
+ remoteEtag: "etag-before-delete",
121
+ localHash: "hash-before-delete",
122
+ }),
123
+ ],
124
+ });
125
+ },
126
+ 8000,
127
+ );
128
+
69
129
  it(
70
130
  "FIRES on a companies/<slug>/knowledge/ edit when personalMode=false (the --companies scope)",
71
131
  async () => {