@indigoai-us/hq-cloud 6.14.0 → 6.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/dist/bin/sync-runner-company.d.ts +4 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  3. package/dist/bin/sync-runner-company.js +50 -1
  4. package/dist/bin/sync-runner-company.js.map +1 -1
  5. package/dist/bin/sync-runner-watch-loop.d.ts +3 -2
  6. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  7. package/dist/bin/sync-runner-watch-loop.js +21 -7
  8. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  9. package/dist/bin/sync-runner.d.ts +6 -2
  10. package/dist/bin/sync-runner.d.ts.map +1 -1
  11. package/dist/bin/sync-runner.js +162 -11
  12. package/dist/bin/sync-runner.js.map +1 -1
  13. package/dist/bin/sync-runner.test.js +155 -17
  14. package/dist/bin/sync-runner.test.js.map +1 -1
  15. package/dist/cli/accept.d.ts.map +1 -1
  16. package/dist/cli/accept.js +29 -0
  17. package/dist/cli/accept.js.map +1 -1
  18. package/dist/cli/invite.d.ts.map +1 -1
  19. package/dist/cli/invite.js +33 -0
  20. package/dist/cli/invite.js.map +1 -1
  21. package/dist/cli/promote.d.ts.map +1 -1
  22. package/dist/cli/promote.js +33 -0
  23. package/dist/cli/promote.js.map +1 -1
  24. package/dist/cli/reindex.d.ts +23 -11
  25. package/dist/cli/reindex.d.ts.map +1 -1
  26. package/dist/cli/reindex.js +39 -10
  27. package/dist/cli/reindex.js.map +1 -1
  28. package/dist/cli/reindex.test.js +40 -13
  29. package/dist/cli/reindex.test.js.map +1 -1
  30. package/dist/cli/share.js +47 -4
  31. package/dist/cli/share.js.map +1 -1
  32. package/dist/cli/share.test.js +123 -2
  33. package/dist/cli/share.test.js.map +1 -1
  34. package/dist/cli/sync-scope.test.js +84 -0
  35. package/dist/cli/sync-scope.test.js.map +1 -1
  36. package/dist/cli/sync.d.ts +44 -3
  37. package/dist/cli/sync.d.ts.map +1 -1
  38. package/dist/cli/sync.js +156 -7
  39. package/dist/cli/sync.js.map +1 -1
  40. package/dist/cli/sync.test.js +133 -0
  41. package/dist/cli/sync.test.js.map +1 -1
  42. package/dist/company-resolver.d.ts +21 -0
  43. package/dist/company-resolver.d.ts.map +1 -1
  44. package/dist/company-resolver.js +31 -6
  45. package/dist/company-resolver.js.map +1 -1
  46. package/dist/company-resolver.test.js +44 -1
  47. package/dist/company-resolver.test.js.map +1 -1
  48. package/dist/ignore.d.ts +10 -0
  49. package/dist/ignore.d.ts.map +1 -1
  50. package/dist/ignore.js +23 -2
  51. package/dist/ignore.js.map +1 -1
  52. package/dist/ignore.test.js +13 -1
  53. package/dist/ignore.test.js.map +1 -1
  54. package/dist/index.d.ts +4 -2
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +4 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/journal.d.ts +12 -0
  59. package/dist/journal.d.ts.map +1 -1
  60. package/dist/journal.js +12 -0
  61. package/dist/journal.js.map +1 -1
  62. package/dist/lib/exit-codes.d.ts +25 -0
  63. package/dist/lib/exit-codes.d.ts.map +1 -0
  64. package/dist/lib/exit-codes.js +25 -0
  65. package/dist/lib/exit-codes.js.map +1 -0
  66. package/dist/object-io.d.ts.map +1 -1
  67. package/dist/object-io.js +45 -14
  68. package/dist/object-io.js.map +1 -1
  69. package/dist/object-io.test.js +61 -1
  70. package/dist/object-io.test.js.map +1 -1
  71. package/dist/operation-lock.d.ts +36 -6
  72. package/dist/operation-lock.d.ts.map +1 -1
  73. package/dist/operation-lock.js +34 -11
  74. package/dist/operation-lock.js.map +1 -1
  75. package/dist/operation-lock.test.js +30 -0
  76. package/dist/operation-lock.test.js.map +1 -1
  77. package/dist/s3.d.ts +1 -0
  78. package/dist/s3.d.ts.map +1 -1
  79. package/dist/s3.js +75 -2
  80. package/dist/s3.js.map +1 -1
  81. package/dist/s3.test.js +56 -1
  82. package/dist/s3.test.js.map +1 -1
  83. package/dist/scope-shrink.d.ts +12 -0
  84. package/dist/scope-shrink.d.ts.map +1 -1
  85. package/dist/scope-shrink.js +12 -0
  86. package/dist/scope-shrink.js.map +1 -1
  87. package/dist/scope-shrink.test.js +31 -0
  88. package/dist/scope-shrink.test.js.map +1 -1
  89. package/dist/skill-telemetry.d.ts +41 -0
  90. package/dist/skill-telemetry.d.ts.map +1 -1
  91. package/dist/skill-telemetry.js +111 -7
  92. package/dist/skill-telemetry.js.map +1 -1
  93. package/dist/skill-telemetry.test.js +187 -5
  94. package/dist/skill-telemetry.test.js.map +1 -1
  95. package/dist/sync/event-sync.d.ts +3 -0
  96. package/dist/sync/event-sync.d.ts.map +1 -1
  97. package/dist/sync/event-sync.js +23 -0
  98. package/dist/sync/event-sync.js.map +1 -1
  99. package/dist/sync/pull-scope.d.ts +21 -0
  100. package/dist/sync/pull-scope.d.ts.map +1 -1
  101. package/dist/sync/pull-scope.js +46 -13
  102. package/dist/sync/pull-scope.js.map +1 -1
  103. package/dist/telemetry-events.d.ts +31 -0
  104. package/dist/telemetry-events.d.ts.map +1 -0
  105. package/dist/telemetry-events.js +140 -0
  106. package/dist/telemetry-events.js.map +1 -0
  107. package/dist/telemetry-events.test.d.ts +2 -0
  108. package/dist/telemetry-events.test.d.ts.map +1 -0
  109. package/dist/telemetry-events.test.js +73 -0
  110. package/dist/telemetry-events.test.js.map +1 -0
  111. package/dist/telemetry.js +1 -1
  112. package/dist/telemetry.js.map +1 -1
  113. package/dist/vault-client.d.ts +38 -5
  114. package/dist/vault-client.d.ts.map +1 -1
  115. package/dist/vault-client.js +69 -6
  116. package/dist/vault-client.js.map +1 -1
  117. package/dist/vault-client.test.js +31 -1
  118. package/dist/vault-client.test.js.map +1 -1
  119. package/dist/watcher.d.ts +7 -0
  120. package/dist/watcher.d.ts.map +1 -1
  121. package/dist/watcher.js +53 -1
  122. package/dist/watcher.js.map +1 -1
  123. package/dist/watcher.test.js +57 -0
  124. package/dist/watcher.test.js.map +1 -1
  125. package/package.json +1 -1
  126. package/pnpm-workspace.yaml +2 -0
  127. package/scripts/presign-transport-e2e.mjs +2 -2
  128. package/src/bin/sync-runner-company.ts +65 -1
  129. package/src/bin/sync-runner-watch-loop.ts +24 -8
  130. package/src/bin/sync-runner.test.ts +194 -16
  131. package/src/bin/sync-runner.ts +209 -11
  132. package/src/cli/accept.ts +27 -0
  133. package/src/cli/invite.ts +31 -0
  134. package/src/cli/promote.ts +31 -0
  135. package/src/cli/reindex.test.ts +44 -13
  136. package/src/cli/reindex.ts +74 -21
  137. package/src/cli/share.test.ts +139 -2
  138. package/src/cli/share.ts +48 -2
  139. package/src/cli/sync-scope.test.ts +90 -0
  140. package/src/cli/sync.test.ts +154 -0
  141. package/src/cli/sync.ts +224 -5
  142. package/src/company-resolver.test.ts +51 -0
  143. package/src/company-resolver.ts +40 -5
  144. package/src/ignore.test.ts +18 -1
  145. package/src/ignore.ts +24 -2
  146. package/src/index.ts +22 -1
  147. package/src/journal.ts +12 -0
  148. package/src/lib/exit-codes.ts +24 -0
  149. package/src/object-io.test.ts +66 -1
  150. package/src/object-io.ts +51 -14
  151. package/src/operation-lock.test.ts +33 -0
  152. package/src/operation-lock.ts +46 -11
  153. package/src/s3.test.ts +87 -0
  154. package/src/s3.ts +93 -2
  155. package/src/scope-shrink.test.ts +32 -0
  156. package/src/scope-shrink.ts +12 -0
  157. package/src/skill-telemetry.test.ts +247 -4
  158. package/src/skill-telemetry.ts +134 -6
  159. package/src/sync/event-sync.ts +37 -0
  160. package/src/sync/pull-scope.ts +62 -13
  161. package/src/telemetry-events.test.ts +88 -0
  162. package/src/telemetry-events.ts +205 -0
  163. package/src/telemetry.ts +1 -1
  164. package/src/vault-client.test.ts +39 -1
  165. package/src/vault-client.ts +115 -14
  166. package/src/watcher.test.ts +82 -0
  167. package/src/watcher.ts +69 -0
