@indigoai-us/hq-cli 5.101.4 → 5.101.6

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
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [5.101.6] — 2026-08-17
6
+
7
+ ### Added
8
+
9
+ - **`hq mesh`** — native work-mesh verbs in the CLI (Cognito + hq-pro REST +
10
+ `~/.hq/work-mesh/cache`). No pack helper required. Verbs: `check`
11
+ (`status` / `projects`), `start`, `progress`, `blocked`, `done`, `note`,
12
+ `story`, `doctor` (warms directory + inbox + pair DMs). Does **not** start
13
+ MQTT listen and is not `hq doctor` (hook guardrails). `--apply` Board PUTs
14
+ from local prd.json are not in the CLI yet.
15
+
16
+ ### Fixed
17
+
18
+ - `hq mesh doctor` now keys `directory` / `inbox` / `contacts` on the real
19
+ `prs_*` / `agt_*` principal. `custom:entityUid` is read from the Cognito ID
20
+ token first; if the JWT has only email/sub (typical human access tokens),
21
+ doctor falls back to `/v1/realtime/credentials` and keeps only the uid —
22
+ AWS IoT creds are discarded. Leftover `session.json` is removed once the
23
+ real principal is known.
24
+
25
+ ## [5.101.5] — 2026-08-17
26
+
27
+ - No user-facing changes recorded.
28
+
3
29
  ## [5.101.4] — 2026-08-16
4
30
 
