@indigoai-us/hq-cli 5.345.169 → 5.345.170

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/command-catalog.generated.d.ts +49 -4
  3. package/dist/command-catalog.generated.js +60 -4
  4. package/dist/commands/access.js +7 -2
  5. package/dist/commands/bot.js +4 -0
  6. package/dist/commands/cloud.d.ts +20 -0
  7. package/dist/commands/cloud.js +55 -4
  8. package/dist/commands/feedback-triage.d.ts +43 -1
  9. package/dist/commands/feedback-triage.js +123 -23
  10. package/dist/commands/feedback.d.ts +40 -1
  11. package/dist/commands/feedback.js +184 -4
  12. package/dist/commands/files.d.ts +42 -0
  13. package/dist/commands/files.js +223 -30
  14. package/dist/commands/mesh.js +6 -0
  15. package/dist/commands/sync-manifest.d.ts +16 -0
  16. package/dist/commands/sync-manifest.js +17 -1
  17. package/dist/lib/bot/decisions.d.ts +62 -0
  18. package/dist/lib/bot/decisions.js +228 -0
  19. package/dist/lib/bot/index.d.ts +1 -0
  20. package/dist/lib/bot/index.js +1 -0
  21. package/dist/lib/bot/run.d.ts +7 -0
  22. package/dist/lib/bot/run.js +104 -1
  23. package/dist/lib/daemon/catalog.d.ts +4 -0
  24. package/dist/lib/daemon/catalog.js +60 -29
  25. package/dist/lib/daemon/config.d.ts +7 -0
  26. package/dist/lib/daemon/config.js +32 -0
  27. package/dist/lib/daemon/run.d.ts +3 -2
  28. package/dist/lib/daemon/run.js +23 -3
  29. package/dist/lib/daemon/supervisor.d.ts +1 -0
  30. package/dist/lib/daemon/supervisor.js +4 -0
  31. package/dist/lib/daemon/sync-core-canary.d.ts +9 -0
  32. package/dist/lib/daemon/sync-core-canary.js +56 -0
  33. package/dist/lib/doctor/checks/sync-health.d.ts +32 -0
  34. package/dist/lib/doctor/checks/sync-health.js +54 -1
  35. package/dist/lib/doctor/fix/apply.d.ts +26 -4
  36. package/dist/lib/doctor/fix/apply.js +95 -11
  37. package/dist/lib/doctor/types.d.ts +1 -1
  38. package/dist/lib/doctor/types.js +1 -0
  39. package/dist/lib/mesh/live/daemon/work-subscribe.js +31 -0
  40. package/dist/lib/work-context/organize.d.ts +7 -0
  41. package/dist/lib/work-context/organize.js +21 -1
  42. package/dist/sentry-before-send.js +1 -0
  43. package/package.json +7 -7
@@ -11,6 +11,29 @@ import { assertFeedbackId, normalizeFeedbackAssignee, readFeedbackError, resolve
11
11
  * send them.
12
12
  */
13
13
  export const TRIAGE_KINDS = ["bug", "feature", "question"];
14
+ /**
15
+ * Internal feedback row statuses `hq feedback list --status` accepts.
16
+ * `stalled` is open work that started and then stopped: a row is in_progress
17
+ * only while it has a linked open PR or a live lane, and moves to stalled after
18
+ * 48h with no activity. It is listed apart from `new` (not started).
19
+ */
20
+ /**
21
+ * Statuses shown in `list --status` help. Help text only: the CLI does not
22
+ * validate against this list, because hq-pro owns the set and returns a 400
23
+ * for an unknown value.
24
+ */
25
+ export const FEEDBACK_LIST_STATUSES = [
26
+ "new",
27
+ "triaging",
28
+ "accepted",
29
+ "wont_fix",
30
+ "in_progress",
31
+ "resolved",
32
+ "waiting_on_customer",
33
+ "reopened",
34
+ "stalled",
35
+ "needs_info",
36
+ ];
14
37
  export const MAX_TRIAGE_SUMMARY = 2000;
15
38
  export const DEFAULT_LIST_LIMIT = 50;
16
39
  /** Safety stop for --all so a server that never ends its cursor can't loop forever. */
@@ -64,49 +87,114 @@ export function cursorFrom(data) {
64
87
  }
65
88
  return null;
66
89
  }
