@indigoai-us/hq-cloud 6.14.12 → 6.14.13

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 (78) hide show
  1. package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +22 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  3. package/dist/bin/sync-runner-company.js +6 -0
  4. package/dist/bin/sync-runner-company.js.map +1 -1
  5. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  6. package/dist/bin/sync-runner-planning.js +3 -1
  7. package/dist/bin/sync-runner-planning.js.map +1 -1
  8. package/dist/bin/sync-runner-watch-loop.d.ts +1 -0
  9. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  10. package/dist/bin/sync-runner-watch-loop.js +7 -1
  11. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  12. package/dist/bin/sync-runner-watch-routes.d.ts +1 -0
  13. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
  14. package/dist/bin/sync-runner-watch-routes.js +3 -0
  15. package/dist/bin/sync-runner-watch-routes.js.map +1 -1
  16. package/dist/bin/sync-runner.d.ts +10 -0
  17. package/dist/bin/sync-runner.d.ts.map +1 -1
  18. package/dist/bin/sync-runner.js +83 -33
  19. package/dist/bin/sync-runner.js.map +1 -1
  20. package/dist/bin/sync-runner.test.js +93 -5
  21. package/dist/bin/sync-runner.test.js.map +1 -1
  22. package/dist/cli/reindex.d.ts.map +1 -1
  23. package/dist/cli/reindex.js +23 -60
  24. package/dist/cli/reindex.js.map +1 -1
  25. package/dist/cli/reindex.test.d.ts +2 -2
  26. package/dist/cli/reindex.test.js +60 -109
  27. package/dist/cli/reindex.test.js.map +1 -1
  28. package/dist/cli/rescue.reindex.test.js +3 -2
  29. package/dist/cli/rescue.reindex.test.js.map +1 -1
  30. package/dist/cli/share.d.ts.map +1 -1
  31. package/dist/cli/share.js +12 -1
  32. package/dist/cli/share.js.map +1 -1
  33. package/dist/cli/share.test.js +14 -0
  34. package/dist/cli/share.test.js.map +1 -1
  35. package/dist/cli/sync.d.ts.map +1 -1
  36. package/dist/cli/sync.js +9 -1
  37. package/dist/cli/sync.js.map +1 -1
  38. package/dist/cli/sync.test.js +8 -0
  39. package/dist/cli/sync.test.js.map +1 -1
  40. package/dist/cognito-auth.d.ts +44 -12
  41. package/dist/cognito-auth.d.ts.map +1 -1
  42. package/dist/cognito-auth.js +440 -76
  43. package/dist/cognito-auth.js.map +1 -1
  44. package/dist/cognito-auth.test.js +174 -0
  45. package/dist/cognito-auth.test.js.map +1 -1
  46. package/dist/index.d.ts +1 -1
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +3 -2
  49. package/dist/index.js.map +1 -1
  50. package/dist/machine-auth.test.js +683 -5
  51. package/dist/machine-auth.test.js.map +1 -1
  52. package/dist/vault-client.d.ts +2 -1
  53. package/dist/vault-client.d.ts.map +1 -1
  54. package/dist/vault-client.js +45 -9
  55. package/dist/vault-client.js.map +1 -1
  56. package/dist/vault-client.test.js +77 -7
  57. package/dist/vault-client.test.js.map +1 -1
  58. package/package.json +2 -1
  59. package/src/bin/sync-runner-company.ts +5 -0
  60. package/src/bin/sync-runner-planning.ts +2 -1
  61. package/src/bin/sync-runner-watch-loop.ts +7 -1
  62. package/src/bin/sync-runner-watch-routes.ts +2 -0
  63. package/src/bin/sync-runner.test.ts +122 -4
  64. package/src/bin/sync-runner.ts +92 -32
  65. package/src/cli/reindex.test.ts +63 -120
  66. package/src/cli/reindex.ts +23 -62
  67. package/src/cli/rescue.reindex.test.ts +3 -2
  68. package/src/cli/share.test.ts +20 -0
  69. package/src/cli/share.ts +7 -1
  70. package/src/cli/sync.test.ts +14 -0
  71. package/src/cli/sync.ts +5 -1
  72. package/src/cognito-auth.test.ts +227 -0
  73. package/src/cognito-auth.ts +533 -71
  74. package/src/index.ts +5 -1
  75. package/src/machine-auth.test.ts +808 -5
  76. package/src/vault-client.test.ts +100 -11
  77. package/src/vault-client.ts +55 -10
  78. package/test/share-sync.integration.test.ts +12 -9
@@ -62,7 +62,9 @@ import * as fs from "fs";
62
62
  import { randomUUID } from "node:crypto";
63
63
  import { fileURLToPath } from "url";
