@indigoai-us/hq-cli 5.103.22 → 5.103.24

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,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.103.24] — 2026-08-27
6
+
7
+ ## [5.103.23] — 2026-08-26
8
+
9
+ ### Changed
10
+
11
+ - The bundled sync engine now requires `@indigoai-us/hq-cloud` 6.15.71, so new
12
+ company skills are registered and stamped with a stable skill UID before
13
+ their first upload while comment-only skill proposals remain available from
14
+ the CLI.
15
+
5
16
  ## [5.103.22] — 2026-08-25
6
17
 
7
18
  ### Fixed
@@ -1,153 +1,55 @@
1
1
  /**
2
- * `hq skill` subcommand group (US-017).
2
+ * Company skill creation and comment-only improvements.
3
3
  *
4
- * Terminal / scriptable access to the skill collaboration loop:
5
- *
6
- * hq skill suggest <uid|path> Propose a change to a skill from working edits.
7
- * hq skill list-suggestions Show the review inbox (suggestions on skills you own).
8
- * hq skill review <sgn_…> Accept (merge) or decline a pending suggestion.
9
- *
10
- * This is a THIN front-end over the SAME wired hq-pro routes the MCP surface
11
- * (US-007) and the console merge path (US-009) use — there is NO forked
12
- * suggestion or merge logic here. The CLI reads the local working SKILL.md,
13
- * computes the proposed content (+ its base for a diff), and POSTs to:
14
- *
15
- * CREATE POST /v1/files/skills/company/{slug}/{skillUid}/suggestions
16
- * LIST POST /v1/files/skills/company/{slug}/suggestions/list
17
- * ACCEPT POST /v1/files/skills/company/{slug}/{skillUid}/suggestions/{id}/accept
18
- * DECLINE POST /v1/files/skills/company/{slug}/{skillUid}/suggestions/{id}/decline
19
- *
20
- * The skill routes are keyed on the company SLUG (path param, resolved
21
- * server-side via findEntityBySlug) — NOT the companyUid the vault/ACL routes
22
- * use — so this module resolves a slug (from `--company` or the active company)
23
- * and passes it straight through.
24
- *
25
- * Lock semantics (AC3): a suggest against a skill the caller cannot WRITE still
26
- * SUCCEEDS as a proposal. The CREATE route is MEMBER-gated (never write-gated),
27
- * so this command performs NO client-side lock/permission pre-check — it always
28
- * posts and renders whatever the server returns. A locked-out member lands a
29
- * suggestion, never a hard permission error.
30
- *
31
- * Attribution (AC4): the invoking identity (from the Cognito JWT) is the server-
32
- * derived `authorPersonUid`; the CLI never sends an author. An optional
33
- * `--note` rides as `authorNote` (the change's rationale / failure context).
4
+ * `hq skill create <slug>` registers a canonical company skill, stamps its
5
+ * immutable UID, reindexes its generated runtime wrapper, and syncs it.
6
+ * `hq skill propose <uid|path> --message "…"` posts a whole-skill comment to
7
+ * the same improvement thread shown in HQ Console. It never uploads a modified
8
+ * SKILL.md and cannot overwrite live content. Structured suggest/list/review
9
+ * commands intentionally are not registered.
34
10
  */
35
11
  import { Command } from "commander";
36
- /**
37
- * A `skl_…` argument to `suggest` is a skill UID (resolve the local file from
38
- * it); anything else is a filesystem path. Lenient on the suffix (the strict
39
- * `skl_<ulid>` shape is `isSkillUid` on the server) so a hand-typed / fixture
40
- * uid still routes to the uid branch.
41
- */
12
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
13
+ import { vaultApiFetch } from "../utils/vault-api.js";
42
14
  export declare const SKILL_UID_PATTERN: RegExp;