90
+ function isTruncated(data) {
91
+ return (!!data &&
92
+ typeof data === "object" &&
93
+ !Array.isArray(data) &&
94
+ data.truncated === true);
95
+ }
67
96
  /** Server page cap (FEEDBACK_LIST_PAGE_MAX). The plain list path rejects a cursor without a limit. */
68
97
  const LIST_PAGE_SIZE = 200;
98
+ /**
99
+ * Lifecycle states hq-pro treats as closed (`TERMINAL_STATUSES` in
100
+ * feedback-status.ts). `--open` lists every row outside this set; a row with
101
+ * no status counts as open.
102
+ */
103
+ export const TERMINAL_FEEDBACK_STATUSES = ["resolved", "wont_fix", "accepted"];
104
+ function isOpenRow(row) {
105
+ const status = row.status;
106
+ return typeof status !== "string" || !TERMINAL_FEEDBACK_STATUSES.includes(status);
107
+ }
69
108
  /** Page through `GET /v1/feedback` until `limit` rows (or every row with `limit: null`). */
70
109
  export async function listFeedback(opts) {
71
110
  const base = opts.selector.kind === "untriaged"
72
111
  ? { triage: "untriaged" }
73
- : { assignee: opts.selector.assignee };
112
+ : opts.selector.kind === "assignee"
113
+ ? { assignee: opts.selector.assignee }
114
+ : {};
74
115
  if (opts.status)
75
116
  base.status = opts.status;
117
+ const keep = opts.selector.kind === "open" ? isOpenRow : () => true;
76
118
  const rows = [];
77
119
  let cursor = null;
120
+ let serverTruncated = false;
78
121
  for (let page = 0; page < MAX_LIST_PAGES; page++) {
79
- const pageSize = opts.limit === null ? LIST_PAGE_SIZE : Math.min(LIST_PAGE_SIZE, opts.limit - rows.length);
122
+ // The untriaged queue applies `limit` after filtering, so ask for exactly
123
+ // what is still needed. The plain list path applies `limit` to the
124
+ // DynamoDB read BEFORE its filters, so a small page returns few or no
125
+ // matches per request and burns the page budget: always ask for a full
126
+ // page there and trim to the limit here.
127
+ const pageSize = opts.limit === null || opts.selector.kind !== "untriaged"
128
+ ? LIST_PAGE_SIZE
129
+ : Math.min(LIST_PAGE_SIZE, opts.limit - rows.length);
80
130
  const query = { ...base, limit: String(pageSize) };
81
131
  if (cursor)
82
132
  query.cursor = cursor;
83
133
  const res = await vaultApiFetch({ token: opts.token, path: "/v1/feedback", query });
84
134
  if (!res.ok) {
85
- throw new Error(`Failed to list feedback: ${await readFeedbackError(res)}`);
135
+ throw new Error("Failed to list feedback: " + (await readFeedbackError(res)));
86
136
  }
87
137
  const data = (await res.json());
88
- rows.push(...rowsFrom(data));
89
- if (opts.limit !== null && rows.length >= opts.limit)
90
- return rows.slice(0, opts.limit);
138
+ rows.push(...rowsFrom(data).filter(keep));
139
+ if (isTruncated(data))
140
+ serverTruncated = true;
91
141
  cursor = cursorFrom(data);
92
- if (!cursor)
93
- return rows;
142
+ if (opts.limit !== null && rows.length >= opts.limit) {
143
+ // More rows exist when this page overflowed the limit or the server
144
+ // has another page.
145
+ const more = rows.length > opts.limit || cursor !== null || serverTruncated;
146
+ return {
147
+ items: rows.slice(0, opts.limit),
148
+ ...(more ? { truncated: serverTruncated ? "server" : "limit" } : {}),
149
+ };
150
+ }
151
+ if (!cursor) {
152
+ return { items: rows, ...(serverTruncated ? { truncated: "server" } : {}) };
153
+ }
94
154
  }
95
- return rows;
155
+ return { items: rows, truncated: "page-cap" };
156
+ }
157
+ /** One-line stderr notice for a list that stopped before the end. */
158
+ export function feedbackTruncationNotice(reason, shown) {
159
+ if (reason === "limit") {
160
+ return "Showing the first " + String(shown) + " items; more may match. Use --all or a larger --limit.";
161
+ }
162
+ if (reason === "page-cap") {
163
+ return ("Stopped after " + String(MAX_LIST_PAGES) + " pages (" + String(shown) +
164
+ " items); more may match. Narrow the query, for example with --status or --assignee instead of --open.");
165
+ }
166
+ return "The server cut this read short; some matching items may be missing.";
96
167
  }
