@indigoai-us/hq-cloud 6.14.22 → 6.14.24

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 (120) hide show
  1. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  2. package/dist/bin/sync-runner-company.js +20 -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-planning.d.ts +11 -0
  7. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  8. package/dist/bin/sync-runner-planning.js +37 -2
  9. package/dist/bin/sync-runner-planning.js.map +1 -1
  10. package/dist/bin/sync-runner-planning.test.js +1 -0
  11. package/dist/bin/sync-runner-planning.test.js.map +1 -1
  12. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  13. package/dist/bin/sync-runner-watch-loop.js +17 -1
  14. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  15. package/dist/bin/sync-runner.d.ts +33 -1
  16. package/dist/bin/sync-runner.d.ts.map +1 -1
  17. package/dist/bin/sync-runner.js +28 -3
  18. package/dist/bin/sync-runner.js.map +1 -1
  19. package/dist/bin/sync-runner.test.js +206 -2
  20. package/dist/bin/sync-runner.test.js.map +1 -1
  21. package/dist/cli/doctor.d.ts +119 -0
  22. package/dist/cli/doctor.d.ts.map +1 -0
  23. package/dist/cli/doctor.js +485 -0
  24. package/dist/cli/doctor.js.map +1 -0
  25. package/dist/cli/doctor.test.d.ts +13 -0
  26. package/dist/cli/doctor.test.d.ts.map +1 -0
  27. package/dist/cli/doctor.test.js +485 -0
  28. package/dist/cli/doctor.test.js.map +1 -0
  29. package/dist/cli/index.d.ts +2 -0
  30. package/dist/cli/index.d.ts.map +1 -1
  31. package/dist/cli/index.js +4 -0
  32. package/dist/cli/index.js.map +1 -1
  33. package/dist/cli/share.js +6 -1
  34. package/dist/cli/share.js.map +1 -1
  35. package/dist/cli/share.test.js +1 -0
  36. package/dist/cli/share.test.js.map +1 -1
  37. package/dist/cli/sync.d.ts +54 -0
  38. package/dist/cli/sync.d.ts.map +1 -1
  39. package/dist/cli/sync.js +223 -3
  40. package/dist/cli/sync.js.map +1 -1
  41. package/dist/cli/sync.test.js +394 -1
  42. package/dist/cli/sync.test.js.map +1 -1
  43. package/dist/cognito-auth.test.js +7 -0
  44. package/dist/cognito-auth.test.js.map +1 -1
  45. package/dist/index.d.ts +2 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +3 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/lib/conflict-file.d.ts.map +1 -1
  50. package/dist/lib/conflict-file.js +5 -1
  51. package/dist/lib/conflict-file.js.map +1 -1
  52. package/dist/lib/conflict-index.d.ts +7 -0
  53. package/dist/lib/conflict-index.d.ts.map +1 -1
  54. package/dist/lib/conflict-index.js +10 -2
  55. package/dist/lib/conflict-index.js.map +1 -1
  56. package/dist/lib/conflict.test.js +31 -0
  57. package/dist/lib/conflict.test.js.map +1 -1
  58. package/dist/lib/machine-id.test.js +14 -0
  59. package/dist/lib/machine-id.test.js.map +1 -1
  60. package/dist/local-path-codec.d.ts +18 -0
  61. package/dist/local-path-codec.d.ts.map +1 -1
  62. package/dist/local-path-codec.js +63 -0
  63. package/dist/local-path-codec.js.map +1 -1
  64. package/dist/local-path-codec.test.d.ts +14 -0
  65. package/dist/local-path-codec.test.d.ts.map +1 -0
  66. package/dist/local-path-codec.test.js +102 -0
  67. package/dist/local-path-codec.test.js.map +1 -0
  68. package/dist/machine-auth.test.js +7 -0
  69. package/dist/machine-auth.test.js.map +1 -1
  70. package/dist/s3.d.ts +5 -0
  71. package/dist/s3.d.ts.map +1 -1
  72. package/dist/s3.js +129 -3
  73. package/dist/s3.js.map +1 -1
  74. package/dist/s3.symlink-materialize.test.d.ts +18 -0
  75. package/dist/s3.symlink-materialize.test.d.ts.map +1 -0
  76. package/dist/s3.symlink-materialize.test.js +394 -0
  77. package/dist/s3.symlink-materialize.test.js.map +1 -0
  78. package/dist/s3.test.js +19 -0
  79. package/dist/s3.test.js.map +1 -1
  80. package/dist/skill-telemetry.d.ts.map +1 -1
  81. package/dist/skill-telemetry.js +23 -2
  82. package/dist/skill-telemetry.js.map +1 -1
  83. package/dist/skill-telemetry.test.js +17 -0
  84. package/dist/skill-telemetry.test.js.map +1 -1
  85. package/dist/watcher.d.ts.map +1 -1
  86. package/dist/watcher.js +10 -1
  87. package/dist/watcher.js.map +1 -1
  88. package/dist/watcher.test.js +28 -0
  89. package/dist/watcher.test.js.map +1 -1
  90. package/package.json +1 -1
  91. package/src/bin/sync-runner-company.ts +18 -0
  92. package/src/bin/sync-runner-events.test.ts +31 -0
  93. package/src/bin/sync-runner-planning.test.ts +1 -0
  94. package/src/bin/sync-runner-planning.ts +46 -3
  95. package/src/bin/sync-runner-watch-loop.ts +19 -1
  96. package/src/bin/sync-runner.test.ts +236 -1
  97. package/src/bin/sync-runner.ts +57 -2
  98. package/src/cli/doctor.test.ts +581 -0
  99. package/src/cli/doctor.ts +640 -0
  100. package/src/cli/index.ts +14 -0
  101. package/src/cli/share.test.ts +1 -0
  102. package/src/cli/share.ts +6 -1
  103. package/src/cli/sync.test.ts +473 -1
  104. package/src/cli/sync.ts +310 -3
  105. package/src/cognito-auth.test.ts +5 -0
  106. package/src/index.ts +10 -0
  107. package/src/lib/conflict-file.ts +6 -1
  108. package/src/lib/conflict-index.ts +18 -2
  109. package/src/lib/conflict.test.ts +33 -0
  110. package/src/lib/machine-id.test.ts +10 -0
  111. package/src/local-path-codec.test.ts +138 -0
  112. package/src/local-path-codec.ts +66 -0
  113. package/src/machine-auth.test.ts +5 -0
  114. package/src/s3.symlink-materialize.test.ts +492 -0
  115. package/src/s3.test.ts +24 -0
  116. package/src/s3.ts +148 -4
  117. package/src/skill-telemetry.test.ts +19 -0
  118. package/src/skill-telemetry.ts +31 -2
  119. package/src/watcher.test.ts +33 -0
  120. package/src/watcher.ts +10 -1
