@indigoai-us/hq-cloud 5.2.1 → 5.4.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.
@@ -4,10 +4,10 @@
4
4
  * (ADR-0001).
5
5
  *
6
6
  * The AppBar Sync menubar (Tauri + Rust) spawns this binary as a subprocess
7
- * and reads ndjson events from stdout. The protocol is intentionally narrow
8
- * and versioned-by-shape, not by tooling — no chalk, no colors, no human
9
- * prose. If you want to invoke sync as a human, use `hq sync` in
10
- * `@indigoai-us/hq-cli`.
7
+ * and reads ndjson events from BOTH stdout and stderr (see "Channels"
8
+ * below). The protocol is intentionally narrow and versioned-by-shape, not
9
+ * by tooling no chalk, no colors, no human prose. If you want to invoke
10
+ * sync as a human, use `hq sync` in `@indigoai-us/hq-cli`.
11
11
  *
12
12
  * Flags:
13
13
  * --companies Fan out across every membership the caller has
@@ -18,14 +18,23 @@
18
18
  * only output mode. Accepted for symmetry with the
19
19
  * AppBar's argv in case someone passes it.
20
20
  *
21
- * Event protocol (one JSON object per line on stdout):
22
- * setup-needed caller signed in but has no person entity yet
23
- * auth-error — no valid token available (interactive login disabled)
24
- * fanout-plan list of companies we're about to sync
25
- * progress per-file download
26
- * error — per-file or per-company error
27
- * complete — per-company summary
28
- * all-complete aggregate summary after fanout
21
+ * Channels (one JSON object per line):
22
+ * stdout protocol stream:
23
+ * setup-needed caller signed in but has no person entity yet
24
+ * fanout-plan list of companies we're about to sync
25
+ * progress per-file download
26
+ * complete per-company summary
27
+ * all-complete aggregate summary after fanout
28
+ * stderr diagnostic stream:
29
+ * error per-file or per-company error
30
+ * auth-error no valid token available (interactive login disabled)
31
+ *
32
+ * Why the split: error-class events go to stderr so the menubar's Sentry
33
+ * breadcrumb pipeline picks them up automatically (see hq-sync
34
+ * src-tauri/src/commands/sync.rs `ProcessEvent::Stderr` handler). The
35
+ * single Sentry capture at runner-exit then ships one #hq-alerts issue
36
+ * with the full per-file → company → exit error trail attached, instead
37
+ * of requiring per-event capture calls in the menubar.
29
38
  *
30
39
  * Exit code:
31
40
  * 0 — event stream describes the outcome (including setup-needed)
@@ -100,10 +109,14 @@ const DEFAULT_HQ_ROOT = path.join(os.homedir(), "hq");
100
109
  // ---------------------------------------------------------------------------
101
110
 
102
111
  /**
103
- * Every event emitted on stdout. The `company` field is present on every
104
- * event except `setup-needed` / `auth-error` / `fanout-plan` / `all-complete`
105
- * (which describe the whole run) consumers should treat its absence as
106
- * "meta-event, not tied to a specific company".
112
+ * Every event the runner emits. Channel routing (stdout vs stderr) is
113
+ * decided inside `runRunner`'s `emit` helper based on the event's `type`
114
+ * see the doc-block on the file header for the split.
115
+ *
116
+ * The `company` field is present on every event except `setup-needed` /
117
+ * `auth-error` / `fanout-plan` / `all-complete` (which describe the whole
118
+ * run) — consumers should treat its absence as "meta-event, not tied to a
119
+ * specific company".
107
120
  */
108
121
  export type RunnerEvent =
109
122
  | { type: "setup-needed" }
@@ -114,6 +127,7 @@ export type RunnerEvent =
114
127
  }
115
128
  | ({ type: "progress"; company: string } & Omit<Extract<SyncProgressEvent, { type: "progress" }>, "type">)
116
129
  | ({ type: "error"; company?: string } & Omit<Extract<SyncProgressEvent, { type: "error" }>, "type">)
