@indigoai-us/hq-cloud 6.1.0 → 6.2.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 (56) hide show
  1. package/dist/bin/sync-runner.d.ts.map +1 -1
  2. package/dist/bin/sync-runner.js +18 -0
  3. package/dist/bin/sync-runner.js.map +1 -1
  4. package/dist/cli/index.d.ts +2 -2
  5. package/dist/cli/index.d.ts.map +1 -1
  6. package/dist/cli/index.js +2 -2
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/cli/reindex.d.ts +4 -11
  9. package/dist/cli/reindex.d.ts.map +1 -1
  10. package/dist/cli/reindex.js +336 -30
  11. package/dist/cli/reindex.js.map +1 -1
  12. package/dist/cli/reindex.test.d.ts +3 -3
  13. package/dist/cli/reindex.test.js +36 -11
  14. package/dist/cli/reindex.test.js.map +1 -1
  15. package/dist/cli/rescue-core.d.ts +36 -0
  16. package/dist/cli/rescue-core.d.ts.map +1 -0
  17. package/dist/cli/rescue-core.js +1536 -0
  18. package/dist/cli/rescue-core.js.map +1 -0
  19. package/dist/cli/rescue-drift-reconcile.test.js +33 -10
  20. package/dist/cli/rescue-drift-reconcile.test.js.map +1 -1
  21. package/dist/cli/rescue-mtime-preserve.test.js +36 -12
  22. package/dist/cli/rescue-mtime-preserve.test.js.map +1 -1
  23. package/dist/cli/rescue.d.ts +4 -10
  24. package/dist/cli/rescue.d.ts.map +1 -1
  25. package/dist/cli/rescue.js +14 -37
  26. package/dist/cli/rescue.js.map +1 -1
  27. package/dist/cli/rescue.reindex.test.js +9 -8
  28. package/dist/cli/rescue.reindex.test.js.map +1 -1
  29. package/dist/cli/rescue.test.js +1 -10
  30. package/dist/cli/rescue.test.js.map +1 -1
  31. package/dist/index.d.ts +2 -2
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +2 -2
  34. package/dist/index.js.map +1 -1
  35. package/dist/lib/conflict-index.d.ts +40 -0
  36. package/dist/lib/conflict-index.d.ts.map +1 -1
  37. package/dist/lib/conflict-index.js +121 -0
  38. package/dist/lib/conflict-index.js.map +1 -1
  39. package/dist/lib/conflict.test.js +145 -1
  40. package/dist/lib/conflict.test.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/bin/sync-runner.ts +18 -0
  43. package/src/cli/index.ts +2 -2
  44. package/src/cli/reindex.test.ts +45 -12
  45. package/src/cli/reindex.ts +345 -36
  46. package/src/cli/rescue-core.ts +1650 -0
  47. package/src/cli/rescue-drift-reconcile.test.ts +33 -12
  48. package/src/cli/rescue-mtime-preserve.test.ts +36 -15
  49. package/src/cli/rescue.reindex.test.ts +9 -8
  50. package/src/cli/rescue.test.ts +1 -11
  51. package/src/cli/rescue.ts +15 -40
  52. package/src/index.ts +2 -2
  53. package/src/lib/conflict-index.ts +146 -0
  54. package/src/lib/conflict.test.ts +171 -0
  55. package/scripts/reindex.sh +0 -318
  56. package/scripts/replace-rescue.sh +0 -1522
@@ -1,30 +1,22 @@
1
1
  /**
2
- * hq reindex — 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.
2
+ * hq reindex — surfaces namespaced skills as Claude Code skill wrappers under
3
+ * .claude/skills/<ns>:<skill>/ (one symlink per source file), mirrors
4
+ * personal/{knowledge,policies,workers,settings}/<entry> into core/<type>/,
5
+ * prunes orphan wrappers + legacy command symlinks, and regenerates the
6
+ * workers registry.
5
7
  *
6
- * The implementation lives in scripts/reindex.sh, shipped with this package.
7
- * This module resolves that script relative to the package (dist/cli → package
8
- * root) and execs it against the caller's HQ root. Historically the script ran
9
- * directly as a Claude Code hook inside hq-core (named "master-sync"); it now
10
- * lives here so a single copy is maintained, and the hq-core hook is a thin
11
- * shim over `hq reindex`.
8
+ * The logic historically lived in a bundled bash script (scripts/reindex.sh)
9
+ * that this module exec'd via `bash`. It is now implemented natively in
10
+ * TypeScript so the package ships a single, testable code path with no runtime
11
+ * dependency on a shipped shell script. Behaviour is preserved exactly: the
12
+ * same skill wrappers, personal-overlay mirrors, legacy-symlink cleanup, and
13
+ * idempotent re-runs as before. Historically the script ran directly as a
14
+ * Claude Code hook inside hq-core (named "master-sync"); the hq-core hook
15
+ * remains a thin shim over `hq reindex`.
12
16
  */