5
31
  - No user-facing changes recorded.
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `hq mesh` — work-mesh verbs for agents and local sessions.
3
+ *
4
+ * Native hq-cli: Cognito + hq-pro REST + ~/.hq/work-mesh/cache. Does not
5
+ * require hq-pack-work-mesh on disk and does not start MQTT listen.
6
+ * Distinct from `hq doctor` (hook guardrails).
7
+ */
8
+ import { Command } from "commander";
9
+ import { type MeshCompany, type MeshThread } from "../lib/mesh/api.js";
10
+ export declare function formatCheckLines(threads: MeshThread[], company: MeshCompany, projectId?: string): string[];
11
+ export declare function registerMeshCommand(program: Command): void;
12
+ //# sourceMappingURL=mesh.d.ts.map
@@ -0,0 +1,173 @@
1
+ /**
2
+ * `hq mesh` — work-mesh verbs for agents and local sessions.
3
+ *
4
+ * Native hq-cli: Cognito + hq-pro REST + ~/.hq/work-mesh/cache. Does not
5
+ * require hq-pack-work-mesh on disk and does not start MQTT listen.
6
+ * Distinct from `hq doctor` (hook guardrails).
7
+ */
8
+ import chalk from "chalk";
9
+ import { loadCachedTokens } from "../utils/cognito-session.js";
10
+ import { STORY_STATUSES, appendThreadEvent, callerLabelFromToken, ensureProjectThread, eventPayload, listActiveThreads, patchStoryStatus, requireToken, resolveMeshCompany, resolveMeshPrincipalUid, warmMeshConversationCache, } from "../lib/mesh/api.js";
11
+ export function formatCheckLines(threads, company, projectId) {
12
+ if (threads.length === 0) {
13
+ return ["Work mesh: no active project threads found."];
14
+ }
15
+ const scope = projectId
16
+ ? `${company.companySlug || company.companyUid}/${projectId}`
17
+ : company.companySlug || company.companyUid;
18
+ const lines = [`Work mesh: ${threads.length} active thread(s) for ${scope}`];
19
+ for (const thread of threads.slice(0, 8)) {
20
+ const owner = thread.ownerUid ? ` owner=${thread.ownerUid}` : "";
21
+ const summary = thread.progressSummary ||
22
+ thread.sourceSignalSummary ||
23
+ thread.blockedReason ||
24
+ "";
25
+ lines.push(`- ${thread.threadStatus ?? "unknown"} ${thread.threadId ?? "?"}${owner}${summary ? `: ${summary}` : ""}`);
26
+ }
27
+ return lines;
28
+ }
29
+ function fail(message) {
30
+ console.error(chalk.red(message));
31
+ process.exit(1);
32
+ }
33
+ function requireProject(opts) {
34
+ const project = opts.project?.trim();
35
+ if (!project)
36
+ fail("`--project <slug>` is required.");
37
+ return project;
38
+ }
39
+ async function withCompany(opts) {
40
+ const token = await requireToken();
41
+ const company = await resolveMeshCompany(token, opts.company);
42
+ return { token, company };
43
+ }
44
+ async function runCheck(opts) {
45
+ const { token, company } = await withCompany(opts);
46
+ const threads = await listActiveThreads(token, company.companyUid, opts.project);
47
+ if (opts.json) {
48
+ console.log(JSON.stringify({ ok: true, action: "check", company, projectId: opts.project, threads }, null, 2));
49
+ return;
50
+ }
51
+ for (const line of formatCheckLines(threads, company, opts.project)) {
52
+ console.log(line);
53
+ }
54
+ }
55
+ async function runEvent(verb, opts) {
56
+ const { token, company } = await withCompany(opts);
57
+ const projectId = requireProject(opts);
58
+ const ensured = await ensureProjectThread(token, company, projectId, {
59
+ threadId: opts.threadId,
60
+ summary: opts.summary,
61
+ });
62
+ const eventKind = verb === "start" ? "claim" : verb;
63
+ const event = await appendThreadEvent(token, company.companyUid, ensured.threadId, eventKind, eventPayload(eventKind, opts, callerLabelFromToken(token)));
64
+ if (opts.json) {
65
+ console.log(JSON.stringify({
66
+ ok: true,
67
+ action: verb,
68
+ eventKind,
69
+ company,
70
+ projectId,
71
+ threadId: ensured.threadId,
72
+ created: ensured.created,
73
+ ...event,
74
+ }, null, 2));
75
+ return;
76
+ }
77
+ console.log(`Work mesh: ${verb} ${ensured.threadId}${event.eventId ? ` (${event.eventId})` : ""}`);
78
+ }
79
+ async function runStory(opts) {
80
+ const storyId = opts.story?.trim();
81
+ const status = opts.status?.trim();
82
+ if (!storyId || !status || !STORY_STATUSES.has(status)) {
83
+ fail("story requires --story <id> and --status queued|in_progress|review|done");
84
+ }
85
+ const { token, company } = await withCompany(opts);
86
+ const projectId = requireProject(opts);
87
+ const patched = await patchStoryStatus(token, company.companyUid, projectId, storyId, status);
88
+ if (opts.json) {
89
+ console.log(JSON.stringify({ ok: true, action: "story", company, ...patched, storyId }, null, 2));
90
+ return;
91
+ }
92
+ console.log(`Work mesh: story ${storyId} → ${patched.status} (v${patched.version ?? "?"})`);
93
+ }
94
+ async function runDoctor(opts) {
95
+ if (opts.apply) {
96
+ fail("`hq mesh doctor --apply` (paced Board PUTs from local prd.json) is not in the CLI yet.\n" +
97
+ "Omit --apply to warm the conversation cache (directory, inbox, pair DMs).");
98
+ }
99
+ const token = await requireToken();
100
+ const principalUid = await resolveMeshPrincipalUid(token, loadCachedTokens());
101
+ const warmed = await warmMeshConversationCache(token, principalUid);
102
+ if (opts.json) {
103
+ console.log(JSON.stringify({ ok: true, action: "doctor", ...warmed }, null, 2));
104
+ return;
105
+ }
106
+ console.log(`Work mesh cache warmed at ${warmed.cacheRoot}` +
107
+ ` (directory=${warmed.directory} inbox=${warmed.inbox}` +
108
+ ` contacts=${warmed.contacts} pair-threads=${warmed.threads})`);
109
+ }
110
+ function wrap(action) {
111
+ return async () => {
112
+ try {
113
+ await action();
114
+ }
115
+ catch (err) {
116
+ fail(err instanceof Error ? err.message : String(err));
117
+ }
118
+ };
119
+ }
120
+ function collectRepeatable(value, previous = []) {
121
+ return previous.concat(value);
122
+ }
123
+ function addSharedFlags(cmd) {
124
+ return cmd
125
+ .option("--company <slug|uid>", "Company slug or cloud uid")
126
+ .option("--project <slug>", "HQ project slug / projectId")
127
+ .option("--thread-id <id>", "Explicit work thread id")
128
+ .option("--json", "Print machine-readable JSON");
129
+ }
130
+ export function registerMeshCommand(program) {
131
+ const mesh = program
132
+ .command("mesh")
133
+ .description("Work mesh — register project work, report progress, and warm ~/.hq/work-mesh/cache");
134
+ addSharedFlags(mesh
135
+ .command("check")
136
+ .alias("status")
137
+ .alias("projects")
138
+ .description("Show active work-mesh threads for a company/project")).action((opts) => wrap(() => runCheck(opts))());
139
+ addSharedFlags(mesh
140
+ .command("start")
141
+ .description("Ensure a project thread exists and claim/report start")
142
+ .option("--summary <text>", "What you are starting")).action((opts) => wrap(() => runEvent("start", opts))());
143
+ addSharedFlags(mesh
144
+ .command("progress")
145
+ .description("Append a progress event to the project thread")
146
+ .requiredOption("--summary <text>", "Progress summary")).action((opts) => wrap(() => runEvent("progress", opts))());
147
+ addSharedFlags(mesh
148
+ .command("blocked")
149
+ .description("Append a blocked event to the project thread")
150
+ .option("--reason <text>", "Why you are blocked")
151
+ .option("--ask <text>", "Repeatable ask", collectRepeatable)
152
+ .option("--summary <text>", "Optional summary")).action((opts) => wrap(() => runEvent("blocked", opts))());
153
+ addSharedFlags(mesh
154
+ .command("done")
155
+ .description("Mark the project thread done")
156
+ .option("--summary <text>", "What shipped")).action((opts) => wrap(() => runEvent("done", opts))());
157
+ addSharedFlags(mesh
158
+ .command("note")
159
+ .description("Append a note with no status change")
160
+ .option("--summary <text>", "Note text")).action((opts) => wrap(() => runEvent("note", opts))());
161
+ addSharedFlags(mesh
162
+ .command("story")
163
+ .description("PATCH one Board story status")
164
+ .requiredOption("--story <id>", "Story id, e.g. US-001")
165
+ .requiredOption("--status <status>", "queued|in_progress|review|done")).action((opts) => wrap(() => runStory(opts))());
166
+ mesh
167
+ .command("doctor")
168
+ .description("Warm ~/.hq/work-mesh/cache from hq-pro (directory, inbox, pair DMs). Not `hq doctor`.")
169
+ .option("--apply", "Reserved — Board PUTs from local prd are not in the CLI yet")
170
+ .option("--json", "Print machine-readable JSON")
171
+ .action((opts) => wrap(() => runDoctor(opts))());
172
+ }
173
+ //# sourceMappingURL=mesh.js.map
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Native work-mesh client for `hq mesh`.
3
+ *
4
+ * Talks to hq-pro over the shared vault fetch + Cognito session. Writes the
5
+ * machine cache apps already read. Does not spawn the pack helper and does
6
+ * not start MQTT listen.
7
+ */
8
+ export declare const STORY_STATUSES: Set<string>;
9
+ export declare const ACTIVE_STATUSES: Set<string>;
10
+ export interface MeshCompany {
11
+ companyUid: string;
12
+ companySlug: string;
13
+ }
14
+ export interface MeshThread {
15
+ threadId?: string;
16
+ projectId?: string;
17
+ companyUid?: string;
18
+ threadStatus?: string;
19
+ ownerUid?: string;
20
+ progressSummary?: string;
21
+ sourceSignalSummary?: string;
22
+ blockedReason?: string;
23
+ lastActivityAt?: string;
24
+ createdAt?: string;
25
+ }
26
+ export declare function meshJson(token: string, path: string, init?: {
27
+ method?: string;
28
+ body?: Record<string, unknown>;
29
+ query?: Record<string, string>;
30
+ }): Promise<unknown>;
31
+ export declare function resolveMeshCompany(token: string, company?: string): Promise<MeshCompany>;
32
+ export declare function requireToken(): Promise<string>;
33
+ export declare function listActiveThreads(token: string, companyUid: string, projectId?: string): Promise<MeshThread[]>;
34
+ export declare function clamp(value: string, max: number): string;
35
+ export declare function eventPayload(kind: string, opts: {
36
+ summary?: string;
37
+ reason?: string;
38
+ ask?: string[];
39
+ }, claimedBy: string): Record<string, unknown>;
40
+ export declare function ensureProjectThread(token: string, company: MeshCompany, projectId: string, opts: {
41
+ threadId?: string;
42
+ summary?: string;
43
+ }): Promise<{
44
+ threadId: string;
45
+ created: boolean;
46
+ }>;
47
+ export declare function appendThreadEvent(token: string, companyUid: string, threadId: string, eventKind: string, payload: Record<string, unknown>): Promise<{
48
+ eventId?: string;
49
+ createdAt?: string;
50
+ }>;
51
+ export declare function patchStoryStatus(token: string, companyUid: string, projectId: string, storyId: string, status: string): Promise<{
52
+ status: string;
53
+ version?: number;
54
+ projectId: string;
55
+ }>;
56
+ export declare function pairDmUidsFromInbox(inbox: unknown): string[];
57
+ export interface WarmCacheResult {
58
+ directory: boolean;
59
+ inbox: boolean;
60
+ contacts: boolean;
61
+ threads: number;
62
+ cacheRoot: string;
63
+ }
64
+ /** Warm inbox + contacts + pair threads + directory into the local cache. */
65
+ export declare function warmMeshConversationCache(token: string, principalUid: string, cacheRoot?: string): Promise<WarmCacheResult>;
66
+ export declare function entityUidFromJwt(token: string): string | undefined;
67
+ export declare function principalUidFromTopic(topic: unknown): string | undefined;
68
+ /** Extract prs_/agt_ from a realtime credentials payload. Never returns secrets. */
69
+ export declare function principalUidFromRealtimePayload(data: unknown): string | undefined;
70
+ /**
71
+ * Cache key for directory/inbox/contacts. `custom:entityUid` rides the ID
72
+ * token (access tokens only have email/sub), so prefer the cached idToken.
73
+ */
74
+ export declare function meshCachePrincipalUid(tokens: {
75
+ idToken?: string;
76
+ accessToken?: string;
77
+ } | null | undefined, fallbackToken?: string): string;
78
+ /**
79
+ * Resolve the cache principal. JWT first; if the human access/id tokens have
80
+ * no entityUid, ask `/v1/realtime/credentials` (same fallback as the pack
81
+ * doctor). Only the uid is kept — credentials are discarded.
82
+ */
83
+ export declare function resolveMeshPrincipalUid(token: string, tokens?: {
84
+ idToken?: string;
85
+ accessToken?: string;
86
+ } | null): Promise<string>;
87
+ export declare function callerLabelFromToken(token: string): string;
88
+ //# sourceMappingURL=api.d.ts.map
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Native work-mesh client for `hq mesh`.
3
+ *
4
+ * Talks to hq-pro over the shared vault fetch + Cognito session. Writes the
5
+ * machine cache apps already read. Does not spawn the pack helper and does
6
+ * not start MQTT listen.
7
+ */
8
+ import { ensureCognitoToken } from "../../utils/cognito-session.js";
9
+ import { peekIdToken } from "../../utils/id-token.js";
10
+ import { getCompanyUid, vaultApiFetch } from "../../utils/vault-api.js";
11
+ import { meshCacheRoot, removeSessionFallbackFiles, writeMeshCacheFile, } from "./cache.js";
12
+ const PRINCIPAL_UID = /^(prs|agt)_/;
13
+ export const STORY_STATUSES = new Set([
14
+ "queued",
15
+ "in_progress",
16
+ "review",
17
+ "done",
18
+ ]);
19
+ export const ACTIVE_STATUSES = new Set([
20
+ "open",
21
+ "claimed",
22
+ "in-progress",
23
+ "blocked",
24
+ "needs-human",
25
+ ]);
26
+ export async function meshJson(token, path, init = {}) {
27
+ const res = await vaultApiFetch({
28
+ token,
29
+ path,
30
+ method: init.method,
31
+ body: init.body,
32
+ query: init.query,
33
+ });
34
+ const text = await res.text();
35
+ let data = {};
36
+ if (text) {
37
+ try {
38
+ data = JSON.parse(text);
39
+ }
40
+ catch {
41
+ data = { raw: text };
42
+ }
43
+ }
44
+ if (!res.ok) {
45
+ const err = data;
46
+ throw new Error(err.error || err.message || `${res.status} ${res.statusText}`);
47
+ }
48
+ return data;
49
+ }
50
+ export async function resolveMeshCompany(token, company) {
51
+ const explicit = process.env.HQ_WORK_MESH_COMPANY_UID || process.env.HQ_COMPANY_UID;
52
+ if (explicit?.trim()) {
53
+ return { companyUid: explicit.trim(), companySlug: company || explicit.trim() };
54
+ }
55
+ if (company && /^(cmp|co)_/.test(company)) {
56
+ return { companyUid: company, companySlug: company };
57
+ }
58
+ const uid = await getCompanyUid(token, company);
59
+ return { companyUid: uid, companySlug: company || uid };
60
+ }
61
+ export async function requireToken() {
62
+ return ensureCognitoToken();
63
+ }
64
+ function asRecord(value) {
65
+ return value && typeof value === "object" && !Array.isArray(value)
66
+ ? value
67
+ : null;
68
+ }
69
+ export async function listActiveThreads(token, companyUid, projectId) {
70
+ const collected = [];
71
+ for (const status of ACTIVE_STATUSES) {
72
+ let cursor = "";
73
+ for (let page = 0; page < 10; page += 1) {
74
+ const query = {
75
+ companyUid,
76
+ status,
77
+ limit: "100",
78
+ };
79
+ if (cursor)
80
+ query.cursor = cursor;
81
+ const data = asRecord(await meshJson(token, "/v1/work-mesh/threads", { query }));
82
+ const rows = Array.isArray(data?.threads)
83
+ ? data.threads
84
+ : Array.isArray(data?.items)
85
+ ? data.items
86
+ : [];
87
+ for (const row of rows) {
88
+ const rec = asRecord(row);
89
+ if (!rec)
90
+ continue;
91
+ collected.push(rec);
92
+ }
93
+ cursor = typeof data?.nextCursor === "string" ? data.nextCursor : "";
94
+ if (!cursor)
95
+ break;
96
+ }
97
+ }
98
+ return collected
99
+ .filter((thread) => !projectId || thread.projectId === projectId)
100
+ .sort((a, b) => String(b.lastActivityAt || b.createdAt || "").localeCompare(String(a.lastActivityAt || a.createdAt || "")));
101
+ }
102
+ export function clamp(value, max) {
103
+ return value.length <= max ? value : value.slice(0, max);
104
+ }
105
+ export function eventPayload(kind, opts, claimedBy) {
106
+ if (kind === "claim") {
107
+ return {
108
+ claimedBy: clamp(claimedBy, 120),
109
+ leaseTtlIso: new Date(Date.now() + 120 * 60 * 1000).toISOString(),
110
+ note: clamp(opts.summary || "Starting HQ project work.", 280),
111
+ };
112
+ }
113
+ if (kind === "progress") {
114
+ return { summary: clamp(opts.summary || "Project work is in progress.", 280) };
115
+ }
116
+ if (kind === "blocked") {
117
+ return {
118
+ reason: clamp(opts.reason || opts.summary || "Project work is blocked.", 500),
119
+ asks: (opts.ask ?? []).filter(Boolean).slice(0, 5),
120
+ };
121
+ }
122
+ if (kind === "done") {
123
+ return { summary: clamp(opts.summary || "Project work completed.", 280) };
124
+ }
125
+ return {
126
+ text: clamp(opts.summary || "HQ project note.", 500),
127
+ noteKind: "audit",
128
+ };
129
+ }
130
+ export async function ensureProjectThread(token, company, projectId, opts) {
131
+ if (opts.threadId)
132
+ return { threadId: opts.threadId, created: false };
133
+ const existing = (await listActiveThreads(token, company.companyUid, projectId))[0];
134
+ if (existing?.threadId)
135
+ return { threadId: existing.threadId, created: false };
136
+ const created = asRecord(await meshJson(token, "/v1/work-mesh/threads", {
137
+ method: "POST",
138
+ body: {
139
+ companyUid: company.companyUid,
140
+ projectId,
141
+ sourceSignalSummary: clamp(opts.summary || `HQ project ${projectId}`, 280),
142
+ routing: {
143
+ priority: "normal",
144
+ tags: ["hq-project", `project:${projectId}`],
145
+ },
146
+ },
147
+ }));
148
+ const threadId = String(created?.threadId || "").trim();
149
+ if (!threadId)
150
+ throw new Error("Work mesh did not return a threadId");
151
+ return { threadId, created: true };
152
+ }
153
+ export async function appendThreadEvent(token, companyUid, threadId, eventKind, payload) {
154
+ const data = asRecord(await meshJson(token, `/v1/work-mesh/threads/${encodeURIComponent(threadId)}/events`, {
155
+ method: "POST",
156
+ body: { companyUid, eventKind, payload },
157
+ }));
158
+ return {
159
+ eventId: typeof data?.eventId === "string" ? data.eventId : undefined,
160
+ createdAt: typeof data?.createdAt === "string" ? data.createdAt : undefined,
161
+ };
162
+ }
163
+ export async function patchStoryStatus(token, companyUid, projectId, storyId, status) {
164
+ const view = asRecord(await meshJson(token, `/v1/work-mesh/projects/${encodeURIComponent(projectId)}/stories/${encodeURIComponent(storyId)}`, {
165
+ method: "PATCH",
166
+ body: { companyUid, status },
167
+ }));
168
+ if (view && typeof view.projectId === "string" && typeof view.companyUid === "string") {
169
+ writeMeshCacheFile(["projects", view.companyUid, `${view.projectId}.json`], view);
170
+ }
171
+ const stories = Array.isArray(view?.stories) ? view.stories : [];
172
+ const patched = stories
173
+ .map(asRecord)
174
+ .find((row) => row && row.id === storyId);
175
+ return {
176
+ projectId: String(view?.projectId || projectId),
177
+ status: typeof patched?.status === "string" ? patched.status : status,
178
+ version: typeof view?.version === "number" ? view.version : undefined,
179
+ };
180
+ }
181
+ export function pairDmUidsFromInbox(inbox) {
182
+ const rec = asRecord(inbox);
183
+ const ids = new Set();
184
+ const events = Array.isArray(rec?.events) ? rec.events : [];
185
+ for (const event of events) {
186
+ const row = asRecord(event);
187
+ const uid = String(row?.fromPersonUid || "").trim();
188
+ if (/^(prs|agt)_[A-Za-z0-9]+$/.test(uid))
189
+ ids.add(uid);
190
+ }
191
+ const pairs = Array.isArray(rec?.pairUnreads) ? rec.pairUnreads : [];
192
+ for (const pair of pairs) {
193
+ const row = asRecord(pair);
194
+ const uid = String(row?.withPersonUid || "").trim();
195
+ if (/^(prs|agt)_[A-Za-z0-9]+$/.test(uid))
196
+ ids.add(uid);
197
+ }
198
+ return [...ids].sort();
199
+ }
200
+ /** Warm inbox + contacts + pair threads + directory into the local cache. */
201
+ export async function warmMeshConversationCache(token, principalUid, cacheRoot = meshCacheRoot()) {
202
+ const result = {
203
+ directory: false,
204
+ inbox: false,
205
+ contacts: false,
206
+ threads: 0,
207
+ cacheRoot,
208
+ };
209
+ try {
210
+ const directory = await meshJson(token, "/v1/notify/channels", {
211
+ query: { cursor: "" },
212
+ });
213
+ writeMeshCacheFile(["directory", `${principalUid}.json`], directory, cacheRoot);
214
+ result.directory = true;
215
+ }
216
+ catch {
217
+ // Directory warm is best-effort; inbox is the pair-DM path.
218
+ }
219
+ const inbox = await meshJson(token, "/v1/notify/inbox", {
220
+ query: { limit: "50" },
221
+ });
222
+ writeMeshCacheFile(["inbox", `${principalUid}.json`], inbox, cacheRoot);
223
+ result.inbox = true;
224
+ try {
225
+ const contacts = await meshJson(token, "/v1/notify/contacts");
226
+ writeMeshCacheFile(["contacts", `${principalUid}.json`], contacts, cacheRoot);
227
+ result.contacts = true;
228
+ }
229
+ catch {
230
+ // Contacts roster is optional for the rail.
231
+ }
232
+ for (const uid of pairDmUidsFromInbox(inbox)) {
233
+ try {
234
+ const thread = await meshJson(token, "/v1/notify/thread", {
235
+ query: { withPersonUid: uid, limit: "50" },
236
+ });
237
+ writeMeshCacheFile(["dms", `${uid}.json`], thread, cacheRoot);
238
+ result.threads += 1;
239
+ }
240
+ catch {
241
+ // one pair must not fail the rest
242
+ }
243
+ }
244
+ if (PRINCIPAL_UID.test(principalUid)) {
245
+ removeSessionFallbackFiles(cacheRoot);
246
+ }
247
+ return result;
248
+ }
249
+ export function entityUidFromJwt(token) {
250
+ const uid = peekIdToken(token)["custom:entityUid"];
251
+ return typeof uid === "string" && PRINCIPAL_UID.test(uid) ? uid : undefined;
252
+ }
253
+ export function principalUidFromTopic(topic) {
254
+ if (typeof topic !== "string")
255
+ return undefined;
256
+ const match = /^hq\/((?:prs|agt)_[0-9A-HJKMNP-TV-Z]{26})(?:\/|$)/.exec(topic);
257
+ return match?.[1];
258
+ }
259
+ /** Extract prs_/agt_ from a realtime credentials payload. Never returns secrets. */
260
+ export function principalUidFromRealtimePayload(data) {
261
+ const rec = asRecord(data);
262
+ if (!rec)
263
+ return undefined;
264
+ const claimed = rec.principalUid;
265
+ if (typeof claimed === "string" && PRINCIPAL_UID.test(claimed))
266
+ return claimed;
267
+ const topics = asRecord(rec.topics);
268
+ return (principalUidFromTopic(topics?.dm) ||
269
+ principalUidFromTopic(rec.topic));
270
+ }
271
+ /**
272
+ * Cache key for directory/inbox/contacts. `custom:entityUid` rides the ID
273
+ * token (access tokens only have email/sub), so prefer the cached idToken.
274
+ */
275
+ export function meshCachePrincipalUid(tokens, fallbackToken) {
276
+ for (const token of [tokens?.idToken, tokens?.accessToken, fallbackToken]) {
277
+ if (!token)
278
+ continue;
279
+ const uid = entityUidFromJwt(token);
280
+ if (uid)
281
+ return uid;
282
+ }
283
+ return "session";
284
+ }
285
+ /**
286
+ * Resolve the cache principal. JWT first; if the human access/id tokens have
287
+ * no entityUid, ask `/v1/realtime/credentials` (same fallback as the pack
288
+ * doctor). Only the uid is kept — credentials are discarded.
289
+ */
290
+ export async function resolveMeshPrincipalUid(token, tokens) {
291
+ const fromJwt = meshCachePrincipalUid(tokens, token);
292
+ if (fromJwt !== "session")
293
+ return fromJwt;
294
+ try {
295
+ const data = await meshJson(token, "/v1/realtime/credentials", {
296
+ method: "POST",
297
+ body: { contractVersion: 2 },
298
+ });
299
+ const uid = principalUidFromRealtimePayload(data);
300
+ if (uid)
301
+ return uid;
302
+ }
303
+ catch {
304
+ // best-effort; session.json is worse than failing doctor
305
+ }
306
+ return "session";
307
+ }
308
+ export function callerLabelFromToken(token) {
309
+ const claims = peekIdToken(token);
310
+ const entityUid = claims["custom:entityUid"];
311
+ if (typeof entityUid === "string" && entityUid)
312
+ return entityUid;
313
+ if (typeof claims.email === "string" && claims.email)
314
+ return claims.email;
315
+ if (typeof claims.sub === "string" && claims.sub)
316
+ return claims.sub;
317
+ return "hq-cli";
318
+ }
319
+ //# sourceMappingURL=api.js.map
@@ -0,0 +1,10 @@
1
+ /**
2
+ * On-disk work-mesh cache. Same layout the desktop/web apps and the pack
3
+ * helper read: ~/.hq/work-mesh/cache/{projects,channels,directory,inbox,contacts,dms}.
4
+ */
5
+ export declare function meshCacheRoot(home?: string, env?: NodeJS.ProcessEnv): string;
6
+ export declare function isSafeCacheSegment(value: string): boolean;
7
+ export declare function writeMeshCacheFile(segments: string[], value: unknown, root?: string): string | null;
8
+ /** Drop leftover session.json once we know the real prs_/agt_ cache key. */
9
+ export declare function removeSessionFallbackFiles(root?: string): void;
10
+ //# sourceMappingURL=cache.d.ts.map
@@ -0,0 +1,42 @@
1
+ /**
2
+ * On-disk work-mesh cache. Same layout the desktop/web apps and the pack
3
+ * helper read: ~/.hq/work-mesh/cache/{projects,channels,directory,inbox,contacts,dms}.
4
+ */
5
+ import * as fs from "node:fs";
6
+ import * as os from "node:os";
7
+ import * as path from "node:path";
8
+ export function meshCacheRoot(home = os.homedir(), env = process.env) {
9
+ const override = env.HQ_WORK_MESH_CACHE?.trim();
10
+ if (override)
11
+ return override;
12
+ return path.join(home, ".hq", "work-mesh", "cache");
13
+ }
14
+ export function isSafeCacheSegment(value) {
15
+ const trimmed = value.trim();
16
+ return Boolean(trimmed &&
17
+ trimmed.length <= 128 &&
18
+ !trimmed.includes("..") &&
19
+ /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(trimmed));
20
+ }
21
+ export function writeMeshCacheFile(segments, value, root = meshCacheRoot()) {
22
+ if (segments.length === 0 || !segments.every(isSafeCacheSegment))
23
+ return null;
24
+ const dest = path.join(root, ...segments);
25
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
26
+ const tmp = `${dest}.tmp`;
27
+ fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
28
+ fs.renameSync(tmp, dest);
29
+ return dest;
30
+ }
31
+ /** Drop leftover session.json once we know the real prs_/agt_ cache key. */
32
+ export function removeSessionFallbackFiles(root = meshCacheRoot()) {
33
+ for (const dir of ["directory", "inbox", "contacts"]) {
34
+ try {
35
+ fs.unlinkSync(path.join(root, dir, "session.json"));
36
+ }
37
+ catch {
38
+ // missing is fine
39
+ }
40
+ }
41
+ }
42
+ //# sourceMappingURL=cache.js.map
package/dist/main.js CHANGED
@@ -61,10 +61,12 @@ import { registerCoreCommands } from "./commands/core.js";
61
61
  import { registerSearchCommand } from "./commands/search.js";
