@indigoai-us/hq-cloud 6.14.16 → 6.14.18

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 (69) hide show
  1. package/dist/active-company.d.ts +43 -0
  2. package/dist/active-company.d.ts.map +1 -0
  3. package/dist/active-company.js +132 -0
  4. package/dist/active-company.js.map +1 -0
  5. package/dist/active-company.test.d.ts +2 -0
  6. package/dist/active-company.test.d.ts.map +1 -0
  7. package/dist/active-company.test.js +149 -0
  8. package/dist/active-company.test.js.map +1 -0
  9. package/dist/bin/sync-runner-planning.d.ts +27 -1
  10. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  11. package/dist/bin/sync-runner-planning.js +33 -4
  12. package/dist/bin/sync-runner-planning.js.map +1 -1
  13. package/dist/bin/sync-runner-planning.test.d.ts +2 -0
  14. package/dist/bin/sync-runner-planning.test.d.ts.map +1 -0
  15. package/dist/bin/sync-runner-planning.test.js +115 -0
  16. package/dist/bin/sync-runner-planning.test.js.map +1 -0
  17. package/dist/bin/sync-runner.d.ts +22 -7
  18. package/dist/bin/sync-runner.d.ts.map +1 -1
  19. package/dist/bin/sync-runner.js +92 -16
  20. package/dist/bin/sync-runner.js.map +1 -1
  21. package/dist/bin/sync-runner.test.js +357 -5
  22. package/dist/bin/sync-runner.test.js.map +1 -1
  23. package/dist/cli/reindex.d.ts.map +1 -1
  24. package/dist/cli/reindex.js +127 -2
  25. package/dist/cli/reindex.js.map +1 -1
  26. package/dist/cli/reindex.test.js +88 -0
  27. package/dist/cli/reindex.test.js.map +1 -1
  28. package/dist/manifest-reconcile.d.ts +118 -5
  29. package/dist/manifest-reconcile.d.ts.map +1 -1
  30. package/dist/manifest-reconcile.js +319 -62
  31. package/dist/manifest-reconcile.js.map +1 -1
  32. package/dist/manifest-reconcile.test.js +824 -2
  33. package/dist/manifest-reconcile.test.js.map +1 -1
  34. package/dist/personal-vault-exclusions.d.ts +1 -6
  35. package/dist/personal-vault-exclusions.d.ts.map +1 -1
  36. package/dist/personal-vault-exclusions.js +34 -6
  37. package/dist/personal-vault-exclusions.js.map +1 -1
  38. package/dist/personal-vault-exclusions.test.js +22 -0
  39. package/dist/personal-vault-exclusions.test.js.map +1 -1
  40. package/dist/personal-vault.d.ts.map +1 -1
  41. package/dist/personal-vault.js +9 -1
  42. package/dist/personal-vault.js.map +1 -1
  43. package/dist/personal-vault.test.js +27 -1
  44. package/dist/personal-vault.test.js.map +1 -1
  45. package/dist/vault-client.d.ts +8 -1
  46. package/dist/vault-client.d.ts.map +1 -1
  47. package/dist/vault-client.js +29 -1
  48. package/dist/vault-client.js.map +1 -1
  49. package/dist/vault-client.test.js +10 -1
  50. package/dist/vault-client.test.js.map +1 -1
  51. package/package.json +1 -1
  52. package/pnpm-workspace.yaml +1 -1
  53. package/src/active-company.test.ts +188 -0
  54. package/src/active-company.ts +168 -0
  55. package/src/bin/sync-runner-planning.test.ts +131 -0
  56. package/src/bin/sync-runner-planning.ts +60 -7
  57. package/src/bin/sync-runner.test.ts +430 -10
  58. package/src/bin/sync-runner.ts +129 -23
  59. package/src/cli/reindex.test.ts +116 -0
  60. package/src/cli/reindex.ts +125 -2
  61. package/src/manifest-reconcile.test.ts +1019 -3
  62. package/src/manifest-reconcile.ts +424 -66
  63. package/src/personal-vault-exclusions.test.ts +24 -0
  64. package/src/personal-vault-exclusions.ts +35 -5
  65. package/src/personal-vault.test.ts +30 -0
  66. package/src/personal-vault.ts +9 -1
  67. package/src/vault-client.test.ts +12 -1
  68. package/src/vault-client.ts +44 -2
  69. package/test/joiner-manifest-reconcile.integration.test.ts +283 -0
