@indigoai-us/hq-cloud 5.11.3 → 5.13.0

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.
package/dist/s3.test.js CHANGED
@@ -12,6 +12,10 @@ import * as path from "path";
12
12
  // Capture every command sent to the S3Client across the test suite. Cleared
13
13
  // in beforeEach so per-test assertions don't leak from neighbours.
14
14
  const sentCommands = [];
15
+ // Per-test override for the ListObjectsV2Command response. Tests of
16
+ // `listRemoteFiles` push the Contents shape they want returned; the default
17
+ // is an empty bucket. Reset in beforeEach so cross-test leakage is impossible.
18
+ let nextListObjectsResponse = { Contents: [] };
15
19
  vi.mock("@aws-sdk/client-s3", () => {
16
20
  class FakeS3Client {
17
21
  async send(command) {
@@ -24,6 +28,9 @@ vi.mock("@aws-sdk/client-s3", () => {
24
28
  if (command.constructor.name === "PutObjectCommand") {
25
29
  return { ETag: '"fake-etag"' };
26
30
  }
31
+ if (command.constructor.name === "ListObjectsV2Command") {
32
+ return nextListObjectsResponse;
33
+ }
27
34
  return {};
28
35
  }
29
36
  }
@@ -69,7 +76,7 @@ vi.mock("@aws-sdk/client-s3", () => {
69
76
  DeleteObjectCommand,
70
77
  };
71
78
  });
72
- import { uploadFile } from "./s3.js";
79
+ import { uploadFile, listRemoteFiles } from "./s3.js";
73
80
  function makeCtx() {
74
81
  return {
75
82
  uid: "cmp_TEST",
@@ -161,4 +168,41 @@ describe("uploadFile", () => {
161
168
  expect(meta["created-by-sub"]).toBeUndefined();
162
169
  });
163
170
  });
171
+ describe("listRemoteFiles", () => {
172
+ beforeEach(() => {
173
+ sentCommands.length = 0;
174
+ nextListObjectsResponse = { Contents: [] };
175
+ });
176
+ // Regression: pre-fix, `if (!obj.Key || !obj.Size) continue;` skipped every
177
+ // 0-byte object on the listing. The user's personal vault stored
178
+ // `projects/.gitkeep` (a 0-byte placeholder) and the sync pull plan never
179
+ // saw it — the file existed on S3 but was invisible to the runner, so the
180
+ // box never created `projects/` on first pull. The fix narrows the guard
181
+ // to `if (!obj.Key) continue;` so Size === 0 passes through with its real
182
+ // value (handled `?? 0` to keep TS Size?: number happy).
183
+ it("includes 0-byte objects in the result (no implicit skip-on-empty)", async () => {
184
+ nextListObjectsResponse = {
185
+ Contents: [
186
+ { Key: "projects/.gitkeep", Size: 0, LastModified: new Date(), ETag: '"d41d8cd98f00b204e9800998ecf8427e"' },
187
+ { Key: "AGENTS.md", Size: 100, LastModified: new Date(), ETag: '"e1"' },
188
+ ],
189
+ };
190
+ const files = await listRemoteFiles(makeCtx());
191
+ expect(files).toHaveLength(2);
192
+ const gitkeep = files.find((f) => f.key === "projects/.gitkeep");
193
+ expect(gitkeep).toBeDefined();
194
+ expect(gitkeep.size).toBe(0);
195
+ });
196
+ it("still skips Contents entries with no Key (defensive against malformed responses)", async () => {
197
+ nextListObjectsResponse = {
198
+ Contents: [
199
+ { Size: 100, LastModified: new Date(), ETag: '"e1"' }, // no Key
200
+ { Key: "real.md", Size: 100, LastModified: new Date(), ETag: '"e2"' },
201
+ ],
202
+ };
203
+ const files = await listRemoteFiles(makeCtx());
204
+ expect(files).toHaveLength(1);
205
+ expect(files[0].key).toBe("real.md");
206
+ });
207
+ });
164
208
  //# sourceMappingURL=s3.test.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"s3.test.js","sourceRoot":"","sources":["../src/s3.test.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"s3.test.js","sourceRoot":"","sources":["../src/s3.test.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cloud",
3
- "version": "5.11.3",
3
+ "version": "5.13.0",
4
4
  "description": "HQ by Indigo cloud sync engine — bidirectional S3 sync for mobile access",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -8,6 +8,9 @@
8
8
  */
9
9
 
10
10
  import { describe, it, expect, vi, beforeEach } from "vitest";
11
+ import * as fs from "fs";
12
+ import * as os from "os";
13
+ import * as path from "path";
11
14
  import { runRunner } from "./sync-runner.js";
12
15
  import type {
13
16
  RunnerEvent,
@@ -1417,6 +1420,161 @@ describe("personal slot fanout", () => {
1417
1420
  expect(keys).not.toContain("journalSlug");
1418
1421
  }
1419
1422
  });
1423
+
1424
+ // ── Push-side personal-vault contract (Slice 2 / personal-vault scope) ────
1425
+ //
1426
+ // Symmetric to the pull-side tests (A/B/C) above. The personal-vault scope
1427
+ // on push is an EXCLUSION list mirroring the Rust hq-sync first-push in
1428
+ // src-tauri/src/commands/personal.rs::PERSONAL_VAULT_EXCLUDED_TOP_LEVEL:
1429
+ // .git/, companies/, core/, data/, personal/, repos/, workspace/ are
1430
+ // skipped; every other top-level entry under hq_root is shared. The
1431
+ // runner enforces this by passing pre-filtered top-level paths to share()
1432
+ // along with personalMode: true + journalSlug: "personal" so share()
1433
+ // keys files hq-root-relative and writes the personal journal.
1434
+
1435
+ it("D: shareFn invoked with personalMode: true + journalSlug: 'personal' for personal slot (push side)", async () => {
1436
+ const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
1437
+ const tmpHqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-runner-test-"));
1438
+ try {
1439
+ // Realistic personal-vault content so the exclusion-filter has something
1440
+ // to keep and something to drop. (Other tests pin exact contents — this
1441
+ // one only cares that personalMode/journalSlug are passed.)
1442
+ fs.mkdirSync(path.join(tmpHqRoot, "knowledge"), { recursive: true });
1443
+ fs.writeFileSync(path.join(tmpHqRoot, "knowledge", "notes.md"), "note");
1444
+
1445
+ const deps = makeDeps({
1446
+ createVaultClient: () =>
1447
+ makeVaultStub({
1448
+ memberships: [{ companyUid: "cmp_a" }],
1449
+ entityGet: (uid: string) =>
1450
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
1451
+ listPersons: () => Promise.resolve([olderPerson]),
1452
+ }),
1453
+ share: shareSpy,
1454
+ });
1455
+
1456
+ const code = await runRunner(
1457
+ ["--companies", "--direction", "both", "--hq-root", tmpHqRoot],
1458
+ deps,
1459
+ );
1460
+ expect(code).toBe(0);
1461
+
1462
+ const personalCall = (shareSpy.mock.calls as Array<[ShareOptions]>).find(
1463
+ (c) => c[0].company?.startsWith("prs_"),
1464
+ );
1465
+ expect(personalCall).toBeDefined();
1466
+ const personalArgs = personalCall![0] as ShareOptions & {
1467
+ personalMode?: boolean;
1468
+ journalSlug?: string;
1469
+ };
1470
+ expect(personalArgs.personalMode).toBe(true);
1471
+ expect(personalArgs.journalSlug).toBe("personal");
1472
+ } finally {
1473
+ fs.rmSync(tmpHqRoot, { recursive: true, force: true });
1474
+ }
1475
+ });
1476
+
1477
+ it("E: shareFn paths for personal slot = top-level entries under hq_root minus PERSONAL_VAULT_EXCLUDED_TOP_LEVEL", async () => {
1478
+ const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
1479
+ const tmpHqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-runner-test-"));
1480
+ try {
1481
+ // Included top-level entries — must appear in shareFn paths.
1482
+ fs.mkdirSync(path.join(tmpHqRoot, ".claude"));
1483
+ fs.mkdirSync(path.join(tmpHqRoot, "knowledge"));
1484
+ fs.mkdirSync(path.join(tmpHqRoot, "modules"));
1485
+ fs.mkdirSync(path.join(tmpHqRoot, ".agents"));
1486
+ fs.writeFileSync(path.join(tmpHqRoot, "README.md"), "readme");
1487
+ // Excluded top-level entries — must be filtered out.
1488
+ fs.mkdirSync(path.join(tmpHqRoot, ".git"));
1489
+ fs.mkdirSync(path.join(tmpHqRoot, "companies"));
1490
+ fs.mkdirSync(path.join(tmpHqRoot, "core"));
1491
+ fs.mkdirSync(path.join(tmpHqRoot, "data"));
1492
+ fs.mkdirSync(path.join(tmpHqRoot, "personal"));
1493
+ fs.mkdirSync(path.join(tmpHqRoot, "repos"));
1494
+ fs.mkdirSync(path.join(tmpHqRoot, "workspace"));
1495
+
1496
+ const deps = makeDeps({
1497
+ createVaultClient: () =>
1498
+ makeVaultStub({
1499
+ memberships: [{ companyUid: "cmp_a" }],
1500
+ entityGet: (uid: string) =>
1501
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
1502
+ listPersons: () => Promise.resolve([olderPerson]),
1503
+ }),
1504
+ share: shareSpy,
1505
+ });
1506
+
1507
+ const code = await runRunner(
1508
+ ["--companies", "--direction", "push", "--hq-root", tmpHqRoot],
1509
+ deps,
1510
+ );
1511
+ expect(code).toBe(0);
1512
+
1513
+ const personalCall = (shareSpy.mock.calls as Array<[ShareOptions]>).find(
1514
+ (c) => c[0].company?.startsWith("prs_"),
1515
+ );
1516
+ expect(personalCall).toBeDefined();
1517
+ const personalArgs = personalCall![0];
1518
+
1519
+ // Paths must be absolute, rooted at tmpHqRoot, and contain exactly the
1520
+ // included entries — no order guarantee from fs.readdirSync, so compare
1521
+ // as a set of basenames. NOTE: `core/` is now INCLUDED in the personal
1522
+ // vault (user directive 2026-05-13) — it ships real project content
1523
+ // (policies/, settings/, skills/, workers/, the hq-core scaffold), so
1524
+ // dropping it on first-push was leaving the box without the rules and
1525
+ // hooks the user expected. The hq-root `core.yaml` identity marker is
1526
+ // filtered separately by the anchored `/core.yaml` DEFAULT_IGNORES
1527
+ // rule, NOT by exclusion at the path level.
1528
+ const basenames = personalArgs.paths.map((p) => path.basename(p)).sort();
1529
+ expect(basenames).toEqual([".agents", ".claude", "README.md", "core", "knowledge", "modules"]);
1530
+ for (const p of personalArgs.paths) {
1531
+ expect(path.isAbsolute(p)).toBe(true);
1532
+ expect(p.startsWith(tmpHqRoot)).toBe(true);
1533
+ }
1534
+ // Excluded entries must NOT appear.
1535
+ for (const forbidden of [".git", "companies", "data", "personal", "repos", "workspace"]) {
1536
+ expect(basenames).not.toContain(forbidden);
1537
+ }
1538
+ } finally {
1539
+ fs.rmSync(tmpHqRoot, { recursive: true, force: true });
1540
+ }
1541
+ });
1542
+
1543
+ it("F: shareFn paths for company slots stay [hqRoot/companies/{slug}] with no personalMode/journalSlug keys", async () => {
1544
+ const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
1545
+ const tmpHqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-runner-test-"));
1546
+ try {
1547
+ const deps = makeDeps({
1548
+ createVaultClient: () =>
1549
+ makeVaultStub({
1550
+ memberships: [{ companyUid: "cmp_a" }],
1551
+ entityGet: (uid: string) =>
1552
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
1553
+ listPersons: () => Promise.resolve([olderPerson]),
1554
+ }),
1555
+ share: shareSpy,
1556
+ });
1557
+
1558
+ const code = await runRunner(
1559
+ ["--companies", "--direction", "push", "--hq-root", tmpHqRoot],
1560
+ deps,
1561
+ );
1562
+ expect(code).toBe(0);
1563
+
1564
+ const companyCalls = (shareSpy.mock.calls as Array<[ShareOptions]>).filter(
1565
+ (c) => c[0].company?.startsWith("cmp_"),
1566
+ );
1567
+ expect(companyCalls.length).toBeGreaterThan(0);
1568
+ for (const [args] of companyCalls) {
1569
+ expect(args.paths).toEqual([path.join(tmpHqRoot, "companies", "acme")]);
1570
+ const keys = Object.keys(args);
1571
+ expect(keys).not.toContain("personalMode");
1572
+ expect(keys).not.toContain("journalSlug");
1573
+ }
1574
+ } finally {
1575
+ fs.rmSync(tmpHqRoot, { recursive: true, force: true });
1576
+ }
1577
+ });
1420
1578
  });
1421
1579
 
1422
1580
  // ---------------------------------------------------------------------------
@@ -115,6 +115,64 @@ const DEFAULT_VAULT_API_URL =
115
115
 
116
116
  const DEFAULT_HQ_ROOT = path.join(os.homedir(), "hq");
117
117
 
118
+ // ---------------------------------------------------------------------------
119
+ // Personal-vault scope (exclusion list)
120
+ // ---------------------------------------------------------------------------
121
+ //
122
+ // Top-level directories under `hq_root/` that the personal-vault push MUST
123
+ // NOT upload. Mirrors the Rust constant of the same name in
124
+ // `hq-sync/src-tauri/src/commands/personal.rs` so the Tauri menubar's
125
+ // first-push and this Node runner's steady-state push enforce identical
126
+ // scope. Every other top-level entry under hq_root (e.g. `.claude/`,
127
+ // `knowledge/`, `modules/`, `README.md`, `.codex/`) is included, subject
128
+ // to the gitignore/hqignore filter that share() applies per-file.
129
+ //
130
+ // Rationale per entry:
131
+ // - `.git`: a repo's internal state is large, opaque, and useless after
132
+ // sync; .gitignore alone doesn't cover `.git/` because it's the repo
133
+ // itself, not a tracked path.
134
+ // - `companies/`: synced separately by the runner's per-membership fanout;
135
+ // do not double-write into the personal vault.
136
+ // - `data/`, `personal/`, `repos/`, `workspace/`: per user directive —
137
+ // heavy local-only content (machine-state, datasets, cloned remotes,
138
+ // session threads) that has no business in the personal vault.
139
+ //
140
+ // Note: `core/` was previously excluded but is now INCLUDED (user directive
141
+ // 2026-05-13). It ships the hq-core scaffold — policies/, settings/,
142
+ // skills/, workers/, plus the rules manifest at core/core.yaml — all real
143
+ // project content that the box needs. The hq-root identity marker
144
+ // `core.yaml` (at hq_root, distinct from `core/core.yaml`) is filtered
145
+ // separately by the root-anchored `/core.yaml` DEFAULT_IGNORES rule.
146
+ export const PERSONAL_VAULT_EXCLUDED_TOP_LEVEL: readonly string[] = [
147
+ ".git",
148
+ "companies",
149
+ "data",
150
+ "personal",
151
+ "repos",
152
+ "workspace",
153
+ ];
154
+
155
+ /**
156
+ * Compute absolute paths to share for the personal vault: every top-level
157
+ * entry under `hqRoot` whose basename is NOT in `PERSONAL_VAULT_EXCLUDED_TOP_LEVEL`.
158
+ * Mirrors the Rust `is_personal_vault_path` predicate (just hoisted to the
159
+ * top-level step). Order is whatever `fs.readdirSync` returns — share()
160
+ * doesn't care, and the per-file walk inside share() handles recursion
161
+ * uniformly. Missing hqRoot returns []; callers treat that as "no personal
162
+ * content to push" rather than a hard error.
163
+ */
164
+ export function computePersonalVaultPaths(hqRoot: string): string[] {
165
+ let entries: string[];
166
+ try {
167
+ entries = fs.readdirSync(hqRoot);
168
+ } catch {
169
+ return [];
170
+ }
171
+ return entries
172
+ .filter((name) => !PERSONAL_VAULT_EXCLUDED_TOP_LEVEL.includes(name))
173
+ .map((name) => path.join(hqRoot, name));
174
+ }
175
+
118
176
  // ---------------------------------------------------------------------------
119
177
  // Event protocol
120
178
  // ---------------------------------------------------------------------------
@@ -736,13 +794,17 @@ export async function runRunner(
736
794
  };
737
795
 
738
796
  // Push first so a subsequent pull doesn't overwrite files we were about
739
- // to broadcast. Uses the walk-everything-under-companies/{slug}/ entry
740
- // point with `skipUnchanged` so we don't re-upload files that haven't
741
- // changed since the last sync.
797
+ // to broadcast. Company targets walk `companies/{slug}/`; the personal
798
+ // target walks every top-level entry under hqRoot minus the exclusion
799
+ // list (see PERSONAL_VAULT_EXCLUDED_TOP_LEVEL). `skipUnchanged: true`
800
+ // keeps both cases efficient on re-runs.
742
801
  if (doPush) {
743
802
  activePhase = "push";
803
+ const pushPaths = target.personalMode === true
804
+ ? computePersonalVaultPaths(parsed.hqRoot)
805
+ : [path.join(parsed.hqRoot, "companies", target.slug)];
744
806
  pushResult = await shareFn({
745
- paths: [path.join(parsed.hqRoot, "companies", target.slug)],
807
+ paths: pushPaths,
746
808
  company: target.uid,
747
809
  vaultConfig,
748
810
  hqRoot: parsed.hqRoot,
@@ -755,6 +817,12 @@ export async function runRunner(
755
817
  propagateDeletes: true,
756
818
  onEvent: tagAndEmit,
757
819
  ...(uploadAuthor ? { author: uploadAuthor } : {}),
820
+ // Mirror the pull-side seam: only spread these for the personal
821
+ // slot so company-target args stay identical to the pre-Slice-2
822
+ // shape (the "no personalMode/journalSlug keys" regression test
823
+ // in sync-runner.test.ts pins that contract).
824
+ ...(target.personalMode !== undefined ? { personalMode: target.personalMode } : {}),
825
+ ...(target.journalSlug !== undefined ? { journalSlug: target.journalSlug } : {}),
758
826
  });
759
827
  }
760
828
 
@@ -1082,4 +1082,150 @@ describe("share", () => {
1082
1082
  const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1083
1083
  expect(journal.files["flaky.md"]).toBeDefined();
1084
1084
  });
1085
+
1086
+ // ── personalMode ───────────────────────────────────────────────────────────
1087
+ //
1088
+ // The personal vault (slug "personal" in the runner's fanout plan) shares
1089
+ // files from hqRoot DIRECTLY — not from hqRoot/companies/<slug>/. Mirrors
1090
+ // the Rust hq-sync first-push contract in src-tauri/src/commands/personal.rs:
1091
+ // syncRoot = hqRoot, journal slug = "personal", remote keys are hq-root-
1092
+ // relative (e.g. ".claude/skills/foo.md", "knowledge/notes.md"). The
1093
+ // exclusion list itself is enforced by the runner (sync-runner.ts) by only
1094
+ // passing in the allowed top-level directories — share() trusts its
1095
+ // `paths` input.
1096
+ describe("personalMode", () => {
1097
+ it("personalMode=true keys files hq-root-relative, not companies/{slug}/-relative", async () => {
1098
+ fs.mkdirSync(path.join(tmpDir, ".claude", "skills"), { recursive: true });
1099
+ fs.mkdirSync(path.join(tmpDir, "knowledge"), { recursive: true });
1100
+ fs.writeFileSync(path.join(tmpDir, ".claude", "skills", "foo.md"), "skill");
1101
+ fs.writeFileSync(path.join(tmpDir, "knowledge", "notes.md"), "note");
1102
+
1103
+ const result = await share({
1104
+ paths: [
1105
+ path.join(tmpDir, ".claude"),
1106
+ path.join(tmpDir, "knowledge"),
1107
+ ],
1108
+ company: "acme",
1109
+ vaultConfig: mockConfig,
1110
+ hqRoot: tmpDir,
1111
+ personalMode: true,
1112
+ journalSlug: "personal",
1113
+ });
1114
+
1115
+ expect(result.filesUploaded).toBe(2);
1116
+ // Remote keys must be hq-root-relative, NOT prefixed with companies/personal/
1117
+ const keys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
1118
+ expect(keys.sort()).toEqual([".claude/skills/foo.md", "knowledge/notes.md"]);
1119
+ });
1120
+
1121
+ it("personalMode=true writes journal under the personal journalSlug", async () => {
1122
+ fs.mkdirSync(path.join(tmpDir, "knowledge"), { recursive: true });
1123
+ fs.writeFileSync(path.join(tmpDir, "knowledge", "notes.md"), "note");
1124
+
1125
+ await share({
1126
+ paths: [path.join(tmpDir, "knowledge")],
1127
+ company: "acme",
1128
+ vaultConfig: mockConfig,
1129
+ hqRoot: tmpDir,
1130
+ personalMode: true,
1131
+ journalSlug: "personal",
1132
+ });
1133
+
1134
+ // Personal journal is keyed "personal", NOT the company's ctx.slug ("acme")
1135
+ const personalJournalPath = path.join(stateDir, "sync-journal.personal.json");
1136
+ const acmeJournalPath = path.join(stateDir, "sync-journal.acme.json");
1137
+ expect(fs.existsSync(personalJournalPath)).toBe(true);
1138
+ expect(fs.existsSync(acmeJournalPath)).toBe(false);
1139
+
1140
+ const journal = JSON.parse(fs.readFileSync(personalJournalPath, "utf-8"));
1141
+ expect(journal.files["knowledge/notes.md"]).toBeDefined();
1142
+ });
1143
+
1144
+ it("personalMode=true accepts files outside companies/<slug>/ (companion to the company-folder rejection)", async () => {
1145
+ // Same fixture as the "skips files outside the company folder" test
1146
+ // above — file at hqRoot root, NOT under companies/acme/. Without
1147
+ // personalMode this is rejected with a "outside company folder" warning;
1148
+ // with personalMode=true the file IS uploaded because syncRoot=hqRoot.
1149
+ const outsideFile = path.join(tmpDir, "stray.md");
1150
+ fs.writeFileSync(outsideFile, "stray");
1151
+
1152
+ const result = await share({
1153
+ paths: [outsideFile],
1154
+ company: "acme",
1155
+ vaultConfig: mockConfig,
1156
+ hqRoot: tmpDir,
1157
+ personalMode: true,
1158
+ journalSlug: "personal",
1159
+ });
1160
+
1161
+ expect(result.filesUploaded).toBe(1);
1162
+ expect(uploadFile).toHaveBeenCalledWith(expect.anything(), outsideFile, "stray.md");
1163
+ });
1164
+
1165
+ it("uploads 0-byte files (e.g. .gitkeep placeholders)", async () => {
1166
+ // Regression for the bug where `projects/.gitkeep` (0 bytes) was
1167
+ // present locally + on the listing target but `listRemoteFiles`
1168
+ // silently skipped 0-byte objects via `!obj.Size`. Same class of bug
1169
+ // can also bite uploads if any size guard treats 0 as falsy. Pin the
1170
+ // push side here: a 0-byte file MUST upload like any other.
1171
+ fs.mkdirSync(path.join(tmpDir, "projects"), { recursive: true });
1172
+ const gitkeep = path.join(tmpDir, "projects", ".gitkeep");
1173
+ fs.writeFileSync(gitkeep, "");
1174
+ expect(fs.statSync(gitkeep).size).toBe(0);
1175
+
1176
+ const result = await share({
1177
+ paths: [path.join(tmpDir, "projects")],
1178
+ company: "acme",
1179
+ vaultConfig: mockConfig,
1180
+ hqRoot: tmpDir,
1181
+ personalMode: true,
1182
+ journalSlug: "personal",
1183
+ });
1184
+
1185
+ expect(result.filesUploaded).toBe(1);
1186
+ expect(uploadFile).toHaveBeenCalledWith(expect.anything(), gitkeep, "projects/.gitkeep");
1187
+ });
1188
+
1189
+ it("personalMode=true + skipUnchanged honors the personal-journal hash", async () => {
1190
+ fs.mkdirSync(path.join(tmpDir, "knowledge"), { recursive: true });
1191
+ const testFile = path.join(tmpDir, "knowledge", "stable.md");
1192
+ fs.writeFileSync(testFile, "stable content");
1193
+
1194
+ const { hashFile } = await import("../journal.js");
1195
+ const hash = hashFile(testFile);
1196
+
1197
+ // Pre-seed the PERSONAL journal (not the per-company one) so the
1198
+ // skipUnchanged short-circuit fires for the right slug.
1199
+ const personalJournalPath = path.join(stateDir, "sync-journal.personal.json");
1200
+ fs.writeFileSync(
1201
+ personalJournalPath,
1202
+ JSON.stringify({
1203
+ version: "1",
1204
+ lastSync: new Date().toISOString(),
1205
+ files: {
1206
+ "knowledge/stable.md": {
1207
+ hash,
1208
+ size: 15,
1209
+ syncedAt: new Date().toISOString(),
1210
+ direction: "up",
1211
+ },
1212
+ },
1213
+ }),
1214
+ );
1215
+
1216
+ const result = await share({
1217
+ paths: [path.join(tmpDir, "knowledge")],
1218
+ company: "acme",
1219
+ vaultConfig: mockConfig,
1220
+ hqRoot: tmpDir,
1221
+ personalMode: true,
1222
+ journalSlug: "personal",
1223
+ skipUnchanged: true,
1224
+ });
1225
+
1226
+ expect(result.filesUploaded).toBe(0);
1227
+ expect(result.filesSkipped).toBe(1);
1228
+ expect(uploadFile).not.toHaveBeenCalled();
1229
+ });
1230
+ });
1085
1231
  });
