@indigoai-us/hq-cli 5.103.24 → 5.103.26

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 CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.103.26] — 2026-08-27
6
+
7
+ ### Changed
8
+
9
+ - Company skill creation and collaboration now use the shared `skills/`
10
+ directory end to end: new skills are discovered and registered with stable
11
+ IDs, `hq skill propose` posts comment-only improvements, and local Claude,
12
+ Cursor, and Codex skill links stay scoped to the active company.
13
+
14
+ ## [5.103.25] — 2026-08-27
15
+
16
+ ### Fixed
17
+
18
+ - Codex Stop hooks now record their automatic checkpoint out of band after a
19
+ user-facing reply has already been delivered, avoiding the visible
20
+ `Hook re-prompted Codex` filler message while preserving the missing-reply
21
+ safety gate.
22
+
5
23
  ## [5.103.24] — 2026-08-27
6
24
 
7
25
  ## [5.103.23] — 2026-08-26
@@ -490,6 +490,32 @@ set -uo pipefail
490
490
  exit 0
491
491
  fi
492
492
 
493
+ # A turn whose user-facing reply is ALREADY delivered cannot be blocked into
494
+ # silence. Hosts re-prompt an assistant turn that produces no visible text
495
+ # ("your previous response had no visible output"), so blocking here forces a
496
+ # trailing filler message — the very double-messaging this gate exists to
497
+ # prevent. Every wording tried so far only shrank that filler (full
498
+ # restatement, then a meta-note, then a bare <br>); none removed it, because
499
+ # the instruction asks for a turn shape the host will not accept.
500
+ #
501
+ # So do not ask. Let the turn end and record the checkpoint out-of-band. The
502
+ # payload carries the transcript, which is what the sibling already reads to
503
+ # derive files, decisions and learnings, so the record keeps its substance
504
+ # without an agent-authored summary. Turns that checkpoint BEFORE replying
505
+ # never reach this branch and keep the full hand-written payload; the
506
+ # reply-required gate above still runs first, so a turn with no reply at all
507
+ # is unaffected.
508
+ if [ "$replied" = 1 ]; then
509
+ (nohup hq core checkpoint \
510
+ --session-id "$session_id" \
511
+ --trigger stop-gate-auto \
512
+ --transcript "$transcript_path" \
513
+ --summary "turn ended with its user-facing reply already delivered" \
514
+ >/dev/null 2>&1 &)
515
+ rm -f "$block_count_file" 2>/dev/null || true
516
+ exit 0
517
+ fi
518
+
493
519
  # Loop guard for the missing-checkpoint demand below: cap consecutive blocks
494
520
  # per session, then fail open (see the counter's comment above).
495
521
  if [ "$block_count" -ge 3 ]; then
@@ -500,18 +526,12 @@ set -uo pipefail
500
526
  # Built with printf rather than concatenation so the session id can appear in
501
527
  # both commands without re-splitting the message into fragments.
502
528
  #
503
- # Two variants, chosen from the transcript rather than left to the agent's
504
- # judgment: a single instruction either demanded a full restatement
505
- # (double-messaging hosts that render pre-tool text) or forbade
506
- # post-checkpoint text (hiding the reply when the agent had not yet written
507
- # one). The transcript already tells us which case we are in, so say exactly
508
- # one thing.
529
+ # Only the not-yet-replied case reaches here a delivered reply took the
530
+ # out-of-band branch above — so this instruction never has to forbid a
531
+ # restatement, and the agent's reply lands in final position where every host
532
+ # renders it in full.
509
533
  flags_spec=' hq core checkpoint --session-id %s --trigger stop-gate --summary "<what changed, in one line>" [--file <path>] [--decision "<choice and why>"] [--learning "<reusable rule>"] [--next "<outstanding step>"]\n\nOnly --summary is required, and the repeatable flags are what the sibling uses to enrich the record, distil policies and update the indexes — a bare summary gives it almost nothing to work with. Write them as machine record, not prose for the user, and pass each one that genuinely applies:\n --file every path you created or modified this turn\n --decision a choice you made that a reader would otherwise have to reverse-engineer\n --learning a rule that changes how someone acts next time, not a restatement of what just happened\n --next work that is genuinely still outstanding\nOmit a flag rather than padding it: an empty or invented learning is worse than none.\n\nIf this turn only read or inspected things and changed no state, the correct call instead is:\n\n hq core checkpoint --session-id %s --idle'
510
- if [ "$replied" = 1 ]; then
511
- reason="$(printf 'This turn changed something, so it needs an end-of-turn checkpoint before it can end.\n\nYour user-facing reply is ALREADY delivered — the message you just wrote is visible to the user. Do NOT send it again, in full or summarized: repeating it double-messages the user, which is exactly the bug this gate guards against.\n\nTHE SIBLING (a background maintenance agent) reads only the checkpoint payload, never your chat reply — anything it needs must go into the flags.\n\nRun the checkpoint now as the FINAL action of the turn and end the turn immediately after it, adding no further text:\n\n'"$flags_spec" "$session_id" "$session_id")"
512
- else
513
- reason="$(printf 'This turn changed something, so it needs an end-of-turn checkpoint. Two different audiences are involved — do not conflate them:\n\n1. THE USER reads your normal chat reply. You have not sent one yet this turn — everything you owe them (results, links, answers, status, decisions) must go into it. The checkpoint is invisible to them and does NOT count as having replied.\n2. THE SIBLING (a background maintenance agent) reads the checkpoint payload. It never sees your chat reply, so anything it needs must go into the flags.\n\nORDER: run the checkpoint FIRST, then deliver your complete user-facing reply as the FINAL text of the turn — final-position text is the one placement every host renders in full. Every link, URL, instruction, command, and decision the user needs must appear in that final message. The gate enforces this: a turn that ends without a user-facing reply is blocked until the reply is delivered.\n\n'"$flags_spec" "$session_id" "$session_id")"
514
- fi
534
+ reason="$(printf 'This turn changed something, so it needs an end-of-turn checkpoint. Two different audiences are involved — do not conflate them:\n\n1. THE USER reads your normal chat reply. You have not sent one yet this turn — everything you owe them (results, links, answers, status, decisions) must go into it. The checkpoint is invisible to them and does NOT count as having replied.\n2. THE SIBLING (a background maintenance agent) reads the checkpoint payload. It never sees your chat reply, so anything it needs must go into the flags.\n\nORDER: run the checkpoint FIRST, then deliver your complete user-facing reply as the FINAL text of the turn — final-position text is the one placement every host renders in full. Every link, URL, instruction, command, and decision the user needs must appear in that final message. The gate enforces this: a turn that ends without a user-facing reply is blocked until the reply is delivered.\n\n'"$flags_spec" "$session_id" "$session_id")"
515
535
 
516
536
  # Codex surfaces a blocked Stop reason as a synthetic user prompt. Preserve
517
537
  # the actionable instruction out-of-band, then use the stable marker covered
@@ -7,10 +7,9 @@
7
7
  * to every check.
8
8
  * - Running outside any HQ tree exits non-zero with a message naming exactly
9
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. `--live-runtimes` is
12
- * the one opt-in exception: it probes the installed AI CLIs (claude, codex,
13
- * grok) with a version read and a one-line prompt.
10
+ * - Most families inspect the local tree. The integrations family makes one
11
+ * control-plane inventory read for the active company, without invoking a
12
+ * provider tool. `--live-runtimes` is the opt-in AI CLI probe.
14
13
  *
