@bli-cockpit/cli 0.2.241 → 0.2.243

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 (127) hide show
  1. package/dist/adapters/git-state.js +1 -0
  2. package/dist/adapters/raw-evidence-collection-state.js +22 -11
  3. package/dist/adapters/raw-evidence-git-diff.js +1 -0
  4. package/dist/agent-rules.js +8 -0
  5. package/dist/autostart-contract.js +12 -0
  6. package/dist/autostart-darwin-plist.js +2 -2
  7. package/dist/autostart-darwin.js +1 -1
  8. package/dist/autostart-remind.js +245 -0
  9. package/dist/autostart-self-heal.js +17 -1
  10. package/dist/autostart-windows-scripts.js +7 -7
  11. package/dist/autostart.js +16 -15
  12. package/dist/commands/approvals-check-where.js +14 -0
  13. package/dist/commands/approvals-render.js +8 -1
  14. package/dist/commands/approvals.js +3 -2
  15. package/dist/commands/autostart-command.js +10 -0
  16. package/dist/commands/autostart-heal.js +36 -1
  17. package/dist/commands/brain.js +1 -1
  18. package/dist/commands/cal-agenda.js +44 -0
  19. package/dist/commands/cal-availability-share.js +97 -0
  20. package/dist/commands/cal-booking.js +147 -0
  21. package/dist/commands/cal-join.js +62 -0
  22. package/dist/commands/cal-links.js +68 -0
  23. package/dist/commands/cal-people.js +136 -0
  24. package/dist/commands/cal-remind.js +176 -0
  25. package/dist/commands/cal-reminders.js +33 -0
  26. package/dist/commands/cal-suggest.js +89 -0
  27. package/dist/commands/cal.js +41 -1
  28. package/dist/commands/cycle-args.js +127 -0
  29. package/dist/commands/cycles.js +117 -0
  30. package/dist/commands/docs-comments-args.js +85 -0
  31. package/dist/commands/docs-comments.js +128 -0
  32. package/dist/commands/docs-files-args.js +49 -0
  33. package/dist/commands/docs-files.js +179 -0
  34. package/dist/commands/docs-formatting.js +45 -0
  35. package/dist/commands/docs-inbox-args.js +93 -0
  36. package/dist/commands/docs-inbox.js +126 -0
  37. package/dist/commands/docs-notion.js +5 -0
  38. package/dist/commands/docs-read.js +2 -1
  39. package/dist/commands/docs-recovery.js +173 -0
  40. package/dist/commands/docs-refs-args.js +104 -0
  41. package/dist/commands/docs-refs.js +153 -0
  42. package/dist/commands/docs-search-args.js +79 -0
  43. package/dist/commands/docs-search.js +64 -0
  44. package/dist/commands/docs-sync-args.js +82 -0
  45. package/dist/commands/docs-sync.js +197 -0
  46. package/dist/commands/docs-tree.js +136 -0
  47. package/dist/commands/docs.js +85 -51
  48. package/dist/commands/doctor-update.js +4 -1
  49. package/dist/commands/inbox-args.js +81 -0
  50. package/dist/commands/inbox.js +88 -0
  51. package/dist/commands/issue-batch.js +61 -0
  52. package/dist/commands/issue-relations.js +151 -0
  53. package/dist/commands/issue-write.js +38 -5
  54. package/dist/commands/issue.js +32 -1
  55. package/dist/commands/local-args-tower-approvals.js +16 -4
  56. package/dist/commands/local-args-tower-cal-booking.js +81 -0
  57. package/dist/commands/local-args-tower-cal-links.js +42 -0
  58. package/dist/commands/local-args-tower-cal-remind.js +74 -0
  59. package/dist/commands/local-args-tower-cal-share.js +41 -0
  60. package/dist/commands/local-args-tower-cal.js +81 -8
  61. package/dist/commands/local-args-tower-docs-msg.js +98 -9
  62. package/dist/commands/local-args-tower-msg.js +279 -10
  63. package/dist/commands/local-args-tower-search.js +32 -5
  64. package/dist/commands/local-args-tower-work.js +110 -34
  65. package/dist/commands/local-args-tower.js +1 -0
  66. package/dist/commands/local-args.js +2 -0
  67. package/dist/commands/local-auth.js +1 -1
  68. package/dist/commands/local-help-commands-tower-ops.js +2 -0
  69. package/dist/commands/local-help-commands-tower.js +48 -32
  70. package/dist/commands/local-help-commands.js +1 -0
  71. package/dist/commands/local-help-cycles.js +9 -0
  72. package/dist/commands/local-help-docs-files.js +10 -0
  73. package/dist/commands/local-help-docs-formatting.js +7 -0
  74. package/dist/commands/local-help-docs-inbox.js +10 -0
  75. package/dist/commands/local-help-docs-recovery.js +10 -0
  76. package/dist/commands/local-help-docs-refs.js +11 -0
  77. package/dist/commands/local-help-docs-sync.js +9 -0
  78. package/dist/commands/local-help-inbox.js +9 -0
  79. package/dist/commands/local-help-project.js +21 -0
  80. package/dist/commands/local-help-templates.js +9 -0
  81. package/dist/commands/local-help-views.js +10 -0
  82. package/dist/commands/local-help-work-notify.js +6 -0
  83. package/dist/commands/local-help.js +4 -2
  84. package/dist/commands/local.js +2 -0
  85. package/dist/commands/mcp-stdio-probe.js +1 -0
  86. package/dist/commands/msg-activity.js +134 -0
  87. package/dist/commands/msg-channel-args.js +92 -0
  88. package/dist/commands/msg-channel.js +125 -0
  89. package/dist/commands/msg-context-args.js +67 -0
  90. package/dist/commands/msg-context.js +65 -0
  91. package/dist/commands/msg-dm.js +28 -0
  92. package/dist/commands/msg-draft.js +193 -0
  93. package/dist/commands/msg-file.js +185 -0
  94. package/dist/commands/msg-mark-args.js +78 -0
  95. package/dist/commands/msg-mark.js +148 -0
  96. package/dist/commands/msg-notify-args.js +100 -0
  97. package/dist/commands/msg-notify.js +122 -0
  98. package/dist/commands/msg-schedule-args.js +114 -0
  99. package/dist/commands/msg-schedule.js +226 -0
  100. package/dist/commands/msg-search-args.js +71 -0
  101. package/dist/commands/msg-search.js +48 -0
  102. package/dist/commands/msg-write.js +15 -1
  103. package/dist/commands/msg.js +77 -39
  104. package/dist/commands/project-args.js +82 -0
  105. package/dist/commands/project.js +71 -7
  106. package/dist/commands/public-root.js +1 -1
  107. package/dist/commands/push.js +50 -0
  108. package/dist/commands/search.js +23 -0
  109. package/dist/commands/session-sync-failures.js +17 -4
  110. package/dist/commands/session-sync-health.js +7 -2
  111. package/dist/commands/session-sync-hold.js +42 -1
  112. package/dist/commands/session-sync-upload.js +2 -0
  113. package/dist/commands/session-sync.js +1 -0
  114. package/dist/commands/sync-doctor.js +14 -1
  115. package/dist/commands/sync-report.js +1 -1
  116. package/dist/commands/template-args.js +122 -0
  117. package/dist/commands/templates.js +162 -0
  118. package/dist/commands/view-args.js +131 -0
  119. package/dist/commands/views.js +138 -0
  120. package/dist/commands/work-notify-args.js +67 -0
  121. package/dist/commands/work-notify.js +63 -0
  122. package/dist/delivery-hold-notice.js +34 -0
  123. package/dist/log-rotation.js +2 -2
  124. package/dist/notifier.js +83 -0
  125. package/dist/process-runner.js +4 -0
  126. package/dist/repo-identity-git.js +1 -0
  127. package/package.json +4 -4
