@indigoai-us/hq-cloud 6.14.21 → 6.14.23

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 (42) hide show
  1. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  2. package/dist/bin/sync-runner-company.js +9 -0
  3. package/dist/bin/sync-runner-company.js.map +1 -1
  4. package/dist/bin/sync-runner-events.test.js +22 -0
  5. package/dist/bin/sync-runner-events.test.js.map +1 -1
  6. package/dist/bin/sync-runner.d.ts +27 -1
  7. package/dist/bin/sync-runner.d.ts.map +1 -1
  8. package/dist/bin/sync-runner.js +14 -1
  9. package/dist/bin/sync-runner.js.map +1 -1
  10. package/dist/bin/sync-runner.test.js +51 -1
  11. package/dist/bin/sync-runner.test.js.map +1 -1
  12. package/dist/cli/reindex.d.ts.map +1 -1
  13. package/dist/cli/reindex.js +54 -20
  14. package/dist/cli/reindex.js.map +1 -1
  15. package/dist/cli/reindex.test.js +66 -4
  16. package/dist/cli/reindex.test.js.map +1 -1
  17. package/dist/cli/share.test.js +1 -0
  18. package/dist/cli/share.test.js.map +1 -1
  19. package/dist/cli/sync.d.ts +25 -0
  20. package/dist/cli/sync.d.ts.map +1 -1
  21. package/dist/cli/sync.js +83 -1
  22. package/dist/cli/sync.js.map +1 -1
  23. package/dist/cli/sync.test.js +171 -0
  24. package/dist/cli/sync.test.js.map +1 -1
  25. package/dist/lib/conflict-index.d.ts +7 -0
  26. package/dist/lib/conflict-index.d.ts.map +1 -1
  27. package/dist/lib/conflict-index.js +3 -0
  28. package/dist/lib/conflict-index.js.map +1 -1
  29. package/dist/lib/conflict.test.js +31 -0
  30. package/dist/lib/conflict.test.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/bin/sync-runner-company.ts +8 -0
  33. package/src/bin/sync-runner-events.test.ts +31 -0
  34. package/src/bin/sync-runner.test.ts +62 -1
  35. package/src/bin/sync-runner.ts +40 -1
  36. package/src/cli/reindex.test.ts +74 -6
  37. package/src/cli/reindex.ts +59 -22
  38. package/src/cli/share.test.ts +1 -0
  39. package/src/cli/sync.test.ts +192 -0
  40. package/src/cli/sync.ts +126 -1
  41. package/src/lib/conflict-index.ts +11 -0
  42. package/src/lib/conflict.test.ts +33 -0
@@ -3783,6 +3783,198 @@ describe("sync", () => {
3783
3783
  expect(errOutput).not.toContain("stale personal-overlay marker");
3784
3784
  });
3785
3785
 
