@bli-cockpit/cli 0.2.243 → 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.
Files changed (39) hide show
  1. package/dist/adapters/raw-evidence-budget-admission.js +63 -0
  2. package/dist/adapters/raw-evidence-collection-state.js +6 -61
  3. package/dist/commands/issue-contracts.js +65 -4
  4. package/dist/commands/issue-list-page.js +34 -6
  5. package/dist/commands/issue-page-args.js +64 -0
  6. package/dist/commands/issue-relations.js +3 -2
  7. package/dist/commands/issue-show.js +13 -2
  8. package/dist/commands/issue-versions.js +5 -8
  9. package/dist/commands/issue-write.js +71 -16
  10. package/dist/commands/issue.js +22 -3
  11. package/dist/commands/local-args-collector-sources.js +2 -1
  12. package/dist/commands/local-args-tower-milestone.js +120 -0
  13. package/dist/commands/local-args-tower-work.js +44 -51
  14. package/dist/commands/local-args-tower.js +1 -0
  15. package/dist/commands/local-args.js +2 -0
  16. package/dist/commands/local-command-shapes-milestone.js +1 -0
  17. package/dist/commands/local-help-commands-tower-ops.js +4 -4
  18. package/dist/commands/local-help-commands-tower.js +6 -3
  19. package/dist/commands/local-help-commands.js +3 -1
  20. package/dist/commands/local-help-milestone.js +23 -0
  21. package/dist/commands/local-help-project.js +1 -0
  22. package/dist/commands/local-help.js +3 -1
  23. package/dist/commands/local.js +2 -0
  24. package/dist/commands/manager-hints.js +4 -2
  25. package/dist/commands/manager-limits.json +3 -1
  26. package/dist/commands/manager-read-reasons.js +1 -0
  27. package/dist/commands/manager-render-home.js +13 -5
  28. package/dist/commands/manager-render.js +3 -0
  29. package/dist/commands/manager.js +3 -1
  30. package/dist/commands/milestone.js +157 -0
  31. package/dist/commands/project-args.js +78 -2
  32. package/dist/commands/project.js +52 -0
  33. package/dist/commands/public-root.js +1 -1
  34. package/dist/commands/session-sync-outside-roots.js +72 -14
  35. package/dist/commands/sources-check.js +63 -2
  36. package/dist/commands/sources-discovery.js +1 -1
  37. package/dist/commands/sources-reconciliation.js +87 -0
  38. package/dist/evidence-transcripts-client.js +1 -0
  39. package/package.json +4 -4
