@indigoai-us/hq-cli 5.103.25 → 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,15 @@
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
+
5
14
  ## [5.103.25] — 2026-08-27
6
15
 
7
16
  ### Fixed
@@ -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
@@ -18,18 +18,22 @@ export interface IntegrationConnection extends AdminConnection {
18
18
  needsReauthReason?: string;
19
19
  /** Server-derived remediation flow; optional for older control planes. */
20
20
  fix_path?: string;
21
+ /** Server-derived remediation class; optional for older control planes. */
22
+ fix_kind?: string;
21
23
  }
22
24
  export interface IntegrationsDoctorDeps {
23
25
  ensureToken?: () => Promise<string>;
24
26
  resolveCompany?: (token: string, company: string | undefined) => Promise<string>;
25
27
  listConnections?: (token: string, companyUid: string) => Promise<IntegrationConnection[]>;
26
28
  }
27
- type FindingKind = "reconnect" | "re-add" | "provider-blocked";
29
+ type FindingKind = "reconnect" | "re-add" | "contact-admin" | "provider-blocked" | "retryable" | "hq-configuration";
28
30
  interface Finding {
29
31
  provider: string;
30
32
  connectionId: string;
31
33
  kind: FindingKind;
32
34
  message: string;
35
+ /** Optional HQ-authored remediation returned by the control plane. */
36
+ remediation?: string;
33
37
  }
34
38
  /**
35
39
  * Expected session/company prerequisites degrade to NA instead of crashing or
@@ -14,6 +14,17 @@ import { getCompanyUid } from "../../../utils/vault-api.js";
14
14
  import { bareProvider, fetchConnections, IntegrationsCliError, } from "../../../commands/integrations-core.js";
15
15
  export const INTEGRATIONS_FAMILY_ID = "integrations";
16
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";
17
28
  /**
18
29
  * Expected session/company prerequisites degrade to NA instead of crashing or
19
30
  * becoming a false connection failure. An unreadable inventory remains UNKNOWN.
@@ -89,15 +100,33 @@ export function classifyConnection(connection) {
89
100
  const provider = bareProvider(connection.provider);
90
101
  const reason = recordedReason(connection);
91
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 [];
92
116
  if (isProviderBlocked(reason) || connection.status === "degraded") {
93
- return [{
117
+ return [withServerRemediation(connection, {
94
118
  provider,
95
119
  connectionId: connection.id,
96
120
  kind: "provider-blocked",
97
121
  message: connectedWithRecordedFailure
98
122
  ? "reports connected, but recorded provider health says access is blocked upstream"
99
123
  : "provider-side access is blocked or unavailable",
100
- }];
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)];
101
130
  }
102
131
  if (isTokenRefreshFailure(reason) || isCredentialFailure(reason)) {
103
132
  const problem = isTokenRefreshFailure(reason)
@@ -105,49 +134,107 @@ export function classifyConnection(connection) {
105
134
  : connectedWithRecordedFailure
106
135
  ? "reports connected, but the provider rejected the stored credentials"
107
136
  : "the provider rejected the stored credentials";
108
- return [{ provider, connectionId: connection.id, kind: "reconnect", message: problem }];
109
- }
110
- if (connection.status !== "connected" && connection.fix_path === "blocked-upstream") {
111
- return [{
137
+ return [withServerRemediation(connection, {
112
138
  provider,
113
139
  connectionId: connection.id,
114
- kind: "provider-blocked",
115
- message: "provider-side access is blocked or unavailable",
116
- }];
117
- }
118
- // The server is authoritative about an API-key credential that must be
119
- // re-entered. Do not apply a stale fix path to a clean connected row, but do
120
- // preserve this useful branch logic for a non-healthy row.
121
- if (connection.status !== "connected" && connection.fix_path === "re-add") {
122
- return [{
123
- provider,
124
- connectionId: connection.id,
125
- kind: "re-add",
126
- message: "needs its API key re-entered",
127
- }];
140
+ kind: "reconnect",
141
+ message: problem,
142
+ })];
128
143
  }
129
144
  if (connection.status === "needs-reauth" || connection.status === "error") {
130
- return [{
131
- provider,
132
- connectionId: connection.id,
133
- kind: "reconnect",
134
- message: connection.status === "needs-reauth" ? "needs re-authentication" : "is in an error state",
135
- }];
145
+ return [withServerRemediation(connection, fallback)];
136
146
  }
137
147
  if (connection.status !== "connected") {
138
- return [{
148
+ return [withServerRemediation(connection, {
139
149
  provider,
140
150
  connectionId: connection.id,
141
151
  kind: "reconnect",
142
152
  message: `reports an unrecognized non-healthy status (${connection.status})`,
143
- }];
153
+ })];
144
154
  }
145
155
  return [];
146
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
+ }
147
234
  function groupFindings(findings, company) {
148
235
  const groups = new Map();
149
236
  for (const finding of findings) {
150
- const key = `${finding.kind}\u0000${finding.provider}\u0000${finding.message}`;
237
+ const key = `${finding.kind}\u0000${finding.provider}\u0000${finding.message}\u0000${finding.remediation ?? ""}`;
151
238
  const entries = groups.get(key) ?? [];
152
239
  entries.push(finding);
153
240
  groups.set(key, entries);
@@ -164,12 +251,14 @@ function resultForGroup(entries, company) {
164
251
  const overflow = ids.length - preview.length;
165
252
  const namedConnections = preview.join(", ") + (overflow > 0 ? ` (+${overflow} more)` : "");
166
253
  const plural = count === 1 ? "connection" : "connections";
254
+ const serverRemediation = first.remediation;
167
255
  if (first.kind === "provider-blocked") {
168
256
  return {
169
257
  status: "FAIL",
170
258
  checkId: `${INTEGRATIONS_PREFIX}.provider-blocked.${first.provider}`,
171
259
  target: namedConnections,
172
260
  message: `${first.provider}: ${count} ${plural} ${first.message}. This is not reported as a local credential repair.`,
261
+ ...(serverRemediation ? { remediation: serverRemediation } : {}),
173
262
  };
174
263
  }
175
264
  if (first.kind === "re-add") {
@@ -178,7 +267,34 @@ function resultForGroup(entries, company) {
178
267
  checkId: `${INTEGRATIONS_PREFIX}.re-add.${first.provider}`,
179
268
  target: namedConnections,
180
269
  message: `${first.provider}: ${count} ${plural} ${first.message}.`,
181
- remediation: `hq integrations connect ${first.provider} --token-stdin${company ? ` --company ${company}` : ""}`,
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 } : {}),
182
298
  };
183
299
  }
184
300
  const companyArg = company ? ` --company ${company}` : "";
@@ -190,9 +306,9 @@ function resultForGroup(entries, company) {
190
306
  checkId: `${INTEGRATIONS_PREFIX}.reconnect.${first.provider}`,
191
307
  target: namedConnections,
192
308
  message: `${first.provider}: ${count} ${plural} ${first.message}.`,
193
- remediation: overflow > 0
309
+ remediation: serverRemediation ?? (overflow > 0
194
310
  ? `Reconnect the listed connections, then repeat for the remaining ${overflow}: ${remediation}`
195
- : remediation,
311
+ : remediation),
196
312
  };
197
313
  }
198
314
  function recordedReason(connection) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.103.25",
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.79",
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",