@indigoai-us/hq-cli 5.345.161 → 5.345.163

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.
@@ -0,0 +1,67 @@
1
+ import { Command } from "commander";
2
+ /**
3
+ * Staff triage commands on the feedback queue: `hq feedback list`,
4
+ * `hq feedback triage` and `hq feedback close`. `hq feedback assign` lives in
5
+ * feedback.ts next to the shared helpers.
6
+ *
7
+ * The server owns triagedAt, triagedBy and priority. These commands never
8
+ * send them.
9
+ */
10
+ export declare const TRIAGE_KINDS: readonly ["bug", "feature", "question"];
11
+ export type TriageKind = (typeof TRIAGE_KINDS)[number];
12
+ export declare const MAX_TRIAGE_SUMMARY = 2000;
13
+ export declare const DEFAULT_LIST_LIMIT = 50;
14
+ export interface FeedbackListRow {
15
+ id: string;
16
+ title?: string;
17
+ plan?: string;
18
+ submittedAt?: string;
19
+ createdAt?: string;
20
+ [key: string]: unknown;
21
+ }
22
+ export type FeedbackListSelector = {
23
+ kind: "untriaged";
24
+ } | {
25
+ kind: "assignee";
26
+ assignee: string;
27
+ };
28
+ /** Email claim of the caller's staff token, used by `list --mine`. */
29
+ export declare function emailFromToken(token: string): string | null;
30
+ /** Compact age such as `5m`, `3h` or `2d`. Unknown or unparsable times print `-`. */
31
+ export declare function formatFeedbackAge(iso: string | undefined, now?: number): string;
32
+ /**
33
+ * hq-pro's `GET /v1/feedback` returns the next page as `nextCursor` on every
34
+ * path (the untriaged queue also mirrors it as `cursor`) and reads it back from
35
+ * `?cursor=`. `nextToken` is accepted for older servers. The value always goes
36
+ * back as `?cursor=`.
37
+ */
38
+ export declare function cursorFrom(data: unknown): string | null;
39
+ /** Page through `GET /v1/feedback` until `limit` rows (or every row with `limit: null`). */
40
+ export declare function listFeedback(opts: {
41
+ token: string;
42
+ selector: FeedbackListSelector;
43
+ status?: string;
44
+ limit: number | null;
45
+ }): Promise<FeedbackListRow[]>;
46
+ export declare function formatFeedbackTable(rows: FeedbackListRow[], now?: number): string;
47
+ export declare function parseListLimit(raw: string): number;
48
+ export declare function buildTriagePatch(opts: {
49
+ id: string;
50
+ kind: string;
51
+ area: string;
52
+ summary: string;
53
+ duplicateOf?: string;
54
+ }): Record<string, string>;
55
+ export declare function buildClosePatch(opts: {
56
+ reason: string;
57
+ wontFix?: boolean;
58
+ }): Record<string, string>;
59
+ /** PATCH one feedback row; returns the server's JSON, or the sent fields when the body is empty. */
60
+ export declare function patchFeedback(opts: {
61
+ id: string;
62
+ token: string;
63
+ body: Record<string, string>;
64
+ action: string;
65
+ }): Promise<unknown>;
66
+ export declare function registerFeedbackTriageCommands(feedbackCmd: Command): void;
67
+ //# sourceMappingURL=feedback-triage.d.ts.map
@@ -0,0 +1,294 @@
1
+ import chalk from "chalk";
2
+ import { assertVaultApiRequestSupported, vaultApiFetch } from "../utils/vault-api.js";
3
+ import { decodeJwtPayload } from "../utils/person-token-cache.js";
4
+ import { assertFeedbackId, normalizeFeedbackAssignee, readFeedbackError, resolveStaffToken, } from "./feedback.js";
5
+ /**
6
+ * Staff triage commands on the feedback queue: `hq feedback list`,
7
+ * `hq feedback triage` and `hq feedback close`. `hq feedback assign` lives in
8
+ * feedback.ts next to the shared helpers.
9
+ *
10
+ * The server owns triagedAt, triagedBy and priority. These commands never
11
+ * send them.
12
+ */
13
+ export const TRIAGE_KINDS = ["bug", "feature", "question"];
14
+ export const MAX_TRIAGE_SUMMARY = 2000;
15
+ export const DEFAULT_LIST_LIMIT = 50;
16
+ /** Safety stop for --all so a server that never ends its cursor can't loop forever. */
17
+ const MAX_LIST_PAGES = 100;
18
+ function fail(err) {
19
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
20
+ process.exit(1);
21
+ }
22
+ /** Email claim of the caller's staff token, used by `list --mine`. */
23
+ export function emailFromToken(token) {
24
+ const claims = decodeJwtPayload(token);
25
+ const email = claims?.email;
26
+ return typeof email === "string" && email.includes("@") ? email.toLowerCase() : null;
27
+ }
28
+ /** Compact age such as `5m`, `3h` or `2d`. Unknown or unparsable times print `-`. */
29
+ export function formatFeedbackAge(iso, now = Date.now()) {
30
+ if (!iso)
31
+ return "-";
32
+ const at = Date.parse(iso);
33
+ if (Number.isNaN(at))
34
+ return "-";
35
+ const minutes = Math.max(0, Math.floor((now - at) / 60_000));
36
+ if (minutes < 60)
37
+ return `${minutes}m`;
38
+ const hours = Math.floor(minutes / 60);
39
+ if (hours < 48)
40
+ return `${hours}h`;
41
+ return `${Math.floor(hours / 24)}d`;
42
+ }
43
+ function rowsFrom(data) {
44
+ if (!data || typeof data !== "object" || Array.isArray(data))
45
+ return [];
46
+ const items = data.items;
47
+ if (!Array.isArray(items))
48
+ return [];
49
+ return items.filter((row) => !!row && typeof row === "object" && typeof row.id === "string");
50
+ }
51
+ /**
52
+ * hq-pro's `GET /v1/feedback` returns the next page as `nextCursor` on every
53
+ * path (the untriaged queue also mirrors it as `cursor`) and reads it back from
54
+ * `?cursor=`. `nextToken` is accepted for older servers. The value always goes
55
+ * back as `?cursor=`.
56
+ */
57
+ export function cursorFrom(data) {
58
+ if (!data || typeof data !== "object" || Array.isArray(data))
59
+ return null;
60
+ for (const name of ["nextCursor", "cursor", "nextToken"]) {
61
+ const value = data[name];
62
+ if (typeof value === "string" && value.length > 0)
63
+ return value;
64
+ }
65
+ return null;
66
+ }
67
+ /** Server page cap (FEEDBACK_LIST_PAGE_MAX). The plain list path rejects a cursor without a limit. */
68
+ const LIST_PAGE_SIZE = 200;
69
+ /** Page through `GET /v1/feedback` until `limit` rows (or every row with `limit: null`). */
70
+ export async function listFeedback(opts) {
71
+ const base = opts.selector.kind === "untriaged"
72
+ ? { triage: "untriaged" }
73
+ : { assignee: opts.selector.assignee };
74
+ if (opts.status)
75
+ base.status = opts.status;
76
+ const rows = [];
77
+ let cursor = null;
78
+ 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);
80
+ const query = { ...base, limit: String(pageSize) };
81
+ if (cursor)
82
+ query.cursor = cursor;
83
+ const res = await vaultApiFetch({ token: opts.token, path: "/v1/feedback", query });
84
+ if (!res.ok) {
85
+ throw new Error(`Failed to list feedback: ${await readFeedbackError(res)}`);
86
+ }
87
+ 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);
91
+ cursor = cursorFrom(data);
92
+ if (!cursor)
93
+ return rows;
94
+ }
95
+ return rows;
96
+ }
97
+ export function formatFeedbackTable(rows, now = Date.now()) {
98
+ const header = ["ID", "AGE", "PLAN", "TITLE"];
99
+ const body = rows.map((row) => [
100
+ row.id,
101
+ formatFeedbackAge(row.submittedAt ?? row.createdAt, now),
102
+ typeof row.plan === "string" && row.plan.length > 0 ? row.plan : "-",
103
+ typeof row.title === "string" ? row.title : "",
104
+ ]);
105
+ const widths = [0, 1, 2].map((col) => Math.max(header[col].length, ...body.map((cells) => cells[col].length)));
106
+ return [header, ...body]
107
+ .map((cells) => cells.map((cell, col) => (col < 3 ? cell.padEnd(widths[col]) : cell)).join(" ").trimEnd())
108
+ .join("\n");
109
+ }
110
+ export function parseListLimit(raw) {
111
+ const n = Number(raw);
112
+ if (!Number.isInteger(n) || n < 1) {
113
+ throw new Error(`Invalid --limit '${raw}': expected a positive whole number.`);
114
+ }
115
+ return n;
116
+ }
117
+ export function buildTriagePatch(opts) {
118
+ if (!TRIAGE_KINDS.includes(opts.kind)) {
119
+ throw new Error(`Invalid --kind '${opts.kind}': expected one of ${TRIAGE_KINDS.join(", ")}.`);
120
+ }
121
+ const area = opts.area.trim();
122
+ if (!area)
123
+ throw new Error("--area must not be empty.");
124
+ const summary = opts.summary.trim();
125
+ if (!summary)
126
+ throw new Error("--summary must not be empty.");
127
+ if (summary.length > MAX_TRIAGE_SUMMARY) {
128
+ throw new Error(`--summary is ${summary.length} characters; the limit is ${MAX_TRIAGE_SUMMARY}.`);
129
+ }
130
+ const body = {
131
+ triageStatus: opts.duplicateOf === undefined ? "triaged" : "duplicate",
132
+ triageKind: opts.kind,
133
+ area,
134
+ summary,
135
+ };
136
+ if (opts.duplicateOf !== undefined) {
137
+ assertFeedbackId(opts.duplicateOf);
138
+ if (opts.duplicateOf === opts.id) {
139
+ throw new Error("--duplicate-of must name a different feedback item.");
140
+ }
141
+ body.duplicateOf = opts.duplicateOf;
142
+ }
143
+ return body;
144
+ }
145
+ export function buildClosePatch(opts) {
146
+ const reason = opts.reason.trim();
147
+ if (!reason)
148
+ throw new Error("--reason must not be empty.");
149
+ return { status: opts.wontFix ? "wont_fix" : "resolved", resolution: reason };
150
+ }
151
+ /** PATCH one feedback row; returns the server's JSON, or the sent fields when the body is empty. */
152
+ export async function patchFeedback(opts) {
153
+ const res = await vaultApiFetch({
154
+ token: opts.token,
155
+ path: `/v1/feedback/${opts.id}`,
156
+ method: "PATCH",
157
+ body: opts.body,
158
+ });
159
+ if (!res.ok) {
160
+ throw new Error(`Failed to ${opts.action} ${opts.id}: ${await readFeedbackError(res)}`);
161
+ }
162
+ const data = (await res.json().catch(() => null));
163
+ return data ?? { id: opts.id, ...opts.body };
164
+ }
165
+ function registerListSubcommand(feedbackCmd) {
166
+ feedbackCmd
167
+ .command("list")
168
+ .description("List feedback: untriaged (default), yours, or one person's")
169
+ .option("--untriaged", "Items nobody has triaged yet (the default)")
170
+ .option("--mine", "Items assigned to you")
171
+ .option("--assignee <person>", "Items assigned to this email address or prs_ uid")
172
+ .option("--status <status>", "Only items with this status")
173
+ .option("--limit <n>", `Stop after this many items (default ${DEFAULT_LIST_LIMIT})`)
174
+ .option("--all", "Fetch every page")
175
+ .option("--json", "Print { items, count } as JSON")
176
+ .action(async (opts) => {
177
+ try {
178
+ const picked = [opts.untriaged, opts.mine, opts.assignee !== undefined].filter(Boolean);
179
+ if (picked.length > 1) {
180
+ throw new Error("Use only one of --untriaged, --mine and --assignee.");
181
+ }
182
+ if (opts.all && opts.limit !== undefined) {
183
+ throw new Error("Use --limit or --all, not both.");
184
+ }
185
+ if (opts.status !== undefined && !opts.status.trim()) {
186
+ throw new Error("--status must not be empty.");
187
+ }
188
+ const limit = opts.all
189
+ ? null
190
+ : opts.limit === undefined
191
+ ? DEFAULT_LIST_LIMIT
192
+ : parseListLimit(opts.limit);
193
+ const token = await resolveStaffToken();
194
+ let selector = { kind: "untriaged" };
195
+ if (opts.mine) {
196
+ const email = emailFromToken(token);
197
+ if (!email) {
198
+ throw new Error("--mine needs a person login with an email (hq login). Use --assignee <person> instead.");
199
+ }
200
+ selector = { kind: "assignee", assignee: email };
201
+ }
202
+ else if (opts.assignee !== undefined) {
203
+ selector = {
204
+ kind: "assignee",
205
+ assignee: normalizeFeedbackAssignee(opts.assignee, "--assignee"),
206
+ };
207
+ }
208
+ assertVaultApiRequestSupported({ token, path: "/v1/feedback" });
209
+ const items = await listFeedback({
210
+ token,
211
+ selector,
212
+ status: opts.status?.trim(),
213
+ limit,
214
+ });
215
+ if (opts.json) {
216
+ console.log(JSON.stringify({ items, count: items.length }));
217
+ }
218
+ else if (items.length === 0) {
219
+ console.log("No feedback items match.");
220
+ }
221
+ else {
222
+ console.log(formatFeedbackTable(items));
223
+ }
224
+ }
225
+ catch (err) {
226
+ fail(err);
227
+ }
228
+ });
229
+ }
230
+ function registerTriageSubcommand(feedbackCmd) {
231
+ feedbackCmd
232
+ .command("triage")
233
+ .description("Label a feedback item with its kind, area and summary, or mark it a duplicate")
234
+ .argument("<id>", "Feedback id (feedback_<uuid>)")
235
+ .requiredOption("--kind <kind>", `One of ${TRIAGE_KINDS.join(", ")}`)
236
+ .requiredOption("--area <area>", "Product area, for example sync or desktop")
237
+ .requiredOption("--summary <text>", `One-line triage summary (at most ${MAX_TRIAGE_SUMMARY} characters)`)
238
+ .option("--duplicate-of <id>", "Mark this item a duplicate of another feedback id")
239
+ .option("--json", "Print the updated item as JSON")
240
+ .action(async (id, opts) => {
241
+ try {
242
+ assertFeedbackId(id);
243
+ const body = buildTriagePatch({ id, ...opts });
244
+ const token = await resolveStaffToken();
245
+ assertVaultApiRequestSupported({ token, path: `/v1/feedback/${id}` });
246
+ const result = await patchFeedback({ id, token, body, action: "triage" });
247
+ if (opts.json) {
248
+ console.log(JSON.stringify(result));
249
+ }
250
+ else if (body.duplicateOf) {
251
+ console.log(`Marked duplicate: ${id} -> ${body.duplicateOf}`);
252
+ }
253
+ else {
254
+ console.log(`Triaged: ${id} (${body.triageKind}, ${body.area})`);
255
+ }
256
+ }
257
+ catch (err) {
258
+ fail(err);
259
+ }
260
+ });
261
+ }
262
+ function registerCloseSubcommand(feedbackCmd) {
263
+ feedbackCmd
264
+ .command("close")
265
+ .description("Close a feedback item as resolved (or won't fix) with a reason")
266
+ .argument("<id>", "Feedback id (feedback_<uuid>)")
267
+ .requiredOption("--reason <text>", "Why it is closed: the fix, PR or version, or the decision")
268
+ .option("--wont-fix", "Close as won't fix instead of resolved")
269
+ .option("--json", "Print the updated item as JSON")
270
+ .action(async (id, opts) => {
271
+ try {
272
+ assertFeedbackId(id);
273
+ const body = buildClosePatch(opts);
274
+ const token = await resolveStaffToken();
275
+ assertVaultApiRequestSupported({ token, path: `/v1/feedback/${id}` });
276
+ const result = await patchFeedback({ id, token, body, action: "close" });
277
+ if (opts.json) {
278
+ console.log(JSON.stringify(result));
279
+ }
280
+ else {
281
+ console.log(`Closed: ${id} (${body.status})`);
282
+ }
283
+ }
284
+ catch (err) {
285
+ fail(err);
286
+ }
287
+ });
288
+ }
289
+ export function registerFeedbackTriageCommands(feedbackCmd) {
290
+ registerListSubcommand(feedbackCmd);
291
+ registerTriageSubcommand(feedbackCmd);
292
+ registerCloseSubcommand(feedbackCmd);
293
+ }
294
+ //# sourceMappingURL=feedback-triage.js.map
@@ -76,6 +76,13 @@ export declare function submitFeedback(opts: FeedbackSubmitOptions): Promise<Fee
76
76
  export declare const PUBLIC_TICKET_STATES: readonly ["triaged", "in-progress", "shipped", "verified", "resolved"];