package/src/s3.test.ts CHANGED
@@ -93,6 +93,7 @@ import {
93
93
  decodeSymlinkMetadataValue,
94
94
  FILE_MTIME_META_KEY,
95
95
  FILE_BTIME_META_KEY,
96
+ validateVaultUploadKey,
96
97
  } from "./s3.js";
97
98
  import {
98
99
  setObjectIOFactory,
@@ -1384,3 +1385,89 @@ describe("downloadFile", () => {
1384
1385
  expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(true);
1385
1386
  });
1386
1387
  });
1388
+
1389
+ describe("validateVaultUploadKey — direct-S3 key poisoning guard (incident 2026-07-11)", () => {
1390
+ // The direct-S3 STS transport bypasses hq-pro's server-side validateObjectKey
1391
+ // (files-acl.ts), so uploadFile/uploadSymlink must enforce the SAME rules
1392
+ // before the PUT. Incident: a stale doubled local tree
1393
+ // (companies/indigo/companies/indigo/…) pushed 25 literal `companies/…` keys
1394
+ // into a company vault; the presign download API then rejected every one
1395
+ // (INVALID_KEY_COMPANIES_SCOPED), erroring the whole company sync fleet-wide.
1396
+ let tmpFile: string;
1397
+
1398
+ function makePersonalCtx(): EntityContext {
1399
+ return { ...makeCtx(), uid: "prs_TEST" };
1400
+ }
1401
+
1402
+ beforeEach(() => {
1403
+ sentCommands.length = 0;
1404
+ tmpFile = path.join(
1405
+ os.tmpdir(),
1406
+ `s3-keyguard-test-${Date.now()}-${Math.random()}.md`,
1407
+ );
1408
+ fs.writeFileSync(tmpFile, "hello");
1409
+ });
1410
+
1411
+ afterEach(() => {
1412
+ try {
1413
+ fs.unlinkSync(tmpFile);
1414
+ } catch {
1415
+ // already gone
1416
+ }
1417
+ });
1418
+
1419
+ it("validateVaultUploadKey rejects companies/-prefixed keys in company scope with the server's error code", () => {
1420
+ expect(() =>
1421
+ validateVaultUploadKey("companies/indigo/knowledge/x.md", "company"),
1422
+ ).toThrowError(
1423
+ expect.objectContaining({ code: "INVALID_KEY_COMPANIES_SCOPED" }),
1424
+ );
1425
+ });
1426
+
1427
+ it("validateVaultUploadKey allows companies/-prefixed keys in personal scope (local non-cloud companies)", () => {
1428
+ expect(() =>
1429
+ validateVaultUploadKey("companies/indigo/knowledge/x.md", "personal"),
1430
+ ).not.toThrow();
1431
+ });
1432
+
1433
+ it.each([
1434
+ ["", "INVALID_KEY_EMPTY"],
1435
+ ["/leading.md", "INVALID_KEY_LEADING_SLASH"],
1436
+ ["folder/", "INVALID_KEY_TRAILING_SLASH"],
1437
+ ["a//b.md", "INVALID_KEY_DOUBLE_SLASH"],
1438
+ ["a/../b.md", "INVALID_KEY_DOT_COMPONENT"],
1439
+ ["a/./b.md", "INVALID_KEY_DOT_COMPONENT"],
1440
+ ["bad\x00key.md", "INVALID_KEY_CONTROL_CHARS"],
1441
+ ])(
1442
+ "validateVaultUploadKey mirrors the server rule set: %j → %s",
1443
+ (key, code) => {
1444
+ expect(() => validateVaultUploadKey(key, "company")).toThrowError(
1445
+ expect.objectContaining({ code }),
1446
+ );
1447
+ expect(() => validateVaultUploadKey(key, "personal")).toThrowError(
1448
+ expect.objectContaining({ code }),
1449
+ );
1450
+ },
1451
+ );
1452
+
1453
+ it("uploadFile refuses to PUT a companies/-prefixed key into a company (cmp_) vault — no S3 command is sent", async () => {
1454
+ await expect(
1455
+ uploadFile(makeCtx(), tmpFile, "companies/indigo/knowledge/x.md"),
1456
+ ).rejects.toMatchObject({ code: "INVALID_KEY_COMPANIES_SCOPED" });
1457
+ expect(sentCommands.length).toBe(0);
1458
+ });
1459
+
1460
+ it("uploadSymlink refuses to PUT a companies/-prefixed key into a company (cmp_) vault — no S3 command is sent", async () => {
1461
+ await expect(
1462
+ uploadSymlink(makeCtx(), "../target.md", "companies/indigo/link.md"),
1463
+ ).rejects.toMatchObject({ code: "INVALID_KEY_COMPANIES_SCOPED" });
1464
+ expect(sentCommands.length).toBe(0);
1465
+ });
1466
+
1467
+ it("uploadFile still allows companies/-prefixed keys for a personal (prs_) vault", async () => {
1468
+ await expect(
1469
+ uploadFile(makePersonalCtx(), tmpFile, "companies/indigo/knowledge/x.md"),
1470
+ ).resolves.toBeDefined();
1471
+ expect(sentCommands.length).toBeGreaterThan(0);
1472
+ });
1473
+ });
package/src/s3.ts CHANGED
@@ -541,6 +541,90 @@ export function toPosixKey(key: string): string {
541
541
  return key.split("\\").join("/");
542
542
  }
