@indigoai-us/hq-cloud 6.11.9 → 6.11.11

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 (72) hide show
  1. package/dist/bin/sync-runner.d.ts.map +1 -1
  2. package/dist/bin/sync-runner.js +4 -0
  3. package/dist/bin/sync-runner.js.map +1 -1
  4. package/dist/bin/sync-runner.test.js +137 -0
  5. package/dist/bin/sync-runner.test.js.map +1 -1
  6. package/dist/cli/reindex.d.ts.map +1 -1
  7. package/dist/cli/reindex.js +16 -1
  8. package/dist/cli/reindex.js.map +1 -1
  9. package/dist/cli/reindex.test.js +83 -1
  10. package/dist/cli/reindex.test.js.map +1 -1
  11. package/dist/cli/rescue-classify-ordering.test.js +25 -0
  12. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  13. package/dist/cli/rescue-core.js +91 -0
  14. package/dist/cli/rescue-core.js.map +1 -1
  15. package/dist/cli/rescue-exec-bit-preserve.test.d.ts +2 -0
  16. package/dist/cli/rescue-exec-bit-preserve.test.d.ts.map +1 -0
  17. package/dist/cli/rescue-exec-bit-preserve.test.js +169 -0
  18. package/dist/cli/rescue-exec-bit-preserve.test.js.map +1 -0
  19. package/dist/cli/sync.d.ts.map +1 -1
  20. package/dist/cli/sync.js +10 -1
  21. package/dist/cli/sync.js.map +1 -1
  22. package/dist/cli/sync.test.js +125 -0
  23. package/dist/cli/sync.test.js.map +1 -1
  24. package/dist/company-resolver.d.ts +77 -0
  25. package/dist/company-resolver.d.ts.map +1 -0
  26. package/dist/company-resolver.js +124 -0
  27. package/dist/company-resolver.js.map +1 -0
  28. package/dist/company-resolver.test.d.ts +7 -0
  29. package/dist/company-resolver.test.d.ts.map +1 -0
  30. package/dist/company-resolver.test.js +120 -0
  31. package/dist/company-resolver.test.js.map +1 -0
  32. package/dist/personal-vault.d.ts +8 -0
  33. package/dist/personal-vault.d.ts.map +1 -1
  34. package/dist/personal-vault.js +9 -1
  35. package/dist/personal-vault.js.map +1 -1
  36. package/dist/skill-telemetry.d.ts.map +1 -1
  37. package/dist/skill-telemetry.js +22 -3
  38. package/dist/skill-telemetry.js.map +1 -1
  39. package/dist/skill-telemetry.test.js +101 -1
  40. package/dist/skill-telemetry.test.js.map +1 -1
  41. package/dist/sync/pull-scope.d.ts +1 -0
  42. package/dist/sync/pull-scope.d.ts.map +1 -1
  43. package/dist/sync/pull-scope.js +26 -0
  44. package/dist/sync/pull-scope.js.map +1 -1
  45. package/dist/telemetry.d.ts +18 -1
  46. package/dist/telemetry.d.ts.map +1 -1
  47. package/dist/telemetry.js +28 -2
  48. package/dist/telemetry.js.map +1 -1
  49. package/dist/telemetry.test.js +93 -1
  50. package/dist/telemetry.test.js.map +1 -1
  51. package/dist/vault-client.d.ts +4 -2
  52. package/dist/vault-client.d.ts.map +1 -1
  53. package/dist/vault-client.js.map +1 -1
  54. package/package.json +1 -1
  55. package/src/bin/sync-runner.test.ts +188 -0
  56. package/src/bin/sync-runner.ts +4 -0
  57. package/src/cli/reindex.test.ts +100 -1
  58. package/src/cli/reindex.ts +17 -1
  59. package/src/cli/rescue-classify-ordering.test.ts +28 -0
  60. package/src/cli/rescue-core.ts +87 -0
  61. package/src/cli/rescue-exec-bit-preserve.test.ts +187 -0
  62. package/src/cli/sync.test.ts +159 -0
  63. package/src/cli/sync.ts +12 -1
  64. package/src/company-resolver.test.ts +136 -0
  65. package/src/company-resolver.ts +147 -0
  66. package/src/personal-vault.ts +10 -1
  67. package/src/skill-telemetry.test.ts +126 -1
  68. package/src/skill-telemetry.ts +26 -3
  69. package/src/sync/pull-scope.ts +26 -1
  70. package/src/telemetry.test.ts +118 -1
  71. package/src/telemetry.ts +50 -2
  72. package/src/vault-client.ts +4 -2
