@indigoai-us/hq-cli 5.115.4 → 5.115.6
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/CHANGELOG.md +49 -0
- package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +102 -5
- package/dist/commands/core-checkpoint.js +38 -22
- package/dist/lib/doctor/checks/sync-health.d.ts +19 -0
- package/dist/lib/doctor/checks/sync-health.js +55 -2
- package/dist/lib/doctor/fix/apply.d.ts +43 -6
- package/dist/lib/doctor/fix/apply.js +116 -18
- package/dist/lib/doctor/fix/remediation.d.ts +8 -3
- package/dist/lib/doctor/fix/remediation.js +21 -2
- package/dist/lib/scan-packages/index.js +158 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,55 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.115.6] — 2026-09-16
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `hq doctor --fix` can now refresh a company sync journal that is stale or
|
|
10
|
+
has never synced, when that company still has a folder under `companies/`.
|
|
11
|
+
It pulls that company only, keeps your local copy if there is a conflict,
|
|
12
|
+
and does not push. Company journals left over from memberships you do not
|
|
13
|
+
keep locally are reported as unused rather than broken, so a session-start
|
|
14
|
+
health check no longer files "could not repair" reports for folders that
|
|
15
|
+
were never on this machine. Personal journals are unchanged: they still
|
|
16
|
+
warn when stale, and `--fix` will not pull your personal vault on its own.
|
|
17
|
+
The repair pass also skips the integrations inventory, which it cannot
|
|
18
|
+
repair, so a slow connection check cannot stall the fix.
|
|
19
|
+
|
|
20
|
+
- HQ's housekeeping pass now clears out add-on shortcuts that lead nowhere,
|
|
21
|
+
instead of warning about them forever. A pack that stops shipping one of its
|
|
22
|
+
files, or a pack whose folder was installed from a scratch directory someone
|
|
23
|
+
later deleted, used to leave shortcuts behind that nothing would ever remove.
|
|
24
|
+
HQ now clears a shortcut only when it sits in a folder the add-on system
|
|
25
|
+
manages, only when it points into the add-on folder, and only when the thing
|
|
26
|
+
it points at is definitely not there. A shortcut it cannot check, one leading
|
|
27
|
+
somewhere else, or a real folder with real files in it are all left alone. On
|
|
28
|
+
one real setup this cleared eight dead shortcuts that had been warning on
|
|
29
|
+
every run.
|
|
30
|
+
|
|
31
|
+
- Add-on packs now repair their own shortcuts after your HQ folder moves to a
|
|
32
|
+
new machine. Those shortcuts point at a full path, so on a new computer they
|
|
33
|
+
all point somewhere that does not exist, and HQ used to leave them alone on
|
|
34
|
+
the grounds that something was already there. That made the breakage
|
|
35
|
+
permanent: every later tidy-up skipped them for the same reason. HQ now
|
|
36
|
+
repoints a shortcut that leads nowhere, while still refusing to touch one
|
|
37
|
+
that leads to real content. Repairing a tree this way fixed a batch of
|
|
38
|
+
long-broken pack shortcuts in one pass.
|
|
39
|
+
|
|
40
|
+
## [5.115.5] — 2026-09-15
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- The setting that turns the end-of-turn checkpoint requirement off,
|
|
45
|
+
`HQ_CHECKPOINT_GATE`, now works from `.claude/settings.json` on every runtime.
|
|
46
|
+
Before, only Claude Code passed that setting through to the checkpoint hook,
|
|
47
|
+
so if you work in Codex or Grok, writing it in the settings file did nothing
|
|
48
|
+
and there was no sign of why. hq now reads the file itself. As with the other
|
|
49
|
+
checkpoint settings, `settings.local.json` overrides it, anything you set in
|
|
50
|
+
your terminal overrides both, and `false` or `0` written as plain JSON works
|
|
51
|
+
as well as `"0"`. If the value is something the setting does not understand,
|
|
52
|
+
hq names it and leaves the requirement on rather than guessing.
|
|
53
|
+
|
|
5
54
|
## [5.115.4] — 2026-09-15
|
|
6
55
|
|
|
7
56
|
### Changed
|
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
|
|
6
6
|
set -uo pipefail
|
|
7
7
|
|
|
8
|
+
# The main block ends with `} 2>/dev/null`: a Stop hook must not spray
|
|
9
|
+
# diagnostics into a session. Keep one copy of the real stderr so a
|
|
10
|
+
# misconfigured operator switch — the one failure the operator alone can fix,
|
|
11
|
+
# and otherwise invisible — still has somewhere to be said.
|
|
12
|
+
exec 3>&2
|
|
13
|
+
|
|
8
14
|
{
|
|
9
15
|
self_hq="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../.." 2>/dev/null && pwd)"
|
|
10
16
|
HQ="${CLAUDE_PROJECT_DIR:-${HQ_ROOT:-$self_hq}}"
|
|
@@ -49,6 +55,88 @@ set -uo pipefail
|
|
|
49
55
|
return 0
|
|
50
56
|
}
|
|
51
57
|
|
|
58
|
+
# checkpoint_gate_trim — strips only the edges. Deleting interior whitespace
|
|
59
|
+
# would quietly repair a typo: "f alse" would become a valid `false` and
|
|
60
|
+
# disable the gate the operator was trying to keep on.
|
|
61
|
+
checkpoint_gate_trim() {
|
|
62
|
+
local s="$1"
|
|
63
|
+
s="${s#"${s%%[![:space:]]*}"}"
|
|
64
|
+
s="${s%"${s##*[![:space:]]}"}"
|
|
65
|
+
printf '%s' "$s"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# checkpoint_gate_setting — one operator switch, resolved the way hq-cli
|
|
69
|
+
# resolves it: the process environment first, then the HQ root's
|
|
70
|
+
# `.claude/settings.json` `env` block (overlaid by `settings.local.json`, the
|
|
71
|
+
# order Claude Code merges them).
|
|
72
|
+
#
|
|
73
|
+
# Reading the file matters because only Claude Code exports that block into
|
|
74
|
+
# hook processes. Under Codex or grok the variable is never set, so an
|
|
75
|
+
# operator who wrote the switch into settings.json would have had it silently
|
|
76
|
+
# ignored on exactly the runtimes where they cannot see why. Fail-open: any
|
|
77
|
+
# missing file, absent jq, or unreadable value leaves the gate at its default.
|
|
78
|
+
#
|
|
79
|
+
# Prints the raw value and returns 0 when one resolves. Returns 1 when the
|
|
80
|
+
# highest-priority file that mentions the key holds something unreadable — a
|
|
81
|
+
# present key stops the lookup rather than falling through, so a stale `"0"`
|
|
82
|
+
# further down cannot be promoted by a typo in the file above it. Same
|
|
83
|
+
# precedence the TypeScript resolver uses.
|
|
84
|
+
checkpoint_gate_setting() {
|
|
85
|
+
local key="$1" value="" file raw
|
|
86
|
+
eval "value=\${$key:-}"
|
|
87
|
+
value="$(checkpoint_gate_trim "$value")"
|
|
88
|
+
if [ -n "$value" ]; then
|
|
89
|
+
printf '%s' "$value"
|
|
90
|
+
return 0
|
|
91
|
+
fi
|
|
92
|
+
command -v jq >/dev/null 2>&1 || return 0
|
|
93
|
+
for file in "$HQ/.claude/settings.local.json" "$HQ/.claude/settings.json"; do
|
|
94
|
+
[ -r "$file" ] || continue
|
|
95
|
+
# Strings, and JSON's own true/false/0/1 — the literal an operator reaches
|
|
96
|
+
# for first — all resolve; null, an object, or an array does not. The
|
|
97
|
+
# leading marker separates "absent" (no output) from "present but
|
|
98
|
+
# unreadable" (`-`), which a bare value could not encode.
|
|
99
|
+
raw="$(jq -r --arg k "$key" '
|
|
100
|
+
try (
|
|
101
|
+
if (.env | type) == "object" and (.env | has($k))
|
|
102
|
+
then (.env[$k]
|
|
103
|
+
| if type == "string" or type == "boolean" or type == "number"
|
|
104
|
+
then "+" + tostring
|
|
105
|
+
else "-" end)
|
|
106
|
+
else empty end
|
|
107
|
+
) catch empty' "$file" 2>/dev/null || true)"
|
|
108
|
+
case "$raw" in
|
|
109
|
+
"") continue ;;
|
|
110
|
+
-*) ;;
|
|
111
|
+
+*)
|
|
112
|
+
value="$(checkpoint_gate_trim "${raw#+}")"
|
|
113
|
+
if [ -n "$value" ]; then
|
|
114
|
+
printf '%s' "$value"
|
|
115
|
+
return 0
|
|
116
|
+
fi
|
|
117
|
+
;;
|
|
118
|
+
esac
|
|
119
|
+
printf 'checkpoint-stop-gate: ignoring %s in %s (not a value it can read; expected a string such as "0" or "1")\n' \
|
|
120
|
+
"$key" "$file" >&3 2>/dev/null
|
|
121
|
+
return 1
|
|
122
|
+
done
|
|
123
|
+
return 0
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# Normalizes one switch to 0, 1, or empty (= not set). An unreadable value is
|
|
127
|
+
# named on stderr rather than passed over: the operator asked for something.
|
|
128
|
+
checkpoint_gate_switch() {
|
|
129
|
+
local key="$1" raw
|
|
130
|
+
raw="$(checkpoint_gate_setting "$key" 2>/dev/null)" || return 0
|
|
131
|
+
[ -n "$raw" ] || return 0
|
|
132
|
+
case "$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')" in
|
|
133
|
+
0|false|off|no) printf '0' ;;
|
|
134
|
+
1|true|on|yes) printf '1' ;;
|
|
135
|
+
*) printf 'checkpoint-stop-gate: ignoring %s=%s (expected 0/1, false/true, off/on or no/yes)\n' "$key" "$raw" >&3 2>/dev/null ;;
|
|
136
|
+
esac
|
|
137
|
+
return 0
|
|
138
|
+
}
|
|
139
|
+
|
|
52
140
|
input="$(cat 2>/dev/null || printf '{}')"
|
|
53
141
|
input_fields="$(printf '%s' "$input" | jq -r '[.transcript_path // "", .session_id // ""] | @tsv' 2>/dev/null)" || exit 0
|
|
54
142
|
IFS=$'\t' read -r transcript_path session_id <<<"$input_fields" || exit 0
|
|
@@ -57,6 +145,15 @@ set -uo pipefail
|
|
|
57
145
|
# recursively demand another checkpoint of itself.
|
|
58
146
|
[ "${HQ_CHECKPOINT_SIBLING:-}" = "1" ] && exit 0
|
|
59
147
|
|
|
148
|
+
# Resolved once: every branch below reads this instead of the raw variable.
|
|
149
|
+
gate_switch="$(checkpoint_gate_switch HQ_CHECKPOINT_GATE)"
|
|
150
|
+
# That was the only diagnostic, so drop the duplicate before anything is
|
|
151
|
+
# spawned. The detached `nohup ... &` commands below redirect fds 1 and 2
|
|
152
|
+
# only: a surviving fd 3 would be inherited by them and hold the hook
|
|
153
|
+
# runner's stderr pipe open, so a runner that reads stderr to EOF would wait
|
|
154
|
+
# on the supposedly detached checkpoint instead of on this shell.
|
|
155
|
+
exec 3>&-
|
|
156
|
+
|
|
60
157
|
# Company-scope gate ---------------------------------------------------------
|
|
61
158
|
# An OPT-IN requirement layered onto this Stop hook: for an operator whose
|
|
62
159
|
# email domain is listed in HQ_CHECKPOINT_SCOPE_GATE_DOMAINS (comma-separated,
|
|
@@ -69,7 +166,7 @@ set -uo pipefail
|
|
|
69
166
|
# alongside the checkpoint gate. Binding is a single recoverable call, so the
|
|
70
167
|
# block loops only until the operator declares a scope.
|
|
71
168
|
gate_domains="${HQ_CHECKPOINT_SCOPE_GATE_DOMAINS:-}"
|
|
72
|
-
case "$
|
|
169
|
+
case "$gate_switch" in
|
|
73
170
|
0) ;;
|
|
74
171
|
*)
|
|
75
172
|
gate_session_id="$(printf '%s' "$input" | jq -r '.session_id // ""' 2>/dev/null || true)"
|
|
@@ -120,7 +217,7 @@ set -uo pipefail
|
|
|
120
217
|
*) [ -d "$gate_root/companies/$bound_co" ] && gate_bound=1 ;;
|
|
121
218
|
esac
|
|
122
219
|
if [ "$gate_bound" = 0 ]; then
|
|
123
|
-
company_reason="$(printf 'This session has not declared its scope, and a scope is required before the turn can end. Decide where this work belongs, then bind the session and finish the turn with your user-facing reply as the final text after the command output:\n\n bash %s/core/scripts/hq-session.sh --session-id %s set company_slug <company-slug>\n\nUse a real tenant slug from companies/manifest.yaml — the company this session is actually working in, never an invented one. If this session does no company-scoped work, bind it to the reserved personal scope instead:\n\n bash %s/core/scripts/hq-session.sh --session-id %s set company_slug personal\n\nAn operator can also disable this requirement for the run by exporting HQ_CHECKPOINT_GATE=0.' "$gate_root" "$gate_session_id" "$gate_root" "$gate_session_id")"
|
|
220
|
+
company_reason="$(printf 'This session has not declared its scope, and a scope is required before the turn can end. Decide where this work belongs, then bind the session and finish the turn with your user-facing reply as the final text after the command output:\n\n bash %s/core/scripts/hq-session.sh --session-id %s set company_slug <company-slug>\n\nUse a real tenant slug from companies/manifest.yaml — the company this session is actually working in, never an invented one. If this session does no company-scoped work, bind it to the reserved personal scope instead:\n\n bash %s/core/scripts/hq-session.sh --session-id %s set company_slug personal\n\nAn operator can also disable this requirement for the run by exporting HQ_CHECKPOINT_GATE=0, or for every runtime by putting \"env\": {\"HQ_CHECKPOINT_GATE\": \"0\"} in .claude/settings.json.' "$gate_root" "$gate_session_id" "$gate_root" "$gate_session_id")"
|
|
124
221
|
printf '{"decision":"block","reason":%s}\n' "$(printf '%s' "$company_reason" | hq_json_encode)"
|
|
125
222
|
exit 0
|
|
126
223
|
fi
|
|
@@ -133,7 +230,7 @@ set -uo pipefail
|
|
|
133
230
|
|
|
134
231
|
# Operator switches precede runtime and identity eligibility. A forced gate
|
|
135
232
|
# intentionally bypasses the rollout rules for local trials and tests.
|
|
136
|
-
case "$
|
|
233
|
+
case "$gate_switch" in
|
|
137
234
|
0) exit 0 ;;
|
|
138
235
|
1) enforce=true ;;
|
|
139
236
|
*)
|
|
@@ -151,7 +248,7 @@ set -uo pipefail
|
|
|
151
248
|
|
|
152
249
|
refresh_eligibility() {
|
|
153
250
|
command -v hq >/dev/null 2>&1 || return 0
|
|
154
|
-
(nohup env HQ_CHECKPOINT_RUNTIME="$runtime" hq core checkpoint --gate-probe >/dev/null 2>&1 &)
|
|
251
|
+
(nohup env HQ_CHECKPOINT_RUNTIME="$runtime" hq core checkpoint --gate-probe >/dev/null 2>&1 3>&- &)
|
|
155
252
|
}
|
|
156
253
|
|
|
157
254
|
if [ "$runtime" = "codex" ] && [ "$enforce" = false ]; then
|
|
@@ -511,7 +608,7 @@ set -uo pipefail
|
|
|
511
608
|
--trigger stop-gate-auto \
|
|
512
609
|
--transcript "$transcript_path" \
|
|
513
610
|
--summary "turn ended with its user-facing reply already delivered" \
|
|
514
|
-
>/dev/null 2>&1 &)
|
|
611
|
+
>/dev/null 2>&1 3>&- &)
|
|
515
612
|
rm -f "$block_count_file" 2>/dev/null || true
|
|
516
613
|
exit 0
|
|
517
614
|
fi
|
|
@@ -49,6 +49,12 @@ const GROK_SIBLING_MAX_TURNS = "100";
|
|
|
49
49
|
* hosts never read it.
|
|
50
50
|
*/
|
|
51
51
|
const SIBLING_SETTING = "HQ_CHECKPOINT_AGENT";
|
|
52
|
+
/**
|
|
53
|
+
* The Stop gate's own switch. Resolved through the same settings file as the
|
|
54
|
+
* sibling switch so it reaches Codex and grok, whose hosts never export the
|
|
55
|
+
* `.claude/settings.json` `env` block into a hook process.
|
|
56
|
+
*/
|
|
57
|
+
const GATE_SETTING = "HQ_CHECKPOINT_GATE";
|
|
52
58
|
const SIBLING_SETTING_OFF = new Set(["0", "false", "off", "no"]);
|
|
53
59
|
const SIBLING_SETTING_ON = new Set(["1", "true", "on", "yes"]);
|
|
54
60
|
const FALLBACK_BACKEND_ORDER = ["claude", "codex", "grok"];
|
|
@@ -445,12 +451,13 @@ function checkpointGateRuntime() {
|
|
|
445
451
|
return runtime;
|
|
446
452
|
return "other";
|
|
447
453
|
}
|
|
448
|
-
function gateEligibility(runtime) {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
+
function gateEligibility(runtime, liveRoot) {
|
|
455
|
+
// An operator switch precedes runtime and identity eligibility, and is read
|
|
456
|
+
// from the settings file as well as the environment: under Codex and grok the
|
|
457
|
+
// environment is the one place the operator cannot reach.
|
|
458
|
+
const forced = resolveSwitchSetting(liveRoot, GATE_SETTING);
|
|
459
|
+
if (forced !== undefined)
|
|
460
|
+
return forced;
|
|
454
461
|
if (runtime === "claude")
|
|
455
462
|
return true;
|
|
456
463
|
if (runtime !== "codex")
|
|
@@ -472,7 +479,7 @@ function writeGateVerdict(liveRoot) {
|
|
|
472
479
|
const stateDir = path.join(liveRoot, "workspace", "orchestrator", "hook-state");
|
|
473
480
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
474
481
|
const runtime = checkpointGateRuntime();
|
|
475
|
-
const eligible = gateEligibility(runtime);
|
|
482
|
+
const eligible = gateEligibility(runtime, liveRoot);
|
|
476
483
|
fs.writeFileSync(path.join(stateDir, `checkpoint-gate-eligible-${runtime}`), eligible ? "1" : "0");
|
|
477
484
|
printResult(eligible ? "eligible" : "ineligible");
|
|
478
485
|
}
|
|
@@ -637,36 +644,45 @@ function settingsEnvValue(liveRoot, key) {
|
|
|
637
644
|
return { kind: "absent" };
|
|
638
645
|
}
|
|
639
646
|
/** Maps one resolved value onto the switch, reporting a vocabulary it cannot read. */
|
|
640
|
-
function
|
|
647
|
+
function parseSwitchSetting(key, raw, source) {
|
|
641
648
|
const value = raw.toLowerCase();
|
|
642
649
|
if (SIBLING_SETTING_OFF.has(value))
|
|
643
650
|
return false;
|
|
644
651
|
if (SIBLING_SETTING_ON.has(value))
|
|
645
652
|
return true;
|
|
646
|
-
printError(`checkpoint: ignoring ${
|
|
653
|
+
printError(`checkpoint: ignoring ${key}=${raw} from ${source} ` +
|
|
647
654
|
"(expected 0/1, false/true, off/on or no/yes)");
|
|
648
|
-
return
|
|
655
|
+
return undefined;
|
|
649
656
|
}
|
|
650
657
|
/**
|
|
651
|
-
*
|
|
652
|
-
*
|
|
653
|
-
*
|
|
654
|
-
*
|
|
658
|
+
* One operator switch: the process environment first, then the HQ root's
|
|
659
|
+
* Claude settings. `undefined` means nobody set it — distinct from a setting
|
|
660
|
+
* that says "off", and distinct from one this cannot read (reported, then
|
|
661
|
+
* treated as unset so a typo never silently flips a default).
|
|
655
662
|
*/
|
|
656
|
-
|
|
657
|
-
const fromEnv = process.env[
|
|
663
|
+
function resolveSwitchSetting(liveRoot, key) {
|
|
664
|
+
const fromEnv = process.env[key]?.trim();
|
|
658
665
|
if (fromEnv)
|
|
659
|
-
return
|
|
660
|
-
const lookup = settingsEnvValue(liveRoot,
|
|
666
|
+
return parseSwitchSetting(key, fromEnv, "the environment");
|
|
667
|
+
const lookup = settingsEnvValue(liveRoot, key);
|
|
661
668
|
if (lookup.kind === "absent")
|
|
662
|
-
return
|
|
669
|
+
return undefined;
|
|
663
670
|
if (lookup.kind === "invalid") {
|
|
664
|
-
printError(`checkpoint: ignoring ${
|
|
671
|
+
printError(`checkpoint: ignoring ${key} in ${lookup.source}: ` +
|
|
665
672
|
`${JSON.stringify(lookup.raw) ?? String(lookup.raw)} is not a value it can read ` +
|
|
666
673
|
'(expected a string such as "0" or "1")');
|
|
667
|
-
return
|
|
674
|
+
return undefined;
|
|
668
675
|
}
|
|
669
|
-
return
|
|
676
|
+
return parseSwitchSetting(key, lookup.value, lookup.source);
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Whether the maintenance sibling may run at all. The process environment wins
|
|
680
|
+
* over the settings file, so a one-off `HQ_CHECKPOINT_AGENT=0 hq core
|
|
681
|
+
* checkpoint …` still works and a host that exports the settings `env` block
|
|
682
|
+
* (Claude Code does) agrees with a host that does not.
|
|
683
|
+
*/
|
|
684
|
+
export function siblingEnabledBySetting(liveRoot) {
|
|
685
|
+
return resolveSwitchSetting(liveRoot, SIBLING_SETTING) ?? true;
|
|
670
686
|
}
|
|
671
687
|
/** Rejects an unknown `--backend` without paying for the probe that resolves one. */
|
|
672
688
|
function assertKnownBackend(requested) {
|
|
@@ -91,7 +91,26 @@ export interface SyncHealthDeps {
|
|
|
91
91
|
* scope that is fine.
|
|
92
92
|
*/
|
|
93
93
|
unresolvedScopes?: (journals: readonly SyncJournalSummary[]) => readonly string[];
|
|
94
|
+
/**
|
|
95
|
+
* Whether a company slug has a local `companies/<slug>` directory in the HQ
|
|
96
|
+
* tree. Leftover journal shards for companies this machine does not keep
|
|
97
|
+
* locally are NA, not WARN — a journal without a tree is not a corroborated
|
|
98
|
+
* unhealthy-sync signal, and `hq doctor --fix` has nothing local to refresh.
|
|
99
|
+
*/
|
|
100
|
+
localCompanyExists?: (slug: string) => boolean;
|
|
94
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* True when `slug` is a filesystem-safe company folder name. Rejects empty
|
|
104
|
+
* values, path separators, and `..` so a journal slug cannot be used to probe
|
|
105
|
+
* outside `companies/`.
|
|
106
|
+
*/
|
|
107
|
+
export declare function isSafeCompanySlug(slug: string): boolean;
|
|
108
|
+
/** True when `hqRoot/companies/<slug>` exists as a directory. Never throws. */
|
|
109
|
+
export declare function defaultLocalCompanyExists(hqRoot: string, slug: string): boolean;
|
|
110
|
+
/** True when this journal slug names a company rather than the personal tree. */
|
|
111
|
+
export declare function isCompanyJournalSlug(slug: string): boolean;
|
|
112
|
+
/** Company slug from a `sync.journal.<slug>` check id, or null if not one. */
|
|
113
|
+
export declare function journalSlugFromCheckId(checkId: string): string | null;
|
|
95
114
|
/** The versions/sync check family. Registered in `createDefaultRegistry`. */
|
|
96
115
|
export declare const syncHealthFamily: CheckFamily;
|
|
97
116
|
/** Run every versions/sync check. A thrown check degrades to UNKNOWN. */
|
|
@@ -47,14 +47,35 @@ export const STALE_JOURNAL_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
47
47
|
/** The offline update cache written by the check-hq-update SessionStart hook. */
|
|
48
48
|
export const UPDATE_CACHE_RELPATH = path.join("workspace", ".hq-update-check", "last-check.json");
|
|
49
49
|
/** Fill in the optional dependencies so call sites never branch on undefined. */
|
|
50
|
-
function withDefaults(deps) {
|
|
50
|
+
function withDefaults(deps, hqRoot) {
|
|
51
51
|
return {
|
|
52
52
|
...deps,
|
|
53
53
|
manifestStatuses: deps.manifestStatuses ?? defaultManifestStatuses,
|
|
54
54
|
manifestAvailable: deps.manifestAvailable ?? (() => loadManifestExports() !== null),
|
|
55
55
|
unresolvedScopes: deps.unresolvedScopes ?? defaultUnresolvedScopes,
|
|
56
|
+
localCompanyExists: deps.localCompanyExists ??
|
|
57
|
+
((slug) => defaultLocalCompanyExists(hqRoot, slug)),
|
|
56
58
|
};
|
|
57
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* True when `slug` is a filesystem-safe company folder name. Rejects empty
|
|
62
|
+
* values, path separators, and `..` so a journal slug cannot be used to probe
|
|
63
|
+
* outside `companies/`.
|
|
64
|
+
*/
|
|
65
|
+
export function isSafeCompanySlug(slug) {
|
|
66
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(slug);
|
|
67
|
+
}
|
|
68
|
+
/** True when `hqRoot/companies/<slug>` exists as a directory. Never throws. */
|
|
69
|
+
export function defaultLocalCompanyExists(hqRoot, slug) {
|
|
70
|
+
if (!isSafeCompanySlug(slug))
|
|
71
|
+
return false;
|
|
72
|
+
try {
|
|
73
|
+
return fs.statSync(path.join(hqRoot, "companies", slug)).isDirectory();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
58
79
|
/**
|
|
59
80
|
* Company journal shards are unresolvable by the default collector: the
|
|
60
81
|
* snapshot store keys company scopes by `companyUid`, which `listJournals()`
|
|
@@ -146,6 +167,15 @@ const NON_COMPANY_JOURNAL_SLUGS = new Set([
|
|
|
146
167
|
PERSONAL_SCOPE_SLUG,
|
|
147
168
|
PERSONAL_VAULT_SCOPE_SLUG,
|
|
148
169
|
]);
|
|
170
|
+
/** True when this journal slug names a company rather than the personal tree. */
|
|
171
|
+
export function isCompanyJournalSlug(slug) {
|
|
172
|
+
return !NON_COMPANY_JOURNAL_SLUGS.has(slug);
|
|
173
|
+
}
|
|
174
|
+
/** Company slug from a `sync.journal.<slug>` check id, or null if not one. */
|
|
175
|
+
export function journalSlugFromCheckId(checkId) {
|
|
176
|
+
const match = /^sync\.journal\.(.+)$/.exec(checkId);
|
|
177
|
+
return match ? match[1] : null;
|
|
178
|
+
}
|
|
149
179
|
/** The versions/sync check family. Registered in `createDefaultRegistry`. */
|
|
150
180
|
export const syncHealthFamily = {
|
|
151
181
|
id: SYNC_FAMILY_ID,
|
|
@@ -154,7 +184,7 @@ export const syncHealthFamily = {
|
|
|
154
184
|
};
|
|
155
185
|
/** Run every versions/sync check. A thrown check degrades to UNKNOWN. */
|
|
156
186
|
export function checkSyncHealth(context, rawDeps = DEFAULT_DEPS) {
|
|
157
|
-
const deps = withDefaults(rawDeps);
|
|
187
|
+
const deps = withDefaults(rawDeps, context.hqRoot);
|
|
158
188
|
try {
|
|
159
189
|
return [
|
|
160
190
|
...versionResults(context, deps),
|
|
@@ -325,6 +355,29 @@ function journalResults(deps, journals) {
|
|
|
325
355
|
return journals.map((entry) => {
|
|
326
356
|
const lastSync = entry.journal?.lastSync;
|
|
327
357
|
const checkId = `sync.journal.${entry.slug}`;
|
|
358
|
+
// A company journal without a local tree is leftover membership state,
|
|
359
|
+
// not a live sync target. WARN would make `hq doctor --fix` look like it
|
|
360
|
+
// failed to repair something it was never going to touch (US-015 health
|
|
361
|
+
// hook files remaining FAIL/WARN after the safe repair pass).
|
|
362
|
+
if (isCompanyJournalSlug(entry.slug)) {
|
|
363
|
+
let local;
|
|
364
|
+
try {
|
|
365
|
+
local = deps.localCompanyExists(entry.slug);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
// Existence probe failed: fall through to the staleness check rather
|
|
369
|
+
// than hiding a possibly-live company behind NA.
|
|
370
|
+
local = true;
|
|
371
|
+
}
|
|
372
|
+
if (!local) {
|
|
373
|
+
return {
|
|
374
|
+
status: "NA",
|
|
375
|
+
checkId,
|
|
376
|
+
target: entry.path,
|
|
377
|
+
message: `Sync journal '${entry.slug}' is unused on this machine — there is no companies/${entry.slug} directory, so this journal is not a live sync target.`,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
}
|
|
328
381
|
if (typeof lastSync !== "string" || lastSync.length === 0) {
|
|
329
382
|
return {
|
|
330
383
|
status: "WARN",
|
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
* `.claude/`, `.codex/`, or `.grok/`, `--fix` refuses and exits non-zero
|
|
9
9
|
* unless `--force`, so a repair can never be tangled up with unrelated
|
|
10
10
|
* in-flight edits to the security layer.
|
|
11
|
-
* 2. Allowlist only. It repairs
|
|
12
|
-
* bit, add a hook id to the gate profiles it is missing from,
|
|
13
|
-
* script present on disk
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* 2. Allowlist only. It repairs the classified safe classes — restore an
|
|
12
|
+
* execute bit, add a hook id to the gate profiles it is missing from,
|
|
13
|
+
* re-register a script present on disk, or pull a company whose local
|
|
14
|
+
* tree exists but whose journal is stale — and NEVER rewrites a hook body
|
|
15
|
+
* or deletes a file. Classification is owned by {@link deriveRemediation};
|
|
16
|
+
* a content-drift finding is manual-only and simply never appears in the
|
|
17
|
+
* fixable set.
|
|
16
18
|
* 3. Preview + confirmation. Every change is shown diff-style and requires
|
|
17
19
|
* confirmation, with `--yes` for non-interactive use.
|
|
18
20
|
* 4. Backup first. Before any write, the affected files are copied under
|
|
@@ -26,7 +28,7 @@
|
|
|
26
28
|
* (the command resolves it once), keeping this module decoupled from the CLI and
|
|
27
29
|
* trivially testable against a fake tree.
|
|
28
30
|
*/
|
|
29
|
-
import type { DoctorStatus } from "../types.js";
|
|
31
|
+
import type { CheckResult, DoctorStatus } from "../types.js";
|
|
30
32
|
import { type GateProfile } from "../hook-gate-profiles.js";
|
|
31
33
|
import { type FixClass } from "./remediation.js";
|
|
32
34
|
/** The tree subtrees whose uncommitted changes block a `--fix` run. */
|
|
@@ -56,7 +58,35 @@ export interface ApplyFixesOptions {
|
|
|
56
58
|
* [] when clean or not a git repo. Default shells out to `git status`.
|
|
57
59
|
*/
|
|
58
60
|
dirtyCheck?: (hqRoot: string) => string[];
|
|
61
|
+
/**
|
|
62
|
+
* Check runner used to collect findings and to re-verify after applying.
|
|
63
|
+
* Default runs the real registry. Tests inject a fake list so refresh-sync
|
|
64
|
+
* does not depend on the machine's live journal store.
|
|
65
|
+
*/
|
|
66
|
+
runChecks?: (hqRoot: string) => Promise<CheckResult[]>;
|
|
67
|
+
/**
|
|
68
|
+
* Targeted pull used by the `refresh-sync` class. Default shells out to
|
|
69
|
+
* `hq sync pull --company <slug> --on-conflict keep`. Tests inject a stub
|
|
70
|
+
* so `--fix` never hits the network.
|
|
71
|
+
*/
|
|
72
|
+
refreshSync?: (target: RefreshSyncTarget) => Promise<RefreshSyncResult>;
|
|
59
73
|
}
|
|
74
|
+
/** One company the `refresh-sync` class will pull. */
|
|
75
|
+
export interface RefreshSyncTarget {
|
|
76
|
+
/** Company slug from the `sync.journal.<slug>` check id. */
|
|
77
|
+
slug: string;
|
|
78
|
+
/** HQ root the pull writes into. */
|
|
79
|
+
hqRoot: string;
|
|
80
|
+
}
|
|
81
|
+
/** Outcome of one {@link ApplyFixesOptions.refreshSync} call. */
|
|
82
|
+
export interface RefreshSyncResult {
|
|
83
|
+
/** Whether the pull exited 0. */
|
|
84
|
+
ok: boolean;
|
|
85
|
+
/** One-line summary for the post-fix report. */
|
|
86
|
+
message: string;
|
|
87
|
+
}
|
|
88
|
+
/** Per-company bound for the default `hq sync pull` repair. */
|
|
89
|
+
export declare const REFRESH_SYNC_TIMEOUT_MS = 90000;
|
|
60
90
|
/** One applied (or attempted) repair, with its post-fix re-check status. */
|
|
61
91
|
export interface AppliedFix {
|
|
62
92
|
/** The check id of the finding that was repaired. */
|
|
@@ -94,6 +124,13 @@ export interface ApplyFixesResult {
|
|
|
94
124
|
* assert on it.
|
|
95
125
|
*/
|
|
96
126
|
export declare function applyFixes(options: ApplyFixesOptions): Promise<ApplyFixesResult>;
|
|
127
|
+
/**
|
|
128
|
+
* Default `refresh-sync` repair: a bounded, pull-only, keep-conflicts
|
|
129
|
+
* `hq sync pull --company <slug>` against the tree `--fix` is repairing.
|
|
130
|
+
* Never a push, never interactive. A timeout or non-zero exit is reported
|
|
131
|
+
* rather than thrown so later plans still run.
|
|
132
|
+
*/
|
|
133
|
+
export declare function defaultRefreshSync(target: RefreshSyncTarget): Promise<RefreshSyncResult>;
|
|
97
134
|
/**
|
|
98
135
|
* The uncommitted changes under {@link HOOK_CONFIG_DIRS}, one porcelain line
|
|
99
136
|
* each. Returns [] when the tree is clean OR when `hqRoot` is not a git repo
|
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
* `.claude/`, `.codex/`, or `.grok/`, `--fix` refuses and exits non-zero
|
|
9
9
|
* unless `--force`, so a repair can never be tangled up with unrelated
|
|
10
10
|
* in-flight edits to the security layer.
|
|
11
|
-
* 2. Allowlist only. It repairs
|
|
12
|
-
* bit, add a hook id to the gate profiles it is missing from,
|
|
13
|
-
* script present on disk
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* 2. Allowlist only. It repairs the classified safe classes — restore an
|
|
12
|
+
* execute bit, add a hook id to the gate profiles it is missing from,
|
|
13
|
+
* re-register a script present on disk, or pull a company whose local
|
|
14
|
+
* tree exists but whose journal is stale — and NEVER rewrites a hook body
|
|
15
|
+
* or deletes a file. Classification is owned by {@link deriveRemediation};
|
|
16
|
+
* a content-drift finding is manual-only and simply never appears in the
|
|
17
|
+
* fixable set.
|
|
16
18
|
* 3. Preview + confirmation. Every change is shown diff-style and requires
|
|
17
19
|
* confirmation, with `--yes` for non-interactive use.
|
|
18
20
|
* 4. Backup first. Before any write, the affected files are copied under
|
|
@@ -36,6 +38,8 @@ import { createBackup } from "./backup.js";
|
|
|
36
38
|
import { deriveRemediation } from "./remediation.js";
|
|
37
39
|
/** The tree subtrees whose uncommitted changes block a `--fix` run. */
|
|
38
40
|
export const HOOK_CONFIG_DIRS = [".claude", ".codex", ".grok"];
|
|
41
|
+
/** Per-company bound for the default `hq sync pull` repair. */
|
|
42
|
+
export const REFRESH_SYNC_TIMEOUT_MS = 90_000;
|
|
39
43
|
/**
|
|
40
44
|
* Apply every auto-fixable finding, honouring the dirty-tree refusal, the
|
|
41
45
|
* preview/confirmation gate, the pre-write backup, and the post-fix re-check.
|
|
@@ -68,8 +72,10 @@ export async function applyFixes(options) {
|
|
|
68
72
|
return empty({ exitCode: 1, refused: "dirty-tree" });
|
|
69
73
|
}
|
|
70
74
|
}
|
|
75
|
+
const collect = options.runChecks ?? defaultRunChecks;
|
|
76
|
+
const refreshSync = options.refreshSync ?? defaultRefreshSync;
|
|
71
77
|
// 2. Collect findings and keep only the allowlisted auto-fixable ones.
|
|
72
|
-
const findings = await
|
|
78
|
+
const findings = await collect(hqRoot);
|
|
73
79
|
const fixable = [];
|
|
74
80
|
for (const result of findings) {
|
|
75
81
|
const rem = deriveRemediation(result);
|
|
@@ -81,7 +87,7 @@ export async function applyFixes(options) {
|
|
|
81
87
|
return empty();
|
|
82
88
|
}
|
|
83
89
|
const plans = fixable
|
|
84
|
-
.map(({ result, rem }) => planFix(hqRoot, result, rem))
|
|
90
|
+
.map(({ result, rem }) => planFix(hqRoot, result, rem, refreshSync))
|
|
85
91
|
.filter((plan) => plan !== null);
|
|
86
92
|
if (plans.length === 0) {
|
|
87
93
|
write("hq doctor --fix: nothing to repair — no auto-fixable findings.\n");
|
|
@@ -99,16 +105,20 @@ export async function applyFixes(options) {
|
|
|
99
105
|
}
|
|
100
106
|
}
|
|
101
107
|
// 4. Back up every file about to change BEFORE the first write (AC6).
|
|
102
|
-
|
|
103
|
-
|
|
108
|
+
// refresh-sync plans have no hook file; skip the backup pass when nothing
|
|
109
|
+
// in the tree is about to be copied.
|
|
110
|
+
const affected = unique(plans.map((plan) => plan.relpath).filter((rel) => rel.length > 0));
|
|
111
|
+
const backup = affected.length > 0 ? createBackup(hqRoot, affected, options.now) : null;
|
|
104
112
|
// 5. Apply. Each plan reads the current on-disk state, so multiple plans that
|
|
105
113
|
// touch the same file (two gate ids) compose correctly.
|
|
106
114
|
for (const plan of plans)
|
|
107
|
-
plan.apply();
|
|
108
|
-
|
|
109
|
-
|
|
115
|
+
await plan.apply();
|
|
116
|
+
if (backup) {
|
|
117
|
+
write(`\nBacked up ${backup.files.length} file${backup.files.length === 1 ? "" : "s"} to ${backup.dir}\n`);
|
|
118
|
+
write(`To restore: ${backup.restoreCommand}\n`);
|
|
119
|
+
}
|
|
110
120
|
// 6. Re-run the checks and report the post-fix status of each repair (AC7).
|
|
111
|
-
const after = await
|
|
121
|
+
const after = await collect(hqRoot);
|
|
112
122
|
const applied = plans.map((plan) => ({
|
|
113
123
|
checkId: plan.checkId,
|
|
114
124
|
fixClass: plan.fixClass,
|
|
@@ -125,17 +135,83 @@ export async function applyFixes(options) {
|
|
|
125
135
|
wrote: true,
|
|
126
136
|
refused: null,
|
|
127
137
|
fixableCount: fixable.length,
|
|
128
|
-
backupDir: backup
|
|
129
|
-
restoreCommand: backup
|
|
138
|
+
backupDir: backup?.dir ?? null,
|
|
139
|
+
restoreCommand: backup?.restoreCommand ?? null,
|
|
130
140
|
applied,
|
|
131
141
|
};
|
|
132
142
|
}
|
|
133
143
|
/** Run the default (read-only) check registry and flatten to a result list. */
|
|
134
|
-
async function
|
|
135
|
-
|
|
144
|
+
async function defaultRunChecks(hqRoot) {
|
|
145
|
+
// `--fix` never repairs the integrations family (networked inventory), so
|
|
146
|
+
// skip it: a hung control-plane read must not stall the safe repair pass.
|
|
147
|
+
const context = {
|
|
148
|
+
hqRoot,
|
|
149
|
+
platform: { id: "unknown" },
|
|
150
|
+
integrations: false,
|
|
151
|
+
};
|
|
136
152
|
const families = await createDefaultRegistry().run(context);
|
|
137
153
|
return flattenFamilies(families);
|
|
138
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Default `refresh-sync` repair: a bounded, pull-only, keep-conflicts
|
|
157
|
+
* `hq sync pull --company <slug>` against the tree `--fix` is repairing.
|
|
158
|
+
* Never a push, never interactive. A timeout or non-zero exit is reported
|
|
159
|
+
* rather than thrown so later plans still run.
|
|
160
|
+
*/
|
|
161
|
+
export function defaultRefreshSync(target) {
|
|
162
|
+
const hqBin = process.argv[1];
|
|
163
|
+
const args = hqBin
|
|
164
|
+
? [
|
|
165
|
+
hqBin,
|
|
166
|
+
"sync",
|
|
167
|
+
"pull",
|
|
168
|
+
"--company",
|
|
169
|
+
target.slug,
|
|
170
|
+
"--hq-root",
|
|
171
|
+
target.hqRoot,
|
|
172
|
+
"--on-conflict",
|
|
173
|
+
"keep",
|
|
174
|
+
"--lock-timeout",
|
|
175
|
+
"30",
|
|
176
|
+
]
|
|
177
|
+
: [
|
|
178
|
+
"sync",
|
|
179
|
+
"pull",
|
|
180
|
+
"--company",
|
|
181
|
+
target.slug,
|
|
182
|
+
"--hq-root",
|
|
183
|
+
target.hqRoot,
|
|
184
|
+
"--on-conflict",
|
|
185
|
+
"keep",
|
|
186
|
+
"--lock-timeout",
|
|
187
|
+
"30",
|
|
188
|
+
];
|
|
189
|
+
const result = spawnSync(hqBin ? process.execPath : "hq", args, {
|
|
190
|
+
encoding: "utf8",
|
|
191
|
+
timeout: REFRESH_SYNC_TIMEOUT_MS,
|
|
192
|
+
cwd: target.hqRoot,
|
|
193
|
+
});
|
|
194
|
+
if (result.error) {
|
|
195
|
+
const timedOut = result.error.message.includes("ETIMEDOUT") || result.signal === "SIGTERM";
|
|
196
|
+
return Promise.resolve({
|
|
197
|
+
ok: false,
|
|
198
|
+
message: timedOut
|
|
199
|
+
? `timed out pulling '${target.slug}'`
|
|
200
|
+
: `could not start pull for '${target.slug}': ${result.error.message}`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
if (result.status !== 0) {
|
|
204
|
+
const detail = (result.stderr || result.stdout || "").trim().split("\n").at(-1) ?? "";
|
|
205
|
+
return Promise.resolve({
|
|
206
|
+
ok: false,
|
|
207
|
+
message: `pull of '${target.slug}' exited ${result.status}${detail ? `: ${detail}` : ""}`,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return Promise.resolve({
|
|
211
|
+
ok: true,
|
|
212
|
+
message: `pulled '${target.slug}'`,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
139
215
|
// --- Dirty-tree probe ----------------------------------------------------------
|
|
140
216
|
/**
|
|
141
217
|
* The uncommitted changes under {@link HOOK_CONFIG_DIRS}, one porcelain line
|
|
@@ -155,7 +231,7 @@ export function uncommittedHookConfigChanges(hqRoot) {
|
|
|
155
231
|
}
|
|
156
232
|
// --- Fix planning --------------------------------------------------------------
|
|
157
233
|
/** Build the concrete plan for one auto-fixable finding, or null if unplannable. */
|
|
158
|
-
function planFix(hqRoot, result, rem) {
|
|
234
|
+
function planFix(hqRoot, result, rem, refreshSync) {
|
|
159
235
|
switch (rem.fixClass) {
|
|
160
236
|
case "executable-bit":
|
|
161
237
|
return planExecutableBit(hqRoot, result, rem);
|
|
@@ -163,10 +239,32 @@ function planFix(hqRoot, result, rem) {
|
|
|
163
239
|
return planGateProfile(hqRoot, result, rem);
|
|
164
240
|
case "register-hook":
|
|
165
241
|
return planRegisterHook(hqRoot, result, rem);
|
|
242
|
+
case "refresh-sync":
|
|
243
|
+
return planRefreshSync(hqRoot, result, rem, refreshSync);
|
|
166
244
|
default:
|
|
167
245
|
return null;
|
|
168
246
|
}
|
|
169
247
|
}
|
|
248
|
+
/** Pull one company to refresh a stale or never-synced journal. */
|
|
249
|
+
function planRefreshSync(hqRoot, result, rem, refreshSync) {
|
|
250
|
+
const slug = rem.fixTarget;
|
|
251
|
+
if (!slug)
|
|
252
|
+
return null;
|
|
253
|
+
return {
|
|
254
|
+
checkId: result.checkId,
|
|
255
|
+
fixClass: "refresh-sync",
|
|
256
|
+
target: slug,
|
|
257
|
+
relpath: "",
|
|
258
|
+
preview: ` companies/${slug}\n` +
|
|
259
|
+
` ~ hq sync pull --company ${slug} --on-conflict keep\n` +
|
|
260
|
+
` refresh the stale sync journal (keeps local conflict copies)`,
|
|
261
|
+
summary: `pull ${slug} to refresh its sync journal`,
|
|
262
|
+
apply: async () => {
|
|
263
|
+
await refreshSync({ slug, hqRoot });
|
|
264
|
+
},
|
|
265
|
+
postStatus: (results) => results.find((entry) => entry.checkId === result.checkId)?.status ?? null,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
170
268
|
/** Restore the execute bit on a hook script. */
|
|
171
269
|
function planExecutableBit(hqRoot, result, rem) {
|
|
172
270
|
const abs = rem.fixTarget;
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* classes, and if so exactly which file or hook id to act on. This module is the
|
|
8
8
|
* single classifier that turns a {@link CheckResult} into that structured shape.
|
|
9
9
|
*
|
|
10
|
-
* The
|
|
11
|
-
*
|
|
10
|
+
* The auto-fixable classes are a deliberate, security-reviewed allowlist
|
|
11
|
+
* (see the PRD decision record):
|
|
12
12
|
*
|
|
13
13
|
* - `executable-bit` — restore the execute bit on a hook script that exists
|
|
14
14
|
* but lost `+x`. Detected by the shared `chmod +x …`
|
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
* `…gate-profiles` FAIL from the Claude wiring tier.
|
|
20
20
|
* - `register-hook` — re-register a script that is present on disk but wired
|
|
21
21
|
* nowhere. Detected by the `…orphan` WARN.
|
|
22
|
+
* - `refresh-sync` — run a targeted pull for a company journal that is
|
|
23
|
+
* stale or never-synced AND has a local
|
|
24
|
+
* `companies/<slug>` tree. Pull-only, `--on-conflict
|
|
25
|
+
* keep`, no hook-file rewrite. Personal journals and
|
|
26
|
+
* leftover shards without a local tree stay manual.
|
|
22
27
|
*
|
|
23
28
|
* EVERYTHING else — content drift between platform copies, a missing script, a
|
|
24
29
|
* missing Codex counterpart, an unquoted `$CLAUDE_PROJECT_DIR`, a stale
|
|
@@ -30,7 +35,7 @@
|
|
|
30
35
|
*/
|
|
31
36
|
import type { CheckResult } from "../types.js";
|
|
32
37
|
/** The allowlisted safe repair classes `--fix` is permitted to apply. */
|
|
33
|
-
export type FixClass = "executable-bit" | "gate-profile" | "register-hook";
|
|
38
|
+
export type FixClass = "executable-bit" | "gate-profile" | "register-hook" | "refresh-sync";
|
|
34
39
|
/**
|
|
35
40
|
* A finding's structured remediation. `autoFixable`, `action`, and `command`
|
|
36
41
|
* are the `--json` surface (AC1); `fixTarget`/`fixClass` are the internal handle
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* classes, and if so exactly which file or hook id to act on. This module is the
|
|
8
8
|
* single classifier that turns a {@link CheckResult} into that structured shape.
|
|
9
9
|
*
|
|
10
|
-
* The
|
|
11
|
-
*
|
|
10
|
+
* The auto-fixable classes are a deliberate, security-reviewed allowlist
|
|
11
|
+
* (see the PRD decision record):
|
|
12
12
|
*
|
|
13
13
|
* - `executable-bit` — restore the execute bit on a hook script that exists
|
|
14
14
|
* but lost `+x`. Detected by the shared `chmod +x …`
|
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
* `…gate-profiles` FAIL from the Claude wiring tier.
|
|
20
20
|
* - `register-hook` — re-register a script that is present on disk but wired
|
|
21
21
|
* nowhere. Detected by the `…orphan` WARN.
|
|
22
|
+
* - `refresh-sync` — run a targeted pull for a company journal that is
|
|
23
|
+
* stale or never-synced AND has a local
|
|
24
|
+
* `companies/<slug>` tree. Pull-only, `--on-conflict
|
|
25
|
+
* keep`, no hook-file rewrite. Personal journals and
|
|
26
|
+
* leftover shards without a local tree stay manual.
|
|
22
27
|
*
|
|
23
28
|
* EVERYTHING else — content drift between platform copies, a missing script, a
|
|
24
29
|
* missing Codex counterpart, an unquoted `$CLAUDE_PROJECT_DIR`, a stale
|
|
@@ -28,6 +33,7 @@
|
|
|
28
33
|
* content-drift finding is manual-only, because that boundary is load-bearing:
|
|
29
34
|
* deciding which of two diverged copies is correct needs human judgement.
|
|
30
35
|
*/
|
|
36
|
+
import { isCompanyJournalSlug, journalSlugFromCheckId, } from "../checks/sync-health.js";
|
|
31
37
|
/** Matches an exec-bit remediation command, quoted or not: `chmod +x <path>`. */
|
|
32
38
|
const CHMOD_EXEC = /^chmod\s+\+x\s+(.+)$/;
|
|
33
39
|
/**
|
|
@@ -90,6 +96,19 @@ export function deriveRemediation(result) {
|
|
|
90
96
|
command: remediation ?? `Register ${target} in .claude/settings.json.`,
|
|
91
97
|
};
|
|
92
98
|
}
|
|
99
|
+
// refresh-sync — a company journal that is stale or never-synced. Only
|
|
100
|
+
// company slugs: personal shards have no companies/<slug> tree and a
|
|
101
|
+
// SessionStart `--fix --yes` must not pull the personal vault unprompted.
|
|
102
|
+
const journalSlug = journalSlugFromCheckId(checkId);
|
|
103
|
+
if (journalSlug && isCompanyJournalSlug(journalSlug)) {
|
|
104
|
+
return {
|
|
105
|
+
autoFixable: true,
|
|
106
|
+
fixClass: "refresh-sync",
|
|
107
|
+
fixTarget: journalSlug,
|
|
108
|
+
action: `Pull company '${journalSlug}' to refresh its sync journal (keeps local conflict copies).`,
|
|
109
|
+
command: `hq sync pull --company ${journalSlug} --on-conflict keep`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
93
112
|
// Manual-only: content drift, missing scripts/counterparts, unquoted
|
|
94
113
|
// expansions, stale allowed-divergence entries, invalid settings, etc. `--fix`
|
|
95
114
|
// never touches these.
|
|
@@ -3,6 +3,7 @@ import * as fs from 'node:fs';
|
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import * as yaml from 'js-yaml';
|
|
5
5
|
import { contributionLinks } from '../../utils/pack-contributions.js';
|
|
6
|
+
import { CONTRIBUTION_TABLE } from '../../utils/contribution-table.js';
|
|
6
7
|
function isDirectory(target) { try {
|
|
7
8
|
return fs.statSync(target).isDirectory();
|
|
8
9
|
}
|
|
@@ -140,6 +141,23 @@ function workerIdClashes(hqRoot, source) {
|
|
|
140
141
|
}
|
|
141
142
|
return undefined;
|
|
142
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* What a symlink's target resolves to: `present`, `absent`, or the errno of a
|
|
146
|
+
* lookup that answered neither. Only ENOENT (nothing there) and ENOTDIR (a
|
|
147
|
+
* path component is not a directory, so the target cannot exist) mean absent.
|
|
148
|
+
* EACCES, ELOOP and friends mean the question went unanswered, which is not a
|
|
149
|
+
* licence to destroy what the link points at.
|
|
150
|
+
*/
|
|
151
|
+
function targetLookup(target) {
|
|
152
|
+
try {
|
|
153
|
+
fs.statSync(target);
|
|
154
|
+
return 'present';
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
const code = error.code;
|
|
158
|
+
return code === 'ENOENT' || code === 'ENOTDIR' ? 'absent' : code ?? 'unknown error';
|
|
159
|
+
}
|
|
160
|
+
}
|
|
143
161
|
function ensureSymlink(link, info, warn) {
|
|
144
162
|
if (!existsOrSymlink(link.src)) {
|
|
145
163
|
warn(`payload missing: ${link.src} (declared but not shipped)`);
|
|
@@ -152,6 +170,26 @@ function ensureSymlink(link, info, warn) {
|
|
|
152
170
|
const existing = fs.readlinkSync(link.dst);
|
|
153
171
|
if (existing === link.src)
|
|
154
172
|
return;
|
|
173
|
+
// "Host content wins" protects content. A symlink that does not resolve
|
|
174
|
+
// is not content — and it is the shape every pack link takes when a tree
|
|
175
|
+
// moves between machines, because these links are absolute and the old
|
|
176
|
+
// HQ root does not exist on the new host. Skipping it would make that
|
|
177
|
+
// state permanent: stale on this run, and on every run after it.
|
|
178
|
+
//
|
|
179
|
+
// Only a lookup that specifically says "not there" earns a relink.
|
|
180
|
+
// fs.existsSync also answers false for EACCES, which would read content
|
|
181
|
+
// this user merely cannot traverse as absent and delete the link to it.
|
|
182
|
+
const lookup = targetLookup(link.dst);
|
|
183
|
+
if (lookup === 'absent') {
|
|
184
|
+
fs.rmSync(link.dst, { force: true });
|
|
185
|
+
fs.symlinkSync(link.src, link.dst);
|
|
186
|
+
info(`relinked ${link.dst} -> ${link.src} (was ${existing}, which does not exist here)`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (lookup !== 'present') {
|
|
190
|
+
warn(`collision: ${link.dst} points at ${existing}, which could not be checked (${lookup}) — leaving it alone`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
155
193
|
warn(`collision: ${link.dst} already points at ${existing} (wanted ${link.src}) — skipping`);
|
|
156
194
|
return;
|
|
157
195
|
}
|
|
@@ -165,6 +203,102 @@ function ensureSymlink(link, info, warn) {
|
|
|
165
203
|
fs.symlinkSync(link.src, link.dst);
|
|
166
204
|
info(`linked ${link.dst} -> ${link.src}`);
|
|
167
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* The link text, when `entry` is a symlink whose target is definitively not
|
|
208
|
+
* there; `undefined` for anything else — a real file or directory, a link that
|
|
209
|
+
* resolves, or a lookup that failed for a reason other than absence.
|
|
210
|
+
*/
|
|
211
|
+
function danglingLinkTarget(entry) {
|
|
212
|
+
let link;
|
|
213
|
+
try {
|
|
214
|
+
if (!fs.lstatSync(entry).isSymbolicLink())
|
|
215
|
+
return undefined;
|
|
216
|
+
link = fs.readlinkSync(entry);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
return targetLookup(entry) === 'absent' ? link : undefined;
|
|
222
|
+
}
|
|
223
|
+
/** Host directories the table routes symlinks into, HQ-root relative. */
|
|
224
|
+
function symlinkHostRoots() {
|
|
225
|
+
const roots = new Set();
|
|
226
|
+
for (const row of Object.values(CONTRIBUTION_TABLE)) {
|
|
227
|
+
if (row.wire === 'symlink')
|
|
228
|
+
roots.add(row.host);
|
|
229
|
+
}
|
|
230
|
+
return [...roots].sort();
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Remove host symlinks that point into `core/packages/` at a payload that is
|
|
234
|
+
* no longer there.
|
|
235
|
+
*
|
|
236
|
+
* Wiring only ever adds. A contribution a pack stops shipping, or a pack whose
|
|
237
|
+
* directory is itself a link into a deleted worktree, leaves its host links
|
|
238
|
+
* pointing at nothing, and nothing removed them — so they accumulated and
|
|
239
|
+
* warned on every run forever.
|
|
240
|
+
*
|
|
241
|
+
* Scoped three ways so this only reaps what the pack system wired:
|
|
242
|
+
* - only inside the host roots the contribution table declares,
|
|
243
|
+
* - only symlinks whose target resolves inside `core/packages/`,
|
|
244
|
+
* - only when the lookup specifically says the target is absent. EACCES and
|
|
245
|
+
* friends mean the question went unanswered, which is not a licence to
|
|
246
|
+
* delete (same discipline as the relink path).
|
|
247
|
+
*
|
|
248
|
+
* A symlinked directory is classified, never descended into: a Dirent reports
|
|
249
|
+
* it as a symlink rather than a directory, so the walk cannot wander out of
|
|
250
|
+
* the host roots through one.
|
|
251
|
+
*/
|
|
252
|
+
function reapOrphanedLinks(hqRoot, info, warn) {
|
|
253
|
+
const packagesRoot = path.join(hqRoot, 'core/packages') + path.sep;
|
|
254
|
+
for (const hostRelative of symlinkHostRoots()) {
|
|
255
|
+
const stack = [path.join(hqRoot, hostRelative)];
|
|
256
|
+
while (stack.length > 0) {
|
|
257
|
+
const current = stack.pop();
|
|
258
|
+
let entries;
|
|
259
|
+
try {
|
|
260
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
|
|
266
|
+
for (const entry of entries) {
|
|
267
|
+
const absolute = path.join(current, entry.name);
|
|
268
|
+
if (entry.isDirectory()) {
|
|
269
|
+
stack.push(absolute);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (!entry.isSymbolicLink())
|
|
273
|
+
continue;
|
|
274
|
+
let target;
|
|
275
|
+
try {
|
|
276
|
+
target = fs.readlinkSync(absolute);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const resolved = path.resolve(path.dirname(absolute), target);
|
|
282
|
+
if (!resolved.startsWith(packagesRoot))
|
|
283
|
+
continue;
|
|
284
|
+
const lookup = targetLookup(absolute);
|
|
285
|
+
if (lookup === 'present')
|
|
286
|
+
continue;
|
|
287
|
+
if (lookup !== 'absent') {
|
|
288
|
+
warn(`${absolute} points into core/packages at ${resolved}, which could not be checked (${lookup}) — leaving it alone`);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
fs.rmSync(absolute, { force: true });
|
|
293
|
+
info(`unwired ${absolute} (its pack payload ${resolved} is gone)`);
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
warn(`could not unwire ${absolute} (${error.message})`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
168
302
|
/** Wire installed pack contributions into their table-declared host locations. */
|
|
169
303
|
export function scanPackages(hqRoot, options = {}) {
|
|
170
304
|
const log = options.log ?? ((message) => process.stdout.write(`${message}\n`));
|
|
@@ -174,6 +308,9 @@ export function scanPackages(hqRoot, options = {}) {
|
|
|
174
308
|
const warn = (message) => warnSink(` [warn] ${message}`);
|
|
175
309
|
const packages = path.join(hqRoot, 'core/packages');
|
|
176
310
|
if (!isDirectory(packages)) {
|
|
311
|
+
// Still sweep: a packages directory that is gone entirely is exactly when
|
|
312
|
+
// every link into it is an orphan.
|
|
313
|
+
reapOrphanedLinks(hqRoot, info, warn);
|
|
177
314
|
info('[scan-packages] no core/packages/ dir; nothing to wire');
|
|
178
315
|
return { status: 0 };
|
|
179
316
|
}
|
|
@@ -190,7 +327,24 @@ export function scanPackages(hqRoot, options = {}) {
|
|
|
190
327
|
for (const name of packageNames) {
|
|
191
328
|
const packDir = path.join(packages, name);
|
|
192
329
|
const manifest = path.join(packDir, 'package.yaml');
|
|
193
|
-
if (!isDirectory(packDir)
|
|
330
|
+
if (!isDirectory(packDir)) {
|
|
331
|
+
// A dangling entry in core/packages/ is not a pack and cannot become
|
|
332
|
+
// one — typically an install from a worktree that was later removed.
|
|
333
|
+
// Only a symlink to nothing qualifies: a real directory holds real
|
|
334
|
+
// files whatever its manifest situation, and is left alone.
|
|
335
|
+
const danglingTarget = danglingLinkTarget(packDir);
|
|
336
|
+
if (danglingTarget !== undefined) {
|
|
337
|
+
try {
|
|
338
|
+
fs.rmSync(packDir, { force: true });
|
|
339
|
+
info(`[scan-packages] removed ${name}: its payload is gone (was a link to ${danglingTarget})`);
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
warn(`could not remove the dangling pack entry ${packDir} (${error.message})`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (!fs.existsSync(manifest))
|
|
194
348
|
continue;
|
|
195
349
|
any = true;
|
|
196
350
|
info(`[scan-packages] wiring ${name}`);
|
|
@@ -211,6 +365,9 @@ export function scanPackages(hqRoot, options = {}) {
|
|
|
211
365
|
warnSink(`[scan-packages] error: ${error.message}`);
|
|
212
366
|
return { status: 1 };
|
|
213
367
|
}
|
|
368
|
+
// After wiring, so anything just created or repointed is present and only
|
|
369
|
+
// genuine orphans are left to reap.
|
|
370
|
+
reapOrphanedLinks(hqRoot, info, warn);
|
|
214
371
|
if (!any)
|
|
215
372
|
info('[scan-packages] no hq-pack manifests found in core/packages');
|
|
216
373
|
return { status: 0 };
|