13
17
  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 reindex.sh. From the compiled module at
22
- * dist/cli/reindex.js, the package root is two levels up; the script lives
23
- * at <package-root>/scripts/reindex.sh.
24
- */
25
- export function reindexScriptPath(): string {
26
- return path.resolve(__dirname, "..", "..", "scripts", "reindex.sh");
27
- }
18
+ import * as fs from "fs";
19
+ import * as path from "path";
28
20
 
29
21
  export interface ReindexOptions {
30
22
  /** HQ root to operate on. Defaults to process.cwd(). */
@@ -32,26 +24,343 @@ export interface ReindexOptions {
32
24
  }
33
25
 
34
26
  export interface ReindexResult {
35
- /** Exit status of the underlying script (0 = success). */
27
+ /** 0 = success; 1 = invalid repo root (mirrors the old script's exit codes). */
36
28
  status: number;
37
29
  }
38
30
 
31
+ // --- small fs helpers (each mirrors a bash test operator) -------------------
32
+
33
+ /** `[ -d "$p" ]` — exists and is a directory (follows symlinks). */
34
+ function isDir(p: string): boolean {
35
+ try {
36
+ return fs.statSync(p).isDirectory();
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ /** `[ -f "$p" ]` — exists and is a regular file (follows symlinks). */
43
+ function isFile(p: string): boolean {
44
+ try {
45
+ return fs.statSync(p).isFile();
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ /** `[ -e "$p" ]` — exists, dereferencing symlinks (a broken symlink is false). */
52
+ function existsFollow(p: string): boolean {
53
+ try {
54
+ fs.statSync(p);
55
+ return true;
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ /** `[ -x "$p" ]` — exists and is executable. */
62
+ function isExecutable(p: string): boolean {
63
+ try {
64
+ fs.accessSync(p, fs.constants.X_OK);
65
+ return true;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ /** lstat without throwing (null when the path doesn't exist). */
72
+ function lstatOrNull(p: string): fs.Stats | null {
73
+ try {
74
+ return fs.lstatSync(p);
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /** readlink without throwing (null when not a symlink / missing). */
81
+ function readlinkOrNull(p: string): string | null {
82
+ try {
83
+ return fs.readlinkSync(p);
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
39
89
  /**
40
- * Run reindex against an HQ root. Synchronous the script is cheap and
90
+ * Entries of `dir` matching a bash `dir/*` glob: dotfiles excluded (no
91
+ * dotglob), sorted lexicographically (bash glob order). Missing dir → [].
92
+ */
93
+ function globEntries(dir: string): string[] {
94
+ let names: string[];
95
+ try {
96
+ names = fs.readdirSync(dir);
97
+ } catch {
98
+ return [];
99
+ }
100
+ return names.filter((n) => !n.startsWith(".")).sort();
101
+ }
102
+
103
+ function warn(msg: string): void {
104
+ process.stderr.write(`${msg}\n`);
105
+ }
106
+
107
+ // --- legacy `.claude/commands/<ns>/<skill>.md` symlink matcher --------------
108
+ // Mirrors the bash `case` patterns; `*` matches any chars (including `/`).
109
+ function isLegacyCommandTarget(t: string): boolean {
110
+ return (
111
+ /^\.\.\/\.\.\/\.\.\/companies\/.*\/skills\/.*\/SKILL\.md$/.test(t) ||
112
+ /^\.\.\/\.\.\/\.\.\/core\/skills\/.*\/SKILL\.md$/.test(t) ||
113
+ /^\.\.\/\.\.\/\.\.\/personal\/skills\/.*\/SKILL\.md$/.test(t) ||
114
+ /^\.\.\/\.\.\/\.\.\/core\/packages\/.*\/skills\/.*\/SKILL\.md$/.test(t)
115
+ );
116
+ }
117
+
118
+ /**
119
+ * Run reindex against an HQ root. Synchronous — the work is cheap and
41
120
  * idempotent, and callers (the hook shim, sync/rescue, tests) want the exit
42
- * status. stdout/stderr from the script are forwarded to stderr so the
43
- * caller's stdout stays clean (hooks must not emit stdout that the agent
44
- * interprets).
121
+ * status. Diagnostics are written to stderr so the caller's stdout stays clean
122
+ * (hooks must not emit stdout that the agent interprets).
45
123
  */
46
124
  export function reindex(opts: ReindexOptions = {}): ReindexResult {
47
- const repoRoot = opts.repoRoot ?? process.cwd();
48
- const script = reindexScriptPath();
49
- const res = spawnSync("bash", [script, repoRoot], {
50
- stdio: ["ignore", "inherit", "inherit"],
51
- });
52
- if (res.error) {
53
- process.stderr.write(`reindex: failed to run ${script}: ${res.error.message}\n`);
125
+ const rawRoot = opts.repoRoot ?? process.cwd();
126
+ if (!isDir(rawRoot)) {
127
+ warn(`reindex: REPO_ROOT '${rawRoot}' is not a directory`);
54
128
  return { status: 1 };
55
129
  }
56
- return { status: res.status ?? 1 };
130
+ const root = path.resolve(rawRoot);
131
+
132
+ fs.mkdirSync(path.join(root, ".claude", "skills"), { recursive: true });
133
+
134
+ // --- Build (namespace, src_rel) pairs -------------------------------------
135
+ const pairs: { ns: string; srcRel: string }[] = [];
136
+ const addNs = (ns: string, srcRel: string): void => {
137
+ if (isDir(path.join(root, srcRel))) pairs.push({ ns, srcRel });
138
+ };
139
+
140
+ for (const slug of globEntries(path.join(root, "companies"))) {
141
+ if (isDir(path.join(root, "companies", slug))) {
142
+ addNs(slug, `companies/${slug}/skills`);
143
+ }
144
+ }
145
+ addNs("core", "core/skills");
146
+ addNs("personal", "personal/skills");
147
+ for (const pack of globEntries(path.join(root, "core", "packages"))) {
148
+ if (isDir(path.join(root, "core", "packages", pack))) {
149
+ addNs(pack, `core/packages/${pack}/skills`);
150
+ }
151
+ }
152
+
153
+ // --- Cleanup pass A: drop legacy .claude/commands/<ns>/<skill>.md links ----
154
+ const commandsDir = path.join(root, ".claude", "commands");
155
+ if (isDir(commandsDir)) {
156
+ for (const nsName of globEntries(commandsDir)) {
157
+ const cmdNsDir = path.join(commandsDir, nsName);
158
+ // Only real directories (bash `*/`); folder-level symlinks are left for
159
+ // a human to resolve. lstat().isDirectory() is false for a symlink, so
160
+ // this single check covers both bash guards.
161
+ const st = lstatOrNull(cmdNsDir);
162
+ if (!st || !st.isDirectory()) continue;
163
+
164
+ for (const f of globEntries(cmdNsDir)) {
165
+ if (!f.endsWith(".md")) continue;
166
+ const fp = path.join(cmdNsDir, f);
167
+ const fst = lstatOrNull(fp);
168
+ if (!fst || !fst.isSymbolicLink()) continue;
169
+ const target = readlinkOrNull(fp);
170
+ if (target && isLegacyCommandTarget(target)) {
171
+ try {
172
+ fs.rmSync(fp);
173
+ } catch {
174
+ /* best-effort */
175
+ }
176
+ }
177
+ }
178
+
179
+ // rmdir if empty; ignore "directory not empty".
180
+ try {
181
+ fs.rmdirSync(cmdNsDir);
182
+ } catch {
183
+ /* not empty / gone — fine */
184
+ }
185
+ }
186
+ }
187
+
188
+ // --- Skill wrapper creation -----------------------------------------------
189
+ const expectedWrappers = new Set<string>();
190
+ const seen = new Set<string>();
191
+
192
+ for (const { ns, srcRel } of pairs) {
193
+ // First writer for a namespace wins.
194
+ if (seen.has(ns)) {
195
+ warn(
196
+ `reindex: namespace '${ns}' already claimed by an earlier source; skipping ${srcRel}`,
197
+ );
198
+ continue;
199
+ }
200
+ seen.add(ns);
201
+
202
+ const srcAbs = path.join(root, srcRel);
203
+ for (const skillName of globEntries(srcAbs)) {
204
+ const skillPath = path.join(srcAbs, skillName);
205
+ if (!isDir(skillPath)) continue;
206
+ // Skip `_shared`, `_template`, etc. (dotfiles already excluded by glob).
207
+ if (skillName.startsWith("_")) continue;
208
+ if (!isFile(path.join(skillPath, "SKILL.md"))) continue;
209
+
210
+ const wrapperName = `${ns}:${skillName}`;
211
+ const wrapper = path.join(root, ".claude", "skills", wrapperName);
212
+ expectedWrappers.add(wrapperName);
213
+
214
+ const wst = lstatOrNull(wrapper);
215
+ // If something non-directory, non-symlink occupies the slot, bail.
216
+ if (wst && !wst.isSymbolicLink() && !wst.isDirectory()) {
217
+ warn(`reindex: ${wrapper} exists and is not a directory; skipping`);
218
+ continue;
219
+ }
220
+ // If it's a symlink (legacy directory-symlink form), replace it with a
221
+ // real directory of per-file symlinks.
222
+ if (wst && wst.isSymbolicLink()) {
223
+ try {
224
+ fs.rmSync(wrapper);
225
+ } catch {
226
+ /* best-effort */
227
+ }
228
+ }
229
+ fs.mkdirSync(wrapper, { recursive: true });
230
+
231
+ // Symlink every (non-hidden) entry in the source skill folder. The
232
+ // wrapper lives three levels below REPO_ROOT.
233
+ for (const entry of globEntries(skillPath)) {
234
+ const entryPath = path.join(skillPath, entry);
235
+ if (!existsFollow(entryPath)) continue;
236
+ const linkPath = path.join(wrapper, entry);
237
+ const relativeTarget = `../../../${srcRel}/${skillName}/${entry}`;
238
+
239
+ const lst = lstatOrNull(linkPath);
240
+ if (lst && lst.isSymbolicLink()) {
241
+ const current = readlinkOrNull(linkPath);
242
+ if (current === relativeTarget) continue;
243
+ warn(
244
+ `reindex: .claude/skills/${wrapperName}/${entry} already points to '${current}' (expected '${relativeTarget}'); leaving alone`,
245
+ );
246
+ continue;
247
+ } else if (lst) {
248
+ warn(`reindex: ${linkPath} already exists and is not a symlink; skipping`);
249
+ continue;
250
+ }
251
+
252
+ fs.symlinkSync(relativeTarget, linkPath);
253
+ }
254
+
255
+ // Prune symlinks in the wrapper whose source entry no longer exists.
256
+ for (const entry of globEntries(wrapper)) {
257
+ const linkPath = path.join(wrapper, entry);
258
+ const lst = lstatOrNull(linkPath);
259
+ if (!lst || !lst.isSymbolicLink()) continue;
260
+ if (existsFollow(linkPath)) continue;
261
+ try {
262
+ fs.rmSync(linkPath);
263
+ } catch {
264
+ /* best-effort */
265
+ }
266
+ }
267
+ }
268
+ }
269
+
270
+ // --- Cleanup pass B: drop orphan <ns>:<skill> wrappers --------------------
271
+ const skillsDir = path.join(root, ".claude", "skills");
272
+ for (const entry of globEntries(skillsDir)) {
273
+ const entryPath = path.join(skillsDir, entry);
274
+ const lst = lstatOrNull(entryPath);
275
+ if (!lst) continue; // accept broken symlinks (lstat still succeeds)
276
+ if (!entry.includes(":")) continue; // not a namespaced wrapper
277
+ if (expectedWrappers.has(entry)) continue; // maintained this run
278
+
279
+ // A wrapper is "managed" only when its namespace prefix matches the
280
+ // namespace encoded in its symlink target — this distinguishes
281
+ // script-produced wrappers from hand-authored composite wrappers.
282
+ const ns = entry.slice(0, entry.indexOf(":"));
283
+ const matchTarget = (t: string | null, p: string): boolean => {
284
+ if (!t) return false;
285
+ if (t.startsWith(`${p}/personal/skills/`)) return ns === "personal";
286
+ if (t.startsWith(`${p}/core/skills/`)) return ns === "core";
287
+ if (t.startsWith(`${p}/companies/${ns}/skills/`)) return true;
288
+ if (t.startsWith(`${p}/core/packages/${ns}/skills/`)) return true;
289
+ return false;
290
+ };
291
+
292
+ let isManaged = false;
293
+ if (lst.isSymbolicLink()) {
294
+ // (a) directory-style symlink wrapper (older shape): 2-level target.
295
+ isManaged = matchTarget(readlinkOrNull(entryPath), "../..");
296
+ } else if (lst.isDirectory()) {
297
+ // (b) real directory of per-file symlinks (current shape): 3-level.
298
+ for (const f of globEntries(entryPath)) {
299
+ const fp = path.join(entryPath, f);
300
+ const fst = lstatOrNull(fp);
301
+ if (!fst || !fst.isSymbolicLink()) continue;
302
+ if (matchTarget(readlinkOrNull(fp), "../../..")) {
303
+ isManaged = true;
304
+ break;
305
+ }
306
+ }
307
+ }
308
+ if (!isManaged) continue;
309
+
310
+ // Managed-namespace wrapper with no corresponding live source → drop.
311
+ try {
312
+ if (lst.isSymbolicLink()) {
313
+ fs.rmSync(entryPath);
314
+ } else if (lst.isDirectory()) {
315
+ fs.rmSync(entryPath, { recursive: true, force: true });
316
+ }
317
+ } catch {
318
+ /* best-effort */
319
+ }
320
+ }
321
+
322
+ // --- Personal type mirroring ----------------------------------------------
323
+ // Mirror personal/<type>/<entry> into core/<type>/<entry> as symlinks.
324
+ for (const type of ["knowledge", "policies", "workers", "settings"]) {
325
+ const personalDir = path.join(root, "personal", type);
326
+ const coreDir = path.join(root, "core", type);
327
+
328
+ if (!isDir(personalDir)) continue;
329
+ fs.mkdirSync(coreDir, { recursive: true });
330
+
331
+ for (const entry of globEntries(personalDir)) {
332
+ const entryPath = path.join(personalDir, entry);
333
+ if (!existsFollow(entryPath)) continue;
334
+
335
+ const linkPath = path.join(coreDir, entry);
336
+ const relativeTarget = `../../personal/${type}/${entry}`;
337
+
338
+ const lst = lstatOrNull(linkPath);
339
+ if (lst && lst.isSymbolicLink()) {
340
+ const current = readlinkOrNull(linkPath);
341
+ if (current === relativeTarget) continue;
342
+ warn(
343
+ `reindex: core/${type}/${entry} already points to '${current}' (expected '${relativeTarget}'); leaving alone`,
344
+ );
345
+ continue;
346
+ } else if (lst) {
347
+ warn(`reindex: core/${type}/${entry} already exists and is not a symlink; skipping`);
348
+ continue;
349
+ }
350
+
351
+ fs.symlinkSync(relativeTarget, linkPath);
352
+ }
353
+ }
354
+
355
+ // --- Workers registry regeneration ----------------------------------------
356
+ // Source of truth: each worker.yaml. The registry is a derived index — the
357
+ // generator (when present in the operated-on tree) keeps it in sync.
358
+ // Best-effort and idempotent; never fails the reindex.
359
+ const genScript = path.join(root, "core", "scripts", "generate-workers-registry.sh");
360
+ if (isExecutable(genScript)) {
361
+ // Route the generator's stdout+stderr to our stderr (`>&2 2>&1`).
362
+ spawnSync("bash", [genScript], { stdio: ["ignore", 2, 2] });
363
+ }
364
+
365
+ return { status: 0 };
57
366
  }