@indigoai-us/hq-cloud 5.32.0 → 5.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/bin/sync-runner.d.ts +9 -0
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +53 -27
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +69 -0
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/share.d.ts +60 -4
  8. package/dist/cli/share.d.ts.map +1 -1
  9. package/dist/cli/share.js +129 -8
  10. package/dist/cli/share.js.map +1 -1
  11. package/dist/cli/share.test.js +104 -6
  12. package/dist/cli/share.test.js.map +1 -1
  13. package/dist/cli/sync.d.ts +20 -0
  14. package/dist/cli/sync.d.ts.map +1 -1
  15. package/dist/cli/sync.js +260 -7
  16. package/dist/cli/sync.js.map +1 -1
  17. package/dist/cli/sync.test.js +469 -0
  18. package/dist/cli/sync.test.js.map +1 -1
  19. package/dist/ignore.d.ts.map +1 -1
  20. package/dist/ignore.js +7 -1
  21. package/dist/ignore.js.map +1 -1
  22. package/dist/ignore.test.js +19 -3
  23. package/dist/ignore.test.js.map +1 -1
  24. package/dist/lib/conflict-file.d.ts +7 -6
  25. package/dist/lib/conflict-file.d.ts.map +1 -1
  26. package/dist/lib/conflict-file.js +7 -27
  27. package/dist/lib/conflict-file.js.map +1 -1
  28. package/dist/lib/conflict.test.d.ts +4 -3
  29. package/dist/lib/conflict.test.d.ts.map +1 -1
  30. package/dist/lib/conflict.test.js +5 -33
  31. package/dist/lib/conflict.test.js.map +1 -1
  32. package/dist/lib/machine-id.d.ts +108 -0
  33. package/dist/lib/machine-id.d.ts.map +1 -0
  34. package/dist/lib/machine-id.js +170 -0
  35. package/dist/lib/machine-id.js.map +1 -0
  36. package/dist/lib/machine-id.test.d.ts +8 -0
  37. package/dist/lib/machine-id.test.d.ts.map +1 -0
  38. package/dist/lib/machine-id.test.js +195 -0
  39. package/dist/lib/machine-id.test.js.map +1 -0
  40. package/dist/s3.d.ts +21 -0
  41. package/dist/s3.d.ts.map +1 -1
  42. package/dist/s3.js +69 -2
  43. package/dist/s3.js.map +1 -1
  44. package/dist/s3.test.js +129 -2
  45. package/dist/s3.test.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/bin/sync-runner.test.ts +85 -0
  48. package/src/bin/sync-runner.ts +62 -25
  49. package/src/cli/share.test.ts +115 -6
  50. package/src/cli/share.ts +149 -9
  51. package/src/cli/sync.test.ts +529 -0
  52. package/src/cli/sync.ts +295 -8
  53. package/src/ignore.test.ts +20 -3
  54. package/src/ignore.ts +7 -1
  55. package/src/lib/conflict-file.ts +7 -27
  56. package/src/lib/conflict.test.ts +4 -40
  57. package/src/lib/machine-id.test.ts +221 -0
  58. package/src/lib/machine-id.ts +175 -0
  59. package/src/s3.test.ts +142 -2
  60. package/src/s3.ts +71 -2
@@ -89,6 +89,7 @@ import type { ConflictStrategy } from "../cli/conflict.js";
89
89
  import type { UploadAuthor } from "../s3.js";
90
90
  import { collectAndSendTelemetry } from "../telemetry.js";
91
91
  import { describeError } from "../lib/describe-error.js";
92
+ import { getOrCreateMachineId } from "../lib/machine-id.js";
92
93
  import {
93
94
  TreeWatcher,
94
95
  WatchPushDriver,
@@ -258,6 +259,15 @@ export type RunnerEvent =
258
259
  */
259
260
  filesTombstoned: number;
260
261
  filesRefusedStale: number;
262
+ /**
263
+ * Paths corresponding to `filesRefusedStale`, capped at 50 (mirrors
264
+ * `newFiles` cap). Surfaced on the `complete` event so operators
265
+ * can triage the recurring `filesRefusedStale: 205` signal that
266
+ * the 5.33.0 deep-test flagged as untriageable — the count alone
267
+ * is impossible to investigate after the per-file
268
+ * `delete-refused-stale-etag` events scroll off.
269
+ */
270
+ filesRefusedStalePaths: string[];
261
271
  filesExcludedByPolicy: number;
262
272
  } & SyncResult)
