@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.
- package/CHANGELOG.md +17 -0
- package/dist/command-catalog.generated.d.ts +385 -1
- package/dist/command-catalog.generated.js +475 -1
- package/dist/command-registration-plan.d.ts +12 -0
- package/dist/command-registration-plan.js +2 -0
- package/dist/commands/agent-inbox.js +6 -2
- package/dist/commands/deploy-domains.d.ts +51 -0
- package/dist/commands/deploy-domains.js +787 -0
- package/dist/commands/feedback-triage.d.ts +67 -0
- package/dist/commands/feedback-triage.js +294 -0
- package/dist/commands/feedback.d.ts +13 -1
- package/dist/commands/feedback.js +32 -8
- package/dist/commands/lanes-loop.js +7 -7
- package/dist/commands/lanes-questions.js +54 -4
- package/dist/commands/lanes.js +2 -1
- package/dist/commands/sync-held.d.ts +87 -0
- package/dist/commands/sync-held.js +191 -0
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +3095 -2769
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/agent-kit/inbox-state.d.ts +5 -4
- package/dist/lib/agent-kit/inbox-state.js +24 -3
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/lanes/loop.d.ts +17 -0
- package/dist/lib/lanes/loop.js +235 -5
- package/dist/lib/lanes/questions.d.ts +1 -0
- package/package.json +8 -8
|
@@ -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
|
|
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
|
-
.
|
|
324
|
-
.
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/commands/lanes.js
CHANGED
|
@@ -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 = {
|