@indigoai-us/hq-cli 5.98.2 → 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 +52 -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/hook-trust.d.ts +10 -13
- package/dist/utils/hook-trust.js +148 -27
- 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 +2 -2
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq doctor` — verify HQ's hook guardrails are wired and firing.
|
|
3
|
+
*
|
|
4
|
+
* US-002 builds the command skeleton and the extensible check registry:
|
|
5
|
+
* - HQ root resolution walks up from the working directory looking for the
|
|
6
|
+
* marker directories that identify an HQ tree, and exposes the resolved root
|
|
7
|
+
* to every check.
|
|
8
|
+
* - Running outside any HQ tree exits non-zero with a message naming exactly
|
|
9
|
+
* what it looked for — never a throw, never a false PASS.
|
|
10
|
+
* - The command performs no network calls and needs no authentication; it is
|
|
11
|
+
* purely a function of the on-disk shape of the tree.
|
|
12
|
+
*
|
|
13
|
+
* US-015 adds reporting, `--json`, and the exit-code contract: the exit code is
|
|
14
|
+
* 0 unless some result is FAIL or UNKNOWN (WARN/UNTESTED/NA/KNOWN-DEFECT never
|
|
15
|
+
* fail); `--verbose` also prints PASS results; `--json` emits the versioned,
|
|
16
|
+
* machine-readable document; and text output carries no ANSI when stdout is not
|
|
17
|
+
* a TTY. The rendering and contract live in ../lib/doctor/report.ts and
|
|
18
|
+
* ../lib/doctor/json-output.ts; this command wires them to the CLI.
|
|
19
|
+
*
|
|
20
|
+
* The detected platform is a placeholder (UNKNOWN_PLATFORM) until host platform
|
|
21
|
+
* detection (US-003) lands and injects the real value through `platform`.
|
|
22
|
+
*/
|
|
23
|
+
import * as fs from "node:fs";
|
|
24
|
+
import * as path from "node:path";
|
|
25
|
+
import { createDefaultRegistry, } from "../lib/doctor/registry.js";
|
|
26
|
+
import { computeExitCode, renderText, UNKNOWN_PLATFORM, } from "../lib/doctor/report.js";
|
|
27
|
+
import { buildDoctorJson, renderJson } from "../lib/doctor/json-output.js";
|
|
28
|
+
import { detectPlatform } from "../lib/doctor/platform.js";
|
|
29
|
+
import { DEEP_FAMILY_ID, DEEP_FAMILY_TITLE, runDeepGuardTests, } from "../lib/doctor/deep/executor.js";
|
|
30
|
+
import { PARITY_FAMILY_ID, PARITY_FAMILY_TITLE, runParityReplay, } from "../lib/doctor/deep/parity.js";
|
|
31
|
+
import { applyFixes } from "../lib/doctor/fix/apply.js";
|
|
32
|
+
import * as readline from "node:readline";
|
|
33
|
+
import { runSideEffectTests } from "../lib/doctor/deep/sandbox.js";
|
|
34
|
+
/**
|
|
35
|
+
* Marker directories that identify an HQ tree root. A directory is an HQ root
|
|
36
|
+
* only when it contains ALL of these. `.claude` + `core` are present in both
|
|
37
|
+
* the real HQ tree and the synthetic fixture trees the doctor is tested
|
|
38
|
+
* against, and are absent from an arbitrary directory — so requiring both
|
|
39
|
+
* avoids a false positive on a random repo that merely carries a `.claude/`
|
|
40
|
+
* folder.
|
|
41
|
+
*/
|
|
42
|
+
export const HQ_ROOT_MARKERS = [".claude", "core"];
|
|
43
|
+
/**
|
|
44
|
+
* Walk up from `startDir` (default: the current working directory) looking for
|
|
45
|
+
* the nearest ancestor that contains every {@link HQ_ROOT_MARKERS} entry as a
|
|
46
|
+
* directory. Returns the resolved (realpath'd) root, or `null` when no ancestor
|
|
47
|
+
* qualifies — i.e. the caller is not inside an HQ tree.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveHqRoot(startDir = process.cwd()) {
|
|
50
|
+
let dir;
|
|
51
|
+
try {
|
|
52
|
+
dir = fs.realpathSync(startDir);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
dir = path.resolve(startDir);
|
|
56
|
+
}
|
|
57
|
+
// Walk to the filesystem root. `path.dirname("/") === "/"` is the loop's
|
|
58
|
+
// terminal fixed point.
|
|
59
|
+
for (;;) {
|
|
60
|
+
if (isHqRoot(dir))
|
|
61
|
+
return dir;
|
|
62
|
+
const parent = path.dirname(dir);
|
|
63
|
+
if (parent === dir)
|
|
64
|
+
return null;
|
|
65
|
+
dir = parent;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function isHqRoot(dir) {
|
|
69
|
+
return HQ_ROOT_MARKERS.every((marker) => {
|
|
70
|
+
try {
|
|
71
|
+
return fs.statSync(path.join(dir, marker)).isDirectory();
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Resolve the HQ root, run every registered check family against it, and render
|
|
80
|
+
* a plain-text summary. Makes no network calls and needs no authentication.
|
|
81
|
+
*/
|
|
82
|
+
export async function runDoctor(options = {}) {
|
|
83
|
+
const write = options.stdout ?? ((chunk) => void process.stdout.write(chunk));
|
|
84
|
+
const writeErr = options.stderr ?? ((chunk) => void process.stderr.write(chunk));
|
|
85
|
+
const hqRoot = options.root ?? resolveHqRoot(options.cwd);
|
|
86
|
+
if (!hqRoot) {
|
|
87
|
+
const from = options.cwd ?? process.cwd();
|
|
88
|
+
writeErr(`hq doctor: not inside an HQ tree.\n` +
|
|
89
|
+
` Searched upward from: ${from}\n` +
|
|
90
|
+
` Looking for a directory containing all of: ${HQ_ROOT_MARKERS.join(", ")}\n` +
|
|
91
|
+
` Run hq doctor from inside your HQ root.\n`);
|
|
92
|
+
return { exitCode: 1, hqRoot: null, families: [] };
|
|
93
|
+
}
|
|
94
|
+
const registry = options.registry ?? createDefaultRegistry();
|
|
95
|
+
const platform = options.platform ?? UNKNOWN_PLATFORM;
|
|
96
|
+
// The detected platform and the session id are exposed to every check so the
|
|
97
|
+
// host-specific runtime probe (US-006) can decide UNKNOWN vs FAIL vs UNTESTED.
|
|
98
|
+
const context = {
|
|
99
|
+
hqRoot,
|
|
100
|
+
platform: { id: platform.id, evidence: platform.evidence },
|
|
101
|
+
sessionId: options.sessionId,
|
|
102
|
+
};
|
|
103
|
+
const families = await registry.run(context);
|
|
104
|
+
// `--deep-test` (US-008): after the read-only tiers, actually fire pure-guard
|
|
105
|
+
// fixtures through the real gate in a sandbox and append the verdicts as their
|
|
106
|
+
// own family. Run only when asked — appending here, not in the default
|
|
107
|
+
// registry, is what keeps `hq doctor` from ever spawning a hook without the
|
|
108
|
+
// flag. Any FAIL/UNKNOWN it produces flows through computeExitCode below.
|
|
109
|
+
if (options.deepTest) {
|
|
110
|
+
const deepResults = await runDeepGuardTests(context);
|
|
111
|
+
// US-009: side-effecting hooks (autocommit, checkpoint, journal, reindex, …)
|
|
112
|
+
// cannot be verified by verdict, so they run in throwaway sandboxes and their
|
|
113
|
+
// fixture cases assert observable effects. Their verdicts join the same deep
|
|
114
|
+
// family and flow through computeExitCode below.
|
|
115
|
+
const effectResults = await runSideEffectTests(context);
|
|
116
|
+
families.push({
|
|
117
|
+
family: { id: DEEP_FAMILY_ID, title: DEEP_FAMILY_TITLE },
|
|
118
|
+
results: [...deepResults, ...effectResults],
|
|
119
|
+
});
|
|
120
|
+
// Cross-platform parity replay (US-010): replay every pure-guard fixture
|
|
121
|
+
// case through the Claude, Codex, and Grok adapters and compare verdicts, so
|
|
122
|
+
// platform drift surfaces as a test result. Also gated behind --deep-test,
|
|
123
|
+
// and it too runs only in its own sandbox — never the live tree.
|
|
124
|
+
const parityResults = await runParityReplay(context);
|
|
125
|
+
families.push({
|
|
126
|
+
family: { id: PARITY_FAMILY_ID, title: PARITY_FAMILY_TITLE },
|
|
127
|
+
results: parityResults,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
if (options.json) {
|
|
131
|
+
write(renderJson(buildDoctorJson({ hqRoot, families, platform })));
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
write(renderText({
|
|
135
|
+
hqRoot,
|
|
136
|
+
families,
|
|
137
|
+
platform,
|
|
138
|
+
verbose: options.verbose ?? false,
|
|
139
|
+
color: options.color ?? false,
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
// The exit-code contract: 0 unless some result is FAIL or UNKNOWN. WARN,
|
|
143
|
+
// UNTESTED, NA, and KNOWN-DEFECT are reported but never fail the command.
|
|
144
|
+
return { exitCode: computeExitCode(families), hqRoot, families };
|
|
145
|
+
}
|
|
146
|
+
/** Register the top-level `hq doctor` command so it appears in `hq --help`. */
|
|
147
|
+
export function registerDoctorCommand(program) {
|
|
148
|
+
program
|
|
149
|
+
.command("doctor")
|
|
150
|
+
.description("Verify HQ hook guardrails are wired and firing (read-only, offline).")
|
|
151
|
+
.option("--json", "Emit the machine-readable JSON document (no colour).")
|
|
152
|
+
.option("--verbose", "Also print every PASS result in text output.")
|
|
153
|
+
.option("--no-color", "Disable ANSI colour even on a TTY.")
|
|
154
|
+
.option("--session-id <id>", "Scope the runtime probe's ledger check to this exact session.")
|
|
155
|
+
.option("--deep-test", "Also fire pure-guard hooks through the real gate under all three profiles (sandboxed).")
|
|
156
|
+
.option("--fix", "Apply the allowlisted safe repairs (backs up first; read-only without this flag).")
|
|
157
|
+
.option("--yes", "Skip the interactive --fix confirmation (non-interactive use).")
|
|
158
|
+
.option("--force", "Let --fix run despite uncommitted changes under .claude/, .codex/, or .grok/.")
|
|
159
|
+
.action(async (opts) => {
|
|
160
|
+
// `--fix` is the only write path. It resolves the tree, applies the
|
|
161
|
+
// allowlisted repairs behind a backup + confirmation, and returns its own
|
|
162
|
+
// exit code; the read-only report below never runs in this branch.
|
|
163
|
+
if (opts.fix === true) {
|
|
164
|
+
const hqRoot = resolveHqRoot();
|
|
165
|
+
if (!hqRoot) {
|
|
166
|
+
process.stderr.write(`hq doctor --fix: not inside an HQ tree.\n` +
|
|
167
|
+
` Looking for a directory containing all of: ${HQ_ROOT_MARKERS.join(", ")}\n`);
|
|
168
|
+
process.exitCode = 1;
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const fixResult = await applyFixes({
|
|
172
|
+
hqRoot,
|
|
173
|
+
yes: opts.yes === true,
|
|
174
|
+
force: opts.force === true,
|
|
175
|
+
confirm: promptYesNo,
|
|
176
|
+
});
|
|
177
|
+
process.exitCode = fixResult.exitCode;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const json = opts.json === true;
|
|
181
|
+
// Colour only when writing text to an interactive terminal, and never
|
|
182
|
+
// when NO_COLOR is set. A non-TTY (pipe, file, CI) gets no ANSI at all.
|
|
183
|
+
const color = !json &&
|
|
184
|
+
opts.color !== false &&
|
|
185
|
+
process.stdout.isTTY === true &&
|
|
186
|
+
!process.env.NO_COLOR;
|
|
187
|
+
// Detect the host once, here at the CLI boundary, and hand it to the run.
|
|
188
|
+
// The runtime probe (US-006) needs the real host to tell an app/SDK
|
|
189
|
+
// runtime that never dispatches hooks apart from a CLI that does.
|
|
190
|
+
const detection = detectPlatform();
|
|
191
|
+
const platform = {
|
|
192
|
+
id: detection.platform,
|
|
193
|
+
evidence: detection.evidence,
|
|
194
|
+
};
|
|
195
|
+
const result = await runDoctor({
|
|
196
|
+
json,
|
|
197
|
+
verbose: opts.verbose === true,
|
|
198
|
+
color,
|
|
199
|
+
platform,
|
|
200
|
+
sessionId: opts.sessionId,
|
|
201
|
+
deepTest: opts.deepTest === true,
|
|
202
|
+
});
|
|
203
|
+
// Set the exit code rather than calling process.exit, so the CLI's
|
|
204
|
+
// normal shutdown (telemetry flush) still runs. Non-zero means either an
|
|
205
|
+
// out-of-tree run or a FAIL/UNKNOWN result.
|
|
206
|
+
process.exitCode = result.exitCode;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Interactive y/N confirmation for `--fix`. Resolves false on a non-TTY stdin
|
|
211
|
+
* (so a piped run without `--yes` writes nothing) and on anything other than an
|
|
212
|
+
* explicit yes.
|
|
213
|
+
*/
|
|
214
|
+
function promptYesNo() {
|
|
215
|
+
if (!process.stdin.isTTY)
|
|
216
|
+
return Promise.resolve(false);
|
|
217
|
+
const rl = readline.createInterface({
|
|
218
|
+
input: process.stdin,
|
|
219
|
+
output: process.stdout,
|
|
220
|
+
});
|
|
221
|
+
return new Promise((resolve) => {
|
|
222
|
+
rl.question("Apply these repairs? [y/N] ", (answer) => {
|
|
223
|
+
rl.close();
|
|
224
|
+
resolve(/^y(es)?$/i.test(answer.trim()));
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
//# sourceMappingURL=doctor.js.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fast-path manifest for relocated scaffold scripts, kept deliberately
|
|
3
|
+
* dependency-light.
|
|
4
|
+
*
|
|
5
|
+
* `main.ts` eagerly imports the CLI's entire (~60-module) command graph, so any
|
|
6
|
+
* command routed through it pays seconds of startup — fine for a human typing a
|
|
7
|
+
* command, ruinous for the hot plumbing this hosts: `hq core hq-session` is
|
|
8
|
+
* called several times per skill/hook, and `hq core checkpoint-stop-gate` runs
|
|
9
|
+
* on every turn's Stop. The entrypoint routes those directly to
|
|
10
|
+
* `runBundledScript` instead, and this module holds the tiny parser + table that
|
|
11
|
+
* routing needs WITHOUT importing anything heavy. `core.ts` names the same
|
|
12
|
+
* assets, and a test pins the two lists together so they cannot drift.
|
|
13
|
+
*/
|
|
14
|
+
export type FastScaffoldScript = {
|
|
15
|
+
/** Direct subcommand name — `hq core <name>`. */
|
|
16
|
+
name: string;
|
|
17
|
+
/** Path under `assets/scaffold/`, e.g. `core/scripts/foo.sh`. */
|
|
18
|
+
asset: string;
|
|
19
|
+
};
|
|
20
|
+
/** The relocated scripts that qualify for the entrypoint fast path. */
|
|
21
|
+
export declare const FAST_SCAFFOLD_SCRIPTS: readonly FastScaffoldScript[];
|
|
22
|
+
export type ParsedFastCore = {
|
|
23
|
+
/** The bundled asset to run. */
|
|
24
|
+
asset: string;
|
|
25
|
+
/** `--hq-root <path>` value, if the caller passed the group option. */
|
|
26
|
+
hqRootArg?: string;
|
|
27
|
+
/** Everything after the command name, passed to the script verbatim. */
|
|
28
|
+
args: string[];
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Parse `hq core [--hq-root <path>] <name> [args...]` when `<name>` is a
|
|
32
|
+
* fast-path script. Returns null for anything else so the caller defers to the
|
|
33
|
+
* full CLI. Mirrors the `core` group's single `--hq-root` option (both the
|
|
34
|
+
* `--hq-root <path>` and `--hq-root=<path>` forms) and its `root: "live"`
|
|
35
|
+
* binding; every remaining token is passed through untouched, exactly as the
|
|
36
|
+
* commander registration does with allowUnknownOption + a variadic argument.
|
|
37
|
+
*/
|
|
38
|
+
export declare function parseFastCore(argv: readonly string[]): ParsedFastCore | null;
|
|
39
|
+
/** Whether this invocation is a fast-path scaffold dispatch. */
|
|
40
|
+
export declare function isFastCoreRequest(argv: readonly string[]): boolean;
|
|
41
|
+
//# sourceMappingURL=scaffold-fast.d.ts.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fast-path manifest for relocated scaffold scripts, kept deliberately
|
|
3
|
+
* dependency-light.
|
|
4
|
+
*
|
|
5
|
+
* `main.ts` eagerly imports the CLI's entire (~60-module) command graph, so any
|
|
6
|
+
* command routed through it pays seconds of startup — fine for a human typing a
|
|
7
|
+
* command, ruinous for the hot plumbing this hosts: `hq core hq-session` is
|
|
8
|
+
* called several times per skill/hook, and `hq core checkpoint-stop-gate` runs
|
|
9
|
+
* on every turn's Stop. The entrypoint routes those directly to
|
|
10
|
+
* `runBundledScript` instead, and this module holds the tiny parser + table that
|
|
11
|
+
* routing needs WITHOUT importing anything heavy. `core.ts` names the same
|
|
12
|
+
* assets, and a test pins the two lists together so they cannot drift.
|
|
13
|
+
*/
|
|
14
|
+
/** The relocated scripts that qualify for the entrypoint fast path. */
|
|
15
|
+
export const FAST_SCAFFOLD_SCRIPTS = [
|
|
16
|
+
{ name: "checkpoint-stop-gate", asset: "core/scripts/checkpoint-stop-gate.sh" },
|
|
17
|
+
{ name: "hq-session", asset: "core/scripts/hq-session.sh" },
|
|
18
|
+
];
|
|
19
|
+
/**
|
|
20
|
+
* Parse `hq core [--hq-root <path>] <name> [args...]` when `<name>` is a
|
|
21
|
+
* fast-path script. Returns null for anything else so the caller defers to the
|
|
22
|
+
* full CLI. Mirrors the `core` group's single `--hq-root` option (both the
|
|
23
|
+
* `--hq-root <path>` and `--hq-root=<path>` forms) and its `root: "live"`
|
|
24
|
+
* binding; every remaining token is passed through untouched, exactly as the
|
|
25
|
+
* commander registration does with allowUnknownOption + a variadic argument.
|
|
26
|
+
*/
|
|
27
|
+
export function parseFastCore(argv) {
|
|
28
|
+
// argv is process.argv: [node, hq, core, ...rest].
|
|
29
|
+
if (argv[2] !== "core")
|
|
30
|
+
return null;
|
|
31
|
+
let i = 3;
|
|
32
|
+
let hqRootArg;
|
|
33
|
+
const head = argv[i];
|
|
34
|
+
if (head === "--hq-root") {
|
|
35
|
+
const value = argv[i + 1];
|
|
36
|
+
if (typeof value !== "string")
|
|
37
|
+
return null;
|
|
38
|
+
hqRootArg = value;
|
|
39
|
+
i += 2;
|
|
40
|
+
}
|
|
41
|
+
else if (typeof head === "string" && head.startsWith("--hq-root=")) {
|
|
42
|
+
hqRootArg = head.slice("--hq-root=".length);
|
|
43
|
+
i += 1;
|
|
44
|
+
}
|
|
45
|
+
const name = argv[i];
|
|
46
|
+
if (typeof name !== "string")
|
|
47
|
+
return null;
|
|
48
|
+
const entry = FAST_SCAFFOLD_SCRIPTS.find((candidate) => candidate.name === name);
|
|
49
|
+
if (!entry)
|
|
50
|
+
return null;
|
|
51
|
+
return { asset: entry.asset, hqRootArg, args: argv.slice(i + 1) };
|
|
52
|
+
}
|
|
53
|
+
/** Whether this invocation is a fast-path scaffold dispatch. */
|
|
54
|
+
export function isFastCoreRequest(argv) {
|
|
55
|
+
return parseFastCore(argv) !== null;
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=scaffold-fast.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fast-path dispatcher for relocated scaffold scripts.
|
|
3
|
+
*
|
|
4
|
+
* Reached from the entrypoint BEFORE `main.ts` (and its heavy command graph) is
|
|
5
|
+
* ever imported, so a hot `hq core hq-session` / `hq core checkpoint-stop-gate`
|
|
6
|
+
* dispatches in roughly node's own startup time. It imports only the light root
|
|
7
|
+
* resolver and the bundled-script runner. The slower commander registration in
|
|
8
|
+
* `core.ts` remains the complete path for any invocation this does not match.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Run the fast-path scaffold script named in `argv` and exit with its code.
|
|
12
|
+
* Only call when {@link isFastCoreRequest} is true; a non-match returns without
|
|
13
|
+
* doing anything.
|
|
14
|
+
*/
|
|
15
|
+
export declare function runFastCore(argv: readonly string[]): void;
|
|
16
|
+
//# sourceMappingURL=fast-core.d.ts.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fast-path dispatcher for relocated scaffold scripts.
|
|
3
|
+
*
|
|
4
|
+
* Reached from the entrypoint BEFORE `main.ts` (and its heavy command graph) is
|
|
5
|
+
* ever imported, so a hot `hq core hq-session` / `hq core checkpoint-stop-gate`
|
|
6
|
+
* dispatches in roughly node's own startup time. It imports only the light root
|
|
7
|
+
* resolver and the bundled-script runner. The slower commander registration in
|
|
8
|
+
* `core.ts` remains the complete path for any invocation this does not match.
|
|
9
|
+
*/
|
|
10
|
+
import { resolveLiveRoot } from "./utils/hq-roots.js";
|
|
11
|
+
import { runBundledScript } from "./utils/run-bundled-script.js";
|
|
12
|
+
import { parseFastCore } from "./commands/scaffold-fast.js";
|
|
13
|
+
/**
|
|
14
|
+
* Run the fast-path scaffold script named in `argv` and exit with its code.
|
|
15
|
+
* Only call when {@link isFastCoreRequest} is true; a non-match returns without
|
|
16
|
+
* doing anything.
|
|
17
|
+
*/
|
|
18
|
+
export function runFastCore(argv) {
|
|
19
|
+
const parsed = parseFastCore(argv);
|
|
20
|
+
if (!parsed)
|
|
21
|
+
return;
|
|
22
|
+
// `root: "live"` — resolve the live HQ root and run the script in it, exactly
|
|
23
|
+
// as core.ts's runEntry does. Fail soft: if no root can be resolved, run the
|
|
24
|
+
// script anyway with the inherited environment (the hook already carries
|
|
25
|
+
// CLAUDE_PROJECT_DIR, and each script has its own root fallback) rather than
|
|
26
|
+
// crashing a Stop hook on a resolution error.
|
|
27
|
+
let hqRoot;
|
|
28
|
+
try {
|
|
29
|
+
hqRoot = resolveLiveRoot(parsed.hqRootArg ? { hqRoot: parsed.hqRootArg } : {});
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
hqRoot = undefined;
|
|
33
|
+
}
|
|
34
|
+
// Keep the CHILD in the caller's cwd, unlike the generic "live" dispatch. The
|
|
35
|
+
// relocated scripts read their data tree from the injected root, not cwd, and
|
|
36
|
+
// hq-session's Work Mesh registration serializes $PWD — so changing directory
|
|
37
|
+
// to the HQ root would mislabel a bind made from inside a project/worktree.
|
|
38
|
+
const { code } = runBundledScript({
|
|
39
|
+
asset: parsed.asset,
|
|
40
|
+
args: parsed.args,
|
|
41
|
+
cwd: process.cwd(),
|
|
42
|
+
hqRoot,
|
|
43
|
+
});
|
|
44
|
+
// Propagate verbatim: these scripts use their exit code as their interface.
|
|
45
|
+
process.exit(code);
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=fast-core.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import "./node-preflight.js";
|
|
3
3
|
import "./node-network-compat.js";
|
|
4
|
+
import { isFastCoreRequest } from "./commands/scaffold-fast.js";
|
|
4
5
|
declare function isVersionRequest(argv: readonly string[]): boolean;
|
|
5
6
|
export declare const __test__: {
|
|
6
7
|
isVersionRequest: typeof isVersionRequest;
|
|
8
|
+
isFastCoreRequest: typeof isFastCoreRequest;
|
|
7
9
|
};
|
|
8
10
|
export {};
|
|
9
11
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
import "./node-preflight.js";
|
|
5
5
|
import "./node-network-compat.js";
|
|
6
6
|
import { CLI_VERSION } from "./cli-version.js";
|
|
7
|
+
// Dependency-light: a pure parser + table, no command graph. Safe to load on
|
|
8
|
+
// every path (including --version) without reintroducing the heavy startup.
|
|
9
|
+
import { isFastCoreRequest } from "./commands/scaffold-fast.js";
|
|
7
10
|
function isVersionRequest(argv) {
|
|
8
11
|
const args = argv.slice(2);
|
|
9
12
|
return args.length === 1 && (args[0] === "--version" || args[0] === "-V" || args[0] === "-v");
|
|
@@ -11,6 +14,12 @@ function isVersionRequest(argv) {
|
|
|
11
14
|
if (isVersionRequest(process.argv)) {
|
|
12
15
|
process.stdout.write(`${CLI_VERSION}\n`);
|
|
13
16
|
}
|
|
17
|
+
else if (isFastCoreRequest(process.argv)) {
|
|
18
|
+
// Hot relocated plumbing (`hq core hq-session`, `hq core checkpoint-stop-gate`)
|
|
19
|
+
// dispatches straight to the bundled-script runner, so it never pays for the
|
|
20
|
+
// ~60-module command graph main.ts imports. See src/commands/scaffold-fast.ts.
|
|
21
|
+
void import("./fast-core.js").then(({ runFastCore }) => runFastCore(process.argv));
|
|
22
|
+
}
|
|
14
23
|
else {
|
|
15
24
|
// Keep the command lifecycle detached from module evaluation, as it was
|
|
16
25
|
// before the fast --version split. Some best-effort teardown work uses
|
|
@@ -19,5 +28,5 @@ else {
|
|
|
19
28
|
// Rejections still become unhandled and preserve a genuine non-zero failure.
|
|
20
29
|
void import("./main.js").then(({ runCli }) => runCli());
|
|
21
30
|
}
|
|
22
|
-
export const __test__ = { isVersionRequest };
|
|
31
|
+
export const __test__ = { isVersionRequest, isFastCoreRequest };
|
|
23
32
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* buildFakeHqTree — a reusable test harness that materialises a fake HQ tree in
|
|
3
|
+
* the OS temp directory from a declarative spec.
|
|
4
|
+
*
|
|
5
|
+
* Every `hq doctor` check is fundamentally a function of the on-disk shape of an
|
|
6
|
+
* HQ tree: which hooks exist, whether they are executable, whether they are
|
|
7
|
+
* registered in settings, which `hook-gate.sh` profiles list them, and whether
|
|
8
|
+
* the Codex mirror still matches its Claude original. This helper lets a test
|
|
9
|
+
* declare that shape — including its deliberately-broken variants — and get back
|
|
10
|
+
* a throwaway tree plus a manifest describing exactly what was written, without
|
|
11
|
+
* ever touching the real HQ tree.
|
|
12
|
+
*
|
|
13
|
+
* Design notes:
|
|
14
|
+
* - Everything is written under a `mkdtempSync` root inside `os.tmpdir()`. The
|
|
15
|
+
* real HQ tree is never read or written.
|
|
16
|
+
* - The root path is canonicalised with `realpathSync` so callers can assert
|
|
17
|
+
* containment under the temp dir on platforms (macOS) where the temp dir is
|
|
18
|
+
* a symlink (`/var` -> `/private/var`).
|
|
19
|
+
* - Cleanup is automatic: the first build registers a single `process` exit
|
|
20
|
+
* handler that removes every tracked root, so nothing survives a test run,
|
|
21
|
+
* including on test failure. `tree.cleanup()` is also exposed for eager
|
|
22
|
+
* removal, and is idempotent.
|
|
23
|
+
*
|
|
24
|
+
* The reference for this pattern is core/scripts/test-codex-hook-adapter.sh,
|
|
25
|
+
* which stands up a throwaway HQ tree with `mktemp -d` and stubbed hook scripts.
|
|
26
|
+
*/
|
|
27
|
+
/** Claude/Codex lifecycle events a hook can be registered against. */
|
|
28
|
+
export type HookEventName = "PreToolUse" | "PostToolUse" | "UserPromptSubmit" | "SessionStart" | "Stop" | "SubagentStop" | "PreCompact" | "Notification";
|
|
29
|
+
/** The three `hook-gate.sh` allowlist profiles. */
|
|
30
|
+
export type GateProfile = "minimal" | "standard" | "strict";
|
|
31
|
+
/** Canonical ordering of the gate profiles. */
|
|
32
|
+
export declare const GATE_PROFILES: readonly GateProfile[];
|
|
33
|
+
/**
|
|
34
|
+
* Declares a Codex mirror of a Claude hook. Claude and Grok both execute the
|
|
35
|
+
* canonical `.claude/hooks/` scripts; only Codex runs duplicated copies, so the
|
|
36
|
+
* Codex mirror is the entire drift surface the doctor has to watch.
|
|
37
|
+
*/
|
|
38
|
+
export interface FakeCodexMirrorSpec {
|
|
39
|
+
/** Whether the mirror file is written to `.codex/hooks/`. Default: true. */
|
|
40
|
+
present?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Script body for the mirror. Defaults to the Claude original's body (an
|
|
43
|
+
* identical, healthy mirror). Supply a different string to model content
|
|
44
|
+
* drift between the Codex copy and its Claude original.
|
|
45
|
+
*/
|
|
46
|
+
body?: string;
|
|
47
|
+
/** Whether the mirror file carries the executable bit. Default: true. */
|
|
48
|
+
executable?: boolean;
|
|
49
|
+
/** Explicit file mode override (masked to 0o777). Overrides `executable`. */
|
|
50
|
+
mode?: number;
|
|
51
|
+
/** Whether the mirror is registered in `.codex/hooks.json`. Default: true. */
|
|
52
|
+
registered?: boolean;
|
|
53
|
+
/** Optional settings matcher (e.g. "Bash", "Glob"). */
|
|
54
|
+
matcher?: string;
|
|
55
|
+
}
|
|
56
|
+
/** Declares a single hook and its cross-platform state. */
|
|
57
|
+
export interface FakeHookSpec {
|
|
58
|
+
/** Hook id, e.g. "detect-secrets". Also names the `<id>.sh` file. */
|
|
59
|
+
id: string;
|
|
60
|
+
/** Script body. Default: a trivial pass-through that exits 0. */
|
|
61
|
+
body?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Whether the `.claude/hooks/<id>.sh` file is written to disk. Default: true.
|
|
64
|
+
* Set false to model a missing hook file (combine with `registered: true` for
|
|
65
|
+
* a hook that is registered in settings but absent from disk).
|
|
66
|
+
*/
|
|
67
|
+
present?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Whether the hook file carries the executable bit. Default: true. Set false
|
|
70
|
+
* to model a present-but-not-executable hook (mode 0o644).
|
|
71
|
+
*/
|
|
72
|
+
executable?: boolean;
|
|
73
|
+
/** Explicit file mode override (masked to 0o777). Overrides `executable`. */
|
|
74
|
+
mode?: number;
|
|
75
|
+
/** Whether the hook is registered in `.claude/settings.json`. Default: true. */
|
|
76
|
+
registered?: boolean;
|
|
77
|
+
/** Events the hook registers against. Default: ["PreToolUse"]. */
|
|
78
|
+
events?: HookEventName[];
|
|
79
|
+
/** Optional settings matcher (e.g. "Bash", "Glob"). */
|
|
80
|
+
matcher?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Which `hook-gate.sh` profiles list this id. Default: all three. Pass a
|
|
83
|
+
* subset (e.g. ["minimal"]) to model a hook id present in only some of the
|
|
84
|
+
* three profiles — the exact defect the three-profile check exists to catch.
|
|
85
|
+
*/
|
|
86
|
+
profiles?: GateProfile[];
|
|
87
|
+
/**
|
|
88
|
+
* Codex mirror configuration. When omitted, a healthy identical mirror is
|
|
89
|
+
* created. Pass `false` to model a Claude hook with no Codex counterpart, or
|
|
90
|
+
* an object to override the mirror (e.g. `{ body }` to model content drift).
|
|
91
|
+
*/
|
|
92
|
+
codex?: FakeCodexMirrorSpec | false;
|
|
93
|
+
}
|
|
94
|
+
/** Declares the `.grok/hooks/` state. */
|
|
95
|
+
export interface FakeGrokSpec {
|
|
96
|
+
/** Whether the `.grok/hooks/` scaffold is created at all. Default: true. */
|
|
97
|
+
present?: boolean;
|
|
98
|
+
/** Whether `hq-grok-hook-adapter.sh` carries the executable bit. Default: true. */
|
|
99
|
+
adapterExecutable?: boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Whether the user-global bridge under `~/.grok/hooks/` is recorded as
|
|
102
|
+
* installed. The real bridge lives in the home dir and cannot be simulated in
|
|
103
|
+
* a project-local temp tree, so this is a manifest flag only. Default: true.
|
|
104
|
+
*/
|
|
105
|
+
bridgeInstalled?: boolean;
|
|
106
|
+
}
|
|
107
|
+
/** The declarative spec passed to {@link buildFakeHqTree}. */
|
|
108
|
+
export interface FakeHqTreeSpec {
|
|
109
|
+
/** Hooks to materialise. Default: none. */
|
|
110
|
+
hooks?: FakeHookSpec[];
|
|
111
|
+
/** Extra top-level keys merged into `.claude/settings.json`. */
|
|
112
|
+
claudeSettings?: Record<string, unknown>;
|
|
113
|
+
/** Grok scaffold config, or `false` to omit `.grok/`. Default: healthy. */
|
|
114
|
+
grok?: FakeGrokSpec | false;
|
|
115
|
+
/** Initialise a git repo at the tree root. Default: false. */
|
|
116
|
+
git?: boolean;
|
|
117
|
+
/** temp-dir name prefix. Default: "hq-doctor-fake-". */
|
|
118
|
+
prefix?: string;
|
|
119
|
+
}
|
|
120
|
+
/** Manifest entry describing a materialised Codex mirror. */
|
|
121
|
+
export interface FakeCodexManifestEntry {
|
|
122
|
+
/** Absolute path where the mirror lives (whether or not it was written). */
|
|
123
|
+
scriptPath: string;
|
|
124
|
+
/** Whether the mirror file exists on disk. */
|
|
125
|
+
present: boolean;
|
|
126
|
+
/** On-disk mode bits (0o777-masked), or null when absent. */
|
|
127
|
+
mode: number | null;
|
|
128
|
+
/** Whether the mirror carries the executable bit. */
|
|
129
|
+
executable: boolean;
|
|
130
|
+
/** Whether the mirror is registered in `.codex/hooks.json`. */
|
|
131
|
+
registered: boolean;
|
|
132
|
+
/** Whether the mirror's content differs from its Claude original. */
|
|
133
|
+
drifted: boolean;
|
|
134
|
+
}
|
|
135
|
+
/** Manifest entry describing a materialised hook across all platforms. */
|
|
136
|
+
export interface FakeHookManifestEntry {
|
|
137
|
+
id: string;
|
|
138
|
+
/** Absolute path of the Claude script (whether or not it was written). */
|
|
139
|
+
scriptPath: string;
|
|
140
|
+
/** Whether the Claude hook file exists on disk. */
|
|
141
|
+
present: boolean;
|
|
142
|
+
/** On-disk mode bits (0o777-masked), or null when absent. */
|
|
143
|
+
mode: number | null;
|
|
144
|
+
/** Whether the Claude hook carries the executable bit. */
|
|
145
|
+
executable: boolean;
|
|
146
|
+
/** Whether the hook is registered in `.claude/settings.json`. */
|
|
147
|
+
registered: boolean;
|
|
148
|
+
/** Events the hook is registered against. */
|
|
149
|
+
events: HookEventName[];
|
|
150
|
+
/** Optional settings matcher (e.g. "Bash", "Glob"), or null when unset. */
|
|
151
|
+
matcher: string | null;
|
|
152
|
+
/** Which `hook-gate.sh` profiles list this id. */
|
|
153
|
+
profiles: GateProfile[];
|
|
154
|
+
/** The Codex mirror, or null when the hook has no Codex counterpart. */
|
|
155
|
+
codex: FakeCodexManifestEntry | null;
|
|
156
|
+
}
|
|
157
|
+
/** A structured description of everything {@link buildFakeHqTree} wrote. */
|
|
158
|
+
export interface FakeHqTreeManifest {
|
|
159
|
+
root: string;
|
|
160
|
+
claudeSettingsPath: string;
|
|
161
|
+
claudeHooksDir: string;
|
|
162
|
+
claudeHookGatePath: string;
|
|
163
|
+
codexHooksJsonPath: string;
|
|
164
|
+
codexHooksDir: string;
|
|
165
|
+
codexHookGatePath: string;
|
|
166
|
+
grokDir: string | null;
|
|
167
|
+
grokAdapterPath: string | null;
|
|
168
|
+
grokRegistrationPath: string | null;
|
|
169
|
+
grokBridgeInstalled: boolean;
|
|
170
|
+
gitInitialised: boolean;
|
|
171
|
+
hooks: FakeHookManifestEntry[];
|
|
172
|
+
}
|
|
173
|
+
/** The handle returned by {@link buildFakeHqTree}. */
|
|
174
|
+
export interface FakeHqTree {
|
|
175
|
+
/** Absolute, canonicalised tree root under the OS temp dir. */
|
|
176
|
+
root: string;
|
|
177
|
+
/** Structured description of everything that was written. */
|
|
178
|
+
manifest: FakeHqTreeManifest;
|
|
179
|
+
/** Join a path relative to the tree root. */
|
|
180
|
+
path: (...segments: string[]) => string;
|
|
181
|
+
/** Remove this tree from disk. Idempotent. */
|
|
182
|
+
cleanup: () => void;
|
|
183
|
+
}
|
|
184
|
+
/** Remove every fake HQ tree still on disk. Safe to call repeatedly. */
|
|
185
|
+
export declare function cleanupAllFakeHqTrees(): void;
|
|
186
|
+
/** The set of tree roots this process is still tracking for cleanup. */
|
|
187
|
+
export declare function trackedFakeHqTreeRoots(): string[];
|
|
188
|
+
/**
|
|
189
|
+
* Materialise a fake HQ tree from `spec` and return its root, manifest, and a
|
|
190
|
+
* cleanup handle. The tree lives under `os.tmpdir()` and is swept automatically
|
|
191
|
+
* when the process exits.
|
|
192
|
+
*/
|
|
193
|
+
export declare function buildFakeHqTree(spec?: FakeHqTreeSpec): FakeHqTree;
|
|
194
|
+
//# sourceMappingURL=fake-hq-tree.d.ts.map
|