@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/cli/accept.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  VaultPermissionDeniedError,
15
15
  } from "../vault-client.js";
16
16
  import type { Membership } from "../vault-client.js";
17
+ import { emitCloudTelemetry } from "../telemetry-events.js";
17
18
 
18
19
  export interface AcceptOptions {
19
20
  /** Raw token or magic link (hq://accept/<token>) */
@@ -78,8 +79,26 @@ export async function accept(options: AcceptOptions): Promise<AcceptResult> {
78
79
  }
79
80
  }
80
81
 
82
+ void emitCloudTelemetry(client, {
83
+ eventName: "membership_accept",
84
+ source: "hq-cli",
85
+ companyUid: membership.companyUid,
86
+ properties: {
87
+ role: membership.role,
88
+ result: "success",
89
+ },
90
+ });
91
+
81
92
  return { membership, companySlug };
82
93
  } catch (err) {
94
+ void emitCloudTelemetry(client, {
95
+ eventName: "membership_accept",
96
+ source: "hq-cli",
97
+ properties: {
98
+ result: "failure",
99
+ errorCode: classifyAcceptError(err),
100
+ },
101
+ });
83
102
  if (err instanceof VaultAuthError) {
84
103
  throw new Error("Authentication failed — run `hq auth` to refresh your session");
85
104
  }
@@ -95,3 +114,11 @@ export async function accept(options: AcceptOptions): Promise<AcceptResult> {
95
114
  throw err;
96
115
  }
97
116
  }
117
+
118
+ function classifyAcceptError(err: unknown): string {
119
+ if (err instanceof VaultAuthError) return "auth";
120
+ if (err instanceof VaultConflictError) return "conflict";
121
+ if (err instanceof VaultNotFoundError) return "not_found";
122
+ if (err instanceof VaultPermissionDeniedError) return "permission";
123
+ return "error";
124
+ }
package/src/cli/invite.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  VaultConflictError,
15
15
  } from "../vault-client.js";
16
16
  import type { MembershipRole, Membership } from "../vault-client.js";
17
+ import { emitCloudTelemetry } from "../telemetry-events.js";
17
18
 
18
19
  export interface InviteOptions {
19
20
  /** Target — email address or person slug/uid */
@@ -83,6 +84,17 @@ export async function invite(options: InviteOptions): Promise<InviteResult> {
83
84
  });
84
85
 
85
86
  const magicLink = `hq://accept/${result.inviteToken}`;
87
+ void emitCloudTelemetry(client, {
88
+ eventName: "membership_invite",
89
+ source: "hq-cli",
90
+ companyUid,
91
+ properties: {
92
+ role,
93
+ result: "success",
94
+ targetType: isEmail ? "email" : "person",
95
+ allowedPrefixCount: allowedPrefixes?.length ?? 0,
96
+ },
97
+ });
86
98
 
87
99
  return {
88
100
  inviteToken: result.inviteToken,
@@ -90,6 +102,18 @@ export async function invite(options: InviteOptions): Promise<InviteResult> {
90
102
  membership: result.membership,
91
103
  };
92
104
  } catch (err) {
105
+ void emitCloudTelemetry(client, {
106
+ eventName: "membership_invite",
107
+ source: "hq-cli",
108
+ companyUid,
109
+ properties: {
110
+ role,
111
+ result: "failure",
112
+ errorCode: classifyInviteError(err),
113
+ targetType: isEmail ? "email" : "person",
114
+ allowedPrefixCount: allowedPrefixes?.length ?? 0,
115
+ },
116
+ });
93
117
  if (err instanceof VaultAuthError) {
94
118
  throw new Error("Authentication failed — run `hq auth` to refresh your session");
95
119
  }
@@ -103,6 +127,13 @@ export async function invite(options: InviteOptions): Promise<InviteResult> {
103
127
  }
104
128
  }
105
129
 