15
14
  * US-015 adds reporting, `--json`, and the exit-code contract: the exit code is
16
15
  * 0 unless some result is FAIL or UNKNOWN (WARN/UNTESTED/NA/KNOWN-DEFECT never
@@ -86,6 +85,10 @@ export interface RunDoctorOptions {
86
85
  * family reports UNTESTED rather than spawning anything.
87
86
  */
88
87
  liveRuntimes?: boolean;
88
+ /** Company slug for the integrations family; omitted uses active membership. */
89
+ company?: string;
90
+ /** Internal test seam; production CLI always runs integrations checks. */
91
+ integrations?: boolean;
89
92
  }
90
93
  /** The outcome of a doctor run, returned rather than thrown so it is testable. */
91
94
  export interface RunDoctorResult {
@@ -98,7 +101,8 @@ export interface RunDoctorResult {
98
101
  }
99
102
  /**
100
103
  * Resolve the HQ root, run every registered check family against it, and render
101
- * a plain-text summary. Makes no network calls and needs no authentication.
104
+ * a plain-text summary. The integrations family makes one bounded inventory
105
+ * read; all other default tiers remain local unless explicitly opted in.
102
106
  */
103
107
  export declare function runDoctor(options?: RunDoctorOptions): Promise<RunDoctorResult>;
104
108
  /** Register the top-level `hq doctor` command so it appears in `hq --help`. */
@@ -7,10 +7,9 @@
7
7
  * to every check.
8
8
  * - Running outside any HQ tree exits non-zero with a message naming exactly
9
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. `--live-runtimes` is
12
- * the one opt-in exception: it probes the installed AI CLIs (claude, codex,
13
- * grok) with a version read and a one-line prompt.
10
+ * - Most families inspect the local tree. The integrations family makes one
11
+ * control-plane inventory read for the active company, without invoking a
12
+ * provider tool. `--live-runtimes` is the opt-in AI CLI probe.
14
13
  *
15
14
  * US-015 adds reporting, `--json`, and the exit-code contract: the exit code is
16
15
  * 0 unless some result is FAIL or UNKNOWN (WARN/UNTESTED/NA/KNOWN-DEFECT never
@@ -79,7 +78,8 @@ function isHqRoot(dir) {
79
78
  }
80
79
  /**
81
80
  * Resolve the HQ root, run every registered check family against it, and render
82
- * a plain-text summary. Makes no network calls and needs no authentication.
81
+ * a plain-text summary. The integrations family makes one bounded inventory
82
+ * read; all other default tiers remain local unless explicitly opted in.
83
83
  */
84
84
  export async function runDoctor(options = {}) {
85
85
  const write = options.stdout ?? ((chunk) => void process.stdout.write(chunk));
@@ -102,6 +102,8 @@ export async function runDoctor(options = {}) {
102
102
  platform: { id: platform.id, evidence: platform.evidence },
103
103
  sessionId: options.sessionId,
104
104
  liveRuntimes: options.liveRuntimes === true,
105
+ company: options.company,
106
+ integrations: options.integrations !== false,
105
107
  };
106
108
  const families = await registry.run(context);
107
109
  // `--deep-test` (US-008): after the read-only tiers, actually fire pure-guard
@@ -150,13 +152,14 @@ export async function runDoctor(options = {}) {
150
152
  export function registerDoctorCommand(program) {
151
153
  program
152
154
  .command("doctor")
153
- .description("Verify HQ hook guardrails are wired and firing (read-only, offline; --live-runtimes adds networked AI CLI probes).")
155
+ .description("Verify HQ hook wiring, runtimes, and connection health (read-only; integrations uses one control-plane inventory read).")
154
156
  .option("--json", "Emit the machine-readable JSON document (no colour).")
155
157
  .option("--verbose", "Also print every PASS result in text output.")
156
158
  .option("--no-color", "Disable ANSI colour even on a TTY.")
157
159
  .option("--session-id <id>", "Scope the runtime probe's ledger check to this exact session.")
158
160
  .option("--deep-test", "Also fire pure-guard hooks through the real gate under all three profiles (sandboxed).")
159
161
  .option("--live-runtimes", "Also probe each installed AI CLI (claude, codex, grok) with a one-line prompt to verify login and subscription (networked; uses your subscriptions).")
162
+ .option("--company <slug>", "Company slug for integrations checks (otherwise resolves your single active company).")
160
163
  .option("--fix", "Apply the allowlisted safe repairs (backs up first; read-only without this flag).")
161
164
  .option("--yes", "Skip the interactive --fix confirmation (non-interactive use).")
162
165
  .option("--force", "Let --fix run despite uncommitted changes under .claude/, .codex/, or .grok/.")
@@ -204,6 +207,7 @@ export function registerDoctorCommand(program) {
204
207
  sessionId: opts.sessionId,
205
208
  deepTest: opts.deepTest === true,
206
209
  liveRuntimes: opts.liveRuntimes === true,
210
+ company: opts.company,
207
211
  });
208
212
  // Set the exit code rather than calling process.exit, so the CLI's
209
213
  // normal shutdown (telemetry flush) still runs. Non-zero means either an
@@ -2,7 +2,7 @@
2
2
  * Company skill creation and comment-only improvements.
3
3
  *
4
4
  * `hq skill create <slug>` registers a canonical company skill, stamps its
5
- * immutable UID, reindexes its generated runtime wrapper, and syncs it.
5
+ * immutable UID, surfaces its generated runtime wrapper, and syncs it.
6
6
  * `hq skill propose <uid|path> --message "…"` posts a whole-skill comment to
7
7
  * the same improvement thread shown in HQ Console. It never uploads a modified
8
8
  * SKILL.md and cannot overwrite live content. Structured suggest/list/review
@@ -11,6 +11,7 @@
11
11
  import { Command } from "commander";
12
12
  import { ensureCognitoToken } from "../utils/cognito-session.js";
13
13
  import { vaultApiFetch } from "../utils/vault-api.js";
14
+ import { surfaceCompanySkill } from "../lib/company-skill-wrapper.js";
14
15
  export declare const SKILL_UID_PATTERN: RegExp;
15
16
  export declare const SKILL_SLUG_PATTERN: RegExp;
16
17
  interface SkillSyncInput {
@@ -27,6 +28,7 @@ interface SkillSyncResult {
27
28
  export declare function parseSkillUid(markdown: string): string | undefined;
28
29
  export declare function readActiveCompanySlug(hqRoot: string): string | undefined;
29
30
  export declare function resolveCompanySlug(flag: string | undefined, hqRoot?: string): string;
31
+ export declare function readCompanyPrefix(hqRoot: string, companySlug: string): string | undefined;
30
32
  /** Resolve a UID directly, or read the immutable UID from a local SKILL.md. */
31
33
  export declare function resolveSkillUid(target: string, cwd: string): string;
32
34
  export declare function canonicalCompanySkillPath(hqRoot: string, companySlug: string, skillSlug: string): string;
@@ -44,11 +46,7 @@ interface SkillCommandDeps {
44
46
  cwd?: () => string;
45
47
  hqRoot?: string;
46
48
  syncFile?: (input: SkillSyncInput) => Promise<SkillSyncResult>;
47
- reindexFn?: (input: {
48
- repoRoot: string;
49
- }) => {
50
- status: number | null;
51
- };
49
+ surfaceSkillFn?: typeof surfaceCompanySkill;
52
50
  }
53
51
  export declare function registerSkillCommand(program: Command, deps?: SkillCommandDeps): Command;
54
52
  export {};
@@ -2,7 +2,7 @@
2
2
  * Company skill creation and comment-only improvements.
3
3
  *
4
4
  * `hq skill create <slug>` registers a canonical company skill, stamps its
5
- * immutable UID, reindexes its generated runtime wrapper, and syncs it.
5
+ * immutable UID, surfaces its generated runtime wrapper, and syncs it.
6
6
  * `hq skill propose <uid|path> --message "…"` posts a whole-skill comment to
7
7
  * the same improvement thread shown in HQ Console. It never uploads a modified
8
8
  * SKILL.md and cannot overwrite live content. Structured suggest/list/review
@@ -12,9 +12,10 @@ import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import chalk from "chalk";
14
14
  import yaml from "js-yaml";
15
- import { reindex, share } from "@indigoai-us/hq-cloud";
15
+ import { share } from "@indigoai-us/hq-cloud";
16
16
  import { ensureCognitoToken, DEFAULT_HQ_ROOT, buildVaultConfig, } from "../utils/cognito-session.js";
17
17
  import { vaultApiFetch } from "../utils/vault-api.js";
18
+ import { surfaceCompanySkill } from "../lib/company-skill-wrapper.js";
18
19
  export const SKILL_UID_PATTERN = /^skl_[A-Za-z0-9]+$/;
19
20
  export const SKILL_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
20
21
  const COMPANY_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
@@ -69,6 +70,31 @@ export function resolveCompanySlug(flag, hqRoot = DEFAULT_HQ_ROOT) {
69
70
  }
70
71
  return slug;
71
72
  }
73
+ export function readCompanyPrefix(hqRoot, companySlug) {
74
+ const manifestPath = path.join(hqRoot, "companies", "manifest.yaml");
75
+ if (!fs.existsSync(manifestPath))
76
+ return undefined;
77
+ try {
78
+ const parsed = yaml.load(fs.readFileSync(manifestPath, "utf8"));
79
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
80
+ return undefined;
81
+ const companies = parsed.companies;
82
+ if (!companies || typeof companies !== "object" || Array.isArray(companies)) {
83
+ return undefined;
84
+ }
85
+ const company = companies[companySlug];
86
+ if (!company || typeof company !== "object" || Array.isArray(company)) {
87
+ return undefined;
88
+ }
89
+ const prefix = company.prefix;
90
+ return typeof prefix === "string" && /^[a-z0-9][a-z0-9-]*$/.test(prefix)
91
+ ? prefix
92
+ : undefined;
93
+ }
94
+ catch {
95
+ return undefined;
96
+ }
97
+ }
72
98
  /** Resolve a UID directly, or read the immutable UID from a local SKILL.md. */
73
99
  export function resolveSkillUid(target, cwd) {
74
100
  if (SKILL_UID_PATTERN.test(target))
@@ -159,7 +185,7 @@ export function registerSkillCommand(program, deps = {}) {
159
185
  const cwd = deps.cwd ?? process.cwd;
160
186
  const hqRoot = deps.hqRoot ?? DEFAULT_HQ_ROOT;
161
187
  const syncFile = deps.syncFile ?? defaultSyncFile;
162
- const reindexFn = deps.reindexFn ?? reindex;
188
+ const surfaceSkillFn = deps.surfaceSkillFn ?? surfaceCompanySkill;
163
189
  const skill = program
164
190
  .command("skill")
165
191
  .description("Create company skills and discuss improvements")
@@ -167,10 +193,11 @@ export function registerSkillCommand(program, deps = {}) {
167
193
  .option("--hq-root <path>", "Local HQ root", hqRoot);
168
194
  skill
169
195
  .command("create <slug>")
170
- .description("Register, stamp, reindex, and sync a company skill")
196
+ .description("Register, stamp, surface, and sync a company skill")
171
197
  .option("--name <name>", "Display name when creating a new template")
172
198
  .option("--description <text>", "Description when creating a new template")
173
199
  .option("--no-sync", "Register locally without uploading the stamped file")
200
+ .option("--surface-only", "Refresh local skill discovery without registration or sync")
174
201
  .action(async (slug, opts) => {
175
202
  if (!SKILL_SLUG_PATTERN.test(slug)) {
176
203
  throw new Error("Skill slug must start with a lowercase letter or number and contain only lowercase letters, numbers, and hyphens.");
@@ -179,9 +206,24 @@ export function registerSkillCommand(program, deps = {}) {
179
206
  const resolvedRoot = path.resolve(parentOpts.hqRoot ?? hqRoot);
180
207
  const companySlug = resolveCompanySlug(parentOpts.company, resolvedRoot);
181
208
  const filePath = canonicalCompanySkillPath(resolvedRoot, companySlug, slug);
209
+ const legacyPrefix = readCompanyPrefix(resolvedRoot, companySlug);
182
210
  if (fs.existsSync(filePath) && !fs.statSync(filePath).isFile()) {
183
211
  throw new Error(`Expected a SKILL.md file at '${filePath}'.`);
184
212
  }
213
+ if (opts.surfaceOnly === true) {
214
+ // Discovery wrappers are execution surfaces. Never create one for an
215
+ // unregistered file, because that would bypass immutable identity and
216
+ // the FILE_ACL-backed registration flow.
217
+ resolveSkillUid(filePath, resolvedRoot);
218
+ const surfaced = surfaceSkillFn({
219
+ hqRoot: resolvedRoot,
220
+ companySlug,
221
+ skillSlug: slug,
222
+ ...(legacyPrefix ? { legacyPrefix } : {}),
223
+ });
224
+ console.log(chalk.green(`Local discovery ready: ${surfaced.wrapperPath}`));
225
+ return;
226
+ }
185
227
  const localContent = fs.existsSync(filePath)
186
228
  ? fs.readFileSync(filePath, "utf8")
187
229
  : makeSkillTemplate({
@@ -213,15 +255,20 @@ export function registerSkillCommand(program, deps = {}) {
213
255
  throw new Error("The server returned an invalid skill registration response; the local file was not changed.");
214
256
  }
215
257
  writeSkillFileAtomically(filePath, registered.content);
216
- let reindexStatus = null;
258
+ let discoveryStatus = null;
217
259
  try {
218
- reindexStatus = reindexFn({ repoRoot: resolvedRoot }).status;
260
+ discoveryStatus = surfaceSkillFn({
261
+ hqRoot: resolvedRoot,
262
+ companySlug,
263
+ skillSlug: slug,
264
+ ...(legacyPrefix ? { legacyPrefix } : {}),
265
+ }).status;
219
266
  }
220
267
  catch (err) {
221
- console.warn(chalk.yellow(`⚠ Skill registered, but HQ reindex failed: ${err instanceof Error ? err.message : String(err)}`));
268
+ console.warn(chalk.yellow(`⚠ Skill registered, but local discovery failed: ${err instanceof Error ? err.message : String(err)}`));
222
269
  }
223
- if (reindexStatus !== null && reindexStatus !== 0) {
224
- console.warn(chalk.yellow(`⚠ Skill registered, but HQ reindex exited ${reindexStatus}. Run 'hq reindex --repo-root ${resolvedRoot}' to retry discovery.`));
270
+ if (discoveryStatus !== null && discoveryStatus !== 0) {
271
+ console.warn(chalk.yellow(`⚠ Skill registered, but local discovery exited ${discoveryStatus}. Run 'hq skill --company ${companySlug} create ${slug} --surface-only' to retry without another registration request.`));
225
272
  }
226
273
  if (opts.sync !== false) {
227
274
  let syncResult;
@@ -242,7 +289,7 @@ export function registerSkillCommand(program, deps = {}) {
242
289
  }
243
290
  console.log(chalk.green(`Skill ready: ${registered.skillUid}`));
244
291
  console.log(` File: ${filePath}`);
245
- console.log(` Discovery: ${reindexStatus === 0 ? "reindexed" : "reindex needs attention"}`);
292
+ console.log(` Discovery: ${discoveryStatus === 0 ? "ready" : "needs attention"}`);
246
293
  console.log(registered.accessPolicy === "open"
247
294
  ? ` Access: Open — every active ${companySlug} member can edit`
248
295
  : " Access: preserved existing policy");
@@ -0,0 +1,24 @@
1
+ import * as fs from "node:fs";
2
+ export interface SurfaceCompanySkillInput {
3
+ hqRoot: string;
4
+ companySlug: string;
5
+ skillSlug: string;
6
+ /** Prefix used by pre-namespaced generated wrappers (for one-time cleanup). */
7
+ legacyPrefix?: string;
8
+ /** Test seam for the Win32 filename adapter. */
9
+ win32?: boolean;
10
+ /** Test seam for replacement rollback failures. */
11
+ renameEntry?: typeof fs.renameSync;
12
+ /** Test seam for environments that deny local symlink creation. */
13
+ symlinkEntry?: typeof fs.symlinkSync;
14
+ }
15
+ export interface SurfaceCompanySkillResult {
16
+ status: 0;
17
+ wrapperPath: string;
18
+ }
19
+ /**
20
+ * Surface one canonical company skill to Claude/Codex without running HQ's
21
+ * global reindex, migrations, worker generation, or unrelated cleanup.
22
+ */
23
+ export declare function surfaceCompanySkill(input: SurfaceCompanySkillInput): SurfaceCompanySkillResult;
24
+ //# sourceMappingURL=company-skill-wrapper.d.ts.map
@@ -0,0 +1,276 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ const WIN32_RESERVED_CHARS = /[<>:"/\\|?*]/;
4
+ const WIN32_DEVICE_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
5
+ const LOCAL_SLUG = /^[a-z0-9][a-z0-9-]*$/;
6
+ const WRAPPER_MARKER_DIR = ".hq-company-skill-wrappers";
7
+ function encodeLocalSegment(segment, win32) {
8
+ if (!win32)
9
+ return segment;
10
+ let encoded = "";
11
+ for (let index = 0; index < segment.length; index += 1) {
12
+ const char = segment[index];
13
+ const trailingDotOrSpace = index === segment.length - 1 && (char === "." || char === " ");
14
+ const dotSegment = (segment === "." || segment === "..") && index === 0;
15
+ const deviceName = WIN32_DEVICE_NAME.test(segment) && index === 0;
16
+ if (char === "%" ||
17
+ char.charCodeAt(0) <= 0x1f ||
18
+ WIN32_RESERVED_CHARS.test(char) ||
19
+ trailingDotOrSpace ||
20
+ dotSegment ||
21
+ deviceName) {
22
+ encoded += `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`;
23
+ }
24
+ else {
25
+ encoded += char;
26
+ }
27
+ }
28
+ return encoded;
29
+ }
30
+ function lstat(pathname) {
31
+ try {
32
+ return fs.lstatSync(pathname);
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ }
38
+ function isWithin(parent, candidate) {
39
+ const relative = path.relative(parent, candidate);
40
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
41
+ }
42
+ function sameFile(left, right) {
43
+ try {
44
+ const a = fs.statSync(left);
45
+ const b = fs.statSync(right);
46
+ return a.dev === b.dev && a.ino === b.ino;
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
52
+ function symlinkTarget(linkPath) {
53
+ try {
54
+ if (!fs.lstatSync(linkPath).isSymbolicLink())
55
+ return undefined;
56
+ return path.resolve(path.dirname(linkPath), fs.readlinkSync(linkPath));
57
+ }
58
+ catch {
59
+ return undefined;
60
+ }
61
+ }
62
+ function generatedMarkerPath(wrapperPath) {
63
+ return path.join(path.dirname(wrapperPath), WRAPPER_MARKER_DIR, `${path.basename(wrapperPath)}.json`);
64
+ }
65
+ function hasBoundGeneratedMarker(wrapperPath, companySlug) {
66
+ try {
67
+ const marker = JSON.parse(fs.readFileSync(generatedMarkerPath(wrapperPath), "utf8"));
68
+ const wrapper = fs.lstatSync(wrapperPath);
69
+ return (marker.version === 2 &&
70
+ marker.companySlug === companySlug &&
71
+ marker.wrapperDev === wrapper.dev &&
72
+ marker.wrapperIno === wrapper.ino &&
73
+ marker.wrapperBirthtimeMs === wrapper.birthtimeMs);
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ function writeGeneratedMarker(wrapperPath, companySlug, skillSlug) {
80
+ const markerPath = generatedMarkerPath(wrapperPath);
81
+ try {
82
+ const wrapper = fs.lstatSync(wrapperPath);
83
+ fs.mkdirSync(path.dirname(markerPath), { recursive: true });
84
+ fs.writeFileSync(markerPath, `${JSON.stringify({
85
+ version: 2,
86
+ companySlug,
87
+ skillSlug,
88
+ wrapperDev: wrapper.dev,
89
+ wrapperIno: wrapper.ino,
90
+ wrapperBirthtimeMs: wrapper.birthtimeMs,
91
+ })}\n`, "utf8");
92
+ }
93
+ catch {
94
+ // Every generated entry is also a live symlink into the canonical company
95
+ // skill, so wrapper ownership remains recoverable if this hint cannot land.
96
+ }
97
+ }
98
+ function removeGeneratedMarker(wrapperPath) {
99
+ try {
100
+ fs.rmSync(generatedMarkerPath(wrapperPath), { force: true });
101
+ }
102
+ catch {
103
+ // The wrapper itself is already gone; an orphaned hint is harmless and is
104
+ // never sufficient to surface content on its own.
105
+ }
106
+ }
107
+ function isGeneratedNamespacedWrapper(wrapperPath, hqRoot, companySlug) {
108
+ const companySkillsRoot = path.join(hqRoot, "companies", companySlug, "skills");
109
+ const wrapperTarget = symlinkTarget(wrapperPath);
110
+ if (wrapperTarget)
111
+ return isWithin(companySkillsRoot, wrapperTarget);
112
+ const wrapper = lstat(wrapperPath);
113
+ if (!wrapper?.isDirectory())
114
+ return false;
115
+ const entries = fs.readdirSync(wrapperPath).filter((entry) => !entry.startsWith("."));
116
+ const containsOnlyLiveBridges = (entries.length > 0 &&
117
+ entries.every((entry) => {
118
+ const target = symlinkTarget(path.join(wrapperPath, entry));
119
+ return target !== undefined && isWithin(companySkillsRoot, target);
120
+ }));
121
+ return containsOnlyLiveBridges || hasBoundGeneratedMarker(wrapperPath, companySlug);
122
+ }
123
+ function isGeneratedLegacyEntry(legacyPath, sourceDir, skillFile) {
124
+ const target = symlinkTarget(legacyPath);
125
+ if (target)
126
+ return target === sourceDir || target === skillFile;
127
+ const entry = lstat(legacyPath);
128
+ if (!entry)
129
+ return false;
130
+ if (entry.isFile())
131
+ return sameFile(legacyPath, skillFile);
132
+ if (!entry.isDirectory())
133
+ return false;
134
+ const legacySkill = path.join(legacyPath, "SKILL.md");
135
+ const skillTarget = symlinkTarget(legacySkill);
136
+ return skillTarget === skillFile || sameFile(legacySkill, skillFile);
137
+ }
138
+ function pruneStaleNamespacedWrappers(runtimeRoot, currentWrapperName, hqRoot, companySlug, win32) {
139
+ const namespacePrefix = encodeLocalSegment(`${companySlug}:`, win32);
140
+ for (const name of fs.readdirSync(runtimeRoot)) {
141
+ if (name === currentWrapperName || !name.startsWith(namespacePrefix))
142
+ continue;
143
+ const candidate = path.join(runtimeRoot, name);
144
+ const skillSlug = name.slice(namespacePrefix.length);
145
+ const canonicalSkill = path.join(hqRoot, "companies", companySlug, "skills", skillSlug, "SKILL.md");
146
+ if (LOCAL_SLUG.test(skillSlug) &&
147
+ !fs.existsSync(canonicalSkill) &&
148
+ isGeneratedNamespacedWrapper(candidate, hqRoot, companySlug)) {
149
+ fs.rmSync(candidate, { recursive: true, force: false });
150
+ removeGeneratedMarker(candidate);
151
+ }
152
+ }
153
+ }
154
+ function bridgeEntry(sourcePath, wrapperPath, target, type, symlinkEntry) {
155
+ try {
156
+ symlinkEntry(target, wrapperPath, type);
157
+ }
158
+ catch (symlinkError) {
159
+ const reason = symlinkError instanceof Error
160
+ ? symlinkError.message
161
+ : (JSON.stringify(symlinkError) ?? "Unknown symlink error");
162
+ throw new Error(`Could not surface '${sourcePath}' as a live skill link: ${reason}. ` +
163
+ "Enable local symlink support (Windows Developer Mode or an elevated shell) and retry.");
164
+ }
165
+ }
166
+ /**
167
+ * Surface one canonical company skill to Claude/Codex without running HQ's
168
+ * global reindex, migrations, worker generation, or unrelated cleanup.
169
+ */
170
+ export function surfaceCompanySkill(input) {
171
+ if (!LOCAL_SLUG.test(input.companySlug)) {
172
+ throw new Error("Company slug must contain only lowercase letters, numbers, and hyphens.");
173
+ }
174
+ if (!LOCAL_SLUG.test(input.skillSlug)) {
175
+ throw new Error("Skill slug must contain only lowercase letters, numbers, and hyphens.");
176
+ }
177
+ if (input.legacyPrefix !== undefined && !LOCAL_SLUG.test(input.legacyPrefix)) {
178
+ throw new Error("Legacy prefix must contain only lowercase letters, numbers, and hyphens.");
179
+ }
180
+ const sourceDir = path.join(input.hqRoot, "companies", input.companySlug, "skills", input.skillSlug);
181
+ const skillFile = path.join(sourceDir, "SKILL.md");
182
+ if (!fs.existsSync(skillFile) || !fs.statSync(skillFile).isFile()) {
183
+ throw new Error(`The canonical SKILL.md does not exist at '${skillFile}'.`);
184
+ }
185
+ const wrapperName = encodeLocalSegment(`${input.companySlug}:${input.skillSlug}`, input.win32 ?? process.platform === "win32");
186
+ const win32 = input.win32 ?? process.platform === "win32";
187
+ const wrapperPath = path.join(input.hqRoot, ".claude", "skills", wrapperName);
188
+ const existingWrapper = lstat(wrapperPath);
189
+ if (existingWrapper &&
190
+ !existingWrapper.isSymbolicLink() &&
191
+ !existingWrapper.isDirectory()) {
192
+ throw new Error(`Cannot surface the skill because '${wrapperPath}' is not a directory.`);
193
+ }
194
+ const sourceEntries = fs.readdirSync(sourceDir).sort();
195
+ const runtimeRoot = path.dirname(wrapperPath);
196
+ fs.mkdirSync(runtimeRoot, { recursive: true });
197
+ const stagingPath = fs.mkdtempSync(path.join(runtimeRoot, `.${wrapperName}.tmp-`));
198
+ try {
199
+ if (win32) {
200
+ // Directory junctions do not require Developer Mode or elevation and
201
+ // keep the runtime wrapper live against the canonical company skill.
202
+ // Replace the mkdtemp directory with the staged junction, then rename it
203
+ // into place through the same rollback-safe commit path below.
204
+ fs.rmSync(stagingPath, { recursive: true, force: false });
205
+ bridgeEntry(sourceDir, stagingPath, sourceDir, "junction", input.symlinkEntry ?? fs.symlinkSync);
206
+ }
207
+ else {
208
+ for (const entry of sourceEntries) {
209
+ const sourcePath = path.join(sourceDir, entry);
210
+ const entryWrapper = path.join(stagingPath, entry);
211
+ // Resolve relative to the FINAL wrapper location. The staging directory
212
+ // is renamed only after every entry has been bridged successfully.
213
+ const relativeTarget = path.relative(wrapperPath, sourcePath);
214
+ bridgeEntry(sourcePath, entryWrapper, relativeTarget, fs.statSync(sourcePath).isDirectory() ? "dir" : "file", input.symlinkEntry ?? fs.symlinkSync);
215
+ }
216
+ }
217
+ const currentWrapper = lstat(wrapperPath);
218
+ if (currentWrapper &&
219
+ !currentWrapper.isSymbolicLink() &&
220
+ !currentWrapper.isDirectory()) {
221
+ throw new Error(`Cannot surface the skill because '${wrapperPath}' is not a directory.`);
222
+ }
223
+ const renameEntry = input.renameEntry ?? fs.renameSync;
224
+ const backupPath = `${stagingPath}.previous`;
225
+ let previousMoved = false;
226
+ if (currentWrapper) {
227
+ if (!isGeneratedNamespacedWrapper(wrapperPath, input.hqRoot, input.companySlug)) {
228
+ throw new Error(`Cannot replace '${wrapperPath}' because it is not an HQ-generated skill wrapper.`);
229
+ }
230
+ renameEntry(wrapperPath, backupPath);
231
+ previousMoved = true;
232
+ }
233
+ try {
234
+ renameEntry(stagingPath, wrapperPath);
235
+ }
236
+ catch (replaceError) {
237
+ if (previousMoved && !lstat(wrapperPath)) {
238
+ try {
239
+ renameEntry(backupPath, wrapperPath);
240
+ }
241
+ catch (restoreError) {
242
+ throw new Error(`Could not replace '${wrapperPath}' and could not restore its previous wrapper ` +
243
+ `(replace: ${replaceError instanceof Error ? replaceError.message : String(replaceError)}; ` +
244
+ `restore: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}).`);
245
+ }
246
+ }
247
+ throw replaceError;
248
+ }
249
+ if (previousMoved) {
250
+ try {
251
+ fs.rmSync(backupPath, { recursive: true, force: true });
252
+ }
253
+ catch {
254
+ // The new wrapper is already committed. A hidden generated backup is
255
+ // safer than rolling back a successful replacement.
256
+ }
257
+ }
258
+ writeGeneratedMarker(wrapperPath, input.companySlug, input.skillSlug);
259
+ pruneStaleNamespacedWrappers(runtimeRoot, wrapperName, input.hqRoot, input.companySlug, win32);
260
+ if (input.legacyPrefix) {
261
+ const legacyBase = path.join(runtimeRoot, `${input.legacyPrefix}-${input.skillSlug}`);
262
+ for (const legacyPath of [legacyBase, `${legacyBase}.md`]) {
263
+ if (legacyPath !== wrapperPath &&
264
+ isGeneratedLegacyEntry(legacyPath, sourceDir, skillFile)) {
265
+ fs.rmSync(legacyPath, { recursive: true, force: false });
266
+ }
267
+ }
268
+ }
269
+ }
270
+ catch (error) {
271
+ fs.rmSync(stagingPath, { recursive: true, force: true });
272
+ throw error;
273
+ }
274
+ return { status: 0, wrapperPath };
275
+ }
276
+ //# sourceMappingURL=company-skill-wrapper.js.map
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Company integration health for `hq doctor`.
3
+ *
4
+ * This family makes one authenticated read of HQ's connection inventory. It
5
+ * never calls an integration gateway or provider tool, so it cannot mutate
6
+ * data, spend provider quota, or trigger provider rate limits. It classifies
7
+ * stored health signals only: a recorded rejection overrides a misleading
8
+ * `connected` status, but a clean connected row is not live-provider proof.
9
+ */
10
+ import { type AdminConnection } from "../../../commands/integrations-core.js";
11
+ import type { CheckContext, CheckFamily, CheckResult } from "../types.js";
12
+ export declare const INTEGRATIONS_FAMILY_ID = "integrations";
13
+ export declare const INTEGRATIONS_PREFIX = "integrations";
14
+ /** Additive health fields returned by newer control planes. */
15
+ export interface IntegrationConnection extends AdminConnection {
16
+ errorReason?: string;
17
+ degradedReason?: string;
18
+ needsReauthReason?: string;
19
+ /** Server-derived remediation flow; optional for older control planes. */
20
+ fix_path?: string;
21
+ /** Server-derived remediation class; optional for older control planes. */
22
+ fix_kind?: string;
23
+ }
24
+ export interface IntegrationsDoctorDeps {
25
+ ensureToken?: () => Promise<string>;
26
+ resolveCompany?: (token: string, company: string | undefined) => Promise<string>;
27
+ listConnections?: (token: string, companyUid: string) => Promise<IntegrationConnection[]>;
28
+ }
29
+ type FindingKind = "reconnect" | "re-add" | "contact-admin" | "provider-blocked" | "retryable" | "hq-configuration";
30
+ interface Finding {
31
+ provider: string;
32
+ connectionId: string;
33
+ kind: FindingKind;
34
+ message: string;
35
+ /** Optional HQ-authored remediation returned by the control plane. */
36
+ remediation?: string;
37
+ }
38
+ /**
39
+ * Expected session/company prerequisites degrade to NA instead of crashing or
40
+ * becoming a false connection failure. An unreadable inventory remains UNKNOWN.
41
+ */
42
+ export declare function checkIntegrations(context: CheckContext, deps?: IntegrationsDoctorDeps): Promise<CheckResult[]>;
43
+ /** Classify without echoing untrusted provider text, which may contain secrets. */
44
+ export declare function classifyConnection(connection: IntegrationConnection): Finding[];
45
+ export declare const integrationsFamily: CheckFamily;
46
+ export {};
47
+ //# sourceMappingURL=integrations.d.ts.map
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Company integration health for `hq doctor`.
3
+ *
4
+ * This family makes one authenticated read of HQ's connection inventory. It
5
+ * never calls an integration gateway or provider tool, so it cannot mutate
6
+ * data, spend provider quota, or trigger provider rate limits. It classifies
7
+ * stored health signals only: a recorded rejection overrides a misleading
8
+ * `connected` status, but a clean connected row is not live-provider proof.
9
+ */
10
+ import { ensureCognitoIdToken } from "../../../utils/cognito-session.js";
11
+ import { isAuthError } from "../../../utils/auth-error.js";
12
+ import { isCompanySelectionError } from "../../../utils/company-selection-error.js";
13
+ import { getCompanyUid } from "../../../utils/vault-api.js";
14
+ import { bareProvider, fetchConnections, IntegrationsCliError, } from "../../../commands/integrations-core.js";
15
+ export const INTEGRATIONS_FAMILY_ID = "integrations";
16
+ export const INTEGRATIONS_PREFIX = "integrations";
17
+ const RECONNECT_REASON_CODES = new Set([
18
+ "oauth_refresh_invalid_grant",
19
+ "oauth_refresh_unavailable",
20
+ "token_refresh_failed",
21
+ "credentials_rejected",
22
+ ]);
23
+ const RETRYABLE_REASON_CODES = new Set([
24
+ "oauth_refresh_transient",
25
+ "oauth_refresh_write_conflict",
26
+ ]);
27
+ const HQ_CONFIGURATION_REASON_CODE = "oauth_client_secret_unavailable";
28
+ /**
29
+ * Expected session/company prerequisites degrade to NA instead of crashing or
30
+ * becoming a false connection failure. An unreadable inventory remains UNKNOWN.
31
+ */
32
+ export async function checkIntegrations(context, deps = {}) {
33
+ if (context.integrations === false) {
34
+ return [{
35
+ status: "NA",
36
+ checkId: `${INTEGRATIONS_PREFIX}.skipped`,
37
+ message: "Integration checks were not requested for this run.",
38
+ }];
39
+ }
40
+ const ensureToken = deps.ensureToken ?? (() => ensureCognitoIdToken({ interactive: false }));
41
+ const resolveCompany = deps.resolveCompany ?? getCompanyUid;
42
+ const listConnections = deps.listConnections ?? ((token, companyUid) => fetchConnections(token, companyUid));
43
+ let token;
44
+ try {
45
+ token = await ensureToken();
46
+ }
47
+ catch {
48
+ return skippedForSession();
49
+ }
50
+ let companyUid;
51
+ try {
52
+ companyUid = await resolveCompany(token, context.company);
53
+ }
54
+ catch (error) {
55
+ if (isAuthError(error))
56
+ return skippedForSession();
57
+ if (isCompanySelectionError(error)) {
58
+ return skippedForCompany(error instanceof Error ? error : new Error("Company selection failed."));
59
+ }
60
+ return unknown("The active company could not be resolved; retry after checking HQ connectivity.");
61
+ }
62
+ let connections;
63
+ try {
64
+ connections = await listConnections(token, companyUid);
65
+ }
66
+ catch (error) {
67
+ if (isAuthError(error))
68
+ return skippedForSession();
69
+ if (error instanceof IntegrationsCliError && error.expected) {
70
+ return [{
71
+ status: "NA",
72
+ checkId: `${INTEGRATIONS_PREFIX}.unavailable`,
73
+ message: "Integration inventory is unavailable to this account, so no connection health was assessed.",
74
+ }];
75
+ }
76
+ return unknown("The integration connection inventory could not be read; retry after checking HQ connectivity.");
77
+ }
78
+ const active = connections.filter((connection) => connection.status !== "revoked");
79
+ if (active.length === 0) {
80
+ return [{
81
+ status: "NA",
82
+ checkId: `${INTEGRATIONS_PREFIX}.connections`,
83
+ message: "No active integration connections to assess. Revoked connection tombstones are ignored.",
84
+ }];
85
+ }
86
+ const findings = active.flatMap(classifyConnection);
87
+ if (findings.length === 0) {
88
+ return [{
89
+ status: "PASS",
90
+ checkId: `${INTEGRATIONS_PREFIX}.connections`,
91
+ message: `${active.length} active integration connection${active.length === 1 ? "" : "s"} reported healthy; only the control-plane inventory was read (no provider calls).`,
92
+ }];
93
+ }
94
+ return groupFindings(findings, context.company);
95
+ }
96
+ /** Classify without echoing untrusted provider text, which may contain secrets. */
97
+ export function classifyConnection(connection) {
98
+ if (connection.status === "revoked")
99
+ return [];
100
+ const provider = bareProvider(connection.provider);
101
+ const reason = recordedReason(connection);
102
+ const connectedWithRecordedFailure = connection.status === "connected" && reason !== "";
103
+ const knownReasonCode = knownReasonCodeFor(connection);
104
+ if (knownReasonCode) {
105
+ const finding = findingForKnownReasonCode(connection, provider, knownReasonCode);
106
+ // Retryable and HQ-configuration reason codes diagnose conditions that a
107
+ // caller-specific remediation cannot change. Reconnect-class codes defer
108
+ // to the server's credential/role-aware remediation classification.
109
+ return [withServerRemediation(connection, RECONNECT_REASON_CODES.has(knownReasonCode)
110
+ ? findingForServerFixKind(finding, connection.fix_kind)
111
+ : finding)];
112
+ }
113
+ // A stale remediation value does not make a clean connected row unhealthy.
114
+ if (!connectedWithRecordedFailure && connection.status === "connected")
115
+ return [];
116
+ if (isProviderBlocked(reason) || connection.status === "degraded") {
117
+ return [withServerRemediation(connection, {
118
+ provider,
119
+ connectionId: connection.id,
120
+ kind: "provider-blocked",
121
+ message: connectedWithRecordedFailure
122
+ ? "reports connected, but recorded provider health says access is blocked upstream"
123
+ : "provider-side access is blocked or unavailable",
124
+ })];
125
+ }
126
+ const fallback = genericReconnectFinding(connection, provider);
127
+ const serverClassified = findingForServerFixKind(fallback, connection.fix_kind);
128
+ if (serverClassified.kind !== "reconnect") {
129
+ return [withServerRemediation(connection, serverClassified)];
130
+ }
131
+ if (isTokenRefreshFailure(reason) || isCredentialFailure(reason)) {
132
+ const problem = isTokenRefreshFailure(reason)
133
+ ? "token refresh failed"
134
+ : connectedWithRecordedFailure
135
+ ? "reports connected, but the provider rejected the stored credentials"
136
+ : "the provider rejected the stored credentials";
137
+ return [withServerRemediation(connection, {
138
+ provider,
139
+ connectionId: connection.id,
140
+ kind: "reconnect",
141
+ message: problem,
142
+ })];
143
+ }
144
+ if (connection.status === "needs-reauth" || connection.status === "error") {
145
+ return [withServerRemediation(connection, fallback)];
146
+ }
147
+ if (connection.status !== "connected") {
148
+ return [withServerRemediation(connection, {
149
+ provider,
150
+ connectionId: connection.id,
151
+ kind: "reconnect",
152
+ message: `reports an unrecognized non-healthy status (${connection.status})`,
153
+ })];
154
+ }
155
+ return [];
156
+ }
157
+ function genericReconnectFinding(connection, provider) {
158
+ return {
159
+ provider,
160
+ connectionId: connection.id,
161
+ kind: "reconnect",
162
+ message: connection.status === "needs-reauth"
163
+ ? "needs re-authentication"
164
+ : "is in an error state",
165
+ };
166
+ }
167
+ /** Only values emitted by hq-pro affect the classification; future values fall back. */
168
+ function findingForServerFixKind(fallback, fixKind) {
169
+ switch (fixKind) {
170
+ case "re-add":
171
+ return {
172
+ ...fallback,
173
+ kind: "re-add",
174
+ message: "requires its API key to be re-entered",
175
+ };
176
+ case "contact-admin":
177
+ return {
178
+ ...fallback,
179
+ kind: "contact-admin",
180
+ message: "must be repaired by a company owner or admin",
181
+ };
182
+ case "reconnect":
183
+ default:
184
+ return fallback;
185
+ }
186
+ }
187
+ /**
188
+ * A fix path is server-authored, role- and credential-aware wording. It cannot
189
+ * make a connection unhealthy: the caller must first have classified a health
190
+ * signal, so a stale fix path on an otherwise clean connected row is ignored.
191
+ */
192
+ function withServerRemediation(connection, finding) {
193
+ return connection.fix_path
194
+ ? { ...finding, remediation: connection.fix_path }
195
+ : finding;
196
+ }
197
+ /**
198
+ * These are a closed hq-pro contract. Exact matching deliberately comes before
199
+ * the legacy text heuristics, which remain below for old and unknown rows.
200
+ */
201
+ function knownReasonCodeFor(connection) {
202
+ return [connection.errorReason, connection.needsReauthReason, connection.degradedReason]
203
+ .find((value) => (typeof value === "string" &&
204
+ (RECONNECT_REASON_CODES.has(value) ||
205
+ RETRYABLE_REASON_CODES.has(value) ||
206
+ value === HQ_CONFIGURATION_REASON_CODE)));
207
+ }
208
+ function findingForKnownReasonCode(connection, provider, code) {
209
+ if (RECONNECT_REASON_CODES.has(code)) {
210
+ return {
211
+ provider,
212
+ connectionId: connection.id,
213
+ kind: "reconnect",
214
+ message: "stored credentials need re-authentication",
215
+ };
216
+ }
217
+ if (RETRYABLE_REASON_CODES.has(code)) {
218
+ return {
219
+ provider,
220
+ connectionId: connection.id,
221
+ kind: "retryable",
222
+ message: code === "oauth_refresh_write_conflict"
223
+ ? "token refresh lost a concurrent write race; the stored credential remains intact"
224
+ : "token refresh is temporarily unavailable; the stored credential remains intact",
225
+ };
226
+ }
227
+ return {
228
+ provider,
229
+ connectionId: connection.id,
230
+ kind: "hq-configuration",
231
+ message: "the HQ OAuth client secret is unavailable",
232
+ };
233
+ }
234
+ function groupFindings(findings, company) {
235
+ const groups = new Map();
236
+ for (const finding of findings) {
237
+ const key = `${finding.kind}\u0000${finding.provider}\u0000${finding.message}\u0000${finding.remediation ?? ""}`;
238
+ const entries = groups.get(key) ?? [];
239
+ entries.push(finding);
240
+ groups.set(key, entries);
241
+ }
242
+ return [...groups.values()]
243
+ .sort((a, b) => a[0].provider.localeCompare(b[0].provider))
244
+ .map((entries) => resultForGroup(entries, company));
245
+ }
246
+ function resultForGroup(entries, company) {
247
+ const first = entries[0];
248
+ const count = entries.length;
249
+ const ids = entries.map((entry) => entry.connectionId);
250
+ const preview = ids.slice(0, 6);
251
+ const overflow = ids.length - preview.length;
252
+ const namedConnections = preview.join(", ") + (overflow > 0 ? ` (+${overflow} more)` : "");
253
+ const plural = count === 1 ? "connection" : "connections";
254
+ const serverRemediation = first.remediation;
255
+ if (first.kind === "provider-blocked") {
256
+ return {
257
+ status: "FAIL",
258
+ checkId: `${INTEGRATIONS_PREFIX}.provider-blocked.${first.provider}`,
259
+ target: namedConnections,
260
+ message: `${first.provider}: ${count} ${plural} ${first.message}. This is not reported as a local credential repair.`,
261
+ ...(serverRemediation ? { remediation: serverRemediation } : {}),
262
+ };
263
+ }
264
+ if (first.kind === "re-add") {
265
+ return {
266
+ status: "FAIL",
267
+ checkId: `${INTEGRATIONS_PREFIX}.re-add.${first.provider}`,
268
+ target: namedConnections,
269
+ message: `${first.provider}: ${count} ${plural} ${first.message}.`,
270
+ remediation: serverRemediation ?? `hq integrations connect ${first.provider} --token-stdin${company ? ` --company ${company}` : ""}`,
271
+ };
272
+ }
273
+ if (first.kind === "contact-admin") {
274
+ return {
275
+ status: "FAIL",
276
+ checkId: `${INTEGRATIONS_PREFIX}.contact-admin.${first.provider}`,
277
+ target: namedConnections,
278
+ message: `${first.provider}: ${count} ${plural} ${first.message}.`,
279
+ remediation: serverRemediation ?? "Ask a company owner or admin to repair this integration.",
280
+ };
281
+ }
282
+ if (first.kind === "retryable") {
283
+ return {
284
+ status: "WARN",
285
+ checkId: `${INTEGRATIONS_PREFIX}.retryable.${first.provider}`,
286
+ target: namedConnections,
287
+ message: `${first.provider}: ${count} ${plural} ${first.message}. Reconnecting is not needed; retry the operation later.`,
288
+ ...(serverRemediation ? { remediation: serverRemediation } : {}),
289
+ };
290
+ }
291
+ if (first.kind === "hq-configuration") {
292
+ return {
293
+ status: "FAIL",
294
+ checkId: `${INTEGRATIONS_PREFIX}.hq-configuration.${first.provider}`,
295
+ target: namedConnections,
296
+ message: `${first.provider}: ${count} ${plural} ${first.message}. An HQ administrator must repair this configuration; reconnecting will not fix it.`,
297
+ ...(serverRemediation ? { remediation: serverRemediation } : {}),
298
+ };
299
+ }
300
+ const companyArg = company ? ` --company ${company}` : "";
301
+ const remediation = preview
302
+ .map((id) => `hq integrations reconnect --connection ${id}${companyArg}`)
303
+ .join("; ");
304
+ return {
305
+ status: "FAIL",
306
+ checkId: `${INTEGRATIONS_PREFIX}.reconnect.${first.provider}`,
307
+ target: namedConnections,
308
+ message: `${first.provider}: ${count} ${plural} ${first.message}.`,
309
+ remediation: serverRemediation ?? (overflow > 0
310
+ ? `Reconnect the listed connections, then repeat for the remaining ${overflow}: ${remediation}`
311
+ : remediation),
312
+ };
313
+ }
314
+ function recordedReason(connection) {
315
+ return [connection.errorReason, connection.needsReauthReason, connection.degradedReason]
316
+ .filter((value) => typeof value === "string")
317
+ .join(" ")
318
+ .toLowerCase();
319
+ }
320
+ function isTokenRefreshFailure(reason) {
321
+ return /token[\s_-]*refresh|refresh[\s_-]*token|invalid_grant/.test(reason);
322
+ }
323
+ function isCredentialFailure(reason) {
324
+ return /rejected[\s_-]*(the[\s_-]*)?stored[\s_-]*credentials|credentials[\s_-]*rejected|unauthori[sz]ed|invalid[\s_-]*(token|credentials)/.test(reason);
325
+ }
326
+ function isProviderBlocked(reason) {
327
+ return /\b403\b|access[\s_-]*denied|upstream[\s_-]*(block|blocked)|provider[\s_-]*(block|blocked|unavailable)|service[\s_-]*unavailable/.test(reason);
328
+ }
329
+ function skippedForSession() {
330
+ return [{
331
+ status: "NA",
332
+ checkId: `${INTEGRATIONS_PREFIX}.session`,
333
+ message: "Integration checks skipped: no usable HQ session is available.",
334
+ remediation: "Run `hq login`, then rerun `hq doctor`.",
335
+ }];
336
+ }
337
+ function skippedForCompany(error) {
338
+ return [{
339
+ status: "NA",
340
+ checkId: `${INTEGRATIONS_PREFIX}.company`,
341
+ message: "Integration checks skipped: no single active company could be selected.",
342
+ remediation: `Rerun \`hq doctor --company <slug>\`. (${error.message})`,
343
+ }];
344
+ }
345
+ function unknown(message) {
346
+ return [{ status: "UNKNOWN", checkId: `${INTEGRATIONS_PREFIX}.inventory`, message }];
347
+ }
348
+ export const integrationsFamily = {
349
+ id: INTEGRATIONS_FAMILY_ID,
350
+ title: "Integration connection health",
351
+ run: (context) => checkIntegrations(context),
352
+ };
353
+ //# sourceMappingURL=integrations.js.map
@@ -19,6 +19,7 @@ import { checkCodexWiring } from "./checks/codex-wiring.js";
19
19
  import { checkGrokWiring } from "./checks/grok-wiring.js";
20
20
  import { checkRuntimeProbe } from "./checks/runtime-probe.js";
21
21
  import { runtimeHealthFamily } from "./checks/runtime-health.js";
22
+ import { integrationsFamily } from "./checks/integrations.js";
22
23
  import { fixtureCoverageFamily } from "./fixtures/discover.js";
23
24
  import { checkClaudeWiring } from "./checks/claude-wiring.js";
24
25
  /**
@@ -177,6 +178,9 @@ export function createDefaultRegistry() {
177
178
  // runtime is logged out or broken; this family closes that blind spot. Its
178
179
  // default tier is a pure PATH scan, preserving the offline contract.
179
180
  registry.register(runtimeHealthFamily);
181
+ // The engine remains family-agnostic: integrations is one bounded,
182
+ // read-only inventory family, registered alongside all other checks.
183
+ registry.register(integrationsFamily);
180
184
  return registry;
181
185
  }
182
186
  //# sourceMappingURL=registry.js.map
@@ -66,6 +66,10 @@ export interface CheckContext {
66
66
  * executing anything.
67
67
  */
68
68
  liveRuntimes?: boolean;
69
+ /** Company slug for company-scoped families; absent uses active membership. */
70
+ company?: string;
71
+ /** Internal test seam; production `hq doctor` always enables integrations. */
72
+ integrations?: boolean;
69
73
  }
70
74
  /**
71
75
  * A check family: an id, a human title, and an async `run` returning per-item
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.103.24",
3
+ "version": "5.103.26",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -30,7 +30,7 @@
30
30
  "dependencies": {
31
31
  "@aws-sdk/client-iot-data-plane": "^3.1096.0",
32
32
  "@aws-sdk/client-s3": "^3.1049.0",
33
- "@indigoai-us/hq-cloud": "~6.15.71",
33
+ "@indigoai-us/hq-cloud": "~6.15.80",
34
34
  "@indigoai-us/hq-onboarding": "^0.1.0",
35
35
  "@sentry/node": "^10.49.0",
36
36
  "@tobilu/qmd": "2.5.3",