@indigoai-us/hq-cloud 6.14.18 → 6.14.19

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 (94) hide show
  1. package/.github/workflows/ci.yml +35 -0
  2. package/dist/bin/sync-runner-company.d.ts +7 -0
  3. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  4. package/dist/bin/sync-runner-company.js +21 -1
  5. package/dist/bin/sync-runner-company.js.map +1 -1
  6. package/dist/bin/sync-runner-watch-loop.d.ts +10 -2
  7. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  8. package/dist/bin/sync-runner-watch-loop.js +201 -25
  9. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  10. package/dist/bin/sync-runner-watch-routes.d.ts +1 -1
  11. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
  12. package/dist/bin/sync-runner-watch-routes.js +14 -2
  13. package/dist/bin/sync-runner-watch-routes.js.map +1 -1
  14. package/dist/bin/sync-runner.d.ts +17 -2
  15. package/dist/bin/sync-runner.d.ts.map +1 -1
  16. package/dist/bin/sync-runner.js +22 -4
  17. package/dist/bin/sync-runner.js.map +1 -1
  18. package/dist/bin/sync-runner.test.js +495 -3
  19. package/dist/bin/sync-runner.test.js.map +1 -1
  20. package/dist/cli/rescue-core.js +27 -1
  21. package/dist/cli/rescue-core.js.map +1 -1
  22. package/dist/cli/rescue-drop-dir-symlink.test.d.ts +2 -0
  23. package/dist/cli/rescue-drop-dir-symlink.test.d.ts.map +1 -0
  24. package/dist/cli/rescue-drop-dir-symlink.test.js +206 -0
  25. package/dist/cli/rescue-drop-dir-symlink.test.js.map +1 -0
  26. package/dist/cli/share.d.ts +15 -0
  27. package/dist/cli/share.d.ts.map +1 -1
  28. package/dist/cli/share.js +100 -55
  29. package/dist/cli/share.js.map +1 -1
  30. package/dist/cli/share.test.js +119 -6
  31. package/dist/cli/share.test.js.map +1 -1
  32. package/dist/cli/sync.d.ts.map +1 -1
  33. package/dist/cli/sync.js +19 -25
  34. package/dist/cli/sync.js.map +1 -1
  35. package/dist/cli/sync.test.js +121 -0
  36. package/dist/cli/sync.test.js.map +1 -1
  37. package/dist/journal.d.ts +3 -1
  38. package/dist/journal.d.ts.map +1 -1
  39. package/dist/journal.js +13 -2
  40. package/dist/journal.js.map +1 -1
  41. package/dist/journal.test.js +26 -1
  42. package/dist/journal.test.js.map +1 -1
  43. package/dist/personal-vault.d.ts +12 -0
  44. package/dist/personal-vault.d.ts.map +1 -1
  45. package/dist/personal-vault.js +20 -0
  46. package/dist/personal-vault.js.map +1 -1
  47. package/dist/qmd-reindex.d.ts +88 -11
  48. package/dist/qmd-reindex.d.ts.map +1 -1
  49. package/dist/qmd-reindex.js +354 -65
  50. package/dist/qmd-reindex.js.map +1 -1
  51. package/dist/qmd-reindex.test.d.ts +4 -0
  52. package/dist/qmd-reindex.test.d.ts.map +1 -1
  53. package/dist/qmd-reindex.test.js +285 -3
  54. package/dist/qmd-reindex.test.js.map +1 -1
  55. package/dist/s3.d.ts +13 -0
  56. package/dist/s3.d.ts.map +1 -1
  57. package/dist/s3.js +34 -1
  58. package/dist/s3.js.map +1 -1
  59. package/dist/s3.test.js +60 -1
  60. package/dist/s3.test.js.map +1 -1
  61. package/dist/telemetry.d.ts +22 -0
  62. package/dist/telemetry.d.ts.map +1 -1
  63. package/dist/telemetry.js +79 -0
  64. package/dist/telemetry.js.map +1 -1
  65. package/dist/telemetry.test.js +117 -1
  66. package/dist/telemetry.test.js.map +1 -1
  67. package/dist/watcher.d.ts +26 -1
  68. package/dist/watcher.d.ts.map +1 -1
  69. package/dist/watcher.js +75 -12
  70. package/dist/watcher.js.map +1 -1
  71. package/package.json +1 -1
  72. package/src/bin/sync-runner-company.ts +29 -1
  73. package/src/bin/sync-runner-watch-loop.ts +322 -32
  74. package/src/bin/sync-runner-watch-routes.ts +14 -1
  75. package/src/bin/sync-runner.test.ts +591 -6
  76. package/src/bin/sync-runner.ts +50 -5
  77. package/src/cli/rescue-core.ts +26 -1
  78. package/src/cli/rescue-drop-dir-symlink.test.ts +224 -0
  79. package/src/cli/share.test.ts +150 -6
  80. package/src/cli/share.ts +143 -48
  81. package/src/cli/sync.test.ts +150 -1
  82. package/src/cli/sync.ts +25 -29
  83. package/src/journal.test.ts +45 -0
  84. package/src/journal.ts +18 -1
  85. package/src/personal-vault.ts +23 -0
  86. package/src/qmd-reindex.test.ts +324 -4
  87. package/src/qmd-reindex.ts +414 -76
  88. package/src/s3.test.ts +79 -0
  89. package/src/s3.ts +48 -1
  90. package/src/telemetry.test.ts +128 -0
  91. package/src/telemetry.ts +80 -0
  92. package/src/watcher.ts +111 -14
  93. package/test/e2e/watcher-real-chokidar.test.ts +62 -2
  94. package/test/e2e/watcher-recursive-backend.test.ts +67 -1