130
+ | ({ type: "conflict"; company: string } & Omit<Extract<SyncProgressEvent, { type: "conflict" }>, "type">)
117
131
  | ({
118
132
  type: "complete";
119
133
  company: string;
@@ -135,6 +149,13 @@ export type RunnerEvent =
135
149
  /** Always emitted; 0 when no push phase ran. */
136
150
  filesUploaded: number;
137
151
  bytesUploaded: number;
152
+ /**
153
+ * Conflict file paths aggregated across every company in the run.
154
+ * Always emitted; empty array when no conflicts were detected. Lets
155
+ * the menubar UI render a flat list without re-walking per-company
156
+ * `complete` events.
157
+ */
158
+ conflictPaths: Array<{ company: string; path: string; direction: "pull" | "push" }>;
138
159
  errors: Array<{ company: string; message: string }>;
139
160
  };
140
161
 
@@ -347,8 +368,35 @@ export async function runRunner(
347
368
  const stdout = deps.stdout ?? process.stdout;
348
369
  const stderr = deps.stderr ?? process.stderr;
349
370
 
371
+ // ---- emit ---------------------------------------------------------------
372
+ // Error-class events go to stderr; everything else to stdout.
373
+ //
374
+ // Why split: the AppBar Sync menubar (Tauri + Rust) feeds runner stderr
375
+ // into Sentry as breadcrumbs and captures one Sentry event when the
376
+ // runner exits non-zero. Routing `error` / `auth-error` events through
377
+ // stderr makes them part of that breadcrumb trail automatically — the
378
+ // menubar doesn't need a per-event capture call, and operators get the
379
+ // full context (per-file errors → company error → exit) in a single
380
+ // Sentry issue alerted to #hq-alerts.
381
+ //
382
+ // Non-error events (progress, complete, fanout-plan, all-complete,
383
+ // setup-needed) stay on stdout. They're the protocol stream the menubar
384
+ // parses for UI updates; mixing them with error events on the same
385
+ // channel was the original design (single ndjson stream, simpler to
386
+ // tee), but error context belongs in the diagnostic channel.
387
+ //
388
+ // Backward compat: older menubar releases (pre-PR-#34) parse only
389
+ // stdout for ndjson; with this change they will NOT receive error
390
+ // events. The menubar's `HQ_CLOUD_VERSION` pin gates which runner
391
+ // they spawn, so old menubars stay on the previous runner version
392
+ // even after this one is published.
393
+ const ERROR_TYPES: ReadonlySet<RunnerEvent["type"]> = new Set([
394
+ "error",
395
+ "auth-error",
396
+ ]);
350
397
  const emit = (event: RunnerEvent): void => {
351
- stdout.write(`${JSON.stringify(event)}\n`);
398
+ const stream = ERROR_TYPES.has(event.type) ? stderr : stdout;
399
+ stream.write(`${JSON.stringify(event)}\n`);
352
400
  };
353
401
 
354
402
  // ---- argv -------------------------------------------------------------
@@ -479,6 +527,7 @@ export async function runRunner(
479
527
  let totalUploaded = 0;
480
528
  let totalUploadedBytes = 0;
481
529
  const errors: Array<{ company: string; message: string }> = [];
530
+ const allConflicts: Array<{ company: string; path: string; direction: "pull" | "push" }> = [];
482
531
 
483
532
  for (const target of plan) {
484
533
  const companyLabel = target.slug;
@@ -493,6 +542,14 @@ export async function runRunner(
493
542
  bytes: event.bytes,
494
543
  ...(event.message ? { message: event.message } : {}),
495
544
  });
545
+ } else if (event.type === "conflict") {
546
+ emit({
547
+ type: "conflict",
548
+ company: companyLabel,
549
+ path: event.path,
550
+ direction: event.direction,
551
+ resolution: event.resolution,
552
+ });
496
553
  } else {
497
554
  emit({
498
555
  type: "error",
@@ -508,6 +565,7 @@ export async function runRunner(
508
565
  filesUploaded: 0,
509
566
  bytesUploaded: 0,
510
567
  filesSkipped: 0,
568
+ conflictPaths: [],
511
569
  aborted: false,
512
570
  };
513
571
  let pullResult: SyncResult = {
@@ -515,6 +573,7 @@ export async function runRunner(
515
573
  bytesDownloaded: 0,
516
574
  filesSkipped: 0,
517
575
  conflicts: 0,
576
+ conflictPaths: [],
518
577
  aborted: false,
519
578
  };
520
579
 
@@ -549,6 +608,13 @@ export async function runRunner(
549
608
  });
550
609
  }
551
610
 
611
+ // Concat push + pull conflict paths into a single per-company list.
612
+ // Both arrays are always present (defaulted to []) so consumers can
613
+ // treat `conflictPaths` as authoritative without a falsy check.
614
+ const mergedConflictPaths = [
615
+ ...pullResult.conflictPaths,
616
+ ...pushResult.conflictPaths,
617
+ ];
552
618
  emit({
553
619
  type: "complete",
554
620
  company: companyLabel,
@@ -558,10 +624,17 @@ export async function runRunner(
558
624
  bytesUploaded: pushResult.bytesUploaded,
559
625
  filesSkipped: pullResult.filesSkipped + pushResult.filesSkipped,
560
626
  conflicts: pullResult.conflicts,
627
+ conflictPaths: mergedConflictPaths,
561
628
  // Either phase aborting marks the company aborted — the UI treats
562
629
  // `aborted: true` as "sync didn't complete cleanly for this company".
563
630
  aborted: pullResult.aborted || pushResult.aborted,
564
631
  });
632
+ for (const p of pullResult.conflictPaths) {
633
+ allConflicts.push({ company: companyLabel, path: p, direction: "pull" });
634
+ }
635
+ for (const p of pushResult.conflictPaths) {
636
+ allConflicts.push({ company: companyLabel, path: p, direction: "push" });
637
+ }
565
638
  totalDownloaded += pullResult.filesDownloaded;
566
639
  totalDownloadedBytes += pullResult.bytesDownloaded;
567
640
  totalUploaded += pushResult.filesUploaded;
@@ -586,6 +659,7 @@ export async function runRunner(
586
659
  bytesDownloaded: totalDownloadedBytes,
587
660
  filesUploaded: totalUploaded,
588
661
  bytesUploaded: totalUploadedBytes,
662
+ conflictPaths: allConflicts,
589
663
  errors,
590
664
  });
591
665
  return 0;
@@ -276,6 +276,62 @@ describe("share", () => {
276
276
  expect(uploadFile).toHaveBeenCalledWith(expect.anything(), testFile, "changed.md");
277
277
  });
278
278
 
279
+ it("populates conflictPaths and emits a conflict event when remote drifted from journal", async () => {
280
+ const companyRoot = path.join(tmpDir, "companies", "acme");
281
+ fs.mkdirSync(companyRoot, { recursive: true });
282
+ const testFile = path.join(companyRoot, "drifted.md");
283
+ fs.writeFileSync(testFile, "local edit");
284
+
285
+ // Journal has a stale hash → local diverged. headRemoteFile returning
286
+ // non-null tells share() the remote also exists; combined with the
287
+ // hash mismatch this trips the conflict branch.
288
+ vi.mocked(headRemoteFile).mockResolvedValueOnce({
289
+ lastModified: new Date(),
290
+ etag: '"remote-changed"',
291
+ size: 99,
292
+ });
293
+
294
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
295
+ fs.writeFileSync(
296
+ journalPath,
297
+ JSON.stringify({
298
+ version: "1",
299
+ lastSync: new Date().toISOString(),
300
+ files: {
301
+ "drifted.md": {
302
+ hash: "stale-hash",
303
+ size: 10,
304
+ syncedAt: new Date().toISOString(),
305
+ direction: "up",
306
+ },
307
+ },
308
+ }),
309
+ );
310
+
311
+ const events: unknown[] = [];
312
+ const result = await share({
313
+ paths: [testFile],
314
+ company: "acme",
315
+ vaultConfig: mockConfig,
316
+ hqRoot: tmpDir,
317
+ onConflict: "keep",
318
+ onEvent: (e) => events.push(e),
319
+ });
320
+
321
+ expect(result.conflictPaths).toEqual(["drifted.md"]);
322
+ const conflicts = events.filter(
323
+ (e): e is { type: "conflict"; path: string; direction: "push"; resolution: string } =>
324
+ typeof e === "object" && e !== null && (e as { type?: string }).type === "conflict",
325
+ );
326
+ expect(conflicts).toHaveLength(1);
327
+ expect(conflicts[0]).toMatchObject({
328
+ type: "conflict",
329
+ path: "drifted.md",
330
+ direction: "push",
331
+ resolution: "keep",
332
+ });
333
+ });
334
+
279
335
  it("skipUnchanged=false (default) uploads even when hash matches", async () => {
280
336
  const companyRoot = path.join(tmpDir, "companies", "acme");
281
337
  fs.mkdirSync(companyRoot, { recursive: true });
package/src/cli/share.ts CHANGED
@@ -52,6 +52,12 @@ export interface ShareResult {
52
52
  filesUploaded: number;
53
53
  bytesUploaded: number;
54
54
  filesSkipped: number;
55
+ /**
56
+ * Paths (company-relative) that were detected as push conflicts. Mirrors
57
+ * `SyncResult.conflictPaths` so push and pull surface conflicts the same
58
+ * way to runner/UI consumers.
59
+ */
60
+ conflictPaths: string[];
55
61
  aborted: boolean;
56
62
  }
57
63
 
@@ -83,6 +89,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
83
89
  let filesUploaded = 0;
84
90
  let bytesUploaded = 0;
85
91
  let filesSkipped = 0;
92
+ const conflictPaths: string[] = [];
86
93
 
87
94
  // Collect all files to share
88
95
  const filesToShare = collectFiles(paths, hqRoot, syncRoot, shouldSync);
@@ -123,6 +130,8 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
123
130
 
124
131
  // If remote has changed since our last sync, it's a conflict
125
132
  if (journalEntry && journalEntry.hash !== localHash) {
133
+ conflictPaths.push(relativePath);
134
+
126
135
  // Local has changes — check if remote also changed
127
136
  const resolution = await resolveConflict(
128
137
  {
@@ -134,8 +143,21 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
134
143
  onConflict,
135
144
  );
136
145
 
146
+ emit({
147
+ type: "conflict",
148
+ path: relativePath,
149
+ direction: "push",
150
+ resolution,
151
+ });
152
+
137
153
  if (resolution === "abort") {
138
- return { filesUploaded, bytesUploaded, filesSkipped, aborted: true };
154
+ return {
155
+ filesUploaded,
156
+ bytesUploaded,
157
+ filesSkipped,
158
+ conflictPaths,
159
+ aborted: true,
160
+ };
139
161
  }
140
162
  if (resolution === "keep" || resolution === "skip") {
141
163
  filesSkipped++;
@@ -179,7 +201,13 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
179
201
 
180
202
  writeJournal(ctx.slug, journal);
181
203
 
182
- return { filesUploaded, bytesUploaded, filesSkipped, aborted: false };
204
+ return {
205
+ filesUploaded,
206
+ bytesUploaded,
207
+ filesSkipped,
208
+ conflictPaths,
209
+ aborted: false,
210
+ };
183
211
  }
184
212
 
185
213
  /**
@@ -193,6 +221,10 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
193
221
  } else {
194
222
  console.log(` ✓ ${event.path}`);
195
223
  }
224
+ } else if (event.type === "conflict") {
225
+ console.error(
226
+ ` ⚠ conflict (${event.direction}): ${event.path} — ${event.resolution}`,
227
+ );
196
228
  } else {
197
229
  console.error(` ✗ ${event.path} — ${event.message}`);
198
230
  }
@@ -176,10 +176,54 @@ describe("sync", () => {
176
176
  });
177
177
 
178
178
  expect(result.conflicts).toBe(1);
179
+ expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
179
180
  expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
180
181
  expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("local version");
181
182
  });
182
183
 
184
+ it("emits a conflict event with path + resolution on hash mismatch", async () => {
185
+ const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
186
+ fs.mkdirSync(companyDocs, { recursive: true });
187
+ fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
188
+
189
+ fs.writeFileSync(
190
+ journalPath,
191
+ JSON.stringify({
192
+ version: "1",
193
+ lastSync: new Date().toISOString(),
194
+ files: {
195
+ "docs/handoff.md": {
196
+ hash: "stale-hash",
197
+ size: 20,
198
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
199
+ direction: "down",
200
+ },
201
+ },
202
+ }),
203
+ );
204
+
205
+ const events: unknown[] = [];
206
+ await sync({
207
+ company: "acme",
208
+ onConflict: "keep",
209
+ vaultConfig: mockConfig,
210
+ hqRoot: tmpDir,
211
+ onEvent: (e) => events.push(e),
212
+ });
213
+
214
+ const conflicts = events.filter(
215
+ (e): e is { type: "conflict"; path: string; direction: "pull"; resolution: string } =>
216
+ typeof e === "object" && e !== null && (e as { type?: string }).type === "conflict",
217
+ );
218
+ expect(conflicts).toHaveLength(1);
219
+ expect(conflicts[0]).toMatchObject({
220
+ type: "conflict",
221
+ path: "docs/handoff.md",
222
+ direction: "pull",
223
+ resolution: "keep",
224
+ });
225
+ });
226
+
183
227
  it("aborts on --on-conflict abort", async () => {
184
228
  const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
185
229
  fs.mkdirSync(companyDocs, { recursive: true });
package/src/cli/sync.ts CHANGED
@@ -13,7 +13,7 @@ import { downloadFile, listRemoteFiles } from "../s3.js";
13
13
  import { readJournal, writeJournal, hashFile, updateEntry, getEntry } from "../journal.js";
14
14
  import { createIgnoreFilter } from "../ignore.js";
15
15
  import { resolveConflict } from "./conflict.js";
16
- import type { ConflictStrategy } from "./conflict.js";
16
+ import type { ConflictStrategy, ConflictResolution } from "./conflict.js";
17
17
 
18
18
  /**
19
19
  * Per-file events emitted by `sync()` as it progresses.
@@ -28,7 +28,13 @@ import type { ConflictStrategy } from "./conflict.js";
28
28
  */
29
29
  export type SyncProgressEvent =
30
30
  | { type: "progress"; path: string; bytes: number; message?: string }
31
- | { type: "error"; path: string; message: string };
31
+ | { type: "error"; path: string; message: string }
32
+ | {
33
+ type: "conflict";
34
+ path: string;
35
+ direction: "pull" | "push";
36
+ resolution: ConflictResolution;
37
+ };
32
38
 
33
39
  export interface SyncOptions {
34
40
  /** Company slug or UID (defaults to active company from config) */
@@ -66,6 +72,12 @@ export interface SyncResult {
66
72
  bytesDownloaded: number;
67
73
  filesSkipped: number;
68
74
  conflicts: number;
75
+ /**
76
+ * Paths (remote keys) that were detected as conflicts during this run.
77
+ * Always populated when `conflicts > 0` so callers can surface them in UI
78
+ * or logs without re-streaming the per-file events.
79
+ */
80
+ conflictPaths: string[];
69
81
  aborted: boolean;
70
82
  }
71
83
 
@@ -106,6 +118,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
106
118
  let bytesDownloaded = 0;
107
119
  let filesSkipped = 0;
108
120
  let conflicts = 0;
121
+ const conflictPaths: string[] = [];
109
122
 
110
123
  // List all remote files (IAM session policy filters at the AWS layer)
111
124
  const remoteFiles = await listRemoteFiles(ctx);
@@ -138,6 +151,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
138
151
  // If local file has changed since last sync, it's a conflict
139
152
  if (journalEntry && journalEntry.hash !== localHash) {
140
153
  conflicts++;
154
+ conflictPaths.push(remoteFile.key);
141
155
 
142
156
  const resolution = await resolveConflict(
143
157
  {
@@ -150,9 +164,23 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
150
164
  onConflict,
151
165
  );
152
166
 
167
+ emit({
168
+ type: "conflict",
169
+ path: remoteFile.key,
170
+ direction: "pull",
171
+ resolution,
172
+ });
173
+
153
174
  if (resolution === "abort") {
154
175
  writeJournal(journalSlug, journal);
155
- return { filesDownloaded, bytesDownloaded, filesSkipped, conflicts, aborted: true };
176
+ return {
177
+ filesDownloaded,
178
+ bytesDownloaded,
179
+ filesSkipped,
180
+ conflicts,
181
+ conflictPaths,
182
+ aborted: true,
183
+ };
156
184
  }
157
185
  if (resolution === "keep" || resolution === "skip") {
158
186
  filesSkipped++;
@@ -208,7 +236,14 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
208
236
 
209
237
  writeJournal(journalSlug, journal);
210
238
 
211
- return { filesDownloaded, bytesDownloaded, filesSkipped, conflicts, aborted: false };
239
+ return {
240
+ filesDownloaded,
241
+ bytesDownloaded,
242
+ filesSkipped,
243
+ conflicts,
244
+ conflictPaths,
245
+ aborted: false,
246
+ };
212
247
  }
213
248
 
214
249
  /**
@@ -251,5 +286,9 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
251
286
  }
252
287
  } else if (event.type === "error") {
253
288
  console.error(` ✗ ${event.path} — ${event.message}`);
289
+ } else if (event.type === "conflict") {
290
+ console.error(
291
+ ` ⚠ conflict (${event.direction}): ${event.path} — ${event.resolution}`,
292
+ );
254
293
  }
255
294
  }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Unit tests for createIgnoreFilter.
3
+ *
4
+ * Covers both modes: legacy permissive (no .hqinclude) and allowlist mode
5
+ * (.hqinclude present). The allowlist tests guard against accidentally
6
+ * leaking sensitive subtrees like data/ or workers/ to S3 — a regression
7
+ * here would silently push private content on the next sync.
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
11
+ import * as fs from "fs";
12
+ import * as os from "os";
13
+ import * as path from "path";
14
+ import { createIgnoreFilter } from "./ignore.js";
15
+
16
+ describe("createIgnoreFilter", () => {
17
+ let hqRoot: string;
18
+
19
+ beforeEach(() => {
20
+ hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-ignore-test-"));
21
+ });
22
+
23
+ afterEach(() => {
24
+ fs.rmSync(hqRoot, { recursive: true, force: true });
25
+ });
26
+
27
+ it("permissive mode: regular files sync, defaults are ignored", () => {
28
+ const shouldSync = createIgnoreFilter(hqRoot);
29
+ expect(shouldSync(path.join(hqRoot, "companies/indigo/notes.md"))).toBe(true);
30
+ expect(shouldSync(path.join(hqRoot, "node_modules/foo/x.js"))).toBe(false);
31
+ expect(shouldSync(path.join(hqRoot, ".env"))).toBe(false);
32
+ });
33
+
34
+ it("permissive mode: .hqignore patterns are honored", () => {
35
+ fs.writeFileSync(path.join(hqRoot, ".hqignore"), "companies/*/data/\n");
36
+ const shouldSync = createIgnoreFilter(hqRoot);
37
+ expect(shouldSync(path.join(hqRoot, "companies/indigo/data/x.csv"))).toBe(false);
38
+ expect(shouldSync(path.join(hqRoot, "companies/indigo/notes.md"))).toBe(true);
39
+ });
40
+
41
+ it("allowlist mode: presence of .hqinclude switches to opt-in", () => {
42
+ fs.writeFileSync(
43
+ path.join(hqRoot, ".hqinclude"),
44
+ "companies/*/knowledge/\ncompanies/*/projects/\n",
45
+ );
46
+ const shouldSync = createIgnoreFilter(hqRoot);
47
+ // Allowlisted paths sync.
48
+ expect(shouldSync(path.join(hqRoot, "companies/indigo/knowledge/foo.md"))).toBe(true);
49
+ expect(shouldSync(path.join(hqRoot, "companies/indigo/projects/p1/prd.json"))).toBe(true);
50
+ // Anything else stays local — this is the privacy guarantee.
51
+ expect(shouldSync(path.join(hqRoot, "companies/indigo/data/leads.csv"))).toBe(false);
52
+ expect(shouldSync(path.join(hqRoot, "companies/indigo/workers/cmo/skill.md"))).toBe(false);
53
+ expect(shouldSync(path.join(hqRoot, "companies/indigo/settings/aws.json"))).toBe(false);
54
+ expect(shouldSync(path.join(hqRoot, "personal/journal/2026-04-26.md"))).toBe(false);
55
+ });
56
+
57
+ it("allowlist mode: exclusion layers still subtract on top", () => {
58
+ // Even when a subtree is allowlisted, default ignores like node_modules/
59
+ // and .env must still apply. Otherwise an allowlisted subdir would sync
60
+ // gigabytes of dependency junk or leak secret env files.
61
+ fs.writeFileSync(path.join(hqRoot, ".hqinclude"), "companies/*/projects/\n");
62
+ const shouldSync = createIgnoreFilter(hqRoot);
63
+ expect(
64
+ shouldSync(path.join(hqRoot, "companies/indigo/projects/p1/prd.json")),
65
+ ).toBe(true);
66
+ expect(
67
+ shouldSync(path.join(hqRoot, "companies/indigo/projects/p1/node_modules/react/index.js")),
68
+ ).toBe(false);
69
+ expect(shouldSync(path.join(hqRoot, "companies/indigo/projects/p1/.env"))).toBe(false);
70
+ });
71
+ });
package/src/ignore.ts CHANGED
@@ -1,17 +1,23 @@
1
1
  /**
2
2
  * Ignore-file parser for cloud sync.
3
3
  *
4
- * Three layers, evaluated in order (later patterns override earlier ones):
5
- * 1. Built-in defaults things that should *never* sync (VCS, node_modules,
6
- * build artifacts, caches, env files). Cover the common stacks so that a
7
- * first-time sync over a random project folder doesn't try to push
8
- * `target/`, `node_modules/`, or `.next/` to S3.
9
- * 2. Repo `.gitignore` at hqRoot reuses the user's existing exclusions so
10
- * we don't re-list every build directory ourselves. Root-level only; we
11
- * do not recurse like real git.
12
- * 3. `.hqignore` (preferred) or `.hqsyncignore` (legacy name) at hqRoot —
13
- * sync-specific overrides. Use `!pattern` to re-include something an
14
- * earlier layer excluded.
4
+ * Two modes:
5
+ * - **Permissive (default)**: everything syncs except what ignore layers
6
+ * subtract. Three layers stack (later overrides earlier):
7
+ * 1. Built-in defaults VCS, node_modules, build artifacts, caches,
8
+ * env files. Covers the common stacks so a first-time sync over a
9
+ * random project folder doesn't push `target/` or `.next/` to S3.
10
+ * 2. Repo `.gitignore` at hqRoot reuses existing exclusions so we
11
+ * don't re-list every build directory. Root-level only.
12
+ * 3. `.hqignore` (preferred) or `.hqsyncignore` (legacy) sync-specific
13
+ * overrides. Use `!pattern` to re-include something earlier layers
14
+ * excluded.
15
+ *
16
+ * - **Allowlist**: triggered when `.hqinclude` exists at hqRoot. Nothing
17
+ * syncs unless its path matches at least one pattern in `.hqinclude`. The
18
+ * three exclusion layers still subtract on top — so even allowlisted
19
+ * subtrees won't push `node_modules/` or `.env`. Privacy-by-default for
20
+ * HQ trees that contain mixed personal + shareable data.
15
21
  */
16
22
 
17
23
  import * as fs from "fs";
@@ -102,10 +108,21 @@ export function createIgnoreFilter(hqRoot: string): (filePath: string) => boolea
102
108
  readIgnoreFile(path.join(hqRoot, ".hqsyncignore"));
103
109
  if (hqignore) ig.add(hqignore);
104
110
 
111
+ // Allowlist mode: when `.hqinclude` exists, sync is opt-in. The matcher
112
+ // here treats include patterns as ignore patterns and inverts the verdict —
113
+ // a path is "allowed" iff its relative path matches at least one entry.
114
+ // Exclusion layers above still subtract, so build artifacts inside an
115
+ // allowlisted subtree (e.g. node_modules/ inside companies/x/repos/y/) are
116
+ // still skipped.
117
+ const hqinclude = readIgnoreFile(path.join(hqRoot, ".hqinclude"));
118
+ const includeMatcher = hqinclude ? ignore().add(hqinclude) : null;
119
+
105
120
  return (filePath: string): boolean => {
106
121
  const relative = path.relative(hqRoot, filePath);
107
122
  if (!relative || relative.startsWith("..")) return true; // outside HQ root
108
- return !ig.ignores(relative);
123
+ if (ig.ignores(relative)) return false;
124
+ if (includeMatcher && !includeMatcher.ignores(relative)) return false;
125
+ return true;
109
126
  };
110
127
  }
111
128