@indigoai-us/hq-cli 5.98.3 → 5.99.1

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.
Files changed (73) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +347 -0
  3. package/assets/scaffold/core/scripts/hook-lib.sh +557 -0
  4. package/assets/scaffold/core/scripts/hq-session.sh +251 -0
  5. package/assets/scaffold/core/scripts/lib/session-id.sh +96 -0
  6. package/assets/scaffold/core/scripts/lib/session-scope-capability.sh +52 -0
  7. package/dist/commands/core-checkpoint.js +11 -3
  8. package/dist/commands/core.js +60 -5
  9. package/dist/commands/doctor.d.ts +97 -0
  10. package/dist/commands/doctor.js +228 -0
  11. package/dist/commands/scaffold-fast.d.ts +41 -0
  12. package/dist/commands/scaffold-fast.js +57 -0
  13. package/dist/fast-core.d.ts +16 -0
  14. package/dist/fast-core.js +47 -0
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.js +10 -1
  17. package/dist/lib/core-utils/soft-timeout.d.ts +55 -0
  18. package/dist/lib/core-utils/soft-timeout.js +205 -0
  19. package/dist/lib/core-utils/timeout-guard.d.ts +62 -0
  20. package/dist/lib/core-utils/timeout-guard.js +207 -0
  21. package/dist/lib/doctor/__testing__/fake-hq-tree.d.ts +194 -0
  22. package/dist/lib/doctor/__testing__/fake-hq-tree.js +357 -0
  23. package/dist/lib/doctor/allowed-divergence.d.ts +72 -0
  24. package/dist/lib/doctor/allowed-divergence.js +134 -0
  25. package/dist/lib/doctor/checks/claude-wiring.d.ts +55 -0
  26. package/dist/lib/doctor/checks/claude-wiring.js +524 -0
  27. package/dist/lib/doctor/checks/codex-wiring.d.ts +45 -0
  28. package/dist/lib/doctor/checks/codex-wiring.js +376 -0
  29. package/dist/lib/doctor/checks/grok-wiring.d.ts +35 -0
  30. package/dist/lib/doctor/checks/grok-wiring.js +186 -0
  31. package/dist/lib/doctor/checks/runtime-probe.d.ts +101 -0
  32. package/dist/lib/doctor/checks/runtime-probe.js +335 -0
  33. package/dist/lib/doctor/compat.d.ts +85 -0
  34. package/dist/lib/doctor/compat.js +102 -0
  35. package/dist/lib/doctor/deep/classify.d.ts +61 -0
  36. package/dist/lib/doctor/deep/classify.js +75 -0
  37. package/dist/lib/doctor/deep/effects.d.ts +107 -0
  38. package/dist/lib/doctor/deep/effects.js +229 -0
  39. package/dist/lib/doctor/deep/executor.d.ts +112 -0
  40. package/dist/lib/doctor/deep/executor.js +369 -0
  41. package/dist/lib/doctor/deep/parity.d.ts +129 -0
  42. package/dist/lib/doctor/deep/parity.js +355 -0
  43. package/dist/lib/doctor/deep/sandbox.d.ts +190 -0
  44. package/dist/lib/doctor/deep/sandbox.js +572 -0
  45. package/dist/lib/doctor/fix/apply.d.ts +119 -0
  46. package/dist/lib/doctor/fix/apply.js +352 -0
  47. package/dist/lib/doctor/fix/backup.d.ts +40 -0
  48. package/dist/lib/doctor/fix/backup.js +64 -0
  49. package/dist/lib/doctor/fix/remediation.d.ts +71 -0
  50. package/dist/lib/doctor/fix/remediation.js +103 -0
  51. package/dist/lib/doctor/fixtures/discover.d.ts +96 -0
  52. package/dist/lib/doctor/fixtures/discover.js +287 -0
  53. package/dist/lib/doctor/fixtures/schema.d.ts +171 -0
  54. package/dist/lib/doctor/fixtures/schema.js +248 -0
  55. package/dist/lib/doctor/hook-gate-profiles.d.ts +55 -0
  56. package/dist/lib/doctor/hook-gate-profiles.js +107 -0
  57. package/dist/lib/doctor/json-output.d.ts +90 -0
  58. package/dist/lib/doctor/json-output.js +76 -0
  59. package/dist/lib/doctor/payload-shapes.d.ts +170 -0
  60. package/dist/lib/doctor/payload-shapes.js +275 -0
  61. package/dist/lib/doctor/platform.d.ts +244 -0
  62. package/dist/lib/doctor/platform.js +490 -0
  63. package/dist/lib/doctor/registry.d.ts +49 -0
  64. package/dist/lib/doctor/registry.js +176 -0
  65. package/dist/lib/doctor/report.d.ts +87 -0
  66. package/dist/lib/doctor/report.js +164 -0
  67. package/dist/lib/doctor/types.d.ts +87 -0
  68. package/dist/lib/doctor/types.js +29 -0
  69. package/dist/main.js +6 -0
  70. package/dist/utils/version-check.js +2 -2
  71. package/dist/utils/version-gate.d.ts +1 -1
  72. package/dist/utils/version-gate.js +1 -1
  73. package/package.json +1 -1