130
+ function classifyInviteError(err: unknown): string {
131
+ if (err instanceof VaultAuthError) return "auth";
132
+ if (err instanceof VaultPermissionDeniedError) return "permission";
133
+ if (err instanceof VaultConflictError) return "conflict";
134
+ return "error";
135
+ }
136
+
106
137
  /**
107
138
  * List pending invites for a company.
108
139
  */
@@ -13,6 +13,7 @@ import {
13
13
  VaultConflictError,
14
14
  } from "../vault-client.js";
15
15
  import type { MembershipRole, Membership } from "../vault-client.js";
16
+ import { emitCloudTelemetry } from "../telemetry-events.js";
16
17
 
17
18
  export interface PromoteOptions {
18
19
  /** Person slug or UID of the member to promote */
@@ -66,8 +67,30 @@ export async function promote(options: PromoteOptions): Promise<PromoteResult> {
66
67
  companyUid,
67
68
  });
68
69
 
70
+ void emitCloudTelemetry(client, {
71
+ eventName: "membership_promote",
72
+ source: "hq-cli",
73
+ companyUid,
74
+ properties: {
75
+ role: newRole,
76
+ result: "success",
77
+ allowedPrefixCount: allowedPrefixes?.length ?? 0,
78
+ },
79
+ });
80
+
69
81
  return { membership };
70
82
  } catch (err) {
83
+ void emitCloudTelemetry(client, {
84
+ eventName: "membership_promote",
85
+ source: "hq-cli",
86
+ companyUid,
87
+ properties: {
88
+ role: newRole,
89
+ result: "failure",
90
+ errorCode: classifyPromoteError(err),
91
+ allowedPrefixCount: allowedPrefixes?.length ?? 0,
92
+ },
93
+ });
71
94
  if (err instanceof VaultAuthError) {
72
95
  throw new Error("Authentication failed — run `hq auth` to refresh your session");
73
96
  }
@@ -84,6 +107,14 @@ export async function promote(options: PromoteOptions): Promise<PromoteResult> {
84
107
  }
85
108
  }
86
109
 
110
+ function classifyPromoteError(err: unknown): string {
111
+ if (err instanceof VaultAuthError) return "auth";
112
+ if (err instanceof VaultPermissionDeniedError) return "permission";
113
+ if (err instanceof VaultNotFoundError) return "not_found";
114
+ if (err instanceof VaultConflictError) return "conflict";
115
+ return "error";
116
+ }
117
+
87
118
  // ---------------------------------------------------------------------------
88
119
  // Helpers
89
120
  // ---------------------------------------------------------------------------
@@ -389,17 +389,46 @@ describe("reindex", () => {
389
389
  expect(fs.readFileSync(path.join(link, "note.md"), "utf-8")).toBe("owned by personal\n");
390
390
  });
391
391
 
392
- // ── operation lock (mutual exclusion with sync/rescue) ──────────────────
392
+ // ── operation lock: reindex uses a SEPARATE scope from sync/rescue ──────
393
+
394
+ // Regression for feedback_ed98d810 (the "hq reindex indefinite hang"): when
395
+ // a watch-mode hq-sync-runner holds the per-root "operation" lock for its
396
+ // whole lifetime, a shared lock starved `hq reindex` forever. reindex now
397
+ // takes its own "reindex" scope, so it must run to completion even while the
398
+ // sync/watch "operation" lock is held by a LIVE holder.
399
+ it("completes while the sync/watch operation lock is held (no starvation)", () => {
400
+ // Simulate the long-lived watch runner holding the "operation"-scope lock.
401
+ // pid = this process → the lock probes it as ALIVE (a live same-PID holder
402
+ // is treated as busy, never reclaimed), so it is a genuine live holder.
403
+ const syncLock = lockPathFor(root); // default "operation" scope
404
+ fs.mkdirSync(path.dirname(syncLock), { recursive: true });
405
+ fs.writeFileSync(
406
+ syncLock,
407
+ JSON.stringify({
408
+ pid: process.pid,
409
+ command: "sync",
410
+ startedAt: new Date(0).toISOString(),
411
+ hqRoot: root,
412
+ }),
413
+ );
414
+ // Even with a zero-wait bound (never block), reindex acquires its OWN scope
415
+ // and finishes — proving the operation lock no longer gates it.
416
+ const { status } = reindex({ repoRoot: root, lockTimeoutSec: 0 });
417
+ expect(status).toBe(0);
418
+ expect(fs.existsSync(path.join(root, ".claude/skills"))).toBe(true);
419
+ // The sync/watch holder is untouched.
420
+ expect(fs.existsSync(syncLock)).toBe(true);
421
+ });
393
422
 
