@indigoai-us/hq-cloud 5.48.4 → 6.0.1
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/cli/index.d.ts +2 -2
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +2 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/reindex.d.ts +23 -0
- package/dist/cli/reindex.d.ts.map +1 -0
- package/dist/cli/reindex.js +45 -0
- package/dist/cli/reindex.js.map +1 -0
- package/dist/cli/reindex.test.d.ts +11 -0
- package/dist/cli/reindex.test.d.ts.map +1 -0
- package/dist/cli/{master-sync.test.js → reindex.test.js} +15 -15
- package/dist/cli/reindex.test.js.map +1 -0
- package/dist/cli/rescue.d.ts.map +1 -1
- package/dist/cli/rescue.js +16 -1
- package/dist/cli/rescue.js.map +1 -1
- package/dist/cli/rescue.reindex.test.d.ts +2 -0
- package/dist/cli/rescue.reindex.test.d.ts.map +1 -0
- package/dist/cli/rescue.reindex.test.js +41 -0
- package/dist/cli/rescue.reindex.test.js.map +1 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +21 -1
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +108 -2
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +9 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +17 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +20 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/scripts/{master-sync.sh → reindex.sh} +10 -10
- package/scripts/replace-rescue.sh +20 -20
- package/src/cli/index.ts +3 -3
- package/src/cli/{master-sync.test.ts → reindex.test.ts} +14 -14
- package/src/cli/reindex.ts +57 -0
- package/src/cli/rescue.reindex.test.ts +46 -0
- package/src/cli/rescue.ts +15 -1
- package/src/cli/share.test.ts +138 -2
- package/src/cli/share.ts +23 -1
- package/src/cli/sync.test.ts +23 -0
- package/src/cli/sync.ts +27 -0
- package/src/index.ts +3 -3
- package/dist/cli/master-sync.d.ts +0 -22
- package/dist/cli/master-sync.d.ts.map +0 -1
- package/dist/cli/master-sync.js +0 -44
- package/dist/cli/master-sync.js.map +0 -1
- package/dist/cli/master-sync.test.d.ts +0 -11
- package/dist/cli/master-sync.test.d.ts.map +0 -1
- package/dist/cli/master-sync.test.js.map +0 -1
- package/src/cli/master-sync.ts +0 -56
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
#
|
|
2
|
+
# reindex.sh — surfaces namespace skills as Claude Code skills under
|
|
3
3
|
# .claude/skills/<ns>:<skill>/ with every file in the source skill folder
|
|
4
4
|
# mirrored as a symlink. Also mirrors personal/{knowledge,policies,workers,
|
|
5
5
|
# settings}/* into core/<type>/<name>.
|
|
6
6
|
#
|
|
7
7
|
# This script ships inside the @indigoai-us/hq-cloud package and is invoked via
|
|
8
|
-
# `hq
|
|
8
|
+
# `hq reindex` (which passes the HQ root as $1). It is idempotent and cheap
|
|
9
9
|
# to re-run, so it doesn't gate on whether personal/ was actually touched. Real
|
|
10
10
|
# files/dirs already at the link path are left untouched.
|
|
11
11
|
#
|
|
12
12
|
# REPO_ROOT resolution (in priority order):
|
|
13
|
-
# 1. $1 — passed by `hq
|
|
13
|
+
# 1. $1 — passed by `hq reindex` (the HQ root)
|
|
14
14
|
# 2. $HQ_REPO_ROOT — env override
|
|
15
15
|
# 3. $PWD — fallback when invoked directly
|
|
16
16
|
#
|
|
@@ -40,7 +40,7 @@ set -uo pipefail
|
|
|
40
40
|
|
|
41
41
|
REPO_ROOT="${1:-${HQ_REPO_ROOT:-$PWD}}"
|
|
42
42
|
if [ ! -d "$REPO_ROOT" ]; then
|
|
43
|
-
echo "
|
|
43
|
+
echo "reindex: REPO_ROOT '$REPO_ROOT' is not a directory" >&2
|
|
44
44
|
exit 1
|
|
45
45
|
fi
|
|
46
46
|
REPO_ROOT="$(cd "$REPO_ROOT" && pwd)"
|
|
@@ -128,7 +128,7 @@ while [ "$i" -lt "${#namespaces[@]}" ]; do
|
|
|
128
128
|
fi
|
|
129
129
|
done
|
|
130
130
|
if [ "$already" -eq 1 ]; then
|
|
131
|
-
echo "
|
|
131
|
+
echo "reindex: namespace '$ns' already claimed by an earlier source; skipping $src_rel" >&2
|
|
132
132
|
continue
|
|
133
133
|
fi
|
|
134
134
|
seen+=("$ns")
|
|
@@ -147,7 +147,7 @@ while [ "$i" -lt "${#namespaces[@]}" ]; do
|
|
|
147
147
|
|
|
148
148
|
# If something non-directory occupies the slot, bail.
|
|
149
149
|
if [ -e "$wrapper" ] && [ ! -L "$wrapper" ] && [ ! -d "$wrapper" ]; then
|
|
150
|
-
echo "
|
|
150
|
+
echo "reindex: $wrapper exists and is not a directory; skipping" >&2
|
|
151
151
|
continue
|
|
152
152
|
fi
|
|
153
153
|
# If it's a symlink (e.g. legacy directory-symlink form from earlier
|
|
@@ -171,10 +171,10 @@ while [ "$i" -lt "${#namespaces[@]}" ]; do
|
|
|
171
171
|
if [ "$current" = "$relative_target" ]; then
|
|
172
172
|
continue
|
|
173
173
|
fi
|
|
174
|
-
echo "
|
|
174
|
+
echo "reindex: .claude/skills/$wrapper_name/$entry already points to '$current' (expected '$relative_target'); leaving alone" >&2
|
|
175
175
|
continue
|
|
176
176
|
elif [ -e "$link_path" ]; then
|
|
177
|
-
echo "
|
|
177
|
+
echo "reindex: $link_path already exists and is not a symlink; skipping" >&2
|
|
178
178
|
continue
|
|
179
179
|
fi
|
|
180
180
|
|
|
@@ -298,10 +298,10 @@ for type in knowledge policies workers settings; do
|
|
|
298
298
|
if [ "$current" = "$relative_target" ]; then
|
|
299
299
|
continue
|
|
300
300
|
fi
|
|
301
|
-
echo "
|
|
301
|
+
echo "reindex: core/$type/$entry already points to '$current' (expected '$relative_target'); leaving alone" >&2
|
|
302
302
|
continue
|
|
303
303
|
elif [ -e "$link_path" ]; then
|
|
304
|
-
echo "
|
|
304
|
+
echo "reindex: core/$type/$entry already exists and is not a symlink; skipping" >&2
|
|
305
305
|
continue
|
|
306
306
|
fi
|
|
307
307
|
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
# not a customization — saving it under personal/ or .hq-conflicts/ just
|
|
78
78
|
# accumulates noise.
|
|
79
79
|
#
|
|
80
|
-
# Symlinks generated by `
|
|
80
|
+
# Symlinks generated by `reindex.sh` (targets resolving into
|
|
81
81
|
# `personal/`) are DELETED during the walk rather than preserved. They
|
|
82
82
|
# rebuild deterministically on the next Stop-hook pass — keeping the old
|
|
83
83
|
# link risks shadowing the upstream file the overlay writes at the same
|
|
@@ -245,7 +245,7 @@ BACKUP_DIR="" # resolved at snapshot time (BACKUP_ROOT/pre-update-<RUN_TS>)
|
|
|
245
245
|
# personal + installed-pack workers. Its content is a function of the
|
|
246
246
|
# user's install state, not the upstream tree, so an overlay that
|
|
247
247
|
# replaces it produces a registry that doesn't match what's on disk
|
|
248
|
-
# (and `
|
|
248
|
+
# (and `reindex` regenerates it on the next Stop hook anyway).
|
|
249
249
|
# Shuttling preserves the up-to-date local version across the rescue.
|
|
250
250
|
# Why .claude/settings.local.json: per-user local settings (denied paths,
|
|
251
251
|
# env vars, allow-listed tool patterns) authored by the user via the CLI
|
|
@@ -436,7 +436,7 @@ if [ "${#PRESERVE_SUBPATHS[@]}" -ne 0 ]; then
|
|
|
436
436
|
fi
|
|
437
437
|
done
|
|
438
438
|
fi
|
|
439
|
-
echo "==> Drift policy: rescue user-edited files to personal/; route .agents|.codex|.obsidian|MIGRATION.md user-edits to .hq-conflicts/rescue-$RUN_TS/; silently overwrite AGENTS.md|USER-GUIDE.md|core/policies/_digest.md (regenerable); leave user-only files untouched; drop
|
|
439
|
+
echo "==> Drift policy: rescue user-edited files to personal/; route .agents|.codex|.obsidian|MIGRATION.md user-edits to .hq-conflicts/rescue-$RUN_TS/; silently overwrite AGENTS.md|USER-GUIDE.md|core/policies/_digest.md (regenerable); leave user-only files untouched; drop reindex symlinks (regenerated by reindex.sh)"
|
|
440
440
|
if [ "$CLOUD_UPDATE" = "1" ]; then
|
|
441
441
|
echo "==> Cloud-update mode: ON (recognize \`hq-symlink:<target>\` flat files as upstream-symlink-equivalent — reconciled as UNCHANGED)"
|
|
442
442
|
fi
|
|
@@ -459,7 +459,7 @@ if [ "$ASSUME_YES" != "1" ] && [ "$DRY_RUN" != "1" ]; then
|
|
|
459
459
|
if [ "$CLOUD_UPDATE" = "1" ]; then
|
|
460
460
|
_cloud_line="\n * reconcile \`hq-symlink:<target>\` flat files against upstream symlinks (cloud-update mode),"
|
|
461
461
|
fi
|
|
462
|
-
printf "\nThis will:\n%s\n * rescue user-edited files (vs the last sync) into personal/,\n * route user-edited .agents/.codex/.obsidian/MIGRATION.md into .hq-conflicts/rescue-%s/ for review,\n * silently overwrite AGENTS.md / USER-GUIDE.md / core/policies/_digest.md (regenerable from upstream or
|
|
462
|
+
printf "\nThis will:\n%s\n * rescue user-edited files (vs the last sync) into personal/,\n * route user-edited .agents/.codex/.obsidian/MIGRATION.md into .hq-conflicts/rescue-%s/ for review,\n * silently overwrite AGENTS.md / USER-GUIDE.md / core/policies/_digest.md (regenerable from upstream or reindex — no copy preserved),%b\n * drop reindex-generated symlinks (regenerated by reindex.sh on next Stop hook),\n * leave user-only files (not in upstream) untouched,\n * delete upstream files unchanged since last sync, then unpack %s@%s on top.\nType 'yes' to proceed: " "$_backup_line" "$RUN_TS" "$_cloud_line" "$SOURCE_REPO" "$REF"
|
|
463
463
|
read -r confirm
|
|
464
464
|
[ "$confirm" = "yes" ] || { echo "Aborted."; exit 4; }
|
|
465
465
|
fi
|
|
@@ -767,7 +767,7 @@ rescue_one() {
|
|
|
767
767
|
#
|
|
768
768
|
# Routing them to .hq-conflicts/rescue-<RUN_TS>/<rel> keeps them quarantined
|
|
769
769
|
# under a timestamped bucket the user can inspect and discard, without
|
|
770
|
-
# polluting the personal/ overlay tree (where
|
|
770
|
+
# polluting the personal/ overlay tree (where reindex would then try
|
|
771
771
|
# to re-mirror them into core/).
|
|
772
772
|
is_conflict_class() {
|
|
773
773
|
local rel="$1"
|
|
@@ -814,7 +814,7 @@ conflict_one() {
|
|
|
814
814
|
# core/docs/hq/USER-GUIDE.md — current release doc; always re-shipped.
|
|
815
815
|
# core/policies/_digest.md — generated by `core/scripts/build-policy-digest.sh`
|
|
816
816
|
# from the union of policy files; rebuilt on
|
|
817
|
-
# the next Stop-hook
|
|
817
|
+
# the next Stop-hook reindex pass.
|
|
818
818
|
# Same rationale as core/workers/registry.yaml,
|
|
819
819
|
# minus the carve-out (digest is small + cheap
|
|
820
820
|
# to regenerate, so just let the overlay win).
|
|
@@ -894,11 +894,11 @@ is_cloud_flattened_symlink_equiv() {
|
|
|
894
894
|
[ "$local_target" = "$upstream_target" ]
|
|
895
895
|
}
|
|
896
896
|
|
|
897
|
-
# True when $local_path is a symlink generated by
|
|
897
|
+
# True when $local_path is a symlink generated by reindex.sh — those
|
|
898
898
|
# point into personal/ (e.g. `core/<type>/<entry>` -> `../../personal/...`,
|
|
899
899
|
# `.claude/skills/<ns>:<entry>` -> `../../../personal/skills/<entry>/...`,
|
|
900
900
|
# `.claude/commands/<ns>/<entry>.md` -> `../../../personal/skills/<entry>/SKILL.md`).
|
|
901
|
-
# They are deterministically rebuilt on the next
|
|
901
|
+
# They are deterministically rebuilt on the next reindex.sh run, so the
|
|
902
902
|
# safe move during rescue is to delete rather than preserve — a leftover
|
|
903
903
|
# symlink can shadow an overlaid-from-upstream real file at the same path.
|
|
904
904
|
is_master_sync_symlink() {
|
|
@@ -958,20 +958,20 @@ process_one() {
|
|
|
958
958
|
;;
|
|
959
959
|
esac
|
|
960
960
|
|
|
961
|
-
# Symlinks (mid-tree).
|
|
961
|
+
# Symlinks (mid-tree). reindex.sh deterministically rebuilds the
|
|
962
962
|
# personal/-targeted ones on its next Stop-hook pass, so the safe move
|
|
963
963
|
# here is to delete them — a leftover symlink at a path the overlay
|
|
964
964
|
# also writes to creates an ambiguous "is this the symlink or the
|
|
965
|
-
# overlaid file?" state on disk. Non-
|
|
965
|
+
# overlaid file?" state on disk. Non-reindex symlinks (user-created,
|
|
966
966
|
# absolute targets, or targets outside personal/) are left alone.
|
|
967
967
|
if [ -L "$local_path" ]; then
|
|
968
968
|
if is_master_sync_symlink "$local_path"; then
|
|
969
969
|
COUNT_SYMLINK_DROPPED=$((COUNT_SYMLINK_DROPPED + 1))
|
|
970
970
|
if [ "$DRY_RUN" = "1" ]; then
|
|
971
|
-
echo " drop
|
|
971
|
+
echo " drop reindex symlink: $rel -> $(readlink "$local_path" 2>/dev/null || true)"
|
|
972
972
|
else
|
|
973
973
|
rm -f "$local_path"
|
|
974
|
-
printf 'symlink-dropped\t%s\t(
|
|
974
|
+
printf 'symlink-dropped\t%s\t(reindex regenerable)\n' "$rel" >> "$RESCUE_LOG" 2>/dev/null || true
|
|
975
975
|
fi
|
|
976
976
|
fi
|
|
977
977
|
return 0
|
|
@@ -1038,7 +1038,7 @@ process_one() {
|
|
|
1038
1038
|
|
|
1039
1039
|
# Convergence guard: in history_floor mode a file is flagged USER-EDIT when
|
|
1040
1040
|
# its blob differs from the *old* baseline (the floor). But HQ's own Stop
|
|
1041
|
-
# hooks (
|
|
1041
|
+
# hooks (reindex symlinking personal/->core/, autocommit, registry/
|
|
1042
1042
|
# _digest/core.yaml regeneration) rewrite scaffold files every session, so a
|
|
1043
1043
|
# file routinely drifts from the floor yet lands byte-for-byte identical to
|
|
1044
1044
|
# the *new* upstream HEAD this overlay is about to write. Rescuing it into
|
|
@@ -1123,16 +1123,16 @@ walk_and_process() {
|
|
|
1123
1123
|
# convenience aliases the overlay overwrites cleanly (rsync -a preserves
|
|
1124
1124
|
# link semantics from source). Master-sync personal-overlay symlinks at
|
|
1125
1125
|
# the top level — rare but possible — are dropped here so the overlay
|
|
1126
|
-
# can land a real file at the same path without ambiguity;
|
|
1126
|
+
# can land a real file at the same path without ambiguity; reindex
|
|
1127
1127
|
# regenerates the link on its next Stop-hook pass.
|
|
1128
1128
|
if [ -L "$root_abs" ]; then
|
|
1129
1129
|
if is_master_sync_symlink "$root_abs"; then
|
|
1130
1130
|
COUNT_SYMLINK_DROPPED=$((COUNT_SYMLINK_DROPPED + 1))
|
|
1131
1131
|
if [ "$DRY_RUN" = "1" ]; then
|
|
1132
|
-
echo " drop
|
|
1132
|
+
echo " drop reindex symlink: $root_rel -> $(readlink "$root_abs" 2>/dev/null || true)"
|
|
1133
1133
|
else
|
|
1134
1134
|
rm -f "$root_abs"
|
|
1135
|
-
printf 'symlink-dropped\t%s\t(
|
|
1135
|
+
printf 'symlink-dropped\t%s\t(reindex regenerable)\n' "$root_rel" >> "$RESCUE_LOG" 2>/dev/null || true
|
|
1136
1136
|
fi
|
|
1137
1137
|
fi
|
|
1138
1138
|
return 0
|
|
@@ -1146,7 +1146,7 @@ walk_and_process() {
|
|
|
1146
1146
|
|
|
1147
1147
|
# Top-level directory: walk recursively, pruning node_modules + nested
|
|
1148
1148
|
# .git (v0.1.103 perf + correctness guard). Symlinks are surfaced via
|
|
1149
|
-
# `-type l` so
|
|
1149
|
+
# `-type l` so reindex-generated links (targets resolving into
|
|
1150
1150
|
# personal/) can be dropped — see process_one + is_master_sync_symlink.
|
|
1151
1151
|
# Under -P (the default), find does not descend INTO directory symlinks,
|
|
1152
1152
|
# which keeps the walk bounded even when those links exist.
|
|
@@ -1217,7 +1217,7 @@ if [ "$DRY_RUN" = "1" ]; then
|
|
|
1217
1217
|
echo " user-edit (overwrite-safe): $COUNT_USER_OVERWRITE files"
|
|
1218
1218
|
echo " cloud-symlink reconciled: $COUNT_CLOUD_SYMLINK_RECONCILED files"
|
|
1219
1219
|
echo " drift reconciled (== upstream): $COUNT_DRIFT_RECONCILED files"
|
|
1220
|
-
echo "
|
|
1220
|
+
echo " reindex symlinks dropped: $COUNT_SYMLINK_DROPPED entries"
|
|
1221
1221
|
if [ "$COUNT_CLAUDE_DIFF_APPEND" -gt 0 ]; then
|
|
1222
1222
|
echo " of which .claude/CLAUDE.md would diff-append to personal/CLAUDE.md"
|
|
1223
1223
|
fi
|
|
@@ -1361,7 +1361,7 @@ echo " user-edits (conflict quarantine): $COUNT_USER_CONFLICT files"
|
|
|
1361
1361
|
echo " user-edits (overwrite-safe): $COUNT_USER_OVERWRITE files"
|
|
1362
1362
|
echo " cloud-symlink reconciled: $COUNT_CLOUD_SYMLINK_RECONCILED files"
|
|
1363
1363
|
echo " drift reconciled (== upstream): $COUNT_DRIFT_RECONCILED files"
|
|
1364
|
-
echo "
|
|
1364
|
+
echo " reindex symlinks dropped: $COUNT_SYMLINK_DROPPED entries"
|
|
1365
1365
|
if [ "$COUNT_CLAUDE_DIFF_APPEND" -gt 0 ]; then
|
|
1366
1366
|
echo " of which .claude/CLAUDE.md diff-appended to personal/CLAUDE.md"
|
|
1367
1367
|
fi
|
|
@@ -1396,7 +1396,7 @@ if [ "$DO_BACKUP" = "1" ] && [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ]; then
|
|
|
1396
1396
|
echo " user-edit (overwrite-safe): $COUNT_USER_OVERWRITE"
|
|
1397
1397
|
echo " cloud-symlink reconciled: $COUNT_CLOUD_SYMLINK_RECONCILED"
|
|
1398
1398
|
echo " drift reconciled (== upstream HEAD): $COUNT_DRIFT_RECONCILED"
|
|
1399
|
-
echo "
|
|
1399
|
+
echo " reindex symlinks dropped: $COUNT_SYMLINK_DROPPED"
|
|
1400
1400
|
if [ "$COUNT_USER_CONFLICT" -gt 0 ]; then
|
|
1401
1401
|
echo " conflict bucket: .hq-conflicts/rescue-$RUN_TS/"
|
|
1402
1402
|
fi
|
package/src/cli/index.ts
CHANGED
|
@@ -25,9 +25,9 @@ export { promote } from "./promote.js";
|
|
|
25
25
|
export type { PromoteOptions, PromoteResult } from "./promote.js";
|
|
26
26
|
|
|
27
27
|
// Skill/personal-overlay mirroring + workers-registry regen (formerly the
|
|
28
|
-
// hq-core master-sync.sh hook).
|
|
29
|
-
export {
|
|
30
|
-
export type {
|
|
28
|
+
// hq-core master-sync.sh hook; `hq reindex`).
|
|
29
|
+
export { reindex, reindexScriptPath } from "./reindex.js";
|
|
30
|
+
export type { ReindexOptions, ReindexResult } from "./reindex.js";
|
|
31
31
|
|
|
32
32
|
// Drift-preserving HQ-core re-sync (formerly bundled only inside the HQ Sync
|
|
33
33
|
// menubar app). Now shared between the app and `hq rescue`.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Unit tests for `hq
|
|
2
|
+
* Unit tests for `hq reindex` (the formerly-bash hq-core hook, now shipped
|
|
3
3
|
* in this package and invoked via the CLI).
|
|
4
4
|
*
|
|
5
|
-
* The logic itself lives in scripts/
|
|
6
|
-
* the
|
|
5
|
+
* The logic itself lives in scripts/reindex.sh; these tests exercise it via
|
|
6
|
+
* the reindex() wrapper against a temp HQ tree, asserting the observable
|
|
7
7
|
* filesystem outcomes (skill wrappers, personal-overlay mirroring) rather than
|
|
8
8
|
* re-deriving the bash internals.
|
|
9
9
|
*/
|
|
@@ -12,17 +12,17 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
|
12
12
|
import * as fs from "fs";
|
|
13
13
|
import * as path from "path";
|
|
14
14
|
import * as os from "os";
|
|
15
|
-
import {
|
|
15
|
+
import { reindex, reindexScriptPath } from "./reindex.js";
|
|
16
16
|
|
|
17
|
-
describe("
|
|
17
|
+
describe("reindexScriptPath", () => {
|
|
18
18
|
it("resolves to the bundled script and the file exists", () => {
|
|
19
|
-
const p =
|
|
20
|
-
expect(p.endsWith(path.join("scripts", "
|
|
19
|
+
const p = reindexScriptPath();
|
|
20
|
+
expect(p.endsWith(path.join("scripts", "reindex.sh"))).toBe(true);
|
|
21
21
|
expect(fs.existsSync(p)).toBe(true);
|
|
22
22
|
});
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
-
describe("
|
|
25
|
+
describe("reindex", () => {
|
|
26
26
|
let root: string;
|
|
27
27
|
|
|
28
28
|
beforeEach(() => {
|
|
@@ -44,7 +44,7 @@ describe("masterSync", () => {
|
|
|
44
44
|
fs.writeFileSync(path.join(root, "core/skills/demo/helper.md"), "h\n");
|
|
45
45
|
writeSkill("companies/acme/skills/widget");
|
|
46
46
|
|
|
47
|
-
const { status } =
|
|
47
|
+
const { status } = reindex({ repoRoot: root });
|
|
48
48
|
expect(status).toBe(0);
|
|
49
49
|
|
|
50
50
|
const coreWrapper = path.join(root, ".claude/skills/core:demo");
|
|
@@ -65,7 +65,7 @@ describe("masterSync", () => {
|
|
|
65
65
|
fs.mkdirSync(path.join(root, "personal/policies"), { recursive: true });
|
|
66
66
|
fs.writeFileSync(path.join(root, "personal/policies/myrule.md"), "rule\n");
|
|
67
67
|
|
|
68
|
-
const { status } =
|
|
68
|
+
const { status } = reindex({ repoRoot: root });
|
|
69
69
|
expect(status).toBe(0);
|
|
70
70
|
|
|
71
71
|
const link = path.join(root, "core/policies/myrule.md");
|
|
@@ -75,18 +75,18 @@ describe("masterSync", () => {
|
|
|
75
75
|
|
|
76
76
|
it("prunes orphan managed wrappers when the source skill disappears", () => {
|
|
77
77
|
writeSkill("core/skills/demo");
|
|
78
|
-
|
|
78
|
+
reindex({ repoRoot: root });
|
|
79
79
|
expect(fs.existsSync(path.join(root, ".claude/skills/core:demo"))).toBe(true);
|
|
80
80
|
|
|
81
81
|
fs.rmSync(path.join(root, "core/skills/demo"), { recursive: true, force: true });
|
|
82
|
-
|
|
82
|
+
reindex({ repoRoot: root });
|
|
83
83
|
expect(fs.existsSync(path.join(root, ".claude/skills/core:demo"))).toBe(false);
|
|
84
84
|
});
|
|
85
85
|
|
|
86
86
|
it("is idempotent — a second run is a no-op", () => {
|
|
87
87
|
writeSkill("core/skills/demo");
|
|
88
|
-
expect(
|
|
89
|
-
expect(
|
|
88
|
+
expect(reindex({ repoRoot: root }).status).toBe(0);
|
|
89
|
+
expect(reindex({ repoRoot: root }).status).toBe(0);
|
|
90
90
|
expect(
|
|
91
91
|
fs.readlinkSync(path.join(root, ".claude/skills/core:demo/SKILL.md")),
|
|
92
92
|
).toBe("../../../core/skills/demo/SKILL.md");
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hq reindex — surfaces namespaced skills as Claude Code skill wrappers,
|
|
3
|
+
* mirrors personal/{knowledge,policies,workers,settings} into core/, prunes
|
|
4
|
+
* orphan wrappers, and regenerates the workers registry.
|
|
5
|
+
*
|
|
6
|
+
* The implementation lives in scripts/reindex.sh, shipped with this package.
|
|
7
|
+
* This module resolves that script relative to the package (dist/cli → package
|
|
8
|
+
* root) and execs it against the caller's HQ root. Historically the script ran
|
|
9
|
+
* directly as a Claude Code hook inside hq-core (named "master-sync"); it now
|
|
10
|
+
* lives here so a single copy is maintained, and the hq-core hook is a thin
|
|
11
|
+
* shim over `hq reindex`.
|
|
12
|
+
*/
|
|
13
|
+
import { spawnSync } from "child_process";
|
|
14
|
+
import { fileURLToPath } from "url";
|
|
15
|
+
import path from "path";
|
|
16
|
+
|
|
17
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
18
|
+
const __dirname = path.dirname(__filename);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Absolute path to the bundled reindex.sh. From the compiled module at
|
|
22
|
+
* dist/cli/reindex.js, the package root is two levels up; the script lives
|
|
23
|
+
* at <package-root>/scripts/reindex.sh.
|
|
24
|
+
*/
|
|
25
|
+
export function reindexScriptPath(): string {
|
|
26
|
+
return path.resolve(__dirname, "..", "..", "scripts", "reindex.sh");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ReindexOptions {
|
|
30
|
+
/** HQ root to operate on. Defaults to process.cwd(). */
|
|
31
|
+
repoRoot?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ReindexResult {
|
|
35
|
+
/** Exit status of the underlying script (0 = success). */
|
|
36
|
+
status: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Run reindex against an HQ root. Synchronous — the script is cheap and
|
|
41
|
+
* idempotent, and callers (the hook shim, sync/rescue, tests) want the exit
|
|
42
|
+
* status. stdout/stderr from the script are forwarded to stderr so the
|
|
43
|
+
* caller's stdout stays clean (hooks must not emit stdout that the agent
|
|
44
|
+
* interprets).
|
|
45
|
+
*/
|
|
46
|
+
export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
47
|
+
const repoRoot = opts.repoRoot ?? process.cwd();
|
|
48
|
+
const script = reindexScriptPath();
|
|
49
|
+
const res = spawnSync("bash", [script, repoRoot], {
|
|
50
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
51
|
+
});
|
|
52
|
+
if (res.error) {
|
|
53
|
+
process.stderr.write(`reindex: failed to run ${script}: ${res.error.message}\n`);
|
|
54
|
+
return { status: 1 };
|
|
55
|
+
}
|
|
56
|
+
return { status: res.status ?? 1 };
|
|
57
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
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.
|
|
5
|
+
*
|
|
6
|
+
* child_process is mocked so the real replace-rescue.sh never runs, and
|
|
7
|
+
* ./reindex.js is mocked to a spy so we assert the call without spawning.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
10
|
+
|
|
11
|
+
vi.mock("child_process", () => ({
|
|
12
|
+
spawnSync: vi.fn(() => ({ status: 0 })),
|
|
13
|
+
}));
|
|
14
|
+
vi.mock("./reindex.js", () => ({
|
|
15
|
+
reindex: vi.fn(() => ({ status: 0 })),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import { spawnSync } from "child_process";
|
|
19
|
+
import { reindex } from "./reindex.js";
|
|
20
|
+
import { rescue } from "./rescue.js";
|
|
21
|
+
|
|
22
|
+
describe("rescue → reindex", () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.clearAllMocks();
|
|
25
|
+
(spawnSync as unknown as ReturnType<typeof vi.fn>).mockReturnValue({ status: 0 });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("refreshes via reindex after a successful rescue", () => {
|
|
29
|
+
const r = rescue({ hqRoot: "/tmp/hq", assumeYes: true });
|
|
30
|
+
expect(r.status).toBe(0);
|
|
31
|
+
expect(reindex).toHaveBeenCalledTimes(1);
|
|
32
|
+
expect(reindex).toHaveBeenCalledWith({ repoRoot: "/tmp/hq" });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("does NOT run reindex on a dry-run", () => {
|
|
36
|
+
rescue({ hqRoot: "/tmp/hq", dryRun: true });
|
|
37
|
+
expect(reindex).not.toHaveBeenCalled();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("does NOT run reindex when the rescue script fails", () => {
|
|
41
|
+
(spawnSync as unknown as ReturnType<typeof vi.fn>).mockReturnValueOnce({ status: 1 });
|
|
42
|
+
const r = rescue({ hqRoot: "/tmp/hq", assumeYes: true });
|
|
43
|
+
expect(r.status).toBe(1);
|
|
44
|
+
expect(reindex).not.toHaveBeenCalled();
|
|
45
|
+
});
|
|
46
|
+
});
|
package/src/cli/rescue.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { spawnSync } from "child_process";
|
|
18
18
|
import { fileURLToPath } from "url";
|
|
19
19
|
import path from "path";
|
|
20
|
+
import { reindex } from "./reindex.js";
|
|
20
21
|
|
|
21
22
|
const __filename = fileURLToPath(import.meta.url);
|
|
22
23
|
const __dirname = path.dirname(__filename);
|
|
@@ -118,5 +119,18 @@ export function rescue(opts: RescueOptions = {}): RescueResult {
|
|
|
118
119
|
process.stderr.write(`rescue: failed to run ${script}: ${res.error.message}\n`);
|
|
119
120
|
return { status: 1 };
|
|
120
121
|
}
|
|
121
|
-
|
|
122
|
+
const status = res.status ?? 1;
|
|
123
|
+
// A successful, non-dry-run rescue re-lays-down core/, so refresh the
|
|
124
|
+
// generated skill wrappers, personal-overlay mirrors, and workers registry.
|
|
125
|
+
// Best-effort + idempotent — never overrides the rescue's own exit status.
|
|
126
|
+
// repoRoot falls back to process.cwd() (reindex's default) when hqRoot is
|
|
127
|
+
// omitted, matching the rescue script's own cwd-based default.
|
|
128
|
+
if (status === 0 && !opts.dryRun) {
|
|
129
|
+
try {
|
|
130
|
+
reindex({ repoRoot: opts.hqRoot });
|
|
131
|
+
} catch {
|
|
132
|
+
// best-effort
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { status };
|
|
122
136
|
}
|
package/src/cli/share.test.ts
CHANGED
|
@@ -1091,6 +1091,142 @@ describe("share", () => {
|
|
|
1091
1091
|
expect(journal.files["dangling-link.md"]).toBeDefined();
|
|
1092
1092
|
});
|
|
1093
1093
|
|
|
1094
|
+
it(
|
|
1095
|
+
"personal-vault override: `owned-only` is coerced to `currency-gated` so a direction:'down' " +
|
|
1096
|
+
"entry whose local file is gone DOES tombstone in the vault (the May-27 .drift-* leak class)",
|
|
1097
|
+
async () => {
|
|
1098
|
+
// The personal vault is a single-human-many-machines target. The
|
|
1099
|
+
// owned-only "I won't tombstone peer content" rule has no peer to
|
|
1100
|
+
// protect against, so it traps legitimate local deletes as permanent
|
|
1101
|
+
// vault litter — exactly what the May-27 `personal/.obsidian/*.drift-*`
|
|
1102
|
+
// pair did on this EC2 (journal direction "down", local rm, push
|
|
1103
|
+
// silently swallowed the delete). Coercing to currency-gated keeps the
|
|
1104
|
+
// only safety intent that still applies (HEAD-verify etag matches the
|
|
1105
|
+
// journal before tombstone) without the multi-user premise.
|
|
1106
|
+
const stateDirPersonal = fs.mkdtempSync(
|
|
1107
|
+
path.join(os.tmpdir(), "hq-state-prs-"),
|
|
1108
|
+
);
|
|
1109
|
+
process.env.HQ_STATE_DIR = stateDirPersonal;
|
|
1110
|
+
try {
|
|
1111
|
+
// Personal-vault syncRoot is `hqRoot` itself in personalMode, so the
|
|
1112
|
+
// missing file's absolute path resolves to <hqRoot>/<key>.
|
|
1113
|
+
// No on-disk file = local missing. Journal records a prior pull.
|
|
1114
|
+
const journalPath = path.join(
|
|
1115
|
+
stateDirPersonal,
|
|
1116
|
+
"sync-journal.__hq_personal_vault__.json",
|
|
1117
|
+
);
|
|
1118
|
+
fs.writeFileSync(
|
|
1119
|
+
journalPath,
|
|
1120
|
+
JSON.stringify({
|
|
1121
|
+
version: "1",
|
|
1122
|
+
lastSync: new Date().toISOString(),
|
|
1123
|
+
files: {
|
|
1124
|
+
"personal/scripts/run-project.sh.drift-1779863862-42519": {
|
|
1125
|
+
hash: "personal-vault-drift-hash",
|
|
1126
|
+
size: 1054,
|
|
1127
|
+
syncedAt: new Date(Date.now() - 86400000).toISOString(),
|
|
1128
|
+
direction: "down",
|
|
1129
|
+
remoteEtag: "personal-vault-drift-etag",
|
|
1130
|
+
},
|
|
1131
|
+
},
|
|
1132
|
+
}),
|
|
1133
|
+
);
|
|
1134
|
+
|
|
1135
|
+
// Currency-gated HEADs the candidate; return an etag matching the
|
|
1136
|
+
// journal so the entry resolves as "remote still current → safe to
|
|
1137
|
+
// tombstone."
|
|
1138
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
1139
|
+
lastModified: new Date(),
|
|
1140
|
+
etag: '"personal-vault-drift-etag"',
|
|
1141
|
+
size: 1054,
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
const personalCtx = makeEntityContext({
|
|
1145
|
+
uid: "prs_01HASSAANTESTUSER",
|
|
1146
|
+
slug: "__hq_personal_vault__",
|
|
1147
|
+
bucketName: "hq-vault-prs-01HASSAANTESTUSER",
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
const result = await share({
|
|
1151
|
+
paths: [tmpDir],
|
|
1152
|
+
entityContext: personalCtx,
|
|
1153
|
+
hqRoot: tmpDir,
|
|
1154
|
+
personalMode: true,
|
|
1155
|
+
skipUnchanged: true,
|
|
1156
|
+
propagateDeletes: true,
|
|
1157
|
+
// Caller pinned owned-only. The override MUST take precedence on a
|
|
1158
|
+
// personal-vault entity — the whole point of the fix is that owned-
|
|
1159
|
+
// only's "don't tombstone peer-uploaded content" rule has no peer
|
|
1160
|
+
// to protect against here, so we coerce to currency-gated.
|
|
1161
|
+
propagateDeletePolicy: "owned-only",
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
expect(result.filesDeleted).toBe(1);
|
|
1165
|
+
expect(deleteRemoteFile).toHaveBeenCalledWith(
|
|
1166
|
+
expect.anything(),
|
|
1167
|
+
"personal/scripts/run-project.sh.drift-1779863862-42519",
|
|
1168
|
+
);
|
|
1169
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1170
|
+
expect(
|
|
1171
|
+
journal.files["personal/scripts/run-project.sh.drift-1779863862-42519"],
|
|
1172
|
+
).toBeUndefined();
|
|
1173
|
+
} finally {
|
|
1174
|
+
fs.rmSync(stateDirPersonal, { recursive: true, force: true });
|
|
1175
|
+
delete process.env.HQ_STATE_DIR;
|
|
1176
|
+
}
|
|
1177
|
+
},
|
|
1178
|
+
);
|
|
1179
|
+
|
|
1180
|
+
it(
|
|
1181
|
+
"personal-vault override does NOT apply to company vaults: `owned-only` on cmp_ entity still " +
|
|
1182
|
+
"refuses to tombstone a direction:'down' entry (multi-user curation intact)",
|
|
1183
|
+
async () => {
|
|
1184
|
+
// Sibling guard for the previous test: the override is scoped strictly
|
|
1185
|
+
// to `prs_` entities. Company vaults preserve owned-only's multi-user
|
|
1186
|
+
// semantics — a behind machine pulling Alice's upload, then rm'ing it
|
|
1187
|
+
// locally, must NOT tombstone Alice's file. Without this guard the
|
|
1188
|
+
// override could silently widen its blast radius the next time someone
|
|
1189
|
+
// refactors share()'s entity-typing.
|
|
1190
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1191
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1192
|
+
// No file on disk under companies/acme/peer-uploaded.md → local missing.
|
|
1193
|
+
|
|
1194
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1195
|
+
fs.writeFileSync(
|
|
1196
|
+
journalPath,
|
|
1197
|
+
JSON.stringify({
|
|
1198
|
+
version: "1",
|
|
1199
|
+
lastSync: new Date().toISOString(),
|
|
1200
|
+
files: {
|
|
1201
|
+
"peer-uploaded.md": {
|
|
1202
|
+
hash: "alice-hash",
|
|
1203
|
+
size: 100,
|
|
1204
|
+
syncedAt: new Date(Date.now() - 86400000).toISOString(),
|
|
1205
|
+
direction: "down",
|
|
1206
|
+
remoteEtag: "alice-etag",
|
|
1207
|
+
},
|
|
1208
|
+
},
|
|
1209
|
+
}),
|
|
1210
|
+
);
|
|
1211
|
+
|
|
1212
|
+
const result = await share({
|
|
1213
|
+
paths: [companyRoot],
|
|
1214
|
+
company: "acme",
|
|
1215
|
+
vaultConfig: mockConfig,
|
|
1216
|
+
hqRoot: tmpDir,
|
|
1217
|
+
skipUnchanged: true,
|
|
1218
|
+
propagateDeletes: true,
|
|
1219
|
+
// Company-vault entity (cmp_) → owned-only stays in effect.
|
|
1220
|
+
propagateDeletePolicy: "owned-only",
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
expect(result.filesDeleted).toBe(0);
|
|
1224
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
1225
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1226
|
+
expect(journal.files["peer-uploaded.md"]).toBeDefined();
|
|
1227
|
+
},
|
|
1228
|
+
);
|
|
1229
|
+
|
|
1094
1230
|
it("propagateDeletes: deletes journal-tracked files whose local copy is gone", async () => {
|
|
1095
1231
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1096
1232
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
@@ -2290,7 +2426,7 @@ describe("share", () => {
|
|
|
2290
2426
|
// target's bytes under the link's key while a nested symlink was
|
|
2291
2427
|
// silently dropped from every push. The link topology never survived a
|
|
2292
2428
|
// round trip — fresh-machine pulls landed in a state where overlay
|
|
2293
|
-
// symlinks just didn't exist until
|
|
2429
|
+
// symlinks just didn't exist until reindex.sh recreated them
|
|
2294
2430
|
// locally. The fix detects symlinks via lstat / Dirent.isSymbolicLink
|
|
2295
2431
|
// and routes them to a new uploadSymlink primitive that PUTs a
|
|
2296
2432
|
// zero-byte object with x-amz-meta-hq-symlink-target carrying the
|
|
@@ -2334,7 +2470,7 @@ describe("share", () => {
|
|
|
2334
2470
|
const realPolicy = path.join(policiesDir, "real.md");
|
|
2335
2471
|
fs.writeFileSync(realPolicy, "real content");
|
|
2336
2472
|
const linkPolicy = path.join(policiesDir, "link.md");
|
|
2337
|
-
// Mirrors the
|
|
2473
|
+
// Mirrors the reindex.sh overlay shape: relative target pointing
|
|
2338
2474
|
// to a sibling in the same dir.
|
|
2339
2475
|
fs.symlinkSync("real.md", linkPolicy);
|
|
2340
2476
|
|
package/src/cli/share.ts
CHANGED
|
@@ -538,7 +538,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
538
538
|
// `propagateDeletePolicy: "currency-gated"` (explicit) or
|
|
539
539
|
// `HQ_SYNC_DELETE_POLICY=currency-gated` (env, honored by sync-runner).
|
|
540
540
|
// The default flip to `"currency-gated"` is scheduled for 5.25.0.
|
|
541
|
-
|
|
541
|
+
let propagateDeletePolicy: "currency-gated" | "owned-only" | "all" =
|
|
542
542
|
options.propagateDeletePolicy ?? "owned-only";
|
|
543
543
|
const emit = options.onEvent ?? defaultConsoleLogger;
|
|
544
544
|
|
|
@@ -581,6 +581,28 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
581
581
|
let ctx: EntityContext = entityContext
|
|
582
582
|
? entityContext
|
|
583
583
|
: await resolveEntityContext(companyRef, vaultConfig!);
|
|
584
|
+
|
|
585
|
+
// Personal-vault policy correction (6.0.1). The `owned-only` rule encodes a
|
|
586
|
+
// multi-user curation premise — "don't tombstone peer-uploaded content even
|
|
587
|
+
// if my journal says I pulled it" — which is meaningful when several humans
|
|
588
|
+
// share a company bucket (a behind machine's first sync must not erase
|
|
589
|
+
// recent uploads from peers). On a personal vault that premise collapses:
|
|
590
|
+
// every file is the same human's content, just routed through different
|
|
591
|
+
// machines, and `direction: "down"` only means "uploaded from my laptop,
|
|
592
|
+
// pulled by my EC2" — it never means "uploaded by someone else." With
|
|
593
|
+
// `owned-only` in effect, `rm <file>` followed by `hq sync` silently fails
|
|
594
|
+
// to propagate the delete, leaving permanent vault litter (the May-27
|
|
595
|
+
// `personal/.obsidian/*.drift-*` files were diagnosed exactly this way).
|
|
596
|
+
// The etag-based `currency-gated` policy already captures the only safety
|
|
597
|
+
// intent that survives the single-user case ("don't tombstone if remote
|
|
598
|
+
// drifted since I last synced"); coerce to it here so the policy is right
|
|
599
|
+
// regardless of which caller's default landed. Explicit `"all"` is
|
|
600
|
+
// preserved — it's the emergency-reconcile opt-out and the caller has
|
|
601
|
+
// already asserted intent.
|
|
602
|
+
if (ctx.uid.startsWith("prs_") && propagateDeletePolicy === "owned-only") {
|
|
603
|
+
propagateDeletePolicy = "currency-gated";
|
|
604
|
+
}
|
|
605
|
+
|
|
584
606
|
// Remote keys are company-relative; the on-disk scoping prefix is
|
|
585
607
|
// companies/{slug}/. Anything outside this folder gets skipped to avoid
|
|
586
608
|
// leaking cross-company state into the vault.
|