@indigoai-us/hq-cli 5.345.94 → 5.345.96
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 +10 -0
- package/dist/lib/doctor/fix/apply.d.ts +5 -3
- package/dist/lib/doctor/fix/apply.js +19 -32
- package/dist/lib/doctor/fix/refresh-sync-progress.d.ts +32 -0
- package/dist/lib/doctor/fix/refresh-sync-progress.js +109 -0
- package/dist/lib/doctor/json-output.d.ts +4 -1
- package/dist/lib/doctor/json-output.js +8 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.345.96] — 2026-10-12
|
|
6
|
+
- `hq doctor --json --client-health-report` no longer lists company journals
|
|
7
|
+
that this repair pass did not try to refresh. Session-start health stops
|
|
8
|
+
filing those leftover rows as unrepaired.
|
|
9
|
+
|
|
10
|
+
## [5.345.95] — 2026-10-12
|
|
11
|
+
- `hq doctor --fix` still refreshes a stale personal-files journal and a
|
|
12
|
+
company journal in the same pass when hook files have uncommitted changes.
|
|
13
|
+
Session-start health no longer treats that pair as unrepaired.
|
|
14
|
+
|
|
5
15
|
## [5.345.94] — 2026-10-12
|
|
6
16
|
- Local bots now turn `@Name` in a group-room post into a real mention, so the addressed bot receives that post in its inbox. A room post that does not mention another bot still does not wake that bot.
|
|
7
17
|
|
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
* `.claude/`, `.codex/`, or `.grok/`, `--fix` refuses hook-file repairs
|
|
9
9
|
* and exits non-zero unless `--force`, so those writes can never be
|
|
10
10
|
* tangled up with unrelated in-flight edits to the security layer.
|
|
11
|
-
* Repairs that do not write those trees (
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Repairs that do not write those trees (`refresh-sync` pulls for a
|
|
12
|
+
* stale company journal or the personal vault) still run: the
|
|
13
|
+
* SessionStart health hook never passes `--force`, and blocking those
|
|
14
|
+
* pulls left `sync.journal.<slug>` and
|
|
15
|
+
* `sync.journal.__hq_personal_vault__` WARN after `--fix`.
|
|
14
16
|
* 2. Allowlist only. It repairs the classified safe classes — restore an
|
|
15
17
|
* execute bit, add a hook id to the gate profiles it is missing from,
|
|
16
18
|
* re-register a script present on disk, or pull a company or personal
|
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
* `.claude/`, `.codex/`, or `.grok/`, `--fix` refuses hook-file repairs
|
|
9
9
|
* and exits non-zero unless `--force`, so those writes can never be
|
|
10
10
|
* tangled up with unrelated in-flight edits to the security layer.
|
|
11
|
-
* Repairs that do not write those trees (
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Repairs that do not write those trees (`refresh-sync` pulls for a
|
|
12
|
+
* stale company journal or the personal vault) still run: the
|
|
13
|
+
* SessionStart health hook never passes `--force`, and blocking those
|
|
14
|
+
* pulls left `sync.journal.<slug>` and
|
|
15
|
+
* `sync.journal.__hq_personal_vault__` WARN after `--fix`.
|
|
14
16
|
* 2. Allowlist only. It repairs the classified safe classes — restore an
|
|
15
17
|
* execute bit, add a hook id to the gate profiles it is missing from,
|
|
16
18
|
* re-register a script present on disk, or pull a company or personal
|
|
@@ -46,6 +48,7 @@ import { botDir } from "../../bot/paths.js";
|
|
|
46
48
|
import { readBotConfig } from "../../bot/config.js";
|
|
47
49
|
import { buildBotDaemonPaths, rewriteBotDaemonNodePath } from "../../bot/daemon.js";
|
|
48
50
|
import { classifyRefreshSyncOutcome, } from "./refresh-sync-outcome.js";
|
|
51
|
+
import { readRefreshSyncProgress, writeRefreshSyncCursor, writeRefreshSyncLastRun, } from "./refresh-sync-progress.js";
|
|
49
52
|
import { clientHealthShowManualOnlyEnabled } from "../client-health-reason-codes-flag.js";
|
|
50
53
|
/** The tree subtrees whose uncommitted changes block a `--fix` run. */
|
|
51
54
|
export const HOOK_CONFIG_DIRS = [".claude", ".codex", ".grok"];
|
|
@@ -55,33 +58,6 @@ export const REFRESH_SYNC_TIMEOUT_MS = 90_000;
|
|
|
55
58
|
export const MAX_FIX_PASSES = 3;
|
|
56
59
|
/** Two 90s pulls leave 120s for startup and check overhead under the hook's 300s ceiling. */
|
|
57
60
|
export const MAX_REFRESH_SYNC_PLANS_PER_RUN = 2;
|
|
58
|
-
const REFRESH_SYNC_PROGRESS_RELATIVE_PATH = path.join("workspace", "doctor", "refresh-sync-progress.json");
|
|
59
|
-
function readRefreshSyncProgress(hqRoot) {
|
|
60
|
-
let contents;
|
|
61
|
-
try {
|
|
62
|
-
contents = fs.readFileSync(path.join(hqRoot, REFRESH_SYNC_PROGRESS_RELATIVE_PATH), "utf8");
|
|
63
|
-
}
|
|
64
|
-
catch (error) {
|
|
65
|
-
if (error.code === "ENOENT")
|
|
66
|
-
return null;
|
|
67
|
-
throw error;
|
|
68
|
-
}
|
|
69
|
-
const parsed = JSON.parse(contents);
|
|
70
|
-
if (typeof parsed === "object" && parsed !== null &&
|
|
71
|
-
parsed.version === 1 &&
|
|
72
|
-
typeof parsed.lastAttemptedSlug === "string") {
|
|
73
|
-
return parsed;
|
|
74
|
-
}
|
|
75
|
-
throw new Error(`Invalid refresh-sync progress cursor at ${REFRESH_SYNC_PROGRESS_RELATIVE_PATH}`);
|
|
76
|
-
}
|
|
77
|
-
function writeRefreshSyncProgress(hqRoot, slug) {
|
|
78
|
-
const progressPath = path.join(hqRoot, REFRESH_SYNC_PROGRESS_RELATIVE_PATH);
|
|
79
|
-
const directory = path.dirname(progressPath);
|
|
80
|
-
fs.mkdirSync(directory, { recursive: true });
|
|
81
|
-
const temporaryPath = `${progressPath}.${process.pid}.tmp`;
|
|
82
|
-
fs.writeFileSync(temporaryPath, `${JSON.stringify({ version: 1, lastAttemptedSlug: slug })}\n`, { mode: 0o600 });
|
|
83
|
-
fs.renameSync(temporaryPath, progressPath);
|
|
84
|
-
}
|
|
85
61
|
function rotateRefreshSyncPlans(plans, afterSlug) {
|
|
86
62
|
const ordered = [...plans].sort((a, b) => a.target < b.target ? -1 : a.target > b.target ? 1 : 0);
|
|
87
63
|
if (!afterSlug || ordered.length < 2)
|
|
@@ -120,7 +96,10 @@ export async function applyFixes(options) {
|
|
|
120
96
|
const refuseDirtyHookWrites = (plans) => {
|
|
121
97
|
if (dirty.length === 0)
|
|
122
98
|
return plans;
|
|
123
|
-
|
|
99
|
+
// Journal refreshes never write hook files. Allow them by class, not
|
|
100
|
+
// only by empty relpath, so a personal-vault pull and a company pull
|
|
101
|
+
// still run when SessionStart `--fix --yes` cannot pass `--force`.
|
|
102
|
+
const allowed = plans.filter((plan) => plan.fixClass === "refresh-sync" || plan.relpath.length === 0);
|
|
124
103
|
if (allowed.length === plans.length)
|
|
125
104
|
return plans;
|
|
126
105
|
if (allowed.length === 0)
|
|
@@ -249,7 +228,7 @@ export async function applyFixes(options) {
|
|
|
249
228
|
// Persist before starting the bounded pull. If this process is killed,
|
|
250
229
|
// the next invocation resumes after this company instead of starving
|
|
251
230
|
// every later journal behind it.
|
|
252
|
-
|
|
231
|
+
writeRefreshSyncCursor(hqRoot, plan.target);
|
|
253
232
|
lastAttemptedRefreshSyncSlug = plan.target;
|
|
254
233
|
refreshSyncAppliedThisRun += 1;
|
|
255
234
|
}
|
|
@@ -279,6 +258,14 @@ export async function applyFixes(options) {
|
|
|
279
258
|
...(refreshSyncOutcome ? { refreshSyncOutcome } : {}),
|
|
280
259
|
};
|
|
281
260
|
});
|
|
261
|
+
const refreshSyncApplied = applied.filter((fix) => fix.fixClass === "refresh-sync");
|
|
262
|
+
if (refreshSyncApplied.length > 0) {
|
|
263
|
+
writeRefreshSyncLastRun(hqRoot, {
|
|
264
|
+
lastAttemptedSlug: lastAttemptedRefreshSyncSlug ?? refreshSyncApplied.at(-1).target,
|
|
265
|
+
lastRunAttemptedCheckIds: refreshSyncApplied.map((fix) => fix.checkId),
|
|
266
|
+
lastRunAtMs: backupClock().getTime(),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
282
269
|
write("\nPost-fix status:\n");
|
|
283
270
|
for (const fix of applied) {
|
|
284
271
|
write(` ${fix.postStatus ?? "UNKNOWN"} ${fix.checkId} [${fix.target}] (pass ${fix.pass})\n`);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisted `--fix` refresh-sync cursor.
|
|
3
|
+
*
|
|
4
|
+
* `lastAttemptedSlug` is written before each pull so a killed repair resumes
|
|
5
|
+
* after that company instead of starving later journals. `lastRunAttemptedCheckIds`
|
|
6
|
+
* is written only when a repair pass finishes, so `--client-health-report`
|
|
7
|
+
* can omit company journals this pass did not try — the SessionStart hook
|
|
8
|
+
* otherwise files those leftover rows as unrepaired.
|
|
9
|
+
*/
|
|
10
|
+
import type { CheckResult } from "../types.js";
|
|
11
|
+
export declare const REFRESH_SYNC_PROGRESS_RELATIVE_PATH: string;
|
|
12
|
+
/** Hook re-verify runs immediately after `--fix`; 15 minutes covers a slow box. */
|
|
13
|
+
export declare const CLIENT_HEALTH_REFRESH_SYNC_FRESH_MS: number;
|
|
14
|
+
export interface RefreshSyncProgress {
|
|
15
|
+
version: 1 | 2;
|
|
16
|
+
lastAttemptedSlug: string;
|
|
17
|
+
lastRunAttemptedCheckIds?: string[];
|
|
18
|
+
lastRunAtMs?: number;
|
|
19
|
+
}
|
|
20
|
+
export declare function readRefreshSyncProgress(hqRoot: string): RefreshSyncProgress | null;
|
|
21
|
+
/** Persist the resume cursor without claiming this run finished. */
|
|
22
|
+
export declare function writeRefreshSyncCursor(hqRoot: string, slug: string): void;
|
|
23
|
+
/** Record the check ids this completed `--fix` pass actually tried to refresh. */
|
|
24
|
+
export declare function writeRefreshSyncLastRun(hqRoot: string, input: {
|
|
25
|
+
lastAttemptedSlug: string;
|
|
26
|
+
lastRunAttemptedCheckIds: string[];
|
|
27
|
+
lastRunAtMs: number;
|
|
28
|
+
}): void;
|
|
29
|
+
export declare function freshAttemptedCompanyJournalCheckIds(hqRoot: string, nowMs: number): ReadonlySet<string> | null;
|
|
30
|
+
/** True when a client-health report should hide this leftover company journal. */
|
|
31
|
+
export declare function isUnreachedCompanyJournalForClientHealth(result: CheckResult, attemptedCheckIds: ReadonlySet<string> | null): boolean;
|
|
32
|
+
//# sourceMappingURL=refresh-sync-progress.d.ts.map
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisted `--fix` refresh-sync cursor.
|
|
3
|
+
*
|
|
4
|
+
* `lastAttemptedSlug` is written before each pull so a killed repair resumes
|
|
5
|
+
* after that company instead of starving later journals. `lastRunAttemptedCheckIds`
|
|
6
|
+
* is written only when a repair pass finishes, so `--client-health-report`
|
|
7
|
+
* can omit company journals this pass did not try — the SessionStart hook
|
|
8
|
+
* otherwise files those leftover rows as unrepaired.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { isCompanyJournalSlug, journalSlugFromCheckId, } from "../checks/sync-health.js";
|
|
13
|
+
import { deriveRemediation } from "./remediation.js";
|
|
14
|
+
export const REFRESH_SYNC_PROGRESS_RELATIVE_PATH = path.join("workspace", "doctor", "refresh-sync-progress.json");
|
|
15
|
+
/** Hook re-verify runs immediately after `--fix`; 15 minutes covers a slow box. */
|
|
16
|
+
export const CLIENT_HEALTH_REFRESH_SYNC_FRESH_MS = 15 * 60 * 1000;
|
|
17
|
+
function progressPath(hqRoot) {
|
|
18
|
+
return path.join(hqRoot, REFRESH_SYNC_PROGRESS_RELATIVE_PATH);
|
|
19
|
+
}
|
|
20
|
+
function isCheckIdList(value) {
|
|
21
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
22
|
+
}
|
|
23
|
+
export function readRefreshSyncProgress(hqRoot) {
|
|
24
|
+
let contents;
|
|
25
|
+
try {
|
|
26
|
+
contents = fs.readFileSync(progressPath(hqRoot), "utf8");
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error.code === "ENOENT")
|
|
30
|
+
return null;
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
const parsed = JSON.parse(contents);
|
|
34
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
35
|
+
throw new Error(`Invalid refresh-sync progress cursor at ${REFRESH_SYNC_PROGRESS_RELATIVE_PATH}`);
|
|
36
|
+
}
|
|
37
|
+
const record = parsed;
|
|
38
|
+
if ((record.version !== 1 && record.version !== 2) ||
|
|
39
|
+
typeof record.lastAttemptedSlug !== "string") {
|
|
40
|
+
throw new Error(`Invalid refresh-sync progress cursor at ${REFRESH_SYNC_PROGRESS_RELATIVE_PATH}`);
|
|
41
|
+
}
|
|
42
|
+
if (record.lastRunAttemptedCheckIds !== undefined &&
|
|
43
|
+
!isCheckIdList(record.lastRunAttemptedCheckIds)) {
|
|
44
|
+
throw new Error(`Invalid refresh-sync progress cursor at ${REFRESH_SYNC_PROGRESS_RELATIVE_PATH}`);
|
|
45
|
+
}
|
|
46
|
+
if (record.lastRunAtMs !== undefined && !Number.isFinite(record.lastRunAtMs)) {
|
|
47
|
+
throw new Error(`Invalid refresh-sync progress cursor at ${REFRESH_SYNC_PROGRESS_RELATIVE_PATH}`);
|
|
48
|
+
}
|
|
49
|
+
return record;
|
|
50
|
+
}
|
|
51
|
+
function writeProgressFile(hqRoot, progress) {
|
|
52
|
+
const target = progressPath(hqRoot);
|
|
53
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
54
|
+
const temporaryPath = `${target}.${process.pid}.tmp`;
|
|
55
|
+
fs.writeFileSync(temporaryPath, `${JSON.stringify(progress)}\n`, { mode: 0o600 });
|
|
56
|
+
fs.renameSync(temporaryPath, target);
|
|
57
|
+
}
|
|
58
|
+
/** Persist the resume cursor without claiming this run finished. */
|
|
59
|
+
export function writeRefreshSyncCursor(hqRoot, slug) {
|
|
60
|
+
const existing = readRefreshSyncProgress(hqRoot);
|
|
61
|
+
writeProgressFile(hqRoot, {
|
|
62
|
+
version: 2,
|
|
63
|
+
lastAttemptedSlug: slug,
|
|
64
|
+
...(existing?.lastRunAttemptedCheckIds
|
|
65
|
+
? { lastRunAttemptedCheckIds: existing.lastRunAttemptedCheckIds }
|
|
66
|
+
: {}),
|
|
67
|
+
...(existing?.lastRunAtMs !== undefined ? { lastRunAtMs: existing.lastRunAtMs } : {}),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** Record the check ids this completed `--fix` pass actually tried to refresh. */
|
|
71
|
+
export function writeRefreshSyncLastRun(hqRoot, input) {
|
|
72
|
+
writeProgressFile(hqRoot, {
|
|
73
|
+
version: 2,
|
|
74
|
+
lastAttemptedSlug: input.lastAttemptedSlug,
|
|
75
|
+
lastRunAttemptedCheckIds: input.lastRunAttemptedCheckIds,
|
|
76
|
+
lastRunAtMs: input.lastRunAtMs,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
export function freshAttemptedCompanyJournalCheckIds(hqRoot, nowMs) {
|
|
80
|
+
let progress;
|
|
81
|
+
try {
|
|
82
|
+
progress = readRefreshSyncProgress(hqRoot);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
if (!progress ||
|
|
88
|
+
progress.lastRunAtMs === undefined ||
|
|
89
|
+
!isCheckIdList(progress.lastRunAttemptedCheckIds) ||
|
|
90
|
+
nowMs - progress.lastRunAtMs < 0 ||
|
|
91
|
+
nowMs - progress.lastRunAtMs > CLIENT_HEALTH_REFRESH_SYNC_FRESH_MS) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
return new Set(progress.lastRunAttemptedCheckIds);
|
|
95
|
+
}
|
|
96
|
+
/** True when a client-health report should hide this leftover company journal. */
|
|
97
|
+
export function isUnreachedCompanyJournalForClientHealth(result, attemptedCheckIds) {
|
|
98
|
+
if (!attemptedCheckIds)
|
|
99
|
+
return false;
|
|
100
|
+
if (result.status !== "FAIL" && result.status !== "WARN")
|
|
101
|
+
return false;
|
|
102
|
+
const slug = journalSlugFromCheckId(result.checkId);
|
|
103
|
+
if (!slug || !isCompanyJournalSlug(slug))
|
|
104
|
+
return false;
|
|
105
|
+
if (attemptedCheckIds.has(result.checkId))
|
|
106
|
+
return false;
|
|
107
|
+
return deriveRemediation(result)?.autoFixable === true;
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=refresh-sync-progress.js.map
|
|
@@ -109,9 +109,12 @@ export interface BuildDoctorJsonInput {
|
|
|
109
109
|
* Ordinary hq-core upgrade WARNs stay in human `hq doctor` output and in
|
|
110
110
|
* default `--json`, but they are omitted here: `--fix` cannot apply a
|
|
111
111
|
* release, and older hooks treated the leftover WARN as an unrepaired
|
|
112
|
-
* failure.
|
|
112
|
+
* failure. Company journals this `--fix` pass did not try to refresh are
|
|
113
|
+
* omitted the same way: the repair cap leaves them for a later session.
|
|
113
114
|
*/
|
|
114
115
|
clientHealthReport?: boolean;
|
|
116
|
+
/** Clock for the leftover-journal freshness window. Default: `Date.now`. */
|
|
117
|
+
now?: () => Date;
|
|
115
118
|
}
|
|
116
119
|
/**
|
|
117
120
|
* True when a sync finding is an ordinary, non-repairable hq-core upgrade.
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { computeExitCode, summarize, UNKNOWN_PLATFORM, } from "./report.js";
|
|
21
21
|
import { deriveRemediation } from "./fix/remediation.js";
|
|
22
|
+
import { freshAttemptedCompanyJournalCheckIds, isUnreachedCompanyJournalForClientHealth, } from "./fix/refresh-sync-progress.js";
|
|
22
23
|
/**
|
|
23
24
|
* The `--json` schema version. Bump on any breaking change to the document
|
|
24
25
|
* shape; the snapshot test in report.test.ts fails until the bump is
|
|
@@ -38,12 +39,19 @@ export function isOrdinaryCoreUpdateWarn(result) {
|
|
|
38
39
|
}
|
|
39
40
|
/** Assemble the machine-readable document from a completed run. */
|
|
40
41
|
export function buildDoctorJson(input) {
|
|
42
|
+
const attemptedRefreshSyncCheckIds = input.clientHealthReport === true
|
|
43
|
+
? freshAttemptedCompanyJournalCheckIds(input.hqRoot, (input.now ?? (() => new Date()))().getTime())
|
|
44
|
+
: null;
|
|
41
45
|
const results = [];
|
|
42
46
|
for (const run of input.families) {
|
|
43
47
|
for (const result of run.results) {
|
|
44
48
|
if (input.clientHealthReport === true && isOrdinaryCoreUpdateWarn(result)) {
|
|
45
49
|
continue;
|
|
46
50
|
}
|
|
51
|
+
if (input.clientHealthReport === true &&
|
|
52
|
+
isUnreachedCompanyJournalForClientHealth(result, attemptedRefreshSyncCheckIds)) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
47
55
|
results.push(toJsonResult(run.family.id, result, input.clientHealthReasonCodesEnabledCheckIds, input.clientHealthCheckStatusEnabledCheckIds));
|
|
48
56
|
}
|
|
49
57
|
}
|