394
- it("refuses (OPERATION_LOCKED_EXIT) when another op holds this root's lock (lockTimeoutSec:0)", () => {
395
- // A live holder in another process (pid 1) with lockTimeoutSec:0 reindex
396
- // refuses immediately and does no work. (The new default is to WAIT; the
397
- // wait-then-acquire path is covered in operation-lock.test.ts.)
398
- const lp = lockPathFor(root);
423
+ it("refuses (OPERATION_LOCKED_EXIT) when another reindex holds the reindex-scope lock (lockTimeoutSec:0)", () => {
424
+ // reindex-vs-reindex is still mutually exclusive. A live holder on the
425
+ // "reindex" scope with lockTimeoutSec:0 reindex refuses immediately and
426
+ // does no work. (The wait-then-acquire path is in operation-lock.test.ts.)
427
+ const lp = lockPathFor(root, "reindex");
399
428
  fs.mkdirSync(path.dirname(lp), { recursive: true });
400
429
  fs.writeFileSync(
401
430
  lp,
402
- JSON.stringify({ pid: 1, command: "sync", startedAt: new Date(0).toISOString(), hqRoot: root }),
431
+ JSON.stringify({ pid: 1, command: "reindex", startedAt: new Date(0).toISOString(), hqRoot: root }),
403
432
  );
404
433
  const before = fs.existsSync(path.join(root, ".claude/skills"));
405
434
  const { status } = reindex({ repoRoot: root, lockTimeoutSec: 0 });
@@ -408,22 +437,24 @@ describe("reindex", () => {
408
437
  expect(fs.existsSync(path.join(root, ".claude/skills"))).toBe(before);
409
438
  });
410
439
 
411
- it("skipLock bypasses the lock (internal sync/rescue caller path)", () => {
412
- const lp = lockPathFor(root);
440
+ it("skipLock bypasses the reindex lock (internal sync/rescue caller path)", () => {
441
+ const lp = lockPathFor(root, "reindex");
413
442
  fs.mkdirSync(path.dirname(lp), { recursive: true });
414
443
  fs.writeFileSync(
415
444
  lp,
416
- JSON.stringify({ pid: 1, command: "sync", startedAt: new Date(0).toISOString(), hqRoot: root }),
445
+ JSON.stringify({ pid: 1, command: "reindex", startedAt: new Date(0).toISOString(), hqRoot: root }),
417
446
  );
418
- // Even with a live holder on record, the internal caller (which already
419
- // holds the lock) runs to completion.
447
+ // Even with a live reindex holder on record, the internal caller (which
448
+ // serializes reindex itself at the tail of a pass) runs to completion.
420
449
  const { status } = reindex({ repoRoot: root, skipLock: true });
421
450
  expect(status).toBe(0);
422
451
  expect(fs.existsSync(path.join(root, ".claude/skills"))).toBe(true);
423
452
  });
424
453
 
425
- it("releases the lock after a normal run (no leftover lock file)", () => {
454
+ it("releases the reindex-scope lock after a normal run (no leftover lock file)", () => {
426
455
  reindex({ repoRoot: root });
456
+ expect(fs.existsSync(lockPathFor(root, "reindex"))).toBe(false);
457
+ // ...and never created the sync/rescue "operation" lock.
427
458
  expect(fs.existsSync(lockPathFor(root))).toBe(false);
428
459
  });
429
460
 
@@ -526,4 +557,73 @@ describe("reindex", () => {
526
557
  fs.lstatSync(path.join(root, ".claude/skills/core:demo/SKILL.md")).isSymbolicLink(),
527
558
  ).toBe(true);
528
559
  });
560
+
561
+ // --- session-log capture into workspace/.session-logs ---------------------
562
+ describe("session-log capture", () => {
563
+ let claudeConfigDir: string;
564
+ // Claude Code encodes the project cwd into its projects-dir name by
565
+ // replacing every non-alphanumeric char with '-'; mirror that here.
566
+ const slugFor = (p: string): string => p.replace(/[^a-zA-Z0-9]/g, "-");
567
+
568
+ beforeEach(() => {
569
+ claudeConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "ms-claude-"));
570
+ process.env.CLAUDE_CONFIG_DIR = claudeConfigDir;
571
+ });
572
+
573
+ afterEach(() => {
574
+ fs.rmSync(claudeConfigDir, { recursive: true, force: true });
575
+ delete process.env.CLAUDE_CONFIG_DIR;
576
+ });
577
+
578
+ function seedProjectLogs(): string {
579
+ const projDir = path.join(claudeConfigDir, "projects", slugFor(root));
580
+ fs.mkdirSync(path.join(projDir, "tool-results"), { recursive: true });
581
+ fs.writeFileSync(path.join(projDir, "session.jsonl"), "line-1\n");
582
+ fs.writeFileSync(path.join(projDir, "tool-results", "r.txt"), "result\n");
583
+ return projDir;
584
+ }
585
+
586
+ it("copies all files and folders from the projects dir into workspace/.session-logs", () => {
587
+ seedProjectLogs();
588
+
589
+ expect(reindex({ repoRoot: root }).status).toBe(0);
590
+
591
+ const dest = path.join(root, "workspace", ".session-logs");
592
+ expect(fs.readFileSync(path.join(dest, "session.jsonl"), "utf8")).toBe("line-1\n");
593
+ expect(fs.readFileSync(path.join(dest, "tool-results", "r.txt"), "utf8")).toBe("result\n");
594
+ });
595
+
596
+ it("overwrites an existing dest file with the newer source copy", () => {
597
+ seedProjectLogs();
598
+ const dest = path.join(root, "workspace", ".session-logs");
599
+ fs.mkdirSync(dest, { recursive: true });
600
+ fs.writeFileSync(path.join(dest, "session.jsonl"), "STALE\n");
601
+
602
+ expect(reindex({ repoRoot: root }).status).toBe(0);
603
+
604
+ expect(fs.readFileSync(path.join(dest, "session.jsonl"), "utf8")).toBe("line-1\n");
605
+ });
606
+
607
+ it("never deletes dest entries that have no source counterpart", () => {
608
+ seedProjectLogs();
609
+ const dest = path.join(root, "workspace", ".session-logs");
610
+ fs.mkdirSync(path.join(dest, "old-run"), { recursive: true });
611
+ fs.writeFileSync(path.join(dest, "old-run", "keep.jsonl"), "keep\n");
612
+ fs.writeFileSync(path.join(dest, "orphan.txt"), "orphan\n");
613
+
614
+ expect(reindex({ repoRoot: root }).status).toBe(0);
615
+
616
+ // Source content is present...
617
+ expect(fs.existsSync(path.join(dest, "session.jsonl"))).toBe(true);
618
+ // ...and the dest-only entries survived (no prune).
619
+ expect(fs.readFileSync(path.join(dest, "old-run", "keep.jsonl"), "utf8")).toBe("keep\n");
620
+ expect(fs.readFileSync(path.join(dest, "orphan.txt"), "utf8")).toBe("orphan\n");
621
+ });
622
+
623
+ it("no-ops (still exit 0) when there is no projects dir for this root", () => {
624
+ // No seedProjectLogs() → the source dir doesn't exist.
625
+ expect(reindex({ repoRoot: root }).status).toBe(0);
626
+ expect(fs.existsSync(path.join(root, "workspace", ".session-logs"))).toBe(false);
627
+ });
628
+ });
529
629
  });
@@ -2,8 +2,9 @@
2
2
  * hq reindex — surfaces namespaced skills as Claude Code skill wrappers under
3
3
  * .claude/skills/<ns>:<skill>/ (one symlink per source file), mirrors
4
4
  * personal/{knowledge,policies,workers,settings}/<entry> into core/<type>/,
5
- * prunes orphan wrappers + legacy command symlinks, and regenerates the
6
- * workers registry.
5
+ * prunes orphan wrappers + legacy command symlinks, captures this root's Claude
6
+ * Code session logs into workspace/.session-logs, and regenerates the workers
7
+ * registry.
7
8
  *
8
9
  * The logic historically lived in a bundled bash script (scripts/reindex.sh)
9
10
  * that this module exec'd via `bash`. It is now implemented natively in
@@ -16,6 +17,7 @@
16
17
  */
17
18
  import { spawnSync } from "child_process";
18
19
  import * as fs from "fs";
20
+ import * as os from "os";
19
21
  import * as path from "path";
20
22
  import {
21
23
  acquireOperationLock,
@@ -25,29 +27,45 @@ import {
25
27
  OPERATION_LOCK_UNWRITABLE_EXIT,
26
28
  type LockHandle,
27
29
  } from "../operation-lock.js";
30
+ import {
31
+ emitCloudTelemetry,
32
+ type CloudTelemetryClient,
33
+ type TelemetryClaims,
34
+ } from "../telemetry-events.js";
28
35
 
29
36
  export interface ReindexOptions {
30
37
  /** HQ root to operate on. Defaults to process.cwd(). */
31
38
  repoRoot?: string;
32
39
  /**
33
- * Skip the per-root operation lock. Internal callers (`sync()` / `rescue()`)
34
- * already hold the lock for this root and pass `true` so reindex doesn't try
35
- * to re-acquire it and refuse against their own live PID. Standalone callers
36
- * (`hq reindex`, the reindex hook) leave it falsy so reindex is mutually
37
- * exclusive with a running sync/rescue.
40
+ * Skip reindex's own per-root "reindex"-scope lock. Internal callers
41
+ * (`sync()` / `rescue()`) run reindex inline at the tail of a pass they
42
+ * already serialize, and pass `true` so a standalone `hq reindex` holding the
43
+ * reindex lock never blocks the sync/rescue tail (reindex is idempotent and
44
+ * re-run every pass, so a benign overlap self-heals). Standalone callers
45
+ * (`hq reindex`, the reindex hook) leave it falsy so concurrent reindexes are
46
+ * mutually exclusive.
38
47
  */
39
48
  skipLock?: boolean;
40
49
  /**
41
- * Bounded wait (seconds) for the per-root operation lock when a sync/rescue
42
- * is already running. `0` → refuse immediately; omitted → inherit
50
+ * Bounded wait (seconds) for the per-root "reindex"-scope lock when another
51
+ * reindex is already running. `0` → refuse immediately; omitted → inherit
43
52
  * `HQ_OP_LOCK_TIMEOUT` / infinite wait. Ignored when `skipLock` is set.
44
53
  *
45
- * NB: standalone `hq reindex` waits by default, which is what a human running
46
- * it wants. The hq-core reindex HOOK (Stop / PostToolUse) should set a small
47
- * `HQ_OP_LOCK_TIMEOUT` (or `0`) so a hook fired mid-sync never blocks the
48
- * interactive agent indefinitely.
54
+ * NB: reindex uses a lock scope SEPARATE from sync/rescue's "operation" lock,
55
+ * so a long-lived watch-mode sync-runner (which holds the operation lock for
56
+ * its whole lifetime) can never starve a standalone `hq reindex`
57
+ * (feedback_ed98d810). Standalone `hq reindex` waits by default, which is what
58
+ * a human running it wants; the hq-core reindex HOOK (Stop / PostToolUse)
59
+ * should set a small `HQ_OP_LOCK_TIMEOUT` (or `0`) so a hook fired while
60
+ * another reindex runs never blocks the interactive agent indefinitely.
49
61
  */
50
62
  lockTimeoutSec?: number;
63
+ /** Optional hq-pro ACTION telemetry sink. Omitted in tests/standalone hooks. */
64
+ telemetryClient?: CloudTelemetryClient | null;
65
+ telemetryClaims?: TelemetryClaims | null;
66
+ telemetrySessionId?: string;
67
+ telemetryCompanyUid?: string;
68
+ changedCount?: number;
51
69
  }
52
70
 
53
71
  export interface ReindexResult {
@@ -181,6 +199,46 @@ function safeSymlink(target: string, linkPath: string, label: string): boolean {
181
199
  }
182
200
  }
183
201
 
202
+ // Copy every file/folder under Claude Code's session-log directory for this HQ
203
+ // root (`<claude-config>/projects/<encoded-root>`) into
204
+ // `workspace/.session-logs`, so the raw session transcripts are captured inside
205
+ // the HQ tree (where sync/search can see them) on each reindex.
206
+ //
207
+ // Contract (per the feature request):
208
+ // - copies ALL files and folders from the projects dir into the dest,
209
+ // - OVERWRITES existing dest files (force), and
210
+ // - NEVER deletes dest entries that lack a source counterpart — this is a
211
+ // one-way overlay, not a mirror, so anything already parked in
212
+ // workspace/.session-logs survives even after Claude Code rotates or prunes
213
+ // its own projects dir.
214
+ //
215
+ // Best-effort: any failure warns to stderr and is swallowed so it can never
216
+ // fail the reindex. `<claude-config>` honors CLAUDE_CONFIG_DIR, else ~/.claude.
217
+ function copySessionLogs(root: string): void {
218
+ // Claude Code names a project's log dir by replacing every non-alphanumeric
219
+ // character in the project's cwd with '-' (e.g. /home/ec2-user/hq ->
220
+ // -home-ec2-user-hq). Re-derive that slug so we target THIS root's logs.
221
+ const slug = root.replace(/[^a-zA-Z0-9]/g, "-");
222
+ const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
223
+ const src = path.join(claudeConfigDir, "projects", slug);
224
+ if (!isDir(src)) return; // no session logs for this root yet — nothing to do
225
+
226
+ const dest = path.join(root, "workspace", ".session-logs");
227
+ if (!safeMkdir(dest, "workspace/.session-logs directory")) return;
228
+
229
+ try {
230
+ // recursive: copy the whole tree; force: overwrite existing dest files.
231
+ // cpSync copies src INTO dest and never removes dest-only entries — exactly
232
+ // the "overwrite, but don't delete extras" contract we want.
233
+ fs.cpSync(src, dest, { recursive: true, force: true });
234
+ } catch (err) {
235
+ warn(
236
+ `reindex: could not copy session logs from '${src}' to '${dest}' ` +
237
+ `(${(err as NodeJS.ErrnoException).code ?? "error"}: ${(err as Error).message}); skipping.`,
238
+ );
239
+ }
240
+ }
241
+
184
242
  // --- legacy `.claude/commands/<ns>/<skill>.md` symlink matcher --------------
185
243
  // Mirrors the bash `case` patterns; `*` matches any chars (including `/`).
186
244
  function isLegacyCommandTarget(t: string): boolean {
@@ -202,23 +260,32 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
202
260
  const rawRoot = opts.repoRoot ?? process.cwd();
203
261
  if (!isDir(rawRoot)) {
204
262
  warn(`reindex: REPO_ROOT '${rawRoot}' is not a directory`);
205
- return { status: 1 };
263
+ const result = { status: 1 };
264
+ emitReindexTelemetry(opts, result, "not_attempted");
265
+ return result;
206
266
  }
207
267
  const root = path.resolve(rawRoot);
208
268
 
209
- // Acquire the per-root operation lock unless an internal caller (sync/rescue,
210
- // which already hold it) opted out. A live holder refuse fast with the
211
- // holder's command + PID. The whole body runs inside the try so the lock is
212
- // released on every exit path (the process-level signal/exit hooks are the
213
- // crash backstop).
269
+ // Acquire the per-root "reindex"-scope lock unless an internal caller
270
+ // (sync/rescue) opted out. This scope is SEPARATE from sync/rescue's
271
+ // "operation" lock, so a long-lived watch-mode sync-runner holding the
272
+ // operation lock never starves us (feedback_ed98d810) we only contend with
273
+ // other reindexes. A live holder → refuse fast with the holder's command +
274
+ // PID. The whole body runs inside the try so the lock is released on every
275
+ // exit path (the process-level signal/exit hooks are the crash backstop).
214
276
  let opLock: LockHandle | null = null;
215
277
  if (!opts.skipLock) {
216
278
  try {
217
- opLock = acquireOperationLock(root, "reindex", { timeoutSec: opts.lockTimeoutSec });
279
+ opLock = acquireOperationLock(root, "reindex", {
280
+ scope: "reindex",
281
+ timeoutSec: opts.lockTimeoutSec,
282
+ });
218
283
  } catch (err) {
219
284
  if (err instanceof OperationLockedError) {
220
285
  warn(err.message);
221
- return { status: OPERATION_LOCKED_EXIT };
286
+ const result = { status: OPERATION_LOCKED_EXIT };
287
+ emitReindexTelemetry(opts, result, "locked");
288
+ return result;
222
289
  }
223
290
  if (err instanceof OperationLockUnwritableError) {
224
291
  // The lock dir isn't writable (e.g. macOS EPERM / Linux EACCES on a
@@ -227,7 +294,9 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
227
294
  // and exit cleanly instead of crashing with a raw `EPERM ... open`
228
295
  // (HQ-CLI-2).
229
296
  warn(err.message);
230
- return { status: OPERATION_LOCK_UNWRITABLE_EXIT };
297
+ const result = { status: OPERATION_LOCK_UNWRITABLE_EXIT };
298
+ emitReindexTelemetry(opts, result, "unwritable");
299
+ return result;
231
300
  }
232
301
  throw err;
233
302
  }
@@ -522,6 +591,11 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
522
591
  }
523
592
  }
524
593
 
594
+ // --- Session-log capture --------------------------------------------------
595
+ // Overlay this root's Claude Code session logs into workspace/.session-logs
596
+ // (copy-all, overwrite, never-prune). Best-effort; never fails the reindex.
597
+ copySessionLogs(root);
598
+
525
599
  // --- Workers registry regeneration ----------------------------------------
526
600
  // Source of truth: each worker.yaml. The registry is a derived index — the
527
601
  // generator (when present in the operated-on tree) keeps it in sync.
@@ -548,8 +622,34 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
548
622
  );
549
623
  }
550
624
 
551
- return { status: 0 };
625
+ const result = { status: 0 };
626
+ emitReindexTelemetry(opts, result, opts.skipLock ? "skipped" : "acquired");
627
+ return result;
552
628
  } finally {
553
629
  opLock?.release();
554
630
  }
555
631
  }
632
+
633
+ function emitReindexTelemetry(
634
+ opts: ReindexOptions,
635
+ result: ReindexResult,
636
+ lockResult: string,
637
+ ): void {
638
+ void emitCloudTelemetry(
639
+ opts.telemetryClient,
640
+ {
641
+ eventName: "reindex_run",
642
+ source: "hq-reindex",
643
+ ...(opts.telemetryCompanyUid ? { companyUid: opts.telemetryCompanyUid } : {}),
644
+ ...(opts.telemetrySessionId ? { sessionId: opts.telemetrySessionId } : {}),
645
+ properties: {
646
+ result: result.status === 0 ? "completed" : "failed",
647
+ statusCode: result.status,
648
+ lockResult,
649
+ changedCount: opts.changedCount ?? 0,
650
+ skipLockState: opts.skipLock === true ? "skipped" : "acquired",
651
+ },
652
+ },
653
+ { claims: opts.telemetryClaims },
654
+ );
655
+ }