@indigoai-us/hq-cloud 6.11.10 → 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.
@@ -8,13 +8,36 @@
8
8
  * re-deriving the implementation internals.
9
9
  */
10
10
 
11
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
11
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
12
12
  import * as fs from "fs";
13
13
  import * as path from "path";
14
14
  import * as os from "os";
15
15
  import { reindex } from "./reindex.js";
16
16
  import { lockPathFor, OPERATION_LOCKED_EXIT } from "../operation-lock.js";
17
17
 
18
+ // HQ-B0: simulate the Windows filesystem rejecting a ':' in a path segment.
19
+ // The flag is off by default, so every other test sees the real `mkdirSync`;
20
+ // the regression test below flips it on only for its own run.
21
+ const hoisted = vi.hoisted(() => ({ failNamespacedMkdir: false }));
22
+ vi.mock("fs", async (importOriginal) => {
23
+ const actual = await importOriginal<typeof import("fs")>();
24
+ const mkdirSync = ((p: unknown, opts: unknown) => {
25
+ if (
26
+ hoisted.failNamespacedMkdir &&
27
+ typeof p === "string" &&
28
+ /[:][^/\\]*$/.test(p) // a colon in the final path segment
29
+ ) {
30
+ throw Object.assign(
31
+ new Error(`ENOENT: no such file or directory, mkdir '${p}'`),
32
+ { code: "ENOENT" },
33
+ );
34
+ }
35
+ return (actual.mkdirSync as (p: unknown, opts: unknown) => unknown)(p, opts);
36
+ }) as typeof actual.mkdirSync;
37
+ const patched = { ...actual, mkdirSync };
38
+ return { ...patched, default: patched };
39
+ });
40
+
18
41
  describe("reindex", () => {
19
42
  let root: string;
20
43
  let stateDir: string;
@@ -223,4 +246,27 @@ describe("reindex", () => {
223
246
  reindex({ repoRoot: root });
224
247
  expect(fs.existsSync(lockPathFor(root))).toBe(false);
225
248
  });
249
+
250
+ // ── HQ-B0: wrapper dir name uses ':' which is illegal on Windows ─────────
251
+ // `<ns>:<skill>` wrapper dirs contain a colon — fine on macOS/Linux, but a
252
+ // reserved drive/ADS separator on Windows, where mkdirSync throws ENOENT.
253
+ // A single un-creatable wrapper must not abort the entire reindex run.
254
+
255
+ it("does not abort reindex when a namespaced wrapper dir cannot be created (':' illegal on Windows)", () => {
256
+ writeSkill("core/skills/demo");
257
+ writeSkill("companies/acme/skills/widget");
258
+
259
+ hoisted.failNamespacedMkdir = true;
260
+ try {
261
+ // Without the guard the wrapper mkdir throws ENOENT and aborts the whole
262
+ // command; with it, the run completes and the bad wrappers are skipped.
263
+ expect(reindex({ repoRoot: root }).status).toBe(0);
264
+ } finally {
265
+ hoisted.failNamespacedMkdir = false;
266
+ }
267
+
268
+ // The un-creatable wrappers are skipped (not half-written).
269
+ expect(fs.existsSync(path.join(root, ".claude/skills/core:demo"))).toBe(false);
270
+ expect(fs.existsSync(path.join(root, ".claude/skills/acme:widget"))).toBe(false);
271
+ });
226
272
  });
@@ -270,7 +270,23 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
270
270
  /* best-effort */
271
271
  }
272
272
  }
273
- fs.mkdirSync(wrapper, { recursive: true });
273
+ try {
274
+ fs.mkdirSync(wrapper, { recursive: true });
275
+ } catch (err) {
276
+ // Namespaced wrappers embed a ':' in the directory name
277
+ // (`<ns>:<skill>`). That is a legal filename character on macOS/Linux
278
+ // but a reserved drive/ADS separator on Windows, so mkdir there fails
279
+ // with ENOENT. Skip this one wrapper with a clear message instead of
280
+ // aborting the whole reindex (the skill's source folder is untouched).
281
+ const code = (err as NodeJS.ErrnoException).code;
282
+ warn(
283
+ `reindex: could not create skill wrapper '${wrapperName}' ` +
284
+ `(${code ?? "error"}: ${(err as Error).message}). Namespaced wrappers ` +
285
+ `use a ':' in the directory name, which is not a legal filename ` +
286
+ `character on this platform (e.g. Windows); skipping this skill.`,
287
+ );
288
+ continue;
289
+ }
274
290
 
275
291
  // Symlink every (non-hidden) entry in the source skill folder. The
276
292
  // wrapper lives three levels below REPO_ROOT.
@@ -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
  //
@@ -94,6 +94,15 @@ export interface PersonalVaultOptions {
94
94
  * hqRoot returns []; callers treat that as "no personal content to push"
95
95
  * rather than a hard error.
96
96
  */
97
+ /**
98
+ * S3 key (hq-root-relative, forward-slash) of the companies manifest — the
99
+ * routing source-of-truth — carved into the personal vault even though
100
+ * `companies/` is otherwise excluded. Exported so the PULL plan applies the
101
+ * SAME exemption: skipping it on the pull leaves it unjournaled, which re-fires
102
+ * a transient push-side conflict every sync (no journal baseline).
103
+ */
104
+ export const PERSONAL_VAULT_MANIFEST_KEY = "companies/manifest.yaml";
105
+
97
106
  export function computePersonalVaultPaths(
98
107
  hqRoot: string,
99
108
  opts: PersonalVaultOptions = {},
@@ -114,7 +123,7 @@ export function computePersonalVaultPaths(
114
123
  // because the parent `companies/` is in PERSONAL_VAULT_EXCLUDED_TOP_LEVEL
115
124
  // (we never enumerate the whole companies tree wholesale).
116
125
  const manifest: string[] = [];
117
- const manifestPath = path.join(hqRoot, "companies", "manifest.yaml");
126
+ const manifestPath = path.join(hqRoot, PERSONAL_VAULT_MANIFEST_KEY);
118
127
  try {
119
128
  if (fs.statSync(manifestPath).isFile()) {
120
129
  manifest.push(manifestPath);