@indigoai-us/hq-cloud 5.47.1 → 5.48.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 (57) hide show
  1. package/dist/cli/index.d.ts +4 -0
  2. package/dist/cli/index.d.ts.map +1 -1
  3. package/dist/cli/index.js +6 -0
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/cli/master-sync.d.ts +22 -0
  6. package/dist/cli/master-sync.d.ts.map +1 -0
  7. package/dist/cli/master-sync.js +44 -0
  8. package/dist/cli/master-sync.js.map +1 -0
  9. package/dist/cli/master-sync.test.d.ts +11 -0
  10. package/dist/cli/master-sync.test.d.ts.map +1 -0
  11. package/dist/cli/master-sync.test.js +73 -0
  12. package/dist/cli/master-sync.test.js.map +1 -0
  13. package/dist/cli/rescue.d.ts +63 -0
  14. package/dist/cli/rescue.d.ts.map +1 -0
  15. package/dist/cli/rescue.js +88 -0
  16. package/dist/cli/rescue.js.map +1 -0
  17. package/dist/cli/rescue.test.d.ts +2 -0
  18. package/dist/cli/rescue.test.d.ts.map +1 -0
  19. package/dist/cli/rescue.test.js +60 -0
  20. package/dist/cli/rescue.test.js.map +1 -0
  21. package/dist/cli/share.d.ts.map +1 -1
  22. package/dist/cli/share.js +5 -5
  23. package/dist/cli/share.js.map +1 -1
  24. package/dist/cli/share.test.js +1 -0
  25. package/dist/cli/share.test.js.map +1 -1
  26. package/dist/cli/sync-scope.test.js +1 -0
  27. package/dist/cli/sync-scope.test.js.map +1 -1
  28. package/dist/cli/sync.test.js +1 -0
  29. package/dist/cli/sync.test.js.map +1 -1
  30. package/dist/index.d.ts +5 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +5 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/s3.d.ts +18 -0
  35. package/dist/s3.d.ts.map +1 -1
  36. package/dist/s3.js +24 -0
  37. package/dist/s3.js.map +1 -1
  38. package/dist/s3.test.js +33 -1
  39. package/dist/s3.test.js.map +1 -1
  40. package/dist/watcher.js +2 -2
  41. package/dist/watcher.js.map +1 -1
  42. package/package.json +1 -1
  43. package/scripts/master-sync.sh +318 -0
  44. package/scripts/replace-rescue.sh +1400 -0
  45. package/src/cli/index.ts +10 -0
  46. package/src/cli/master-sync.test.ts +94 -0
  47. package/src/cli/master-sync.ts +56 -0
  48. package/src/cli/rescue.test.ts +65 -0
  49. package/src/cli/rescue.ts +122 -0
  50. package/src/cli/share.test.ts +1 -0
  51. package/src/cli/share.ts +5 -4
  52. package/src/cli/sync-scope.test.ts +1 -0
  53. package/src/cli/sync.test.ts +1 -0
  54. package/src/index.ts +9 -0
  55. package/src/s3.test.ts +50 -0
  56. package/src/s3.ts +25 -0
  57. package/src/watcher.ts +2 -2
package/src/cli/index.ts CHANGED
@@ -23,3 +23,13 @@ export type { AcceptOptions, AcceptResult } from "./accept.js";
23
23
 
24
24
  export { promote } from "./promote.js";
25
25
  export type { PromoteOptions, PromoteResult } from "./promote.js";