@@ -125,7 +125,11 @@ import {
125
125
  } from "../lib/net-errors.js";
126
126
  import { PARTIAL_SYNC_EXIT } from "../lib/exit-codes.js";
127
127
  import { describeError } from "../lib/describe-error.js";
128
- import type { Clock, TreeChangeBatch } from "../watcher.js";
128
+ import type {
129
+ Clock,
130
+ LocalDeleteSnapshot,
131
+ TreeChangeBatch,
132
+ } from "../watcher.js";
129
133
  import type { PushReceiver, SyncEngineFn } from "../sync/push-receiver.js";
130
134
  import {
131
135
  type EventSyncHandles,
@@ -664,6 +668,8 @@ export interface RunnerDeps {
664
668
  operationLockAlreadyHeld?: boolean;
665
669
  /** Internal watch-loop result override; one-shot callers keep exit 0. */
666
670
  authRequiredExitCode?: number;
671
+ /** Internal watch-loop callback carrying uncapped per-path push outcomes. */
672
+ onPassResult?: (result: RunnerPassResult) => void;
667
673
  /** Share function (push phase). Defaults to `cli/share.share`. */
668
674
  share?: (options: ShareOptions) => Promise<ShareResult>;
669
675
  /**
@@ -679,6 +685,20 @@ export interface RunnerDeps {
679
685
  telemetryTimeoutMs?: number;
680
686
  }
681
687
 
688
+ export interface RunnerPassResult {
689
+ pushPathResults: Array<{
690
+ relativePath: string;
691
+ status: "accepted" | "refused";
692
+ operation: "delete" | "tombstone";
693
+ reason?: string;
694
+ }>;
695
+ }
696
+
697
+ export interface RunnerPassOutcome {
698
+ exitCode: number;
699
+ result?: RunnerPassResult;
700
+ }
701
+
682
702
  // ---------------------------------------------------------------------------
683
703
  // JWT claim decoder — inlined to avoid pulling a dep just to read an idToken.
684
704
  // We do NOT verify the signature here — Cognito already did that when it
@@ -812,6 +832,11 @@ interface ParsedArgs {
812
832
  skipPersonal: boolean;
813
833
  /** Repeatable company/personal-relative path scope for push-only passes. */
814
834
  scopePaths: string[];
835
+ /**
836
+ * Internal watcher-only delete roots. Unlike `scopePaths`, these remain
837
+ * meaningful after the corresponding local file or directory is absent.
838
+ */
839
+ deleteScopePaths: string[];
815
840
  /**
816
841
  * Bounded wait (seconds) for the per-root operation lock when another op is
817
842
  * already running. `0` → refuse immediately (pre-wait behavior); omitted →
@@ -832,6 +857,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
832
857
  let pollRemoteMs: number | undefined;
833
858
  let skipPersonal = false;
834
859
  const scopePaths: string[] = [];
860
+ const deleteScopePaths: string[] = [];
835
861
  let eventPush = false;
836
862
  let lockTimeoutSec: number | undefined;
837
863
 
@@ -913,6 +939,12 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
913
939
  scopePaths.push(val);
914
940
  break;
915
941
  }
942
+ case "--delete-scope-path": {
943
+ const val = argv[++i];
944
+ if (!val) return { error: "--delete-scope-path requires a value" };
945
+ deleteScopePaths.push(val);
946
+ break;
947
+ }
916
948
  case "--lock-timeout": {
917
949
  const val = argv[++i];
918
950
  if (!val) return { error: "--lock-timeout requires a value (seconds)" };
@@ -964,6 +996,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
964
996
  pollRemoteMs,
965
997
  skipPersonal,
966
998
  scopePaths,
999
+ deleteScopePaths,
967
1000
  eventPush,
968
1001
  lockTimeoutSec,
969
1002
  };
@@ -1418,6 +1451,7 @@ export async function runRunner(
1418
1451
  resolveDeletePolicy,
1419
1452
  emit,
1420
1453
  scopePaths: parsed.scopePaths,
1454
+ deleteScopePaths: parsed.deleteScopePaths,
1421
1455
  telemetryClient: client,
1422
1456
  telemetryClaims: claims,
1423
1457
  telemetrySessionId,
@@ -1431,6 +1465,7 @@ export async function runRunner(
1431
1465
  throw err;
1432
1466
  }
1433
1467
  const { errors, allConflicts, meaningfulOutcomeCount } = fanout;
1468
+ deps.onPassResult?.({ pushPathResults: fanout.pushPathResults });
1434
1469
  const rollup = rollupAllComplete(plan, fanout.stateByCompany);
1435
1470
 
1436
1471
  // The personal-vault target runs last and can pull an older copy of the
@@ -1860,7 +1895,7 @@ export interface RunnerLoopDeps {
1860
1895
  * targeted push share one seam and one in-flight guard. The default ignores
1861
1896
  * `deps` and forwards just the argv to `runRunner`.
1862
1897
  */
1863
- runPass?: (passArgv: string[]) => Promise<number>;
1898
+ runPass?: (passArgv: string[]) => Promise<number | RunnerPassOutcome>;
1864
1899
  /** Stage-0 backup retention backstop: a dry-run only, never a sync-tick action. */
1865
1900
  runBackupPrune?: (hqRoot: string) => Promise<void>;
1866
1901
  /**
@@ -1878,6 +1913,10 @@ export interface RunnerLoopDeps {
1878
1913
  hqRoot: string;
1879
1914
  debounceMs: number;
1880
1915
  clock: Clock;
1916
+ captureLocalDeleteSnapshots: (
1917
+ relativePath: string,
1918
+ kind: "unlink" | "unlinkDir",
1919
+ ) => LocalDeleteSnapshot[];
1881
1920
  }) => WatcherSurface;
1882
1921
  /**
1883
1922
  * Register a one-shot shutdown signal handler. Defaults to listening for
@@ -1955,11 +1994,17 @@ export async function runRunnerWithLoop(
1955
1994
  ): Promise<number> {
1956
1995
  const parsed = parseArgs(argv);
1957
1996
  const runtime = {
1958
- runPassWithOperationLockAlreadyHeld: (passArgv: string[]) =>
1959
- runRunner(passArgv, {
1997
+ runPassWithOperationLockAlreadyHeld: async (passArgv: string[]) => {
1998
+ let result: RunnerPassResult | undefined;
1999
+ const exitCode = await runRunner(passArgv, {
1960
2000
  operationLockAlreadyHeld: true,
1961
2001
  authRequiredExitCode: AUTH_REQUIRED_PASS_EXIT,
1962
- }),
2002
+ onPassResult: (passResult) => {
2003
+ result = passResult;
2004
+ },
2005
+ });
2006
+ return { exitCode, ...(result ? { result } : {}) };
2007
+ },
1963
2008
  authRequiredPassExit: AUTH_REQUIRED_PASS_EXIT,
1964
2009
  defaultGetIdTokenClaims,
1965
2010
  defaultGetAccessToken: () =>
@@ -1515,6 +1515,31 @@ function masterSyncSymlinkTarget(localPath: string): string | null {
1515
1515
  return tgt.includes("/personal/") || tgt.startsWith("personal/") ? tgt : null;
1516
1516
  }
1517
1517
 
1518
+ /**
1519
+ * Drop a reindex symlink with lstat + unlink semantics: treat the path as a
1520
+ * link and remove the link itself, never following or recursing into its
1521
+ * target.
1522
+ *
1523
+ * `fs.rmSync(p, { force: true })` stat-follows on some platforms (notably
1524
+ * macOS): a symlink whose target is a directory is seen as a directory, and
1525
+ * with `recursive` unset it throws `EISDIR` ("Path is a directory"). During a
1526
+ * rescue apply that abort rolls back the whole update — the exact failure this
1527
+ * guards against (a directory-target overlay symlink such as
1528
+ * core/knowledge/<name> -> personal/knowledge/<name>). `unlink(2)` removes the
1529
+ * link regardless of what it points at, so file- and directory-target reindex
1530
+ * symlinks are dropped identically. reindex.sh re-lays the link afterward.
1531
+ *
1532
+ * A missing path is a no-op (force-equivalent); a non-symlink is left untouched
1533
+ * so we can never recurse into or delete a real directory (the classifier only
1534
+ * ever routes real symlinks here — this stays defensive).
1535
+ */
1536
+ function dropReindexSymlink(localPath: string): void {
1537
+ const st = lstatOrNull(localPath);
1538
+ if (!st) return; // already gone — nothing to drop
1539
+ if (!st.isSymbolicLink()) return; // never follow/recurse into a real target
1540
+ fs.unlinkSync(localPath);
1541
+ }
1542
+
1518
1543
  function isUnderPreserve(cfg: Config, rel: string): boolean {
1519
1544
  for (const sp of cfg.preserveSubpaths) {
1520
1545
  if (rel === sp || rel.startsWith(sp + "/")) return true;
@@ -1972,7 +1997,7 @@ function buildRescueActions(ctx: WalkCtx): RescueAction[] {
1972
1997
  break;
1973
1998
  case "drop-reindex-symlink":
1974
1999
  add(`drop reindex symlink: ${decision.rel}`, [decision.rel], () => {
1975
- fs.rmSync(localPath, { force: true });
2000
+ dropReindexSymlink(localPath);
1976
2001
  ctx.appendLog(`symlink-dropped\t${decision.rel}\t(reindex regenerable)\n`);
1977
2002
  });
1978
2003
  break;
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Regression for the rescue "drop reindex symlink" fix in
3
+ * src/cli/rescue-core.ts (dropReindexSymlink).
4
+ *
5
+ * The bug: the apply step removed a reindex overlay symlink with
6
+ * `fs.rmSync(localPath, { force: true })`. `rmSync` stat-FOLLOWS on some
7
+ * platforms (notably macOS), so a symlink whose target is a directory — e.g.
8
+ * core/knowledge/ad-creative-engine -> personal/knowledge/ad-creative-engine —
9
+ * is seen as a directory and, with `recursive` unset, throws `EISDIR`
10
+ * ("Path is a directory"). During a rescue apply that abort rolls back the
11
+ * whole update, so /update-hq fails on any install carrying a directory-target
12
+ * overlay symlink. The fix removes the link with lstat + unlink semantics
13
+ * (`fs.unlinkSync`), which drops the link itself regardless of what it points
14
+ * at and never recurses into the target.
15
+ *
16
+ * The macOS `rmSync` follow-behavior is not reproducible with a plain path on
17
+ * Linux CI, so we reproduce it deterministically: `fs` is mocked so `rmSync`
18
+ * throws `EISDIR` on exactly a directory-target symlink (every other call is
19
+ * delegated to the real implementation). Under that fault the OLD code aborts
20
+ * the apply and rolls the whole update back; the fixed code drops the link with
21
+ * `unlinkSync` and the rescue still succeeds.
22
+ *
23
+ * Mirrors rescue-mtime-preserve.test.ts: shim `git clone` to a local fixture
24
+ * and run the real rescue (non-dry-run, --no-backup) so the overlay lays files
25
+ * down and the symlink-drop action really runs.
26
+ */
27
+ import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
28
+ import { execFileSync } from "child_process";
29
+ import * as fs from "fs";
30
+ import * as os from "os";
31
+ import * as path from "path";
32
+ import { runRescue } from "./rescue-core.js";
33
+
34
+ // Simulate the reporter's platform (macOS): fs.rmSync stat-follows a
35
+ // symlink-to-directory and throws EISDIR ("Path is a directory"). Only the
36
+ // dir-target symlink trips this; every other rmSync call — and every other fs
37
+ // function — is the real implementation, so clone/overlay/backup all work.
38
+ vi.mock("fs", async (importOriginal) => {
39
+ const real = await importOriginal<typeof import("fs")>();
40
+ const patchedRmSync: typeof real.rmSync = ((p: fs.PathLike, opts?: fs.RmOptions) => {
41
+ try {
42
+ const lst = real.lstatSync(p as fs.PathLike);
43
+ if (lst.isSymbolicLink() && real.statSync(p as fs.PathLike).isDirectory()) {
44
+ const err = new Error(
45
+ `EISDIR: illegal operation on a directory, Path is a directory: ${String(p)}`,
46
+ ) as NodeJS.ErrnoException;
47
+ err.code = "ERR_FS_EISDIR";
48
+ throw err;
49
+ }
50
+ } catch (e) {
51
+ if ((e as NodeJS.ErrnoException).code === "ERR_FS_EISDIR") throw e;
52
+ // lstat/stat failed (missing path / broken link) — fall through to real rm.
53
+ }
54
+ return real.rmSync(p, opts);
55
+ }) as typeof real.rmSync;
56
+ return { ...real, rmSync: patchedRmSync };
57
+ });
58
+
59
+ function has(bin: string, ...args: string[]): boolean {
60
+ try {
61
+ execFileSync(bin, args, { stdio: "ignore" });
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+ const toolsAvailable = has("git", "--version") && has("rsync", "--version");
68
+
69
+ const EPOCH = 1609459200; // 2021-01-01T00:00:00Z
70
+
71
+ /** Run the rescue in-process, capturing its stdout/stderr. */
72
+ function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
73
+ let stdout = "";
74
+ let stderr = "";
75
+ const origOut = process.stdout.write.bind(process.stdout);
76
+ const origErr = process.stderr.write.bind(process.stderr);
77
+ process.stdout.write = ((chunk: unknown) => {
78
+ stdout += String(chunk);
79
+ return true;
80
+ }) as typeof process.stdout.write;
81
+ process.stderr.write = ((chunk: unknown) => {
82
+ stderr += String(chunk);
83
+ return true;
84
+ }) as typeof process.stderr.write;
85
+ let status: number;
86
+ try {
87
+ status = runRescue(argv, { env }).status;
88
+ } finally {
89
+ process.stdout.write = origOut;
90
+ process.stderr.write = origErr;
91
+ }
92
+ return { status, stdout, stderr };
93
+ }
94
+
95
+ describe.skipIf(!toolsAvailable)("rescue drops directory-target reindex symlinks", () => {
96
+ let workDir: string;
97
+ let upstream: string;
98
+ let shimDir: string;
99
+ let floorSha: string;
100
+ let env: NodeJS.ProcessEnv;
101
+
102
+ const gitAt = (cwd: string, ...args: string[]) =>
103
+ execFileSync("git", args, {
104
+ cwd,
105
+ stdio: ["ignore", "pipe", "pipe"],
106
+ env: {
107
+ ...process.env,
108
+ GIT_AUTHOR_NAME: "t",
109
+ GIT_AUTHOR_EMAIL: "t@t",
110
+ GIT_COMMITTER_NAME: "t",
111
+ GIT_COMMITTER_EMAIL: "t@t",
112
+ GIT_AUTHOR_DATE: `${EPOCH} +0000`,
113
+ GIT_COMMITTER_DATE: `${EPOCH} +0000`,
114
+ },
115
+ })
116
+ .toString()
117
+ .trim();
118
+
119
+ beforeAll(() => {
120
+ workDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rescue-dirsymlink-"));
121
+
122
+ // --- "upstream" repo: a single core/ file so the wipe set includes core/ --
123
+ upstream = path.join(workDir, "upstream");
124
+ fs.mkdirSync(path.join(upstream, "core/knowledge"), { recursive: true });
125
+ gitAt(workDir, "init", "-b", "main", "upstream");
126
+ fs.writeFileSync(path.join(upstream, "core/knowledge/README.md"), "shipped\n");
127
+ gitAt(upstream, "add", "-A");
128
+ gitAt(upstream, "commit", "-m", "floor");
129
+ floorSha = gitAt(upstream, "rev-parse", "HEAD");
130
+
131
+ // --- git shim: redirect `git clone <github-url>` to the local fixture -----
132
+ const realGit =
133
+ execFileSync("bash", ["-lc", "command -v git"]).toString().trim() || "/usr/bin/git";
134
+ shimDir = path.join(workDir, "shim");
135
+ fs.mkdirSync(shimDir, { recursive: true });
136
+ const shim = `#!/usr/bin/env bash
137
+ if [ "$1" = "clone" ]; then
138
+ args=()
139
+ for a in "$@"; do
140
+ case "$a" in
141
+ https://github.com/*) a=${JSON.stringify(upstream)} ;;
142
+ esac
143
+ args+=("$a")
144
+ done
145
+ exec ${JSON.stringify(realGit)} "\${args[@]}"
146
+ fi
147
+ exec ${JSON.stringify(realGit)} "$@"
148
+ `;
149
+ fs.writeFileSync(path.join(shimDir, "git"), shim, { mode: 0o755 });
150
+ env = { ...process.env, PATH: `${shimDir}:${process.env.PATH ?? ""}` };
151
+ });
152
+
153
+ afterAll(() => {
154
+ if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
155
+ });
156
+
157
+ /**
158
+ * Build a fresh local HQ root carrying a directory-target reindex symlink
159
+ * (core/knowledge/ad-creative-engine -> <hqRoot>/personal/knowledge/ad-creative-engine),
160
+ * whose target directory holds a sentinel file that must survive the drop.
161
+ */
162
+ function makeHqRoot(): { hqRoot: string; link: string; targetDir: string; sentinel: string } {
163
+ const hqRoot = fs.mkdtempSync(path.join(workDir, "hq-"));
164
+ // HQ-root shape check: companies/ + one of .claude/|core/|personal/.
165
+ fs.mkdirSync(path.join(hqRoot, "companies"), { recursive: true });
166
+ fs.mkdirSync(path.join(hqRoot, "core/knowledge"), { recursive: true });
167
+ // Unchanged vs upstream so the overlay is a no-op for it.
168
+ fs.writeFileSync(path.join(hqRoot, "core/knowledge/README.md"), "shipped\n");
169
+
170
+ const targetDir = path.join(hqRoot, "personal/knowledge/ad-creative-engine");
171
+ fs.mkdirSync(targetDir, { recursive: true });
172
+ const sentinel = path.join(targetDir, "brief.md");
173
+ fs.writeFileSync(sentinel, "authored locally\n");
174
+
175
+ const link = path.join(hqRoot, "core/knowledge/ad-creative-engine");
176
+ // Absolute target that includes "/personal/" — matches the overlay
177
+ // convention masterSyncSymlinkTarget() recognizes.
178
+ fs.symlinkSync(targetDir, link);
179
+
180
+ return { hqRoot, link, targetDir, sentinel };
181
+ }
182
+
183
+ function rescueArgs(hqRoot: string): string[] {
184
+ return [
185
+ "--hq-root", hqRoot,
186
+ "--source", "test/repo",
187
+ "--ref", "main",
188
+ "--floor-sha", floorSha,
189
+ "--yes",
190
+ "--no-backup",
191
+ ];
192
+ }
193
+
194
+ it("sanity: the mocked rmSync reproduces the macOS EISDIR on a dir-target symlink", () => {
195
+ const { link, targetDir, sentinel } = makeHqRoot();
196
+ // The old apply path used exactly this call — it must throw under the fault.
197
+ expect(() => fs.rmSync(link, { force: true })).toThrow(/Path is a directory/);
198
+ // ...and the fixed path (unlink) removes the link while sparing the target.
199
+ fs.unlinkSync(link);
200
+ expect(fs.existsSync(link)).toBe(false);
201
+ expect(fs.existsSync(targetDir)).toBe(true);
202
+ expect(fs.readFileSync(sentinel, "utf-8")).toBe("authored locally\n");
203
+ });
204
+
205
+ it("does not abort the apply on a directory-target symlink (drops it, spares the target)", () => {
206
+ const { hqRoot, link, targetDir, sentinel } = makeHqRoot();
207
+
208
+ const r = runRescueCapture(rescueArgs(hqRoot), env);
209
+
210
+ // The apply must survive the simulated-macOS rmSync fault: the fix removes
211
+ // the link with unlink semantics, so the whole update no longer rolls back.
212
+ // (The old rmSync-based drop throws EISDIR here → non-zero + rollback.)
213
+ if (r.status !== 0) {
214
+ throw new Error(`rescue aborted on the dir-target symlink (${r.status}):\n${r.stdout}\n${r.stderr}`);
215
+ }
216
+
217
+ // The link itself is gone (reindex.sh re-lays it later)...
218
+ expect(fs.existsSync(link)).toBe(false);
219
+ // ...but the target directory and its contents are untouched — the drop
220
+ // must never follow/recurse into the target.
221
+ expect(fs.existsSync(targetDir)).toBe(true);
222
+ expect(fs.readFileSync(sentinel, "utf-8")).toBe("authored locally\n");
223
+ });
224
+ });
@@ -2008,6 +2008,11 @@ describe("share", () => {
2008
2008
  });
2009
2009
 
2010
2010
  expect(result.filesDeleted).toBe(1);
2011
+ expect(result.pathResults).toContainEqual({
2012
+ path: "knowledge",
2013
+ status: "accepted",
2014
+ operation: "delete",
2015
+ });
2011
2016
  expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "knowledge");
2012
2017
  const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2013
2018
  expect(journal.files["knowledge"]).toBeUndefined();
@@ -2198,6 +2203,44 @@ describe("share", () => {
2198
2203
  );
2199
2204
  });
2200
2205
 
2206
+ it("personalMode excludes generated core mirror symlinks but keeps canonical core files", async () => {
2207
+ const packagePolicy = path.join(
2208
+ tmpDir,
2209
+ "core",
2210
+ "packages",
2211
+ "hq-pack-example",
2212
+ "policies",
2213
+ "generated.md",
2214
+ );
2215
+ const generatedMirror = path.join(tmpDir, "core", "policies", "generated.md");
2216
+ const canonicalCoreFile = path.join(tmpDir, "core", "policies", "shipped.md");
2217
+ fs.mkdirSync(path.dirname(packagePolicy), { recursive: true });
2218
+ fs.mkdirSync(path.dirname(generatedMirror), { recursive: true });
2219
+ fs.writeFileSync(packagePolicy, "generated package policy\n");
2220
+ fs.symlinkSync(packagePolicy, generatedMirror);
2221
+ fs.writeFileSync(canonicalCoreFile, "shipped core policy\n");
2222
+
2223
+ const result = await share({
2224
+ paths: [path.join(tmpDir, "core")],
2225
+ entityContext: makeEntityContext({
2226
+ uid: "prs_01PERSONALTEST",
2227
+ slug: "__hq_personal_vault__",
2228
+ bucketName: "hq-vault-prs-01PERSONALTEST",
2229
+ }),
2230
+ hqRoot: tmpDir,
2231
+ personalMode: true,
2232
+ journalSlug: "personal-generated-core-mirror",
2233
+ });
2234
+
2235
+ expect(result.filesExcludedByPolicy).toBe(1);
2236
+ expect(result.filesUploaded).toBe(2);
2237
+ expect(vi.mocked(uploadSymlink)).not.toHaveBeenCalled();
2238
+ expect(vi.mocked(uploadFile).mock.calls.map((call) => call[2]).sort()).toEqual([
2239
+ "core/packages/hq-pack-example/policies/generated.md",
2240
+ "core/policies/shipped.md",
2241
+ ]);
2242
+ });
2243
+
2201
2244
  it(
2202
2245
  "personal-vault override does NOT apply to company vaults: `owned-only` on cmp_ entity still " +
2203
2246
  "refuses to tombstone a direction:'down' entry (multi-user curation intact)",
@@ -3033,7 +3076,7 @@ describe("share", () => {
3033
3076
  });
3034
3077
  });
3035
3078
 
3036
- it("currency-gated: refuses delete for legacy journal entry with no recorded remoteEtag", async () => {
3079
+ it("daemon-stopped deletion has no watcher intent and is safely refused", async () => {
3037
3080
  const companyRoot = path.join(tmpDir, "companies", "acme");
3038
3081
  fs.mkdirSync(companyRoot, { recursive: true });
3039
3082
  // Journal entry from before remoteEtag tracking — no etag to compare
@@ -3078,6 +3121,12 @@ describe("share", () => {
3078
3121
  reason: "missing-delete-intent",
3079
3122
  });
3080
3123
  expect(result.filesRefusedStale).toBe(1);
3124
+ expect(result.pathResults).toContainEqual({
3125
+ path: "legacy-no-etag.md",
3126
+ status: "refused",
3127
+ operation: "delete",
3128
+ reason: "missing-delete-intent",
3129
+ });
3081
3130
  });
3082
3131
 
3083
3132
  it("currency-gated: real-world /update-hq scenario — direction:'down' delete propagates when current", async () => {
@@ -3351,6 +3400,58 @@ describe("share", () => {
3351
3400
  // passing in the allowed top-level directories — share() trusts its
3352
3401
  // `paths` input.
3353
3402
 
3403
+ it("plans an intent-backed personal delete from an explicit missing scope root", async () => {
3404
+ const key = "deleted-top-level.md";
3405
+ const journalSlug = "personal-scoped-delete";
3406
+ const journalPath = path.join(
3407
+ stateDir,
3408
+ `sync-journal.${journalSlug}.json`,
3409
+ );
3410
+ fs.writeFileSync(
3411
+ journalPath,
3412
+ JSON.stringify({
3413
+ version: "2",
3414
+ lastSync: new Date().toISOString(),
3415
+ pulls: [],
3416
+ files: {
3417
+ [key]: {
3418
+ hash: "captured-hash",
3419
+ size: 8,
3420
+ syncedAt: new Date().toISOString(),
3421
+ direction: "up",
3422
+ remoteEtag: "captured-etag",
3423
+ kind: "file",
3424
+ localDeleteIntent: deleteIntent(
3425
+ "captured-etag",
3426
+ "captured-hash",
3427
+ ),
3428
+ },
3429
+ },
3430
+ }),
3431
+ );
3432
+
3433
+ const result = await share({
3434
+ // computePersonalVaultPaths() has no path to contribute after this
3435
+ // top-level file is gone. The watcher-captured root is the sole scope.
3436
+ paths: [],
3437
+ deleteScopeRoots: [key],
3438
+ company: "acme",
3439
+ vaultConfig: mockConfig,
3440
+ hqRoot: tmpDir,
3441
+ personalMode: true,
3442
+ journalSlug,
3443
+ skipUnchanged: true,
3444
+ propagateDeletes: true,
3445
+ propagateDeletePolicy: "owned-only",
3446
+ });
3447
+
3448
+ expect(result.filesDeleted).toBe(1);
3449
+ expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), key);
3450
+ expect(
3451
+ JSON.parse(fs.readFileSync(journalPath, "utf8")).files[key],
3452
+ ).toBeUndefined();
3453
+ });
3454
+
3354
3455
  // ── Decommission prefixes ──────────────────────────────────────────────────
3355
3456
  //
3356
3457
  // `decommissionPrefixes` is the "company was promoted to its own team
@@ -4059,10 +4160,12 @@ describe("share", () => {
4059
4160
  total,
4060
4161
  missing,
4061
4162
  direction = "up" as "up" | "down",
4163
+ withIntents = true,
4062
4164
  }: {
4063
4165
  total: number;
4064
4166
  missing: number;
4065
4167
  direction?: "up" | "down";
4168
+ withIntents?: boolean;
4066
4169
  }): { companyRoot: string; journalPath: string } {
4067
4170
  const companyRoot = path.join(tmpDir, "companies", "acme");
4068
4171
  fs.mkdirSync(companyRoot, { recursive: true });
@@ -4075,7 +4178,7 @@ describe("share", () => {
4075
4178
  direction: "up" | "down";
4076
4179
  remoteEtag: string;
4077
4180
  kind: "file";
4078
- localDeleteIntent: ReturnType<typeof deleteIntent>;
4181
+ localDeleteIntent?: ReturnType<typeof deleteIntent>;
4079
4182
  }
4080
4183
  > = {};
4081
4184
  for (let i = 0; i < total; i++) {
@@ -4087,7 +4190,9 @@ describe("share", () => {
4087
4190
  direction,
4088
4191
  remoteEtag: `etag-${i}`,
4089
4192
  kind: "file",
4090
- localDeleteIntent: deleteIntent(`etag-${i}`, "h"),
4193
+ ...(withIntents
4194
+ ? { localDeleteIntent: deleteIntent(`etag-${i}`, "h") }
4195
+ : {}),
4091
4196
  };
4092
4197
  if (i >= missing) {
4093
4198
  fs.writeFileSync(path.join(companyRoot, key), "hi");
@@ -4110,7 +4215,11 @@ describe("share", () => {
4110
4215
  });
4111
4216
 
4112
4217
  it("trips at the 11/100 boundary (11% > 10%, abs >= 10): refuses every delete and emits bulk-asymmetry event", async () => {
4113
- const { companyRoot, journalPath } = setupJournal({ total: 100, missing: 11 });
4218
+ const { companyRoot, journalPath } = setupJournal({
4219
+ total: 100,
4220
+ missing: 11,
4221
+ withIntents: false,
4222
+ });
4114
4223
  // currency-gated: remote HEAD would match every journal etag — guard
4115
4224
  // must trip BEFORE any HEAD round-trip occurs.
4116
4225
  vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => ({
@@ -4166,6 +4275,33 @@ describe("share", () => {
4166
4275
  expect(perKeyRefusals.length).toBe(11);
4167
4276
  });
4168
4277
 
4278
+ it("intent-backed 11/100 subtree disappearance bypasses the breaker and deletes every covered path", async () => {
4279
+ const { companyRoot } = setupJournal({ total: 100, missing: 11 });
4280
+ vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => ({
4281
+ etag: `etag-${parseInt(key.replace(/[^0-9]/g, ""), 10)}`,
4282
+ lastModified: new Date(),
4283
+ size: 5,
4284
+ }));
4285
+ const events: Array<{ type: string }> = [];
4286
+
4287
+ const result = await share({
4288
+ paths: [companyRoot],
4289
+ company: "acme",
4290
+ vaultConfig: mockConfig,
4291
+ hqRoot: tmpDir,
4292
+ skipUnchanged: true,
4293
+ propagateDeletes: true,
4294
+ propagateDeletePolicy: "currency-gated",
4295
+ onEvent: (e) => events.push(e as { type: string }),
4296
+ });
4297
+
4298
+ expect(result.filesDeleted).toBe(11);
4299
+ expect(deleteRemoteFile).toHaveBeenCalledTimes(11);
4300
+ expect(
4301
+ events.find((e) => e.type === "delete-refused-bulk-asymmetry"),
4302
+ ).toBeUndefined();
4303
+ });
4304
+
4169
4305
  it("does NOT trip at 9/100 (below ratio): all candidates delete normally", async () => {
4170
4306
  const { companyRoot } = setupJournal({ total: 100, missing: 9 });
4171
4307
  vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => ({
@@ -4232,7 +4368,11 @@ describe("share", () => {
4232
4368
  });
4233
4369
 
4234
4370
  it("fully-empty mirror (100/100) trips the guard — the canonical mass-delete failure mode", async () => {
4235
- const { companyRoot, journalPath } = setupJournal({ total: 50, missing: 50 });
4371
+ const { companyRoot, journalPath } = setupJournal({
4372
+ total: 50,
4373
+ missing: 50,
4374
+ withIntents: false,
4375
+ });
4236
4376
  vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => ({
4237
4377
  etag: `etag-${parseInt(key.replace(/[^0-9]/g, ""), 10)}`,
4238
4378
  lastModified: new Date(),
@@ -4288,7 +4428,11 @@ describe("share", () => {
4288
4428
  });
4289
4429
 
4290
4430
  it("owned-only policy also goes through the guard: 11/100 missing direction:'up' entries refused", async () => {
4291
- const { companyRoot, journalPath } = setupJournal({ total: 100, missing: 11 });
4431
+ const { companyRoot, journalPath } = setupJournal({
4432
+ total: 100,
4433
+ missing: 11,
4434
+ withIntents: false,
4435
+ });
4292
4436
 
4293
4437
  const events: Array<{ type: string }> = [];
4294
4438
  const result = await share({