@indigoai-us/hq-cloud 5.14.0 → 5.16.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.
@@ -448,6 +448,249 @@ describe("sync", () => {
448
448
  });
449
449
  });
450
450
 
451
+ // ── New-file detection (US-001) ──────────────────────────────────────
452
+
453
+ it("classifies all files as new on first sync (clean directory)", async () => {
454
+ const result = await sync({
455
+ company: "acme",
456
+ vaultConfig: mockConfig,
457
+ hqRoot: tmpDir,
458
+ });
459
+
460
+ expect(result.newFilesCount).toBe(2);
461
+ expect(result.newFiles).toEqual(
462
+ expect.arrayContaining([
463
+ { path: "docs/handoff.md", bytes: 42 },
464
+ { path: "knowledge/readme.md", bytes: 100 },
465
+ ]),
466
+ );
467
+ expect(result.newFiles).toHaveLength(2);
468
+ });
469
+
470
+ it("classifies only genuinely new files when some exist locally", async () => {
471
+ // Pre-create one file + journal entry so it's treated as an update,
472
+ // while the second file is genuinely new.
473
+ const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
474
+ fs.mkdirSync(companyDocs, { recursive: true });
475
+ fs.writeFileSync(path.join(companyDocs, "handoff.md"), "mock file content");
476
+
477
+ // Journal entry with matching remoteEtag but stale hash to force download
478
+ // (remote-only changed scenario).
479
+ fs.writeFileSync(
480
+ journalPath,
481
+ JSON.stringify({
482
+ version: "1",
483
+ lastSync: new Date().toISOString(),
484
+ files: {
485
+ "docs/handoff.md": {
486
+ hash: "old-hash-before-remote-update",
487
+ size: 20,
488
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
489
+ direction: "down",
490
+ // Stale etag — forces remoteChanged=true, localChanged=false
491
+ // (local hash matches what's on disk because we didn't edit it,
492
+ // but wait — journal hash != current disk hash because we wrote
493
+ // "mock file content" but stored "old-hash-before-remote-update".
494
+ // To avoid conflict we need localChanged=false. Let's compute
495
+ // the real hash and use it.)
496
+ },
497
+ },
498
+ }),
499
+ );
500
+
501
+ // Re-read the journal to compute the actual hash of the file we wrote,
502
+ // then update the journal so localChanged is false.
503
+ const { hashFile: realHashFile } = await import("../journal.js");
504
+ const actualHash = realHashFile(path.join(companyDocs, "handoff.md"));
505
+ fs.writeFileSync(
506
+ journalPath,
507
+ JSON.stringify({
508
+ version: "1",
509
+ lastSync: new Date().toISOString(),
510
+ files: {
511
+ "docs/handoff.md": {
512
+ hash: actualHash,
513
+ size: 17,
514
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
515
+ direction: "down",
516
+ remoteEtag: "stale-etag-to-force-download",
517
+ },
518
+ },
519
+ }),
520
+ );
521
+
522
+ const result = await sync({
523
+ company: "acme",
524
+ onConflict: "overwrite",
525
+ vaultConfig: mockConfig,
526
+ hqRoot: tmpDir,
527
+ });
528
+
529
+ // handoff.md existed locally → isNew: false (update)
530
+ // readme.md did not exist → isNew: true (new)
531
+ expect(result.newFilesCount).toBe(1);
532
+ expect(result.newFiles).toEqual([
533
+ { path: "knowledge/readme.md", bytes: 100 },
534
+ ]);
535
+ });
536
+
537
+ it("returns empty newFiles when all files already exist", async () => {
538
+ // First sync downloads everything
539
+ await sync({
540
+ company: "acme",
541
+ vaultConfig: mockConfig,
542
+ hqRoot: tmpDir,
543
+ });
544
+
545
+ // Second sync — files are now local and journal is up to date.
546
+ // Nothing downloads, newFiles should be empty.
547
+ const result = await sync({
548
+ company: "acme",
549
+ vaultConfig: mockConfig,
550
+ hqRoot: tmpDir,
551
+ });
552
+
553
+ expect(result.newFilesCount).toBe(0);
554
+ expect(result.newFiles).toEqual([]);
555
+ });
556
+
557
+ // ── New-files event with attribution (US-002) ───────────────────────
558
+
559
+ it("emits a new-files event with attribution from HeadObject metadata", async () => {
560
+ vi.mocked(s3Module.headRemoteFile).mockImplementation(async (_ctx, key) => {
561
+ if (key === "docs/handoff.md") {
562
+ return {
563
+ lastModified: new Date(),
564
+ etag: '"abc123"',
565
+ size: 42,
566
+ metadata: { "created-by": "alice@example.com" },
567
+ };
568
+ }
569
+ if (key === "knowledge/readme.md") {
570
+ return {
571
+ lastModified: new Date(),
572
+ etag: '"def456"',
573
+ size: 100,
574
+ metadata: { "created-by": "bob@example.com" },
575
+ };
576
+ }
577
+ return null;
578
+ });
579
+
580
+ const newFilesEvents: Array<{
581
+ type: string;
582
+ files: Array<{ path: string; bytes: number; addedBy: string | null }>;
583
+ }> = [];
584
+ await sync({
585
+ company: "acme",
586
+ vaultConfig: mockConfig,
587
+ hqRoot: tmpDir,
588
+ onEvent: (e) => {
589
+ if (e.type === "new-files") newFilesEvents.push(e);
590
+ },
591
+ });
592
+
593
+ expect(newFilesEvents).toHaveLength(1);
594
+ expect(newFilesEvents[0].files).toHaveLength(2);
595
+ expect(newFilesEvents[0].files).toEqual(
596
+ expect.arrayContaining([
597
+ { path: "docs/handoff.md", bytes: 42, addedBy: "alice@example.com" },
598
+ { path: "knowledge/readme.md", bytes: 100, addedBy: "bob@example.com" },
599
+ ]),
600
+ );
601
+ });
602
+
603
+ it("sets addedBy to null when HeadObject fails (best-effort)", async () => {
604
+ vi.mocked(s3Module.headRemoteFile).mockRejectedValue(new Error("S3 transient error"));
605
+
606
+ const newFilesEvents: Array<{
607
+ type: string;
608
+ files: Array<{ path: string; bytes: number; addedBy: string | null }>;
609
+ }> = [];
610
+ await sync({
611
+ company: "acme",
612
+ vaultConfig: mockConfig,
613
+ hqRoot: tmpDir,
614
+ onEvent: (e) => {
615
+ if (e.type === "new-files") newFilesEvents.push(e);
616
+ },
617
+ });
618
+
619
+ expect(newFilesEvents).toHaveLength(1);
620
+ // Both files should still appear, just with null addedBy
621
+ expect(newFilesEvents[0].files).toHaveLength(2);
622
+ for (const f of newFilesEvents[0].files) {
623
+ expect(f.addedBy).toBeNull();
624
+ }
625
+ });
626
+
627
+ it("sets addedBy to null when metadata has no created-by key", async () => {
628
+ vi.mocked(s3Module.headRemoteFile).mockResolvedValue({
629
+ lastModified: new Date(),
630
+ etag: '"abc"',
631
+ size: 10,
632
+ metadata: {},
633
+ });
634
+
635
+ const newFilesEvents: Array<{
636
+ type: string;
637
+ files: Array<{ path: string; bytes: number; addedBy: string | null }>;
638
+ }> = [];
639
+ await sync({
640
+ company: "acme",
641
+ vaultConfig: mockConfig,
642
+ hqRoot: tmpDir,
643
+ onEvent: (e) => {
644
+ if (e.type === "new-files") newFilesEvents.push(e);
645
+ },
646
+ });
647
+
648
+ expect(newFilesEvents).toHaveLength(1);
649
+ for (const f of newFilesEvents[0].files) {
650
+ expect(f.addedBy).toBeNull();
651
+ }
652
+ });
653
+
654
+ it("emits new-files event with empty array when no new files exist", async () => {
655
+ // First sync downloads everything
656
+ await sync({
657
+ company: "acme",
658
+ vaultConfig: mockConfig,
659
+ hqRoot: tmpDir,
660
+ });
661
+
662
+ // Second sync — files already exist locally
663
+ const newFilesEvents: Array<{
664
+ type: string;
665
+ files: Array<{ path: string; bytes: number; addedBy: string | null }>;
666
+ }> = [];
667
+ await sync({
668
+ company: "acme",
669
+ vaultConfig: mockConfig,
670
+ hqRoot: tmpDir,
671
+ onEvent: (e) => {
672
+ if (e.type === "new-files") newFilesEvents.push(e);
673
+ },
674
+ });
675
+
676
+ expect(newFilesEvents).toHaveLength(1);
677
+ expect(newFilesEvents[0].files).toEqual([]);
678
+ });
679
+
680
+ it("new-files event is emitted after all progress events", async () => {
681
+ const eventTypes: string[] = [];
682
+ await sync({
683
+ company: "acme",
684
+ vaultConfig: mockConfig,
685
+ hqRoot: tmpDir,
686
+ onEvent: (e) => eventTypes.push(e.type),
687
+ });
688
+
689
+ const lastProgressIdx = eventTypes.lastIndexOf("progress");
690
+ const newFilesIdx = eventTypes.indexOf("new-files");
691
+ expect(newFilesIdx).toBeGreaterThan(lastProgressIdx);
692
+ });
693
+
451
694
  it("plan event counts a 3-way conflict separately from downloads", async () => {
452
695
  // Local edit + journal-tracked + remote ETag drifted → conflict.
453
696
  const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
package/src/cli/sync.ts CHANGED
@@ -9,7 +9,7 @@ import * as fs from "fs";
9
9
  import * as path from "path";
10
10
  import type { VaultServiceConfig, SyncJournal } from "../types.js";
11
11
  import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
12
- import { downloadFile, listRemoteFiles } from "../s3.js";
12
+ import { downloadFile, listRemoteFiles, headRemoteFile } from "../s3.js";
13
13
  import type { RemoteFile } from "../s3.js";
14
14
  import { readJournal, writeJournal, hashFile, updateEntry, getEntry, normalizeEtag } from "../journal.js";
15
15
  import { createIgnoreFilter } from "../ignore.js";
@@ -80,6 +80,10 @@ export type SyncProgressEvent =
80
80
  path: string;
81
81
  direction: "pull" | "push";
82
82
  resolution: ConflictResolution;
83
+ }
84
+ | {
85
+ type: "new-files";
86
+ files: Array<{ path: string; bytes: number; addedBy: string | null }>;
83
87
  };
84
88
 
85
89
  export interface SyncOptions {
@@ -125,6 +129,14 @@ export interface SyncResult {
125
129
  */
126
130
  conflictPaths: string[];
127
131
  aborted: boolean;
132
+ /**
133
+ * Files classified as "new" during pull — i.e. the remote file had no
134
+ * local counterpart at classification time. Additive field; empty array
135
+ * when no new files were detected or on push-only syncs.
136
+ */
137
+ newFiles: Array<{ path: string; bytes: number }>;
138
+ /** Convenience count: `newFiles.length`. */
139
+ newFilesCount: number;
128
140
  }
129
141
 
130
142
  /**
@@ -276,6 +288,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
276
288
  }
277
289
 
278
290
  if (resolution === "abort") {
291
+ emit({ type: "new-files", files: [] });
279
292
  writeJournal(journalSlug, journal);
280
293
  return {
281
294
  filesDownloaded,
@@ -284,6 +297,8 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
284
297
  conflicts,
285
298
  conflictPaths,
286
299
  aborted: true,
300
+ newFiles: plan.newFiles,
301
+ newFilesCount: plan.newFilesCount,
287
302
  };
288
303
  }
289
304
  if (resolution === "keep" || resolution === "skip") {
@@ -352,6 +367,41 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
352
367
  }
353
368
  }
354
369
 
370
+ // ── New-files attribution (US-002) ─────────────────────────────────────
371
+ // Enrich plan.newFiles with `addedBy` from S3 user metadata. HeadObject
372
+ // calls are best-effort and capped at 5 concurrent to avoid hammering S3.
373
+ const enrichedNewFiles: Array<{ path: string; bytes: number; addedBy: string | null }> = [];
374
+ const HEAD_CONCURRENCY = 5;
375
+ for (let i = 0; i < plan.newFiles.length; i += HEAD_CONCURRENCY) {
376
+ const batch = plan.newFiles.slice(i, i + HEAD_CONCURRENCY);
377
+ const results = await Promise.all(
378
+ batch.map(async (nf) => {
379
+ let addedBy: string | null = null;
380
+ try {
381
+ const head = await headRemoteFile(ctx, nf.path);
382
+ if (head?.metadata?.["created-by"]) {
383
+ addedBy = head.metadata["created-by"];
384
+ }
385
+ } catch (headErr) {
386
+ // Best-effort: log to console (Sentry captures via global handler)
387
+ // and fall through with addedBy = null.
388
+ try {
389
+ console.error(
390
+ `[hq-sync] HeadObject failed for ${nf.path}: ${
391
+ headErr instanceof Error ? headErr.message : String(headErr)
392
+ }`,
393
+ );
394
+ } catch {
395
+ // Swallow — logging must never break sync.
396
+ }
397
+ }
398
+ return { path: nf.path, bytes: nf.bytes, addedBy };
399
+ }),
400
+ );
401
+ enrichedNewFiles.push(...results);
402
+ }
403
+ emit({ type: "new-files", files: enrichedNewFiles });
404
+
355
405
  // Stamp lastSync on every successful run so the menubar's "Last sync · X ago"
356
406
  // ticks even when nothing transferred. updateEntry only fires on actual
357
407
  // downloads; without this, a no-op sync leaves lastSync at the time of the
@@ -366,6 +416,8 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
366
416
  conflicts,
367
417
  conflictPaths,
368
418
  aborted: false,
419
+ newFiles: plan.newFiles,
420
+ newFilesCount: plan.newFilesCount,
369
421
  };
370
422
  }
371
423
 
@@ -409,7 +461,7 @@ function hasRemoteChanged(
409
461
  * executor can hand it to `resolveConflict` without re-hashing.
410
462
  */
411
463
  type PullPlanItem =
412
- | { action: "download"; remoteFile: RemoteFile; localPath: string }
464
+ | { action: "download"; remoteFile: RemoteFile; localPath: string; isNew: boolean }
413
465
  | { action: "skip-ignored"; remoteFile: RemoteFile; localPath: string }
414
466
  | { action: "skip-personal-mode"; remoteFile: RemoteFile; localPath: string }
415
467
  | { action: "skip-unchanged"; remoteFile: RemoteFile; localPath: string }
@@ -427,6 +479,9 @@ interface PullPlan {
427
479
  bytesToDownload: number;
428
480
  filesToSkip: number;
429
481
  filesToConflict: number;
482
+ /** Files classified as new (no local counterpart at classification time). */
483
+ newFiles: Array<{ path: string; bytes: number }>;
484
+ newFilesCount: number;
430
485
  }
431
486
 
432
487
  /**
@@ -463,8 +518,9 @@ function computePullPlan(
463
518
  }
464
519
 
465
520
  const journalEntry = getEntry(journal, remoteFile.key);
521
+ const localExists = fs.existsSync(localPath);
466
522
 
467
- if (fs.existsSync(localPath)) {
523
+ if (localExists) {
468
524
  const localHash = hashFile(localPath);
469
525
  const localChanged = !!journalEntry && journalEntry.hash !== localHash;
470
526
  const remoteChanged =
@@ -493,17 +549,21 @@ function computePullPlan(
493
549
  // No journal entry, or remote-only changed → fall through to download.
494
550
  }
495
551
 
496
- items.push({ action: "download", remoteFile, localPath });
552
+ items.push({ action: "download", remoteFile, localPath, isNew: !localExists });
497
553
  }
498
554
 
499
555
  let filesToDownload = 0;
500
556
  let bytesToDownload = 0;
501
557
  let filesToSkip = 0;
502
558
  let filesToConflict = 0;
559
+ const newFiles: Array<{ path: string; bytes: number }> = [];
503
560
  for (const item of items) {
504
561
  if (item.action === "download") {
505
562
  filesToDownload++;
506
563
  bytesToDownload += item.remoteFile.size;
564
+ if (item.isNew) {
565
+ newFiles.push({ path: item.remoteFile.key, bytes: item.remoteFile.size });
566
+ }
507
567
  } else if (item.action === "conflict") {
508
568
  filesToConflict++;
509
569
  } else {
@@ -517,6 +577,8 @@ function computePullPlan(
517
577
  bytesToDownload,
518
578
  filesToSkip,
519
579
  filesToConflict,
580
+ newFiles,
581
+ newFilesCount: newFiles.length,
520
582
  };
521
583
  }
522
584
 
@@ -567,5 +629,18 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
567
629
  console.error(
568
630
  ` ⚠ conflict (${event.direction}): ${event.path} — ${event.resolution}`,
569
631
  );
632
+ } else if (event.type === "new-files") {
633
+ if (event.files.length > 0) {
634
+ console.log(`${event.files.length} new file${event.files.length === 1 ? "" : "s"}`);
635
+ const MAX_SHOWN = 20;
636
+ const shown = event.files.slice(0, MAX_SHOWN);
637
+ for (const f of shown) {
638
+ const who = f.addedBy ? ` (added by ${f.addedBy})` : "";
639
+ console.log(` + ${f.path}${who}`);
640
+ }
641
+ if (event.files.length > MAX_SHOWN) {
642
+ console.log(` ... and ${event.files.length - MAX_SHOWN} more`);
643
+ }
644
+ }
570
645
  }
571
646
  }
package/src/index.ts CHANGED
@@ -50,8 +50,14 @@ export {
50
50
  } from "./cognito-auth.js";
51
51
  export type { CognitoAuthConfig, CognitoTokens } from "./cognito-auth.js";
52
52
 
53
+ // Personal-vault scope helpers — shared between hq-sync-runner and `hq sync`
54
+ export {
55
+ PERSONAL_VAULT_EXCLUDED_TOP_LEVEL,
56
+ computePersonalVaultPaths,
57
+ } from "./personal-vault.js";
58
+
53
59
  // VaultClient SDK (VLT-7)
54
- export { VaultClient } from "./vault-client.js";
60
+ export { VaultClient, pickCanonicalPersonEntity } from "./vault-client.js";
55
61
  export {
56
62
  VaultClientError,
57
63
  VaultAuthError,
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Personal-vault scope helpers — shared between the menubar runner
3
+ * (`hq-sync-runner`) and the `hq sync` CLI so every composer of a personal
4
+ * push uses the same exclusion list.
5
+ *
6
+ * The exclusion list mirrors the Rust constant of the same name in
7
+ * `hq-sync/src-tauri/src/commands/personal.rs` so the Tauri menubar's
8
+ * first-push and this Node engine's steady-state push enforce identical
9
+ * scope. Every other top-level entry under hq_root (e.g. `.claude/`,
10
+ * `knowledge/`, `modules/`, `README.md`, `.codex/`, `core/`, `data/`,
11
+ * `personal/`) is included, subject to the usual `.hqignore` filter.
12
+ *
13
+ * Excluded entries (and why):
14
+ * - `.git`: a git repo's own metadata is hostile to multi-machine
15
+ * sync; .gitignore alone doesn't cover `.git/` because it's the repo
16
+ * itself, not a tracked path.
17
+ * - `companies/`: synced separately by the runner's per-membership
18
+ * fanout; do not double-write into the personal vault.
19
+ * - `repos/`, `workspace/`: per user directive — heavy local-only
20
+ * content (cloned remotes, session threads) that has no business in
21
+ * the personal vault.
22
+ *
23
+ * Note: `core/`, `data/`, and `personal/` were previously excluded but are
24
+ * INCLUDED as of user directive 2026-05-13. `core/` ships the hq-core
25
+ * scaffold — policies/, settings/, skills/, workers/, plus the rules
26
+ * manifest at core/core.yaml. `data/` and `personal/` carry per-user data,
27
+ * policies, hooks, and skills that follow the user across machines. The
28
+ * hq-root identity marker `core.yaml` (at hq_root, distinct from
29
+ * `core/core.yaml`) is filtered separately by the root-anchored
30
+ * `/core.yaml` DEFAULT_IGNORES rule.
31
+ */
32
+
33
+ import * as fs from "fs";
34
+ import * as path from "path";
35
+
36
+ export const PERSONAL_VAULT_EXCLUDED_TOP_LEVEL: readonly string[] = [
37
+ ".git",
38
+ "companies",
39
+ "repos",
40
+ "workspace",
41
+ ];
42
+
43
+ /**
44
+ * Compute absolute paths to share for the personal vault: every top-level
45
+ * entry under `hqRoot` whose basename is NOT in
46
+ * `PERSONAL_VAULT_EXCLUDED_TOP_LEVEL`. Mirrors the Rust
47
+ * `is_personal_vault_path` predicate (just hoisted to the top-level step).
48
+ * Order is whatever `fs.readdirSync` returns — share() doesn't care, and
49
+ * the per-file walk inside share() handles recursion uniformly. Missing
50
+ * hqRoot returns []; callers treat that as "no personal content to push"
51
+ * rather than a hard error.
52
+ */
53
+ export function computePersonalVaultPaths(hqRoot: string): string[] {
54
+ let entries: string[];
55
+ try {
56
+ entries = fs.readdirSync(hqRoot);
57
+ } catch {
58
+ return [];
59
+ }
60
+ return entries
61
+ .filter((name) => !PERSONAL_VAULT_EXCLUDED_TOP_LEVEL.includes(name))
62
+ .map((name) => path.join(hqRoot, name));
63
+ }
package/src/s3.ts CHANGED
@@ -214,7 +214,7 @@ export async function deleteRemoteFile(
214
214
  export async function headRemoteFile(
215
215
  ctx: EntityContext,
216
216
  key: string,
217
- ): Promise<{ lastModified: Date; etag: string; size: number } | null> {
217
+ ): Promise<{ lastModified: Date; etag: string; size: number; metadata?: Record<string, string> } | null> {
218
218
  const client = buildClient(ctx);
219
219
  try {
220
220
  const response = await client.send(
@@ -227,6 +227,7 @@ export async function headRemoteFile(
227
227
  lastModified: response.LastModified || new Date(),
228
228
  etag: response.ETag || "",
229
229
  size: response.ContentLength || 0,
230
+ metadata: response.Metadata,
230
231
  };
231
232
  } catch (err: unknown) {
232
233
  if (err && typeof err === "object" && "name" in err && err.name === "NotFound") {