3786
+ it("directory-overlay marker whose children are already materialized: skips cleanly, no 'rm -rf' advice, children still pull (feedback_d2082110)", async () => {
3787
+ // Reporter feedback_d2082110 (cody@jonesroadbeauty.com): the DPP vault
3788
+ // stored a cloud symlink marker at `skills/cody-copywriter` while the local
3789
+ // path was a real directory holding that overlay's 13 materialized child
3790
+ // objects. The vault stores a directory overlay as a symlink RECORD at the
3791
+ // key itself AND the directory's contents as separate child objects beneath
3792
+ // it. Pre-fix, the pull planner hit the generic dir-vs-object collision
3793
+ // branch and emitted, on EVERY sync cycle, a "manual reconciliation required
3794
+ // (rm -rf the local directory to pull)" warning — advice that here would
3795
+ // DELETE the materialized (and possibly locally-authored) contents to install
3796
+ // an inert link. The marker "never resolved" and the noisy warning recurred.
3797
+ // Fix: recognize that the marker's key ALSO carries child objects in the same
3798
+ // LIST (a real directory never syncs as a single object at its own key), keep
3799
+ // the local directory, skip the marker quietly, and let the children pull via
3800
+ // their own plan items.
3801
+ const markerKey = "skills/cody-copywriter";
3802
+ const childKey = "skills/cody-copywriter/SKILL.md";
3803
+ const localDir = path.join(tmpDir, "companies", "acme", "skills", "cody-copywriter");
3804
+ fs.mkdirSync(localDir, { recursive: true });
3805
+ // A child that is already materialized on this device (stands in for the
3806
+ // reporter's 13 present child files) — it must survive untouched.
3807
+ fs.writeFileSync(path.join(localDir, "materialized.md"), "already here");
3808
+
3809
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
3810
+ { key: markerKey, size: 30, lastModified: new Date(), etag: '"overlay-marker"' },
3811
+ { key: childKey, size: 18, lastModified: new Date(), etag: '"child-obj"' },
3812
+ ]);
3813
+ // Deterministic child materialization, independent of the ambient default
3814
+ // downloadFile mock (which earlier tests in this suite may have replaced).
3815
+ // The marker is skipped by the planner, so this Once impl serves the single
3816
+ // child download.
3817
+ vi.mocked(s3Module.downloadFile).mockImplementationOnce(
3818
+ async (_ctx: unknown, _key: string, localPath: string) => {
3819
+ const dir = path.dirname(localPath);
3820
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
3821
+ fs.writeFileSync(localPath, "child content");
3822
+ return { metadata: {} };
3823
+ },
3824
+ );
3825
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
3826
+
3827
+ let crashed = false;
3828
+ let result;
3829
+ try {
3830
+ result = await sync({
3831
+ company: "acme",
3832
+ vaultConfig: mockConfig,
3833
+ hqRoot: tmpDir,
3834
+ });
3835
+ } catch {
3836
+ crashed = true;
3837
+ }
3838
+ const errOutput = errSpy.mock.calls.map((c) => c.join(" ")).join("\n");
3839
+ errSpy.mockRestore();
3840
+
3841
+ expect(crashed).toBe(false);
3842
+ expect(result).toBeDefined();
3843
+ // The marker is a benign skip; the child object still downloads.
3844
+ expect(result!.filesDownloaded).toBe(1);
3845
+ expect(result!.filesSkipped).toBeGreaterThanOrEqual(1);
3846
+ // Local directory and its already-materialized contents are untouched...
3847
+ expect(fs.readFileSync(path.join(localDir, "materialized.md"), "utf-8")).toBe(
3848
+ "already here",
3849
+ );
3850
+ // ...and the child materialized under the kept directory.
3851
+ expect(fs.existsSync(path.join(localDir, "SKILL.md"))).toBe(true);
3852
+ // The dangerous, recurring reconciliation advice is GONE...
3853
+ expect(errOutput).not.toContain("rm -rf");
3854
+ expect(errOutput).not.toContain("manual");
3855
+ // ...replaced by a calm, no-action-needed note naming the marker.
3856
+ expect(errOutput).toContain("directory-overlay marker");
3857
+ expect(errOutput).toContain(markerKey);
3858
+ });
3859
+
3860
+ it("company dir-vs-object collision with NO children in the LIST still warns to reconcile (boundary)", async () => {
3861
+ // Boundary guard for the feedback_d2082110 fix: the quiet overlay-marker
3862
+ // skip is scoped to a remote object whose key ALSO has child objects under
3863
+ // it (a directory overlay). A single remote object at a company key that is
3864
+ // locally a real directory but has NO children in the LIST is a genuine
3865
+ // file-vs-directory structural collision the operator must resolve, so the
3866
+ // original warning + skip-local-only classification must remain intact.
3867
+ const key = "skills/lonely-file";
3868
+ const localDir = path.join(tmpDir, "companies", "acme", "skills", "lonely-file");
3869
+ fs.mkdirSync(localDir, { recursive: true });
3870
+ fs.writeFileSync(path.join(localDir, "inside.md"), "local content");
3871
+
3872
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
3873
+ { key, size: 40, lastModified: new Date(), etag: '"single-obj"' },
3874
+ ]);
3875
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
3876
+
3877
+ let crashed = false;
3878
+ let result;
3879
+ try {
3880
+ result = await sync({
3881
+ company: "acme",
3882
+ vaultConfig: mockConfig,
3883
+ hqRoot: tmpDir,
3884
+ });
3885
+ } catch {
3886
+ crashed = true;
3887
+ }
3888
+ const errOutput = errSpy.mock.calls.map((c) => c.join(" ")).join("\n");
3889
+ errSpy.mockRestore();
3890
+
3891
+ expect(crashed).toBe(false);
3892
+ expect(result!.filesDownloaded).toBe(0);
3893
+ expect(result!.filesSkipped).toBe(1);
3894
+ expect(fs.existsSync(path.join(localDir, "inside.md"))).toBe(true);
3895
+ // Generic collision path preserved: reconciliation warning still emitted,
3896
+ // NOT the overlay-marker note.
3897
+ expect(errOutput).toContain("manual");
3898
+ expect(errOutput).not.toContain("directory-overlay marker");
3899
+ });
3900
+
3901
+ it("shared-mode leg surfaces the materialization gap and names the shared→all lever (feedback_d2082110)", async () => {
3902
+ // Reporter feedback_d2082110 (cody@jonesroadbeauty.com): four memberships
3903
+ // were configured as `shared` rather than `all`, so not all vault content
3904
+ // materialized on the second device — a SILENT gap the reporter had to
3905
+ // notice and work around by flipping each membership to `all` by hand.
3906
+ // `SyncResult.filesOutOfScope` counted the skipped keys but nothing SURFACED
3907
+ // the gap or the lever. Fix: a `shared`/`custom` leg that skips one or more
3908
+ // keys as out-of-scope emits a single `scope-materialization-gap` event so
3909
+ // full materialization becomes a deliberate, visible choice.
3910
+ const events: Array<Record<string, unknown>> = [];
3911
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
3912
+ { key: "knowledge/present.md", size: 12, lastModified: new Date(), etag: '"in-scope"' },
3913
+ { key: "projects/secret.md", size: 20, lastModified: new Date(), etag: '"out-of-scope"' },
3914
+ ]);
3915
+ // Deterministic materialization of the single in-scope download, independent
3916
+ // of the ambient default downloadFile mock (earlier tests in this suite may
3917
+ // have replaced it — clearAllMocks resets call history, not implementations).
3918
+ vi.mocked(s3Module.downloadFile).mockImplementationOnce(
3919
+ async (_ctx: unknown, _key: string, localPath: string) => {
3920
+ const dir = path.dirname(localPath);
3921
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
3922
+ fs.writeFileSync(localPath, "in-scope content");
3923
+ return { metadata: {} };
3924
+ },
3925
+ );
3926
+
3927
+ const result = await sync({
3928
+ company: "acme",
3929
+ vaultConfig: mockConfig,
3930
+ hqRoot: tmpDir,
3931
+ syncMode: "shared",
3932
+ prefixSet: ["knowledge/"],
3933
+ onEvent: (e) => events.push(e as unknown as Record<string, unknown>),
3934
+ });
3935
+
3936
+ // The in-scope key pulled; the out-of-scope key was NOT materialized.
3937
+ expect(result.filesDownloaded).toBe(1);
3938
+ expect(result.filesOutOfScope).toBe(1);
3939
+ expect(
3940
+ fs.existsSync(path.join(tmpDir, "companies", "acme", "knowledge", "present.md")),
3941
+ ).toBe(true);
3942
+ expect(
3943
+ fs.existsSync(path.join(tmpDir, "companies", "acme", "projects", "secret.md")),
3944
+ ).toBe(false);
3945
+
3946
+ // The gap is now SURFACED with the count, the active mode, and the lever.
3947
+ const gap = events.find((e) => e.type === "scope-materialization-gap");
3948
+ expect(gap).toBeDefined();
3949
+ expect(gap!.count).toBe(1);
3950
+ expect(gap!.syncMode).toBe("shared");
3951
+ expect(gap!.samplePaths).toEqual(["projects/secret.md"]);
3952
+ });
3953
+
3954
+ it("all-mode leg (default) never emits a materialization gap — no scoping, no noise", async () => {
3955
+ // Boundary for the feedback_d2082110 gap surface: in `all` mode every key is
3956
+ // in scope, so the gap event must stay silent even when the LIST is fully
3957
+ // downloaded. Prevents the guidance from firing on the common full-access
3958
+ // path where there is nothing for the operator to act on.
3959
+ const events: Array<Record<string, unknown>> = [];
3960
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
3961
+ { key: "knowledge/a.md", size: 12, lastModified: new Date(), etag: '"a"' },
3962
+ { key: "projects/b.md", size: 20, lastModified: new Date(), etag: '"b"' },
3963
+ ]);
3964
+
3965
+ const result = await sync({
3966
+ company: "acme",
3967
+ vaultConfig: mockConfig,
3968
+ hqRoot: tmpDir,
3969
+ // syncMode omitted → defaults to "all".
3970
+ onEvent: (e) => events.push(e as unknown as Record<string, unknown>),
3971
+ });
3972
+
3973
+ expect(result.filesDownloaded).toBe(2);
3974
+ expect(result.filesOutOfScope).toBe(0);
3975
+ expect(events.find((e) => e.type === "scope-materialization-gap")).toBeUndefined();
3976
+ });
3977
+
3786
3978
  it("pulls a remote symlink record under an .hqinclude dir-only allowlist pattern", async () => {
3787
3979
  // Codex round-7 P1 follow-up: pre-fix, computePullPlan called
3788
3980
  // shouldSync(localPath) with the default isDir=false. LIST gives
package/src/cli/sync.ts CHANGED
@@ -307,6 +307,32 @@ export type SyncProgressEvent =
307
307
  count: number;
308
308
  samplePaths: string[];
309
309
  }
310
+ | {
311
+ /**
312
+ * Emitted at most ONCE per PULL leg when the leg ran under a
313
+ * membership-scoped `syncMode` (`"shared"` or `"custom"` — never `"all"`)
314
+ * AND one or more remote keys were skipped as out-of-scope
315
+ * (`filesOutOfScope > 0`). This turns the silent shared-vs-all gap into a
316
+ * VISIBLE, actionable surface. The reporter of feedback_d2082110 lost
317
+ * files across devices precisely because four memberships defaulted to
318
+ * `shared` rather than `all`, so not all vault content materialized on the
319
+ * second device — and nothing told them; they had to notice the gap and
320
+ * flip each membership to `all` by hand. This event names the gap and the
321
+ * lever (raise the membership's access level to `all`) so FULL
322
+ * materialization is a deliberate, visible choice rather than a silent
323
+ * omission.
324
+ *
325
+ * `count` is the number of remote keys skipped as out-of-scope on this
326
+ * leg; `samplePaths` carries up to 10 company-relative keys for display;
327
+ * `syncMode` is the active scoped mode. Distinct from the push-side
328
+ * `scope-excluded` (which reports what a grantee could not PUSH). Not
329
+ * emitted in `all` mode or when `count === 0` — no gap, no noise.
330
+ */
331
+ type: "scope-materialization-gap";
332
+ count: number;
333
+ samplePaths: string[];
334
+ syncMode: SyncMode;
335
+ }
310
336
  | {
311
337
  /**
312
338
  * Emitted at most ONCE per `share()` push leg when the base ignore
@@ -856,6 +882,8 @@ async function syncWithOperationLockHeld(
856
882
  await verifyPlannedJournalTombstones(run, plan);
857
883
  executeJournalTombstoneDeletes(run, plan, counters);
858
884
 
885
+ emitScopeMaterializationGap(run, plan, counters);
886
+
859
887
  return finalizePullRun(run, plan, scopeRun, counters);
860
888
  }
861
889
 
@@ -1170,7 +1198,8 @@ async function executeConflictExecutor(
1170
1198
  item.action === "skip-personal-mode" ||
1171
1199
  item.action === "skip-unchanged" ||
1172
1200
  item.action === "skip-local-only" ||
1173
- item.action === "skip-stale-overlay-marker"
1201
+ item.action === "skip-stale-overlay-marker" ||
1202
+ item.action === "skip-overlay-marker-with-children"
1174
1203
  ) {
1175
1204
  counters.filesSkipped++;
1176
1205
  continue;
@@ -1844,6 +1873,37 @@ function finalizePullRun(
1844
1873
  };
1845
1874
  }
1846
1875
 
1876
+ /**
1877
+ * Surface the shared-vs-all materialization gap (feedback_d2082110). When a
1878
+ * pull leg ran under a membership-scoped `syncMode` and skipped one or more
1879
+ * remote keys as out-of-scope, emit a single summary event so the operator
1880
+ * SEES that not all vault content materialized on this device and knows the
1881
+ * lever — raise the membership's access level to `all`. Silent in `all` mode
1882
+ * (nothing is scoped away) and when nothing fell out of scope: no gap, no
1883
+ * noise. Deliberately AFTER the transfer executors, so `filesOutOfScope` is
1884
+ * final; sample keys are read off the plan's `skip-out-of-scope` items (pure,
1885
+ * no I/O). This is the visible complement to the previously-silent
1886
+ * `SyncResult.filesOutOfScope` count.
1887
+ */
1888
+ function emitScopeMaterializationGap(
1889
+ run: PullRunContext,
1890
+ plan: PullPlan,
1891
+ counters: PullCounters,
1892
+ ): void {
1893
+ if (run.syncMode === "all") return;
1894
+ if (counters.filesOutOfScope <= 0) return;
1895
+ const samplePaths = plan.items
1896
+ .filter((item) => item.action === "skip-out-of-scope")
1897
+ .slice(0, 10)
1898
+ .map((item) => item.remoteFile.key);
1899
+ run.emit({
1900
+ type: "scope-materialization-gap",
1901
+ count: counters.filesOutOfScope,
1902
+ samplePaths,
1903
+ syncMode: run.syncMode,
1904
+ });
1905
+ }
1906
+
1847
1907
  /**
1848
1908
  * Decide whether a remote object present in the LIST is a GENUINE RE-CREATE
1849
1909
  * written AFTER a FILE_TOMBSTONE — in which case the tombstone is stale and the
@@ -2034,6 +2094,15 @@ type PullPlanItem =
2034
2094
  // no-action-needed case is never surfaced with the alarming "rm -rf the
2035
2095
  // local directory" reconciliation advice.
2036
2096
  | { action: "skip-stale-overlay-marker"; remoteFile: RemoteFile; localPath: string }
2097
+ // A directory-overlay marker (a single vault object at a key that ALSO has
2098
+ // child objects under it in the same LIST — a symlink record for a directory
2099
+ // mirrored from another tree) colliding with a local REAL directory whose
2100
+ // contents are already materialized. The marker can never be renamed over the
2101
+ // directory; its children carry the real content and materialize via their own
2102
+ // plan items. A benign, no-action skip — distinct from skip-local-only so the
2103
+ // case is never surfaced with the alarming (and, for a materialized directory,
2104
+ // data-destroying) "rm -rf the local directory to pull" advice. feedback_d2082110.
2105
+ | { action: "skip-overlay-marker-with-children"; remoteFile: RemoteFile; localPath: string }
2037
2106
  // Remote keys refused by ephemeral-mirror policy. The push walker has
2038
2107
  // refused to upload these since 5.33.0; the pull walker now refuses to
2039
2108
  // download them so legacy litter in cloud staging drains naturally.
@@ -2176,6 +2245,28 @@ function computePullPlan(
2176
2245
  localPath: string;
2177
2246
  }> = [];
2178
2247
 
2248
+ // Remote keys that ALSO appear as an ancestor of another remote key — i.e.
2249
+ // keys that carry child objects under `${key}/…` in this same LIST. The
2250
+ // vault stores a directory OVERLAY (a symlink into another tree, e.g. a
2251
+ // company skill mirrored from a shared repo) as a symlink RECORD at the
2252
+ // directory's own key AND stores the directory's contents as separate child
2253
+ // objects beneath it. A REAL directory, by contrast, only ever syncs as
2254
+ // those child objects — never as a single object AT its own key. So a single
2255
+ // remote object sitting at a key that also has children is unambiguously a
2256
+ // directory-overlay marker, not a regular file. Precomputed once (pure path
2257
+ // derivation, no I/O — the planner is synchronous) so the dir-vs-object
2258
+ // collision branch can tell an inert overlay marker apart from a genuine
2259
+ // file-vs-directory structural collision. feedback_d2082110.
2260
+ const remoteKeysWithChildren = new Set<string>();
2261
+ for (const rf of remoteFiles) {
2262
+ const key = toPosixKey(rf.key);
2263
+ let slash = key.indexOf("/");
2264
+ while (slash !== -1) {
2265
+ remoteKeysWithChildren.add(key.slice(0, slash));
2266
+ slash = key.indexOf("/", slash + 1);
2267
+ }
2268
+ }
2269
+
2179
2270
  for (const remoteFile of remoteFiles) {
2180
2271
  const invalidKey = classifyVaultKey(
2181
2272
  remoteFile.key,
@@ -2437,6 +2528,30 @@ function computePullPlan(
2437
2528
  });
2438
2529
  continue;
2439
2530
  }
2531
+ // Directory-overlay marker vs. a locally-materialized directory:
2532
+ // the remote object at this key carries CHILD objects under it in the
2533
+ // same LIST (`remoteKeysWithChildren`), so it is a symlink/overlay
2534
+ // record for a directory — e.g. the DPP vault's `skills/cody-copywriter`
2535
+ // overlay whose 13 child objects are already materialized locally. The
2536
+ // marker can never be renamed over the real directory, and its children
2537
+ // carry the actual content and materialize via their own plan items, so
2538
+ // the marker is a benign, no-action skip. The generic "rm -rf the local
2539
+ // directory to pull" advice below would tell the operator to DELETE the
2540
+ // materialized (and possibly locally-authored) contents to install an
2541
+ // inert link, and it recurred on EVERY sync cycle. feedback_d2082110.
2542
+ if (remoteKeysWithChildren.has(toPosixKey(remoteFile.key))) {
2543
+ console.error(
2544
+ ` Note: ${remoteFile.key} is a directory-overlay marker whose ` +
2545
+ `contents already exist locally as a directory; keeping the ` +
2546
+ `local directory and ignoring the marker (no action needed).`,
2547
+ );
2548
+ items.push({
2549
+ action: "skip-overlay-marker-with-children",
2550
+ remoteFile,
2551
+ localPath,
2552
+ });
2553
+ continue;
2554
+ }
2440
2555
  console.error(
2441
2556
  ` Warning: ${remoteFile.key} exists locally as a directory; ` +
2442
2557
  `cloud has a single object at this key. Skipping; manual ` +
@@ -2896,6 +3011,16 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
2896
3011
  if (event.count > event.samplePaths.length) {
2897
3012
  console.log(` ... and ${event.count - event.samplePaths.length} more`);
2898
3013
  }
3014
+ } else if (event.type === "scope-materialization-gap") {
3015
+ console.warn(
3016
+ ` ! ${event.count} item${event.count === 1 ? "" : "s"} in this vault did NOT sync to this device — your access level is "${event.syncMode}", so only shared paths materialize. To pull everything you have access to, set this membership's access level to "all":`,
3017
+ );
3018
+ for (const p of event.samplePaths) {
3019
+ console.warn(` · ${p}`);
3020
+ }
3021
+ if (event.count > event.samplePaths.length) {
3022
+ console.warn(` ... and ${event.count - event.samplePaths.length} more`);
3023
+ }
2899
3024
  } else if (event.type === "ignore-excluded") {
2900
3025
  console.warn(
2901
3026
  ` ! ${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):`,
@@ -136,6 +136,13 @@ export interface PruneConflictIndexResult {
136
136
  removedMirrors: number;
137
137
  /** Rows kept (genuine divergence, or unprovable — fail-safe retained). */
138
138
  kept: number;
139
+ /**
140
+ * Up to 10 ORIGINAL (non-mirror) paths of the kept rows, for a
141
+ * post-sync reconcile surface (`conflicts-remaining`). Empty when
142
+ * `kept === 0`. Lets a caller name the preserved conflict variants a human
143
+ * still has to resolve without re-reading the index. feedback_d2082110.
144
+ */
145
+ keptSamplePaths: string[];
139
146
  }
140
147
 
141
148
  /**
@@ -198,6 +205,7 @@ export function pruneConflictIndex(hqRoot: string): PruneConflictIndexResult {
198
205
  prunedIdentical: 0,
199
206
  removedMirrors: 0,
200
207
  kept: 0,
208
+ keptSamplePaths: [],
201
209
  };
202
210
 
203
211
  const index = readConflictIndex(hqRoot);
@@ -254,6 +262,9 @@ export function pruneConflictIndex(hqRoot: string): PruneConflictIndexResult {
254
262
  }
255
263
  }
256
264
 
265
+ // Name the preserved divergences for the post-sync reconcile surface.
266
+ result.keptSamplePaths = kept.slice(0, 10).map((e) => e.originalPath);
267
+
257
268
  // No row dropped → leave the file (and its mtime) untouched.
258
269
  if (kept.length === index.conflicts.length) return result;
259
270
 
@@ -194,6 +194,7 @@ describe("pruneConflictIndex", () => {
194
194
  prunedIdentical: 0,
195
195
  removedMirrors: 0,
196
196
  kept: 0,
197
+ keptSamplePaths: [],
197
198
  });
198
199
  expect(fs.existsSync(getConflictIndexPath(tmpHq))).toBe(false);
199
200
  });
@@ -292,6 +293,7 @@ describe("pruneConflictIndex", () => {
292
293
  prunedIdentical: 1,
293
294
  removedMirrors: 1,
294
295
  kept: 1,
296
+ keptSamplePaths: ["dir/real.md"],
295
297
  });
296
298
  expect(readConflictIndex(tmpHq).conflicts.map((c) => c.id)).toEqual(["real"]);
297
299
  });
@@ -312,4 +314,35 @@ describe("pruneConflictIndex", () => {
312
314
  pruneConflictIndex(tmpHq);
313
315
  expect(fs.statSync(indexPath).mtimeMs).toBe(before);
314
316
  });
317
+
318
+ it("names the preserved conflict variants (keptSamplePaths, capped at 10) for the reconcile surface (feedback_d2082110)", () => {
319
+ // Reporter feedback_d2082110 finished a full sync with "20 older preserved
320
+ // conflict entries" still on disk and NO signal they were there. The prune
321
+ // self-heals the ledger but conservatively KEEPS genuine divergences; those
322
+ // were silent. keptSamplePaths surfaces the original (non-mirror) paths so
323
+ // the runner can emit a `conflicts-remaining` reconcile line pointing the
324
+ // operator at `/resolve-conflicts`.
325
+ const rows: ConflictIndexEntry[] = [];
326
+ for (let i = 0; i < 12; i++) {
327
+ const id = `real-${String(i).padStart(2, "0")}`;
328
+ const row = rowFor(id);
329
+ put(row.originalPath, `local-${i}`);
330
+ put(row.conflictPath, `remote-${i}`);
331
+ rows.push(row);
332
+ }
333
+ writeConflictIndex(tmpHq, { version: 1, conflicts: rows });
334
+
335
+ const res = pruneConflictIndex(tmpHq);
336
+ expect(res.kept).toBe(12);
337
+ // Capped at 10 samples, drawn from the kept (detectedAt-sorted) rows.
338
+ expect(res.keptSamplePaths).toHaveLength(10);
339
+ expect(res.keptSamplePaths.every((p) => p.startsWith("dir/real-"))).toBe(true);
340
+ // Orphaned/identical rows never contribute to the surfaced sample.
341
+ const orphan = rowFor("orphan");
342
+ put(orphan.originalPath, "x"); // mirror missing → dropped, not sampled
343
+ writeConflictIndex(tmpHq, { version: 1, conflicts: [orphan] });
344
+ const res2 = pruneConflictIndex(tmpHq);
345
+ expect(res2.kept).toBe(0);
346
+ expect(res2.keptSamplePaths).toEqual([]);
347
+ });
315
348
  });