@@ -869,6 +869,16 @@ function doRescue(
869
869
  }
870
870
  }
871
871
 
872
+ // --- Restore executable bit on shipped shebang scripts (defense-in-depth) ---
873
+ // rsync -a already propagated each file's upstream git tree mode; this guards
874
+ // the case where upstream itself committed an executable script as 0644 -- a
875
+ // hook the runtime exec's then dies with EACCES and is silently disabled. A
876
+ // shebang is an unambiguous "meant to be run" marker. See restoreShebangExecBits.
877
+ const execBitFixes = restoreShebangExecBits(srcDir, hqRoot, env);
878
+ if (execBitFixes > 0) {
879
+ out(`==> Restored executable bit on ${execBitFixes} shipped shebang script(s) lacking +x\n`);
880
+ }
881
+
872
882
  // --- Stamp sync-point provenance into core/core.yaml ---
873
883
  // `last_sync_at` = the source commit's committer time, NOT wall-clock now:
874
884
  // the stamp must be a pure function of srcSha so every machine rescuing the
@@ -1269,6 +1279,83 @@ function diffAppendClaudeMd(ctx: WalkCtx): void {
1269
1279
  ctx.counts.claudeDiffAppend += 1;
1270
1280
  }
1271
1281
 
1282
+ /**
1283
+ * Defense-in-depth for executable scaffold scripts. `rescue` rebuilds the tree
1284
+ * with `git clone` + `rsync -a`, faithfully propagating each file's git tree
1285
+ * mode (100755 vs 100644). So a script accidentally committed upstream WITHOUT
1286
+ * its executable bit (e.g. a `.claude/hooks/*.sh` checked in as 100644) is
1287
+ * delivered non-executable to every machine, and a hook the runtime exec's
1288
+ * directly then fails with EACCES ("Permission denied") and is silently
1289
+ * disabled.
1290
+ *
1291
+ * A `#!` shebang is an unambiguous "this file is meant to be run" marker, so
1292
+ * after the overlay we ensure every shipped shebang script is executable,
1293
+ * regardless of the (possibly wrong) upstream mode bit. Execute is added only
1294
+ * where read is already granted (0644 -> 0755, 0640 -> 0750), mirroring the
1295
+ * umask convention; files that already carry any exec bit, non-files, and
1296
+ * symlinks are left untouched. Paths are enumerated from the SOURCE repo's git
1297
+ * index, so this only ever touches release-shipped scaffold -- never user
1298
+ * content the rescue leaves in place.
1299
+ *
1300
+ * Best-effort and non-fatal: a failed `git ls-files` or chmod (read-only FS,
1301
+ * EPERM) is swallowed, matching the rest of rescue's posture. Returns the count
1302
+ * of files whose mode was changed, for the run summary.
1303
+ */
1304
+ function restoreShebangExecBits(
1305
+ srcDir: string,
1306
+ hqRoot: string,
1307
+ env: NodeJS.ProcessEnv,
1308
+ ): number {
1309
+ let listing: string;
1310
+ try {
1311
+ const r = spawnSync("git", ["-C", srcDir, "ls-files", "-z"], {
1312
+ encoding: "utf-8",
1313
+ env,
1314
+ maxBuffer: 128 * 1024 * 1024,
1315
+ });
1316
+ if (r.status !== 0 || typeof r.stdout !== "string") return 0;
1317
+ listing = r.stdout;
1318
+ } catch {
1319
+ return 0;
1320
+ }
1321
+ let fixed = 0;
1322
+ for (const rel of listing.split("\0")) {
1323
+ if (!rel) continue;
1324
+ const dest = path.join(hqRoot, rel);
1325
+ let st: fs.Stats;
1326
+ try {
1327
+ st = fs.lstatSync(dest);
1328
+ } catch {
1329
+ continue; // not delivered here (preserve-excluded, ignored, narrowed out)
1330
+ }
1331
+ if (!st.isFile()) continue; // skip dirs and symlinks (lstat: a link is not a file)
1332
+ if ((st.mode & 0o111) !== 0) continue; // already executable
1333
+ // Sniff the first two bytes for a shebang.
1334
+ let head = "";
1335
+ try {
1336
+ const fd = fs.openSync(dest, "r");
1337
+ try {
1338
+ const buf = Buffer.alloc(2);
1339
+ const n = fs.readSync(fd, buf, 0, 2, 0);
1340
+ head = buf.subarray(0, n).toString("latin1");
1341
+ } finally {
1342
+ fs.closeSync(fd);
1343
+ }
1344
+ } catch {
1345
+ continue;
1346
+ }
1347
+ if (head !== "#!") continue;
1348
+ const execBits = (st.mode & 0o444) >> 2; // copy r-bits into the x-bit slots
1349
+ try {
1350
+ fs.chmodSync(dest, st.mode | execBits);
1351
+ fixed += 1;
1352
+ } catch {
1353
+ // read-only FS / EPERM -- non-fatal.
1354
+ }
1355
+ }
1356
+ return fixed;
1357
+ }
1358
+
1272
1359
  function readFileOrEmpty(p: string): string {
1273
1360
  try {
1274
1361
  return fs.readFileSync(p, "utf-8");
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Regression for the rescue executable-bit guarantee in src/cli/rescue-core.ts
3
+ * (restoreShebangExecBits).
4
+ *
5
+ * rescue rebuilds the tree with `git clone` + `rsync -a`, which faithfully
6
+ * propagates the upstream git tree mode. So a script committed upstream WITHOUT
7
+ * its executable bit (a `.sh` checked in as 100644) is delivered non-executable
8
+ * to every machine -- and a hook the runtime exec's directly then fails with
9
+ * "Permission denied". After the overlay, rescue restores +x on any shipped
10
+ * file that begins with a `#!` shebang (and leaves non-shebang files alone).
11
+ *
12
+ * Mirrors rescue-mtime-preserve.test.ts: shim `git clone` to a local fixture
13
+ * and run the real rescue (non-dry-run, --no-backup) so the overlay lays files
14
+ * down for real.
15
+ */
16
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
17
+ import { execFileSync } from "child_process";
18
+ import * as fs from "fs";
19
+ import * as os from "os";
20
+ import * as path from "path";
21
+ import { runRescue } from "./rescue-core.js";
22
+
23
+ function has(bin: string, ...args: string[]): boolean {
24
+ try {
25
+ execFileSync(bin, args, { stdio: "ignore" });
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+ const toolsAvailable = has("git", "--version") && has("rsync", "--version");
32
+
33
+ const FLOOR_EPOCH = 1577836800; // 2020-01-01
34
+ const HEAD_EPOCH = 1609459200; // 2021-01-01
35
+
36
+ function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
37
+ let stdout = "";
38
+ let stderr = "";
39
+ const origOut = process.stdout.write.bind(process.stdout);
40
+ const origErr = process.stderr.write.bind(process.stderr);
41
+ process.stdout.write = ((chunk: unknown) => {
42
+ stdout += String(chunk);
43
+ return true;
44
+ }) as typeof process.stdout.write;
45
+ process.stderr.write = ((chunk: unknown) => {
46
+ stderr += String(chunk);
47
+ return true;
48
+ }) as typeof process.stderr.write;
49
+ let status: number;
50
+ try {
51
+ status = runRescue(argv, { env }).status;
52
+ } finally {
53
+ process.stdout.write = origOut;
54
+ process.stderr.write = origErr;
55
+ }
56
+ return { status, stdout, stderr };
57
+ }
58
+
59
+ describe.skipIf(!toolsAvailable)("rescue restores +x on shipped shebang scripts", () => {
60
+ let workDir: string;
61
+ let upstream: string;
62
+ let hqRoot: string;
63
+ let shimDir: string;
64
+ let floorSha: string;
65
+ let env: NodeJS.ProcessEnv;
66
+
67
+ const gitAt = (cwd: string, epoch: number, ...args: string[]) =>
68
+ execFileSync("git", args, {
69
+ cwd,
70
+ stdio: ["ignore", "pipe", "pipe"],
71
+ env: {
72
+ ...process.env,
73
+ GIT_AUTHOR_NAME: "t",
74
+ GIT_AUTHOR_EMAIL: "t@t",
75
+ GIT_COMMITTER_NAME: "t",
76
+ GIT_COMMITTER_EMAIL: "t@t",
77
+ GIT_AUTHOR_DATE: `${epoch} +0000`,
78
+ GIT_COMMITTER_DATE: `${epoch} +0000`,
79
+ },
80
+ })
81
+ .toString()
82
+ .trim();
83
+
84
+ const isExec = (p: string) => (fs.statSync(p).mode & 0o111) !== 0;
85
+ const mode = (p: string) => fs.statSync(p).mode & 0o777;
86
+
87
+ beforeAll(() => {
88
+ workDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rescue-exec-"));
89
+
90
+ // --- "upstream" repo ----------------------------------------------------
91
+ upstream = path.join(workDir, "upstream");
92
+ fs.mkdirSync(path.join(upstream, "core/scripts"), { recursive: true });
93
+ fs.mkdirSync(path.join(upstream, "core/docs"), { recursive: true });
94
+ gitAt(workDir, FLOOR_EPOCH, "init", "-b", "main", "upstream");
95
+
96
+ // Shebang script committed WITHOUT +x (the bug: arrives non-executable).
97
+ const needs = path.join(upstream, "core/scripts/needs-exec.sh");
98
+ fs.writeFileSync(needs, "#!/bin/bash\necho needs\n", { mode: 0o644 });
99
+ fs.chmodSync(needs, 0o644);
100
+ // Shebang script committed WITH +x (control: rsync -a already preserves it).
101
+ const already = path.join(upstream, "core/scripts/already-exec.sh");
102
+ fs.writeFileSync(already, "#!/bin/bash\necho already\n");
103
+ fs.chmodSync(already, 0o755);
104
+ // Non-shebang data file (control: must NOT be made executable).
105
+ fs.writeFileSync(path.join(upstream, "core/docs/note.md"), "# note\n", { mode: 0o644 });
106
+
107
+ gitAt(upstream, FLOOR_EPOCH, "add", "-A");
108
+ gitAt(upstream, FLOOR_EPOCH, "commit", "-m", "floor");
109
+ floorSha = gitAt(upstream, FLOOR_EPOCH, "rev-parse", "HEAD");
110
+ // A second commit so floor != HEAD (mirrors the mtime fixture).
111
+ fs.writeFileSync(path.join(upstream, "core/docs/head.md"), "head\n");
112
+ gitAt(upstream, HEAD_EPOCH, "add", "-A");
113
+ gitAt(upstream, HEAD_EPOCH, "commit", "-m", "head");
114
+
115
+ // Confirm the fixture actually stored the intended tree modes.
116
+ const lsFloor = gitAt(upstream, HEAD_EPOCH, "ls-tree", "-r", "HEAD");
117
+ if (!/100644\s+blob\s+\S+\s+core\/scripts\/needs-exec\.sh/.test(lsFloor)) {
118
+ throw new Error(`fixture needs-exec.sh not stored as 100644:\n${lsFloor}`);
119
+ }
120
+ if (!/100755\s+blob\s+\S+\s+core\/scripts\/already-exec\.sh/.test(lsFloor)) {
121
+ throw new Error(`fixture already-exec.sh not stored as 100755:\n${lsFloor}`);
122
+ }
123
+
124
+ // --- local HQ root being rescued (fresh; upstream files are brand-new) --
125
+ hqRoot = path.join(workDir, "hq");
126
+ fs.mkdirSync(path.join(hqRoot, "core"), { recursive: true });
127
+ fs.mkdirSync(path.join(hqRoot, "personal"), { recursive: true });
128
+ fs.mkdirSync(path.join(hqRoot, "companies"), { recursive: true });
129
+
130
+ // --- git shim: redirect `git clone <github-url>` to the local fixture ---
131
+ const realGit = execFileSync("bash", ["-lc", "command -v git"]).toString().trim() || "/usr/bin/git";
132
+ shimDir = path.join(workDir, "shim");
133
+ fs.mkdirSync(shimDir, { recursive: true });
134
+ const shim = `#!/usr/bin/env bash
135
+ if [ "$1" = "clone" ]; then
136
+ args=()
137
+ for a in "$@"; do
138
+ case "$a" in
139
+ https://github.com/*) a=${JSON.stringify(upstream)} ;;
140
+ esac
141
+ args+=("$a")
142
+ done
143
+ exec ${JSON.stringify(realGit)} "\${args[@]}"
144
+ fi
145
+ exec ${JSON.stringify(realGit)} "$@"
146
+ `;
147
+ fs.writeFileSync(path.join(shimDir, "git"), shim, { mode: 0o755 });
148
+ env = { ...process.env, PATH: `${shimDir}:${process.env.PATH ?? ""}` };
149
+
150
+ const r = runRescueCapture(
151
+ [
152
+ "--hq-root", hqRoot,
153
+ "--source", "test/repo",
154
+ "--ref", "main",
155
+ "--floor-sha", floorSha,
156
+ "--yes",
157
+ "--no-backup",
158
+ ],
159
+ env,
160
+ );
161
+ if (r.status !== 0) {
162
+ throw new Error(`rescue failed (${r.status}):\n${r.stdout}\n${r.stderr}`);
163
+ }
164
+ });
165
+
166
+ afterAll(() => {
167
+ if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
168
+ });
169
+
170
+ it("restores +x on a shebang script shipped as 0644 (the bug)", () => {
171
+ const f = path.join(hqRoot, "core/scripts/needs-exec.sh");
172
+ expect(fs.readFileSync(f, "utf-8")).toBe("#!/bin/bash\necho needs\n");
173
+ expect(isExec(f)).toBe(true);
174
+ expect(mode(f)).toBe(0o755); // 0644 + (read-bits -> exec-bits)
175
+ });
176
+
177
+ it("leaves an already-executable shebang script executable", () => {
178
+ const f = path.join(hqRoot, "core/scripts/already-exec.sh");
179
+ expect(isExec(f)).toBe(true);
180
+ });
181
+
182
+ it("never makes a non-shebang data file executable", () => {
183
+ const f = path.join(hqRoot, "core/docs/note.md");
184
+ expect(fs.existsSync(f)).toBe(true);
185
+ expect(isExec(f)).toBe(false);
186
+ });
187
+ });
@@ -611,6 +611,165 @@ describe("sync", () => {
611
611
  expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "readme.md"))).toBe(false);
612
612
  });