@@ -140,10 +140,18 @@ import {
140
140
  buildFanoutPlan,
141
141
  emitFanoutPlan,
142
142
  resolveMembershipsForRun,
143
+ type RunnerTarget,
144
+ type SetupNeededReason,
143
145
  } from "./sync-runner-planning.js";
146
+ import {
147
+ seedActiveCompany,
148
+ type SeedActiveCompanyOptions,
149
+ type SeedActiveCompanyResult,
150
+ } from "../active-company.js";
144
151
  import {
145
152
  reconcileCompanyManifest,
146
153
  type ManifestReconcileOptions,
154
+ type ManifestReconcileResult,
147
155
  } from "../manifest-reconcile.js";
148
156
  import { executeCompanyFanout } from "./sync-runner-company.js";
149
157
  import { rollupAllComplete } from "./sync-runner-rollup.js";
@@ -333,11 +341,28 @@ export { PERSONAL_VAULT_EXCLUDED_TOP_LEVEL, computePersonalVaultPaths };
333
341
  * specific company".
334
342
  */
335
343
  export type RunnerEvent =
336
- | { type: "setup-needed" }
344
+ | {
345
+ /**
346
+ * The run cannot proceed. `reason` and `pendingInviteCount` are additive
347
+ * and optional by design: a consumer that only reads `type` behaves
348
+ * exactly as before, while /setup and /hq-sync can tell the user WHY they
349
+ * look solo — most usefully, that an invite is still waiting.
350
+ */
351
+ type: "setup-needed";
352
+ reason?: SetupNeededReason;
353
+ pendingInviteCount?: number;
354
+ }
337
355
  | { type: "auth-error"; message: string }
338
356
  | {
357
+ /**
358
+ * The resolved fanout plan. Carries whole `RunnerTarget`s: the runtime
359
+ * payload has always been the plan entries verbatim, so the previous
360
+ * `{uid, slug, name?}` declaration understated the wire contract (it
361
+ * already omitted `bucketName`, `personalMode`, and `journalSlug`).
362
+ * Consumers deserialize leniently and ignore fields they do not know.
363
+ */
339
364
  type: "fanout-plan";
340
- companies: Array<{ uid: string; slug: string; name?: string }>;
365
+ companies: RunnerTarget[];
341
366
  }
