@indigoai-us/hq-cli 5.345.95 → 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 +5 -0
- package/dist/lib/doctor/fix/apply.js +10 -28
- 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,11 @@
|
|
|
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
|
+
|
|
5
10
|
## [5.345.95] — 2026-10-12
|
|
6
11
|
- `hq doctor --fix` still refreshes a stale personal-files journal and a
|
|
7
12
|
company journal in the same pass when hook files have uncommitted changes.
|
|
@@ -48,6 +48,7 @@ import { botDir } from "../../bot/paths.js";
|
|
|
48
48
|
import { readBotConfig } from "../../bot/config.js";
|
|
49
49
|
import { buildBotDaemonPaths, rewriteBotDaemonNodePath } from "../../bot/daemon.js";
|
|
50
50
|
import { classifyRefreshSyncOutcome, } from "./refresh-sync-outcome.js";
|
|
51
|
+
import { readRefreshSyncProgress, writeRefreshSyncCursor, writeRefreshSyncLastRun, } from "./refresh-sync-progress.js";
|
|
51
52
|
import { clientHealthShowManualOnlyEnabled } from "../client-health-reason-codes-flag.js";
|
|
52
53
|
/** The tree subtrees whose uncommitted changes block a `--fix` run. */
|
|
53
54
|
export const HOOK_CONFIG_DIRS = [".claude", ".codex", ".grok"];
|
|
@@ -57,33 +58,6 @@ export const REFRESH_SYNC_TIMEOUT_MS = 90_000;
|
|
|
57
58
|
export const MAX_FIX_PASSES = 3;
|
|
58
59
|
/** Two 90s pulls leave 120s for startup and check overhead under the hook's 300s ceiling. */
|
|
59
60
|
export const MAX_REFRESH_SYNC_PLANS_PER_RUN = 2;
|
|
60
|
-
const REFRESH_SYNC_PROGRESS_RELATIVE_PATH = path.join("workspace", "doctor", "refresh-sync-progress.json");
|
|
61
|
-
function readRefreshSyncProgress(hqRoot) {
|
|
62
|
-
let contents;
|
|
63
|
-
try {
|
|
64
|
-
contents = fs.readFileSync(path.join(hqRoot, REFRESH_SYNC_PROGRESS_RELATIVE_PATH), "utf8");
|
|
65
|
-
}
|
|
66
|
-
catch (error) {
|
|
67
|
-
if (error.code === "ENOENT")
|
|
68
|
-
return null;
|
|
69
|
-
throw error;
|
|
70
|
-
}
|
|
71
|
-
const parsed = JSON.parse(contents);
|
|
72
|
-
if (typeof parsed === "object" && parsed !== null &&
|
|
73
|
-
parsed.version === 1 &&
|
|
74
|
-
typeof parsed.lastAttemptedSlug === "string") {
|
|
75
|
-
return parsed;
|
|
76
|
-
}
|
|
77
|
-
throw new Error(`Invalid refresh-sync progress cursor at ${REFRESH_SYNC_PROGRESS_RELATIVE_PATH}`);
|
|
78
|
-
}
|
|
79
|
-
function writeRefreshSyncProgress(hqRoot, slug) {
|
|
80
|
-
const progressPath = path.join(hqRoot, REFRESH_SYNC_PROGRESS_RELATIVE_PATH);
|
|
81
|
-
const directory = path.dirname(progressPath);
|
|
82
|
-
fs.mkdirSync(directory, { recursive: true });
|
|
83
|
-
const temporaryPath = `${progressPath}.${process.pid}.tmp`;
|
|
84
|
-
fs.writeFileSync(temporaryPath, `${JSON.stringify({ version: 1, lastAttemptedSlug: slug })}\n`, { mode: 0o600 });
|
|
85
|
-
fs.renameSync(temporaryPath, progressPath);
|
|
86
|
-
}
|
|
87
61
|
function rotateRefreshSyncPlans(plans, afterSlug) {
|
|
88
62
|
const ordered = [...plans].sort((a, b) => a.target < b.target ? -1 : a.target > b.target ? 1 : 0);
|
|
89
63
|
if (!afterSlug || ordered.length < 2)
|
|
@@ -254,7 +228,7 @@ export async function applyFixes(options) {
|
|
|
254
228
|
// Persist before starting the bounded pull. If this process is killed,
|
|
255
229
|
// the next invocation resumes after this company instead of starving
|
|
256
230
|
// every later journal behind it.
|
|
257
|
-
|
|
231
|
+
writeRefreshSyncCursor(hqRoot, plan.target);
|
|
258
232
|
lastAttemptedRefreshSyncSlug = plan.target;
|
|
259
233
|
refreshSyncAppliedThisRun += 1;
|
|
260
234
|
}
|
|
@@ -284,6 +258,14 @@ export async function applyFixes(options) {
|
|
|
284
258
|
...(refreshSyncOutcome ? { refreshSyncOutcome } : {}),
|
|
285
259
|
};
|
|
286
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
|
+
}
|
|
287
269
|
write("\nPost-fix status:\n");
|
|
288
270
|
for (const fix of applied) {
|
|
289
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
|
}
|