@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.
- package/dist/active-company.d.ts +43 -0
- package/dist/active-company.d.ts.map +1 -0
- package/dist/active-company.js +132 -0
- package/dist/active-company.js.map +1 -0
- package/dist/active-company.test.d.ts +2 -0
- package/dist/active-company.test.d.ts.map +1 -0
- package/dist/active-company.test.js +149 -0
- package/dist/active-company.test.js.map +1 -0
- package/dist/bin/sync-runner-planning.d.ts +27 -1
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +33 -4
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner-planning.test.d.ts +2 -0
- package/dist/bin/sync-runner-planning.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-planning.test.js +115 -0
- package/dist/bin/sync-runner-planning.test.js.map +1 -0
- package/dist/bin/sync-runner.d.ts +22 -7
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +92 -16
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +357 -5
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +127 -2
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +88 -0
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/manifest-reconcile.d.ts +118 -5
- package/dist/manifest-reconcile.d.ts.map +1 -1
- package/dist/manifest-reconcile.js +319 -62
- package/dist/manifest-reconcile.js.map +1 -1
- package/dist/manifest-reconcile.test.js +824 -2
- package/dist/manifest-reconcile.test.js.map +1 -1
- package/dist/personal-vault-exclusions.d.ts +1 -6
- package/dist/personal-vault-exclusions.d.ts.map +1 -1
- package/dist/personal-vault-exclusions.js +34 -6
- package/dist/personal-vault-exclusions.js.map +1 -1
- package/dist/personal-vault-exclusions.test.js +22 -0
- package/dist/personal-vault-exclusions.test.js.map +1 -1
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +9 -1
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.js +27 -1
- package/dist/personal-vault.test.js.map +1 -1
- package/dist/vault-client.d.ts +8 -1
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +29 -1
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +10 -1
- package/dist/vault-client.test.js.map +1 -1
- package/package.json +1 -1
- package/pnpm-workspace.yaml +1 -1
- package/src/active-company.test.ts +188 -0
- package/src/active-company.ts +168 -0
- package/src/bin/sync-runner-planning.test.ts +131 -0
- package/src/bin/sync-runner-planning.ts +60 -7
- package/src/bin/sync-runner.test.ts +430 -10
- package/src/bin/sync-runner.ts +129 -23
- package/src/cli/reindex.test.ts +116 -0
- package/src/cli/reindex.ts +125 -2
- package/src/manifest-reconcile.test.ts +1019 -3
- package/src/manifest-reconcile.ts +424 -66
- package/src/personal-vault-exclusions.test.ts +24 -0
- package/src/personal-vault-exclusions.ts +35 -5
- package/src/personal-vault.test.ts +30 -0
- package/src/personal-vault.ts +9 -1
- package/src/vault-client.test.ts +12 -1
- package/src/vault-client.ts +44 -2
- package/test/joiner-manifest-reconcile.integration.test.ts +283 -0
package/src/bin/sync-runner.ts
CHANGED
|
@@ -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
|
-
| {
|
|
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:
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
|
|
726
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
|
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:
|
|
1460
|
+
targets: companyTargets,
|
|
1399
1461
|
completedCompanySlugs,
|
|
1400
|
-
|
|
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) {
|
package/src/cli/reindex.test.ts
CHANGED
|
@@ -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");
|
package/src/cli/reindex.ts
CHANGED
|
@@ -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),
|
|
4
|
-
*
|
|
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
|