64
64
  import {
65
+ CognitoRefreshError,
65
66
  getValidAccessToken,
67
+ invalidateCachedTokensByFingerprint,
66
68
  loadCachedTokens,
67
69
  VaultClient,
68
70
  VaultAuthError,
@@ -532,6 +534,16 @@ export type { PullScope };
532
534
  */
533
535
  const MEMBERSHIPS_RETRY_BACKOFFS_MS: readonly number[] = [50, 100];
534
536
 
537
+ const REAUTH_MESSAGE =
538
+ "Your HQ session needs a quick refresh. Sign in again to keep sync moving.";
539
+
540
+ /**
541
+ * Internal-only result used between a watch pass and its owning loop. The
542
+ * outer process still exits 0 (auth-required is a handled UX state), while
543
+ * the loop stops instead of retrying a known-unusable session forever.
544
+ */
545
+ export const AUTH_REQUIRED_PASS_EXIT = 18;
546
+
535
547
  /**
536
548
  * Call `listMyMemberships()` with up to 3 attempts and a small linear
537
549
  * backoff between them. The single network call that drives every cloud
@@ -589,6 +601,8 @@ export interface RunnerDeps {
589
601
  stderr?: { write: (chunk: string) => boolean | void };
590
602
  /** Resolve a valid access token. Defaults to `getValidAccessToken` non-interactive. */
591
603
  getAccessToken?: () => Promise<string>;
604
+ /** Invalidate only the rejected token generation. Injectable for tests. */
605
+ clearSession?: (tokenFingerprint?: string) => void;
592
606
  /**
593
607
  * Read the caller's identity claims (sub/email/name) off the cached Cognito
594
608
  * idToken. Defaults to decoding `loadCachedTokens().idToken`. Returns `null`
@@ -621,6 +635,8 @@ export interface RunnerDeps {
621
635
  ) => Promise<void>;
622
636
  /** Internal: set when runRunner is invoked under the per-root operation lock. */
623
637
  operationLockAlreadyHeld?: boolean;
638
+ /** Internal watch-loop result override; one-shot callers keep exit 0. */
639
+ authRequiredExitCode?: number;
624
640
  /** Share function (push phase). Defaults to `cli/share.share`. */
625
641
  share?: (options: ShareOptions) => Promise<ShareResult>;
626
642
  /**
@@ -1109,14 +1125,27 @@ export async function runRunner(
1109
1125
  const getAccessToken =
1110
1126
  deps.getAccessToken ??
1111
1127
  (() => getValidAccessToken(DEFAULT_COGNITO, { interactive: false }));
1128
+ const clearSession =
1129
+ deps.clearSession ?? invalidateCachedTokensByFingerprint;
1112
1130
  try {
1113
1131
  await getAccessToken();
1114
1132
  } catch (err) {
1133
+ if (
1134
+ (err instanceof CognitoRefreshError && !err.requiresReauth) ||
1135
+ isTransientNetworkError(err)
1136
+ ) {
1137
+ emit({
1138
+ type: "error",
1139
+ path: "(auth)",
1140
+ message: err instanceof Error ? err.message : String(err),
1141
+ });
1142
+ return TRANSIENT_NETWORK_EXIT;
1143
+ }
1115
1144
  emit({
1116
1145
  type: "auth-error",
1117
- message: err instanceof Error ? err.message : String(err),
1146
+ message: REAUTH_MESSAGE,
1118
1147
  });
1119
- return 0;
1148
+ return deps.authRequiredExitCode ?? 0;
1120
1149
  }
1121
1150
 
1122
1151
  // ---- vault client -----------------------------------------------------
@@ -1257,11 +1286,12 @@ export async function runRunner(
1257
1286
  memberships = resolution.memberships;
1258
1287
  } catch (err) {
1259
1288
  if (err instanceof VaultAuthError) {
1289
+ clearSession(err.tokenFingerprint);
1260
1290
  emit({
1261
1291
  type: "auth-error",
1262
- message: err.message,
1292
+ message: REAUTH_MESSAGE,
1263
1293
  });
1264
- return 0;
1294
+ return deps.authRequiredExitCode ?? 0;
1265
1295
  }
1266
1296
  // Surface the failure as an error event for observability either way.
1267
1297
  emit({
@@ -1279,15 +1309,31 @@ export async function runRunner(
1279
1309
  return isTransientNetworkError(err) ? TRANSIENT_NETWORK_EXIT : 1;
1280
1310
  }
1281
1311
 
1282
- const targetPlan = await buildFanoutPlan({
1283
- memberships,
1284
- companies: parsed.companies,
1285
- personal: parsed.personal,
1286
- skipPersonal: parsed.skipPersonal,
1287
- client,
1288
- claims,
1289
- resolveSkipPersonal,
1290
- });
1312
+ let targetPlan: Awaited<ReturnType<typeof buildFanoutPlan>>;
1313
+ try {
1314
+ targetPlan = await buildFanoutPlan({
1315
+ memberships,
1316
+ companies: parsed.companies,
1317
+ personal: parsed.personal,
1318
+ skipPersonal: parsed.skipPersonal,
1319
+ client,
1320
+ claims,
1321
+ resolveSkipPersonal,
1322
+ });
1323
+ } catch (err) {
1324
+ if (err instanceof VaultAuthError) {
1325
+ clearSession(err.tokenFingerprint);
1326
+ emit({ type: "auth-error", message: REAUTH_MESSAGE });
1327
+ return deps.authRequiredExitCode ?? 0;
1328
+ }
1329
+ emit({
1330
+ type: "error",
1331
+ message: err instanceof Error ? err.message : String(err),
1332
+ path: "(discovery)",
1333
+ });
1334
+ emitRunnerTerminalTelemetry({ status: "failed" });
1335
+ return isTransientNetworkError(err) ? TRANSIENT_NETWORK_EXIT : 1;
1336
+ }
1291
1337
  if (targetPlan.status === "setup-needed") {
1292
1338
  emit({ type: "setup-needed" });
1293
1339
  return 0;
@@ -1306,24 +1352,34 @@ export async function runRunner(
1306
1352
  // ---- fanout -----------------------------------------------------------
1307
1353
  const syncFn = deps.sync ?? defaultSync;
1308
1354
  const shareFn = deps.share ?? defaultShare;
1309
- const fanout = await executeCompanyFanout({
1310
- plan,
1311
- direction: parsed.direction,
1312
- hqRoot: parsed.hqRoot,
1313
- onConflict: parsed.onConflict,
1314
- client,
1315
- vaultConfig,
1316
- ...(uploadAuthor ? { uploadAuthor } : {}),
1317
- ...(deps.operationLockAlreadyHeld ? { operationLockAlreadyHeld: true } : {}),
1318
- syncFn,
1319
- shareFn,
1320
- resolveDeletePolicy,
1321
- emit,
1322
- scopePaths: parsed.scopePaths,
1323
- telemetryClient: client,
1324
- telemetryClaims: claims,
1325
- telemetrySessionId,
1326
- });
1355
+ let fanout: Awaited<ReturnType<typeof executeCompanyFanout>>;
1356
+ try {
1357
+ fanout = await executeCompanyFanout({
1358
+ plan,
1359
+ direction: parsed.direction,
1360
+ hqRoot: parsed.hqRoot,
1361
+ onConflict: parsed.onConflict,
1362
+ client,
1363
+ vaultConfig,
1364
+ ...(uploadAuthor ? { uploadAuthor } : {}),
1365
+ ...(deps.operationLockAlreadyHeld ? { operationLockAlreadyHeld: true } : {}),
1366
+ syncFn,
1367
+ shareFn,
1368
+ resolveDeletePolicy,
1369
+ emit,
1370
+ scopePaths: parsed.scopePaths,
1371
+ telemetryClient: client,
1372
+ telemetryClaims: claims,
1373
+ telemetrySessionId,
1374
+ });
1375
+ } catch (err) {
1376
+ if (err instanceof VaultAuthError) {
1377
+ clearSession(err.tokenFingerprint);
1378
+ emit({ type: "auth-error", message: REAUTH_MESSAGE });
1379
+ return deps.authRequiredExitCode ?? 0;
1380
+ }
1381
+ throw err;
1382
+ }
1327
1383
  const { errors, allConflicts, meaningfulOutcomeCount } = fanout;
1328
1384
  const rollup = rollupAllComplete(plan, fanout.stateByCompany);
1329
1385
 
@@ -1787,7 +1843,11 @@ export async function runRunnerWithLoop(
1787
1843
  const parsed = parseArgs(argv);
1788
1844
  const runtime = {
1789
1845
  runPassWithOperationLockAlreadyHeld: (passArgv: string[]) =>
1790
- runRunner(passArgv, { operationLockAlreadyHeld: true }),
1846
+ runRunner(passArgv, {
1847
+ operationLockAlreadyHeld: true,
1848
+ authRequiredExitCode: AUTH_REQUIRED_PASS_EXIT,
1849
+ }),
1850
+ authRequiredPassExit: AUTH_REQUIRED_PASS_EXIT,
1791
1851
  defaultGetIdTokenClaims,
1792
1852
  defaultGetAccessToken: () =>
1793
1853
  getValidAccessToken(DEFAULT_COGNITO, { interactive: false }),
@@ -4,8 +4,8 @@
4
4
  *
5
5
  * The logic is implemented natively in TypeScript in ./reindex.ts; these tests
6
6
  * exercise reindex() directly against a temp HQ tree, asserting the observable
7
- * filesystem outcomes (skill wrappers, personal-overlay mirroring) rather than
8
- * re-deriving the implementation internals.
7
+ * filesystem outcomes (skill wrappers, retired personal-overlay mirror cleanup)
8
+ * rather than re-deriving the implementation internals.
9
9
  */
10
10
 
11
11
  import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
@@ -112,26 +112,29 @@ describe("reindex", () => {
112
112
  );
113
113
  });