@@ -0,0 +1,63 @@
1
+ import { terminalDeliveryReason } from "../raw-evidence-staging.js";
2
+ import { admitToStaging } from "../staging-gate.js";
3
+ /**
4
+ * The one door a file passes before its bytes may be copied: the per-sync
5
+ * budget below, then the disk (BLI-12832). A refusal from the disk hands the
6
+ * budget back, because nothing was staged and the next tick re-reads the source.
7
+ * A copy already staged costs the disk nothing and is never refused by it.
8
+ */
9
+ export function admitEvidence(collection, byteLength, file) {
10
+ // Ticket 21952: a copy staged on an earlier tick is queued, not new bytes; re-charging it let a backlog use up the
11
+ // byte budget every tick. It still costs one object, and its upload resumes from the chunks the server holds.
12
+ const alreadyStaged = collection.staging.staged[file.contentHash] !== undefined;
13
+ const charged = alreadyStaged ? 0 : byteLength;
14
+ const budgetReason = admitToBudget(collection.budget, charged);
15
+ if (budgetReason)
16
+ return budgetReason;
17
+ const allowance = collection.budget.staging;
18
+ if (!allowance)
19
+ return null;
20
+ // BLI-12832: bytes the server refused for good are reclaimed by the prune
21
+ // (the refusal stays as a receipt), so copying them again would be churn.
22
+ if (!alreadyStaged &&
23
+ terminalDeliveryReason(collection.staging.delivery_attempts[file.contentHash]?.last_reason)) {
24
+ refundBudget(collection.budget, charged);
25
+ console.error("[staging-gate] not copying bytes the server refused for good", JSON.stringify({ reason: "delivery_terminal_refused", kind: file.kind, file_bytes: byteLength }));
26
+ return "delivery_terminal_refused";
27
+ }
28
+ const diskReason = admitToStaging(allowance, byteLength, {
29
+ alreadyStaged,
30
+ kind: file.kind,
31
+ });
32
+ if (!diskReason)
33
+ return null;
34
+ refundBudget(collection.budget, charged);
35
+ return `staging_deferred:${diskReason}`;
36
+ }
37
+ /** Hands back one admission that a later gate refused, so nothing was staged and nothing counts as sent. */
38
+ function refundBudget(budget, byteLength) {
39
+ budget.remainingObjects += 1;
40
+ budget.remainingBytes += byteLength;
41
+ budget.admittedBytes = Math.max(0, (budget.admittedBytes ?? 0) - byteLength);
42
+ budget.admittedObjects = Math.max(0, (budget.admittedObjects ?? 0) - 1);
43
+ }
44
+ /**
45
+ * Decrements the per-sync budget when a file fits, or returns a deferred-skip
46
+ * reason when it does not. The object budget bounds request count; the byte
47
+ * budget bounds buffered bytes.
48
+ */
49
+ export function admitToBudget(budget, byteLength) {
50
+ const single = budget.singleFileByteAllowance;
51
+ const firstLargeFile = single && budget.remainingBytes === single.normalBytes &&
52
+ byteLength <= single.maxFileBytes;
53
+ const reason = budget.remainingObjects <= 0 ? "deferred_object_budget" :
54
+ byteLength > 0 && byteLength > budget.remainingBytes && !firstLargeFile ? "deferred_byte_budget" : null;
55
+ if (reason) {
56
+ [budget.deferredObjects, budget.deferredBytes] = [(budget.deferredObjects ?? 0) + 1, (budget.deferredBytes ?? 0) + byteLength];
57
+ return reason;
58
+ }
59
+ budget.remainingObjects -= 1;
60
+ budget.remainingBytes -= byteLength;
61
+ [budget.admittedBytes, budget.admittedObjects] = [(budget.admittedBytes ?? 0) + byteLength, (budget.admittedObjects ?? 0) + 1];
62
+ return null;
63
+ }
@@ -7,13 +7,14 @@
7
7
  * contract, so `raw-evidence-completeness.ts` can read and write them without
8
8
  * knowing about packs, budgets or disks.
9
9
  *
10
- * Two mechanics live here rather than in any one reader, because the Codex
10
+ * Two mechanics are exposed here rather than in any one reader, because the Codex
11
11
  * reader, the Claude reader, the image reader and the git-diff reader all spend
12
12
  * them:
13
13
  *
14
14
  * - **`admitToBudget`** — the per-sync byte and object budget (D7b),
15
15
  * decremented in place so one `cockpit sync` across many worktrees honours
16
- * one cap.
16
+ * one cap. Admission and disk-refusal refunds live together in
17
+ * `raw-evidence-budget-admission.ts`; the exports here keep reader APIs stable.
17
18
  * - **`stageEvidenceBytes`** — bytes go on disk exactly once; content an
18
19
  * earlier sync already staged is adopted rather than copied again.
19
20
  *
@@ -26,8 +27,8 @@ import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core"
26
27
  import crypto from "node:crypto";
27
28
  import fs from "node:fs/promises";
28
29
  import path from "node:path";
29
- import { DELIVERY_BACKOFF_BYPASS_REASON, deliveryBackoffApplies, heldSourceKeys, readRawEvidenceStagingState, resolveStagedObject, terminalDeliveryReason, } from "../raw-evidence-staging.js";
30
- import { admitToStaging, openStagingAllowance, } from "../staging-gate.js";
30
+ import { DELIVERY_BACKOFF_BYPASS_REASON, deliveryBackoffApplies, heldSourceKeys, readRawEvidenceStagingState, resolveStagedObject, } from "../raw-evidence-staging.js";
31
+ import { openStagingAllowance, } from "../staging-gate.js";
31
32
  import { chmodPrivate } from "./raw-evidence-pack-store.js";