342
367
  | ({
343
368
  /**
@@ -632,7 +657,9 @@ export interface RunnerDeps {
632
657
  */
633
658
  reconcileManifest?: (
634
659
  options: ManifestReconcileOptions,
635
- ) => Promise<void>;
660
+ ) => Promise<ManifestReconcileResult>;
661
+ /** Internal test seam for `.hq/config.json` activeCompany seeding. */
662
+ seedActiveCompany?: (options: SeedActiveCompanyOptions) => SeedActiveCompanyResult;
636
663
  /** Internal: set when runRunner is invoked under the per-root operation lock. */
637
664
  operationLockAlreadyHeld?: boolean;
638
665
  /** Internal watch-loop result override; one-shot callers keep exit 0. */
@@ -698,10 +725,11 @@ async function runClaimDance(
698
725
  client: VaultClientSurface,
699
726
  claims: IdTokenClaims,
700
727
  stderr: { write: (chunk: string) => boolean | void },
701
- ): Promise<void> {
728
+ reportDiagnostic: RunnerDiagnosticReporter = () => undefined,
729
+ ): Promise<number> {
702
730
  try {
703
731
  const pending = await client.listMyPendingInvitesByEmail();
704
- if (pending.length === 0) return;
732
+ if (pending.length === 0) return 0;
705
733
 
706
734
  const displayName =
707
735
  claims.name ??
@@ -710,10 +738,15 @@ async function runClaimDance(
710
738
  "";
711
739
  const ownerSub = claims.sub ?? "";
712
740
  if (!ownerSub || !displayName) {
713
- stderr.write(
714
- "hq-sync-runner: skipping claim-dance — idToken missing sub/name\n",
715
- );
716
- return;
741
+ reportDiagnostic({
742
+ component: "claim-dance",
743
+ event: "runner.claim_dance.skipped",
744
+ message: "skipping claim-dance — idToken missing sub/name",
745
+ context: { pendingInviteCount: pending.length },
746
+ });
747
+ // The invites are real and still pending; the caller must be able to say
748
+ // so even though we could not claim them.
749
+ return pending.length;
717
750
  }
718
751
 
719
752
  const person = await client.ensureMyPersonEntity({
@@ -721,9 +754,19 @@ async function runClaimDance(
721
754
  displayName,
722
755
  });
723
756
  await client.claimPendingInvitesByEmail(person.uid);
757
+ return pending.length;
724
758
  } catch (err) {
725
- const msg = err instanceof Error ? err.message : String(err);
726
- stderr.write(`hq-sync-runner: claim-dance skipped${msg}\n`);
759
+ // Non-throwing by contract: a claim-dance failure must never take down a
760
+ // sync. But it stops being invisible a systematic failure here is exactly
761
+ // what makes a joiner look permanently solo.
762
+ reportDiagnostic({
763
+ component: "claim-dance",
764
+ event: "runner.claim_dance.skipped",
765
+ message: "claim-dance skipped",
766
+ err,
767
+ context: {},
768
+ });
769
+ return 0;
727
770
  }
728
771
  }
729
772
 
@@ -1276,11 +1319,18 @@ export async function runRunner(
1276
1319
  client,
1277
1320
  claims,
1278
1321
  stderr,
1279
- runClaimDance,
1322
+ runClaimDance: (client, claims, stderr) =>
1323
+ runClaimDance(client, claims, stderr, reportDiagnostic),
1280
1324
  listMemberships: listMembershipsWithRetry,
1281
1325
  });
1282
1326
  if (resolution.status === "setup-needed") {
1283
- emit({ type: "setup-needed" });
1327
+ emit({
1328
+ type: "setup-needed",
1329
+ reason: resolution.reason,
1330
+ ...(resolution.pendingInviteCount === undefined
1331
+ ? {}
1332
+ : { pendingInviteCount: resolution.pendingInviteCount }),
1333
+ });
1284
1334
  return 0;
1285
1335
  }
1286
1336
  memberships = resolution.memberships;
@@ -1335,7 +1385,7 @@ export async function runRunner(
1335
1385
  return isTransientNetworkError(err) ? TRANSIENT_NETWORK_EXIT : 1;
1336
1386
  }
1337
1387
  if (targetPlan.status === "setup-needed") {
1338
- emit({ type: "setup-needed" });
1388
+ emit({ type: "setup-needed", reason: targetPlan.reason });
1339
1389
  return 0;
1340
1390
  }
1341
1391
  const plan = targetPlan.plan;
@@ -1386,19 +1436,43 @@ export async function runRunner(
1386
1436
  // The personal-vault target runs last and can pull an older copy of the
1387
1437
  // manifest. Reconcile only after the entire pull fanout has settled so the
1388
1438
  // successfully materialized cloud directories restore their own entries.
1389
- if (parsed.direction !== "push") {
1439
+ //
1440
+ // `--companies` only. A single-company run (`--company <slug>`) and a
1441
+ // `--personal` run both see just one leg of the fanout, so neither has the
1442
+ // full picture the manifest is reconciled against; treating a partial view as
1443
+ // authoritative is how entries get lost. A push-only pass materializes
1444
+ // nothing new locally, so it has nothing to reconcile either.
1445
+ if (parsed.companies && parsed.direction !== "push") {
1446
+ const completedCompanySlugs = new Set(
1447
+ Array.from(fanout.stateByCompany.entries())
1448
+ .filter(([, state]) => state.status === "complete")
1449
+ .map(([slug]) => slug),
1450
+ );
1451
+ // The personal vault is never a manifest entry. The reconciler filters it
1452
+ // too, but that is its own internal invariant — excluding it here means
1453
+ // the personal target is never even handed across the seam.
1454
+ const companyTargets = plan.filter(
1455
+ (target) => target.personalMode !== true && target.slug !== "personal",
1456
+ );
1390
1457
  try {
1391
- const completedCompanySlugs = new Set(
1392
- Array.from(fanout.stateByCompany.entries())
1393
- .filter(([, state]) => state.status === "complete")
1394
- .map(([slug]) => slug),
1395
- );
1396
- await (deps.reconcileManifest ?? reconcileCompanyManifest)({
1458
+ const result = await (deps.reconcileManifest ?? reconcileCompanyManifest)({
1397
1459
  hqRoot: parsed.hqRoot,
1398
- targets: plan,
1460
+ targets: companyTargets,
1399
1461
  completedCompanySlugs,
1400
- getEntity: client.entity.get,
1462
+ reportDiagnostic: (diagnostic) =>
1463
+ reportDiagnostic({ component: "manifest-reconcile", ...diagnostic }),
1401
1464
  });
1465
+ // Only on an actual write. A steady-state sync stays silent, so an
1466
+ // unexpected diagnostic rate is itself the signal that skip-if-unchanged
1467
+ // has regressed.
1468
+ if (result.written) {
1469
+ reportDiagnostic({
1470
+ component: "manifest-reconcile",
1471
+ event: "manifest-reconciled",
1472
+ message: "restored company entries in companies/manifest.yaml",
1473
+ context: { added: result.added, updated: result.updated },
1474
+ });
1475
+ }
1402
1476
  } catch (err) {
1403
1477
  reportDiagnostic({
1404
1478
  component: "manifest-reconcile",
@@ -1408,6 +1482,38 @@ export async function runRunner(
1408
1482
  context: { hqRoot: parsed.hqRoot },
1409
1483
  });
1410
1484
  }
1485
+
1486
+ // A joiner whose manifest entry was just restored still has nothing telling
1487
+ // HQ which company is current. Seed it only when the answer cannot be wrong
1488
+ // — deliberately its own try, so a manifest failure does not also cost the
1489
+ // user their routing.
1490
+ try {
1491
+ const localCompanySlugs = companyTargets
1492
+ .map((target) => target.slug)
1493
+ .filter((slug) => completedCompanySlugs.has(slug));
1494
+ const seeded = (deps.seedActiveCompany ?? seedActiveCompany)({
1495
+ hqRoot: parsed.hqRoot,
1496
+ companySlugs: localCompanySlugs,
1497
+ reportDiagnostic: (diagnostic) =>
1498
+ reportDiagnostic({ component: "active-company", ...diagnostic }),
1499
+ });
1500
+ if (seeded.written) {
1501
+ reportDiagnostic({
1502
+ component: "active-company",
1503
+ event: "active-company-seeded",
1504
+ message: "seeded activeCompany in .hq/config.json",
1505
+ context: { activeCompany: seeded.activeCompany },
1506
+ });
1507
+ }
1508
+ } catch (err) {
1509
+ reportDiagnostic({
1510
+ component: "active-company",
1511
+ event: "runner.active_company.failed",
1512
+ message: "seeding activeCompany failed",
1513
+ err,
1514
+ context: { hqRoot: parsed.hqRoot },
1515
+ });
1516
+ }
1411
1517
  }
1412
1518
 
1413
1519
  if (rollup.needsReindex) {
@@ -90,6 +90,14 @@ describe("reindex", () => {
90
90
  fs.writeFileSync(path.join(dir, "SKILL.md"), "skill\n");
91
91
  }
92
92
 
93
+ // Write a namespaced slash-command source file, e.g.
94
+ // writeCommand("core/commands/demo.md") → root/core/commands/demo.md.
95
+ function writeCommand(rel: string, body = "command\n"): void {
96
+ const abs = path.join(root, rel);
97
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
98
+ fs.writeFileSync(abs, body);
99
+ }
100
+
93
101
  it("surfaces namespaced skills as .claude/skills/<ns>:<skill>/ wrappers", () => {
94
102
  writeSkill("core/skills/demo");
95
103
  fs.writeFileSync(path.join(root, "core/skills/demo/helper.md"), "h\n");
@@ -112,6 +120,114 @@ describe("reindex", () => {
112
120
  );
113
121
  });
114
122
 
123
+ // ── namespaced slash-command surfacing (parallel to skills) ─────────────
124
+ // reindex mirrors <ns>/commands/<cmd>.md into .claude/commands/<ns>:<cmd>.md
125
+ // as a single symlink per source file, using the same <ns>:<name> naming as
126
+ // skills so a company/core/personal/pack command shows up as /<ns>:<cmd>.
127
+
128
+ it("surfaces namespaced commands as .claude/commands/<ns>:<cmd>.md symlinks", () => {
129
+ writeCommand("core/commands/demo.md");
130
+ writeCommand("companies/acme/commands/widget.md");
131
+ writeCommand("personal/commands/note.md");
132
+ writeCommand("core/packages/hq-pack-demo/commands/hello.md");
133
+
134
+ const { status } = reindex({ repoRoot: root });
135
+ expect(status).toBe(0);
136
+
137
+ const link = (name: string) => path.join(root, ".claude/commands", name);
138
+
139
+ expect(fs.lstatSync(link("core:demo.md")).isSymbolicLink()).toBe(true);
140
+ expect(fs.readlinkSync(link("core:demo.md"))).toBe("../../core/commands/demo.md");
141
+
142
+ expect(fs.readlinkSync(link("acme:widget.md"))).toBe(
143
+ "../../companies/acme/commands/widget.md",
144
+ );
145
+ expect(fs.readlinkSync(link("personal:note.md"))).toBe(
146
+ "../../personal/commands/note.md",
147
+ );
148
+ expect(fs.readlinkSync(link("hq-pack-demo:hello.md"))).toBe(
149
+ "../../core/packages/hq-pack-demo/commands/hello.md",
150
+ );
151
+ });
152
+
153
+ it("skips `_`-prefixed, dotfile, and non-.md command files", () => {
154
+ writeCommand("core/commands/_partial.md");
155
+ writeCommand("core/commands/.hidden.md");
156
+ writeCommand("core/commands/notes.txt");
157
+ writeCommand("core/commands/real.md");
158
+
159
+ reindex({ repoRoot: root });
160
+
161
+ const cmds = path.join(root, ".claude/commands");
162
+ expect(fs.existsSync(path.join(cmds, "core:real.md"))).toBe(true);
163
+ expect(fs.existsSync(path.join(cmds, "core:_partial.md"))).toBe(false);
164
+ expect(fs.existsSync(path.join(cmds, "core:.hidden.md"))).toBe(false);
165
+ expect(fs.existsSync(path.join(cmds, "core:notes.txt"))).toBe(false);
166
+ });
167
+
168
+ it("prunes orphan managed command links when the source command disappears", () => {
169
+ writeCommand("core/commands/demo.md");
170
+ reindex({ repoRoot: root });
171
+ expect(fs.existsSync(path.join(root, ".claude/commands/core:demo.md"))).toBe(true);
172
+
173
+ fs.rmSync(path.join(root, "core/commands/demo.md"));
174
+ reindex({ repoRoot: root });
175
+ expect(fs.existsSync(path.join(root, ".claude/commands/core:demo.md"))).toBe(false);
176
+ });
177
+
178
+ it("is idempotent for commands — a second run keeps the same link", () => {
179
+ writeCommand("core/commands/demo.md");
180
+ expect(reindex({ repoRoot: root }).status).toBe(0);
181
+ expect(reindex({ repoRoot: root }).status).toBe(0);
182
+ expect(fs.readlinkSync(path.join(root, ".claude/commands/core:demo.md"))).toBe(
183
+ "../../core/commands/demo.md",
184
+ );
185
+ });
186
+
187
+ it("leaves hand-authored / non-managed command files alone", () => {
188
+ const cmds = path.join(root, ".claude/commands");
189
+ fs.mkdirSync(cmds, { recursive: true });
190
+ // A real file with no namespace colon → preserved.
191
+ fs.writeFileSync(path.join(cmds, "manual.md"), "manual\n");
192
+ // A real (non-symlink) file that happens to use a managed namespace prefix
193
+ // → still preserved; reindex only ever prunes symlinks it created.
194
+ fs.writeFileSync(path.join(cmds, "core:hand.md"), "hand\n");
195
+ // A composite symlink under an UNMANAGED namespace → preserved.
196
+ writeCommand("core/commands/other.md");
197
+ fs.symlinkSync("../../core/commands/other.md", path.join(cmds, "vendor:tool.md"));
198
+
199
+ reindex({ repoRoot: root });
200
+
201
+ expect(fs.existsSync(path.join(cmds, "manual.md"))).toBe(true);
202
+ expect(fs.existsSync(path.join(cmds, "core:hand.md"))).toBe(true);
203
+ expect(fs.lstatSync(path.join(cmds, "vendor:tool.md")).isSymbolicLink()).toBe(true);
204
+ });
205
+
206
+ it("preserves a hand-authored alias under a managed namespace (target basename differs)", () => {
207
+ // A live source command...
208
+ writeCommand("core/commands/demo.md");
209
+ // ...plus a hand-authored alias in the SAME (managed) namespace pointing at
210
+ // it under a different command name, with NO core/commands/alias.md source.
211
+ // A prefix-only ownership test would wrongly treat this as a reindex link
212
+ // and delete it; the exact-target cross-check must keep it.
213
+ const cmds = path.join(root, ".claude/commands");
214
+ fs.mkdirSync(cmds, { recursive: true });
215
+ fs.symlinkSync("../../core/commands/demo.md", path.join(cmds, "core:alias.md"));
216
+
217
+ reindex({ repoRoot: root });
218
+
219
+ // The alias survives, and the real managed link is still created.
220
+ expect(fs.lstatSync(path.join(cmds, "core:alias.md")).isSymbolicLink()).toBe(true);
221
+ expect(fs.readlinkSync(path.join(cmds, "core:alias.md"))).toBe("../../core/commands/demo.md");
222
+ expect(fs.existsSync(path.join(cmds, "core:demo.md"))).toBe(true);
223
+ });
224
+
225
+ it("does not create an empty .claude/commands when there are no command sources", () => {
226
+ writeSkill("core/skills/demo");
227
+ reindex({ repoRoot: root });
228
+ expect(fs.existsSync(path.join(root, ".claude/commands"))).toBe(false);
229
+ });
230
+
115
231
  it("does NOT mirror the personal overlay into core/<type>/ (read directly now)", () => {
116
232
  fs.mkdirSync(path.join(root, "personal/policies"), { recursive: true });
117
233
  fs.writeFileSync(path.join(root, "personal/policies/myrule.md"), "rule\n");
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * hq reindex — surfaces namespaced skills as Claude Code skill wrappers under
3
- * .claude/skills/<ns>:<skill>/ (one symlink per source file), prunes orphan
4
- * wrappers + legacy command symlinks (plus a migration cleanup of retired
3
+ * .claude/skills/<ns>:<skill>/ (one symlink per source file), surfaces
4
+ * namespaced slash commands as .claude/commands/<ns>:<cmd>.md symlinks (one per
5
+ * source <ns>/commands/<cmd>.md file), prunes orphan wrappers + command links +
6
+ * legacy command symlinks (plus a migration cleanup of retired
5
7
  * personal/{knowledge,policies,workers,settings} mirror symlinks in core/<type>/
6
8
  * — that overlay is now read directly from personal/, not mirrored), captures
7
9
  * this HQ tree's coding-harness session logs (Claude Code / Codex / Grok) into
@@ -510,6 +512,27 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
510
512
  }
511
513
  }
512
514
 
515
+ // --- Build (namespace, src_rel) pairs for slash commands ------------------
516
+ // Mirrors the skill namespaces but reads from <ns>/commands/ instead of
517
+ // <ns>/skills/. Built independently so a namespace with commands but no
518
+ // skills (or vice-versa) is handled on its own.
519
+ const cmdPairs: { ns: string; srcRel: string }[] = [];
520
+ const addCmdNs = (ns: string, srcRel: string): void => {
521
+ if (isDir(path.join(root, srcRel))) cmdPairs.push({ ns, srcRel });
522
+ };
523
+ for (const slug of globEntries(path.join(root, "companies"))) {
524
+ if (isDir(path.join(root, "companies", slug))) {
525
+ addCmdNs(slug, `companies/${slug}/commands`);
526
+ }
527
+ }
528
+ addCmdNs("core", "core/commands");
529
+ addCmdNs("personal", "personal/commands");
530
+ for (const pack of globEntries(path.join(root, "core", "packages"))) {
531
+ if (isDir(path.join(root, "core", "packages", pack))) {
532
+ addCmdNs(pack, `core/packages/${pack}/commands`);
533
+ }
534
+ }
535
+
513
536
  // --- Cleanup pass A: drop legacy .claude/commands/<ns>/<skill>.md links ----
514
537
  const commandsDir = path.join(root, ".claude", "commands");
515
538
  if (isDir(commandsDir)) {
@@ -690,6 +713,106 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
690
713
  }
691
714
  }
692
715
 
716
+ // --- Slash-command surfacing ----------------------------------------------
717
+ // Parallel to skills: mirror each namespace's commands/<cmd>.md into
718
+ // .claude/commands/<ns>:<cmd>.md, so a company/core/personal/pack command
719
+ // shows up as /<ns>:<cmd> with the same <ns>:<name> naming as skills. A slash
720
+ // command is a single .md file (not a folder), so each wrapper is ONE symlink
721
+ // two levels below REPO_ROOT — no per-file wrapper directory.
722
+ const expectedCommands = new Set<string>();
723
+ const seenCmd = new Set<string>();
724
+ // Lazy: only materialize .claude/commands when a source command exists, so a
725
+ // tree with no commands never gains an empty directory. The orphan-prune pass
726
+ // below still runs against whatever already exists.
727
+ if (cmdPairs.length > 0) {
728
+ safeMkdir(commandsDir, ".claude/commands directory");
729
+ }
730
+ for (const { ns, srcRel } of cmdPairs) {
731
+ // First writer for a namespace wins (matches the skill dedup).
732
+ if (seenCmd.has(ns)) {
733
+ warn(
734
+ `reindex: command namespace '${ns}' already claimed by an earlier source; skipping ${srcRel}`,
735
+ );
736
+ continue;
737
+ }
738
+ seenCmd.add(ns);
739
+
740
+ const srcAbs = path.join(root, srcRel);
741
+ for (const entry of globEntries(srcAbs)) {
742
+ // Skip `_`-prefixed include partials (dotfiles already excluded by glob)
743
+ // and anything that isn't a regular `.md` command file.
744
+ if (entry.startsWith("_")) continue;
745
+ if (!entry.endsWith(".md")) continue;
746
+ if (!isFile(path.join(srcAbs, entry))) continue;
747
+
748
+ const wrapperName = encodeLocalVaultSegment(`${ns}:${entry}`);
749
+ const linkPath = path.join(commandsDir, wrapperName);
750
+ const relativeTarget = `../../${srcRel}/${entry}`;
751
+ expectedCommands.add(wrapperName);
752
+
753
+ const lst = lstatOrNull(linkPath);
754
+ if (lst && lst.isSymbolicLink()) {
755
+ const current = readlinkOrNull(linkPath);
756
+ if (current === relativeTarget) continue;
757
+ skippedPointsElsewhere++;
758
+ if (verbose) {
759
+ warn(
760
+ `reindex: .claude/commands/${wrapperName} already points to '${current}' (expected '${relativeTarget}'); leaving alone`,
761
+ );
762
+ }
763
+ continue;
764
+ } else if (lst) {
765
+ skippedNonSymlink++;
766
+ if (verbose) {
767
+ warn(`reindex: ${linkPath} already exists and is not a symlink; skipping`);
768
+ }
769
+ continue;
770
+ }
771
+
772
+ safeSymlink(relativeTarget, linkPath, `.claude/commands/${wrapperName}`);
773
+ }
774
+ }
775
+
776
+ // --- Cleanup: drop orphan <ns>:<cmd>.md command links ---------------------
777
+ // Symmetric to skill Cleanup pass B, adapted to the flat one-symlink-per-file
778
+ // shape (`../../` target, no wrapper directory). Only prunes symlinks reindex
779
+ // owns: a managed namespace whose target resolves into that namespace's
780
+ // commands/. Real files (even under a managed namespace) and composite links
781
+ // under an unmanaged namespace are left for a human to resolve.
782
+ for (const entry of globEntries(commandsDir)) {
783
+ const entryPath = path.join(commandsDir, entry);
784
+ const lst = lstatOrNull(entryPath);
785
+ if (!lst) continue; // accept broken symlinks (lstat still succeeds)
786
+ if (!lst.isSymbolicLink()) continue; // real files / legacy subdirs → not ours
787
+ const canonicalEntry = decodeLocalVaultSegment(entry);
788
+ if (!canonicalEntry.endsWith(".md")) continue;
789
+ if (!canonicalEntry.includes(":")) continue; // not a namespaced command
790
+ if (expectedCommands.has(entry)) continue; // maintained this run
791
+
792
+ const ns = canonicalEntry.slice(0, canonicalEntry.indexOf(":"));
793
+ const cmdName = canonicalEntry.slice(canonicalEntry.indexOf(":") + 1);
794
+ const target = readlinkOrNull(entryPath);
795
+ // A reindex-created link points at EXACTLY ../../<managed-base>/commands/
796
+ // <cmdName> — its basename equals the decoded command name. Cross-check the
797
+ // full expected target (not just the namespace-dir prefix) so a hand-authored
798
+ // alias such as `core:alias.md -> ../../core/commands/demo.md` (basename
799
+ // 'demo.md' != 'alias.md', and with no core/commands/alias.md source) is
800
+ // preserved rather than pruned as if reindex owned it.
801
+ const managedTargets: string[] = [];
802
+ if (ns === "personal") managedTargets.push(`../../personal/commands/${cmdName}`);
803
+ if (ns === "core") managedTargets.push(`../../core/commands/${cmdName}`);
804
+ managedTargets.push(`../../companies/${ns}/commands/${cmdName}`);
805
+ managedTargets.push(`../../core/packages/${ns}/commands/${cmdName}`);
806
+ if (target === null || !managedTargets.includes(target)) continue;
807
+
808
+ // Managed command link with no corresponding live source → drop.
809
+ try {
810
+ fs.rmSync(entryPath);
811
+ } catch {
812
+ /* best-effort */
813
+ }
814
+ }
815
+
693
816
  // --- Personal-overlay mirroring: RETIRED + migration cleanup ---------------
694
817
  // personal/{knowledge,policies,workers,settings} is now read DIRECTLY by the
695
818
  // HQ tooling (the inject-policy-on-trigger hook reads personal/policies, the