@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.
Files changed (70) hide show
  1. package/CHANGELOG.md +52 -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.js +25 -5
  8. package/dist/commands/doctor.d.ts +97 -0
  9. package/dist/commands/doctor.js +228 -0
  10. package/dist/commands/scaffold-fast.d.ts +41 -0
  11. package/dist/commands/scaffold-fast.js +57 -0
  12. package/dist/fast-core.d.ts +16 -0
  13. package/dist/fast-core.js +47 -0
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +10 -1
  16. package/dist/lib/doctor/__testing__/fake-hq-tree.d.ts +194 -0
  17. package/dist/lib/doctor/__testing__/fake-hq-tree.js +357 -0
  18. package/dist/lib/doctor/allowed-divergence.d.ts +72 -0
  19. package/dist/lib/doctor/allowed-divergence.js +134 -0
  20. package/dist/lib/doctor/checks/claude-wiring.d.ts +55 -0
  21. package/dist/lib/doctor/checks/claude-wiring.js +524 -0
  22. package/dist/lib/doctor/checks/codex-wiring.d.ts +45 -0
  23. package/dist/lib/doctor/checks/codex-wiring.js +376 -0
  24. package/dist/lib/doctor/checks/grok-wiring.d.ts +35 -0
  25. package/dist/lib/doctor/checks/grok-wiring.js +186 -0
  26. package/dist/lib/doctor/checks/runtime-probe.d.ts +101 -0
  27. package/dist/lib/doctor/checks/runtime-probe.js +335 -0
  28. package/dist/lib/doctor/compat.d.ts +85 -0
  29. package/dist/lib/doctor/compat.js +102 -0
  30. package/dist/lib/doctor/deep/classify.d.ts +61 -0
  31. package/dist/lib/doctor/deep/classify.js +75 -0
  32. package/dist/lib/doctor/deep/effects.d.ts +107 -0
  33. package/dist/lib/doctor/deep/effects.js +229 -0
  34. package/dist/lib/doctor/deep/executor.d.ts +112 -0
  35. package/dist/lib/doctor/deep/executor.js +369 -0
  36. package/dist/lib/doctor/deep/parity.d.ts +129 -0
  37. package/dist/lib/doctor/deep/parity.js +355 -0
  38. package/dist/lib/doctor/deep/sandbox.d.ts +190 -0
  39. package/dist/lib/doctor/deep/sandbox.js +572 -0
  40. package/dist/lib/doctor/fix/apply.d.ts +119 -0
  41. package/dist/lib/doctor/fix/apply.js +352 -0
  42. package/dist/lib/doctor/fix/backup.d.ts +40 -0
  43. package/dist/lib/doctor/fix/backup.js +64 -0
  44. package/dist/lib/doctor/fix/remediation.d.ts +71 -0
  45. package/dist/lib/doctor/fix/remediation.js +103 -0
  46. package/dist/lib/doctor/fixtures/discover.d.ts +96 -0
  47. package/dist/lib/doctor/fixtures/discover.js +287 -0
  48. package/dist/lib/doctor/fixtures/schema.d.ts +171 -0
  49. package/dist/lib/doctor/fixtures/schema.js +248 -0
  50. package/dist/lib/doctor/hook-gate-profiles.d.ts +55 -0
  51. package/dist/lib/doctor/hook-gate-profiles.js +107 -0
  52. package/dist/lib/doctor/json-output.d.ts +90 -0
  53. package/dist/lib/doctor/json-output.js +76 -0
  54. package/dist/lib/doctor/payload-shapes.d.ts +170 -0
  55. package/dist/lib/doctor/payload-shapes.js +275 -0
  56. package/dist/lib/doctor/platform.d.ts +244 -0
  57. package/dist/lib/doctor/platform.js +490 -0
  58. package/dist/lib/doctor/registry.d.ts +49 -0
  59. package/dist/lib/doctor/registry.js +176 -0
  60. package/dist/lib/doctor/report.d.ts +87 -0
  61. package/dist/lib/doctor/report.js +164 -0
  62. package/dist/lib/doctor/types.d.ts +87 -0
  63. package/dist/lib/doctor/types.js +29 -0
  64. package/dist/main.js +6 -0
  65. package/dist/utils/hook-trust.d.ts +10 -13
  66. package/dist/utils/hook-trust.js +148 -27
  67. package/dist/utils/version-check.js +2 -2
  68. package/dist/utils/version-gate.d.ts +1 -1
  69. package/dist/utils/version-gate.js +1 -1
  70. package/package.json +2 -2
