@indigoai-us/hq-cli 5.98.3 → 5.99.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 +41 -0
- package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +347 -0
- package/assets/scaffold/core/scripts/hook-lib.sh +557 -0
- package/assets/scaffold/core/scripts/hq-session.sh +251 -0
- package/assets/scaffold/core/scripts/lib/session-id.sh +96 -0
- package/assets/scaffold/core/scripts/lib/session-scope-capability.sh +52 -0
- package/dist/commands/core.js +25 -5
- package/dist/commands/doctor.d.ts +97 -0
- package/dist/commands/doctor.js +228 -0
- package/dist/commands/scaffold-fast.d.ts +41 -0
- package/dist/commands/scaffold-fast.js +57 -0
- package/dist/fast-core.d.ts +16 -0
- package/dist/fast-core.js +47 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +10 -1
- package/dist/lib/doctor/__testing__/fake-hq-tree.d.ts +194 -0
- package/dist/lib/doctor/__testing__/fake-hq-tree.js +357 -0
- package/dist/lib/doctor/allowed-divergence.d.ts +72 -0
- package/dist/lib/doctor/allowed-divergence.js +134 -0
- package/dist/lib/doctor/checks/claude-wiring.d.ts +55 -0
- package/dist/lib/doctor/checks/claude-wiring.js +524 -0
- package/dist/lib/doctor/checks/codex-wiring.d.ts +45 -0
- package/dist/lib/doctor/checks/codex-wiring.js +376 -0
- package/dist/lib/doctor/checks/grok-wiring.d.ts +35 -0
- package/dist/lib/doctor/checks/grok-wiring.js +186 -0
- package/dist/lib/doctor/checks/runtime-probe.d.ts +101 -0
- package/dist/lib/doctor/checks/runtime-probe.js +335 -0
- package/dist/lib/doctor/compat.d.ts +85 -0
- package/dist/lib/doctor/compat.js +102 -0
- package/dist/lib/doctor/deep/classify.d.ts +61 -0
- package/dist/lib/doctor/deep/classify.js +75 -0
- package/dist/lib/doctor/deep/effects.d.ts +107 -0
- package/dist/lib/doctor/deep/effects.js +229 -0
- package/dist/lib/doctor/deep/executor.d.ts +112 -0
- package/dist/lib/doctor/deep/executor.js +369 -0
- package/dist/lib/doctor/deep/parity.d.ts +129 -0
- package/dist/lib/doctor/deep/parity.js +355 -0
- package/dist/lib/doctor/deep/sandbox.d.ts +190 -0
- package/dist/lib/doctor/deep/sandbox.js +572 -0
- package/dist/lib/doctor/fix/apply.d.ts +119 -0
- package/dist/lib/doctor/fix/apply.js +352 -0
- package/dist/lib/doctor/fix/backup.d.ts +40 -0
- package/dist/lib/doctor/fix/backup.js +64 -0
- package/dist/lib/doctor/fix/remediation.d.ts +71 -0
- package/dist/lib/doctor/fix/remediation.js +103 -0
- package/dist/lib/doctor/fixtures/discover.d.ts +96 -0
- package/dist/lib/doctor/fixtures/discover.js +287 -0
- package/dist/lib/doctor/fixtures/schema.d.ts +171 -0
- package/dist/lib/doctor/fixtures/schema.js +248 -0
- package/dist/lib/doctor/hook-gate-profiles.d.ts +55 -0
- package/dist/lib/doctor/hook-gate-profiles.js +107 -0
- package/dist/lib/doctor/json-output.d.ts +90 -0
- package/dist/lib/doctor/json-output.js +76 -0
- package/dist/lib/doctor/payload-shapes.d.ts +170 -0
- package/dist/lib/doctor/payload-shapes.js +275 -0
- package/dist/lib/doctor/platform.d.ts +244 -0
- package/dist/lib/doctor/platform.js +490 -0
- package/dist/lib/doctor/registry.d.ts +49 -0
- package/dist/lib/doctor/registry.js +176 -0
- package/dist/lib/doctor/report.d.ts +87 -0
- package/dist/lib/doctor/report.js +164 -0
- package/dist/lib/doctor/types.d.ts +87 -0
- package/dist/lib/doctor/types.js +29 -0
- package/dist/main.js +6 -0
- package/dist/utils/version-check.js +2 -2
- package/dist/utils/version-gate.d.ts +1 -1
- package/dist/utils/version-gate.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The doctor engine — an ordered set of check families — and the first family
|
|
3
|
+
* (hooks).
|
|
4
|
+
*
|
|
5
|
+
* The engine is deliberately ignorant of any specific family: `register`
|
|
6
|
+
* accepts any object implementing {@link CheckFamily}, and `run` iterates them
|
|
7
|
+
* generically. A new family (vault, sync, MCP, secrets, qmd freshness, …) is
|
|
8
|
+
* added by registering it — never by editing this class — which is exactly the
|
|
9
|
+
* property US-002 requires and later stories rely on.
|
|
10
|
+
*
|
|
11
|
+
* The hooks family here is intentionally a skeleton: it establishes the family
|
|
12
|
+
* so the wiring/parity/runtime checks (US-004+) have somewhere to live, and
|
|
13
|
+
* reports only the presence and validity of `.claude/settings.json`. Those
|
|
14
|
+
* later stories add per-item results to this family without touching the engine.
|
|
15
|
+
*/
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
import { checkCodexWiring } from "./checks/codex-wiring.js";
|
|
19
|
+
import { checkGrokWiring } from "./checks/grok-wiring.js";
|
|
20
|
+
import { checkRuntimeProbe } from "./checks/runtime-probe.js";
|
|
21
|
+
import { fixtureCoverageFamily } from "./fixtures/discover.js";
|
|
22
|
+
import { checkClaudeWiring } from "./checks/claude-wiring.js";
|
|
23
|
+
/**
|
|
24
|
+
* An ordered, id-keyed set of check families. Registration order is preserved
|
|
25
|
+
* (Map iteration order), and re-registering an id replaces the family in place
|
|
26
|
+
* rather than appending a duplicate.
|
|
27
|
+
*/
|
|
28
|
+
export class DoctorRegistry {
|
|
29
|
+
familiesById = new Map();
|
|
30
|
+
/** Register a family. Re-registering the same id replaces it, keeping order. */
|
|
31
|
+
register(family) {
|
|
32
|
+
this.familiesById.set(family.id, family);
|
|
33
|
+
}
|
|
34
|
+
/** The registered families, in registration order. */
|
|
35
|
+
families() {
|
|
36
|
+
return [...this.familiesById.values()];
|
|
37
|
+
}
|
|
38
|
+
/** Whether a family with this id is registered. */
|
|
39
|
+
has(id) {
|
|
40
|
+
return this.familiesById.has(id);
|
|
41
|
+
}
|
|
42
|
+
/** Run every family in order and collect grouped results. */
|
|
43
|
+
async run(context) {
|
|
44
|
+
const runs = [];
|
|
45
|
+
for (const family of this.families()) {
|
|
46
|
+
const results = await family.run(context);
|
|
47
|
+
runs.push({ family: { id: family.id, title: family.title }, results });
|
|
48
|
+
}
|
|
49
|
+
return runs;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** The id of the hooks family — the first, and for now only, check family. */
|
|
53
|
+
export const HOOKS_FAMILY_ID = "hooks";
|
|
54
|
+
/**
|
|
55
|
+
* The hooks check family. US-002 shipped the skeleton (Claude settings presence
|
|
56
|
+
* and validity). US-005 adds the Codex and Grok wiring/parity tiers, which run
|
|
57
|
+
* for every platform regardless of host. US-004+ extend the Claude tier with the
|
|
58
|
+
* full settings/registration wiring checks. Each tier appends its own results;
|
|
59
|
+
* the engine (DoctorRegistry) is untouched.
|
|
60
|
+
*/
|
|
61
|
+
export const hooksFamily = {
|
|
62
|
+
id: HOOKS_FAMILY_ID,
|
|
63
|
+
title: "Hook wiring",
|
|
64
|
+
run: (context) => Promise.resolve(runHooksFamily(context)),
|
|
65
|
+
};
|
|
66
|
+
function runHooksFamily(context) {
|
|
67
|
+
const results = [];
|
|
68
|
+
results.push(...runClaudeSettingsCheck(context));
|
|
69
|
+
// Codex and Grok wiring/parity (US-005) run for every platform regardless of
|
|
70
|
+
// host. Each tier is wrapped so a parse failure in one cannot crash the whole
|
|
71
|
+
// doctor run — a thrown tier degrades to an UNKNOWN result instead.
|
|
72
|
+
results.push(...safeTier("hooks.codex", () => checkCodexWiring(context)));
|
|
73
|
+
results.push(...safeTier("hooks.grok", () => checkGrokWiring(context)));
|
|
74
|
+
// The runtime probe (US-006) is the live tier: it reads the policy-trigger
|
|
75
|
+
// ledger to report whether hooks actually FIRED on the detected host, catching
|
|
76
|
+
// an app/SDK runtime that loads hook config correctly and then never dispatches
|
|
77
|
+
// it. Runs last so the wiring picture is reported before the runtime verdict.
|
|
78
|
+
results.push(...safeTier("hooks.runtime", () => checkRuntimeProbe(context)));
|
|
79
|
+
return results;
|
|
80
|
+
}
|
|
81
|
+
/** Run one hooks tier, converting an unexpected throw into an UNKNOWN result. */
|
|
82
|
+
function safeTier(idPrefix, run) {
|
|
83
|
+
try {
|
|
84
|
+
return run();
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
return [
|
|
88
|
+
{
|
|
89
|
+
status: "UNKNOWN",
|
|
90
|
+
checkId: `${idPrefix}.error`,
|
|
91
|
+
message: `${idPrefix} checks could not run: ${error.message}`,
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function runClaudeSettingsCheck(context) {
|
|
97
|
+
const settingsPath = path.join(context.hqRoot, ".claude", "settings.json");
|
|
98
|
+
if (!fs.existsSync(settingsPath)) {
|
|
99
|
+
return [
|
|
100
|
+
{
|
|
101
|
+
status: "FAIL",
|
|
102
|
+
checkId: "hooks.settings-present",
|
|
103
|
+
target: settingsPath,
|
|
104
|
+
message: ".claude/settings.json is missing — no Claude hooks can be wired.",
|
|
105
|
+
remediation: "Restore it, e.g. `hq rescue -y --paths .claude`.",
|
|
106
|
+
},
|
|
107
|
+
];
|
|
108
|
+
}
|
|
109
|
+
let parsed;
|
|
110
|
+
try {
|
|
111
|
+
parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return [
|
|
115
|
+
{
|
|
116
|
+
status: "FAIL",
|
|
117
|
+
checkId: "hooks.settings-valid-json",
|
|
118
|
+
target: settingsPath,
|
|
119
|
+
message: ".claude/settings.json is present but is not valid JSON.",
|
|
120
|
+
},
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
const count = countHookRegistrations(parsed);
|
|
124
|
+
return [
|
|
125
|
+
{
|
|
126
|
+
status: "PASS",
|
|
127
|
+
checkId: "hooks.settings-present",
|
|
128
|
+
target: settingsPath,
|
|
129
|
+
message: `.claude/settings.json is present and valid (${count} hook registration${count === 1 ? "" : "s"}).`,
|
|
130
|
+
},
|
|
131
|
+
// US-004+: the settings file is present and valid, so the full Claude wiring
|
|
132
|
+
// checks (script existence and executability, orphaned scripts, three-profile
|
|
133
|
+
// membership, unquoted $CLAUDE_PROJECT_DIR, and core/hooks/<Event>/ executable
|
|
134
|
+
// bits) extend the hooks family here without touching the engine.
|
|
135
|
+
...checkClaudeWiring(context),
|
|
136
|
+
];
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Count command-hook registrations across every lifecycle event in a parsed
|
|
140
|
+
* `.claude/settings.json`. Tolerant of malformed shapes — any non-conforming
|
|
141
|
+
* branch contributes zero rather than throwing.
|
|
142
|
+
*/
|
|
143
|
+
function countHookRegistrations(settings) {
|
|
144
|
+
if (!settings || typeof settings !== "object")
|
|
145
|
+
return 0;
|
|
146
|
+
const hooks = settings.hooks;
|
|
147
|
+
if (!hooks || typeof hooks !== "object")
|
|
148
|
+
return 0;
|
|
149
|
+
let count = 0;
|
|
150
|
+
for (const entries of Object.values(hooks)) {
|
|
151
|
+
if (!Array.isArray(entries))
|
|
152
|
+
continue;
|
|
153
|
+
for (const entry of entries) {
|
|
154
|
+
const inner = entry?.hooks;
|
|
155
|
+
if (Array.isArray(inner))
|
|
156
|
+
count += inner.length;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return count;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Build a registry pre-loaded with the default families. Hooks is the first
|
|
163
|
+
* family; later families are registered here as they are implemented, each
|
|
164
|
+
* one line, with no engine change.
|
|
165
|
+
*/
|
|
166
|
+
export function createDefaultRegistry() {
|
|
167
|
+
const registry = new DoctorRegistry();
|
|
168
|
+
registry.register(hooksFamily);
|
|
169
|
+
// Fixture coverage (US-007): reports UNTESTED for every registered hook with
|
|
170
|
+
// no fixture and prints the tested/total coverage line. A separate family so
|
|
171
|
+
// it needs no change to the hooks tier, per the registry's extensibility
|
|
172
|
+
// contract.
|
|
173
|
+
registry.register(fixtureCoverageFamily);
|
|
174
|
+
return registry;
|
|
175
|
+
}
|
|
176
|
+
//# sourceMappingURL=registry.js.map
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reporting for `hq doctor`: status counting, the exit-code contract, and the
|
|
3
|
+
* TTY-aware plain-text renderer.
|
|
4
|
+
*
|
|
5
|
+
* Three properties here are load-bearing and deliberately decoupled from any
|
|
6
|
+
* one check family:
|
|
7
|
+
*
|
|
8
|
+
* 1. The exit code is a function of the *results*, not of any family's
|
|
9
|
+
* internal logic: exit 1 iff some result is FAIL or UNKNOWN. WARN,
|
|
10
|
+
* UNTESTED, NA, and KNOWN-DEFECT never fail the command — a doctor that
|
|
11
|
+
* exits non-zero on a healthy install trains users to ignore it, which is
|
|
12
|
+
* the exact failure mode `hq doctor` exists to prevent.
|
|
13
|
+
*
|
|
14
|
+
* 2. UNTESTED and KNOWN-DEFECT are always visible and always counted
|
|
15
|
+
* *separately* from PASS. A wired-but-never-exercised hook (UNTESTED) or a
|
|
16
|
+
* tracked-but-unfixed defect (KNOWN-DEFECT) must never blend into the pass
|
|
17
|
+
* pile, because that is how a correct-looking setup reads as working
|
|
18
|
+
* enforcement when it is not.
|
|
19
|
+
*
|
|
20
|
+
* 3. Colour is opt-in per render (`color`), never auto-detected here. The
|
|
21
|
+
* command layer decides from `process.stdout.isTTY`; when colour is off the
|
|
22
|
+
* output carries no ANSI escape sequences at all, so a run piped to a file
|
|
23
|
+
* or consumed by CI has the same information content as an interactive one.
|
|
24
|
+
*
|
|
25
|
+
* The platform field is a placeholder in this story (US-015). Host platform
|
|
26
|
+
* detection is US-003; until it lands, {@link UNKNOWN_PLATFORM} is emitted and
|
|
27
|
+
* US-003 injects the real value through the same `platform` option.
|
|
28
|
+
*/
|
|
29
|
+
import { type CheckResult, type DoctorStatus, type FamilyRun } from "./types.js";
|
|
30
|
+
/**
|
|
31
|
+
* The detected agent platform and the evidence for the verdict. A structural
|
|
32
|
+
* placeholder in US-015: host platform detection (US-003) produces the real
|
|
33
|
+
* value and passes it through unchanged. `evidence` is intentionally `unknown`
|
|
34
|
+
* so US-003 can attach whatever signal shape it settles on without a breaking
|
|
35
|
+
* change here.
|
|
36
|
+
*/
|
|
37
|
+
export interface DoctorPlatform {
|
|
38
|
+
/** Platform id, e.g. `claude-code-cli`, `codex`, `grok`, or `unknown`. */
|
|
39
|
+
id: string;
|
|
40
|
+
/** Evidence that led to the verdict. Shape owned by US-003. */
|
|
41
|
+
evidence?: unknown;
|
|
42
|
+
}
|
|
43
|
+
/** The placeholder platform emitted until host detection (US-003) lands. */
|
|
44
|
+
export declare const UNKNOWN_PLATFORM: DoctorPlatform;
|
|
45
|
+
/** Per-status tallies across every result of a run. */
|
|
46
|
+
export type StatusCounts = Record<DoctorStatus, number>;
|
|
47
|
+
/**
|
|
48
|
+
* The statuses that fail the command. This is the whole exit-code contract:
|
|
49
|
+
* only FAIL and UNKNOWN are here. UNKNOWN counts as failing because "could not
|
|
50
|
+
* be determined" must never be mistaken for "verified working".
|
|
51
|
+
*/
|
|
52
|
+
export declare const FAILING_STATUSES: readonly DoctorStatus[];
|
|
53
|
+
/** Whether a status fails the command per {@link FAILING_STATUSES}. */
|
|
54
|
+
export declare function isFailingStatus(status: DoctorStatus): boolean;
|
|
55
|
+
/** Flatten grouped family runs into a single ordered result list. */
|
|
56
|
+
export declare function flattenFamilies(families: FamilyRun[]): CheckResult[];
|
|
57
|
+
/** A zeroed {@link StatusCounts} with an entry for every status. */
|
|
58
|
+
export declare function emptyStatusCounts(): StatusCounts;
|
|
59
|
+
/** Tally results by status across all families. */
|
|
60
|
+
export declare function summarize(families: FamilyRun[]): StatusCounts;
|
|
61
|
+
/**
|
|
62
|
+
* The process exit code for a run: 1 if any result is FAIL or UNKNOWN, else 0.
|
|
63
|
+
* Independent of rendering so callers and tests can assert the contract without
|
|
64
|
+
* parsing output.
|
|
65
|
+
*/
|
|
66
|
+
export declare function computeExitCode(families: FamilyRun[]): number;
|
|
67
|
+
/** Options for {@link renderText}. */
|
|
68
|
+
export interface RenderTextOptions {
|
|
69
|
+
/** The resolved HQ root, printed in the header. */
|
|
70
|
+
hqRoot: string;
|
|
71
|
+
/** Grouped per-family results, rendered in order. */
|
|
72
|
+
families: FamilyRun[];
|
|
73
|
+
/** Detected platform. Defaults to {@link UNKNOWN_PLATFORM}. */
|
|
74
|
+
platform?: DoctorPlatform;
|
|
75
|
+
/** Also print every PASS result. Default: false. */
|
|
76
|
+
verbose?: boolean;
|
|
77
|
+
/** Emit ANSI colour. Default: false (no escape sequences). */
|
|
78
|
+
color?: boolean;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Render the plain-text report. Results are grouped by family; PASS results are
|
|
82
|
+
* hidden unless `verbose`, while every non-PASS result — including UNTESTED and
|
|
83
|
+
* KNOWN-DEFECT — is always shown. A summary block reports per-status counts and
|
|
84
|
+
* states the exit outcome.
|
|
85
|
+
*/
|
|
86
|
+
export declare function renderText(options: RenderTextOptions): string;
|
|
87
|
+
//# sourceMappingURL=report.d.ts.map
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reporting for `hq doctor`: status counting, the exit-code contract, and the
|
|
3
|
+
* TTY-aware plain-text renderer.
|
|
4
|
+
*
|
|
5
|
+
* Three properties here are load-bearing and deliberately decoupled from any
|
|
6
|
+
* one check family:
|
|
7
|
+
*
|
|
8
|
+
* 1. The exit code is a function of the *results*, not of any family's
|
|
9
|
+
* internal logic: exit 1 iff some result is FAIL or UNKNOWN. WARN,
|
|
10
|
+
* UNTESTED, NA, and KNOWN-DEFECT never fail the command — a doctor that
|
|
11
|
+
* exits non-zero on a healthy install trains users to ignore it, which is
|
|
12
|
+
* the exact failure mode `hq doctor` exists to prevent.
|
|
13
|
+
*
|
|
14
|
+
* 2. UNTESTED and KNOWN-DEFECT are always visible and always counted
|
|
15
|
+
* *separately* from PASS. A wired-but-never-exercised hook (UNTESTED) or a
|
|
16
|
+
* tracked-but-unfixed defect (KNOWN-DEFECT) must never blend into the pass
|
|
17
|
+
* pile, because that is how a correct-looking setup reads as working
|
|
18
|
+
* enforcement when it is not.
|
|
19
|
+
*
|
|
20
|
+
* 3. Colour is opt-in per render (`color`), never auto-detected here. The
|
|
21
|
+
* command layer decides from `process.stdout.isTTY`; when colour is off the
|
|
22
|
+
* output carries no ANSI escape sequences at all, so a run piped to a file
|
|
23
|
+
* or consumed by CI has the same information content as an interactive one.
|
|
24
|
+
*
|
|
25
|
+
* The platform field is a placeholder in this story (US-015). Host platform
|
|
26
|
+
* detection is US-003; until it lands, {@link UNKNOWN_PLATFORM} is emitted and
|
|
27
|
+
* US-003 injects the real value through the same `platform` option.
|
|
28
|
+
*/
|
|
29
|
+
import { Chalk } from "chalk";
|
|
30
|
+
import { DOCTOR_STATUSES, } from "./types.js";
|
|
31
|
+
import { FIXTURE_COVERAGE_CHECK_ID } from "./fixtures/discover.js";
|
|
32
|
+
/** The placeholder platform emitted until host detection (US-003) lands. */
|
|
33
|
+
export const UNKNOWN_PLATFORM = { id: "unknown" };
|
|
34
|
+
/**
|
|
35
|
+
* The statuses that fail the command. This is the whole exit-code contract:
|
|
36
|
+
* only FAIL and UNKNOWN are here. UNKNOWN counts as failing because "could not
|
|
37
|
+
* be determined" must never be mistaken for "verified working".
|
|
38
|
+
*/
|
|
39
|
+
export const FAILING_STATUSES = ["FAIL", "UNKNOWN"];
|
|
40
|
+
/** Whether a status fails the command per {@link FAILING_STATUSES}. */
|
|
41
|
+
export function isFailingStatus(status) {
|
|
42
|
+
return FAILING_STATUSES.includes(status);
|
|
43
|
+
}
|
|
44
|
+
/** Flatten grouped family runs into a single ordered result list. */
|
|
45
|
+
export function flattenFamilies(families) {
|
|
46
|
+
return families.flatMap((run) => run.results);
|
|
47
|
+
}
|
|
48
|
+
/** A zeroed {@link StatusCounts} with an entry for every status. */
|
|
49
|
+
export function emptyStatusCounts() {
|
|
50
|
+
const counts = {};
|
|
51
|
+
for (const status of DOCTOR_STATUSES)
|
|
52
|
+
counts[status] = 0;
|
|
53
|
+
return counts;
|
|
54
|
+
}
|
|
55
|
+
/** Tally results by status across all families. */
|
|
56
|
+
export function summarize(families) {
|
|
57
|
+
const counts = emptyStatusCounts();
|
|
58
|
+
for (const result of flattenFamilies(families))
|
|
59
|
+
counts[result.status] += 1;
|
|
60
|
+
return counts;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The process exit code for a run: 1 if any result is FAIL or UNKNOWN, else 0.
|
|
64
|
+
* Independent of rendering so callers and tests can assert the contract without
|
|
65
|
+
* parsing output.
|
|
66
|
+
*/
|
|
67
|
+
export function computeExitCode(families) {
|
|
68
|
+
return flattenFamilies(families).some((result) => isFailingStatus(result.status))
|
|
69
|
+
? 1
|
|
70
|
+
: 0;
|
|
71
|
+
}
|
|
72
|
+
/** Width the status label is padded to so result lines align. */
|
|
73
|
+
const STATUS_LABEL_WIDTH = Math.max(...DOCTOR_STATUSES.map((s) => s.length));
|
|
74
|
+
/**
|
|
75
|
+
* Build a status colouriser. When `color` is false a level-0 Chalk instance is
|
|
76
|
+
* used, which returns its input verbatim — guaranteeing zero ANSI output on the
|
|
77
|
+
* non-TTY path regardless of the ambient environment.
|
|
78
|
+
*/
|
|
79
|
+
function buildPainter(color) {
|
|
80
|
+
const chalk = new Chalk({ level: color ? 1 : 0 });
|
|
81
|
+
const paint = {
|
|
82
|
+
PASS: (text) => chalk.green(text),
|
|
83
|
+
FAIL: (text) => chalk.red(text),
|
|
84
|
+
WARN: (text) => chalk.yellow(text),
|
|
85
|
+
UNTESTED: (text) => chalk.cyan(text),
|
|
86
|
+
NA: (text) => chalk.gray(text),
|
|
87
|
+
UNKNOWN: (text) => chalk.magenta(text),
|
|
88
|
+
"KNOWN-DEFECT": (text) => chalk.blue(text),
|
|
89
|
+
};
|
|
90
|
+
return (status, text) => paint[status](text);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Render the plain-text report. Results are grouped by family; PASS results are
|
|
94
|
+
* hidden unless `verbose`, while every non-PASS result — including UNTESTED and
|
|
95
|
+
* KNOWN-DEFECT — is always shown. A summary block reports per-status counts and
|
|
96
|
+
* states the exit outcome.
|
|
97
|
+
*/
|
|
98
|
+
export function renderText(options) {
|
|
99
|
+
const { hqRoot, families } = options;
|
|
100
|
+
const platform = options.platform ?? UNKNOWN_PLATFORM;
|
|
101
|
+
const verbose = options.verbose ?? false;
|
|
102
|
+
const paint = buildPainter(options.color ?? false);
|
|
103
|
+
const pad = " ".repeat(STATUS_LABEL_WIDTH);
|
|
104
|
+
const lines = [];
|
|
105
|
+
lines.push(`hq doctor — HQ root: ${hqRoot}`);
|
|
106
|
+
lines.push(`Platform: ${platform.id}`);
|
|
107
|
+
for (const run of families) {
|
|
108
|
+
lines.push("");
|
|
109
|
+
lines.push(`${run.family.title} (${run.family.id})`);
|
|
110
|
+
if (run.results.length === 0) {
|
|
111
|
+
lines.push(" (no results)");
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const visible = run.results.filter((result) => verbose || result.status !== "PASS");
|
|
115
|
+
if (visible.length === 0) {
|
|
116
|
+
const n = run.results.length;
|
|
117
|
+
lines.push(` all ${n} check${n === 1 ? "" : "s"} passing (use --verbose to list)`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
for (const result of visible) {
|
|
121
|
+
const label = paint(result.status, result.status.padEnd(STATUS_LABEL_WIDTH));
|
|
122
|
+
const target = result.target ? ` [${result.target}]` : "";
|
|
123
|
+
lines.push(` ${label} ${result.checkId}${target}: ${result.message}`);
|
|
124
|
+
if (result.remediation) {
|
|
125
|
+
lines.push(` ${pad} remediation: ${result.remediation}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
lines.push("");
|
|
130
|
+
lines.push(renderSummary(families, paint));
|
|
131
|
+
return lines.join("\n") + "\n";
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The summary block: a per-status count line in canonical status order, an
|
|
135
|
+
* explicit note that UNTESTED and KNOWN-DEFECT are never counted as passing,
|
|
136
|
+
* and the exit outcome. Rendered from {@link summarize}/{@link computeExitCode}
|
|
137
|
+
* so it can never disagree with the machine-readable contract.
|
|
138
|
+
*/
|
|
139
|
+
function renderSummary(families, paint) {
|
|
140
|
+
const counts = summarize(families);
|
|
141
|
+
const total = DOCTOR_STATUSES.reduce((sum, status) => sum + counts[status], 0);
|
|
142
|
+
const parts = DOCTOR_STATUSES.map((status) => `${paint(status, status)} ${counts[status]}`);
|
|
143
|
+
const lines = [
|
|
144
|
+
`Summary (${total} check${total === 1 ? "" : "s"}): ${parts.join(" ")}`,
|
|
145
|
+
`UNTESTED (${counts.UNTESTED}) and KNOWN-DEFECT (${counts["KNOWN-DEFECT"]}) ` +
|
|
146
|
+
`are reported but never counted as passing.`,
|
|
147
|
+
];
|
|
148
|
+
// Surface the fixture-coverage ratio (US-007) in the summary — the forcing
|
|
149
|
+
// function that keeps a mostly-unfixtured doctor from reading as a clean bill
|
|
150
|
+
// of health. Emitted by the fixture-coverage family; absent only when that
|
|
151
|
+
// family did not run.
|
|
152
|
+
const coverage = flattenFamilies(families).find((result) => result.checkId === FIXTURE_COVERAGE_CHECK_ID);
|
|
153
|
+
if (coverage)
|
|
154
|
+
lines.push(coverage.message);
|
|
155
|
+
if (computeExitCode(families) === 0) {
|
|
156
|
+
lines.push("Result: OK — no FAIL or UNKNOWN results (exit 0).");
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
lines.push(`Result: ATTENTION — ${counts.FAIL} FAIL, ${counts.UNKNOWN} UNKNOWN ` +
|
|
160
|
+
`(exit 1).`);
|
|
161
|
+
}
|
|
162
|
+
return lines.join("\n");
|
|
163
|
+
}
|
|
164
|
+
//# sourceMappingURL=report.js.map
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core doctor types: the status vocabulary and the check-registry contract.
|
|
3
|
+
*
|
|
4
|
+
* The status vocabulary is the load-bearing design decision of `hq doctor`.
|
|
5
|
+
* UNTESTED (wired but never exercised), NA (untestable on this platform),
|
|
6
|
+
* UNKNOWN (could not be determined), and KNOWN-DEFECT (tracked and unfixed) must
|
|
7
|
+
* never collapse into PASS — that separation is what keeps the tool honest and
|
|
8
|
+
* stops a correct-looking configuration from reading as working enforcement.
|
|
9
|
+
*
|
|
10
|
+
* `DoctorStatus` is derived from the `DOCTOR_STATUSES` tuple, so the union is
|
|
11
|
+
* closed: any consumer that assigns a value outside the tuple is a compile
|
|
12
|
+
* error, and the vocabulary can only grow by editing the tuple in one place.
|
|
13
|
+
*/
|
|
14
|
+
/** The complete, fixed status vocabulary, in canonical display order. */
|
|
15
|
+
export declare const DOCTOR_STATUSES: readonly ["PASS", "FAIL", "WARN", "UNTESTED", "NA", "UNKNOWN", "KNOWN-DEFECT"];
|
|
16
|
+
/**
|
|
17
|
+
* A doctor result status. Closed union derived from {@link DOCTOR_STATUSES};
|
|
18
|
+
* the type system rejects any other string at every call site.
|
|
19
|
+
*/
|
|
20
|
+
export type DoctorStatus = (typeof DOCTOR_STATUSES)[number];
|
|
21
|
+
/** Runtime type guard: true only for a member of the fixed vocabulary. */
|
|
22
|
+
export declare function isDoctorStatus(value: unknown): value is DoctorStatus;
|
|
23
|
+
/** A single per-item result produced by a check family. */
|
|
24
|
+
export interface CheckResult {
|
|
25
|
+
/** Status drawn from the fixed vocabulary. */
|
|
26
|
+
status: DoctorStatus;
|
|
27
|
+
/** Stable machine id for this item, e.g. `hooks.settings-present`. */
|
|
28
|
+
checkId: string;
|
|
29
|
+
/** What was inspected — a path, hook id, etc. Optional. */
|
|
30
|
+
target?: string;
|
|
31
|
+
/** Human-readable one-line explanation of the result. */
|
|
32
|
+
message: string;
|
|
33
|
+
/** Optional remediation hint (a command or instruction). */
|
|
34
|
+
remediation?: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The context handed to every check family's `run`. The resolved HQ root is
|
|
38
|
+
* exposed here so no family re-implements tree discovery; later stories extend
|
|
39
|
+
* this interface (e.g. detected platform, session id) without touching the
|
|
40
|
+
* engine, since adding an optional field is non-breaking.
|
|
41
|
+
*/
|
|
42
|
+
export interface CheckContext {
|
|
43
|
+
/** Absolute, resolved HQ tree root every check reads from. */
|
|
44
|
+
hqRoot: string;
|
|
45
|
+
/**
|
|
46
|
+
* The detected host platform (id, and optionally the evidence behind it).
|
|
47
|
+
* Host-specific live checks — the US-006 runtime probe — key their
|
|
48
|
+
* UNKNOWN/FAIL behaviour off this. Absent means host detection has not run and
|
|
49
|
+
* is treated as `unknown`. Kept structurally minimal so the engine does not
|
|
50
|
+
* depend on the platform module.
|
|
51
|
+
*/
|
|
52
|
+
platform?: {
|
|
53
|
+
id: string;
|
|
54
|
+
evidence?: unknown;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* The session id to scope runtime/ledger checks to (US-006 `--session-id`),
|
|
58
|
+
* so an older session's ledger cannot be mistaken for the current runtime.
|
|
59
|
+
* Absent means "any session's ledger counts".
|
|
60
|
+
*/
|
|
61
|
+
sessionId?: string;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* A check family: an id, a human title, and an async `run` returning per-item
|
|
65
|
+
* results. Adding a new family (vault, sync, MCP, secrets, qmd freshness, …)
|
|
66
|
+
* means implementing this interface and registering the object — the engine
|
|
67
|
+
* needs no changes.
|
|
68
|
+
*/
|
|
69
|
+
export interface CheckFamily {
|
|
70
|
+
/** Stable family id, e.g. `hooks`. Unique within a registry. */
|
|
71
|
+
id: string;
|
|
72
|
+
/** Human-readable family title for grouped output. */
|
|
73
|
+
title: string;
|
|
74
|
+
/** Run this family's checks against the resolved tree. */
|
|
75
|
+
run: (context: CheckContext) => Promise<CheckResult[]>;
|
|
76
|
+
}
|
|
77
|
+
/** The grouped results of running one family. */
|
|
78
|
+
export interface FamilyRun {
|
|
79
|
+
/** Identifying metadata for the family that produced these results. */
|
|
80
|
+
family: {
|
|
81
|
+
id: string;
|
|
82
|
+
title: string;
|
|
83
|
+
};
|
|
84
|
+
/** The per-item results the family returned. */
|
|
85
|
+
results: CheckResult[];
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core doctor types: the status vocabulary and the check-registry contract.
|
|
3
|
+
*
|
|
4
|
+
* The status vocabulary is the load-bearing design decision of `hq doctor`.
|
|
5
|
+
* UNTESTED (wired but never exercised), NA (untestable on this platform),
|
|
6
|
+
* UNKNOWN (could not be determined), and KNOWN-DEFECT (tracked and unfixed) must
|
|
7
|
+
* never collapse into PASS — that separation is what keeps the tool honest and
|
|
8
|
+
* stops a correct-looking configuration from reading as working enforcement.
|
|
9
|
+
*
|
|
10
|
+
* `DoctorStatus` is derived from the `DOCTOR_STATUSES` tuple, so the union is
|
|
11
|
+
* closed: any consumer that assigns a value outside the tuple is a compile
|
|
12
|
+
* error, and the vocabulary can only grow by editing the tuple in one place.
|
|
13
|
+
*/
|
|
14
|
+
/** The complete, fixed status vocabulary, in canonical display order. */
|
|
15
|
+
export const DOCTOR_STATUSES = [
|
|
16
|
+
"PASS",
|
|
17
|
+
"FAIL",
|
|
18
|
+
"WARN",
|
|
19
|
+
"UNTESTED",
|
|
20
|
+
"NA",
|
|
21
|
+
"UNKNOWN",
|
|
22
|
+
"KNOWN-DEFECT",
|
|
23
|
+
];
|
|
24
|
+
/** Runtime type guard: true only for a member of the fixed vocabulary. */
|
|
25
|
+
export function isDoctorStatus(value) {
|
|
26
|
+
return (typeof value === "string" &&
|
|
27
|
+
DOCTOR_STATUSES.includes(value));
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=types.js.map
|
package/dist/main.js
CHANGED
|
@@ -60,6 +60,7 @@ import { registerDbCommand } from "./commands/db.js";
|
|
|
60
60
|
import { registerCoreCommands } from "./commands/core.js";
|
|
61
61
|
import { registerSearchCommand } from "./commands/search.js";
|
|
62
62
|
import { registerIndexCommand } from "./commands/index-cmd.js";
|
|
63
|
+
import { registerDoctorCommand } from "./commands/doctor.js";
|
|
63
64
|
import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
|
|
64
65
|
import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
|
|
65
66
|
import { networkTransportErrorMessage } from "./utils/network-transport-error.js";
|
|
@@ -237,6 +238,11 @@ registerCoreCommands(program);
|
|
|
237
238
|
// which converges scaffold-owned files and hooks rather than search data.
|
|
238
239
|
registerSearchCommand(program);
|
|
239
240
|
registerIndexCommand(program);
|
|
241
|
+
// Hook guardrail diagnostics (top-level — `hq doctor`). Read-only, offline
|
|
242
|
+
// verification that HQ's hooks are wired and firing, backed by an extensible
|
|
243
|
+
// check registry so later check families (vault, sync, MCP, …) plug in without
|
|
244
|
+
// engine changes.
|
|
245
|
+
registerDoctorCommand(program);
|
|
240
246
|
program.hook("preAction", async () => {
|
|
241
247
|
await emitCliSessionStarted();
|
|
242
248
|
});
|
|
@@ -5,8 +5,8 @@ import semver from "semver";
|
|
|
5
5
|
import { CLI_VERSION } from "../cli-version.js";
|
|
6
6
|
const PACKAGE_NAME = "@indigoai-us/hq-cli";
|
|
7
7
|
const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}/latest`;
|
|
8
|
-
const CACHE_TTL_MS =
|
|
9
|
-
const CACHE_TTL_JITTER_MS =
|
|
8
|
+
const CACHE_TTL_MS = 60 * 60 * 1000; // 1h — catch fresh releases within the hour
|
|
9
|
+
const CACHE_TTL_JITTER_MS = 5 * 60 * 1000; // up to 5m early, to spread a fleet's refreshes off a single instant
|
|
10
10
|
const FETCH_TIMEOUT_MS = 3_000;
|
|
11
11
|
const REFRESH_LOCK_STALE_MS = 10 * 60 * 1000;
|
|
12
12
|
function cachePath() {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Why both?
|
|
9
9
|
* - `version-check.ts` answers "is there something newer?" by polling npm
|
|
10
|
-
* directly. It's a soft hint, lives on a
|
|
10
|
+
* directly. It's a soft hint, lives on a 1h cache, and never blocks.
|
|
11
11
|
* - `version-gate.ts` answers "is the team currently allowing your version
|
|
12
12
|
* to run?" via an authoritative hq-pro endpoint. The server can yank a
|
|
13
13
|
* known-bad release without waiting for the npm `latest` dist-tag move.
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Why both?
|
|
9
9
|
* - `version-check.ts` answers "is there something newer?" by polling npm
|
|
10
|
-
* directly. It's a soft hint, lives on a
|
|
10
|
+
* directly. It's a soft hint, lives on a 1h cache, and never blocks.
|
|
11
11
|
* - `version-gate.ts` answers "is the team currently allowing your version
|
|
12
12
|
* to run?" via an authoritative hq-pro endpoint. The server can yank a
|
|
13
13
|
* known-bad release without waiting for the npm `latest` dist-tag move.
|