62
62
  import { registerIndexCommand } from "./commands/index-cmd.js";
63
63
  import { registerDoctorCommand } from "./commands/doctor.js";
64
+ import { registerMeshCommand } from "./commands/mesh.js";
64
65
  import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
65
66
  import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
66
67
  import { networkTransportErrorMessage } from "./utils/network-transport-error.js";
67
68
  import { qmdNativeBindingErrorMessage } from "./utils/qmd-native-binding-error.js";
69
+ import { qmdMissingCollectionMessage } from "./utils/qmd-collection-missing-error.js";
68
70
  import { isExpectedUserError } from "./utils/expected-cli-error.js";
69
71
  import { isEpipe } from "./utils/epipe.js";
70
72
  import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
@@ -273,6 +275,9 @@ registerIndexCommand(program);
273
275
  // check registry so later check families (vault, sync, MCP, …) plug in without
274
276
  // engine changes.
275
277
  registerDoctorCommand(program);
278
+ // Work mesh (subcommand group — `hq mesh …`). Native REST + cache. Distinct
279
+ // from `hq doctor` (hook guardrails). Does not start MQTT listen.
280
+ registerMeshCommand(program);
276
281
  program.hook("preAction", async () => {
277
282
  await emitCliSessionStarted();
278
283
  });