package/src/cli/sync.ts CHANGED
@@ -19,7 +19,12 @@ import {
19
19
  } from "../telemetry-events.js";
20
20
  import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
21
21
  import { createSyncProgressRecorder } from "../sync-progress.js";
22
- import { localPathForVaultKey, vaultKeyForLocalPath } from "../local-path-codec.js";
22
+ import {
23
+ canonicalVaultKeySpelling,
24
+ localPathForVaultKey,
25
+ vaultKeyForLocalPath,
26
+ } from "../local-path-codec.js";
27
+ import { SKILLS_KEY_PREFIX } from "./doctor.js";
23
28
  import {
24
29
  downloadFile,
25
30
  listRemoteFiles,
@@ -307,6 +312,32 @@ export type SyncProgressEvent =
307
312
  count: number;
308
313
  samplePaths: string[];
309
314
  }
315
+ | {
316
+ /**
317
+ * Emitted at most ONCE per PULL leg when the leg ran under a
318
+ * membership-scoped `syncMode` (`"shared"` or `"custom"` — never `"all"`)
319
+ * AND one or more remote keys were skipped as out-of-scope
320
+ * (`filesOutOfScope > 0`). This turns the silent shared-vs-all gap into a
321
+ * VISIBLE, actionable surface. The reporter of feedback_d2082110 lost
322
+ * files across devices precisely because four memberships defaulted to
323
+ * `shared` rather than `all`, so not all vault content materialized on the
324
+ * second device — and nothing told them; they had to notice the gap and
325
+ * flip each membership to `all` by hand. This event names the gap and the
326
+ * lever (raise the membership's access level to `all`) so FULL
327
+ * materialization is a deliberate, visible choice rather than a silent
328
+ * omission.
329
+ *
330
+ * `count` is the number of remote keys skipped as out-of-scope on this
331
+ * leg; `samplePaths` carries up to 10 company-relative keys for display;
332
+ * `syncMode` is the active scoped mode. Distinct from the push-side
333
+ * `scope-excluded` (which reports what a grantee could not PUSH). Not
334
+ * emitted in `all` mode or when `count === 0` — no gap, no noise.
335
+ */
336
+ type: "scope-materialization-gap";
337
+ count: number;
338
+ samplePaths: string[];
339
+ syncMode: SyncMode;
340
+ }
310
341
  | {
311
342
  /**
312
343
  * Emitted at most ONCE per `share()` push leg when the base ignore
@@ -375,6 +406,27 @@ export type SyncProgressEvent =
375
406
  path: string;
376
407
  /** Server-compatible invalid-key code, such as INVALID_KEY_CONTROL_CHARS. */
377
408
  errorCode?: string;
409
+ }
410
+ | {
411
+ /**
412
+ * Emitted by the PULL leg once per remote key skipped because its
413
+ * spelling percent-decodes to the same logical path as an
414
+ * already-journaled key with a DIFFERENT spelling — the mixed-version
415
+ * amplifier (a pre-codec peer minting `%3A`/`%253A` twins of a
416
+ * canonical `:` key). Downloading the twin would materialize a junk
417
+ * local dir and fork the journal into a second key family for one
418
+ * logical path, so the established spelling wins and the twin is
419
+ * skipped. Like `skip-size-limit`, this is deliberately NOT
420
+ * `type: "error"` (policy hq-sync-deliberate-skip-not-fatal-error-exit2:
421
+ * a recurring benign per-file skip emitted as an error lands in the
422
+ * runner's `errors[]`, forces exit 2 on every pass, and the menubar
423
+ * Sentry-alerts each time). The US-003 vault doctor is the cleanup
424
+ * path that collapses the junk family itself.
425
+ */
426
+ type: "skip-junk-key-spelling";
427
+ path: string;
428
+ /** The already-journaled spelling that owns this logical path. */
429
+ journaledKey: string;
378
430
  };