263
273
  | {
@@ -593,24 +603,17 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
593
603
  async function defaultCollectTelemetry(
594
604
  client: VaultClientSurface,
595
605
  clientIsStub: boolean,
606
+ hqRoot: string,
596
607
  ): Promise<void> {
597
608
  if (clientIsStub) return;
598
609
  try {
599
- // machineId: prefer ~/.hq/menubar.json (set by the menubar app on first
600
- // launch). When absent — e.g. fresh CLI-only install fall back to a
601
- // value that makes the row identifiable as "unattributed" rather than
602
- // crashing or spoofing another machine's id.
603
- const menubarPath = path.join(os.homedir(), ".hq", "menubar.json");
604
- let machineId = "unknown";
605
- try {
606
- const raw = await fs.promises.readFile(menubarPath, "utf-8");
607
- const parsed = JSON.parse(raw) as { machineId?: unknown };
608
- if (typeof parsed.machineId === "string" && parsed.machineId.length > 0) {
609
- machineId = parsed.machineId;
610
- }
611
- } catch {
612
- // No menubar.json — proceed with "unknown".
613
- }
610
+ // machineId: hq-cloud owns provisioning via `<hqRoot>/.hq/machine-id`
611
+ // (see `src/lib/machine-id.ts`). The resolver migrates forward from
612
+ // any legacy `~/.hq/menubar.json` value on first call, then becomes
613
+ // self-sufficient. On a clean Linux outpost (no menubar app), a fresh
614
+ // UUID is generated + persisted, so this row is attributable rather
615
+ // than collapsing onto the legacy `"unknown"` sentinel.
616
+ const machineId = getOrCreateMachineId(hqRoot);
614
617
 
615
618
  // installerVersion: callers (the Tauri menubar) set this when spawning
616
619
  // the runner so the historical `installerVersion` dimension on
@@ -954,6 +957,7 @@ export async function runRunner(
954
957
  filesDeleted: 0,
955
958
  filesTombstoned: 0,
956
959
  filesRefusedStale: 0,
960
+ filesRefusedStalePaths: [],
957
961
  filesExcludedByPolicy: 0,
958
962
  conflictPaths: [],
959
963
  aborted: false,
@@ -967,6 +971,8 @@ export async function runRunner(
967
971
  aborted: false,
968
972
  newFiles: [],
969
973
  newFilesCount: 0,
974
+ filesExcludedByPolicy: 0,
975
+ filesTombstoned: 0,
970
976
  };
971
977
 
972
978
  // Push first so a subsequent pull doesn't overwrite files we were about
@@ -1094,13 +1100,24 @@ export async function runRunner(
1094
1100
  });
1095
1101
  }
1096
1102
 
1097
- // Concat push + pull conflict paths into a single per-company list.
1098
- // Both arrays are always present (defaulted to []) so consumers can
1099
- // treat `conflictPaths` as authoritative without a falsy check.
1100
- const mergedConflictPaths = [
1101
- ...pullResult.conflictPaths,
1102
- ...pushResult.conflictPaths,
1103
- ];
1103
+ // Concat push + pull conflict paths into a single per-company list,
1104
+ // then dedupe a key that legitimately conflicts on both halves of
1105
+ // a bidirectional run (e.g. `.hq/install-manifest.json` round-trip
1106
+ // before Fix #1) appears twice in the concat but represents a single
1107
+ // logical conflict for the operator. Bug #3 in the 5.33.0 deep-test:
1108
+ // every round produced `conflictPaths: [X, X]` and `conflicts: 2`,
1109
+ // double-counting the conflict in every metric downstream. The push
1110
+ // and pull halves each emit a `conflict` event in their own direction
1111
+ // (preserved on the event stream for tracing); only the merged
1112
+ // result list is collapsed. Stable first-seen order is preserved so
1113
+ // consumers can rely on the pull entry coming before its push twin.
1114
+ const seenConflictPaths = new Set<string>();
1115
+ const mergedConflictPaths: string[] = [];
1116
+ for (const p of [...pullResult.conflictPaths, ...pushResult.conflictPaths]) {
1117
+ if (seenConflictPaths.has(p)) continue;
1118
+ seenConflictPaths.add(p);
1119
+ mergedConflictPaths.push(p);
1120
+ }
1104
1121
  const aborted = pullResult.aborted || pushResult.aborted;
1105
1122
 
1106
1123
  // Overwrite the progress-derived counts with the authoritative numbers
@@ -1131,9 +1148,28 @@ export async function runRunner(
1131
1148
  // from the legacy-engine `None` and useful when the UI wants to
1132
1149
  // distinguish "engine ran, nothing tombstoned" from "engine
1133
1150
  // didn't report".
1134
- filesTombstoned: pushResult.filesTombstoned,
1151
+ // Tombstones now flow on both legs:
1152
+ // - push side: `ShareResult.filesTombstoned` (remote was already
1153
+ // 404 at HEAD time, journal entry dropped).
1154
+ // - pull side: `SyncResult.filesTombstoned` (Bug #9 — journal-
1155
+ // known key missing from remote LIST, applied as local delete).
1156
+ // Sum them so the menubar's `SyncCompleteEvent` reflects the total
1157
+ // delete-propagation activity for that company across the run.
1158
+ filesTombstoned: pushResult.filesTombstoned + pullResult.filesTombstoned,
1135
1159
  filesRefusedStale: pushResult.filesRefusedStale,
1136
- filesExcludedByPolicy: pushResult.filesExcludedByPolicy,
1160
+ // Bonus diagnostic: surface the paths so operators can triage the
1161
+ // recurring `filesRefusedStale: N` signal — the count alone was
1162
+ // untriageable per the 5.33.0 deep-test report's "205 issue".
1163
+ // Pre-capped at 50 by share() itself.
1164
+ filesRefusedStalePaths: pushResult.filesRefusedStalePaths,
1165
+ // Pull side now reports an `filesExcludedByPolicy` too (Bug #2 —
1166
+ // ephemeral conflict-mirror refusals in the pull walker). Sum
1167
+ // both legs so the `complete` event reports total excluded across
1168
+ // the full bidirectional pass; pre-fix the pull half silently
1169
+ // pushed legacy `.conflict-*` litter into clean trees with the
1170
+ // same counter showing 0.
1171
+ filesExcludedByPolicy:
1172
+ pushResult.filesExcludedByPolicy + pullResult.filesExcludedByPolicy,
1137
1173
  // Sourced from the merged path list so push-side conflicts are
1138
1174
  // counted too — `ShareResult` doesn't expose a numeric counter,
1139
1175
  // and using `pullResult.conflicts` alone silently dropped any
@@ -1180,6 +1216,7 @@ export async function runRunner(
1180
1216
  // throw.
1181
1217
  filesTombstoned: 0,
1182
1218
  filesRefusedStale: 0,
1219
+ filesRefusedStalePaths: [],
1183
1220
  filesExcludedByPolicy: 0,
1184
1221
  conflicts: 0,
1185
1222
  conflictPaths: [],
@@ -1245,7 +1282,7 @@ export async function runRunner(
1245
1282
  // which naturally bounds the outer wait.
1246
1283
  const telemetryFn =
1247
1284
  deps.collectTelemetry ??
1248
- (() => defaultCollectTelemetry(client, deps.createVaultClient !== undefined));
1285
+ (() => defaultCollectTelemetry(client, deps.createVaultClient !== undefined, parsed.hqRoot));
1249
1286
  await telemetryFn().catch(() => undefined);
1250
1287
 
1251
1288
  emit({
@@ -378,6 +378,95 @@ describe("share", () => {
378
378
  });
379
379
  });
380
380
 
381
+ it("first-time-upload-with-cloud-collision: emits conflict + writes mirror under --on-conflict keep (Bug #7)", async () => {
382
+ // Bug #7 (data-loss class) from the 5.33.0 deep test: when a file has
383
+ // NO prior journal entry (fresh upload from this machine) but the
384
+ // remote already has a different object at that key (a peer pushed
385
+ // first), the previous detector required \`!!journalEntry\` for BOTH
386
+ // localChanged and remoteChanged, so the whole \`localChanged &&
387
+ // remoteChanged\` predicate evaluated false. Push fell through to
388
+ // an unconditional PUT, silently overwriting the peer's content
389
+ // without recording a conflict event or writing a mirror.
390
+ //
391
+ // The verification report's V7 isolated the mechanism: the bug is
392
+ // independent of \`--on-conflict\` mode — the conflict-detection
393
+ // branch is keyed on "do I have a prior journal entry?", not on the
394
+ // policy flag. \`s13-conflict.txt\` (had a journal entry) correctly
395
+ // aborted under --on-conflict abort; \`v7b-fresh-collision.txt\`
396
+ // (no journal entry) was never even reported.
397
+ //
398
+ // Fix: even on first-time upload, if HEAD shows remote exists and
399
+ // its content (etag) differs from what we'd put, emit a conflict.
400
+ // Under keep, the existing pull-side mirror routine downloads the
401
+ // remote bytes to \`<orig>.conflict-<ts>-<short>\` so both versions
402
+ // survive on disk; under abort, the company aborts before PUT.
403
+ const companyRoot = path.join(tmpDir, "companies", "acme");
404
+ fs.mkdirSync(companyRoot, { recursive: true });
405
+ const testFile = path.join(companyRoot, "v7b-fresh.txt");
406
+ fs.writeFileSync(testFile, "macos-fresh-collision-content");
407
+
408
+ // Remote exists with a different etag — and there is NO journal
409
+ // entry for this path (this is the first push from this machine).
410
+ vi.mocked(headRemoteFile).mockResolvedValueOnce({
411
+ lastModified: new Date(),
412
+ etag: '"remote-version-etag"',
413
+ size: 40,
414
+ });
415
+
416
+ // No journal file written — first-time upload contract.
417
+ const events: unknown[] = [];
418
+ const result = await share({
419
+ paths: [testFile],
420
+ company: "acme",
421
+ vaultConfig: mockConfig,
422
+ hqRoot: tmpDir,
423
+ onConflict: "keep",
424
+ onEvent: (e) => events.push(e),
425
+ });
426
+
427
+ expect(result.conflictPaths).toEqual(["v7b-fresh.txt"]);
428
+ // Under keep, local stays as-is (no upload) and the mirror records
429
+ // the remote bytes so neither version is lost.
430
+ expect(result.filesUploaded).toBe(0);
431
+ const conflicts = events.filter(
432
+ (e): e is { type: "conflict"; path: string } =>
433
+ typeof e === "object" && e !== null && (e as { type?: string }).type === "conflict",
434
+ );
435
+ expect(conflicts).toHaveLength(1);
436
+ expect(conflicts[0].path).toBe("v7b-fresh.txt");
437
+ // Local file unmodified — it must still contain the local content.
438
+ expect(fs.readFileSync(testFile, "utf-8")).toBe("macos-fresh-collision-content");
439
+ });
440
+
441
+ it("first-time-upload-with-cloud-collision: aborts company under --on-conflict abort (Bug #7)", async () => {
442
+ // Symmetric to the keep case: when --on-conflict abort is passed, a
443
+ // first-time-upload-with-cloud-collision must abort the share leg
444
+ // before the PUT (preserving remote intact) and surface
445
+ // \`aborted: true\` + \`conflicts: 1\` in the result.
446
+ const companyRoot = path.join(tmpDir, "companies", "acme");
447
+ fs.mkdirSync(companyRoot, { recursive: true });
448
+ const testFile = path.join(companyRoot, "v7b-abort.txt");
449
+ fs.writeFileSync(testFile, "local-version");
450
+
451
+ vi.mocked(headRemoteFile).mockResolvedValueOnce({
452
+ lastModified: new Date(),
453
+ etag: '"different-remote"',
454
+ size: 99,
455
+ });
456
+
457
+ const result = await share({
458
+ paths: [testFile],
459
+ company: "acme",
460
+ vaultConfig: mockConfig,
461
+ hqRoot: tmpDir,
462
+ onConflict: "abort",
463
+ });
464
+
465
+ expect(result.aborted).toBe(true);
466
+ expect(result.conflictPaths).toEqual(["v7b-abort.txt"]);
467
+ expect(result.filesUploaded).toBe(0);
468
+ });
469
+
381
470
  it("uploads (no conflict) when only the local side changed since last sync", async () => {
382
471
  // Regression for hq-cloud#<conflict-detection>: a local edit to a file
383
472
  // that exists on S3 used to trigger a push conflict because the
@@ -2454,6 +2543,23 @@ describe("isEphemeralPath (conflict-mirror pattern contract)", () => {
2454
2543
  ["foo.conflict-2026-05-19T17-05-56Z-deadbeef.md", true],
2455
2544
  // Non-markdown extensions also valid (sh scripts, ts files, etc.).
2456
2545
  ["foo.sh.conflict-2026-05-19T17-05-56Z-abc123.sh", true],
2546
+ // ── extensionless originals (regression: `path.extname('.gitignore')`
2547
+ // returns '' in Node, so `buildConflictPath` produces no trailing
2548
+ // `.<ext>` segment for hidden-but-extensionless files).
2549
+ [".gitignore.conflict-2026-05-23T19-51-38Z-4dff71", true],
2550
+ [".hqignore.conflict-2026-05-23T19-51-38Z-4dff71", true],
2551
+ [".agents/skills.conflict-2026-05-19T17-07-01Z-0a513b", true],
2552
+ // ── legacy "unknown" machine token (regression: hosts without
2553
+ // `~/.hq/menubar.json` pre-Fix-3 fell through to the literal string
2554
+ // `"unknown"`, which `[a-f0-9]+` refused. Producer side is closed in
2555
+ // `../lib/machine-id.ts`, but the regex still must filter the
2556
+ // already-on-disk legacy files so the next push removes them).
2557
+ [".gitignore.conflict-2026-05-15T15-10-35Z-unknown", true],
2558
+ [".agents/skills.conflict-2026-05-15T15-10-35Z-unknown", true],
2559
+ ["notes.md.conflict-2026-05-15T15-10-35Z-unknown.md", true],
2560
+ [".hq/install-manifest.json.conflict-2026-05-15T15-11-58Z-unknown.json", true],
2561
+ // Multi-dot extension (e.g., archive tarballs that conflicted).
2562
+ ["dump.conflict-2026-05-13T19-40-40Z-abc.tar.gz", true],
2457
2563
  ])("matches conflict mirror: %s", (p, expected) => {
2458
2564
  expect(isEphemeralPath(p)).toBe(expected);
2459
2565
  });
@@ -2467,17 +2573,20 @@ describe("isEphemeralPath (conflict-mirror pattern contract)", () => {
2467
2573
  ["conflict-resolution.md", false],
2468
2574
  ["my-conflict.md", false],
2469
2575
  ["foo.conflict-handler.md", false],
2470
- // Date-shaped but missing the trailing dot + extension (real conflicts
2471
- // always carry a file extension; the trailing `\.` in the pattern is the
2472
- // safety against bare-substring false positives).
2473
- ["foo.conflict-2026-05-13T19-40-40Z-abc", false],
2474
- // Wrong-case or non-hex machine hash.
2576
+ // Wrong-case or non-hex/non-"unknown" machine hash.
2475
2577
  ["foo.conflict-2026-05-13T19-40-40Z-ZZZZZZ.md", false],
2476
2578
  // Wrong timestamp format (real conflicts use UTC ISO with Z suffix).
2477
2579
  ["foo.conflict-2026-05-13-abc123.md", false],
2478
- // Missing leading dot before "conflict" (this protects against legitimate
2580
+ // Missing leading dot before "conflict" (protects against legitimate
2479
2581
  // files that happen to contain the word "conflict" mid-name).
2480
2582
  ["fooconflict-2026-05-13T19-40-40Z-abc.md", false],
2583
+ // Extra trailing segments after the machine hash — the `$` anchor +
2584
+ // `[^/]*` ext class ensure a conflict marker can't appear mid-path.
2585
+ ["foo.conflict-2026-05-13T19-40-40Z-abc/extra/path", false],
2586
+ ["foo.conflict-2026-05-13T19-40-40Z-abc-then-more-text", false],
2587
+ // Bare "unknown"-like tokens that aren't the literal sentinel.
2588
+ ["foo.conflict-2026-05-13T19-40-40Z-unknowing.md", false],
2589
+ ["foo.conflict-2026-05-13T19-40-40Z-UNKNOWN.md", false],
2481
2590
  ])("rejects non-mirror: %s", (p, expected) => {
2482
2591
  expect(isEphemeralPath(p)).toBe(expected);
2483
2592
  });
package/src/cli/share.ts CHANGED
@@ -9,7 +9,8 @@ import * as fs from "fs";
9
9
  import * as path from "path";
10
10
  import type { EntityContext, VaultServiceConfig, SyncJournal } from "../types.js";
11
11
  import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
12
- import { uploadFile, uploadSymlink, headRemoteFile, deleteRemoteFile } from "../s3.js";
12
+ import { uploadFile, uploadSymlink, headRemoteFile, deleteRemoteFile, downloadFile } from "../s3.js";
13
+ import * as crypto from "crypto";
13
14
  import type { UploadAuthor } from "../s3.js";
14
15
  import {
15
16
  readJournal,
@@ -28,12 +29,21 @@ import {
28
29
  import { resolveConflict } from "./conflict.js";
29
30
  import type { ConflictStrategy } from "./conflict.js";
30
31
  import type { SyncProgressEvent } from "./sync.js";
32
+ import {
33
+ buildConflictId,
34
+ buildConflictPath,
35
+ readShortMachineId,
36
+ } from "../lib/conflict-file.js";
37
+ import { appendConflictEntry } from "../lib/conflict-index.js";
31
38
 
32
39
  /**
33
40
  * Local-only ephemeral artifacts: conflict-mirror files written by the pull
34
41
  * leg whenever a 3-way merge keeps local AND wants to preserve the remote
35
- * version for inspection. Format: `<orig>.conflict-<ISO-utc>-<machineHash>.<ext>`
36
- * (e.g. `.claude/CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md`).
42
+ * version for inspection. Format: `<orig>.conflict-<ISO-utc>-<machineHash>[.ext]`
43
+ * (e.g. `.claude/CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md`,
44
+ * or `.gitignore.conflict-2026-05-13T19-40-40Z-e5797a` — extensionless
45
+ * originals produce no trailing dot, see `buildConflictPath` in
46
+ * `../lib/conflict-file.ts`).
37
47
  *
38
48
  * These files MUST never round-trip to S3 — they're local-only safety backups
39
49
  * the user reviews and deletes once the merge is resolved. Pre-fix, the push
@@ -42,21 +52,46 @@ import type { SyncProgressEvent } from "./sync.js";
42
52
  * deleted them locally (because pull-confirmation had stamped them as
43
53
  * `direction: "down"`). Net effect: a permanent litter ratchet on remote.
44
54
  *
55
+ * Two known producer-shapes the regex must accommodate (both observed on
56
+ * affected user trees prior to this fix):
57
+ *
58
+ * 1. **`unknown` machine token.** Pre-`<hqRoot>/.hq/machine-id`
59
+ * provisioning (see `../lib/machine-id.ts`), hosts without
60
+ * `~/.hq/menubar.json` — every Linux HQ Pro Outpost, every fresh CLI
61
+ * install — fell through to the literal string `"unknown"` from the
62
+ * old `readShortMachineId()` fallback. The letters `k`, `n`, `o`, `w`
63
+ * live outside `[a-f]`, so the pre-fix `[a-f0-9]+` class refused those
64
+ * filenames. They round-tripped to S3 as ordinary files (which IS the
65
+ * "permanent litter ratchet" this module's contract was supposed to
66
+ * prevent). The new machine-id provisioning closes the producer side,
67
+ * but we still accept `unknown` here so legacy files already on disk
68
+ * are filtered out by the next push.
69
+ *
70
+ * 2. **Extensionless originals.** `path.extname('.gitignore')` returns
71
+ * `''` in Node, so `buildConflictPath` produces no trailing `.<ext>`
72
+ * segment for hidden-but-extensionless files like `.gitignore`,
73
+ * `.hqignore`, or any `.agents/skills`-style entry. The pre-fix `\.`
74
+ * tail was mandatory, so those names slipped through.
75
+ *
45
76
  * Wire-points: (1) push walker — `collectFiles` / `walkDir` skip these so
46
77
  * they never upload; (2) `computeDeletePlan` — skip these so an already-
47
78
  * journaled mirror that's been deleted locally doesn't get included in the
48
79
  * regular delete plan (the dedicated reconcile path handles existing litter).
49
80
  */
50
- const EPHEMERAL_PATH_PATTERN =
51
- /\.conflict-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z-[a-f0-9]+\./;
81
+ export const EPHEMERAL_PATH_PATTERN =
82
+ /\.conflict-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z-(?:[a-f0-9]+|unknown)(?:\.[^/]*)?$/;
52
83
 
53
84
  /**
54
85
  * Cheap pure check — pass the relative key OR a basename; either works. Used
55
86
  * in both the file walker (basename matching) and the delete-plan walker
56
- * (relative-key matching). The regex matches anywhere in the string, which is
57
- * fine: the `.conflict-<ISO>-<hash>.` token is unambiguous.
87
+ * (relative-key matching), and also by the pull walker in sync.ts to refuse
88
+ * downloading legacy conflict-mirror files that still live in cloud staging
89
+ * (Bug #2 in the 5.33.0 deep-test report — push-side filtered them since
90
+ * 5.33.0 but pull-side downloaded them freely until this export). The regex
91
+ * matches anywhere in the string, which is fine: the
92
+ * `.conflict-<ISO>-<hash>.` token is unambiguous.
58
93
  */
59
- function isEphemeralPath(p: string): boolean {
94
+ export function isEphemeralPath(p: string): boolean {
60
95
  return EPHEMERAL_PATH_PATTERN.test(p);
61
96
  }
62
97
 
@@ -416,6 +451,16 @@ export interface ShareResult {
416
451
  * `currency-gated`.
417
452
  */
418
453
  filesRefusedStale: number;
454
+ /**
455
+ * Paths corresponding to `filesRefusedStale`, capped at 50 to keep the
456
+ * event payload bounded (mirrors `newFiles` capping). Surfaces *which*
457
+ * paths were refused so operators can triage the recurring
458
+ * \`filesRefusedStale: 205\` signal flagged in the 5.33.0 deep-test —
459
+ * the count alone is impossible to investigate because the per-file
460
+ * \`delete-refused-stale-etag\` events vanish from the event stream
461
+ * once the runner has folded them into the totals.
462
+ */
463
+ filesRefusedStalePaths: string[];
419
464
  /**
420
465
  * Number of paths blocked by `PERSONAL_VAULT_DEFAULT_EXCLUSIONS` during this
421
466
  * run (push leg, personalMode=true). Includes both files that would have
@@ -544,6 +589,9 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
544
589
  // propagateDeletes=false path.
545
590
  let filesTombstoned = 0;
546
591
  let filesRefusedStale = 0;
592
+ // Capped at 50 to bound event payload size — `newFiles` uses the same cap.
593
+ const REFUSED_STALE_PATH_CAP = 50;
594
+ const filesRefusedStalePaths: string[] = [];
547
595
  const conflictPaths: string[] = [];
548
596
 
549
597
  // Collect all files to share
@@ -668,13 +716,58 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
668
716
  // the current remote ETag against the one captured at last sync; when
669
717
  // missing (legacy entries), we fall back to the same `lastModified >
670
718
  // syncedAt` heuristic the pull side uses.
719
+ //
720
+ // Bug #7 (data-loss class — see workspace/reports/hq-cloud-5.33.0-
721
+ // deep-test.md): for a path with NO prior journal entry (first push
722
+ // from this machine), the localChanged/remoteChanged predicates above
723
+ // both evaluate FALSE (their guards require `!!journalEntry`). Push
724
+ // fell through to an unconditional PUT, silently clobbering any
725
+ // peer's content already at that key. The verification report's V7
726
+ // isolated this — the bug is independent of \`--on-conflict\` mode;
727
+ // it's keyed on "do I have a prior journal entry?" not on the flag.
728
+ //
729
+ // Fresh-collision branch: when remoteMeta exists and there's no
730
+ // journal entry, hash the local body (MD5 for parity with S3's
731
+ // single-part etag) and compare. Match → no conflict, silently skip
732
+ // the PUT (the bytes are already there). Mismatch → treat as a
733
+ // conflict in the same shared branch below.
671
734
  const remoteMeta = await headRemoteFile(ctx, relativePath);
672
735
  if (remoteMeta) {
673
736
  const journalEntry = journal.files[relativePath];
674
737
  const localChanged = !!journalEntry && journalEntry.hash !== localHash;
675
738
  const remoteChanged = !!journalEntry && hasRemoteChanged(remoteMeta, journalEntry);
676
739
 
677
- if (localChanged && remoteChanged) {
740
+ let isFreshCollision = false;
741
+ if (!journalEntry && item.kind === "file") {
742
+ // Single-part S3 PUT etag is MD5 of the body. Multipart uploads
743
+ // produce \`<md5>-<partCount>\`; we treat any non-single-part etag
744
+ // as ambiguous and DO classify as a conflict (safer for the
745
+ // first-time path — false positives prompt the operator, false
746
+ // negatives lose data). Symlink records (\`kind: "symlink"\`)
747
+ // skip the check entirely — the wire body shape (\`hq-symlink:\`
748
+ // prefix + target) isn't a pure byte mirror and would mis-
749
+ // classify; symlink overwrites are rare and an audit pass after
750
+ // the broader bug-cleanup wave can extend coverage if needed.
751
+ const remoteEtagNormalized = normalizeEtag(remoteMeta.etag);
752
+ const isMultipart = /-\d+$/.test(remoteEtagNormalized);
753
+ if (!isMultipart) {
754
+ const localBody = fs.readFileSync(absolutePath);
755
+ const localMd5 = crypto.createHash("md5").update(localBody).digest("hex");
756
+ if (localMd5 !== remoteEtagNormalized) {
757
+ isFreshCollision = true;
758
+ }
759
+ // Match → bytes are already there; fall through to upload
760
+ // path which is idempotent (S3 will overwrite with identical
761
+ // content + carry our metadata). Cheap, no behavior change.
762
+ } else {
763
+ // Multipart object pre-exists with unknown body shape — assume
764
+ // collision rather than risk a silent overwrite. The operator
765
+ // can resolve via the standard conflict prompt.
766
+ isFreshCollision = true;
767
+ }
768
+ }
769
+
770
+ if ((localChanged && remoteChanged) || isFreshCollision) {
678
771
  conflictPaths.push(relativePath);
679
772
 
680
773
  const resolution = await resolveConflict(
@@ -705,6 +798,10 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
705
798
  // ShareResult shape stable for consumers that destructure.
706
799
  filesTombstoned,
707
800
  filesRefusedStale,
801
+ // Always present so consumers can destructure without a
802
+ // defaulting fallback. Empty on the abort path because the
803
+ // delete-plan execution loop is short-circuited.
804
+ filesRefusedStalePaths,
708
805
  // Exclusions are computed during the upload walk which has
709
806
  // already completed by the time we hit a per-file conflict-
710
807
  // abort, so the count is meaningful here. No event emit on
@@ -716,6 +813,45 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
716
813
  };
717
814
  }
718
815
  if (resolution === "keep" || resolution === "skip") {
816
+ // Bug #7 mirror branch: when the resolution is keep/skip on a
817
+ // FRESH collision (no prior journal entry), download the
818
+ // remote bytes to \`<orig>.conflict-<ts>-<short>\` so both
819
+ // versions survive on disk. Mirrors the pull-side mirror-write
820
+ // routine in sync.ts exactly. Skipped for stale-journal
821
+ // conflicts (the pre-Bug-#7 codepath) — those already produce
822
+ // a pull-side mirror on the next sync cycle.
823
+ if (isFreshCollision) {
824
+ try {
825
+ const detectedAt = new Date().toISOString();
826
+ const machineId = readShortMachineId(hqRoot);
827
+ const originalRelative = path.relative(hqRoot, absolutePath);
828
+ const conflictRelative = buildConflictPath(
829
+ originalRelative,
830
+ detectedAt,
831
+ machineId,
832
+ );
833
+ const conflictAbs = path.join(hqRoot, conflictRelative);
834
+ await downloadFile(ctx, relativePath, conflictAbs);
835
+ appendConflictEntry(hqRoot, {
836
+ id: buildConflictId(originalRelative, detectedAt),
837
+ originalPath: originalRelative,
838
+ conflictPath: conflictRelative,
839
+ detectedAt,
840
+ side: "push",
841
+ machineId,
842
+ localHash,
843
+ remoteHash: normalizeEtag(remoteMeta.etag),
844
+ });
845
+ } catch (mirrorErr) {
846
+ emit({
847
+ type: "error",
848
+ path: relativePath,
849
+ message: `conflict mirror write failed: ${
850
+ mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr)
851
+ }`,
852
+ });
853
+ }
854
+ }
719
855
  filesSkipped++;
720
856
  continue;
721
857
  }
@@ -838,6 +974,9 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
838
974
  for (const refused of deletePlan.refusedStale) {
839
975
  if (decommissionedSet && decommissionedSet.has(refused.key)) continue;
840
976
  filesRefusedStale++;
977
+ if (filesRefusedStalePaths.length < REFUSED_STALE_PATH_CAP) {
978
+ filesRefusedStalePaths.push(refused.key);
979
+ }
841
980
  emit({
842
981
  type: "delete-refused-stale-etag",
843
982
  path: refused.key,
@@ -877,6 +1016,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
877
1016
  filesDeleted,
878
1017
  filesTombstoned,
879
1018
  filesRefusedStale,
1019
+ filesRefusedStalePaths,
880
1020
  filesExcludedByPolicy: excludedSet.size,
881
1021
  conflictPaths,
882
1022
  aborted: false,