@@ -438,7 +443,21 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
438
443
  // Ordered FIRST so the narrow native-binding signature wins over the
439
444
  // broader environmental / transport / generic branches.
440
445
  const qmdMsg = qmdNativeBindingErrorMessage(err);
441
- const envMsg = qmdMsg ? null : environmentalFsErrorMessage(err);
446
+ // A `hq search <query> -c <collection>` against a collection that is not
447
+ // indexed on THIS machine is the user's local index state, not an hq-cli
448
+ // defect — the remedy is an explicit `hq index sync`. finishRunQmd already
449
+ // types the condition QmdCollectionMissingError, but nothing caught it, so
450
+ // it fell through to the capture below; because the synthesized message
451
+ // embeds the caller's whole argv, each distinct query minted a brand-new
452
+ // permanent issue. Print an actionable line naming the missing collection
453
+ // and skip capture (HQ-CLI-S, Sentry 7672722729). Evaluated AFTER the
454
+ // native-binding check so that narrower signature keeps priority, and
455
+ // BEFORE the environmental / transport / generic branches. Scoped to
456
+ // caller-supplied reads (search/vsearch/query/get); a collection-missing
457
+ // raised by hq's OWN reconciliation (`collection add`/`context add`) names
458
+ // a collection hq built itself, so it stays a captured internal error.
459
+ const collectionMsg = qmdMsg ? null : qmdMissingCollectionMessage(err);
460
+ const envMsg = qmdMsg || collectionMsg ? null : environmentalFsErrorMessage(err);
442
461
  // A raw network transport failure (undici's `TypeError: fetch failed`