543
543
 
544
+ /**
545
+ * Client-side mirror of hq-pro's server validator
546
+ * (`vault-service/handlers/files-acl.ts validateObjectKey`). The direct-S3
547
+ * STS transport bypasses server key validation entirely — the upload
548
+ * primitives (uploadFile / uploadSymlink) are the only shared choke point —
549
+ * so we enforce the SAME rules here BEFORE the PUT. Incident 2026-07-11: a
550
+ * client with a stale doubled local tree (companies/{slug}/companies/{slug}/…)
551
+ * pushed literal `companies/…` keys into a company-scoped vault bucket via
552
+ * direct S3; agent boxes could LIST them but the presign download API
553
+ * rejected every one (INVALID_KEY_COMPANIES_SCOPED), erroring the whole
554
+ * company sync fleet-wide. A poisoned key must fail loudly at the writer,
555
+ * not at every reader.
556
+ *
557
+ * `scope` selects the companies/-prefix rule: company vault buckets are
558
+ * already company-scoped so a `companies/…` key is always wrong there;
559
+ * personal vaults legitimately carry `companies/{slug}/…` keys for local
560
+ * (non-cloud) companies. Callers derive scope from the EntityContext uid
561
+ * (`cmp_*` → "company", otherwise "personal").
562
+ *
563
+ * Errors carry the same `code` values the server throws so telemetry and
564
+ * operator messaging line up across client and server.
565
+ */
566
+ // Matching control chars is the whole point — block NUL/0x00–0x1F/0x7F in keys
567
+ // (they can smuggle past path checks or corrupt HTTP headers on the signed URL).
568
+ // eslint-disable-next-line no-control-regex
569
+ const KEY_CONTROL_CHARS = /[\x00-\x1F\x7F]/;
570
+
571
+ export function validateVaultUploadKey(
572
+ key: string,
573
+ scope: "company" | "personal",
574
+ ): void {
575
+ const fail = (message: string, code: string): never => {
576
+ throw Object.assign(new Error(message), { code });
577
+ };
578
+ if (key.length === 0) {
579
+ fail("Invalid key: must not be empty", "INVALID_KEY_EMPTY");
580
+ }
581
+ if (key.startsWith("/")) {
582
+ fail("Invalid key: leading '/' not allowed", "INVALID_KEY_LEADING_SLASH");
583
+ }
584
+ if (key.endsWith("/")) {
585
+ fail(
586
+ "Invalid key: trailing '/' (folder marker, not an object)",
587
+ "INVALID_KEY_TRAILING_SLASH",
588
+ );
589
+ }
590
+ if (scope === "company" && key.startsWith("companies/")) {
591
+ fail(
592
+ "Invalid key: vault keys are bucket-relative and the vault is already " +
593
+ "company-scoped — do not prefix with 'companies/<slug>/'. This " +
594
+ "usually means a stale doubled local tree " +
595
+ "(companies/<slug>/companies/<slug>/…); remove the inner copy.",
596
+ "INVALID_KEY_COMPANIES_SCOPED",
597
+ );
598
+ }
599
+ if (KEY_CONTROL_CHARS.test(key)) {
600
+ fail("Invalid key: contains control characters", "INVALID_KEY_CONTROL_CHARS");
601
+ }
602
+ if (key.includes("\\")) {
603
+ // Unreachable after toPosixKey normalization at the upload boundary, but
604
+ // kept so the validator mirrors the server rule set 1:1 for other callers.
605
+ fail(
606
+ "Invalid key: backslash separators are not allowed — vault keys are POSIX ('/')",
607
+ "INVALID_KEY_BACKSLASH",
608
+ );
609
+ }
610
+ if (key.includes("//")) {
611
+ fail("Invalid key: consecutive slashes not allowed", "INVALID_KEY_DOUBLE_SLASH");
612
+ }
613
+ for (const segment of key.split("/")) {
614
+ if (segment === "." || segment === "..") {
615
+ fail(
616
+ "Invalid key: '.' and '..' path components are not allowed",
617
+ "INVALID_KEY_DOT_COMPONENT",
618
+ );
619
+ }
620
+ }
621
+ }
622
+
623
+ /** Scope for {@link validateVaultUploadKey}, derived from the entity uid. */
624
+ function uploadScopeFor(ctx: EntityContext): "company" | "personal" {
625
+ return ctx.uid.startsWith("cmp_") ? "company" : "personal";
626
+ }
627
+
544
628
  export async function uploadFile(
545
629
  ctx: EntityContext,
546
630
  localPath: string,
@@ -548,8 +632,12 @@ export async function uploadFile(
548
632
  author?: UploadAuthor,
549
633
  precondition?: PutPrecondition,
550
634
  ): Promise<{ etag: string }> {
551
- // Boundary guardrail: never store a non-POSIX key (see toPosixKey).
635
+ // Boundary guardrail: never store a non-POSIX key (see toPosixKey), and
636
+ // never store a key the server-side validator would reject (see
637
+ // validateVaultUploadKey — the direct-S3 STS transport bypasses server
638
+ // validation, so this is the last line of defense against key poisoning).
552
639
  key = toPosixKey(key);
640
+ validateVaultUploadKey(key, uploadScopeFor(ctx));
553
641
  const io = resolveObjectIO(ctx);
554
642
  const body = fs.readFileSync(localPath);
555
643
 
@@ -616,8 +704,11 @@ export async function uploadSymlink(
616
704
  author?: UploadAuthor,
617
705
  precondition?: PutPrecondition,
618
706
  ): Promise<{ etag: string }> {
619
- // Boundary guardrail: never store a non-POSIX key (see toPosixKey).
707
+ // Boundary guardrail: never store a non-POSIX key (see toPosixKey), and
708
+ // never store a key the server-side validator would reject (see
709
+ // validateVaultUploadKey).
620
710
  key = toPosixKey(key);
711
+ validateVaultUploadKey(key, uploadScopeFor(ctx));
621
712
  const io = resolveObjectIO(ctx);
622
713
  const symlinkBody = encodeSymlinkBody(target);
623
714
 
@@ -240,6 +240,38 @@ describe("buildScopeShrinkPlan", () => {
240
240
  expect(plan.orphans).toEqual([]);
241
241
  });
242
242
 
243
+ // US-006: a session transcript authored + pushed locally (direction: "up")
244
+ // under the push-only `sessions/{personUid}/` prefix must NEVER be orphaned by
245
+ // a scope shrink, even though `sessions/` sits outside the effective pull
246
+ // scope (it is subtracted from every pull scope as an excludePrefix). This is
247
+ // the authorship arm of the push-only contract: sessions are pushed, then
248
+ // never pulled, and the direction:"up" exemption keeps the author's own
249
+ // transcript from being pruned. Regression guard for AC4/AC6.
250
+ it("never orphans a locally-authored session (direction: 'up') when sessions is outside pull scope (US-006)", () => {
251
+ const journal: SyncJournal = {
252
+ ...emptyJournal(),
253
+ files: {
254
+ "sessions/prs_1/2026-07-11T10-00-00.json": {
255
+ hash: "h",
256
+ size: 1,
257
+ syncedAt: "2026-07-11T00:00:00.000Z",
258
+ direction: "up",
259
+ },
260
+ },
261
+ };
262
+ const plan = buildScopeShrinkPlan({
263
+ journal,
264
+ hqRoot,
265
+ // Last pull saw everything ([""] = full scope); the current pull scope
266
+ // covers knowledge but NOT the push-only sessions prefix — exactly the
267
+ // shape the sessions exclusion produces on the download side.
268
+ lastPrefixSet: [""],
269
+ currentPrefixSet: ["knowledge/"],
270
+ });
271
+ expect(plan.orphans).toEqual([]);
272
+ expect(plan.scopeChangeDetected).toBe(false);
273
+ });
274
+
243
275
  // ── Authorship guard ──────────────────────────────────────────────────────
244
276
  // Sync mode decides whether you mirror OTHER people's files; it must never
245
277
  // orphan content the caller authored. Owners hold their whole vault by
@@ -18,6 +18,18 @@
18
18
  * tombstone write — that lives in `journal.ts`. It DOES touch disk for the
19
19
  * orphan classification (hash + stat) because cleanliness is a function of
20
20
  * the file's current on-disk state vs the journal.
21
+ *
22
+ * PUSH-ONLY PREFIXES ARE NEVER PRUNED HERE (US-006). Session transcripts under
23
+ * `sessions/{personUid}/...` are push-only: pushed into the vault, then
24
+ * subtracted from every pull scope (incl. `all`/owner) via
25
+ * `resolvePullScope().excludePrefixes`. That exclude set is applied ONLY to the
26
+ * download filter (`computePullPlan`) and is DELIBERATELY NOT passed into this
27
+ * module's `currentPrefixSet`. So a session file that was authored locally rides
28
+ * the `direction:"up"` skip below, and one materialized on demand (`hq files
29
+ * get`) rides the pin union already folded into the caller's inclusion
30
+ * `prefixSet` — either way it is never pulled AND never orphaned. Feeding the
31
+ * exclude set into `currentPrefixSet` would prune exactly those files; callers
32
+ * must not.
21
33
  */
22
34
 
23
35
  import * as fs from "fs";
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
2
2
  import { promises as fs } from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
+ import { createHash } from "node:crypto";
5
6
  import {
6
7
  extractSkillEvents,
7
8
  extractCodexSkillEvents,
@@ -9,6 +10,7 @@ import {
9
10
  codexRowTurnId,
10
11
  parseCodexSessionMeta,
11
12
  collectAndSendSkillTelemetry,
13
+ computeSkillVersion,
12
14
  readFileRegion,
13
15
  } from "./skill-telemetry.js";
14
16
  import type { SkillInvocationBatch } from "./vault-client.js";
@@ -226,6 +228,58 @@ describe("extractCodexSkillToolEvents (Codex model-driven SKILL.md reads)", () =
226
228
  expect(ev.source).toBe("model");
227
229
  });
228
230
 
231
+ it("reconstructs <slug>:<name> when reading a company skill's real path", () => {
232
+ // Codex loads a company skill by reading companies/<slug>/skills/<name>/
233
+ // SKILL.md; the slug must be recovered so the run attributes to the company
234
+ // rather than being dropped to a bare, unattributed name.
235
+ const ev = extractCodexSkillToolEvents(
236
+ execEnd("sed -n '1,240p' companies/indigo/skills/signals/SKILL.md"),
237
+ ctx,
238
+ )[0];
239
+ expect(ev.skill).toBe("indigo:signals");
240
+ expect(ev.source).toBe("model");
241
+ // The dedup/idempotency uuid keys off the reconstructed token.
242
+ expect(ev.uuid).toBe("codex:skill:sess-x:turn-A:indigo:signals");
243
+ });
244
+
245
+ it("reconstructs the slug for any company and absolute path", () => {
246
+ expect(
247
+ extractCodexSkillToolEvents(
248
+ execEnd("cat /home/ec2-user/hq/companies/beta/skills/support/SKILL.md"),
249
+ ctx,
250
+ )[0].skill,
251
+ ).toBe("beta:support");
252
+ // Hyphenated slug + hyphenated skill name both survive.
253
+ expect(
254
+ extractCodexSkillToolEvents(
255
+ execEnd("rg -n x companies/indigo-auto/skills/call-prep-brief/SKILL.md"),
256
+ ctx,
257
+ )[0].skill,
258
+ ).toBe("indigo-auto:call-prep-brief");
259
+ });
260
+
261
+ it("leaves an already-namespaced symlink read untouched (no double prefix)", () => {
262
+ // Reading via the .claude/skills/<slug>:<name>/ symlink already carries the
263
+ // prefix in the captured directory — it must NOT get a second one.
264
+ expect(
265
+ extractCodexSkillToolEvents(
266
+ execEnd("cat .claude/skills/indigo:signals/SKILL.md"),
267
+ ctx,
268
+ )[0].skill,
269
+ ).toBe("indigo:signals");
270
+ });
271
+
272
+ it("does not fabricate a company prefix for a non-company skill", () => {
273
+ // A core skill read from its bare directory stays bare — the reconstruction
274
+ // only triggers on the companies/<slug>/skills/ shape.
275
+ expect(
276
+ extractCodexSkillToolEvents(
277
+ execEnd("cat .claude/skills/deploy/SKILL.md"),
278
+ ctx,
279
+ )[0].skill,
280
+ ).toBe("deploy");
281
+ });
282
+
229
283
  it("matches normal Codex skill paths without backtracking ambiguity", () => {
230
284
  expect(
231
285
  extractCodexSkillToolEvents(
@@ -1102,10 +1156,13 @@ describe("collectAndSendSkillTelemetry — companyUid attribution", () => {
1102
1156
  await fs.writeFile(
1103
1157
  path.join(dir, "s.jsonl"),
1104
1158
  [
1105
- // cwd inside the company repo → attributed.
1159
+ // Core skill, cwd inside the company repo → attributed by cwd.
1106
1160
  row({ type: "user", sessionId: "s1", timestamp: "2026-06-10T10:00:00Z", cwd: inRepo, uuid: "u1", message: { role: "user", content: "<command-name>/deploy</command-name>" } }),
1107
- // cwd at hqRoot (passes scope) but maps to no repo → unattributed.
1161
+ // Company skill `indigo:hello-world` run at hqRoot (maps to no repo)
1162
+ // attributed by SKILL NAMESPACE to the indigo company, regardless of cwd.
1108
1163
  row({ type: "assistant", sessionId: "s1", timestamp: "2026-06-10T10:01:00Z", cwd: atRoot, message: { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "Skill", input: { skill: "indigo:hello-world" } }] } }),
1164
+ // Core skill at hqRoot (no company repo, no namespace) → unattributed.
1165
+ row({ type: "user", sessionId: "s1", timestamp: "2026-06-10T10:02:00Z", cwd: atRoot, uuid: "u3", message: { role: "user", content: "<command-name>/handoff</command-name>" } }),
1109
1166
  ].join("\n") + "\n",
1110
1167
  "utf-8",
1111
1168
  );
@@ -1121,11 +1178,15 @@ describe("collectAndSendSkillTelemetry — companyUid attribution", () => {
1121
1178
  cursorPath: path.join(tmp, "cursor.json"),
1122
1179
  });
1123
1180
 
1124
- expect(result.eventsSent).toBe(2);
1181
+ expect(result.eventsSent).toBe(3);
1125
1182
  const events = captured.flatMap((b) => b.events);
1126
1183
  const bySkill = new Map(events.map((e) => [e.skill, e]));
1184
+ // Core skill attributed by its cwd (inside the company repo).
1127
1185
  expect(bySkill.get("deploy")!.companyUid).toBe(COMPANY_UID);
1128
- expect("companyUid" in bySkill.get("indigo:hello-world")!).toBe(false);
1186
+ // Company skill attributed by its namespace, even though cwd maps to no repo.
1187
+ expect(bySkill.get("indigo:hello-world")!.companyUid).toBe(COMPANY_UID);
1188
+ // Core skill at hqRoot with no owning repo → stays unattributed.
1189
+ expect("companyUid" in bySkill.get("handoff")!).toBe(false);
1129
1190
  // Never the reserved sentinel.
1130
1191
  for (const e of events) expect(e.companyUid).not.toBe("unattributed");
1131
1192
 
@@ -1275,3 +1336,185 @@ describe("readFileRegion — HQ-SYNC-WEB-15 (SIGABRT on >2GiB single read)", ()
1275
1336
  expect(got).toBe(30);
1276
1337
  });
1277
1338
  });
1339
+
1340
+ describe("computeSkillVersion (US-015)", () => {
1341
+ function sha256(bytes: string): string {
1342
+ return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
1343
+ }
1344
+
1345
+ it("hashes .claude/skills/<skill>/SKILL.md to a sha256:<hex> marker", async () => {
1346
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-ver-"));
1347
+ const body = "---\nname: deploy\n---\nDeploy things.\n";
1348
+ const dir = path.join(tmp, ".claude", "skills", "deploy");
1349
+ await fs.mkdir(dir, { recursive: true });
1350
+ await fs.writeFile(path.join(dir, "SKILL.md"), body, "utf-8");
1351
+
1352
+ expect(await computeSkillVersion(tmp, "deploy")).toBe(sha256(body));
1353
+ await fs.rm(tmp, { recursive: true, force: true });
1354
+ });
1355
+
1356
+ it("resolves a namespaced skill by its full invocation name (colon in dir name)", async () => {
1357
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-ver-"));
1358
+ const body = "hello world skill\n";
1359
+ const dir = path.join(tmp, ".claude", "skills", "indigo:hello-world");
1360
+ await fs.mkdir(dir, { recursive: true });
1361
+ await fs.writeFile(path.join(dir, "SKILL.md"), body, "utf-8");
1362
+
1363
+ expect(await computeSkillVersion(tmp, "indigo:hello-world")).toBe(sha256(body));
1364
+ await fs.rm(tmp, { recursive: true, force: true });
1365
+ });
1366
+
1367
+ it("falls back to .agents/skills/ (Codex bridge layout)", async () => {
1368
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-ver-"));
1369
+ const body = "codex-bridged skill\n";
1370
+ const dir = path.join(tmp, ".agents", "skills", "learn");
1371
+ await fs.mkdir(dir, { recursive: true });
1372
+ await fs.writeFile(path.join(dir, "SKILL.md"), body, "utf-8");
1373
+
1374
+ expect(await computeSkillVersion(tmp, "learn")).toBe(sha256(body));
1375
+ await fs.rm(tmp, { recursive: true, force: true });
1376
+ });
1377
+
1378
+ it("returns undefined for a missing hqRoot, a missing file, or a traversal attempt", async () => {
1379
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-ver-"));
1380
+ expect(await computeSkillVersion(undefined, "deploy")).toBeUndefined();
1381
+ expect(await computeSkillVersion(tmp, "does-not-exist")).toBeUndefined();
1382
+ // Path-traversal / separator-bearing names never map to a SKILL.md here.
1383
+ expect(await computeSkillVersion(tmp, "../../etc/passwd")).toBeUndefined();
1384
+ expect(await computeSkillVersion(tmp, "a/b")).toBeUndefined();
1385
+ await fs.rm(tmp, { recursive: true, force: true });
1386
+ });
1387
+ });
1388
+
1389
+ describe("collectAndSendSkillTelemetry — skillVersion emission (US-015)", () => {
1390
+ function stubClient(captured: SkillInvocationBatch[]) {
1391
+ return {
1392
+ async getTelemetryOptIn() {
1393
+ return { enabled: true, updatedAt: null };
1394
+ },
1395
+ async postSkillInvocations(batch: SkillInvocationBatch) {
1396
+ captured.push(batch);
1397
+ return { ok: true, written: batch.events.length, skipped: [] };
1398
+ },
1399
+ };
1400
+ }
1401
+ function row(obj: Record<string, unknown>): string {
1402
+ return JSON.stringify(obj);
1403
+ }
1404
+
1405
+ it("stamps the SKILL.md content hash onto the shipped event", async () => {
1406
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-ver-emit-"));
1407
+ // hqRoot IS the session cwd, so the invocation passes the scope filter and
1408
+ // the resolver can find the skill under hqRoot/.claude/skills.
1409
+ const hqRoot = path.join(tmp, "hq");
1410
+ const skillBody = "---\nname: deploy\n---\nDeploy.\n";
1411
+ const skillDir = path.join(hqRoot, ".claude", "skills", "deploy");
1412
+ await fs.mkdir(skillDir, { recursive: true });
1413
+ await fs.writeFile(path.join(skillDir, "SKILL.md"), skillBody, "utf-8");
1414
+
1415
+ const projects = path.join(tmp, "projects");
1416
+ const dir = path.join(projects, "-hq");
1417
+ await fs.mkdir(dir, { recursive: true });
1418
+ await fs.writeFile(
1419
+ path.join(dir, "s.jsonl"),
1420
+ row({
1421
+ type: "user",
1422
+ sessionId: "s1",
1423
+ timestamp: "2026-07-09T09:00:00Z",
1424
+ cwd: hqRoot,
1425
+ uuid: "u1",
1426
+ message: { role: "user", content: "<command-name>/deploy</command-name>" },
1427
+ }) + "\n",
1428
+ "utf-8",
1429
+ );
1430
+
1431
+ const captured: SkillInvocationBatch[] = [];
1432
+ const result = await collectAndSendSkillTelemetry({
1433
+ client: stubClient(captured),
1434
+ machineId: "m",
1435
+ installerVersion: "t",
1436
+ hqRoot,
1437
+ claudeProjectsRoot: projects,
1438
+ codexSessionsRoot: path.join(tmp, "codex"),
1439
+ cursorPath: path.join(tmp, "cursor.json"),
1440
+ });
1441
+
1442
+ expect(result.eventsSent).toBe(1);
1443
+ const ev = captured[0].events[0];
1444
+ expect(ev.skill).toBe("deploy");
1445
+ expect(ev.skillVersion).toBe(
1446
+ `sha256:${createHash("sha256").update(skillBody).digest("hex")}`,
1447
+ );
1448
+ await fs.rm(tmp, { recursive: true, force: true });
1449
+ });
1450
+
1451
+ it("emits the injected resolver's marker (wiring is decoupled from disk)", async () => {
1452
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-ver-inj-"));
1453
+ const projects = path.join(tmp, "projects");
1454
+ const dir = path.join(projects, "-p");
1455
+ await fs.mkdir(dir, { recursive: true });
1456
+ await fs.writeFile(
1457
+ path.join(dir, "s.jsonl"),
1458
+ row({
1459
+ type: "assistant",
1460
+ sessionId: "s2",
1461
+ timestamp: "2026-07-09T10:00:00Z",
1462
+ cwd: "/x",
1463
+ message: {
1464
+ role: "assistant",
1465
+ content: [{ type: "tool_use", id: "t1", name: "Skill", input: { skill: "code-review" } }],
1466
+ },
1467
+ }) + "\n",
1468
+ "utf-8",
1469
+ );
1470
+
1471
+ const captured: SkillInvocationBatch[] = [];
1472
+ const result = await collectAndSendSkillTelemetry({
1473
+ client: stubClient(captured),
1474
+ machineId: "m",
1475
+ installerVersion: "t",
1476
+ claudeProjectsRoot: projects,
1477
+ codexSessionsRoot: path.join(tmp, "codex"),
1478
+ cursorPath: path.join(tmp, "cursor.json"),
1479
+ resolveSkillVersion: (skill) => `sha256:stub-for-${skill}`,
1480
+ });
1481
+
1482
+ expect(result.eventsSent).toBe(1);
1483
+ expect(captured[0].events[0].skillVersion).toBe("sha256:stub-for-code-review");
1484
+ await fs.rm(tmp, { recursive: true, force: true });
1485
+ });
1486
+
1487
+ it("omits skillVersion when the SKILL.md can't be resolved (additive/back-compat)", async () => {
1488
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-ver-none-"));
1489
+ const projects = path.join(tmp, "projects");
1490
+ const dir = path.join(projects, "-p");
1491
+ await fs.mkdir(dir, { recursive: true });
1492
+ await fs.writeFile(
1493
+ path.join(dir, "s.jsonl"),
1494
+ row({
1495
+ type: "user",
1496
+ sessionId: "s3",
1497
+ timestamp: "2026-07-09T11:00:00Z",
1498
+ cwd: "/x",
1499
+ uuid: "u3",
1500
+ message: { role: "user", content: "<command-name>/unknown-skill</command-name>" },
1501
+ }) + "\n",
1502
+ "utf-8",
1503
+ );
1504
+
1505
+ // No hqRoot → the default resolver can't locate any SKILL.md → field omitted.
1506
+ const captured: SkillInvocationBatch[] = [];
1507
+ const result = await collectAndSendSkillTelemetry({
1508
+ client: stubClient(captured),
1509
+ machineId: "m",
1510
+ installerVersion: "t",
1511
+ claudeProjectsRoot: projects,
1512
+ codexSessionsRoot: path.join(tmp, "codex"),
1513
+ cursorPath: path.join(tmp, "cursor.json"),
1514
+ });
1515
+
1516
+ expect(result.eventsSent).toBe(1);
1517
+ expect("skillVersion" in captured[0].events[0]).toBe(false);
1518
+ await fs.rm(tmp, { recursive: true, force: true });
1519
+ });
1520
+ });