@bli-cockpit/cli 0.2.244 → 0.2.245

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.
@@ -119,11 +119,18 @@ export async function readBody(command, io) {
119
119
  return { ok: false, reason: "description_too_long", detail: error instanceof Error ? error.message : String(error) };
120
120
  }
121
121
  }
122
- /** The project/parent halves both `create` and `update` need, resolved once. */
122
+ /**
123
+ * The project/parent halves both `create` and `update` need, resolved once. Three answers per field: `undefined`
124
+ * (not asked to change), `null` (`none`: clear it, update only; the parser refuses it elsewhere, BLI-22367), or a
125
+ * uuid. A project or ticket literally named "none" is reached by its uuid.
126
+ */
123
127
  export async function resolveRefs(command, door) {
124
128
  let projectId;
125
129
  let parentId;
126
- if (command.project) {
130
+ if (command.project && command.project.toLowerCase() === "none") {
131
+ projectId = null;
132
+ }
133
+ else if (command.project) {
127
134
  const resolved = await resolveProjectId(door, command.project);
128
135
  if (resolved.status === "list_failed")
129
136
  return { ok: false, reason: resolved.reason, detail: resolved.detail };
@@ -136,11 +143,65 @@ export async function resolveRefs(command, door) {
136
143
  }
137
144
  projectId = resolved.id;
138
145
  }
139
- if (command.parentRef) {
146
+ if (command.parentRef && command.parentRef.toLowerCase() === "none") {
147
+ parentId = null;
148
+ }
149
+ else if (command.parentRef) {
140
150
  const resolved = await resolveIssueUuid(door, command.parentRef);
141
151
  if (resolved.status === "failed")
142
152
  return { ok: false, reason: resolved.reason, detail: resolved.detail };
143
153
  parentId = resolved.id;
144
154
  }
145
- return { ok: true, ...(projectId ? { projectId } : {}), ...(parentId ? { parentId } : {}) };
155
+ return { ok: true, ...(projectId !== undefined ? { projectId } : {}), ...(parentId !== undefined ? { parentId } : {}) };
156
+ }
157
+ /**
158
+ * The receipt for a write the door REFUSED. The door's own receipt wins. Without one, a 4xx answer that names its
159
+ * reason is a known refusal before anything was written: `not_committed`, never "check again" (BLI-22367; the
160
+ * self-parent and unknown-label refusals used to say `unknown_check_again` forever). Only a lost answer, a
161
+ * `mutation_unknown`, a 5xx, or a status with no reason label stays `unknown_check_again`.
162
+ */
163
+ export function receiptAfterRefusal(answer, requestId) {
164
+ const sent = answer.body?.mutation;
165
+ if (sent && typeof sent === "object" && sent.outcome)
166
+ return sent;
167
+ const status = answer.httpStatus;
168
+ const known = typeof status === "number" && status >= 400 && status < 500
169
+ && answer.reason !== "mutation_unknown" && !/^http_\d+$/.test(answer.reason);
170
+ return known
171
+ ? { request_id: requestId, outcome: "not_committed", reason: answer.reason }
172
+ : { request_id: requestId, outcome: "unknown_check_again" };
173
+ }
174
+ const DELIVERY_WORDS = {
175
+ pending: "queued, not delivered yet",
176
+ delivered: "delivered",
177
+ failed: "failed; the Tower save stands",
178
+ not_mirrored: "not mirrored",
179
+ tower_only: "not mirrored (Tower-only)",
180
+ unknown_check_again: "status unknown; check cockpit ticket mutation-status",
181
+ };
182
+ /**
183
+ * One sentence a person reads after a committed write: the Tower save first, the Linear copy second, so a pending
184
+ * or failed delivery never reads as a failed save (contract C1).
185
+ */
186
+ export function savedLine(receipt) {
187
+ if (!receipt || receipt.outcome !== "committed")
188
+ return null;
189
+ const revision = receipt.revision === undefined ? "" : ` (revision ${receipt.revision})`;
190
+ if (receipt.reason === "unchanged")
191
+ return `Saved in Tower${revision}: nothing needed to change.`;
192
+ const status = receipt.delivery?.status;
193
+ if (!status)
194
+ return `Saved in Tower${revision}.`;
195
+ const words = DELIVERY_WORDS[status] ?? status;
196
+ const why = receipt.delivery?.reason ? ` (${receipt.delivery.reason})` : "";
197
+ return `Saved in Tower${revision}. Linear copy: ${words}${why}.`;
198
+ }
199
+ /**
200
+ * The canonical ticket link (contract C3): the door's own `canonical_url` when it sent one, else
201
+ * `<configured dashboard url>/tickets/<IDENTIFIER>`. Never a Linear link.
202
+ */
203
+ export function canonicalTicketUrl(dashboardUrl, identifier, sent) {
204
+ if (typeof sent === "string" && /^https?:\/\//.test(sent))
205
+ return sent;
206
+ return `${dashboardUrl.replace(/\/+$/, "")}/tickets/${encodeURIComponent(identifier)}`;
146
207
  }
@@ -10,8 +10,23 @@
10
10
  */
11
11
  import { askAgentDoor } from "./agent-door.js";
12
12
  import { writeLine } from "./cli-io.js";
13
- import { READ_DEADLINE_MS, TAG, withSizeLabel } from "./issue-contracts.js";
13
+ import { TAG, withSizeLabel } from "./issue-contracts.js";
14
14
  const ALL_PAGE_SIZE = 500;
15
+ /**
16
+ * `--all` page deadline and bounded retry (BLI-22367). A live export timed out on page 5 of 16 (`turn_timed_out`
17
+ * at the 30 s read deadline) while the database answered every `work_issue_page` call in at most 288 ms
18
+ * (pg_stat_statements, 2026-10-19): the stall was transport or the serverless function, not the query. A 500-row
19
+ * page gets a longer deadline than a 100-row read, and a page that times out, cannot reach Tower or gets a 5xx is
20
+ * asked again FROM THE SAME CURSOR, at most twice, before the walk stops honestly with `failed_partial` and the
21
+ * resume cursor. A 4xx (an invalid cursor or filter) is never retried. Every retry is counted on the coverage line.
22
+ */
23
+ export const ALL_PAGE_DEADLINE_MS = 60_000;
24
+ export const allWalkRetry = { delaysMs: [1_000, 3_000] };
25
+ function retryablePageFailure(answer) {
26
+ if (answer.httpStatus !== undefined)
27
+ return answer.httpStatus >= 500;
28
+ return answer.reason === "turn_timed_out" || answer.reason === "gateway_unreachable";
29
+ }
15
30
  /** The additive page fields as `--json` output, only the ones the door sent. */
16
31
  export function pageFieldsOf(body) {
17
32
  const fields = {};
@@ -61,20 +76,33 @@ export async function streamAllIssues(command, door, base) {
61
76
  let pages = 0;
62
77
  let expected = null;
63
78
  let cursor = null;
79
+ let retries = 0;
64
80
  for (;;) {
65
81
  if (cursor)
66
82
  query.set("cursor", cursor);
67
- const answer = await askAgentDoor(door, {
83
+ let answer = await askAgentDoor(door, {
68
84
  path: `/api/work/issues?${query.toString()}`,
69
85
  method: "GET",
70
86
  label: "issue list",
71
- timeoutMs: READ_DEADLINE_MS,
87
+ timeoutMs: ALL_PAGE_DEADLINE_MS,
72
88
  });
89
+ for (let attempt = 0; !answer.ok && retryablePageFailure(answer) && attempt < allWalkRetry.delaysMs.length; attempt += 1) {
90
+ retries += 1;
91
+ console.error(`${TAG} page retry`, JSON.stringify({ page: pages + 1, attempt: attempt + 2, reason: answer.reason, http_status: answer.httpStatus ?? null }));
92
+ await new Promise((resolve) => setTimeout(resolve, allWalkRetry.delaysMs[attempt]));
93
+ answer = await askAgentDoor(door, {
94
+ path: `/api/work/issues?${query.toString()}`,
95
+ method: "GET",
96
+ label: "issue list",
97
+ timeoutMs: ALL_PAGE_DEADLINE_MS,
98
+ });
99
+ }
73
100
  if (!answer.ok) {
74
- console.error(`${TAG} walk interrupted`, JSON.stringify({ reason: answer.reason, pages, returned }));
101
+ console.error(`${TAG} walk interrupted`, JSON.stringify({ reason: answer.reason, pages, returned, retries }));
75
102
  writeLine(door.io.stdout, JSON.stringify({
76
103
  type: "coverage", coverage: "failed_partial", reason: answer.reason, detail: answer.detail,
77
- pages, returned, unique: seen.size, expected, next_cursor: cursor,
104
+ pages, requested: expected, returned, unique: seen.size, expected, retries, next_cursor: cursor,
105
+ resume: cursor ? `cockpit ticket list --cursor ${cursor} --limit ${query.get("limit")} --json (same filters), then continue page by page` : "start again; the first page failed",
78
106
  }));
79
107
  return 1;
80
108
  }
@@ -94,7 +122,7 @@ export async function streamAllIssues(command, door, base) {
94
122
  }
95
123
  const exact = expected === null || expected === returned;
96
124
  writeLine(door.io.stdout, JSON.stringify({
97
- type: "coverage", coverage: "complete", pages, returned, unique: seen.size, expected,
125
+ type: "coverage", coverage: "complete", pages, requested: expected, returned, unique: seen.size, expected, retries,
98
126
  ...(returned === seen.size && exact ? {} : { note: "returned, unique and expected do not all agree; rows changed during the walk" }),
99
127
  }));
100
128
  return 0;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `cockpit ticket list` page and filter flags (BLI-21315) and `--archived` (BLI-22367), split out of
3
+ * `local-args-tower-work.ts` so that parser stays within its size band. Pure argument checks: no I/O.
4
+ */
5
+ import { optionalNonEmpty } from "./local-arg-values.js";
6
+ /**
7
+ * `list --archived` (BLI-22367): archived tickets only, most recently archived first. The archived page takes
8
+ * --q, --project, --limit, --cursor and --all; any other filter is refused here rather than silently ignored.
9
+ */
10
+ export function readArchivedFlag(action, values) {
11
+ if (!values.booleans.has("--archived"))
12
+ return false;
13
+ if (action !== "list")
14
+ throw new Error("--archived belongs to `cockpit ticket list` (archived tickets only).");
15
+ const unsupported = ["--state", "--assignee", "--label", "--created-after", "--created-before", "--updated-after", "--updated-before", "--sort", "--dir"]
16
+ .filter((flag) => values.flags.has(flag));
17
+ if (unsupported.length > 0)
18
+ throw new Error(`list --archived takes --q, --project, --limit, --cursor and --all; not ${unsupported.join(", ")}.`);
19
+ return true;
20
+ }
21
+ const INSTANT = /^\d{4}-\d\d-\d\d(?:T\d\d:\d\d:\d\d(?:\.\d{1,6})?(?:Z|[+-]\d\d:\d\d))?$/;
22
+ /** BLI-21315: the cursor contract's flags. Filters and sort belong to `list`; `--cursor` also to `history`. */
23
+ export function readIssuePage(action, values) {
24
+ const named = ["--q", "--created-after", "--created-before", "--updated-after", "--updated-before", "--sort", "--dir"];
25
+ for (const flag of named) {
26
+ if (values.flags.has(flag) && action !== "list")
27
+ throw new Error(`${flag} belongs to \`cockpit ticket list\`.`);
28
+ }
29
+ if (values.booleans.has("--all") && action !== "list")
30
+ throw new Error("--all belongs to `cockpit ticket list`.");
31
+ if (values.flags.has("--cursor") && action !== "list" && action !== "history") {
32
+ throw new Error("--cursor belongs to `cockpit ticket list` and `cockpit ticket history`.");
33
+ }
34
+ const instants = {};
35
+ for (const flag of ["--created-after", "--created-before", "--updated-after", "--updated-before"]) {
36
+ const value = optionalNonEmpty(values.flags.get(flag));
37
+ if (value !== undefined && (!INSTANT.test(value) || !Number.isFinite(Date.parse(value)))) {
38
+ throw new Error(`${flag} must be a UTC instant like 2026-10-01T00:00:00Z or a date like 2026-10-01.`);
39
+ }
40
+ instants[flag] = value;
41
+ }
42
+ const sort = optionalNonEmpty(values.flags.get("--sort"));
43
+ if (sort !== undefined && sort !== "updated_at" && sort !== "created_at")
44
+ throw new Error("--sort must be updated_at or created_at.");
45
+ const dir = optionalNonEmpty(values.flags.get("--dir"));
46
+ if (dir !== undefined && dir !== "asc" && dir !== "desc")
47
+ throw new Error("--dir must be asc or desc.");
48
+ const all = values.booleans.has("--all");
49
+ const cursor = optionalNonEmpty(values.flags.get("--cursor"));
50
+ if (all && cursor)
51
+ throw new Error("--all walks from the start; drop --cursor, or pass --cursor without --all to read one page.");
52
+ const q = optionalNonEmpty(values.flags.get("--q"));
53
+ return {
54
+ ...(cursor ? { cursor } : {}),
55
+ ...(q ? { q } : {}),
56
+ ...(instants["--created-after"] ? { createdAfter: instants["--created-after"] } : {}),
57
+ ...(instants["--created-before"] ? { createdBefore: instants["--created-before"] } : {}),
58
+ ...(instants["--updated-after"] ? { updatedAfter: instants["--updated-after"] } : {}),
59
+ ...(instants["--updated-before"] ? { updatedBefore: instants["--updated-before"] } : {}),
60
+ ...(sort ? { sort } : {}),
61
+ ...(dir ? { dir } : {}),
62
+ ...(all ? { all } : {}),
63
+ };
64
+ }
@@ -9,7 +9,7 @@ import { randomUUID } from "node:crypto";
9
9
  */
10
10
  import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
11
11
  import { writeLine } from "./cli-io.js";
12
- import { askTicketWrite, refuseWrite } from "./issue-write.js";
12
+ import { askTicketWrite, printSaved, refuseWrite, reportCommit } from "./issue-write.js";
13
13
  import { READ_DEADLINE_MS, TAG, WRITE_DEADLINE_MS } from "./issue-contracts.js";
14
14
  export const RELATION_VALUE_FLAGS = ["--blocks", "--blocked-by", "--related", "--duplicate-of", "--relation"];
15
15
  const KIND_FLAGS = [
@@ -120,10 +120,11 @@ export async function unrelateIssue(command, door) {
120
120
  }
121
121
  function finishWrite(door, body, requestId, sentence, tag) {
122
122
  writeLine(door.io.stderr, `${TAG} ${tag} ${JSON.stringify({ relation_id: body.relation_id ?? null })}`);
123
- writeLine(door.io.stderr, `${TAG} local commit receipt ${JSON.stringify(body.mutation ?? { outcome: "unknown_check_again", request_id: requestId })}`);
123
+ reportCommit(door, body.mutation, requestId);
124
124
  if (door.json)
125
125
  return emitAgentDoor(door, { ok: true, issue: body.issue, relation_id: body.relation_id, mutation: body.mutation });
126
126
  writeLine(door.io.stdout, sentence);
127
+ printSaved(door, body.mutation);
127
128
  return 0;
128
129
  }
129
130
  /** `unrelate <id> --blocks B`: the edge id, found through the same read `relations` prints. */
@@ -15,7 +15,7 @@ import { mirroredTicketIdInTitle } from "@bli-cockpit/telemetry-core";
15
15
  import { askAgentDoor, emitAgentDoor, failAgentDoor, } from "./agent-door.js";
16
16
  import { writeLine } from "./cli-io.js";
17
17
  import { assigneeLabel, resolveAssigneeDirectory, withAssigneeIdentity } from "./issue-directory.js";
18
- import { READ_DEADLINE_MS, TAG, sizeForEstimate, withSizeLabel, } from "./issue-contracts.js";
18
+ import { READ_DEADLINE_MS, TAG, canonicalTicketUrl, sizeForEstimate, withSizeLabel, } from "./issue-contracts.js";
19
19
  async function fetchOneIssue(door, ref) {
20
20
  const answer = await askAgentDoor(door, {
21
21
  path: `/api/work/issues/${encodeURIComponent(ref)}`,
@@ -25,7 +25,8 @@ async function fetchOneIssue(door, ref) {
25
25
  });
26
26
  if (!answer.ok)
27
27
  return { ref, ok: false, reason: answer.reason, detail: answer.detail };
28
- const issue = answer.body.issue;
28
+ const detailBody = answer.body;
29
+ const issue = detailBody.issue;
29
30
  if (!issue) {
30
31
  return {
31
32
  ref,
@@ -54,6 +55,8 @@ async function fetchOneIssue(door, ref) {
54
55
  ok: true,
55
56
  issue,
56
57
  comments,
58
+ canonicalUrl: canonicalTicketUrl(door.dashboardUrl, issue.identifier, detailBody.canonical_url),
59
+ ...(detailBody.resolved_from ? { resolvedFrom: detailBody.resolved_from } : {}),
57
60
  ...(linearTicketId ? { linearTicketId } : {}),
58
61
  ...(commentsAnswer.ok ? {} : { commentsUnreadReason: commentsAnswer.reason }),
59
62
  };
@@ -64,6 +67,12 @@ function renderOneIssue(door, result, directory) {
64
67
  const size = sizeForEstimate(issue.estimate ?? null);
65
68
  writeLine(door.io.stdout, `${issue.state} · priority ${issue.priority ?? 0}${size ? ` · size ${size}` : ""} · assignee ${assigneeLabel(issue.assignee_id, directory)} · updated ${issue.updated_at}`);
66
69
  writeLine(door.io.stdout, `id ${issue.id}${issue.parent_id ? ` · parent ${issue.parent_id}` : ""}`);
70
+ const archivedAt = issue.archived_at;
71
+ if (archivedAt)
72
+ writeLine(door.io.stdout, `ARCHIVED ${archivedAt} — hidden from lists and the board; \`cockpit ticket unarchive ${issue.identifier}\` brings it back.`);
73
+ writeLine(door.io.stdout, `Link: ${result.canonicalUrl}`);
74
+ if (result.resolvedFrom)
75
+ writeLine(door.io.stdout, `(found through the old Linear id ${result.resolvedFrom.ref})`);
67
76
  if (linearTicketId)
68
77
  writeLine(door.io.stdout, `Linear: ${linearTicketId}`);
69
78
  writeLine(door.io.stdout, "");
@@ -100,6 +109,8 @@ function readIssueJson(result, directory) {
100
109
  return {
101
110
  issue: withSizeLabel(withAssigneeIdentity(result.issue, directory)),
102
111
  comments: result.comments,
112
+ canonical_url: result.canonicalUrl,
113
+ ...(result.resolvedFrom ? { resolved_from: result.resolvedFrom } : {}),
103
114
  ...(result.linearTicketId ? { linear_ticket_id: result.linearTicketId } : {}),
104
115
  ...(result.commentsUnreadReason ? { comments_unread_reason: result.commentsUnreadReason } : {}),
105
116
  };
@@ -12,6 +12,7 @@ import { randomUUID } from "node:crypto";
12
12
  */
13
13
  import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
14
14
  import { writeLine } from "./cli-io.js";
15
+ import { printSaved, refuseWrite, reportCommit } from "./issue-write.js";
15
16
  import { READ_DEADLINE_MS, TAG, WRITE_DEADLINE_MS } from "./issue-contracts.js";
16
17
  export async function listIssueVersions(command, door) {
17
18
  const ref = command.issueRef ?? "";
@@ -75,19 +76,15 @@ export async function restoreIssueVersion(command, door) {
75
76
  writeLine(door.io.stderr, `${TAG} check local commit with cockpit ticket mutation-status ${requestId}; retry unchanged input with --request-id ${requestId}.`);
76
77
  return failAgentDoor(door, TAG, "mutation_unknown", "Result unknown. Check this request ID before retrying with identical input.");
77
78
  }
78
- if (!answer.ok) {
79
- writeLine(door.io.stderr, `${TAG} check local commit with cockpit ticket mutation-status ${requestId}; retry unchanged input with --request-id ${requestId}.`);
80
- const mutation = answer.body?.mutation ?? { request_id: requestId, outcome: "unknown_check_again" };
81
- if (door.json)
82
- return emitAgentDoor(door, { ok: false, error: answer.reason, detail: answer.detail, mutation }, 1);
83
- return failAgentDoor(door, TAG, answer.reason, answer.detail);
84
- }
79
+ if (!answer.ok)
80
+ return refuseWrite(door, answer, requestId);
85
81
  const body = answer.body;
86
82
  const newVersion = body.mutation?.description_version;
87
83
  writeLine(door.io.stderr, `${TAG} restored ${JSON.stringify({ restored_version: chosen.version, new_version: newVersion ?? null })}`);
88
- writeLine(door.io.stderr, `${TAG} local commit receipt ${JSON.stringify(body.mutation ?? { outcome: "unknown_check_again", request_id: requestId })}`);
84
+ reportCommit(door, body.mutation, requestId);
89
85
  if (door.json)
90
86
  return emitAgentDoor(door, { ok: true, issue: body.issue, mutation: body.mutation });
91
87
  writeLine(door.io.stdout, `Restored version ${chosen.version} of ${ref} as new version ${newVersion ?? "?"}.`);
88
+ printSaved(door, body.mutation);
92
89
  return 0;
93
90
  }
@@ -10,7 +10,7 @@ import { randomUUID } from "node:crypto";
10
10
  */
11
11
  import { askAgentDoor, emitAgentDoor, failAgentDoor, } from "./agent-door.js";
12
12
  import { writeLine } from "./cli-io.js";
13
- import { TAG, WRITE_DEADLINE_MS, maybeWithSizeLabel, readBody, resolveRefs, } from "./issue-contracts.js";
13
+ import { TAG, WRITE_DEADLINE_MS, maybeWithSizeLabel, readBody, resolveRefs, receiptAfterRefusal, savedLine, } from "./issue-contracts.js";
14
14
  export async function createIssue(command, door) {
15
15
  if (!command.title)
16
16
  return failAgentDoor(door, TAG, "invalid_body", "issue create needs --title.");
@@ -44,10 +44,11 @@ export async function createIssue(command, door) {
44
44
  return refuseWrite(door, answer, requestId);
45
45
  const issue = maybeWithSizeLabel(answer.body.issue);
46
46
  writeLine(door.io.stderr, `${TAG} created ${JSON.stringify({ issue_id: issue?.id ?? null, identifier: issue?.identifier ?? null, description_bytes: Buffer.byteLength(body.text, "utf8") })}`);
47
- writeLine(door.io.stderr, `${TAG} local commit receipt ${JSON.stringify(answer.body.mutation ?? { outcome: "unknown_check_again", request_id: requestId })}`);
47
+ reportCommit(door, answer.body.mutation, requestId);
48
48
  if (door.json)
49
49
  return emitAgentDoor(door, { ok: true, issue, mutation: answer.body.mutation });
50
50
  writeLine(door.io.stdout, `Created ${issue?.identifier ?? ""} — ${issue?.title ?? ""} (${issue?.id ?? ""}).`);
51
+ printSaved(door, answer.body.mutation);
51
52
  return 0;
52
53
  }
53
54
  export async function updateIssue(command, door) {
@@ -63,7 +64,7 @@ export async function updateIssue(command, door) {
63
64
  description = body.text;
64
65
  }
65
66
  if (!changesAnything(command, description, refs)) {
66
- return failAgentDoor(door, TAG, "invalid_body", "issue update needs at least one of --title, --priority, --size, --assignee, --due, --project, --parent, --cycle, or a description on --body-stdin/--file.");
67
+ return failAgentDoor(door, TAG, "invalid_body", "issue update needs at least one of --title, --priority, --size, --assignee, --due, --project, --parent, --cycle (none clears size, assignee, due, project, parent and cycle), or a description on --body-stdin/--file.");
67
68
  }
68
69
  const requestId = command.requestId ?? randomUUID();
69
70
  writeLine(door.io.stderr, `${TAG} request ${JSON.stringify({ request_id: requestId, expected_revision: command.expectedRevision })}`);
@@ -79,8 +80,8 @@ export async function updateIssue(command, door) {
79
80
  ...(command.estimate === undefined ? {} : { estimate: command.estimate }),
80
81
  ...(command.assignee ? { assignee_id: command.assignee === "none" ? null : command.assignee } : {}),
81
82
  ...(command.due !== undefined ? { due_date: command.due } : {}),
82
- ...(refs.projectId ? { project_id: refs.projectId } : {}),
83
- ...(refs.parentId ? { parent_id: refs.parentId } : {}),
83
+ ...(refs.projectId !== undefined ? { project_id: refs.projectId } : {}),
84
+ ...(refs.parentId !== undefined ? { parent_id: refs.parentId } : {}),
84
85
  ...(command.cycle === undefined ? {} : { cycle: command.cycle }),
85
86
  },
86
87
  });
@@ -88,10 +89,11 @@ export async function updateIssue(command, door) {
88
89
  return refuseWrite(door, answer, requestId);
89
90
  const issue = maybeWithSizeLabel(answer.body.issue);
90
91
  writeLine(door.io.stderr, `${TAG} updated ${JSON.stringify({ issue_id: issue?.id ?? null, identifier: issue?.identifier ?? null })}`);
91
- writeLine(door.io.stderr, `${TAG} local commit receipt ${JSON.stringify(answer.body.mutation ?? { outcome: "unknown_check_again", request_id: requestId })}`);
92
+ reportCommit(door, answer.body.mutation, requestId);
92
93
  if (door.json)
93
94
  return emitAgentDoor(door, { ok: true, issue, mutation: answer.body.mutation });
94
95
  writeLine(door.io.stdout, `Updated ${issue?.identifier ?? ref} — ${issue?.title ?? ""}.`);
96
+ printSaved(door, answer.body.mutation);
95
97
  return 0;
96
98
  }
97
99
  export async function moveIssue(command, door) {
@@ -109,10 +111,11 @@ export async function moveIssue(command, door) {
109
111
  return refuseWrite(door, answer, requestId);
110
112
  const issue = answer.body.issue;
111
113
  writeLine(door.io.stderr, `${TAG} moved ${JSON.stringify({ issue_id: issue?.id ?? null, identifier: issue?.identifier ?? null, to_state: command.moveState })}`);
112
- writeLine(door.io.stderr, `${TAG} local commit receipt ${JSON.stringify(answer.body.mutation ?? { outcome: "unknown_check_again", request_id: requestId })}`);
114
+ reportCommit(door, answer.body.mutation, requestId);
113
115
  if (door.json)
114
116
  return emitAgentDoor(door, { ok: true, issue, mutation: answer.body.mutation });
115
117
  writeLine(door.io.stdout, `${issue?.identifier ?? ref} is now ${issue?.state ?? command.moveState}.`);
118
+ printSaved(door, answer.body.mutation);
116
119
  return 0;
117
120
  }
118
121
  export async function commentIssue(command, door) {
@@ -136,10 +139,11 @@ export async function commentIssue(command, door) {
136
139
  return refuseWrite(door, answer, requestId);
137
140
  const comment = answer.body.comment;
138
141
  writeLine(door.io.stderr, `${TAG} commented ${JSON.stringify({ comment_id: comment?.id ?? null, byte_size: Buffer.byteLength(body.text, "utf8") })}`);
139
- writeLine(door.io.stderr, `${TAG} local commit receipt ${JSON.stringify(answer.body.mutation ?? { outcome: "unknown_check_again", request_id: requestId })}`);
142
+ reportCommit(door, answer.body.mutation, requestId);
140
143
  if (door.json)
141
144
  return emitAgentDoor(door, { ok: true, comment, mutation: answer.body.mutation });
142
145
  writeLine(door.io.stdout, `Commented on ${ref} (${comment?.id ?? ""}).`);
146
+ printSaved(door, answer.body.mutation);
143
147
  return 0;
144
148
  }
145
149
  /** `ticket label <id> --add <name> --remove <name>`: one receipt, one history row (BLI-21422). */
@@ -162,10 +166,41 @@ export async function labelIssue(command, door) {
162
166
  const body = answer.body;
163
167
  const names = (body.labels ?? []).map((l) => l.name);
164
168
  writeLine(door.io.stderr, `${TAG} labelled ${JSON.stringify({ issue_id: body.issue?.id ?? null, added: command.addLabels?.length ?? 0, removed: command.removeLabels?.length ?? 0, now: names.length })}`);
165
- writeLine(door.io.stderr, `${TAG} local commit receipt ${JSON.stringify(answer.body.mutation ?? { outcome: "unknown_check_again", request_id: requestId })}`);
169
+ reportCommit(door, answer.body.mutation, requestId);
166
170
  if (door.json)
167
171
  return emitAgentDoor(door, { ok: true, issue: maybeWithSizeLabel(body.issue), labels: body.labels ?? [], mutation: answer.body.mutation });
168
172
  writeLine(door.io.stdout, `${body.issue?.identifier ?? ref} labels: ${names.length ? names.join(", ") : "none"}.`);
173
+ printSaved(door, answer.body.mutation);
174
+ return 0;
175
+ }
176
+ /**
177
+ * `ticket archive <id>` / `ticket unarchive <id>` (BLI-22367): hide a ticket from lists, the board and counts,
178
+ * or bring it back. Nothing is deleted; the ticket stays readable with `show` and every field, comment and
179
+ * history row survives. Tower-only: the receipt says the Linear copy is not mirrored (archive_is_tower_only).
180
+ */
181
+ export async function archiveIssue(command, door, archived) {
182
+ const ref = command.issueRef ?? "";
183
+ const verb = archived ? "archive" : "unarchive";
184
+ const requestId = command.requestId ?? randomUUID();
185
+ writeLine(door.io.stderr, `${TAG} request ${JSON.stringify({ request_id: requestId, expected_revision: command.expectedRevision, action: verb })}`);
186
+ const answer = await askTicketWrite(door, {
187
+ path: `/api/work/issues/${encodeURIComponent(ref)}/${verb}`,
188
+ method: "POST",
189
+ label: `issue ${verb}`,
190
+ timeoutMs: WRITE_DEADLINE_MS,
191
+ body: { request_id: requestId, ...(command.expectedRevision === undefined ? {} : { expected_revision: command.expectedRevision }) },
192
+ });
193
+ if (!answer.ok)
194
+ return refuseWrite(door, answer, requestId);
195
+ const issue = answer.body.issue;
196
+ writeLine(door.io.stderr, `${TAG} ${archived ? "archived" : "unarchived"} ${JSON.stringify({ issue_id: issue?.id ?? null, identifier: issue?.identifier ?? null })}`);
197
+ reportCommit(door, answer.body.mutation, requestId);
198
+ if (door.json)
199
+ return emitAgentDoor(door, { ok: true, issue: maybeWithSizeLabel(issue), mutation: answer.body.mutation });
200
+ writeLine(door.io.stdout, archived
201
+ ? `Archived ${issue?.identifier ?? ref}. It is hidden from lists and the board; \`cockpit ticket unarchive ${issue?.identifier ?? ref}\` brings it back.`
202
+ : `Unarchived ${issue?.identifier ?? ref}. It is back in lists and on the board.`);
203
+ printSaved(door, answer.body.mutation);
169
204
  return 0;
170
205
  }
171
206
  /** A truncated or missing response body cannot be called a successful ticket write. */
@@ -197,16 +232,36 @@ function changesAnything(command, description, refs) {
197
232
  || refs.parentId !== undefined
198
233
  || command.cycle !== undefined;
199
234
  }
200
- /** Says where the local commit stands, then fails with the door's own reason. */
235
+ /** The local commit receipt on stderr, plus the "Saved in Tower ... Linear copy: ..." sentence when --json owns stdout. */
236
+ export function reportCommit(door, mutation, requestId) {
237
+ writeLine(door.io.stderr, `${TAG} local commit receipt ${JSON.stringify(mutation ?? { outcome: "unknown_check_again", request_id: requestId })}`);
238
+ const line = savedLine(mutation);
239
+ if (door.json && line)
240
+ writeLine(door.io.stderr, `${TAG} ${line}`);
241
+ }
242
+ /** The same sentence under a text answer (contract C1): the Tower save first, the Linear copy second. */
243
+ export function printSaved(door, mutation) {
244
+ const line = savedLine(mutation);
245
+ if (line)
246
+ writeLine(door.io.stdout, line);
247
+ }
248
+ /**
249
+ * Says where the local commit stands, then fails with the door's own reason. A known refusal says nothing changed
250
+ * (`not_committed`); only a genuinely unknown answer asks for a status check and an identical retry (BLI-22367).
251
+ */
201
252
  export function refuseWrite(door, answer, requestId) {
202
- writeLine(door.io.stderr, `${TAG} check local commit with cockpit ticket mutation-status ${requestId}; retry unchanged input with --request-id ${requestId}.`);
203
- const mutation = answer.body?.mutation ?? { request_id: requestId, outcome: "unknown_check_again" };
253
+ const mutation = receiptAfterRefusal(answer, requestId);
254
+ if (mutation.outcome === "not_committed") {
255
+ writeLine(door.io.stderr, `${TAG} not committed: nothing changed ${JSON.stringify({ reason: mutation.reason ?? answer.reason, request_id: requestId })}. Fix the input and send it again with a new request id.`);
256
+ }
257
+ else {
258
+ writeLine(door.io.stderr, `${TAG} result unknown: check local commit with cockpit ticket mutation-status ${requestId}; retry unchanged input with --request-id ${requestId}.`);
259
+ }
204
260
  if (door.json) {
205
- writeLine(door.io.stderr, `${TAG} refused ${JSON.stringify({ reason: answer.reason, request_id: requestId })}`);
261
+ writeLine(door.io.stderr, `${TAG} refused ${JSON.stringify({ reason: answer.reason, request_id: requestId, outcome: mutation.outcome })}`);
206
262
  return emitAgentDoor(door, { ok: false, error: answer.reason, detail: answer.detail, mutation }, 1);
207
263
  }
208
- const receipt = mutation;
209
- if (receipt.current)
210
- writeLine(door.io.stderr, `${TAG} conflict ${JSON.stringify({ request_id: requestId, outcome: receipt.outcome, current_state: receipt.current.state, current_revision: receipt.current.revision })}`);
264
+ if (mutation.current)
265
+ writeLine(door.io.stderr, `${TAG} conflict ${JSON.stringify({ request_id: requestId, outcome: mutation.outcome, current_state: mutation.current.state, current_revision: mutation.current.revision })}`);
211
266
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
212
267
  }
@@ -32,8 +32,8 @@ import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js"
32
32
  import { readTicketBoard } from "./tickets-board.js";
33
33
  import { showIssue } from "./issue-show.js";
34
34
  import { addIssuePageQuery, pageFieldsOf, pageFooter, streamAllIssues } from "./issue-list-page.js";
35
- import { READ_DEADLINE_MS, TAG, resolveProjectId, sizeForEstimate, withSizeLabel, } from "./issue-contracts.js";
36
- import { commentIssue, createIssue, labelIssue, moveIssue, updateIssue } from "./issue-write.js";
35
+ import { READ_DEADLINE_MS, TAG, resolveProjectId, sizeForEstimate, withSizeLabel, savedLine, } from "./issue-contracts.js";
36
+ import { archiveIssue, commentIssue, createIssue, labelIssue, moveIssue, updateIssue } from "./issue-write.js";
37
37
  import { batchIssues } from "./issue-batch.js";
38
38
  import { runInbox } from "./inbox.js";
39
39
  import { runWorkNotify } from "./work-notify.js";
@@ -52,6 +52,8 @@ export async function runIssue(command, io) {
52
52
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
53
53
  if (door.json)
54
54
  return emitAgentDoor(door, { ok: true, ...answer.body });
55
+ const receipt = answer.body.mutation;
56
+ writeLine(door.io.stdout, statusSentence(receipt, command.issueRef ?? ""));
55
57
  writeLine(door.io.stdout, JSON.stringify(answer.body));
56
58
  return 0;
57
59
  }
@@ -71,6 +73,10 @@ export async function runIssue(command, io) {
71
73
  return commentIssue(command, door);
72
74
  case "label":
73
75
  return labelIssue(command, door);
76
+ case "archive":
77
+ return archiveIssue(command, door, true);
78
+ case "unarchive":
79
+ return archiveIssue(command, door, false);
74
80
  case "relations":
75
81
  return showRelations(command, door);
76
82
  case "relate":
@@ -101,11 +107,21 @@ export async function runIssue(command, io) {
101
107
  return runWorkNotify(command, io, door);
102
108
  }
103
109
  }
110
+ /** `mutation-status` in words: committed (with the Linear copy), refused (nothing changed), or no receipt yet. */
111
+ export function statusSentence(receipt, requestId) {
112
+ if (receipt?.outcome === "committed")
113
+ return savedLine(receipt) ?? "Saved in Tower.";
114
+ if (receipt?.outcome === "not_committed")
115
+ return `Not committed: nothing changed (${receipt.reason ?? "refused"}).`;
116
+ return `No receipt for ${requestId} yet: the write may still be in flight, or it never reached Tower (a refusal before the write leaves no receipt). Retry the identical input with --request-id ${requestId}.`;
117
+ }
104
118
  function issueLine(issue, directory) {
105
119
  const assignee = issue.assignee_id ? ` [${assigneeLabel(issue.assignee_id, directory)}]` : "";
106
120
  const size = sizeForEstimate(issue.estimate ?? null);
107
121
  const sizeTag = size ? ` (${size})` : "";
108
- return `${issue.identifier.padEnd(9)} ${issue.state.padEnd(11)} ${issue.title}${sizeTag}${assignee}`;
122
+ const archived = issue.archived_at;
123
+ const archivedTag = archived ? ` (archived ${archived.slice(0, 10)})` : "";
124
+ return `${issue.identifier.padEnd(9)} ${issue.state.padEnd(11)} ${issue.title}${sizeTag}${assignee}${archivedTag}`;
109
125
  }
110
126
  /**
111
127
  * What the list did not show (BLI-10447): a capped window of a larger set,
@@ -202,6 +218,9 @@ async function listIssues(command, door) {
202
218
  /** The list query for the filters given, or the exit code of the project lookup that failed. */
203
219
  async function buildIssueListQuery(command, door) {
204
220
  const query = new URLSearchParams();
221
+ // BLI-22367: archived tickets only; every other list hides them.
222
+ if (command.archived)
223
+ query.set("archived", "only");
205
224
  if (command.stateFilter)
206
225
  query.set("state", command.stateFilter);
207
226
  if (command.assignee)