443
462
  // with a ConnectTimeoutError / ECONNREFUSED / ENOTFOUND cause) is the
444
463
  // caller's connectivity, not an hq-cli defect. Before this branch it fell
@@ -449,10 +468,13 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
449
468
  // message that names the unreachable host, exit 1, and skip Sentry.
450
469
  // Ordered after the environmental check so a full disk keeps its exact
451
470
  // existing message.
452
- const transportMsg = qmdMsg || envMsg ? null : networkTransportErrorMessage(err);
471
+ const transportMsg = qmdMsg || collectionMsg || envMsg ? null : networkTransportErrorMessage(err);
453
472
  if (qmdMsg) {
454
473
  deps.stderr.write(`hq: ${qmdMsg}\n`);
455
474
  }
475
+ else if (collectionMsg) {
476
+ deps.stderr.write(`hq: ${collectionMsg}\n`);
477
+ }
456
478
  else if (envMsg) {
457
479
  deps.stderr.write(`hq: ${envMsg}\n`);
458
480
  }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * If `err` is a caller-driven "collection not found" from `hq search` (or its
3
+ * `get` sibling), return an actionable, query-free remedy naming the missing
4
+ * collection; otherwise return `null`. Mirrors qmdNativeBindingErrorMessage /
5
+ * environmentalFsErrorMessage / networkTransportErrorMessage so the top-level
6
+ * handler can branch on it the same way: a non-null result means
7
+ * print-and-skip-Sentry, null means "handle as usual (capture to Sentry)".
8
+ */
9
+ export declare function qmdMissingCollectionMessage(err: unknown): string | null;
10
+ //# sourceMappingURL=qmd-collection-missing-error.d.ts.map
@@ -0,0 +1,95 @@
1
+ // src/utils/qmd-collection-missing-error.ts
2
+ //
3
+ // Classify a "collection not found" qmd failure that came from a caller-supplied
4
+ // READ — `hq search`, its `--mode` siblings (vsearch/query), or `hq search get`
5
+ // — as the user's LOCAL index state, not an hq-cli code defect. The collection
6
+ // the caller named is simply not indexed on this machine; the remedy is an
7
+ // explicit `hq index sync`, so the CLI prints an actionable line and SKIPS
8
+ // Sentry capture. Sibling of qmd-native-binding-error.ts (HQ-CLI-J, unbuilt
9
+ // bindings), environmental-error.ts (HQ-CLI-2, full disk), and
10
+ // network-transport-error.ts (HQ-CLI-G, connectivity): a failure that is NOT an
11
+ // hq-cli defect is printed with an actionable message and never filed as a crash.
12
+ //
13
+ // HQ-CLI-S (Sentry 7672722729): `hq search <query> -c <collection>` against a
14
+ // collection that isn't indexed locally made qmd exit 1 with `Collection not
15
+ // found: <collection>`. finishRunQmd already classifies that wording into the
16
+ // dedicated QmdCollectionMissingError subclass, but nothing caught it, so it
17
+ // unwound to the top-level handler's final else and was captured. Worse, the
18
+ // synthesized message interpolates the caller's whole argv, so each distinct
19
+ // query minted a brand-new permanent issue. This classifier closes that boundary.
20
+ //
21
+ // The gate is deliberately narrow on TWO axes so it can neither be tripped by
22
+ // user input nor silence a real bug:
23
+ // 1. CLASS: only a QmdCollectionMissingError (the typed subclass finishRunQmd
24
+ // raises for the collection-not-found wording) qualifies — never a plain
25
+ // QmdExitError, a native-binding failure, or any other error.
26
+ // 2. INVOCATION: only qmd's caller-supplied-collection READS — `search`,
27
+ // `vsearch`, `query`, `get` — qualify. A collection-missing raised by hq's
28
+ // OWN reconciliation (`collection add`/`collection list`, `context add`)
29
+ // names a collection hq constructed itself, so a not-found there is an
30
+ // internal inconsistency worth reporting: it must keep crashing and
31
+ // capturing, so the classifier returns null for those invocations.
32
+ //
33
+ // The collection name is read STRUCTURALLY from the error's own `args` (the
34
+ // token after `-c`/`--collection`), never by parsing qmd's stderr, and the
35
+ // caller's free-text query (args[1]) is never echoed — the same unbounded-
36
+ // fingerprint discipline that motivated the fix. The name is itself a
37
+ // user-supplied `-c` value, so before it is interpolated it is passed through
38
+ // redactErrorText — the same credential-redaction + control-character-flattening
39
+ // + length-bounding chain the generic error path uses — so a newline, terminal
40
+ // escape sequence, credential-like token, or oversized value can neither forge a
41
+ // second `hq:` line nor leak; if nothing survives redaction the message falls
42
+ // back to the name-free variant.
43
+ import { redactErrorText } from "./redact-error-text.js";
44
+ /** qmd subcommands whose collection is chosen by the CALLER (a read surface). */
45
+ const CALLER_COLLECTION_READS = new Set(["search", "vsearch", "query", "get"]);
46
+ /**
47
+ * The two-pronged, factual remedy. `hq index sync` builds the collections HQ can
48
+ * derive from this local root; `hq index collections` shows what is actually
49
+ * registered (covering a name that maps to a company not present here, which
50
+ * sync will never create). Neither claims the named collection will appear.
51
+ */
52
+ function remedyMessage(collection) {
53
+ return collection
54
+ ? `No local search collection named '${collection}'. Run 'hq index sync' to build it, or 'hq index collections' to see what is registered.`
55
+ : "No local search collection was found for that query. Run 'hq index sync' to build the local index, or 'hq index collections' to see what is registered.";
56
+ }
57
+ /** The collection name the caller passed after `-c`/`--collection`, or null. */
58
+ function collectionFromArgs(args) {
59
+ for (let i = 0; i < args.length - 1; i += 1) {
60
+ if (args[i] === "-c" || args[i] === "--collection") {
61
+ const value = args[i + 1];
62
+ if (typeof value === "string" && value.length > 0)
63
+ return value;
64
+ }
65
+ }
66
+ return null;
67
+ }
68
+ /**
69
+ * If `err` is a caller-driven "collection not found" from `hq search` (or its
70
+ * `get` sibling), return an actionable, query-free remedy naming the missing
71
+ * collection; otherwise return `null`. Mirrors qmdNativeBindingErrorMessage /
72
+ * environmentalFsErrorMessage / networkTransportErrorMessage so the top-level
73
+ * handler can branch on it the same way: a non-null result means
74
+ * print-and-skip-Sentry, null means "handle as usual (capture to Sentry)".
75
+ */
76
+ export function qmdMissingCollectionMessage(err) {
77
+ if (err === null || typeof err !== "object")
78
+ return null;
79
+ const record = err;
80
+ if (record.name !== "QmdCollectionMissingError")
81
+ return null;
82
+ const args = Array.isArray(record.args) ? record.args : [];
83
+ const subcommand = args[0];
84
+ if (typeof subcommand !== "string" || !CALLER_COLLECTION_READS.has(subcommand)) {
85
+ return null;
86
+ }
87
+ const rawCollection = collectionFromArgs(args);
88
+ // The name is a user-supplied `-c` value: redact + flatten + bound it before
89
+ // printing so it cannot forge a second `hq:` line, emit terminal escapes, or
90
+ // leak a credential-shaped token. redactErrorText returns "" when nothing
91
+ // survives, in which case we fall back to the name-free variant.
92
+ const collection = rawCollection ? redactErrorText(rawCollection) || null : null;
93
+ return remedyMessage(collection);
94
+ }
95
+ //# sourceMappingURL=qmd-collection-missing-error.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.101.4",
3
+ "version": "5.101.6",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -24,7 +24,8 @@
24
24
  "test:e2e": "vitest run --config vitest.e2e.config.ts",
25
25
  "coverage": "vitest run --coverage --coverage.reporter=text-summary --coverage.reporter=json-summary",
26
26
  "vitest": "vitest",
27
- "clean": "rm -rf dist"
27
+ "clean": "rm -rf dist",
28
+ "prepare": "husky || true"
28
29
  },
29
30
  "dependencies": {
30
31
  "@aws-sdk/client-iot-data-plane": "^3.1096.0",
@@ -52,6 +53,8 @@
52
53
  "@vitest/coverage-v8": "4.1.6",
53
54
  "aws-sdk-client-mock": "^4.1.0",
54
55
  "eslint": "^10.5.0",
56
+ "husky": "^9.1.7",
57
+ "lint-staged": "^15.5.2",
55
58
  "typescript": "^5.7.0",
56
59
  "typescript-eslint": "^8.61.1",
57
60
  "vitest": "^4.1.2"
@@ -77,5 +80,8 @@
77
80
  "onlyBuiltDependencies": [
78
81
  "better-sqlite3"
79
82
  ]
83
+ },
84
+ "lint-staged": {
85
+ "*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}": "eslint --cache --no-error-on-unmatched-pattern"
80
86
  }
81
87
  }