@indigoai-us/hq-cloud 6.12.1 → 6.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  2. package/dist/bin/sync-runner-company.js +10 -0
  3. package/dist/bin/sync-runner-company.js.map +1 -1
  4. package/dist/bin/sync-runner-events.d.ts +18 -0
  5. package/dist/bin/sync-runner-events.d.ts.map +1 -1
  6. package/dist/bin/sync-runner-events.js +26 -0
  7. package/dist/bin/sync-runner-events.js.map +1 -1
  8. package/dist/bin/sync-runner-events.test.d.ts +2 -0
  9. package/dist/bin/sync-runner-events.test.d.ts.map +1 -0
  10. package/dist/bin/sync-runner-events.test.js +90 -0
  11. package/dist/bin/sync-runner-events.test.js.map +1 -0
  12. package/dist/bin/sync-runner.d.ts +6 -0
  13. package/dist/bin/sync-runner.d.ts.map +1 -1
  14. package/dist/bin/sync-runner.js +10 -2
  15. package/dist/bin/sync-runner.js.map +1 -1
  16. package/dist/bin/sync-runner.test.js +1 -0
  17. package/dist/bin/sync-runner.test.js.map +1 -1
  18. package/dist/cli/reindex.d.ts.map +1 -1
  19. package/dist/cli/reindex.js +25 -2
  20. package/dist/cli/reindex.js.map +1 -1
  21. package/dist/cli/reindex.test.js +51 -1
  22. package/dist/cli/reindex.test.js.map +1 -1
  23. package/dist/cli/share.d.ts +16 -0
  24. package/dist/cli/share.d.ts.map +1 -1
  25. package/dist/cli/share.js +62 -3
  26. package/dist/cli/share.js.map +1 -1
  27. package/dist/cli/share.test.js +101 -0
  28. package/dist/cli/share.test.js.map +1 -1
  29. package/dist/cli/sync.d.ts +15 -0
  30. package/dist/cli/sync.d.ts.map +1 -1
  31. package/dist/cli/sync.js +9 -0
  32. package/dist/cli/sync.js.map +1 -1
  33. package/dist/ignore.d.ts +14 -0
  34. package/dist/ignore.d.ts.map +1 -1
  35. package/dist/ignore.js +66 -0
  36. package/dist/ignore.js.map +1 -1
  37. package/dist/ignore.test.js +43 -1
  38. package/dist/ignore.test.js.map +1 -1
  39. package/dist/object-io.d.ts.map +1 -1
  40. package/dist/object-io.js +15 -0
  41. package/dist/object-io.js.map +1 -1
  42. package/dist/object-io.test.js +52 -0
  43. package/dist/object-io.test.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/bin/sync-runner-company.ts +9 -0
  46. package/src/bin/sync-runner-events.test.ts +112 -0
  47. package/src/bin/sync-runner-events.ts +30 -0
  48. package/src/bin/sync-runner.test.ts +1 -0
  49. package/src/bin/sync-runner.ts +14 -4
  50. package/src/cli/reindex.test.ts +68 -1
  51. package/src/cli/reindex.ts +27 -2
  52. package/src/cli/share.test.ts +120 -0
  53. package/src/cli/share.ts +81 -3
  54. package/src/cli/sync.ts +26 -0
  55. package/src/ignore.test.ts +54 -1
  56. package/src/ignore.ts +73 -0
  57. package/src/object-io.test.ts +54 -0
  58. package/src/object-io.ts +18 -0
@@ -24,6 +24,11 @@ const hoisted = vi.hoisted(() => ({
24
24
  // lets a test simulate a mkdir failure at a NON-wrapper site (e.g. the
25
25
  // `.claude/skills` parent or a `core/<type>` mirror dir).
26
26
  failMkdirContaining: "" as string,
27
+ // HQ-CLI-4: when set, any symlink whose LINK path contains this substring
28
+ // throws EPERM — simulates Windows WITHOUT Developer Mode, where symlink
29
+ // creation is a privileged operation. Off by default so other tests see the
30
+ // real `symlinkSync`.
31
+ failSymlinkContaining: "" as string,
27
32
  }));