@@ -0,0 +1,490 @@
1
+ /**
2
+ * Host platform detection for `hq doctor`.
3
+ *
4
+ * The doctor runs live checks against the agent runtime it is embedded in, so it
5
+ * has to know which host launched it. This module answers that question and,
6
+ * crucially, records the *evidence* behind every verdict so the report can show
7
+ * its work rather than assert a bare label.
8
+ *
9
+ * ## Why this is the project's highest-risk piece
10
+ *
11
+ * `personal-context.md` and `core/docs/hq/HOOKS-NOT-FIRING.md` document that the
12
+ * Claude Code **app** and **SDK** runtimes do not dispatch command hooks at all,
13
+ * even when `.claude/settings.json` is present and correct. The terminal **CLI**
14
+ * does dispatch them. So the single most important distinction this module can
15
+ * get wrong is calling an app/SDK host a CLI host: that would let a runtime with
16
+ * *zero* live hook enforcement report PASS on hook checks — the exact false
17
+ * confidence the whole tool exists to prevent.
18
+ *
19
+ * The design therefore treats `claude-code-cli` as the verdict that requires the
20
+ * strongest, most specific positive evidence, and treats ambiguity as `unknown`
21
+ * rather than guessing:
22
+ *
23
+ * - `claude-code-cli` is asserted only on a positive CLI signal
24
+ * (`CLAUDE_CODE_ENTRYPOINT=cli`) with no competing strong signal.
25
+ * - `CLAUDECODE=1` alone is *weak*: the CLI, app, and SDK all set it, so on its
26
+ * own it proves only "some Claude Code host", never which one. Weak-only
27
+ * evidence resolves to `unknown`, never to CLI.
28
+ * - Conflicting strong signals (e.g. `entrypoint=cli` while a Claude desktop
29
+ * bundle id is also present) resolve to `unknown`, because a host that looks
30
+ * like two things at once cannot be trusted to be the safe one.
31
+ * - `unknown` is a first-class, safe outcome: the doctor still runs every
32
+ * platform-independent check and marks host-specific checks UNKNOWN (see
33
+ * {@link hostSpecificChecksTrustworthy}) rather than PASS.
34
+ *
35
+ * Residual limitation, stated honestly: if a non-dispatching host set
36
+ * `CLAUDE_CODE_ENTRYPOINT=cli` and left behind no other distinguishing signal,
37
+ * this module would report `claude-code-cli`. That gap is closed downstream by
38
+ * the runtime probe (US-006), which reads the policy-trigger ledger to confirm
39
+ * hooks *actually fired*, independent of the label here. Platform detection is a
40
+ * strong hint; the ledger is the proof.
41
+ *
42
+ * ## Signals, not a single variable
43
+ *
44
+ * Detection combines environment signals with process ancestry, mirroring the
45
+ * canonical shell detector `core/scripts/lib/detect-codex.sh`. Every signal is
46
+ * collected by its own small, individually testable function; {@link
47
+ * detectPlatform} composes them and applies the resolution rules above.
48
+ *
49
+ * The whole path is cheap (env reads plus at most one `ps` snapshot) and never
50
+ * launches an agent binary, satisfying the startup-latency budget.
51
+ */
52
+ import * as childProcess from "node:child_process";
53
+ /** Every platform the doctor can report, including the `unknown` fallback. */
54
+ export const PLATFORMS = [
55
+ "claude-code-cli",
56
+ "claude-code-app",
57
+ "claude-code-sdk",
58
+ "codex",
59
+ "grok",
60
+ "unknown",
61
+ ];
62
+ // --- environment signal collectors --------------------------------------------
63
+ //
64
+ // Each collector is exported and takes an explicit environment so a single
65
+ // signal can be exercised in isolation with a stubbed environment.
66
+ /**
67
+ * The `CLAUDE_CODE_ENTRYPOINT` signal — the one variable that distinguishes the
68
+ * Claude Code CLI from the SDK. `cli` ⇒ CLI; anything starting `sdk` (e.g.
69
+ * `sdk-ts`, `sdk-py`, `sdk-cli`) ⇒ SDK. Any other non-empty value is a Claude
70
+ * Code host of a variant we do not map, recorded as a *weak* Claude-app-leaning
71
+ * hint that on its own resolves to `unknown` rather than a CLI false positive.
72
+ */
73
+ export function entrypointEvidence(env) {
74
+ const raw = env.CLAUDE_CODE_ENTRYPOINT;
75
+ if (!raw)
76
+ return null;
77
+ const value = raw.trim();
78
+ if (value === "")
79
+ return null;
80
+ const key = "env.CLAUDE_CODE_ENTRYPOINT";
81
+ const detail = `CLAUDE_CODE_ENTRYPOINT=${value}`;
82
+ if (value === "cli") {
83
+ return { key, detail, points: "claude-code-cli", strength: "strong" };
84
+ }
85
+ if (/^sdk/i.test(value)) {
86
+ return { key, detail, points: "claude-code-sdk", strength: "strong" };
87
+ }
88
+ // A recognized Claude Code host of an unmapped variant. Deliberately weak and
89
+ // pointed at the app: we will not let it masquerade as the CLI, and if it is
90
+ // the only evidence the verdict falls to `unknown`.
91
+ return { key, detail, points: "claude-code-app", strength: "weak" };
92
+ }
93
+ /**
94
+ * The `CLAUDECODE=1` signal. Set by the CLI, app, and SDK alike, so it proves
95
+ * only that some Claude Code host is present — never which one. Always weak, and
96
+ * pointed at the app so it can never, on its own, promote a run to CLI.
97
+ */
98
+ export function claudeCodeFlagEvidence(env) {
99
+ const raw = env.CLAUDECODE;
100
+ if (!raw || raw.trim() === "" || raw.trim() === "0")
101
+ return null;
102
+ return {
103
+ key: "env.CLAUDECODE",
104
+ detail: `CLAUDECODE=${raw.trim()}`,
105
+ points: "claude-code-app",
106
+ strength: "weak",
107
+ };
108
+ }
109
+ /**
110
+ * The macOS `__CFBundleIdentifier` signal — the bundle of the app that launched
111
+ * this process, set when a GUI application spawns it. Mirrors the Codex check in
112
+ * `detect-codex.sh`. A terminal emulator sets its own bundle id (e.g.
113
+ * `com.googlecode.iterm2`), so this fires only under a real desktop host.
114
+ */
115
+ export function bundleIdEvidence(env) {
116
+ const raw = env.__CFBundleIdentifier;
117
+ if (!raw)
118
+ return null;
119
+ const id = raw.trim();
120
+ if (id === "")
121
+ return null;
122
+ const key = "env.__CFBundleIdentifier";
123
+ const detail = `__CFBundleIdentifier=${id}`;
124
+ if (id === "com.openai.codex") {
125
+ return { key, detail, points: "codex", strength: "strong" };
126
+ }
127
+ if (/grok|(?:^|\.)x\.ai(?:\.|$)|com\.xai/i.test(id)) {
128
+ return { key, detail, points: "grok", strength: "strong" };
129
+ }
130
+ if (looksLikeClaudeDesktopBundle(id)) {
131
+ return { key, detail, points: "claude-code-app", strength: "strong" };
132
+ }
133
+ return null;
134
+ }
135
+ /**
136
+ * A Claude *desktop app* bundle id — an Anthropic-owned bundle that names Claude.
137
+ * Kept deliberately narrow so a terminal or unrelated app never matches: the
138
+ * bundle must be under an Anthropic/Claude namespace, not merely mention the
139
+ * word.
140
+ */
141
+ function looksLikeClaudeDesktopBundle(id) {
142
+ const lower = id.toLowerCase();
143
+ const anthropicClaude = lower.startsWith("com.anthropic.") && lower.includes("claude");
144
+ const claudeDesktop = lower.includes("claude") && lower.includes("desktop");
145
+ return anthropicClaude || claudeDesktop;
146
+ }
147
+ /**
148
+ * Codex runtime environment variables, kept in parity with the canonical shell
149
+ * detector `core/scripts/lib/detect-codex.sh`. Presence of any is a strong Codex
150
+ * signal. One evidence entry is produced per variable found so a single Codex
151
+ * signal can be tested in isolation.
152
+ */
153
+ export const CODEX_ENV_VARS = [
154
+ "CODEX_SANDBOX",
155
+ "CODEX_SESSION_ID",
156
+ "CODEX_EXECUTION_ID",
157
+ "CODEX_AGENT_ID",
158
+ "CODEX_THREAD_ID",
159
+ "CODEX_SHELL",
160
+ "CODEX_CI",
161
+ "CODEX_INTERNAL_ORIGINATOR_OVERRIDE",
162
+ "OPENAI_CODEX",
163
+ ];
164
+ /** Every Codex environment signal present in `env`, one entry per variable. */
165
+ export function codexEnvEvidence(env) {
166
+ const found = [];
167
+ for (const name of CODEX_ENV_VARS) {
168
+ const raw = env[name];
169
+ if (raw === undefined || raw.trim() === "")
170
+ continue;
171
+ found.push({
172
+ key: `env.${name}`,
173
+ detail: `${name} is set`,
174
+ points: "codex",
175
+ strength: "strong",
176
+ });
177
+ }
178
+ return found;
179
+ }
180
+ /**
181
+ * Grok runtime environment variables. Restricted to variables the Grok host
182
+ * exports into the session (a session id, its workspace root) — HQ's own Grok
183
+ * *launcher* variables (`GROK_BIN`, `GROK_CONFIG`, `XAI_API_KEY`, …) are
184
+ * excluded on purpose: those can be set inside a Claude or Codex session that is
185
+ * merely preparing to launch Grok, and treating them as "we are running under
186
+ * Grok" would be a false positive.
187
+ */
188
+ export const GROK_ENV_VARS = ["GROK_SESSION_ID", "GROK_WORKSPACE_ROOT"];
189
+ /** Every Grok environment signal present in `env`, one entry per variable. */
190
+ export function grokEnvEvidence(env) {
191
+ const found = [];
192
+ for (const name of GROK_ENV_VARS) {
193
+ const raw = env[name];
194
+ if (raw === undefined || raw.trim() === "")
195
+ continue;
196
+ found.push({
197
+ key: `env.${name}`,
198
+ detail: `${name} is set`,
199
+ points: "grok",
200
+ strength: "strong",
201
+ });
202
+ }
203
+ return found;
204
+ }
205
+ // --- process ancestry signal --------------------------------------------------
206
+ /**
207
+ * Match an ancestor process's command against a platform's known binary names.
208
+ * `codex`/`codex-*`/`Codex*` ⇒ Codex, `grok`/`grok-*`/`Grok*` ⇒ Grok — the same
209
+ * name families `detect-codex.sh` walks for. A Codex/Grok ancestor is a strong
210
+ * signal because those runtimes launch a distinctly-named binary.
211
+ *
212
+ * The Claude CLI is a Node application whose process is frequently reported as
213
+ * `node`, so a Claude ancestor cannot be matched reliably by name; Claude relies
214
+ * on its environment signals instead, and this function does not guess it.
215
+ */
216
+ export function ancestryEvidence(ancestors) {
217
+ const found = [];
218
+ for (const ancestor of ancestors) {
219
+ const platform = classifyAncestorCommand(ancestor.command);
220
+ if (!platform)
221
+ continue;
222
+ found.push({
223
+ key: "proc.ancestor",
224
+ detail: `ancestor process ${basename(ancestor.command)} (pid ${ancestor.pid})`,
225
+ points: platform,
226
+ strength: "strong",
227
+ });
228
+ }
229
+ return found;
230
+ }
231
+ function classifyAncestorCommand(command) {
232
+ const exe = basename(command);
233
+ if (/^codex(-.*)?$/i.test(exe))
234
+ return "codex";
235
+ if (/^grok(-.*)?$/i.test(exe))
236
+ return "grok";
237
+ return null;
238
+ }
239
+ function basename(command) {
240
+ const trimmed = command.trim();
241
+ // `ps` may report a full path and/or trailing arguments; take the first token
242
+ // then its path basename.
243
+ const firstToken = trimmed.split(/\s+/)[0] ?? trimmed;
244
+ const parts = firstToken.split("/");
245
+ return parts[parts.length - 1] || firstToken;
246
+ }
247
+ /** Default hop bound when walking the ancestor chain. */
248
+ const DEFAULT_MAX_HOPS = 20;
249
+ /** Wall-clock bound for the single `ps` snapshot, keeping startup fast. */
250
+ const PS_TIMEOUT_MS = 250;
251
+ /** The one command this module ever launches to read process ancestry. */
252
+ export const PS_COMMAND = "ps";
253
+ /** `ps` arguments: every process as `pid ppid comm`, header-suppressed. */
254
+ export const PS_ARGS = ["-Ao", "pid=,ppid=,comm="];
255
+ /**
256
+ * Read this process's ancestor chain (nearest parent first) by taking one `ps`
257
+ * snapshot of the process table and walking parent pointers upward. A single
258
+ * spawn — never per-hop — keeps the cost to a few milliseconds, and it launches
259
+ * only `ps`, never an agent binary. Fails closed (returns `[]`) if `ps` is
260
+ * unavailable or its output cannot be parsed, so detection degrades to
261
+ * environment signals rather than throwing.
262
+ */
263
+ export function readProcessAncestry(options = {}) {
264
+ const startPid = options.pid ?? process.pid;
265
+ const maxHops = options.maxHops ?? DEFAULT_MAX_HOPS;
266
+ const spawner = options.spawner ?? defaultPsSpawner;
267
+ const out = spawner(PS_COMMAND, PS_ARGS);
268
+ if (!out)
269
+ return [];
270
+ const table = parseProcessTable(out);
271
+ const ancestors = [];
272
+ const seen = new Set();
273
+ let current = table.get(startPid);
274
+ let hops = 0;
275
+ while (current && hops < maxHops) {
276
+ const parentPid = current.ppid;
277
+ if (parentPid <= 1 || seen.has(parentPid))
278
+ break;
279
+ seen.add(parentPid);
280
+ const parent = table.get(parentPid);
281
+ if (!parent)
282
+ break;
283
+ ancestors.push({ pid: parentPid, command: parent.command });
284
+ current = parent;
285
+ hops += 1;
286
+ }
287
+ return ancestors;
288
+ }
289
+ function parseProcessTable(psOutput) {
290
+ const table = new Map();
291
+ for (const line of psOutput.split("\n")) {
292
+ const trimmed = line.trim();
293
+ if (trimmed === "")
294
+ continue;
295
+ // `pid ppid comm...` — comm may contain spaces (a full path), so split only
296
+ // the first two numeric columns off and keep the remainder as the command.
297
+ const match = /^(\d+)\s+(\d+)\s+(.*)$/.exec(trimmed);
298
+ if (!match)
299
+ continue;
300
+ const pid = Number.parseInt(match[1], 10);
301
+ const ppid = Number.parseInt(match[2], 10);
302
+ if (Number.isNaN(pid) || Number.isNaN(ppid))
303
+ continue;
304
+ table.set(pid, { ppid, command: match[3] });
305
+ }
306
+ return table;
307
+ }
308
+ /** The default spawner. The only place this module launches a process. */
309
+ function defaultPsSpawner(command, args) {
310
+ try {
311
+ const result = childProcess.spawnSync(command, [...args], {
312
+ encoding: "utf8",
313
+ timeout: PS_TIMEOUT_MS,
314
+ maxBuffer: 8 * 1024 * 1024,
315
+ });
316
+ if (result.error ||
317
+ result.status !== 0 ||
318
+ typeof result.stdout !== "string") {
319
+ return null;
320
+ }
321
+ return result.stdout;
322
+ }
323
+ catch {
324
+ return null;
325
+ }
326
+ }
327
+ // --- composition and resolution -----------------------------------------------
328
+ /**
329
+ * Collect the environment signals, in a stable order: entrypoint, the Claude
330
+ * flag, bundle id, Codex vars, then Grok vars. Process ancestry is collected
331
+ * separately (and lazily) by {@link detectPlatform}.
332
+ */
333
+ export function collectEnvEvidence(env) {
334
+ const evidence = [];
335
+ const entrypoint = entrypointEvidence(env);
336
+ if (entrypoint)
337
+ evidence.push(entrypoint);
338
+ const flag = claudeCodeFlagEvidence(env);
339
+ if (flag)
340
+ evidence.push(flag);
341
+ const bundle = bundleIdEvidence(env);
342
+ if (bundle)
343
+ evidence.push(bundle);
344
+ evidence.push(...codexEnvEvidence(env));
345
+ evidence.push(...grokEnvEvidence(env));
346
+ return evidence;
347
+ }
348
+ /**
349
+ * Collect every signal from an environment and an explicit ancestor chain, in a
350
+ * stable order: environment signals first, then process ancestry. Exposed for
351
+ * callers that already hold an ancestor chain and want the full combined view.
352
+ */
353
+ export function collectPlatformEvidence(env, ancestors) {
354
+ return [...collectEnvEvidence(env), ...ancestryEvidence(ancestors)];
355
+ }
356
+ /** The distinct platforms with a `strong` signal in an evidence list. */
357
+ function strongPlatformsOf(evidence) {
358
+ return new Set(evidence.filter((e) => e.strength === "strong").map((e) => e.points));
359
+ }
360
+ /**
361
+ * Detect the host platform from environment signals and process ancestry, and
362
+ * return the verdict together with the evidence that produced it.
363
+ *
364
+ * Resolution rules (see the module header for the safety rationale):
365
+ * 1. If exactly one platform has strong evidence, that platform wins.
366
+ * 2. If more than one platform has strong evidence, the verdict is `unknown`
367
+ * (a host that looks like two things cannot be trusted to be the safe one).
368
+ * 3. With no strong evidence, weak signals never establish a platform — the
369
+ * verdict is `unknown`, which keeps a bare `CLAUDECODE=1` from being read
370
+ * as the CLI.
371
+ *
372
+ * Process ancestry is consulted **lazily**: when the environment already yields
373
+ * exactly one strong platform, or a genuine strong conflict, the ancestor chain
374
+ * cannot safely change the answer, so it is not read at all. This makes the
375
+ * common case (a dispositive env signal) spawn no process whatsoever, and limits
376
+ * the one `ps` snapshot to the cases where env signals are silent and ancestry
377
+ * is the only remaining evidence.
378
+ */
379
+ export function detectPlatform(options = {}) {
380
+ const env = options.env ?? process.env;
381
+ const readAncestry = options.ancestry ?? readProcessAncestry;
382
+ const envEvidence = collectEnvEvidence(env);
383
+ const envStrong = strongPlatformsOf(envEvidence);
384
+ // A single strong env signal, or a genuine env conflict, is already decisive;
385
+ // reading process ancestry cannot safely override it, so skip the ps spawn.
386
+ if (envStrong.size >= 1) {
387
+ return resolveVerdict(envEvidence);
388
+ }
389
+ // Env signals are silent or only weak — consult process ancestry for a
390
+ // stronger signal (e.g. a `codex`/`grok` binary in the parent chain).
391
+ const ancestors = readAncestry();
392
+ const evidence = [...envEvidence, ...ancestryEvidence(ancestors)];
393
+ return resolveVerdict(evidence);
394
+ }
395
+ /** Apply the resolution rules to a fully-collected evidence list. */
396
+ function resolveVerdict(evidence) {
397
+ const strong = evidence.filter((e) => e.strength === "strong");
398
+ const strongPlatforms = new Set(strong.map((e) => e.points));
399
+ if (strongPlatforms.size === 1) {
400
+ const [platform] = [...strongPlatforms];
401
+ const decidedBy = strong.map((e) => e.key);
402
+ return {
403
+ platform,
404
+ evidence,
405
+ decidedBy,
406
+ reason: `Resolved to ${platformLabel(platform)} from ${describeKeys(decidedBy)}.`,
407
+ };
408
+ }
409
+ if (strongPlatforms.size > 1) {
410
+ const names = [...strongPlatforms].map(platformLabel).join(", ");
411
+ return {
412
+ platform: "unknown",
413
+ evidence,
414
+ decidedBy: [],
415
+ reason: `Conflicting strong signals for ${names}; cannot safely pick one, so reporting unknown.`,
416
+ };
417
+ }
418
+ if (evidence.length > 0) {
419
+ return {
420
+ platform: "unknown",
421
+ evidence,
422
+ decidedBy: [],
423
+ reason: "Only weak or ambiguous signals were found (e.g. CLAUDECODE is shared by the CLI, app, and SDK), so the specific host cannot be established; reporting unknown rather than a possibly-false verdict.",
424
+ };
425
+ }
426
+ return {
427
+ platform: "unknown",
428
+ evidence,
429
+ decidedBy: [],
430
+ reason: "No recognizable platform signals were found in the environment or process ancestry.",
431
+ };
432
+ }
433
+ // --- consumer-facing helpers --------------------------------------------------
434
+ const PLATFORM_LABELS = {
435
+ "claude-code-cli": "Claude Code (CLI)",
436
+ "claude-code-app": "Claude Code (app)",
437
+ "claude-code-sdk": "Claude Code (SDK)",
438
+ codex: "Codex",
439
+ grok: "Grok",
440
+ unknown: "unknown",
441
+ };
442
+ /** A human-readable label for a platform, for text and report output. */
443
+ export function platformLabel(platform) {
444
+ return PLATFORM_LABELS[platform];
445
+ }
446
+ /**
447
+ * Whether host-specific *live* checks can be trusted to report PASS on this
448
+ * platform. False for `unknown`: a check that verifies live behavior on the
449
+ * detected host has no trustworthy host to verify against when the host is
450
+ * unknown, and must report UNKNOWN instead of PASS. This is the single source of
451
+ * truth the runtime tier (US-006) keys its UNKNOWN-not-PASS behavior off.
452
+ */
453
+ export function hostSpecificChecksTrustworthy(platform) {
454
+ return platform !== "unknown";
455
+ }
456
+ /**
457
+ * The status a host-specific check must use when the platform is unknown —
458
+ * always {@link DoctorStatus} `UNKNOWN`, never PASS. Exposed so consumers share
459
+ * one honest answer rather than each re-deriving it.
460
+ */
461
+ export function unknownPlatformCheckStatus() {
462
+ return "UNKNOWN";
463
+ }
464
+ /**
465
+ * Render a detection as plain text lines for the default report: the labelled
466
+ * verdict, the reason, and each piece of evidence. US-015 owns the surrounding
467
+ * layout, colour, and `--json`; this is a self-contained, colour-free block it
468
+ * can drop in.
469
+ */
470
+ export function formatPlatformLines(detection) {
471
+ const lines = [`Platform: ${platformLabel(detection.platform)}`];
472
+ lines.push(` ${detection.reason}`);
473
+ if (detection.evidence.length === 0) {
474
+ lines.push(" Evidence: none");
475
+ return lines;
476
+ }
477
+ lines.push(" Evidence:");
478
+ for (const item of detection.evidence) {
479
+ const decided = detection.decidedBy.includes(item.key) ? " (decisive)" : "";
480
+ lines.push(` - [${item.strength}] ${item.detail} → ${platformLabel(item.points)}${decided}`);
481
+ }
482
+ return lines;
483
+ }
484
+ function describeKeys(keys) {
485
+ if (keys.length === 0)
486
+ return "no signals";
487
+ const unique = [...new Set(keys)];
488
+ return unique.join(", ");
489
+ }
490
+ //# sourceMappingURL=platform.js.map
@@ -0,0 +1,49 @@
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 type { CheckContext, CheckFamily, FamilyRun } from "./types.js";
17
+ /**
18
+ * An ordered, id-keyed set of check families. Registration order is preserved
19
+ * (Map iteration order), and re-registering an id replaces the family in place
20
+ * rather than appending a duplicate.
21
+ */
22
+ export declare class DoctorRegistry {
23
+ private readonly familiesById;
24
+ /** Register a family. Re-registering the same id replaces it, keeping order. */
25
+ register(family: CheckFamily): void;
26
+ /** The registered families, in registration order. */
27
+ families(): CheckFamily[];
28
+ /** Whether a family with this id is registered. */
29
+ has(id: string): boolean;
30
+ /** Run every family in order and collect grouped results. */
31
+ run(context: CheckContext): Promise<FamilyRun[]>;
32
+ }
33
+ /** The id of the hooks family — the first, and for now only, check family. */
34
+ export declare const HOOKS_FAMILY_ID = "hooks";
35
+ /**
36
+ * The hooks check family. US-002 shipped the skeleton (Claude settings presence
37
+ * and validity). US-005 adds the Codex and Grok wiring/parity tiers, which run
38
+ * for every platform regardless of host. US-004+ extend the Claude tier with the
39
+ * full settings/registration wiring checks. Each tier appends its own results;
40
+ * the engine (DoctorRegistry) is untouched.
41
+ */
42
+ export declare const hooksFamily: CheckFamily;
43
+ /**
44
+ * Build a registry pre-loaded with the default families. Hooks is the first
45
+ * family; later families are registered here as they are implemented, each
46
+ * one line, with no engine change.
47
+ */
48
+ export declare function createDefaultRegistry(): DoctorRegistry;
49
+ //# sourceMappingURL=registry.d.ts.map