@@ -31,8 +31,17 @@ import { randomUUID } from "node:crypto";
31
31
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
32
32
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
33
33
  import { readDoc } from "./docs-read.js";
34
+ import { searchDocs } from "./docs-search.js";
35
+ import { favoriteDocs, listAllDocs, marksDocs, treeDocs } from "./docs-tree.js";
34
36
  import { exportDoc } from "./docs-export.js";
35
37
  import { linkDoc, syncStatusDocs } from "./docs-notion.js";
38
+ import { restoreDocs, trashDocs, versionsDocs } from "./docs-recovery.js";
39
+ import { commentDocs, readDocComments } from "./docs-comments.js";
40
+ import { runDocsFiles } from "./docs-files.js";
41
+ import { runDocsRefs } from "./docs-refs.js";
42
+ import { runDocsSync } from "./docs-sync.js";
43
+ import { runDocsFormatting } from "./docs-formatting.js";
44
+ import { runDocsInbox } from "./docs-inbox.js";
36
45
  import { accessDocs, shareDocs } from "./docs-share.js";
37
46
  import { readNoteFile } from "./notes-file.js";
38
47
  import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
@@ -45,12 +54,19 @@ export async function runDocs(command, io) {
45
54
  switch (command.action) {
46
55
  case "list":
47
56
  return listDocs(command, door);
57
+ case "search":
58
+ return searchDocs(command, door);
48
59
  case "tree":
49
60
  return treeDocs(door);
61
+ case "favorite":
62
+ case "unfavorite":
63
+ return favoriteDocs(command, door);
64
+ case "marks":
65
+ return marksDocs(door);
50
66
  case "read":
51
67
  return readDoc(command, door);
52
68
  case "create":
53
- return createDoc(command, door);
69
+ return command.docsRefs?.templateId ? runDocsRefs(command, door) : createDoc(command, door);
54
70
  case "update":
55
71
  return updateDoc(command, door);
56
72
  case "delete":
@@ -67,6 +83,35 @@ export async function runDocs(command, io) {
67
83
  return shareDocs(command, door);
68
84
  case "access":
69
85
  return accessDocs(command, door);
86
+ case "comments":
87
+ return readDocComments(command, door);
88
+ case "comment":
89
+ return commentDocs(command, door);
90
+ case "inbox":
91
+ case "inbox-read":
92
+ case "subscribe":
93
+ case "unsubscribe":
94
+ case "notifications":
95
+ return runDocsInbox(command, door);
96
+ case "files":
97
+ case "file":
98
+ return runDocsFiles(command, door);
99
+ case "formatting":
100
+ return runDocsFormatting(command, door);
101
+ case "templates":
102
+ case "refs":
103
+ case "review":
104
+ case "maintenance":
105
+ return runDocsRefs(command, door);
106
+ case "sync-resolve":
107
+ case "manifest":
108
+ return runDocsSync(command, door);
109
+ case "trash":
110
+ return trashDocs(command, door);
111
+ case "restore":
112
+ return restoreDocs(command, door);
113
+ case "versions":
114
+ return versionsDocs(command, door);
70
115
  }
71
116
  }
72
117
  /** `--parent`, `--query`, `--word`, `--limit`, `--bodies` (BLI-10450) as the door's own query parameters (BLI-3737). */
@@ -82,10 +127,14 @@ export function docsListPath(command) {
82
127
  params.set("limit", String(command.limit));
83
128
  if (command.bodies)
84
129
  params.set("bodies", "true");
130
+ if (command.cursor)
131
+ params.set("cursor", command.cursor);
85
132
  const search = params.toString();
86
133
  return search.length > 0 ? `/api/docs/documents?${search}` : "/api/docs/documents";
87
134
  }
88
135
  async function listDocs(command, door) {
136
+ if (command.all)
137
+ return listAllDocs(command, door);
89
138
  const listPath = docsListPath(command);
90
139
  const answer = await readThroughCache({
91
140
  io: door.io,
@@ -125,32 +174,9 @@ async function listDocs(command, door) {
125
174
  }
126
175
  writeLine(door.io.stdout, "");
127
176
  writeLine(door.io.stdout, `${documents.length} document(s). Bodies come from \`cockpit docs read <id|slug>\`.`);
128
- return 0;
129
- }
130
- function renderTree(nodes, io, depth) {
131
- for (const node of nodes) {
132
- writeLine(io.stdout, `${" ".repeat(depth)}${node.title} (${node.slug ?? node.id})`);
133
- if (node.children.length > 0)
134
- renderTree(node.children, io, depth + 1);
135
- }
136
- }
137
- async function treeDocs(door) {
138
- const answer = await askAgentDoor(door, {
139
- path: "/api/docs/tree",
140
- method: "GET",
141
- label: "docs tree",
142
- timeoutMs: READ_DEADLINE_MS,
143
- });
144
- if (!answer.ok)
145
- return failAgentDoor(door, TAG, answer.reason, answer.detail);
146
- const tree = (answer.body.tree ?? []);
147
- if (door.json)
148
- return emitAgentDoor(door, { ok: true, tree });
149
- if (tree.length === 0) {
150
- writeLine(door.io.stdout, "No documents.");
151
- return 0;
152
- }
153
- renderTree(tree, door.io, 0);
177
+ const nextCursor = answer.body.next_cursor;
178
+ if (nextCursor)
179
+ writeLine(door.io.stdout, `More: cockpit docs list --cursor ${nextCursor} (or --all to walk every page)`);
154
180
  return 0;
155
181
  }
156
182
  /** Finds a document id by exact id or exact slug against the flat list. Never fuzzy. */
@@ -161,30 +187,39 @@ export async function resolveDocId(door, ref) {
161
187
  const id = resolved.ids.get(ref);
162
188
  return id ? { status: "ok", id } : { status: "not_found" };
163
189
  }
190
+ const RESOLVE_CONCURRENCY = 8;
164
191
  /**
165
- * BLI-10450: `docs read <ref1> <ref2> ...` resolves every ref against ONE
166
- * `docs list` call, not one per ref — `resolveDocId` above is now this
167
- * function's single-ref case, so a bulk read pays one list call total.
192
+ * Resolves every ref with ONE exact lookup each (BLI-21657): `GET /api/docs/documents/<ref>?fields=revision`
193
+ * answers the real id for an id or a slug (slug match is exact, case-insensitive, never fuzzy). Never a scan
194
+ * of the capped list, so the oldest page resolves like the newest. At most 8 lookups in flight. A ref the
195
+ * door answers 404 for is simply absent from `ids`; any other failure is `list_failed` with the door's reason.
168
196
  */
169
197
  export async function resolveDocIds(door, refs) {
170
- const answer = await askAgentDoor(door, {
171
- path: "/api/docs/documents",
172
- method: "GET",
173
- label: "docs resolve",
174
- timeoutMs: READ_DEADLINE_MS,
175
- });
176
- if (!answer.ok)
177
- return { status: "list_failed", reason: answer.reason, detail: answer.detail };
178
- const documents = (answer.body.documents ?? []);
179
- const byId = new Map(documents.map((doc) => [doc.id, doc.id]));
180
- const bySlug = new Map(documents.filter((doc) => doc.slug).map((doc) => [doc.slug, doc.id]));
198
+ const unique = [...new Set(refs)];
181
199
  const ids = new Map();
182
- for (const ref of refs) {
183
- const id = byId.get(ref) ?? bySlug.get(ref);
184
- if (id)
185
- ids.set(ref, id);
186
- }
187
- return { status: "ok", ids };
200
+ let failure = null;
201
+ let next = 0;
202
+ const worker = async () => {
203
+ while (failure === null && next < unique.length) {
204
+ const ref = unique[next++];
205
+ const answer = await askAgentDoor(door, {
206
+ path: `/api/docs/documents/${encodeURIComponent(ref)}?fields=revision`,
207
+ method: "GET",
208
+ label: "docs resolve",
209
+ timeoutMs: READ_DEADLINE_MS,
210
+ });
211
+ if (answer.ok) {
212
+ const id = answer.body.document?.id;
213
+ if (id)
214
+ ids.set(ref, id);
215
+ }
216
+ else if (answer.reason !== "document_not_found_or_unreadable") {
217
+ failure = { reason: answer.reason, detail: answer.detail };
218
+ }
219
+ }
220
+ };
221
+ await Promise.all(Array.from({ length: Math.min(RESOLVE_CONCURRENCY, unique.length) }, worker));
222
+ return failure ? { status: "list_failed", ...failure } : { status: "ok", ids };
188
223
  }
189
224
  /** Body on stdin, never argv (BLI-3706) — `--file` is the safest way on Windows, same as `cockpit notes paste`. */
190
225
  async function readBody(command, io) {
@@ -296,8 +331,7 @@ async function mutationStatusDocs(command, door) {
296
331
  /**
297
332
  * BLI‑10555: soft-deletes one or more documents — `DELETE
298
333
  * /api/docs/documents/[id]`, the same door the browser's own delete calls.
299
- * Recoverable (only a super_admin clearing `deleted_at` brings it back, no
300
- * self-service undo) and never cascades: a deleted document's children keep
334
+ * Recoverable (`cockpit docs trash` lists it, `docs restore <id>` brings it back) and never cascades: a deleted document's children keep
301
335
  * their own `parent_id` and stay exactly where they were.
302
336
  *
303
337
  * Mirrors `readDoc`'s multi-ref shape (BLI-10450): one shared `docs list`
@@ -319,7 +353,7 @@ async function deleteDocs(command, door) {
319
353
  }
320
354
  if (door.json)
321
355
  return emitAgentDoor(door, { ok: true, document_id: result.id });
322
- writeLine(door.io.stdout, `Deleted ${result.id}. Recoverable by a super_admin; its children stay in place.`);
356
+ writeLine(door.io.stdout, `Deleted ${result.id}. Restore it with: cockpit docs restore ${result.id} (its children stay in place).`);
323
357
  return 0;
324
358
  }
325
359
  if (door.json) {
@@ -340,7 +374,7 @@ async function deleteDocs(command, door) {
340
374
  }
341
375
  });
342
376
  writeLine(door.io.stdout, "");
343
- writeLine(door.io.stdout, `${results.length - failed.length}/${results.length} document(s) deleted. Recoverable by a super_admin; children stay in place.`);
377
+ writeLine(door.io.stdout, `${results.length - failed.length}/${results.length} document(s) deleted. Restore with: cockpit docs restore <id> (children stay in place).`);
344
378
  return failed.length === 0 ? 0 : 1;
345
379
  }
346
380
  async function deleteOneDocument(door, ref, id) {
@@ -51,8 +51,11 @@ export async function latestCliVersionFromNpm(context) {
51
51
  ? `@bli-cockpit/cli@${context.command.updateTag}`
52
52
  : "@bli-cockpit/cli";
53
53
  const result = await exec("npm", ["view", packageSpec, "version", "--json"]);
54
- if (result.code !== 0)
54
+ if (result.code !== 0) {
55
+ // Ticket 21952: this branch was silent, so "npm could not even start" read the same as "npm is unreachable".
56
+ console.error("[doctor] npm latest unknown", JSON.stringify({ reason: "npm_view_failed", exit_code: result.code, stderr_bytes: result.stderr.length }));
55
57
  return null;
58
+ }
56
59
  return parseNpmVersion(result.stdout);
57
60
  }
58
61
  function parseNpmVersion(stdout) {
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `cockpit ticket inbox|read|unread|subscribe|unsubscribe` argument parsing (BLI-21703, P9). These are
3
+ * `issue` actions; their flags are read here so `local-args-tower-work.ts` only lists the names.
4
+ */
5
+ import { optionalNonEmpty, optionalPositiveInteger, optionalUrl } from "./local-arg-values.js";
6
+ export const INBOX_ACTIONS = ["inbox", "read", "unread", "subscribe", "unsubscribe"];
7
+ export const INBOX_VALUE_FLAGS = ["--kind", "--before"];
8
+ export const INBOX_BOOLEAN_FLAGS = ["--unread"];
9
+ export const INBOX_KINDS = ["assigned", "mentioned", "commented"];
10
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
+ export function isInboxAction(action) {
12
+ return INBOX_ACTIONS.includes(action);
13
+ }
14
+ export function parseInboxArgs(values, action, rest) {
15
+ const given = (flag) => values.flags.has(flag) || values.booleans.has(flag);
16
+ if (action !== "inbox") {
17
+ for (const flag of [...INBOX_VALUE_FLAGS, ...INBOX_BOOLEAN_FLAGS, "--limit"]) {
18
+ if (given(flag))
19
+ throw new Error(`${flag} belongs to \`cockpit ticket inbox\`.`);
20
+ }
21
+ }
22
+ if (action !== "read" && action !== "inbox" && given("--all"))
23
+ throw new Error("--all belongs to `cockpit ticket read`.");
24
+ if (given("--request-id") || given("--expected-revision"))
25
+ throw new Error(`ticket ${action} takes no --request-id or --expected-revision.`);
26
+ const inbox = {};
27
+ if (action === "inbox") {
28
+ if (rest.length > 0)
29
+ throw new Error(`ticket inbox does not take "${rest[0]}".`);
30
+ if (given("--all"))
31
+ throw new Error("--all belongs to `cockpit ticket read`. Page the inbox with --before and --limit.");
32
+ if (values.booleans.has("--unread"))
33
+ inbox.unreadOnly = true;
34
+ const kind = optionalNonEmpty(values.flags.get("--kind"));
35
+ if (kind) {
36
+ if (!INBOX_KINDS.includes(kind))
37
+ throw new Error(`--kind must be ${INBOX_KINDS.join(", ")}.`);
38
+ inbox.kind = kind;
39
+ }
40
+ const before = optionalNonEmpty(values.flags.get("--before"));
41
+ if (before)
42
+ inbox.before = before;
43
+ const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
44
+ if (limit !== undefined) {
45
+ if (limit > 100)
46
+ throw new Error("--limit is at most 100.");
47
+ inbox.limit = limit;
48
+ }
49
+ }
50
+ else if (action === "read" || action === "unread") {
51
+ if (action === "read" && given("--all")) {
52
+ if (rest.length > 0)
53
+ throw new Error("ticket read takes notification ids or --all, not both.");
54
+ inbox.all = true;
55
+ }
56
+ else {
57
+ if (rest.length === 0)
58
+ throw new Error(`ticket ${action} needs one or more notification ids${action === "read" ? " or --all" : ""}.`);
59
+ if (rest.length > 200)
60
+ throw new Error("Mark at most 200 items at a time.");
61
+ for (const id of rest) {
62
+ if (!UUID.test(id))
63
+ throw new Error(`"${id.slice(0, 40)}" is not a notification id. Copy the id from \`cockpit ticket inbox --json\`.`);
64
+ }
65
+ inbox.ids = rest;
66
+ }
67
+ }
68
+ else {
69
+ if (rest.length !== 1)
70
+ throw new Error(`ticket ${action} takes exactly one ticket reference.`);
71
+ inbox.ref = rest[0];
72
+ }
73
+ return {
74
+ kind: "issue",
75
+ action,
76
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
77
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
78
+ json: values.booleans.has("--json"),
79
+ inbox,
80
+ };
81
+ }
@@ -0,0 +1,88 @@
1
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
2
+ import { writeLine } from "./cli-io.js";
3
+ import { READ_DEADLINE_MS, WRITE_DEADLINE_MS } from "./issue-contracts.js";
4
+ const TAG = "[ticket inbox cli]";
5
+ const unreadText = (counts) => counts ? `${counts.unread}${counts.capped ? "+" : ""} unread` : "unread count unavailable";
6
+ export async function runInbox(command, io, door) {
7
+ void io;
8
+ const inbox = command.inbox;
9
+ switch (command.action) {
10
+ case "inbox":
11
+ return readInbox(command, door);
12
+ case "read":
13
+ case "unread": {
14
+ const read = command.action === "read";
15
+ const answer = await askAgentDoor(door, {
16
+ path: "/api/work/inbox/read",
17
+ method: "POST",
18
+ label: `ticket ${command.action}`,
19
+ timeoutMs: WRITE_DEADLINE_MS,
20
+ body: inbox.all ? { all: true, read: true } : { ids: inbox.ids, read },
21
+ });
22
+ if (!answer.ok)
23
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
24
+ const body = answer.body;
25
+ console.error(`${TAG} ${command.action} done`, JSON.stringify({ requested: body.requested, updated: body.updated }));
26
+ if (door.json)
27
+ return emitAgentDoor(door, { ok: true, ...body });
28
+ writeLine(door.io.stdout, `Marked ${body.updated ?? 0} item(s) ${read ? "read" : "unread"}. ${unreadText(body.counts)}.`);
29
+ return 0;
30
+ }
31
+ default: {
32
+ const follow = command.action === "subscribe";
33
+ const answer = await askAgentDoor(door, {
34
+ path: `/api/work/issues/${encodeURIComponent(inbox.ref ?? "")}/follow`,
35
+ method: follow ? "PUT" : "DELETE",
36
+ label: `ticket ${command.action}`,
37
+ timeoutMs: WRITE_DEADLINE_MS,
38
+ });
39
+ if (!answer.ok)
40
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
41
+ const body = answer.body;
42
+ console.error(`${TAG} ${command.action} done`, JSON.stringify({ following: body.follow?.following }));
43
+ if (door.json)
44
+ return emitAgentDoor(door, { ok: true, ...body });
45
+ writeLine(door.io.stdout, follow ? `Following ${inbox.ref}.` : `Unfollowed ${inbox.ref}. Direct assignments and @mentions still arrive.`);
46
+ return 0;
47
+ }
48
+ }
49
+ }
50
+ async function readInbox(command, door) {
51
+ const inbox = command.inbox;
52
+ const query = new URLSearchParams();
53
+ if (inbox.unreadOnly)
54
+ query.set("unread", "true");
55
+ if (inbox.kind)
56
+ query.set("kind", inbox.kind);
57
+ if (inbox.before)
58
+ query.set("before", inbox.before);
59
+ if (inbox.limit !== undefined)
60
+ query.set("limit", String(inbox.limit));
61
+ const suffix = query.toString();
62
+ const answer = await askAgentDoor(door, {
63
+ path: `/api/work/inbox${suffix ? `?${suffix}` : ""}`,
64
+ method: "GET",
65
+ label: "ticket inbox",
66
+ timeoutMs: READ_DEADLINE_MS,
67
+ });
68
+ if (!answer.ok)
69
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
70
+ const body = answer.body;
71
+ if (door.json)
72
+ return emitAgentDoor(door, { ok: true, ...body });
73
+ const out = door.io.stdout;
74
+ const items = body.items ?? [];
75
+ if (items.length === 0) {
76
+ writeLine(out, `${inbox.unreadOnly ? "No unread items" : "Your inbox is empty"}. ${unreadText(body.counts)}.`);
77
+ }
78
+ for (const item of items) {
79
+ writeLine(out, `${item.read_at ? " " : "*"} ${item.id} ${item.kind.padEnd(9)} ${item.identifier.padEnd(9)} ${item.title} (${item.created_at})`);
80
+ }
81
+ if (items.length > 0)
82
+ writeLine(out, `${unreadText(body.counts)}.`);
83
+ if (body.coverage?.dropped)
84
+ writeLine(out, `${body.coverage.dropped} item(s) are not shown: you cannot read their ticket.`);
85
+ if (body.has_more && body.next_cursor)
86
+ writeLine(out, `More items available. Continue with --before ${body.next_cursor}`);
87
+ return 0;
88
+ }
@@ -0,0 +1,61 @@
1
+ import { randomUUID } from "node:crypto";
2
+ /**
3
+ * `cockpit ticket batch` (P5, BLI-21551): change up to 50 tickets in one call.
4
+ *
5
+ * Items come from stdin or `--file` (`{ "items": [...] }` or a bare array),
6
+ * never argv. The door applies each item as its own mutation and answers per
7
+ * item; this verb prints one stderr line per item and exits 0 only when every
8
+ * item committed. The batch request id is printed so a retry of the same body
9
+ * returns the first receipts instead of writing again.
10
+ */
11
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
12
+ import { writeLine } from "./cli-io.js";
13
+ import { TAG, WRITE_DEADLINE_MS, readBody } from "./issue-contracts.js";
14
+ export async function batchIssues(command, door) {
15
+ const body = await readBody(command, door.io);
16
+ if (!body.ok)
17
+ return failAgentDoor(door, TAG, body.reason, body.detail);
18
+ if (body.text.trim() === "") {
19
+ return failAgentDoor(door, TAG, "invalid_body", "issue batch reads { \"items\": [...] } from stdin or --file; nothing was given.");
20
+ }
21
+ let parsed;
22
+ try {
23
+ parsed = JSON.parse(body.text);
24
+ }
25
+ catch {
26
+ return failAgentDoor(door, TAG, "invalid_json_body", "issue batch needs JSON: { \"items\": [{ \"issue\": \"BLI-1\", \"priority\": 2 }] } or a bare array.");
27
+ }
28
+ const items = Array.isArray(parsed) ? parsed : parsed?.items;
29
+ if (!Array.isArray(items))
30
+ return failAgentDoor(door, TAG, "invalid_body", "issue batch needs a list of items.");
31
+ const requestId = command.requestId ?? randomUUID();
32
+ writeLine(door.io.stderr, `${TAG} batch request ${JSON.stringify({ request_id: requestId, items: items.length })}`);
33
+ let answer;
34
+ try {
35
+ answer = await askAgentDoor(door, {
36
+ path: "/api/work/issues/batch", method: "POST", label: "issue batch", timeoutMs: WRITE_DEADLINE_MS,
37
+ body: { request_id: requestId, items },
38
+ });
39
+ }
40
+ catch {
41
+ console.error(`${TAG} batch response lost`, JSON.stringify({ reason: "mutation_unknown", request_id: requestId }));
42
+ answer = { ok: false, reason: "mutation_unknown", detail: "Result unknown. Retry the same input with the same --request-id." };
43
+ }
44
+ if (!answer.ok) {
45
+ writeLine(door.io.stderr, `${TAG} retry unchanged input with --request-id ${requestId}; nothing is written twice.`);
46
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
47
+ }
48
+ const result = answer.body;
49
+ for (const item of result.items ?? []) {
50
+ const why = item.reason ? ` ${item.reason}${item.message ? ` - ${item.message}` : ""}` : "";
51
+ writeLine(door.io.stderr, `${TAG} ${item.issue} ${item.outcome}${why}`);
52
+ }
53
+ writeLine(door.io.stderr, `${TAG} ${result.committed} committed, ${result.refused} refused, ${result.unknown} unknown of ${result.requested}. batch request id ${result.request_id}`);
54
+ const allCommitted = result.committed === result.requested;
55
+ if (!allCommitted)
56
+ writeLine(door.io.stderr, `${TAG} to retry the same body use --request-id ${result.request_id}.`);
57
+ if (door.json)
58
+ return emitAgentDoor(door, { ok: allCommitted, ...result }, allCommitted ? 0 : 1);
59
+ writeLine(door.io.stdout, `${result.committed} of ${result.requested} tickets changed.`);
60
+ return allCommitted ? 0 : 1;
61
+ }
@@ -0,0 +1,151 @@
1
+ import { randomUUID } from "node:crypto";
2
+ /**
3
+ * `cockpit ticket relations|relate|unrelate` (BLI-21558, P6): what blocks what, over the same doors the
4
+ * browser uses (`/api/work/issues/<id>/relations`). A child is not a blocker; the parent link is `--parent`.
5
+ *
6
+ * `relate <id> --blocks B | --blocked-by B | --related B | --duplicate-of B` takes exactly one kind flag.
7
+ * `unrelate <id> --relation <relation-id>` or the same kind flag plus the other ticket, found through the read.
8
+ * Parsing lives here too, so `local-args-tower-work.ts` only lists the flag names.
9
+ */
10
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
11
+ import { writeLine } from "./cli-io.js";
12
+ import { askTicketWrite, refuseWrite } from "./issue-write.js";
13
+ import { READ_DEADLINE_MS, TAG, WRITE_DEADLINE_MS } from "./issue-contracts.js";
14
+ export const RELATION_VALUE_FLAGS = ["--blocks", "--blocked-by", "--related", "--duplicate-of", "--relation"];
15
+ const KIND_FLAGS = [
16
+ ["--blocks", "blocks"], ["--blocked-by", "blocked_by"], ["--related", "related"], ["--duplicate-of", "duplicate"],
17
+ ];
18
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
19
+ /** Reads the relation flags for one action; refuses a flag on the wrong verb, none, or several. */
20
+ export function readRelationFlags(action, values) {
21
+ const given = KIND_FLAGS.filter(([flag]) => values.flags.has(flag));
22
+ const relationId = values.flags.get("--relation");
23
+ if (action !== "relate" && action !== "unrelate") {
24
+ if (given.length > 0 || relationId !== undefined)
25
+ throw new Error("--blocks, --blocked-by, --related, --duplicate-of and --relation belong to `cockpit ticket relate` and `unrelate`.");
26
+ return {};
27
+ }
28
+ if (given.length > 1)
29
+ throw new Error("Name exactly one of --blocks, --blocked-by, --related or --duplicate-of.");
30
+ if (relationId !== undefined && (action === "relate" || given.length > 0)) {
31
+ throw new Error("--relation names the edge to remove on `unrelate`; use it instead of a kind flag.");
32
+ }
33
+ if (relationId !== undefined && !UUID.test(relationId))
34
+ throw new Error("--relation must be a relation id (see `cockpit ticket relations <id> --json`).");
35
+ const [flag, kind] = given[0] ?? [];
36
+ if (!flag || !kind) {
37
+ if (action === "relate")
38
+ throw new Error("ticket relate needs one of --blocks <id>, --blocked-by <id>, --related <id> or --duplicate-of <id>.");
39
+ if (relationId === undefined)
40
+ throw new Error("ticket unrelate needs --relation <relation-id>, or a kind flag with the other ticket.");
41
+ return { relationId };
42
+ }
43
+ return { relation: { kind, target: values.flags.get(flag) ?? "" } };
44
+ }
45
+ const GROUPS = [
46
+ ["blocks", "Blocks"], ["blocked_by", "Blocked by"], ["related", "Related"], ["duplicate_of", "Duplicate of"], ["duplicates", "Duplicates"],
47
+ ];
48
+ export async function showRelations(command, door) {
49
+ const ref = command.issueRef ?? "";
50
+ const answer = await askAgentDoor(door, {
51
+ path: `/api/work/issues/${encodeURIComponent(ref)}/relations`, method: "GET", label: "issue relations", timeoutMs: READ_DEADLINE_MS,
52
+ });
53
+ if (!answer.ok)
54
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
55
+ const relations = answer.body.relations;
56
+ if (!relations)
57
+ return failAgentDoor(door, TAG, "read_failed", "The door answered without relations.");
58
+ console.error(`${TAG} relations ${JSON.stringify(relations.coverage)}`);
59
+ if (door.json)
60
+ return emitAgentDoor(door, { ok: true, relations });
61
+ let printed = 0;
62
+ for (const [key, title] of GROUPS) {
63
+ const items = relations[key];
64
+ if (items.length === 0)
65
+ continue;
66
+ writeLine(door.io.stdout, `${title}:`);
67
+ for (const item of items)
68
+ writeLine(door.io.stdout, ` ${item.issue.identifier.padEnd(9)} ${item.issue.state.padEnd(11)} ${item.issue.title} (${item.relation_id})`);
69
+ printed += items.length;
70
+ }
71
+ if (relations.unmapped.length > 0) {
72
+ writeLine(door.io.stdout, "Not in Tower (from Linear):");
73
+ for (const item of relations.unmapped)
74
+ writeLine(door.io.stdout, ` ${item.kind} ${item.label} (${item.relation_id})`);
75
+ printed += relations.unmapped.length;
76
+ }
77
+ if (printed === 0)
78
+ writeLine(door.io.stdout, `${ref} has no relations.`);
79
+ const { hidden, truncated, limit } = relations.coverage;
80
+ if (hidden > 0)
81
+ writeLine(door.io.stdout, `${hidden} relation(s) to tickets you cannot read or that were deleted are not shown.`);
82
+ if (truncated)
83
+ writeLine(door.io.stdout, `Showing the first ${limit} relations; there are more.`);
84
+ return 0;
85
+ }
86
+ export async function relateIssue(command, door) {
87
+ const ref = command.issueRef ?? "";
88
+ const relation = command.relation;
89
+ if (!relation)
90
+ return failAgentDoor(door, TAG, "invalid_body", "ticket relate needs a kind flag and the other ticket.");
91
+ const requestId = command.requestId ?? randomUUID();
92
+ writeLine(door.io.stderr, `${TAG} request ${JSON.stringify({ request_id: requestId, expected_revision: command.expectedRevision })}`);
93
+ const answer = await askTicketWrite(door, {
94
+ path: `/api/work/issues/${encodeURIComponent(ref)}/relations`, method: "POST", label: "issue relate", timeoutMs: WRITE_DEADLINE_MS,
95
+ body: { request_id: requestId, expected_revision: command.expectedRevision, kind: relation.kind, target: relation.target },
96
+ });
97
+ if (!answer.ok)
98
+ return refuseWrite(door, answer, requestId);
99
+ return finishWrite(door, answer.body, requestId, `${ref} ${relation.kind.replace("_", " ")} ${relation.target}: recorded.`, "related");
100
+ }
101
+ export async function unrelateIssue(command, door) {
102
+ const ref = command.issueRef ?? "";
103
+ let relationId = command.relationId;
104
+ if (!relationId && command.relation) {
105
+ const found = await findRelation(command, door);
106
+ if (typeof found === "number")
107
+ return found;
108
+ relationId = found;
109
+ }
110
+ const requestId = command.requestId ?? randomUUID();
111
+ writeLine(door.io.stderr, `${TAG} request ${JSON.stringify({ request_id: requestId, expected_revision: command.expectedRevision })}`);
112
+ const query = command.expectedRevision === undefined ? `?request_id=${requestId}` : `?request_id=${requestId}&expected_revision=${command.expectedRevision}`;
113
+ const answer = await askTicketWrite(door, {
114
+ path: `/api/work/issues/${encodeURIComponent(ref)}/relations/${encodeURIComponent(relationId ?? "")}${query}`,
115
+ method: "DELETE", label: "issue unrelate", timeoutMs: WRITE_DEADLINE_MS, body: { request_id: requestId },
116
+ });
117
+ if (!answer.ok)
118
+ return refuseWrite(door, answer, requestId);
119
+ return finishWrite(door, answer.body, requestId, `${ref}: relation removed.`, "unrelated");
120
+ }
121
+ function finishWrite(door, body, requestId, sentence, tag) {
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 })}`);
124
+ if (door.json)
125
+ return emitAgentDoor(door, { ok: true, issue: body.issue, relation_id: body.relation_id, mutation: body.mutation });
126
+ writeLine(door.io.stdout, sentence);
127
+ return 0;
128
+ }
129
+ /** `unrelate <id> --blocks B`: the edge id, found through the same read `relations` prints. */
130
+ async function findRelation(command, door) {
131
+ const ref = command.issueRef ?? "";
132
+ const wanted = command.relation;
133
+ if (!wanted)
134
+ return failAgentDoor(door, TAG, "invalid_body", "ticket unrelate needs --relation or a kind flag.");
135
+ const answer = await askAgentDoor(door, {
136
+ path: `/api/work/issues/${encodeURIComponent(ref)}/relations`, method: "GET", label: "issue relations", timeoutMs: READ_DEADLINE_MS,
137
+ });
138
+ if (!answer.ok)
139
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
140
+ const relations = answer.body.relations;
141
+ const group = wanted.kind === "blocks" ? relations?.blocks : wanted.kind === "blocked_by" ? relations?.blocked_by
142
+ : wanted.kind === "related" ? relations?.related : relations?.duplicate_of;
143
+ const target = wanted.target.trim().toUpperCase();
144
+ const hit = (group ?? []).find((item) => item.issue.identifier.toUpperCase() === target || item.issue.id.toLowerCase() === target.toLowerCase())
145
+ ?? (relations?.unmapped ?? []).find((item) => item.label.toUpperCase() === target && item.kind === (wanted.kind === "blocked_by" ? "" : wanted.kind));
146
+ if (!hit) {
147
+ console.error(`${TAG} unrelate refused ${JSON.stringify({ reason: "relation_not_found", kind: wanted.kind })}`);
148
+ return failAgentDoor(door, TAG, "relation_not_found", `${ref} has no ${wanted.kind.replace("_", " ")} relation with ${wanted.target}.`);
149
+ }
150
+ return hit.relation_id;
151
+ }