97
168
  export function formatFeedbackTable(rows, now = Date.now()) {
98
- const header = ["ID", "AGE", "PLAN", "TITLE"];
169
+ const header = ["ID", "AGE", "STATUS", "PLAN", "TITLE"];
99
170
  const body = rows.map((row) => [
100
171
  row.id,
101
172
  formatFeedbackAge(row.submittedAt ?? row.createdAt, now),
173
+ formatFeedbackStatus(row.status),
102
174
  typeof row.plan === "string" && row.plan.length > 0 ? row.plan : "-",
103
175
  typeof row.title === "string" ? row.title : "",
104
176
  ]);
105
- const widths = [0, 1, 2].map((col) => Math.max(header[col].length, ...body.map((cells) => cells[col].length)));
177
+ const widths = [0, 1, 2, 3].map((col) => Math.max(header[col].length, ...body.map((cells) => cells[col].length)));
106
178
  return [header, ...body]
107
- .map((cells) => cells.map((cell, col) => (col < 3 ? cell.padEnd(widths[col]) : cell)).join(" ").trimEnd())
179
+ .map((cells) => cells.map((cell, col) => (col < 4 ? cell.padEnd(widths[col]) : cell)).join(" ").trimEnd())
108
180
  .join("\n");
109
181
  }
182
+ /** Status cell for the list table. A stalled row is flagged so it does not read as not started. */
183
+ export function formatFeedbackStatus(status) {
184
+ if (typeof status !== "string" || status.length === 0)
185
+ return "-";
186
+ return status === "stalled" ? "stalled!" : status;
187
+ }
188
+ /**
189
+ * Normalize `list --status`; accepts `in-progress` as an alias of `in_progress`.
190
+ * Any non-empty value goes to the server, which validates it.
191
+ */
192
+ export function parseListStatus(raw) {
193
+ const value = raw.trim().toLowerCase().replace(/-/g, "_");
194
+ if (!value)
195
+ throw new Error("--status must not be empty.");
196
+ return value;
197
+ }
110
198
  export function parseListLimit(raw) {
111
199
  const n = Number(raw);
112
200
  if (!Number.isInteger(n) || n < 1) {
@@ -167,31 +255,40 @@ function registerListSubcommand(feedbackCmd) {
167
255
  .command("list")
168
256
  .description("List feedback: untriaged (default), yours, or one person's")
169
257
  .option("--untriaged", "Items nobody has triaged yet (the default)")
258
+ .option("--open", "Every item not resolved, won't-fix or accepted, triaged or not")
170
259
  .option("--mine", "Items assigned to you")
171
260
  .option("--assignee <person>", "Items assigned to this email address or prs_ uid")
172
- .option("--status <status>", "Only items with this status")
261
+ .option("--status <status>", `Only items with this status (${FEEDBACK_LIST_STATUSES.join(", ")}). Without --untriaged, --mine or --assignee it lists every item in that status`)
173
262
  .option("--limit <n>", `Stop after this many items (default ${DEFAULT_LIST_LIMIT})`)
174
263
  .option("--all", "Fetch every page")
175
- .option("--json", "Print { items, count } as JSON")
264
+ .option("--json", "Print { items, count } as JSON, plus truncated: true when more items match")
176
265
  .action(async (opts) => {
177
266
  try {
178
- const picked = [opts.untriaged, opts.mine, opts.assignee !== undefined].filter(Boolean);
267
+ const picked = [opts.untriaged, opts.open, opts.mine, opts.assignee !== undefined].filter(Boolean);
179
268
  if (picked.length > 1) {
180
- throw new Error("Use only one of --untriaged, --mine and --assignee.");
269
+ throw new Error("Use only one of --untriaged, --open, --mine and --assignee.");
270
+ }
271
+ if (opts.open && opts.status !== undefined) {
272
+ throw new Error("--open already selects every open status; use --status on its own instead.");
181
273
  }
182
274
  if (opts.all && opts.limit !== undefined) {
183
275
  throw new Error("Use --limit or --all, not both.");
184
276
  }
185
- if (opts.status !== undefined && !opts.status.trim()) {
186
- throw new Error("--status must not be empty.");
187
- }
277
+ const status = opts.status === undefined ? undefined : parseListStatus(opts.status);
188
278
  const limit = opts.all
189
279
  ? null
190
280
  : opts.limit === undefined
191
281
  ? DEFAULT_LIST_LIMIT
192
282
  : parseListLimit(opts.limit);
193
283
  const token = await resolveStaffToken();
194
- let selector = { kind: "untriaged" };
284
+ // No selector means the untriaged queue, except with --status: a
285
+ // status filter alone lists every row in that status. The queue only
286
+ // holds untriaged rows, so `--status accepted` there was nearly empty.
287
+ let selector = opts.open
288
+ ? { kind: "open" }
289
+ : opts.status !== undefined && !opts.untriaged
290
+ ? { kind: "any" }
291
+ : { kind: "untriaged" };
195
292
  if (opts.mine) {
196
293
  const email = emailFromToken(token);
197
294
  if (!email) {
@@ -206,14 +303,17 @@ function registerListSubcommand(feedbackCmd) {
206
303
  };
207
304
  }
208
305
  assertVaultApiRequestSupported({ token, path: "/v1/feedback" });
209
- const items = await listFeedback({
306
+ const { items, truncated } = await listFeedback({
210
307
  token,
211
308
  selector,
212
- status: opts.status?.trim(),
309
+ status,
213
310
  limit,
214
311
  });
312
+ if (truncated) {
313
+ console.error(chalk.yellow("Note:"), feedbackTruncationNotice(truncated, items.length));
314
+ }
215
315
  if (opts.json) {
216
- console.log(JSON.stringify({ items, count: items.length }));
316
+ console.log(JSON.stringify({ items, count: items.length, ...(truncated ? { truncated: true } : {}) }));
217
317
  }
218
318
  else if (items.length === 0) {
219
319
  console.log("No feedback items match.");
@@ -73,7 +73,7 @@ export declare function attachDebugLogs(requestBody: Record<string, unknown>, di
73
73
  }): void;
74
74
  export declare function submitFeedback(opts: FeedbackSubmitOptions): Promise<FeedbackResult>;
75
75
  /** Customer-visible states accepted by `hq feedback update --state`. */
76
- export declare const PUBLIC_TICKET_STATES: readonly ["triaged", "in-progress", "shipped", "verified", "resolved"];
76
+ export declare const PUBLIC_TICKET_STATES: readonly ["triaged", "in-progress", "stalled", "shipped", "verified", "resolved"];
77
77
  export type PublicTicketState = (typeof PUBLIC_TICKET_STATES)[number];
78
78
  export declare function assertFeedbackId(id: string): void;
79
79
  /**
@@ -88,6 +88,45 @@ export declare function fetchFeedbackLink(opts: {
88
88
  id: string;
89
89
  token: string;
90
90
  }): Promise<string>;
91
+ /**
92
+ * Work links for an in-progress update. A feedback row is in_progress only
93
+ * while it has a linked open PR or a live lane, so `--state in-progress`
94
+ * requires one of them. Field names match the hq-pro PATCH body.
95
+ */
96
+ export interface FeedbackWorkLink {
97
+ prUrl?: string;
98
+ laneId?: string;
99
+ }
100
+ /** Accept `in_progress` as an alias of the public `in-progress` state. */
101
+ export declare function normalizePublicState(raw: string): string;
102
+ /** Validate --pr/--lane for a state; in-progress needs at least one, other states take none. */
103
+ export declare function buildWorkLink(state: string, opts: {
104
+ pr?: string;
105
+ lane?: string;
106
+ }): FeedbackWorkLink | null;
107
+ /** Read a feedback row with the staff GET. Returns null when it cannot be read. */
108
+ export declare function fetchFeedbackRow(opts: {
109
+ id: string;
110
+ token: string;
111
+ }): Promise<Record<string, unknown> | null>;
112
+ /** Delays between read-back attempts: 3 reads over about 1s. */
113
+ export declare const LINK_READBACK_DELAYS_MS: readonly number[];
114
+ /** Overridable in tests so retries do not wait on real timers. */
115
+ export declare const linkReadbackTimer: {
116
+ sleep: (ms: number) => Promise<void>;
117
+ };
118
+ /**
119
+ * Store the PR or lane link before any state change, then confirm it was
120
+ * saved by reading the row back. The PATCH response does not echo prUrl or
121
+ * laneId, and an older server ignores unknown fields, so only the GET shows
122
+ * the link is stored. A link-only PATCH can itself change the row (hq-pro
123
+ * resumes a stalled row), so errors report the row's actual state.
124
+ */
125
+ export declare function storeFeedbackWorkLink(opts: {
126
+ id: string;
127
+ token: string;
128
+ link: FeedbackWorkLink;
129
+ }): Promise<void>;
91
130
  /** Post a customer-visible update; the server also mirrors it into #hq-feedback. */
92
131
  export declare function postFeedbackUpdate(opts: {
93
132
  id: string;
@@ -9,6 +9,7 @@ import { FEEDBACK_LOGS_MAX_TOTAL_BYTES, FEEDBACK_LOGS_MIN_USEFUL_BYTES, collectF
9
9
  import { uploadLogBundle } from "../utils/feedback-log-bundle.js";
10
10
  import { resolveProcessFlagGate } from "../lib/flag-registry.js";
11
11
  import { cachedLatestVersion } from "../utils/version-check.js";
12
+ import { decodeJwtPayload } from "../utils/person-token-cache.js";
12
13
  import { registerFeedbackShowCommand } from "./feedback-show.js";
13
14
  import { registerFeedbackTriageCommands } from "./feedback-triage.js";
14
15
  export const FEEDBACK_LATEST_VERSION_FLAG = "feedback.latest-version";
@@ -223,6 +224,7 @@ export async function submitFeedback(opts) {
223
224
  export const PUBLIC_TICKET_STATES = [
224
225
  "triaged",
225
226
  "in-progress",
227
+ "stalled",
226
228
  "shipped",
227
229
  "verified",
228
230
  "resolved",
@@ -277,6 +279,129 @@ export async function fetchFeedbackLink(opts) {
277
279
  }
278
280
  return url;
279
281
  }
282
+ const PR_URL_RE = /^https:\/\/[^\s/]+\/[^\s]+$/;
283
+ /** Max `--lane` length; matches hq-pro LANE_ID_MAX_CHARS. */
284
+ const LANE_ID_MAX_CHARS = 200;
285
+ /** Same rule as hq-pro normalizeLaneId: non-empty, at most 200 chars, no control characters. */
286
+ function isValidLaneId(lane) {
287
+ if (lane.length === 0 || lane.length > LANE_ID_MAX_CHARS)
288
+ return false;
289
+ for (let i = 0; i < lane.length; i += 1) {
290
+ const code = lane.charCodeAt(i);
291
+ if (code < 0x20 || code === 0x7f)
292
+ return false;
293
+ }
294
+ return true;
295
+ }
296
+ /** Accept `in_progress` as an alias of the public `in-progress` state. */
297
+ export function normalizePublicState(raw) {
298
+ const value = raw.trim().toLowerCase();
299
+ return value === "in_progress" ? "in-progress" : value;
300
+ }
301
+ /** Validate --pr/--lane for a state; in-progress needs at least one, other states take none. */
302
+ export function buildWorkLink(state, opts) {
303
+ const pr = opts.pr?.trim();
304
+ const lane = opts.lane?.trim();
305
+ if (state !== "in-progress") {
306
+ if (pr !== undefined || lane !== undefined) {
307
+ throw new Error("--pr and --lane only apply to --state in-progress.");
308
+ }
309
+ return null;
310
+ }
311
+ if (!pr && !lane) {
312
+ throw new Error("--state in-progress needs --pr <url> or --lane <id>: a ticket is in progress only while it has an open PR or a live lane. Claiming a ticket does not need a state; use hq feedback claim.");
313
+ }
314
+ const link = {};
315
+ if (pr) {
316
+ if (!PR_URL_RE.test(pr))
317
+ throw new Error(`Invalid --pr '${opts.pr}': expected an https:// pull request URL.`);
318
+ link.prUrl = pr;
319
+ }
320
+ if (lane) {
321
+ if (!isValidLaneId(lane)) {
322
+ throw new Error(`Invalid --lane '${opts.lane}': expected a lane id of at most ${LANE_ID_MAX_CHARS} characters with no control characters.`);
323
+ }
324
+ link.laneId = lane;
325
+ }
326
+ return link;
327
+ }
328
+ function asRecord(value) {
329
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
330
+ }
331
+ function storedLinkMatches(row, link) {
332
+ if (!row)
333
+ return false;
334
+ return ((link.prUrl === undefined || row.prUrl === link.prUrl) &&
335
+ (link.laneId === undefined || row.laneId === link.laneId));
336
+ }
337
+ /** Read a feedback row with the staff GET. Returns null when it cannot be read. */
338
+ export async function fetchFeedbackRow(opts) {
339
+ try {
340
+ const res = await vaultApiFetch({ token: opts.token, path: `/v1/feedback/${opts.id}`, method: "GET" });
341
+ if (!res.ok)
342
+ return null;
343
+ const data = asRecord(await res.json().catch(() => null));
344
+ return asRecord(data?.item);
345
+ }
346
+ catch {
347
+ return null;
348
+ }
349
+ }
350
+ function describeRowState(row) {
351
+ return typeof row?.status === "string"
352
+ ? `The ticket's current state is '${row.status}'.`
353
+ : "The ticket's current state could not be read; check it with hq feedback list.";
354
+ }
355
+ /** Re-read the row and describe its actual state, for error messages. */
356
+ async function currentStateNote(id, token) {
357
+ return describeRowState(await fetchFeedbackRow({ id, token }));
358
+ }
359
+ const OLD_SERVER_HINT = "The server may not support work links yet.";
360
+ /** Delays between read-back attempts: 3 reads over about 1s. */
361
+ export const LINK_READBACK_DELAYS_MS = [300, 700];
362
+ /** Overridable in tests so retries do not wait on real timers. */
363
+ export const linkReadbackTimer = {
364
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
365
+ };
366
+ function sleep(ms) {
367
+ return linkReadbackTimer.sleep(ms);
368
+ }
369
+ /**
370
+ * Store the PR or lane link before any state change, then confirm it was
371
+ * saved by reading the row back. The PATCH response does not echo prUrl or
372
+ * laneId, and an older server ignores unknown fields, so only the GET shows
373
+ * the link is stored. A link-only PATCH can itself change the row (hq-pro
374
+ * resumes a stalled row), so errors report the row's actual state.
375
+ */
376
+ export async function storeFeedbackWorkLink(opts) {
377
+ const res = await vaultApiFetch({
378
+ token: opts.token,
379
+ path: `/v1/feedback/${opts.id}`,
380
+ method: "PATCH",
381
+ body: { ...opts.link },
382
+ });
383
+ if (!res.ok) {
384
+ const message = await readFeedbackError(res);
385
+ throw new Error(`The server rejected the work link for ${opts.id}: ${message}. ${OLD_SERVER_HINT} ${await currentStateNote(opts.id, opts.token)}`);
386
+ }
387
+ // The GET resolves through an eventually consistent index, so a read right
388
+ // after the PATCH can return the old row. Retry briefly before deciding.
389
+ let row = null;
390
+ let readOk = false;
391
+ for (let attempt = 0; attempt < LINK_READBACK_DELAYS_MS.length + 1; attempt += 1) {
392
+ if (attempt > 0)
393
+ await sleep(LINK_READBACK_DELAYS_MS[attempt - 1]);
394
+ row = await fetchFeedbackRow({ id: opts.id, token: opts.token });
395
+ if (row)
396
+ readOk = true;
397
+ if (storedLinkMatches(row, opts.link))
398
+ return;
399
+ }
400
+ if (!readOk) {
401
+ throw new Error(`Could not read ${opts.id} back to confirm the work link; the link may have been saved. The state was not posted; rerun the same command to retry.`);
402
+ }
403
+ throw new Error(`The server did not store the work link for ${opts.id}. ${OLD_SERVER_HINT} ${describeRowState(row)}`);
404
+ }
280
405
  /** Post a customer-visible update; the server also mirrors it into #hq-feedback. */
281
406
  export async function postFeedbackUpdate(opts) {
282
407
  const res = await vaultApiFetch({
@@ -286,7 +411,11 @@ export async function postFeedbackUpdate(opts) {
286
411
  body: { publicUpdate: { state: opts.state, note: opts.note } },
287
412
  });
288
413
  if (!res.ok) {
289
- throw new Error(`Failed to update ${opts.id}: ${await readFeedbackError(res)}`);
414
+ const message = await readFeedbackError(res);
415
+ const hint = opts.state === "stalled" && (res.status === 400 || res.status === 422)
416
+ ? ` The server may not support the stalled state yet. ${await currentStateNote(opts.id, opts.token)}`
417
+ : "";
418
+ throw new Error(`Failed to update ${opts.id}: ${message}${hint}`);
290
419
  }
291
420
  const data = (await res.json());
292
421
  return {
@@ -367,6 +496,41 @@ function registerAssignSubcommand(feedbackCmd) {
367
496
  }
368
497
  });
369
498
  }
499
+ function registerClaimSubcommand(feedbackCmd) {
500
+ feedbackCmd
501
+ .command("claim")
502
+ .description("Assign a ticket to yourself (sets the assignee only, never the status)")
503
+ .argument("<id>", "Feedback id (feedback_<uuid>)")
504
+ .option("--json", "Print { id, assignee } as JSON")
505
+ .action(async (id, opts) => {
506
+ try {
507
+ assertFeedbackId(id);
508
+ const token = await resolveStaffToken();
509
+ const email = emailFromStaffToken(token);
510
+ if (!email) {
511
+ throw new Error("hq feedback claim needs a person login with an email (hq login). Use hq feedback assign <id> <person> instead.");
512
+ }
513
+ assertVaultApiRequestSupported({ token, path: `/v1/feedback/${id}` });
514
+ await assignFeedback({ id, token, assignee: email });
515
+ if (opts.json) {
516
+ console.log(JSON.stringify({ id, assignee: email }));
517
+ }
518
+ else {
519
+ console.log(`Claimed: ${id} -> ${email}`);
520
+ console.log("Set --state in-progress with --pr <url> or --lane <id> once work is linked.");
521
+ }
522
+ }
523
+ catch (err) {
524
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
525
+ process.exit(1);
526
+ }
527
+ });
528
+ }
529
+ function emailFromStaffToken(token) {
530
+ const claims = decodeJwtPayload(token);
531
+ const email = claims?.email;
532
+ return typeof email === "string" && email.includes("@") ? email.toLowerCase() : null;
533
+ }
370
534
  function registerLinkSubcommand(feedbackCmd) {
371
535
  feedbackCmd
372
536
  .command("link")
@@ -393,12 +557,16 @@ function registerUpdateSubcommand(feedbackCmd) {
393
557
  .argument("<id>", "Feedback id (feedback_<uuid>)")
394
558
  .requiredOption("--state <state>", `One of: ${PUBLIC_TICKET_STATES.join(", ")}`)
395
559
  .requiredOption("--note <text>", "What the customer should read (plain text, up to 2000 characters)")
560
+ .option("--pr <url>", "Open pull request for this ticket (required with --state in-progress unless --lane is given)")
561
+ .option("--lane <id>", "Live lane working this ticket (required with --state in-progress unless --pr is given)")
396
562
  .action(async (id, opts) => {
397
563
  try {
398
564
  assertFeedbackId(id);
399
- if (!PUBLIC_TICKET_STATES.includes(opts.state)) {
565
+ const state = normalizePublicState(opts.state);
566
+ if (!PUBLIC_TICKET_STATES.includes(state)) {
400
567
  throw new Error(`Invalid --state '${opts.state}': expected one of ${PUBLIC_TICKET_STATES.join(", ")}.`);
401
568
  }
569
+ const link = buildWorkLink(state, opts);
402
570
  const note = opts.note.trim();
403
571
  if (note.length === 0)
404
572
  throw new Error("--note must not be empty.");
@@ -406,13 +574,24 @@ function registerUpdateSubcommand(feedbackCmd) {
406
574
  throw new Error("--note must be at most 2000 characters.");
407
575
  const token = await resolveStaffToken();
408
576
  assertVaultApiRequestSupported({ token, path: `/v1/feedback/${id}` });
577
+ if (link)
578
+ await storeFeedbackWorkLink({ id, token, link });
409
579
  const result = await postFeedbackUpdate({
410
580
  id,
411
581
  token,
412
- state: opts.state,
582
+ state: state,
413
583
  note,
584
+ }).catch((err) => {
585
+ if (!link)
586
+ throw err;
587
+ const message = err instanceof Error ? err.message : String(err);
588
+ throw new Error(`${message} The work link was saved; rerun the same command to set the state.`);
414
589
  });
415
- console.log(`Updated: ${id} -> ${opts.state}`);
590
+ console.log(`Updated: ${id} -> ${state}`);
591
+ if (link?.prUrl)
592
+ console.log(`PR: ${link.prUrl}`);
593
+ if (link?.laneId)
594
+ console.log(`Lane: ${link.laneId}`);
416
595
  if (result.publicUrl)
417
596
  console.log(`Track: ${result.publicUrl}`);
418
597
  if (result.slack === "failed") {
@@ -530,6 +709,7 @@ export function registerFeedbackCommand(program) {
530
709
  registerUpdateSubcommand(feedbackCmd);
531
710
  registerFeedbackShowCommand(feedbackCmd);
532
711
  registerAssignSubcommand(feedbackCmd);
712
+ registerClaimSubcommand(feedbackCmd);
533
713
  registerFeedbackTriageCommands(feedbackCmd);
534
714
  }
535
715
  //# sourceMappingURL=feedback.js.map
@@ -66,6 +66,48 @@ export declare class ShareSessionHttpError extends Error {
66
66
  */
67
67
  export declare function formatShareSessionError(err: ShareSessionHttpError): string;
68
68
  export declare function registerFilesCommand(program: Command): Command;
69
+ /**
70
+ * True for a prefix that might name a folder without saying so: no trailing
71
+ * `/` (private folder), no `/*` (shared folder glob), and not the `*`
72
+ * wildcard, and a last segment with no extension (a name with an extension is
73
+ * taken as the file it names). The vault ACL treats such a path as an exact
74
+ * object key, so
75
+ * `hq files share knowledge --with x` used to grant an object named
76
+ * "knowledge" and nothing under `knowledge/` (DEV-5173: members were blocked
77
+ * under a folder their admin had "shared"). The name alone does not decide
78
+ * whether it is a folder (`Makefile`, `bin/hq`); resolveBareSharePrefix asks
79
+ * the vault.
80
+ */
81
+ export declare function looksLikeBareFolderPrefix(prefix: string): boolean;
82
+ /**
83
+ * Refuse a bare prefix that would become a malformed grant if it were
84
+ * rewritten to a folder glob: a `*` inside a segment (`**`, `know*`) or an
85
+ * empty segment (`/knowledge`, `a//b`). Returns the error text, or null.
86
+ */
87
+ export declare function malformedBareSharePrefix(prefix: string): string | null;
88
+ export type BareSharePrefixResolution = {
89
+ kind: "unchanged";
90
+ prefix: string;
91
+ } | {
92
+ kind: "folder";
93
+ prefix: string;
94
+ folderGlob: string;
95
+ } | {
96
+ kind: "refused";
97
+ prefix: string;
98
+ message: string;
99
+ };
100
+ /**
101
+ * Decide what a bare prefix means by looking it up in the vault. Rewrite `X`
102
+ * to `X/*` only when `X/` has children and no object `X` exists. Keep `X` when
103
+ * only the object exists. Refuse when both exist, when neither does, or when
104
+ * the lookup fails; the caller exits non-zero and points to --exact or `X/*`.
105
+ */
106
+ export declare function resolveBareSharePrefix(params: {
107
+ token: string;
108
+ companyUid: string;
109
+ prefix: string;
110
+ }): Promise<BareSharePrefixResolution>;
69
111
  /** Server response shape for POST /v1/files/delete. */
70
112
  export interface FilesDeleteResponse {
71
113
  prefix: string;