28
33
  vi.mock("fs", async (importOriginal) => {
29
34
  const actual = await importOriginal<typeof import("fs")>();
@@ -40,7 +45,24 @@ vi.mock("fs", async (importOriginal) => {
40
45
  }
41
46
  return (actual.mkdirSync as (p: unknown, opts: unknown) => unknown)(p, opts);
42
47
  }) as typeof actual.mkdirSync;
43
- const patched = { ...actual, mkdirSync };
48
+ const symlinkSync = ((target: unknown, linkPath: unknown, type?: unknown) => {
49
+ if (
50
+ hoisted.failSymlinkContaining !== "" &&
51
+ typeof linkPath === "string" &&
52
+ linkPath.includes(hoisted.failSymlinkContaining)
53
+ ) {
54
+ throw Object.assign(
55
+ new Error(`EPERM: operation not permitted, symlink '${target}' -> '${linkPath}'`),
56
+ { code: "EPERM" },
57
+ );
58
+ }
59
+ return (actual.symlinkSync as (t: unknown, l: unknown, ty?: unknown) => unknown)(
60
+ target,
61
+ linkPath,
62
+ type,
63
+ );
64
+ }) as typeof actual.symlinkSync;
65
+ const patched = { ...actual, mkdirSync, symlinkSync };
44
66
  return { ...patched, default: patched };
45
67
  });
46
68
 
@@ -307,4 +329,49 @@ describe("reindex", () => {
307
329
  // The mirror for the failed type is skipped; skill surfacing still happened.
308
330
  expect(fs.existsSync(path.join(root, ".claude/skills/core:demo"))).toBe(true);
309
331
  });
332
+
333
+ // ── HQ-CLI-4: symlink creation is privileged on Windows. Without Developer
334
+ // Mode, fs.symlinkSync throws EPERM. The HQ-B0 work guarded every mkdir
335
+ // site but left the symlink sites unguarded, so `hq reindex` still aborted
336
+ // on Windows. safeSymlink must degrade gracefully like safeMkdir.
337
+ it("does not abort reindex when a skill-wrapper symlink fails with EPERM (Windows no Developer Mode)", () => {
338
+ writeSkill("core/skills/demo");
339
+
340
+ hoisted.failSymlinkContaining = path.join(".claude", "skills");
341
+ try {
342
+ // Every per-file wrapper symlink throws EPERM, but reindex must still
343
+ // complete cleanly (exit 0) rather than crash + flood Sentry.
344
+ expect(reindex({ repoRoot: root }).status).toBe(0);
345
+ } finally {
346
+ hoisted.failSymlinkContaining = "";
347
+ }
348
+
349
+ // The wrapper DIR was still created; the symlink inside was just skipped.
350
+ expect(fs.existsSync(path.join(root, ".claude/skills/core:demo"))).toBe(true);
351
+ expect(
352
+ fs.existsSync(path.join(root, ".claude/skills/core:demo/SKILL.md")),
353
+ ).toBe(false);
354
+ });
355
+
356
+ it("does not abort reindex when a personal-mirror symlink fails with EPERM, and other work still runs", () => {
357
+ writeSkill("core/skills/demo");
358
+ fs.mkdirSync(path.join(root, "personal/knowledge/my-kb"), { recursive: true });
359
+ fs.writeFileSync(path.join(root, "personal/knowledge/my-kb/note.md"), "n\n");
360
+
361
+ // Fail ONLY the core/<type> personal-mirror symlinks; wrapper symlinks
362
+ // succeed, proving the skip is scoped and the reindex completes.
363
+ hoisted.failSymlinkContaining = path.join("core", "knowledge");
364
+ try {
365
+ expect(reindex({ repoRoot: root }).status).toBe(0);
366
+ } finally {
367
+ hoisted.failSymlinkContaining = "";
368
+ }
369
+
370
+ // The personal mirror was skipped (EPERM)...
371
+ expect(fs.existsSync(path.join(root, "core/knowledge/my-kb"))).toBe(false);
372
+ // ...but unaffected work still happened: the skill wrapper + its symlink.
373
+ expect(
374
+ fs.lstatSync(path.join(root, ".claude/skills/core:demo/SKILL.md")).isSymbolicLink(),
375
+ ).toBe(true);
376
+ });
310
377
  });
@@ -154,6 +154,31 @@ function safeMkdir(dir: string, label: string): boolean {
154
154
  }
155
155
  }
156
156
 