613
613
 
614
+ it("personalMode: downloads + journals companies/manifest.yaml (carve-out round-trips) while still skipping other companies/* keys", async () => {
615
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
616
+ { key: "companies/foo/bar.md", size: 50, lastModified: new Date(), etag: '"xyz789"' },
617
+ { key: "companies/manifest.yaml", size: 40, lastModified: new Date(), etag: '"man111"' },
618
+ ]);
619
+
620
+ const result = await sync({
621
+ company: "acme",
622
+ vaultConfig: mockConfig,
623
+ hqRoot: tmpDir,
624
+ personalMode: true,
625
+ });
626
+
627
+ // The manifest is the lone companies/* exemption: it downloads; other
628
+ // companies/* keys are still dropped.
629
+ expect(result.filesSkipped).toBe(1);
630
+ expect(result.filesDownloaded).toBe(1);
631
+ expect(fs.existsSync(path.join(tmpDir, "companies", "manifest.yaml"))).toBe(true);
632
+ expect(fs.existsSync(path.join(tmpDir, "companies", "foo", "bar.md"))).toBe(false);
633
+
634
+ // The whole point: it now gets a journal baseline, so the push side stops
635
+ // re-firing a transient conflict every sync (the bug this fix closes).
636
+ const journaledManifest = fs
637
+ .readdirSync(stateDir)
638
+ .filter((f) => f.startsWith("sync-journal."))
639
+ .some((f) => {
640
+ const j = JSON.parse(fs.readFileSync(path.join(stateDir, f), "utf8"));
641
+ return j.files?.["companies/manifest.yaml"] != null;
642
+ });
643
+ expect(journaledManifest).toBe(true);
644
+ });
645
+
646
+ it("personalMode pull lands the session-continuity pointer + active thread file under <hqRoot>/workspace/threads/ so a handoff resumes on machine B (DEV-1778)", async () => {
647
+ // End-to-end download leg of the cross-machine handoff. Machine A pushed
648
+ // workspace/threads/handoff.json + the thread file it points to into the
649
+ // personal bucket; machine B pulls and both must land hq-root-relative
650
+ // (NOT under companies/<slug>/) with the pointer still resolving to the
651
+ // thread file that also landed — that is what lets /startwork resume.
652
+ const handoffKey = "workspace/threads/handoff.json";
653
+ const threadKey = "workspace/threads/T-20260619-1200-resume-me.json";
654
+
655
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
656
+ { key: handoffKey, size: 80, lastModified: new Date(), etag: '"h1"' },
657
+ { key: threadKey, size: 40, lastModified: new Date(), etag: '"t1"' },
658
+ ]);
659
+
660
+ // Materialize realistic bytes per key (the default mock writes a fixed
661
+ // string, but the pointer must be valid JSON referencing the thread).
662
+ const origDownload = vi.mocked(s3Module.downloadFile).getMockImplementation();
663
+ vi.mocked(s3Module.downloadFile).mockImplementation(
664
+ async (_ctx: unknown, key: string, localPath: string) => {
665
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
666
+ const body = key.endsWith("handoff.json")
667
+ ? JSON.stringify({ thread_path: threadKey, message: "from machine A" })
668
+ : JSON.stringify({ conversation_summary: "pick up here" });
669
+ fs.writeFileSync(localPath, body);
670
+ return { metadata: {} };
671
+ },
672
+ );
673
+
674
+ try {
675
+ const result = await sync({
676
+ company: "acme",
677
+ vaultConfig: mockConfig,
678
+ hqRoot: tmpDir,
679
+ personalMode: true,
680
+ });
681
+
682
+ expect(result.filesDownloaded).toBe(2);
683
+
684
+ const handoffLocal = path.join(tmpDir, "workspace", "threads", "handoff.json");
685
+ const threadLocal = path.join(
686
+ tmpDir,
687
+ "workspace",
688
+ "threads",
689
+ "T-20260619-1200-resume-me.json",
690
+ );
691
+ expect(fs.existsSync(handoffLocal)).toBe(true);
692
+ expect(fs.existsSync(threadLocal)).toBe(true);
693
+
694
+ // Pointer round-trips and resolves to the thread file that also landed.
695
+ const pointer = JSON.parse(fs.readFileSync(handoffLocal, "utf-8"));
696
+ expect(pointer.thread_path).toBe(threadKey);
697
+ expect(fs.existsSync(path.join(tmpDir, pointer.thread_path))).toBe(true);
698
+
699
+ // Must NOT be misfiled under companies/<slug>/.
700
+ expect(
701
+ fs.existsSync(path.join(tmpDir, "companies", "acme", handoffKey)),
702
+ ).toBe(false);
703
+ } finally {
704
+ if (origDownload) {
705
+ vi.mocked(s3Module.downloadFile).mockImplementation(origDownload);
706
+ }
707
+ }
708
+ });
709
+
710
+ it("personalMode pull does NOT clobber a newer local session-continuity pointer (conflict → keep local) (DEV-1778)", async () => {
711
+ // Machine B did its OWN /handoff after machine A's push, so B's local
712
+ // handoff.json is newer than the remote. The pull must preserve B's
713
+ // pointer rather than overwrite it with A's stale one — the brief's
714
+ // "download cleanly without clobbering a newer local pointer".
715
+ const threadsLocal = path.join(tmpDir, "workspace", "threads");
716
+ fs.mkdirSync(threadsLocal, { recursive: true });
717
+ fs.writeFileSync(
718
+ path.join(threadsLocal, "handoff.json"),
719
+ JSON.stringify({
720
+ thread_path: "workspace/threads/T-machineB.json",
721
+ message: "newer local from B",
722
+ }),
723
+ );
724
+
725
+ // Journal records a prior synced baseline (stale hash, no remoteEtag) so
726
+ // the planner sees local-changed AND remote-changed → conflict. Keys are
727
+ // hq-root-relative in personalMode.
728
+ fs.writeFileSync(
729
+ journalPath,
730
+ JSON.stringify({
731
+ version: "1",
732
+ lastSync: new Date().toISOString(),
733
+ files: {
734
+ "workspace/threads/handoff.json": {
735
+ hash: "old-hash-from-last-sync",
736
+ size: 10,
737
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
738
+ direction: "down",
739
+ },
740
+ },
741
+ }),
742
+ );
743
+
744
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
745
+ {
746
+ key: "workspace/threads/handoff.json",
747
+ size: 50,
748
+ lastModified: new Date(),
749
+ etag: '"remote-from-A"',
750
+ },
751
+ ]);
752
+
753
+ const result = await sync({
754
+ company: "acme",
755
+ onConflict: "keep",
756
+ vaultConfig: mockConfig,
757
+ hqRoot: tmpDir,
758
+ personalMode: true,
759
+ });
760
+
761
+ expect(result.conflicts).toBe(1);
762
+ expect(result.conflictPaths).toEqual(["workspace/threads/handoff.json"]);
763
+ expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
764
+
765
+ // B's newer local pointer is preserved verbatim — not clobbered by A's.
766
+ const kept = JSON.parse(
767
+ fs.readFileSync(path.join(threadsLocal, "handoff.json"), "utf-8"),
768
+ );
769
+ expect(kept.message).toBe("newer local from B");
770
+ expect(kept.thread_path).toBe("workspace/threads/T-machineB.json");
771
+ });
772
+
614
773
  it("personalMode + includeLocalCompanies: downloads companies/{cloud-false-slug}/... keys when slug NOT in teamSyncedSlugs", async () => {
615
774
  // The symmetric flip for the cloud:false → personal-bucket fallback.
616
775
  // Machine A pushed `companies/free-co/notes.md` to the personal bucket
package/src/cli/sync.ts CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  PERSONAL_VAULT_JOURNAL_SLUG,
36
36
  migratePersonalVaultJournal,
37
37
  } from "../journal.js";
38
+ import { PERSONAL_VAULT_MANIFEST_KEY } from "../personal-vault.js";
38
39
  import {
39
40
  buildScopeShrinkPlan,
40
41
  applyScopeShrink,
@@ -1751,7 +1752,17 @@ function computePullPlan(
1751
1752
  continue;
1752
1753
  }
1753
1754
 
1754
- if (personalMode && remoteFile.key.startsWith("companies/")) {
1755
+ if (
1756
+ personalMode &&
1757
+ remoteFile.key.startsWith("companies/") &&
1758
+ // EXEMPTION: companies/manifest.yaml is the routing source-of-truth
1759
+ // carved INTO the personal vault on the push side
1760
+ // (computePersonalVaultPaths). It must round-trip on the pull leg too —
1761
+ // skipping it here leaves it forever unjournaled, which re-fires a
1762
+ // transient push-side conflict every sync (no journal baseline). Let it
1763
+ // fall through to download + journal like any personal file.
1764
+ remoteFile.key !== PERSONAL_VAULT_MANIFEST_KEY
1765
+ ) {
1755
1766
  // Default: drop every `companies/...` key — the legacy contract
1756
1767
  // is that the personal bucket should never contain them.
1757
1768
  //
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Tests for the cwd → owning-company resolver (surface-hq-console-telemetry
3
+ * US-002). Covers manifest parsing, cmp_* extraction, longest-prefix matching,
4
+ * the trailing-slash sibling boundary, and the unattributed (no-match) path.
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
8
+ import * as fs from "fs";
9
+ import * as os from "os";
10
+ import * as path from "path";
11
+ import {
12
+ buildRepoCompanyMap,
13
+ resolveCompanyForCwd,
14
+ } from "./company-resolver.js";
15
+
16
+ const INDIGO = "cmp_01INDIGO";
17
+ const HPO = "cmp_01HPO";
18
+
19
+ const MANIFEST = `companies:
20
+ personal:
21
+ name: Personal
22
+ repos: []
23
+ indigo:
24
+ name: Indigo
25
+ repos:
26
+ - repos/private/hq-cloud
27
+ - repos/public/hq-core
28
+ cloud_uid: ${INDIGO}
29
+ hpo:
30
+ name: HPO
31
+ repos:
32
+ - repos/private/hpo-jobs
33
+ cloud_uid: ${HPO}
34
+ liverecover:
35
+ name: LiveRecover
36
+ repos:
37
+ - repos/private/liverecover-site
38
+ `;
39
+
40
+ function setupHqRoot(manifest: string = MANIFEST): string {
41
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-resolver-test-"));
42
+ fs.mkdirSync(path.join(root, "companies"), { recursive: true });
43
+ fs.writeFileSync(path.join(root, "companies", "manifest.yaml"), manifest);
44
+ return root;
45
+ }
46
+
47
+ describe("buildRepoCompanyMap + resolveCompanyForCwd", () => {
48
+ let hqRoot: string;
49
+ afterEach(() => {
50
+ if (hqRoot) fs.rmSync(hqRoot, { recursive: true, force: true });
51
+ });
52
+
53
+ it("resolves a cwd inside a company repo to that company's cmp_ uid", async () => {
54
+ hqRoot = setupHqRoot();
55
+ const map = await buildRepoCompanyMap(hqRoot);
56
+ const cwd = path.join(hqRoot, "repos/private/hq-cloud/src");
57
+ expect(resolveCompanyForCwd(cwd, map)).toBe(INDIGO);
58
+ });
59
+
60
+ it("resolves the repo root itself (exact match, no trailing slash)", async () => {
61
+ hqRoot = setupHqRoot();
62
+ const map = await buildRepoCompanyMap(hqRoot);
63
+ const cwd = path.join(hqRoot, "repos/public/hq-core");
64
+ expect(resolveCompanyForCwd(cwd, map)).toBe(INDIGO);
65
+ });
66
+
67
+ it("maps each repo to its own company", async () => {
68
+ hqRoot = setupHqRoot();
69
+ const map = await buildRepoCompanyMap(hqRoot);
70
+ expect(resolveCompanyForCwd(path.join(hqRoot, "repos/private/hpo-jobs/x"), map)).toBe(HPO);
71
+ });
72
+
73
+ it("returns undefined for a cwd outside any company repo (unattributed)", async () => {
74
+ hqRoot = setupHqRoot();
75
+ const map = await buildRepoCompanyMap(hqRoot);
76
+ expect(resolveCompanyForCwd("/Users/x/random-repo", map)).toBeUndefined();
77
+ expect(resolveCompanyForCwd(path.join(hqRoot, "companies/indigo"), map)).toBeUndefined();
78
+ });
79
+
80
+ it("does NOT attribute a company that lacks a cloud_uid (not cloud-backed)", async () => {
81
+ hqRoot = setupHqRoot();
82
+ const map = await buildRepoCompanyMap(hqRoot);
83
+ // liverecover-site has no cloud_uid → unattributed.
84
+ expect(resolveCompanyForCwd(path.join(hqRoot, "repos/private/liverecover-site"), map)).toBeUndefined();
85
+ });
86
+
87
+ it("does not match a sibling that merely shares a path prefix", async () => {
88
+ hqRoot = setupHqRoot();
89
+ const map = await buildRepoCompanyMap(hqRoot);
90
+ // `<repo>-other` shares the string prefix but must NOT match (slash boundary).
91
+ expect(resolveCompanyForCwd(path.join(hqRoot, "repos/private/hq-cloud-other"), map)).toBeUndefined();
92
+ });
93
+
94
+ it("handles a trailing slash on the cwd", async () => {
95
+ hqRoot = setupHqRoot();
96
+ const map = await buildRepoCompanyMap(hqRoot);
97
+ expect(resolveCompanyForCwd(path.join(hqRoot, "repos/private/hq-cloud") + "/", map)).toBe(INDIGO);
98
+ });
99
+
100
+ it("returns undefined for an undefined/empty cwd", async () => {
101
+ hqRoot = setupHqRoot();
102
+ const map = await buildRepoCompanyMap(hqRoot);
103
+ expect(resolveCompanyForCwd(undefined, map)).toBeUndefined();
104
+ expect(resolveCompanyForCwd("", map)).toBeUndefined();
105
+ });
106
+
107
+ it("never returns the reserved 'unattributed' sentinel", async () => {
108
+ hqRoot = setupHqRoot(`companies:
109
+ bad:
110
+ repos:
111
+ - repos/x
112
+ cloud_uid: unattributed
113
+ `);
114
+ const map = await buildRepoCompanyMap(hqRoot);
115
+ // `unattributed` is not a cmp_* uid → not added to the map → no match.
116
+ expect(map.entries).toHaveLength(0);
117
+ expect(resolveCompanyForCwd(path.join(hqRoot, "repos/x"), map)).toBeUndefined();
118
+ });
119
+
120
+ it("yields an empty map for a missing manifest", async () => {
121
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-resolver-empty-"));
122
+ try {
123
+ const map = await buildRepoCompanyMap(root);
124
+ expect(map.entries).toHaveLength(0);
125
+ expect(resolveCompanyForCwd(path.join(root, "repos/private/hq-cloud"), map)).toBeUndefined();
126
+ } finally {
127
+ fs.rmSync(root, { recursive: true, force: true });
128
+ }
129
+ });
130
+
131
+ it("yields an empty map for an unparseable manifest", async () => {
132
+ hqRoot = setupHqRoot(":\n not: [valid: yaml");
133
+ const map = await buildRepoCompanyMap(hqRoot);
134
+ expect(map.entries).toHaveLength(0);
135
+ });
136
+ });