379
431
 
380
432
  export interface SyncOptions {
@@ -856,6 +908,8 @@ async function syncWithOperationLockHeld(
856
908
  await verifyPlannedJournalTombstones(run, plan);
857
909
  executeJournalTombstoneDeletes(run, plan, counters);
858
910
 
911
+ emitScopeMaterializationGap(run, plan, counters);
912
+
859
913
  return finalizePullRun(run, plan, scopeRun, counters);
860
914
  }
861
915
 
@@ -1170,7 +1224,8 @@ async function executeConflictExecutor(
1170
1224
  item.action === "skip-personal-mode" ||
1171
1225
  item.action === "skip-unchanged" ||
1172
1226
  item.action === "skip-local-only" ||
1173
- item.action === "skip-stale-overlay-marker"
1227
+ item.action === "skip-stale-overlay-marker" ||
1228
+ item.action === "skip-overlay-marker-with-children"
1174
1229
  ) {
1175
1230
  counters.filesSkipped++;
1176
1231
  continue;
@@ -1182,6 +1237,18 @@ async function executeConflictExecutor(
1182
1237
  counters.filesOutOfScope++;
1183
1238
  continue;
1184
1239
  }
1240
+ if (item.action === "skip-junk-key-spelling") {
1241
+ // Deliberate, recoverable skip — counted as skipped and surfaced via a
1242
+ // dedicated warning event, never `type: "error"` (see the event doc /
1243
+ // policy hq-sync-deliberate-skip-not-fatal-error-exit2).
1244
+ counters.filesSkipped++;
1245
+ run.emit({
1246
+ type: "skip-junk-key-spelling",
1247
+ path: item.remoteFile.key,
1248
+ journaledKey: item.journaledKey,
1249
+ });
1250
+ continue;
1251
+ }
1185
1252
  if (item.action === "tombstone-delete") {
1186
1253
  executeFileTombstoneDelete(run, item, counters);
1187
1254
  continue;
@@ -1844,6 +1911,37 @@ function finalizePullRun(
1844
1911
  };
1845
1912
  }
1846
1913
 
1914
+ /**
1915
+ * Surface the shared-vs-all materialization gap (feedback_d2082110). When a
1916
+ * pull leg ran under a membership-scoped `syncMode` and skipped one or more
1917
+ * remote keys as out-of-scope, emit a single summary event so the operator
1918
+ * SEES that not all vault content materialized on this device and knows the
1919
+ * lever — raise the membership's access level to `all`. Silent in `all` mode
1920
+ * (nothing is scoped away) and when nothing fell out of scope: no gap, no
1921
+ * noise. Deliberately AFTER the transfer executors, so `filesOutOfScope` is
1922
+ * final; sample keys are read off the plan's `skip-out-of-scope` items (pure,
1923
+ * no I/O). This is the visible complement to the previously-silent
1924
+ * `SyncResult.filesOutOfScope` count.
1925
+ */
1926
+ function emitScopeMaterializationGap(
1927
+ run: PullRunContext,
1928
+ plan: PullPlan,
1929
+ counters: PullCounters,
1930
+ ): void {
1931
+ if (run.syncMode === "all") return;
1932
+ if (counters.filesOutOfScope <= 0) return;
1933
+ const samplePaths = plan.items
1934
+ .filter((item) => item.action === "skip-out-of-scope")
1935
+ .slice(0, 10)
1936
+ .map((item) => item.remoteFile.key);
1937
+ run.emit({
1938
+ type: "scope-materialization-gap",
1939
+ count: counters.filesOutOfScope,
1940
+ samplePaths,
1941
+ syncMode: run.syncMode,
1942
+ });
1943
+ }
1944
+
1847
1945
  /**
1848
1946
  * Decide whether a remote object present in the LIST is a GENUINE RE-CREATE
1849
1947
  * written AFTER a FILE_TOMBSTONE — in which case the tombstone is stale and the
@@ -2034,6 +2132,15 @@ type PullPlanItem =
2034
2132
  // no-action-needed case is never surfaced with the alarming "rm -rf the
2035
2133
  // local directory" reconciliation advice.
2036
2134
  | { action: "skip-stale-overlay-marker"; remoteFile: RemoteFile; localPath: string }
2135
+ // A directory-overlay marker (a single vault object at a key that ALSO has
2136
+ // child objects under it in the same LIST — a symlink record for a directory
2137
+ // mirrored from another tree) colliding with a local REAL directory whose
2138
+ // contents are already materialized. The marker can never be renamed over the
2139
+ // directory; its children carry the real content and materialize via their own
2140
+ // plan items. A benign, no-action skip — distinct from skip-local-only so the
2141
+ // case is never surfaced with the alarming (and, for a materialized directory,
2142
+ // data-destroying) "rm -rf the local directory to pull" advice. feedback_d2082110.
2143
+ | { action: "skip-overlay-marker-with-children"; remoteFile: RemoteFile; localPath: string }
2037
2144
  // Remote keys refused by ephemeral-mirror policy. The push walker has
2038
2145
  // refused to upload these since 5.33.0; the pull walker now refuses to
2039
2146
  // download them so legacy litter in cloud staging drains naturally.
@@ -2042,6 +2149,14 @@ type PullPlanItem =
2042
2149
  // the remote LIST (and accessible per STS) but deliberately not downloaded
2043
2150
  // because the membership's sync scope doesn't cover them.
2044
2151
  | { action: "skip-out-of-scope"; remoteFile: RemoteFile; localPath: string }
2152
+ | {
2153
+ // Mixed-version amplifier guard — see the `skip-junk-key-spelling`
2154
+ // event doc. Carries the journaled spelling for the warning surface.
2155
+ action: "skip-junk-key-spelling";
2156
+ remoteFile: RemoteFile;
2157
+ localPath: string;
2158
+ journaledKey: string;
2159
+ }
2045
2160
  // Remote key present in the LIST but carrying a FILE_TOMBSTONE that marks it
2046
2161
  // intentionally deleted (and the remote object is NOT a newer re-create). The
2047
2162
  // executor deletes any local copy and drops the journal entry — the
@@ -2139,6 +2254,39 @@ interface PullPlan {
2139
2254
  const PULL_INTENTIONAL_DELETE_MIN_ABS = 10;
2140
2255
  const PULL_INTENTIONAL_DELETE_RATIO = 0.1;
2141
2256
 
2257
+ /**
2258
+ * win32 only: true when an existing local symlink's TARGET exists but the
2259
+ * link cannot be traversed as that target's type — the file-flavor-link-at-
2260
+ * a-directory shape minted while the target was absent (or by an older
2261
+ * client). A dangling link is NOT broken-flavored (there is nothing to
2262
+ * repair until the target appears). POSIX links are flavorless: always
2263
+ * false off win32.
2264
+ */
2265
+ export function win32SymlinkFlavorBroken(
2266
+ localPath: string,
2267
+ win32: boolean = process.platform === "win32",
2268
+ ): boolean {
2269
+ if (!win32) return false;
2270
+ let target: string;
2271
+ try {
2272
+ target = fs.readlinkSync(localPath);
2273
+ } catch {
2274
+ return false;
2275
+ }
2276
+ const resolved = path.resolve(path.dirname(localPath), target);
2277
+ let targetIsDir: boolean;
2278
+ try {
2279
+ targetIsDir = fs.statSync(resolved).isDirectory();
2280
+ } catch {
2281
+ return false; // dangling target — nothing to repair yet
2282
+ }
2283
+ try {
2284
+ return fs.statSync(localPath).isDirectory() !== targetIsDir;
2285
+ } catch {
2286
+ return true; // target exists but the link cannot traverse: wrong flavor
2287
+ }
2288
+ }
2289
+
2142
2290
  function computePullPlan(
2143
2291
  remoteFiles: RemoteFile[],
2144
2292
  journal: SyncJournal,
@@ -2176,6 +2324,46 @@ function computePullPlan(
2176
2324
  localPath: string;
2177
2325
  }> = [];
2178
2326
 
2327
+ // Junk-spelling guard (mixed-version amplifier): index every journaled key
2328
+ // by its canonical (percent-decode fixpoint) form so a remote twin minted
2329
+ // under a different spelling is recognized before it materializes. When
2330
+ // several journaled spellings already share one canonical form (a
2331
+ // pre-guard fork), the exactly-canonical spelling is preferred as the
2332
+ // representative; entries journaled under their own exact spelling are
2333
+ // never gated by this map (see the guard below), so pre-existing families
2334
+ // keep syncing untouched until the vault doctor (US-003) collapses them.
2335
+ const journaledKeyByCanonical = new Map<string, string>();
2336
+ for (const journaledKey of Object.keys(journal.files)) {
2337
+ if (!journaledKey.startsWith(SKILLS_KEY_PREFIX)) continue;
2338
+ const canonical = canonicalVaultKeySpelling(journaledKey);
2339
+ const existing = journaledKeyByCanonical.get(canonical);
2340
+ if (existing === undefined || journaledKey === canonical) {
2341
+ journaledKeyByCanonical.set(canonical, journaledKey);
2342
+ }
2343
+ }
2344
+
2345
+ // Remote keys that ALSO appear as an ancestor of another remote key — i.e.
2346
+ // keys that carry child objects under `${key}/…` in this same LIST. The
2347
+ // vault stores a directory OVERLAY (a symlink into another tree, e.g. a
2348
+ // company skill mirrored from a shared repo) as a symlink RECORD at the
2349
+ // directory's own key AND stores the directory's contents as separate child
2350
+ // objects beneath it. A REAL directory, by contrast, only ever syncs as
2351
+ // those child objects — never as a single object AT its own key. So a single
2352
+ // remote object sitting at a key that also has children is unambiguously a
2353
+ // directory-overlay marker, not a regular file. Precomputed once (pure path
2354
+ // derivation, no I/O — the planner is synchronous) so the dir-vs-object
2355
+ // collision branch can tell an inert overlay marker apart from a genuine
2356
+ // file-vs-directory structural collision. feedback_d2082110.
2357
+ const remoteKeysWithChildren = new Set<string>();
2358
+ for (const rf of remoteFiles) {
2359
+ const key = toPosixKey(rf.key);
2360
+ let slash = key.indexOf("/");
2361
+ while (slash !== -1) {
2362
+ remoteKeysWithChildren.add(key.slice(0, slash));
2363
+ slash = key.indexOf("/", slash + 1);
2364
+ }
2365
+ }
2366
+
2179
2367
  for (const remoteFile of remoteFiles) {
2180
2368
  const invalidKey = classifyVaultKey(
2181
2369
  remoteFile.key,
@@ -2315,6 +2503,43 @@ function computePullPlan(
2315
2503
  continue;
2316
2504
  }
2317
2505
 
2506
+ // Junk-spelling guard — SKILL-FARM KEYS ONLY. The amplifier is specific
2507
+ // to `.claude/skills/<ns>:<skill>` wrapper spellings; outside that prefix,
2508
+ // keys whose percent-decode fixpoints collide (`notes/a b.md` vs a genuine
2509
+ // `notes/a%20b.md`) are DISTINCT files that must both pull.
2510
+ // Within the farm: a remote key that is NOT journaled under its own
2511
+ // spelling but whose percent-decode fixpoint matches an already-journaled
2512
+ // key with a different spelling is a mixed-version twin (`%3A`/`%253A`
2513
+ // generations of a canonical `:` key). Materializing it would mint junk
2514
+ // local dirs and fork the journal into a second key family for the same
2515
+ // logical path — skip it (loudly, never fatally; see the
2516
+ // `skip-junk-key-spelling` event doc) and leave the journaled spelling
2517
+ // authoritative for this machine.
2518
+ if (
2519
+ journalEntry === undefined &&
2520
+ remoteFile.key.startsWith(SKILLS_KEY_PREFIX) &&
2521
+ // Gate NON-canonical remote spellings only. A remote key that already
2522
+ // IS the canonical spelling must always be downloadable — after the
2523
+ // doctor collapses the vault, a machine whose journal still holds only
2524
+ // the junk spelling would otherwise skip the canonical object forever
2525
+ // (and the delete-resync twin protection keeps that stale junk entry),
2526
+ // deadlocking it out of every future update.
2527
+ canonicalVaultKeySpelling(remoteFile.key) !== remoteFile.key
2528
+ ) {
2529
+ const journaledTwin = journaledKeyByCanonical.get(
2530
+ canonicalVaultKeySpelling(remoteFile.key),
2531
+ );
2532
+ if (journaledTwin !== undefined && journaledTwin !== remoteFile.key) {
2533
+ items.push({
2534
+ action: "skip-junk-key-spelling",
2535
+ remoteFile,
2536
+ localPath,
2537
+ journaledKey: journaledTwin,
2538
+ });
2539
+ continue;
2540
+ }
2541
+ }
2542
+
2318
2543
  // ── FILE_TOMBSTONE consult (delete-resync) ───────────────────────────────
2319
2544
  // A remote object present in the LIST may be an intentionally-deleted key
2320
2545
  // (a peer re-pushed it, or its delete-marker hasn't propagated to this
@@ -2437,6 +2662,30 @@ function computePullPlan(
2437
2662
  });
2438
2663
  continue;
2439
2664
  }
2665
+ // Directory-overlay marker vs. a locally-materialized directory:
2666
+ // the remote object at this key carries CHILD objects under it in the
2667
+ // same LIST (`remoteKeysWithChildren`), so it is a symlink/overlay
2668
+ // record for a directory — e.g. the DPP vault's `skills/cody-copywriter`
2669
+ // overlay whose 13 child objects are already materialized locally. The
2670
+ // marker can never be renamed over the real directory, and its children
2671
+ // carry the actual content and materialize via their own plan items, so
2672
+ // the marker is a benign, no-action skip. The generic "rm -rf the local
2673
+ // directory to pull" advice below would tell the operator to DELETE the
2674
+ // materialized (and possibly locally-authored) contents to install an
2675
+ // inert link, and it recurred on EVERY sync cycle. feedback_d2082110.
2676
+ if (remoteKeysWithChildren.has(toPosixKey(remoteFile.key))) {
2677
+ console.error(
2678
+ ` Note: ${remoteFile.key} is a directory-overlay marker whose ` +
2679
+ `contents already exist locally as a directory; keeping the ` +
2680
+ `local directory and ignoring the marker (no action needed).`,
2681
+ );
2682
+ items.push({
2683
+ action: "skip-overlay-marker-with-children",
2684
+ remoteFile,
2685
+ localPath,
2686
+ });
2687
+ continue;
2688
+ }
2440
2689
  console.error(
2441
2690
  ` Warning: ${remoteFile.key} exists locally as a directory; ` +
2442
2691
  `cloud has a single object at this key. Skipping; manual ` +
@@ -2537,6 +2786,25 @@ function computePullPlan(
2537
2786
  continue;
2538
2787
  }
2539
2788
  if (journalEntry && !localChanged && !remoteChanged) {
2789
+ // Flavor-health repair (win32 skill farm): a link journaled while its
2790
+ // target was absent was minted file-flavor; once the target directory
2791
+ // exists the hashes still match (hash = sha256(target)) so this
2792
+ // branch would skip it forever, leaving the wrapper broken. Re-download
2793
+ // instead — the materializer's flavor-aware path recreates it 'dir'.
2794
+ if (
2795
+ isLocalSymlink &&
2796
+ remoteFile.key.startsWith(SKILLS_KEY_PREFIX) &&
2797
+ win32SymlinkFlavorBroken(localPath)
2798
+ ) {
2799
+ items.push({
2800
+ action: "download",
2801
+ remoteFile,
2802
+ localPath,
2803
+ isNew: false,
2804
+ localSnapshot: plannedLocalSnapshot,
2805
+ });
2806
+ continue;
2807
+ }
2540
2808
  items.push({ action: "skip-unchanged", remoteFile, localPath });
2541
2809
  continue;
2542
2810
  }
@@ -2730,7 +2998,20 @@ function computePullPlan(
2730
2998
  // as a local delete + journal removal. Symmetric to the push side's
2731
2999
  // `propagateDeletes` plan in share.ts.
2732
3000
  const remoteKeySet = new Set<string>();
2733
- for (const rf of remoteFiles) remoteKeySet.add(rf.key);
3001
+ // Skill-farm twin protection (companion to the junk-spelling pull guard):
3002
+ // when the LIST holds only a junk spelling of a journaled canonical key,
3003
+ // the logical object is still remotely present — a raw has() would class
3004
+ // the canonical journal entry as remote-deleted, unlink the good local
3005
+ // wrapper, and drop the journal entry (after which the next pull would
3006
+ // materialize the junk spelling the guard just refused). Track canonical
3007
+ // forms of listed skill keys and consult them below.
3008
+ const remoteCanonicalSkillKeys = new Set<string>();
3009
+ for (const rf of remoteFiles) {
3010
+ remoteKeySet.add(rf.key);
3011
+ if (rf.key.startsWith(SKILLS_KEY_PREFIX)) {
3012
+ remoteCanonicalSkillKeys.add(canonicalVaultKeySpelling(rf.key));
3013
+ }
3014
+ }
2734
3015
  const tombstones: PullPlan["tombstones"] = [];
2735
3016
  for (const key of Object.keys(journal.files)) {
2736
3017
  // Compare membership in POSIX space. A pre-5.47.2 Windows journal key can
@@ -2740,6 +3021,18 @@ function computePullPlan(
2740
3021
  // POSIX compare is defense-in-depth (ridge data-loss, feedback_b8d09d0f).
2741
3022
  const posixKey = toPosixKey(key);
2742
3023
  if (remoteKeySet.has(posixKey)) continue;
3024
+ if (
3025
+ posixKey.startsWith(SKILLS_KEY_PREFIX) &&
3026
+ remoteCanonicalSkillKeys.has(canonicalVaultKeySpelling(posixKey)) &&
3027
+ // An EXACT FILE_TOMBSTONE for the journaled key is an authoritative
3028
+ // delete and wins over the twin protection — a lingering junk
3029
+ // spelling in the LIST must not keep a deliberately-deleted wrapper
3030
+ // alive forever. (The key is absent from the LIST here, so there is
3031
+ // no newer re-create to defer to.)
3032
+ fileTombstones.get(posixKey) === undefined
3033
+ ) {
3034
+ continue;
3035
+ }
2743
3036
  const localPath = resolveContainedVaultPath(companyRoot, key);
2744
3037
  if (localPath === null) continue;
2745
3038
  // PersonalMode key gating — mirror the download branch. Local (non-cloud)
@@ -2896,6 +3189,16 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
2896
3189
  if (event.count > event.samplePaths.length) {
2897
3190
  console.log(` ... and ${event.count - event.samplePaths.length} more`);
2898
3191
  }
3192
+ } else if (event.type === "scope-materialization-gap") {
3193
+ console.warn(
3194
+ ` ! ${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":`,
3195
+ );
3196
+ for (const p of event.samplePaths) {
3197
+ console.warn(` · ${p}`);
3198
+ }
3199
+ if (event.count > event.samplePaths.length) {
3200
+ console.warn(` ... and ${event.count - event.samplePaths.length} more`);
3201
+ }
2899
3202
  } else if (event.type === "ignore-excluded") {
2900
3203
  console.warn(
2901
3204
  ` ! ${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):`,
@@ -2915,5 +3218,9 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
2915
3218
  console.warn(
2916
3219
  ` ! ${event.path} skipped — invalid vault key (${event.errorCode ?? "INVALID_KEY_COMPANIES_SCOPED"})`,
2917
3220
  );
3221
+ } else if (event.type === "skip-junk-key-spelling") {
3222
+ console.warn(
3223
+ ` ! ${event.path} skipped — junk key spelling; this logical path is already synced as ${event.journaledKey} (mixed-version peer artifact; the vault doctor collapses the family)`,
3224
+ );
2918
3225
  }
2919
3226
  }
@@ -14,18 +14,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
14
  // Sandbox HOME *before* importing the module — it reads os.homedir() at load
15
15
  // time to compute the cache file path.
16
16
  let originalHome: string | undefined;
17
+ let originalUserProfile: string | undefined;
17
18
  let tmpHome: string;
18
19
 
19
20
  beforeEach(() => {
20
21
  originalHome = process.env.HOME;
22
+ originalUserProfile = process.env.USERPROFILE;
21
23
  tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "hq-cognito-auth-test-"));
22
24
  process.env.HOME = tmpHome;
25
+ process.env.USERPROFILE = tmpHome;
23
26
  vi.resetModules();
24
27
  });
25
28
 
26
29
  afterEach(() => {
27
30
  if (originalHome === undefined) delete process.env.HOME;
28
31
  else process.env.HOME = originalHome;
32
+ if (originalUserProfile === undefined) delete process.env.USERPROFILE;
33
+ else process.env.USERPROFILE = originalUserProfile;
29
34
  fs.rmSync(tmpHome, { recursive: true, force: true });
30
35
  vi.unstubAllGlobals();
31
36
  vi.restoreAllMocks();
package/src/index.ts CHANGED
@@ -300,6 +300,16 @@ export type { ReindexOptions, ReindexResult } from "./cli/index.js";
300
300
  export { rescue, buildRescueArgs } from "./cli/index.js";
301
301
  export type { RescueOptions, RescueResult } from "./cli/index.js";
302
302
 
303
+ // `hq sync doctor` — skill-key dedupe + local farm GC (US-003). Consumed by
304
+ // @indigoai-us/hq-cli's `sync doctor` subcommand.
305
+ export { syncDoctor, SKILLS_KEY_PREFIX } from "./cli/index.js";
306
+ export type {
307
+ SyncDoctorOptions,
308
+ SyncDoctorResult,
309
+ SyncDoctorPlan,
310
+ DoctorStore,
311
+ } from "./cli/index.js";
312
+
303
313
  export type {
304
314
  EntityContext,
305
315
  VaultCredentials,
@@ -18,6 +18,8 @@
18
18
  import * as fs from "fs";
19
19
  import * as path from "path";
20
20
 
21
+ import { localPathForVaultKey } from "../local-path-codec.js";
22
+
21
23
  export { readShortMachineId, getOrCreateMachineId } from "./machine-id.js";
22
24
 
23
25
  /**
@@ -58,7 +60,10 @@ export function writeConflictFile(
58
60
  conflictRelative: string,
59
61
  contents: Buffer,
60
62
  ): void {
61
- const abs = path.join(hqRoot, conflictRelative);
63
+ // Codec boundary: conflict paths are canonical (hq-root-relative,
64
+ // forward-slash) keys, so the mirror must land at the encoded local name
65
+ // on win32 — a raw join of a colon key can never be created there.
66
+ const abs = localPathForVaultKey(hqRoot, conflictRelative);
62
67
  fs.mkdirSync(path.dirname(abs), { recursive: true });
63
68
  fs.writeFileSync(abs, contents);
64
69
  }
@@ -22,6 +22,7 @@
22
22
  import * as crypto from "crypto";
23
23
  import * as fs from "fs";
24
24
  import * as path from "path";
25
+ import { localPathForVaultKey } from "../local-path-codec.js";
25
26
  import type { ConflictIndex, ConflictIndexEntry } from "../types.js";
26
27
 
27
28
  const CONFLICTS_DIR = ".hq-conflicts";
@@ -136,6 +137,13 @@ export interface PruneConflictIndexResult {
136
137
  removedMirrors: number;
137
138
  /** Rows kept (genuine divergence, or unprovable — fail-safe retained). */
138
139
  kept: number;
140
+ /**
141
+ * Up to 10 ORIGINAL (non-mirror) paths of the kept rows, for a
142
+ * post-sync reconcile surface (`conflicts-remaining`). Empty when
143
+ * `kept === 0`. Lets a caller name the preserved conflict variants a human
144
+ * still has to resolve without re-reading the index. feedback_d2082110.
145
+ */
146
+ keptSamplePaths: string[];
139
147
  }
140
148
 
141
149
  /**
@@ -198,6 +206,7 @@ export function pruneConflictIndex(hqRoot: string): PruneConflictIndexResult {
198
206
  prunedIdentical: 0,
199
207
  removedMirrors: 0,
200
208
  kept: 0,
209
+ keptSamplePaths: [],
201
210
  };
202
211
 
203
212
  const index = readConflictIndex(hqRoot);
@@ -207,8 +216,12 @@ export function pruneConflictIndex(hqRoot: string): PruneConflictIndexResult {
207
216
  const mirrorsToRemove: string[] = [];
208
217
 
209
218
  for (const entry of index.conflicts) {
210
- const mirrorAbs = path.join(hqRoot, entry.conflictPath);
211
- const originalAbs = path.join(hqRoot, entry.originalPath);
219
+ // Codec boundary: index rows store canonical (hq-root-relative) keys, so
220
+ // both probes must resolve through the codec — a raw join of a win32
221
+ // colon key always lstat-fails, which would mis-classify a live encoded
222
+ // mirror as an orphaned row and silently drop it.
223
+ const mirrorAbs = localPathForVaultKey(hqRoot, entry.conflictPath);
224
+ const originalAbs = localPathForVaultKey(hqRoot, entry.originalPath);
212
225
 
213
226
  let mirrorStat: fs.Stats;
214
227
  try {
@@ -254,6 +267,9 @@ export function pruneConflictIndex(hqRoot: string): PruneConflictIndexResult {
254
267
  }
255
268
  }
256
269
 
270
+ // Name the preserved divergences for the post-sync reconcile surface.
271
+ result.keptSamplePaths = kept.slice(0, 10).map((e) => e.originalPath);
272
+
257
273
  // No row dropped → leave the file (and its mtime) untouched.
258
274
  if (kept.length === index.conflicts.length) return result;
259
275
 
@@ -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
  });
@@ -17,22 +17,27 @@ function freshTmp(prefix: string): string {
17
17
 
18
18
  describe("getOrCreateMachineId (four-tier resolver)", () => {
19
19
  let originalHome: string | undefined;
20
+ let originalUserProfile: string | undefined;
20
21
  let originalEnvId: string | undefined;
21
22
  let tmpHome: string;
22
23
  let tmpHqRoot: string;
23
24
 
24
25
  beforeEach(() => {
25
26
  originalHome = process.env.HOME;
27
+ originalUserProfile = process.env.USERPROFILE;
26
28
  originalEnvId = process.env.HQ_MACHINE_ID;
27
29
  delete process.env.HQ_MACHINE_ID;
28
30
  tmpHome = freshTmp("hq-machineid-home-");
29
31
  tmpHqRoot = freshTmp("hq-machineid-root-");
30
32
  process.env.HOME = tmpHome;
33
+ process.env.USERPROFILE = tmpHome;
31
34
  });
32
35
 
33
36
  afterEach(() => {
34
37
  if (originalHome) process.env.HOME = originalHome;
35
38
  else delete process.env.HOME;
39
+ if (originalUserProfile) process.env.USERPROFILE = originalUserProfile;
40
+ else delete process.env.USERPROFILE;
36
41
  if (originalEnvId !== undefined) process.env.HQ_MACHINE_ID = originalEnvId;
37
42
  else delete process.env.HQ_MACHINE_ID;
38
43
  fs.rmSync(tmpHome, { recursive: true, force: true });
@@ -135,22 +140,27 @@ describe("getOrCreateMachineId (four-tier resolver)", () => {
135
140
 
136
141
  describe("readShortMachineId", () => {
137
142
  let originalHome: string | undefined;
143
+ let originalUserProfile: string | undefined;
138
144
  let originalEnvId: string | undefined;
139
145
  let tmpHome: string;
140
146
  let tmpHqRoot: string;
141
147
 
142
148
  beforeEach(() => {
143
149
  originalHome = process.env.HOME;
150
+ originalUserProfile = process.env.USERPROFILE;
144
151
  originalEnvId = process.env.HQ_MACHINE_ID;
145
152
  delete process.env.HQ_MACHINE_ID;
146
153
  tmpHome = freshTmp("hq-machineid-short-home-");
147
154
  tmpHqRoot = freshTmp("hq-machineid-short-root-");
148
155
  process.env.HOME = tmpHome;
156
+ process.env.USERPROFILE = tmpHome;
149
157
  });
150
158
 
151
159
  afterEach(() => {
152
160
  if (originalHome) process.env.HOME = originalHome;
153
161
  else delete process.env.HOME;
162
+ if (originalUserProfile) process.env.USERPROFILE = originalUserProfile;
163
+ else delete process.env.USERPROFILE;
154
164
  if (originalEnvId !== undefined) process.env.HQ_MACHINE_ID = originalEnvId;
155
165
  else delete process.env.HQ_MACHINE_ID;
156
166
  fs.rmSync(tmpHome, { recursive: true, force: true });