157
+ // Create a symlink, tolerating platform-specific failures instead of aborting
158
+ // the whole reindex — the symlink sibling of safeMkdir. The classic case
159
+ // (HQ-CLI-4) is Windows WITHOUT Developer Mode (or an elevated shell), where
160
+ // symlink creation is a privileged operation and `fs.symlinkSync` throws EPERM
161
+ // (Win32 ERROR_PRIVILEGE_NOT_HELD). A single symlink we can't create should
162
+ // degrade gracefully — warn with an actionable hint and skip just that link so
163
+ // the rest of the reindex (other wrappers, mirrors, registry regen) still runs.
164
+ // reindex is idempotent and re-run on hooks, so a later run re-creates the link
165
+ // once the privilege is granted. Returns true on success, false on skip.
166
+ function safeSymlink(target: string, linkPath: string, label: string): boolean {
167
+ try {
168
+ fs.symlinkSync(target, linkPath);
169
+ return true;
170
+ } catch (err) {
171
+ const code = (err as NodeJS.ErrnoException).code;
172
+ warn(
173
+ `reindex: could not create ${label} '${linkPath}' -> '${target}' ` +
174
+ `(${code ?? "error"}: ${(err as Error).message}); skipping. On Windows, ` +
175
+ `creating symlinks needs Developer Mode (Settings -> Privacy & security ` +
176
+ `-> For developers) or an elevated shell.`,
177
+ );
178
+ return false;
179
+ }
180
+ }
181
+
157
182
  // --- legacy `.claude/commands/<ns>/<skill>.md` symlink matcher --------------
158
183
  // Mirrors the bash `case` patterns; `*` matches any chars (including `/`).
159
184
  function isLegacyCommandTarget(t: string): boolean {
@@ -325,7 +350,7 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
325
350
  continue;
326
351
  }
327
352
 
328
- fs.symlinkSync(relativeTarget, linkPath);
353
+ safeSymlink(relativeTarget, linkPath, `.claude/skills/${wrapperName}/${entry}`);
329
354
  }
330
355
 
331
356
  // Prune symlinks in the wrapper whose source entry no longer exists.
@@ -424,7 +449,7 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
424
449
  continue;
425
450
  }
426
451
 
427
- fs.symlinkSync(relativeTarget, linkPath);
452
+ safeSymlink(relativeTarget, linkPath, `core/${type}/${entry}`);
428
453
  }
429
454
  }
430
455
 
@@ -71,6 +71,39 @@ function makeEntityContext(overrides: Partial<EntityContext> = {}): EntityContex
71
71
  };
72
72
  }
73
73
 