43
- /** A `sgn_…` suggestion id — the `review` target and the LIST row key. */
44
- export declare const SUGGESTION_ID_PATTERN: RegExp;
45
- /**
46
- * Read the top-level `skill_uid` from a SKILL.md's YAML frontmatter. Mirrors
47
- * hq-pro's `parseSkillFrontmatter` (block extraction → YAML parse → read the
48
- * top-level `skill_uid` string). Returns `undefined` when there is no
49
- * frontmatter, it fails to parse, or `skill_uid` is absent / not a `skl_…`
50
- * string. Never throws.
51
- */
52
- export declare function parseSkillUid(md: string): string | undefined;
53
- /** sha256 hex of a string — the base-version fingerprint the server records (AC2). */
54
- export declare function sha256Hex(content: string): string;
55
- export interface SuggestionCreateBody {
56
- proposedContent: string;
57
- baseContent?: string;
58
- baseContentHash?: string;
59
- authorNote?: string;
15
+ export declare const SKILL_SLUG_PATTERN: RegExp;
16
+ interface SkillSyncInput {
17
+ filePath: string;
18
+ companySlug: string;
19
+ hqRoot: string;
20
+ token: string;
60
21
  }
61
- /**
62
- * Build the CREATE request body from the resolved proposed content and an
63
- * optional base. Mirrors the server's two payload shapes:
64
- * - `baseContent` present → the server derives the unified diff + base hash
65
- * (the diff-by-default path). A no-op (base === proposed) is rejected here
66
- * rather than round-tripped to an EmptySuggestion 400.
67
- * - no base → a `full-file` proposal; the server REQUIRES a `baseContentHash`,
68
- * so we fingerprint the proposed content (a "here is my whole file" proposal
69
- * with no base to diff against).
70
- * An empty / whitespace-only note is dropped (kept absent, not blank).
71
- */
72
- export declare function buildSuggestionCreateBody(input: {
73
- proposedContent: string;
74
- baseContent?: string;
75
- note?: string;
76
- }): SuggestionCreateBody;
77
- /**
78
- * Map an hq-pro skill route error to a single user-facing line. Pure so the
79
- * status → copy mapping is unit-tested independently of the network. Prefers the
80
- * server's own `error` / `message` (they carry the actionable specifics — e.g.
81
- * "Skill not found", "You need write access to this skill…").
82
- */
83
- export declare function mapSkillError(status: number, body: Record<string, unknown>): string;
84
- /** LIST inbox row shape returned by `suggestionToWire` on the server. */
85
- export interface SuggestionRow {
86
- suggestionId: string;
87
- skillUid: string;
88
- authorPersonUid: string;
89
- status: string;
90
- baseChanged: boolean;
91
- presentation?: string;
92
- unifiedDiff?: string;
93
- fullContent?: string;
94
- baseContentHash?: string;
95
- currentContentHash?: string;
96
- authorNote?: string;
97
- createdAt: string;
98
- path: string;
22
+ interface SkillSyncResult {
23
+ filesUploaded: number;
24
+ filesSkipped: number;
25
+ aborted: boolean;
99
26
  }
100
- /**
101
- * Render the review inbox as a table (one row per suggestion), optionally
102
- * printing each suggestion's unified diff (or full proposed file, when the base
103
- * drifted / the proposal is full-file) beneath its row. Pure → snapshot-testable.
104
- */
105
- export declare function formatSuggestionsList(suggestions: SuggestionRow[], opts?: {
106
- showDiff?: boolean;
107
- }): string;
108
- /** Read `.hq/config.json`'s `activeCompany` (mirrors signals/sources). */
27
+ export declare function parseSkillUid(markdown: string): string | undefined;
109
28
  export declare function readActiveCompanySlug(hqRoot: string): string | undefined;
110
- /**
111
- * The skill routes are keyed on the company SLUG. Precedence: explicit
112
- * `--company` → `.hq/config.json` activeCompany. Throws with actionable copy
113
- * when neither is available.
114
- */
115
29
  export declare function resolveCompanySlug(flag: string | undefined, hqRoot?: string): string;
116
- export interface ResolvedSkillTarget {
117
- /** Absolute path to the SKILL.md. */
118
- filePath: string;
119
- /** The `skl_…` uid read from its frontmatter (or the uid arg). */
120
- skillUid: string;
121
- /** The working-tree SKILL.md content — the proposed content. */
122
- content: string;
30
+ /** Resolve a UID directly, or read the immutable UID from a local SKILL.md. */
31
+ export declare function resolveSkillUid(target: string, cwd: string): string;
32
+ export declare function canonicalCompanySkillPath(hqRoot: string, companySlug: string, skillSlug: string): string;
33
+ export declare function makeSkillTemplate(input: {
34
+ slug: string;
35
+ name?: string;
36
+ description?: string;
37
+ }): string;
38
+ /** Replace SKILL.md without exposing a partially-written identity to agents. */
39
+ export declare function writeSkillFileAtomically(filePath: string, content: string): void;
40
+ export declare function mapSkillError(status: number, body: Record<string, unknown>): string;
41
+ interface SkillCommandDeps {
42
+ ensureToken?: typeof ensureCognitoToken;
43
+ apiFetch?: typeof vaultApiFetch;
44
+ cwd?: () => string;
45
+ hqRoot?: string;
46
+ syncFile?: (input: SkillSyncInput) => Promise<SkillSyncResult>;
47
+ reindexFn?: (input: {
48
+ repoRoot: string;
49
+ }) => {
50
+ status: number | null;
51
+ };
123
52
  }
124
- /**
125
- * Recursively find a SKILL.md whose frontmatter `skill_uid` equals `uid`,
126
- * searching each root breadth-first with a bounded depth (skips VCS / build /
127
- * dependency dirs). Returns the first match's absolute path, or null.
128
- */
129
- export declare function findSkillFileByUid(roots: string[], uid: string, maxDepth?: number): string | null;
130
- /**
131
- * Resolve a `suggest` target (a `skl_…` uid OR a filesystem path) to the local
132
- * SKILL.md, its uid, and its content. A uid is resolved by scanning the company
133
- * skills dir and the cwd; a path is read directly (a directory → its SKILL.md),
134
- * with the uid read from the file's frontmatter.
135
- */
136
- export declare function resolveSkillTarget(target: string, deps: {
137
- cwd: string;
138
- hqRoot: string;
139
- companySlug: string;
140
- }): ResolvedSkillTarget;
141
- /**
142
- * Read the committed (HEAD) version of a file from its git repo — the diff base
143
- * for "propose my working changes". Returns null when the file is untracked, not
144
- * in a repo, or git is unavailable (the caller then falls back to full-file, or
145
- * errors under `--diff`). Never throws.
146
- */
147
- export declare function readGitBase(filePath: string): Promise<string | null>;
148
- /** Injectable git-base seam so `suggest` is testable without a real repo. */
149
- export type GitBaseReader = (filePath: string) => Promise<string | null>;
150
- export declare function registerSkillCommand(program: Command, deps?: {
151
- gitBase?: GitBaseReader;
152
- }): Command;
53
+ export declare function registerSkillCommand(program: Command, deps?: SkillCommandDeps): Command;
54
+ export {};
153
55
  //# sourceMappingURL=skill.d.ts.map
@@ -1,226 +1,67 @@
1
1
  /**
2
- * `hq skill` subcommand group (US-017).
2
+ * Company skill creation and comment-only improvements.
3
3
  *
4
- * Terminal / scriptable access to the skill collaboration loop:
5
- *
6
- * hq skill suggest <uid|path> Propose a change to a skill from working edits.
7
- * hq skill list-suggestions Show the review inbox (suggestions on skills you own).
8
- * hq skill review <sgn_…> Accept (merge) or decline a pending suggestion.
9
- *
10
- * This is a THIN front-end over the SAME wired hq-pro routes the MCP surface
11
- * (US-007) and the console merge path (US-009) use — there is NO forked
12
- * suggestion or merge logic here. The CLI reads the local working SKILL.md,
13
- * computes the proposed content (+ its base for a diff), and POSTs to:
14
- *
15
- * CREATE POST /v1/files/skills/company/{slug}/{skillUid}/suggestions
16
- * LIST POST /v1/files/skills/company/{slug}/suggestions/list
17
- * ACCEPT POST /v1/files/skills/company/{slug}/{skillUid}/suggestions/{id}/accept
18
- * DECLINE POST /v1/files/skills/company/{slug}/{skillUid}/suggestions/{id}/decline
19
- *
20
- * The skill routes are keyed on the company SLUG (path param, resolved
21
- * server-side via findEntityBySlug) — NOT the companyUid the vault/ACL routes
22
- * use — so this module resolves a slug (from `--company` or the active company)
23
- * and passes it straight through.
24
- *
25
- * Lock semantics (AC3): a suggest against a skill the caller cannot WRITE still
26
- * SUCCEEDS as a proposal. The CREATE route is MEMBER-gated (never write-gated),
27
- * so this command performs NO client-side lock/permission pre-check — it always
28
- * posts and renders whatever the server returns. A locked-out member lands a
29
- * suggestion, never a hard permission error.
30
- *
31
- * Attribution (AC4): the invoking identity (from the Cognito JWT) is the server-
32
- * derived `authorPersonUid`; the CLI never sends an author. An optional
33
- * `--note` rides as `authorNote` (the change's rationale / failure context).
4
+ * `hq skill create <slug>` registers a canonical company skill, stamps its
5
+ * immutable UID, reindexes its generated runtime wrapper, and syncs it.
6
+ * `hq skill propose <uid|path> --message "…"` posts a whole-skill comment to
7
+ * the same improvement thread shown in HQ Console. It never uploads a modified
8
+ * SKILL.md and cannot overwrite live content. Structured suggest/list/review
9
+ * commands intentionally are not registered.
34
10
  */
35
11
  import * as fs from "node:fs";
36
12
  import * as path from "node:path";
37
- import { createHash } from "node:crypto";
38
13
  import chalk from "chalk";
39
14
  import yaml from "js-yaml";
40
- import simpleGit from "simple-git";
41
- import { ensureCognitoToken, DEFAULT_HQ_ROOT } from "../utils/cognito-session.js";
15
+ import { reindex, share } from "@indigoai-us/hq-cloud";
16
+ import { ensureCognitoToken, DEFAULT_HQ_ROOT, buildVaultConfig, } from "../utils/cognito-session.js";
42
17
  import { vaultApiFetch } from "../utils/vault-api.js";
43
- // ---------------------------------------------------------------------------
44
- // Patterns
45
- // ---------------------------------------------------------------------------
46
- /**
47
- * A `skl_…` argument to `suggest` is a skill UID (resolve the local file from
48
- * it); anything else is a filesystem path. Lenient on the suffix (the strict
49
- * `skl_<ulid>` shape is `isSkillUid` on the server) so a hand-typed / fixture
50
- * uid still routes to the uid branch.
51
- */
52
18
  export const SKILL_UID_PATTERN = /^skl_[A-Za-z0-9]+$/;
53
- /** A `sgn_…` suggestion id — the `review` target and the LIST row key. */
54
- export const SUGGESTION_ID_PATTERN = /^sgn_[A-Za-z0-9]+$/;
55
- const VALID_LIST_STATUSES = ["open", "accepted", "declined", "all"];
56
- // ---------------------------------------------------------------------------
57
- // Pure helpers (exported for unit tests)
58
- // ---------------------------------------------------------------------------
19
+ export const SKILL_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
20
+ const COMPANY_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
59
21
  const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
60
- /**
61
- * Read the top-level `skill_uid` from a SKILL.md's YAML frontmatter. Mirrors
62
- * hq-pro's `parseSkillFrontmatter` (block extraction → YAML parse → read the
63
- * top-level `skill_uid` string). Returns `undefined` when there is no
64
- * frontmatter, it fails to parse, or `skill_uid` is absent / not a `skl_…`
65
- * string. Never throws.
66
- */
67
- export function parseSkillUid(md) {
68
- const match = md.match(FRONTMATTER_RE);
22
+ async function defaultSyncFile(input) {
23
+ const result = await share({
24
+ paths: [input.filePath],
25
+ company: input.companySlug,
26
+ hqRoot: input.hqRoot,
27
+ vaultConfig: buildVaultConfig(input.token),
28
+ onConflict: "abort",
29
+ });
30
+ return {
31
+ filesUploaded: result.filesUploaded,
32
+ filesSkipped: result.filesSkipped,
33
+ aborted: result.aborted,
34
+ };
35
+ }
36
+ export function parseSkillUid(markdown) {
37
+ const match = markdown.match(FRONTMATTER_RE);
69
38
  if (!match)
70
39
  return undefined;
71
- let doc;
72
40
  try {
73
- doc = yaml.load(match[1]);
41
+ const doc = yaml.load(match[1]);
42
+ if (!doc || typeof doc !== "object" || Array.isArray(doc))
43
+ return undefined;
44
+ const uid = doc.skill_uid;
45
+ return typeof uid === "string" && SKILL_UID_PATTERN.test(uid) ? uid : undefined;
74
46
  }
75
47
  catch {
76
48
  return undefined;
77
49
  }
78
- if (!doc || typeof doc !== "object" || Array.isArray(doc))
79
- return undefined;
80
- const uid = doc.skill_uid;
81
- return typeof uid === "string" && SKILL_UID_PATTERN.test(uid) ? uid : undefined;
82
- }
83
- /** sha256 hex of a string — the base-version fingerprint the server records (AC2). */
84
- export function sha256Hex(content) {
85
- return createHash("sha256").update(content, "utf8").digest("hex");
86
- }
87
- /**
88
- * Build the CREATE request body from the resolved proposed content and an
89
- * optional base. Mirrors the server's two payload shapes:
90
- * - `baseContent` present → the server derives the unified diff + base hash
91
- * (the diff-by-default path). A no-op (base === proposed) is rejected here
92
- * rather than round-tripped to an EmptySuggestion 400.
93
- * - no base → a `full-file` proposal; the server REQUIRES a `baseContentHash`,
94
- * so we fingerprint the proposed content (a "here is my whole file" proposal
95
- * with no base to diff against).
96
- * An empty / whitespace-only note is dropped (kept absent, not blank).
97
- */
98
- export function buildSuggestionCreateBody(input) {
99
- const body = { proposedContent: input.proposedContent };
100
- if (input.baseContent !== undefined) {
101
- if (input.baseContent === input.proposedContent) {
102
- throw new Error("No changes to propose — the working SKILL.md matches its base.");
103
- }
104
- body.baseContent = input.baseContent;
105
- }
106
- else {
107
- body.baseContentHash = sha256Hex(input.proposedContent);
108
- }
109
- const note = input.note?.trim();
110
- if (note)
111
- body.authorNote = note;
112
- return body;
113
- }
114
- /**
115
- * Map an hq-pro skill route error to a single user-facing line. Pure so the
116
- * status → copy mapping is unit-tested independently of the network. Prefers the
117
- * server's own `error` / `message` (they carry the actionable specifics — e.g.
118
- * "Skill not found", "You need write access to this skill…").
119
- */
120
- export function mapSkillError(status, body) {
121
- const server = (typeof body.error === "string" && body.error) ||
122
- (typeof body.message === "string" && body.message) ||
123
- "";
124
- if (status === 401)
125
- return "Not authenticated — please run `hq login`";
126
- if (status === 403)
127
- return server || "Not authorized";
128
- if (status === 404)
129
- return server || "Not found";
130
- if (status === 409)
131
- return server || "Conflict — please retry";
132
- if (status === 422)
133
- return server || "Company vault not provisioned";
134
- if (status >= 500)
135
- return `Server error: ${server || status}`;
136
- return server || `Request failed (${status})`;
137
- }
138
- function shortDate(iso) {
139
- return typeof iso === "string" ? iso.slice(0, 10) : "";
140
- }
141
- /**
142
- * Render the review inbox as a table (one row per suggestion), optionally
143
- * printing each suggestion's unified diff (or full proposed file, when the base
144
- * drifted / the proposal is full-file) beneath its row. Pure → snapshot-testable.
145
- */
146
- export function formatSuggestionsList(suggestions, opts = {}) {
147
- if (suggestions.length === 0) {
148
- return chalk.gray("No suggestions match — the review inbox is empty.");
149
- }
150
- const idW = Math.max(13, ...suggestions.map((s) => s.suggestionId.length));
151
- const skillW = Math.max(8, ...suggestions.map((s) => s.skillUid.length));
152
- const authW = Math.max(6, ...suggestions.map((s) => s.authorPersonUid.length));
153
- const statusW = Math.max(6, ...suggestions.map((s) => s.status.length));
154
- const lines = [];
155
- lines.push(chalk.bold([
156
- "SUGGESTION_ID".padEnd(idW),
157
- "SKILL".padEnd(skillW),
158
- "AUTHOR".padEnd(authW),
159
- "STATUS".padEnd(statusW),
160
- "BASE",
161
- "CREATED",
162
- ].join(" ")));
163
- for (const s of suggestions) {
164
- lines.push([
165
- s.suggestionId.padEnd(idW),
166
- s.skillUid.padEnd(skillW),
167
- s.authorPersonUid.padEnd(authW),
168
- s.status.padEnd(statusW),
169
- s.baseChanged ? chalk.yellow("changed") : "ok".padEnd(7),
170
- shortDate(s.createdAt),
171
- ].join(" "));
172
- if (s.authorNote) {
173
- lines.push(chalk.dim(` note: ${s.authorNote}`));
174
- }
175
- if (opts.showDiff) {
176
- const diff = s.presentation === "diff" && s.unifiedDiff
177
- ? s.unifiedDiff
178
- : (s.fullContent ?? "");
179
- const label = s.presentation === "diff" && s.unifiedDiff
180
- ? "diff"
181
- : `full file${s.baseChanged ? " (base changed)" : ""}`;
182
- lines.push(chalk.dim(` ── ${label} ──`));
183
- for (const dl of diff.split("\n")) {
184
- lines.push(colorizeDiffLine(` ${dl}`));
185
- }
186
- }
187
- }
188
- return lines.join("\n");
189
50
  }
190
- /** Colour a unified-diff line (green add / red del / dim header). Pure. */
191
- function colorizeDiffLine(line) {
192
- const trimmed = line.trimStart();
193
- if (trimmed.startsWith("+"))
194
- return chalk.green(line);
195
- if (trimmed.startsWith("-"))
196
- return chalk.red(line);
197
- if (trimmed.startsWith("@@"))
198
- return chalk.cyan(line);
199
- return chalk.dim(line);
200
- }
201
- // ---------------------------------------------------------------------------
202
- // Company slug + local skill resolution
203
- // ---------------------------------------------------------------------------
204
- /** Read `.hq/config.json`'s `activeCompany` (mirrors signals/sources). */
205
51
  export function readActiveCompanySlug(hqRoot) {
206
52
  const configPath = path.join(hqRoot, ".hq", "config.json");
207
53
  if (!fs.existsSync(configPath))
208
54
  return undefined;
209
55
  try {
210
- const cfg = JSON.parse(fs.readFileSync(configPath, "utf-8"));
211
- return typeof cfg.activeCompany === "string" && cfg.activeCompany.length > 0
212
- ? cfg.activeCompany
56
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
57
+ return typeof config.activeCompany === "string" && config.activeCompany.length > 0
58
+ ? config.activeCompany
213
59
  : undefined;
214
60
  }
215
61
  catch {
216
62
  return undefined;
217
63
  }
218
64
  }
219
- /**
220
- * The skill routes are keyed on the company SLUG. Precedence: explicit
221
- * `--company` → `.hq/config.json` activeCompany. Throws with actionable copy
222
- * when neither is available.
223
- */
224
65
  export function resolveCompanySlug(flag, hqRoot = DEFAULT_HQ_ROOT) {
225
66
  const slug = flag ?? readActiveCompanySlug(hqRoot);
226
67
  if (!slug) {
@@ -228,362 +69,214 @@ export function resolveCompanySlug(flag, hqRoot = DEFAULT_HQ_ROOT) {
228
69
  }
229
70
  return slug;
230
71
  }
231
- /**
232
- * Recursively find a SKILL.md whose frontmatter `skill_uid` equals `uid`,
233
- * searching each root breadth-first with a bounded depth (skips VCS / build /
234
- * dependency dirs). Returns the first match's absolute path, or null.
235
- */
236
- export function findSkillFileByUid(roots, uid, maxDepth = 6) {
237
- const SKIP = new Set([
238
- "node_modules",
239
- ".git",
240
- "dist",
241
- "build",
242
- ".next",
243
- "coverage",
244
- ]);
245
- const queue = [];
246
- for (const r of roots) {
247
- if (fs.existsSync(r))
248
- queue.push({ dir: r, depth: 0 });
249
- }
250
- while (queue.length > 0) {
251
- const { dir, depth } = queue.shift();
252
- let entries;
253
- try {
254
- entries = fs.readdirSync(dir, { withFileTypes: true });
255
- }
256
- catch {
257
- continue;
258
- }
259
- for (const entry of entries) {
260
- const full = path.join(dir, entry.name);
261
- if (entry.isDirectory()) {
262
- if (depth < maxDepth && !SKIP.has(entry.name)) {
263
- queue.push({ dir: full, depth: depth + 1 });
264
- }
265
- }
266
- else if (entry.isFile() && entry.name === "SKILL.md") {
267
- let content;
268
- try {
269
- content = fs.readFileSync(full, "utf-8");
270
- }
271
- catch {
272
- continue;
273
- }
274
- if (parseSkillUid(content) === uid)
275
- return full;
276
- }
277
- }
278
- }
279
- return null;
280
- }
281
- /**
282
- * Resolve a `suggest` target (a `skl_…` uid OR a filesystem path) to the local
283
- * SKILL.md, its uid, and its content. A uid is resolved by scanning the company
284
- * skills dir and the cwd; a path is read directly (a directory → its SKILL.md),
285
- * with the uid read from the file's frontmatter.
286
- */
287
- export function resolveSkillTarget(target, deps) {
288
- if (SKILL_UID_PATTERN.test(target)) {
289
- const roots = Array.from(new Set([
290
- path.join(deps.hqRoot, "companies", deps.companySlug, "skills"),
291
- deps.cwd,
292
- ]));
293
- const filePath = findSkillFileByUid(roots, target);
294
- if (!filePath) {
295
- throw new Error(`Could not find a local SKILL.md with skill_uid '${target}' under ${roots.join(", ")}. ` +
296
- "Pass the path to the edited SKILL.md instead.");
297
- }
298
- return { filePath, skillUid: target, content: fs.readFileSync(filePath, "utf-8") };
299
- }
300
- // Filesystem path — accept either a SKILL.md file or a directory containing one.
301
- let filePath = path.resolve(deps.cwd, target);
72
+ /** Resolve a UID directly, or read the immutable UID from a local SKILL.md. */
73
+ export function resolveSkillUid(target, cwd) {
74
+ if (SKILL_UID_PATTERN.test(target))
75
+ return target;
76
+ let filePath = path.resolve(cwd, target);
302
77
  if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
303
78
  filePath = path.join(filePath, "SKILL.md");
304
79
  }
305
80
  if (!fs.existsSync(filePath)) {
306
- throw new Error(`No SKILL.md found at '${target}'. Pass the path to a SKILL.md (or its directory), or a skl_… uid.`);
81
+ throw new Error(`No SKILL.md found at '${target}'. Pass a SKILL.md path, its directory, or a skl_… uid.`);
307
82
  }
308
- const content = fs.readFileSync(filePath, "utf-8");
309
- const skillUid = parseSkillUid(content);
310
- if (!skillUid) {
311
- throw new Error(`The SKILL.md at '${filePath}' has no skill_uid in its frontmatter — it is not registered yet. ` +
312
- "Sync / reindex the skill first so it has a stable identity, then suggest against it.");
83
+ const uid = parseSkillUid(fs.readFileSync(filePath, "utf8"));
84
+ if (!uid) {
85
+ throw new Error(`The SKILL.md at '${filePath}' has no registered skill_uid. Register and stamp it with 'hq skill create <slug> --company <company>', then retry.`);
313
86
  }
314
- return { filePath, skillUid, content };
87
+ return uid;
315
88
  }
316
- /**
317
- * Read the committed (HEAD) version of a file from its git repo — the diff base
318
- * for "propose my working changes". Returns null when the file is untracked, not
319
- * in a repo, or git is unavailable (the caller then falls back to full-file, or
320
- * errors under `--diff`). Never throws.
321
- */
322
- export async function readGitBase(filePath) {
323
- try {
324
- const git = simpleGit(path.dirname(filePath));
325
- const top = (await git.revparse(["--show-toplevel"])).trim();
326
- const rel = path.relative(top, filePath);
327
- return await git.show([`HEAD:${rel}`]);
89
+ export function canonicalCompanySkillPath(hqRoot, companySlug, skillSlug) {
90
+ if (!COMPANY_SLUG_PATTERN.test(companySlug)) {
91
+ throw new Error("Company slug must start with a lowercase letter or number and contain only lowercase letters, numbers, and hyphens.");
328
92
  }
329
- catch {
330
- return null;
93
+ if (!SKILL_SLUG_PATTERN.test(skillSlug)) {
94
+ throw new Error("Skill slug must start with a lowercase letter or number and contain only lowercase letters, numbers, and hyphens.");
331
95
  }
96
+ return path.join(hqRoot, "companies", companySlug, "skills", skillSlug, "SKILL.md");
332
97
  }
333
- // ---------------------------------------------------------------------------
334
- // Network helpers
335
- // ---------------------------------------------------------------------------
336
- async function readJsonBody(res) {
337
- return (await res.json().catch(() => ({})));
98
+ function displayNameFromSlug(slug) {
99
+ return slug
100
+ .split("-")
101
+ .filter(Boolean)
102
+ .map((part) => part[0].toUpperCase() + part.slice(1))
103
+ .join(" ");
338
104
  }
339
- /** Print the mapped error for a non-2xx response and exit(1). Returns never. */
340
- async function failFromResponse(res) {
341
- const body = await readJsonBody(res);
342
- console.error(chalk.red(mapSkillError(res.status, body)));
343
- process.exit(1);
105
+ export function makeSkillTemplate(input) {
106
+ const name = input.name?.trim() || displayNameFromSlug(input.slug);
107
+ const description = input.description?.trim() ?? "";
108
+ return [
109
+ "---",
110
+ `name: ${JSON.stringify(name)}`,
111
+ `description: ${JSON.stringify(description)}`,
112
+ "tags: []",
113
+ "---",
114
+ "",
115
+ `# ${name}`,
116
+ "",
117
+ "Describe when and how to use this skill.",
118
+ "",
119
+ ].join("\n");
344
120
  }
345
- function skillRoutePrefix(slug) {
346
- return `/v1/files/skills/company/${encodeURIComponent(slug)}`;
121
+ /** Replace SKILL.md without exposing a partially-written identity to agents. */
122
+ export function writeSkillFileAtomically(filePath, content) {
123
+ const dir = path.dirname(filePath);
124
+ fs.mkdirSync(dir, { recursive: true });
125
+ const existingMode = fs.existsSync(filePath)
126
+ ? fs.statSync(filePath).mode
127
+ : 0o644;
128
+ const tempPath = path.join(dir, `.SKILL.md.${process.pid}.${Date.now()}.tmp`);
129
+ try {
130
+ fs.writeFileSync(tempPath, content, {
131
+ encoding: "utf8",
132
+ flag: "wx",
133
+ mode: existingMode,
134
+ });
135
+ fs.renameSync(tempPath, filePath);
136
+ }
137
+ finally {
138
+ if (fs.existsSync(tempPath))
139
+ fs.rmSync(tempPath, { force: true });
140
+ }
141
+ }
142
+ export function mapSkillError(status, body) {
143
+ const server = (typeof body.error === "string" && body.error) ||
144
+ (typeof body.message === "string" && body.message) ||
145
+ "";
146
+ if (status === 401)
147
+ return "Not authenticated — please run `hq login`";
148
+ if (status === 403)
149
+ return server || "Not authorized";
150
+ if (status === 404)
151
+ return server || "Skill not found";
152
+ if (status >= 500)
153
+ return `Server error: ${server || status}`;
154
+ return server || `Request failed (${status})`;
347
155
  }
348
- // ---------------------------------------------------------------------------
349
- // Command registration
350
- // ---------------------------------------------------------------------------
351
156
  export function registerSkillCommand(program, deps = {}) {
352
- const gitBase = deps.gitBase ?? readGitBase;
157
+ const ensureToken = deps.ensureToken ?? ensureCognitoToken;
158
+ const apiFetch = deps.apiFetch ?? vaultApiFetch;
159
+ const cwd = deps.cwd ?? process.cwd;
160
+ const hqRoot = deps.hqRoot ?? DEFAULT_HQ_ROOT;
161
+ const syncFile = deps.syncFile ?? defaultSyncFile;
162
+ const reindexFn = deps.reindexFn ?? reindex;
353
163
  const skill = program
354
164
  .command("skill")
355
- .description("Propose, review, and merge skill changes from the terminal")
356
- .option("--company <slug>", "Company slug (defaults to the active company)");
357
- // ── suggest ────────────────────────────────────────────────────────────────
165
+ .description("Create company skills and discuss improvements")
166
+ .option("--company <slug>", "Company slug (defaults to the active company)")
167
+ .option("--hq-root <path>", "Local HQ root", hqRoot);
358
168
  skill
359
- .command("suggest <target>")
360
- .description("Create a suggestion from a local SKILL.md (a skl_… uid or a path). Never blocked by a skill lock — a suggestion is always a proposal.")
361
- .option("--diff", "Require a diff base (git HEAD, or --base): fail if none is found. Default falls back to a full-file proposal.")
362
- .option("--base <file>", "Explicit base file to diff the working SKILL.md against")
363
- .option("--note <text>", "Author note the rationale / failure context attached to the suggestion")
364
- .action(async (target, opts) => {
365
- try {
366
- const companySlug = resolveCompanySlug(skill.opts().company);
367
- const resolved = resolveSkillTarget(target, {
368
- cwd: process.cwd(),
369
- hqRoot: DEFAULT_HQ_ROOT,
370
- companySlug,
371
- });
372
- // Base resolution: explicit --base wins; otherwise diff against git
373
- // HEAD when the file is tracked. No base + --diff → hard error; no
374
- // base without --diff → a full-file proposal.
375
- let baseContent;
376
- let baseSource = "";
377
- if (opts.base !== undefined) {
378
- const basePath = path.resolve(process.cwd(), opts.base);
379
- if (!fs.existsSync(basePath)) {
380
- console.error(chalk.red(`Base file not found: ${opts.base}`));
381
- process.exit(1);
382
- }
383
- baseContent = fs.readFileSync(basePath, "utf-8");
384
- baseSource = opts.base;
385
- }
386
- else {
387
- const fromGit = await gitBase(resolved.filePath);
388
- if (fromGit !== null) {
389
- baseContent = fromGit;
390
- baseSource = "git HEAD";
391
- }
392
- }
393
- if (baseContent === undefined && opts.diff) {
394
- console.error(chalk.red(`--diff needs a base, but none was found for ${resolved.filePath}. ` +
395
- "Pass --base <file>, or commit a base version first."));
396
- process.exit(1);
397
- }
398
- if (baseContent !== undefined && baseContent === resolved.content) {
399
- console.error(chalk.red(`No changes to propose — the working SKILL.md matches its base (${baseSource}).`));
400
- process.exit(1);
401
- }
402
- const body = buildSuggestionCreateBody({
403
- proposedContent: resolved.content,
404
- baseContent,
405
- note: opts.note,
406
- });
407
- const token = await ensureCognitoToken();
408
- const res = await vaultApiFetch({
409
- token,
410
- path: `${skillRoutePrefix(companySlug)}/${encodeURIComponent(resolved.skillUid)}/suggestions`,
411
- method: "POST",
412
- body: body,
169
+ .command("create <slug>")
170
+ .description("Register, stamp, reindex, and sync a company skill")
171
+ .option("--name <name>", "Display name when creating a new template")
172
+ .option("--description <text>", "Description when creating a new template")
173
+ .option("--no-sync", "Register locally without uploading the stamped file")
174
+ .action(async (slug, opts) => {
175
+ if (!SKILL_SLUG_PATTERN.test(slug)) {
176
+ throw new Error("Skill slug must start with a lowercase letter or number and contain only lowercase letters, numbers, and hyphens.");
177
+ }
178
+ const parentOpts = skill.opts();
179
+ const resolvedRoot = path.resolve(parentOpts.hqRoot ?? hqRoot);
180
+ const companySlug = resolveCompanySlug(parentOpts.company, resolvedRoot);
181
+ const filePath = canonicalCompanySkillPath(resolvedRoot, companySlug, slug);
182
+ if (fs.existsSync(filePath) && !fs.statSync(filePath).isFile()) {
183
+ throw new Error(`Expected a SKILL.md file at '${filePath}'.`);
184
+ }
185
+ const localContent = fs.existsSync(filePath)
186
+ ? fs.readFileSync(filePath, "utf8")
187
+ : makeSkillTemplate({
188
+ slug,
189
+ ...(opts.name !== undefined ? { name: opts.name } : {}),
190
+ ...(opts.description !== undefined
191
+ ? { description: opts.description }
192
+ : {}),
413
193
  });
414
- if (!res.ok)
415
- await failFromResponse(res);
416
- const data = (await res.json());
417
- console.log(chalk.green(`Suggestion created: ${data.suggestionId}`));
418
- console.log(` Skill: ${data.skillUid}`);
419
- console.log(` Status: ${data.status}`);
420
- // AC4 — attribution is server-derived from the invoking identity.
421
- console.log(` Author: ${data.authorPersonUid}`);
422
- console.log(` Change: ${data.payloadFormat === "diff" ? `diff vs ${baseSource || "base"}` : "full file"}`);
423
- if (data.authorNote)
424
- console.log(` Note: ${data.authorNote}`);
425
- console.log(chalk.dim(` The skill owner can review it: hq skill review ${data.suggestionId} --accept|--decline`));
194
+ const token = await ensureToken();
195
+ const response = await apiFetch({
196
+ token,
197
+ path: `/v1/files/skills/company/${encodeURIComponent(companySlug)}/register`,
198
+ method: "POST",
199
+ body: {
200
+ path: `skills/${slug}/SKILL.md`,
201
+ content: localContent,
202
+ },
203
+ });
204
+ if (!response.ok) {
205
+ const body = (await response.json().catch(() => ({})));
206
+ throw new Error(mapSkillError(response.status, body));
426
207
  }
427
- catch (err) {
428
- console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
429
- process.exit(1);
208
+ const registered = (await response.json());
209
+ if (typeof registered.skillUid !== "string" ||
210
+ !SKILL_UID_PATTERN.test(registered.skillUid) ||
211
+ typeof registered.content !== "string" ||
212
+ parseSkillUid(registered.content) !== registered.skillUid) {
213
+ throw new Error("The server returned an invalid skill registration response; the local file was not changed.");
430
214
  }
431
- });
432
- // ── list-suggestions ─────────────────────────────────────────────────────────
433
- skill
434
- .command("list-suggestions")
435
- .description("List the review inbox — open suggestions on skills you own (or one skill via --skill)")
436
- .option("--skill <skillUid>", "Scope to one skill (skl_…); requires write access on it")
437
- .option("--status <status>", "open | accepted | declined | all (default: open)")
438
- .option("--show-diff", "Print each suggestion's unified diff / proposed file")
439
- .option("--json", "Emit raw JSON instead of a table")
440
- .action(async (opts) => {
215
+ writeSkillFileAtomically(filePath, registered.content);
216
+ let reindexStatus = null;
441
217
  try {
442
- const companySlug = resolveCompanySlug(skill.opts().company);
443
- const status = (opts.status ?? "open");
444
- if (!VALID_LIST_STATUSES.includes(status)) {
445
- console.error(chalk.red(`Invalid --status '${opts.status}': must be one of ${VALID_LIST_STATUSES.join(", ")}`));
446
- process.exit(1);
447
- }
448
- if (opts.skill !== undefined && !SKILL_UID_PATTERN.test(opts.skill)) {
449
- console.error(chalk.red(`Invalid --skill '${opts.skill}': must be a skl_… uid`));
450
- process.exit(1);
451
- }
452
- const body = { status };
453
- if (opts.skill !== undefined)
454
- body.skillUid = opts.skill;
455
- const token = await ensureCognitoToken();
456
- const res = await vaultApiFetch({
457
- token,
458
- path: `${skillRoutePrefix(companySlug)}/suggestions/list`,
459
- method: "POST",
460
- body,
461
- });
462
- if (!res.ok)
463
- await failFromResponse(res);
464
- const data = (await res.json());
465
- if (opts.json) {
466
- console.log(JSON.stringify(data, null, 2));
467
- return;
468
- }
469
- console.log(formatSuggestionsList(data.suggestions, { showDiff: opts.showDiff }));
218
+ reindexStatus = reindexFn({ repoRoot: resolvedRoot }).status;
470
219
  }
471
220
  catch (err) {
472
- console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
473
- process.exit(1);
221
+ console.warn(chalk.yellow(`⚠ Skill registered, but HQ reindex failed: ${err instanceof Error ? err.message : String(err)}`));
474
222
  }
475
- });
476
- // ── review ─────────────────────────────────────────────────────────────────
477
- skill
478
- .command("review <suggestionId>")
479
- .description("Accept (merge) or decline a pending suggestion")
480
- .option("--accept", "Accept the suggestion — merges the proposed change into the skill")
481
- .option("--decline", "Decline the suggestion — leaves the skill unchanged")
482
- .option("--reason <text>", "Reason recorded on a decline")
483
- .option("--skill <skillUid>", "Scope the lookup to one skill (skl_…)")
484
- .option("--acknowledge-base-changed", "On accept: overwrite with the full proposed file even if the skill changed since the suggestion")
485
- .action(async (suggestionId, opts) => {
486
- try {
487
- if (!SUGGESTION_ID_PATTERN.test(suggestionId)) {
488
- console.error(chalk.red(`Invalid suggestion id '${suggestionId}': must be a sgn_… id`));
489
- process.exit(1);
490
- }
491
- const accept = opts.accept === true;
492
- const decline = opts.decline === true;
493
- if (accept === decline) {
494
- console.error(chalk.red("Pass exactly one of --accept or --decline."));
495
- process.exit(1);
496
- }
497
- if (opts.skill !== undefined && !SKILL_UID_PATTERN.test(opts.skill)) {
498
- console.error(chalk.red(`Invalid --skill '${opts.skill}': must be a skl_… uid`));
499
- process.exit(1);
500
- }
501
- const companySlug = resolveCompanySlug(skill.opts().company);
502
- const token = await ensureCognitoToken();
503
- // Resolve the suggestion's skillUid + live path from the SAME LIST route
504
- // the console uses — accept/decline are path↔uid guarded, so the path
505
- // must come from the authoritative row, never guessed. `all` so an
506
- // already-resolved suggestion yields a precise "already <status>" error.
507
- const listBody = { status: "all" };
508
- if (opts.skill !== undefined)
509
- listBody.skillUid = opts.skill;
510
- const listRes = await vaultApiFetch({
511
- token,
512
- path: `${skillRoutePrefix(companySlug)}/suggestions/list`,
513
- method: "POST",
514
- body: listBody,
515
- });
516
- if (!listRes.ok)
517
- await failFromResponse(listRes);
518
- const { suggestions } = (await listRes.json());
519
- const row = suggestions.find((s) => s.suggestionId === suggestionId);
520
- if (!row) {
521
- console.error(chalk.red(`Suggestion ${suggestionId} is not in your review inbox. ` +
522
- "If it targets a skill you don't own, scope the lookup with --skill <skl_…>."));
523
- process.exit(1);
524
- }
525
- if (row.status !== "open") {
526
- console.error(chalk.yellow(`Suggestion ${suggestionId} is already ${row.status} — nothing to do.`));
527
- process.exit(1);
528
- }
529
- if (accept) {
530
- if (!row.path) {
531
- console.error(chalk.red("Could not resolve the live skill path for this suggestion — the skill may have been removed."));
532
- process.exit(1);
533
- }
534
- const acceptBody = { path: row.path };
535
- if (opts.acknowledgeBaseChanged)
536
- acceptBody.acknowledgeBaseChanged = true;
537
- const res = await vaultApiFetch({
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.`));
225
+ }
226
+ if (opts.sync !== false) {
227
+ let syncResult;
228
+ try {
229
+ syncResult = await syncFile({
230
+ filePath,
231
+ companySlug,
232
+ hqRoot: resolvedRoot,
538
233
  token,
539
- path: `${skillRoutePrefix(companySlug)}/${encodeURIComponent(row.skillUid)}/suggestions/${encodeURIComponent(suggestionId)}/accept`,
540
- method: "POST",
541
- body: acceptBody,
542
234
  });
543
- if (!res.ok) {
544
- const errBody = await readJsonBody(res);
545
- // AC4 (US-009): the base drifted — guide the operator to re-run
546
- // with acknowledgement rather than silently overwriting.
547
- if (res.status === 409 &&
548
- errBody.code === "WRITE_SKILL_SUGGESTION_BASE_CHANGED") {
549
- console.error(chalk.yellow("The skill changed since this suggestion was written. Re-run with " +
550
- "--acknowledge-base-changed to overwrite it with the full proposed file."));
551
- process.exit(1);
552
- }
553
- console.error(chalk.red(mapSkillError(res.status, errBody)));
554
- process.exit(1);
555
- }
556
- const data = (await res.json());
557
- console.log(chalk.green("Suggestion accepted — merged and closed."));
558
- console.log(` Skill: ${data.skillUid}`);
559
- console.log(` Suggestion: ${data.suggestionId}`);
560
- console.log(` Accepted by: ${data.acceptedBy}`);
561
- return;
562
235
  }
563
- // Decline.
564
- const declineBody = {};
565
- if (opts.reason !== undefined)
566
- declineBody.reason = opts.reason;
567
- const res = await vaultApiFetch({
568
- token,
569
- path: `${skillRoutePrefix(companySlug)}/${encodeURIComponent(row.skillUid)}/suggestions/${encodeURIComponent(suggestionId)}/decline`,
570
- method: "POST",
571
- body: declineBody,
572
- });
573
- if (!res.ok)
574
- await failFromResponse(res);
575
- const data = (await res.json());
576
- console.log(chalk.green("Suggestion declined — the skill was left unchanged."));
577
- console.log(` Skill: ${data.skillUid}`);
578
- console.log(` Suggestion: ${data.suggestionId}`);
579
- console.log(` Declined by: ${data.declinedBy}`);
580
- if (data.reason)
581
- console.log(` Reason: ${data.reason}`);
236
+ catch (err) {
237
+ throw new Error(`Skill ${registered.skillUid} is stamped locally at '${filePath}', but sync failed: ${err instanceof Error ? err.message : String(err)}`);
238
+ }
239
+ if (syncResult.aborted) {
240
+ throw new Error(`Skill ${registered.skillUid} is stamped locally at '${filePath}', but sync aborted because the remote file conflicts.`);
241
+ }
582
242
  }
583
- catch (err) {
584
- console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
585
- process.exit(1);
243
+ console.log(chalk.green(`Skill ready: ${registered.skillUid}`));
244
+ console.log(` File: ${filePath}`);
245
+ console.log(` Discovery: ${reindexStatus === 0 ? "reindexed" : "reindex needs attention"}`);
246
+ console.log(registered.accessPolicy === "open"
247
+ ? ` Access: Open — every active ${companySlug} member can edit`
248
+ : " Access: preserved existing policy");
249
+ console.log(` Sync: ${opts.sync === false ? "not requested" : "complete"}`);
250
+ });
251
+ skill
252
+ .command("propose <target>")
253
+ .description("Post a comment-only improvement for a skill")
254
+ .requiredOption("-m, --message <text>", "The improvement to discuss")
255
+ .action(async (target, opts) => {
256
+ const message = opts.message.trim();
257
+ if (!message)
258
+ throw new Error("An improvement message is required.");
259
+ const parentOpts = skill.opts();
260
+ const resolvedRoot = path.resolve(parentOpts.hqRoot ?? hqRoot);
261
+ const companySlug = resolveCompanySlug(parentOpts.company, resolvedRoot);
262
+ const skillUid = resolveSkillUid(target, cwd());
263
+ const token = await ensureToken();
264
+ const response = await apiFetch({
265
+ token,
266
+ path: `/v1/files/skills/company/${encodeURIComponent(companySlug)}/${encodeURIComponent(skillUid)}/comments`,
267
+ method: "POST",
268
+ body: { anchor: { kind: "whole-skill" }, body: message },
269
+ });
270
+ if (!response.ok) {
271
+ const body = (await response.json().catch(() => ({})));
272
+ throw new Error(mapSkillError(response.status, body));
586
273
  }
274
+ const posted = (await response.json());
275
+ console.log(chalk.green(`Improvement posted: ${posted.commentId}`));
276
+ console.log(` Skill: ${posted.skillUid}`);
277
+ console.log(` Author: ${posted.authorPersonUid}`);
278
+ console.log(` Message: ${posted.body}`);
279
+ console.log(chalk.dim(" Review it in HQ Console → Skills → Improvements"));
587
280
  });
588
281
  return skill;
589
282
  }
package/dist/main.js CHANGED
@@ -72,6 +72,7 @@ import { isExpectedUserError } from "./utils/expected-cli-error.js";
72
72
  import { isEpipe } from "./utils/epipe.js";
73
73
  import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
74
74
  import { isAuthError } from "./utils/auth-error.js";
75
+ import { browserLoginAbandonedMessage } from "./utils/browser-login-abandoned.js";
75
76
  import { isCompanySelectionError } from "./utils/company-selection-error.js";
76
77
  import { canOfferTeamUpgrade, formatPlanGateError, isPlanGateError, offerTeamUpgrade, } from "./utils/plan-gate-error.js";
77
78
  import { upgradeToTeam } from "./utils/team-upgrade.js";
@@ -206,9 +207,8 @@ registerGroupGrantsCommand(program);
206
207
  // browse-vs-sync subcommands (`hq files browse`/`cat`) onto the same group.
207
208
  const filesCmd = registerFilesCommand(program);
208
209
  registerFilesBrowseCommands(filesCmd);
209
- // Skill collaboration loop (subcommand group hq skill suggest|list-suggestions|review).
210
- // A thin terminal front-end over the SAME wired hq-pro skill suggestion + merge
211
- // routes the MCP (US-007) and console merge (US-009) surfaces use — no forked logic.
210
+ // Comment-only skill improvement loop. Structured suggestion/review commands are
211
+ // intentionally absent; live content changes remain governed by FILE_ACL sync.
212
212
  registerSkillCommand(program);
213
213
  // Membership management (subcommand group — hq members invite|list|revoke)
214
214
  registerMembersCommand(program);
@@ -395,6 +395,21 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
395
395
  deps.stderr.write(`hq: ${err.message}\n`);
396
396
  deps.setExitCode(1);
397
397
  }
398
+ else if (browserLoginAbandonedMessage(err)) {
399
+ // HQ-CLI-V: the caller triggered the implicit browser sign-in fallback
400
+ // (an expiring/absent HQ session) and then never completed it —
401
+ // @indigoai-us/hq-cloud's browserLogin rejected either at its 15-minute
402
+ // deadline or because the user declined at the IdP (access_denied). A
403
+ // human walking away from an OAuth prompt is expected auth state the user
404
+ // fixes with `hq login`, not an hq-cli defect. Print the fixed actionable
405
+ // remedy and skip Sentry so one abandoned login doesn't file a permanent
406
+ // high-priority "crash". Placed with the auth carve-out (both mean "your
407
+ // HQ session isn't usable; run hq login"); every genuine browser-login or
408
+ // token-exchange fault stays outside the closed allowlist and still
409
+ // captures below.
410
+ deps.stderr.write(`hq: ${browserLoginAbandonedMessage(err)}\n`);
411
+ deps.setExitCode(1);
412
+ }
398
413
  else if (isPlanGateError(err)) {
399
414
  // hq-pro's plan denials are expected product limits, never a CLI crash.
400
415
  // The shared vault client has already decoded and typed the small safe
@@ -0,0 +1,12 @@
1
+ /**
2
+ * If `err` is an abandoned implicit browser sign-in, return a short, fixed
3
+ * user-facing remedy; otherwise return `null`.
4
+ *
5
+ * A non-null result means the caller should print the message and SKIP Sentry
6
+ * capture — a user who walked away from an OAuth prompt is not an hq-cli defect.
7
+ * A null result means "handle as usual (capture to Sentry)". CognitoRefreshError
8
+ * is a refresh fault, never an abandonment, so it is excluded structurally even
9
+ * though it extends CognitoAuthError.
10
+ */
11
+ export declare function browserLoginAbandonedMessage(err: unknown): string | null;
12
+ //# sourceMappingURL=browser-login-abandoned.d.ts.map
@@ -0,0 +1,70 @@
1
+ // src/utils/browser-login-abandoned.ts
2
+ //
3
+ // Classify an ABANDONED implicit browser sign-in — the caller triggered the
4
+ // fallback OAuth flow (an expiring/absent HQ session on any of the ~100
5
+ // ensureCognitoToken/ensureCognitoIdToken call sites) and then never completed
6
+ // it. @indigoai-us/hq-cloud's browserLogin arms a 15-minute deadline and also
7
+ // rejects when the identity provider reports the user declined; both mint a
8
+ // bare CognitoAuthError. A human walking away from an OAuth prompt is expected
9
+ // auth state the user fixes with `hq login`, NOT an hq-cli defect — so the
10
+ // top-level handler prints an actionable line and exits non-zero but SKIPS
11
+ // Sentry capture, mirroring the auth (HQ-CLI-8), company-selection (HQ-CLI-7),
12
+ // expected-user-error (HQ-CLI-6), and environmental-FS (HQ-CLI-2) carve-outs.
13
+ //
14
+ // HQ-CLI-V (Sentry indigo-d0/hq-cli 7685055900): `hq integrations list
15
+ // --company <slug> --json` tried to refresh an expiring session, the refresh
16
+ // 400'd (invalid_grant), and it fell back to browserLogin(). Nobody finished
17
+ // the sign-in, so exactly 15 minutes later browserLogin rejected with
18
+ // `CognitoAuthError("Login timed out after 15 minutes")`. That error carries no
19
+ // `code`, no `expected` flag, and is not an AuthError/CompanySelectionError/…,
20
+ // so every predicate in handleTopLevelError returned false and the abandoned
21
+ // login was captured as a high-priority, unfixable "crash".
22
+ //
23
+ // The allowlist is CLOSED at the two upstream-minted human-abandonment
24
+ // signatures and defaults to null (capture). Every other CognitoAuthError — a
25
+ // state-parameter mismatch (possible CSRF), a missing callback code, a
26
+ // non-access_denied IdP error, a token-exchange failure — and every
27
+ // CognitoRefreshError (a refresh fault, not an abandonment) still reaches
28
+ // Sentry.
29
+ import { CognitoAuthError, CognitoRefreshError } from "@indigoai-us/hq-cloud";
30
+ /**
31
+ * Fixed, actionable remedy. Deliberately interpolates NO upstream error text,
32
+ * so this carve-out can never widen disclosure and needs no redaction pass.
33
+ */
34
+ const BROWSER_LOGIN_ABANDONED_MESSAGE = "Browser sign-in was not completed. Run `hq login`, finish the sign-in in " +
35
+ "your browser, then re-run your command.";
36
+ /**
37
+ * The 15-minute login-deadline signature (@indigoai-us/hq-cloud cognito-auth
38
+ * `waitForAuthCode`). The minute count is tolerated (`\d+`) so an upstream
39
+ * retune of the deadline turns the contract test red rather than silently
40
+ * restoring the Sentry noise.
41
+ */
42
+ const LOGIN_TIMED_OUT = /^Login timed out after \d+ minutes?$/;
43
+ /**
44
+ * The user declined at the identity provider. Matched EXACTLY — other
45
+ * `Cognito returned error: <x>` values (server_error, …) are not abandonment
46
+ * and stay reportable.
47
+ */
48
+ const ACCESS_DENIED = "Cognito returned error: access_denied";
49
+ /**
50
+ * If `err` is an abandoned implicit browser sign-in, return a short, fixed
51
+ * user-facing remedy; otherwise return `null`.
52
+ *
53
+ * A non-null result means the caller should print the message and SKIP Sentry
54
+ * capture — a user who walked away from an OAuth prompt is not an hq-cli defect.
55
+ * A null result means "handle as usual (capture to Sentry)". CognitoRefreshError
56
+ * is a refresh fault, never an abandonment, so it is excluded structurally even
57
+ * though it extends CognitoAuthError.
58
+ */
59
+ export function browserLoginAbandonedMessage(err) {
60
+ if (!(err instanceof CognitoAuthError))
61
+ return null;
62
+ if (err instanceof CognitoRefreshError)
63
+ return null;
64
+ const { message } = err;
65
+ if (LOGIN_TIMED_OUT.test(message) || message === ACCESS_DENIED) {
66
+ return BROWSER_LOGIN_ABANDONED_MESSAGE;
67
+ }
68
+ return null;
69
+ }
70
+ //# sourceMappingURL=browser-login-abandoned.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.103.22",
3
+ "version": "5.103.24",
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.37",
33
+ "@indigoai-us/hq-cloud": "~6.15.71",
34
34
  "@indigoai-us/hq-onboarding": "^0.1.0",
35
35
  "@sentry/node": "^10.49.0",
36
36
  "@tobilu/qmd": "2.5.3",