@indigoai-us/hq-cloud 6.15.0 → 6.15.2
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/bin/sync-mutation.d.ts +16 -0
- package/dist/bin/sync-mutation.d.ts.map +1 -0
- package/dist/bin/sync-mutation.js +60 -0
- package/dist/bin/sync-mutation.js.map +1 -0
- package/dist/bin/sync-mutation.test.d.ts +2 -0
- package/dist/bin/sync-mutation.test.d.ts.map +1 -0
- package/dist/bin/sync-mutation.test.js +165 -0
- package/dist/bin/sync-mutation.test.js.map +1 -0
- package/dist/bin/sync-runner-company.d.ts +8 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +16 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-company.test.d.ts +2 -0
- package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-company.test.js +36 -0
- package/dist/bin/sync-runner-company.test.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +98 -8
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +17 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +109 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/conflict-recovery.test.d.ts +2 -0
- package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
- package/dist/cli/conflict-recovery.test.js +201 -0
- package/dist/cli/conflict-recovery.test.js.map +1 -0
- package/dist/cli/conflict.d.ts +60 -0
- package/dist/cli/conflict.d.ts.map +1 -1
- package/dist/cli/conflict.js +333 -0
- package/dist/cli/conflict.js.map +1 -1
- package/dist/cli/sync.d.ts +35 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +100 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +85 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/skill-telemetry.d.ts +6 -0
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +14 -2
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +79 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/candidate-uploader.d.ts +88 -0
- package/dist/sync/candidate-uploader.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.js +212 -0
- package/dist/sync/candidate-uploader.js.map +1 -0
- package/dist/sync/candidate-uploader.test.d.ts +2 -0
- package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.test.js +132 -0
- package/dist/sync/candidate-uploader.test.js.map +1 -0
- package/dist/sync/delta-client.d.ts +73 -0
- package/dist/sync/delta-client.d.ts.map +1 -0
- package/dist/sync/delta-client.js +201 -0
- package/dist/sync/delta-client.js.map +1 -0
- package/dist/sync/delta-client.test.d.ts +2 -0
- package/dist/sync/delta-client.test.d.ts.map +1 -0
- package/dist/sync/delta-client.test.js +97 -0
- package/dist/sync/delta-client.test.js.map +1 -0
- package/dist/sync/durable-apply.d.ts +76 -0
- package/dist/sync/durable-apply.d.ts.map +1 -0
- package/dist/sync/durable-apply.js +530 -0
- package/dist/sync/durable-apply.js.map +1 -0
- package/dist/sync/durable-apply.test.d.ts +2 -0
- package/dist/sync/durable-apply.test.d.ts.map +1 -0
- package/dist/sync/durable-apply.test.js +180 -0
- package/dist/sync/durable-apply.test.js.map +1 -0
- package/dist/sync/event-sync.d.ts +33 -1
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +149 -1
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +142 -1
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/index.d.ts +2 -0
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +1 -0
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/multipart-uploader.d.ts +99 -0
- package/dist/sync/multipart-uploader.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.js +447 -0
- package/dist/sync/multipart-uploader.js.map +1 -0
- package/dist/sync/multipart-uploader.test.d.ts +2 -0
- package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.test.js +119 -0
- package/dist/sync/multipart-uploader.test.js.map +1 -0
- package/dist/sync/mutation-client.d.ts +85 -0
- package/dist/sync/mutation-client.d.ts.map +1 -0
- package/dist/sync/mutation-client.js +245 -0
- package/dist/sync/mutation-client.js.map +1 -0
- package/dist/sync/mutation-client.test.d.ts +2 -0
- package/dist/sync/mutation-client.test.d.ts.map +1 -0
- package/dist/sync/mutation-client.test.js +51 -0
- package/dist/sync/mutation-client.test.js.map +1 -0
- package/dist/sync/push-receiver.d.ts +45 -0
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +101 -0
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +54 -2
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/sync/scope-inventory-client.d.ts +69 -0
- package/dist/sync/scope-inventory-client.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.js +210 -0
- package/dist/sync/scope-inventory-client.js.map +1 -0
- package/dist/sync/scope-inventory-client.test.d.ts +2 -0
- package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.test.js +94 -0
- package/dist/sync/scope-inventory-client.test.js.map +1 -0
- package/dist/sync/snapshot-client.d.ts +98 -0
- package/dist/sync/snapshot-client.d.ts.map +1 -0
- package/dist/sync/snapshot-client.js +402 -0
- package/dist/sync/snapshot-client.js.map +1 -0
- package/dist/sync/snapshot-client.test.d.ts +2 -0
- package/dist/sync/snapshot-client.test.d.ts.map +1 -0
- package/dist/sync/snapshot-client.test.js +169 -0
- package/dist/sync/snapshot-client.test.js.map +1 -0
- package/dist/sync/uploader-finalization.d.ts +97 -0
- package/dist/sync/uploader-finalization.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.js +273 -0
- package/dist/sync/uploader-finalization.js.map +1 -0
- package/dist/sync/uploader-finalization.test.d.ts +2 -0
- package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.test.js +92 -0
- package/dist/sync/uploader-finalization.test.js.map +1 -0
- package/dist/telemetry.d.ts +11 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +21 -2
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +80 -0
- package/dist/telemetry.test.js.map +1 -1
- package/package.json +6 -1
- package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
- package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
- package/.github/workflows/ci.yml +0 -84
- package/.github/workflows/publish.yml +0 -56
- package/.github/workflows/unreleased-commits-nag.yml +0 -256
- package/eslint.config.js +0 -67
- package/pnpm-workspace.yaml +0 -2
- package/scripts/presign-transport-e2e.mjs +0 -250
- package/scripts/vault-rebaseline.sh +0 -323
- package/scripts/vault-rescue.sh +0 -332
- package/src/active-company.test.ts +0 -188
- package/src/active-company.ts +0 -168
- package/src/agent-codex-instructions.test.ts +0 -332
- package/src/agent-codex-instructions.ts +0 -309
- package/src/auth.ts +0 -146
- package/src/backup-prune.test.ts +0 -98
- package/src/backup-prune.ts +0 -182
- package/src/bin/backup-prune-runner.ts +0 -33
- package/src/bin/rescue-runner.ts +0 -25
- package/src/bin/sync-runner-company.ts +0 -695
- package/src/bin/sync-runner-events.test.ts +0 -143
- package/src/bin/sync-runner-events.ts +0 -55
- package/src/bin/sync-runner-planning.test.ts +0 -311
- package/src/bin/sync-runner-planning.ts +0 -258
- package/src/bin/sync-runner-rollup.test.ts +0 -37
- package/src/bin/sync-runner-rollup.ts +0 -97
- package/src/bin/sync-runner-telemetry.ts +0 -15
- package/src/bin/sync-runner-watch-loop.ts +0 -1235
- package/src/bin/sync-runner-watch-routes.test.ts +0 -71
- package/src/bin/sync-runner-watch-routes.ts +0 -184
- package/src/bin/sync-runner.test.ts +0 -8767
- package/src/bin/sync-runner.ts +0 -2190
- package/src/cli/accept.ts +0 -124
- package/src/cli/conflict.ts +0 -119
- package/src/cli/doctor.test.ts +0 -581
- package/src/cli/doctor.ts +0 -642
- package/src/cli/index.ts +0 -49
- package/src/cli/invite.test.ts +0 -250
- package/src/cli/invite.ts +0 -214
- package/src/cli/promote.ts +0 -157
- package/src/cli/reindex-knowledge.test.ts +0 -307
- package/src/cli/reindex-knowledge.ts +0 -450
- package/src/cli/reindex.test.ts +0 -957
- package/src/cli/reindex.ts +0 -979
- package/src/cli/rescue-classify-ordering.test.ts +0 -548
- package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
- package/src/cli/rescue-core.ts +0 -3011
- package/src/cli/rescue-drift-reconcile.test.ts +0 -179
- package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
- package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
- package/src/cli/rescue-hq-root-guard.test.ts +0 -232
- package/src/cli/rescue-journal-reconcile.test.ts +0 -215
- package/src/cli/rescue-mtime-preserve.test.ts +0 -203
- package/src/cli/rescue-settings-reconcile.test.ts +0 -637
- package/src/cli/rescue-snapshot.test.ts +0 -57
- package/src/cli/rescue-snapshot.ts +0 -51
- package/src/cli/rescue.reindex.test.ts +0 -63
- package/src/cli/rescue.test.ts +0 -131
- package/src/cli/rescue.ts +0 -182
- package/src/cli/share.test.ts +0 -7843
- package/src/cli/share.ts +0 -3663
- package/src/cli/sync-scope.test.ts +0 -652
- package/src/cli/sync.test.ts +0 -5207
- package/src/cli/sync.ts +0 -3470
- package/src/cli/tombstones.ts +0 -106
- package/src/cli/watch-event-push-conflict.test.ts +0 -234
- package/src/client-info.test.ts +0 -214
- package/src/client-info.ts +0 -121
- package/src/cognito-auth.test.ts +0 -712
- package/src/cognito-auth.ts +0 -1422
- package/src/company-resolver.test.ts +0 -618
- package/src/company-resolver.ts +0 -521
- package/src/context.test.ts +0 -583
- package/src/context.ts +0 -378
- package/src/daemon-worker.ts +0 -26
- package/src/daemon.ts +0 -99
- package/src/entity-resolver.test.ts +0 -315
- package/src/entity-resolver.ts +0 -180
- package/src/ignore.test.ts +0 -466
- package/src/ignore.ts +0 -469
- package/src/index.ts +0 -439
- package/src/journal.test.ts +0 -968
- package/src/journal.ts +0 -765
- package/src/lib/cloud-authoritative.test.ts +0 -45
- package/src/lib/cloud-authoritative.ts +0 -59
- package/src/lib/conflict-file.ts +0 -86
- package/src/lib/conflict-index.ts +0 -289
- package/src/lib/conflict.test.ts +0 -348
- package/src/lib/describe-error.test.ts +0 -100
- package/src/lib/describe-error.ts +0 -58
- package/src/lib/exit-codes.ts +0 -24
- package/src/lib/machine-id.test.ts +0 -231
- package/src/lib/machine-id.ts +0 -175
- package/src/lib/net-errors.test.ts +0 -65
- package/src/lib/net-errors.ts +0 -86
- package/src/lib/readlink-safe.test.ts +0 -43
- package/src/lib/readlink-safe.ts +0 -29
- package/src/local-path-codec.test.ts +0 -138
- package/src/local-path-codec.ts +0 -161
- package/src/machine-auth.test.ts +0 -1323
- package/src/manifest-reconcile.test.ts +0 -1123
- package/src/manifest-reconcile.ts +0 -518
- package/src/object-io.test.ts +0 -1221
- package/src/object-io.ts +0 -1306
- package/src/operation-lock.test.ts +0 -484
- package/src/operation-lock.ts +0 -680
- package/src/outcome-telemetry.test.ts +0 -498
- package/src/outcome-telemetry.ts +0 -639
- package/src/personal-vault-exclusions.test.ts +0 -308
- package/src/personal-vault-exclusions.ts +0 -354
- package/src/personal-vault.test.ts +0 -756
- package/src/personal-vault.ts +0 -496
- package/src/prefix-coalesce.test.ts +0 -240
- package/src/prefix-coalesce.ts +0 -273
- package/src/public-surface.test.ts +0 -117
- package/src/qmd-reindex.test.ts +0 -877
- package/src/qmd-reindex.ts +0 -842
- package/src/read-only-state-dir.test.ts +0 -188
- package/src/remote-pull.test.ts +0 -1130
- package/src/remote-pull.ts +0 -618
- package/src/s3.symlink-materialize.test.ts +0 -492
- package/src/s3.test.ts +0 -1789
- package/src/s3.ts +0 -1532
- package/src/schemas/signal-types.test.ts +0 -82
- package/src/schemas/signal-types.ts +0 -38
- package/src/schemas/source-channels.test.ts +0 -82
- package/src/schemas/source-channels.ts +0 -53
- package/src/scope-shrink.test.ts +0 -633
- package/src/scope-shrink.ts +0 -481
- package/src/signals/get.test.ts +0 -310
- package/src/signals/get.ts +0 -75
- package/src/signals/internals.ts +0 -195
- package/src/signals/list.test.ts +0 -420
- package/src/signals/list.ts +0 -79
- package/src/signals/parse.ts +0 -8
- package/src/signals/types.ts +0 -91
- package/src/skill-telemetry.test.ts +0 -1825
- package/src/skill-telemetry.ts +0 -1439
- package/src/sources/get.test.ts +0 -293
- package/src/sources/get.ts +0 -66
- package/src/sources/internals.ts +0 -198
- package/src/sources/list.test.ts +0 -402
- package/src/sources/list.ts +0 -84
- package/src/sources/parse.ts +0 -43
- package/src/sources/types.ts +0 -84
- package/src/sync/event-sync.test.ts +0 -594
- package/src/sync/event-sync.ts +0 -545
- package/src/sync/feature-flags.test.ts +0 -378
- package/src/sync/feature-flags.ts +0 -62
- package/src/sync/index.ts +0 -76
- package/src/sync/lease-client.test.ts +0 -128
- package/src/sync/lease-client.ts +0 -207
- package/src/sync/logger.test.ts +0 -242
- package/src/sync/logger.ts +0 -79
- package/src/sync/metrics.test.ts +0 -462
- package/src/sync/metrics.ts +0 -213
- package/src/sync/pull-scope.ts +0 -265
- package/src/sync/push-event.test.ts +0 -266
- package/src/sync/push-event.ts +0 -224
- package/src/sync/push-receiver.test.ts +0 -566
- package/src/sync/push-receiver.ts +0 -1048
- package/src/sync/push-transport.ts +0 -231
- package/src/sync/realtime-rollout.test.ts +0 -86
- package/src/sync/realtime-rollout.ts +0 -262
- package/src/sync/state-store.test.ts +0 -194
- package/src/sync/state-store.ts +0 -727
- package/src/sync-core.ts +0 -58
- package/src/sync-progress.test.ts +0 -94
- package/src/sync-progress.ts +0 -140
- package/src/telemetry-events.test.ts +0 -88
- package/src/telemetry-events.ts +0 -205
- package/src/telemetry.test.ts +0 -1280
- package/src/telemetry.ts +0 -1109
- package/src/types.ts +0 -314
- package/src/vault-client.test.ts +0 -1380
- package/src/vault-client.ts +0 -1694
- package/src/version.ts +0 -24
- package/src/watch-roots.test.ts +0 -278
- package/src/watch-roots.ts +0 -162
- package/src/watcher-event-gate.test.ts +0 -212
- package/src/watcher.test.ts +0 -1079
- package/src/watcher.ts +0 -1741
- package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
- package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
- package/test/e2e/sync/transient-company-leg.test.ts +0 -384
- package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
- package/test/e2e/watcher-real-chokidar.test.ts +0 -165
- package/test/e2e/watcher-recursive-backend.test.ts +0 -181
- package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
- package/test/invite-flow.integration.test.ts +0 -244
- package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
- package/test/share-sync.integration.test.ts +0 -213
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -22
package/src/cli/reindex.ts
DELETED
|
@@ -1,979 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* hq reindex — surfaces namespaced skills as Claude Code skill wrappers under
|
|
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
|
|
7
|
-
* personal/{knowledge,policies,workers,settings} mirror symlinks in core/<type>/
|
|
8
|
-
* — that overlay is now read directly from personal/, not mirrored), captures
|
|
9
|
-
* this HQ tree's coding-harness session logs (Claude Code / Codex / Grok) into
|
|
10
|
-
* workspace/.session-logs/<harness>/, and regenerates the workers registry.
|
|
11
|
-
*
|
|
12
|
-
* The logic historically lived in a bundled bash script (scripts/reindex.sh)
|
|
13
|
-
* that this module exec'd via `bash`. It is now implemented natively in
|
|
14
|
-
* TypeScript so the package ships a single, testable code path with no runtime
|
|
15
|
-
* dependency on a shipped shell script. Behaviour is preserved exactly: the
|
|
16
|
-
* same skill wrappers, personal-overlay mirrors, legacy-symlink cleanup, and
|
|
17
|
-
* idempotent re-runs as before. Historically the script ran directly as a
|
|
18
|
-
* Claude Code hook inside hq-core (named "master-sync"); the hq-core hook
|
|
19
|
-
* remains a thin shim over `hq reindex`.
|
|
20
|
-
*/
|
|
21
|
-
import { spawnSync } from "child_process";
|
|
22
|
-
import * as fs from "fs";
|
|
23
|
-
import * as os from "os";
|
|
24
|
-
import * as path from "path";
|
|
25
|
-
import { readlinkOrNull } from "../lib/readlink-safe.js";
|
|
26
|
-
import {
|
|
27
|
-
acquireOperationLock,
|
|
28
|
-
OperationLockedError,
|
|
29
|
-
OperationLockUnwritableError,
|
|
30
|
-
OPERATION_LOCKED_EXIT,
|
|
31
|
-
OPERATION_LOCK_UNWRITABLE_EXIT,
|
|
32
|
-
type LockHandle,
|
|
33
|
-
} from "../operation-lock.js";
|
|
34
|
-
import {
|
|
35
|
-
emitCloudTelemetry,
|
|
36
|
-
type CloudTelemetryClient,
|
|
37
|
-
type TelemetryClaims,
|
|
38
|
-
} from "../telemetry-events.js";
|
|
39
|
-
import { decodeLocalVaultSegment, encodeLocalVaultSegment } from "../local-path-codec.js";
|
|
40
|
-
import { materializeLegacyKnowledgeRepos } from "./reindex-knowledge.js";
|
|
41
|
-
|
|
42
|
-
export interface ReindexOptions {
|
|
43
|
-
/** HQ root to operate on. Defaults to process.cwd(). */
|
|
44
|
-
repoRoot?: string;
|
|
45
|
-
/**
|
|
46
|
-
* Skip reindex's own per-root "reindex"-scope lock. Internal callers
|
|
47
|
-
* (`sync()` / `rescue()`) run reindex inline at the tail of a pass they
|
|
48
|
-
* already serialize, and pass `true` so a standalone `hq reindex` holding the
|
|
49
|
-
* reindex lock never blocks the sync/rescue tail (reindex is idempotent and
|
|
50
|
-
* re-run every pass, so a benign overlap self-heals). Standalone callers
|
|
51
|
-
* (`hq reindex`, the reindex hook) leave it falsy so concurrent reindexes are
|
|
52
|
-
* mutually exclusive.
|
|
53
|
-
*/
|
|
54
|
-
skipLock?: boolean;
|
|
55
|
-
/**
|
|
56
|
-
* Bounded wait (seconds) for the per-root "reindex"-scope lock when another
|
|
57
|
-
* reindex is already running. `0` → refuse immediately; omitted → inherit
|
|
58
|
-
* `HQ_OP_LOCK_TIMEOUT` / infinite wait. Ignored when `skipLock` is set.
|
|
59
|
-
*
|
|
60
|
-
* NB: reindex uses a lock scope SEPARATE from sync/rescue's "operation" lock,
|
|
61
|
-
* so a long-lived watch-mode sync-runner (which holds the operation lock for
|
|
62
|
-
* its whole lifetime) can never starve a standalone `hq reindex`
|
|
63
|
-
* (feedback_ed98d810). Standalone `hq reindex` waits by default, which is what
|
|
64
|
-
* a human running it wants; the hq-core reindex HOOK (Stop / PostToolUse)
|
|
65
|
-
* should set a small `HQ_OP_LOCK_TIMEOUT` (or `0`) so a hook fired while
|
|
66
|
-
* another reindex runs never blocks the interactive agent indefinitely.
|
|
67
|
-
*/
|
|
68
|
-
lockTimeoutSec?: number;
|
|
69
|
-
/** Optional hq-pro ACTION telemetry sink. Omitted in tests/standalone hooks. */
|
|
70
|
-
telemetryClient?: CloudTelemetryClient | null;
|
|
71
|
-
telemetryClaims?: TelemetryClaims | null;
|
|
72
|
-
telemetrySessionId?: string;
|
|
73
|
-
telemetryCompanyUid?: string;
|
|
74
|
-
changedCount?: number;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export interface ReindexResult {
|
|
78
|
-
/** 0 = success; 1 = invalid repo root (mirrors the old script's exit codes). */
|
|
79
|
-
status: number;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// --- small fs helpers (each mirrors a bash test operator) -------------------
|
|
83
|
-
|
|
84
|
-
/** `[ -d "$p" ]` — exists and is a directory (follows symlinks). */
|
|
85
|
-
function isDir(p: string): boolean {
|
|
86
|
-
try {
|
|
87
|
-
return fs.statSync(p).isDirectory();
|
|
88
|
-
} catch {
|
|
89
|
-
return false;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** `[ -f "$p" ]` — exists and is a regular file (follows symlinks). */
|
|
94
|
-
function isFile(p: string): boolean {
|
|
95
|
-
try {
|
|
96
|
-
return fs.statSync(p).isFile();
|
|
97
|
-
} catch {
|
|
98
|
-
return false;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** `[ -e "$p" ]` — exists, dereferencing symlinks (a broken symlink is false). */
|
|
103
|
-
function existsFollow(p: string): boolean {
|
|
104
|
-
try {
|
|
105
|
-
fs.statSync(p);
|
|
106
|
-
return true;
|
|
107
|
-
} catch {
|
|
108
|
-
return false;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** `[ -x "$p" ]` — exists and is executable. */
|
|
113
|
-
function isExecutable(p: string): boolean {
|
|
114
|
-
try {
|
|
115
|
-
fs.accessSync(p, fs.constants.X_OK);
|
|
116
|
-
return true;
|
|
117
|
-
} catch {
|
|
118
|
-
return false;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** lstat without throwing (null when the path doesn't exist). */
|
|
123
|
-
function lstatOrNull(p: string): fs.Stats | null {
|
|
124
|
-
try {
|
|
125
|
-
return fs.lstatSync(p);
|
|
126
|
-
} catch {
|
|
127
|
-
return null;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Entries of `dir` matching a bash `dir/*` glob: dotfiles excluded (no
|
|
133
|
-
* dotglob), sorted lexicographically (bash glob order). Missing dir → [].
|
|
134
|
-
*/
|
|
135
|
-
function globEntries(dir: string): string[] {
|
|
136
|
-
let names: string[];
|
|
137
|
-
try {
|
|
138
|
-
names = fs.readdirSync(dir);
|
|
139
|
-
} catch {
|
|
140
|
-
return [];
|
|
141
|
-
}
|
|
142
|
-
return names.filter((n) => !n.startsWith(".")).sort();
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function warn(msg: string): void {
|
|
146
|
-
process.stderr.write(`${msg}\n`);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Create a directory (recursively), tolerating platform-specific mkdir
|
|
150
|
-
// failures instead of aborting the whole reindex. The classic case (HQ-B0) is
|
|
151
|
-
// a `<ns>:<skill>` wrapper whose ':' is a reserved drive/ADS separator on
|
|
152
|
-
// Windows, where mkdirSync throws ENOENT — but any single mkdir failure (a
|
|
153
|
-
// stray file in the slot, a permission hiccup) should degrade gracefully and
|
|
154
|
-
// let the rest of the reindex proceed. Returns true on success; on failure it
|
|
155
|
-
// logs a clear, actionable warning and returns false so the caller can skip
|
|
156
|
-
// just that piece. PR #98 guarded only the wrapper mkdir; this guards every
|
|
157
|
-
// mkdir site so no single one can crash `hq reindex`.
|
|
158
|
-
function safeMkdir(dir: string, label: string): boolean {
|
|
159
|
-
try {
|
|
160
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
161
|
-
return true;
|
|
162
|
-
} catch (err) {
|
|
163
|
-
const code = (err as NodeJS.ErrnoException).code;
|
|
164
|
-
warn(
|
|
165
|
-
`reindex: could not create ${label} '${dir}' ` +
|
|
166
|
-
`(${code ?? "error"}: ${(err as Error).message}); skipping. On Windows ` +
|
|
167
|
-
`this is usually an illegal path character (e.g. a ':' in a '<ns>:<skill>' ` +
|
|
168
|
-
`wrapper name).`,
|
|
169
|
-
);
|
|
170
|
-
return false;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// Bridge a source entry into a wrapper, tolerating platform-specific failures
|
|
175
|
-
// instead of aborting the whole reindex — the bridging sibling of safeMkdir.
|
|
176
|
-
// A symlink is preferred: it is nearly free and keeps the wrapper in lock-step
|
|
177
|
-
// with edits to the source. The classic failure (HQ-CLI-4) is Windows WITHOUT
|
|
178
|
-
// Developer Mode (or an elevated shell), where symlink creation is a privileged
|
|
179
|
-
// operation and `fs.symlinkSync` throws EPERM (Win32 ERROR_PRIVILEGE_NOT_HELD).
|
|
180
|
-
//
|
|
181
|
-
// Historically we just skipped that entry, which on Windows left the wrapper
|
|
182
|
-
// directory EMPTY — so the bridged skill/command never surfaced as a slash
|
|
183
|
-
// command at all (bridging silently never worked on such machines). Instead of
|
|
184
|
-
// skipping, fall back to an NTFS hardlink (unprivileged on the same volume, and
|
|
185
|
-
// still inode-shared with the source) and then, only if that also fails, to a
|
|
186
|
-
// plain copy. That is exactly the manual `New-Item -ItemType HardLink` workaround
|
|
187
|
-
// users resort to, made automatic.
|
|
188
|
-
//
|
|
189
|
-
// `absSource` is the real (absolute) path of the source entry — needed because a
|
|
190
|
-
// hardlink/copy references the source directly, not via the relative symlink
|
|
191
|
-
// target. reindex is idempotent and re-run on hooks, so a wrapper left empty by
|
|
192
|
-
// an older build self-heals on the next run: the missing entry is (re)bridged
|
|
193
|
-
// here. On a machine that later gains symlink privilege, an entry already bridged
|
|
194
|
-
// as a hardlink/copy is left in place (the caller treats a non-symlink as
|
|
195
|
-
// pre-existing) — it still works, just isn't upgraded to a symlink. Returns true
|
|
196
|
-
// if the entry was bridged by any method, false only if all of them failed.
|
|
197
|
-
function safeBridgeLink(
|
|
198
|
-
relativeTarget: string,
|
|
199
|
-
linkPath: string,
|
|
200
|
-
absSource: string,
|
|
201
|
-
label: string,
|
|
202
|
-
): boolean {
|
|
203
|
-
try {
|
|
204
|
-
fs.symlinkSync(relativeTarget, linkPath);
|
|
205
|
-
return true;
|
|
206
|
-
} catch (symlinkErr) {
|
|
207
|
-
const symlinkCode = (symlinkErr as NodeJS.ErrnoException).code;
|
|
208
|
-
// Fallback 1: hardlink. Unprivileged on Windows/NTFS and stays inode-shared
|
|
209
|
-
// with the source, so in-place edits still propagate. Files only — hardlinking
|
|
210
|
-
// a directory fails, so a directory entry falls through to the copy below.
|
|
211
|
-
try {
|
|
212
|
-
fs.linkSync(absSource, linkPath);
|
|
213
|
-
return true;
|
|
214
|
-
} catch {
|
|
215
|
-
/* not supported here (e.g. directory, cross-volume) — try a copy */
|
|
216
|
-
}
|
|
217
|
-
// Fallback 2: plain copy (handles files and directories). A point-in-time
|
|
218
|
-
// snapshot — the last resort when neither a symlink nor a hardlink is possible.
|
|
219
|
-
try {
|
|
220
|
-
fs.cpSync(absSource, linkPath, { recursive: true, force: true });
|
|
221
|
-
return true;
|
|
222
|
-
} catch (copyErr) {
|
|
223
|
-
const copyCode = (copyErr as NodeJS.ErrnoException).code;
|
|
224
|
-
warn(
|
|
225
|
-
`reindex: could not bridge ${label} '${linkPath}' -> '${relativeTarget}' ` +
|
|
226
|
-
`(symlink ${symlinkCode ?? "error"}: ${(symlinkErr as Error).message}; ` +
|
|
227
|
-
`copy ${copyCode ?? "error"}: ${(copyErr as Error).message}); skipping. ` +
|
|
228
|
-
`On Windows, creating symlinks needs Developer Mode (Settings -> Privacy ` +
|
|
229
|
-
`& security -> For developers) or an elevated shell.`,
|
|
230
|
-
);
|
|
231
|
-
return false;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// Capture the raw session transcripts of every supported coding harness for
|
|
237
|
-
// this HQ tree into `workspace/.session-logs/<harness>/`, so they live inside
|
|
238
|
-
// the HQ tree (where sync/search can see them) on each reindex.
|
|
239
|
-
//
|
|
240
|
-
// Scope — "this HQ tree" = the HQ root cwd OR any working directory UNDER it
|
|
241
|
-
// (worktrees, repo subdirs). Harnesses key their logs by cwd differently, so
|
|
242
|
-
// each helper re-derives the same scope in its own encoding.
|
|
243
|
-
//
|
|
244
|
-
// Contract (per the feature request), applied by every helper:
|
|
245
|
-
// - copies ALL matching files/folders into `.session-logs/<harness>/…`,
|
|
246
|
-
// - OVERWRITES existing dest files, and
|
|
247
|
-
// - NEVER deletes dest entries that lack a source counterpart — a one-way
|
|
248
|
-
// overlay, not a mirror, so anything already parked in `.session-logs`
|
|
249
|
-
// survives even after a harness rotates or prunes its own store.
|
|
250
|
-
//
|
|
251
|
-
// Best-effort throughout: any failure warns to stderr and is swallowed so it
|
|
252
|
-
// can never fail the reindex. Each harness is independent — one failing must
|
|
253
|
-
// not stop the others. Home dirs honor CLAUDE_CONFIG_DIR / CODEX_HOME /
|
|
254
|
-
// GROK_HOME, else ~/.claude, ~/.codex, ~/.grok.
|
|
255
|
-
function copySessionLogs(root: string): void {
|
|
256
|
-
const destBase = path.join(root, "workspace", ".session-logs");
|
|
257
|
-
for (const capture of [
|
|
258
|
-
copyClaudeSessionLogs,
|
|
259
|
-
copyCodexSessionLogs,
|
|
260
|
-
copyGrokSessionLogs,
|
|
261
|
-
]) {
|
|
262
|
-
try {
|
|
263
|
-
capture(root, destBase);
|
|
264
|
-
} catch (err) {
|
|
265
|
-
warn(`reindex: session-log capture step failed (${(err as Error).message}); skipping.`);
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Candidate spellings of the HQ root to match harness-recorded cwds against:
|
|
271
|
-
// the lexical root AND its realpath. Harnesses key logs by the cwd the user
|
|
272
|
-
// actually ran in, which may reach the same tree through a symlink or a
|
|
273
|
-
// case-different spelling (macOS volumes are case-insensitive); realpath
|
|
274
|
-
// canonicalizes both. Best-effort: realpath may throw if root is gone.
|
|
275
|
-
// De-duplicated, lexical root first (stable, preferred dest naming).
|
|
276
|
-
function hqRootCandidates(root: string): string[] {
|
|
277
|
-
const out = [root];
|
|
278
|
-
try {
|
|
279
|
-
const real = fs.realpathSync.native(root);
|
|
280
|
-
if (real && real !== root) out.push(real);
|
|
281
|
-
} catch {
|
|
282
|
-
// best-effort — a missing/unreadable root just means no realpath candidate
|
|
283
|
-
}
|
|
284
|
-
return out;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/** `fs.statSync` without throwing (follows symlinks; null when missing). */
|
|
288
|
-
function statOrNull(p: string): fs.Stats | null {
|
|
289
|
-
try {
|
|
290
|
-
return fs.statSync(p);
|
|
291
|
-
} catch {
|
|
292
|
-
return null;
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// Overlay-copy a whole directory tree: mkdir dest, then recursive+force cpSync
|
|
297
|
-
// (overwrite existing, never prune dest-only entries). Best-effort — warns and
|
|
298
|
-
// returns on any failure. Shared by the Claude + Grok per-cwd-folder captures.
|
|
299
|
-
function copyTreeOverlay(src: string, dest: string, label: string): void {
|
|
300
|
-
if (!safeMkdir(dest, `${label} directory`)) return;
|
|
301
|
-
try {
|
|
302
|
-
fs.cpSync(src, dest, { recursive: true, force: true });
|
|
303
|
-
} catch (err) {
|
|
304
|
-
warn(
|
|
305
|
-
`reindex: could not copy ${label} from '${src}' to '${dest}' ` +
|
|
306
|
-
`(${(err as NodeJS.ErrnoException).code ?? "error"}: ${(err as Error).message}); skipping.`,
|
|
307
|
-
);
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// Claude Code: `<claude-config>/projects/<encoded-cwd>/…`, where the cwd is
|
|
312
|
-
// encoded by replacing every non-alphanumeric char with '-'. Because that map
|
|
313
|
-
// is per-character, a descendant cwd's dir name is exactly the root's encoded
|
|
314
|
-
// name + '-' + <encoded-subpath> — so "this HQ tree" is the exact-root dir OR
|
|
315
|
-
// any dir whose name starts with `<slug>-`. Copy each into `.session-logs/claude/`.
|
|
316
|
-
function copyClaudeSessionLogs(root: string, destBase: string): void {
|
|
317
|
-
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
318
|
-
const projectsDir = path.join(configDir, "projects");
|
|
319
|
-
if (!isDir(projectsDir)) return;
|
|
320
|
-
|
|
321
|
-
const slugs = [...new Set(hqRootCandidates(root).map((r) => r.replace(/[^a-zA-Z0-9]/g, "-")))];
|
|
322
|
-
for (const entry of globEntries(projectsDir)) {
|
|
323
|
-
if (!slugs.some((slug) => entry === slug || entry.startsWith(`${slug}-`))) continue;
|
|
324
|
-
const src = path.join(projectsDir, entry);
|
|
325
|
-
if (!isDir(src)) continue;
|
|
326
|
-
copyTreeOverlay(src, path.join(destBase, "claude", entry), "claude session logs");
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// Grok: `<grok-home>/sessions/<url-encoded-cwd>/<session-uuid>/…`, where the cwd
|
|
331
|
-
// is URL-encoded (encodeURIComponent: '/' -> '%2F'). A descendant cwd's key is
|
|
332
|
-
// the root's key + '%2F' + <encoded-subpath>, so match the exact-root key OR
|
|
333
|
-
// that prefix. Copy each matching cwd folder into `.session-logs/grok/`.
|
|
334
|
-
function copyGrokSessionLogs(root: string, destBase: string): void {
|
|
335
|
-
const grokHome = process.env.GROK_HOME || path.join(os.homedir(), ".grok");
|
|
336
|
-
const sessionsDir = path.join(grokHome, "sessions");
|
|
337
|
-
if (!isDir(sessionsDir)) return;
|
|
338
|
-
|
|
339
|
-
const keys = [...new Set(hqRootCandidates(root).map((r) => encodeURIComponent(r)))];
|
|
340
|
-
for (const entry of globEntries(sessionsDir)) {
|
|
341
|
-
if (!keys.some((key) => entry === key || entry.startsWith(`${key}%2F`))) continue;
|
|
342
|
-
const src = path.join(sessionsDir, entry);
|
|
343
|
-
if (!isDir(src)) continue;
|
|
344
|
-
copyTreeOverlay(src, path.join(destBase, "grok", entry), "grok session logs");
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// Codex: `<codex-home>/sessions/YYYY/MM/DD/rollout-*.jsonl` — a DATE tree, not
|
|
349
|
-
// per-project. The working dir is only recorded inside each rollout's first
|
|
350
|
-
// line (`session_meta`, at `payload.cwd`). Walk the tree and capture a rollout
|
|
351
|
-
// iff its recorded cwd is the HQ root or under it, mirroring the date-relative
|
|
352
|
-
// path under `.session-logs/codex/`. A rollout already copied and unchanged is
|
|
353
|
-
// skipped by mtime, so a steady-state reindex only classifies newly-written
|
|
354
|
-
// rollouts — the full-tree cwd scan is a one-time first-run cost.
|
|
355
|
-
function copyCodexSessionLogs(root: string, destBase: string): void {
|
|
356
|
-
const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
357
|
-
const sessionsDir = path.join(codexHome, "sessions");
|
|
358
|
-
if (!isDir(sessionsDir)) return;
|
|
359
|
-
|
|
360
|
-
const destDir = path.join(destBase, "codex");
|
|
361
|
-
const candidates = hqRootCandidates(root);
|
|
362
|
-
const prefixes = candidates.map((r) => (r.endsWith(path.sep) ? r : r + path.sep));
|
|
363
|
-
|
|
364
|
-
for (const abs of walkFiles(sessionsDir)) {
|
|
365
|
-
if (!abs.endsWith(".jsonl")) continue;
|
|
366
|
-
const srcStat = statOrNull(abs);
|
|
367
|
-
if (!srcStat) continue;
|
|
368
|
-
|
|
369
|
-
const dest = path.join(destDir, path.relative(sessionsDir, abs));
|
|
370
|
-
// Cheap skip: already captured and unchanged since (dest mtime is the copy
|
|
371
|
-
// time, so it stays >= src mtime until the rollout is appended to again).
|
|
372
|
-
const destStat = statOrNull(dest);
|
|
373
|
-
if (destStat && destStat.mtimeMs >= srcStat.mtimeMs) continue;
|
|
374
|
-
|
|
375
|
-
const cwd = readCodexRolloutCwd(abs);
|
|
376
|
-
if (cwd === null) continue;
|
|
377
|
-
if (!candidates.includes(cwd) && !prefixes.some((prefix) => cwd.startsWith(prefix))) continue;
|
|
378
|
-
|
|
379
|
-
if (!safeMkdir(path.dirname(dest), "codex session-log dir")) continue;
|
|
380
|
-
try {
|
|
381
|
-
fs.copyFileSync(abs, dest);
|
|
382
|
-
} catch (err) {
|
|
383
|
-
warn(
|
|
384
|
-
`reindex: could not copy codex session log '${abs}' -> '${dest}' ` +
|
|
385
|
-
`(${(err as NodeJS.ErrnoException).code ?? "error"}: ${(err as Error).message}); skipping.`,
|
|
386
|
-
);
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// Collect every regular file under `dir` (absolute paths) in depth-first order.
|
|
392
|
-
// Symlinks are not followed (lstat gate) so a stray link can't escape the tree
|
|
393
|
-
// or loop. Missing/unreadable dirs yield []. Best-effort — mirrors
|
|
394
|
-
// globEntries' tolerance.
|
|
395
|
-
function walkFiles(dir: string): string[] {
|
|
396
|
-
const out: string[] = [];
|
|
397
|
-
const stack: Array<{ names: string[]; index: number; dir: string }> = [];
|
|
398
|
-
const enterDirectory = (currentDir: string): void => {
|
|
399
|
-
try {
|
|
400
|
-
stack.push({ names: fs.readdirSync(currentDir), index: 0, dir: currentDir });
|
|
401
|
-
} catch {
|
|
402
|
-
// Missing and unreadable children are ignored just like the recursive
|
|
403
|
-
// walker did when its nested call returned an empty list.
|
|
404
|
-
}
|
|
405
|
-
};
|
|
406
|
-
|
|
407
|
-
enterDirectory(dir);
|
|
408
|
-
while (stack.length > 0) {
|
|
409
|
-
const frame = stack[stack.length - 1];
|
|
410
|
-
if (frame.index >= frame.names.length) {
|
|
411
|
-
stack.pop();
|
|
412
|
-
continue;
|
|
413
|
-
}
|
|
414
|
-
const name = frame.names[frame.index++];
|
|
415
|
-
const abs = path.join(frame.dir, name);
|
|
416
|
-
const st = lstatOrNull(abs);
|
|
417
|
-
if (!st) continue;
|
|
418
|
-
if (st.isDirectory()) enterDirectory(abs);
|
|
419
|
-
else if (st.isFile()) out.push(abs);
|
|
420
|
-
}
|
|
421
|
-
return out;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
// Extract `payload.cwd` (Codex ≥ the session_meta format; falls back to a
|
|
425
|
-
// top-level `cwd`) from a rollout's first line, reading only that line. Codex
|
|
426
|
-
// records cwd early, before the large `base_instructions` blob, but the line
|
|
427
|
-
// can still be tens of KB — so read up to a generous cap and stop at the first
|
|
428
|
-
// newline. Returns null on any parse/read failure (the rollout is then skipped).
|
|
429
|
-
function readCodexRolloutCwd(file: string): string | null {
|
|
430
|
-
const line = readFirstLine(file, 4 * 1024 * 1024);
|
|
431
|
-
if (line === null) return null;
|
|
432
|
-
try {
|
|
433
|
-
const obj = JSON.parse(line) as { cwd?: unknown; payload?: { cwd?: unknown } };
|
|
434
|
-
const cwd = obj?.payload?.cwd ?? obj?.cwd;
|
|
435
|
-
return typeof cwd === "string" ? cwd : null;
|
|
436
|
-
} catch {
|
|
437
|
-
return null;
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
// Read the first line of `file` (up to `capBytes`), returning it WITHOUT the
|
|
442
|
-
// trailing newline, or null on error / empty. Byte-accumulates and decodes once
|
|
443
|
-
// so a multibyte char split across a read boundary can't corrupt the result.
|
|
444
|
-
function readFirstLine(file: string, capBytes: number): string | null {
|
|
445
|
-
let fd: number | null = null;
|
|
446
|
-
try {
|
|
447
|
-
fd = fs.openSync(file, "r");
|
|
448
|
-
const chunks: Buffer[] = [];
|
|
449
|
-
const chunk = Buffer.allocUnsafe(65536);
|
|
450
|
-
let total = 0;
|
|
451
|
-
while (total < capBytes) {
|
|
452
|
-
const n = fs.readSync(fd, chunk, 0, chunk.length, total);
|
|
453
|
-
if (n <= 0) break;
|
|
454
|
-
const nl = chunk.indexOf(0x0a, 0);
|
|
455
|
-
if (nl !== -1 && nl < n) {
|
|
456
|
-
chunks.push(Buffer.from(chunk.subarray(0, nl)));
|
|
457
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
458
|
-
}
|
|
459
|
-
chunks.push(Buffer.from(chunk.subarray(0, n)));
|
|
460
|
-
total += n;
|
|
461
|
-
}
|
|
462
|
-
return chunks.length ? Buffer.concat(chunks).toString("utf8") : null;
|
|
463
|
-
} catch {
|
|
464
|
-
return null;
|
|
465
|
-
} finally {
|
|
466
|
-
if (fd !== null) {
|
|
467
|
-
try {
|
|
468
|
-
fs.closeSync(fd);
|
|
469
|
-
} catch {
|
|
470
|
-
/* best-effort */
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// --- legacy `.claude/commands/<ns>/<skill>.md` symlink matcher --------------
|
|
477
|
-
// Mirrors the bash `case` patterns; `*` matches any chars (including `/`).
|
|
478
|
-
function isLegacyCommandTarget(t: string): boolean {
|
|
479
|
-
return (
|
|
480
|
-
/^\.\.\/\.\.\/\.\.\/companies\/.*\/skills\/.*\/SKILL\.md$/.test(t) ||
|
|
481
|
-
/^\.\.\/\.\.\/\.\.\/core\/skills\/.*\/SKILL\.md$/.test(t) ||
|
|
482
|
-
/^\.\.\/\.\.\/\.\.\/personal\/skills\/.*\/SKILL\.md$/.test(t) ||
|
|
483
|
-
/^\.\.\/\.\.\/\.\.\/core\/packages\/.*\/skills\/.*\/SKILL\.md$/.test(t)
|
|
484
|
-
);
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
/**
|
|
488
|
-
* Run reindex against an HQ root. Synchronous — the work is cheap and
|
|
489
|
-
* idempotent, and callers (the hook shim, sync/rescue, tests) want the exit
|
|
490
|
-
* status. Diagnostics are written to stderr so the caller's stdout stays clean
|
|
491
|
-
* (hooks must not emit stdout that the agent interprets).
|
|
492
|
-
*/
|
|
493
|
-
export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
494
|
-
const rawRoot = opts.repoRoot ?? process.cwd();
|
|
495
|
-
if (!isDir(rawRoot)) {
|
|
496
|
-
warn(`reindex: REPO_ROOT '${rawRoot}' is not a directory`);
|
|
497
|
-
const result = { status: 1 };
|
|
498
|
-
emitReindexTelemetry(opts, result, "not_attempted");
|
|
499
|
-
return result;
|
|
500
|
-
}
|
|
501
|
-
const root = path.resolve(rawRoot);
|
|
502
|
-
|
|
503
|
-
// Acquire the per-root "reindex"-scope lock unless an internal caller
|
|
504
|
-
// (sync/rescue) opted out. This scope is SEPARATE from sync/rescue's
|
|
505
|
-
// "operation" lock, so a long-lived watch-mode sync-runner holding the
|
|
506
|
-
// operation lock never starves us (feedback_ed98d810) — we only contend with
|
|
507
|
-
// other reindexes. A live holder → refuse fast with the holder's command +
|
|
508
|
-
// PID. The whole body runs inside the try so the lock is released on every
|
|
509
|
-
// exit path (the process-level signal/exit hooks are the crash backstop).
|
|
510
|
-
let opLock: LockHandle | null = null;
|
|
511
|
-
if (!opts.skipLock) {
|
|
512
|
-
try {
|
|
513
|
-
opLock = acquireOperationLock(root, "reindex", {
|
|
514
|
-
scope: "reindex",
|
|
515
|
-
timeoutSec: opts.lockTimeoutSec,
|
|
516
|
-
});
|
|
517
|
-
} catch (err) {
|
|
518
|
-
if (err instanceof OperationLockedError) {
|
|
519
|
-
warn(err.message);
|
|
520
|
-
const result = { status: OPERATION_LOCKED_EXIT };
|
|
521
|
-
emitReindexTelemetry(opts, result, "locked");
|
|
522
|
-
return result;
|
|
523
|
-
}
|
|
524
|
-
if (err instanceof OperationLockUnwritableError) {
|
|
525
|
-
// The lock dir isn't writable (e.g. macOS EPERM / Linux EACCES on a
|
|
526
|
-
// root-owned or restricted ~/.hq). This is an environmental problem on
|
|
527
|
-
// the user's machine, not an HQ fault — surface the actionable guidance
|
|
528
|
-
// and exit cleanly instead of crashing with a raw `EPERM ... open`
|
|
529
|
-
// (HQ-CLI-2).
|
|
530
|
-
warn(err.message);
|
|
531
|
-
const result = { status: OPERATION_LOCK_UNWRITABLE_EXIT };
|
|
532
|
-
emitReindexTelemetry(opts, result, "unwritable");
|
|
533
|
-
return result;
|
|
534
|
-
}
|
|
535
|
-
throw err;
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
try {
|
|
539
|
-
|
|
540
|
-
// --- Legacy knowledge-repo materialization --------------------------------
|
|
541
|
-
// Knowledge repositories must be real directories at their canonical paths
|
|
542
|
-
// (policy knowledge-repositories-never-symlink); a symlink to a repo under
|
|
543
|
-
// repos/ syncs as an empty vault marker. Scour the canonical knowledge
|
|
544
|
-
// locations, pull + copy each legacy repo inline, and drop the fully
|
|
545
|
-
// migrated legacy repo. Best-effort and a pure no-op on compliant trees.
|
|
546
|
-
materializeLegacyKnowledgeRepos(root);
|
|
547
|
-
|
|
548
|
-
// Overlay/skill-wrapper mirroring is best-effort and, on a normal synced HQ,
|
|
549
|
-
// legitimately no-ops for MANY entries (a real core file already sits where a
|
|
550
|
-
// symlink would go, or a link already points elsewhere). Emitting one stderr
|
|
551
|
-
// line PER such entry floods the runner's Sentry breadcrumb ring (100 entries)
|
|
552
|
-
// and evicts the real per-company sync error, making code-2 syncs impossible to
|
|
553
|
-
// triage (HQ-SYNC-2 / HQ-SYNC-WEB-6). Collapse those per-file lines into a
|
|
554
|
-
// single end-of-run summary; keep per-file detail behind HQ_REINDEX_VERBOSE=1.
|
|
555
|
-
const verbose = process.env.HQ_REINDEX_VERBOSE === "1";
|
|
556
|
-
let skippedNonSymlink = 0;
|
|
557
|
-
let skippedPointsElsewhere = 0;
|
|
558
|
-
|
|
559
|
-
// Best-effort: each wrapper mkdir below is recursive and re-creates this
|
|
560
|
-
// parent as needed, so a failure here is non-fatal — warn and carry on so
|
|
561
|
-
// personal-overlay mirroring and registry regeneration still run.
|
|
562
|
-
safeMkdir(path.join(root, ".claude", "skills"), ".claude/skills directory");
|
|
563
|
-
|
|
564
|
-
// --- Build (namespace, src_rel) pairs -------------------------------------
|
|
565
|
-
const pairs: { ns: string; srcRel: string }[] = [];
|
|
566
|
-
const addNs = (ns: string, srcRel: string): void => {
|
|
567
|
-
if (isDir(path.join(root, srcRel))) pairs.push({ ns, srcRel });
|
|
568
|
-
};
|
|
569
|
-
|
|
570
|
-
for (const slug of globEntries(path.join(root, "companies"))) {
|
|
571
|
-
if (isDir(path.join(root, "companies", slug))) {
|
|
572
|
-
addNs(slug, `companies/${slug}/skills`);
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
addNs("core", "core/skills");
|
|
576
|
-
addNs("personal", "personal/skills");
|
|
577
|
-
for (const pack of globEntries(path.join(root, "core", "packages"))) {
|
|
578
|
-
if (isDir(path.join(root, "core", "packages", pack))) {
|
|
579
|
-
addNs(pack, `core/packages/${pack}/skills`);
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
// --- Build (namespace, src_rel) pairs for slash commands ------------------
|
|
584
|
-
// Mirrors the skill namespaces but reads from <ns>/commands/ instead of
|
|
585
|
-
// <ns>/skills/. Built independently so a namespace with commands but no
|
|
586
|
-
// skills (or vice-versa) is handled on its own.
|
|
587
|
-
const cmdPairs: { ns: string; srcRel: string }[] = [];
|
|
588
|
-
const addCmdNs = (ns: string, srcRel: string): void => {
|
|
589
|
-
if (isDir(path.join(root, srcRel))) cmdPairs.push({ ns, srcRel });
|
|
590
|
-
};
|
|
591
|
-
for (const slug of globEntries(path.join(root, "companies"))) {
|
|
592
|
-
if (isDir(path.join(root, "companies", slug))) {
|
|
593
|
-
addCmdNs(slug, `companies/${slug}/commands`);
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
addCmdNs("core", "core/commands");
|
|
597
|
-
addCmdNs("personal", "personal/commands");
|
|
598
|
-
for (const pack of globEntries(path.join(root, "core", "packages"))) {
|
|
599
|
-
if (isDir(path.join(root, "core", "packages", pack))) {
|
|
600
|
-
addCmdNs(pack, `core/packages/${pack}/commands`);
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
// --- Cleanup pass A: drop legacy .claude/commands/<ns>/<skill>.md links ----
|
|
605
|
-
const commandsDir = path.join(root, ".claude", "commands");
|
|
606
|
-
if (isDir(commandsDir)) {
|
|
607
|
-
for (const nsName of globEntries(commandsDir)) {
|
|
608
|
-
const cmdNsDir = path.join(commandsDir, nsName);
|
|
609
|
-
// Only real directories (bash `*/`); folder-level symlinks are left for
|
|
610
|
-
// a human to resolve. lstat().isDirectory() is false for a symlink, so
|
|
611
|
-
// this single check covers both bash guards.
|
|
612
|
-
const st = lstatOrNull(cmdNsDir);
|
|
613
|
-
if (!st || !st.isDirectory()) continue;
|
|
614
|
-
|
|
615
|
-
for (const f of globEntries(cmdNsDir)) {
|
|
616
|
-
if (!f.endsWith(".md")) continue;
|
|
617
|
-
const fp = path.join(cmdNsDir, f);
|
|
618
|
-
const fst = lstatOrNull(fp);
|
|
619
|
-
if (!fst || !fst.isSymbolicLink()) continue;
|
|
620
|
-
const target = readlinkOrNull(fp);
|
|
621
|
-
if (target && isLegacyCommandTarget(target)) {
|
|
622
|
-
try {
|
|
623
|
-
fs.rmSync(fp);
|
|
624
|
-
} catch {
|
|
625
|
-
/* best-effort */
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
// rmdir if empty; ignore "directory not empty".
|
|
631
|
-
try {
|
|
632
|
-
fs.rmdirSync(cmdNsDir);
|
|
633
|
-
} catch {
|
|
634
|
-
/* not empty / gone — fine */
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
// --- Skill wrapper creation -----------------------------------------------
|
|
640
|
-
const expectedWrappers = new Set<string>();
|
|
641
|
-
const seen = new Set<string>();
|
|
642
|
-
|
|
643
|
-
for (const { ns, srcRel } of pairs) {
|
|
644
|
-
// First writer for a namespace wins.
|
|
645
|
-
if (seen.has(ns)) {
|
|
646
|
-
warn(
|
|
647
|
-
`reindex: namespace '${ns}' already claimed by an earlier source; skipping ${srcRel}`,
|
|
648
|
-
);
|
|
649
|
-
continue;
|
|
650
|
-
}
|
|
651
|
-
seen.add(ns);
|
|
652
|
-
|
|
653
|
-
const srcAbs = path.join(root, srcRel);
|
|
654
|
-
for (const skillName of globEntries(srcAbs)) {
|
|
655
|
-
const skillPath = path.join(srcAbs, skillName);
|
|
656
|
-
if (!isDir(skillPath)) continue;
|
|
657
|
-
// Skip `_shared`, `_template`, etc. (dotfiles already excluded by glob).
|
|
658
|
-
if (skillName.startsWith("_")) continue;
|
|
659
|
-
if (!isFile(path.join(skillPath, "SKILL.md"))) continue;
|
|
660
|
-
|
|
661
|
-
const wrapperName = encodeLocalVaultSegment(`${ns}:${skillName}`);
|
|
662
|
-
const wrapper = path.join(root, ".claude", "skills", wrapperName);
|
|
663
|
-
expectedWrappers.add(wrapperName);
|
|
664
|
-
|
|
665
|
-
const wst = lstatOrNull(wrapper);
|
|
666
|
-
// If something non-directory, non-symlink occupies the slot, bail.
|
|
667
|
-
if (wst && !wst.isSymbolicLink() && !wst.isDirectory()) {
|
|
668
|
-
warn(`reindex: ${wrapper} exists and is not a directory; skipping`);
|
|
669
|
-
continue;
|
|
670
|
-
}
|
|
671
|
-
// If it's a symlink (legacy directory-symlink form), replace it with a
|
|
672
|
-
// real directory of per-file symlinks.
|
|
673
|
-
if (wst && wst.isSymbolicLink()) {
|
|
674
|
-
try {
|
|
675
|
-
fs.rmSync(wrapper);
|
|
676
|
-
} catch {
|
|
677
|
-
/* best-effort */
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
// Namespaced wrappers embed a ':' in the directory name (`<ns>:<skill>`),
|
|
681
|
-
// which is a reserved drive/ADS separator on Windows where mkdir fails
|
|
682
|
-
// with ENOENT (HQ-B0). Skip just this wrapper instead of aborting the
|
|
683
|
-
// whole reindex; the skill's source folder is untouched.
|
|
684
|
-
if (!safeMkdir(wrapper, `skill wrapper '${wrapperName}'`)) continue;
|
|
685
|
-
|
|
686
|
-
// Symlink every (non-hidden) entry in the source skill folder. The
|
|
687
|
-
// wrapper lives three levels below REPO_ROOT.
|
|
688
|
-
for (const entry of globEntries(skillPath)) {
|
|
689
|
-
const entryPath = path.join(skillPath, entry);
|
|
690
|
-
if (!existsFollow(entryPath)) continue;
|
|
691
|
-
const linkPath = path.join(wrapper, entry);
|
|
692
|
-
const relativeTarget = `../../../${srcRel}/${skillName}/${entry}`;
|
|
693
|
-
|
|
694
|
-
const lst = lstatOrNull(linkPath);
|
|
695
|
-
if (lst && lst.isSymbolicLink()) {
|
|
696
|
-
const current = readlinkOrNull(linkPath);
|
|
697
|
-
if (current === relativeTarget) continue;
|
|
698
|
-
skippedPointsElsewhere++;
|
|
699
|
-
if (verbose) {
|
|
700
|
-
warn(
|
|
701
|
-
`reindex: .claude/skills/${wrapperName}/${entry} already points to '${current}' (expected '${relativeTarget}'); leaving alone`,
|
|
702
|
-
);
|
|
703
|
-
}
|
|
704
|
-
continue;
|
|
705
|
-
} else if (lst) {
|
|
706
|
-
skippedNonSymlink++;
|
|
707
|
-
if (verbose) {
|
|
708
|
-
warn(`reindex: ${linkPath} already exists and is not a symlink; skipping`);
|
|
709
|
-
}
|
|
710
|
-
continue;
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
safeBridgeLink(relativeTarget, linkPath, entryPath, `.claude/skills/${wrapperName}/${entry}`);
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
// Prune symlinks in the wrapper whose source entry no longer exists.
|
|
717
|
-
for (const entry of globEntries(wrapper)) {
|
|
718
|
-
const linkPath = path.join(wrapper, entry);
|
|
719
|
-
const lst = lstatOrNull(linkPath);
|
|
720
|
-
if (!lst || !lst.isSymbolicLink()) continue;
|
|
721
|
-
if (existsFollow(linkPath)) continue;
|
|
722
|
-
try {
|
|
723
|
-
fs.rmSync(linkPath);
|
|
724
|
-
} catch {
|
|
725
|
-
/* best-effort */
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
// --- Cleanup pass B: drop orphan <ns>:<skill> wrappers --------------------
|
|
732
|
-
const skillsDir = path.join(root, ".claude", "skills");
|
|
733
|
-
for (const entry of globEntries(skillsDir)) {
|
|
734
|
-
const entryPath = path.join(skillsDir, entry);
|
|
735
|
-
const lst = lstatOrNull(entryPath);
|
|
736
|
-
if (!lst) continue; // accept broken symlinks (lstat still succeeds)
|
|
737
|
-
const canonicalEntry = decodeLocalVaultSegment(entry);
|
|
738
|
-
if (!canonicalEntry.includes(":")) continue; // not a namespaced wrapper
|
|
739
|
-
if (expectedWrappers.has(entry)) continue; // maintained this run
|
|
740
|
-
|
|
741
|
-
// A wrapper is "managed" only when its namespace prefix matches the
|
|
742
|
-
// namespace encoded in its symlink target — this distinguishes
|
|
743
|
-
// script-produced wrappers from hand-authored composite wrappers.
|
|
744
|
-
const ns = canonicalEntry.slice(0, canonicalEntry.indexOf(":"));
|
|
745
|
-
const matchTarget = (t: string | null, p: string): boolean => {
|
|
746
|
-
if (!t) return false;
|
|
747
|
-
if (t.startsWith(`${p}/personal/skills/`)) return ns === "personal";
|
|
748
|
-
if (t.startsWith(`${p}/core/skills/`)) return ns === "core";
|
|
749
|
-
if (t.startsWith(`${p}/companies/${ns}/skills/`)) return true;
|
|
750
|
-
if (t.startsWith(`${p}/core/packages/${ns}/skills/`)) return true;
|
|
751
|
-
return false;
|
|
752
|
-
};
|
|
753
|
-
|
|
754
|
-
let isManaged = false;
|
|
755
|
-
if (lst.isSymbolicLink()) {
|
|
756
|
-
// (a) directory-style symlink wrapper (older shape): 2-level target.
|
|
757
|
-
isManaged = matchTarget(readlinkOrNull(entryPath), "../..");
|
|
758
|
-
} else if (lst.isDirectory()) {
|
|
759
|
-
// (b) real directory of per-file symlinks (current shape): 3-level.
|
|
760
|
-
for (const f of globEntries(entryPath)) {
|
|
761
|
-
const fp = path.join(entryPath, f);
|
|
762
|
-
const fst = lstatOrNull(fp);
|
|
763
|
-
if (!fst || !fst.isSymbolicLink()) continue;
|
|
764
|
-
if (matchTarget(readlinkOrNull(fp), "../../..")) {
|
|
765
|
-
isManaged = true;
|
|
766
|
-
break;
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
if (!isManaged) continue;
|
|
771
|
-
|
|
772
|
-
// Managed-namespace wrapper with no corresponding live source → drop.
|
|
773
|
-
try {
|
|
774
|
-
if (lst.isSymbolicLink()) {
|
|
775
|
-
fs.rmSync(entryPath);
|
|
776
|
-
} else if (lst.isDirectory()) {
|
|
777
|
-
fs.rmSync(entryPath, { recursive: true, force: true });
|
|
778
|
-
}
|
|
779
|
-
} catch {
|
|
780
|
-
/* best-effort */
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
// --- Slash-command surfacing ----------------------------------------------
|
|
785
|
-
// Parallel to skills: mirror each namespace's commands/<cmd>.md into
|
|
786
|
-
// .claude/commands/<ns>:<cmd>.md, so a company/core/personal/pack command
|
|
787
|
-
// shows up as /<ns>:<cmd> with the same <ns>:<name> naming as skills. A slash
|
|
788
|
-
// command is a single .md file (not a folder), so each wrapper is ONE symlink
|
|
789
|
-
// two levels below REPO_ROOT — no per-file wrapper directory.
|
|
790
|
-
const expectedCommands = new Set<string>();
|
|
791
|
-
const seenCmd = new Set<string>();
|
|
792
|
-
// Lazy: only materialize .claude/commands when a source command exists, so a
|
|
793
|
-
// tree with no commands never gains an empty directory. The orphan-prune pass
|
|
794
|
-
// below still runs against whatever already exists.
|
|
795
|
-
if (cmdPairs.length > 0) {
|
|
796
|
-
safeMkdir(commandsDir, ".claude/commands directory");
|
|
797
|
-
}
|
|
798
|
-
for (const { ns, srcRel } of cmdPairs) {
|
|
799
|
-
// First writer for a namespace wins (matches the skill dedup).
|
|
800
|
-
if (seenCmd.has(ns)) {
|
|
801
|
-
warn(
|
|
802
|
-
`reindex: command namespace '${ns}' already claimed by an earlier source; skipping ${srcRel}`,
|
|
803
|
-
);
|
|
804
|
-
continue;
|
|
805
|
-
}
|
|
806
|
-
seenCmd.add(ns);
|
|
807
|
-
|
|
808
|
-
const srcAbs = path.join(root, srcRel);
|
|
809
|
-
for (const entry of globEntries(srcAbs)) {
|
|
810
|
-
// Skip `_`-prefixed include partials (dotfiles already excluded by glob)
|
|
811
|
-
// and anything that isn't a regular `.md` command file.
|
|
812
|
-
if (entry.startsWith("_")) continue;
|
|
813
|
-
if (!entry.endsWith(".md")) continue;
|
|
814
|
-
if (!isFile(path.join(srcAbs, entry))) continue;
|
|
815
|
-
|
|
816
|
-
const wrapperName = encodeLocalVaultSegment(`${ns}:${entry}`);
|
|
817
|
-
const linkPath = path.join(commandsDir, wrapperName);
|
|
818
|
-
const relativeTarget = `../../${srcRel}/${entry}`;
|
|
819
|
-
expectedCommands.add(wrapperName);
|
|
820
|
-
|
|
821
|
-
const lst = lstatOrNull(linkPath);
|
|
822
|
-
if (lst && lst.isSymbolicLink()) {
|
|
823
|
-
const current = readlinkOrNull(linkPath);
|
|
824
|
-
if (current === relativeTarget) continue;
|
|
825
|
-
skippedPointsElsewhere++;
|
|
826
|
-
if (verbose) {
|
|
827
|
-
warn(
|
|
828
|
-
`reindex: .claude/commands/${wrapperName} already points to '${current}' (expected '${relativeTarget}'); leaving alone`,
|
|
829
|
-
);
|
|
830
|
-
}
|
|
831
|
-
continue;
|
|
832
|
-
} else if (lst) {
|
|
833
|
-
skippedNonSymlink++;
|
|
834
|
-
if (verbose) {
|
|
835
|
-
warn(`reindex: ${linkPath} already exists and is not a symlink; skipping`);
|
|
836
|
-
}
|
|
837
|
-
continue;
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
safeBridgeLink(relativeTarget, linkPath, path.join(srcAbs, entry), `.claude/commands/${wrapperName}`);
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
// --- Cleanup: drop orphan <ns>:<cmd>.md command links ---------------------
|
|
845
|
-
// Symmetric to skill Cleanup pass B, adapted to the flat one-symlink-per-file
|
|
846
|
-
// shape (`../../` target, no wrapper directory). Only prunes symlinks reindex
|
|
847
|
-
// owns: a managed namespace whose target resolves into that namespace's
|
|
848
|
-
// commands/. Real files (even under a managed namespace) and composite links
|
|
849
|
-
// under an unmanaged namespace are left for a human to resolve.
|
|
850
|
-
for (const entry of globEntries(commandsDir)) {
|
|
851
|
-
const entryPath = path.join(commandsDir, entry);
|
|
852
|
-
const lst = lstatOrNull(entryPath);
|
|
853
|
-
if (!lst) continue; // accept broken symlinks (lstat still succeeds)
|
|
854
|
-
if (!lst.isSymbolicLink()) continue; // real files / legacy subdirs → not ours
|
|
855
|
-
const canonicalEntry = decodeLocalVaultSegment(entry);
|
|
856
|
-
if (!canonicalEntry.endsWith(".md")) continue;
|
|
857
|
-
if (!canonicalEntry.includes(":")) continue; // not a namespaced command
|
|
858
|
-
if (expectedCommands.has(entry)) continue; // maintained this run
|
|
859
|
-
|
|
860
|
-
const ns = canonicalEntry.slice(0, canonicalEntry.indexOf(":"));
|
|
861
|
-
const cmdName = canonicalEntry.slice(canonicalEntry.indexOf(":") + 1);
|
|
862
|
-
const target = readlinkOrNull(entryPath);
|
|
863
|
-
// A reindex-created link points at EXACTLY ../../<managed-base>/commands/
|
|
864
|
-
// <cmdName> — its basename equals the decoded command name. Cross-check the
|
|
865
|
-
// full expected target (not just the namespace-dir prefix) so a hand-authored
|
|
866
|
-
// alias such as `core:alias.md -> ../../core/commands/demo.md` (basename
|
|
867
|
-
// 'demo.md' != 'alias.md', and with no core/commands/alias.md source) is
|
|
868
|
-
// preserved rather than pruned as if reindex owned it.
|
|
869
|
-
const managedTargets: string[] = [];
|
|
870
|
-
if (ns === "personal") managedTargets.push(`../../personal/commands/${cmdName}`);
|
|
871
|
-
if (ns === "core") managedTargets.push(`../../core/commands/${cmdName}`);
|
|
872
|
-
managedTargets.push(`../../companies/${ns}/commands/${cmdName}`);
|
|
873
|
-
managedTargets.push(`../../core/packages/${ns}/commands/${cmdName}`);
|
|
874
|
-
if (target === null || !managedTargets.includes(target)) continue;
|
|
875
|
-
|
|
876
|
-
// Managed command link with no corresponding live source → drop.
|
|
877
|
-
try {
|
|
878
|
-
fs.rmSync(entryPath);
|
|
879
|
-
} catch {
|
|
880
|
-
/* best-effort */
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
// --- Personal-overlay mirroring: RETIRED + migration cleanup ---------------
|
|
885
|
-
// personal/{knowledge,policies,workers,settings} is now read DIRECTLY by the
|
|
886
|
-
// HQ tooling (the inject-policy-on-trigger hook reads personal/policies, the
|
|
887
|
-
// workers-registry generator walks personal/workers, qmd-reindex-after-sync
|
|
888
|
-
// registers a personal/knowledge collection, and repo-run-registry reads
|
|
889
|
-
// personal/settings/orchestrator.yaml). personal is the SOLE read source for
|
|
890
|
-
// the personal overlay, so reindex no longer SYMLINKS personal/<type>/<entry>
|
|
891
|
-
// into core/<type>/<entry> — a mirror would now double-surface every entry
|
|
892
|
-
// (read once directly, once via the link).
|
|
893
|
-
//
|
|
894
|
-
// Migration cleanup: prune any mirror symlink a PRIOR release created —
|
|
895
|
-
// core/<type>/<entry> whose target is EXACTLY `../../personal/<type>/<entry>`.
|
|
896
|
-
// That exact-target discriminator (policy wrapper-sync-orphan-cleanup-cross-
|
|
897
|
-
// check) means a real release-shipped core file (not a symlink) or a human-
|
|
898
|
-
// authored symlink pointing elsewhere is never touched. Unlike the old
|
|
899
|
-
// "Cleanup pass C", this prunes the link WHETHER OR NOT its personal source
|
|
900
|
-
// still exists: the mirror is retired, so every managed link is now stale.
|
|
901
|
-
// Kept for a few releases so existing trees self-heal on the next reindex.
|
|
902
|
-
for (const type of ["knowledge", "policies", "workers", "settings"]) {
|
|
903
|
-
const coreDir = path.join(root, "core", type);
|
|
904
|
-
for (const entry of globEntries(coreDir)) {
|
|
905
|
-
const linkPath = path.join(coreDir, entry);
|
|
906
|
-
const lst = lstatOrNull(linkPath);
|
|
907
|
-
if (!lst || !lst.isSymbolicLink()) continue; // real file/dir → not ours
|
|
908
|
-
if (readlinkOrNull(linkPath) !== `../../personal/${type}/${entry}`) continue; // not a managed mirror link
|
|
909
|
-
// Managed mirror symlink from a prior release → prune (mirror is retired).
|
|
910
|
-
try {
|
|
911
|
-
fs.rmSync(linkPath);
|
|
912
|
-
} catch {
|
|
913
|
-
/* best-effort */
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
// --- Session-log capture --------------------------------------------------
|
|
919
|
-
// Overlay this root's Claude Code session logs into workspace/.session-logs
|
|
920
|
-
// (copy-all, overwrite, never-prune). Best-effort; never fails the reindex.
|
|
921
|
-
copySessionLogs(root);
|
|
922
|
-
|
|
923
|
-
// --- Workers registry regeneration ----------------------------------------
|
|
924
|
-
// Source of truth: each worker.yaml. The registry is a derived index — the
|
|
925
|
-
// generator (when present in the operated-on tree) keeps it in sync.
|
|
926
|
-
// Best-effort and idempotent; never fails the reindex.
|
|
927
|
-
const genScript = path.join(root, "core", "scripts", "generate-workers-registry.sh");
|
|
928
|
-
if (isExecutable(genScript)) {
|
|
929
|
-
// Route the generator's stdout+stderr to our stderr (`>&2 2>&1`).
|
|
930
|
-
spawnSync("bash", [genScript], { stdio: ["ignore", 2, 2] });
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
// One-line summary in place of the per-file overlay-skip spam (see the comment
|
|
934
|
-
// at the top of the try). Only emitted when something was skipped, so a clean
|
|
935
|
-
// run stays silent and the breadcrumb ring is free for real sync errors.
|
|
936
|
-
if (skippedNonSymlink > 0 || skippedPointsElsewhere > 0) {
|
|
937
|
-
const parts: string[] = [];
|
|
938
|
-
if (skippedNonSymlink > 0) {
|
|
939
|
-
parts.push(`${skippedNonSymlink} pre-existing non-symlink ${skippedNonSymlink === 1 ? "entry" : "entries"}`);
|
|
940
|
-
}
|
|
941
|
-
if (skippedPointsElsewhere > 0) {
|
|
942
|
-
parts.push(`${skippedPointsElsewhere} ${skippedPointsElsewhere === 1 ? "entry" : "entries"} already pointing elsewhere`);
|
|
943
|
-
}
|
|
944
|
-
warn(
|
|
945
|
-
`reindex: left ${parts.join(" + ")} untouched (overlay mirror is best-effort; set HQ_REINDEX_VERBOSE=1 for per-file detail)`,
|
|
946
|
-
);
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
const result = { status: 0 };
|
|
950
|
-
emitReindexTelemetry(opts, result, opts.skipLock ? "skipped" : "acquired");
|
|
951
|
-
return result;
|
|
952
|
-
} finally {
|
|
953
|
-
opLock?.release();
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
function emitReindexTelemetry(
|
|
958
|
-
opts: ReindexOptions,
|
|
959
|
-
result: ReindexResult,
|
|
960
|
-
lockResult: string,
|
|
961
|
-
): void {
|
|
962
|
-
void emitCloudTelemetry(
|
|
963
|
-
opts.telemetryClient,
|
|
964
|
-
{
|
|
965
|
-
eventName: "reindex_run",
|
|
966
|
-
source: "hq-reindex",
|
|
967
|
-
...(opts.telemetryCompanyUid ? { companyUid: opts.telemetryCompanyUid } : {}),
|
|
968
|
-
...(opts.telemetrySessionId ? { sessionId: opts.telemetrySessionId } : {}),
|
|
969
|
-
properties: {
|
|
970
|
-
result: result.status === 0 ? "completed" : "failed",
|
|
971
|
-
statusCode: result.status,
|
|
972
|
-
lockResult,
|
|
973
|
-
changedCount: opts.changedCount ?? 0,
|
|
974
|
-
skipLockState: opts.skipLock === true ? "skipped" : "acquired",
|
|
975
|
-
},
|
|
976
|
-
},
|
|
977
|
-
{ claims: opts.telemetryClaims },
|
|
978
|
-
);
|
|
979
|
-
}
|