114
114
 
115
- it("mirrors the personal overlay into core/<type>/", () => {
115
+ it("does NOT mirror the personal overlay into core/<type>/ (read directly now)", () => {
116
116
  fs.mkdirSync(path.join(root, "personal/policies"), { recursive: true });
117
117
  fs.writeFileSync(path.join(root, "personal/policies/myrule.md"), "rule\n");
118
118
 
119
119
  const { status } = reindex({ repoRoot: root });
120
120
  expect(status).toBe(0);
121
121
 
122
- const link = path.join(root, "core/policies/myrule.md");
123
- expect(fs.lstatSync(link).isSymbolicLink()).toBe(true);
124
- expect(fs.readlinkSync(link)).toBe("../../personal/policies/myrule.md");
122
+ // personal is the sole read source — reindex must NOT create a mirror
123
+ // symlink in core/policies. The HQ tooling reads personal/policies directly,
124
+ // so a mirror would only double-surface every entry.
125
+ expect(fs.existsSync(path.join(root, "core/policies/myrule.md"))).toBe(false);
125
126
  });
126
127
 
127
- it("collapses per-file overlay-skip spam into ONE summary line (HQ-SYNC-2)", () => {
128
- // A REAL core file sits where an overlay symlink would go — the legitimate
129
- // no-op that used to spam one stderr line per entry and flood the Sentry
130
- // breadcrumb ring, evicting the real per-company sync error.
131
- fs.mkdirSync(path.join(root, "personal/policies"), { recursive: true });
132
- fs.writeFileSync(path.join(root, "personal/policies/collide.md"), "rule\n");
133
- fs.mkdirSync(path.join(root, "core/policies"), { recursive: true });
134
- fs.writeFileSync(path.join(root, "core/policies/collide.md"), "REAL core file\n");
128
+ it("collapses per-file wrapper-skip spam into ONE summary line (HQ-SYNC-2)", () => {
129
+ // A REAL file sits where a managed skill-wrapper symlink would go — the
130
+ // legitimate no-op that used to spam one stderr line per entry and flood the
131
+ // Sentry breadcrumb ring, evicting the real per-company sync error.
132
+ writeSkill("core/skills/demo");
133
+ reindex({ repoRoot: root });
134
+ // Replace the managed per-file symlink with a real (non-symlink) file.
135
+ const slot = path.join(root, ".claude/skills/core:demo/SKILL.md");
136
+ fs.rmSync(slot);
137
+ fs.writeFileSync(slot, "REAL file, not a link\n");
135
138
 
136
139
  const writes: string[] = [];
137
140
  const spy = vi
@@ -150,7 +153,7 @@ describe("reindex", () => {
150
153
 
151
154
  expect(status).toBe(0);
152
155
  // The pre-existing real file is left untouched (never overwritten).
153
- expect(fs.lstatSync(path.join(root, "core/policies/collide.md")).isSymbolicLink()).toBe(false);
156
+ expect(fs.lstatSync(slot).isSymbolicLink()).toBe(false);
154
157
  // No per-file spam…
155
158
  expect(stderr).not.toContain("already exists and is not a symlink; skipping");
156
159
  // …just ONE collapsed summary naming the count.
@@ -158,11 +161,12 @@ describe("reindex", () => {
158
161
  expect(stderr).toContain("HQ_REINDEX_VERBOSE=1");
159
162
  });
160
163
 
161
- it("keeps per-file overlay-skip detail under HQ_REINDEX_VERBOSE=1", () => {
162
- fs.mkdirSync(path.join(root, "personal/policies"), { recursive: true });
163
- fs.writeFileSync(path.join(root, "personal/policies/collide.md"), "rule\n");
164
- fs.mkdirSync(path.join(root, "core/policies"), { recursive: true });
165
- fs.writeFileSync(path.join(root, "core/policies/collide.md"), "REAL\n");
164
+ it("keeps per-file wrapper-skip detail under HQ_REINDEX_VERBOSE=1", () => {
165
+ writeSkill("core/skills/demo");
166
+ reindex({ repoRoot: root });
167
+ const slot = path.join(root, ".claude/skills/core:demo/SKILL.md");
168
+ fs.rmSync(slot);
169
+ fs.writeFileSync(slot, "REAL\n");
166
170
 
167
171
  const writes: string[] = [];
168
172
  const spy = vi
@@ -180,7 +184,7 @@ describe("reindex", () => {
180
184
  }
181
185
  const stderr = writes.join("");
182
186
  expect(stderr).toContain(
183
- "core/policies/collide.md already exists and is not a symlink; skipping",
187
+ ".claude/skills/core:demo/SKILL.md already exists and is not a symlink; skipping",
184
188
  );
185
189
  });
186
190
 
@@ -203,75 +207,55 @@ describe("reindex", () => {
203
207
  ).toBe("../../../core/skills/demo/SKILL.md");
204
208
  });
205
209
 
206
- // ── Cleanup pass C: orphan personal-overlay mirror symlinks ──────────────
207
- // The mirror creates core/<type>/<entry> -> ../../personal/<type>/<entry>.
208
- // When the personal source is later deleted/renamed, the next reindex must
209
- // prune the now-dangling core symlink the gap that used to leave a retired
210
- // personal policy pointer surfacing in core/policies/ until hand-deleted.
210
+ // ── Migration cleanup: retired personal-overlay mirror symlinks ──────────
211
+ // reindex no longer CREATES core/<type>/<entry> -> ../../personal/<type>/<entry>
212
+ // mirror symlinks personal is read directly. A managed mirror symlink a
213
+ // PRIOR release left behind must be pruned on the next reindex, WHETHER OR NOT
214
+ // its personal source still exists, so it can never double-surface alongside
215
+ // the direct read.
216
+
217
+ // Forge a legacy managed mirror symlink exactly as the old reindex would have.
218
+ function legacyMirror(type: string, entry: string): string {
219
+ const coreDir = path.join(root, "core", type);
220
+ fs.mkdirSync(coreDir, { recursive: true });
221
+ const link = path.join(coreDir, entry);
222
+ fs.symlinkSync(`../../personal/${type}/${entry}`, link);
223
+ return link;
224
+ }
211
225
 
212
- it("prunes an orphan personal-overlay mirror symlink when the source is deleted", () => {
226
+ it("prunes a legacy mirror symlink even when its personal source still exists", () => {
213
227
  fs.mkdirSync(path.join(root, "personal/policies"), { recursive: true });
214
228
  fs.writeFileSync(path.join(root, "personal/policies/myrule.md"), "rule\n");
215
- reindex({ repoRoot: root });
216
- const link = path.join(root, "core/policies/myrule.md");
229
+ const link = legacyMirror("policies", "myrule.md");
217
230
  expect(fs.lstatSync(link).isSymbolicLink()).toBe(true);
218
231
 
219
- // Retire the personal source, then reindex again.
220
- fs.rmSync(path.join(root, "personal/policies/myrule.md"));
221
232
  expect(reindex({ repoRoot: root }).status).toBe(0);
222
233
 
223
- // The dangling mirror symlink is gone no entry at all (lstat throws).
234
+ // Pruned personal/policies/myrule.md is read directly now, no mirror.
224
235
  expect(fs.existsSync(link)).toBe(false);
225
236
  expect(() => fs.lstatSync(link)).toThrow();
237
+ // The personal source itself is untouched.
238
+ expect(fs.existsSync(path.join(root, "personal/policies/myrule.md"))).toBe(true);
226
239
  });
227
240
 
228
- it("prunes mirror symlinks when the ENTIRE personal/<type>/ directory is removed", () => {
229
- fs.mkdirSync(path.join(root, "personal/policies"), { recursive: true });
230
- fs.writeFileSync(path.join(root, "personal/policies/a.md"), "a\n");
231
- fs.writeFileSync(path.join(root, "personal/policies/b.md"), "b\n");
232
- reindex({ repoRoot: root });
233
- expect(fs.lstatSync(path.join(root, "core/policies/a.md")).isSymbolicLink()).toBe(true);
234
- expect(fs.lstatSync(path.join(root, "core/policies/b.md")).isSymbolicLink()).toBe(true);
241
+ it("prunes a legacy mirror symlink whose personal source was deleted", () => {
242
+ const link = legacyMirror("policies", "gone.md"); // dangling: no source
243
+ expect(fs.lstatSync(link).isSymbolicLink()).toBe(true);
235
244
 
236
- // Nuke the whole personal/policies dir (the creation loop's isDir guard would
237
- // skip the type entirely — the prune must still run and clean both links).
238
- fs.rmSync(path.join(root, "personal/policies"), { recursive: true, force: true });
239
245
  expect(reindex({ repoRoot: root }).status).toBe(0);
240
-
241
- expect(fs.existsSync(path.join(root, "core/policies/a.md"))).toBe(false);
242
- expect(fs.existsSync(path.join(root, "core/policies/b.md"))).toBe(false);
243
- });
244
-
245
- it("keeps a LIVE personal-overlay mirror symlink (only orphans are pruned)", () => {
246
- fs.mkdirSync(path.join(root, "personal/policies"), { recursive: true });
247
- fs.writeFileSync(path.join(root, "personal/policies/keep.md"), "keep\n");
248
- fs.writeFileSync(path.join(root, "personal/policies/drop.md"), "drop\n");
249
- reindex({ repoRoot: root });
250
-
251
- // Retire only one source; the other stays live.
252
- fs.rmSync(path.join(root, "personal/policies/drop.md"));
253
- reindex({ repoRoot: root });
254
-
255
- const keep = path.join(root, "core/policies/keep.md");
256
- expect(fs.lstatSync(keep).isSymbolicLink()).toBe(true);
257
- expect(fs.readlinkSync(keep)).toBe("../../personal/policies/keep.md");
258
- expect(fs.readFileSync(keep, "utf-8")).toBe("keep\n");
259
- expect(fs.existsSync(path.join(root, "core/policies/drop.md"))).toBe(false);
246
+ expect(fs.existsSync(link)).toBe(false);
260
247
  });
261
248
 
262
- it("prunes an orphan personal-overlay mirror DIRECTORY symlink (DEV-1767 shape)", () => {
263
- // personal/knowledge/<kb>/ mirrors as a directory symlink; deleting the kb
264
- // must prune the dangling directory symlink too (rmSync on a symlink, not
265
- // recursive into a resolved dir).
249
+ it("prunes a legacy mirror DIRECTORY symlink without recursing into the source (DEV-1767 shape)", () => {
266
250
  fs.mkdirSync(path.join(root, "personal/knowledge/my-kb"), { recursive: true });
267
251
  fs.writeFileSync(path.join(root, "personal/knowledge/my-kb/note.md"), "n\n");
268
- reindex({ repoRoot: root });
269
- const link = path.join(root, "core/knowledge/my-kb");
252
+ const link = legacyMirror("knowledge", "my-kb"); // directory-shaped symlink
270
253
  expect(fs.lstatSync(link).isSymbolicLink()).toBe(true);
271
254
 
272
- fs.rmSync(path.join(root, "personal/knowledge/my-kb"), { recursive: true, force: true });
273
255
  expect(reindex({ repoRoot: root }).status).toBe(0);
274
256
  expect(fs.existsSync(link)).toBe(false);
257
+ // rmSync on the symlink must NOT recurse into the resolved directory.
258
+ expect(fs.existsSync(path.join(root, "personal/knowledge/my-kb/note.md"))).toBe(true);
275
259
  });
276
260
 
277
261
  it("leaves a real core file and a foreign symlink in core/<type>/ untouched (cross-check invariant)", () => {
@@ -369,24 +353,21 @@ describe("reindex", () => {
369
353
  expect(fs.readlinkSync(agentsLink)).toBe("../../../core/skills/demo/agents");
370
354
  });
371
355
 
372
- it("mirrors a personal/<type> DIRECTORY entry as a directory symlink into core/ and preserves it", () => {
373
- // personal/knowledge/<kb>/ is a directory of notes the exact DEV-1767
374
- // reindex artifact shape that rescue/update-hq used to choke on.
356
+ it("does NOT mirror a personal/<type> DIRECTORY entry into core/ (read directly now)", () => {
357
+ // personal/knowledge/<kb>/ is a directory of notes. It used to mirror as a
358
+ // core/ directory symlink (the DEV-1767 shape rescue/update-hq choked on).
359
+ // The mirror is retired: qmd indexes personal/knowledge directly, so reindex
360
+ // must leave core/knowledge alone — nothing to choke on anymore.
375
361
  fs.mkdirSync(path.join(root, "personal/knowledge/my-kb"), { recursive: true });
376
362
  fs.writeFileSync(path.join(root, "personal/knowledge/my-kb/note.md"), "owned by personal\n");
377
363
 
378
364
  expect(reindex({ repoRoot: root }).status).toBe(0);
379
365
 
380
- const link = path.join(root, "core/knowledge/my-kb");
381
- expect(fs.lstatSync(link).isSymbolicLink()).toBe(true);
382
- expect(fs.readlinkSync(link)).toBe("../../personal/knowledge/my-kb");
383
- expect(fs.statSync(link).isDirectory()).toBe(true);
384
- expect(fs.readFileSync(path.join(link, "note.md"), "utf-8")).toBe("owned by personal\n");
385
-
386
- // Idempotent re-run: existing directory symlink recognised, left in place.
387
- expect(reindex({ repoRoot: root }).status).toBe(0);
388
- expect(fs.readlinkSync(link)).toBe("../../personal/knowledge/my-kb");
389
- expect(fs.readFileSync(path.join(link, "note.md"), "utf-8")).toBe("owned by personal\n");
366
+ expect(fs.existsSync(path.join(root, "core/knowledge/my-kb"))).toBe(false);
367
+ // The personal source is untouched.
368
+ expect(fs.readFileSync(path.join(root, "personal/knowledge/my-kb/note.md"), "utf-8")).toBe(
369
+ "owned by personal\n",
370
+ );
390
371
  });
391
372
 
392
373
  // ── operation lock: reindex uses a SEPARATE scope from sync/rescue ──────
@@ -497,22 +478,6 @@ describe("reindex", () => {
497
478
  }
498
479
  });
499
480
 
500
- it("does not abort reindex when a core/<type> mirror mkdir fails", () => {
501
- writeSkill("core/skills/demo");
502
- fs.mkdirSync(path.join(root, "personal/knowledge/my-kb"), { recursive: true });
503
- fs.writeFileSync(path.join(root, "personal/knowledge/my-kb/note.md"), "n\n");
504
-
505
- hoisted.failMkdirContaining = path.join("core", "knowledge");
506
- try {
507
- expect(reindex({ repoRoot: root }).status).toBe(0);
508
- } finally {
509
- hoisted.failMkdirContaining = "";
510
- }
511
-
512
- // The mirror for the failed type is skipped; skill surfacing still happened.
513
- expect(fs.existsSync(path.join(root, ".claude/skills/core:demo"))).toBe(true);
514
- });
515
-
516
481
  // ── HQ-CLI-4: symlink creation is privileged on Windows. Without Developer
517
482
  // Mode, fs.symlinkSync throws EPERM. The HQ-B0 work guarded every mkdir
518
483
  // site but left the symlink sites unguarded, so `hq reindex` still aborted
@@ -536,28 +501,6 @@ describe("reindex", () => {
536
501
  ).toBe(false);
537
502
  });
538
503
 
539
- it("does not abort reindex when a personal-mirror symlink fails with EPERM, and other work still runs", () => {
540
- writeSkill("core/skills/demo");
541
- fs.mkdirSync(path.join(root, "personal/knowledge/my-kb"), { recursive: true });
542
- fs.writeFileSync(path.join(root, "personal/knowledge/my-kb/note.md"), "n\n");
543
-
544
- // Fail ONLY the core/<type> personal-mirror symlinks; wrapper symlinks
545
- // succeed, proving the skip is scoped and the reindex completes.
546
- hoisted.failSymlinkContaining = path.join("core", "knowledge");
547
- try {
548
- expect(reindex({ repoRoot: root }).status).toBe(0);
549
- } finally {
550
- hoisted.failSymlinkContaining = "";
551
- }
552
-
553
- // The personal mirror was skipped (EPERM)...
554
- expect(fs.existsSync(path.join(root, "core/knowledge/my-kb"))).toBe(false);
555
- // ...but unaffected work still happened: the skill wrapper + its symlink.
556
- expect(
557
- fs.lstatSync(path.join(root, ".claude/skills/core:demo/SKILL.md")).isSymbolicLink(),
558
- ).toBe(true);
559
- });
560
-
561
504
  // --- multi-harness session-log capture into workspace/.session-logs/<harness>/
562
505
  describe("session-log capture", () => {
563
506
  let claudeDir: string; // CLAUDE_CONFIG_DIR
@@ -1,9 +1,10 @@
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), mirrors
4
- * personal/{knowledge,policies,workers,settings}/<entry> into core/<type>/,
5
- * prunes orphan wrappers + legacy command symlinks, captures this HQ tree's
6
- * coding-harness session logs (Claude Code / Codex / Grok) into
3
+ * .claude/skills/<ns>:<skill>/ (one symlink per source file), prunes orphan
4
+ * wrappers + legacy command symlinks (plus a migration cleanup of retired
5
+ * personal/{knowledge,policies,workers,settings} mirror symlinks in core/<type>/
6
+ * that overlay is now read directly from personal/, not mirrored), captures
7
+ * this HQ tree's coding-harness session logs (Claude Code / Codex / Grok) into
7
8
  * workspace/.session-logs/<harness>/, and regenerates the workers registry.
8
9
  *
9
10
  * The logic historically lived in a bundled bash script (scripts/reindex.sh)
@@ -689,63 +690,24 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
689
690
  }
690
691
  }
691
692
 
692
- // --- Personal type mirroring ----------------------------------------------
693
- // Mirror personal/<type>/<entry> into core/<type>/<entry> as symlinks.
694
- for (const type of ["knowledge", "policies", "workers", "settings"]) {
695
- const personalDir = path.join(root, "personal", type);
696
- const coreDir = path.join(root, "core", type);
697
-
698
- if (!isDir(personalDir)) continue;
699
- if (!safeMkdir(coreDir, `core/${type} directory`)) continue;
700
-
701
- for (const entry of globEntries(personalDir)) {
702
- const entryPath = path.join(personalDir, entry);
703
- if (!existsFollow(entryPath)) continue;
704
-
705
- const linkPath = path.join(coreDir, entry);
706
- const relativeTarget = `../../personal/${type}/${entry}`;
707
-
708
- const lst = lstatOrNull(linkPath);
709
- if (lst && lst.isSymbolicLink()) {
710
- const current = readlinkOrNull(linkPath);
711
- if (current === relativeTarget) continue;
712
- skippedPointsElsewhere++;
713
- if (verbose) {
714
- warn(
715
- `reindex: core/${type}/${entry} already points to '${current}' (expected '${relativeTarget}'); leaving alone`,
716
- );
717
- }
718
- continue;
719
- } else if (lst) {
720
- skippedNonSymlink++;
721
- if (verbose) {
722
- warn(`reindex: core/${type}/${entry} already exists and is not a symlink; skipping`);
723
- }
724
- continue;
725
- }
726
-
727
- safeSymlink(relativeTarget, linkPath, `core/${type}/${entry}`);
728
- }
729
- }
730
-
731
- // --- Cleanup pass C: drop orphan personal-overlay mirror symlinks ----------
732
- // The mirror above creates core/<type>/<entry> -> ../../personal/<type>/<entry>
733
- // for each LIVE personal entry, but historically never removed that symlink
734
- // when its personal source was later deleted or renamed — leaving a dangling
735
- // link in core/<type>/ (e.g. a retired personal policy that still surfaced via
736
- // the policy-trigger hook until a human hand-deleted it). This pass prunes it.
693
+ // --- Personal-overlay mirroring: RETIRED + migration cleanup ---------------
694
+ // personal/{knowledge,policies,workers,settings} is now read DIRECTLY by the
695
+ // HQ tooling (the inject-policy-on-trigger hook reads personal/policies, the
696
+ // workers-registry generator walks personal/workers, qmd-reindex-after-sync
697
+ // registers a personal/knowledge collection, and repo-run-registry reads
698
+ // personal/settings/orchestrator.yaml). personal is the SOLE read source for
699
+ // the personal overlay, so reindex no longer SYMLINKS personal/<type>/<entry>
700
+ // into core/<type>/<entry> a mirror would now double-surface every entry
701
+ // (read once directly, once via the link).
737
702
  //
738
- // Ownership discriminator (policy wrapper-sync-orphan-cleanup-cross-check): a
739
- // core/<type>/<entry> is "managed by this mirror" ONLY when it is a symlink
740
- // whose target is EXACTLY `../../personal/<type>/<entry>` — i.e. the target
741
- // encodes the same <type> and <entry> as the link's own location and name.
742
- // That exact cross-check means a real release-shipped core file (not a
743
- // symlink), or a human-authored symlink pointing anywhere else, never matches
744
- // and is left untouched. A managed link is pruned IFF its target no longer
745
- // resolves (existsFollow=false) which, given the exact-target shape, is
746
- // precisely "the personal source was removed". This runs independently of the
747
- // creation loop above (note: no `isDir(personalDir)` guard) so that deleting
748
- // the ENTIRE personal/<type>/ directory still prunes every mirror it produced.
703
+ // Migration cleanup: prune any mirror symlink a PRIOR release created —
704
+ // core/<type>/<entry> whose target is EXACTLY `../../personal/<type>/<entry>`.
705
+ // That exact-target discriminator (policy wrapper-sync-orphan-cleanup-cross-
706
+ // check) means a real release-shipped core file (not a symlink) or a human-
707
+ // authored symlink pointing elsewhere is never touched. Unlike the old
708
+ // "Cleanup pass C", this prunes the link WHETHER OR NOT its personal source
709
+ // still exists: the mirror is retired, so every managed link is now stale.
710
+ // Kept for a few releases so existing trees self-heal on the next reindex.
749
711
  for (const type of ["knowledge", "policies", "workers", "settings"]) {
750
712
  const coreDir = path.join(root, "core", type);
751
713
  for (const entry of globEntries(coreDir)) {
@@ -753,8 +715,7 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
753
715
  const lst = lstatOrNull(linkPath);
754
716
  if (!lst || !lst.isSymbolicLink()) continue; // real file/dir → not ours
755
717
  if (readlinkOrNull(linkPath) !== `../../personal/${type}/${entry}`) continue; // not a managed mirror link
756
- if (existsFollow(linkPath)) continue; // live: personal source still present
757
- // Managed mirror symlink whose personal source is gone → prune.
718
+ // Managed mirror symlink from a prior release → prune (mirror is retired).
758
719
  try {
759
720
  fs.rmSync(linkPath);
760
721
  } catch {
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Wiring tests: `rescue()` runs `reindex()` after a successful, non-dry-run
3
- * rescue so the generated skill wrappers / personal mirrors / workers registry
4
- * are refreshed once core/ has been re-laid-down.
3
+ * rescue so the generated skill wrappers / workers registry are refreshed once
4
+ * core/ has been re-laid-down (the personal overlay is read directly, not
5
+ * mirrored, so there are no personal mirror symlinks to refresh).
5
6
  *
6
7
  * The rescue algorithm (./rescue-core.js) is mocked so no real clone/overlay
7
8
  * runs, and ./reindex.js is mocked to a spy so we assert the call without
@@ -41,6 +41,7 @@ import * as readline from "readline";
41
41
  import { share, _testing as shareTesting } from "./share.js";
42
42
  import { deleteRemoteFile, downloadFile, headRemoteFile, uploadFile, uploadSymlink } from "../s3.js";
43
43
  import type { EntityContext } from "../types.js";
44
+ import { VaultAuthError } from "../vault-client.js";
44
45
 
45
46
  const mockConfig: VaultServiceConfig = {
46
47
  apiUrl: "https://vault-api.test",
@@ -207,6 +208,25 @@ describe("share", () => {
207
208
  expect(uploadFile).toHaveBeenCalledWith(expect.anything(), testFile, "test.md", undefined, expect.anything());
208
209
  });
209
210
 
211
+ it("propagates a per-file vault 401 to the runner auth boundary", async () => {
212
+ const companyRoot = path.join(tmpDir, "companies", "acme");
213
+ fs.mkdirSync(companyRoot, { recursive: true });
214
+ const testFile = path.join(companyRoot, "private.md");
215
+ fs.writeFileSync(testFile, "private");
216
+ vi.mocked(uploadFile).mockRejectedValueOnce(
217
+ new VaultAuthError("upload unauthorized"),
218
+ );
219
+
220
+ await expect(
221
+ share({
222
+ paths: [testFile],
223
+ company: "acme",
224
+ vaultConfig: mockConfig,
225
+ hqRoot: tmpDir,
226
+ }),
227
+ ).rejects.toBeInstanceOf(VaultAuthError);
228
+ });
229
+
210
230
  it("respects ignore rules", async () => {
211
231
  const companyRoot = path.join(tmpDir, "companies", "acme");
212
232
  fs.mkdirSync(path.join(companyRoot, ".git"), { recursive: true });