77
77
  export type PublicTicketState = (typeof PUBLIC_TICKET_STATES)[number];
78
78
  export declare function assertFeedbackId(id: string): void;
79
+ /**
80
+ * The staff feedback routes gate on the id token's email claim, so send the
81
+ * cached id token when a person session has one. Machine identities already
82
+ * get their id token back from ensureCognitoToken.
83
+ */
84
+ export declare function resolveStaffToken(): Promise<string>;
85
+ export declare function readFeedbackError(res: Response): Promise<string>;
79
86
  /** Resolve (and lazily mint) the public tracking link for a ticket. */
80
87
  export declare function fetchFeedbackLink(opts: {
81
88
  id: string;
@@ -92,7 +99,7 @@ export declare function postFeedbackUpdate(opts: {
92
99
  slack: string | null;
93
100
  }>;
94
101
  /** Mirror of hq-pro normalizeAssignee: a prs_ uid as-is, an email lowercased. */
95
- export declare function normalizeFeedbackAssignee(value: string): string;
102
+ export declare function normalizeFeedbackAssignee(value: string, label?: string): string;
96
103
  /**
97
104
  * Reassign a ticket. A person's own login may move a ticket that another
98
105
  * person claimed; agent and operator service tokens get 409
@@ -103,5 +110,10 @@ export declare function assignFeedback(opts: {
103
110
  token: string;
104
111
  assignee: string;
105
112
  }): Promise<void>;
113
+ /**
114
+ * Pick the new assignee from the positional `[person]` or the `--to` flag.
115
+ * Either form works; naming two different people is an error.
116
+ */
117
+ export declare function resolveAssignTarget(person: string | undefined, to: string | undefined): string;
106
118
  export declare function registerFeedbackCommand(program: Command): void;
107
119
  //# sourceMappingURL=feedback.d.ts.map
@@ -9,6 +9,7 @@ import { uploadLogBundle } from "../utils/feedback-log-bundle.js";
9
9
  import { resolveProcessFlagGate } from "../lib/flag-registry.js";
10
10
  import { cachedLatestVersion } from "../utils/version-check.js";
11
11
  import { registerFeedbackShowCommand } from "./feedback-show.js";
12
+ import { registerFeedbackTriageCommands } from "./feedback-triage.js";
12
13
  export const FEEDBACK_LATEST_VERSION_FLAG = "feedback.latest-version";
13
14
  export const FEEDBACK_LATEST_VERSION_FLAG_FALLBACK = false;
14
15
  export const FEEDBACK_REPORTER_KIND_FLAG = "feedback.reporter-kind";
@@ -229,14 +230,14 @@ export function assertFeedbackId(id) {
229
230
  * cached id token when a person session has one. Machine identities already
230
231
  * get their id token back from ensureCognitoToken.
231
232
  */
232
- async function resolveStaffToken() {
233
+ export async function resolveStaffToken() {
233
234
  const token = await ensureCognitoToken({ interactive: false });
234
235
  const cached = loadCachedTokens();
235
236
  return typeof cached?.idToken === "string" && cached.idToken.length > 0
236
237
  ? cached.idToken
237
238
  : token;
238
239
  }
239
- async function readFeedbackError(res) {
240
+ export async function readFeedbackError(res) {
240
241
  const data = await res.json().catch(() => ({}));
241
242
  return data &&
242
243
  typeof data === "object" &&
@@ -287,10 +288,10 @@ export async function postFeedbackUpdate(opts) {
287
288
  }
288
289
  const ASSIGNEE_RE = /^(?:prs_[A-Za-z0-9]+|[^\s@]+@[^\s@]+\.[^\s@]+)$/;
289
290
  /** Mirror of hq-pro normalizeAssignee: a prs_ uid as-is, an email lowercased. */
290
- export function normalizeFeedbackAssignee(value) {
291
+ export function normalizeFeedbackAssignee(value, label = "--to") {
291
292
  const trimmed = value.trim();
292
293
  if (!ASSIGNEE_RE.test(trimmed)) {
293
- throw new Error(`Invalid --to '${value}': expected an email address or a prs_ uid.`);
294
+ throw new Error(`Invalid ${label} '${value}': expected an email address or a prs_ uid.`);
294
295
  }
295
296
  return trimmed.startsWith("prs_") ? trimmed : trimmed.toLowerCase();
296
297
  }
@@ -315,20 +316,42 @@ export async function assignFeedback(opts) {
315
316
  }
316
317
  throw new Error(`Failed to assign ${opts.id}: ${message}`);
317
318
  }
319
+ /**
320
+ * Pick the new assignee from the positional `[person]` or the `--to` flag.
321
+ * Either form works; naming two different people is an error.
322
+ */
323
+ export function resolveAssignTarget(person, to) {
324
+ if (person === undefined && to === undefined) {
325
+ throw new Error("Name the new assignee: hq feedback assign <id> <person> (or --to <person>).");
326
+ }
327
+ const fromPerson = person === undefined ? undefined : normalizeFeedbackAssignee(person, "person");
328
+ const fromTo = to === undefined ? undefined : normalizeFeedbackAssignee(to);
329
+ if (fromPerson !== undefined && fromTo !== undefined && fromPerson !== fromTo) {
330
+ throw new Error(`Conflicting assignees '${person}' and --to '${to}': give only one.`);
331
+ }
332
+ return (fromPerson ?? fromTo);
333
+ }
318
334
  function registerAssignSubcommand(feedbackCmd) {
319
335
  feedbackCmd
320
336
  .command("assign")
321
337
  .description("Reassign a ticket to a person, including one another person has claimed")
322
338
  .argument("<id>", "Feedback id (feedback_<uuid>)")
323
- .requiredOption("--to <assignee>", "New assignee: an email address or a prs_ uid")
324
- .action(async (id, opts) => {
339
+ .argument("[person]", "New assignee: an email address or a prs_ uid")
340
+ .option("--to <assignee>", "New assignee: an email address or a prs_ uid")
341
+ .option("--json", "Print the result as JSON")
342
+ .action(async (id, person, opts) => {
325
343
  try {
326
344
  assertFeedbackId(id);
327
- const assignee = normalizeFeedbackAssignee(opts.to);
345
+ const assignee = resolveAssignTarget(person, opts.to);
328
346
  const token = await resolveStaffToken();
329
347
  assertVaultApiRequestSupported({ token, path: `/v1/feedback/${id}` });
330
348
  await assignFeedback({ id, token, assignee });
331
- console.log(`Assigned: ${id} -> ${assignee}`);
349
+ if (opts.json) {
350
+ console.log(JSON.stringify({ id, assignee }));
351
+ }
352
+ else {
353
+ console.log(`Assigned: ${id} -> ${assignee}`);
354
+ }
332
355
  }
333
356
  catch (err) {
334
357
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
@@ -499,5 +522,6 @@ export function registerFeedbackCommand(program) {
499
522
  registerUpdateSubcommand(feedbackCmd);
500
523
  registerFeedbackShowCommand(feedbackCmd);
501
524
  registerAssignSubcommand(feedbackCmd);
525
+ registerFeedbackTriageCommands(feedbackCmd);
502
526
  }
503
527
  //# sourceMappingURL=feedback.js.map
@@ -7,7 +7,7 @@ import { readLatestEnvelopeRecord } from "../lib/lanes/watcher.js";
7
7
  import { spawnLaneWorker } from "../lib/lanes/spawn.js";
8
8
  import { raiseLaneQuestion } from "../lib/lanes/questions.js";
9
9
  import { isTerminalLaneState } from "../lib/lanes/types.js";
10
- import { clearLoopControllerPid, loopAdmissionPath, runLoopLane, setLoopControllerPid } from "../lib/lanes/loop.js";
10
+ import { HANDOFF_HOLD_CHOICES, clearLoopControllerPid, loopAdmissionPath, runLoopLane, setLoopControllerPid } from "../lib/lanes/loop.js";
11
11
  import { laneStories } from "../lib/lanes/stories.js";
12
12
  import { readLanesRegistryFlagFailOpen } from "../lib/lanes/registry-flag.js";
13
13
  import { LANE_LOOP_HANDOFF_REPAIR_FLAG } from "../lib/lanes/flag-keys.js";
@@ -255,7 +255,7 @@ export async function runLaneLoopController(ctx, laneId, deps) {
255
255
  signal.removeEventListener("abort", abortPhase);
256
256
  }
257
257
  },
258
- raiseQuestion: ({ envelope, envelopeId, attempt, error, questionId }) => {
258
+ raiseQuestion: ({ envelope, envelopeId, attempt, error, questionId, reason }) => {
259
259
  const current = readLane(hqRoot, laneId);
260
260
  const data = envelope;
261
261
  raiseLaneQuestion({
@@ -265,11 +265,11 @@ export async function runLaneLoopController(ctx, laneId, deps) {
265
265
  raisedVia: "cli",
266
266
  at: ctx.now().toISOString(),
267
267
  body: {
268
- question: `Loop phase ${String(data.phase ?? envelopeId)} stalled twice. How should it proceed?`,
269
- context: `${error}\nThe phase is parked; later envelopes remain queued. Stall attempt: ${attempt}.`,
270
- options: ["Retry with a longer deadline", "Reroute to another worker", "Drop this phase and block the story"],
271
- recommended: "Retry with a longer deadline",
272
- loop_stall: { lane_id: laneId, envelope_id: envelopeId, attempt },
268
+ question: reason === "handoff_needs_repair" ? `Loop phase ${String(data.phase ?? envelopeId)} has an invalid handoff. How should it proceed?` : `Loop phase ${String(data.phase ?? envelopeId)} stalled twice. How should it proceed?`,
269
+ context: reason === "handoff_needs_repair" ? `${error}\nThe worker file is preserved and the phase is parked; later envelopes remain queued. Repair the handoff file before accepting it. Retrying can repeat external effects.` : `${error}\nThe phase is parked; later envelopes remain queued. Stall attempt: ${attempt}.`,
270
+ options: reason === "handoff_needs_repair" ? [...HANDOFF_HOLD_CHOICES] : ["Retry with a longer deadline", "Reroute to another worker", "Drop this phase and block the story"],
271
+ recommended: reason === "handoff_needs_repair" ? HANDOFF_HOLD_CHOICES[0] : "Retry with a longer deadline",
272
+ loop_stall: { lane_id: laneId, envelope_id: envelopeId, attempt, ...(reason ? { reason } : {}) },
273
273
  },
274
274
  });
275
275
  },
@@ -1,4 +1,5 @@
1
1
  import * as path from "node:path";
2
+ import { readSessionCompanyLock } from "../lib/session-company-lock.js";
2
3
  import { isQuestionImported } from "../lib/lanes/imported-question.js";
3
4
  import { buildOutpostQuestionAnswerCommand, buildOutpostQuestionCancelCommand, buildOutpostQuestionShowCommand, dispatchErrorMessage } from "../lib/lanes/outpost-dispatch.js";
4
5
  import { assertNoSecretText } from "../lib/lanes/text-scan.js";
@@ -6,7 +7,7 @@ import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
6
7
  import { appendCappedHistory, createQuestion, expandLocalLaneIdPrefix, isLanesError, listSessionLiveness, parseLivenessState, parseQuestionRecord, parseQuestionStatus, readDaemonPresenceConnected, readLane, readQuestion, wakeAnsweredLaneNow, sessionLiveness, appendDropboxMessage, answerDropboxMessage, laneAcceptsDropboxDelivery, laneIdForAnswerDelivery, writeQuestionBody } from "../lib/lanes/index.js";
7
8
  import { resolveQuestionCompanyWithFallback } from "../lib/lanes/questions.js";
8
9
  import { LanesError } from "../lib/lanes/errors.js";
9
- import { applyLoopStallAnswer, findLoopStallResult } from "../lib/lanes/loop.js";
10
+ import { HANDOFF_HOLD_CHOICES, applyLoopStallAnswer, findLoopStallResult, loopHandoffHoldIdentity } from "../lib/lanes/loop.js";
10
11
  export function resolveAnswerChoice(choice, options, freeform) {
11
12
  const trimmed = choice.trim();
12
13
  if (/^[0-9]+$/.test(trimmed)) {
@@ -847,11 +848,48 @@ export function createLaneQuestionsHandlers(deps) {
847
848
  const retryingSameLoopAnswer = isLoopStallQuestion && question.status === "answered" && question.answer?.choice === choice &&
848
849
  question.answer.answer_kind === incomingAnswerKind && incomingNotesMatch &&
849
850
  deps.sameQuestionAnswer(question.answer, { ...question.answer });
850
- if (question.status !== "pending" && !retryingSameLoopAnswer)
851
- throwIfQuestionNotPending(question);
852
851
  const loopStallResult = isLoopStallQuestion
853
852
  ? findLoopStallResult(path.join(hqRoot, "workspace", "lanes-runs", askingLane.lane_id), questionId)
854
853
  : undefined;
854
+ if (loopStallResult?.hold_reason === "handoff_needs_repair" && !HANDOFF_HOLD_CHOICES.includes(choice)) {
855
+ throw new LanesError("invalid_handoff_disposition", "Choose an explicit handoff repair disposition", { question_id: questionId });
856
+ }
857
+ const handoffIdentity = askingLane.loop_mode ? loopHandoffHoldIdentity(path.join(hqRoot, "workspace", "lanes-runs", askingLane.lane_id)) : undefined;
858
+ const changingHeldAnswer = body.loop_stall?.reason === "handoff_needs_repair" && isLoopStallQuestion &&
859
+ question.status === "answered" && question.answer?.choice === HANDOFF_HOLD_CHOICES[2] &&
860
+ choice !== HANDOFF_HOLD_CHOICES[2] && handoffIdentity?.unresolved === true &&
861
+ handoffIdentity.question_id === questionId && handoffIdentity.envelope_id === body.loop_stall.envelope_id &&
862
+ loopStallResult?.hold_reason === "handoff_needs_repair";
863
+ if (question.status !== "pending" && !retryingSameLoopAnswer && !changingHeldAnswer)
864
+ throwIfQuestionNotPending(question);
865
+ const isHandoffQuestion = body.loop_stall?.reason === "handoff_needs_repair" || loopStallResult?.hold_reason === "handoff_needs_repair" || handoffIdentity !== undefined;
866
+ if (isHandoffQuestion) {
867
+ assertQuestionAnswerAuthorized(ctx, askingLane);
868
+ assertLaneRunsHereForOperatorMutation(ctx, askingLane, "resolve a handoff repair hold on");
869
+ const targetCompany = questionCompanyForLane(question, askingLane, hqRoot);
870
+ const lock = readSessionCompanyLock({ ...ctx.env, HQ_ROOT: hqRoot });
871
+ const actorLaneId = ctx.env.HQ_LANE_ID?.trim();
872
+ if (actorLaneId) {
873
+ const actorLane = readLane(hqRoot, actorLaneId);
874
+ const actorCompany = actorLane.company ?? actorLane.mesh_company;
875
+ if (!targetCompany || !actorCompany || !sameQuestionCompany(actorCompany, targetCompany))
876
+ throw new LanesError("handoff_answer_company_mismatch", "Caller lane does not own this handoff company", { question_id: questionId });
877
+ }
878
+ // Lane-only callers may use their owned lane identity; a present session lock
879
+ // always constrains that identity and may never be widened by HQ_LANE_ID.
880
+ if ((lock || !actorLaneId) && (!lock || !targetCompany || !lock.companies.includes(targetCompany.slug))) {
881
+ throw new LanesError("handoff_answer_company_mismatch", "The current session is not bound to this handoff company", { question_id: questionId });
882
+ }
883
+ if (!isLoopStallQuestion || body.loop_stall?.reason !== "handoff_needs_repair" ||
884
+ (handoffIdentity && (handoffIdentity.question_id !== questionId || handoffIdentity.envelope_id !== body.loop_stall.envelope_id)) ||
885
+ (loopStallResult && loopStallResult.envelope_id !== body.loop_stall.envelope_id)) {
886
+ throw new LanesError("handoff_question_identity_mismatch", "The persisted handoff question and hold identities do not match", { question_id: questionId });
887
+ }
888
+ if (!HANDOFF_HOLD_CHOICES.includes(choice))
889
+ throw new LanesError("invalid_handoff_disposition", "Choose an explicit handoff repair disposition", { question_id: questionId });
890
+ if (!loopStallResult && question.status === "pending")
891
+ throw new LanesError("handoff_result_missing", "The handoff hold result is missing; no disposition was applied", { question_id: questionId });
892
+ }
855
893
  const at = ctx.now().toISOString();
856
894
  const answeredBy = sessionIdFromEnv(ctx.env)
857
895
  ? `session:${sessionIdFromEnv(ctx.env)}`
@@ -860,7 +898,14 @@ export function createLaneQuestionsHandlers(deps) {
860
898
  if (retryingSameLoopAnswer && current.status === "answered" && current.answer?.choice === choice &&
861
899
  deps.sameQuestionAnswer(current.answer, question.answer))
862
900
  return current;
863
- throwIfQuestionNotPending(current);
901
+ const currentHold = changingHeldAnswer ? loopHandoffHoldIdentity(path.join(hqRoot, "workspace", "lanes-runs", askingLane.lane_id)) : undefined;
902
+ const stillHeld = changingHeldAnswer && current.status === "answered" &&
903
+ current.answer?.choice === HANDOFF_HOLD_CHOICES[2] &&
904
+ deps.sameQuestionAnswer(current.answer, question.answer) &&
905
+ currentHold?.unresolved === true && currentHold.question_id === questionId &&
906
+ currentHold.envelope_id === body.loop_stall?.envelope_id;
907
+ if (!stillHeld)
908
+ throwIfQuestionNotPending(current);
864
909
  assertQuestionCompanyAccess(current, askingLane, requestedCompany, hqRoot);
865
910
  const recordedCompany = current.company ?? questionCompany;
866
911
  const currentForBody = current.company === null
@@ -882,6 +927,7 @@ export function createLaneQuestionsHandlers(deps) {
882
927
  body: nextBody,
883
928
  status: "answered",
884
929
  delivered_at: null,
930
+ ...(stillHeld && current.answer ? { answer_history: [...(current.answer_history ?? []), current.answer] } : {}),
885
931
  answer: {
886
932
  choice,
887
933
  answer_kind: freeform ? "freeform" : "option",
@@ -911,6 +957,10 @@ export function createLaneQuestionsHandlers(deps) {
911
957
  printJson({ ok: true, action: "questions.answer", lane_id: askingLane.lane_id, question: updated, loop_action: resolved.action });
912
958
  return;
913
959
  }
960
+ if (isHandoffQuestion && resolved.action === "parked") {
961
+ printJson({ ok: true, action: "questions.answer", lane_id: askingLane.lane_id, question: updated, loop_action: "parked" });
962
+ return;
963
+ }
914
964
  const controllerPid = await deps.restartLoopController(ctx, askingLane.lane_id);
915
965
  printJson({ ok: true, action: "questions.answer", lane_id: askingLane.lane_id, question: updated, loop_action: resolved.action, controller_pid: controllerPid });
916
966
  return;
@@ -717,7 +717,8 @@ export function parseQuestionBody(raw) {
717
717
  typeof obj.loop_stall.envelope_id !== "string" ||
718
718
  typeof obj.loop_stall.attempt !== "number" ||
719
719
  !Number.isSafeInteger(obj.loop_stall.attempt) ||
720
- obj.loop_stall.attempt < 1))
720
+ obj.loop_stall.attempt < 1 ||
721
+ (obj.loop_stall.reason !== undefined && obj.loop_stall.reason !== "handoff_needs_repair")))
721
722
  return null;
722
723
  const options = obj.options.filter((item) => typeof item === "string");
723
724
  const body = {