74
+ describe("wrapFilterWithIgnoreVisibility", () => {
75
+ it("records noteworthy base-ignore exclusions while counting all rejections", () => {
76
+ const hqRoot = path.join("/tmp", "hqroot");
77
+ const noteworthy: string[] = [];
78
+ let total = 0;
79
+ const underlying = (absPath: string): boolean =>
80
+ !absPath.includes(`${path.sep}repos${path.sep}`) &&
81
+ !absPath.includes(`${path.sep}node_modules${path.sep}`);
82
+ const wrapped = shareTesting.wrapFilterWithIgnoreVisibility(
83
+ underlying,
84
+ hqRoot,
85
+ (rel) => {
86
+ noteworthy.push(rel);
87
+ },
88
+ () => {
89
+ total++;
90
+ },
91
+ );
92
+
93
+ expect(wrapped(path.join(hqRoot, "companies/indigo/knowledge/notes.md"))).toBe(true);
94
+ expect(total).toBe(0);
95
+ expect(noteworthy).toEqual([]);
96
+
97
+ expect(wrapped(path.join(hqRoot, "companies/indigo/knowledge/repos/foo.md"))).toBe(false);
98
+ expect(total).toBe(1);
99
+ expect(noteworthy).toEqual(["companies/indigo/knowledge/repos/foo.md"]);
100
+
101
+ expect(wrapped(path.join(hqRoot, "node_modules/x"))).toBe(false);
102
+ expect(total).toBe(2);
103
+ expect(noteworthy).toEqual(["companies/indigo/knowledge/repos/foo.md"]);
104
+ });
105
+ });
106
+
74
107
  const mockEntity = {
75
108
  uid: "cmp_01ABCDEF",
76
109
  slug: "acme",
@@ -993,6 +1026,92 @@ describe("share", () => {
993
1026
  expect(putKeys).not.toContain("projects/.gitkeep");
994
1027
  });
995
1028
 
1029
+ it("push surfaces an OVER-BROAD ignore exclusion via ignore-excluded instead of dropping it silently (feedback_b9dfc7ae)", async () => {
1030
+ // Regression for the silent-drop failure mode behind DEV-1791: an
1031
+ // over-broad ignore rule (here a deliberately un-anchored `repos/` in
1032
+ // .hqignore) eats /discover knowledge under companies/*/knowledge/repos/.
1033
+ // The discriminator fix stops the over-broad MATCH; this test pins the
1034
+ // OBSERVABILITY: when an ignore rule IS over-broad, the dropped content
1035
+ // is surfaced as a noteworthy `ignore-excluded` event (count + sample)
1036
+ // rather than vanishing. Build noise stays unsurfaced.
1037
+ fs.writeFileSync(path.join(tmpDir, ".hqignore"), "repos/\n");
1038
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1039
+ fs.mkdirSync(path.join(companyRoot, "knowledge", "repos", "dev-docs"), { recursive: true });
1040
+ fs.mkdirSync(path.join(companyRoot, "node_modules", "left-pad"), { recursive: true });
1041
+ fs.writeFileSync(path.join(companyRoot, "knowledge", "overview.md"), "# kb\n");
1042
+ fs.writeFileSync(path.join(companyRoot, "knowledge", "repos", "dev-docs", "notes.md"), "discover knowledge\n");
1043
+ fs.writeFileSync(path.join(companyRoot, "node_modules", "left-pad", "index.js"), "module.exports = 1\n");
1044
+
1045
+ vi.mocked(headRemoteFile).mockResolvedValue(null);
1046
+
1047
+ const events: Array<{ type?: string; path?: string; count?: number; totalExcluded?: number; samplePaths?: string[] }> = [];
1048
+ const result = await share({
1049
+ paths: [companyRoot],
1050
+ company: "acme",
1051
+ vaultConfig: mockConfig,
1052
+ hqRoot: tmpDir,
1053
+ onEvent: (e) => events.push(e as { type?: string }),
1054
+ });
1055
+
1056
+ // The legitimate knowledge file uploads; the over-broadly-excluded
1057
+ // knowledge under knowledge/repos/ does NOT.
1058
+ const uploadedPaths = events
1059
+ .filter((e) => e.type === "progress")
1060
+ .map((e) => e.path)
1061
+ .sort();
1062
+ expect(uploadedPaths).toEqual(["knowledge/overview.md"]);
1063
+
1064
+ // The drop is now VISIBLE: one noteworthy path surfaced, with the
1065
+ // knowledge/repos/ file named; node_modules counted but NOT noteworthy.
1066
+ expect(result.filesExcludedByIgnore).toBe(1);
1067
+ const ignoreEv = events.find((e) => e.type === "ignore-excluded") as
1068
+ | { count: number; totalExcluded: number; samplePaths: string[] }
1069
+ | undefined;
1070
+ expect(ignoreEv).toBeDefined();
1071
+ expect(ignoreEv!.count).toBe(1);
1072
+ expect(ignoreEv!.samplePaths).toEqual(["companies/acme/knowledge/repos"]);
1073
+ expect(ignoreEv!.totalExcluded).toBeGreaterThanOrEqual(2); // repos/ dir + node_modules/ dir
1074
+ expect(ignoreEv!.samplePaths).not.toContain("companies/acme/node_modules");
1075
+ // No upload for the dropped subtree, no error, clean completion.
1076
+ const putKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
1077
+ expect(putKeys).toEqual(["knowledge/overview.md"]);
1078
+ expect(result.aborted).toBe(false);
1079
+ expect(events.some((e) => e.type === "error")).toBe(false);
1080
+ });
1081
+
1082
+ it("default console logger prints a WARN naming the silently-excluded path (no onEvent)", async () => {
1083
+ // The user-facing CLI surface: with no onEvent, share() falls back to the
1084
+ // default console logger. An over-broad ignore drop must produce a visible
1085
+ // WARN line naming the path — the whole point of "not silent". Spy on
1086
+ // console.warn rather than passing onEvent (which would suppress it).
1087
+ fs.writeFileSync(path.join(tmpDir, ".hqignore"), "repos/\n");
1088
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1089
+ fs.mkdirSync(path.join(companyRoot, "knowledge", "repos", "dev-docs"), { recursive: true });
1090
+ fs.writeFileSync(path.join(companyRoot, "knowledge", "overview.md"), "# kb\n");
1091
+ fs.writeFileSync(path.join(companyRoot, "knowledge", "repos", "dev-docs", "notes.md"), "discover knowledge\n");
1092
+
1093
+ vi.mocked(headRemoteFile).mockResolvedValue(null);
1094
+ // Direct monkeypatch rather than vi.spyOn — vitest's console interception
1095
+ // can shadow a spy on console.warn, and we want to assert the REAL writes.
1096
+ const warnings: string[] = [];
1097
+ const originalWarn = console.warn;
1098
+ console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(" ")); };
1099
+ try {
1100
+ await share({
1101
+ paths: [companyRoot],
1102
+ company: "acme",
1103
+ vaultConfig: mockConfig,
1104
+ hqRoot: tmpDir,
1105
+ // no onEvent → defaultConsoleLogger
1106
+ });
1107
+ } finally {
1108
+ console.warn = originalWarn;
1109
+ }
1110
+
1111
+ expect(warnings.some((w) => w.includes("EXCLUDED from sync"))).toBe(true);
1112
+ expect(warnings.some((w) => w.includes("companies/acme/knowledge/repos"))).toBe(true);
1113
+ });
1114
+
996
1115
  it("scoped push defense-in-depth: a 403 on HEAD skips that key as scope-excluded without an error event", async () => {
997
1116
  // Belt-and-suspenders: even if a key slips past the prefix filter (a grant
998
1117
  // that changed mid-run, a pin outside the grant, prefix-coalesce
@@ -1374,6 +1493,7 @@ describe("share", () => {
1374
1493
  e.type === "new-files" ||
1375
1494
  e.type === "personal-vault-out-of-policy" ||
1376
1495
  e.type === "scope-excluded" ||
1496
+ e.type === "ignore-excluded" ||
1377
1497
  e.type === "delete-refused-bulk-asymmetry"
1378
1498
  ) return;
1379
1499
  events.push({
package/src/cli/share.ts CHANGED
@@ -35,7 +35,7 @@ import {
35
35
  PERSONAL_VAULT_JOURNAL_SLUG,
36
36
  migratePersonalVaultJournal,
37
37
  } from "../journal.js";
38
- import { createIgnoreFilter, isWithinSizeLimit } from "../ignore.js";
38
+ import { createIgnoreFilter, isExpectedIgnore, isWithinSizeLimit } from "../ignore.js";
39
39
  import {
40
40
  wrapFilterWithPersonalVaultDefaults,
41
41
  type PersonalVaultExclusion,
@@ -261,6 +261,7 @@ export function isMalformedVaultKey(key: string): boolean {
261
261
  export const _testing = {
262
262
  isEphemeralPath,
263
263
  EPHEMERAL_PATH_PATTERN,
264
+ wrapFilterWithIgnoreVisibility,
264
265
  };
265
266
 
266
267
  /**
@@ -734,6 +735,13 @@ export interface ShareResult {
734
735
  * its in-scope subset.
735
736
  */
736
737
  filesExcludedByScope: number;
738
+ /**
739
+ * Number of distinct hq-root-relative paths skipped by the base ignore
740
+ * filter that look like real content rather than expected build/VCS/cache
741
+ * noise. Mirrors the `count` field of the `ignore-excluded` event (emitted
742
+ * once if this is > 0).
743
+ */
744
+ filesExcludedByIgnore: number;
737
745
  /**
738
746
  * Paths (company-relative) that were detected as push conflicts. Mirrors
739
747
  * `SyncResult.conflictPaths` so push and pull surface conflicts the same
@@ -792,6 +800,31 @@ function wrapFilterWithScope(
792
800
  };
793
801
  }
794
802
 
803
+ /**
804
+ * Wrap the base ignore filter so push can observe paths it rejects before
805
+ * personal-vault policy or ACL scope wrappers add their own exclusions.
806
+ * Every base-ignore rejection increments `onAnyExcluded`; only rejections
807
+ * that do NOT match expected build/VCS/cache noise are tagged through
808
+ * `onIgnoreExcluded` for the one-shot `ignore-excluded` summary.
809
+ */
810
+ export function wrapFilterWithIgnoreVisibility(
811
+ underlying: (absPath: string, isDir?: boolean) => boolean,
812
+ hqRoot: string,
813
+ onIgnoreExcluded: (relPath: string) => void,
814
+ onAnyExcluded?: () => void,
815
+ ): (absPath: string, isDir?: boolean) => boolean {
816
+ return (absPath: string, isDir?: boolean) => {
817
+ const allowed = underlying(absPath, isDir);
818
+ if (allowed) return true;
819
+
820
+ onAnyExcluded?.();
821
+ const rel = path.relative(hqRoot, absPath).split(path.sep).join("/");
822
+ if (rel === "" || rel.startsWith("..")) return false;
823
+ if (!isExpectedIgnore(rel)) onIgnoreExcluded(rel);
824
+ return false;
825
+ };
826
+ }
827
+
795
828
  /**
796
829
  * Share local file(s) to the entity vault.
797
830
  */
@@ -857,6 +890,8 @@ interface PushRunContext {
857
890
  excludedSet: Set<string>;
858
891
  excludedById: Record<string, number>;
859
892
  scopeExcludedSet: Set<string>;
893
+ ignoreExcludedSet: Set<string>;
894
+ ignoreExcludedTotal: { value: number };
860
895
  }
861
896
 
862
897
  interface ShareCounters {
@@ -939,6 +974,18 @@ async function createPushRunContext(options: ShareOptions): Promise<PushRunConte
939
974
  : path.join(hqRoot, "companies", ctx.slug);
940
975
 
941
976
  const ignoreFilter = createIgnoreFilter(hqRoot);
977
+ const ignoreExcludedSet = new Set<string>();
978
+ const ignoreExcludedTotal = { value: 0 };
979
+ const recordedIgnoreFilter = wrapFilterWithIgnoreVisibility(
980
+ ignoreFilter,
981
+ hqRoot,
982
+ (rel) => {
983
+ ignoreExcludedSet.add(rel);
984
+ },
985
+ () => {
986
+ ignoreExcludedTotal.value++;
987
+ },
988
+ );
942
989
  const excludedSet = new Set<string>();
943
990
  const excludedById: Record<string, number> = {};
944
991
  const onExcluded = (rel: string, match: PersonalVaultExclusion) => {
@@ -951,8 +998,8 @@ async function createPushRunContext(options: ShareOptions): Promise<PushRunConte
951
998
  scopeExcludedSet.add(rel);
952
999
  };
953
1000
  const baseFilter = options.personalMode === true
954
- ? wrapFilterWithPersonalVaultDefaults(ignoreFilter, syncRoot, onExcluded)
955
- : ignoreFilter;
1001
+ ? wrapFilterWithPersonalVaultDefaults(recordedIgnoreFilter, syncRoot, onExcluded)
1002
+ : recordedIgnoreFilter;
956
1003
  const shouldSync = options.prefixSet !== undefined
957
1004
  ? wrapFilterWithScope(baseFilter, syncRoot, options.prefixSet, onScopeExcluded)
958
1005
  : baseFilter;
@@ -981,6 +1028,8 @@ async function createPushRunContext(options: ShareOptions): Promise<PushRunConte
981
1028
  excludedSet,
982
1029
  excludedById,
983
1030
  scopeExcludedSet,
1031
+ ignoreExcludedSet,
1032
+ ignoreExcludedTotal,
984
1033
  };
985
1034
  }
986
1035
 
@@ -1494,6 +1543,20 @@ function finalizeShareJournal(run: PushRunContext): void {
1494
1543
  samplePaths,
1495
1544
  });
1496
1545
  }
1546
+
1547
+ if (run.ignoreExcludedSet.size > 0) {
1548
+ const samplePaths: string[] = [];
1549
+ for (const p of run.ignoreExcludedSet) {
1550
+ samplePaths.push(p);
1551
+ if (samplePaths.length >= 10) break;
1552
+ }
1553
+ run.emit({
1554
+ type: "ignore-excluded",
1555
+ count: run.ignoreExcludedSet.size,
1556
+ totalExcluded: run.ignoreExcludedTotal.value,
1557
+ samplePaths,
1558
+ });
1559
+ }
1497
1560
  }
1498
1561
 
1499
1562
  function throwUploadWorkerErrors(workerErrors: Error[]): void {
@@ -1528,6 +1591,7 @@ function buildShareResult(
1528
1591
  filesSuppressedByTombstone: counters.filesSuppressedByTombstone,
1529
1592
  filesExcludedByPolicy: run.excludedSet.size,
1530
1593
  filesExcludedByScope: run.scopeExcludedSet.size,
1594
+ filesExcludedByIgnore: run.ignoreExcludedSet.size,
1531
1595
  conflictPaths,
1532
1596
  aborted,
1533
1597
  };
@@ -1589,6 +1653,20 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
1589
1653
  ` Sample refused paths: ${event.samplePaths.slice(0, 5).join(", ")}${event.samplePaths.length > 5 ? ", …" : ""}\n` +
1590
1654
  ` To proceed anyway: re-run with HQ_SYNC_DELETE_BULK_OVERRIDE=1 (or propagateDeletePolicy:"all").`,
1591
1655
  );
1656
+ } else if (event.type === "ignore-excluded") {
1657
+ // The "not silent" surface: an ignore rule dropped content that doesn't
1658
+ // look like expected build/VCS/cache noise, so it never reached the vault.
1659
+ // Surface it as a WARN naming the paths so an over-broad exclusion is
1660
+ // observable rather than invisible (DEV-1791 / feedback_b9dfc7ae).
1661
+ console.warn(
1662
+ ` ! ${event.count} path${event.count === 1 ? "" : "s"} were EXCLUDED from sync by an ignore rule and did NOT reach the vault (review in case this is unintended):`,
1663
+ );
1664
+ for (const p of event.samplePaths) {
1665
+ console.warn(` · ${p}`);
1666
+ }
1667
+ if (event.count > event.samplePaths.length) {
1668
+ console.warn(` ... and ${event.count - event.samplePaths.length} more`);
1669
+ }
1592
1670
  }
1593
1671
  }
1594
1672
 
package/src/cli/sync.ts CHANGED
@@ -287,6 +287,22 @@ export type SyncProgressEvent =
287
287
  count: number;
288
288
  samplePaths: string[];
289
289
  }