@@ -0,0 +1,205 @@
1
+ /**
2
+ * `hq core soft-timeout <interval> [--hard-cap <spec>] [--label <name>] -- <cmd…>`
3
+ *
4
+ * Run a command under a SOFT timeout: WARN, don't kill. This is the CLI-hosted
5
+ * form of the shell primitive `core/scripts/lib/soft-timeout.sh`, so HQ scripts
6
+ * and hooks can call `hq core soft-timeout …` directly instead of shelling out
7
+ * to a bundled script.
8
+ *
9
+ * Finding 2.1 (team harness analysis 2026-08-10 — 1,393 forced-kill events):
10
+ * a fixed deadline SIGTERMs an operation still making progress and loses the
11
+ * in-flight work. The fix is warn-and-continue — on each interval the command
12
+ * keeps running and a SOFT-TIMEOUT notice is written to stderr so whoever is
13
+ * watching decides (wait / background / kill), instead of the runner deciding
14
+ * by killing progressing work.
15
+ *
16
+ * Contract (identical to the shell primitive):
17
+ * - `<interval>` — warn every interval. Bare number = seconds; 90s / 2m / 1h.
18
+ * - `--hard-cap <spec>` — OPTIONAL safety ceiling for unattended callers.
19
+ * Pure soft has no cap and never terminates. At the cap the command is
20
+ * SIGTERM'd, then SIGKILL'd after a short grace, and the exit code is 124.
21
+ * - `--label <name>` — name shown in the notice (default: the command basename).
22
+ * - Exit status: the command's own, verbatim — except 124 on a hard-cap.
23
+ * A soft warning alone never changes the exit status.
24
+ */
25
+ import { spawn } from "node:child_process";
26
+ import { basename } from "node:path";
27
+ import { constants as osConstants } from "node:os";
28
+ /** Parse a duration spec (Ns / Nm / Nh / bare N=seconds) to integer seconds. */
29
+ export function parseDurationSecs(spec) {
30
+ const m = /^([0-9]+)([smh]?)$/.exec(spec);
31
+ if (!m)
32
+ return null;
33
+ const n = Number(m[1]);
34
+ switch (m[2]) {
35
+ case "":
36
+ case "s":
37
+ return n;
38
+ case "m":
39
+ return n * 60;
40
+ case "h":
41
+ return n * 3600;
42
+ default:
43
+ return null;
44
+ }
45
+ }
46
+ /** Parse the `hq core soft-timeout` argv. Throws a plain Error on misuse. */
47
+ export function parseSoftTimeoutArgs(argv) {
48
+ const args = [...argv];
49
+ if (args.length === 0 || args[0].startsWith("--")) {
50
+ throw new Error("soft-timeout: first argument must be the warn interval");
51
+ }
52
+ const intervalSecs = parseDurationSecs(args.shift());
53
+ if (intervalSecs === null || intervalSecs <= 0) {
54
+ throw new Error("soft-timeout: interval must be a positive duration (e.g. 120, 2m)");
55
+ }
56
+ let hardCapSecs = null;
57
+ let label = null;
58
+ // Consume our own options; the FIRST non-option token (or an explicit `--`)
59
+ // begins the wrapped command. `--` is optional because Commander strips a
60
+ // single leading `--` before these args reach us — so `hq core soft-timeout
61
+ // 30 -- sleep 60` arrives here as `30 sleep 60`, and treating the first
62
+ // non-option as the command start makes both forms parse identically.
63
+ while (args.length > 0) {
64
+ const tok = args[0];
65
+ if (tok === "--") {
66
+ args.shift();
67
+ break;
68
+ }
69
+ if (tok === "--hard-cap") {
70
+ args.shift();
71
+ const spec = args.shift();
72
+ const secs = spec ? parseDurationSecs(spec) : null;
73
+ if (secs === null || secs <= 0)
74
+ throw new Error(`soft-timeout: invalid --hard-cap '${spec ?? ""}'`);
75
+ hardCapSecs = secs;
76
+ }
77
+ else if (tok === "--label") {
78
+ args.shift();
79
+ label = args.shift() ?? null;
80
+ if (label === null)
81
+ throw new Error("soft-timeout: --label needs a value");
82
+ }
83
+ else {
84
+ break; // first non-option token: the wrapped command starts here
85
+ }
86
+ }
87
+ if (args.length === 0) {
88
+ throw new Error("soft-timeout: no command given (expected: soft-timeout <interval> [...] -- cmd)");
89
+ }
90
+ return { intervalSecs, hardCapSecs, label: label ?? null, command: args };
91
+ }
92
+ function hhmmss(now) {
93
+ return now.toISOString().slice(11, 19);
94
+ }
95
+ /**
96
+ * Execute the command under the soft timeout. Resolves to the exit code the
97
+ * process should exit with. Never rejects on a timeout — a warning is only a
98
+ * stderr line. Rejects only on spawn failure.
99
+ */
100
+ export function runSoftTimeout(parsed, deps = {}) {
101
+ const spawnFn = deps.spawnFn ?? spawn;
102
+ const err = deps.stderr ?? process.stderr;
103
+ const now = deps.now ?? (() => new Date());
104
+ const killGroup = deps.killGroup ?? ((pid, sig) => process.kill(-pid, sig));
105
+ const { intervalSecs, hardCapSecs, command } = parsed;
106
+ const label = parsed.label ?? basename(command[0]);
107
+ return new Promise((resolve, reject) => {
108
+ // detached: the child leads its own process group so a hard cap can signal
109
+ // the WHOLE group (child + descendants), not just the child — otherwise an
110
+ // orphaned grandchild (e.g. run-project's builder) keeps running after the
111
+ // wrapper returns. stdio inherited so the command's stdin/stdout/stderr —
112
+ // including piped or interactive input — pass through unchanged.
113
+ const child = spawnFn(command[0], command.slice(1), { stdio: "inherit", detached: true });
114
+ let capped = false;
115
+ let ticks = 0;
116
+ // Signal the child's process group when we can (detached leader), else the
117
+ // child alone. Guarded: the group may already be gone.
118
+ const signalTree = (sig) => {
119
+ try {
120
+ if (typeof child.pid === "number")
121
+ killGroup(child.pid, sig);
122
+ else
123
+ child.kill(sig);
124
+ }
125
+ catch {
126
+ try {
127
+ child.kill(sig);
128
+ }
129
+ catch {
130
+ /* already gone */
131
+ }
132
+ }
133
+ };
134
+ const warner = setInterval(() => {
135
+ ticks += 1;
136
+ const elapsed = ticks * intervalSecs;
137
+ err.write(`[${hhmmss(now())}] SOFT-TIMEOUT: ${label} still running after ${elapsed}s ` +
138
+ `(${ticks}× ${intervalSecs}s window) — exceeded its window; NOT killed. ` +
139
+ `Decide: wait / background / kill (kill ${child.pid}).\n`);
140
+ }, intervalSecs * 1000);
141
+ // The hard cap runs on its OWN timer, independent of the warning interval,
142
+ // so a cap shorter than (or not a multiple of) the interval still fires on
143
+ // time rather than at the next interval boundary.
144
+ let capTimer = null;
145
+ let killTimer = null;
146
+ if (hardCapSecs !== null) {
147
+ capTimer = setTimeout(() => {
148
+ capped = true;
149
+ err.write(`[${hhmmss(now())}] SOFT-TIMEOUT: ${label} hit hard cap ${hardCapSecs}s — sending SIGTERM.\n`);
150
+ signalTree("SIGTERM");
151
+ killTimer = setTimeout(() => signalTree("SIGKILL"), 5000);
152
+ }, hardCapSecs * 1000);
153
+ }
154
+ const cleanup = () => {
155
+ clearInterval(warner);
156
+ if (capTimer)
157
+ clearTimeout(capTimer);
158
+ if (killTimer)
159
+ clearTimeout(killTimer);
160
+ };
161
+ child.on("error", (e) => {
162
+ cleanup();
163
+ reject(e);
164
+ });
165
+ child.on("exit", (code, signal) => {
166
+ cleanup();
167
+ if (capped) {
168
+ resolve(124);
169
+ }
170
+ else if (code !== null) {
171
+ resolve(code);
172
+ }
173
+ else {
174
+ // Killed by a signal (external SIGINT/SIGHUP/SIGTERM/…): mirror the
175
+ // shell convention of 128 + signal number for whichever signal it was,
176
+ // so callers keep the real termination status (e.g. 130 for SIGINT).
177
+ const n = signal ? osConstants.signals[signal] : undefined;
178
+ resolve(typeof n === "number" ? 128 + n : 1);
179
+ }
180
+ });
181
+ });
182
+ }
183
+ /**
184
+ * Entry point for the `hq core soft-timeout` command. Parses argv, runs, and
185
+ * returns the exit code (never throws for a timeout; throws only on misuse or
186
+ * spawn failure, which the caller maps to a non-zero exit).
187
+ */
188
+ export async function softTimeoutCommand(argv) {
189
+ let parsed;
190
+ try {
191
+ parsed = parseSoftTimeoutArgs(argv);
192
+ }
193
+ catch (e) {
194
+ process.stderr.write(`${e.message}\n`);
195
+ return 2;
196
+ }
197
+ try {
198
+ return await runSoftTimeout(parsed);
199
+ }
200
+ catch (e) {
201
+ process.stderr.write(`soft-timeout: failed to run command: ${e.message}\n`);
202
+ return 127;
203
+ }
204
+ }
205
+ //# sourceMappingURL=soft-timeout.js.map
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `hq core timeout-guard` — PreToolUse decision for the foreground-timeout guard.
3
+ *
4
+ * The hook logic that used to live in
5
+ * `.claude/hooks/block-foreground-timeout-over-harness-ceiling.sh` now lives
6
+ * here so it can be maintained (and gated) in one place; the shipped hook is a
7
+ * thin shim that pipes the PreToolUse JSON to this command and blocks iff it
8
+ * exits 2.
9
+ *
10
+ * Finding 2.1 (team harness analysis 2026-08-10 — 1,393 forced-kill events): a
11
+ * FOREGROUND shell tool call is SIGTERM'd at the harness's outer deadline
12
+ * (~2 min default, 10 min max) regardless of any longer timeout it declares, so
13
+ * the in-flight work is lost (exit 143). We can't change the harness, so the
14
+ * guard blocks a long foreground declaration and steers it to a background run
15
+ * (no outer deadline, auto-notifies). It fires for Claude, Codex, and Grok —
16
+ * their shell tool calls all reach the hook normalized to `tool_input.command`.
17
+ *
18
+ * Rollout gate: for now the guard only acts for `@getindigo.ai` HQ users; every
19
+ * other identity (and machine identities / logged-out) is allowed through.
20
+ *
21
+ * Exit codes: 0 = allow, 2 = block.
22
+ */
23
+ /**
24
+ * Worst-case inner deadline (seconds) declared in a command: a `timeout` /
25
+ * `gtimeout` prefix (matched by executable BASENAME, so `/usr/bin/timeout`
26
+ * counts) or a `perl -e 'alarm(N)…'` deadline. Segment on shell separators and
27
+ * only treat a segment's LEADING command word as an invocation, so the word
28
+ * `timeout` inside a quoted argument (e.g. `printf 'uses timeout 601s'`) does
29
+ * not trip it.
30
+ */
31
+ export declare function parseInnerDeadlineSecs(command: string): number;
32
+ export interface TimeoutGuardInput {
33
+ command: string;
34
+ toolTimeoutMs?: number;
35
+ runInBackground?: boolean;
36
+ }
37
+ export interface GuardVerdict {
38
+ block: boolean;
39
+ reason?: string;
40
+ }
41
+ /**
42
+ * Pure decision: does this foreground declaration exceed the deadline it can
43
+ * actually secure? Compares the inner deadline against the EFFECTIVE ceiling —
44
+ * the declared tool timeout (capped at the harness max) or the 2-minute default
45
+ * when none is declared — rather than always against the 10-minute max.
46
+ */
47
+ export declare function evaluateTimeoutGuard(input: TimeoutGuardInput): GuardVerdict;
48
+ export interface TimeoutGuardDeps {
49
+ /** Resolve the current HQ user email for the rollout gate. Default: cached id token. */
50
+ getEmail?: () => string | undefined;
51
+ env?: NodeJS.ProcessEnv;
52
+ stderr?: NodeJS.WritableStream;
53
+ }
54
+ /** Whether the guard is active for this identity (rollout gate). */
55
+ export declare function isGatedUser(email: string | undefined): boolean;
56
+ /**
57
+ * Command entry: read the PreToolUse JSON, apply the gate + short-circuits, and
58
+ * return the exit code (0 allow / 2 block). Never throws — a parse failure or a
59
+ * missing identity fails OPEN (allow), because a hook must not break tool calls.
60
+ */
61
+ export declare function timeoutGuardCommand(stdin: string, deps?: TimeoutGuardDeps): number;
62
+ //# sourceMappingURL=timeout-guard.d.ts.map
@@ -0,0 +1,207 @@
1
+ /**
2
+ * `hq core timeout-guard` — PreToolUse decision for the foreground-timeout guard.
3
+ *
4
+ * The hook logic that used to live in
5
+ * `.claude/hooks/block-foreground-timeout-over-harness-ceiling.sh` now lives
6
+ * here so it can be maintained (and gated) in one place; the shipped hook is a
7
+ * thin shim that pipes the PreToolUse JSON to this command and blocks iff it
8
+ * exits 2.
9
+ *
10
+ * Finding 2.1 (team harness analysis 2026-08-10 — 1,393 forced-kill events): a
11
+ * FOREGROUND shell tool call is SIGTERM'd at the harness's outer deadline
12
+ * (~2 min default, 10 min max) regardless of any longer timeout it declares, so
13
+ * the in-flight work is lost (exit 143). We can't change the harness, so the
14
+ * guard blocks a long foreground declaration and steers it to a background run
15
+ * (no outer deadline, auto-notifies). It fires for Claude, Codex, and Grok —
16
+ * their shell tool calls all reach the hook normalized to `tool_input.command`.
17
+ *
18
+ * Rollout gate: for now the guard only acts for `@getindigo.ai` HQ users; every
19
+ * other identity (and machine identities / logged-out) is allowed through.
20
+ *
21
+ * Exit codes: 0 = allow, 2 = block.
22
+ */
23
+ import { loadCachedTokens } from "@indigoai-us/hq-cloud";
24
+ import { peekIdToken } from "../../utils/id-token.js";
25
+ // Observed Claude Code Bash-tool bounds. The tool `timeout` (ms) raises the
26
+ // deadline up to the max; with none declared the default applies.
27
+ const HARNESS_DEFAULT_MS = 120_000; // 2 min
28
+ const HARNESS_MAX_MS = 600_000; // 10 min
29
+ const GATE_DOMAIN = "@getindigo.ai";
30
+ function basename(token) {
31
+ const i = token.lastIndexOf("/");
32
+ return i >= 0 ? token.slice(i + 1) : token;
33
+ }
34
+ /** Duration spec (Ns / Nm / Nh / Nd / bare N=seconds) → seconds, or 0. */
35
+ function durationToSecs(tok) {
36
+ const m = /^([0-9]+)([smhd]?)$/.exec(tok);
37
+ if (!m)
38
+ return 0;
39
+ const n = Number(m[1]);
40
+ switch (m[2]) {
41
+ case "":
42
+ case "s":
43
+ return n;
44
+ case "m":
45
+ return n * 60;
46
+ case "h":
47
+ return n * 3600;
48
+ case "d":
49
+ return n * 86400;
50
+ default:
51
+ return 0;
52
+ }
53
+ }
54
+ /**
55
+ * Worst-case inner deadline (seconds) declared in a command: a `timeout` /
56
+ * `gtimeout` prefix (matched by executable BASENAME, so `/usr/bin/timeout`
57
+ * counts) or a `perl -e 'alarm(N)…'` deadline. Segment on shell separators and
58
+ * only treat a segment's LEADING command word as an invocation, so the word
59
+ * `timeout` inside a quoted argument (e.g. `printf 'uses timeout 601s'`) does
60
+ * not trip it.
61
+ */
62
+ export function parseInnerDeadlineSecs(command) {
63
+ let worst = 0;
64
+ for (const rawSeg of command.split(/[;|&()]/)) {
65
+ const seg = rawSeg.trim();
66
+ if (!seg)
67
+ continue;
68
+ const words = seg.split(/\s+/);
69
+ let i = 0;
70
+ while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]))
71
+ i++; // skip env assigns
72
+ if (i >= words.length)
73
+ continue;
74
+ const cmd = basename(words[i]);
75
+ if (cmd !== "timeout" && cmd !== "gtimeout")
76
+ continue;
77
+ i++;
78
+ let dur = "";
79
+ while (i < words.length) {
80
+ const t = words[i];
81
+ if (t === "--foreground" || t === "--preserve-status" || t === "-v" || t === "--verbose") {
82
+ i++;
83
+ continue;
84
+ }
85
+ if (t === "-s" || t === "--signal" || t === "-k" || t === "--kill-after") {
86
+ i += 2;
87
+ continue;
88
+ }
89
+ if (t.startsWith("-")) {
90
+ i++;
91
+ continue;
92
+ }
93
+ dur = t;
94
+ break;
95
+ }
96
+ if (dur)
97
+ worst = Math.max(worst, durationToSecs(dur));
98
+ }
99
+ // perl alarm() — spans separators, so match on the raw command.
100
+ const mMin = /alarm\(\s*([0-9]+)\s*\*\s*60/.exec(command);
101
+ if (mMin)
102
+ worst = Math.max(worst, Number(mMin[1]) * 60);
103
+ else {
104
+ const mSec = /alarm\(?\s*([0-9]+)/.exec(command);
105
+ if (mSec)
106
+ worst = Math.max(worst, Number(mSec[1]));
107
+ }
108
+ return worst;
109
+ }
110
+ /**
111
+ * Pure decision: does this foreground declaration exceed the deadline it can
112
+ * actually secure? Compares the inner deadline against the EFFECTIVE ceiling —
113
+ * the declared tool timeout (capped at the harness max) or the 2-minute default
114
+ * when none is declared — rather than always against the 10-minute max.
115
+ */
116
+ export function evaluateTimeoutGuard(input) {
117
+ const { command, toolTimeoutMs } = input;
118
+ if (input.runInBackground)
119
+ return { block: false };
120
+ if (toolTimeoutMs !== undefined && toolTimeoutMs > HARNESS_MAX_MS) {
121
+ return {
122
+ block: true,
123
+ reason: `the Bash tool 'timeout' parameter is ${toolTimeoutMs}ms (> the ${HARNESS_MAX_MS}ms / 10-minute harness ceiling)`,
124
+ };
125
+ }
126
+ const effectiveMs = toolTimeoutMs !== undefined ? Math.min(toolTimeoutMs, HARNESS_MAX_MS) : HARNESS_DEFAULT_MS;
127
+ const innerSecs = parseInnerDeadlineSecs(command);
128
+ if (innerSecs > 0 && innerSecs * 1000 > effectiveMs) {
129
+ const ceilingDesc = toolTimeoutMs !== undefined
130
+ ? `the declared ${Math.round(effectiveMs / 1000)}s tool deadline`
131
+ : `the ~${HARNESS_DEFAULT_MS / 1000}s default foreground deadline`;
132
+ return {
133
+ block: true,
134
+ reason: `an inner ${innerSecs}s deadline (timeout/gtimeout/alarm) exceeds ${ceilingDesc}`,
135
+ };
136
+ }
137
+ return { block: false };
138
+ }
139
+ const BLOCK_MESSAGE = (reason) => `BLOCKED: this FOREGROUND command declares a timeout past the harness ceiling —
140
+ ${reason}.
141
+
142
+ A foreground shell tool call is SIGTERM'd at the harness's outer deadline
143
+ (~2 min default, 10 min max) regardless of any longer timeout you declared. It
144
+ will die with exit 143 ("timed out after 10m 0s") and lose whatever it was doing
145
+ — the largest harness-friction cluster in the 2026-08-10 team analysis
146
+ (finding 2.1, 1,393 events).
147
+
148
+ Do this instead: launch it as a background task (run_in_background: true).
149
+ Background tasks survive turn boundaries, have no outer deadline, and auto-notify
150
+ on completion. (\`hq core soft-timeout\` warns without killing for work you
151
+ supervise yourself.)
152
+
153
+ Policy: hq-foreground-timeout-killed-by-harness-deadline.
154
+ Bypass for one sanctioned foreground run: prefix HQ_ALLOW_LONG_FOREGROUND=1.`;
155
+ function defaultEmail() {
156
+ try {
157
+ const cached = loadCachedTokens();
158
+ if (!cached)
159
+ return undefined;
160
+ const email = peekIdToken(cached.idToken).email;
161
+ return typeof email === "string" ? email : undefined;
162
+ }
163
+ catch {
164
+ return undefined;
165
+ }
166
+ }
167
+ /** Whether the guard is active for this identity (rollout gate). */
168
+ export function isGatedUser(email) {
169
+ return typeof email === "string" && email.toLowerCase().endsWith(GATE_DOMAIN);
170
+ }
171
+ /**
172
+ * Command entry: read the PreToolUse JSON, apply the gate + short-circuits, and
173
+ * return the exit code (0 allow / 2 block). Never throws — a parse failure or a
174
+ * missing identity fails OPEN (allow), because a hook must not break tool calls.
175
+ */
176
+ export function timeoutGuardCommand(stdin, deps = {}) {
177
+ const env = deps.env ?? process.env;
178
+ const err = deps.stderr ?? process.stderr;
179
+ let payload;
180
+ try {
181
+ payload = JSON.parse(stdin);
182
+ }
183
+ catch {
184
+ return 0;
185
+ }
186
+ const ti = payload?.tool_input ?? {};
187
+ const command = typeof ti.command === "string" ? ti.command : "";
188
+ if (!command)
189
+ return 0;
190
+ // The inline escape hatch and the env escape hatch both bypass.
191
+ if (env.HQ_ALLOW_LONG_FOREGROUND === "1")
192
+ return 0;
193
+ if (/(^|\s)HQ_ALLOW_LONG_FOREGROUND=1(\s|$)/.test(command))
194
+ return 0;
195
+ // Rollout gate: only act for @getindigo.ai identities.
196
+ const getEmail = deps.getEmail ?? defaultEmail;
197
+ if (!isGatedUser(getEmail()))
198
+ return 0;
199
+ const runInBackground = ti.run_in_background === true;
200
+ const toolTimeoutMs = typeof ti.timeout === "number" ? ti.timeout : undefined;
201
+ const verdict = evaluateTimeoutGuard({ command, toolTimeoutMs, runInBackground });
202
+ if (!verdict.block)
203
+ return 0;
204
+ err.write(`${BLOCK_MESSAGE(verdict.reason ?? "declared timeout exceeds the harness ceiling")}\n`);
205
+ return 2;
206
+ }
207
+ //# sourceMappingURL=timeout-guard.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