@indigoai-us/hq-cli 5.142.0 → 5.143.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.143.0] — 2026-09-23
6
+
7
+ ### Fixed
8
+
9
+ - `hq dm` and report commands now reject non-recipient and non-company channel
10
+ requests locally, and report updates retry one fresh revision after a
11
+ conflict.
12
+ - `hq mesh project register` now verifies the company's Board view before
13
+ registering and reports an actionable local error when the Board is missing.
14
+
5
15
  ## [5.142.0] — 2026-09-23
6
16
 
7
17
  ### Added
@@ -5740,7 +5740,7 @@ export declare const COMMAND_CATALOG: readonly [{
5740
5740
  readonly subcommands: readonly [];
5741
5741
  }, {
5742
5742
  readonly name: "register";
5743
- readonly description: "POST project registration to create or bind its project channel and Board";
5743
+ readonly description: "Register an existing Board and bind its project channel; use project ensure to create or repair a Board";
5744
5744
  readonly aliases: readonly [];
5745
5745
  readonly hidden: false;
5746
5746
  readonly usage: "[options] <projectId>";
@@ -7409,7 +7409,7 @@ export const COMMAND_CATALOG = [
7409
7409
  },
7410
7410
  {
7411
7411
  "name": "register",
7412
- "description": "POST project registration to create or bind its project channel and Board",
7412
+ "description": "Register an existing Board and bind its project channel; use project ensure to create or repair a Board",
7413
7413
  "aliases": [],
7414
7414
  "hidden": false,
7415
7415
  "usage": "[options] <projectId>",
@@ -336,6 +336,7 @@ export declare function formatJobsTable(jobs: AgentJobView[]): string;
336
336
  export declare function formatPauseResult(result: PauseJobResult): string;
337
337
  /** Human-readable "hot-applied to the running box" vs "saved for next launch". */
338
338
  export declare function appliedHint(applied: boolean): string;
339
+ /** Agent uid shape (`agt_…`). */
339
340
  /** One message in a DM thread, as returned by `GET /v1/notify/thread`. */
340
341
  export interface DmThreadMessage {
341
342
  eventId: string;
@@ -36,6 +36,7 @@ import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
36
36
  import { launchSessionManagerPlugin, SessionManagerPluginLaunchError, TerminalSessionTimeoutError, waitForTerminalSession, } from "@indigoai-us/hq-cloud/outposts/node";
37
37
  import { peekIdToken } from "../utils/id-token.js";
38
38
  import { isPlanGateError } from "../utils/plan-gate-error.js";
39
+ import { AGENT_UID_PATTERN, isAgentUid } from "../lib/notify-recipient.js";
39
40
  import { confirmChargeOrExit, formatUsd, parseBillingPayload, surfaceBillingBlocked, } from "../utils/billing-gate.js";
40
41
  /** Reasoning-effort values hq-pro accepts on `runtime-config`. */
41
42
  export const VALID_EFFORTS = new Set([
@@ -807,14 +808,6 @@ export function appliedHint(applied) {
807
808
  ? "applied to the running box now"
808
809
  : "saved — takes effect on the agent's next launch";
809
810
  }
810
- // ---------------------------------------------------------------------------
811
- // Talking to agents (DM). Agents are members addressable over HQ's DM system:
812
- // a message rides `POST /v1/notify/dm` (the wire contract accepts `agt_*`), the
813
- // agent's box picks it up and replies over DM (a first-class agent reply
814
- // channel), and the two-way conversation is read back via `GET /v1/notify/thread`.
815
- // ---------------------------------------------------------------------------
816
- /** Agent uid shape (`agt_…`). */
817
- const AGENT_UID_PATTERN = /^agt_[A-Za-z0-9_-]+$/;
818
811
  /**
819
812
  * Resolve an agent reference to its `agt_` uid. An `agt_…` value is used
820
813
  * directly; anything else is treated as a slug or display name and resolved
@@ -909,6 +902,9 @@ async function resolveJobsTargetOrExit(ref, company) {
909
902
  }
910
903
  /** Send a DM to an agent. Returns nothing meaningful beyond success. */
911
904
  export async function sendAgentDm(token, agentUid, message) {
905
+ if (!isAgentUid(agentUid)) {
906
+ throw new Error(`Invalid agent recipient '${agentUid}': expected an agt_ uid.`);
907
+ }
912
908
  await agentsRequest({
913
909
  token,
914
910
  path: "/v1/notify/dm",
@@ -4,11 +4,7 @@ import { ensureCognitoToken } from "../utils/cognito-session.js";
4
4
  import { vaultApiFetch } from "../utils/vault-api.js";
5
5
  import { isMachineIdentity } from "@indigoai-us/hq-cloud";
6
6
  import { peekIdToken } from "../utils/id-token.js";
7
- const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
8
- // People (prs_) and agents (agt_) are both first-class DM participants; the
9
- // server applies the same membership-overlap gate to either and delivers
10
- // agt_* recipients into the agent's durable box inbox.
11
- const RECIPIENT_UID_PATTERN = /^(prs|agt)_[A-Za-z0-9_-]+$/;
7
+ import { EMAIL_PATTERN, RECIPIENT_UID_PATTERN } from "../lib/notify-recipient.js";
12
8
  /**
13
9
  * Classify a recipient arg as an email or a person/agent uid. Mirrors the
14
10
  * email/uid heuristic used by `hq members`. Returns null for neither.
@@ -400,7 +400,30 @@ async function runProjectRegister(projectId, opts) {
400
400
  }
401
401
  const { token, company } = await withCompany(opts);
402
402
  const principalUid = await resolveMeshPrincipalUid(token, loadCachedTokens());
403
- const registration = await registerMeshProject(token, company.companyUid, projectId);
403
+ const companyRef = company.companySlug || company.companyUid;
404
+ try {
405
+ const board = await getProjectView(token, company.companyUid, projectId);
406
+ if (board.companyUid !== company.companyUid || board.projectId !== projectId) {
407
+ fail(`Work mesh project ${projectId} has no Board view in company ${companyRef}. Create the Board before registering it.`);
408
+ }
409
+ }
410
+ catch (err) {
411
+ if (httpStatus(err) === 404) {
412
+ fail(`Work mesh project ${projectId} has no Board view in company ${companyRef}. Create the Board before registering it.`);
413
+ }
414
+ throw err;
415
+ }
416
+ let registration;
417
+ try {
418
+ registration = await registerMeshProject(token, company.companyUid, projectId);
419
+ }
420
+ catch (err) {
421
+ const failure = err;
422
+ if (failure.status === 403 && failure.code === "PROJECT_NOT_IN_COMPANY") {
423
+ fail(`Work mesh project ${projectId} could not be registered in company ${companyRef}: the Board or its genesis thread is missing. Run "hq mesh project ensure ${projectId} --company ${companyRef} --stories-file <prd.json>" to create or restore them.`);
424
+ }
425
+ throw err;
426
+ }
404
427
  const boardState = "registered";
405
428
  if (opts.json) {
406
429
  console.log(JSON.stringify({ ok: true, action: "project.register", principalUid, boardState, ...registration }, null, 2));
@@ -1601,7 +1624,7 @@ export function registerMeshCommand(program) {
1601
1624
  .action((projectId, opts) => wrap(() => runProjectEnsure(projectId, opts))());
1602
1625
  project
1603
1626
  .command("register <projectId>")
1604
- .description("POST project registration to create or bind its project channel and Board")
1627
+ .description("Register an existing Board and bind its project channel; use project ensure to create or repair a Board")
1605
1628
  .requiredOption("--company <slug|uid>", "Company slug or cloud uid")
1606
1629
  .option("--channel <channelId>", "Reserved: the current server chooses the project channel")
1607
1630
  .option("--json", "Print machine-readable JSON")
@@ -1,8 +1,9 @@
1
1
  import { Command } from "commander";
2
2
  import { buildReportTarball, deployOrgContext, type ChannelReport, type DeployAccessPolicy, type ReportDeployment, type ReportPatch } from "../lib/report-publish.js";
3
+ type ReportPatchInput = ReportPatch | ((current: ChannelReport) => ReportPatch);
3
4
  /** Explicit context has priority; discovery is the authenticated fallback for normal CLI use. */
4
5
  export declare function resolveReport(token: string, reportId: string, requestedChannelId?: string): Promise<ChannelReport>;
5
- export declare function patchReport(token: string, report: ChannelReport, patch: ReportPatch): Promise<ChannelReport>;
6
+ export declare function patchReport(token: string, report: ChannelReport, patch: ReportPatchInput): Promise<ChannelReport>;
6
7
  export declare function resolveCompanySlug(token: string, companyUid: string): Promise<string>;
7
8
  export declare function deployReportStatic(token: string, input: {
8
9
  slug: string;
@@ -12,4 +13,5 @@ export declare function deployReportStatic(token: string, input: {
12
13
  }): Promise<ReportDeployment>;
13
14
  export declare function registerReportCommand(program: Command): void;
14
15
  export declare function runReportCommand(action: () => Promise<void>): Promise<void>;
16
+ export {};
15
17
  //# sourceMappingURL=report.d.ts.map
@@ -86,10 +86,39 @@ function print(value, json) {
86
86
  else
87
87
  console.log(typeof value === "string" ? value : JSON.stringify(value, null, 2));
88
88
  }
89
+ async function listReportChannels(token) {
90
+ const channels = await vaultApiFetch({ token, path: "/v1/notify/channels" });
91
+ if (!channels.ok)
92
+ throw new ReportPublishError("Could not resolve the report channel.");
93
+ const listed = await channels.json();
94
+ return Array.isArray(listed.channels) ? listed.channels : [];
95
+ }
96
+ function isCompanyReportChannel(channel) {
97
+ return channel.scope === "company"
98
+ || (typeof channel.scope !== "string" && Boolean(channel.companyUid));
99
+ }
100
+ function requireCompanyReportChannel(channels, channelId) {
101
+ const channel = channels.find((row) => row.channelId === channelId);
102
+ if (!channel)
103
+ throw new ReportPublishError("Report channel was not found in your channels.");
104
+ if (!isCompanyReportChannel(channel))
105
+ throw new ReportPublishError("Channel reports require a company channel.");
106
+ }
107
+ async function readReportInChannel(token, channelId, reportId) {
108
+ const res = await vaultApiFetch({ token, path: reportPath(channelId, reportId) });
109
+ if (!res.ok)
110
+ return undefined;
111
+ const body = await res.json();
112
+ if (!body.report)
113
+ throw new ReportPublishError("Report response was incomplete.");
114
+ return body.report;
115
+ }
89
116
  /** Explicit context has priority; discovery is the authenticated fallback for normal CLI use. */
90
117
  export async function resolveReport(token, reportId, requestedChannelId) {
91
118
  const explicit = requestedChannelId ?? process.env.HQ_REPORT_CHANNEL_ID;
119
+ const channels = await listReportChannels(token);
92
120
  if (explicit) {
121
+ requireCompanyReportChannel(channels, explicit);
93
122
  const res = await vaultApiFetch({ token, path: reportPath(explicit, reportId) });
94
123
  if (!res.ok)
95
124
  throw new ReportPublishError("Report was not found in the supplied channel context.");
@@ -100,24 +129,20 @@ export async function resolveReport(token, reportId, requestedChannelId) {
100
129
  }
101
130
  const recordedChannel = locallyRecordedChannel(reportId);
102
131
  if (recordedChannel) {
103
- const res = await vaultApiFetch({ token, path: reportPath(recordedChannel, reportId) });
104
- if (res.ok) {
105
- const body = await res.json();
106
- if (body.report)
107
- return body.report;
108
- }
132
+ requireCompanyReportChannel(channels, recordedChannel);
133
+ const recorded = await readReportInChannel(token, recordedChannel, reportId);
134
+ if (recorded)
135
+ return recorded;
109
136
  }
110
137
  // The report API is intentionally channel-scoped. Discover only among the
111
138
  // caller's existing channels, using list endpoints so missing reports never
112
139
  // become one server-side 404 per channel.
113
- const channels = await vaultApiFetch({ token, path: "/v1/notify/channels" });
114
- if (!channels.ok)
115
- throw new ReportPublishError("Could not resolve the report channel.");
116
- const listed = await channels.json();
117
140
  let scannedChannels = 0;
118
- for (const row of listed.channels ?? []) {
141
+ for (const row of channels) {
119
142
  if (!row.channelId)
120
143
  continue;
144
+ if (!isCompanyReportChannel(row))
145
+ continue;
121
146
  if (scannedChannels >= MAX_REPORT_DISCOVERY_CHANNELS)
122
147
  break;
123
148
  scannedChannels += 1;
@@ -125,19 +150,45 @@ export async function resolveReport(token, reportId, requestedChannelId) {
125
150
  if (!res.ok)
126
151
  continue;
127
152
  const body = await res.json();
128
- const match = body.reports?.find((report) => report.reportId === reportId);
153
+ const reports = Array.isArray(body.reports) ? body.reports : [];
154
+ const match = reports.find((report) => report.reportId === reportId);
129
155
  if (match)
130
156
  return match;
131
157
  }
132
158
  throw new ReportPublishError(notFoundMessage(scannedChannels));
133
159
  }
134
160
  export async function patchReport(token, report, patch) {
135
- const res = await vaultApiFetch({ token, path: reportPath(report.channelId, report.reportId), method: "PATCH", body: patch });
161
+ if (!report.companyUid)
162
+ throw new ReportPublishError("Channel reports require a company channel.");
163
+ const patchOnce = (current) => vaultApiFetch({
164
+ token,
165
+ path: reportPath(current.channelId, current.reportId),
166
+ method: "PATCH",
167
+ body: {
168
+ ...(typeof patch === "function" ? patch(current) : patch),
169
+ revision: current.revision,
170
+ },
171
+ });
172
+ let res = await patchOnce(report);
136
173
  if (res.status === 409) {
137
174
  const body = await res.json().catch(() => ({}));
138
175
  if (body.error === "already-running")
139
176
  throw new ReportPublishError("already running", "already-running");
140
- throw new ReportPublishError("Report changed while it was being updated.");
177
+ if (body.error !== "revision-mismatch")
178
+ throw new ReportPublishError("Report changed while it was being updated.", "revision-conflict");
179
+ const latest = await readReportInChannel(token, report.channelId, report.reportId);
180
+ if (!latest)
181
+ throw new ReportPublishError("Could not reread the report after it changed.", "revision-conflict");
182
+ if (typeof patch !== "function") {
183
+ throw new ReportPublishError("Report changed while it was being updated; refresh and retry.", "revision-conflict");
184
+ }
185
+ res = await patchOnce(latest);
186
+ if (res.status === 409) {
187
+ const retryBody = await res.json().catch(() => ({}));
188
+ if (retryBody.error === "already-running")
189
+ throw new ReportPublishError("already running", "already-running");
190
+ throw new ReportPublishError("Report changed while it was being updated; refresh and retry.", "revision-conflict");
191
+ }
141
192
  }
142
193
  if (!res.ok)
143
194
  throw new ReportPublishError("Could not update the report record.");
@@ -267,7 +318,10 @@ async function discardDraftAction(reportId, options) {
267
318
  if (!res.ok && res.status !== 404)
268
319
  throw new ReportPublishError("Could not remove the draft deployment.");
269
320
  }
270
- const updated = await patchReport(token, report, { draft: null, state: report.url ? "live" : "draft-pending" });
321
+ const updated = await patchReport(token, report, (current) => ({
322
+ draft: null,
323
+ state: current.url ? "live" : "draft-pending",
324
+ }));
271
325
  print(updated, options.json === true);
272
326
  }
273
327
  async function accessAction(reportId, options) {
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { CLI_VERSION } from "../../../cli-version.js";
19
19
  import { peekIdToken } from "../../../utils/id-token.js";
20
+ import { isRecipientUid } from "../../../lib/notify-recipient.js";
20
21
  import { markInboxDone, mergeAgentInbox, readDoneIds, summarizeInboxEntry } from "../inbox-state.js";
21
22
  import { errorResult, McpToolInputError, textResult } from "./jsonrpc.js";
22
23
  export const MAX_TOOL_OUTPUT_CHARS = 40_000;
@@ -222,7 +223,7 @@ export function buildAgentMcpTools(clients) {
222
223
  const message = str(args, "message", { required: true, max: 20_000 }).trim();
223
224
  const body = /^[^\s]+@[^\s]+$/.test(to)
224
225
  ? { toEmail: to.toLowerCase(), body: message }
225
- : /^(prs|agt)_[A-Za-z0-9_-]+$/.test(to)
226
+ : isRecipientUid(to)
226
227
  ? { toPersonUid: to, body: message }
227
228
  : (() => {
228
229
  throw new McpToolInputError('"to" must be an email, prs_… or agt_… uid');
@@ -143,6 +143,10 @@ export declare class BotApiError extends Error {
143
143
  readonly tokenAgentUid?: string | undefined;
144
144
  constructor(status: number, path: string, code?: string | undefined, detail?: string | undefined, retryAfterMs?: number | undefined, tokenAgentUid?: string | undefined);
145
145
  }
146
+ export declare class InvalidDmRecipientError extends Error {
147
+ readonly recipient: string;
148
+ constructor(recipient: string);
149
+ }
146
150
  /** Parse a Retry-After header (delta-seconds or HTTP-date) to milliseconds. */
147
151
  export declare function retryAfterHeaderMs(value: string | null | undefined, nowMs?: number): number | undefined;
148
152
  export interface BotApiOptions {
@@ -8,6 +8,7 @@
8
8
  import { vaultApiFetch } from "../../utils/vault-api.js";
9
9
  import { tokenAgentUid } from "../heartbeat-identity.js";
10
10
  import { assertNoSecretKeys } from "./local-config.js";
11
+ import { isRecipientUid } from "../notify-recipient.js";
11
12
  export class BotApiError extends Error {
12
13
  status;
13
14
  path;
@@ -26,6 +27,14 @@ export class BotApiError extends Error {
26
27
  this.name = "BotApiError";
27
28
  }
28
29
  }
30
+ export class InvalidDmRecipientError extends Error {
31
+ recipient;
32
+ constructor(recipient) {
33
+ super(`Invalid DM recipient '${recipient}': expected a prs_ or agt_ uid.`);
34
+ this.recipient = recipient;
35
+ this.name = "InvalidDmRecipientError";
36
+ }
37
+ }
29
38
  /** Parse a Retry-After header (delta-seconds or HTTP-date) to milliseconds. */
30
39
  export function retryAfterHeaderMs(value, nowMs = Date.now()) {
31
40
  if (!value)
@@ -141,7 +150,10 @@ export class BotApi {
141
150
  }
142
151
  /** POST /v1/notify/dm — send as the bot (agent identity). */
143
152
  async sendDm(input) {
144
- const payload = { toPersonUid: input.toPersonUid, body: input.body };
153
+ const recipient = input.toPersonUid.trim();
154
+ if (!isRecipientUid(recipient))
155
+ throw new InvalidDmRecipientError(input.toPersonUid);
156
+ const payload = { toPersonUid: recipient, body: input.body };
145
157
  if (input.rootEventId)
146
158
  payload.rootEventId = input.rootEventId;
147
159
  const { body } = await this.call("/v1/notify/dm", { method: "POST", body: payload });
@@ -0,0 +1,7 @@
1
+ /** Canonical recipients accepted by the notify DM wire contract. */
2
+ export declare const RECIPIENT_UID_PATTERN: RegExp;
3
+ export declare const AGENT_UID_PATTERN: RegExp;
4
+ export declare const EMAIL_PATTERN: RegExp;
5
+ export declare function isRecipientUid(value: string): boolean;
6
+ export declare function isAgentUid(value: string): boolean;
7
+ //# sourceMappingURL=notify-recipient.d.ts.map
@@ -0,0 +1,11 @@
1
+ /** Canonical recipients accepted by the notify DM wire contract. */
2
+ export const RECIPIENT_UID_PATTERN = /^(prs|agt)_[A-Za-z0-9_-]+$/;
3
+ export const AGENT_UID_PATTERN = /^agt_[A-Za-z0-9_-]+$/;
4
+ export const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
5
+ export function isRecipientUid(value) {
6
+ return RECIPIENT_UID_PATTERN.test(value.trim());
7
+ }
8
+ export function isAgentUid(value) {
9
+ return AGENT_UID_PATTERN.test(value.trim());
10
+ }
11
+ //# sourceMappingURL=notify-recipient.js.map
@@ -124,8 +124,8 @@ export interface ReportAdoptDependencies {
124
124
  patch(report: ChannelReport, patch: ReportPatch): Promise<ChannelReport>;
125
125
  }
126
126
  export declare class ReportPublishError extends Error {
127
- readonly code: "already-running" | "source-invalid" | "deploy-failed" | "job-failed";
128
- constructor(message: string, code?: "already-running" | "source-invalid" | "deploy-failed" | "job-failed");
127
+ readonly code: "already-running" | "source-invalid" | "deploy-failed" | "job-failed" | "revision-conflict";
128
+ constructor(message: string, code?: "already-running" | "source-invalid" | "deploy-failed" | "job-failed" | "revision-conflict");
129
129
  }
130
130
  /** A deliberately boring, token-safe failure reason stored on the report. */
131
131
  export declare function plainFailureReason(error: unknown): string;
@@ -41,7 +41,7 @@ export class ReportPublishError extends Error {
41
41
  /** A deliberately boring, token-safe failure reason stored on the report. */
42
42
  export function plainFailureReason(error) {
43
43
  if (error instanceof ReportPublishError) {
44
- if (error.code === "source-invalid" || error.code === "job-failed")
44
+ if (error.code === "source-invalid" || error.code === "job-failed" || error.code === "revision-conflict")
45
45
  return error.message.slice(0, 240);
46
46
  if (error.code === "already-running")
47
47
  return "already running";
@@ -264,9 +264,10 @@ export async function adoptReport(report, deps) {
264
264
  export async function publishReport(report, directory, draft, deps) {
265
265
  const startedAt = deps.now();
266
266
  let locked = false;
267
+ let current = report;
267
268
  try {
268
269
  try {
269
- await deps.patch(report, { lastRun: { at: startedAt, status: "running" } });
270
+ current = await deps.patch(current, { lastRun: { at: startedAt, status: "running" } });
270
271
  locked = true;
271
272
  }
272
273
  catch (error) {
@@ -276,9 +277,9 @@ export async function publishReport(report, directory, draft, deps) {
276
277
  }
277
278
  const source = loadReportSource(directory);
278
279
  const deployment = await deps.deploy({
279
- slug: reportDeploySlug(report, draft),
280
+ slug: reportDeploySlug(current, draft),
280
281
  files: source.files,
281
- access: defaultAccessPolicy(report),
282
+ access: defaultAccessPolicy(current),
282
283
  });
283
284
  const completedAt = deps.now();
284
285
  const patch = {
@@ -295,13 +296,13 @@ export async function publishReport(report, directory, draft, deps) {
295
296
  patch.url = deployment.url;
296
297
  patch.deployAppId = deployment.appId;
297
298
  }
298
- return await deps.patch(report, patch);
299
+ return await deps.patch(current, patch);
299
300
  }
300
301
  catch (error) {
301
302
  if (locked) {
302
303
  const reason = plainFailureReason(error);
303
304
  try {
304
- await deps.patch(report, {
305
+ await deps.patch(current, {
305
306
  lastRun: { at: deps.now(), status: "failed", reason },
306
307
  });
307
308
  }
@@ -312,8 +313,8 @@ export async function publishReport(report, directory, draft, deps) {
312
313
  const notice = ownerFailureNotice({
313
314
  code: error instanceof ReportPublishError ? error.code : undefined,
314
315
  reason,
315
- reportUrl: report.url,
316
- ownerPersonUid: report.createdBy,
316
+ reportUrl: current.url,
317
+ ownerPersonUid: current.createdBy,
317
318
  });
318
319
  if (notice && deps.notifyOwner) {
319
320
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.142.0",
3
+ "version": "5.143.0",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {