@indigoai-us/hq-cli 5.183.0 → 5.184.0
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 +11 -0
- package/dist/command-catalog.generated.d.ts +3 -0
- package/dist/command-catalog.generated.js +4 -0
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +100 -6
- package/dist/commands/mesh.js +18 -1
- package/dist/lib/doctor/checks/sync-health.js +13 -0
- package/dist/lib/doctor/client-health-reason-codes-flag.d.ts +26 -0
- package/dist/lib/doctor/client-health-reason-codes-flag.js +94 -0
- package/dist/lib/doctor/fix/apply.d.ts +5 -0
- package/dist/lib/doctor/fix/apply.js +28 -3
- package/dist/lib/doctor/fix/json-output.d.ts +20 -0
- package/dist/lib/doctor/fix/json-output.js +22 -0
- package/dist/lib/doctor/fix/refresh-sync-outcome.d.ts +12 -0
- package/dist/lib/doctor/fix/refresh-sync-outcome.js +25 -0
- package/dist/lib/doctor/json-output.d.ts +7 -1
- package/dist/lib/doctor/json-output.js +6 -2
- package/dist/lib/doctor/types.d.ts +5 -0
- package/dist/lib/doctor/types.js +15 -0
- package/dist/lib/mesh/live/daemon/index.d.ts +1 -1
- package/dist/lib/mesh/live/daemon/index.js +1 -1
- package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +7 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.js +25 -3
- package/dist/lib/mesh/live/emit-client.d.ts +6 -0
- package/dist/lib/mesh/live/emit-client.js +8 -0
- package/dist/lib/mesh/live/enqueue.d.ts +5 -0
- package/dist/lib/mesh/live/enqueue.js +19 -0
- package/dist/lib/mesh/live/flush.js +5 -3
- package/dist/lib/mesh/live/format-spool-line.d.ts +5 -2
- package/dist/lib/mesh/live/format-spool-line.js +10 -0
- package/dist/lib/mesh/live/report-summary.d.ts +45 -0
- package/dist/lib/mesh/live/report-summary.js +222 -0
- package/dist/lib/mesh/live/session-event.schema.json +24 -1
- package/dist/lib/mesh/live/validate-session-event.d.ts +1 -1
- package/dist/lib/mesh/live/validate-session-event.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.184.0] — 2026-09-25
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Work Mesh transcript-watch now emits `session_end` for hook-covered sessions that have already been quiet for 10 minutes when first seen (the live skip path that left `skippedHook=411 ended=0`). Held/spool rescans stamp last hook activity from each event's `at`, not the scan clock, so leftover `held.jsonl` lines cannot reset the quiet timer.
|
|
10
|
+
- Quiet `session_end` (and SessionEnd flush) send an optional one-sentence summary of the last assistant reply (~140 chars, tokens and secrets-file paths redacted), plus git repo, branch, and PR URLs when cheap.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `hq doctor --json` includes stable sync `reasonCode` enums. Client-health reports add `checkId: reasonCode` pairs only when the company-scoped `client-health-sync-reason-codes` hq-flags gate is enabled. `hq doctor --fix --json` reports refresh-sync outcomes without raw error details.
|
|
15
|
+
|
|
5
16
|
## [5.183.0] — 2026-09-25
|
|
6
17
|
|
|
7
18
|
### Added
|
|
@@ -5495,6 +5495,9 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
5495
5495
|
}, {
|
|
5496
5496
|
readonly flags: "--fix";
|
|
5497
5497
|
readonly description: "Apply the allowlisted safe repairs (backs up first; read-only without this flag).";
|
|
5498
|
+
}, {
|
|
5499
|
+
readonly flags: "--client-health-report";
|
|
5500
|
+
readonly description: "Resolve the opt-in hq-flags gate used by the client-health report hook.";
|
|
5498
5501
|
}, {
|
|
5499
5502
|
readonly flags: "--yes";
|
|
5500
5503
|
readonly description: "Skip the interactive --fix confirmation (non-interactive use).";
|
|
@@ -7089,6 +7089,10 @@ export const COMMAND_CATALOG = [
|
|
|
7089
7089
|
"flags": "--fix",
|
|
7090
7090
|
"description": "Apply the allowlisted safe repairs (backs up first; read-only without this flag)."
|
|
7091
7091
|
},
|
|
7092
|
+
{
|
|
7093
|
+
"flags": "--client-health-report",
|
|
7094
|
+
"description": "Resolve the opt-in hq-flags gate used by the client-health report hook."
|
|
7095
|
+
},
|
|
7092
7096
|
{
|
|
7093
7097
|
"flags": "--yes",
|
|
7094
7098
|
"description": "Skip the interactive --fix confirmation (non-interactive use)."
|
|
@@ -55,6 +55,8 @@ export interface RunDoctorOptions {
|
|
|
55
55
|
stderr?: (chunk: string) => void;
|
|
56
56
|
/** Emit the machine-readable JSON document instead of text. Default: false. */
|
|
57
57
|
json?: boolean;
|
|
58
|
+
/** Resolve hq-flags independently for each company represented in sync results. */
|
|
59
|
+
resolveClientHealthReasonCodesForCompany?: (companySlug: string | null) => Promise<boolean>;
|
|
58
60
|
/** In text mode, also print every PASS result. Default: false. */
|
|
59
61
|
verbose?: boolean;
|
|
60
62
|
/** In text mode, emit ANSI colour. Default: false (no escape sequences). */
|
package/dist/commands/doctor.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import * as fs from "node:fs";
|
|
25
25
|
import * as path from "node:path";
|
|
26
|
+
import { Option } from "commander";
|
|
26
27
|
import { createDefaultRegistry, } from "../lib/doctor/registry.js";
|
|
27
28
|
import { computeExitCode, renderText, UNKNOWN_PLATFORM, } from "../lib/doctor/report.js";
|
|
28
29
|
import { buildDoctorJson, renderJson } from "../lib/doctor/json-output.js";
|
|
@@ -30,6 +31,10 @@ import { detectPlatform } from "../lib/doctor/platform.js";
|
|
|
30
31
|
import { DEEP_FAMILY_ID, DEEP_FAMILY_TITLE, runDeepGuardTests, } from "../lib/doctor/deep/executor.js";
|
|
31
32
|
import { PARITY_FAMILY_ID, PARITY_FAMILY_TITLE, runParityReplay, } from "../lib/doctor/deep/parity.js";
|
|
32
33
|
import { applyFixes } from "../lib/doctor/fix/apply.js";
|
|
34
|
+
import { renderDoctorFixJson } from "../lib/doctor/fix/json-output.js";
|
|
35
|
+
import { clientHealthReasonCodesEnabled as isActiveCompanyClientHealthReasonCodesEnabled, clientHealthReasonCodesEnabledForCompany, } from "../lib/doctor/client-health-reason-codes-flag.js";
|
|
36
|
+
import { isCompanyJournalSlug } from "../lib/doctor/checks/sync-health.js";
|
|
37
|
+
import { readCompaniesManifestMap } from "../lib/work-context/repo-remote.js";
|
|
33
38
|
import * as readline from "node:readline";
|
|
34
39
|
import { runSideEffectTests } from "../lib/doctor/deep/sandbox.js";
|
|
35
40
|
/**
|
|
@@ -157,7 +162,16 @@ export async function runDoctor(options = {}) {
|
|
|
157
162
|
});
|
|
158
163
|
}
|
|
159
164
|
if (options.json) {
|
|
160
|
-
|
|
165
|
+
const clientHealthReasonCodesEnabledCheckIds = options.resolveClientHealthReasonCodesForCompany
|
|
166
|
+
? await resolveClientHealthReasonCodesCheckIds(families, options.resolveClientHealthReasonCodesForCompany)
|
|
167
|
+
: undefined;
|
|
168
|
+
write(renderJson(buildDoctorJson({
|
|
169
|
+
hqRoot,
|
|
170
|
+
families,
|
|
171
|
+
platform,
|
|
172
|
+
scope,
|
|
173
|
+
clientHealthReasonCodesEnabledCheckIds,
|
|
174
|
+
})));
|
|
161
175
|
}
|
|
162
176
|
else {
|
|
163
177
|
write(renderText({
|
|
@@ -172,6 +186,61 @@ export async function runDoctor(options = {}) {
|
|
|
172
186
|
// UNTESTED, NA, and KNOWN-DEFECT are reported but never fail the command.
|
|
173
187
|
return { exitCode: computeExitCode(families), hqRoot, families };
|
|
174
188
|
}
|
|
189
|
+
async function resolveClientHealthReasonCodesCheckIds(families, resolveForCompany) {
|
|
190
|
+
const checksByCompany = new Map();
|
|
191
|
+
for (const run of families) {
|
|
192
|
+
if (run.family.id !== "sync")
|
|
193
|
+
continue;
|
|
194
|
+
for (const result of run.results) {
|
|
195
|
+
if (result.reasonCode === undefined ||
|
|
196
|
+
(result.status !== "FAIL" && result.status !== "WARN")) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const scoped = /^sync\.(?:journal|manifest)\.(.+)$/.exec(result.checkId);
|
|
200
|
+
const companySlug = scoped && isCompanyJournalSlug(scoped[1]) ? scoped[1] : null;
|
|
201
|
+
const checkIds = checksByCompany.get(companySlug) ?? [];
|
|
202
|
+
checkIds.push(result.checkId);
|
|
203
|
+
checksByCompany.set(companySlug, checkIds);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const enabledChecks = new Set();
|
|
207
|
+
const decisions = await Promise.all([...checksByCompany.entries()].map(async ([companySlug, checkIds]) => ({
|
|
208
|
+
checkIds,
|
|
209
|
+
enabled: await resolveForCompany(companySlug),
|
|
210
|
+
})));
|
|
211
|
+
for (const decision of decisions) {
|
|
212
|
+
if (decision.enabled) {
|
|
213
|
+
for (const checkId of decision.checkIds)
|
|
214
|
+
enabledChecks.add(checkId);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return enabledChecks;
|
|
218
|
+
}
|
|
219
|
+
function createClientHealthReasonCodeCompanyResolver(hqRoot) {
|
|
220
|
+
const companies = readCompaniesManifestMap(hqRoot);
|
|
221
|
+
return async (companySlug) => {
|
|
222
|
+
if (companySlug === null) {
|
|
223
|
+
return isActiveCompanyClientHealthReasonCodesEnabled();
|
|
224
|
+
}
|
|
225
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(companySlug)) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
const manifestUid = companies?.[companySlug]?.cloud_uid;
|
|
229
|
+
const activeSlug = process.env.HQ_COMPANY_SLUG?.trim() || "";
|
|
230
|
+
const activeUid = process.env.HQ_COMPANY_UID?.trim() || "";
|
|
231
|
+
const companyUid = typeof manifestUid === "string" && manifestUid.trim()
|
|
232
|
+
? manifestUid.trim()
|
|
233
|
+
: companySlug === activeSlug
|
|
234
|
+
? activeUid
|
|
235
|
+
: "";
|
|
236
|
+
if (!/^cmp_[A-Za-z0-9]{3,128}$/.test(companyUid))
|
|
237
|
+
return false;
|
|
238
|
+
return clientHealthReasonCodesEnabledForCompany({
|
|
239
|
+
companyUid,
|
|
240
|
+
companySlug,
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
}
|
|
175
244
|
/** Register the top-level `hq doctor` command so it appears in `hq --help`. */
|
|
176
245
|
export function registerDoctorCommand(program) {
|
|
177
246
|
program
|
|
@@ -186,6 +255,7 @@ export function registerDoctorCommand(program) {
|
|
|
186
255
|
.option("--company <slug>", "Company slug for integrations checks (otherwise resolves your single active company).")
|
|
187
256
|
.option("--only <families>", "Run only these comma-separated check families (e.g. hooks). Unknown names are rejected; --json then records the scope.")
|
|
188
257
|
.option("--fix", "Apply the allowlisted safe repairs (backs up first; read-only without this flag).")
|
|
258
|
+
.addOption(new Option("--client-health-report", "Resolve the opt-in hq-flags gate used by the client-health report hook.").hideHelp())
|
|
189
259
|
.option("--yes", "Skip the interactive --fix confirmation (non-interactive use).")
|
|
190
260
|
.option("--force", "Let --fix run despite uncommitted changes under .claude/, .codex/, or .grok/.")
|
|
191
261
|
.action(async (opts) => {
|
|
@@ -208,8 +278,17 @@ export function registerDoctorCommand(program) {
|
|
|
208
278
|
if (opts.fix === true) {
|
|
209
279
|
const hqRoot = resolveHqRoot();
|
|
210
280
|
if (!hqRoot) {
|
|
211
|
-
|
|
212
|
-
|
|
281
|
+
if (opts.json === true) {
|
|
282
|
+
process.stdout.write(renderDoctorFixJson({
|
|
283
|
+
exitCode: 1,
|
|
284
|
+
refused: "outside-hq-tree",
|
|
285
|
+
applied: [],
|
|
286
|
+
}));
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
process.stderr.write(`hq doctor --fix: not inside an HQ tree.\n` +
|
|
290
|
+
` Looking for a directory containing all of: ${HQ_ROOT_MARKERS.join(", ")}\n`);
|
|
291
|
+
}
|
|
213
292
|
process.exitCode = 1;
|
|
214
293
|
return;
|
|
215
294
|
}
|
|
@@ -217,8 +296,14 @@ export function registerDoctorCommand(program) {
|
|
|
217
296
|
hqRoot,
|
|
218
297
|
yes: opts.yes === true,
|
|
219
298
|
force: opts.force === true,
|
|
220
|
-
confirm: promptYesNo,
|
|
299
|
+
confirm: () => promptYesNo(opts.json === true ? process.stderr : process.stdout),
|
|
300
|
+
...(opts.json === true
|
|
301
|
+
? { stdout: () => { }, stderr: () => { } }
|
|
302
|
+
: {}),
|
|
221
303
|
});
|
|
304
|
+
if (opts.json === true) {
|
|
305
|
+
process.stdout.write(renderDoctorFixJson(fixResult));
|
|
306
|
+
}
|
|
222
307
|
process.exitCode = fixResult.exitCode;
|
|
223
308
|
return;
|
|
224
309
|
}
|
|
@@ -237,6 +322,14 @@ export function registerDoctorCommand(program) {
|
|
|
237
322
|
id: detection.platform,
|
|
238
323
|
evidence: detection.evidence,
|
|
239
324
|
};
|
|
325
|
+
const clientHealthReasonCodesResolver = json && opts.clientHealthReport === true
|
|
326
|
+
? (() => {
|
|
327
|
+
const hqRoot = resolveHqRoot();
|
|
328
|
+
return hqRoot
|
|
329
|
+
? createClientHealthReasonCodeCompanyResolver(hqRoot)
|
|
330
|
+
: undefined;
|
|
331
|
+
})()
|
|
332
|
+
: undefined;
|
|
240
333
|
const result = await runDoctor({
|
|
241
334
|
json,
|
|
242
335
|
verbose: opts.verbose === true,
|
|
@@ -247,6 +340,7 @@ export function registerDoctorCommand(program) {
|
|
|
247
340
|
liveRuntimes: opts.liveRuntimes === true,
|
|
248
341
|
company: opts.company,
|
|
249
342
|
only: parseOnly(opts.only),
|
|
343
|
+
resolveClientHealthReasonCodesForCompany: clientHealthReasonCodesResolver,
|
|
250
344
|
});
|
|
251
345
|
// Set the exit code rather than calling process.exit, so the CLI's
|
|
252
346
|
// normal shutdown (telemetry flush) still runs. Non-zero means either an
|
|
@@ -272,12 +366,12 @@ function parseOnly(value) {
|
|
|
272
366
|
* (so a piped run without `--yes` writes nothing) and on anything other than an
|
|
273
367
|
* explicit yes.
|
|
274
368
|
*/
|
|
275
|
-
function promptYesNo() {
|
|
369
|
+
function promptYesNo(output = process.stdout) {
|
|
276
370
|
if (!process.stdin.isTTY)
|
|
277
371
|
return Promise.resolve(false);
|
|
278
372
|
const rl = readline.createInterface({
|
|
279
373
|
input: process.stdin,
|
|
280
|
-
output
|
|
374
|
+
output,
|
|
281
375
|
});
|
|
282
376
|
return new Promise((resolve) => {
|
|
283
377
|
rl.question("Apply these repairs? [y/N] ", (answer) => {
|
package/dist/commands/mesh.js
CHANGED
|
@@ -29,6 +29,7 @@ const MEMBERSHIP_VALIDATION_TIMEOUT_MS = 5_000;
|
|
|
29
29
|
import { buildEmitEvidence, machineEmitDisposition } from "../lib/mesh/live/emit-evidence.js";
|
|
30
30
|
import { appendHeldLine } from "../lib/mesh/live/spool.js";
|
|
31
31
|
import { createEmitPoster, buildMeshEmitEvent } from "../lib/mesh/live/emit-client.js";
|
|
32
|
+
import { enrichSessionEndEvent } from "../lib/mesh/live/report-summary.js";
|
|
32
33
|
import { emitEvents, formatEmitRejectionLines } from "../lib/mesh/live/emit.js";
|
|
33
34
|
import { readEmitRetry, writeEmitRetry } from "../lib/mesh/live/emit-retry.js";
|
|
34
35
|
import { generateUlid } from "../lib/mesh/live/ulid.js";
|
|
@@ -1533,6 +1534,14 @@ async function runSessionEnqueue(cliKind, opts) {
|
|
|
1533
1534
|
task: opts.task,
|
|
1534
1535
|
env: process.env,
|
|
1535
1536
|
});
|
|
1537
|
+
const reportFields = kind === "session_end"
|
|
1538
|
+
? enrichSessionEndEvent({
|
|
1539
|
+
kind,
|
|
1540
|
+
sessionId: sessionId,
|
|
1541
|
+
cwd: opts.cwd,
|
|
1542
|
+
summary: opts.summary,
|
|
1543
|
+
})
|
|
1544
|
+
: {};
|
|
1536
1545
|
const event = buildMeshEmitEvent({
|
|
1537
1546
|
eventId: opts.eventId?.trim() || generateUlid(Date.now()),
|
|
1538
1547
|
kind: kind,
|
|
@@ -1546,7 +1555,15 @@ async function runSessionEnqueue(cliKind, opts) {
|
|
|
1546
1555
|
taskId: opts.taskId,
|
|
1547
1556
|
status: opts.status,
|
|
1548
1557
|
reason: opts.reason,
|
|
1549
|
-
summary: opts.summary
|
|
1558
|
+
summary: opts.summary ||
|
|
1559
|
+
(typeof reportFields.summary === "string" ? reportFields.summary : undefined),
|
|
1560
|
+
repo: typeof reportFields.repo === "string" ? reportFields.repo : undefined,
|
|
1561
|
+
gitBranch: typeof reportFields.gitBranch === "string"
|
|
1562
|
+
? reportFields.gitBranch
|
|
1563
|
+
: undefined,
|
|
1564
|
+
prUrls: Array.isArray(reportFields.prUrls)
|
|
1565
|
+
? reportFields.prUrls
|
|
1566
|
+
: undefined,
|
|
1550
1567
|
evidence,
|
|
1551
1568
|
});
|
|
1552
1569
|
const meshRoot = workMeshHomeRoot();
|
|
@@ -223,6 +223,7 @@ function versionResults(context, deps) {
|
|
|
223
223
|
results.push({
|
|
224
224
|
status: "WARN",
|
|
225
225
|
checkId: "sync.versions.core",
|
|
226
|
+
reasonCode: "core-version-unavailable",
|
|
226
227
|
target: path.join(context.hqRoot, "core", "core.yaml"),
|
|
227
228
|
message: "hq-core version could not be read from core/core.yaml — the scaffold may be missing or pre-v12.",
|
|
228
229
|
remediation: "Run /update-hq to (re)install the hq-core scaffold.",
|
|
@@ -282,6 +283,7 @@ function updateAvailabilityResult(context, deps) {
|
|
|
282
283
|
{
|
|
283
284
|
status: "WARN",
|
|
284
285
|
checkId: "sync.update.core",
|
|
286
|
+
reasonCode: "core-update-available",
|
|
285
287
|
message: `hq-core update available: local v${core}, latest v${latest}.`,
|
|
286
288
|
remediation: "Run /update-hq in a fresh session to upgrade.",
|
|
287
289
|
},
|
|
@@ -373,6 +375,7 @@ function journalResults(deps, journals) {
|
|
|
373
375
|
return {
|
|
374
376
|
status: "NA",
|
|
375
377
|
checkId,
|
|
378
|
+
reasonCode: "no-local-tree",
|
|
376
379
|
target: entry.path,
|
|
377
380
|
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
381
|
};
|
|
@@ -382,6 +385,7 @@ function journalResults(deps, journals) {
|
|
|
382
385
|
return {
|
|
383
386
|
status: "WARN",
|
|
384
387
|
checkId,
|
|
388
|
+
reasonCode: "never-synced",
|
|
385
389
|
target: entry.path,
|
|
386
390
|
message: `Sync journal '${entry.slug}' exists but has never recorded a successful sync.`,
|
|
387
391
|
remediation: "Run /hq-sync (or `hq sync`) to complete a first sync.",
|
|
@@ -392,6 +396,7 @@ function journalResults(deps, journals) {
|
|
|
392
396
|
return {
|
|
393
397
|
status: "UNKNOWN",
|
|
394
398
|
checkId,
|
|
399
|
+
reasonCode: "invalid-last-sync",
|
|
395
400
|
target: entry.path,
|
|
396
401
|
message: `Sync journal '${entry.slug}' has an unparseable lastSync (${lastSync}).`,
|
|
397
402
|
};
|
|
@@ -402,6 +407,7 @@ function journalResults(deps, journals) {
|
|
|
402
407
|
return {
|
|
403
408
|
status: "WARN",
|
|
404
409
|
checkId,
|
|
410
|
+
reasonCode: "stale-threshold",
|
|
405
411
|
target: entry.path,
|
|
406
412
|
message: `Sync journal '${entry.slug}' is stale: last successful sync ${days} day${days === 1 ? "" : "s"} ago (${lastSync}).`,
|
|
407
413
|
remediation: "Run /hq-sync (or `hq sync`) and check the sync runner.",
|
|
@@ -443,6 +449,7 @@ function manifestResults(deps, journals) {
|
|
|
443
449
|
{
|
|
444
450
|
status: "NA",
|
|
445
451
|
checkId: "sync.manifest",
|
|
452
|
+
reasonCode: "manifest-unavailable",
|
|
446
453
|
message: `${MANIFEST_UNAVAILABLE_REASON} — upgrade hq-cloud to audit manifest uploads.`,
|
|
447
454
|
},
|
|
448
455
|
];
|
|
@@ -456,6 +463,7 @@ function manifestResults(deps, journals) {
|
|
|
456
463
|
{
|
|
457
464
|
status: "UNKNOWN",
|
|
458
465
|
checkId: "sync.manifest",
|
|
466
|
+
reasonCode: "manifest-state-unavailable",
|
|
459
467
|
message: `Manifest upload state could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
460
468
|
remediation: "Check that the HQ state directory is readable, then re-run `hq doctor`.",
|
|
461
469
|
},
|
|
@@ -469,6 +477,7 @@ function manifestResults(deps, journals) {
|
|
|
469
477
|
{
|
|
470
478
|
status: "NA",
|
|
471
479
|
checkId: "sync.manifest",
|
|
480
|
+
reasonCode: "no-manifest-scopes",
|
|
472
481
|
message: "No manifest-auditable sync scopes on this machine — nothing to reconcile.",
|
|
473
482
|
},
|
|
474
483
|
];
|
|
@@ -480,6 +489,7 @@ function manifestResults(deps, journals) {
|
|
|
480
489
|
return {
|
|
481
490
|
status: "UNTESTED",
|
|
482
491
|
checkId,
|
|
492
|
+
reasonCode: "never-uploaded",
|
|
483
493
|
message: `Scope '${entry.scopeKey}' has never uploaded a sync manifest — expected until the first pass runs.`,
|
|
484
494
|
remediation: "Run `hq sync manifest` to upload one now.",
|
|
485
495
|
};
|
|
@@ -489,6 +499,7 @@ function manifestResults(deps, journals) {
|
|
|
489
499
|
return {
|
|
490
500
|
status: "UNKNOWN",
|
|
491
501
|
checkId,
|
|
502
|
+
reasonCode: "invalid-upload-time",
|
|
492
503
|
message: `Scope '${entry.scopeKey}' has an unparseable last manifest upload time (${entry.lastUploadAt}).`,
|
|
493
504
|
};
|
|
494
505
|
}
|
|
@@ -501,6 +512,7 @@ function manifestResults(deps, journals) {
|
|
|
501
512
|
return {
|
|
502
513
|
status: "WARN",
|
|
503
514
|
checkId,
|
|
515
|
+
reasonCode: "stale-threshold",
|
|
504
516
|
message: `Scope '${entry.scopeKey}' last uploaded a sync manifest ${days} day${days === 1 ? "" : "s"} ago (${entry.lastUploadAt}) — the daily audit pass is not running.`,
|
|
505
517
|
remediation: "Run `hq sync manifest` and check HQ_SYNC_MANIFEST_DISABLED and the sync runner.",
|
|
506
518
|
};
|
|
@@ -532,6 +544,7 @@ function unresolvedResults(deps, journals) {
|
|
|
532
544
|
return slugs.map((slug) => ({
|
|
533
545
|
status: "UNTESTED",
|
|
534
546
|
checkId: `sync.manifest.unresolved.${slug}`,
|
|
547
|
+
reasonCode: "unresolved-company-uid",
|
|
535
548
|
message: `Company scope '${slug}' has a sync journal but its companyUid could not be resolved locally — its manifest uploads were not checked.`,
|
|
536
549
|
remediation: `Run \`hq sync manifest --scope ${slug}\` to audit it explicitly.`,
|
|
537
550
|
}));
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type FlagClient, type FlagClientOptions } from "@indigoai-us/hq-flags-client";
|
|
2
|
+
export declare const CLIENT_HEALTH_REASON_CODES_FLAG = "client-health-sync-reason-codes";
|
|
3
|
+
export declare const CLIENT_HEALTH_REASON_CODES_FLAG_FALLBACK = false;
|
|
4
|
+
export declare const CLIENT_HEALTH_REASON_CODES_FLAG_TIMEOUT_MS = 150;
|
|
5
|
+
type ClientHealthFlagClient = Pick<FlagClient, "ready" | "isEnabled" | "close">;
|
|
6
|
+
export interface ClientHealthReasonCodesFlagDependencies {
|
|
7
|
+
env?: NodeJS.ProcessEnv;
|
|
8
|
+
createClient?: (options: FlagClientOptions) => ClientHealthFlagClient;
|
|
9
|
+
getToken?: FlagClientOptions["getToken"];
|
|
10
|
+
fetch?: typeof fetch;
|
|
11
|
+
reportFailure?: (error: unknown) => void;
|
|
12
|
+
}
|
|
13
|
+
export interface ClientHealthReasonCodesCompany {
|
|
14
|
+
companyUid: string;
|
|
15
|
+
companySlug?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the privacy gate from hq-flags with a bounded request. Missing
|
|
19
|
+
* identity, service configuration, and every lookup failure keep diagnostics
|
|
20
|
+
* off so a flag outage cannot delay the doctor's health result.
|
|
21
|
+
*/
|
|
22
|
+
export declare function clientHealthReasonCodesEnabled(dependencies?: ClientHealthReasonCodesFlagDependencies, callerSignal?: AbortSignal): Promise<boolean>;
|
|
23
|
+
/** Resolve the same default-off gate for one explicitly identified company. */
|
|
24
|
+
export declare function clientHealthReasonCodesEnabledForCompany(company: ClientHealthReasonCodesCompany, dependencies?: ClientHealthReasonCodesFlagDependencies, callerSignal?: AbortSignal): Promise<boolean>;
|
|
25
|
+
export {};
|
|
26
|
+
//# sourceMappingURL=client-health-reason-codes-flag.d.ts.map
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { loadCachedTokens } from "@indigoai-us/hq-cloud";
|
|
2
|
+
import { createFlagClient, } from "@indigoai-us/hq-flags-client";
|
|
3
|
+
export const CLIENT_HEALTH_REASON_CODES_FLAG = "client-health-sync-reason-codes";
|
|
4
|
+
export const CLIENT_HEALTH_REASON_CODES_FLAG_FALLBACK = false;
|
|
5
|
+
export const CLIENT_HEALTH_REASON_CODES_FLAG_TIMEOUT_MS = 150;
|
|
6
|
+
function safeErrorClass(error) {
|
|
7
|
+
const name = error instanceof Error ? error.name : "UnknownError";
|
|
8
|
+
return /^[A-Za-z][A-Za-z0-9]{0,63}$/.test(name) ? name : "UnknownError";
|
|
9
|
+
}
|
|
10
|
+
function reportFlagFailure(error) {
|
|
11
|
+
process.stderr.write(`HQ client-health reason-code flag lookup failed (${safeErrorClass(error)}); using default off.\n`);
|
|
12
|
+
}
|
|
13
|
+
function combineSignals(first, second) {
|
|
14
|
+
return first ? AbortSignal.any([first, second]) : second;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the privacy gate from hq-flags with a bounded request. Missing
|
|
18
|
+
* identity, service configuration, and every lookup failure keep diagnostics
|
|
19
|
+
* off so a flag outage cannot delay the doctor's health result.
|
|
20
|
+
*/
|
|
21
|
+
export async function clientHealthReasonCodesEnabled(dependencies = {}, callerSignal) {
|
|
22
|
+
const env = dependencies.env ?? process.env;
|
|
23
|
+
const companyUid = env.HQ_COMPANY_UID?.trim() || "";
|
|
24
|
+
if (!/^cmp_[A-Za-z0-9]{3,128}$/.test(companyUid)) {
|
|
25
|
+
return CLIENT_HEALTH_REASON_CODES_FLAG_FALLBACK;
|
|
26
|
+
}
|
|
27
|
+
return clientHealthReasonCodesEnabledForCompany({
|
|
28
|
+
companyUid,
|
|
29
|
+
companySlug: env.HQ_COMPANY_SLUG?.trim() || undefined,
|
|
30
|
+
}, dependencies, callerSignal);
|
|
31
|
+
}
|
|
32
|
+
/** Resolve the same default-off gate for one explicitly identified company. */
|
|
33
|
+
export async function clientHealthReasonCodesEnabledForCompany(company, dependencies = {}, callerSignal) {
|
|
34
|
+
const env = dependencies.env ?? process.env;
|
|
35
|
+
const endpoint = env.HQ_FLAGS_API_URL?.trim() || "";
|
|
36
|
+
const companyUid = company.companyUid.trim();
|
|
37
|
+
if (!endpoint || !/^cmp_[A-Za-z0-9]{3,128}$/.test(companyUid)) {
|
|
38
|
+
return CLIENT_HEALTH_REASON_CODES_FLAG_FALLBACK;
|
|
39
|
+
}
|
|
40
|
+
const companySlug = company.companySlug?.trim() || "";
|
|
41
|
+
const deadline = AbortSignal.timeout(CLIENT_HEALTH_REASON_CODES_FLAG_TIMEOUT_MS);
|
|
42
|
+
const signal = callerSignal ? AbortSignal.any([callerSignal, deadline]) : deadline;
|
|
43
|
+
const doFetch = dependencies.fetch ?? globalThis.fetch;
|
|
44
|
+
let client;
|
|
45
|
+
let failed = false;
|
|
46
|
+
let failureReported = false;
|
|
47
|
+
let enabled = CLIENT_HEALTH_REASON_CODES_FLAG_FALLBACK;
|
|
48
|
+
const reportFailureOnce = (error) => {
|
|
49
|
+
failed = true;
|
|
50
|
+
if (failureReported)
|
|
51
|
+
return;
|
|
52
|
+
failureReported = true;
|
|
53
|
+
(dependencies.reportFailure ?? reportFlagFailure)(error);
|
|
54
|
+
};
|
|
55
|
+
try {
|
|
56
|
+
client = (dependencies.createClient ?? createFlagClient)({
|
|
57
|
+
endpoint,
|
|
58
|
+
companyUid,
|
|
59
|
+
...(companySlug && /^[A-Za-z0-9][A-Za-z0-9-]{0,63}$/.test(companySlug)
|
|
60
|
+
? { companyIdentifiers: [companySlug] }
|
|
61
|
+
: {}),
|
|
62
|
+
env: {},
|
|
63
|
+
getToken: dependencies.getToken ?? (() => loadCachedTokens()?.idToken ?? ""),
|
|
64
|
+
fetch: (input, init) => doFetch(input, {
|
|
65
|
+
...init,
|
|
66
|
+
signal: combineSignals(init?.signal ?? undefined, signal),
|
|
67
|
+
}),
|
|
68
|
+
refreshIntervalMs: 0,
|
|
69
|
+
requestTimeoutMs: CLIENT_HEALTH_REASON_CODES_FLAG_TIMEOUT_MS,
|
|
70
|
+
onError: reportFailureOnce,
|
|
71
|
+
});
|
|
72
|
+
await client.ready();
|
|
73
|
+
if (!failed && !signal.aborted) {
|
|
74
|
+
enabled = client.isEnabled(CLIENT_HEALTH_REASON_CODES_FLAG, {
|
|
75
|
+
fallback: CLIENT_HEALTH_REASON_CODES_FLAG_FALLBACK,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
reportFailureOnce(error);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
try {
|
|
84
|
+
client?.close();
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
reportFailureOnce(error);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return failed || signal.aborted
|
|
91
|
+
? CLIENT_HEALTH_REASON_CODES_FLAG_FALLBACK
|
|
92
|
+
: enabled;
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=client-health-reason-codes-flag.js.map
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
import type { CheckResult, DoctorStatus } from "../types.js";
|
|
32
32
|
import { type GateProfile } from "../hook-gate-profiles.js";
|
|
33
33
|
import { type FixClass } from "./remediation.js";
|
|
34
|
+
import { type RefreshSyncOutcome } from "./refresh-sync-outcome.js";
|
|
34
35
|
/** The tree subtrees whose uncommitted changes block a `--fix` run. */
|
|
35
36
|
export declare const HOOK_CONFIG_DIRS: readonly [".claude", ".codex", ".grok"];
|
|
36
37
|
/** Options for {@link applyFixes}. Everything is injectable for hermetic tests. */
|
|
@@ -91,6 +92,8 @@ export interface RefreshSyncTarget {
|
|
|
91
92
|
export interface RefreshSyncResult {
|
|
92
93
|
/** Whether the pull exited 0. */
|
|
93
94
|
ok: boolean;
|
|
95
|
+
/** Closed enum for machine-readable client-health diagnostics. */
|
|
96
|
+
outcome: RefreshSyncOutcome;
|
|
94
97
|
/** One-line summary for the post-fix report. */
|
|
95
98
|
message: string;
|
|
96
99
|
}
|
|
@@ -108,6 +111,8 @@ export interface AppliedFix {
|
|
|
108
111
|
summary: string;
|
|
109
112
|
/** The status of the re-run check for this target, or null if not found. */
|
|
110
113
|
postStatus: DoctorStatus | null;
|
|
114
|
+
/** Present for refresh-sync plans; contains no command output or path data. */
|
|
115
|
+
refreshSyncOutcome?: RefreshSyncOutcome;
|
|
111
116
|
}
|
|
112
117
|
/** The outcome of an {@link applyFixes} run. */
|
|
113
118
|
export interface ApplyFixesResult {
|
|
@@ -40,6 +40,7 @@ import { deriveRemediation } from "./remediation.js";
|
|
|
40
40
|
import { botDir } from "../../bot/paths.js";
|
|
41
41
|
import { readBotConfig } from "../../bot/config.js";
|
|
42
42
|
import { buildBotDaemonPaths, rewriteBotDaemonNodePath } from "../../bot/daemon.js";
|
|
43
|
+
import { classifyRefreshSyncOutcome, } from "./refresh-sync-outcome.js";
|
|
43
44
|
/** The tree subtrees whose uncommitted changes block a `--fix` run. */
|
|
44
45
|
export const HOOK_CONFIG_DIRS = [".claude", ".codex", ".grok"];
|
|
45
46
|
/** Per-company bound for the default `hq sync pull` repair. */
|
|
@@ -130,6 +131,9 @@ export async function applyFixes(options) {
|
|
|
130
131
|
target: plan.target,
|
|
131
132
|
summary: plan.summary,
|
|
132
133
|
postStatus: plan.postStatus(after),
|
|
134
|
+
...(plan.refreshSyncOutcome?.()
|
|
135
|
+
? { refreshSyncOutcome: plan.refreshSyncOutcome() }
|
|
136
|
+
: {}),
|
|
133
137
|
}));
|
|
134
138
|
write("\nPost-fix status:\n");
|
|
135
139
|
for (const fix of applied) {
|
|
@@ -197,23 +201,41 @@ export function defaultRefreshSync(target) {
|
|
|
197
201
|
cwd: target.hqRoot,
|
|
198
202
|
});
|
|
199
203
|
if (result.error) {
|
|
200
|
-
const
|
|
204
|
+
const spawnError = result.error;
|
|
205
|
+
const outcome = classifyRefreshSyncOutcome({
|
|
206
|
+
errorCode: spawnError.code,
|
|
207
|
+
...(result.error.message.includes("ETIMEDOUT")
|
|
208
|
+
? { errorCode: "ETIMEDOUT" }
|
|
209
|
+
: {}),
|
|
210
|
+
signal: result.signal,
|
|
211
|
+
status: result.status,
|
|
212
|
+
stdout: result.stdout,
|
|
213
|
+
stderr: result.stderr,
|
|
214
|
+
});
|
|
201
215
|
return Promise.resolve({
|
|
202
216
|
ok: false,
|
|
203
|
-
|
|
217
|
+
outcome,
|
|
218
|
+
message: outcome === "pull-timeout"
|
|
204
219
|
? `timed out pulling '${target.slug}'`
|
|
205
220
|
: `could not start pull for '${target.slug}': ${result.error.message}`,
|
|
206
221
|
});
|
|
207
222
|
}
|
|
208
223
|
if (result.status !== 0) {
|
|
224
|
+
const outcome = classifyRefreshSyncOutcome({
|
|
225
|
+
status: result.status,
|
|
226
|
+
stdout: result.stdout,
|
|
227
|
+
stderr: result.stderr,
|
|
228
|
+
});
|
|
209
229
|
const detail = (result.stderr || result.stdout || "").trim().split("\n").at(-1) ?? "";
|
|
210
230
|
return Promise.resolve({
|
|
211
231
|
ok: false,
|
|
232
|
+
outcome,
|
|
212
233
|
message: `pull of '${target.slug}' exited ${result.status}${detail ? `: ${detail}` : ""}`,
|
|
213
234
|
});
|
|
214
235
|
}
|
|
215
236
|
return Promise.resolve({
|
|
216
237
|
ok: true,
|
|
238
|
+
outcome: "pull-succeeded",
|
|
217
239
|
message: `pulled '${target.slug}'`,
|
|
218
240
|
});
|
|
219
241
|
}
|
|
@@ -287,6 +309,7 @@ function planRefreshSync(hqRoot, result, rem, refreshSync) {
|
|
|
287
309
|
const slug = rem.fixTarget;
|
|
288
310
|
if (!slug)
|
|
289
311
|
return null;
|
|
312
|
+
let outcome;
|
|
290
313
|
return {
|
|
291
314
|
checkId: result.checkId,
|
|
292
315
|
fixClass: "refresh-sync",
|
|
@@ -297,8 +320,10 @@ function planRefreshSync(hqRoot, result, rem, refreshSync) {
|
|
|
297
320
|
` refresh the stale sync journal (keeps local conflict copies)`,
|
|
298
321
|
summary: `pull ${slug} to refresh its sync journal`,
|
|
299
322
|
apply: async () => {
|
|
300
|
-
await refreshSync({ slug, hqRoot });
|
|
323
|
+
const result = await refreshSync({ slug, hqRoot });
|
|
324
|
+
outcome = result.outcome;
|
|
301
325
|
},
|
|
326
|
+
refreshSyncOutcome: () => outcome,
|
|
302
327
|
postStatus: (results) => results.find((entry) => entry.checkId === result.checkId)?.status ?? null,
|
|
303
328
|
};
|
|
304
329
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { AppliedFix, ApplyFixesResult } from "./apply.js";
|
|
2
|
+
export declare const DOCTOR_FIX_JSON_SCHEMA_VERSION = 1;
|
|
3
|
+
export interface DoctorFixJsonResult {
|
|
4
|
+
schemaVersion: number;
|
|
5
|
+
exitCode: number;
|
|
6
|
+
refused: ApplyFixesResult["refused"] | "outside-hq-tree";
|
|
7
|
+
refreshSync: {
|
|
8
|
+
checkId: string;
|
|
9
|
+
outcome: NonNullable<AppliedFix["refreshSyncOutcome"]>;
|
|
10
|
+
postStatus: AppliedFix["postStatus"];
|
|
11
|
+
}[];
|
|
12
|
+
}
|
|
13
|
+
/** Build the intentionally narrow JSON surface for `hq doctor --fix --json`. */
|
|
14
|
+
export declare function buildDoctorFixJson(input: {
|
|
15
|
+
exitCode: number;
|
|
16
|
+
refused: ApplyFixesResult["refused"] | "outside-hq-tree";
|
|
17
|
+
applied: readonly AppliedFix[];
|
|
18
|
+
}): DoctorFixJsonResult;
|
|
19
|
+
export declare function renderDoctorFixJson(input: Parameters<typeof buildDoctorFixJson>[0]): string;
|
|
20
|
+
//# sourceMappingURL=json-output.d.ts.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const DOCTOR_FIX_JSON_SCHEMA_VERSION = 1;
|
|
2
|
+
/** Build the intentionally narrow JSON surface for `hq doctor --fix --json`. */
|
|
3
|
+
export function buildDoctorFixJson(input) {
|
|
4
|
+
return {
|
|
5
|
+
schemaVersion: DOCTOR_FIX_JSON_SCHEMA_VERSION,
|
|
6
|
+
exitCode: input.exitCode,
|
|
7
|
+
refused: input.refused,
|
|
8
|
+
refreshSync: input.applied.flatMap((fix) => fix.fixClass === "refresh-sync" && fix.refreshSyncOutcome
|
|
9
|
+
? [
|
|
10
|
+
{
|
|
11
|
+
checkId: fix.checkId,
|
|
12
|
+
outcome: fix.refreshSyncOutcome,
|
|
13
|
+
postStatus: fix.postStatus,
|
|
14
|
+
},
|
|
15
|
+
]
|
|
16
|
+
: []),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function renderDoctorFixJson(input) {
|
|
20
|
+
return JSON.stringify(buildDoctorFixJson(input), null, 2) + "\n";
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=json-output.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Privacy-safe outcomes for a targeted refresh-sync attempt. */
|
|
2
|
+
export type RefreshSyncOutcome = "pull-succeeded" | "pull-timeout" | "pull-failed:4xx" | "pull-failed:5xx" | "pull-failed:network" | "pull-failed:other";
|
|
3
|
+
export interface RefreshSyncObservation {
|
|
4
|
+
errorCode?: string;
|
|
5
|
+
signal?: string | null;
|
|
6
|
+
status?: number | null;
|
|
7
|
+
stdout?: string;
|
|
8
|
+
stderr?: string;
|
|
9
|
+
}
|
|
10
|
+
/** Classify process/HTTP results without retaining their free-text details. */
|
|
11
|
+
export declare function classifyRefreshSyncOutcome(observation: RefreshSyncObservation): RefreshSyncOutcome;
|
|
12
|
+
//# sourceMappingURL=refresh-sync-outcome.d.ts.map
|