package/src/cli/share.ts CHANGED
@@ -193,6 +193,23 @@ export interface ShareOptions {
193
193
  * this engine. The runner pipes Cognito idToken claims through here.
194
194
  */
195
195
  author?: UploadAuthor;
196
+ /**
197
+ * When true, share() targets the caller's person-entity bucket: syncRoot
198
+ * is `hqRoot` itself (NOT `hqRoot/companies/<slug>/`), so remote keys are
199
+ * hq-root-relative (e.g. ".claude/skills/foo.md", "knowledge/notes.md") to
200
+ * match the Rust hq-sync first-push contract in
201
+ * `src-tauri/src/commands/personal.rs`. The exclusion of top-level dirs
202
+ * (.git, companies, core, data, personal, repos, workspace) is enforced
203
+ * by the runner — share() trusts its `paths` input.
204
+ */
205
+ personalMode?: boolean;
206
+ /**
207
+ * Override for the per-slug journal file name. Defaults to `ctx.slug`. The
208
+ * runner passes `journalSlug: "personal"` for the personal slot so the TS
209
+ * push and the Rust personal first-push share idempotency state under one
210
+ * `sync-journal.personal.json` file.
211
+ */
212
+ journalSlug?: string;
196
213
  }
197
214
 
198
215
  export interface ShareResult {
@@ -263,9 +280,17 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
263
280
  // Remote keys are company-relative; the on-disk scoping prefix is
264
281
  // companies/{slug}/. Anything outside this folder gets skipped to avoid
265
282
  // leaking cross-company state into the vault.
266
- const syncRoot = path.join(hqRoot, "companies", ctx.slug);
283
+ //
284
+ // In personalMode the syncRoot is `hqRoot` itself — remote keys are
285
+ // hq-root-relative to match the Rust personal first-push (which uploads
286
+ // every non-excluded top-level dir under ~/HQ). The exclusion list is
287
+ // enforced upstream by the runner; share() just trusts `paths`.
288
+ const syncRoot = options.personalMode === true
289
+ ? hqRoot
290
+ : path.join(hqRoot, "companies", ctx.slug);
267
291
  const shouldSync = createIgnoreFilter(hqRoot);
268
- const journal = readJournal(ctx.slug);
292
+ const journalSlug = options.journalSlug ?? ctx.slug;
293
+ const journal = readJournal(journalSlug);
269
294
 
270
295
  let filesUploaded = 0;
271
296
  let bytesUploaded = 0;
@@ -457,7 +482,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
457
482
  // See cli/sync.ts: stamp lastSync on completion so a no-op share still
458
483
  // ticks the "Last sync" indicator.
459
484
  journal.lastSync = new Date().toISOString();
460
- writeJournal(ctx.slug, journal);
485
+ writeJournal(journalSlug, journal);
461
486
 
462
487
  return {
463
488
  filesUploaded,
@@ -38,12 +38,17 @@ describe("createIgnoreFilter", () => {
38
38
  expect(shouldSync(path.join(hqRoot, "companies/indigo/notes.md"))).toBe(true);
39
39
  });
40
40
 
41
- it("permissive mode: HQ-root core.yaml marker is ignored", () => {
42
- // core.yaml is the local hq-root identity marker. It must never
41
+ it("permissive mode: HQ-root core.yaml marker is ignored, but nested core/core.yaml syncs", () => {
42
+ // The hq-root `core.yaml` is the local identity marker. It must never
43
43
  // round-trip through the bucket — pulling another machine's marker
44
- // would corrupt root discovery.
44
+ // would corrupt root discovery. But `core/core.yaml` (the hq-core
45
+ // scaffold definition) is a real project file that DOES need to sync.
46
+ // Before the rule was anchored, the unanchored `core.yaml` pattern
47
+ // matched at any depth and silently blocked `core/core.yaml` from
48
+ // syncing along with the rest of `core/`.
45
49
  const shouldSync = createIgnoreFilter(hqRoot);
46
50
  expect(shouldSync(path.join(hqRoot, "core.yaml"))).toBe(false);
51
+ expect(shouldSync(path.join(hqRoot, "core/core.yaml"))).toBe(true);
47
52
  });
48
53
 
49
54
  it("permissive mode: modules/modules.yaml manifest is ignored", () => {
package/src/ignore.ts CHANGED
@@ -76,7 +76,11 @@ export const DEFAULT_IGNORES = [
76
76
  ".hq-*",
77
77
  "modules.lock",
78
78
  // hq-root identity marker — discovered locally per-machine, never synced.
79
- "core.yaml",
79
+ // Root-anchored: only the literal `core.yaml` at hq-root matches. Without
80
+ // the leading slash, gitignore semantics match `core.yaml` at any depth,
81
+ // which previously silently filtered out `core/core.yaml` (the hq-core
82
+ // scaffold rules manifest — a real project file that DOES sync).
83
+ "/core.yaml",
80
84
  // hq modules manifest — local module-resolution state, never synced.
81
85
  "modules/modules.yaml",
82
86
  // per-company identity file — written locally on first sync, never round-tripped.