32
33
  import { GIT_DIFF_TIMEOUT_MS, MAX_GIT_DIFF_BYTES, } from "./raw-evidence-git-diff.js";
33
34
  export const DEFAULT_SINCE_MINUTES = 24 * 60;
@@ -201,60 +202,4 @@ export async function stageEvidenceBytes(collection, options) {
201
202
  collection.stagedNewCount += 1;
202
203
  return { local_path: destination, staged_in_pack: true };
203
204
  }
204
- /**
205
- * The one door a file passes before its bytes may be copied: the per-sync
206
- * budget below, then the disk (BLI-12832). A refusal from the disk hands the
207
- * budget back, because nothing was staged and the next tick re-reads the source.
208
- * A copy already staged costs the disk nothing and is never refused by it.
209
- */
210
- export function admitEvidence(collection, byteLength, file) {
211
- // Ticket 21952: a copy staged on an earlier tick is queued, not new bytes; re-charging it let a backlog use up the
212
- // byte budget every tick. It still costs one object, and its upload resumes from the chunks the server holds.
213
- const alreadyStaged = collection.staging.staged[file.contentHash] !== undefined;
214
- const charged = alreadyStaged ? 0 : byteLength;
215
- const budgetReason = admitToBudget(collection.budget, charged);
216
- if (budgetReason)
217
- return budgetReason;
218
- const allowance = collection.budget.staging;
219
- if (!allowance)
220
- return null;
221
- // BLI-12832: bytes the server refused for good are reclaimed by the prune
222
- // (the refusal stays as a receipt), so copying them again would be churn.
223
- if (!alreadyStaged &&
224
- terminalDeliveryReason(collection.staging.delivery_attempts[file.contentHash]?.last_reason)) {
225
- refundBudget(collection.budget, charged);
226
- console.error("[staging-gate] not copying bytes the server refused for good", JSON.stringify({ reason: "delivery_terminal_refused", kind: file.kind, file_bytes: byteLength }));
227
- return "delivery_terminal_refused";
228
- }
229
- const diskReason = admitToStaging(allowance, byteLength, {
230
- alreadyStaged,
231
- kind: file.kind,
232
- });
233
- if (!diskReason)
234
- return null;
235
- refundBudget(collection.budget, charged);
236
- return `staging_deferred:${diskReason}`;
237
- }
238
- /** Hands back one admission that a later gate refused, so nothing was staged and nothing counts as sent. */
239
- function refundBudget(budget, byteLength) {
240
- budget.remainingObjects += 1;
241
- budget.remainingBytes += byteLength;
242
- budget.admittedBytes = Math.max(0, (budget.admittedBytes ?? 0) - byteLength);
243
- budget.admittedObjects = Math.max(0, (budget.admittedObjects ?? 0) - 1);
244
- }
245
- /**
246
- * Decrements the per-sync budget when a file fits, or returns a deferred-skip
247
- * reason when it does not. The object budget bounds request count; the byte
248
- * budget bounds buffered bytes.
249
- */
250
- export function admitToBudget(budget, byteLength) {
251
- const reason = budget.remainingObjects <= 0 ? "deferred_object_budget" : byteLength > budget.remainingBytes ? "deferred_byte_budget" : null;
252
- if (reason) {
253
- [budget.deferredObjects, budget.deferredBytes] = [(budget.deferredObjects ?? 0) + 1, (budget.deferredBytes ?? 0) + byteLength];
254
- return reason;
255
- }
256
- budget.remainingObjects -= 1;
257
- budget.remainingBytes -= byteLength;
258
- [budget.admittedBytes, budget.admittedObjects] = [(budget.admittedBytes ?? 0) + byteLength, (budget.admittedObjects ?? 0) + 1];
259
- return null;
260
- }
205
+ export { admitEvidence, admitToBudget } from "./raw-evidence-budget-admission.js";
@@ -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
  }