290
+ | {
291
+ /**
292
+ * Emitted at most ONCE per `share()` push leg when the base ignore
293
+ * filter dropped one or more NOTEWORTHY paths: content that does not
294
+ * match expected build/VCS/cache noise classes. Surfaces an over-broad
295
+ * exclusion that would otherwise drop content silently (DEV-1791).
296
+ *
297
+ * `count` is the number of distinct noteworthy paths; `totalExcluded`
298
+ * is all base-ignore rejections including expected noise; `samplePaths`
299
+ * carries up to 10 noteworthy paths. Not emitted when `count === 0`.
300
+ */
301
+ type: "ignore-excluded";
302
+ count: number;
303
+ totalExcluded: number;
304
+ samplePaths: string[];
305
+ }
290
306
  | {
291
307
  /**
292
308
  * Emitted by the PUSH leg (`share()`) once per key whose upload was
@@ -2268,5 +2284,15 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
2268
2284
  if (event.count > event.samplePaths.length) {
2269
2285
  console.log(` ... and ${event.count - event.samplePaths.length} more`);
2270
2286
  }
2287
+ } else if (event.type === "ignore-excluded") {
2288
+ console.warn(
2289
+ ` ! ${event.count} path${event.count === 1 ? "" : "s"} were EXCLUDED from sync by an ignore rule and did NOT reach the vault (review in case this is unintended):`,
2290
+ );
2291
+ for (const p of event.samplePaths) {
2292
+ console.warn(` · ${p}`);
2293
+ }
2294
+ if (event.count > event.samplePaths.length) {
2295
+ console.warn(` ... and ${event.count - event.samplePaths.length} more`);
2296
+ }
2271
2297
  }
2272
2298
  }
@@ -11,7 +11,31 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
11
11
  import * as fs from "fs";
12
12
  import * as os from "os";
13
13
  import * as path from "path";
14
- import { createIgnoreFilter } from "./ignore.js";
14
+ import { createIgnoreFilter, isExpectedIgnore } from "./ignore.js";
15
+ import { wrapFilterWithIgnoreVisibility } from "./cli/share.js";
16
+
17
+ describe("isExpectedIgnore", () => {
18
+ it("classifies top-level HQ-managed roots as expected", () => {
19
+ expect(isExpectedIgnore("repos")).toBe(true);
20
+ expect(isExpectedIgnore("repos/private/x.ts")).toBe(true);
21
+ expect(isExpectedIgnore("workspace/tmp/file.md")).toBe(true);
22
+ });
23
+
24
+ it("classifies build, VCS, cache, and known basename noise as expected", () => {
25
+ expect(isExpectedIgnore("node_modules/react/index.js")).toBe(true);
26
+ expect(isExpectedIgnore("apps/web/node_modules/react/index.js")).toBe(true);
27
+ expect(isExpectedIgnore(".git/config")).toBe(true);
28
+ expect(isExpectedIgnore(".DS_Store")).toBe(true);
29
+ expect(isExpectedIgnore("x/.env")).toBe(true);
30
+ expect(isExpectedIgnore("a/b/.hq-sync.pid")).toBe(true);
31
+ expect(isExpectedIgnore("y/foo.pyc")).toBe(true);
32
+ });
33
+
34
+ it("keeps nested repos/workspace and ordinary content noteworthy", () => {
35
+ expect(isExpectedIgnore("companies/indigo/knowledge/repos/foo/notes.md")).toBe(false);
36
+ expect(isExpectedIgnore("companies/indigo/knowledge/overview.md")).toBe(false);
37
+ });
38
+ });
15
39
 
16
40
  describe("createIgnoreFilter", () => {
17
41
  let hqRoot: string;
@@ -69,6 +93,35 @@ describe("createIgnoreFilter", () => {
69
93
  expect(shouldSync(path.join(hqRoot, "companies/indigo/notes.md"))).toBe(true);
70
94
  });
71
95
 
96
+ it("visibility fires on an OVER-BROAD exclusion (real filter + recorder, DEV-1791)", () => {
97
+ // Simulate the regressed over-broad bug: an un-anchored `repos/` in
98
+ // .hqignore that matches ANY nested repos segment — exactly what
99
+ // silently ate /discover knowledge under companies/*/knowledge/repos/
100
+ // before the root-anchor fix. The visibility recorder must surface the
101
+ // dropped knowledge as NOTEWORTHY while staying quiet on build noise.
102
+ fs.writeFileSync(path.join(hqRoot, ".hqignore"), "repos/\n");
103
+ const ignoreFilter = createIgnoreFilter(hqRoot);
104
+ const noteworthy: string[] = [];
105
+ let total = 0;
106
+ const recorded = wrapFilterWithIgnoreVisibility(
107
+ ignoreFilter,
108
+ hqRoot,
109
+ (rel) => { noteworthy.push(rel); },
110
+ () => { total++; },
111
+ );
112
+
113
+ // Knowledge under a nested repos/ is dropped by the over-broad rule and
114
+ // is genuine content → captured as noteworthy.
115
+ expect(recorded(path.join(hqRoot, "companies/indigo/knowledge/repos/foo/notes.md"))).toBe(false);
116
+ // Build noise is also excluded but is EXPECTED → counted, not noteworthy.
117
+ expect(recorded(path.join(hqRoot, "node_modules/react/index.js"))).toBe(false);
118
+ // Ordinary knowledge that the filter ALLOWS fires nothing.
119
+ expect(recorded(path.join(hqRoot, "companies/indigo/knowledge/overview.md"))).toBe(true);
120
+
121
+ expect(noteworthy).toEqual(["companies/indigo/knowledge/repos/foo/notes.md"]);
122
+ expect(total).toBe(2); // both exclusions counted; only one is noteworthy
123
+ });
124
+
72
125
  it("v15+ layout (core/core.yaml present): root core.yaml is ignored, nested core/core.yaml syncs", () => {
73
126
  // On v15+ the scaffold/version manifest lives at `core/core.yaml` (a real
74
127
  // project file that DOES sync). Any root-level `core.yaml` here is a stale
package/src/ignore.ts CHANGED
@@ -209,6 +209,79 @@ function segmentToRegex(seg: string): RegExp {
209
209
  return new RegExp(`^${body}$`);
210
210
  }
211
211
 
212
+ export const EXPECTED_IGNORE_SEGMENTS = new Set([
213
+ ".git",
214
+ "node_modules",
215
+ ".pnpm-store",
216
+ "dist",
217
+ "build",
218
+ ".next",
219
+ ".nuxt",
220
+ ".svelte-kit",
221
+ ".turbo",
222
+ ".parcel-cache",
223
+ ".vite",
224
+ "coverage",
225
+ "target",
226
+ "__pycache__",
227
+ ".pytest_cache",
228
+ ".mypy_cache",
229
+ ".ruff_cache",
230
+ ".venv",
231
+ "venv",
232
+ "vendor",
233
+ "out",
234
+ ".cache",
235
+ "tmp",
236
+ ".tmp",
237
+ ".hq",
238
+ ".claude",
239
+ ]);
240
+
241
+ /**
242
+ * Classify a base-ignore rejection as expected noise vs noteworthy content.
243
+ *
244
+ * `true` means the ignored path belongs to an expected by-design exclusion
245
+ * class: HQ-managed top-level roots, build/VCS/cache/machine-local segments,
246
+ * or known generated/noise basenames. `false` means the rejected path looks
247
+ * like real user content and should be surfaced by push observability.
248
+ *
249
+ * Top-level `repos/` and `workspace/` are expected, but nested segments named
250
+ * `repos` or `workspace` are deliberately noteworthy: that is the DEV-1791
251
+ * regression signal this classifier exists to preserve.
252
+ */
253
+ export function isExpectedIgnore(relPath: string): boolean {
254
+ if (
255
+ relPath === "repos" ||
256
+ relPath.startsWith("repos/") ||
257
+ relPath === "workspace" ||
258
+ relPath.startsWith("workspace/")
259
+ ) {
260
+ return true;
261
+ }
262
+
263
+ const segments = relPath.split("/");
264
+ for (const segment of segments) {
265
+ if (EXPECTED_IGNORE_SEGMENTS.has(segment)) return true;
266
+ }
267
+
268
+ const basename = segments[segments.length - 1] ?? "";
269
+ return (
270
+ basename === ".DS_Store" ||
271
+ basename === "Thumbs.db" ||
272
+ basename === "company.yaml" ||
273
+ basename === "INDEX.md" ||
274
+ basename === "modules.lock" ||
275
+ basename.endsWith(".pyc") ||
276
+ basename.endsWith(".class") ||
277
+ basename.endsWith(".pid") ||
278
+ basename.startsWith(".hq-") ||
279
+ basename === ".env" ||
280
+ basename.startsWith(".env.") ||
281
+ basename === ".mcp.json"
282
+ );
283
+ }
284
+
212
285
  export function createIgnoreFilter(
213
286
  hqRoot: string,
214
287
  ): (filePath: string, isDir?: boolean) => boolean {