26
+
27
+ // Skill/personal-overlay mirroring + workers-registry regen (formerly the
28
+ // hq-core master-sync.sh hook).
29
+ export { masterSync, masterSyncScriptPath } from "./master-sync.js";
30
+ export type { MasterSyncOptions, MasterSyncResult } from "./master-sync.js";
31
+
32
+ // Drift-preserving HQ-core re-sync (formerly bundled only inside the HQ Sync
33
+ // menubar app). Now shared between the app and `hq rescue`.
34
+ export { rescue, rescueScriptPath, buildRescueArgs } from "./rescue.js";
35
+ export type { RescueOptions, RescueResult } from "./rescue.js";
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Unit tests for `hq master-sync` (the formerly-bash hq-core hook, now shipped
3
+ * in this package and invoked via the CLI).
4
+ *
5
+ * The logic itself lives in scripts/master-sync.sh; these tests exercise it via
6
+ * the masterSync() wrapper against a temp HQ tree, asserting the observable
7
+ * filesystem outcomes (skill wrappers, personal-overlay mirroring) rather than
8
+ * re-deriving the bash internals.
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
12
+ import * as fs from "fs";
13
+ import * as path from "path";
14
+ import * as os from "os";
15
+ import { masterSync, masterSyncScriptPath } from "./master-sync.js";
16
+
17
+ describe("masterSyncScriptPath", () => {
18
+ it("resolves to the bundled script and the file exists", () => {
19
+ const p = masterSyncScriptPath();
20
+ expect(p.endsWith(path.join("scripts", "master-sync.sh"))).toBe(true);
21
+ expect(fs.existsSync(p)).toBe(true);
22
+ });
23
+ });
24
+
25
+ describe("masterSync", () => {
26
+ let root: string;
27
+
28
+ beforeEach(() => {
29
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "ms-test-"));
30
+ });
31
+
32
+ afterEach(() => {
33
+ fs.rmSync(root, { recursive: true, force: true });
34
+ });
35
+
36
+ function writeSkill(rel: string): void {
37
+ const dir = path.join(root, rel);
38
+ fs.mkdirSync(dir, { recursive: true });
39
+ fs.writeFileSync(path.join(dir, "SKILL.md"), "skill\n");
40
+ }
41
+
42
+ it("surfaces namespaced skills as .claude/skills/<ns>:<skill>/ wrappers", () => {
43
+ writeSkill("core/skills/demo");
44
+ fs.writeFileSync(path.join(root, "core/skills/demo/helper.md"), "h\n");
45
+ writeSkill("companies/acme/skills/widget");
46
+
47
+ const { status } = masterSync({ repoRoot: root });
48
+ expect(status).toBe(0);
49
+
50
+ const coreWrapper = path.join(root, ".claude/skills/core:demo");
51
+ expect(fs.lstatSync(path.join(coreWrapper, "SKILL.md")).isSymbolicLink()).toBe(true);
52
+ expect(fs.readlinkSync(path.join(coreWrapper, "SKILL.md"))).toBe(
53
+ "../../../core/skills/demo/SKILL.md",
54
+ );
55
+ // Every source file is mirrored, not just SKILL.md.
56
+ expect(fs.existsSync(path.join(coreWrapper, "helper.md"))).toBe(true);
57
+
58
+ const acmeWrapper = path.join(root, ".claude/skills/acme:widget");
59
+ expect(fs.readlinkSync(path.join(acmeWrapper, "SKILL.md"))).toBe(
60
+ "../../../companies/acme/skills/widget/SKILL.md",
61
+ );
62
+ });
63
+
64
+ it("mirrors the personal overlay into core/<type>/", () => {
65
+ fs.mkdirSync(path.join(root, "personal/policies"), { recursive: true });
66
+ fs.writeFileSync(path.join(root, "personal/policies/myrule.md"), "rule\n");
67
+
68
+ const { status } = masterSync({ repoRoot: root });
69
+ expect(status).toBe(0);
70
+
71
+ const link = path.join(root, "core/policies/myrule.md");
72
+ expect(fs.lstatSync(link).isSymbolicLink()).toBe(true);
73
+ expect(fs.readlinkSync(link)).toBe("../../personal/policies/myrule.md");
74
+ });
75
+
76
+ it("prunes orphan managed wrappers when the source skill disappears", () => {
77
+ writeSkill("core/skills/demo");
78
+ masterSync({ repoRoot: root });
79
+ expect(fs.existsSync(path.join(root, ".claude/skills/core:demo"))).toBe(true);
80
+
81
+ fs.rmSync(path.join(root, "core/skills/demo"), { recursive: true, force: true });
82
+ masterSync({ repoRoot: root });
83
+ expect(fs.existsSync(path.join(root, ".claude/skills/core:demo"))).toBe(false);
84
+ });
85
+
86
+ it("is idempotent — a second run is a no-op", () => {
87
+ writeSkill("core/skills/demo");
88
+ expect(masterSync({ repoRoot: root }).status).toBe(0);
89
+ expect(masterSync({ repoRoot: root }).status).toBe(0);
90
+ expect(
91
+ fs.readlinkSync(path.join(root, ".claude/skills/core:demo/SKILL.md")),
92
+ ).toBe("../../../core/skills/demo/SKILL.md");
93
+ });
94
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * hq master-sync — surfaces namespaced skills as Claude Code skill wrappers,
3
+ * mirrors personal/{knowledge,policies,workers,settings} into core/, prunes
4
+ * orphan wrappers, and regenerates the workers registry.
5
+ *
6
+ * The implementation lives in scripts/master-sync.sh, shipped with this
7
+ * package. This module resolves that script relative to the package (dist/cli
8
+ * → package root) and execs it against the caller's HQ root. Historically the
9
+ * script ran directly as a Claude Code hook inside hq-core; it now lives here
10
+ * so a single copy is maintained, and the hq-core hook is a thin shim over
11
+ * `hq master-sync`.
12
+ */
13
+ import { spawnSync } from "child_process";
14
+ import { fileURLToPath } from "url";
15
+ import path from "path";
16
+
17
+ const __filename = fileURLToPath(import.meta.url);
18
+ const __dirname = path.dirname(__filename);
19
+
20
+ /**
21
+ * Absolute path to the bundled master-sync.sh. From the compiled module at
22
+ * dist/cli/master-sync.js, the package root is two levels up; the script lives
23
+ * at <package-root>/scripts/master-sync.sh.
24
+ */
25
+ export function masterSyncScriptPath(): string {
26
+ return path.resolve(__dirname, "..", "..", "scripts", "master-sync.sh");
27
+ }
28
+
29
+ export interface MasterSyncOptions {
30
+ /** HQ root to operate on. Defaults to process.cwd(). */
31
+ repoRoot?: string;
32
+ }
33
+
34
+ export interface MasterSyncResult {
35
+ /** Exit status of the underlying script (0 = success). */
36
+ status: number;
37
+ }
38
+
39
+ /**
40
+ * Run master-sync against an HQ root. Synchronous — the script is cheap and
41
+ * idempotent, and callers (the hook shim, tests) want the exit status.
42
+ * stdout/stderr from the script are forwarded to stderr so the caller's stdout
43
+ * stays clean (hooks must not emit stdout that the agent interprets).
44
+ */
45
+ export function masterSync(opts: MasterSyncOptions = {}): MasterSyncResult {
46
+ const repoRoot = opts.repoRoot ?? process.cwd();
47
+ const script = masterSyncScriptPath();
48
+ const res = spawnSync("bash", [script, repoRoot], {
49
+ stdio: ["ignore", "inherit", "inherit"],
50
+ });
51
+ if (res.error) {
52
+ process.stderr.write(`master-sync: failed to run ${script}: ${res.error.message}\n`);
53
+ return { status: 1 };
54
+ }
55
+ return { status: res.status ?? 1 };
56
+ }
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildRescueArgs, rescueScriptPath } from "./rescue.js";
3
+
4
+ describe("rescueScriptPath", () => {
5
+ it("resolves to the bundled script at the package root", () => {
6
+ const p = rescueScriptPath();
7
+ expect(p.endsWith("scripts/replace-rescue.sh")).toBe(true);
8
+ // From dist/cli/rescue.js the package root is two levels up; ensure we
9
+ // don't accidentally point inside dist/.
10
+ expect(p).not.toContain("/dist/scripts/");
11
+ });
12
+ });
13
+
14
+ describe("buildRescueArgs", () => {
15
+ it("emits no args for an empty option set (script defaults apply)", () => {
16
+ expect(buildRescueArgs()).toEqual([]);
17
+ });
18
+
19
+ it("maps the prod-update shape the menubar app uses", () => {
20
+ const args = buildRescueArgs({
21
+ hqRoot: "/Users/x/HQ",
22
+ source: "indigoai-us/hq-core",
23
+ ref: "v12.3.0",
24
+ floorSha: "a".repeat(40),
25
+ assumeYes: true,
26
+ });
27
+ expect(args).toEqual([
28
+ "--hq-root",
29
+ "/Users/x/HQ",
30
+ "--source",
31
+ "indigoai-us/hq-core",
32
+ "--ref",
33
+ "v12.3.0",
34
+ "--floor-sha",
35
+ "a".repeat(40),
36
+ "--yes",
37
+ ]);
38
+ });
39
+
40
+ it("joins narrow paths and repeats preserve flags", () => {
41
+ const args = buildRescueArgs({
42
+ paths: ["core", ".claude"],
43
+ preserve: ["foo", "bar"],
44
+ preserveSubpaths: ["core/keep.md"],
45
+ });
46
+ expect(args).toContain("--paths");
47
+ expect(args[args.indexOf("--paths") + 1]).toBe("core,.claude");
48
+ expect(args.filter((a) => a === "--preserve")).toHaveLength(2);
49
+ expect(args).toContain("foo");
50
+ expect(args).toContain("bar");
51
+ expect(args).toContain("--preserve-subpath");
52
+ expect(args).toContain("core/keep.md");
53
+ });
54
+
55
+ it("passes dry-run and no-backup toggles through", () => {
56
+ const args = buildRescueArgs({ dryRun: true, noBackup: true, noHistoryCheck: true });
57
+ expect(args).toContain("--dry-run");
58
+ expect(args).toContain("--no-backup");
59
+ expect(args).toContain("--no-history-check");
60
+ });
61
+
62
+ it("does not emit --paths for an empty path list", () => {
63
+ expect(buildRescueArgs({ paths: [] })).toEqual([]);
64
+ });
65
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * hq rescue — re-sync a local HQ tree to an upstream hq-core release (or a
3
+ * staging branch) WITHOUT destroying the user's local edits ("drift").
4
+ *
5
+ * The implementation lives in scripts/replace-rescue.sh, shipped with this
6
+ * package. This module resolves that script relative to the package (dist/cli
7
+ * → package root) and execs it against the caller's HQ root. The script was
8
+ * historically bundled inside the HQ Sync menubar app; it now lives here so a
9
+ * single copy is maintained and BOTH consumers — the menubar app (which
10
+ * bundles this package's copy) and `@indigoai-us/hq-cli`'s `hq rescue`
11
+ * command — drive the exact same rescue logic.
12
+ *
13
+ * The script is channel-agnostic: `--source <repo>` + `--ref <tag|branch>`
14
+ * select prod vs staging, and `--floor-sha` pins the three-way history floor.
15
+ * See scripts/replace-rescue.sh for the full classify/overlay/stamp algorithm.
16
+ */
17
+ import { spawnSync } from "child_process";
18
+ import { fileURLToPath } from "url";
19
+ import path from "path";
20
+
21
+ const __filename = fileURLToPath(import.meta.url);
22
+ const __dirname = path.dirname(__filename);
23
+
24
+ /**
25
+ * Absolute path to the bundled replace-rescue.sh. From the compiled module at
26
+ * dist/cli/rescue.js, the package root is two levels up; the script lives at
27
+ * <package-root>/scripts/replace-rescue.sh.
28
+ */
29
+ export function rescueScriptPath(): string {
30
+ return path.resolve(__dirname, "..", "..", "scripts", "replace-rescue.sh");
31
+ }
32
+
33
+ export interface RescueOptions {
34
+ /** HQ root to operate on. Passed as `--hq-root`. Defaults to the script's
35
+ * own resolution (cwd-based) when omitted. */
36
+ hqRoot?: string;
37
+ /** Upstream repo, e.g. `indigoai-us/hq-core`. Script default:
38
+ * `indigoai-us/hq-core-staging`. */
39
+ source?: string;
40
+ /** Git ref (tag or branch). Script default: `main`. */
41
+ ref?: string;
42
+ /** 40-char lowercase hex SHA pinning the three-way history floor. When
43
+ * omitted the script reads the on-disk sync stamp from core/core.yaml. */
44
+ floorSha?: string;
45
+ /** Narrow the wipe set to an explicit list of top-level paths (`--paths`,
46
+ * comma-joined). */
47
+ paths?: string[];
48
+ /** Extra top-level entries to always preserve (`--preserve`, repeatable). */
49
+ preserve?: string[];
50
+ /** Extra relative subpaths to carve out + restore (`--preserve-subpath`,
51
+ * repeatable). */
52
+ preserveSubpaths?: string[];
53
+ /** Plan only — classify + report, change nothing on disk (`--dry-run`). */
54
+ dryRun?: boolean;
55
+ /** Skip the interactive confirmation prompt (`--yes`). Required for any
56
+ * non-interactive caller. */
57
+ assumeYes?: boolean;
58
+ /** Skip the pre-op safety snapshot under ~/.hq/backups (`--no-backup`). */
59
+ noBackup?: boolean;
60
+ /** Override the backup root (`--backup-dir`). */
61
+ backupDir?: string;
62
+ /** Shallow-clone the source and skip the history-floor compare
63
+ * (`--no-history-check`). */
64
+ noHistoryCheck?: boolean;
65
+ /** Cloud-update mode (`--cloud-update`). */
66
+ cloudUpdate?: boolean;
67
+ /** GitHub token forwarded to the script as `GH_TOKEN` (avoids the
68
+ * anonymous-clone rate limit; required for private sources). */
69
+ ghToken?: string;
70
+ /** Escape hatch — additional raw args appended verbatim. */
71
+ extraArgs?: string[];
72
+ }
73
+
74
+ export interface RescueResult {
75
+ /** Exit status of the underlying script (0 = success). */
76
+ status: number;
77
+ }
78
+
79
+ /**
80
+ * Build the argv passed to bash (after the script path itself). Pure +
81
+ * exported so callers and tests can assert the flag mapping without spawning.
82
+ */
83
+ export function buildRescueArgs(opts: RescueOptions = {}): string[] {
84
+ const args: string[] = [];
85
+ if (opts.hqRoot) args.push("--hq-root", opts.hqRoot);
86
+ if (opts.source) args.push("--source", opts.source);
87
+ if (opts.ref) args.push("--ref", opts.ref);
88
+ if (opts.floorSha) args.push("--floor-sha", opts.floorSha);
89
+ if (opts.paths && opts.paths.length > 0) args.push("--paths", opts.paths.join(","));
90
+ for (const p of opts.preserve ?? []) args.push("--preserve", p);
91
+ for (const sp of opts.preserveSubpaths ?? []) args.push("--preserve-subpath", sp);
92
+ if (opts.noHistoryCheck) args.push("--no-history-check");
93
+ if (opts.noBackup) args.push("--no-backup");
94
+ if (opts.backupDir) args.push("--backup-dir", opts.backupDir);
95
+ if (opts.cloudUpdate) args.push("--cloud-update");
96
+ if (opts.dryRun) args.push("--dry-run");
97
+ if (opts.assumeYes) args.push("--yes");
98
+ for (const extra of opts.extraArgs ?? []) args.push(extra);
99
+ return args;
100
+ }
101
+
102
+ /**
103
+ * Run the rescue script against an HQ root. Synchronous — the script is
104
+ * long-running (clone + scan) and callers want the exit status + live output.
105
+ * stdout/stderr from the script are inherited so the user sees the scan log
106
+ * and the confirmation prompt (unless `assumeYes` is set).
107
+ */
108
+ export function rescue(opts: RescueOptions = {}): RescueResult {
109
+ const script = rescueScriptPath();
110
+ const args = buildRescueArgs(opts);
111
+ const env = { ...process.env };
112
+ if (opts.ghToken) env.GH_TOKEN = opts.ghToken;
113
+ const res = spawnSync("bash", [script, ...args], {
114
+ stdio: "inherit",
115
+ env,
116
+ });
117
+ if (res.error) {
118
+ process.stderr.write(`rescue: failed to run ${script}: ${res.error.message}\n`);
119
+ return { status: 1 };
120
+ }
121
+ return { status: res.status ?? 1 };
122
+ }
@@ -15,6 +15,7 @@ import type { VaultServiceConfig } from "../types.js";
15
15
  // preserving sibling that puts a zero-byte object with target metadata
16
16
  // instead of dereferencing the link.
17
17
  vi.mock("../s3.js", () => ({
18
+ toPosixKey: (key: string) => key.split("\\").join("/"),
18
19
  uploadFile: vi.fn().mockResolvedValue({ etag: '"upload-etag"' }),
19
20
  uploadSymlink: vi.fn().mockResolvedValue({ etag: '"upload-symlink-etag"' }),
20
21
  downloadFile: vi.fn().mockResolvedValue(undefined),
package/src/cli/share.ts CHANGED
@@ -12,6 +12,7 @@ import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../c
12
12
  import {
13
13
  uploadFile,
14
14
  uploadSymlink,
15
+ toPosixKey,
15
16
  headRemoteFile,
16
17
  deleteRemoteFile,
17
18
  downloadFile,
@@ -1384,7 +1385,7 @@ function collectFiles(
1384
1385
  console.error(` Warning: ${p} is outside company folder, skipping.`);
1385
1386
  continue;
1386
1387
  }
1387
- const relativePath = path.relative(syncRoot, absolutePath);
1388
+ const relativePath = toPosixKey(path.relative(syncRoot, absolutePath));
1388
1389
  // Probe the filter with both isDir hints — we don't know whether
1389
1390
  // the link's target is a file or a directory without
1390
1391
  // stat-following the link, which we explicitly avoid (it would
@@ -1414,7 +1415,7 @@ function collectFiles(
1414
1415
  if (!filter(absolutePath, true)) continue;
1415
1416
  results.push(...walkDir(absolutePath, syncRoot, filter));
1416
1417
  } else if (lstat.isFile()) {
1417
- const relativePath = path.relative(syncRoot, absolutePath);
1418
+ const relativePath = toPosixKey(path.relative(syncRoot, absolutePath));
1418
1419
  if (filter(absolutePath)) {
1419
1420
  results.push({ kind: "file", absolutePath, relativePath });
1420
1421
  }
@@ -1464,7 +1465,7 @@ function walkDir(
1464
1465
  results.push({
1465
1466
  kind: "symlink",
1466
1467
  absolutePath,
1467
- relativePath: path.relative(syncRoot, absolutePath),
1468
+ relativePath: toPosixKey(path.relative(syncRoot, absolutePath)),
1468
1469
  target: fs.readlinkSync(absolutePath),
1469
1470
  });
1470
1471
  continue;
@@ -1480,7 +1481,7 @@ function walkDir(
1480
1481
  results.push({
1481
1482
  kind: "file",
1482
1483
  absolutePath,
1483
- relativePath: path.relative(syncRoot, absolutePath),
1484
+ relativePath: toPosixKey(path.relative(syncRoot, absolutePath)),
1484
1485
  });
1485
1486
  }
1486
1487
  }
@@ -39,6 +39,7 @@ vi.mock("../s3.js", async () => {
39
39
  const innerPath = await import("path");
40
40
  const { vi: innerVi } = await import("vitest");
41
41
  return {
42
+ toPosixKey: (key: string) => key.split("\\").join("/"),
42
43
  uploadFile: innerVi.fn().mockResolvedValue(undefined),
43
44
  downloadFile: innerVi
44
45
  .fn()
@@ -21,6 +21,7 @@ vi.mock("../s3.js", async () => {
21
21
  ];
22
22
 
23
23
  return {
24
+ toPosixKey: (key: string) => key.split("\\").join("/"),
24
25
  uploadFile: innerVi.fn().mockResolvedValue(undefined),
25
26
  downloadFile: innerVi.fn().mockImplementation(async (_ctx: unknown, _key: string, localPath: string) => {
26
27
  const dir = innerPath.dirname(localPath);
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ export {
18
18
  listRemoteFiles,
19
19
  deleteRemoteFile,
20
20
  headRemoteFile,
21
+ toPosixKey,
21
22
  } from "./s3.js";
22
23
 
23
24
  export type { RemoteFile, UploadAuthor } from "./s3.js";
@@ -211,6 +212,14 @@ export type { AcceptOptions, AcceptResult } from "./cli/index.js";
211
212
  export { promote } from "./cli/index.js";
212
213
  export type { PromoteOptions, PromoteResult } from "./cli/index.js";
213
214
 
215
+ // Skill/personal-overlay mirroring + workers-registry regen (`hq master-sync`).
216
+ export { masterSync, masterSyncScriptPath } from "./cli/index.js";
217
+ export type { MasterSyncOptions, MasterSyncResult } from "./cli/index.js";
218
+
219
+ // Drift-preserving HQ-core re-sync — shared by the HQ Sync app and `hq rescue`.
220
+ export { rescue, rescueScriptPath, buildRescueArgs } from "./cli/index.js";
221
+ export type { RescueOptions, RescueResult } from "./cli/index.js";
222
+
214
223
  export type {
215
224
  EntityContext,
216
225
  VaultCredentials,
package/src/s3.test.ts CHANGED
@@ -82,6 +82,7 @@ vi.mock("@aws-sdk/client-s3", () => {
82
82
  import {
83
83
  uploadFile,
84
84
  uploadSymlink,
85
+ toPosixKey,
85
86
  downloadFile,
86
87
  listRemoteFiles,
87
88
  SYMLINK_BODY_PREFIX,
@@ -108,6 +109,55 @@ function makeCtx(): EntityContext {
108
109
  };
109
110
  }
110
111
 
112
+ describe("backslash key normalization (Windows client → POSIX S3 key)", () => {
113
+ // Regression for the Ridges vault bug: a non-POSIX (Windows) HQ Sync client
114
+ // built object keys from path.relative(), whose separator is "\\". Stored
115
+ // verbatim, `knowledge\books-eoi.md` had no "/", so the vault listing (which
116
+ // splits keys on "/") rendered it flat at the root as one oddly-named file.
117
+ let tmpFile: string;
118
+
119
+ beforeEach(() => {
120
+ sentCommands.length = 0;
121
+ tmpFile = path.join(
122
+ os.tmpdir(),
123
+ `s3-backslash-test-${Date.now()}-${Math.random()}.md`,
124
+ );
125
+ fs.writeFileSync(tmpFile, "hello");
126
+ });
127
+
128
+ it("toPosixKey converts every backslash to a forward slash", () => {
129
+ expect(toPosixKey("knowledge\\books-eoi.md")).toBe("knowledge/books-eoi.md");
130
+ expect(toPosixKey("data\\sub\\boots-accounts.json")).toBe(
131
+ "data/sub/boots-accounts.json",
132
+ );
133
+ });
134
+
135
+ it("toPosixKey leaves an already-POSIX key unchanged (idempotent)", () => {
136
+ expect(toPosixKey("knowledge/books-eoi.md")).toBe("knowledge/books-eoi.md");
137
+ expect(toPosixKey("flat.md")).toBe("flat.md");
138
+ });
139
+
140
+ it("uploadFile stores a POSIX key when given a Windows-style backslash path", async () => {
141
+ await uploadFile(makeCtx(), tmpFile, "knowledge\\books-eoi.md");
142
+
143
+ const put = sentCommands.find((c) => c.name === "PutObjectCommand");
144
+ expect(put).toBeDefined();
145
+ expect(put!.input.Key).toBe("knowledge/books-eoi.md");
146
+ });
147
+
148
+ it("uploadSymlink stores a POSIX key when given a Windows-style backslash path", async () => {
149
+ await uploadSymlink(
150
+ makeCtx(),
151
+ "../knowledge-ridges/target.md",
152
+ "data\\boots-accounts.json",
153
+ );
154
+
155
+ const put = sentCommands.find((c) => c.name === "PutObjectCommand");
156
+ expect(put).toBeDefined();
157
+ expect(put!.input.Key).toBe("data/boots-accounts.json");
158
+ });
159
+ });
160
+
111
161
  describe("uploadFile", () => {
112
162
  let tmpFile: string;
113
163
 
package/src/s3.ts CHANGED
@@ -391,12 +391,35 @@ export async function primeUploads(
391
391
  await io.prime("put", putKeys);
392
392
  }
393
393
 
394
+ /**
395
+ * Normalize an S3 object key to POSIX ("/") separators.
396
+ *
397
+ * S3 keys are always "/"-separated, and the vault listing reconstructs the
398
+ * folder tree by splitting keys on "/". A non-POSIX (Windows) sync client
399
+ * builds a key from `path.relative(...)`, whose separator is the native "\\";
400
+ * stored verbatim, a nested key like `knowledge\books-eoi.md` contains no "/"
401
+ * and the listing renders it flat at the vault root as one oddly-named file.
402
+ * Converting every "\\" to "/" makes the stored key POSIX regardless of the
403
+ * client OS.
404
+ *
405
+ * Mirrors the prefix guard in hq-pro files-acl (`FORBIDDEN_CHARS = /[?#\\[\]]/`,
406
+ * which already rejects "\\" on browse/grant prefixes). Uploads go direct to
407
+ * S3 via vended STS creds, so the server never re-validates the uploaded key —
408
+ * the upload primitives (uploadFile / uploadSymlink) are the only shared choke
409
+ * point, and they call this so a backslash key can never be stored again.
410
+ */
411
+ export function toPosixKey(key: string): string {
412
+ return key.split("\\").join("/");
413
+ }
414
+
394
415
  export async function uploadFile(
395
416
  ctx: EntityContext,
396
417
  localPath: string,
397
418
  key: string,
398
419
  author?: UploadAuthor,
399
420
  ): Promise<{ etag: string }> {
421
+ // Boundary guardrail: never store a non-POSIX key (see toPosixKey).
422
+ key = toPosixKey(key);
400
423
  const io = resolveObjectIO(ctx);
401
424
  const body = fs.readFileSync(localPath);
402
425
 
@@ -459,6 +482,8 @@ export async function uploadSymlink(
459
482
  key: string,
460
483
  author?: UploadAuthor,
461
484
  ): Promise<{ etag: string }> {
485
+ // Boundary guardrail: never store a non-POSIX key (see toPosixKey).
486
+ key = toPosixKey(key);
462
487
  const io = resolveObjectIO(ctx);
463
488
  const symlinkBody = encodeSymlinkBody(target);
464
489
 
package/src/watcher.ts CHANGED
@@ -16,7 +16,7 @@ import type { FSWatcher } from "chokidar";
16
16
  import type { EntityContext } from "./types.js";
17
17
  import { createIgnoreFilter, isWithinSizeLimit } from "./ignore.js";
18
18
  import { readJournal, writeJournal, hashFile, updateEntry } from "./journal.js";
19
- import { uploadFile, deleteRemoteFile } from "./s3.js";
19
+ import { uploadFile, deleteRemoteFile, toPosixKey } from "./s3.js";
20
20
  import type { UploadAuthor } from "./s3.js";
21
21
  import { isPersonalVaultExcluded } from "./personal-vault-exclusions.js";
22
22
  import { PERSONAL_VAULT_EXCLUDED_TOP_LEVEL } from "./personal-vault.js";
@@ -259,7 +259,7 @@ export class SyncWatcher {
259
259
  }
260
260
 
261
261
  private queueChange(type: "add" | "change" | "unlink", absolutePath: string): void {
262
- const relativePath = path.relative(this.hqRoot, absolutePath);
262
+ const relativePath = toPosixKey(path.relative(this.hqRoot, absolutePath));
263
263
 
264
264
  // Skip files that exceed size limit
265
265
  if (type !== "unlink" && !isWithinSizeLimit(absolutePath)) {